feat: aggregate rows on import feature

This commit is contained in:
Ahmed Bouhuolia
2024-04-04 05:01:09 +02:00
parent b9651f30d5
commit 3851d34ba4
32 changed files with 1115 additions and 298 deletions

View File

@@ -355,11 +355,13 @@
"expense.field.status.published": "Published", "expense.field.status.published": "Published",
"expense.field.created_at": "Created at", "expense.field.created_at": "Created at",
"manual_journal.field.date": "Date", "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.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.amount": "Amount",
"manual_journal.field.description": "Description", "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.status": "Status",
"manual_journal.field.created_at": "Created at", "manual_journal.field.created_at": "Created at",
"receipt.field.amount": "Amount", "receipt.field.amount": "Amount",

View File

@@ -37,6 +37,7 @@ export class ImportController extends BaseController {
[ [
param('import_id').exists().isString(), param('import_id').exists().isString(),
body('mapping').exists().isArray({ min: 1 }), body('mapping').exists().isArray({ min: 1 }),
body('mapping.*.group').optional(),
body('mapping.*.from').exists(), body('mapping.*.from').exists(),
body('mapping.*.to').exists(), body('mapping.*.to').exists(),
], ],

View File

@@ -69,6 +69,7 @@ export type IModelMetaField = IModelMetaFieldCommon &
| IModelMetaFieldUrl | IModelMetaFieldUrl
| IModelMetaEnumerationField | IModelMetaEnumerationField
| IModelMetaRelationField | IModelMetaRelationField
| IModelMetaCollectionField
); );
export interface IModelMetaEnumerationOption { export interface IModelMetaEnumerationOption {
@@ -92,12 +93,71 @@ export interface IModelMetaRelationEnumerationField {
relationEntityKey: string; 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 & export type IModelMetaRelationField = IModelMetaRelationFieldCommon &
IModelMetaRelationEnumerationField; IModelMetaRelationEnumerationField;
export interface IModelMeta { export interface IModelMeta {
defaultFilterField: string; defaultFilterField: string;
defaultSort: IModelMetaDefaultSort; defaultSort: IModelMetaDefaultSort;
importable?: boolean; importable?: boolean;
importAggregator?: string;
importAggregateOn?: string;
importAggregateBy?: string;
fields: { [key: string]: IModelMetaField }; 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
);

View File

@@ -1,10 +1,13 @@
export default { export default {
defaultFilterField: 'vendor', defaultFilterField: 'vendor',
defaultSort: { defaultSort: {
sortOrder: 'DESC', sortOrder: 'DESC',
sortField: 'bill_date', sortField: 'bill_date',
}, },
importable: true,
importAggregator: 'group',
importAggregateOn: 'entries',
importAggregateBy: 'billNumber',
fields: { fields: {
vendor: { vendor: {
name: 'bill.field.vendor', name: 'bill.field.vendor',
@@ -77,6 +80,76 @@ export default {
fieldType: 'date', 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',
},
},
},
},
}; };
/** /**

View File

@@ -4,54 +4,193 @@ export default {
sortOrder: 'DESC', sortOrder: 'DESC',
sortField: 'name', sortField: 'name',
}, },
importable: true,
importAggregator: 'group',
importAggregateOn: 'entries',
importAggregateBy: 'journalNumber',
fields: { fields: {
'date': { date: {
name: 'manual_journal.field.date', name: 'manual_journal.field.date',
column: 'date', column: 'date',
fieldType: 'date', fieldType: 'date',
importable: true,
required: true,
}, },
'journal_number': { journalNumber: {
name: 'manual_journal.field.journal_number', name: 'manual_journal.field.journal_number',
column: 'journal_number', column: 'journal_number',
fieldType: 'text', fieldType: 'text',
importable: true,
required: true,
}, },
'reference': { reference: {
name: 'manual_journal.field.reference', name: 'manual_journal.field.reference',
column: 'reference', column: 'reference',
fieldType: 'text', fieldType: 'text',
importable: true,
}, },
'journal_type': { journalType: {
name: 'manual_journal.field.journal_type', name: 'manual_journal.field.journal_type',
column: 'journal_type', column: 'journal_type',
fieldType: 'text', fieldType: 'text',
}, },
'amount': { amount: {
name: 'manual_journal.field.amount', name: 'manual_journal.field.amount',
column: 'amount', column: 'amount',
fieldType: 'number', fieldType: 'number',
}, },
'description': { description: {
name: 'manual_journal.field.description', name: 'manual_journal.field.description',
column: 'description', column: 'description',
fieldType: 'text', 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', name: 'manual_journal.field.status',
column: 'status', column: 'status',
fieldType: 'enumeration', fieldType: 'enumeration',
options: [ options: [
{ key: 'draft', label: 'Draft' }, { key: 'draft', label: 'Draft' },
{ key: 'published', label: 'published' } { key: 'published', label: 'published' },
], ],
filterCustomQuery: StatusFieldFilterQuery, filterCustomQuery: StatusFieldFilterQuery,
sortCustomQuery: StatusFieldSortQuery, sortCustomQuery: StatusFieldSortQuery,
}, },
'created_at': { createdAt: {
name: 'manual_journal.field.created_at', name: 'manual_journal.field.created_at',
column: 'created_at', column: 'created_at',
fieldType: 'date', 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. * Status field filter custom query.
*/ */
function StatusFieldFilterQuery(query, role) { function StatusFieldFilterQuery(query, role) {
query.modify('filterByStatus', role.value); query.modify('filterByStatus', role.value);
} }

View File

@@ -71,7 +71,7 @@ export class ImportFileCommon {
parsedData: Record<string, any>[], parsedData: Record<string, any>[],
trx?: Knex.Transaction trx?: Knex.Transaction
): Promise<[ImportOperSuccess[], ImportOperError[]]> { ): Promise<[ImportOperSuccess[], ImportOperError[]]> {
const importableFields = this.resource.getResourceImportableFields( const resourceFields = this.resource.getResourceFields2(
tenantId, tenantId,
importFile.resource importFile.resource
); );
@@ -90,7 +90,7 @@ export class ImportFileCommon {
}; };
const transformedDTO = importable.transform(objectDTO, context); const transformedDTO = importable.transform(objectDTO, context);
const rowNumber = index + 1; const rowNumber = index + 1;
const uniqueValue = getUniqueImportableValue(importableFields, objectDTO); const uniqueValue = getUniqueImportableValue(resourceFields, objectDTO);
const errorContext = { const errorContext = {
rowNumber, rowNumber,
uniqueValue, uniqueValue,
@@ -98,7 +98,7 @@ export class ImportFileCommon {
try { try {
// Validate the DTO object before passing it to the service layer. // Validate the DTO object before passing it to the service layer.
await this.importFileValidator.validateData( await this.importFileValidator.validateData(
importableFields, resourceFields,
transformedDTO transformedDTO
); );
try { try {

View File

@@ -1,63 +1,91 @@
import { Inject, Service } from 'typedi'; import { Inject, Service } from 'typedi';
import * as R from 'ramda';
import bluebird from 'bluebird'; import bluebird from 'bluebird';
import { isUndefined, get, chain, toArray, pickBy, castArray } from 'lodash'; import { isUndefined, pickBy, set } from 'lodash';
import { Knex } from 'knex'; import { Knex } from 'knex';
import { ImportMappingAttr, ResourceMetaFieldsMap } from './interfaces'; import { ImportMappingAttr, ResourceMetaFieldsMap } from './interfaces';
import { trimObject, parseBoolean } from './_utils'; import {
import { Account, Item } from '@/models'; valueParser,
parseKey,
getFieldKey,
aggregate,
sanitizeSheetData,
} from './_utils';
import ResourceService from '../Resource/ResourceService'; import ResourceService from '../Resource/ResourceService';
import { multiNumberParse } from '@/utils/multi-number-parse'; import HasTenancyService from '../Tenancy/TenancyService';
const CurrencyParsingDTOs = 10; const CurrencyParsingDTOs = 10;
const getMapToPath = (to: string, group = '') =>
group ? `${group}.${to}` : to;
@Service() @Service()
export class ImportFileDataTransformer { export class ImportFileDataTransformer {
@Inject() @Inject()
private resource: ResourceService; private resource: ResourceService;
@Inject()
private tenancy: HasTenancyService;
/** /**
* Parses the given sheet data before passing to the service layer. * 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 {number} tenantId -
* @param {} * @param {}
*/ */
public async parseSheetData( public async parseSheetData(
tenantId: number, tenantId: number,
importFile: any, importFile: any,
importableFields: any, importableFields: ResourceMetaFieldsMap,
data: Record<string, unknown>[], data: Record<string, unknown>[],
trx?: Knex.Transaction trx?: Knex.Transaction
) { ): Promise<Record<string, any>[]> {
// Sanitize the sheet data. // Sanitize the sheet data.
const sanitizedData = this.sanitizeSheetData(data); const sanitizedData = sanitizeSheetData(data);
// Map the sheet columns key with the given map. // Map the sheet columns key with the given map.
const mappedDTOs = this.mapSheetColumns( const mappedDTOs = this.mapSheetColumns(
sanitizedData, sanitizedData,
importFile.mappingParsed importFile.mappingParsed
); );
const resourceModel = this.resource.getResourceModel(
tenantId,
importFile.resource
);
// Parse the mapped sheet values. // Parse the mapped sheet values.
return this.parseExcelValues( const parsedValues = await this.parseExcelValues(
tenantId,
importableFields, importableFields,
mappedDTOs, mappedDTOs,
resourceModel,
trx trx
); );
const aggregateValues = this.aggregateParsedValues(
tenantId,
importFile.resource,
parsedValues
);
return aggregateValues;
} }
/** /**
* Sanitizes the data in the imported sheet by trimming object keys. * Aggregates parsed data based on resource metadata configuration.
* @param json - The JSON data representing the imported sheet. * @param {number} tenantId
* @returns {string[][]} - The sanitized data with trimmed object keys. * @param {string} resourceName
* @param {Record<string, any>} parsedData
* @returns {Record<string, any>[]}
*/ */
public sanitizeSheetData(json) { public aggregateParsedValues = (
return R.compose(R.map(trimObject))(json); tenantId: number,
} resourceName: string,
parsedData: Record<string, any>[]
): Record<string, any>[] => {
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. * Maps the columns of the imported data based on the provided mapping attributes.
@@ -74,7 +102,8 @@ export class ImportFileDataTransformer {
map map
.filter((mapping) => !isUndefined(item[mapping.from])) .filter((mapping) => !isUndefined(item[mapping.from]))
.forEach((mapping) => { .forEach((mapping) => {
newItem[mapping.to] = item[mapping.from]; const toPath = getMapToPath(mapping.to, mapping.group);
newItem[toPath] = item[mapping.from];
}); });
return newItem; return newItem;
}); });
@@ -87,78 +116,32 @@ export class ImportFileDataTransformer {
* @returns {Record<string, any>} * @returns {Record<string, any>}
*/ */
public async parseExcelValues( public async parseExcelValues(
tenantId: number,
fields: ResourceMetaFieldsMap, fields: ResourceMetaFieldsMap,
valueDTOs: Record<string, any>[], valueDTOs: Record<string, any>[],
resourceModel: any,
trx?: Knex.Transaction trx?: Knex.Transaction
): Promise<Record<string, any>> { ): Promise<Record<string, any>[]> {
// Prases the given object value based on the field key type. const tenantModels = this.tenancy.models(tenantId);
const parser = async (value, key) => { const _valueParser = valueParser(fields, tenantModels, trx);
let _value = value; const _keyParser = parseKey(fields);
const field = fields[key];
// 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) => { const parseAsync = async (valueDTO) => {
// Remove the undefined fields. // Clean up the undefined keys that not exist in resource fields.
const _valueDTO = pickBy( const _valueDTO = pickBy(
valueDTO, 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); const keys = Object.keys(_valueDTO);
// Map the object values. // Map the object values.
return bluebird.reduce( return bluebird.reduce(
keys, keys,
async (acc, key) => { async (acc, key) => {
const parsedValue = await parser(_valueDTO[key], key); const parsedValue = await _valueParser(_valueDTO[key], key);
const parsedKey = await parseKey(key); const parsedKey = await _keyParser(key);
acc[parsedKey] = parsedValue;
set(acc, parsedKey, parsedValue);
return acc; return acc;
}, },
{} {}

View File

@@ -1,4 +1,4 @@
import { fromPairs } from 'lodash'; import { fromPairs, isUndefined } from 'lodash';
import { Inject, Service } from 'typedi'; import { Inject, Service } from 'typedi';
import HasTenancyService from '../Tenancy/TenancyService'; import HasTenancyService from '../Tenancy/TenancyService';
import { import {
@@ -69,7 +69,7 @@ export class ImportFileMapping {
importFile: any, importFile: any,
maps: ImportMappingAttr[] maps: ImportMappingAttr[]
) { ) {
const fields = this.resource.getResourceImportableFields( const fields = this.resource.getResourceFields2(
tenantId, tenantId,
importFile.resource importFile.resource
); );
@@ -78,11 +78,20 @@ export class ImportFileMapping {
); );
const invalid = []; const invalid = [];
// is not empty, is not undefined or map.group
maps.forEach((map) => { maps.forEach((map) => {
if ( let _invalid = true;
'undefined' === typeof fields[map.to] ||
'undefined' === typeof columnsMap[map.from] 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); invalid.push(map);
} }
}); });
@@ -105,10 +114,14 @@ export class ImportFileMapping {
} else { } else {
fromMap[map.from] = true; 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); throw new ServiceError(ERRORS.DUPLICATED_TO_MAP_ATTR);
} else { } else {
toMap[map.to] = true; toMap[toPath] = true;
} }
}); });
} }
@@ -128,6 +141,7 @@ export class ImportFileMapping {
tenantId, tenantId,
resource resource
); );
// @todo Validate date type of the nested fields.
maps.forEach((map) => { maps.forEach((map) => {
if ( if (
typeof fields[map.to] !== 'undefined' && typeof fields[map.to] !== 'undefined' &&

View File

@@ -53,11 +53,10 @@ export class ImportFileProcess {
const sheetData = this.importCommon.parseXlsxSheet(buffer); const sheetData = this.importCommon.parseXlsxSheet(buffer);
const header = getSheetColumns(sheetData); const header = getSheetColumns(sheetData);
const importableFields = this.resource.getResourceImportableFields( const resourceFields = this.resource.getResourceFields2(
tenantId, tenantId,
importFile.resource importFile.resource
); );
// Runs the importing operation with ability to return errors that will happen. // Runs the importing operation with ability to return errors that will happen.
const [successedImport, failedImport, allData] = const [successedImport, failedImport, allData] =
await this.uow.withTransaction( await this.uow.withTransaction(
@@ -67,7 +66,7 @@ export class ImportFileProcess {
const parsedData = await this.importParser.parseSheetData( const parsedData = await this.importParser.parseSheetData(
tenantId, tenantId,
importFile, importFile,
importableFields, resourceFields,
sheetData, sheetData,
trx trx
); );

View File

@@ -1,8 +1,11 @@
import { Inject, Service } from 'typedi'; import { Inject, Service } from 'typedi';
import HasTenancyService from '../Tenancy/TenancyService'; import HasTenancyService from '../Tenancy/TenancyService';
import { sanitizeResourceName, validateSheetEmpty } from './_utils'; import {
getResourceColumns,
sanitizeResourceName,
validateSheetEmpty,
} from './_utils';
import ResourceService from '../Resource/ResourceService'; import ResourceService from '../Resource/ResourceService';
import { IModelMetaField } from '@/interfaces';
import { ImportFileCommon } from './ImportFileCommon'; import { ImportFileCommon } from './ImportFileCommon';
import { ImportFileDataValidator } from './ImportFileDataValidator'; import { ImportFileDataValidator } from './ImportFileDataValidator';
import { ImportFileUploadPOJO } from './interfaces'; import { ImportFileUploadPOJO } from './interfaces';
@@ -77,11 +80,11 @@ export class ImportFileUploadService {
columns: coumnsStringified, columns: coumnsStringified,
params: paramsStringified, params: paramsStringified,
}); });
const resourceColumnsMap = this.resourceService.getResourceImportableFields( const resourceColumnsMap = this.resourceService.getResourceFields2(
tenantId, tenantId,
resource resource
); );
const resourceColumns = this.getResourceColumns(resourceColumnsMap); const resourceColumns = getResourceColumns(resourceColumnsMap);
return { return {
import: { import: {
@@ -92,23 +95,4 @@ export class ImportFileUploadService {
resourceColumns, 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
);
}
} }

View File

@@ -5,7 +5,7 @@ export class ImportableRegistry {
private static instance: ImportableRegistry; private static instance: ImportableRegistry;
private importables: Record<string, Importable>; private importables: Record<string, Importable>;
private constructor() { constructor() {
this.importables = {}; this.importables = {};
} }

View File

@@ -6,6 +6,8 @@ import { CustomersImportable } from '../Contacts/Customers/CustomersImportable';
import { VendorsImportable } from '../Contacts/Vendors/VendorsImportable'; import { VendorsImportable } from '../Contacts/Vendors/VendorsImportable';
import { ItemsImportable } from '../Items/ItemsImportable'; import { ItemsImportable } from '../Items/ItemsImportable';
import { ItemCategoriesImportable } from '../ItemCategories/ItemCategoriesImportable'; import { ItemCategoriesImportable } from '../ItemCategories/ItemCategoriesImportable';
import { ManualJournalImportable } from '../ManualJournals/ManualJournalsImport';
import { BillsImportable } from '../Purchases/Bills/BillsImportable';
@Service() @Service()
export class ImportableResources { export class ImportableResources {
@@ -28,6 +30,8 @@ export class ImportableResources {
{ resource: 'Vendor', importable: VendorsImportable }, { resource: 'Vendor', importable: VendorsImportable },
{ resource: 'Item', importable: ItemsImportable }, { resource: 'Item', importable: ItemsImportable },
{ resource: 'ItemCategory', importable: ItemCategoriesImportable }, { resource: 'ItemCategory', importable: ItemCategoriesImportable },
{ resource: 'ManualJournal', importable: ManualJournalImportable },
{ resource: 'Bill', importable: BillsImportable },
]; ];
public get registry() { public get registry() {

View File

@@ -1,5 +1,7 @@
import * as Yup from 'yup'; import * as Yup from 'yup';
import moment from 'moment'; import moment from 'moment';
import * as R from 'ramda';
import { Knex } from 'knex';
import { import {
defaultTo, defaultTo,
upperFirst, upperFirst,
@@ -8,11 +10,17 @@ import {
isUndefined, isUndefined,
pickBy, pickBy,
isEmpty, isEmpty,
castArray,
get,
head,
split,
last,
} from 'lodash'; } from 'lodash';
import pluralize from 'pluralize'; import pluralize from 'pluralize';
import { ResourceMetaFieldsMap } from './interfaces'; import { ResourceMetaFieldsMap } from './interfaces';
import { IModelMetaField } from '@/interfaces'; import { IModelMetaField, IModelMetaField2 } from '@/interfaces';
import { ServiceError } from '@/exceptions'; import { ServiceError } from '@/exceptions';
import { multiNumberParse } from '@/utils/multi-number-parse';
export const ERRORS = { export const ERRORS = {
RESOURCE_NOT_IMPORTABLE: 'RESOURCE_NOT_IMPORTABLE', RESOURCE_NOT_IMPORTABLE: 'RESOURCE_NOT_IMPORTABLE',
@@ -40,6 +48,7 @@ export function trimObject(obj) {
export const convertFieldsToYupValidation = (fields: ResourceMetaFieldsMap) => { export const convertFieldsToYupValidation = (fields: ResourceMetaFieldsMap) => {
const yupSchema = {}; const yupSchema = {};
Object.keys(fields).forEach((fieldName: string) => { Object.keys(fields).forEach((fieldName: string) => {
const field = fields[fieldName] as IModelMetaField; const field = fields[fieldName] as IModelMetaField;
let fieldSchema; let fieldSchema;
@@ -89,6 +98,17 @@ export const convertFieldsToYupValidation = (fields: ResourceMetaFieldsMap) => {
); );
} else if (field.fieldType === 'url') { } else if (field.fieldType === 'url') {
fieldSchema = fieldSchema.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) { if (field.required) {
fieldSchema = fieldSchema.required(); fieldSchema = fieldSchema.required();
@@ -103,9 +123,9 @@ export const convertFieldsToYupValidation = (fields: ResourceMetaFieldsMap) => {
const parseFieldName = (fieldName: string, field: IModelMetaField) => { const parseFieldName = (fieldName: string, field: IModelMetaField) => {
let _key = fieldName; let _key = fieldName;
if (field.fieldType === 'relation') { // if (field.fieldType === 'relation') {
_key = `${fieldName}Id`; // _key = `${fieldName}Id`;
} // }
if (field.dataTransferObjectKey) { if (field.dataTransferObjectKey) {
_key = field.dataTransferObjectKey; _key = field.dataTransferObjectKey;
} }
@@ -134,7 +154,7 @@ export const getSheetColumns = (sheetData: unknown[]) => {
* @returns {string} * @returns {string}
*/ */
export const getUniqueImportableValue = ( export const getUniqueImportableValue = (
importableFields: { [key: string]: IModelMetaField }, importableFields: { [key: string]: IModelMetaField2 },
objectDTO: Record<string, any> objectDTO: Record<string, any>
) => { ) => {
const uniqueImportableValue = pickBy( const uniqueImportableValue = pickBy(
@@ -155,15 +175,15 @@ export const validateSheetEmpty = (sheetData: Array<any>) => {
if (isEmpty(sheetData)) { if (isEmpty(sheetData)) {
throw new ServiceError(ERRORS.IMPORTED_SHEET_EMPTY); throw new ServiceError(ERRORS.IMPORTED_SHEET_EMPTY);
} }
} };
const booleanValuesRepresentingTrue: string[] = ['true', 'yes', 'y', 't', '1']; const booleanValuesRepresentingTrue: string[] = ['true', 'yes', 'y', 't', '1'];
const booleanValuesRepresentingFalse: string[] = ['false', 'no', 'n', 'f', '0']; const booleanValuesRepresentingFalse: string[] = ['false', 'no', 'n', 'f', '0'];
/** /**
* Parses the given string value to boolean. * Parses the given string value to boolean.
* @param {string} value * @param {string} value
* @returns {string|null} * @returns {string|null}
*/ */
export const parseBoolean = (value: string): boolean | null => { export const parseBoolean = (value: string): boolean | null => {
const normalizeValue = (value: string): string => const normalizeValue = (value: string): string =>
@@ -182,3 +202,204 @@ export const parseBoolean = (value: string): boolean | null => {
} }
return 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<string, any> = {};
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<any>,
comparatorAttr: string,
groupOn: string
): Array<Record<string, any>> {
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);
};

View File

@@ -1,9 +1,10 @@
import { IModelMetaField } from '@/interfaces'; import { IModelMetaField, IModelMetaField2 } from '@/interfaces';
import Import from '@/models/Import'; import Import from '@/models/Import';
export interface ImportMappingAttr { export interface ImportMappingAttr {
from: string; from: string;
to: string; to: string;
group?: string;
dateFormat?: string; dateFormat?: string;
} }
@@ -13,7 +14,7 @@ export interface ImportValidationError {
constraints: Record<string, string>; constraints: Record<string, string>;
} }
export type ResourceMetaFieldsMap = { [key: string]: IModelMetaField }; export type ResourceMetaFieldsMap = { [key: string]: IModelMetaField2 };
export interface ImportInsertError { export interface ImportInsertError {
rowNumber: number; rowNumber: number;
@@ -61,16 +62,15 @@ export interface ImportOperError {
error: ImportInsertError[]; error: ImportInsertError[];
index: number; index: number;
} }
export interface ImportableContext { export interface ImportableContext {
import: Import, import: Import;
rowIndex: number; rowIndex: number;
} }
export const ImportDateFormats = [ export const ImportDateFormats = [
'yyyy-MM-dd', 'yyyy-MM-dd',
'dd.MM.yy', 'dd.MM.yy',
'MM/dd/yy', 'MM/dd/yy',
'dd/MMM/yyyy' 'dd/MMM/yyyy',
] ];

View File

@@ -0,0 +1 @@
export class ImportDeleteExpiredFilesJobs {}

View File

@@ -73,9 +73,7 @@ export class CreateManualJournalService {
return R.compose( return R.compose(
// Omits the `branchId` from entries if multiply branches feature not active. // Omits the `branchId` from entries if multiply branches feature not active.
this.branchesDTOTransformer.transformDTO(tenantId) this.branchesDTOTransformer.transformDTO(tenantId)
)( )(initialDTO);
initialDTO
);
} }
/** /**
@@ -133,7 +131,8 @@ export class CreateManualJournalService {
public makeJournalEntries = async ( public makeJournalEntries = async (
tenantId: number, tenantId: number,
manualJournalDTO: IManualJournalDTO, manualJournalDTO: IManualJournalDTO,
authorizedUser: ISystemUser authorizedUser: ISystemUser,
trx?: Knex.Transaction
): Promise<{ manualJournal: IManualJournal }> => { ): Promise<{ manualJournal: IManualJournal }> => {
const { ManualJournal } = this.tenancy.models(tenantId); const { ManualJournal } = this.tenancy.models(tenantId);
@@ -156,27 +155,31 @@ export class CreateManualJournalService {
); );
// Creates a manual journal transactions with associated transactions // Creates a manual journal transactions with associated transactions
// under unit-of-work envirement. // under unit-of-work envirement.
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => { return this.uow.withTransaction(
// Triggers `onManualJournalCreating` event. tenantId,
await this.eventPublisher.emitAsync(events.manualJournals.onCreating, { async (trx: Knex.Transaction) => {
tenantId, // Triggers `onManualJournalCreating` event.
manualJournalDTO, await this.eventPublisher.emitAsync(events.manualJournals.onCreating, {
trx, tenantId,
} as IManualJournalCreatingPayload); manualJournalDTO,
trx,
} as IManualJournalCreatingPayload);
// Upsert the manual journal object. // Upsert the manual journal object.
const manualJournal = await ManualJournal.query(trx).upsertGraph({ const manualJournal = await ManualJournal.query(trx).upsertGraph({
...manualJournalObj, ...manualJournalObj,
}); });
// Triggers `onManualJournalCreated` event. // Triggers `onManualJournalCreated` event.
await this.eventPublisher.emitAsync(events.manualJournals.onCreated, { await this.eventPublisher.emitAsync(events.manualJournals.onCreated, {
tenantId, tenantId,
manualJournal, manualJournal,
manualJournalId: manualJournal.id, manualJournalId: manualJournal.id,
trx, trx,
} as IManualJournalEventCreatedPayload); } as IManualJournalEventCreatedPayload);
return { manualJournal }; return { manualJournal };
}); },
trx
);
}; };
} }

View File

@@ -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<string, any>} createDTO
* @param {ImportableContext} context
* @returns {Record<string, any>}
*/
public transform(createDTO: Record<string, any>, 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<string, any>}
*/
public sampleData(): Record<string, any>[] {
return ManualJournalsSampleData;
}
}

View File

@@ -29,3 +29,36 @@ export const CONTACTS_CONFIG = [
]; ];
export const DEFAULT_VIEWS = []; 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',
},
];

View File

@@ -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 [];
}
}

View File

@@ -53,7 +53,8 @@ export class CreateBill {
public async createBill( public async createBill(
tenantId: number, tenantId: number,
billDTO: IBillDTO, billDTO: IBillDTO,
authorizedUser: ISystemUser authorizedUser: ISystemUser,
trx?: Knex.Transaction
): Promise<IBill> { ): Promise<IBill> {
const { Bill, Contact } = this.tenancy.models(tenantId); const { Bill, Contact } = this.tenancy.models(tenantId);
@@ -91,26 +92,30 @@ export class CreateBill {
authorizedUser authorizedUser
); );
// Write new bill transaction with associated transactions under UOW env. // Write new bill transaction with associated transactions under UOW env.
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => { return this.uow.withTransaction(
// Triggers `onBillCreating` event. tenantId,
await this.eventPublisher.emitAsync(events.bill.onCreating, { async (trx: Knex.Transaction) => {
trx, // Triggers `onBillCreating` event.
billDTO, await this.eventPublisher.emitAsync(events.bill.onCreating, {
tenantId, trx,
} as IBillCreatingPayload); billDTO,
tenantId,
} as IBillCreatingPayload);
// Inserts the bill graph object to the storage. // Inserts the bill graph object to the storage.
const bill = await Bill.query(trx).upsertGraph(billObj); const bill = await Bill.query(trx).upsertGraph(billObj);
// Triggers `onBillCreated` event. // Triggers `onBillCreated` event.
await this.eventPublisher.emitAsync(events.bill.onCreated, { await this.eventPublisher.emitAsync(events.bill.onCreated, {
tenantId, tenantId,
bill, bill,
billId: bill.id, billId: bill.id,
trx, trx,
} as IBillCreatedPayload); } as IBillCreatedPayload);
return bill; return bill;
}); },
trx
);
} }
} }

View File

@@ -2,7 +2,7 @@ import { Service, Inject } from 'typedi';
import { camelCase, upperFirst, pickBy } from 'lodash'; import { camelCase, upperFirst, pickBy } from 'lodash';
import * as qim from 'qim'; import * as qim from 'qim';
import pluralize from 'pluralize'; import pluralize from 'pluralize';
import { IModelMeta, IModelMetaField } from '@/interfaces'; import { IModelMeta, IModelMetaField, IModelMetaField2 } from '@/interfaces';
import TenancyService from '@/services/Tenancy/TenancyService'; import TenancyService from '@/services/Tenancy/TenancyService';
import { ServiceError } from '@/exceptions'; import { ServiceError } from '@/exceptions';
import I18nService from '@/services/I18n/I18nService'; import I18nService from '@/services/I18n/I18nService';
@@ -74,11 +74,20 @@ export default class ResourceService {
return meta.fields; 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 {number} tenantId
* @param {string} modelName * @param {string} modelName
* @returns * @returns
*/ */
public getResourceImportableFields( public getResourceImportableFields(
tenantId: number, tenantId: number,
@@ -98,7 +107,9 @@ export default class ResourceService {
const naviagations = [ const naviagations = [
['fields', qim.$each, 'name'], ['fields', qim.$each, 'name'],
['fields2', qim.$each, 'name'],
['fields', qim.$each, $enumerationType, 'options', qim.$each, 'label'], ['fields', qim.$each, $enumerationType, 'options', qim.$each, 'label'],
['fields2', qim.$each, $enumerationType, 'options', qim.$each, 'label'],
]; ];
return this.i18nService.i18nApply(naviagations, meta, tenantId); return this.i18nService.i18nApply(naviagations, meta, tenantId);
} }

View File

@@ -72,6 +72,10 @@ function ManualJournalActionsBar({
const handleRefreshBtnClick = () => { const handleRefreshBtnClick = () => {
refresh(); refresh();
}; };
// Handle import button click.
const handleImportBtnClick = () => {
history.push('/manual-journals/import');
}
// Handle table row size change. // Handle table row size change.
const handleTableRowSizeChange = (size) => { const handleTableRowSizeChange = (size) => {
@@ -130,6 +134,7 @@ function ManualJournalActionsBar({
className={Classes.MINIMAL} className={Classes.MINIMAL}
icon={<Icon icon="file-import-16" iconSize={16} />} icon={<Icon icon="file-import-16" iconSize={16} />}
text={<T id={'import'} />} text={<T id={'import'} />}
onClick={handleImportBtnClick}
/> />
<Button <Button
className={Classes.MINIMAL} className={Classes.MINIMAL}

View File

@@ -0,0 +1,25 @@
// @ts-nocheck
import { DashboardInsider } from '@/components';
import { ImportView } from '../Import/ImportView';
import { useHistory } from 'react-router-dom';
export default function ManualJournalsImport() {
const history = useHistory();
const handleCancelBtnClick = () => {
history.push('/manual-journals');
};
const handleImportSuccess = () => {
history.push('/manual-journals');
};
return (
<DashboardInsider name={'import-manual-journals'}>
<ImportView
resource={'manual-journals'}
onCancelClick={handleCancelBtnClick}
onImportSuccess={handleImportSuccess}
/>
</DashboardInsider>
);
}

View File

@@ -1,18 +1,19 @@
import { useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import clsx from 'classnames'; import clsx from 'classnames';
import { Button, Intent, Position } from '@blueprintjs/core'; import { Button, Intent, Position } from '@blueprintjs/core';
import { useFormikContext } from 'formik'; import { useFormikContext } from 'formik';
import { FSelect, Group, Hint } from '@/components'; import { Box, FSelect, Group, Hint } from '@/components';
import { ImportFileMappingForm } from './ImportFileMappingForm'; import { ImportFileMappingForm } from './ImportFileMappingForm';
import { EntityColumn, useImportFileContext } from './ImportFileProvider'; import { EntityColumnField, useImportFileContext } from './ImportFileProvider';
import { CLASSES } from '@/constants'; import { CLASSES } from '@/constants';
import { ImportFileContainer } from './ImportFileContainer'; import { ImportFileContainer } from './ImportFileContainer';
import { ImportStepperStep } from './_types'; import { ImportStepperStep } from './_types';
import { ImportFileMapBootProvider } from './ImportFileMappingBoot'; import { ImportFileMapBootProvider } from './ImportFileMappingBoot';
import styles from './ImportFileMapping.module.scss'; import styles from './ImportFileMapping.module.scss';
import { getFieldKey } from './_utils';
export function ImportFileMapping() { export function ImportFileMapping() {
const { importId } = useImportFileContext(); const { importId, entityColumns } = useImportFileContext();
return ( return (
<ImportFileMapBootProvider importId={importId}> <ImportFileMapBootProvider importId={importId}>
@@ -23,56 +24,98 @@ export function ImportFileMapping() {
Bigcapital fields. Bigcapital fields.
</p> </p>
<table className={clsx('bp4-html-table', styles.table)}> {entityColumns.map((entityColumn, index) => (
<thead> <ImportFileMappingGroup
<tr> groupKey={entityColumn.groupKey}
<th className={styles.label}>Bigcapital Fields</th> groupName={entityColumn.groupName}
<th className={styles.field}>Sheet Column Headers</th> fields={entityColumn.fields}
</tr> />
</thead> ))}
<tbody>
<ImportFileMappingFields />
</tbody>
</table>
</ImportFileContainer> </ImportFileContainer>
<ImportFileMappingFloatingActions /> <ImportFileMappingFloatingActions />
</ImportFileMappingForm> </ImportFileMappingForm>
</ImportFileMapBootProvider> </ImportFileMapBootProvider>
); );
} }
function ImportFileMappingFields() { interface ImportFileMappingGroupProps {
const { entityColumns, sheetColumns } = useImportFileContext(); groupKey: string;
groupName: string;
fields: any;
}
/**
* Mapping fields group
* @returns {React.ReactNode}
*/
function ImportFileMappingGroup({
groupKey,
groupName,
fields,
}: ImportFileMappingGroupProps) {
return (
<Box>
{groupName && <h3>{groupName}</h3>}
<table className={clsx('bp4-html-table', styles.table)}>
<thead>
<tr>
<th className={styles.label}>Bigcapital Fields</th>
<th className={styles.field}>Sheet Column Headers</th>
</tr>
</thead>
<tbody>
<ImportFileMappingFields fields={fields} />
</tbody>
</table>
</Box>
);
}
interface ImportFileMappingFieldsProps {
fields: EntityColumnField[];
}
/**
* Import mapping fields.
* @returns {React.ReactNode}
*/
function ImportFileMappingFields({ fields }: ImportFileMappingFieldsProps) {
const { sheetColumns } = useImportFileContext();
const items = useMemo( const items = useMemo(
() => sheetColumns.map((column) => ({ value: column, text: column })), () => sheetColumns.map((column) => ({ value: column, text: column })),
[sheetColumns], [sheetColumns],
); );
const columnMapper = (column: EntityColumn, index: number) => ( const columnMapper = useCallback(
<tr key={index}> (column: EntityColumnField, index: number) => (
<td className={styles.label}> <tr key={index}>
{column.name}{' '} <td className={styles.label}>
{column.required && <span className={styles.requiredSign}>*</span>} {column.name}{' '}
</td> {column.required && <span className={styles.requiredSign}>*</span>}
<td className={styles.field}> </td>
<Group spacing={4}> <td className={styles.field}>
<FSelect <Group spacing={4}>
name={column.key} <FSelect
items={items} name={getFieldKey(column.key, column.group)}
popoverProps={{ minimal: true }} items={items}
minimal={true} popoverProps={{ minimal: true }}
fill={true} minimal={true}
/> fill={true}
{column.hint && ( />
<Hint content={column.hint} position={Position.BOTTOM} /> {column.hint && (
)} <Hint content={column.hint} position={Position.BOTTOM} />
</Group> )}
</td> </Group>
</tr> </td>
</tr>
),
[items],
);
const columns = useMemo(
() => fields.map(columnMapper),
[columnMapper, fields],
); );
const columns = entityColumns.map(columnMapper);
return <>{columns}</>; return <>{columns}</>;
} }

View File

@@ -3,17 +3,12 @@ import { Intent } from '@blueprintjs/core';
import { useImportFileMapping } from '@/hooks/query/import'; import { useImportFileMapping } from '@/hooks/query/import';
import { Form, Formik, FormikHelpers } from 'formik'; import { Form, Formik, FormikHelpers } from 'formik';
import { useImportFileContext } from './ImportFileProvider'; import { useImportFileContext } from './ImportFileProvider';
import { useMemo } from 'react';
import { isEmpty, lowerCase } from 'lodash';
import { AppToaster } from '@/components'; import { AppToaster } from '@/components';
import { useImportFileMapBootContext } from './ImportFileMappingBoot'; import { ImportFileMappingFormProps } from './_types';
import { transformToForm } from '@/utils'; import {
transformValueToReq,
interface ImportFileMappingFormProps { useImportFileMappingInitialValues,
children: React.ReactNode; } from './_utils';
}
type ImportFileMappingFormValues = Record<string, string | null>;
export function ImportFileMappingForm({ export function ImportFileMappingForm({
children, children,
@@ -52,50 +47,3 @@ export function ImportFileMappingForm({
</Formik> </Formik>
); );
} }
const transformValueToReq = (value: ImportFileMappingFormValues) => {
const mapping = Object.keys(value)
.filter((key) => !isEmpty(value[key]))
.map((key) => ({ from: value[key], to: key }));
return { mapping };
};
const transformResToFormValues = (value: { from: string; to: string }[]) => {
return value?.reduce((acc, map) => {
acc[map.to] = map.from;
return acc;
}, {});
};
const useImportFileMappingInitialValues = () => {
const { importFile } = useImportFileMapBootContext();
const { entityColumns, sheetColumns } = useImportFileContext();
const initialResValues = useMemo(
() => transformResToFormValues(importFile?.map || []),
[importFile?.map],
);
const initialValues = useMemo(
() =>
entityColumns.reduce((acc, { key, name }) => {
const _name = lowerCase(name);
const _matched = sheetColumns.find(
(column) => lowerCase(column) === _name,
);
// Match the default column name the same field name
// if matched one of sheet columns has the same field name.
acc[key] = _matched ? _matched : '';
return acc;
}, {}),
[entityColumns, sheetColumns],
);
return useMemo(
() => ({
...transformToForm(initialResValues, initialValues),
...initialValues,
}),
[initialValues, initialResValues],
);
};

View File

@@ -7,12 +7,19 @@ import React, {
useState, useState,
} from 'react'; } from 'react';
export type EntityColumn = { export type EntityColumnField = {
key: string; key: string;
name: string; name: string;
required?: boolean; required?: boolean;
hint?: string; hint?: string;
group?: string;
}; };
export interface EntityColumn {
groupKey: string;
groupName: string;
fields: EntityColumnField[];
}
export type SheetColumn = string; export type SheetColumn = string;
export type SheetMap = { from: string; to: string }; export type SheetMap = { from: string; to: string };

View File

@@ -7,6 +7,7 @@ import * as Yup from 'yup';
import { useImportFileContext } from './ImportFileProvider'; import { useImportFileContext } from './ImportFileProvider';
import { ImportAlert, ImportStepperStep } from './_types'; import { ImportAlert, ImportStepperStep } from './_types';
import { useAlertsManager } from './AlertsManager'; import { useAlertsManager } from './AlertsManager';
import { transformToCamelCase } from '@/utils';
const initialValues = { const initialValues = {
file: null, file: null,
@@ -55,9 +56,11 @@ export function ImportFileUploadForm({
uploadImportFile(formData) uploadImportFile(formData)
.then(({ data }) => { .then(({ data }) => {
setImportId(data.import.import_id); const _data = transformToCamelCase(data);
setSheetColumns(data.sheet_columns);
setEntityColumns(data.resource_columns); setImportId(_data.import.importId);
setSheetColumns(_data.sheetColumns);
setEntityColumns(_data.resourceColumns);
setStep(ImportStepperStep.Mapping); setStep(ImportStepperStep.Mapping);
setSubmitting(false); setSubmitting(false);
}) })

View File

@@ -5,5 +5,11 @@ export enum ImportStepperStep {
} }
export enum ImportAlert { export enum ImportAlert {
IMPORTED_SHEET_EMPTY = 'IMPORTED_SHEET_EMPTY' IMPORTED_SHEET_EMPTY = 'IMPORTED_SHEET_EMPTY',
} }
export interface ImportFileMappingFormProps {
children: React.ReactNode;
}
export type ImportFileMappingFormValues = Record<string, string | null>;

View File

@@ -0,0 +1,87 @@
import { useMemo } from 'react';
import { chain, isEmpty, lowerCase, head, last, set } from 'lodash';
import { useImportFileContext } from './ImportFileProvider';
import { useImportFileMapBootContext } from './ImportFileMappingBoot';
import { deepdash, transformToForm } from '@/utils';
import { ImportFileMappingFormValues } from './_types';
export const getFieldKey = (key: string, group = '') => {
return group ? `${group}.${key}` : key;
};
type ImportFileMappingRes = { from: string; to: string; group: string }[];
/**
* Transformes the mapping form values to request.
* @param {ImportFileMappingFormValues} value
* @returns {ImportFileMappingRes[]}
*/
export const transformValueToReq = (
value: ImportFileMappingFormValues,
): { mapping: ImportFileMappingRes[] } => {
const mapping = chain(value)
.thru(deepdash.index)
.pickBy((_value, key) => !isEmpty(_.get(value, key)))
.map((from, key) => ({
from,
to: key.includes('.') ? last(key.split('.')) : key,
group: key.includes('.') ? head(key.split('.')) : '',
}))
.value();
return { mapping };
};
/**
*
* @param value
* @returns
*/
export const transformResToFormValues = (
value: { from: string; to: string }[],
) => {
return value?.reduce((acc, map) => {
acc[map.to] = map.from;
return acc;
}, {});
};
/**
* Retrieves the initial values of mapping form.
* @returns {Record<string, any>}
*/
export const useImportFileMappingInitialValues = () => {
const { importFile } = useImportFileMapBootContext();
const { entityColumns, sheetColumns } = useImportFileContext();
const initialResValues = useMemo(
() => transformResToFormValues(importFile?.map || []),
[importFile?.map],
);
const initialValues = useMemo(
() =>
entityColumns.reduce((acc, { fields, groupKey }) => {
fields.forEach(({ key, name }) => {
const _name = lowerCase(name);
const _matched = sheetColumns.find(
(column) => lowerCase(column) === _name,
);
const _key = groupKey ? `${groupKey}.${key}` : key;
const _value = _matched ? _matched : '';
set(acc, _key, _value);
});
return acc;
}, {}),
[entityColumns, sheetColumns],
);
return useMemo<Record<string, any>>(
() => ({
...transformToForm(initialResValues, initialValues),
...initialValues,
}),
[initialValues, initialResValues],
);
};

View File

@@ -0,0 +1,25 @@
// @ts-nocheck
import { useHistory } from 'react-router-dom';
import { DashboardInsider } from '@/components';
import { ImportView } from '@/containers/Import';
export default function BillsImport() {
const history = useHistory();
const handleCancelBtnClick = () => {
history.push('/bills');
};
const handleImportSuccess = () => {
history.push('/bills');
};
return (
<DashboardInsider name={'import-bills'}>
<ImportView
resource={'bills'}
onCancelClick={handleCancelBtnClick}
onImportSuccess={handleImportSuccess}
/>
</DashboardInsider>
);
}

View File

@@ -78,6 +78,11 @@ function BillActionsBar({
addSetting('bills', 'tableSize', size); addSetting('bills', 'tableSize', size);
}; };
// Handle the import button click.
const handleImportBtnClick = () => {
history.push('/bills/import');
}
return ( return (
<DashboardActionsBar> <DashboardActionsBar>
<NavbarGroup> <NavbarGroup>
@@ -130,6 +135,7 @@ function BillActionsBar({
className={Classes.MINIMAL} className={Classes.MINIMAL}
icon={<Icon icon={'file-import-16'} />} icon={<Icon icon={'file-import-16'} />}
text={<T id={'import'} />} text={<T id={'import'} />}
onClick={handleImportBtnClick}
/> />
<Button <Button
className={Classes.MINIMAL} className={Classes.MINIMAL}

View File

@@ -51,6 +51,18 @@ export const getDashboardRoutes = () => [
defaultSearchResource: RESOURCES_TYPES.MANUAL_JOURNAL, defaultSearchResource: RESOURCES_TYPES.MANUAL_JOURNAL,
subscriptionActive: [SUBSCRIPTION_TYPE.MAIN], subscriptionActive: [SUBSCRIPTION_TYPE.MAIN],
}, },
{
path: `/manual-journals/import`,
component: lazy(
() => import('@/containers/Accounting/ManualJournalsImport'),
),
breadcrumb: intl.get('edit'),
pageTitle: 'Manual Journals Import',
sidebarExpand: false,
backLink: true,
defaultSearchResource: RESOURCES_TYPES.MANUAL_JOURNAL,
subscriptionActive: [SUBSCRIPTION_TYPE.MAIN],
},
{ {
path: `/manual-journals`, path: `/manual-journals`,
component: lazy( component: lazy(
@@ -893,6 +905,17 @@ export const getDashboardRoutes = () => [
}, },
// Bills // Bills
{
path: `/bills/import`,
component: lazy(() => import('@/containers/Purchases/Bills/BillImport')),
name: 'bill-edit',
// breadcrumb: intl.get('edit'),
pageTitle: 'Bills Import',
sidebarExpand: false,
backLink: true,
defaultSearchResource: RESOURCES_TYPES.BILL,
subscriptionActive: [SUBSCRIPTION_TYPE.MAIN],
},
{ {
path: `/bills/:id/edit`, path: `/bills/:id/edit`,
component: lazy( component: lazy(