@whyshu/argent-engine 0.7.6 → 0.7.8

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 (43) hide show
  1. package/MANIFEST.json +39 -35
  2. package/engine/VERSION +1 -1
  3. package/engine/core/contracts/argent.contracts.json +12 -4
  4. package/engine/scripts/appointment-create.mjs +10 -2
  5. package/engine/scripts/appointment-worker.mjs +72 -15
  6. package/engine/scripts/appointment_grab.py +132 -21
  7. package/engine/scripts/argent-machine-setup.mjs +118 -0
  8. package/engine/scripts/argent-runner.mjs +63 -10
  9. package/engine/scripts/capability-packs.lock.json +54 -51
  10. package/engine/scripts/ci-run.sh +14 -2
  11. package/engine/scripts/collect-evidence.mjs +1 -1
  12. package/engine/scripts/customer-preflight.py +33 -0
  13. package/engine/scripts/flow-auto-retry.py +3 -0
  14. package/engine/scripts/flow-requirements.json +23 -13
  15. package/engine/scripts/flow-session-retention.mjs +88 -0
  16. package/engine/scripts/flow-trigger-wake.mjs +13 -1
  17. package/engine/scripts/flow-trigger.sh +2 -1
  18. package/engine/scripts/flow_config.py +4 -0
  19. package/engine/scripts/flow_failure.py +9 -4
  20. package/engine/scripts/flow_preflight.py +30 -0
  21. package/engine/scripts/flow_retry.py +22 -7
  22. package/engine/scripts/gen-agent-bundles.mjs +1 -1
  23. package/engine/scripts/gen-preset-rows.mjs +2 -2
  24. package/engine/scripts/invoice_lip_runner.py +30 -2
  25. package/engine/scripts/invoice_stageb.py +101 -0
  26. package/engine/scripts/login-readiness.py +220 -0
  27. package/engine/scripts/ml-oauth-setup.mjs +1 -1
  28. package/engine/scripts/orchestrator.py +416 -30
  29. package/engine/scripts/probe_ml_site.py +103 -0
  30. package/engine/scripts/release-argent.sh +15 -1
  31. package/engine/skills/argent-machine-onboarding/SKILL.md +143 -0
  32. package/engine/skills/internal.json +4 -0
  33. package/engine/skills/ml-full-appointment/SKILL.md +21 -13
  34. package/engine/skills/ml-invoice/SKILL.md +1 -1
  35. package/engine/skills/ml-invoice-xzh/SKILL.md +2 -2
  36. package/engine/version.mjs +1 -1
  37. package/engine/verticals/_engine-words.baseline.txt +42 -23
  38. package/engine/verticals/mercadolibre/flows.json +9 -5
  39. package/engine/verticals/mercadolibre/manifest.json +5 -4
  40. package/engine/verticals/mercadolibre/prompts/daily-report.failure.md +1 -1
  41. package/engine/verticals/mercadolibre/prompts/daily-report.md +7 -10
  42. package/package.json +1 -1
  43. package/engine/scripts/ml-ads.mjs +0 -181
@@ -625,6 +625,20 @@ def _notify_mode() -> str:
625
625
  return mode if mode in NOTIFY_MODES else "final" # 非法值按缺省(保守:不发步骤)
626
626
 
627
627
 
628
+ DONE_CARD_MODE_ENV = "FULL_DONE_CARD_MODE"
629
+ DONE_CARD_MODES = ("single", "batch", "off")
630
+
631
+
632
+ def _done_card_mode() -> str:
633
+ """FULL 完成卡粒度:single=每票发(旧缺省);batch=只由 batch 汇总发;off=都不发。
634
+
635
+ 客户机 2026-10-21 口径:不要再逐票发 FULL 成功卡 ⇒ 设 FULL_DONE_CARD_MODE=batch。
636
+ """
637
+ raw = _profile_from_deploy(DONE_CARD_MODE_ENV, "") or "single"
638
+ mode = raw.strip().lower()
639
+ return mode if mode in DONE_CARD_MODES else "single"
640
+
641
+
628
642
  def _should_send(kind: str, final: bool = False) -> bool:
629
643
  """该条通知要不要真的对外发(纯函数,便于回归)。
630
644
 
@@ -639,6 +653,15 @@ def _should_send(kind: str, final: bool = False) -> bool:
639
653
  return bool(final)
640
654
 
641
655
 
656
+ def _message_id_from_output(out: str) -> str:
657
+ """从 lark-cli 的 JSON 输出里取 message_id(兼容 data.message_id / message_id)。"""
658
+ try:
659
+ d = json.loads(str(out or ""))
660
+ return str(d.get("data", {}).get("message_id") or d.get("message_id") or "")
661
+ except Exception:
662
+ return ""
663
+
664
+
642
665
  def _notify_fact_path() -> Path:
643
666
  try:
644
667
  return _flow_data_dir() / NOTIFY_FACT_FILE
@@ -2450,6 +2473,35 @@ STEP_FALLBACKS: dict[int, dict[str, tuple[Optional[str], list[str]]]] = {
2450
2473
  # 修复「1 号格做月边界」缺陷:11 号会被误判为新月初(8/13 双月视图截断 bug)——
2451
2474
  # 改为逐格月份过渡分配(大数→1 回落即进下月,月份可越出视图末月=下月尾部格)。
2452
2475
  # __DATE_OFFSET__ 为占位符:调用处 replace 为实际 date_offset(JS 含 {} 对象字面量,不能用 .format)。
2476
+ EXACT_DATE_JS = r"""({targetIso}) => {
2477
+ const days = document.querySelectorAll('div.day');
2478
+ const MONTHS = ['enero','febrero','marzo','abril','mayo','junio','julio',
2479
+ 'agosto','septiembre','octubre','noviembre','diciembre'];
2480
+ const my = document.querySelector('.focus-ui-datepicker__content__month-year');
2481
+ const header = my ? (my.textContent || '').trim().toLowerCase() : '';
2482
+ const viewMonths = MONTHS.map((m, mi) => header.includes(m) ? mi : -1).filter(x => x >= 0);
2483
+ const ym = header.match(/(\d{4})/);
2484
+ if (!viewMonths.length || !ym) return {found: false, reason: 'header_unreliable', header};
2485
+ const viewYear = parseInt(ym[1], 10);
2486
+ const t = new Date(targetIso + 'T12:00:00');
2487
+ let month = viewMonths[0] - 1, year = viewYear, prev = 0, started = false;
2488
+ for (let i = 0; i < days.length; i++) {
2489
+ const txt = (days[i].textContent || '').trim();
2490
+ if (!/^\d+$/.test(txt)) continue;
2491
+ const d = parseInt(txt, 10);
2492
+ if (d === 1 && (prev > 1 || prev === 0)) {
2493
+ if (!started) { started = true; month = viewMonths[0]; year = viewYear; }
2494
+ else { month++; if (month > 11) { month = 0; year++; } }
2495
+ }
2496
+ prev = d;
2497
+ if (started && year === t.getFullYear() && month === t.getMonth() && d === t.getDate()) {
2498
+ const cls = days[i].className || '';
2499
+ return {found: true, idx: i, disabled: cls.includes('day--disabled'), cls, header};
2500
+ }
2501
+ }
2502
+ return {found: false, reason: 'not_in_view', header};
2503
+ }"""
2504
+
2453
2505
  FALLBACK_DATE_JS = r"""() => {
2454
2506
  const days = document.querySelectorAll('div.day');
2455
2507
  const n = days.length;
@@ -2930,6 +2982,33 @@ class RequestRateMeter:
2930
2982
  return f"请求 {self.rate()} 次/分(峰值 {self.peak}/分,整轮 {self.total} 次)"
2931
2983
 
2932
2984
 
2985
+ def _tab_close_lock(cdp_url: str):
2986
+ """跨进程串行化「窗口最后一个标签」守卫;失败时退化为无锁(不影响关闭)。"""
2987
+ try:
2988
+ import fcntl
2989
+ port = str(cdp_url or '').rsplit(':', 1)[-1].strip('/ ')
2990
+ path = f"/tmp/argent-tab-close-{port or 'default'}.lock"
2991
+ f = open(path, 'a+')
2992
+ fcntl.flock(f, fcntl.LOCK_EX)
2993
+ return f
2994
+ except Exception:
2995
+ return None
2996
+
2997
+
2998
+ def _tab_close_unlock(handle) -> None:
2999
+ if handle is None:
3000
+ return
3001
+ try:
3002
+ import fcntl
3003
+ fcntl.flock(handle, fcntl.LOCK_UN)
3004
+ except Exception:
3005
+ pass
3006
+ try:
3007
+ handle.close()
3008
+ except Exception:
3009
+ pass
3010
+
3011
+
2933
3012
  class PlaywrightClient:
2934
3013
  """Playwright CDP 统一封装:connect_over_cdp 直连紫鸟浏览器(默认 127.0.0.1:52420)。
2935
3014
 
@@ -3052,11 +3131,15 @@ class PlaywrightClient:
3052
3131
  if page is None or page.is_closed():
3053
3132
  return
3054
3133
  pctx = page.context
3055
- if len(pctx.pages) > 1:
3056
- await page.close()
3057
- else:
3058
- print(f"[{time.strftime('%H:%M:%S')}] 🗂️ 记录标签为窗口最后 1 个,保留不关"
3059
- f"(KB-133 窗口守卫:防紫鸟窗口退出)", file=sys.stderr, flush=True)
3134
+ lock = _tab_close_lock(self.cdp_url)
3135
+ try:
3136
+ if len(pctx.pages) > 1:
3137
+ await page.close()
3138
+ else:
3139
+ print(f"[{time.strftime('%H:%M:%S')}] 🗂️ 记录标签为窗口最后 1 个,保留不关"
3140
+ f"(KB-133 窗口守卫:防紫鸟窗口退出)", file=sys.stderr, flush=True)
3141
+ finally:
3142
+ _tab_close_unlock(lock)
3060
3143
  except Exception:
3061
3144
  pass
3062
3145
 
@@ -3194,12 +3277,16 @@ class PlaywrightClient:
3194
3277
  if pg.is_closed():
3195
3278
  continue
3196
3279
  pctx = pg.context
3197
- if len(pctx.pages) > 1:
3198
- await pg.close()
3199
- closed += 1
3200
- else:
3201
- print(f"[{time.strftime('%H:%M:%S')}] 🗂️ 保留最后 1 个标签不关"
3202
- f"(窗口守卫:防紫鸟窗口退出)", file=sys.stderr, flush=True)
3280
+ lock = _tab_close_lock(self.cdp_url)
3281
+ try:
3282
+ if len(pctx.pages) > 1:
3283
+ await pg.close()
3284
+ closed += 1
3285
+ else:
3286
+ print(f"[{time.strftime('%H:%M:%S')}] 🗂️ 保留最后 1 个标签不关"
3287
+ f"(窗口守卫:防紫鸟窗口退出)", file=sys.stderr, flush=True)
3288
+ finally:
3289
+ _tab_close_unlock(lock)
3203
3290
  except Exception:
3204
3291
  pass
3205
3292
  if closed:
@@ -6540,6 +6627,10 @@ class FeishuClient:
6540
6627
  """
6541
6628
  if not record_id or not self.cfg.feishu_ready:
6542
6629
  return False
6630
+ # 测试/只取消预约:所有进度写入口统一跳过(含 _finalize_partial 等直接调用点)
6631
+ if os.environ.get("ARGENT_NO_PROGRESS") == "1":
6632
+ print(f"[{time.strftime('%H:%M:%S')}] ⏭ ARGENT_NO_PROGRESS=1:跳过写「当前步骤」", file=sys.stderr, flush=True)
6633
+ return False
6543
6634
  label = str(step_label or "")
6544
6635
  if "全部完成" in label:
6545
6636
  why = ""
@@ -6815,7 +6906,7 @@ class FeishuClient:
6815
6906
  def send_card(self, card: dict, fallback_text: str,
6816
6907
  profile: Optional[str] = None,
6817
6908
  chat_id: Optional[str] = None,
6818
- final: bool = False) -> None:
6909
+ final: bool = False) -> str:
6819
6910
  """KB-110:发送 interactive card;失败自动降级为文字(不丢通知)。
6820
6911
 
6821
6912
  profile:通知身份(默认 NOTIFY_PROFILE 客户 Bot);chat_id 覆盖目标群
@@ -6827,23 +6918,23 @@ class FeishuClient:
6827
6918
  if not _should_send("card", final=final):
6828
6919
  _record_notify_fact("card", target_chat or target_user,
6829
6920
  fallback_text or json.dumps(card, ensure_ascii=False))
6830
- return
6921
+ return ""
6831
6922
  if not (self.cfg.feishu_ready and (target_chat or target_user)):
6832
6923
  return
6833
6924
  eff_profile = profile or self.cfg.notify_profile or NOTIFY_PROFILE
6834
6925
  try:
6835
6926
  if target_chat:
6836
- self._run(["im", "+messages-send", "--chat-id", target_chat,
6927
+ out = self._run(["im", "+messages-send", "--chat-id", target_chat,
6837
6928
  "--msg-type", "interactive",
6838
6929
  "--content", json.dumps(card, ensure_ascii=False)],
6839
6930
  timeout=60, retries=1, profile=eff_profile)
6840
6931
  else:
6841
- self._run(["im", "+messages-send", "--user-id", target_user,
6932
+ out = self._run(["im", "+messages-send", "--user-id", target_user,
6842
6933
  "--msg-type", "interactive",
6843
6934
  "--content", json.dumps(card, ensure_ascii=False)],
6844
6935
  timeout=60, retries=1, profile=eff_profile)
6845
6936
  self._log(" 📇 已发送飞书卡片通知") if hasattr(self, "_log") else print("[lark-cli] 卡片已发送", flush=True)
6846
- return
6937
+ return _message_id_from_output(out)
6847
6938
  except StepError as exc:
6848
6939
  # 降级兜底:卡片失败 → 发原文字(不丢通知)
6849
6940
  if hasattr(self, "_log"):
@@ -6863,6 +6954,17 @@ class FeishuClient:
6863
6954
  pass
6864
6955
 
6865
6956
 
6957
+ def update_card(self, message_id: str, card: dict, profile: Optional[str] = None) -> str:
6958
+ """原地更新已发送的 interactive 卡片;返回 message_id(失败抛 StepError)。"""
6959
+ if not message_id:
6960
+ raise StepError(0, "cli_error", "update_card 缺少 message_id")
6961
+ eff_profile = profile or self.cfg.notify_profile or NOTIFY_PROFILE
6962
+ out = self._run(["im", "messages", "patch", "--message-id", message_id,
6963
+ "--data", json.dumps({"content": json.dumps(card, ensure_ascii=False)}, ensure_ascii=False)],
6964
+ timeout=60, retries=1, profile=eff_profile)
6965
+ return _message_id_from_output(out) or message_id
6966
+
6967
+
6866
6968
  # ────────────────────────────────────────────────
6867
6969
  # JS 片段(仅保留数据提取类;交互全部用 Playwright 原生 click/fill)
6868
6970
  # ────────────────────────────────────────────────
@@ -7387,8 +7489,21 @@ class Orchestrator:
7387
7489
  ticket_attestation=self._ticket_attestation())
7388
7490
  self.feishu.set_confirm(self.state.record_id, False)
7389
7491
  self._log(f" ✅ 整票完成({why})")
7492
+ if getattr(self, "_batch_card_mode", False):
7493
+ # 批量模式:单票不再发完成卡,统一由批次卡片汇总
7494
+ return True
7390
7495
  if not notify:
7391
7496
  return True
7497
+ # 2026-10-21 客户口径:不再逐票发 FULL 成功卡;batch 模式只允许批次汇总卡。
7498
+ mode = _done_card_mode()
7499
+ if mode == "off" or (mode == "batch" and not getattr(self, "_batch_card_mode", False)):
7500
+ try:
7501
+ _record_notify_fact("card", self.cfg.feishu_chat or self.cfg.feishu_user,
7502
+ f"FULL 完成卡已抑制({DONE_CARD_MODE_ENV}={mode}):"
7503
+ f"货件#{self.state.shipment_id} {self.state.sku}")
7504
+ except Exception:
7505
+ pass
7506
+ return True
7392
7507
  # KB-110:完成通知卡片化(绿卡 ✅),失败降级为原文字
7393
7508
  # 2026-09-18:这是**唯一默认外发**的通知(final=True)——落内部群、主 Bot 身份
7394
7509
  _done_chat, _done_profile = _done_card_sink(self.cfg)
@@ -7501,6 +7616,10 @@ class Orchestrator:
7501
7616
  """
7502
7617
  if not self.state.record_id:
7503
7618
  return
7619
+ # 测试/只取消预约:不写 FULL 当前步骤(避免测试污染生产 FULL 流程)
7620
+ if os.environ.get("ARGENT_NO_PROGRESS") == "1":
7621
+ self._log(" ⏭ ARGENT_NO_PROGRESS=1:跳过写「当前步骤」")
7622
+ return
7504
7623
  labels = STEP_PROGRESS.get(step)
7505
7624
  if not labels:
7506
7625
  return
@@ -7520,6 +7639,8 @@ class Orchestrator:
7520
7639
  """
7521
7640
  if not self.state.record_id:
7522
7641
  return
7642
+ if os.environ.get("ARGENT_NO_PROGRESS") == "1":
7643
+ return
7523
7644
  labels = STEP_PROGRESS.get(step)
7524
7645
  if labels:
7525
7646
  base = labels[0]
@@ -9486,9 +9607,10 @@ class Orchestrator:
9486
9607
 
9487
9608
  async def step4_appointment(self) -> None:
9488
9609
  self._log("步骤4 货件预约时间")
9489
- # 同店步骤4互斥锁(方案C,2026-08-14):同店多记录并行时预约提交串行排队——
9490
- # ML 同店同一时段只允许一个有效预约,锁消除并发同时段冲突(根治)。换时段/换日期
9491
- # 重试兜底平台侧残留冲突(如历史预约占时段)。锁覆盖整个步骤 4(含重试循环),finally 释放。
9610
+ # 同店步骤4互斥锁(方案C,2026-08-14):**保守串行**,不是已验证的平台规则。
9611
+ # ⚠️ 2026-10-20 复核:没有实验/事故证据证明 ML 同店同时段只能有一个预约;
9612
+ # 这条锁最初只是设计假设。保留它的收益是降低同店并发提交/页态干扰,
9613
+ # 代价是步4串行排队;待双票同店实测后再决定保留或移除。锁覆盖整个步骤4,finally 释放。
9492
9614
  self._acquire_store_step4_lock()
9493
9615
  try:
9494
9616
  # ── 4-pre:**仓容前置探测**(2026-10-10)─────────────────────────────
@@ -9607,7 +9729,8 @@ class Orchestrator:
9607
9729
  ev["platform_error"] = plat
9608
9730
  return ev
9609
9731
 
9610
- async def _book_appointment(self, hour_idx: int, date_offset: int = 1) -> None:
9732
+ async def _book_appointment(self, hour_idx: int, date_offset: int = 1, exact_date: str = '',
9733
+ time_from: str = '', time_to: str = '') -> None:
9611
9734
  """预约页表单填写+提交:运输方式 → 日期(平台今天+date_offset) → 时段(hour_idx) → 双 Confirmar → 等跳转。
9612
9735
 
9613
9736
  页面必须已在 appointment-v2(调用方保证);hour_idx=0 选第一个可用时段(现状),
@@ -9789,8 +9912,13 @@ class Orchestrator:
9789
9912
  # 4c. 日期选择:点击只读日期输入框打开日历,然后灰圈算法选第 30 格
9790
9913
  await self.browser.click_selector_with_fallback(4, "date_input")
9791
9914
  await asyncio.sleep(1)
9792
- picked = await self._pick_date(date_offset)
9793
- self._log(f" 预约日期: {picked}")
9915
+ if exact_date:
9916
+ picked = await self._pick_exact_date(exact_date)
9917
+ self._last_booked_date = exact_date
9918
+ else:
9919
+ picked = await self._pick_date(date_offset)
9920
+ self._last_booked_date = getattr(self, '_last_picked_date', '') or ''
9921
+ self._log(f" 预约日期: {picked}(严格目标={exact_date or '-'})")
9794
9922
  # 4d. 时间选择(div.hour 列表;hour_idx=0 取第一个=现状,冲突重试时递增换时段)
9795
9923
  hour_chain = self.sel.chain(4, "hour")
9796
9924
  loc = self.browser.page.locator(hour_chain[0])
@@ -9810,7 +9938,37 @@ class Orchestrator:
9810
9938
  raise StepError(4, "business",
9811
9939
  f"预约失败:可用时段不足(需第 {hour_idx + 1} 个时段,实际 {await loc.count()} 个)",
9812
9940
  recovery_attempted=["appointment_hour_retry"])
9813
- hour_text = (await loc.nth(hour_idx).text_content() or "").strip()
9941
+ # 用户三个条件之「时间范围」:按页面真实时段筛选;无匹配时段 ⇒ 不提交,
9942
+ # 让 appointment worker 把该日标为 selectable_no_slot 并继续下一日/下一轮。
9943
+ chosen_idx = hour_idx
9944
+ hour_texts = []
9945
+ for _i in range(await loc.count()):
9946
+ try:
9947
+ hour_texts.append(((await loc.nth(_i).text_content()) or "").strip())
9948
+ except Exception:
9949
+ hour_texts.append("")
9950
+ if time_from or time_to:
9951
+ def _minutes(t: str) -> int:
9952
+ m = re.search(r"(\d{1,2})[::.](\d{2})", t or "")
9953
+ if not m:
9954
+ return -1
9955
+ return int(m.group(1)) * 60 + int(m.group(2))
9956
+ lo = _minutes(time_from) if time_from else 0
9957
+ hi = _minutes(time_to) if time_to else 24 * 60
9958
+ chosen_idx = -1
9959
+ for _i, _text in enumerate(hour_texts):
9960
+ _start = _minutes(_text)
9961
+ if _start >= 0 and lo <= _start < hi:
9962
+ chosen_idx = _i
9963
+ break
9964
+ if chosen_idx < 0:
9965
+ raise StepError(4, "business",
9966
+ f"预约失败:指定时间范围内无可预约时段({time_from or '-'}~{time_to or '-'};"
9967
+ f"该日实际时段 {len(hour_texts)} 个)",
9968
+ recovery_attempted=["appointment_hour_range"])
9969
+ hour_idx = chosen_idx
9970
+ hour_text = (hour_texts[hour_idx] if hour_idx < len(hour_texts) else "") or ""
9971
+ self._last_booked_slot = hour_text
9814
9972
  await loc.nth(hour_idx).click(timeout=15000)
9815
9973
  except StepError:
9816
9974
  raise
@@ -9969,6 +10127,75 @@ class Orchestrator:
9969
10127
  ev = await self._appointment_page_evidence()
9970
10128
  return appointment_failure_text(appointment_form_verdict(ev), f"{base}|{await self._calendar_diag()}")
9971
10129
 
10130
+ async def _selected_date_iso(self) -> str:
10131
+ """读取当前日历中 day--selected 对应的日期(YYYY-MM-DD);失败返回空串。"""
10132
+ try:
10133
+ return await self.browser.page.evaluate(r"""() => {
10134
+ const days = document.querySelectorAll('div.day');
10135
+ const MONTHS = ['enero','febrero','marzo','abril','mayo','junio','julio',
10136
+ 'agosto','septiembre','octubre','noviembre','diciembre'];
10137
+ const my = document.querySelector('.focus-ui-datepicker__content__month-year');
10138
+ const header = my ? (my.textContent || '').trim().toLowerCase() : '';
10139
+ const viewMonths = MONTHS.map((m, mi) => header.includes(m) ? mi : -1).filter(x => x >= 0);
10140
+ const ym = header.match(/(\d{4})/);
10141
+ if (!viewMonths.length || !ym) return '';
10142
+ let month = viewMonths[0] - 1, year = parseInt(ym[1], 10), prev = 0, started = false;
10143
+ for (let i = 0; i < days.length; i++) {
10144
+ const txt = (days[i].textContent || '').trim();
10145
+ if (!/^\d+$/.test(txt)) continue;
10146
+ const d = parseInt(txt, 10);
10147
+ if (d === 1 && (prev > 1 || prev === 0)) {
10148
+ if (!started) { started = true; month = viewMonths[0]; year = parseInt(ym[1], 10); }
10149
+ else { month++; if (month > 11) { month = 0; year++; } }
10150
+ }
10151
+ prev = d;
10152
+ if (started && (days[i].className || '').includes('day--selected')) {
10153
+ return year + '-' + String(month + 1).padStart(2, '0') + '-' + String(d).padStart(2, '0');
10154
+ }
10155
+ }
10156
+ return '';
10157
+ }""") or ""
10158
+ except Exception:
10159
+ return ""
10160
+
10161
+ async def _pick_exact_date(self, target_iso: str) -> str:
10162
+ """严格选目标日期:只允许点目标日;disabled/找不到 ⇒ 报错,⛔ 不跳日。"""
10163
+ from datetime import date as _date
10164
+ tgt = _date.fromisoformat(target_iso)
10165
+ target_month, target_year = tgt.month - 1, tgt.year
10166
+ days = self.browser.page.locator("div.day")
10167
+ for flip in range(3):
10168
+ st = await self._calendar_state()
10169
+ header = str(st.get("header") or "").lower()
10170
+ months = ['enero','febrero','marzo','abril','mayo','junio','julio',
10171
+ 'agosto','septiembre','octubre','noviembre','diciembre']
10172
+ vis = [i for i, m in enumerate(months) if m in header]
10173
+ yr_m = __import__('re').search(r'(\d{4})', header)
10174
+ yr = int(yr_m.group(1)) if yr_m else 0
10175
+ if target_month in vis and (not yr or yr == target_year):
10176
+ r = await self.browser.page.evaluate(EXACT_DATE_JS, {"targetIso": target_iso})
10177
+ if not r.get("found"):
10178
+ raise StepError(4, "selector_not_found",
10179
+ f"预约失败:目标日期 {target_iso} 在日历中找不到(不跳日)",
10180
+ recovery_attempted=["exact_date_strict"])
10181
+ if r.get("disabled"):
10182
+ raise StepError(4, "business",
10183
+ f"目标日期 {target_iso} 不可选(day--disabled);不跳到其他日期",
10184
+ recovery_attempted=["exact_date_strict"])
10185
+ idx = int(r["idx"])
10186
+ txt = (await days.nth(idx).text_content() or "").strip()
10187
+ await days.nth(idx).click(timeout=15000)
10188
+ await asyncio.sleep(1.0)
10189
+ await self._verify_day_selected(days, idx)
10190
+ await self._validate_picked_date()
10191
+ self._last_picked_date = target_iso
10192
+ return txt
10193
+ await self._flip_month(st, target_month, target_year)
10194
+ await asyncio.sleep(0.8)
10195
+ raise StepError(4, "selector_not_found",
10196
+ f"预约失败:翻月后仍未找到目标日期 {target_iso}(不跳日)",
10197
+ recovery_attempted=["exact_date_strict", "exact_date_flip"])
10198
+
9972
10199
  async def _pick_date(self, date_offset: int = 1) -> str:
9973
10200
  """灰圈算法:找 div.day--current,从其后的第 date_offset 格(灰圈后 1 格=明天)起选日期(跳过表头与 disabled 格)。
9974
10201
 
@@ -10027,6 +10254,7 @@ class Orchestrator:
10027
10254
  # 8/13 用户实测加固:选日成功后校验所选日期 >= 今天+1(第二天)(防 fallback
10028
10255
  # 在翻月失败后误选当月最近可用日,如 8/13 < 8/14 要求)→ 拒绝确认
10029
10256
  await self._validate_picked_date()
10257
+ self._last_picked_date = await self._selected_date_iso()
10030
10258
  return txt
10031
10259
  n = await days.count()
10032
10260
  target = idx + date_offset # 灰圈后第 date_offset 格=平台今天+date_offset(跳过表头/disabled 由下方 while 循环处理)
@@ -10053,6 +10281,7 @@ class Orchestrator:
10053
10281
  # 8/13 用户实测加固:主路径也校验所选日期 >= 今天+1(灰圈 idx+1 在翻月
10054
10282
  # 失败/视图异常时可能指向旧视图过早日期)→ 拒绝确认
10055
10283
  await self._validate_picked_date()
10284
+ self._last_picked_date = await self._selected_date_iso()
10056
10285
  return txt
10057
10286
  raise StepError(4, "selector_not_found",
10058
10287
  await self._pick_date_failure_text("翻月后仍无法选中"),
@@ -12224,7 +12453,7 @@ class Orchestrator:
12224
12453
  同 record 双写会往生产表写两遍(旧实现是函数内 `import fcntl` + `except: pass` ⇒
12225
12454
  Windows 上静默无锁)。被别人持有仍按原语义返回 None(skipped)。
12226
12455
  """
12227
- if self.args.no_lock: # 并发实测/调试开关:跳过文件锁
12456
+ if self.args.no_lock and not force: # 并发实测/调试开关:跳过文件锁
12228
12457
  self._log("⚠️ 并发实测模式(无锁)")
12229
12458
  return True
12230
12459
  record_id = (self.state.record_id or "").strip()
@@ -12266,10 +12495,11 @@ class Orchestrator:
12266
12495
  name = str(self.state.store_name or self.cfg.store_name or "").strip()
12267
12496
  return re.sub(r"[^\w.-]+", "_", name).strip("_")
12268
12497
 
12269
- def _acquire_store_step4_lock(self) -> None:
12498
+ def _acquire_store_step4_lock(self, force: bool = False) -> None:
12270
12499
  """阻塞获取同店步骤4互斥锁(`$ARGENT_LOCK_DIR/ziniao-store-<key>-step4.lock`,默认 /tmp)。
12271
12500
 
12272
- 同店多记录并行时,步骤4预约提交串行排队(ML 同店同时段互斥规则)。
12501
+ ⚠️ 这是**保守串行锁**:没有实验/事故证据证明“ML 同店同时段只能有一个预约”;
12502
+ 它只用于降低同店并发提交/页态干扰,不是已验证的平台规则(2026-10-20 复核)。
12273
12503
  锁随进程退出自动释放:锁对象保存 self._store_step4_lock 防回收。
12274
12504
  键口径与执行者锁统一走 `_store_lock_key()`(缺陷⑪)。
12275
12505
 
@@ -12278,7 +12508,7 @@ class Orchestrator:
12278
12508
  且平台侧的残留预约要人工擦。所以锁**不可用** ⇒ 抛 `StepError`(本步明确失败、下轮重试),
12279
12509
  绝不静默无锁硬提交。("被别人持有"不是不可用:这里本来就是阻塞等它释放。)
12280
12510
  """
12281
- if self.args.no_lock: # 并发实测/调试开关:跳过文件锁
12511
+ if self.args.no_lock and not force: # 并发实测/调试开关:跳过文件锁
12282
12512
  self._log("⚠️ 并发实测模式(无锁)")
12283
12513
  return
12284
12514
  store_id = self._store_lock_key()
@@ -12530,6 +12760,93 @@ class Orchestrator:
12530
12760
  result["capacity_lift_max"] = _capacity_max_lift
12531
12761
  return result
12532
12762
 
12763
+ # ── 批量汇总卡片(一张卡实时更新)────────────────────────────────────────
12764
+ BATCH_ERP_URL = os.environ.get("ML_ERP_BATCH_URL", "https://whyshu.com/admin/argent/#/office")
12765
+
12766
+ def _batch_card_payload(self, results: list[dict], total: int, done: bool = False) -> dict:
12767
+ rows = []
12768
+ ok = bad = 0
12769
+ for i, r in enumerate(results, 1):
12770
+ st = str(r.get("status") or "")
12771
+ if st == "success":
12772
+ ok += 1; state = "完成"; detail = "已上传标签/箱唛"
12773
+ elif st == "partial":
12774
+ bad += 1; state = "失败"; detail = "部分步骤失败"
12775
+ elif st == "failed":
12776
+ bad += 1; state = "失败"; detail = "失败"
12777
+ elif st == "skipped":
12778
+ state = "跳过"; detail = "跳过"
12779
+ else:
12780
+ state = st or "未知"; detail = ""
12781
+ if st in ("partial", "failed") and r.get("error"):
12782
+ detail = str((r.get("error") or {}).get("message") or detail)[:80]
12783
+ rows.append({
12784
+ "seq": str(i),
12785
+ "record": str(r.get("record_id") or ""),
12786
+ "shipment": str(r.get("shipment_id") or "-"),
12787
+ "sku": str(r.get("sku") or "-"),
12788
+ "state": state,
12789
+ "detail": detail or "-",
12790
+ })
12791
+ title = "✅ FULL 批次完成" if done else "⏳ FULL 批次进行中"
12792
+ header = "green" if done and bad == 0 else ("orange" if done else "blue")
12793
+ elements = [
12794
+ {"tag": "markdown", "content": f"**{self.state.store_name or self.cfg.store_name or '店铺'}**|完成 {len(results)}/{total}|成功 {ok}|失败 {bad}"},
12795
+ ]
12796
+ if rows:
12797
+ elements.append({
12798
+ "tag": "table", "page_size": 20, "row_height": "low",
12799
+ "header_style": {"bold": True, "background_style": "grey"},
12800
+ "columns": [
12801
+ {"name": "seq", "display_name": "#", "data_type": "text", "width": "80px"},
12802
+ {"name": "shipment", "display_name": "货件号", "data_type": "text", "width": "120px"},
12803
+ {"name": "sku", "display_name": "SKU", "data_type": "text", "width": "140px"},
12804
+ {"name": "state", "display_name": "状态", "data_type": "text", "width": "90px"},
12805
+ {"name": "detail", "display_name": "结果", "data_type": "text", "width": "260px"},
12806
+ ],
12807
+ "rows": rows,
12808
+ })
12809
+ else:
12810
+ elements.append({"tag": "markdown", "content": "暂无完成记录"})
12811
+ elements.append({
12812
+ "tag": "button", "text": {"tag": "plain_text", "content": "查看更多 / 打开 ERP"},
12813
+ "type": "primary_filled", "width": "fill",
12814
+ "behaviors": [{"type": "open_url", "default_url": self.BATCH_ERP_URL}],
12815
+ })
12816
+ return {
12817
+ "schema": "2.0",
12818
+ "config": {"wide_screen_mode": True},
12819
+ "header": {"template": header, "title": {"tag": "plain_text", "content": title}},
12820
+ "body": {"elements": elements},
12821
+ }
12822
+
12823
+ def _batch_card_init(self, total: int) -> None:
12824
+ self._batch_card_mode = True
12825
+ self._batch_card_total = total
12826
+ self._batch_card_message_id = ""
12827
+ if getattr(self.feishu, "dry_run", False):
12828
+ return
12829
+ try:
12830
+ chat, profile = _done_card_sink(self.cfg)
12831
+ self._batch_card_chat = chat
12832
+ self._batch_card_profile = profile
12833
+ self._batch_card_message_id = self.feishu.send_card(
12834
+ self._batch_card_payload([], total, done=False),
12835
+ f"FULL 批次进行中:{self.state.store_name or self.cfg.store_name}(0/{total})",
12836
+ profile=profile, chat_id=chat, final=True)
12837
+ except Exception as exc: # noqa: BLE001
12838
+ self._log(f" ⚠️ 批次卡片初始化失败(不影响流程): {exc}")
12839
+
12840
+ def _batch_card_update(self, results: list[dict], done: bool = False) -> None:
12841
+ if not getattr(self, "_batch_card_message_id", ""):
12842
+ return
12843
+ try:
12844
+ self.feishu.update_card(self._batch_card_message_id,
12845
+ self._batch_card_payload(results, self._batch_card_total, done=done),
12846
+ profile=getattr(self, "_batch_card_profile", None))
12847
+ except Exception as exc: # noqa: BLE001
12848
+ self._log(f" ⚠️ 批次卡片更新失败(不影响流程): {exc}")
12849
+
12533
12850
  async def _run_batch(self, mode: str, allow_write: bool,
12534
12851
  step_filter: Optional[int]) -> dict[str, Any]:
12535
12852
  """KB-085 批量模式:一次 CDP 连接内处理同店多条记录(每记录独立标签页)。
@@ -12618,7 +12935,10 @@ class Orchestrator:
12618
12935
  "results": results, "dry_run": dry_run}
12619
12936
  self._log(f" ✅ 单连接已建立(连接 1 次 / 标签页 {len(record_ids)} 个),开始逐记录执行")
12620
12937
  results: list[dict[str, Any]] = []
12938
+ self._batch_card_init(len(record_ids))
12621
12939
  for idx, rid in enumerate(record_ids):
12940
+ if idx > 0:
12941
+ self._batch_card_update(results, done=False)
12622
12942
  self._log(f" ── 记录 {idx + 1}/{len(record_ids)}: {rid} ──")
12623
12943
  try:
12624
12944
  rec = self.feishu.get_record(rid)
@@ -12726,6 +13046,7 @@ class Orchestrator:
12726
13046
  # KB-133:改用带窗口守卫的关标签(关掉窗口最后一个标签会连带 Chromium
12727
13047
  # 窗口退出 → 批内下一条记录「未找到窗口」,2026-09-11 实证)
12728
13048
  await self.browser.close_record_page(page)
13049
+ self._batch_card_update(results, done=True)
12729
13050
  await self.browser.close()
12730
13051
  self._log(f"📦 批量模式完成:连接 1 次 / 标签页 {len(record_ids)} 个 / 结果 {len(results)} 条")
12731
13052
  _rate = self._rate_summary()
@@ -12817,8 +13138,9 @@ class Orchestrator:
12817
13138
 
12818
13139
  # §8.57:**先拿店铺级执行者锁,再碰店铺窗口**(record 锁在取窗口之后才生效,挡不住抢窗)。
12819
13140
  # 拿不到 ⇒ skipped 退出:不写表、不发卡、不算失败。
12820
- if not self.acquire_store_executor_lock():
12821
- return {"status": "skipped", "reason": "store_busy",
13141
+ if not getattr(self.args, "no_store_lock", False):
13142
+ if not self.acquire_store_executor_lock():
13143
+ return {"status": "skipped", "reason": "store_busy",
12822
13144
  "holder": self._store_exec_holder,
12823
13145
  "store": self.state.store_name or self.cfg.store_name}
12824
13146
 
@@ -13495,14 +13817,75 @@ def build_parser() -> argparse.ArgumentParser:
13495
13817
  p.add_argument("--store-name", help="店铺名过滤待处理记录(如 1店-子账号;取第一条店铺名匹配的 Pending+就绪 记录)")
13496
13818
  # KB-085:批量模式——一次 CDP 连接内处理同店多条记录(每记录独立标签页)
13497
13819
  p.add_argument("--records", help="批量模式:逗号分隔记录 ID 列表(poll 按店分组传入;单连接多标签页,需配合 --store-name)")
13820
+ p.add_argument("--parallel-records", type=int, default=0,
13821
+ help="批量模式并行度:>1 时每条记录独立子进程+独立标签页并发执行(默认 0=逐记录串行)")
13498
13822
  p.add_argument("--cdp-url", help=f"紫鸟浏览器 CDP 地址(默认 {DEFAULT_CDP_URL})")
13499
13823
  p.add_argument("--no-lock", action="store_true",
13500
13824
  help="跳过 record flock 锁,仅供并发实测/调试")
13825
+ p.add_argument("--no-store-lock", action="store_true",
13826
+ help="跳过店铺级执行者锁(仅供父批处理已持锁时派出的子进程)")
13501
13827
  p.add_argument("--dry-run", action="store_true",
13502
13828
  help="lip-poll 预演:识别/拾取判定只打印不写表(KB-095 远程机安全实测)")
13503
13829
  return p
13504
13830
 
13505
13831
 
13832
+ async def _run_batch_parallel(orch: "Orchestrator", args: argparse.Namespace) -> dict[str, Any]:
13833
+ """批量并行:父进程持有店铺锁,每条记录派独立 orchestrator 子进程 + 独立标签页。"""
13834
+ record_ids = [r.strip() for r in str(args.records or "").split(",") if r.strip()]
13835
+ if not record_ids:
13836
+ return {"status": "failed", "error": {"step": None, "type": "business", "message": "--records 为空"}}
13837
+ if not orch.acquire_store_executor_lock():
13838
+ return {"status": "batch", "connections": 0, "records": len(record_ids),
13839
+ "results": [{"status": "skipped", "record_id": r, "reason": "store_busy"} for r in record_ids]}
13840
+ parallel = max(1, min(5, int(args.parallel_records or 2)))
13841
+ store = args.store_name or orch.state.store_name or orch.cfg.store_name or ""
13842
+ sem = asyncio.Semaphore(parallel)
13843
+ script = str(Path(__file__).resolve())
13844
+
13845
+ async def run_one(rid: str) -> dict[str, Any]:
13846
+ async with sem:
13847
+ cmd = [sys.executable, script, "--mode", "full", "--record-id", rid,
13848
+ "--store-name", store, "--allow-write", "--no-store-lock"]
13849
+ orch._log(f" 🚀 并行子进程: record={rid}(店铺锁由父进程持有)")
13850
+ proc = await asyncio.create_subprocess_exec(
13851
+ *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
13852
+ env={**os.environ, "ARGENT_NO_STORE_LOCK": "1"},
13853
+ )
13854
+ out, err = await proc.communicate()
13855
+ out_s = out.decode("utf-8", "replace")
13856
+ err_s = err.decode("utf-8", "replace")
13857
+ result = None
13858
+ for line in reversed(out_s.splitlines()):
13859
+ try:
13860
+ j = json.loads(line)
13861
+ if isinstance(j, dict) and j.get("status"):
13862
+ result = j
13863
+ break
13864
+ except Exception:
13865
+ continue
13866
+ if result is None:
13867
+ result = {"status": "failed", "record_id": rid,
13868
+ "error": {"step": None, "type": "cli_error",
13869
+ "message": (err_s or out_s)[-800:] or f"子进程退出码 {proc.returncode}"}}
13870
+ result.setdefault("record_id", rid)
13871
+ result["parallel_child_exit"] = proc.returncode
13872
+ orch._log(f" ✅ 并行子进程完成: record={rid} status={result.get('status')} exit={proc.returncode}")
13873
+ return result
13874
+
13875
+ try:
13876
+ results = await asyncio.gather(*(run_one(r) for r in record_ids))
13877
+ finally:
13878
+ try:
13879
+ lock = getattr(orch, "_store_exec_lock", None)
13880
+ if lock is not None:
13881
+ lock.release()
13882
+ orch._store_exec_lock = None
13883
+ except Exception:
13884
+ pass
13885
+ return {"status": "batch", "connections": 0, "records": len(record_ids),
13886
+ "parallel": parallel, "store": store, "results": list(results)}
13887
+
13888
+
13506
13889
  async def _run_and_close(orch: "Orchestrator", args: argparse.Namespace) -> dict[str, Any]:
13507
13890
  """统一运行入口:无论成功/失败/needs_approval/异常,finally 必关自建标签页(防泄漏)。
13508
13891
 
@@ -13850,7 +14233,10 @@ def main(argv: Optional[list[str]] = None) -> int:
13850
14233
 
13851
14234
  orch = Orchestrator(cfg, sel, args)
13852
14235
  try:
13853
- result = asyncio.run(_run_and_close(orch, args))
14236
+ if args.records and int(args.parallel_records or 0) > 1 and args.mode == "full":
14237
+ result = asyncio.run(_run_batch_parallel(orch, args))
14238
+ else:
14239
+ result = asyncio.run(_run_and_close(orch, args))
13854
14240
  except StepError as exc: # 顶层兜底(如配置缺失/步骤保护未捕获)
13855
14241
  orch._fail_record(exc.step or 0, exc, dry_run=args.mode == "dry-run")
13856
14242
  result = {"status": "failed", "record_id": orch.state.record_id or None,