diff --git a/server/src/api/controllers/Inventory/InventoryAdjustments.ts b/server/src/api/controllers/Inventory/InventoryAdjustments.ts new file mode 100644 index 000000000..1d951a3e9 --- /dev/null +++ b/server/src/api/controllers/Inventory/InventoryAdjustments.ts @@ -0,0 +1,172 @@ +import { Inject, Service } from 'typedi'; +import { Router, Request, Response, NextFunction } from 'express'; +import { check, param } from 'express-validator'; +import { ServiceError } from 'exceptions'; +import BaseController from "../BaseController"; +import InventoryAdjustmentService from "services/Inventory/InventoryAdjustmentService"; + +@Service() +export default class InventoryAdjustmentsController extends BaseController { + @Inject() + inventoryAdjustmentService: InventoryAdjustmentService; + + /** + * Router constructor. + */ + router() { + const router = Router(); + + router.delete( + '/:id', + [ + param('id').exists().isNumeric().toInt(), + ], + this.validationResult, + this.asyncMiddleware(this.deleteInventoryAdjustment.bind(this)), + this.handleServiceErrors, + ); + router.post( + '/quick', + this.validatateQuickAdjustment, + this.validationResult, + this.asyncMiddleware(this.createQuickInventoryAdjustment.bind(this)), + this.handleServiceErrors + ); + router.get( + '/', + this.asyncMiddleware(this.getInventoryAdjustments.bind(this)), + this.handleServiceErrors + ); + return router; + } + + /** + * Quick inventory adjustment validation schema. + */ + get validatateQuickAdjustment() { + return [ + check('date').exists().isISO8601(), + check('type').exists().isIn(['increment', 'decrement', 'value_adjustment']), + check('reference_no').exists(), + check('adjustment_account_id').exists().isInt().toInt(), + check('reason').exists().isString().exists(), + check('description').optional().isString(), + check('item_id').exists().isInt().toInt(), + check('new_quantity').optional().isInt(), + check('new_value').optional().toFloat(), + ]; + } + + /** + * Creates a quick inventory adjustment. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + async createQuickInventoryAdjustment(req: Request, res: Response, next: NextFunction) { + const { tenantId } = req; + const quickInventoryAdjustment = this.matchedBodyData(req); + + try { + await this.inventoryAdjustmentService + .createQuickAdjustment(tenantId, quickInventoryAdjustment); + + return res.status(200).send({ + message: 'The inventory adjustment has been created successfully.', + }); + } catch (error) { + next(error); + } + } + + /** + * Deletes the given inventory adjustment transaction. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + async deleteInventoryAdjustment(req: Request, res: Response, next: NextFunction) { + const { tenantId } = req; + const { id: adjustmentId } = req.params; + + try { + await this.inventoryAdjustmentService + .deleteInventoryAdjustment(tenantId, adjustmentId); + + return res.status(200).send({ + message: 'The inventory adjustment has been deleted successfully.', + }); + } catch (error) { + next(error); + } + } + + /** + * Retrieve the inventory adjustments paginated list. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + async getInventoryAdjustments(req: Request, res: Response, next: NextFunction) { + const { tenantId } = req; + const filter = { + page: 1, + pageSize: 12, + ...this.matchedQueryData(req), + }; + + try { + const { + pagination, + inventoryAdjustments, + } = await this.inventoryAdjustmentService + .getInventoryAdjustments(tenantId, filter); + + return res.status(200).send({ + inventoy_adjustments: inventoryAdjustments, + pagination: this.transfromToResponse(pagination), + }); + } catch (error) { + next(error); + } + } + + /** + * Handles service errors. + * @param {Error} error + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + handleServiceErrors( + error: Error, + req: Request, + res: Response, + next: NextFunction + ) { + if (error instanceof ServiceError) { + if (error.errorType === 'INVENTORY_ADJUSTMENT_NOT_FOUND') { + return res.status(400).send({ + errors: [{ type: 'INVENTORY_ADJUSTMENT.NOT.FOUND', code: 100 }], + }); + } + if (error.errorType === 'NOT_FOUND') { + return res.status(400).send({ + errors: [{ type: 'ITEM.NOT.FOUND', code: 140 }], + }); + } + if (error.errorType === 'account_not_found') { + return res.boom.notFound('The given account not found.', { + errors: [{ type: 'ACCOUNT.NOT.FOUND', code: 100 }], + }); + } + if (error.errorType === 'ITEM_SHOULD_BE_INVENTORY_TYPE') { + return res.boom.badRequest( + 'You could not make adjustment on item has no inventory type.', + { errors: [{ type: 'ITEM_SHOULD_BE_INVENTORY_TYPE', code: 300 }], } + ); + } + } + next(error); + } +} \ No newline at end of file diff --git a/server/src/api/index.ts b/server/src/api/index.ts index 359803e70..0143b5200 100644 --- a/server/src/api/index.ts +++ b/server/src/api/index.ts @@ -37,6 +37,7 @@ import Media from 'api/controllers/Media'; import Ping from 'api/controllers/Ping'; import Subscription from 'api/controllers/Subscription'; import Licenses from 'api/controllers/Subscription/Licenses'; +import InventoryAdjustments from 'api/controllers/Inventory/InventoryAdjustments'; export default () => { const app = Router(); @@ -99,6 +100,7 @@ export default () => { dashboard.use('/resources', Container.get(Resources).router()); dashboard.use('/exchange_rates', Container.get(ExchangeRates).router()); dashboard.use('/media', Container.get(Media).router()); + dashboard.use('/inventory_adjustments', Container.get(InventoryAdjustments).router()); app.use('/', dashboard); diff --git a/server/src/database/migrations/20200810121809_create_inventory_adjustments_table.js b/server/src/database/migrations/20200810121809_create_inventory_adjustments_table.js new file mode 100644 index 000000000..b0b2351dd --- /dev/null +++ b/server/src/database/migrations/20200810121809_create_inventory_adjustments_table.js @@ -0,0 +1,15 @@ + +exports.up = function(knex) { + return knex.schema.createTable('inventory_adjustments', table => { + table.increments(); + table.date('date').index(); + table.string('type').index(); + table.string('reason'); + table.string('reference_no').index(); + table.string('description'); + }); +}; + +exports.down = function(knex) { + return knex.schema.dropTableIfExists('inventory_adjustments'); +}; diff --git a/server/src/database/migrations/20200810121810_create_inventory_adjustments_entries_table.js b/server/src/database/migrations/20200810121810_create_inventory_adjustments_entries_table.js new file mode 100644 index 000000000..e58402ff2 --- /dev/null +++ b/server/src/database/migrations/20200810121810_create_inventory_adjustments_entries_table.js @@ -0,0 +1,15 @@ + +exports.up = function(knex) { + return knex.schema.createTable('inventory_adjustments_entries', table => { + table.increments(); + table.integer('adjustment_id').unsigned().index().references('id').inTable('inventory_adjustments'); + table.integer('index').unsigned(); + table.integer('item_id').unsigned().index().references('id').inTable('items'); + table.decimal('new_quantity').unsigned(); + table.decimal('new_cost').unsigned(); + }); +}; + +exports.down = function(knex) { + return knex.schema.dropTableIfExists('inventory_adjustments_entries'); +}; diff --git a/server/src/interfaces/InventoryAdjustment.ts b/server/src/interfaces/InventoryAdjustment.ts new file mode 100644 index 000000000..d42ccf905 --- /dev/null +++ b/server/src/interfaces/InventoryAdjustment.ts @@ -0,0 +1,38 @@ + +type IAdjustmentTypes = 'increment' | 'decrement' | 'value_adjustment'; + +export interface IQuickInventoryAdjustmentDTO { + date: Date | string; + type: IAdjustmentTypes, + adjustmentAccountId: number; + reason: string; + description: string; + referenceNo: string; + itemId: number; + newQuantity: number; + newValue: number; +}; + +export interface IInventoryAdjustment { + id?: number, + date: Date | string; + adjustmentAccountId: number; + reason: string; + description: string; + referenceNo: string; + entries: IInventoryAdjustmentEntry[] +}; + +export interface IInventoryAdjustmentEntry { + id?: number, + adjustmentId?: number, + index: number, + itemId: number; + newQuantity: number; + newValue: number; +} + +export interface IInventoryAdjustmentsFilter{ + page: number, + pageSize: number, +}; \ No newline at end of file diff --git a/server/src/interfaces/index.ts b/server/src/interfaces/index.ts index 0912db23b..da77e0ed8 100644 --- a/server/src/interfaces/index.ts +++ b/server/src/interfaces/index.ts @@ -38,4 +38,5 @@ export * from './JournalReport'; export * from './AgingReport'; export * from './ARAgingSummaryReport'; export * from './APAgingSummaryReport'; -export * from './Mailable'; \ No newline at end of file +export * from './Mailable'; +export * from './InventoryAdjustment'; \ No newline at end of file diff --git a/server/src/models/InventoryAdjustment.js b/server/src/models/InventoryAdjustment.js new file mode 100644 index 000000000..5edf37e4c --- /dev/null +++ b/server/src/models/InventoryAdjustment.js @@ -0,0 +1,29 @@ +import { Model } from 'objection'; +import TenantModel from 'models/TenantModel'; + +export default class InventoryAdjustment extends TenantModel { + /** + * Table name + */ + static get tableName() { + return 'inventory_adjustments'; + } + + /** + * Relationship mapping. + */ + static get relationMappings() { + const Account = require('models/InventoryAdjustmentEntry'); + + return { + entries: { + relation: Model.BelongsToOneRelation, + modelClass: Account.default, + join: { + from: 'inventory_adjustments.id', + to: 'inventory_adjustments_entries.adjustmentId', + }, + }, + }; + } +} diff --git a/server/src/models/InventoryAdjustmentEntry.js b/server/src/models/InventoryAdjustmentEntry.js new file mode 100644 index 000000000..eb0e72b8c --- /dev/null +++ b/server/src/models/InventoryAdjustmentEntry.js @@ -0,0 +1,29 @@ +import { Model } from 'objection'; +import TenantModel from 'models/TenantModel'; + +export default class InventoryAdjustmentEntry extends TenantModel { + /** + * Table name + */ + static get tableName() { + return 'inventory_adjustments_entries'; + } + + /** + * Relationship mapping. + */ + static get relationMappings() { + const Account = require('models/InventoryAdjustment'); + + return { + entries: { + relation: Model.BelongsToOneRelation, + modelClass: Account.default, + join: { + from: 'inventory_adjustments_entries.adjustmentId', + to: 'inventory_adjustments.id', + }, + }, + }; + } +} diff --git a/server/src/services/Inventory/InventoryAdjustmentService.ts b/server/src/services/Inventory/InventoryAdjustmentService.ts new file mode 100644 index 000000000..5e02df7f4 --- /dev/null +++ b/server/src/services/Inventory/InventoryAdjustmentService.ts @@ -0,0 +1,190 @@ +import { Inject, Service } from 'typedi'; +import { omit } from 'lodash'; +import { + EventDispatcher, + EventDispatcherInterface, +} from 'decorators/eventDispatcher'; +import { ServiceError } from 'exceptions'; +import { + IQuickInventoryAdjustmentDTO, + IInventoryAdjustment, + IPaginationMeta, + IInventoryAdjustmentsFilter +} from 'interfaces'; +import events from 'subscribers/events'; +import AccountsService from 'services/Accounts/AccountsService'; +import ItemsService from 'services/Items/ItemsService'; +import HasTenancyService from 'services/Tenancy/TenancyService'; + +const ERRORS = { + INVENTORY_ADJUSTMENT_NOT_FOUND: 'INVENTORY_ADJUSTMENT_NOT_FOUND', + ITEM_SHOULD_BE_INVENTORY_TYPE: 'ITEM_SHOULD_BE_INVENTORY_TYPE', +}; + +@Service() +export default class InventoryAdjustmentService { + @Inject() + itemsService: ItemsService; + + @Inject() + accountsService: AccountsService; + + @Inject() + tenancy: HasTenancyService; + + @Inject('logger') + logger: any; + + @EventDispatcher() + eventDispatcher: EventDispatcherInterface; + + /** + * Transformes the quick inventory adjustment DTO to model object. + * @param {IQuickInventoryAdjustmentDTO} adjustmentDTO - + * @return {IInventoryAdjustment} + */ + transformQuickAdjToModel( + adjustmentDTO: IQuickInventoryAdjustmentDTO + ): IInventoryAdjustment { + return { + ...omit(adjustmentDTO, ['newQuantity', 'newCost', 'itemId']), + entries: [ + { + index: 1, + itemId: adjustmentDTO.itemId, + newQuantity: adjustmentDTO.newQuantity, + newCost: adjustmentDTO.newCost, + }, + ], + }; + } + + /** + * Validate the item inventory type. + * @param {IItem} item + */ + validateItemInventoryType(item) { + if (item.type !== 'inventory') { + throw new ServiceError(ERRORS.ITEM_SHOULD_BE_INVENTORY_TYPE); + } + } + + /** + * Retrieve the inventory adjustment or throw not found service error. + * @param {number} tenantId - + * @param {number} adjustmentId - + */ + async getInventoryAdjustmentOrThrowError( + tenantId: number, + adjustmentId: number + ) { + const { InventoryAdjustment } = this.tenancy.models(tenantId); + + const inventoryAdjustment = await InventoryAdjustment.query() + .findById(adjustmentId) + .withGraphFetched('entries'); + + if (!inventoryAdjustment) { + throw new ServiceError(ERRORS.INVENTORY_ADJUSTMENT_NOT_FOUND); + } + return inventoryAdjustment; + } + + /** + * Creates a quick inventory adjustment for specific item. + * @param {number} tenantId - Tenant id. + * @param {IQuickInventoryAdjustmentDTO} quickAdjustmentDTO - qucik adjustment DTO. + */ + async createQuickAdjustment( + tenantId: number, + quickAdjustmentDTO: IQuickInventoryAdjustmentDTO + ) { + const { InventoryAdjustment } = this.tenancy.models(tenantId); + + // Retrieve the adjustment account or throw not found error. + const adjustmentAccount = await this.accountsService.getAccountOrThrowError( + tenantId, + quickAdjustmentDTO.adjustmentAccountId + ); + // Retrieve the item model or throw not found service error. + const item = await this.itemsService.getItemOrThrowError( + tenantId, + quickAdjustmentDTO.itemId + ); + // Validate item inventory type. + this.validateItemInventoryType(item); + + // Transform the DTO to inventory adjustment model. + const invAdjustmentObject = this.transformQuickAdjToModel( + quickAdjustmentDTO + ); + + await InventoryAdjustment.query().upsertGraph({ + ...invAdjustmentObject, + }); + // Triggers `onInventoryAdjustmentQuickCreated` event. + await this.eventDispatcher.dispatch( + events.inventoryAdjustment.onQuickCreated, + { + tenantId, + } + ); + } + + /** + * Deletes the inventory adjustment transaction. + * @param {number} tenantId - Tenant id. + * @param {number} inventoryAdjustmentId - Inventory adjustment id. + */ + async deleteInventoryAdjustment( + tenantId: number, + inventoryAdjustmentId: number + ): Promise { + // Retrieve the inventory adjustment or throw not found service error. + const adjustment = await this.getInventoryAdjustmentOrThrowError( + tenantId, + inventoryAdjustmentId + ); + const { + InventoryAdjustmentEntry, + InventoryAdjustment, + } = this.tenancy.models(tenantId); + + // Deletes the inventory adjustment entries. + await InventoryAdjustmentEntry.query() + .where('adjustment_id', inventoryAdjustmentId) + .delete(); + + // Deletes the inventory adjustment transaction. + await InventoryAdjustment.query().findById(inventoryAdjustmentId).delete(); + + // Triggers `onInventoryAdjustmentDeleted` event. + await this.eventDispatcher.dispatch(events.inventoryAdjustment.onDeleted, { + tenantId, + }); + } + + /** + * Retrieve the inventory adjustments paginated list. + * @param {number} tenantId + * @param {IInventoryAdjustmentsFilter} adjustmentsFilter + */ + async getInventoryAdjustments( + tenantId: number, + adjustmentsFilter: IInventoryAdjustmentsFilter + ): Promise<{ + inventoryAdjustments: IInventoryAdjustment[]; + pagination: IPaginationMeta; + }> { + const { InventoryAdjustment } = this.tenancy.models(tenantId); + + const { results, pagination } = await InventoryAdjustment.query() + .withGraphFetched('entries') + .pagination(adjustmentsFilter.page - 1, adjustmentsFilter.pageSize); + + return { + inventoryAdjustments: results, + pagination, + }; + } +} diff --git a/server/src/subscribers/events.ts b/server/src/subscribers/events.ts index a6219b022..0e55b0406 100644 --- a/server/src/subscribers/events.ts +++ b/server/src/subscribers/events.ts @@ -191,5 +191,11 @@ export default { onComputeItemCostJobScheduled: 'onComputeItemCostJobScheduled', onComputeItemCostJobStarted: 'onComputeItemCostJobStarted', onComputeItemCostJobCompleted: 'onComputeItemCostJobCompleted' + }, + + inventoryAdjustment: { + onCreated: 'onInventoryAdjustmentCreated', + onQuickCreated: 'onInventoryAdjustmentQuickCreated', + onDeleted: 'onInventoryAdjustmentDeleted', } }