@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 +10 -3
- package/lib/plugin/storage.d.ts +108 -0
- package/lib/plugin/storage.js +32 -0
- package/lib/plugin/tools.js +594 -3
- package/package.json +1 -1
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
|
-
|
|
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
|
|
package/lib/plugin/storage.d.ts
CHANGED
|
@@ -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;
|
package/lib/plugin/storage.js
CHANGED
|
@@ -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,
|
package/lib/plugin/tools.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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",
|