@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,279 @@
1
+ #!/usr/bin/env python3
2
+ """invoice_carrier_notice —— 给"勾了就绪、但货代未接入"的行写「发票错误信息」(2026-09-21 用户要求)。
3
+
4
+ **为什么需要**:lip 表是多货代共用表(允墨/商达/鑫隆/美加运通/冲港/三瀛/鸿泰信…),而自动开票目前只接入
5
+ 上海文鳐 与 鑫中航-美墨 两家。运营勾了「发票就绪」的行如果属于未接入的货代,**不会被任何流程拾取** ——
6
+ 这是正确行为(我们不该去动没接入的货代),但运营在表上看不出来:她以为"勾了就等着"。
7
+ 用户口径(逐字):「能否将这个原因写入发票错误信息中让运营知道呢?」
8
+
9
+ **写法(三条纪律,与发票回写器同规格)**:
10
+ ① 只写一个字段 `发票错误信息`(白名单);不改状态、不勾/不摘「发票就绪」、不碰附件;
11
+ ② 只写"**我方确实无法承接**"的行:`发票就绪=true ∧ FULL状态=已完成`(发票流程的开启前提)
12
+ ∧ `货代公司 ∉ 已接入货代`(后者从**注册表**的 `flows.*.routing.match` 派生,不写死);
13
+ ③ 幂等 + 不覆盖人工:已是我们自己的标记 ⇒ 跳过;字段里是**别人的话** ⇒ 默认不覆盖(`--force` 才覆盖)。
14
+ 另有:写前整行备份、写后读回验收、审计 JSONL、默认 dry-run(`--yes` 才真写)。
15
+
16
+ 用法:
17
+ python3 scripts/invoice_carrier_notice.py # 预览(不写)
18
+ python3 scripts/invoice_carrier_notice.py --yes # 真写(逐行备份 + 读回 + 审计)
19
+ python3 scripts/invoice_carrier_notice.py --json # 机器可读(供工作台/巡检)
20
+ python3 scripts/invoice_carrier_notice.py --record recXXX [--yes] # 只处理一行(排障)
21
+ 退出码:0 正常;1 有读取/写入失败;2 参数错误
22
+ """
23
+ from __future__ import annotations
24
+ import argparse, json, os, pathlib, subprocess, sys, time
25
+
26
+ HERE = pathlib.Path(__file__).resolve().parent
27
+ REGISTRY = HERE / "flow-requirements.json"
28
+
29
+ try:
30
+ from flow_registry import consumer_doc as _registry_doc
31
+ except Exception: # noqa: BLE001 —— 半坏机器上退回单文件(升级链路要能跑)
32
+ def _registry_doc(path=None):
33
+ return json.loads((pathlib.Path(path) if path else REGISTRY).read_text(encoding="utf-8"))
34
+
35
+ sys.path.insert(0, str(HERE / 'lib'))
36
+ from argent_datadir import resolve_data_dir # noqa: E402 数据目录唯一判据(勿在此复制链)
37
+
38
+ DATA = pathlib.Path(resolve_data_dir()['dir'])
39
+ AUDIT = DATA / "invoice-carrier-notice"
40
+ MARK = "[未接入货代]" # 我方标记:幂等判据 + 将来接入后可按此清理
41
+
42
+ # 字段:**运行时按名解析 id**(`scripts/lip_table.py` 是唯一入口 —— 写死物理 id 会被
43
+ # `no-hardcoded-customer-ids` 门禁拦下,因为字段 id 是客户表的事实、可能变)。命中不了会降级按名,
44
+ # 那时的读回验收会暴露问题。写入一律用解析出来的 id(按名解析偶发 20008,见 AGENTS.md)。
45
+ FIELDS_NAMES = ("货件号", "货代公司", "发票就绪", "FULL状态", "发票状态", "发票错误信息")
46
+
47
+
48
+ def resolve_fields(env: dict) -> dict:
49
+ """字段名 → id(走 lip_table;解析失败降级为按名,不静默改语义)。"""
50
+ try:
51
+ sys.path.insert(0, str(HERE))
52
+ import lip_table
53
+ t = {"base": env.get("base", ""), "table": env.get("table", ""), "profile": env.get("profile", "")}
54
+ return {n: lip_table.field_id(n, t) for n in FIELDS_NAMES}
55
+ except Exception: # noqa: BLE001
56
+ return {n: n for n in FIELDS_NAMES}
57
+
58
+
59
+ def load_registry() -> dict:
60
+ return _registry_doc()
61
+
62
+
63
+ def connected_carriers(doc: dict) -> dict:
64
+ """已接入的货代 → 负责流程(从注册表 routing.match 派生;适配器契约是第二处,这里够用)。"""
65
+ out = {}
66
+ for fid, f in (doc.get("flows") or {}).items():
67
+ cap = str((f or {}).get("capability") or "")
68
+ if cap != "invoice":
69
+ continue
70
+ match = str(((f or {}).get("routing") or {}).get("match") or "").strip()
71
+ if match:
72
+ out[match] = fid
73
+ return out
74
+
75
+
76
+ def base_env(doc: dict) -> dict:
77
+ """lip 表坐标与身份:与发票流程同源(source.baseEnv/tableEnv/identityEnv → 部署 env 文件)。"""
78
+ for fid, f in (doc.get("flows") or {}).items():
79
+ src = (f or {}).get("source") or {}
80
+ if str((f or {}).get("capability") or "") != "invoice" or not src.get("baseEnv"):
81
+ continue
82
+ env_file = DATA / str(src.get("envFile") or "fulfillment.env")
83
+ vals = {}
84
+ try:
85
+ for line in env_file.read_text(encoding="utf-8").splitlines():
86
+ t = line.strip()
87
+ if t.startswith("#") or "=" not in t:
88
+ continue
89
+ k, v = t.split("=", 1)
90
+ vals[k.strip()] = v.strip().strip('"').strip("'")
91
+ except Exception: # noqa: BLE001
92
+ pass
93
+ return {"base": os.environ.get(str(src["baseEnv"])) or vals.get(str(src["baseEnv"]), ""),
94
+ "table": os.environ.get(str(src["tableEnv"])) or vals.get(str(src["tableEnv"]), ""),
95
+ "profile": os.environ.get(str(src.get("identityEnv") or "")) or vals.get(str(src.get("identityEnv") or ""), "")
96
+ or str(src.get("identity") or "ml-fulfillment")}
97
+ return {}
98
+
99
+
100
+ def lark(args: list, profile: str, tries: int = 3) -> tuple:
101
+ """调 lark-cli —— 带重试:实测 `accounts.feishu.cn/oauth/v3/token` 偶发
102
+ `context deadline exceeded`(令牌刷新超时,与具体操作无关),一次失败不代表操作有问题。
103
+ 只对**这类瞬时失败**重试(退避 2s/4s),业务错误(如字段不存在)照样立刻返回。"""
104
+ last = (1, "", "")
105
+ for i in range(max(1, tries)):
106
+ p = subprocess.run(["lark-cli", "--profile", profile, *args], capture_output=True, text=True)
107
+ out = (p.stdout or "") + (p.stderr or "")
108
+ transient = p.returncode != 0 and any(k in out for k in (
109
+ "deadline exceeded", "context deadline", "rate limit", "too many requests",
110
+ '"type": "network"', '"subtype": "transport"', "ECONNRESET", "ETIMEDOUT",
111
+ "socket hang up", "connection reset", "temporarily unavailable"))
112
+ if not transient:
113
+ return p.returncode, p.stdout, p.stderr
114
+ last = (p.returncode, p.stdout, p.stderr)
115
+ time.sleep(2 * (i + 1))
116
+ return last
117
+
118
+
119
+ def first(v):
120
+ if isinstance(v, list):
121
+ return " ".join(str(x.get("text") if isinstance(x, dict) else x) for x in v)
122
+ return v
123
+
124
+
125
+ def list_rows(env: dict, limit: int = 600) -> list:
126
+ """窄投影读表(≤6 列;宽投影在 lip 表上会错位 —— AGENTS.md 实测)。"""
127
+ tmpdir = pathlib.Path("/tmp/invoice-carrier-notice")
128
+ tmpdir.mkdir(parents=True, exist_ok=True)
129
+ out = tmpdir / "rows.ndjson"
130
+ cmd = ["base", "+record-list", "--base-token", env["base"], "--table-id", env["table"],
131
+ "--as", "bot", "--format", "ndjson", "--output", "./rows.ndjson", "--overwrite",
132
+ "--limit", str(limit)]
133
+ for fid in resolve_fields(env).values():
134
+ cmd += ["--field-id", fid]
135
+ p = subprocess.run(["lark-cli", "--profile", env["profile"], *cmd], capture_output=True, text=True, cwd=str(tmpdir))
136
+ if p.returncode != 0:
137
+ raise RuntimeError(f"读表失败(exit {p.returncode}):{(p.stderr or p.stdout).strip()[:200]}")
138
+ rows = []
139
+ for line in out.read_text(encoding="utf-8").splitlines():
140
+ line = line.strip()
141
+ if line:
142
+ try:
143
+ rows.append(json.loads(line))
144
+ except Exception: # noqa: BLE001
145
+ continue
146
+ return rows
147
+
148
+
149
+ def row_field(row: dict, name: str):
150
+ return first((row.get("fields") or row).get(name))
151
+
152
+
153
+ def notice_text(carrier: str, connected: list) -> str:
154
+ return (f"{MARK}自动化未接入该货代「{carrier}」:当前自动开票只支持 {'、'.join(connected)}"
155
+ f" ⇒ 本行不会被自动拾取,请人工处理或联系问述科技接入")
156
+
157
+
158
+ def plan(rows: list, doc: dict) -> list:
159
+ """纯函数:给出该写哪些行、写什么(便于离线回归)。"""
160
+ conn = connected_carriers(doc)
161
+ out = []
162
+ for r in rows:
163
+ carrier = str(row_field(r, "货代公司") or "").strip()
164
+ if not carrier or carrier in conn:
165
+ continue
166
+ if str(row_field(r, "发票就绪")).strip().lower() != "true":
167
+ continue
168
+ if str(row_field(r, "FULL状态") or "").strip() != "已完成":
169
+ continue # 发票流程的开启前提没满足 ⇒ 还没到"该被拾取"的时候
170
+ cur = str(row_field(r, "发票错误信息") or "").strip()
171
+ text = notice_text(carrier, sorted(conn))
172
+ if cur == text:
173
+ action = "skip" # 幂等:已提示过同样的话
174
+ elif cur.startswith(MARK):
175
+ action = "update" # 我们自己的旧提示(例如已接入货代列表变了)
176
+ elif cur:
177
+ action = "occupied" # 有人写过别的话 ⇒ 默认不覆盖
178
+ else:
179
+ action = "write"
180
+ out.append({"recordId": r.get("record_id"), "shipment": str(row_field(r, "货件号") or ""),
181
+ "carrier": carrier, "current": cur, "text": text, "action": action})
182
+ return out
183
+
184
+
185
+ def write_note(env: dict, item: dict, doc: dict) -> str:
186
+ AUDIT.mkdir(parents=True, exist_ok=True)
187
+ ts = time.strftime("%Y%m%d-%H%M%S")
188
+ # ① 写前备份整行
189
+ rc, so, se = lark(["api", "GET",
190
+ f"/open-apis/bitable/v1/apps/{env['base']}/tables/{env['table']}/records/{item['recordId']}",
191
+ "--as", "bot"], env["profile"])
192
+ if rc == 0:
193
+ (AUDIT / f"{item['recordId']}-{ts}.json").write_text(so, encoding="utf-8")
194
+ # ② 写入(单字段、按 field_id)
195
+ err_field = resolve_fields(env)["发票错误信息"]
196
+ body = json.dumps({"update_records": {item["recordId"]: {err_field: item["text"]}}}, ensure_ascii=False)
197
+ rc, so, se = lark(["base", "+record-batch-update", "--base-token", env["base"], "--table-id", env["table"],
198
+ "--as", "bot", "--json", body], env["profile"])
199
+ ok = rc == 0 and '"record_id_list"' in (so or "")
200
+ err_tail = (se or so or "").strip().replace("\n", " ")[:200]
201
+ # ③ 读回验收(Base 最终一致:稍等再读)
202
+ time.sleep(3)
203
+ rc2, so2, _ = lark(["api", "GET",
204
+ f"/open-apis/bitable/v1/apps/{env['base']}/tables/{env['table']}/records/{item['recordId']}",
205
+ "--as", "bot"], env["profile"])
206
+ got = ""
207
+ if rc2 == 0:
208
+ try:
209
+ got = str(first(((json.loads(so2).get("data") or {}).get("record") or {}).get("fields", {}).get("发票错误信息")) or "")
210
+ except Exception: # noqa: BLE001
211
+ got = ""
212
+ verified = got.strip() == item["text"]
213
+ with (AUDIT / "audit.jsonl").open("a", encoding="utf-8") as fh:
214
+ fh.write(json.dumps({"ts": ts, "recordId": item["recordId"], "shipment": item["shipment"],
215
+ "carrier": item["carrier"], "action": item["action"], "text": item["text"],
216
+ "writeOk": ok, "readBackOk": verified, "err": "" if ok else err_tail,
217
+ "by": "invoice_carrier_notice"},
218
+ ensure_ascii=False) + "\n")
219
+ return "verified" if verified else ("written-unverified" if ok else "failed")
220
+
221
+
222
+ def main(argv=None) -> int:
223
+ ap = argparse.ArgumentParser(description="给未接入货代的「已就绪」发票行写提示(默认 dry-run)")
224
+ ap.add_argument("--yes", action="store_true", help="真写(默认只预览)")
225
+ ap.add_argument("--force", action="store_true", help="连人工写过的行也覆盖(默认跳过)")
226
+ ap.add_argument("--record", help="只处理指定 record_id(排障)")
227
+ ap.add_argument("--json", action="store_true")
228
+ a = ap.parse_args(argv)
229
+
230
+ doc = load_registry()
231
+ env = base_env(doc)
232
+ if not env.get("base") or not env.get("table"):
233
+ print("❌ 缺 lip 表坐标(FULL_BASE_TOKEN/FULL_TABLE_ID 未解析)——检查 fulfillment.env", file=sys.stderr)
234
+ return 1
235
+ try:
236
+ rows = list_rows(env)
237
+ except Exception as exc: # noqa: BLE001
238
+ print(f"❌ {exc}", file=sys.stderr)
239
+ return 1
240
+ items = plan(rows, doc)
241
+ if a.record:
242
+ items = [x for x in items if x["recordId"] == a.record]
243
+ todo = [x for x in items if x["action"] in ("write", "update")]
244
+ occ = [x for x in items if x["action"] == "occupied"]
245
+ skipped = [x for x in items if x["action"] == "skip"]
246
+
247
+ if not a.yes:
248
+ if a.json:
249
+ print(json.dumps({"connected": connected_carriers(doc), "todo": todo, "occupied": occ,
250
+ "alreadyNoticed": len(skipped), "dryRun": True}, ensure_ascii=False, indent=2))
251
+ else:
252
+ print(f" 已接入货代:{'、'.join(sorted(connected_carriers(doc)))}")
253
+ print(f" 待写 {len(todo)} 行|已提示过(跳过){len(skipped)}|人工写过(不覆盖){len(occ)}")
254
+ for x in todo:
255
+ print(f" + {x['recordId']} 货件号={x['shipment']} 货代={x['carrier']} ⇒ 「{x['text']}」")
256
+ for x in occ:
257
+ print(f" ! {x['recordId']} 货代={x['carrier']} 已有他人文字,跳过:{x['current'][:40]}")
258
+ print(" (这是预览;加 --yes 才真写)")
259
+ return 0
260
+
261
+ results = []
262
+ for x in items:
263
+ if x["action"] == "skip":
264
+ continue
265
+ if x["action"] == "occupied" and not a.force:
266
+ continue
267
+ results.append({**x, "result": write_note(env, x, doc)})
268
+ if a.json:
269
+ print(json.dumps({"results": results}, ensure_ascii=False, indent=2))
270
+ else:
271
+ for r in results:
272
+ icon = {"verified": "✅", "written-unverified": "⚠️", "failed": "❌"}[r["result"]]
273
+ print(f" {icon} {r['recordId']} 货件号={r['shipment']} 货代={r['carrier']} → {r['result']}")
274
+ print(f" 共处理 {len(results)} 行(审计:{AUDIT}/audit.jsonl)")
275
+ return 0 if all(r["result"] == "verified" for r in results) else 1
276
+
277
+
278
+ if __name__ == "__main__":
279
+ raise SystemExit(main())
@@ -0,0 +1,297 @@
1
+ #!/usr/bin/env python3
2
+ """invoice_fail_closeout —— 发票流程的**失败收口 / 成功收口**:把"为什么失败 + 怎么续跑"写进表。
3
+
4
+ 背景(2026-10-09 客户机生产实测,缺陷 B):`recvvgPQgoUOwx` 跑失败后,表里「发票就绪」被取消,
5
+ 而「发票错误信息」**是空的**(真实原因只在台账里)⇒ 运营看到"勾没了 + 没有任何说明",
6
+ 以为脚本坏了,也不知道怎么处理。用户口径(逐字要求):**失败原因 + 续跑方式必须写进表**。
7
+
8
+ 三条纪律(本脚本就是它们的唯一执行者):
9
+ ① **文案唯一来源** = `flow_failure.ops_message()`(`结论:<业务类|系统类|我方缺陷类>|<原因>|动作:<可照做>`)。
10
+ 本脚本把**同一串**分别写进 ① 表字段(`guards.errorField`,发票流程 =「发票错误信息」)
11
+ 与 ② 台账(`failed.error` = 规范原因原文;`failed.detail` = 该串)⇒ **两处不可能各写一套**。
12
+ ② **绝不空写**:`--error` 与台账都取不到原因时**响亮拒绝**(exit 2)——"表里空白"正是本次要消灭的形态。
13
+ ③ **绝不把裸异常栈给运营**:代码级异常(`AttributeError` / `object has no attribute` / traceback…)
14
+ 归**我方缺陷类**,表里只写"流程脚本内部错误(明细在日志/台账)"+ 可照做的动作(`ops_reason`)。
15
+
16
+ 写表守卫**复用**该流程**声明**的写手(`guards.writer` —— 白名单 / 记录白名单 / 写前整行备份 /
17
+ 写后读回 / 审计 JSONL / 终态需 --force / 默认 dry-run)—— ⛔ 本脚本**不自己写表**,只做编排与文案;
18
+ 未声明 `guards.writer` 的流程**拒绝执行**(不许猜一个写手去写生产表)。
19
+
20
+ 用法:
21
+ # 失败收口(默认 dry-run;两件真动作:写表 + 记台账)
22
+ python3 scripts/invoice_fail_closeout.py --flow <发票流程> --case <flow>__<record_id> \
23
+ --error "缺 产品海关编码、产品图片" --missing 产品海关编码,产品图片 --clear-ready --yes
24
+ # 成功收口(清空过时错误信息,避免陈旧文案误导)
25
+ python3 scripts/invoice_fail_closeout.py --flow <发票流程> --case <flow>__<record_id> --outcome success --yes
26
+ # 离线/夹具:--error 直接给原因;--fields-from-json 给行字段(缺口自动算)
27
+ 退出码:0 成功;2 用法/前置错误;3 写表失败;4 读回不一致;5 台账写入失败
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import argparse
32
+ import json
33
+ import pathlib
34
+ import subprocess
35
+ import sys
36
+
37
+ HERE = pathlib.Path(__file__).resolve().parent
38
+ sys.path.insert(0, str(HERE))
39
+ sys.path.insert(0, str(HERE / 'lib'))
40
+ import flow_config # noqa: E402 唯一配置读取点
41
+ import flow_failure as F # noqa: E402 分类 + **文案唯一来源**
42
+ from argent_datadir import resolve_data_dir # noqa: E402 数据目录唯一判据(勿在此复制链)
43
+
44
+ LEDGER = HERE / 'flow_ledger.py'
45
+ AUDIT = pathlib.Path(resolve_data_dir()['dir']) / 'invoice-closeout-audit.jsonl'
46
+ EVENTS = ('failed', 'blocked')
47
+
48
+
49
+ def writer_of(flow: str) -> str:
50
+ """该流程声明的**写手**(`guards.writer`,仓库内相对路径)—— 未声明则返回空串(调用方拒绝执行)。
51
+
52
+ 为什么由注册表声明而不是写死在脚本里:① 写手是**能力专属**的(换货代/换表可能换写手);
53
+ ② 引擎脚本不得出现业务词(`vertical-lint` 的 engine-words 判据)——路径写在流程声明里,
54
+ 这里只做「读声明 + 存在性校验」。
55
+ """
56
+ g = (flow_config.load_config(flow, strict=False).get('guards') or {})
57
+ rel = str(g.get('writer') or '')
58
+ if not rel:
59
+ return ''
60
+ p = (HERE.parent / rel) if not rel.startswith('/') else pathlib.Path(rel)
61
+ return str(p) if p.exists() else ''
62
+
63
+
64
+ def error_field(flow: str) -> str:
65
+ """该流程声明的**失败原因列**(`guards.errorField`,退化到 `guards.writeFields` 里的同名项)。
66
+
67
+ 未声明的流程**拒绝执行**:⛔ 不许把发票那一列写到别的流程的表上(FULL 的失败原因在
68
+ `FULL备注`,走 `orchestrator._fail_note`,不归本脚本管)。
69
+ """
70
+ g = (flow_config.load_config(flow, strict=False).get('guards') or {})
71
+ fld = str(g.get('errorField') or '')
72
+ if not fld and '发票错误信息' in [str(x) for x in (g.get('writeFields') or [])]:
73
+ fld = '发票错误信息'
74
+ return fld
75
+
76
+
77
+ def human_gaps(flow: str, fields: dict) -> list:
78
+ """需要**运营**补的缺口字段(复用 `flow_return_to_ops.human_gaps` 的判据,不写第二套)。"""
79
+ try:
80
+ import flow_return_to_ops as R2O # noqa: PLC0415
81
+ return R2O.human_gaps(flow, fields)
82
+ except Exception: # noqa: BLE001
83
+ return []
84
+
85
+
86
+ def read_case_fields(flow: str, record: str) -> dict:
87
+ """读该行字段(**只读**,用于自动算缺口);读不到返回 {}(调用方降级成通用动作,绝不静默)。
88
+
89
+ ⚠️ 必须连 `SystemExit` 一起吞:`flow_return_to_ops.read_row()` 读表失败时是 `raise SystemExit`
90
+ (`BaseException` 子类,`except Exception` **接不住**)⇒ 生产里"身份失效"会变成整条收口**崩掉**,
91
+ 而正确行为是降级成通用动作、**照常把原因写进表**(写不写表由写手自己的守卫决定)。
92
+ 子进程/库的 stderr 也一并吞掉(那是给排障的,不是给运营的)——但降级**会打印可见说明**。
93
+ """
94
+ try:
95
+ import contextlib
96
+ import io
97
+ import flow_return_to_ops as R2O # noqa: PLC0415
98
+ cfg = flow_config.load_config(flow, strict=False)
99
+ src = cfg.get('source') or {}
100
+ buf = io.StringIO()
101
+ with contextlib.redirect_stderr(buf):
102
+ row = R2O.read_row(str(src.get('identity') or ''), str(src.get('base') or ''),
103
+ str(src.get('table') or ''), record)
104
+ return row or {}
105
+ except (Exception, SystemExit): # noqa: BLE001
106
+ return {}
107
+
108
+
109
+ def canonical_from_ledger(flow: str, case_id: str, ledger: pathlib.Path) -> tuple[str, str]:
110
+ """台账里的**规范失败原因**(`flow_failure.reasons`)→ (原因, 来源说明)。取不到返回 ('','')。"""
111
+ try:
112
+ r = F.reasons(ledger, case_id, flow)
113
+ except Exception: # noqa: BLE001
114
+ return '', ''
115
+ if r.get('ok') and str(r.get('reason') or '').strip():
116
+ return str(r['reason']).strip(), f"台账 failed({r.get('source')})"
117
+ return '', ''
118
+
119
+
120
+ def writeback(args, record: str, *, error_message: str | None, clear_error: bool,
121
+ clear_ready: bool, status: str | None) -> int:
122
+ """调共用写手(真跑与否由 `--yes` 决定);返回退出码。"""
123
+ wb = str(args.writeback or args._writer)
124
+ if args.writeback:
125
+ # 只在**夹具/排障**时走这里;真跑永远用**流程声明**的写手(守卫都在它里面)
126
+ print(f" ⚠️ 写手路径被覆盖(仅夹具/排障用):{wb}")
127
+ cmd = ['node', wb, '--flow', args.flow, '--record-id', record,
128
+ '--allow-record', record, '--text-only']
129
+ if error_message is not None:
130
+ cmd += ['--error-message', error_message]
131
+ if clear_error:
132
+ cmd += ['--clear-error']
133
+ if clear_ready:
134
+ cmd += ['--clear-ready']
135
+ if status:
136
+ cmd += ['--status', status]
137
+ cmd += ['--yes'] if args.yes else ['--dry-run']
138
+ p = subprocess.run(cmd, capture_output=True, text=True)
139
+ out = (p.stdout or '') + (('\n' + p.stderr) if p.stderr else '')
140
+ print('\n'.join(' ' + ln for ln in out.strip().splitlines()))
141
+ return p.returncode
142
+
143
+
144
+ def ledger_write(case_id: str, event: str, *, error: str, detail: str, by: str,
145
+ ledger: pathlib.Path | None = None) -> int:
146
+ """写台账(**同一串** `detail`)——`error` 放规范原因原文,供分类器/取数口使用。
147
+
148
+ ⚠️ `--ledger` **必须**透传:漏了它会写进**默认数据目录**的台账,而夹具/隔离实例期望的是
149
+ 它自己那份(实测踩到:夹具那次写进了真实 `~/.dsh/argent/flow-ledger.jsonl`)。
150
+ """
151
+ cmd = [sys.executable, str(LEDGER), '--case-id', case_id, '--event', event, '--by', by,
152
+ '--detail', detail]
153
+ if ledger:
154
+ cmd += ['--ledger', str(ledger)]
155
+ if error:
156
+ cmd += ['--error', error]
157
+ return subprocess.run(cmd, capture_output=True, text=True).returncode
158
+
159
+
160
+ def main(argv=None) -> int:
161
+ ap = argparse.ArgumentParser(description='发票流程失败/成功收口:写表「发票错误信息」+ 台账(同源同文)')
162
+ ap.add_argument('--flow', required=True,
163
+ help='发票流程 id(须在业务线声明 `guards.errorField` 与 `guards.writer`)')
164
+ ap.add_argument('--case', help='台账 case id(<flow>__<record_id>)或 ticket')
165
+ ap.add_argument('--record', help='record_id(与 --case 二选一)')
166
+ ap.add_argument('--outcome', choices=('failed', 'success'), default='failed',
167
+ help='failed=写原因+动作(默认);success=**清空**该列,避免陈旧文案误导')
168
+ ap.add_argument('--error', help='原始失败原因(不给则取台账里的规范原因;两处都取不到 ⇒ 拒绝执行)')
169
+ ap.add_argument('--missing', help='逗号分隔的运营缺口字段(不给则自动算:行可读时用 human_gaps)')
170
+ ap.add_argument('--attempt', type=int, help='第几次失败(影响分类:retryable 超上限 ⇒ 需判断)')
171
+ ap.add_argument('--event', choices=EVENTS, default='failed',
172
+ help='台账事件(默认 failed;角色裁定后改用 blocked)')
173
+ ap.add_argument('--status', help='顺带推进「发票状态」(如 失败);不给则**不动**状态')
174
+ ap.add_argument('--clear-ready', action='store_true',
175
+ help='同时取消勾选「发票就绪」(**运营类**缺口的既定口径;单向:只允许 false)')
176
+ ap.add_argument('--fields-from-json', help='离线:该行字段 JSON(夹具;跳过读表)')
177
+ ap.add_argument('--ledger', help='台账路径(夹具用)')
178
+ ap.add_argument('--writeback', help='写手路径覆盖(**仅夹具/排障**;默认 = 流程声明的 `guards.writer`)')
179
+ ap.add_argument('--by', default='invoice-closeout', help='台账记录者')
180
+ ap.add_argument('--yes', action='store_true', help='真跑(默认 dry-run:只打印计划)')
181
+ ap.add_argument('--json', action='store_true')
182
+ a = ap.parse_args(argv)
183
+
184
+ a._writer = writer_of(a.flow) # 流程声明的写手(--writeback 可覆盖,仅夹具/排障)
185
+ if not a._writer and not a.writeback:
186
+ print(f"❌ 流程 {a.flow} 未声明 `guards.writer`(写手路径)⇒ 拒绝执行:\n"
187
+ f" 本命令**不自己写表**,只编排「流程声明的写手」(守卫都在写手里);"
188
+ f"⛔ 缺声明时猜一个写手写生产表是不可接受的。", file=sys.stderr)
189
+ return 2
190
+ fld = error_field(a.flow)
191
+ if not fld:
192
+ print(f"❌ 流程 {a.flow} 未声明 `guards.errorField`(失败原因列)⇒ 拒绝执行:\n"
193
+ f" 本命令只服务**发票流程**(运营看的列 =「发票错误信息」);FULL 的失败原因在 `FULL备注`,"
194
+ f"由 orchestrator._fail_note 负责。", file=sys.stderr)
195
+ return 2
196
+ record = a.record or (a.case.split('__', 1)[1] if a.case and '__' in a.case else '')
197
+ if not record:
198
+ print('❌ 需要 --case <flow>__<record_id> 或 --record <record_id>(记录白名单,一次一条)', file=sys.stderr)
199
+ return 2
200
+ case_id = a.case if (a.case and '__' in a.case) else f'{a.flow}__{record}'
201
+ ledger = pathlib.Path(a.ledger) if a.ledger else pathlib.Path(resolve_data_dir()['dir']) / 'flow-ledger.jsonl'
202
+
203
+ # ── 成功收口:清空该列(避免陈旧错误误导)────────────────────────────────
204
+ if a.outcome == 'success':
205
+ rc = writeback(a, record, error_message=None, clear_error=True,
206
+ clear_ready=a.clear_ready, status=a.status)
207
+ print(f" {'✅' if rc == 0 else '❌'} 成功收口:清空「{fld}」"
208
+ f"{'(+ 取消「发票就绪」)' if a.clear_ready else ''}"
209
+ f"{'(dry-run,未落库)' if not a.yes else ''}")
210
+ if a.yes and rc == 0:
211
+ ledger_write(case_id, 'done', error='', detail=f'成功收口:已清空「{fld}」', by=a.by,
212
+ ledger=ledger)
213
+ return 0 if rc == 0 else 3
214
+
215
+ # ── 失败收口:原因必须**取得到**(取不到就响亮拒绝,⛔ 绝不写空)──────────
216
+ raw, src = str(a.error or '').strip(), '命令行 --error'
217
+ if not raw:
218
+ raw, src = canonical_from_ledger(a.flow, case_id, ledger)
219
+ if not raw:
220
+ print(f"❌ 取不到失败原因(--error 未给,台账 {ledger} 里也没有 {case_id} 的 failed 事件)⇒ **拒绝执行**:\n"
221
+ f" 「{fld}」写一条空/占位说明比不写更糟(运营会以为脚本坏了)——请把真实原因用 --error 传入。",
222
+ file=sys.stderr)
223
+ return 2
224
+
225
+ # 缺口:显式 --missing > 行字段(离线 JSON 或只读读表)> 通用动作(可见降级)
226
+ miss = [x.strip() for x in str(a.missing or '').split(',') if x.strip()]
227
+ miss_src = '命令行 --missing' if miss else ''
228
+ if not miss:
229
+ row = {}
230
+ if a.fields_from_json:
231
+ row = json.loads(pathlib.Path(a.fields_from_json).read_text(encoding='utf-8'))
232
+ miss_src = '--fields-from-json 自动算'
233
+ else:
234
+ row = read_case_fields(a.flow, record)
235
+ miss_src = '读表自动算' if row else ''
236
+ if not row:
237
+ # **可见**降级(不是静默):动作退成通用版,原因照写 —— 缺字段名不影响"必写原因"
238
+ print(' ℹ️ 读不到该行字段(身份/网络/记录不存在)⇒ 缺口名不写进动作,'
239
+ '改用通用动作;原因与结论照写(可见降级,非静默)')
240
+ if row:
241
+ miss = human_gaps(a.flow, row)
242
+
243
+ msg = F.ops_message(a.flow, raw, missing=miss,
244
+ kind=F.classify(raw, a.attempt).get('kind'))
245
+ rc_hint = F.release_config(a.flow)
246
+ plan = {'flow': a.flow, 'caseId': case_id, 'record': record, 'field': fld,
247
+ 'outcome': 'failed', 'event': a.event, 'reasonFrom': src,
248
+ 'missing': miss, 'missingFrom': miss_src or '(未给 ⇒ 用通用动作)',
249
+ 'category': F.ops_category(raw), 'message': msg, 'len': len(msg),
250
+ 'clearReady': bool(a.clear_ready), 'status': a.status,
251
+ 'release': {'enabled': rc_hint['enabled'], 'field': rc_hint['field'],
252
+ 'maxPerCase': rc_hint['maxPerCase'], 'cooldownMin': rc_hint['cooldownMin']},
253
+ 'tableWrite': bool(a.yes), 'ledgerWrite': bool(a.yes), 'dryRun': not a.yes}
254
+
255
+ print('── 现场 ──')
256
+ print(f" 流程/列 : {a.flow}|{fld}")
257
+ print(f" 记录 : {record}(case {case_id})")
258
+ print(f" 原因来源 : {src}")
259
+ print(f" 结论类别 : {plan['category']}")
260
+ print(f" 运营缺口 : {'、'.join(miss) if miss else '(未取到 / 无需)——来源:' + (miss_src or '未给')}")
261
+ print('── 计划(表 + 台账**同一串**)──')
262
+ print(f" ① 表 {fld} → {msg}")
263
+ print(f" ② 台账 {a.event}: error={F.canonical_reason(raw)[:120]!r}|detail={msg[:80]!r}…")
264
+ if a.clear_ready:
265
+ print(" ③ 取消勾选「发票就绪」(单向:只允许 false;⚙️ 仅在**运营类**缺口时该口径成立)")
266
+ if a.event == 'failed' and rc_hint['enabled'] and a.clear_ready:
267
+ print(f" ↳ 运营补齐后重勾「{rc_hint['field']}」⇒ 下一轮自动重跑"
268
+ f"(上限 {rc_hint['maxPerCase']} 次 / 冷却 {rc_hint['cooldownMin']} 分;Rule R)")
269
+ if len(msg) > F.OPS_REASON_LIMIT:
270
+ print(f" ⚠️ 文案 {len(msg)} 字 > 台账 detail 上限 {F.OPS_REASON_LIMIT} ⇒ 会被截断(同源同文不成立)",
271
+ file=sys.stderr)
272
+ return 2
273
+ if a.json:
274
+ print(json.dumps(plan, ensure_ascii=False, indent=2))
275
+ if not a.yes:
276
+ print('\n[dry-run] 未写表、未记台账。加 --yes 才落库。')
277
+ return 0
278
+
279
+ rc = writeback(a, record, error_message=msg, clear_error=False,
280
+ clear_ready=a.clear_ready, status=a.status)
281
+ if rc != 0:
282
+ print(f"❌ 写表失败(写手退出码 {rc})——台账**不写**(宁可留证据缺口,也不留「表已写」的假象)",
283
+ file=sys.stderr)
284
+ return 3
285
+ if ledger_write(case_id, a.event, error=F.canonical_reason(raw), detail=msg, by=a.by,
286
+ ledger=ledger) != 0:
287
+ print('❌ 台账写入失败(表已写成功;请重跑本命令,写表是幂等的)', file=sys.stderr)
288
+ return 5
289
+ AUDIT.parent.mkdir(parents=True, exist_ok=True)
290
+ with AUDIT.open('a', encoding='utf-8') as fh:
291
+ fh.write(json.dumps({**plan, 'dryRun': False, 'ok': True}, ensure_ascii=False) + '\n')
292
+ print(f" ✅ 收口完成:表「{fld}」与台账 {a.event}.detail **同一串**|审计 {AUDIT.name}")
293
+ return 0
294
+
295
+
296
+ if __name__ == '__main__':
297
+ raise SystemExit(main())