@whyshu/argent-engine 0.0.0-stage → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/MANIFEST.json +307 -0
- package/engine/VERSION +1 -0
- package/engine/agents/catalog.json +454 -0
- package/engine/configs/_aliases.proposed.json +651 -0
- package/engine/configs/_migration-plan.proposed.json +899 -0
- package/engine/configs/account.json +272 -0
- package/engine/configs/advanced.json +205 -0
- package/engine/configs/host.json +149 -0
- package/engine/configs/hr.json +27 -0
- package/engine/configs/notify.json +264 -0
- package/engine/core/cli/argent.mjs +277 -0
- package/engine/core/contracts/argent.contracts.json +1065 -0
- package/engine/core/contracts/verbs.schema.json +2539 -0
- package/engine/core/lib/argent-datadir.mjs +223 -0
- package/engine/core/lib/audit.mjs +187 -0
- package/engine/core/lib/claim.mjs +258 -0
- package/engine/core/lib/config-catalog.mjs +1172 -0
- package/engine/core/lib/doctor-customer.mjs +577 -0
- package/engine/core/lib/flow-list.mjs +138 -0
- package/engine/core/lib/flow-registry.mjs +48 -0
- package/engine/core/lib/init.mjs +247 -0
- package/engine/core/lib/ledger.mjs +124 -0
- package/engine/core/lib/license-activate.mjs +813 -0
- package/engine/core/lib/license-device.mjs +314 -0
- package/engine/core/lib/license-file.mjs +349 -0
- package/engine/core/lib/license-guard.mjs +123 -0
- package/engine/core/lib/license-ops.mjs +1645 -0
- package/engine/core/lib/license-verdict.mjs +133 -0
- package/engine/core/lib/license.mjs +577 -0
- package/engine/core/lib/notify/dedup.mjs +101 -0
- package/engine/core/lib/notify/feishu.mjs +38 -0
- package/engine/core/lib/notify/index.mjs +95 -0
- package/engine/core/lib/notify/verb.mjs +327 -0
- package/engine/core/lib/notify/webhook.mjs +135 -0
- package/engine/core/lib/notify/wecom.mjs +36 -0
- package/engine/core/lib/python.mjs +145 -0
- package/engine/core/lib/sop.mjs +116 -0
- package/engine/core/lib/versions.mjs +205 -0
- package/engine/core/mcp/client.mjs +67 -0
- package/engine/core/mcp/http.mjs +138 -0
- package/engine/core/mcp/server.mjs +702 -0
- package/engine/scripts/_engine_rules_probe.py +15 -0
- package/engine/scripts/analyze-full-day.py +568 -0
- package/engine/scripts/archive/feishu-bridge.js +246 -0
- package/engine/scripts/argent-agents.mjs +527 -0
- package/engine/scripts/argent-capabilities.mjs +776 -0
- package/engine/scripts/argent-catalog.mjs +186 -0
- package/engine/scripts/argent-datadir.py +62 -0
- package/engine/scripts/argent-doctor.mjs +624 -0
- package/engine/scripts/argent-env-write.mjs +302 -0
- package/engine/scripts/argent-schedule.mjs +620 -0
- package/engine/scripts/argent-setup-check.mjs +369 -0
- package/engine/scripts/argent-upgrade.mjs +1046 -0
- package/engine/scripts/base-bootstrap.sh +575 -0
- package/engine/scripts/billing-mode-daily.sh +12 -0
- package/engine/scripts/build-engine-package.mjs +123 -0
- package/engine/scripts/capability-packs.lock.json +484 -0
- package/engine/scripts/capability_pack.py +1242 -0
- package/engine/scripts/check-1.0-criteria.mjs +355 -0
- package/engine/scripts/check-agent-catalog.mjs +141 -0
- package/engine/scripts/check-billing-mode.sh +377 -0
- package/engine/scripts/check-child-sessions.mjs +83 -0
- package/engine/scripts/check-config-catalog.mjs +677 -0
- package/engine/scripts/check-docs-drift.mjs +522 -0
- package/engine/scripts/check-dsh-web-expose.sh +98 -0
- package/engine/scripts/check-judgment-drift.mjs +403 -0
- package/engine/scripts/check-license.mjs +199 -0
- package/engine/scripts/check-model-auth.py +443 -0
- package/engine/scripts/check-pack-closure.mjs +661 -0
- package/engine/scripts/check-portable-symbols.mjs +116 -0
- package/engine/scripts/check-preset-landing.mjs +124 -0
- package/engine/scripts/check-production-home.mjs +602 -0
- package/engine/scripts/check-role-mapping.mjs +461 -0
- package/engine/scripts/check-sample-redaction.mjs +320 -0
- package/engine/scripts/check-skill-classification.mjs +188 -0
- package/engine/scripts/check-sop-consistency.mjs +431 -0
- package/engine/scripts/check-test-env-safety.mjs +627 -0
- package/engine/scripts/check-version-consistency.mjs +452 -0
- package/engine/scripts/ci-run.sh +558 -0
- package/engine/scripts/config-explain.mjs +278 -0
- package/engine/scripts/configure-provider.rb +29 -0
- package/engine/scripts/customer-config-migrate.mjs +179 -0
- package/engine/scripts/customer-preflight.py +892 -0
- package/engine/scripts/customer-readiness.sh +134 -0
- package/engine/scripts/daily-report-inbox.py +242 -0
- package/engine/scripts/daily-report-reconcile.mjs +312 -0
- package/engine/scripts/daily-report.mjs +1310 -0
- package/engine/scripts/daily-report.sh +46 -0
- package/engine/scripts/data/lip-product-dict.json +306 -0
- package/engine/scripts/deliver-customer.mjs +932 -0
- package/engine/scripts/delivery-faces.txt +31 -0
- package/engine/scripts/desktop-trial.sh +146 -0
- package/engine/scripts/dev-window.sh +260 -0
- package/engine/scripts/drill-clean-machine.sh +315 -0
- package/engine/scripts/dsh-api-client.mjs +215 -0
- package/engine/scripts/dsh-backup-scope.sh +83 -0
- package/engine/scripts/dsh-offline-pack.mjs +77 -0
- package/engine/scripts/dsh-overlay-pin.sh +87 -0
- package/engine/scripts/dsh-plane.mjs +153 -0
- package/engine/scripts/dsh-portforward.py +223 -0
- package/engine/scripts/dsh-preflight.mjs +179 -0
- package/engine/scripts/dsh-release-watch-daily.sh +19 -0
- package/engine/scripts/dsh-release-watch.mjs +458 -0
- package/engine/scripts/dsh-remote-check.sh +99 -0
- package/engine/scripts/dsh-restart.sh +179 -0
- package/engine/scripts/dsh-restore.sh +111 -0
- package/engine/scripts/dsh-scope-rewire.mjs +803 -0
- package/engine/scripts/dsh-snapshot.mjs +116 -0
- package/engine/scripts/dsh-upgrade-readiness.mjs +165 -0
- package/engine/scripts/dsh-upgrade.mjs +612 -0
- package/engine/scripts/dsh-url.sh +86 -0
- package/engine/scripts/dsh-web-launch.mjs +155 -0
- package/engine/scripts/ensure-store-windows.py +509 -0
- package/engine/scripts/entitlements.py +458 -0
- package/engine/scripts/env_probe.py +411 -0
- package/engine/scripts/feishu-card.mjs +252 -0
- package/engine/scripts/fix-skill-frontmatter.py +209 -0
- package/engine/scripts/flow-auto-retry.py +454 -0
- package/engine/scripts/flow-requirements.json +720 -0
- package/engine/scripts/flow-scaffold.py +275 -0
- package/engine/scripts/flow-timers-plist.sh +86 -0
- package/engine/scripts/flow-timers.sh +182 -0
- package/engine/scripts/flow-trigger-wake.mjs +284 -0
- package/engine/scripts/flow-trigger.sh +571 -0
- package/engine/scripts/flow_billing.py +354 -0
- package/engine/scripts/flow_capability_stats.py +314 -0
- package/engine/scripts/flow_case.py +194 -0
- package/engine/scripts/flow_claim.py +497 -0
- package/engine/scripts/flow_config.py +1024 -0
- package/engine/scripts/flow_failure.py +588 -0
- package/engine/scripts/flow_learn.py +105 -0
- package/engine/scripts/flow_ledger.py +235 -0
- package/engine/scripts/flow_policy.py +238 -0
- package/engine/scripts/flow_preflight.py +339 -0
- package/engine/scripts/flow_registry.py +341 -0
- package/engine/scripts/flow_retry.py +304 -0
- package/engine/scripts/flow_return_to_ops.py +191 -0
- package/engine/scripts/flow_source.py +315 -0
- package/engine/scripts/flow_sources/__init__.py +53 -0
- package/engine/scripts/flow_sources/cases_json.py +106 -0
- package/engine/scripts/flow_sources/common.py +428 -0
- package/engine/scripts/flow_sources/feishu_base.py +273 -0
- package/engine/scripts/flow_stale.py +185 -0
- package/engine/scripts/flow_status_reconcile.py +344 -0
- package/engine/scripts/flow_triage.py +849 -0
- package/engine/scripts/flow_write_verify.py +496 -0
- package/engine/scripts/fulfillment.js +586 -0
- package/engine/scripts/gen-agent-bundles.mjs +276 -0
- package/engine/scripts/gen-preset-rows.mjs +259 -0
- package/engine/scripts/init-ml.sh +2275 -0
- package/engine/scripts/install-internal-role.sh +190 -0
- package/engine/scripts/install-license.mjs +641 -0
- package/engine/scripts/install-one-shot.mjs +665 -0
- package/engine/scripts/integration_contract.py +220 -0
- package/engine/scripts/integrations/wenyao/adapter.py +98 -0
- package/engine/scripts/integrations/xzh/adapter.py +75 -0
- package/engine/scripts/invoice-mapping.example.json +40 -0
- package/engine/scripts/invoice-table.sh +194 -0
- package/engine/scripts/invoice_carrier_notice.py +279 -0
- package/engine/scripts/invoice_fail_closeout.py +297 -0
- package/engine/scripts/invoice_lip_runner.py +1508 -0
- package/engine/scripts/invoice_lip_runner.py.bak-20260922-1710-freready +1090 -0
- package/engine/scripts/invoice_ready_audit.py +191 -0
- package/engine/scripts/invoice_runner.py +675 -0
- package/engine/scripts/invoice_stageb.py +516 -0
- package/engine/scripts/launchd/com.whyshu.argent-bridge.plist.example +42 -0
- package/engine/scripts/launchd/com.whyshu.fulfillment-poll.plist.example +29 -0
- package/engine/scripts/lease.py +377 -0
- package/engine/scripts/lib/agent-install.mjs +649 -0
- package/engine/scripts/lib/argent-datadir.mjs +16 -0
- package/engine/scripts/lib/argent_datadir.py +262 -0
- package/engine/scripts/lib/argent_filelock.py +507 -0
- package/engine/scripts/lib/bootcheck-wiring.mjs +510 -0
- package/engine/scripts/lib/cdp_ports.py +197 -0
- package/engine/scripts/lib/customer-env.mjs +102 -0
- package/engine/scripts/lib/daily-report-core.mjs +517 -0
- package/engine/scripts/lib/dep-declaration.mjs +81 -0
- package/engine/scripts/lib/deps-migrate.mjs +310 -0
- package/engine/scripts/lib/dsh-host-resolve.mjs +109 -0
- package/engine/scripts/lib/dsh-scope.mjs +500 -0
- package/engine/scripts/lib/dsh-snapshot-path.mjs +35 -0
- package/engine/scripts/lib/dsh-versions.mjs +96 -0
- package/engine/scripts/lib/dsh-web-probe.mjs +214 -0
- package/engine/scripts/lib/is-direct-run.mjs +61 -0
- package/engine/scripts/lib/patch-compose.mjs +241 -0
- package/engine/scripts/lib/patch-target.mjs +35 -0
- package/engine/scripts/lib/plugin-lifecycle.mjs +128 -0
- package/engine/scripts/lib/plugin-packages.mjs +332 -0
- package/engine/scripts/lib/preset-compat.mjs +127 -0
- package/engine/scripts/lib/preset-install.mjs +575 -0
- package/engine/scripts/lib/preset-mechanism.mjs +56 -0
- package/engine/scripts/lib/preset-sync.mjs +59 -0
- package/engine/scripts/lib/profile-retire.mjs +97 -0
- package/engine/scripts/lib/python-runtime.mjs +190 -0
- package/engine/scripts/lib/registry.mjs +72 -0
- package/engine/scripts/lib/repo-root.sh +56 -0
- package/engine/scripts/lib/schedule-mechanism.mjs +659 -0
- package/engine/scripts/lib/schedule-probe.mjs +835 -0
- package/engine/scripts/lib/scope-pointer.mjs +164 -0
- package/engine/scripts/lib/settings-panels.mjs +816 -0
- package/engine/scripts/lib/stdio_utf8.py +86 -0
- package/engine/scripts/lib/wake-auth.mjs +392 -0
- package/engine/scripts/lib/webdriver_ipc.py +264 -0
- package/engine/scripts/lib/zstd-frames.mjs +104 -0
- package/engine/scripts/license-e2e-drill.mjs +426 -0
- package/engine/scripts/license-gate.mjs +245 -0
- package/engine/scripts/lip_backlog.py +171 -0
- package/engine/scripts/lip_table.py +185 -0
- package/engine/scripts/local-usage.mjs +214 -0
- package/engine/scripts/migrate-providers.mjs +127 -0
- package/engine/scripts/migrate-skills.sh +65 -0
- package/engine/scripts/ml-oauth-setup.mjs +550 -0
- package/engine/scripts/ml-store-ids.json +26 -0
- package/engine/scripts/ml_artifact_attribution.py +333 -0
- package/engine/scripts/ml_capacity_probe.py +1158 -0
- package/engine/scripts/ml_hub_status.py +1556 -0
- package/engine/scripts/ml_schema_check.py +115 -0
- package/engine/scripts/ml_store_ids.py +139 -0
- package/engine/scripts/npm-publish-argent.mjs +499 -0
- package/engine/scripts/npm-publish-argent.sh +20 -0
- package/engine/scripts/orchestrator.py +13811 -0
- package/engine/scripts/pack-plugins.mjs +140 -0
- package/engine/scripts/patches/dsh-llm-deepseek--transport-diagnostics.patch +297 -0
- package/engine/scripts/patches/dsh-session--append-message-shape.patch +15 -0
- package/engine/scripts/patches/dsh-session--tool-result-block-017.patch +28 -0
- package/engine/scripts/patches/dsh-session-format-v0-to-v1--permission-preset-origin.patch +11 -0
- package/engine/scripts/patches/dsh-session-format-v3-to-v4--turn-outside-replace.patch +20 -0
- package/engine/scripts/patches/dsh-session-persistence-jsonl--worker-bundled-migrators.patch +39 -0
- package/engine/scripts/patches/dsh-tools--render-return-shape.patch +50 -0
- package/engine/scripts/patches/verify-bundled-migrators.mjs +79 -0
- package/engine/scripts/patches/verify-hardening.mjs +167 -0
- package/engine/scripts/patches/verify-session-migration.mjs +149 -0
- package/engine/scripts/patches/verify-transport-diagnostics.mjs +227 -0
- package/engine/scripts/plan-role-rename.mjs +125 -0
- package/engine/scripts/plugin-bootcheck.mjs +451 -0
- package/engine/scripts/plugin-composition.json +15 -0
- package/engine/scripts/poc-mcp-host.mjs +455 -0
- package/engine/scripts/poll-fulfillment.sh +293 -0
- package/engine/scripts/poll-invoice.sh +74 -0
- package/engine/scripts/profile-doctor.mjs +686 -0
- package/engine/scripts/provider-select.sh +70 -0
- package/engine/scripts/provider-switch.sh +146 -0
- package/engine/scripts/provider-sync-target.check.mjs +64 -0
- package/engine/scripts/python-runtime.sh +78 -0
- package/engine/scripts/query_runner.py +310 -0
- package/engine/scripts/release-argent.sh +574 -0
- package/engine/scripts/repair-unresolved-tools.mjs +178 -0
- package/engine/scripts/run-tests.sh +256 -0
- package/engine/scripts/self-sign-license.mjs +179 -0
- package/engine/scripts/session-health-scan.mjs +63 -0
- package/engine/scripts/session-repair-tool-result-content.mjs +181 -0
- package/engine/scripts/setup.sh +22 -0
- package/engine/scripts/skill-install.mjs +629 -0
- package/engine/scripts/sku_translate.py +148 -0
- package/engine/scripts/sync-plugins.sh +90 -0
- package/engine/scripts/sync-presets.sh +27 -0
- package/engine/scripts/sync-scripts.sh +445 -0
- package/engine/scripts/telemetry.py +873 -0
- package/engine/scripts/ui-copy-count.mjs +220 -0
- package/engine/scripts/usage-anchor.sh +136 -0
- package/engine/scripts/usage_report.py +531 -0
- package/engine/scripts/validate-preset-schema.mjs +208 -0
- package/engine/scripts/vertical-init.mjs +323 -0
- package/engine/scripts/vertical-lint.mjs +482 -0
- package/engine/scripts/wenyao-browser.sh +73 -0
- package/engine/scripts/wenyao-fixture-export.py +116 -0
- package/engine/scripts/wenyao-invoice-writeback.mjs +540 -0
- package/engine/scripts/wenyao-login.sh +34 -0
- package/engine/scripts/wenyao-template-map.json +292 -0
- package/engine/scripts/wenyao_attach.py +131 -0
- package/engine/scripts/wenyao_download_template.py +144 -0
- package/engine/scripts/wenyao_fill_template.py +568 -0
- package/engine/scripts/wenyao_login.py +145 -0
- package/engine/scripts/wenyao_portal.py +633 -0
- package/engine/scripts/wenyao_recon.py +230 -0
- package/engine/scripts/wenyao_run.py +503 -0
- package/engine/scripts/wenyao_shadow_diff.py +160 -0
- package/engine/scripts/wenyao_skill_contract.py +156 -0
- package/engine/scripts/wenyao_template_fetch.py +175 -0
- package/engine/scripts/win/dsh-web-autostart.ps1 +372 -0
- package/engine/scripts/ziniao-auth-probe.sh +268 -0
- package/engine/scripts/ziniao-keepalive.sh +216 -0
- package/engine/scripts/ziniao-post-upgrade-check.sh +120 -0
- package/engine/scripts/ziniao-windows.sh +153 -0
- package/engine/version.mjs +1 -0
- package/engine/verticals/README.md +103 -0
- package/engine/verticals/_engine-words.baseline.txt +237 -0
- package/engine/verticals/_engine-words.json +19 -0
- package/engine/verticals/mercadolibre/README.md +43 -0
- package/engine/verticals/mercadolibre/config.json +709 -0
- package/engine/verticals/mercadolibre/flows.json +706 -0
- package/engine/verticals/mercadolibre/manifest.json +538 -0
- package/engine/verticals/mercadolibre/migrate-machine-state.mjs +138 -0
- package/engine/verticals/mercadolibre/preflight.py +143 -0
- package/engine/verticals/mercadolibre/prompts/daily-report.failure.md +69 -0
- package/engine/verticals/mercadolibre/prompts/daily-report.md +47 -0
- package/engine/verticals/mercadolibre/prompts/daily-report.scheduled.md +79 -0
- package/engine/verticals/mercadolibre/prompts/full.failure.md +125 -0
- package/engine/verticals/mercadolibre/prompts/full.md +91 -0
- package/engine/verticals/mercadolibre/prompts/wenyao-invoice.failure.md +88 -0
- package/engine/verticals/mercadolibre/prompts/wenyao-invoice.md +64 -0
- package/engine/verticals/mercadolibre/prompts/xzh-invoice.failure.md +91 -0
- package/engine/verticals/mercadolibre/prompts/xzh-invoice.md +46 -0
- package/package.json +21 -4
- package/README.md +0 -3
|
@@ -0,0 +1,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
|
+
}
|