From d423365a19fb33ce8b83e475bd5b13289775bc1d Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Tue, 18 Aug 2020 02:28:08 +0200 Subject: [PATCH 1/8] 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 From a202a21df5a3fc001f3a72d41ffa114e9004fd2a Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Tue, 18 Aug 2020 03:33:00 +0200 Subject: [PATCH 2/8] feat: Remove unnecessary files. --- common/models/AccountBalance.test.js | 0 common/models/Expense.test.js | 34 ---------------------------- common/models/Permission.test.js | 21 ----------------- common/models/ResourceField.test.js | 18 --------------- common/models/Role.test.js | 34 ---------------------------- 5 files changed, 107 deletions(-) delete mode 100644 common/models/AccountBalance.test.js delete mode 100644 common/models/Expense.test.js delete mode 100644 common/models/Permission.test.js delete mode 100644 common/models/ResourceField.test.js delete mode 100644 common/models/Role.test.js diff --git a/common/models/AccountBalance.test.js b/common/models/AccountBalance.test.js deleted file mode 100644 index e69de29bb..000000000 diff --git a/common/models/Expense.test.js b/common/models/Expense.test.js deleted file mode 100644 index 500654b18..000000000 --- a/common/models/Expense.test.js +++ /dev/null @@ -1,34 +0,0 @@ -import { create, expect } from '~/testInit'; -import Expense from '@/models/Expense'; -import factory from '../../src/database/factories'; - -describe('Model: Expense', () => { - describe('relations', () => { - it('Expense model may belongs to associated payment account.', async () => { - const expense = await factory.create('expense'); - - const expenseModel = await Expense.query().findById(expense.id); - const paymentAccountModel = await expenseModel.$relatedQuery('paymentAccount'); - - expect(paymentAccountModel.id).equals(expense.paymentAccountId); - }); - - it('Expense model may belongs to associated expense account.', async () => { - const expense = await factory.create('expense'); - - const expenseModel = await Expense.query().findById(expense.id); - const expenseAccountModel = await expenseModel.$relatedQuery('expenseAccount'); - - expect(expenseAccountModel.id).equals(expense.expenseAccountId); - }); - - it('Expense model may belongs to associated user model.', async () => { - const expense = await factory.create('expense'); - - const expenseModel = await Expense.query().findById(expense.id); - const expenseUserModel = await expenseModel.$relatedQuery('user'); - - expect(expenseUserModel.id).equals(expense.userId); - }); - }); -}); diff --git a/common/models/Permission.test.js b/common/models/Permission.test.js deleted file mode 100644 index f0e7f81ee..000000000 --- a/common/models/Permission.test.js +++ /dev/null @@ -1,21 +0,0 @@ -import { create } from '~/testInit'; -import Resource from '@/models/Resource'; -import '@/models/Role'; - -describe('Model: Permission', () => { - it('Permission model may has associated role.', async () => { - const roleHasPermissions = await create('role_has_permission'); - const resourceModel = await Resource.where('id', roleHasPermissions.resource_id).fetch(); - const roleModel = await resourceModel.role().fetch(); - - console.log(roleModel); - }); - - it('Permission model may has associated resource.', async () => { - const roleHasPermissions = await create('role_has_permission'); - const resourceModel = await Resource.where('id', roleHasPermissions.resource_id).fetch(); - const permissionModel = await resourceModel.permission().fetch(); - - console.log(permissionModel); - }); -}); diff --git a/common/models/ResourceField.test.js b/common/models/ResourceField.test.js deleted file mode 100644 index eb8d69d80..000000000 --- a/common/models/ResourceField.test.js +++ /dev/null @@ -1,18 +0,0 @@ -import { create, expect } from '~/testInit'; -import Resource from '@/models/Resource'; -import ResourceField from '@/models/ResourceField'; -import '@/models/View'; - -describe('Model: ResourceField', () => { - it('Resource field model may belongs to associated resource.', async () => { - const resourceField = await create('resource_field'); - - const resourceFieldModel = await ResourceField.where('id', resourceField.id).fetch(); - const resourceModel = resourceFieldModel.resource().fetch(); - - const foundResource = await Resource.where('id', resourceField.resource_id).fetch(); - - expect(resourceModel.attributes.id).equals(foundResource.id); - expect(resourceModel.attributes.name).equals(foundResource.name); - }); -}); diff --git a/common/models/Role.test.js b/common/models/Role.test.js deleted file mode 100644 index 93cc7480e..000000000 --- a/common/models/Role.test.js +++ /dev/null @@ -1,34 +0,0 @@ -import { expect, create } from '~/testInit'; -import Role from '@/models/Role'; -import '@/models/Permission'; -import '@/models/Resource'; - -describe('Model: Role', () => { - it('Role model may has many associated users', async () => { - const userHasRole = await create('user_has_role'); - await create('user_has_role', { role_id: userHasRole.roleId }); - - const roleModel = await Role.query().findById(userHasRole.roleId); - const roleUsers = await roleModel.$relatedQuery('users'); - - expect(roleUsers).to.have.lengthOf(2); - }); - - it('Role model may has many associated permissions.', async () => { - const roleHasPermissions = await create('role_has_permission'); - - const roleModel = await Role.query().findById(roleHasPermissions.roleId); - const rolePermissions = await roleModel.$relatedQuery('permissions'); - - expect(rolePermissions).to.have.lengthOf(1); - }); - - it('Role model may has many associated resources that has some or all permissions.', async () => { - const roleHasPermissions = await create('role_has_permission'); - - const roleModel = await Role.query().findById(roleHasPermissions.roleId); - const roleResources = await roleModel.$relatedQuery('resources'); - - expect(roleResources).to.have.lengthOf(1); - }); -}); From 52d01b4ed8c072e632b6bda1c478cac6cf762fea Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Wed, 19 Aug 2020 00:13:53 +0200 Subject: [PATCH 3/8] fix: Date format in sales/purchases APIs. fix: Algorithm FIFO cost calculate method. --- server/package.json | 2 +- ...200715193633_create_sale_invoices_table.js | 1 + ...s => 20200719152005_create_bills_table.js} | 1 + ...251_create_inventory_transactions_table.js | 2 + ...create_inventory_cost_lot_tracker_table.js | 2 +- .../src/http/middleware/prettierMiddleware.ts | 35 ++++++++ server/src/jobs/ComputeItemCost.ts | 11 ++- server/src/models/InventoryTransaction.js | 2 +- server/src/models/index.js | 2 + .../services/Accounting/JournalCommands.ts | 4 - server/src/services/Inventory/Inventory.ts | 52 ++++++++---- .../Inventory/InventoryCostLotTracker.ts | 20 +++-- server/src/services/Purchases/BillPayments.js | 27 ++++--- server/src/services/Purchases/Bills.js | 80 ++++++++++++------- server/src/services/Sales/PaymentsReceives.ts | 5 +- server/src/services/Sales/SalesEstimate.ts | 57 +++++++------ server/src/services/Sales/SalesInvoices.ts | 62 +++++++++++--- server/src/services/Sales/SalesReceipts.ts | 43 ++++++---- server/src/utils/index.js | 12 +++ server/tests/routes/accounts.test.js | 2 - server/tests/routes/views.test.js | 2 - 21 files changed, 291 insertions(+), 133 deletions(-) rename server/src/database/migrations/{20200719152005_reate_bills_table.js => 20200719152005_create_bills_table.js} (93%) create mode 100644 server/src/http/middleware/prettierMiddleware.ts diff --git a/server/package.json b/server/package.json index 48ed24091..b29facc3b 100644 --- a/server/package.json +++ b/server/package.json @@ -7,7 +7,7 @@ "build": "webpack", "start": "npm-run-all --parallel watch:server watch:build", "watch:build": "webpack --watch", - "watch:server": "nodemon \"./dist/bundle.js\" --watch \"./dist\" ", + "watch:server": "nodemon --inspect=\"9229\" \"./dist/bundle.js\" --watch \"./dist\" ", "test": "cross-env NODE_ENV=test mocha-webpack --webpack-config webpack.config.js \"tests/**/*.test.js\"", "test:watch": "cross-env NODE_ENV=test mocha-webpack --watch --webpack-config webpack.config.js --timeout=30000 tests/**/*.test.js" }, diff --git a/server/src/database/migrations/20200715193633_create_sale_invoices_table.js b/server/src/database/migrations/20200715193633_create_sale_invoices_table.js index bd66a66a9..7f5b394b8 100644 --- a/server/src/database/migrations/20200715193633_create_sale_invoices_table.js +++ b/server/src/database/migrations/20200715193633_create_sale_invoices_table.js @@ -15,6 +15,7 @@ exports.up = function(knex) { table.decimal('balance', 13, 3); table.decimal('payment_amount', 13, 3); + table.string('inv_lot_number'); table.timestamps(); }); }; diff --git a/server/src/database/migrations/20200719152005_reate_bills_table.js b/server/src/database/migrations/20200719152005_create_bills_table.js similarity index 93% rename from server/src/database/migrations/20200719152005_reate_bills_table.js rename to server/src/database/migrations/20200719152005_create_bills_table.js index 007f55b4d..d6f3c2f7a 100644 --- a/server/src/database/migrations/20200719152005_reate_bills_table.js +++ b/server/src/database/migrations/20200719152005_create_bills_table.js @@ -13,6 +13,7 @@ exports.up = function(knex) { table.decimal('amount', 13, 3).defaultTo(0); table.decimal('payment_amount', 13, 3).defaultTo(0); + table.string('inv_lot_number'); table.timestamps(); }); }; 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 ae1fa39b9..6555220c8 100644 --- a/server/src/database/migrations/20200722164251_create_inventory_transactions_table.js +++ b/server/src/database/migrations/20200722164251_create_inventory_transactions_table.js @@ -9,6 +9,8 @@ exports.up = function(knex) { table.integer('item_id').unsigned(); table.integer('quantity').unsigned(); table.decimal('rate', 13, 3).unsigned(); + + table.integer('lot_number'); 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 26c72be2b..1318515e3 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 @@ -10,7 +10,7 @@ exports.up = function(knex) { table.integer('quantity').unsigned(); table.decimal('rate', 13, 3); table.integer('remaining'); - table.string('lot_number'); + table.integer('lot_number'); table.string('transaction_type'); table.integer('transaction_id'); diff --git a/server/src/http/middleware/prettierMiddleware.ts b/server/src/http/middleware/prettierMiddleware.ts new file mode 100644 index 000000000..c7a6b7814 --- /dev/null +++ b/server/src/http/middleware/prettierMiddleware.ts @@ -0,0 +1,35 @@ +import { camelCase, snakeCase } from 'lodash'; + +/** + * create a middleware to change json format from snake case to camelcase in request + * then change back to snake case in response + * + */ +export default function createMiddleware() { + return function (req, res, next) { + /** + * camelize req.body + */ + if (req.body && typeof req.body === 'object') { + req.body = camelCase(req.body); + } + + /** + * camelize req.query + */ + if (req.query && typeof req.query === 'object') { + req.query = camelCase(req.query); + } + + /** + * wrap res.json() + */ + const sendJson = res.json; + + res.json = (data) => { + return sendJson.call(res, snakeCase(data)); + } + + return next(); + } +} \ No newline at end of file diff --git a/server/src/jobs/ComputeItemCost.ts b/server/src/jobs/ComputeItemCost.ts index c471fb48b..09b069e03 100644 --- a/server/src/jobs/ComputeItemCost.ts +++ b/server/src/jobs/ComputeItemCost.ts @@ -6,7 +6,14 @@ export default class ComputeItemCostJob { const Logger = Container.get('logger'); const { startingDate, itemId, costMethod } = job.attrs.data; - await InventoryService.computeItemCost(startingDate, itemId, costMethod); - done(); + try { + await InventoryService.computeItemCost(startingDate, itemId, costMethod); + Logger.log(`Compute item cost: ${job.attrs.data}`); + done(); + } catch(e) { + Logger.error(`Compute item cost: ${job.attrs.data}, error: ${e}`); + done(e); + } + } } diff --git a/server/src/models/InventoryTransaction.js b/server/src/models/InventoryTransaction.js index 3b1a7fc7d..d108dd3a6 100644 --- a/server/src/models/InventoryTransaction.js +++ b/server/src/models/InventoryTransaction.js @@ -12,7 +12,7 @@ export default class InventoryTransaction extends TenantModel { /** * Model timestamps. */ - static get timestamps() { + get timestamps() { return ['createdAt', 'updatedAt']; } diff --git a/server/src/models/index.js b/server/src/models/index.js index ddd03d173..cc025cbb0 100644 --- a/server/src/models/index.js +++ b/server/src/models/index.js @@ -1,5 +1,6 @@ import Customer from './Customer'; import Vendor from './Vendor'; +import Option from './Option'; import SaleEstimate from './SaleEstimate'; import SaleEstimateEntry from './SaleEstimateEntry'; import SaleReceipt from './SaleReceipt'; @@ -44,4 +45,5 @@ export { InventoryTransaction, InventoryLotCostTracker, AccountType, + Option, }; \ No newline at end of file diff --git a/server/src/services/Accounting/JournalCommands.ts b/server/src/services/Accounting/JournalCommands.ts index ecac13c90..52d4709f1 100644 --- a/server/src/services/Accounting/JournalCommands.ts +++ b/server/src/services/Accounting/JournalCommands.ts @@ -45,8 +45,6 @@ export default class JournalCommands{ .map((groupedTrans: IInventoryTransaction[], transType: string) => [groupedTrans, transType]) .value(); - console.log(groupedInvTransactions); - return Promise.all( groupedInvTransactions.map(async (grouped: [IInventoryTransaction[], string]) => { const [invTransGroup, referenceType] = grouped; @@ -58,8 +56,6 @@ export default class JournalCommands{ .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)); diff --git a/server/src/services/Inventory/Inventory.ts b/server/src/services/Inventory/Inventory.ts index fe7e2290b..381b37a4f 100644 --- a/server/src/services/Inventory/Inventory.ts +++ b/server/src/services/Inventory/Inventory.ts @@ -1,9 +1,11 @@ import { InventoryTransaction, - Item + Item, + Option, } from '@/models'; import InventoryAverageCost from '@/services/Inventory/InventoryAverageCost'; import InventoryCostLotTracker from '@/services/Inventory/InventoryCostLotTracker'; +import { option } from 'commander'; type TCostMethod = 'FIFO' | 'LIFO' | 'AVG'; @@ -38,10 +40,7 @@ export default class InventoryService { */ static async recordInventoryTransactions( entries: [], - date: Date, - transactionType: string, - transactionId: number, - direction: string, + deleteOld: boolean, ) { const storedOpers: any = []; const entriesItemsIds = entries.map((e: any) => e.item_id); @@ -56,19 +55,22 @@ export default class InventoryService { const inventoryEntries = entries.filter( (entry: any) => inventoryItemsIds.indexOf(entry.item_id) !== -1 ); - inventoryEntries.forEach((entry: any) => { + inventoryEntries.forEach(async (entry: any) => { + if (deleteOld) { + await this.deleteInventoryTransactions( + entry.transactionId, + entry.transactionType, + ); + } 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, + ...entry, + lotNumber: entry.lotNumber, }); storedOpers.push(oper); - }); - return Promise.all(storedOpers); + }); + return Promise.all([ + ...storedOpers, + ]); } /** @@ -90,4 +92,24 @@ export default class InventoryService { revertInventoryLotsCost(fromDate?: Date) { } + + /** + * Retrieve the lot number after the increment. + */ + static async nextLotNumber() { + const LOT_NUMBER_KEY = 'lot_number_increment'; + const effectRows = await Option.tenant().query() + .where('key', LOT_NUMBER_KEY) + .increment('value', 1); + + if (effectRows) { + await Option.tenant().query() + .insert({ + key: LOT_NUMBER_KEY, + value: 1, + }); + } + const options = await Option.tenant().query(); + return options.getMeta(LOT_NUMBER_KEY, 1); + } } \ No newline at end of file diff --git a/server/src/services/Inventory/InventoryCostLotTracker.ts b/server/src/services/Inventory/InventoryCostLotTracker.ts index 4e64b3f40..db4701680 100644 --- a/server/src/services/Inventory/InventoryCostLotTracker.ts +++ b/server/src/services/Inventory/InventoryCostLotTracker.ts @@ -1,5 +1,4 @@ import { omit, pick, chain } from 'lodash'; -import uniqid from 'uniqid'; import { InventoryTransaction, InventoryLotCostTracker, @@ -62,6 +61,7 @@ export default class InventoryCostLotTracker implements IInventoryCostMethod { .query() .where('date', '>=', this.startingDate) .orderBy('date', 'ASC') + .orderBy('lot_number', 'ASC') .where('item_id', this.itemId) .withGraphFetched('item'); @@ -70,6 +70,7 @@ export default class InventoryCostLotTracker implements IInventoryCostMethod { .query() .where('date', '<', this.startingDate) .orderBy('date', 'ASC') + .orderBy('lot_number', 'ASC') .where('item_id', this.itemId) .where('direction', 'IN') .whereNot('remaining', 0); @@ -267,17 +268,16 @@ export default class InventoryCostLotTracker implements IInventoryCostMethod { ...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; + const idsShouldDel: number[] = []; inventoryByItem?.[itemId]?.some(( _invTransactionId: number, - index: number, ) => { const _invINTransaction = inventoryINTrans[_invTransactionId]; if (invRemaining <= 0) { return true; } @@ -285,22 +285,23 @@ export default class InventoryCostLotTracker implements IInventoryCostMethod { // 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; + const maxDecrement = Math.min(decrement, invRemaining); - _invINTransaction.decrement += decrement; + _invINTransaction.decrement += maxDecrement; _invINTransaction.remaining = Math.max( - _invINTransaction.remaining - decrement, + _invINTransaction.remaining - maxDecrement, 0, ); - invRemaining = Math.max(invRemaining - decrement, 0); + invRemaining = Math.max(invRemaining - maxDecrement, 0); costLotsTransactions.push({ ...commonLotTransaction, - quantity: decrement, + quantity: maxDecrement, lotNumber: _invINTransaction.lotNumber, }); // Pop the 'IN' lots that has zero remaining. if (_invINTransaction.remaining === 0) { - inventoryByItem?.[itemId].splice(index, 1); + idsShouldDel.push(_invTransactionId); } return false; }); @@ -310,6 +311,9 @@ export default class InventoryCostLotTracker implements IInventoryCostMethod { quantity: invRemaining, }); } + // Remove the IN transactions that has zero remaining amount. + inventoryByItem[itemId] = inventoryByItem?.[itemId] + ?.filter((transId: number) => idsShouldDel.indexOf(transId) === -1); } }); return costLotsTransactions; diff --git a/server/src/services/Purchases/BillPayments.js b/server/src/services/Purchases/BillPayments.js index a5dc9f1a8..65dd1e117 100644 --- a/server/src/services/Purchases/BillPayments.js +++ b/server/src/services/Purchases/BillPayments.js @@ -14,6 +14,7 @@ import AccountsService from '@/services/Accounts/AccountsService'; import JournalPoster from '@/services/Accounting/JournalPoster'; import JournalEntry from '@/services/Accounting/JournalEntry'; import JournalPosterService from '@/services/Sales/JournalPosterService'; +import { formatDateFields } from '@/utils'; /** * Bill payments service. @@ -32,14 +33,16 @@ export default class BillPaymentsService { * - Decrement the vendor balance. * - Records payment journal entries. * - * @param {IBillPayment} billPayment + * @param {BillPaymentDTO} billPayment */ - static async createBillPayment(billPayment) { - const amount = sumBy(billPayment.entries, 'payment_amount'); + static async createBillPayment(billPaymentDTO) { + const billPayment = { + amount: sumBy(billPaymentDTO.entries, 'payment_amount'), + ...formatDateFields(billPaymentDTO, ['payment_date']), + } const storedBillPayment = await BillPayment.tenant() .query() .insert({ - amount, ...omit(billPayment, ['entries']), }); const storeOpers = []; @@ -62,7 +65,7 @@ export default class BillPaymentsService { // Decrement the vendor balance after bills payments. const vendorDecrementOper = Vendor.changeBalance( billPayment.vendor_id, - amount * -1, + billPayment.amount * -1, ); // Records the journal transactions after bills payment // and change diff acoount balance. @@ -92,24 +95,24 @@ export default class BillPaymentsService { * - Update the diff bill payment amount. * * @param {Integer} billPaymentId - * @param {IBillPayment} billPayment + * @param {BillPaymentDTO} billPayment * @param {IBillPayment} oldBillPayment */ - static async editBillPayment(billPaymentId, billPayment, oldBillPayment) { - const amount = sumBy(billPayment.entries, 'payment_amount'); + static async editBillPayment(billPaymentId, billPaymentDTO, oldBillPayment) { + const billPayment = { + amount: sumBy(billPaymentDTO.entries, 'payment_amount'), + ...formatDateFields(billPaymentDTO, ['payment_date']), + }; const updateBillPayment = await BillPayment.tenant() .query() .where('id', billPaymentId) .update({ - amount, ...omit(billPayment, ['entries']), }); const opers = []; - const entriesHasIds = billpayment.entries.filter((i) => i.id); + const entriesHasIds = billPayment.entries.filter((i) => i.id); const entriesHasNoIds = billPayment.entries.filter((e) => !e.id); - const entriesIds = entriesHasIds.map((e) => e.id); - const entriesIdsShouldDelete = ServiceItemsEntries.entriesShouldDeleted( oldBillPayment.entries, entriesHasIds diff --git a/server/src/services/Purchases/Bills.js b/server/src/services/Purchases/Bills.js index 58efb39ba..b115b732c 100644 --- a/server/src/services/Purchases/Bills.js +++ b/server/src/services/Purchases/Bills.js @@ -1,4 +1,4 @@ -import { omit, sumBy } from 'lodash'; +import { omit, sumBy, pick } from 'lodash'; import moment from 'moment'; import { Container } from 'typedi'; import { @@ -7,7 +7,6 @@ import { Vendor, ItemEntry, Item, - InventoryTransaction, AccountTransaction, } from '@/models'; import JournalPoster from '@/services/Accounting/JournalPoster'; @@ -16,14 +15,16 @@ import AccountsService from '@/services/Accounts/AccountsService'; import JournalPosterService from '@/services/Sales/JournalPosterService'; import InventoryService from '@/services/Inventory/Inventory'; import HasItemsEntries from '@/services/Sales/HasItemsEntries'; +import { formatDateFields } from '@/utils'; /** * Vendor bills services. + * @service */ export default class BillsService { /** * Creates a new bill and stored it to the storage. - *| + * * Precedures. * ---- * - Insert bill transactions to the storage. @@ -35,16 +36,18 @@ export default class BillsService { * @param {IBill} bill - * @return {void} */ - static async createBill(bill) { - const agenda = Container.get('agenda'); - - const amount = sumBy(bill.entries, 'amount'); + static async createBill(billDTO) { + const invLotNumber = await InventoryService.nextLotNumber(); + const bill = { + ...formatDateFields(billDTO, ['bill_date', 'due_date']), + amount: sumBy(billDTO.entries, 'amount'), + invLotNumber: billDTO.invLotNumber || invLotNumber + }; const saveEntriesOpers = []; const storedBill = await Bill.tenant() .query() .insert({ - amount, ...omit(bill, ['entries']), }); bill.entries.forEach((entry) => { @@ -58,11 +61,11 @@ export default class BillsService { saveEntriesOpers.push(oper); }); // Increments vendor balance. - const incrementOper = Vendor.changeBalance(bill.vendor_id, amount); + const incrementOper = Vendor.changeBalance(bill.vendor_id, bill.amount); // Rewrite the inventory transactions for inventory items. - const writeInvTransactionsOper = InventoryService.recordInventoryTransactions( - bill.entries, bill.bill_date, 'Bill', storedBill.id, 'IN', + const writeInvTransactionsOper = this.recordInventoryTransactions( + bill, storedBill.id ); // Writes the journal entries for the given bill transaction. const writeJEntriesOper = this.recordJournalTransactions({ @@ -75,7 +78,6 @@ export default class BillsService { writeInvTransactionsOper, writeJEntriesOper, ]); - // Schedule bill re-compute based on the item cost // method and starting date. await this.scheduleComputeItemsCost(bill); @@ -83,7 +85,14 @@ export default class BillsService { return storedBill; } - scheduleComputeItemCost(bill) { + /** + * Schedule a job to re-compute the bill's items based on cost method + * of the each one. + * @param {Bill} bill + */ + static scheduleComputeItemsCost(bill) { + const agenda = Container.get('agenda'); + 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, @@ -91,6 +100,25 @@ export default class BillsService { }); } + /** + * Records the inventory transactions from the given bill input. + * @param {Bill} bill + * @param {number} billId + */ + static recordInventoryTransactions(bill, billId, override) { + const inventoryTransactions = bill.entries + .map((entry) => ({ + ...pick(entry, ['item_id', 'quantity', 'rate']), + lotNumber: bill.invLotNumber, + transactionType: 'Bill', + transactionId: billId, + direction: 'IN', + date: bill.bill_date, + })); + + return InventoryService.recordInventoryTransactions(inventoryTransactions, override); + } + /** * Edits details of the given bill id with associated entries. * @@ -106,19 +134,20 @@ export default class BillsService { * @param {Integer} billId - The given bill id. * @param {IBill} bill - The given new bill details. */ - static async editBill(billId, bill) { + static async editBill(billId, billDTO) { const oldBill = await Bill.tenant().query().findById(billId); - const amount = sumBy(bill.entries, 'amount'); - + const bill = { + ...formatDateFields(billDTO, ['bill_date', 'due_date']), + amount: sumBy(billDTO.entries, 'amount'), + invLotNumber: oldBill.invLotNumber, + }; // Update the bill transaction. const updatedBill = await Bill.tenant() .query() .where('id', billId) .update({ - amount, - ...omit(bill, ['entries']) + ...omit(bill, ['entries', 'invLotNumber']) }); - // Old stored entries. const storedEntries = await ItemEntry.tenant() .query() @@ -133,17 +162,12 @@ export default class BillsService { const changeVendorBalanceOper = Vendor.changeDiffBalance( bill.vendor_id, oldBill.vendorId, - amount, + bill.amount, oldBill.amount, ); // 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' - ); + const writeInvTransactionsOper = this.recordInventoryTransactions(bill, billId, true); + // Writes the journal entries for the given bill transaction. const writeJEntriesOper = this.recordJournalTransactions({ id: billId, @@ -154,10 +178,8 @@ export default class BillsService { patchEntriesOper, changeVendorBalanceOper, writeInvTransactionsOper, - deleteInventoryTransOper, writeJEntriesOper, ]); - // Schedule sale invoice re-compute based on the item cost // method and starting date. await this.scheduleComputeItemsCost(bill); diff --git a/server/src/services/Sales/PaymentsReceives.ts b/server/src/services/Sales/PaymentsReceives.ts index 1cd6ed9e9..5b26bcfae 100644 --- a/server/src/services/Sales/PaymentsReceives.ts +++ b/server/src/services/Sales/PaymentsReceives.ts @@ -15,6 +15,7 @@ import JournalPosterService from '@/services/Sales/JournalPosterService'; import ServiceItemsEntries from '@/services/Sales/ServiceItemsEntries'; import PaymentReceiveEntryRepository from '@/repositories/PaymentReceiveEntryRepository'; import CustomerRepository from '@/repositories/CustomerRepository'; +import { formatDateFields } from '@/utils'; /** * Payment receive service. @@ -33,7 +34,7 @@ export default class PaymentReceiveService { .query() .insert({ amount: paymentAmount, - ...omit(paymentReceive, ['entries']), + ...formatDateFields(omit(paymentReceive, ['entries']), ['payment_date']), }); const storeOpers: Array = []; @@ -97,7 +98,7 @@ export default class PaymentReceiveService { .where('id', paymentReceiveId) .update({ amount: paymentAmount, - ...omit(paymentReceive, ['entries']), + ...formatDateFields(omit(paymentReceive, ['entries']), ['payment_date']), }); const opers = []; const entriesIds = paymentReceive.entries.filter((i: any) => i.id); diff --git a/server/src/services/Sales/SalesEstimate.ts b/server/src/services/Sales/SalesEstimate.ts index c74ab7691..7bd725335 100644 --- a/server/src/services/Sales/SalesEstimate.ts +++ b/server/src/services/Sales/SalesEstimate.ts @@ -2,21 +2,23 @@ import { omit, difference, sumBy, mixin } from 'lodash'; import moment from 'moment'; import { SaleEstimate, ItemEntry } from '@/models'; import HasItemsEntries from '@/services/Sales/HasItemsEntries'; +import { formatDateFields } from '@/utils'; export default class SaleEstimateService { /** * Creates a new estimate with associated entries. * @async - * @param {IEstimate} estimate + * @param {EstimateDTO} estimate * @return {void} */ - static async createEstimate(estimate: any) { - const amount = sumBy(estimate.entries, 'amount'); - + static async createEstimate(estimateDTO: any) { + const estimate = { + amount: sumBy(estimateDTO.entries, 'amount'), + ...formatDateFields(estimateDTO, ['estimate_date', 'expiration_date']), + }; const storedEstimate = await SaleEstimate.tenant() .query() .insert({ - amount, ...omit(estimate, ['entries']), }); const storeEstimateEntriesOpers: any[] = []; @@ -36,34 +38,21 @@ export default class SaleEstimateService { return storedEstimate; } - /** - * Deletes the given estimate id with associated entries. - * @async - * @param {IEstimate} estimateId - * @return {void} - */ - static async deleteEstimate(estimateId: number) { - await ItemEntry.tenant() - .query() - .where('reference_id', estimateId) - .where('reference_type', 'SaleEstimate') - .delete(); - await SaleEstimate.tenant().query().where('id', estimateId).delete(); - } - /** * Edit details of the given estimate with associated entries. * @async * @param {Integer} estimateId - * @param {IEstimate} estimate + * @param {EstimateDTO} estimate * @return {void} */ - static async editEstimate(estimateId: number, estimate: any) { - const amount = sumBy(estimate.entries, 'amount'); + static async editEstimate(estimateId: number, estimateDTO: any) { + const estimate = { + amount: sumBy(estimateDTO.entries, 'amount'), + ...formatDateFields(estimateDTO, ['estimate_date', 'expiration_date']), + }; const updatedEstimate = await SaleEstimate.tenant() .query() .update({ - amount, ...omit(estimate, ['entries']), }); const storedEstimateEntries = await ItemEntry.tenant() @@ -79,6 +68,26 @@ export default class SaleEstimateService { ]); } + /** + * Deletes the given estimate id with associated entries. + * @async + * @param {IEstimate} estimateId + * @return {void} + */ + static async deleteEstimate(estimateId: number) { + await ItemEntry.tenant() + .query() + .where('reference_id', estimateId) + .where('reference_type', 'SaleEstimate') + .delete(); + + await SaleEstimate.tenant() + .query() + .where('id', estimateId) + .delete(); + } + + /** * Validates the given estimate ID exists. * @async diff --git a/server/src/services/Sales/SalesInvoices.ts b/server/src/services/Sales/SalesInvoices.ts index 82461d5bc..913fcca43 100644 --- a/server/src/services/Sales/SalesInvoices.ts +++ b/server/src/services/Sales/SalesInvoices.ts @@ -1,4 +1,4 @@ -import { omit, sumBy, difference } from 'lodash'; +import { omit, sumBy, difference, pick } from 'lodash'; import { Container } from 'typedi'; import { SaleInvoice, @@ -12,6 +12,7 @@ import JournalPoster from '@/services/Accounting/JournalPoster'; import HasItemsEntries from '@/services/Sales/HasItemsEntries'; import CustomerRepository from '@/repositories/CustomerRepository'; import InventoryService from '@/services/Inventory/Inventory'; +import { formatDateFields } from '@/utils'; /** * Sales invoices service @@ -25,14 +26,19 @@ export default class SaleInvoicesService { * @param {ISaleInvoice} * @return {ISaleInvoice} */ - static async createSaleInvoice(saleInvoice: any) { - const balance = sumBy(saleInvoice.entries, 'amount'); + static async createSaleInvoice(saleInvoiceDTO: any) { + const balance = sumBy(saleInvoiceDTO.entries, 'amount'); + const invLotNumber = await InventoryService.nextLotNumber(); + const saleInvoice = { + ...formatDateFields(saleInvoiceDTO, ['invoide_date', 'due_date']), + balance, + paymentAmount: 0, + invLotNumber, + }; const storedInvoice = await SaleInvoice.tenant() .query() .insert({ ...omit(saleInvoice, ['entries']), - balance, - payment_amount: 0, }); const opers: Array = []; @@ -52,8 +58,8 @@ export default class SaleInvoicesService { balance, ); // Records the inventory transactions for inventory items. - const recordInventoryTransOpers = InventoryService.recordInventoryTransactions( - saleInvoice.entries, saleInvoice.invoice_date, 'SaleInvoice', storedInvoice.id, 'OUT', + const recordInventoryTransOpers = this.recordInventoryTranscactions( + saleInvoice, storedInvoice.id ); // Await all async operations. await Promise.all([ @@ -79,11 +85,33 @@ export default class SaleInvoicesService { } + /** + * Records the inventory transactions from the givne sale invoice input. + * @param {SaleInvoice} saleInvoice - + * @param {number} saleInvoiceId - + * @param {boolean} override - + */ + static recordInventoryTranscactions(saleInvoice, saleInvoiceId: number, override?: boolean){ + const inventortyTransactions = saleInvoice.entries + .map((entry) => ({ + ...pick(entry, ['item_id', 'quantity', 'rate']), + lotNumber: saleInvoice.invLotNumber, + transactionType: 'SaleInvoice', + transactionId: saleInvoiceId, + direction: 'OUT', + date: saleInvoice.invoice_date, + })); + + return InventoryService.recordInventoryTransactions( + inventortyTransactions, override, + ); + } + /** * Schedule sale invoice re-compute based on the item * cost method and starting date * - * @param saleInvoice + * @param {SaleInvoice} saleInvoice - * @return {Promise} */ static scheduleComputeItemsCost(saleInvoice) { @@ -102,18 +130,22 @@ export default class SaleInvoicesService { * @param {Number} saleInvoiceId - * @param {ISaleInvoice} saleInvoice - */ - static async editSaleInvoice(saleInvoiceId: number, saleInvoice: any) { - const balance = sumBy(saleInvoice.entries, 'amount'); + static async editSaleInvoice(saleInvoiceId: number, saleInvoiceDTO: any) { + const balance = sumBy(saleInvoiceDTO.entries, 'amount'); const oldSaleInvoice = await SaleInvoice.tenant().query() .where('id', saleInvoiceId) .first(); + const saleInvoice = { + ...formatDateFields(saleInvoiceDTO, ['invoice_date', 'due_date']), + balance, + invLotNumber: oldSaleInvoice.invLotNumber, + }; const updatedSaleInvoices = await SaleInvoice.tenant() .query() .where('id', saleInvoiceId) .update({ - balance, - ...omit(saleInvoice, ['entries']), + ...omit(saleInvoice, ['entries', 'invLotNumber']), }); // Fetches the sale invoice items entries. const storedEntries = await ItemEntry.tenant() @@ -132,9 +164,14 @@ export default class SaleInvoicesService { balance, oldSaleInvoice.balance, ); + // Records the inventory transactions for inventory items. + const recordInventoryTransOper = this.recordInventoryTranscactions( + saleInvoice, saleInvoiceId, true, + ); await Promise.all([ patchItemsEntriesOper, changeCustomerBalanceOper, + recordInventoryTransOper, ]); // Schedule sale invoice re-compute based on the item cost @@ -221,7 +258,6 @@ export default class SaleInvoicesService { const revertInventoryTransactionsOper = this.revertInventoryTransactions( inventoryTransactions ); - // Await all async operations. await Promise.all([ journal.deleteEntries(), diff --git a/server/src/services/Sales/SalesReceipts.ts b/server/src/services/Sales/SalesReceipts.ts index f3ba7ae62..c50707816 100644 --- a/server/src/services/Sales/SalesReceipts.ts +++ b/server/src/services/Sales/SalesReceipts.ts @@ -7,6 +7,7 @@ import { import JournalPoster from '@/services/Accounting/JournalPoster'; import JournalPosterService from '@/services/Sales/JournalPosterService'; import HasItemEntries from '@/services/Sales/HasItemsEntries'; +import { formatDateFields } from '@/utils'; export default class SalesReceipt { /** @@ -15,12 +16,14 @@ export default class SalesReceipt { * @param {ISaleReceipt} saleReceipt * @return {Object} */ - static async createSaleReceipt(saleReceipt: any) { - const amount = sumBy(saleReceipt.entries, 'amount'); + static async createSaleReceipt(saleReceiptDTO: any) { + const saleReceipt = { + amount: sumBy(saleReceiptDTO.entries, 'amount'); + ...formatDateFields(saleReceiptDTO, ['receipt_date']) + }; const storedSaleReceipt = await SaleReceipt.tenant() .query() .insert({ - amount, ...omit(saleReceipt, ['entries']), }); const storeSaleReceiptEntriesOpers: Array = []; @@ -39,29 +42,21 @@ export default class SalesReceipt { return storedSaleReceipt; } - /** - * Records journal transactions for sale receipt. - * @param {ISaleReceipt} saleReceipt - * @return {Promise} - */ - static async _recordJournalTransactions(saleReceipt: any) { - const accountsDepGraph = await Account.tenant().depGraph().query(); - const journalPoster = new JournalPoster(accountsDepGraph); - } - /** * Edit details sale receipt with associated entries. * @param {Integer} saleReceiptId * @param {ISaleReceipt} saleReceipt * @return {void} */ - static async editSaleReceipt(saleReceiptId: number, saleReceipt: any) { - const amount = sumBy(saleReceipt.entries, 'amount'); + static async editSaleReceipt(saleReceiptId: number, saleReceiptDTO: any) { + const saleReceipt = { + amount: sumBy(saleReceiptDTO.entries, 'amount'), + ...formatDateFields(saleReceiptDTO, ['receipt_date']) + }; const updatedSaleReceipt = await SaleReceipt.tenant() .query() .where('id', saleReceiptId) .update({ - amount, ...omit(saleReceipt, ['entries']), }); const storedSaleReceiptEntries = await ItemEntry.tenant() @@ -82,7 +77,11 @@ export default class SalesReceipt { * @return {void} */ static async deleteSaleReceipt(saleReceiptId: number) { - const deleteSaleReceiptOper = SaleReceipt.tenant().query().where('id', saleReceiptId).delete(); + const deleteSaleReceiptOper = SaleReceipt.tenant() + .query() + .where('id', saleReceiptId) + .delete(); + const deleteItemsEntriesOper = ItemEntry.tenant() .query() .where('reference_id', saleReceiptId) @@ -148,4 +147,14 @@ export default class SalesReceipt { return saleReceipt; } + + /** + * Records journal transactions for sale receipt. + * @param {ISaleReceipt} saleReceipt + * @return {Promise} + */ + static async _recordJournalTransactions(saleReceipt: any) { + const accountsDepGraph = await Account.tenant().depGraph().query(); + const journalPoster = new JournalPoster(accountsDepGraph); + } } diff --git a/server/src/utils/index.js b/server/src/utils/index.js index 5d6959e86..0b2067854 100644 --- a/server/src/utils/index.js +++ b/server/src/utils/index.js @@ -144,6 +144,17 @@ function applyMixins(derivedCtor, baseCtors) { }); } +const formatDateFields = (inputDTO, fields, format = 'YYYY-DD-MM') => { + const _inputDTO = { ...inputDTO }; + + fields.forEach((field) => { + if (_inputDTO[field]) { + _inputDTO[field] = moment(_inputDTO[field]).format(format); + } + }); + return _inputDTO; +}; + export { hashPassword, origin, @@ -156,4 +167,5 @@ export { itemsStartWith, getTotalDeep, applyMixins, + formatDateFields, }; diff --git a/server/tests/routes/accounts.test.js b/server/tests/routes/accounts.test.js index bcaa5c166..6c9a9418c 100644 --- a/server/tests/routes/accounts.test.js +++ b/server/tests/routes/accounts.test.js @@ -204,8 +204,6 @@ describe('routes: /accounts/', () => { code: '123', }); - console.log(res.body); - expect(res.status).equals(200); }); }); diff --git a/server/tests/routes/views.test.js b/server/tests/routes/views.test.js index f42f01784..36652d731 100644 --- a/server/tests/routes/views.test.js +++ b/server/tests/routes/views.test.js @@ -802,8 +802,6 @@ describe('routes: `/views`', () => { value: '100', }], }); - - // console.log(res.status, res.body); const foundViewColumns = await ViewColumn.tenant().query().where('id', viewColumn.id); expect(foundViewColumns.length).equals(0); }); From c2a60e6ba5bc29f1c9679aa247bec18b8043e47d Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Wed, 19 Aug 2020 01:30:12 +0200 Subject: [PATCH 4/8] feat: Schedule to compute items cost. --- server/src/jobs/ComputeItemCost.ts | 3 +- server/src/services/Inventory/Inventory.ts | 25 +++- server/src/services/Purchases/Bills.js | 156 +++++++++---------- server/src/services/Sales/SalesInvoices.ts | 166 +++++++++------------ 4 files changed, 175 insertions(+), 175 deletions(-) diff --git a/server/src/jobs/ComputeItemCost.ts b/server/src/jobs/ComputeItemCost.ts index 09b069e03..e296c051f 100644 --- a/server/src/jobs/ComputeItemCost.ts +++ b/server/src/jobs/ComputeItemCost.ts @@ -4,13 +4,14 @@ 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; + const { startingDate, itemId, costMethod = 'FIFO' } = job.attrs.data; try { await InventoryService.computeItemCost(startingDate, itemId, costMethod); Logger.log(`Compute item cost: ${job.attrs.data}`); done(); } catch(e) { + console.log(e); Logger.error(`Compute item cost: ${job.attrs.data}, error: ${e}`); done(e); } diff --git a/server/src/services/Inventory/Inventory.ts b/server/src/services/Inventory/Inventory.ts index 381b37a4f..d82b34970 100644 --- a/server/src/services/Inventory/Inventory.ts +++ b/server/src/services/Inventory/Inventory.ts @@ -1,3 +1,4 @@ +import { Container } from 'typedi'; import { InventoryTransaction, Item, @@ -5,7 +6,6 @@ import { } from '@/models'; import InventoryAverageCost from '@/services/Inventory/InventoryAverageCost'; import InventoryCostLotTracker from '@/services/Inventory/InventoryCostLotTracker'; -import { option } from 'commander'; type TCostMethod = 'FIFO' | 'LIFO' | 'AVG'; @@ -33,6 +33,23 @@ export default class InventoryService { await costMethodComputer.computeItemCost() } + /** + * SChedule item cost compute job. + * @param {number} itemId + * @param {Date} startingDate + */ + static async scheduleComputeItemCost(itemId: number, startingDate: Date|string) { + const agenda = Container.get('agenda'); + + // Delete the scheduled job in case has the same given data. + await agenda.cancel({ + name: 'compute-item-cost', + }); + return agenda.schedule('in 3 seconds', 'compute-item-cost', { + startingDate, itemId, + }); + } + /** * Records the inventory transactions. * @param {Bill} bill @@ -89,10 +106,6 @@ export default class InventoryService { .delete(); } - revertInventoryLotsCost(fromDate?: Date) { - - } - /** * Retrieve the lot number after the increment. */ @@ -102,7 +115,7 @@ export default class InventoryService { .where('key', LOT_NUMBER_KEY) .increment('value', 1); - if (effectRows) { + if (effectRows === 0) { await Option.tenant().query() .insert({ key: LOT_NUMBER_KEY, diff --git a/server/src/services/Purchases/Bills.js b/server/src/services/Purchases/Bills.js index b115b732c..c44d9dd3d 100644 --- a/server/src/services/Purchases/Bills.js +++ b/server/src/services/Purchases/Bills.js @@ -1,6 +1,5 @@ import { omit, sumBy, pick } from 'lodash'; import moment from 'moment'; -import { Container } from 'typedi'; import { Account, Bill, @@ -36,7 +35,7 @@ export default class BillsService { * @param {IBill} bill - * @return {void} */ - static async createBill(billDTO) { + static async createBill(billDTO) { const invLotNumber = await InventoryService.nextLotNumber(); const bill = { ...formatDateFields(billDTO, ['bill_date', 'due_date']), @@ -85,40 +84,6 @@ export default class BillsService { return storedBill; } - /** - * Schedule a job to re-compute the bill's items based on cost method - * of the each one. - * @param {Bill} bill - */ - static scheduleComputeItemsCost(bill) { - const agenda = Container.get('agenda'); - - 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', - }); - } - - /** - * Records the inventory transactions from the given bill input. - * @param {Bill} bill - * @param {number} billId - */ - static recordInventoryTransactions(bill, billId, override) { - const inventoryTransactions = bill.entries - .map((entry) => ({ - ...pick(entry, ['item_id', 'quantity', 'rate']), - lotNumber: bill.invLotNumber, - transactionType: 'Bill', - transactionId: billId, - direction: 'IN', - date: bill.bill_date, - })); - - return InventoryService.recordInventoryTransactions(inventoryTransactions, override); - } - /** * Edits details of the given bill id with associated entries. * @@ -185,6 +150,72 @@ export default class BillsService { await this.scheduleComputeItemsCost(bill); } + /** + * Deletes the bill with associated entries. + * @param {Integer} billId + * @return {void} + */ + static async deleteBill(billId) { + const bill = await Bill.tenant().query() + .where('id', billId) + .withGraphFetched('entries') + .first(); + + // Delete all associated bill entries. + const deleteBillEntriesOper = ItemEntry.tenant() + .query() + .where('reference_type', 'Bill') + .where('reference_id', billId) + .delete(); + + // Delete the bill transaction. + const deleteBillOper = Bill.tenant().query().where('id', billId).delete(); + + // Delete associated bill journal transactions. + const deleteTransactionsOper = JournalPosterService.deleteJournalTransactions( + billId, + 'Bill' + ); + // Delete bill associated inventory transactions. + const deleteInventoryTransOper = InventoryService.deleteInventoryTransactions( + billId, 'Bill' + ); + // Revert vendor balance. + const revertVendorBalance = Vendor.changeBalance(bill.vendorId, bill.amount * -1); + + await Promise.all([ + deleteBillOper, + deleteBillEntriesOper, + deleteTransactionsOper, + deleteInventoryTransOper, + revertVendorBalance, + ]); + // Schedule sale invoice re-compute based on the item cost + // method and starting date. + await this.scheduleComputeItemsCost(bill); + } + + /** + * Records the inventory transactions from the given bill input. + * @param {Bill} bill + * @param {number} billId + */ + static recordInventoryTransactions(bill, billId, override) { + const inventoryTransactions = bill.entries + .map((entry) => ({ + ...pick(entry, ['item_id', 'quantity', 'rate']), + lotNumber: bill.invLotNumber, + transactionType: 'Bill', + transactionId: billId, + direction: 'IN', + date: bill.bill_date, + })); + + return InventoryService.recordInventoryTransactions( + inventoryTransactions, override + ); + } + /** * Records the bill journal transactions. * @async @@ -254,48 +285,21 @@ export default class BillsService { } /** - * Deletes the bill with associated entries. - * @param {Integer} billId - * @return {void} + * Schedule a job to re-compute the bill's items based on cost method + * of the each one. + * @param {Bill} bill */ - static async deleteBill(billId) { - const bill = await Bill.tenant().query() - .where('id', billId) - .withGraphFetched('entries') - .first(); + static scheduleComputeItemsCost(bill) { + const asyncOpers = []; - // Delete all associated bill entries. - const deleteBillEntriesOper = ItemEntry.tenant() - .query() - .where('reference_type', 'Bill') - .where('reference_id', billId) - .delete(); - - // Delete the bill transaction. - const deleteBillOper = Bill.tenant().query().where('id', billId).delete(); - - // Delete associated bill journal transactions. - const deleteTransactionsOper = JournalPosterService.deleteJournalTransactions( - billId, - 'Bill' - ); - // Delete bill associated inventory transactions. - const deleteInventoryTransOper = InventoryService.deleteInventoryTransactions( - billId, 'Bill' - ); - // Revert vendor balance. - const revertVendorBalance = Vendor.changeBalance(bill.vendorId, bill.amount * -1); - - await Promise.all([ - deleteBillOper, - deleteBillEntriesOper, - deleteTransactionsOper, - deleteInventoryTransOper, - revertVendorBalance, - ]); - // Schedule sale invoice re-compute based on the item cost - // method and starting date. - await this.scheduleComputeItemsCost(bill); + bill.entries.forEach((entry) => { + const oper = InventoryService.scheduleComputeItemCost( + entry.item_id || entry.itemId, + bill.bill_date || bill.billDate, + ); + asyncOpers.push(oper); + }); + return Promise.all(asyncOpers); } /** diff --git a/server/src/services/Sales/SalesInvoices.ts b/server/src/services/Sales/SalesInvoices.ts index 913fcca43..837ac7fe7 100644 --- a/server/src/services/Sales/SalesInvoices.ts +++ b/server/src/services/Sales/SalesInvoices.ts @@ -1,5 +1,4 @@ import { omit, sumBy, difference, pick } from 'lodash'; -import { Container } from 'typedi'; import { SaleInvoice, AccountTransaction, @@ -74,56 +73,6 @@ export default class SaleInvoicesService { return storedInvoice; } - /** - * 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. - * - * @param {SaleInvoice} saleInvoice - - * @param {Array} inventoryTransactions - - */ - static async recordJournalEntries(saleInvoice: any, inventoryTransactions: array[]) { - - } - - /** - * Records the inventory transactions from the givne sale invoice input. - * @param {SaleInvoice} saleInvoice - - * @param {number} saleInvoiceId - - * @param {boolean} override - - */ - static recordInventoryTranscactions(saleInvoice, saleInvoiceId: number, override?: boolean){ - const inventortyTransactions = saleInvoice.entries - .map((entry) => ({ - ...pick(entry, ['item_id', 'quantity', 'rate']), - lotNumber: saleInvoice.invLotNumber, - transactionType: 'SaleInvoice', - transactionId: saleInvoiceId, - direction: 'OUT', - date: saleInvoice.invoice_date, - })); - - return InventoryService.recordInventoryTransactions( - inventortyTransactions, override, - ); - } - - /** - * Schedule sale invoice re-compute based on the item - * cost method and starting date - * - * @param {SaleInvoice} 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 @@ -179,42 +128,6 @@ export default class SaleInvoicesService { await this.scheduleComputeItemsCost(saleInvoice); } - /** - * Deletes the inventory transactions. - * @param {string} transactionType - * @param {number} transactionId - */ - static async revertInventoryTransactions(inventoryTransactions: array) { - const opers: Promise<[]>[] = []; - - inventoryTransactions.forEach((trans: any) => { - switch(trans.direction) { - case 'OUT': - if (trans.inventoryTransactionId) { - const revertRemaining = InventoryTransaction.tenant() - .query() - .where('id', trans.inventoryTransactionId) - .where('direction', 'OUT') - .increment('remaining', trans.quanitity); - - opers.push(revertRemaining); - } - break; - case 'IN': - const removeRelationOper = InventoryTransaction.tenant() - .query() - .where('inventory_transaction_id', trans.id) - .where('direction', 'IN') - .update({ - inventory_transaction_id: null, - }); - opers.push(removeRelationOper); - break; - } - }); - return Promise.all(opers); - } - /** * Deletes the given sale invoice with associated entries * and journal transactions. @@ -271,13 +184,82 @@ export default class SaleInvoicesService { } /** - * Records the journal entries of sale invoice. - * @async - * @param {ISaleInvoice} saleInvoice - * @return {void} + * Records the inventory transactions from the givne sale invoice input. + * @param {SaleInvoice} saleInvoice - + * @param {number} saleInvoiceId - + * @param {boolean} override - */ - async recordJournalEntries(saleInvoice: any) { + static recordInventoryTranscactions(saleInvoice, saleInvoiceId: number, override?: boolean){ + const inventortyTransactions = saleInvoice.entries + .map((entry) => ({ + ...pick(entry, ['item_id', 'quantity', 'rate']), + lotNumber: saleInvoice.invLotNumber, + transactionType: 'SaleInvoice', + transactionId: saleInvoiceId, + direction: 'OUT', + date: saleInvoice.invoice_date, + })); + return InventoryService.recordInventoryTransactions( + inventortyTransactions, override, + ); + } + + /** + * Schedule sale invoice re-compute based on the item + * cost method and starting date + * + * @private + * @param {SaleInvoice} saleInvoice - + * @return {Promise} + */ + private static scheduleComputeItemsCost(saleInvoice: any) { + const asyncOpers: Promise<[]>[] = []; + + saleInvoice.entries.forEach((entry: any) => { + const oper: Promise<[]> = InventoryService.scheduleComputeItemCost( + entry.item_id || entry.itemId, + saleInvoice.bill_date || saleInvoice.billDate, + ); + asyncOpers.push(oper); + }); + return Promise.all(asyncOpers); + } + + /** + * Deletes the inventory transactions. + * @param {string} transactionType + * @param {number} transactionId + */ + static async revertInventoryTransactions(inventoryTransactions: array) { + const opers: Promise<[]>[] = []; + + inventoryTransactions.forEach((trans: any) => { + switch(trans.direction) { + case 'OUT': + if (trans.inventoryTransactionId) { + const revertRemaining = InventoryTransaction.tenant() + .query() + .where('id', trans.inventoryTransactionId) + .where('direction', 'OUT') + .increment('remaining', trans.quanitity); + + opers.push(revertRemaining); + } + break; + case 'IN': + const removeRelationOper = InventoryTransaction.tenant() + .query() + .where('inventory_transaction_id', trans.id) + .where('direction', 'IN') + .update({ + inventory_transaction_id: null, + }); + opers.push(removeRelationOper); + break; + } + }); + return Promise.all(opers); } /** From b46570dc016163581c601ce58e6a9e3c573e1296 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Wed, 19 Aug 2020 02:17:23 +0200 Subject: [PATCH 5/8] feat: Merge sales branch --- .../components/DataTableCells/DivFieldCell.js | 11 + .../DataTableCells/EstimatesListFieldCell.js | 38 ++ .../DataTableCells/InputGroupCell.js | 13 +- .../PaymentReceiveListFieldCell.js | 35 ++ .../DataTableCells/PercentFieldCell.js | 43 ++ client/src/components/DataTableCells/index.js | 9 +- client/src/components/EstimateListField.js | 73 ++++ .../src/components/PaymentReceiveListField.js | 38 ++ client/src/config/sidebarMenu.js | 59 ++- .../Purchases/Bill/BillActionsBar.js | 148 +++++++ .../src/containers/Purchases/Bill/BillForm.js | 319 +++++++++++++++ .../Purchases/Bill/BillFormFooter.js | 52 +++ .../Purchases/Bill/BillFormHeader.js | 190 +++++++++ .../src/containers/Purchases/Bill/BillList.js | 178 ++++++++ .../containers/Purchases/Bill/BillViewTabs.js | 110 +++++ client/src/containers/Purchases/Bill/Bills.js | 85 ++++ .../Purchases/Bill/BillsDataTable.js | 243 +++++++++++ .../Purchases/Bill/withBillActions.js | 32 ++ .../Purchases/Bill/withBillDetail.js | 11 + .../containers/Purchases/Bill/withBills.js | 30 ++ client/src/containers/Purchases/BillForm.js | 299 ++++++++++++++ .../containers/Purchases/BillFormFooter.js | 41 ++ .../containers/Purchases/BillFormHeader.js | 185 +++++++++ client/src/containers/Purchases/Bills.js | 62 +++ .../containers/Purchases/withBillActions.js | 32 ++ .../Sales/Estimate/EntriesItemsTable.js | 259 ++++++++++++ .../Sales/Estimate/EstimateActionsBar.js | 168 ++++++++ .../containers/Sales/Estimate/EstimateForm.js | 360 +++++++++++++++++ .../Sales/Estimate/EstimateFormFooter.js | 60 +++ .../Sales/Estimate/EstimateFormHeader.js | 192 +++++++++ .../containers/Sales/Estimate/EstimateList.js | 190 +++++++++ .../Sales/Estimate/EstimateViewTabs.js | 110 +++++ .../containers/Sales/Estimate/Estimates.js | 68 ++++ .../Sales/Estimate/EstimatesDataTable.js | 254 ++++++++++++ .../Sales/Estimate/withEstimateActions.js | 32 ++ .../Sales/Estimate/withEstimateDetail.js | 11 + .../Sales/Estimate/withEstimates.js | 27 ++ .../Sales/Invoice/InvoiceActionsBar.js | 151 +++++++ .../containers/Sales/Invoice/InvoiceForm.js | 351 ++++++++++++++++ .../Sales/Invoice/InvoiceFormFooter.js | 52 +++ .../Sales/Invoice/InvoiceFormHeader.js | 177 ++++++++ .../containers/Sales/Invoice/InvoiceList.js | 177 ++++++++ .../Sales/Invoice/InvoiceViewTabs.js | 113 ++++++ .../src/containers/Sales/Invoice/Invoices.js | 68 ++++ .../Sales/Invoice/InvoicesDataTable.js | 254 ++++++++++++ .../Sales/Invoice/withInvoiceActions.js | 31 ++ .../Sales/Invoice/withInvoiceDetail.js | 11 + .../containers/Sales/Invoice/withInvoices.js | 28 ++ .../PaymentReceive/PaymentReceiveForm.js | 260 ++++++++++++ .../PaymentReceiveFormFooter.js | 46 +++ .../PaymentReceiveFormHeader.js | 204 ++++++++++ .../PaymentReceiveItemsTable.js | 13 + .../Sales/PaymentReceive/PaymentReceives.js | 69 ++++ .../withPaymentReceivesActions.js | 34 ++ .../Sales/Receipt/ReceiptActionsBar.js | 149 +++++++ .../containers/Sales/Receipt/ReceiptForm.js | 340 ++++++++++++++++ .../Sales/Receipt/ReceiptFormFooter.js | 52 +++ .../Sales/Receipt/ReceiptFormHeader.js | 214 ++++++++++ .../containers/Sales/Receipt/ReceiptList.js | 191 +++++++++ .../Sales/Receipt/ReceiptViewTabs.js | 109 +++++ .../src/containers/Sales/Receipt/Receipts.js | 82 ++++ .../Sales/Receipt/ReceiptsDataTable.js | 247 ++++++++++++ .../Sales/Receipt/withReceipActions.js | 33 ++ .../Sales/Receipt/withReceiptDetail.js | 11 + .../containers/Sales/Receipt/withReceipts.js | 29 ++ .../containers/Vendors/withVendorActions.js | 32 ++ client/src/containers/Vendors/withVendors.js | 27 ++ client/src/lang/en/index.js | 140 ++++++- client/src/routes/dashboard.js | 118 ++++++ client/src/store/Bills/bills.actions.js | 95 +++++ client/src/store/Bills/bills.reducer.js | 104 +++++ client/src/store/Bills/bills.selectors.js | 53 +++ client/src/store/Bills/bills.type.js | 12 + .../src/store/Estimate/estimates.actions.js | 140 +++++++ .../src/store/Estimate/estimates.reducer.js | 107 +++++ .../src/store/Estimate/estimates.selectors.js | 51 +++ client/src/store/Estimate/estimates.types.js | 13 + client/src/store/Invoice/invoices.actions.js | 145 +++++++ client/src/store/Invoice/invoices.reducer.js | 105 +++++ client/src/store/Invoice/invoices.selector.js | 53 +++ client/src/store/Invoice/invoices.types.js | 12 + .../PaymentReceive/paymentReceive.actions.js | 136 +++++++ .../PaymentReceive/paymentReceive.reducer.js | 0 .../PaymentReceive/paymentReceive.selector.js | 0 .../PaymentReceive/paymentReceive.type.js | 11 + client/src/store/receipt/receipt.actions.js | 138 +++++++ client/src/store/receipt/receipt.reducer.js | 103 +++++ client/src/store/receipt/receipt.selector.js | 52 +++ client/src/store/receipt/receipt.type.js | 12 + client/src/store/reducers.js | 12 + client/src/store/types.js | 12 + client/src/store/vendors/vendors.actions.js | 120 ++++++ client/src/store/vendors/vendors.reducer.js | 96 +++++ client/src/store/vendors/vendors.selectors.js | 54 +++ client/src/store/vendors/vendors.types.js | 11 + client/src/style/App.scss | 43 +- client/src/style/pages/estimate.scss | 379 ++++++++++++++++++ 97 files changed, 9901 insertions(+), 48 deletions(-) create mode 100644 client/src/components/DataTableCells/DivFieldCell.js create mode 100644 client/src/components/DataTableCells/EstimatesListFieldCell.js create mode 100644 client/src/components/DataTableCells/PaymentReceiveListFieldCell.js create mode 100644 client/src/components/DataTableCells/PercentFieldCell.js create mode 100644 client/src/components/EstimateListField.js create mode 100644 client/src/components/PaymentReceiveListField.js create mode 100644 client/src/containers/Purchases/Bill/BillActionsBar.js create mode 100644 client/src/containers/Purchases/Bill/BillForm.js create mode 100644 client/src/containers/Purchases/Bill/BillFormFooter.js create mode 100644 client/src/containers/Purchases/Bill/BillFormHeader.js create mode 100644 client/src/containers/Purchases/Bill/BillList.js create mode 100644 client/src/containers/Purchases/Bill/BillViewTabs.js create mode 100644 client/src/containers/Purchases/Bill/Bills.js create mode 100644 client/src/containers/Purchases/Bill/BillsDataTable.js create mode 100644 client/src/containers/Purchases/Bill/withBillActions.js create mode 100644 client/src/containers/Purchases/Bill/withBillDetail.js create mode 100644 client/src/containers/Purchases/Bill/withBills.js create mode 100644 client/src/containers/Purchases/BillForm.js create mode 100644 client/src/containers/Purchases/BillFormFooter.js create mode 100644 client/src/containers/Purchases/BillFormHeader.js create mode 100644 client/src/containers/Purchases/Bills.js create mode 100644 client/src/containers/Purchases/withBillActions.js create mode 100644 client/src/containers/Sales/Estimate/EntriesItemsTable.js create mode 100644 client/src/containers/Sales/Estimate/EstimateActionsBar.js create mode 100644 client/src/containers/Sales/Estimate/EstimateForm.js create mode 100644 client/src/containers/Sales/Estimate/EstimateFormFooter.js create mode 100644 client/src/containers/Sales/Estimate/EstimateFormHeader.js create mode 100644 client/src/containers/Sales/Estimate/EstimateList.js create mode 100644 client/src/containers/Sales/Estimate/EstimateViewTabs.js create mode 100644 client/src/containers/Sales/Estimate/Estimates.js create mode 100644 client/src/containers/Sales/Estimate/EstimatesDataTable.js create mode 100644 client/src/containers/Sales/Estimate/withEstimateActions.js create mode 100644 client/src/containers/Sales/Estimate/withEstimateDetail.js create mode 100644 client/src/containers/Sales/Estimate/withEstimates.js create mode 100644 client/src/containers/Sales/Invoice/InvoiceActionsBar.js create mode 100644 client/src/containers/Sales/Invoice/InvoiceForm.js create mode 100644 client/src/containers/Sales/Invoice/InvoiceFormFooter.js create mode 100644 client/src/containers/Sales/Invoice/InvoiceFormHeader.js create mode 100644 client/src/containers/Sales/Invoice/InvoiceList.js create mode 100644 client/src/containers/Sales/Invoice/InvoiceViewTabs.js create mode 100644 client/src/containers/Sales/Invoice/Invoices.js create mode 100644 client/src/containers/Sales/Invoice/InvoicesDataTable.js create mode 100644 client/src/containers/Sales/Invoice/withInvoiceActions.js create mode 100644 client/src/containers/Sales/Invoice/withInvoiceDetail.js create mode 100644 client/src/containers/Sales/Invoice/withInvoices.js create mode 100644 client/src/containers/Sales/PaymentReceive/PaymentReceiveForm.js create mode 100644 client/src/containers/Sales/PaymentReceive/PaymentReceiveFormFooter.js create mode 100644 client/src/containers/Sales/PaymentReceive/PaymentReceiveFormHeader.js create mode 100644 client/src/containers/Sales/PaymentReceive/PaymentReceiveItemsTable.js create mode 100644 client/src/containers/Sales/PaymentReceive/PaymentReceives.js create mode 100644 client/src/containers/Sales/PaymentReceive/withPaymentReceivesActions.js create mode 100644 client/src/containers/Sales/Receipt/ReceiptActionsBar.js create mode 100644 client/src/containers/Sales/Receipt/ReceiptForm.js create mode 100644 client/src/containers/Sales/Receipt/ReceiptFormFooter.js create mode 100644 client/src/containers/Sales/Receipt/ReceiptFormHeader.js create mode 100644 client/src/containers/Sales/Receipt/ReceiptList.js create mode 100644 client/src/containers/Sales/Receipt/ReceiptViewTabs.js create mode 100644 client/src/containers/Sales/Receipt/Receipts.js create mode 100644 client/src/containers/Sales/Receipt/ReceiptsDataTable.js create mode 100644 client/src/containers/Sales/Receipt/withReceipActions.js create mode 100644 client/src/containers/Sales/Receipt/withReceiptDetail.js create mode 100644 client/src/containers/Sales/Receipt/withReceipts.js create mode 100644 client/src/containers/Vendors/withVendorActions.js create mode 100644 client/src/containers/Vendors/withVendors.js create mode 100644 client/src/store/Bills/bills.actions.js create mode 100644 client/src/store/Bills/bills.reducer.js create mode 100644 client/src/store/Bills/bills.selectors.js create mode 100644 client/src/store/Bills/bills.type.js create mode 100644 client/src/store/Estimate/estimates.actions.js create mode 100644 client/src/store/Estimate/estimates.reducer.js create mode 100644 client/src/store/Estimate/estimates.selectors.js create mode 100644 client/src/store/Estimate/estimates.types.js create mode 100644 client/src/store/Invoice/invoices.actions.js create mode 100644 client/src/store/Invoice/invoices.reducer.js create mode 100644 client/src/store/Invoice/invoices.selector.js create mode 100644 client/src/store/Invoice/invoices.types.js create mode 100644 client/src/store/PaymentReceive/paymentReceive.actions.js create mode 100644 client/src/store/PaymentReceive/paymentReceive.reducer.js create mode 100644 client/src/store/PaymentReceive/paymentReceive.selector.js create mode 100644 client/src/store/PaymentReceive/paymentReceive.type.js create mode 100644 client/src/store/receipt/receipt.actions.js create mode 100644 client/src/store/receipt/receipt.reducer.js create mode 100644 client/src/store/receipt/receipt.selector.js create mode 100644 client/src/store/receipt/receipt.type.js create mode 100644 client/src/store/vendors/vendors.actions.js create mode 100644 client/src/store/vendors/vendors.reducer.js create mode 100644 client/src/store/vendors/vendors.selectors.js create mode 100644 client/src/store/vendors/vendors.types.js create mode 100644 client/src/style/pages/estimate.scss diff --git a/client/src/components/DataTableCells/DivFieldCell.js b/client/src/components/DataTableCells/DivFieldCell.js new file mode 100644 index 000000000..a9274ceae --- /dev/null +++ b/client/src/components/DataTableCells/DivFieldCell.js @@ -0,0 +1,11 @@ +import React, { useState, useEffect } from 'react'; + +export default function DivFieldCell({ cell: { value: initialValue } }) { + const [value, setValue] = useState(initialValue); + + useEffect(() => { + setValue(initialValue); + }, [initialValue]); + + return
${value}
; +} diff --git a/client/src/components/DataTableCells/EstimatesListFieldCell.js b/client/src/components/DataTableCells/EstimatesListFieldCell.js new file mode 100644 index 000000000..1923028c0 --- /dev/null +++ b/client/src/components/DataTableCells/EstimatesListFieldCell.js @@ -0,0 +1,38 @@ +import React, { useCallback, useMemo } from 'react'; +import EstimateListField from 'components/EstimateListField'; +import classNames from 'classnames'; +import { FormGroup, Classes, Intent } from '@blueprintjs/core'; + +function EstimatesListFieldCell({ + column: { id }, + row: { index }, + cell: { value: initialValue }, + payload: { products, updateData, errors }, +}) { + const handleProductSelected = useCallback( + (item) => { + updateData(index, id, item.id); + }, + [updateData, index, id], + ); + + const error = errors?.[index]?.[id]; + + return ( + + + + ); +} + +export default EstimatesListFieldCell; diff --git a/client/src/components/DataTableCells/InputGroupCell.js b/client/src/components/DataTableCells/InputGroupCell.js index 07872a00f..3521fcbba 100644 --- a/client/src/components/DataTableCells/InputGroupCell.js +++ b/client/src/components/DataTableCells/InputGroupCell.js @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react'; import classNames from 'classnames'; -import { Classes, InputGroup, FormGroup } from '@blueprintjs/core'; +import { Classes, InputGroup, FormGroup, Intent } from '@blueprintjs/core'; const InputEditableCell = ({ row: { index }, @@ -20,8 +20,17 @@ const InputEditableCell = ({ setValue(initialValue); }, [initialValue]); + const error = payload.errors?.[index]?.[id]; + return ( - + { + updateData(index, id, _item.id); + }, + [updateData, index, id], + ); + + const error = errors?.[index]?.[id]; + + return ( + + + + ); +} + +export default PaymentReceiveListFieldCell; diff --git a/client/src/components/DataTableCells/PercentFieldCell.js b/client/src/components/DataTableCells/PercentFieldCell.js new file mode 100644 index 000000000..e6b2a0092 --- /dev/null +++ b/client/src/components/DataTableCells/PercentFieldCell.js @@ -0,0 +1,43 @@ +import React, { useCallback, useState, useEffect } from 'react'; +import { FormGroup, Intent } from '@blueprintjs/core'; +import MoneyInputGroup from 'components/MoneyInputGroup'; + +const PercentFieldCell = ({ + cell: { value: initialValue }, + row: { index }, + column: { id }, + payload: { errors, updateData }, +}) => { + const [value, setValue] = useState(initialValue); + + const onBlur = (e) => { + updateData(index, id, parseInt(e.target.value, 10)); + }; + + const onChange = useCallback((e) => { + setValue(e.target.value); + }, []); + + + useEffect(() => { + setValue(initialValue); + }, [initialValue]); + + const error = errors?.[index]?.[id]; + + return ( + + + + ); +}; + +export default PercentFieldCell; diff --git a/client/src/components/DataTableCells/index.js b/client/src/components/DataTableCells/index.js index 60232831e..54be925fe 100644 --- a/client/src/components/DataTableCells/index.js +++ b/client/src/components/DataTableCells/index.js @@ -2,10 +2,15 @@ import AccountsListFieldCell from './AccountsListFieldCell'; import MoneyFieldCell from './MoneyFieldCell'; import InputGroupCell from './InputGroupCell'; import ContactsListFieldCell from './ContactsListFieldCell'; - +import EstimatesListFieldCell from './EstimatesListFieldCell'; +import PercentFieldCell from './PercentFieldCell'; +import DivFieldCell from './DivFieldCell'; export { AccountsListFieldCell, MoneyFieldCell, InputGroupCell, ContactsListFieldCell, -} \ No newline at end of file + EstimatesListFieldCell, + PercentFieldCell, + DivFieldCell, +}; diff --git a/client/src/components/EstimateListField.js b/client/src/components/EstimateListField.js new file mode 100644 index 000000000..a7b94a662 --- /dev/null +++ b/client/src/components/EstimateListField.js @@ -0,0 +1,73 @@ +import React, { useCallback, useMemo, useEffect, useState } from 'react'; +import { MenuItem } from '@blueprintjs/core'; +import ListSelect from 'components/ListSelect'; +import { FormattedMessage as T } from 'react-intl'; + +function EstimateListField({ + products, + initialProductId, + selectedProductId, + defautlSelectText = , + onProductSelected, +}) { + const initialProduct = useMemo( + () => products.find((a) => a.id === initialProductId), + [initialProductId], + ); + + const [selectedProduct, setSelectedProduct] = useState( + initialProduct || null, + ); + + useEffect(() => { + if (typeof selectedProductId !== 'undefined') { + const product = selectedProductId + ? products.find((a) => a.id === selectedProductId) + : null; + setSelectedProduct(product); + } + }, [selectedProductId, products, setSelectedProduct]); + + const onProductSelect = useCallback( + (product) => { + setSelectedProduct({ ...product }); + onProductSelected && onProductSelected(product); + }, + [onProductSelected], + ); + + const productRenderer = useCallback( + (item, { handleClick }) => ( + + ), + [], + ); + + const filterProduct = useCallback((query, product, _index, exactMatch) => { + const normalizedTitle = product.name.toLowerCase(); + const normalizedQuery = query.toLowerCase(); + + if (exactMatch) { + return normalizedTitle === normalizedQuery; + } else { + return normalizedTitle.indexOf(normalizedQuery) >= 0; + } + }, []); + + return ( + } + itemRenderer={productRenderer} + itemPredicate={filterProduct} + popoverProps={{ minimal: true }} + onItemSelect={onProductSelect} + selectedItem={`${selectedProductId}`} + selectedItemProp={'id'} + labelProp={'name'} + defaultText={selectedProduct ? selectedProduct.name : defautlSelectText} + /> + ); +} + +export default EstimateListField; diff --git a/client/src/components/PaymentReceiveListField.js b/client/src/components/PaymentReceiveListField.js new file mode 100644 index 000000000..4c9f828ba --- /dev/null +++ b/client/src/components/PaymentReceiveListField.js @@ -0,0 +1,38 @@ +import React, { useCallback } from 'react'; +import { MenuItem } from '@blueprintjs/core'; +import ListSelect from 'components/ListSelect'; +import { FormattedMessage as T } from 'react-intl'; + +function PaymentReceiveListField({ + invoices, + selectedInvoiceId, + onInvoiceSelected, + defaultSelectText = , +}) { + const onInvoiceSelect = useCallback((_invoice) => { + onInvoiceSelected && onInvoiceSelected(_invoice); + }); + + const handleInvoiceRenderer = useCallback( + (item, { handleClick }) => ( + + ), + [], + ); + + return ( + } + itemRenderer={handleInvoiceRenderer} + popoverProps={{ minimal: true }} + onItemSelect={onInvoiceSelect} + selectedItem={`${selectedInvoiceId}`} + selectedItemProp={'id'} + labelProp={'name'} + defaultText={defaultSelectText} + /> + ); +} + +export default PaymentReceiveListField; diff --git a/client/src/config/sidebarMenu.js b/client/src/config/sidebarMenu.js index 1f904e8cd..8945fa9dd 100644 --- a/client/src/config/sidebarMenu.js +++ b/client/src/config/sidebarMenu.js @@ -37,16 +37,55 @@ export default [ }, { text: , - children: [], + children: [ + { + text: , + href: '/estimates/new', + }, + // { + // text: , + // href: '/estimates', + // }, + { + text: , + href: '/invoices/new', + }, + // { + // text: , + // href: '/invoices', + // }, + { + text: , + href: '/payment-receive/new', + }, + { + divider: true, + text: , + href: '/invoices', + }, + { + text: , + href: '/receipts/new', + }, + // { + // text: , + // href: '/receipts', + // }, + ], }, { text: , children: [ { - icon: 'cut', - text: 'cut', - label: '⌘C', - disabled: false, + text: , + href: '/bills/new', + }, + // { + // text: , + // href: '/bills', + // }, + { + text: , }, ], }, @@ -99,11 +138,11 @@ export default [ text: , children: [ { - text: , + text: , href: '/expenses-list', }, { - text: , + text: , href: '/expenses/new', }, ], @@ -140,12 +179,12 @@ export default [ }, { text: 'Receivable Aging Summary', - href: '/financial-reports/receivable-aging-summary' + href: '/financial-reports/receivable-aging-summary', }, { text: 'Payable Aging Summary', - href: '/financial-reports/payable-aging-summary' - } + href: '/financial-reports/payable-aging-summary', + }, ], }, { diff --git a/client/src/containers/Purchases/Bill/BillActionsBar.js b/client/src/containers/Purchases/Bill/BillActionsBar.js new file mode 100644 index 000000000..dad1c4f2b --- /dev/null +++ b/client/src/containers/Purchases/Bill/BillActionsBar.js @@ -0,0 +1,148 @@ +import React, { useCallback, useState, useMemo } from 'react'; +import Icon from 'components/Icon'; +import { + Button, + Classes, + Menu, + MenuItem, + Popover, + NavbarDivider, + NavbarGroup, + PopoverInteractionKind, + Position, + Intent, +} from '@blueprintjs/core'; + +import classNames from 'classnames'; +import { useRouteMatch, useHistory } from 'react-router-dom'; +import { FormattedMessage as T, useIntl } from 'react-intl'; + +import { connect } from 'react-redux'; +import FilterDropdown from 'components/FilterDropdown'; +import DashboardActionsBar from 'components/Dashboard/DashboardActionsBar'; + +import { If, DashboardActionViewsList } from 'components'; + +import withResourceDetail from 'containers/Resources/withResourceDetails'; +import withBillActions from './withBillActions'; +import withBills from './withBills'; + +import { compose } from 'utils'; + +function BillActionsBar({ + // #withResourceDetail + resourceFields, + + //#withBills + billsViews, + + //#withBillActions + addBillsTableQueries, + + // #own Porps + onFilterChanged, + selectedRows = [], +}) { + const history = useHistory(); + const { path } = useRouteMatch(); + const [filterCount, setFilterCount] = useState(0); + const { formatMessage } = useIntl(); + + const handleClickNewBill = useCallback(() => { + history.push('/bills/new'); + }, [history]); + + // const FilterDropdown = FilterDropdown({ + // initialCondition: { + // fieldKey: '', + // compatator: '', + // value: '', + // }, + // fields: resourceFields, + // onFilterChange: (filterConditions) => { + // addBillsTableQueries({ + // filter_roles: filterConditions || '', + // }); + // onFilterChanged && onFilterChanged(filterConditions); + // }, + // }); + + const hasSelectedRows = useMemo(() => selectedRows.length > 0, [ + selectedRows, + ]); + + return ( + + + + +