diff --git a/packages/server/resources/locales/en.json b/packages/server/resources/locales/en.json index c878c0576..8e5ace3ee 100644 --- a/packages/server/resources/locales/en.json +++ b/packages/server/resources/locales/en.json @@ -245,25 +245,26 @@ "account.field.currency": "Currency", "account.field.balance": "Balance", "account.field.created_at": "Created at", - "item.field.type": "Item type", + "item.field.type": "Item Type", "item.field.type.inventory": "Inventory", "item.field.type.service": "Service", - "item.field.type.non-inventory": "Non inventory", - "item.field.name": "Name", - "item.field.code": "Code", + "item.field.type.non-inventory": "Non Inventory", + "item.field.name": "Item Name", + "item.field.code": "Item Code", "item.field.sellable": "Sellable", "item.field.purchasable": "Purchasable", - "item.field.cost_price": "Cost price", - "item.field.cost_account": "Cost account", - "item.field.sell_account": "Sell account", - "item.field.sell_description": "Sell description", - "item.field.inventory_account": "Inventory account", - "item.field.purchase_description": "Purchase description", - "item.field.quantity_on_hand": "Quantity on hand", + "item.field.cost_price": "Cost Price", + "item.field.sell_price": "Sell Price", + "item.field.cost_account": "Cost Account", + "item.field.sell_account": "Sell Account", + "item.field.sell_description": "Sell Description", + "item.field.inventory_account": "Inventory Account", + "item.field.purchase_description": "Purchase Description", + "item.field.quantity_on_hand": "Quantity on Hand", "item.field.note": "Note", "item.field.category": "Category", "item.field.active": "Active", - "item.field.created_at": "Created at", + "item.field.created_at": "Created At", "item_category.field.name": "Name", "item_category.field.description": "Description", "item_category.field.count": "Count", diff --git a/packages/server/src/api/controllers/Items/Items.ts b/packages/server/src/api/controllers/Items/Items.ts index a77f991e9..fda90b80e 100644 --- a/packages/server/src/api/controllers/Items/Items.ts +++ b/packages/server/src/api/controllers/Items/Items.ts @@ -344,7 +344,7 @@ export default class ItemsController extends BaseController { const filter = { sortOrder: 'DESC', - columnSortBy: 'created_at', + columnSortBy: 'createdAt', page: 1, pageSize: 12, inactiveMode: false, diff --git a/packages/server/src/api/controllers/Items/index.ts b/packages/server/src/api/controllers/Items/index.ts index 33ef7d3b0..a64e598eb 100644 --- a/packages/server/src/api/controllers/Items/index.ts +++ b/packages/server/src/api/controllers/Items/index.ts @@ -6,7 +6,7 @@ import ItemTransactionsController from './ItemsTransactions'; @Service() export default class ItemsBaseController { - router() { + public router() { const router = Router(); router.use('/', Container.get(ItemsController).router()); diff --git a/packages/server/src/interfaces/Model.ts b/packages/server/src/interfaces/Model.ts index c54d05f38..fdebe7d8c 100644 --- a/packages/server/src/interfaces/Model.ts +++ b/packages/server/src/interfaces/Model.ts @@ -32,12 +32,13 @@ export interface IModelMetaFieldCommon { name: string; column: string; columnable?: boolean; - fieldType: IModelColumnType; customQuery?: Function; required?: boolean; importHint?: string; + importableRelationLabel?: string; order?: number; unique?: number; + dataTransferObjectKey?: string; } export interface IModelMetaFieldText { diff --git a/packages/server/src/models/Item.Settings.ts b/packages/server/src/models/Item.Settings.ts index 3ff5b5613..a1bdec592 100644 --- a/packages/server/src/models/Item.Settings.ts +++ b/packages/server/src/models/Item.Settings.ts @@ -16,45 +16,53 @@ export default { { key: 'non-inventory', label: 'item.field.type.non-inventory' }, ], importable: true, + required: true, }, name: { name: 'item.field.name', column: 'name', fieldType: 'text', importable: true, + required: true, + unique: true, }, code: { name: 'item.field.code', column: 'code', fieldType: 'text', importable: true, + }, sellable: { name: 'item.field.sellable', column: 'sellable', fieldType: 'boolean', importable: true, + required: true, }, purchasable: { name: 'item.field.purchasable', column: 'purchasable', fieldType: 'boolean', importable: true, + required: true, }, sellPrice: { - name: 'item.field.cost_price', + name: 'item.field.sell_price', column: 'sell_price', fieldType: 'number', importable: true, + required: true, }, costPrice: { - name: 'item.field.cost_account', + name: 'item.field.cost_price', column: 'cost_price', fieldType: 'number', importable: true, + required: true, }, costAccount: { - name: 'item.field.sell_account', + name: 'item.field.cost_account', column: 'cost_account_id', fieldType: 'relation', @@ -64,10 +72,13 @@ export default { relationEntityLabel: 'name', relationEntityKey: 'slug', + dataTransferObjectKey: 'costAccountId', + importableRelationLabel: ['name', 'code'], importable: true, + required: true, }, sellAccount: { - name: 'item.field.sell_description', + name: 'item.field.sell_account', column: 'sell_account_id', fieldType: 'relation', @@ -77,11 +88,15 @@ export default { relationEntityLabel: 'name', relationEntityKey: 'slug', + importableRelationLabel: ['name', 'code'], importable: true, + + required: true, }, inventoryAccount: { name: 'item.field.inventory_account', column: 'inventory_account_id', + fieldType: 'relation', relationType: 'enumeration', relationKey: 'inventoryAccount', @@ -89,7 +104,10 @@ export default { relationEntityLabel: 'name', relationEntityKey: 'slug', + importableRelationLabel: ['name', 'code'], importable: true, + + required: true, }, sellDescription: { name: 'Sell description', @@ -107,7 +125,6 @@ export default { name: 'item.field.quantity_on_hand', column: 'quantity_on_hand', fieldType: 'number', - importable: true, }, note: { name: 'item.field.note', @@ -118,12 +135,15 @@ export default { category: { name: 'item.field.category', column: 'category_id', + fieldType: 'relation', relationType: 'enumeration', relationKey: 'category', relationEntityLabel: 'name', relationEntityKey: 'id', + + importableRelationLabel: 'name', importable: true, }, active: { diff --git a/packages/server/src/models/ItemCategory.Settings.ts b/packages/server/src/models/ItemCategory.Settings.ts index 1ce8d9190..75337c203 100644 --- a/packages/server/src/models/ItemCategory.Settings.ts +++ b/packages/server/src/models/ItemCategory.Settings.ts @@ -4,16 +4,19 @@ export default { sortField: 'name', sortOrder: 'DESC', }, + importable: true, fields: { name: { name: 'item_category.field.name', column: 'name', fieldType: 'text', + importable: true, }, description: { name: 'item_category.field.description', column: 'description', fieldType: 'text', + importable: true, }, count: { name: 'item_category.field.count', diff --git a/packages/server/src/services/Import/ImportFileDataTransformer.ts b/packages/server/src/services/Import/ImportFileDataTransformer.ts index aa331d678..239fda383 100644 --- a/packages/server/src/services/Import/ImportFileDataTransformer.ts +++ b/packages/server/src/services/Import/ImportFileDataTransformer.ts @@ -1,21 +1,32 @@ -import { Service } from 'typedi'; +import { Inject, Service } from 'typedi'; import * as R from 'ramda'; -import { isUndefined, get, chain } from 'lodash'; +import bluebird from 'bluebird'; +import { isUndefined, get, chain, toArray, pickBy, castArray } from 'lodash'; import { ImportMappingAttr, ResourceMetaFieldsMap } from './interfaces'; import { parseBoolean } from '@/utils'; import { trimObject } from './_utils'; +import { Account, Item } from '@/models'; +import ResourceService from '../Resource/ResourceService'; +import { Knex } from 'knex'; + +const CurrencyParsingDTOs = 10; @Service() export class ImportFileDataTransformer { + @Inject() + private resource: ResourceService; + /** * * @param {number} tenantId - * @param {} */ - public parseSheetData( + public async parseSheetData( + tenantId: number, importFile: any, importableFields: any, - data: Record[] + data: Record[], + trx?: Knex.Transaction ) { // Sanitize the sheet data. const sanitizedData = this.sanitizeSheetData(data); @@ -25,10 +36,17 @@ export class ImportFileDataTransformer { sanitizedData, importFile.mappingParsed ); + const resourceModel = this.resource.getResourceModel( + tenantId, + importFile.resource + ); // Parse the mapped sheet values. - const parsedValues = this.parseExcelValues(importableFields, mappedDTOs); - - return parsedValues; + return this.parseExcelValues( + importableFields, + mappedDTOs, + resourceModel, + trx + ); } /** @@ -67,35 +85,86 @@ export class ImportFileDataTransformer { * @param {Record} valueDTOS - * @returns {Record} */ - public parseExcelValues( + public async parseExcelValues( fields: ResourceMetaFieldsMap, - valueDTOs: Record[] - ): Record { - const parser = (value, key) => { + valueDTOs: Record[], + resourceModel: any, + trx?: Knex.Transaction + ): Promise> { + // Prases the given object value based on the field key type. + const parser = async (value, key) => { let _value = value; + const field = fields[key]; // Parses the boolean value. if (fields[key].fieldType === 'boolean') { _value = parseBoolean(value, false); // Parses the enumeration value. - } else if (fields[key].fieldType === 'enumeration') { + } else if (field.fieldType === 'enumeration') { const field = fields[key]; const option = get(field, 'options', []).find( (option) => option.label === value ); _value = get(option, 'key'); - // Prases the numeric value. + // Parses the numeric value. } else if (fields[key].fieldType === 'number') { _value = parseFloat(value); + // Parses the relation value. + } else if (field.fieldType === 'relation') { + const relationModel = resourceModel.relationMappings[field.relationKey]; + const RelationModel = relationModel?.modelClass; + + if (!relationModel || !RelationModel) { + throw new Error(`The relation model of ${key} field is not exist.`); + } + const relationQuery = RelationModel.query(trx); + const relationKeys = field?.importableRelationLabel + ? castArray(field?.importableRelationLabel) + : castArray(field?.relationEntityLabel); + + relationQuery.where(function () { + relationKeys.forEach((relationKey: string) => { + this.orWhereRaw('LOWER(??) = LOWER(?)', [relationKey, value]); + }); + }); + const result = await relationQuery.first(); + _value = get(result, 'id'); } return _value; }; - return valueDTOs.map((DTO) => { - return chain(DTO) - .pickBy((value, key) => !isUndefined(fields[key])) - .mapValues(parser) - .value(); + + const parseKey = (key: string) => { + const field = fields[key]; + let _objectTransferObjectKey = key; + + if (field.fieldType === 'relation') { + _objectTransferObjectKey = `${key}Id`; + } + return _objectTransferObjectKey; + }; + const parseAsync = async (valueDTO) => { + // Remove the undefined fields. + const _valueDTO = pickBy( + valueDTO, + (value, key) => !isUndefined(fields[key]) + ); + const keys = Object.keys(_valueDTO); + + // Map the object values. + return bluebird.reduce( + keys, + async (acc, key) => { + const parsedValue = await parser(_valueDTO[key], key); + const parsedKey = await parseKey(key); + acc[parsedKey] = parsedValue; + return acc; + }, + {} + ); + }; + return bluebird.map(valueDTOs, parseAsync, { + concurrency: CurrencyParsingDTOs, }); } } diff --git a/packages/server/src/services/Import/ImportFileProcess.ts b/packages/server/src/services/Import/ImportFileProcess.ts index a552f5ad8..55726bc3c 100644 --- a/packages/server/src/services/Import/ImportFileProcess.ts +++ b/packages/server/src/services/Import/ImportFileProcess.ts @@ -57,19 +57,31 @@ export class ImportFileProcess { tenantId, importFile.resource ); - // Prases the sheet json data. - const parsedData = this.importParser.parseSheetData( - importFile, - importableFields, - sheetData - ); + // Runs the importing operation with ability to return errors that will happen. - const [successedImport, failedImport] = await this.uow.withTransaction( - tenantId, - (trx: Knex.Transaction) => - this.importCommon.import(tenantId, importFile, parsedData, trx), - trx - ); + const [successedImport, failedImport, allData] = + await this.uow.withTransaction( + tenantId, + async (trx: Knex.Transaction) => { + // Prases the sheet json data. + const parsedData = await this.importParser.parseSheetData( + tenantId, + importFile, + importableFields, + sheetData, + trx + ); + const [successedImport, failedImport] = + await this.importCommon.import( + tenantId, + importFile, + parsedData, + trx + ); + return [successedImport, failedImport, parsedData]; + }, + trx + ); const mapping = importFile.mappingParsed; const errors = chain(failedImport) .map((oper) => oper.error) @@ -77,7 +89,7 @@ export class ImportFileProcess { .value(); const unmappedColumns = getUnmappedSheetColumns(header, mapping); - const totalCount = parsedData.length; + const totalCount = allData.length; const createdCount = successedImport.length; const errorsCount = failedImport.length; diff --git a/packages/server/src/services/Import/ImportableResources.ts b/packages/server/src/services/Import/ImportableResources.ts index bbc5e3b4c..96aee4290 100644 --- a/packages/server/src/services/Import/ImportableResources.ts +++ b/packages/server/src/services/Import/ImportableResources.ts @@ -4,6 +4,8 @@ import { ImportableRegistry } from './ImportableRegistry'; import { UncategorizedTransactionsImportable } from '../Cashflow/UncategorizedTransactionsImportable'; import { CustomersImportable } from '../Contacts/Customers/CustomersImportable'; import { VendorsImportable } from '../Contacts/Vendors/VendorsImportable'; +import { ItemsImportable } from '../Items/ItemsImportable'; +import { ItemCategoriesImportable } from '../ItemCategories/ItemCategoriesImportable'; @Service() export class ImportableResources { @@ -24,6 +26,8 @@ export class ImportableResources { }, { resource: 'Customer', importable: CustomersImportable }, { resource: 'Vendor', importable: VendorsImportable }, + { resource: 'Item', importable: ItemsImportable }, + { resource: 'ItemCategory', importable: ItemCategoriesImportable }, ]; public get registry() { diff --git a/packages/server/src/services/Import/_utils.ts b/packages/server/src/services/Import/_utils.ts index 6c94bfade..f1b55ea14 100644 --- a/packages/server/src/services/Import/_utils.ts +++ b/packages/server/src/services/Import/_utils.ts @@ -1,5 +1,12 @@ import * as Yup from 'yup'; -import { defaultTo, upperFirst, camelCase, first, isUndefined, pickBy } from 'lodash'; +import { + defaultTo, + upperFirst, + camelCase, + first, + isUndefined, + pickBy, +} from 'lodash'; import pluralize from 'pluralize'; import { ResourceMetaFieldsMap } from './interfaces'; import { IModelMetaField } from '@/interfaces'; @@ -83,11 +90,25 @@ export const convertFieldsToYupValidation = (fields: ResourceMetaFieldsMap) => { if (field.required) { fieldSchema = fieldSchema.required(); } - yupSchema[fieldName] = fieldSchema; + const _fieldName = parseFieldName(fieldName, field); + + yupSchema[_fieldName] = fieldSchema; }); return Yup.object().shape(yupSchema); }; +const parseFieldName = (fieldName: string, field: IModelMetaField) => { + let _key = fieldName; + + if (field.fieldType === 'relation') { + _key = `${fieldName}Id`; + } + if (field.dataTransferObjectKey) { + _key = field.dataTransferObjectKey; + } + return _key; +}; + export const getUnmappedSheetColumns = (columns, mapping) => { return columns.filter( (column) => !mapping.some((map) => map.from === column) diff --git a/packages/server/src/services/ItemCategories/ItemCategoriesImportable.ts b/packages/server/src/services/ItemCategories/ItemCategoriesImportable.ts new file mode 100644 index 000000000..458a64a8a --- /dev/null +++ b/packages/server/src/services/ItemCategories/ItemCategoriesImportable.ts @@ -0,0 +1,38 @@ +import { Inject, Service } from 'typedi'; +import ItemCategoriesService from './ItemCategoriesService'; +import { Importable } from '../Import/Importable'; +import { Knex } from 'knex'; +import { IItemCategoryOTD } from '@/interfaces'; +import { ItemCategoriesSampleData } from './constants'; + +@Service() +export class ItemCategoriesImportable extends Importable { + @Inject() + private itemCategoriesService: ItemCategoriesService; + + /** + * Importing to create new item category service. + * @param {number} tenantId + * @param {any} createDTO + * @param {Knex.Transaction} trx + */ + public async importable( + tenantId: number, + createDTO: IItemCategoryOTD, + trx?: Knex.Transaction + ) { + await this.itemCategoriesService.newItemCategory( + tenantId, + createDTO, + {}, + trx + ); + } + + /** + * Item categories sample data used to download sample sheet file. + */ + public sampleData(): any[] { + return ItemCategoriesSampleData; + } +} diff --git a/packages/server/src/services/ItemCategories/ItemCategoriesService.ts b/packages/server/src/services/ItemCategories/ItemCategoriesService.ts index f1ba27621..344ce1240 100644 --- a/packages/server/src/services/ItemCategories/ItemCategoriesService.ts +++ b/packages/server/src/services/ItemCategories/ItemCategoriesService.ts @@ -1,6 +1,6 @@ import { Inject } from 'typedi'; import * as R from 'ramda'; -import Knex from 'knex'; +import { Knex } from 'knex'; import { ServiceError } from '@/exceptions'; import { IItemCategory, @@ -115,7 +115,8 @@ export default class ItemCategoriesService implements IItemCategoriesService { public async newItemCategory( tenantId: number, itemCategoryOTD: IItemCategoryOTD, - authorizedUser: ISystemUser + authorizedUser: ISystemUser, + trx?: Knex.Transaction ): Promise { const { ItemCategory } = this.tenancy.models(tenantId); @@ -139,20 +140,24 @@ export default class ItemCategoriesService implements IItemCategoriesService { authorizedUser ); // Creates item category under unit-of-work evnirement. - return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => { - // Inserts the item category. - const itemCategory = await ItemCategory.query(trx).insert({ - ...itemCategoryObj, - }); - // Triggers `onItemCategoryCreated` event. - await this.eventPublisher.emitAsync(events.itemCategory.onCreated, { - itemCategory, - tenantId, - trx, - } as IItemCategoryCreatedPayload); + return this.uow.withTransaction( + tenantId, + async (trx: Knex.Transaction) => { + // Inserts the item category. + const itemCategory = await ItemCategory.query(trx).insert({ + ...itemCategoryObj, + }); + // Triggers `onItemCategoryCreated` event. + await this.eventPublisher.emitAsync(events.itemCategory.onCreated, { + itemCategory, + tenantId, + trx, + } as IItemCategoryCreatedPayload); - return itemCategory; - }); + return itemCategory; + }, + trx + ); } /** @@ -308,7 +313,7 @@ export default class ItemCategoriesService implements IItemCategoriesService { } as IItemCategoryDeletedPayload); }); } - + /** * Parses items categories filter DTO. * @param {} filterDTO diff --git a/packages/server/src/services/ItemCategories/constants.ts b/packages/server/src/services/ItemCategories/constants.ts index bbf5aea77..c92830805 100644 --- a/packages/server/src/services/ItemCategories/constants.ts +++ b/packages/server/src/services/ItemCategories/constants.ts @@ -11,3 +11,25 @@ export const ERRORS = { INVENTORY_ACCOUNT_NOT_INVENTORY: 'INVENTORY_ACCOUNT_NOT_INVENTORY', CATEGORY_HAVE_ITEMS: 'CATEGORY_HAVE_ITEMS', }; + +export const ItemCategoriesSampleData = [ + { + Name: 'Kassulke Group', + Description: 'Optio itaque eaque qui adipisci illo sed.', + }, + { + Name: 'Crist, Mraz and Lueilwitz', + Description: + 'Dolores veniam deserunt sed commodi error quia veritatis non.', + }, + { + Name: 'Gutmann and Sons', + Description: + 'Ratione aperiam voluptas rem adipisci assumenda eos neque veritatis tempora.', + }, + { + Name: 'Reichel - Raynor', + Description: + 'Necessitatibus repellendus placeat possimus dolores excepturi ut.', + }, +]; diff --git a/packages/server/src/services/Items/CreateItem.ts b/packages/server/src/services/Items/CreateItem.ts index 4ecb984b3..5de01aec9 100644 --- a/packages/server/src/services/Items/CreateItem.ts +++ b/packages/server/src/services/Items/CreateItem.ts @@ -88,7 +88,11 @@ export class CreateItem { * @param {IItemDTO} item * @return {Promise} */ - public async createItem(tenantId: number, itemDTO: IItemDTO): Promise { + public async createItem( + tenantId: number, + itemDTO: IItemDTO, + trx?: Knex.Transaction + ): Promise { const { Item } = this.tenancy.models(tenantId); // Authorize the item before creating. @@ -111,7 +115,8 @@ export class CreateItem { } as IItemEventCreatedPayload); return item; - } + }, + trx ); return item; } diff --git a/packages/server/src/services/Items/ItemValidators.ts b/packages/server/src/services/Items/ItemValidators.ts index cdf8a652e..c81ac9c7e 100644 --- a/packages/server/src/services/Items/ItemValidators.ts +++ b/packages/server/src/services/Items/ItemValidators.ts @@ -35,7 +35,10 @@ export class ItemsValidators { } }); if (foundItems.length > 0) { - throw new ServiceError(ERRORS.ITEM_NAME_EXISTS); + throw new ServiceError( + ERRORS.ITEM_NAME_EXISTS, + 'The item name is already exist.' + ); } } diff --git a/packages/server/src/services/Items/ItemsImportable.ts b/packages/server/src/services/Items/ItemsImportable.ts new file mode 100644 index 000000000..de1a986be --- /dev/null +++ b/packages/server/src/services/Items/ItemsImportable.ts @@ -0,0 +1,34 @@ +import { Inject, Service } from 'typedi'; +import { Knex } from 'knex'; +import { Importable } from '@/services/Import/Importable'; +import { IItemCreateDTO } from '@/interfaces'; +import { CreateItem } from './CreateItem'; + +@Service() +export class ItemsImportable extends Importable { + @Inject() + private createItemService: CreateItem; + + /** + * Mapps the imported data to create a new item service. + * @param {number} tenantId + * @param {ICustomerNewDTO} createDTO + * @param {Knex.Transaction} trx + * @returns {Promise} + */ + public async importable( + tenantId: number, + createDTO: IItemCreateDTO, + trx?: Knex.Transaction + ): Promise { + console.log(createDTO, tenantId, 'XX'); + await this.createItemService.createItem(tenantId, createDTO, trx); + } + + /** + * Retrieves the sample data of customers used to download sample sheet. + */ + public sampleData(): any[] { + return []; + } +} diff --git a/packages/webapp/src/containers/Items/ItemsActionsBar.tsx b/packages/webapp/src/containers/Items/ItemsActionsBar.tsx index eb0e0f936..3c83a7a1d 100644 --- a/packages/webapp/src/containers/Items/ItemsActionsBar.tsx +++ b/packages/webapp/src/containers/Items/ItemsActionsBar.tsx @@ -94,6 +94,10 @@ function ItemsActionsBar({ const handleTableRowSizeChange = (size) => { addSetting('items', 'tableSize', size); }; + // Handles the import button click. + const handleImportBtnClick = () => { + history.push('/items/import'); + }; return ( @@ -143,6 +147,7 @@ function ItemsActionsBar({