@perrylink/dsh-einvoice 0.1.0-alpha.0 → 0.1.0-alpha.2

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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  File-level Chinese e-invoice ingest, parse and pool for DeepSeek Harness (SKU-04 S1).
4
4
 
5
- > **Status: S1 / public demo.** The customer evidence gate (2 enterprises + 1 bookkeeping firm + 100 real invoices) is still PARKED. S1 uses public/open-source and synthetic fixtures; its acceptance rows are `not counted` toward GA. Do not describe this package as GA-ready.
5
+ > **Status: S1+S2+S3 / public demo (0.1.0-alpha.2).** The customer evidence gate (2 enterprises + 1 bookkeeping firm + 100 real invoices) is still PARKED. S1 uses public/open-source and synthetic fixtures; its acceptance rows are `not counted` toward GA. Do not describe this package as GA-ready.
6
6
 
7
7
  ## Install
8
8
 
@@ -20,8 +20,15 @@ The bundle inserts one Host row. It requires the base profile's `tools` and `sto
20
20
  | `invoice.ingest` | Ingest one local OFD/XML file, compute SHA-256, store the original locally, idempotent by bytes | R1 local write |
21
21
  | `invoice.parse` | Parse old OFD `Attachs/original_invoice.xml` or 数电票 `Tags/CustomTag.xml` + ObjectRef; return eight fields, lines, evidence and unknown fields | R0 |
22
22
  | `invoice.query` | Query the invoice pool by entity, period, lifecycle or text | R0 |
23
-
24
- S2/S3 add dedup, verification/receipt, red/blue matching, archive packages and archive checks; S1 does not register them.
23
+ | `invoice.dedup` | Group duplicates by invoice number or seller+date+total | R0 |
24
+ | `invoice.verify` | Queue parsed invoices for manual verification; optional buyer-tax-id mismatch flags | R1 local write |
25
+ | `invoice.receipt` | Hash a local receipt, append it, move lifecycle to verified/quarantined; idempotent by receipt bytes | R1 local write |
26
+ | `invoice.redlink` | Bind a negative red invoice to its blue invoice; store deltas | R1 local write |
27
+ | `invoice.archive` | Build a manifest + originals + receipts ZIP under the local archive directory | R1 local write |
28
+ | `invoice.check` | Verify manifest/member hashes, missing files and traversal | R0 |
29
+ | `invoice.restore` | Restore a verified archive into the local restore root | R1 local write |
30
+
31
+ S3 adds red/blue matching, deterministic archive packages, check and restore. S2 verification is manual/local (no tax-bureau network call).
25
32
 
26
33
  ## Configuration
27
34
 
@@ -15,6 +15,45 @@ export declare const lineSchema: z.ZodObject<{
15
15
  tax_rate: z.ZodOptional<z.ZodString>;
16
16
  tax_cents: z.ZodOptional<z.ZodNumber>;
17
17
  }, z.core.$strip>;
18
+ export declare const verificationSchema: z.ZodObject<{
19
+ state: z.ZodEnum<{
20
+ verified: "verified";
21
+ pending: "pending";
22
+ failed: "failed";
23
+ manual: "manual";
24
+ }>;
25
+ queued_at: z.ZodOptional<z.ZodString>;
26
+ updated_at: z.ZodOptional<z.ZodString>;
27
+ attempts: z.ZodDefault<z.ZodNumber>;
28
+ last_result: z.ZodOptional<z.ZodString>;
29
+ manual_reason: z.ZodOptional<z.ZodString>;
30
+ }, z.core.$strip>;
31
+ export declare const receiptSchema: z.ZodObject<{
32
+ receipt_id: z.ZodString;
33
+ result_code: z.ZodString;
34
+ result: z.ZodEnum<{
35
+ pass: "pass";
36
+ fail: "fail";
37
+ unknown: "unknown";
38
+ }>;
39
+ verified_at: z.ZodString;
40
+ operator: z.ZodString;
41
+ receipt_sha256: z.ZodString;
42
+ receipt_path: z.ZodString;
43
+ recorded_at: z.ZodString;
44
+ }, z.core.$strip>;
45
+ export declare const redlinkSchema: z.ZodObject<{
46
+ blue_id: z.ZodString;
47
+ mode: z.ZodEnum<{
48
+ manual: "manual";
49
+ auto: "auto";
50
+ }>;
51
+ amount_delta_cents: z.ZodNumber;
52
+ tax_delta_cents: z.ZodNumber;
53
+ allocation_cents: z.ZodOptional<z.ZodNumber>;
54
+ bound_at: z.ZodString;
55
+ bound_by: z.ZodString;
56
+ }, z.core.$strip>;
18
57
  export declare const invoiceSchema: z.ZodObject<{
19
58
  doc_id: z.ZodString;
20
59
  entity_id: z.ZodString;
@@ -53,7 +92,47 @@ export declare const invoiceSchema: z.ZodObject<{
53
92
  archived: "archived";
54
93
  quarantined: "quarantined";
55
94
  }>;
95
+ verification: z.ZodOptional<z.ZodObject<{
96
+ state: z.ZodEnum<{
97
+ verified: "verified";
98
+ pending: "pending";
99
+ failed: "failed";
100
+ manual: "manual";
101
+ }>;
102
+ queued_at: z.ZodOptional<z.ZodString>;
103
+ updated_at: z.ZodOptional<z.ZodString>;
104
+ attempts: z.ZodDefault<z.ZodNumber>;
105
+ last_result: z.ZodOptional<z.ZodString>;
106
+ manual_reason: z.ZodOptional<z.ZodString>;
107
+ }, z.core.$strip>>;
108
+ receipts: z.ZodOptional<z.ZodArray<z.ZodObject<{
109
+ receipt_id: z.ZodString;
110
+ result_code: z.ZodString;
111
+ result: z.ZodEnum<{
112
+ pass: "pass";
113
+ fail: "fail";
114
+ unknown: "unknown";
115
+ }>;
116
+ verified_at: z.ZodString;
117
+ operator: z.ZodString;
118
+ receipt_sha256: z.ZodString;
119
+ receipt_path: z.ZodString;
120
+ recorded_at: z.ZodString;
121
+ }, z.core.$strip>>>;
122
+ redlink: z.ZodOptional<z.ZodObject<{
123
+ blue_id: z.ZodString;
124
+ mode: z.ZodEnum<{
125
+ manual: "manual";
126
+ auto: "auto";
127
+ }>;
128
+ amount_delta_cents: z.ZodNumber;
129
+ tax_delta_cents: z.ZodNumber;
130
+ allocation_cents: z.ZodOptional<z.ZodNumber>;
131
+ bound_at: z.ZodString;
132
+ bound_by: z.ZodString;
133
+ }, z.core.$strip>>;
56
134
  source_path: z.ZodString;
135
+ source_name: z.ZodOptional<z.ZodString>;
57
136
  source_sha256: z.ZodString;
58
137
  source_size: z.ZodNumber;
59
138
  created_at: z.ZodString;
@@ -116,6 +195,34 @@ export declare const finInvoiceSpec: {
116
195
  actor: string;
117
196
  evidence_sha256: string;
118
197
  invoice_code?: string | undefined;
198
+ verification?: {
199
+ state: "verified" | "pending" | "failed" | "manual";
200
+ attempts: number;
201
+ queued_at?: string | undefined;
202
+ updated_at?: string | undefined;
203
+ last_result?: string | undefined;
204
+ manual_reason?: string | undefined;
205
+ } | undefined;
206
+ receipts?: {
207
+ receipt_id: string;
208
+ result_code: string;
209
+ result: "pass" | "fail" | "unknown";
210
+ verified_at: string;
211
+ operator: string;
212
+ receipt_sha256: string;
213
+ receipt_path: string;
214
+ recorded_at: string;
215
+ }[] | undefined;
216
+ redlink?: {
217
+ blue_id: string;
218
+ mode: "manual" | "auto";
219
+ amount_delta_cents: number;
220
+ tax_delta_cents: number;
221
+ bound_at: string;
222
+ bound_by: string;
223
+ allocation_cents?: number | undefined;
224
+ } | undefined;
225
+ source_name?: string | undefined;
119
226
  }>;
120
227
  };
121
228
  };
@@ -132,6 +239,7 @@ export declare function emptyInvoiceRecord(input: {
132
239
  docId: string;
133
240
  entityId: string;
134
241
  sourcePath: string;
242
+ sourceName: string;
135
243
  sha256: string;
136
244
  size: number;
137
245
  runId: string;
@@ -19,6 +19,33 @@ export const lineSchema = z.object({
19
19
  tax_rate: z.string().optional(),
20
20
  tax_cents: z.number().int().optional(),
21
21
  });
22
+ export const verificationSchema = z.object({
23
+ state: z.enum(['pending', 'verified', 'failed', 'manual']),
24
+ queued_at: z.string().optional(),
25
+ updated_at: z.string().optional(),
26
+ attempts: z.number().int().nonnegative().default(0),
27
+ last_result: z.string().optional(),
28
+ manual_reason: z.string().optional(),
29
+ });
30
+ export const receiptSchema = z.object({
31
+ receipt_id: z.string(),
32
+ result_code: z.string(),
33
+ result: z.enum(['pass', 'fail', 'unknown']),
34
+ verified_at: z.string(),
35
+ operator: z.string(),
36
+ receipt_sha256: z.string(),
37
+ receipt_path: z.string(),
38
+ recorded_at: z.string(),
39
+ });
40
+ export const redlinkSchema = z.object({
41
+ blue_id: z.string(),
42
+ mode: z.enum(['auto', 'manual']),
43
+ amount_delta_cents: z.number().int(),
44
+ tax_delta_cents: z.number().int(),
45
+ allocation_cents: z.number().int().optional(),
46
+ bound_at: z.string(),
47
+ bound_by: z.string(),
48
+ });
22
49
  export const invoiceSchema = z.object({
23
50
  doc_id: z.string().min(1),
24
51
  entity_id: z.string(),
@@ -37,7 +64,11 @@ export const invoiceSchema = z.object({
37
64
  unknown_fields: z.record(z.string(), z.unknown()),
38
65
  parser_version: z.string(),
39
66
  lifecycle: z.enum(['received', 'parsed', 'verified', 'archived', 'quarantined']),
67
+ verification: verificationSchema.optional(),
68
+ receipts: z.array(receiptSchema).optional(),
69
+ redlink: redlinkSchema.optional(),
40
70
  source_path: z.string(),
71
+ source_name: z.string().optional(),
41
72
  source_sha256: z.string(),
42
73
  source_size: z.number().int().nonnegative(),
43
74
  created_at: z.string(),
@@ -154,6 +185,7 @@ export function emptyInvoiceRecord(input) {
154
185
  parser_version: '',
155
186
  lifecycle: 'received',
156
187
  source_path: input.sourcePath,
188
+ source_name: input.sourceName,
157
189
  source_sha256: input.sha256,
158
190
  source_size: input.size,
159
191
  created_at: now,
@@ -1,8 +1,9 @@
1
1
  /** Three S1 model-facing tools: ingest, parse, query. */
2
2
  import { createHash } from 'node:crypto';
3
- import { readFile, realpath, stat } from 'node:fs/promises';
3
+ import { mkdir, readFile, realpath, stat, writeFile } from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import { defineTool } from '@deepseek-ai/dsh-tools';
6
+ import { unzipSync, zipSync } from 'fflate';
6
7
  import { InvoiceError, assertEightFields, canonicalJson, invoicePeriod, } from '../core/invoice.js';
7
8
  import { getParser } from '../registry/parsers.js';
8
9
  import { assertUniqueInvoice, emptyInvoiceRecord, findBySha, newDocId, recordLines, sha256Buffer, storeOriginal, } from './storage.js';
@@ -81,6 +82,68 @@ function parsedToRecord(record, parsed) {
81
82
  evidence_sha256: createHash('sha256').update(canonicalJson(parsed)).digest('hex'),
82
83
  };
83
84
  }
85
+ function safeZipPath(name) {
86
+ return !name.startsWith('/') && !name.split('/').includes('..') && !name.includes('\\0');
87
+ }
88
+ function archivePkgId(manifest) {
89
+ const base = { ...manifest, pkg_id: undefined };
90
+ return `pkg_${createHash('sha256').update(canonicalJson(base)).digest('hex').slice(0, 16)}`;
91
+ }
92
+ function parseArchive(buffer) {
93
+ let files;
94
+ try {
95
+ files = unzipSync(buffer);
96
+ }
97
+ catch {
98
+ throw new InvoiceError('E_INV_ARCHIVE_INVALID', 'archive is not a readable ZIP');
99
+ }
100
+ const raw = files['manifest.json'];
101
+ if (!raw)
102
+ throw new InvoiceError('E_INV_ARCHIVE_INVALID', 'archive has no manifest.json');
103
+ try {
104
+ const manifest = JSON.parse(Buffer.from(raw).toString('utf8'));
105
+ if (!manifest || manifest.schema_version !== 1 || !Array.isArray(manifest.members)) {
106
+ throw new Error('bad manifest');
107
+ }
108
+ return { files, manifest };
109
+ }
110
+ catch {
111
+ throw new InvoiceError('E_INV_ARCHIVE_INVALID', 'archive manifest is not valid v1 JSON');
112
+ }
113
+ }
114
+ function verifyArchive(buffer) {
115
+ const { files, manifest } = parseArchive(buffer);
116
+ const expectedPkgId = archivePkgId(manifest);
117
+ const missing = [];
118
+ const hashMismatch = [];
119
+ const dirViolation = [];
120
+ if (manifest.pkg_id !== expectedPkgId)
121
+ hashMismatch.push('manifest:pkg_id');
122
+ for (const member of manifest.members) {
123
+ for (const file of member.files ?? []) {
124
+ if (!safeZipPath(file.path)) {
125
+ dirViolation.push(file.path);
126
+ continue;
127
+ }
128
+ const data = files[file.path];
129
+ if (!data) {
130
+ missing.push(file.path);
131
+ continue;
132
+ }
133
+ if (sha256Buffer(Buffer.from(data)) !== file.sha256)
134
+ hashMismatch.push(file.path);
135
+ }
136
+ }
137
+ return {
138
+ manifest,
139
+ pkg_id: manifest.pkg_id,
140
+ missing,
141
+ hash_mismatch: hashMismatch,
142
+ dir_violation: dirViolation,
143
+ file_count: Object.keys(files).length,
144
+ signable: missing.length === 0 && hashMismatch.length === 0 && dirViolation.length === 0,
145
+ };
146
+ }
84
147
  export function createTools(deps) {
85
148
  const ingest = defineTool({
86
149
  name: 'invoice.ingest',
@@ -135,6 +198,7 @@ export function createTools(deps) {
135
198
  docId,
136
199
  entityId,
137
200
  sourcePath: storedPath,
201
+ sourceName: path.basename(resolved),
138
202
  sha256,
139
203
  size: info.size,
140
204
  runId: runIdOf(exec),
@@ -182,7 +246,7 @@ export function createTools(deps) {
182
246
  throw new InvoiceError('E_INV_DOC_NOT_FOUND', `unknown doc_id: ${args.doc_id}`);
183
247
  const buffer = await readFile(record.source_path);
184
248
  const parser = getParser(args.profile);
185
- const parsed = parser.parse({ buffer, fileName: path.basename(record.source_path) });
249
+ const parsed = parser.parse({ buffer, fileName: record.source_name ?? path.basename(record.source_path) });
186
250
  assertEightFields(parsed);
187
251
  assertUniqueInvoice(deps.table, record.entity_id, parsed.invoice_no, record.doc_id);
188
252
  const updated = parsedToRecord(record, parsed);
@@ -300,5 +364,532 @@ export function createTools(deps) {
300
364
  };
301
365
  },
302
366
  });
303
- return [ingest, parse, query];
367
+ const dedup = defineTool({
368
+ name: 'invoice.dedup',
369
+ 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.',
370
+ parameters: {
371
+ filter: {
372
+ type: 'object',
373
+ additionalProperties: false,
374
+ properties: {
375
+ entity_id: { type: 'string' },
376
+ period: { type: 'string', description: 'YYYY-MM from issue_date' },
377
+ },
378
+ },
379
+ doc_ids: { type: 'array', items: { type: 'string' }, description: 'Optional subset; defaults to the whole pool' },
380
+ },
381
+ output: {
382
+ schema: {
383
+ type: 'object',
384
+ additionalProperties: false,
385
+ properties: {
386
+ groups: {
387
+ type: 'array',
388
+ required: true,
389
+ items: {
390
+ type: 'object',
391
+ additionalProperties: false,
392
+ properties: {
393
+ fingerprint: { type: 'string', required: true },
394
+ kind: { type: 'string', required: true },
395
+ basis: { type: 'string', required: true },
396
+ members: {
397
+ type: 'array',
398
+ required: true,
399
+ items: {
400
+ type: 'object',
401
+ additionalProperties: false,
402
+ properties: {
403
+ doc_id: { type: 'string', required: true },
404
+ invoice_no: { type: 'string', required: true },
405
+ seller_name: { type: 'string', required: true },
406
+ total_cents: { type: 'number', required: true },
407
+ },
408
+ },
409
+ },
410
+ },
411
+ },
412
+ },
413
+ total: { type: 'number', required: true },
414
+ },
415
+ },
416
+ render: (_args, value) => text(`dedup: ${value.total} duplicate groups`),
417
+ },
418
+ async execute(args) {
419
+ const filter = args.filter ?? {};
420
+ const selected = args.doc_ids?.length
421
+ ? args.doc_ids.map((id) => deps.table.get(id)).filter((record) => record !== undefined)
422
+ : [...deps.table.entries()].map(([, record]) => record);
423
+ const groups = new Map();
424
+ for (const record of selected) {
425
+ if (filter.entity_id && record.entity_id !== filter.entity_id)
426
+ continue;
427
+ if (filter.period && invoicePeriod(record.issue_date) !== filter.period)
428
+ continue;
429
+ const byNumber = record.invoice_no !== '';
430
+ const fingerprint = byNumber
431
+ ? `invoice_no:${record.invoice_no}`
432
+ : `seller_date_total:${record.seller.tax_id}:${record.issue_date}:${record.total_cents}`;
433
+ const kind = byNumber ? 'invoice_no' : 'seller_date_total';
434
+ const basis = byNumber ? 'same invoice number' : 'same seller tax id + issue date + total';
435
+ let group = groups.get(fingerprint);
436
+ if (!group) {
437
+ group = { fingerprint, kind, basis, members: [] };
438
+ groups.set(fingerprint, group);
439
+ }
440
+ group.members.push({
441
+ doc_id: record.doc_id,
442
+ invoice_no: record.invoice_no,
443
+ seller_name: record.seller.name,
444
+ total_cents: record.total_cents,
445
+ });
446
+ }
447
+ const duplicateGroups = [...groups.values()].filter((group) => group.members.length > 1);
448
+ return { groups: duplicateGroups, total: duplicateGroups.length };
449
+ },
450
+ });
451
+ const verify = defineTool({
452
+ name: 'invoice.verify',
453
+ 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.',
454
+ parameters: {
455
+ invoice_ids: { type: 'array', items: { type: 'string' }, description: 'Optional subset; defaults to parsed invoices' },
456
+ entity_id: { type: 'string' },
457
+ period: { type: 'string', description: 'YYYY-MM from issue_date' },
458
+ entity_tax_id: { type: 'string', description: 'Expected buyer tax id for header validation' },
459
+ limit: { type: 'number', description: '1..200, default 100' },
460
+ },
461
+ output: {
462
+ schema: {
463
+ type: 'object',
464
+ additionalProperties: false,
465
+ properties: {
466
+ queue: {
467
+ type: 'array',
468
+ required: true,
469
+ items: {
470
+ type: 'object',
471
+ additionalProperties: false,
472
+ properties: {
473
+ doc_id: { type: 'string', required: true },
474
+ invoice_no: { type: 'string', required: true },
475
+ seller_name: { type: 'string', required: true },
476
+ total_cents: { type: 'number', required: true },
477
+ state: { type: 'string', required: true },
478
+ entity_match: { type: 'boolean', required: true },
479
+ needs_review: { type: 'array', items: { type: 'string' }, required: true },
480
+ },
481
+ },
482
+ },
483
+ total: { type: 'number', required: true },
484
+ },
485
+ },
486
+ render: (_args, value) => text(`verify queue: ${value.total} pending`),
487
+ },
488
+ async execute(args) {
489
+ const limit = Math.max(1, Math.min(200, Math.floor(args.limit ?? 100)));
490
+ const expected = args.entity_tax_id?.trim().toUpperCase();
491
+ const selected = args.invoice_ids?.length
492
+ ? args.invoice_ids.map((id) => deps.table.get(id)).filter((record) => record !== undefined)
493
+ : [...deps.table.entries()].map(([, record]) => record);
494
+ const queue = [];
495
+ for (const record of selected) {
496
+ if (queue.length >= limit)
497
+ break;
498
+ if (record.lifecycle !== 'parsed' && record.lifecycle !== 'verified')
499
+ continue;
500
+ if (args.entity_id && record.entity_id !== args.entity_id)
501
+ continue;
502
+ if (args.period && invoicePeriod(record.issue_date) !== args.period)
503
+ continue;
504
+ const entityMatch = !expected || record.buyer.tax_id.toUpperCase() === expected;
505
+ const needsReview = [];
506
+ if (!entityMatch)
507
+ needsReview.push('entity_mismatch');
508
+ if (!record.buyer.tax_id)
509
+ needsReview.push('missing_buyer_tax_id');
510
+ const now = new Date().toISOString();
511
+ const updated = {
512
+ ...record,
513
+ verification: {
514
+ state: 'pending',
515
+ queued_at: record.verification?.queued_at ?? now,
516
+ updated_at: now,
517
+ attempts: record.verification?.attempts ?? 0,
518
+ manual_reason: needsReview.join(',') || record.verification?.manual_reason,
519
+ },
520
+ };
521
+ await deps.table.put(record.doc_id, updated);
522
+ queue.push({
523
+ doc_id: record.doc_id,
524
+ invoice_no: record.invoice_no,
525
+ seller_name: record.seller.name,
526
+ total_cents: record.total_cents,
527
+ state: 'pending',
528
+ entity_match: entityMatch,
529
+ needs_review: needsReview,
530
+ });
531
+ }
532
+ return { queue, total: queue.length };
533
+ },
534
+ });
535
+ const receipt = defineTool({
536
+ name: 'invoice.receipt',
537
+ 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.',
538
+ parameters: {
539
+ invoice_id: { type: 'string', required: true },
540
+ receipt_file: { type: 'string', required: true, description: 'Local receipt file under an allowed input root' },
541
+ result_code: { type: 'string', required: true },
542
+ result: { type: 'string', enum: ['pass', 'fail', 'unknown'], required: true },
543
+ verified_at: { type: 'string', required: true },
544
+ operator: { type: 'string', required: true },
545
+ },
546
+ output: {
547
+ schema: {
548
+ type: 'object',
549
+ additionalProperties: false,
550
+ properties: {
551
+ receipt_id: { type: 'string', required: true },
552
+ receipt_sha256: { type: 'string', required: true },
553
+ duplicate: { type: 'boolean', required: true },
554
+ lifecycle: { type: 'string', required: true },
555
+ },
556
+ },
557
+ render: (_args, value) => text(`receipt ${value.receipt_id} (${value.duplicate ? 'duplicate' : 'recorded'}), invoice=${value.lifecycle}`),
558
+ },
559
+ async execute(args) {
560
+ const record = deps.table.get(args.invoice_id);
561
+ if (!record)
562
+ throw new InvoiceError('E_INV_DOC_NOT_FOUND', `unknown invoice: ${args.invoice_id}`);
563
+ if (!args.result_code.trim() || !args.verified_at.trim() || !args.operator.trim()) {
564
+ throw new InvoiceError('E_INV_RECEIPT_INCOMPLETE', 'result_code, verified_at and operator are required', { needs_review: true });
565
+ }
566
+ const resolved = await resolveAllowedPath(args.receipt_file, deps.inputRoots);
567
+ const info = await stat(resolved);
568
+ if (!info.isFile() || info.size === 0)
569
+ throw new InvoiceError('E_INV_RECEIPT_INCOMPLETE', 'receipt file is empty or not a file');
570
+ const buffer = await readFile(resolved);
571
+ const receiptSha = sha256Buffer(buffer);
572
+ const existing = (record.receipts ?? []).find((item) => item.receipt_sha256 === receiptSha);
573
+ if (existing) {
574
+ return { receipt_id: existing.receipt_id, receipt_sha256: receiptSha, duplicate: true, lifecycle: record.lifecycle };
575
+ }
576
+ const now = new Date().toISOString();
577
+ const receiptRecord = {
578
+ receipt_id: newDocId(),
579
+ result_code: args.result_code.trim(),
580
+ result: args.result,
581
+ verified_at: args.verified_at.trim(),
582
+ operator: args.operator.trim(),
583
+ receipt_sha256: receiptSha,
584
+ receipt_path: resolved,
585
+ recorded_at: now,
586
+ };
587
+ const lifecycle = args.result === 'pass' ? 'verified' : args.result === 'fail' ? 'quarantined' : 'parsed';
588
+ const updated = {
589
+ ...record,
590
+ lifecycle,
591
+ receipts: [...(record.receipts ?? []), receiptRecord],
592
+ verification: {
593
+ state: args.result === 'pass' ? 'verified' : args.result === 'fail' ? 'failed' : 'manual',
594
+ queued_at: record.verification?.queued_at,
595
+ updated_at: now,
596
+ attempts: record.verification?.attempts ?? 0,
597
+ last_result: args.result_code.trim(),
598
+ manual_reason: args.result === 'unknown' ? 'unknown_result' : undefined,
599
+ },
600
+ updated_at: now,
601
+ };
602
+ await deps.table.put(record.doc_id, updated);
603
+ return { receipt_id: receiptRecord.receipt_id, receipt_sha256: receiptSha, duplicate: false, lifecycle };
604
+ },
605
+ });
606
+ const archiveDir = path.join(deps.baseDir, 'archives');
607
+ const restoreRoot = path.join(deps.baseDir, 'restore');
608
+ async function resolveArchiveFile(args) {
609
+ if (args.path) {
610
+ let resolved;
611
+ try {
612
+ resolved = await realpath(path.resolve(args.path));
613
+ }
614
+ catch {
615
+ throw new InvoiceError('E_INV_ARCHIVE_NOT_FOUND', `archive not found: ${args.path}`);
616
+ }
617
+ const rel = path.relative(archiveDir, resolved);
618
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
619
+ throw new InvoiceError('E_INV_PATH_NOT_ALLOWED', `archive is outside the archive directory: ${args.path}`);
620
+ }
621
+ return resolved;
622
+ }
623
+ if (!args.pkg_id || !/^pkg_[0-9a-f]{16}$/.test(args.pkg_id)) {
624
+ throw new InvoiceError('E_INV_ARCHIVE_INVALID', 'pkg_id or path is required');
625
+ }
626
+ const candidate = path.join(archiveDir, `${args.pkg_id}.zip`);
627
+ try {
628
+ await stat(candidate);
629
+ }
630
+ catch {
631
+ throw new InvoiceError('E_INV_ARCHIVE_NOT_FOUND', `archive not found: ${args.pkg_id}`);
632
+ }
633
+ return candidate;
634
+ }
635
+ const redlink = defineTool({
636
+ name: 'invoice.redlink',
637
+ description: 'Bind a negative red invoice to a positive blue invoice by seller and total, store the binding and deltas. Local R1 write; no tax-bureau call.',
638
+ parameters: {
639
+ red_id: { type: 'string', required: true },
640
+ blue_id: { type: 'string' },
641
+ mode: { type: 'string', enum: ['auto', 'manual'] },
642
+ allocation_cents: { type: 'number', description: 'Positive allocation; default is the absolute red total' },
643
+ bound_by: { type: 'string' },
644
+ },
645
+ output: {
646
+ schema: {
647
+ type: 'object',
648
+ additionalProperties: false,
649
+ properties: {
650
+ red_id: { type: 'string', required: true },
651
+ blue_id: { type: 'string', required: true },
652
+ mode: { type: 'string', required: true },
653
+ amount_delta_cents: { type: 'number', required: true },
654
+ tax_delta_cents: { type: 'number', required: true },
655
+ },
656
+ },
657
+ render: (_args, value) => text(`redlink ${value.red_id} -> ${value.blue_id}, delta=${value.amount_delta_cents}`),
658
+ },
659
+ async execute(args) {
660
+ const red = deps.table.get(args.red_id);
661
+ if (!red)
662
+ throw new InvoiceError('E_INV_DOC_NOT_FOUND', `unknown red invoice: ${args.red_id}`);
663
+ if (red.total_cents >= 0)
664
+ throw new InvoiceError('E_INV_REDLINK_UNBOUND', 'red invoice must have a negative total');
665
+ const mode = args.mode ?? 'auto';
666
+ const blueId = args.blue_id ?? [...deps.table.entries()]
667
+ .map(([, record]) => record)
668
+ .find((record) => record.doc_id !== red.doc_id && record.seller.tax_id === red.seller.tax_id && record.total_cents === -red.total_cents)?.doc_id;
669
+ if (!blueId)
670
+ throw new InvoiceError('E_INV_REDLINK_UNBOUND', 'no matching blue invoice found');
671
+ if (red.redlink && red.redlink.blue_id !== blueId) {
672
+ throw new InvoiceError('E_INV_REDLINK_BOUND', `red invoice is already bound to ${red.redlink.blue_id}`);
673
+ }
674
+ const blue = deps.table.get(blueId);
675
+ if (!blue)
676
+ throw new InvoiceError('E_INV_DOC_NOT_FOUND', `unknown blue invoice: ${blueId}`);
677
+ const allocation = args.allocation_cents ?? -red.total_cents;
678
+ if (!Number.isInteger(allocation) || allocation <= 0)
679
+ throw new InvoiceError('E_INV_REDLINK_UNBOUND', 'allocation must be a positive integer');
680
+ if (allocation > blue.total_cents)
681
+ throw new InvoiceError('E_INV_REDLINK_OVER', 'red allocation exceeds the blue invoice total');
682
+ const amountDelta = blue.total_cents + red.total_cents;
683
+ const taxDelta = blue.tax_cents + red.tax_cents;
684
+ const now = new Date().toISOString();
685
+ const updated = {
686
+ ...red,
687
+ redlink: {
688
+ blue_id: blueId,
689
+ mode,
690
+ amount_delta_cents: amountDelta,
691
+ tax_delta_cents: taxDelta,
692
+ allocation_cents: allocation,
693
+ bound_at: now,
694
+ bound_by: args.bound_by?.trim() || 'invoice.redlink',
695
+ },
696
+ updated_at: now,
697
+ };
698
+ await deps.table.put(red.doc_id, updated);
699
+ return { red_id: red.doc_id, blue_id: blueId, mode, amount_delta_cents: amountDelta, tax_delta_cents: taxDelta };
700
+ },
701
+ });
702
+ const archive = defineTool({
703
+ name: 'invoice.archive',
704
+ description: 'Build a local deterministic invoice archive ZIP with manifest.json, original files and receipts; hash the manifest and verify the result. Local R1 write; no signing.',
705
+ parameters: {
706
+ entity_id: { type: 'string' },
707
+ period: { type: 'string', description: 'YYYY-MM from issue_date' },
708
+ template_id: { type: 'string', description: 'default-v1' },
709
+ include_receipts: { type: 'boolean' },
710
+ },
711
+ output: {
712
+ schema: {
713
+ type: 'object',
714
+ additionalProperties: false,
715
+ properties: {
716
+ pkg_id: { type: 'string', required: true },
717
+ manifest_sha256: { type: 'string', required: true },
718
+ zip_path: { type: 'string', required: true },
719
+ file_count: { type: 'number', required: true },
720
+ check_state: { type: 'string', required: true },
721
+ },
722
+ },
723
+ render: (_args, value) => text(`archive ${value.pkg_id}: ${value.file_count} files (${value.check_state})`),
724
+ },
725
+ async execute(args) {
726
+ const selected = [...deps.table.entries()]
727
+ .map(([, record]) => record)
728
+ .filter((record) => record.lifecycle !== 'quarantined')
729
+ .filter((record) => !args.entity_id || record.entity_id === args.entity_id)
730
+ .filter((record) => !args.period || invoicePeriod(record.issue_date) === args.period);
731
+ if (selected.length === 0)
732
+ throw new InvoiceError('E_INV_ARCHIVE_EMPTY', 'no invoices matched the archive filter');
733
+ const entries = {};
734
+ const members = [];
735
+ for (const record of selected) {
736
+ let sourceBytes;
737
+ try {
738
+ sourceBytes = await readFile(record.source_path);
739
+ }
740
+ catch {
741
+ throw new InvoiceError('E_INV_ARCHIVE_MISSING_PART', `original missing for ${record.doc_id}`);
742
+ }
743
+ const sourceName = record.source_name ?? path.basename(record.source_path);
744
+ const sourcePath = `members/${record.doc_id}/${sourceName}`;
745
+ entries[sourcePath] = sourceBytes;
746
+ const files = [{ path: sourcePath, sha256: sha256Buffer(sourceBytes), size: sourceBytes.length }];
747
+ if (args.include_receipts !== false) {
748
+ for (const item of record.receipts ?? []) {
749
+ let receiptBytes;
750
+ try {
751
+ receiptBytes = await readFile(item.receipt_path);
752
+ }
753
+ catch {
754
+ throw new InvoiceError('E_INV_ARCHIVE_MISSING_PART', `receipt missing for ${record.doc_id}/${item.receipt_id}`);
755
+ }
756
+ const receiptPath = `members/${record.doc_id}/receipts/${item.receipt_id}${path.extname(item.receipt_path) || '.bin'}`;
757
+ entries[receiptPath] = receiptBytes;
758
+ files.push({ path: receiptPath, sha256: sha256Buffer(receiptBytes), size: receiptBytes.length });
759
+ }
760
+ }
761
+ members.push({
762
+ doc_id: record.doc_id,
763
+ invoice_no: record.invoice_no,
764
+ lifecycle: record.lifecycle,
765
+ source_sha256: record.source_sha256,
766
+ files,
767
+ });
768
+ }
769
+ const base = {
770
+ schema_version: 1,
771
+ entity_id: args.entity_id ?? 'ALL',
772
+ period: args.period ?? 'ALL',
773
+ template_id: args.template_id ?? 'default-v1',
774
+ created_at: new Date().toISOString(),
775
+ created_by: 'invoice.archive',
776
+ members,
777
+ };
778
+ const pkgId = archivePkgId(base);
779
+ const manifest = { ...base, pkg_id: pkgId };
780
+ const manifestText = JSON.stringify(manifest, null, 2);
781
+ entries['manifest.json'] = new TextEncoder().encode(manifestText);
782
+ const zipBuffer = Buffer.from(zipSync(entries));
783
+ await mkdir(archiveDir, { recursive: true });
784
+ const zipPath = path.join(archiveDir, `${pkgId}.zip`);
785
+ await writeFile(zipPath, zipBuffer);
786
+ await writeFile(path.join(archiveDir, `${pkgId}.manifest.json`), manifestText);
787
+ const check = verifyArchive(zipBuffer);
788
+ return {
789
+ pkg_id: pkgId,
790
+ manifest_sha256: sha256Buffer(Buffer.from(manifestText)),
791
+ zip_path: zipPath,
792
+ file_count: Object.keys(entries).length,
793
+ check_state: check.signable ? 'ok' : 'failed',
794
+ };
795
+ },
796
+ });
797
+ const check = defineTool({
798
+ name: 'invoice.check',
799
+ description: 'Verify an archive package: manifest hash, member hashes, missing files and directory traversal. Read-only; does not restore.',
800
+ parameters: {
801
+ pkg_id: { type: 'string' },
802
+ path: { type: 'string', description: 'Optional archive path under the local archive directory' },
803
+ },
804
+ output: {
805
+ schema: {
806
+ type: 'object',
807
+ additionalProperties: false,
808
+ properties: {
809
+ pkg_id: { type: 'string', required: true },
810
+ missing: { type: 'array', items: { type: 'string' }, required: true },
811
+ hash_mismatch: { type: 'array', items: { type: 'string' }, required: true },
812
+ dir_violation: { type: 'array', items: { type: 'string' }, required: true },
813
+ signable: { type: 'boolean', required: true },
814
+ file_count: { type: 'number', required: true },
815
+ },
816
+ },
817
+ render: (_args, value) => text(`check ${value.pkg_id}: ${value.signable ? 'ok' : 'failed'} (${value.file_count} files)`),
818
+ },
819
+ async execute(args) {
820
+ const zipPath = await resolveArchiveFile(args);
821
+ const buffer = await readFile(zipPath);
822
+ const report = verifyArchive(buffer);
823
+ return {
824
+ pkg_id: report.pkg_id,
825
+ missing: report.missing,
826
+ hash_mismatch: report.hash_mismatch,
827
+ dir_violation: report.dir_violation,
828
+ signable: report.signable,
829
+ file_count: report.file_count,
830
+ };
831
+ },
832
+ });
833
+ const restore = defineTool({
834
+ name: 'invoice.restore',
835
+ description: 'Restore a verified local archive ZIP into a directory under the local restore root, verifying each member hash before writing. Local R1 write.',
836
+ parameters: {
837
+ pkg_id: { type: 'string', required: true },
838
+ target_dir: { type: 'string', description: 'Optional directory under the local restore root' },
839
+ },
840
+ output: {
841
+ schema: {
842
+ type: 'object',
843
+ additionalProperties: false,
844
+ properties: {
845
+ pkg_id: { type: 'string', required: true },
846
+ restore_dir: { type: 'string', required: true },
847
+ restored: { type: 'number', required: true },
848
+ mismatches: { type: 'array', items: { type: 'string' }, required: true },
849
+ },
850
+ },
851
+ render: (_args, value) => text(`restore ${value.pkg_id}: ${value.restored} files`),
852
+ },
853
+ async execute(args) {
854
+ const zipPath = await resolveArchiveFile({ pkg_id: args.pkg_id });
855
+ const buffer = await readFile(zipPath);
856
+ const { files, manifest } = parseArchive(buffer);
857
+ const report = verifyArchive(buffer);
858
+ let target = path.join(restoreRoot, report.pkg_id);
859
+ if (args.target_dir) {
860
+ target = path.resolve(args.target_dir);
861
+ const rel = path.relative(restoreRoot, target);
862
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
863
+ throw new InvoiceError('E_INV_PATH_NOT_ALLOWED', 'target_dir must be under the local restore root');
864
+ }
865
+ }
866
+ await mkdir(target, { recursive: true });
867
+ let restored = 0;
868
+ for (const member of manifest.members) {
869
+ for (const file of member.files ?? []) {
870
+ if (!safeZipPath(file.path))
871
+ continue;
872
+ const data = files[file.path];
873
+ if (!data)
874
+ continue;
875
+ if (sha256Buffer(Buffer.from(data)) !== file.sha256)
876
+ continue;
877
+ const destination = path.join(target, file.path);
878
+ const rel = path.relative(target, destination);
879
+ if (rel.startsWith('..') || path.isAbsolute(rel))
880
+ continue;
881
+ await mkdir(path.dirname(destination), { recursive: true });
882
+ await writeFile(destination, data);
883
+ restored += 1;
884
+ }
885
+ }
886
+ return {
887
+ pkg_id: report.pkg_id,
888
+ restore_dir: target,
889
+ restored,
890
+ mismatches: [...report.missing, ...report.hash_mismatch, ...report.dir_violation],
891
+ };
892
+ },
893
+ });
894
+ return [ingest, parse, query, dedup, verify, receipt, redlink, archive, check, restore];
304
895
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@perrylink/dsh-einvoice",
3
- "version": "0.1.0-alpha.0",
3
+ "version": "0.1.0-alpha.2",
4
4
  "description": "File-level Chinese e-invoice (OFD/XML) ingest, parse and pool for DeepSeek Harness (SKU-04 S1).",
5
5
  "type": "module",
6
6
  "main": "./lib/plugin/index.js",