@fin.cx/skr 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist_ts/index.d.ts +6 -0
- package/dist_ts/index.js +7 -1
- package/dist_ts/plugins.d.ts +8 -1
- package/dist_ts/plugins.js +10 -2
- package/dist_ts/skr.api.d.ts +70 -0
- package/dist_ts/skr.api.js +348 -1
- package/dist_ts/skr.export.accounts.d.ts +53 -0
- package/dist_ts/skr.export.accounts.js +111 -0
- package/dist_ts/skr.export.balances.d.ts +59 -0
- package/dist_ts/skr.export.balances.js +205 -0
- package/dist_ts/skr.export.d.ts +110 -0
- package/dist_ts/skr.export.js +315 -0
- package/dist_ts/skr.export.ledger.d.ts +95 -0
- package/dist_ts/skr.export.ledger.js +164 -0
- package/dist_ts/skr.export.pdf.d.ts +82 -0
- package/dist_ts/skr.export.pdf.js +548 -0
- package/dist_ts/skr.invoice.adapter.d.ts +98 -0
- package/dist_ts/skr.invoice.adapter.js +476 -0
- package/dist_ts/skr.invoice.booking.d.ts +102 -0
- package/dist_ts/skr.invoice.booking.js +556 -0
- package/dist_ts/skr.invoice.entity.d.ts +287 -0
- package/dist_ts/skr.invoice.entity.js +2 -0
- package/dist_ts/skr.invoice.mapper.d.ts +69 -0
- package/dist_ts/skr.invoice.mapper.js +401 -0
- package/dist_ts/skr.invoice.storage.d.ts +140 -0
- package/dist_ts/skr.invoice.storage.js +529 -0
- package/dist_ts/skr.security.d.ts +65 -0
- package/dist_ts/skr.security.js +319 -0
- package/dist_ts/skr.types.d.ts +1 -0
- package/package.json +17 -12
- package/readme.md +207 -16
- package/ts/index.ts +6 -0
- package/ts/plugins.ts +22 -1
- package/ts/skr.api.ts +485 -0
- package/ts/skr.export.accounts.ts +154 -0
- package/ts/skr.export.balances.ts +270 -0
- package/ts/skr.export.ledger.ts +249 -0
- package/ts/skr.export.pdf.ts +601 -0
- package/ts/skr.export.ts +443 -0
- package/ts/skr.invoice.adapter.ts +581 -0
- package/ts/skr.invoice.booking.ts +738 -0
- package/ts/skr.invoice.entity.ts +351 -0
- package/ts/skr.invoice.mapper.ts +486 -0
- package/ts/skr.invoice.storage.ts +710 -0
- package/ts/skr.security.ts +405 -0
- package/ts/skr.types.ts +1 -0
|
@@ -0,0 +1,710 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import type {
|
|
4
|
+
IInvoice,
|
|
5
|
+
IInvoiceFilter,
|
|
6
|
+
IDuplicateCheckResult
|
|
7
|
+
} from './skr.invoice.entity.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Invoice storage metadata
|
|
11
|
+
*/
|
|
12
|
+
export interface IInvoiceMetadata {
|
|
13
|
+
invoiceId: string;
|
|
14
|
+
invoiceNumber: string;
|
|
15
|
+
direction: 'inbound' | 'outbound';
|
|
16
|
+
issueDate: string;
|
|
17
|
+
supplierName: string;
|
|
18
|
+
customerName: string;
|
|
19
|
+
totalAmount: number;
|
|
20
|
+
currency: string;
|
|
21
|
+
contentHash: string;
|
|
22
|
+
pdfHash?: string;
|
|
23
|
+
xmlHash: string;
|
|
24
|
+
journalEntryId?: string;
|
|
25
|
+
transactionIds?: string[];
|
|
26
|
+
validationResult: {
|
|
27
|
+
isValid: boolean;
|
|
28
|
+
errors: number;
|
|
29
|
+
warnings: number;
|
|
30
|
+
};
|
|
31
|
+
parserVersion: string;
|
|
32
|
+
storedAt: string;
|
|
33
|
+
storedBy: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Invoice registry entry (for NDJSON streaming)
|
|
38
|
+
*/
|
|
39
|
+
export interface IInvoiceRegistryEntry {
|
|
40
|
+
id: string;
|
|
41
|
+
hash: string;
|
|
42
|
+
metadata: IInvoiceMetadata;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Storage statistics
|
|
47
|
+
*/
|
|
48
|
+
export interface IStorageStats {
|
|
49
|
+
totalInvoices: number;
|
|
50
|
+
inboundCount: number;
|
|
51
|
+
outboundCount: number;
|
|
52
|
+
totalSize: number;
|
|
53
|
+
duplicatesDetected: number;
|
|
54
|
+
lastUpdate: Date;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Content-addressed storage for invoices
|
|
59
|
+
* Integrates with BagIt archive structure for GoBD compliance
|
|
60
|
+
*/
|
|
61
|
+
export class InvoiceStorage {
|
|
62
|
+
private exportPath: string;
|
|
63
|
+
private logger: plugins.smartlog.ConsoleLog;
|
|
64
|
+
private registryPath: string;
|
|
65
|
+
private metadataCache: Map<string, IInvoiceMetadata>;
|
|
66
|
+
private readonly MAX_CACHE_SIZE = 10000; // Maximum number of cached entries
|
|
67
|
+
private cacheAccessOrder: string[] = []; // Track access order for LRU eviction
|
|
68
|
+
|
|
69
|
+
constructor(exportPath: string) {
|
|
70
|
+
this.exportPath = exportPath;
|
|
71
|
+
this.logger = new plugins.smartlog.ConsoleLog();
|
|
72
|
+
this.registryPath = path.join(exportPath, 'data', 'documents', 'invoices', 'registry.ndjson');
|
|
73
|
+
this.metadataCache = new Map();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Manage cache size using LRU eviction
|
|
78
|
+
*/
|
|
79
|
+
private manageCacheSize(): void {
|
|
80
|
+
if (this.metadataCache.size > this.MAX_CACHE_SIZE) {
|
|
81
|
+
// Remove least recently used entries
|
|
82
|
+
const entriesToRemove = Math.min(100, Math.floor(this.MAX_CACHE_SIZE * 0.1)); // Remove 10% or 100 entries
|
|
83
|
+
const keysToRemove = this.cacheAccessOrder.splice(0, entriesToRemove);
|
|
84
|
+
|
|
85
|
+
for (const key of keysToRemove) {
|
|
86
|
+
this.metadataCache.delete(key);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
this.logger.log('info', `Evicted ${entriesToRemove} entries from metadata cache`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Update cache access order for LRU
|
|
95
|
+
*/
|
|
96
|
+
private touchCacheEntry(key: string): void {
|
|
97
|
+
const index = this.cacheAccessOrder.indexOf(key);
|
|
98
|
+
if (index > -1) {
|
|
99
|
+
this.cacheAccessOrder.splice(index, 1);
|
|
100
|
+
}
|
|
101
|
+
this.cacheAccessOrder.push(key);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Initialize storage directories
|
|
106
|
+
*/
|
|
107
|
+
public async initialize(): Promise<void> {
|
|
108
|
+
const dirs = [
|
|
109
|
+
path.join(this.exportPath, 'data', 'documents', 'invoices', 'inbound'),
|
|
110
|
+
path.join(this.exportPath, 'data', 'documents', 'invoices', 'inbound', 'metadata'),
|
|
111
|
+
path.join(this.exportPath, 'data', 'documents', 'invoices', 'outbound'),
|
|
112
|
+
path.join(this.exportPath, 'data', 'documents', 'invoices', 'outbound', 'metadata'),
|
|
113
|
+
path.join(this.exportPath, 'data', 'validation')
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
for (const dir of dirs) {
|
|
117
|
+
await plugins.smartfile.fs.ensureDir(dir);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Load existing registry if it exists
|
|
121
|
+
await this.loadRegistry();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private readonly MAX_PDF_SIZE = 50 * 1024 * 1024; // 50MB max
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Store an invoice with content addressing
|
|
128
|
+
*/
|
|
129
|
+
public async storeInvoice(
|
|
130
|
+
invoice: IInvoice,
|
|
131
|
+
pdfBuffer?: Buffer
|
|
132
|
+
): Promise<string> {
|
|
133
|
+
try {
|
|
134
|
+
// Validate PDF size if provided
|
|
135
|
+
if (pdfBuffer && pdfBuffer.length > this.MAX_PDF_SIZE) {
|
|
136
|
+
throw new Error(`PDF file too large: ${pdfBuffer.length} bytes (max ${this.MAX_PDF_SIZE} bytes)`);
|
|
137
|
+
}
|
|
138
|
+
// Calculate hashes
|
|
139
|
+
const xmlHash = await this.calculateHash(invoice.xmlContent || '');
|
|
140
|
+
const pdfHash = pdfBuffer ? await this.calculateHash(pdfBuffer) : undefined;
|
|
141
|
+
const contentHash = xmlHash; // Primary content hash is XML
|
|
142
|
+
|
|
143
|
+
// Check for duplicates
|
|
144
|
+
const duplicateCheck = await this.checkDuplicate(invoice, contentHash);
|
|
145
|
+
if (duplicateCheck.isDuplicate) {
|
|
146
|
+
this.logger.log('warn', `Duplicate invoice detected: ${invoice.invoiceNumber}`);
|
|
147
|
+
return duplicateCheck.matchedContentHash || contentHash;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Determine storage path
|
|
151
|
+
const direction = invoice.direction;
|
|
152
|
+
const basePath = path.join(
|
|
153
|
+
this.exportPath,
|
|
154
|
+
'data',
|
|
155
|
+
'documents',
|
|
156
|
+
'invoices',
|
|
157
|
+
direction
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
// Create filename with content hash
|
|
161
|
+
const dateStr = invoice.issueDate.toISOString().split('T')[0];
|
|
162
|
+
const sanitizedNumber = invoice.invoiceNumber.replace(/[^a-zA-Z0-9-_]/g, '_');
|
|
163
|
+
const xmlFilename = `${contentHash.substring(0, 8)}_${dateStr}_${sanitizedNumber}.xml`;
|
|
164
|
+
const xmlPath = path.join(basePath, xmlFilename);
|
|
165
|
+
|
|
166
|
+
// Store XML
|
|
167
|
+
await plugins.smartfile.memory.toFs(invoice.xmlContent || '', xmlPath);
|
|
168
|
+
|
|
169
|
+
// Store PDF if available
|
|
170
|
+
let pdfFilename: string | undefined;
|
|
171
|
+
if (pdfBuffer) {
|
|
172
|
+
pdfFilename = xmlFilename.replace('.xml', '.pdf');
|
|
173
|
+
const pdfPath = path.join(basePath, pdfFilename);
|
|
174
|
+
await plugins.smartfile.memory.toFs(pdfBuffer, pdfPath);
|
|
175
|
+
|
|
176
|
+
// Also store PDF/A-3 with embedded XML if supported
|
|
177
|
+
if (invoice.format === 'zugferd' || invoice.format === 'facturx') {
|
|
178
|
+
const pdfA3Filename = xmlFilename.replace('.xml', '_pdfa3.pdf');
|
|
179
|
+
const pdfA3Path = path.join(basePath, pdfA3Filename);
|
|
180
|
+
// The PDF should already have embedded XML if it's ZUGFeRD/Factur-X
|
|
181
|
+
await plugins.smartfile.memory.toFs(pdfBuffer, pdfA3Path);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Create and store metadata
|
|
186
|
+
const metadata: IInvoiceMetadata = {
|
|
187
|
+
invoiceId: invoice.id,
|
|
188
|
+
invoiceNumber: invoice.invoiceNumber,
|
|
189
|
+
direction: invoice.direction,
|
|
190
|
+
issueDate: invoice.issueDate.toISOString(),
|
|
191
|
+
supplierName: invoice.supplier.name,
|
|
192
|
+
customerName: invoice.customer.name,
|
|
193
|
+
totalAmount: invoice.payableAmount,
|
|
194
|
+
currency: invoice.currencyCode,
|
|
195
|
+
contentHash,
|
|
196
|
+
pdfHash,
|
|
197
|
+
xmlHash,
|
|
198
|
+
journalEntryId: invoice.bookingInfo?.journalEntryId,
|
|
199
|
+
transactionIds: invoice.bookingInfo?.transactionIds,
|
|
200
|
+
validationResult: {
|
|
201
|
+
isValid: invoice.validationResult?.isValid || false,
|
|
202
|
+
errors: this.countErrors(invoice.validationResult),
|
|
203
|
+
warnings: this.countWarnings(invoice.validationResult)
|
|
204
|
+
},
|
|
205
|
+
parserVersion: invoice.metadata?.parserVersion || '5.1.4',
|
|
206
|
+
storedAt: new Date().toISOString(),
|
|
207
|
+
storedBy: invoice.createdBy
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const metadataPath = path.join(basePath, 'metadata', `${contentHash}.json`);
|
|
211
|
+
await plugins.smartfile.memory.toFs(
|
|
212
|
+
JSON.stringify(metadata, null, 2),
|
|
213
|
+
metadataPath
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
// Update registry
|
|
217
|
+
await this.updateRegistry(invoice.id, contentHash, metadata);
|
|
218
|
+
|
|
219
|
+
// Cache metadata with LRU management
|
|
220
|
+
this.setCacheEntry(contentHash, metadata);
|
|
221
|
+
|
|
222
|
+
this.logger.log('info', `Invoice stored: ${invoice.invoiceNumber} (${contentHash})`);
|
|
223
|
+
|
|
224
|
+
return contentHash;
|
|
225
|
+
} catch (error) {
|
|
226
|
+
this.logger.log('error', `Failed to store invoice: ${error}`);
|
|
227
|
+
throw new Error(`Invoice storage failed: ${error.message}`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Retrieve an invoice by content hash
|
|
233
|
+
*/
|
|
234
|
+
public async retrieveInvoice(contentHash: string): Promise<IInvoice | null> {
|
|
235
|
+
try {
|
|
236
|
+
// Check cache first
|
|
237
|
+
const metadata = this.getCacheEntry(contentHash);
|
|
238
|
+
if (!metadata) {
|
|
239
|
+
this.logger.log('warn', `Invoice not found: ${contentHash}`);
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Load XML content
|
|
244
|
+
const xmlPath = await this.findInvoiceFile(contentHash, '.xml');
|
|
245
|
+
if (!xmlPath) {
|
|
246
|
+
throw new Error(`XML file not found for invoice ${contentHash}`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const xmlContent = await plugins.smartfile.fs.toStringSync(xmlPath);
|
|
250
|
+
|
|
251
|
+
// Load PDF if exists
|
|
252
|
+
let pdfContent: Buffer | undefined;
|
|
253
|
+
const pdfPath = await this.findInvoiceFile(contentHash, '.pdf');
|
|
254
|
+
if (pdfPath) {
|
|
255
|
+
pdfContent = await plugins.smartfile.fs.toBuffer(pdfPath);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Reconstruct invoice object (partial)
|
|
259
|
+
const invoice: Partial<IInvoice> = {
|
|
260
|
+
id: metadata.invoiceId,
|
|
261
|
+
invoiceNumber: metadata.invoiceNumber,
|
|
262
|
+
direction: metadata.direction as any,
|
|
263
|
+
issueDate: new Date(metadata.issueDate),
|
|
264
|
+
supplier: {
|
|
265
|
+
name: metadata.supplierName,
|
|
266
|
+
id: '',
|
|
267
|
+
address: { countryCode: 'DE' }
|
|
268
|
+
},
|
|
269
|
+
customer: {
|
|
270
|
+
name: metadata.customerName,
|
|
271
|
+
id: '',
|
|
272
|
+
address: { countryCode: 'DE' }
|
|
273
|
+
},
|
|
274
|
+
payableAmount: metadata.totalAmount,
|
|
275
|
+
currencyCode: metadata.currency,
|
|
276
|
+
contentHash: metadata.contentHash,
|
|
277
|
+
xmlContent,
|
|
278
|
+
pdfContent,
|
|
279
|
+
pdfHash: metadata.pdfHash
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
return invoice as IInvoice;
|
|
283
|
+
} catch (error) {
|
|
284
|
+
this.logger.log('error', `Failed to retrieve invoice: ${error}`);
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Check for duplicate invoices
|
|
291
|
+
*/
|
|
292
|
+
public async checkDuplicate(
|
|
293
|
+
invoice: IInvoice,
|
|
294
|
+
contentHash: string
|
|
295
|
+
): Promise<IDuplicateCheckResult> {
|
|
296
|
+
// Check by content hash (exact match)
|
|
297
|
+
const existing = this.getCacheEntry(contentHash);
|
|
298
|
+
if (existing) {
|
|
299
|
+
return {
|
|
300
|
+
isDuplicate: true,
|
|
301
|
+
matchedInvoiceId: existing.invoiceId,
|
|
302
|
+
matchedContentHash: contentHash,
|
|
303
|
+
matchedFields: ['contentHash'],
|
|
304
|
+
confidence: 100
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Check by invoice number and supplier/customer
|
|
309
|
+
for (const [hash, metadata] of this.metadataCache.entries()) {
|
|
310
|
+
if (
|
|
311
|
+
metadata.invoiceNumber === invoice.invoiceNumber &&
|
|
312
|
+
metadata.direction === invoice.direction
|
|
313
|
+
) {
|
|
314
|
+
// Same invoice number and direction
|
|
315
|
+
if (invoice.direction === 'inbound' && metadata.supplierName === invoice.supplier.name) {
|
|
316
|
+
// Same supplier
|
|
317
|
+
return {
|
|
318
|
+
isDuplicate: true,
|
|
319
|
+
matchedInvoiceId: metadata.invoiceId,
|
|
320
|
+
matchedContentHash: hash,
|
|
321
|
+
matchedFields: ['invoiceNumber', 'supplier'],
|
|
322
|
+
confidence: 95
|
|
323
|
+
};
|
|
324
|
+
} else if (invoice.direction === 'outbound' && metadata.customerName === invoice.customer.name) {
|
|
325
|
+
// Same customer
|
|
326
|
+
return {
|
|
327
|
+
isDuplicate: true,
|
|
328
|
+
matchedInvoiceId: metadata.invoiceId,
|
|
329
|
+
matchedContentHash: hash,
|
|
330
|
+
matchedFields: ['invoiceNumber', 'customer'],
|
|
331
|
+
confidence: 95
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Check by amount and date within tolerance
|
|
337
|
+
const dateTolerance = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
338
|
+
const amountTolerance = 0.01;
|
|
339
|
+
|
|
340
|
+
if (
|
|
341
|
+
Math.abs(metadata.totalAmount - invoice.payableAmount) < amountTolerance &&
|
|
342
|
+
Math.abs(new Date(metadata.issueDate).getTime() - invoice.issueDate.getTime()) < dateTolerance &&
|
|
343
|
+
metadata.direction === invoice.direction
|
|
344
|
+
) {
|
|
345
|
+
if (
|
|
346
|
+
(invoice.direction === 'inbound' && metadata.supplierName === invoice.supplier.name) ||
|
|
347
|
+
(invoice.direction === 'outbound' && metadata.customerName === invoice.customer.name)
|
|
348
|
+
) {
|
|
349
|
+
return {
|
|
350
|
+
isDuplicate: true,
|
|
351
|
+
matchedInvoiceId: metadata.invoiceId,
|
|
352
|
+
matchedContentHash: hash,
|
|
353
|
+
matchedFields: ['amount', 'date', 'party'],
|
|
354
|
+
confidence: 85
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return {
|
|
361
|
+
isDuplicate: false,
|
|
362
|
+
confidence: 0
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Search invoices by filter
|
|
368
|
+
*/
|
|
369
|
+
public async searchInvoices(filter: IInvoiceFilter): Promise<IInvoiceMetadata[]> {
|
|
370
|
+
const results: IInvoiceMetadata[] = [];
|
|
371
|
+
|
|
372
|
+
for (const metadata of this.metadataCache.values()) {
|
|
373
|
+
if (this.matchesFilter(metadata, filter)) {
|
|
374
|
+
results.push(metadata);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Sort by date descending
|
|
379
|
+
results.sort((a, b) =>
|
|
380
|
+
new Date(b.issueDate).getTime() - new Date(a.issueDate).getTime()
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
return results;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Get storage statistics
|
|
388
|
+
*/
|
|
389
|
+
public async getStatistics(): Promise<IStorageStats> {
|
|
390
|
+
let totalSize = 0;
|
|
391
|
+
let inboundCount = 0;
|
|
392
|
+
let outboundCount = 0;
|
|
393
|
+
|
|
394
|
+
for (const metadata of this.metadataCache.values()) {
|
|
395
|
+
if (metadata.direction === 'inbound') {
|
|
396
|
+
inboundCount++;
|
|
397
|
+
} else {
|
|
398
|
+
outboundCount++;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Estimate size (would need actual file sizes in production)
|
|
402
|
+
totalSize += 50000; // Rough estimate
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return {
|
|
406
|
+
totalInvoices: this.metadataCache.size,
|
|
407
|
+
inboundCount,
|
|
408
|
+
outboundCount,
|
|
409
|
+
totalSize,
|
|
410
|
+
duplicatesDetected: 0, // Would track this in production
|
|
411
|
+
lastUpdate: new Date()
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Create EN16931 compliance report
|
|
417
|
+
*/
|
|
418
|
+
public async createComplianceReport(): Promise<void> {
|
|
419
|
+
const report = {
|
|
420
|
+
timestamp: new Date().toISOString(),
|
|
421
|
+
totalInvoices: this.metadataCache.size,
|
|
422
|
+
validInvoices: 0,
|
|
423
|
+
invalidInvoices: 0,
|
|
424
|
+
warnings: 0,
|
|
425
|
+
byFormat: {} as Record<string, number>,
|
|
426
|
+
byDirection: {
|
|
427
|
+
inbound: 0,
|
|
428
|
+
outbound: 0
|
|
429
|
+
},
|
|
430
|
+
validationErrors: [] as string[],
|
|
431
|
+
complianceLevel: 'EN16931',
|
|
432
|
+
validatorVersion: '5.1.4'
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
for (const metadata of this.metadataCache.values()) {
|
|
436
|
+
if (metadata.validationResult.isValid) {
|
|
437
|
+
report.validInvoices++;
|
|
438
|
+
} else {
|
|
439
|
+
report.invalidInvoices++;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
report.warnings += metadata.validationResult.warnings;
|
|
443
|
+
|
|
444
|
+
if (metadata.direction === 'inbound') {
|
|
445
|
+
report.byDirection.inbound++;
|
|
446
|
+
} else {
|
|
447
|
+
report.byDirection.outbound++;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const reportPath = path.join(
|
|
452
|
+
this.exportPath,
|
|
453
|
+
'data',
|
|
454
|
+
'validation',
|
|
455
|
+
'en16931_compliance.json'
|
|
456
|
+
);
|
|
457
|
+
|
|
458
|
+
await plugins.smartfile.memory.toFs(
|
|
459
|
+
JSON.stringify(report, null, 2),
|
|
460
|
+
reportPath
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Load registry from disk
|
|
466
|
+
*/
|
|
467
|
+
private async loadRegistry(): Promise<void> {
|
|
468
|
+
try {
|
|
469
|
+
if (await plugins.smartfile.fs.fileExists(this.registryPath)) {
|
|
470
|
+
const content = await plugins.smartfile.fs.toStringSync(this.registryPath);
|
|
471
|
+
const lines = content.split('\n').filter(line => line.trim());
|
|
472
|
+
|
|
473
|
+
for (const line of lines) {
|
|
474
|
+
try {
|
|
475
|
+
const entry: IInvoiceRegistryEntry = JSON.parse(line);
|
|
476
|
+
this.setCacheEntry(entry.hash, entry.metadata);
|
|
477
|
+
} catch (e) {
|
|
478
|
+
this.logger.log('warn', `Invalid registry entry: ${line}`);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
this.logger.log('info', `Loaded ${this.metadataCache.size} invoices from registry`);
|
|
483
|
+
}
|
|
484
|
+
} catch (error) {
|
|
485
|
+
this.logger.log('error', `Failed to load registry: ${error}`);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Update registry with new entry
|
|
491
|
+
*/
|
|
492
|
+
private async updateRegistry(
|
|
493
|
+
invoiceId: string,
|
|
494
|
+
contentHash: string,
|
|
495
|
+
metadata: IInvoiceMetadata
|
|
496
|
+
): Promise<void> {
|
|
497
|
+
try {
|
|
498
|
+
const entry: IInvoiceRegistryEntry = {
|
|
499
|
+
id: invoiceId,
|
|
500
|
+
hash: contentHash,
|
|
501
|
+
metadata
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
// Append to NDJSON file
|
|
505
|
+
const line = JSON.stringify(entry) + '\n';
|
|
506
|
+
await plugins.smartfile.fs.ensureDir(path.dirname(this.registryPath));
|
|
507
|
+
|
|
508
|
+
// Use native fs for atomic append (better performance and concurrency safety)
|
|
509
|
+
const fs = await import('fs/promises');
|
|
510
|
+
await fs.appendFile(this.registryPath, line, 'utf8');
|
|
511
|
+
} catch (error) {
|
|
512
|
+
this.logger.log('error', `Failed to update registry: ${error}`);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Find invoice file by hash and extension
|
|
518
|
+
*/
|
|
519
|
+
private async findInvoiceFile(
|
|
520
|
+
contentHash: string,
|
|
521
|
+
extension: string
|
|
522
|
+
): Promise<string | null> {
|
|
523
|
+
const dirs = [
|
|
524
|
+
path.join(this.exportPath, 'data', 'documents', 'invoices', 'inbound'),
|
|
525
|
+
path.join(this.exportPath, 'data', 'documents', 'invoices', 'outbound')
|
|
526
|
+
];
|
|
527
|
+
|
|
528
|
+
for (const dir of dirs) {
|
|
529
|
+
const files = await plugins.smartfile.fs.listFileTree(dir, '**/*' + extension);
|
|
530
|
+
|
|
531
|
+
for (const file of files) {
|
|
532
|
+
if (file.includes(contentHash.substring(0, 8))) {
|
|
533
|
+
return path.join(dir, file);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
return null;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Calculate SHA-256 hash
|
|
543
|
+
*/
|
|
544
|
+
private async calculateHash(data: string | Buffer): Promise<string> {
|
|
545
|
+
if (typeof data === 'string') {
|
|
546
|
+
return await plugins.smarthash.sha256FromString(data);
|
|
547
|
+
} else {
|
|
548
|
+
return await plugins.smarthash.sha256FromBuffer(data);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Check if metadata matches filter
|
|
554
|
+
*/
|
|
555
|
+
private matchesFilter(metadata: IInvoiceMetadata, filter: IInvoiceFilter): boolean {
|
|
556
|
+
if (filter.direction && metadata.direction !== filter.direction) {
|
|
557
|
+
return false;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
if (filter.dateFrom && new Date(metadata.issueDate) < filter.dateFrom) {
|
|
561
|
+
return false;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
if (filter.dateTo && new Date(metadata.issueDate) > filter.dateTo) {
|
|
565
|
+
return false;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
if (filter.minAmount && metadata.totalAmount < filter.minAmount) {
|
|
569
|
+
return false;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if (filter.maxAmount && metadata.totalAmount > filter.maxAmount) {
|
|
573
|
+
return false;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
if (filter.invoiceNumber && !metadata.invoiceNumber.includes(filter.invoiceNumber)) {
|
|
577
|
+
return false;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
if (filter.supplierId && !metadata.supplierName.includes(filter.supplierId)) {
|
|
581
|
+
return false;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (filter.customerId && !metadata.customerName.includes(filter.customerId)) {
|
|
585
|
+
return false;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
return true;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Count errors in validation result
|
|
593
|
+
*/
|
|
594
|
+
private countErrors(validationResult?: IInvoice['validationResult']): number {
|
|
595
|
+
if (!validationResult) return 0;
|
|
596
|
+
|
|
597
|
+
return (
|
|
598
|
+
validationResult.syntax.errors.length +
|
|
599
|
+
validationResult.semantic.errors.length +
|
|
600
|
+
validationResult.businessRules.errors.length +
|
|
601
|
+
(validationResult.countrySpecific?.errors.length || 0)
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Count warnings in validation result
|
|
607
|
+
*/
|
|
608
|
+
private countWarnings(validationResult?: IInvoice['validationResult']): number {
|
|
609
|
+
if (!validationResult) return 0;
|
|
610
|
+
|
|
611
|
+
return (
|
|
612
|
+
validationResult.syntax.warnings.length +
|
|
613
|
+
validationResult.semantic.warnings.length +
|
|
614
|
+
validationResult.businessRules.warnings.length +
|
|
615
|
+
(validationResult.countrySpecific?.warnings.length || 0)
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Clean up old invoices (for testing only)
|
|
621
|
+
*/
|
|
622
|
+
public async cleanup(olderThanDays: number = 365): Promise<number> {
|
|
623
|
+
let removed = 0;
|
|
624
|
+
const cutoffDate = new Date();
|
|
625
|
+
cutoffDate.setDate(cutoffDate.getDate() - olderThanDays);
|
|
626
|
+
|
|
627
|
+
for (const [hash, metadata] of this.metadataCache.entries()) {
|
|
628
|
+
if (new Date(metadata.issueDate) < cutoffDate) {
|
|
629
|
+
this.metadataCache.delete(hash);
|
|
630
|
+
removed++;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
this.logger.log('info', `Removed ${removed} old invoices from cache`);
|
|
635
|
+
return removed;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Set cache entry with LRU eviction
|
|
640
|
+
*/
|
|
641
|
+
private setCacheEntry(key: string, value: IInvoiceMetadata): void {
|
|
642
|
+
// Remove from access order if already exists
|
|
643
|
+
const existingIndex = this.cacheAccessOrder.indexOf(key);
|
|
644
|
+
if (existingIndex > -1) {
|
|
645
|
+
this.cacheAccessOrder.splice(existingIndex, 1);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// Add to end (most recently used)
|
|
649
|
+
this.cacheAccessOrder.push(key);
|
|
650
|
+
this.metadataCache.set(key, value);
|
|
651
|
+
|
|
652
|
+
// Evict oldest if cache is too large
|
|
653
|
+
while (this.metadataCache.size > this.MAX_CACHE_SIZE) {
|
|
654
|
+
const oldestKey = this.cacheAccessOrder.shift();
|
|
655
|
+
if (oldestKey) {
|
|
656
|
+
this.metadataCache.delete(oldestKey);
|
|
657
|
+
this.logger.log('debug', `Evicted invoice from cache: ${oldestKey}`);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Get cache entry and update access order
|
|
664
|
+
*/
|
|
665
|
+
private getCacheEntry(key: string): IInvoiceMetadata | undefined {
|
|
666
|
+
const value = this.metadataCache.get(key);
|
|
667
|
+
if (value) {
|
|
668
|
+
// Move to end (most recently used)
|
|
669
|
+
const index = this.cacheAccessOrder.indexOf(key);
|
|
670
|
+
if (index > -1) {
|
|
671
|
+
this.cacheAccessOrder.splice(index, 1);
|
|
672
|
+
}
|
|
673
|
+
this.cacheAccessOrder.push(key);
|
|
674
|
+
}
|
|
675
|
+
return value;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Update metadata in storage and cache
|
|
680
|
+
*/
|
|
681
|
+
public async updateMetadata(contentHash: string, updates: Partial<IInvoiceMetadata>): Promise<void> {
|
|
682
|
+
const metadata = this.getCacheEntry(contentHash);
|
|
683
|
+
if (!metadata) {
|
|
684
|
+
this.logger.log('warn', `Cannot update metadata - invoice not found: ${contentHash}`);
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// Update metadata
|
|
689
|
+
const updatedMetadata = { ...metadata, ...updates };
|
|
690
|
+
this.setCacheEntry(contentHash, updatedMetadata);
|
|
691
|
+
|
|
692
|
+
// Persist to disk
|
|
693
|
+
const metadataPath = path.join(
|
|
694
|
+
this.exportPath,
|
|
695
|
+
'data',
|
|
696
|
+
'documents',
|
|
697
|
+
'invoices',
|
|
698
|
+
metadata.direction,
|
|
699
|
+
'metadata',
|
|
700
|
+
`${contentHash}.json`
|
|
701
|
+
);
|
|
702
|
+
|
|
703
|
+
await plugins.smartfile.memory.toFs(
|
|
704
|
+
JSON.stringify(updatedMetadata, null, 2),
|
|
705
|
+
metadataPath
|
|
706
|
+
);
|
|
707
|
+
|
|
708
|
+
this.logger.log('info', `Updated metadata for invoice: ${contentHash}`);
|
|
709
|
+
}
|
|
710
|
+
}
|