From d423365a19fb33ce8b83e475bd5b13289775bc1d Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Tue, 18 Aug 2020 02:28:08 +0200 Subject: [PATCH] feat: Re-compute the given items cost job. feat: Optimize the architecture. --- server/config/config.js | 24 +- server/package.json | 8 +- server/src/app.js | 35 -- server/src/database/knex.js | 12 +- ...251_create_inventory_transactions_table.js | 6 +- ...create_inventory_cost_lot_tracker_table.js | 4 +- server/src/database/objection.js | 5 +- server/src/database/seeds/seed_resources.js | 1 - server/src/decorators/eventDispatcher.ts | 16 + server/src/http/controllers/Agendash.ts | 25 + server/src/http/controllers/Items.js | 512 ------------------ server/src/http/controllers/Items.ts | 409 ++++++++++++++ server/src/http/controllers/Ping.ts | 46 +- server/src/http/index.js | 10 +- server/src/interfaces/InventoryCostMethod.ts | 6 + server/src/interfaces/InventoryTransaction.ts | 1 + server/src/interfaces/index.ts | 11 + server/src/jobs/ComputeItemCost.ts | 12 + server/src/jobs/welcomeEmail.ts | 11 + server/src/loaders/agenda.ts | 11 + server/src/loaders/dependencyInjector.ts | 20 + server/src/loaders/events.ts | 0 server/src/loaders/express.ts | 21 + server/src/loaders/index.ts | 32 ++ server/src/loaders/jobs.ts | 17 + server/src/loaders/mongoose.ts | 11 + server/src/models/InventoryCostLotTracker.js | 36 ++ server/src/models/InventoryLotCostTracker.js | 18 - server/src/models/InventoryTransaction.js | 18 + server/src/models/View.js | 2 + server/src/models/index.js | 2 + server/src/server.js | 37 +- .../services/Accounting/JournalCommands.ts | 135 +++++ .../src/services/Accounting/JournalPoster.js | 11 + .../src/services/Accounts/AccountsService.js | 2 - server/src/services/Inventory/Inventory.ts | 126 ++--- .../Inventory/InventoryAverageCost.ts | 207 +++++++ .../Inventory/InventoryCostLotTracker.js | 11 - .../Inventory/InventoryCostLotTracker.ts | 318 +++++++++++ server/src/services/Items/ItemsService.js | 38 ++ server/src/services/Purchases/Bills.js | 95 ++-- server/src/services/Sales/SalesEstimate.ts | 2 + server/src/services/Sales/SalesInvoices.ts | 70 ++- server/src/subscribers/events.ts | 9 + 44 files changed, 1605 insertions(+), 798 deletions(-) delete mode 100644 server/src/app.js create mode 100644 server/src/decorators/eventDispatcher.ts create mode 100644 server/src/http/controllers/Agendash.ts delete mode 100644 server/src/http/controllers/Items.js create mode 100644 server/src/http/controllers/Items.ts create mode 100644 server/src/interfaces/InventoryCostMethod.ts create mode 100644 server/src/interfaces/index.ts create mode 100644 server/src/jobs/ComputeItemCost.ts create mode 100644 server/src/jobs/welcomeEmail.ts create mode 100644 server/src/loaders/agenda.ts create mode 100644 server/src/loaders/dependencyInjector.ts create mode 100644 server/src/loaders/events.ts create mode 100644 server/src/loaders/express.ts create mode 100644 server/src/loaders/index.ts create mode 100644 server/src/loaders/jobs.ts create mode 100644 server/src/loaders/mongoose.ts create mode 100644 server/src/models/InventoryCostLotTracker.js delete mode 100644 server/src/models/InventoryLotCostTracker.js create mode 100644 server/src/services/Accounting/JournalCommands.ts create mode 100644 server/src/services/Inventory/InventoryAverageCost.ts delete mode 100644 server/src/services/Inventory/InventoryCostLotTracker.js create mode 100644 server/src/services/Inventory/InventoryCostLotTracker.ts create mode 100644 server/src/subscribers/events.ts diff --git a/server/config/config.js b/server/config/config.js index 59eabc410..48260db25 100644 --- a/server/config/config.js +++ b/server/config/config.js @@ -28,5 +28,27 @@ module.exports = { secure: false, username: '842f331d3dc005', password: '172f97b34f1a17', - } + }, + mongoDb: { + /** + * That long string from mlab + */ + databaseURL: 'mongodb://localhost/bigcapital', + }, + /** + * Agenda.js stuff + */ + agenda: { + dbCollection: process.env.AGENDA_DB_COLLECTION, + pooltime: process.env.AGENDA_POOL_TIME, + concurrency: parseInt(process.env.AGENDA_CONCURRENCY, 10), + }, + + /** + * Agendash config + */ + agendash: { + user: 'agendash', + password: '123456' + }, }; diff --git a/server/package.json b/server/package.json index 2641e42a1..48ed24091 100644 --- a/server/package.json +++ b/server/package.json @@ -1,5 +1,5 @@ { - "name": "moosher-server", + "name": "bigcapital-server", "version": "1.0.0", "description": "", "main": "index.js", @@ -18,6 +18,8 @@ }, "dependencies": { "@hapi/boom": "^7.4.3", + "agenda": "^3.1.0", + "agendash": "^1.0.0", "app-root-path": "^3.0.0", "bcryptjs": "^2.4.3", "bookshelf": "^0.15.1", @@ -28,7 +30,9 @@ "csurf": "^1.10.0", "dotenv": "^8.1.0", "errorhandler": "^1.5.1", + "event-dispatch": "^0.4.1", "express": "^4.17.1", + "express-basic-auth": "^1.2.0", "express-boom": "^3.0.0", "express-fileupload": "^1.1.7-alpha.3", "express-oauth-server": "^2.0.0", @@ -43,6 +47,7 @@ "memory-cache": "^0.2.0", "moment": "^2.24.0", "moment-range": "^4.0.2", + "mongoose": "^5.10.0", "mustache": "^3.0.3", "mysql": "^2.17.1", "mysql2": "^1.6.5", @@ -83,6 +88,7 @@ "nyc": "^14.1.1", "sinon": "^7.4.2", "ts-loader": "^8.0.1", + "typedi": "^0.8.0", "typescript": "^3.9.7", "webpack": "^4.0.0", "webpack-cli": "^3.3.7", diff --git a/server/src/app.js b/server/src/app.js deleted file mode 100644 index 3a6c0aea5..000000000 --- a/server/src/app.js +++ /dev/null @@ -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; diff --git a/server/src/database/knex.js b/server/src/database/knex.js index 4ee9662e6..fb16d69cc 100644 --- a/server/src/database/knex.js +++ b/server/src/database/knex.js @@ -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 }), + }); +}; \ No newline at end of file diff --git a/server/src/database/migrations/20200722164251_create_inventory_transactions_table.js b/server/src/database/migrations/20200722164251_create_inventory_transactions_table.js index c004fe02f..ae1fa39b9 100644 --- a/server/src/database/migrations/20200722164251_create_inventory_transactions_table.js +++ b/server/src/database/migrations/20200722164251_create_inventory_transactions_table.js @@ -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'); diff --git a/server/src/database/migrations/20200810121807_create_inventory_cost_lot_tracker_table.js b/server/src/database/migrations/20200810121807_create_inventory_cost_lot_tracker_table.js index 676799164..26c72be2b 100644 --- a/server/src/database/migrations/20200810121807_create_inventory_cost_lot_tracker_table.js +++ b/server/src/database/migrations/20200810121807_create_inventory_cost_lot_tracker_table.js @@ -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'); diff --git a/server/src/database/objection.js b/server/src/database/objection.js index bb4a39eff..cf57361c4 100644 --- a/server/src/database/objection.js +++ b/server/src/database/objection.js @@ -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); +}; diff --git a/server/src/database/seeds/seed_resources.js b/server/src/database/seeds/seed_resources.js index fd086ae40..00d10878b 100644 --- a/server/src/database/seeds/seed_resources.js +++ b/server/src/database/seeds/seed_resources.js @@ -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' }, diff --git a/server/src/decorators/eventDispatcher.ts b/server/src/decorators/eventDispatcher.ts new file mode 100644 index 000000000..219a583aa --- /dev/null +++ b/server/src/decorators/eventDispatcher.ts @@ -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'; diff --git a/server/src/http/controllers/Agendash.ts b/server/src/http/controllers/Agendash.ts new file mode 100644 index 000000000..4365d3ae4 --- /dev/null +++ b/server/src/http/controllers/Agendash.ts @@ -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; + } +} diff --git a/server/src/http/controllers/Items.js b/server/src/http/controllers/Items.js deleted file mode 100644 index 4dc2e69db..000000000 --- a/server/src/http/controllers/Items.js +++ /dev/null @@ -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, - }, - }); - }, - }, -}; diff --git a/server/src/http/controllers/Items.ts b/server/src/http/controllers/Items.ts new file mode 100644 index 000000000..59bba9e49 --- /dev/null +++ b/server/src/http/controllers/Items.ts @@ -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, + } + } + : {}), + }, + }); + } +} \ No newline at end of file diff --git a/server/src/http/controllers/Ping.ts b/server/src/http/controllers/Ping.ts index 8bf3003bb..7f38cc579 100644 --- a/server/src/http/controllers/Ping.ts +++ b/server/src/http/controllers/Ping.ts @@ -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, + }); } } \ No newline at end of file diff --git a/server/src/http/index.js b/server/src/http/index.js index d78c5c37a..dbe0c44be 100644 --- a/server/src/http/index.js +++ b/server/src/http/index.js @@ -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); }; diff --git a/server/src/interfaces/InventoryCostMethod.ts b/server/src/interfaces/InventoryCostMethod.ts new file mode 100644 index 000000000..4804805f1 --- /dev/null +++ b/server/src/interfaces/InventoryCostMethod.ts @@ -0,0 +1,6 @@ + + +interface IInventoryCostMethod { + computeItemsCost(fromDate: Date): void, + initialize(): void, +} \ No newline at end of file diff --git a/server/src/interfaces/InventoryTransaction.ts b/server/src/interfaces/InventoryTransaction.ts index fd4a45822..5b1d23bc8 100644 --- a/server/src/interfaces/InventoryTransaction.ts +++ b/server/src/interfaces/InventoryTransaction.ts @@ -18,6 +18,7 @@ export interface IInventoryLotCost { itemId: number, rate: number, remaining: number, + lotNumber: string|number, transactionType: string, transactionId: string, } \ No newline at end of file diff --git a/server/src/interfaces/index.ts b/server/src/interfaces/index.ts new file mode 100644 index 000000000..03b016bcb --- /dev/null +++ b/server/src/interfaces/index.ts @@ -0,0 +1,11 @@ +import { IInventoryTransaction, IInventoryLotCost } from './InventoryTransaction'; +import { IBillPaymentEntry, IBillPayment } from './BillPayment'; +import { IInventoryCostMethod } from './IInventoryCostMethod'; + +export { + IBillPaymentEntry, + IBillPayment, + IInventoryTransaction, + IInventoryLotCost, + IInventoryCostMethod, +}; \ No newline at end of file diff --git a/server/src/jobs/ComputeItemCost.ts b/server/src/jobs/ComputeItemCost.ts new file mode 100644 index 000000000..c471fb48b --- /dev/null +++ b/server/src/jobs/ComputeItemCost.ts @@ -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 { + const Logger = Container.get('logger'); + const { startingDate, itemId, costMethod } = job.attrs.data; + + await InventoryService.computeItemCost(startingDate, itemId, costMethod); + done(); + } +} diff --git a/server/src/jobs/welcomeEmail.ts b/server/src/jobs/welcomeEmail.ts new file mode 100644 index 000000000..089824512 --- /dev/null +++ b/server/src/jobs/welcomeEmail.ts @@ -0,0 +1,11 @@ +import { Container } from 'typedi'; +import MailerService from '../services/mailer'; + +export default class WelcomeEmailJob { + public async handler(job, done: Function): Promise { + const Logger = Container.get('logger'); + + console.log('✌Email Sequence Job triggered!'); + done(); + } +} diff --git a/server/src/loaders/agenda.ts b/server/src/loaders/agenda.ts new file mode 100644 index 000000000..225008097 --- /dev/null +++ b/server/src/loaders/agenda.ts @@ -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, + }); +}; diff --git a/server/src/loaders/dependencyInjector.ts b/server/src/loaders/dependencyInjector.ts new file mode 100644 index 000000000..37d3e74b6 --- /dev/null +++ b/server/src/loaders/dependencyInjector.ts @@ -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; + } +}; diff --git a/server/src/loaders/events.ts b/server/src/loaders/events.ts new file mode 100644 index 000000000..e69de29bb diff --git a/server/src/loaders/express.ts b/server/src/loaders/express.ts new file mode 100644 index 000000000..2524dd4a7 --- /dev/null +++ b/server/src/loaders/express.ts @@ -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); +}; \ No newline at end of file diff --git a/server/src/loaders/index.ts b/server/src/loaders/index.ts new file mode 100644 index 000000000..2edf4305f --- /dev/null +++ b/server/src/loaders/index.ts @@ -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'); +}; diff --git a/server/src/loaders/jobs.ts b/server/src/loaders/jobs.ts new file mode 100644 index 000000000..dccd93bfb --- /dev/null +++ b/server/src/loaders/jobs.ts @@ -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(); +}; diff --git a/server/src/loaders/mongoose.ts b/server/src/loaders/mongoose.ts new file mode 100644 index 000000000..1bc2ea9ac --- /dev/null +++ b/server/src/loaders/mongoose.ts @@ -0,0 +1,11 @@ +import mongoose from 'mongoose'; +import { Db } from 'mongodb'; +import config from '@/../config/config'; + +export default async (): Promise => { + const connection = await mongoose.connect( + config.mongoDb.databaseURL, + { useNewUrlParser: true, useCreateIndex: true }, + ); + return connection.connection.db; +}; diff --git a/server/src/models/InventoryCostLotTracker.js b/server/src/models/InventoryCostLotTracker.js new file mode 100644 index 000000000..ca4fe007b --- /dev/null +++ b/server/src/models/InventoryCostLotTracker.js @@ -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', + }, + }, + }; + } +} diff --git a/server/src/models/InventoryLotCostTracker.js b/server/src/models/InventoryLotCostTracker.js deleted file mode 100644 index 3ead24f31..000000000 --- a/server/src/models/InventoryLotCostTracker.js +++ /dev/null @@ -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 []; - } -} diff --git a/server/src/models/InventoryTransaction.js b/server/src/models/InventoryTransaction.js index 56b19b8d0..3b1a7fc7d 100644 --- a/server/src/models/InventoryTransaction.js +++ b/server/src/models/InventoryTransaction.js @@ -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', + }, + }, + }; + } } diff --git a/server/src/models/View.js b/server/src/models/View.js index 5432e16b2..87d366a6c 100644 --- a/server/src/models/View.js +++ b/server/src/models/View.js @@ -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; } diff --git a/server/src/models/index.js b/server/src/models/index.js index e46b03571..ddd03d173 100644 --- a/server/src/models/index.js +++ b/server/src/models/index.js @@ -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, }; \ No newline at end of file diff --git a/server/src/server.js b/server/src/server.js index cd134a098..d4b8be0ec 100644 --- a/server/src/server.js +++ b/server/src/server.js @@ -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(); \ No newline at end of file diff --git a/server/src/services/Accounting/JournalCommands.ts b/server/src/services/Accounting/JournalCommands.ts new file mode 100644 index 000000000..ecac13c90 --- /dev/null +++ b/server/src/services/Accounting/JournalCommands.ts @@ -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; + } + }); + } +} \ No newline at end of file diff --git a/server/src/services/Accounting/JournalPoster.js b/server/src/services/Accounting/JournalPoster.js index fb0ad66b3..652573533 100644 --- a/server/src/services/Accounting/JournalPoster.js +++ b/server/src/services/Accounting/JournalPoster.js @@ -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. */ diff --git a/server/src/services/Accounts/AccountsService.js b/server/src/services/Accounts/AccountsService.js index 700a53be8..df082024f 100644 --- a/server/src/services/Accounts/AccountsService.js +++ b/server/src/services/Accounts/AccountsService.js @@ -17,8 +17,6 @@ export default class AccountsService { .where('account_type_id', accountType.id) .first(); - console.log(account); - return account; } } diff --git a/server/src/services/Inventory/Inventory.ts b/server/src/services/Inventory/Inventory.ts index 437a0f156..fe7e2290b 100644 --- a/server/src/services/Inventory/Inventory.ts +++ b/server/src/services/Inventory/Inventory.ts @@ -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; - } - } \ No newline at end of file diff --git a/server/src/services/Inventory/InventoryAverageCost.ts b/server/src/services/Inventory/InventoryAverageCost.ts new file mode 100644 index 000000000..586849192 --- /dev/null +++ b/server/src/services/Inventory/InventoryAverageCost.ts @@ -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 = 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 = 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); + } +} \ No newline at end of file diff --git a/server/src/services/Inventory/InventoryCostLotTracker.js b/server/src/services/Inventory/InventoryCostLotTracker.js deleted file mode 100644 index a689ccc82..000000000 --- a/server/src/services/Inventory/InventoryCostLotTracker.js +++ /dev/null @@ -1,11 +0,0 @@ - -export default class InventoryCostLotTracker { - - recalcInventoryLotsCost(inventoryTransactions) { - - } - - deleteTransactionsFromDate(fromDate) { - - } -} \ No newline at end of file diff --git a/server/src/services/Inventory/InventoryCostLotTracker.ts b/server/src/services/Inventory/InventoryCostLotTracker.ts new file mode 100644 index 000000000..4e64b3f40 --- /dev/null +++ b/server/src/services/Inventory/InventoryCostLotTracker.ts @@ -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; + + /** + * 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 { + 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 { + 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; + } + +} \ No newline at end of file diff --git a/server/src/services/Items/ItemsService.js b/server/src/services/Items/ItemsService.js index a63a39bae..19511a7be 100644 --- a/server/src/services/Items/ItemsService.js +++ b/server/src/services/Items/ItemsService.js @@ -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 diff --git a/server/src/services/Purchases/Bills.js b/server/src/services/Purchases/Bills.js index 086ff0986..58efb39ba 100644 --- a/server/src/services/Purchases/Bills.js +++ b/server/src/services/Purchases/Bills.js @@ -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 - diff --git a/server/src/services/Sales/SalesEstimate.ts b/server/src/services/Sales/SalesEstimate.ts index 61a4514a3..c74ab7691 100644 --- a/server/src/services/Sales/SalesEstimate.ts +++ b/server/src/services/Sales/SalesEstimate.ts @@ -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({ diff --git a/server/src/services/Sales/SalesInvoices.ts b/server/src/services/Sales/SalesInvoices.ts index 1a87f27ce..82461d5bc 100644 --- a/server/src/services/Sales/SalesInvoices.ts +++ b/server/src/services/Sales/SalesInvoices.ts @@ -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} + */ + 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) { const storedInvoices = await SaleInvoice.tenant() .query() - .onBuild((builder) => { + .onBuild((builder: any) => { builder.whereIn('id', invoicesIds); return builder; }); diff --git a/server/src/subscribers/events.ts b/server/src/subscribers/events.ts new file mode 100644 index 000000000..83c3035ff --- /dev/null +++ b/server/src/subscribers/events.ts @@ -0,0 +1,9 @@ + + +export default { + auth: { + login: 'onLogin', + register: 'onRegister', + resetPassword: 'onResetPassword', + }, +} \ No newline at end of file