mirror of
https://github.com/bigcapitalhq/bigcapital.git
synced 2026-02-17 21:30:31 +00:00
feat: Re-compute the given items cost job.
feat: Optimize the architecture.
This commit is contained in:
@@ -1,35 +0,0 @@
|
||||
import express from 'express';
|
||||
import helmet from 'helmet';
|
||||
import boom from 'express-boom';
|
||||
import i18n from 'i18n';
|
||||
import rootPath from 'app-root-path';
|
||||
import fileUpload from 'express-fileupload';
|
||||
import '../config';
|
||||
import '@/database/objection';
|
||||
import routes from '@/http';
|
||||
|
||||
global.rootPath = rootPath.path;
|
||||
|
||||
const app = express();
|
||||
|
||||
// i18n.configure({
|
||||
// locales: ['en'],
|
||||
// directory: `${__dirname}/resources/locale`,
|
||||
// });
|
||||
|
||||
// // i18n init parses req for language headers, cookies, etc.
|
||||
// app.use(i18n.init);
|
||||
|
||||
// Express configuration
|
||||
app.set('port', process.env.PORT || 3000);
|
||||
|
||||
app.use(helmet());
|
||||
app.use(boom());
|
||||
app.use(express.json());
|
||||
app.use(fileUpload({
|
||||
createParentPath: true,
|
||||
}));
|
||||
|
||||
routes(app);
|
||||
|
||||
export default app;
|
||||
@@ -4,9 +4,9 @@ import knexfile from '@/../config/systemKnexfile';
|
||||
|
||||
const config = knexfile[process.env.NODE_ENV];
|
||||
|
||||
const knex = Knex({
|
||||
...config,
|
||||
...knexSnakeCaseMappers({ upperCase: true }),
|
||||
});
|
||||
|
||||
export default knex;
|
||||
export default () => {
|
||||
return Knex({
|
||||
...config,
|
||||
...knexSnakeCaseMappers({ upperCase: true }),
|
||||
});
|
||||
};
|
||||
@@ -6,9 +6,9 @@ exports.up = function(knex) {
|
||||
|
||||
table.string('direction');
|
||||
|
||||
table.integer('item_id');
|
||||
table.integer('quantity');
|
||||
table.decimal('rate', 13, 3);
|
||||
table.integer('item_id').unsigned();
|
||||
table.integer('quantity').unsigned();
|
||||
table.decimal('rate', 13, 3).unsigned();
|
||||
|
||||
table.string('transaction_type');
|
||||
table.integer('transaction_id');
|
||||
|
||||
@@ -6,9 +6,11 @@ exports.up = function(knex) {
|
||||
|
||||
table.string('direction');
|
||||
|
||||
table.integer('item_id');
|
||||
table.integer('item_id').unsigned();
|
||||
table.integer('quantity').unsigned();
|
||||
table.decimal('rate', 13, 3);
|
||||
table.integer('remaining');
|
||||
table.string('lot_number');
|
||||
|
||||
table.string('transaction_type');
|
||||
table.integer('transaction_id');
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Model } from 'objection';
|
||||
import knex from '@/database/knex';
|
||||
|
||||
// Bind all Models to a knex instance. If you only have one database in
|
||||
// your server this is all you have to do. For multi database systems, see
|
||||
// the Model.bindKnex() method.
|
||||
Model.knex(knex);
|
||||
export default ({ knex }) => {
|
||||
Model.knex(knex);
|
||||
};
|
||||
|
||||
@@ -7,7 +7,6 @@ exports.seed = (knex) => {
|
||||
return knex('resources').insert([
|
||||
{ id: 1, name: 'accounts' },
|
||||
{ id: 8, name: 'accounts_types' },
|
||||
|
||||
{ id: 2, name: 'items' },
|
||||
{ id: 3, name: 'expenses' },
|
||||
{ id: 4, name: 'manual_journals' },
|
||||
|
||||
16
server/src/decorators/eventDispatcher.ts
Normal file
16
server/src/decorators/eventDispatcher.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Originally taken from 'w3tecch/express-typescript-boilerplate'
|
||||
* Credits to the author
|
||||
*/
|
||||
|
||||
import { EventDispatcher as EventDispatcherClass } from 'event-dispatch';
|
||||
import { Container } from 'typedi';
|
||||
|
||||
export function EventDispatcher() {
|
||||
return (object: any, propertyName: string, index?: number): void => {
|
||||
const eventDispatcher = new EventDispatcherClass();
|
||||
Container.registerHandler({ object, propertyName, index, value: () => eventDispatcher });
|
||||
};
|
||||
}
|
||||
|
||||
export { EventDispatcher as EventDispatcherInterface } from 'event-dispatch';
|
||||
25
server/src/http/controllers/Agendash.ts
Normal file
25
server/src/http/controllers/Agendash.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
|
||||
import { Router } from 'express'
|
||||
import basicAuth from 'express-basic-auth';
|
||||
import agendash from 'agendash'
|
||||
import { Container } from 'typedi'
|
||||
import config from '@/../config/config'
|
||||
|
||||
export default class AgendashController {
|
||||
|
||||
static router() {
|
||||
const router = Router();
|
||||
const agendaInstance = Container.get('agenda')
|
||||
|
||||
router.use('/dash',
|
||||
basicAuth({
|
||||
users: {
|
||||
[config.agendash.user]: config.agendash.password,
|
||||
},
|
||||
challenge: true,
|
||||
}),
|
||||
agendash(agendaInstance)
|
||||
);
|
||||
return router;
|
||||
}
|
||||
}
|
||||
@@ -1,512 +0,0 @@
|
||||
import express from 'express';
|
||||
import { check, query, validationResult } from 'express-validator';
|
||||
import { difference } from 'lodash';
|
||||
import fs from 'fs';
|
||||
import asyncMiddleware from '@/http/middleware/asyncMiddleware';
|
||||
import {
|
||||
mapViewRolesToConditionals,
|
||||
mapFilterRolesToDynamicFilter,
|
||||
} from '@/lib/ViewRolesBuilder';
|
||||
import {
|
||||
DynamicFilter,
|
||||
DynamicFilterSortBy,
|
||||
DynamicFilterViews,
|
||||
DynamicFilterFilterRoles,
|
||||
} from '@/lib/DynamicFilter';
|
||||
import Logger from '@/services/Logger';
|
||||
|
||||
const fsPromises = fs.promises;
|
||||
|
||||
|
||||
export default {
|
||||
/**
|
||||
* Router constructor.
|
||||
*/
|
||||
router() {
|
||||
const router = express.Router();
|
||||
|
||||
router.post('/:id',
|
||||
this.editItem.validation,
|
||||
asyncMiddleware(this.editItem.handler)
|
||||
);
|
||||
router.post('/',
|
||||
this.newItem.validation,
|
||||
asyncMiddleware(this.newItem.handler)
|
||||
);
|
||||
router.delete('/:id',
|
||||
this.deleteItem.validation,
|
||||
asyncMiddleware(this.deleteItem.handler)
|
||||
);
|
||||
router.delete('/',
|
||||
this.bulkDeleteItems.validation,
|
||||
asyncMiddleware(this.bulkDeleteItems.handler)
|
||||
);
|
||||
router.get('/',
|
||||
this.listItems.validation,
|
||||
asyncMiddleware(this.listItems.handler)
|
||||
);
|
||||
return router;
|
||||
},
|
||||
|
||||
/**
|
||||
* Creates a new item.
|
||||
*/
|
||||
newItem: {
|
||||
validation: [
|
||||
check('name').exists(),
|
||||
check('type').exists().trim().escape()
|
||||
.isIn(['service', 'non-inventory', 'inventory']),
|
||||
check('sku').optional({ nullable: true }).trim().escape(),
|
||||
|
||||
check('purchasable').exists().isBoolean().toBoolean(),
|
||||
check('sellable').exists().isBoolean().toBoolean(),
|
||||
|
||||
check('cost_price').exists().isNumeric().toFloat(),
|
||||
check('sell_price').exists().isNumeric().toFloat(),
|
||||
check('cost_account_id').exists().isInt().toInt(),
|
||||
check('sell_account_id').exists().isInt().toInt(),
|
||||
check('inventory_account_id')
|
||||
.if(check('type').equals('inventory'))
|
||||
.exists()
|
||||
.isInt()
|
||||
.toInt(),
|
||||
|
||||
check('sell_description').optional().trim().escape(),
|
||||
check('cost_description').optional().trim().escape(),
|
||||
|
||||
check('category_id').optional({ nullable: true }).isInt().toInt(),
|
||||
|
||||
check('custom_fields').optional().isArray({ min: 1 }),
|
||||
check('custom_fields.*.key').exists().isNumeric().toInt(),
|
||||
check('custom_fields.*.value').exists(),
|
||||
|
||||
check('note').optional(),
|
||||
|
||||
check('media_ids').optional().isArray(),
|
||||
check('media_ids.*').exists().isNumeric().toInt(),
|
||||
],
|
||||
async handler(req, res) {
|
||||
const validationErrors = validationResult(req);
|
||||
|
||||
if (!validationErrors.isEmpty()) {
|
||||
return res.boom.badData(null, {
|
||||
code: 'validation_error', ...validationErrors,
|
||||
});
|
||||
}
|
||||
const { user } = req;
|
||||
const form = {
|
||||
custom_fields: [],
|
||||
media_ids: [],
|
||||
...req.body,
|
||||
};
|
||||
const {
|
||||
Account,
|
||||
Resource,
|
||||
ResourceField,
|
||||
ItemCategory,
|
||||
Item,
|
||||
MediaLink,
|
||||
} = req.models;
|
||||
const errorReasons = [];
|
||||
|
||||
const costAccountPromise = Account.query().findById(form.cost_account_id);
|
||||
const sellAccountPromise = Account.query().findById(form.sell_account_id);
|
||||
const inventoryAccountPromise = (form.type === 'inventory')
|
||||
? Account.query().findById(form.inventory_account_id) : null;
|
||||
|
||||
const itemCategoryPromise = (form.category_id)
|
||||
? ItemCategory.query().findById(form.category_id) : null;
|
||||
|
||||
// Validate the custom fields key and value type.
|
||||
if (form.custom_fields.length > 0) {
|
||||
const customFieldsKeys = form.custom_fields.map((field) => field.key);
|
||||
|
||||
// Get resource id than get all resource fields.
|
||||
const resource = await Resource.where('name', 'items').fetch();
|
||||
const fields = await ResourceField.query((builder) => {
|
||||
builder.where('resource_id', resource.id);
|
||||
builder.whereIn('key', customFieldsKeys);
|
||||
}).fetchAll();
|
||||
|
||||
const storedFieldsKey = fields.map((f) => f.attributes.key);
|
||||
|
||||
// Get all not defined resource fields.
|
||||
const notFoundFields = difference(customFieldsKeys, storedFieldsKey);
|
||||
|
||||
if (notFoundFields.length > 0) {
|
||||
errorReasons.push({ type: 'FIELD_KEY_NOT_FOUND', code: 150, fields: notFoundFields });
|
||||
}
|
||||
}
|
||||
const [
|
||||
costAccount,
|
||||
sellAccount,
|
||||
itemCategory,
|
||||
inventoryAccount,
|
||||
] = await Promise.all([
|
||||
costAccountPromise,
|
||||
sellAccountPromise,
|
||||
itemCategoryPromise,
|
||||
inventoryAccountPromise,
|
||||
]);
|
||||
if (!costAccount) {
|
||||
errorReasons.push({ type: 'COST_ACCOUNT_NOT_FOUND', code: 100 });
|
||||
}
|
||||
if (!sellAccount) {
|
||||
errorReasons.push({ type: 'SELL_ACCOUNT_NOT_FOUND', code: 120 });
|
||||
}
|
||||
if (!itemCategory && form.category_id) {
|
||||
errorReasons.push({ type: 'ITEM_CATEGORY_NOT_FOUND', code: 140 });
|
||||
}
|
||||
if (!inventoryAccount && form.type === 'inventory') {
|
||||
errorReasons.push({ type: 'INVENTORY_ACCOUNT_NOT_FOUND', code: 150 });
|
||||
}
|
||||
if (errorReasons.length > 0) {
|
||||
return res.boom.badRequest(null, { errors: errorReasons });
|
||||
}
|
||||
|
||||
const bulkSaveMediaLinks = [];
|
||||
const item = await Item.query().insertAndFetch({
|
||||
name: form.name,
|
||||
type: form.type,
|
||||
sku: form.sku,
|
||||
cost_price: form.cost_price,
|
||||
sell_price: form.sell_price,
|
||||
sell_account_id: form.sell_account_id,
|
||||
cost_account_id: form.cost_account_id,
|
||||
currency_code: form.currency_code,
|
||||
category_id: form.category_id,
|
||||
user_id: user.id,
|
||||
note: form.note,
|
||||
});
|
||||
|
||||
form.media_ids.forEach((mediaId) => {
|
||||
const oper = MediaLink.query().insert({
|
||||
model_name: 'Item',
|
||||
media_id: mediaId,
|
||||
model_id: item.id,
|
||||
});
|
||||
bulkSaveMediaLinks.push(oper);
|
||||
});
|
||||
|
||||
// Save the media links.
|
||||
await Promise.all([
|
||||
...bulkSaveMediaLinks,
|
||||
]);
|
||||
return res.status(200).send({ id: item.id });
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Edit the given item.
|
||||
*/
|
||||
editItem: {
|
||||
validation: [
|
||||
check('name').exists(),
|
||||
check('type')
|
||||
.exists()
|
||||
.trim()
|
||||
.escape()
|
||||
.isIn(['product', 'service']),
|
||||
check('cost_price').exists().isNumeric(),
|
||||
check('sell_price').exists().isNumeric(),
|
||||
check('cost_account_id').exists().isInt(),
|
||||
check('sell_account_id').exists().isInt(),
|
||||
check('category_id').optional({ nullable: true }).isInt().toInt(),
|
||||
check('note').optional().trim().escape(),
|
||||
check('attachment').optional(),
|
||||
check('sell_description').optional().trim().escape(),
|
||||
check('cost_description').optional().trim().escape(),
|
||||
check('purchasable').exists().isBoolean().toBoolean(),
|
||||
check('sellable').exists().isBoolean().toBoolean(),
|
||||
],
|
||||
async handler(req, res) {
|
||||
const validationErrors = validationResult(req);
|
||||
|
||||
if (!validationErrors.isEmpty()) {
|
||||
return res.boom.badData(null, {
|
||||
code: 'validation_error', ...validationErrors,
|
||||
});
|
||||
}
|
||||
const { Account, Item, ItemCategory, MediaLink } = req.models;
|
||||
const { id } = req.params;
|
||||
|
||||
const form = {
|
||||
custom_fields: [],
|
||||
...req.body,
|
||||
};
|
||||
const item = await Item.query().findById(id).withGraphFetched('media');
|
||||
|
||||
if (!item) {
|
||||
return res.boom.notFound(null, {
|
||||
errors: [{ type: 'ITEM.NOT.FOUND', code: 100 }],
|
||||
});
|
||||
}
|
||||
const errorReasons = [];
|
||||
|
||||
const costAccountPromise = Account.query().findById(form.cost_account_id);
|
||||
const sellAccountPromise = Account.query().findById(form.sell_account_id);
|
||||
const itemCategoryPromise = (form.category_id)
|
||||
? ItemCategory.query().findById(form.category_id) : null;
|
||||
|
||||
const [costAccount, sellAccount, itemCategory] = await Promise.all([
|
||||
costAccountPromise, sellAccountPromise, itemCategoryPromise,
|
||||
]);
|
||||
if (!costAccount) {
|
||||
errorReasons.push({ type: 'COST_ACCOUNT_NOT_FOUND', code: 100 });
|
||||
}
|
||||
if (!sellAccount) {
|
||||
errorReasons.push({ type: 'SELL_ACCOUNT_NOT_FOUND', code: 120 });
|
||||
}
|
||||
if (!itemCategory && form.category_id) {
|
||||
errorReasons.push({ type: 'ITEM_CATEGORY_NOT_FOUND', code: 140 });
|
||||
}
|
||||
|
||||
const attachment = req.files && req.files.attachment ? req.files.attachment : null;
|
||||
const attachmentsMimes = ['image/png', 'image/jpeg'];
|
||||
|
||||
// Validate the attachment.
|
||||
if (attachment && attachmentsMimes.indexOf(attachment.mimetype) === -1) {
|
||||
errorReasons.push({ type: 'ATTACHMENT.MINETYPE.NOT.SUPPORTED', code: 160 });
|
||||
}
|
||||
if (errorReasons.length > 0) {
|
||||
return res.boom.badRequest(null, { errors: errorReasons });
|
||||
}
|
||||
if (attachment) {
|
||||
const publicPath = 'storage/app/public/';
|
||||
const tenantPath = `${publicPath}${req.organizationId}`;
|
||||
|
||||
try {
|
||||
await fsPromises.unlink(`${tenantPath}/${item.attachmentFile}`);
|
||||
} catch (error) {
|
||||
Logger.log('error', 'Delete item attachment file delete failed.', { error });
|
||||
}
|
||||
try {
|
||||
await attachment.mv(`${tenantPath}/${attachment.md5}.png`);
|
||||
} catch (error) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'ATTACHMENT.UPLOAD.FAILED', code: 600 }],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const updatedItem = await Item.query().findById(id).patch({
|
||||
name: form.name,
|
||||
type: form.type,
|
||||
cost_price: form.cost_price,
|
||||
sell_price: form.sell_price,
|
||||
currency_code: form.currency_code,
|
||||
sell_account_id: form.sell_account_id,
|
||||
cost_account_id: form.cost_account_id,
|
||||
category_id: form.category_id,
|
||||
note: form.note,
|
||||
});
|
||||
|
||||
// Save links of new inserted media that associated to the item model.
|
||||
const itemMediaIds = item.media.map((m) => m.id);
|
||||
const newInsertedMedia = difference(form.media_ids, itemMediaIds);
|
||||
const bulkSaveMediaLink = [];
|
||||
|
||||
newInsertedMedia.forEach((mediaId) => {
|
||||
const oper = MediaLink.query().insert({
|
||||
model_name: 'Journal',
|
||||
model_id: manualJournal.id,
|
||||
media_id: mediaId,
|
||||
});
|
||||
bulkSaveMediaLink.push(oper);
|
||||
});
|
||||
await Promise.all([ ...newInsertedMedia ]);
|
||||
|
||||
return res.status(200).send({ id: updatedItem.id });
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete the given item from the storage.
|
||||
*/
|
||||
deleteItem: {
|
||||
validation: [],
|
||||
async handler(req, res) {
|
||||
const { id } = req.params;
|
||||
const { Item } = req.models;
|
||||
const item = await Item.query().findById(id);
|
||||
|
||||
if (!item) {
|
||||
return res.boom.notFound(null, {
|
||||
errors: [{ type: 'ITEM_NOT_FOUND', code: 100 }],
|
||||
});
|
||||
}
|
||||
// Delete the fucking the given item id.
|
||||
await Item.query().findById(item.id).delete();
|
||||
|
||||
if (item.attachmentFile) {
|
||||
const publicPath = 'storage/app/public/';
|
||||
const tenantPath = `${publicPath}${req.organizationId}`;
|
||||
|
||||
try {
|
||||
await fsPromises.unlink(`${tenantPath}/${item.attachmentFile}`);
|
||||
} catch (error) {
|
||||
Logger.log('error', 'Delete item attachment file delete failed.', { error });
|
||||
}
|
||||
}
|
||||
return res.status(200).send();
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Bulk delete the given items ids.
|
||||
*/
|
||||
bulkDeleteItems: {
|
||||
validation: [
|
||||
query('ids').isArray({ min: 2 }),
|
||||
query('ids.*').isNumeric().toInt(),
|
||||
],
|
||||
async handler(req, res) {
|
||||
const validationErrors = validationResult(req);
|
||||
|
||||
if (!validationErrors.isEmpty()) {
|
||||
return res.boom.badData(null, {
|
||||
code: 'validation_error', ...validationErrors,
|
||||
});
|
||||
}
|
||||
const filter = { ids: [], ...req.query };
|
||||
const { Item } = req.models;
|
||||
|
||||
const items = await Item.query().whereIn('id', filter.ids);
|
||||
|
||||
const storedItemsIds = items.map((a) => a.id);
|
||||
const notFoundItems = difference(filter.ids, storedItemsIds);
|
||||
|
||||
// Validate the not found items.
|
||||
if (notFoundItems.length > 0) {
|
||||
return res.status(404).send({
|
||||
errors: [{ type: 'ITEMS.NOT.FOUND', code: 200, ids: notFoundItems }],
|
||||
});
|
||||
}
|
||||
|
||||
// Delete the given items ids.
|
||||
await Item.query().whereIn('id', storedItemsIds).delete();
|
||||
|
||||
return res.status(200).send();
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Retrive the list items with pagination meta.
|
||||
*/
|
||||
listItems: {
|
||||
validation: [
|
||||
query('column_sort_order').optional().isIn(['created_at', 'name', 'amount', 'sku']),
|
||||
query('sort_order').optional().isIn(['desc', 'asc']),
|
||||
query('page').optional().isNumeric().toInt(),
|
||||
query('page_size').optional().isNumeric().toInt(),
|
||||
query('custom_view_id').optional().isNumeric().toInt(),
|
||||
query('stringified_filter_roles').optional().isJSON(),
|
||||
],
|
||||
async handler(req, res) {
|
||||
const validationErrors = validationResult(req);
|
||||
|
||||
if (!validationErrors.isEmpty()) {
|
||||
return res.boom.badData(null, {
|
||||
code: 'validation_error', ...validationErrors,
|
||||
});
|
||||
}
|
||||
const errorReasons = [];
|
||||
const viewConditions = [];
|
||||
const { Resource, Item, View } = req.models;
|
||||
const itemsResource = await Resource.query()
|
||||
.where('name', 'items')
|
||||
.withGraphFetched('fields')
|
||||
.first();
|
||||
|
||||
if (!itemsResource) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'ITEMS_RESOURCE_NOT_FOUND', code: 200 }],
|
||||
});
|
||||
}
|
||||
const filter = {
|
||||
column_sort_order: '',
|
||||
sort_order: '',
|
||||
page: 1,
|
||||
page_size: 10,
|
||||
custom_view_id: null,
|
||||
filter_roles: [],
|
||||
...req.query,
|
||||
};
|
||||
if (filter.stringified_filter_roles) {
|
||||
filter.filter_roles = JSON.parse(filter.stringified_filter_roles);
|
||||
}
|
||||
|
||||
const view = await View.query().onBuild((builder) => {
|
||||
if (filter.custom_view_id) {
|
||||
builder.where('id', filter.custom_view_id);
|
||||
} else {
|
||||
builder.where('favourite', true);
|
||||
}
|
||||
builder.where('resource_id', itemsResource.id);
|
||||
builder.withGraphFetched('roles.field');
|
||||
builder.withGraphFetched('columns');
|
||||
builder.first();
|
||||
});
|
||||
const resourceFieldsKeys = itemsResource.fields.map((c) => c.key);
|
||||
const dynamicFilter = new DynamicFilter(Item.tableName);
|
||||
|
||||
// Dynamic filter with view roles.
|
||||
if (view && view.roles.length > 0) {
|
||||
const viewFilter = new DynamicFilterViews(
|
||||
mapViewRolesToConditionals(view.roles),
|
||||
view.rolesLogicExpression,
|
||||
);
|
||||
if (!viewFilter.validateFilterRoles()) {
|
||||
errorReasons.push({ type: 'VIEW.LOGIC.EXPRESSION.INVALID', code: 400 });
|
||||
}
|
||||
dynamicFilter.setFilter(viewFilter);
|
||||
}
|
||||
|
||||
// Dynamic filter with filter roles.
|
||||
if (filter.filter_roles.length > 0) {
|
||||
// Validate the accounts resource fields.
|
||||
const filterRoles = new DynamicFilterFilterRoles(
|
||||
mapFilterRolesToDynamicFilter(filter.filter_roles),
|
||||
itemsResource.fields,
|
||||
);
|
||||
dynamicFilter.setFilter(filterRoles);
|
||||
|
||||
if (filterRoles.validateFilterRoles().length > 0) {
|
||||
errorReasons.push({ type: 'ITEMS.RESOURCE.HAS.NO.FIELDS', code: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamic filter with column sort order.
|
||||
if (filter.column_sort_order) {
|
||||
if (resourceFieldsKeys.indexOf(filter.column_sort_order) === -1) {
|
||||
errorReasons.push({ type: 'COLUMN.SORT.ORDER.NOT.FOUND', code: 300 });
|
||||
}
|
||||
const sortByFilter = new DynamicFilterSortBy(
|
||||
filter.column_sort_order,
|
||||
filter.sort_order,
|
||||
);
|
||||
dynamicFilter.setFilter(sortByFilter);
|
||||
}
|
||||
if (errorReasons.length > 0) {
|
||||
return res.status(400).send({ errors: errorReasons });
|
||||
}
|
||||
const items = await Item.query().onBuild((builder) => {
|
||||
builder.withGraphFetched('costAccount');
|
||||
builder.withGraphFetched('sellAccount');
|
||||
builder.withGraphFetched('inventoryAccount');
|
||||
builder.withGraphFetched('category');
|
||||
|
||||
dynamicFilter.buildQuery()(builder);
|
||||
}).pagination(filter.page - 1, filter.page_size);
|
||||
|
||||
return res.status(200).send({
|
||||
items,
|
||||
...(view) && {
|
||||
customViewId: view.id,
|
||||
viewColumns: view.columns,
|
||||
viewConditions,
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
409
server/src/http/controllers/Items.ts
Normal file
409
server/src/http/controllers/Items.ts
Normal file
@@ -0,0 +1,409 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { check, param, query, oneOf, ValidationChain } from 'express-validator';
|
||||
import asyncMiddleware from '@/http/middleware/asyncMiddleware';
|
||||
import validateMiddleware from '@/http/middleware/validateMiddleware';
|
||||
import ItemsService from '@/services/Items/ItemsService';
|
||||
import DynamicListing from '@/services/DynamicListing/DynamicListing';
|
||||
import DynamicListingBuilder from '@/services/DynamicListing/DynamicListingBuilder';
|
||||
import { dynamicListingErrorsToResponse } from '@/services/DynamicListing/hasDynamicListing';
|
||||
|
||||
export default class ItemsController {
|
||||
/**
|
||||
* Router constructor.
|
||||
*/
|
||||
static router() {
|
||||
const router = Router();
|
||||
|
||||
router.post(
|
||||
'/',
|
||||
this.validateItemSchema,
|
||||
validateMiddleware,
|
||||
asyncMiddleware(this.validateCategoryExistance),
|
||||
asyncMiddleware(this.validateCostAccountExistance),
|
||||
asyncMiddleware(this.validateSellAccountExistance),
|
||||
asyncMiddleware(this.validateInventoryAccountExistance),
|
||||
asyncMiddleware(this.validateItemNameExistance),
|
||||
asyncMiddleware(this.newItem),
|
||||
);
|
||||
router.post(
|
||||
'/:id', [
|
||||
...this.validateItemSchema,
|
||||
...this.validateSpecificItemSchema,
|
||||
],
|
||||
validateMiddleware,
|
||||
asyncMiddleware(this.validateItemExistance),
|
||||
asyncMiddleware(this.validateCategoryExistance),
|
||||
asyncMiddleware(this.validateCostAccountExistance),
|
||||
asyncMiddleware(this.validateSellAccountExistance),
|
||||
asyncMiddleware(this.validateInventoryAccountExistance),
|
||||
asyncMiddleware(this.validateItemNameExistance),
|
||||
asyncMiddleware(this.editItem),
|
||||
);
|
||||
router.delete(
|
||||
'/:id',
|
||||
this.validateSpecificItemSchema,
|
||||
validateMiddleware,
|
||||
asyncMiddleware(this.validateItemExistance),
|
||||
asyncMiddleware(this.deleteItem),
|
||||
);
|
||||
router.get(
|
||||
'/:id',
|
||||
this.validateSpecificItemSchema,
|
||||
validateMiddleware,
|
||||
asyncMiddleware(this.validateItemExistance),
|
||||
asyncMiddleware(this.getItem),
|
||||
);
|
||||
router.get(
|
||||
'/',
|
||||
this.validateListQuerySchema,
|
||||
validateMiddleware,
|
||||
asyncMiddleware(this.listItems),
|
||||
);
|
||||
return router;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate item schema.
|
||||
*
|
||||
* @param {Request} req -
|
||||
* @param {Response} res -
|
||||
* @return {ValidationChain[]} - validation chain.
|
||||
*/
|
||||
static get validateItemSchema(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: Function,
|
||||
): ValidationChain[] {
|
||||
return [
|
||||
check('name').exists(),
|
||||
check('type').exists().trim().escape()
|
||||
.isIn(['service', 'non-inventory', 'inventory']),
|
||||
check('sku').optional({ nullable: true }).trim().escape(),
|
||||
|
||||
// Purchase attributes.
|
||||
check('purchasable').optional().isBoolean().toBoolean(),
|
||||
check('cost_price')
|
||||
.if(check('purchasable').equals('true'))
|
||||
.exists()
|
||||
.isNumeric()
|
||||
.toFloat(),
|
||||
check('cost_account_id')
|
||||
.if(check('purchasable').equals('true'))
|
||||
.exists()
|
||||
.isInt()
|
||||
.toInt(),
|
||||
|
||||
// Sell attributes.
|
||||
check('sellable').optional().isBoolean().toBoolean(),
|
||||
check('sell_price')
|
||||
.if(check('sellable').equals('true'))
|
||||
.exists()
|
||||
.isNumeric()
|
||||
.toFloat(),
|
||||
check('sell_account_id')
|
||||
.if(check('sellable').equals('true'))
|
||||
.exists()
|
||||
.isInt()
|
||||
.toInt(),
|
||||
|
||||
check('inventory_account_id')
|
||||
.if(check('type').equals('inventory'))
|
||||
.exists()
|
||||
.isInt()
|
||||
.toInt(),
|
||||
check('sell_description').optional({ nullable: true }).trim().escape(),
|
||||
check('cost_description').optional({ nullable: true }).trim().escape(),
|
||||
|
||||
check('category_id').optional({ nullable: true }).isInt().toInt(),
|
||||
check('note').optional(),
|
||||
|
||||
check('media_ids').optional().isArray(),
|
||||
check('media_ids.*').exists().isNumeric().toInt(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate specific item params schema.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {NextFunction} next
|
||||
*/
|
||||
static get validateSpecificItemSchema(): ValidationChain[] {
|
||||
return [
|
||||
param('id').exists().isNumeric().toInt(),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
static get validateListQuerySchema() {
|
||||
return [
|
||||
query('column_sort_order').optional().isIn(['created_at', 'name', 'amount', 'sku']),
|
||||
query('sort_order').optional().isIn(['desc', 'asc']),
|
||||
query('page').optional().isNumeric().toInt(),
|
||||
query('page_size').optional().isNumeric().toInt(),
|
||||
query('custom_view_id').optional().isNumeric().toInt(),
|
||||
query('stringified_filter_roles').optional().isJSON(),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the given item existance on the storage.
|
||||
* @param {Request} req -
|
||||
* @param {Response} res -
|
||||
* @param {NextFunction} next -
|
||||
*/
|
||||
static async validateItemExistance(req: Request, res: Response, next: Function) {
|
||||
const { Item } = req.models;
|
||||
const itemId: number = req.params.id;
|
||||
|
||||
const foundItem = await Item.query().findById(itemId);
|
||||
|
||||
if (!foundItem) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'ITEM.NOT.FOUND', code: 100 }],
|
||||
});
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate wether the given item name already exists on the storage.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {NextFunction} next
|
||||
*/
|
||||
static async validateItemNameExistance(req: Request, res: Response, next: Function) {
|
||||
const { Item } = req.models;
|
||||
const item = req.body;
|
||||
const itemId: number = req.params.id;
|
||||
|
||||
const foundItems: [] = await Item.query().onBuild((builder: any) => {
|
||||
builder.where('name', item.name);
|
||||
|
||||
if (itemId) {
|
||||
builder.whereNot('id', itemId);
|
||||
}
|
||||
});
|
||||
if (foundItems.length > 0) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'ITEM.NAME.ALREADY.EXISTS', code: 210 }],
|
||||
});
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate wether the given category existance on the storage.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {Function} next
|
||||
*/
|
||||
static async validateCategoryExistance(req: Request, res: Response, next: Function) {
|
||||
const { ItemCategory } = req.models;
|
||||
const item = req.body;
|
||||
|
||||
if (item.category_id) {
|
||||
const foundCategory = await ItemCategory.query().findById(item.category_id);
|
||||
|
||||
if (!foundCategory) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'ITEM_CATEGORY.NOT.FOUND', code: 140 }],
|
||||
});
|
||||
}
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate wether the given cost account exists on the storage.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {Function} next
|
||||
*/
|
||||
static async validateCostAccountExistance(req: Request, res: Response, next: Function) {
|
||||
const { Account } = req.models;
|
||||
const item = req.body;
|
||||
|
||||
if (item.cost_account_id) {
|
||||
const foundAccount = await Account.query().findById(item.cost_account_id);
|
||||
|
||||
if (!foundAccount) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'COST.ACCOUNT.NOT.FOUND', code: 120 }],
|
||||
});
|
||||
}
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate wether the given sell account exists on the storage.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {NextFunction} next
|
||||
*/
|
||||
static async validateSellAccountExistance(req: Request, res: Response, next: Function) {
|
||||
const { Account } = req.models;
|
||||
const item = req.body;
|
||||
|
||||
if (item.sell_account_id) {
|
||||
const foundAccount = await Account.query().findById(item.sell_account_id);
|
||||
|
||||
if (!foundAccount) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'SELL.ACCOUNT.NOT.FOUND', code: 130 }],
|
||||
});
|
||||
}
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates wether the given inventory account exists on the storage.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {NextFunction} next
|
||||
*/
|
||||
static async validateInventoryAccountExistance(req: Request, res: Response, next: Function) {
|
||||
const { Account } = req.models;
|
||||
const item = req.body;
|
||||
|
||||
if (item.inventory_account_id) {
|
||||
const foundAccount = await Account.query().findById(item.inventory_account_id);
|
||||
|
||||
if (!foundAccount) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'INVENTORY.ACCOUNT.NOT.FOUND', code: 200}],
|
||||
});
|
||||
}
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the given item details to the storage.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
static async newItem(req: Request, res: Response,) {
|
||||
const item = req.body;
|
||||
const storedItem = await ItemsService.newItem(item);
|
||||
|
||||
return res.status(200).send({ id: storedItem.id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the given item details on the storage.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
static async editItem(req: Request, res: Response) {
|
||||
const item = req.body;
|
||||
const itemId: number = req.params.id;
|
||||
const updatedItem = await ItemsService.editItem(item, itemId);
|
||||
|
||||
return res.status(200).send({ id: itemId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the given item from the storage.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
static async deleteItem(req: Request, res: Response) {
|
||||
const itemId: number = req.params.id;
|
||||
await ItemsService.deleteItem(itemId);
|
||||
|
||||
return res.status(200).send({ id: itemId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve details the given item id.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @return {Response}
|
||||
*/
|
||||
static async getItem(req: Request, res: Response) {
|
||||
const itemId: number = req.params.id;
|
||||
const storedItem = await ItemsService.getItemWithMetadata(itemId);
|
||||
|
||||
return res.status(200).send({ item: storedItem });
|
||||
}
|
||||
|
||||
/**
|
||||
* Listing items with pagination metadata.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
static async listItems(req: Request, res: Response) {
|
||||
const filter = {
|
||||
filter_roles: [],
|
||||
sort_order: 'asc',
|
||||
page: 1,
|
||||
page_size: 10,
|
||||
...req.query,
|
||||
};
|
||||
if (filter.stringified_filter_roles) {
|
||||
filter.filter_roles = JSON.parse(filter.stringified_filter_roles);
|
||||
}
|
||||
const { Resource, Item, View } = req.models;
|
||||
const resource = await Resource.query()
|
||||
.remember()
|
||||
.where('name', 'items')
|
||||
.withGraphFetched('fields')
|
||||
.first();
|
||||
|
||||
if (!resource) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'ITEMS.RESOURCE.NOT_FOUND', code: 200 }],
|
||||
});
|
||||
}
|
||||
const viewMeta = await View.query()
|
||||
.modify('allMetadata')
|
||||
.modify('specificOrFavourite', filter.custom_view_id)
|
||||
.where('resource_id', resource.id)
|
||||
.first();
|
||||
|
||||
const listingBuilder = new DynamicListingBuilder();
|
||||
const errorReasons = [];
|
||||
|
||||
listingBuilder.addModelClass(Item);
|
||||
listingBuilder.addCustomViewId(filter.custom_view_id);
|
||||
listingBuilder.addFilterRoles(filter.filter_roles);
|
||||
listingBuilder.addSortBy(filter.sort_by, filter.sort_order);
|
||||
listingBuilder.addView(viewMeta);
|
||||
|
||||
const dynamicListing = new DynamicListing(listingBuilder);
|
||||
|
||||
if (dynamicListing instanceof Error) {
|
||||
const errors = dynamicListingErrorsToResponse(dynamicListing);
|
||||
errorReasons.push(...errors);
|
||||
}
|
||||
if (errorReasons.length > 0) {
|
||||
return res.status(400).send({ errors: errorReasons });
|
||||
}
|
||||
const items = await Item.query().onBuild((builder: any) => {
|
||||
builder.withGraphFetched('costAccount');
|
||||
builder.withGraphFetched('sellAccount');
|
||||
builder.withGraphFetched('inventoryAccount');
|
||||
builder.withGraphFetched('category');
|
||||
|
||||
dynamicListing.buildQuery()(builder);
|
||||
return builder;
|
||||
}).pagination(filter.page - 1, filter.page_size);
|
||||
|
||||
return res.status(200).send({
|
||||
items: {
|
||||
...items,
|
||||
...(viewMeta
|
||||
? {
|
||||
viewMeta: {
|
||||
custom_view_id: viewMeta.id,
|
||||
view_columns: viewMeta.columns,
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import InventoryService from '@/services/Inventory/Inventory';
|
||||
|
||||
export default class Ping {
|
||||
|
||||
/**
|
||||
* Router constur
|
||||
*/
|
||||
@@ -17,46 +15,14 @@ export default class Ping {
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Handle the ping request.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
static async ping(req: Request, res: Response) {
|
||||
|
||||
const result = await InventoryService.trackingInventoryLotsCost([
|
||||
{
|
||||
id: 1,
|
||||
date: '2020-02-02',
|
||||
direction: 'IN',
|
||||
itemId: 1,
|
||||
quantity: 100,
|
||||
rate: 10,
|
||||
transactionType: 'Bill',
|
||||
transactionId: 1,
|
||||
remaining: 100,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
date: '2020-02-02',
|
||||
direction: 'OUT',
|
||||
itemId: 1,
|
||||
quantity: 80,
|
||||
rate: 10,
|
||||
transactionType: 'SaleInvoice',
|
||||
transactionId: 1,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
date: '2020-02-02',
|
||||
direction: 'OUT',
|
||||
itemId: 2,
|
||||
quantity: 500,
|
||||
rate: 10,
|
||||
transactionType: 'SaleInvoice',
|
||||
transactionId: 2,
|
||||
},
|
||||
]);
|
||||
|
||||
return res.status(200).send({ id: 1231231 });
|
||||
static async ping(req: Request, res: Response)
|
||||
{
|
||||
return res.status(200).send({
|
||||
server: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -23,14 +23,14 @@ import Media from '@/http/controllers/Media';
|
||||
import JWTAuth from '@/http/middleware/jwtAuth';
|
||||
import TenancyMiddleware from '@/http/middleware/TenancyMiddleware';
|
||||
import Ping from '@/http/controllers/Ping';
|
||||
import Agendash from '@/http/controllers/Agendash';
|
||||
|
||||
export default (app) => {
|
||||
// app.use('/api/oauth2', OAuth2.router());
|
||||
app.use('/api/auth', Authentication.router());
|
||||
app.use('/api/invite', InviteUsers.router());
|
||||
app.use('/api/ping', Ping.router());
|
||||
|
||||
const dashboard = express.Router();
|
||||
|
||||
const dashboard = express.Router();
|
||||
|
||||
dashboard.use(JWTAuth);
|
||||
dashboard.use(TenancyMiddleware);
|
||||
@@ -53,6 +53,8 @@ export default (app) => {
|
||||
dashboard.use('/api/resources', Resources.router());
|
||||
dashboard.use('/api/exchange_rates', ExchangeRates.router());
|
||||
dashboard.use('/api/media', Media.router());
|
||||
dashboard.use('/api/ping', Ping.router());
|
||||
|
||||
app.use('/', dashboard);
|
||||
app.use('/agendash', Agendash.router());
|
||||
app.use('/', dashboard);
|
||||
};
|
||||
|
||||
6
server/src/interfaces/InventoryCostMethod.ts
Normal file
6
server/src/interfaces/InventoryCostMethod.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
|
||||
|
||||
interface IInventoryCostMethod {
|
||||
computeItemsCost(fromDate: Date): void,
|
||||
initialize(): void,
|
||||
}
|
||||
@@ -18,6 +18,7 @@ export interface IInventoryLotCost {
|
||||
itemId: number,
|
||||
rate: number,
|
||||
remaining: number,
|
||||
lotNumber: string|number,
|
||||
transactionType: string,
|
||||
transactionId: string,
|
||||
}
|
||||
11
server/src/interfaces/index.ts
Normal file
11
server/src/interfaces/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { IInventoryTransaction, IInventoryLotCost } from './InventoryTransaction';
|
||||
import { IBillPaymentEntry, IBillPayment } from './BillPayment';
|
||||
import { IInventoryCostMethod } from './IInventoryCostMethod';
|
||||
|
||||
export {
|
||||
IBillPaymentEntry,
|
||||
IBillPayment,
|
||||
IInventoryTransaction,
|
||||
IInventoryLotCost,
|
||||
IInventoryCostMethod,
|
||||
};
|
||||
12
server/src/jobs/ComputeItemCost.ts
Normal file
12
server/src/jobs/ComputeItemCost.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Container } from 'typedi';
|
||||
import InventoryService from '@/services/Inventory/Inventory';
|
||||
|
||||
export default class ComputeItemCostJob {
|
||||
public async handler(job, done: Function): Promise<void> {
|
||||
const Logger = Container.get('logger');
|
||||
const { startingDate, itemId, costMethod } = job.attrs.data;
|
||||
|
||||
await InventoryService.computeItemCost(startingDate, itemId, costMethod);
|
||||
done();
|
||||
}
|
||||
}
|
||||
11
server/src/jobs/welcomeEmail.ts
Normal file
11
server/src/jobs/welcomeEmail.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Container } from 'typedi';
|
||||
import MailerService from '../services/mailer';
|
||||
|
||||
export default class WelcomeEmailJob {
|
||||
public async handler(job, done: Function): Promise<void> {
|
||||
const Logger = Container.get('logger');
|
||||
|
||||
console.log('✌Email Sequence Job triggered!');
|
||||
done();
|
||||
}
|
||||
}
|
||||
11
server/src/loaders/agenda.ts
Normal file
11
server/src/loaders/agenda.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import Agenda from 'agenda';
|
||||
import config from '@/../config/config';
|
||||
|
||||
export default ({ mongoConnection }) => {
|
||||
return new Agenda({
|
||||
mongo: mongoConnection,
|
||||
db: { collection: config.agenda.dbCollection },
|
||||
processEvery: config.agenda.pooltime,
|
||||
maxConcurrency: config.agenda.concurrency,
|
||||
});
|
||||
};
|
||||
20
server/src/loaders/dependencyInjector.ts
Normal file
20
server/src/loaders/dependencyInjector.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Container } from 'typedi';
|
||||
import LoggerInstance from '@/services/Logger';
|
||||
import agendaFactory from '@/loaders/agenda';
|
||||
|
||||
export default ({ mongoConnection, knex }) => {
|
||||
try {;
|
||||
const agendaInstance = agendaFactory({ mongoConnection });
|
||||
|
||||
Container.set('agenda', agendaInstance);
|
||||
Container.set('logger', LoggerInstance)
|
||||
Container.set('knex', knex);
|
||||
|
||||
LoggerInstance.info('Agenda has been injected into container');
|
||||
|
||||
return { agenda: agendaInstance };
|
||||
} catch (e) {
|
||||
LoggerInstance.error('Error on dependency injector loader: %o', e);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
0
server/src/loaders/events.ts
Normal file
0
server/src/loaders/events.ts
Normal file
21
server/src/loaders/express.ts
Normal file
21
server/src/loaders/express.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import express from 'express';
|
||||
import helmet from 'helmet';
|
||||
import boom from 'express-boom';
|
||||
import errorHandler from 'errorhandler';
|
||||
import i18n from 'i18n';
|
||||
import fileUpload from 'express-fileupload';
|
||||
import routes from '@/http';
|
||||
|
||||
export default ({ app }) => {
|
||||
// Express configuration.
|
||||
app.set('port', 3000);
|
||||
|
||||
app.use(helmet());
|
||||
app.use(errorHandler());
|
||||
app.use(boom());
|
||||
app.use(express.json());
|
||||
app.use(fileUpload({
|
||||
createParentPath: true,
|
||||
}));
|
||||
routes(app);
|
||||
};
|
||||
32
server/src/loaders/index.ts
Normal file
32
server/src/loaders/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import Logger from '@/services/Logger';
|
||||
import mongooseLoader from '@/loaders/mongoose';
|
||||
import jobsLoader from '@/loaders/jobs';
|
||||
import expressLoader from '@/loaders/express';
|
||||
import databaseLoader from '@/database/knex';
|
||||
import dependencyInjectorLoader from '@/loaders/dependencyInjector';
|
||||
import objectionLoader from '@/database/objection';
|
||||
|
||||
// We have to import at least all the events once so they can be triggered
|
||||
import '@/loaders/events';
|
||||
|
||||
export default async ({ expressApp }) => {
|
||||
const mongoConnection = await mongooseLoader();
|
||||
Logger.info('MongoDB loaded and connected!');
|
||||
|
||||
// Initialize the system database once app started.
|
||||
const knex = databaseLoader();
|
||||
|
||||
// Initialize the objection.js from knex instance.
|
||||
objectionLoader({ knex });
|
||||
|
||||
// It returns the agenda instance because it's needed in the subsequent loaders
|
||||
const { agenda } = await dependencyInjectorLoader({
|
||||
mongoConnection,
|
||||
knex,
|
||||
});
|
||||
await jobsLoader({ agenda });
|
||||
Logger.info('Jobs loaded');
|
||||
|
||||
expressLoader({ app: expressApp });
|
||||
Logger.info('Express loaded');
|
||||
};
|
||||
17
server/src/loaders/jobs.ts
Normal file
17
server/src/loaders/jobs.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import Agenda from 'agenda';
|
||||
import WelcomeEmailJob from '@/Jobs/welcomeEmail';
|
||||
import ComputeItemCost from '@/Jobs/ComputeItemCost';
|
||||
|
||||
export default ({ agenda }: { agenda: Agenda }) => {
|
||||
agenda.define(
|
||||
'welcome-email',
|
||||
{ priority: 'high' },
|
||||
new WelcomeEmailJob().handler,
|
||||
);
|
||||
agenda.define(
|
||||
'compute-item-cost',
|
||||
{ priority: 'high' },
|
||||
new ComputeItemCost().handler,
|
||||
);
|
||||
agenda.start();
|
||||
};
|
||||
11
server/src/loaders/mongoose.ts
Normal file
11
server/src/loaders/mongoose.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { Db } from 'mongodb';
|
||||
import config from '@/../config/config';
|
||||
|
||||
export default async (): Promise<Db> => {
|
||||
const connection = await mongoose.connect(
|
||||
config.mongoDb.databaseURL,
|
||||
{ useNewUrlParser: true, useCreateIndex: true },
|
||||
);
|
||||
return connection.connection.db;
|
||||
};
|
||||
36
server/src/models/InventoryCostLotTracker.js
Normal file
36
server/src/models/InventoryCostLotTracker.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Model } from 'objection';
|
||||
import TenantModel from '@/models/TenantModel';
|
||||
|
||||
export default class InventoryCostLotTracker extends TenantModel {
|
||||
/**
|
||||
* Table name
|
||||
*/
|
||||
static get tableName() {
|
||||
return 'inventory_cost_lot_tracker';
|
||||
}
|
||||
|
||||
/**
|
||||
* Model timestamps.
|
||||
*/
|
||||
static get timestamps() {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Relationship mapping.
|
||||
*/
|
||||
static get relationMappings() {
|
||||
const Item = require('@/models/Item');
|
||||
|
||||
return {
|
||||
item: {
|
||||
relation: Model.BelongsToOneRelation,
|
||||
modelClass: this.relationBindKnex(Item.default),
|
||||
join: {
|
||||
from: 'inventory_cost_lot_tracker.itemId',
|
||||
to: 'items.id',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Model } from 'objection';
|
||||
import TenantModel from '@/models/TenantModel';
|
||||
|
||||
export default class InventoryLotCostTracker extends TenantModel {
|
||||
/**
|
||||
* Table name
|
||||
*/
|
||||
static get tableName() {
|
||||
return 'inventory_cost_lot_tracker';
|
||||
}
|
||||
|
||||
/**
|
||||
* Model timestamps.
|
||||
*/
|
||||
static get timestamps() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -15,4 +15,22 @@ export default class InventoryTransaction extends TenantModel {
|
||||
static get timestamps() {
|
||||
return ['createdAt', 'updatedAt'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Relationship mapping.
|
||||
*/
|
||||
static get relationMappings() {
|
||||
const Item = require('@/models/Item');
|
||||
|
||||
return {
|
||||
item: {
|
||||
relation: Model.BelongsToOneRelation,
|
||||
modelClass: this.relationBindKnex(Item.default),
|
||||
join: {
|
||||
from: 'inventory_transactions.itemId',
|
||||
to: 'items.id',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,8 @@ export default class View extends mixin(TenantModel, [CachableModel]) {
|
||||
specificOrFavourite(query, viewId) {
|
||||
if (viewId) {
|
||||
query.where('id', viewId)
|
||||
} else {
|
||||
query.where('favourite', true);
|
||||
}
|
||||
return query;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import View from './View';
|
||||
import ItemEntry from './ItemEntry';
|
||||
import InventoryTransaction from './InventoryTransaction';
|
||||
import AccountType from './AccountType';
|
||||
import InventoryLotCostTracker from './InventoryCostLotTracker';
|
||||
|
||||
export {
|
||||
Customer,
|
||||
@@ -41,5 +42,6 @@ export {
|
||||
View,
|
||||
ItemEntry,
|
||||
InventoryTransaction,
|
||||
InventoryLotCostTracker,
|
||||
AccountType,
|
||||
};
|
||||
@@ -1,15 +1,28 @@
|
||||
import errorHandler from 'errorhandler';
|
||||
import app from '@/app';
|
||||
import express from 'express';
|
||||
import rootPath from 'app-root-path';
|
||||
import loadersFactory from '@/loaders';
|
||||
import '../config';
|
||||
|
||||
app.use(errorHandler);
|
||||
global.rootPath = rootPath.path;
|
||||
|
||||
const server = app.listen(app.get('port'), () => {
|
||||
console.log(
|
||||
' App is running at http://localhost:%d in %s mode',
|
||||
app.get('port'),
|
||||
app.get('env'),
|
||||
);
|
||||
console.log(' Press CTRL-C to stop');
|
||||
});
|
||||
async function startServer() {
|
||||
const app = express();
|
||||
|
||||
export default server;
|
||||
// Intiialize all registered loaders.
|
||||
await loadersFactory({ expressApp: app });
|
||||
|
||||
app.listen(app.get('port'), (err) => {
|
||||
if (err) {
|
||||
console.log(err);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
console.log(`
|
||||
################################################
|
||||
🛡️ Server listening on port: ${app.get('port')} 🛡️
|
||||
################################################
|
||||
`);
|
||||
});
|
||||
}
|
||||
|
||||
startServer();
|
||||
135
server/src/services/Accounting/JournalCommands.ts
Normal file
135
server/src/services/Accounting/JournalCommands.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { sumBy, chain } from 'lodash';
|
||||
import JournalPoster from "./JournalPoster";
|
||||
import JournalEntry from "./JournalEntry";
|
||||
import { AccountTransaction } from '@/models';
|
||||
import { IInventoryTransaction } from '@/interfaces';
|
||||
import AccountsService from '../Accounts/AccountsService';
|
||||
import { IInventoryTransaction, IInventoryTransaction } from '../../interfaces';
|
||||
|
||||
interface IInventoryCostEntity {
|
||||
date: Date,
|
||||
|
||||
referenceType: string,
|
||||
referenceId: number,
|
||||
|
||||
costAccount: number,
|
||||
incomeAccount: number,
|
||||
inventoryAccount: number,
|
||||
|
||||
inventory: number,
|
||||
cost: number,
|
||||
income: number,
|
||||
};
|
||||
|
||||
export default class JournalCommands{
|
||||
journal: JournalPoster;
|
||||
|
||||
/**
|
||||
* Constructor method.
|
||||
* @param {JournalPoster} journal -
|
||||
*/
|
||||
constructor(journal: JournalPoster) {
|
||||
this.journal = journal;
|
||||
Object.assign(this, arguments[1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes and revert accounts balance journal entries that associated
|
||||
* to the given inventory transactions.
|
||||
* @param {IInventoryTransaction[]} inventoryTransactions
|
||||
* @param {Journal} journal
|
||||
*/
|
||||
revertEntriesFromInventoryTransactions(inventoryTransactions: IInventoryTransaction[]) {
|
||||
const groupedInvTransactions = chain(inventoryTransactions)
|
||||
.groupBy((invTransaction: IInventoryTransaction) => invTransaction.transactionType)
|
||||
.map((groupedTrans: IInventoryTransaction[], transType: string) => [groupedTrans, transType])
|
||||
.value();
|
||||
|
||||
console.log(groupedInvTransactions);
|
||||
|
||||
return Promise.all(
|
||||
groupedInvTransactions.map(async (grouped: [IInventoryTransaction[], string]) => {
|
||||
const [invTransGroup, referenceType] = grouped;
|
||||
const referencesIds = invTransGroup.map((trans: IInventoryTransaction) => trans.transactionId);
|
||||
|
||||
const _transactions = await AccountTransaction.tenant()
|
||||
.query()
|
||||
.where('reference_type', referenceType)
|
||||
.whereIn('reference_id', referencesIds)
|
||||
.withGraphFetched('account.type');
|
||||
|
||||
console.log(_transactions, referencesIds);
|
||||
|
||||
if (_transactions.length > 0) {
|
||||
this.journal.loadEntries(_transactions);
|
||||
this.journal.removeEntries(_transactions.map((t: any) => t.id));
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} referenceType -
|
||||
* @param {number} referenceId -
|
||||
* @param {ISaleInvoice[]} sales -
|
||||
*/
|
||||
public async inventoryEntries(
|
||||
transactions: IInventoryCostEntity[],
|
||||
) {
|
||||
const receivableAccount = { id: 10 };
|
||||
const payableAccount = { id: 11 };
|
||||
|
||||
transactions.forEach((sale: IInventoryCostEntity) => {
|
||||
const commonEntry = {
|
||||
date: sale.date,
|
||||
referenceId: sale.referenceId,
|
||||
referenceType: sale.referenceType,
|
||||
};
|
||||
switch(sale.referenceType) {
|
||||
case 'Bill':
|
||||
const inventoryDebit: JournalEntry = new JournalEntry({
|
||||
...commonEntry,
|
||||
debit: sale.inventory,
|
||||
account: sale.inventoryAccount,
|
||||
});
|
||||
const payableEntry: JournalEntry = new JournalEntry({
|
||||
...commonEntry,
|
||||
credit: sale.inventory,
|
||||
account: payableAccount.id,
|
||||
});
|
||||
this.journal.debit(inventoryDebit);
|
||||
this.journal.credit(payableEntry);
|
||||
break;
|
||||
case 'SaleInvoice':
|
||||
const receivableEntry: JournalEntry = new JournalEntry({
|
||||
...commonEntry,
|
||||
debit: sale.income,
|
||||
account: receivableAccount.id,
|
||||
});
|
||||
const incomeEntry: JournalEntry = new JournalEntry({
|
||||
...commonEntry,
|
||||
credit: sale.income,
|
||||
account: sale.incomeAccount,
|
||||
});
|
||||
// Cost journal transaction.
|
||||
const costEntry: JournalEntry = new JournalEntry({
|
||||
...commonEntry,
|
||||
debit: sale.cost,
|
||||
account: sale.costAccount,
|
||||
});
|
||||
const inventoryCredit: JournalEntry = new JournalEntry({
|
||||
...commonEntry,
|
||||
credit: sale.cost,
|
||||
account: sale.inventoryAccount,
|
||||
});
|
||||
this.journal.debit(receivableEntry);
|
||||
this.journal.debit(costEntry);
|
||||
|
||||
this.journal.credit(incomeEntry);
|
||||
this.journal.credit(inventoryCredit);
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -248,6 +248,17 @@ export default class JournalPoster {
|
||||
this.deletedEntriesIds.push(...removeEntries.map((entry) => entry.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Revert the given transactions.
|
||||
* @param {*} entries
|
||||
*/
|
||||
removeTransactions(entries) {
|
||||
this.loadEntries(entries);
|
||||
|
||||
|
||||
this.deletedEntriesIds.push(...entriesIDsShouldDel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all the stacked entries.
|
||||
*/
|
||||
|
||||
@@ -17,8 +17,6 @@ export default class AccountsService {
|
||||
.where('account_type_id', accountType.id)
|
||||
.first();
|
||||
|
||||
console.log(account);
|
||||
|
||||
return account;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,36 @@
|
||||
import { InventoryTransaction, Item } from '@/models';
|
||||
import InventoryCostLotTracker from './InventoryCostLotTracker';
|
||||
import { IInventoryTransaction, IInventoryLotCost } from '@/interfaces/InventoryTransaction';
|
||||
import { IInventoryLotCost, IInventoryLotCost } from '../../interfaces/InventoryTransaction';
|
||||
import { pick } from 'lodash';
|
||||
import {
|
||||
InventoryTransaction,
|
||||
Item
|
||||
} from '@/models';
|
||||
import InventoryAverageCost from '@/services/Inventory/InventoryAverageCost';
|
||||
import InventoryCostLotTracker from '@/services/Inventory/InventoryCostLotTracker';
|
||||
|
||||
type TCostMethod = 'FIFO' | 'LIFO' | 'AVG';
|
||||
|
||||
export default class InventoryService {
|
||||
/**
|
||||
* Computes the given item cost and records the inventory lots transactions
|
||||
* and journal entries based on the cost method FIFO, LIFO or average cost rate.
|
||||
* @param {Date} fromDate
|
||||
* @param {number} itemId
|
||||
*/
|
||||
static async computeItemCost(fromDate: Date, itemId: number) {
|
||||
const costMethod: TCostMethod = 'FIFO';
|
||||
let costMethodComputer: IInventoryCostMethod;
|
||||
|
||||
switch(costMethod) {
|
||||
case 'FIFO':
|
||||
case 'LIFO':
|
||||
costMethodComputer = new InventoryCostLotTracker(fromDate, itemId);
|
||||
break;
|
||||
case 'AVG':
|
||||
costMethodComputer = new InventoryAverageCost(fromDate, itemId);
|
||||
break
|
||||
}
|
||||
await costMethodComputer.initialize();
|
||||
await costMethodComputer.computeItemCost()
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the inventory transactions.
|
||||
* @param {Bill} bill
|
||||
@@ -15,6 +41,7 @@ export default class InventoryService {
|
||||
date: Date,
|
||||
transactionType: string,
|
||||
transactionId: number,
|
||||
direction: string,
|
||||
) {
|
||||
const storedOpers: any = [];
|
||||
const entriesItemsIds = entries.map((e: any) => e.item_id);
|
||||
@@ -23,20 +50,19 @@ export default class InventoryService {
|
||||
.whereIn('id', entriesItemsIds)
|
||||
.where('type', 'inventory');
|
||||
|
||||
const inventoryItemsIds = inventoryItems.map((i) => i.id);
|
||||
const inventoryItemsIds = inventoryItems.map((i: any) => i.id);
|
||||
|
||||
// Filter the bill entries that have inventory items.
|
||||
const inventoryEntries = entries.filter(
|
||||
(entry) => inventoryItemsIds.indexOf(entry.item_id) !== -1
|
||||
(entry: any) => inventoryItemsIds.indexOf(entry.item_id) !== -1
|
||||
);
|
||||
inventoryEntries.forEach((entry: any) => {
|
||||
const oper = InventoryTransaction.tenant().query().insert({
|
||||
date,
|
||||
|
||||
direction,
|
||||
item_id: entry.item_id,
|
||||
quantity: entry.quantity,
|
||||
rate: entry.rate,
|
||||
|
||||
transaction_type: transactionType,
|
||||
transaction_id: transactionId,
|
||||
});
|
||||
@@ -64,86 +90,4 @@ export default class InventoryService {
|
||||
revertInventoryLotsCost(fromDate?: Date) {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the journal entries transactions.
|
||||
* @param {IInventoryLotCost[]} inventoryTransactions -
|
||||
*
|
||||
*/
|
||||
static async recordJournalEntries(inventoryLots: IInventoryLotCost[]) {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracking the given inventory transactions to lots costs transactions.
|
||||
* @param {IInventoryTransaction[]} inventoryTransactions - Inventory transactions.
|
||||
* @return {IInventoryLotCost[]}
|
||||
*/
|
||||
static async trackingInventoryLotsCost(inventoryTransactions: IInventoryTransaction[]) {
|
||||
// Collect cost lots transactions to insert them to the storage in bulk.
|
||||
const costLotsTransactions: IInventoryLotCost[] = [];
|
||||
|
||||
// Collect inventory transactions by item id.
|
||||
const inventoryByItem: any = {};
|
||||
// Collection `IN` inventory tranaction by transaction id.
|
||||
const inventoryINTrans: any = {};
|
||||
|
||||
inventoryTransactions.forEach((transaction: IInventoryTransaction) => {
|
||||
const { itemId, id } = transaction;
|
||||
(inventoryByItem[itemId] || (inventoryByItem[itemId] = []));
|
||||
|
||||
const commonLotTransaction: IInventoryLotCost = {
|
||||
...pick(transaction, [
|
||||
'date', 'rate', 'itemId', 'quantity',
|
||||
'direction', 'transactionType', 'transactionId',
|
||||
]),
|
||||
};
|
||||
// Record inventory `IN` cost lot transaction.
|
||||
if (transaction.direction === 'IN') {
|
||||
inventoryByItem[itemId].push(id);
|
||||
inventoryINTrans[id] = {
|
||||
...commonLotTransaction,
|
||||
remaining: commonLotTransaction.quantity,
|
||||
};
|
||||
costLotsTransactions.push(inventoryINTrans[id]);
|
||||
|
||||
// Record inventory 'OUT' cost lots from 'IN' transactions.
|
||||
} else if (transaction.direction === 'OUT') {
|
||||
let invRemaining = transaction.quantity;
|
||||
|
||||
inventoryByItem?.[itemId]?.forEach((
|
||||
_invTransactionId: number,
|
||||
index: number,
|
||||
) => {
|
||||
const _invINTransaction = inventoryINTrans[_invTransactionId];
|
||||
|
||||
// Detarmines the 'OUT' lot tranasctions whether bigger than 'IN' remaining transaction.
|
||||
const biggerThanRemaining = (_invINTransaction.remaining - transaction.quantity) > 0;
|
||||
const decrement = (biggerThanRemaining) ? transaction.quantity : _invINTransaction.remaining;
|
||||
|
||||
_invINTransaction.remaining = Math.max(
|
||||
_invINTransaction.remaining - decrement, 0,
|
||||
);
|
||||
invRemaining = Math.max(invRemaining - decrement, 0);
|
||||
|
||||
costLotsTransactions.push({
|
||||
...commonLotTransaction,
|
||||
quantity: decrement,
|
||||
});
|
||||
// Pop the 'IN' lots that has zero remaining.
|
||||
if (_invINTransaction.remaining === 0) {
|
||||
inventoryByItem?.[itemId].splice(index, 1);
|
||||
}
|
||||
});
|
||||
if (invRemaining > 0) {
|
||||
costLotsTransactions.push({
|
||||
...commonLotTransaction,
|
||||
quantity: invRemaining,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
return costLotsTransactions;
|
||||
}
|
||||
|
||||
}
|
||||
207
server/src/services/Inventory/InventoryAverageCost.ts
Normal file
207
server/src/services/Inventory/InventoryAverageCost.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { Account, InventoryTransaction } from '@/models';
|
||||
import { IInventoryTransaction } from '@/interfaces';
|
||||
import JournalPoster from '@/services/Accounting/JournalPoster';
|
||||
import JournalCommands from '@/services/Accounting/JournalCommands';
|
||||
|
||||
export default class InventoryAverageCostMethod implements IInventoryCostMethod {
|
||||
journal: JournalPoster;
|
||||
journalCommands: JournalCommands;
|
||||
fromDate: Date;
|
||||
itemId: number;
|
||||
|
||||
/**
|
||||
* Constructor method.
|
||||
* @param {Date} fromDate -
|
||||
* @param {number} itemId -
|
||||
*/
|
||||
constructor(
|
||||
fromDate: Date,
|
||||
itemId: number,
|
||||
) {
|
||||
this.fromDate = fromDate;
|
||||
this.itemId = itemId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the inventory average cost method.
|
||||
* @async
|
||||
*/
|
||||
async initialize() {
|
||||
const accountsDepGraph = await Account.tenant().depGraph().query();
|
||||
|
||||
this.journal = new JournalPoster(accountsDepGraph);
|
||||
this.journalCommands = new JournalCommands(this.journal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes items costs from the given date using average cost method.
|
||||
*
|
||||
* - Calculate the items average cost in the given date.
|
||||
* - Remove the journal entries that associated to the inventory transacions
|
||||
* after the given date.
|
||||
* - Re-compute the inventory transactions and re-write the journal entries
|
||||
* after the given date.
|
||||
* ----------
|
||||
* @asycn
|
||||
* @param {Date} fromDate
|
||||
* @param {number} referenceId
|
||||
* @param {string} referenceType
|
||||
*/
|
||||
public async computeItemCost() {
|
||||
const openingAvgCost = await this.getOpeningAvaregeCost(this.fromDate, this.itemId);
|
||||
|
||||
// @todo from `invTransactions`.
|
||||
const afterInvTransactions: IInventoryTransaction[] = await InventoryTransaction
|
||||
.tenant()
|
||||
.query()
|
||||
.where('date', '>=', this.fromDate)
|
||||
// .where('direction', 'OUT')
|
||||
.orderBy('date', 'asc')
|
||||
.withGraphFetched('item');
|
||||
|
||||
// Remove and revert accounts balance journal entries from
|
||||
// inventory transactions.
|
||||
await this.journalCommands
|
||||
.revertEntriesFromInventoryTransactions(afterInvTransactions);
|
||||
|
||||
// Re-write the journal entries from the new recorded inventory transactions.
|
||||
await this.jEntriesFromItemInvTransactions(
|
||||
afterInvTransactions,
|
||||
openingAvgCost,
|
||||
);
|
||||
// Saves the new recorded journal entries to the storage.
|
||||
await Promise.all([
|
||||
this.journal.deleteEntries(),
|
||||
this.journal.saveEntries(),
|
||||
this.journal.saveBalance(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get items Avarege cost from specific date from inventory transactions.
|
||||
* @static
|
||||
* @param {Date} fromDate
|
||||
* @return {number}
|
||||
*/
|
||||
public async getOpeningAvaregeCost(fromDate: Date, itemId: number) {
|
||||
const commonBuilder = (builder: any) => {
|
||||
if (fromDate) {
|
||||
builder.where('date', '<', fromDate);
|
||||
}
|
||||
builder.where('item_id', itemId);
|
||||
builder.groupBy('rate');
|
||||
builder.groupBy('quantity');
|
||||
builder.groupBy('item_id');
|
||||
builder.groupBy('direction');
|
||||
builder.sum('rate as rate');
|
||||
builder.sum('quantity as quantity');
|
||||
};
|
||||
// Calculates the total inventory total quantity and rate `IN` transactions.
|
||||
|
||||
// @todo total `IN` transactions.
|
||||
const inInvSumationOper: Promise<any> = InventoryTransaction.tenant()
|
||||
.query()
|
||||
.onBuild(commonBuilder)
|
||||
.where('direction', 'IN')
|
||||
.first();
|
||||
|
||||
// Calculates the total inventory total quantity and rate `OUT` transactions.
|
||||
// @todo total `OUT` transactions.
|
||||
const outInvSumationOper: Promise<any> = InventoryTransaction.tenant()
|
||||
.query()
|
||||
.onBuild(commonBuilder)
|
||||
.where('direction', 'OUT')
|
||||
.first();
|
||||
|
||||
const [inInvSumation, outInvSumation] = await Promise.all([
|
||||
inInvSumationOper,
|
||||
outInvSumationOper,
|
||||
]);
|
||||
return this.computeItemAverageCost(
|
||||
inInvSumation?.quantity || 0,
|
||||
inInvSumation?.rate || 0,
|
||||
outInvSumation?.quantity || 0,
|
||||
outInvSumation?.rate || 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the item average cost.
|
||||
* @static
|
||||
* @param {number} quantityIn
|
||||
* @param {number} rateIn
|
||||
* @param {number} quantityOut
|
||||
* @param {number} rateOut
|
||||
*/
|
||||
public computeItemAverageCost(
|
||||
quantityIn: number,
|
||||
rateIn: number,
|
||||
|
||||
quantityOut: number,
|
||||
rateOut: number,
|
||||
) {
|
||||
const totalQuantity = (quantityIn - quantityOut);
|
||||
const totalRate = (rateIn - rateOut);
|
||||
const averageCost = (totalRate) ? (totalQuantity / totalRate) : totalQuantity;
|
||||
|
||||
return averageCost;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the journal entries from specific item inventory transactions.
|
||||
* @param {IInventoryTransaction[]} invTransactions
|
||||
* @param {number} openingAverageCost
|
||||
* @param {string} referenceType
|
||||
* @param {number} referenceId
|
||||
* @param {JournalCommand} journalCommands
|
||||
*/
|
||||
async jEntriesFromItemInvTransactions(
|
||||
invTransactions: IInventoryTransaction[],
|
||||
openingAverageCost: number,
|
||||
) {
|
||||
const transactions: any[] = [];
|
||||
let accQuantity: number = 0;
|
||||
let accCost: number = 0;
|
||||
|
||||
invTransactions.forEach((invTransaction: IInventoryTransaction) => {
|
||||
const commonEntry = {
|
||||
date: invTransaction.date,
|
||||
referenceType: invTransaction.transactionType,
|
||||
referenceId: invTransaction.transactionId,
|
||||
};
|
||||
switch(invTransaction.direction) {
|
||||
case 'IN':
|
||||
accQuantity += invTransaction.quantity;
|
||||
accCost += invTransaction.rate * invTransaction.quantity;
|
||||
|
||||
const inventory = invTransaction.quantity * invTransaction.rate;
|
||||
|
||||
transactions.push({
|
||||
...commonEntry,
|
||||
inventory,
|
||||
inventoryAccount: invTransaction.item.inventoryAccountId,
|
||||
});
|
||||
break;
|
||||
case 'OUT':
|
||||
const income = invTransaction.quantity * invTransaction.rate;
|
||||
const transactionAvgCost = accCost ? (accCost / accQuantity) : 0;
|
||||
const averageCost = transactionAvgCost;
|
||||
const cost = (invTransaction.quantity * averageCost);
|
||||
|
||||
accQuantity -= invTransaction.quantity;
|
||||
accCost -= accCost;
|
||||
|
||||
transactions.push({
|
||||
...commonEntry,
|
||||
income,
|
||||
cost,
|
||||
incomeAccount: invTransaction.item.sellAccountId,
|
||||
costAccount: invTransaction.item.costAccountId,
|
||||
inventoryAccount: invTransaction.item.inventoryAccountId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
});
|
||||
this.journalCommands.inventoryEntries(transactions);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
|
||||
export default class InventoryCostLotTracker {
|
||||
|
||||
recalcInventoryLotsCost(inventoryTransactions) {
|
||||
|
||||
}
|
||||
|
||||
deleteTransactionsFromDate(fromDate) {
|
||||
|
||||
}
|
||||
}
|
||||
318
server/src/services/Inventory/InventoryCostLotTracker.ts
Normal file
318
server/src/services/Inventory/InventoryCostLotTracker.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
import { omit, pick, chain } from 'lodash';
|
||||
import uniqid from 'uniqid';
|
||||
import {
|
||||
InventoryTransaction,
|
||||
InventoryLotCostTracker,
|
||||
Account,
|
||||
Item,
|
||||
} from "@/models";
|
||||
import { IInventoryLotCost, IInventoryTransaction } from "@/interfaces";
|
||||
import JournalPoster from '@/services/Accounting/JournalPoster';
|
||||
import JournalCommands from '@/services/Accounting/JournalCommands';
|
||||
|
||||
type TCostMethod = 'FIFO' | 'LIFO';
|
||||
|
||||
export default class InventoryCostLotTracker implements IInventoryCostMethod {
|
||||
journal: JournalPoster;
|
||||
journalCommands: JournalCommands;
|
||||
startingDate: Date;
|
||||
headDate: Date;
|
||||
itemId: number;
|
||||
costMethod: TCostMethod;
|
||||
itemsById: Map<number, any>;
|
||||
|
||||
/**
|
||||
* Constructor method.
|
||||
* @param {Date} startingDate -
|
||||
* @param {number} itemId -
|
||||
* @param {string} costMethod -
|
||||
*/
|
||||
constructor(startingDate: Date, itemId: number, costMethod: TCostMethod = 'FIFO') {
|
||||
this.startingDate = startingDate;
|
||||
this.itemId = itemId;
|
||||
this.costMethod = costMethod;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the inventory average cost method.
|
||||
* @async
|
||||
*/
|
||||
public async initialize() {
|
||||
const accountsDepGraph = await Account.tenant().depGraph().query();
|
||||
this.journal = new JournalPoster(accountsDepGraph);
|
||||
this.journalCommands = new JournalCommands(this.journal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes items costs from the given date using FIFO or LIFO cost method.
|
||||
* --------
|
||||
* - Revert the inventory lots after the given date.
|
||||
* - Remove all the journal entries from the inventory transactions
|
||||
* after the given date.
|
||||
* - Re-tracking the inventory lots from inventory transactions.
|
||||
* - Re-write the journal entries from the given inventory transactions.
|
||||
* @async
|
||||
* @return {void}
|
||||
*/
|
||||
public async computeItemCost(): Promise<any> {
|
||||
await this.revertInventoryLots(this.startingDate);
|
||||
|
||||
const afterInvTransactions: IInventoryTransaction[] =
|
||||
await InventoryTransaction.tenant()
|
||||
.query()
|
||||
.where('date', '>=', this.startingDate)
|
||||
.orderBy('date', 'ASC')
|
||||
.where('item_id', this.itemId)
|
||||
.withGraphFetched('item');
|
||||
|
||||
const availiableINLots: IInventoryLotCost[] =
|
||||
await InventoryLotCostTracker.tenant()
|
||||
.query()
|
||||
.where('date', '<', this.startingDate)
|
||||
.orderBy('date', 'ASC')
|
||||
.where('item_id', this.itemId)
|
||||
.where('direction', 'IN')
|
||||
.whereNot('remaining', 0);
|
||||
|
||||
const merged = [
|
||||
...availiableINLots.map((trans) => ({ lotTransId: trans.id, ...trans })),
|
||||
...afterInvTransactions.map((trans) => ({ invTransId: trans.id, ...trans })),
|
||||
];
|
||||
const itemsIds = chain(merged).map(e => e.itemId).uniq().value();
|
||||
|
||||
const storedItems = await Item.tenant()
|
||||
.query()
|
||||
.where('type', 'inventory')
|
||||
.whereIn('id', itemsIds);
|
||||
|
||||
this.itemsById = new Map(storedItems.map((item: any) => [item.id, item]));
|
||||
|
||||
// Re-tracking the inventory `IN` and `OUT` lots costs.
|
||||
const trackedInvLotsCosts = this.trackingInventoryLotsCost(merged);
|
||||
const storedTrackedInvLotsOper = this.storeInventoryLotsCost(trackedInvLotsCosts);
|
||||
|
||||
// Remove and revert accounts balance journal entries from inventory transactions.
|
||||
const revertJEntriesOper = this.revertJournalEntries(afterInvTransactions);
|
||||
|
||||
// Records the journal entries operation.
|
||||
this.recordJournalEntries(trackedInvLotsCosts);
|
||||
|
||||
return Promise.all([
|
||||
storedTrackedInvLotsOper,
|
||||
revertJEntriesOper.then(() =>
|
||||
Promise.all([
|
||||
// Saves the new recorded journal entries to the storage.
|
||||
this.journal.deleteEntries(),
|
||||
this.journal.saveEntries(),
|
||||
this.journal.saveBalance(),
|
||||
])),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revert the inventory lots to the given date by removing the inventory lots
|
||||
* transactions after the given date and increment the remaining that
|
||||
* associate to lot number.
|
||||
* @async
|
||||
* @return {Promise}
|
||||
*/
|
||||
public async revertInventoryLots(startingDate: Date) {
|
||||
const asyncOpers: any[] = [];
|
||||
const inventoryLotsTrans = await InventoryLotCostTracker.tenant()
|
||||
.query()
|
||||
.orderBy('date', 'DESC')
|
||||
.where('item_id', this.itemId)
|
||||
.where('date', '>=', startingDate)
|
||||
.where('direction', 'OUT');
|
||||
|
||||
const deleteInvLotsTrans = InventoryLotCostTracker.tenant()
|
||||
.query()
|
||||
.where('date', '>=', startingDate)
|
||||
.where('item_id', this.itemId)
|
||||
.delete();
|
||||
|
||||
inventoryLotsTrans.forEach((inventoryLot: IInventoryLotCost) => {
|
||||
if (!inventoryLot.lotNumber) { return; }
|
||||
|
||||
const incrementOper = InventoryLotCostTracker.tenant()
|
||||
.query()
|
||||
.where('lot_number', inventoryLot.lotNumber)
|
||||
.where('direction', 'IN')
|
||||
.increment('remaining', inventoryLot.quantity);
|
||||
|
||||
asyncOpers.push(incrementOper);
|
||||
});
|
||||
return Promise.all([deleteInvLotsTrans, ...asyncOpers]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverts the journal entries from inventory lots costs transaction.
|
||||
* @param {} inventoryLots
|
||||
*/
|
||||
async revertJournalEntries(
|
||||
inventoryLots: IInventoryLotCost[],
|
||||
) {
|
||||
const invoiceTransactions = inventoryLots
|
||||
.filter(e => e.transactionType === 'SaleInvoice');
|
||||
|
||||
return this.journalCommands
|
||||
.revertEntriesFromInventoryTransactions(invoiceTransactions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the journal entries transactions.
|
||||
* @async
|
||||
* @param {IInventoryLotCost[]} inventoryTransactions -
|
||||
* @param {string} referenceType -
|
||||
* @param {number} referenceId -
|
||||
* @param {Date} date -
|
||||
* @return {Promise}
|
||||
*/
|
||||
public recordJournalEntries(
|
||||
inventoryLots: IInventoryLotCost[],
|
||||
): void {
|
||||
const outTransactions: any[] = [];
|
||||
const inTransByLotNumber: any = {};
|
||||
const transactions: any = [];
|
||||
|
||||
inventoryLots.forEach((invTransaction: IInventoryLotCost) => {
|
||||
switch(invTransaction.direction) {
|
||||
case 'IN':
|
||||
inTransByLotNumber[invTransaction.lotNumber] = invTransaction;
|
||||
break;
|
||||
case 'OUT':
|
||||
outTransactions.push(invTransaction);
|
||||
break;
|
||||
}
|
||||
});
|
||||
outTransactions.forEach((outTransaction: IInventoryLotCost) => {
|
||||
const { lotNumber, quantity, rate, itemId } = outTransaction;
|
||||
const income = quantity * rate;
|
||||
const item = this.itemsById.get(itemId);
|
||||
|
||||
const transaction = {
|
||||
date: outTransaction.date,
|
||||
referenceType: outTransaction.transactionType,
|
||||
referenceId: outTransaction.transactionId,
|
||||
cost: 0,
|
||||
income,
|
||||
incomeAccount: item.sellAccountId,
|
||||
costAccount: item.costAccountId,
|
||||
inventoryAccount: item.inventoryAccountId,
|
||||
};
|
||||
if (lotNumber && inTransByLotNumber[lotNumber]) {
|
||||
const inInvTrans = inTransByLotNumber[lotNumber];
|
||||
transaction.cost = (outTransaction.quantity * inInvTrans.rate);
|
||||
}
|
||||
transactions.push(transaction);
|
||||
});
|
||||
this.journalCommands.inventoryEntries(transactions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the inventory lots costs transactions in bulk.
|
||||
* @param {IInventoryLotCost[]} costLotsTransactions
|
||||
* @return {Promise[]}
|
||||
*/
|
||||
storeInventoryLotsCost(costLotsTransactions: IInventoryLotCost[]): Promise<object> {
|
||||
const opers: any = [];
|
||||
|
||||
costLotsTransactions.forEach((transaction: IInventoryLotCost) => {
|
||||
if (transaction.lotTransId && transaction.decrement) {
|
||||
const decrementOper = InventoryLotCostTracker.tenant()
|
||||
.query()
|
||||
.where('id', transaction.lotTransId)
|
||||
.decrement('remaining', transaction.decrement);
|
||||
opers.push(decrementOper);
|
||||
} else if(!transaction.lotTransId) {
|
||||
const operation = InventoryLotCostTracker.tenant().query()
|
||||
.insert({
|
||||
...omit(transaction, ['decrement', 'invTransId', 'lotTransId']),
|
||||
});
|
||||
opers.push(operation);
|
||||
}
|
||||
});
|
||||
return Promise.all(opers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracking the given inventory transactions to lots costs transactions.
|
||||
* @param {IInventoryTransaction[]} inventoryTransactions - Inventory transactions.
|
||||
* @return {IInventoryLotCost[]}
|
||||
*/
|
||||
public trackingInventoryLotsCost(
|
||||
inventoryTransactions: IInventoryTransaction[],
|
||||
) : IInventoryLotCost {
|
||||
// Collect cost lots transactions to insert them to the storage in bulk.
|
||||
const costLotsTransactions: IInventoryLotCost[] = [];
|
||||
// Collect inventory transactions by item id.
|
||||
const inventoryByItem: any = {};
|
||||
// Collection `IN` inventory tranaction by transaction id.
|
||||
const inventoryINTrans: any = {};
|
||||
|
||||
inventoryTransactions.forEach((transaction: IInventoryTransaction) => {
|
||||
const { itemId, id } = transaction;
|
||||
(inventoryByItem[itemId] || (inventoryByItem[itemId] = []));
|
||||
|
||||
const commonLotTransaction: IInventoryLotCost = {
|
||||
...pick(transaction, [
|
||||
'date', 'rate', 'itemId', 'quantity', 'invTransId', 'lotTransId',
|
||||
'direction', 'transactionType', 'transactionId', 'lotNumber', 'remaining'
|
||||
]),
|
||||
};
|
||||
// Record inventory `IN` cost lot transaction.
|
||||
if (transaction.direction === 'IN') {
|
||||
inventoryByItem[itemId].push(id);
|
||||
inventoryINTrans[id] = {
|
||||
...commonLotTransaction,
|
||||
decrement: 0,
|
||||
remaining: commonLotTransaction.remaining || commonLotTransaction.quantity,
|
||||
lotNumber: commonLotTransaction.lotNumber || uniqid.time(),
|
||||
};
|
||||
costLotsTransactions.push(inventoryINTrans[id]);
|
||||
|
||||
// Record inventory 'OUT' cost lots from 'IN' transactions.
|
||||
} else if (transaction.direction === 'OUT') {
|
||||
let invRemaining = transaction.quantity;
|
||||
|
||||
inventoryByItem?.[itemId]?.some((
|
||||
_invTransactionId: number,
|
||||
index: number,
|
||||
) => {
|
||||
const _invINTransaction = inventoryINTrans[_invTransactionId];
|
||||
if (invRemaining <= 0) { return true; }
|
||||
|
||||
// Detarmines the 'OUT' lot tranasctions whether bigger than 'IN' remaining transaction.
|
||||
const biggerThanRemaining = (_invINTransaction.remaining - transaction.quantity) > 0;
|
||||
const decrement = (biggerThanRemaining) ? transaction.quantity : _invINTransaction.remaining;
|
||||
|
||||
_invINTransaction.decrement += decrement;
|
||||
_invINTransaction.remaining = Math.max(
|
||||
_invINTransaction.remaining - decrement,
|
||||
0,
|
||||
);
|
||||
invRemaining = Math.max(invRemaining - decrement, 0);
|
||||
|
||||
costLotsTransactions.push({
|
||||
...commonLotTransaction,
|
||||
quantity: decrement,
|
||||
lotNumber: _invINTransaction.lotNumber,
|
||||
});
|
||||
// Pop the 'IN' lots that has zero remaining.
|
||||
if (_invINTransaction.remaining === 0) {
|
||||
inventoryByItem?.[itemId].splice(index, 1);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (invRemaining > 0) {
|
||||
costLotsTransactions.push({
|
||||
...commonLotTransaction,
|
||||
quantity: invRemaining,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
return costLotsTransactions;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -3,6 +3,44 @@ import { Item } from '@/models';
|
||||
|
||||
export default class ItemsService {
|
||||
|
||||
static async newItem(item) {
|
||||
const storedItem = await Item.tenant()
|
||||
.query()
|
||||
.insertAndFetch({
|
||||
...item,
|
||||
});
|
||||
return storedItem;
|
||||
}
|
||||
|
||||
static async editItem(item, itemId) {
|
||||
const updateItem = await Item.tenant()
|
||||
.query()
|
||||
.findById(itemId)
|
||||
.patch({
|
||||
...item,
|
||||
});
|
||||
return updateItem;
|
||||
}
|
||||
|
||||
static async deleteItem(itemId) {
|
||||
return Item.tenant()
|
||||
.query()
|
||||
.findById(itemId)
|
||||
.delete();
|
||||
}
|
||||
|
||||
static async getItemWithMetadata(itemId) {
|
||||
return Item.tenant()
|
||||
.query()
|
||||
.findById(itemId)
|
||||
.withGraphFetched(
|
||||
'costAccount',
|
||||
'sellAccount',
|
||||
'inventoryAccount',
|
||||
'category'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the given items IDs exists or not returns the not found ones.
|
||||
* @param {Array} itemsIDs
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { omit, sumBy } from 'lodash';
|
||||
import moment from 'moment';
|
||||
import { Container } from 'typedi';
|
||||
import {
|
||||
Account,
|
||||
Bill,
|
||||
@@ -22,7 +23,7 @@ import HasItemsEntries from '@/services/Sales/HasItemsEntries';
|
||||
export default class BillsService {
|
||||
/**
|
||||
* Creates a new bill and stored it to the storage.
|
||||
*
|
||||
*|
|
||||
* Precedures.
|
||||
* ----
|
||||
* - Insert bill transactions to the storage.
|
||||
@@ -30,11 +31,13 @@ export default class BillsService {
|
||||
* - Increment the given vendor id.
|
||||
* - Record bill journal transactions on the given accounts.
|
||||
* - Record bill items inventory transactions.
|
||||
*
|
||||
* ----
|
||||
* @param {IBill} bill -
|
||||
* @return {void}
|
||||
*/
|
||||
static async createBill(bill) {
|
||||
const agenda = Container.get('agenda');
|
||||
|
||||
const amount = sumBy(bill.entries, 'amount');
|
||||
const saveEntriesOpers = [];
|
||||
|
||||
@@ -57,20 +60,37 @@ export default class BillsService {
|
||||
// Increments vendor balance.
|
||||
const incrementOper = Vendor.changeBalance(bill.vendor_id, amount);
|
||||
|
||||
// // Rewrite the inventory transactions for inventory items.
|
||||
// const writeInvTransactionsOper = InventoryService.recordInventoryTransactions(
|
||||
// bill.entries, 'Bill', billId,
|
||||
// );
|
||||
// Rewrite the inventory transactions for inventory items.
|
||||
const writeInvTransactionsOper = InventoryService.recordInventoryTransactions(
|
||||
bill.entries, bill.bill_date, 'Bill', storedBill.id, 'IN',
|
||||
);
|
||||
// Writes the journal entries for the given bill transaction.
|
||||
const writeJEntriesOper = this.recordJournalTransactions({
|
||||
id: storedBill.id,
|
||||
...bill
|
||||
});
|
||||
await Promise.all([
|
||||
...saveEntriesOpers,
|
||||
incrementOper,
|
||||
// this.recordInventoryTransactions(bill, storedBill.id),
|
||||
this.recordJournalTransactions({ ...bill, id: storedBill.id }),
|
||||
// writeInvTransactionsOper,
|
||||
]);
|
||||
incrementOper,
|
||||
writeInvTransactionsOper,
|
||||
writeJEntriesOper,
|
||||
]);
|
||||
|
||||
// Schedule bill re-compute based on the item cost
|
||||
// method and starting date.
|
||||
await this.scheduleComputeItemsCost(bill);
|
||||
|
||||
return storedBill;
|
||||
}
|
||||
|
||||
scheduleComputeItemCost(bill) {
|
||||
return agenda.schedule('in 1 second', 'compute-item-cost', {
|
||||
startingDate: bill.bill_date || bill.billDate,
|
||||
itemId: bill.entries[0].item_id || bill.entries[0].itemId,
|
||||
costMethod: 'FIFO',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits details of the given bill id with associated entries.
|
||||
*
|
||||
@@ -116,21 +136,31 @@ export default class BillsService {
|
||||
amount,
|
||||
oldBill.amount,
|
||||
);
|
||||
// // Deletes the old inventory transactions.
|
||||
// const deleteInvTransactionsOper = InventorySevice.deleteInventoryTransactions(
|
||||
// billId, 'Bill',
|
||||
// );
|
||||
// // Re-write the inventory transactions for inventory items.
|
||||
// const writeInvTransactionsOper = InventoryService.recordInventoryTransactions(
|
||||
// bill.entries, 'Bill', billId,
|
||||
// );
|
||||
// Re-write the inventory transactions for inventory items.
|
||||
const writeInvTransactionsOper = InventoryService.recordInventoryTransactions(
|
||||
bill.entries, bill.bill_date, 'Bill', billId, 'IN'
|
||||
);
|
||||
// Delete bill associated inventory transactions.
|
||||
const deleteInventoryTransOper = InventoryService.deleteInventoryTransactions(
|
||||
billId, 'Bill'
|
||||
);
|
||||
// Writes the journal entries for the given bill transaction.
|
||||
const writeJEntriesOper = this.recordJournalTransactions({
|
||||
id: billId,
|
||||
...bill,
|
||||
}, billId);
|
||||
|
||||
await Promise.all([
|
||||
patchEntriesOper,
|
||||
recordTransactionsOper,
|
||||
changeVendorBalanceOper,
|
||||
// deleteInvTransactionsOper,
|
||||
// writeInvTransactionsOper,
|
||||
writeInvTransactionsOper,
|
||||
deleteInventoryTransOper,
|
||||
writeJEntriesOper,
|
||||
]);
|
||||
|
||||
// Schedule sale invoice re-compute based on the item cost
|
||||
// method and starting date.
|
||||
await this.scheduleComputeItemsCost(bill);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -149,19 +179,15 @@ export default class BillsService {
|
||||
.whereIn('id', entriesItemsIds);
|
||||
|
||||
const storedItemsMap = new Map(storedItems.map((item) => [item.id, item]));
|
||||
const payableAccount = await AccountsService.getAccountByType(
|
||||
'accounts_payable'
|
||||
);
|
||||
if (!payableAccount) {
|
||||
throw new Error('New payable account on the storage.');
|
||||
}
|
||||
const payableAccount = await AccountsService.getAccountByType('accounts_payable');
|
||||
|
||||
const accountsDepGraph = await Account.tenant().depGraph().query();
|
||||
const journal = new JournalPoster(accountsDepGraph);
|
||||
|
||||
const commonJournalMeta = {
|
||||
debit: 0,
|
||||
credit: 0,
|
||||
referenceId: billId,
|
||||
referenceId: bill.id,
|
||||
referenceType: 'Bill',
|
||||
date: formattedDate,
|
||||
accural: true,
|
||||
@@ -198,7 +224,7 @@ export default class BillsService {
|
||||
});
|
||||
journal.debit(debitEntry);
|
||||
});
|
||||
await Promise.all([
|
||||
return Promise.all([
|
||||
journal.deleteEntries(),
|
||||
journal.saveEntries(),
|
||||
journal.saveBalance(),
|
||||
@@ -211,7 +237,10 @@ export default class BillsService {
|
||||
* @return {void}
|
||||
*/
|
||||
static async deleteBill(billId) {
|
||||
const bill = await Bill.tenant().query().where('id', billId).first();
|
||||
const bill = await Bill.tenant().query()
|
||||
.where('id', billId)
|
||||
.withGraphFetched('entries')
|
||||
.first();
|
||||
|
||||
// Delete all associated bill entries.
|
||||
const deleteBillEntriesOper = ItemEntry.tenant()
|
||||
@@ -242,6 +271,9 @@ export default class BillsService {
|
||||
deleteInventoryTransOper,
|
||||
revertVendorBalance,
|
||||
]);
|
||||
// Schedule sale invoice re-compute based on the item cost
|
||||
// method and starting date.
|
||||
await this.scheduleComputeItemsCost(bill);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -284,7 +316,6 @@ export default class BillsService {
|
||||
return Bill.tenant().query().where('id', billId).first();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Retrieve the given bill details with associated items entries.
|
||||
* @param {Integer} billId -
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { omit, difference, sumBy, mixin } from 'lodash';
|
||||
import moment from 'moment';
|
||||
import { SaleEstimate, ItemEntry } from '@/models';
|
||||
import HasItemsEntries from '@/services/Sales/HasItemsEntries';
|
||||
|
||||
@@ -11,6 +12,7 @@ export default class SaleEstimateService {
|
||||
*/
|
||||
static async createEstimate(estimate: any) {
|
||||
const amount = sumBy(estimate.entries, 'amount');
|
||||
|
||||
const storedEstimate = await SaleEstimate.tenant()
|
||||
.query()
|
||||
.insert({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { omit, sumBy, difference, chain, sum } from 'lodash';
|
||||
import { omit, sumBy, difference } from 'lodash';
|
||||
import { Container } from 'typedi';
|
||||
import {
|
||||
SaleInvoice,
|
||||
AccountTransaction,
|
||||
@@ -10,7 +11,7 @@ import {
|
||||
import JournalPoster from '@/services/Accounting/JournalPoster';
|
||||
import HasItemsEntries from '@/services/Sales/HasItemsEntries';
|
||||
import CustomerRepository from '@/repositories/CustomerRepository';
|
||||
import moment from 'moment';
|
||||
import InventoryService from '@/services/Inventory/Inventory';
|
||||
|
||||
/**
|
||||
* Sales invoices service
|
||||
@@ -51,8 +52,8 @@ export default class SaleInvoicesService {
|
||||
balance,
|
||||
);
|
||||
// Records the inventory transactions for inventory items.
|
||||
const recordInventoryTransOpers = this.recordInventoryTransactions(
|
||||
saleInvoice, storedInvoice.id
|
||||
const recordInventoryTransOpers = InventoryService.recordInventoryTransactions(
|
||||
saleInvoice.entries, saleInvoice.invoice_date, 'SaleInvoice', storedInvoice.id, 'OUT',
|
||||
);
|
||||
// Await all async operations.
|
||||
await Promise.all([
|
||||
@@ -60,19 +61,13 @@ export default class SaleInvoicesService {
|
||||
incrementOper,
|
||||
recordInventoryTransOpers,
|
||||
]);
|
||||
// Schedule sale invoice re-compute based on the item cost
|
||||
// method and starting date.
|
||||
await this.scheduleComputeItemsCost(saleInvoice);
|
||||
|
||||
return storedInvoice;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the inventory items transactions.
|
||||
* @param {SaleInvoice} saleInvoice -
|
||||
* @param {number} saleInvoiceId -
|
||||
* @return {Promise}
|
||||
*/
|
||||
static async recordInventoryTransactions(saleInvoice, saleInvoiceId) {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the sale invoice journal entries and calculate the items cost
|
||||
* based on the given cost method in the options FIFO, LIFO or average cost rate.
|
||||
@@ -84,6 +79,23 @@ export default class SaleInvoicesService {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule sale invoice re-compute based on the item
|
||||
* cost method and starting date
|
||||
*
|
||||
* @param saleInvoice
|
||||
* @return {Promise<Agenda>}
|
||||
*/
|
||||
static scheduleComputeItemsCost(saleInvoice) {
|
||||
const agenda = Container.get('agenda');
|
||||
|
||||
return agenda.schedule('in 1 second', 'compute-item-cost', {
|
||||
startingDate: saleInvoice.invoice_date || saleInvoice.invoiceDate,
|
||||
itemId: saleInvoice.entries[0].item_id || saleInvoice.entries[0].itemId,
|
||||
costMethod: 'FIFO',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit the given sale invoice.
|
||||
* @async
|
||||
@@ -124,12 +136,10 @@ export default class SaleInvoicesService {
|
||||
patchItemsEntriesOper,
|
||||
changeCustomerBalanceOper,
|
||||
]);
|
||||
}
|
||||
|
||||
async recalcInventoryTransactionsCost(inventoryTransactions: array) {
|
||||
const inventoryTransactionsMap = this.mapInventoryTransByItem(inventoryTransactions);
|
||||
|
||||
|
||||
// Schedule sale invoice re-compute based on the item cost
|
||||
// method and starting date.
|
||||
await this.scheduleComputeItemsCost(saleInvoice);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,7 +148,7 @@ export default class SaleInvoicesService {
|
||||
* @param {number} transactionId
|
||||
*/
|
||||
static async revertInventoryTransactions(inventoryTransactions: array) {
|
||||
const opers = [];
|
||||
const opers: Promise<[]>[] = [];
|
||||
|
||||
inventoryTransactions.forEach((trans: any) => {
|
||||
switch(trans.direction) {
|
||||
@@ -175,7 +185,9 @@ export default class SaleInvoicesService {
|
||||
* @param {Number} saleInvoiceId
|
||||
*/
|
||||
static async deleteSaleInvoice(saleInvoiceId: number) {
|
||||
const oldSaleInvoice = await SaleInvoice.tenant().query().findById(saleInvoiceId);
|
||||
const oldSaleInvoice = await SaleInvoice.tenant().query()
|
||||
.findById(saleInvoiceId)
|
||||
.withGraphFetched('entries');
|
||||
|
||||
await SaleInvoice.tenant().query().where('id', saleInvoiceId).delete();
|
||||
await ItemEntry.tenant()
|
||||
@@ -206,14 +218,20 @@ export default class SaleInvoicesService {
|
||||
.where('transaction_id', saleInvoiceId);
|
||||
|
||||
// Revert inventory transactions.
|
||||
const revertInventoryTransactionsOper = this.revertInventoryTransactions(inventoryTransactions);
|
||||
|
||||
const revertInventoryTransactionsOper = this.revertInventoryTransactions(
|
||||
inventoryTransactions
|
||||
);
|
||||
|
||||
// Await all async operations.
|
||||
await Promise.all([
|
||||
journal.deleteEntries(),
|
||||
journal.saveBalance(),
|
||||
revertCustomerBalanceOper,
|
||||
revertInventoryTransactionsOper,
|
||||
]);
|
||||
// Schedule sale invoice re-compute based on the item cost
|
||||
// method and starting date.
|
||||
await this.scheduleComputeItemsCost(oldSaleInvoice)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -261,7 +279,7 @@ export default class SaleInvoicesService {
|
||||
static async isSaleInvoiceNumberExists(saleInvoiceNumber: string|number, saleInvoiceId: number) {
|
||||
const foundSaleInvoice = await SaleInvoice.tenant()
|
||||
.query()
|
||||
.onBuild((query) => {
|
||||
.onBuild((query: any) => {
|
||||
query.where('invoice_no', saleInvoiceNumber);
|
||||
|
||||
if (saleInvoiceId) {
|
||||
@@ -269,7 +287,7 @@ export default class SaleInvoicesService {
|
||||
}
|
||||
return query;
|
||||
});
|
||||
return foundSaleInvoice.length !== 0;
|
||||
return (foundSaleInvoice.length !== 0);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -280,7 +298,7 @@ export default class SaleInvoicesService {
|
||||
static async isInvoicesExist(invoicesIds: Array<number>) {
|
||||
const storedInvoices = await SaleInvoice.tenant()
|
||||
.query()
|
||||
.onBuild((builder) => {
|
||||
.onBuild((builder: any) => {
|
||||
builder.whereIn('id', invoicesIds);
|
||||
return builder;
|
||||
});
|
||||
|
||||
9
server/src/subscribers/events.ts
Normal file
9
server/src/subscribers/events.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
|
||||
|
||||
export default {
|
||||
auth: {
|
||||
login: 'onLogin',
|
||||
register: 'onRegister',
|
||||
resetPassword: 'onResetPassword',
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user