@perrylink/dsh-einvoice 0.1.0-alpha.1 → 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+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.
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
 
@@ -23,8 +23,12 @@ The bundle inserts one Host row. It requires the base profile's `tools` and `sto
23
23
  | `invoice.dedup` | Group duplicates by invoice number or seller+date+total | R0 |
24
24
  | `invoice.verify` | Queue parsed invoices for manual verification; optional buyer-tax-id mismatch flags | R1 local write |
25
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 |
26
30
 
27
- S3 adds red/blue matching, archive packages and archive checks; S2 verification is manual/local (no tax-bureau network call).
31
+ S3 adds red/blue matching, deterministic archive packages, check and restore. S2 verification is manual/local (no tax-bureau network call).
28
32
 
29
33
  ## Configuration
30
34
 
@@ -42,6 +42,18 @@ export declare const receiptSchema: z.ZodObject<{
42
42
  receipt_path: z.ZodString;
43
43
  recorded_at: z.ZodString;
44
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>;
45
57
  export declare const invoiceSchema: z.ZodObject<{
46
58
  doc_id: z.ZodString;
47
59
  entity_id: z.ZodString;
@@ -107,7 +119,20 @@ export declare const invoiceSchema: z.ZodObject<{
107
119
  receipt_path: z.ZodString;
108
120
  recorded_at: z.ZodString;
109
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>>;
110
134
  source_path: z.ZodString;
135
+ source_name: z.ZodOptional<z.ZodString>;
111
136
  source_sha256: z.ZodString;
112
137
  source_size: z.ZodNumber;
113
138
  created_at: z.ZodString;
@@ -188,6 +213,16 @@ export declare const finInvoiceSpec: {
188
213
  receipt_path: string;
189
214
  recorded_at: string;
190
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;
191
226
  }>;
192
227
  };
193
228
  };
@@ -204,6 +239,7 @@ export declare function emptyInvoiceRecord(input: {
204
239
  docId: string;
205
240
  entityId: string;
206
241
  sourcePath: string;
242
+ sourceName: string;
207
243
  sha256: string;
208
244
  size: number;
209
245
  runId: string;
@@ -37,6 +37,15 @@ export const receiptSchema = z.object({
37
37
  receipt_path: z.string(),
38
38
  recorded_at: z.string(),
39
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
+ });
40
49
  export const invoiceSchema = z.object({
41
50
  doc_id: z.string().min(1),
42
51
  entity_id: z.string(),
@@ -57,7 +66,9 @@ export const invoiceSchema = z.object({
57
66
  lifecycle: z.enum(['received', 'parsed', 'verified', 'archived', 'quarantined']),
58
67
  verification: verificationSchema.optional(),
59
68
  receipts: z.array(receiptSchema).optional(),
69
+ redlink: redlinkSchema.optional(),
60
70
  source_path: z.string(),
71
+ source_name: z.string().optional(),
61
72
  source_sha256: z.string(),
62
73
  source_size: z.number().int().nonnegative(),
63
74
  created_at: z.string(),
@@ -174,6 +185,7 @@ export function emptyInvoiceRecord(input) {
174
185
  parser_version: '',
175
186
  lifecycle: 'received',
176
187
  source_path: input.sourcePath,
188
+ source_name: input.sourceName,
177
189
  source_sha256: input.sha256,
178
190
  source_size: input.size,
179
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);
@@ -539,5 +603,293 @@ export function createTools(deps) {
539
603
  return { receipt_id: receiptRecord.receipt_id, receipt_sha256: receiptSha, duplicate: false, lifecycle };
540
604
  },
541
605
  });
542
- return [ingest, parse, query, dedup, verify, receipt];
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];
543
895
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@perrylink/dsh-einvoice",
3
- "version": "0.1.0-alpha.1",
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",