weaver-work-cli 0.1.0 → 0.1.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 +15 -2
- package/dist/internal/e10/auth/commands.js +24 -3
- package/dist/internal/e10/auth/crypto.js +7 -22
- package/dist/internal/e10/auth/session.js +1 -1
- package/dist/shortcuts/invoice/host.js +129 -103
- package/dist/shortcuts/invoice/index.js +7 -2
- package/dist/shortcuts/invoice/manifest.js +22 -11
- package/dist/shortcuts/invoice/operations/ocr-preview.js +6 -3
- package/dist/shortcuts/invoice/operations/shared.js +24 -6
- package/dist/shortcuts/invoice/operations/validate-preview.js +1 -1
- package/docs/_catalog.md +1 -0
- package/docs/agent-invoice.md +12 -1
- package/docs/agent-skill-install.md +92 -0
- package/docs/e10-auth.md +7 -3
- package/docs/invoice.md +9 -4
- package/package.json +1 -1
- package/skill-template/domains/shared.md +6 -2
- package/skill-template/skill-template.md +15 -0
- package/skills/weaver-work-cli-invoice/SKILL.md +21 -2
- package/skills/weaver-work-cli-invoice/references/invoice-agent-entry.md +2 -0
- package/skills/weaver-work-cli-invoice/references/invoice-download.md +2 -0
- package/skills/weaver-work-cli-invoice/references/invoice-enterprise-list.md +6 -4
- package/skills/weaver-work-cli-invoice/references/invoice-file-upload.md +6 -2
- package/skills/weaver-work-cli-invoice/references/invoice-import.md +10 -2
- package/skills/weaver-work-cli-invoice/references/invoice-ocr-preview.md +2 -0
- package/skills/weaver-work-cli-invoice/references/invoice-personal-list.md +8 -5
- package/skills/weaver-work-cli-invoice/references/invoice-validation-preview.md +1 -1
- package/skills/weaver-work-cli-shared/SKILL.md +16 -6
- package/skills/weaver-work-cli-shared/references/e10-auth-and-session.md +8 -1
- package/skills/weaver-work-cli-shared/references/json-output-contract.md +25 -0
- package/skills/weaver-work-cli-shared/references/weaver-work-cli-installation.md +13 -1
package/README.md
CHANGED
|
@@ -91,7 +91,15 @@ weaver-work-cli skills install invoice
|
|
|
91
91
|
`skills install invoice` 会同时安装 `weaver-work-cli-shared` 和
|
|
92
92
|
`weaver-work-cli-invoice`。默认目标目录是 `$CODEX_HOME/skills`,未设置
|
|
93
93
|
`CODEX_HOME` 时使用 `~/.codex/skills`;需要自定义时可加
|
|
94
|
-
`--target-dir <path
|
|
94
|
+
`--target-dir <path>`。默认目标面向 Codex CLI;WorkBuddy 等桌面 Agent 的
|
|
95
|
+
用户级 Skill 目录不是默认目标,需显式指定:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
weaver-work-cli skills install invoice --target-dir ~/.workbuddy/skills
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
各 Agent 环境的 skill 目录、从本地开发仓库升级到最新版的完整流程与验证
|
|
102
|
+
步骤见 `docs/agent-skill-install.md`。
|
|
95
103
|
|
|
96
104
|
卸载全局命令:
|
|
97
105
|
|
|
@@ -315,7 +323,10 @@ weaver-work-cli --json invoice download --fid <fid> --file-id <fileId> --output
|
|
|
315
323
|
写操作固定使用 `prepare -> apply`:
|
|
316
324
|
|
|
317
325
|
```bash
|
|
318
|
-
echo '{"file":"./invoice.pdf"
|
|
326
|
+
echo '{"file":"./invoice.pdf"}' \
|
|
327
|
+
| weaver-work-cli --json invoice run invoice.ocr.preview --input -
|
|
328
|
+
|
|
329
|
+
echo '{"file":"./invoice.pdf","validate":true,"syncToOa":true}' \
|
|
319
330
|
| weaver-work-cli --json invoice import prepare --input -
|
|
320
331
|
|
|
321
332
|
echo '{"file":"./invoice.pdf","continuation":"<prepare返回值>","confirm":true}' \
|
|
@@ -324,6 +335,8 @@ echo '{"file":"./invoice.pdf","continuation":"<prepare返回值>","confirm":true
|
|
|
324
335
|
|
|
325
336
|
Agent 应优先调用 `weaver-work-cli --json invoice run <operation> --input -`,并读取 `weaver-work-cli invoice schema` 获取 operation 契约。共享、转让和标签接口因缺少稳定 ID 来源和写后回查契约,当前不暴露给 Agent。
|
|
326
337
|
|
|
338
|
+
输出处理参考飞书 CLI 的 Agent 友好实践,但当前项目仍是轻量输出层:`ctx.output.write` 在 JSON 模式下直接把完整 envelope 打到 stdout,失败写 stderr;暂未提供通用 `--jq`、`--format table/ndjson` 或 `--page-all`。因此业务 Skill 会预先要求 Agent 控制响应量:列表默认 `page_size=10`,按业务分页字段继续;给用户只渲染摘要、关键字段、数量和下一步,超长详情/OCR/调试 JSON 优先落本地文件并返回路径。
|
|
339
|
+
|
|
327
340
|
如需交付通用 Agent Skill 包:
|
|
328
341
|
|
|
329
342
|
```bash
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
2
|
import { createServer } from 'node:http';
|
|
3
3
|
import { CliError, ExitCode } from '../../../core/errors.js';
|
|
4
|
+
import { clearInvoiceTokenForSession } from '../../../shortcuts/invoice/host.js';
|
|
4
5
|
import { buildE10CookieHeader, E10AuthSession, clearProfileAuthFile, extractRawSetCookie, findAuthFile, getActiveProfile, getProfileAuthPath, getProfileDir, listProfiles, profileNameFromUrl, readProfileConfig, readTeamsCheckUserInfo, saveAuthFile, saveProfileConfig, setActiveProfile, } from './session.js';
|
|
5
6
|
import { getE10AuthRoot } from './paths.js';
|
|
6
7
|
const DEFAULT_AGENT_TYPE = 'weaver-work-cli';
|
|
@@ -83,6 +84,15 @@ function formatLoginResult(result) {
|
|
|
83
84
|
'authFile: ' + result.authFile,
|
|
84
85
|
].join('\n');
|
|
85
86
|
}
|
|
87
|
+
async function clearInvoiceTokenCache(profile, authData) {
|
|
88
|
+
if (!authData.baseUrl || !authData.userId)
|
|
89
|
+
return;
|
|
90
|
+
await clearInvoiceTokenForSession({
|
|
91
|
+
baseUrl: normalizeUrl(authData.baseUrl),
|
|
92
|
+
profile,
|
|
93
|
+
userId: authData.userId,
|
|
94
|
+
}).catch(() => false);
|
|
95
|
+
}
|
|
86
96
|
function shellQuote(value) {
|
|
87
97
|
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
88
98
|
}
|
|
@@ -118,7 +128,7 @@ async function resolveUserInfo(baseUrl, eteamsId, rawCookie = '', agentType = ''
|
|
|
118
128
|
'content-type': 'application/json',
|
|
119
129
|
eteamsid: eteamsId,
|
|
120
130
|
cookie: buildE10CookieHeader(rawCookie, eteamsId, agentType),
|
|
121
|
-
'user-agent': agentType ? `AgentType=${agentType},IsAgent=true` :
|
|
131
|
+
'user-agent': agentType ? `AgentType=${agentType},IsAgent=true` : DEFAULT_AGENT_TYPE,
|
|
122
132
|
origin: baseUrl,
|
|
123
133
|
},
|
|
124
134
|
redirect: 'manual',
|
|
@@ -201,6 +211,7 @@ async function oidcExchange(code, redirectUri, baseUrl, tenantKeyFallback, agent
|
|
|
201
211
|
};
|
|
202
212
|
const profile = profileHint || profileNameFromUrl(base);
|
|
203
213
|
setActiveProfile(profile);
|
|
214
|
+
await clearInvoiceTokenCache(profile, authData);
|
|
204
215
|
const authFile = saveAuthFile(authData, profile);
|
|
205
216
|
saveProfileConfig(base, base, profile, tenantKey);
|
|
206
217
|
return {
|
|
@@ -455,6 +466,7 @@ export function registerE10AuthCommands(parent, action) {
|
|
|
455
466
|
passportUrl,
|
|
456
467
|
cookieSavedAt: Date.now(),
|
|
457
468
|
};
|
|
469
|
+
await clearInvoiceTokenCache(profile, authData);
|
|
458
470
|
const authFile = saveAuthFile(authData, profile);
|
|
459
471
|
saveProfileConfig(baseUrl, passportUrl, profile, tenantKey);
|
|
460
472
|
const result = {
|
|
@@ -475,9 +487,18 @@ export function registerE10AuthCommands(parent, action) {
|
|
|
475
487
|
auth.command('logout')
|
|
476
488
|
.alias('clear')
|
|
477
489
|
.description('Clear saved E10 auth for the current or selected profile')
|
|
478
|
-
.action(action((ctx) => {
|
|
490
|
+
.action(action(async (ctx) => {
|
|
479
491
|
const opts = ctx.getGlobalOptions();
|
|
480
|
-
const
|
|
492
|
+
const profile = opts.profile || getActiveProfile();
|
|
493
|
+
const authFile = getProfileAuthPath(profile);
|
|
494
|
+
const session = E10AuthSession.fromFile(authFile, opts.baseUrl, opts.passportUrl);
|
|
495
|
+
if (session) {
|
|
496
|
+
await clearInvoiceTokenCache(profile, {
|
|
497
|
+
baseUrl: session.getBaseUrl(),
|
|
498
|
+
userId: session.userId,
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
const result = clearProfileAuthFile(profile);
|
|
481
502
|
const text = result.cleared
|
|
482
503
|
? 'Cleared E10 auth.\nprofile: ' + result.profile + '\nauthFile: ' + result.authFile
|
|
483
504
|
: 'No E10 auth to clear.\nprofile: ' + result.profile + '\nauthFile: ' + result.authFile;
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
3
|
-
import { homedir } from 'node:os';
|
|
4
3
|
import { resolve } from 'node:path';
|
|
5
4
|
import { Entry as KeyringEntry } from '@napi-rs/keyring';
|
|
6
5
|
import { writeWithRetry } from '../../../core/write-with-retry.js';
|
|
@@ -9,15 +8,11 @@ const ALGORITHM = 'aes-256-gcm';
|
|
|
9
8
|
const IV_LENGTH = 12;
|
|
10
9
|
const TAG_LENGTH = 16;
|
|
11
10
|
const KEYCHAIN_SERVICE = 'weaver-work-cli';
|
|
12
|
-
const LEGACY_KEYCHAIN_SERVICE = 'e10-cli';
|
|
13
11
|
const KEYCHAIN_ACCOUNT = 'auth-key';
|
|
14
12
|
let encKey = null;
|
|
15
13
|
function getKeyFilePath() {
|
|
16
14
|
return resolve(getE10AuthRoot(), '.key');
|
|
17
15
|
}
|
|
18
|
-
function getLegacyKeyFilePath() {
|
|
19
|
-
return resolve(homedir(), '.e10-cli', '.key');
|
|
20
|
-
}
|
|
21
16
|
function readKeychainKey(service) {
|
|
22
17
|
try {
|
|
23
18
|
const entry = new KeyringEntry(service, KEYCHAIN_ACCOUNT);
|
|
@@ -73,13 +68,6 @@ function getEncKey() {
|
|
|
73
68
|
writeWithRetry(keyFile, encKey.toString('base64'), { mode: 0o600 });
|
|
74
69
|
return encKey;
|
|
75
70
|
}
|
|
76
|
-
function getDecryptKeys() {
|
|
77
|
-
const keys = [getEncKey()];
|
|
78
|
-
const legacyKey = readKeychainKey(LEGACY_KEYCHAIN_SERVICE) || readKeyFile(getLegacyKeyFilePath());
|
|
79
|
-
if (legacyKey && !keys.some((key) => key.equals(legacyKey)))
|
|
80
|
-
keys.push(legacyKey);
|
|
81
|
-
return keys;
|
|
82
|
-
}
|
|
83
71
|
export function encryptAuthData(plaintext) {
|
|
84
72
|
const iv = randomBytes(IV_LENGTH);
|
|
85
73
|
const cipher = createCipheriv(ALGORITHM, getEncKey(), iv, { authTagLength: TAG_LENGTH });
|
|
@@ -95,17 +83,14 @@ export function decryptAuthData(wrapper) {
|
|
|
95
83
|
const iv = combined.subarray(0, IV_LENGTH);
|
|
96
84
|
const tag = combined.subarray(combined.length - TAG_LENGTH);
|
|
97
85
|
const encrypted = combined.subarray(IV_LENGTH, combined.length - TAG_LENGTH);
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
// Try the next known key.
|
|
106
|
-
}
|
|
86
|
+
try {
|
|
87
|
+
const decipher = createDecipheriv(ALGORITHM, getEncKey(), iv, { authTagLength: TAG_LENGTH });
|
|
88
|
+
decipher.setAuthTag(tag);
|
|
89
|
+
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf-8');
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return null;
|
|
107
93
|
}
|
|
108
|
-
return null;
|
|
109
94
|
}
|
|
110
95
|
export function parseAuthFile(raw) {
|
|
111
96
|
try {
|
|
@@ -254,7 +254,7 @@ export class E10AuthSession {
|
|
|
254
254
|
return buildE10CookieHeader(raw, this.eteamsId, this.agentType);
|
|
255
255
|
}
|
|
256
256
|
uaHeader() {
|
|
257
|
-
return this.agentType ? `AgentType=${this.agentType},IsAgent=true` : '
|
|
257
|
+
return this.agentType ? `AgentType=${this.agentType},IsAgent=true` : 'weaver-work-cli';
|
|
258
258
|
}
|
|
259
259
|
toJSON() {
|
|
260
260
|
return {
|
|
@@ -5,6 +5,7 @@ import { getActiveProfile } from '../../internal/e10/auth/session.js';
|
|
|
5
5
|
import { InvoiceCliError, invoiceAuthError, invoiceNetworkError, invoiceValidationError, } from './errors.js';
|
|
6
6
|
const MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024;
|
|
7
7
|
const TOKEN_SERVICE = 'cn.weaver.weaver-work-cli.invoice-token';
|
|
8
|
+
const INVOICE_SESSION_EXPIRED_CODE = '-2';
|
|
8
9
|
function assertPath(requestPath) {
|
|
9
10
|
if (typeof requestPath !== 'string' || !requestPath.startsWith('/') || requestPath.startsWith('//')) {
|
|
10
11
|
throw invoiceValidationError('path_invalid', '请求路径必须是相对绝对路径');
|
|
@@ -63,6 +64,10 @@ function responseCode(value) {
|
|
|
63
64
|
const root = responseRoot(value);
|
|
64
65
|
return root?.ret ?? root?.code;
|
|
65
66
|
}
|
|
67
|
+
function isInvoiceSessionExpired(value) {
|
|
68
|
+
const code = responseCode(value);
|
|
69
|
+
return code !== undefined && String(code) === INVOICE_SESSION_EXPIRED_CODE;
|
|
70
|
+
}
|
|
66
71
|
function tokenAccount(session) {
|
|
67
72
|
return createHash('sha256')
|
|
68
73
|
.update(`${session.profile}\n${session.baseUrl}\n${session.userId}`)
|
|
@@ -109,6 +114,9 @@ function createInvoiceTokenStore(session) {
|
|
|
109
114
|
},
|
|
110
115
|
};
|
|
111
116
|
}
|
|
117
|
+
export async function clearInvoiceTokenForSession(session) {
|
|
118
|
+
return createInvoiceTokenStore(session).clear();
|
|
119
|
+
}
|
|
112
120
|
function inferProfile(ctx) {
|
|
113
121
|
const options = ctx.getGlobalOptions();
|
|
114
122
|
return options.profile || getActiveProfile();
|
|
@@ -125,14 +133,16 @@ function requestHeaders(session, invoiceToken) {
|
|
|
125
133
|
headers.token = invoiceToken;
|
|
126
134
|
return headers;
|
|
127
135
|
}
|
|
128
|
-
export function createInvoiceHost(ctx) {
|
|
136
|
+
export function createInvoiceHost(ctx, dependencies = {}) {
|
|
129
137
|
let e10Session = null;
|
|
130
138
|
let sessionContext = null;
|
|
131
139
|
let invoiceToken = null;
|
|
132
140
|
let tokenStore = null;
|
|
141
|
+
const requireSession = dependencies.requireSession || requireE10Session;
|
|
142
|
+
const tokenStoreFactory = dependencies.tokenStoreFactory || createInvoiceTokenStore;
|
|
133
143
|
async function ensureSession() {
|
|
134
144
|
if (!e10Session) {
|
|
135
|
-
e10Session = await
|
|
145
|
+
e10Session = await requireSession(ctx);
|
|
136
146
|
sessionContext = {
|
|
137
147
|
baseUrl: e10Session.getBaseUrl(),
|
|
138
148
|
profile: inferProfile(ctx),
|
|
@@ -148,7 +158,7 @@ export function createInvoiceHost(ctx) {
|
|
|
148
158
|
async function ensureInvoiceToken(timeoutMs = 30_000) {
|
|
149
159
|
await ensureSession();
|
|
150
160
|
const context = await getSession();
|
|
151
|
-
tokenStore ||=
|
|
161
|
+
tokenStore ||= tokenStoreFactory(context);
|
|
152
162
|
if (invoiceToken)
|
|
153
163
|
return invoiceToken;
|
|
154
164
|
invoiceToken = await tokenStore.load();
|
|
@@ -182,7 +192,31 @@ export function createInvoiceHost(ctx) {
|
|
|
182
192
|
}
|
|
183
193
|
return ensureInvoiceToken(timeoutMs);
|
|
184
194
|
}
|
|
185
|
-
async function
|
|
195
|
+
async function clearCachedInvoiceToken() {
|
|
196
|
+
invoiceToken = null;
|
|
197
|
+
await tokenStore?.clear().catch(() => false);
|
|
198
|
+
}
|
|
199
|
+
function businessError(value) {
|
|
200
|
+
const code = responseCode(value);
|
|
201
|
+
return new InvoiceCliError('api', 'business_error', responseMessage(value), {
|
|
202
|
+
code: String(code),
|
|
203
|
+
details: { ret: code },
|
|
204
|
+
exitCode: 1,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
async function handleBusinessResponse(value, retry, canRetry) {
|
|
208
|
+
const code = responseCode(value);
|
|
209
|
+
if (code === undefined || code === 0 || code === 200)
|
|
210
|
+
return value;
|
|
211
|
+
if (isInvoiceSessionExpired(value)) {
|
|
212
|
+
await clearCachedInvoiceToken();
|
|
213
|
+
if (canRetry)
|
|
214
|
+
return retry();
|
|
215
|
+
throw invoiceAuthError('invoice_session_expired', '发票业务登录态已失效,已清理本地发票 Token;请重新运行 weaver-work-cli auth login --base-url <域名> 后再试', { ret: code });
|
|
216
|
+
}
|
|
217
|
+
throw businessError(value);
|
|
218
|
+
}
|
|
219
|
+
async function invoiceRequest(request, canRetryExpiredToken = true) {
|
|
186
220
|
const { method = 'POST', path, query, body, needsInvoiceToken = true, timeoutMs = 30_000, } = request;
|
|
187
221
|
assertPath(path);
|
|
188
222
|
const token = await ensureContext(needsInvoiceToken, timeoutMs, path);
|
|
@@ -220,118 +254,110 @@ export function createInvoiceHost(ctx) {
|
|
|
220
254
|
exitCode: 1,
|
|
221
255
|
});
|
|
222
256
|
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
257
|
+
return handleBusinessResponse(value, () => invoiceRequest(request, false), canRetryExpiredToken && Boolean(token));
|
|
258
|
+
}
|
|
259
|
+
async function invoiceMultipartRequest(request, canRetryExpiredToken = true) {
|
|
260
|
+
const { method = 'POST', path, query, fields = {}, file, needsInvoiceToken = true, timeoutMs = 30_000, } = request;
|
|
261
|
+
if (!file && !fields.url) {
|
|
262
|
+
throw invoiceValidationError('input_invalid', 'multipart 请求需要文件或 URL');
|
|
263
|
+
}
|
|
264
|
+
assertPath(path);
|
|
265
|
+
const token = await ensureContext(needsInvoiceToken, timeoutMs, path);
|
|
266
|
+
const session = await ensureSession();
|
|
267
|
+
const form = new FormData();
|
|
268
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
269
|
+
if (value === undefined || value === null)
|
|
270
|
+
continue;
|
|
271
|
+
form.append(key, typeof value === 'string' ? value : JSON.stringify(value));
|
|
272
|
+
}
|
|
273
|
+
if (file) {
|
|
274
|
+
const content = await readFile(file.path);
|
|
275
|
+
form.append('file', new Blob([content], { type: file.mimeType || 'application/octet-stream' }), file.name);
|
|
276
|
+
}
|
|
277
|
+
const response = await fetchWithTimeout(withQuery(session.getBaseUrl(), path, query), {
|
|
278
|
+
method,
|
|
279
|
+
headers: requestHeaders(session, token),
|
|
280
|
+
body: form,
|
|
281
|
+
redirect: 'manual',
|
|
282
|
+
}, timeoutMs);
|
|
283
|
+
const text = await response.text();
|
|
284
|
+
if (response.status === 401 || response.status === 302) {
|
|
285
|
+
throw invoiceAuthError('session_expired', 'E10 登录态已失效,请先向用户确认 E10 登录域名,再运行 weaver-work-cli auth login --base-url <域名> 完成登录', { httpStatus: response.status });
|
|
286
|
+
}
|
|
287
|
+
let value = {};
|
|
288
|
+
try {
|
|
289
|
+
value = text ? JSON.parse(text) : {};
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
throw invoiceNetworkError('protocol', `接口返回不是合法 JSON(HTTP ${response.status})`, { retryable: false });
|
|
293
|
+
}
|
|
294
|
+
if (response.status < 200 || response.status >= 300) {
|
|
295
|
+
throw new InvoiceCliError('api', 'http_error', `泛微接口 HTTP ${response.status}`, {
|
|
296
|
+
code: response.status,
|
|
297
|
+
details: { status: response.status, message: responseMessage(value) },
|
|
228
298
|
exitCode: 1,
|
|
229
299
|
});
|
|
230
300
|
}
|
|
231
|
-
return value;
|
|
301
|
+
return handleBusinessResponse(value, () => invoiceMultipartRequest(request, false), canRetryExpiredToken && Boolean(token));
|
|
232
302
|
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
body: form,
|
|
257
|
-
redirect: 'manual',
|
|
258
|
-
}, timeoutMs);
|
|
259
|
-
const text = await response.text();
|
|
260
|
-
if (response.status === 401 || response.status === 302) {
|
|
261
|
-
throw invoiceAuthError('session_expired', 'E10 登录态已失效,请先向用户确认 E10 登录域名,再运行 weaver-work-cli auth login --base-url <域名> 完成登录', { httpStatus: response.status });
|
|
262
|
-
}
|
|
303
|
+
async function invoiceDownload({ id, fid, needLog = false, timeoutMs = 30_000 }, canRetryExpiredToken = true) {
|
|
304
|
+
const token = await ensureContext(true, timeoutMs, '/api/inc/file/download');
|
|
305
|
+
const session = await ensureSession();
|
|
306
|
+
const url = withQuery(session.getBaseUrl(), '/api/inc/file/download', { id, fid, needLog });
|
|
307
|
+
const response = await fetchWithTimeout(url, {
|
|
308
|
+
method: 'GET',
|
|
309
|
+
headers: {
|
|
310
|
+
...requestHeaders(session, token),
|
|
311
|
+
Accept: 'application/octet-stream, application/pdf, */*',
|
|
312
|
+
},
|
|
313
|
+
redirect: 'manual',
|
|
314
|
+
}, timeoutMs);
|
|
315
|
+
if (response.status === 401 || response.status === 302) {
|
|
316
|
+
throw invoiceAuthError('session_expired', 'E10 登录态已失效,请先向用户确认 E10 登录域名,再运行 weaver-work-cli auth login --base-url <域名> 完成登录', { httpStatus: response.status });
|
|
317
|
+
}
|
|
318
|
+
const contentType = response.headers.get('content-type') || 'application/octet-stream';
|
|
319
|
+
const declaredLength = Number(response.headers.get('content-length'));
|
|
320
|
+
if (Number.isFinite(declaredLength) && declaredLength > MAX_DOWNLOAD_BYTES) {
|
|
321
|
+
throw invoiceNetworkError('response_too_large', `下载文件超过 ${MAX_DOWNLOAD_BYTES} 字节限制`, { retryable: false });
|
|
322
|
+
}
|
|
323
|
+
const content = Buffer.from(await response.arrayBuffer());
|
|
324
|
+
const looksLikeJson = /^[\s\r\n]*[\[{]/u.test(content.subarray(0, 64).toString('utf8'));
|
|
325
|
+
if (response.status < 200 || response.status >= 300 || contentType.includes('json') || looksLikeJson) {
|
|
263
326
|
let value = {};
|
|
264
327
|
try {
|
|
265
|
-
value =
|
|
328
|
+
value = JSON.parse(content.toString('utf8'));
|
|
266
329
|
}
|
|
267
330
|
catch {
|
|
268
|
-
|
|
269
|
-
}
|
|
270
|
-
if (response.status < 200 || response.status >= 300) {
|
|
271
|
-
throw new InvoiceCliError('api', 'http_error', `泛微接口 HTTP ${response.status}`, {
|
|
272
|
-
code: response.status,
|
|
273
|
-
details: { status: response.status, message: responseMessage(value) },
|
|
274
|
-
exitCode: 1,
|
|
275
|
-
});
|
|
276
|
-
}
|
|
277
|
-
const code = responseCode(value);
|
|
278
|
-
if (code !== undefined && code !== 0 && code !== 200) {
|
|
279
|
-
throw new InvoiceCliError('api', 'business_error', responseMessage(value), {
|
|
280
|
-
code: String(code),
|
|
281
|
-
details: { ret: code },
|
|
282
|
-
exitCode: 1,
|
|
283
|
-
});
|
|
284
|
-
}
|
|
285
|
-
return value;
|
|
286
|
-
},
|
|
287
|
-
async invoiceDownload({ id, fid, needLog = false, timeoutMs = 30_000 }) {
|
|
288
|
-
const token = await ensureContext(true, timeoutMs, '/api/inc/file/download');
|
|
289
|
-
const session = await ensureSession();
|
|
290
|
-
const url = withQuery(session.getBaseUrl(), '/api/inc/file/download', { id, fid, needLog });
|
|
291
|
-
const response = await fetchWithTimeout(url, {
|
|
292
|
-
method: 'GET',
|
|
293
|
-
headers: {
|
|
294
|
-
...requestHeaders(session, token),
|
|
295
|
-
Accept: 'application/octet-stream, application/pdf, */*',
|
|
296
|
-
},
|
|
297
|
-
redirect: 'manual',
|
|
298
|
-
}, timeoutMs);
|
|
299
|
-
if (response.status === 401 || response.status === 302) {
|
|
300
|
-
throw invoiceAuthError('session_expired', 'E10 登录态已失效,请先向用户确认 E10 登录域名,再运行 weaver-work-cli auth login --base-url <域名> 完成登录', { httpStatus: response.status });
|
|
331
|
+
value = {};
|
|
301
332
|
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
333
|
+
if (isInvoiceSessionExpired(value)) {
|
|
334
|
+
await clearCachedInvoiceToken();
|
|
335
|
+
if (canRetryExpiredToken && token)
|
|
336
|
+
return invoiceDownload({ id, fid, needLog, timeoutMs }, false);
|
|
337
|
+
throw invoiceAuthError('invoice_session_expired', '发票业务登录态已失效,已清理本地发票 Token;请重新运行 weaver-work-cli auth login --base-url <域名> 后再试', { ret: responseCode(value), status: response.status });
|
|
306
338
|
}
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
}
|
|
326
|
-
if (content.length > MAX_DOWNLOAD_BYTES) {
|
|
327
|
-
throw invoiceNetworkError('response_too_large', `下载文件超过 ${MAX_DOWNLOAD_BYTES} 字节限制`, { retryable: false });
|
|
328
|
-
}
|
|
329
|
-
return { content, contentType };
|
|
330
|
-
},
|
|
339
|
+
throw new InvoiceCliError('api', 'download_failed', responseMessage(value) || `下载失败(HTTP ${response.status})`, {
|
|
340
|
+
code: response.status,
|
|
341
|
+
details: { status: response.status, ret: responseCode(value) },
|
|
342
|
+
exitCode: 1,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
if (!content.length) {
|
|
346
|
+
throw invoiceNetworkError('protocol', '下载接口返回空文件', { retryable: false });
|
|
347
|
+
}
|
|
348
|
+
if (content.length > MAX_DOWNLOAD_BYTES) {
|
|
349
|
+
throw invoiceNetworkError('response_too_large', `下载文件超过 ${MAX_DOWNLOAD_BYTES} 字节限制`, { retryable: false });
|
|
350
|
+
}
|
|
351
|
+
return { content, contentType };
|
|
352
|
+
}
|
|
353
|
+
return {
|
|
354
|
+
invoiceRequest,
|
|
355
|
+
invoiceMultipartRequest,
|
|
356
|
+
invoiceDownload,
|
|
331
357
|
getSession,
|
|
332
358
|
invalidateToken() {
|
|
333
359
|
invoiceToken = null;
|
|
334
|
-
void
|
|
360
|
+
void clearCachedInvoiceToken();
|
|
335
361
|
},
|
|
336
362
|
};
|
|
337
363
|
}
|
|
@@ -48,6 +48,11 @@ function intOption(value) {
|
|
|
48
48
|
const parsed = Number(value);
|
|
49
49
|
return Number.isInteger(parsed) ? parsed : undefined;
|
|
50
50
|
}
|
|
51
|
+
function stringOption(value) {
|
|
52
|
+
if (value === undefined || value === null || value === '')
|
|
53
|
+
return undefined;
|
|
54
|
+
return String(value);
|
|
55
|
+
}
|
|
51
56
|
function successEnvelope(operation, result) {
|
|
52
57
|
return {
|
|
53
58
|
schemaVersion: 1,
|
|
@@ -111,7 +116,7 @@ export const invoiceShortcut = {
|
|
|
111
116
|
content: opts.content,
|
|
112
117
|
page_size: intOption(opts.pageSize),
|
|
113
118
|
start_pos: intOption(opts.startPos),
|
|
114
|
-
sreim:
|
|
119
|
+
sreim: stringOption(opts.sreim),
|
|
115
120
|
}));
|
|
116
121
|
}));
|
|
117
122
|
const enterprise = invoice.command('enterprise').description('Enterprise invoice folder commands');
|
|
@@ -128,7 +133,7 @@ export const invoiceShortcut = {
|
|
|
128
133
|
content: opts.content,
|
|
129
134
|
page_size: intOption(opts.pageSize),
|
|
130
135
|
start_pos: intOption(opts.startPos),
|
|
131
|
-
sreim:
|
|
136
|
+
sreim: stringOption(opts.sreim),
|
|
132
137
|
}));
|
|
133
138
|
}));
|
|
134
139
|
invoice.command('get')
|
|
@@ -20,10 +20,10 @@ const listProperties = {
|
|
|
20
20
|
total_end: { type: 'string', pattern: '^\\d+(\\.\\d{1,2})?$' },
|
|
21
21
|
attribute: { type: 'integer', enum: [0, 1, 2] },
|
|
22
22
|
sreim: {
|
|
23
|
-
type: '
|
|
24
|
-
enum: [0, 1, 2, 3, 4],
|
|
25
|
-
default: 3,
|
|
26
|
-
description: '
|
|
23
|
+
type: 'string',
|
|
24
|
+
enum: ['0', '1', '2', '3', '4'],
|
|
25
|
+
default: '3',
|
|
26
|
+
description: '报销状态筛选。默认不要传本字段,CLI 会使用 "3"=未报销发票;只有用户明确要求全部/报销中/已报销/不可报销时才传。取值:"0"=全部发票,"1"=发票报销中,"2"=报销完成,"3"=未报销发票,"4"=不可报销',
|
|
27
27
|
},
|
|
28
28
|
valids: { type: 'array', items: { type: 'integer', enum: [0, 1, 2, 3, 4, 5] } },
|
|
29
29
|
sources: { type: 'array', items: { type: 'integer' } },
|
|
@@ -43,21 +43,26 @@ const listProperties = {
|
|
|
43
43
|
required: ['sort_type', 'sort'],
|
|
44
44
|
},
|
|
45
45
|
},
|
|
46
|
-
bill_type: {
|
|
46
|
+
bill_type: {
|
|
47
|
+
type: 'integer',
|
|
48
|
+
enum: [0, 1],
|
|
49
|
+
default: 0,
|
|
50
|
+
description: '票据类型:0=发票,1=凭证。默认不要传本字段,CLI 会使用 0 查询发票;只有用户明确要求查凭证时才传 1。',
|
|
51
|
+
},
|
|
47
52
|
};
|
|
48
53
|
export const invoiceOperations = [
|
|
49
54
|
{
|
|
50
55
|
name: 'invoice.list',
|
|
51
56
|
risk: 'read',
|
|
52
57
|
scope: 'personal',
|
|
53
|
-
fixed: { flag: 0, defaultSreim: 3 },
|
|
58
|
+
fixed: { flag: 0, defaultSreim: '3', defaultBillType: 0 },
|
|
54
59
|
inputSchema: { type: 'object', additionalProperties: false, properties: listProperties },
|
|
55
60
|
},
|
|
56
61
|
{
|
|
57
62
|
name: 'invoice.enterprise.list',
|
|
58
63
|
risk: 'read',
|
|
59
64
|
scope: 'enterprise',
|
|
60
|
-
fixed: { flag: 6, defaultSreim: 3 },
|
|
65
|
+
fixed: { flag: 6, defaultSreim: '3', defaultBillType: 0 },
|
|
61
66
|
inputSchema: { type: 'object', additionalProperties: false, properties: listProperties },
|
|
62
67
|
},
|
|
63
68
|
{
|
|
@@ -76,7 +81,7 @@ export const invoiceOperations = [
|
|
|
76
81
|
{
|
|
77
82
|
name: 'invoice.upload',
|
|
78
83
|
risk: 'external-artifact',
|
|
79
|
-
fixed: { ocr:
|
|
84
|
+
fixed: { ocr: 0 },
|
|
80
85
|
inputSchema: {
|
|
81
86
|
type: 'object',
|
|
82
87
|
additionalProperties: false,
|
|
@@ -106,7 +111,7 @@ export const invoiceOperations = [
|
|
|
106
111
|
{
|
|
107
112
|
name: 'invoice.validate.preview',
|
|
108
113
|
risk: 'external-read',
|
|
109
|
-
fixed: { flag: 100, is_save: 1, needLog:
|
|
114
|
+
fixed: { flag: 100, is_save: 1, needLog: true },
|
|
110
115
|
inputSchema: {
|
|
111
116
|
type: 'object',
|
|
112
117
|
additionalProperties: false,
|
|
@@ -125,8 +130,14 @@ export const invoiceOperations = [
|
|
|
125
130
|
additionalProperties: false,
|
|
126
131
|
properties: {
|
|
127
132
|
file: { type: 'string', minLength: 1 },
|
|
128
|
-
validate: {
|
|
129
|
-
|
|
133
|
+
validate: {
|
|
134
|
+
type: 'boolean',
|
|
135
|
+
description: '是否在导入时请求服务端查验。正常上传发票流程默认应传 true;只有用户明确要求跳过查验时才传 false。',
|
|
136
|
+
},
|
|
137
|
+
syncToOa: {
|
|
138
|
+
type: 'boolean',
|
|
139
|
+
description: '是否同步到 OA。validate=true 时必须为 true;正常导入推荐传 true。',
|
|
140
|
+
},
|
|
130
141
|
},
|
|
131
142
|
required: ['file', 'validate', 'syncToOa'],
|
|
132
143
|
},
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { InvoiceCliError, invoiceValidationError } from '../errors.js';
|
|
2
|
+
import { toErrorEnvelope } from '../../../core/errors.js';
|
|
2
3
|
import { previewOcr, uploadFromInput, uploadInvoiceFile, workflow } from './shared.js';
|
|
3
4
|
export class InvoiceOcrPreviewOperation {
|
|
4
5
|
name = 'invoice.ocr.preview';
|
|
@@ -10,16 +11,18 @@ export class InvoiceOcrPreviewOperation {
|
|
|
10
11
|
throw invoiceValidationError('input_ambiguous', 'upload 与 file/url 只能选择一种来源');
|
|
11
12
|
}
|
|
12
13
|
const suppliedUpload = uploadFromInput(input);
|
|
13
|
-
const upload = suppliedUpload || await uploadInvoiceFile(input, host);
|
|
14
|
+
const upload = suppliedUpload || await uploadInvoiceFile(input, host, { ocr: true });
|
|
14
15
|
let ocr;
|
|
15
16
|
try {
|
|
16
17
|
ocr = await previewOcr(upload, host);
|
|
17
18
|
}
|
|
18
19
|
catch (error) {
|
|
19
20
|
if (!suppliedUpload) {
|
|
20
|
-
|
|
21
|
+
const cause = toErrorEnvelope(error);
|
|
22
|
+
throw new InvoiceCliError('partial', 'artifact_uploaded', `文件已上传但 OCR 预览失败:${cause.message}`, {
|
|
23
|
+
code: cause.code,
|
|
21
24
|
partialData: { upload, workflow: workflow('invoice.ocr.preview', 'OCR_RUNNING') },
|
|
22
|
-
details: { stage: 'ocr' },
|
|
25
|
+
details: { stage: 'ocr', cause },
|
|
23
26
|
exitCode: 11,
|
|
24
27
|
});
|
|
25
28
|
}
|