weaver-work-cli 0.1.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.
Files changed (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +422 -0
  3. package/dist/cmd/action.js +35 -0
  4. package/dist/cmd/commands/index.js +15 -0
  5. package/dist/cmd/config/index.js +64 -0
  6. package/dist/cmd/docs/index.js +56 -0
  7. package/dist/cmd/doctor/index.js +45 -0
  8. package/dist/cmd/e10/index.js +25 -0
  9. package/dist/cmd/index.js +18 -0
  10. package/dist/cmd/register.js +19 -0
  11. package/dist/cmd/root.js +36 -0
  12. package/dist/cmd/run/index.js +38 -0
  13. package/dist/cmd/setup/index.js +77 -0
  14. package/dist/cmd/skills/index.js +72 -0
  15. package/dist/core/config-store.js +43 -0
  16. package/dist/core/context.js +31 -0
  17. package/dist/core/errors.js +60 -0
  18. package/dist/core/hooks.js +41 -0
  19. package/dist/core/logger.js +27 -0
  20. package/dist/core/output.js +25 -0
  21. package/dist/core/write-with-retry.js +21 -0
  22. package/dist/index.js +11 -0
  23. package/dist/internal/e10/auth/commands.js +594 -0
  24. package/dist/internal/e10/auth/crypto.js +126 -0
  25. package/dist/internal/e10/auth/paths.js +12 -0
  26. package/dist/internal/e10/auth/session.js +449 -0
  27. package/dist/internal/e10/client.js +104 -0
  28. package/dist/internal/e10/context.js +20 -0
  29. package/dist/internal/e10/index.js +2 -0
  30. package/dist/internal/e10/types.js +1 -0
  31. package/dist/internal/skills/install.js +99 -0
  32. package/dist/internal/skills/reader.js +119 -0
  33. package/dist/services/command-runner.js +44 -0
  34. package/dist/shortcuts/index.js +4 -0
  35. package/dist/shortcuts/invoice/continuation.js +70 -0
  36. package/dist/shortcuts/invoice/errors.js +59 -0
  37. package/dist/shortcuts/invoice/host.js +337 -0
  38. package/dist/shortcuts/invoice/index.js +301 -0
  39. package/dist/shortcuts/invoice/manifest.js +288 -0
  40. package/dist/shortcuts/invoice/operations/add.js +106 -0
  41. package/dist/shortcuts/invoice/operations/delete.js +92 -0
  42. package/dist/shortcuts/invoice/operations/download.js +16 -0
  43. package/dist/shortcuts/invoice/operations/get.js +7 -0
  44. package/dist/shortcuts/invoice/operations/import.js +128 -0
  45. package/dist/shortcuts/invoice/operations/list.js +12 -0
  46. package/dist/shortcuts/invoice/operations/ocr-preview.js +40 -0
  47. package/dist/shortcuts/invoice/operations/registry.js +50 -0
  48. package/dist/shortcuts/invoice/operations/shared.js +640 -0
  49. package/dist/shortcuts/invoice/operations/types.js +1 -0
  50. package/dist/shortcuts/invoice/operations/update.js +109 -0
  51. package/dist/shortcuts/invoice/operations/upload.js +8 -0
  52. package/dist/shortcuts/invoice/operations/validate-preview.js +39 -0
  53. package/dist/shortcuts/invoice/operations.js +8 -0
  54. package/dist/types.js +1 -0
  55. package/docs/SKILL.md +36 -0
  56. package/docs/_catalog.md +12 -0
  57. package/docs/agent-invoice.md +53 -0
  58. package/docs/e10-auth.md +51 -0
  59. package/docs/invoice.md +99 -0
  60. package/package.json +51 -0
  61. package/scripts/clean.mjs +4 -0
  62. package/scripts/package-skill.mjs +164 -0
  63. package/scripts/sync-docs.mjs +17 -0
  64. package/skill-template/business-info.json +4 -0
  65. package/skill-template/domains/invoice.md +23 -0
  66. package/skill-template/domains/shared.md +8 -0
  67. package/skill-template/master-skill-template.md +29 -0
  68. package/skill-template/skill-template.md +60 -0
  69. package/skills/weaver-work-cli-invoice/SKILL.md +103 -0
  70. package/skills/weaver-work-cli-invoice/product.json +8 -0
  71. package/skills/weaver-work-cli-invoice/references/invoice-add.md +18 -0
  72. package/skills/weaver-work-cli-invoice/references/invoice-agent-entry.md +38 -0
  73. package/skills/weaver-work-cli-invoice/references/invoice-delete.md +20 -0
  74. package/skills/weaver-work-cli-invoice/references/invoice-detail.md +29 -0
  75. package/skills/weaver-work-cli-invoice/references/invoice-disabled-capabilities.md +26 -0
  76. package/skills/weaver-work-cli-invoice/references/invoice-download.md +22 -0
  77. package/skills/weaver-work-cli-invoice/references/invoice-enterprise-list.md +35 -0
  78. package/skills/weaver-work-cli-invoice/references/invoice-file-upload.md +28 -0
  79. package/skills/weaver-work-cli-invoice/references/invoice-import.md +28 -0
  80. package/skills/weaver-work-cli-invoice/references/invoice-ocr-preview.md +33 -0
  81. package/skills/weaver-work-cli-invoice/references/invoice-personal-list.md +47 -0
  82. package/skills/weaver-work-cli-invoice/references/invoice-update.md +38 -0
  83. package/skills/weaver-work-cli-invoice/references/invoice-validation-preview.md +26 -0
  84. package/skills/weaver-work-cli-shared/SKILL.md +53 -0
  85. package/skills/weaver-work-cli-shared/references/e10-auth-and-session.md +83 -0
  86. package/skills/weaver-work-cli-shared/references/high-risk-write.md +36 -0
  87. package/skills/weaver-work-cli-shared/references/json-output-contract.md +61 -0
  88. package/skills/weaver-work-cli-shared/references/weaver-work-cli-installation.md +61 -0
  89. package/templates/shortcut/index.ts.tpl +19 -0
  90. package/templates/shortcut/operations/ping.ts.tpl +7 -0
@@ -0,0 +1,640 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync } from 'node:fs';
3
+ import { readFile, stat, writeFile } from 'node:fs/promises';
4
+ import { dirname, extname, basename, resolve } from 'node:path';
5
+ import { InvoiceCliError, invoiceValidationError, } from '../errors.js';
6
+ const LIST_FIELDS = new Set([
7
+ 'page_size',
8
+ 'start_pos',
9
+ 'req_type',
10
+ 'mask',
11
+ 'mext',
12
+ 'content',
13
+ 'types',
14
+ 'date_begin',
15
+ 'date_end',
16
+ 'create_tm_begin',
17
+ 'create_tm_end',
18
+ 'payer_company',
19
+ 'buyer_company',
20
+ 'payer_taxno',
21
+ 'buyer_taxno',
22
+ 'code',
23
+ 'number',
24
+ 'total_begin',
25
+ 'total_end',
26
+ 'attribute',
27
+ 'sreim',
28
+ 'valids',
29
+ 'sources',
30
+ 'is_lian',
31
+ 'is_modify',
32
+ 'tag_ids',
33
+ 'tag_condition_type',
34
+ 'sorts',
35
+ 'bill_type',
36
+ ]);
37
+ const FILE_EXTENSIONS = new Set(['pdf', 'ofd', 'xml', 'png', 'jpg', 'jpeg']);
38
+ const MIME_TYPES = {
39
+ pdf: 'application/pdf',
40
+ ofd: 'application/ofd',
41
+ xml: 'application/xml',
42
+ png: 'image/png',
43
+ jpg: 'image/jpeg',
44
+ jpeg: 'image/jpeg',
45
+ };
46
+ const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
47
+ const MAIN_INVOICE_TYPES = new Set([1, 2, 3, 4, 5, 10, 22, 28]);
48
+ const CODE_REQUIRED_TYPES = new Set([1, 2, 3, 4, 5, 6, 7, 28, 49, 50]);
49
+ const CHECK_CODE_REQUIRED_TYPES = new Set([2, 3, 4, 5]);
50
+ const AMOUNT_REQUIRED_TYPES = new Set([1, 6, 28, 49]);
51
+ const TOTAL_REQUIRED_TYPES = new Set([7, 32, 33, 47, 48, 52, 53, 54, 56]);
52
+ const MONEY_PATTERN = /^\d+(?:\.\d{1,2})?$/u;
53
+ const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/u;
54
+ const FORBIDDEN_INPUT_KEYS = new Set([
55
+ 'access_token',
56
+ 'app_key',
57
+ 'app_secret',
58
+ 'cookie',
59
+ 'eteamsid',
60
+ 'token',
61
+ 'sedit',
62
+ 'dubboFlag',
63
+ 'dubboToken',
64
+ 'skipFlag',
65
+ 'secondSectionType',
66
+ ]);
67
+ const FORBIDDEN_NORMALIZED_KEYS = new Set([...FORBIDDEN_INPUT_KEYS].map((key) => key.toLowerCase()));
68
+ const UPDATE_FIELDS = new Map([
69
+ ['code', 'string'],
70
+ ['number', 'string'],
71
+ ['comm_info.pro.date', 'date'],
72
+ ['comm_info.price.amount', 'money'],
73
+ ['comm_info.price.total', 'money'],
74
+ ['comm_info.price.dtax', 'money'],
75
+ ['comm_info.buyer.company', 'string'],
76
+ ['comm_info.buyer.tcode', 'string'],
77
+ ['comm_info.payer.company', 'string'],
78
+ ['comm_info.payer.tcode', 'string'],
79
+ ['fylx', 'string'],
80
+ ['currency', 'string'],
81
+ ['currencyName', 'string'],
82
+ ]);
83
+ export function asObject(value, name = '业务输入') {
84
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
85
+ throw invoiceValidationError('input_invalid', `${name}必须是 JSON 对象`);
86
+ }
87
+ return value;
88
+ }
89
+ export function asPositiveString(value, name) {
90
+ if (typeof value !== 'string' || !/^\d+$/u.test(value) || value === '0') {
91
+ throw invoiceValidationError('input_invalid', `${name} 必须是正整数文本`);
92
+ }
93
+ return value;
94
+ }
95
+ export function reqBase({ numericTrace = false } = {}) {
96
+ const trace = Date.now();
97
+ return { trace: numericTrace ? trace : String(trace), dev: 1 };
98
+ }
99
+ function canonicalJson(value) {
100
+ if (Array.isArray(value))
101
+ return `[${value.map(canonicalJson).join(',')}]`;
102
+ if (value && typeof value === 'object') {
103
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`;
104
+ }
105
+ return JSON.stringify(value);
106
+ }
107
+ export function objectFingerprint(value) {
108
+ return createHash('sha256').update(canonicalJson(value)).digest('hex');
109
+ }
110
+ export function assertNoForbiddenKeys(value, location = 'input') {
111
+ if (!value || typeof value !== 'object')
112
+ return;
113
+ if (Array.isArray(value)) {
114
+ value.forEach((item, index) => assertNoForbiddenKeys(item, `${location}[${index}]`));
115
+ return;
116
+ }
117
+ for (const [key, child] of Object.entries(value)) {
118
+ if (FORBIDDEN_INPUT_KEYS.has(key) || FORBIDDEN_NORMALIZED_KEYS.has(key.toLowerCase())) {
119
+ throw invoiceValidationError('field_forbidden', `禁止业务输入字段:${location}.${key}`);
120
+ }
121
+ assertNoForbiddenKeys(child, `${location}.${key}`);
122
+ }
123
+ }
124
+ export function stripForbiddenKeys(value) {
125
+ if (Array.isArray(value))
126
+ return value.map(stripForbiddenKeys);
127
+ if (!value || typeof value !== 'object')
128
+ return value;
129
+ const result = {};
130
+ for (const [key, child] of Object.entries(value)) {
131
+ if (FORBIDDEN_INPUT_KEYS.has(key) || FORBIDDEN_NORMALIZED_KEYS.has(key.toLowerCase()))
132
+ continue;
133
+ result[key] = stripForbiddenKeys(child);
134
+ }
135
+ return result;
136
+ }
137
+ export function normalizeScope(scope) {
138
+ if (scope !== 'personal' && scope !== 'enterprise') {
139
+ throw invoiceValidationError('scope_invalid', 'scope 必须是 personal 或 enterprise');
140
+ }
141
+ return scope;
142
+ }
143
+ export function operateType(scope) {
144
+ return normalizeScope(scope) === 'enterprise' ? 2 : 0;
145
+ }
146
+ export function sessionMatches(lease, session) {
147
+ return lease.baseUrl === session.baseUrl && lease.profile === session.profile && lease.userId === session.userId;
148
+ }
149
+ function ensureMoney(value, name, { required = false } = {}) {
150
+ if (value === undefined || value === null || value === '') {
151
+ if (required)
152
+ throw invoiceValidationError('invoice_field_required', `${name} 必填`);
153
+ return;
154
+ }
155
+ if (typeof value !== 'string' || !MONEY_PATTERN.test(value)) {
156
+ throw invoiceValidationError('invoice_field_invalid', `${name} 必须是最多两位小数的非负金额文本`);
157
+ }
158
+ }
159
+ function ensureDate(value, name, { required = false } = {}) {
160
+ if (value === undefined || value === null || value === '') {
161
+ if (required)
162
+ throw invoiceValidationError('invoice_field_required', `${name} 必填`);
163
+ return;
164
+ }
165
+ if (typeof value !== 'string' || !DATE_PATTERN.test(value) || Number.isNaN(Date.parse(`${value}T00:00:00Z`))) {
166
+ throw invoiceValidationError('invoice_field_invalid', `${name} 必须是有效的 YYYY-MM-DD`);
167
+ }
168
+ const today = new Date().toISOString().slice(0, 10);
169
+ if (value > today)
170
+ throw invoiceValidationError('invoice_field_invalid', `${name} 不能晚于今天`);
171
+ }
172
+ export function identityFingerprint(info) {
173
+ return objectFingerprint({
174
+ code: info.code || '',
175
+ number: info.number,
176
+ type: info.comm_info?.pro?.type,
177
+ date: info.comm_info?.pro?.date || '',
178
+ amount: info.comm_info?.price?.amount || '',
179
+ total: info.comm_info?.price?.total || '',
180
+ buyerTaxNo: info.comm_info?.buyer?.tcode || '',
181
+ payerTaxNo: info.comm_info?.payer?.tcode || '',
182
+ });
183
+ }
184
+ export function detailFingerprint(info) {
185
+ const p = info.comm_info?.pro || {};
186
+ const price = info.comm_info?.price || {};
187
+ return createHash('sha256').update(JSON.stringify({
188
+ fid: info.fid || info.id,
189
+ code: info.code,
190
+ number: info.number,
191
+ date: p.date,
192
+ total: price.total,
193
+ status: p.status,
194
+ valid: p.valid,
195
+ sreim: p.sreim,
196
+ del: info.modify_info?.del,
197
+ })).digest('hex');
198
+ }
199
+ export function detailInfo(value) {
200
+ const item = Array.isArray(value?.infos)
201
+ ? value.infos.find((candidate) => candidate?.ret?.ret === 0 && candidate.info)
202
+ : undefined;
203
+ if (!item) {
204
+ throw new InvoiceCliError('api', 'invoice_not_found', '未找到有权查看的发票', {
205
+ details: { result: value },
206
+ exitCode: 1,
207
+ });
208
+ }
209
+ return item.info;
210
+ }
211
+ export async function getDetail(input, host) {
212
+ const body = {};
213
+ if (input.fid !== undefined)
214
+ body.ids = [asPositiveString(input.fid, 'fid')];
215
+ if (body.ids === undefined && input.number !== undefined) {
216
+ if (typeof input.number !== 'string' || !input.number.trim()) {
217
+ throw invoiceValidationError('input_invalid', 'number 不能为空');
218
+ }
219
+ body.number = input.number.trim();
220
+ }
221
+ if (!body.ids && !body.number)
222
+ throw invoiceValidationError('input_invalid', '必须提供 fid 或 number');
223
+ return host.invoiceRequest({
224
+ path: '/api/inc/get',
225
+ method: 'POST',
226
+ body: { ...body, userFlag: '1', isDelFlag: '1', req_base: reqBase() },
227
+ });
228
+ }
229
+ export function preview(info) {
230
+ const p = info.comm_info?.pro || {};
231
+ const price = info.comm_info?.price || {};
232
+ const rawFid = info.fid ?? info.id;
233
+ return {
234
+ fid: rawFid === undefined || rawFid === null ? '' : String(rawFid),
235
+ code: info.code || '',
236
+ number: info.number || '',
237
+ date: p.date || '',
238
+ total: price.total || '',
239
+ buyer: info.comm_info?.buyer?.company || '',
240
+ payer: info.comm_info?.payer?.company || '',
241
+ valid: p.valid,
242
+ reimbursement: p.sreim,
243
+ status: p.status,
244
+ };
245
+ }
246
+ export function validationPreview(info, scope, targetFid) {
247
+ const p = info?.comm_info?.pro || {};
248
+ const item = preview(info);
249
+ return {
250
+ ...item,
251
+ fid: targetFid,
252
+ targetFid,
253
+ resultFid: item.fid,
254
+ scope,
255
+ validation: p.valid,
256
+ invoiceStatus: p.status,
257
+ saved: false,
258
+ };
259
+ }
260
+ export function workflow(name, state) {
261
+ return { workflow: name, version: 1, state };
262
+ }
263
+ function uploadSource(input) {
264
+ const { file, url } = input;
265
+ if (file !== undefined && url !== undefined)
266
+ throw invoiceValidationError('input_ambiguous', 'file 与 url 只能提供一个');
267
+ if (file === undefined && url === undefined)
268
+ throw invoiceValidationError('file_required', '必须提供本地 file 路径或可访问的 url');
269
+ if (file !== undefined) {
270
+ if (typeof file !== 'string' || !file.trim())
271
+ throw invoiceValidationError('file_invalid', 'file 必须是本地文件路径');
272
+ return { kind: 'file', path: resolve(file) };
273
+ }
274
+ if (typeof url !== 'string' || !url.trim())
275
+ throw invoiceValidationError('url_invalid', 'url 必须是非空 URL');
276
+ let parsed;
277
+ try {
278
+ parsed = new URL(url);
279
+ }
280
+ catch {
281
+ throw invoiceValidationError('url_invalid', 'url 不是合法 URL');
282
+ }
283
+ if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) {
284
+ throw invoiceValidationError('url_invalid', 'url 只允许 http/https 且不能包含账号密码');
285
+ }
286
+ return { kind: 'url', url: parsed.href };
287
+ }
288
+ async function normalizeFileSource(source) {
289
+ if (source.kind === 'url')
290
+ return { fields: { url: source.url } };
291
+ let metadata;
292
+ try {
293
+ metadata = await stat(source.path);
294
+ }
295
+ catch {
296
+ throw invoiceValidationError('file_not_found', `文件不存在或不可读取:${source.path}`);
297
+ }
298
+ if (!metadata.isFile())
299
+ throw invoiceValidationError('file_invalid', 'file 必须指向普通文件');
300
+ if (metadata.size <= 0)
301
+ throw invoiceValidationError('file_empty', '不能上传空文件');
302
+ if (metadata.size > MAX_UPLOAD_BYTES)
303
+ throw invoiceValidationError('file_too_large', `文件不能超过 ${MAX_UPLOAD_BYTES} 字节`);
304
+ const extension = extname(source.path).slice(1).toLowerCase();
305
+ if (!FILE_EXTENSIONS.has(extension)) {
306
+ throw invoiceValidationError('file_type_unsupported', `不支持的发票文件类型:${extension || '(无扩展名)'}`);
307
+ }
308
+ return {
309
+ fields: {},
310
+ file: { path: source.path, name: basename(source.path), mimeType: MIME_TYPES[extension] },
311
+ };
312
+ }
313
+ export async function importFile(input) {
314
+ if (input.url !== undefined)
315
+ throw invoiceValidationError('import_url_unsupported', '确定性导入当前只接受本地 file,不接受远程 URL');
316
+ const source = uploadSource({ file: input.file });
317
+ const prepared = await normalizeFileSource(source);
318
+ if (!prepared.file)
319
+ throw invoiceValidationError('file_required', '必须提供本地 file 路径');
320
+ const content = await readFile(prepared.file.path);
321
+ return {
322
+ file: prepared.file,
323
+ size: content.length,
324
+ sha256: createHash('sha256').update(content).digest('hex'),
325
+ };
326
+ }
327
+ export function importOptions(input) {
328
+ if (typeof input.validate !== 'boolean' || typeof input.syncToOa !== 'boolean') {
329
+ throw invoiceValidationError('import_option_required', 'validate 和 syncToOa 必须显式提供布尔值');
330
+ }
331
+ if (input.validate && !input.syncToOa) {
332
+ throw invoiceValidationError('import_option_conflict', 'validate=true 分支由服务端固定同步 OA,不能设置 syncToOa=false');
333
+ }
334
+ return { validate: input.validate, syncToOa: input.validate ? true : input.syncToOa };
335
+ }
336
+ function normalizeUploadResponse(value, source) {
337
+ if (value?.fail === true) {
338
+ throw new InvoiceCliError('api', 'upload_failed', value?.res_base?.message || '发票文件上传或切割失败', {
339
+ details: { source: source.kind },
340
+ exitCode: 1,
341
+ });
342
+ }
343
+ const rawIds = Array.isArray(value?.ids) ? value.ids : (value?.id === undefined ? [] : [value.id]);
344
+ const ids = rawIds
345
+ .filter((id) => id !== undefined && id !== null && String(id).trim())
346
+ .map((id) => String(id));
347
+ if (!ids.length) {
348
+ throw new InvoiceCliError('api', 'upload_empty', '上传成功但服务端没有返回文件 ID', {
349
+ details: { source: source.kind },
350
+ exitCode: 1,
351
+ });
352
+ }
353
+ const id = value?.id === undefined || value.id === null ? ids[0] : String(value.id);
354
+ const fileType = typeof value?.fileType === 'string' ? value.fileType.toLowerCase() : '';
355
+ if (!FILE_EXTENSIONS.has(fileType)) {
356
+ throw new InvoiceCliError('api', 'upload_type_missing', '上传成功但服务端没有返回受支持的文件类型', { exitCode: 1 });
357
+ }
358
+ return {
359
+ id,
360
+ ids,
361
+ fileType,
362
+ fileName: typeof value.fileName === 'string' ? value.fileName : undefined,
363
+ totalPages: Number.isInteger(value.totalPages) ? value.totalPages : ids.length,
364
+ hasFanWei: value.hasFanWei === true,
365
+ ocrMap: typeof value.ocrMap === 'string' ? value.ocrMap : undefined,
366
+ fail: false,
367
+ };
368
+ }
369
+ export async function uploadInvoiceFile(input, host) {
370
+ const source = uploadSource(input);
371
+ const prepared = await normalizeFileSource(source);
372
+ const result = await host.invoiceMultipartRequest({
373
+ path: '/api/inc/file/uploadFileSpilit',
374
+ fields: { ...prepared.fields, ocr: '1' },
375
+ file: prepared.file,
376
+ });
377
+ return normalizeUploadResponse(result, source);
378
+ }
379
+ export function uploadFromInput(input) {
380
+ if (input.upload === undefined)
381
+ return null;
382
+ if (!input.upload || typeof input.upload !== 'object' || Array.isArray(input.upload)) {
383
+ throw invoiceValidationError('upload_invalid', 'upload 必须是 upload 操作返回的 JSON 对象');
384
+ }
385
+ const upload = input.upload;
386
+ const rawIds = Array.isArray(upload.ids) ? upload.ids : (upload.id === undefined ? [] : [upload.id]);
387
+ const ids = rawIds
388
+ .filter((id) => id !== undefined && id !== null && String(id).trim())
389
+ .map((id) => String(id));
390
+ const fileType = typeof upload.fileType === 'string' ? upload.fileType.toLowerCase() : '';
391
+ if (!ids.length || !FILE_EXTENSIONS.has(fileType)) {
392
+ throw invoiceValidationError('upload_invalid', 'upload 缺少有效的 id/ids 或 fileType');
393
+ }
394
+ return {
395
+ id: upload.id === undefined ? ids[0] : String(upload.id),
396
+ ids,
397
+ fileType,
398
+ fileName: typeof upload.fileName === 'string' ? upload.fileName : undefined,
399
+ totalPages: Number.isInteger(upload.totalPages) ? upload.totalPages : ids.length,
400
+ hasFanWei: upload.hasFanWei === true,
401
+ ocrMap: typeof upload.ocrMap === 'string' ? upload.ocrMap : undefined,
402
+ fail: false,
403
+ };
404
+ }
405
+ function ocrInfo(result) {
406
+ const infos = Array.isArray(result?.infos) ? result.infos : [];
407
+ const failed = infos.filter((item) => item?.ret?.ret !== 0);
408
+ if (failed.length) {
409
+ throw new InvoiceCliError('api', 'ocr_item_failed', '部分文件页 OCR 失败', {
410
+ details: { failed: failed.map((item) => item?.ret?.message || 'OCR 失败') },
411
+ exitCode: 1,
412
+ });
413
+ }
414
+ if (!infos.length)
415
+ throw new InvoiceCliError('api', 'ocr_empty', 'OCR 未返回任何发票结果', { exitCode: 1 });
416
+ return infos;
417
+ }
418
+ export async function previewOcr(upload, host) {
419
+ const results = [];
420
+ const invoices = [];
421
+ let latestMainFid = '';
422
+ const knownMainFids = [];
423
+ for (const imageIndex of upload.ids) {
424
+ const body = {
425
+ image_index: imageIndex,
426
+ fileTypeStr: upload.fileType,
427
+ flag: 13,
428
+ is_save: 1,
429
+ is_sync: 1,
430
+ operate_type: 0,
431
+ req_base: reqBase(),
432
+ };
433
+ if (upload.ocrMap !== undefined)
434
+ body.ocrMap = upload.ocrMap;
435
+ if (upload.ids.length > 1) {
436
+ body.origin = {
437
+ id: upload.id,
438
+ name: upload.fileName || '',
439
+ type: upload.fileType,
440
+ fid: latestMainFid,
441
+ fids: JSON.stringify(knownMainFids),
442
+ };
443
+ }
444
+ const result = await host.invoiceRequest({ path: '/api/inc/ocr', method: 'POST', body });
445
+ const infos = ocrInfo(result);
446
+ results.push(result);
447
+ invoices.push(...infos);
448
+ for (const item of infos) {
449
+ const info = item?.info;
450
+ const type = Number(info?.comm_info?.pro?.type);
451
+ const fid = info?.fid ?? info?.id;
452
+ if (MAIN_INVOICE_TYPES.has(type) && fid !== undefined && String(fid).trim()) {
453
+ latestMainFid = String(fid);
454
+ knownMainFids.push({ fid: latestMainFid, type });
455
+ }
456
+ }
457
+ }
458
+ return { results, invoices };
459
+ }
460
+ export async function listInvoices(input, host, flag) {
461
+ const body = { flag, page_size: 10, start_pos: 0, req_type: 1, sreim: 3, req_base: reqBase() };
462
+ for (const [key, value] of Object.entries(input)) {
463
+ if (key === 'flag')
464
+ continue;
465
+ if (!LIST_FIELDS.has(key))
466
+ throw invoiceValidationError('input_field_unsupported', `列表查询不支持字段:${key}`);
467
+ body[key] = value;
468
+ }
469
+ const result = await host.invoiceRequest({ path: '/api/inc/sync', method: 'POST', body });
470
+ return {
471
+ status: 'COMPLETE',
472
+ data: {
473
+ items: result.infos || [],
474
+ total: result.total ?? 0,
475
+ totalAmount: result.totalTn ?? '0',
476
+ totalAmountExcludingTax: result.totalAm ?? '0',
477
+ hasMore: result.end_flag === 1,
478
+ scope: flag === 6 ? 'enterprise' : 'personal',
479
+ },
480
+ };
481
+ }
482
+ export function normalizeAddInfo(rawInfo) {
483
+ const info = structuredClone(asObject(rawInfo, 'info'));
484
+ assertNoForbiddenKeys(info, 'info');
485
+ if (typeof info.number !== 'string' || !info.number.trim())
486
+ throw invoiceValidationError('invoice_field_required', 'info.number 必填');
487
+ info.number = info.number.trim();
488
+ const pro = info.comm_info?.pro;
489
+ if (!pro || typeof pro !== 'object' || !Number.isSafeInteger(pro.type) || pro.type <= 0) {
490
+ throw invoiceValidationError('invoice_field_required', 'info.comm_info.pro.type 必须是正整数');
491
+ }
492
+ if (CODE_REQUIRED_TYPES.has(pro.type) && (typeof info.code !== 'string' || !info.code.trim())) {
493
+ throw invoiceValidationError('invoice_field_required', `票种 ${pro.type} 必须提供 info.code`);
494
+ }
495
+ if (CHECK_CODE_REQUIRED_TYPES.has(pro.type) && (typeof pro.chcode !== 'string' || !pro.chcode.trim())) {
496
+ throw invoiceValidationError('invoice_field_required', `票种 ${pro.type} 必须提供 info.comm_info.pro.chcode`);
497
+ }
498
+ if (pro.type === 50 && (typeof pro.electronicNumber !== 'string' || !pro.electronicNumber.trim())) {
499
+ throw invoiceValidationError('invoice_field_required', '票种 50 必须提供 electronicNumber');
500
+ }
501
+ ensureDate(pro.date, 'info.comm_info.pro.date');
502
+ const price = info.comm_info?.price || {};
503
+ ensureMoney(price.amount, 'info.comm_info.price.amount', { required: AMOUNT_REQUIRED_TYPES.has(pro.type) });
504
+ ensureMoney(price.total, 'info.comm_info.price.total', { required: TOTAL_REQUIRED_TYPES.has(pro.type) });
505
+ ensureMoney(price.dtax, 'info.comm_info.price.dtax');
506
+ if (info.ext !== undefined) {
507
+ if (typeof info.ext !== 'string')
508
+ throw invoiceValidationError('invoice_field_invalid', 'info.ext 必须是 JSON 字符串');
509
+ try {
510
+ JSON.parse(info.ext);
511
+ }
512
+ catch {
513
+ throw invoiceValidationError('invoice_field_invalid', 'info.ext 不是合法 JSON 字符串');
514
+ }
515
+ }
516
+ if (!info.modify_info || typeof info.modify_info !== 'object' || Array.isArray(info.modify_info))
517
+ info.modify_info = {};
518
+ if (info.modify_info.source === undefined)
519
+ info.modify_info.source = 7;
520
+ if (info.currency === undefined)
521
+ info.currency = 'CNY';
522
+ if (info.currencyName === undefined)
523
+ info.currencyName = '人民币';
524
+ return info;
525
+ }
526
+ export async function assertNotDuplicate(info, host) {
527
+ try {
528
+ const result = await getDetail({ number: info.number }, host);
529
+ const duplicate = result?.infos?.find((item) => item?.ret?.ret === 0 && item.info);
530
+ if (duplicate) {
531
+ throw invoiceValidationError('invoice_duplicate', '当前上下文已存在相同号码的发票', { existing: preview(duplicate.info) });
532
+ }
533
+ }
534
+ catch (error) {
535
+ if (error instanceof InvoiceCliError
536
+ && (error.code === '300241' || error.subtype === 'invoice_not_found')) {
537
+ return;
538
+ }
539
+ throw error;
540
+ }
541
+ }
542
+ export function addPreview(info, scope) {
543
+ return { ...preview(info), type: info.comm_info?.pro?.type, scope };
544
+ }
545
+ function getPath(value, fieldPath) {
546
+ return fieldPath.split('.').reduce((current, key) => current?.[key], value);
547
+ }
548
+ function setPath(value, fieldPath, next) {
549
+ const parts = fieldPath.split('.');
550
+ let current = value;
551
+ for (const key of parts.slice(0, -1)) {
552
+ if (!current[key] || typeof current[key] !== 'object' || Array.isArray(current[key]))
553
+ current[key] = {};
554
+ current = current[key];
555
+ }
556
+ current[parts[parts.length - 1]] = next;
557
+ }
558
+ export function normalizeChanges(rawChanges) {
559
+ const changes = asObject(rawChanges, 'changes');
560
+ const entries = Object.entries(changes);
561
+ if (!entries.length)
562
+ throw invoiceValidationError('changes_empty', 'changes 至少包含一个允许修改的字段');
563
+ if (entries.length > 20)
564
+ throw invoiceValidationError('changes_too_many', '单次最多修改 20 个字段');
565
+ for (const [field, value] of entries) {
566
+ const kind = UPDATE_FIELDS.get(field);
567
+ if (!kind)
568
+ throw invoiceValidationError('update_field_unsupported', `不支持修改字段:${field}`);
569
+ if (kind === 'money')
570
+ ensureMoney(value, field, { required: true });
571
+ else if (kind === 'date')
572
+ ensureDate(value, field, { required: true });
573
+ else if (typeof value !== 'string' || value.length > 500 || /[\r\n]/u.test(value)) {
574
+ throw invoiceValidationError('invoice_field_invalid', `${field} 必须是 500 字符以内的单行文本`);
575
+ }
576
+ if (field === 'number' && typeof value === 'string' && !value.trim()) {
577
+ throw invoiceValidationError('invoice_field_invalid', 'number 不能为空');
578
+ }
579
+ }
580
+ return structuredClone(changes);
581
+ }
582
+ export function applyChanges(info, changes) {
583
+ const updated = structuredClone(info);
584
+ for (const [field, value] of Object.entries(changes))
585
+ setPath(updated, field, value);
586
+ return updated;
587
+ }
588
+ export function changePreview(info, changes) {
589
+ return Object.entries(changes).map(([field, to]) => ({ field, from: getPath(info, field), to }));
590
+ }
591
+ export function assertEditable(info) {
592
+ const p = info.comm_info?.pro || {};
593
+ if ((p.sreim ?? 0) !== 0 || (p.status ?? 0) !== 0 || (info.modify_info?.del ?? 0) !== 0) {
594
+ throw invoiceValidationError('invoice_not_editable', '发票当前状态不允许编辑', { status: preview(info) });
595
+ }
596
+ }
597
+ export function verifyChangedFields(info, changes) {
598
+ return Object.entries(changes).every(([field, value]) => Object.is(getPath(info, field), value));
599
+ }
600
+ export function associatedFileIds(info) {
601
+ const values = new Set();
602
+ for (const item of info.flist || []) {
603
+ if (item?.id !== undefined && item.id !== null)
604
+ values.add(String(item.id));
605
+ }
606
+ for (const field of ['furl', 'pdf', 'xml']) {
607
+ const value = info.modify_info?.[field];
608
+ if (value !== undefined && value !== null && String(value).trim())
609
+ values.add(String(value));
610
+ }
611
+ return values;
612
+ }
613
+ export async function resolveDownloadOutput(output) {
614
+ if (typeof output !== 'string' || !output.trim())
615
+ throw invoiceValidationError('output_required', 'output 必须是明确的本地文件路径');
616
+ const resolved = resolve(output);
617
+ let parent;
618
+ try {
619
+ parent = await stat(dirname(resolved));
620
+ }
621
+ catch {
622
+ throw invoiceValidationError('output_parent_missing', `输出目录不存在:${dirname(resolved)}`);
623
+ }
624
+ if (!parent.isDirectory())
625
+ throw invoiceValidationError('output_parent_invalid', '输出文件的父路径不是目录');
626
+ if (existsSync(resolved))
627
+ throw invoiceValidationError('output_exists', `输出文件已存在,拒绝覆盖:${resolved}`);
628
+ return resolved;
629
+ }
630
+ export async function writeDownload(resolved, content) {
631
+ try {
632
+ await writeFile(resolved, content, { flag: 'wx', mode: 0o600 });
633
+ }
634
+ catch (error) {
635
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'EEXIST') {
636
+ throw invoiceValidationError('output_exists', `输出文件已存在,拒绝覆盖:${resolved}`);
637
+ }
638
+ throw error;
639
+ }
640
+ }
@@ -0,0 +1 @@
1
+ export {};