@whyshu/argent-engine 0.0.0-stage → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (340) hide show
  1. package/MANIFEST.json +341 -0
  2. package/engine/VERSION +1 -0
  3. package/engine/agents/catalog.json +454 -0
  4. package/engine/configs/_aliases.proposed.json +651 -0
  5. package/engine/configs/_migration-plan.proposed.json +899 -0
  6. package/engine/configs/account.json +272 -0
  7. package/engine/configs/advanced.json +205 -0
  8. package/engine/configs/host.json +149 -0
  9. package/engine/configs/hr.json +27 -0
  10. package/engine/configs/notify.json +264 -0
  11. package/engine/core/cli/argent.mjs +277 -0
  12. package/engine/core/contracts/argent.contracts.json +1065 -0
  13. package/engine/core/contracts/verbs.schema.json +2539 -0
  14. package/engine/core/lib/argent-datadir.mjs +223 -0
  15. package/engine/core/lib/audit.mjs +187 -0
  16. package/engine/core/lib/claim.mjs +258 -0
  17. package/engine/core/lib/config-catalog.mjs +1172 -0
  18. package/engine/core/lib/doctor-customer.mjs +577 -0
  19. package/engine/core/lib/flow-list.mjs +138 -0
  20. package/engine/core/lib/flow-registry.mjs +48 -0
  21. package/engine/core/lib/init.mjs +247 -0
  22. package/engine/core/lib/ledger.mjs +124 -0
  23. package/engine/core/lib/license-activate.mjs +813 -0
  24. package/engine/core/lib/license-device.mjs +314 -0
  25. package/engine/core/lib/license-file.mjs +349 -0
  26. package/engine/core/lib/license-guard.mjs +123 -0
  27. package/engine/core/lib/license-ops.mjs +1645 -0
  28. package/engine/core/lib/license-verdict.mjs +133 -0
  29. package/engine/core/lib/license.mjs +577 -0
  30. package/engine/core/lib/notify/dedup.mjs +101 -0
  31. package/engine/core/lib/notify/feishu.mjs +38 -0
  32. package/engine/core/lib/notify/index.mjs +95 -0
  33. package/engine/core/lib/notify/verb.mjs +327 -0
  34. package/engine/core/lib/notify/webhook.mjs +135 -0
  35. package/engine/core/lib/notify/wecom.mjs +36 -0
  36. package/engine/core/lib/python.mjs +145 -0
  37. package/engine/core/lib/sop.mjs +116 -0
  38. package/engine/core/lib/versions.mjs +205 -0
  39. package/engine/core/mcp/client.mjs +67 -0
  40. package/engine/core/mcp/http.mjs +138 -0
  41. package/engine/core/mcp/server.mjs +702 -0
  42. package/engine/scripts/_engine_rules_probe.py +15 -0
  43. package/engine/scripts/analyze-full-day.py +568 -0
  44. package/engine/scripts/archive/feishu-bridge.js +246 -0
  45. package/engine/scripts/argent-agents.mjs +527 -0
  46. package/engine/scripts/argent-capabilities.mjs +776 -0
  47. package/engine/scripts/argent-catalog.mjs +186 -0
  48. package/engine/scripts/argent-datadir.py +62 -0
  49. package/engine/scripts/argent-doctor.mjs +624 -0
  50. package/engine/scripts/argent-env-write.mjs +302 -0
  51. package/engine/scripts/argent-schedule.mjs +620 -0
  52. package/engine/scripts/argent-setup-check.mjs +369 -0
  53. package/engine/scripts/argent-upgrade.mjs +1046 -0
  54. package/engine/scripts/base-bootstrap.sh +576 -0
  55. package/engine/scripts/billing-mode-daily.sh +13 -0
  56. package/engine/scripts/build-engine-package.mjs +126 -0
  57. package/engine/scripts/capability-packs.lock.json +484 -0
  58. package/engine/scripts/capability_pack.py +1242 -0
  59. package/engine/scripts/check-1.0-criteria.mjs +355 -0
  60. package/engine/scripts/check-agent-catalog.mjs +141 -0
  61. package/engine/scripts/check-billing-mode.sh +378 -0
  62. package/engine/scripts/check-child-sessions.mjs +83 -0
  63. package/engine/scripts/check-config-catalog.mjs +677 -0
  64. package/engine/scripts/check-docs-drift.mjs +522 -0
  65. package/engine/scripts/check-dsh-web-expose.sh +98 -0
  66. package/engine/scripts/check-judgment-drift.mjs +403 -0
  67. package/engine/scripts/check-license.mjs +199 -0
  68. package/engine/scripts/check-model-auth.py +443 -0
  69. package/engine/scripts/check-pack-closure.mjs +661 -0
  70. package/engine/scripts/check-portable-symbols.mjs +116 -0
  71. package/engine/scripts/check-preset-landing.mjs +124 -0
  72. package/engine/scripts/check-production-home.mjs +602 -0
  73. package/engine/scripts/check-role-mapping.mjs +461 -0
  74. package/engine/scripts/check-sample-redaction.mjs +320 -0
  75. package/engine/scripts/check-skill-classification.mjs +188 -0
  76. package/engine/scripts/check-sop-consistency.mjs +431 -0
  77. package/engine/scripts/check-test-env-safety.mjs +627 -0
  78. package/engine/scripts/check-version-consistency.mjs +452 -0
  79. package/engine/scripts/ci-run.sh +558 -0
  80. package/engine/scripts/collect-evidence.mjs +42 -0
  81. package/engine/scripts/config-explain.mjs +278 -0
  82. package/engine/scripts/configure-provider.rb +29 -0
  83. package/engine/scripts/customer-config-migrate.mjs +179 -0
  84. package/engine/scripts/customer-preflight.py +892 -0
  85. package/engine/scripts/customer-readiness.sh +135 -0
  86. package/engine/scripts/daily-report-inbox.py +242 -0
  87. package/engine/scripts/daily-report-reconcile.mjs +312 -0
  88. package/engine/scripts/daily-report.mjs +1310 -0
  89. package/engine/scripts/daily-report.sh +47 -0
  90. package/engine/scripts/data/lip-product-dict.json +306 -0
  91. package/engine/scripts/deliver-customer.mjs +932 -0
  92. package/engine/scripts/delivery-faces.txt +31 -0
  93. package/engine/scripts/desktop-trial.sh +146 -0
  94. package/engine/scripts/dev-window.sh +261 -0
  95. package/engine/scripts/drill-clean-machine.sh +315 -0
  96. package/engine/scripts/dsh-api-client.mjs +215 -0
  97. package/engine/scripts/dsh-backup-scope.sh +83 -0
  98. package/engine/scripts/dsh-offline-pack.mjs +77 -0
  99. package/engine/scripts/dsh-overlay-pin.sh +87 -0
  100. package/engine/scripts/dsh-plane.mjs +153 -0
  101. package/engine/scripts/dsh-portforward.py +223 -0
  102. package/engine/scripts/dsh-preflight.mjs +179 -0
  103. package/engine/scripts/dsh-release-watch-daily.sh +19 -0
  104. package/engine/scripts/dsh-release-watch.mjs +458 -0
  105. package/engine/scripts/dsh-remote-check.sh +99 -0
  106. package/engine/scripts/dsh-restart.sh +179 -0
  107. package/engine/scripts/dsh-restore.sh +111 -0
  108. package/engine/scripts/dsh-scope-rewire.mjs +803 -0
  109. package/engine/scripts/dsh-snapshot.mjs +116 -0
  110. package/engine/scripts/dsh-upgrade-readiness.mjs +165 -0
  111. package/engine/scripts/dsh-upgrade.mjs +612 -0
  112. package/engine/scripts/dsh-url.sh +86 -0
  113. package/engine/scripts/dsh-web-launch.mjs +155 -0
  114. package/engine/scripts/ensure-store-windows.py +509 -0
  115. package/engine/scripts/entitlements.py +458 -0
  116. package/engine/scripts/env_probe.py +411 -0
  117. package/engine/scripts/feishu-card.mjs +252 -0
  118. package/engine/scripts/final-check.mjs +43 -0
  119. package/engine/scripts/fix-skill-frontmatter.py +209 -0
  120. package/engine/scripts/flow-auto-retry.py +454 -0
  121. package/engine/scripts/flow-requirements.json +735 -0
  122. package/engine/scripts/flow-scaffold.py +275 -0
  123. package/engine/scripts/flow-timers-plist.sh +87 -0
  124. package/engine/scripts/flow-timers.sh +184 -0
  125. package/engine/scripts/flow-trigger-wake.mjs +284 -0
  126. package/engine/scripts/flow-trigger.sh +571 -0
  127. package/engine/scripts/flow_billing.py +354 -0
  128. package/engine/scripts/flow_capability_stats.py +314 -0
  129. package/engine/scripts/flow_case.py +194 -0
  130. package/engine/scripts/flow_claim.py +497 -0
  131. package/engine/scripts/flow_config.py +1025 -0
  132. package/engine/scripts/flow_failure.py +588 -0
  133. package/engine/scripts/flow_learn.py +105 -0
  134. package/engine/scripts/flow_ledger.py +235 -0
  135. package/engine/scripts/flow_policy.py +238 -0
  136. package/engine/scripts/flow_preflight.py +339 -0
  137. package/engine/scripts/flow_registry.py +341 -0
  138. package/engine/scripts/flow_retry.py +304 -0
  139. package/engine/scripts/flow_return_to_ops.py +191 -0
  140. package/engine/scripts/flow_source.py +315 -0
  141. package/engine/scripts/flow_sources/__init__.py +53 -0
  142. package/engine/scripts/flow_sources/cases_json.py +106 -0
  143. package/engine/scripts/flow_sources/common.py +428 -0
  144. package/engine/scripts/flow_sources/feishu_base.py +273 -0
  145. package/engine/scripts/flow_stale.py +185 -0
  146. package/engine/scripts/flow_status_reconcile.py +344 -0
  147. package/engine/scripts/flow_triage.py +849 -0
  148. package/engine/scripts/flow_write_verify.py +496 -0
  149. package/engine/scripts/fulfillment.js +586 -0
  150. package/engine/scripts/gen-agent-bundles.mjs +276 -0
  151. package/engine/scripts/gen-preset-rows.mjs +259 -0
  152. package/engine/scripts/init-ml.sh +2278 -0
  153. package/engine/scripts/install-internal-role.sh +190 -0
  154. package/engine/scripts/install-license.mjs +641 -0
  155. package/engine/scripts/install-one-shot.mjs +665 -0
  156. package/engine/scripts/integration_contract.py +220 -0
  157. package/engine/scripts/integrations/wenyao/adapter.py +98 -0
  158. package/engine/scripts/integrations/xzh/adapter.py +75 -0
  159. package/engine/scripts/invoice-mapping.example.json +40 -0
  160. package/engine/scripts/invoice-table.sh +195 -0
  161. package/engine/scripts/invoice_carrier_notice.py +279 -0
  162. package/engine/scripts/invoice_fail_closeout.py +297 -0
  163. package/engine/scripts/invoice_lip_runner.py +1508 -0
  164. package/engine/scripts/invoice_lip_runner.py.bak-20260922-1710-freready +1090 -0
  165. package/engine/scripts/invoice_ready_audit.py +191 -0
  166. package/engine/scripts/invoice_runner.py +675 -0
  167. package/engine/scripts/invoice_stageb.py +516 -0
  168. package/engine/scripts/launchd/com.whyshu.argent-bridge.plist.example +42 -0
  169. package/engine/scripts/launchd/com.whyshu.fulfillment-poll.plist.example +29 -0
  170. package/engine/scripts/lease.py +377 -0
  171. package/engine/scripts/lib/agent-install.mjs +649 -0
  172. package/engine/scripts/lib/argent-datadir.mjs +16 -0
  173. package/engine/scripts/lib/argent-datadir.sh +18 -0
  174. package/engine/scripts/lib/argent_datadir.py +285 -0
  175. package/engine/scripts/lib/argent_filelock.py +507 -0
  176. package/engine/scripts/lib/bootcheck-wiring.mjs +510 -0
  177. package/engine/scripts/lib/cdp_ports.py +197 -0
  178. package/engine/scripts/lib/customer-env.mjs +102 -0
  179. package/engine/scripts/lib/daily-report-core.mjs +517 -0
  180. package/engine/scripts/lib/dep-declaration.mjs +81 -0
  181. package/engine/scripts/lib/deps-migrate.mjs +310 -0
  182. package/engine/scripts/lib/dsh-host-resolve.mjs +109 -0
  183. package/engine/scripts/lib/dsh-scope.mjs +500 -0
  184. package/engine/scripts/lib/dsh-snapshot-path.mjs +35 -0
  185. package/engine/scripts/lib/dsh-versions.mjs +96 -0
  186. package/engine/scripts/lib/dsh-web-probe.mjs +214 -0
  187. package/engine/scripts/lib/is-direct-run.mjs +61 -0
  188. package/engine/scripts/lib/patch-compose.mjs +241 -0
  189. package/engine/scripts/lib/patch-target.mjs +35 -0
  190. package/engine/scripts/lib/plugin-lifecycle.mjs +128 -0
  191. package/engine/scripts/lib/plugin-packages.mjs +332 -0
  192. package/engine/scripts/lib/preset-compat.mjs +127 -0
  193. package/engine/scripts/lib/preset-install.mjs +575 -0
  194. package/engine/scripts/lib/preset-mechanism.mjs +56 -0
  195. package/engine/scripts/lib/preset-sync.mjs +59 -0
  196. package/engine/scripts/lib/profile-retire.mjs +97 -0
  197. package/engine/scripts/lib/python-runtime.mjs +190 -0
  198. package/engine/scripts/lib/registry.mjs +72 -0
  199. package/engine/scripts/lib/repo-root.sh +56 -0
  200. package/engine/scripts/lib/schedule-mechanism.mjs +659 -0
  201. package/engine/scripts/lib/schedule-probe.mjs +835 -0
  202. package/engine/scripts/lib/scope-pointer.mjs +164 -0
  203. package/engine/scripts/lib/settings-panels.mjs +816 -0
  204. package/engine/scripts/lib/stdio_utf8.py +86 -0
  205. package/engine/scripts/lib/wake-auth.mjs +392 -0
  206. package/engine/scripts/lib/webdriver_ipc.py +264 -0
  207. package/engine/scripts/lib/zstd-frames.mjs +104 -0
  208. package/engine/scripts/license-e2e-drill.mjs +426 -0
  209. package/engine/scripts/license-gate.mjs +245 -0
  210. package/engine/scripts/lip_backlog.py +171 -0
  211. package/engine/scripts/lip_table.py +185 -0
  212. package/engine/scripts/local-usage.mjs +214 -0
  213. package/engine/scripts/migrate-providers.mjs +127 -0
  214. package/engine/scripts/migrate-skills.sh +65 -0
  215. package/engine/scripts/ml-oauth-setup.mjs +550 -0
  216. package/engine/scripts/ml-store-ids.json +26 -0
  217. package/engine/scripts/ml_artifact_attribution.py +333 -0
  218. package/engine/scripts/ml_capacity_probe.py +1158 -0
  219. package/engine/scripts/ml_hub_status.py +1556 -0
  220. package/engine/scripts/ml_schema_check.py +115 -0
  221. package/engine/scripts/ml_store_ids.py +139 -0
  222. package/engine/scripts/npm-publish-argent.mjs +499 -0
  223. package/engine/scripts/npm-publish-argent.sh +20 -0
  224. package/engine/scripts/orchestrator.py +13811 -0
  225. package/engine/scripts/pack-plugins.mjs +140 -0
  226. package/engine/scripts/patches/dsh-llm-deepseek--transport-diagnostics.patch +297 -0
  227. package/engine/scripts/patches/dsh-session--append-message-shape.patch +15 -0
  228. package/engine/scripts/patches/dsh-session--tool-result-block-017.patch +28 -0
  229. package/engine/scripts/patches/dsh-session-format-v0-to-v1--permission-preset-origin.patch +11 -0
  230. package/engine/scripts/patches/dsh-session-format-v3-to-v4--turn-outside-replace.patch +20 -0
  231. package/engine/scripts/patches/dsh-session-persistence-jsonl--worker-bundled-migrators.patch +39 -0
  232. package/engine/scripts/patches/dsh-tools--render-return-shape.patch +50 -0
  233. package/engine/scripts/patches/verify-bundled-migrators.mjs +79 -0
  234. package/engine/scripts/patches/verify-hardening.mjs +167 -0
  235. package/engine/scripts/patches/verify-session-migration.mjs +149 -0
  236. package/engine/scripts/patches/verify-transport-diagnostics.mjs +227 -0
  237. package/engine/scripts/plan-role-rename.mjs +125 -0
  238. package/engine/scripts/plugin-bootcheck.mjs +451 -0
  239. package/engine/scripts/plugin-composition.json +15 -0
  240. package/engine/scripts/poc-mcp-host.mjs +455 -0
  241. package/engine/scripts/poll-fulfillment.sh +295 -0
  242. package/engine/scripts/poll-invoice.sh +76 -0
  243. package/engine/scripts/profile-doctor.mjs +686 -0
  244. package/engine/scripts/provider-select.sh +71 -0
  245. package/engine/scripts/provider-switch.sh +146 -0
  246. package/engine/scripts/provider-sync-target.check.mjs +64 -0
  247. package/engine/scripts/python-runtime.sh +78 -0
  248. package/engine/scripts/query_runner.py +310 -0
  249. package/engine/scripts/release-argent.sh +574 -0
  250. package/engine/scripts/repair-unresolved-tools.mjs +178 -0
  251. package/engine/scripts/retire-inventory.mjs +40 -0
  252. package/engine/scripts/run-tests.sh +257 -0
  253. package/engine/scripts/s1-audit.mjs +85 -0
  254. package/engine/scripts/self-sign-license.mjs +179 -0
  255. package/engine/scripts/session-health-scan.mjs +63 -0
  256. package/engine/scripts/session-repair-tool-result-content.mjs +181 -0
  257. package/engine/scripts/setup.sh +22 -0
  258. package/engine/scripts/skill-install.mjs +629 -0
  259. package/engine/scripts/sku_translate.py +148 -0
  260. package/engine/scripts/sync-plugins.sh +90 -0
  261. package/engine/scripts/sync-presets.sh +27 -0
  262. package/engine/scripts/sync-scripts.sh +445 -0
  263. package/engine/scripts/telemetry.py +873 -0
  264. package/engine/scripts/ui-copy-count.mjs +220 -0
  265. package/engine/scripts/usage-anchor.sh +137 -0
  266. package/engine/scripts/usage_report.py +531 -0
  267. package/engine/scripts/validate-preset-schema.mjs +208 -0
  268. package/engine/scripts/vertical-init.mjs +323 -0
  269. package/engine/scripts/vertical-lint.mjs +482 -0
  270. package/engine/scripts/wenyao-browser.sh +74 -0
  271. package/engine/scripts/wenyao-fixture-export.py +116 -0
  272. package/engine/scripts/wenyao-invoice-writeback.mjs +540 -0
  273. package/engine/scripts/wenyao-login.sh +35 -0
  274. package/engine/scripts/wenyao-template-map.json +292 -0
  275. package/engine/scripts/wenyao_attach.py +131 -0
  276. package/engine/scripts/wenyao_download_template.py +144 -0
  277. package/engine/scripts/wenyao_fill_template.py +568 -0
  278. package/engine/scripts/wenyao_login.py +145 -0
  279. package/engine/scripts/wenyao_portal.py +633 -0
  280. package/engine/scripts/wenyao_recon.py +230 -0
  281. package/engine/scripts/wenyao_run.py +503 -0
  282. package/engine/scripts/wenyao_shadow_diff.py +160 -0
  283. package/engine/scripts/wenyao_skill_contract.py +156 -0
  284. package/engine/scripts/wenyao_template_fetch.py +175 -0
  285. package/engine/scripts/win/dsh-web-autostart.ps1 +372 -0
  286. package/engine/scripts/ziniao-auth-probe.sh +268 -0
  287. package/engine/scripts/ziniao-keepalive.sh +217 -0
  288. package/engine/scripts/ziniao-post-upgrade-check.sh +121 -0
  289. package/engine/scripts/ziniao-windows.sh +154 -0
  290. package/engine/skills/README.md +86 -0
  291. package/engine/skills/argent-dev/SKILL.md +41 -0
  292. package/engine/skills/internal.json +37 -0
  293. package/engine/skills/lktop-pm/SKILL.md +35 -0
  294. package/engine/skills/lktopapp-dev/SKILL.md +34 -0
  295. package/engine/skills/ml-config-guide/SKILL.md +50 -0
  296. package/engine/skills/ml-fulfillment-sop/SKILL.md +178 -0
  297. package/engine/skills/ml-fulfillment-sop/references/andes-components.md +69 -0
  298. package/engine/skills/ml-fulfillment-sop/references/cdp-port-mapping.md +35 -0
  299. package/engine/skills/ml-fulfillment-sop/references/feishu-base-fulfillment-schema.md +136 -0
  300. package/engine/skills/ml-fulfillment-sop/references/multi-window-concurrency.md +69 -0
  301. package/engine/skills/ml-fulfillment-sop/references/pitfalls.md +77 -0
  302. package/engine/skills/ml-fulfillment-sop/references/selectors.md +243 -0
  303. package/engine/skills/ml-fulfillment-sop/scripts/fulfillment.js +586 -0
  304. package/engine/skills/ml-fulfillment-sop/scripts/poll-and-execute.sh +271 -0
  305. package/engine/skills/ml-fulfillment-sop/templates/report.md +55 -0
  306. package/engine/skills/ml-invoice/SKILL.md +506 -0
  307. package/engine/skills/ml-invoice/references/pitfalls.md +268 -0
  308. package/engine/skills/ml-invoice/references/portal-flow.md +360 -0
  309. package/engine/skills/ml-invoice/references/template-fill-contract.md +133 -0
  310. package/engine/skills/ml-invoice-xzh/SKILL.md +133 -0
  311. package/engine/skills/ml-invoice-xzh/references/pitfalls.md +230 -0
  312. package/engine/skills/ml-store-daily-report/SKILL.md +224 -0
  313. package/engine/skills/ml-store-daily-report/scripts/README.md +25 -0
  314. package/engine/skills/ml-store-daily-report/scripts/extract.js +691 -0
  315. package/engine/skills/ml-store-daily-report/scripts/generate.js +299 -0
  316. package/engine/skills/pet-dev/SKILL.md +42 -0
  317. package/engine/skills/whyshu-pm/SKILL.md +35 -0
  318. package/engine/skills/whyshu-web-dev/SKILL.md +50 -0
  319. package/engine/version.mjs +1 -0
  320. package/engine/verticals/README.md +103 -0
  321. package/engine/verticals/_engine-words.baseline.txt +237 -0
  322. package/engine/verticals/_engine-words.json +19 -0
  323. package/engine/verticals/mercadolibre/README.md +43 -0
  324. package/engine/verticals/mercadolibre/config.json +709 -0
  325. package/engine/verticals/mercadolibre/flows.json +738 -0
  326. package/engine/verticals/mercadolibre/manifest.json +538 -0
  327. package/engine/verticals/mercadolibre/migrate-machine-state.mjs +138 -0
  328. package/engine/verticals/mercadolibre/preflight.py +143 -0
  329. package/engine/verticals/mercadolibre/prompts/daily-report.failure.md +69 -0
  330. package/engine/verticals/mercadolibre/prompts/daily-report.md +47 -0
  331. package/engine/verticals/mercadolibre/prompts/daily-report.scheduled.md +79 -0
  332. package/engine/verticals/mercadolibre/prompts/full.failure.md +125 -0
  333. package/engine/verticals/mercadolibre/prompts/full.md +91 -0
  334. package/engine/verticals/mercadolibre/prompts/wenyao-invoice.failure.md +88 -0
  335. package/engine/verticals/mercadolibre/prompts/wenyao-invoice.md +64 -0
  336. package/engine/verticals/mercadolibre/prompts/xzh-invoice.failure.md +91 -0
  337. package/engine/verticals/mercadolibre/prompts/xzh-invoice.md +46 -0
  338. package/package.json +22 -4
  339. package/requirements.txt +5 -0
  340. package/README.md +0 -3
@@ -0,0 +1,1310 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * daily-report.mjs — 美客多多店铺日报(官方 API 数据源)· 定时推送飞书
4
+ *
5
+ * 链路:ml-api-stores.json(OAuth token,由 ml-oauth-setup.mjs 生成)
6
+ * → 逐店刷新 access token → /orders/search(昨日 + 近7日,按站点本地时区)
7
+ * → /users/{uid}/items/search(库存/价格)→ 畅销榜 + 补货预警
8
+ * → 飞书(ml-daily 角色 Bot 优先、webhook 兜底;两者都没配则 lark-cli bot → 配置中心 chatId)
9
+ *
10
+ * 时间语义:按店铺站点本地时间计算「昨日」,每天本地 REPORT_TIME(默认 08:00)
11
+ * 推送前一天日报;cron 每小时触发本脚本,由脚本做时间门控 + 幂等标记。
12
+ *
13
+ * 用法:
14
+ * node daily-report.mjs --dry-run # 只打印日报,不推送
15
+ * node daily-report.mjs --force # 跳过时间门控立即推送(成功后写幂等标记)
16
+ * node daily-report.mjs --force --no-mark # 测试/验证:强制推送但不写幂等标记(不挡当晚定时推送)
17
+ * node daily-report.mjs --date 2026-08-20 # 指定日报日期(默认各店本地昨日)
18
+ * node daily-report.mjs --env <env 文件路径> # 默认 ~/.dsh/argent/daily-report.env
19
+ * node daily-report.mjs --mock <fixture.json> # 开发自测:跳过联网,仅渲染/打印
20
+ *
21
+ * 数据(~/.dsh/argent/,update 不覆盖):
22
+ * daily-report.env 配置(init-ml.sh 生成模板)
23
+ * ml-api-stores.json 店铺 OAuth token(ml-oauth-setup.mjs 生成)
24
+ * daily-report-cache/ 每日快照 + 推送标记(环比/防重复数据源)
25
+ * daily-report-logs/ 运行日志(cron wrapper 落盘)
26
+ */
27
+
28
+ import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync, appendFileSync, accessSync } from 'node:fs'
29
+ import { homedir } from 'node:os'
30
+ import { join, dirname } from 'node:path'
31
+ import { fileURLToPath } from 'node:url'
32
+ import { spawnSync } from 'node:child_process'
33
+ import { resolveDataDir } from './lib/argent-datadir.mjs'
34
+ import { isDirectRun } from './lib/is-direct-run.mjs'
35
+ // 共用取数/聚合/口径的唯一实现(2026-10-12 抽取):日报 CLI 与只读对账 CLI 共用同一份,
36
+ // ⛔ 不许在任何一边复制第二份口径逻辑。见 `scripts/lib/daily-report-core.mjs` 文件头。
37
+ import {
38
+ SITE_TZ, SITE_CURRENCY, fallbackSite, sleep, loadEnvFile,
39
+ aggregateOrders, reportWindows, windowInfo, capturedAtInfo, captureNoteLines,
40
+ matchStores, storeKey,
41
+ localDateStr, tzOffsetMs, startOfLocalDayUTC, localHour,
42
+ apiGet as coreApiGet, fetchOrders as coreFetchOrders, refreshStoreToken as coreRefreshStoreToken,
43
+ } from './lib/daily-report-core.mjs'
44
+
45
+ // 逐字转出(既有调用方/单测 import 路径不变;同一绑定 ⇒ `M.aggregateOrders === core.aggregateOrders`)
46
+ export { aggregateOrders, localDateStr, tzOffsetMs, startOfLocalDayUTC, localHour, loadEnvFile }
47
+
48
+ const __dirname = dirname(fileURLToPath(import.meta.url)) // ESM 里没有 __dirname(用于找同目录脚本)
49
+
50
+ // 每次调用时解析(不在模块加载时固化):测试可把 ML_API_BASE 指向本地假服务
51
+ // ⚠️ 引擎侧**不把平台域名**放进新文件(engine-words 门禁只拦新增、⛔ 不抬基线)⇒
52
+ // 这份历史常量留在本文件(基线已记账),共用实现按参数接收基址;对账 CLI 从快照记录的
53
+ // `apiBase` 取(见 lib/daily-report-core.mjs 文件头)。
54
+ const apiBase = () => process.env.ML_API_BASE || 'https://api.mercadolibre.com'
55
+
56
+ // ── 参数 ───────────────────────────────────────────────────
57
+ const args = process.argv.slice(2)
58
+ const argVal = (name) => {
59
+ const i = args.indexOf(name)
60
+ return i >= 0 && args[i + 1] ? args[i + 1] : null
61
+ }
62
+ // 数据目录:内核三级链(唯一判据;原为硬编码 ~/.dsh/argent ⇒ 隔离实例读生产配置)
63
+ const DATA_DIR = resolveDataDir({ env: process.env }).dir
64
+ const ENV_PATH = argVal('--env') || join(DATA_DIR, 'daily-report.env')
65
+ const STORES_PATH = process.env.ML_API_STORES_JSON || join(DATA_DIR, 'ml-api-stores.json')
66
+ // 运行期店铺授权列表(main 加载后挂模块级,token 刷新轮换后原子写回同一文件——
67
+ // ML refresh_token 为轮换制:不落盘则下次仍用已作废旧 token 刷新 → 429 rate limit
68
+ // (2026-09-04/05 连续两日断供根因:文件 access_expires_at 停在 08-24 证明从未写回))
69
+ let STORE_LIST = null
70
+ function persistStores() {
71
+ try {
72
+ if (!STORE_LIST) return
73
+ const tmp = `${STORES_PATH}.tmp`
74
+ writeFileSync(tmp, JSON.stringify({ stores: STORE_LIST }, null, 2) + '\n')
75
+ renameSync(tmp, STORES_PATH)
76
+ } catch (e) {
77
+ // 写回失败不阻断本次推送;但下次刷新会再用旧 token,可能再次触发限流
78
+ console.error(`✗ token 写回 ${STORES_PATH} 失败:${e.message}`)
79
+ }
80
+ }
81
+ const FLAG_DRY = args.includes('--dry-run')
82
+ const FLAG_FORCE = args.includes('--force')
83
+ // KB-074:测试/验证用——推送成功后不写幂等标记(手动 --force 不挡当晚定时推送)
84
+ const FLAG_NO_MARK = args.includes('--no-mark')
85
+ const FLAG_JSON = args.includes('--json') // KB-063:只输出日报卡片 JSON(BOT 回复卡片用),不推送
86
+ const ARG_DATE = argVal('--date')
87
+ const FLAG_LIST_SECTIONS = args.includes('--list-sections')
88
+ const FLAG_LIST_STORES = args.includes('--list-stores')
89
+ const ARG_STORE = (argVal('--store') || '').split(',').map((x) => x.trim()).filter(Boolean)
90
+ const ARG_SECTIONS = (argVal('--sections') || '').split(',').map((x) => x.trim()).filter(Boolean)
91
+ const ARG_GROUPS = (argVal('--groups') || '').split(',').map((x) => x.trim()).filter(Boolean)
92
+ const MOCK_FILE = argVal('--mock')
93
+ // `ARGENT_DATA_HOME` 是历史别名(core/mcp 影子跑两者都设)⇒ 保留优先,其余走内核三级链
94
+ const DATA_HOME = process.env.ARGENT_DATA_HOME || DATA_DIR
95
+
96
+ // ── 纯工具函数(导出供单测)────────────────────────────────
97
+ // `loadEnvFile` 的实现已抽到 `scripts/lib/daily-report-core.mjs`(对账 CLI 共用同一份),
98
+ // 本文件顶部逐字转出 ⇒ 既有调用方与单测 import 路径不变。
99
+
100
+ export function parseTimeGate(s) {
101
+ const m = /^(\d{1,2})(?::(\d{2}))?$/.exec(String(s || '').trim())
102
+ if (!m) return { hour: 8, minute: 0 }
103
+ return { hour: Number(m[1]), minute: Number(m[2] || 0) }
104
+ }
105
+
106
+ // 某时区的「本地日期 YYYY-MM-DD」/ 偏移 / 本地日 00:00 / 本地小时 —— 实现已抽到
107
+ // `scripts/lib/daily-report-core.mjs`(本文件顶部逐字转出,调用方与单测不受影响)。
108
+ // 时间门控:站点本地时间是否已到 REPORT_TIME 所在小时
109
+
110
+ export function gateDue(tz, now, reportTime) {
111
+ const { hour } = parseTimeGate(reportTime)
112
+ return localHour(tz, now) === hour
113
+ }
114
+
115
+ // 补推韧性(2026-09-06):站点本地已过推送时刻、当日尚未成功推送(幂等标记)时,
116
+ // 推送时刻后的 +5 小时窗口内允许每小时 cron 补试——单窗口失败(429/网络/漏跑)不再让当天断供。
117
+ // already = 当日是否已推送(调用方按站点本地「昨日」日期查幂等标记)
118
+ export function gateCatchUp(tz, now, reportTime, already) {
119
+ if (already) return false
120
+ const { hour } = parseTimeGate(reportTime)
121
+ const h = localHour(tz, now)
122
+ return h >= hour && h <= hour + 5
123
+ }
124
+
125
+ export function money(code, n) {
126
+ if (n == null) return '—'
127
+ return `${code} ${Math.round(n).toLocaleString('en-US')}`
128
+ }
129
+
130
+ export function pctMark(cur, prev) {
131
+ if (!prev) return ''
132
+ const d = cur - prev
133
+ if (d === 0) return '(与昨日持平)'
134
+ const p = Math.round((d / prev) * 100)
135
+ return d > 0 ? `(↗ 环比 +${p}%)` : `(↘ 环比 ${p}%)`
136
+ }
137
+
138
+ // ── 订单聚合(近7日 + 昨日,按 SKU)────────────────────────
139
+ // 实现已抽到 `scripts/lib/daily-report-core.mjs`(本文件顶部逐字转出;对账 CLI 共用同一份)。
140
+
141
+ // ── 补货预警 + 畅销榜 ─────────────────────────────────────
142
+ export function computeAlerts(skus, leadDays, bufferDays, topN) {
143
+ const threshold = leadDays + bufferDays
144
+ const alerts = []
145
+ for (const s of skus) {
146
+ const rate = s.units7 / 7
147
+ const days = rate > 0 && s.avail != null ? s.avail / rate : null
148
+ s.rate = rate
149
+ s.days = days
150
+ let level = 'none'
151
+ if (s.avail === 0 && s.units7 > 0) level = 'red'
152
+ else if (s.avail > 0 && days != null && days < threshold) level = 'yellow'
153
+ else if (s.avail === 0 && s.units7 === 0) level = 'gray'
154
+ s.level = level
155
+ s.suggested = rate > 0 ? Math.max(0, Math.ceil(threshold * rate - (s.avail || 0))) : 0
156
+ if (level === 'red' || level === 'yellow') alerts.push(s)
157
+ }
158
+ const redFirst = (a, b) =>
159
+ (a.level === 'red' ? 0 : 1) - (b.level === 'red' ? 0 : 1) || (a.days ?? Infinity) - (b.days ?? Infinity)
160
+ alerts.sort(redFirst)
161
+ const top = [...skus].sort((a, b) => b.units7 - a.units7 || b.gmv7 - a.gmv7).slice(0, topN)
162
+ return { alerts, top, threshold }
163
+ }
164
+
165
+ // ── 展示名:**只用 SKU**(2026-09-27 用户口径:日报必须与 lip 表解耦)──
166
+ // 决策经过:先做过「SKU · 中文品名」(中文名来自 lip 表 `产品名称`),随后用户明确
167
+ // **"日报是要跟 lip 表解耦的,所以直接显示 SKU 就好了"** ⇒ 中文名链路(脚本/缓存/依赖)整条移除:
168
+ // 日报只依赖美客多官方 API,不再读任何客户飞书表,也不会因为客户表没录/换列名而失效。
169
+ // 展示链:`SKU` → 无 SKU 时中性占位 `商品 <id>`(**绝不退回西语标题**,用户口径"看不懂也没意义")。
170
+ export function skuLabel(s) {
171
+ const code = String((s && s.sku) || '').trim()
172
+ return code || `商品 ${(s && s.id) || ''}`.trim()
173
+ }
174
+
175
+ // ── 分区(section)注册表:日报的**插拔单位** ────────────────
176
+ // 设计(docs/日报信息架构与插拔方案-2026-09-27.md):
177
+ // · 每个分区自描述:id / group / needs(数据依赖)/ cost(请求量)/ cadence / default / minPlan
178
+ // · **采集层也按需**:只跑"已启用分区 needs 的并集" ⇒ 关掉的组不产生任何 API 请求
179
+ // · 注册表是唯一真源,三个消费方共用:① 定时卡片 ② 工作台详情 ③ 角色对话(SKILL)
180
+ // 渲染约定:分区返回**行数组**(不自行 join),由 renderStoreSection 统一拼 ⇒ 重构前后逐字节一致。
181
+ export const SECTION_GROUPS = ['risk', 'sales', 'profit', 'traffic', 'ads', 'ops']
182
+
183
+ export const SECTIONS = [
184
+ {
185
+ id: 'risk.reputation', group: 'risk', title: '信誉与合规', needs: ['reputation'], cost: { requests: 1 },
186
+ cadence: 'daily', default: true,
187
+ render: ({ d }) => {
188
+ const rep = (d && d.reputation) || null
189
+ if (!rep) return d && d.__errors && d.__errors.reputation ? [`**🛡 信誉与合规**:未取到(${d.__errors.reputation})`] : null
190
+ const m = rep.metrics || {}
191
+ const rate = (x) => (x && x.rate != null ? `${(x.rate * 100).toFixed(2)}%` : '—')
192
+ const val = (x) => (x && x.value != null ? x.value : '—')
193
+ const flag = (x, warn, bad) => { const v = (x && x.rate) || 0; return v >= bad ? '🔴' : v >= warn ? '🟡' : '✅' }
194
+ return [
195
+ `**🛡 信誉与合规**(60 天)· ${rep.level_id || '—'}${rep.power_seller_status ? ` / ${rep.power_seller_status}` : ''}`,
196
+ `${flag(m.claims, 0.01, 0.02)} 纠纷 ${rate(m.claims)}(${val(m.claims)} 件) · ${flag(m.delayed_handling_time, 0.01, 0.02)} 延迟发货 ${rate(m.delayed_handling_time)}(${val(m.delayed_handling_time)}) · ${flag(m.cancellations, 0.01, 0.02)} 取消 ${rate(m.cancellations)}(${val(m.cancellations)})`,
197
+ ]
198
+ },
199
+ },
200
+ {
201
+ id: 'risk.todo', group: 'risk', title: '待办与时效', needs: ['claims', 'questions'], cost: { requests: 2 },
202
+ cadence: 'daily', default: true,
203
+ render: ({ d }) => {
204
+ const out = []
205
+ if (d && d.claims) out.push(`**📮 待办**:待处理纠纷 **${d.claims.total}** 条${d.claims.oldestDays != null ? `(最久 ${d.claims.oldestDays} 天)` : ''}`)
206
+ if (d && d.questions) out.push(`${out.length ? ' · ' : '**📮 待办**:'}未回复提问 **${d.questions.total}** 条${d.questions.oldestHours != null ? `(最久 ${d.questions.oldestHours} 小时)` : ''}`)
207
+ if (!out.length) {
208
+ const errs = (d && d.__errors) || {}
209
+ const why = [errs.claims, errs.questions].filter(Boolean).join(' / ')
210
+ return why ? [`**📮 待办**:未取到(${why})`] : null
211
+ }
212
+ return [out.join('')]
213
+ },
214
+ },
215
+ {
216
+ id: 'risk.sla', group: 'risk', title: '发货时效', needs: ['orders', 'items', 'shipments'], cost: { requests: 'adaptive' },
217
+ cadence: 'daily', default: true,
218
+ render: ({ d }) => {
219
+ const sla = (d && d.sla) || null
220
+ if (!sla) return d && d.__errors && d.__errors.sla ? [`**🚚 发货时效**:未取到(${d.__errors.sla})`] : null
221
+ if (!sla.checked) {
222
+ if (!sla.candidates) return sla.unknown ? [`**🚚 发货时效**:无自发货订单(另 ${sla.unknown} 单类型未知未查)`] : null
223
+ return [`**🚚 发货时效**:本轮未抽查(自发货 ${sla.candidates} 单 > 上限 ${sla.cap})`]
224
+ }
225
+ const un = sla.unknown ? ` · 另 ${sla.unknown} 单类型未知未查` : ''
226
+ const out = [`**🚚 发货时效**:待发出 **${sla.pending}** 单 / 抽查 ${sla.checked} 单(自发货)${sla.oldestHours != null ? ` · 最久已等 ${sla.oldestHours} 小时` : ''}${un}`]
227
+ if (sla.pending > 10) out.push('🔴 建议今天优先处理超时发货(超 24–48h 会计入延迟发货指标)')
228
+ else if (sla.pending > 0) out.push('🟡 记得今天发出')
229
+ return out
230
+ },
231
+ },
232
+ {
233
+ id: 'sales.summary', group: 'sales', title: '昨日概览', needs: ['orders'], cost: { requests: 0 },
234
+ cadence: 'daily', default: true,
235
+ render: ({ r, store }) => {
236
+ const cur = money(SITE_CURRENCY[store.site] || 'USD', r.summary.gmv)
237
+ return [
238
+ `昨日订单 **${r.summary.orders}** · 销售额 **${cur}** · 已售件数 **${r.summary.units}** · 客单价 ${r.summary.avg != null ? Math.round(r.summary.avg).toLocaleString('en-US') : '—'} · 取消 ${r.summary.cancelled}${pctMark(r.summary.orders, r.prev && r.prev.summary.orders)}`,
239
+ // 2026-10-12(客户现场"为什么和平台差几个数"):**追加**「数据截至 + 一行口径」。
240
+ // ⛔ 上面那行数字与措辞**一个字节都没改**;⛔ 不改推送目标/不新增推送。
241
+ // 老快照/夹具没有抓取时刻 ⇒ captureNoteLines 返回空数组 ⇒ 卡片与改动前逐字节一致。
242
+ ...captureNoteLines(r, SITE_TZ[fallbackSite(store.site)]),
243
+ '',
244
+ ]
245
+ },
246
+ },
247
+ {
248
+ id: 'sales.top', group: 'sales', title: '畅销榜', needs: ['orders', 'items'], cost: { requests: 0 },
249
+ cadence: 'daily', default: true,
250
+ render: ({ r, store, topN }) => {
251
+ const out = [`**🔥 畅销榜 Top ${Math.min(topN, r.top.length)}**(近7日)`]
252
+ if (!r.top.length) out.push('(近 7 日无成交)')
253
+ else r.top.forEach((s, i) => {
254
+ const days = s.days != null ? `可售≈${Math.max(0, Math.ceil(s.days))}天` : '可售 —'
255
+ out.push(`${i + 1}. **${skuLabel(s)}** · 7日 ${s.units7}件/${money(SITE_CURRENCY[store.site] || 'USD', s.gmv7)} · 累计已售 ${s.sold != null ? `${s.sold}件` : '—'} · 库存 ${s.avail ?? '—'} · ${days}`)
256
+ })
257
+ out.push('')
258
+ return out
259
+ },
260
+ },
261
+ {
262
+ id: 'sales.trend', group: 'sales', title: '每日趋势', needs: ['orders'], cost: { requests: 0 },
263
+ cadence: 'daily', default: true,
264
+ render: ({ r, store }) => {
265
+ const rows = (r.daily || [])
266
+ if (rows.length < 2) return null
267
+ const cur = (n) => money(SITE_CURRENCY[store.site] || 'USD', n)
268
+ const max = Math.max(...rows.map((x) => x.gmv), 1)
269
+ const bars = '▁▂▃▄▅▆▇█'
270
+ const spark = rows.map((x) => bars[Math.min(7, Math.floor((x.gmv / max) * 7))]).join('')
271
+ const best = rows.reduce((a, b) => (b.gmv > a.gmv ? b : a))
272
+ const worst = rows.reduce((a, b) => (b.gmv < a.gmv ? b : a))
273
+ return [`**📈 近 ${rows.length} 日** ${spark} 最好 ${best.date.slice(5)} ${cur(best.gmv)} · 最差 ${worst.date.slice(5)} ${cur(worst.gmv)}`]
274
+ },
275
+ },
276
+ {
277
+ id: 'structure.logistics', group: 'sales', title: '履约结构', needs: ['orders', 'items'], cost: { requests: 0 },
278
+ cadence: 'daily', default: true,
279
+ render: ({ r }) => {
280
+ let fullGmv = 0, fullUnits = 0, selfGmv = 0, selfUnits = 0, unknownGmv = 0
281
+ const byId = r.itemLogistics || {}
282
+ for (const s of (r.skus7 || [])) {
283
+ const lt = byId[s.id]
284
+ if (lt === 'fulfillment') { fullGmv += s.gmv7; fullUnits += s.units7 }
285
+ else if (lt) { selfGmv += s.gmv7; selfUnits += s.units7 }
286
+ else unknownGmv += s.gmv7 // 不在售/已下架 ⇒ 拿不到履约类型,不硬猜
287
+ }
288
+ const tot = fullGmv + selfGmv
289
+ if (!tot) return null
290
+ const pct = (x) => Math.round((x / tot) * 100)
291
+ const is3 = r.itemStats || {}
292
+ const un = unknownGmv ? ` · 未知(已下架/未取到)${Math.round((unknownGmv / (tot + unknownGmv)) * 100)}%` : ''
293
+ const note = is3.failed ? `(商品详情 ${is3.failed} 个未取到,已归入"未知")` : ''
294
+ return [`**🚚 履约结构**(近7日 GMV):Full **${pct(fullGmv)}%**(${fullUnits} 件) · 自发货 **${pct(selfGmv)}%**(${selfUnits} 件)${un}${note}`]
295
+ },
296
+ },
297
+ {
298
+ id: 'structure.concentration', group: 'sales', title: '销量集中度', needs: ['orders', 'items'], cost: { requests: 0 },
299
+ cadence: 'daily', default: true,
300
+ render: ({ r }) => {
301
+ const rows = [...(r.skus7 || [])].sort((a, b) => b.gmv7 - a.gmv7)
302
+ const tot = rows.reduce((a, x) => a + x.gmv7, 0)
303
+ if (!tot || rows.length < 3) return null // 1-2 个 SKU:谈不上集中度,直接不显示
304
+
305
+ const top10 = rows.slice(0, 10).reduce((a, x) => a + x.gmv7, 0)
306
+ const share = Math.round((top10 / tot) * 100)
307
+ if (rows.length < 10) return [`**🎯 集中度**:成交 SKU 仅 ${rows.length} 个(样本太少,暂不给结论)`]
308
+ const tone = share >= 70 ? '🔴 依赖偏高' : share >= 50 ? '🟡 尚可' : '✅ 分散健康'
309
+ return [`**🎯 集中度**:Top10 SKU 占近 7 日 GMV **${share}%**(${tone})· 在售成交 SKU ${rows.length} 个`]
310
+ },
311
+ },
312
+ {
313
+ id: 'sales.movers', group: 'sales', title: '涨跌榜', needs: ['orders', 'ordersPrev', 'items'], cost: { requests: 'adaptive' },
314
+ cadence: 'daily', default: true,
315
+ render: ({ r }) => {
316
+ if (!r.prevSkus) return null
317
+ const prev = new Map(r.prevSkus.map((x) => [x.id, x]))
318
+ const rows = []
319
+ for (const s of (r.skus7 || [])) {
320
+ const p = prev.get(s.id)
321
+ const pUnits = p ? p.units7 : 0
322
+ const delta = s.units7 - pUnits
323
+ if (pUnits === 0 && s.units7 === 0) continue
324
+ rows.push({ sku: s.sku || s.id, units: s.units7, pUnits, delta, base: pUnits })
325
+ }
326
+ if (!rows.length) return null
327
+ const up = rows.filter((x) => x.delta > 0).sort((a, b) => b.delta - a.delta).slice(0, 3)
328
+ const down = rows.filter((x) => x.delta < 0).sort((a, b) => a.delta - b.delta).slice(0, 3)
329
+ const fmt = (x) => `${x.sku} ${x.pUnits}→${x.units}件(${x.delta > 0 ? '+' : ''}${x.delta})`
330
+ const out = []
331
+ if (up.length) out.push(`**🚀 涨**:${up.map(fmt).join(' · ')}`)
332
+ if (down.length) out.push(`**🔻 跌**:${down.map(fmt).join(' · ')}`)
333
+ return out.length ? [`**🔁 环比上周**(近7日 vs 前7日,件数)`, ...out] : null
334
+ },
335
+ },
336
+ {
337
+ id: 'profit.fees', group: 'profit', title: '平台费用', needs: ['orders'], cost: { requests: 0 },
338
+ cadence: 'daily', default: true,
339
+ render: ({ r, store }) => {
340
+ const cur = (n) => money(SITE_CURRENCY[store.site] || 'USD', n)
341
+ const gmv = r.summary.gmv || 0
342
+ const fees = r.summary.fees || 0
343
+ if (!gmv) return null
344
+ const rate = ((fees / gmv) * 100).toFixed(1)
345
+ const out = [`**💰 平台费用**:佣金 ${cur(fees)}(占 GMV ${rate}%)`]
346
+ if (r.summary.coupons) out.push(` · 优惠券 ${cur(r.summary.coupons)}`)
347
+ return [out.join('')]
348
+ },
349
+ },
350
+ {
351
+ id: 'profit.net', group: 'profit', title: '净收入估算', needs: ['orders'], cost: { requests: 0 },
352
+ cadence: 'daily', default: true,
353
+ render: ({ r, store }) => {
354
+ const cur = (n) => money(SITE_CURRENCY[store.site] || 'USD', n)
355
+ const gmv = r.summary.gmv || 0
356
+ if (!gmv) return null
357
+ const net = gmv - (r.summary.fees || 0) - (r.summary.coupons || 0)
358
+ return [`**🧾 净收入估算** ≈ ${cur(net)}(GMV − 佣金 − 优惠券;**未含运费**,运费在物流批次补)`]
359
+ },
360
+ },
361
+ {
362
+ id: 'traffic.visits', group: 'traffic', title: '流量与转化', needs: ['visits', 'orders', 'items'],
363
+ cost: { requests: '按 Top N(默认 20/店)' }, cadence: 'weekly', default: true,
364
+ render: ({ r, store, d }) => {
365
+ const v = (d && d.visits) || null
366
+ if (!v) return d && d.__errors && d.__errors.visits ? [`**👀 流量**:未取到(${d.__errors.visits})`] : null
367
+ const known = Object.values(v).filter((x) => x != null)
368
+ if (!known.length) return null
369
+ const total = known.reduce((a, x) => a + x, 0)
370
+ const orders7 = (r.summary && r.summary.orders7) || 0
371
+ const cvr = total ? ((orders7 / total) * 100).toFixed(2) : '—'
372
+ const byId = r.itemById || {}
373
+ const top = Object.entries(v).filter(([, n]) => n != null && n > 0)
374
+ .sort((a, b) => b[1] - a[1]).slice(0, 5)
375
+ .map(([id, n]) => `${(byId[id] && byId[id].sku) || id} ${n}`)
376
+ const out = [`**👀 流量与转化**(近 7 日):访客 **${total.toLocaleString('en-US')}**(抽查 ${known.length} 个商品) · 转化率 **${cvr}%**(${orders7} 单 / ${total} 访客)`]
377
+ if (top.length) out.push(`访客 Top5:${top.join(' · ')}`)
378
+ if (known.length < Object.keys(v).length) out.push(`⚠️ ${Object.keys(v).length - known.length} 个商品访客未取到(已从合计中剔除)`)
379
+ const is = r.itemStats || {}
380
+ if (is.failed) out.push(`⚠️ 商品详情 ${is.failed}/${is.total} 个未取到(限流等)⇒ 抽查样本偏小,重跑可补全`)
381
+ return out
382
+ },
383
+ },
384
+ {
385
+ id: 'traffic.zombies', group: 'traffic', title: '低效 listing', needs: ['visitsAll', 'items'],
386
+ cost: { requests: '全部在售商品(1 请求/个)' }, cadence: 'weekly', default: true,
387
+ render: ({ r, d }) => {
388
+ const v = (d && d.visits) || null
389
+ if (!v) return null
390
+ const byId = r.itemById || {}
391
+ const zero = Object.entries(v).filter(([id, n]) => n === 0 && byId[id] && byId[id].status === 'active')
392
+ const unknown = Object.values(v).filter((x) => x == null).length
393
+ const paused = d && d.paused != null ? d.paused : null
394
+ const out = [`**🧟 低效 listing**:7 日 **0 访客**的在售商品 **${zero.length}** 个(共抽查 ${Object.keys(v).length} 个)${paused != null ? ` · 已暂停 ${paused} 个` : ''}`]
395
+ const eg = zero.slice(0, 3).map(([id]) => (byId[id] && byId[id].sku) || id)
396
+ if (eg.length) out.push(`例:${eg.join(' · ')} ⇒ 建议重写标题/主图或下架重上`)
397
+ if (unknown) out.push(`⚠️ ${unknown} 个商品访客未取到(未计入 0 访客)`)
398
+ const is2 = r.itemStats || {}
399
+ if (is2.failed) out.push(`⚠️ 商品详情 ${is2.failed}/${is2.total} 个未取到(限流等)⇒ 上述"0 访客"可能漏计,重跑可补全`)
400
+ return out
401
+ },
402
+ },
403
+ {
404
+ id: 'traffic.priceToWin', group: 'traffic', title: '价格竞争力', needs: ['priceToWin', 'items'],
405
+ cost: { requests: '按 Top N(默认 20/店)' }, cadence: 'weekly', default: false,
406
+ render: ({ r, d }) => {
407
+ const p = (d && d.price) || null
408
+ if (!p) return null
409
+ const byId = r.itemById || {}
410
+ const rows = Object.entries(p).filter(([, x]) => x && x.target != null)
411
+ if (!rows.length) {
412
+ const n = Object.values(p).filter((x) => x && x.status === 'not_listed').length
413
+ return [`**🏷 价格竞争力**:本店商品未参与目录竞争(${n} 个 not_listed)⇒ 暂无价格建议`]
414
+ }
415
+ const out = [`**🏷 价格竞争力**(可获推荐位的目标价)`]
416
+ for (const [id, x] of rows.slice(0, 5)) {
417
+ const cur = x.current != null ? x.current : '—'
418
+ const gap = x.current != null && x.target != null ? (x.current - x.target).toFixed(2) : null
419
+ out.push(`· **${(byId[id] && byId[id].sku) || id}** 现价 ${cur} → 建议 ${x.target}${gap && Number(gap) > 0 ? `(差 ${gap})` : ''} · ${x.status}${x.boosts ? ` · ${x.boosts} 项加成` : ''}`)
420
+ }
421
+ return out
422
+ },
423
+ },
424
+ {
425
+ id: 'ops.restock', group: 'ops', title: '补货提醒', needs: ['orders', 'items'], cost: { requests: 0 },
426
+ cadence: 'daily', default: true,
427
+ render: ({ r, threshold }) => {
428
+ const out = [`**⚠️ 补货提醒**(可售天数 < ${threshold} 天预警)`]
429
+ if (!r.alerts.length) out.push('✅ 今日无预警,库存健康')
430
+ else for (const s of r.alerts) {
431
+ if (s.level === 'red') out.push(`🔴 **${skuLabel(s)}**:已断货(近7日售 ${s.units7} 件)→ 建议补货 **${s.suggested} 件**`)
432
+ else {
433
+ const days = s.days != null ? `≈${Math.max(0, Math.ceil(s.days))}天` : '—'
434
+ out.push(`🟡 **${skuLabel(s)}**:剩 ${s.avail} 件,可售 ${days} → 建议补货 **${s.suggested} 件**`)
435
+ }
436
+ }
437
+ return out
438
+ },
439
+ },
440
+ ]
441
+
442
+ const SECTION_BY_ID = new Map(SECTIONS.map((x) => [x.id, x]))
443
+
444
+ /** 解析启用集合:命令行 --sections > 单条目开关 > 组开关/默认值。返回 Set<id>。 */
445
+ export function resolveSections(cfg = {}, env = {}, cli = {}) {
446
+ if (cli.sections && cli.sections.length) return new Set(cli.sections.filter((x) => SECTION_BY_ID.has(x)))
447
+ const on = new Set()
448
+ for (const sec of SECTIONS) {
449
+ const norm = sec.id.replace(/[^A-Za-z0-9]/g, '_')
450
+ const envKey = `ML_SECTION_${norm.toUpperCase()}`
451
+ // 配置键统一用归一化下划线(控制台表单友好):mlSection_risk_sla
452
+ // 同时兼容原始点号写法(mlSection_risk.sla),避免历史配置失效
453
+ let v = cfg[`mlSection_${norm}`]
454
+ if (v === undefined) v = cfg[`mlSection_${sec.id}`]
455
+ if (v === undefined) v = env[envKey]
456
+ if (v === undefined) {
457
+ const grpEnv = env[`ML_GROUP_${sec.group.toUpperCase()}`]
458
+ if (grpEnv !== undefined) v = grpEnv
459
+ else if (cfg.mlReportGroups && cfg.mlReportGroups[sec.group] !== undefined) v = cfg.mlReportGroups[sec.group]
460
+ }
461
+ if (cli.groups && cli.groups.length && !cli.groups.includes(sec.group)) v = false
462
+ const enabled = v === undefined ? !!sec.default : !(v === false || v === 0 || v === '0' || v === 'false')
463
+ if (enabled) on.add(sec.id)
464
+ }
465
+ return on
466
+ }
467
+
468
+ /** 已启用分区需要的数据类别(采集层据此**只发必要请求**)。 */
469
+ export function neededData(enabled) {
470
+ const need = new Set()
471
+ for (const id of enabled) { const sec = SECTION_BY_ID.get(id); if (sec) for (const n of sec.needs) need.add(n) }
472
+ return need
473
+ }
474
+
475
+ /** 分区清单(--list-sections / 控制台 / 角色对话共用)。 */
476
+ export function sectionCatalog(enabled) {
477
+ return SECTIONS.map((s) => ({
478
+ id: s.id, group: s.group, title: s.title, needs: s.needs, requests: (s.cost && s.cost.requests) || 0,
479
+ cadence: s.cadence, default: !!s.default, enabled: enabled ? enabled.has(s.id) : !!s.default,
480
+ }))
481
+ }
482
+
483
+ // ── 渲染:单店 Markdown 段(按注册表顺序、只渲染已启用分区)──
484
+ export function renderStoreSection(store, r, topN, threshold, opts = {}) {
485
+ const enabled = opts.enabled || null
486
+ const lines = [`🏪 **${store.name}**(${store.site})· ${r.date} 昨日数据`]
487
+ for (const sec of SECTIONS) {
488
+ if (enabled && !enabled.has(sec.id)) continue
489
+ if (r.error && sec.group !== 'sales') continue
490
+ const chunk = sec.render({ r, store, topN, threshold, d: (r.data || {}), opts })
491
+ if (chunk && chunk.length) lines.push(...chunk)
492
+ }
493
+ return lines.join('\n')
494
+ }
495
+
496
+
497
+ // ── 卡片 v2:折叠分区(用户 2026-09-27 决策 D)──────────────
498
+ // 布局:**每店每天 1 张卡**(风险区常显 + 其他组折叠)+ 1 张跨店总览。
499
+ // 注意:一律**发新卡**,不做 message patch —— 用户口径:"更新卡片会让客户误会数据没更新,
500
+ // 发新卡用户一眼就知道是新数据"。
501
+ const GROUP_LABEL = { risk: '风险与待办', sales: '经营', profit: '利润与费用', traffic: '流量与转化', ads: '广告', ops: '库存与补货' }
502
+ const GROUP_ORDER = ['sales', 'profit', 'traffic', 'ads', 'ops']
503
+
504
+ /** 按分组把已启用分区渲染成块:{ risk: [lines], groups: [{group, lines}] }。 */
505
+ export function renderStoreBlocks(store, r, topN, threshold, enabled, opts = {}) {
506
+ const byGroup = new Map()
507
+ const head = [`🏪 **${store.name}**(${store.site})· ${r.date} 昨日数据`]
508
+ for (const sec of SECTIONS) {
509
+ if (enabled && !enabled.has(sec.id)) continue
510
+ if (r.error && sec.group !== 'sales') continue
511
+ let lines
512
+ if (opts.cached && opts.cached.has(sec.id)) {
513
+ const c = (r.weeklyCache || {})[sec.id]
514
+ lines = c && c.lines && c.lines.length ? [...c.lines, `(本组每周更新,上面是 ${c.at ? c.at.slice(0, 10) : '上次'} 的数据)`] : null
515
+ } else {
516
+ lines = sec.render({ r, store, topN, threshold, d: (r.data || {}) })
517
+ if (lines && lines.length && sec.cadence === 'weekly' && opts.onWeeklyRendered) opts.onWeeklyRendered(sec.id, lines)
518
+ }
519
+ if (!lines || !lines.length) continue
520
+ if (!byGroup.has(sec.group)) byGroup.set(sec.group, [])
521
+ byGroup.get(sec.group).push(...lines)
522
+ }
523
+ return {
524
+ head,
525
+ risk: byGroup.get('risk') || [],
526
+ groups: GROUP_ORDER.filter((g) => byGroup.has(g)).map((g) => ({ group: g, lines: byGroup.get(g) })),
527
+ }
528
+ }
529
+
530
+ /** 风险灯:🔴 > 🟡 > ✅ ⇒ 卡片模板色。 */
531
+ export function riskLevel(blocks) {
532
+ const txt = (blocks.risk || []).join('\n')
533
+ if (txt.includes('🔴')) return 'red'
534
+ if (txt.includes('🟡')) return 'yellow'
535
+ return 'green'
536
+ }
537
+
538
+ /** 折叠面板:带图标 + 展开旋转,让人一眼知道能点(用户要求)。
539
+ * · icon:飞书标准图标 token(默认 down-small-ccm_outlined,可用 mlFoldIcon 覆盖)
540
+ * · icon_position=right、icon_expanded_angle=180 ⇒ 收起时朝下、展开时自动翻转
541
+ * · 标题再补一个文字箭头作为**降级**提示(客户端若不支持标准图标,仍看得出可展开)
542
+ */
543
+ export function collapsiblePanel(group, lines, opts = {}) {
544
+ const label = GROUP_LABEL[group] || group
545
+ const token = opts.foldIcon || process.env.ML_FOLD_ICON || 'down-small-ccm_outlined'
546
+ const mark = opts.foldMark === undefined ? '▾' : opts.foldMark // 文字降级箭头(''=不要)
547
+ return {
548
+ tag: 'collapsible_panel',
549
+ expanded: false,
550
+ header: {
551
+ title: { tag: 'markdown', content: `${mark ? `${mark} ` : ''}**${label}**` },
552
+ icon: { tag: 'standard_icon', token, color: 'grey' },
553
+ icon_position: 'right',
554
+ icon_expanded_angle: 180,
555
+ },
556
+ elements: chunkText(lines.join('\n')).map((content) => ({ tag: 'markdown', content })),
557
+ }
558
+ }
559
+
560
+ /** 飞书卡片 schema 2.0(body.elements + collapsible_panel)。 */
561
+ export function buildCardV2(title, blocks, opts = {}) {
562
+ const body = []
563
+ for (const line of blocks.head || []) body.push({ tag: 'markdown', content: line })
564
+ if (blocks.risk && blocks.risk.length) body.push({ tag: 'markdown', content: blocks.risk.join('\n') })
565
+ for (const g of blocks.groups || []) {
566
+ body.push(collapsiblePanel(g.group, g.lines, opts))
567
+ }
568
+ return {
569
+ schema: '2.0',
570
+ config: { wide_screen_mode: true },
571
+ header: { template: opts.template || 'blue', title: { tag: 'plain_text', content: title } },
572
+ body: { elements: body },
573
+ }
574
+ }
575
+
576
+ export function cardMessageV2(title, blocks, opts = {}) {
577
+ return { msg_type: 'interactive', card: buildCardV2(title, blocks, opts) }
578
+ }
579
+
580
+ /** 跨店总览(≥2 店时默认发;mlOverviewCard=false 可关)。 */
581
+ export function buildOverviewBlocks(dateLabel, items) {
582
+ const lines = [`**跨店总览** · ${dateLabel}(昨日数据)`, '']
583
+ for (const x of items) {
584
+ const lvl = x.level === 'red' ? '🔴' : x.level === 'yellow' ? '🟡' : '✅'
585
+ lines.push(`${lvl} **${x.name}** · ${x.orders} 单 / ${money(x.currency, x.gmv)}${x.riskLine ? ` · ${x.riskLine}` : ''}`)
586
+ }
587
+ return { head: lines, risk: [], groups: [] }
588
+ }
589
+
590
+ // 超长内容分段(飞书卡片 markdown 元素单段上限保守取 3000 字符)
591
+ export function chunkText(text, size = 3000) {
592
+ const out = []
593
+ let rest = text
594
+ while (rest.length > size) {
595
+ let cut = rest.lastIndexOf('\n', size)
596
+ if (cut <= 0) cut = size
597
+ out.push(rest.slice(0, cut))
598
+ rest = rest.slice(cut).replace(/^\n+/, '')
599
+ }
600
+ if (rest) out.push(rest)
601
+ return out
602
+ }
603
+
604
+ // ── 飞书 webhook 交互卡片 ─────────────────────────────────
605
+ export function buildCardPayload(title, sections) {
606
+ return {
607
+ msg_type: 'interactive',
608
+ card: {
609
+ config: { wide_screen_mode: true },
610
+ header: {
611
+ template: 'blue',
612
+ title: { tag: 'plain_text', content: title },
613
+ },
614
+ elements: sections.flatMap((s) =>
615
+ chunkText(s).map((content) => ({ tag: 'markdown', content })),
616
+ ),
617
+ },
618
+ }
619
+ }
620
+
621
+ // ── 网络层:共用取数实现(本文件只注入 API 基址;实现见 lib/daily-report-core.mjs)──
622
+ // `apiGet` / `fetchOrders` / `refreshStoreToken` 的**判断**(429 退避、token 复用 15min 阈值、
623
+ // 轮换写回)全在共用实现里 —— 两个 CLI 一份实现,⛔ 不再有第二份。这里只做一件事:
624
+ // 把本 CLI 的基址(历史常量 + `ML_API_BASE` 覆盖)与写回钩子(`persistStores`)注入。
625
+ export async function apiGet(path, token, opts = {}) {
626
+ return coreApiGet(path, token, { ...opts, base: opts.base || apiBase() })
627
+ }
628
+
629
+ async function refreshStoreToken(env, store) {
630
+ return coreRefreshStoreToken(env, store, { base: apiBase(), onRotate: persistStores })
631
+ }
632
+
633
+ async function fetchOrders(token, sellerId, fromISO, toISO) {
634
+ return coreFetchOrders(token, sellerId, fromISO, toISO, { base: apiBase() })
635
+ }
636
+
637
+ async function fetchItems(token, userId) {
638
+ const map = {}
639
+ const ids = []
640
+ let failed = 0
641
+ let offset = 0
642
+ for (;;) {
643
+ const p = new URLSearchParams({ status: 'active', limit: '50', offset: String(offset) })
644
+ const d = await apiGet(`/users/${userId}/items/search?${p}`, token)
645
+ for (const it of d.results || []) {
646
+ const id = String((it && typeof it === 'object' && it.id) || it || '').trim()
647
+ if (id && !ids.includes(id)) ids.push(id)
648
+ }
649
+ const total = (d.paging && d.paging.total) || ids.length
650
+ if (ids.length >= total || ids.length >= 2000 || !(d.results || []).length) break
651
+ offset += 50
652
+ }
653
+ // 逐 ID 拉详情(并发 3 + 批间 80ms:**并发 5 无间隔会被限流**,中途失败的详情此前被静默丢掉 ⇒
654
+ // 商品表不完整 ⇒ 履约结构"未知"虚高、流量抽查样本偏小。现在既放慢也**统计失败数**)
655
+ for (let i = 0; i < ids.length; i += 3) {
656
+ await Promise.all(
657
+ ids.slice(i, i + 3).map(async (id) => {
658
+ try {
659
+ const it = await apiGet(
660
+ `/items/${id}?attributes=id,title,price,available_quantity,sold_quantity,status,shipping`,
661
+ token,
662
+ )
663
+ if (it && it.id) {
664
+ map[String(it.id)] = {
665
+ id: String(it.id),
666
+ title: it.title || '',
667
+ price: it.price ?? null,
668
+ available_quantity: it.available_quantity ?? null,
669
+ sold_quantity: it.sold_quantity ?? null,
670
+ permalink: it.permalink || '',
671
+ status: it.status || '',
672
+ // ⚠️ 必须带上 shipping:履约类型(fulfillment=Full / xd_drop_off=自发货)全靠它。
673
+ // 2026-09-27 踩过:这里只挑了部分字段 ⇒ logistic_type 恒为空 ⇒
674
+ // ① 履约结构恒显示"Full 0%";② SLA 抽查把所有订单当自发货(300 单全部白查)。
675
+ shipping: it.shipping || null,
676
+ }
677
+ }
678
+ } catch { failed++ } // 单商品失败不阻塞,但要计数
679
+ }),
680
+ )
681
+ if (i + 3 < ids.length) await new Promise((r) => setTimeout(r, 80))
682
+ }
683
+ return { map, total: ids.length, failed }
684
+ }
685
+
686
+ // ── 快照缓存(环比 + 幂等标记)─────────────────────────────
687
+ // 以 user_id 稳定命名(不按下标——多时区店铺在不同小时触发时下标会漂移)
688
+ const cacheDir = () => join(DATA_HOME, 'daily-report-cache')
689
+ // `storeKey` 由共用实现提供(对账 CLI 用同一份命名,否则两边找不到同一个快照)
690
+ const snapshotFile = (date, store) => join(cacheDir(), `${date}-${storeKey(store)}.json`)
691
+ const markerFile = (date, store) => join(cacheDir(), `.pushed-${date}-${storeKey(store)}`)
692
+
693
+ function loadSnapshot(date, store) {
694
+ try { return JSON.parse(readFileSync(snapshotFile(date, store), 'utf8')) } catch { return null }
695
+ }
696
+ function saveSnapshot(r, store) {
697
+ mkdirSync(cacheDir(), { recursive: true })
698
+ writeFileSync(snapshotFile(r.date, store), JSON.stringify(r, null, 2) + '\n')
699
+ }
700
+ function alreadyPushed(date, store) { return existsSync(markerFile(date, store)) }
701
+ function markPushed(date, store) {
702
+ mkdirSync(cacheDir(), { recursive: true })
703
+ writeFileSync(markerFile(date, store), new Date().toISOString() + '\n')
704
+ }
705
+
706
+ // ── 飞书推送 ───────────────────────────────────────────────
707
+ function larkConfig() {
708
+ try {
709
+ const cfg = JSON.parse(readFileSync(join(DATA_HOME, 'config.json'), 'utf8'))
710
+ return cfg
711
+ } catch { return {} }
712
+ }
713
+
714
+ // 读取链:Web 配置中心 config.json(mlDaily*)→ daily-report.env(旧键)→ 默认
715
+ export function pick(cfg, env, cfgKey, envKey, def) {
716
+ const v = cfg[cfgKey] ?? env[envKey]
717
+ return v === undefined || v === null || v === '' ? def : v
718
+ }
719
+
720
+ async function pushWebhook(url, payload) {
721
+ const res = await fetch(url, {
722
+ method: 'POST',
723
+ headers: { 'Content-Type': 'application/json' },
724
+ body: JSON.stringify(payload),
725
+ })
726
+ const body = await res.json().catch(() => ({}))
727
+ if (!res.ok || (body.code != null && body.code !== 0)) {
728
+ throw new Error(`webhook 推送失败 HTTP ${res.status}: ${JSON.stringify(body).slice(0, 300)}`)
729
+ }
730
+ return body
731
+ }
732
+
733
+ // KB-046 + 2026-08-30:日报推送统一身份角色 profile(默认 ml-daily,env LARK_PROFILE 可覆盖——
734
+ // 客户机如无 ml-daily 独立 Bot,设 LARK_PROFILE=<主Bot profile> 即用主 Bot 身份);webhook 降级为 fallback
735
+ const LARK_PROFILE = process.env.LARK_PROFILE || 'ml-daily'
736
+ const ML_DAILY_CHAT_ID = 'oc_e02401074a181e7e03610be93bed8cbd'
737
+
738
+ // KB-091:告警与业务推送彻底解耦——内部运维/开发告警目标 = Argent 内部协作群
739
+ // (09-03 事故:告警误发客户日报群 oc_d839;客户群不可承载内部技术信息)。
740
+ // 告警 profile 默认主 Bot(cli_a93b9354c6e35cd1,可达内部群);客户机可在 config.json
741
+ // 配 mlDailyAlertProfile/mlDailyAlertChatId 或 env ML_DAILY_ALERT_PROFILE/ML_DAILY_ALERT_CHAT_ID 覆盖。
742
+ const ML_DAILY_ALERT_CHAT_ID = 'oc_8ce5cbe50dd62f6df6e2c0ea92ab0bac'
743
+ const ML_DAILY_ALERT_PROFILE = process.env.ML_DAILY_ALERT_PROFILE || 'cli_a93b9354c6e35cd1'
744
+
745
+ // 2026-09-03 修复:cron 最小 PATH 无 ~/.local/bin(npm 全局 lark-cli)→ spawnSync('lark-cli')
746
+ // ENOENT 且错误信息为空(status=null),09-01 起 Huangwei Mac mini 定时推送静默失败。
747
+ // 三链兜底(同 poll-fulfillment.sh):显式候选路径逐个 accessSync(X_OK),都失败才原样交给 PATH。
748
+ function resolveLarkCli() {
749
+ const home = homedir()
750
+ const cands = [
751
+ process.env.LARK_CLI || '',
752
+ join(home, '.local/bin/lark-cli'),
753
+ join(home, 'node/bin/lark-cli'),
754
+ '/opt/homebrew/bin/lark-cli',
755
+ '/usr/local/bin/lark-cli',
756
+ ]
757
+ for (const c of cands) {
758
+ if (!c) continue
759
+ try { accessSync(c, 0o1); return c } catch { /* 下一个 */ }
760
+ }
761
+ return 'lark-cli' // 最后兜底:依赖 PATH(SSH/交互环境通常可达)
762
+ }
763
+ const LARK_BIN = resolveLarkCli()
764
+ // lark-cli(npm 包)shebang 为 /usr/bin/env node——最小 PATH(cron)下即使 lark-cli 用绝对路径
765
+ // 也会因 env node 找不到而失败(2026-09-03 实测「env: node: No such file or directory」)。
766
+ // spawn 时把 ~/.local/bin 与 ~/node/bin 注入 PATH(同 crontab PATH 行的双保险,防 crontab 被重置)。
767
+ const LARK_ENV = {
768
+ ...process.env,
769
+ PATH: [join(homedir(), '.local/bin'), join(homedir(), 'node/bin'), process.env.PATH]
770
+ .filter(Boolean).join(':'),
771
+ }
772
+
773
+ function pushLark(md, chatId, profile) {
774
+ const r = spawnSync(LARK_BIN, ['--profile', profile, 'im', '+messages-send', '--chat-id', String(chatId), '--markdown', md, '--as', 'bot'], {
775
+ encoding: 'utf8',
776
+ timeout: 60000,
777
+ env: LARK_ENV,
778
+ })
779
+ if (r.status !== 0) {
780
+ throw new Error(`lark-cli 推送失败: ${(r.error?.message || r.stderr || r.stdout || '').slice(0, 300)}`)
781
+ }
782
+ }
783
+
784
+ // 2026-08-31:lark-bot 通道改发 interactive 卡片(与 webhook 卡片同构,角色化后保持卡片式)
785
+ function pushLarkCard(card, chatId, profile) {
786
+ const r = spawnSync(LARK_BIN, ['--profile', profile, 'im', '+messages-send', '--chat-id', String(chatId), '--msg-type', 'interactive', '--content', JSON.stringify(card), '--as', 'bot'], {
787
+ encoding: 'utf8',
788
+ timeout: 60000,
789
+ env: LARK_ENV,
790
+ })
791
+ if (r.status !== 0) {
792
+ throw new Error(`lark-cli 卡片推送失败: ${(r.error?.message || r.stderr || r.stdout || '').slice(0, 300)}`)
793
+ }
794
+ }
795
+
796
+ async function pushCards(env, cards, overview) {
797
+ const cfg = larkConfig()
798
+ const chatId = pick(cfg, env, 'mlDailyChatId', 'ML_DAILY_CHAT_ID', ML_DAILY_CHAT_ID)
799
+ const profile = pick(cfg, env, 'mlDailyProfile', 'LARK_PROFILE', LARK_PROFILE)
800
+ if (!chatId) {
801
+ const webhook = cfg.mlDailyWebhook || env.FEISHU_WEBHOOK_URL
802
+ if (!webhook) throw new Error('未配置推送通道:ML_DAILY_CHAT_ID(lark-bot 身份)或 mlDailyWebhook(降级)')
803
+ for (const c of cards) await pushWebhook(webhook, c) // webhook 兜底:逐卡 POST
804
+ if (overview) await pushWebhook(webhook, overview)
805
+ return 'webhook'
806
+ }
807
+ for (const c of cards) pushLarkCard(c.card, chatId, profile) // 每店一张卡(用户决策 D)
808
+ if (overview) pushLarkCard(overview.card, chatId, profile)
809
+ return 'lark-bot-card'
810
+ }
811
+
812
+ async function notifyPushFailure(env, err) {
813
+ const cfg = larkConfig()
814
+ const text = `⚠️ **日报推送失败告警**\n时间:${new Date().toISOString()}\n错误:${String((err && err.message) || err).slice(0, 400)}`
815
+ // 通道1:lark-bot 告警(resolveLarkCli 三链 + PATH 注入后可达)。
816
+ // 目标群:env.ML_DAILY_ALERT_CHAT_ID → cfg.mlDailyAlertChatId → 内部群常量(oc_8ce5…)。
817
+ // profile:env.ML_DAILY_ALERT_PROFILE → cfg.mlDailyAlertProfile → 主 Bot(cli_a93b9354c6e35cd1)。
818
+ try {
819
+ const chatId = env.ML_DAILY_ALERT_CHAT_ID || (cfg && cfg.mlDailyAlertChatId) || ML_DAILY_ALERT_CHAT_ID
820
+ const profile = env.ML_DAILY_ALERT_PROFILE || (cfg && cfg.mlDailyAlertProfile) || ML_DAILY_ALERT_PROFILE
821
+ if (chatId) {
822
+ pushLark(text, chatId, profile)
823
+ console.error('[daily-report] 已通过 lark-bot 发送推送失败告警到内部群')
824
+ return 'lark-bot'
825
+ }
826
+ } catch (e) {
827
+ console.error(`✗ 告警 lark-bot 发送失败:${e.message}`)
828
+ }
829
+ // 通道2:落盘告警日志(定时巡检/人工兜底)
830
+ try {
831
+ const dir = join(DATA_DIR, 'daily-report-logs')
832
+ mkdirSync(dir, { recursive: true })
833
+ appendFileSync(join(dir, 'alert.log'), `[${new Date().toISOString()}] ${text}\n`)
834
+ console.error('[daily-report] 告警通道均失败,已落盘 alert.log')
835
+ return 'alert.log'
836
+ } catch {
837
+ /* 落盘也失败:静默(已尽力) */
838
+ }
839
+ return 'none'
840
+ }
841
+
842
+ // ── 单店采集 ───────────────────────────────────────────────
843
+
844
+
845
+ // ── 周更分区(流量组等重型分区:每周只真算一次,其余日子用缓存)──────
846
+ // 为什么:流量组每店要按商品逐个查访客(1店 238 个在售 ⇒ 238 请求),每天算太浪费;
847
+ // 而"访客/转化/僵尸清单"看**周趋势**才稳定。⇒ cadence=weekly 的分区只在 mlWeeklyDay(默认周一)
848
+ // 真算一次,其余日子直接渲染缓存(卡片上标注"(周一数据)",不假装是今天的)。
849
+ // 默认按**本机/部署时区**判断星期(不能写死 Asia/Shanghai:客户机在墨西哥,
850
+ // 写死会让"周一"错一天 —— 2026-09-27 实测踩到:本机周日 17:5x 时上海已是周一)。
851
+ export function weekdayDue(now, day, tz = process.env.ML_WEEKLY_TZ || Intl.DateTimeFormat().resolvedOptions().timeZone) {
852
+ const wd = new Date(now.toLocaleString('en-US', { timeZone: tz })).getDay() // 0=周日
853
+ return Number(day) === wd
854
+ }
855
+
856
+ function sectionCachePath() { return join(cacheDir(), 'section-cache.json') }
857
+ export function loadSectionCache() {
858
+ try { return JSON.parse(readFileSync(sectionCachePath(), 'utf8')) } catch { return {} }
859
+ }
860
+ function saveSectionCache(cache) {
861
+ try {
862
+ mkdirSync(cacheDir(), { recursive: true })
863
+ const tmp = `${sectionCachePath()}.tmp-${process.pid}`
864
+ writeFileSync(tmp, JSON.stringify(cache, null, 1), { mode: 0o600 })
865
+ renameSync(tmp, sectionCachePath())
866
+ } catch (e) { console.error(`[daily-report] ⚠️ 周更缓存写入失败:${e.message}`) }
867
+ }
868
+
869
+ /** 把启用集合拆成"今天要算的"与"用缓存的"(cadence=weekly 且今天不是 mlWeeklyDay)。 */
870
+ export function splitByCadence(enabled, now, cfg = {}, env = {}) {
871
+ const day = Number(pick(cfg, env, 'mlWeeklyDay', 'ML_WEEKLY_DAY', 1))
872
+ const due = weekdayDue(now, day)
873
+ const active = new Set(), cached = new Set()
874
+ for (const id of enabled) {
875
+ const sec = SECTION_BY_ID.get(id)
876
+ if (!sec) continue
877
+ if (sec.cadence === 'weekly' && !due) cached.add(id)
878
+ else active.add(id)
879
+ }
880
+ return { active, cached, weeklyDay: day }
881
+ }
882
+
883
+ // ── 数据采集器(按 need 取数;只被"已启用分区"需要时才调用)──────
884
+ const SHIP_PENDING_STATUS = new Set(['pending', 'handling', 'ready_to_ship'])
885
+
886
+ async function mapLimit(items, limit, fn, gapMs = 120) {
887
+ const out = []
888
+ for (let i = 0; i < items.length; i += limit) {
889
+ // eslint-disable-next-line no-await-in-loop
890
+ out.push(...(await Promise.all(items.slice(i, i + limit).map(fn))))
891
+ if (gapMs && i + limit < items.length) await new Promise((r) => setTimeout(r, gapMs))
892
+ }
893
+ return out
894
+ }
895
+
896
+ async function collectReputation(token) {
897
+ const me = await apiGet('/users/me', token)
898
+ const rep = me.seller_reputation || {}
899
+ return { level_id: rep.level_id || '', power_seller_status: rep.power_seller_status || '', metrics: rep.metrics || {} }
900
+ }
901
+
902
+ async function collectClaims(token) {
903
+ const d = await apiGet('/post-purchase/v1/claims/search?role=seller&status=opened&limit=50', token)
904
+ const rows = d.data || []
905
+ const total = (d.paging && d.paging.total) != null ? d.paging.total : rows.length
906
+ const ts = rows.map((x) => Date.parse(x.date_created || '')).filter(Boolean)
907
+ return { total, oldestDays: ts.length ? Math.floor((Date.now() - Math.min(...ts)) / 86400000) : null }
908
+ }
909
+
910
+ async function collectQuestions(token, userId) {
911
+ const d = await apiGet(`/questions/search?seller_id=${userId}&status=UNANSWERED&limit=50`, token)
912
+ const rows = d.questions || []
913
+ const total = (d.paging && d.paging.total) != null ? d.paging.total : (d.total != null ? d.total : rows.length)
914
+ const ts = rows.map((x) => Date.parse(x.date_created || '')).filter(Boolean)
915
+ return { total, oldestHours: ts.length ? Math.floor((Date.now() - Math.min(...ts)) / 3600000) : null }
916
+ }
917
+
918
+ /** 发货时效:只抽查**自发货**订单(Full 单由平台揽收,卖家侧无发货 SLA)⇒ 请求量降一个数量级。 */
919
+ async function collectSla(token, { orders, itemById, windowDays, cap }) {
920
+ const since = Date.now() - windowDays * 86400000
921
+ let unknown = 0
922
+ const rows = (orders || []).filter((o) => {
923
+ const t = Date.parse(o.date_created || '')
924
+ if (!t || t < since) return false
925
+ if (o.status === 'cancelled' || o.status === 'payment_required') return false
926
+ const id = String((o.order_items && o.order_items[0] && o.order_items[0].item && o.order_items[0].item.id) || '')
927
+ const it = itemById[id]
928
+ const lt = it && it.shipping && it.shipping.logistic_type
929
+ if (!lt) { unknown++; return false } // 类型未知(下架/未在售)⇒ 不猜、不白查
930
+ return lt !== 'fulfillment' // 非 Full ⇒ 卖家自发货(才有发货 SLA)
931
+ })
932
+ const candidates = rows.length
933
+ const picked = rows.slice(0, cap)
934
+ const res = await mapLimit(picked, 3, async (o) => {
935
+ const sid = (o.shipping && o.shipping.id) || null
936
+ if (!sid) return null
937
+ try { return await apiGet(`/shipments/${sid}`, token) } catch { return null }
938
+ })
939
+ let pending = 0
940
+ let oldestMs = null
941
+ for (const sh of res) {
942
+ if (!sh || !sh.status) continue
943
+ if (SHIP_PENDING_STATUS.has(String(sh.status))) {
944
+ pending++
945
+ const t = Date.parse(sh.date_created || '')
946
+ if (t && (oldestMs === null || t < oldestMs)) oldestMs = t
947
+ }
948
+ }
949
+ return { candidates, cap, checked: picked.length, pending, unknown,
950
+ oldestHours: oldestMs ? Math.floor((Date.now() - oldestMs) / 3600000) : null }
951
+ }
952
+
953
+
954
+ /** 逐商品访客(1 请求/商品;null=取数失败,**不等于 0 访客**)。 */
955
+ async function collectVisits(token, ids, gapMs = 100) {
956
+ const out = {}
957
+ await mapLimit(ids, 3, async (id) => {
958
+ try { out[id] = Number((await apiGet(`/items/${id}/visits/time_window?last=7&unit=day`, token)).total_visits || 0) }
959
+ catch { out[id] = null }
960
+ }, gapMs)
961
+ return out
962
+ }
963
+
964
+ /** 已暂停/已下架商品数(僵尸清单的对照项;1 请求)。 */
965
+ async function collectPausedCount(token, userId) {
966
+ try {
967
+ const d = await apiGet(`/users/${userId}/items/search?status=paused&limit=1`, token)
968
+ return (d.paging && d.paging.total) != null ? d.paging.total : null
969
+ } catch { return null }
970
+ }
971
+
972
+ /** 价格竞争力(price_to_win v2):只对 Top N 商品查(1 请求/商品)。 */
973
+ async function collectPriceToWin(token, ids, gapMs = 100) {
974
+ const out = {}
975
+ await mapLimit(ids, 3, async (id) => {
976
+ try {
977
+ const p = await apiGet(`/items/${id}/price_to_win?version=v2`, token)
978
+ out[id] = { status: p.status || '', current: p.current_price ?? null, target: p.price_to_win ?? null,
979
+ share: p.visit_share ?? null, boosts: (p.boosts || []).length }
980
+ } catch { out[id] = null }
981
+ }, gapMs)
982
+ return out
983
+ }
984
+
985
+ /** 按"已启用分区的 needs 并集"取数;返回 { reputation, claims, questions, sla } 中真正需要的项。 */
986
+ async function collectExtras(env, store, token, need, ctx) {
987
+ const cfg = larkConfig()
988
+ const want = (n) => need.has(n)
989
+ const out = { __errors: {} }
990
+ const run = (name, p) => p.then((v) => { out[name] = v })
991
+ // 失败**必须可见**:限流(429)/权限(403) 要能在卡片上看到原因,而不是整块静默消失
992
+ .catch((e) => { const m = String((e && e.message) || e); out.__errors[name] = /429/.test(m) ? '限流(429)' : /403/.test(m) ? '无权限(403)' : m.slice(0, 60) })
993
+ const jobs = []
994
+ if (want('reputation')) jobs.push(run('reputation', collectReputation(token)))
995
+ if (want('claims')) jobs.push(run('claims', collectClaims(token)))
996
+ if (want('questions')) jobs.push(run('questions', collectQuestions(token, store.user_id)))
997
+ if (want('visits') || want('visitsAll') || want('priceToWin')) {
998
+ const topN = Number(pick(cfg, env, 'mlTrafficTopN', 'ML_TRAFFIC_TOP_N', 20))
999
+ const ranked = [...(ctx.skus || [])].sort((a, b) => b.units7 - a.units7).map((x) => x.id)
1000
+ const activeIds = Object.keys(ctx.itemById || {})
1001
+ const visitIds = want('visitsAll') ? activeIds : ranked.slice(0, topN)
1002
+ if (want('visits') || want('visitsAll')) {
1003
+ jobs.push(run('visits', collectVisits(token, visitIds)))
1004
+ if (want('visitsAll')) jobs.push(run('paused', collectPausedCount(token, store.user_id)))
1005
+ }
1006
+ if (want('priceToWin')) jobs.push(run('price', collectPriceToWin(token, ranked.slice(0, topN))))
1007
+ }
1008
+ if (want('shipments')) {
1009
+ const windowDays = Number(pick(cfg, env, 'mlSlaWindowDays', 'ML_SLA_WINDOW_DAYS', 2))
1010
+ const cap = Number(pick(cfg, env, 'mlSlaMaxChecks', 'ML_SLA_MAX_CHECKS', 300))
1011
+ jobs.push(run('sla', collectSla(token, { ...ctx, windowDays, cap })))
1012
+ }
1013
+ await Promise.all(jobs)
1014
+ return out
1015
+ }
1016
+
1017
+ async function collectStore(env, store, now, reportDate, opts = {}) {
1018
+ // 窗口计算(站点本地自然日 + 近 7 日)与抓取时刻:共用实现(对账 CLI 用同一份口径)
1019
+ const { site, tz, weekStart, reportStart, todayStart, prevStart } =
1020
+ reportWindows(store.site, reportDate, now, { salesWindowDays: env.SALES_WINDOW_DAYS })
1021
+ const token = await refreshStoreToken(env, store)
1022
+ const needPrev = (opts.need || new Set()).has('ordersPrev')
1023
+ const [orders, itemsRes, prevOrders] = await Promise.all([
1024
+ fetchOrders(token, store.user_id, new Date(weekStart).toISOString(), new Date(todayStart).toISOString()),
1025
+ fetchItems(token, store.user_id),
1026
+ needPrev ? fetchOrders(token, store.user_id, new Date(prevStart).toISOString(), new Date(weekStart).toISOString()) : Promise.resolve(null),
1027
+ ])
1028
+ const itemById = itemsRes.map // fetchItems 现在返回 {map,total,failed}
1029
+ const windows = { weekStartMs: weekStart, reportStartMs: reportStart, todayStartMs: todayStart }
1030
+ const { summary, skus, daily, buyers } = aggregateOrders(orders, itemById, windows, site)
1031
+ let prevSkus = null
1032
+ if (prevOrders) {
1033
+ const prevWindows = { weekStartMs: prevStart, reportStartMs: prevStart, todayStartMs: weekStart }
1034
+ prevSkus = aggregateOrders(prevOrders, itemById, prevWindows, site).skus
1035
+ }
1036
+ const cfg = larkConfig()
1037
+ const topN = Number(pick(cfg, env, 'mlDailyTopN', 'TOP_N', 5))
1038
+ const lead = Number(pick(cfg, env, 'mlRestockLeadDays', 'RESTOCK_LEAD_DAYS', 15))
1039
+ const buffer = Number(pick(cfg, env, 'mlRestockBufferDays', 'RESTOCK_BUFFER_DAYS', 7))
1040
+ const { alerts, top, threshold } = computeAlerts(skus, lead, buffer, topN)
1041
+ const dateStr = localDateStr(tz, reportDate)
1042
+ const prev = loadSnapshot(dateStr, store)
1043
+ const need = opts.need || new Set(['orders', 'items'])
1044
+ const data = await collectExtras(env, store, token, need, { orders, itemById, skus })
1045
+ const captured = capturedAtInfo(site, now)
1046
+ return {
1047
+ date: dateStr,
1048
+ site,
1049
+ name: store.name,
1050
+ // ── 2026-10-12 新增(只新增,⛔ 不改任何既有字段名/形状)──────────────
1051
+ // 抓取时刻(站点本地语义)+ 窗口边界:让"跟平台对不上"从争论变成可核对的事实。
1052
+ // 老快照没有这三个字段 ⇒ 渲染优雅降级(卡片不追加那两行,见 captureNoteLines)。
1053
+ capturedAt: captured.capturedAt, // ISO8601 **带站点偏移**
1054
+ capturedAtLocal: captured.capturedAtLocal, // 站点本地 MM-DD HH:mm
1055
+ window: windowInfo(site, reportStart, todayStart), // {tz, from, to, fromLocal, toLocal}
1056
+ // 抓取时用的 API 基址:只读对账工具据此重拉(引擎侧不硬编码平台域名,见 core 文件头)
1057
+ apiBase: apiBase(),
1058
+ summary,
1059
+ top,
1060
+ alerts,
1061
+ threshold,
1062
+ prev,
1063
+ daily,
1064
+ buyers,
1065
+ prevSkus,
1066
+ skus7: skus,
1067
+ itemStats: { total: itemsRes.total, failed: itemsRes.failed, got: Object.keys(itemById).length },
1068
+ itemById: Object.fromEntries(Object.entries(itemById).map(([id, it]) => {
1069
+ const agg = skus.find((x) => x.id === id)
1070
+ return [id, { sku: (agg && agg.sku) || it.sku || '', status: it.status || '', title: it.title || '' }]
1071
+ })),
1072
+ itemLogistics: Object.fromEntries(Object.entries(itemById).map(([id, it]) => [id, (it && it.shipping && it.shipping.logistic_type) || ''])),
1073
+ data,
1074
+ raw: { skuCount: skus.length, orderCount: orders.length },
1075
+ }
1076
+ }
1077
+
1078
+ // ── mock 模式(开发自测,不联网)───────────────────────────
1079
+ export function collectMock(fixture) {
1080
+ return fixture.stores.map((s, i) => {
1081
+ const env = fixture.env || {}
1082
+ const cfg = fixture.cfg || {}
1083
+ const topN = Number(pick(cfg, env, 'mlDailyTopN', 'TOP_N', 5))
1084
+ const lead = Number(pick(cfg, env, 'mlRestockLeadDays', 'RESTOCK_LEAD_DAYS', 15))
1085
+ const buffer = Number(pick(cfg, env, 'mlRestockBufferDays', 'RESTOCK_BUFFER_DAYS', 7))
1086
+ const { alerts, top, threshold } = computeAlerts(
1087
+ s.skus.map((k) => ({ ...k })),
1088
+ lead, buffer, topN,
1089
+ )
1090
+ return {
1091
+ date: fixture.reportDate,
1092
+ site: fallbackSite(s.site),
1093
+ name: s.name,
1094
+ // 夹具可自带抓取时刻/窗口(预览新增两行);不给 ⇒ 渲染优雅降级(与老快照同路)
1095
+ capturedAt: s.capturedAt || null,
1096
+ capturedAtLocal: s.capturedAtLocal || null,
1097
+ window: s.window || null,
1098
+ summary: s.summary,
1099
+ top,
1100
+ alerts,
1101
+ threshold,
1102
+ prev: s.prev || null,
1103
+ raw: { skuCount: s.skus.length, orderCount: 0 },
1104
+ }
1105
+ })
1106
+ }
1107
+
1108
+ // ── 主流程 ─────────────────────────────────────────────────
1109
+ async function main() {
1110
+ // 进程 env 覆盖 env 文件(文件仍是部署基线;命令行临时覆盖用 process.env)
1111
+ // 2026-09-27 修:此前只读文件 ⇒ `ML_WEEKLY_DAY=0 node daily-report.mjs` 这类临时覆盖**静默失效**,
1112
+ // 排障时会被误判成"功能没生效"。优先级:config.json(控制台)→ process.env → 文件 → 默认。
1113
+ const env = { ...loadEnvFile(ENV_PATH), ...process.env }
1114
+ const cfg = larkConfig()
1115
+ const enabled = resolveSections(cfg, env, { sections: ARG_SECTIONS, groups: ARG_GROUPS })
1116
+ const cadence = splitByCadence(enabled, new Date(), cfg, env)
1117
+ // 周更分区今天不算 ⇒ 不进 need ⇒ **一个请求都不发**(用上次缓存渲染)
1118
+ const need = neededData(cadence.active)
1119
+ if (FLAG_LIST_SECTIONS) {
1120
+ const cat = sectionCatalog(enabled)
1121
+ const reqs = cat.filter((x) => x.enabled).reduce((a, x) => a + (typeof x.requests === 'number' ? x.requests : 0), 0)
1122
+ console.log(JSON.stringify({ sections: cat, enabled: [...enabled], needs: [...need],
1123
+ weeklyDay: cadence.weeklyDay, dueToday: [...cadence.active], cachedToday: [...cadence.cached], fixedRequests: reqs }, null, 1))
1124
+ return 0
1125
+ }
1126
+ console.error(`[daily-report] 启用分区 ${enabled.size}/${SECTIONS.length}:${[...enabled].join(', ')}`
1127
+ + (cadence.cached.size ? `|周更(用缓存,不取数):${[...cadence.cached].join(', ')}` : '')
1128
+ + `|数据需求:${[...need].join(', ')}`)
1129
+ const now = new Date()
1130
+ const reportTime = pick(cfg, env, 'mlDailyTime', 'REPORT_TIME', '08:00')
1131
+
1132
+ if (MOCK_FILE) {
1133
+ const fixture = JSON.parse(readFileSync(MOCK_FILE, 'utf8'))
1134
+ const reports = collectMock(fixture)
1135
+ const fxEnabled = resolveSections(fixture.cfg || {}, fixture.env || {}, { sections: ARG_SECTIONS, groups: ARG_GROUPS })
1136
+ const sections = reports.map((r) => renderStoreSection({ name: r.name, site: r.site }, r, Number(pick(fixture.cfg || {}, fixture.env || {}, 'mlDailyTopN', 'TOP_N', 5)), r.threshold, { enabled: fxEnabled }))
1137
+ const md = `📊 **美客多日报** · ${fixture.reportDate}(昨日数据)\n\n` + sections.join('\n\n---\n\n')
1138
+ console.log(md)
1139
+ return 0
1140
+ }
1141
+
1142
+ let stores = null
1143
+ try {
1144
+ stores = JSON.parse(readFileSync(STORES_PATH, 'utf8'))
1145
+ } catch {
1146
+ console.error(`✗ 未找到店铺授权文件 ${STORES_PATH} —— 请先运行 node ml-oauth-setup.mjs 逐店授权`)
1147
+ return 1
1148
+ }
1149
+ stores = stores.stores || []
1150
+ STORE_LIST = stores // 供 token 轮换后原子写回(persistStores)
1151
+ if (FLAG_LIST_STORES) {
1152
+ console.log(JSON.stringify({ stores: stores.map((x) => ({ name: x.name, site: x.site, user_id: String(x.user_id) })) }, null, 1))
1153
+ return 0
1154
+ }
1155
+ if (ARG_STORE.length) { // 角色对话/手工排障:只跑指定店(子串匹配,1店 → 墨西哥-1店-主账号)
1156
+ const hit = matchStores(stores, ARG_STORE) // 匹配判据与对账 CLI 共用同一份(matchStores)
1157
+ if (!hit.length) {
1158
+ console.error(`✗ --store 没匹配到店铺:${ARG_STORE.join(', ')}|可用:${stores.map((x) => x.name).join(' / ')}`)
1159
+ return 1
1160
+ }
1161
+ console.error(`[daily-report] --store 过滤:${stores.length} → ${hit.length} 家(${hit.map((x) => x.name).join('、')})`)
1162
+ stores = hit
1163
+ }
1164
+ if (!stores.length) {
1165
+ console.error('✗ ml-api-stores.json 无已授权店铺 —— 请先运行 node ml-oauth-setup.mjs 逐店授权')
1166
+ return 1
1167
+ }
1168
+
1169
+ // 时间门控(每店按自己站点时区判断;--force 跳过)
1170
+ // 主触发 = 站点本地推送时刻整点;错过/失败后由 gateCatchUp 在 +5h 窗口逐小时补试(幂等防重)
1171
+ const dueStores = stores.filter((s) => {
1172
+ if (FLAG_FORCE) return true
1173
+ const tz = SITE_TZ[fallbackSite(s.site)]
1174
+ if (gateDue(tz, now, reportTime)) return true
1175
+ const reportDate = ARG_DATE ? new Date(`${ARG_DATE}T12:00:00Z`) : new Date(now.getTime() - 86400000)
1176
+ const dateStr = localDateStr(tz, reportDate)
1177
+ return gateCatchUp(tz, now, reportTime, alreadyPushed(dateStr, s))
1178
+ })
1179
+ if (!dueStores.length) {
1180
+ console.log(`[daily-report] ${now.toISOString()} 未到推送时段(各店本地 ${reportTime} 触发),跳过`)
1181
+ return 0
1182
+ }
1183
+
1184
+ const reports = []
1185
+ for (const [si, store] of dueStores.entries()) {
1186
+ if (si > 0) await sleep(Number(process.env.ML_STORE_GAP_MS || 1500)) // 店与店之间留口气
1187
+ const reportDate = ARG_DATE ? new Date(`${ARG_DATE}T12:00:00Z`) : new Date(now.getTime() - 86400000)
1188
+ try {
1189
+ console.error(`[daily-report] 采集 ${store.name}(${store.site})...`) // stderr:--json 模式下 stdout 保持纯 JSON
1190
+ const r = await collectStore(env, store, now, reportDate, { enabled, need })
1191
+ saveSnapshot(r, store)
1192
+ reports.push({ store, r })
1193
+ } catch (err) {
1194
+ console.error(`✗ 采集 ${store.name}(${store.site})失败:${err.message}`)
1195
+ reports.push({
1196
+ store,
1197
+ r: { date: '—', site: store.site, name: store.name, summary: null, top: [], alerts: [], prev: null, raw: {}, error: err.message },
1198
+ })
1199
+ }
1200
+ }
1201
+
1202
+ const okReports = reports.filter((x) => x.r.summary)
1203
+ if (!okReports.length) {
1204
+ console.error('✗ 全部店铺采集失败,不推送')
1205
+ return 1
1206
+ }
1207
+
1208
+ const topN = Number(pick(cfg, env, 'mlDailyTopN', 'TOP_N', 5))
1209
+ const sectionCache = loadSectionCache()
1210
+ const blocksList = okReports.map(({ store, r }) => {
1211
+ const key = storeKey(store)
1212
+ r.weeklyCache = sectionCache[key] || {}
1213
+ const onWeeklyRendered = (id, lines) => {
1214
+ sectionCache[key] = sectionCache[key] || {}
1215
+ sectionCache[key][id] = { at: new Date().toISOString(), lines }
1216
+ }
1217
+ return { store, r, blocks: renderStoreBlocks(store, r, topN, r.threshold, enabled, { cached: cadence.cached, onWeeklyRendered }) }
1218
+ })
1219
+ if (cadence.active.size) saveSectionCache(sectionCache) // 只有真算过周更分区才写缓存
1220
+ const dateLabel = okReports[0].r.date
1221
+ // 按需问答(给了 --sections / --store)时,标题要能一眼看出"这是什么范围的数据",
1222
+ // 而不是让人以为又是一份完整日报(2026-09-27 第 4 批)。
1223
+ const scoped = ARG_SECTIONS.length > 0 || ARG_STORE.length > 0
1224
+ const groupLabels = [...new Set(SECTIONS.filter((x) => enabled.has(x.id)).map((x) => GROUP_LABEL[x.group] || x.group))]
1225
+ const title = scoped
1226
+ ? `📊 美客多 · ${groupLabels.join('/')} · ${dateLabel}`
1227
+ : `📊 美客多日报 · ${dateLabel}(昨日数据)`
1228
+ const failed = reports.filter((x) => !x.r.summary)
1229
+
1230
+ if (FLAG_DRY) {
1231
+ const preview = blocksList.map(({ blocks }) => {
1232
+ const parts = [...blocks.head, '', ...blocks.risk]
1233
+ for (const g of blocks.groups) parts.push('', `[折叠]**${GROUP_LABEL[g.group] || g.group}**`, ...g.lines)
1234
+ return parts.join('\n')
1235
+ })
1236
+ console.log(`\n${title}\n\n${preview.join('\n\n---\n\n')}\n`)
1237
+ console.log(`[dry-run] 共 ${okReports.length} 家店铺(${failed.length} 家失败)|启用分区 ${enabled.size}/${SECTIONS.length},未推送`)
1238
+ return 0
1239
+ }
1240
+
1241
+ // KB-063:--json 模式——只生成日报卡片 JSON 输出 stdout,不推送(BOT 回复卡片用;
1242
+ // 配合 --force 跳过时段门控/幂等;agent 执行后以 CARD:<json> 回复,脚本不再推送)
1243
+ if (FLAG_JSON) {
1244
+ // v2 折叠卡:每店一个块(head + 风险常显 + 其余组折叠)——工作台/角色对话共用同一形态
1245
+ const elements = []
1246
+ for (const { blocks } of blocksList) {
1247
+ elements.push({ tag: 'markdown', content: blocks.head.join('\n') })
1248
+ if (blocks.risk.length) elements.push({ tag: 'markdown', content: blocks.risk.join('\n') })
1249
+ for (const g of blocks.groups) elements.push(collapsiblePanel(g.group, g.lines))
1250
+ }
1251
+ if (failed.length) elements.push({ tag: 'markdown', content: `⚠️ **数据缺失**:${failed.map((f) => `${f.store.name}(${f.r.error || '未知错误'})`).join(';')}` })
1252
+ const card = { schema: '2.0', config: { wide_screen_mode: true },
1253
+ header: { template: riskLevel(blocksList[0].blocks), title: { tag: 'plain_text', content: title } },
1254
+ body: { elements } }
1255
+ console.log(JSON.stringify({ msg_type: 'interactive', card }))
1256
+ return 0
1257
+ }
1258
+
1259
+ // 幂等:非 force 时当天已推送过则跳过
1260
+ const due = okReports.filter(({ store, r }) => FLAG_FORCE || !alreadyPushed(r.date, store))
1261
+ if (!due.length) {
1262
+ console.log('[daily-report] 今日已推送过(幂等标记),跳过')
1263
+ return 0
1264
+ }
1265
+
1266
+ try {
1267
+ const cards = blocksList.map(({ store, blocks }) =>
1268
+ cardMessageV2(`📊 美客多日报 · ${dateLabel}|${store.name}`, blocks, { template: riskLevel(blocks) }))
1269
+ let overview = null
1270
+ const wantOverview = pick(cfg, env, 'mlOverviewCard', 'ML_OVERVIEW_CARD', 'true') !== 'false'
1271
+ if (wantOverview && blocksList.length > 1) {
1272
+ const items = blocksList.map(({ store, r, blocks }) => {
1273
+ const riskLine = (blocks.risk.find((l) => l.includes('待办')) || '').replace(/\*\*/g, '').slice(0, 60)
1274
+ return { name: store.name, orders: r.summary.orders, gmv: r.summary.gmv,
1275
+ currency: SITE_CURRENCY[store.site] || 'USD', level: riskLevel(blocks), riskLine }
1276
+ })
1277
+ overview = cardMessageV2(`📊 美客多日报 · ${dateLabel}|跨店总览`, buildOverviewBlocks(dateLabel, items),
1278
+ { template: items.some((x) => x.level === 'red') ? 'red' : items.some((x) => x.level === 'yellow') ? 'yellow' : 'green' })
1279
+ }
1280
+ if (failed.length) {
1281
+ cards.push(cardMessageV2(`⚠️ 美客多日报 · ${dateLabel}|部分店铺未取到`, {
1282
+ head: [`**以下店铺本轮采集失败**(其余店铺正常)`, ''],
1283
+ risk: failed.map((f) => `🔴 **${f.store.name}**:${f.r.error || '未知错误'}`),
1284
+ groups: [],
1285
+ }, { template: 'yellow' }))
1286
+ }
1287
+ const channel = await pushCards(env, cards, overview)
1288
+ // KB-074:--no-mark(手动 --force 测试/验证)不写幂等标记——否则白天测试会
1289
+ // 污染当天 22:00 定时推送(幂等检查「今日已推送过」跳过,客户收不到日报)
1290
+ if (!FLAG_NO_MARK) {
1291
+ for (const { store, r } of due) markPushed(r.date, store)
1292
+ }
1293
+ console.log(`[daily-report] ✅ 日报已推送(${channel}):${title}(${due.length} 家店铺)${FLAG_NO_MARK ? '(--no-mark 未写幂等标记)' : ''}`)
1294
+ return 0
1295
+ } catch (err) {
1296
+ console.error(`✗ 推送失败:${err.message}`)
1297
+ // KB-090:推送失败 → 飞书告警(webhook 优先/lark-bot 兜底/alert.log 落盘)
1298
+ await notifyPushFailure(env, err).catch(() => {})
1299
+ return 1
1300
+ }
1301
+ }
1302
+
1303
+ // 仅直接执行时跑主流程(import 单测时不触发)
1304
+ const isMain = isDirectRun(import.meta.url)
1305
+ if (isMain) {
1306
+ main().then((code) => process.exit(code)).catch((err) => {
1307
+ console.error(`✗ 异常:${err.message}`)
1308
+ process.exit(1)
1309
+ })
1310
+ }