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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,140 @@
1
+ /** Storage domain and local original-file store for SKU-04 S1. */
2
+ import { z } from 'zod';
3
+ import type { Context } from '@deepseek-ai/cordis';
4
+ import { type InvoiceLine } from '../core/invoice.js';
5
+ export declare const partySchema: z.ZodObject<{
6
+ name: z.ZodString;
7
+ tax_id: z.ZodString;
8
+ }, z.core.$strip>;
9
+ export declare const lineSchema: z.ZodObject<{
10
+ line_no: z.ZodNumber;
11
+ description: z.ZodString;
12
+ quantity: z.ZodOptional<z.ZodString>;
13
+ unit_price_cents: z.ZodOptional<z.ZodNumber>;
14
+ amount_cents: z.ZodNumber;
15
+ tax_rate: z.ZodOptional<z.ZodString>;
16
+ tax_cents: z.ZodOptional<z.ZodNumber>;
17
+ }, z.core.$strip>;
18
+ export declare const invoiceSchema: z.ZodObject<{
19
+ doc_id: z.ZodString;
20
+ entity_id: z.ZodString;
21
+ invoice_no: z.ZodString;
22
+ invoice_code: z.ZodOptional<z.ZodString>;
23
+ issue_date: z.ZodString;
24
+ buyer: z.ZodObject<{
25
+ name: z.ZodString;
26
+ tax_id: z.ZodString;
27
+ }, z.core.$strip>;
28
+ seller: z.ZodObject<{
29
+ name: z.ZodString;
30
+ tax_id: z.ZodString;
31
+ }, z.core.$strip>;
32
+ amount_cents: z.ZodNumber;
33
+ tax_cents: z.ZodNumber;
34
+ total_cents: z.ZodNumber;
35
+ currency: z.ZodLiteral<"CNY">;
36
+ lines: z.ZodArray<z.ZodObject<{
37
+ line_no: z.ZodNumber;
38
+ description: z.ZodString;
39
+ quantity: z.ZodOptional<z.ZodString>;
40
+ unit_price_cents: z.ZodOptional<z.ZodNumber>;
41
+ amount_cents: z.ZodNumber;
42
+ tax_rate: z.ZodOptional<z.ZodString>;
43
+ tax_cents: z.ZodOptional<z.ZodNumber>;
44
+ }, z.core.$strip>>;
45
+ confidence: z.ZodNumber;
46
+ field_evidence: z.ZodRecord<z.ZodString, z.ZodString>;
47
+ unknown_fields: z.ZodRecord<z.ZodString, z.ZodUnknown>;
48
+ parser_version: z.ZodString;
49
+ lifecycle: z.ZodEnum<{
50
+ received: "received";
51
+ parsed: "parsed";
52
+ verified: "verified";
53
+ archived: "archived";
54
+ quarantined: "quarantined";
55
+ }>;
56
+ source_path: z.ZodString;
57
+ source_sha256: z.ZodString;
58
+ source_size: z.ZodNumber;
59
+ created_at: z.ZodString;
60
+ updated_at: z.ZodString;
61
+ run_id: z.ZodString;
62
+ actor: z.ZodString;
63
+ evidence_sha256: z.ZodString;
64
+ }, z.core.$strip>;
65
+ export type InvoiceRecord = z.infer<typeof invoiceSchema>;
66
+ export type InvoiceTable = {
67
+ get(key: string): InvoiceRecord | undefined;
68
+ entries(): IterableIterator<[string, InvoiceRecord]>;
69
+ keys(): IterableIterator<string>;
70
+ readonly size: number;
71
+ put(key: string, value: InvoiceRecord): Promise<void>;
72
+ delete(key: string): Promise<boolean>;
73
+ update(key: string, fn: (current: InvoiceRecord) => InvoiceRecord): Promise<InvoiceRecord>;
74
+ };
75
+ export declare const finInvoiceSpec: {
76
+ name: string;
77
+ version: number;
78
+ tables: {
79
+ invoices: import("@deepseek-ai/dsh-storage-domain").DomainTableSpec<string, {
80
+ doc_id: string;
81
+ entity_id: string;
82
+ invoice_no: string;
83
+ issue_date: string;
84
+ buyer: {
85
+ name: string;
86
+ tax_id: string;
87
+ };
88
+ seller: {
89
+ name: string;
90
+ tax_id: string;
91
+ };
92
+ amount_cents: number;
93
+ tax_cents: number;
94
+ total_cents: number;
95
+ currency: "CNY";
96
+ lines: {
97
+ line_no: number;
98
+ description: string;
99
+ amount_cents: number;
100
+ quantity?: string | undefined;
101
+ unit_price_cents?: number | undefined;
102
+ tax_rate?: string | undefined;
103
+ tax_cents?: number | undefined;
104
+ }[];
105
+ confidence: number;
106
+ field_evidence: Record<string, string>;
107
+ unknown_fields: Record<string, unknown>;
108
+ parser_version: string;
109
+ lifecycle: "received" | "parsed" | "verified" | "archived" | "quarantined";
110
+ source_path: string;
111
+ source_sha256: string;
112
+ source_size: number;
113
+ created_at: string;
114
+ updated_at: string;
115
+ run_id: string;
116
+ actor: string;
117
+ evidence_sha256: string;
118
+ invoice_code?: string | undefined;
119
+ }>;
120
+ };
121
+ };
122
+ export declare function openInvoiceTable(ctx: Context): Promise<InvoiceTable>;
123
+ /** Generate a ULID-compatible 26-character document id. */
124
+ export declare function newDocId(now?: number): string;
125
+ export declare function sha256Buffer(buffer: Buffer): string;
126
+ export declare function sha256File(filePath: string): Promise<string>;
127
+ export declare function storeOriginal(baseDir: string, sha256: string, extension: string, buffer: Buffer): Promise<string>;
128
+ export declare function findBySha(table: InvoiceTable, sha256: string): InvoiceRecord | undefined;
129
+ export declare function findByInvoiceNo(table: InvoiceTable, entityId: string, invoiceNo: string): InvoiceRecord | undefined;
130
+ export declare function assertUniqueInvoice(table: InvoiceTable, entityId: string, invoiceNo: string, docId: string): void;
131
+ export declare function emptyInvoiceRecord(input: {
132
+ docId: string;
133
+ entityId: string;
134
+ sourcePath: string;
135
+ sha256: string;
136
+ size: number;
137
+ runId: string;
138
+ actor: string;
139
+ }): InvoiceRecord;
140
+ export declare function recordLines(lines: InvoiceLine[]): InvoiceLine[];
@@ -0,0 +1,168 @@
1
+ /** Storage domain and local original-file store for SKU-04 S1. */
2
+ import { createHash, randomBytes } from 'node:crypto';
3
+ import { createReadStream } from 'node:fs';
4
+ import { mkdir, writeFile } from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ import { z } from 'zod';
7
+ import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain';
8
+ import { InvoiceError } from '../core/invoice.js';
9
+ export const partySchema = z.object({
10
+ name: z.string(),
11
+ tax_id: z.string(),
12
+ });
13
+ export const lineSchema = z.object({
14
+ line_no: z.number().int().nonnegative(),
15
+ description: z.string(),
16
+ quantity: z.string().optional(),
17
+ unit_price_cents: z.number().int().optional(),
18
+ amount_cents: z.number().int(),
19
+ tax_rate: z.string().optional(),
20
+ tax_cents: z.number().int().optional(),
21
+ });
22
+ export const invoiceSchema = z.object({
23
+ doc_id: z.string().min(1),
24
+ entity_id: z.string(),
25
+ invoice_no: z.string(),
26
+ invoice_code: z.string().optional(),
27
+ issue_date: z.string(),
28
+ buyer: partySchema,
29
+ seller: partySchema,
30
+ amount_cents: z.number().int(),
31
+ tax_cents: z.number().int(),
32
+ total_cents: z.number().int(),
33
+ currency: z.literal('CNY'),
34
+ lines: z.array(lineSchema),
35
+ confidence: z.number(),
36
+ field_evidence: z.record(z.string(), z.string()),
37
+ unknown_fields: z.record(z.string(), z.unknown()),
38
+ parser_version: z.string(),
39
+ lifecycle: z.enum(['received', 'parsed', 'verified', 'archived', 'quarantined']),
40
+ source_path: z.string(),
41
+ source_sha256: z.string(),
42
+ source_size: z.number().int().nonnegative(),
43
+ created_at: z.string(),
44
+ updated_at: z.string(),
45
+ run_id: z.string(),
46
+ actor: z.string(),
47
+ evidence_sha256: z.string(),
48
+ });
49
+ export const finInvoiceSpec = defineDomain({
50
+ name: 'fin_invoice',
51
+ version: 1,
52
+ tables: {
53
+ invoices: domainTable(invoiceSchema),
54
+ },
55
+ });
56
+ export async function openInvoiceTable(ctx) {
57
+ const domain = await ctx.storageDomain.open(finInvoiceSpec);
58
+ ctx.effect(() => () => domain.close(), 'dsh-einvoice.domainClose');
59
+ return domain.table('invoices');
60
+ }
61
+ const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
62
+ /** Generate a ULID-compatible 26-character document id. */
63
+ export function newDocId(now = Date.now()) {
64
+ let time = now;
65
+ let timePart = '';
66
+ for (let index = 0; index < 10; index += 1) {
67
+ timePart = (CROCKFORD[time % 32] ?? '0') + timePart;
68
+ time = Math.floor(time / 32);
69
+ }
70
+ const bytes = randomBytes(16);
71
+ let buffer = 0;
72
+ let bits = 0;
73
+ let randomPart = '';
74
+ for (const byte of bytes) {
75
+ buffer = (buffer << 8) | byte;
76
+ bits += 8;
77
+ while (bits >= 5 && randomPart.length < 16) {
78
+ bits -= 5;
79
+ randomPart += CROCKFORD[(buffer >> bits) & 31] ?? '0';
80
+ }
81
+ }
82
+ return timePart + randomPart;
83
+ }
84
+ export function sha256Buffer(buffer) {
85
+ return createHash('sha256').update(buffer).digest('hex');
86
+ }
87
+ export async function sha256File(filePath) {
88
+ const hash = createHash('sha256');
89
+ await new Promise((resolve, reject) => {
90
+ const stream = createReadStream(filePath);
91
+ stream.on('data', (chunk) => hash.update(chunk));
92
+ stream.on('error', reject);
93
+ stream.on('end', resolve);
94
+ });
95
+ return hash.digest('hex');
96
+ }
97
+ export async function storeOriginal(baseDir, sha256, extension, buffer) {
98
+ const directory = path.join(baseDir, 'originals');
99
+ await mkdir(directory, { recursive: true });
100
+ const safeExtension = extension.replace(/[^a-z0-9.]/gi, '').slice(0, 8);
101
+ const target = path.join(directory, `${sha256}${safeExtension}`);
102
+ try {
103
+ await writeFile(target, buffer, { mode: 0o600, flag: 'wx' });
104
+ }
105
+ catch (error) {
106
+ const code = error.code;
107
+ if (code !== 'EEXIST')
108
+ throw new InvoiceError('E_INV_STORAGE_FAILED', `cannot store original: ${String(error)}`, { retryable: true });
109
+ }
110
+ return target;
111
+ }
112
+ export function findBySha(table, sha256) {
113
+ for (const [, record] of table.entries()) {
114
+ if (record.source_sha256 === sha256)
115
+ return record;
116
+ }
117
+ return undefined;
118
+ }
119
+ export function findByInvoiceNo(table, entityId, invoiceNo) {
120
+ for (const [, record] of table.entries()) {
121
+ if (record.entity_id === entityId && record.invoice_no === invoiceNo)
122
+ return record;
123
+ }
124
+ return undefined;
125
+ }
126
+ export function assertUniqueInvoice(table, entityId, invoiceNo, docId) {
127
+ if (!invoiceNo)
128
+ return;
129
+ const existing = findByInvoiceNo(table, entityId, invoiceNo);
130
+ if (existing && existing.doc_id !== docId) {
131
+ throw new InvoiceError('E_INV_DUP_CONFLICT', `invoice ${invoiceNo} already exists for entity ${entityId}`, {
132
+ needs_review: true,
133
+ details: { existing_doc_id: existing.doc_id },
134
+ });
135
+ }
136
+ }
137
+ export function emptyInvoiceRecord(input) {
138
+ const now = new Date().toISOString();
139
+ return {
140
+ doc_id: input.docId,
141
+ entity_id: input.entityId,
142
+ invoice_no: '',
143
+ issue_date: '',
144
+ buyer: { name: '', tax_id: '' },
145
+ seller: { name: '', tax_id: '' },
146
+ amount_cents: 0,
147
+ tax_cents: 0,
148
+ total_cents: 0,
149
+ currency: 'CNY',
150
+ lines: [],
151
+ confidence: 0,
152
+ field_evidence: {},
153
+ unknown_fields: {},
154
+ parser_version: '',
155
+ lifecycle: 'received',
156
+ source_path: input.sourcePath,
157
+ source_sha256: input.sha256,
158
+ source_size: input.size,
159
+ created_at: now,
160
+ updated_at: now,
161
+ run_id: input.runId,
162
+ actor: input.actor,
163
+ evidence_sha256: '',
164
+ };
165
+ }
166
+ export function recordLines(lines) {
167
+ return lines.map((line) => ({ ...line }));
168
+ }
@@ -0,0 +1,8 @@
1
+ /** Three S1 model-facing tools: ingest, parse, query. */
2
+ import { type InvoiceTable } from './storage.js';
3
+ export interface ToolDeps {
4
+ table: InvoiceTable;
5
+ baseDir: string;
6
+ inputRoots: string[];
7
+ }
8
+ export declare function createTools(deps: ToolDeps): import("@deepseek-ai/dsh-tools").ToolDefinition[];
@@ -0,0 +1,304 @@
1
+ /** Three S1 model-facing tools: ingest, parse, query. */
2
+ import { createHash } from 'node:crypto';
3
+ import { readFile, realpath, stat } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { defineTool } from '@deepseek-ai/dsh-tools';
6
+ import { InvoiceError, assertEightFields, canonicalJson, invoicePeriod, } from '../core/invoice.js';
7
+ import { getParser } from '../registry/parsers.js';
8
+ import { assertUniqueInvoice, emptyInvoiceRecord, findBySha, newDocId, recordLines, sha256Buffer, storeOriginal, } from './storage.js';
9
+ const partyOut = {
10
+ type: 'object',
11
+ additionalProperties: false,
12
+ properties: {
13
+ name: { type: 'string', required: true },
14
+ tax_id: { type: 'string', required: true },
15
+ },
16
+ };
17
+ const lineOut = {
18
+ type: 'object',
19
+ additionalProperties: false,
20
+ properties: {
21
+ line_no: { type: 'number', required: true },
22
+ description: { type: 'string', required: true },
23
+ quantity: { type: 'string' },
24
+ unit_price_cents: { type: 'number' },
25
+ amount_cents: { type: 'number', required: true },
26
+ tax_rate: { type: 'string' },
27
+ tax_cents: { type: 'number' },
28
+ },
29
+ };
30
+ function text(value) {
31
+ return [{ type: 'text', text: value }];
32
+ }
33
+ function actorOf(exec) {
34
+ return exec.agent?.id ? `agent:${exec.agent.id}` : 'local';
35
+ }
36
+ function runIdOf(exec) {
37
+ return String(exec.callId);
38
+ }
39
+ async function resolveAllowedPath(inputPath, roots) {
40
+ const absolute = path.resolve(inputPath);
41
+ let resolved;
42
+ try {
43
+ resolved = await realpath(absolute);
44
+ }
45
+ catch {
46
+ throw new InvoiceError('E_INV_FILE_UNREADABLE', `file does not exist: ${inputPath}`);
47
+ }
48
+ const resolvedRoots = await Promise.all(roots.map(async (root) => {
49
+ try {
50
+ return await realpath(path.resolve(root));
51
+ }
52
+ catch {
53
+ return undefined;
54
+ }
55
+ }));
56
+ const allowed = resolvedRoots.some((root) => root !== undefined && (resolved === root || resolved.startsWith(root + path.sep)));
57
+ if (!allowed)
58
+ throw new InvoiceError('E_INV_PATH_NOT_ALLOWED', `path is outside the configured input roots: ${inputPath}`);
59
+ return resolved;
60
+ }
61
+ function parsedToRecord(record, parsed) {
62
+ const updatedAt = new Date().toISOString();
63
+ return {
64
+ ...record,
65
+ invoice_no: parsed.invoice_no,
66
+ invoice_code: parsed.invoice_code,
67
+ issue_date: parsed.issue_date,
68
+ buyer: parsed.buyer,
69
+ seller: parsed.seller,
70
+ amount_cents: parsed.amount_cents,
71
+ tax_cents: parsed.tax_cents,
72
+ total_cents: parsed.total_cents,
73
+ currency: 'CNY',
74
+ lines: recordLines(parsed.lines),
75
+ confidence: parsed.confidence,
76
+ field_evidence: parsed.field_evidence,
77
+ unknown_fields: parsed.unknown_fields,
78
+ parser_version: parsed.parser_version,
79
+ lifecycle: 'parsed',
80
+ updated_at: updatedAt,
81
+ evidence_sha256: createHash('sha256').update(canonicalJson(parsed)).digest('hex'),
82
+ };
83
+ }
84
+ export function createTools(deps) {
85
+ const ingest = defineTool({
86
+ name: 'invoice.ingest',
87
+ description: 'Ingest one local OFD/XML invoice file into the SKU-04 pool. Computes SHA-256, stores the original under the local pool, and returns the document id. Re-ingesting the same bytes is idempotent.',
88
+ parameters: {
89
+ source: {
90
+ type: 'object',
91
+ required: true,
92
+ additionalProperties: false,
93
+ properties: {
94
+ kind: { type: 'string', enum: ['file'], required: true, description: 'S1 accepts one local file' },
95
+ path: { type: 'string', required: true, description: 'Local path under the configured input roots' },
96
+ sha256: { type: 'string', description: 'Optional expected SHA-256; mismatch is rejected' },
97
+ },
98
+ },
99
+ entity_id: { type: 'string', description: 'Legal entity id; defaults to "default"' },
100
+ },
101
+ output: {
102
+ schema: {
103
+ type: 'object',
104
+ additionalProperties: false,
105
+ properties: {
106
+ doc_id: { type: 'string', required: true },
107
+ sha256: { type: 'string', required: true },
108
+ size: { type: 'number', required: true },
109
+ existing: { type: 'boolean', required: true },
110
+ },
111
+ },
112
+ render: (_args, value) => text(`ingested ${value.doc_id} (${value.size} bytes, sha256 ${value.sha256.slice(0, 12)}…${value.existing ? ', existing' : ''})`),
113
+ },
114
+ async execute(args, exec) {
115
+ const resolved = await resolveAllowedPath(args.source.path, deps.inputRoots);
116
+ const info = await stat(resolved);
117
+ if (!info.isFile())
118
+ throw new InvoiceError('E_INV_FILE_UNREADABLE', 'source is not a regular file');
119
+ if (info.size === 0)
120
+ throw new InvoiceError('E_INV_FILE_UNREADABLE', 'source file is empty');
121
+ if (info.size > 50 * 1024 * 1024)
122
+ throw new InvoiceError('E_INV_FILE_UNREADABLE', 'source file exceeds 50MB');
123
+ const buffer = await readFile(resolved);
124
+ const sha256 = sha256Buffer(buffer);
125
+ if (args.source.sha256 && args.source.sha256.toLowerCase() !== sha256) {
126
+ throw new InvoiceError('E_INV_STORAGE_FAILED', 'expected sha256 does not match the source file');
127
+ }
128
+ const existing = findBySha(deps.table, sha256);
129
+ if (existing)
130
+ return { doc_id: existing.doc_id, sha256, size: info.size, existing: true };
131
+ const docId = newDocId();
132
+ const entityId = args.entity_id?.trim() || 'default';
133
+ const storedPath = await storeOriginal(deps.baseDir, sha256, path.extname(resolved), buffer);
134
+ const record = emptyInvoiceRecord({
135
+ docId,
136
+ entityId,
137
+ sourcePath: storedPath,
138
+ sha256,
139
+ size: info.size,
140
+ runId: runIdOf(exec),
141
+ actor: actorOf(exec),
142
+ });
143
+ await deps.table.put(docId, record);
144
+ return { doc_id: docId, sha256, size: info.size, existing: false };
145
+ },
146
+ });
147
+ const parse = defineTool({
148
+ name: 'invoice.parse',
149
+ description: 'Parse one ingested OFD/XML invoice into the eight fields, line items, field evidence and unknown fields. Supports old OFD invoice XML and 数电票 CustomTag/ObjectRef.',
150
+ parameters: {
151
+ doc_id: { type: 'string', required: true },
152
+ profile: { type: 'string', description: 'Parser profile; S1 uses ofd-zip-xml' },
153
+ ocr: { type: 'string', enum: ['off'], description: 'S1 does not run OCR' },
154
+ },
155
+ output: {
156
+ schema: {
157
+ type: 'object',
158
+ additionalProperties: false,
159
+ properties: {
160
+ doc_id: { type: 'string', required: true },
161
+ invoice_no: { type: 'string', required: true },
162
+ issue_date: { type: 'string', required: true },
163
+ buyer: { ...partyOut, required: true },
164
+ seller: { ...partyOut, required: true },
165
+ amount_cents: { type: 'number', required: true },
166
+ tax_cents: { type: 'number', required: true },
167
+ total_cents: { type: 'number', required: true },
168
+ currency: { type: 'string', required: true },
169
+ lines: { type: 'array', items: lineOut, required: true },
170
+ confidence: { type: 'number', required: true },
171
+ field_evidence: { type: 'json', required: true },
172
+ unknown_fields: { type: 'json', required: true },
173
+ parser_version: { type: 'string', required: true },
174
+ warnings: { type: 'array', items: { type: 'string' }, required: true },
175
+ },
176
+ },
177
+ render: (_args, value) => text(`parsed ${value.invoice_no || '(no number)'} ${value.seller.name || '(no seller)'} total=${(value.total_cents / 100).toFixed(2)} CNY lines=${value.lines.length} unknown=${Object.keys(value.unknown_fields ?? {}).length}`),
178
+ },
179
+ async execute(args, exec) {
180
+ const record = deps.table.get(args.doc_id);
181
+ if (!record)
182
+ throw new InvoiceError('E_INV_DOC_NOT_FOUND', `unknown doc_id: ${args.doc_id}`);
183
+ const buffer = await readFile(record.source_path);
184
+ const parser = getParser(args.profile);
185
+ const parsed = parser.parse({ buffer, fileName: path.basename(record.source_path) });
186
+ assertEightFields(parsed);
187
+ assertUniqueInvoice(deps.table, record.entity_id, parsed.invoice_no, record.doc_id);
188
+ const updated = parsedToRecord(record, parsed);
189
+ updated.actor = actorOf(exec);
190
+ updated.run_id = runIdOf(exec);
191
+ await deps.table.put(record.doc_id, updated);
192
+ return {
193
+ doc_id: record.doc_id,
194
+ invoice_no: parsed.invoice_no,
195
+ issue_date: parsed.issue_date,
196
+ buyer: parsed.buyer,
197
+ seller: parsed.seller,
198
+ amount_cents: parsed.amount_cents,
199
+ tax_cents: parsed.tax_cents,
200
+ total_cents: parsed.total_cents,
201
+ currency: parsed.currency,
202
+ lines: parsed.lines,
203
+ confidence: parsed.confidence,
204
+ field_evidence: parsed.field_evidence,
205
+ unknown_fields: parsed.unknown_fields,
206
+ parser_version: parsed.parser_version,
207
+ warnings: parsed.warnings,
208
+ };
209
+ },
210
+ });
211
+ const query = defineTool({
212
+ name: 'invoice.query',
213
+ description: 'Query the SKU-04 invoice pool by entity, period, lifecycle or text; returns a stable page.',
214
+ parameters: {
215
+ filter: {
216
+ type: 'object',
217
+ additionalProperties: false,
218
+ properties: {
219
+ entity_id: { type: 'string' },
220
+ period: { type: 'string', description: 'YYYY-MM from issue_date' },
221
+ lifecycle: { type: 'string', enum: ['received', 'parsed', 'verified', 'archived', 'quarantined'] },
222
+ q: { type: 'string', description: 'Case-insensitive text match on invoice number or seller name' },
223
+ },
224
+ },
225
+ cursor: { type: 'string', description: 'Opaque offset cursor' },
226
+ page: { type: 'number', description: '1..200, default 50' },
227
+ },
228
+ output: {
229
+ schema: {
230
+ type: 'object',
231
+ additionalProperties: false,
232
+ properties: {
233
+ rows: {
234
+ required: true,
235
+ type: 'array',
236
+ items: {
237
+ type: 'object',
238
+ additionalProperties: false,
239
+ properties: {
240
+ doc_id: { type: 'string', required: true },
241
+ invoice_no: { type: 'string', required: true },
242
+ issue_date: { type: 'string', required: true },
243
+ buyer: { ...partyOut, required: true },
244
+ seller: { ...partyOut, required: true },
245
+ amount_cents: { type: 'number', required: true },
246
+ tax_cents: { type: 'number', required: true },
247
+ total_cents: { type: 'number', required: true },
248
+ lifecycle: { type: 'string', required: true },
249
+ parser_version: { type: 'string', required: true },
250
+ confidence: { type: 'number', required: true },
251
+ },
252
+ },
253
+ },
254
+ total: { type: 'number', required: true },
255
+ next_cursor: { type: 'string', required: true },
256
+ },
257
+ },
258
+ render: (_args, value) => text(`invoice pool: ${value.total} matched, ${value.rows.length} on this page`),
259
+ },
260
+ async execute(args) {
261
+ const pageSize = Math.max(1, Math.min(200, Math.floor(args.page ?? 50)));
262
+ const offset = Math.max(0, Number.parseInt(args.cursor ?? '0', 10) || 0);
263
+ const filter = args.filter ?? {};
264
+ const rows = [...deps.table.entries()]
265
+ .map(([, record]) => record)
266
+ .filter((record) => {
267
+ if (filter.entity_id && record.entity_id !== filter.entity_id)
268
+ return false;
269
+ if (filter.period && invoicePeriod(record.issue_date) !== filter.period)
270
+ return false;
271
+ if (filter.lifecycle && record.lifecycle !== filter.lifecycle)
272
+ return false;
273
+ if (filter.q) {
274
+ const queryText = filter.q.toLowerCase();
275
+ if (!record.invoice_no.toLowerCase().includes(queryText) && !record.seller.name.toLowerCase().includes(queryText))
276
+ return false;
277
+ }
278
+ return true;
279
+ })
280
+ .sort((a, b) => b.created_at.localeCompare(a.created_at));
281
+ const total = rows.length;
282
+ const pageRows = rows.slice(offset, offset + pageSize).map((record) => ({
283
+ doc_id: record.doc_id,
284
+ invoice_no: record.invoice_no,
285
+ issue_date: record.issue_date,
286
+ buyer: record.buyer,
287
+ seller: record.seller,
288
+ amount_cents: record.amount_cents,
289
+ tax_cents: record.tax_cents,
290
+ total_cents: record.total_cents,
291
+ lifecycle: record.lifecycle,
292
+ parser_version: record.parser_version,
293
+ confidence: record.confidence,
294
+ }));
295
+ const nextOffset = offset + pageRows.length;
296
+ return {
297
+ rows: pageRows,
298
+ total,
299
+ next_cursor: nextOffset < total ? String(nextOffset) : '',
300
+ };
301
+ },
302
+ });
303
+ return [ingest, parse, query];
304
+ }
@@ -0,0 +1,5 @@
1
+ /** Frozen parser registry: exactly one S1 adapter. */
2
+ import { type ParserAdapter } from '../adapters/ofd-zip-xml.js';
3
+ export declare const DEFAULT_PARSER_ID: string;
4
+ export declare function getParser(id?: string): ParserAdapter;
5
+ export declare function listParsers(): readonly string[];
@@ -0,0 +1,20 @@
1
+ /** Frozen parser registry: exactly one S1 adapter. */
2
+ import { ofdZipXmlParser } from '../adapters/ofd-zip-xml.js';
3
+ import { InvoiceError } from '../core/invoice.js';
4
+ const registry = new Map();
5
+ for (const parser of [ofdZipXmlParser]) {
6
+ if (registry.has(parser.id))
7
+ throw new Error(`duplicate parser id: ${parser.id}`);
8
+ registry.set(parser.id, parser);
9
+ }
10
+ Object.freeze([...registry.keys()]);
11
+ export const DEFAULT_PARSER_ID = ofdZipXmlParser.id;
12
+ export function getParser(id = DEFAULT_PARSER_ID) {
13
+ const parser = registry.get(id);
14
+ if (!parser)
15
+ throw new InvoiceError('E_INV_UNKNOWN_IMPL', `unknown parser implementation: ${id}`);
16
+ return parser;
17
+ }
18
+ export function listParsers() {
19
+ return [...registry.keys()];
20
+ }