• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

gflohr / e-invoice-eu / 14067914634

25 Mar 2025 06:59PM UTC coverage: 68.943%. First build
14067914634

Pull #110

github

web-flow
Merge 128bb09e8 into fbaf9faed
Pull Request #110: Move logic into core library

233 of 371 branches covered (62.8%)

Branch coverage included in aggregate %.

427 of 556 new or added lines in 22 files covered. (76.8%)

726 of 1020 relevant lines covered (71.18%)

72.58 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.35
/packages/core/src/mapping/mapping.service.ts
1
import * as XLSX from '@e965/xlsx';
3✔
2
import Ajv2019, {
3✔
3
        ErrorObject,
4
        JSONSchemaType,
5
        ValidateFunction,
6
        ValidationError,
7
} from 'ajv/dist/2019';
8
import * as jsonpath from 'jsonpath-plus';
3✔
9

10
import { FormatFactoryService } from '../format/format.factory.service';
3✔
11
import { Logger } from '../logger.interface';
12
import { Mapping, MappingMetaInformation } from './mapping.interface';
13
import { ValidationService } from '../validation';
3✔
14
import { mappingSchema } from './mapping.schema';
3✔
15
import { Invoice, invoiceSchema } from '../invoice';
3✔
16
import { mappingValueRe, sectionReferenceRe } from './mapping.regex';
3✔
17

18
type SectionRanges = { [key: string]: { [key: string]: number[] } };
19

20
type MappingContext = {
21
        meta: MappingMetaInformation;
22
        workbook: XLSX.WorkBook;
23
        sectionRanges: SectionRanges;
24
        schemaPath: string[];
25
        arrayPath: Array<[string, number, number]>;
26
        rowRange: [number, number];
27
};
28

29
/**
30
 * Generate an {@link Invoice} object (data in the internal invoice format)
31
 * from spreadsheet data and a {@link Mapping}.
32
 */
33
export class MappingService {
3✔
34
        private readonly validator: ValidateFunction<Mapping>;
35
        private readonly validationService: ValidationService;
36
        private readonly formatFactoryService: FormatFactoryService;
37

38
        /**
39
         * Creates a new instance of the service.
40
         *
41
         * @param logger - The logger instance used for logging messages, warnings and errors.
42
         */
43
        constructor(private readonly logger: Logger) {
66✔
44
                this.formatFactoryService = new FormatFactoryService();
66✔
45
                const ajv = new Ajv2019({
66✔
46
                        strict: true,
47
                        allErrors: true,
48
                        useDefaults: true,
49
                });
50
                this.validator = ajv.compile(mappingSchema);
66✔
51
                this.validationService = new ValidationService(this.logger);
66✔
52
        }
53

54
        private validateMapping(format: string, data: Mapping): Mapping {
55
                const valid = this.validationService.validate(
3✔
56
                        'mapping data',
57
                        this.validator,
58
                        data,
59
                );
60

61
                const formatter = this.formatFactoryService.createFormatService(
3✔
62
                        format,
63
                        this.logger,
64
                );
65
                formatter.fillMappingDefaults(valid);
3✔
66

67
                return valid;
3✔
68
        }
69

70
        /**
71
         * Transform invoice spreadsheet data to invoice data in the internal
72
         * format via a mapping.
73
         *
74
         * @param dataBuffer the spreadsheet data
75
         * @param format one of the supported invoice formats, see {@link FormatFactoryService.listFormatServices}
76
         * @param mapping the mapping definition
77
         * @returns the invoice data in the internal format
78
         */
79
        transform(dataBuffer: Buffer, format: string, mapping: Mapping): Invoice {
80
                mapping = this.validateMapping(format, mapping);
18✔
81
                const workbook = XLSX.read(dataBuffer, {
18✔
82
                        type: 'buffer',
83
                        cellDates: true,
84
                });
85

86
                const invoice: { [key: string]: any } = { 'ubl:Invoice': {} };
18✔
87

88
                const ctx: MappingContext = {
18✔
89
                        meta: mapping.meta,
90
                        workbook,
91
                        sectionRanges: {},
92
                        schemaPath: ['properties', 'ubl:Invoice'],
93
                        arrayPath: [],
94
                        rowRange: [1, Infinity],
95
                };
96
                this.fillSectionRanges(mapping, workbook, ctx);
18✔
97

98
                this.transformObject(invoice['ubl:Invoice'], mapping['ubl:Invoice'], ctx);
18✔
99

100
                this.cleanAttributes(invoice);
6✔
101

102
                return invoice as unknown as Invoice;
6✔
103
        }
104

105
        private cleanAttributes(data: { [key: string]: any }) {
106
                for (const property in data) {
54✔
107
                        const [elem, attr] = property.split('@', 2);
99✔
108

109
                        // Remove hard-coded attributes if the associated element is
110
                        // missing.
111
                        if (typeof attr !== 'undefined') {
99✔
112
                                if (!(elem in data)) {
9!
NEW
113
                                        delete data[property];
×
NEW
114
                                        continue;
×
115
                                }
116
                        }
117

118
                        if (typeof data[property] === 'object') {
99✔
119
                                this.cleanAttributes(data[property]);
48✔
120
                        }
121
                }
122
        }
123

124
        private transformObject(
125
                target: { [key: string]: any },
126
                mapping: { [key: string]: any },
127
                ctx: MappingContext,
128
        ) {
129
                for (const property in mapping) {
84✔
130
                        if (property === 'section') {
237✔
131
                                continue;
21✔
132
                        }
133
                        ctx.schemaPath.push('properties', property);
216✔
134
                        const schema = this.getSchema(ctx.schemaPath);
216✔
135
                        if (typeof mapping[property] === 'string') {
216✔
136
                                const value = this.resolveValue(mapping[property], schema, ctx);
150✔
137
                                if (value !== '') {
144✔
138
                                        target[property] = value;
144✔
139
                                }
140
                        } else if (schema.type === 'array') {
66✔
141
                                if ('section' in mapping[property]) {
36✔
142
                                        target[property] = [];
21✔
143
                                        this.transformArray(target[property], mapping[property], ctx);
21✔
144
                                } else {
145
                                        target[property] = [{}];
15✔
146
                                        ctx.schemaPath.push('items');
15✔
147
                                        this.transformObject(target[property][0], mapping[property], ctx);
15✔
148
                                        ctx.schemaPath.pop();
15✔
149
                                }
150
                        } else {
151
                                target[property] = {};
30✔
152
                                this.transformObject(target[property], mapping[property], ctx);
30✔
153
                        }
154
                        ctx.schemaPath.pop();
198✔
155
                        ctx.schemaPath.pop();
198✔
156
                }
157
        }
158

159
        private transformArray(
160
                target: Array<any>,
161
                mapping: { [key: string]: any },
162
                ctx: MappingContext,
163
        ) {
164
                const sectionRef = mapping.section;
21✔
165

166
                const matches = sectionRef.match(sectionReferenceRe);
21✔
167
                const sheetName =
168
                        typeof matches[1] === 'undefined'
21✔
169
                                ? ctx.workbook.SheetNames[0]
170
                                : matches[1];
171
                const section = matches[2];
21✔
172

173
                try {
21✔
174
                        if (!(sheetName in ctx.sectionRanges)) {
21✔
175
                                throw new Error(`no section column for sheet '${sheetName}'`);
3✔
176
                        }
177
                        if (!(section in ctx.sectionRanges[sheetName])) {
18✔
178
                                throw new Error(`no section '${section}' in sheet '${sheetName}'`);
3✔
179
                        }
180
                } catch (e) {
181
                        const message =
182
                                `section reference '${sectionRef}' resolves to null: ` + e.message;
6✔
183

184
                        ctx.schemaPath.push('properties');
6✔
185
                        ctx.schemaPath.push('section');
6✔
186
                        throw this.makeValidationError(message, ctx);
6✔
187
                }
188

189
                ctx.schemaPath.push('items');
15✔
190

191
                const sectionRanges = ctx.sectionRanges[sheetName][section];
15✔
192
                const upperBound = sectionRanges[sectionRanges.length - 1];
15✔
193
                if (upperBound < ctx.rowRange[1]) {
15✔
194
                        // If this is a top-level section, the upper bound of the row range
195
                        // is plus infinity!
196
                        ctx.rowRange[1] = upperBound;
6✔
197
                }
198

199
                const sectionIndices = this.computeSectionIndices(sheetName, section, ctx);
15✔
200
                const arrayPathIndex = ctx.arrayPath.length;
15✔
201
                ctx.arrayPath[arrayPathIndex] = [section, -1, -1];
15✔
202

203
                const savedRowRange = ctx.rowRange;
15✔
204
                for (let i = 0; i < sectionIndices.length; ++i) {
15✔
205
                        const startIndex = sectionIndices[i];
21✔
206
                        const start = ctx.sectionRanges[sheetName][section][startIndex];
21✔
207
                        const end = ctx.sectionRanges[sheetName][section][startIndex + 1];
21✔
208
                        ctx.rowRange = [start, end];
21✔
209
                        target[i] = {};
21✔
210
                        ctx.arrayPath[arrayPathIndex][1] = startIndex;
21✔
211
                        ctx.arrayPath[arrayPathIndex][2] = i;
21✔
212
                        this.transformObject(target[i], mapping, ctx);
21✔
213
                }
214
                ctx.rowRange = savedRowRange;
12✔
215

216
                ctx.arrayPath.pop();
12✔
217
                ctx.schemaPath.pop();
12✔
218
        }
219

220
        private makeValidationError(
221
                message: string,
222
                ctx: MappingContext,
223
        ): ValidationError {
224
                const instancePath = this.getInstancePath(ctx);
15✔
225
                const error: ErrorObject = {
15✔
226
                        instancePath,
227
                        schemaPath: '#/' + ctx.schemaPath.map(encodeURIComponent).join('/'),
228
                        keyword: 'type',
229
                        params: { type: 'string' },
230
                        message,
231
                };
232

233
                return new ValidationError([error]);
15✔
234
        }
235

236
        private computeSectionIndices(
237
                sheetName: string,
238
                section: string,
239
                ctx: MappingContext,
240
        ): number[] {
241
                const result: number[] = [];
15✔
242

243
                for (let i = 0; i < ctx.sectionRanges[sheetName][section].length; ++i) {
15✔
244
                        const row = ctx.sectionRanges[sheetName][section][i];
48✔
245
                        if (row < ctx.rowRange[0]) {
48✔
246
                                continue;
6✔
247
                        } else if (row >= ctx.rowRange[1]) {
42✔
248
                                break;
15✔
249
                        }
250
                        result.push(i as unknown as number);
27✔
251
                }
252

253
                return result;
15✔
254
        }
255

256
        private unquoteSheetName(name: string): string | undefined {
257
                if (typeof name === 'undefined') {
117✔
258
                        return undefined;
99✔
259
                } else if (name.match(/^'.*'$/)) {
18✔
260
                        return name.substring(1, name.length - 1);
15✔
261
                } else {
262
                        return name;
3✔
263
                }
264
        }
265

266
        private resolveValue(
267
                ref: string,
268
                schema: JSONSchemaType<any>,
269
                ctx: MappingContext,
270
        ): string {
271
                const matches = ref.match(mappingValueRe) as RegExpMatchArray;
162✔
272
                if (typeof matches[4] !== 'undefined') {
162✔
273
                        return this.unquoteLiteral(matches[4]);
45✔
274
                }
275

276
                const sheetMatch = this.unquoteSheetName(matches[1]);
117✔
277
                const section = matches[2];
117✔
278
                let cellName = matches[3];
117✔
279

280
                const sheetName =
281
                        typeof sheetMatch === 'undefined'
117✔
282
                                ? ctx.workbook.SheetNames[0]
283
                                : sheetMatch;
284

285
                try {
117✔
286
                        if (typeof section !== 'undefined') {
117✔
287
                                const match = cellName.match(/^([A-Z]+)(\d+)$/) as RegExpMatchArray;
45✔
288
                                const letters = match[1];
45✔
289
                                const offset = this.getOffset(sheetName, section, ctx);
45✔
290
                                const number = offset + parseInt(match[2], 10) - 1;
39✔
291

292
                                cellName = letters + number;
39✔
293
                        }
294

295
                        const worksheet = ctx.workbook.Sheets[sheetName];
111✔
296

297
                        if (typeof worksheet === 'undefined') {
111✔
298
                                throw new Error(`no such sheet '${sheetName}'`);
3✔
299
                        }
300

301
                        const value = this.getCellValue(worksheet, cellName, schema);
108✔
302
                        if (ctx.meta.empty && ctx.meta.empty.includes(value)) {
108✔
303
                                return '';
3✔
304
                        }
305

306
                        return value;
105✔
307
                } catch (x) {
308
                        const message = `reference '${ref}' resolves to null: ${x.message}`;
9✔
309
                        throw this.makeValidationError(message, ctx);
9✔
310
                }
311
        }
312

313
        private unquoteLiteral(literal: string): string {
314
                return literal[0] === "'" ? literal.substring(1) : literal;
45✔
315
        }
316

317
        private getCellValue(
318
                worksheet: XLSX.WorkSheet,
319
                cellName: string,
320
                schema: JSONSchemaType<any>,
321
        ): string {
322
                if (!(cellName in worksheet)) {
108✔
323
                        return '';
3✔
324
                }
325

326
                const cell = worksheet[cellName];
105✔
327

328
                const $ref = schema.$ref;
105✔
329

330
                let value: string;
331
                switch ($ref) {
105✔
332
                        case '#/$defs/dataTypes/Date':
333
                                if (cell.t === 'd') {
30✔
334
                                        value = this.getDateValue(cell.v as Date);
15✔
335
                                } else {
336
                                        value = cell.v;
15✔
337
                                }
338
                                break;
30✔
339
                        default:
340
                                value = cell.v.toString();
75✔
341
                }
342

343
                return value;
105✔
344
        }
345

346
        private getDateValue(value: Date): string {
347
                return value.toISOString().substring(0, 10);
15✔
348
        }
349

350
        private getSchema(path: string[]): JSONSchemaType<any> {
351
                const jsonPath = ['$', ...path].join('.');
216✔
352

353
                return jsonpath.JSONPath({
216✔
354
                        path: jsonPath,
355
                        json: invoiceSchema,
356
                })[0] as JSONSchemaType<any>;
357
        }
358

359
        private fillSectionRanges(
360
                mapping: Mapping,
361
                workbook: XLSX.WorkBook,
362
                ctx: MappingContext,
363
        ) {
364
                for (const sheetName in mapping.meta.sectionColumn) {
15✔
365
                        if (!(sheetName in workbook.Sheets)) {
30✔
366
                                continue;
15✔
367
                        }
368

369
                        const column = mapping.meta.sectionColumn[sheetName] as string;
15✔
370
                        const sheet = workbook.Sheets[sheetName];
15✔
371
                        const range = XLSX.utils.decode_range(sheet['!ref'] as string);
15✔
372

373
                        ctx.sectionRanges[sheetName] = {};
15✔
374

375
                        for (let row = range.s.r; row <= range.e.r; ++row) {
15✔
376
                                const cellAddress = `${column}${row + 1}`;
14,985✔
377
                                const cell = sheet[cellAddress];
14,985✔
378
                                if (cell) {
14,985✔
379
                                        ctx.sectionRanges[sheetName][cell.v] ??= [];
108✔
380
                                        ctx.sectionRanges[sheetName][cell.v].push(row + 1);
108✔
381
                                }
382
                        }
383

384
                        for (const section in ctx.sectionRanges[sheetName]) {
15✔
385
                                // The last section could be in the last row of the sheet.
386
                                // Because `range.e.r` is zero-based but our row numbers are
387
                                // one-based, we have to add 2.
388
                                ctx.sectionRanges[sheetName][section].push(range.e.r + 2);
45✔
389
                        }
390
                }
391
        }
392

393
        private getOffset(
394
                sheetName: string,
395
                cellSection: string,
396
                ctx: MappingContext,
397
        ): number {
398
                for (const pathInfo of ctx.arrayPath) {
45✔
399
                        const section = pathInfo[0];
39✔
400
                        const index = pathInfo[1];
39✔
401

402
                        if (section === cellSection) {
39✔
403
                                return ctx.sectionRanges[sheetName][section][index];
27✔
404
                        }
405
                }
406

407
                if (!(cellSection in ctx.sectionRanges[sheetName])) {
18✔
408
                        throw new Error(
3✔
409
                                `cannot find section '${cellSection}' in sheet '${sheetName}'`,
410
                        );
411
                }
412

413
                const rows = ctx.sectionRanges[sheetName][cellSection];
15✔
414
                if (rows.length !== 2) {
15✔
415
                        throw new Error(
3✔
416
                                `multiple unbound sections '${cellSection}' in sheet '${sheetName}'`,
417
                        );
418
                }
419

420
                return rows[0];
12✔
421
        }
422

423
        private getInstancePath(ctx: MappingContext): string {
424
                const instancePath = [''];
15✔
425
                let arrayLevel = -1;
15✔
426
                ctx.schemaPath
15✔
427
                        .filter(item => item !== 'properties')
87✔
428
                        .forEach(item => {
429
                                if (item === 'items') {
45✔
430
                                        instancePath.push(ctx.arrayPath[++arrayLevel][2].toString());
3✔
431
                                } else {
432
                                        instancePath.push(item);
42✔
433
                                }
434
                        });
435

436
                return instancePath.join('/');
15✔
437
        }
438
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc