@whyshu/argent-engine 0.0.0-stage → 0.6.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 (305) hide show
  1. package/MANIFEST.json +307 -0
  2. package/engine/VERSION +1 -0
  3. package/engine/agents/catalog.json +454 -0
  4. package/engine/configs/_aliases.proposed.json +651 -0
  5. package/engine/configs/_migration-plan.proposed.json +899 -0
  6. package/engine/configs/account.json +272 -0
  7. package/engine/configs/advanced.json +205 -0
  8. package/engine/configs/host.json +149 -0
  9. package/engine/configs/hr.json +27 -0
  10. package/engine/configs/notify.json +264 -0
  11. package/engine/core/cli/argent.mjs +277 -0
  12. package/engine/core/contracts/argent.contracts.json +1065 -0
  13. package/engine/core/contracts/verbs.schema.json +2539 -0
  14. package/engine/core/lib/argent-datadir.mjs +223 -0
  15. package/engine/core/lib/audit.mjs +187 -0
  16. package/engine/core/lib/claim.mjs +258 -0
  17. package/engine/core/lib/config-catalog.mjs +1172 -0
  18. package/engine/core/lib/doctor-customer.mjs +577 -0
  19. package/engine/core/lib/flow-list.mjs +138 -0
  20. package/engine/core/lib/flow-registry.mjs +48 -0
  21. package/engine/core/lib/init.mjs +247 -0
  22. package/engine/core/lib/ledger.mjs +124 -0
  23. package/engine/core/lib/license-activate.mjs +813 -0
  24. package/engine/core/lib/license-device.mjs +314 -0
  25. package/engine/core/lib/license-file.mjs +349 -0
  26. package/engine/core/lib/license-guard.mjs +123 -0
  27. package/engine/core/lib/license-ops.mjs +1645 -0
  28. package/engine/core/lib/license-verdict.mjs +133 -0
  29. package/engine/core/lib/license.mjs +577 -0
  30. package/engine/core/lib/notify/dedup.mjs +101 -0
  31. package/engine/core/lib/notify/feishu.mjs +38 -0
  32. package/engine/core/lib/notify/index.mjs +95 -0
  33. package/engine/core/lib/notify/verb.mjs +327 -0
  34. package/engine/core/lib/notify/webhook.mjs +135 -0
  35. package/engine/core/lib/notify/wecom.mjs +36 -0
  36. package/engine/core/lib/python.mjs +145 -0
  37. package/engine/core/lib/sop.mjs +116 -0
  38. package/engine/core/lib/versions.mjs +205 -0
  39. package/engine/core/mcp/client.mjs +67 -0
  40. package/engine/core/mcp/http.mjs +138 -0
  41. package/engine/core/mcp/server.mjs +702 -0
  42. package/engine/scripts/_engine_rules_probe.py +15 -0
  43. package/engine/scripts/analyze-full-day.py +568 -0
  44. package/engine/scripts/archive/feishu-bridge.js +246 -0
  45. package/engine/scripts/argent-agents.mjs +527 -0
  46. package/engine/scripts/argent-capabilities.mjs +776 -0
  47. package/engine/scripts/argent-catalog.mjs +186 -0
  48. package/engine/scripts/argent-datadir.py +62 -0
  49. package/engine/scripts/argent-doctor.mjs +624 -0
  50. package/engine/scripts/argent-env-write.mjs +302 -0
  51. package/engine/scripts/argent-schedule.mjs +620 -0
  52. package/engine/scripts/argent-setup-check.mjs +369 -0
  53. package/engine/scripts/argent-upgrade.mjs +1046 -0
  54. package/engine/scripts/base-bootstrap.sh +575 -0
  55. package/engine/scripts/billing-mode-daily.sh +12 -0
  56. package/engine/scripts/build-engine-package.mjs +123 -0
  57. package/engine/scripts/capability-packs.lock.json +484 -0
  58. package/engine/scripts/capability_pack.py +1242 -0
  59. package/engine/scripts/check-1.0-criteria.mjs +355 -0
  60. package/engine/scripts/check-agent-catalog.mjs +141 -0
  61. package/engine/scripts/check-billing-mode.sh +377 -0
  62. package/engine/scripts/check-child-sessions.mjs +83 -0
  63. package/engine/scripts/check-config-catalog.mjs +677 -0
  64. package/engine/scripts/check-docs-drift.mjs +522 -0
  65. package/engine/scripts/check-dsh-web-expose.sh +98 -0
  66. package/engine/scripts/check-judgment-drift.mjs +403 -0
  67. package/engine/scripts/check-license.mjs +199 -0
  68. package/engine/scripts/check-model-auth.py +443 -0
  69. package/engine/scripts/check-pack-closure.mjs +661 -0
  70. package/engine/scripts/check-portable-symbols.mjs +116 -0
  71. package/engine/scripts/check-preset-landing.mjs +124 -0
  72. package/engine/scripts/check-production-home.mjs +602 -0
  73. package/engine/scripts/check-role-mapping.mjs +461 -0
  74. package/engine/scripts/check-sample-redaction.mjs +320 -0
  75. package/engine/scripts/check-skill-classification.mjs +188 -0
  76. package/engine/scripts/check-sop-consistency.mjs +431 -0
  77. package/engine/scripts/check-test-env-safety.mjs +627 -0
  78. package/engine/scripts/check-version-consistency.mjs +452 -0
  79. package/engine/scripts/ci-run.sh +558 -0
  80. package/engine/scripts/config-explain.mjs +278 -0
  81. package/engine/scripts/configure-provider.rb +29 -0
  82. package/engine/scripts/customer-config-migrate.mjs +179 -0
  83. package/engine/scripts/customer-preflight.py +892 -0
  84. package/engine/scripts/customer-readiness.sh +134 -0
  85. package/engine/scripts/daily-report-inbox.py +242 -0
  86. package/engine/scripts/daily-report-reconcile.mjs +312 -0
  87. package/engine/scripts/daily-report.mjs +1310 -0
  88. package/engine/scripts/daily-report.sh +46 -0
  89. package/engine/scripts/data/lip-product-dict.json +306 -0
  90. package/engine/scripts/deliver-customer.mjs +932 -0
  91. package/engine/scripts/delivery-faces.txt +31 -0
  92. package/engine/scripts/desktop-trial.sh +146 -0
  93. package/engine/scripts/dev-window.sh +260 -0
  94. package/engine/scripts/drill-clean-machine.sh +315 -0
  95. package/engine/scripts/dsh-api-client.mjs +215 -0
  96. package/engine/scripts/dsh-backup-scope.sh +83 -0
  97. package/engine/scripts/dsh-offline-pack.mjs +77 -0
  98. package/engine/scripts/dsh-overlay-pin.sh +87 -0
  99. package/engine/scripts/dsh-plane.mjs +153 -0
  100. package/engine/scripts/dsh-portforward.py +223 -0
  101. package/engine/scripts/dsh-preflight.mjs +179 -0
  102. package/engine/scripts/dsh-release-watch-daily.sh +19 -0
  103. package/engine/scripts/dsh-release-watch.mjs +458 -0
  104. package/engine/scripts/dsh-remote-check.sh +99 -0
  105. package/engine/scripts/dsh-restart.sh +179 -0
  106. package/engine/scripts/dsh-restore.sh +111 -0
  107. package/engine/scripts/dsh-scope-rewire.mjs +803 -0
  108. package/engine/scripts/dsh-snapshot.mjs +116 -0
  109. package/engine/scripts/dsh-upgrade-readiness.mjs +165 -0
  110. package/engine/scripts/dsh-upgrade.mjs +612 -0
  111. package/engine/scripts/dsh-url.sh +86 -0
  112. package/engine/scripts/dsh-web-launch.mjs +155 -0
  113. package/engine/scripts/ensure-store-windows.py +509 -0
  114. package/engine/scripts/entitlements.py +458 -0
  115. package/engine/scripts/env_probe.py +411 -0
  116. package/engine/scripts/feishu-card.mjs +252 -0
  117. package/engine/scripts/fix-skill-frontmatter.py +209 -0
  118. package/engine/scripts/flow-auto-retry.py +454 -0
  119. package/engine/scripts/flow-requirements.json +720 -0
  120. package/engine/scripts/flow-scaffold.py +275 -0
  121. package/engine/scripts/flow-timers-plist.sh +86 -0
  122. package/engine/scripts/flow-timers.sh +182 -0
  123. package/engine/scripts/flow-trigger-wake.mjs +284 -0
  124. package/engine/scripts/flow-trigger.sh +571 -0
  125. package/engine/scripts/flow_billing.py +354 -0
  126. package/engine/scripts/flow_capability_stats.py +314 -0
  127. package/engine/scripts/flow_case.py +194 -0
  128. package/engine/scripts/flow_claim.py +497 -0
  129. package/engine/scripts/flow_config.py +1024 -0
  130. package/engine/scripts/flow_failure.py +588 -0
  131. package/engine/scripts/flow_learn.py +105 -0
  132. package/engine/scripts/flow_ledger.py +235 -0
  133. package/engine/scripts/flow_policy.py +238 -0
  134. package/engine/scripts/flow_preflight.py +339 -0
  135. package/engine/scripts/flow_registry.py +341 -0
  136. package/engine/scripts/flow_retry.py +304 -0
  137. package/engine/scripts/flow_return_to_ops.py +191 -0
  138. package/engine/scripts/flow_source.py +315 -0
  139. package/engine/scripts/flow_sources/__init__.py +53 -0
  140. package/engine/scripts/flow_sources/cases_json.py +106 -0
  141. package/engine/scripts/flow_sources/common.py +428 -0
  142. package/engine/scripts/flow_sources/feishu_base.py +273 -0
  143. package/engine/scripts/flow_stale.py +185 -0
  144. package/engine/scripts/flow_status_reconcile.py +344 -0
  145. package/engine/scripts/flow_triage.py +849 -0
  146. package/engine/scripts/flow_write_verify.py +496 -0
  147. package/engine/scripts/fulfillment.js +586 -0
  148. package/engine/scripts/gen-agent-bundles.mjs +276 -0
  149. package/engine/scripts/gen-preset-rows.mjs +259 -0
  150. package/engine/scripts/init-ml.sh +2275 -0
  151. package/engine/scripts/install-internal-role.sh +190 -0
  152. package/engine/scripts/install-license.mjs +641 -0
  153. package/engine/scripts/install-one-shot.mjs +665 -0
  154. package/engine/scripts/integration_contract.py +220 -0
  155. package/engine/scripts/integrations/wenyao/adapter.py +98 -0
  156. package/engine/scripts/integrations/xzh/adapter.py +75 -0
  157. package/engine/scripts/invoice-mapping.example.json +40 -0
  158. package/engine/scripts/invoice-table.sh +194 -0
  159. package/engine/scripts/invoice_carrier_notice.py +279 -0
  160. package/engine/scripts/invoice_fail_closeout.py +297 -0
  161. package/engine/scripts/invoice_lip_runner.py +1508 -0
  162. package/engine/scripts/invoice_lip_runner.py.bak-20260922-1710-freready +1090 -0
  163. package/engine/scripts/invoice_ready_audit.py +191 -0
  164. package/engine/scripts/invoice_runner.py +675 -0
  165. package/engine/scripts/invoice_stageb.py +516 -0
  166. package/engine/scripts/launchd/com.whyshu.argent-bridge.plist.example +42 -0
  167. package/engine/scripts/launchd/com.whyshu.fulfillment-poll.plist.example +29 -0
  168. package/engine/scripts/lease.py +377 -0
  169. package/engine/scripts/lib/agent-install.mjs +649 -0
  170. package/engine/scripts/lib/argent-datadir.mjs +16 -0
  171. package/engine/scripts/lib/argent_datadir.py +262 -0
  172. package/engine/scripts/lib/argent_filelock.py +507 -0
  173. package/engine/scripts/lib/bootcheck-wiring.mjs +510 -0
  174. package/engine/scripts/lib/cdp_ports.py +197 -0
  175. package/engine/scripts/lib/customer-env.mjs +102 -0
  176. package/engine/scripts/lib/daily-report-core.mjs +517 -0
  177. package/engine/scripts/lib/dep-declaration.mjs +81 -0
  178. package/engine/scripts/lib/deps-migrate.mjs +310 -0
  179. package/engine/scripts/lib/dsh-host-resolve.mjs +109 -0
  180. package/engine/scripts/lib/dsh-scope.mjs +500 -0
  181. package/engine/scripts/lib/dsh-snapshot-path.mjs +35 -0
  182. package/engine/scripts/lib/dsh-versions.mjs +96 -0
  183. package/engine/scripts/lib/dsh-web-probe.mjs +214 -0
  184. package/engine/scripts/lib/is-direct-run.mjs +61 -0
  185. package/engine/scripts/lib/patch-compose.mjs +241 -0
  186. package/engine/scripts/lib/patch-target.mjs +35 -0
  187. package/engine/scripts/lib/plugin-lifecycle.mjs +128 -0
  188. package/engine/scripts/lib/plugin-packages.mjs +332 -0
  189. package/engine/scripts/lib/preset-compat.mjs +127 -0
  190. package/engine/scripts/lib/preset-install.mjs +575 -0
  191. package/engine/scripts/lib/preset-mechanism.mjs +56 -0
  192. package/engine/scripts/lib/preset-sync.mjs +59 -0
  193. package/engine/scripts/lib/profile-retire.mjs +97 -0
  194. package/engine/scripts/lib/python-runtime.mjs +190 -0
  195. package/engine/scripts/lib/registry.mjs +72 -0
  196. package/engine/scripts/lib/repo-root.sh +56 -0
  197. package/engine/scripts/lib/schedule-mechanism.mjs +659 -0
  198. package/engine/scripts/lib/schedule-probe.mjs +835 -0
  199. package/engine/scripts/lib/scope-pointer.mjs +164 -0
  200. package/engine/scripts/lib/settings-panels.mjs +816 -0
  201. package/engine/scripts/lib/stdio_utf8.py +86 -0
  202. package/engine/scripts/lib/wake-auth.mjs +392 -0
  203. package/engine/scripts/lib/webdriver_ipc.py +264 -0
  204. package/engine/scripts/lib/zstd-frames.mjs +104 -0
  205. package/engine/scripts/license-e2e-drill.mjs +426 -0
  206. package/engine/scripts/license-gate.mjs +245 -0
  207. package/engine/scripts/lip_backlog.py +171 -0
  208. package/engine/scripts/lip_table.py +185 -0
  209. package/engine/scripts/local-usage.mjs +214 -0
  210. package/engine/scripts/migrate-providers.mjs +127 -0
  211. package/engine/scripts/migrate-skills.sh +65 -0
  212. package/engine/scripts/ml-oauth-setup.mjs +550 -0
  213. package/engine/scripts/ml-store-ids.json +26 -0
  214. package/engine/scripts/ml_artifact_attribution.py +333 -0
  215. package/engine/scripts/ml_capacity_probe.py +1158 -0
  216. package/engine/scripts/ml_hub_status.py +1556 -0
  217. package/engine/scripts/ml_schema_check.py +115 -0
  218. package/engine/scripts/ml_store_ids.py +139 -0
  219. package/engine/scripts/npm-publish-argent.mjs +499 -0
  220. package/engine/scripts/npm-publish-argent.sh +20 -0
  221. package/engine/scripts/orchestrator.py +13811 -0
  222. package/engine/scripts/pack-plugins.mjs +140 -0
  223. package/engine/scripts/patches/dsh-llm-deepseek--transport-diagnostics.patch +297 -0
  224. package/engine/scripts/patches/dsh-session--append-message-shape.patch +15 -0
  225. package/engine/scripts/patches/dsh-session--tool-result-block-017.patch +28 -0
  226. package/engine/scripts/patches/dsh-session-format-v0-to-v1--permission-preset-origin.patch +11 -0
  227. package/engine/scripts/patches/dsh-session-format-v3-to-v4--turn-outside-replace.patch +20 -0
  228. package/engine/scripts/patches/dsh-session-persistence-jsonl--worker-bundled-migrators.patch +39 -0
  229. package/engine/scripts/patches/dsh-tools--render-return-shape.patch +50 -0
  230. package/engine/scripts/patches/verify-bundled-migrators.mjs +79 -0
  231. package/engine/scripts/patches/verify-hardening.mjs +167 -0
  232. package/engine/scripts/patches/verify-session-migration.mjs +149 -0
  233. package/engine/scripts/patches/verify-transport-diagnostics.mjs +227 -0
  234. package/engine/scripts/plan-role-rename.mjs +125 -0
  235. package/engine/scripts/plugin-bootcheck.mjs +451 -0
  236. package/engine/scripts/plugin-composition.json +15 -0
  237. package/engine/scripts/poc-mcp-host.mjs +455 -0
  238. package/engine/scripts/poll-fulfillment.sh +293 -0
  239. package/engine/scripts/poll-invoice.sh +74 -0
  240. package/engine/scripts/profile-doctor.mjs +686 -0
  241. package/engine/scripts/provider-select.sh +70 -0
  242. package/engine/scripts/provider-switch.sh +146 -0
  243. package/engine/scripts/provider-sync-target.check.mjs +64 -0
  244. package/engine/scripts/python-runtime.sh +78 -0
  245. package/engine/scripts/query_runner.py +310 -0
  246. package/engine/scripts/release-argent.sh +574 -0
  247. package/engine/scripts/repair-unresolved-tools.mjs +178 -0
  248. package/engine/scripts/run-tests.sh +256 -0
  249. package/engine/scripts/self-sign-license.mjs +179 -0
  250. package/engine/scripts/session-health-scan.mjs +63 -0
  251. package/engine/scripts/session-repair-tool-result-content.mjs +181 -0
  252. package/engine/scripts/setup.sh +22 -0
  253. package/engine/scripts/skill-install.mjs +629 -0
  254. package/engine/scripts/sku_translate.py +148 -0
  255. package/engine/scripts/sync-plugins.sh +90 -0
  256. package/engine/scripts/sync-presets.sh +27 -0
  257. package/engine/scripts/sync-scripts.sh +445 -0
  258. package/engine/scripts/telemetry.py +873 -0
  259. package/engine/scripts/ui-copy-count.mjs +220 -0
  260. package/engine/scripts/usage-anchor.sh +136 -0
  261. package/engine/scripts/usage_report.py +531 -0
  262. package/engine/scripts/validate-preset-schema.mjs +208 -0
  263. package/engine/scripts/vertical-init.mjs +323 -0
  264. package/engine/scripts/vertical-lint.mjs +482 -0
  265. package/engine/scripts/wenyao-browser.sh +73 -0
  266. package/engine/scripts/wenyao-fixture-export.py +116 -0
  267. package/engine/scripts/wenyao-invoice-writeback.mjs +540 -0
  268. package/engine/scripts/wenyao-login.sh +34 -0
  269. package/engine/scripts/wenyao-template-map.json +292 -0
  270. package/engine/scripts/wenyao_attach.py +131 -0
  271. package/engine/scripts/wenyao_download_template.py +144 -0
  272. package/engine/scripts/wenyao_fill_template.py +568 -0
  273. package/engine/scripts/wenyao_login.py +145 -0
  274. package/engine/scripts/wenyao_portal.py +633 -0
  275. package/engine/scripts/wenyao_recon.py +230 -0
  276. package/engine/scripts/wenyao_run.py +503 -0
  277. package/engine/scripts/wenyao_shadow_diff.py +160 -0
  278. package/engine/scripts/wenyao_skill_contract.py +156 -0
  279. package/engine/scripts/wenyao_template_fetch.py +175 -0
  280. package/engine/scripts/win/dsh-web-autostart.ps1 +372 -0
  281. package/engine/scripts/ziniao-auth-probe.sh +268 -0
  282. package/engine/scripts/ziniao-keepalive.sh +216 -0
  283. package/engine/scripts/ziniao-post-upgrade-check.sh +120 -0
  284. package/engine/scripts/ziniao-windows.sh +153 -0
  285. package/engine/version.mjs +1 -0
  286. package/engine/verticals/README.md +103 -0
  287. package/engine/verticals/_engine-words.baseline.txt +237 -0
  288. package/engine/verticals/_engine-words.json +19 -0
  289. package/engine/verticals/mercadolibre/README.md +43 -0
  290. package/engine/verticals/mercadolibre/config.json +709 -0
  291. package/engine/verticals/mercadolibre/flows.json +706 -0
  292. package/engine/verticals/mercadolibre/manifest.json +538 -0
  293. package/engine/verticals/mercadolibre/migrate-machine-state.mjs +138 -0
  294. package/engine/verticals/mercadolibre/preflight.py +143 -0
  295. package/engine/verticals/mercadolibre/prompts/daily-report.failure.md +69 -0
  296. package/engine/verticals/mercadolibre/prompts/daily-report.md +47 -0
  297. package/engine/verticals/mercadolibre/prompts/daily-report.scheduled.md +79 -0
  298. package/engine/verticals/mercadolibre/prompts/full.failure.md +125 -0
  299. package/engine/verticals/mercadolibre/prompts/full.md +91 -0
  300. package/engine/verticals/mercadolibre/prompts/wenyao-invoice.failure.md +88 -0
  301. package/engine/verticals/mercadolibre/prompts/wenyao-invoice.md +64 -0
  302. package/engine/verticals/mercadolibre/prompts/xzh-invoice.failure.md +91 -0
  303. package/engine/verticals/mercadolibre/prompts/xzh-invoice.md +46 -0
  304. package/package.json +21 -4
  305. package/README.md +0 -3
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env python3
2
+ """wenyao-fixture-export.py — 从(有权限的)机器上只读导出「上海文鳐」测试夹具(2026-09-10)
3
+
4
+ 用途:lip 表在客户 Base(`K8zjbMB9TaGKADsruYRcuKPFnAe`),**本机 bot 无权限(91403)**。
5
+ 在**有权限的机器**(Huangwei 客户机)上跑本脚本,把 `FULL状态=已完成` 且 `货代` 含目标货代的行
6
+ 以 **JSON 全字段**导出(我不预设 lip 表列名,导出后按实际字段名适配),再把这一个 JSON 文件
7
+ 拷到本机 `~/.dsh/argent/` 做夹具 —— 本机全程不碰 lip 表、不碰生产。
8
+
9
+ 只读:只调用 `base +record-list`,不写任何字段。
10
+
11
+ 用法(在客户机执行):
12
+ ~/.dsh/argent/venv/bin/python3 scripts/wenyao-fixture-export.py --carrier 文鳐 --limit 3 --out ~/wenyao-fixture.json
13
+ 然后:scp ~/wenyao-fixture.json <本机>:~/.dsh/argent/wenyao-fixture.json (或飞书发文件给自己)
14
+
15
+ 参数:--profile(默认 ml-fulfillment)--base-token(默认 lip base)--table-id(默认 lip 表)
16
+ --carrier(货代匹配子串,默认 文鳐)--fstate(FULL 状态列名,默认 FULL状态)--limit(默认 3)
17
+ """
18
+ import argparse
19
+ import json
20
+ import os
21
+ import re
22
+ import shutil
23
+ import subprocess
24
+ import sys
25
+
26
+ # 2026-09-20(迭代1-残留项):底座/表改走部署 env(可 --base/--table 覆盖),不再写死。
27
+ # 用途是"导出夹具",所以缺 env 时给的是**明确的报错**而不是猜一张表。
28
+ LIP_BASE = os.environ.get("FULL_BASE_TOKEN", "")
29
+ LIP_TABLE = os.environ.get("FULL_TABLE_ID", "")
30
+
31
+
32
+ def find_lark():
33
+ for c in (shutil.which("lark-cli"), f"{os.path.expanduser('~')}/.local/bin/lark-cli",
34
+ f"{os.path.expanduser('~')}/node/bin/lark-cli", "/opt/homebrew/bin/lark-cli"):
35
+ if c and os.path.exists(c):
36
+ return c
37
+ return "lark-cli"
38
+
39
+
40
+ def fetch_markdown(lark, profile, base, table, limit):
41
+ cmd = [lark, "--profile", profile, "base", "+record-list",
42
+ "--base-token", base, "--table-id", table, "--as", "bot", "--format", "markdown",
43
+ "--limit", str(limit)]
44
+ r = subprocess.run(cmd, capture_output=True, text=True)
45
+ if r.returncode != 0:
46
+ raise RuntimeError(f"lark-cli 失败({r.returncode}): {(r.stderr or r.stdout)[:300]}")
47
+ return r.stdout
48
+
49
+
50
+ def parse_md(text):
51
+ rows = [l for l in text.splitlines() if l.strip().startswith("|")]
52
+ if len(rows) < 2:
53
+ return [], []
54
+ def cells(line):
55
+ return [c.strip() for c in line.strip().strip("|").split("|")]
56
+ header = cells(rows[0])
57
+ body = [cells(r) for r in rows[2:] if r.strip().startswith("|")]
58
+ return header, body
59
+
60
+
61
+ def main():
62
+ ap = argparse.ArgumentParser()
63
+ ap.add_argument("--profile", default="ml-fulfillment")
64
+ ap.add_argument("--base-token", default=LIP_BASE)
65
+ ap.add_argument("--table-id", default=LIP_TABLE)
66
+ ap.add_argument("--carrier", default="文鳐")
67
+ ap.add_argument("--fstate", default="FULL状态")
68
+ ap.add_argument("--limit", type=int, default=3)
69
+ ap.add_argument("--out", default="-")
70
+ a = ap.parse_args()
71
+
72
+ lark = find_lark()
73
+ md = fetch_markdown(lark, a.profile, a.base_token, a.table_id, a.limit)
74
+ header, body = parse_md(md)
75
+ if not header:
76
+ print(json.dumps({"ok": False, "why": "未解析到表格(可能无权限或表为空)", "head": md[:200]}, ensure_ascii=False))
77
+ return 1
78
+ idx = {name: i for i, name in enumerate(header)}
79
+
80
+ def val(row, name):
81
+ i = idx.get(name)
82
+ return row[i] if i is not None and i < len(row) else ""
83
+
84
+ picked = []
85
+ for row in body:
86
+ fstate = val(row, a.fstate)
87
+ carrier = val(row, "货代公司") or val(row, "货代")
88
+ if "已完成" not in fstate:
89
+ continue
90
+ if a.carrier and a.carrier not in carrier:
91
+ continue
92
+ rec = {}
93
+ for i, name in enumerate(header):
94
+ if i < len(row):
95
+ rec[name] = row[i]
96
+ picked.append(rec)
97
+ if len(picked) >= a.limit:
98
+ break
99
+
100
+ out = {"ok": True, "source": {"base": a.base_token, "table": a.table_id, "profile": a.profile},
101
+ "filter": {"FULL状态": "contains 已完成", "货代": f"contains {a.carrier}"},
102
+ "columns": header, "rows": picked}
103
+ payload = json.dumps(out, ensure_ascii=False, indent=1)
104
+ if a.out == "-":
105
+ print(payload)
106
+ else:
107
+ with open(a.out, "w", encoding="utf-8") as f:
108
+ f.write(payload)
109
+ os.chmod(a.out, 0o600)
110
+ print(json.dumps({"ok": True, "rows": len(picked), "columns": len(header), "out": a.out,
111
+ "hint": f"拷到本机:scp {a.out} <本机>:~/.dsh/argent/wenyao-fixture.json"}, ensure_ascii=False))
112
+ return 0 if picked else 2
113
+
114
+
115
+ if __name__ == "__main__":
116
+ sys.exit(main())
@@ -0,0 +1,540 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 上海文鳐发票流程 · 生产表回写器(P1,2026-09-11)
4
+ *
5
+ * 目标:把**已确认的**填报件写回客户 lip 表(Base `K8zjbMB9TaGKADsruYRcuKPFnAe` /
6
+ * 表 `tblj6CBX0JQWDLhh`)的**指定一条记录**:附件字段 `发票文件` 追加文件 + `发票状态` 置「填写中」。
7
+ *
8
+ * 设计红线(在客户生产表上写入,必须可回滚、可审计、最小面):
9
+ * ① **字段白名单**:只允许 `发票状态` / `发票文件` / `大货标签` / `发票错误信息`;其余字段一律拒绝。
10
+ * 附件字段**不允许**当普通 CellValue 写(必须走 `+record-upload-attachment`)。
11
+ * ② **记录白名单**:必须显式 `--allow-record <record_id>`;不给就拒绝,避免误伤其他行。
12
+ * ③ **状态机**:`已完成/已上传/标签已下载` 属终态,回写需 `--force`;同值写视为 noop。
13
+ * ④ **写前整行备份**:以「字段名 → 值」结构落 `~/.dsh/argent/invoice-writeback/backup/`(按名映射,不用位置猜)。
14
+ * ⑤ **写后读回验收** + **审计 JSONL**(每次写入一行,含 old/new/file_token)。
15
+ * ⑥ 默认 `--dry-run`(只打印计划,不写);真写需显式 `--yes`。
16
+ *
17
+ * 守卫函数是纯函数(无 IO),`scripts/tests/wenyao-writeback.test.mjs` 直接 import 断言。
18
+ *
19
+ * 用法:
20
+ * node scripts/wenyao-invoice-writeback.mjs \
21
+ * --record-id recvuIaAFrq4Cg --allow-record recvuIaAFrq4Cg \
22
+ * --file ~/.dsh/argent/wenyao-output/xxx.xlsx --status 填写中 --dry-run
23
+ * 去掉 `--dry-run` 并加 `--yes` 才真正落库。
24
+ */
25
+ import { execFileSync } from 'node:child_process'
26
+ import { existsSync, mkdirSync, writeFileSync, appendFileSync, statSync } from 'node:fs'
27
+ import { homedir } from 'node:os'
28
+ import { join, basename, dirname } from 'node:path'
29
+ import { fileURLToPath } from 'node:url'
30
+ import { resolveDataDir } from './lib/argent-datadir.mjs'
31
+ import { isDirectRun } from './lib/is-direct-run.mjs'
32
+
33
+ // 2026-09-20(迭代1-步骤4):**不再硬编码**表/身份 —— 那是"换客户=改代码"的根。
34
+ // 缺省目标改为**惰性解析**(首次使用时经 flow_config 走部署 env:envFile + baseEnv/tableEnv/
35
+ // identityEnv);解析失败 ⇒ 抛错拒绝执行(绝不"猜一张表写进去")。
36
+ // 为什么惰性而不是 import 时解析:纯函数(守卫)单测不该因为缺 env 就跑不起来。
37
+
38
+ /**
39
+ * 回写目标(**共用守卫**,2026-09-17 起按用户口径「鑫中航复用文鳐这套守卫」)。
40
+ *
41
+ * 默认 = 上海文鳐 lip 表;`--flow <id>` 时用 `flow_config.py`(唯一配置读取点)解析出
42
+ * 该流程声明的 base/table/identity ⇒ 同一份守卫服务多条发票流程,**不复制第二套逻辑**。
43
+ * 解析失败(缺部署 env / 表 id 拿不到)⇒ 直接拒绝执行,绝不"猜一张表写进去"。
44
+ */
45
+ const DEFAULT_FLOW = 'wenyao-invoice'
46
+ let _default = null
47
+ /** 缺省回写目标(惰性解析一次并缓存)。 */
48
+ export function defaultTarget() {
49
+ if (!_default) _default = resolveTarget(DEFAULT_FLOW)
50
+ return { ..._default }
51
+ }
52
+ let TARGET = null
53
+ export function getTarget() { return TARGET ? { ...TARGET } : defaultTarget() }
54
+ export function setTarget(t = {}) {
55
+ TARGET = { ...getTarget(), ...t } // 基线取当前目标(可能是惰性解析出的缺省)
56
+ return getTarget()
57
+ }
58
+
59
+ /** 按流程 id 解析回写目标(读注册表 + 部署 env;失败即抛,宁可不写)。 */
60
+ export function resolveTarget(flowId, { repo, argv = process.argv } = {}) {
61
+ const root = repo || join(dirname(fileURLToPath(import.meta.url)), '..')
62
+ const py = (process.env.ARGENT_PY || process.env.ARGENT_PYTHON || `${process.env.DSH_HOME || `${process.env.HOME}/.dsh`}/argent/venv/bin/python3`)
63
+ let cfg
64
+ try {
65
+ cfg = JSON.parse(execFileSync(py, [join(root, 'scripts', 'flow_config.py'), '--flow', flowId, '--json'],
66
+ { encoding: 'utf8' }))
67
+ } catch (e) {
68
+ throw new Error(`流程 ${flowId} 配置不可解析(缺部署 env?):${String(e.stderr || e.message).trim().split('\n').pop()}`)
69
+ }
70
+ const src = cfg.source || {}
71
+ const base = String(src.base || '')
72
+ const table = String(src.table || '')
73
+ const profile = String(src.identity || '')
74
+ if (!base || !table) throw new Error(`流程 ${flowId} 未解析出 base/table(${JSON.stringify(src).slice(0, 200)})`)
75
+ if (!profile) throw new Error(`流程 ${flowId} 未声明 identity(回写需要知道用哪个 bot 身份)`)
76
+ return { flow: flowId, base, table, profile }
77
+ }
78
+
79
+ /** 只许碰这四个字段;`发票文件` 必须走附件上传通道。 */
80
+ export const ALLOWED_FIELDS = ['发票状态', '发票文件', '大货标签', '发票错误信息']
81
+ export const ATTACHMENT_FIELDS = ['发票文件', '大货标签']
82
+ /**
83
+ * **单向释放字段**(2026-09-18):运营的「放行勾」只允许被**取消**,永不允许由程序勾上。
84
+ *
85
+ * 背景(真实事故):文鳐收口只写「发票状态=已完成」,**没有释放放行勾** ⇒ 该行长期处于
86
+ * `FULL状态=已完成 ∧ 发票就绪=true` = 表上看着"还等着跑";而且只要台账那条 `done` 一失效
87
+ * (身份漂移/台账轮转),开启闸门就会重新放行 ⇒ 重复开票、重复上传
88
+ * (2026-09-18 23:17 已真实预演过一次:同一业务行被重新认领,靠角色判 blocked 才没重复写)。
89
+ * 鑫中航的 runner 早就做了这件事(`{发票状态:已完成, 发票就绪:False}`),文鳐漏了。
90
+ *
91
+ * 为什么不把「发票就绪」塞进 ALLOWED_FIELDS:那是**双向可写**的普通字段边界;
92
+ * 勾选权必须永远归运营,所以单独约束 —— **值只允许 false**,写 true 一律拒绝。
93
+ */
94
+ export const ONEWAY_FALSE_FIELDS = ['发票就绪']
95
+ export const STATUS_OPTIONS = ['未开发票', '待勾选', '待生成', '填写中', '已上传', '标签已下载', '已完成', '失败', '跳过']
96
+ /** 可以开始/重跑的状态。 */
97
+ export const STATUS_STARTABLE = ['', '未开发票', '待勾选', '待生成', '失败', '跳过']
98
+ /** 已进入流程、允许覆盖自身(重跑/重传)。 */
99
+ export const STATUS_IN_PROGRESS = ['填写中']
100
+ /** 终态:回写要 --force。 */
101
+ export const STATUS_TERMINAL = ['已上传', '标签已下载', '已完成']
102
+ export const SHIPPING_STATE = '填写中'
103
+
104
+ /** select/attachment 常以数组返回;统一取首值。 */
105
+ export function pickValue(v) {
106
+ if (Array.isArray(v)) return v.length === 0 ? '' : pickValue(v[0])
107
+ if (v === null || v === undefined) return ''
108
+ if (typeof v === 'object') return v.name ?? v.text ?? ''
109
+ return v
110
+ }
111
+
112
+ /** 飞书 checkbox 的"真值"判断:'True'/'true'/'是'/true 都算勾上;''/None/'False' 都算没勾。 */
113
+ export function isTruthy(v) {
114
+ const s = pickValue(v)
115
+ if (s === true) return true
116
+ if (s === false || s === null || s === undefined) return false
117
+ return ['true', '1', 'yes', '是'].includes(String(s).trim().toLowerCase())
118
+ }
119
+
120
+ export function normalizeStatus(v) {
121
+ const s = pickValue(v)
122
+ return typeof s === 'string' ? s.trim() : String(s)
123
+ }
124
+
125
+ /**
126
+ * 字段级守卫。返回 `{ errors, warnings, clean }`;errors 非空 ⇒ 拒绝写入。
127
+ */
128
+ export function guardPatch(patch, allowed = ALLOWED_FIELDS) {
129
+ const errors = []
130
+ const warnings = []
131
+ const clean = {}
132
+ if (!patch || typeof patch !== 'object') return { errors: ['empty-patch'], warnings, clean }
133
+ const keys = Object.keys(patch)
134
+ if (keys.length === 0) errors.push('empty-patch')
135
+ for (const k of keys) {
136
+ // 单向释放字段:只允许显式 false —— 「勾上」这件事永远只能由运营在表里做
137
+ if (ONEWAY_FALSE_FIELDS.includes(k)) {
138
+ if (patch[k] !== false) { errors.push(`oneway-field-only-false:${k}`); continue }
139
+ clean[k] = false
140
+ continue
141
+ }
142
+ if (!allowed.includes(k)) { errors.push(`field-not-whitelisted:${k}`); continue }
143
+ if (ATTACHMENT_FIELDS.includes(k)) { errors.push(`attachment-field-needs-upload:${k}`); continue }
144
+ const v = patch[k]
145
+ if (v !== null && typeof v === 'object' && !Array.isArray(v)) { errors.push(`value-not-lossless:${k}`); continue }
146
+ if (Array.isArray(v) && v.some((x) => x && typeof x === 'object')) { errors.push(`value-not-lossless:${k}`); continue }
147
+ clean[k] = v
148
+ }
149
+ return { errors, warnings, clean }
150
+ }
151
+
152
+ /** 记录级守卫:必须显式授权目标记录。 */
153
+ export function guardRecord(recordId, allowedRecords) {
154
+ if (!recordId || typeof recordId !== 'string') return { ok: false, error: 'missing-record-id' }
155
+ if (!Array.isArray(allowedRecords) || allowedRecords.length === 0) return { ok: false, error: 'missing-allow-record' }
156
+ if (!allowedRecords.includes(recordId)) return { ok: false, error: `record-not-whitelisted:${recordId}` }
157
+ return { ok: true }
158
+ }
159
+
160
+ /** 状态机守卫:返回 `{ ok, action }`,action ∈ write|noop。 */
161
+ export function guardStatusTransition(current, target, { force = false } = {}) {
162
+ const from = normalizeStatus(current)
163
+ const to = normalizeStatus(target)
164
+ if (!STATUS_OPTIONS.includes(to)) return { ok: false, error: `unknown-status:${to}` }
165
+ if (from === to) return { ok: true, action: 'noop', from, to }
166
+ if (STATUS_TERMINAL.includes(from) && !force) return { ok: false, error: `terminal-status-needs-force:${from}`, from, to }
167
+ return { ok: true, action: 'write', from, to }
168
+ }
169
+
170
+ /** 每个附件字段允许的文件类型(发票文件收表格;大货标签收唛头 PDF/压缩包)。 */
171
+ export const FIELD_FILE_PATTERN = {
172
+ 发票文件: /\.(xlsx|xls)$/i,
173
+ 大货标签: /\.(pdf|zip)$/i,
174
+ }
175
+
176
+ /** 附件守卫:真实存在、非空、后缀符合该字段的类型(生产件的语义由调用方保证)。 */
177
+ export function guardFile(path, pattern = FIELD_FILE_PATTERN['发票文件']) {
178
+ if (!path) return { ok: false, error: 'missing-file' }
179
+ if (!existsSync(path)) return { ok: false, error: `file-not-found:${path}` }
180
+ const st = statSync(path)
181
+ if (!st.isFile() || st.size === 0) return { ok: false, error: `file-empty:${path}` }
182
+ if (pattern && !pattern.test(path)) return { ok: false, error: `file-type-not-allowed:${basename(path)}` }
183
+ return { ok: true, size: st.size, name: basename(path) }
184
+ }
185
+
186
+ /** 审计行(纯函数,便于断言)。 */
187
+ export function auditLine({ at, recordId, op, field, from, to, file, fileToken, bytes, ok, error }) {
188
+ return JSON.stringify({
189
+ at, record: recordId, op, field,
190
+ from: from ?? null, to: to ?? null,
191
+ file: file ?? null, fileToken: fileToken ?? null, bytes: bytes ?? null,
192
+ ok: !!ok, error: error ?? null,
193
+ who: 'argent/wenyao-invoice P1',
194
+ })
195
+ }
196
+
197
+ // ───────────────────────────── IO ─────────────────────────────
198
+
199
+ const HOME = homedir()
200
+ const WB_DIR = process.env.WENYAO_WB_DIR || join(resolveDataDir({ env: process.env }).dir, 'invoice-writeback')
201
+ const BACKUP_DIR = join(WB_DIR, 'backup')
202
+ const AUDIT = join(WB_DIR, 'audit.jsonl')
203
+
204
+ /** 瞬时错误(实测 20008 在按名解析时约 20% 概率偶发)→ 有限重试。 */
205
+ const TRANSIENT = /20008|The user does not exist|rate limit|internal error/i
206
+
207
+ function larkOnce(args) {
208
+ const out = execFileSync('lark-cli', ['--profile', getTarget().profile, 'base', ...args, '--as', 'bot', '--format', 'json'], {
209
+ encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'],
210
+ })
211
+ const j = JSON.parse(out)
212
+ if (j.ok === false) throw new Error(`lark-cli: ${j.error?.code} ${j.error?.message}`)
213
+ return j
214
+ }
215
+
216
+ function lark(args, { tries = 3 } = {}) {
217
+ let last
218
+ for (let i = 0; i < tries; i++) {
219
+ try { return larkOnce(args) } catch (e) {
220
+ last = e
221
+ if (!TRANSIENT.test(String(e.message))) throw e
222
+ sleep(400 * (i + 1))
223
+ }
224
+ }
225
+ throw last
226
+ }
227
+
228
+ function sleep(ms) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms) }
229
+
230
+ /** 全部字段(id/name/type 真源,来自 field-list,不猜位置)。 */
231
+ export function listFields() {
232
+ const j = lark(['+field-list', '--base-token', getTarget().base, '--table-id', getTarget().table])
233
+ return j.data.fields.map((f) => ({ id: f.id, name: f.name, type: f.type }))
234
+ }
235
+
236
+ /**
237
+ * 读取记录:**一律传 field_id**(按名解析既会 20008 偶发,也依赖名字唯一)。
238
+ * 返回「字段名 → 值」的映射:名字来自接口回显的 `fields`,逐名对应,避免宽投影错位。
239
+ */
240
+ export function readRecord(recordId, fieldIds) {
241
+ const args = ['+record-get', '--base-token', getTarget().base, '--table-id', getTarget().table, '--record-id', recordId]
242
+ for (const f of fieldIds) args.push('--field-id', f)
243
+ const j = lark(args)
244
+ const d = j.data
245
+ const names = d.fields
246
+ const row = d.data?.[0] ?? []
247
+ const out = {}
248
+ names.forEach((n, i) => { out[n] = row[i] ?? null })
249
+ return out
250
+ }
251
+
252
+ function backup(recordId, fieldIds, tag) {
253
+ mkdirSync(BACKUP_DIR, { recursive: true })
254
+ const row = readRecord(recordId, fieldIds)
255
+ const at = new Date().toISOString().replace(/[:.]/g, '-')
256
+ const p = join(BACKUP_DIR, `${tag}-${recordId}-${at}.json`)
257
+ writeFileSync(p, JSON.stringify({ flow: getTarget().flow, base: getTarget().base, table: getTarget().table, record: recordId, at, fields: row }, null, 2))
258
+ return { path: p, row }
259
+ }
260
+
261
+ /**
262
+ * 附件上传:**必须用 field_id**。
263
+ * 实测:用字段名(`--field-id 发票文件`)走 `/base/v3/.../fields/<name>` 解析会返回
264
+ * `20008 The user does not exist`;换 field_id 立刻成功 —— 与附件下载那次的坑同源。
265
+ * lark-cli 安全策略:`--file` 必须是**当前目录内**的相对路径(绝对路径被拒),故切到文件所在目录传 ./basename。
266
+ */
267
+ function uploadAttachment(recordId, fieldId, file) {
268
+ const cwd = dirname(file)
269
+ const rel = `./${basename(file)}`
270
+ let out
271
+ try {
272
+ out = execFileSync('lark-cli', ['--profile', getTarget().profile, 'base', '+record-upload-attachment',
273
+ '--base-token', getTarget().base, '--table-id', getTarget().table,
274
+ '--record-id', recordId, '--field-id', fieldId, '--file', rel, '--as', 'bot', '--format', 'json'],
275
+ { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] })
276
+ } catch (e) {
277
+ throw new Error(`step②upload 失败(field_id=${fieldId}):${String(e.stderr || e.message).slice(0, 500)}`)
278
+ }
279
+ const j = JSON.parse(out)
280
+ if (j.ok === false) throw new Error(`step②upload 失败:${j.error?.code} ${j.error?.message}`)
281
+ const tokens = JSON.stringify(j).match(/file_token"\s*:\s*"([^"]+)"/g) || []
282
+ return { raw: j, token: tokens.length ? tokens[tokens.length - 1].split('"')[3] : null }
283
+ }
284
+
285
+ /** 移除附件格里的指定 file_token(清理重复上传用)。 */
286
+ function removeAttachments(recordId, fieldId, tokens) {
287
+ if (tokens.length === 0) return null
288
+ const args = ['--profile', getTarget().profile, 'base', '+record-remove-attachment', '--base-token', getTarget().base,
289
+ '--table-id', getTarget().table, '--record-id', recordId, '--field-id', fieldId, '--as', 'bot', '--format', 'json', '--yes']
290
+ for (const t of tokens) args.push('--file-token', t)
291
+ const out = execFileSync('lark-cli', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 })
292
+ const j = JSON.parse(out)
293
+ if (j.ok === false) throw new Error(`remove-attachment 失败:${j.error?.code} ${j.error?.message}`)
294
+ return j
295
+ }
296
+
297
+ /** 同名列里已存在的附件(按 名 + 大小 判定同一份)——用于幂等跳过与去重。 */
298
+ export function findExisting(attachments, name, size) {
299
+ if (!Array.isArray(attachments)) return []
300
+ return attachments.filter((a) => a && a.name === name && (size == null || Number(a.size) === Number(size)))
301
+ }
302
+
303
+ /**
304
+ * 附件格的写入计划(纯函数,可单测)。
305
+ *
306
+ * 为什么按**名字**而不是"名字+大小"决定要不要清:内容更新后大小会变,
307
+ * 若只按"同名同大小"判重,重传的新件会被当成"格内没有",而**旧件仍在** ⇒ 一格里两份。
308
+ * 规则:
309
+ * · `--replace`(内容已变、要覆盖):移除格内**所有同名**件,再传新的;
310
+ * · 否则:同名 + 同大小视为同一份 ⇒ 跳过上传;同名多份 ⇒ 只留第一份,其余移除。
311
+ */
312
+ export function planAttachment({ cell, name, size, replace = false } = {}) {
313
+ const all = Array.isArray(cell) ? cell.filter((a) => a && a.name === name) : []
314
+ const same = all.filter((a) => Number(a.size) === Number(size))
315
+ if (replace) {
316
+ return { needUpload: true, removeTokens: all.map((a) => a.file_token).filter(Boolean), keepToken: null, sameName: all.length }
317
+ }
318
+ if (same.length === 0) {
319
+ return { needUpload: true, removeTokens: [], keepToken: null, sameName: all.length }
320
+ }
321
+ return {
322
+ needUpload: false,
323
+ removeTokens: all.slice(1).map((a) => a.file_token).filter(Boolean),
324
+ keepToken: same[0]?.file_token ?? all[0]?.file_token ?? null,
325
+ sameName: all.length,
326
+ }
327
+ }
328
+
329
+ /** 单次 batch-update 提交全部普通字段(附件字段不走这里)。 */
330
+ function updateFields(recordId, map) {
331
+ const payload = JSON.stringify({ update_records: { [recordId]: map } })
332
+ return lark(['+record-batch-update', '--base-token', getTarget().base, '--table-id', getTarget().table, '--json', payload])
333
+ }
334
+
335
+ // ── 守卫姿态导出(供 flow_preflight 比对;2026-09-20 迭代1-步骤4)──
336
+ // 为什么需要:注册表的 `guards.backup/audit/writeFields` 此前**没有任何执行者**(死配置),
337
+ // 且两条发票 flow 声明相反(true/true vs false/false)而注释又写着"有备份+审计" —— 声明说谎。
338
+ // 现在把"实际写手怎么做的"导出成机器可读,由 preflight 强制一致(不一致就 FAIL)。
339
+ const PRINT_POSTURE = process.argv.includes('--print-posture')
340
+ if (PRINT_POSTURE) {
341
+ console.log(JSON.stringify({
342
+ flowDefault: getTarget().flow,
343
+ allowedFields: ALLOWED_FIELDS,
344
+ attachmentFields: ATTACHMENT_FIELDS,
345
+ onewayFalseFields: ONEWAY_FALSE_FIELDS,
346
+ statusOptions: STATUS_OPTIONS,
347
+ backup: true, // 写前整行备份(backup())
348
+ audit: true, // 审计 JSONL(audit())
349
+ readback: true, // 写后读回验收(轮询重试)
350
+ }))
351
+ process.exit(0)
352
+ }
353
+
354
+ // ─────────────────────────── CLI ───────────────────────────
355
+
356
+ function parseArgv(argv) {
357
+ const o = { status: SHIPPING_STATE, 'dry-run': true, yes: false, force: false, clearError: false, replace: false, field: '发票文件', textOnly: false }
358
+ for (let i = 0; i < argv.length; i++) {
359
+ const a = argv[i]
360
+ if (a === '--dry-run') { o['dry-run'] = true; continue }
361
+ if (a === '--yes') { o.yes = true; o['dry-run'] = false; continue }
362
+ if (a === '--force') { o.force = true; continue }
363
+ if (a === '--replace') { o.replace = true; continue }
364
+ if (a === '--clear-error') { o.clearError = true; continue }
365
+ if (a === '--text-only') { o.textOnly = true; continue } // 纯文本更正:不涉及附件
366
+ if (a === '--clear-ready') { o.clearReady = true; continue } // 释放运营放行勾(单向:只置 false)
367
+ if (a === '--status') { o.status = argv[++i]; o.statusGiven = true; continue }
368
+ if (a === '--error-message') { o.errorMessage = argv[++i]; continue }
369
+ if (a === '--write') { o['dry-run'] = false; continue }
370
+ if (a.startsWith('--')) { o[a.slice(2)] = argv[++i]; continue }
371
+ }
372
+ return o
373
+ }
374
+
375
+ function main() {
376
+ const o = parseArgv(process.argv.slice(2))
377
+ const recordId = o['record-id']
378
+ const allow = String(o['allow-record'] || '').split(',').map((s) => s.trim()).filter(Boolean)
379
+ const file = o.file
380
+
381
+ // 目标解析:缺省文鳐 lip 表;`--flow xzh-invoice` ⇒ 按注册表里该流程声明的表与身份(共用同一套守卫)。
382
+ if (o.flow) {
383
+ try {
384
+ setTarget(resolveTarget(String(o.flow)))
385
+ } catch (e) {
386
+ console.error(`❌ 目标解析失败:${e.message}`)
387
+ process.exit(2)
388
+ }
389
+ }
390
+ const tag = o.tag || getTarget().flow
391
+
392
+ const targetField = String(o.field || '发票文件')
393
+ const problems = []
394
+ const rGuard = guardRecord(recordId, allow) // 记录白名单:两种模式都必填
395
+ if (!rGuard.ok) problems.push(rGuard.error)
396
+ // 纯文本更正模式(2026-09-18):只改 `发票错误信息`(+可选 `发票状态`),**不碰附件** ——
397
+ // 用于更正历史遗留的过时/错误文案(如"未接入发票自动化"其实早已接入)。
398
+ let fGuard = { ok: true, name: '', size: 0 }
399
+ if (!o.textOnly) {
400
+ if (!ATTACHMENT_FIELDS.includes(targetField)) problems.push(`field-not-an-attachment:${targetField}`)
401
+ fGuard = guardFile(file, FIELD_FILE_PATTERN[targetField])
402
+ if (!fGuard.ok) problems.push(fGuard.error)
403
+ } else if (!o.errorMessage && !o.clearError && !o.statusGiven && !o.clearReady) {
404
+ problems.push('text-only-needs-something-to-write(--error-message / --clear-error / --status / --clear-ready 至少一个)')
405
+ }
406
+
407
+ if (problems.length) { console.error('❌ 拒绝执行:\n - ' + problems.join('\n - ')); process.exit(2) }
408
+
409
+ const fieldList = listFields()
410
+ const ids = fieldList.map((f) => f.id)
411
+ const idOf = (n) => fieldList.find((f) => f.name === n)?.id
412
+ const before = readRecord(recordId, ids)
413
+ const currentStatus = normalizeStatus(before['发票状态'])
414
+ const wantStatus = (o.textOnly && !o.statusGiven) ? currentStatus : o.status // text-only 未显式给状态 ⇒ 保持原值
415
+ const sGuard = guardStatusTransition(currentStatus, wantStatus, { force: o.force })
416
+ if (!sGuard.ok) { console.error(`❌ 状态守卫拒绝:${sGuard.error}(当前=${currentStatus})`); process.exit(2) }
417
+
418
+ const patch = {}
419
+ const touched = [] // 审计用:**实际**要写的字段(下面每加一项就记一笔)
420
+ if (sGuard.action === 'write') { patch['发票状态'] = [sGuard.to]; touched.push('发票状态') }
421
+ // 释放放行勾(2026-09-18):**只允许 false**(守卫 ONEWAY_FALSE_FIELDS)。已是 false ⇒ 幂等不动。
422
+ const readyBefore = isTruthy(before['发票就绪'])
423
+ if (o.clearReady && readyBefore) { patch['发票就绪'] = false; touched.push('发票就绪') }
424
+ if (o.clearError && pickValue(before['发票错误信息'])) { patch['发票错误信息'] = null; touched.push('发票错误信息(清空)') }
425
+ if (o.errorMessage !== undefined && String(o.errorMessage) !== String(pickValue(before['发票错误信息']) || '')) {
426
+ patch['发票错误信息'] = String(o.errorMessage); touched.push('发票错误信息')
427
+ }
428
+ // 状态同值 = noop 时 patch 为空是正常的(只剩附件幂等/去重要做),不当错误。
429
+ const pGuard = Object.keys(patch).length ? guardPatch(patch) : { errors: [], clean: {} }
430
+ if (pGuard.errors.length) { console.error('❌ 字段守卫拒绝:' + pGuard.errors.join(', ')); process.exit(2) }
431
+
432
+ // 幂等/覆盖:同名同大小 ⇒ 跳过上传;`--replace` ⇒ 传新件并清掉全部同名旧件(内容更新用)。
433
+ const plan = o.textOnly
434
+ ? { sameName: 0, needUpload: false, keepToken: null, removeTokens: [] }
435
+ : planAttachment({ cell: before[targetField], name: fGuard.name, size: fGuard.size, replace: !!o.replace })
436
+
437
+ console.log('── 计划 ──')
438
+ console.log(` 流程/表 : ${getTarget().flow}|base ${getTarget().base}|table ${getTarget().table}|身份 ${getTarget().profile}`)
439
+ console.log(` 记录 : ${recordId}(字段 id: ${targetField}=${idOf(targetField)} 发票状态=${idOf('发票状态')})`)
440
+ console.log(` 货件号 : ${pickValue(before['货件号']) || '(空)'} | 货代: ${pickValue(before['货代公司'])}`)
441
+ console.log(` 发票状态 : ${currentStatus || '(空)'} → ${wantStatus}${sGuard.action === 'noop' ? '(同值,noop:text-only 未显式给状态时保持不动)' : ''}`)
442
+ if (o.clearReady) {
443
+ console.log(` 放行勾 : 发票就绪 ${readyBefore ? 'True → False(本次释放)' : 'False(已是取消态,幂等跳过)'}`)
444
+ }
445
+ if (o.textOnly) {
446
+ console.log(` 纯文本更正: 发票错误信息 → ${JSON.stringify(o.errorMessage !== undefined ? o.errorMessage : (pickValue(before['发票错误信息']) || ''))}`)
447
+ console.log(` (当前值:${JSON.stringify(pickValue(before['发票错误信息']) || '')})`)
448
+ } else {
449
+ console.log(` ${targetField} : 格内已有同名件 ${plan.sameName} 份 → ${plan.needUpload ? `${o.replace ? '覆盖为' : '追加'} ${fGuard.name}(${fGuard.size} bytes)` : '跳过上传(幂等,同一份)'}`)
450
+ }
451
+ if (plan.removeTokens.length) console.log(` 清理 : 将移除同名旧件 ${plan.removeTokens.length} 份(${plan.removeTokens.join(', ')})`)
452
+ console.log(` 字段白名单: ${Object.keys(patch).join(', ') || '(仅附件)'}`)
453
+ if (o['dry-run']) { console.log('\n[dry-run] 未写入任何数据。加 --yes 才落库。'); return }
454
+
455
+ const bk = backup(recordId, ids, tag)
456
+ console.log(`\n① 已备份整行 → ${basename(bk.path)}`)
457
+
458
+ let fileToken = plan.keepToken
459
+ try {
460
+ if (plan.needUpload) {
461
+ const up = uploadAttachment(recordId, idOf(targetField), file)
462
+ fileToken = up.token || findTokenInRecord(recordId, idOf(targetField), fGuard.name)
463
+ console.log(`② 已上传附件(file_token=${fileToken || '?'})`)
464
+ } else {
465
+ console.log(`② 跳过上传(格内已是同一份,file_token=${fileToken})`)
466
+ }
467
+ if (plan.removeTokens.length) {
468
+ // 先传后清:万一清理失败至少新件已在,不会丢内容。
469
+ removeAttachments(recordId, idOf(targetField), plan.removeTokens)
470
+ console.log(`②b 已移除同名旧件 ${plan.removeTokens.length} 份`)
471
+ }
472
+ if (Object.keys(patch).length) {
473
+ updateFields(recordId, patch)
474
+ console.log(`③ 已更新字段 → ${Object.keys(patch).map((k) => `${k}=${JSON.stringify(patch[k])}`).join(', ')}`)
475
+ } else {
476
+ console.log('③ 普通字段无变更,跳过更新')
477
+ }
478
+ } catch (e) {
479
+ audit({ recordId, op: 'upload+status', field: [...(o.textOnly ? [] : [targetField]), ...touched].join(',') || '(无字段变更)', from: currentStatus, to: sGuard.to,
480
+ file: fGuard.name, fileToken, bytes: fGuard.size, ok: false, error: String(e.message).slice(0, 300) })
481
+ console.error(`\n❌ ${e.message}`)
482
+ console.error(' (写前备份已留存,可用备份 JSON 逐字段回滚)')
483
+ process.exit(1)
484
+ }
485
+
486
+ // ⚠️ 写后读回必须**轮询重试**:飞书 Base 更新走异步链路,写完立刻读可能还是旧值
487
+ //(2026-09-11 实测:收口那次明明写成功,读回却报 `填写中 ❌` 假失败)。
488
+ let after = {}, afterStatus = '', afterFiles = null, kept = []
489
+ for (let i = 0; i < 8; i++) {
490
+ after = readRecord(recordId, [idOf('发票状态'), idOf(targetField), idOf('发票错误信息'), idOf('发票就绪')])
491
+ afterStatus = normalizeStatus(after['发票状态'])
492
+ afterFiles = after[targetField]
493
+ kept = findExisting(afterFiles, fGuard.name, fGuard.size)
494
+ if (afterStatus === sGuard.to && kept.length === 1 && !(o.clearReady && isTruthy(after['发票就绪']))) break
495
+ if (i < 7) sleep(1500)
496
+ }
497
+ const okStatus = afterStatus === sGuard.to
498
+ const okReady = !o.clearReady || !isTruthy(after['发票就绪'])
499
+ const okFile = o.textOnly
500
+ ? (pickValue(after['发票错误信息']) || '') === (o.errorMessage !== undefined ? String(o.errorMessage) : (pickValue(after['发票错误信息']) || ''))
501
+ : (kept.length === 1 && (!Array.isArray(afterFiles) || afterFiles.length === 1))
502
+ console.log(o.textOnly
503
+ ? `\n④ 读回验收:状态=${afterStatus} ${okStatus ? '✅' : '❌'} | 错误信息=${JSON.stringify(pickValue(after['发票错误信息']) || '')} ${okFile ? '✅' : '❌'}`
504
+ : `\n④ 读回验收:状态=${afterStatus} ${okStatus ? '✅' : '❌'} | 目标附件恰 1 份=${okFile ? '✅' : '❌'}(格内共 ${Array.isArray(afterFiles) ? afterFiles.length : 0} 份)`)
505
+ if (o.clearReady) console.log(` 放行勾 : 发票就绪=${isTruthy(after['发票就绪']) ? 'True ❌(未释放)' : 'False ✅'}`)
506
+
507
+ audit({ recordId, op: 'upload+status', field: [...(o.textOnly ? [] : [targetField]), ...touched].join(',') || '(无字段变更)', from: currentStatus, to: sGuard.to,
508
+ file: fGuard.name, fileToken, bytes: fGuard.size,
509
+ readyReleased: o.clearReady ? (readyBefore ? 'true→false' : 'already-false') : undefined,
510
+ ok: okStatus && okFile && okReady, error: null })
511
+ console.log(`⑤ 审计已追加 → ${AUDIT}`)
512
+ if (!okStatus || !okFile || !okReady) process.exit(1)
513
+ }
514
+
515
+ function audit(rec) {
516
+ mkdirSync(WB_DIR, { recursive: true })
517
+ appendFileSync(AUDIT, auditLine({ at: new Date().toISOString(), ...rec }) + '\n')
518
+ }
519
+
520
+ function findTokenInRecord(recordId, fieldId, name, tries = 4) {
521
+ // 表格写入是**异步生效**的:传完立刻读可能还看不到(实测审计里 file_token 因此记成 None)。
522
+ // 这里重试几次;并且同名可能有多份,取**最后一个**(新传的通常在后面)。
523
+ for (let i = 0; i < tries; i++) {
524
+ try {
525
+ const row = readRecord(recordId, [fieldId])
526
+ const v = row[Object.keys(row)[0]]
527
+ if (Array.isArray(v)) {
528
+ const hits = v.filter((x) => x && x.name === name)
529
+ if (hits.length) return hits[hits.length - 1].file_token
530
+ }
531
+ } catch { /* 读回失败不影响主流程,继续重试 */ }
532
+ if (i < tries - 1) sleep(500)
533
+ }
534
+ return null
535
+ }
536
+
537
+ export { backup, readRecord as _readRecord, findTokenInRecord }
538
+
539
+ const isMain = isDirectRun(import.meta.url)
540
+ if (isMain) main()