@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.
- package/MANIFEST.json +307 -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 +575 -0
- package/engine/scripts/billing-mode-daily.sh +12 -0
- package/engine/scripts/build-engine-package.mjs +123 -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 +377 -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/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 +134 -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 +46 -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 +260 -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/fix-skill-frontmatter.py +209 -0
- package/engine/scripts/flow-auto-retry.py +454 -0
- package/engine/scripts/flow-requirements.json +720 -0
- package/engine/scripts/flow-scaffold.py +275 -0
- package/engine/scripts/flow-timers-plist.sh +86 -0
- package/engine/scripts/flow-timers.sh +182 -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 +1024 -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 +2275 -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 +194 -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.py +262 -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 +293 -0
- package/engine/scripts/poll-invoice.sh +74 -0
- package/engine/scripts/profile-doctor.mjs +686 -0
- package/engine/scripts/provider-select.sh +70 -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/run-tests.sh +256 -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 +136 -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 +73 -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 +34 -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 +216 -0
- package/engine/scripts/ziniao-post-upgrade-check.sh +120 -0
- package/engine/scripts/ziniao-windows.sh +153 -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 +706 -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 +21 -4
- package/README.md +0 -3
|
@@ -0,0 +1,1508 @@
|
|
|
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
|
+
# 数据目录:**唯一判据**在 `scripts/lib/argent_datadir.py`(三级链 `ARGENT_DATA_DIR` →
|
|
46
|
+
# `$DSH_HOME/argent` → `~/.dsh/argent`,与 JS 内核同语义)。此前本文件里**两种口径并存**
|
|
47
|
+
# (模块级硬编码 + 函数内两级链)⇒ 隔离实例一半写隔离目录、一半写生产。
|
|
48
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent / 'lib'))
|
|
49
|
+
from argent_datadir import resolve_data_dir # noqa: E402 —— 勿在别处复制判据
|
|
50
|
+
DATA = Path(resolve_data_dir()['dir'])
|
|
51
|
+
ENV_FILE = DATA / "fulfillment.env"
|
|
52
|
+
|
|
53
|
+
# 唯一真源:ML 店铺 → 卖家 user_id(2026-09-19;事故说明见 scripts/ml_store_ids.py 顶部)
|
|
54
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
55
|
+
import ml_store_ids # noqa: E402
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
from flow_registry import consumer_doc as _registry_doc # P2a:base + 业务线合并(唯一真源)
|
|
59
|
+
except Exception: # noqa: BLE001 —— 半坏机器上退回单文件(升级链路要能跑)
|
|
60
|
+
def _registry_doc(path=None):
|
|
61
|
+
_p = pathlib.Path(path) if path else (HERE / "flow-requirements.json")
|
|
62
|
+
return json.loads(_p.read_text(encoding="utf-8"))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ── env 装载(fulfillment.env:表源/身份/词表路径)──
|
|
66
|
+
def _load_env():
|
|
67
|
+
if ENV_FILE.exists():
|
|
68
|
+
for line in ENV_FILE.read_text(encoding="utf-8").splitlines():
|
|
69
|
+
line = line.strip()
|
|
70
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
71
|
+
continue
|
|
72
|
+
k, _, v = line.partition("=")
|
|
73
|
+
os.environ.setdefault(k.strip(), v.strip())
|
|
74
|
+
|
|
75
|
+
_load_env()
|
|
76
|
+
# lark-cli shebang「/usr/bin/env node」需 node 在 PATH(launchd/ssh 最小 PATH 兼容)
|
|
77
|
+
_EXTRA_PATH = [p for p in (f"{HOME}/.local/bin", f"{HOME}/node/bin",
|
|
78
|
+
"/opt/homebrew/bin", "/usr/local/bin")
|
|
79
|
+
if p not in (os.environ.get("PATH") or "").split(":")]
|
|
80
|
+
if _EXTRA_PATH:
|
|
81
|
+
os.environ["PATH"] = ":".join(_EXTRA_PATH + [os.environ.get("PATH", "")])
|
|
82
|
+
|
|
83
|
+
BASE_TOKEN = os.environ.get("FULL_BASE_TOKEN", "")
|
|
84
|
+
TABLE_ID = os.environ.get("FULL_TABLE_ID", "")
|
|
85
|
+
PROFILE = os.environ.get("LARK_PROFILE", "cli_aaf7cafb66b85ccc")
|
|
86
|
+
DICT_FILE = DATA / "lip-sku-dict.json"
|
|
87
|
+
MAT_DICT_FILE = DATA / "lip-material-dict.json"
|
|
88
|
+
OUTPUT_DIR = DATA / "invoice-output-lip"
|
|
89
|
+
LOG_FILE = DATA / "invoice-lip.log"
|
|
90
|
+
SHEET_NAME = "入仓预报" # 鑫中航模板填报 sheet(与 invoice_runner 对齐)
|
|
91
|
+
XINZHONGHANG_STORE = "鑫中航-美墨" # lip 货代列匹配值(运营命名含 -美墨 后缀)
|
|
92
|
+
|
|
93
|
+
# lip 发票字段映射(lip 物理字段集中定义,勿散落)
|
|
94
|
+
F_ST = "发票状态"
|
|
95
|
+
F_READY = "发票就绪"
|
|
96
|
+
F_HS = "HS编码"
|
|
97
|
+
F_MAT = "材质"
|
|
98
|
+
F_FILE = "发票文件"
|
|
99
|
+
F_LABEL = "大货标签"
|
|
100
|
+
F_ERR = "发票错误信息"
|
|
101
|
+
F_FULL_ST = "FULL状态"
|
|
102
|
+
F_SKU = "SKU"
|
|
103
|
+
F_NAME = "产品名称"
|
|
104
|
+
F_QTY = "数量"
|
|
105
|
+
F_BOX = "箱数"
|
|
106
|
+
F_PER_BOX = "装箱数"
|
|
107
|
+
F_WEIGHT = "重量"
|
|
108
|
+
F_LEN = "长"
|
|
109
|
+
F_WID = "宽"
|
|
110
|
+
F_HGT = "高"
|
|
111
|
+
F_ETA = "到货时间"
|
|
112
|
+
F_SHIP = "货件号"
|
|
113
|
+
F_STORE = "店铺"
|
|
114
|
+
F_FREIGHT = "货代公司"
|
|
115
|
+
F_IMG = "产品图"
|
|
116
|
+
F_CHARGE = "是否带电"
|
|
117
|
+
F_MAGNETIC = "是否带磁"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _yes_no(v):
|
|
121
|
+
"""运营填值标准化:lip 单选字段返回 ['是'](数组)——先 _first 取首元素。
|
|
122
|
+
含 是/带/Y/1 → 是;空/否/0 → 否(默认否兜底)。"""
|
|
123
|
+
s = _first(v).lower()
|
|
124
|
+
return "是" if s and s in ("是", "带", "y", "yes", "1", "带电", "带磁") else "否"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def lip_download_attachment(rid, file_token, out_dir):
|
|
128
|
+
"""下载 lip 行附件(产品图)到本地,返回本地路径或 None。"""
|
|
129
|
+
out_dir = Path(out_dir)
|
|
130
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
131
|
+
out = out_dir / f"{file_token}.img"
|
|
132
|
+
if out.exists():
|
|
133
|
+
return out
|
|
134
|
+
r = subprocess.run(
|
|
135
|
+
[LARK_CLI, "--profile", PROFILE, "base", "+record-download-attachment",
|
|
136
|
+
"--base-token", BASE_TOKEN, "--table-id", TABLE_ID,
|
|
137
|
+
"--record-id", rid, "--file-token", file_token,
|
|
138
|
+
"--output", out.name, "--format", "json"],
|
|
139
|
+
capture_output=True, text=True, timeout=120, cwd=str(out_dir))
|
|
140
|
+
return out if out.exists() else None
|
|
141
|
+
|
|
142
|
+
ST_NOT = "未开发票"
|
|
143
|
+
ST_PICK = "待勾选"
|
|
144
|
+
ST_GEN = "待生成"
|
|
145
|
+
ST_FILL = "填写中"
|
|
146
|
+
ST_UPLOADED = "已上传"
|
|
147
|
+
ST_LABEL = "标签已下载"
|
|
148
|
+
ST_DONE = "已完成"
|
|
149
|
+
ST_FAIL = "失败"
|
|
150
|
+
ST_SKIP = "跳过"
|
|
151
|
+
|
|
152
|
+
# ── lark-cli 封装(主 Bot 表读写)──
|
|
153
|
+
def _resolve_lark_cli():
|
|
154
|
+
import shutil
|
|
155
|
+
for cand in (shutil.which("lark-cli") or "",
|
|
156
|
+
f"{HOME}/.local/bin/lark-cli", f"{HOME}/node/bin/lark-cli",
|
|
157
|
+
"/opt/homebrew/bin/lark-cli", "/usr/local/bin/lark-cli"):
|
|
158
|
+
if cand and os.path.isfile(cand) and os.access(cand, os.X_OK):
|
|
159
|
+
return cand
|
|
160
|
+
return "lark-cli"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
LARK_CLI = _resolve_lark_cli()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _cli(*args, timeout=90):
|
|
167
|
+
r = subprocess.run([LARK_CLI, "--profile", PROFILE, *args, "--format", "json"],
|
|
168
|
+
capture_output=True, text=True, timeout=timeout)
|
|
169
|
+
try:
|
|
170
|
+
return json.loads(r.stdout[r.stdout.find("{"):])
|
|
171
|
+
except Exception:
|
|
172
|
+
raise RuntimeError(f"lark-cli 调用失败: {r.stderr[:300] or r.stdout[:300]}")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def lip_list(filter_json=None):
|
|
176
|
+
cmd = ["base", "+record-list", "--base-token", BASE_TOKEN,
|
|
177
|
+
"--table-id", TABLE_ID, "--limit", "200"] # ⚠️ json 上限就是 200;全表读取请用 lip_list_all()
|
|
178
|
+
if filter_json:
|
|
179
|
+
cmd += ["--filter-json", filter_json]
|
|
180
|
+
d = _cli(*cmd)
|
|
181
|
+
data = d.get("data", {})
|
|
182
|
+
fields = data.get("fields", [])
|
|
183
|
+
rows = data.get("data", [])
|
|
184
|
+
ids = data.get("record_id_list", [])
|
|
185
|
+
out = []
|
|
186
|
+
for i, row in enumerate(rows):
|
|
187
|
+
f = dict(zip(fields, row))
|
|
188
|
+
f["_id"] = ids[i] if i < len(ids) else ""
|
|
189
|
+
out.append(f)
|
|
190
|
+
return out
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# 允许写回的字段白名单(2026-09-22 用户口径)
|
|
194
|
+
# 用户明确:**跑流程就是要写 lip 表对应行的发票相关字段**,这是设计而非缺陷。
|
|
195
|
+
# 因此 `--generate-only` 的正确语义是:**生成产物 + 回写发票相关字段**(发票状态/发票文件/
|
|
196
|
+
# 发票错误信息),只是**不继续**门户上传→标签→完成那一段;它写表是预期行为。
|
|
197
|
+
# 守卫要防的是"写到无关字段":patch 里出现白名单外的键 ⇒ 直接拒绝执行(响亮失败),
|
|
198
|
+
# 并落一条审计(谁、哪行、写了哪些字段),便于事后逐字段复核。
|
|
199
|
+
def _allowed_write_fields() -> set:
|
|
200
|
+
"""写回白名单 = 能力的 `writeFields ∪ stateFields`(来自注册表,单一事实源)。
|
|
201
|
+
|
|
202
|
+
⚠️ 2026-09-22 回归教训:第一版白名单只抄了 `writeFields`(4 个),**漏了 stateFields**,
|
|
203
|
+
而 `gapPolicy` 明确要求"运营缺口时**取消勾选「发票就绪」**" —— runner 在缺口路径写
|
|
204
|
+
`{发票状态: 待勾选, 发票就绪: False}`,于是**自己的合法写回被守卫拒绝**(SystemExit),
|
|
205
|
+
鑫中航连续两票(77635236 / 77642739)因此阻断,执行者只能把那两处写入删掉,
|
|
206
|
+
结果口径⑤的"取消勾选"动作被**静默移除**(比阻断更危险:错的行会被反复派发)。
|
|
207
|
+
⇒ 白名单必须由注册表派生(含 stateFields),而不是手抄;注册表读不到时**兜底也要含
|
|
208
|
+
「发票就绪」**,绝不静默收窄。
|
|
209
|
+
"""
|
|
210
|
+
cap = os.environ.get('ARGENT_CAPABILITY') or 'invoice'
|
|
211
|
+
for cand in (pathlib.Path(__file__).resolve().parent / 'flow-requirements.json',
|
|
212
|
+
DATA / 'flow-requirements.json'):
|
|
213
|
+
try:
|
|
214
|
+
doc = json.loads(cand.read_text(encoding='utf-8'))
|
|
215
|
+
c = (doc.get('capabilities') or {}).get(cap) or {}
|
|
216
|
+
got = set(c.get('writeFields') or []) | set(c.get('stateFields') or [])
|
|
217
|
+
if got:
|
|
218
|
+
return got
|
|
219
|
+
except Exception:
|
|
220
|
+
continue
|
|
221
|
+
return {"发票状态", "发票文件", "大货标签", "发票错误信息", "发票就绪"}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
WRITE_FIELDS = _allowed_write_fields()
|
|
225
|
+
|
|
226
|
+
# 大货标签的本地目录(stageb 下载落点)。可用 ARGENT_LABEL_DIRS(冒号分隔)覆盖。
|
|
227
|
+
LABEL_DIRS = [pathlib.Path(x) for x in (os.environ.get("ARGENT_LABEL_DIRS") or "").split(":") if x] or \
|
|
228
|
+
[DATA / "invoice-labels"]
|
|
229
|
+
# --label-from-local / ARGENT_LABEL_FROM_LOCAL=1:允许用本地标签跳过门户**下载**
|
|
230
|
+
# (用户 2026-09-22 授权;**不做**页数=箱数校验 —— 运营暂无此要求)。
|
|
231
|
+
LABEL_FROM_LOCAL = os.environ.get("ARGENT_LABEL_FROM_LOCAL") == "1"
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _local_label_path(row) -> pathlib.Path:
|
|
235
|
+
"""本票已有的本地大货标签(`运单-<货件号>.pdf`)→ Path 或 None。
|
|
236
|
+
|
|
237
|
+
守卫(2026-09-22 评审结论,只保留"能证明是**本票**产物"的那些):
|
|
238
|
+
· 文件名必须精确等于 `运单-<货件号>.pdf`(本票唯一,防同名残留/串票)
|
|
239
|
+
· 非空且是 PDF(`%PDF-` 魔数)
|
|
240
|
+
· 目录可通过 ARGENT_LABEL_DIRS 收紧
|
|
241
|
+
不校验页数:运营明确暂无"页数=箱数"要求(且实测条件不成立)。
|
|
242
|
+
"""
|
|
243
|
+
ship = _first(row.get(F_SHIP))
|
|
244
|
+
if not ship:
|
|
245
|
+
return None
|
|
246
|
+
name = f"运单-{ship}.pdf"
|
|
247
|
+
for d in LABEL_DIRS:
|
|
248
|
+
f = pathlib.Path(d) / name
|
|
249
|
+
try:
|
|
250
|
+
if not f.is_file() or f.stat().st_size <= 0:
|
|
251
|
+
continue
|
|
252
|
+
if f.read_bytes()[:5] != b"%PDF-":
|
|
253
|
+
continue
|
|
254
|
+
return f
|
|
255
|
+
except OSError:
|
|
256
|
+
continue
|
|
257
|
+
return None
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _audit_note(rids, note: str) -> None:
|
|
261
|
+
"""审计里记一条"非写表"的事实(例如:标签来源=本地)。"""
|
|
262
|
+
try:
|
|
263
|
+
AUDIT_DIR.mkdir(parents=True, exist_ok=True)
|
|
264
|
+
with (AUDIT_DIR / "audit.jsonl").open("a", encoding="utf-8") as fh:
|
|
265
|
+
fh.write(json.dumps({"ts": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
|
266
|
+
"flow": os.environ.get("ARGENT_FLOW", "xzh-invoice"),
|
|
267
|
+
"records": list(rids), "fields": [], "ok": True, "note": note},
|
|
268
|
+
ensure_ascii=False) + "\n")
|
|
269
|
+
except Exception:
|
|
270
|
+
pass
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _download_label_or_local(row, ship: str) -> tuple:
|
|
274
|
+
"""取大货标签 → (本地路径 或 '', 输出文本, 是否来自本地)。
|
|
275
|
+
|
|
276
|
+
默认走门户下载(`--download-label`);开了 `--label-from-local`(或环境变量)且本地已有
|
|
277
|
+
本票标签 ⇒ **跳过下载**直接复用,并在审计里记明来源(只跳过下载;**上传**那一段不受影响)。
|
|
278
|
+
"""
|
|
279
|
+
if LABEL_FROM_LOCAL:
|
|
280
|
+
local = _local_label_path(row)
|
|
281
|
+
if local:
|
|
282
|
+
log(f"{row.get('_id')} 使用本地标签(跳过门户下载):{local.name}")
|
|
283
|
+
_audit_note([row.get("_id")], f"大货标签来源=本地 {local}")
|
|
284
|
+
return str(local), f"下载结果: {local}(本地复用,未访问门户)", True
|
|
285
|
+
dl_out = _run_stageb(["--download-label", ship])
|
|
286
|
+
m = re.search(r"下载结果: (\S+)", dl_out)
|
|
287
|
+
return (m.group(1) if m else ""), dl_out, False
|
|
288
|
+
AUDIT_DIR = DATA / "invoice-lip-writes"
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _audit_write(rids, patch, ok, err=""):
|
|
292
|
+
try:
|
|
293
|
+
AUDIT_DIR.mkdir(parents=True, exist_ok=True)
|
|
294
|
+
rec = {"ts": time.strftime("%Y-%m-%dT%H:%M:%S"), "flow": os.environ.get("ARGENT_FLOW", "xzh-invoice"),
|
|
295
|
+
"records": list(rids), "fields": sorted(patch.keys()), "ok": bool(ok), "error": err[:200]}
|
|
296
|
+
with (AUDIT_DIR / "audit.jsonl").open("a", encoding="utf-8") as fh:
|
|
297
|
+
fh.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
|
298
|
+
except Exception:
|
|
299
|
+
pass # 审计失败不影响主流程(不因日志问题阻断业务)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def lip_list_all(filter_json: str = "") -> list:
|
|
303
|
+
"""**全表**读取(ndjson,上限 2000)→ 行列表;可选按 filter_json 的 conditions 在客户端过滤。
|
|
304
|
+
|
|
305
|
+
为什么单列一个函数(2026-09-23 事故):`lip_list()` 用的 json 格式 `--limit` **上限 200**,
|
|
306
|
+
而表有 531 行 ⇒ 任何基于它的扫描都会**静默漏行**(用户指出的"就绪=true ∧ 待执行"6 条
|
|
307
|
+
就是因为只看了前 200 行而没被发现)。凡"对账/全表扫描"一律用本函数。
|
|
308
|
+
"""
|
|
309
|
+
import lip_table as _lt
|
|
310
|
+
if filter_json:
|
|
311
|
+
try: # 服务端过滤(全表 + 过滤,无 200 行截断)
|
|
312
|
+
return _lt.all_rows(profile=PROFILE, base=BASE_TOKEN, table=TABLE_ID, filter_json=filter_json)
|
|
313
|
+
except Exception:
|
|
314
|
+
pass # 过滤语法不被接受时退回"全表 + 客户端过滤"
|
|
315
|
+
rows = _lt.all_rows(profile=PROFILE, base=BASE_TOKEN, table=TABLE_ID)
|
|
316
|
+
if not filter_json:
|
|
317
|
+
return rows
|
|
318
|
+
try:
|
|
319
|
+
doc = json.loads(filter_json)
|
|
320
|
+
except Exception:
|
|
321
|
+
return rows
|
|
322
|
+
conds = doc.get("conditions") or []
|
|
323
|
+
|
|
324
|
+
def _val(r, f):
|
|
325
|
+
v = r.get(f)
|
|
326
|
+
return v[0] if isinstance(v, list) and v else v
|
|
327
|
+
|
|
328
|
+
def _ok(r):
|
|
329
|
+
for f, op, want in conds:
|
|
330
|
+
got = _val(r, f)
|
|
331
|
+
if op in ("==", "is"):
|
|
332
|
+
if str(got).strip().lower() != str(want).strip().lower():
|
|
333
|
+
return False
|
|
334
|
+
elif op in ("!=", "isNot"):
|
|
335
|
+
if str(got).strip().lower() == str(want).strip().lower():
|
|
336
|
+
return False
|
|
337
|
+
elif op == "isEmpty":
|
|
338
|
+
if str(got or "").strip():
|
|
339
|
+
return False
|
|
340
|
+
elif op == "isNotEmpty":
|
|
341
|
+
if not str(got or "").strip():
|
|
342
|
+
return False
|
|
343
|
+
return True
|
|
344
|
+
|
|
345
|
+
return [r for r in rows if _ok(r)]
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def lip_update(rids, patch):
|
|
349
|
+
bad = sorted(set(patch.keys()) - WRITE_FIELDS)
|
|
350
|
+
if bad:
|
|
351
|
+
_audit_write(rids, patch, False, f"越界字段: {bad}")
|
|
352
|
+
raise SystemExit(f"❌ 拒绝写回:{bad} 不在发票字段白名单 {sorted(WRITE_FIELDS)} 内"
|
|
353
|
+
f"(只允许改发票相关字段,其余字段由运营/其他流程负责)")
|
|
354
|
+
d = _cli("base", "+record-batch-update", "--base-token", BASE_TOKEN,
|
|
355
|
+
"--table-id", TABLE_ID,
|
|
356
|
+
"--json", json.dumps({"record_id_list": rids, "patch": patch}, ensure_ascii=False))
|
|
357
|
+
_audit_write(rids, patch, d.get("ok", False), "" if d.get("ok") else str(d)[:200])
|
|
358
|
+
return d.get("ok", False)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _fid(name: str) -> str:
|
|
362
|
+
"""字段名 → 字段 id(运行时解析,见 scripts/lip_table.py;查不到则按名传)。"""
|
|
363
|
+
try:
|
|
364
|
+
import sys as _s, pathlib as _pl
|
|
365
|
+
_s.path.insert(0, str(_pl.Path(__file__).resolve().parent))
|
|
366
|
+
import lip_table as _lip
|
|
367
|
+
return _lip.field_id(name, {"flow": "xzh-invoice", "base": BASE_TOKEN,
|
|
368
|
+
"table": TABLE_ID, "profile": PROFILE})
|
|
369
|
+
except Exception:
|
|
370
|
+
return name
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _attachments_of(row, field) -> list:
|
|
374
|
+
v = (row or {}).get(field)
|
|
375
|
+
return [a for a in v if isinstance(a, dict)] if isinstance(v, list) else []
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _dedupe_choice(atts: list) -> tuple:
|
|
379
|
+
"""附件去重策略 → (保留的下标 或 None, 原因)。
|
|
380
|
+
|
|
381
|
+
· 空/单条 ⇒ (None, '无需处理')
|
|
382
|
+
· 全部同名 ⇒ (最后一条, '同名重复:保留最新一份') # 追加语义 ⇒ 新的在后
|
|
383
|
+
· 名字不一致 ⇒ (None, '存在不同名附件:交人工判断,不擅自丢件')
|
|
384
|
+
"""
|
|
385
|
+
if not atts or len(atts) < 2:
|
|
386
|
+
return None, '无需处理'
|
|
387
|
+
names = {str(a.get('name') or '') for a in atts}
|
|
388
|
+
if len(names) > 1:
|
|
389
|
+
return None, '存在不同名附件:交人工判断,不擅自丢件'
|
|
390
|
+
return len(atts) - 1, '同名重复:保留最新一份'
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _same_name_attachments(row, field, name) -> list:
|
|
394
|
+
return [a for a in _attachments_of(row, field) if str(a.get('name') or '') == name]
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _needs_refresh(same: list, path) -> bool:
|
|
398
|
+
"""同名附件是否需要"重传刷新" → True=重传,False=视为已存在(幂等跳过)。
|
|
399
|
+
|
|
400
|
+
判据(2026-09-22 角色提的加固点):门户 PDF 每次导出会**重打时间戳** ⇒ 同名文件的字节数
|
|
401
|
+
可能不同。「同名+同大小」是弱代理,但**只看名字**又会永远留着旧件 ⇒ 采取折中:
|
|
402
|
+
· 有一份同名且**能取到大小且与本地不同** ⇒ 重传刷新(不是再挂一份)
|
|
403
|
+
· 大小一致 / 取不到大小 ⇒ 视为已存在(保守,不重复挂)
|
|
404
|
+
比内容哈希更轻(哈希要先把远端附件下载回来,每行多一次网络往返;而标签文件名
|
|
405
|
+
本身已按票号唯一化:运单-<货件号>.pdf ⇒ 同票不会有两个不同标签)。
|
|
406
|
+
"""
|
|
407
|
+
local = _file_size(path)
|
|
408
|
+
if local < 0:
|
|
409
|
+
return False
|
|
410
|
+
for a in same:
|
|
411
|
+
asz = a.get('size')
|
|
412
|
+
if asz in (None, ''):
|
|
413
|
+
continue
|
|
414
|
+
try:
|
|
415
|
+
if int(asz) != local:
|
|
416
|
+
return True
|
|
417
|
+
except (TypeError, ValueError):
|
|
418
|
+
continue
|
|
419
|
+
return False
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _file_size(path) -> int:
|
|
423
|
+
try:
|
|
424
|
+
return Path(path).stat().st_size
|
|
425
|
+
except OSError:
|
|
426
|
+
return -1
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _already_attached(row, field, path) -> bool:
|
|
430
|
+
"""同名(且大小一致时)已挂 ⇒ 视为已上传。**补跑幂等**的关键。
|
|
431
|
+
|
|
432
|
+
2026-09-22 实测事故:补跑时 `upload-attachment` 是**追加**语义 ⇒ 大货标签被挂了两份,
|
|
433
|
+
执行者被迫中止第二票的补跑。判据只用 name(大小能取到就一起比),不猜 file_token。
|
|
434
|
+
"""
|
|
435
|
+
name = Path(path).name
|
|
436
|
+
try:
|
|
437
|
+
size = Path(path).stat().st_size
|
|
438
|
+
except OSError:
|
|
439
|
+
size = -1
|
|
440
|
+
for a in _attachments_of(row, field):
|
|
441
|
+
if str(a.get('name') or '') != name:
|
|
442
|
+
continue
|
|
443
|
+
asz = a.get('size')
|
|
444
|
+
if size < 0 or asz in (None, ''):
|
|
445
|
+
return True
|
|
446
|
+
try:
|
|
447
|
+
if int(asz) == size:
|
|
448
|
+
return True
|
|
449
|
+
except (TypeError, ValueError):
|
|
450
|
+
return True
|
|
451
|
+
return False
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def lip_upload_attachment(rid, field, path, row=None):
|
|
455
|
+
"""上传附件(lark-cli 文件路径仅接受 cwd 相对 → 切到文件目录用文件名)。
|
|
456
|
+
|
|
457
|
+
upload-attachment 是**追加**语义——重复生成会累积重复附件。两道防线:
|
|
458
|
+
① 上传前:同名(+同大小)已在格内 ⇒ **直接跳过**(补跑幂等,不产生第二份);
|
|
459
|
+
② 上传后:用**该字段自己**的 attachments 归一成单条(原实现写死查「发票文件」,
|
|
460
|
+
上传标签时查不到自己那条 ⇒ 归一失效、附件越挂越多)。
|
|
461
|
+
"""
|
|
462
|
+
path = Path(path)
|
|
463
|
+
same = _same_name_attachments(row, field, path.name)
|
|
464
|
+
if row is not None and same and not _needs_refresh(same, path):
|
|
465
|
+
if len(same) > 1:
|
|
466
|
+
# 已经重复挂上了(2026-09-22 事故遗留)⇒ 就地归一为 1 份。
|
|
467
|
+
# 只在**全部同名**时才动;名字不同说明是新旧两版,交人工判断,不擅自丢件。
|
|
468
|
+
lip_update([rid], {field: [{"file_token": same[-1].get("file_token"),
|
|
469
|
+
"name": same[-1].get("name", path.name)}]})
|
|
470
|
+
log(f"{rid} {field} 有 {len(same)} 份同名附件 ⇒ 已去重为 1 份")
|
|
471
|
+
else:
|
|
472
|
+
log(f"{rid} {field} 已有同名附件({path.name})⇒ 跳过上传(幂等)")
|
|
473
|
+
return True
|
|
474
|
+
if row is not None and same:
|
|
475
|
+
# 同名但大小不同:门户每次导出会重打时间戳 ⇒ 同一张标签的字节数会变。
|
|
476
|
+
# 这时**不能**当作"新附件"再挂一份(那是重复),也不能当"已存在"跳过(那会永远留着旧件);
|
|
477
|
+
# 正确做法是**重传刷新**,上传后由下面的归一把字段收敛成最新的一份。
|
|
478
|
+
log(f"{rid} {field} 同名但大小不同({path.name})⇒ 重传刷新(门户重打时间戳场景)")
|
|
479
|
+
r = subprocess.run(
|
|
480
|
+
[LARK_CLI, "--profile", PROFILE, "base", "+record-upload-attachment",
|
|
481
|
+
"--base-token", BASE_TOKEN, "--table-id", TABLE_ID,
|
|
482
|
+
"--record-id", rid, "--field-id", field, "--file", path.name,
|
|
483
|
+
"--format", "json"],
|
|
484
|
+
capture_output=True, text=True, timeout=120, cwd=str(path.parent))
|
|
485
|
+
try:
|
|
486
|
+
d = json.loads(r.stdout[r.stdout.find("{"):])
|
|
487
|
+
if not d.get("ok"):
|
|
488
|
+
return False
|
|
489
|
+
atts = (d.get("data", {}).get("attachments", {}).get(rid, {})
|
|
490
|
+
# 2026-09-20(迭代1-残留项):字段 id 运行时解析(原写死 fldvmra3Rp ⇒ 表相关)
|
|
491
|
+
.get(_fid(field), []))
|
|
492
|
+
if atts:
|
|
493
|
+
last = atts[-1]
|
|
494
|
+
lip_update([rid], {field: [{"file_token": last.get("file_token"),
|
|
495
|
+
"name": last.get("name", path.name)}]})
|
|
496
|
+
return True
|
|
497
|
+
except Exception:
|
|
498
|
+
return False
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _can_uncheck_ready(row: dict) -> tuple:
|
|
502
|
+
"""终态行才允许补正「发票就绪」→ (是否允许, 原因)。
|
|
503
|
+
|
|
504
|
+
为什么需要:口径⑤要求完成即取消勾选,但**在最小修复版下完成的终态行**仍留着 true
|
|
505
|
+
(实测 77635236)。这类行**不能靠补跑修** —— runner 对终态行是**早退跳过**的
|
|
506
|
+
(`st in (已完成/跳过/失败) ⇒ 跳过`),补跑只会白跑一趟、还可能重复碰门户。
|
|
507
|
+
正确做法是**单字段补正**(不碰门户、不动产物);且只允许对终态行做,
|
|
508
|
+
在飞行的行由流程自己管,绝不能被人为取消勾选。
|
|
509
|
+
"""
|
|
510
|
+
st = str((row.get(F_ST) or [''])[0] if isinstance(row.get(F_ST), list) else (row.get(F_ST) or '')).strip()
|
|
511
|
+
if st not in (ST_DONE, ST_SKIP):
|
|
512
|
+
return False, f'状态={st or "空"} 不是终态(仅允许 已完成/跳过)'
|
|
513
|
+
if row.get(F_READY) is not True:
|
|
514
|
+
return False, '「发票就绪」本来就是未勾选,无需补正'
|
|
515
|
+
return True, '终态行且仍勾选就绪 ⇒ 可补正为未勾选'
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def uncheck_ready(rid, dash_yes=False) -> str:
|
|
519
|
+
"""把终态行的「发票就绪」补正为未勾选(对齐口径⑤;不碰门户、不改产物)。"""
|
|
520
|
+
row = _read_row(rid)
|
|
521
|
+
ok, why = _can_uncheck_ready(row)
|
|
522
|
+
out = [f" {rid}:{why}"]
|
|
523
|
+
if not ok:
|
|
524
|
+
return "\n".join(out)
|
|
525
|
+
if dash_yes:
|
|
526
|
+
lip_update([rid], {F_READY: False})
|
|
527
|
+
out.append(" ✅ 已写回「发票就绪」= 未勾选")
|
|
528
|
+
else:
|
|
529
|
+
out.append(" (dry-run;加 --yes 才写)")
|
|
530
|
+
return "\n".join(out)
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _read_row(rid) -> dict:
|
|
534
|
+
"""按 record id 取一行(字段名 → 值)。与 run_record 同一读取路径。"""
|
|
535
|
+
d = _cli("base", "+record-get", "--base-token", BASE_TOKEN,
|
|
536
|
+
"--table-id", TABLE_ID, "--record-id", rid)
|
|
537
|
+
if not d.get("ok"):
|
|
538
|
+
raise RuntimeError(f"记录 {rid} 读取失败: {(d.get('error') or {}).get('message', '')}")
|
|
539
|
+
data = d.get("data", {}) or {}
|
|
540
|
+
rows = data.get("data") or []
|
|
541
|
+
if not rows:
|
|
542
|
+
raise RuntimeError(f"记录 {rid} 无数据")
|
|
543
|
+
f = dict(zip(data.get("fields") or [], rows[0]))
|
|
544
|
+
f["_id"] = (data.get("record_id_list") or [rid])[0]
|
|
545
|
+
return f
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def dedupe_attachments(rid, dash_yes=False):
|
|
549
|
+
"""把某行的「发票文件」「大货标签」归一为单条(同名重复才动)。
|
|
550
|
+
|
|
551
|
+
为什么需要它:附件是**追加**语义,历史补跑可能已留下重复(实测 77635236 的
|
|
552
|
+
大货标签有两份同名同大小);这些行状态已是「已完成」⇒ 正常流程会跳过,
|
|
553
|
+
重复就永远留在表里。本命令是那类行的**修复入口**(默认 dry-run)。
|
|
554
|
+
"""
|
|
555
|
+
row = _read_row(rid)
|
|
556
|
+
out = []
|
|
557
|
+
for field in ("发票文件", "大货标签"):
|
|
558
|
+
atts = _attachments_of(row, field)
|
|
559
|
+
keep, why = _dedupe_choice(atts)
|
|
560
|
+
out.append(f" {field}: {len(atts)} 份 → {why}")
|
|
561
|
+
if keep is not None and dash_yes:
|
|
562
|
+
lip_update([rid], {field: [{"file_token": atts[keep].get("file_token"),
|
|
563
|
+
"name": atts[keep].get("name")}]})
|
|
564
|
+
out.append(" ✅ 已写回单条")
|
|
565
|
+
return "\n".join(out)
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def log(msg):
|
|
569
|
+
line = f"[{time.strftime('%F %T')}] {msg}"
|
|
570
|
+
print(line, file=sys.stderr, flush=True)
|
|
571
|
+
try:
|
|
572
|
+
with open(LOG_FILE, "a", encoding="utf-8") as f:
|
|
573
|
+
f.write(line + "\n")
|
|
574
|
+
except Exception:
|
|
575
|
+
pass
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
# ── PM 手动接管(2026-09-10):发票线三处改造 ──
|
|
579
|
+
# 用户决定:① 不再发飞书通知(表格「发票错误信息」即唯一提示面)
|
|
580
|
+
# ② AI 补录用本地品名词表自愈,不阻塞拾取流程
|
|
581
|
+
# ③ 大货标签未就绪不再永久失败(落可续跑态「已上传」,每轮只补标签)
|
|
582
|
+
PRODUCT_DICT_FILE = DATA / "lip-product-dict.json"
|
|
583
|
+
# 全新品名实时翻译(PM 2026-09-10):走 whyshu.com 兼容代理(客户机已有 apiKey),
|
|
584
|
+
# 实测 deepseek-v4-flash + reasoning_effort=none ≈ 1.1s 出结构化 JSON;结果落词表缓存,重复品名零成本。
|
|
585
|
+
LLM_BASE = os.environ.get("ARGENT_LLM_BASE", "") or "https://whyshu.com/api/argent/v1"
|
|
586
|
+
LLM_MODEL = os.environ.get("ARGENT_LLM_MODEL", "") or "deepseek-v4-flash"
|
|
587
|
+
LLM_TIMEOUT = int(os.environ.get("ARGENT_LLM_TIMEOUT", "120"))
|
|
588
|
+
LABEL_RETRY_FILE = DATA / "invoice-lip-label-retry.jsonl"
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
# ── A. 字典自愈:中文品名 → 英文品名(本地词表命中即补字典,本轮继续开票)──
|
|
592
|
+
def load_product_dict():
|
|
593
|
+
try:
|
|
594
|
+
return json.loads(PRODUCT_DICT_FILE.read_text(encoding="utf-8"))
|
|
595
|
+
except Exception:
|
|
596
|
+
return {}
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _write_dict_entry(sku, entry):
|
|
600
|
+
"""原子写字典单条(tmp + rename),避免半写坏字典。"""
|
|
601
|
+
d = load_dict()
|
|
602
|
+
d[sku] = entry
|
|
603
|
+
tmp = DATA / ".lip-sku-dict.json.tmp"
|
|
604
|
+
try:
|
|
605
|
+
tmp.write_text(json.dumps(d, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
606
|
+
os.replace(tmp, DATA / "lip-sku-dict.json")
|
|
607
|
+
return True
|
|
608
|
+
except Exception as exc:
|
|
609
|
+
log(f"字典写入失败 {sku}: {exc}")
|
|
610
|
+
return False
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _norm_name(name):
|
|
614
|
+
"""品名规范化(去掉 <br> 等抓取残留与多余空白),用于词表查找。"""
|
|
615
|
+
name = re.sub(r"<br\s*/?>", "", name or "", flags=re.I)
|
|
616
|
+
return re.sub(r"\s+", " ", name).strip()
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _llm_key():
|
|
620
|
+
key = os.environ.get("WHYSHU_API_KEY", "")
|
|
621
|
+
if key:
|
|
622
|
+
return key
|
|
623
|
+
try:
|
|
624
|
+
return json.loads((DATA / "config.json").read_text(encoding="utf-8")).get("apiKey", "")
|
|
625
|
+
except Exception:
|
|
626
|
+
return ""
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
# ── 产品英文名称:只留**直译名**(2026-10-13 用户现场纠正)──────────────────────
|
|
630
|
+
# 现象:发票「产品英文名称」被写成 `150mm Digital Caliper (Zinc Alloy)` —— 括号里是材质/颜色/规格。
|
|
631
|
+
# 用户口径:**只直译产品名**;材质另有 `材质中文`/`材质英文` 两列,不该在品名里重复。
|
|
632
|
+
# 成因:① LLM 提示词曾要求"含关键规格/颜色/材质";② 该值会被固化进字典并被后续复用。
|
|
633
|
+
# ⇒ 在「读字典 / 收 LLM 结果 / 写 xlsx」三处都过一遍(幂等;⛔ 只动**尾部**括号)。
|
|
634
|
+
_PAREN_TAIL_RE = re.compile(r"\s*[((][^(())]*[))]\s*$")
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def strip_name_parens(name):
|
|
638
|
+
"""去掉**尾部**括号注释(可连续多段):`X (A) (B)` → `X`;`SSD (Black)` → `SSD`。
|
|
639
|
+
⛔ 只动尾部(名称中间的括号保留);清洗后为空 ⇒ 回退原值(宁可不改,也不把品名清空)。"""
|
|
640
|
+
s = str(name or "").strip()
|
|
641
|
+
if not s:
|
|
642
|
+
return s
|
|
643
|
+
out = s
|
|
644
|
+
while True:
|
|
645
|
+
nxt = _PAREN_TAIL_RE.sub("", out).strip()
|
|
646
|
+
if not nxt or nxt == out:
|
|
647
|
+
break
|
|
648
|
+
out = nxt
|
|
649
|
+
return out or s
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def llm_translate_product(name_zh, mat_zh="", hs_hint=""):
|
|
653
|
+
"""中文品名 → 报关用英文品名/材质英文/HS 候选(实时 LLM,失败返回 None、不阻塞流程)。"""
|
|
654
|
+
name_zh = (name_zh or "").strip()
|
|
655
|
+
key = _llm_key()
|
|
656
|
+
if not name_zh or not key:
|
|
657
|
+
return None
|
|
658
|
+
prompt = (
|
|
659
|
+
"你是跨境电商报关资料助手。把中文品名**直译**成申报用英文品名:"
|
|
660
|
+
"只译产品名本身,⛔ 不要带材质/颜色/规格/容量等括号注释(那些另有字段),"
|
|
661
|
+
"并给出材质英文与 10 位中国海关编码。只输出 JSON,无其他文字:\n"
|
|
662
|
+
'{"品名英文":"...","材质英文":"...","海关编码":"..."}\n'
|
|
663
|
+
f"中文品名:{name_zh}"
|
|
664
|
+
+ (f"(材质:{mat_zh})" if mat_zh else "")
|
|
665
|
+
+ (f"\n参考HS:{hs_hint}" if hs_hint else "")
|
|
666
|
+
)
|
|
667
|
+
body = json.dumps({"model": LLM_MODEL, "temperature": 0, "max_tokens": 400,
|
|
668
|
+
"reasoning_effort": "none",
|
|
669
|
+
"messages": [{"role": "user", "content": prompt}]}).encode("utf-8")
|
|
670
|
+
req = urllib.request.Request(LLM_BASE.rstrip("/") + "/chat/completions", data=body,
|
|
671
|
+
headers={"Authorization": f"Bearer {key}",
|
|
672
|
+
"Content-Type": "application/json"})
|
|
673
|
+
try:
|
|
674
|
+
with urllib.request.urlopen(req, timeout=LLM_TIMEOUT) as resp:
|
|
675
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
676
|
+
except Exception as exc:
|
|
677
|
+
log(f"LLM 翻译失败({name_zh}):{type(exc).__name__} {str(exc)[:120]}")
|
|
678
|
+
return None
|
|
679
|
+
content = ((data.get("choices") or [{}])[0].get("message") or {}).get("content") or ""
|
|
680
|
+
m = re.search(r"\{.*\}", content, re.S)
|
|
681
|
+
if not m:
|
|
682
|
+
log(f"LLM 翻译未返回 JSON({name_zh}):{content.strip()[:80]}")
|
|
683
|
+
return None
|
|
684
|
+
try:
|
|
685
|
+
d = json.loads(m.group(0))
|
|
686
|
+
except json.JSONDecodeError:
|
|
687
|
+
return None
|
|
688
|
+
en = strip_name_parens(str(d.get("品名英文") or "").strip())
|
|
689
|
+
if not en:
|
|
690
|
+
return None
|
|
691
|
+
cand = {"品名英文": en,
|
|
692
|
+
"材质英文": str(d.get("材质英文") or "").strip(),
|
|
693
|
+
"海关编码": re.sub(r"\D", "", str(d.get("海关编码") or ""))[:10],
|
|
694
|
+
"_源": "LLM 实时翻译"}
|
|
695
|
+
log(f"LLM 实时翻译:{name_zh} → {en}(HS候选 {cand['海关编码'] or '-'})")
|
|
696
|
+
return cand
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def cache_product_dict(name_zh, cand):
|
|
700
|
+
"""把翻译结果写入本地品名词表(原子写),后续同名品名零成本命中。"""
|
|
701
|
+
name_zh = _norm_name(name_zh)
|
|
702
|
+
if not name_zh or not cand.get("品名英文"):
|
|
703
|
+
return
|
|
704
|
+
d = load_product_dict()
|
|
705
|
+
d[name_zh] = {"品名英文": strip_name_parens(cand["品名英文"]), "海关编码": cand.get("海关编码", ""),
|
|
706
|
+
"材质英文": cand.get("材质英文", ""), "confirmed": False,
|
|
707
|
+
"来源": "LLM 实时翻译 " + time.strftime("%Y-%m-%d")}
|
|
708
|
+
tmp = DATA / ".lip-product-dict.json.tmp"
|
|
709
|
+
try:
|
|
710
|
+
tmp.write_text(json.dumps(d, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
711
|
+
os.replace(tmp, PRODUCT_DICT_FILE)
|
|
712
|
+
except Exception as exc:
|
|
713
|
+
log(f"词表缓存写入失败({name_zh}):{exc}")
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def heal_dict_from_product(row, sku_dict):
|
|
717
|
+
"""字典缺品名英文 → 用本地中英品名词表自愈(无需等 agent/人)。
|
|
718
|
+
|
|
719
|
+
命中:写字典条目(confirmed=False、来源标注)+ 更新内存 dict,返回英文品名;
|
|
720
|
+
未命中:返回 None(保持缺口文案,下一轮继续试)。
|
|
721
|
+
"""
|
|
722
|
+
sku = _first(row.get(F_SKU))
|
|
723
|
+
if not sku or (sku_dict.get(sku) or {}).get("品名英文"):
|
|
724
|
+
return None
|
|
725
|
+
name_zh = _norm_name(_first(row.get(F_NAME)))
|
|
726
|
+
pdict = load_product_dict()
|
|
727
|
+
cand = pdict.get(name_zh) or {}
|
|
728
|
+
if not cand: # 兜底:按规范化键再试一轮
|
|
729
|
+
cand = next((v for k, v in pdict.items() if _norm_name(k) == name_zh), {})
|
|
730
|
+
if not (cand.get("品名英文") or "").strip():
|
|
731
|
+
# 全新品名:实时 LLM 翻译(≈1s),结果落词表缓存
|
|
732
|
+
cand = llm_translate_product(name_zh, _first(row.get(F_MAT)), _first(row.get(F_HS))) or {}
|
|
733
|
+
if cand:
|
|
734
|
+
cache_product_dict(name_zh, cand)
|
|
735
|
+
en = strip_name_parens((cand.get("品名英文") or "").strip())
|
|
736
|
+
if not en:
|
|
737
|
+
return None
|
|
738
|
+
src = cand.get("_源") or ("LLM 实时翻译" if str(cand.get("来源", "")).startswith("LLM") else "本地品名词表自愈")
|
|
739
|
+
entry = {
|
|
740
|
+
"品名英文": en,
|
|
741
|
+
"海关编码": (cand.get("海关编码") or _first(row.get(F_HS)) or "").strip(),
|
|
742
|
+
"材质英文": (cand.get("材质英文") or translate_material(_first(row.get(F_MAT))) or "").strip(),
|
|
743
|
+
"confirmed": False,
|
|
744
|
+
"来源": f"{src} {time.strftime('%Y-%m-%d')}",
|
|
745
|
+
"备注": "PM 接管自愈;发票跑通后固化",
|
|
746
|
+
}
|
|
747
|
+
if _write_dict_entry(sku, entry):
|
|
748
|
+
sku_dict[sku] = entry
|
|
749
|
+
log(f"{row.get('_id', '')} 字典自愈:{sku}({name_zh})→ {en}(confirmed=false,跑通后固化)")
|
|
750
|
+
return en
|
|
751
|
+
return None
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
# ── B. 不重复写表:值与当前一致时跳过写(减少无谓表写)──
|
|
755
|
+
def lip_update_if_changed(row, patch):
|
|
756
|
+
rid = row.get("_id", "")
|
|
757
|
+
changed = {k: v for k, v in patch.items() if _first(row.get(k)) != v}
|
|
758
|
+
if not changed:
|
|
759
|
+
return False
|
|
760
|
+
lip_update([rid], changed)
|
|
761
|
+
return True
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
# ── C. 大货标签「可续跑」:写表节流 + 失败可恢复 ──
|
|
765
|
+
# 实测(2026-09-10 15:19):刚上传的发票,鑫中航订单页搜不到该货件(数据行=0)
|
|
766
|
+
# → 大货标签暂不可下载;原实现直接写「失败」而 handle_row 对「失败」一律跳过 ⇒ 永久卡死等人工。
|
|
767
|
+
# 改为:发票状态=已上传(可续跑),每轮只补标签;写表按尝试次数节流(1/5 次,之后每 20 次)。
|
|
768
|
+
LABEL_WRITE_AT = (1, 5)
|
|
769
|
+
LABEL_WRITE_EVERY = 20
|
|
770
|
+
LABEL_HUMAN_HINT_AT = 20
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def _label_attempt_count(rid):
|
|
774
|
+
n = 0
|
|
775
|
+
try:
|
|
776
|
+
for line in LABEL_RETRY_FILE.read_text(encoding="utf-8").splitlines():
|
|
777
|
+
line = line.strip()
|
|
778
|
+
if not line:
|
|
779
|
+
continue
|
|
780
|
+
try:
|
|
781
|
+
d = json.loads(line)
|
|
782
|
+
except json.JSONDecodeError:
|
|
783
|
+
continue
|
|
784
|
+
if d.get("record_id") == rid:
|
|
785
|
+
n += 1
|
|
786
|
+
except Exception:
|
|
787
|
+
n = 0
|
|
788
|
+
return n
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def _append_label_attempt(rid, n, detail=""):
|
|
792
|
+
try:
|
|
793
|
+
LABEL_RETRY_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
794
|
+
with open(LABEL_RETRY_FILE, "a", encoding="utf-8") as f:
|
|
795
|
+
f.write(json.dumps({
|
|
796
|
+
"ts": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
797
|
+
"record_id": rid, "attempt": n, "detail": (detail or "")[-200:],
|
|
798
|
+
}, ensure_ascii=False) + "\n")
|
|
799
|
+
except Exception:
|
|
800
|
+
pass
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def _picks_of(spec):
|
|
804
|
+
"""该流程 `source` 下的 `pick` 候选(**变体优先 + 顶层兜底**,与引擎 `_picks_of` 同口径)。"""
|
|
805
|
+
src = (spec or {}).get("source") or {}
|
|
806
|
+
variants = src.get("variants") or {}
|
|
807
|
+
out = [((variants.get(str(src.get("default") or "")) or {}).get("pick") or {})]
|
|
808
|
+
out += [((v or {}).get("pick") or {}) for v in variants.values()]
|
|
809
|
+
out.append(src.get("pick") or {})
|
|
810
|
+
return out
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def _external_cooldown_min(doc=None, flow=None):
|
|
814
|
+
"""**外部时序**长冷却(分钟)—— 取自**声明面** `…releaseReDispatch.externalTiming.cooldownMin`。
|
|
815
|
+
|
|
816
|
+
⛔ **不写死 120**(那是注册表声明的值,不是代码常量);读不到/多条声明**不一致** ⇒ 返回 0
|
|
817
|
+
(文案就不编数字,但**仍然**给出"会自动再试 + 怎么人工接管"两要素)。
|
|
818
|
+
流程取 `ARGENT_FLOW`(未设 ⇒ 从声明面里找声明了该通道的那些流程;取值一致 ⇒ 用它,
|
|
819
|
+
不一致 ⇒ 0,⛔ 不猜)。读取链与引擎同源:合并注册表(`flow_registry.consumer_doc()`,
|
|
820
|
+
可用 `ARGENT_REGISTRY_ROOT` 隔离)。`doc` 可注入(夹具用,⛔ 不改判据)。
|
|
821
|
+
"""
|
|
822
|
+
try:
|
|
823
|
+
d = doc if doc is not None else _registry_doc()
|
|
824
|
+
flows = d.get("flows") or {}
|
|
825
|
+
fid = flow or os.environ.get("ARGENT_FLOW") or ""
|
|
826
|
+
if fid:
|
|
827
|
+
canon_id = (d.get("aliases") or {}).get(fid, fid)
|
|
828
|
+
cands = [flows.get(canon_id) or flows.get(fid) or {}]
|
|
829
|
+
else:
|
|
830
|
+
cands = list(flows.values())
|
|
831
|
+
vals = set()
|
|
832
|
+
for spec in cands:
|
|
833
|
+
for pick in _picks_of(spec):
|
|
834
|
+
et = ((pick.get("releaseReDispatch") or {}).get("externalTiming") or {})
|
|
835
|
+
if et.get("enabled") is not False and et.get("cooldownMin"):
|
|
836
|
+
vals.add(int(et["cooldownMin"]))
|
|
837
|
+
return vals.pop() if len(vals) == 1 else 0
|
|
838
|
+
except Exception: # noqa: BLE001(读不到 ⇒ 不编数字)
|
|
839
|
+
return 0
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def _label_wait_text(n, cooldown_min=None, doc=None):
|
|
843
|
+
""""标签还没生成"的运营可见文案(**外部时序类**,措辞必须与机制一致)。
|
|
844
|
+
|
|
845
|
+
2026-10-13(放行额度与上游变化):这类失败**不占运营放行额度** —— 引擎按 `externalTiming`
|
|
846
|
+
走独立通道(长冷却 + 独立上限)。旧文案写「系统**每轮**自动重试」是**兑现不了的话**
|
|
847
|
+
(实测:额度用满后该票再也不会被拾取,运营于是白补产品图 ⇒ 用户报的 BUG)。
|
|
848
|
+
2026-10-14(本批):补上运营真正需要的两句 —— **自动再试节奏**(约 N 分钟,N 取自声明面
|
|
849
|
+
`externalTiming.cooldownMin`,⛔ 不写死)与**人工接管方式**(现在处理完就**取消勾选**
|
|
850
|
+
「发票就绪」⇒ 系统不再自动拾取)。两要素缺一即判红(回归
|
|
851
|
+
`scripts/tests/external-timing-ops-copy.test.py`)。
|
|
852
|
+
⚠️ 卡片侧同一句见 `scripts/flow_triage.py#external_wait_copy`(那边是 markdown 加粗版;
|
|
853
|
+
表字段是纯文本 ⇒ 这里不写 `**`)。
|
|
854
|
+
"""
|
|
855
|
+
n_min = _external_cooldown_min(doc=doc) if cooldown_min is None else int(cooldown_min or 0)
|
|
856
|
+
cadence = (f"系统将在约 {n_min} 分钟后自动再试;" if n_min > 0 else "系统会在冷却到期后自动再试;")
|
|
857
|
+
takeover = f"你也可以现在人工处理(处理完取消勾选「{F_READY}」,系统就不再自动拾取)"
|
|
858
|
+
tail = f"{cadence}{takeover}"
|
|
859
|
+
if n >= LABEL_HUMAN_HINT_AT:
|
|
860
|
+
return (f"发票已上传;鑫中航暂无该大货订单/标签(已尝试 {n} 次,不占运营放行额度)"
|
|
861
|
+
f"——{tail}|请确认鑫中航是否已生成该单")
|
|
862
|
+
return f"发票已上传;鑫中航暂无该大货订单/标签(第 {n} 次尝试)——{tail}"
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
def _first(v):
|
|
866
|
+
if v is None:
|
|
867
|
+
return ""
|
|
868
|
+
if isinstance(v, list):
|
|
869
|
+
return str(v[0]).strip() if v and v[0] is not None else ""
|
|
870
|
+
return str(v).strip()
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def _num(v):
|
|
874
|
+
s = _first(v)
|
|
875
|
+
try:
|
|
876
|
+
return float(s) if s else 0.0
|
|
877
|
+
except ValueError:
|
|
878
|
+
return 0.0
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def _num_ok(v):
|
|
882
|
+
return _num(v) > 0
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
# ── 字典与词表 ──
|
|
886
|
+
def load_dict():
|
|
887
|
+
if DICT_FILE.exists():
|
|
888
|
+
return json.loads(DICT_FILE.read_text(encoding="utf-8"))
|
|
889
|
+
return {}
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def save_dict(d):
|
|
893
|
+
DICT_FILE.write_text(json.dumps(d, ensure_ascii=False, indent=1), encoding="utf-8")
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
def load_mat_dict():
|
|
897
|
+
if MAT_DICT_FILE.exists():
|
|
898
|
+
return json.loads(MAT_DICT_FILE.read_text(encoding="utf-8"))
|
|
899
|
+
return {}
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
def translate_material(zh):
|
|
903
|
+
"""中文材质 → 英文(词表)。多材质(含 '/' '、' '+')分段翻译。"""
|
|
904
|
+
md = load_mat_dict()
|
|
905
|
+
zh = str(zh or "").strip()
|
|
906
|
+
if not zh:
|
|
907
|
+
return ""
|
|
908
|
+
parts = re.split(r"[\/、+]+", zh)
|
|
909
|
+
en = []
|
|
910
|
+
for p in parts:
|
|
911
|
+
p = p.strip()
|
|
912
|
+
if not p:
|
|
913
|
+
continue
|
|
914
|
+
if p in md:
|
|
915
|
+
en.append(md[p])
|
|
916
|
+
else:
|
|
917
|
+
return "" # 未命中整段视为缺(由调用方提示)
|
|
918
|
+
return ", ".join(en)
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
# ── hsbianma 自动查询(headless,复用批量脚本验证逻辑)──
|
|
922
|
+
def _resolve_browser():
|
|
923
|
+
"""浏览器路径探测:Chrome → Edge(国内环境常用,Chromium 同内核 playwright 兼容)。"""
|
|
924
|
+
cands = [
|
|
925
|
+
os.environ.get("INVOICE_BROWSER", ""),
|
|
926
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
927
|
+
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
928
|
+
]
|
|
929
|
+
for c in cands:
|
|
930
|
+
if c and os.path.exists(c):
|
|
931
|
+
return c
|
|
932
|
+
return ""
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
CHROME = _resolve_browser()
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
def hsbianma_query(name):
|
|
939
|
+
"""按商品名查 hsbianma 返回候选编码列表(10 位)。失败/无结果返回 []。"""
|
|
940
|
+
if not CHROME:
|
|
941
|
+
log("浏览器缺失(Chrome/Edge 未安装),HS 自动查询不可用")
|
|
942
|
+
return []
|
|
943
|
+
from playwright.sync_api import sync_playwright
|
|
944
|
+
out = []
|
|
945
|
+
try:
|
|
946
|
+
with sync_playwright() as p:
|
|
947
|
+
b = p.chromium.launch(headless=True, executable_path=CHROME,
|
|
948
|
+
args=["--no-sandbox"])
|
|
949
|
+
pg = b.new_page()
|
|
950
|
+
pg.goto("https://www.hsbianma.com/", timeout=20000,
|
|
951
|
+
wait_until="domcontentloaded")
|
|
952
|
+
pg.wait_for_timeout(1500)
|
|
953
|
+
box = pg.locator("input[type=text], input[placeholder*=搜索], "
|
|
954
|
+
"input[placeholder*=编码]").first
|
|
955
|
+
box.fill(name)
|
|
956
|
+
box.press("Enter")
|
|
957
|
+
pg.wait_for_timeout(3500)
|
|
958
|
+
rows = pg.locator("table tr").all()[:6]
|
|
959
|
+
for r in rows:
|
|
960
|
+
cells = [c.strip() for c in r.locator("td").all_inner_texts()]
|
|
961
|
+
if cells and re.fullmatch(r"\d{8,12}", cells[0]):
|
|
962
|
+
out.append(cells[0])
|
|
963
|
+
b.close()
|
|
964
|
+
except Exception as exc:
|
|
965
|
+
log(f"hsbianma 查询失败 {name}: {exc}")
|
|
966
|
+
return out
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
# ── 预检与自动补全 ──
|
|
970
|
+
def precheck(row, sku_dict):
|
|
971
|
+
"""返回缺口列表 [] 或 [缺项,...]。sku_dict 值:None=字典无 / dict=候选。"""
|
|
972
|
+
gaps = []
|
|
973
|
+
sku = _first(row.get(F_SKU))
|
|
974
|
+
sd = sku_dict.get(sku)
|
|
975
|
+
if sd is None or not sd.get("品名英文"):
|
|
976
|
+
if _name_usable(row):
|
|
977
|
+
gaps.append("SKU字典缺品名英文(待AI补录)")
|
|
978
|
+
else:
|
|
979
|
+
# 产品名称=SKU 本身/空 → 属运营待办(用户 2026-09-10 口径),我方不自愈不编造
|
|
980
|
+
gaps.append(GAP_NEED_NAME)
|
|
981
|
+
if not _first(row.get(F_HS)):
|
|
982
|
+
if sd and sd.get("confirmed") and sd.get("海关编码"):
|
|
983
|
+
pass # HS 列空但字典已确认 → 由补全步骤回填字典值
|
|
984
|
+
else:
|
|
985
|
+
gaps.append("HS编码待预填/核对")
|
|
986
|
+
if not _first(row.get(F_MAT)):
|
|
987
|
+
# 缺中文材质 → 运营待办(同族:缺产品图,见下)
|
|
988
|
+
gaps.append("缺中文材质(运营补)")
|
|
989
|
+
# 缺产品图 ⇒ **运营待办**(用户口径 2026-10-13:表里没有产品图时必须把缺口写进「发票错误信息」,
|
|
990
|
+
# 提示运营补图,⛔ 不许默默往下走 —— 现场 79408052 / 79406731 两行就是无图却没有任何提示)。
|
|
991
|
+
# ⚠️ 本 runner 的图源**只有**本表行内的 `产品图` 附件(见本文件下载函数);其它 runner 可能有 SKU 主数据兜底,
|
|
992
|
+
# 那条路径不适用于本链 ⇒ 这里以"行内附件为空"为判据。
|
|
993
|
+
if not (row.get(F_IMG) or []):
|
|
994
|
+
gaps.append("缺产品图(运营补)")
|
|
995
|
+
if not _num_ok(row.get(F_QTY)):
|
|
996
|
+
gaps.append("缺数量")
|
|
997
|
+
if not _num_ok(row.get(F_BOX)):
|
|
998
|
+
gaps.append("缺箱数")
|
|
999
|
+
if not _num_ok(row.get(F_WEIGHT)):
|
|
1000
|
+
gaps.append("缺重量")
|
|
1001
|
+
if not (_num_ok(row.get(F_LEN)) and _num_ok(row.get(F_WID))
|
|
1002
|
+
and _num_ok(row.get(F_HGT))):
|
|
1003
|
+
gaps.append("缺尺寸")
|
|
1004
|
+
return gaps
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
# ── KB-125:缺口分型(我方动作 / 运营动作)──
|
|
1008
|
+
# 我方动作缺口(补字典 / 预填核对 HS)——运营勾「发票就绪」也无法推进,
|
|
1009
|
+
# 文案必须区别于运营动作缺口,避免再次出现「勾了就绪无反应」的困惑。
|
|
1010
|
+
GAP_NEED_NAME = "缺产品名称(表里填的是 SKU 本身,运营补)"
|
|
1011
|
+
|
|
1012
|
+
OUR_GAPS = {
|
|
1013
|
+
"SKU字典缺品名英文(待AI补录)",
|
|
1014
|
+
"HS编码待预填/核对",
|
|
1015
|
+
"HS自动查询无结果,请人工查填",
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
def _name_usable(row):
|
|
1020
|
+
"""产品名称是否可用于生成英文品名。
|
|
1021
|
+
|
|
1022
|
+
运营常见填法:留空、直接填 SKU(如 HW-MX-091)、填纯编码 → 均视为「需运营补真实品名」,
|
|
1023
|
+
不做自愈(不编造品名);只有真实中文/英文品名才走我方字典补录/自愈。
|
|
1024
|
+
"""
|
|
1025
|
+
name = _norm_name(_first(row.get(F_NAME)))
|
|
1026
|
+
if not name:
|
|
1027
|
+
return False
|
|
1028
|
+
sku = _norm_name(_first(row.get(F_SKU)))
|
|
1029
|
+
if sku and name.upper().replace(" ", "") == sku.upper().replace(" ", ""):
|
|
1030
|
+
return False
|
|
1031
|
+
# 纯编码形态(无中文、无空格、像型号):HW-MX-091 / HW-XXX-123-1 等
|
|
1032
|
+
if re.fullmatch(r"[A-Za-z0-9._/-]+", name) and re.search(r"\d", name):
|
|
1033
|
+
return False
|
|
1034
|
+
return True
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
def split_gaps(gaps):
|
|
1038
|
+
"""缺口按动作方拆分 → (我方缺口, 运营缺口)。"""
|
|
1039
|
+
ours = [g for g in gaps if g in OUR_GAPS]
|
|
1040
|
+
ops = [g for g in gaps if g not in OUR_GAPS]
|
|
1041
|
+
return ours, ops
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
def gap_hint(gaps):
|
|
1045
|
+
"""缺口尾部指引(KB-125 B-1):我方动作 → 明确「无需运营操作」。"""
|
|
1046
|
+
ours, ops = split_gaps(gaps)
|
|
1047
|
+
if ours and ops:
|
|
1048
|
+
return "(我方补录;运营补齐字段后勾选发票就绪)"
|
|
1049
|
+
if ours:
|
|
1050
|
+
return "(我方补录,无需运营操作)"
|
|
1051
|
+
return "(处理完勾选发票就绪)"
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
def fill_hs_candidate(row, sku_dict):
|
|
1055
|
+
"""HS编码 列空时自动补全:字典已确认→回填字典值;未确认→hsbianma 查候选预填。
|
|
1056
|
+
返回 (动作文案, 是否已填候选)。"""
|
|
1057
|
+
sku = _first(row.get(F_SKU))
|
|
1058
|
+
sd = sku_dict.get(sku) or {}
|
|
1059
|
+
name = _first(row.get(F_NAME)) or sku
|
|
1060
|
+
if sd.get("confirmed") and sd.get("海关编码"):
|
|
1061
|
+
lip_update([row["_id"]], {F_HS: sd["海关编码"]})
|
|
1062
|
+
return f"HS已回填字典确认值 {sd['海关编码']}", True
|
|
1063
|
+
cands = hsbianma_query(name.split("-")[0].split("(")[0].strip())
|
|
1064
|
+
if cands:
|
|
1065
|
+
lip_update([row["_id"]], {F_HS: cands[0]})
|
|
1066
|
+
return f"HS候选已预填 {cands[0]}(共{len(cands)}条候选,请核对)", True
|
|
1067
|
+
return "HS自动查询无结果,请人工查填", False
|
|
1068
|
+
|
|
1069
|
+
|
|
1070
|
+
# ── 生成发票(组装 record → 复用 invoice_runner.generate_invoice)──
|
|
1071
|
+
def generate_for_row(row):
|
|
1072
|
+
import importlib.util
|
|
1073
|
+
spec = importlib.util.spec_from_file_location(
|
|
1074
|
+
"invoice_runner", str(Path(__file__).parent / "invoice_runner.py"))
|
|
1075
|
+
ir = importlib.util.module_from_spec(spec)
|
|
1076
|
+
spec.loader.exec_module(ir)
|
|
1077
|
+
mapping = ir.load_mapping()
|
|
1078
|
+
sku = _first(row.get(F_SKU))
|
|
1079
|
+
sd = (load_dict().get(sku) or {})
|
|
1080
|
+
hs = _first(row.get(F_HS)) or (sd.get("海关编码") if sd.get("confirmed") else "")
|
|
1081
|
+
mat_zh = _first(row.get(F_MAT))
|
|
1082
|
+
mat_en = translate_material(mat_zh) or (sd.get("材质英文") or "")
|
|
1083
|
+
# 组装 generate_invoice 的 record dict(对齐 dsh 版消费字段)
|
|
1084
|
+
store = _first(row.get(F_STORE))
|
|
1085
|
+
# ⚠️ 这里**刻意不再读 stores.json 的紫鸟 store_id**:它是另一个 ID 体系
|
|
1086
|
+
# (浏览器店铺 ID ≠ ML 卖家 ID),历史上正是它被当"兜底值"写进发票(见下方事故说明)。
|
|
1087
|
+
# ── PO Number = ML 卖家 user_id(2026-09-19 收敛为唯一真源 + 响亮失败)──────────────
|
|
1088
|
+
# 历史事故:这里曾按 `lip-po-mapping.json`(从未创建)→ 回退 `~/.dsh/argent/stores.json`
|
|
1089
|
+
# 的**紫鸟 store_id**(浏览器店铺 ID,如 27494792824433)。那是另一个 ID 体系,
|
|
1090
|
+
# 写进发票就是错的(应为 ML 卖家 ID,如 2475636269)—— 6 张发票因此写错且无人发现。
|
|
1091
|
+
# 另一个失效源:invoice-mapping.json#storeId 的键是短名 `1店-子账号`,lip 的完整店名
|
|
1092
|
+
# `墨西哥-1店-子账号` 根本匹配不上。
|
|
1093
|
+
# 现在:**只认 scripts/ml-store-ids.json**(键=国家-店号,主/子账号共用同一 ID);
|
|
1094
|
+
# 解析不到 ⇒ **不发发票**(写「发票错误信息」,交人补配置),**绝不用别的 ID 顶上**。
|
|
1095
|
+
store_ml = ml_store_ids.resolve(store)
|
|
1096
|
+
if not store_ml:
|
|
1097
|
+
# 原因写清楚(用户 2026-09-19 口径:「巴西-1店当前是没有给到账号的,写明原因即可」):
|
|
1098
|
+
# 这是**已知的等待态**(等运营给账号),不是系统故障 ⇒ 措辞是"陈述+下一步"。
|
|
1099
|
+
msg = (f"{ml_store_ids.missing_reason(store)} ⇒ 本行**暂不自动开发票**"
|
|
1100
|
+
f"(拿到账号 ID 后加入 scripts/ml-store-ids.json 即会自动跑;"
|
|
1101
|
+
f"**不会**用紫鸟 store_id 顶替 PO)")
|
|
1102
|
+
log(f"❌ {msg}")
|
|
1103
|
+
try:
|
|
1104
|
+
if row.get("_id"):
|
|
1105
|
+
lip_update_if_changed(row, {F_ERR: msg})
|
|
1106
|
+
except Exception as exc:
|
|
1107
|
+
log(f"⚠️ 回填「发票错误信息」失败: {exc}")
|
|
1108
|
+
return f"{_first(row.get('_id')) or '(未知记录)'} 缺 ML 卖家 ID(未生成发票)"
|
|
1109
|
+
mapping.setdefault("storeId", {})[store] = store_ml
|
|
1110
|
+
|
|
1111
|
+
# B6/B7 带电/带磁:读 lip「是否带电/是否带磁」列(运营新增,默认否兜底),
|
|
1112
|
+
# 覆盖 defaults 后 generate_invoice 填模板 R6/R7(B6 带电 / B7 带磁)。
|
|
1113
|
+
mapping.setdefault("defaults", {})["带电"] = _yes_no(row.get(F_CHARGE))
|
|
1114
|
+
mapping.setdefault("defaults", {})["带磁"] = _yes_no(row.get(F_MAGNETIC))
|
|
1115
|
+
record = {
|
|
1116
|
+
"货件号": _first(row.get(F_SHIP)),
|
|
1117
|
+
"品名": _first(row.get(F_NAME)),
|
|
1118
|
+
"数量": _first(row.get(F_QTY)),
|
|
1119
|
+
"箱数": _first(row.get(F_BOX)),
|
|
1120
|
+
"店铺名称": store,
|
|
1121
|
+
"国内收货仓库": mapping.get("defaults", {}).get("国内收货仓库", "深圳仓"),
|
|
1122
|
+
"到达时间": _first(row.get(F_ETA)) or "本周",
|
|
1123
|
+
"备注": "",
|
|
1124
|
+
"sku_data": {
|
|
1125
|
+
"品名英文": strip_name_parens(sd.get("品名英文") or ""),
|
|
1126
|
+
"海关编码": hs,
|
|
1127
|
+
"材质中文": mat_zh,
|
|
1128
|
+
"材质英文": mat_en,
|
|
1129
|
+
"重量(kg/箱)": _first(row.get(F_WEIGHT)),
|
|
1130
|
+
"长(cm)": _first(row.get(F_LEN)),
|
|
1131
|
+
"宽(cm)": _first(row.get(F_WID)),
|
|
1132
|
+
"高(cm)": _first(row.get(F_HGT)),
|
|
1133
|
+
"产品图": [],
|
|
1134
|
+
},
|
|
1135
|
+
}
|
|
1136
|
+
tpl = mapping.get("templates", {}).get("鑫中航", "")
|
|
1137
|
+
if not tpl or not os.path.exists(tpl):
|
|
1138
|
+
raise RuntimeError(f"鑫中航模板缺失: {tpl}")
|
|
1139
|
+
import openpyxl
|
|
1140
|
+
wb = openpyxl.load_workbook(tpl)
|
|
1141
|
+
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
1142
|
+
out = OUTPUT_DIR / f"{_first(row.get(F_SHIP))}-发票.xlsx"
|
|
1143
|
+
ir.generate_invoice(record, mapping, wb, out)
|
|
1144
|
+
# 产品图(lip「产品图」列附件)→ 插图(复用 dsh 版锚点逻辑;lip 附件从本表下载)
|
|
1145
|
+
imgs = row.get(F_IMG) or []
|
|
1146
|
+
if isinstance(imgs, list) and imgs and isinstance(imgs[0], dict):
|
|
1147
|
+
ft = str(imgs[0].get("file_token", ""))
|
|
1148
|
+
if ft:
|
|
1149
|
+
local_img = lip_download_attachment(row["_id"], ft, DATA / "invoice-tmp-lip")
|
|
1150
|
+
if local_img:
|
|
1151
|
+
from openpyxl.drawing.image import Image as XLImage
|
|
1152
|
+
from openpyxl.drawing.spreadsheet_drawing import TwoCellAnchor, AnchorMarker
|
|
1153
|
+
wb2 = openpyxl.load_workbook(out)
|
|
1154
|
+
ws2 = wb2[SHEET_NAME]
|
|
1155
|
+
ws2._images.clear()
|
|
1156
|
+
ws2.row_dimensions[22].height = 93
|
|
1157
|
+
xl_img = XLImage(str(local_img))
|
|
1158
|
+
xl_img.anchor = TwoCellAnchor(
|
|
1159
|
+
editAs="oneCell",
|
|
1160
|
+
_from=AnchorMarker(col=17, colOff=210185, row=21, rowOff=265430),
|
|
1161
|
+
to=AnchorMarker(col=17, colOff=1675765, row=21, rowOff=880745),
|
|
1162
|
+
)
|
|
1163
|
+
ws2.add_image(xl_img)
|
|
1164
|
+
wb2.save(str(out))
|
|
1165
|
+
try:
|
|
1166
|
+
ir.fix_drawing_xfrm(out, ws2, 0, 0, 0, 0, 0)
|
|
1167
|
+
except Exception:
|
|
1168
|
+
pass
|
|
1169
|
+
return out
|
|
1170
|
+
|
|
1171
|
+
|
|
1172
|
+
# ── 主流程 ──
|
|
1173
|
+
def _run_stageb(args):
|
|
1174
|
+
"""调 invoice_stageb.py(同目录,当前解释器)。返回 stdout(失败时附 stderr 末尾)。
|
|
1175
|
+
|
|
1176
|
+
注意:必须用带 playwright 的解释器(客户机为 ~/.dsh/argent/venv/bin/python3)运行本
|
|
1177
|
+
脚本;KB-125 实测:用系统 python3 跑时 stageb 抛 ModuleNotFoundError → stdout
|
|
1178
|
+
为空 → 下游误判为「标签下载失败」。故此处把 stderr 末尾一并回传(可诊断)。
|
|
1179
|
+
"""
|
|
1180
|
+
stageb = str(Path(__file__).parent / "invoice_stageb.py")
|
|
1181
|
+
r = subprocess.run([sys.executable, stageb, *args],
|
|
1182
|
+
capture_output=True, text=True, timeout=300)
|
|
1183
|
+
out = r.stdout or ""
|
|
1184
|
+
if r.returncode != 0 and (r.stderr or "").strip():
|
|
1185
|
+
tail = (r.stderr or "").strip().splitlines()[-3:]
|
|
1186
|
+
out = (out + "\n" + "\n".join(tail)).strip()
|
|
1187
|
+
log(f"stageb {' '.join(args[:1])} 退出码={r.returncode}: {tail[-1][:200]}")
|
|
1188
|
+
return out
|
|
1189
|
+
|
|
1190
|
+
|
|
1191
|
+
def _confirm_dict(sku, hs, mat_en, name_en=None):
|
|
1192
|
+
"""发票流程跑通后固化字典(confirmed=true,本次实际使用值)。"""
|
|
1193
|
+
d = load_dict()
|
|
1194
|
+
cur = d.get(sku) or {}
|
|
1195
|
+
d[sku] = {"品名英文": strip_name_parens(name_en or cur.get("品名英文", "")),
|
|
1196
|
+
"海关编码": hs or cur.get("海关编码", ""),
|
|
1197
|
+
"材质英文": mat_en or cur.get("材质英文", ""),
|
|
1198
|
+
"confirmed": True, "来源": "实际跑通固化 " + time.strftime("%Y-%m-%d"),
|
|
1199
|
+
"备注": ""}
|
|
1200
|
+
save_dict(d)
|
|
1201
|
+
log(f"字典固化: {sku} confirmed=true (HS={hs})")
|
|
1202
|
+
|
|
1203
|
+
|
|
1204
|
+
def _upload_and_finish(row, out, generate_only=False, dry=False):
|
|
1205
|
+
"""上传鑫中航 → 下载大货标签 → 回填 → 已完成 + 固化。返回动作描述。
|
|
1206
|
+
|
|
1207
|
+
KB-125 修复(阻断性):原先内部调 `_finish_with_label(row, dry=dry)` 但本函数
|
|
1208
|
+
无 dry 形参 → `NameError: name 'dry' is not defined`,导致「新上传成功后」的路径
|
|
1209
|
+
整行处理异常(实测 recvuIaAFow03Z 2026-09-10 15:36)。此处补形参并透传。
|
|
1210
|
+
"""
|
|
1211
|
+
rid = row["_id"]
|
|
1212
|
+
sku = _first(row.get(F_SKU))
|
|
1213
|
+
ship = _first(row.get(F_SHIP))
|
|
1214
|
+
# 1) 上传(CLI stdout 含「上传结果」dialogs;重复=鑫中航已有该单=幂等成功)
|
|
1215
|
+
# 弹窗偶发未捕获(15s 等待不足)→ 判定缺失时重试一次再下结论。
|
|
1216
|
+
up_out = _run_stageb(["--upload", str(out)])
|
|
1217
|
+
if ("全部完成" not in up_out and "成功" not in up_out
|
|
1218
|
+
and "重复" not in up_out and "参考单号" not in up_out):
|
|
1219
|
+
log(f"{rid} 上传结果弹窗未捕获,重试一次…")
|
|
1220
|
+
up_out = _run_stageb(["--upload", str(out)])
|
|
1221
|
+
if "参考单号" in up_out and "重复" in up_out:
|
|
1222
|
+
log(f"{rid} 上传: 鑫中航已有该单发票(幂等,视为已存在)")
|
|
1223
|
+
# 已存在也补下载大货标签(2026-09-09:漏下载致大货标签字段空)
|
|
1224
|
+
label_path, dl_out, _from_local = _download_label_or_local(row, ship)
|
|
1225
|
+
if label_path and os.path.exists(label_path):
|
|
1226
|
+
lip_upload_attachment(rid, F_LABEL, label_path, row=row)
|
|
1227
|
+
# 完成即取消勾选「发票就绪」:留 true 会让"已完成"的行继续满足门条件
|
|
1228
|
+
# (门 = FULL状态=已完成 ∧ 发票就绪=true)⇒ 每轮重复派发。口径⑤要求如此。
|
|
1229
|
+
lip_update([rid], {F_ST: ST_DONE, F_READY: False, F_ERR: ""})
|
|
1230
|
+
else:
|
|
1231
|
+
# 标签仍未就绪 → 落可续跑态,下一轮只补标签(不重复生成/上传)
|
|
1232
|
+
n = _label_attempt_count(rid) + 1
|
|
1233
|
+
_append_label_attempt(rid, n, dl_out)
|
|
1234
|
+
lip_update_if_changed(row, {F_ST: ST_UPLOADED, F_ERR: _label_wait_text(n)})
|
|
1235
|
+
log(f"{rid} 已存在(鑫中航有单)但标签未就绪(第 {n} 次)→ 下一轮补")
|
|
1236
|
+
return f"{rid} 已存在但标签未就绪(第{n}次)"
|
|
1237
|
+
_confirm_dict(sku, _first(row.get(F_HS)),
|
|
1238
|
+
translate_material(_first(row.get(F_MAT))))
|
|
1239
|
+
return f"{rid} 已存在(鑫中航有单)→ 已完成+固化"
|
|
1240
|
+
if "全部完成" not in up_out and "成功" not in up_out and "完成 1/1" not in up_out:
|
|
1241
|
+
lip_update([rid], {F_ST: ST_FAIL, F_ERR: f"上传失败(鑫中航)——请人工检查"})
|
|
1242
|
+
log(f"{rid} 上传失败: {up_out[-300:]}")
|
|
1243
|
+
return f"{rid} 上传失败"
|
|
1244
|
+
log(f"{rid} 上传成功")
|
|
1245
|
+
if generate_only:
|
|
1246
|
+
lip_update([rid], {F_ST: ST_FILL, F_ERR: ""})
|
|
1247
|
+
return f"{rid} 上传成功(generate-only 停在填写中)"
|
|
1248
|
+
# 2) 下载大货标签 → 回填 → 已完成 + 固化(标签未就绪则落可续跑态,不永久失败)
|
|
1249
|
+
return _finish_with_label(row, dry=dry)
|
|
1250
|
+
|
|
1251
|
+
|
|
1252
|
+
def _finish_with_label(row, dry=False):
|
|
1253
|
+
"""下载大货标签 → 回填「大货标签」→ 已完成 + 固化。
|
|
1254
|
+
|
|
1255
|
+
标签未就绪(实测:刚上传的发票在鑫中航订单页搜不到该货件)→ 发票状态=已上传(可续跑态),
|
|
1256
|
+
每轮自动重试且只补标签;文案按尝试次数节流写入(避免每 5 分钟重复写表)。
|
|
1257
|
+
"""
|
|
1258
|
+
rid = row["_id"]
|
|
1259
|
+
sku = _first(row.get(F_SKU))
|
|
1260
|
+
ship = _first(row.get(F_SHIP))
|
|
1261
|
+
if dry:
|
|
1262
|
+
return f"{rid} [dry] 补标签"
|
|
1263
|
+
label_path, dl_out, _from_local = _download_label_or_local(row, ship)
|
|
1264
|
+
if not label_path or not os.path.exists(label_path):
|
|
1265
|
+
n = _label_attempt_count(rid) + 1
|
|
1266
|
+
_append_label_attempt(rid, n, dl_out)
|
|
1267
|
+
if n in LABEL_WRITE_AT or n % LABEL_WRITE_EVERY == 0:
|
|
1268
|
+
lip_update_if_changed(row, {F_ST: ST_UPLOADED, F_ERR: _label_wait_text(n)})
|
|
1269
|
+
log(f"{rid} 标签未就绪(第 {n} 次)→ 已上传态等待下一轮")
|
|
1270
|
+
else:
|
|
1271
|
+
log(f"{rid} 标签未就绪(第 {n} 次)→ 继续等待(不写表)")
|
|
1272
|
+
return f"{rid} 标签未就绪(第{n}次)"
|
|
1273
|
+
lip_upload_attachment(rid, F_LABEL, label_path, row=row)
|
|
1274
|
+
# 3) 回填完成 + 固化(同样取消勾选「发票就绪」,见上)
|
|
1275
|
+
lip_update([rid], {F_ST: ST_DONE, F_READY: False, F_ERR: ""})
|
|
1276
|
+
_confirm_dict(sku, _first(row.get(F_HS)),
|
|
1277
|
+
translate_material(_first(row.get(F_MAT))))
|
|
1278
|
+
log(f"{rid} 发票流程完成(上传+标签+固化)")
|
|
1279
|
+
return f"{rid} 完成(上传+标签+固化)"
|
|
1280
|
+
|
|
1281
|
+
|
|
1282
|
+
def handle_row(row, generate_only=False, dry=False, stats=None):
|
|
1283
|
+
"""处理一行。返回动作描述。stats(可选):缺口计数 {'our':n,'ops':m}(KB-125 B-3)。"""
|
|
1284
|
+
rid = row["_id"]
|
|
1285
|
+
sku = _first(row.get(F_SKU))
|
|
1286
|
+
st = _first(row.get(F_ST))
|
|
1287
|
+
freight = _first(row.get(F_FREIGHT))
|
|
1288
|
+
|
|
1289
|
+
# PM 接管:历史「大货标签下载失败」的失败行视为可续跑(发票已上传,只差标签)
|
|
1290
|
+
if st == ST_FAIL and "大货标签" in (_first(row.get(F_ERR)) or ""):
|
|
1291
|
+
st = ST_UPLOADED
|
|
1292
|
+
|
|
1293
|
+
if st in (ST_DONE, ST_SKIP, ST_FAIL):
|
|
1294
|
+
return f"{rid} 状态={st} 跳过"
|
|
1295
|
+
if freight != XINZHONGHANG_STORE:
|
|
1296
|
+
if st == ST_NOT or st == "":
|
|
1297
|
+
if not dry:
|
|
1298
|
+
lip_update([rid], {F_ST: ST_SKIP,
|
|
1299
|
+
F_ERR: f"货代{freight or '空'}未接入发票自动化(首期仅{XINZHONGHANG_STORE})"})
|
|
1300
|
+
return f"{rid} 货代={freight or '空'} → 跳过"
|
|
1301
|
+
return f"{rid} 货代不符但状态={st} 不动"
|
|
1302
|
+
# 发票已生成/已上传(填写中 / 已上传 / 标签已下载)= 只差大货标签 → 只补标签
|
|
1303
|
+
# (幂等:不重复生成、不重复上传;避免每轮重复出票)
|
|
1304
|
+
if st in (ST_FILL, ST_UPLOADED, ST_LABEL):
|
|
1305
|
+
return _finish_with_label(row, dry=dry)
|
|
1306
|
+
if st == ST_PICK and row.get(F_READY) is not True:
|
|
1307
|
+
# 表格文案即行动指令(用户口径 2026-09-10):就绪未勾时也保持缺口文案准确
|
|
1308
|
+
# (只刷新文案,不做任何补全/生成;文案不变则不写表)
|
|
1309
|
+
if not dry:
|
|
1310
|
+
gaps0 = precheck(row, load_dict())
|
|
1311
|
+
if gaps0:
|
|
1312
|
+
lip_update_if_changed(row, {F_ERR: "需处理:" + ";".join(gaps0) + gap_hint(gaps0)})
|
|
1313
|
+
return f"{rid} 待勾选未就绪 等待"
|
|
1314
|
+
|
|
1315
|
+
sku_dict = load_dict()
|
|
1316
|
+
gaps = precheck(row, sku_dict)
|
|
1317
|
+
# 字典缺品名英文 → 本地品名词表自愈(命中即补字典并本轮继续,不阻塞拾取)
|
|
1318
|
+
if any(g.startswith("SKU字典缺品名英文") for g in gaps) and not dry and _name_usable(row):
|
|
1319
|
+
if heal_dict_from_product(row, sku_dict):
|
|
1320
|
+
gaps = precheck(row, sku_dict)
|
|
1321
|
+
if gaps:
|
|
1322
|
+
# 能自动补的(HS)先补(dry-run 只报告不查询不写)
|
|
1323
|
+
if "HS编码待预填/核对" in gaps and not dry:
|
|
1324
|
+
act, filled = fill_hs_candidate(row, sku_dict)
|
|
1325
|
+
if filled:
|
|
1326
|
+
gaps.remove("HS编码待预填/核对")
|
|
1327
|
+
log(f"{rid} {act}")
|
|
1328
|
+
else:
|
|
1329
|
+
gaps[gaps.index("HS编码待预填/核对")] = "HS自动查询无结果,请人工查填"
|
|
1330
|
+
elif "HS编码待预填/核对" in gaps and dry:
|
|
1331
|
+
gaps[gaps.index("HS编码待预填/核对")] = "HS编码待预填(dry-run 不查询)"
|
|
1332
|
+
if gaps:
|
|
1333
|
+
ours, ops = split_gaps(gaps) # 先分类:决定要不要动「发票就绪」
|
|
1334
|
+
if not dry:
|
|
1335
|
+
# 文案按动作方区分(我方缺口明确「无需运营操作」);同内容不重复写表
|
|
1336
|
+
patch = {F_ST: ST_PICK, F_ERR: "需处理:" + ";".join(gaps) + gap_hint(gaps)}
|
|
1337
|
+
if ops:
|
|
1338
|
+
# 口径⑤(2026-09-22 用户授权实现):**运营缺口** ⇒ 取消勾选「发票就绪」,
|
|
1339
|
+
# 运营补齐后需自己重勾(这就是放行勾的握手语义:不重勾 ⇒ 不会带着缺口被派发)。
|
|
1340
|
+
# 我方缺口**不动**它 —— 不该让运营为我们的问题重勾一遍。
|
|
1341
|
+
patch[F_READY] = False
|
|
1342
|
+
lip_update_if_changed(row, patch)
|
|
1343
|
+
if stats is not None:
|
|
1344
|
+
if ours:
|
|
1345
|
+
stats["our"] = stats.get("our", 0) + 1
|
|
1346
|
+
if ops:
|
|
1347
|
+
stats["ops"] = stats.get("ops", 0) + 1
|
|
1348
|
+
return f"{rid} 缺口: {gaps}"
|
|
1349
|
+
# HS 补完且无其他缺口 → 落待勾选(HS 候选需运营核)
|
|
1350
|
+
if not dry:
|
|
1351
|
+
# 同属"等运营核"的缺口(gaps 为空但 HS 只是候选)⇒ 同样取消勾选就绪(口径⑤)
|
|
1352
|
+
lip_update([rid], {F_ST: ST_PICK, F_READY: False,
|
|
1353
|
+
F_ERR: "HS已补候选,请核对HS/材质后勾选发票就绪"})
|
|
1354
|
+
return f"{rid} HS候选待核 → 待勾选"
|
|
1355
|
+
|
|
1356
|
+
# 字段齐:未开发票/空 → 字典已确认直接生成;待勾选+就绪 → 生成
|
|
1357
|
+
sd = sku_dict.get(sku) or {}
|
|
1358
|
+
if st == ST_PICK and row.get(F_READY) is not True:
|
|
1359
|
+
return f"{rid} 待勾选未就绪"
|
|
1360
|
+
if st == ST_PICK:
|
|
1361
|
+
pass # 就绪已确认,继续生成
|
|
1362
|
+
elif st == ST_NOT or st == "":
|
|
1363
|
+
if not (sd.get("confirmed")):
|
|
1364
|
+
# 字典未确认但字段齐(运营核过 HS/材质+勾就绪才会到这)——若未就绪等待
|
|
1365
|
+
if row.get(F_READY) is not True:
|
|
1366
|
+
return f"{rid} 新品待运营勾就绪"
|
|
1367
|
+
# confirmed 或已就绪 → 生成
|
|
1368
|
+
if dry:
|
|
1369
|
+
return f"{rid} [dry] 可生成"
|
|
1370
|
+
try:
|
|
1371
|
+
out = generate_for_row(row)
|
|
1372
|
+
except Exception as exc:
|
|
1373
|
+
if not dry:
|
|
1374
|
+
lip_update([rid], {F_ST: ST_FAIL, F_ERR: f"生成失败:{exc}"})
|
|
1375
|
+
return f"{rid} 生成失败: {exc}"
|
|
1376
|
+
if not dry:
|
|
1377
|
+
lip_update([rid], {F_ST: ST_FILL, F_ERR: ""})
|
|
1378
|
+
lip_upload_attachment(rid, F_FILE, out, row=row)
|
|
1379
|
+
log(f"{rid} 发票已生成: {out.name}")
|
|
1380
|
+
# generate_only(试点核对):停在填写中;否则自动继续 上传→标签→完成+固化
|
|
1381
|
+
if not generate_only and not dry:
|
|
1382
|
+
return _upload_and_finish(row, out, generate_only=False)
|
|
1383
|
+
return f"{rid} 生成完成 → 填写中"
|
|
1384
|
+
|
|
1385
|
+
|
|
1386
|
+
def scan(generate_only=False, dry=False):
|
|
1387
|
+
rows = lip_list(json.dumps({"logic": "and",
|
|
1388
|
+
"conditions": [[F_FULL_ST, "==", "已完成"]]}))
|
|
1389
|
+
acted = 0
|
|
1390
|
+
stats = {"our": 0, "ops": 0} # KB-125 B-3:我方/运营待办计数
|
|
1391
|
+
for row in rows:
|
|
1392
|
+
try:
|
|
1393
|
+
msg = handle_row(row, generate_only=generate_only, dry=dry, stats=stats)
|
|
1394
|
+
if msg and "跳过" not in msg and "等待" not in msg and "不动" not in msg:
|
|
1395
|
+
acted += 1
|
|
1396
|
+
log(msg)
|
|
1397
|
+
except Exception as exc:
|
|
1398
|
+
log(f"{row.get('_id')} 处理异常: {exc}")
|
|
1399
|
+
# KB-125 B-3:汇总行(只统计与提示,不写任何业务字段)
|
|
1400
|
+
log(f"汇总:我方待办 {stats['our']} 行 / 运营待办 {stats['ops']} 行"
|
|
1401
|
+
f"(扫描 {len(rows)} 行,本轮动作 {acted} 行)")
|
|
1402
|
+
return acted
|
|
1403
|
+
|
|
1404
|
+
|
|
1405
|
+
def run_record(rid, generate_only=False, dry=False):
|
|
1406
|
+
d = _cli("base", "+record-get", "--base-token", BASE_TOKEN,
|
|
1407
|
+
"--table-id", TABLE_ID, "--record-id", rid)
|
|
1408
|
+
if not d.get("ok"):
|
|
1409
|
+
raise RuntimeError(f"记录 {rid} 读取失败: {d.get('error',{}).get('message','')}")
|
|
1410
|
+
data = d.get("data", {})
|
|
1411
|
+
fields = data.get("fields", [])
|
|
1412
|
+
rows = data.get("data", [])
|
|
1413
|
+
ids = data.get("record_id_list", [])
|
|
1414
|
+
if not rows:
|
|
1415
|
+
raise RuntimeError(f"记录 {rid} 无数据")
|
|
1416
|
+
f = dict(zip(fields, rows[0]))
|
|
1417
|
+
f["_id"] = ids[0] if ids else rid
|
|
1418
|
+
return handle_row(f, generate_only=generate_only, dry=dry)
|
|
1419
|
+
|
|
1420
|
+
|
|
1421
|
+
def regenerate(record_id: str) -> int:
|
|
1422
|
+
"""按记录重生成发票 xlsx(**只落本地文件**),并核对 PO Number = 真源 ML 卖家 ID。
|
|
1423
|
+
|
|
1424
|
+
为什么需要它(2026-09-19):6 张发票的 PO 曾静默写成紫鸟 store_id(另一个 ID 体系)。
|
|
1425
|
+
正常的 run_record 对 `发票状态=已完成` 的行直接跳过(终态保护,正确),所以纠正历史
|
|
1426
|
+
产物需要一条**明确只做本地生成**的通道 —— 不写表、不上传门户,避免把已完成的单
|
|
1427
|
+
重新推进门户(那会产生重复发票)。
|
|
1428
|
+
"""
|
|
1429
|
+
# 单条直取(不走 lip_list:它默认 limit 200,而表有 500+ 行 ⇒ 靠后的记录会"找不到";
|
|
1430
|
+
# 另外已完成的行若放行勾已释放,也不在候选筛选里)
|
|
1431
|
+
try:
|
|
1432
|
+
d = _cli("api", "GET",
|
|
1433
|
+
f"/open-apis/bitable/v1/apps/{BASE_TOKEN}/tables/{TABLE_ID}/records/{record_id}")
|
|
1434
|
+
fields = ((d.get("data") or {}).get("record") or {}).get("fields") or {}
|
|
1435
|
+
except Exception as exc:
|
|
1436
|
+
print(f"❌ 读取记录 {record_id} 失败:{exc}")
|
|
1437
|
+
return 2
|
|
1438
|
+
if not fields:
|
|
1439
|
+
print(f"❌ 未找到记录 {record_id}(或该记录无字段)")
|
|
1440
|
+
return 2
|
|
1441
|
+
row = dict(fields)
|
|
1442
|
+
row["_id"] = record_id
|
|
1443
|
+
store = _first(row.get(F_STORE))
|
|
1444
|
+
want = ml_store_ids.resolve(store)
|
|
1445
|
+
print(f" 记录 {record_id}|店铺 {store}(键 {ml_store_ids.store_key(store) or '—'})"
|
|
1446
|
+
f"|ML 卖家ID = {want or '❌ 未配置'}")
|
|
1447
|
+
if not want:
|
|
1448
|
+
print(" ❌ 真源里没有该店的 ML 卖家 ID ⇒ 拒绝生成(绝不用紫鸟 ID 顶替)")
|
|
1449
|
+
return 2
|
|
1450
|
+
out = generate_for_row(row)
|
|
1451
|
+
if not out:
|
|
1452
|
+
print(" ❌ 生成失败(见上方日志)")
|
|
1453
|
+
return 1
|
|
1454
|
+
print(f" ✅ 已生成 {out}")
|
|
1455
|
+
try:
|
|
1456
|
+
import openpyxl
|
|
1457
|
+
ws = openpyxl.load_workbook(out)[SHEET_NAME]
|
|
1458
|
+
got = str(ws["B22"].value)
|
|
1459
|
+
okp = got == want
|
|
1460
|
+
print(f" {'✅' if okp else '❌'} 核对 B22(PO Number):{got}"
|
|
1461
|
+
f"{'' if okp else f'(期望 {want})'}")
|
|
1462
|
+
return 0 if okp else 1
|
|
1463
|
+
except Exception as exc:
|
|
1464
|
+
print(f" ⚠️ 未能自动核对 B22:{exc}")
|
|
1465
|
+
return 0
|
|
1466
|
+
|
|
1467
|
+
|
|
1468
|
+
def main():
|
|
1469
|
+
ap = argparse.ArgumentParser()
|
|
1470
|
+
ap.add_argument("--scan", action="store_true")
|
|
1471
|
+
ap.add_argument("--record-id", default="")
|
|
1472
|
+
ap.add_argument("--generate-only", action="store_true",
|
|
1473
|
+
help="生成后停(不上传/不流转已完成)——试点核对用")
|
|
1474
|
+
ap.add_argument("--dry-run-pick", action="store_true", help="预演:判定只打印不写表")
|
|
1475
|
+
ap.add_argument("--dedupe-attachments", action="store_true",
|
|
1476
|
+
help="把该行的发票文件/大货标签附件归一到单条(默认 dry-run,配 --yes 才写)")
|
|
1477
|
+
ap.add_argument("--yes", action="store_true", help="真正写回(默认只打印)")
|
|
1478
|
+
ap.add_argument("--label-from-local", action="store_true",
|
|
1479
|
+
help="本地已有本票大货标签则跳过门户下载(默认关;仅跳过下载,上传不受影响)")
|
|
1480
|
+
ap.add_argument("--uncheck-ready", action="store_true",
|
|
1481
|
+
help="终态行补正「发票就绪」=未勾选(对齐口径⑤;不碰门户,默认 dry-run)")
|
|
1482
|
+
ap.add_argument("--regenerate-only", action="store_true",
|
|
1483
|
+
help="按记录重生成发票文件(用于纠正历史错误,如 2026-09-19 的 PO 错写)——"
|
|
1484
|
+
"**只落本地 xlsx、不写表、不上传门户**;已完成的行也允许")
|
|
1485
|
+
args = ap.parse_args()
|
|
1486
|
+
if getattr(args, "label_from_local", False):
|
|
1487
|
+
globals()["LABEL_FROM_LOCAL"] = True
|
|
1488
|
+
if args.record_id and args.uncheck_ready:
|
|
1489
|
+
print(uncheck_ready(args.record_id, dash_yes=args.yes))
|
|
1490
|
+
return 0
|
|
1491
|
+
if args.record_id and args.dedupe_attachments:
|
|
1492
|
+
print(dedupe_attachments(args.record_id, dash_yes=args.yes))
|
|
1493
|
+
return 0
|
|
1494
|
+
if args.record_id and args.regenerate_only:
|
|
1495
|
+
rc = regenerate(args.record_id)
|
|
1496
|
+
raise SystemExit(rc)
|
|
1497
|
+
if args.record_id:
|
|
1498
|
+
print(run_record(args.record_id, generate_only=args.generate_only,
|
|
1499
|
+
dry=args.dry_run_pick))
|
|
1500
|
+
elif args.scan:
|
|
1501
|
+
n = scan(generate_only=args.generate_only, dry=args.dry_run_pick)
|
|
1502
|
+
print(f"scan 完成,处理 {n} 行")
|
|
1503
|
+
else:
|
|
1504
|
+
ap.print_help()
|
|
1505
|
+
|
|
1506
|
+
|
|
1507
|
+
if __name__ == "__main__":
|
|
1508
|
+
main()
|