@perrylink/dsh-einvoice 0.0.0-stage → 0.1.0-alpha.1

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.
@@ -0,0 +1,543 @@
1
+ /** Three S1 model-facing tools: ingest, parse, query. */
2
+ import { createHash } from 'node:crypto';
3
+ import { readFile, realpath, stat } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { defineTool } from '@deepseek-ai/dsh-tools';
6
+ import { InvoiceError, assertEightFields, canonicalJson, invoicePeriod, } from '../core/invoice.js';
7
+ import { getParser } from '../registry/parsers.js';
8
+ import { assertUniqueInvoice, emptyInvoiceRecord, findBySha, newDocId, recordLines, sha256Buffer, storeOriginal, } from './storage.js';
9
+ const partyOut = {
10
+ type: 'object',
11
+ additionalProperties: false,
12
+ properties: {
13
+ name: { type: 'string', required: true },
14
+ tax_id: { type: 'string', required: true },
15
+ },
16
+ };
17
+ const lineOut = {
18
+ type: 'object',
19
+ additionalProperties: false,
20
+ properties: {
21
+ line_no: { type: 'number', required: true },
22
+ description: { type: 'string', required: true },
23
+ quantity: { type: 'string' },
24
+ unit_price_cents: { type: 'number' },
25
+ amount_cents: { type: 'number', required: true },
26
+ tax_rate: { type: 'string' },
27
+ tax_cents: { type: 'number' },
28
+ },
29
+ };
30
+ function text(value) {
31
+ return [{ type: 'text', text: value }];
32
+ }
33
+ function actorOf(exec) {
34
+ return exec.agent?.id ? `agent:${exec.agent.id}` : 'local';
35
+ }
36
+ function runIdOf(exec) {
37
+ return String(exec.callId);
38
+ }
39
+ async function resolveAllowedPath(inputPath, roots) {
40
+ const absolute = path.resolve(inputPath);
41
+ let resolved;
42
+ try {
43
+ resolved = await realpath(absolute);
44
+ }
45
+ catch {
46
+ throw new InvoiceError('E_INV_FILE_UNREADABLE', `file does not exist: ${inputPath}`);
47
+ }
48
+ const resolvedRoots = await Promise.all(roots.map(async (root) => {
49
+ try {
50
+ return await realpath(path.resolve(root));
51
+ }
52
+ catch {
53
+ return undefined;
54
+ }
55
+ }));
56
+ const allowed = resolvedRoots.some((root) => root !== undefined && (resolved === root || resolved.startsWith(root + path.sep)));
57
+ if (!allowed)
58
+ throw new InvoiceError('E_INV_PATH_NOT_ALLOWED', `path is outside the configured input roots: ${inputPath}`);
59
+ return resolved;
60
+ }
61
+ function parsedToRecord(record, parsed) {
62
+ const updatedAt = new Date().toISOString();
63
+ return {
64
+ ...record,
65
+ invoice_no: parsed.invoice_no,
66
+ invoice_code: parsed.invoice_code,
67
+ issue_date: parsed.issue_date,
68
+ buyer: parsed.buyer,
69
+ seller: parsed.seller,
70
+ amount_cents: parsed.amount_cents,
71
+ tax_cents: parsed.tax_cents,
72
+ total_cents: parsed.total_cents,
73
+ currency: 'CNY',
74
+ lines: recordLines(parsed.lines),
75
+ confidence: parsed.confidence,
76
+ field_evidence: parsed.field_evidence,
77
+ unknown_fields: parsed.unknown_fields,
78
+ parser_version: parsed.parser_version,
79
+ lifecycle: 'parsed',
80
+ updated_at: updatedAt,
81
+ evidence_sha256: createHash('sha256').update(canonicalJson(parsed)).digest('hex'),
82
+ };
83
+ }
84
+ export function createTools(deps) {
85
+ const ingest = defineTool({
86
+ name: 'invoice.ingest',
87
+ description: 'Ingest one local OFD/XML invoice file into the SKU-04 pool. Computes SHA-256, stores the original under the local pool, and returns the document id. Re-ingesting the same bytes is idempotent.',
88
+ parameters: {
89
+ source: {
90
+ type: 'object',
91
+ required: true,
92
+ additionalProperties: false,
93
+ properties: {
94
+ kind: { type: 'string', enum: ['file'], required: true, description: 'S1 accepts one local file' },
95
+ path: { type: 'string', required: true, description: 'Local path under the configured input roots' },
96
+ sha256: { type: 'string', description: 'Optional expected SHA-256; mismatch is rejected' },
97
+ },
98
+ },
99
+ entity_id: { type: 'string', description: 'Legal entity id; defaults to "default"' },
100
+ },
101
+ output: {
102
+ schema: {
103
+ type: 'object',
104
+ additionalProperties: false,
105
+ properties: {
106
+ doc_id: { type: 'string', required: true },
107
+ sha256: { type: 'string', required: true },
108
+ size: { type: 'number', required: true },
109
+ existing: { type: 'boolean', required: true },
110
+ },
111
+ },
112
+ render: (_args, value) => text(`ingested ${value.doc_id} (${value.size} bytes, sha256 ${value.sha256.slice(0, 12)}…${value.existing ? ', existing' : ''})`),
113
+ },
114
+ async execute(args, exec) {
115
+ const resolved = await resolveAllowedPath(args.source.path, deps.inputRoots);
116
+ const info = await stat(resolved);
117
+ if (!info.isFile())
118
+ throw new InvoiceError('E_INV_FILE_UNREADABLE', 'source is not a regular file');
119
+ if (info.size === 0)
120
+ throw new InvoiceError('E_INV_FILE_UNREADABLE', 'source file is empty');
121
+ if (info.size > 50 * 1024 * 1024)
122
+ throw new InvoiceError('E_INV_FILE_UNREADABLE', 'source file exceeds 50MB');
123
+ const buffer = await readFile(resolved);
124
+ const sha256 = sha256Buffer(buffer);
125
+ if (args.source.sha256 && args.source.sha256.toLowerCase() !== sha256) {
126
+ throw new InvoiceError('E_INV_STORAGE_FAILED', 'expected sha256 does not match the source file');
127
+ }
128
+ const existing = findBySha(deps.table, sha256);
129
+ if (existing)
130
+ return { doc_id: existing.doc_id, sha256, size: info.size, existing: true };
131
+ const docId = newDocId();
132
+ const entityId = args.entity_id?.trim() || 'default';
133
+ const storedPath = await storeOriginal(deps.baseDir, sha256, path.extname(resolved), buffer);
134
+ const record = emptyInvoiceRecord({
135
+ docId,
136
+ entityId,
137
+ sourcePath: storedPath,
138
+ sha256,
139
+ size: info.size,
140
+ runId: runIdOf(exec),
141
+ actor: actorOf(exec),
142
+ });
143
+ await deps.table.put(docId, record);
144
+ return { doc_id: docId, sha256, size: info.size, existing: false };
145
+ },
146
+ });
147
+ const parse = defineTool({
148
+ name: 'invoice.parse',
149
+ description: 'Parse one ingested OFD/XML invoice into the eight fields, line items, field evidence and unknown fields. Supports old OFD invoice XML and 数电票 CustomTag/ObjectRef.',
150
+ parameters: {
151
+ doc_id: { type: 'string', required: true },
152
+ profile: { type: 'string', description: 'Parser profile; S1 uses ofd-zip-xml' },
153
+ ocr: { type: 'string', enum: ['off'], description: 'S1 does not run OCR' },
154
+ },
155
+ output: {
156
+ schema: {
157
+ type: 'object',
158
+ additionalProperties: false,
159
+ properties: {
160
+ doc_id: { type: 'string', required: true },
161
+ invoice_no: { type: 'string', required: true },
162
+ issue_date: { type: 'string', required: true },
163
+ buyer: { ...partyOut, required: true },
164
+ seller: { ...partyOut, required: true },
165
+ amount_cents: { type: 'number', required: true },
166
+ tax_cents: { type: 'number', required: true },
167
+ total_cents: { type: 'number', required: true },
168
+ currency: { type: 'string', required: true },
169
+ lines: { type: 'array', items: lineOut, required: true },
170
+ confidence: { type: 'number', required: true },
171
+ field_evidence: { type: 'json', required: true },
172
+ unknown_fields: { type: 'json', required: true },
173
+ parser_version: { type: 'string', required: true },
174
+ warnings: { type: 'array', items: { type: 'string' }, required: true },
175
+ },
176
+ },
177
+ render: (_args, value) => text(`parsed ${value.invoice_no || '(no number)'} ${value.seller.name || '(no seller)'} total=${(value.total_cents / 100).toFixed(2)} CNY lines=${value.lines.length} unknown=${Object.keys(value.unknown_fields ?? {}).length}`),
178
+ },
179
+ async execute(args, exec) {
180
+ const record = deps.table.get(args.doc_id);
181
+ if (!record)
182
+ throw new InvoiceError('E_INV_DOC_NOT_FOUND', `unknown doc_id: ${args.doc_id}`);
183
+ const buffer = await readFile(record.source_path);
184
+ const parser = getParser(args.profile);
185
+ const parsed = parser.parse({ buffer, fileName: path.basename(record.source_path) });
186
+ assertEightFields(parsed);
187
+ assertUniqueInvoice(deps.table, record.entity_id, parsed.invoice_no, record.doc_id);
188
+ const updated = parsedToRecord(record, parsed);
189
+ updated.actor = actorOf(exec);
190
+ updated.run_id = runIdOf(exec);
191
+ await deps.table.put(record.doc_id, updated);
192
+ return {
193
+ doc_id: record.doc_id,
194
+ invoice_no: parsed.invoice_no,
195
+ issue_date: parsed.issue_date,
196
+ buyer: parsed.buyer,
197
+ seller: parsed.seller,
198
+ amount_cents: parsed.amount_cents,
199
+ tax_cents: parsed.tax_cents,
200
+ total_cents: parsed.total_cents,
201
+ currency: parsed.currency,
202
+ lines: parsed.lines,
203
+ confidence: parsed.confidence,
204
+ field_evidence: parsed.field_evidence,
205
+ unknown_fields: parsed.unknown_fields,
206
+ parser_version: parsed.parser_version,
207
+ warnings: parsed.warnings,
208
+ };
209
+ },
210
+ });
211
+ const query = defineTool({
212
+ name: 'invoice.query',
213
+ description: 'Query the SKU-04 invoice pool by entity, period, lifecycle or text; returns a stable page.',
214
+ parameters: {
215
+ filter: {
216
+ type: 'object',
217
+ additionalProperties: false,
218
+ properties: {
219
+ entity_id: { type: 'string' },
220
+ period: { type: 'string', description: 'YYYY-MM from issue_date' },
221
+ lifecycle: { type: 'string', enum: ['received', 'parsed', 'verified', 'archived', 'quarantined'] },
222
+ q: { type: 'string', description: 'Case-insensitive text match on invoice number or seller name' },
223
+ },
224
+ },
225
+ cursor: { type: 'string', description: 'Opaque offset cursor' },
226
+ page: { type: 'number', description: '1..200, default 50' },
227
+ },
228
+ output: {
229
+ schema: {
230
+ type: 'object',
231
+ additionalProperties: false,
232
+ properties: {
233
+ rows: {
234
+ required: true,
235
+ type: 'array',
236
+ items: {
237
+ type: 'object',
238
+ additionalProperties: false,
239
+ properties: {
240
+ doc_id: { type: 'string', required: true },
241
+ invoice_no: { type: 'string', required: true },
242
+ issue_date: { type: 'string', required: true },
243
+ buyer: { ...partyOut, required: true },
244
+ seller: { ...partyOut, required: true },
245
+ amount_cents: { type: 'number', required: true },
246
+ tax_cents: { type: 'number', required: true },
247
+ total_cents: { type: 'number', required: true },
248
+ lifecycle: { type: 'string', required: true },
249
+ parser_version: { type: 'string', required: true },
250
+ confidence: { type: 'number', required: true },
251
+ },
252
+ },
253
+ },
254
+ total: { type: 'number', required: true },
255
+ next_cursor: { type: 'string', required: true },
256
+ },
257
+ },
258
+ render: (_args, value) => text(`invoice pool: ${value.total} matched, ${value.rows.length} on this page`),
259
+ },
260
+ async execute(args) {
261
+ const pageSize = Math.max(1, Math.min(200, Math.floor(args.page ?? 50)));
262
+ const offset = Math.max(0, Number.parseInt(args.cursor ?? '0', 10) || 0);
263
+ const filter = args.filter ?? {};
264
+ const rows = [...deps.table.entries()]
265
+ .map(([, record]) => record)
266
+ .filter((record) => {
267
+ if (filter.entity_id && record.entity_id !== filter.entity_id)
268
+ return false;
269
+ if (filter.period && invoicePeriod(record.issue_date) !== filter.period)
270
+ return false;
271
+ if (filter.lifecycle && record.lifecycle !== filter.lifecycle)
272
+ return false;
273
+ if (filter.q) {
274
+ const queryText = filter.q.toLowerCase();
275
+ if (!record.invoice_no.toLowerCase().includes(queryText) && !record.seller.name.toLowerCase().includes(queryText))
276
+ return false;
277
+ }
278
+ return true;
279
+ })
280
+ .sort((a, b) => b.created_at.localeCompare(a.created_at));
281
+ const total = rows.length;
282
+ const pageRows = rows.slice(offset, offset + pageSize).map((record) => ({
283
+ doc_id: record.doc_id,
284
+ invoice_no: record.invoice_no,
285
+ issue_date: record.issue_date,
286
+ buyer: record.buyer,
287
+ seller: record.seller,
288
+ amount_cents: record.amount_cents,
289
+ tax_cents: record.tax_cents,
290
+ total_cents: record.total_cents,
291
+ lifecycle: record.lifecycle,
292
+ parser_version: record.parser_version,
293
+ confidence: record.confidence,
294
+ }));
295
+ const nextOffset = offset + pageRows.length;
296
+ return {
297
+ rows: pageRows,
298
+ total,
299
+ next_cursor: nextOffset < total ? String(nextOffset) : '',
300
+ };
301
+ },
302
+ });
303
+ const dedup = defineTool({
304
+ name: 'invoice.dedup',
305
+ description: 'Group parsed invoices by invoice number, or by seller+date+total when the number is absent; returns duplicate groups and their basis. Read-only.',
306
+ parameters: {
307
+ filter: {
308
+ type: 'object',
309
+ additionalProperties: false,
310
+ properties: {
311
+ entity_id: { type: 'string' },
312
+ period: { type: 'string', description: 'YYYY-MM from issue_date' },
313
+ },
314
+ },
315
+ doc_ids: { type: 'array', items: { type: 'string' }, description: 'Optional subset; defaults to the whole pool' },
316
+ },
317
+ output: {
318
+ schema: {
319
+ type: 'object',
320
+ additionalProperties: false,
321
+ properties: {
322
+ groups: {
323
+ type: 'array',
324
+ required: true,
325
+ items: {
326
+ type: 'object',
327
+ additionalProperties: false,
328
+ properties: {
329
+ fingerprint: { type: 'string', required: true },
330
+ kind: { type: 'string', required: true },
331
+ basis: { type: 'string', required: true },
332
+ members: {
333
+ type: 'array',
334
+ required: true,
335
+ items: {
336
+ type: 'object',
337
+ additionalProperties: false,
338
+ properties: {
339
+ doc_id: { type: 'string', required: true },
340
+ invoice_no: { type: 'string', required: true },
341
+ seller_name: { type: 'string', required: true },
342
+ total_cents: { type: 'number', required: true },
343
+ },
344
+ },
345
+ },
346
+ },
347
+ },
348
+ },
349
+ total: { type: 'number', required: true },
350
+ },
351
+ },
352
+ render: (_args, value) => text(`dedup: ${value.total} duplicate groups`),
353
+ },
354
+ async execute(args) {
355
+ const filter = args.filter ?? {};
356
+ const selected = args.doc_ids?.length
357
+ ? args.doc_ids.map((id) => deps.table.get(id)).filter((record) => record !== undefined)
358
+ : [...deps.table.entries()].map(([, record]) => record);
359
+ const groups = new Map();
360
+ for (const record of selected) {
361
+ if (filter.entity_id && record.entity_id !== filter.entity_id)
362
+ continue;
363
+ if (filter.period && invoicePeriod(record.issue_date) !== filter.period)
364
+ continue;
365
+ const byNumber = record.invoice_no !== '';
366
+ const fingerprint = byNumber
367
+ ? `invoice_no:${record.invoice_no}`
368
+ : `seller_date_total:${record.seller.tax_id}:${record.issue_date}:${record.total_cents}`;
369
+ const kind = byNumber ? 'invoice_no' : 'seller_date_total';
370
+ const basis = byNumber ? 'same invoice number' : 'same seller tax id + issue date + total';
371
+ let group = groups.get(fingerprint);
372
+ if (!group) {
373
+ group = { fingerprint, kind, basis, members: [] };
374
+ groups.set(fingerprint, group);
375
+ }
376
+ group.members.push({
377
+ doc_id: record.doc_id,
378
+ invoice_no: record.invoice_no,
379
+ seller_name: record.seller.name,
380
+ total_cents: record.total_cents,
381
+ });
382
+ }
383
+ const duplicateGroups = [...groups.values()].filter((group) => group.members.length > 1);
384
+ return { groups: duplicateGroups, total: duplicateGroups.length };
385
+ },
386
+ });
387
+ const verify = defineTool({
388
+ name: 'invoice.verify',
389
+ description: 'Queue parsed invoices for manual/semi-automatic verification. S2 has no tax-bureau network call: it records a pending local queue and optional entity-tax-id mismatch flags.',
390
+ parameters: {
391
+ invoice_ids: { type: 'array', items: { type: 'string' }, description: 'Optional subset; defaults to parsed invoices' },
392
+ entity_id: { type: 'string' },
393
+ period: { type: 'string', description: 'YYYY-MM from issue_date' },
394
+ entity_tax_id: { type: 'string', description: 'Expected buyer tax id for header validation' },
395
+ limit: { type: 'number', description: '1..200, default 100' },
396
+ },
397
+ output: {
398
+ schema: {
399
+ type: 'object',
400
+ additionalProperties: false,
401
+ properties: {
402
+ queue: {
403
+ type: 'array',
404
+ required: true,
405
+ items: {
406
+ type: 'object',
407
+ additionalProperties: false,
408
+ properties: {
409
+ doc_id: { type: 'string', required: true },
410
+ invoice_no: { type: 'string', required: true },
411
+ seller_name: { type: 'string', required: true },
412
+ total_cents: { type: 'number', required: true },
413
+ state: { type: 'string', required: true },
414
+ entity_match: { type: 'boolean', required: true },
415
+ needs_review: { type: 'array', items: { type: 'string' }, required: true },
416
+ },
417
+ },
418
+ },
419
+ total: { type: 'number', required: true },
420
+ },
421
+ },
422
+ render: (_args, value) => text(`verify queue: ${value.total} pending`),
423
+ },
424
+ async execute(args) {
425
+ const limit = Math.max(1, Math.min(200, Math.floor(args.limit ?? 100)));
426
+ const expected = args.entity_tax_id?.trim().toUpperCase();
427
+ const selected = args.invoice_ids?.length
428
+ ? args.invoice_ids.map((id) => deps.table.get(id)).filter((record) => record !== undefined)
429
+ : [...deps.table.entries()].map(([, record]) => record);
430
+ const queue = [];
431
+ for (const record of selected) {
432
+ if (queue.length >= limit)
433
+ break;
434
+ if (record.lifecycle !== 'parsed' && record.lifecycle !== 'verified')
435
+ continue;
436
+ if (args.entity_id && record.entity_id !== args.entity_id)
437
+ continue;
438
+ if (args.period && invoicePeriod(record.issue_date) !== args.period)
439
+ continue;
440
+ const entityMatch = !expected || record.buyer.tax_id.toUpperCase() === expected;
441
+ const needsReview = [];
442
+ if (!entityMatch)
443
+ needsReview.push('entity_mismatch');
444
+ if (!record.buyer.tax_id)
445
+ needsReview.push('missing_buyer_tax_id');
446
+ const now = new Date().toISOString();
447
+ const updated = {
448
+ ...record,
449
+ verification: {
450
+ state: 'pending',
451
+ queued_at: record.verification?.queued_at ?? now,
452
+ updated_at: now,
453
+ attempts: record.verification?.attempts ?? 0,
454
+ manual_reason: needsReview.join(',') || record.verification?.manual_reason,
455
+ },
456
+ };
457
+ await deps.table.put(record.doc_id, updated);
458
+ queue.push({
459
+ doc_id: record.doc_id,
460
+ invoice_no: record.invoice_no,
461
+ seller_name: record.seller.name,
462
+ total_cents: record.total_cents,
463
+ state: 'pending',
464
+ entity_match: entityMatch,
465
+ needs_review: needsReview,
466
+ });
467
+ }
468
+ return { queue, total: queue.length };
469
+ },
470
+ });
471
+ const receipt = defineTool({
472
+ name: 'invoice.receipt',
473
+ description: 'Record a local verification receipt for one parsed invoice: hash the receipt file, append an immutable receipt record, and move the invoice to verified/quarantined. Local R1 write; no tax-bureau write.',
474
+ parameters: {
475
+ invoice_id: { type: 'string', required: true },
476
+ receipt_file: { type: 'string', required: true, description: 'Local receipt file under an allowed input root' },
477
+ result_code: { type: 'string', required: true },
478
+ result: { type: 'string', enum: ['pass', 'fail', 'unknown'], required: true },
479
+ verified_at: { type: 'string', required: true },
480
+ operator: { type: 'string', required: true },
481
+ },
482
+ output: {
483
+ schema: {
484
+ type: 'object',
485
+ additionalProperties: false,
486
+ properties: {
487
+ receipt_id: { type: 'string', required: true },
488
+ receipt_sha256: { type: 'string', required: true },
489
+ duplicate: { type: 'boolean', required: true },
490
+ lifecycle: { type: 'string', required: true },
491
+ },
492
+ },
493
+ render: (_args, value) => text(`receipt ${value.receipt_id} (${value.duplicate ? 'duplicate' : 'recorded'}), invoice=${value.lifecycle}`),
494
+ },
495
+ async execute(args) {
496
+ const record = deps.table.get(args.invoice_id);
497
+ if (!record)
498
+ throw new InvoiceError('E_INV_DOC_NOT_FOUND', `unknown invoice: ${args.invoice_id}`);
499
+ if (!args.result_code.trim() || !args.verified_at.trim() || !args.operator.trim()) {
500
+ throw new InvoiceError('E_INV_RECEIPT_INCOMPLETE', 'result_code, verified_at and operator are required', { needs_review: true });
501
+ }
502
+ const resolved = await resolveAllowedPath(args.receipt_file, deps.inputRoots);
503
+ const info = await stat(resolved);
504
+ if (!info.isFile() || info.size === 0)
505
+ throw new InvoiceError('E_INV_RECEIPT_INCOMPLETE', 'receipt file is empty or not a file');
506
+ const buffer = await readFile(resolved);
507
+ const receiptSha = sha256Buffer(buffer);
508
+ const existing = (record.receipts ?? []).find((item) => item.receipt_sha256 === receiptSha);
509
+ if (existing) {
510
+ return { receipt_id: existing.receipt_id, receipt_sha256: receiptSha, duplicate: true, lifecycle: record.lifecycle };
511
+ }
512
+ const now = new Date().toISOString();
513
+ const receiptRecord = {
514
+ receipt_id: newDocId(),
515
+ result_code: args.result_code.trim(),
516
+ result: args.result,
517
+ verified_at: args.verified_at.trim(),
518
+ operator: args.operator.trim(),
519
+ receipt_sha256: receiptSha,
520
+ receipt_path: resolved,
521
+ recorded_at: now,
522
+ };
523
+ const lifecycle = args.result === 'pass' ? 'verified' : args.result === 'fail' ? 'quarantined' : 'parsed';
524
+ const updated = {
525
+ ...record,
526
+ lifecycle,
527
+ receipts: [...(record.receipts ?? []), receiptRecord],
528
+ verification: {
529
+ state: args.result === 'pass' ? 'verified' : args.result === 'fail' ? 'failed' : 'manual',
530
+ queued_at: record.verification?.queued_at,
531
+ updated_at: now,
532
+ attempts: record.verification?.attempts ?? 0,
533
+ last_result: args.result_code.trim(),
534
+ manual_reason: args.result === 'unknown' ? 'unknown_result' : undefined,
535
+ },
536
+ updated_at: now,
537
+ };
538
+ await deps.table.put(record.doc_id, updated);
539
+ return { receipt_id: receiptRecord.receipt_id, receipt_sha256: receiptSha, duplicate: false, lifecycle };
540
+ },
541
+ });
542
+ return [ingest, parse, query, dedup, verify, receipt];
543
+ }
@@ -0,0 +1,5 @@
1
+ /** Frozen parser registry: exactly one S1 adapter. */
2
+ import { type ParserAdapter } from '../adapters/ofd-zip-xml.js';
3
+ export declare const DEFAULT_PARSER_ID: string;
4
+ export declare function getParser(id?: string): ParserAdapter;
5
+ export declare function listParsers(): readonly string[];
@@ -0,0 +1,20 @@
1
+ /** Frozen parser registry: exactly one S1 adapter. */
2
+ import { ofdZipXmlParser } from '../adapters/ofd-zip-xml.js';
3
+ import { InvoiceError } from '../core/invoice.js';
4
+ const registry = new Map();
5
+ for (const parser of [ofdZipXmlParser]) {
6
+ if (registry.has(parser.id))
7
+ throw new Error(`duplicate parser id: ${parser.id}`);
8
+ registry.set(parser.id, parser);
9
+ }
10
+ Object.freeze([...registry.keys()]);
11
+ export const DEFAULT_PARSER_ID = ofdZipXmlParser.id;
12
+ export function getParser(id = DEFAULT_PARSER_ID) {
13
+ const parser = registry.get(id);
14
+ if (!parser)
15
+ throw new InvoiceError('E_INV_UNKNOWN_IMPL', `unknown parser implementation: ${id}`);
16
+ return parser;
17
+ }
18
+ export function listParsers() {
19
+ return [...registry.keys()];
20
+ }
package/package.json CHANGED
@@ -1,6 +1,84 @@
1
1
  {
2
2
  "name": "@perrylink/dsh-einvoice",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0-alpha.1",
4
+ "description": "File-level Chinese e-invoice (OFD/XML) ingest, parse and pool for DeepSeek Harness (SKU-04 S1).",
5
+ "type": "module",
6
+ "main": "./lib/plugin/index.js",
7
+ "types": "./lib/plugin/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./lib/plugin/index.d.ts",
11
+ "default": "./lib/plugin/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "lib",
16
+ "cordis.patch.yml",
17
+ "README.md",
18
+ "LICENSE",
19
+ "NOTICE",
20
+ "THIRD-PARTY-NOTICES"
21
+ ],
22
+ "dsh": {
23
+ "bundle": {
24
+ "patch": "./cordis.patch.yml"
25
+ }
26
+ },
27
+ "engines": {
28
+ "dsh": ">=0.2.1-rc.3 <0.3.0",
29
+ "node": ">=24.9.0 <25"
30
+ },
31
+ "packageManager": "pnpm@11.28.5",
32
+ "scripts": {
33
+ "build": "tsc -p tsconfig.json",
34
+ "lint": "node scripts/lint.mjs",
35
+ "typecheck": "tsc -p tsconfig.json --noEmit",
36
+ "test": "pnpm build && node --test test/*.test.mjs",
37
+ "gate:sku04": "node scripts/gate-sku04.mjs",
38
+ "prepublishOnly": "node scripts/check-npm-name.mjs",
39
+ "pack:check": "npm pack --dry-run --json > pack.json && node scripts/check-pack.mjs"
40
+ },
41
+ "keywords": [
42
+ "deepseek-harness",
43
+ "dsh",
44
+ "dsh-plugin",
45
+ "einvoice",
46
+ "invoice",
47
+ "ofd",
48
+ "perrylink"
49
+ ],
50
+ "repository": {
51
+ "type": "git",
52
+ "url": "git+https://github.com/PerryLink/dsh-einvoice.git"
53
+ },
54
+ "license": "Apache-2.0",
55
+ "publishConfig": {
56
+ "access": "public"
57
+ },
58
+ "peerDependencies": {
59
+ "@deepseek-ai/cordis": ">=4.0.5 <5",
60
+ "@deepseek-ai/dsh-storage-domain": ">=0.2.1-rc.3 <0.3.0",
61
+ "@deepseek-ai/dsh-tools": ">=0.2.1-rc.3 <0.3.0"
62
+ },
63
+ "dependencies": {
64
+ "fast-xml-parser": "5.11.2",
65
+ "fflate": "0.8.3",
66
+ "zod": "4.4.3"
67
+ },
68
+ "devDependencies": {
69
+ "@deepseek-ai/cordis": "4.0.5",
70
+ "@deepseek-ai/dsh-agent": "0.2.1-rc.3",
71
+ "@deepseek-ai/dsh-llm": "0.2.1-rc.3",
72
+ "@deepseek-ai/dsh-scope": "0.2.1-rc.3",
73
+ "@deepseek-ai/dsh-session": "0.2.1-rc.3",
74
+ "@deepseek-ai/dsh-storage": "0.2.1-rc.3",
75
+ "@deepseek-ai/dsh-storage-domain": "0.2.1-rc.3",
76
+ "@deepseek-ai/dsh-system-prompt": "0.2.1-rc.3",
77
+ "@deepseek-ai/dsh-tools": "0.2.1-rc.3",
78
+ "@deepseek-ai/dsh-user-approval": "0.2.1-rc.3",
79
+ "@deepseek-ai/dsh-util-values": "0.2.1-rc.3",
80
+ "@deepseek-ai/schemastery": "3.18.5",
81
+ "@types/node": "^24.0.0",
82
+ "typescript": "5.9.3"
83
+ }
84
+ }