@whyshu/argent-engine 0.0.0-stage → 0.6.1
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.
- package/MANIFEST.json +341 -0
- package/engine/VERSION +1 -0
- package/engine/agents/catalog.json +454 -0
- package/engine/configs/_aliases.proposed.json +651 -0
- package/engine/configs/_migration-plan.proposed.json +899 -0
- package/engine/configs/account.json +272 -0
- package/engine/configs/advanced.json +205 -0
- package/engine/configs/host.json +149 -0
- package/engine/configs/hr.json +27 -0
- package/engine/configs/notify.json +264 -0
- package/engine/core/cli/argent.mjs +277 -0
- package/engine/core/contracts/argent.contracts.json +1065 -0
- package/engine/core/contracts/verbs.schema.json +2539 -0
- package/engine/core/lib/argent-datadir.mjs +223 -0
- package/engine/core/lib/audit.mjs +187 -0
- package/engine/core/lib/claim.mjs +258 -0
- package/engine/core/lib/config-catalog.mjs +1172 -0
- package/engine/core/lib/doctor-customer.mjs +577 -0
- package/engine/core/lib/flow-list.mjs +138 -0
- package/engine/core/lib/flow-registry.mjs +48 -0
- package/engine/core/lib/init.mjs +247 -0
- package/engine/core/lib/ledger.mjs +124 -0
- package/engine/core/lib/license-activate.mjs +813 -0
- package/engine/core/lib/license-device.mjs +314 -0
- package/engine/core/lib/license-file.mjs +349 -0
- package/engine/core/lib/license-guard.mjs +123 -0
- package/engine/core/lib/license-ops.mjs +1645 -0
- package/engine/core/lib/license-verdict.mjs +133 -0
- package/engine/core/lib/license.mjs +577 -0
- package/engine/core/lib/notify/dedup.mjs +101 -0
- package/engine/core/lib/notify/feishu.mjs +38 -0
- package/engine/core/lib/notify/index.mjs +95 -0
- package/engine/core/lib/notify/verb.mjs +327 -0
- package/engine/core/lib/notify/webhook.mjs +135 -0
- package/engine/core/lib/notify/wecom.mjs +36 -0
- package/engine/core/lib/python.mjs +145 -0
- package/engine/core/lib/sop.mjs +116 -0
- package/engine/core/lib/versions.mjs +205 -0
- package/engine/core/mcp/client.mjs +67 -0
- package/engine/core/mcp/http.mjs +138 -0
- package/engine/core/mcp/server.mjs +702 -0
- package/engine/scripts/_engine_rules_probe.py +15 -0
- package/engine/scripts/analyze-full-day.py +568 -0
- package/engine/scripts/archive/feishu-bridge.js +246 -0
- package/engine/scripts/argent-agents.mjs +527 -0
- package/engine/scripts/argent-capabilities.mjs +776 -0
- package/engine/scripts/argent-catalog.mjs +186 -0
- package/engine/scripts/argent-datadir.py +62 -0
- package/engine/scripts/argent-doctor.mjs +624 -0
- package/engine/scripts/argent-env-write.mjs +302 -0
- package/engine/scripts/argent-schedule.mjs +620 -0
- package/engine/scripts/argent-setup-check.mjs +369 -0
- package/engine/scripts/argent-upgrade.mjs +1046 -0
- package/engine/scripts/base-bootstrap.sh +576 -0
- package/engine/scripts/billing-mode-daily.sh +13 -0
- package/engine/scripts/build-engine-package.mjs +126 -0
- package/engine/scripts/capability-packs.lock.json +484 -0
- package/engine/scripts/capability_pack.py +1242 -0
- package/engine/scripts/check-1.0-criteria.mjs +355 -0
- package/engine/scripts/check-agent-catalog.mjs +141 -0
- package/engine/scripts/check-billing-mode.sh +378 -0
- package/engine/scripts/check-child-sessions.mjs +83 -0
- package/engine/scripts/check-config-catalog.mjs +677 -0
- package/engine/scripts/check-docs-drift.mjs +522 -0
- package/engine/scripts/check-dsh-web-expose.sh +98 -0
- package/engine/scripts/check-judgment-drift.mjs +403 -0
- package/engine/scripts/check-license.mjs +199 -0
- package/engine/scripts/check-model-auth.py +443 -0
- package/engine/scripts/check-pack-closure.mjs +661 -0
- package/engine/scripts/check-portable-symbols.mjs +116 -0
- package/engine/scripts/check-preset-landing.mjs +124 -0
- package/engine/scripts/check-production-home.mjs +602 -0
- package/engine/scripts/check-role-mapping.mjs +461 -0
- package/engine/scripts/check-sample-redaction.mjs +320 -0
- package/engine/scripts/check-skill-classification.mjs +188 -0
- package/engine/scripts/check-sop-consistency.mjs +431 -0
- package/engine/scripts/check-test-env-safety.mjs +627 -0
- package/engine/scripts/check-version-consistency.mjs +452 -0
- package/engine/scripts/ci-run.sh +558 -0
- package/engine/scripts/collect-evidence.mjs +42 -0
- package/engine/scripts/config-explain.mjs +278 -0
- package/engine/scripts/configure-provider.rb +29 -0
- package/engine/scripts/customer-config-migrate.mjs +179 -0
- package/engine/scripts/customer-preflight.py +892 -0
- package/engine/scripts/customer-readiness.sh +135 -0
- package/engine/scripts/daily-report-inbox.py +242 -0
- package/engine/scripts/daily-report-reconcile.mjs +312 -0
- package/engine/scripts/daily-report.mjs +1310 -0
- package/engine/scripts/daily-report.sh +47 -0
- package/engine/scripts/data/lip-product-dict.json +306 -0
- package/engine/scripts/deliver-customer.mjs +932 -0
- package/engine/scripts/delivery-faces.txt +31 -0
- package/engine/scripts/desktop-trial.sh +146 -0
- package/engine/scripts/dev-window.sh +261 -0
- package/engine/scripts/drill-clean-machine.sh +315 -0
- package/engine/scripts/dsh-api-client.mjs +215 -0
- package/engine/scripts/dsh-backup-scope.sh +83 -0
- package/engine/scripts/dsh-offline-pack.mjs +77 -0
- package/engine/scripts/dsh-overlay-pin.sh +87 -0
- package/engine/scripts/dsh-plane.mjs +153 -0
- package/engine/scripts/dsh-portforward.py +223 -0
- package/engine/scripts/dsh-preflight.mjs +179 -0
- package/engine/scripts/dsh-release-watch-daily.sh +19 -0
- package/engine/scripts/dsh-release-watch.mjs +458 -0
- package/engine/scripts/dsh-remote-check.sh +99 -0
- package/engine/scripts/dsh-restart.sh +179 -0
- package/engine/scripts/dsh-restore.sh +111 -0
- package/engine/scripts/dsh-scope-rewire.mjs +803 -0
- package/engine/scripts/dsh-snapshot.mjs +116 -0
- package/engine/scripts/dsh-upgrade-readiness.mjs +165 -0
- package/engine/scripts/dsh-upgrade.mjs +612 -0
- package/engine/scripts/dsh-url.sh +86 -0
- package/engine/scripts/dsh-web-launch.mjs +155 -0
- package/engine/scripts/ensure-store-windows.py +509 -0
- package/engine/scripts/entitlements.py +458 -0
- package/engine/scripts/env_probe.py +411 -0
- package/engine/scripts/feishu-card.mjs +252 -0
- package/engine/scripts/final-check.mjs +43 -0
- package/engine/scripts/fix-skill-frontmatter.py +209 -0
- package/engine/scripts/flow-auto-retry.py +454 -0
- package/engine/scripts/flow-requirements.json +735 -0
- package/engine/scripts/flow-scaffold.py +275 -0
- package/engine/scripts/flow-timers-plist.sh +87 -0
- package/engine/scripts/flow-timers.sh +184 -0
- package/engine/scripts/flow-trigger-wake.mjs +284 -0
- package/engine/scripts/flow-trigger.sh +571 -0
- package/engine/scripts/flow_billing.py +354 -0
- package/engine/scripts/flow_capability_stats.py +314 -0
- package/engine/scripts/flow_case.py +194 -0
- package/engine/scripts/flow_claim.py +497 -0
- package/engine/scripts/flow_config.py +1025 -0
- package/engine/scripts/flow_failure.py +588 -0
- package/engine/scripts/flow_learn.py +105 -0
- package/engine/scripts/flow_ledger.py +235 -0
- package/engine/scripts/flow_policy.py +238 -0
- package/engine/scripts/flow_preflight.py +339 -0
- package/engine/scripts/flow_registry.py +341 -0
- package/engine/scripts/flow_retry.py +304 -0
- package/engine/scripts/flow_return_to_ops.py +191 -0
- package/engine/scripts/flow_source.py +315 -0
- package/engine/scripts/flow_sources/__init__.py +53 -0
- package/engine/scripts/flow_sources/cases_json.py +106 -0
- package/engine/scripts/flow_sources/common.py +428 -0
- package/engine/scripts/flow_sources/feishu_base.py +273 -0
- package/engine/scripts/flow_stale.py +185 -0
- package/engine/scripts/flow_status_reconcile.py +344 -0
- package/engine/scripts/flow_triage.py +849 -0
- package/engine/scripts/flow_write_verify.py +496 -0
- package/engine/scripts/fulfillment.js +586 -0
- package/engine/scripts/gen-agent-bundles.mjs +276 -0
- package/engine/scripts/gen-preset-rows.mjs +259 -0
- package/engine/scripts/init-ml.sh +2278 -0
- package/engine/scripts/install-internal-role.sh +190 -0
- package/engine/scripts/install-license.mjs +641 -0
- package/engine/scripts/install-one-shot.mjs +665 -0
- package/engine/scripts/integration_contract.py +220 -0
- package/engine/scripts/integrations/wenyao/adapter.py +98 -0
- package/engine/scripts/integrations/xzh/adapter.py +75 -0
- package/engine/scripts/invoice-mapping.example.json +40 -0
- package/engine/scripts/invoice-table.sh +195 -0
- package/engine/scripts/invoice_carrier_notice.py +279 -0
- package/engine/scripts/invoice_fail_closeout.py +297 -0
- package/engine/scripts/invoice_lip_runner.py +1508 -0
- package/engine/scripts/invoice_lip_runner.py.bak-20260922-1710-freready +1090 -0
- package/engine/scripts/invoice_ready_audit.py +191 -0
- package/engine/scripts/invoice_runner.py +675 -0
- package/engine/scripts/invoice_stageb.py +516 -0
- package/engine/scripts/launchd/com.whyshu.argent-bridge.plist.example +42 -0
- package/engine/scripts/launchd/com.whyshu.fulfillment-poll.plist.example +29 -0
- package/engine/scripts/lease.py +377 -0
- package/engine/scripts/lib/agent-install.mjs +649 -0
- package/engine/scripts/lib/argent-datadir.mjs +16 -0
- package/engine/scripts/lib/argent-datadir.sh +18 -0
- package/engine/scripts/lib/argent_datadir.py +285 -0
- package/engine/scripts/lib/argent_filelock.py +507 -0
- package/engine/scripts/lib/bootcheck-wiring.mjs +510 -0
- package/engine/scripts/lib/cdp_ports.py +197 -0
- package/engine/scripts/lib/customer-env.mjs +102 -0
- package/engine/scripts/lib/daily-report-core.mjs +517 -0
- package/engine/scripts/lib/dep-declaration.mjs +81 -0
- package/engine/scripts/lib/deps-migrate.mjs +310 -0
- package/engine/scripts/lib/dsh-host-resolve.mjs +109 -0
- package/engine/scripts/lib/dsh-scope.mjs +500 -0
- package/engine/scripts/lib/dsh-snapshot-path.mjs +35 -0
- package/engine/scripts/lib/dsh-versions.mjs +96 -0
- package/engine/scripts/lib/dsh-web-probe.mjs +214 -0
- package/engine/scripts/lib/is-direct-run.mjs +61 -0
- package/engine/scripts/lib/patch-compose.mjs +241 -0
- package/engine/scripts/lib/patch-target.mjs +35 -0
- package/engine/scripts/lib/plugin-lifecycle.mjs +128 -0
- package/engine/scripts/lib/plugin-packages.mjs +332 -0
- package/engine/scripts/lib/preset-compat.mjs +127 -0
- package/engine/scripts/lib/preset-install.mjs +575 -0
- package/engine/scripts/lib/preset-mechanism.mjs +56 -0
- package/engine/scripts/lib/preset-sync.mjs +59 -0
- package/engine/scripts/lib/profile-retire.mjs +97 -0
- package/engine/scripts/lib/python-runtime.mjs +190 -0
- package/engine/scripts/lib/registry.mjs +72 -0
- package/engine/scripts/lib/repo-root.sh +56 -0
- package/engine/scripts/lib/schedule-mechanism.mjs +659 -0
- package/engine/scripts/lib/schedule-probe.mjs +835 -0
- package/engine/scripts/lib/scope-pointer.mjs +164 -0
- package/engine/scripts/lib/settings-panels.mjs +816 -0
- package/engine/scripts/lib/stdio_utf8.py +86 -0
- package/engine/scripts/lib/wake-auth.mjs +392 -0
- package/engine/scripts/lib/webdriver_ipc.py +264 -0
- package/engine/scripts/lib/zstd-frames.mjs +104 -0
- package/engine/scripts/license-e2e-drill.mjs +426 -0
- package/engine/scripts/license-gate.mjs +245 -0
- package/engine/scripts/lip_backlog.py +171 -0
- package/engine/scripts/lip_table.py +185 -0
- package/engine/scripts/local-usage.mjs +214 -0
- package/engine/scripts/migrate-providers.mjs +127 -0
- package/engine/scripts/migrate-skills.sh +65 -0
- package/engine/scripts/ml-oauth-setup.mjs +550 -0
- package/engine/scripts/ml-store-ids.json +26 -0
- package/engine/scripts/ml_artifact_attribution.py +333 -0
- package/engine/scripts/ml_capacity_probe.py +1158 -0
- package/engine/scripts/ml_hub_status.py +1556 -0
- package/engine/scripts/ml_schema_check.py +115 -0
- package/engine/scripts/ml_store_ids.py +139 -0
- package/engine/scripts/npm-publish-argent.mjs +499 -0
- package/engine/scripts/npm-publish-argent.sh +20 -0
- package/engine/scripts/orchestrator.py +13811 -0
- package/engine/scripts/pack-plugins.mjs +140 -0
- package/engine/scripts/patches/dsh-llm-deepseek--transport-diagnostics.patch +297 -0
- package/engine/scripts/patches/dsh-session--append-message-shape.patch +15 -0
- package/engine/scripts/patches/dsh-session--tool-result-block-017.patch +28 -0
- package/engine/scripts/patches/dsh-session-format-v0-to-v1--permission-preset-origin.patch +11 -0
- package/engine/scripts/patches/dsh-session-format-v3-to-v4--turn-outside-replace.patch +20 -0
- package/engine/scripts/patches/dsh-session-persistence-jsonl--worker-bundled-migrators.patch +39 -0
- package/engine/scripts/patches/dsh-tools--render-return-shape.patch +50 -0
- package/engine/scripts/patches/verify-bundled-migrators.mjs +79 -0
- package/engine/scripts/patches/verify-hardening.mjs +167 -0
- package/engine/scripts/patches/verify-session-migration.mjs +149 -0
- package/engine/scripts/patches/verify-transport-diagnostics.mjs +227 -0
- package/engine/scripts/plan-role-rename.mjs +125 -0
- package/engine/scripts/plugin-bootcheck.mjs +451 -0
- package/engine/scripts/plugin-composition.json +15 -0
- package/engine/scripts/poc-mcp-host.mjs +455 -0
- package/engine/scripts/poll-fulfillment.sh +295 -0
- package/engine/scripts/poll-invoice.sh +76 -0
- package/engine/scripts/profile-doctor.mjs +686 -0
- package/engine/scripts/provider-select.sh +71 -0
- package/engine/scripts/provider-switch.sh +146 -0
- package/engine/scripts/provider-sync-target.check.mjs +64 -0
- package/engine/scripts/python-runtime.sh +78 -0
- package/engine/scripts/query_runner.py +310 -0
- package/engine/scripts/release-argent.sh +574 -0
- package/engine/scripts/repair-unresolved-tools.mjs +178 -0
- package/engine/scripts/retire-inventory.mjs +40 -0
- package/engine/scripts/run-tests.sh +257 -0
- package/engine/scripts/s1-audit.mjs +85 -0
- package/engine/scripts/self-sign-license.mjs +179 -0
- package/engine/scripts/session-health-scan.mjs +63 -0
- package/engine/scripts/session-repair-tool-result-content.mjs +181 -0
- package/engine/scripts/setup.sh +22 -0
- package/engine/scripts/skill-install.mjs +629 -0
- package/engine/scripts/sku_translate.py +148 -0
- package/engine/scripts/sync-plugins.sh +90 -0
- package/engine/scripts/sync-presets.sh +27 -0
- package/engine/scripts/sync-scripts.sh +445 -0
- package/engine/scripts/telemetry.py +873 -0
- package/engine/scripts/ui-copy-count.mjs +220 -0
- package/engine/scripts/usage-anchor.sh +137 -0
- package/engine/scripts/usage_report.py +531 -0
- package/engine/scripts/validate-preset-schema.mjs +208 -0
- package/engine/scripts/vertical-init.mjs +323 -0
- package/engine/scripts/vertical-lint.mjs +482 -0
- package/engine/scripts/wenyao-browser.sh +74 -0
- package/engine/scripts/wenyao-fixture-export.py +116 -0
- package/engine/scripts/wenyao-invoice-writeback.mjs +540 -0
- package/engine/scripts/wenyao-login.sh +35 -0
- package/engine/scripts/wenyao-template-map.json +292 -0
- package/engine/scripts/wenyao_attach.py +131 -0
- package/engine/scripts/wenyao_download_template.py +144 -0
- package/engine/scripts/wenyao_fill_template.py +568 -0
- package/engine/scripts/wenyao_login.py +145 -0
- package/engine/scripts/wenyao_portal.py +633 -0
- package/engine/scripts/wenyao_recon.py +230 -0
- package/engine/scripts/wenyao_run.py +503 -0
- package/engine/scripts/wenyao_shadow_diff.py +160 -0
- package/engine/scripts/wenyao_skill_contract.py +156 -0
- package/engine/scripts/wenyao_template_fetch.py +175 -0
- package/engine/scripts/win/dsh-web-autostart.ps1 +372 -0
- package/engine/scripts/ziniao-auth-probe.sh +268 -0
- package/engine/scripts/ziniao-keepalive.sh +217 -0
- package/engine/scripts/ziniao-post-upgrade-check.sh +121 -0
- package/engine/scripts/ziniao-windows.sh +154 -0
- package/engine/skills/README.md +86 -0
- package/engine/skills/argent-dev/SKILL.md +41 -0
- package/engine/skills/internal.json +37 -0
- package/engine/skills/lktop-pm/SKILL.md +35 -0
- package/engine/skills/lktopapp-dev/SKILL.md +34 -0
- package/engine/skills/ml-config-guide/SKILL.md +50 -0
- package/engine/skills/ml-fulfillment-sop/SKILL.md +178 -0
- package/engine/skills/ml-fulfillment-sop/references/andes-components.md +69 -0
- package/engine/skills/ml-fulfillment-sop/references/cdp-port-mapping.md +35 -0
- package/engine/skills/ml-fulfillment-sop/references/feishu-base-fulfillment-schema.md +136 -0
- package/engine/skills/ml-fulfillment-sop/references/multi-window-concurrency.md +69 -0
- package/engine/skills/ml-fulfillment-sop/references/pitfalls.md +77 -0
- package/engine/skills/ml-fulfillment-sop/references/selectors.md +243 -0
- package/engine/skills/ml-fulfillment-sop/scripts/fulfillment.js +586 -0
- package/engine/skills/ml-fulfillment-sop/scripts/poll-and-execute.sh +271 -0
- package/engine/skills/ml-fulfillment-sop/templates/report.md +55 -0
- package/engine/skills/ml-invoice/SKILL.md +506 -0
- package/engine/skills/ml-invoice/references/pitfalls.md +268 -0
- package/engine/skills/ml-invoice/references/portal-flow.md +360 -0
- package/engine/skills/ml-invoice/references/template-fill-contract.md +133 -0
- package/engine/skills/ml-invoice-xzh/SKILL.md +133 -0
- package/engine/skills/ml-invoice-xzh/references/pitfalls.md +230 -0
- package/engine/skills/ml-store-daily-report/SKILL.md +224 -0
- package/engine/skills/ml-store-daily-report/scripts/README.md +25 -0
- package/engine/skills/ml-store-daily-report/scripts/extract.js +691 -0
- package/engine/skills/ml-store-daily-report/scripts/generate.js +299 -0
- package/engine/skills/pet-dev/SKILL.md +42 -0
- package/engine/skills/whyshu-pm/SKILL.md +35 -0
- package/engine/skills/whyshu-web-dev/SKILL.md +50 -0
- package/engine/version.mjs +1 -0
- package/engine/verticals/README.md +103 -0
- package/engine/verticals/_engine-words.baseline.txt +237 -0
- package/engine/verticals/_engine-words.json +19 -0
- package/engine/verticals/mercadolibre/README.md +43 -0
- package/engine/verticals/mercadolibre/config.json +709 -0
- package/engine/verticals/mercadolibre/flows.json +738 -0
- package/engine/verticals/mercadolibre/manifest.json +538 -0
- package/engine/verticals/mercadolibre/migrate-machine-state.mjs +138 -0
- package/engine/verticals/mercadolibre/preflight.py +143 -0
- package/engine/verticals/mercadolibre/prompts/daily-report.failure.md +69 -0
- package/engine/verticals/mercadolibre/prompts/daily-report.md +47 -0
- package/engine/verticals/mercadolibre/prompts/daily-report.scheduled.md +79 -0
- package/engine/verticals/mercadolibre/prompts/full.failure.md +125 -0
- package/engine/verticals/mercadolibre/prompts/full.md +91 -0
- package/engine/verticals/mercadolibre/prompts/wenyao-invoice.failure.md +88 -0
- package/engine/verticals/mercadolibre/prompts/wenyao-invoice.md +64 -0
- package/engine/verticals/mercadolibre/prompts/xzh-invoice.failure.md +91 -0
- package/engine/verticals/mercadolibre/prompts/xzh-invoice.md +46 -0
- package/package.json +22 -4
- package/requirements.txt +5 -0
- package/README.md +0 -3
|
@@ -0,0 +1,1090 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""lip 表发票流程编排(2026-09-08,两段式全自动 + 异常介入)
|
|
3
|
+
|
|
4
|
+
复用 invoice_runner.py 已验证逻辑:generate_invoice(鑫中航模板填报)/
|
|
5
|
+
template_addr / clean_hs / fix_drawing_xfrm / 上传鑫中航(阶段4接)。
|
|
6
|
+
lip 专属层:字段映射 / 预检 / 自动补全(hsbianma HS 候选)/ 两段式状态机 /
|
|
7
|
+
成功固化字典。dsh 表路径(invoice_runner)零侵入。
|
|
8
|
+
|
|
9
|
+
流程(每轮 scan / 定向处理):
|
|
10
|
+
FULL状态=已完成 的行:
|
|
11
|
+
├ 货代 ≠ 鑫中航-美墨 → 发票状态=跳过 + 发票错误信息=货代未接入/未填
|
|
12
|
+
├ 发票状态 ∈ {已完成, 跳过, 失败} → 不动
|
|
13
|
+
├ 预检缺口:
|
|
14
|
+
│ 1. SKU 字典缺(品名英文无候选)→ 待勾选+提示(我们补字典)
|
|
15
|
+
│ 2. HS编码 列空:字典已确认 → 字典值回填列 → 继续;
|
|
16
|
+
│ 字典未确认 → hsbianma 查候选预填列 → 待勾选+提示核 HS
|
|
17
|
+
│ 3. 材质列空 → 待勾选+提示补中文材质
|
|
18
|
+
│ 4. 行字段缺(数量/箱数/重量/尺寸)→ 待勾选+提示
|
|
19
|
+
│ 5. 材质英文:词表命中或用字典候选 → ok;都没有 → 待勾选+提示
|
|
20
|
+
├ 字段齐 ∧ (发票状态=未开发票/空 或 待勾选∧发票就绪=true)
|
|
21
|
+
│ → 生成发票(generate_invoice)→ 回填「发票文件」→ 发票状态=填写中
|
|
22
|
+
│ (阶段4 接上传→已上传→标签已下载→已完成+固化)
|
|
23
|
+
用法:
|
|
24
|
+
invoice_lip_runner.py --scan
|
|
25
|
+
invoice_lip_runner.py --record-id <rid> [--generate-only]
|
|
26
|
+
--generate-only 语义(2026-09-22 用户口径):**生成产物并回写「发票相关字段」**
|
|
27
|
+
(发票状态/发票文件/发票错误信息/大货标签 —— 白名单外的字段一律拒绝写入),
|
|
28
|
+
只是**不继续**门户上传 → 标签 → 完成那一段。它写表是预期行为,不是缺陷。
|
|
29
|
+
invoice_lip_runner.py --record-id <rid> --dry-run-pick # 预演:打印判定不写表
|
|
30
|
+
配置: fulfillment.env(FULL_BASE_TOKEN/FULL_TABLE_ID=lip 表;LARK_PROFILE=主 Bot)
|
|
31
|
+
"""
|
|
32
|
+
import argparse
|
|
33
|
+
import json
|
|
34
|
+
import os
|
|
35
|
+
import pathlib
|
|
36
|
+
import re
|
|
37
|
+
import subprocess
|
|
38
|
+
import sys
|
|
39
|
+
import urllib.error
|
|
40
|
+
import urllib.request
|
|
41
|
+
import time
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
|
|
44
|
+
HOME = Path.home()
|
|
45
|
+
DATA = HOME / ".dsh" / "argent"
|
|
46
|
+
ENV_FILE = DATA / "fulfillment.env"
|
|
47
|
+
|
|
48
|
+
# 唯一真源:ML 店铺 → 卖家 user_id(2026-09-19;事故说明见 scripts/ml_store_ids.py 顶部)
|
|
49
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
50
|
+
import ml_store_ids # noqa: E402
|
|
51
|
+
|
|
52
|
+
# ── env 装载(fulfillment.env:表源/身份/词表路径)──
|
|
53
|
+
def _load_env():
|
|
54
|
+
if ENV_FILE.exists():
|
|
55
|
+
for line in ENV_FILE.read_text(encoding="utf-8").splitlines():
|
|
56
|
+
line = line.strip()
|
|
57
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
58
|
+
continue
|
|
59
|
+
k, _, v = line.partition("=")
|
|
60
|
+
os.environ.setdefault(k.strip(), v.strip())
|
|
61
|
+
|
|
62
|
+
_load_env()
|
|
63
|
+
# lark-cli shebang「/usr/bin/env node」需 node 在 PATH(launchd/ssh 最小 PATH 兼容)
|
|
64
|
+
_EXTRA_PATH = [p for p in (f"{HOME}/.local/bin", f"{HOME}/node/bin",
|
|
65
|
+
"/opt/homebrew/bin", "/usr/local/bin")
|
|
66
|
+
if p not in (os.environ.get("PATH") or "").split(":")]
|
|
67
|
+
if _EXTRA_PATH:
|
|
68
|
+
os.environ["PATH"] = ":".join(_EXTRA_PATH + [os.environ.get("PATH", "")])
|
|
69
|
+
|
|
70
|
+
BASE_TOKEN = os.environ.get("FULL_BASE_TOKEN", "")
|
|
71
|
+
TABLE_ID = os.environ.get("FULL_TABLE_ID", "")
|
|
72
|
+
PROFILE = os.environ.get("LARK_PROFILE", "cli_aaf7cafb66b85ccc")
|
|
73
|
+
DICT_FILE = DATA / "lip-sku-dict.json"
|
|
74
|
+
MAT_DICT_FILE = DATA / "lip-material-dict.json"
|
|
75
|
+
OUTPUT_DIR = DATA / "invoice-output-lip"
|
|
76
|
+
LOG_FILE = DATA / "invoice-lip.log"
|
|
77
|
+
SHEET_NAME = "入仓预报" # 鑫中航模板填报 sheet(与 invoice_runner 对齐)
|
|
78
|
+
XINZHONGHANG_STORE = "鑫中航-美墨" # lip 货代列匹配值(运营命名含 -美墨 后缀)
|
|
79
|
+
|
|
80
|
+
# lip 发票字段映射(lip 物理字段集中定义,勿散落)
|
|
81
|
+
F_ST = "发票状态"
|
|
82
|
+
F_READY = "发票就绪"
|
|
83
|
+
F_HS = "HS编码"
|
|
84
|
+
F_MAT = "材质"
|
|
85
|
+
F_FILE = "发票文件"
|
|
86
|
+
F_LABEL = "大货标签"
|
|
87
|
+
F_ERR = "发票错误信息"
|
|
88
|
+
F_FULL_ST = "FULL状态"
|
|
89
|
+
F_SKU = "SKU"
|
|
90
|
+
F_NAME = "产品名称"
|
|
91
|
+
F_QTY = "数量"
|
|
92
|
+
F_BOX = "箱数"
|
|
93
|
+
F_PER_BOX = "装箱数"
|
|
94
|
+
F_WEIGHT = "重量"
|
|
95
|
+
F_LEN = "长"
|
|
96
|
+
F_WID = "宽"
|
|
97
|
+
F_HGT = "高"
|
|
98
|
+
F_ETA = "到货时间"
|
|
99
|
+
F_SHIP = "货件号"
|
|
100
|
+
F_STORE = "店铺"
|
|
101
|
+
F_FREIGHT = "货代公司"
|
|
102
|
+
F_IMG = "产品图"
|
|
103
|
+
F_CHARGE = "是否带电"
|
|
104
|
+
F_MAGNETIC = "是否带磁"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _yes_no(v):
|
|
108
|
+
"""运营填值标准化:lip 单选字段返回 ['是'](数组)——先 _first 取首元素。
|
|
109
|
+
含 是/带/Y/1 → 是;空/否/0 → 否(默认否兜底)。"""
|
|
110
|
+
s = _first(v).lower()
|
|
111
|
+
return "是" if s and s in ("是", "带", "y", "yes", "1", "带电", "带磁") else "否"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def lip_download_attachment(rid, file_token, out_dir):
|
|
115
|
+
"""下载 lip 行附件(产品图)到本地,返回本地路径或 None。"""
|
|
116
|
+
out_dir = Path(out_dir)
|
|
117
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
out = out_dir / f"{file_token}.img"
|
|
119
|
+
if out.exists():
|
|
120
|
+
return out
|
|
121
|
+
r = subprocess.run(
|
|
122
|
+
[LARK_CLI, "--profile", PROFILE, "base", "+record-download-attachment",
|
|
123
|
+
"--base-token", BASE_TOKEN, "--table-id", TABLE_ID,
|
|
124
|
+
"--record-id", rid, "--file-token", file_token,
|
|
125
|
+
"--output", out.name, "--format", "json"],
|
|
126
|
+
capture_output=True, text=True, timeout=120, cwd=str(out_dir))
|
|
127
|
+
return out if out.exists() else None
|
|
128
|
+
|
|
129
|
+
ST_NOT = "未开发票"
|
|
130
|
+
ST_PICK = "待勾选"
|
|
131
|
+
ST_GEN = "待生成"
|
|
132
|
+
ST_FILL = "填写中"
|
|
133
|
+
ST_UPLOADED = "已上传"
|
|
134
|
+
ST_LABEL = "标签已下载"
|
|
135
|
+
ST_DONE = "已完成"
|
|
136
|
+
ST_FAIL = "失败"
|
|
137
|
+
ST_SKIP = "跳过"
|
|
138
|
+
|
|
139
|
+
# ── lark-cli 封装(主 Bot 表读写)──
|
|
140
|
+
def _resolve_lark_cli():
|
|
141
|
+
import shutil
|
|
142
|
+
for cand in (shutil.which("lark-cli") or "",
|
|
143
|
+
f"{HOME}/.local/bin/lark-cli", f"{HOME}/node/bin/lark-cli",
|
|
144
|
+
"/opt/homebrew/bin/lark-cli", "/usr/local/bin/lark-cli"):
|
|
145
|
+
if cand and os.path.isfile(cand) and os.access(cand, os.X_OK):
|
|
146
|
+
return cand
|
|
147
|
+
return "lark-cli"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
LARK_CLI = _resolve_lark_cli()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _cli(*args, timeout=90):
|
|
154
|
+
r = subprocess.run([LARK_CLI, "--profile", PROFILE, *args, "--format", "json"],
|
|
155
|
+
capture_output=True, text=True, timeout=timeout)
|
|
156
|
+
try:
|
|
157
|
+
return json.loads(r.stdout[r.stdout.find("{"):])
|
|
158
|
+
except Exception:
|
|
159
|
+
raise RuntimeError(f"lark-cli 调用失败: {r.stderr[:300] or r.stdout[:300]}")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def lip_list(filter_json=None):
|
|
163
|
+
cmd = ["base", "+record-list", "--base-token", BASE_TOKEN,
|
|
164
|
+
"--table-id", TABLE_ID, "--limit", "200"]
|
|
165
|
+
if filter_json:
|
|
166
|
+
cmd += ["--filter-json", filter_json]
|
|
167
|
+
d = _cli(*cmd)
|
|
168
|
+
data = d.get("data", {})
|
|
169
|
+
fields = data.get("fields", [])
|
|
170
|
+
rows = data.get("data", [])
|
|
171
|
+
ids = data.get("record_id_list", [])
|
|
172
|
+
out = []
|
|
173
|
+
for i, row in enumerate(rows):
|
|
174
|
+
f = dict(zip(fields, row))
|
|
175
|
+
f["_id"] = ids[i] if i < len(ids) else ""
|
|
176
|
+
out.append(f)
|
|
177
|
+
return out
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# 允许写回的字段白名单(2026-09-22 用户口径)
|
|
181
|
+
# 用户明确:**跑流程就是要写 lip 表对应行的发票相关字段**,这是设计而非缺陷。
|
|
182
|
+
# 因此 `--generate-only` 的正确语义是:**生成产物 + 回写发票相关字段**(发票状态/发票文件/
|
|
183
|
+
# 发票错误信息),只是**不继续**门户上传→标签→完成那一段;它写表是预期行为。
|
|
184
|
+
# 守卫要防的是"写到无关字段":patch 里出现白名单外的键 ⇒ 直接拒绝执行(响亮失败),
|
|
185
|
+
# 并落一条审计(谁、哪行、写了哪些字段),便于事后逐字段复核。
|
|
186
|
+
WRITE_FIELDS = {"发票状态", "发票文件", "大货标签", "发票错误信息"}
|
|
187
|
+
AUDIT_DIR = pathlib.Path(os.environ.get("ARGENT_DATA_DIR") or (pathlib.Path.home() / ".dsh" / "argent")) / "invoice-lip-writes"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _audit_write(rids, patch, ok, err=""):
|
|
191
|
+
try:
|
|
192
|
+
AUDIT_DIR.mkdir(parents=True, exist_ok=True)
|
|
193
|
+
rec = {"ts": time.strftime("%Y-%m-%dT%H:%M:%S"), "flow": os.environ.get("ARGENT_FLOW", "xzh-invoice"),
|
|
194
|
+
"records": list(rids), "fields": sorted(patch.keys()), "ok": bool(ok), "error": err[:200]}
|
|
195
|
+
with (AUDIT_DIR / "audit.jsonl").open("a", encoding="utf-8") as fh:
|
|
196
|
+
fh.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
|
197
|
+
except Exception:
|
|
198
|
+
pass # 审计失败不影响主流程(不因日志问题阻断业务)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def lip_update(rids, patch):
|
|
202
|
+
bad = sorted(set(patch.keys()) - WRITE_FIELDS)
|
|
203
|
+
if bad:
|
|
204
|
+
_audit_write(rids, patch, False, f"越界字段: {bad}")
|
|
205
|
+
raise SystemExit(f"❌ 拒绝写回:{bad} 不在发票字段白名单 {sorted(WRITE_FIELDS)} 内"
|
|
206
|
+
f"(只允许改发票相关字段,其余字段由运营/其他流程负责)")
|
|
207
|
+
d = _cli("base", "+record-batch-update", "--base-token", BASE_TOKEN,
|
|
208
|
+
"--table-id", TABLE_ID,
|
|
209
|
+
"--json", json.dumps({"record_id_list": rids, "patch": patch}, ensure_ascii=False))
|
|
210
|
+
_audit_write(rids, patch, d.get("ok", False), "" if d.get("ok") else str(d)[:200])
|
|
211
|
+
return d.get("ok", False)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _fid(name: str) -> str:
|
|
215
|
+
"""字段名 → 字段 id(运行时解析,见 scripts/lip_table.py;查不到则按名传)。"""
|
|
216
|
+
try:
|
|
217
|
+
import sys as _s, pathlib as _pl
|
|
218
|
+
_s.path.insert(0, str(_pl.Path(__file__).resolve().parent))
|
|
219
|
+
import lip_table as _lip
|
|
220
|
+
return _lip.field_id(name, {"flow": "xzh-invoice", "base": BASE_TOKEN,
|
|
221
|
+
"table": TABLE_ID, "profile": PROFILE})
|
|
222
|
+
except Exception:
|
|
223
|
+
return name
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def lip_upload_attachment(rid, field, path):
|
|
227
|
+
"""上传附件(lark-cli 文件路径仅接受 cwd 相对 → 切到文件目录用文件名)。
|
|
228
|
+
|
|
229
|
+
upload-attachment 是追加语义——重复生成会累积重复附件;上传成功后用
|
|
230
|
+
patch 覆盖字段为单条(file_token 数组),保证字段恒为最新一份。
|
|
231
|
+
"""
|
|
232
|
+
path = Path(path)
|
|
233
|
+
r = subprocess.run(
|
|
234
|
+
[LARK_CLI, "--profile", PROFILE, "base", "+record-upload-attachment",
|
|
235
|
+
"--base-token", BASE_TOKEN, "--table-id", TABLE_ID,
|
|
236
|
+
"--record-id", rid, "--field-id", field, "--file", path.name,
|
|
237
|
+
"--format", "json"],
|
|
238
|
+
capture_output=True, text=True, timeout=120, cwd=str(path.parent))
|
|
239
|
+
try:
|
|
240
|
+
d = json.loads(r.stdout[r.stdout.find("{"):])
|
|
241
|
+
if not d.get("ok"):
|
|
242
|
+
return False
|
|
243
|
+
atts = (d.get("data", {}).get("attachments", {}).get(rid, {})
|
|
244
|
+
# 2026-09-20(迭代1-残留项):字段 id 运行时解析(原写死 fldvmra3Rp ⇒ 表相关)
|
|
245
|
+
.get(_fid("发票文件"), []))
|
|
246
|
+
if atts:
|
|
247
|
+
last = atts[-1]
|
|
248
|
+
lip_update([rid], {field: [{"file_token": last.get("file_token"),
|
|
249
|
+
"name": last.get("name", path.name)}]})
|
|
250
|
+
return True
|
|
251
|
+
except Exception:
|
|
252
|
+
return False
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def log(msg):
|
|
256
|
+
line = f"[{time.strftime('%F %T')}] {msg}"
|
|
257
|
+
print(line, file=sys.stderr, flush=True)
|
|
258
|
+
try:
|
|
259
|
+
with open(LOG_FILE, "a", encoding="utf-8") as f:
|
|
260
|
+
f.write(line + "\n")
|
|
261
|
+
except Exception:
|
|
262
|
+
pass
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# ── PM 手动接管(2026-09-10):发票线三处改造 ──
|
|
266
|
+
# 用户决定:① 不再发飞书通知(表格「发票错误信息」即唯一提示面)
|
|
267
|
+
# ② AI 补录用本地品名词表自愈,不阻塞拾取流程
|
|
268
|
+
# ③ 大货标签未就绪不再永久失败(落可续跑态「已上传」,每轮只补标签)
|
|
269
|
+
PRODUCT_DICT_FILE = DATA / "lip-product-dict.json"
|
|
270
|
+
# 全新品名实时翻译(PM 2026-09-10):走 whyshu.com 兼容代理(客户机已有 apiKey),
|
|
271
|
+
# 实测 deepseek-v4-flash + reasoning_effort=none ≈ 1.1s 出结构化 JSON;结果落词表缓存,重复品名零成本。
|
|
272
|
+
LLM_BASE = os.environ.get("ARGENT_LLM_BASE", "") or "https://whyshu.com/api/argent/v1"
|
|
273
|
+
LLM_MODEL = os.environ.get("ARGENT_LLM_MODEL", "") or "deepseek-v4-flash"
|
|
274
|
+
LLM_TIMEOUT = int(os.environ.get("ARGENT_LLM_TIMEOUT", "120"))
|
|
275
|
+
LABEL_RETRY_FILE = DATA / "invoice-lip-label-retry.jsonl"
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# ── A. 字典自愈:中文品名 → 英文品名(本地词表命中即补字典,本轮继续开票)──
|
|
279
|
+
def load_product_dict():
|
|
280
|
+
try:
|
|
281
|
+
return json.loads(PRODUCT_DICT_FILE.read_text(encoding="utf-8"))
|
|
282
|
+
except Exception:
|
|
283
|
+
return {}
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _write_dict_entry(sku, entry):
|
|
287
|
+
"""原子写字典单条(tmp + rename),避免半写坏字典。"""
|
|
288
|
+
d = load_dict()
|
|
289
|
+
d[sku] = entry
|
|
290
|
+
tmp = DATA / ".lip-sku-dict.json.tmp"
|
|
291
|
+
try:
|
|
292
|
+
tmp.write_text(json.dumps(d, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
293
|
+
os.replace(tmp, DATA / "lip-sku-dict.json")
|
|
294
|
+
return True
|
|
295
|
+
except Exception as exc:
|
|
296
|
+
log(f"字典写入失败 {sku}: {exc}")
|
|
297
|
+
return False
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _norm_name(name):
|
|
301
|
+
"""品名规范化(去掉 <br> 等抓取残留与多余空白),用于词表查找。"""
|
|
302
|
+
name = re.sub(r"<br\s*/?>", "", name or "", flags=re.I)
|
|
303
|
+
return re.sub(r"\s+", " ", name).strip()
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _llm_key():
|
|
307
|
+
key = os.environ.get("WHYSHU_API_KEY", "")
|
|
308
|
+
if key:
|
|
309
|
+
return key
|
|
310
|
+
try:
|
|
311
|
+
return json.loads((DATA / "config.json").read_text(encoding="utf-8")).get("apiKey", "")
|
|
312
|
+
except Exception:
|
|
313
|
+
return ""
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def llm_translate_product(name_zh, mat_zh="", hs_hint=""):
|
|
317
|
+
"""中文品名 → 报关用英文品名/材质英文/HS 候选(实时 LLM,失败返回 None、不阻塞流程)。"""
|
|
318
|
+
name_zh = (name_zh or "").strip()
|
|
319
|
+
key = _llm_key()
|
|
320
|
+
if not name_zh or not key:
|
|
321
|
+
return None
|
|
322
|
+
prompt = (
|
|
323
|
+
"你是跨境电商报关资料助手。把中文品名翻译成申报用英文品名(简洁,含关键规格/颜色/材质),"
|
|
324
|
+
"并给出材质英文与 10 位中国海关编码。只输出 JSON,无其他文字:\n"
|
|
325
|
+
'{"品名英文":"...","材质英文":"...","海关编码":"..."}\n'
|
|
326
|
+
f"中文品名:{name_zh}"
|
|
327
|
+
+ (f"(材质:{mat_zh})" if mat_zh else "")
|
|
328
|
+
+ (f"\n参考HS:{hs_hint}" if hs_hint else "")
|
|
329
|
+
)
|
|
330
|
+
body = json.dumps({"model": LLM_MODEL, "temperature": 0, "max_tokens": 400,
|
|
331
|
+
"reasoning_effort": "none",
|
|
332
|
+
"messages": [{"role": "user", "content": prompt}]}).encode("utf-8")
|
|
333
|
+
req = urllib.request.Request(LLM_BASE.rstrip("/") + "/chat/completions", data=body,
|
|
334
|
+
headers={"Authorization": f"Bearer {key}",
|
|
335
|
+
"Content-Type": "application/json"})
|
|
336
|
+
try:
|
|
337
|
+
with urllib.request.urlopen(req, timeout=LLM_TIMEOUT) as resp:
|
|
338
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
339
|
+
except Exception as exc:
|
|
340
|
+
log(f"LLM 翻译失败({name_zh}):{type(exc).__name__} {str(exc)[:120]}")
|
|
341
|
+
return None
|
|
342
|
+
content = ((data.get("choices") or [{}])[0].get("message") or {}).get("content") or ""
|
|
343
|
+
m = re.search(r"\{.*\}", content, re.S)
|
|
344
|
+
if not m:
|
|
345
|
+
log(f"LLM 翻译未返回 JSON({name_zh}):{content.strip()[:80]}")
|
|
346
|
+
return None
|
|
347
|
+
try:
|
|
348
|
+
d = json.loads(m.group(0))
|
|
349
|
+
except json.JSONDecodeError:
|
|
350
|
+
return None
|
|
351
|
+
en = str(d.get("品名英文") or "").strip()
|
|
352
|
+
if not en:
|
|
353
|
+
return None
|
|
354
|
+
cand = {"品名英文": en,
|
|
355
|
+
"材质英文": str(d.get("材质英文") or "").strip(),
|
|
356
|
+
"海关编码": re.sub(r"\D", "", str(d.get("海关编码") or ""))[:10],
|
|
357
|
+
"_源": "LLM 实时翻译"}
|
|
358
|
+
log(f"LLM 实时翻译:{name_zh} → {en}(HS候选 {cand['海关编码'] or '-'})")
|
|
359
|
+
return cand
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def cache_product_dict(name_zh, cand):
|
|
363
|
+
"""把翻译结果写入本地品名词表(原子写),后续同名品名零成本命中。"""
|
|
364
|
+
name_zh = _norm_name(name_zh)
|
|
365
|
+
if not name_zh or not cand.get("品名英文"):
|
|
366
|
+
return
|
|
367
|
+
d = load_product_dict()
|
|
368
|
+
d[name_zh] = {"品名英文": cand["品名英文"], "海关编码": cand.get("海关编码", ""),
|
|
369
|
+
"材质英文": cand.get("材质英文", ""), "confirmed": False,
|
|
370
|
+
"来源": "LLM 实时翻译 " + time.strftime("%Y-%m-%d")}
|
|
371
|
+
tmp = DATA / ".lip-product-dict.json.tmp"
|
|
372
|
+
try:
|
|
373
|
+
tmp.write_text(json.dumps(d, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
374
|
+
os.replace(tmp, PRODUCT_DICT_FILE)
|
|
375
|
+
except Exception as exc:
|
|
376
|
+
log(f"词表缓存写入失败({name_zh}):{exc}")
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def heal_dict_from_product(row, sku_dict):
|
|
380
|
+
"""字典缺品名英文 → 用本地中英品名词表自愈(无需等 agent/人)。
|
|
381
|
+
|
|
382
|
+
命中:写字典条目(confirmed=False、来源标注)+ 更新内存 dict,返回英文品名;
|
|
383
|
+
未命中:返回 None(保持缺口文案,下一轮继续试)。
|
|
384
|
+
"""
|
|
385
|
+
sku = _first(row.get(F_SKU))
|
|
386
|
+
if not sku or (sku_dict.get(sku) or {}).get("品名英文"):
|
|
387
|
+
return None
|
|
388
|
+
name_zh = _norm_name(_first(row.get(F_NAME)))
|
|
389
|
+
pdict = load_product_dict()
|
|
390
|
+
cand = pdict.get(name_zh) or {}
|
|
391
|
+
if not cand: # 兜底:按规范化键再试一轮
|
|
392
|
+
cand = next((v for k, v in pdict.items() if _norm_name(k) == name_zh), {})
|
|
393
|
+
if not (cand.get("品名英文") or "").strip():
|
|
394
|
+
# 全新品名:实时 LLM 翻译(≈1s),结果落词表缓存
|
|
395
|
+
cand = llm_translate_product(name_zh, _first(row.get(F_MAT)), _first(row.get(F_HS))) or {}
|
|
396
|
+
if cand:
|
|
397
|
+
cache_product_dict(name_zh, cand)
|
|
398
|
+
en = (cand.get("品名英文") or "").strip()
|
|
399
|
+
if not en:
|
|
400
|
+
return None
|
|
401
|
+
src = cand.get("_源") or ("LLM 实时翻译" if str(cand.get("来源", "")).startswith("LLM") else "本地品名词表自愈")
|
|
402
|
+
entry = {
|
|
403
|
+
"品名英文": en,
|
|
404
|
+
"海关编码": (cand.get("海关编码") or _first(row.get(F_HS)) or "").strip(),
|
|
405
|
+
"材质英文": (cand.get("材质英文") or translate_material(_first(row.get(F_MAT))) or "").strip(),
|
|
406
|
+
"confirmed": False,
|
|
407
|
+
"来源": f"{src} {time.strftime('%Y-%m-%d')}",
|
|
408
|
+
"备注": "PM 接管自愈;发票跑通后固化",
|
|
409
|
+
}
|
|
410
|
+
if _write_dict_entry(sku, entry):
|
|
411
|
+
sku_dict[sku] = entry
|
|
412
|
+
log(f"{row.get('_id', '')} 字典自愈:{sku}({name_zh})→ {en}(confirmed=false,跑通后固化)")
|
|
413
|
+
return en
|
|
414
|
+
return None
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
# ── B. 不重复写表:值与当前一致时跳过写(减少无谓表写)──
|
|
418
|
+
def lip_update_if_changed(row, patch):
|
|
419
|
+
rid = row.get("_id", "")
|
|
420
|
+
changed = {k: v for k, v in patch.items() if _first(row.get(k)) != v}
|
|
421
|
+
if not changed:
|
|
422
|
+
return False
|
|
423
|
+
lip_update([rid], changed)
|
|
424
|
+
return True
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
# ── C. 大货标签「可续跑」:写表节流 + 失败可恢复 ──
|
|
428
|
+
# 实测(2026-09-10 15:19):刚上传的发票,鑫中航订单页搜不到该货件(数据行=0)
|
|
429
|
+
# → 大货标签暂不可下载;原实现直接写「失败」而 handle_row 对「失败」一律跳过 ⇒ 永久卡死等人工。
|
|
430
|
+
# 改为:发票状态=已上传(可续跑),每轮只补标签;写表按尝试次数节流(1/5 次,之后每 20 次)。
|
|
431
|
+
LABEL_WRITE_AT = (1, 5)
|
|
432
|
+
LABEL_WRITE_EVERY = 20
|
|
433
|
+
LABEL_HUMAN_HINT_AT = 20
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _label_attempt_count(rid):
|
|
437
|
+
n = 0
|
|
438
|
+
try:
|
|
439
|
+
for line in LABEL_RETRY_FILE.read_text(encoding="utf-8").splitlines():
|
|
440
|
+
line = line.strip()
|
|
441
|
+
if not line:
|
|
442
|
+
continue
|
|
443
|
+
try:
|
|
444
|
+
d = json.loads(line)
|
|
445
|
+
except json.JSONDecodeError:
|
|
446
|
+
continue
|
|
447
|
+
if d.get("record_id") == rid:
|
|
448
|
+
n += 1
|
|
449
|
+
except Exception:
|
|
450
|
+
n = 0
|
|
451
|
+
return n
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _append_label_attempt(rid, n, detail=""):
|
|
455
|
+
try:
|
|
456
|
+
LABEL_RETRY_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
457
|
+
with open(LABEL_RETRY_FILE, "a", encoding="utf-8") as f:
|
|
458
|
+
f.write(json.dumps({
|
|
459
|
+
"ts": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
460
|
+
"record_id": rid, "attempt": n, "detail": (detail or "")[-200:],
|
|
461
|
+
}, ensure_ascii=False) + "\n")
|
|
462
|
+
except Exception:
|
|
463
|
+
pass
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _label_wait_text(n):
|
|
467
|
+
if n >= LABEL_HUMAN_HINT_AT:
|
|
468
|
+
return (f"发票已上传;鑫中航暂无该大货订单/标签(已尝试 {n} 次,系统继续自动重试)"
|
|
469
|
+
f"——请运营确认鑫中航是否已生成该单")
|
|
470
|
+
return f"发票已上传;鑫中航暂无该大货订单/标签(第 {n} 次尝试,系统每轮自动重试)"
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _first(v):
|
|
474
|
+
if v is None:
|
|
475
|
+
return ""
|
|
476
|
+
if isinstance(v, list):
|
|
477
|
+
return str(v[0]).strip() if v and v[0] is not None else ""
|
|
478
|
+
return str(v).strip()
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _num(v):
|
|
482
|
+
s = _first(v)
|
|
483
|
+
try:
|
|
484
|
+
return float(s) if s else 0.0
|
|
485
|
+
except ValueError:
|
|
486
|
+
return 0.0
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _num_ok(v):
|
|
490
|
+
return _num(v) > 0
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
# ── 字典与词表 ──
|
|
494
|
+
def load_dict():
|
|
495
|
+
if DICT_FILE.exists():
|
|
496
|
+
return json.loads(DICT_FILE.read_text(encoding="utf-8"))
|
|
497
|
+
return {}
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def save_dict(d):
|
|
501
|
+
DICT_FILE.write_text(json.dumps(d, ensure_ascii=False, indent=1), encoding="utf-8")
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def load_mat_dict():
|
|
505
|
+
if MAT_DICT_FILE.exists():
|
|
506
|
+
return json.loads(MAT_DICT_FILE.read_text(encoding="utf-8"))
|
|
507
|
+
return {}
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def translate_material(zh):
|
|
511
|
+
"""中文材质 → 英文(词表)。多材质(含 '/' '、' '+')分段翻译。"""
|
|
512
|
+
md = load_mat_dict()
|
|
513
|
+
zh = str(zh or "").strip()
|
|
514
|
+
if not zh:
|
|
515
|
+
return ""
|
|
516
|
+
parts = re.split(r"[\/、+]+", zh)
|
|
517
|
+
en = []
|
|
518
|
+
for p in parts:
|
|
519
|
+
p = p.strip()
|
|
520
|
+
if not p:
|
|
521
|
+
continue
|
|
522
|
+
if p in md:
|
|
523
|
+
en.append(md[p])
|
|
524
|
+
else:
|
|
525
|
+
return "" # 未命中整段视为缺(由调用方提示)
|
|
526
|
+
return ", ".join(en)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
# ── hsbianma 自动查询(headless,复用批量脚本验证逻辑)──
|
|
530
|
+
def _resolve_browser():
|
|
531
|
+
"""浏览器路径探测:Chrome → Edge(国内环境常用,Chromium 同内核 playwright 兼容)。"""
|
|
532
|
+
cands = [
|
|
533
|
+
os.environ.get("INVOICE_BROWSER", ""),
|
|
534
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
535
|
+
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
536
|
+
]
|
|
537
|
+
for c in cands:
|
|
538
|
+
if c and os.path.exists(c):
|
|
539
|
+
return c
|
|
540
|
+
return ""
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
CHROME = _resolve_browser()
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def hsbianma_query(name):
|
|
547
|
+
"""按商品名查 hsbianma 返回候选编码列表(10 位)。失败/无结果返回 []。"""
|
|
548
|
+
if not CHROME:
|
|
549
|
+
log("浏览器缺失(Chrome/Edge 未安装),HS 自动查询不可用")
|
|
550
|
+
return []
|
|
551
|
+
from playwright.sync_api import sync_playwright
|
|
552
|
+
out = []
|
|
553
|
+
try:
|
|
554
|
+
with sync_playwright() as p:
|
|
555
|
+
b = p.chromium.launch(headless=True, executable_path=CHROME,
|
|
556
|
+
args=["--no-sandbox"])
|
|
557
|
+
pg = b.new_page()
|
|
558
|
+
pg.goto("https://www.hsbianma.com/", timeout=20000,
|
|
559
|
+
wait_until="domcontentloaded")
|
|
560
|
+
pg.wait_for_timeout(1500)
|
|
561
|
+
box = pg.locator("input[type=text], input[placeholder*=搜索], "
|
|
562
|
+
"input[placeholder*=编码]").first
|
|
563
|
+
box.fill(name)
|
|
564
|
+
box.press("Enter")
|
|
565
|
+
pg.wait_for_timeout(3500)
|
|
566
|
+
rows = pg.locator("table tr").all()[:6]
|
|
567
|
+
for r in rows:
|
|
568
|
+
cells = [c.strip() for c in r.locator("td").all_inner_texts()]
|
|
569
|
+
if cells and re.fullmatch(r"\d{8,12}", cells[0]):
|
|
570
|
+
out.append(cells[0])
|
|
571
|
+
b.close()
|
|
572
|
+
except Exception as exc:
|
|
573
|
+
log(f"hsbianma 查询失败 {name}: {exc}")
|
|
574
|
+
return out
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
# ── 预检与自动补全 ──
|
|
578
|
+
def precheck(row, sku_dict):
|
|
579
|
+
"""返回缺口列表 [] 或 [缺项,...]。sku_dict 值:None=字典无 / dict=候选。"""
|
|
580
|
+
gaps = []
|
|
581
|
+
sku = _first(row.get(F_SKU))
|
|
582
|
+
sd = sku_dict.get(sku)
|
|
583
|
+
if sd is None or not sd.get("品名英文"):
|
|
584
|
+
if _name_usable(row):
|
|
585
|
+
gaps.append("SKU字典缺品名英文(待AI补录)")
|
|
586
|
+
else:
|
|
587
|
+
# 产品名称=SKU 本身/空 → 属运营待办(用户 2026-09-10 口径),我方不自愈不编造
|
|
588
|
+
gaps.append(GAP_NEED_NAME)
|
|
589
|
+
if not _first(row.get(F_HS)):
|
|
590
|
+
if sd and sd.get("confirmed") and sd.get("海关编码"):
|
|
591
|
+
pass # HS 列空但字典已确认 → 由补全步骤回填字典值
|
|
592
|
+
else:
|
|
593
|
+
gaps.append("HS编码待预填/核对")
|
|
594
|
+
if not _first(row.get(F_MAT)):
|
|
595
|
+
gaps.append("缺中文材质(运营补)")
|
|
596
|
+
if not _num_ok(row.get(F_QTY)):
|
|
597
|
+
gaps.append("缺数量")
|
|
598
|
+
if not _num_ok(row.get(F_BOX)):
|
|
599
|
+
gaps.append("缺箱数")
|
|
600
|
+
if not _num_ok(row.get(F_WEIGHT)):
|
|
601
|
+
gaps.append("缺重量")
|
|
602
|
+
if not (_num_ok(row.get(F_LEN)) and _num_ok(row.get(F_WID))
|
|
603
|
+
and _num_ok(row.get(F_HGT))):
|
|
604
|
+
gaps.append("缺尺寸")
|
|
605
|
+
return gaps
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
# ── KB-125:缺口分型(我方动作 / 运营动作)──
|
|
609
|
+
# 我方动作缺口(补字典 / 预填核对 HS)——运营勾「发票就绪」也无法推进,
|
|
610
|
+
# 文案必须区别于运营动作缺口,避免再次出现「勾了就绪无反应」的困惑。
|
|
611
|
+
GAP_NEED_NAME = "缺产品名称(表里填的是 SKU 本身,运营补)"
|
|
612
|
+
|
|
613
|
+
OUR_GAPS = {
|
|
614
|
+
"SKU字典缺品名英文(待AI补录)",
|
|
615
|
+
"HS编码待预填/核对",
|
|
616
|
+
"HS自动查询无结果,请人工查填",
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def _name_usable(row):
|
|
621
|
+
"""产品名称是否可用于生成英文品名。
|
|
622
|
+
|
|
623
|
+
运营常见填法:留空、直接填 SKU(如 HW-MX-091)、填纯编码 → 均视为「需运营补真实品名」,
|
|
624
|
+
不做自愈(不编造品名);只有真实中文/英文品名才走我方字典补录/自愈。
|
|
625
|
+
"""
|
|
626
|
+
name = _norm_name(_first(row.get(F_NAME)))
|
|
627
|
+
if not name:
|
|
628
|
+
return False
|
|
629
|
+
sku = _norm_name(_first(row.get(F_SKU)))
|
|
630
|
+
if sku and name.upper().replace(" ", "") == sku.upper().replace(" ", ""):
|
|
631
|
+
return False
|
|
632
|
+
# 纯编码形态(无中文、无空格、像型号):HW-MX-091 / HW-XXX-123-1 等
|
|
633
|
+
if re.fullmatch(r"[A-Za-z0-9._/-]+", name) and re.search(r"\d", name):
|
|
634
|
+
return False
|
|
635
|
+
return True
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def split_gaps(gaps):
|
|
639
|
+
"""缺口按动作方拆分 → (我方缺口, 运营缺口)。"""
|
|
640
|
+
ours = [g for g in gaps if g in OUR_GAPS]
|
|
641
|
+
ops = [g for g in gaps if g not in OUR_GAPS]
|
|
642
|
+
return ours, ops
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def gap_hint(gaps):
|
|
646
|
+
"""缺口尾部指引(KB-125 B-1):我方动作 → 明确「无需运营操作」。"""
|
|
647
|
+
ours, ops = split_gaps(gaps)
|
|
648
|
+
if ours and ops:
|
|
649
|
+
return "(我方补录;运营补齐字段后勾选发票就绪)"
|
|
650
|
+
if ours:
|
|
651
|
+
return "(我方补录,无需运营操作)"
|
|
652
|
+
return "(处理完勾选发票就绪)"
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def fill_hs_candidate(row, sku_dict):
|
|
656
|
+
"""HS编码 列空时自动补全:字典已确认→回填字典值;未确认→hsbianma 查候选预填。
|
|
657
|
+
返回 (动作文案, 是否已填候选)。"""
|
|
658
|
+
sku = _first(row.get(F_SKU))
|
|
659
|
+
sd = sku_dict.get(sku) or {}
|
|
660
|
+
name = _first(row.get(F_NAME)) or sku
|
|
661
|
+
if sd.get("confirmed") and sd.get("海关编码"):
|
|
662
|
+
lip_update([row["_id"]], {F_HS: sd["海关编码"]})
|
|
663
|
+
return f"HS已回填字典确认值 {sd['海关编码']}", True
|
|
664
|
+
cands = hsbianma_query(name.split("-")[0].split("(")[0].strip())
|
|
665
|
+
if cands:
|
|
666
|
+
lip_update([row["_id"]], {F_HS: cands[0]})
|
|
667
|
+
return f"HS候选已预填 {cands[0]}(共{len(cands)}条候选,请核对)", True
|
|
668
|
+
return "HS自动查询无结果,请人工查填", False
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
# ── 生成发票(组装 record → 复用 invoice_runner.generate_invoice)──
|
|
672
|
+
def generate_for_row(row):
|
|
673
|
+
import importlib.util
|
|
674
|
+
spec = importlib.util.spec_from_file_location(
|
|
675
|
+
"invoice_runner", str(Path(__file__).parent / "invoice_runner.py"))
|
|
676
|
+
ir = importlib.util.module_from_spec(spec)
|
|
677
|
+
spec.loader.exec_module(ir)
|
|
678
|
+
mapping = ir.load_mapping()
|
|
679
|
+
sku = _first(row.get(F_SKU))
|
|
680
|
+
sd = (load_dict().get(sku) or {})
|
|
681
|
+
hs = _first(row.get(F_HS)) or (sd.get("海关编码") if sd.get("confirmed") else "")
|
|
682
|
+
mat_zh = _first(row.get(F_MAT))
|
|
683
|
+
mat_en = translate_material(mat_zh) or (sd.get("材质英文") or "")
|
|
684
|
+
# 组装 generate_invoice 的 record dict(对齐 dsh 版消费字段)
|
|
685
|
+
store = _first(row.get(F_STORE))
|
|
686
|
+
# ⚠️ 这里**刻意不再读 stores.json 的紫鸟 store_id**:它是另一个 ID 体系
|
|
687
|
+
# (浏览器店铺 ID ≠ ML 卖家 ID),历史上正是它被当"兜底值"写进发票(见下方事故说明)。
|
|
688
|
+
# ── PO Number = ML 卖家 user_id(2026-09-19 收敛为唯一真源 + 响亮失败)──────────────
|
|
689
|
+
# 历史事故:这里曾按 `lip-po-mapping.json`(从未创建)→ 回退 `~/.dsh/argent/stores.json`
|
|
690
|
+
# 的**紫鸟 store_id**(浏览器店铺 ID,如 27494792824433)。那是另一个 ID 体系,
|
|
691
|
+
# 写进发票就是错的(应为 ML 卖家 ID,如 2475636269)—— 6 张发票因此写错且无人发现。
|
|
692
|
+
# 另一个失效源:invoice-mapping.json#storeId 的键是短名 `1店-子账号`,lip 的完整店名
|
|
693
|
+
# `墨西哥-1店-子账号` 根本匹配不上。
|
|
694
|
+
# 现在:**只认 scripts/ml-store-ids.json**(键=国家-店号,主/子账号共用同一 ID);
|
|
695
|
+
# 解析不到 ⇒ **不发发票**(写「发票错误信息」,交人补配置),**绝不用别的 ID 顶上**。
|
|
696
|
+
store_ml = ml_store_ids.resolve(store)
|
|
697
|
+
if not store_ml:
|
|
698
|
+
# 原因写清楚(用户 2026-09-19 口径:「巴西-1店当前是没有给到账号的,写明原因即可」):
|
|
699
|
+
# 这是**已知的等待态**(等运营给账号),不是系统故障 ⇒ 措辞是"陈述+下一步"。
|
|
700
|
+
msg = (f"{ml_store_ids.missing_reason(store)} ⇒ 本行**暂不自动开发票**"
|
|
701
|
+
f"(拿到账号 ID 后加入 scripts/ml-store-ids.json 即会自动跑;"
|
|
702
|
+
f"**不会**用紫鸟 store_id 顶替 PO)")
|
|
703
|
+
log(f"❌ {msg}")
|
|
704
|
+
try:
|
|
705
|
+
if row.get("_id"):
|
|
706
|
+
lip_update_if_changed(row, {F_ERR: msg})
|
|
707
|
+
except Exception as exc:
|
|
708
|
+
log(f"⚠️ 回填「发票错误信息」失败: {exc}")
|
|
709
|
+
return f"{_first(row.get('_id')) or '(未知记录)'} 缺 ML 卖家 ID(未生成发票)"
|
|
710
|
+
mapping.setdefault("storeId", {})[store] = store_ml
|
|
711
|
+
|
|
712
|
+
# B6/B7 带电/带磁:读 lip「是否带电/是否带磁」列(运营新增,默认否兜底),
|
|
713
|
+
# 覆盖 defaults 后 generate_invoice 填模板 R6/R7(B6 带电 / B7 带磁)。
|
|
714
|
+
mapping.setdefault("defaults", {})["带电"] = _yes_no(row.get(F_CHARGE))
|
|
715
|
+
mapping.setdefault("defaults", {})["带磁"] = _yes_no(row.get(F_MAGNETIC))
|
|
716
|
+
record = {
|
|
717
|
+
"货件号": _first(row.get(F_SHIP)),
|
|
718
|
+
"品名": _first(row.get(F_NAME)),
|
|
719
|
+
"数量": _first(row.get(F_QTY)),
|
|
720
|
+
"箱数": _first(row.get(F_BOX)),
|
|
721
|
+
"店铺名称": store,
|
|
722
|
+
"国内收货仓库": mapping.get("defaults", {}).get("国内收货仓库", "深圳仓"),
|
|
723
|
+
"到达时间": _first(row.get(F_ETA)) or "本周",
|
|
724
|
+
"备注": "",
|
|
725
|
+
"sku_data": {
|
|
726
|
+
"品名英文": sd.get("品名英文") or "",
|
|
727
|
+
"海关编码": hs,
|
|
728
|
+
"材质中文": mat_zh,
|
|
729
|
+
"材质英文": mat_en,
|
|
730
|
+
"重量(kg/箱)": _first(row.get(F_WEIGHT)),
|
|
731
|
+
"长(cm)": _first(row.get(F_LEN)),
|
|
732
|
+
"宽(cm)": _first(row.get(F_WID)),
|
|
733
|
+
"高(cm)": _first(row.get(F_HGT)),
|
|
734
|
+
"产品图": [],
|
|
735
|
+
},
|
|
736
|
+
}
|
|
737
|
+
tpl = mapping.get("templates", {}).get("鑫中航", "")
|
|
738
|
+
if not tpl or not os.path.exists(tpl):
|
|
739
|
+
raise RuntimeError(f"鑫中航模板缺失: {tpl}")
|
|
740
|
+
import openpyxl
|
|
741
|
+
wb = openpyxl.load_workbook(tpl)
|
|
742
|
+
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
743
|
+
out = OUTPUT_DIR / f"{_first(row.get(F_SHIP))}-发票.xlsx"
|
|
744
|
+
ir.generate_invoice(record, mapping, wb, out)
|
|
745
|
+
# 产品图(lip「产品图」列附件)→ 插图(复用 dsh 版锚点逻辑;lip 附件从本表下载)
|
|
746
|
+
imgs = row.get(F_IMG) or []
|
|
747
|
+
if isinstance(imgs, list) and imgs and isinstance(imgs[0], dict):
|
|
748
|
+
ft = str(imgs[0].get("file_token", ""))
|
|
749
|
+
if ft:
|
|
750
|
+
local_img = lip_download_attachment(row["_id"], ft, DATA / "invoice-tmp-lip")
|
|
751
|
+
if local_img:
|
|
752
|
+
from openpyxl.drawing.image import Image as XLImage
|
|
753
|
+
from openpyxl.drawing.spreadsheet_drawing import TwoCellAnchor, AnchorMarker
|
|
754
|
+
wb2 = openpyxl.load_workbook(out)
|
|
755
|
+
ws2 = wb2[SHEET_NAME]
|
|
756
|
+
ws2._images.clear()
|
|
757
|
+
ws2.row_dimensions[22].height = 93
|
|
758
|
+
xl_img = XLImage(str(local_img))
|
|
759
|
+
xl_img.anchor = TwoCellAnchor(
|
|
760
|
+
editAs="oneCell",
|
|
761
|
+
_from=AnchorMarker(col=17, colOff=210185, row=21, rowOff=265430),
|
|
762
|
+
to=AnchorMarker(col=17, colOff=1675765, row=21, rowOff=880745),
|
|
763
|
+
)
|
|
764
|
+
ws2.add_image(xl_img)
|
|
765
|
+
wb2.save(str(out))
|
|
766
|
+
try:
|
|
767
|
+
ir.fix_drawing_xfrm(out, ws2, 0, 0, 0, 0, 0)
|
|
768
|
+
except Exception:
|
|
769
|
+
pass
|
|
770
|
+
return out
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
# ── 主流程 ──
|
|
774
|
+
def _run_stageb(args):
|
|
775
|
+
"""调 invoice_stageb.py(同目录,当前解释器)。返回 stdout(失败时附 stderr 末尾)。
|
|
776
|
+
|
|
777
|
+
注意:必须用带 playwright 的解释器(客户机为 /tmp/pw-venv/bin/python3)运行本
|
|
778
|
+
脚本;KB-125 实测:用系统 python3 跑时 stageb 抛 ModuleNotFoundError → stdout
|
|
779
|
+
为空 → 下游误判为「标签下载失败」。故此处把 stderr 末尾一并回传(可诊断)。
|
|
780
|
+
"""
|
|
781
|
+
stageb = str(Path(__file__).parent / "invoice_stageb.py")
|
|
782
|
+
r = subprocess.run([sys.executable, stageb, *args],
|
|
783
|
+
capture_output=True, text=True, timeout=300)
|
|
784
|
+
out = r.stdout or ""
|
|
785
|
+
if r.returncode != 0 and (r.stderr or "").strip():
|
|
786
|
+
tail = (r.stderr or "").strip().splitlines()[-3:]
|
|
787
|
+
out = (out + "\n" + "\n".join(tail)).strip()
|
|
788
|
+
log(f"stageb {' '.join(args[:1])} 退出码={r.returncode}: {tail[-1][:200]}")
|
|
789
|
+
return out
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def _confirm_dict(sku, hs, mat_en, name_en=None):
|
|
793
|
+
"""发票流程跑通后固化字典(confirmed=true,本次实际使用值)。"""
|
|
794
|
+
d = load_dict()
|
|
795
|
+
cur = d.get(sku) or {}
|
|
796
|
+
d[sku] = {"品名英文": name_en or cur.get("品名英文", ""),
|
|
797
|
+
"海关编码": hs or cur.get("海关编码", ""),
|
|
798
|
+
"材质英文": mat_en or cur.get("材质英文", ""),
|
|
799
|
+
"confirmed": True, "来源": "实际跑通固化 " + time.strftime("%Y-%m-%d"),
|
|
800
|
+
"备注": ""}
|
|
801
|
+
save_dict(d)
|
|
802
|
+
log(f"字典固化: {sku} confirmed=true (HS={hs})")
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def _upload_and_finish(row, out, generate_only=False, dry=False):
|
|
806
|
+
"""上传鑫中航 → 下载大货标签 → 回填 → 已完成 + 固化。返回动作描述。
|
|
807
|
+
|
|
808
|
+
KB-125 修复(阻断性):原先内部调 `_finish_with_label(row, dry=dry)` 但本函数
|
|
809
|
+
无 dry 形参 → `NameError: name 'dry' is not defined`,导致「新上传成功后」的路径
|
|
810
|
+
整行处理异常(实测 recvuIaAFow03Z 2026-09-10 15:36)。此处补形参并透传。
|
|
811
|
+
"""
|
|
812
|
+
rid = row["_id"]
|
|
813
|
+
sku = _first(row.get(F_SKU))
|
|
814
|
+
ship = _first(row.get(F_SHIP))
|
|
815
|
+
# 1) 上传(CLI stdout 含「上传结果」dialogs;重复=鑫中航已有该单=幂等成功)
|
|
816
|
+
# 弹窗偶发未捕获(15s 等待不足)→ 判定缺失时重试一次再下结论。
|
|
817
|
+
up_out = _run_stageb(["--upload", str(out)])
|
|
818
|
+
if ("全部完成" not in up_out and "成功" not in up_out
|
|
819
|
+
and "重复" not in up_out and "参考单号" not in up_out):
|
|
820
|
+
log(f"{rid} 上传结果弹窗未捕获,重试一次…")
|
|
821
|
+
up_out = _run_stageb(["--upload", str(out)])
|
|
822
|
+
if "参考单号" in up_out and "重复" in up_out:
|
|
823
|
+
log(f"{rid} 上传: 鑫中航已有该单发票(幂等,视为已存在)")
|
|
824
|
+
# 已存在也补下载大货标签(2026-09-09:漏下载致大货标签字段空)
|
|
825
|
+
dl_out = _run_stageb(["--download-label", ship])
|
|
826
|
+
m2 = re.search(r"下载结果: (\S+)", dl_out)
|
|
827
|
+
label_path = m2.group(1) if m2 else ""
|
|
828
|
+
if label_path and os.path.exists(label_path):
|
|
829
|
+
lip_upload_attachment(rid, F_LABEL, label_path)
|
|
830
|
+
lip_update([rid], {F_ST: ST_DONE, F_READY: False, F_ERR: ""})
|
|
831
|
+
else:
|
|
832
|
+
# 标签仍未就绪 → 落可续跑态,下一轮只补标签(不重复生成/上传)
|
|
833
|
+
n = _label_attempt_count(rid) + 1
|
|
834
|
+
_append_label_attempt(rid, n, dl_out)
|
|
835
|
+
lip_update_if_changed(row, {F_ST: ST_UPLOADED, F_ERR: _label_wait_text(n)})
|
|
836
|
+
log(f"{rid} 已存在(鑫中航有单)但标签未就绪(第 {n} 次)→ 下一轮补")
|
|
837
|
+
return f"{rid} 已存在但标签未就绪(第{n}次)"
|
|
838
|
+
_confirm_dict(sku, _first(row.get(F_HS)),
|
|
839
|
+
translate_material(_first(row.get(F_MAT))))
|
|
840
|
+
return f"{rid} 已存在(鑫中航有单)→ 已完成+固化"
|
|
841
|
+
if "全部完成" not in up_out and "成功" not in up_out and "完成 1/1" not in up_out:
|
|
842
|
+
lip_update([rid], {F_ST: ST_FAIL, F_ERR: f"上传失败(鑫中航)——请人工检查"})
|
|
843
|
+
log(f"{rid} 上传失败: {up_out[-300:]}")
|
|
844
|
+
return f"{rid} 上传失败"
|
|
845
|
+
log(f"{rid} 上传成功")
|
|
846
|
+
if generate_only:
|
|
847
|
+
lip_update([rid], {F_ST: ST_FILL, F_ERR: ""})
|
|
848
|
+
return f"{rid} 上传成功(generate-only 停在填写中)"
|
|
849
|
+
# 2) 下载大货标签 → 回填 → 已完成 + 固化(标签未就绪则落可续跑态,不永久失败)
|
|
850
|
+
return _finish_with_label(row, dry=dry)
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def _finish_with_label(row, dry=False):
|
|
854
|
+
"""下载大货标签 → 回填「大货标签」→ 已完成 + 固化。
|
|
855
|
+
|
|
856
|
+
标签未就绪(实测:刚上传的发票在鑫中航订单页搜不到该货件)→ 发票状态=已上传(可续跑态),
|
|
857
|
+
每轮自动重试且只补标签;文案按尝试次数节流写入(避免每 5 分钟重复写表)。
|
|
858
|
+
"""
|
|
859
|
+
rid = row["_id"]
|
|
860
|
+
sku = _first(row.get(F_SKU))
|
|
861
|
+
ship = _first(row.get(F_SHIP))
|
|
862
|
+
if dry:
|
|
863
|
+
return f"{rid} [dry] 补标签"
|
|
864
|
+
dl_out = _run_stageb(["--download-label", ship])
|
|
865
|
+
m = re.search(r"下载结果: (\S+)", dl_out)
|
|
866
|
+
label_path = m.group(1) if m else ""
|
|
867
|
+
if not label_path or not os.path.exists(label_path):
|
|
868
|
+
n = _label_attempt_count(rid) + 1
|
|
869
|
+
_append_label_attempt(rid, n, dl_out)
|
|
870
|
+
if n in LABEL_WRITE_AT or n % LABEL_WRITE_EVERY == 0:
|
|
871
|
+
lip_update_if_changed(row, {F_ST: ST_UPLOADED, F_ERR: _label_wait_text(n)})
|
|
872
|
+
log(f"{rid} 标签未就绪(第 {n} 次)→ 已上传态等待下一轮")
|
|
873
|
+
else:
|
|
874
|
+
log(f"{rid} 标签未就绪(第 {n} 次)→ 继续等待(不写表)")
|
|
875
|
+
return f"{rid} 标签未就绪(第{n}次)"
|
|
876
|
+
lip_upload_attachment(rid, F_LABEL, label_path)
|
|
877
|
+
# 3) 回填完成 + 固化
|
|
878
|
+
lip_update([rid], {F_ST: ST_DONE, F_READY: False, F_ERR: ""})
|
|
879
|
+
_confirm_dict(sku, _first(row.get(F_HS)),
|
|
880
|
+
translate_material(_first(row.get(F_MAT))))
|
|
881
|
+
log(f"{rid} 发票流程完成(上传+标签+固化)")
|
|
882
|
+
return f"{rid} 完成(上传+标签+固化)"
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
def handle_row(row, generate_only=False, dry=False, stats=None):
|
|
886
|
+
"""处理一行。返回动作描述。stats(可选):缺口计数 {'our':n,'ops':m}(KB-125 B-3)。"""
|
|
887
|
+
rid = row["_id"]
|
|
888
|
+
sku = _first(row.get(F_SKU))
|
|
889
|
+
st = _first(row.get(F_ST))
|
|
890
|
+
freight = _first(row.get(F_FREIGHT))
|
|
891
|
+
|
|
892
|
+
# PM 接管:历史「大货标签下载失败」的失败行视为可续跑(发票已上传,只差标签)
|
|
893
|
+
if st == ST_FAIL and "大货标签" in (_first(row.get(F_ERR)) or ""):
|
|
894
|
+
st = ST_UPLOADED
|
|
895
|
+
|
|
896
|
+
if st in (ST_DONE, ST_SKIP, ST_FAIL):
|
|
897
|
+
return f"{rid} 状态={st} 跳过"
|
|
898
|
+
if freight != XINZHONGHANG_STORE:
|
|
899
|
+
if st == ST_NOT or st == "":
|
|
900
|
+
if not dry:
|
|
901
|
+
lip_update([rid], {F_ST: ST_SKIP,
|
|
902
|
+
F_ERR: f"货代{freight or '空'}未接入发票自动化(首期仅{XINZHONGHANG_STORE})"})
|
|
903
|
+
return f"{rid} 货代={freight or '空'} → 跳过"
|
|
904
|
+
return f"{rid} 货代不符但状态={st} 不动"
|
|
905
|
+
# 发票已生成/已上传(填写中 / 已上传 / 标签已下载)= 只差大货标签 → 只补标签
|
|
906
|
+
# (幂等:不重复生成、不重复上传;避免每轮重复出票)
|
|
907
|
+
if st in (ST_FILL, ST_UPLOADED, ST_LABEL):
|
|
908
|
+
return _finish_with_label(row, dry=dry)
|
|
909
|
+
if st == ST_PICK and row.get(F_READY) is not True:
|
|
910
|
+
# 表格文案即行动指令(用户口径 2026-09-10):就绪未勾时也保持缺口文案准确
|
|
911
|
+
# (只刷新文案,不做任何补全/生成;文案不变则不写表)
|
|
912
|
+
if not dry:
|
|
913
|
+
gaps0 = precheck(row, load_dict())
|
|
914
|
+
if gaps0:
|
|
915
|
+
lip_update_if_changed(row, {F_ERR: "需处理:" + ";".join(gaps0) + gap_hint(gaps0)})
|
|
916
|
+
return f"{rid} 待勾选未就绪 等待"
|
|
917
|
+
|
|
918
|
+
sku_dict = load_dict()
|
|
919
|
+
gaps = precheck(row, sku_dict)
|
|
920
|
+
# 字典缺品名英文 → 本地品名词表自愈(命中即补字典并本轮继续,不阻塞拾取)
|
|
921
|
+
if any(g.startswith("SKU字典缺品名英文") for g in gaps) and not dry and _name_usable(row):
|
|
922
|
+
if heal_dict_from_product(row, sku_dict):
|
|
923
|
+
gaps = precheck(row, sku_dict)
|
|
924
|
+
if gaps:
|
|
925
|
+
# 能自动补的(HS)先补(dry-run 只报告不查询不写)
|
|
926
|
+
if "HS编码待预填/核对" in gaps and not dry:
|
|
927
|
+
act, filled = fill_hs_candidate(row, sku_dict)
|
|
928
|
+
if filled:
|
|
929
|
+
gaps.remove("HS编码待预填/核对")
|
|
930
|
+
log(f"{rid} {act}")
|
|
931
|
+
else:
|
|
932
|
+
gaps[gaps.index("HS编码待预填/核对")] = "HS自动查询无结果,请人工查填"
|
|
933
|
+
elif "HS编码待预填/核对" in gaps and dry:
|
|
934
|
+
gaps[gaps.index("HS编码待预填/核对")] = "HS编码待预填(dry-run 不查询)"
|
|
935
|
+
if gaps:
|
|
936
|
+
if not dry:
|
|
937
|
+
# 文案按动作方区分(我方缺口明确「无需运营操作」);同内容不重复写表
|
|
938
|
+
lip_update_if_changed(row, {F_ST: ST_PICK,
|
|
939
|
+
F_ERR: "需处理:" + ";".join(gaps) + gap_hint(gaps)})
|
|
940
|
+
ours, ops = split_gaps(gaps)
|
|
941
|
+
if stats is not None:
|
|
942
|
+
if ours:
|
|
943
|
+
stats["our"] = stats.get("our", 0) + 1
|
|
944
|
+
if ops:
|
|
945
|
+
stats["ops"] = stats.get("ops", 0) + 1
|
|
946
|
+
return f"{rid} 缺口: {gaps}"
|
|
947
|
+
# HS 补完且无其他缺口 → 落待勾选(HS 候选需运营核)
|
|
948
|
+
if not dry:
|
|
949
|
+
lip_update([rid], {F_ST: ST_PICK,
|
|
950
|
+
F_ERR: "HS已补候选,请核对HS/材质后勾选发票就绪"})
|
|
951
|
+
return f"{rid} HS候选待核 → 待勾选"
|
|
952
|
+
|
|
953
|
+
# 字段齐:未开发票/空 → 字典已确认直接生成;待勾选+就绪 → 生成
|
|
954
|
+
sd = sku_dict.get(sku) or {}
|
|
955
|
+
if st == ST_PICK and row.get(F_READY) is not True:
|
|
956
|
+
return f"{rid} 待勾选未就绪"
|
|
957
|
+
if st == ST_PICK:
|
|
958
|
+
pass # 就绪已确认,继续生成
|
|
959
|
+
elif st == ST_NOT or st == "":
|
|
960
|
+
if not (sd.get("confirmed")):
|
|
961
|
+
# 字典未确认但字段齐(运营核过 HS/材质+勾就绪才会到这)——若未就绪等待
|
|
962
|
+
if row.get(F_READY) is not True:
|
|
963
|
+
return f"{rid} 新品待运营勾就绪"
|
|
964
|
+
# confirmed 或已就绪 → 生成
|
|
965
|
+
if dry:
|
|
966
|
+
return f"{rid} [dry] 可生成"
|
|
967
|
+
try:
|
|
968
|
+
out = generate_for_row(row)
|
|
969
|
+
except Exception as exc:
|
|
970
|
+
if not dry:
|
|
971
|
+
lip_update([rid], {F_ST: ST_FAIL, F_ERR: f"生成失败:{exc}"})
|
|
972
|
+
return f"{rid} 生成失败: {exc}"
|
|
973
|
+
if not dry:
|
|
974
|
+
lip_update([rid], {F_ST: ST_FILL, F_ERR: ""})
|
|
975
|
+
lip_upload_attachment(rid, F_FILE, out)
|
|
976
|
+
log(f"{rid} 发票已生成: {out.name}")
|
|
977
|
+
# generate_only(试点核对):停在填写中;否则自动继续 上传→标签→完成+固化
|
|
978
|
+
if not generate_only and not dry:
|
|
979
|
+
return _upload_and_finish(row, out, generate_only=False)
|
|
980
|
+
return f"{rid} 生成完成 → 填写中"
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
def scan(generate_only=False, dry=False):
|
|
984
|
+
rows = lip_list(json.dumps({"logic": "and",
|
|
985
|
+
"conditions": [[F_FULL_ST, "==", "已完成"]]}))
|
|
986
|
+
acted = 0
|
|
987
|
+
stats = {"our": 0, "ops": 0} # KB-125 B-3:我方/运营待办计数
|
|
988
|
+
for row in rows:
|
|
989
|
+
try:
|
|
990
|
+
msg = handle_row(row, generate_only=generate_only, dry=dry, stats=stats)
|
|
991
|
+
if msg and "跳过" not in msg and "等待" not in msg and "不动" not in msg:
|
|
992
|
+
acted += 1
|
|
993
|
+
log(msg)
|
|
994
|
+
except Exception as exc:
|
|
995
|
+
log(f"{row.get('_id')} 处理异常: {exc}")
|
|
996
|
+
# KB-125 B-3:汇总行(只统计与提示,不写任何业务字段)
|
|
997
|
+
log(f"汇总:我方待办 {stats['our']} 行 / 运营待办 {stats['ops']} 行"
|
|
998
|
+
f"(扫描 {len(rows)} 行,本轮动作 {acted} 行)")
|
|
999
|
+
return acted
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
def run_record(rid, generate_only=False, dry=False):
|
|
1003
|
+
d = _cli("base", "+record-get", "--base-token", BASE_TOKEN,
|
|
1004
|
+
"--table-id", TABLE_ID, "--record-id", rid)
|
|
1005
|
+
if not d.get("ok"):
|
|
1006
|
+
raise RuntimeError(f"记录 {rid} 读取失败: {d.get('error',{}).get('message','')}")
|
|
1007
|
+
data = d.get("data", {})
|
|
1008
|
+
fields = data.get("fields", [])
|
|
1009
|
+
rows = data.get("data", [])
|
|
1010
|
+
ids = data.get("record_id_list", [])
|
|
1011
|
+
if not rows:
|
|
1012
|
+
raise RuntimeError(f"记录 {rid} 无数据")
|
|
1013
|
+
f = dict(zip(fields, rows[0]))
|
|
1014
|
+
f["_id"] = ids[0] if ids else rid
|
|
1015
|
+
return handle_row(f, generate_only=generate_only, dry=dry)
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
def regenerate(record_id: str) -> int:
|
|
1019
|
+
"""按记录重生成发票 xlsx(**只落本地文件**),并核对 PO Number = 真源 ML 卖家 ID。
|
|
1020
|
+
|
|
1021
|
+
为什么需要它(2026-09-19):6 张发票的 PO 曾静默写成紫鸟 store_id(另一个 ID 体系)。
|
|
1022
|
+
正常的 run_record 对 `发票状态=已完成` 的行直接跳过(终态保护,正确),所以纠正历史
|
|
1023
|
+
产物需要一条**明确只做本地生成**的通道 —— 不写表、不上传门户,避免把已完成的单
|
|
1024
|
+
重新推进门户(那会产生重复发票)。
|
|
1025
|
+
"""
|
|
1026
|
+
# 单条直取(不走 lip_list:它默认 limit 200,而表有 500+ 行 ⇒ 靠后的记录会"找不到";
|
|
1027
|
+
# 另外已完成的行若放行勾已释放,也不在候选筛选里)
|
|
1028
|
+
try:
|
|
1029
|
+
d = _cli("api", "GET",
|
|
1030
|
+
f"/open-apis/bitable/v1/apps/{BASE_TOKEN}/tables/{TABLE_ID}/records/{record_id}")
|
|
1031
|
+
fields = ((d.get("data") or {}).get("record") or {}).get("fields") or {}
|
|
1032
|
+
except Exception as exc:
|
|
1033
|
+
print(f"❌ 读取记录 {record_id} 失败:{exc}")
|
|
1034
|
+
return 2
|
|
1035
|
+
if not fields:
|
|
1036
|
+
print(f"❌ 未找到记录 {record_id}(或该记录无字段)")
|
|
1037
|
+
return 2
|
|
1038
|
+
row = dict(fields)
|
|
1039
|
+
row["_id"] = record_id
|
|
1040
|
+
store = _first(row.get(F_STORE))
|
|
1041
|
+
want = ml_store_ids.resolve(store)
|
|
1042
|
+
print(f" 记录 {record_id}|店铺 {store}(键 {ml_store_ids.store_key(store) or '—'})"
|
|
1043
|
+
f"|ML 卖家ID = {want or '❌ 未配置'}")
|
|
1044
|
+
if not want:
|
|
1045
|
+
print(" ❌ 真源里没有该店的 ML 卖家 ID ⇒ 拒绝生成(绝不用紫鸟 ID 顶替)")
|
|
1046
|
+
return 2
|
|
1047
|
+
out = generate_for_row(row)
|
|
1048
|
+
if not out:
|
|
1049
|
+
print(" ❌ 生成失败(见上方日志)")
|
|
1050
|
+
return 1
|
|
1051
|
+
print(f" ✅ 已生成 {out}")
|
|
1052
|
+
try:
|
|
1053
|
+
import openpyxl
|
|
1054
|
+
ws = openpyxl.load_workbook(out)[SHEET_NAME]
|
|
1055
|
+
got = str(ws["B22"].value)
|
|
1056
|
+
okp = got == want
|
|
1057
|
+
print(f" {'✅' if okp else '❌'} 核对 B22(PO Number):{got}"
|
|
1058
|
+
f"{'' if okp else f'(期望 {want})'}")
|
|
1059
|
+
return 0 if okp else 1
|
|
1060
|
+
except Exception as exc:
|
|
1061
|
+
print(f" ⚠️ 未能自动核对 B22:{exc}")
|
|
1062
|
+
return 0
|
|
1063
|
+
|
|
1064
|
+
|
|
1065
|
+
def main():
|
|
1066
|
+
ap = argparse.ArgumentParser()
|
|
1067
|
+
ap.add_argument("--scan", action="store_true")
|
|
1068
|
+
ap.add_argument("--record-id", default="")
|
|
1069
|
+
ap.add_argument("--generate-only", action="store_true",
|
|
1070
|
+
help="生成后停(不上传/不流转已完成)——试点核对用")
|
|
1071
|
+
ap.add_argument("--dry-run-pick", action="store_true", help="预演:判定只打印不写表")
|
|
1072
|
+
ap.add_argument("--regenerate-only", action="store_true",
|
|
1073
|
+
help="按记录重生成发票文件(用于纠正历史错误,如 2026-09-19 的 PO 错写)——"
|
|
1074
|
+
"**只落本地 xlsx、不写表、不上传门户**;已完成的行也允许")
|
|
1075
|
+
args = ap.parse_args()
|
|
1076
|
+
if args.record_id and args.regenerate_only:
|
|
1077
|
+
rc = regenerate(args.record_id)
|
|
1078
|
+
raise SystemExit(rc)
|
|
1079
|
+
if args.record_id:
|
|
1080
|
+
print(run_record(args.record_id, generate_only=args.generate_only,
|
|
1081
|
+
dry=args.dry_run_pick))
|
|
1082
|
+
elif args.scan:
|
|
1083
|
+
n = scan(generate_only=args.generate_only, dry=args.dry_run_pick)
|
|
1084
|
+
print(f"scan 完成,处理 {n} 行")
|
|
1085
|
+
else:
|
|
1086
|
+
ap.print_help()
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
if __name__ == "__main__":
|
|
1090
|
+
main()
|