mirror of
https://github.com/bigcapitalhq/bigcapital.git
synced 2026-02-13 19:30:30 +00:00
fix: import rows aggregator
This commit is contained in:
@@ -55,9 +55,8 @@ export class ImportFileDataTransformer {
|
||||
|
||||
/**
|
||||
* Aggregates parsed data based on resource metadata configuration.
|
||||
* @param {number} tenantId
|
||||
* @param {string} resourceName
|
||||
* @param {Record<string, any>} parsedData
|
||||
* @param {string} resourceName - The resource name.
|
||||
* @param {Record<string, any>} parsedData - The parsed data to aggregate.
|
||||
* @returns {Record<string, any>[]}
|
||||
*/
|
||||
public aggregateParsedValues(
|
||||
@@ -110,8 +109,11 @@ export class ImportFileDataTransformer {
|
||||
valueDTOs: Record<string, any>[],
|
||||
trx?: Knex.Transaction
|
||||
): Promise<Record<string, any>[]> {
|
||||
// const tenantModels = this.tenancy.models(tenantId);
|
||||
const _valueParser = valueParser(fields, {}, trx);
|
||||
// Create a model resolver function that uses ResourceService
|
||||
const modelResolver = (modelName: string) => {
|
||||
return this.resource.getResourceModel(modelName)();
|
||||
};
|
||||
const _valueParser = valueParser(fields, modelResolver, trx);
|
||||
const _keyParser = parseKey(fields);
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
export type ModelResolver = (modelName: string) => any;
|
||||
|
||||
// Prases the given object value based on the field key type.
|
||||
export const valueParser =
|
||||
(fields: ResourceMetaFieldsMap, tenantModels: any, trx?: Knex.Transaction) =>
|
||||
(fields: ResourceMetaFieldsMap, modelResolver: ModelResolver, trx?: Knex.Transaction) =>
|
||||
async (value: any, key: string, group = '') => {
|
||||
let _value = value;
|
||||
|
||||
@@ -308,7 +310,7 @@ export const valueParser =
|
||||
_value = multiNumberParse(value);
|
||||
// Parses the relation value.
|
||||
} else if (field.fieldType === 'relation') {
|
||||
const RelationModel = tenantModels[field.relationModel];
|
||||
const RelationModel = modelResolver(field.relationModel);
|
||||
|
||||
if (!RelationModel) {
|
||||
throw new Error(`The relation model of ${key} field is not exist.`);
|
||||
@@ -325,7 +327,7 @@ export const valueParser =
|
||||
_value = get(result, 'id');
|
||||
} else if (field.fieldType === 'collection') {
|
||||
const ObjectFieldKey = key.includes('.') ? key.split('.')[1] : key;
|
||||
const _valueParser = valueParser(fields, tenantModels);
|
||||
const _valueParser = valueParser(fields, modelResolver);
|
||||
_value = await _valueParser(value, ObjectFieldKey, fieldKey);
|
||||
}
|
||||
return _value;
|
||||
@@ -402,12 +404,17 @@ export function aggregate(
|
||||
groupOn: string,
|
||||
): Array<Record<string, any>> {
|
||||
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(
|
||||
(entry) => entry[comparatorAttr] === curr[comparatorAttr],
|
||||
);
|
||||
|
||||
if (existingEntry) {
|
||||
existingEntry[groupOn].push(...curr.entries);
|
||||
existingEntry[groupOn].push(...curr[groupOn]);
|
||||
} else {
|
||||
acc.push({ ...curr });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user