Compare commits

...

12 Commits

Author SHA1 Message Date
Neo
d269ee6401 docs(ai): app2a v1.2 system prompt + 多 APP 派生设计 v2 + 审计 + A/B 脚本
1. docs/ai/app2a_finance_area_system_prompt_20260422_v1.md (新建 · v1.2 生产版):
   - 基于 app2_finance V5.1 派生
   - 板块 C 改"业态收入结构" · 板块 E 改"业态定位与对比"
   - 新增 H7 硬约束:业态特征引用必须紧跟 payload 真实数据
   - H6 扩展区域级 6 类字段缺失降级(储值卡/分渠道现金流/现金流出/会员占比/按星期/日异常)
   - 经 3 次修正:v1"稀疏" → v1.1 纠正为业务真实 0/非 0 → v1.2 纠正为字段存在/整块缺失
   - 已同步百炼控制台 APP ID 0ae965029bc54706bcff44f511ac716b

2. docs/ai/app2_finance_multi_app_design.md (新建 · v2 定稿):
   - 6 章 + 3 附录 · Q1-Q7 全部决策 · 6 阶段 28 项 checklist
   - 72 组合数据源支持度三档梳理(必须 / 业务级全店 / 字段存在 vs 整块缺失)
   - 2 套 prompt 拼接方案 · 2 个派生百炼 APP 策略

3. docs/audit/changes/2026-04-23__app2a_finance_area_integrated.md (新建):
   - 完整审计记录 · 13 高风险文件逐项注解
   - 数据库变更 + 风险与回滚 + 验证方式 + 合规检查

4. docs/audit/audit_dashboard.md (刷新 · 135 条记录)

5. scripts/ab_test_app2a_area.py (新建):
   - 8 业态 × 3 轮 = 24 次采样评估含金量
   - 自动检测 H1/H2/H3/H7 硬约束通过率 + seq11 三色灯分布

6. scripts/ab_to_cache.py (新建):
   - 复用 A/B 结果直接写 ai_cache · 绕开百炼预算验证 UI 端到端

A/B 实测 24/24 成功 · 12 条齐整率 100% · H1/H3/H7 100% · 达生产级。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 21:56:46 +08:00
Neo
7107884138 feat(admin-web): AIPrewarm 分组展示 + 每行触发 + AppType 联合类型
1. AIPrewarm.tsx:
   - areaToAppType(area) helper · area='all' → app2_finance · 其他 → app2a_finance_area
   - handleRunOne / handleBackfillMissing 按 area 动态选 app_type
   - MissingRowWithGroup 含 __group_header 字段
   - groupedMissing 数据构造(全域 + 区域两组 · 每组前插 header 行)
   - 每列 onCell colSpan 合并单元格实现"全域 / 区域"分组标题行
   - Descriptions 加全域 8/X + 区域 64/X 双段统计

2. api/adminAI.ts:
   - 新增 AppType 联合类型(9 项,含 app2a_finance_area)
   - runApp 签名 appType: AppType(替代原 string)
   - RunAppResponse.app_type 同步为 AppType

3. AIOperations.tsx:
   - runAppType state 类型改为 AppType | undefined
   - import { AppType } type

实测:
- pnpm tsc --noEmit 全项目通过
- playwright E2E 访问 /ai/prewarm 显示 "全域 8/8 · 区域 63/64" 分段统计
  分组标题行正确合并 · 单独生成按钮按 area 路由到正确 app_type

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 21:56:17 +08:00
Neo
66be873e70 feat(miniprogram): 财务看板按 area 切 cache_type + seq 精确匹配 + UX 修复
board-finance.ts _loadAIInsights 改造:

1. cache_type 动态切换:
   area='all' → 'app2_finance'
   area != 'all' → 'app2a_finance_area'

2. seq 精确匹配(替代末两条启发式):
   - map 阶段保留 seq 字段 (Number(item.seq) || idx+1)
   - _extractSummary 优先 find(i => i.seq === 11/12)
   - 回退:找不到时用末两条启发式

3. UX bug 修复:
   原代码 cache miss 时静默 return 导致切换区域后 UI 保留上个区域陈旧数据
   修复:进入函数先 setData 清空 aiInsights / aiInsightSummary / aiInsightDetails
   / summaryLightType / summaryLightLabel

实测:微信开发者 MCP E2E 验证:
- 全域面板 12 条 + 🔴 红灯 + seq 1-12 精确
- 切 vip 显示 app2a "客单价异动 321 元 符合 VIP 高客单定位"
- 切 mahjong 显示 app2a "麻将房成交收入 46,339 元 + 🟡 黄灯"
- 业态差异化识别准确

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 21:55:58 +08:00
Neo
8638ecad2a feat(backend): 新增 app2a 区域财务洞察 APP 派生 · dispatcher 72 循环拆分
1. apps/backend/app/ai/prompts/app2a_finance_area_prompt.py (新建):
   - payload: 业态说明 + 区域占比 + 对比口径 + 核心 KPI + 优惠构成
     + 助教成本 + 区域级单位经济 + 按星期聚合 + 日粒度异常 + 行业基线
   - 5 个区域级辅助函数:_fetch_area_daily_series / _build_area_unit_economics
     / _aggregate_by_weekday_area / _detect_anomaly_days_area / _fetch_area_share
   - AREA_INDUSTRY_TRAITS 字典(7 业态 trait + peer 描述)
   - 复用 app2_finance_prompt 的 _build_coach_kpi / _build_discount_kpi 等公共函数

2. config.py: AIConfig 增加 app_id_2a_finance_area + DASHSCOPE_APP_ID_2A_FINANCE_AREA

3. schemas.py: CacheTypeEnum 增加 APP2A_FINANCE_AREA

4. dispatcher.py:
   - APP2A_AREA_OPTIONS 常量(8 业态 · area != 'all')
   - _handle_dws_completed 72 循环拆分:
     area='all' 走 app2_finance · 其他 8 业态走 app2a_finance_area
   - run_single_app 新增 elif 'app2a_finance_area' 分支(拒绝 area='all')

5. admin_ai.py: _SUPPORTED_APP_TYPES 加 'app2a_finance_area'

6. prompts/__init__.py: 导出 build_app2a_area_prompt

7. .env: 追加 DASHSCOPE_APP_ID_2A_FINANCE_AREA 百炼 APP ID

实测:7 项集成单测全通过(config/cache_type/router/prompts/dispatcher 常量/
4 业态 prompt 构建/拒绝 area=all)· 端到端实调 vip 组合返回 12 条高质量洞察
严格遵守 v1.2 system prompt 全部 7 项硬约束(H1-H7)。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 21:55:26 +08:00
Neo
76a23639ee feat(db): app2a DWS 新列 + ai_cache CHECK 约束放开
1. db/etl_feiqiu/migrations/20260423__app2a_add_member_order_count.sql:
   - dws.dws_finance_area_daily 增加 member_order_count 列 (integer NOT NULL DEFAULT 0)
   - 重建 app.v_dws_finance_area_daily RLS 视图暴露新列
   - 同步重建 dws.v_dws_finance_area_daily(遵守双 schema 规则)
   - 列顺序因 PostgreSQL CREATE OR REPLACE VIEW 限制必须加在末尾

2. db/zqyy_app/migrations/20260423__ai_cache_allow_app2a.sql:
   - biz.ai_cache.chk_ai_cache_type CHECK 约束放开 app2a_finance_area 新值
   - DROP 旧 7 项 CHECK + CREATE 含 8 项的新 CHECK(新增 app2a_finance_area)

3. docs/database/changes/ 两份变更文档:
   - 变更说明 + 兼容性 + 回滚策略 + 3-4 条验证 SQL

测试库已执行 + 验证通过。生产库待上线窗口按 checklist 跑。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 21:55:01 +08:00
Neo
cd511d0670 feat(etl): app2a DWS 增加 member_order_count 聚合 + 修复 area 未匹配订单 all 兜底
1. finance_area_daily.py:
   - _AREA_AGG_FIELDS 增加 member_order_count · _COUNT_FIELDS 常量统一 int 转换
   - extract SQL 增加 sh.member_id 字段
   - transform 按 CLAUDE.md DWS 规范 member_id > 0 判定是否会员订单
   - _build_area_row / _build_sum_row 支持新计数字段

2. pre-existing bug 修复(顺手):
   area_code 为 None(table_id 未映射)的订单之前既不计入具体区域也不计入 all,
   导致全店 order_count/member_order_count > 各区域之和。
   修复:新增 _unknown 桶收纳未匹配订单 · 构建 all 行时追加合入 source_rows。

3. backfill_finance_area_daily.py extract SQL 加 sh.member_id
   支持回填历史 member_order_count 数据。

实测:纯函数单测 + 测试库 ETL 7 天回放 · 04-18/04-20 等日期全店 vs 区域和
从差 1 单修复为 0 差异 · 纯函数新增 2 条未匹配订单用例断言全通过。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 21:54:37 +08:00
Neo
b44096600d chore(cleanup): 清理历史会话遗留未跟踪产物 — gitignore 追加 + 文件归位
- .gitignore: 追加 .playwright-mcp/ 与 apps/miniprogram/.font_patch_tmp/ 两条忽略规则
- apps/miniprogram/scripts/: 新建目录, 迁入 TDesign BOM 修复脚本 inspect-wechat-font.ps1 及其检查报告
- 根目录 excel_analysis_report.txt / sheet_structure.txt 归位 tmp/, 修正 root-file 风险标签
- 审计记录 2026-04-20__legacy-untracked-cleanup-review.md, 含实际执行段与回滚路径
- audit_dashboard.md 由 gen_audit_dashboard.py 刷新至 130 条

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 06:48:27 +08:00
Neo
95dd1fa6b9 chore(audit): 修正审计文件命名 — 单下划线补为双下划线
2026-03-24_fix_cfg_skill_type_missing_records.md
  → 2026-03-24__fix_cfg_skill_type_missing_records.md

该文件源自 audit-gap-recovery 阶段 1 补追的 96 份 D 类孤本之一,原命名采用单
下划线分隔日期与 slug,与 gen_audit_dashboard.py 期望的双下划线格式不符而被
脚本忽略。补齐下划线后 dashboard 记录数从 128 升至 129,与主目录 md 文件
数一致。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 06:39:13 +08:00
Neo
ec8f7d4e03 chore(audit): 清理 changes/changes/ 嵌套误产物目录 — audit-gap-recovery 收尾
按 docs/specs/audit-gap-recovery/tasks.md 阶段 2/4 完成:

阶段 2(B 类内容漂移,1 份)
决策:保留主目录版 2026-04-06__v1-cleanup-ddl-consolidation.md。
两版仅差 1 行路径(第 128 行):
  - 主目录版:cd c:/NeoZQYY && python tools/db/gen_consolidated_ddl.py
  - 嵌套版:   cd c:/Project/NeoZQYY && ...
主目录版是 2026-04-06 审计当天原始写法(与 v1 整理 commit 779b2f6 同期),
忠于历史真相;嵌套版是开发机迁移后批量路径替换过的版本,舍弃。
差异过小,不单独备份。

阶段 4(删除嵌套目录)
- rm -rf docs/audit/changes/changes/(本地删除,目录本就被 .gitignore 屏蔽,
  无 git 历史需清理)
- .gitignore 移除 docs/audit/changes/changes/ 临时屏蔽规则(嵌套目录已不复存在)
- 刷新 docs/audit/audit_dashboard.md,当前 128 条记录
  (主目录 129 份 md 中有 1 份 2026-03-24_fix_cfg_skill_type_missing_records.md
   因单下划线命名异常被 dashboard 脚本忽略,后续可重命名)

验证
- ls docs/audit/changes/*.md | wc -l = 129(原 33 + D 补追 96 = 129 ✓)
- ls docs/audit/changes/changes/ 返回 "No such file"(✓)
- git log --oneline -- "docs/audit/changes/" 有本次补追 + 清理 commit(✓)

audit-gap-recovery PRD 至此全部 4 阶段执行完毕。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 06:38:11 +08:00
Neo
14a12342b5 chore(audit): 补追 96 份未入仓审计孤本 — 覆盖 2026-02-26 ~ 2026-04-08
这些审计记录原本堆积在 docs/audit/changes/changes/ 嵌套误产物目录下(由开发机迁移
79d3c2e 前后的不明批量操作产生)。由于同期 .gitignore 屏蔽了 docs/audit/ 全目录,
它们从未入过 git 任何分支 history。删除即永久丢失。

按 docs/specs/audit-gap-recovery/tasks.md 阶段 1 执行,将全部 96 份 D 类孤本
(主目录无同名、git history 亦无记录)复制到 docs/audit/changes/ 主目录入仓。

涵盖主题: P1-P18 全栈集成 / 多模块累积变更 / ETL bug 修复 / 业务日切 /
   召回与任务引擎改造 / 租户管理与审批 / 董事会财务 / 客户与助教详情 /
   DDL 基线合并 / Kiro 到 Claude Code 迁移

阶段 2(B 类内容漂移 1 份)和阶段 4(嵌套目录删除)独立推进。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 06:35:42 +08:00
Neo
80bda9b991 chore(audit): 2026-04-20 历史批次预审 + 文档同步 + .gitignore 修正
- 新增 docs/audit/changes/2026-04-20__historical-batch-pre-audit.md
  157 文件分批盘点审计(7 条主线 + 10 项高/中风险 + 2 份迁移 SQL DDL 清单)
- 补追 docs/audit/changes/2026-04-15__meituan-settle-core-sync.md
  原审计产物因 .gitignore 屏蔽长期未入仓,本次一并追回
- 刷新 docs/audit/audit_dashboard.md(33 条审计记录)
- .gitignore 白名单放行 docs/audit/changes/*.md 与 audit_dashboard.md
  同时屏蔽 changes/changes/ 嵌套误产物目录
- 新增 docs/specs/audit-gap-recovery/tasks.md
  扫描嵌套目录发现 96 份 D 类孤本(从未入过 git history),
  生成独立 PRD 供单开任务清理与补追
- 文档同步(高风险项):
  - apps/backend/docs/API-REFERENCE.md (+69)
  - apps/miniprogram/README.md (+50)
  - apps/etl/connectors/feiqiu/docs/architecture/data_flow.md (+52/-2)
  - apps/etl/connectors/feiqiu/docs/architecture/system_overview.md (+5/-3)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 06:32:58 +08:00
Neo
2a7a5d68aa feat: 2026-04-15~04-20 累积变更基线 — 多主线合流
主线 1: rns1-customer-coach-api + 04-miniapp-core-business 后端实施
  - 新增 GET /xcx/coaches/{id}/banner 轻量接口
  - performance/records 加 coach_id 参数 + view_board_coach 权限分流
  - coach/customer/performance/board/task 服务层重构
  - fdw_queries 结算单粒度聚合 + consumption_summary 视图统一
  - task_generator 回访宽限 72h + UPSERT 替代策略 + Step 5 保底清理
  - recall_detector settle_type=3 双重限制 + 门店级 resolved

主线 2: 小程序权限分流 + 新增 coach-service-records 管理者视角业绩明细页
  - perf-progress 共享模块去重 task-list/coach-detail 动画逻辑
  - isScattered 散客标记端到端
  - foodDetail/phoneFull/creator* 字段透传

主线 3: P19 指数回测框架 Phase 1+2
  - 3 个指数表 stat_date 日快照模式
  - 新增 DWS_INDEX_BACKFILL / DWS_TASK_SIMULATION 工具任务
  - task_engine 升级 HTTP 实时 + 推演回测双模式

主线 4: Core 维度层启用
  - 新增 CORE_DIM_SYNC 任务(DWD → core 4 维度表)
  - 修复 app 视图空查询问题

主线 5: member_project_tag 改为 LAST_30_VISITS 消费次数窗口

主线 6: 2 个迁移 SQL 已执行(stat_date + member_project_tag 新窗口)
  - schema 基线与 DDL 快照同步

主线 7: 开发机路径迁移 C:\NeoZQYY → C:\Project\NeoZQYY(约 95% 改动量)

附带: 新建运维脚本(churned_customer_report / simulate_historical_tasks /
      backfill_index_snapshots)+ tools/task-analysis/ 任务分析工具

合计 157 文件。未包含中间产物(tmp/ .playwright-mcp/ inspect-* excel/sheet 分析 txt)。
审计记录见下一个 commit。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 06:32:07 +08:00
285 changed files with 29233 additions and 3985 deletions

View File

@@ -1,45 +1,13 @@
{
"permissions": {
"allow": [
"Bash(du -sh /c/NeoZQYY/*)",
"Bash(du -sh /c/NeoZQYY/.*)",
"Bash(wc -l /c/NeoZQYY/scripts/ops/*.py)",
"Read(//c/Users/Administrator/.kiro//**)",
"Bash(ls -lS /c/NeoZQYY/tmp/*.md)",
"Bash(xargs -I {} basename {})",
"Bash(sed 's/_[a-z].*//')",
"Bash(ls tests/*.py)",
"Bash(sed 's/test_//')",
"Bash(ls test_property_*.py)",
"Bash(ls test_p*.py)",
"Bash(ls test_rns*.py)",
"Bash(ls test_tenant_*.py)",
"Bash(ls test_trace_*.py)",
"Bash(mv .kiro/steering _DEL/.kiro/steering)",
"Bash(mv .kiroignore _DEL/.kiroignore)",
"Bash(mv .specstory _DEL/.specstory)",
"Bash(mv .cursorindexingignore _DEL/.cursorindexingignore)",
"Bash(mv AI_CHANGELOG.md _DEL/AI_CHANGELOG.md)",
"Bash(mv _tmp_replace2.py _DEL/)",
"Bash(mv backend_test_results.txt _DEL/)",
"Bash(mv test_results.txt _DEL/)",
"Bash(mv dev-trace-coverage-working.png _DEL/)",
"Bash(mv dev-trace-page.png _DEL/)",
"Bash(mv export/pytest_result.txt _DEL/export/)",
"Bash(mv export/test_auth_results.txt _DEL/export/)",
"Bash(mv export/p13_test_result.txt _DEL/export/)",
"Bash(mv export/p13_result.txt _DEL/export/)",
"Bash(cp -r tmp _DEL/tmp_backup)",
"Bash(*)",
"Bash(touch tmp/.gitkeep)",
"Bash(ls -la c:/NeoZQYY/docs/audit/session_logs/_session_index*.json)",
"mcp__pg-etl-test__execute_sql",
"mcp__pg-app-test__execute_sql",
"mcp__pg-app-test__list_schemas"
],
"additionalDirectories": [
"C:\\Users\\Administrator\\.claude",
"c:\\NeoZQYY\\.git"
"C:\\Users\\Administrator\\.claude"
]
},
"hooks": {

30
.env
View File

@@ -77,35 +77,35 @@ BUSINESS_DAY_START_HOUR=8
# ETL Connector飞球输出路径
# ------------------------------------------------------------------------------
# JSON 导出根目录ODS 抓取落盘,按 TASK_CODE/run_id 自动建子目录)
EXPORT_ROOT=C:/NeoZQYY/export/ETL-Connectors/feiqiu/JSON
EXPORT_ROOT=C:/Project/NeoZQYY/export/ETL-Connectors/feiqiu/JSON
# ETL 运行日志根目录
LOG_ROOT=C:/NeoZQYY/export/ETL-Connectors/feiqiu/LOGS
LOG_ROOT=C:/Project/NeoZQYY/export/ETL-Connectors/feiqiu/LOGS
# 在线抓取 JSON 输出根目录FETCH_ONLY 模式使用)
FETCH_ROOT=C:/NeoZQYY/export/ETL-Connectors/feiqiu/JSON
FETCH_ROOT=C:/Project/NeoZQYY/export/ETL-Connectors/feiqiu/JSON
# ETL 质检/完整性报告输出目录
ETL_REPORT_ROOT=C:/NeoZQYY/export/ETL-Connectors/feiqiu/REPORTS
ETL_REPORT_ROOT=C:/Project/NeoZQYY/export/ETL-Connectors/feiqiu/REPORTS
# ------------------------------------------------------------------------------
# 系统级输出路径
# ------------------------------------------------------------------------------
# 数据流结构分析报告输出目录gen_dataflow_report.py / analyze_dataflow.py
SYSTEM_ANALYZE_ROOT=C:/NeoZQYY/export/SYSTEM/REPORTS/dataflow_analysis
SYSTEM_ANALYZE_ROOT=C:/Project/NeoZQYY/export/SYSTEM/REPORTS/dataflow_analysis
# 字段排查报告输出目录field_audit.py
FIELD_AUDIT_ROOT=C:/NeoZQYY/export/SYSTEM/REPORTS/field_audit
FIELD_AUDIT_ROOT=C:/Project/NeoZQYY/export/SYSTEM/REPORTS/field_audit
# 全链路数据流文档输出目录gen_full_dataflow_doc.py
FULL_DATAFLOW_DOC_ROOT=C:/NeoZQYY/export/SYSTEM/REPORTS/full_dataflow_doc
FULL_DATAFLOW_DOC_ROOT=C:/Project/NeoZQYY/export/SYSTEM/REPORTS/full_dataflow_doc
# API 样本缓存目录gen_full_dataflow_doc.py 的 24h 缓存)
API_SAMPLE_CACHE_ROOT=C:/NeoZQYY/export/SYSTEM/CACHE/api_samples
API_SAMPLE_CACHE_ROOT=C:/Project/NeoZQYY/export/SYSTEM/CACHE/api_samples
# 系统级运维日志目录
SYSTEM_LOG_ROOT=C:/NeoZQYY/export/SYSTEM/LOGS
SYSTEM_LOG_ROOT=C:/Project/NeoZQYY/export/SYSTEM/LOGS
# ------------------------------------------------------------------------------
# 后端输出路径(预留)
# ------------------------------------------------------------------------------
# 后端结构化日志目录
BACKEND_LOG_ROOT=C:/NeoZQYY/export/BACKEND/LOGS
BACKEND_LOG_ROOT=C:/Project/NeoZQYY/export/BACKEND/LOGS
# 用户头像存储目录
AVATAR_EXPORT_PATH=C:/NeoZQYY/export/BACKEND/avatars
AVATAR_EXPORT_PATH=C:/Project/NeoZQYY/export/BACKEND/avatars
# ------------------------------------------------------------------------------
# DashScope AI 配置(百炼 Application API
@@ -127,6 +127,8 @@ DASHSCOPE_APP_ID_5_TACTICS=46f54e6053df4bb0b83be29366025cf6
DASHSCOPE_APP_ID_6_NOTE=025bb344146b4e4e8be30c444adab3b4
DASHSCOPE_APP_ID_7_CUSTOMER=df35e06991b24d49971c03c6428a9c87
DASHSCOPE_APP_ID_8_CONSOLIDATE=407dfb89283b4196934eec5fefe3ebc2
# 应用 2a区域财务洞察64 组合 · area != 'all' · 板块 C/E 重分工 · 新增 H7 业态特征硬约束)
DASHSCOPE_APP_ID_2A_FINANCE_AREA=0ae965029bc54706bcff44f511ac716b
# 应用 9Session 日志摘要生成Kiro agent_on_stop + batch_generate_summaries 使用)
DASHSCOPE_APP_ID_SUMMARY=e0cf8913b1ee4a4eb9464cc1ee0bf300
@@ -156,9 +158,9 @@ PIPELINE_RATE_MAX=2.0
# 后端运维面板路径配置
# CHANGE 2026-03-06 | 显式锁定,避免 __file__ 推算在不同部署环境指向错误路径
# ------------------------------------------------------------------------------
OPS_SERVER_BASE=C:/NeoZQYY
ETL_PROJECT_PATH=C:/NeoZQYY/apps/etl/connectors/feiqiu
ETL_PYTHON_EXECUTABLE=C:/NeoZQYY/.venv/Scripts/python.exe
OPS_SERVER_BASE=C:/Project/NeoZQYY
ETL_PROJECT_PATH=C:/Project/NeoZQYY/apps/etl/connectors/feiqiu
ETL_PYTHON_EXECUTABLE=C:/Project/NeoZQYY/.venv/Scripts/python.exe
# === Dev Trace Log ===
# 全链路请求追踪日志(仅开发/测试环境使用,生产环境关闭)

View File

@@ -83,26 +83,26 @@ BUSINESS_DAY_START_HOUR=8
# ------------------------------------------------------------------------------
# ETL Connector 输出路径
# ------------------------------------------------------------------------------
EXPORT_ROOT=C:/NeoZQYY/export/ETL-Connectors/feiqiu/JSON
LOG_ROOT=C:/NeoZQYY/export/ETL-Connectors/feiqiu/LOGS
FETCH_ROOT=C:/NeoZQYY/export/ETL-Connectors/feiqiu/JSON
ETL_REPORT_ROOT=C:/NeoZQYY/export/ETL-Connectors/feiqiu/REPORTS
EXPORT_ROOT=C:/Project/NeoZQYY/export/ETL-Connectors/feiqiu/JSON
LOG_ROOT=C:/Project/NeoZQYY/export/ETL-Connectors/feiqiu/LOGS
FETCH_ROOT=C:/Project/NeoZQYY/export/ETL-Connectors/feiqiu/JSON
ETL_REPORT_ROOT=C:/Project/NeoZQYY/export/ETL-Connectors/feiqiu/REPORTS
# ------------------------------------------------------------------------------
# 系统级输出路径
# ------------------------------------------------------------------------------
SYSTEM_ANALYZE_ROOT=C:/NeoZQYY/export/SYSTEM/REPORTS/dataflow_analysis
FIELD_AUDIT_ROOT=C:/NeoZQYY/export/SYSTEM/REPORTS/field_audit
FULL_DATAFLOW_DOC_ROOT=C:/NeoZQYY/export/SYSTEM/REPORTS/full_dataflow_doc
API_SAMPLE_CACHE_ROOT=C:/NeoZQYY/export/SYSTEM/CACHE/api_samples
SYSTEM_LOG_ROOT=C:/NeoZQYY/export/SYSTEM/LOGS
SYSTEM_ANALYZE_ROOT=C:/Project/NeoZQYY/export/SYSTEM/REPORTS/dataflow_analysis
FIELD_AUDIT_ROOT=C:/Project/NeoZQYY/export/SYSTEM/REPORTS/field_audit
FULL_DATAFLOW_DOC_ROOT=C:/Project/NeoZQYY/export/SYSTEM/REPORTS/full_dataflow_doc
API_SAMPLE_CACHE_ROOT=C:/Project/NeoZQYY/export/SYSTEM/CACHE/api_samples
SYSTEM_LOG_ROOT=C:/Project/NeoZQYY/export/SYSTEM/LOGS
# ------------------------------------------------------------------------------
# 后端输出路径
# ------------------------------------------------------------------------------
BACKEND_LOG_ROOT=C:/NeoZQYY/export/BACKEND/LOGS
BACKEND_LOG_ROOT=C:/Project/NeoZQYY/export/BACKEND/LOGS
# 用户头像存储目录chooseAvatar 上传后保存到此目录,文件名 {user_id}.jpg
AVATAR_EXPORT_PATH=C:/NeoZQYY/export/BACKEND/avatars
AVATAR_EXPORT_PATH=C:/Project/NeoZQYY/export/BACKEND/avatars
# ------------------------------------------------------------------------------
# DashScope AI 配置(百炼 Application API
@@ -336,19 +336,19 @@ INDEX_LOOKBACK_DAYS=90
# ETL 项目路径(子进程 cwd
# CHANGE 2026-03-06 | 必须显式设置,禁止依赖 __file__ 推算
# ------------------------------------------------------------------------------
ETL_PROJECT_PATH=C:/NeoZQYY/apps/etl/connectors/feiqiu
ETL_PROJECT_PATH=C:/Project/NeoZQYY/apps/etl/connectors/feiqiu
# ------------------------------------------------------------------------------
# ETL 子进程 Python 可执行路径
# CHANGE 2026-03-06 | 必须显式设置,避免 PATH 歧义
# ------------------------------------------------------------------------------
ETL_PYTHON_EXECUTABLE=C:/NeoZQYY/.venv/Scripts/python.exe
ETL_PYTHON_EXECUTABLE=C:/Project/NeoZQYY/.venv/Scripts/python.exe
# ------------------------------------------------------------------------------
# 运维面板服务器根目录
# CHANGE 2026-03-06 | 必须显式设置,消除 __file__ 推算风险
# ------------------------------------------------------------------------------
OPS_SERVER_BASE=C:/NeoZQYY
OPS_SERVER_BASE=C:/Project/NeoZQYY
# === Dev Trace Log ===
# 全链路请求追踪日志(仅开发/测试环境使用,生产环境关闭)

12
.gitignore vendored
View File

@@ -12,8 +12,18 @@ logs/
*.log
*.jsonl
# Playwright MCP 浏览器日志与页面快照
.playwright-mcp/
# 小程序字体补丁临时目录
apps/miniprogram/.font_patch_tmp/
# ===== 审计文件 =====
docs/audit/
docs/audit/*
# 白名单:允许追踪审计变更记录与仪表盘
!docs/audit/changes/
!docs/audit/changes/*.md
!docs/audit/audit_dashboard.md
# ===== 运行时产出 =====
export/

View File

@@ -37,7 +37,7 @@
"args": ["/c", "npx", "-y", "weixin-devtools-mcp", "--tools-profile=full", "--ws-endpoint=ws://127.0.0.1:9420"],
"env": {
"WECHAT_DEVTOOLS_CLI": "C:\\dev\\WechatDevtools\\cli.bat",
"WECHAT_DEVTOOLS_PROJECT": "C:\\NeoZQYY\\apps\\miniprogram"
"WECHAT_DEVTOOLS_PROJECT": "C:\\Project\\NeoZQYY\\apps\\miniprogram"
},
"disabled": false
},
@@ -55,7 +55,7 @@
"awslabs.openapi-mcp-server.exe",
"--api-name", "miniapp-backend",
"--api-url", "http://127.0.0.1:8000",
"--spec-path", "C:\\NeoZQYY\\docs\\contracts\\openapi\\backend-api.json",
"--spec-path", "C:\\Project\\NeoZQYY\\docs\\contracts\\openapi\\backend-api.json",
"--log-level", "ERROR"
],
"env": {

View File

@@ -610,7 +610,7 @@ Tailwind 的 `leading-*` 是比例值,不是 px。换算方式`line-height
#### 微信开发者工具连接
```bash
# 用户手动启动自动化端口
& "C:\dev\WechatDevtools\cli.bat" auto --project "C:\NeoZQYY\apps\miniprogram" --auto-port 9420
& "C:\dev\WechatDevtools\cli.bat" auto --project "C:\Project\NeoZQYY\apps\miniprogram" --auto-port 9420
```
连接规范:

View File

@@ -17,7 +17,7 @@ async (page) => {
deviceScaleFactor: 1.5
});
const p = await ctx.newPage();
await p.goto('file:///C:/NeoZQYY/docs/h5_ui/pages/<page>.html',
await p.goto('file:///C:/Project/NeoZQYY/docs/h5_ui/pages/<page>.html',
{ waitUntil: 'domcontentloaded', timeout: 30000 });
await p.waitForTimeout(3000); // Tailwind CDN JIT
@@ -36,7 +36,7 @@ async (page) => {
await p.evaluate((scrollTop) => window.scrollTo(0, scrollTop), <scrollTop>);
await p.waitForTimeout(300);
await p.screenshot({
path: 'C:/NeoZQYY/docs/h5_ui/compare/<page>/h5--step-<scrollTop>.png',
path: 'C:/Project/NeoZQYY/docs/h5_ui/compare/<page>/h5--step-<scrollTop>.png',
type: 'png', scale: 'device'
});
await ctx.close();

View File

@@ -10,13 +10,13 @@ inclusion: manual
1. 启动自动化端口:
```powershell
& "C:\dev\WechatDevtools\cli.bat" auto --project "C:\NeoZQYY\apps\miniprogram" --auto-port 9420
& "C:\dev\WechatDevtools\cli.bat" auto --project "C:\Project\NeoZQYY\apps\miniprogram" --auto-port 9420
```
2. AI 使用 `connect_devtools` 时,只能用 `wsEndpoint` 策略:
- `strategy`: `wsEndpoint`
- `wsEndpoint`: `ws://127.0.0.1:9420`
- `projectPath`: `C:\NeoZQYY\apps\miniprogram`
- `projectPath`: `C:\Project\NeoZQYY\apps\miniprogram`
## 禁止事项

View File

@@ -133,7 +133,7 @@ evaluate_script({
### 保存截图到文件
```
screenshot(path: "C:/NeoZQYY/export/screenshots/task-list.png")
screenshot(path: "C:/Project/NeoZQYY/export/screenshots/task-list.png")
```
### 配合 pixel-audit Power 做视觉对比

View File

@@ -7,6 +7,32 @@
import { apiClient } from "./client";
// ---- 公共类型 ----
/**
* AI APP 类型联合(与后端 `CacheTypeEnum` / `_SUPPORTED_APP_TYPES` 同步)。
*
* - app1_chat · 小程序聊天(无缓存)
* - app2_finance · 全域财务洞察area = 'all'8 组合)
* - app2a_finance_area · 区域财务洞察area != 'all'64 组合2026-04-23 新增)
* - app3_clue · 客户线索分析
* - app4_analysis · 助教关系分析
* - app5_tactics · 话术参考
* - app6_note_analysis · 备注分析
* - app7_customer_analysis · 客户综合分析
* - app8_clue_consolidated · 线索整合
*/
export type AppType =
| "app1_chat"
| "app2_finance"
| "app2a_finance_area"
| "app3_clue"
| "app4_analysis"
| "app5_tactics"
| "app6_note_analysis"
| "app7_customer_analysis"
| "app8_clue_consolidated";
// ---- 类型定义 ----
// Dashboard
@@ -201,9 +227,16 @@ export interface AlertActionResponse {
// ---- API 调用 ----
// Dashboard
export async function getDashboard(siteId?: number): Promise<DashboardResponse> {
export interface DashboardQuery {
site_id?: number;
range_days?: number; // 1 / 3 / 7 / 10
date_from?: string; // YYYY-MM-DD与 date_to 成对)
date_to?: string;
}
export async function getDashboard(query?: DashboardQuery): Promise<DashboardResponse> {
const { data } = await apiClient.get<DashboardResponse>("/admin/ai/dashboard", {
params: siteId != null ? { site_id: siteId } : undefined,
params: query,
});
return data;
}
@@ -275,3 +308,101 @@ export async function ignoreAlert(id: number): Promise<AlertActionResponse> {
const { data } = await apiClient.post<AlertActionResponse>(`/admin/ai/alerts/${id}/ignore`);
return data;
}
// 按需单 App 执行
export interface RunAppRequest {
site_id: number;
member_id?: number;
assistant_id?: number;
time_dimension?: string;
area?: string;
note_content?: string;
noted_by_name?: string;
noted_by_created_at?: string;
}
export interface RunAppResponse {
app_type: AppType;
success: boolean;
result: Record<string, unknown> | null;
error: string | null;
}
export async function runApp(appType: AppType, body: RunAppRequest): Promise<RunAppResponse> {
const { data } = await apiClient.post<RunAppResponse>(`/admin/ai/run/${appType}`, body);
return data;
}
// ---- 触发器管理biz.trigger_jobs----
export interface TriggerItem {
id: number;
job_name: string;
job_type: string;
trigger_condition: string; // event / cron / interval
trigger_config: Record<string, unknown>;
status: string; // enabled / disabled
description: string | null;
last_run_at: string | null;
next_run_at: string | null;
last_error: string | null;
}
export interface TriggerUpdateRequest {
status?: string; // enabled / disabled
cron_expression?: string;
description?: string;
}
export async function listTriggers(): Promise<TriggerItem[]> {
const { data } = await apiClient.get<TriggerItem[]>("/admin/ai/triggers");
return data;
}
export async function updateTrigger(id: number, body: TriggerUpdateRequest): Promise<TriggerItem> {
const { data } = await apiClient.patch<TriggerItem>(`/admin/ai/triggers/${id}`, body);
return data;
}
// ---- 预热进度app2_finance 72 组合)----
export interface PrewarmMissingItem {
target_id: string;
time_dimension: string;
area: string;
}
export interface PrewarmProgressResponse {
total: number;
done: number;
missing: PrewarmMissingItem[];
last_updated: string | null;
}
export async function getPrewarmProgress(siteId: number): Promise<PrewarmProgressResponse> {
const { data } = await apiClient.get<PrewarmProgressResponse>("/admin/ai/prewarm/progress", {
params: { site_id: siteId },
});
return data;
}
// ---- 手动触发事件链(越过去重)----
export interface ManualTriggerRequest {
event_type: string; // consumption / dws_completed / note_created / task_assigned
site_id: number;
member_id?: number;
assistant_id?: number;
payload?: Record<string, unknown>;
is_forced?: boolean;
}
export interface ManualTriggerResponse {
trigger_job_id: number;
status: string;
}
export async function triggerEvent(body: ManualTriggerRequest): Promise<ManualTriggerResponse> {
const { data } = await apiClient.post<ManualTriggerResponse>("/admin/ai/trigger-event", body);
return data;
}

View File

@@ -17,10 +17,17 @@ import { ReloadOutlined } from "@ant-design/icons";
import type { ColumnsType } from "antd/es/table";
import {
retryTriggerJob, invalidateCache, createBatchRun, confirmBatchRun,
getAlerts, ackAlert, ignoreAlert,
type AlertItem, type BatchRunEstimate,
getAlerts, ackAlert, ignoreAlert, runApp, triggerEvent,
type AlertItem, type AppType, type BatchRunEstimate,
} from "../api/adminAI";
const EVENT_TYPE_OPTIONS = [
{ label: "消费事件App3→App8→App7 [+ App4→App5]", value: "consumption" },
{ label: "备注事件App6→App8", value: "note_created" },
{ label: "任务分配App4→App5", value: "task_assigned" },
{ label: "DWS 完成App2 × 72 组合预热)", value: "dws_completed" },
];
const { TextArea } = Input;
const { Title } = Typography;
@@ -92,6 +99,66 @@ const AIOperations: React.FC = () => {
}
};
// ---- Card 2.5: 按需重新生成 ----
const [runAppType, setRunAppType] = useState<AppType | undefined>();
const [runMemberId, setRunMemberId] = useState<string>("");
const [runSiteId, setRunSiteId] = useState<number>(2790685415443269);
const [runLoading, setRunLoading] = useState(false);
const [runResult, setRunResult] = useState<{ success: boolean; text: string } | null>(null);
const handleRunApp = async () => {
if (!runAppType) { message.warning("请选择 App 类型"); return; }
setRunLoading(true);
setRunResult(null);
try {
const res = await runApp(runAppType, {
site_id: runSiteId,
member_id: runMemberId ? Number(runMemberId) : undefined,
});
if (res.success) {
setRunResult({ success: true, text: "执行成功,缓存已更新" });
message.success("执行成功");
} else {
setRunResult({ success: false, text: res.error ?? "执行失败" });
message.error(res.error ?? "执行失败");
}
} catch {
message.error("请求失败");
} finally {
setRunLoading(false);
}
};
// ---- Card 2.6: 手动触发事件链(越过去重)----
const [evtType, setEvtType] = useState<string>("consumption");
const [evtSiteId, setEvtSiteId] = useState<number>(2790685415443269);
const [evtMemberId, setEvtMemberId] = useState<string>("");
const [evtAssistantId, setEvtAssistantId] = useState<string>("");
const [evtForced, setEvtForced] = useState<boolean>(true);
const [evtLoading, setEvtLoading] = useState(false);
const [evtResult, setEvtResult] = useState<number | null>(null);
const handleTriggerEvent = async () => {
if (!evtType) { message.warning("请选择事件类型"); return; }
setEvtLoading(true);
setEvtResult(null);
try {
const res = await triggerEvent({
event_type: evtType,
site_id: evtSiteId,
member_id: evtMemberId ? Number(evtMemberId) : undefined,
assistant_id: evtAssistantId ? Number(evtAssistantId) : undefined,
is_forced: evtForced,
});
setEvtResult(res.trigger_job_id);
message.success(`事件已触发job_id=${res.trigger_job_id}(后台异步执行)`);
} catch {
message.error("触发失败");
} finally {
setEvtLoading(false);
}
};
// ---- Card 3: 批量执行 ----
const [batchAppTypes, setBatchAppTypes] = useState<string[]>([]);
const [batchMemberIds, setBatchMemberIds] = useState<string>("");
@@ -247,6 +314,89 @@ const AIOperations: React.FC = () => {
</Col>
</Row>
{/* Card 2.5: 按需重新生成 */}
<Card title="按需重新生成" size="small" style={{ marginBottom: 16 }}>
<Row gutter={16}>
<Col span={6}>
<Select
allowClear placeholder="App 类型" style={{ width: "100%" }}
value={runAppType} onChange={setRunAppType}
options={APP_TYPE_OPTIONS}
/>
</Col>
<Col span={6}>
<Input
placeholder="会员 ID部分 App 必填)" value={runMemberId}
onChange={(e) => setRunMemberId(e.target.value)}
/>
</Col>
<Col span={6}>
<Select
placeholder="门店" style={{ width: "100%" }}
value={runSiteId} onChange={setRunSiteId}
options={[{ label: "默认门店", value: 2790685415443269 }]}
/>
</Col>
<Col span={6}>
<Space>
<Button type="primary" onClick={handleRunApp} loading={runLoading}></Button>
{runResult && (
<Tag color={runResult.success ? "success" : "error"}>{runResult.text}</Tag>
)}
</Space>
</Col>
</Row>
</Card>
{/* Card 2.6: 手动触发事件链(越过去重,调试利器)*/}
<Card
title="手动触发事件链(调试用)" size="small" style={{ marginBottom: 16 }}
extra={<Tag color="orange"></Tag>}
>
<Row gutter={12}>
<Col span={6}>
<div style={{ marginBottom: 4, fontSize: 12, color: "#888" }}></div>
<Select
value={evtType} onChange={setEvtType}
options={EVENT_TYPE_OPTIONS}
style={{ width: "100%" }}
/>
</Col>
<Col span={5}>
<div style={{ marginBottom: 4, fontSize: 12, color: "#888" }}></div>
<Select
value={evtSiteId} onChange={setEvtSiteId}
options={[{ label: "默认门店", value: 2790685415443269 }]}
style={{ width: "100%" }}
/>
</Col>
<Col span={4}>
<div style={{ marginBottom: 4, fontSize: 12, color: "#888" }}>member_id</div>
<Input
placeholder="consumption/note/task 事件需填"
value={evtMemberId}
onChange={(e) => setEvtMemberId(e.target.value)}
/>
</Col>
<Col span={4}>
<div style={{ marginBottom: 4, fontSize: 12, color: "#888" }}>assistant_id</div>
<Input
placeholder="task_assigned 事件需填"
value={evtAssistantId}
onChange={(e) => setEvtAssistantId(e.target.value)}
/>
</Col>
<Col span={5}>
<div style={{ marginBottom: 4, fontSize: 12, color: "#888" }}> + </div>
<Space>
<Checkbox checked={evtForced} onChange={(e) => setEvtForced(e.target.checked)}></Checkbox>
<Button type="primary" danger onClick={handleTriggerEvent} loading={evtLoading}></Button>
{evtResult != null && <Tag color="processing">job #{evtResult}</Tag>}
</Space>
</Col>
</Row>
</Card>
{/* Card 3: 批量执行 */}
<Card title="批量执行" size="small" style={{ marginBottom: 16 }}>
<Row gutter={16}>

View File

@@ -0,0 +1,327 @@
/**
* AI 预热进度页面。
*
* 监控 app2_finance 的 72 组合8 时间维度 × 9 区域)缓存覆盖率。
* 支持:
* - 进度条 + 缺失组合表格
* - 一键补跑所有缺失(串行 POST /admin/ai/run/app2_finance
* - 触发全量预热POST /admin/ai/trigger-event
* - 按单组合手动重跑
*/
import React, { useCallback, useEffect, useState } from "react";
import {
Card, Progress, Table, Button, Space, Tag, message, Select,
Typography, Descriptions, Modal, Alert,
} from "antd";
import { ReloadOutlined, ThunderboltOutlined, PlayCircleOutlined } from "@ant-design/icons";
import type { ColumnsType } from "antd/es/table";
import {
getPrewarmProgress, runApp, triggerEvent,
type PrewarmProgressResponse, type PrewarmMissingItem,
} from "../api/adminAI";
const { Title, Paragraph, Text } = Typography;
const TIME_LABELS: Record<string, string> = {
this_month: "本月", last_month: "上月",
this_week: "本周", last_week: "上周",
this_quarter: "本季度", last_quarter: "上季度",
last_3_months: "近三个月", last_6_months: "近六个月",
};
const AREA_LABELS: Record<string, string> = {
all: "全部区域", hall: "大厅", hallA: "A区", hallB: "B区", hallC: "C区",
vip: "台球包厢", snooker: "斯诺克", mahjong: "麻将房", ktv: "团建房",
};
/**
* 按 area 选 app_type
* - area = 'all' → app2_finance全域8 组合)
* - area != 'all' → app2a_finance_area区域精简版64 组合)
*/
function areaToAppType(area: string): "app2_finance" | "app2a_finance_area" {
return area === "all" ? "app2_finance" : "app2a_finance_area";
}
function fmtTime(raw: string | null): string {
if (!raw) return "—";
const d = new Date(raw);
return Number.isNaN(d.getTime()) ? raw : d.toLocaleString("zh-CN");
}
// 用于在 missing 表格插入分组标题行AntD onCell 合并单元格方案)
type MissingRowWithGroup = PrewarmMissingItem & { __group_header?: string };
const AIPrewarm: React.FC = () => {
const [siteId, setSiteId] = useState<number>(2790685415443269);
const [data, setData] = useState<PrewarmProgressResponse | null>(null);
const [loading, setLoading] = useState(false);
// 单组合重跑
const [runningTarget, setRunningTarget] = useState<string | null>(null);
// 批量补缺
const [backfillOpen, setBackfillOpen] = useState(false);
const [backfillRunning, setBackfillRunning] = useState(false);
const [backfillProgress, setBackfillProgress] = useState<{ done: number; total: number; current: string } | null>(null);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await getPrewarmProgress(siteId);
setData(res);
} catch {
message.error("加载预热进度失败");
} finally {
setLoading(false);
}
}, [siteId]);
useEffect(() => { load(); }, [load]);
const handleRunOne = async (item: PrewarmMissingItem) => {
setRunningTarget(item.target_id);
try {
const res = await runApp(areaToAppType(item.area), {
site_id: siteId,
time_dimension: item.time_dimension,
area: item.area,
});
if (res.success) {
message.success(`${TIME_LABELS[item.time_dimension]} × ${AREA_LABELS[item.area]} 已生成`);
load();
} else {
message.error(res.error || "生成失败");
}
} catch {
message.error("请求失败");
} finally {
setRunningTarget(null);
}
};
const handleTriggerFullPrewarm = async () => {
Modal.confirm({
title: "全量预热",
content: `将触发 dws_completed 事件,后台串行生成 72 组合缓存(预计 1-2 小时)。继续?`,
okText: "开始预热",
onOk: async () => {
try {
const res = await triggerEvent({
event_type: "dws_completed",
site_id: siteId,
is_forced: true,
});
message.success(`已触发全量预热trigger_job_id=${res.trigger_job_id}),在后台异步执行`);
load();
} catch {
message.error("触发失败");
}
},
});
};
const handleBackfillMissing = async () => {
if (!data || data.missing.length === 0) return;
setBackfillOpen(false);
setBackfillRunning(true);
const total = data.missing.length;
let done = 0;
let fail = 0;
for (const item of data.missing) {
setBackfillProgress({ done, total, current: `${TIME_LABELS[item.time_dimension]} × ${AREA_LABELS[item.area]}` });
try {
const res = await runApp(areaToAppType(item.area), {
site_id: siteId,
time_dimension: item.time_dimension,
area: item.area,
});
if (!res.success) fail++;
} catch {
fail++;
}
done++;
}
setBackfillProgress({ done, total, current: "完成" });
setBackfillRunning(false);
if (fail === 0) {
message.success(`已补齐 ${done}/${total} 组合`);
} else {
message.warning(`完成 ${done - fail}/${total},失败 ${fail} 条(详见调用明细)`);
}
load();
};
// 按 area 分组的缺失列表(插入 group header 行)
const groupedMissing: MissingRowWithGroup[] = (() => {
if (!data || data.missing.length === 0) return [];
const global = data.missing.filter((i) => i.area === "all");
const area = data.missing.filter((i) => i.area !== "all");
const result: MissingRowWithGroup[] = [];
if (global.length > 0) {
result.push({
time_dimension: "__header_global",
area: "__header_global",
target_id: "__header_global",
__group_header: `🌐 全域组合app2_finance· 缺失 ${global.length} / 8 条`,
});
result.push(...global);
}
if (area.length > 0) {
result.push({
time_dimension: "__header_area",
area: "__header_area",
target_id: "__header_area",
__group_header: `🏷️ 区域组合app2a_finance_area· 缺失 ${area.length} / 64 条`,
});
result.push(...area);
}
return result;
})();
// 全域 / 区域 完成统计
const globalDone = data ? 8 - data.missing.filter((i) => i.area === "all").length : 0;
const areaDone = data ? 64 - data.missing.filter((i) => i.area !== "all").length : 0;
const missingColumns: ColumnsType<MissingRowWithGroup> = [
{
title: "时间维度",
dataIndex: "time_dimension",
key: "time_dimension",
width: 220,
onCell: (r) => (r.__group_header ? { colSpan: 4 } : {}),
render: (v: string, r) =>
r.__group_header ? (
<div style={{ padding: "6px 4px", background: "#fafafa", fontWeight: 600, color: "#595959" }}>
{r.__group_header}
</div>
) : (
<span>{TIME_LABELS[v] || v} <Text type="secondary" style={{ fontSize: 12 }}>({v})</Text></span>
),
},
{
title: "区域",
dataIndex: "area",
key: "area",
width: 140,
onCell: (r) => (r.__group_header ? { colSpan: 0 } : {}),
render: (v: string) => (
<span>{AREA_LABELS[v] || v} <Text type="secondary" style={{ fontSize: 12 }}>({v})</Text></span>
),
},
{
title: "缓存键",
dataIndex: "target_id",
key: "target_id",
onCell: (r) => (r.__group_header ? { colSpan: 0 } : {}),
render: (v: string) => <code>{v}</code>,
},
{
title: "操作",
key: "action",
width: 100,
fixed: "right",
onCell: (r) => (r.__group_header ? { colSpan: 0 } : {}),
render: (_: unknown, r) =>
r.__group_header ? null : (
<Button
size="small"
type="primary"
icon={<PlayCircleOutlined />}
loading={runningTarget === r.target_id}
onClick={() => handleRunOne(r as PrewarmMissingItem)}
>
</Button>
),
},
];
const donePercent = data ? Math.round((data.done / data.total) * 100) : 0;
return (
<div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}>
<div>
<Title level={4} style={{ margin: 0 }}>AI App2 </Title>
<Paragraph type="secondary" style={{ margin: 0, fontSize: 13 }}>
<code>app2_finance</code> 72 cron <code>ai_dws_prewarm_1000</code> 10:00
</Paragraph>
</div>
<Space>
<Select
value={siteId} onChange={setSiteId} style={{ width: 200 }}
options={[{ label: "默认门店 (2790685415443269)", value: 2790685415443269 }]}
/>
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}></Button>
</Space>
</div>
<Card size="small" style={{ marginBottom: 16 }}>
<Descriptions column={4} size="small">
<Descriptions.Item label="总完成度">
<Text strong style={{ fontSize: 16 }}>{data?.done ?? 0} / {data?.total ?? 72}</Text>
</Descriptions.Item>
<Descriptions.Item label="全域 (app2_finance)">
<Tag color={globalDone === 8 ? "success" : "warning"}>{globalDone} / 8</Tag>
</Descriptions.Item>
<Descriptions.Item label="区域 (app2a_finance_area)">
<Tag color={areaDone === 64 ? "success" : "warning"}>{areaDone} / 64</Tag>
</Descriptions.Item>
<Descriptions.Item label="最近更新">{fmtTime(data?.last_updated ?? null)}</Descriptions.Item>
</Descriptions>
<div style={{ marginTop: 12 }}>
<Progress
percent={donePercent}
status={donePercent === 100 ? "success" : "active"}
strokeColor={{ from: "#108ee9", to: "#87d068" }}
/>
</div>
<Space style={{ marginTop: 16 }}>
<Button
type="primary" icon={<ThunderboltOutlined />}
onClick={handleTriggerFullPrewarm}
></Button>
<Button
icon={<PlayCircleOutlined />}
disabled={!data || data.missing.length === 0}
onClick={() => setBackfillOpen(true)}
>{data?.missing.length ?? 0}</Button>
</Space>
</Card>
{backfillRunning && backfillProgress && (
<Alert
type="info" showIcon style={{ marginBottom: 16 }}
message={`补齐进行中:${backfillProgress.done}/${backfillProgress.total}${backfillProgress.current}`}
/>
)}
<Card title={`缺失组合(${data?.missing.length ?? 0} · 按全域 / 区域分组`} size="small">
<Table<MissingRowWithGroup>
columns={missingColumns}
dataSource={groupedMissing}
rowKey="target_id"
loading={loading}
pagination={{ pageSize: 30 }}
size="small"
/>
</Card>
<Modal
title="一键补齐缺失组合"
open={backfillOpen}
onCancel={() => setBackfillOpen(false)}
onOk={handleBackfillMissing}
okText="开始补齐" cancelText="取消"
>
<p> <code>POST /admin/ai/run/app2_finance</code> <strong>{data?.missing.length ?? 0}</strong> </p>
<p> 30-120 <strong>{Math.ceil((data?.missing.length ?? 0) * 1.5)}</strong>~<strong>{Math.ceil((data?.missing.length ?? 0) * 2.5)}</strong> </p>
<p style={{ color: "#d46b08" }}></p>
</Modal>
</div>
);
};
export default AIPrewarm;

View File

@@ -45,4 +45,4 @@ LOG_LEVEL=INFO
# ------------------------------------------------------------------------------
# ETL 项目路径(子进程 cwd缺省按 monorepo 相对路径推算)
# ------------------------------------------------------------------------------
# ETL_PROJECT_PATH=C:/NeoZQYY/apps/etl/connectors/feiqiu
# ETL_PROJECT_PATH=C:/Project/NeoZQYY/apps/etl/connectors/feiqiu

View File

@@ -14,17 +14,18 @@ from dataclasses import dataclass
class AIConfig:
"""AI 模块配置从环境变量加载。不可变frozen"""
api_key: str # DASHSCOPE_API_KEY
workspace_id: str | None # DASHSCOPE_WORKSPACE_ID可选
app_id_1_chat: str # DASHSCOPE_APP_ID_1_CHAT
app_id_2_finance: str # DASHSCOPE_APP_ID_2_FINANCE
app_id_3_clue: str # DASHSCOPE_APP_ID_3_CLUE
app_id_4_analysis: str # DASHSCOPE_APP_ID_4_ANALYSIS
app_id_5_tactics: str # DASHSCOPE_APP_ID_5_TACTICS
app_id_6_note: str # DASHSCOPE_APP_ID_6_NOTE
app_id_7_customer: str # DASHSCOPE_APP_ID_7_CUSTOMER
app_id_8_consolidate: str # DASHSCOPE_APP_ID_8_CONSOLIDATE
internal_api_token: str # INTERNAL_API_TOKEN
api_key: str # DASHSCOPE_API_KEY
workspace_id: str | None # DASHSCOPE_WORKSPACE_ID可选
app_id_1_chat: str # DASHSCOPE_APP_ID_1_CHAT
app_id_2_finance: str # DASHSCOPE_APP_ID_2_FINANCE
app_id_2a_finance_area: str # DASHSCOPE_APP_ID_2A_FINANCE_AREA2026-04-23 新增,区域财务洞察)
app_id_3_clue: str # DASHSCOPE_APP_ID_3_CLUE
app_id_4_analysis: str # DASHSCOPE_APP_ID_4_ANALYSIS
app_id_5_tactics: str # DASHSCOPE_APP_ID_5_TACTICS
app_id_6_note: str # DASHSCOPE_APP_ID_6_NOTE
app_id_7_customer: str # DASHSCOPE_APP_ID_7_CUSTOMER
app_id_8_consolidate: str # DASHSCOPE_APP_ID_8_CONSOLIDATE
internal_api_token: str # INTERNAL_API_TOKEN
@classmethod
def from_env(cls) -> AIConfig:
@@ -37,6 +38,7 @@ class AIConfig:
"DASHSCOPE_API_KEY": "api_key",
"DASHSCOPE_APP_ID_1_CHAT": "app_id_1_chat",
"DASHSCOPE_APP_ID_2_FINANCE": "app_id_2_finance",
"DASHSCOPE_APP_ID_2A_FINANCE_AREA": "app_id_2a_finance_area",
"DASHSCOPE_APP_ID_3_CLUE": "app_id_3_clue",
"DASHSCOPE_APP_ID_4_ANALYSIS": "app_id_4_analysis",
"DASHSCOPE_APP_ID_5_TACTICS": "app_id_5_tactics",

File diff suppressed because it is too large Load Diff

View File

@@ -1 +1,28 @@
# AI Prompt 模板子模块
"""AI 应用 Prompt 拼装模块。
8 个百炼自定义应用的后端 prompt 拼装函数集中此处。
- 所有函数返回 str直接传给 dashscope.Application.call(prompt=...)
- system prompt 在百炼控制台配置,本模块只负责拼数据上下文 JSON
- 数据源走 data_fetchers / board_service集中真实业务数据
- 失败降级:数据获取失败时拼"_data_warnings"字段,不阻断 AI 调用
"""
from app.ai.prompts.app2_finance_prompt import build_prompt as build_app2_prompt
from app.ai.prompts.app2a_finance_area_prompt import build_prompt as build_app2a_area_prompt
from app.ai.prompts.app3_clue_prompt import build_prompt as build_app3_prompt
from app.ai.prompts.app4_analysis_prompt import build_prompt as build_app4_prompt
from app.ai.prompts.app5_tactics_prompt import build_prompt as build_app5_prompt
from app.ai.prompts.app6_note_prompt import build_prompt as build_app6_prompt
from app.ai.prompts.app7_customer_prompt import build_prompt as build_app7_prompt
from app.ai.prompts.app8_consolidation_prompt import build_prompt as build_app8_prompt
__all__ = [
"build_app2_prompt",
"build_app2a_area_prompt",
"build_app3_prompt",
"build_app4_prompt",
"build_app5_prompt",
"build_app6_prompt",
"build_app7_prompt",
"build_app8_prompt",
]

View File

@@ -0,0 +1,498 @@
"""应用 2a 区域财务洞察 Prompt 拼装app2_finance 的区域派生版本)。
面向 72 组合中 area != 'all' 的 64 个组合8 时间 × 8 业态)。
差异点(相较 app2_finance
- payload 新增顶层字段:「业态说明」「区域占比」
- 派生比率精简:仅「人力成本占成交收入比」「优惠侵蚀率」(其他比率区域级无法计算)
- 单位经济区域级:支持客单价/日均订单数及环比(暂不输出会员占比,与 v1.2 system prompt H6 对齐)
- 按星期聚合区域级:无「日均现金流入」(区域级无 cash_inflow 数据)
- 日粒度异常区域级:仅对 gross_amount 做异常检测(无 cash_inflow
- 不注入:预收资产/现金流入/现金流出/储值卡余额变化(全店级字段,区域级无业务意义)
数据源:
- 主数据board_service.get_finance_board(time, area, compare=1)
- 日粒度etl 库 app.v_dws_finance_area_daily按 area_code 过滤)
- 区域占比:调用 board_service 两次(一次区域 + 一次 all后派生
"""
from __future__ import annotations
import logging
from collections import defaultdict
from datetime import datetime
from typing import Any
from app.services.board_service import _calc_date_range, _calc_prev_range, get_finance_board
# 复用 app2_finance_prompt 的公共常量与辅助函数
from app.ai.prompts.app2_finance_prompt import (
AREA_LABELS,
DIMENSION_LABELS,
DIMENSION_MAP,
INDUSTRY_BASELINES,
_aggregate_expense,
_build_coach_kpi,
_build_discount_kpi,
_slim,
_translate_keys,
_WEEKDAY_MIN_DAYS,
_ANOMALY_DEVIATION,
_ANOMALY_MAX_ITEMS,
_ANOMALY_MIN_DAYS,
_ANOMALY_MIN_SAME_WEEKDAY,
_WEEKDAY_ZH,
)
logger = logging.getLogger(__name__)
# 业态特征字典(与 v1.2 system prompt「三、业态特征」章节对齐
# trait业态的数据表征客单/订单密度/会员占比/周期规律)
# peer典型对比项给 AI 做区域对比时的参照方向)
AREA_INDUSTRY_TRAITS: dict[str, dict[str, str]] = {
"hall": {
"trait": "大厅(合并 hallA+B+C· 散客主力 · 客单价中等 · 订单密度最高 · 会员占比相对低",
"peer": "与 VIP 包厢对比单客贡献差异 · 与团购占比对比获客成本",
},
"hallA": {
"trait": "A 区大厅 · 散客主力 · 客单价中等 · 订单密度高",
"peer": "与 hallB/hallC 对比识别区位差异 · 与 hall 合计对比看单区占比",
},
"hallB": {
"trait": "B 区大厅 · 散客主力 · 客单价中等 · 订单密度高",
"peer": "与 hallA/hallC 对比识别区位差异",
},
"hallC": {
"trait": "C 区大厅(含 TV 台/美洲豹赛台)· 散客主力 · 客单价中等偏上 · 订单密度较高",
"peer": "与 hallA/hallB 对比识别区位差异",
},
"vip": {
"trait": "VIP 台球包厢 · 会员主力 · 客单价显著高于大厅 2-3 倍 · 订单密度低 · 助教服务收入占比高",
"peer": "与 hall 大厅对比单客贡献 · 与 snooker 对比高客单群体差异",
},
"snooker": {
"trait": "斯诺克 · 专业台球爱好者 · 客单价中高 · 会员占比较高 · 周末/夜场爆满",
"peer": "与 VIP 对比高端群体结构 · 与 hall 对比专业 vs 大众",
},
"mahjong": {
"trait": "麻将房 · 散客 + 小团 · 客单价高(时长计费)· 停留久 · 订单密度低 · 助教参与度极低",
"peer": "与 KTV 对比包间型业态 · 与 hall 对比客单价与时长",
},
"ktv": {
"trait": "团建房 · 团建场景 · 客单价集中在套餐 · 订单密度低 · 周末峰值明显 · 助教几乎不参与",
"peer": "与 mahjong 对比包间型业态 · 与 vip 对比高客单群体",
},
}
def _fetch_area_daily_series(
site_id: int, start_date: str, end_date: str, area_code: str,
) -> list[tuple] | None:
"""查区域级日粒度 [start, end],供单位经济/按星期/异常检测复用。
返回字段顺序:(stat_date, gross, order_count, member_order_count, confirmed)
注:区域级无 cash_inflow对齐 v1.2 H6 降级),故与全店版 series 字段少一个 cash_in。
area_code 必须为非 "all" 的具体业态编码。
"""
from app.database import get_connection
from app.services.fdw_queries import _fdw_context
try:
conn = get_connection()
except Exception:
logger.debug("区域日粒度查询连接失败", exc_info=True)
return None
try:
with _fdw_context(conn, site_id) as cur:
cur.execute(
"""
SELECT stat_date,
COALESCE(gross_amount, 0) AS gross,
COALESCE(order_count, 0) AS order_count,
COALESCE(member_order_count, 0) AS member_order_count,
COALESCE(confirmed_income, 0) AS confirmed
FROM app.v_dws_finance_area_daily
WHERE area_code = %s
AND stat_date >= %s::date
AND stat_date <= %s::date
ORDER BY stat_date
""",
(area_code, start_date, end_date),
)
rows = cur.fetchall()
except Exception:
logger.debug(
"区域日粒度查询失败: site_id=%s area=%s", site_id, area_code, exc_info=True,
)
return None
finally:
try:
conn.close()
except Exception:
pass
active = [
(r[0], float(r[1]), int(r[2] or 0), int(r[3] or 0), float(r[4] or 0))
for r in rows
if float(r[1] or 0) > 0
]
return active if active else None
def _build_area_unit_economics(
series: list[tuple] | None,
prev_series: list[tuple] | None = None,
) -> dict | None:
"""区域级单位经济:客单价 + 日均订单数(含环比)。
与全店版差异:
- 不输出「会员订单占比」(对齐 v1.2 system prompt H6 · 等 DWS 回填完成 + A/B 评估后再开放)
- series 字段顺序:(stat_date, gross, order_count, member_order_count, confirmed)
月初场景(上期样本 < 5 天)附加"样本不足"后缀让 AI 降权引用。
"""
if not series:
return None
total_orders = sum(r[2] for r in series)
if total_orders <= 0:
return None
total_gross = sum(r[1] for r in series)
total_confirmed = sum(r[4] for r in series)
days = len(series)
price_confirmed = total_confirmed / total_orders
price_gross = total_gross / total_orders
daily_orders = total_orders / days
out: dict[str, Any] = {
"总订单数": total_orders,
"日均订单数": round(daily_orders, 1),
"客单价_按成交收入": round(price_confirmed, 2),
"客单价_按发生额": round(price_gross, 2),
}
if prev_series:
prev_orders = sum(r[2] for r in prev_series)
if prev_orders > 0:
prev_days = len(prev_series)
prev_gross = sum(r[1] for r in prev_series)
prev_confirmed = sum(r[4] for r in prev_series)
low_sample = prev_days < 5
def _pct_change(cur: float, prev: float) -> str:
if prev <= 0:
return "无上期数据"
value = f"{(cur - prev) / prev * 100:+.1f}%"
return f"{value}(上期仅 {prev_days} 天,样本不足仅供参考)" if low_sample else value
out["客单价_按成交收入_环比"] = _pct_change(price_confirmed, prev_confirmed / prev_orders)
out["客单价_按发生额_环比"] = _pct_change(price_gross, prev_gross / prev_orders)
out["日均订单数_环比"] = _pct_change(daily_orders, prev_orders / prev_days)
return out
def _aggregate_by_weekday_area(series: list[tuple] | None) -> dict | None:
"""区域级按星期聚合(无现金流入字段)。
series 字段顺序:(stat_date, gross, order_count, member_order_count, confirmed)
"""
if not series or len(series) < _WEEKDAY_MIN_DAYS:
return None
buckets: dict[int, list[tuple]] = defaultdict(list)
for row in series:
buckets[row[0].weekday()].append(row)
out: dict[str, dict] = {}
for wd in range(7):
rows = buckets.get(wd) or []
if not rows:
continue
n = len(rows)
out[_WEEKDAY_ZH[wd]] = {
"日均发生额": round(sum(r[1] for r in rows) / n, 2),
"日均订单数": round(sum(r[2] for r in rows) / n, 1),
"营业日数": n,
}
return out or None
def _detect_anomaly_days_area(
series: list[tuple] | None,
) -> list[dict] | None:
"""区域级日粒度异常(仅对 gross_amount 做,无现金流入)。
series 字段顺序:(stat_date, gross, order_count, member_order_count, confirmed)
"""
if not series or len(series) < _ANOMALY_MIN_DAYS:
return None
def _scan(idx: int, label: str) -> list[dict]:
vals = [row[idx] for row in series]
global_mean = sum(vals) / len(vals)
if global_mean <= 0:
return []
by_weekday: dict[int, list[float]] = defaultdict(list)
for d, *metrics in series:
by_weekday[d.weekday()].append(metrics[idx - 1])
weekday_mean: dict[int, float] = {
wd: (sum(xs) / len(xs)) for wd, xs in by_weekday.items()
}
flagged: list[dict] = []
for d, *metrics in series:
v = metrics[idx - 1]
wd = d.weekday()
same_count = len(by_weekday.get(wd, []))
if same_count >= _ANOMALY_MIN_SAME_WEEKDAY and weekday_mean[wd] > 0:
base = weekday_mean[wd]
base_label = f"{_WEEKDAY_ZH[wd]}均值"
else:
base = global_mean
base_label = "期均"
deviation = (v - base) / base
if abs(deviation) >= _ANOMALY_DEVIATION:
flagged.append({
"日期": f"{d} {_WEEKDAY_ZH[wd]}",
"指标": label,
"当日": round(v, 2),
"基线": round(base, 2),
"基线类型": base_label,
"偏离": f"{deviation * 100:+.1f}%",
"_abs_dev": abs(deviation),
})
return flagged
candidates = _scan(1, "发生额") # 区域级仅发生额做异常(无现金流入)
if not candidates:
return None
candidates.sort(key=lambda x: x["_abs_dev"], reverse=True)
out = []
for c in candidates[:_ANOMALY_MAX_ITEMS]:
c.pop("_abs_dev", None)
out.append(c)
return out
async def _fetch_area_share(
site_id: int, time_dimension: str, area_confirmed: float,
) -> dict | None:
"""查全店成交收入 + 上期全店成交收入,派生「区域占比」字段。
返回:{本区域成交收入, 占全店成交收入, 占比环比}
失败或数据不足返回 None。
"""
board_time = DIMENSION_MAP.get(time_dimension)
if not board_time:
return None
try:
all_board = await get_finance_board(
time=board_time, area="all", compare=1, site_id=site_id,
)
except Exception:
logger.debug("区域占比·全店数据查询失败", exc_info=True)
return None
all_overview = (all_board or {}).get("overview") or {}
all_confirmed = float(all_overview.get("confirmed_revenue") or 0)
if all_confirmed <= 0:
return None
share = area_confirmed / all_confirmed
out: dict[str, Any] = {
"本区域成交收入": round(area_confirmed, 2),
"全店成交收入": round(all_confirmed, 2),
"占全店成交收入": f"{share * 100:.1f}%",
}
# 环比:上期区域占比(本轮简化:若 all 的 confirmed_revenue_compare 可用,则给出"全店环比参照"让 AI 自己对比)
# 本区域占比环比 = (本期区域占比 上期区域占比),需查上期 area board为避免额外 DB 访问,暂只给出本期占比
return out
def _build_area_derived_ratios(
overview: dict | None, coach_kpi: dict | None, discount_kpi: dict | None,
) -> dict | None:
"""区域级派生比率:仅「人力成本占成交收入比」「优惠侵蚀率」。
其他比率(储值卡占比/结余率)区域级无数据,不输出。
"""
if not isinstance(overview, dict):
return None
confirmed = float(overview.get("confirmed_revenue") or 0)
ratios: dict[str, Any] = {}
if coach_kpi and confirmed > 0:
total_pay = float(coach_kpi.get("人力薪酬合计") or 0)
if total_pay > 0:
ratios["人力成本占成交收入比"] = round(total_pay / confirmed, 4)
if discount_kpi and confirmed > 0:
total_discount = float(discount_kpi.get("总优惠") or 0)
gross = float(overview.get("occurrence") or 0)
if gross > 0:
ratios["优惠侵蚀率"] = round(total_discount / gross, 4)
return ratios or None
async def build_prompt(
context: dict,
cache_svc: Any | None = None, # 兼容统一签名
) -> str:
"""构建 app2a 区域财务洞察 prompt 字符串。
Args:
context: site_id, time_dimension, areaarea != 'all'
Returns:
JSON 序列化的 prompt 字符串,字段已翻译为中文。
Raises:
ValueError: time_dimension 不支持 · area 为 'all' · area 不在白名单
"""
import json
site_id = context["site_id"]
time_dimension = context["time_dimension"]
area = context.get("area")
if area == "all":
raise ValueError("app2a_finance_area 仅处理区域组合 · area='all' 应走 app2_finance")
if area not in AREA_LABELS:
raise ValueError(f"app2a_finance_area 不支持的区域: {area}")
board_time = DIMENSION_MAP.get(time_dimension)
if not board_time:
raise ValueError(f"app2a_finance_area 不支持的时间维度: {time_dimension}")
try:
board_data = await get_finance_board(
time=board_time, area=area, compare=1, site_id=site_id,
)
except Exception:
logger.warning(
"app2a 财务看板查询失败: site_id=%s dimension=%s area=%s",
site_id, time_dimension, area, exc_info=True,
)
board_data = {}
overview = board_data.get("overview") if isinstance(board_data, dict) else None
revenue = board_data.get("revenue") if isinstance(board_data, dict) else None
coach = board_data.get("coach_analysis") if isinstance(board_data, dict) else None
expense = board_data.get("expense") if isinstance(board_data, dict) else None
discount_kpi = _build_discount_kpi(revenue, overview)
coach_kpi = _build_coach_kpi(coach)
expense_kpi = _aggregate_expense(expense)
ratios = _build_area_derived_ratios(overview, coach_kpi, discount_kpi)
# 原始数据 slim 后翻译,供 AI 追溯细节
slim_data = _slim(board_data) or {}
raw_cn = _translate_keys(slim_data)
# 对比口径(所有环比字段的前置依赖 · H1
compare_caliber: dict[str, Any] | None = None
try:
cur_start, cur_end = _calc_date_range(board_time)
prev_start, prev_end = _calc_prev_range(board_time, cur_start, cur_end)
cur_days = (cur_end - cur_start).days + 1
prev_days = (prev_end - prev_start).days + 1
compare_caliber = {
"当期范围": f"{cur_start} ~ {cur_end}{cur_days} 天)",
"对比期范围": f"{prev_start} ~ {prev_end}{prev_days} 天)",
"对齐方式": "上期同天数对齐(非整月/整周对比)",
"说明": "所有 _环比 / _compare 字段均按上表口径计算;月中调用时对比期会自动截断到与当期相同天数",
}
except Exception:
logger.debug("对比口径字段生成失败(不影响主流程)", exc_info=True)
# 业态说明v1.2 system prompt H7 引用依据)
trait_info = AREA_INDUSTRY_TRAITS.get(area, {})
industry_brief = {
"区域编码": area,
"区域名称": AREA_LABELS.get(area, area),
"业态特征": trait_info.get("trait", ""),
"典型对比项": trait_info.get("peer", ""),
}
payload: dict[str, Any] = {
"当前时间": datetime.now().strftime("%Y-%m-%d %H:%M"),
"门店编号": site_id,
"时间维度": DIMENSION_LABELS.get(time_dimension, time_dimension),
"区域": AREA_LABELS.get(area, area),
**({"对比口径": compare_caliber} if compare_caliber else {}),
"业态说明": industry_brief,
"核心KPI": {
"发生额": float((overview or {}).get("occurrence") or 0),
"发生额环比": (overview or {}).get("occurrence_compare") or "持平",
"成交收入": float((overview or {}).get("confirmed_revenue") or 0),
"成交收入环比": (overview or {}).get("confirmed_revenue_compare") or "持平",
# 区域级无现金流入数据v1.2 H6 降级),不输出现金相关 KPI
},
}
# 派生比率(仅 2 项)
if ratios:
payload["派生比率"] = ratios
# 区域占比(需异步查全店)
area_confirmed = float((overview or {}).get("confirmed_revenue") or 0)
if area_confirmed > 0:
area_share = await _fetch_area_share(site_id, time_dimension, area_confirmed)
if area_share:
payload["区域占比"] = area_share
# 优惠构成(复用全店版逻辑)
if discount_kpi:
payload["优惠构成"] = discount_kpi
# 助教成本画像(复用全店版逻辑 · 空则整块不注入 · 符合 v1.2 H6
if coach_kpi:
payload["助教成本"] = coach_kpi
# 支出概况区域级仅助教支出有效v1.2 禁谈运营/固定/平台支出 · 但注入给 AI 追溯)
# 注v1.2 system prompt 明确要求 D 板块禁谈这三类AI 自会规避
if expense_kpi:
payload["支出概况"] = expense_kpi
# 日粒度派生(区域级)
try:
start_date, end_date = _calc_date_range(board_time)
series = _fetch_area_daily_series(
site_id, str(start_date), str(end_date), area_code=area,
)
prev_series: list[tuple] | None = None
try:
prev_start, prev_end = _calc_prev_range(board_time, start_date, end_date)
prev_series = _fetch_area_daily_series(
site_id, str(prev_start), str(prev_end), area_code=area,
)
except Exception:
logger.debug("区域上期 series 查询失败,客单价环比字段将省略", exc_info=True)
if series:
unit_econ = _build_area_unit_economics(series, prev_series=prev_series)
if unit_econ:
payload["单位经济"] = unit_econ
by_weekday = _aggregate_by_weekday_area(series)
if by_weekday:
payload["按星期聚合"] = by_weekday
anomalies = _detect_anomaly_days_area(series)
if anomalies:
payload["日粒度异常"] = anomalies
except Exception:
logger.debug("区域日粒度派生字段注入失败(不影响主流程)", exc_info=True)
# 行业基线
payload["行业基线"] = INDUSTRY_BASELINES
# 原始指标slim 后的区域子集)
payload["原始指标"] = raw_cn
if not board_data:
payload["数据缺失提示"] = "区域财务看板数据获取失败,请基于已有缓存或常识分析"
return json.dumps(payload, ensure_ascii=False, default=str)

View File

@@ -37,6 +37,7 @@ class SSEEvent(BaseModel):
class CacheTypeEnum(str, enum.Enum):
APP2_FINANCE = "app2_finance"
APP2A_FINANCE_AREA = "app2a_finance_area" # 2026-04-23 新增区域财务洞察64 组合)
APP3_CLUE = "app3_clue"
APP4_ANALYSIS = "app4_analysis"
APP5_TACTICS = "app5_tactics"

View File

@@ -11,7 +11,7 @@ from pathlib import Path
from dotenv import load_dotenv
# CHANGE 2026-03-07 | 项目根目录定位:防止 junction/symlink 穿透到 D 盘
# 背景C:\NeoZQYY 是 junction → D:\NeoZQYY\...\repo
# 背景C:\Project\NeoZQYY 是 junction → D:\NeoZQYY\...\repo
# Path(__file__).resolve() 和 absolute() 都可能解析到 D 盘,
# 导致加载 D 盘的 .env路径全指向 D 盘ETL 命令因此携带错误路径。
# 策略:环境变量 > 已知固定路径 > __file__ 推算(最后手段)

View File

@@ -27,8 +27,8 @@ from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from app.auth.dependencies import CurrentUser
from app.middleware.permission import require_permission
from app.auth.dependencies import CurrentUser, get_current_user
from app.middleware.permission import require_permission # 保留给可能的其他依赖
from app.schemas.admin_ai import (
AlertActionResponse,
AlertListResponse,
@@ -40,11 +40,18 @@ from app.schemas.admin_ai import (
CacheInvalidateRequest,
CacheInvalidateResponse,
DashboardResponse,
ManualTriggerRequest,
ManualTriggerResponse,
PrewarmProgressResponse,
RetryResponse,
RunAppRequest,
RunAppResponse,
RunLogDetailResponse,
RunLogListResponse,
TriggerItem,
TriggerJobDetailResponse,
TriggerJobListResponse,
TriggerUpdateRequest,
)
from app.services.ai.admin_service import AdminAIService
@@ -62,18 +69,43 @@ _admin_svc = AdminAIService()
def _require_admin():
"""
管理端依赖:要求 JWT status=approved 且角色包含 site_admin tenant_admin。
管理端依赖:直接从 JWT 读 roles 判定是否 adminsite_admin / tenant_admin / super_admin
2026-04-21改为不依赖 auth.users.status 查询admin-web 登录用 admin_users 表,
与 require_permission 走的 auth.users 不是同一张表。status 实时校验通过 admin_users.is_active。
"""
async def _dependency(
user: CurrentUser = Depends(require_permission()),
user: CurrentUser = Depends(get_current_user),
) -> CurrentUser:
admin_roles = {"site_admin", "tenant_admin"}
admin_roles = {"site_admin", "tenant_admin", "super_admin"}
if not admin_roles.intersection(user.roles):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限site_admin tenant_admin",
detail="需要管理员权限site_admin / tenant_admin / super_admin",
)
# 实时校验 admin_users 表的 is_active若 user_id 在该表)
from app.database import get_connection
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"SELECT is_active FROM admin_users WHERE id = %s",
(user.user_id,),
)
row = cur.fetchone()
conn.commit()
finally:
conn.close()
# 在 admin_users 中找到且未激活 → 拒绝
if row is not None and not row[0]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="管理员账号已禁用",
)
# 不在 admin_users 中但 JWT 带 admin 角色(如 xcx 用户临时升权),也允许通过
return user
return _dependency
@@ -85,10 +117,18 @@ def _require_admin():
@router.get("/dashboard", response_model=DashboardResponse)
async def get_dashboard(
site_id: Optional[int] = Query(None, description="门店 ID 筛选"),
range_days: Optional[int] = Query(None, ge=1, le=365, description="回溯天数1=今日 / 3 / 7 / 10"),
date_from: Optional[str] = Query(None, description="起始日期 YYYY-MM-DD与 date_to 成对使用)"),
date_to: Optional[str] = Query(None, description="结束日期 YYYY-MM-DD"),
user: CurrentUser = Depends(_require_admin()),
) -> DashboardResponse:
"""总览统计(支持 site_id 筛选)。"""
data = await _admin_svc.get_dashboard(site_id=site_id)
"""总览统计(支持 site_id + 时间范围筛选)。"""
data = await _admin_svc.get_dashboard(
site_id=site_id,
range_days=range_days,
date_from=date_from,
date_to=date_to,
)
return DashboardResponse(**data)
@@ -292,3 +332,152 @@ async def ignore_alert(
"""忽略告警alert_status → ignored。"""
new_status = await _admin_svc.ignore_alert(log_id)
return AlertActionResponse(id=log_id, alert_status=new_status)
# ── 按需执行单个 Appadmin-web 重新生成按钮用)──────────
_SUPPORTED_APP_TYPES = {
"app2_finance",
"app2a_finance_area", # 2026-04-23 新增:区域财务洞察
"app3_clue",
"app4_analysis",
"app5_tactics",
"app6_note",
"app7_customer",
"app8_consolidation",
}
@router.post("/run/{app_type}", response_model=RunAppResponse)
async def run_single_app(
app_type: str,
body: RunAppRequest,
user: CurrentUser = Depends(_require_admin()),
) -> RunAppResponse:
"""按需执行单个 App跳过链路编排。
使用场景admin-web 缓存详情页 / 告警页的"重新生成"按钮。
熔断/限流/预算检查由 dispatcher._run_step 自动执行。
结果写入 ai_cache失败不抛异常通过 success=False 返回。
"""
if app_type not in _SUPPORTED_APP_TYPES:
raise HTTPException(
status_code=400,
detail=f"不支持的 app_type: {app_type};支持 {sorted(_SUPPORTED_APP_TYPES)}",
)
from app.ai.dispatcher import get_dispatcher
try:
dispatcher = get_dispatcher()
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
context = body.model_dump(exclude_none=True)
try:
result = await dispatcher.run_single_app(
app_type=app_type,
context=context,
triggered_by=f"admin:{user.user_id}",
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if result is None:
return RunAppResponse(
app_type=app_type,
success=False,
error="AI 调用失败(详情见 ai_run_logs可能为熔断/限流/预算/超时)",
)
return RunAppResponse(app_type=app_type, success=True, result=result)
# ── 触发器管理biz.trigger_jobs─────────────────────────
@router.get("/triggers", response_model=list[TriggerItem])
async def list_triggers(
_user: CurrentUser = Depends(_require_admin()),
) -> list[TriggerItem]:
"""列出所有 AI 相关触发器job_type=ai_* 或 task_generator"""
rows = await _admin_svc.list_triggers()
return [TriggerItem(**r) for r in rows]
@router.patch("/triggers/{trigger_id}", response_model=TriggerItem)
async def update_trigger(
trigger_id: int,
body: TriggerUpdateRequest,
_user: CurrentUser = Depends(_require_admin()),
) -> TriggerItem:
"""更新触发器:启用/禁用、修改 cron 表达式、修改描述。"""
try:
row = await _admin_svc.update_trigger(
trigger_id,
status_new=body.status,
cron_expression=body.cron_expression,
description=body.description,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return TriggerItem(**row)
# ── 预热进度查询 ─────────────────────────────────────────
@router.get("/prewarm/progress", response_model=PrewarmProgressResponse)
async def get_prewarm_progress(
site_id: int = Query(..., description="门店 ID"),
_user: CurrentUser = Depends(_require_admin()),
) -> PrewarmProgressResponse:
"""查询 app2_finance 72 组合预热进度done / missing"""
data = await _admin_svc.get_prewarm_progress(site_id)
return PrewarmProgressResponse(**data)
# ── 手动事件触发(跨越去重)──────────────────────────────
@router.post("/trigger-event", response_model=ManualTriggerResponse)
async def manual_trigger_event(
body: ManualTriggerRequest,
user: CurrentUser = Depends(_require_admin()),
) -> ManualTriggerResponse:
"""手动触发 AI 事件链,默认 is_forced=True 跳过去重。
事件类型consumption / dws_completed / note_created / task_assigned
"""
from app.ai.dispatcher import TriggerEvent, get_dispatcher
valid_events = {"consumption", "dws_completed", "note_created", "task_assigned"}
if body.event_type not in valid_events:
raise HTTPException(
status_code=400,
detail=f"非法 event_type: {body.event_type};支持 {sorted(valid_events)}",
)
try:
dispatcher = get_dispatcher()
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
payload = dict(body.payload or {})
if body.assistant_id is not None:
payload.setdefault("assistant_id", body.assistant_id)
event = TriggerEvent(
event_type=body.event_type,
site_id=body.site_id,
member_id=body.member_id,
payload=payload,
is_forced=body.is_forced,
)
logger.info(
"admin 手动触发事件: user=%s event=%s site_id=%s member_id=%s forced=%s",
user.user_id, body.event_type, body.site_id, body.member_id, body.is_forced,
)
job_id = await dispatcher.handle_trigger(event)
return ManualTriggerResponse(trigger_job_id=job_id)

View File

@@ -14,13 +14,27 @@ from fastapi import APIRouter, Depends
from app.auth.dependencies import CurrentUser
from app.middleware.permission import require_permission
from app.schemas.xcx_coaches import CoachDetailResponse
from app.schemas.xcx_coaches import CoachBannerResponse, CoachDetailResponse
from app.services import coach_service
from app.trace.decorators import trace_service
router = APIRouter(prefix="/api/xcx/coaches", tags=["小程序助教"])
@router.get("/{coach_id}/banner", response_model=CoachBannerResponse)
@trace_service("获取助教 banner", "Get coach banner")
async def get_coach_banner(
coach_id: int,
user: CurrentUser = Depends(require_permission("view_board_coach")),
):
"""
助教 banner 轻量信息(仅 name / level / store_name
比 /{coach_id} 快一个数量级,供 PERF-2 等只需 banner 的页面调用。
"""
return await coach_service.get_coach_banner(coach_id, user.site_id)
@router.get("/{coach_id}", response_model=CoachDetailResponse)
@trace_service("获取助教详情", "Get coach detail")
async def get_coach_detail(

View File

@@ -11,7 +11,7 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, HTTPException, Query
from app.auth.dependencies import CurrentUser
from app.middleware.permission import require_approved, require_permission
@@ -20,6 +20,7 @@ from app.schemas.xcx_performance import (
PerformanceRecordsResponse,
)
from app.services import performance_service
from app.services.role import get_user_permissions
from app.trace.decorators import trace_service
router = APIRouter(prefix="/api/xcx/performance", tags=["小程序绩效"])
@@ -46,9 +47,29 @@ async def get_performance_records(
month: int = Query(..., ge=1, le=12),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
user: CurrentUser = Depends(require_permission("view_tasks")),
coach_id: int | None = Query(None, description="目标助教 ID仅管理员可用"),
user: CurrentUser = Depends(require_approved()),
):
"""绩效明细PERF-2"""
"""
绩效明细PERF-2
权限分流(请求路径):
- 不带 coach_id查自己要求 view_tasks 权限assistant_id 由 user 绑定决定
- 带 coach_id查他人要求 view_board_coach 权限manager/head_coach/staff
assistant_id 直接用传入值;同 site 由 user.site_id 隐式约束
"""
user_perms = await get_user_permissions(user.user_id, user.site_id)
if coach_id is None:
if "view_tasks" not in user_perms:
raise HTTPException(status_code=403, detail="权限不足")
return await performance_service.get_records(
user.user_id, user.site_id, year, month, page, page_size,
)
if "view_board_coach" not in user_perms:
raise HTTPException(status_code=403, detail="权限不足")
return await performance_service.get_records(
user.user_id, user.site_id, year, month, page, page_size
user.user_id, user.site_id, year, month, page, page_size,
assistant_id_override=coach_id,
)

View File

@@ -8,12 +8,29 @@ from app.schemas.base import CamelModel
class PerformanceMetrics(CamelModel):
monthly_hours: float
monthly_salary: float
customer_balance: float
tasks_completed: int
perf_current: float
perf_target: float
"""绩效概览 -- 与任务页 PerformanceSummary 统一数据源monthly_summary 实时值)。"""
# 核心绩效字段(来自 build_performance_summary与任务页一致
total_hours: float = 0
total_income: float = 0
total_customers: int = 0
month_label: str = ""
tier_nodes: list[float] = []
basic_hours: float = 0
bonus_hours: float = 0
current_tier: int = 0
next_tier_hours: float = 0
tier_completed: bool = False
bonus_money: float = 0
income_trend: str = ""
income_trend_dir: str = "up"
prev_month: str = ""
current_tier_label: str = ""
# 助教详情页专属扩展字段
customer_balance: float = 0
tasks_completed: int = 0
# 兼容旧字段名(前端渐进适配)
monthly_hours: float = 0
monthly_salary: float = 0
class IncomeItem(CamelModel):
@@ -56,6 +73,7 @@ class TopCustomer(CamelModel):
# CHANGE 2026-03-29 | str → float后端返回原始数字前端 WXS 格式化(避免 NaN
balance: float
consume: float
is_scattered: bool = False # 散客标识,前端据此置灰名称
class CoachServiceRecord(CamelModel):
@@ -71,6 +89,7 @@ class CoachServiceRecord(CamelModel):
income: float
date: str
perf_hours: str | None = None
is_scattered: bool = False # 散客标识,前端据此置灰名称
class HistoryMonth(CamelModel):
@@ -94,6 +113,20 @@ class CoachNoteItem(CamelModel):
created_at: str
class CoachTaskStats(CamelModel):
"""当月任务完成统计(回访/召回分类)。"""
callback: int = 0 # follow_up_visit 完成数
recall: int = 0 # high_priority_recall + priority_recall 完成数
class CoachBannerResponse(CamelModel):
"""助教 banner 轻量响应(仅 name / level / store_name用于 PERF-2 等只需 banner 的页面。"""
id: int
name: str
level: str = ""
store_name: str = ""
class CoachDetailResponse(CamelModel):
"""COACH-1 响应。"""
# 基础信息
@@ -101,6 +134,8 @@ class CoachDetailResponse(CamelModel):
name: str
avatar: str
level: str
# 门店名称:跟随被查看助教所在门店,供小程序 banner 展示
store_name: str = ""
skills: list[str] = []
work_years: float = 0
customer_count: int = 0
@@ -111,6 +146,8 @@ class CoachDetailResponse(CamelModel):
income: IncomeSection
# 档位
tier_nodes: list[float] = []
# 当月任务完成统计
task_stats: CoachTaskStats = CoachTaskStats()
# 任务分组
visible_tasks: list[CoachTaskItem] = []
hidden_tasks: list[CoachTaskItem] = []

View File

@@ -64,6 +64,7 @@ class ConsumptionRecord(CamelModel):
coaches: list[CoachServiceItem] = []
food_amount: float | None = None
food_orig_price: float | None = None
food_detail: str | None = None
total_amount: float
total_orig_price: float | None = None
pay_method: str | None = None
@@ -76,6 +77,8 @@ class RetentionClue(CamelModel):
class CustomerNote(CamelModel):
id: int
tag_label: str
creator_name: str = ""
creator_role: str = ""
created_at: str
content: str

View File

@@ -21,6 +21,7 @@ class DateGroupRecord(CamelModel):
member_id: int | None = None # 前端用于计算头像颜色
avatar_char: str | None = None # PERF-1 返回PERF-2 不返回
heart_score: float | None = None # RS 分数,前端用于 heart-icon 组件
is_scattered: bool = False # 散客member_id ≤ 0标识前端据此置灰
time_range: str
hours: str
course_type: str

View File

@@ -406,9 +406,9 @@ def _query_coach_tasks(
"""
查询助教任务完成数BOARD-1 task 维度)。
CHANGE 2026-04-08 | Fix-13 改造
- recall: 广义召回数(从 biz.recall_events 统计,按天去重,不重复叠加)
- callback: 回访完成数(从 biz.coach_tasks 统计status='completed'
CHANGE 2026-04-08 | Fix-13 改造
CHANGE 2026-04-13 | 狭义召回recall 改为从 coach_tasks 统计 status='completed'
不再使用 recall_events广义。recall + callback 统一口径。
"""
if not assistant_ids:
return {}
@@ -416,41 +416,27 @@ def _query_coach_tasks(
result: dict[int, dict] = {}
try:
with conn.cursor() as cur:
# 广义召回数:从 recall_events 统计(天然去重)
# 义召回+回访完成数:coach_tasks 统计status='completed' 表示助教亲自完成
cur.execute(
"""
SELECT assistant_id, COUNT(*) AS recall_count
FROM biz.recall_events
WHERE assistant_id = ANY(%s)
AND site_id = %s
AND pay_time >= %s::date
AND pay_time < (%s::date + INTERVAL '1 day')
GROUP BY assistant_id
""",
(assistant_ids, site_id, start_date, end_date),
)
for row in cur.fetchall():
result.setdefault(row[0], {"recall": 0, "callback": 0})
result[row[0]]["recall"] = row[1] or 0
# 回访完成数:从 coach_tasks 统计
cur.execute(
"""
SELECT assistant_id, COUNT(*) AS callback_count
SELECT assistant_id, task_type, COUNT(*) AS cnt
FROM biz.coach_tasks
WHERE assistant_id = ANY(%s)
AND site_id = %s
AND completed_at >= %s::date
AND completed_at < (%s::date + INTERVAL '1 day')::timestamptz
AND status = 'completed'
AND task_type = 'follow_up_visit'
GROUP BY assistant_id
GROUP BY assistant_id, task_type
""",
(assistant_ids, site_id, start_date, end_date),
)
for row in cur.fetchall():
result.setdefault(row[0], {"recall": 0, "callback": 0})
result[row[0]]["callback"] = row[1] or 0
aid, task_type, cnt = row[0], row[1], row[2] or 0
result.setdefault(aid, {"recall": 0, "callback": 0})
if task_type in ("high_priority_recall", "priority_recall"):
result[aid]["recall"] += cnt
elif task_type == "follow_up_visit":
result[aid]["callback"] += cnt
conn.commit()
except Exception:

View File

@@ -25,6 +25,7 @@ from decimal import Decimal
from app.services import fdw_queries
from app.services.task_generator import compute_heart_icon
from app.services.task_manager import build_performance_summary
from app.trace.decorators import trace_service
logger = logging.getLogger(__name__)
@@ -87,6 +88,51 @@ def _format_currency(amount: float) -> str:
# ── 6.1 核心函数 ──────────────────────────────────────────
@trace_service("获取助教 banner", "Get coach banner")
async def get_coach_banner(coach_id: int, site_id: int) -> dict:
"""
助教 banner 轻量信息(仅 name / level / store_name
用途:小程序需要展示助教 banner 但不需要详情页全套数据时
(如 PERF-2 业绩明细页 banner。比 get_coach_detail 快一个数量级
(仅 2~3 条 SQL跳过绩效/TOP/服务记录/任务/备注/历史月份)。
"""
conn = _get_biz_connection()
try:
# 1. name + level来自 v_dim_assistant + level_map
info = fdw_queries.get_assistant_info(conn, site_id, coach_id)
if not info:
raise HTTPException(status_code=404, detail="助教不存在")
# 2. store_name来自业务库 biz.sites
store_name = ""
try:
with conn.cursor() as cur:
cur.execute(
"SELECT site_name FROM biz.sites WHERE site_id = %s",
(site_id,),
)
row = cur.fetchone()
if row:
store_name = row[0] or ""
conn.commit()
except Exception:
logger.warning("查询 store_name 失败,降级为空", exc_info=True)
try:
conn.rollback()
except Exception:
pass
return {
"id": coach_id,
"name": info.get("name", ""),
"level": info.get("level", ""),
"store_name": store_name,
}
finally:
conn.close()
@trace_service("获取助教详情", "Get coach detail")
async def get_coach_detail(coach_id: int, site_id: int) -> dict:
"""
@@ -103,14 +149,35 @@ async def get_coach_detail(coach_id: int, site_id: int) -> dict:
now = datetime.date.today()
# 绩效数据(当月
salary_this = fdw_queries.get_salary_calc(
conn, site_id, coach_id, now.year, now.month
)
if not salary_this:
salary_this = {}
# 门店名称(用于小程序 banner 展示,跟随被查看助教所在门店
# 必须在所有 fdw 查询前执行:后续任意 fdw 查询失败会污染事务
# psycopg2 的 InFailedSqlTransaction导致此处 SELECT 拿不到结果。
store_name = ""
try:
with conn.cursor() as cur:
cur.execute(
"SELECT site_name FROM biz.sites WHERE site_id = %s",
(site_id,),
)
row = cur.fetchone()
if row:
store_name = row[0] or ""
conn.commit()
except Exception:
logger.warning("查询 store_name 失败,降级为空", exc_info=True)
try:
conn.rollback()
except Exception:
pass
# customerBalance该助教所有客户余额合计
# 绩效数据:统一使用 build_performance_summary与任务页同源数据来自 monthly_summary 实时值)
try:
perf_summary = build_performance_summary(conn, site_id, coach_id)
except Exception:
logger.warning("build_performance_summary 失败,降级为空", exc_info=True)
perf_summary = {}
# customerBalance该助教所有客户余额合计绩效概览之外的扩展数据
customer_balance = 0.0
try:
top_custs = fdw_queries.get_coach_top_customers(conn, site_id, coach_id, limit=1000)
@@ -121,76 +188,65 @@ async def get_coach_detail(coach_id: int, site_id: int) -> dict:
except Exception:
logger.warning("查询 customerBalance 失败,降级为 0", exc_info=True)
# tasksCompleted当月已完成任务数
# tasksCompleted + taskStats:当月已完成任务数,按类型分组
# tasksCompleted + taskStats当月已完成任务数狭义助教亲自完成不含 resolved
tasks_completed = 0
task_stats = {"callback": 0, "recall": 0}
try:
month_start = now.replace(day=1)
with conn.cursor() as cur:
cur.execute(
"""
SELECT COUNT(*)
SELECT task_type, COUNT(*) AS cnt
FROM biz.coach_tasks
WHERE assistant_id = %s
AND status = 'completed'
AND updated_at >= %s
GROUP BY task_type
""",
(coach_id, month_start),
)
row = cur.fetchone()
tasks_completed = row[0] if row else 0
for row in cur.fetchall():
task_type, cnt = row[0], row[1]
tasks_completed += cnt
if task_type == "follow_up_visit":
task_stats["callback"] += cnt
elif task_type in ("high_priority_recall", "priority_recall"):
task_stats["recall"] += cnt
except Exception:
logger.warning("查询 tasksCompleted 失败,降级为 0", exc_info=True)
# customerCount不重复客户数(从 top_customers 获取)
customer_count = 0
try:
cc_map = fdw_queries.get_monthly_customer_count(
conn, site_id, coach_id, [now.strftime("%Y-%m-01")]
)
customer_count = sum(cc_map.values())
except Exception:
logger.warning("查询 customerCount 失败,降级为 0", exc_info=True)
# customerCount从绩效概览获取,回退到独立查询
customer_count = perf_summary.get("total_customers", 0)
if not customer_count:
try:
cc_map = fdw_queries.get_monthly_customer_count(
conn, site_id, coach_id, [now.strftime("%Y-%m-01")]
)
customer_count = sum(cc_map.values())
except Exception:
logger.warning("查询 customerCount 失败,降级为 0", exc_info=True)
# 构建 performance 字段:合并绩效概览 + 助教详情专属扩展字段
performance = {
"monthly_hours": salary_this.get("total_hours", 0.0),
# CHANGE 2026-03-26 | 到手 = base_income + bonus_income + bonus_money + room_incomeDWS 层已扣抽成
"monthly_salary": (
salary_this.get("assistant_pd_money_total", 0.0)
+ salary_this.get("assistant_cx_money_total", 0.0)
+ salary_this.get("bonus_money", 0.0)
+ salary_this.get("room_income", 0.0)
),
**perf_summary,
# 助教详情页专属字段(绩效概览中没有的
"customer_balance": customer_balance,
"tasks_completed": tasks_completed,
"perf_current": salary_this.get("total_hours", 0.0),
# CHANGE 2026-03-19 | perf_target 从 tier_nodes 推算,不再依赖 salary_calc 的硬编码 0
"perf_target": 0.0, # 占位,下方用 tier_nodes 覆盖
# 兼容旧字段名(前端渐进适配)
"monthly_hours": perf_summary.get("total_hours", 0.0),
"monthly_salary": perf_summary.get("total_income", 0.0),
}
# ── 扩展模块(独立 try/except 优雅降级)──
# 收入明细 + 档位
# 收入明细
try:
income = _build_income(conn, site_id, coach_id, now)
except Exception:
logger.warning("构建 income 失败,降级为空", exc_info=True)
income = {"this_month": [], "last_month": []}
try:
tier_nodes = _build_tier_nodes(conn, site_id)
except Exception:
logger.warning("构建 tierNodes 失败,降级为 fallback", exc_info=True)
tier_nodes = list(_FALLBACK_TIER_NODES)
# CHANGE 2026-03-19 | 用 tier_nodes 推算 perf_target下一档 min_hours
current_hours = performance["perf_current"]
perf_target = tier_nodes[-1] if tier_nodes else 0.0 # 默认最高档
for node in tier_nodes:
if node > current_hours:
perf_target = node
break
performance["perf_target"] = perf_target
# TOP 客户
try:
top_customers = _build_top_customers(conn, site_id, coach_id)
@@ -231,17 +287,20 @@ async def get_coach_detail(coach_id: int, site_id: int) -> dict:
"id": coach_id,
"name": assistant_info.get("name", ""),
"avatar": assistant_info.get("avatar", ""),
"level": salary_this.get("coach_level", assistant_info.get("level", "")),
"level": perf_summary.get("current_tier_label", assistant_info.get("level", "")),
"store_name": store_name,
"skills": assistant_info.get("skills", []),
"work_years": assistant_info.get("work_years", 0.0),
"customer_count": customer_count,
"hire_date": assistant_info.get("hire_date"),
# 绩效
# 绩效(包含 tier_nodes、total_hours 等完整字段)
"performance": performance,
# 收入
"income": income,
# 档位
"tier_nodes": tier_nodes,
# 档位(保留顶级字段兼容前端已有逻辑)
"tier_nodes": perf_summary.get("tier_nodes", list(_FALLBACK_TIER_NODES)),
# 当月任务完成统计(回访/召回分类)
"task_stats": task_stats,
# 任务分组
"visible_tasks": task_groups["visible_tasks"],
"hidden_tasks": task_groups["hidden_tasks"],
@@ -377,7 +436,12 @@ def _build_top_customers(
result = []
for i, cust in enumerate(raw):
mid = cust.get("member_id")
name = cust.get("customer_name", "")
# 散客member_id ≤ 0展示"散客待转换会员",头像首字统一为"?"
is_scattered = not mid or mid <= 0
if is_scattered:
name = "散客待转换会员"
else:
name = cust.get("customer_name", "")
score = relation_map.get(mid, 0.0)
# 四级 heart icon 映射P6 AC3rs_display 0-10 刻度)
@@ -398,7 +462,8 @@ def _build_top_customers(
result.append({
"id": mid or 0,
"name": name,
"initial": _get_initial(name),
"initial": "?" if is_scattered else _get_initial(name),
"is_scattered": is_scattered,
"avatar_gradient": _get_avatar_gradient(i),
"heart_emoji": heart_emoji,
"score": f"{score:.2f}",
@@ -428,7 +493,13 @@ def _build_service_records(
result = []
for i, rec in enumerate(raw):
name = rec.get("customer_name", "")
# 散客member_id ≤ 0展示"散客待转换会员",头像首字统一为"?"
mid = rec.get("member_id")
is_scattered = not mid or mid <= 0
if is_scattered:
name = "散客待转换会员"
else:
name = rec.get("customer_name", "")
course_type = rec.get("course_type", "")
# type_class 映射
@@ -446,11 +517,12 @@ def _build_service_records(
result.append({
"customer_id": rec.get("member_id"),
"customer_name": name,
"initial": _get_initial(name),
"initial": "?" if is_scattered else _get_initial(name),
"is_scattered": is_scattered,
"avatar_gradient": _get_avatar_gradient(i),
"type": course_type or "课程",
"type_class": type_class,
"table": rec.get("table_name") or None,
"table": rec.get("table_name") or "",
"duration": f"{hours:.1f}h",
"income": float(income),
"date": date_str,
@@ -481,7 +553,15 @@ def _build_task_groups(
FROM biz.coach_tasks
WHERE assistant_id = %s
AND status IN ('active', 'inactive', 'abandoned')
ORDER BY created_at DESC
ORDER BY
CASE task_type
WHEN 'high_priority_recall' THEN 0
WHEN 'priority_recall' THEN 1
WHEN 'follow_up_visit' THEN 2
WHEN 'relationship_building' THEN 3
ELSE 4
END,
created_at DESC
""",
(coach_id,),
)

View File

@@ -287,32 +287,43 @@ def _build_retention_clues(customer_id: int, conn) -> list[dict]:
return [{"type": r[0] or "", "text": r[1] or ""} for r in rows]
NOTE_TYPE_LABELS = {"normal": "备注", "follow_up": "回访", "system": "系统", "ai": "AI"}
def _build_notes(customer_id: int, conn) -> list[dict]:
"""
构建 notes 模块。
查询 biz.notes WHERE target_type='member',最多 20 条,按 created_at 倒序。
JOIN auth.users 获取创建者名称JOIN auth.user_site_roles + auth.roles 获取角色。
"""
with conn.cursor() as cur:
cur.execute(
"""
SELECT id, type, created_at, content
FROM biz.notes
WHERE target_type = 'member'
AND target_id = %s
ORDER BY created_at DESC
SELECT n.id, n.type, n.created_at, n.content,
COALESCE(u.nickname, '') AS creator_name,
COALESCE(r.name, '') AS role_name
FROM biz.notes n
LEFT JOIN auth.users u ON n.user_id = u.id
LEFT JOIN auth.user_site_roles usr
ON n.user_id = usr.user_id
AND usr.is_removed = false
LEFT JOIN auth.roles r ON usr.role_id = r.id
WHERE n.target_type = 'member'
AND n.target_id = %s
ORDER BY n.created_at DESC
LIMIT 20
""",
(customer_id,),
)
rows = cur.fetchall()
NOTE_TYPE_LABELS = {"normal": "备注", "system": "系统", "ai": "AI"}
return [
{
"id": r[0],
"tag_label": NOTE_TYPE_LABELS.get(r[1], r[1] or "备注"),
"creator_name": r[4] or "",
"creator_role": r[5] or "",
"created_at": r[2].strftime("%Y-%m-%d %H:%M") if r[2] else "",
"content": r[3] or "",
}
@@ -323,22 +334,92 @@ def _build_notes(customer_id: int, conn) -> list[dict]:
# ── 3.3 消费记录 ──────────────────────────────────────────
def _build_coaches_from_json(coaches_json: list, level_map: dict) -> list[dict]:
"""从 SQL json_agg 结果构建 coaches 子数组。"""
coaches = []
for c in coaches_json:
level_code = c.get("assistant_level")
level_name = level_map.get(level_code, "") if level_code else ""
hrs = float(c.get("service_hours") or 0)
fee = float(c.get("ledger_amount") or 0)
if fee or hrs:
coaches.append({
"name": c.get("assistant_name", ""),
"level": level_name,
"level_color": LEVEL_COLOR_MAP.get(level_name, ""),
"course_type": c.get("course_type") or "基础课",
"hours": f"{hrs:.1f}h",
"perf_hours": None,
"fee": fee,
})
return coaches
def _build_settlement_card(rec: dict, table_name_map: dict, level_map: dict) -> dict:
"""从一条结算单级记录构建前端卡片数据。"""
import json as _json
coaches_json = rec.get("coaches_json") or []
if isinstance(coaches_json, str):
coaches_json = _json.loads(coaches_json)
coaches = _build_coaches_from_json(coaches_json, level_map)
settle_time = rec.get("settle_time")
date_str = settle_time.strftime("%Y-%m-%d") if settle_time else ""
start_raw = rec.get("start_time")
end_raw = rec.get("end_time")
start_str = start_raw.strftime("%H:%M") if start_raw else None
end_str = end_raw.strftime("%H:%M") if end_raw else None
svc_hours = rec.get("service_hours", 0.0)
dur_h = int(svc_hours)
dur_m = int((svc_hours - dur_h) * 60)
duration_str = f"{dur_h}h {dur_m}min" if dur_h > 0 else f"{dur_m}min"
table_fee = rec.get("table_charge_money", 0.0)
adjust = rec.get("adjust_amount", 0.0)
table_orig = None
if adjust > 0.01:
table_orig = round(table_fee + adjust, 2)
total_actual = rec.get("total_amount", 0.0)
consume_orig = rec.get("consume_money", 0.0)
total_orig = consume_orig if consume_orig > total_actual + 0.01 else None
return {
"id": rec.get("id", ""),
"type": "table",
"date": date_str,
"table_name": table_name_map.get(rec.get("table_id"), str(rec.get("table_id", ""))),
"start_time": start_str,
"end_time": end_str,
"duration": duration_str,
"table_fee": table_fee,
"table_orig_price": table_orig,
"coaches": coaches,
"food_amount": rec.get("goods_money", 0.0),
"food_orig_price": None,
"food_detail": rec.get("drinks"),
"total_amount": total_actual,
"total_orig_price": total_orig,
"pay_method": "",
"recharge_amount": None,
}
def _build_consumption_records(
customer_id: int, site_id: int, conn, *, etl_conn: Any = None
) -> list[dict]:
"""
构建 consumptionRecords 模块。
调用 fdw_queries.get_consumption_records() 获取结算单列表
⚠️ DWD-DOC 规则 1: totalAmount 使用 ledger_amountitems_sum 口径)。
⚠️ DWD-DOC 规则 2: coaches fee 使用 assistant_pd_money / assistant_cx_money。
按结算单粒度返回,同一结算单下多个助教聚合到 coaches 数组
⚠️ DWD-DOC 规则 1: totalAmount 使用 SUM(ledger_amount)items_sum 口径)。
⚠️ 废单排除: is_delete = 0正向交易: settle_type IN (1, 3)。
"""
raw_records = fdw_queries.get_consumption_records(
conn, site_id, customer_id, limit=5, offset=0, etl_conn=etl_conn
)
result = []
# 批量查询台桌名称
table_ids = list({rec.get("table_id") for rec in raw_records if rec.get("table_id")})
table_name_map: dict = {}
@@ -364,81 +445,7 @@ def _build_consumption_records(
except Exception:
pass
for rec in raw_records:
# 构建 coaches 子数组
coaches = []
pd_money = rec.get("assistant_pd_money", 0.0)
cx_money = rec.get("assistant_cx_money", 0.0)
level_code = rec.get("assistant_level")
level_name = level_map.get(level_code, "") if level_code else ""
if pd_money:
hrs = rec.get("service_hours", 0.0)
coaches.append({
"name": rec.get("assistant_name", ""),
"level": level_name,
"level_color": LEVEL_COLOR_MAP.get(level_name, ""),
"course_type": "基础课",
"hours": f"{hrs:.1f}h",
"perf_hours": None,
"fee": pd_money,
})
if cx_money:
coaches.append({
"name": rec.get("assistant_name", ""),
"level": level_name,
"level_color": LEVEL_COLOR_MAP.get(level_name, ""),
"course_type": "激励课",
"hours": "0h",
"perf_hours": None,
"fee": cx_money,
})
settle_time = rec.get("settle_time")
date_str = settle_time.strftime("%Y-%m-%d") if settle_time else ""
start_raw = rec.get("start_time")
end_raw = rec.get("end_time")
# 格式化时间为 HH:mm
start_str = start_raw.strftime("%H:%M") if start_raw else None
end_str = end_raw.strftime("%H:%M") if end_raw else None
# 格式化时长为 Xh Xmin
svc_hours = rec.get("service_hours", 0.0)
dur_h = int(svc_hours)
dur_m = int((svc_hours - dur_h) * 60)
duration_str = f"{dur_h}h {dur_m}min" if dur_h > 0 else f"{dur_m}min"
# 台费原价table_charge_money + adjust_amount台费调整/大客户优惠)
table_fee = rec.get("table_charge_money", 0.0)
adjust = rec.get("adjust_amount", 0.0)
table_orig = None
if adjust > 0.01:
table_orig = round(table_fee + adjust, 2)
# 总金额原价consume_money > items_sum 时显示)
total_actual = rec.get("total_amount", 0.0)
consume_orig = rec.get("consume_money", 0.0)
total_orig = consume_orig if consume_orig > total_actual + 0.01 else None
result.append({
"id": rec.get("id", ""),
"type": "table",
"date": date_str,
"table_name": table_name_map.get(rec.get("table_id"), str(rec.get("table_id", ""))),
"start_time": start_str,
"end_time": end_str,
"duration": duration_str,
"table_fee": table_fee,
"table_orig_price": table_orig,
"coaches": coaches,
"food_amount": rec.get("goods_money", 0.0),
"food_orig_price": None,
"total_amount": total_actual,
"total_orig_price": total_orig,
"pay_method": "",
"recharge_amount": None,
})
return result
return [_build_settlement_card(rec, table_name_map, level_map) for rec in raw_records]
# ── 3.4 关联助教任务T2-2──────────────────────────────
@@ -996,10 +1003,9 @@ def _get_consumption_records_by_month(
*, etl_conn=None,
) -> list[dict]:
"""
按月份过滤的消费记录,复用 _build_consumption_records 的构建逻辑。
按月份过滤的消费记录,复用 _build_settlement_card 构建逻辑。
⚠️ DWD-DOC 规则 1: totalAmount 使用 ledger_amount。
⚠️ DWD-DOC 规则 2: coaches fee 使用 assistant_pd_money / assistant_cx_money。
⚠️ DWD-DOC 规则 1: totalAmount 使用 SUM(ledger_amount)items_sum 口径)
"""
raw_records = fdw_queries.get_consumption_records(
conn, site_id, customer_id, limit=200, offset=0, etl_conn=etl_conn,
@@ -1031,77 +1037,7 @@ def _get_consumption_records_by_month(
except Exception:
pass
result = []
for rec in raw_records:
coaches = []
pd_money = rec.get("assistant_pd_money", 0.0)
cx_money = rec.get("assistant_cx_money", 0.0)
level_code = rec.get("assistant_level")
level_name = level_map.get(level_code, "") if level_code else ""
if pd_money:
hrs = rec.get("service_hours", 0.0)
coaches.append({
"name": rec.get("assistant_name", ""),
"level": level_name,
"level_color": LEVEL_COLOR_MAP.get(level_name, ""),
"course_type": "基础课",
"hours": f"{hrs:.1f}h",
"perf_hours": None,
"fee": pd_money,
})
if cx_money:
coaches.append({
"name": rec.get("assistant_name", ""),
"level": level_name,
"level_color": LEVEL_COLOR_MAP.get(level_name, ""),
"course_type": "激励课",
"hours": "0h",
"perf_hours": None,
"fee": cx_money,
})
settle_time = rec.get("settle_time")
date_str = settle_time.strftime("%Y-%m-%d") if settle_time else ""
start_raw = rec.get("start_time")
end_raw = rec.get("end_time")
start_str = start_raw.strftime("%H:%M") if start_raw else None
end_str = end_raw.strftime("%H:%M") if end_raw else None
svc_hours = rec.get("service_hours", 0.0)
dur_h = int(svc_hours)
dur_m = int((svc_hours - dur_h) * 60)
duration_str = f"{dur_h}h {dur_m}min" if dur_h > 0 else f"{dur_m}min"
table_fee = rec.get("table_charge_money", 0.0)
adjust = rec.get("adjust_amount", 0.0)
table_orig = None
if adjust > 0.01:
table_orig = round(table_fee + adjust, 2)
total_actual = rec.get("total_amount", 0.0)
consume_orig = rec.get("consume_money", 0.0)
total_orig = consume_orig if consume_orig > total_actual + 0.01 else None
result.append({
"id": rec.get("id", ""),
"type": "table",
"date": date_str,
"table_name": table_name_map.get(rec.get("table_id"), str(rec.get("table_id", ""))),
"start_time": start_str,
"end_time": end_str,
"duration": duration_str,
"table_fee": table_fee,
"table_orig_price": table_orig,
"coaches": coaches,
"food_amount": rec.get("goods_money", 0.0),
"food_orig_price": None,
"total_amount": total_actual,
"total_orig_price": total_orig,
"pay_method": "",
"recharge_amount": None,
})
return result
return [_build_settlement_card(rec, table_name_map, level_map) for rec in raw_records]
def _get_consumption_month_summary(

View File

@@ -180,10 +180,9 @@ def get_last_visit_days(
"""
批量查询客户距上次到店天数。
来源: app.v_dwd_assistant_service_log
废单排除: is_delete = 0RLS 视图使用 is_delete 而非 is_trash
时间字段: create_time对应 design.md 中的 settle_time
会员字段: tenant_member_id对应 design.md 中的 member_id
来源: app.v_dws_member_consumption_summary.days_since_last基于结算单
FIX: 原查 v_dwd_assistant_service_log 导致无助教服务的客户缺失到店记录
consumption_summary 按 stat_date 有多行快照,取最新一行
返回 {member_id: days_since_visit} 映射,无记录的会员不在结果中。
"""
@@ -194,16 +193,20 @@ def get_last_visit_days(
with _fdw_context(conn, site_id, etl_conn=etl_conn) as cur:
cur.execute(
"""
SELECT tenant_member_id,
CURRENT_DATE - MAX(create_time::date) AS days_since_visit
FROM app.v_dwd_assistant_service_log
WHERE tenant_member_id = ANY(%s) AND is_delete = 0
GROUP BY tenant_member_id
SELECT member_id, days_since_last
FROM app.v_dws_member_consumption_summary
WHERE member_id = ANY(%s)
AND days_since_last IS NOT NULL
ORDER BY member_id, stat_date DESC
""",
(member_ids,),
)
seen: set[int] = set()
for row in cur.fetchall():
result[row[0]] = row[1]
mid = row[0]
if mid not in seen:
seen.add(mid)
result[mid] = row[1]
return result
@@ -415,19 +418,24 @@ def batch_query_for_task_list(
for row in cur.fetchall():
balance_map[row[0]] = Decimal(str(row[1])) if row[1] is not None else Decimal("0")
# 3. 最后到店天数
# 3. 最后到店天数(基于消费汇总表,口径=结算单)
# FIX: 原查 v_dwd_assistant_service_log 导致无助教服务的客户缺失到店记录
cur.execute(
"""
SELECT tenant_member_id,
CURRENT_DATE - MAX(create_time::date) AS days_since_visit
FROM app.v_dwd_assistant_service_log
WHERE tenant_member_id = ANY(%s) AND is_delete = 0
GROUP BY tenant_member_id
SELECT member_id, days_since_last
FROM app.v_dws_member_consumption_summary
WHERE member_id = ANY(%s)
AND days_since_last IS NOT NULL
ORDER BY member_id, stat_date DESC
""",
(member_ids,),
)
seen_members: set[int] = set()
for row in cur.fetchall():
last_visit_map[row[0]] = row[1]
mid = row[0]
if mid not in seen_members:
seen_members.add(mid)
last_visit_map[mid] = row[1]
# 4. RS 指数
cur.execute(
@@ -687,6 +695,63 @@ def get_level_map(conn: Any, site_id: int) -> dict[int, str]:
return {}
@trace_service(description_zh="获取服务记录汇总", description_en="Get service records summary")
def get_service_records_summary(
conn: Any,
site_id: int,
assistant_id: int,
year: int,
month: int,
) -> dict:
"""
单条 SQL 直接聚合月度汇总count / sum(hours) / sum(income)。
用途:替代"先拉全量再 Python 算 summary"的高耗模式PERF-2
口径与 get_service_records 完全一致(同表/同 JOIN/同费率公式)。
返回 { total_count, total_hours, total_hours_raw, total_income }
total_hours_raw 暂沿用 0.0DWD 层服务记录无折前时长字段)。
"""
start_date = f"{year}-{month:02d}-01"
if month == 12:
end_date = f"{year + 1}-01-01"
else:
end_date = f"{year}-{month + 1:02d}-01"
with _fdw_context(conn, site_id) as cur:
cur.execute(
"""
SELECT COUNT(*),
COALESCE(SUM(sl.income_seconds / 3600.0), 0),
COALESCE(SUM(
CASE
WHEN sl.skill_name ILIKE '%%激励%%' OR sl.skill_name ILIKE '%%超休%%'
THEN (sl.income_seconds / 3600.0) * COALESCE(sc.bonus_course_price * (1 - sc.bonus_deduction_ratio), 0)
ELSE (sl.income_seconds / 3600.0) * COALESCE(sc.base_course_price - sc.base_deduction, 0)
END
), 0)
FROM app.v_dwd_assistant_service_log sl
LEFT JOIN app.v_dws_assistant_salary_calc sc
ON sl.site_assistant_id = sc.assistant_id
AND date_trunc('month', sl.create_time)::date = sc.salary_month
WHERE sl.site_assistant_id = %s AND sl.is_delete = 0
AND sl.create_time >= %s::timestamptz
AND sl.create_time < %s::timestamptz
""",
(assistant_id, start_date, end_date),
)
row = cur.fetchone()
if not row:
return {"total_count": 0, "total_hours": 0.0, "total_hours_raw": 0.0, "total_income": 0.0}
return {
"total_count": int(row[0] or 0),
"total_hours": round(float(row[1] or 0), 2),
"total_hours_raw": 0.0, # DWD 层无折前时长字段;与原 compute_summary 行为一致
"total_income": round(float(row[2] or 0), 2),
}
@trace_service(description_zh="获取服务记录", description_en="Get service records")
def get_service_records(
conn: Any,
@@ -1008,57 +1073,72 @@ def get_consumption_records(
"""
查询客户消费记录CUST-1 consumptionRecords 用)。
来源: app.v_dwd_assistant_service_log + v_dwd_settlement_head + v_dim_assistant
⚠️ DWD-DOC 规则 1: totalAmount 使用 ledger_amount来自 service_log
⚠️ DWD-DOC 规则 2: coaches fee 使用 assistant_pd_money / assistant_cx_money来自 settlement_head)。
⚠️ 费用拆分字段table_charge_money, goods_money, settle_type来自 settlement_head。
按结算单order_settle_id粒度返回同一结算单下的多个助教聚合到 coaches 数组
来源: v_dwd_settlement_head + v_dwd_assistant_service_log + v_dim_assistant
⚠️ DWD-DOC 规则 1: totalAmount 使用 SUM(ledger_amount)items_sum 口径)。
⚠️ 废单排除: is_delete = 0。
⚠️ 正向交易: settle_type IN (1, 3)。
⚠️ DQ-6: 助教姓名通过 v_dim_assistant 获取。
"""
records: list[dict] = []
# CHANGE 2026-03-29 | CUST-3: 支持按月份过滤消费记录
date_clause = ""
date_params: list = []
if start_date:
date_clause += " AND sl.create_time >= %s::timestamptz"
date_clause += " AND sh.create_time >= %s::timestamptz"
date_params.append(start_date)
if end_date:
date_clause += " AND sl.create_time < %s::timestamptz"
date_clause += " AND sh.create_time < %s::timestamptz"
date_params.append(end_date)
with _fdw_context(conn, site_id, etl_conn=etl_conn) as cur:
cur.execute(
f"""
SELECT sl.assistant_service_id AS id,
sl.create_time AS settle_time,
sl.start_use_time AS start_time,
sl.last_use_time AS end_time,
sl.income_seconds / 3600.0 AS service_hours,
sl.ledger_amount AS total_amount,
sl.skill_name AS course_type,
sl.site_table_id AS table_id,
sl.site_assistant_id AS assistant_id,
COALESCE(da.nickname, da.real_name, '') AS assistant_name,
da.level AS assistant_level,
SELECT sh.order_settle_id AS id,
sh.create_time AS settle_time,
MIN(sl.start_use_time) AS start_time,
MAX(sl.last_use_time) AS end_time,
SUM(sl.income_seconds) / 3600.0 AS service_hours,
SUM(sl.ledger_amount) AS total_amount,
MIN(sl.site_table_id) AS table_id,
sh.table_charge_money,
sh.goods_money,
sh.assistant_pd_money,
sh.assistant_cx_money,
sh.settle_type,
sh.consume_money,
sh.adjust_amount
FROM app.v_dwd_assistant_service_log sl
sh.adjust_amount,
gs_agg.drinks,
json_agg(json_build_object(
'assistant_id', sl.site_assistant_id,
'assistant_name', COALESCE(da.nickname, da.real_name, ''),
'assistant_level', da.level,
'service_hours', sl.income_seconds / 3600.0,
'ledger_amount', sl.ledger_amount,
'course_type', sl.skill_name
) ORDER BY sl.ledger_amount DESC NULLS LAST) AS coaches_json
FROM app.v_dwd_settlement_head sh
INNER JOIN app.v_dwd_assistant_service_log sl
ON sh.order_settle_id = sl.order_settle_id
AND sl.tenant_member_id = %s
AND sl.is_delete = 0
LEFT JOIN app.v_dim_assistant da
ON sl.site_assistant_id = da.assistant_id
AND da.scd2_is_current = 1
LEFT JOIN app.v_dwd_settlement_head sh
ON sl.order_settle_id = sh.order_settle_id
WHERE sl.tenant_member_id = %s
AND sl.is_delete = 0
AND sh.settle_type IN (1, 3)
AND da.scd2_is_current = 1
LEFT JOIN LATERAL (
SELECT string_agg(gs.ledger_name || '*' || gs.total_count, ' | ' ORDER BY gs.subtotal DESC) AS drinks
FROM (
SELECT ledger_name,
SUM(ledger_count) AS total_count,
SUM(ledger_amount) AS subtotal
FROM app.v_dwd_store_goods_sale
WHERE order_settle_id = sh.order_settle_id
AND is_delete = 0
GROUP BY ledger_name
) gs
) gs_agg ON true
WHERE sh.settle_type IN (1, 3)
{date_clause}
ORDER BY sl.create_time DESC
GROUP BY sh.order_settle_id, sh.create_time,
sh.table_charge_money, sh.goods_money,
sh.consume_money, sh.adjust_amount,
gs_agg.drinks
ORDER BY sh.create_time DESC
LIMIT %s OFFSET %s
""",
(member_id,) + tuple(date_params) + (limit, offset),
@@ -1071,18 +1151,13 @@ def get_consumption_records(
"end_time": row[3],
"service_hours": float(row[4]) if row[4] is not None else 0.0,
"total_amount": float(row[5]) if row[5] is not None else 0.0,
"course_type": row[6] or "",
"table_id": row[7],
"assistant_id": row[8],
"assistant_name": row[9] or "",
"assistant_level": row[10], # int level code
"table_charge_money": float(row[11]) if row[11] is not None else 0.0,
"goods_money": float(row[12]) if row[12] is not None else 0.0,
"assistant_pd_money": float(row[13]) if row[13] is not None else 0.0,
"assistant_cx_money": float(row[14]) if row[14] is not None else 0.0,
"settle_type": row[15],
"consume_money": float(row[16]) if row[16] is not None else 0.0,
"adjust_amount": float(row[17]) if row[17] is not None else 0.0,
"table_id": row[6],
"table_charge_money": float(row[7]) if row[7] is not None else 0.0,
"goods_money": float(row[8]) if row[8] is not None else 0.0,
"consume_money": float(row[9]) if row[9] is not None else 0.0,
"adjust_amount": float(row[10]) if row[10] is not None else 0.0,
"drinks": row[11],
"coaches_json": row[12] or [],
})
return records
@@ -1741,8 +1816,13 @@ def get_coach_sv_data(
AND ri.session_count > 0
),
period_consume AS (
-- DWD-DOC 规则 1: items_sum 需拆分计算settlement_head 无此字段
SELECT sh.member_id,
COALESCE(SUM(sh.items_sum), 0) AS consume_amount
COALESCE(SUM(
sh.table_charge_money + sh.goods_money
+ sh.assistant_pd_money + sh.assistant_cx_money
+ sh.electricity_money
), 0) AS consume_amount
FROM app.v_dwd_settlement_head sh
WHERE sh.member_id = ANY(SELECT member_id FROM coach_members)
AND sh.settle_type IN (1, 3)
@@ -2044,7 +2124,7 @@ def get_customer_board_balance(
"name": row[1] or "",
"balance": float(row[2]) if row[2] is not None else 0.0,
# CHANGE 2026-03-29 | last_visit 格式化为"X天前"ideal_days 从 winback_index 获取
"last_visit": f"{row[3]}天前" if row[3] is not None else "--",
"last_visit": "今天" if row[3] == 0 else f"{row[3]}天前" if row[3] is not None else "--",
"last_visit_date": row[3],
"ideal_days": None, # balance 维度无 ideal_days由 board_service 补充
# CHANGE 2026-04-07 | Fix-4consume_amount_60d 是 60 天总额,月均 = /2
@@ -2130,7 +2210,7 @@ def get_customer_board_recharge(
"recharges_60d": row[4] or 0,
"current_balance": float(row[5]) if row[5] is not None else 0.0,
# CHANGE 2026-03-29 | 补充 last_visit 和 ideal_days头部展示用
"last_visit": f"{row[6]}天前" if row[6] is not None else "--",
"last_visit": "今天" if row[6] == 0 else f"{row[6]}天前" if row[6] is not None else "--",
"ideal_days": None, # 由 board_service 补充
})
@@ -3492,12 +3572,19 @@ def get_nci_batch(
来源: app.v_dws_member_newconv_indexRLS 视图)
返回: {member_id: display_score}
FIX 2026-04-12: 排除已转老客的会员。NCI 表只在 NEW 阶段写入,
会员转 OLD 后不再更新,导致残留过时高分。用 WBI status='OLD' 过滤。
"""
with _fdw_context(conn, site_id) as cur:
cur.execute(
"""
SELECT member_id, COALESCE(display_score, 0)
FROM app.v_dws_member_newconv_index
SELECT n.member_id, COALESCE(n.display_score, 0)
FROM app.v_dws_member_newconv_index n
WHERE NOT EXISTS (
SELECT 1 FROM app.v_dws_member_winback_index w
WHERE w.member_id = n.member_id AND w.status = 'OLD'
)
"""
)
return {row[0]: Decimal(str(row[1])) for row in cur.fetchall()}
@@ -3547,6 +3634,7 @@ def get_all_service_pairs(
返回: [{"assistant_id", "member_id", "rs"}]
"""
with _fdw_context(conn, site_id) as cur:
# POOL 客户需 session_count >= 3 才纳入保底任务MAIN/COMANAGE 无限制
cur.execute(
"""
SELECT assistant_id,
@@ -3554,6 +3642,7 @@ def get_all_service_pairs(
COALESCE(rs_display, 0) AS rs
FROM app.v_dws_member_assistant_relation_index
WHERE session_count > 0
AND (os_label IN ('MAIN', 'COMANAGE') OR session_count >= 3)
"""
)
return [

View File

@@ -82,10 +82,18 @@ def group_records_by_date(
# CHANGE 2026-03-24 | 课程类型直接用数据库原始值skill_name不做二次映射
raw_course_type = rec.get("course_type", "") or "基础课"
customer_name = rec.get("customer_name") or "未知客户"
# 散客member_id ≤ 0展示"散客待转换会员"
# 真实会员姓名缺失时回退"未知客户"
mid_for_name = rec.get("member_id")
is_scattered = not mid_for_name or mid_for_name <= 0
if is_scattered:
customer_name = "散客待转换会员"
else:
customer_name = rec.get("customer_name") or "未知客户"
record_item: dict = {
"customer_name": customer_name,
"is_scattered": is_scattered,
"time_range": time_range,
"hours": f"{rec.get('service_hours', 0.0):.1f}",
"course_type": raw_course_type,
@@ -594,29 +602,33 @@ def _build_customer_lists(
async def get_records(
user_id: int, site_id: int,
year: int, month: int, page: int, page_size: int,
assistant_id_override: int | None = None,
) -> dict:
"""
绩效明细PERF-2
1. 获取 assistant_id
1. 获取 assistant_idassistant_id_override 非空时直接使用,跳过 user 绑定查询)
2. fdw_queries.get_service_records() 带分页
3. 按日期分组为 dateGroups不含 avatarChar/avatarColor
4. 计算 summary 汇总
5. 返回 { summary, dateGroups, hasMore }
assistant_id_override 用于"管理员/店长查看其他助教"场景,
调用方负责完成越权校验后再传入目标 assistant_id。
"""
conn = _get_connection()
try:
assistant_id = _get_assistant_id(conn, user_id, site_id)
if assistant_id_override is not None:
assistant_id = assistant_id_override
else:
assistant_id = _get_assistant_id(conn, user_id, site_id)
# 先获取全量记录用于 summary 计算
all_records = fdw_queries.get_service_records(
# CHANGE 2026-04-20 | 性能优化summary 改用 SQL 聚合,
# 不再先 limit=100000 全量拉取再 Python 算 summary
summary = fdw_queries.get_service_records_summary(
conn, site_id, assistant_id, year, month,
limit=100000, offset=0,
)
# 计算月度汇总
summary = compute_summary(all_records)
# 分页获取记录
offset = (page - 1) * page_size
page_records = fdw_queries.get_service_records(
@@ -624,8 +636,8 @@ async def get_records(
limit=page_size, offset=offset,
)
# 判断 hasMore
has_more = len(all_records) > page * page_size
# 判断 hasMore(由 summary.total_count 直接推算,避免再次拉全量)
has_more = summary["total_count"] > page * page_size
# CHANGE 2026-03-27 | 批量查 RS 分数,注入到服务记录
page_member_ids = list({r.get("member_id") for r in page_records if r.get("member_id")})

View File

@@ -12,6 +12,10 @@ CHANGE 2026-04-08 | Fix-13 改造:
- 扫描范围从"有 active 任务的客户"扩大为"所有 os_label='MAIN' 的关联客户"
- 新增 recall_events 事件表记录广义召回(按天去重)
- 无 active 任务的客户到店也生成 follow_up_visit
CHANGE 2026-04-12 | 召回完成逻辑调整:
- settle_type=3 仅计入有 BONUS 服务的订单(纯商品不算到店)
- 门店级召回解除:客户到店后,未服务助教的召回任务标记 resolved
"""
import json
@@ -72,6 +76,7 @@ def run(payload: dict | None = None, job_id: int | None = None) -> dict:
"""
completed_count = 0
event_count = 0
resolved_count = 0
conn = _get_connection()
try:
@@ -89,6 +94,7 @@ def run(payload: dict | None = None, job_id: int | None = None) -> dict:
result = _process_site(conn, site_id)
completed_count += result["completed"]
event_count += result["events"]
resolved_count += result["resolved"]
except Exception:
logger.exception(
"处理门店召回检测失败: site_id=%s", site_id
@@ -108,10 +114,14 @@ def run(payload: dict | None = None, job_id: int | None = None) -> dict:
conn.close()
logger.info(
"召回完成检测完成: completed_count=%d, event_count=%d",
completed_count, event_count,
"召回完成检测完成: completed=%d, events=%d, resolved=%d",
completed_count, event_count, resolved_count,
)
return {"completed_count": completed_count, "event_count": event_count}
return {
"completed_count": completed_count,
"event_count": event_count,
"resolved_count": resolved_count,
}
def _process_site(conn, site_id: int) -> dict:
@@ -122,9 +132,13 @@ def _process_site(conn, site_id: int) -> dict:
1. 从 ETL 查询所有 os_label='MAIN' 的 (assistant_id, member_id) 对
2. 批量查询这些客户的最新结算记录
3. 对每个有新结算的关系对:写 recall_events + 完成任务 + 生成回访
CHANGE 2026-04-12 | 门店级召回解除:
4. 客户到店后,未被服务的助教的召回任务标记 resolved
"""
completed = 0
events = 0
resolved = 0
from app.services.fdw_queries import _fdw_context
@@ -140,13 +154,15 @@ def _process_site(conn, site_id: int) -> dict:
main_pairs = [(r[0], r[1]) for r in cur.fetchall()]
if not main_pairs:
return {"completed": 0, "events": 0}
return {"completed": 0, "events": 0, "resolved": 0}
# ── 2. 批量查询这些客户的最新结算时间 ──
member_ids = list({mid for _, mid in main_pairs})
settlement_map: dict[tuple[int, int], object] = {} # (assistant_id, member_id) → latest_pay_time
with _fdw_context(conn, site_id) as cur:
# 助教级结算(用于狭义完成判定)
# settle_type=1 全部计入settle_type=3 仅计入有 BONUS 服务的
cur.execute(
"""
SELECT sl.site_assistant_id AS assistant_id,
@@ -157,7 +173,10 @@ def _process_site(conn, site_id: int) -> dict:
ON sl.order_settle_id = sh.order_settle_id
AND sl.is_delete = 0
WHERE sh.member_id = ANY(%s)
AND sh.settle_type IN (1, 3)
AND (
sh.settle_type = 1
OR (sh.settle_type = 3 AND sl.order_assistant_type = 2)
)
GROUP BY sl.site_assistant_id, sh.member_id
""",
(member_ids,),
@@ -165,6 +184,29 @@ def _process_site(conn, site_id: int) -> dict:
for row in cur.fetchall():
settlement_map[(row[0], row[1])] = row[2]
# 门店级到店检测(含无助教服务的 settle_type=1用于 resolved 判定)
cur.execute(
"""
SELECT sh.member_id, MAX(sh.pay_time) AS latest_pay_time
FROM app.v_dwd_settlement_head sh
WHERE sh.member_id = ANY(%s)
AND (
sh.settle_type = 1
OR (sh.settle_type = 3 AND EXISTS (
SELECT 1 FROM app.v_dwd_assistant_service_log sl
WHERE sl.order_settle_id = sh.order_settle_id
AND sl.is_delete = 0
AND sl.order_assistant_type = 2
))
)
GROUP BY sh.member_id
""",
(member_ids,),
)
member_visited_map = {}
for row in cur.fetchall():
member_visited_map[row[0]] = row[1]
# ── 3. 获取本门店所有 active 的召回/回访任务(用于匹配) ──
active_tasks_map: dict[tuple[int, int], list] = {} # (assistant_id, member_id) → [(id, task_type, created_at)]
with conn.cursor() as cur:
@@ -207,7 +249,56 @@ def _process_site(conn, site_id: int) -> dict:
)
conn.rollback()
return {"completed": completed, "events": events}
# ── 5. 门店级召回解除:客户到店后,未被服务的助教任务标记 resolved ──
# 服务助教的任务已在 Step 4 中 completedcommitted
# 此处查到的 active 召回任务是未被服务的助教持有的
for member_id, pay_time in member_visited_map.items():
try:
with conn.cursor() as cur:
cur.execute("BEGIN")
cur.execute(
"""
SELECT id, assistant_id, task_type, created_at
FROM biz.coach_tasks
WHERE site_id = %s AND member_id = %s
AND status = 'active'
AND task_type IN ('high_priority_recall', 'priority_recall')
AND created_at < %s
""",
(site_id, member_id, pay_time),
)
remaining = cur.fetchall()
for task_id, aid, task_type, _ in remaining:
cur.execute(
"""
UPDATE biz.coach_tasks
SET status = 'resolved', updated_at = NOW()
WHERE id = %s AND status = 'active'
""",
(task_id,),
)
_insert_history(
cur, task_id,
action="customer_returned",
old_status="active",
new_status="resolved",
old_task_type=task_type,
new_task_type=task_type,
detail={
"reason": "customer_visited_store",
"service_time": str(pay_time),
},
)
resolved += 1
conn.commit()
except Exception:
logger.exception(
"门店级召回解除失败: site_id=%s, member_id=%s",
site_id, member_id,
)
conn.rollback()
return {"completed": completed, "events": events, "resolved": resolved}
def _process_pair(
@@ -224,7 +315,7 @@ def _process_pair(
CHANGE 2026-04-08 | Fix-13 改造:
- 写 recall_eventsON CONFLICT DO NOTHING 按天去重)
- 有 active 召回任务且 pay_time > created_at → 完成任务
- 关闭旧回访 → 新建回访(48h
- 关闭旧回访 → 新建回访(72h
- 无 active 任务也生成回访
返回: {"completed": int, "events": int}
@@ -323,9 +414,9 @@ def _process_pair(
detail={"reason": "new_service_record", "service_time": str(latest_pay_time)},
)
# ── 4. 创建新的回访任务(48h 过期) ──
# ── 4. 创建新的回访任务(72h / 3天过期) ──
expires_at = (
latest_pay_time + timedelta(hours=48)
latest_pay_time + timedelta(hours=72)
if hasattr(latest_pay_time, '__add__') else None
)
cur.execute(

View File

@@ -147,7 +147,7 @@ _DEFAULT_PARAMS: dict[str, float] = {
"transfer_score_w_ms": 0.3,
"transfer_score_w_ml": 0.2,
"max_transfer_count": 4,
"follow_up_visit_retention_hours": 48,
"follow_up_visit_retention_hours": 72,
# CHANGE 2026-03-29 | OS 分级分配:升级倍数参数
"escalation_comanage_multiplier": 2.5,
"escalation_pool_multiplier": 4.0,
@@ -554,15 +554,22 @@ def _process_pair(
stats["skipped"] += 1
return
# Case B: 不同类型的 active 任务 → 关闭旧任务 + 创建新任务
for task_id, old_type, old_expires_at, old_created_at in existing_tasks:
if should_replace_task(old_type, new_task_type):
# follow_up_visit 被高优先级任务顶替时,填充 expires_at 而非直接 inactive
if old_type == "follow_up_visit" and old_expires_at is None:
# Case B: 不同类型的 active 任务 → 混合策略
# - follow_up_visit 被替代:保留宽限期(填 expires_at 72h+ 新建高优先任务
# - 其他类型被替代原地覆盖UPDATE task_type + priority_score
overridden = False
need_create_new = False
for i, (task_id, old_type, old_expires_at, old_created_at) in enumerate(existing_tasks):
if not should_replace_task(old_type, new_task_type):
continue
if old_type == "follow_up_visit":
# follow_up_visit 特殊处理:填充 72h 宽限期,不关闭
if old_expires_at is None:
cur.execute(
"""
UPDATE biz.coach_tasks
SET expires_at = created_at + INTERVAL '48 hours',
SET expires_at = created_at + INTERVAL '72 hours',
updated_at = NOW()
WHERE id = %s
""",
@@ -578,60 +585,86 @@ def _process_pair(
new_task_type=old_type,
detail={"reason": "higher_priority_task_created"},
)
else:
cur.execute(
"""
UPDATE biz.coach_tasks
SET status = 'inactive', updated_at = NOW()
WHERE id = %s
""",
(task_id,),
)
_insert_history(
cur,
task_id,
action="type_change_close",
old_status="active",
new_status="inactive",
old_task_type=old_type,
new_task_type=new_task_type,
)
need_create_new = True
stats["replaced"] += 1
elif not overridden:
# 非 follow_up原地覆盖
cur.execute(
"""
UPDATE biz.coach_tasks
SET task_type = %s, priority_score = %s, updated_at = NOW()
WHERE id = %s AND status = 'active'
""",
(new_task_type, float(priority_score), task_id),
)
_insert_history(
cur,
task_id,
action="type_override",
old_status="active",
new_status="active",
old_task_type=old_type,
new_task_type=new_task_type,
detail={"old_priority": float(priority_score)},
)
overridden = True
stats["replaced"] += 1
else:
# 多余的同对任务:关闭
cur.execute(
"""
UPDATE biz.coach_tasks
SET status = 'inactive', updated_at = NOW()
WHERE id = %s
""",
(task_id,),
)
_insert_history(
cur,
task_id,
action="type_change_close",
old_status="active",
new_status="inactive",
old_task_type=old_type,
new_task_type=new_task_type,
)
# ── 创建新任务 ──
expires_at_val = None
cur.execute(
"""
INSERT INTO biz.coach_tasks
(site_id, assistant_id, member_id, task_type, status,
priority_score, expires_at, parent_task_id)
VALUES (%s, %s, %s, %s, 'active', %s, %s, %s)
RETURNING id
""",
(
site_id,
assistant_id,
member_id,
new_task_type,
float(priority_score),
expires_at_val,
existing_tasks[0][0] if existing_tasks else None,
),
)
new_task_id = cur.fetchone()[0]
_insert_history(
cur,
new_task_id,
action="created",
old_status=None,
new_status="active",
old_task_type=existing_tasks[0][1] if existing_tasks else None,
new_task_type=new_task_type,
)
stats["created"] += 1
# 需要新建任务的场景:
# 1. follow_up_visit 被替代(宽限期保留原任务,需新建高优先任务)
# 2. 没有可覆盖的非 follow_up 任务
if need_create_new or not overridden:
# upsert若同类型 active 已存在recall_detector 先行创建)则更新 priority
cur.execute(
"""
INSERT INTO biz.coach_tasks
(site_id, assistant_id, member_id, task_type, status,
priority_score, expires_at, parent_task_id)
VALUES (%s, %s, %s, %s, 'active', %s, %s, %s)
ON CONFLICT (site_id, assistant_id, member_id, task_type) WHERE (status = 'active')
DO UPDATE SET priority_score = EXCLUDED.priority_score, updated_at = NOW()
RETURNING id
""",
(
site_id,
assistant_id,
member_id,
new_task_type,
float(priority_score),
None,
existing_tasks[0][0] if existing_tasks else None,
),
)
new_task_id = cur.fetchone()[0]
_insert_history(
cur,
new_task_id,
action="created",
old_status=None,
new_status="active",
old_task_type=existing_tasks[0][1] if existing_tasks else None,
new_task_type=new_task_type,
)
stats["created"] += 1
conn.commit()
@@ -1145,6 +1178,45 @@ def _generate_baseline_relationship_tasks(
pair["member_id"],
)
biz_conn.rollback()
# Step 5: 反向清理 -- 关闭不再符合条件的 relationship_building 任务
# 对已有 active relationship_building 但不在 get_all_service_pairs 结果中的对,关闭
valid_pairs = {(p["assistant_id"], p["member_id"]) for p in all_pairs}
stale_closed = 0
try:
with biz_conn.cursor() as cur:
cur.execute(
"""
SELECT id, assistant_id, member_id
FROM biz.coach_tasks
WHERE site_id = %s
AND task_type = 'relationship_building'
AND status = 'active'
""",
(site_id,),
)
for task_id, aid, mid in cur.fetchall():
if (aid, mid) not in valid_pairs:
cur.execute(
"UPDATE biz.coach_tasks SET status = 'inactive', updated_at = NOW() WHERE id = %s",
(task_id,),
)
_insert_history(
cur, task_id,
action="pool_cleanup",
old_status="active",
new_status="inactive",
old_task_type="relationship_building",
new_task_type="relationship_building",
detail={"reason": "pair_no_longer_qualifies"},
)
stale_closed += 1
biz_conn.commit()
if stale_closed:
logger.info("保底任务清理: site_id=%s, 关闭 %d 个不再符合条件的 relationship_building", site_id, stale_closed)
except Exception:
logger.exception("保底任务清理失败: site_id=%s", site_id)
biz_conn.rollback()
finally:
biz_conn.close()
@@ -1158,7 +1230,7 @@ def _handle_no_task_condition(
) -> None:
"""
当不满足任何任务生成条件时:
1. follow_up_visit → 填充 expires_at = created_at + 48h
1. follow_up_visit → 填充 expires_at = created_at + 72h
2. high_priority_recall / priority_recall → 直接关闭inactive
CHANGE 2026-03-24 | Prompt: 修复召回任务不自动关闭 bug |
@@ -1185,7 +1257,7 @@ def _handle_no_task_condition(
cur.execute(
"""
UPDATE biz.coach_tasks
SET expires_at = created_at + INTERVAL '48 hours',
SET expires_at = created_at + INTERVAL '72 hours',
updated_at = NOW()
WHERE id = %s
""",

View File

@@ -2,7 +2,7 @@
# - 2026-03-20 | Prompt: H2 FDW→直连ETL统一改造 | get_task_list() 中 2 处、get_task_list_v2() 中 1 处、
# get_task_detail() 中 1 处 fdw_etl.v_dim_member / v_dws_member_assistant_relation_index
# 改为直连 ETL 库查询 app.v_* RLS 视图。使用 fdw_queries._fdw_context()。
# - 2026-03-24 | Prompt: 修复小程序前端没有档位进度 | _build_performance_summary 中 tier_nodes
# - 2026-03-24 | Prompt: 修复小程序前端没有档位进度 | build_performance_summary 中 tier_nodes
# 从 cfg_performance_tier 配置表读取(不再依赖 salary_calc 的空列表),
# next_tier_hours/tier_completed 根据 effective_hours 和 tier_nodes 实时计算。
# - 2026-03-24 | Prompt: bonus_money 公式修正 | bonus_money 改为基础课节省 + 打赏课节省:
@@ -649,7 +649,7 @@ async def get_task_list_v2(
if not tasks:
# 即使无任务也需要返回绩效概览
performance = _build_performance_summary(conn, site_id, assistant_id)
performance = build_performance_summary(conn, site_id, assistant_id)
return {
"items": [],
"total": 0,
@@ -732,7 +732,7 @@ async def get_task_list_v2(
# ── 8. 绩效概览(使用批量查询的预取数据) ──
# CHANGE 2026-03-23 | 复用 batch_data 避免额外 3 次 ETL 连接
performance = _build_performance_summary(
performance = build_performance_summary(
conn, site_id, assistant_id, batch_data=batch_data,
)
@@ -792,7 +792,7 @@ async def get_task_list_v2(
conn.close()
def _build_performance_summary(
def build_performance_summary(
conn, site_id: int, assistant_id: int, *, batch_data: dict | None = None,
) -> dict:
"""
@@ -858,9 +858,17 @@ def _build_performance_summary(
basic_hours = summary["base_hours"] if summary else 0.0
bonus_hours = summary["bonus_hours"] if summary else 0.0
total_customers = summary["unique_customers"] if summary else 0
current_tier = summary["tier_id"] if summary else (salary["tier_index"] if salary else 0)
coach_level = summary["coach_level"] if summary else (salary["coach_level"] if salary else "")
# current_tier根据 total_hours 在 tier_nodes 中的位置计算数组索引0-based
# 不能用 tier_id数据库主键前端把 current_tier 当数组下标用
current_tier = 0
for i, node in enumerate(tier_nodes):
if total_hours >= node:
current_tier = i
else:
break
# next_tier_hours / tier_completed: 根据 effective_hours 和 tier_nodes 计算
tier_completed = False
next_tier_hours = 0.0

View File

@@ -80,8 +80,10 @@ DWD_TASKS: list[TaskDefinition] = [
# ── DWS 任务定义 ──────────────────────────────────────────────
DWS_TASKS: list[TaskDefinition] = [
TaskDefinition("CORE_DIM_SYNC", "Core 维度同步", "将 DWD 当前维度同步到 core 统一层(助教/会员/门店/台桌)", "通用", "DWS", requires_window=False),
TaskDefinition("DWS_BUILD_ORDER_SUMMARY", "订单汇总构建", "构建订单汇总宽表", "结算", "DWS"),
TaskDefinition("DWS_ASSISTANT_DAILY", "助教日报", "汇总助教每日业绩", "助教", "DWS"),
TaskDefinition("DWS_ASSISTANT_ORDER_CONTRIBUTION", "助教订单贡献", "计算助教订单流水四项统计", "助教", "DWS"),
TaskDefinition("DWS_ASSISTANT_MONTHLY", "助教月报", "汇总助教月度业绩", "助教", "DWS"),
TaskDefinition("DWS_ASSISTANT_CUSTOMER", "助教客户分析", "汇总助教-客户关系", "助教", "DWS"),
TaskDefinition("DWS_ASSISTANT_SALARY", "助教工资计算", "计算助教工资", "助教", "DWS"),
@@ -114,7 +116,9 @@ INDEX_TASKS: list[TaskDefinition] = [
TaskDefinition("DWS_RELATION_INDEX", "关系指数 (RS)", "产出 RS/OS/MS/ML 四个子指数", "指数", "INDEX"),
TaskDefinition("DWS_SPENDING_POWER_INDEX", "消费力指数 (SPI)", "计算会员消费力指数", "指数", "INDEX"),
# CHANGE 2026-03-29 | DWS_TASK_ENGINE编排后端任务引擎完成检查→过期检查→任务生成
TaskDefinition("DWS_TASK_ENGINE", "任务引擎", "编排后端任务引擎:完成检查→过期检查→任务生成", "指数", "INDEX", requires_window=False, is_common=True),
TaskDefinition("DWS_TASK_ENGINE", "任务引擎", "日常:编排完成检查→过期检查→任务生成;设置时间窗口时:基于历史指数快照推演任务生命周期", "指数", "INDEX", requires_window=False, is_common=True),
# CHANGE 2026-04-12 | 指数回填工具任务
TaskDefinition("DWS_INDEX_BACKFILL", "指数回填", "逐天回填 RS/WBI/NCI 历史日快照,需设置时间窗口", "指数", "INDEX", requires_window=True, is_common=True),
]
# ── 工具类任务定义 ────────────────────────────────────────────

View File

@@ -1,6 +1,6 @@
..................F........... [100%]
================================== FAILURES ===================================
__________________ test_invalid_credentials_always_rejected ___________________
+ Exception Group Traceback (most recent call last):\n | File "C:\\ProgramData\\miniconda3\\Lib\\unittest\\mock.py", line 1422, in test_invalid_credentials_always_rejected\n | def patched(*args, **keywargs):\n | ^^^^^^^^^^^^^^^^^^^^\n | File "C:\\NeoZQYY\\.venv\\Lib\\site-packages\\hypothesis\\core.py", line 2246, in wrapped_test\n | raise the_error_hypothesis_found\n | hypothesis.errors.FlakyFailure: Hypothesis test_invalid_credentials_always_rejected(password='\u4d77\U0002325c\u0133', username='uv') produces unreliable results: Falsified on the first call but did not on a subsequent one (1 sub-exception)\n | Falsifying example: test_invalid_credentials_always_rejected(\n | username='uv',\n | password='\u4d77\U0002325c\u0133',\n | )\n | Unreliable test timings! On an initial run, this test took 285.80ms, which exceeded the deadline of 200.00ms, but on a subsequent run it took 5.67 ms, which did not. If you expect this sort of variability in your test timings, consider turning deadlines off for this test by setting deadline=None.\n +-+---------------- 1 ----------------\n | Traceback (most recent call last):\n | File "C:\\NeoZQYY\\.venv\\Lib\\site-packages\\hypothesis\\core.py", line 1211, in _execute_once_for_engine\n | result = self.execute_once(data)\n | File "C:\\NeoZQYY\\.venv\\Lib\\site-packages\\hypothesis\\core.py", line 1150, in execute_once\n | result = self.test_runner(data, run)\n | File "C:\\NeoZQYY\\.venv\\Lib\\site-packages\\hypothesis\\core.py", line 824, in default_executor\n | return function(data)\n | File "C:\\NeoZQYY\\.venv\\Lib\\site-packages\\hypothesis\\core.py", line 1107, in run\n | return test(*args, **kwargs)\n | File "C:\\ProgramData\\miniconda3\\Lib\\unittest\\mock.py", line 1422, in test_invalid_credentials_always_rejected\n | def patched(*args, **keywargs):\n | ^^^^^^^^^^^^^^^^^^^^\n | File "C:\\NeoZQYY\\.venv\\Lib\\site-packages\\hypothesis\\core.py", line 1041, in test\n | raise DeadlineExceeded(\n | datetime.timedelta(seconds=runtime), self.settings.deadline\n | )\n | hypothesis.errors.DeadlineExceeded: Test took 285.80ms, which exceeds the deadline of 200.00ms. If you expect test cases to take this long, you can use @settings(deadline=...) to either set a higher deadline, or to disable it with deadline=None.\n +------------------------------------\n=========================== short test summary info ===========================
+ Exception Group Traceback (most recent call last):\n | File "C:\\ProgramData\\miniconda3\\Lib\\unittest\\mock.py", line 1422, in test_invalid_credentials_always_rejected\n | def patched(*args, **keywargs):\n | ^^^^^^^^^^^^^^^^^^^^\n | File "C:\\Project\\NeoZQYY\\.venv\\Lib\\site-packages\\hypothesis\\core.py", line 2246, in wrapped_test\n | raise the_error_hypothesis_found\n | hypothesis.errors.FlakyFailure: Hypothesis test_invalid_credentials_always_rejected(password='\u4d77\U0002325c\u0133', username='uv') produces unreliable results: Falsified on the first call but did not on a subsequent one (1 sub-exception)\n | Falsifying example: test_invalid_credentials_always_rejected(\n | username='uv',\n | password='\u4d77\U0002325c\u0133',\n | )\n | Unreliable test timings! On an initial run, this test took 285.80ms, which exceeded the deadline of 200.00ms, but on a subsequent run it took 5.67 ms, which did not. If you expect this sort of variability in your test timings, consider turning deadlines off for this test by setting deadline=None.\n +-+---------------- 1 ----------------\n | Traceback (most recent call last):\n | File "C:\\Project\\NeoZQYY\\.venv\\Lib\\site-packages\\hypothesis\\core.py", line 1211, in _execute_once_for_engine\n | result = self.execute_once(data)\n | File "C:\\Project\\NeoZQYY\\.venv\\Lib\\site-packages\\hypothesis\\core.py", line 1150, in execute_once\n | result = self.test_runner(data, run)\n | File "C:\\Project\\NeoZQYY\\.venv\\Lib\\site-packages\\hypothesis\\core.py", line 824, in default_executor\n | return function(data)\n | File "C:\\Project\\NeoZQYY\\.venv\\Lib\\site-packages\\hypothesis\\core.py", line 1107, in run\n | return test(*args, **kwargs)\n | File "C:\\ProgramData\\miniconda3\\Lib\\unittest\\mock.py", line 1422, in test_invalid_credentials_always_rejected\n | def patched(*args, **keywargs):\n | ^^^^^^^^^^^^^^^^^^^^\n | File "C:\\Project\\NeoZQYY\\.venv\\Lib\\site-packages\\hypothesis\\core.py", line 1041, in test\n | raise DeadlineExceeded(\n | datetime.timedelta(seconds=runtime), self.settings.deadline\n | )\n | hypothesis.errors.DeadlineExceeded: Test took 285.80ms, which exceeds the deadline of 200.00ms. If you expect test cases to take this long, you can use @settings(deadline=...) to either set a higher deadline, or to disable it with deadline=None.\n +------------------------------------\n=========================== short test summary info ===========================
FAILED tests/test_auth_properties.py::test_invalid_credentials_always_rejected
1 failed, 29 passed in 11.59s

View File

@@ -1,8 +1,8 @@
============================= test session starts =============================
platform win32 -- Python 3.13.9, pytest-9.0.2, pluggy-1.6.0 -- C:\NeoZQYY\.venv\Scripts\python.exe
platform win32 -- Python 3.13.9, pytest-9.0.2, pluggy-1.6.0 -- C:\Project\NeoZQYY\.venv\Scripts\python.exe
cachedir: .pytest_cache
hypothesis profile 'default'
rootdir: C:\NeoZQYY\apps\backend
rootdir: C:\Project\NeoZQYY\apps\backend
configfile: pyproject.toml
plugins: anyio-4.12.1, hypothesis-6.151.6, asyncio-1.3.0
asyncio: mode=Mode.STRICT, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function

View File

@@ -1,8 +1,8 @@
============================= test session starts =============================
platform win32 -- Python 3.13.9, pytest-9.0.2, pluggy-1.6.0 -- C:\NeoZQYY\.venv\Scripts\python.exe
platform win32 -- Python 3.13.9, pytest-9.0.2, pluggy-1.6.0 -- C:\Project\NeoZQYY\.venv\Scripts\python.exe
cachedir: .pytest_cache
hypothesis profile 'default'
rootdir: C:\NeoZQYY\apps\backend
rootdir: C:\Project\NeoZQYY\apps\backend
configfile: pyproject.toml
plugins: anyio-4.12.1, hypothesis-6.151.6, asyncio-1.3.0
asyncio: mode=Mode.STRICT, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function

View File

@@ -463,9 +463,21 @@ MVP 全链路验证端点,从 `test."xcx-test"` 表读取数据。
绩效明细PERF-2。返回指定月份的服务记录明细按日期分组支持分页。
查询参数:
- `month`份(格式 `YYYY-MM`,默认当月
- `year`份(必填
- `month`:月份(`1-12`,必填)
- `page`:页码(默认 1
- `page_size`:每页条数(默认 20
- `page_size`:每页条数(默认 20,最大 100
- `coach_id`:目标助教 ID可选管理者视角
权限分流(运行时通过 `get_user_permissions()` 实时检查,非 `require_permission` 中间件):
- 不带 `coach_id`(查自己):需 `view_tasks` 权限,`assistant_id` 由当前登录用户绑定决定
-`coach_id`(查他人):需 `view_board_coach` 权限(`manager`/`head_coach`/`staff``assistant_id` 使用传入值;同 site 约束由 `user.site_id` 隐式保证
- 缺少对应权限统一返回 `HTTP 403 权限不足`
对应服务层变更:`performance_service.get_records()` 新增 `assistant_id_override` 参数。
响应字段新增:
- `dateGroups[].records[].isScattered`:散客标记(`member_id ≤ 0` 时为 `true`),前端据此将客户姓名置灰
---
@@ -573,6 +585,63 @@ MVP 全链路验证端点,从 `test."xcx-test"` 表读取数据。
---
## 17A. 小程序助教详情 `/api/xcx/coaches`
所有端点需 JWTapproved 状态)+ `view_board_coach` 权限。
### GET `/api/xcx/coaches/{coach_id}/banner`
助教 banner 轻量信息。仅返回 `id` / `name` / `level` / `storeName`,用于 `coach-service-records` 等只需 banner 数据的页面首屏快速加载。
`/{coach_id}` 详情快一个数量级。
权限:`view_board_coach`
响应:`CoachBannerResponse`
```json
{ "id": 123, "name": "张三", "level": "金牌", "storeName": "朗朗桌球(总店)" }
```
### GET `/api/xcx/coaches/{coach_id}`
助教详情COACH-1。返回助教基础信息、绩效、收入、档位、任务分组、TOP 客户、近期服务、历史月份、备注。
权限:`view_board_coach`2026-03-27 权限改造 W4助教详情跟助教看板走
响应:`CoachDetailResponse`
字段说明:
- `storeName`:助教所在门店名(跟随被查看助教所在门店,供小程序 banner 展示)
- `performance``PerformanceMetrics`,与任务页 `PerformanceSummary` 同源(来自 `monthly_summary` 实时值)。字段从 6 扩展到 25
- 核心字段:`totalHours``totalIncome``totalCustomers``monthLabel``tierNodes``basicHours``bonusHours``currentTier`(数组下标 0-based`nextTierHours``tierCompleted``bonusMoney``incomeTrend``incomeTrendDir``prevMonth``currentTierLabel`
- 详情专属扩展:`customerBalance``tasksCompleted`
- 兼容旧字段(前端渐进适配):`monthlyHours``monthlySalary`
- `taskStats``CoachTaskStats`,当月任务完成统计(按 `task_type` 分类计数,数据源 `coach_tasks` 表)
- `callback``follow_up_visit` 完成数
- `recall``high_priority_recall` + `priority_recall` 完成数
- `topCustomers[].isScattered`:散客标识(`member_id ≤ 0`),前端据此将客户姓名置灰
- `serviceRecords[].isScattered`:同上,作用于近期服务记录
---
## 17B. 小程序客户详情 `/api/xcx/customers`
所有端点需 JWTapproved 状态)+ `view_board_customer` 权限。
### GET `/api/xcx/customers/{customer_id}`
客户详情CUST-1。返回客户基础信息、Banner 概览、AI 洞察、助教任务、心动助教、维客线索、消费记录、备注。
权限:`view_board_customer`2026-03-27 权限改造 W4客户详情跟客户看板走
响应:`CustomerDetailResponse`
字段说明(本次新增):
- `consumptionRecords[].foodDetail`:自定义食品类目名称(`string | null`)。为空时前端降级展示「食品酒水」
- `notes[].creatorName`:备注创建者姓名
- `notes[].creatorRole`:备注创建者角色
---
## 18. 小程序 CHAT `/api/xcx/chat`
所有端点需 JWTapproved 状态)。替代原 `xcx_ai_chat``/api/ai/*`),统一迁移到 `/api/xcx/chat/*` 路径。

View File

@@ -35,7 +35,7 @@
}
},
"mini-ios": {
"sdkVersion": "1.6.28",
"sdkVersion": "1.6.29",
"toolkitVersion": "0.0.9",
"useExtendedSdk": {
"WeAppOpenFuns": true,

View File

@@ -1,6 +1,6 @@
{
"description": "项目私有配置文件。此文件中的内容将覆盖 project.config.json 中的相同字段。项目的改动优先同步到此文件中。详见文档https://developers.weixin.qq.com/miniprogram/dev/devtools/projectconfig.html",
"projectname": "DEMO-NeoZQYY",
"projectname": "demo-miniprogram",
"setting": {
"compileHotReLoad": true,
"urlCheck": false,

View File

@@ -26,7 +26,7 @@ SCHEMA_ETL=meta
# API 配置(上游 SaaS API
# ------------------------------------------------------------------------------
API_BASE=https://pc.ficoo.vip/apiprod/admin/v1/
API_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGllbnQtdHlwZSI6IjQiLCJ1c2VyLXR5cGUiOiIxIiwiaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93cy8yMDA4LzA2L2lkZW50aXR5L2NsYWltcy9yb2xlIjoiMTIiLCJyb2xlLWlkIjoiMTIiLCJ0ZW5hbnQtaWQiOiIyNzkwNjgzMTYwNzA5OTU3Iiwibmlja25hbWUiOiLnp5_miLfnrqHnkIblkZjvvJrmganmgakxIiwic2l0ZS1pZCI6IjAiLCJtb2JpbGUiOiIxMzgxMDUwMjMwNCIsInNpZCI6IjI5NTA0ODk2NTgzOTU4NDUiLCJzdGFmZi1pZCI6IjMwMDk5MTg2OTE1NTkwNDUiLCJvcmctaWQiOiIwIiwicm9sZS10eXBlIjoiMyIsInJlZnJlc2hUb2tlbiI6Ik9OUTkreFhSWjFPVFhzQWhieTJVa3RyVXR6UzdldVE5Q1VVQ3QzQ1ArMlE9IiwicmVmcmVzaEV4cGlyeVRpbWUiOiIyMDI2LzQvMTQg5LiL5Y2INTo0MToyNSIsIm5lZWRDaGVja1Rva2VuIjoiZmFsc2UiLCJleHAiOjE3NzYxNTk2ODUsImlzcyI6InRlc3QiLCJhdWQiOiJVc2VyIn0.KyULo2a6dirmiAkka5Ocu_ieoZY5VsVWVqMj5smwvmE
API_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGllbnQtdHlwZSI6IjQiLCJ1c2VyLXR5cGUiOiIxIiwiaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93cy8yMDA4LzA2L2lkZW50aXR5L2NsYWltcy9yb2xlIjoiMTIiLCJyb2xlLWlkIjoiMTIiLCJ0ZW5hbnQtaWQiOiIyNzkwNjgzMTYwNzA5OTU3Iiwibmlja25hbWUiOiLnp5_miLfnrqHnkIblkZjvvJrmganmgakxIiwic2l0ZS1pZCI6IjAiLCJtb2JpbGUiOiIxMzgxMDUwMjMwNCIsInNpZCI6IjI5NTA0ODk2NTgzOTU4NDUiLCJzdGFmZi1pZCI6IjMwMDk5MTg2OTE1NTkwNDUiLCJvcmctaWQiOiIwIiwicm9sZS10eXBlIjoiMyIsInJlZnJlc2hUb2tlbiI6IlJZNldUM2w1UUdLb2hiVnJnd3kzM3pZeVNUZjk3ZkNQS2xwSXNPa0RORTg9IiwicmVmcmVzaEV4cGlyeVRpbWUiOiIyMDI2LzQvMjQg5LiK5Y2IMToyMzo0OSIsIm5lZWRDaGVja1Rva2VuIjoiZmFsc2UiLCJleHAiOjE3NzY5NjUwMjksImlzcyI6InRlc3QiLCJhdWQiOiJVc2VyIn0.l1qPPgUy6Xq0XZprDhhuyZ1phL-zpzf-W7VHQy86DkA
API_TIMEOUT=20
API_PAGE_SIZE=200
API_RETRY_MAX=3
@@ -35,9 +35,9 @@ API_RETRY_MAX=3
# 路径配置
# CHANGE 2026-02-19 | 统一迁移到 export/ETL-Connectors/feiqiu/ 下
# ------------------------------------------------------------------------------
EXPORT_ROOT=C:/NeoZQYY/export/ETL-Connectors/feiqiu/JSON
LOG_ROOT=C:/NeoZQYY/export/ETL-Connectors/feiqiu/LOGS
FETCH_ROOT=C:/NeoZQYY/export/ETL-Connectors/feiqiu/JSON
EXPORT_ROOT=C:/Project/NeoZQYY/export/ETL-Connectors/feiqiu/JSON
LOG_ROOT=C:/Project/NeoZQYY/export/ETL-Connectors/feiqiu/LOGS
FETCH_ROOT=C:/Project/NeoZQYY/export/ETL-Connectors/feiqiu/JSON
WRITE_PRETTY_JSON=true
# ------------------------------------------------------------------------------

View File

@@ -19,9 +19,17 @@
│ 明细数据 — 清洗、标准化、关联 │
│ 维度表走 SCD2缓慢变化维度
│ 事实表按时间增量写入 │
└───────────────┬───────────────────────
DWS 汇总任务
└───────┬───────────────┬───────────────┘
CORE_DIM_SYNC
│ DWS 汇总任务
│ ┌───────────────────────┐
│ │ Core 层core
│ │ 跨平台统一维度层 │
│ │ dim_assistant / dim_ │
│ │ member / dim_site / │
│ │ dim_table │
│ └───────────────────────┘
┌───────────────────────────────────────┐
│ DWS 层dws
│ 数据服务 — 汇总、指标 │
@@ -34,6 +42,7 @@
│ INDEX 层dws
│ 自定义指数算法 │
│ WBI / NCI / RS / OS / MS / ML │
│ 日快照存储stat_date
└───────────────────────────────────────┘
```
@@ -86,6 +95,7 @@
| 会员分析 | `DWS_MEMBER_CONSUMPTION``DWS_MEMBER_VISIT` | 每日 |
| 工资计算 | `DWS_ASSISTANT_SALARY` | 每月(月初) |
| 指数算法 | `DWS_WINBACK_INDEX``DWS_NEWCONV_INDEX``DWS_RELATION_INDEX` | 每 2-4 小时 |
| Core 维度同步 | `CORE_DIM_SYNC` | 每日DWD_LOAD_FROM_ODS 之后) |
### 自定义指数算法
@@ -102,6 +112,44 @@
公共参数:`percentile_lower/upper`(分位截断锚点)、`ewma_alpha`(指数加权移动平均平滑系数)。
### 指数日快照2026-04 重构)
三张指数表(`relation_index` / `winback_index` / `newconv_index`)新增 `stat_date DATE NOT NULL DEFAULT CURRENT_DATE` 字段,唯一约束扩展为 `(site_id, member_id[, assistant_id], stat_date)`。每次任务运行追加当日快照而非覆盖历史为指数回测、历史演化分析提供原始数据基础。Winback 任务配套 365 天保留策略,清理超过 365 天的历史快照以控制表体积。
## Core 维度层(跨平台统一维度)
- Schema`core`
- 定位:跨平台统一维度层,屏蔽 ODS/DWD 多数据源(飞球 / 美团 / 后续新平台)的字段差异,供 `app` 视图层和业务库 FDW 统一引用
- 核心任务:`CORE_DIM_SYNC`(归属 DWS 层任务目录 `tasks/dws/core_dim_sync_task.py`
- 上游依赖:`DWD_LOAD_FROM_ODS`(必须先完成 DWD 维度表 SCD2 写入)
- 同步范围4 张维度表 — `dim_assistant` / `dim_member` / `dim_site` / `dim_table`
- 数据来源DWD 层 `scd2_is_current = 1` 的当前版本快照
- 同步策略TRUNCATE + 全量 INSERT维度表行数较小执行时间极短避免增量合并复杂度
- 起因:修复 `app` 视图在 core 维度为空时返回空结果的问题(关联审计 `docs/audit/changes/2026-04-15__meituan-settle-core-sync.md`
## Utility 工具任务
工具任务位于 `tasks/utility/`,不在日常编排链路中,由手动 CLI 触发,服务于 schema 初始化、历史数据回填和回测推演等一次性或周期性离线需求。
### DWS_INDEX_BACKFILL指数历史快照回填
- 源码:`tasks/utility/index_backfill_task.py`
- 职责:逐天调用三个指数任务(`DWS_RELATION_INDEX` / `DWS_WINBACK_INDEX` / `DWS_NEWCONV_INDEX`)生成历史日快照,回填缺失的 `stat_date`
- 性能优化:三个指数任务实例在整轮回填中创建 1 次、循环 N 天复用,避免 N×3 次初始化开销(以 255 天为例避免 765 次初始化)
- 前置条件:三张指数表已完成日快照改造(`stat_date` 字段 + 复合唯一约束)
- 用途:指数算法参数调优验证、历史对照、补齐生产数据缺口
- 关联专题P19 指数回测框架 Phase 1e
### DWS_TASK_SIMULATION任务引擎历史推演
- 源码:`tasks/utility/task_simulation_task.py`
- 职责:基于指数日快照,逐天重放 `task_generator` + `recall_detector` 逻辑,还原任务的完整生命周期(生成 / 升级 / 转移 / 完成 / follow_up
- 前置条件:需先运行 `DWS_INDEX_BACKFILL` 产出完整的历史指数快照作为推演输入
- 数据库连接:额外使用业务库独立连接(`APP_DB_DSN` 环境变量),推演结果写入业务库任务相关表
- 任务引擎双模式配合:`task_engine.py` 同期升级为双模式 — HTTP 实时模式(日常生产)+ 推演模式(回测,由 `as_of_date` 参数驱动),两种模式共用核心规则逻辑
- 用途:回测验证分级分配、升级、转移规则的参数配置与边界行为
- 关联专题P19 指数回测框架 Phase 2
## ETL 管理层
- Schema`meta`

View File

@@ -44,8 +44,10 @@
│ ├── ods/ ODS 抓取任务23 个业务实体) │
│ ├── dwd/ DWD 装载任务(维度/事实/质检) │
│ ├── dws/ DWS 汇总与指数任务 │
│ │ ── index/ 指数计算WBI/NCI/RS/OS/MS/ML
├── utility/ 工具任务Schema 初始化等)
│ │ ── index/ 指数计算WBI/NCI/RS/OS/MS/ML
│ └── core_dim_sync Core 维度层全量同步4 维度)
│ ├── utility/ 工具任务Schema 初始化 / │
│ │ 指数回填 / 任务引擎推演) │
│ └── verification/ ETL 后置校验 │
└──────────┬──────────────────────────────────────────────┘

View File

@@ -42,6 +42,12 @@
| 23 | create_time | TIMESTAMPTZ | YES | | 创建时间 |
| 24 | member_discount_money | NUMERIC(18,2) | YES | | 会员折扣金额 |
| 25 | coupon_sale_id | BIGINT | YES | | 优惠券销售 ID |
| 26 | mt_settlement_price | NUMERIC(14,2) | YES | | 美团结算价(扣费后实际到手,元) |
| 27 | mt_gross_income | NUMERIC(14,2) | YES | | 美团总收入(售价,元) |
| 28 | mt_service_fee | NUMERIC(14,2) | YES | | 美团平台技术服务费(负数,元) |
| 29 | mt_marketing_fee | NUMERIC(14,2) | YES | | 商家营销费用(负数,元) |
| 30 | mt_other_adjust | NUMERIC(14,2) | YES | | 其他调整(元) |
| 31 | mt_import_time | TIMESTAMPTZ | YES | | 美团结算数据导入时间 |
## 使用说明

View File

@@ -60,7 +60,7 @@
## 分类映射(完整台桌清单)
### BILLIARD 🎱 中式/追分43台)
### BILLIARD 🎱 中式/追分44台)
| 区域 | 台桌 |
|------|------|
| A区 | A1-A1818台 |
@@ -68,6 +68,7 @@
| C区 | C1-C66台 |
| VIP包厢 | VIP1, VIP2, VIP3 |
| TV台 | TV |
| 美洲豹赛台 | 美洲豹赛台 |
### SNOOKER 斯诺克5台
| 区域 | 台桌 |
@@ -110,6 +111,7 @@
| 2026-03-07 | 新增 source_table_name 支持台桌级细分;废弃 BILLIARD_VIP |
| 2026-03-09 | 改为纯台桌级精确映射,删除所有 LIKE 和区域级映射 |
| 2026-03-20 | 新增 sort_order 字段,控制前端筛选器分类显示排序 |
| 2026-04-11 | 新增美洲豹赛台 → BILLIARDBILLIARD 总数 43→44 |
## 验证 SQL
@@ -133,12 +135,12 @@ ORDER BY source_area_name, source_table_name;
-- 4. 确认总记录数
SELECT COUNT(*) AS total FROM dws.cfg_area_category;
-- 期望: 7574 台桌 + 1 DEFAULT 兜底)
-- 期望: 7675 台桌 + 1 DEFAULT 兜底)
-- 5. 按分类汇总
SELECT category_code, COUNT(*) AS cnt
FROM dws.cfg_area_category
WHERE match_type = 'EXACT'
GROUP BY category_code ORDER BY category_code;
-- 期望: BILLIARD=43, KTV=7, MAHJONG=11, SNOOKER=5, SPECIAL=8
-- 期望: BILLIARD=44, KTV=7, MAHJONG=11, SNOOKER=5, SPECIAL=8
```

View File

@@ -12,7 +12,7 @@
| 唯一键 | (site_id, member_id, time_window, category_code) |
| 数据来源 | dwd_table_fee_log + dim_table + cfg_area_category |
| 更新频率 | 每日全量重建(按 site_id 删除后重新插入) |
| 说明 | 按时间窗口计算客户在四大项目的消费时长占比≥25% 分配标签。散客不参与。 |
| 说明 | 按每位客户最近 30 次消费计算四大项目时长占比≥25% 分配标签。散客不参与。 |
## 字段说明
@@ -36,12 +36,11 @@
## 时间窗口
客户看板使用 2 个时间窗口:
| 枚举值 | 说明 |
|--------|------|
| LAST_30_DAYS | 近30天含今天base_date-29天 ~ base_date |
| LAST_60_DAYS | 近60天含今天base_date-59天 ~ base_date |
| LAST_30_VISITS | 每位客户最近 30 次消费(开台记录),按 ledger_end_time 倒序 |
> 2026-04-11 改为按消费次数取数,不再按固定日期范围,避免长期未到店或来店频率不稳定的客户标签丢失。
## 索引
@@ -55,26 +54,29 @@
## 数据链路
```
dwd.dwd_table_fee_log (ledger_count, site_table_id)
dwd.dwd_table_fee_log (ledger_count, site_table_id, ledger_end_time)
→ ROW_NUMBER() OVER (PARTITION BY member_id ORDER BY ledger_end_time DESC)
→ 取最近 30 条记录LAST_N_VISITS=30
→ 按 (member_id, site_table_id) 聚合 ledger_count
→ JOIN dwd.dim_table (site_table_id → table_id, scd2_is_current=1)
→ get_area_category(area_name, table_name) -- 通过 cfg_area_category 映射
→ 只保留 BILLIARD/SNOOKER/MAHJONG/KTV
排除散客(member_id IS NULL 或 = 0
→ 按 (member_id, category_code) 汇总 ledger_count
按 (member_id, category_code) 汇总
→ 计算占比 percentage = duration_seconds / total_seconds
→ ≥0.25 标记 is_tagged=TRUE
→ 写入 dws.dws_member_project_tag
→ 写入 dws.dws_member_project_tagtime_window='LAST_30_VISITS'
```
### 关键规则
1. 数据链路走 `dim_table`(通过 `site_table_id` JOIN不直接用事实表的 `site_table_area_name`
2. 客户时长使用 `ledger_count`(计费时长),不使用 `income_seconds`(那是助教工作时长)
3. 散客member_id=0 或 NULL不参与标签计算
4. 只计算四大项目BILLIARD/SNOOKER/MAHJONG/KTV
5. 标签阈值 25%`TAG_THRESHOLD = 0.25`
6. 全量删除重建策略:按 `site_id` 删除后重新插入所有时间窗口
7. `COALESCE(is_delete, 0) = 0` 过滤已删除的台费记录
1. 按每位客户最近 30 次消费取数,不按固定日期范围,避免来店频率不稳定的客户标签丢失
2. 数据链路走 `dim_table`(通过 `site_table_id` JOIN不直接用事实表的 `site_table_area_name`
3. 客户时长使用 `ledger_count`(计费时长),不使用 `income_seconds`(那是助教工作时长)
4. 散客member_id=0 或 NULL不参与标签计算
5. 只计算四大项目BILLIARD/SNOOKER/MAHJONG/KTV
6. 标签阈值 25%`TAG_THRESHOLD = 0.25`
7. 全量删除重建策略:按 `site_id` 删除后重新插入
8. `COALESCE(is_delete, 0) = 0` 过滤已删除的台费记录
## ETL 任务
@@ -90,6 +92,7 @@ dwd.dwd_table_fee_log (ledger_count, site_table_id)
| 日期 | 变更 | 说明 |
|------|------|------|
| 2026-03-07 | 新建表 | 支持客户看板按项目类型筛选 |
| 2026-04-11 | 时间窗口改为消费次数 | LAST_30/60_DAYS → LAST_30_VISITS按最近30次消费取数 |
## 验证 SQL

View File

@@ -97,6 +97,12 @@ graph LR
| `DWD_LOAD_FROM_ODS` | `DwdLoadTask` | 核心装载:遍历 TABLE_MAP维度走 SCD2事实走增量 | [查看](dwd_tasks.md) |
| `DWD_QUALITY_CHECK` | `DwdQualityTask` | ODS 与 DWD 行数/金额核对,输出 JSON 报表 | [查看](dwd_tasks.md) |
### Core 层(统一维度)
| 任务代码 | Python 类 | 目标表 | 粒度 | 说明 |
|----------|-----------|--------|------|------|
| `CORE_DIM_SYNC` | `CoreDimSyncTask` | `core.dim_assistant/member/site/table` | 全量 | 将 DWD 当前版本scd2_is_current=1同步到 core 统一维度层TRUNCATE+INSERT 幂等 |
### DWS 层(数据服务)
#### 助教业绩域

View File

@@ -57,8 +57,11 @@ from tasks.dws import (
)
# CHANGE [2026-07-14] intent: 合并 MV 刷新 + 数据清理为 DWS_MAINTENANCE
from tasks.dws.maintenance_task import DwsMaintenanceTask
from tasks.dws.core_dim_sync_task import CoreDimSyncTask
# CHANGE 2026-03-29 | DWS_TASK_ENGINE编排后端任务引擎完成检查→过期检查→任务生成
from tasks.dws.task_engine import DwsTaskEngineTask
# CHANGE 2026-04-12 | 指数回填工具任务
from tasks.utility.index_backfill_task import IndexBackfillTask
@dataclass
@@ -150,6 +153,9 @@ default_registry.register("SEED_DWS_CONFIG", SeedDwsConfigTask, task_type="utili
# ── 校验类任务 ────────────────────────────────────────────────
default_registry.register("DATA_INTEGRITY_CHECK", DataIntegrityTask, requires_db_config=False, task_type="verification")
# ── Core 层同步任务 ────────────────────────────────────────────
default_registry.register("CORE_DIM_SYNC", CoreDimSyncTask, layer="DWS", depends_on=["DWD_LOAD_FROM_ODS"])
# ── DWS 层业务任务 ────────────────────────────────────────────
default_registry.register("DWS_BUILD_ORDER_SUMMARY", DwsBuildOrderSummaryTask, requires_db_config=False, layer="DWS")
default_registry.register("DWS_ASSISTANT_DAILY", AssistantDailyTask, layer="DWS")
@@ -201,5 +207,11 @@ default_registry.register("DWS_SPENDING_POWER_INDEX", SpendingPowerIndexTask, re
# CHANGE 2026-03-29 | DWS_TASK_ENGINEDWS 指数计算完成后执行后端任务引擎
# depends_on: 所有指数任务——任务生成依赖 WBI/NCI/RS 指数数据
default_registry.register("DWS_TASK_ENGINE", DwsTaskEngineTask, requires_db_config=False, layer="INDEX", depends_on=[
"DWS_WINBACK_INDEX", "DWS_NEWCONV_INDEX", "DWS_RELATION_INDEX",
"DWS_WINBACK_INDEX", "DWS_NEWCONV_INDEX", "DWS_RELATION_INDEX", "DWS_INDEX_BACKFILL",
])
# ── 回填 / 推演工具任务 ─────────────────────────────────────────
# CHANGE 2026-04-12 | 指数日快照回填工具任务
# layer="INDEX" 确保拓扑排序中 DWS_TASK_ENGINE 在其后执行(同层显式依赖优先)
# depends_on DWD_LOAD_FROM_ODS回填读取 dwd.* 表,需要 DWD 数据已入库
default_registry.register("DWS_INDEX_BACKFILL", IndexBackfillTask, requires_db_config=False, layer="INDEX", task_type="utility", depends_on=["DWD_LOAD_FROM_ODS"])

View File

@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
"""一次性调研脚本:拉取全部团购详情并写入 ods.group_buy_package_details。
用法cwd = C:\\NeoZQYY/
用法cwd = C:\\Project\\NeoZQYY/
python apps/etl/connectors/feiqiu/scripts/research_coupon_details.py
流程:

View File

@@ -31,6 +31,7 @@ from .finance_board_cache import FinanceBoardCacheTask
from .coach_area_hours_task import CoachAreaHoursTask
from .finance_base_task import FinanceBaseTask
from .maintenance_task import DwsMaintenanceTask
from .core_dim_sync_task import CoreDimSyncTask
from .goods_stock_daily_task import GoodsStockDailyTask
from .goods_stock_weekly_task import GoodsStockWeeklyTask
from .goods_stock_monthly_task import GoodsStockMonthlyTask
@@ -73,6 +74,7 @@ __all__ = [
"FinanceDiscountDetailTask",
"CoachAreaHoursTask",
"DwsMaintenanceTask",
"CoreDimSyncTask",
# 库存维度
"GoodsStockDailyTask",
"GoodsStockWeeklyTask",

View File

@@ -0,0 +1,173 @@
# -*- coding: utf-8 -*-
"""
Core 维度同步任务
功能说明:
将 DWD 层当前版本scd2_is_current=1的维度数据同步到 core 层。
core 层作为跨平台统一维度层,屏蔽 ODS/DWD 多数据源差异。
同步表:
- core.dim_assistant <- dwd.dim_assistant
- core.dim_member <- dwd.dim_member
- core.dim_site <- dwd.dim_site
- core.dim_table <- dwd.dim_table
更新策略:
TRUNCATE + INSERT 全量刷新(维度表数据量小,全量代价低)
作者ETL 团队
创建日期2026-04-15
"""
from __future__ import annotations
from typing import Any, Dict, List
from .base_dws_task import BaseDwsTask, TaskContext
# 同步映射:(core 表, DWD 源表, 字段映射)
# 字段映射格式:(core_col, dwd_expr)
SYNC_TABLES = [
{
"core_table": "core.dim_assistant",
"dwd_source": "dwd.dim_assistant",
"columns": [
("assistant_id", "assistant_id"),
("tenant_id", "tenant_id"),
("site_id", "site_id"),
("real_name", "real_name"),
("nickname", "nickname"),
("mobile", "mobile"),
("level", "level"),
("assistant_status", "assistant_status"),
("leave_status", "leave_status"),
],
},
{
"core_table": "core.dim_member",
"dwd_source": "dwd.dim_member",
"columns": [
("member_id", "member_id"),
("system_member_id", "system_member_id"),
("tenant_id", "tenant_id"),
("register_site_id", "register_site_id"),
("mobile", "mobile"),
("nickname", "nickname"),
("member_card_grade_name", "member_card_grade_name"),
("status", "1"), # DWD 无 status 字段scd2_is_current=1 即有效
],
},
{
"core_table": "core.dim_site",
"dwd_source": "dwd.dim_site",
"columns": [
("site_id", "site_id"),
("tenant_id", "tenant_id"),
("shop_name", "shop_name"),
("site_label", "site_label"),
("shop_status", "shop_status"),
],
},
{
"core_table": "core.dim_table",
"dwd_source": "dwd.dim_table",
"columns": [
("table_id", "table_id"),
("site_id", "site_id"),
("table_name", "table_name"),
("site_table_area_name", "site_table_area_name"),
("table_price", "table_price"),
],
},
]
class CoreDimSyncTask(BaseDwsTask):
"""Core 维度同步任务DWD -> core 全量刷新"""
# 无日期列,全量刷新
DATE_COL = None
def get_task_code(self) -> str:
return "CORE_DIM_SYNC"
def get_target_table(self) -> str:
# 多表同步,此方法不直接使用
return "core_dim_sync"
def get_primary_keys(self) -> List[str]:
return []
def extract(self, context: TaskContext) -> Dict[str, Any]:
"""提取 DWD 当前版本维度数据"""
result = {}
for table_def in SYNC_TABLES:
core_table = table_def["core_table"]
dwd_source = table_def["dwd_source"]
dwd_exprs = [col[1] for col in table_def["columns"]]
select_clause = ", ".join(dwd_exprs)
sql = f"SELECT {select_clause} FROM {dwd_source} WHERE scd2_is_current = 1"
with self.db.conn.cursor() as cur:
cur.execute(sql)
rows = cur.fetchall()
core_cols = [col[0] for col in table_def["columns"]]
result[core_table] = {
"rows": [dict(zip(core_cols, row)) for row in rows],
"columns": core_cols,
}
self.logger.info(
"%s: %s <- %s: %d rows",
self.get_task_code(), core_table, dwd_source, len(rows),
)
return {"tables": result, "site_id": context.store_id}
def transform(self, extracted: Dict[str, Any], context: TaskContext) -> Dict[str, Any]:
"""直通,无需转换"""
return extracted
def load(self, transformed: Dict[str, Any], context: TaskContext) -> dict:
"""TRUNCATE + INSERT 全量刷新"""
tables = transformed.get("tables", {})
total_inserted = 0
total_deleted = 0
for core_table, data in tables.items():
rows = data["rows"]
columns = data["columns"]
with self.db.conn.cursor() as cur:
# TRUNCATE
cur.execute(f"TRUNCATE {core_table}")
self.logger.info("%s: TRUNCATE %s", self.get_task_code(), core_table)
# INSERT
if rows:
cols_str = ", ".join(columns)
placeholders = ", ".join(["%s"] * len(columns))
insert_sql = f"INSERT INTO {core_table} ({cols_str}) VALUES ({placeholders})"
for row in rows:
values = [row.get(col) for col in columns]
cur.execute(insert_sql, values)
total_inserted += len(rows)
self.logger.info(
"%s: INSERT %s: %d rows",
self.get_task_code(), core_table, len(rows),
)
return {
"counts": {
"fetched": total_inserted,
"inserted": total_inserted,
"updated": 0,
"skipped": 0,
"errors": 0,
},
"extra": {"tables_synced": len(tables)},
}

View File

@@ -99,8 +99,11 @@ _RECHARGE_FIELDS = [
# 所有仅 all 行有值的字段
_ALL_ONLY_FIELDS = _CASHFLOW_FIELDS + _CARD_FIELDS + _RECHARGE_FIELDS
# 按区域聚合的字段(收入 + 优惠 + order_count
_AREA_AGG_FIELDS = _REVENUE_FIELDS + _DISCOUNT_FIELDS + ["confirmed_income", "order_count"]
# 按区域聚合的字段(收入 + 优惠 + order_count + member_order_count
_AREA_AGG_FIELDS = _REVENUE_FIELDS + _DISCOUNT_FIELDS + ["confirmed_income", "order_count", "member_order_count"]
# 计数字段int 类型,非 Decimal— 用于 _build_area_row / _build_sum_row 区分类型转换
_COUNT_FIELDS = {"order_count", "member_order_count"}
_ZERO = Decimal("0")
@@ -190,7 +193,9 @@ class FinanceAreaDailyTask(FinanceBaseTask):
sh.adjust_amount,
sh.member_discount_amount,
sh.rounding_amount,
sh.gift_card_amount
sh.gift_card_amount,
-- 会员标识(散客 member_id ≤ 0会员 member_id > 0详见 CLAUDE.md DWS 规范)
sh.member_id
FROM dwd.dwd_settlement_head sh
LEFT JOIN dwd.dim_table dt
ON dt.table_id = sh.table_id
@@ -439,6 +444,13 @@ def transform_area_daily(
confirmed_income = gross - discount_total
# 会员订单判定(散客 member_id ≤ 0 不计入,与全店 finance_base_task.py 逻辑一致)
member_id_raw = row.get("member_id")
try:
is_member_order = 1 if (member_id_raw is not None and int(member_id_raw) > 0) else 0
except (TypeError, ValueError):
is_member_order = 0
fields = {
"table_fee_amount": table_fee,
"goods_amount": goods,
@@ -454,19 +466,24 @@ def transform_area_daily(
"discount_total": discount_total,
"confirmed_income": confirmed_income,
"order_count": 1,
"member_order_count": is_member_order,
}
# 累加到具体区域
# 累加到具体区域(未匹配区域的订单累加到 _unknown 桶,供 all 行汇总使用)
# CHANGE 2026-04-23 | 修复 pre-existing bugarea_code 为 None 的订单之前既不计入具体区域也不计入 all
# 导致全店 order_count/member_order_count > 各区域之和。现在 _unknown 桶会合入 all 汇总。
if area_code is not None:
bucket = area_agg[sd][area_code]
for k, v in fields.items():
bucket[k] = bucket[k] + v
else:
bucket = area_agg[sd]["_unknown"]
for k, v in fields.items():
bucket[k] = bucket[k] + v
# 汇总输出未知区域名称(避免逐行 warning 刷屏)
if _unknown_area_counts:
summary = ", ".join(f"'{k}': {v}" for k, v in _unknown_area_counts.items())
logger.warning(
"DWS_FINANCE_AREA_DAILY: 共 %d 条结算单区域未匹配(不计入具体区域,仅计入 all: %s",
"DWS_FINANCE_AREA_DAILY: 共 %d 条结算单区域未匹配(已计入 all 但不计入任何具体区域): %s",
sum(_unknown_area_counts.values()),
summary,
)
@@ -505,13 +522,24 @@ def transform_area_daily(
source_rows=hall_source,
)
# all 行 = 各具体区域之和(收入/优惠/order_count+ 全局现金流/充值/卡消费
# all 行 = 各具体区域之和 + 未匹配区域订单_unknown 桶+ 全局现金流/充值/卡消费
# CHANGE 2026-04-23 | 修复全店 vs 区域和差异:未匹配区域订单通过 _unknown 桶合入 all
all_sources = list(specific_rows.values())
unknown_bucket = day_agg.get("_unknown", {})
if unknown_bucket:
all_sources.append(_build_area_row(
site_id=site_id,
tenant_id=tenant_id,
stat_date=sd,
area_code="_unknown",
agg=unknown_bucket,
))
all_row = _build_sum_row(
site_id=site_id,
tenant_id=tenant_id,
stat_date=sd,
area_code="all",
source_rows=list(specific_rows.values()),
source_rows=all_sources,
)
# 填充全局现金流/充值/卡消费
gs = global_index.get(sd, {})
@@ -541,9 +569,9 @@ def _build_area_row(
"stat_date": stat_date,
"area_code": area_code,
}
# 收入 + 优惠 + confirmed_income + order_count
# 收入 + 优惠 + confirmed_income + order_count + member_order_count
for field in _AREA_AGG_FIELDS:
if field == "order_count":
if field in _COUNT_FIELDS:
row[field] = int(agg.get(field, 0))
else:
row[field] = agg.get(field, _ZERO)
@@ -568,7 +596,7 @@ def _build_sum_row(
"area_code": area_code,
}
for field in _AREA_AGG_FIELDS:
if field == "order_count":
if field in _COUNT_FIELDS:
row[field] = sum(int(r.get(field, 0)) for r in source_rows)
else:
row[field] = sum(

View File

@@ -530,10 +530,12 @@ class MemberIndexBaseTask(BaseIndexTask):
enable_stop_exception = int(params.get('enable_stop_high_balance_exception', 0)) == 1
high_balance_threshold = float(params.get('high_balance_threshold', 1000))
# CHANGE 2026-04-12 | STOP 不再排除:超出 recency 窗口的老客归入 OLD 继续计算
# WBI 衰减公式自然给出高分,避免最需要召回的客户被遗漏
if data.t_a >= recency_days:
if enable_stop_exception and data.sv_balance >= high_balance_threshold:
return "STOP", "STOP_HIGH_BALANCE", True
return "STOP", "STOP", False
return "OLD", "STOP_OVERDUE", True
new_visit_threshold = int(params.get('new_visit_threshold', 2))
new_days_threshold = int(params.get('new_days_threshold', 30))

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import math
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Dict, List, Optional
from .member_index_base import MemberActivityData, MemberIndexBaseTask
@@ -202,9 +203,10 @@ class NewconvIndexTask(MemberIndexBaseTask):
avg_raw=sum(all_raw) / len(all_raw)
)
# P19: 回测模式传入 calc_time
calc_time = (context.as_of_date if context and context.as_of_date else None)
inserted = self._save_newconv_data(newconv_list, calc_time=calc_time)
# 日快照模式:始终按 stat_date 写入
now = (context.as_of_date if context and context.as_of_date else None) or datetime.now(self.tz)
stat_date = now.date() if hasattr(now, 'date') else now
inserted = self._save_newconv_data(newconv_list, stat_date=stat_date)
self.logger.info("NCI calculation finished, inserted %d rows", inserted)
return {
@@ -288,30 +290,23 @@ class NewconvIndexTask(MemberIndexBaseTask):
if data.raw_score < 0:
data.raw_score = 0.0
def _save_newconv_data(self, data_list: List[MemberNewconvData], *, calc_time=None) -> int:
"""保存 NCI 数据"""
def _save_newconv_data(self, data_list: List[MemberNewconvData], *, stat_date) -> int:
"""日快照模式:按 (site_id, stat_date) 删除后插入。"""
if not data_list:
return 0
site_id = data_list[0].activity.site_id
# P19: 回测模式按 calc_time 删除(保留其他快照),正常模式按 site_id 全量刷新
use_param_time = calc_time is not None
with self.db.conn.cursor() as cur:
if use_param_time:
cur.execute(
"DELETE FROM dws.dws_member_newconv_index WHERE site_id = %s AND calc_time = %s",
(site_id, calc_time),
)
else:
cur.execute(
"DELETE FROM dws.dws_member_newconv_index WHERE site_id = %s",
(site_id,),
)
from datetime import date as date_type
if not isinstance(stat_date, date_type):
stat_date = stat_date.date() if hasattr(stat_date, 'date') else stat_date
# P19: 回测模式传入 calc_time正常模式用 NOW()
use_param_time = calc_time is not None
time_placeholder = "%s, %s, %s" if use_param_time else "NOW(), NOW(), NOW()"
insert_sql = f"""
site_id = data_list[0].activity.site_id
with self.db.conn.cursor() as cur:
cur.execute(
"DELETE FROM dws.dws_member_newconv_index WHERE site_id = %s AND stat_date = %s",
(site_id, stat_date),
)
insert_sql = """
INSERT INTO dws.dws_member_newconv_index (
site_id, tenant_id, member_id,
status, segment,
@@ -325,7 +320,7 @@ class NewconvIndexTask(MemberIndexBaseTask):
raw_score_welcome, raw_score_convert, raw_score,
display_score_welcome, display_score_convert, display_score,
last_wechat_touch_time,
calc_time, created_at, updated_at
calc_time, created_at, updated_at, stat_date
) VALUES (
%s, %s, %s,
%s, %s,
@@ -339,32 +334,40 @@ class NewconvIndexTask(MemberIndexBaseTask):
%s, %s, %s,
%s, %s, %s,
%s,
{time_placeholder}
NOW(), NOW(), NOW(), %s
)
"""
inserted = 0
# 批量写入executemany 替代逐行 execute
batch_params = []
for data in data_list:
activity = data.activity
batch_params.append((
activity.site_id, activity.tenant_id, activity.member_id,
data.status, data.segment,
activity.member_create_time, activity.first_visit_time, activity.last_visit_time, activity.last_recharge_time,
activity.t_v, activity.t_r, activity.t_a,
activity.visits_14d, activity.visits_30d, activity.visits_60d, activity.visits_total,
activity.spend_30d, activity.spend_180d, activity.sv_balance, activity.recharge_60d_amt,
activity.interval_count,
data.need_new, data.salvage_new, data.recharge_new, data.value_new,
data.welcome_new,
data.raw_score_welcome, data.raw_score_convert, data.raw_score,
data.display_score_welcome, data.display_score_convert, data.display_score,
None,
stat_date,
))
from psycopg2.extras import execute_batch
with self.db.conn.cursor() as cur:
for data in data_list:
activity = data.activity
params = (
activity.site_id, activity.tenant_id, activity.member_id,
data.status, data.segment,
activity.member_create_time, activity.first_visit_time, activity.last_visit_time, activity.last_recharge_time,
activity.t_v, activity.t_r, activity.t_a,
activity.visits_14d, activity.visits_30d, activity.visits_60d, activity.visits_total,
activity.spend_30d, activity.spend_180d, activity.sv_balance, activity.recharge_60d_amt,
activity.interval_count,
data.need_new, data.salvage_new, data.recharge_new, data.value_new,
data.welcome_new,
data.raw_score_welcome, data.raw_score_convert, data.raw_score,
data.display_score_welcome, data.display_score_convert, data.display_score,
None,
)
if use_param_time:
params = params + (calc_time, calc_time, calc_time)
cur.execute(insert_sql, params)
inserted += cur.rowcount
execute_batch(cur, insert_sql, batch_params, page_size=200)
inserted = len(batch_params)
# 保留策略:清理 365 天前的快照
cur.execute(
"DELETE FROM dws.dws_member_newconv_index WHERE site_id = %s AND stat_date < CURRENT_DATE - INTERVAL '365 days'",
(site_id,),
)
self.db.conn.commit()
return inserted

View File

@@ -180,9 +180,9 @@ class RelationIndexTask(BaseIndexTask):
self._apply_display_scores(pair_map, params_rs, params_ms, params_ml, site_id)
# P19: 仅回测模式传 calc_time按 calc_time 删除保留其他快照),正常模式传 None按 site_id 全量刷新)
backtest_calc_time = now if (context and context.as_of_date) else None
inserted = self._save_relation_rows(site_id, list(pair_map.values()), calc_time=backtest_calc_time)
# 日快照模式:始终按 stat_date 写入/覆盖,支持多日快照共存
stat_date = now.date() if hasattr(now, 'date') else now
inserted = self._save_relation_rows(site_id, list(pair_map.values()), stat_date=stat_date)
self.logger.info("关系指数计算完成,写入 %d 条记录", inserted)
return {
@@ -585,27 +585,23 @@ class RelationIndexTask(BaseIndexTask):
return "asinh"
return "none"
def _save_relation_rows(self, site_id: int, rows: List[RelationPairMetrics], *, calc_time: Optional[datetime] = None) -> int:
# P19: 回测模式传入 calc_time正常模式用 NOW()
use_param_time = calc_time is not None
def _save_relation_rows(self, site_id: int, rows: List[RelationPairMetrics], *, stat_date) -> int:
"""日快照模式:始终按 (site_id, stat_date) 删除后插入,支持多日快照共存。"""
from datetime import date as date_type
if not isinstance(stat_date, date_type):
stat_date = stat_date.date() if hasattr(stat_date, 'date') else stat_date
with self.db.conn.cursor() as cur:
# P19: 回测模式按 calc_time 删除(保留其他快照),正常模式按 site_id 全量刷新
if use_param_time:
cur.execute(
"DELETE FROM dws.dws_member_assistant_relation_index WHERE site_id = %s AND calc_time = %s",
(site_id, calc_time),
)
else:
cur.execute(
"DELETE FROM dws.dws_member_assistant_relation_index WHERE site_id = %s",
(site_id,),
)
cur.execute(
"DELETE FROM dws.dws_member_assistant_relation_index WHERE site_id = %s AND stat_date = %s",
(site_id, stat_date),
)
if not rows:
self.db.conn.commit()
return 0
insert_sql = f"""
insert_sql = """
INSERT INTO dws.dws_member_assistant_relation_index (
site_id, tenant_id, member_id, assistant_id,
session_count, total_duration_minutes, basic_session_count, incentive_session_count,
@@ -614,7 +610,7 @@ class RelationIndexTask(BaseIndexTask):
os_share, os_label, os_rank,
ms_f_short, ms_f_long, ms_raw, ms_display,
ml_order_count, ml_allocated_amount, ml_raw, ml_display,
calc_time, created_at, updated_at
calc_time, created_at, updated_at, stat_date
) VALUES (
%s, %s, %s, %s,
%s, %s, %s, %s,
@@ -623,42 +619,34 @@ class RelationIndexTask(BaseIndexTask):
%s, %s, %s,
%s, %s, %s, %s,
%s, %s, %s, %s,
{('%s, %s, %s' if use_param_time else 'NOW(), NOW(), NOW()')}
NOW(), NOW(), NOW(), %s
)
"""
inserted = 0
for row in rows:
params = (
row.site_id,
row.tenant_id,
row.member_id,
row.assistant_id,
row.session_count,
row.total_duration_minutes,
row.basic_session_count,
row.incentive_session_count,
# 批量写入executemany 替代逐行 execute
batch_params = [
(
row.site_id, row.tenant_id, row.member_id, row.assistant_id,
row.session_count, row.total_duration_minutes,
row.basic_session_count, row.incentive_session_count,
row.days_since_last_session,
row.rs_f,
row.rs_d,
row.rs_r,
row.rs_raw,
row.rs_display,
row.os_share,
row.os_label,
row.os_rank,
row.ms_f_short,
row.ms_f_long,
row.ms_raw,
row.ms_display,
row.ml_order_count,
row.ml_allocated_amount,
row.ml_raw,
row.ml_display,
row.rs_f, row.rs_d, row.rs_r, row.rs_raw, row.rs_display,
row.os_share, row.os_label, row.os_rank,
row.ms_f_short, row.ms_f_long, row.ms_raw, row.ms_display,
row.ml_order_count, row.ml_allocated_amount, row.ml_raw, row.ml_display,
stat_date,
)
if use_param_time:
params = params + (calc_time, calc_time, calc_time)
cur.execute(insert_sql, params)
inserted += max(cur.rowcount, 0)
for row in rows
]
from psycopg2.extras import execute_batch
execute_batch(cur, insert_sql, batch_params, page_size=200)
inserted = len(batch_params)
# 保留策略:清理 365 天前的快照
cur.execute(
"DELETE FROM dws.dws_member_assistant_relation_index WHERE site_id = %s AND stat_date < CURRENT_DATE - INTERVAL '365 days'",
(site_id,),
)
self.db.conn.commit()
return inserted

View File

@@ -178,9 +178,10 @@ class WinbackIndexTask(MemberIndexBaseTask):
avg_raw=sum(all_raw) / len(all_raw)
)
# P19: 回测模式传入 calc_time
calc_time = (context.as_of_date if context and context.as_of_date else None)
inserted = self._save_winback_data(winback_list, calc_time=calc_time)
# 日快照模式:始终按 stat_date 写入
now = (context.as_of_date if context and context.as_of_date else None) or datetime.now(self.tz)
stat_date = now.date() if hasattr(now, 'date') else now
inserted = self._save_winback_data(winback_list, stat_date=stat_date)
self.logger.info("WBI calculation finished, inserted %d rows", inserted)
return {
@@ -341,29 +342,23 @@ class WinbackIndexTask(MemberIndexBaseTask):
if data.raw_score < 0:
data.raw_score = 0.0
def _save_winback_data(self, data_list: List[MemberWinbackData], *, calc_time: Optional[datetime] = None) -> int:
"""保存 WBI 数据"""
def _save_winback_data(self, data_list: List[MemberWinbackData], *, stat_date) -> int:
"""日快照模式:按 (site_id, stat_date) 删除后插入。"""
if not data_list:
return 0
site_id = data_list[0].activity.site_id
# P19: 回测模式传入 calc_time正常模式用 NOW()
use_param_time = calc_time is not None
# P19: 回测模式按 calc_time 删除(保留其他快照),正常模式按 site_id 全量刷新
with self.db.conn.cursor() as cur:
if use_param_time:
cur.execute(
"DELETE FROM dws.dws_member_winback_index WHERE site_id = %s AND calc_time = %s",
(site_id, calc_time),
)
else:
cur.execute(
"DELETE FROM dws.dws_member_winback_index WHERE site_id = %s",
(site_id,),
)
from datetime import date as date_type
if not isinstance(stat_date, date_type):
stat_date = stat_date.date() if hasattr(stat_date, 'date') else stat_date
time_placeholder = "%s, %s, %s" if use_param_time else "NOW(), NOW(), NOW()"
insert_sql = f"""
site_id = data_list[0].activity.site_id
with self.db.conn.cursor() as cur:
cur.execute(
"DELETE FROM dws.dws_member_winback_index WHERE site_id = %s AND stat_date = %s",
(site_id, stat_date),
)
insert_sql = """
INSERT INTO dws.dws_member_winback_index (
site_id, tenant_id, member_id,
status, segment,
@@ -376,7 +371,7 @@ class WinbackIndexTask(MemberIndexBaseTask):
ideal_interval_days, ideal_next_visit_date,
raw_score, display_score,
last_wechat_touch_time,
calc_time, created_at, updated_at
calc_time, created_at, updated_at, stat_date
) VALUES (
%s, %s, %s,
%s, %s,
@@ -389,31 +384,39 @@ class WinbackIndexTask(MemberIndexBaseTask):
%s, %s,
%s, %s,
%s,
{time_placeholder}
NOW(), NOW(), NOW(), %s
)
"""
inserted = 0
# 批量写入executemany 替代逐行 execute
batch_params = []
for data in data_list:
activity = data.activity
batch_params.append((
activity.site_id, activity.tenant_id, activity.member_id,
data.status, data.segment,
activity.member_create_time, activity.first_visit_time, activity.last_visit_time, activity.last_recharge_time,
activity.t_v, activity.t_r, activity.t_a,
activity.visits_14d, activity.visits_30d, activity.visits_60d, activity.visits_total,
activity.spend_30d, activity.spend_180d, activity.sv_balance, activity.recharge_60d_amt,
activity.interval_count,
data.overdue_old, data.overdue_cdf_p, data.drop_old, data.recharge_old, data.value_old,
data.ideal_interval_days, data.ideal_next_visit_date,
data.raw_score, data.display_score,
None,
stat_date,
))
from psycopg2.extras import execute_batch
with self.db.conn.cursor() as cur:
for data in data_list:
activity = data.activity
params = (
activity.site_id, activity.tenant_id, activity.member_id,
data.status, data.segment,
activity.member_create_time, activity.first_visit_time, activity.last_visit_time, activity.last_recharge_time,
activity.t_v, activity.t_r, activity.t_a,
activity.visits_14d, activity.visits_30d, activity.visits_60d, activity.visits_total,
activity.spend_30d, activity.spend_180d, activity.sv_balance, activity.recharge_60d_amt,
activity.interval_count,
data.overdue_old, data.overdue_cdf_p, data.drop_old, data.recharge_old, data.value_old,
data.ideal_interval_days, data.ideal_next_visit_date,
data.raw_score, data.display_score,
None,
)
if use_param_time:
params = params + (calc_time, calc_time, calc_time)
cur.execute(insert_sql, params)
inserted += cur.rowcount
execute_batch(cur, insert_sql, batch_params, page_size=200)
inserted = len(batch_params)
# 保留策略:清理 365 天前的快照
cur.execute(
"DELETE FROM dws.dws_member_winback_index WHERE site_id = %s AND stat_date < CURRENT_DATE - INTERVAL '365 days'",
(site_id,),
)
self.db.conn.commit()
return inserted

View File

@@ -2,11 +2,17 @@
"""
DWS 客户项目标签任务
时间窗口计算每位客户在四大项目BILLIARD/SNOOKER/MAHJONG/KTV
消费时长占比占比≥25% 则分配标签。散客member_id=0不参与。
每位客户最近 N 次消费(开台记录)计算四大项目BILLIARD/SNOOKER/MAHJONG/KTV
消费时长占比占比≥25% 则分配标签。散客member_id=0不参与。
设计思路:
不按固定日期窗口30天/60天而按每位客户最近的消费记录数量取数
避免长期未到店或来店频率不稳定的客户标签丢失。
数据链路:
dwd_table_fee_log (ledger_count)
→ ROW_NUMBER() OVER (PARTITION BY member_id ORDER BY ledger_end_time DESC)
→ 取最近 LAST_N_VISITS 条记录
→ JOIN dim_table (site_table_id → table_id, scd2_is_current=1)
→ get_area_category(area_name, table_name)
→ 按 category_code 汇总 → 计算占比 → 写入 dws_member_project_tag
@@ -15,25 +21,23 @@ DWS 客户项目标签任务
dws.dws_member_project_tag
更新策略:
全量删除重建(按 site_id 删除后重新插入所有时间窗口
全量删除重建(按 site_id 删除后重新插入)
"""
from __future__ import annotations
from datetime import date
from decimal import Decimal
from typing import Any, Dict, List, Optional
from tasks.dws.base_dws_task import BaseDwsTask, TimeWindow
from neozqyy_shared.datetime_utils import biz_date_sql_expr
from tasks.dws.base_dws_task import BaseDwsTask
# 只计算四大项目
VALID_CATEGORIES = {"BILLIARD", "SNOOKER", "MAHJONG", "KTV"}
# 客户看板的 2 个时间窗口
MEMBER_WINDOWS = [
TimeWindow.LAST_30_DAYS,
TimeWindow.LAST_60_DAYS,
]
# 取每位客户最近 30 次消费(开台记录)
LAST_N_VISITS = 30
# 写入 time_window 字段的枚举值
TIME_WINDOW_VALUE = "LAST_30_VISITS"
TAG_THRESHOLD = Decimal("0.25")
@@ -52,21 +56,15 @@ class MemberProjectTagTask(BaseDwsTask):
def extract(self, context) -> Dict[str, Any]:
site_id = context.store_id
self.logger.info("%s: 提取客户台费时长数据", self.get_task_code())
self.logger.info("%s: 提取客户最近 %d 次消费的台费时长数据",
self.get_task_code(), LAST_N_VISITS)
self.load_config_cache()
table_info = self._extract_table_info(site_id)
window_data: Dict[str, List[Dict]] = {}
for window in MEMBER_WINDOWS:
time_range = self.get_time_window_range(window)
rows = self._extract_member_durations(
site_id, time_range.start, time_range.end
)
window_data[window.value] = rows
rows = self._extract_member_durations(site_id)
return {
"window_data": window_data,
"rows": rows,
"table_info": table_info,
"site_id": site_id,
}
@@ -81,30 +79,37 @@ class MemberProjectTagTask(BaseDwsTask):
rows = self.db.query(sql, (site_id,))
return {r["table_id"]: dict(r) for r in (rows or [])}
def _extract_member_durations(
self, site_id: int, start_date: date, end_date: date
) -> List[Dict[str, Any]]:
"""提取客户台费时长明细(按客户+台桌聚合),排除散客"""
cutoff = self.config.get("app.business_day_start_hour", 8)
biz_expr = biz_date_sql_expr("tfl.ledger_end_time", cutoff)
sql = f"""
SELECT
tfl.member_id,
tfl.site_table_id AS table_id,
COALESCE(SUM(tfl.ledger_count), 0) AS duration_seconds
FROM dwd.dwd_table_fee_log tfl
WHERE tfl.site_id = %(site_id)s
AND {biz_expr} >= %(start_date)s
AND {biz_expr} <= %(end_date)s
AND COALESCE(tfl.is_delete, 0) = 0
AND tfl.member_id IS NOT NULL
AND tfl.member_id != 0
GROUP BY tfl.member_id, tfl.site_table_id
def _extract_member_durations(self, site_id: int) -> List[Dict[str, Any]]:
"""按每位客户最近 N 次消费提取台费时长明细,排除散客。
使用 ROW_NUMBER() 按 member_id 分区、ledger_end_time 倒序排名,
取最近 LAST_N_VISITS 条记录后再按 (member_id, table_id) 聚合。
"""
sql = """
WITH ranked AS (
SELECT tfl.member_id,
tfl.site_table_id AS table_id,
tfl.ledger_count AS duration_seconds,
ROW_NUMBER() OVER (
PARTITION BY tfl.member_id
ORDER BY tfl.ledger_end_time DESC
) AS rn
FROM dwd.dwd_table_fee_log tfl
WHERE tfl.site_id = %(site_id)s
AND COALESCE(tfl.is_delete, 0) = 0
AND tfl.member_id IS NOT NULL
AND tfl.member_id != 0
)
SELECT member_id,
table_id,
COALESCE(SUM(duration_seconds), 0) AS duration_seconds
FROM ranked
WHERE rn <= %(last_n)s
GROUP BY member_id, table_id
"""
rows = self.db.query(sql, {
"site_id": site_id,
"start_date": start_date,
"end_date": end_date,
"last_n": LAST_N_VISITS,
})
return [dict(r) for r in rows] if rows else []
@@ -114,59 +119,59 @@ class MemberProjectTagTask(BaseDwsTask):
tenant_id = getattr(context, "tenant_id", 0) or 0
results: List[Dict[str, Any]] = []
for window_value, rows in extracted["window_data"].items():
# member_id → category_code → seconds
member_cats: Dict[int, Dict[str, int]] = {}
# member_id → category_code → seconds
member_cats: Dict[int, Dict[str, int]] = {}
for row in rows:
mid = row["member_id"]
tid = row["table_id"]
secs = self.safe_int(row["duration_seconds"])
if secs <= 0:
continue
for row in extracted["rows"]:
mid = row["member_id"]
tid = row["table_id"]
secs = self.safe_int(row["duration_seconds"])
if secs <= 0:
continue
tinfo = table_info.get(tid, {})
area_name = tinfo.get("area_name")
table_name = tinfo.get("table_name")
cat = self.get_area_category(area_name, table_name)
code = cat.get("category_code", "OTHER")
tinfo = table_info.get(tid, {})
area_name = tinfo.get("area_name")
table_name = tinfo.get("table_name")
cat = self.get_area_category(area_name, table_name)
code = cat.get("category_code", "OTHER")
if code not in VALID_CATEGORIES:
continue
if code not in VALID_CATEGORIES:
continue
if mid not in member_cats:
member_cats[mid] = {}
member_cats[mid][code] = member_cats[mid].get(code, 0) + secs
if mid not in member_cats:
member_cats[mid] = {}
member_cats[mid][code] = member_cats[mid].get(code, 0) + secs
for mid, cats in member_cats.items():
total = sum(cats.values())
if total <= 0:
continue
for mid, cats in member_cats.items():
total = sum(cats.values())
if total <= 0:
continue
for code, secs in cats.items():
pct = Decimal(str(secs)) / Decimal(str(total))
pct = pct.quantize(Decimal("0.0001"))
cat_info = self._get_category_display(code)
for code, secs in cats.items():
pct = Decimal(str(secs)) / Decimal(str(total))
pct = pct.quantize(Decimal("0.0001"))
cat_info = self._get_category_display(code)
results.append({
"site_id": site_id,
"tenant_id": tenant_id,
"member_id": mid,
"time_window": window_value,
"category_code": code,
"category_name": cat_info["category_name"],
"short_name": cat_info["short_name"],
"duration_seconds": secs,
"total_seconds": total,
"percentage": float(pct),
"is_tagged": pct >= TAG_THRESHOLD,
})
results.append({
"site_id": site_id,
"tenant_id": tenant_id,
"member_id": mid,
"time_window": TIME_WINDOW_VALUE,
"category_code": code,
"category_name": cat_info["category_name"],
"short_name": cat_info["short_name"],
"duration_seconds": secs,
"total_seconds": total,
"percentage": float(pct),
"is_tagged": pct >= TAG_THRESHOLD,
})
self.logger.info(
"%s: 生成 %d 条标签记录(其中 %d 条达标)",
"%s: 生成 %d 条标签记录(其中 %d 条达标),基于每客户最近 %d 次消费",
self.get_task_code(),
len(results),
sum(1 for r in results if r["is_tagged"]),
LAST_N_VISITS,
)
return results

View File

@@ -2,96 +2,130 @@
# - 2026-03-29 | Prompt: DWS_TASK_ENGINE ETL 任务 | 新建文件。
# 编排任务引擎全流程:完成检查 → 过期检查 → 任务生成。
# 通过 HTTP 调用后端 POST /api/internal/run-job 按 job_name 执行。
# - 2026-04-12 | 合并 DWS_TASK_SIMULATION有时间窗口时走推演模式
# 无时间窗口时走原来的 HTTP 模式。
# -*- coding: utf-8 -*-
"""
DWS 任务引擎编排任务DWS_TASK_ENGINE
在 DWS 指数计算完成后执行,按顺序调用后端任务引擎的各个步骤
1. recall_completion_check — 检测召回是否完成,生成回访任务
2. task_expiry_check — 标记超时未处理的任务
3. task_generator — 根据 WBI/NCI/RS 指数生成/替换任务
双模式
- 无时间窗口(日常 Flow通过 HTTP 调用后端任务引擎
1. recall_completion_check — 检测召回完成
2. task_expiry_check — 标<><E6A087>超时任务
3. task_generator — 根据指数生成/替换任务
通过 HTTP 调用后端 POST /api/internal/run-jobInternal-Token 认证),
每步失败仅记录日志,不中断后续步骤。
- 有时间窗口(历史推演):基于指数日快照逐天重放任务生命周期
需先运行 DWS_INDEX_BACKFILL 生成历史快照
"""
from __future__ import annotations
import json
import logging
import os
import sys
import time
from datetime import date, datetime, timedelta
from decimal import Decimal
from pathlib import Path
from typing import Any
from typing import Any, Dict, Optional
from zoneinfo import ZoneInfo
import requests
from dotenv import load_dotenv
from ..base_task import BaseTask, TaskContext
# 加载根 .envBACKEND_API_URL / INTERNAL_API_TOKEN 不在 AppConfig 映射中)
# task_engine.py → dws/ → tasks/ → feiqiu/ → connectors/ → etl/ → apps/ → root
# 加载根 .env
_REPO_ROOT = Path(__file__).resolve().parents[6]
load_dotenv(_REPO_ROOT / ".env", override=False)
logger = logging.getLogger(__name__)
_TIMEOUT = (5, 30) # 连接 5s读取 30s任务执行可能较慢
_TIMEOUT = (5, 30)
# 按顺序执行的后端任务列表
# HTTP 模式<E6A8A1><E5BC8F><EFBFBD>按顺序执行的后端任务
_JOB_SEQUENCE = [
"recall_completion_check",
"task_expiry_check",
"task_generator",
]
# 推演模式<EFBC9A><E5AFBC> task_generator 纯函数
_BACKEND = _REPO_ROOT / "apps" / "backend"
if str(_BACKEND) not in sys.path:
sys.path.insert(0, str(_BACKEND))
try:
from app.services.task_generator import (
IndexData,
determine_task_type,
should_replace_task,
)
_SIMULATION_AVAILABLE = True
except ImportError:
_SIMULATION_AVAILABLE = False
# 推演截止日期(现有 active 任务从 03-29 开始)
CUTOFF_DATE = date(2026, 3, 28)
FOLLOW_UP_HOURS = 72
# ── HTTP <20><>式辅助 ──
def _run_backend_job(backend_url: str, token: str, job_name: str) -> dict:
"""调用后端 POST /api/internal/run-job 执行指定任务。
Returns:
{"success": bool, "message": str} 或 {"success": False, "message": error}
"""
url = f"{backend_url}/api/internal/run-job"
headers = {
"Authorization": f"Internal-Token {token}",
"Content-Type": "application/json",
}
body = {"job_name": job_name}
try:
resp = requests.post(url, json=body, headers=headers, timeout=_TIMEOUT)
resp = requests.post(url, json={"job_name": job_name}, headers=headers, timeout=_TIMEOUT)
if resp.status_code == 200:
data = resp.json()
# 后端 ResponseWrapperMiddleware 包装:{"code": 0, "data": {...}}
inner = data.get("data", data)
return {
"success": inner.get("success", False),
"message": inner.get("message", ""),
}
else:
return {
"success": False,
"message": f"HTTP {resp.status_code}: {resp.text[:200]}",
}
return {"success": inner.get("success", False), "message": inner.get("message", "")}
return {"success": False, "message": f"HTTP {resp.status_code}: {resp.text[:200]}"}
except requests.RequestException as exc:
return {"success": False, "message": str(exc)}
class DwsTaskEngineTask(BaseTask):
"""DWS 任务引擎编排任务
"""DWS 任务引擎(双模式)
不读写 DWS 表,仅通过 HTTP 调用后端执行任务引擎步骤。
继承 BaseTask 而非 BaseDwsTask因为不需要 DWS 层的数据操作方法。
无时间窗口 → HTTP 模式(日常 Flow
有时间窗口 → 推演模式(历史回填)
"""
def get_task_code(self) -> str:
return "DWS_TASK_ENGINE"
def extract(self, context: TaskContext) -> dict[str, Any]:
"""无需提取数据,返回空上下文。"""
return {}
def execute(self, context=None) -> Dict[str, Any]:
"""直接 override execute(),绕过 BaseTask 的 E/T/L 模板。
def load(self, extracted: dict[str, Any], context: TaskContext) -> dict[str, Any]:
"""按顺序调用后端任务引擎的各个步骤。"""
根据是否有时间窗口决定模式:
- 有窗口 → 推演模式(逐天生成+完成任务)
- 无窗口 → HTTP 模式(调用后端执行当天任务引擎)
"""
if self._has_window(context):
return self._run_simulation_mode(context)
return self._run_http_mode()
def _has_window(self, context=None) -> bool:
"""检查是否指定了时间窗口config 或 context 均可)。"""
# 优先从 configCLI --window-start/--window-end
wo = self.config.get("run.window_override") or {}
if wo.get("start") and wo.get("end"):
return True
# 其次从 contexttask_executor 构建的)
if context and hasattr(context, 'window_start') and hasattr(context, 'window_end'):
if context.window_start and context.window_end and context.window_start != context.window_end:
return True
return False
# ── HTTP 模式(日常) ──
def _run_http_mode(self) -> dict[str, Any]:
backend_url = os.environ.get("BACKEND_API_URL", "").rstrip("/")
token = os.environ.get("INTERNAL_API_TOKEN", "")
@@ -103,22 +137,667 @@ class DwsTaskEngineTask(BaseTask):
return {"skipped": True, "reason": "INTERNAL_API_TOKEN 未配置"}
results: dict[str, Any] = {}
for job_name in _JOB_SEQUENCE:
self.logger.info("DWS_TASK_ENGINE: 执行 %s ...", job_name)
result = _run_backend_job(backend_url, token, job_name)
success = result.get("success", False)
message = result.get("message", "")
results[job_name] = {"success": success, "message": message}
if success:
self.logger.info(
"DWS_TASK_ENGINE: %s 成功 — %s", job_name, message
)
self.logger.info("DWS_TASK_ENGINE: %s 成功 — %s", job_name, message)
else:
self.logger.warning(
"DWS_TASK_ENGINE: %s 失败 — %s", job_name, message
)
self.logger.warning("DWS_TASK_ENGINE: %s 失败 — %s", job_name, message)
return results
# ── 推演模式(历史) ──
def _run_simulation_mode(self, context: Optional[TaskContext]) -> dict[str, Any]:
if not _SIMULATION_AVAILABLE:
raise RuntimeError("推演模式不可用:无法导入 app.services.task_generator")
import psycopg2
start_date, end_date = self._parse_date_range(context)
tz = ZoneInfo(self.config.get("app.timezone", "Asia/Shanghai"))
etl_conn = self.db.conn
app_dsn = os.environ.get("APP_DB_DSN")
if not app_dsn:
raise ValueError("推演模式需要 APP_DB_DSN 环境变量")
app_conn = psycopg2.connect(app_dsn)
app_conn.set_client_encoding("UTF8")
site_id = self._get_site_id(etl_conn)
total_days = (end_date - start_date).days + 1
self.logger.info(
"DWS_TASK_ENGINE [推演模式]: %s ~ %s (%d天), site_id=%s",
start_date, end_date, total_days, site_id,
)
# 清理指定范围内的旧数据,保留范围外的
self._clean_date_range(app_conn, tz, start_date, end_date)
# 加载推演范围之前就存在的 active 任务(不加载范围之后的"未来"任务)
active_tasks = self._load_existing_active_tasks(app_conn, site_id, before_date=start_date)
self.logger.info("DWS_TASK_ENGINE [推演]: 范围前已有 active 任务 %d", len(active_tasks))
stats = {
"created": 0, "completed": 0, "resolved": 0, "overridden": 0,
"expired": 0, "follow_up_created": 0,
"recall_events": 0, "skipped_no_snapshot": 0,
}
# 预加载全量数据,按日分片(避免 255 x 5 次逐日查询 -> 5 次全量查询)
self.logger.info("DWS_TASK_ENGINE [推演]: 预加载快照 + 结算数据 ...")
snapshots_by_date = self._bulk_load_snapshots(etl_conn, site_id, start_date, end_date)
settlements_by_date = self._bulk_load_settlements(etl_conn, site_id, start_date, end_date, tz)
member_visits_by_date = self._bulk_load_member_visits(etl_conn, site_id, start_date, end_date, tz)
self.logger.info(
"DWS_TASK_ENGINE [推演]: 预加载完成, %d 天有快照, %d 天有助教结算, %d 天有到店记录",
len(snapshots_by_date), len(settlements_by_date), len(member_visits_by_date),
)
# 加载任务生成参数(与日常 task_generator 保持一致)
task_params = self._load_task_generator_params(app_conn, site_id)
self.logger.info(
"DWS_TASK_ENGINE [推演]: 任务阈值 high=%.1f, normal=%.1f, rs=[%.1f, %.1f)",
task_params["high_threshold"], task_params["normal_threshold"],
task_params["rs_min"], task_params["rs_max"],
)
t0 = time.time()
current = start_date
while current <= end_date:
snapshot = snapshots_by_date.get(current, {"relation": {}, "wbi": {}, "nci": {}})
if not snapshot["relation"] and not snapshot["wbi"] and not snapshot["nci"]:
stats["skipped_no_snapshot"] += 1
current += timedelta(days=1)
continue
day_settlements = settlements_by_date.get(current, {})
day_visits = member_visits_by_date.get(current, {})
self._simulate_day(app_conn, etl_conn, site_id, current, tz, snapshot, active_tasks, stats,
preloaded_settlements=day_settlements, preloaded_visits=day_visits,
task_params=task_params)
day_num = (current - start_date).days + 1
if day_num % 30 == 0 or current == end_date:
elapsed = time.time() - t0
self.logger.info(
"DWS_TASK_ENGINE [推演]: %s (%d/%d) 创建=%d 完成=%d 解除=%d 覆盖=%d 过期=%d %.0fs",
current, day_num, total_days,
stats["created"], stats["completed"], stats["resolved"],
stats["overridden"], stats["expired"], elapsed,
)
current += timedelta(days=1)
# 收尾:清理推演结束后仍 active 但 expires_at 已过期的任务
now_dt = datetime.now(tz)
cleanup_count = 0
with app_conn.cursor() as cur:
cur.execute(
"""UPDATE biz.coach_tasks SET status = 'expired', updated_at = %s
WHERE site_id = %s AND status = 'active'
AND expires_at IS NOT NULL AND expires_at < %s
RETURNING id, task_type""",
(now_dt, site_id, now_dt),
)
for task_id, task_type in cur.fetchall():
self._history(cur, task_id, "expired", "active", "expired",
task_type, task_type, {"reason": "post_simulation_cleanup"})
cleanup_count += 1
app_conn.commit()
stats["expired"] += cleanup_count
if cleanup_count:
self.logger.info("DWS_TASK_ENGINE [推演]: 收尾清理 %d 个已过期任务", cleanup_count)
total_elapsed = time.time() - t0
self.logger.info(
"DWS_TASK_ENGINE [推演] 完成: %.0fs, 创建=%d 完成=%d 解除=%d 覆盖=%d 过期=%d 回访=%d 事件=%d 跳过=%d active=%d",
total_elapsed, stats["created"], stats["completed"], stats["resolved"],
stats["overridden"], stats["expired"],
stats["follow_up_created"], stats["recall_events"],
stats["skipped_no_snapshot"], len(active_tasks),
)
app_conn.close()
# 推演完成后触发日常流程recall_detector + task_generator
# 让最新到店数据触发召回完成、POOL 过滤清理存量
self.logger.info("DWS_TASK_ENGINE [推演]: 触发日常流程 ...")
try:
http_result = self._run_http_mode()
self.logger.info("DWS_TASK_ENGINE [推演]: 日常流程完成 %s", http_result)
except Exception:
self.logger.exception("DWS_TASK_ENGINE [推演]: 日常流程触发失败(不影响推演结果)")
return {
"status": "SUCCESS",
"counts": {
# 框架标准字段(总结框显示用)
"inserted": stats["created"],
"updated": stats["overridden"],
"skipped": stats["skipped_no_snapshot"],
"errors": 0,
# 原始明细
**stats,
},
}
# ── 推演辅助方法 ──
def _parse_date_range(self, context: Optional[TaskContext]) -> tuple[date, date]:
wo = self.config.get("run.window_override") or {}
start_str = wo.get("start")
end_str = wo.get("end")
if start_str and end_str:
return self._parse_date(start_str), self._parse_date(end_str)
if context and context.window_start and context.window_end:
return context.window_start.date(), context.window_end.date()
raise ValueError("推演模式需要指定时间窗口")
@staticmethod
def _parse_date(s) -> date:
if isinstance(s, date) and not isinstance(s, datetime):
return s
if isinstance(s, datetime):
return s.date()
return date.fromisoformat(str(s).strip()[:10])
def _get_site_id(self, etl_conn) -> int:
with etl_conn.cursor() as cur:
cur.execute("SELECT DISTINCT site_id FROM dws.dws_member_assistant_relation_index LIMIT 1")
row = cur.fetchone()
etl_conn.commit()
if not row:
raise RuntimeError("relation_index 表为空,请先运行 DWS_INDEX_BACKFILL")
return row[0]
def _load_snapshot(self, etl_conn, site_id: int, stat_date: date) -> dict:
result = {"relation": {}, "wbi": {}, "nci": {}}
with etl_conn.cursor() as cur:
cur.execute(
"""SELECT assistant_id, member_id, rs_display, os_label, session_count
FROM dws.dws_member_assistant_relation_index
WHERE site_id = %s AND stat_date = %s""",
(site_id, stat_date),
)
for r in cur.fetchall():
result["relation"][(r[0], r[1])] = {
"rs": Decimal(str(r[2])), "os_label": r[3], "session_count": r[4],
}
# WBI同时记录 status 用于过滤老客 NCI
old_members = set()
cur.execute(
"""SELECT member_id, display_score, status FROM dws.dws_member_winback_index
WHERE site_id = %s AND stat_date = %s""",
(site_id, stat_date),
)
for r in cur.fetchall():
result["wbi"][r[0]] = Decimal(str(r[1])) if r[1] else Decimal(0)
if r[2] == "OLD":
old_members.add(r[0])
# NCI排除已转老客避免使用过时高分
cur.execute(
"""SELECT member_id, display_score FROM dws.dws_member_newconv_index
WHERE site_id = %s AND stat_date = %s""",
(site_id, stat_date),
)
for r in cur.fetchall():
if r[0] not in old_members:
result["nci"][r[0]] = Decimal(str(r[1])) if r[1] else Decimal(0)
etl_conn.commit()
return result
def _load_settlements(self, etl_conn, site_id: int, d: date) -> dict:
"""助教级结算settle_type=1 全部计入settle_type=3 仅 BONUS 服务。"""
tz = ZoneInfo(self.config.get("app.timezone", "Asia/Shanghai"))
day_start = datetime(d.year, d.month, d.day, 0, 0, 0, tzinfo=tz)
day_end = day_start + timedelta(days=1)
settlements = {}
with etl_conn.cursor() as cur:
cur.execute(
"""SELECT sl.site_assistant_id, sh.member_id, MAX(sh.pay_time)
FROM dwd.dwd_settlement_head sh
JOIN dwd.dwd_assistant_service_log sl
ON sl.order_settle_id = sh.order_settle_id AND sl.is_delete = 0
WHERE sh.site_id = %s
AND (sh.settle_type = 1 OR (sh.settle_type = 3 AND sl.order_assistant_type = 2))
AND sh.pay_time >= %s AND sh.pay_time < %s
GROUP BY sl.site_assistant_id, sh.member_id""",
(site_id, day_start, day_end),
)
for r in cur.fetchall():
if r[0] and r[1]:
settlements[(r[0], r[1])] = r[2]
etl_conn.commit()
return settlements
def _load_member_visits(self, etl_conn, site_id: int, d: date) -> dict:
"""门店级到店检测:含无助教服务的 settle_type=1用于 resolved 判定。"""
tz = ZoneInfo(self.config.get("app.timezone", "Asia/Shanghai"))
day_start = datetime(d.year, d.month, d.day, 0, 0, 0, tzinfo=tz)
day_end = day_start + timedelta(days=1)
visits = {}
with etl_conn.cursor() as cur:
cur.execute(
"""SELECT sh.member_id, MAX(sh.pay_time)
FROM dwd.dwd_settlement_head sh
WHERE sh.site_id = %s
AND (
sh.settle_type = 1
OR (sh.settle_type = 3 AND EXISTS (
SELECT 1 FROM dwd.dwd_assistant_service_log sl
WHERE sl.order_settle_id = sh.order_settle_id
AND sl.is_delete = 0
AND sl.order_assistant_type = 2
))
)
AND sh.pay_time >= %s AND sh.pay_time < %s
GROUP BY sh.member_id""",
(site_id, day_start, day_end),
)
for r in cur.fetchall():
if r[0]:
visits[r[0]] = r[1]
etl_conn.commit()
return visits
def _bulk_load_settlements(self, etl_conn, site_id: int, start: date, end: date, tz) -> dict:
"""一次查全量助教级结算,按日分片返回 {date: {(aid,mid): pay_time}}。"""
from collections import defaultdict
day_start = datetime(start.year, start.month, start.day, 0, 0, 0, tzinfo=tz)
day_end = datetime(end.year, end.month, end.day, 0, 0, 0, tzinfo=tz) + timedelta(days=1)
result = defaultdict(dict)
with etl_conn.cursor() as cur:
cur.execute(
"""SELECT sl.site_assistant_id, sh.member_id, sh.pay_time
FROM dwd.dwd_settlement_head sh
JOIN dwd.dwd_assistant_service_log sl
ON sl.order_settle_id = sh.order_settle_id AND sl.is_delete = 0
WHERE sh.site_id = %s
AND (sh.settle_type = 1 OR (sh.settle_type = 3 AND sl.order_assistant_type = 2))
AND sh.pay_time >= %s AND sh.pay_time < %s""",
(site_id, day_start, day_end),
)
for aid, mid, pay_time in cur.fetchall():
if aid and mid:
d_key = pay_time.astimezone(tz).date()
existing = result[d_key].get((aid, mid))
if existing is None or pay_time > existing:
result[d_key][(aid, mid)] = pay_time
etl_conn.commit()
return dict(result)
def _bulk_load_member_visits(self, etl_conn, site_id: int, start: date, end: date, tz) -> dict:
"""一次查全量门店级到店,按日分片返回 {date: {mid: pay_time}}。"""
from collections import defaultdict
day_start = datetime(start.year, start.month, start.day, 0, 0, 0, tzinfo=tz)
day_end = datetime(end.year, end.month, end.day, 0, 0, 0, tzinfo=tz) + timedelta(days=1)
result = defaultdict(dict)
with etl_conn.cursor() as cur:
cur.execute(
"""SELECT sh.member_id, sh.pay_time
FROM dwd.dwd_settlement_head sh
WHERE sh.site_id = %s
AND (
sh.settle_type = 1
OR (sh.settle_type = 3 AND EXISTS (
SELECT 1 FROM dwd.dwd_assistant_service_log sl
WHERE sl.order_settle_id = sh.order_settle_id
AND sl.is_delete = 0
AND sl.order_assistant_type = 2
))
)
AND sh.pay_time >= %s AND sh.pay_time < %s""",
(site_id, day_start, day_end),
)
for mid, pay_time in cur.fetchall():
if mid:
d_key = pay_time.astimezone(tz).date()
existing = result[d_key].get(mid)
if existing is None or pay_time > existing:
result[d_key][mid] = pay_time
etl_conn.commit()
return dict(result)
@staticmethod
def _load_task_generator_params(app_conn, site_id: int) -> dict:
"""从 cfg_task_generator_params 加载任务生成阈值,与日常 task_generator 保持一致。"""
defaults = {
"high_threshold": 7.5,
"normal_threshold": 4.0,
"rs_min": 1.0,
"rs_max": 6.0,
}
key_map = {
"high_priority_recall_threshold": "high_threshold",
"priority_recall_threshold": "normal_threshold",
"rs_min_for_relationship": "rs_min",
"rs_max_for_relationship": "rs_max",
}
with app_conn.cursor() as cur:
cur.execute("SELECT param_key, param_value FROM biz.cfg_task_generator_params")
for param_key, param_value in cur.fetchall():
mapped = key_map.get(param_key)
if mapped:
defaults[mapped] = float(param_value)
app_conn.commit()
return defaults
def _bulk_load_snapshots(self, etl_conn, site_id: int, start: date, end: date) -> dict:
"""一次查全量指数快照relation/wbi/nci按日分片返回 {date: snapshot_dict}。"""
from collections import defaultdict
result = defaultdict(lambda: {"relation": {}, "wbi": {}, "nci": {}})
with etl_conn.cursor() as cur:
# relation_index
cur.execute(
"""SELECT stat_date, assistant_id, member_id, rs_display, os_label, session_count
FROM dws.dws_member_assistant_relation_index
WHERE site_id = %s AND stat_date >= %s AND stat_date <= %s""",
(site_id, start, end),
)
for sd, aid, mid, rs, os_label, sc in cur.fetchall():
result[sd]["relation"][(aid, mid)] = {
"rs": Decimal(str(rs)), "os_label": os_label, "session_count": sc,
}
# WBI同时收集 OLD 状态用于过滤 NCI
old_members_by_date = defaultdict(set)
cur.execute(
"""SELECT stat_date, member_id, display_score, status
FROM dws.dws_member_winback_index
WHERE site_id = %s AND stat_date >= %s AND stat_date <= %s""",
(site_id, start, end),
)
for sd, mid, score, status in cur.fetchall():
result[sd]["wbi"][mid] = Decimal(str(score)) if score else Decimal(0)
if status == "OLD":
old_members_by_date[sd].add(mid)
# NCI排除已转老客
cur.execute(
"""SELECT stat_date, member_id, display_score
FROM dws.dws_member_newconv_index
WHERE site_id = %s AND stat_date >= %s AND stat_date <= %s""",
(site_id, start, end),
)
for sd, mid, score in cur.fetchall():
if mid not in old_members_by_date.get(sd, set()):
result[sd]["nci"][mid] = Decimal(str(score)) if score else Decimal(0)
etl_conn.commit()
return dict(result)
def _simulate_day(self, app_conn, etl_conn, site_id, d, tz, snapshot, active_tasks, stats,
*, preloaded_settlements=None, preloaded_visits=None, task_params=None):
day_dt = datetime(d.year, d.month, d.day, 7, 0, 0, tzinfo=tz)
# 1. 过期检测
expired_keys = [k for k, t in active_tasks.items() if t.get("expires_at") and t["expires_at"] < day_dt]
for key in expired_keys:
task = active_tasks.pop(key)
stats["expired"] += 1
with app_conn.cursor() as cur:
cur.execute("UPDATE biz.coach_tasks SET status = 'expired', updated_at = %s WHERE id = %s", (day_dt, task["id"]))
self._history(cur, task["id"], "expired", "active", "expired", task["task_type"], task["task_type"], {"simulated": True})
# 2. 任务生成<EFBC88><E6B7B7><EFBFBD>冲突策略
relation = snapshot["relation"]
wbi_map = snapshot["wbi"]
nci_map = snapshot["nci"]
ownership_pairs = [
(aid, mid, info)
for (aid, mid), info in relation.items()
if info["os_label"] in ("MAIN", "COMANAGE") and info["session_count"] > 0
]
for aid, mid, info in ownership_pairs:
wbi = wbi_map.get(mid, Decimal(0))
nci = nci_map.get(mid, Decimal(0))
rs = info["rs"]
# 参数化任务判定(与日常 task_generator._process_pair 保持一致)
priority_score = max(wbi, nci)
if task_params:
ht = Decimal(str(task_params["high_threshold"]))
nt = Decimal(str(task_params["normal_threshold"]))
rs_min = Decimal(str(task_params["rs_min"]))
rs_max = Decimal(str(task_params["rs_max"]))
else:
ht, nt, rs_min, rs_max = Decimal(7), Decimal(5), Decimal(1), Decimal(6)
if priority_score > ht:
new_type = "high_priority_recall"
elif priority_score > nt:
new_type = "priority_recall"
elif rs > rs_min and rs < rs_max:
new_type = "relationship_building"
else:
new_type = None
if not new_type:
continue
key = (aid, mid)
existing = active_tasks.get(key)
priority = float(max(wbi, nci)) if new_type in ("high_priority_recall", "priority_recall") else float(rs)
if existing:
if existing["task_type"] == new_type:
continue
if existing["task_type"] == "follow_up_visit":
# follow_up_visit 保留宽限期 + 新建高优先任务
with app_conn.cursor() as cur:
if not existing.get("expires_at"):
cur.execute(
"UPDATE biz.coach_tasks SET expires_at = created_at + INTERVAL '72 hours', updated_at = %s WHERE id = %s",
(day_dt, existing["id"]),
)
self._history(cur, existing["id"], "expires_at_filled", "active", "active",
"follow_up_visit", "follow_up_visit",
{"reason": "higher_priority_task_created", "simulated": True})
cur.execute(
"""INSERT INTO biz.coach_tasks
(site_id, assistant_id, member_id, task_type, status,
priority_score, parent_task_id, created_at, updated_at)
VALUES (%s, %s, %s, %s, 'active', %s, %s, %s, %s)
ON CONFLICT (site_id, assistant_id, member_id, task_type) WHERE (status = 'active')
DO UPDATE SET priority_score = EXCLUDED.priority_score, updated_at = EXCLUDED.updated_at
RETURNING id""",
(site_id, aid, mid, new_type, priority, existing["id"], day_dt, day_dt),
)
new_id = cur.fetchone()[0]
self._history(cur, new_id, "created", None, "active", "follow_up_visit", new_type, {"simulated": True})
active_tasks[key] = {"id": new_id, "task_type": new_type, "created_at": day_dt, "expires_at": None, "priority": priority}
stats["created"] += 1
else:
# 非 follow_up原地覆盖
with app_conn.cursor() as cur:
# 先关闭可能冲突的同 new_type active 记录(避免唯一约束冲突)
cur.execute(
"""UPDATE biz.coach_tasks SET status = 'inactive', updated_at = %s
WHERE site_id = %s AND assistant_id = %s AND member_id = %s
AND task_type = %s AND status = 'active' AND id != %s""",
(day_dt, site_id, aid, mid, new_type, existing["id"]),
)
cur.execute(
"UPDATE biz.coach_tasks SET task_type = %s, priority_score = %s, updated_at = %s WHERE id = %s AND status = 'active'",
(new_type, priority, day_dt, existing["id"]),
)
self._history(cur, existing["id"], "type_override", "active", "active",
existing["task_type"], new_type,
{"old_priority": existing.get("priority"), "simulated": True})
existing["task_type"] = new_type
existing["priority"] = priority
stats["overridden"] += 1
else:
# 新建任务upsert若同类型 active 已存在则更新 priority
with app_conn.cursor() as cur:
cur.execute(
"""INSERT INTO biz.coach_tasks
(site_id, assistant_id, member_id, task_type, status,
priority_score, created_at, updated_at)
VALUES (%s, %s, %s, %s, 'active', %s, %s, %s)
ON CONFLICT (site_id, assistant_id, member_id, task_type) WHERE (status = 'active')
DO UPDATE SET priority_score = EXCLUDED.priority_score, updated_at = EXCLUDED.updated_at
RETURNING id""",
(site_id, aid, mid, new_type, priority, day_dt, day_dt),
)
task_id = cur.fetchone()[0]
self._history(cur, task_id, "created", None, "active", None, new_type, {"simulated": True})
active_tasks[key] = {"id": task_id, "task_type": new_type, "created_at": day_dt, "expires_at": None, "priority": priority}
stats["created"] += 1
# 3. 召回检测(优先使用预加载数据)
settlements = preloaded_settlements if preloaded_settlements is not None else self._load_settlements(etl_conn, site_id, d)
for (aid, mid), pay_time in settlements.items():
key = (aid, mid)
task = active_tasks.get(key)
with app_conn.cursor() as cur:
try:
cur.execute(
"""INSERT INTO biz.recall_events
(site_id, assistant_id, member_id, pay_time, task_id, task_type, created_at)
VALUES (%s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (site_id, assistant_id, member_id,
(date_trunc('day', pay_time AT TIME ZONE 'Asia/Shanghai')))
DO NOTHING RETURNING id""",
(site_id, aid, mid, pay_time, task["id"] if task else None, task["task_type"] if task else None, day_dt),
)
if cur.fetchone():
stats["recall_events"] += 1
except Exception:
pass
if not task or task["task_type"] not in ("high_priority_recall", "priority_recall") or pay_time <= task["created_at"]:
continue
with app_conn.cursor() as cur:
cur.execute(
"""UPDATE biz.coach_tasks SET status = 'completed', completed_at = %s,
completed_task_type = %s, completion_type = 'auto', updated_at = %s
WHERE id = %s AND status = 'active'""",
(pay_time, task["task_type"], day_dt, task["id"]),
)
self._history(cur, task["id"], "completed", "active", "completed",
task["task_type"], task["task_type"],
{"service_time": str(pay_time), "simulated": True})
stats["completed"] += 1
expires_at = pay_time + timedelta(hours=FOLLOW_UP_HOURS)
with app_conn.cursor() as cur:
cur.execute(
"""INSERT INTO biz.coach_tasks
(site_id, assistant_id, member_id, task_type, status, expires_at, created_at, updated_at)
VALUES (%s, %s, %s, 'follow_up_visit', 'active', %s, %s, %s)
ON CONFLICT (site_id, assistant_id, member_id, task_type) WHERE (status = 'active')
DO UPDATE SET expires_at = EXCLUDED.expires_at, updated_at = EXCLUDED.updated_at
RETURNING id""",
(site_id, aid, mid, expires_at, day_dt, day_dt),
)
fu_id = cur.fetchone()[0]
self._history(cur, fu_id, "created", None, "active", None, "follow_up_visit",
{"reason": "recall_completed", "simulated": True})
active_tasks[key] = {"id": fu_id, "task_type": "follow_up_visit", "created_at": day_dt, "expires_at": expires_at}
stats["follow_up_created"] += 1
# 3b. 门店级召回解除:客户到店后,未被服务的助教任务标记 resolved
member_visits = preloaded_visits if preloaded_visits is not None else self._load_member_visits(etl_conn, site_id, d)
resolved_keys = [
k for k, t in active_tasks.items()
if k[1] in member_visits
and t["task_type"] in ("high_priority_recall", "priority_recall")
and member_visits[k[1]] > t["created_at"]
]
for key in resolved_keys:
task = active_tasks.pop(key)
pay_time = member_visits[key[1]]
with app_conn.cursor() as cur:
cur.execute(
"""UPDATE biz.coach_tasks SET status = 'resolved', updated_at = %s
WHERE id = %s AND status = 'active'""",
(day_dt, task["id"]),
)
self._history(cur, task["id"], "customer_returned", "active", "resolved",
task["task_type"], task["task_type"],
{"service_time": str(pay_time), "simulated": True})
stats["resolved"] += 1
app_conn.commit()
@staticmethod
def _history(cur, task_id, action, old_status, new_status, old_task_type, new_task_type, detail=None):
if task_id is None:
return
cur.execute(
"""INSERT INTO biz.coach_task_history
(task_id, action, old_status, new_status, old_task_type, new_task_type, detail)
VALUES (%s, %s, %s, %s, %s, %s, %s)""",
(task_id, action, old_status, new_status, old_task_type, new_task_type,
json.dumps(detail) if detail else None),
)
def _clean_date_range(self, app_conn, tz, start_date: date, end_date: date):
"""清理指定日期范围内的旧任务数据(保留范围外的)。"""
range_start = datetime(start_date.year, start_date.month, start_date.day, 0, 0, 0, tzinfo=tz)
range_end = datetime(end_date.year, end_date.month, end_date.day, 23, 59, 59, tzinfo=tz)
with app_conn.cursor() as cur:
cur.execute(
"DELETE FROM biz.coach_task_history WHERE task_id IN (SELECT id FROM biz.coach_tasks WHERE created_at >= %s AND created_at <= %s)",
(range_start, range_end),
)
h = cur.rowcount
cur.execute(
"DELETE FROM biz.recall_events WHERE created_at >= %s AND created_at <= %s",
(range_start, range_end),
)
e = cur.rowcount
cur.execute(
"DELETE FROM biz.coach_tasks WHERE created_at >= %s AND created_at <= %s",
(range_start, range_end),
)
t = cur.rowcount
app_conn.commit()
if t > 0 or e > 0:
self.logger.info("DWS_TASK_ENGINE [推演]: 清理 %s~%s 旧数据: %d history, %d events, %d tasks", start_date, end_date, h, e, t)
def _load_existing_active_tasks(self, app_conn, site_id: int, before_date: date = None) -> dict:
"""加载数据库中已有的 active 任务到内存字典。
before_date: 只加载 created_at < before_date 的任务,避免加载推演范围之后的"未来"任务。
"""
active_tasks = {}
tz = ZoneInfo(self.config.get("app.timezone", "Asia/Shanghai"))
with app_conn.cursor() as cur:
if before_date:
cutoff = datetime(before_date.year, before_date.month, before_date.day, 0, 0, 0, tzinfo=tz)
cur.execute(
"""SELECT id, assistant_id, member_id, task_type, created_at, expires_at, priority_score
FROM biz.coach_tasks
WHERE site_id = %s AND status = 'active' AND created_at < %s""",
(site_id, cutoff),
)
else:
cur.execute(
"""SELECT id, assistant_id, member_id, task_type, created_at, expires_at, priority_score
FROM biz.coach_tasks
WHERE site_id = %s AND status = 'active'""",
(site_id,),
)
for row in cur.fetchall():
key = (row[1], row[2])
active_tasks[key] = {
"id": row[0], "task_type": row[3],
"created_at": row[4], "expires_at": row[5],
"priority": float(row[6]) if row[6] else 0,
}
app_conn.commit()
return active_tasks

View File

@@ -0,0 +1,151 @@
# -*- coding: utf-8 -*-
"""
指数日快照回填任务DWS_INDEX_BACKFILL
逐天调用 RelationIndexTask / WinbackIndexTask / NewconvIndexTask
为 3 张指数表生成历史日快照。
CHANGE 2026-04-12 | 性能优化:
- 任务实例复用(创建 1 次,循环 N 天复用)
- 减少 765 次 Task 初始化和参数表查询
CLI 用法:
python -m cli.main --tasks DWS_INDEX_BACKFILL \\
--window-start 2025-08-01 --window-end 2026-04-11
admin-web在 ETL 任务配置页面选择 DWS_INDEX_BACKFILL设置时间窗口。
"""
from __future__ import annotations
import time
from datetime import date, datetime, timedelta
from typing import Any, Dict, Optional
from ..base_task import BaseTask, TaskContext
from ..dws.index.relation_index_task import RelationIndexTask
from ..dws.index.winback_index_task import WinbackIndexTask
from ..dws.index.newconv_index_task import NewconvIndexTask
class IndexBackfillTask(BaseTask):
"""指数日快照回填工具任务。"""
def get_task_code(self) -> str:
return "DWS_INDEX_BACKFILL"
def execute(self, context: Optional[TaskContext] = None) -> Dict[str, Any]:
"""主流程:解析日期范围,逐天执行 3 个指数任务。"""
start_date, end_date = self._parse_date_range(context)
store_id = self._resolve_store_id(context)
total_days = (end_date - start_date).days + 1
self.logger.info(
"DWS_INDEX_BACKFILL: %s ~ %s (%d天), store_id=%s",
start_date, end_date, total_days, store_id,
)
# 创建 1 次实例,循环复用(避免 765 次 __init__ + 参数查询)
task_instances = [
RelationIndexTask(self.config, self.db, self.api, self.logger),
WinbackIndexTask(self.config, self.db, self.api, self.logger),
NewconvIndexTask(self.config, self.db, self.api, self.logger),
]
task_names = ["RS", "WBI", "NCI"]
completed = 0
errors = 0
t0 = time.time()
current = start_date
while current <= end_date:
ctx = self._build_day_context(current, store_id)
day_num = (current - start_date).days + 1
day_t0 = time.time()
for i, task in enumerate(task_instances):
try:
task.execute(ctx)
completed += 1
except Exception:
self.logger.exception(
"DWS_INDEX_BACKFILL: %s %s 失败",
task.__class__.__name__, current,
)
errors += 1
self.logger.info(
"DWS_INDEX_BACKFILL: %s [%d/%d] %s (%d/3)",
current, day_num, total_days, task_names[i], i + 1,
)
elapsed = time.time() - day_t0
total_elapsed_so_far = time.time() - t0
avg_per_day = total_elapsed_so_far / day_num
eta = avg_per_day * (total_days - day_num)
self.logger.info(
"DWS_INDEX_BACKFILL: %s [%d/%d %.0f%%] %.1fs/天 ETA %.0fs",
current, day_num, total_days, day_num / total_days * 100,
elapsed, eta,
)
current += timedelta(days=1)
total_elapsed = time.time() - t0
self.logger.info(
"DWS_INDEX_BACKFILL 完成: %d/%d 成功, %d 失败, %.0fs",
completed, total_days * 3, errors, total_elapsed,
)
return {
"status": "SUCCESS" if errors == 0 else "PARTIAL",
"counts": {
"days": total_days,
"completed": completed,
"errors": errors,
"elapsed_sec": round(total_elapsed, 1),
},
}
def _parse_date_range(self, context: Optional[TaskContext]) -> tuple[date, date]:
"""从 config 或 context 解析日期范围。"""
wo = self.config.get("run.window_override") or {}
start_str = wo.get("start")
end_str = wo.get("end")
if start_str and end_str:
return self._parse_date(start_str), self._parse_date(end_str)
if context and context.window_start and context.window_end:
return context.window_start.date(), context.window_end.date()
raise ValueError(
"DWS_INDEX_BACKFILL 需要指定日期范围。"
"CLI: --window-start 2025-08-01 --window-end 2026-04-11"
)
@staticmethod
def _parse_date(s) -> date:
if isinstance(s, date) and not isinstance(s, datetime):
return s
if isinstance(s, datetime):
return s.date()
return date.fromisoformat(str(s).strip()[:10])
def _resolve_store_id(self, context: Optional[TaskContext]) -> int:
if context and getattr(context, "store_id", None):
return int(context.store_id)
sid = self.config.get("app.store_id")
if sid:
return int(sid)
raise ValueError("DWS_INDEX_BACKFILL 需要 store_id")
def _build_day_context(self, d: date, store_id: int) -> TaskContext:
from zoneinfo import ZoneInfo
tz = ZoneInfo(self.config.get("app.timezone", "Asia/Shanghai"))
as_of = datetime(d.year, d.month, d.day, 23, 59, 0, tzinfo=tz)
window_start = as_of - timedelta(days=90)
return TaskContext(
store_id=store_id,
window_start=window_start,
window_end=as_of,
window_minutes=int((as_of - window_start).total_seconds() / 60),
as_of_date=as_of,
)

View File

@@ -0,0 +1,473 @@
# -*- coding: utf-8 -*-
"""
历史任务推演任务DWS_TASK_SIMULATION
基于指数日快照,逐天重放 task_generator + recall_detector 逻辑,
还原完整的任务生命周期。
CLI 用法:
python -m cli.main --tasks DWS_TASK_SIMULATION \\
--window-start 2025-08-01 --window-end 2026-03-28
admin-web在 ETL 任务配置页面选择 DWS_TASK_SIMULATION设置时间窗口。
"""
from __future__ import annotations
import json
import logging
import os
import sys
import time
from datetime import date, datetime, timedelta
from decimal import Decimal
from pathlib import Path
from typing import Any, Dict, Optional
from zoneinfo import ZoneInfo
import psycopg2
from ..base_task import BaseTask, TaskContext
# 导入 task_generator 纯函数(后端代码)
_BACKEND = Path(__file__).resolve().parents[5] / "backend"
if str(_BACKEND) not in sys.path:
sys.path.insert(0, str(_BACKEND))
from app.services.task_generator import (
IndexData,
determine_task_type,
should_replace_task,
)
logger = logging.getLogger(__name__)
# 推演截止日期(现有 active 任务从 03-29 开始)
CUTOFF_DATE = date(2026, 3, 28)
FOLLOW_UP_HOURS = 48
class TaskSimulationTask(BaseTask):
"""历史任务推演工具任务。"""
def get_task_code(self) -> str:
return "DWS_TASK_SIMULATION"
def execute(self, context: Optional[TaskContext] = None) -> Dict[str, Any]:
"""主流程:解析日期范围,逐天推演。"""
start_date, end_date = self._parse_date_range(context)
if end_date > CUTOFF_DATE:
self.logger.warning(
"end_date %s 超过截止日期 %s,自动截断", end_date, CUTOFF_DATE
)
end_date = CUTOFF_DATE
tz = ZoneInfo(self.config.get("app.timezone", "Asia/Shanghai"))
# ETL 库连接复用 self.db.conn
etl_conn = self.db.conn
# 业务库需要独立连接
app_dsn = os.environ.get("APP_DB_DSN")
if not app_dsn:
raise ValueError("DWS_TASK_SIMULATION 需要 APP_DB_DSN 环境变量")
app_conn = psycopg2.connect(app_dsn)
app_conn.set_client_encoding("UTF8")
site_id = self._get_site_id(etl_conn)
total_days = (end_date - start_date).days + 1
self.logger.info(
"DWS_TASK_SIMULATION: %s ~ %s (%d天), site_id=%s",
start_date, end_date, total_days, site_id,
)
# 清理截止日期前的旧数据(模拟数据可安全清理)
self._clean_before_cutoff(app_conn, CUTOFF_DATE)
active_tasks: dict[tuple[int, int], dict] = {}
stats = {
"created": 0, "completed": 0, "overridden": 0,
"expired": 0, "follow_up_created": 0,
"recall_events": 0, "skipped_no_snapshot": 0,
}
t0 = time.time()
current = start_date
while current <= end_date:
snapshot = self._load_snapshot(etl_conn, site_id, current)
if not snapshot["relation"] and not snapshot["wbi"] and not snapshot["nci"]:
stats["skipped_no_snapshot"] += 1
current += timedelta(days=1)
continue
self._simulate_day(
app_conn, etl_conn, site_id, current, tz,
snapshot, active_tasks, stats,
)
day_num = (current - start_date).days + 1
if day_num % 30 == 0 or current == end_date:
elapsed = time.time() - t0
self.logger.info(
"DWS_TASK_SIMULATION: %s (%d/%d) 创建=%d 完成=%d 覆盖=%d 过期=%d %.0fs",
current, day_num, total_days,
stats["created"], stats["completed"],
stats["overridden"], stats["expired"], elapsed,
)
current += timedelta(days=1)
total_elapsed = time.time() - t0
self.logger.info(
"DWS_TASK_SIMULATION 完成: %.0fs, 创建=%d 完成=%d 覆盖=%d 过期=%d 回访=%d 事件=%d 跳过=%d active=%d",
total_elapsed, stats["created"], stats["completed"],
stats["overridden"], stats["expired"],
stats["follow_up_created"], stats["recall_events"],
stats["skipped_no_snapshot"], len(active_tasks),
)
app_conn.close()
return {
"status": "SUCCESS",
"counts": stats,
}
# ── 日期解析 ──
def _parse_date_range(self, context: Optional[TaskContext]) -> tuple[date, date]:
wo = self.config.get("run.window_override") or {}
start_str = wo.get("start")
end_str = wo.get("end")
if start_str and end_str:
return self._parse_date(start_str), self._parse_date(end_str)
if context and context.window_start and context.window_end:
return context.window_start.date(), context.window_end.date()
raise ValueError(
"DWS_TASK_SIMULATION 需要指定日期范围。"
"CLI: --window-start 2025-08-01 --window-end 2026-03-28"
)
@staticmethod
def _parse_date(s) -> date:
if isinstance(s, date) and not isinstance(s, datetime):
return s
if isinstance(s, datetime):
return s.date()
return date.fromisoformat(str(s).strip()[:10])
def _get_site_id(self, etl_conn) -> int:
with etl_conn.cursor() as cur:
cur.execute(
"SELECT DISTINCT site_id FROM dws.dws_member_assistant_relation_index LIMIT 1"
)
row = cur.fetchone()
etl_conn.commit()
if not row:
raise RuntimeError("relation_index 表为空,请先运行 DWS_INDEX_BACKFILL")
return row[0]
# ── 数据加载 ──
def _load_snapshot(self, etl_conn, site_id: int, stat_date: date) -> dict:
result = {"relation": {}, "wbi": {}, "nci": {}}
with etl_conn.cursor() as cur:
cur.execute(
"""SELECT assistant_id, member_id, rs_display, os_label, session_count
FROM dws.dws_member_assistant_relation_index
WHERE site_id = %s AND stat_date = %s""",
(site_id, stat_date),
)
for r in cur.fetchall():
result["relation"][(r[0], r[1])] = {
"rs": Decimal(str(r[2])), "os_label": r[3], "session_count": r[4],
}
cur.execute(
"""SELECT member_id, display_score FROM dws.dws_member_winback_index
WHERE site_id = %s AND stat_date = %s""",
(site_id, stat_date),
)
for r in cur.fetchall():
result["wbi"][r[0]] = Decimal(str(r[1])) if r[1] else Decimal(0)
cur.execute(
"""SELECT member_id, display_score FROM dws.dws_member_newconv_index
WHERE site_id = %s AND stat_date = %s""",
(site_id, stat_date),
)
for r in cur.fetchall():
result["nci"][r[0]] = Decimal(str(r[1])) if r[1] else Decimal(0)
etl_conn.commit()
return result
def _load_settlements(self, etl_conn, site_id: int, d: date) -> dict:
"""加载当天结算 → {(assistant_id, member_id): pay_time}"""
tz = ZoneInfo(self.config.get("app.timezone", "Asia/Shanghai"))
day_start = datetime(d.year, d.month, d.day, 0, 0, 0, tzinfo=tz)
day_end = day_start + timedelta(days=1)
settlements = {}
with etl_conn.cursor() as cur:
cur.execute(
"""SELECT sl.site_assistant_id, sh.member_id, MAX(sh.pay_time)
FROM dwd.dwd_settlement_head sh
JOIN dwd.dwd_assistant_service_log sl
ON sl.order_settle_id = sh.order_settle_id AND sl.is_delete = 0
WHERE sh.site_id = %s AND sh.settle_type IN (1,3)
AND sh.pay_time >= %s AND sh.pay_time < %s
GROUP BY sl.site_assistant_id, sh.member_id""",
(site_id, day_start, day_end),
)
for r in cur.fetchall():
if r[0] and r[1]:
settlements[(r[0], r[1])] = r[2]
etl_conn.commit()
return settlements
# ── 模拟逻辑 ──
def _simulate_day(
self, app_conn, etl_conn, site_id, d, tz,
snapshot, active_tasks, stats,
):
day_dt = datetime(d.year, d.month, d.day, 7, 0, 0, tzinfo=tz)
# 1. 过期检测
expired_keys = [
k for k, t in active_tasks.items()
if t.get("expires_at") and t["expires_at"] < day_dt
]
for key in expired_keys:
task = active_tasks.pop(key)
stats["expired"] += 1
with app_conn.cursor() as cur:
cur.execute(
"UPDATE biz.coach_tasks SET status = 'expired', updated_at = %s WHERE id = %s",
(day_dt, task["id"]),
)
self._history(cur, task["id"], "expired", "active", "expired",
task["task_type"], task["task_type"],
{"simulated": True})
# 2. 任务生成(冲突覆盖)
relation = snapshot["relation"]
wbi_map = snapshot["wbi"]
nci_map = snapshot["nci"]
ownership_pairs = [
(aid, mid, info)
for (aid, mid), info in relation.items()
if info["os_label"] in ("MAIN", "COMANAGE") and info["session_count"] > 0
]
for aid, mid, info in ownership_pairs:
wbi = wbi_map.get(mid, Decimal(0))
nci = nci_map.get(mid, Decimal(0))
rs = info["rs"]
new_type = determine_task_type(IndexData(
site_id=site_id, assistant_id=aid, member_id=mid,
wbi=wbi, nci=nci, rs=rs,
has_active_recall=False, has_follow_up_note=False,
))
if not new_type:
continue
key = (aid, mid)
existing = active_tasks.get(key)
priority = float(max(wbi, nci)) if new_type in (
"high_priority_recall", "priority_recall"
) else float(rs)
if existing:
if existing["task_type"] == new_type:
continue # 同类型跳过
if existing["task_type"] == "follow_up_visit":
# follow_up_visit 保留宽限期,填 expires_at新建高优先任务
with app_conn.cursor() as cur:
if not existing.get("expires_at"):
cur.execute(
"""UPDATE biz.coach_tasks
SET expires_at = created_at + INTERVAL '48 hours', updated_at = %s
WHERE id = %s""",
(day_dt, existing["id"]),
)
self._history(cur, existing["id"], "expires_at_filled",
"active", "active",
"follow_up_visit", "follow_up_visit",
{"reason": "higher_priority_task_created", "simulated": True})
# 新建高优先任务
cur.execute(
"""INSERT INTO biz.coach_tasks
(site_id, assistant_id, member_id, task_type, status,
priority_score, parent_task_id, created_at, updated_at)
VALUES (%s, %s, %s, %s, 'active', %s, %s, %s, %s)
RETURNING id""",
(site_id, aid, mid, new_type, priority,
existing["id"], day_dt, day_dt),
)
new_id = cur.fetchone()[0]
self._history(cur, new_id, "created", None, "active",
"follow_up_visit", new_type, {"simulated": True})
active_tasks[key] = {
"id": new_id, "task_type": new_type,
"created_at": day_dt, "expires_at": None,
"priority": priority,
}
stats["created"] += 1
else:
# 非 follow_up原地覆盖
with app_conn.cursor() as cur:
cur.execute(
"""UPDATE biz.coach_tasks
SET task_type = %s, priority_score = %s, updated_at = %s
WHERE id = %s AND status = 'active'""",
(new_type, priority, day_dt, existing["id"]),
)
self._history(cur, existing["id"], "type_override", "active", "active",
existing["task_type"], new_type,
{"old_priority": existing.get("priority"), "simulated": True})
existing["task_type"] = new_type
existing["priority"] = priority
stats["overridden"] += 1
else:
# 新建任务
with app_conn.cursor() as cur:
cur.execute(
"""INSERT INTO biz.coach_tasks
(site_id, assistant_id, member_id, task_type, status,
priority_score, created_at, updated_at)
VALUES (%s, %s, %s, %s, 'active', %s, %s, %s)
RETURNING id""",
(site_id, aid, mid, new_type, priority, day_dt, day_dt),
)
task_id = cur.fetchone()[0]
self._history(cur, task_id, "created", None, "active",
None, new_type, {"simulated": True})
active_tasks[key] = {
"id": task_id, "task_type": new_type,
"created_at": day_dt, "expires_at": None,
"priority": priority,
}
stats["created"] += 1
# 3. 召回检测
settlements = self._load_settlements(etl_conn, site_id, d)
for (aid, mid), pay_time in settlements.items():
key = (aid, mid)
task = active_tasks.get(key)
# 写 recall_event
with app_conn.cursor() as cur:
try:
cur.execute(
"""INSERT INTO biz.recall_events
(site_id, assistant_id, member_id, pay_time,
task_id, task_type, created_at)
VALUES (%s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (site_id, assistant_id, member_id,
(date_trunc('day', pay_time AT TIME ZONE 'Asia/Shanghai')))
DO NOTHING RETURNING id""",
(site_id, aid, mid, pay_time,
task["id"] if task else None,
task["task_type"] if task else None,
day_dt),
)
if cur.fetchone():
stats["recall_events"] += 1
except Exception:
pass
if not task:
continue
if task["task_type"] not in ("high_priority_recall", "priority_recall"):
continue
if pay_time <= task["created_at"]:
continue
# 完成召回
with app_conn.cursor() as cur:
cur.execute(
"""UPDATE biz.coach_tasks
SET status = 'completed', completed_at = %s,
completed_task_type = %s, completion_type = 'auto', updated_at = %s
WHERE id = %s AND status = 'active'""",
(pay_time, task["task_type"], day_dt, task["id"]),
)
self._history(cur, task["id"], "completed", "active", "completed",
task["task_type"], task["task_type"],
{"service_time": str(pay_time), "simulated": True})
stats["completed"] += 1
# 生成回访
expires_at = pay_time + timedelta(hours=FOLLOW_UP_HOURS)
with app_conn.cursor() as cur:
cur.execute(
"""INSERT INTO biz.coach_tasks
(site_id, assistant_id, member_id, task_type, status,
expires_at, created_at, updated_at)
VALUES (%s, %s, %s, 'follow_up_visit', 'active', %s, %s, %s)
RETURNING id""",
(site_id, aid, mid, expires_at, day_dt, day_dt),
)
fu_id = cur.fetchone()[0]
self._history(cur, fu_id, "created", None, "active",
None, "follow_up_visit",
{"reason": "recall_completed", "simulated": True})
active_tasks[key] = {
"id": fu_id, "task_type": "follow_up_visit",
"created_at": day_dt, "expires_at": expires_at,
}
stats["follow_up_created"] += 1
app_conn.commit()
# ── 辅助 ──
@staticmethod
def _history(cur, task_id, action, old_status, new_status,
old_task_type, new_task_type, detail=None):
if task_id is None:
return
cur.execute(
"""INSERT INTO biz.coach_task_history
(task_id, action, old_status, new_status,
old_task_type, new_task_type, detail)
VALUES (%s, %s, %s, %s, %s, %s, %s)""",
(task_id, action, old_status, new_status,
old_task_type, new_task_type,
json.dumps(detail) if detail else None),
)
def _clean_before_cutoff(self, app_conn, cutoff: date):
"""清理截止日期前的数据(安全:只删模拟产生的历史数据)。"""
tz = ZoneInfo(self.config.get("app.timezone", "Asia/Shanghai"))
cutoff_dt = datetime(cutoff.year, cutoff.month, cutoff.day, 23, 59, 59, tzinfo=tz)
with app_conn.cursor() as cur:
cur.execute(
"""DELETE FROM biz.coach_task_history
WHERE task_id IN (SELECT id FROM biz.coach_tasks WHERE created_at < %s)""",
(cutoff_dt,),
)
h = cur.rowcount
cur.execute(
"DELETE FROM biz.recall_events WHERE created_at < %s",
(cutoff_dt,),
)
e = cur.rowcount
cur.execute(
"DELETE FROM biz.coach_tasks WHERE created_at < %s",
(cutoff_dt,),
)
t = cur.rowcount
app_conn.commit()
if t > 0:
self.logger.info(
"DWS_TASK_SIMULATION: 清理旧数据 %d history, %d events, %d tasks",
h, e, t,
)

View File

@@ -89,6 +89,7 @@ apps/miniprogram/
| `pages/customer-detail/customer-detail` | 客户详情页(完整档案 + AI 洞察 + 维客线索) | |
| `pages/customer-service-records/customer-service-records` | 客户服务记录页(按日期分组的消费记录) | |
| `pages/coach-detail/coach-detail` | 助教详情页(业绩数据 + 客户列表) | |
| `pages/coach-service-records/coach-service-records` | 助教业绩明细页(管理者视角,查看指定助教的业绩明细) | |
| `pages/chat/chat` | AI 对话页SSE 流式输出,按上下文进入对话) | |
| `pages/chat-history/chat-history` | 对话历史列表页 | |
| `pages/dev-tools/dev-tools` | 开发调试面板(仅 develop 环境,通过 dev-fab 浮动按钮进入) | |
@@ -118,6 +119,50 @@ apps/miniprogram/
| service-record-card | `components/service-record-card/` | 服务记录单项卡片 |
| star-rating | `components/star-rating/` | 星级评分组件 |
## 业绩明细双视角
业绩明细存在两个严格隔离的页面,职责互不交叉:
- `performance-records`(助教自查)
- 视角:当前登录用户(无需 coachId
- 权限:无需额外守卫
- 点击单条记录:跳 `task-detail?taskId=xxx`(关注任务)
- `coach-service-records`(管理者查看)
- 视角指定助教coachId 必传(来自 URL query
- 权限:`view_board_coach`
- 点击单条记录:跳 `customer-detail?memberId=xxx`(关注客户)
coach-service-records 页面约定:
- 入口:`coach-detail` 页面的"查看更多"按钮
- `onShow` 必须通过 `checkPageAccess('view_board_coach')` 守卫
- 数据源:
- Banner`fetchCoachBanner(coachId)` 轻量接口
- 记录:`fetchPerformanceRecords({ year, month, coachId, page, pageSize })`
- 源码:`miniprogram/pages/coach-service-records/*`
## 共享工具
业绩进度条动画参数与计算逻辑的单一源头:`miniprogram/utils/perf-progress.ts`。被 `task-list.ts``coach-detail.ts` 共同使用,避免双份漂移。
导出清单:
- 常量:`SHINE_SPEED``SPARK_DELAY_MS``SPARK_DUR_MS``NEXT_LOOP_DELAY_MS``SHINE_WIDTH_RPX`
- 类型:`TickItem` 接口
- 函数:`calcShineDur``buildTicks``buildProgressBarData`
新建同类动画参数或档位刻度逻辑时,统一落在此文件,禁止在页面脚本内就地写常量。
## 数据规范
### 散客标记isScattered
后端判定规则:`member_id <= 0` 时在响应字段上标记 `isScattered = true`,标识该条记录为散客(无会员档案)。
涉及页面:`coach-detail``performance-records``coach-service-records``customer-detail`
前端渲染约定:为承载散客信息的节点追加 CSS 类 `--scattered`,以置灰方式区分于实名会员;散客条目不提供跳转到 `customer-detail` 的入口。
## 后端 API 集成
### API 地址配置
@@ -210,6 +255,11 @@ POST /api/xcx-auth/login → 重新登录获取完整令牌(含 site_id + ro
| `/api/xcx/chat/messages` | GET | 通过上下文查消息 |
| `/api/xcx/chat/stream` | POST | SSE 流式对话 |
### services/api.ts 近期新增
- `fetchCoachBanner(coachId: number)` — 轻量助教 banner 数据,供 `coach-service-records` 顶部卡片复用
- `fetchPerformanceRecords({ year, month, page, pageSize, coachId? })` — 新增可选 `coachId` 参数;不传为助教自查视角(后端以当前登录用户过滤),传入则为管理者查看视角(后端按权限码 `view_board_coach` 分流校验)
> 完整接口契约见 [`docs/miniprogram-dev/API-contract.md`](../../docs/miniprogram-dev/API-contract.md)
> 后端 API 参考见 [`apps/backend/docs/API-REFERENCE.md`](../backend/docs/API-REFERENCE.md)

View File

@@ -17,6 +17,7 @@
"pages/customer-service-records/customer-service-records",
"pages/customer-records/customer-records",
"pages/coach-detail/coach-detail",
"pages/coach-service-records/coach-service-records",
"pages/chat/chat",
"pages/chat-history/chat-history",
"pages/dev-tools/dev-tools"

View File

@@ -255,6 +255,6 @@
font-weight: 600;
}
.ppb-tick--highlight {
color: rgba(255, 255, 255, 0.85);
color: var(--ppb-tick-highlight-color, rgba(255, 255, 255, 0.85));
font-weight: 500;
}

View File

@@ -270,8 +270,8 @@ Page({
svAmountLabel: formatMoney(c.svAmount ?? 0),
svCustomerCountLabel: formatCount(c.svCustomerCount ?? 0, '人'),
svConsumeLabel: formatMoney(c.svConsume ?? 0),
taskRecallLabel: formatCount(c.taskRecall ?? 0, '次'),
taskCallbackLabel: formatCount(c.taskCallback ?? 0, '次'),
taskRecallLabel: `${c.taskRecall ?? 0}`,
taskCallbackLabel: `${c.taskCallback ?? 0}`,
}))
// 追加时按 id 去重,避免 wx:key 重复警告

View File

@@ -11,12 +11,45 @@
import { checkPageAccess, getVisibleBoardTabs } from '../../utils/auth-guard'
import { getRandomAiColor } from '../../utils/ai-color'
import { fetchBoardFinance } from '../../services/api'
import { fetchBoardFinance, fetchAICache } from '../../services/api'
function isCurrentMonthFilter(selectedTime: string): boolean {
return selectedTime === 'month' && new Date().getDate() <= 5
}
// 2026-04-22 小程序轻量 Markdown 内联解析:支持 **加粗** / *倾斜* / _倾斜_ / ***加粗倾斜***
// - 不支持块级标题、列表、代码块AI 洞察文案为单段纯文本,足够覆盖
// - 输出片段数组,供 WXML 通过 <text> 循环渲染不同样式
interface MarkdownSeg {
text: string
bold?: boolean
italic?: boolean
}
function parseMarkdownInline(text: string): MarkdownSeg[] {
if (!text) return []
const segs: MarkdownSeg[] = []
// 先匹配 ***x***(加粗倾斜)、再匹配 **x**(加粗)、再匹配 *x* / _x_倾斜、__x__加粗
const re = /\*\*\*([^*]+)\*\*\*|\*\*([^*]+)\*\*|__([^_]+)__|\*([^*\s][^*]*)\*|_([^_\s][^_]*)_/g
let last = 0
let m: RegExpExecArray | null
while ((m = re.exec(text)) !== null) {
if (m.index > last) {
segs.push({ text: text.slice(last, m.index) })
}
if (m[1] !== undefined) segs.push({ text: m[1], bold: true, italic: true })
else if (m[2] !== undefined) segs.push({ text: m[2], bold: true })
else if (m[3] !== undefined) segs.push({ text: m[3], bold: true })
else if (m[4] !== undefined) segs.push({ text: m[4], italic: true })
else if (m[5] !== undefined) segs.push({ text: m[5], italic: true })
last = m.index + m[0].length
}
if (last < text.length) {
segs.push({ text: text.slice(last) })
}
return segs.length ? segs : [{ text }]
}
interface TocItem {
emoji: string
title: string
@@ -73,7 +106,17 @@ Page({
compareEnabled: false,
isCurrentMonth: true,
aiInsights: [] as Array<{ icon: string; text: string }>,
// 2026-04-22 改版:拆成 title/body 两段title 在 wxml 渲染为 ai-insight-dim灰色标题body 为正文
aiInsights: [] as Array<{ title: string; body: string }>,
// 2026-04-22 seq11/12 置顶:从 aiInsights 派生,供"本期总结"卡片渲染
aiInsightSummary: {
evaluation: null as null | { title: string; body: string },
tracking: null as null | { title: string; body: string },
},
aiInsightDetails: [] as Array<{ title: string; body: string }>,
summaryLightType: '' as '' | 'green' | 'yellow' | 'red',
summaryLightLabel: '',
aiInsightsModalVisible: false, // 查看全部弹窗可见性
tocVisible: false,
tocItems: [
@@ -453,8 +496,16 @@ Page({
},
})
const aiInsights = (data.aiInsights || []) as Array<{ icon: string; text: string }>
this.setData({ aiInsights, pageState: 'normal' })
// 后端不返回 aiInsights仅在有值时覆盖避免重载时清空旧缓存洞察
const dataToSet: Record<string, any> = { pageState: 'normal' }
if (Array.isArray(data.aiInsights) && data.aiInsights.length > 0) {
dataToSet.aiInsights = data.aiInsights
}
this.setData(dataToSet)
// Phase 2.5:独立加载 AI 财务洞察缓存(不阻塞主流程)
// 2026-04-21缓存按 time__area 粒度预热cron 每日 10:00 × 72 组合),按当前筛选读缓存
this._loadAIInsights(this.data.selectedTime, this.data.selectedArea).catch(() => {})
} catch (err) {
console.error('[board-finance] 数据加载失败', err)
@@ -465,6 +516,124 @@ Page({
}
},
async _loadAIInsights(selectedTime: string, selectedArea: string) {
// 2026-04-21缓存粒度 time__area后端 dispatcher._app2_target_id 同款拼装规则
// 2026-04-23按 area 切 cache_type全域 app2_finance / 区域 app2a_finance_area
const TIME_MAP: Record<string, string> = {
month: 'this_month', lastMonth: 'last_month',
week: 'this_week', lastWeek: 'last_week',
quarter: 'this_quarter', lastQuarter: 'last_quarter',
quarter3: 'last_3_months', half6: 'last_6_months',
}
const timeKey = TIME_MAP[selectedTime]
if (!timeKey) return
const areaKey = selectedArea || 'all'
const targetId = `${timeKey}__${areaKey}`
const cacheType = areaKey === 'all' ? 'app2_finance' : 'app2a_finance_area'
// 切换筛选前先清空当前展示的洞察,避免 cache miss 时仍显示上个区域的陈旧数据
this.setData({
aiInsights: [],
aiInsightSummary: { evaluation: null, tracking: null },
aiInsightDetails: [],
summaryLightType: '',
summaryLightLabel: '',
})
const cache = await fetchAICache(cacheType, targetId)
if (!cache?.result_json) return
const rj = cache.result_json as any
// 2026-04-22忠于 demo 两段式渲染dim 标题 + 白色正文),拆分 title/body 存到 data
// 百炼返回字段 content兜底 bodyseq 字段用于 _extractSummary 精确定位 seq11/12
const insights = Array.isArray(rj.insights)
? rj.insights.map((item: any, idx: number) => {
const title = (item.title || '').trim()
const body = (item.content || item.body || '').trim()
const seq = Number(item.seq) || (idx + 1)
return {
seq,
title,
body,
titleSegs: parseMarkdownInline(title),
bodySegs: parseMarkdownInline(body),
}
}).filter((i: { title: string; body: string }) => i.title.length > 0 || i.body.length > 0)
: []
if (insights.length === 0) return
// 2026-04-22 seq11/12 置顶:识别"本期总结"(健康度评级 + 跟踪指标)
// - 优先按 seq 字段精确匹配seq=11 健康度 · seq=12 跟踪指标)
// - 失败时回退到"末两条启发式",兼容旧缓存或输出顺序错位
const summary = this._extractSummary(insights)
this.setData({
aiInsights: insights,
aiInsightSummary: summary.summary,
aiInsightDetails: summary.details,
summaryLightType: summary.lightType,
summaryLightLabel: summary.lightLabel,
})
},
/**
* 识别 seq11(健康度) + seq12(跟踪指标),并解析三色灯类型。
* 规则:
* 1. 优先按 item.seq === 11 / 12 精确匹配
* 2. 找不到时回退"末两条启发式"(数组长度 ≥ 4
* 3. 数组过短直接降级为全量明细无总结
*/
_extractSummary(insights: Array<{ seq?: number; title: string; body: string }>) {
const empty = {
summary: { evaluation: null, tracking: null },
details: insights,
lightType: '' as '' | 'green' | 'yellow' | 'red',
lightLabel: '',
}
if (insights.length < 4) return empty
// 优先按 seq 精确匹配
let evaluation = insights.find(i => i.seq === 11) || null
let tracking = insights.find(i => i.seq === 12) || null
let details: typeof insights
if (evaluation && tracking) {
// seq 精确匹配成功 → 过滤掉 seq 11/12
details = insights.filter(i => i.seq !== 11 && i.seq !== 12)
} else {
// 回退:末两条启发式
evaluation = insights[insights.length - 2]
tracking = insights[insights.length - 1]
details = insights.slice(0, insights.length - 2)
}
// 三色灯识别:优先匹配 emoji其次匹配文案
const text = `${evaluation.title} ${evaluation.body}`
let lightType: '' | 'green' | 'yellow' | 'red' = ''
let lightLabel = ''
if (/🔴|红灯|警告/.test(text)) {
lightType = 'red'
lightLabel = '🔴 红灯警告'
} else if (/🟡|黄灯|观察/.test(text)) {
lightType = 'yellow'
lightLabel = '🟡 黄灯观察'
} else if (/🟢|绿灯|健康/.test(text)) {
lightType = 'green'
lightLabel = '🟢 绿灯健康'
}
return {
summary: { evaluation, tracking },
details,
lightType,
lightLabel,
}
},
openAllInsights() {
this.setData({ aiInsightsModalVisible: true })
},
closeAllInsights() {
this.setData({ aiInsightsModalVisible: false })
},
/** 弹窗遮罩点击穿透阻断(避免滚动到 page 底) */
_noop() {},
onPullDownRefresh() {
this._loadData()
setTimeout(() => wx.stopPullDownRefresh(), 500)

View File

@@ -8,40 +8,12 @@ import { fetchCoachDetail } from '../../services/api'
import { formatMoney, formatCount } from '../../utils/money'
import { formatHours } from '../../utils/time'
import { sortByTimestamp } from '../../utils/sort'
/* ── 进度条动画参数(与 task-list 共享相同逻辑) ──
* 修改说明见 apps/miniprogram/doc/progress-bar-animation.md
*/
const SHINE_SPEED = 70
const SPARK_DELAY_MS = -150
const SPARK_DUR_MS = 1400
const NEXT_LOOP_DELAY_MS = 400
const SHINE_WIDTH_RPX = 120
const TRACK_WIDTH_RPX = 634
const SHINE_WIDTH_PCT = (SHINE_WIDTH_RPX / TRACK_WIDTH_RPX) * 100
function calcShineDur(filledPct: number): number {
const t = (SHINE_SPEED - 1) / 99
const baseDur = 5000 - t * (5000 - 50)
const distRatio = (filledPct + SHINE_WIDTH_PCT) / (100 + SHINE_WIDTH_PCT)
return Math.max(50, Math.round(baseDur * distRatio))
}
interface TickItem {
value: number
label: string
left: string
highlight: boolean
}
function buildTicks(tierNodes: number[], maxHours: number): TickItem[] {
return tierNodes.map((v, i) => ({
value: v,
label: String(v),
left: `${Math.round((v / maxHours) * 10000) / 100}%`,
highlight: i === 2,
}))
}
import { nameToAvatarColor } from '../../utils/avatar-color'
import {
SPARK_DELAY_MS, SPARK_DUR_MS, NEXT_LOOP_DELAY_MS,
calcShineDur, buildProgressBarData,
type TickItem,
} from '../../utils/perf-progress'
/** 助教详情(含绩效、收入、任务、客户关系等) */
interface CoachDetail {
@@ -54,18 +26,33 @@ interface CoachDetail {
customerCount: number
hireDate: string
performance: {
monthlyHours: number
monthlySalary: number
// 核心绩效字段(与任务页 PerformanceSummary 一致)
totalHours: number
totalIncome: number
totalCustomers: number
tierNodes: number[]
basicHours: number
bonusHours: number
currentTier: number
nextTierHours: number
tierCompleted: boolean
bonusMoney: number
incomeTrend: string
incomeTrendDir: string
currentTierLabel: string
// 助教详情专属扩展
customerBalance: number
tasksCompleted: number
/** 绩效档位 */
perfCurrent: number
perfTarget: number
// 兼容旧字段
monthlyHours: number
monthlySalary: number
}
income: {
thisMonth: IncomeItem[]
lastMonth: IncomeItem[]
}
taskStats: { callback: number; recall: number }
tierNodes: number[]
notes: NoteItem[]
}
@@ -147,17 +134,30 @@ const mockCoachDetail: CoachDetail = {
customerCount: 0,
hireDate: '',
performance: {
monthlyHours: 0,
monthlySalary: 0,
totalHours: 0,
totalIncome: 0,
totalCustomers: 0,
tierNodes: [],
basicHours: 0,
bonusHours: 0,
currentTier: 0,
nextTierHours: 0,
tierCompleted: false,
bonusMoney: 0,
incomeTrend: '',
incomeTrendDir: 'up',
currentTierLabel: '',
customerBalance: 0,
tasksCompleted: 0,
perfCurrent: 0,
perfTarget: 0,
monthlyHours: 0,
monthlySalary: 0,
},
income: {
thisMonth: [],
lastMonth: [],
},
taskStats: { callback: 0, recall: 0 },
tierNodes: [],
notes: [],
}
@@ -197,6 +197,8 @@ Page({
coachId: '',
/** 助教详情 */
detail: null as CoachDetail | null,
/** 等级标签背景色(助教详情页专属,适配深色背景) */
levelBgColor: '',
/** 绩效指标卡片 */
perfCards: [] as Array<{ label: string; value: string; unit: string; sub: string; bgClass: string; valueColor: string }>,
/** 绩效进度 */
@@ -307,52 +309,67 @@ Page({
}
const perf = d.performance || {} as any
// 统一使用 totalHours来自 monthly_summary 实时值),与任务页一致
const totalHours = perf.totalHours ?? perf.total_hours ?? perf.monthlyHours ?? 0
const perfCards = [
{ label: '本月定档业绩', value: formatHours(perf.monthlyHours ?? 0), unit: '', sub: '', bgClass: 'perf-blue', valueColor: 'text-primary' },
{ label: '本月工资(预估)', value: formatMoney(perf.monthlySalary ?? 0), unit: '', sub: '', bgClass: 'perf-green', valueColor: 'text-success' },
{ label: '客源储值余额', value: formatMoney(perf.customerBalance ?? 0), unit: '', sub: `${formatCount(d.customerCount ?? 0, '位')}客户合计`, bgClass: 'perf-orange', valueColor: 'text-warning' },
{ label: '本月任务完成', value: formatCount(perf.tasksCompleted ?? 0, '个') || '--', unit: '', sub: '', bgClass: 'perf-purple', valueColor: 'text-purple' },
{ label: '本月定档业绩', value: formatHours(totalHours), unit: '', sub: '', bgClass: 'perf-blue', valueColor: 'text-primary' },
{ label: '本月工资(预估)', value: formatMoney(perf.monthlySalary ?? perf.totalIncome ?? perf.total_income ?? 0), unit: '', sub: '', bgClass: 'perf-green', valueColor: 'text-success' },
{ label: '客源储值余额', value: formatMoney(perf.customerBalance ?? perf.customer_balance ?? 0), unit: '', sub: `${formatCount(d.customerCount ?? 0, '位')}客户合计`, bgClass: 'perf-orange', valueColor: 'text-warning' },
{ label: '本月任务完成', value: formatCount(perf.tasksCompleted ?? perf.tasks_completed ?? 0, '个') || '--', unit: '', sub: '', bgClass: 'perf-purple', valueColor: 'text-purple' },
]
const perfGap = (perf.perfTarget ?? 0) - (perf.perfCurrent ?? 0)
const perfPercent = perf.perfTarget > 0 ? Math.min(Math.round(((perf.perfCurrent ?? 0) / perf.perfTarget) * 100), 100) : 0
// 统一使用共用模块计算进度条数据(与任务页相同逻辑)
const pbData = buildProgressBarData(perf)
// 档位节点从 API 返回fallback [0, 120, 150, 180, 210]
const tierNodes = d.tierNodes && d.tierNodes.length > 0 ? d.tierNodes : [0, 120, 150, 180, 210]
const maxHours = tierNodes[tierNodes.length - 1] || 210
const totalHours = perf.monthlyHours ?? 0
const pbFilledPct = Math.min(100, Math.round((totalHours / maxHours) * 1000) / 10)
let pbCurrentTier = 0
for (let i = 1; i < tierNodes.length; i++) {
if (totalHours >= tierNodes[i]) pbCurrentTier = i
else break
}
const nextTierHours = perf.nextTierHours ?? perf.next_tier_hours ?? 0
const perfGap = Math.max(0, nextTierHours - totalHours)
const tierNodes = perf.tierNodes ?? perf.tier_nodes ?? [0]
const maxHours = tierNodes.length > 0 ? tierNodes[tierNodes.length - 1] : 0
const perfPercent = maxHours > 0 ? Math.min(Math.round((totalHours / maxHours) * 100), 100) : 0
const sorted = sortByTimestamp(d.notes || [], 'timestamp') as NoteItem[]
const taskStats = d.taskStats ?? { recall: 0, callback: 0 }
// 等级标签背景色(助教详情页深色背景专用)
const level = (d as any).level || ''
const levelBgMap: Record<string, string> = {
'junior': '#E5EDFB', '初级': '#E5EDFB',
'middle': '#FDEFE5', '中级': '#FDEFE5',
'senior': '#FDE3EC', '高级': '#FDE3EC',
}
const levelBgColor = levelBgMap[level] || ''
this.setData({
pageState: 'normal',
detail: d,
levelBgColor,
perfCards,
perfCurrent: perf.perfCurrent ?? 0,
perfTarget: perf.perfTarget ?? 0,
perfCurrent: totalHours,
perfTarget: nextTierHours,
perfGap,
perfPercent,
taskStats,
visibleTasks: d.visibleTasks || [],
hiddenTasks: d.hiddenTasks || [],
abandonedTasks: d.abandonedTasks || [],
topCustomers: d.topCustomers || [],
serviceRecords: d.serviceRecords || [],
topCustomers: (d.topCustomers || [])
.map((c: any) => ({
...c,
avatarGradient: nameToAvatarColor(String(c.id || '')),
}))
.sort((a: any, b: any) => parseFloat(b.score || 0) - parseFloat(a.score || 0)),
serviceRecords: (d.serviceRecords || []).map((r: any) => ({
...r,
avatarGradient: nameToAvatarColor(String(r.customerId || '')),
})),
historyMonths: d.historyMonths || [],
sortedNotes: sorted,
pbFilledPct,
pbClampedSparkPct: Math.max(0, Math.min(100, pbFilledPct)),
pbCurrentTier,
pbTicks: buildTicks(tierNodes, maxHours),
pbShineDurMs: calcShineDur(pbFilledPct),
pbSparkDurMs: SPARK_DUR_MS,
pbFilledPct: pbData.filledPct,
pbClampedSparkPct: pbData.clampedSparkPct,
pbCurrentTier: pbData.currentTier,
pbTicks: pbData.ticks,
pbShineDurMs: pbData.shineDurMs,
pbSparkDurMs: pbData.sparkDurMs,
})
this.switchIncomeTab('this')
@@ -394,11 +411,18 @@ Page({
this.setData({ tasksExpanded: !this.data.tasksExpanded })
},
/** 点击任务项 — 跳转客户详情 */
/** 点击任务项 — 跳转客户详情(散客 customer_id ≤ 0 时无详情可看) */
onTaskItemTap(e: WechatMiniprogram.CustomEvent) {
const name = e.currentTarget.dataset.name as string
if (!name) return
wx.navigateTo({ url: `/pages/customer-detail/customer-detail?name=${encodeURIComponent(name)}` })
const { id } = e.currentTarget.dataset
const cid = Number(id)
if (!cid || cid <= 0) {
wx.showToast({ title: '散客无详情可查看', icon: 'none' })
return
}
wx.navigateTo({
url: `/pages/customer-detail/customer-detail?id=${cid}`,
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
})
},
/** 展开/收起客户关系列表 */
@@ -432,29 +456,39 @@ Page({
this.setData({ notesPopupVisible: false })
},
/** 点击客户卡片 — 跳转客户详情 */
/** 点击客户卡片 — 跳转客户详情(散客 id ≤ 0 时无详情可看) */
onCustomerTap(e: WechatMiniprogram.CustomEvent) {
const id = e.currentTarget.dataset.id as string
const { id } = e.currentTarget.dataset
const cid = Number(id)
if (!cid || cid <= 0) {
wx.showToast({ title: '散客无详情可查看', icon: 'none' })
return
}
wx.navigateTo({
url: `/pages/customer-detail/customer-detail?id=${id}`,
url: `/pages/customer-detail/customer-detail?id=${cid}`,
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
})
},
/** 近期服务明细 — 点击跳转客户详情 */
/** 近期服务明细 — 点击跳转客户详情(散客 customer_id ≤ 0 时无详情可看) */
onSvcCardTap(e: WechatMiniprogram.TouchEvent) {
const id = e.currentTarget.dataset.id as string
const { id } = e.currentTarget.dataset
const cid = Number(id)
if (!cid || cid <= 0) {
wx.showToast({ title: '散客无详情可查看', icon: 'none' })
return
}
wx.navigateTo({
url: `/pages/customer-detail/customer-detail${id ? '?id=' + id : ''}`,
url: `/pages/customer-detail/customer-detail?id=${cid}`,
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
})
},
/** 查看更多服务记录 */
/** 查看更多服务记录 → 跳"助教业绩明细"页(管理者视角,独立于任务 tab 自查页) */
onViewMoreRecords() {
const coachId = this.data.coachId || this.data.detail?.id || ''
wx.navigateTo({
url: `/pages/performance-records/performance-records?coachId=${coachId}`,
url: `/pages/coach-service-records/coach-service-records?coachId=${coachId}`,
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
})
},

View File

@@ -25,7 +25,7 @@
<view class="info-middle">
<view class="name-row">
<text class="coach-name">{{fmt.safe(detail.name)}}</text>
<coach-level-tag level="{{detail.level}}" />
<coach-level-tag level="{{detail.level}}" bgColor="{{levelBgColor}}" shadowColor="transparent" />
</view>
<view class="skill-row">
<text class="skill-tag" wx:for="{{detail.skills}}" wx:key="index">{{item}}</text>
@@ -79,7 +79,7 @@
sparkRunning="{{pbSparkRunning}}"
shineDurMs="{{pbShineDurMs}}"
sparkDurMs="{{pbSparkDurMs}}"
style="--ppb-track-bg-color: rgba(59,130,246,0.25); --ppb-divider-color: rgba(255,255,255,1); --ppb-tick-done-color: #60a5fa; --ppb-tick-color: rgba(147,197,253,0.6);"
style="--ppb-track-bg-color: rgba(59,130,246,0.25); --ppb-divider-color: rgba(255,255,255,1); --ppb-tick-done-color: #60a5fa; --ppb-tick-color: rgba(147,197,253,0.6); --ppb-tick-highlight-color: #3b82f6;"
/>
</view>
</view>
@@ -117,14 +117,14 @@
<text class="section-title title-orange">任务执行</text>
<view class="task-summary">
<text class="task-summary-label">本月完成</text>
<text class="task-summary-callback">回访<text class="task-summary-num">{{fmt.count(taskStats.callback, '个')}}</text></text>
<text class="task-summary-recall">召回<text class="task-summary-num">{{fmt.count(taskStats.recall, '个')}}</text></text>
<text class="task-summary-callback">回访<text class="task-summary-num">{{taskStats.callback}}</text></text>
<text class="task-summary-recall">召回<text class="task-summary-num">{{taskStats.recall}}</text></text>
</view>
</view>
<view class="task-list">
<view class="task-item task-item-{{item.typeClass}}" wx:for="{{visibleTasks}}" wx:key="index"
bindtap="onTaskItemTap" data-name="{{item.customerName}}" hover-class="task-item--hover">
bindtap="onTaskItemTap" data-id="{{item.customerId}}" data-name="{{item.customerName}}" hover-class="task-item--hover">
<text class="task-tag-text {{item.typeClass}}">{{item.typeLabel}}</text>
<text class="task-customer-name">{{item.customerName}}</text>
<view class="task-note-btn" wx:if="{{item.noteCount > 0}}" catchtap="onTaskNoteTap" data-index="{{index}}" hover-class="task-note-btn--hover">
@@ -137,7 +137,7 @@
<block wx:if="{{tasksExpanded}}">
<view class="task-list task-list-extra">
<view class="task-item task-item-{{item.typeClass}}" wx:for="{{hiddenTasks}}" wx:key="index"
bindtap="onTaskItemTap" data-name="{{item.customerName}}" hover-class="task-item--hover">
bindtap="onTaskItemTap" data-id="{{item.customerId}}" data-name="{{item.customerName}}" hover-class="task-item--hover">
<text class="task-tag-text {{item.typeClass}}">{{item.typeLabel}}</text>
<text class="task-customer-name">{{item.customerName}}</text>
<view class="task-note-btn" wx:if="{{item.noteCount > 0}}" catchtap="onTaskNoteTap" data-hidden-index="{{index}}" hover-class="task-note-btn--hover">
@@ -177,7 +177,7 @@
</view>
<view class="top-customer-info">
<view class="top-customer-name-row">
<text class="top-customer-name">{{item.name}}</text>
<text class="top-customer-name {{item.isScattered ? 'top-customer-name--scattered' : ''}}">{{item.name}}</text>
<text class="top-customer-heart">{{item.heartEmoji}}</text>
<text class="top-customer-score top-customer-score-{{item.scoreColor}}">{{fmt.safe(item.score)}}</text>
</view>
@@ -211,14 +211,14 @@
<view class="svc-content">
<!-- 第1行客户名 + 类型标签 + 日期 -->
<view class="svc-row1">
<text class="svc-customer">{{item.customerName}}</text>
<text class="svc-customer {{item.isScattered ? 'svc-customer--scattered' : ''}}">{{item.customerName}}</text>
<text class="svc-type svc-type-{{item.typeClass}}">{{item.type}}</text>
<text class="svc-date">{{item.date}}</text>
</view>
<!-- 第2行台号 + 时长 + 绩效 + 收入 -->
<view class="svc-row2">
<view class="svc-row2-left">
<text class="svc-table-tag">{{item.table}}</text>
<text class="svc-table-tag" wx:if="{{item.table}}">{{item.table}}</text>
<text class="svc-duration">{{item.duration}}</text>
<text class="svc-perf" wx:if="{{item.perfHours}}">定档绩效:{{item.perfHours}}</text>
</view>

View File

@@ -744,6 +744,8 @@ view {
}
.svc-row1 { display: flex; align-items: center; gap: 12rpx; }
.svc-customer { font-size: 28rpx; font-weight: 600; color: #242424; }
/* 散客名称置灰 */
.svc-customer--scattered { color: #999; font-weight: 500; }
.svc-type {
font-size: 22rpx;
padding: 2rpx 12rpx;
@@ -1083,6 +1085,8 @@ view {
.top-customer-info { flex: 1; min-width: 0; }
.top-customer-name-row { display: flex; align-items: center; gap: 8rpx; margin-bottom: 8rpx; }
.top-customer-name { font-size: 28rpx; font-weight: 600; color: #242424; }
/* 散客名称置灰 */
.top-customer-name--scattered { color: #999; font-weight: 500; }
.top-customer-heart { font-size: 24rpx; }
.top-customer-score { font-size: 24rpx; font-weight: 700; font-variant-numeric: tabular-nums; }
.top-customer-score-success { color: #00a870; }

View File

@@ -0,0 +1,13 @@
{
"navigationBarTitleText": "助教业绩明细",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black",
"enablePullDownRefresh": true,
"usingComponents": {
"ai-float-button": "/components/ai-float-button/ai-float-button",
"dev-fab": "/components/dev-fab/dev-fab",
"heart-icon": "/components/heart-icon/heart-icon",
"t-icon": "tdesign-miniprogram/icon/icon",
"t-loading": "tdesign-miniprogram/loading/loading"
}
}

View File

@@ -0,0 +1,275 @@
/**
* 助教服务明细页(管理者视角)。
*
* 入口pages/coach-detail/coach-detail "近期服务明细" 卡片"查看更多"按钮
* 必传 querycoachIdassistant_id
*
* 与任务 tab 下的 pages/performance-records/ 区别:
* - Banner 用 fetchCoachBanner 取目标助教信息name/level/storeName
* - 标题展示"<助教名>的业绩"突出查看视角
* - 单条记录右下角显示"助教预估收入"(去第一人称)
* - 点击单条记录跳 customer-detail管理者关心客户而非任务
* - 后端 /api/xcx/performance/records?coach_id=xxx权限码 view_board_coach
*/
import { checkPageAccess } from '../../utils/auth-guard'
import { fetchPerformanceRecords, fetchCoachBanner } from '../../services/api'
import { nameToAvatarColor } from '../../utils/avatar-color'
import { formatMoney, formatCount } from '../../utils/money'
import { formatHours } from '../../utils/time'
const COURSE_TAG_MAP: Record<string, string> = {
'陪打': 'basic', '基础课': 'basic',
'包厢': 'room', '包厢课': 'room',
'超休': 'incentive', '激励课': 'incentive', '打赏课': 'incentive',
}
function courseTagClass(courseType: string): string {
return COURSE_TAG_MAP[courseType] || 'basic'
}
interface DateGroup {
date: string
totalHours: string
totalIncome: string
records: RecordItem[]
}
interface RecordItem {
customerName: string
memberId: number
avatarChar: string
avatarColor: string
timeRange: string
hours: string
courseType: string
courseTagClass: string
location: string
income: string
isScattered?: boolean
}
Page({
data: {
pageState: 'loading' as 'loading' | 'empty' | 'error' | 'normal',
/** 目标助教 ID必传来自 query */
coachId: 0,
/** Banner — 来自 fetchCoachBanner */
coachName: '',
coachRole: '',
storeName: '',
/** Banner 主标题:用助教名生成"<助教名>的业绩" */
pageTitle: '业绩明细',
/** 月份切换 */
currentYear: new Date().getFullYear(),
currentMonth: new Date().getMonth() + 1,
monthLabel: '',
canGoPrev: true,
canGoNext: false,
/** 当月预估判断 */
isCurrentMonth: false,
/** 统计概览 */
totalCountLabel: '--',
totalHoursLabel: '--',
totalHoursRawLabel: '',
totalIncomeLabel: '--',
/** 按日期分组的记录 */
dateGroups: [] as DateGroup[],
/** 分页 */
page: 1,
pageSize: 20,
hasMore: false,
},
onLoad(options: Record<string, string | undefined>) {
const coachIdNum = Number(options?.coachId)
const coachId = Number.isFinite(coachIdNum) && coachIdNum > 0 ? coachIdNum : 0
if (coachId === 0) {
// 必传参数缺失:提示并退回上一页
wx.showToast({ title: '缺少助教标识', icon: 'none' })
setTimeout(() => wx.navigateBack({ fail: () => wx.switchTab({ url: '/pages/board-finance/board-finance' }) }), 1000)
return
}
const now = new Date()
this.setData({
coachId,
currentYear: now.getFullYear(),
currentMonth: now.getMonth() + 1,
monthLabel: `${now.getFullYear()}${now.getMonth() + 1}`,
})
this.loadBanner()
this.loadData()
},
onShow() {
checkPageAccess('pages/coach-service-records/coach-service-records')
},
onPullDownRefresh() {
this.setData({ page: 1, dateGroups: [] })
this.loadData(() => wx.stopPullDownRefresh())
},
onReachBottom() {
if (!this.data.hasMore) return
this.setData({ page: this.data.page + 1 })
this.loadData()
},
/** 加载 Banner仅 name/level/storeName3 字段轻量接口) */
async loadBanner() {
try {
const banner = await fetchCoachBanner(String(this.data.coachId))
if (!banner) return
const name = banner.name || ''
this.setData({
coachName: name,
coachRole: banner.level || '助教',
storeName: banner.storeName || '',
pageTitle: name ? `${name}的业绩` : '业绩明细',
})
// 同步原生 navbar 标题
if (name) {
wx.setNavigationBarTitle({ title: `${name}的业绩` })
}
} catch (_e) {
// banner 加载失败不阻塞列表
}
},
/** 加载服务明细 */
async loadData(cb?: () => void) {
if (this.data.page === 1) {
this.setData({ pageState: 'loading' })
}
wx.showLoading({ title: '加载中...', mask: true })
const now = new Date()
const { currentYear, currentMonth } = this.data
const isCurrentMonth = currentYear === now.getFullYear()
&& currentMonth === now.getMonth() + 1
&& now.getDate() <= 5
try {
const res = await fetchPerformanceRecords({
year: currentYear,
month: currentMonth,
page: this.data.page,
pageSize: this.data.pageSize,
coachId: this.data.coachId,
})
const newGroups = (res.dateGroups || []).map((g: any) => ({
...g,
records: (g.records || []).map((rec: any) => ({
...rec,
avatarColor: nameToAvatarColor(String(rec.memberId ?? '')),
avatarChar: rec.avatarChar || (rec.customerName || '?').charAt(0),
courseTagClass: courseTagClass(rec.courseType || ''),
})),
}))
let dateGroups: DateGroup[]
if (this.data.page === 1) {
dateGroups = newGroups
} else {
dateGroups = this._mergeGroups(this.data.dateGroups, newGroups)
}
const updates: Record<string, any> = {
pageState: dateGroups.length > 0 ? 'normal' : 'empty',
isCurrentMonth,
dateGroups,
hasMore: res.hasMore ?? false,
}
if (this.data.page === 1 && res.summary) {
const s = res.summary
updates.totalCountLabel = formatCount(s.totalCount, '笔')
updates.totalHoursLabel = formatHours(s.totalHours)
updates.totalIncomeLabel = formatMoney(s.totalIncome)
updates.totalHoursRawLabel = (s.totalHoursRaw !== s.totalHours && s.totalHoursRaw > 0)
? formatHours(s.totalHoursRaw) : ''
}
this.setData(updates)
} catch (_err) {
if (this.data.page === 1) {
this.setData({ pageState: 'error' })
}
} finally {
wx.hideLoading()
}
cb?.()
},
_mergeGroups(existing: DateGroup[], incoming: DateGroup[]): DateGroup[] {
const merged = [...existing]
for (const g of incoming) {
const found = merged.find(m => m.date === g.date)
if (found) {
found.records = [...found.records, ...g.records]
} else {
merged.push(g)
}
}
return merged
},
onRetry() {
this.setData({ page: 1, dateGroups: [] })
this.loadData()
},
/** 点击单条记录 → 跳 customer-detail管理者视角关心客户 */
onRecordTap(e: WechatMiniprogram.TouchEvent) {
const { memberId } = e.currentTarget.dataset
const mid = Number(memberId)
if (!mid || mid <= 0) {
wx.showToast({ title: '散客无详情可查看', icon: 'none' })
return
}
wx.navigateTo({
url: `/pages/customer-detail/customer-detail?id=${memberId}`,
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
})
},
switchMonth(e: WechatMiniprogram.TouchEvent) {
const direction = e.currentTarget.dataset.direction as 'prev' | 'next'
let { currentYear, currentMonth } = this.data
if (direction === 'prev') {
currentMonth--
if (currentMonth < 1) { currentMonth = 12; currentYear-- }
} else {
currentMonth++
if (currentMonth > 12) { currentMonth = 1; currentYear++ }
}
const now = new Date()
const nowYear = now.getFullYear()
const nowMonth = now.getMonth() + 1
const canGoNext = currentYear < nowYear || (currentYear === nowYear && currentMonth < nowMonth)
const isCurrentMonth = currentYear === nowYear && currentMonth === nowMonth && now.getDate() <= 5
this.setData({
currentYear,
currentMonth,
monthLabel: `${currentYear}${currentMonth}`,
canGoNext,
canGoPrev: true,
isCurrentMonth,
page: 1,
dateGroups: [],
})
this.loadData()
},
})

View File

@@ -0,0 +1,123 @@
<wxs src="../../utils/format.wxs" module="fmt" />
<!-- 错误态 -->
<view class="page-error" wx:if="{{pageState === 'error'}}">
<t-icon name="close-circle" size="120rpx" color="#e34d59" />
<text class="error-text">加载失败,请点击重试</text>
<view class="retry-btn" hover-class="retry-btn--hover" bindtap="onRetry">
<text>重试</text>
</view>
</view>
<!-- 主体内容 -->
<block wx:if="{{pageState !== 'error'}}">
<!-- Banner 区域(对齐 performance 页面) -->
<view class="banner-section">
<image class="banner-bg-img" src="/assets/images/banner-bg-coral-aurora.svg" mode="widthFix" />
<view class="banner-content">
<view class="user-info-section">
<view class="user-info-row">
<view class="avatar-wrap">
<image src="{{avatarUrl || '/assets/images/avatar-coach.png'}}" class="avatar-img" mode="aspectFill" />
</view>
<view class="user-detail">
<view class="user-name-row">
<text class="user-name">{{fmt.safe(coachName)}}</text>
<text class="user-role-tag">{{fmt.safe(coachRole)}}</text>
</view>
<view class="user-store-row">
<text class="user-store">{{fmt.safe(storeName)}}</text>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 月份切换 -->
<view class="month-switcher">
<view class="month-btn {{canGoPrev ? '' : 'month-btn-disabled'}}" hover-class="month-btn--hover" data-direction="prev" bindtap="switchMonth">
<t-icon name="chevron-left" size="32rpx" />
</view>
<text class="month-label">{{monthLabel}}</text>
<view class="month-btn {{canGoNext ? '' : 'month-btn-disabled'}}" hover-class="month-btn--hover" data-direction="next" bindtap="switchMonth">
<t-icon name="chevron-right" size="32rpx" />
</view>
</view>
<!-- 统计概览 -->
<view class="stats-overview">
<view class="stat-item">
<text class="stat-label">总记录</text>
<text class="stat-value">{{totalCountLabel}}</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-label">总业绩时长</text>
<text class="stat-value stat-primary">{{totalHoursLabel}}</text>
<text class="stat-hint" wx:if="{{isCurrentMonth}}">预估</text>
<text class="stat-hours-raw" wx:if="{{totalHoursRawLabel}}">折前 {{totalHoursRawLabel}}</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-label">{{isCurrentMonth ? '预估收入' : '收入'}}</text>
<text class="stat-value stat-success">{{totalIncomeLabel}}</text>
<text class="stat-hint" wx:if="{{isCurrentMonth}}">预估</text>
</view>
</view>
<!-- 空态 -->
<view class="page-empty" wx:if="{{pageState === 'empty'}}">
<t-icon name="chart-bar" size="120rpx" color="#dcdcdc" />
<text class="empty-text">暂无数据</text>
</view>
<!-- 记录列表(复用 performance 页面服务记录明细样式) -->
<view class="records-container" wx:elif="{{pageState === 'normal' || pageState === 'loading'}}">
<view class="records-card">
<block wx:for="{{dateGroups}}" wx:key="date">
<!-- 日期分隔线 -->
<view class="date-divider">
<text decode class="dd-date">{{fmt.safe(item.date)}}&nbsp;&nbsp;—</text>
<text decode class="dd-stats" wx:if="{{item.totalHours}}">{{fmt.safe(item.totalHours)}}小时&nbsp;·&nbsp;{{isCurrentMonth ? '预估 ' : ''}}¥{{fmt.safe(item.totalIncome)}}&nbsp;&nbsp;</text>
<view class="dd-line"></view>
</view>
<!-- 该日期下的记录(与 performance 页面卡片一致) -->
<view class="record-item" wx:for="{{item.records}}" wx:for-item="rec" wx:key="customerName"
hover-class="record-item--hover" bindtap="onRecordTap"
data-member-id="{{rec.memberId}}">
<view class="record-avatar avatar-{{rec.avatarColor}}">
<text>{{rec.avatarChar}}</text>
</view>
<view class="record-content">
<view class="record-top">
<view class="record-name-time">
<text class="record-name {{rec.isScattered ? 'record-name--scattered' : ''}}">{{fmt.safe(rec.customerName)}}</text>
<heart-icon score="{{rec.heartScore}}" size="small" />
<text class="record-time">{{fmt.safe(rec.timeRange)}}</text>
</view>
<text class="record-hours">{{fmt.safe(rec.hours)}}小时</text>
</view>
<view class="record-bottom">
<view class="record-tags">
<text class="course-tag course-tag--{{rec.courseTagClass}}">{{fmt.safe(rec.courseType)}}</text>
<text class="record-location">{{fmt.safe(rec.location)}}</text>
</view>
<text class="record-income">{{isCurrentMonth ? '助教预估收入' : '助教收入'}} <text class="record-income-val">¥{{fmt.safe(rec.income)}}</text></text>
</view>
</view>
</view>
</block>
<!-- 列表底部提示 -->
<view class="list-end-hint" wx:if="{{!hasMore && dateGroups.length > 0}}">
<text>— 已加载全部记录 —</text>
</view>
</view>
</view>
</block>
<!-- AI 悬浮按钮 -->
<ai-float-button />
<dev-fab />

View File

@@ -0,0 +1,400 @@
/* pages/performance-records/performance-records.wxss */
/* CHANGE 2026-03-27 | 联调改造Banner 对齐 performance卡片样式复用 performance 服务记录明细 */
page {
background-color: #f3f3f3;
line-height: 1.5;
}
view {
line-height: inherit;
}
/* ============================================
* 加载态 / 空态 / 错误态
* ============================================ */
.page-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx 0;
gap: 24rpx;
}
.empty-text {
font-size: 26rpx;
color: #a6a6a6;
}
.page-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 60vh;
gap: 24rpx;
}
.error-text {
font-size: 28rpx;
color: #a6a6a6;
}
.retry-btn {
padding: 16rpx 48rpx;
background: #0052d9;
color: #ffffff;
border-radius: 16rpx;
font-size: 28rpx;
}
.retry-btn--hover {
opacity: 0.7;
}
/* ============================================
* Banner对齐 performance 页面)
* ============================================ */
.banner-section {
position: relative;
width: 100%;
overflow: hidden;
}
.banner-bg-img {
position: absolute;
top: -50rpx;
left: 0;
width: 100%;
height: auto;
z-index: 0;
}
.banner-content {
position: relative;
z-index: 2;
padding: 40rpx;
display: flex;
flex-direction: column;
justify-content: center;
}
.user-info-section {
position: relative;
z-index: 2;
}
.user-info-row {
display: flex;
align-items: center;
gap: 29rpx;
}
.avatar-wrap {
width: 98rpx;
height: 98rpx;
border-radius: 24rpx;
background: rgba(255, 255, 255, 0.2);
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.15);
overflow: hidden;
flex-shrink: 0;
}
.avatar-img {
width: 100%;
height: 100%;
}
.user-detail {
flex: 1;
}
.user-name-row {
display: flex;
align-items: center;
gap: 15rpx;
margin-bottom: 7rpx;
line-height: 51rpx;
}
.user-name {
font-size: 32rpx;
font-weight: 600;
color: #ffffff;
}
.user-role-tag {
font-size: 22rpx;
line-height: 29rpx;
padding: 4rpx 15rpx;
background: rgba(255, 255, 255, 0.2);
border-radius: 9999rpx;
color: #ffffff;
}
.user-store-row {
line-height: 36rpx;
}
.user-store {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.7);
}
/* ============================================
* 月份切换
* ============================================ */
.month-switcher {
display: flex;
align-items: center;
justify-content: center;
gap: 48rpx;
padding: 24rpx 32rpx;
background: #ffffff;
border-bottom: 2rpx solid #eeeeee;
}
.month-btn {
padding: 12rpx;
border-radius: 50%;
}
.month-btn-disabled {
opacity: 0.3;
pointer-events: none;
}
.month-btn--hover {
opacity: 0.6;
}
.month-label {
font-size: 28rpx;
font-weight: 600;
color: #242424;
}
/* ============================================
* 统计概览
* ============================================ */
.stats-overview {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 24rpx 32rpx;
background: #ffffff;
border-bottom: 2rpx solid #eeeeee;
}
.stat-item {
flex: 1;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
}
.stat-label {
font-size: 20rpx;
color: #a6a6a6;
margin-bottom: 4rpx;
}
.stat-value {
font-size: 36rpx;
font-weight: 700;
color: #242424;
font-variant-numeric: tabular-nums;
}
.stat-primary { color: #0052d9; }
.stat-success { color: #00a870; }
.stat-hours-raw {
font-size: 20rpx;
color: #a6a6a6;
margin-top: 2rpx;
line-height: 26rpx;
}
.stat-hint {
font-size: 20rpx;
color: #ed7b2f;
margin-top: 2rpx;
}
.stat-divider {
width: 2rpx;
height: 80rpx;
background: #eeeeee;
margin-top: 4rpx;
}
/* ============================================
* 记录列表(复用 performance 页面服务记录明细样式)
* ============================================ */
.records-container {
padding: 24rpx;
padding-bottom: 40rpx;
}
.records-card {
background: #ffffff;
border-radius: 32rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
overflow: hidden;
padding: 0 32rpx;
}
.date-divider {
display: flex;
align-items: center;
gap: 16rpx;
padding: 20rpx 0 8rpx;
}
.dd-date {
font-size: 22rpx;
color: #8b8b8b;
font-weight: 500;
white-space: nowrap;
line-height: 29rpx;
}
.dd-line {
flex: 1;
height: 2rpx;
background: #dcdcdc;
}
.dd-stats {
font-size: 22rpx;
color: #a6a6a6;
font-variant-numeric: tabular-nums;
white-space: nowrap;
line-height: 29rpx;
}
.record-item {
display: flex;
align-items: center;
gap: 20rpx;
padding: 16rpx 0;
}
.record-item--hover {
opacity: 0.7;
}
/* 头像(渐变色由 app.wxss 全局 .avatar-{key} 提供) */
.record-avatar {
width: 76rpx;
height: 76rpx;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 30rpx;
font-weight: 500;
flex-shrink: 0;
}
.record-content {
flex: 1;
min-width: 0;
}
.record-top {
display: flex;
align-items: center;
justify-content: space-between;
}
.record-name-time {
display: flex;
align-items: center;
gap: 12rpx;
min-width: 0;
}
.record-name {
font-size: 26rpx;
font-weight: 500;
color: #242424;
flex-shrink: 0;
line-height: 36rpx;
}
/* 散客名称置灰 */
.record-name--scattered {
color: #999;
font-weight: 400;
}
.record-time {
font-size: 22rpx;
color: #a6a6a6;
line-height: 29rpx;
}
.record-hours {
font-size: 26rpx;
font-weight: 700;
color: #059669;
font-variant-numeric: tabular-nums;
flex-shrink: 0;
line-height: 36rpx;
}
.record-bottom {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 8rpx;
}
.record-tags {
display: flex;
align-items: center;
gap: 12rpx;
}
.course-tag {
padding: 2rpx 12rpx;
border-radius: 8rpx;
font-size: 22rpx;
font-weight: 500;
line-height: 29rpx;
}
.course-tag--basic { background: #ecfdf5; color: #15803d; }
.course-tag--room { background: #eff6ff; color: #1d4ed8; }
.course-tag--incentive { background: #fffbeb; color: #a16207; }
.record-location {
font-size: 22rpx;
color: #8b8b8b;
line-height: 29rpx;
}
.record-income {
font-size: 22rpx;
color: #c5c5c5;
flex-shrink: 0;
line-height: 29rpx;
}
.record-income-val {
font-weight: 500;
color: #5e5e5e;
}
/* 列表底部提示 */
.list-end-hint {
text-align: center;
padding: 24rpx 0 28rpx;
font-size: 22rpx;
color: #c5c5c5;
}

View File

@@ -27,6 +27,7 @@ interface ConsumptionRecord {
}>
foodAmount?: number
foodOrigPrice?: number
foodDetail?: string
totalAmount?: number
totalOrigPrice?: number
payMethod?: string
@@ -46,6 +47,7 @@ Page({
name: '',
avatarChar: '',
phone: '',
phoneFull: '',
balance: null as number | null,
consumption60d: null as number | null,
idealInterval: null as number | null,
@@ -109,6 +111,7 @@ Page({
name: d.name || '',
avatarChar: (d.name || '')[0] || '',
phone: d.phone || '',
phoneFull: d.phoneFull || '',
balance: d.balance ?? null,
// CHANGE 2026-03-29 | Pydantic 把 consumption_60d → consumption60D大写 D
consumption60d: d.consumption60D ?? d.consumption60d ?? null,
@@ -147,9 +150,9 @@ Page({
this.setData({ phoneVisible: !this.data.phoneVisible })
},
/** 复制手机号 */
/** 复制手机号(复制完整号码) */
onCopyPhone() {
const phone = this.data.detail.phone
const phone = this.data.detail.phoneFull || this.data.detail.phone
wx.setClipboardData({
data: phone,
success: () => {

View File

@@ -27,7 +27,7 @@
<text class="customer-name">{{detail.name}}</text>
</view>
<view class="sub-info">
<text class="phone">{{phoneVisible ? detail.phone : '138****5678'}}</text>
<text class="phone">{{phoneVisible ? detail.phoneFull : detail.phone}}</text>
<view class="phone-toggle-btn" bindtap="{{phoneVisible ? 'onCopyPhone' : 'onTogglePhone'}}" hover-class="phone-toggle-btn--hover">
<text class="phone-toggle-text">{{phoneVisible ? '复制' : '查看'}}</text>
</view>
@@ -221,7 +221,7 @@
</view>
</view>
<view class="record-food-row" wx:if="{{item.foodAmount > 0}}">
<text class="record-food-label">🍷 食品酒水</text>
<text class="record-food-label">🍷 {{item.foodDetail || '食品酒水'}}</text>
<view class="record-food-right">
<text class="record-food-amount">{{fmt.money(item.foodAmount)}}</text>
<text class="record-fee-orig" wx:if="{{item.foodOrigPrice}}">{{fmt.money(item.foodOrigPrice)}}</text>
@@ -261,7 +261,7 @@
</view>
</view>
<view class="record-food-row" wx:if="{{item.foodAmount > 0}}">
<text class="record-food-label">🍷 食品酒水</text>
<text class="record-food-label">🍷 {{item.foodDetail || '食品酒水'}}</text>
<text class="record-food-amount">{{fmt.money(item.foodAmount)}}</text>
</view>
<view class="record-total-row" wx:if="{{item.totalAmount}}">
@@ -292,7 +292,7 @@
<view class="note-list" wx:if="{{sortedNotes.length > 0}}">
<view class="note-item" wx:for="{{sortedNotes}}" wx:key="id">
<view class="note-top">
<text class="note-author">{{fmt.safe(item.tagLabel)}}</text>
<text class="note-author">{{item.creatorName || item.tagLabel}}{{item.creatorRole ? ' · ' + item.creatorRole : ''}}</text>
<view class="note-top-right">
<text class="note-time">{{fmt.safe(item.createdAt)}}</text>
<view class="note-delete-btn" data-id="{{item.id}}" bindtap="onDeleteNote" hover-class="note-delete-btn--hover">

View File

@@ -857,11 +857,20 @@ view {
justify-content: space-between;
padding: 16rpx 24rpx;
border-top: 2rpx solid var(--border-light);
gap: 16rpx;
}
.record-food-label {
font-size: 24rpx;
color: var(--text-secondary);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
word-break: break-all;
}
.record-food-right {

View File

@@ -131,7 +131,7 @@
</view>
</view>
<view class="record-food-row" wx:if="{{item.foodAmount > 0}}">
<text class="record-food-label">🍷 食品酒水</text>
<text class="record-food-label">🍷 {{item.foodDetail || '食品酒水'}}</text>
<view class="record-food-right">
<text class="record-food-amount">{{fmt.money(item.foodAmount)}}</text>
<text class="record-fee-orig" wx:if="{{item.foodOrigPrice}}">{{fmt.money(item.foodOrigPrice)}}</text>
@@ -156,7 +156,7 @@
<text class="record-date">{{fmt.safe(item.date)}}</text>
</view>
<view class="record-food-row" wx:if="{{item.foodAmount > 0}}">
<text class="record-food-label">🍷 食品酒水</text>
<text class="record-food-label">🍷 {{item.foodDetail || '食品酒水'}}</text>
<text class="record-food-amount">{{fmt.money(item.foodAmount)}}</text>
</view>
<view class="record-total-row" wx:if="{{item.totalAmount}}">

View File

@@ -288,8 +288,15 @@ page {
justify-content: space-between;
padding: 16rpx 24rpx;
border-top: 2rpx solid var(--border-light, #f0f0f0);
gap: 16rpx;
}
.record-food-label {
font-size: 24rpx;
color: var(--text-secondary, #666);
flex: 1;
min-width: 0;
word-break: break-all;
}
.record-food-label { font-size: 24rpx; color: var(--text-secondary, #666); }
.record-food-right { display: flex; align-items: baseline; gap: 8rpx; }
.record-food-amount {
font-size: 28rpx;

View File

@@ -3,6 +3,7 @@
|------|--------|------|
| 2026-03-23 | 角色路由+页面权限守卫 | onShow 添加 checkPageAccess 权限守卫 |
| 2026-03-27 | 联调改造 | 重写Banner 对齐 performance 页面,数据对接后端 PERF-2卡片样式复用 performance 服务记录明细 |
| 2026-04-20 | 拆分助教视角 | 删除 coachId 分支,本页恢复"任务 tab 助教自查"单一职责;管理者视角迁至 pages/coach-service-records/ |
*/
import { checkPageAccess } from '../../utils/auth-guard'
import { fetchPerformanceRecords, fetchMe } from '../../services/api'

View File

@@ -92,7 +92,7 @@
<view class="record-content">
<view class="record-top">
<view class="record-name-time">
<text class="record-name">{{fmt.safe(rec.customerName)}}</text>
<text class="record-name {{rec.isScattered ? 'record-name--scattered' : ''}}">{{fmt.safe(rec.customerName)}}</text>
<heart-icon score="{{rec.heartScore}}" size="small" />
<text class="record-time">{{fmt.safe(rec.timeRange)}}</text>
</view>

View File

@@ -138,8 +138,8 @@ view {
}
.user-store {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.75);
font-size: 26rpx;
color: rgba(255, 255, 255, 0.7);
}
/* ============================================
@@ -327,6 +327,12 @@ view {
line-height: 36rpx;
}
/* 散客名称置灰 */
.record-name--scattered {
color: #999;
font-weight: 400;
}
.record-time {
font-size: 22rpx;
color: #a6a6a6;

View File

@@ -22,62 +22,15 @@ import { formatDeadline } from '../../utils/time'
import { formatStorageLevel } from '../../utils/storage-level'
// CHANGE 2026-03-27 | 头像:需要 API_BASE 构建头像完整 URL
import { API_BASE } from '../../utils/config'
import {
SPARK_DELAY_MS, SPARK_DUR_MS, NEXT_LOOP_DELAY_MS,
calcShineDur, buildTicks, buildProgressBarData,
type TickItem,
} from '../../utils/perf-progress'
/** CHANGE 2026-03-14 | 所有任务类型统一跳转到 task-detail由详情页根据 taskId 动态展示内容 */
const DETAIL_ROUTE = '/pages/task-detail/task-detail'
/* ╔══════════════════════════════════════════════════════╗
* ║ 进度条动画参数 — 在此调节 ║
* ╚══════════════════════════════════════════════════════╝
*
* 动画由 JS 状态机驱动,每轮循环重新读取实际进度条宽度:
*
* ┌─────────────┐ SPARK_DELAY_MS ┌─────────────┐ NEXT_LOOP_DELAY_MS ┌─────────────┐
* │ 高光匀速扫过 │ ───────────────▶ │ 火花迸发 │ ──────────────────▶ │ 下一轮 │
* │ 时长由速度决定│ │ SPARK_DUR_MS│ │(重新读进度) │
* └─────────────┘ └─────────────┘ └─────────────┘
*
* SHINE_SPEED : 高光移动速度,范围 1~100
* 1 = 最慢最宽进度条100%)下 5 秒走完
* 100 = 最快最宽进度条100%)下 0.05 秒走完
* 实际时长 = 基准时长 × (filledPct/100)
* 基准时长 = 5s - (SHINE_SPEED-1)/(100-1) × (5-0.05)s
*
* SPARK_DELAY_MS : 高光到达最右端后,光柱点亮+火花迸发的延迟(毫秒)
* 正数 = 高光结束后停顿再点亮
* 负数 = 高光还未结束,提前点亮(高光末尾与火花重叠)
*
* SPARK_DUR_MS : 火花迸发到完全消散的时长(毫秒)
*
* NEXT_LOOP_DELAY_MS: 火花消散后到下一轮高光启动的延迟(毫秒)
* 正数 = 停顿一段时间
* 负数 = 火花还未消散完,高光已从左端启动
*/
const SHINE_SPEED = 70 // 1~100速度值
const SPARK_DELAY_MS = -200 // 毫秒,高光结束→光柱点亮+火花(负=提前)
const SPARK_DUR_MS = 1400 // 毫秒,火花持续时长
const NEXT_LOOP_DELAY_MS = 400 // 毫秒,火花结束→下轮高光(负=提前)
/* 根据速度值和进度百分比计算高光时长
* 高光宽度固定SHINE_WIDTH_RPX需要走过的距离 = 填充条宽度 + 高光宽度
* 轨道宽度约 634rpx750 - 左右padding各58rpx高光宽度约占轨道 19%
* 时长正比于需要走过的总距离,保证视觉速度恒定
*
* 速度1 → baseDur=5000ms最慢速度100 → baseDur=50ms最快
* shineDurMs = baseDur × (filledPct + SHINE_WIDTH_PCT) / (100 + SHINE_WIDTH_PCT)
*/
const SHINE_WIDTH_RPX = 120 // rpx需与 WXSS 的 --shine-width 保持一致
const TRACK_WIDTH_RPX = 634 // rpx进度条轨道宽度750 - padding 116rpx
const SHINE_WIDTH_PCT = (SHINE_WIDTH_RPX / TRACK_WIDTH_RPX) * 100 // ≈19%
function calcShineDur(filledPct: number): number {
const t = (SHINE_SPEED - 1) / 99 // 0(最慢) ~ 1(最快)
const baseDur = 5000 - t * (5000 - 50) // ms走完100%进度条所需时长
// 实际距离 = 填充条 + 高光自身,相对于(100% + 高光宽度%)归一化
const distRatio = (filledPct + SHINE_WIDTH_PCT) / (100 + SHINE_WIDTH_PCT)
return Math.max(50, Math.round(baseDur * distRatio))
}
/** 扩展任务字段 */
interface EnrichedTask extends Task {
lastVisitDays: number
@@ -94,24 +47,6 @@ interface EnrichedTask extends Task {
recent60dIncome: number
}
/** 刻度项 */
interface TickItem {
value: number // 刻度数值(如 100
label: string // 显示文字(如 '100'
left: string // 百分比位置字符串(如 '45.45%'),首尾项忽略此字段
highlight: boolean // 是否加粗高亮
}
/** Mock: 根据档位节点数组生成刻度数据 */
function buildTicks(tierNodes: number[], maxHours: number): TickItem[] {
return tierNodes.map((v, i) => ({
value: v,
label: String(v),
left: `${Math.round((v / maxHours) * 10000) / 100}%`,
highlight: i === 2, // 第3个档位如130h高亮可由接口控制
}))
}
/** P0: 业绩进度卡片数据 */
interface PerfData {
nextTierHours: number
@@ -414,7 +349,6 @@ Page({
const totalHours = perf.totalHours ?? perf.total_hours ?? 0
const basicHours = perf.basicHours ?? perf.basic_hours ?? 0
const bonusHours = perf.bonusHours ?? perf.bonus_hours ?? 0
const currentTier = perf.currentTier ?? perf.current_tier ?? 0
const nextTierHours = perf.nextTierHours ?? perf.next_tier_hours ?? 0
const tierCompleted = perf.tierCompleted ?? perf.tier_completed ?? false
const bonusMoney = perf.bonusMoney ?? perf.bonus_money ?? 0
@@ -423,19 +357,16 @@ Page({
const totalIncome = perf.totalIncome ?? perf.total_income ?? 0
const incomeTrend = perf.incomeTrend ?? perf.income_trend ?? ''
const incomeTrendDir = perf.incomeTrendDir ?? perf.income_trend_dir ?? 'up'
const tierNodes: number[] = perf.tierNodes ?? perf.tier_nodes ?? [0, 100, 130, 160, 190, 220]
// 计算进度条百分比(基于最大档位)
const maxHours = tierNodes.length > 0 ? tierNodes[tierNodes.length - 1] : 220
const filledPct = maxHours > 0 ? Math.min(100, Math.round((totalHours / maxHours) * 1000) / 10) : 0
const remainHours = Math.max(0, nextTierHours - totalHours)
// 统一使用共用模块计算进度条数据
const pbData = buildProgressBarData(perf)
perfData.totalHours = totalHours
perfData.basicHours = basicHours
perfData.bonusHours = bonusHours
perfData.currentTier = currentTier
perfData.currentTier = pbData.currentTier
perfData.nextTierHours = nextTierHours
perfData.remainHours = remainHours
perfData.remainHours = pbData.remainHours
perfData.tierCompleted = tierCompleted
perfData.bonusMoney = bonusMoney
perfData.incomeMonth = monthLabel
@@ -443,21 +374,15 @@ Page({
perfData.incomeFormatted = formatMoney(totalIncome)
perfData.incomeTrend = incomeTrend
perfData.incomeTrendDir = incomeTrendDir === 'down' ? 'down' : 'up'
// 从 "7373" / "368" 中提取纯数字,用于千分位格式化
// 从 "^7373" / "v368" 中提取纯数字,用于千分位格式化
const trendNumMatch = incomeTrend.replace(/[^0-9.]/g, '')
perfData.incomeTrendValue = trendNumMatch ? parseFloat(trendNumMatch) : null
perfData.filledPct = filledPct
perfData.clampedSparkPct = Math.max(0, Math.min(100, filledPct))
perfData.ticks = buildTicks(tierNodes, maxHours)
perfData.shineDurMs = calcShineDur(filledPct)
perfData.sparkDurMs = SPARK_DUR_MS
// 计算段内进度
const segStart = tierNodes[currentTier] ?? 0
const segEnd = tierNodes[currentTier + 1] ?? maxHours
perfData.tierProgress = segEnd > segStart
? Math.min(100, Math.round(((totalHours - segStart) / (segEnd - segStart)) * 100))
: 100
perfData.filledPct = pbData.filledPct
perfData.clampedSparkPct = pbData.clampedSparkPct
perfData.ticks = pbData.ticks
perfData.shineDurMs = pbData.shineDurMs
perfData.sparkDurMs = pbData.sparkDurMs
perfData.tierProgress = pbData.tierProgress
}
// G2: 当月预估判断

View File

@@ -169,7 +169,7 @@
<text class="expected-tag expected-tag--overdue" wx:if="{{item.expectedDays != null && item.expectedDays < 0 && item.taskType !== 'relationship_building' && item.taskType !== 'follow_up_visit'}}">逾期{{-item.expectedDays}}天</text>
</view>
<view class="card-row-2">
<text class="visit-text">到店:{{fmt.days(item.lastVisitDays)}} · 储值 {{fmt.safe(item.balanceLabel)}} · 近60天 {{fmt.hoursH(item.recent60dHours)}} | {{fmt.money(item.recent60dIncome)}}</text>
<text class="visit-text">到店:{{fmt.daysAgo(item.lastVisitDays)}} · 储值 {{fmt.safe(item.balanceLabel)}} · 近60天 {{fmt.hoursH(item.recent60dHours)}} | {{fmt.money(item.recent60dIncome)}}</text>
</view>
<view class="card-row-deadline" wx:if="{{item.deadlineLabel && item.deadlineLabel !== '--' && item.deadlineStyle !== 'danger'}}">
<text class="deadline-text deadline-text--{{item.deadlineStyle}}">{{item.deadlineLabel}}</text>
@@ -213,7 +213,7 @@
<text class="expected-tag expected-tag--overdue" wx:if="{{item.expectedDays != null && item.expectedDays < 0 && item.taskType !== 'relationship_building' && item.taskType !== 'follow_up_visit'}}">逾期{{-item.expectedDays}}天</text>
</view>
<view class="card-row-2">
<text class="visit-text">到店:{{fmt.days(item.lastVisitDays)}} · 储值 {{fmt.safe(item.balanceLabel)}} · 近60天 {{fmt.hoursH(item.recent60dHours)}} | {{fmt.money(item.recent60dIncome)}}</text>
<text class="visit-text">到店:{{fmt.daysAgo(item.lastVisitDays)}} · 储值 {{fmt.safe(item.balanceLabel)}} · 近60天 {{fmt.hoursH(item.recent60dHours)}} | {{fmt.money(item.recent60dIncome)}}</text>
</view>
<view class="card-row-deadline" wx:if="{{item.deadlineLabel && item.deadlineLabel !== '--' && item.deadlineStyle !== 'danger'}}">
<text class="deadline-text deadline-text--{{item.deadlineStyle}}">{{item.deadlineLabel}}</text>
@@ -255,7 +255,7 @@
<heart-icon score="{{item.heartScore}}" size="small" />
</view>
<view class="card-row-2">
<text class="visit-text visit-text--abandoned">到店:{{fmt.days(item.lastVisitDays)}} · 储值 {{fmt.safe(item.balanceLabel)}} · 近60天 {{fmt.hoursH(item.recent60dHours)}} | {{fmt.money(item.recent60dIncome)}}</text>
<text class="visit-text visit-text--abandoned">到店:{{fmt.daysAgo(item.lastVisitDays)}} · 储值 {{fmt.safe(item.balanceLabel)}} · 近60天 {{fmt.hoursH(item.recent60dHours)}} | {{fmt.money(item.recent60dIncome)}}</text>
</view>
<view class="card-row-abandon" wx:if="{{item.abandonReason}}">
<text class="abandon-reason">放弃原因:{{fmt.safe(item.abandonReason)}}</text>

View File

@@ -179,6 +179,8 @@ export async function fetchPerformanceRecords(params: {
month: number
page?: number
pageSize?: number
/** 目标助教 ID从 coach-detail 跳入时传入,要求调用者具备 view_board_coach 权限 */
coachId?: number
}): Promise<{
summary: { totalCount: number; totalHours: number; totalHoursRaw: number; totalIncome: number }
dateGroups: Array<{
@@ -198,10 +200,20 @@ export async function fetchPerformanceRecords(params: {
}>
hasMore: boolean
}> {
// 后端 FastAPI Query 参数为 snake_case前端 camelCase 需手动映射
const query: Record<string, any> = {
year: params.year,
month: params.month,
page: params.page ?? 1,
page_size: params.pageSize ?? 20,
}
if (params.coachId !== undefined && params.coachId !== null) {
query.coach_id = params.coachId
}
return request({
url: '/api/xcx/performance/records',
method: 'GET',
data: params,
data: query,
needAuth: true,
})
}
@@ -317,6 +329,20 @@ export async function fetchBoardFinance(params: {
// 助教模块
// ============================================
/** 助教 banner 轻量信息(仅 name/level/storeName— 比 fetchCoachDetail 快一个数量级 */
export async function fetchCoachBanner(coachId: string): Promise<{
id: number
name: string
level: string
storeName: string
} | null> {
return request({
url: `/api/xcx/coaches/${coachId}/banner`,
method: 'GET',
needAuth: true,
})
}
/** 助教详情 */
export async function fetchCoachDetail(coachId: string): Promise<CoachCard | null> {
return request({

View File

@@ -155,6 +155,16 @@ function days(value) {
return value + '天'
}
/**
* 距今天数格式化WXS 版)
* "今天" / "3天前" / "--"
*/
function daysAgo(value) {
if (value === undefined || value === null) return '--'
if (value === 0) return '今天'
return value + '天前'
}
/**
* 储值等级格式化WXS 版)
* 无/少/一般/多/非常多
@@ -219,6 +229,7 @@ module.exports = {
thousands: thousands,
trendValue: trendValue,
days: days,
daysAgo: daysAgo,
storageLevel: storageLevel,
maskPhone: maskPhone,
negativeMoney: negativeMoney,

View File

@@ -0,0 +1,124 @@
/**
* 绩效进度条共用模块 -- task-list / coach-detail 统一使用
*
* 包含:动画参数、刻度计算、高光时长计算、类型定义
*/
/* ======================================================
* 进度条动画参数 -- 在此调节
* ======================================================
*
* 动画由 JS 状态机驱动,每轮循环重新读取实际进度条宽度:
*
* +-------------+ SPARK_DELAY_MS +-------------+ NEXT_LOOP_DELAY_MS +-------------+
* | 高光匀速扫过 | --------------> | 火花迸发 | ------------------> | 下一轮 |
* | 时长由速度决定| | SPARK_DUR_MS| |(重新读进度) |
* +-------------+ +-------------+ +-------------+
*
* SHINE_SPEED : 高光移动速度,范围 1~100
* 1 = 最慢,最宽进度条(100%)下 5 秒走完
* 100 = 最快,最宽进度条(100%)下 0.05 秒走完
* 实际时长 = 基准时长 x (filledPct/100)
* 基准时长 = 5s - (SHINE_SPEED-1)/(100-1) x (5-0.05)s
*
* SPARK_DELAY_MS : 高光到达最右端后,光柱点亮+火花迸发的延迟(毫秒)
* 正数 = 高光结束后停顿再点亮
* 负数 = 高光还未结束,提前点亮(高光末尾与火花重叠)
*
* SPARK_DUR_MS : 火花迸发到完全消散的时长(毫秒)
*
* NEXT_LOOP_DELAY_MS: 火花消散后到下一轮高光启动的延迟(毫秒)
* 正数 = 停顿一段时间
* 负数 = 火花还未消散完,高光已从左端启动
*
* 修改说明见 apps/miniprogram/doc/progress-bar-animation.md
*/
export const SHINE_SPEED = 70 // 1~100速度值
export const SPARK_DELAY_MS = -200 // 毫秒,高光结束->光柱点亮+火花(负=提前)
export const SPARK_DUR_MS = 1400 // 毫秒,火花持续时长
export const NEXT_LOOP_DELAY_MS = 400 // 毫秒,火花结束->下轮高光(负=提前)
/* 高光宽度固定(SHINE_WIDTH_RPX),需要走过的距离 = 填充条宽度 + 高光宽度
* 轨道宽度约 634rpx(750 - 左右padding各58rpx),高光宽度约占轨道 19%
* 时长正比于需要走过的总距离,保证视觉速度恒定
*
* 速度1 -> baseDur=5000ms(最慢)速度100 -> baseDur=50ms(最快)
* shineDurMs = baseDur x (filledPct + SHINE_WIDTH_PCT) / (100 + SHINE_WIDTH_PCT)
*/
export const SHINE_WIDTH_RPX = 120 // rpx需与 WXSS 的 --shine-width 保持一致
export const TRACK_WIDTH_RPX = 634 // rpx进度条轨道宽度(750 - padding 116rpx)
export const SHINE_WIDTH_PCT = (SHINE_WIDTH_RPX / TRACK_WIDTH_RPX) * 100 // ~19%
// ── 类型定义 ────────────────────────────────────────────────
/** 刻度项(传给 perf-progress-bar 组件的 ticks 属性) */
export interface TickItem {
value: number // 刻度数值(如 100
label: string // 显示文字(如 '100'
left: string // 百分比位置字符串(如 '45.45%'),首尾项忽略此字段
highlight: boolean // 是否加粗高亮
}
// ── 工具函数 ────────────────────────────────────────────────
/**
* 根据速度值和进度百分比计算高光扫过时长。
*
* 速度 1 -> baseDur=5000ms最慢速度 100 -> baseDur=50ms最快
* 实际时长 = baseDur x (filledPct + SHINE_WIDTH_PCT) / (100 + SHINE_WIDTH_PCT)
*/
export function calcShineDur(filledPct: number): number {
const t = (SHINE_SPEED - 1) / 99 // 0(最慢) ~ 1(最快)
const baseDur = 5000 - t * (5000 - 50)
const distRatio = (filledPct + SHINE_WIDTH_PCT) / (100 + SHINE_WIDTH_PCT)
return Math.max(50, Math.round(baseDur * distRatio))
}
/**
* 根据档位节点数组生成刻度数据(供 perf-progress-bar 组件渲染)。
*/
export function buildTicks(tierNodes: number[], maxHours: number): TickItem[] {
return tierNodes.map((v, i) => ({
value: v,
label: String(v),
left: `${Math.round((v / maxHours) * 10000) / 100}%`,
highlight: false,
}))
}
/**
* 从后端返回的 performance 对象中计算进度条所需的全部数据。
*
* 两个页面统一调用此函数,确保进度百分比、当前档位、刻度等计算逻辑一致。
*/
export function buildProgressBarData(perf: Record<string, any>) {
// 后端返回 snake_caseCamelModel 可能已转 camelCase兼容两种命名
const totalHours = perf.totalHours ?? perf.total_hours ?? 0
const tierNodes: number[] = perf.tierNodes ?? perf.tier_nodes ?? [0]
const currentTier = perf.currentTier ?? perf.current_tier ?? 0
const nextTierHours = perf.nextTierHours ?? perf.next_tier_hours ?? 0
const maxHours = tierNodes.length > 0 ? tierNodes[tierNodes.length - 1] : 0
const filledPct = maxHours > 0 ? Math.min(100, Math.round((totalHours / maxHours) * 1000) / 10) : 0
const clampedSparkPct = Math.max(0, Math.min(100, filledPct))
const remainHours = Math.max(0, nextTierHours - totalHours)
const ticks = buildTicks(tierNodes, maxHours)
// 段内进度(当前档位内的百分比)
const segStart = tierNodes[currentTier] ?? 0
const segEnd = tierNodes[currentTier + 1] ?? maxHours
const tierProgress = segEnd > segStart
? Math.min(100, Math.round(((totalHours - segStart) / (segEnd - segStart)) * 100))
: 100
return {
filledPct,
clampedSparkPct,
currentTier,
ticks,
remainHours,
tierProgress,
shineDurMs: calcShineDur(filledPct),
sparkDurMs: SPARK_DUR_MS,
}
}

View File

@@ -53,12 +53,12 @@
"packOptions": {
"ignore": [
{
"type": "glob",
"value": "miniprogram/pages/task-detail-callback/**"
"value": "miniprogram/pages/task-detail-callback/**",
"type": "glob"
},
{
"type": "glob",
"value": "miniprogram/pages/task-detail-relationship/**"
"value": "miniprogram/pages/task-detail-relationship/**",
"type": "glob"
}
],
"include": []

View File

@@ -35,7 +35,7 @@
}
},
"mini-ios": {
"sdkVersion": "1.6.28",
"sdkVersion": "1.7.0",
"toolkitVersion": "0.0.9",
"useExtendedSdk": {
"WeAppOpenFuns": true,
@@ -65,4 +65,4 @@
},
"enableOpenUrlNavigate": true
}
}
}

View File

@@ -1,6 +1,6 @@
{
"description": "项目私有配置文件。此文件中的内容将覆盖 project.config.json 中的相同字段。项目的改动优先同步到此文件中。详见文档https://developers.weixin.qq.com/miniprogram/dev/devtools/projectconfig.html",
"projectname": "NeoZQYY",
"projectname": "miniprogram",
"setting": {
"compileHotReLoad": true,
"urlCheck": false,

View File

@@ -0,0 +1,32 @@
$ErrorActionPreference = "Stop"
function Write-NoBomFile {
param(
[string]$Path
)
if (!(Test-Path $Path)) {
Write-Host "SKIP: $Path (not found)"
return
}
$fullPath = (Resolve-Path $Path).Path
Write-Host "PROCESS: $fullPath"
$content = [System.IO.File]::ReadAllText($fullPath)
# 去掉开头可能混入的 BOM / 零宽字符
$content = $content.TrimStart([char]0xFEFF, [char]0x200B, [char]0x0000)
# 强制写成 UTF-8 without BOM
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($fullPath, $content, $utf8NoBom)
Write-Host " Rewritten as UTF-8 without BOM."
}
Write-NoBomFile ".\miniprogram\miniprogram_npm\tdesign-miniprogram\icon\icon.wxss"
Write-NoBomFile ".\node_modules\tdesign-miniprogram\miniprogram_dist\icon\icon.wxss"
Write-Host ""
Write-Host "DONE"

File diff suppressed because one or more lines are too long

View File

@@ -41,7 +41,7 @@ db/
| `etl_feiqiu/schemas/meta.sql` | etl_feiqiu | meta | 3 表 |
| `etl_feiqiu/schemas/ods.sql` | etl_feiqiu | ods | 23 表 |
| `etl_feiqiu/schemas/dwd.sql` | etl_feiqiu | dwd | 42 表 |
| `etl_feiqiu/schemas/core.sql` | etl_feiqiu | core | 7 表 |
| `etl_feiqiu/schemas/core.sql` | etl_feiqiu | core | 7 表(跨平台统一维度层,由 CORE_DIM_SYNC 任务从 DWD 同步) |
| `etl_feiqiu/schemas/dws.sql` | etl_feiqiu | dws | 38 表 |
| `etl_feiqiu/schemas/app.sql` | etl_feiqiu | app | 仅视图 |
| `zqyy_app/schemas/public.sql` | zqyy_app | public | 12 表 |

View File

@@ -0,0 +1,213 @@
-- 迁移:为 3 张指数表添加 stat_date 列,改为日快照模式
-- 目的:支持历史指数回溯和任务推演
-- 回滚:见文件末尾
BEGIN;
-- ============================================================
-- 1a. dws.dws_member_assistant_relation_index
-- ============================================================
ALTER TABLE dws.dws_member_assistant_relation_index
ADD COLUMN stat_date DATE;
UPDATE dws.dws_member_assistant_relation_index
SET stat_date = (calc_time AT TIME ZONE 'Asia/Shanghai')::date;
ALTER TABLE dws.dws_member_assistant_relation_index
ALTER COLUMN stat_date SET NOT NULL,
ALTER COLUMN stat_date SET DEFAULT CURRENT_DATE;
-- 改唯一约束:(site_id, member_id, assistant_id) → (site_id, member_id, assistant_id, stat_date)
ALTER TABLE dws.dws_member_assistant_relation_index
DROP CONSTRAINT uk_dws_member_assistant_relation_index;
ALTER TABLE dws.dws_member_assistant_relation_index
ADD CONSTRAINT uk_dws_member_assistant_relation_index
UNIQUE (site_id, member_id, assistant_id, stat_date);
-- 索引优化:去掉 calc_time 索引,加 stat_date 索引
DROP INDEX IF EXISTS dws.idx_dws_relation_calc_time;
CREATE INDEX idx_dws_relation_stat_date
ON dws.dws_member_assistant_relation_index (site_id, stat_date);
-- ============================================================
-- 1b. dws.dws_member_winback_index
-- ============================================================
ALTER TABLE dws.dws_member_winback_index
ADD COLUMN stat_date DATE;
UPDATE dws.dws_member_winback_index
SET stat_date = (calc_time AT TIME ZONE 'Asia/Shanghai')::date;
ALTER TABLE dws.dws_member_winback_index
ALTER COLUMN stat_date SET NOT NULL,
ALTER COLUMN stat_date SET DEFAULT CURRENT_DATE;
-- 改唯一约束:(site_id, member_id) → (site_id, member_id, stat_date)
ALTER TABLE dws.dws_member_winback_index
DROP CONSTRAINT uk_dws_member_winback;
ALTER TABLE dws.dws_member_winback_index
ADD CONSTRAINT uk_dws_member_winback
UNIQUE (site_id, member_id, stat_date);
CREATE INDEX idx_dws_winback_stat_date
ON dws.dws_member_winback_index (site_id, stat_date);
-- ============================================================
-- 1c. dws.dws_member_newconv_index
-- ============================================================
ALTER TABLE dws.dws_member_newconv_index
ADD COLUMN stat_date DATE;
UPDATE dws.dws_member_newconv_index
SET stat_date = (calc_time AT TIME ZONE 'Asia/Shanghai')::date;
ALTER TABLE dws.dws_member_newconv_index
ALTER COLUMN stat_date SET NOT NULL,
ALTER COLUMN stat_date SET DEFAULT CURRENT_DATE;
-- 改唯一约束:(site_id, member_id) → (site_id, member_id, stat_date)
ALTER TABLE dws.dws_member_newconv_index
DROP CONSTRAINT uk_dws_member_newconv;
ALTER TABLE dws.dws_member_newconv_index
ADD CONSTRAINT uk_dws_member_newconv
UNIQUE (site_id, member_id, stat_date);
CREATE INDEX idx_dws_newconv_stat_date
ON dws.dws_member_newconv_index (site_id, stat_date);
-- ============================================================
-- 2. 视图改造DISTINCT ON 取最新快照,下游零改动)
-- ============================================================
-- 2a. app.v_dws_member_assistant_relation_index
CREATE OR REPLACE VIEW app.v_dws_member_assistant_relation_index AS
SELECT DISTINCT ON (member_id, assistant_id)
relation_id, site_id, tenant_id, member_id, assistant_id,
session_count, total_duration_minutes, basic_session_count, incentive_session_count,
days_since_last_session,
rs_f, rs_d, rs_r, rs_raw, rs_display,
os_share, os_label, os_rank,
ms_f_short, ms_f_long, ms_raw, ms_display,
ml_order_count, ml_allocated_amount, ml_raw, ml_display,
calc_time, created_at, updated_at,
recall_created_total, recall_completed_total,
follow_up_created_total, follow_up_completed_total,
total_created, total_completed,
stat_date
FROM dws.dws_member_assistant_relation_index
WHERE site_id = current_setting('app.current_site_id')::bigint
ORDER BY member_id, assistant_id, stat_date DESC;
-- 2b. app.v_dws_member_winback_index
CREATE OR REPLACE VIEW app.v_dws_member_winback_index AS
SELECT DISTINCT ON (member_id)
winback_id, site_id, tenant_id, member_id,
status, segment,
member_create_time, first_visit_time, last_visit_time, last_recharge_time,
t_v, t_r, t_a,
visits_14d, visits_30d, visits_60d, visits_total,
spend_30d, spend_180d, sv_balance, recharge_60d_amt,
interval_count,
overdue_old, drop_old, recharge_old, value_old,
raw_score, display_score,
last_wechat_touch_time, calc_time, calc_version,
created_at, updated_at,
overdue_cdf_p, ideal_interval_days, ideal_next_visit_date,
stat_date
FROM dws.dws_member_winback_index
WHERE site_id = current_setting('app.current_site_id')::bigint
ORDER BY member_id, stat_date DESC;
-- 2c. app.v_dws_member_newconv_index
CREATE OR REPLACE VIEW app.v_dws_member_newconv_index AS
SELECT DISTINCT ON (member_id)
newconv_id, site_id, tenant_id, member_id,
status, segment,
member_create_time, first_visit_time, last_visit_time, last_recharge_time,
t_v, t_r, t_a,
visits_14d, visits_30d, visits_60d, visits_total,
spend_30d, spend_180d, sv_balance, recharge_60d_amt,
interval_count,
need_new, salvage_new, recharge_new, value_new, welcome_new,
raw_score_welcome, raw_score_convert, raw_score,
display_score_welcome, display_score_convert, display_score,
last_wechat_touch_time, calc_time, calc_version,
created_at, updated_at,
stat_date
FROM dws.dws_member_newconv_index
WHERE site_id = current_setting('app.current_site_id')::bigint
ORDER BY member_id, stat_date DESC;
-- 2d. dws.v_member_recall_priorityUNION ALL 两支先取最新快照)
CREATE OR REPLACE VIEW dws.v_member_recall_priority AS
SELECT w.site_id, w.tenant_id, w.member_id,
'WBI'::varchar(10) AS index_type,
w.status, w.segment,
w.member_create_time, w.first_visit_time, w.last_visit_time, w.last_recharge_time,
w.t_v, w.t_r, w.t_a,
w.visits_14d, w.visits_30d, w.visits_60d, w.visits_total,
w.spend_30d, w.spend_180d, w.sv_balance, w.recharge_60d_amt,
NULL::numeric(10,4) AS need_new,
NULL::numeric(10,4) AS salvage_new,
NULL::numeric(10,4) AS recharge_new,
NULL::numeric(10,4) AS value_new,
NULL::numeric(10,4) AS welcome_new,
NULL::numeric(14,6) AS raw_score_welcome,
NULL::numeric(14,6) AS raw_score_convert,
w.raw_score,
NULL::numeric(4,2) AS display_score_welcome,
NULL::numeric(4,2) AS display_score_convert,
w.display_score,
w.last_wechat_touch_time,
w.calc_time
FROM (
SELECT DISTINCT ON (site_id, member_id) *
FROM dws.dws_member_winback_index
ORDER BY site_id, member_id, stat_date DESC
) w
UNION ALL
SELECT n.site_id, n.tenant_id, n.member_id,
'NCI'::varchar(10) AS index_type,
n.status, n.segment,
n.member_create_time, n.first_visit_time, n.last_visit_time, n.last_recharge_time,
n.t_v, n.t_r, n.t_a,
n.visits_14d, n.visits_30d, n.visits_60d, n.visits_total,
n.spend_30d, n.spend_180d, n.sv_balance, n.recharge_60d_amt,
n.need_new, n.salvage_new, n.recharge_new, n.value_new, n.welcome_new,
n.raw_score_welcome, n.raw_score_convert, n.raw_score,
n.display_score_welcome, n.display_score_convert, n.display_score,
n.last_wechat_touch_time,
n.calc_time
FROM (
SELECT DISTINCT ON (site_id, member_id) *
FROM dws.dws_member_newconv_index
ORDER BY site_id, member_id, stat_date DESC
) n;
COMMIT;
-- ============================================================
-- 回滚脚本(按需手动执行,不在 BEGIN/COMMIT 内)
-- ============================================================
-- 删除历史快照只保留最新
-- DELETE FROM dws.dws_member_assistant_relation_index WHERE stat_date != (SELECT MAX(stat_date) FROM dws.dws_member_assistant_relation_index);
-- DELETE FROM dws.dws_member_winback_index WHERE stat_date != (SELECT MAX(stat_date) FROM dws.dws_member_winback_index);
-- DELETE FROM dws.dws_member_newconv_index WHERE stat_date != (SELECT MAX(stat_date) FROM dws.dws_member_newconv_index);
-- 恢复原唯一约束
-- ALTER TABLE dws.dws_member_assistant_relation_index DROP CONSTRAINT uk_dws_member_assistant_relation_index;
-- ALTER TABLE dws.dws_member_assistant_relation_index ADD CONSTRAINT uk_dws_member_assistant_relation_index UNIQUE (site_id, member_id, assistant_id);
-- ALTER TABLE dws.dws_member_winback_index DROP CONSTRAINT uk_dws_member_winback;
-- ALTER TABLE dws.dws_member_winback_index ADD CONSTRAINT uk_dws_member_winback UNIQUE (site_id, member_id);
-- ALTER TABLE dws.dws_member_newconv_index DROP CONSTRAINT uk_dws_member_newconv;
-- ALTER TABLE dws.dws_member_newconv_index ADD CONSTRAINT uk_dws_member_newconv UNIQUE (site_id, member_id);
-- 删除 stat_date 列
-- ALTER TABLE dws.dws_member_assistant_relation_index DROP COLUMN stat_date;
-- ALTER TABLE dws.dws_member_winback_index DROP COLUMN stat_date;
-- ALTER TABLE dws.dws_member_newconv_index DROP COLUMN stat_date;
-- 恢复原视图(使用 gen_consolidated_ddl.py 重新生成)

View File

@@ -0,0 +1,23 @@
-- 迁移:客户项目标签改为按消费次数取数
-- 日期2026-04-11
-- 说明:
-- 1. cfg_area_category 补充美洲豹赛台 → BILLIARD
-- 2. 清理旧时间窗口数据LAST_30_DAYS / LAST_60_DAYS
-- 下次 ETL 跑完会写入新窗口 LAST_30_VISITS
BEGIN;
-- 1. 补充美洲豹赛台映射(幂等:冲突则跳过)
INSERT INTO dws.cfg_area_category
(source_area_name, source_table_name, category_code, category_name,
display_name, short_name, match_type, match_priority, is_active, sort_order, description)
VALUES
('美洲豹赛台', '美洲豹赛台', 'BILLIARD', '🎱 中式/追分',
'中式/追分', '🎱', 'EXACT', 10, true, 100, '美洲豹品牌赛台,归入中式台球')
ON CONFLICT (source_area_name, COALESCE(source_table_name, '')) DO NOTHING;
-- 2. 清理旧时间窗口数据ETL 重跑后会生成新的 LAST_30_VISITS 数据)
DELETE FROM dws.dws_member_project_tag
WHERE time_window IN ('LAST_30_DAYS', 'LAST_60_DAYS');
COMMIT;

View File

@@ -0,0 +1,180 @@
-- =============================================================
-- Migration: 20260423__app2a_add_member_order_count.sql
-- Purpose: 为 app2a 区域财务洞察增加 dws 层区域级会员订单数列
-- Related: docs/ai/app2_finance_multi_app_design.md v2 · Phase B
-- Author: Claude + Neo
-- =============================================================
--
-- 改动说明:
-- 1. dws.dws_finance_area_daily 增加 member_order_count 列(默认 0允许新历史数据为 0
-- 2. 重建 RLS 视图 app.v_dws_finance_area_daily暴露新列
-- 3. 若存在 dws.v_dws_finance_area_daily 同名镜像视图,同步重建
--
-- 后置依赖:
-- - ETL loader apps/etl/connectors/feiqiu/loaders/dws_finance_area_daily.py
-- 需要同步改造:按 (area_code, stat_date) 聚合 DWD 订单时,
-- 将 is_member_order = true 的订单数合入 member_order_count
-- - 改造前历史数据 member_order_count = 0改造后新增数据生效
-- - 如需历史回填scripts/ops/backfill_area_member_order.py本迁移不含
--
-- 回滚策略:
-- 见文件末 ROLLBACK 段
--
-- 验证 SQL
-- 见文件末 VERIFICATION 段
-- =============================================================
BEGIN;
-- ------------------------------------------------------------
-- Step 1: dws 层表加列
-- ------------------------------------------------------------
ALTER TABLE dws.dws_finance_area_daily
ADD COLUMN IF NOT EXISTS member_order_count integer NOT NULL DEFAULT 0;
COMMENT ON COLUMN dws.dws_finance_area_daily.member_order_count IS
'会员订单数 (区域粒度, 从 DWD 聚合 is_member_order=true 的订单计数, 默认 0)';
-- ------------------------------------------------------------
-- Step 2: 重建 app schema 的 RLS 视图
-- - 保留原 RLS 过滤条件 (current_setting('app.current_site_id'))
-- - PostgreSQL 限制CREATE OR REPLACE VIEW 仅允许末尾加列,不能中间插入
-- - 故 member_order_count 加到末尾(逻辑归属仍为单位经济,顺序不影响语义)
-- ------------------------------------------------------------
CREATE OR REPLACE VIEW app.v_dws_finance_area_daily AS
SELECT
id,
site_id,
tenant_id,
stat_date,
area_code,
table_fee_amount,
goods_amount,
assistant_pd_amount,
assistant_cx_amount,
gross_amount,
discount_groupbuy,
discount_vip,
discount_manual,
discount_gift_card,
discount_rounding,
discount_other,
discount_total,
confirmed_income,
cash_pay_amount,
cash_paper_amount,
scan_pay_amount,
groupbuy_pay_amount,
recharge_cash_inflow,
cash_inflow_total,
cash_outflow_total,
cash_balance_change,
card_consume_total,
recharge_card_consume,
gift_card_consume,
recharge_cash,
first_recharge_cash,
renewal_cash,
order_count,
created_at,
updated_at,
member_order_count -- 新增列PostgreSQL CREATE OR REPLACE VIEW 限制:只能末尾加)
FROM dws.dws_finance_area_daily
WHERE site_id = (current_setting('app.current_site_id'::text))::bigint;
COMMENT ON VIEW app.v_dws_finance_area_daily IS
'区域级财务日粒度 RLS 视图 | app2a 区域财务洞察数据源 | 2026-04-23 新增 member_order_count';
-- ------------------------------------------------------------
-- Step 3: 重建 dws schema 镜像 RLS 视图
-- (按 CLAUDE.md "RLS 视图双 Schema 规则" 要求dws 和 app 两个 schema 的 RLS 视图必须同步)
-- ------------------------------------------------------------
CREATE OR REPLACE VIEW dws.v_dws_finance_area_daily AS
SELECT
id,
site_id,
tenant_id,
stat_date,
area_code,
table_fee_amount,
goods_amount,
assistant_pd_amount,
assistant_cx_amount,
gross_amount,
discount_groupbuy,
discount_vip,
discount_manual,
discount_gift_card,
discount_rounding,
discount_other,
discount_total,
confirmed_income,
cash_pay_amount,
cash_paper_amount,
scan_pay_amount,
groupbuy_pay_amount,
recharge_cash_inflow,
cash_inflow_total,
cash_outflow_total,
cash_balance_change,
card_consume_total,
recharge_card_consume,
gift_card_consume,
recharge_cash,
first_recharge_cash,
renewal_cash,
order_count,
created_at,
updated_at,
member_order_count -- 新增列PostgreSQL CREATE OR REPLACE VIEW 限制:只能末尾加)
FROM dws.dws_finance_area_daily
WHERE site_id = (current_setting('app.current_site_id'::text))::bigint;
COMMENT ON VIEW dws.v_dws_finance_area_daily IS
'区域级财务日粒度 RLS 视图 (dws schema 镜像) | 与 app.v_dws_finance_area_daily 双 schema 规则要求下同步';
COMMIT;
-- =============================================================
-- VERIFICATION (事后手工执行)
-- =============================================================
-- 1. 列存在性
-- SELECT column_name, data_type, column_default, is_nullable
-- FROM information_schema.columns
-- WHERE table_schema = 'dws'
-- AND table_name = 'dws_finance_area_daily'
-- AND column_name = 'member_order_count';
-- -- 期望: 1 行, integer, 0, NO
--
-- 2. 视图暴露新列
-- SELECT column_name
-- FROM information_schema.columns
-- WHERE table_schema = 'app'
-- AND table_name = 'v_dws_finance_area_daily'
-- AND column_name = 'member_order_count';
-- -- 期望: 1 行
--
-- 3. 数据校验 (ETL loader 改造并跑一次后执行)
-- SET app.current_site_id = <测试门店 ID>;
-- SELECT s.site_id,
-- s.stat_date,
-- s.member_order_count AS full_store_member_orders,
-- COALESCE(SUM(a.member_order_count), 0) AS sum_area_member_orders,
-- s.member_order_count - COALESCE(SUM(a.member_order_count), 0) AS diff
-- FROM app.v_dws_finance_daily_summary s
-- LEFT JOIN app.v_dws_finance_area_daily a
-- ON s.site_id = a.site_id AND s.stat_date = a.stat_date
-- WHERE s.stat_date >= CURRENT_DATE - INTERVAL '7 days'
-- GROUP BY s.site_id, s.stat_date, s.member_order_count
-- HAVING s.member_order_count <> COALESCE(SUM(a.member_order_count), 0);
-- -- 期望: 0 行 (全店会员订单数 = 区域之和)
-- =============================================================
-- ROLLBACK
-- =============================================================
-- BEGIN;
-- DROP VIEW IF EXISTS app.v_dws_finance_area_daily CASCADE;
-- DROP VIEW IF EXISTS dws.v_dws_finance_area_daily CASCADE;
-- ALTER TABLE dws.dws_finance_area_daily DROP COLUMN IF EXISTS member_order_count;
-- -- 重新 CREATE VIEW app.v_dws_finance_area_daily (不含新列) - 从 db/etl_feiqiu/schemas/app.sql:714 复制
-- COMMIT;

View File

@@ -1,6 +1,6 @@
-- =============================================================================
-- etl_feiqiu / appRLS 视图层)
-- 生成日期2026-04-06
-- 生成日期2026-04-12
-- 来源:测试库(通过脚本自动导出)
-- =============================================================================
@@ -439,10 +439,8 @@ SELECT table_fee_log_id,
WHERE (site_id = (current_setting('app.current_site_id'::text))::bigint);
;
-- CHANGE 2026-04-08 | DISTINCT ON 只取每对 (assistant, member) 最新快照
CREATE OR REPLACE VIEW app.v_dws_assistant_customer_stats AS
SELECT DISTINCT ON (assistant_id, member_id)
id,
SELECT DISTINCT ON (assistant_id, member_id) id,
site_id,
tenant_id,
assistant_id,
@@ -946,7 +944,7 @@ SELECT intimacy_id,
;
CREATE OR REPLACE VIEW app.v_dws_member_assistant_relation_index AS
SELECT relation_id,
SELECT DISTINCT ON (member_id, assistant_id) relation_id,
site_id,
tenant_id,
member_id,
@@ -974,15 +972,21 @@ SELECT relation_id,
ml_display,
calc_time,
created_at,
updated_at
updated_at,
recall_created_total,
recall_completed_total,
follow_up_created_total,
follow_up_completed_total,
total_created,
total_completed,
stat_date
FROM dws.dws_member_assistant_relation_index
WHERE (site_id = (current_setting('app.current_site_id'::text))::bigint);
WHERE (site_id = (current_setting('app.current_site_id'::text))::bigint)
ORDER BY member_id, assistant_id, stat_date DESC;
;
-- CHANGE 2026-04-08 | DISTINCT ON 只取每个会员最新快照,避免多 stat_date 行膨胀
CREATE OR REPLACE VIEW app.v_dws_member_consumption_summary AS
SELECT DISTINCT ON (member_id)
id,
SELECT DISTINCT ON (member_id) id,
site_id,
tenant_id,
member_id,
@@ -1034,7 +1038,7 @@ SELECT DISTINCT ON (member_id)
;
CREATE OR REPLACE VIEW app.v_dws_member_newconv_index AS
SELECT newconv_id,
SELECT DISTINCT ON (member_id) newconv_id,
site_id,
tenant_id,
member_id,
@@ -1071,9 +1075,11 @@ SELECT newconv_id,
calc_time,
calc_version,
created_at,
updated_at
updated_at,
stat_date
FROM dws.dws_member_newconv_index
WHERE (site_id = (current_setting('app.current_site_id'::text))::bigint);
WHERE (site_id = (current_setting('app.current_site_id'::text))::bigint)
ORDER BY member_id, stat_date DESC;
;
CREATE OR REPLACE VIEW app.v_dws_member_project_tag AS
@@ -1160,7 +1166,7 @@ SELECT id,
;
CREATE OR REPLACE VIEW app.v_dws_member_winback_index AS
SELECT winback_id,
SELECT DISTINCT ON (member_id) winback_id,
site_id,
tenant_id,
member_id,
@@ -1195,9 +1201,11 @@ SELECT winback_id,
updated_at,
overdue_cdf_p,
ideal_interval_days,
ideal_next_visit_date
ideal_next_visit_date,
stat_date
FROM dws.dws_member_winback_index
WHERE (site_id = (current_setting('app.current_site_id'::text))::bigint);
WHERE (site_id = (current_setting('app.current_site_id'::text))::bigint)
ORDER BY member_id, stat_date DESC;
;
CREATE OR REPLACE VIEW app.v_dws_order_summary AS

View File

@@ -1,7 +1,11 @@
-- =============================================================================
-- etl_feiqiu / core门店标准化维度/事实)
-- etl_feiqiu / core平台统一维度/事实
-- 生成日期2026-04-06
-- 来源:测试库(通过脚本自动导出)
--
-- 定位:屏蔽 ODS/DWD 多数据源差异,输出标准化维度和事实。
-- DWS/app 层只依赖 core不直接查 DWD。
-- 当前数据源飞球dwd.*),后续可接入美团、抖音等。
-- =============================================================================
CREATE SCHEMA IF NOT EXISTS core;

View File

@@ -695,7 +695,13 @@ CREATE TABLE dwd.dwd_groupbuy_redemption (
ledger_name character varying(128),
create_time timestamp with time zone,
member_discount_money numeric(18,2),
coupon_sale_id bigint
coupon_sale_id bigint,
mt_settlement_price numeric(14,2),
mt_gross_income numeric(14,2),
mt_service_fee numeric(14,2),
mt_marketing_fee numeric(14,2),
mt_other_adjust numeric(14,2),
mt_import_time timestamp with time zone
);
CREATE TABLE dwd.dwd_groupbuy_redemption_ex (

View File

@@ -1,6 +1,6 @@
-- =============================================================================
-- etl_feiqiu / dws汇总数据层
-- 生成日期2026-04-06
-- 生成日期2026-04-12
-- 来源:测试库(通过脚本自动导出)
-- =============================================================================
@@ -722,7 +722,8 @@ CREATE TABLE dws.dws_member_assistant_relation_index (
follow_up_created_total integer DEFAULT 0 NOT NULL,
follow_up_completed_total integer DEFAULT 0 NOT NULL,
total_created integer DEFAULT 0 NOT NULL,
total_completed integer DEFAULT 0 NOT NULL
total_completed integer DEFAULT 0 NOT NULL,
stat_date date DEFAULT CURRENT_DATE NOT NULL
);
CREATE TABLE dws.dws_member_consumption_summary (
@@ -789,7 +790,6 @@ CREATE TABLE dws.dws_member_newconv_index (
t_r numeric(6,2),
t_a numeric(6,2),
visits_14d integer DEFAULT 0 NOT NULL,
visits_30d integer DEFAULT 0 NOT NULL,
visits_60d integer DEFAULT 0 NOT NULL,
visits_total integer DEFAULT 0 NOT NULL,
spend_30d numeric(14,2) DEFAULT 0 NOT NULL,
@@ -812,7 +812,9 @@ CREATE TABLE dws.dws_member_newconv_index (
calc_time timestamp with time zone DEFAULT now() NOT NULL,
calc_version integer DEFAULT 1 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL
updated_at timestamp with time zone DEFAULT now() NOT NULL,
visits_30d integer DEFAULT 0 NOT NULL,
stat_date date DEFAULT CURRENT_DATE NOT NULL
);
CREATE TABLE dws.dws_member_project_tag (
@@ -908,7 +910,6 @@ CREATE TABLE dws.dws_member_winback_index (
t_r numeric(6,2),
t_a numeric(6,2),
visits_14d integer DEFAULT 0 NOT NULL,
visits_30d integer DEFAULT 0 NOT NULL,
visits_60d integer DEFAULT 0 NOT NULL,
visits_total integer DEFAULT 0 NOT NULL,
spend_30d numeric(14,2) DEFAULT 0 NOT NULL,
@@ -929,7 +930,9 @@ CREATE TABLE dws.dws_member_winback_index (
updated_at timestamp with time zone DEFAULT now() NOT NULL,
overdue_cdf_p numeric(10,4),
ideal_interval_days numeric(10,2),
ideal_next_visit_date date
ideal_next_visit_date date,
visits_30d integer DEFAULT 0 NOT NULL,
stat_date date DEFAULT CURRENT_DATE NOT NULL
);
CREATE TABLE dws.dws_ml_manual_order_alloc (
@@ -1093,18 +1096,18 @@ ALTER TABLE dws.dws_index_percentile_history ADD CONSTRAINT uk_dws_index_percent
ALTER TABLE dws.dws_member_assistant_intimacy ADD CONSTRAINT dws_member_assistant_intimacy_pkey PRIMARY KEY (intimacy_id);
ALTER TABLE dws.dws_member_assistant_intimacy ADD CONSTRAINT uk_dws_member_assistant_intimacy UNIQUE (site_id, member_id, assistant_id);
ALTER TABLE dws.dws_member_assistant_relation_index ADD CONSTRAINT dws_member_assistant_relation_index_pkey PRIMARY KEY (relation_id);
ALTER TABLE dws.dws_member_assistant_relation_index ADD CONSTRAINT uk_dws_member_assistant_relation_index UNIQUE (site_id, member_id, assistant_id);
ALTER TABLE dws.dws_member_assistant_relation_index ADD CONSTRAINT uk_dws_member_assistant_relation_index UNIQUE (site_id, member_id, assistant_id, stat_date);
ALTER TABLE dws.dws_member_consumption_summary ADD CONSTRAINT dws_member_consumption_summary_pkey PRIMARY KEY (id);
ALTER TABLE dws.dws_member_consumption_summary ADD CONSTRAINT uk_dws_member_consumption UNIQUE (site_id, member_id, stat_date);
ALTER TABLE dws.dws_member_newconv_index ADD CONSTRAINT dws_member_newconv_index_pkey PRIMARY KEY (newconv_id);
ALTER TABLE dws.dws_member_newconv_index ADD CONSTRAINT uk_dws_member_newconv UNIQUE (site_id, member_id);
ALTER TABLE dws.dws_member_newconv_index ADD CONSTRAINT uk_dws_member_newconv UNIQUE (site_id, member_id, stat_date);
ALTER TABLE dws.dws_member_project_tag ADD CONSTRAINT pk_dws_member_project_tag PRIMARY KEY (id);
ALTER TABLE dws.dws_member_project_tag ADD CONSTRAINT uk_dws_member_project_tag UNIQUE (site_id, member_id, time_window, category_code);
ALTER TABLE dws.dws_member_spending_power_index ADD CONSTRAINT dws_member_spending_power_index_pkey PRIMARY KEY (spi_id);
ALTER TABLE dws.dws_member_visit_detail ADD CONSTRAINT dws_member_visit_detail_pkey PRIMARY KEY (id);
ALTER TABLE dws.dws_member_visit_detail ADD CONSTRAINT uk_dws_member_visit UNIQUE (site_id, member_id, order_settle_id);
ALTER TABLE dws.dws_member_winback_index ADD CONSTRAINT dws_member_winback_index_pkey PRIMARY KEY (winback_id);
ALTER TABLE dws.dws_member_winback_index ADD CONSTRAINT uk_dws_member_winback UNIQUE (site_id, member_id);
ALTER TABLE dws.dws_member_winback_index ADD CONSTRAINT uk_dws_member_winback UNIQUE (site_id, member_id, stat_date);
ALTER TABLE dws.dws_ml_manual_order_alloc ADD CONSTRAINT dws_ml_manual_order_alloc_pkey PRIMARY KEY (alloc_id);
ALTER TABLE dws.dws_ml_manual_order_alloc ADD CONSTRAINT uk_dws_ml_manual_order_alloc UNIQUE (site_id, external_id, assistant_id);
ALTER TABLE dws.dws_ml_manual_order_source ADD CONSTRAINT dws_ml_manual_order_source_pkey PRIMARY KEY (source_id);
@@ -1164,12 +1167,13 @@ CREATE INDEX idx_dws_percentile_history ON dws.dws_index_percentile_history USIN
CREATE INDEX idx_dws_intimacy_assistant ON dws.dws_member_assistant_intimacy USING btree (site_id, assistant_id, display_score DESC);
CREATE INDEX idx_dws_intimacy_member ON dws.dws_member_assistant_intimacy USING btree (site_id, member_id, display_score DESC);
CREATE INDEX idx_dws_relation_assistant ON dws.dws_member_assistant_relation_index USING btree (site_id, assistant_id, rs_display DESC);
CREATE INDEX idx_dws_relation_calc_time ON dws.dws_member_assistant_relation_index USING btree (calc_time);
CREATE INDEX idx_dws_relation_member ON dws.dws_member_assistant_relation_index USING btree (site_id, member_id, os_share DESC);
CREATE INDEX idx_dws_relation_stat_date ON dws.dws_member_assistant_relation_index USING btree (site_id, stat_date);
CREATE INDEX idx_dws_member_consumption_date ON dws.dws_member_consumption_summary USING btree (stat_date);
CREATE INDEX idx_dws_member_consumption_member ON dws.dws_member_consumption_summary USING btree (member_id, stat_date);
CREATE INDEX idx_dws_member_consumption_tier ON dws.dws_member_consumption_summary USING btree (customer_tier);
CREATE INDEX idx_dws_newconv_display ON dws.dws_member_newconv_index USING btree (site_id, display_score DESC);
CREATE INDEX idx_dws_newconv_stat_date ON dws.dws_member_newconv_index USING btree (site_id, stat_date);
CREATE INDEX idx_mpt_site_window_tagged ON dws.dws_member_project_tag USING btree (site_id, time_window) WHERE (is_tagged = true);
CREATE INDEX idx_spi_display_score ON dws.dws_member_spending_power_index USING btree (site_id, display_score DESC);
CREATE UNIQUE INDEX idx_spi_site_member ON dws.dws_member_spending_power_index USING btree (site_id, member_id);
@@ -1177,6 +1181,7 @@ CREATE INDEX idx_dws_member_visit_date ON dws.dws_member_visit_detail USING btre
CREATE INDEX idx_dws_member_visit_member ON dws.dws_member_visit_detail USING btree (member_id, visit_date);
CREATE INDEX idx_dws_member_visit_order ON dws.dws_member_visit_detail USING btree (order_settle_id);
CREATE INDEX idx_dws_winback_display ON dws.dws_member_winback_index USING btree (site_id, display_score DESC);
CREATE INDEX idx_dws_winback_stat_date ON dws.dws_member_winback_index USING btree (site_id, stat_date);
CREATE INDEX idx_dws_ml_alloc_member_assistant ON dws.dws_ml_manual_order_alloc USING btree (site_id, member_id, assistant_id);
CREATE INDEX idx_dws_ml_alloc_scope ON dws.dws_ml_manual_order_alloc USING btree (site_id, biz_date);
CREATE INDEX idx_dws_ml_source_external ON dws.dws_ml_manual_order_source USING btree (site_id, external_id);
@@ -1285,27 +1290,27 @@ SELECT id,
;
CREATE OR REPLACE VIEW dws.v_member_recall_priority AS
SELECT dws_member_winback_index.site_id,
dws_member_winback_index.tenant_id,
dws_member_winback_index.member_id,
SELECT w.site_id,
w.tenant_id,
w.member_id,
'WBI'::character varying(10) AS index_type,
dws_member_winback_index.status,
dws_member_winback_index.segment,
dws_member_winback_index.member_create_time,
dws_member_winback_index.first_visit_time,
dws_member_winback_index.last_visit_time,
dws_member_winback_index.last_recharge_time,
dws_member_winback_index.t_v,
dws_member_winback_index.t_r,
dws_member_winback_index.t_a,
dws_member_winback_index.visits_14d,
dws_member_winback_index.visits_30d,
dws_member_winback_index.visits_60d,
dws_member_winback_index.visits_total,
dws_member_winback_index.spend_30d,
dws_member_winback_index.spend_180d,
dws_member_winback_index.sv_balance,
dws_member_winback_index.recharge_60d_amt,
w.status,
w.segment,
w.member_create_time,
w.first_visit_time,
w.last_visit_time,
w.last_recharge_time,
w.t_v,
w.t_r,
w.t_a,
w.visits_14d,
w.visits_30d,
w.visits_60d,
w.visits_total,
w.spend_30d,
w.spend_180d,
w.sv_balance,
w.recharge_60d_amt,
NULL::numeric(10,4) AS need_new,
NULL::numeric(10,4) AS salvage_new,
NULL::numeric(10,4) AS recharge_new,
@@ -1313,49 +1318,127 @@ SELECT dws_member_winback_index.site_id,
NULL::numeric(10,4) AS welcome_new,
NULL::numeric(14,6) AS raw_score_welcome,
NULL::numeric(14,6) AS raw_score_convert,
dws_member_winback_index.raw_score,
w.raw_score,
NULL::numeric(4,2) AS display_score_welcome,
NULL::numeric(4,2) AS display_score_convert,
dws_member_winback_index.display_score,
dws_member_winback_index.last_wechat_touch_time,
dws_member_winback_index.calc_time
FROM dws.dws_member_winback_index
w.display_score,
w.last_wechat_touch_time,
w.calc_time
FROM ( SELECT DISTINCT ON (dws_member_winback_index.site_id, dws_member_winback_index.member_id) dws_member_winback_index.winback_id,
dws_member_winback_index.site_id,
dws_member_winback_index.tenant_id,
dws_member_winback_index.member_id,
dws_member_winback_index.status,
dws_member_winback_index.segment,
dws_member_winback_index.member_create_time,
dws_member_winback_index.first_visit_time,
dws_member_winback_index.last_visit_time,
dws_member_winback_index.last_recharge_time,
dws_member_winback_index.t_v,
dws_member_winback_index.t_r,
dws_member_winback_index.t_a,
dws_member_winback_index.visits_14d,
dws_member_winback_index.visits_60d,
dws_member_winback_index.visits_total,
dws_member_winback_index.spend_30d,
dws_member_winback_index.spend_180d,
dws_member_winback_index.sv_balance,
dws_member_winback_index.recharge_60d_amt,
dws_member_winback_index.interval_count,
dws_member_winback_index.overdue_old,
dws_member_winback_index.drop_old,
dws_member_winback_index.recharge_old,
dws_member_winback_index.value_old,
dws_member_winback_index.raw_score,
dws_member_winback_index.display_score,
dws_member_winback_index.last_wechat_touch_time,
dws_member_winback_index.calc_time,
dws_member_winback_index.calc_version,
dws_member_winback_index.created_at,
dws_member_winback_index.updated_at,
dws_member_winback_index.overdue_cdf_p,
dws_member_winback_index.ideal_interval_days,
dws_member_winback_index.ideal_next_visit_date,
dws_member_winback_index.visits_30d,
dws_member_winback_index.stat_date
FROM dws.dws_member_winback_index
ORDER BY dws_member_winback_index.site_id, dws_member_winback_index.member_id, dws_member_winback_index.stat_date DESC) w
UNION ALL
SELECT dws_member_newconv_index.site_id,
dws_member_newconv_index.tenant_id,
dws_member_newconv_index.member_id,
SELECT n.site_id,
n.tenant_id,
n.member_id,
'NCI'::character varying(10) AS index_type,
dws_member_newconv_index.status,
dws_member_newconv_index.segment,
dws_member_newconv_index.member_create_time,
dws_member_newconv_index.first_visit_time,
dws_member_newconv_index.last_visit_time,
dws_member_newconv_index.last_recharge_time,
dws_member_newconv_index.t_v,
dws_member_newconv_index.t_r,
dws_member_newconv_index.t_a,
dws_member_newconv_index.visits_14d,
dws_member_newconv_index.visits_30d,
dws_member_newconv_index.visits_60d,
dws_member_newconv_index.visits_total,
dws_member_newconv_index.spend_30d,
dws_member_newconv_index.spend_180d,
dws_member_newconv_index.sv_balance,
dws_member_newconv_index.recharge_60d_amt,
dws_member_newconv_index.need_new,
dws_member_newconv_index.salvage_new,
dws_member_newconv_index.recharge_new,
dws_member_newconv_index.value_new,
dws_member_newconv_index.welcome_new,
dws_member_newconv_index.raw_score_welcome,
dws_member_newconv_index.raw_score_convert,
dws_member_newconv_index.raw_score,
dws_member_newconv_index.display_score_welcome,
dws_member_newconv_index.display_score_convert,
dws_member_newconv_index.display_score,
dws_member_newconv_index.last_wechat_touch_time,
dws_member_newconv_index.calc_time
FROM dws.dws_member_newconv_index;
n.status,
n.segment,
n.member_create_time,
n.first_visit_time,
n.last_visit_time,
n.last_recharge_time,
n.t_v,
n.t_r,
n.t_a,
n.visits_14d,
n.visits_30d,
n.visits_60d,
n.visits_total,
n.spend_30d,
n.spend_180d,
n.sv_balance,
n.recharge_60d_amt,
n.need_new,
n.salvage_new,
n.recharge_new,
n.value_new,
n.welcome_new,
n.raw_score_welcome,
n.raw_score_convert,
n.raw_score,
n.display_score_welcome,
n.display_score_convert,
n.display_score,
n.last_wechat_touch_time,
n.calc_time
FROM ( SELECT DISTINCT ON (dws_member_newconv_index.site_id, dws_member_newconv_index.member_id) dws_member_newconv_index.newconv_id,
dws_member_newconv_index.site_id,
dws_member_newconv_index.tenant_id,
dws_member_newconv_index.member_id,
dws_member_newconv_index.status,
dws_member_newconv_index.segment,
dws_member_newconv_index.member_create_time,
dws_member_newconv_index.first_visit_time,
dws_member_newconv_index.last_visit_time,
dws_member_newconv_index.last_recharge_time,
dws_member_newconv_index.t_v,
dws_member_newconv_index.t_r,
dws_member_newconv_index.t_a,
dws_member_newconv_index.visits_14d,
dws_member_newconv_index.visits_60d,
dws_member_newconv_index.visits_total,
dws_member_newconv_index.spend_30d,
dws_member_newconv_index.spend_180d,
dws_member_newconv_index.sv_balance,
dws_member_newconv_index.recharge_60d_amt,
dws_member_newconv_index.interval_count,
dws_member_newconv_index.need_new,
dws_member_newconv_index.salvage_new,
dws_member_newconv_index.recharge_new,
dws_member_newconv_index.value_new,
dws_member_newconv_index.welcome_new,
dws_member_newconv_index.raw_score_welcome,
dws_member_newconv_index.raw_score_convert,
dws_member_newconv_index.raw_score,
dws_member_newconv_index.display_score_welcome,
dws_member_newconv_index.display_score_convert,
dws_member_newconv_index.display_score,
dws_member_newconv_index.last_wechat_touch_time,
dws_member_newconv_index.calc_time,
dws_member_newconv_index.calc_version,
dws_member_newconv_index.created_at,
dws_member_newconv_index.updated_at,
dws_member_newconv_index.visits_30d,
dws_member_newconv_index.stat_date
FROM dws.dws_member_newconv_index
ORDER BY dws_member_newconv_index.site_id, dws_member_newconv_index.member_id, dws_member_newconv_index.stat_date DESC) n;
;
-- 物化视图

View File

@@ -0,0 +1,70 @@
-- =============================================================
-- Migration: 20260423__ai_cache_allow_app2a.sql
-- Purpose: 放开 biz.ai_cache.chk_ai_cache_type CHECK 约束,
-- 允许新 cache_type 'app2a_finance_area'app2a 区域财务洞察)
-- Related: docs/ai/app2_finance_multi_app_design.md v2 · Phase F
-- Author: Claude + Neo
-- =============================================================
--
-- 改动说明:
-- 1. DROP 旧 CHECK 约束 chk_ai_cache_type
-- 2. CREATE 新 CHECK 约束,加入 'app2a_finance_area' 值
--
-- 兼容性:
-- - 旧 cache_type 值全部保留,仅新增一个允许值
-- - 无需数据迁移
--
-- 回滚策略:
-- 见文件末 ROLLBACK 段
--
-- 验证 SQL
-- 见文件末 VERIFICATION 段
-- =============================================================
BEGIN;
-- 1. DROP 旧约束
ALTER TABLE biz.ai_cache DROP CONSTRAINT IF EXISTS chk_ai_cache_type;
-- 2. CREATE 新约束(加入 app2a_finance_area
ALTER TABLE biz.ai_cache ADD CONSTRAINT chk_ai_cache_type
CHECK (cache_type IN (
'app2_finance',
'app2a_finance_area', -- 2026-04-23 新增(区域财务洞察 · 64 组合)
'app3_clue',
'app4_analysis',
'app5_tactics',
'app6_note_analysis',
'app7_customer_analysis',
'app8_clue_consolidated'
));
COMMIT;
-- =============================================================
-- VERIFICATION事后手工执行
-- =============================================================
-- 1. 约束允许值检查
-- SELECT pg_get_constraintdef(oid)
-- FROM pg_constraint
-- WHERE conname = 'chk_ai_cache_type';
-- -- 期望:返回的 CHECK 里包含 'app2a_finance_area'
--
-- 2. 插入新 cache_type 应成功
-- INSERT INTO biz.ai_cache (cache_type, site_id, target_id, result_json, status, expires_at)
-- VALUES ('app2a_finance_area', 1, 'test__all', '{}', 'valid', NOW() + INTERVAL '1 day');
-- -- 期望:成功插入
-- DELETE FROM biz.ai_cache WHERE cache_type = 'app2a_finance_area' AND site_id = 1 AND target_id = 'test__all';
-- =============================================================
-- ROLLBACK
-- =============================================================
-- BEGIN;
-- ALTER TABLE biz.ai_cache DROP CONSTRAINT IF EXISTS chk_ai_cache_type;
-- ALTER TABLE biz.ai_cache ADD CONSTRAINT chk_ai_cache_type
-- CHECK (cache_type IN (
-- 'app2_finance', 'app3_clue', 'app4_analysis', 'app5_tactics',
-- 'app6_note_analysis', 'app7_customer_analysis', 'app8_clue_consolidated'
-- ));
-- COMMIT;
-- 注:回滚前必须先 DELETE 所有 cache_type = 'app2a_finance_area' 的记录,否则 CHECK 会失败

View File

@@ -148,7 +148,7 @@ cd apps/etl/connectors/feiqiu && python -m cli.main --dry-run --tasks DWD_LOAD_F
# 测试
cd apps/etl/connectors/feiqiu && pytest tests/unit
cd C:\NeoZQYY && pytest tests/ -v
cd C:\Project\NeoZQYY && pytest tests/ -v
```
## 文件归属规则

Some files were not shown because too many files have changed in this diff Show More