@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,568 @@
1
+ #!/usr/bin/env python3
2
+ """wenyao_fill_template.py — 上海文鳐「发票模版」填报器(P0,2026-09-11)
3
+
4
+ 输入:① 运营版《上海文鳐发票模版.xlsx》 ② 一行飞书数据(JSON) ③ 列映射 scripts/wenyao-template-map.json
5
+ 输出:填好的 xlsx(顶部块 + 数据写**第 8 行**,说明行被覆盖)+ 一份「写了什么」的 JSON 摘要
6
+
7
+ 设计要点(对齐运营说明,逐条可测):
8
+ · 数据行 = 第 8 行(用户 2026-09-11 明确:"说明就是要你填入的行,删掉重填");9 行起清空
9
+ · 箱序 `1-<箱数>`;单箱数量 = 数量÷箱数;货箱总重 = 重量×箱数;方数 = 长×宽×高÷1e6;
10
+ 申报总价 = 7×数量;材质 = `中文,english`;卖家ID 由店铺名(如"墨西哥-1店-子账号")映射
11
+ · 产品图:lip 附件 → 下载到本地 → 以**浮动图片**锚定在 AA 列(openpyxl)
12
+ · **材质英文三级解析**(`resolve_material_en`,本文件是唯一实现):
13
+ ① SKU 字典(<数据目录>/lip-sku-dict.json,键=SKU)→ ② 通用小词典(映射真源 `materialEn`)
14
+ → ③ 会话传入(`--material-en` / 行内 `材质英文`)。三级都没命中 ⇒ **阻断**(缺口文案指路);
15
+ ③ 定音时把译文**写回 SKU 字典**(`confirmed:false` + 来源 "LLM 译 <日期>…",运营可事后复核)。
16
+ · 只读输入、只写本地文件;**不碰飞书表、不开门户**(P0 边界)
17
+
18
+ 用法:
19
+ python3 scripts/wenyao_fill_template.py --template <模版.xlsx> --row <行.json> \
20
+ --out <输出.xlsx> [--english-name "..."] [--material-en "<材质英文>"] \
21
+ [--image <图片路径>] [--dry-run] [--today YYYY-MM-DD]
22
+ --dry-run 只打印将要写入的单元格(JSON),不生成文件(供单测做金标断言);**任何模式都不写表/不联网**。
23
+ 「阻断性缺口」(材质英文三级未解析 / 同名不同值冲突)⇒ 拒绝写文件并 exit 1;
24
+ 其余缺口(如图片缺失)照旧只报告,由编排器的 P3 统一拦。
25
+ """
26
+ import argparse
27
+ import json
28
+ import os
29
+ import re
30
+ import sys
31
+ from datetime import datetime
32
+ from pathlib import Path
33
+
34
+ HERE = Path(__file__).resolve().parent
35
+ MAP_FILE = HERE / "wenyao-template-map.json"
36
+
37
+ # 唯一真源:ML 店铺 → 卖家 user_id(2026-09-19 起;见 scripts/ml_store_ids.py 顶部事故说明)
38
+ sys.path.insert(0, str(HERE))
39
+ import ml_store_ids # noqa: E402
40
+
41
+
42
+ def load_map():
43
+ return json.loads(MAP_FILE.read_text(encoding="utf-8"))
44
+
45
+
46
+ # ═══════════════════════════════════════════════════════════════════════════
47
+ # 材质英文:三级解析(**唯一实现**,勿在别处复制判据)
48
+ # ───────────────────────────────────────────────────────────────────────────
49
+ # 合同:`skills/ml-invoice/references/template-fill-contract.md`(真源 = 本目录的模板映射真源)
50
+ # —— S 列 = `{材质},{材质英文}`。2026-10-12 事故固化:
51
+ # 该列的英文此前只查 8 条通用小词典(不含『铁』),而**已确认的 per-SKU 字典根本没被查**
52
+ # ⇒ 答案现成却被拦下(两条票同日 blocked,闸门 exit 1)。现在三级:
53
+ # ① SKU 字典(<数据目录>/lip-sku-dict.json,键=SKU;优先 `confirmed:true` 的「材质英文」)
54
+ # ② 通用小词典(映射真源 `materialEn`)
55
+ # ③ 会话传入(`--material-en` / 行内 `材质英文`)——**政策A:LLM 译出的材质英文可以直接用**
56
+ # (写台账留痕 + 写回字典标 `confirmed:false`,运营事后可复核)
57
+ # ⛔ HS 编码那条红线**完全不动**(人工维护、不预填、不写候选、不自动确认):
58
+ # 它是**只给 HS** 的,不许套到材质英文上(今天的误阻正是这么来的)。
59
+ # ⛔ 同一份 SKU 字典的既有读写方是另一条发票链的 runner:键/形状/原子写口径一致,
60
+ # 改路径或条目形状要两处一起改(本文件不复制它的键空间判据,只共用同一份文件)。
61
+ MATERIAL_POLICY = "政策A:材质英文允许 LLM 译;HS 编码仍必须人工维护"
62
+ MATERIAL_SKU_DICT_NAME = "lip-sku-dict.json"
63
+ MATERIAL_AUDIT_REL = ("material-en", "audit.jsonl")
64
+ MATERIAL_WRITEBACK_SOURCE = "LLM 译 {date}(政策A:材质英文可直接用,HS 不动)"
65
+ MATERIAL_LEVEL_SKU = "①SKU字典"
66
+ MATERIAL_LEVEL_SMALL = "②小词典"
67
+ MATERIAL_LEVEL_SESSION = "③会话传入"
68
+
69
+
70
+ def data_dir():
71
+ """数据目录:**唯一判据**在 `scripts/lib/argent_datadir.py`(延迟求值 ⇒ 隔离跑得动)。"""
72
+ sys.path.insert(0, str(HERE / "lib"))
73
+ from argent_datadir import resolve_data_dir # noqa: PLC0415
74
+ return Path(resolve_data_dir()["dir"])
75
+
76
+
77
+ def sku_dict_path():
78
+ return data_dir() / MATERIAL_SKU_DICT_NAME
79
+
80
+
81
+ def load_sku_dict(path=None, strict=False):
82
+ """读 SKU 字典(**只读**)。文件不存在 ⇒ `{}`;
83
+ `strict=True` 时「存在但不可解析」**抛错**(别把坏字典当空字典写,否则会毁掉已确认条目)。"""
84
+ p = Path(path) if path else sku_dict_path()
85
+ if not p.exists():
86
+ return {}
87
+ d = json.loads(p.read_text(encoding="utf-8"))
88
+ return d if isinstance(d, dict) else {}
89
+
90
+
91
+ def _norm_en(s):
92
+ return re.sub(r"\s+", " ", str(s or "").strip()).casefold()
93
+
94
+
95
+ def material_gap_text(mat):
96
+ """三级都没命中时的**阻断文案**(指路,不复述 HS 那条红线)。"""
97
+ return (f"材质「{mat}」未能在 SKU 字典 / 小词典命中 ⇒ 请翻译成英文后用 --material-en 重跑"
98
+ f"({MATERIAL_POLICY})")
99
+
100
+
101
+ def material_conflict_text(sku, kept, rejected):
102
+ return (f"材质英文冲突(SKU {sku or '?'}):字典保留『{kept}』、拒绝本次传入『{rejected}』"
103
+ f"⇒ 未写回字典,请人工裁决后重跑({MATERIAL_POLICY})")
104
+
105
+
106
+ def resolve_material_en(mat_zh, *, sku="", material_en="", small_dict=None,
107
+ sku_dict=None, dict_path=None, today=""):
108
+ """材质英文**三级解析(唯一实现)** —— 纯计算,**不写任何文件**(写回见 `apply_material_writeback`)。
109
+
110
+ :returns: `{"中文","英文","命中","来源","备注":[],"冲突":[],"缺口","写回","字典","覆盖"}`
111
+ · 命中 = `①SKU字典` / `②小词典` / `③会话传入` / `""`(三级都没命中);
112
+ · `缺口` 非空 ⇒ **闸门必须阻断**(编排器按 `plan["gaps"]` 拦,文案即此串);
113
+ · `写回` 非空 ⇒ ③ 定音且字典里没有可用条目 ⇒ 应写回(`{"mode","sku","条目"}`)。
114
+
115
+ 判据(与 `flow_case` 的「同名不同值不覆盖、记冲突交人」同一口径):
116
+ · ① 命中 ⇒ 用字典值;③ 与它同名不同值 ⇒ **冲突**(保留字典值、不写回、交人);
117
+ · ② 命中且 ③ 不同 ⇒ 用 ③(更具体)并留痕;⛔ **不写字典**(通用小词典无需 per-SKU 固化);
118
+ · ①② 都没命中而 ③ 有值 ⇒ 用 ③ 并**写回**;字典里已有 `confirmed:true` 条目但缺材质英文
119
+ ⇒ 只留痕、**不写**(不许把已确认条目降级成 `confirmed:false`)。
120
+ """
121
+ out = {"中文": str(mat_zh or "").strip(), "英文": "", "命中": "", "来源": "",
122
+ "备注": [], "冲突": [], "缺口": "", "写回": None, "字典": None, "覆盖": False}
123
+ mat, sku = out["中文"], str(sku or "").strip()
124
+ today = str(today or "")[:10] or datetime.now().strftime("%Y-%m-%d")
125
+ entry = None
126
+ if sku and isinstance(sku_dict, dict):
127
+ e = sku_dict.get(sku)
128
+ entry = e if isinstance(e, dict) else None
129
+ out["字典"] = entry
130
+ dict_en = str((entry or {}).get("材质英文") or "").strip()
131
+ small_en = str((small_dict or {}).get(mat) or "").strip()
132
+ session_en = str(material_en or "").strip()
133
+
134
+ if dict_en:
135
+ out.update({"英文": dict_en, "命中": MATERIAL_LEVEL_SKU,
136
+ "来源": str((entry or {}).get("来源") or "")})
137
+ if small_en and _norm_en(small_en) != _norm_en(dict_en):
138
+ # per-SKU 值与通用小词典不一致(多半是 SKU 材质变过或字典陈旧):按 per-SKU 优先,
139
+ # 但**必须留痕**(这类不一致只看 S 列看不出来)—— 不阻断,交运营复核。
140
+ out["备注"].append(f"SKU 字典({dict_en})与通用小词典({small_en})不一致"
141
+ f"⇒ 按 per-SKU 优先用字典值,请复核")
142
+ if session_en and _norm_en(session_en) != _norm_en(dict_en):
143
+ out["冲突"].append({
144
+ "field": f"材质英文[{sku or mat}]",
145
+ "kept": {"value": dict_en, "来源": out["来源"] or "(字典)",
146
+ "confirmed": bool((entry or {}).get("confirmed"))},
147
+ "rejected": {"value": session_en, "来源": "会话传入(--material-en / 行内 材质英文)"}})
148
+ out["缺口"] = material_conflict_text(sku or mat, dict_en, session_en)
149
+ return out
150
+
151
+ if small_en:
152
+ out.update({"英文": small_en, "命中": MATERIAL_LEVEL_SMALL, "来源": "映射真源 materialEn"})
153
+ if session_en and _norm_en(session_en) != _norm_en(small_en):
154
+ out["备注"].append(f"会话传入覆盖小词典:{small_en} → {session_en}(未写回字典)")
155
+ out["英文"], out["覆盖"] = session_en, True
156
+ return out
157
+
158
+ if session_en:
159
+ out.update({"英文": session_en, "命中": MATERIAL_LEVEL_SESSION,
160
+ "来源": "会话传入(--material-en / 行内 材质英文)"})
161
+ if entry is None:
162
+ out["写回"] = {"mode": "新增", "sku": sku, "条目": {
163
+ "材质英文": session_en, "confirmed": False,
164
+ "来源": MATERIAL_WRITEBACK_SOURCE.format(date=today), "备注": ""}}
165
+ elif entry.get("confirmed") is True:
166
+ out["备注"].append("字典已有 confirmed:true 条目但缺材质英文 ⇒ 只留痕、**不写回**"
167
+ "(不把已确认条目降级为 confirmed:false);请人工补录该 SKU 的材质英文")
168
+ else:
169
+ out["写回"] = {"mode": "合并", "sku": sku, "条目": {
170
+ "材质英文": session_en, "confirmed": False,
171
+ "来源": MATERIAL_WRITEBACK_SOURCE.format(date=today),
172
+ "备注": str(entry.get("备注") or "")}}
173
+ return out
174
+
175
+ if mat:
176
+ out["缺口"] = material_gap_text(mat)
177
+ return out
178
+
179
+
180
+ def _material_audit(rec, dict_path=None):
181
+ """审计留痕(JSONL 追加)。落点:<数据目录>/material-en/audit.jsonl(与回写器的审计同族)。"""
182
+ base = Path(dict_path).parent if dict_path else data_dir()
183
+ f = base.joinpath(*MATERIAL_AUDIT_REL)
184
+ try:
185
+ f.parent.mkdir(parents=True, exist_ok=True)
186
+ with f.open("a", encoding="utf-8") as fh:
187
+ fh.write(json.dumps(rec, ensure_ascii=False) + "\n")
188
+ return str(f)
189
+ except Exception: # noqa: BLE001 —— 审计失败不该毁掉填报(但调用方会把动作如实报出)
190
+ return ""
191
+
192
+
193
+ def record_material_audit(plan, *, dict_path=None):
194
+ """**冲突留痕**(唯一入口):字典已有值 vs 本次传入不同 ⇒ 即使被闸门拦下(不写回)也要落审计。
195
+
196
+ 为什么单独一个函数:冲突时**不会**走到 `apply_material_writeback`(闸门在写盘前就拦了),
197
+ 若只在写回路径留痕,冲突就只剩异常串里的那句话 —— 交人之后无从复核。
198
+ """
199
+ m = dict((plan or {}).get("material") or {})
200
+ if not m.get("冲突"):
201
+ return {"written": False, "动作": "无需留痕(无冲突)"}
202
+ rec = {"at": datetime.now().astimezone().isoformat(timespec="seconds"),
203
+ "sku": str(((m.get("写回") or {}).get("sku")) or ""), "材质": m.get("中文", ""),
204
+ "材质英文": m.get("英文", ""), "命中": m.get("命中", ""),
205
+ "政策": MATERIAL_POLICY, "动作": "冲突 ⇒ 未写回(交人)", "冲突": m["冲突"]}
206
+ rec["审计"] = _material_audit(rec, dict_path)
207
+ return rec
208
+
209
+
210
+ def apply_material_writeback(plan, *, audit=True, dict_path=None):
211
+ """执行 ③ 命中后的**字典写回**(唯一写入口)+ 审计留痕;返回动作回执(可直接进台账)。
212
+
213
+ ⛔ 不覆盖 `confirmed:true`(解析阶段就不会给出写回计划;写前**再查一次**,防计划过期);
214
+ ⛔ 同 SKU 同值 ⇒ 幂等跳过(不重写、不动 mtime);原子写(tmp + `os.replace`)。
215
+ """
216
+ m = dict((plan or {}).get("material") or {})
217
+ wb = m.get("写回") or None
218
+ rec = {"at": datetime.now().astimezone().isoformat(timespec="seconds"),
219
+ "sku": str((wb or {}).get("sku") or ""), "材质": m.get("中文", ""),
220
+ "材质英文": m.get("英文", ""), "命中": m.get("命中", ""),
221
+ "政策": MATERIAL_POLICY, "动作": ""}
222
+ if not wb:
223
+ rec["动作"] = ("冲突 ⇒ 未写回(交人)" if m.get("冲突")
224
+ else ("无需写回(命中 " + m["命中"] + ")" if m.get("命中") else "未解析 ⇒ 不写"))
225
+ if audit and (m.get("冲突") or m.get("命中") == MATERIAL_LEVEL_SESSION):
226
+ rec["审计"] = _material_audit(rec, dict_path)
227
+ return {"written": False, "动作": rec["动作"], "sku": rec["sku"], "条目": None}
228
+
229
+ p = Path(dict_path) if dict_path else sku_dict_path()
230
+ try:
231
+ cur = load_sku_dict(p, strict=True)
232
+ except Exception as exc: # noqa: BLE001 —— 坏字典 ⇒ 拒绝写(绝不覆盖成"只剩本条的字典")
233
+ rec["动作"] = f"字典不可解析 ⇒ 拒绝写回({type(exc).__name__})"
234
+ if audit:
235
+ rec["审计"] = _material_audit(rec, dict_path)
236
+ return {"written": False, "动作": rec["动作"], "sku": rec["sku"], "条目": None}
237
+
238
+ merged = dict(wb.get("条目") or {})
239
+ if wb.get("mode") == "合并":
240
+ merged = {**(cur.get(wb["sku"]) or {}), **merged}
241
+ prev = cur.get(wb["sku"]) or {}
242
+ prev_en = str(prev.get("材质英文") or "").strip()
243
+ if prev_en and _norm_en(prev_en) != _norm_en(merged.get("材质英文")):
244
+ # 计划之后字典被别的进程改过(或缓存过期)⇒ 仍按"不覆盖 + 记冲突"办
245
+ rec["动作"] = "冲突 ⇒ 未写回(字典现值与本次不同,交人)"
246
+ rec["冲突"] = {"kept": prev_en, "rejected": merged.get("材质英文")}
247
+ if audit:
248
+ rec["审计"] = _material_audit(rec, dict_path)
249
+ return {"written": False, "动作": rec["动作"], "sku": wb["sku"], "条目": None}
250
+ if prev == merged:
251
+ rec["动作"] = "幂等跳过(同 SKU 同值,未重写字典)"
252
+ if audit:
253
+ rec["审计"] = _material_audit(rec, dict_path)
254
+ return {"written": False, "动作": rec["动作"], "sku": wb["sku"], "条目": merged}
255
+
256
+ new = dict(cur)
257
+ new[wb["sku"]] = merged
258
+ tmp = p.parent / ("." + p.name + ".tmp")
259
+ p.parent.mkdir(parents=True, exist_ok=True)
260
+ tmp.write_text(json.dumps(new, ensure_ascii=False, indent=2), encoding="utf-8")
261
+ os.replace(tmp, p)
262
+ rec.update({"动作": f"字典写回({wb.get('mode')},confirmed=false)", "条目": merged, "字典": str(p)})
263
+ if audit:
264
+ rec["审计"] = _material_audit(rec, dict_path)
265
+ return {"written": True, "动作": rec["动作"], "sku": wb["sku"], "条目": merged,
266
+ "path": str(p), "审计": rec.get("审计", "")}
267
+
268
+
269
+ def num(v, default=None):
270
+ """把飞书里的数字(可能是 '17.7' / 17.7 / '[17.7]')转 float"""
271
+ s = str(v if v is not None else "").strip()
272
+ s = s.strip("[]").strip()
273
+ if not s:
274
+ return default
275
+ m = re.search(r"-?\d+(?:\.\d+)?", s)
276
+ return float(m.group(0)) if m else default
277
+
278
+
279
+ def pick(v, default=""):
280
+ """飞书 select/文本值归一:'["上海文鳐"]' → '上海文鳐'"""
281
+ s = str(v if v is not None else "").strip()
282
+ try:
283
+ arr = json.loads(s)
284
+ if isinstance(arr, list) and arr:
285
+ return str(arr[0]).strip()
286
+ if isinstance(arr, str):
287
+ return arr.strip()
288
+ except Exception:
289
+ pass
290
+ if len(s) >= 4 and s[0] == "[" and s[-1] == "]":
291
+ return s[1:-1].strip()
292
+ return s
293
+
294
+
295
+ def fmt_num(x):
296
+ if x is None:
297
+ return ""
298
+ f = float(x)
299
+ return int(f) if abs(f - round(f)) < 1e-9 else round(f, 6)
300
+
301
+
302
+ def store_id(store_name, ids=None):
303
+ """'墨西哥-1店-子账号' → ML 卖家 ID(唯一真源 `scripts/ml-store-ids.json`)。
304
+
305
+ 2026-09-19:三份映射收敛到一处。此前本函数的键是裸 `1店`,与
306
+ `~/.dsh/argent/lip-po-mapping.json`(完整店名)、`invoice-mapping.json`(短名+后缀)
307
+ 各写一份 —— 鑫中航那条线因键匹配不上而**静默回退成紫鸟 store_id**,6 张发票写错。
308
+ 现在统一由 `ml_store_ids.resolve()` 解析(键 = `国家-店号`,主/子账号共用同一 ID)。
309
+
310
+ `ids` 仅为兼容旧调用/单测保留:显式传入时按旧口径查(键 `N店`),不传则读真源。
311
+ """
312
+ if ids is None:
313
+ return ml_store_ids.resolve(pick(store_name))
314
+ s = pick(store_name)
315
+ m = re.search(r"(\d+)店", s)
316
+ if m:
317
+ return ids.get(f"{m.group(1)}店", "")
318
+ for k, v in ids.items():
319
+ if k in s:
320
+ return v
321
+ return ""
322
+
323
+
324
+ def plan_cells(row, m, today, english_name=None, material_en=None, sku_dict=None):
325
+ """纯计算:返回 {cell: value} 与诊断 —— 不碰文件(可单测)。
326
+
327
+ `material_en` = 会话传入的材质英文(三级解析的第③级;`--material-en` 或行内 `材质英文`)。
328
+ `sku_dict` = 只读注入的 SKU 字典快照(None ⇒ 读数据目录里的那份;**只读,不写**)。
329
+ 返回额外带 `material`(三级解析结果)与 `blocking`(**阻断性**缺口;其余缺口只报告)。
330
+ """
331
+ cells, notes, gaps = {}, [], []
332
+ # 顶部块
333
+ for name, spec in m["topBlock"].items():
334
+ v = spec.get("value", "")
335
+ if v == "__TODAY__":
336
+ cells[spec["cell"]] = today
337
+ else:
338
+ cells[spec["cell"]] = v
339
+ # 数据行
340
+ r = m["dataRow"]
341
+ qty = num(row.get("数量") if row.get("数量") is not None else row.get("总产品数量"))
342
+ cart = num(row.get("箱数") if row.get("箱数") is not None else row.get("总箱数"))
343
+ wt = num(row.get("重量") if row.get("重量") is not None else row.get("单箱总重"))
344
+ L = num(row.get("长") if row.get("长") is not None else row.get("货箱长度"))
345
+ W = num(row.get("宽") if row.get("宽") is not None else row.get("货箱宽度"))
346
+ H = num(row.get("高") if row.get("高") is not None else row.get("货箱高度"))
347
+ mat = pick(row.get("材质"))
348
+ # 材质英文:**三级解析**(① SKU 字典 → ② 小词典 → ③ 会话传入)。
349
+ # 解析本身不写文件;③ 定音后的字典写回由 `apply_material_writeback(plan)` 单独做。
350
+ session_en = (str(material_en or "").strip()
351
+ or pick(row.get("材质英文")) or pick(row.get("materialEn")))
352
+ mat_res = resolve_material_en(
353
+ mat, sku=pick(row.get("SKU")), material_en=session_en,
354
+ small_dict=m.get("materialEn") or {},
355
+ sku_dict=(load_sku_dict() if sku_dict is None else sku_dict), today=today)
356
+ mat_en = mat_res["英文"]
357
+ notes.extend(mat_res["备注"])
358
+
359
+ for spec in m["columns"]:
360
+ cell = f"{spec['col']}{r}"
361
+ src = spec["source"]
362
+ if src == "field":
363
+ raw = row.get(spec["field"])
364
+ if spec.get("numeric"):
365
+ # 数值列必须写**数字**(飞书里可能是文本,直接写会让门户当成文本数字)
366
+ v = num(raw)
367
+ if v is None:
368
+ gaps.append(spec["header"])
369
+ v = ""
370
+ else:
371
+ v = fmt_num(v)
372
+ else:
373
+ # 文本列保持字符串:货件号/HS编码/SKU/卖家ID(防科学计数法、防丢前导零)
374
+ v = pick(raw, spec.get("default", ""))
375
+ if not v and spec.get("default"):
376
+ v = spec["default"]
377
+ if not v:
378
+ gaps.append(spec["header"])
379
+ cells[cell] = v
380
+ elif src == "const":
381
+ raw_v = spec.get("value", "")
382
+ if isinstance(raw_v, (int, float)) and not isinstance(raw_v, bool):
383
+ # 常数是数字就写数字(写成文本会导致门户不认,如申报单价)
384
+ cells[cell] = raw_v
385
+ else:
386
+ tpl = str(raw_v)
387
+ cells[cell] = tpl.replace("{材质}", mat or "").replace("{材质英文}", mat_en)
388
+ elif src == "calc":
389
+ expr = spec["expr"]
390
+ if expr == "1-{箱数}":
391
+ cells[cell] = f"1-{fmt_num(cart)}" if cart else ""
392
+ else:
393
+ try:
394
+ val = eval(expr.format(数量=qty or 0, 箱数=cart or 0, 重量=wt or 0, 长=L or 0, 宽=W or 0, 高=H or 0), {"__builtins__": {}})
395
+ cells[cell] = fmt_num(val)
396
+ except Exception as e:
397
+ cells[cell] = ""
398
+ notes.append(f"{spec['header']} 算式失败: {e}")
399
+ if cells[cell] == "":
400
+ gaps.append(spec["header"])
401
+ elif src == "storemap":
402
+ v = store_id(row.get(spec["field"])) # 唯一真源 scripts/ml-store-ids.json
403
+ if not v:
404
+ # 把**原因**带进缺口文案(人看的是这句):等账号 vs 认不出店铺名,动作完全不同
405
+ gaps.append(f"卖家ID:{ml_store_ids.missing_reason(pick(row.get(spec['field'])))}")
406
+ cells[cell] = v
407
+ elif src == "llm":
408
+ v = english_name or ""
409
+ if not v:
410
+ notes.append(f"{spec['header']} 待 LLM 翻译(中文:{pick(row.get(spec['field']))})")
411
+ cells[cell] = v
412
+ elif src == "image":
413
+ has = str(row.get(spec["field"], "")).strip() not in ("", "[]")
414
+ if not has:
415
+ # 缺口文案与**另一条发票链统一**(用户口径 2026-10-13):表内没有产品图时,
416
+ # 提示运营补图。此前这里写的是**表头名**(`产品图片`/`*产品图片 picture`),
417
+ # 对运营不构成行动指令;表头名仍留在 notes 里便于溯源。
418
+ gaps.append("缺产品图(运营补)")
419
+ notes.append(f"{spec['header']}:表内无附件")
420
+ # 关键:无论有没有图都要**清空**该格 —— 运营版的第 8 行本身写着说明文字,
421
+ # 不清就会被原样带进导入文件(2026-09-11 读回验证抓到)
422
+ cells[cell] = "@IMAGE@" if has else ""
423
+ # 闸门(**唯一判据**):材质英文三级都解析不出来(或与字典同名不同值 ⇒ 冲突交人)⇒ 阻断。
424
+ # ⛔ 判据是「**三级都无法解析**」,不是「英文为空 ⇒ 按报关敏感字段拦」——后者是 HS 的口径,
425
+ # 套到材质英文上就是 2026-10-12 那两条票被误阻的成因。
426
+ blocking = []
427
+ if mat_res["缺口"]:
428
+ gaps.append(mat_res["缺口"])
429
+ blocking.append(mat_res["缺口"])
430
+ # 图片锚点:**以映射真源为准**(`imageAnchor`),不要按数据行硬编码。
431
+ # 2026-09-11 用户实测:锚在数据行 AA8 时飞书里显示位置不对,应为 **AA7**(「*产品图片 picture」表头格,
432
+ # 也落在冻结窗格 A8 的冻结区内)。回退顺序:imageAnchor → 图片列 spec.anchor → AA<数据行>。
433
+ anchor = (m.get("imageAnchor")
434
+ or next((c.get("anchor") for c in m["columns"] if c.get("source") == "image" and c.get("anchor")), None)
435
+ or f"AA{r}")
436
+ return {"cells": cells, "notes": notes, "gaps": gaps, "blocking": blocking,
437
+ "material": mat_res, "image_cell": anchor}
438
+
439
+
440
+ def cell_pixel_box(ws, cell_ref, margin=0.96, default_col_width=8.43, default_row_height=15.0):
441
+ """单元格可用像素尺寸(Excel 换算规则:列宽→像素 = round(width*7)+5;行高 pt→像素 = pt*96/72)。
442
+
443
+ 2026-09-11 用户实测:产品图**比单元格大、装不下**(原来写死 120px 宽,而 AA 列仅约 96px 宽、
444
+ 第 8 行仅约 53px 高)⇒ 一律按单元格实际尺寸等比缩放。
445
+ """
446
+ import re as _re
447
+ m = _re.match(r"^([A-Z]+)(\d+)$", cell_ref.strip().upper())
448
+ if not m:
449
+ raise SystemExit(f"图片锚点格式不合法: {cell_ref!r}")
450
+ col, row = m.group(1), int(m.group(2))
451
+ # ⚠️ 必须用 `cd[col]` 取,**不能用 `cd.get(col)`**:openpyxl 的 DimensionHolder 对
452
+ # 未显式列出但存在于 <cols> 的列(实测 AA 列宽 13),`.get()` 返回 None(会退化成默认 8.43 ⇒ 算小),
453
+ # 而 `[]` 能拿到真值。取不到或 width 为空时才回落默认值。
454
+ try:
455
+ cd = ws.column_dimensions[col]
456
+ width = cd.width if (cd is not None and cd.width) else None
457
+ except (KeyError, AttributeError):
458
+ width = None
459
+ if width is None:
460
+ width = default_col_width
461
+ rd = ws.row_dimensions.get(row)
462
+ height_pt = rd.height if (rd is not None and rd.height) else default_row_height
463
+ px_w = int(round(float(width) * 7 + 5))
464
+ px_h = int(round(float(height_pt) * 96.0 / 72.0))
465
+ return max(1, int(px_w * margin)), max(1, int(px_h * margin))
466
+
467
+
468
+ def fit_image_to_cell(img, ws, cell_ref, margin=0.96):
469
+ """把图片等比缩放到**装得进单元格**为止(只缩不放,避免糊)。返回尺寸信息。"""
470
+ box_w, box_h = cell_pixel_box(ws, cell_ref, margin=margin)
471
+ ow, oh = int(img.width), int(img.height)
472
+ scale = min(box_w / float(ow), box_h / float(oh), 1.0)
473
+ if scale < 1.0:
474
+ img.width = max(1, int(round(ow * scale)))
475
+ img.height = max(1, int(round(oh * scale)))
476
+ return {"original": [ow, oh], "box": [box_w, box_h], "size": [int(img.width), int(img.height)]}
477
+
478
+
479
+ def fill_xlsx(template, plan, out, image_path=None):
480
+ import openpyxl
481
+ if image_path:
482
+ # 前置检查:openpyxl 插图片需要 Pillow;缺了就明确报错(别抛栈)
483
+ try:
484
+ from openpyxl.drawing.image import Image as XLImage # noqa: F401
485
+ import PIL # noqa: F401
486
+ except ImportError:
487
+ raise SystemExit("插入产品图需要 Pillow(openpyxl 依赖):pip install Pillow(本机 venv:~/.dsh/argent/venv/bin/pip install Pillow)")
488
+ from openpyxl.drawing.image import Image as XLImage
489
+ wb = openpyxl.load_workbook(template)
490
+ ws = wb[plan.get("sheet") or "Sheet1"]
491
+ # ── 清底(用户 2026-09-11 明确):门户模板**自带的值一律不用**,值全部来自运营版说明/飞书行/算式。
492
+ # 具体:先把顶部块 8 格(A2..H2)清空(模板里 E2/F2/G2/H2 预填了 CNYTN/USLGB/东莞集货仓/墨西哥),
493
+ # 再由 plan 写入我们的值(其中港前仓=华南仓,**覆盖**模板自带的东莞集货仓)。
494
+ # 注:不删 G2 的单元格下拉校验(用户:"港前仓不用管校验,就填华南仓")。
495
+ # 第 5-6 行是门户模板自带的填写示例、门户要求不删(用户 2026-09-11)⇒ 不动。
496
+ for col in ("A", "B", "C", "D", "E", "F", "G", "H"):
497
+ ws[col + "2"] = None
498
+ for cell, val in plan["cells"].items():
499
+ if val == "@IMAGE@":
500
+ # 有图也必须先把该格写空:运营版第 8 行原本是说明文字,不清就会被带进导入文件
501
+ ws[cell] = ""
502
+ continue
503
+ ws[cell] = val
504
+ # 清掉数据行以下的残留(避免示例/说明残值干扰导入)
505
+ if image_path and os.path.exists(image_path):
506
+ img = XLImage(image_path)
507
+ info = fit_image_to_cell(img, ws, plan["image_cell"])
508
+ ws.add_image(img, plan["image_cell"])
509
+ plan["image_size"] = info["size"]
510
+ plan["image_box"] = info["box"]
511
+ plan["image_original"] = info["original"]
512
+ out.parent.mkdir(parents=True, exist_ok=True)
513
+ wb.save(out)
514
+ os.chmod(out, 0o600)
515
+ return out
516
+
517
+
518
+ def main():
519
+ ap = argparse.ArgumentParser()
520
+ ap.add_argument("--template", required=True)
521
+ ap.add_argument("--row", required=True, help="一行飞书数据的 JSON 文件")
522
+ ap.add_argument("--out", default="")
523
+ ap.add_argument("--english-name", default="")
524
+ ap.add_argument("--material-en", default="",
525
+ help="材质英文(三级解析的第③级:SKU 字典与小词典都没命中时由会话传入;"
526
+ "政策A 允许 LLM 译;命中后会写回 SKU 字典并标 confirmed:false)")
527
+ ap.add_argument("--image", default="")
528
+ ap.add_argument("--today", default="")
529
+ ap.add_argument("--dry-run", action="store_true")
530
+ a = ap.parse_args()
531
+ from datetime import date
532
+ today = a.today or date.today().strftime("%Y-%m-%d")
533
+ m = load_map()
534
+ row = json.loads(Path(a.row).read_text(encoding="utf-8"))
535
+ plan = plan_cells(row, m, today, a.english_name, material_en=a.material_en)
536
+ summary = {"sheet": m["sheet"], "dataRow": m["dataRow"], "today": today,
537
+ "cells": plan["cells"], "notes": plan["notes"], "gaps": plan["gaps"],
538
+ "blocking": plan["blocking"], "material": plan["material"],
539
+ "image": bool(a.image), "image_cell": plan["image_cell"]}
540
+ if a.dry_run:
541
+ if a.image:
542
+ import openpyxl
543
+ from openpyxl.drawing.image import Image as XLImage
544
+ _ws = openpyxl.load_workbook(a.template)[m["sheet"]]
545
+ _info = fit_image_to_cell(XLImage(a.image), _ws, plan["image_cell"])
546
+ summary.update({"image_size": _info["size"], "image_box": _info["box"], "image_original": _info["original"]})
547
+ print(json.dumps(summary, ensure_ascii=False, indent=1))
548
+ return 0
549
+ if plan["blocking"]:
550
+ # 阻断性缺口(材质英文三级未解析 / 冲突)⇒ **不落盘**:宁可不出文件,也别出一张 S 列残缺的票
551
+ print("❌ 阻断,未生成文件:" + ";".join(plan["blocking"]), file=sys.stderr)
552
+ return 1
553
+ if not a.out:
554
+ print("需要 --out", file=sys.stderr)
555
+ return 2
556
+ out = fill_xlsx(a.template, plan, Path(a.out), a.image or None)
557
+ # ③ 命中 ⇒ 把译文写回 SKU 字典(政策A;confirmed:false)+ 审计留痕。⛔ ①/② 命中一律不写。
558
+ summary["writeback"] = apply_material_writeback(plan)
559
+ summary["out"] = str(out)
560
+ for k in ("image_size", "image_box", "image_original"):
561
+ if k in plan:
562
+ summary[k] = plan[k]
563
+ print(json.dumps(summary, ensure_ascii=False, indent=1))
564
+ return 0
565
+
566
+
567
+ if __name__ == "__main__":
568
+ sys.exit(main())