import { defaultTo, difference } from 'lodash'; import { Service, Inject } from 'typedi'; import { EventDispatcher, EventDispatcherInterface, } from 'decorators/eventDispatcher'; import events from 'subscribers/events'; import { IItemsFilter, IItemsService, IItemDTO, IItem } from 'interfaces'; import DynamicListingService from 'services/DynamicListing/DynamicListService'; import TenancyService from 'services/Tenancy/TenancyService'; import { ServiceError } from 'exceptions'; import InventoryService from 'services/Inventory/Inventory'; import { ACCOUNT_PARENT_TYPE, ACCOUNT_ROOT_TYPE, ACCOUNT_TYPE } from 'data/AccountTypes' const ERRORS = { NOT_FOUND: 'NOT_FOUND', ITEMS_NOT_FOUND: 'ITEMS_NOT_FOUND', ITEM_NAME_EXISTS: 'ITEM_NAME_EXISTS', ITEM_CATEOGRY_NOT_FOUND: 'ITEM_CATEOGRY_NOT_FOUND', COST_ACCOUNT_NOT_COGS: 'COST_ACCOUNT_NOT_COGS', COST_ACCOUNT_NOT_FOUMD: 'COST_ACCOUNT_NOT_FOUMD', SELL_ACCOUNT_NOT_FOUND: 'SELL_ACCOUNT_NOT_FOUND', SELL_ACCOUNT_NOT_INCOME: 'SELL_ACCOUNT_NOT_INCOME', INVENTORY_ACCOUNT_NOT_FOUND: 'INVENTORY_ACCOUNT_NOT_FOUND', INVENTORY_ACCOUNT_NOT_INVENTORY: 'INVENTORY_ACCOUNT_NOT_INVENTORY', ITEMS_HAVE_ASSOCIATED_TRANSACTIONS: 'ITEMS_HAVE_ASSOCIATED_TRANSACTIONS', ITEM_HAS_ASSOCIATED_TRANSACTINS: 'ITEM_HAS_ASSOCIATED_TRANSACTINS', ITEM_HAS_ASSOCIATED_INVENTORY_ADJUSTMENT: 'ITEM_HAS_ASSOCIATED_INVENTORY_ADJUSTMENT', }; @Service() export default class ItemsService implements IItemsService { @Inject() tenancy: TenancyService; @Inject() dynamicListService: DynamicListingService; @Inject('logger') logger: any; @Inject() inventoryService: InventoryService; @EventDispatcher() eventDispatcher: EventDispatcherInterface; /** * Retrieve item details or throw not found error. * @param {number} tenantId * @param {number} itemId * @return {Promise} */ public async getItemOrThrowError( tenantId: number, itemId: number ): Promise { const { Item } = this.tenancy.models(tenantId); this.logger.info('[items] validate item id existance.', { itemId }); const foundItem = await Item.query().findById(itemId); if (!foundItem) { this.logger.info('[items] item not found.', { itemId }); throw new ServiceError(ERRORS.NOT_FOUND); } return foundItem; } /** * Validate wether the given item name already exists on the storage. * @param {number} tenantId * @param {string} itemName * @param {number} notItemId * @return {Promise} */ private async validateItemNameUniquiness( tenantId: number, itemName: string, notItemId?: number ): Promise { const { Item } = this.tenancy.models(tenantId); this.logger.info('[items] validate item name uniquiness.', { itemName, tenantId, }); const foundItems: [] = await Item.query().onBuild((builder: any) => { builder.where('name', itemName); if (notItemId) { builder.whereNot('id', notItemId); } }); if (foundItems.length > 0) { this.logger.info('[items] item name already exists.', { itemName, tenantId, }); throw new ServiceError(ERRORS.ITEM_NAME_EXISTS); } } /** * Validate item COGS account existance and type. * @param {number} tenantId * @param {number} costAccountId * @return {Promise} */ private async validateItemCostAccountExistance( tenantId: number, costAccountId: number ): Promise { const { accountRepository } = this.tenancy.repositories(tenantId); this.logger.info('[items] validate cost account existance.', { tenantId, costAccountId, }); const foundAccount = await accountRepository.findOneById(costAccountId); if (!foundAccount) { this.logger.info('[items] cost account not found.', { tenantId, costAccountId, }); throw new ServiceError(ERRORS.COST_ACCOUNT_NOT_FOUMD); // Detarmines the cost of goods sold account. } else if (!foundAccount.isParentType(ACCOUNT_PARENT_TYPE.EXPENSE)) { this.logger.info('[items] validate cost account not COGS type.', { tenantId, costAccountId, }); throw new ServiceError(ERRORS.COST_ACCOUNT_NOT_COGS); } } /** * Validate item sell account existance and type. * @param {number} tenantId - Tenant id. * @param {number} sellAccountId - Sell account id. */ private async validateItemSellAccountExistance( tenantId: number, sellAccountId: number ) { const { accountRepository, } = this.tenancy.repositories(tenantId); this.logger.info('[items] validate sell account existance.', { tenantId, sellAccountId, }); const foundAccount = await accountRepository.findOneById(sellAccountId); if (!foundAccount) { this.logger.info('[items] sell account not found.', { tenantId, sellAccountId, }); throw new ServiceError(ERRORS.SELL_ACCOUNT_NOT_FOUND); } else if (!foundAccount.isParentType(ACCOUNT_ROOT_TYPE.INCOME)) { this.logger.info('[items] sell account not income type.', { tenantId, sellAccountId, }); throw new ServiceError(ERRORS.SELL_ACCOUNT_NOT_INCOME); } } /** * Validate item inventory account existance and type. * @param {number} tenantId * @param {number} inventoryAccountId */ private async validateItemInventoryAccountExistance( tenantId: number, inventoryAccountId: number ) { const { accountRepository, } = this.tenancy.repositories(tenantId); this.logger.info('[items] validate inventory account existance.', { tenantId, inventoryAccountId, }); const foundAccount = await accountRepository.findOneById( inventoryAccountId ); if (!foundAccount) { this.logger.info('[items] inventory account not found.', { tenantId, inventoryAccountId, }); throw new ServiceError(ERRORS.INVENTORY_ACCOUNT_NOT_FOUND); } else if (!foundAccount.isAccountType(ACCOUNT_TYPE.INVENTORY)) { this.logger.info('[items] inventory account not inventory type.', { tenantId, inventoryAccountId, }); throw new ServiceError(ERRORS.INVENTORY_ACCOUNT_NOT_INVENTORY); } } /** * Validate item category existance. * @param {number} tenantId * @param {number} itemCategoryId */ private async validateItemCategoryExistance( tenantId: number, itemCategoryId: number ) { const { ItemCategory } = this.tenancy.models(tenantId); const foundCategory = await ItemCategory.query().findById(itemCategoryId); if (!foundCategory) { throw new ServiceError(ERRORS.ITEM_CATEOGRY_NOT_FOUND); } } /** * Transforms the item DTO to model. * @param {IItemDTO} itemDTO - Item DTO. * @return {IItem} */ private transformNewItemDTOToModel(itemDTO: IItemDTO) { return { ...itemDTO, active: defaultTo(itemDTO.active, 1), quantityOnHand: itemDTO.type === 'inventory' ? 0 : null, }; } /** * Creates a new item. * @param {number} tenantId DTO * @param {IItemDTO} item * @return {Promise} */ public async newItem(tenantId: number, itemDTO: IItemDTO): Promise { const { Item } = this.tenancy.models(tenantId); // Validate whether the given item name already exists on the storage. await this.validateItemNameUniquiness(tenantId, itemDTO.name); if (itemDTO.categoryId) { await this.validateItemCategoryExistance(tenantId, itemDTO.categoryId); } if (itemDTO.sellAccountId) { await this.validateItemSellAccountExistance( tenantId, itemDTO.sellAccountId ); } if (itemDTO.costAccountId) { await this.validateItemCostAccountExistance( tenantId, itemDTO.costAccountId ); } if (itemDTO.inventoryAccountId) { await this.validateItemInventoryAccountExistance( tenantId, itemDTO.inventoryAccountId ); } const item = await Item.query().insertAndFetch({ ...this.transformNewItemDTOToModel(itemDTO), }); this.logger.info('[items] item inserted successfully.', { tenantId, itemDTO, }); // Triggers `onItemCreated` event. await this.eventDispatcher.dispatch(events.item.onCreated, { tenantId, item, itemId: item.id, }); return item; } /** * Edits the item metadata. * @param {number} tenantId * @param {number} itemId * @param {IItemDTO} itemDTO */ public async editItem(tenantId: number, itemId: number, itemDTO: IItemDTO) { const { Item } = this.tenancy.models(tenantId); // Validates the given item existance on the storage. const oldItem = await this.getItemOrThrowError(tenantId, itemId); // Validate whether the given item name already exists on the storage. await this.validateItemNameUniquiness(tenantId, itemDTO.name, itemId); // Validate the item category existance on the storage, if (itemDTO.categoryId) { await this.validateItemCategoryExistance(tenantId, itemDTO.categoryId); } // Validate the sell account existance on the storage. if (itemDTO.sellAccountId) { await this.validateItemSellAccountExistance( tenantId, itemDTO.sellAccountId ); } // Validate the cost account existance on the storage. if (itemDTO.costAccountId) { await this.validateItemCostAccountExistance( tenantId, itemDTO.costAccountId ); } // Validate the inventory account existance onthe storage. if (itemDTO.inventoryAccountId) { await this.validateItemInventoryAccountExistance( tenantId, itemDTO.inventoryAccountId ); } const newItem = await Item.query().patchAndFetchById(itemId, { ...itemDTO, }); this.logger.info('[items] item edited successfully.', { tenantId, itemId, itemDTO, }); return newItem; } /** * Delete the given item from the storage. * @param {number} tenantId - Tenant id. * @param {number} itemId - Item id. * @return {Promise} */ public async deleteItem(tenantId: number, itemId: number) { const { Item } = this.tenancy.models(tenantId); this.logger.info('[items] trying to delete item.', { tenantId, itemId }); // Retreive the given item or throw not found service error. await this.getItemOrThrowError(tenantId, itemId); // Validate the item has no associated inventory transactions. await this.validateHasNoInventoryAdjustments(tenantId, itemId); // Validate the item has no associated invoices or bills. await this.validateHasNoInvoicesOrBills(tenantId, itemId); await Item.query().findById(itemId).delete(); this.logger.info('[items] deleted successfully.', { tenantId, itemId }); } /** * Activates the given item on the storage. * @param {number} tenantId - * @param {number} itemId - * @return {Promise} */ public async activateItem(tenantId: number, itemId: number): Promise { const { Item } = this.tenancy.models(tenantId); this.logger.info('[items] trying to activate the given item.', { tenantId, itemId, }); const item = await this.getItemOrThrowError(tenantId, itemId); await Item.query().findById(itemId).patch({ active: true }); this.logger.info('[items] activated successfully.', { tenantId, itemId }); } /** * Inactivates the given item on the storage. * @param {number} tenantId * @param {number} itemId * @return {Promise} */ public async inactivateItem(tenantId: number, itemId: number): Promise { const { Item } = this.tenancy.models(tenantId); this.logger.info('[items] trying to inactivate the given item.', { tenantId, itemId, }); const item = await this.getItemOrThrowError(tenantId, itemId); await Item.query().findById(itemId).patch({ active: false }); this.logger.info('[items] activated successfully.', { tenantId, itemId }); } /** * Retrieve the item details of the given id with associated details. * @param {number} tenantId * @param {number} itemId */ public async getItem(tenantId: number, itemId: number): Promise { const { Item } = this.tenancy.models(tenantId); this.logger.info('[items] trying to get the specific item.', { tenantId, itemId, }); const item = await Item.query() .findById(itemId) .withGraphFetched( 'costAccount', 'sellAccount', 'inventoryAccount', 'category' ); if (!item) { throw new ServiceError(ERRORS.NOT_FOUND); } return item; } /** * Validates the given items IDs exists or throw not found service error. * @param {number} tenantId - * @param {number[]} itemsIDs - * @return {Promise} */ private async validateItemsIdsExists( tenantId: number, itemsIDs: number[] ): Promise { const { Item } = this.tenancy.models(tenantId); const storedItems = await Item.query().whereIn('id', itemsIDs); const storedItemsIds = storedItems.map((t: IItem) => t.id); const notFoundItemsIds = difference(itemsIDs, storedItemsIds); if (notFoundItemsIds.length > 0) { throw new ServiceError(ERRORS.ITEMS_NOT_FOUND, null, { notFoundItemsIds, }); } } /** * Deletes items in bulk. * @param {number} tenantId * @param {number[]} itemsIds - Items ids. */ public async bulkDeleteItems(tenantId: number, itemsIds: number[]) { const { Item } = this.tenancy.models(tenantId); this.logger.info('[items] trying to delete items in bulk.', { tenantId, itemsIds, }); // Validates the given items exist on the storage. await this.validateItemsIdsExists(tenantId, itemsIds); // Validate the item has no associated inventory transactions. await this.validateHasNoInventoryAdjustments(tenantId, itemsIds); // Validate the items have no associated invoices or bills. await this.validateHasNoInvoicesOrBills(tenantId, itemsIds); await Item.query().whereIn('id', itemsIds).delete(); this.logger.info('[items] deleted successfully in bulk.', { tenantId, itemsIds, }); } /** * Retrieve items datatable list. * @param {number} tenantId * @param {IItemsFilter} itemsFilter */ public async itemsList(tenantId: number, itemsFilter: IItemsFilter) { const { Item } = this.tenancy.models(tenantId); const dynamicFilter = await this.dynamicListService.dynamicList( tenantId, Item, itemsFilter ); const { results, pagination } = await Item.query() .onBuild((builder) => { builder.withGraphFetched('inventoryAccount'); builder.withGraphFetched('sellAccount'); builder.withGraphFetched('costAccount'); builder.withGraphFetched('category'); dynamicFilter.buildQuery()(builder); }) .pagination(itemsFilter.page - 1, itemsFilter.pageSize); return { items: results, pagination, filterMeta: dynamicFilter.getResponseMeta(), }; } /** * Validates the given item or items have no associated invoices or bills. * @param {number} tenantId - Tenant id. * @param {number|number[]} itemId - Item id. * @throws {ServiceError} */ private async validateHasNoInvoicesOrBills( tenantId: number, itemId: number[] | number ) { const { ItemEntry } = this.tenancy.models(tenantId); const ids = Array.isArray(itemId) ? itemId : [itemId]; const foundItemEntries = await ItemEntry.query() .whereIn('item_id', ids) .whereIn('reference_type', ['SaleInvoice', 'Bill']); if (foundItemEntries.length > 0) { throw new ServiceError( ids.length > 1 ? ERRORS.ITEMS_HAVE_ASSOCIATED_TRANSACTIONS : ERRORS.ITEM_HAS_ASSOCIATED_TRANSACTINS ); } } /** * Validates the given item has no associated inventory adjustment transactions. * @param {number} tenantId - * @param {number} itemId - */ private async validateHasNoInventoryAdjustments( tenantId: number, itemId: number[] | number ): Promise { const { InventoryAdjustmentEntry } = this.tenancy.models(tenantId); const itemsIds = Array.isArray(itemId) ? itemId : [itemId]; const inventoryAdjEntries = await InventoryAdjustmentEntry.query().whereIn( 'item_id', itemsIds ); if (inventoryAdjEntries.length > 0) { throw new ServiceError(ERRORS.ITEM_HAS_ASSOCIATED_INVENTORY_ADJUSTMENT); } } }