@perrylink/dsh-einvoice 0.1.0-alpha.0 → 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.
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 / public demo (0.1.0-alpha.1).** 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,11 @@ 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
+ | `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 |
23
26
 
24
- S2/S3 add dedup, verification/receipt, red/blue matching, archive packages and archive checks; S1 does not register them.
27
+ S3 adds red/blue matching, archive packages and archive checks; S2 verification is manual/local (no tax-bureau network call).
25
28
 
26
29
  ## Configuration
27
30
 
@@ -15,6 +15,33 @@ 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>;
18
45
  export declare const invoiceSchema: z.ZodObject<{
19
46
  doc_id: z.ZodString;
20
47
  entity_id: z.ZodString;
@@ -53,6 +80,33 @@ export declare const invoiceSchema: z.ZodObject<{
53
80
  archived: "archived";
54
81
  quarantined: "quarantined";
55
82
  }>;
83
+ verification: z.ZodOptional<z.ZodObject<{
84
+ state: z.ZodEnum<{
85
+ verified: "verified";
86
+ pending: "pending";
87
+ failed: "failed";
88
+ manual: "manual";
89
+ }>;
90
+ queued_at: z.ZodOptional<z.ZodString>;
91
+ updated_at: z.ZodOptional<z.ZodString>;
92
+ attempts: z.ZodDefault<z.ZodNumber>;
93
+ last_result: z.ZodOptional<z.ZodString>;
94
+ manual_reason: z.ZodOptional<z.ZodString>;
95
+ }, z.core.$strip>>;
96
+ receipts: z.ZodOptional<z.ZodArray<z.ZodObject<{
97
+ receipt_id: z.ZodString;
98
+ result_code: z.ZodString;
99
+ result: z.ZodEnum<{
100
+ pass: "pass";
101
+ fail: "fail";
102
+ unknown: "unknown";
103
+ }>;
104
+ verified_at: z.ZodString;
105
+ operator: z.ZodString;
106
+ receipt_sha256: z.ZodString;
107
+ receipt_path: z.ZodString;
108
+ recorded_at: z.ZodString;
109
+ }, z.core.$strip>>>;
56
110
  source_path: z.ZodString;
57
111
  source_sha256: z.ZodString;
58
112
  source_size: z.ZodNumber;
@@ -116,6 +170,24 @@ export declare const finInvoiceSpec: {
116
170
  actor: string;
117
171
  evidence_sha256: string;
118
172
  invoice_code?: string | undefined;
173
+ verification?: {
174
+ state: "verified" | "pending" | "failed" | "manual";
175
+ attempts: number;
176
+ queued_at?: string | undefined;
177
+ updated_at?: string | undefined;
178
+ last_result?: string | undefined;
179
+ manual_reason?: string | undefined;
180
+ } | undefined;
181
+ receipts?: {
182
+ receipt_id: string;
183
+ result_code: string;
184
+ result: "pass" | "fail" | "unknown";
185
+ verified_at: string;
186
+ operator: string;
187
+ receipt_sha256: string;
188
+ receipt_path: string;
189
+ recorded_at: string;
190
+ }[] | undefined;
119
191
  }>;
120
192
  };
121
193
  };
@@ -19,6 +19,24 @@ 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
+ });
22
40
  export const invoiceSchema = z.object({
23
41
  doc_id: z.string().min(1),
24
42
  entity_id: z.string(),
@@ -37,6 +55,8 @@ export const invoiceSchema = z.object({
37
55
  unknown_fields: z.record(z.string(), z.unknown()),
38
56
  parser_version: z.string(),
39
57
  lifecycle: z.enum(['received', 'parsed', 'verified', 'archived', 'quarantined']),
58
+ verification: verificationSchema.optional(),
59
+ receipts: z.array(receiptSchema).optional(),
40
60
  source_path: z.string(),
41
61
  source_sha256: z.string(),
42
62
  source_size: z.number().int().nonnegative(),
@@ -300,5 +300,244 @@ export function createTools(deps) {
300
300
  };
301
301
  },
302
302
  });
303
- return [ingest, parse, query];
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];
304
543
  }
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.1",
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",