diff --git a/packages/server/resources/locales/en.json b/packages/server/resources/locales/en.json index 8e5ace3ee..a82ad052e 100644 --- a/packages/server/resources/locales/en.json +++ b/packages/server/resources/locales/en.json @@ -355,11 +355,13 @@ "expense.field.status.published": "Published", "expense.field.created_at": "Created at", "manual_journal.field.date": "Date", - "manual_journal.field.journal_number": "Journal number", + "manual_journal.field.journal_number": "Journal No.", "manual_journal.field.reference": "Reference No.", - "manual_journal.field.journal_type": "Journal type", + "manual_journal.field.journal_type": "Journal Type", "manual_journal.field.amount": "Amount", "manual_journal.field.description": "Description", + "manual_journal.field.currency": "Currency", + "manual_journal.field.exchange_rate": "Exchange Rate", "manual_journal.field.status": "Status", "manual_journal.field.created_at": "Created at", "receipt.field.amount": "Amount", diff --git a/packages/server/src/api/controllers/Import/ImportController.ts b/packages/server/src/api/controllers/Import/ImportController.ts index 645531f28..7db23fd1d 100644 --- a/packages/server/src/api/controllers/Import/ImportController.ts +++ b/packages/server/src/api/controllers/Import/ImportController.ts @@ -37,6 +37,7 @@ export class ImportController extends BaseController { [ param('import_id').exists().isString(), body('mapping').exists().isArray({ min: 1 }), + body('mapping.*.group').optional(), body('mapping.*.from').exists(), body('mapping.*.to').exists(), ], diff --git a/packages/server/src/interfaces/Model.ts b/packages/server/src/interfaces/Model.ts index fdebe7d8c..87912bafe 100644 --- a/packages/server/src/interfaces/Model.ts +++ b/packages/server/src/interfaces/Model.ts @@ -69,6 +69,7 @@ export type IModelMetaField = IModelMetaFieldCommon & | IModelMetaFieldUrl | IModelMetaEnumerationField | IModelMetaRelationField + | IModelMetaCollectionField ); export interface IModelMetaEnumerationOption { @@ -92,12 +93,71 @@ export interface IModelMetaRelationEnumerationField { relationEntityKey: string; } +export interface IModelMetaFieldWithFields { + fields: IModelMetaFieldCommon2 & + ( + | IModelMetaFieldText + | IModelMetaFieldNumber + | IModelMetaFieldBoolean + | IModelMetaFieldDate + | IModelMetaFieldUrl + | IModelMetaEnumerationField + | IModelMetaRelationField + ); +} + +interface IModelMetaCollectionObjectField extends IModelMetaFieldWithFields { + collectionOf: 'object'; +} + +export interface IModelMetaCollectionFieldCommon { + fieldType: 'collection'; + collectionMinLength?: number; + collectionMaxLength?: number; +} + +export type IModelMetaCollectionField = IModelMetaCollectionFieldCommon & + IModelMetaCollectionObjectField; + export type IModelMetaRelationField = IModelMetaRelationFieldCommon & IModelMetaRelationEnumerationField; export interface IModelMeta { defaultFilterField: string; defaultSort: IModelMetaDefaultSort; + importable?: boolean; + + importAggregator?: string; + importAggregateOn?: string; + importAggregateBy?: string; + fields: { [key: string]: IModelMetaField }; } + +// ---- +export interface IModelMetaFieldCommon2 { + name: string; + required?: boolean; + importHint?: string; + order?: number; + unique?: number; +} + +export interface IModelMetaRelationField2 { + fieldType: 'relation'; + relationModel: string; + importableRelationLabel: string | string[]; +} + +export type IModelMetaField2 = IModelMetaFieldCommon2 & + ( + | IModelMetaFieldText + | IModelMetaFieldNumber + | IModelMetaFieldBoolean + | IModelMetaFieldDate + | IModelMetaFieldUrl + | IModelMetaEnumerationField + | IModelMetaRelationField2 + | IModelMetaCollectionField + ); diff --git a/packages/server/src/models/Bill.Settings.ts b/packages/server/src/models/Bill.Settings.ts index 166648a0c..962aafca5 100644 --- a/packages/server/src/models/Bill.Settings.ts +++ b/packages/server/src/models/Bill.Settings.ts @@ -1,10 +1,13 @@ - export default { defaultFilterField: 'vendor', defaultSort: { sortOrder: 'DESC', sortField: 'bill_date', }, + importable: true, + importAggregator: 'group', + importAggregateOn: 'entries', + importAggregateBy: 'billNumber', fields: { vendor: { name: 'bill.field.vendor', @@ -77,6 +80,76 @@ export default { fieldType: 'date', }, }, + fields2: { + billNumber: { + name: 'Bill No.', + fieldType: 'text', + required: true, + }, + referenceNo: { + name: 'Reference No.', + fieldType: 'text', + }, + billDate: { + name: 'Date', + fieldType: 'date', + required: true, + }, + dueDate: { + name: 'Due Date', + fieldType: 'date', + required: true, + }, + vendorId: { + name: 'Vendor', + fieldType: 'relation', + relationModel: 'Contact', + relationImportMatch: 'displayName', + required: true, + }, + exchangeRate: { + name: 'Exchange Rate', + fieldType: 'number', + }, + note: { + name: 'Note', + fieldType: 'text', + }, + open: { + name: 'Open', + fieldType: 'boolean', + }, + entries: { + name: 'Entries', + fieldType: 'collection', + collectionOf: 'object', + collectionMinLength: 1, + required: true, + fields: { + itemId: { + name: 'Item', + fieldType: 'relation', + relationModel: 'Item', + relationImportMatch: ['name', 'code'], + required: true, + }, + rate: { + name: 'Rate', + fieldType: 'number', + required: true, + }, + quantity: { + name: 'Quantity', + fieldType: 'number', + required: true, + }, + description: { + name: 'Description', + fieldType: 'text', + }, + }, + }, + }, }; /** diff --git a/packages/server/src/models/ManualJournal.Settings.ts b/packages/server/src/models/ManualJournal.Settings.ts index bc6ae8d64..210f07682 100644 --- a/packages/server/src/models/ManualJournal.Settings.ts +++ b/packages/server/src/models/ManualJournal.Settings.ts @@ -4,54 +4,193 @@ export default { sortOrder: 'DESC', sortField: 'name', }, + importable: true, + importAggregator: 'group', + importAggregateOn: 'entries', + importAggregateBy: 'journalNumber', fields: { - 'date': { + date: { name: 'manual_journal.field.date', column: 'date', fieldType: 'date', + importable: true, + required: true, }, - 'journal_number': { + journalNumber: { name: 'manual_journal.field.journal_number', column: 'journal_number', fieldType: 'text', + importable: true, + required: true, }, - 'reference': { + reference: { name: 'manual_journal.field.reference', column: 'reference', fieldType: 'text', + importable: true, }, - 'journal_type': { + journalType: { name: 'manual_journal.field.journal_type', column: 'journal_type', fieldType: 'text', }, - 'amount': { + amount: { name: 'manual_journal.field.amount', column: 'amount', fieldType: 'number', }, - 'description': { + description: { name: 'manual_journal.field.description', column: 'description', fieldType: 'text', + importable: true, }, - 'status': { + entries: { + name: 'Entries', + fieldType: 'collection', + collectionOf: 'object', + collectionMinLength: 2, + required: true, + importable: true, + filterable: false, + fields: { + credit: { + name: 'Credit', + fieldType: 'number', + importable: true, + required: true, + }, + debit: { + name: 'Debit', + fieldType: 'number', + importable: true, + required: true, + }, + accountId: { + name: 'Account', + fieldType: 'relation', + + relationKey: 'account', + relationModel: 'Account', + + importable: true, + required: true, + importableRelationLabel: ['name', 'code'], + }, + contactId: { + name: 'Contact', + fieldType: 'relation', + + relationKey: 'contact', + relationModel: 'Contact', + + required: false, + + importable: true, + importableRelationLabel: 'displayName', + }, + note: { + name: 'Note', + fieldType: 'text', + importable: true, + }, + }, + }, + publish: { + name: 'Publish', + fieldType: 'boolean', + importable: true, + }, + status: { name: 'manual_journal.field.status', column: 'status', fieldType: 'enumeration', options: [ { key: 'draft', label: 'Draft' }, - { key: 'published', label: 'published' } + { key: 'published', label: 'published' }, ], filterCustomQuery: StatusFieldFilterQuery, sortCustomQuery: StatusFieldSortQuery, }, - 'created_at': { + createdAt: { name: 'manual_journal.field.created_at', column: 'created_at', fieldType: 'date', }, }, + fields2: { + date: { + name: 'manual_journal.field.date', + fieldType: 'date', + required: true, + }, + journalNumber: { + name: 'manual_journal.field.journal_number', + fieldType: 'text', + required: true, + }, + reference: { + name: 'manual_journal.field.reference', + fieldType: 'text', + importable: true, + }, + journalType: { + name: 'manual_journal.field.journal_type', + fieldType: 'text', + }, + currencyCode: { + name: 'manual_journal.field.currency', + fieldType: 'text', + }, + exchange_rate: { + name: 'manual_journal.field.exchange_rate', + fieldType: 'number', + }, + description: { + name: 'manual_journal.field.description', + fieldType: 'text', + }, + entries: { + name: 'Entries', + fieldType: 'collection', + collectionOf: 'object', + collectionMinLength: 2, + required: true, + fields: { + credit: { + name: 'Credit', + fieldType: 'number', + required: true, + }, + debit: { + name: 'Debit', + fieldType: 'number', + required: true, + }, + accountId: { + name: 'Account', + fieldType: 'relation', + relationModel: 'Account', + relationImportMatch: ['name', 'code'], + required: true, + }, + contact: { + name: 'Contact', + fieldType: 'relation', + relationModel: 'Contact', + relationImportMatch: 'displayName', + }, + note: { + name: 'Note', + fieldType: 'text', + }, + }, + }, + publish: { + name: 'Publish', + fieldType: 'boolean', + }, + }, }; /** @@ -64,6 +203,6 @@ function StatusFieldSortQuery(query, role) { /** * Status field filter custom query. */ - function StatusFieldFilterQuery(query, role) { +function StatusFieldFilterQuery(query, role) { query.modify('filterByStatus', role.value); } diff --git a/packages/server/src/services/Import/ImportFileCommon.ts b/packages/server/src/services/Import/ImportFileCommon.ts index 9562e083a..f41ec2b50 100644 --- a/packages/server/src/services/Import/ImportFileCommon.ts +++ b/packages/server/src/services/Import/ImportFileCommon.ts @@ -71,7 +71,7 @@ export class ImportFileCommon { parsedData: Record[], trx?: Knex.Transaction ): Promise<[ImportOperSuccess[], ImportOperError[]]> { - const importableFields = this.resource.getResourceImportableFields( + const resourceFields = this.resource.getResourceFields2( tenantId, importFile.resource ); @@ -90,7 +90,7 @@ export class ImportFileCommon { }; const transformedDTO = importable.transform(objectDTO, context); const rowNumber = index + 1; - const uniqueValue = getUniqueImportableValue(importableFields, objectDTO); + const uniqueValue = getUniqueImportableValue(resourceFields, objectDTO); const errorContext = { rowNumber, uniqueValue, @@ -98,7 +98,7 @@ export class ImportFileCommon { try { // Validate the DTO object before passing it to the service layer. await this.importFileValidator.validateData( - importableFields, + resourceFields, transformedDTO ); try { diff --git a/packages/server/src/services/Import/ImportFileDataTransformer.ts b/packages/server/src/services/Import/ImportFileDataTransformer.ts index 4fb8cf300..8fd47b584 100644 --- a/packages/server/src/services/Import/ImportFileDataTransformer.ts +++ b/packages/server/src/services/Import/ImportFileDataTransformer.ts @@ -1,63 +1,91 @@ import { Inject, Service } from 'typedi'; -import * as R from 'ramda'; import bluebird from 'bluebird'; -import { isUndefined, get, chain, toArray, pickBy, castArray } from 'lodash'; +import { isUndefined, pickBy, set } from 'lodash'; import { Knex } from 'knex'; import { ImportMappingAttr, ResourceMetaFieldsMap } from './interfaces'; -import { trimObject, parseBoolean } from './_utils'; -import { Account, Item } from '@/models'; +import { + valueParser, + parseKey, + getFieldKey, + aggregate, + sanitizeSheetData, +} from './_utils'; import ResourceService from '../Resource/ResourceService'; -import { multiNumberParse } from '@/utils/multi-number-parse'; +import HasTenancyService from '../Tenancy/TenancyService'; const CurrencyParsingDTOs = 10; +const getMapToPath = (to: string, group = '') => + group ? `${group}.${to}` : to; + @Service() export class ImportFileDataTransformer { @Inject() private resource: ResourceService; + @Inject() + private tenancy: HasTenancyService; + /** * Parses the given sheet data before passing to the service layer. - * based on the mapped fields and the each field type . + * based on the mapped fields and the each field type. * @param {number} tenantId - * @param {} */ public async parseSheetData( tenantId: number, importFile: any, - importableFields: any, + importableFields: ResourceMetaFieldsMap, data: Record[], trx?: Knex.Transaction - ) { + ): Promise[]> { // Sanitize the sheet data. - const sanitizedData = this.sanitizeSheetData(data); + const sanitizedData = sanitizeSheetData(data); // Map the sheet columns key with the given map. const mappedDTOs = this.mapSheetColumns( sanitizedData, importFile.mappingParsed ); - const resourceModel = this.resource.getResourceModel( - tenantId, - importFile.resource - ); // Parse the mapped sheet values. - return this.parseExcelValues( + const parsedValues = await this.parseExcelValues( + tenantId, importableFields, mappedDTOs, - resourceModel, trx ); + const aggregateValues = this.aggregateParsedValues( + tenantId, + importFile.resource, + parsedValues + ); + return aggregateValues; } /** - * Sanitizes the data in the imported sheet by trimming object keys. - * @param json - The JSON data representing the imported sheet. - * @returns {string[][]} - The sanitized data with trimmed object keys. + * Aggregates parsed data based on resource metadata configuration. + * @param {number} tenantId + * @param {string} resourceName + * @param {Record} parsedData + * @returns {Record[]} */ - public sanitizeSheetData(json) { - return R.compose(R.map(trimObject))(json); - } + public aggregateParsedValues = ( + tenantId: number, + resourceName: string, + parsedData: Record[] + ): Record[] => { + let _value = parsedData; + const meta = this.resource.getResourceMeta(tenantId, resourceName); + + if (meta.importAggregator === 'group') { + _value = aggregate( + _value, + meta.importAggregateBy, + meta.importAggregateOn + ); + } + return _value; + }; /** * Maps the columns of the imported data based on the provided mapping attributes. @@ -74,7 +102,8 @@ export class ImportFileDataTransformer { map .filter((mapping) => !isUndefined(item[mapping.from])) .forEach((mapping) => { - newItem[mapping.to] = item[mapping.from]; + const toPath = getMapToPath(mapping.to, mapping.group); + newItem[toPath] = item[mapping.from]; }); return newItem; }); @@ -87,78 +116,32 @@ export class ImportFileDataTransformer { * @returns {Record} */ public async parseExcelValues( + tenantId: number, fields: ResourceMetaFieldsMap, 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]; + ): Promise[]> { + const tenantModels = this.tenancy.models(tenantId); + const _valueParser = valueParser(fields, tenantModels, trx); + const _keyParser = parseKey(fields); - // Parses the boolean value. - if (fields[key].fieldType === 'boolean') { - _value = parseBoolean(value); - - // Parses the enumeration value. - } else if (field.fieldType === 'enumeration') { - const field = fields[key]; - const option = get(field, 'options', []).find( - (option) => option.label === value - ); - _value = get(option, 'key'); - // Parses the numeric value. - } else if (fields[key].fieldType === 'number') { - _value = multiNumberParse(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; - }; - - 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. + // Clean up the undefined keys that not exist in resource fields. const _valueDTO = pickBy( valueDTO, - (value, key) => !isUndefined(fields[key]) + (value, key) => !isUndefined(fields[getFieldKey(key)]) ); + // Keys of mapped values. key structure: `group.key` or `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; + const parsedValue = await _valueParser(_valueDTO[key], key); + const parsedKey = await _keyParser(key); + + set(acc, parsedKey, parsedValue); return acc; }, {} diff --git a/packages/server/src/services/Import/ImportFileMapping.ts b/packages/server/src/services/Import/ImportFileMapping.ts index 86b5e42cb..8d8f062ac 100644 --- a/packages/server/src/services/Import/ImportFileMapping.ts +++ b/packages/server/src/services/Import/ImportFileMapping.ts @@ -1,4 +1,4 @@ -import { fromPairs } from 'lodash'; +import { fromPairs, isUndefined } from 'lodash'; import { Inject, Service } from 'typedi'; import HasTenancyService from '../Tenancy/TenancyService'; import { @@ -69,7 +69,7 @@ export class ImportFileMapping { importFile: any, maps: ImportMappingAttr[] ) { - const fields = this.resource.getResourceImportableFields( + const fields = this.resource.getResourceFields2( tenantId, importFile.resource ); @@ -78,11 +78,20 @@ export class ImportFileMapping { ); const invalid = []; + // is not empty, is not undefined or map.group maps.forEach((map) => { - if ( - 'undefined' === typeof fields[map.to] || - 'undefined' === typeof columnsMap[map.from] - ) { + let _invalid = true; + + if (!map.group && fields[map.to]) { + _invalid = false; + } + if (map.group && fields[map.group] && fields[map.group]?.fields[map.to]) { + _invalid = false; + } + if (columnsMap[map.from]) { + _invalid = false; + } + if (_invalid) { invalid.push(map); } }); @@ -105,10 +114,14 @@ export class ImportFileMapping { } else { fromMap[map.from] = true; } - if (toMap[map.to]) { + const toPath = !isUndefined(map?.group) + ? `${map.group}.${map.to}` + : map.to; + + if (toMap[toPath]) { throw new ServiceError(ERRORS.DUPLICATED_TO_MAP_ATTR); } else { - toMap[map.to] = true; + toMap[toPath] = true; } }); } @@ -128,6 +141,7 @@ export class ImportFileMapping { tenantId, resource ); + // @todo Validate date type of the nested fields. maps.forEach((map) => { if ( typeof fields[map.to] !== 'undefined' && diff --git a/packages/server/src/services/Import/ImportFileProcess.ts b/packages/server/src/services/Import/ImportFileProcess.ts index 55726bc3c..3c57f2b69 100644 --- a/packages/server/src/services/Import/ImportFileProcess.ts +++ b/packages/server/src/services/Import/ImportFileProcess.ts @@ -53,11 +53,10 @@ export class ImportFileProcess { const sheetData = this.importCommon.parseXlsxSheet(buffer); const header = getSheetColumns(sheetData); - const importableFields = this.resource.getResourceImportableFields( + const resourceFields = this.resource.getResourceFields2( tenantId, importFile.resource ); - // Runs the importing operation with ability to return errors that will happen. const [successedImport, failedImport, allData] = await this.uow.withTransaction( @@ -67,7 +66,7 @@ export class ImportFileProcess { const parsedData = await this.importParser.parseSheetData( tenantId, importFile, - importableFields, + resourceFields, sheetData, trx ); diff --git a/packages/server/src/services/Import/ImportFileUpload.ts b/packages/server/src/services/Import/ImportFileUpload.ts index 048739e03..11dfc52c0 100644 --- a/packages/server/src/services/Import/ImportFileUpload.ts +++ b/packages/server/src/services/Import/ImportFileUpload.ts @@ -1,8 +1,11 @@ import { Inject, Service } from 'typedi'; import HasTenancyService from '../Tenancy/TenancyService'; -import { sanitizeResourceName, validateSheetEmpty } from './_utils'; +import { + getResourceColumns, + sanitizeResourceName, + validateSheetEmpty, +} from './_utils'; import ResourceService from '../Resource/ResourceService'; -import { IModelMetaField } from '@/interfaces'; import { ImportFileCommon } from './ImportFileCommon'; import { ImportFileDataValidator } from './ImportFileDataValidator'; import { ImportFileUploadPOJO } from './interfaces'; @@ -77,11 +80,11 @@ export class ImportFileUploadService { columns: coumnsStringified, params: paramsStringified, }); - const resourceColumnsMap = this.resourceService.getResourceImportableFields( + const resourceColumnsMap = this.resourceService.getResourceFields2( tenantId, resource ); - const resourceColumns = this.getResourceColumns(resourceColumnsMap); + const resourceColumns = getResourceColumns(resourceColumnsMap); return { import: { @@ -92,23 +95,4 @@ export class ImportFileUploadService { resourceColumns, }; } - - getResourceColumns(resourceColumns: { [key: string]: IModelMetaField }) { - return Object.entries(resourceColumns) - .map( - ([key, { name, importHint, required, order }]: [ - string, - IModelMetaField - ]) => ({ - key, - name, - required, - hint: importHint, - order, - }) - ) - .sort((a, b) => - a.order && b.order ? a.order - b.order : a.order ? -1 : b.order ? 1 : 0 - ); - } } diff --git a/packages/server/src/services/Import/ImportableRegistry.ts b/packages/server/src/services/Import/ImportableRegistry.ts index c260c5bd5..9fe25ec43 100644 --- a/packages/server/src/services/Import/ImportableRegistry.ts +++ b/packages/server/src/services/Import/ImportableRegistry.ts @@ -5,7 +5,7 @@ export class ImportableRegistry { private static instance: ImportableRegistry; private importables: Record; - private constructor() { + constructor() { this.importables = {}; } diff --git a/packages/server/src/services/Import/ImportableResources.ts b/packages/server/src/services/Import/ImportableResources.ts index 96aee4290..1cffc1a8a 100644 --- a/packages/server/src/services/Import/ImportableResources.ts +++ b/packages/server/src/services/Import/ImportableResources.ts @@ -6,6 +6,8 @@ import { CustomersImportable } from '../Contacts/Customers/CustomersImportable'; import { VendorsImportable } from '../Contacts/Vendors/VendorsImportable'; import { ItemsImportable } from '../Items/ItemsImportable'; import { ItemCategoriesImportable } from '../ItemCategories/ItemCategoriesImportable'; +import { ManualJournalImportable } from '../ManualJournals/ManualJournalsImport'; +import { BillsImportable } from '../Purchases/Bills/BillsImportable'; @Service() export class ImportableResources { @@ -28,6 +30,8 @@ export class ImportableResources { { resource: 'Vendor', importable: VendorsImportable }, { resource: 'Item', importable: ItemsImportable }, { resource: 'ItemCategory', importable: ItemCategoriesImportable }, + { resource: 'ManualJournal', importable: ManualJournalImportable }, + { resource: 'Bill', importable: BillsImportable }, ]; public get registry() { diff --git a/packages/server/src/services/Import/_utils.ts b/packages/server/src/services/Import/_utils.ts index 95cfb1367..67fb7f5cc 100644 --- a/packages/server/src/services/Import/_utils.ts +++ b/packages/server/src/services/Import/_utils.ts @@ -1,5 +1,7 @@ import * as Yup from 'yup'; import moment from 'moment'; +import * as R from 'ramda'; +import { Knex } from 'knex'; import { defaultTo, upperFirst, @@ -8,11 +10,17 @@ import { isUndefined, pickBy, isEmpty, + castArray, + get, + head, + split, + last, } from 'lodash'; import pluralize from 'pluralize'; import { ResourceMetaFieldsMap } from './interfaces'; -import { IModelMetaField } from '@/interfaces'; +import { IModelMetaField, IModelMetaField2 } from '@/interfaces'; import { ServiceError } from '@/exceptions'; +import { multiNumberParse } from '@/utils/multi-number-parse'; export const ERRORS = { RESOURCE_NOT_IMPORTABLE: 'RESOURCE_NOT_IMPORTABLE', @@ -40,6 +48,7 @@ export function trimObject(obj) { export const convertFieldsToYupValidation = (fields: ResourceMetaFieldsMap) => { const yupSchema = {}; + Object.keys(fields).forEach((fieldName: string) => { const field = fields[fieldName] as IModelMetaField; let fieldSchema; @@ -89,6 +98,17 @@ export const convertFieldsToYupValidation = (fields: ResourceMetaFieldsMap) => { ); } else if (field.fieldType === 'url') { fieldSchema = fieldSchema.url(); + } else if (field.fieldType === 'collection') { + const nestedFieldShema = convertFieldsToYupValidation(field.fields); + fieldSchema = Yup.array().label(field.name); + + if (!isUndefined(field.collectionMaxLength)) { + fieldSchema = fieldSchema.max(field.collectionMaxLength); + } + if (!isUndefined(field.collectionMinLength)) { + fieldSchema = fieldSchema.min(field.collectionMinLength); + } + fieldSchema = fieldSchema.of(nestedFieldShema); } if (field.required) { fieldSchema = fieldSchema.required(); @@ -103,9 +123,9 @@ export const convertFieldsToYupValidation = (fields: ResourceMetaFieldsMap) => { const parseFieldName = (fieldName: string, field: IModelMetaField) => { let _key = fieldName; - if (field.fieldType === 'relation') { - _key = `${fieldName}Id`; - } + // if (field.fieldType === 'relation') { + // _key = `${fieldName}Id`; + // } if (field.dataTransferObjectKey) { _key = field.dataTransferObjectKey; } @@ -134,7 +154,7 @@ export const getSheetColumns = (sheetData: unknown[]) => { * @returns {string} */ export const getUniqueImportableValue = ( - importableFields: { [key: string]: IModelMetaField }, + importableFields: { [key: string]: IModelMetaField2 }, objectDTO: Record ) => { const uniqueImportableValue = pickBy( @@ -155,15 +175,15 @@ export const validateSheetEmpty = (sheetData: Array) => { if (isEmpty(sheetData)) { throw new ServiceError(ERRORS.IMPORTED_SHEET_EMPTY); } -} +}; const booleanValuesRepresentingTrue: string[] = ['true', 'yes', 'y', 't', '1']; const booleanValuesRepresentingFalse: string[] = ['false', 'no', 'n', 'f', '0']; /** * Parses the given string value to boolean. - * @param {string} value - * @returns {string|null} + * @param {string} value + * @returns {string|null} */ export const parseBoolean = (value: string): boolean | null => { const normalizeValue = (value: string): string => @@ -182,3 +202,204 @@ export const parseBoolean = (value: string): boolean | null => { } return null; }; + +export const transformInputToGroupedFields = (input) => { + const output = []; + + // Group for non-nested fields + const mainGroup = { + groupLabel: '', + groupKey: '', + fields: [], + }; + input.forEach((item) => { + if (!item.fields) { + // If the item does not have nested fields, add it to the main group + mainGroup.fields.push(item); + } else { + // If the item has nested fields, create a new group for these fields + output.push({ + groupLabel: item.name, + groupKey: item.key, + fields: item.fields, + }); + } + }); + // Add the main group to the output if it contains any fields + if (mainGroup.fields.length > 0) { + output.unshift(mainGroup); // Add the main group at the beginning + } + return output; +}; + +export const getResourceColumns = (resourceColumns: { + [key: string]: IModelMetaField2; +}) => { + const mapColumn = + (group: string) => + ([fieldKey, { name, importHint, required, order, ...field }]: [ + string, + IModelMetaField2 + ]) => { + const extra: Record = {}; + const key = fieldKey; + + if (group) { + extra.group = group; + } + if (field.fieldType === 'collection') { + extra.fields = mapColumns(field.fields, key); + } + return { + key, + name, + required, + hint: importHint, + order, + ...extra, + }; + }; + const sortColumn = (a, b) => + a.order && b.order ? a.order - b.order : a.order ? -1 : b.order ? 1 : 0; + + const mapColumns = (columns, parentKey = '') => + Object.entries(columns).map(mapColumn(parentKey)).sort(sortColumn); + + return R.compose(transformInputToGroupedFields, mapColumns)(resourceColumns); +}; + +// Prases the given object value based on the field key type. +export const valueParser = + (fields: ResourceMetaFieldsMap, tenantModels: any, trx?: Knex.Transaction) => + async (value: any, key: string, group = '') => { + let _value = value; + + const fieldKey = key.includes('.') ? key.split('.')[0] : key; + const field = group ? fields[group]?.fields[fieldKey] : fields[fieldKey]; + + // Parses the boolean value. + if (field.fieldType === 'boolean') { + _value = parseBoolean(value); + + // Parses the enumeration value. + } else if (field.fieldType === 'enumeration') { + const option = get(field, 'options', []).find( + (option) => option.label === value + ); + _value = get(option, 'key'); + // Parses the numeric value. + } else if (field.fieldType === 'number') { + _value = multiNumberParse(value); + // Parses the relation value. + } else if (field.fieldType === 'relation') { + const RelationModel = tenantModels[field.relationModel]; + + if (!RelationModel) { + throw new Error(`The relation model of ${key} field is not exist.`); + } + const relationQuery = RelationModel.query(trx); + const relationKeys = castArray(field?.relationImportMatch); + + relationQuery.where(function () { + relationKeys.forEach((relationKey: string) => { + this.orWhereRaw('LOWER(??) = LOWER(?)', [relationKey, value]); + }); + }); + const result = await relationQuery.first(); + _value = get(result, 'id'); + } else if (field.fieldType === 'collection') { + const ObjectFieldKey = key.includes('.') ? key.split('.')[1] : key; + const _valueParser = valueParser(fields, tenantModels); + _value = await _valueParser(value, ObjectFieldKey, fieldKey); + } + return _value; + }; + +export const parseKey = R.curry( + /** + * + * @param {{ [key: string]: IModelMetaField2 }} fields + * @param {string} key - Mapped key path. formats: `group.key` or `key`. + * @returns {string} + */ + (fields: { [key: string]: IModelMetaField2 }, key: string) => { + const fieldKey = getFieldKey(key); + const field = fields[fieldKey]; + let _key = key; + + if (field.fieldType === 'collection') { + if (field.collectionOf === 'object') { + const nestedFieldKey = last(key.split('.')); + _key = `${fieldKey}[0].${nestedFieldKey}`; + } else if ( + field.collectionOf === 'string' || + field.collectionOf || + 'numberic' + ) { + _key = `${fieldKey}`; + } + } + console.log(_key); + return _key; + } +); + +export const getFieldKey = (input: string) => { + const keys = split(input, '.'); + const firstKey = head(keys).split('[')[0]; // Split by "[" in case of array notation + return firstKey; +}; + +/** +{ * Aggregates the input array of objects based on a comparator attribute and groups the entries. + * This function is useful for combining multiple entries into a single entry based on a specific attribute, + * while aggregating other attributes into an array.} + * + * @param {Array} input - The array of objects to be aggregated. + * @param {string} comparatorAttr - The attribute of the objects used for comparison to aggregate. + * @param {string} groupOn - The attribute of the objects where the grouped entries will be pushed. + * @returns {Array} - The aggregated array of objects. + * + * @example + * // Example input: + * const input = [ + * { id: 1, name: 'John', entries: ['entry1'] }, + * { id: 2, name: 'Jane', entries: ['entry2'] }, + * { id: 1, name: 'John', entries: ['entry3'] }, + * ]; + * const comparatorAttr = 'id'; + * const groupOn = 'entries'; + * + * // Example output: + * const output = [ + * { id: 1, name: 'John', entries: ['entry1', 'entry3'] }, + * { id: 2, name: 'Jane', entries: ['entry2'] }, + * ]; + */ +export function aggregate( + input: Array, + comparatorAttr: string, + groupOn: string +): Array> { + return input.reduce((acc, curr) => { + const existingEntry = acc.find( + (entry) => entry[comparatorAttr] === curr[comparatorAttr] + ); + + if (existingEntry) { + existingEntry[groupOn].push(...curr.entries); + } else { + acc.push({ ...curr }); + } + return acc; + }, []); +} + +/** + * Sanitizes the data in the imported sheet by trimming object keys. + * @param json - The JSON data representing the imported sheet. + * @returns {string[][]} - The sanitized data with trimmed object keys. + */ +export const sanitizeSheetData = (json) => { + return R.compose(R.map(trimObject))(json); +}; diff --git a/packages/server/src/services/Import/interfaces.ts b/packages/server/src/services/Import/interfaces.ts index b3cb9d8e1..3da238e5f 100644 --- a/packages/server/src/services/Import/interfaces.ts +++ b/packages/server/src/services/Import/interfaces.ts @@ -1,9 +1,10 @@ -import { IModelMetaField } from '@/interfaces'; +import { IModelMetaField, IModelMetaField2 } from '@/interfaces'; import Import from '@/models/Import'; export interface ImportMappingAttr { from: string; to: string; + group?: string; dateFormat?: string; } @@ -13,7 +14,7 @@ export interface ImportValidationError { constraints: Record; } -export type ResourceMetaFieldsMap = { [key: string]: IModelMetaField }; +export type ResourceMetaFieldsMap = { [key: string]: IModelMetaField2 }; export interface ImportInsertError { rowNumber: number; @@ -61,16 +62,15 @@ export interface ImportOperError { error: ImportInsertError[]; index: number; } - + export interface ImportableContext { - import: Import, + import: Import; rowIndex: number; } - export const ImportDateFormats = [ 'yyyy-MM-dd', 'dd.MM.yy', 'MM/dd/yy', - 'dd/MMM/yyyy' -] + 'dd/MMM/yyyy', +]; diff --git a/packages/server/src/services/Import/jobs/ImportDeleteExpiredFilesJob.ts b/packages/server/src/services/Import/jobs/ImportDeleteExpiredFilesJob.ts new file mode 100644 index 000000000..89a6f5b21 --- /dev/null +++ b/packages/server/src/services/Import/jobs/ImportDeleteExpiredFilesJob.ts @@ -0,0 +1 @@ +export class ImportDeleteExpiredFilesJobs {} diff --git a/packages/server/src/services/ManualJournals/CreateManualJournal.ts b/packages/server/src/services/ManualJournals/CreateManualJournal.ts index 4cd213947..7374fb358 100644 --- a/packages/server/src/services/ManualJournals/CreateManualJournal.ts +++ b/packages/server/src/services/ManualJournals/CreateManualJournal.ts @@ -73,9 +73,7 @@ export class CreateManualJournalService { return R.compose( // Omits the `branchId` from entries if multiply branches feature not active. this.branchesDTOTransformer.transformDTO(tenantId) - )( - initialDTO - ); + )(initialDTO); } /** @@ -133,7 +131,8 @@ export class CreateManualJournalService { public makeJournalEntries = async ( tenantId: number, manualJournalDTO: IManualJournalDTO, - authorizedUser: ISystemUser + authorizedUser: ISystemUser, + trx?: Knex.Transaction ): Promise<{ manualJournal: IManualJournal }> => { const { ManualJournal } = this.tenancy.models(tenantId); @@ -156,27 +155,31 @@ export class CreateManualJournalService { ); // Creates a manual journal transactions with associated transactions // under unit-of-work envirement. - return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => { - // Triggers `onManualJournalCreating` event. - await this.eventPublisher.emitAsync(events.manualJournals.onCreating, { - tenantId, - manualJournalDTO, - trx, - } as IManualJournalCreatingPayload); + return this.uow.withTransaction( + tenantId, + async (trx: Knex.Transaction) => { + // Triggers `onManualJournalCreating` event. + await this.eventPublisher.emitAsync(events.manualJournals.onCreating, { + tenantId, + manualJournalDTO, + trx, + } as IManualJournalCreatingPayload); - // Upsert the manual journal object. - const manualJournal = await ManualJournal.query(trx).upsertGraph({ - ...manualJournalObj, - }); - // Triggers `onManualJournalCreated` event. - await this.eventPublisher.emitAsync(events.manualJournals.onCreated, { - tenantId, - manualJournal, - manualJournalId: manualJournal.id, - trx, - } as IManualJournalEventCreatedPayload); + // Upsert the manual journal object. + const manualJournal = await ManualJournal.query(trx).upsertGraph({ + ...manualJournalObj, + }); + // Triggers `onManualJournalCreated` event. + await this.eventPublisher.emitAsync(events.manualJournals.onCreated, { + tenantId, + manualJournal, + manualJournalId: manualJournal.id, + trx, + } as IManualJournalEventCreatedPayload); - return { manualJournal }; - }); + return { manualJournal }; + }, + trx + ); }; } diff --git a/packages/server/src/services/ManualJournals/ManualJournalsImport.ts b/packages/server/src/services/ManualJournals/ManualJournalsImport.ts new file mode 100644 index 000000000..1f3af74e9 --- /dev/null +++ b/packages/server/src/services/ManualJournals/ManualJournalsImport.ts @@ -0,0 +1,60 @@ +import { Inject } from 'typedi'; +import { Knex } from 'knex'; +import * as Yup from 'yup'; +import { Importable } from '../Import/Importable'; +import { CreateManualJournalService } from './CreateManualJournal'; +import { IManualJournalDTO } from '@/interfaces'; +import { ImportableContext } from '../Import/interfaces'; +import { ManualJournalsSampleData } from './constants'; + +export class ManualJournalImportable extends Importable { + @Inject() + private createManualJournalService: CreateManualJournalService; + + /** + * Importing to account service. + * @param {number} tenantId + * @param {IAccountCreateDTO} createAccountDTO + * @returns + */ + public importable( + tenantId: number, + createJournalDTO: IManualJournalDTO, + trx?: Knex.Transaction + ) { + return this.createManualJournalService.makeJournalEntries( + tenantId, + createJournalDTO, + {}, + trx + ); + } + + /** + * Transformes the DTO before passing it to importable and validation. + * @param {Record} createDTO + * @param {ImportableContext} context + * @returns {Record} + */ + public transform(createDTO: Record, context: ImportableContext) { + return createDTO; + } + + /** + * Params validation schema. + * @returns {ValidationSchema[]} + */ + public paramsValidationSchema() { + return Yup.object().shape({ + autoIncrement: Yup.boolean(), + }); + } + + /** + * Retrieves the sample data of manual journals that used to download sample sheet. + * @returns {Record} + */ + public sampleData(): Record[] { + return ManualJournalsSampleData; + } +} diff --git a/packages/server/src/services/ManualJournals/constants.ts b/packages/server/src/services/ManualJournals/constants.ts index 6f6379be2..e72cc11cf 100644 --- a/packages/server/src/services/ManualJournals/constants.ts +++ b/packages/server/src/services/ManualJournals/constants.ts @@ -29,3 +29,36 @@ export const CONTACTS_CONFIG = [ ]; export const DEFAULT_VIEWS = []; + +export const ManualJournalsSampleData = [ + { + Date: '2024-02-02', + 'Journal No': 'J-100022', + 'Reference No.': 'REF-10000', + 'Currency Code': '', + 'Exchange Rate': '', + 'Journal Type': '', + Description: 'Animi quasi qui itaque aut possimus illum est magnam enim.', + Credit: 1000, + Debit: 0, + Note: 'Qui reprehenderit voluptate.', + Account: 'Bank Account', + Contact: '', + Publish: 'T', + }, + { + Date: '2024-02-02', + 'Journal No': 'J-100022', + 'Reference No.': 'REF-10000', + 'Currency Code': '', + 'Exchange Rate': '', + 'Journal Type': '', + Description: 'In assumenda dicta autem non est corrupti non et.', + Credit: 0, + Debit: 1000, + Note: 'Omnis tempora qui fugiat neque dolor voluptatem aut repudiandae nihil.', + Account: 'Bank Account', + Contact: '', + Publish: 'T', + }, +]; diff --git a/packages/server/src/services/Purchases/Bills/BillsImportable.ts b/packages/server/src/services/Purchases/Bills/BillsImportable.ts new file mode 100644 index 000000000..a67f93546 --- /dev/null +++ b/packages/server/src/services/Purchases/Bills/BillsImportable.ts @@ -0,0 +1,46 @@ +import { Inject, Service } from 'typedi'; +import { Knex } from 'knex'; +import { Importable } from '@/services/Import/Importable'; +import { CreateBill } from './CreateBill'; +import { IBillDTO } from '@/interfaces'; + +@Service() +export class BillsImportable extends Importable { + @Inject() + private createBillService: CreateBill; + + /** + * Importing to account service. + * @param {number} tenantId + * @param {IAccountCreateDTO} createAccountDTO + * @returns + */ + public importable( + tenantId: number, + createAccountDTO: IBillDTO, + trx?: Knex.Transaction + ) { + console.log(JSON.stringify(createAccountDTO)); + return this.createBillService.createBill( + tenantId, + createAccountDTO, + {}, + trx + ); + } + + /** + * Concurrrency controlling of the importing process. + * @returns {number} + */ + public get concurrency() { + return 1; + } + + /** + * Retrieves the sample data that used to download accounts sample sheet. + */ + public sampleData(): any[] { + return []; + } +} diff --git a/packages/server/src/services/Purchases/Bills/CreateBill.ts b/packages/server/src/services/Purchases/Bills/CreateBill.ts index 47dfadecc..98faea3c5 100644 --- a/packages/server/src/services/Purchases/Bills/CreateBill.ts +++ b/packages/server/src/services/Purchases/Bills/CreateBill.ts @@ -53,7 +53,8 @@ export class CreateBill { public async createBill( tenantId: number, billDTO: IBillDTO, - authorizedUser: ISystemUser + authorizedUser: ISystemUser, + trx?: Knex.Transaction ): Promise { const { Bill, Contact } = this.tenancy.models(tenantId); @@ -91,26 +92,30 @@ export class CreateBill { authorizedUser ); // Write new bill transaction with associated transactions under UOW env. - return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => { - // Triggers `onBillCreating` event. - await this.eventPublisher.emitAsync(events.bill.onCreating, { - trx, - billDTO, - tenantId, - } as IBillCreatingPayload); + return this.uow.withTransaction( + tenantId, + async (trx: Knex.Transaction) => { + // Triggers `onBillCreating` event. + await this.eventPublisher.emitAsync(events.bill.onCreating, { + trx, + billDTO, + tenantId, + } as IBillCreatingPayload); - // Inserts the bill graph object to the storage. - const bill = await Bill.query(trx).upsertGraph(billObj); + // Inserts the bill graph object to the storage. + const bill = await Bill.query(trx).upsertGraph(billObj); - // Triggers `onBillCreated` event. - await this.eventPublisher.emitAsync(events.bill.onCreated, { - tenantId, - bill, - billId: bill.id, - trx, - } as IBillCreatedPayload); + // Triggers `onBillCreated` event. + await this.eventPublisher.emitAsync(events.bill.onCreated, { + tenantId, + bill, + billId: bill.id, + trx, + } as IBillCreatedPayload); - return bill; - }); + return bill; + }, + trx + ); } } diff --git a/packages/server/src/services/Resource/ResourceService.ts b/packages/server/src/services/Resource/ResourceService.ts index 529448f8d..edf97cd70 100644 --- a/packages/server/src/services/Resource/ResourceService.ts +++ b/packages/server/src/services/Resource/ResourceService.ts @@ -2,7 +2,7 @@ import { Service, Inject } from 'typedi'; import { camelCase, upperFirst, pickBy } from 'lodash'; import * as qim from 'qim'; import pluralize from 'pluralize'; -import { IModelMeta, IModelMetaField } from '@/interfaces'; +import { IModelMeta, IModelMetaField, IModelMetaField2 } from '@/interfaces'; import TenancyService from '@/services/Tenancy/TenancyService'; import { ServiceError } from '@/exceptions'; import I18nService from '@/services/I18n/I18nService'; @@ -74,11 +74,20 @@ export default class ResourceService { return meta.fields; } + public getResourceFields2( + tenantId: number, + modelName: string + ): { [key: string]: IModelMetaField2 } { + const meta = this.getResourceMeta(tenantId, modelName); + + return meta.fields2; + } + /** - * - * @param {number} tenantId - * @param {string} modelName - * @returns + * + * @param {number} tenantId + * @param {string} modelName + * @returns */ public getResourceImportableFields( tenantId: number, @@ -98,7 +107,9 @@ export default class ResourceService { const naviagations = [ ['fields', qim.$each, 'name'], + ['fields2', qim.$each, 'name'], ['fields', qim.$each, $enumerationType, 'options', qim.$each, 'label'], + ['fields2', qim.$each, $enumerationType, 'options', qim.$each, 'label'], ]; return this.i18nService.i18nApply(naviagations, meta, tenantId); } diff --git a/packages/webapp/src/containers/Accounting/JournalsLanding/ManualJournalActionsBar.tsx b/packages/webapp/src/containers/Accounting/JournalsLanding/ManualJournalActionsBar.tsx index e1a9bbac5..676322da1 100644 --- a/packages/webapp/src/containers/Accounting/JournalsLanding/ManualJournalActionsBar.tsx +++ b/packages/webapp/src/containers/Accounting/JournalsLanding/ManualJournalActionsBar.tsx @@ -72,6 +72,10 @@ function ManualJournalActionsBar({ const handleRefreshBtnClick = () => { refresh(); }; + // Handle import button click. + const handleImportBtnClick = () => { + history.push('/manual-journals/import'); + } // Handle table row size change. const handleTableRowSizeChange = (size) => { @@ -130,6 +134,7 @@ function ManualJournalActionsBar({ className={Classes.MINIMAL} icon={} text={} + onClick={handleImportBtnClick} />