mirror of
https://github.com/bigcapitalhq/bigcapital.git
synced 2026-02-19 06:10:31 +00:00
fix: import rows aggregator
This commit is contained in:
@@ -167,6 +167,9 @@
|
|||||||
"**/*.(t|j)s"
|
"**/*.(t|j)s"
|
||||||
],
|
],
|
||||||
"coverageDirectory": "../coverage",
|
"coverageDirectory": "../coverage",
|
||||||
"testEnvironment": "node"
|
"testEnvironment": "node",
|
||||||
|
"moduleNameMapper": {
|
||||||
|
"^@/(.*)$": "<rootDir>/$1"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,9 +55,8 @@ export class ImportFileDataTransformer {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Aggregates parsed data based on resource metadata configuration.
|
* Aggregates parsed data based on resource metadata configuration.
|
||||||
* @param {number} tenantId
|
* @param {string} resourceName - The resource name.
|
||||||
* @param {string} resourceName
|
* @param {Record<string, any>} parsedData - The parsed data to aggregate.
|
||||||
* @param {Record<string, any>} parsedData
|
|
||||||
* @returns {Record<string, any>[]}
|
* @returns {Record<string, any>[]}
|
||||||
*/
|
*/
|
||||||
public aggregateParsedValues(
|
public aggregateParsedValues(
|
||||||
@@ -110,8 +109,11 @@ export class ImportFileDataTransformer {
|
|||||||
valueDTOs: Record<string, any>[],
|
valueDTOs: Record<string, any>[],
|
||||||
trx?: Knex.Transaction
|
trx?: Knex.Transaction
|
||||||
): Promise<Record<string, any>[]> {
|
): Promise<Record<string, any>[]> {
|
||||||
// const tenantModels = this.tenancy.models(tenantId);
|
// Create a model resolver function that uses ResourceService
|
||||||
const _valueParser = valueParser(fields, {}, trx);
|
const modelResolver = (modelName: string) => {
|
||||||
|
return this.resource.getResourceModel(modelName)();
|
||||||
|
};
|
||||||
|
const _valueParser = valueParser(fields, modelResolver, trx);
|
||||||
const _keyParser = parseKey(fields);
|
const _keyParser = parseKey(fields);
|
||||||
|
|
||||||
const parseAsync = async (valueDTO) => {
|
const parseAsync = async (valueDTO) => {
|
||||||
|
|||||||
287
packages/server/src/modules/Import/_utils.spec.ts
Normal file
287
packages/server/src/modules/Import/_utils.spec.ts
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
import { aggregate } from './_utils';
|
||||||
|
|
||||||
|
describe('aggregate', () => {
|
||||||
|
describe('basic aggregation', () => {
|
||||||
|
it('should aggregate entries with matching comparator attribute', () => {
|
||||||
|
const input = [
|
||||||
|
{ id: 1, name: 'John', entries: ['entry1'] },
|
||||||
|
{ id: 2, name: 'Jane', entries: ['entry2'] },
|
||||||
|
{ id: 1, name: 'John', entries: ['entry3'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = aggregate(input, 'id', 'entries');
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0]).toEqual({
|
||||||
|
id: 1,
|
||||||
|
name: 'John',
|
||||||
|
entries: ['entry1', 'entry3'],
|
||||||
|
});
|
||||||
|
expect(result[1]).toEqual({
|
||||||
|
id: 2,
|
||||||
|
name: 'Jane',
|
||||||
|
entries: ['entry2'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve order of first occurrence', () => {
|
||||||
|
const input = [
|
||||||
|
{ id: 2, entries: ['a'] },
|
||||||
|
{ id: 1, entries: ['b'] },
|
||||||
|
{ id: 2, entries: ['c'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = aggregate(input, 'id', 'entries');
|
||||||
|
|
||||||
|
expect(result[0].id).toBe(2);
|
||||||
|
expect(result[1].id).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('no matching entries', () => {
|
||||||
|
it('should return all entries unchanged when no comparator matches', () => {
|
||||||
|
const input = [
|
||||||
|
{ id: 1, name: 'John', entries: ['entry1'] },
|
||||||
|
{ id: 2, name: 'Jane', entries: ['entry2'] },
|
||||||
|
{ id: 3, name: 'Bob', entries: ['entry3'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = aggregate(input, 'id', 'entries');
|
||||||
|
|
||||||
|
expect(result).toHaveLength(3);
|
||||||
|
expect(result).toEqual(input);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('should return empty array when input is empty', () => {
|
||||||
|
const result = aggregate([], 'id', 'entries');
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
expect(result).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return single entry unchanged when input has one item', () => {
|
||||||
|
const input = [{ id: 1, name: 'John', entries: ['entry1'] }];
|
||||||
|
|
||||||
|
const result = aggregate(input, 'id', 'entries');
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]).toEqual({
|
||||||
|
id: 1,
|
||||||
|
name: 'John',
|
||||||
|
entries: ['entry1'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple entries with same comparator value', () => {
|
||||||
|
const input = [
|
||||||
|
{ id: 1, entries: ['a'] },
|
||||||
|
{ id: 1, entries: ['b'] },
|
||||||
|
{ id: 1, entries: ['c'] },
|
||||||
|
{ id: 1, entries: ['d'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = aggregate(input, 'id', 'entries');
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].entries).toEqual(['a', 'b', 'c', 'd']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('different comparator attributes', () => {
|
||||||
|
it('should work with string comparator attribute', () => {
|
||||||
|
const input = [
|
||||||
|
{ name: 'Product A', category: 'Electronics', entries: ['item1'] },
|
||||||
|
{ name: 'Product B', category: 'Books', entries: ['item2'] },
|
||||||
|
{ name: 'Product C', category: 'Electronics', entries: ['item3'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = aggregate(input, 'category', 'entries');
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0]).toEqual({
|
||||||
|
name: 'Product A',
|
||||||
|
category: 'Electronics',
|
||||||
|
entries: ['item1', 'item3'],
|
||||||
|
});
|
||||||
|
expect(result[1]).toEqual({
|
||||||
|
name: 'Product B',
|
||||||
|
category: 'Books',
|
||||||
|
entries: ['item2'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not aggregate items with undefined comparator values', () => {
|
||||||
|
const input = [
|
||||||
|
{ id: undefined, entries: ['a'] },
|
||||||
|
{ id: 1, entries: ['b'] },
|
||||||
|
{ id: undefined, entries: ['c'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = aggregate(input, 'id', 'entries');
|
||||||
|
|
||||||
|
expect(result).toHaveLength(3);
|
||||||
|
// Items with undefined id are NOT aggregated - each remains separate
|
||||||
|
expect(result[0].entries).toEqual(['a']);
|
||||||
|
expect(result[1].entries).toEqual(['b']);
|
||||||
|
expect(result[2].entries).toEqual(['c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle null comparator values separately', () => {
|
||||||
|
const input = [
|
||||||
|
{ id: null, entries: ['a'] },
|
||||||
|
{ id: 1, entries: ['b'] },
|
||||||
|
{ id: null, entries: ['c'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = aggregate(input, 'id', 'entries');
|
||||||
|
|
||||||
|
expect(result).toHaveLength(3);
|
||||||
|
expect(result[0].entries).toEqual(['a']);
|
||||||
|
expect(result[1].entries).toEqual(['b']);
|
||||||
|
expect(result[2].entries).toEqual(['c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not aggregate items missing the comparatorAttr property', () => {
|
||||||
|
const input = [
|
||||||
|
{ id: 1, entries: ['a'] },
|
||||||
|
{ name: 'No ID', entries: ['b'] }, // missing 'id' property
|
||||||
|
{ id: 1, entries: ['c'] },
|
||||||
|
{ entries: ['d'] }, // also missing 'id' property
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = aggregate(input, 'id', 'entries');
|
||||||
|
|
||||||
|
// 3 entries: aggregated id:1, and two separate items without 'id' property
|
||||||
|
expect(result).toHaveLength(3);
|
||||||
|
// Items with id: 1 are aggregated
|
||||||
|
expect(result[0]).toEqual({ id: 1, entries: ['a', 'c'] });
|
||||||
|
// Items missing 'id' are NOT aggregated - each remains separate
|
||||||
|
expect(result[1]).toEqual({ name: 'No ID', entries: ['b'] });
|
||||||
|
expect(result[2]).toEqual({ entries: ['d'] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('different group attributes', () => {
|
||||||
|
it('should work with different groupOn attribute name', () => {
|
||||||
|
const input = [
|
||||||
|
{ id: 1, items: ['item1'] },
|
||||||
|
{ id: 1, items: ['item2'] },
|
||||||
|
{ id: 2, items: ['item3'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = aggregate(input, 'id', 'items');
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0].items).toEqual(['item1', 'item2']);
|
||||||
|
expect(result[1].items).toEqual(['item3']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('complex entries', () => {
|
||||||
|
it('should aggregate entries containing objects', () => {
|
||||||
|
const input = [
|
||||||
|
{ invoiceId: 'INV-001', entries: [{ itemId: 1, quantity: 2 }] },
|
||||||
|
{ invoiceId: 'INV-002', entries: [{ itemId: 2, quantity: 1 }] },
|
||||||
|
{ invoiceId: 'INV-001', entries: [{ itemId: 3, quantity: 5 }] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = aggregate(input, 'invoiceId', 'entries');
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0].entries).toEqual([
|
||||||
|
{ itemId: 1, quantity: 2 },
|
||||||
|
{ itemId: 3, quantity: 5 },
|
||||||
|
]);
|
||||||
|
expect(result[1].entries).toEqual([{ itemId: 2, quantity: 1 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should aggregate entries with multiple items in each entry', () => {
|
||||||
|
const input = [
|
||||||
|
{ id: 1, entries: ['a', 'b'] },
|
||||||
|
{ id: 1, entries: ['c', 'd'] },
|
||||||
|
{ id: 2, entries: ['e'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = aggregate(input, 'id', 'entries');
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0].entries).toEqual(['a', 'b', 'c', 'd']);
|
||||||
|
expect(result[1].entries).toEqual(['e']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('numeric comparator values', () => {
|
||||||
|
it('should correctly compare numeric values', () => {
|
||||||
|
const input = [
|
||||||
|
{ id: 1, entries: ['a'] },
|
||||||
|
{ id: 2, entries: ['b'] },
|
||||||
|
{ id: 1, entries: ['c'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = aggregate(input, 'id', 'entries');
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result.find((r) => r.id === 1).entries).toEqual(['a', 'c']);
|
||||||
|
expect(result.find((r) => r.id === 2).entries).toEqual(['b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should treat 0 as a valid comparator value', () => {
|
||||||
|
const input = [
|
||||||
|
{ id: 0, entries: ['a'] },
|
||||||
|
{ id: 1, entries: ['b'] },
|
||||||
|
{ id: 0, entries: ['c'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = aggregate(input, 'id', 'entries');
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0].entries).toEqual(['a', 'c']);
|
||||||
|
expect(result[1].entries).toEqual(['b']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('preserving other properties', () => {
|
||||||
|
it('should preserve all properties from the first matching entry', () => {
|
||||||
|
const input = [
|
||||||
|
{ id: 1, name: 'First', extra: 'data1', entries: ['a'] },
|
||||||
|
{ id: 1, name: 'Second', extra: 'data2', entries: ['b'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = aggregate(input, 'id', 'entries');
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].name).toBe('First');
|
||||||
|
expect(result[0].extra).toBe('data1');
|
||||||
|
expect(result[0].entries).toEqual(['a', 'b']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('empty entries arrays', () => {
|
||||||
|
it('should handle empty entries arrays', () => {
|
||||||
|
const input = [
|
||||||
|
{ id: 1, entries: [] },
|
||||||
|
{ id: 1, entries: ['a'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = aggregate(input, 'id', 'entries');
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].entries).toEqual(['a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle all empty entries arrays', () => {
|
||||||
|
const input = [
|
||||||
|
{ id: 1, entries: [] },
|
||||||
|
{ id: 1, entries: [] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = aggregate(input, 'id', 'entries');
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].entries).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -284,9 +284,11 @@ export const getResourceColumns = (resourceColumns: {
|
|||||||
return R.compose(transformInputToGroupedFields, mapColumns)(resourceColumns);
|
return R.compose(transformInputToGroupedFields, mapColumns)(resourceColumns);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ModelResolver = (modelName: string) => any;
|
||||||
|
|
||||||
// Prases the given object value based on the field key type.
|
// Prases the given object value based on the field key type.
|
||||||
export const valueParser =
|
export const valueParser =
|
||||||
(fields: ResourceMetaFieldsMap, tenantModels: any, trx?: Knex.Transaction) =>
|
(fields: ResourceMetaFieldsMap, modelResolver: ModelResolver, trx?: Knex.Transaction) =>
|
||||||
async (value: any, key: string, group = '') => {
|
async (value: any, key: string, group = '') => {
|
||||||
let _value = value;
|
let _value = value;
|
||||||
|
|
||||||
@@ -308,7 +310,7 @@ export const valueParser =
|
|||||||
_value = multiNumberParse(value);
|
_value = multiNumberParse(value);
|
||||||
// Parses the relation value.
|
// Parses the relation value.
|
||||||
} else if (field.fieldType === 'relation') {
|
} else if (field.fieldType === 'relation') {
|
||||||
const RelationModel = tenantModels[field.relationModel];
|
const RelationModel = modelResolver(field.relationModel);
|
||||||
|
|
||||||
if (!RelationModel) {
|
if (!RelationModel) {
|
||||||
throw new Error(`The relation model of ${key} field is not exist.`);
|
throw new Error(`The relation model of ${key} field is not exist.`);
|
||||||
@@ -325,7 +327,7 @@ export const valueParser =
|
|||||||
_value = get(result, 'id');
|
_value = get(result, 'id');
|
||||||
} else if (field.fieldType === 'collection') {
|
} else if (field.fieldType === 'collection') {
|
||||||
const ObjectFieldKey = key.includes('.') ? key.split('.')[1] : key;
|
const ObjectFieldKey = key.includes('.') ? key.split('.')[1] : key;
|
||||||
const _valueParser = valueParser(fields, tenantModels);
|
const _valueParser = valueParser(fields, modelResolver);
|
||||||
_value = await _valueParser(value, ObjectFieldKey, fieldKey);
|
_value = await _valueParser(value, ObjectFieldKey, fieldKey);
|
||||||
}
|
}
|
||||||
return _value;
|
return _value;
|
||||||
@@ -402,12 +404,17 @@ export function aggregate(
|
|||||||
groupOn: string,
|
groupOn: string,
|
||||||
): Array<Record<string, any>> {
|
): Array<Record<string, any>> {
|
||||||
return input.reduce((acc, curr) => {
|
return input.reduce((acc, curr) => {
|
||||||
|
// Skip aggregation if the current item doesn't have the comparator attribute
|
||||||
|
if (curr[comparatorAttr] === undefined || curr[comparatorAttr] === null) {
|
||||||
|
acc.push({ ...curr });
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
const existingEntry = acc.find(
|
const existingEntry = acc.find(
|
||||||
(entry) => entry[comparatorAttr] === curr[comparatorAttr],
|
(entry) => entry[comparatorAttr] === curr[comparatorAttr],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (existingEntry) {
|
if (existingEntry) {
|
||||||
existingEntry[groupOn].push(...curr.entries);
|
existingEntry[groupOn].push(...curr[groupOn]);
|
||||||
} else {
|
} else {
|
||||||
acc.push({ ...curr });
|
acc.push({ ...curr });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user