@whyshu/argent-engine 0.6.0 → 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.
Files changed (67) hide show
  1. package/MANIFEST.json +63 -29
  2. package/engine/VERSION +1 -1
  3. package/engine/core/contracts/argent.contracts.json +1 -1
  4. package/engine/scripts/base-bootstrap.sh +2 -1
  5. package/engine/scripts/billing-mode-daily.sh +2 -1
  6. package/engine/scripts/build-engine-package.mjs +5 -2
  7. package/engine/scripts/capability-packs.lock.json +25 -25
  8. package/engine/scripts/check-billing-mode.sh +2 -1
  9. package/engine/scripts/collect-evidence.mjs +42 -0
  10. package/engine/scripts/customer-readiness.sh +2 -1
  11. package/engine/scripts/daily-report.sh +2 -1
  12. package/engine/scripts/dev-window.sh +2 -1
  13. package/engine/scripts/final-check.mjs +43 -0
  14. package/engine/scripts/flow-requirements.json +24 -9
  15. package/engine/scripts/flow-timers-plist.sh +2 -1
  16. package/engine/scripts/flow-timers.sh +3 -1
  17. package/engine/scripts/flow_config.py +2 -1
  18. package/engine/scripts/init-ml.sh +5 -2
  19. package/engine/scripts/invoice-table.sh +2 -1
  20. package/engine/scripts/lib/argent-datadir.sh +18 -0
  21. package/engine/scripts/lib/argent_datadir.py +23 -0
  22. package/engine/scripts/poll-fulfillment.sh +4 -2
  23. package/engine/scripts/poll-invoice.sh +4 -2
  24. package/engine/scripts/provider-select.sh +2 -1
  25. package/engine/scripts/release-argent.sh +1 -1
  26. package/engine/scripts/retire-inventory.mjs +40 -0
  27. package/engine/scripts/run-tests.sh +2 -1
  28. package/engine/scripts/s1-audit.mjs +85 -0
  29. package/engine/scripts/usage-anchor.sh +3 -2
  30. package/engine/scripts/wenyao-browser.sh +3 -2
  31. package/engine/scripts/wenyao-login.sh +3 -2
  32. package/engine/scripts/ziniao-keepalive.sh +2 -1
  33. package/engine/scripts/ziniao-post-upgrade-check.sh +2 -1
  34. package/engine/scripts/ziniao-windows.sh +2 -1
  35. package/engine/skills/README.md +86 -0
  36. package/engine/skills/argent-dev/SKILL.md +41 -0
  37. package/engine/skills/internal.json +37 -0
  38. package/engine/skills/lktop-pm/SKILL.md +35 -0
  39. package/engine/skills/lktopapp-dev/SKILL.md +34 -0
  40. package/engine/skills/ml-config-guide/SKILL.md +50 -0
  41. package/engine/skills/ml-fulfillment-sop/SKILL.md +178 -0
  42. package/engine/skills/ml-fulfillment-sop/references/andes-components.md +69 -0
  43. package/engine/skills/ml-fulfillment-sop/references/cdp-port-mapping.md +35 -0
  44. package/engine/skills/ml-fulfillment-sop/references/feishu-base-fulfillment-schema.md +136 -0
  45. package/engine/skills/ml-fulfillment-sop/references/multi-window-concurrency.md +69 -0
  46. package/engine/skills/ml-fulfillment-sop/references/pitfalls.md +77 -0
  47. package/engine/skills/ml-fulfillment-sop/references/selectors.md +243 -0
  48. package/engine/skills/ml-fulfillment-sop/scripts/fulfillment.js +586 -0
  49. package/engine/skills/ml-fulfillment-sop/scripts/poll-and-execute.sh +271 -0
  50. package/engine/skills/ml-fulfillment-sop/templates/report.md +55 -0
  51. package/engine/skills/ml-invoice/SKILL.md +506 -0
  52. package/engine/skills/ml-invoice/references/pitfalls.md +268 -0
  53. package/engine/skills/ml-invoice/references/portal-flow.md +360 -0
  54. package/engine/skills/ml-invoice/references/template-fill-contract.md +133 -0
  55. package/engine/skills/ml-invoice-xzh/SKILL.md +133 -0
  56. package/engine/skills/ml-invoice-xzh/references/pitfalls.md +230 -0
  57. package/engine/skills/ml-store-daily-report/SKILL.md +224 -0
  58. package/engine/skills/ml-store-daily-report/scripts/README.md +25 -0
  59. package/engine/skills/ml-store-daily-report/scripts/extract.js +691 -0
  60. package/engine/skills/ml-store-daily-report/scripts/generate.js +299 -0
  61. package/engine/skills/pet-dev/SKILL.md +42 -0
  62. package/engine/skills/whyshu-pm/SKILL.md +35 -0
  63. package/engine/skills/whyshu-web-dev/SKILL.md +50 -0
  64. package/engine/version.mjs +1 -1
  65. package/engine/verticals/mercadolibre/flows.json +37 -5
  66. package/package.json +3 -2
  67. package/requirements.txt +5 -0
@@ -0,0 +1,268 @@
1
+ # 踩坑清单(上海文鳐发票流程)
2
+
3
+ 按**出现频率/杀伤力**排序。每条含「现象 / 根因 / 修法 / 证据」。改脚本或排障前先对照本清单。
4
+ **本文是这类教训的第一落点**;`~/.dsh/AGENTS.md` 只留与全局相关的摘要。
5
+
6
+ ---
7
+
8
+ ## 1. 附件上传按「字段名」会偶发 `20008 The user does not exist`(抖动 ~20%)
9
+
10
+ - **现象**:`base +record-upload-attachment --field-id 发票文件` 有时成功、有时返回
11
+ `20008 The user does not exist`(该码会**掩盖真实原因**)。同一命令同一参数,连跑 5 次能挂 1 次。
12
+ - **根因**:传字段**名**时,CLI 先去 `/open-apis/base/v3/bases/<b>/tables/<t>/fields/<name>` 解析成 id;
13
+ 这条路偶发失败,而失败被包装成 20008。传 `field_id` 则直连,不经解析。
14
+ - **修法**:**全程传 `field_id`**。启动时 `+field-list` 建「名 ↔ id」映射,读记录、上传附件、写值都用 id;
15
+ 另给 CLI 调用加有限重试(识别 `20008 / The user does not exist / rate limit`)兜底。
16
+ - **证据**:2026-09-11 P1 首次实写,`--field-id 发票文件` 挂在 20008;换 `--field-id fldvmra3Rp` 立刻成功。
17
+ 改全程 field_id 后 **6/6 稳定**。
18
+ - ⚠️ 同源坑:`+record-download-attachment` 对**用户/运营上传**的附件也报 20008(见 §3)。
19
+
20
+ ---
21
+
22
+ ## 2. `--file` / `--output` 必须是**当前目录内的相对路径**
23
+
24
+ - **现象**:`unsafe file path: --file must be a relative path within the current directory, got "/Users/..."`。
25
+ - **根因**:lark-cli 的安全策略,拒绝把 cwd 之外的绝对路径交给读写文件类 flag。
26
+ - **修法**:`cd` 到文件所在目录,传 `./<basename>`(脚本里即 `cwd = dirname(file)` + `'./' + basename(file)`)。
27
+ 同类约束也适用于 `--output`(下载附件时)。
28
+ - **证据**:P1 第一次 `--yes` 就死在这里,改成 `cwd` + 相对路径后通过。
29
+
30
+ ---
31
+
32
+ ## 3. 附件下载对用户上传的文件报 20008
33
+
34
+ - **现象**:`base +record-download-attachment` 对**运营/用户上传**的附件(如产品图)报
35
+ `20008 The user does not exist`;bot 自己上传的附件能正常下载。raw API `GET .../records/<rec>` 也报同一个码。
36
+ - **修法(实测可行)**:
37
+ ```bash
38
+ lark-cli --profile lip-customer api GET \
39
+ /open-apis/drive/v1/medias/<file_token>/download --as bot --output ./x.png
40
+ ```
41
+ `file_token` 从**窄投影**记录读里拿(`发票文件`/`产品图`/`大货标签` 都是附件数组,元素含 `file_token`/`name`/`size`)。
42
+ - **证据**:成功取回运营上传的 236KB 产品图 PNG。
43
+
44
+ ---
45
+
46
+ ## 4. 宽投影读表会**错位**,单值核验必须窄投影
47
+
48
+ - **现象**:一次取 20+ 列(markdown 格式)时字段错位:同一轮里 `材质/产品图` 读对、**`HS编码` 被读成空**,
49
+ 据此差点误判"缺 HS 不能开跑"。实际 76636923 / 76637122 都有 `HS=9603290090`。
50
+ - **修法**:
51
+ - 单值核验:一次 **≤ 6~8 列**,按**表头名**映射(第一列恒为 `_record_id`)。
52
+ - 要全行:用 `+record-get --format json`,它回显 `fields` 名单 + `field_id_list`,
53
+ **按名 zip** 而不是按位置猜(脚本 `readRecord()` 即此法)。
54
+ - 更稳:直接传 `field_id`。
55
+ - **证据**:用户当场纠正"76636923 这条是有 HS 编码的,你是不是看错了?"
56
+
57
+ ---
58
+
59
+ ## 5. 附件格会**重复上传**,必须幂等
60
+
61
+ - **现象**:同一份文件在 `发票文件` 格里出现两份(同名同大小,两个 `file_token`)。
62
+ - **根因**:`+record-upload-attachment` 是 **append 语义**(追加),不是覆盖;重跑就多一份。
63
+ - **修法**:上传前先读格内附件,**同名 + 同大小**判定为同一份 ⇒ 默认跳过上传;发现重复用
64
+ `+record-remove-attachment --file-token <多余 token> --yes` 清掉,只留一份。要强制重传用 `--replace`。
65
+ - **证据**:首次实写因中途失败重跑,格内变成 2 份,已去重为 1 份(保留 `WYp0bFaq3oBduMx3ZIscxACVnyb`)。
66
+
67
+ ---
68
+
69
+ ## 6. 审计不能只在成功路径写
70
+
71
+ - **现象**:首次 `--yes` 已把附件和状态写进生产表,但在最后一步崩溃 ⇒ **审计里什么都没有**,
72
+ 真值迁移(跳过→填写中)只在备份 JSON 里能看到。
73
+ - **修法**:把写操作包在 try/catch 里,**失败也 append 一条 `ok:false` 的审计行**(含 error 摘要),
74
+ 并提示"写前备份已留存,可按备份回滚"。事后对已发生的静默写入**补记 `note-partial-run`**,不假装没发生。
75
+ - **证据**:`~/.dsh/argent/invoice-writeback/audit.jsonl` 现有 2 行(1 条收口 + 1 条补记)。
76
+
77
+ ---
78
+
79
+ ## 7. 模板真源必须是**门户下载版**,运营版只是说明
80
+
81
+ - **现象**:门户版比运营版**多一列 `*是否投保`** ⇒ `仓库编码` 从运营版的 AF 挪到**门户版 AG**。
82
+ 照运营版列序填就会整行右移。
83
+ - **修法**:列映射以门户下载的《跨境电商-下单模板.xlsx》为准;运营版只用来读"每列怎么填"。
84
+ 映射真源固化在 `scripts/wenyao-template-map.json`(A..AG,33 列)。
85
+ - **证据**:用户明确"上海文鳐模版你应该用门户下载的,而不用运营给的,运营给的只是说明用的"。
86
+
87
+ ---
88
+
89
+ ## 8. 门户版模板的**预填值不能直接用**
90
+
91
+ - **现象**:门户版 `E2/F2/G2/H2` 预填 `CNYTN / USLGB / 东莞集货仓 / 墨西哥`,
92
+ 其中 `港前仓=东莞集货仓` 与运营要求 `华南仓` 冲突;`G2` 还带下拉校验(只列 `东莞集货仓`)。
93
+ - **修法**:**写值前先清空 `A2..H2`**(清底),再写按运营说明算出的值;
94
+ **港前仓写 `华南仓`**、**不管 G2 校验**(保留原校验不动,不删不改)。
95
+ - **证据**:用户"港前仓不用管校验,就填华南仓"。
96
+ - ⚠️ 待观察:门户导入时是否接受 `华南仓`(其下拉只有东莞集货仓)。P2 第一阶段就要验证这一点。
97
+
98
+ ---
99
+
100
+ ## 9. 「只改两行」是硬约束,第 5-6 行是门户自带示例
101
+
102
+ - **现象**:门户模板第 5-6 行自带**填写示例**(含公式),一开始被误以为是需要清理的脏数据。
103
+ - **修法**:**只写第 2 行(顶部块)+ 第 8 行(明细)**;示例行、表头(第 7 行)、声明行、隐藏行、冻结窗格、
104
+ 单元格校验**一律不动**。测试里加"除第 2、8 行外与模板逐格一致"的回归断言。
105
+ - **证据**:用户"第 5-6 行本身就是门户模板提供的示例…门户要求不删那就不用管"。
106
+
107
+ ---
108
+
109
+ ## 10. 插图需要 Pillow,且图片是**浮动图片**
110
+
111
+ - **现象**:`You must install Pillow to fetch image objects`;`@IMAGE@` 占位若被跳过,会把运营的说明文字留在第 8 行。
112
+ - **修法**:Pillow 装进跑脚本的解释器(本机 `/tmp/pw-venv`,uv 管理的需 `--break-system-packages`);
113
+ 插图用**浮动图片锚在 `AA8`**(数据行「\*产品图片」列,不是 cell value);缺 Pillow 时给明确中文报错而不是抛栈。
114
+ - **证据**:`76636923-...xlsx` 最终件 / 图片数 1。
115
+
116
+ ---
117
+
118
+ ## 10b. 图片**必须缩放到装得进单元格**,不要写死像素宽度
119
+
120
+ - **现象**:用户实测反馈"产品图片已经比单元格大,装不下了"。
121
+ - **根因**:原先写死 `max_w = 120` px。而目标格 AA8 实际可用是
122
+ **AA 列宽 13 ≈ 96 px、第 8 行高 40pt ≈ 53 px** ⇒ 120×119 px 明显溢出。
123
+ - **修法**:按**单元格真实几何**等比缩放(只缩不放):
124
+ 列宽→像素 `round(width*7)+5`;行高→像素 `round(pt*96/72)`;留 4% 余量。落盘后断言"图片像素 ≤ 单元格像素"。
125
+ 当前结果:原图 500×496 → **50×50**(高度是瓶颈)。
126
+ - ⚠️ **openpyxl 取列宽必须用 `ws.column_dimensions['AA']`,不能用 `.get('AA')`**:
127
+ 模板 `<cols>` 里有该列,但 `DimensionHolder.get()` 返回 `None`(实测 AA 宽 13 被读成默认 8.43,
128
+ 盒子算小约 1/3,图片被缩过头)。取不到再回落默认值。
129
+ - **证据**:`wenyao-template.test.mjs` §⑤ 落盘断言「图片装得进单元格」+「不是写死的 120px」。
130
+
131
+ ---
132
+
133
+ ## 10c. 门户模板**第 3 行是隐藏行** ⇒ 预览器行号会"少 1",别据此判断行列
134
+
135
+ - **现象**:用户在飞书预览附件时报告"数据行是 #7"、"图片跑到了 A10",而文件实际是**数据在第 8 行、图片锚点 AA8**。
136
+ - **根因**:门户模板 **第 3 行是隐藏行**(**运营版模板没有隐藏行**)。部分查看器把隐藏行折叠后,
137
+ 其后所有行的**显示序号比真实行号小 1**(真实第 8 行显示成第 7 行);图片高约 3 行,下沿又容易被读成第 10 行。
138
+ - **修法**:
139
+ - **判断行列一律以真实行号为准**,别按预览器显示的数字;可疑就用 `openpyxl` 直读单元格核对。
140
+ - 注意:门户版隐藏第 3 行、运营版没有 ⇒ 同样内容在两份文件里"看起来"会差一行。
141
+ - 交叉验证:把飞书里的附件**下载回来**再读(`lark-cli api GET /open-apis/drive/v1/medias/<token>/download`),
142
+ 确认"用户看到的那份"和"我生成的那份"是同一份。
143
+ - **教训**:用户报"位置/行号不对"时,**先把文件本身查清楚(含从远端拉回原件)再改代码**——
144
+ 本次险些把本来正确的 `AA8` 改成错误的 `AA7`(表头行)。
145
+
146
+ ---
147
+
148
+
149
+ ## 10d. 别用"下拉/接口的取值范围"反推后端校验(差点误判港前仓)
150
+
151
+ - **现象**:读到门户 `warehouse/listAll` 只返回 4 个仓(东莞集货仓/义乌/宁波/上海),
152
+ 就断定"没有华南仓 ⇒ 填 `G2=华南仓` 会被拒",还写成了"🔴 冲突待拍板"。
153
+ - **实际**:那 4 个只是**下拉预设选项**;真导入 `G2=华南仓` 的文件,门户**成功 1 票、失败 0 条**。
154
+ - **修法/教训**:
155
+ - 预设取值范围 ≠ 后端白名单,**校验宽松度只能靠真跑一票验证**;
156
+ - 结论要么标注"**实测**",要么标注"**未验证的推测**",不要写成既成事实;
157
+ - 与运营/用户口径冲突时,**先问、再实测**,不要自己替业务拍板。
158
+ - **证据**:2026-09-11 首票导入 → `WY535082 审单中`,`货件号 76636923`、`仓库编码 MXRC03`、`件数 6`。
159
+
160
+ ---
161
+
162
+ ## 10e. 门户导出的文件接不住(0 字节 / 不知去哪了)
163
+
164
+ - **现象**:点「下载」后本地找不到文件;或用 `download.save_as()` 存下来是 **0 字节**。
165
+ - **根因**:
166
+ ① 长驻 Chrome 由外部启动,`Browser.setDownloadBehavior` **设在 page CDP session 上不生效**,必须设**浏览器级**;
167
+ ② `save_as()` 在下载尚未落盘到底层临时文件时就拷贝,拿到空文件。
168
+ - **修法**:浏览器级 `new_browser_cdp_session()` 设下载目录 + `expect_download()` 捕获 +
169
+ **`shutil.copy(download.path(), dest)`**;或直接 `download.url`(`/api/v1/common/file?fileId=…`)自己取字节。
170
+ - **证据**:`唛头20260911180600.zip` 经 `copy(path())` 得到正确 45,838 bytes(与 API 报的 fileSize 一致)。
171
+
172
+ ## 10f. 「导出唛头」是**异步任务**,文件不在工具栏
173
+
174
+ - **现象**:点完「导出唛头」只弹 toast,工具栏没有「导出文件」下载入口。
175
+ - **实际**:文件落在**右上角「导出任务」面板**(按时间倒序,最上面一条即本次);
176
+ ⚠️ 该面板混着大量历史任务(实测 **190 个「下载」按钮**)⇒ 必须核对**时间戳**,别拿错别人的唛头。
177
+ - **证据**:2026-09-11 18:06 那条 → zip 内 `WY535082-76636923_mark.pdf`,6 页 = 6 箱。
178
+
179
+ ---
180
+
181
+ ## 10g. 写后**立刻**读回会"假失败"(飞书 Base 是异步生效的)
182
+
183
+ - **现象**:脚本报 `读回验收:状态=填写中 ❌` 并 exit 1,但**写入其实成功了**;
184
+ 隔几秒再读就是新值。(2026-09-11 收口那次真实踩到。)
185
+ - **根因**:`lark-cli` 文档已写明——**Table 下大多数更新通过异步链路生效,接口成功返回后立即读取可能暂时看不到最新状态**。
186
+ - **修法**:
187
+ - **写完不要立刻断言**,改成**轮询重试**(本脚本:最多 8 次 × 1.5s,直到状态与附件都符合预期);
188
+ - 判定"操作成功"优先用**写入响应**,读回只作**最终验收**;
189
+ - 同类坑:`+record-upload-attachment` 后立刻查 `file_token` 也会拿到 `None`(同一原因,已加重试)。
190
+ - **教训**:**"读回失败"不等于"写入失败"**,别据此重试写入(会重复写);先延迟复读再判断。
191
+
192
+ ---
193
+
194
+ ## 10h. 下载的临时文件会被清掉(别 `copy(download.path())` 当主路径)
195
+
196
+ - **现象**:`shutil.copy(download.path(), dest)` 报 `FileNotFoundError` —— Playwright 的临时文件
197
+ **在拿到 download 对象之后可能已被清理**(竞态)。而 `save_as()` 又实测存成 **0 字节**。
198
+ - **修法(可靠)**:用 `expect_download()` **只为拿稳定的 `download.url`**,然后
199
+ **用页面自身会话 fetch 字节**:`page.evaluate` 里 `fetch(url,{credentials:'include'})` → `arrayBuffer` → base64 → Python `b64decode` 落盘。
200
+ 临时文件还在时才走 `copy(path())` 兜底。
201
+ - **证据**:2026-09-11 首票用 copy 成功、第二票(76637122)就因竞态失败;改为页面取字节后稳定。
202
+
203
+ ## 10i. 「导出任务」面板的遮罩会挡住后续点击 —— **且 Esc 关不掉它(会静默给错答案)**
204
+
205
+ - **现象 A(点击被拦)**:下载完接着点「查询」,Playwright 报
206
+ `<div … items> from <div class="wenyao-shell-my-top"> subtree intercepts pointer events`(TimeoutError)。
207
+ - **现象 B(更危险的后果:查重结论是错的)**:2026-09-18 真跑 **77447894**,
208
+ P7 报「门户已有 1 条 77447894,停止导入以免重复」而**白停**;实查门户 API `totalCount = 0`
209
+ (那 1 条是**上一次 76637122 查询残留的旧行**)。
210
+ - **根因(修正 2026-09-11 的旧结论)**:导出任务面板容器的**遮罩确实挡住了按钮**,
211
+ 但 **`close_overlays()` 里的连按 Esc 根本关不掉这个面板** ⇒ ① 的 `click(force=True)`
212
+ 兜底会**点在遮罩上**、`/lcl/order/page` 请求**根本没发出去** ⇒ 于是脚本读到的
213
+ `.ff-base-table2-summary` / `.BaseTable__row` 全是**上一次查询的 DOM 残留**。
214
+ 假阳性 = 白停一票;**假阴性 = 以为"没有" ⇒ 重复导入对方生产系统**(更严重)。
215
+ - **修法(已落地 2026-09-18)**:
216
+ ① `close_task_panel(page)`:点 `.taskcenterHandle` 收起面板(Esc 无效),`close_overlays` 内调用;
217
+ ② `query_shipment` **fail-closed + 以接口为准**:用 `page.expect_response('/api/v1/lcl/order/page')`
218
+ 抓本次货件号的响应,取 `data.page.totalCount` 作 `count`(附 `api_total`/`dom_count` 便于对照),
219
+ **抓不到响应或解析不出 totalCount 就直接抛错**,绝不回落到可能过期的 DOM 数字。
220
+ - **验证**:修复后只读实测 `77447894 → 0 条`(= 接口真值)、对照组 `76637122 → 1 条`(可区分);
221
+ 回归断言进 `scripts/tests/ml-invoice-skill.test.mjs`(④c)。
222
+ - **教训**:**"查重说有"和"查重说没有"都可能是在读缓存/残留**——凡是用 DOM 数字做**决策**
223
+ (尤其"要不要导入对方生产系统"),必须拿**接口响应**当判据,且拿不到就**停下来**。
224
+ - **证据**:76637122 续跑时先踩到;2026-09-18 77447894 因它白停一票(本次修复)。
225
+
226
+ ---
227
+
228
+ ## 11. `跳过` 必须算作"可开始",否则文鳐行全被挡掉
229
+
230
+ - **现象**:老流程给文鳐行写死 `发票状态=跳过` + `发票错误信息="货代上海文鳐未接入发票自动化(首期仅鑫中航-美墨)"`;
231
+ 若把 `跳过` 当终态,文鳐接入后这批行永远不会被拾取。
232
+ - **修法**:状态机里 `跳过/空/待生成/失败` 均为可开始;只有 `已上传/标签已下载/已完成` 是终态。
233
+ 文鳐待办行 = `FULL状态=已完成 ∧ 发票状态∈{跳过,空}`(实测 **19 行**)。
234
+ - ⚠️ 同时注意:这些行的 `发票错误信息` 是**过时文案**,接入后应清掉(脚本支持 `--clear-error`,属额外字段需用户授权)。
235
+
236
+ ---
237
+
238
+ ## 12. 别在本机做"自动拾取"
239
+
240
+ - **现象**:本机和生产客户机会**抢同一批生产行**,且跨机 `pgrep` 无效、看不出来。
241
+ - **修法**:本机 `~/.dsh/argent/PAUSE_POLL`(FULL)+ `PAUSE_INVOICE`(发票)置位;
242
+ 要试跑就**单独执行 runner**,不要靠 cron。生产在 Huangwei 客户机。
243
+
244
+ ---
245
+
246
+ ## 13. 并发抢**同一个门户标签页** ⇒ 互踩成死循环(2026-10-12 生产事故)
247
+
248
+ - **现象**(用户亲历,客户机生产):三条发票票 `79212271 / 79298694 / 79210359` 被**同时派发**后
249
+ "永远跑不完",最后靠运气交叉跑完(三条都成了 `发票状态=已完成`,但过程不可控)。
250
+ - **根因(确切代码)**:`scripts/wenyao_portal.py` 的 `connect()` 每个并发执行者都
251
+ 从既有标签页里挑一个返回 ⇒ **三条跑批复用同一个 page** ⇒ 导航/点击互相打断 ⇒ 死循环形态。
252
+ - **次生竞争**:`Browser.setDownloadBehavior` 是**浏览器级**的 ⇒ 并发跑批互相覆盖下载目录
253
+ (产物可能落到别人的目录,或下载事件错配、拿到别人的文件)。
254
+ - **修法(用户口径)**:
255
+ 1. **每条票一个自己的标签页**:`connect()` 默认 `context.new_page()` 新建并导航(返回 `PortalSession`),
256
+ 调用方 `finally: sess.close()` **只关自己那个页**(⛔ 不动用户自己开着的页);
257
+ 2. **门户阶段串行锁**:`P6→P14` 包在 `portal_lock()`(每门户一把 flock,`<数据目录>/wenyao-portal.lock`,
258
+ **有界等待**默认 300s / `WENYAO_PORTAL_LOCK_TIMEOUT`,抢锁每 15s 打印一行);超时**明确失败**
259
+ 「门户被占用/等待超时,避免并发踩踏;请稍后重跑或提高超时」——⛔ **绝不无限等待**;
260
+ 3. **下载归属**:下载行为尽量按 `browserContextId` 收到本次自己的 context;每票用自己的
261
+ `wenyao-downloads/portal-<货件号>/`;下载后按 `suggestedFilename`(8 位票号,8 位**日期**不算)
262
+ + 包内容(DDP 包内 = `<运单号>-<货件号>_mark.pdf`)校验归属,不符即报错(⛔ 不静默用错文件)。
263
+ - **可观测**:开始/结束各一行 `[门户] 本次标签页=<targetId 前 8>|锁=<获得/等待 Ns>|票=<货件号>`
264
+ (与阶段日志**同源**,走 `wenyao_run.log()`;⛔ 不新造第二套日志)⇒ 下次一眼看出是不是并发问题。
265
+ - **回归**:`node scripts/tests/wenyao-portal-isolation.test.mjs`(72 项纯夹具:隔离/只关自己/
266
+ 锁区间不相交/有界等待/超时非零退出且不进门户阶段/下载归属/单跑不回归/真 home 零增量)。
267
+ - **教训**:**"复用同一个浏览器页"= 隐式共享可变状态**。凡是"多条并发 + 一个共享资源",
268
+ 要么给它**独立的实例**(自己的页),要么给它**排他锁 + 有界等待**;⛔ 不能靠"大家都不抢"。
@@ -0,0 +1,360 @@
1
+ # 文鳐门户操作(阶段 ④-⑧)
2
+
3
+ 门户:**`https://my.ffl-tech.com`**(HTTPS)。登录后落地页
4
+ `https://my.ffl-tech.com/my/trailer-center#/trailer-center`(跨境电商订单)。
5
+ 账号主体:**武汉市六禾园网络科技有限公司**(显示名「六禾园」)。
6
+
7
+ ---
8
+
9
+ ## ④ 登录(人工过一次验证码,之后复用)
10
+
11
+ - 手机号 + 密码;**阿里云行为验证码(滑块)**。
12
+ - **合成鼠标过不了**(实测连续 2 次失败)⇒ 设计上必须留「人工过验证 → 继续」的介入点。
13
+ - 过一次后把登录态落成**持久 profile**,后续用 CDP 复用:
14
+ - 浏览器:`scripts/wenyao-browser.sh`(长驻 Chrome,CDP 端口 **9333**;`start/status/entries/shot/stop`)
15
+ - 登录态:`~/.dsh/argent/wenyao-storage.json`(600)
16
+ - 连接方式:`connect_over_cdp('http://127.0.0.1:9333')`
17
+ - 登录脚本:`scripts/wenyao_login.py` / `wenyao-login.sh`
18
+ - **多标签并行**(登录态是 profile 级的,允许并行)前提:
19
+ ① tab ↔ 货件号映射明确;② 批量匹配;③ **并发 ≤ 2**;④ **每个 tab 一个独立子进程**。
20
+
21
+ ---
22
+
23
+ ## ⑤ 导入订单
24
+
25
+ 1. 页面工具栏点 **`导入订单`**。
26
+ 2. 弹窗里上传阶段 ② 生成的 xlsx(门户版《跨境电商-下单模板.xlsx》格式)。
27
+ 3. 弹窗内可下载空白模板:蓝色链接 **`跨境电商-下单模板.xlsx`**;
28
+ 后端 API `https://fcc-api.ffl-tech.com/api/v1/lcl/order/getTemplateUrl`(返回 JSON 再取文件)。
29
+ - ⚠️ 浏览器用 `open -na` 启动 ⇒ Playwright **收不到 download 事件**;
30
+ 稳法 = 页面内钩 `URL.createObjectURL` 取 blob 再 base64 回传落盘(`scripts/wenyao_template_fetch.py`)。
31
+ 4. **上传前先确认**:`G2=华南仓` 是否被接受(门户 `G2` 下拉只列 `东莞集货仓`)。
32
+ 若被拒,需回到用户确认是改填 `东莞集货仓` 还是走别的途径。
33
+
34
+ ---
35
+
36
+ ## ⑥ 查询刷新 + 勾选货件号
37
+
38
+ 1. 导入成功后点 **`查询`** 刷新列表。
39
+ 2. 按 `导入批次号` / `货件号` / `订单状态` 定位本次行(查询条件区)。
40
+ 3. **勾选**对应飞书内的**货件号**行。
41
+
42
+ 页面结构(2026-09-10 登录态实测):状态页签
43
+ `全部 225 / 待审单 0 / 待送达集货仓 21 / 待入仓数据确认 0 / 待拼箱 18 / 待开船 6 / 待抵港 14 / 待提柜 0 / 待入港后仓 34 / 已完成 109 / 已取消 23`。
44
+ 列表行样例:`WY534799 待入集货仓` → `货件号:76636729`、`商品名称:分装瓶`、`仓库编码:MXRC03`、`件数:7`、`下单体积(CBM):0.58`
45
+ (**货件号与飞书一致** ⇒ 可作为勾选锚点)。
46
+
47
+ ---
48
+
49
+ ## ⑦ 导出进仓单 + 导出唛头 + 导出文件
50
+
51
+ 工具栏:**`导入订单 / 取消订单 / 导出唛头 / 导出进仓单`**。
52
+
53
+ 1. 点 **`导出进仓单`** → 选 **港前仓 `华南仓`**。
54
+ - ⚠️ 用户 SOP 原文写"选**东莞集货仓**",但运营版模板要求 `华南仓`;用户 2026-09-11 明确
55
+ **「港前仓不用管校验,就填华南仓」** ⇒ 以 `华南仓` 为准。
56
+ - **进仓单是本地产物、只留本地,不回写飞书**。
57
+ 2. 点 **`导出唛头`**。
58
+ 3. 点 **`导出文件`** → 下载最上面的 **「DDP批量导出唛头」压缩包**。
59
+
60
+ ---
61
+
62
+ ## ⑧ 解压 + 按规则改名
63
+
64
+ - 解压 DDP 压缩包。
65
+ - 改名规则(**用户 2026-09-11 确认**):
66
+
67
+ ```
68
+ 运单-<货件号>-<箱数>箱-<品名>
69
+ ```
70
+
71
+ 例:`运单-76636923-6箱-42件专业化妆刷海绵化妆包-棕色.pdf`
72
+ - 箱数取飞书行的 `箱数`;品名取飞书行的 `产品名称`。
73
+
74
+ ---
75
+
76
+ ## ⑨ 回写(2026-09-11 首票已落地)
77
+
78
+ ```bash
79
+ node scripts/wenyao-invoice-writeback.mjs \
80
+ --record-id recvuIaAFrq4Cg --allow-record recvuIaAFrq4Cg --field 大货标签 \
81
+ --file "./运单-76636923-6箱-42件专业化妆刷海绵化妆包-棕色.pdf" --status 填写中 --yes
82
+ ```
83
+
84
+ - 实测结果:上传成功(token `RoRxbGkK5oUmJzxc3b6c3ENYn9d`,51,683 bytes),
85
+ **43 字段逐字段 diff 只变 1 个**(`大货标签`);`发票文件` 仍在、`发票就绪` 未勾、`发票状态` 保持 `填写中`。
86
+ - `大货标签` 收 `.pdf/.zip`;`发票文件` 收 `.xlsx/.xls`(脚本按字段校验)。
87
+ - 状态流转:`填写中` → … → `标签已下载` / `已完成`(**待用户确认口径后再推**)。
88
+ - **不要**顺手勾 `发票就绪`(用户明确不勾)。
89
+
90
+ ---
91
+
92
+ ## 与鑫中航(M1)的差异
93
+
94
+ | 环节 | 鑫中航(已跑通) | 上海文鳐 |
95
+ |---|---|---|
96
+ | 门户 | `106.13.216.234:10001`(http 明文) | **`my.ffl-tech.com`(https)** |
97
+ | 上传 | 单票导入上传 | **导入订单**(导入发票文件) |
98
+ | 标签产物 | 单张 PDF(`*_PHD.pdf`) | **「DDP批量导出唛头」压缩包** → 解压 → 批量改名 |
99
+ | 额外步骤 | 无 | **导出进仓单 → 选港前仓 → 导出唛头** |
100
+ | 登录 | 账号 100610 | 手机号 + 密码 + **阿里云滑块验证** |
101
+
102
+ > 结论:文鳐**不是**"只加一个模板路由",而是**新门户集成**。M1(鑫中航)流程保持不动,
103
+ > 等文鳐新机制验证可行后再评估是否回改 FULL / 鑫中航。
104
+
105
+ ### 编排与触发层同样**独立**(用户 2026-09-15 明确)
106
+
107
+ 文鳐**不复用**鑫中航/lip 那套轮询与 runner(`poll-invoice.sh`、`invoice_runner.py`、
108
+ `invoice_lip_runner.py`)——**触发方式、判断逻辑、并行/派生机制都要另起**:
109
+
110
+ | 层 | 鑫中航 / lip(保持不动) | 文鳐(新做) |
111
+ |---|---|---|
112
+ | 触发 | launchd `com.whyshu.invoice-poll` 每 5 分钟 → `poll-invoice.sh` | **不复用**:另起(定时唤醒角色 / 会话驱动;旧看板已退场) |
113
+ | 判断 | 硬编码状态机 + `precheck()` | **角色(agent)判断** |
114
+ | 并行 | `invoice_runner` **单实例串行**(浏览器慢,求稳) | **subagent 票级并行** + 店级锁 |
115
+ | 执行体 | `invoice_lip_runner.py` | `wenyao_run.py` + `wenyao_portal.py`(已有,**零侵入**) |
116
+
117
+ **当前分隔状态**:文鳐新代码是**独立文件**(对 dsh 表路径"零侵入");而
118
+ **旧流程对文鳐行的处理是「跳过」**(写 `发票状态=跳过` + 备注「货代上海文鳐未接入发票流程」,
119
+ 见 `pitfalls.md`)⇒ 两条链**不会互相抢行** ✅。**但文鳐目前没有任何触发器**(grep 各
120
+ `poll-*.sh` / runner 均 0 命中)⇒ 只能手工单跑,这也正是"还没测完、未部署"的原因。
121
+
122
+ ---
123
+
124
+ # 门户侦察实录(2026-09-11,**只读**,门户口径真源)
125
+
126
+ > 采集方式:连已登录的长驻 Chrome(CDP 9333)读 DOM + 读页面**自己已加载**的接口清单;
127
+ > 打开「导入订单」弹窗**只看不提交**(已确认 `file input.files.length = 0`,零业务动作);
128
+ > 「导出进仓单」**只 hover 不点击**(点击可能真触发导出)。
129
+ > 探针:`python3 scripts/wenyao_recon.py --toolbar|--grid|--import-dialog|--export-menu`。
130
+
131
+ ## 工具栏与状态页签(选择器)
132
+
133
+ | 元素 | 选择器 / 类 | 备注 |
134
+ |---|---|---|
135
+ | 查询 / 重置 | `button:has-text('查询')` / `'重置'` | 查询是 `next-btn-primary` |
136
+ | **导入订单** | `button:has-text('导入订单')` | `next-btn-primary` |
137
+ | 取消订单 | `button:has-text('取消订单')` | |
138
+ | 导出唛头 | `button:has-text('导出唛头')` | |
139
+ | **导出进仓单** | `button:has-text('导出进仓单')` | ⚠️ 带 **`next-menu-btn`** = 下拉菜单 |
140
+ | 隐藏查询栏 | `button:has-text('隐藏查询栏')` | |
141
+ | 状态页签 | `.next-tabs-tab`(文本如 `全部 225` / `待送达集货仓 21` / `已完成 109`) | |
142
+ | 已选/总数 | `.ff-base-table2-summary` → `已选择 N 条 / 共 M 条` | 勾选后看这里确认选中数 |
143
+
144
+ ## 列表页
145
+
146
+ - **不是原生 `<table>`,是 react-base-table 虚拟化表格**(`.BaseTable__table`):
147
+ - 行:`.BaseTable__row.ddp-row`;单元格:`.BaseTable__row-cell`
148
+ - 表头:`服务流程` / `订单信息` / `操作`
149
+ - 行内可见字段:订单号(如 `WY534799`)、状态标签(`待入集货仓`)、
150
+ `货件号:<号>`、`商品名称`、`仓库编码:MXRC03`、`件数`、`下单体积(CBM)`
151
+ - 每行有 `详情` 按钮(`next-btn-text`);行 checkbox 选择后工具栏计数变化
152
+ - 分页:`next-pagination`(`上一页/下一页`、页码、`每页显示 30`、`跳至…确定`)
153
+ - 底部另有 `导出订单` 按钮
154
+
155
+ ## 「导入订单」弹窗(结构真源)
156
+
157
+ | 元素 | 选择器 | 文本/属性 |
158
+ |---|---|---|
159
+ | 弹窗 | `.next-dialog.ff-modal`(体 `.next-dialog-body`,脚 `.next-dialog-footer`) | 标题 `导入订单` |
160
+ | 文件输入 | `input[type=file][name=file][accept=".xls, .xlsx"]` | 单文件、≤20M、只收 `.xls/.xlsx` |
161
+ | **模板下载** | 文本按钮 `跨境电商-下单模板.xlsx`(`next-btn-text`) | 后端 `/api/v1/lcl/order/getTemplateUrl` |
162
+ | 取消 / 提交 | `button:has-text('取消')` / `button:has-text('导入')` | **提交按钮文本是「导入」** |
163
+ | 关闭 | `.next-dialog-close-icon` | |
164
+
165
+ ⚠️ 弹窗里**没有港前仓选择器** ⇒ **港前仓来自 Excel 的 `G2` 单元格**(不是 UI 里选的)。
166
+ ⚠️ DOM 里该弹窗会渲染多份(4 组重复的 file input / 按钮),取**可见**的那份。
167
+
168
+ ## 后端接口(fcc-api.ffl-tech.com,页面自身已调用)
169
+
170
+ | 路径 | 用途 |
171
+ |---|---|
172
+ | `POST /api/v1/lcl/order/status` | 状态页签计数 |
173
+ | `POST /api/v1/lcl/order/page` | 订单分页列表 |
174
+ | `POST /api/v1/lcl/order/getTemplateUrl` | 取《跨境电商-下单模板.xlsx》下载地址 |
175
+ | **`POST /api/v1/lcl/order/warehouse/listAll`** | **港前仓列表**(GET 会 405,必须 POST,空体 `{}`) |
176
+
177
+ ## ✅ 港前仓结论(2026-09-11 **真导入实测**,冲突已解除)
178
+
179
+ `warehouse/listAll` 返回的 4 个值(**下拉预设,不是白名单**):
180
+
181
+ | warehouseCode | warehouseName | 地址 |
182
+ |---|---|---|
183
+ | **HNBD** | 东莞集货仓 | 东莞市凤岗镇五联科研路9号 八达仓 |
184
+ | YWC | 义乌 | 浙江省金华市金东区菜鸟物流园2-2期A6栋… |
185
+ | NBC | 宁波 | 浙江省宁波市镇海区定海路159号… |
186
+ | SHC | 上海 | 上海市闵行区昆阳路1600号铁闵物流园… |
187
+
188
+ **把 `G2=华南仓` 的发票真导入,门户 `成功 1 票业务,失败 0 条`** ⇒ **`华南仓` 被接受**。
189
+
190
+ - 运营已确认「华南仓没问题」;用户口径:**港前仓一律填 `华南仓`**(不管模板自带校验)。
191
+ - ⚠️ **教训**:不要因为某个下拉/接口只返回 4 个值,就断定"只接受这 4 个"——
192
+ 这是**预设选项**,后端对 Excel 字面值的校验可能是宽松的。**先真跑一票再下结论。**
193
+
194
+ ---
195
+
196
+ # 真导入实测记录(2026-09-11,首票)
197
+
198
+ **输入**:`76636923-上海文鳐发票-42件专业化妆刷海绵化妆包-棕色.xlsx`(`G2=华南仓`、图片 AA8)
199
+
200
+ **UI 路径(可编排)**:
201
+
202
+ 1. 点 `button:has-text('导入订单')` → 弹窗 `.next-dialog.ff-modal`
203
+ 2. `input[type=file][name=file]` 用 `set_input_files(<xlsx>)`(弹窗内**没有**港前仓选择器)
204
+ 3. 点提交按钮 **`导入`**(文本就是"导入",不是"确定")
205
+ 4. 结果以 **toast** 返回,不在弹窗里:
206
+ - `本次预计处理 1 行Excel数据!`
207
+ - `批量导入DDP订单 成功 1 票业务,失败 0 条。…点击右下方查看更多进入下载唛头、进仓单(后续也可从消息中心重新进入)`
208
+ 5. 弹窗自动关闭;列表刷新
209
+
210
+ **后端**:`POST /api/v1/lcl/order/import?fileId=<32位>&importType=…`(multipart 上传,先传文件拿 fileId)
211
+
212
+ **结果核实**(按货件号查回,共 1 条):
213
+
214
+ | 项 | 值 |
215
+ |---|---|
216
+ | 订单号 | **WY535082** |
217
+ | 状态 | **审单中** |
218
+ | 货件号 | 76636923 |
219
+ | 商品名称 | 42件专业化妆刷海绵化妆包-棕色 |
220
+ | 仓库编码 | **MXRC03** |
221
+ | 件数 | **6** |
222
+ | 下单体积(CBM) | 0.6(门户按自己口径算,与文件里的 0.597168 不同,属正常) |
223
+
224
+ **导入后**:`待审单` 计数 +1;列表总数 +1。
225
+
226
+ ## 下单后的门户落库实况(2026-09-11 首票 WY535082 详情页实录)
227
+
228
+ **基本信息**:业务编号 `WY535082`(审单中)|客户名称 `武汉市六禾园网络科技有限公司`|
229
+ 导入批次号 `20260911175335`|货件号 `76636923`|箱序 `1-6`|
230
+ 起运港 `Yantian, Shenzhen`(← `CNYTN`)|目的港 `Long Beach, CA`(← `USLGB`)|
231
+ 送货国家 `墨西哥`|仓库编码 `MXRC03` ✅
232
+
233
+ **货品信息**:中文品名 / **英文品名(我们 LLM 翻的那条,原样接受 ✅)** / 总箱数 6 /
234
+ 单箱总重 17.7 / 货箱总重 106.2 / 单件方数 0.1 / 总方数 0.6 / 单箱产品数量 50 / 总产品数量 300 / 货箱 58 /
235
+ **产品图片「查看图片(1)」⇒ 浮动图片被门户成功读取 ✅**;合计:总件数 6|总毛重 106.2|总体积 0.6
236
+
237
+ **服务流程**:下单 `2026-09-11 17:53:35` → **审单(当前,审单时间空)** → 送集货仓 → 入仓数据确认 → 装柜 → 开船 → 抵港 → 提柜 → 到墨西哥海外仓
238
+
239
+ ### 🔑 港前仓:门户把 `华南仓` 落成了 `东莞集货仓`
240
+
241
+ 详情页「送集货仓」节点下 `港前仓 = ` **`东莞集货仓`**(我们 Excel 里填的是 `华南仓`)。
242
+
243
+ ⇒ **结论**:门户对 Excel 的港前仓字面值**输入宽松、内部归一到自己的仓**(或直接忽略、取默认)。
244
+ 这与 SOP「导出进仓单 → 选**东莞集货仓**」**完全自洽**,也和门户模板 `Sheet2` 只列东莞集货仓一致。
245
+ **所以:Excel 仍填 `华南仓`(运营口径),导出进仓单时按 SOP 选 `东莞集货仓`,两者不冲突。**
246
+
247
+ > 详情页是**路由跳转**(`?parentFulfillNo=…`),不是弹窗;看完记得 `goto` 回
248
+ > `#/trailer-center`,别把标签留在详情页。
249
+
250
+ ### ✅ 改名规则(2026-09-11 用户确认)
251
+
252
+ ```
253
+ 运单-<货件号>-<箱数>箱-<中文品名>.pdf
254
+ ```
255
+ 例:`运单-76636923-6箱-42件专业化妆刷海绵化妆包-棕色.pdf`
256
+
257
+ - **箱数后面必须带「箱」字**(用户从两个候选里显式选了带「箱」的写法);
258
+ - 品名用**中文品名**;扩展名 `.pdf`。
259
+
260
+ ## 查询/定位订单的稳定做法(P3 复用)
261
+
262
+ - UI:货件号输入框 = 第 **3** 个 `input[placeholder='支持多号查询']`(1=业务编号,2=导入批次号,**3=货件号**),
263
+ 填值 → 点 `button:has-text('查询')`(exact)→ 读 `.ff-base-table2-summary` 的「共 N 条」。
264
+ 对照组要选**已知存在**的货件号(本次用 76636729 → 1 条),否则无法区分"没这单"和"查询没生效"。
265
+ - **门户自己的查询体(抓包实录)**:
266
+ ```json
267
+ {"querys":[{"name":"CIRCULATION_NO","type":"NORMAL","showValue":"76636923",
268
+ "label":"货件号","id":1407,"value":"76636923"},
269
+ {"name":"WAITING_STATUS","value":"ALL"}],
270
+ "pageNum":1,"pageSize":30}
271
+ ```
272
+ - 查完**记得点「重置」**,别把筛选条件留给下一个人。
273
+
274
+ # ⑥→⑪ 实操实录(2026-09-11 首票 WY535082 **全部跑通**)
275
+
276
+ **前置**:按货件号查回并**勾选**该行(`.BaseTable__row.ddp-row` 按文本匹配 → 行内 checkbox),
277
+ 确认 `.ff-base-table2-summary` 变为 `已选择 1 条 / 共 1 条` 后,导出按钮才可用。
278
+
279
+ ## ⑦ 导出进仓单
280
+
281
+ - 点 `button:has-text('导出进仓单')` → 下拉菜单项:**`集货仓 - 东莞集货仓`** / `卫星仓 - 义乌` / `卫星仓 - 宁波` / `卫星仓 - 上海`
282
+ - 选 `集货仓 - 东莞集货仓` → `POST /api/v1/lcl/order/export/warehouse/receipt`
283
+ `{"parentFulfillNoList":["<批次id>"],"warehouseCode":"HNBD"}`(**HNBD = 东莞集货仓**)
284
+ - 返回方式:**`window.open(<阿里云 OSS 预签名 .xlsx>)`** ⇒ 进仓单直接下载。
285
+ - 进仓单内容(校对过):`批次号 20260911175335` / 运单号 `WY535082` / 客户单号 `76636923` /
286
+ 货物 `42件专业化妆刷海绵化妆包-棕色` / 件数 `6` / 毛重 `106.2` / 体积 `0.6`;
287
+ 仓库地址 = **东莞市凤岗镇五联科研路9号 八达仓**(= 东莞集货仓)。
288
+ - ⚠️ **进仓单只留本地,不回写飞书**(用户口径)。
289
+
290
+ ## ⑧ 导出唛头
291
+
292
+ - 点 `button:has-text('导出唛头')`(**普通按钮,不是菜单**)→
293
+ `POST /api/v1/lcl/order/export/shippingMark` `{"parentFulfillNoList":["<批次id>"]}`
294
+ - toast:**`DDP批量导出唛头-成功`** / `导出任务已完成`;右上角「导出任务」计数 +1。
295
+ - **这是异步任务**:文件**不在工具栏**,要去「导出任务」面板拿(⑨)。
296
+
297
+ ## ⑨ 导出文件 → 下载 DDP 压缩包
298
+
299
+ 1. 点右上角 **「导出任务」** → 面板列出全部任务(**按时间倒序,最上面一条即本次**)。
300
+ ⚠️ 面板里历史任务很多(实测 **190 个「下载」按钮**),务必核对**时间戳**再点。
301
+ 2. 点该条「下载」→ `GET https://workbench-api.ffl-tech.com/api/v1/taskcenter/result/<taskId>`
302
+ → 返回 `{"fileId":"…","fileName":"唛头20260911180600.zip","fileSize":45838,"md5Hex":"…"}`
303
+ 3. **真实下载地址**:`https://workbench-api.ffl-tech.com/api/v1/common/file?fileId=<fileId>`
304
+
305
+ ### 🔧 下载接法(长驻 Chrome,关键坑)
306
+
307
+ - 长驻 Chrome 由 `open -na` 启动 ⇒ `Browser.setDownloadBehavior` **必须设在浏览器级 CDP session**:
308
+ `browser.new_browser_cdp_session().send("Browser.setDownloadBehavior", {"behavior":"allow","downloadPath":DL,"eventsEnabled":True})`
309
+ (设在 page session 上不生效)。
310
+ - ⚠️ **2026-10-12 补**:浏览器级 = **全局**的 ⇒ 并发跑批会互相覆盖下载目录。现在带
311
+ `browserContextId` 把范围收窄到**本次跑批自己的 context**(`Target.getTargetInfo` 取),
312
+ 且每票用自己的目录 `wenyao-downloads/portal-<货件号>/`;只能在浏览器级时由**门户锁**保证串行。
313
+ - 用 `page.expect_download()` 捕获;**`download.save_as()` 实测会存成 0 字节**(时序问题)⇒
314
+ 改用 **`shutil.copy(download.path(), dest)`**(实测拿到正确 45838 bytes)。
315
+ - `download.url` 就是上一步那个 `common/file?fileId=…`,也可直接用页面 `fetch` 取字节。
316
+ - ⛔ **归属校验**:实测包名是 `唛头<YYYYMMDDHHMMSS>.zip`(**不含票号**)⇒ 光看文件名拦不住"用错文件"。
317
+ 现在**两层判据**:① 文件名里 8 位票号(8 位**日期**不算)不是本票 ⇒ 报错;
318
+ ② zip 内成员必须是 `<运单号>-<货件号>_mark.pdf`(`79212271` 这类本票货件号)⇒ 不符即报错,
319
+ ⛔ 不静默使用。
320
+
321
+ ### 🚦 并发纪律(2026-10-12 事故:三条票抢同一个 page ⇒ 死循环)
322
+
323
+ - **每条票一个自己的标签页**:`wenyao_portal.connect()` 默认 `context.new_page()` 新建并导航到列表页,
324
+ 返回 `PortalSession`(`browser`/`page`/`target_id`/`close()`);调用方 `finally: sess.close()`
325
+ **只关自己那个页**(⛔ 不关别人/用户自己开着的页)。
326
+ - **门户阶段串行锁**:`P6→P14` 包在 `wenyao_portal.portal_lock()`(每门户一把 flock,
327
+ `<数据目录>/wenyao-portal.lock`,**有界等待**默认 300s,`WENYAO_PORTAL_LOCK_TIMEOUT` 可配;
328
+ 抢锁每 15s 打印 `⏳ 等待其他发票跑批释放门户(已等 Ns)`)。超时 ⇒ 明确失败
329
+ 「门户被占用/等待超时,避免并发踩踏;请稍后重跑或提高超时」——⛔ **绝不无限等待**。
330
+ - **观测**:开始/结束各一行 `[门户] 本次标签页=<targetId 前 8 位>|锁=<获得/等待 Ns>|票=<货件号>`。
331
+ - **回归**:`node scripts/tests/wenyao-portal-isolation.test.mjs`。
332
+ - **排障**:`python3 scripts/wenyao_portal.py --lock-probe`(先有界拿锁再开自己的页;
333
+ exit 3 = 门户被占用/等待超时,且**根本不开浏览器**)。
334
+
335
+ ## ⑩ 解压
336
+
337
+ zip 内**恰好 1 个文件**:`WY535082-76636923_mark.pdf`(命名 = `<运单号>-<货件号>_mark.pdf`)。
338
+ **页数 = 箱数**(本案 6 页 = 6 箱,一箱一页)。
339
+
340
+ ## ⑪ 改名
341
+
342
+ ```
343
+ 运单-76636923-6箱-42件专业化妆刷海绵化妆包-棕色.pdf
344
+ ```
345
+ 即 `运单-<货件号>-<箱数>箱-<中文品名>.pdf`。
346
+
347
+ **内容核对**(第 1 页):条码 `WY535082`、`Shipment ID: 76636923/1`、`Piece No. 1`、`Total 6`、
348
+ `墨西哥 (MX)`、`Made In China 2026/09`。
349
+
350
+ ## ⚠️ 这是**活的生产账户**
351
+
352
+ 实测同一时段列表总数在变(225 → 229 → 230),说明**有人/别的机器也在导入**。
353
+ ⇒ 自动化时不要假设"总数不变",也不要靠"最后一条"定位自己刚导的单,**一律按货件号查回**。
354
+
355
+
356
+ ## 已确认的正面证据
357
+
358
+ - 门户现有订单的 `仓库编码` 显示 **MXRC03**(另一条 `MXCD03`)⇒ 运营版写的 `MXCR03` 确系**笔误**,
359
+ 我们填的 **`MXRC03` 正确**(这条原本只是推断,现已由门户实况证实)。
360
+