@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,675 @@
1
+ #!/usr/bin/env python3
2
+ """⚠️ **已作废(2026-09-17)—— 不要据本文件的判据改流程**。
3
+
4
+ 用户口径:文鳐与鑫中航**同属发票流程、同一张 lip 表**,开启前提是
5
+ `货代公司=<自家> ∧ FULL状态=已完成 ∧ 发票就绪=true`(本文件原先用的
6
+ 「`状态=已完成 ∧ 发票状态=待生成`」+ 旧表 `TJrmbxuIyauNqrsJL9FclLfbnhc/tbl4vDxpCa71YLpe` 口径已废弃)。
7
+ 新链路:`flow_triage.py --flow xzh-invoice`(候选)→ 角色按 `scripts/flow-prompts/xzh-invoice.md` 执行
8
+ → 缺口回填走 `scripts/flow_return_to_ops.py`。保留本文件仅供历史追溯与旧部署回滚。
9
+ """
10
+ """Argent 发票填写/上传自动化 — invoice_runner
11
+ M1 阶段 A:根据飞书 FULL任务表生成鑫中航入仓预报发票(openpyxl 填模板)
12
+ M1 阶段 B:本机 Chrome 上传鑫中航 + 下载大货标签(已落地:scripts/invoice_stageb.py;见 docs/ml-invoice-automation.md)
13
+
14
+ 单表方案(2026-08-20):发票字段挂在 FULL任务 同一行(发票状态/发票文件/大货标签/
15
+ 发票错误信息/国内收货仓库),运营只填 FULL任务 一张表;FULL 状态=已完成 且
16
+ 发票状态=待生成 的行自动进入发票流程。
17
+
18
+ 用法:
19
+ invoice_runner.py --test 用内置测试数据生成一张发票(验证模板映射)
20
+ invoice_runner.py --generate 处理 FULL任务表待开发票的行(阶段 A)
21
+ invoice_runner.py --record-id X 指定记录生成
22
+ """
23
+ import json
24
+ import os
25
+ import re
26
+ import subprocess
27
+ import sys
28
+ import shutil
29
+ from pathlib import Path
30
+
31
+ # KB-090:launchd/cron 最小 PATH 兜底(同 poll-fulfillment.sh L87-90 与 daily-report.mjs
32
+ # resolveLarkCli/LARK_ENV 机制)——本脚本由 poll-invoice.sh(LaunchAgent)调用,launchd
33
+ # 环境 PATH 无 ~/.local/bin(npm 全局 lark-cli 实测位置)→ 裸 spawn lark-cli 报 ENOENT
34
+ # 静默失败(2026-09-03 daily-report cron 同源事故,2026-09-02 poll launchd 事故)。
35
+ # 1) 三链绝对路径解析 lark-cli(PATH → ~/.local/bin → ~/node/bin → /opt/homebrew/bin → /usr/local/bin)
36
+ # 2) 注入 ~/.local/bin + ~/node/bin 到 os.environ["PATH"](lark-cli shebang 为 /usr/bin/env
37
+ # node,最小 PATH 下即使绝对路径也会因 env node 找不到而失败;子进程默认继承 os.environ)
38
+ _HOME = str(Path.home())
39
+
40
+
41
+ # ── 产品英文名称:只留**直译名**(2026-10-13 用户现场纠正)──────────────────────
42
+ # 现象:发票「产品英文名称」被写成 `150mm Digital Caliper (Zinc Alloy)` —— 括号里是材质/颜色/规格。
43
+ # 用户口径:**只直译产品名**;材质另有 `材质中文`/`材质英文` 两列,不该在品名里重复。
44
+ # 成因:① LLM 提示词曾要求"含关键规格/颜色/材质";② 该值会被固化进字典并被后续复用。
45
+ # ⇒ 在「读字典 / 收 LLM 结果 / 写 xlsx」三处都过一遍(幂等;⛔ 只动**尾部**括号)。
46
+ _PAREN_TAIL_RE = re.compile(r"\s*[((][^(())]*[))]\s*$")
47
+
48
+
49
+ def strip_name_parens(name):
50
+ """去掉**尾部**括号注释(可连续多段):`X (A) (B)` → `X`;`SSD (Black)` → `SSD`。
51
+ ⛔ 只动尾部(名称中间的括号保留);清洗后为空 ⇒ 回退原值(宁可不改,也不把品名清空)。"""
52
+ s = str(name or "").strip()
53
+ if not s:
54
+ return s
55
+ out = s
56
+ while True:
57
+ nxt = _PAREN_TAIL_RE.sub("", out).strip()
58
+ if not nxt or nxt == out:
59
+ break
60
+ out = nxt
61
+ return out or s
62
+
63
+
64
+ def _resolve_lark_cli() -> str:
65
+ for cand in (shutil.which("lark-cli") or "",
66
+ f"{_HOME}/.local/bin/lark-cli",
67
+ f"{_HOME}/node/bin/lark-cli",
68
+ "/opt/homebrew/bin/lark-cli",
69
+ "/usr/local/bin/lark-cli"):
70
+ if cand and os.path.isfile(cand) and os.access(cand, os.X_OK):
71
+ return cand
72
+ return "lark-cli" # 最后兜底:依赖 PATH(SSH/交互环境通常可达)
73
+
74
+
75
+ LARK_CLI = _resolve_lark_cli()
76
+ # cron 最小 PATH(/usr/bin:/bin…)下 lark-cli shebang「/usr/bin/env node」找不到 node → stdout 空 →
77
+ # json.loads('') JSONDecodeError(2026-09-06 本机 stageB 每 5 分钟失败根因;huangwei 机 node 在 ~/node/bin
78
+ # 命中旧列表,本机 node 在 /opt/homebrew/bin(brew symlink)漏了)。brew/usr/local 一并注入。
79
+ _EXTRA_PATH = [p for p in (f"{_HOME}/.local/bin", f"{_HOME}/node/bin", "/opt/homebrew/bin", "/usr/local/bin")
80
+ if p not in (os.environ.get("PATH") or "").split(":")]
81
+ if _EXTRA_PATH:
82
+ os.environ["PATH"] = ":".join(_EXTRA_PATH + [os.environ.get("PATH", "")])
83
+
84
+ HOME = Path.home()
85
+ # 数据目录:**唯一判据**在 `scripts/lib/argent_datadir.py`(三级链,与 JS 内核同语义)
86
+ sys.path.insert(0, str(Path(__file__).resolve().parent / 'lib'))
87
+ from argent_datadir import resolve_data_dir # noqa: E402 —— 勿在此复制判据
88
+ DATA = Path(resolve_data_dir()['dir'])
89
+ MAPPING_FILE = DATA / "invoice-mapping.json"
90
+
91
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
92
+ import ml_store_ids # noqa: E402 (店铺→卖家ID 唯一真源)
93
+ OUTPUT_DIR = DATA / "invoice-output"
94
+ CONFIG_FILE = DATA / "config.json"
95
+
96
+ # ── 模板「入仓预报」sheet 填写位置(实测解析 2026-08-19)──
97
+ # R2 国内收货仓库*(必填,选项1 下拉:深圳仓/义乌仓/广州仓)
98
+ # R3 客户单号 | R4 服务类型 | R5 报关方式 | R6 带电 | R7 带磁 | R8 备注
99
+ # R11-18 收件人(姓名/公司/地址一/城市/省/邮编/国家/电话) | R19 申报币种
100
+ # R20 箱数 | R21 表头 | R22+ 数据行(18 列)
101
+ DATA_START_ROW = 22
102
+ SHEET_NAME = "入仓预报"
103
+ ADDR_SHEET = "选项2"
104
+ HEADER_COLS = 18
105
+
106
+
107
+ def load_mapping():
108
+ m = json.load(open(MAPPING_FILE))
109
+ m["output"] = {k: Path(os.path.expanduser(v)) for k, v in m.get("output", {}).items()}
110
+ return m
111
+
112
+
113
+ def load_creds():
114
+ try:
115
+ c = json.load(open(CONFIG_FILE))
116
+ return c.get("xhInvoiceUser", ""), c.get("xhInvoicePass", "")
117
+ except Exception:
118
+ return "", ""
119
+
120
+
121
+ def norm(v):
122
+ """飞书字段值规范化:select 单选返回数组,取首元素。"""
123
+ if isinstance(v, list):
124
+ return v[0] if v else ""
125
+ return v
126
+
127
+
128
+ def template_addr(mapping, wb, addr_code):
129
+ """从「选项2」地址库读取收件人信息(按地址库编码)。"""
130
+ ws = wb[ADDR_SHEET]
131
+ for r in range(2, ws.max_row + 1):
132
+ if str(ws.cell(row=r, column=2).value or "").strip() == addr_code:
133
+ return {
134
+ "公司": ws.cell(row=r, column=2).value,
135
+ "城市": ws.cell(row=r, column=3).value,
136
+ "地区": ws.cell(row=r, column=4).value,
137
+ "邮编": ws.cell(row=r, column=5).value,
138
+ "地址": ws.cell(row=r, column=6).value,
139
+ "电话": ws.cell(row=r, column=7).value,
140
+ "国家": ws.cell(row=r, column=8).value,
141
+ }
142
+ return {}
143
+
144
+
145
+ def query_sku_images(sku, mapping):
146
+ """按 SKU 名称查 SKU 主数据表,返回 (附件列表, record_id)。
147
+
148
+ lookup 附件字段带出不可靠(FULL任务 表产品图 lookup 可能未配置/未带出),
149
+ 统一直查 SKU 主数据表。lark-cli --format json 是列式数组(无字段名),
150
+ markdown 表格有表头 + _record_id 列,专表解析(SKU 主数据表值简单)。
151
+ """
152
+ src = mapping.get("skuSource", {})
153
+ base, table = src.get("base", ""), src.get("table", "")
154
+ if not base or not table or not sku:
155
+ return [], ""
156
+ try:
157
+ r = subprocess.run(
158
+ [LARK_CLI, "--profile", "ml-fulfillment", "base", "+record-list", "--base-token", base,
159
+ "--table-id", table, "--as", "bot", "--format", "markdown"],
160
+ capture_output=True, text=True, timeout=60)
161
+ lines = [l for l in (r.stdout or "").splitlines() if l.strip().startswith("|")]
162
+ if len(lines) < 2:
163
+ return [], ""
164
+ headers = [h.strip() for h in lines[0].strip("|").split("|")]
165
+ for line in lines[2:]: # 表头后一行是分隔行,跳过
166
+ cells = [c.strip() for c in line.strip("|").split("|")]
167
+ if len(cells) != len(headers):
168
+ continue
169
+ row = dict(zip(headers, cells))
170
+ if str(row.get("SKU", "")).strip() == sku:
171
+ imgs_raw = row.get("产品图", "")
172
+ try:
173
+ imgs = json.loads(imgs_raw) if imgs_raw else []
174
+ except Exception:
175
+ imgs = []
176
+ return imgs, str(row.get("_record_id", ""))
177
+ except Exception:
178
+ pass
179
+ return [], ""
180
+
181
+
182
+ def download_attachment(file_token, record_id, mapping):
183
+ """下载 SKU 主数据表附件到临时目录,返回本地路径或 None(幂等缓存)。"""
184
+ src = mapping.get("skuSource", {})
185
+ base, table = src.get("base", ""), src.get("table", "")
186
+ if not base or not table or not file_token or not record_id:
187
+ return None
188
+ tmp = DATA / "invoice-tmp"
189
+ tmp.mkdir(parents=True, exist_ok=True)
190
+ out = tmp / f"{file_token}.img"
191
+ if out.exists():
192
+ return out
193
+ try:
194
+ subprocess.run(
195
+ [LARK_CLI, "--profile", "ml-fulfillment", "base", "+record-download-attachment", "--base-token", base,
196
+ "--table-id", table, "--record-id", record_id, "--file-token", file_token,
197
+ "--output", f"./{file_token}.img", "--as", "bot"],
198
+ capture_output=True, text=True, timeout=120, cwd=str(tmp))
199
+ except Exception:
200
+ return None
201
+ return out if out.exists() else None
202
+
203
+
204
+ def clean_hs(v):
205
+ """清洗海关编码:飞书 lookup 可能带出 markdown 链接([3926909090](url)),提取纯数字。"""
206
+ s = str(v or "").strip()
207
+ if not s:
208
+ return ""
209
+ m = re.search(r"\[(\d{6,12})\]|(\d{6,12})", s)
210
+ return m.group(1) or m.group(2) if m else s
211
+
212
+
213
+ def fix_drawing_xfrm(xlsx_path, ws, img_w, img_h, off_x, off_y, row_idx):
214
+ """修复 drawing1.xml:恢复官方模板的 drawing 结构(xdr 单锚点 + 相对路径 rels)。
215
+
216
+ 两个问题(2026-08-29 实测鑫中航后端导入):
217
+ 1) 官方模板 drawing1.xml 自带 2 个示例图锚点(R22/R23 各一),openpyxl 保存时
218
+ 可能原样保留模板 drawing(锚点成对残留),同一张产品图显示两次 → 取模板
219
+ drawing1.xml 并删除第二个锚点,只留 R22 的官方锚点(坐标/结构与后端认可一致)。
220
+ 2) openpyxl 3.1.5 重写的 drawing/rels 使用绝对路径(/xl/media/image1.png),
221
+ 鑫中航后端按相对路径解析 → 导入报「图片对象初始化时错误:[rId1] 图片位置错误」;
222
+ 因此 drawing rels 用模板的相对路径(../media/image1.png),sheet rels 的
223
+ drawing Target 相对化(../drawings/drawing1.xml)。
224
+ """
225
+ import re
226
+ import zipfile
227
+ import shutil
228
+ mapping = load_mapping()
229
+ tpl_path = mapping["templates"].get("鑫中航", "")
230
+ tpl_dr = tpl_rels = None
231
+ if tpl_path and os.path.exists(tpl_path):
232
+ try:
233
+ with zipfile.ZipFile(tpl_path) as tz:
234
+ tpl_dr = tz.read("xl/drawings/drawing1.xml").decode()
235
+ tpl_rels = tz.read("xl/drawings/_rels/drawing1.xml.rels")
236
+ except KeyError:
237
+ tpl_dr = tpl_rels = None
238
+ if tpl_dr:
239
+ # 只保留第一个 twoCellAnchor(R22 产品图锚点),删除模板示例残留锚点
240
+ anchors = re.findall(r"<xdr:twoCellAnchor\b.*?</xdr:twoCellAnchor>", tpl_dr, re.S)
241
+ if len(anchors) > 1:
242
+ tpl_dr = tpl_dr.replace("".join(anchors[1:]), "")
243
+ tmp = xlsx_path.with_name(xlsx_path.name + ".tmp")
244
+ with zipfile.ZipFile(xlsx_path) as zin, zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) as zout:
245
+ for item in zin.namelist():
246
+ data = zin.read(item)
247
+ if item == "xl/drawings/drawing1.xml" and tpl_dr:
248
+ data = tpl_dr.encode()
249
+ elif item == "xl/drawings/_rels/drawing1.xml.rels" and tpl_rels:
250
+ data = tpl_rels
251
+ elif item.endswith("worksheets/_rels/sheet1.xml.rels"):
252
+ xml = data.decode()
253
+ xml = xml.replace('Target="/xl/drawings/drawing1.xml"', 'Target="../drawings/drawing1.xml"')
254
+ data = xml.encode()
255
+ zout.writestr(item, data)
256
+ shutil.move(tmp, xlsx_path)
257
+
258
+
259
+ def generate_invoice(record, mapping, wb, out_path):
260
+ """用一条记录数据填模板 → 另存发票文件。
261
+ record 字段:货件号/店铺名称/品名/数量/箱数 + SKU 主数据(重量/尺寸/材质/编码/图)
262
+ """
263
+ import openpyxl
264
+ ws = wb[SHEET_NAME]
265
+ ws._images.clear() # 清模板自带示例图(官方模板含 2 张示例产品图,避免后端多图解析异常)
266
+ d = mapping["defaults"]
267
+ # 2026-09-19:PO(ML 卖家 ID)改走唯一真源(scripts/ml-store-ids.json)。
268
+ # 本文件已作废,但它是"三份副本"之一(键=短名 '1店-子账号',lip 完整店名匹配不上);
269
+ # 留着副本只会让下一个人再踩一次"匹配不上就静默顶替"。
270
+ store_id = ml_store_ids.resolve(record.get("店铺名称"))
271
+
272
+ # 收件人(地址库)
273
+ addr = template_addr(mapping, wb, d.get("收件人地址库", "MXRC03"))
274
+
275
+ # 基础区:A 列为标题(模板已有),值填 B 列(column=2)
276
+ ws.cell(row=2, column=2).value = norm(record.get("国内收货仓库")) or d.get("国内收货仓库", "深圳仓") # 国内收货仓库*(必填)
277
+ ws.cell(row=3, column=2).value = record.get("货件号", "") # 客户单号
278
+ ws.cell(row=4, column=2).value = d.get("服务类型", "美转墨S1")
279
+ ws.cell(row=5, column=2).value = d.get("报关方式", "买单报关")
280
+ ws.cell(row=6, column=2).value = d.get("带电", "否")
281
+ ws.cell(row=7, column=2).value = d.get("带磁", "否")
282
+ remark = norm(record.get("备注")) or ""
283
+ if remark:
284
+ ws.cell(row=8, column=2).value = remark # 备注(可选,空则不填)
285
+ ws.cell(row=11, column=2).value = d.get("收件人姓名") or addr.get("公司", "") or "MXRC03"
286
+ ws.cell(row=12, column=2).value = addr.get("公司", "")
287
+ ws.cell(row=13, column=2).value = addr.get("地址", "")
288
+ ws.cell(row=14, column=2).value = addr.get("城市", "")
289
+ ws.cell(row=15, column=2).value = addr.get("地区", "")
290
+ ws.cell(row=16, column=2).value = addr.get("邮编", "")
291
+ ws.cell(row=17, column=2).value = addr.get("国家", "")
292
+ ws.cell(row=18, column=2).value = addr.get("电话", "")
293
+ ws.cell(row=19, column=2).value = d.get("申报币种", "USD")
294
+ # R20 官方模板为「到达时间*」:飞书 FULL任务 表「到达时间」字段(运营填写),无值默认"本周"
295
+ ws.cell(row=20, column=2).value = norm(record.get("到达时间")) or "本周"
296
+
297
+ # 数据行(R22+,每行=每箱一个商品;箱数重复即多行同箱号)
298
+ sku = record.get("sku_data", {})
299
+ boxes = int(record.get("箱数") or 1)
300
+ qty_per_box = 0
301
+ try:
302
+ qty_per_box = int(record.get("数量") or 0) // max(boxes, 1)
303
+ except Exception:
304
+ qty_per_box = 0
305
+
306
+ row = DATA_START_ROW
307
+ # 数据行统一行高(模板数据行高 93pt,避免图片溢出/错位)
308
+ data_row_height = 93
309
+ # R 列(18)像素换算:列宽 25 字符 ≈ 180px;行高 93pt ≈ 124px
310
+ r_col_px = int((ws.column_dimensions.get("R").width if ws.column_dimensions.get("R") else 8.43) * 7 + 5)
311
+ r_row_px = int(data_row_height * 4 / 3)
312
+ # 一行 = 一个商品(SKU),不按箱数拆行(箱数体现在"货箱编号 1-N / 总箱数"列;
313
+ # 模板说明:一箱货有多个商品,箱号重复即可——一条 FULL 记录一个 SKU → 一行)
314
+ ws.row_dimensions[row].height = data_row_height
315
+ ws.cell(row=row, column=18).value = None # 清模板 R 列提示文字(如 R22"提取飞书中的图片...")
316
+ ws.cell(row=row, column=1).value = f"1-{boxes}" # 货箱编号:固定前缀"1-" + 箱数(如 1-12)
317
+ ws.cell(row=row, column=2).value = int(store_id) if str(store_id).isdigit() else store_id # PO Number(数字)
318
+ ws.cell(row=row, column=3).value = boxes # 总箱数
319
+ ws.cell(row=row, column=4).value = strip_name_parens(sku.get("品名英文"))
320
+ ws.cell(row=row, column=5).value = norm(record.get("品名")) or ""
321
+ hs = clean_hs(sku.get("海关编码"))
322
+ ws.cell(row=row, column=6).value = int(hs) if str(hs).isdigit() else hs # 目的国海关编码(数字)
323
+ ws.cell(row=row, column=7).value = sku.get("材质英文") or ""
324
+ ws.cell(row=row, column=8).value = sku.get("材质中文") or ""
325
+ ws.cell(row=row, column=9).value = d.get("用途", "家用")
326
+ ws.cell(row=row, column=10).value = qty_per_box
327
+ try:
328
+ ws.cell(row=row, column=11).value = float(d.get("申报价值USD", "10") or 0) # 单个申报价值(数字)
329
+ except Exception:
330
+ ws.cell(row=row, column=11).value = d.get("申报价值USD", "10")
331
+
332
+ def num(v):
333
+ try:
334
+ return float(v)
335
+ except Exception:
336
+ return v
337
+
338
+ ws.cell(row=row, column=12).value = num(sku.get("重量(kg/箱)", ""))
339
+ ws.cell(row=row, column=13).value = num(sku.get("长(cm)", ""))
340
+ ws.cell(row=row, column=14).value = num(sku.get("宽(cm)", ""))
341
+ ws.cell(row=row, column=15).value = num(sku.get("高(cm)", ""))
342
+ ws.cell(row=row, column=16).value = d.get("brand", "无")
343
+ ws.cell(row=row, column=17).value = d.get("model", "无")
344
+ # 列 18 产品图片:SKU 主数据附件 → 下载 → OneCellAnchor 精确锚定 R 列该行
345
+ # (固定 from 单元格 + ext 像素,不依赖 TwoCellAnchor 的 to 扩散计算)
346
+ imgs = sku.get("产品图") or []
347
+ if imgs and sku.get("产品图记录"):
348
+ ft = imgs[0].get("file_token") if isinstance(imgs[0], dict) else str(imgs[0])
349
+ local_img = download_attachment(ft, sku.get("产品图记录"), mapping)
350
+ if local_img:
351
+ from openpyxl.drawing.image import Image as XLImage
352
+ from openpyxl.drawing.spreadsheet_drawing import TwoCellAnchor, AnchorMarker
353
+ # 用原始附件图片(不做 PIL 处理——鑫中航后端对处理过的图片识别异常)
354
+ xl_img = XLImage(str(local_img))
355
+ # 锚点完全复用官方模板示例图1(R22 位置,后端认可的坐标)
356
+ xl_img.anchor = TwoCellAnchor(
357
+ editAs="oneCell",
358
+ _from=AnchorMarker(col=17, colOff=210185, row=row - 1, rowOff=265430),
359
+ to=AnchorMarker(col=17, colOff=1675765, row=row - 1, rowOff=880745),
360
+ )
361
+ ws.add_image(xl_img)
362
+ row += 1
363
+
364
+ # 清理旧数据行残留(模板自带示例行 R23 含 B 列 2555555、示例商品等;
365
+ # 显式清固定范围,不依赖 max_row——模板示例行/历史残留必须全部抹掉)
366
+ for rr in range(row, DATA_START_ROW + 50):
367
+ for cc in range(1, HEADER_COLS + 1):
368
+ ws.cell(row=rr, column=cc).value = None
369
+
370
+ out_path.parent.mkdir(parents=True, exist_ok=True)
371
+ wb.save(str(out_path))
372
+ # 补图片 xfrm(鑫中航后端要求;无图时跳过)。坐标固定用官方示例图1(R22)
373
+ try:
374
+ if imgs:
375
+ fix_drawing_xfrm(out_path, ws, 0, 0, 0, 0, 0)
376
+ except Exception:
377
+ pass
378
+ return out_path
379
+
380
+
381
+ def run_feishu_generate():
382
+ """处理 FULL任务表「已完成 + 发票状态=待生成」的行:生成发票 → 回写附件/状态。
383
+
384
+ 单表方案 v2(2026-08-20):FULL任务 表重建——SKU 为双向关联,品名/编码/材质/重量/
385
+ 尺寸/产品图全部 lookup 自动带出(无 join、无主字段回填),运营只填 数量/箱数/店铺/
386
+ 货代/国内收货仓库/SKU/就绪。"""
387
+ import openpyxl
388
+ import subprocess
389
+
390
+ import json as _json
391
+ mapping = load_mapping()
392
+ template_path = mapping["templates"].get("鑫中航", "")
393
+ if not template_path or not os.path.exists(template_path):
394
+ print(f"✗ 模板不存在: {template_path}")
395
+ return 1
396
+
397
+ base_token = mapping.get("fullSource", {}).get("base", "")
398
+ full_tbl = mapping.get("fullSource", {}).get("table", "")
399
+ if not full_tbl:
400
+ print("✗ 未配置 FULL 源表(invoice-mapping.json fullSource.table)")
401
+ return 1
402
+
403
+ # 待开发票任务:FULL 已完成 + 发票状态=待生成
404
+ out = subprocess.run(
405
+ [LARK_CLI, "--profile", "ml-fulfillment", "base", "+record-list", "--base-token", base_token, "--table-id", full_tbl,
406
+ "--filter-json", '{"logic":"and","conditions":[["状态","==","已完成"],["发票状态","==","待生成"]]}',
407
+ "--as", "bot", "--format", "json", "--limit", "10"],
408
+ capture_output=True, text=True, timeout=30,
409
+ ).stdout
410
+ data = json.loads(out)
411
+ records = data.get("data", {}).get("data", [])
412
+ fields = data.get("data", {}).get("fields", [])
413
+ ids = data.get("data", {}).get("record_id_list", [])
414
+ if not records:
415
+ print("无待开发票的已完成货件(状态=已完成 且 发票状态=待生成)")
416
+ return 0
417
+ print(f"发现 {len(records)} 条已完成货件待开发票")
418
+
419
+ # AI 翻译:英文申报字段(品名英文/材质英文)缺失时自动翻译并回填 SKU 主数据
420
+ translated = {}
421
+ try:
422
+ import sku_translate
423
+ translated = sku_translate.translate_and_backfill(sku_translate.load_api_key())
424
+ except Exception as e:
425
+ print(f"⚠ 自动翻译失败(继续处理): {e}")
426
+
427
+ def mark_fail(rid, reason):
428
+ upd = _json.dumps({"update_records": {rid: {"发票状态": ["失败"], "发票错误信息": reason}}}, ensure_ascii=False)
429
+ subprocess.run(
430
+ [LARK_CLI, "--profile", "ml-fulfillment", "base", "+record-batch-update", "--base-token", base_token,
431
+ "--table-id", full_tbl, "--json", upd, "--as", "bot", "--format", "json"],
432
+ capture_output=True, text=True, timeout=30,
433
+ )
434
+
435
+ for i, row in enumerate(records):
436
+ rec = {k: norm(v) for k, v in zip(fields, row)}
437
+ rid = str(ids[i]) if i < len(ids) else ""
438
+ # SKU 关联方案:lookup 字段已自动带出字典数据,直接组装 sku_data(无 join)
439
+ sku = str(norm(rec.get("SKU名称")) or "").strip()
440
+ # 产品图:统一直查 SKU 主数据表(lookup 附件带出不可靠),拿附件 + record_id
441
+ sku_imgs, sku_rid = query_sku_images(sku, mapping)
442
+ rec["sku_data"] = {
443
+ "品名英文": strip_name_parens(norm(rec.get("品名英文")) or translated.get((sku, "品名英文"), "")),
444
+ "海关编码": norm(rec.get("海关编码")),
445
+ "材质英文": norm(rec.get("材质英文")) or translated.get((sku, "材质英文"), ""),
446
+ "材质中文": norm(rec.get("材质中文")),
447
+ "重量(kg/箱)": norm(rec.get("重量(kg/箱)")),
448
+ "长(cm)": norm(rec.get("长(cm)")),
449
+ "宽(cm)": norm(rec.get("宽(cm)")),
450
+ "高(cm)": norm(rec.get("高(cm)")),
451
+ "产品图": sku_imgs,
452
+ "产品图记录": sku_rid,
453
+ }
454
+ # 防呆校验:lookup 关键字段缺失 → 标记失败(SKU 未选或字典未维护)
455
+ missing = [k for k in ("海关编码", "材质中文", "重量(kg/箱)", "长(cm)", "宽(cm)", "高(cm)")
456
+ if not rec["sku_data"].get(k)]
457
+ if not sku:
458
+ missing.insert(0, "SKU")
459
+ if missing:
460
+ mark_fail(rid, "SKU 主数据缺: " + ", ".join(missing))
461
+ print(f"✗ {rec.get('货件号','')}: SKU 主数据缺失({', '.join(missing)}),已标记失败待补")
462
+ continue
463
+ name = str(norm(rec.get("品名")) or "").replace("/", "-").strip() or "发票"
464
+ out_path = mapping["output"]["invoice"] / f"运单-{rec.get('货件号','')}-{rec.get('箱数','')}-{name}.xlsx"
465
+ try:
466
+ wb = openpyxl.load_workbook(template_path)
467
+ generate_invoice(rec, mapping, wb, out_path)
468
+ # 回写 FULL 行状态 + 附件
469
+ upd = _json.dumps({"update_records": {rid: {"发票状态": ["填写中"], "当前步骤": "发票已生成: " + out_path.name}}}, ensure_ascii=False)
470
+ subprocess.run(
471
+ [LARK_CLI, "--profile", "ml-fulfillment", "base", "+record-batch-update", "--base-token", base_token,
472
+ "--table-id", full_tbl, "--json", upd,
473
+ "--as", "bot", "--format", "json"],
474
+ capture_output=True, text=True, timeout=30,
475
+ )
476
+ up = subprocess.run(
477
+ [LARK_CLI, "--profile", "ml-fulfillment", "base", "+record-upload-attachment", "--base-token", base_token,
478
+ "--table-id", full_tbl, "--record-id", rid, "--field-id", "发票文件",
479
+ "--file", out_path.name, "--as", "bot", "--format", "json"],
480
+ capture_output=True, text=True, timeout=60, cwd=str(out_path.parent),
481
+ )
482
+ print(f"✓ {rec.get('货件号','')}: 发票已生成并回写 {out_path.name}" + ("" if up.returncode == 0 else "(附件上传失败)"))
483
+ except Exception as e:
484
+ print(f"✗ {rec.get('货件号','')}: {e}")
485
+ mark_fail(rid, str(e)[:100])
486
+ return 0
487
+
488
+
489
+ def run_test():
490
+ mapping = load_mapping()
491
+ template_path = mapping["templates"].get("鑫中航", "")
492
+ if not template_path or not os.path.exists(template_path):
493
+ print(f"✗ 模板不存在: {template_path}")
494
+ sys.exit(1)
495
+ import openpyxl
496
+ wb = openpyxl.load_workbook(template_path)
497
+ record = {
498
+ "货件号": "73699999",
499
+ "店铺名称": "3店-主账号",
500
+ "品名": "外卖背包",
501
+ "数量": 240,
502
+ "箱数": 12,
503
+ "国内收货仓库": "深圳仓",
504
+ "备注": "测试生成",
505
+ "sku_data": {
506
+ "品名英文": "Takeout Backpack",
507
+ "海关编码": "4202929100",
508
+ "材质中文": "尼龙",
509
+ "材质英文": "Nylon",
510
+ "重量(kg/箱)": 4.5,
511
+ "长(cm)": 60, "宽(cm)": 40, "高(cm)": 30,
512
+ },
513
+ }
514
+ out = OUTPUT_DIR / "运单-73699999-12-外卖背包.xlsx"
515
+ generate_invoice(record, mapping, wb, out)
516
+ print(f"✓ 测试发票已生成: {out}")
517
+ # 回读验证
518
+ wb2 = openpyxl.load_workbook(out)
519
+ ws2 = wb2[SHEET_NAME]
520
+ print(" 国内收货仓库:", ws2.cell(row=2, column=2).value)
521
+ print(" 客户单号:", ws2.cell(row=3, column=1).value)
522
+ print(" 收件人公司:", ws2.cell(row=12, column=1).value)
523
+ print(" 箱数:", ws2.cell(row=20, column=1).value)
524
+ print(" 首行数据:", [ws2.cell(row=22, column=c).value for c in (2, 4, 5, 10)])
525
+
526
+
527
+ # ── 阶段 B:上传鑫中航 + 下载大货标签 + 飞书状态流转(KB-045)──
528
+ # 状态机:待生成 → 填写中 → 已上传 → 标签已下载 → 已完成;失败 → 失败 + 发票错误信息
529
+ # SOP(用户要求):上传鑫中航之前必须先回写飞书 FULL任务表「发票文件」字段(顺序固化)
530
+ def run_feishu_stageb(record_id=None, skip_upload=False):
531
+ """处理 FULL任务表「发票状态=填写中」的行(发票已生成待上传/下载标签)。
532
+
533
+ 顺序固化(每行):
534
+ 1) 确认发票文件已生成并回写飞书「发票文件」附件(阶段 A 已做,此处补校验/重写)
535
+ 2) upload_invoice() 上传鑫中航 → 成功回写「已上传」+ 状态字段
536
+ 3) download_label() 按订单号下载大货标签 → 回写「大货标签」附件 → 「标签已下载」→「已完成」
537
+ 失败任一步 → 「失败」+「发票错误信息」。
538
+ """
539
+ import openpyxl
540
+ import subprocess
541
+
542
+ import json as _json
543
+ import invoice_stageb as sb
544
+ mapping = load_mapping()
545
+ base_token = mapping.get("fullSource", {}).get("base", "")
546
+ full_tbl = mapping.get("fullSource", {}).get("table", "")
547
+ if not base_token or not full_tbl:
548
+ print("✗ 未配置 FULL 源表(invoice-mapping.json fullSource)")
549
+ return 1
550
+
551
+ def lark_list(filter_json, limit=10):
552
+ r = subprocess.run(
553
+ [LARK_CLI, "--profile", "ml-fulfillment", "base", "+record-list", "--base-token", base_token, "--table-id", full_tbl,
554
+ "--filter-json", filter_json, "--as", "bot", "--format", "json", "--limit", str(limit)],
555
+ capture_output=True, text=True, timeout=30,
556
+ )
557
+ out = (r.stdout or "").strip()
558
+ # 诊断增强:无 JSON 输出时把 rc/stderr 带进错误(此前裸 JSONDecodeError 无法定位
559
+ # cron 环境下 env node 失败这类根因)
560
+ if not out.startswith("{"):
561
+ raise RuntimeError(
562
+ f"lark-cli 查询无 JSON 输出(rc={r.returncode})stderr: {((r.stderr or '').strip() or '(空)')[:300]}"
563
+ )
564
+ data = json.loads(out)
565
+ return (data.get("data", {}).get("data", []),
566
+ data.get("data", {}).get("fields", []),
567
+ data.get("data", {}).get("record_id_list", []))
568
+
569
+ def lark_update(rid, patch):
570
+ upd = _json.dumps({"update_records": {rid: patch}}, ensure_ascii=False)
571
+ subprocess.run(
572
+ [LARK_CLI, "--profile", "ml-fulfillment", "base", "+record-batch-update", "--base-token", base_token,
573
+ "--table-id", full_tbl, "--json", upd, "--as", "bot", "--format", "json"],
574
+ capture_output=True, text=True, timeout=30,
575
+ )
576
+
577
+ def mark_state(rid, state, step=""):
578
+ patch = {"发票状态": [state]}
579
+ if step:
580
+ patch["当前步骤"] = step
581
+ lark_update(rid, patch)
582
+
583
+ def mark_fail(rid, reason):
584
+ lark_update(rid, {"发票状态": ["失败"], "发票错误信息": reason})
585
+ print(f" ✗ 标记失败: {reason}", flush=True)
586
+
587
+ # 待上传/下载标签:发票状态=填写中(阶段 A 已生成回写)
588
+ if record_id:
589
+ rows, fields, ids = lark_list('{"logic":"and","conditions":[["发票状态","==","填写中"]]}')
590
+ target = None
591
+ for i, row in enumerate(rows):
592
+ if str(ids[i]) == record_id:
593
+ target = (row, fields, str(ids[i]))
594
+ break
595
+ if not target:
596
+ print(f"✗ 未找到 record_id={record_id}(发票状态=填写中)")
597
+ return 1
598
+ rows, fields, ids = [target[0]], target[1], [target[2]]
599
+ else:
600
+ rows, fields, ids = lark_list('{"logic":"and","conditions":[["发票状态","==","填写中"]]}')
601
+ if not rows:
602
+ print("无待上传/下载标签的发票(发票状态=填写中)")
603
+ return 0
604
+ print(f"发现 {len(rows)} 条待阶段 B 的发票")
605
+
606
+ for i, row in enumerate(rows):
607
+ rec = {k: norm(v) for k, v in zip(fields, row)}
608
+ rid = str(ids[i]) if i < len(ids) else ""
609
+ order_no = str(norm(rec.get("订单号码")) or "").strip()
610
+ full_no = str(norm(rec.get("货件号")) or "").strip()
611
+ if not order_no:
612
+ mark_fail(rid, "缺订单号码(鑫中航订单号),无法上传/下载标签")
613
+ continue
614
+ # 找阶段 A 生成的发票文件(~/.dsh/argent/invoice-output/运单-货件号-*.xlsx)
615
+ invoice_file = None
616
+ if full_no:
617
+ cands = sorted((OUTPUT_DIR / "").glob(f"运单-{full_no}-*.xlsx")) if OUTPUT_DIR.exists() else []
618
+ if cands:
619
+ invoice_file = str(cands[-1])
620
+ if not invoice_file:
621
+ mark_fail(rid, f"未找到发票文件(invoice-output/运单-{full_no}-*.xlsx)")
622
+ continue
623
+ # SOP:上传前必须先回写飞书发票文件字段(顺序固化——即使阶段 A 已回写,这里显式重写保证)
624
+ print(f"✓ [{full_no}] 发票文件: {invoice_file}", flush=True)
625
+ try:
626
+ up = subprocess.run(
627
+ [LARK_CLI, "--profile", "ml-fulfillment", "base", "+record-upload-attachment", "--base-token", base_token,
628
+ "--table-id", full_tbl, "--record-id", rid, "--field-id", "发票文件",
629
+ "--file", os.path.basename(invoice_file), "--as", "bot", "--format", "json"],
630
+ capture_output=True, text=True, timeout=60, cwd=str(OUTPUT_DIR),
631
+ )
632
+ print(f" 回写飞书发票文件: {'OK' if up.returncode == 0 else '附件上传失败'}(SOP:先回写再上传)", flush=True)
633
+ except Exception as e:
634
+ print(f" ⚠ 回写发票文件异常(继续上传): {e}", flush=True)
635
+ # 上传鑫中航(SOP:已先回写飞书)
636
+ if skip_upload:
637
+ print(f" [--skip-upload] 跳过上传(干跑)", flush=True)
638
+ else:
639
+ ok_up = sb.upload_invoice(invoice_file, order_no)
640
+ if not ok_up:
641
+ mark_fail(rid, f"鑫中航上传失败({invoice_file})")
642
+ continue
643
+ mark_state(rid, "已上传", f"已上传鑫中航: {invoice_file}")
644
+ # 下载大货标签
645
+ save_name = f"运单-{full_no}-{norm(rec.get('箱数'))}-{norm(rec.get('品名'))}.pdf".replace("/", "-") if full_no else None
646
+ ok_dl = sb.download_label(order_no, save_name)
647
+ if not ok_dl:
648
+ mark_fail(rid, "大货标签下载失败(订单页未就绪/单行未命中/PDF 未捕获)")
649
+ continue
650
+ # 回写大货标签附件 + 完成
651
+ try:
652
+ subprocess.run(
653
+ [LARK_CLI, "--profile", "ml-fulfillment", "base", "+record-upload-attachment", "--base-token", base_token,
654
+ "--table-id", full_tbl, "--record-id", rid, "--field-id", "大货标签",
655
+ "--file", os.path.basename(ok_dl), "--as", "bot", "--format", "json"],
656
+ capture_output=True, text=True, timeout=60, cwd=str(sb.LABELS_DIR),
657
+ )
658
+ except Exception as e:
659
+ print(f" ⚠ 回写大货标签异常: {e}", flush=True)
660
+ mark_state(rid, "已完成", f"标签已下载: {os.path.basename(ok_dl)}")
661
+ print(f"✓ [{full_no}] 发票流程完成(上传→标签→状态流转)", flush=True)
662
+ return 0
663
+
664
+
665
+ if __name__ == "__main__":
666
+ if "--test" in sys.argv:
667
+ run_test()
668
+ elif "--stageb" in sys.argv:
669
+ rid = None
670
+ if "--record-id" in sys.argv:
671
+ rid = sys.argv[sys.argv.index("--record-id") + 1]
672
+ skip = "--skip-upload" in sys.argv
673
+ sys.exit(run_feishu_stageb(record_id=rid, skip_upload=skip))
674
+ else:
675
+ sys.exit(run_feishu_generate())