@whyshu/argent 0.1.27 → 0.2.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,77 @@
1
+ /**
2
+ * billing-upload.mjs —— 计费事件的**上报骨架**(v0.9.0,默认关闭)
3
+ *
4
+ * 为什么要上报:本地台账只在客户机上,服务端要能对账就必须收到**签名批次**;
5
+ * 但上报必须先做到"可断网、可重试、不重复计费",否则宁可不上报(本地台账永远是权威副本)。
6
+ *
7
+ * 规则:
8
+ * · 只有 `signPending()` 签过名的批次才上报(未签名 = 未定稿,不传);
9
+ * · 每个批次附带它覆盖的事件(**只含 action/points/tenant/user/at/seq**,⛔ 无参数/无结果/无真名);
10
+ * · `batchId` 幂等:收到 2xx 才写入 `billing-upload.json`,重复调用不会重传;
11
+ * · 失败(网络/5xx)保留在待传队列,下次继续;4xx 视为**永久失败**,跳过并记入 `failed[]`。
12
+ *
13
+ * 默认关闭:`url` 未配置 ⇒ 只返回 `{ok:false, reason:'not_configured'}`,绝不偷偷发。
14
+ */
15
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, openSync, closeSync, writeSync, chmodSync } from 'node:fs'
16
+ import { join } from 'node:path'
17
+
18
+ const MAX_BATCHES = 20
19
+
20
+ function dirOf(dataDir) {
21
+ const d = join(dataDir, 'gateway')
22
+ mkdirSync(d, { recursive: true })
23
+ return d
24
+ }
25
+
26
+ function readJson(file, fallback) {
27
+ try { return JSON.parse(readFileSync(file, 'utf8')) } catch { return fallback }
28
+ }
29
+
30
+ function writeJson600(file, obj) {
31
+ const fd = openSync(file, 'w', 0o600)
32
+ writeSync(fd, JSON.stringify(obj, null, 2) + '\n')
33
+ closeSync(fd)
34
+ try { chmodSync(file, 0o600) } catch { /* noop */ }
35
+ }
36
+
37
+ function readLines(file) {
38
+ if (!existsSync(file)) return []
39
+ return readFileSync(file, 'utf8').split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l) } catch { return null } }).filter(Boolean)
40
+ }
41
+
42
+ /**
43
+ * @returns {{ok:boolean, reason?:string, sent:number, skipped:number, failed:number}}
44
+ */
45
+ export async function uploadPending({ dataDir, url, token = '', fetchImpl = fetch, maxBatches = MAX_BATCHES } = {}) {
46
+ if (!url) return { ok: false, reason: 'not_configured', sent: 0, skipped: 0, failed: 0 }
47
+ const dir = dirOf(dataDir)
48
+ const stateFile = join(dir, 'billing-upload.json')
49
+ const state = readJson(stateFile, { uploadedBatches: [], failed: [] })
50
+ const uploaded = new Set(state.uploadedBatches || [])
51
+ const failedSet = new Set(state.failed || [])
52
+
53
+ const batches = readLines(join(dir, 'events.signed.jsonl'))
54
+ const events = readLines(join(dir, 'events.jsonl'))
55
+ let sent = 0, skipped = 0, failed = 0
56
+
57
+ for (const batch of batches.slice(0, maxBatches)) {
58
+ if (uploaded.has(batch.batchId) || failedSet.has(batch.batchId)) { skipped++; continue }
59
+ const slice = events.filter((e) => e.seq >= batch.fromSeq && e.seq <= batch.toSeq)
60
+ let r
61
+ try {
62
+ r = await fetchImpl(url, {
63
+ method: 'POST',
64
+ headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) },
65
+ body: JSON.stringify({ batch, events: slice }),
66
+ })
67
+ } catch {
68
+ failed++; continue // 网络问题:留在队列,下次重试
69
+ }
70
+ if (r.ok) { uploaded.add(batch.batchId); sent++ }
71
+ else if (r.status >= 400 && r.status < 500) { failedSet.add(batch.batchId); failed++ } // 永久失败
72
+ else failed++ // 5xx:下次重试
73
+ }
74
+
75
+ writeJson600(stateFile, { uploadedBatches: [...uploaded], failed: [...failedSet], updatedAt: new Date().toISOString() })
76
+ return { ok: true, sent, skipped, failed, pending: batches.length - uploaded.size - failedSet.size }
77
+ }
@@ -0,0 +1,296 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * erp-server.mjs —— ERP 的 MCP 服务端(v0.9.0 · 只读第一版)
4
+ *
5
+ * 定位:把 whyshu-svr 现有的 HTTP 只读接口包成 MCP 工具,给 dsh 会话/侧栏面板用。
6
+ * ⛔ 不改 ERP 逻辑、不直连数据库、不写 whyshu-svr;只做「协议形状适配 + 轻量脱敏」。
7
+ *
8
+ * 鉴权:复用问述账号 token(`WHYSHU_API_KEY`,与 LLM 代理同一把 JWT,
9
+ * 服务端 `authenticate` 同时接受 x-api-key 与 Bearer)。
10
+ * 租户:`ARGENT_TENANT`(默认 ly-0001)。
11
+ *
12
+ * 传输:stdio(每行一个 JSON-RPC 2.0)。协议子集:initialize / notifications/initialized /
13
+ * tools/list / tools/call / ping —— 与 core/mcp/server.mjs 同款,零第三方依赖。
14
+ *
15
+ * 红线:
16
+ * · 只读:本版不支持任何写操作(`prepare_action` 需要服务端新路由,等 whyshu-svr 侧提供);
17
+ * · 脱敏:手机号打码、附件 token/URL 不返回、客户名可选代号(v2 接 vault);
18
+ * · 不落日志正文:只记 tool/耗时/ok。
19
+ */
20
+
21
+ const API_BASE = (process.env.ARGENT_API_BASE || 'https://whyshu.com').replace(/\/+$/, '')
22
+ const TENANT = String(process.env.ARGENT_TENANT || 'ly-0001').trim()
23
+ import { resolveAccountToken } from './vendor/account-token.mjs'
24
+ const TOKEN = resolveAccountToken().token
25
+ const TIMEOUT_MS = Number(process.env.ARGENT_ERP_TIMEOUT_MS || 20000)
26
+ /** 写路径总开关(默认关):ERP 上线 prepare 路由后设 ARGENT_ERP_WRITE=1 即可,无需改代码 */
27
+ const WRITE_ENABLED = process.env.ARGENT_ERP_WRITE === '1'
28
+ export const SERVER_INFO = { name: 'argent-erp', version: '0.9.0' }
29
+
30
+ import { redactDeep } from './vendor/redact.mjs'
31
+
32
+ async function apiPost(path, body = {}) {
33
+ if (!WRITE_ENABLED) throw Object.assign(new Error('写路径未启用(ARGENT_ERP_WRITE=1 才开)'), { code: 'write_disabled' })
34
+ if (!TOKEN) throw Object.assign(new Error('缺少账号 token(WHYSHU_API_KEY)'), { code: 'no_token' })
35
+ let r
36
+ try {
37
+ r = await fetch(`${API_BASE}/api/argent/v1${path}`, {
38
+ method: 'POST',
39
+ headers: { 'x-api-key': TOKEN, 'content-type': 'application/json', accept: 'application/json' },
40
+ body: JSON.stringify(body),
41
+ signal: AbortSignal.timeout(TIMEOUT_MS),
42
+ })
43
+ } catch (e) {
44
+ throw Object.assign(new Error(`ERP 不可达:${String(e.message).slice(0, 120)}`), { code: 'network' })
45
+ }
46
+ const text = await r.text()
47
+ let j = null
48
+ try { j = JSON.parse(text) } catch { j = { raw: text.slice(0, 200) } }
49
+ if (r.status === 401) throw Object.assign(new Error('账号 token 失效(401)'), { code: 'unauthorized', status: 401 })
50
+ if (!r.ok) throw Object.assign(new Error((j && j.error && j.error.message) || `ERP HTTP ${r.status}`), { code: 'http', status: r.status })
51
+ return j
52
+ }
53
+
54
+ async function apiGet(path, query = {}) {
55
+ if (!TOKEN) throw Object.assign(new Error('缺少账号 token(WHYSHU_API_KEY)'), { code: 'no_token' })
56
+ const url = new URL(`${API_BASE}/api/argent/v1${path}`)
57
+ for (const [k, v] of Object.entries(query)) {
58
+ if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, String(v))
59
+ }
60
+ const ctl = AbortSignal.timeout(TIMEOUT_MS)
61
+ const r = await fetch(url, { headers: { 'x-api-key': TOKEN, accept: 'application/json' }, signal: ctl })
62
+ const text = await r.text()
63
+ let body = null
64
+ try { body = JSON.parse(text) } catch { body = { raw: text.slice(0, 300) } }
65
+ if (!r.ok) {
66
+ const err = new Error(`ERP HTTP ${r.status}: ${(body && body.error && body.error.message) || ''}`.trim())
67
+ err.code = r.status === 401 ? 'unauthorized' : r.status === 403 ? 'forbidden' : 'http_error'
68
+ err.status = r.status
69
+ throw err
70
+ }
71
+ return body
72
+ }
73
+
74
+ const tenantPath = (suffix) => `/tenants/${encodeURIComponent(TENANT)}${suffix}`
75
+
76
+ /**
77
+ * 列表投影(2026-11-04):ERP 的 `/shipments` 每条内嵌 progress/groups/fileRefs,
78
+ * 单条 ~5KB;一次 limit=20 能把模型上下文顶爆。列表只回"看得懂的一行",重数据走 _detail。
79
+ */
80
+ const F = ['sourceRecordId', 'shipmentNo', 'shop', 'sku', 'status', 'fullStatus', 'currentStep', 'invoiceStatus', 'invoiceReady', 'boxCount', 'ready', 'updatedAt']
81
+ /** 发票列表投影:fileRefs(每条 4 组附件)→ 只留数量与状态 */
82
+ export function pickInvoice(it = {}) {
83
+ const fr = it.fileRefs || {}
84
+ const out = {
85
+ sourceRecordId: it.sourceRecordId, shipmentNo: it.shipmentNo,
86
+ invoiceStatus: it.invoiceStatus, invoiceReady: it.invoiceReady,
87
+ invoiceError: it.invoiceError || '',
88
+ files: { invoice: (fr.invoiceFile || []).length, bulkLabel: (fr.bulkLabel || []).length, fullLabel: (fr.fullLabelFile || []).length, fullCarton: (fr.fullCartonFile || []).length },
89
+ updatedAt: it.updatedAt,
90
+ }
91
+ return out
92
+ }
93
+
94
+ /** 任务列表投影:payload 里只取面板/模型要用的几项 */
95
+ export function pickTask(it = {}) {
96
+ const pl = it.payload || {}
97
+ return {
98
+ id: it._id, type: it.type, title: it.title, status: it.status,
99
+ shipmentNo: pl.shipmentNo || '', shop: pl.shop || '', stageLabel: pl.stageLabel || '',
100
+ nextAction: pl.nextAction || '', updatedAt: it.updatedAt,
101
+ }
102
+ }
103
+
104
+ export function pickShipment(it = {}) {
105
+ const out = {}
106
+ for (const k of F) if (it[k] !== undefined) out[k] = it[k]
107
+ const pg = it.progress || {}
108
+ if (pg.percent !== undefined) out.progress = { percent: pg.percent, doneCount: pg.doneCount, totalCount: pg.totalCount, currentLabel: pg.currentLabel }
109
+ return out
110
+ }
111
+
112
+ /** 工具表:**只读**,参数尽量贴近 ERP 现有 query(不发明新语义) */
113
+ export const TOOLS = {
114
+ erp_tenant_info: {
115
+ description: '当前账号与所属租户(/me + /me/tenants)',
116
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
117
+ handler: async () => ({ me: await apiGet('/me'), tenants: await apiGet('/me/tenants') }),
118
+ },
119
+ erp_shipment_search: {
120
+ description: '查货件(GET /shipments):q / status / fullStatus / ready / stage / group / page / limit',
121
+ inputSchema: {
122
+ type: 'object',
123
+ properties: {
124
+ q: { type: 'string' }, status: { type: 'string' }, fullStatus: { type: 'string' },
125
+ ready: { type: 'boolean' }, stage: { type: 'string' }, group: { type: 'string' },
126
+ page: { type: 'integer' }, limit: { type: 'integer', maximum: 50 },
127
+ },
128
+ additionalProperties: false,
129
+ },
130
+ handler: async (a) => {
131
+ const r = await apiGet(tenantPath('/shipments'), a)
132
+ const d = r.data || {}
133
+ return { total: d.total, page: d.page, limit: d.limit, items: (d.items || []).map(pickShipment) }
134
+ },
135
+ },
136
+ erp_shipment_detail: {
137
+ description: '货件详情(GET /shipments/:sourceRecordId)',
138
+ inputSchema: { type: 'object', properties: { sourceRecordId: { type: 'string' } }, required: ['sourceRecordId'], additionalProperties: false },
139
+ handler: async ({ sourceRecordId }) => apiGet(tenantPath(`/shipments/${encodeURIComponent(sourceRecordId)}`)),
140
+ },
141
+ erp_invoice_list: {
142
+ description: '查发票(GET /invoices):q / invoiceStatus / invoiceReady / page / limit',
143
+ inputSchema: {
144
+ type: 'object',
145
+ properties: { q: { type: 'string' }, invoiceStatus: { type: 'string' }, invoiceReady: { type: 'boolean' }, page: { type: 'integer' }, limit: { type: 'integer', maximum: 50 } },
146
+ additionalProperties: false,
147
+ },
148
+ handler: async (a) => {
149
+ const r = await apiGet(tenantPath('/invoices'), a)
150
+ const d = r.data || {}
151
+ return { total: d.total, page: d.page, limit: d.limit, items: (d.items || []).map(pickInvoice) }
152
+ },
153
+ },
154
+ erp_task_list: {
155
+ description: '任务列表(GET /tasks):status / type / limit',
156
+ inputSchema: { type: 'object', properties: { status: { type: 'string' }, type: { type: 'string' }, limit: { type: 'integer', maximum: 100 } }, additionalProperties: false },
157
+ handler: async (a) => {
158
+ const r = await apiGet(tenantPath('/tasks'), a)
159
+ const d = r.data || {}
160
+ const items = Array.isArray(d.items) ? d.items : []
161
+ return { items: items.map(pickTask) }
162
+ },
163
+ },
164
+ erp_run_list: {
165
+ description: '运行记录(GET /runs):cardId / limit',
166
+ inputSchema: { type: 'object', properties: { cardId: { type: 'string' }, limit: { type: 'integer', maximum: 200 } }, additionalProperties: false },
167
+ handler: async (a) => apiGet(tenantPath('/runs'), a),
168
+ },
169
+ erp_ledger_list: {
170
+ description: '台账(GET /ledger):flow / ticket / kind / days / limit',
171
+ inputSchema: {
172
+ type: 'object',
173
+ properties: { flow: { type: 'string' }, ticket: { type: 'string' }, kind: { type: 'string' }, days: { type: 'integer' }, limit: { type: 'integer' } },
174
+ additionalProperties: false,
175
+ },
176
+ handler: async (a) => apiGet(tenantPath('/ledger'), a),
177
+ },
178
+ erp_member_list: {
179
+ description: '成员(GET /members;手机号自动打码)',
180
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
181
+ handler: async () => apiGet(tenantPath('/members')),
182
+ },
183
+ erp_office_snapshot: {
184
+ description: '办公室现场快照(GET /office:KPI/在岗员工/任务流/动态)',
185
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
186
+ handler: async () => apiGet(tenantPath('/office')),
187
+ },
188
+ erp_dashboard: {
189
+ description: '仪表盘聚合(GET /dashboard)',
190
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
191
+ handler: async () => apiGet(tenantPath('/dashboard')),
192
+ },
193
+ erp_shipment_stats: {
194
+ description: '货件聚合统计(取 GET /dashboard:总数/完成率/byStage/byGroup;**适配层派生**,非 ERP 专用 stats 路由)',
195
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
196
+ handler: async () => {
197
+ const d = (await apiGet(tenantPath('/dashboard'))).data || {}
198
+ return { total: d.total || 0, completed: d.completed || 0, completionRate: d.completionRate || 0, byStage: d.byStage || {}, byGroup: d.byGroup || {} }
199
+ },
200
+ },
201
+ erp_shipment_timeline: {
202
+ description: '货件节点时间线(取 GET /shipments/:id 的 progress.nodes;**适配层派生**)',
203
+ inputSchema: { type: 'object', properties: { sourceRecordId: { type: 'string' } }, required: ['sourceRecordId'], additionalProperties: false },
204
+ handler: async ({ sourceRecordId }) => {
205
+ const d = (await apiGet(tenantPath(`/shipments/${encodeURIComponent(sourceRecordId)}`))).data || {}
206
+ const nodes = ((d.progress && d.progress.nodes) || []).map((n) => ({ key: n.key, label: n.label, status: n.status, completedAt: n.completedAt || null, ownerName: n.ownerName || '' }))
207
+ return { nodes }
208
+ },
209
+ },
210
+ erp_invoice_detail: {
211
+ description: '发票详情(复用 GET /shipments/:sourceRecordId 的 invoice/progress 段;不含附件下载地址)',
212
+ inputSchema: { type: 'object', properties: { sourceRecordId: { type: 'string' } }, required: ['sourceRecordId'], additionalProperties: false },
213
+ handler: async ({ sourceRecordId }) => {
214
+ const r = await apiGet(tenantPath(`/shipments/${encodeURIComponent(sourceRecordId)}`))
215
+ const d = r.data || {}
216
+ return { invoice: d.invoice || null, progress: d.progress || null }
217
+ },
218
+ },
219
+ erp_activity_list: {
220
+ description: '办公室动态(取 GET /office 的 activity 段):limit 默认 20',
221
+ inputSchema: { type: 'object', properties: { limit: { type: 'integer', maximum: 50 } }, additionalProperties: false },
222
+ handler: async ({ limit = 20 } = {}) => {
223
+ const r = await apiGet(tenantPath('/office'))
224
+ const items = (r.data && r.data.activity) || []
225
+ return { items: items.slice(0, Math.min(Number(limit) || 20, 50)), total: items.length }
226
+ },
227
+ },
228
+ erp_prepare_action: {
229
+ description: '生成写操作提议(POST /office/actions/prepare;需 ARGENT_ERP_WRITE=1 且 ERP 已提供该路由)。只生成提议、不执行、不计费。',
230
+ inputSchema: { type: 'object', properties: { action: { type: 'string' }, payload: { type: 'object' } }, required: ['action'], additionalProperties: false },
231
+ handler: async ({ action, payload = {} }) => {
232
+ if (!WRITE_ENABLED) return { ok: false, code: 'write_disabled', message: '写路径未启用:ARGENT_ERP_WRITE=1 才开放(需 ERP 先提供 prepare 路由)' }
233
+ const r = await apiPost(tenantPath('/office/actions/prepare'), { action, payload })
234
+ return { ok: true, proposal: r.data || r }
235
+ },
236
+ },
237
+ erp_execute_action: {
238
+ description: '执行已确认的提议(POST /office/actions/:proposalId/execute;需 ARGENT_ERP_WRITE=1)。⛔ 仅在用户明确确认后调用;成功才计费。',
239
+ inputSchema: { type: 'object', properties: { proposalId: { type: 'string' }, idempotencyKey: { type: 'string' } }, required: ['proposalId'], additionalProperties: false },
240
+ handler: async ({ proposalId }) => {
241
+ if (!WRITE_ENABLED) return { ok: false, code: 'write_disabled', message: '写路径未启用:ARGENT_ERP_WRITE=1 才开放' }
242
+ const r = await apiPost(tenantPath(`/office/actions/${encodeURIComponent(proposalId)}/execute`), {})
243
+ return { ok: true, result: r.data || r }
244
+ },
245
+ },
246
+ }
247
+
248
+ export const TOOL_NAMES = Object.freeze(Object.keys(TOOLS))
249
+
250
+ /* ── stdio JSON-RPC(与 core/mcp/server.mjs 同款协议子集) ───────────────── */
251
+ const send = (msg) => process.stdout.write(`${JSON.stringify(msg)}\n`)
252
+
253
+ export async function handle(msg) {
254
+ const { id, method, params } = msg || {}
255
+ if (method === 'initialize') {
256
+ return { jsonrpc: '2.0', id, result: { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: SERVER_INFO } }
257
+ }
258
+ if (method === 'notifications/initialized') return null
259
+ if (method === 'ping') return { jsonrpc: '2.0', id, result: {} }
260
+ if (method === 'tools/list') {
261
+ return {
262
+ jsonrpc: '2.0', id,
263
+ result: { tools: TOOL_NAMES.map((n) => ({ name: n, description: TOOLS[n].description, inputSchema: TOOLS[n].inputSchema, annotations: { readOnlyHint: !/prepare_action|execute_action/.test(n) } })) },
264
+ }
265
+ }
266
+ if (method === 'tools/call') {
267
+ const name = params && params.name
268
+ const args = (params && params.arguments) || {}
269
+ const tool = TOOLS[name]
270
+ if (!tool) return { jsonrpc: '2.0', id, result: { isError: true, content: [{ type: 'text', text: `unknown tool: ${name}` }] } }
271
+ try {
272
+ const out = await tool.handler(args)
273
+ return { jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: JSON.stringify(redactDeep(out)) }] } }
274
+ } catch (e) {
275
+ return { jsonrpc: '2.0', id, result: { isError: true, content: [{ type: 'text', text: JSON.stringify({ ok: false, code: e.code || 'error', message: String(e.message || e).slice(0, 300), tenant: TENANT }) }] } }
276
+ }
277
+ }
278
+ return { jsonrpc: '2.0', id, error: { code: -32601, message: `method not found: ${method}` } }
279
+ }
280
+
281
+ if (process.argv[1] && /erp-server\.mjs$/.test(process.argv[1])) {
282
+ process.stdin.setEncoding('utf8')
283
+ let buf = ''
284
+ process.stdin.on('data', async (chunk) => {
285
+ buf += chunk
286
+ let i
287
+ while ((i = buf.indexOf('\n')) >= 0) {
288
+ const line = buf.slice(0, i).trim(); buf = buf.slice(i + 1)
289
+ if (!line) continue
290
+ let msg; try { msg = JSON.parse(line) } catch { continue }
291
+ const out = await handle(msg)
292
+ if (out) send(out)
293
+ }
294
+ })
295
+ if (!TOKEN) console.error('[argent-erp] ⚠️ 未设置 WHYSHU_API_KEY:工具会返回 no_token')
296
+ }