145 lines
54 KiB
JSON
145 lines
54 KiB
JSON
{
|
||
"built_at": "2026-03-20T03:26:03.446244+08:00",
|
||
"prompt_id": "P20260320-032340",
|
||
"prompt_at": "2026-03-20T03:23:40.279993+08:00",
|
||
"audit_required": true,
|
||
"db_docs_required": true,
|
||
"reasons": [
|
||
"dir:backend",
|
||
"dir:etl",
|
||
"dir:miniprogram",
|
||
"dir:db",
|
||
"db-schema-change",
|
||
"root-file"
|
||
],
|
||
"changed_files": [
|
||
"apps/backend/app/services/fdw_queries.py",
|
||
"apps/etl/connectors/feiqiu/docs/database/DWS/main/BD_manual_dws_finance_recharge_summary.md",
|
||
"apps/etl/connectors/feiqiu/tasks/dws/finance_recharge_task.py",
|
||
"apps/miniprogram/miniprogram/pages/board-finance/board-finance.ts",
|
||
"db/zqyy_app/migrations/2026-03-20_rebuild_rls_view_gift_breakdown.sql",
|
||
"db/zqyy_app/migrations/2026-03-20_refresh_fdw_finance_recharge_summary.sql",
|
||
"docs/database/ddl/etl_feiqiu__app.sql",
|
||
"scripts/ops/verify_gift_card_breakdown.sql",
|
||
"xmkiro.zip"
|
||
],
|
||
"high_risk_files": [
|
||
"apps/backend/app/services/fdw_queries.py",
|
||
"apps/etl/connectors/feiqiu/tasks/dws/finance_recharge_task.py",
|
||
"apps/miniprogram/miniprogram/pages/board-finance/board-finance.ts",
|
||
"db/zqyy_app/migrations/2026-03-20_rebuild_rls_view_gift_breakdown.sql",
|
||
"db/zqyy_app/migrations/2026-03-20_refresh_fdw_finance_recharge_summary.sql"
|
||
],
|
||
"session_diff": {
|
||
"added": [
|
||
"docs/audit/prompt_logs/prompt_log_20260320_032340.md",
|
||
"docs/audit/prompt_logs/prompt_log_20260320_032539.md",
|
||
"docs/audit/session_logs/2026-03/20/01_53359e23_014223/main_01_7b5de273.md",
|
||
"docs/audit/session_logs/2026-03/20/04_f43303a6_020807/main_01_46d59ec7.md",
|
||
"docs/audit/session_logs/2026-03/20/04_f43303a6_020807/sub_01_28259c55.md",
|
||
"docs/audit/session_logs/2026-03/20/04_f43303a6_020807/sub_01_7b5de273.md",
|
||
"docs/audit/session_logs/2026-03/20/04_f43303a6_020807/sub_02_28259c55.md",
|
||
"docs/audit/session_logs/2026-03/20/04_f43303a6_020807/sub_03_28259c55.md",
|
||
"docs/audit/session_logs/2026-03/20/04_f43303a6_020807/sub_04_28259c55.md",
|
||
"docs/audit/session_logs/2026-03/20/04_f43303a6_020807/sub_05_28259c55.md",
|
||
"docs/audit/session_logs/2026-03/20/04_f43303a6_020807/sub_06_28259c55.md",
|
||
"docs/audit/session_logs/2026-03/20/04_f43303a6_020807/sub_07_28259c55.md",
|
||
"docs/audit/session_logs/2026-03/20/04_f43303a6_020807/sub_08_28259c55.md",
|
||
"docs/audit/session_logs/2026-03/20/04_f43303a6_020807/sub_09_28259c55.md",
|
||
"docs/audit/session_logs/2026-03/20/04_f43303a6_020807/sub_10_28259c55.md",
|
||
"docs/audit/session_logs/2026-03/20/04_f43303a6_020807/sub_11_28259c55.md",
|
||
"docs/audit/session_logs/2026-03/20/04_f43303a6_020807/sub_12_28259c55.md",
|
||
"docs/audit/session_logs/2026-03/20/04_f43303a6_020807/sub_13_28259c55.md"
|
||
],
|
||
"modified": [
|
||
"apps/backend/app/services/fdw_queries.py",
|
||
"apps/etl/connectors/feiqiu/docs/database/DWS/main/BD_manual_dws_finance_recharge_summary.md",
|
||
"apps/etl/connectors/feiqiu/tasks/dws/finance_recharge_task.py",
|
||
"apps/miniprogram/miniprogram/pages/board-finance/board-finance.ts",
|
||
"db/zqyy_app/migrations/2026-03-20_rebuild_rls_view_gift_breakdown.sql",
|
||
"db/zqyy_app/migrations/2026-03-20_refresh_fdw_finance_recharge_summary.sql",
|
||
"docs/audit/session_logs/2026-02/11/_day_index.json",
|
||
"docs/audit/session_logs/2026-02/11/_day_index_full.json",
|
||
"docs/audit/session_logs/2026-02/12/_day_index.json",
|
||
"docs/audit/session_logs/2026-02/12/_day_index_full.json",
|
||
"docs/audit/session_logs/2026-02/13/_day_index.json",
|
||
"docs/audit/session_logs/2026-02/13/_day_index_full.json",
|
||
"docs/audit/session_logs/2026-02/14/_day_index.json",
|
||
"docs/audit/session_logs/2026-02/14/_day_index_full.json",
|
||
"docs/audit/session_logs/2026-02/15/_day_index.json",
|
||
"docs/audit/session_logs/2026-02/15/_day_index_full.json",
|
||
"docs/audit/session_logs/2026-02/16/_day_index.json",
|
||
"docs/audit/session_logs/2026-02/16/_day_index_full.json",
|
||
"docs/audit/session_logs/2026-02/17/_day_index.json",
|
||
"docs/audit/session_logs/2026-02/17/_day_index_full.json",
|
||
"docs/audit/session_logs/2026-02/18/_day_index.json",
|
||
"docs/audit/session_logs/2026-02/18/_day_index_full.json",
|
||
"docs/audit/session_logs/2026-02/19/_day_index.json",
|
||
"docs/audit/session_logs/2026-02/19/_day_index_full.json",
|
||
"docs/audit/session_logs/2026-02/20/_day_index.json",
|
||
"docs/audit/session_logs/2026-02/20/_day_index_full.json",
|
||
"docs/audit/session_logs/2026-02/21/_day_index.json",
|
||
"docs/audit/session_logs/2026-02/21/_day_index_full.json",
|
||
"docs/audit/session_logs/2026-02/22/_day_index.json",
|
||
"docs/audit/session_logs/2026-02/22/_day_index_full.json",
|
||
"docs/audit/session_logs/2026-02/23/_day_index.json",
|
||
"docs/audit/session_logs/2026-02/23/_day_index_full.json",
|
||
"docs/audit/session_logs/2026-02/24/_day_index.json",
|
||
"docs/audit/session_logs/2026-02/24/_day_index_full.json",
|
||
"docs/audit/session_logs/2026-02/25/_day_index.json",
|
||
"docs/audit/session_logs/2026-02/25/_day_index_full.json",
|
||
"docs/audit/session_logs/2026-02/26/_day_index.json",
|
||
"docs/audit/session_logs/2026-02/26/_day_index_full.json",
|
||
"docs/audit/session_logs/2026-02/27/_day_index.json",
|
||
"docs/audit/session_logs/2026-02/27/_day_index_full.json",
|
||
"docs/audit/session_logs/2026-02/28/_day_index.json",
|
||
"docs/audit/session_logs/2026-02/28/_day_index_full.json",
|
||
"docs/audit/session_logs/2026-03/01/_day_index.json",
|
||
"docs/audit/session_logs/2026-03/01/_day_index_full.json",
|
||
"docs/audit/session_logs/2026-03/02/_day_index.json",
|
||
"docs/audit/session_logs/2026-03/02/_day_index_full.json",
|
||
"docs/audit/session_logs/2026-03/03/_day_index.json",
|
||
"docs/audit/session_logs/2026-03/03/_day_index_full.json",
|
||
"docs/audit/session_logs/2026-03/04/_day_index.json",
|
||
"docs/audit/session_logs/2026-03/04/_day_index_full.json"
|
||
],
|
||
"deleted": [
|
||
"docs/audit/session_logs/2026-03/20/01_53359e23_014223/main_01_34640990.md"
|
||
]
|
||
},
|
||
"compliance": {
|
||
"code_without_docs": [
|
||
{
|
||
"file": "apps/backend/app/services/fdw_queries.py",
|
||
"expected_docs": [
|
||
"apps/backend/docs/API-REFERENCE.md",
|
||
"apps/backend/README.md"
|
||
]
|
||
},
|
||
{
|
||
"file": "apps/etl/connectors/feiqiu/tasks/dws/finance_recharge_task.py",
|
||
"expected_docs": [
|
||
"apps/etl/connectors/feiqiu/docs/etl_tasks/"
|
||
]
|
||
},
|
||
{
|
||
"file": "apps/miniprogram/miniprogram/pages/board-finance/board-finance.ts",
|
||
"expected_docs": [
|
||
"apps/miniprogram/README.md"
|
||
]
|
||
}
|
||
],
|
||
"new_migration_sql": [
|
||
"db/zqyy_app/migrations/2026-03-20_rebuild_rls_view_gift_breakdown.sql",
|
||
"db/zqyy_app/migrations/2026-03-20_refresh_fdw_finance_recharge_summary.sql"
|
||
],
|
||
"has_bd_manual": false,
|
||
"has_audit_record": false,
|
||
"has_ddl_baseline": true,
|
||
"api_changed": false,
|
||
"openapi_spec_stale": false
|
||
},
|
||
"diff_stat": ".kiro/state/.compliance_state.json | 25 ++++++++++++++++++++++---\n 1 file changed, 22 insertions(+), 3 deletions(-)",
|
||
"high_risk_diff": "--- /dev/null\n+++ b/apps/backend/app/services/fdw_queries.py\n@@ -0,0 +1 @@\n# AI_CHANGELOG\n# - 2026-03-20 | Prompt: RNS1.3 FDW 列名修正 | 修正 17 处列名映射(design.md 理想名 → 实际视图列名),\n# gift_rows 每个 cell 改为 GiftCell dict 避免 Pydantic 校验失败,\n# v_dws_member_spending_power_index 降级为空列表,skill_filter 暂不生效\n\n\"\"\"\nETL RLS 视图查询封装服务\n\n直连 ETL 库(test_etl_feiqiu)查询 app.v_* RLS 视图,实现门店隔离。\n\n⚠️ 架构说明:不使用 zqyy_app 的 fdw_etl.* foreign table,而是直连 ETL 库。\n原因:postgres_fdw 不传递自定义 GUC 参数到远端连接,导致 RLS 视图的\ncurrent_setting('app.current_site_id') 在远端未设置而报错。\n直连 ETL 库后,SET LOCAL app.current_site_id 在同一连接上生效。\n\n⚠️ DWD-DOC 强制规则在此模块统一实施:\n- 规则 1: 收入使用 ledger_amount(对应 items_sum 口径)\n- 规则 2: 助教费用使用 base_income + bonus_income(对应 pd/cx 拆分)\n- DQ-6: 会员信息通过 tenant_member_id JOIN v_dim_member (scd2_is_current=1)\n- DQ-7: 会员卡通过 tenant_member_id JOIN v_dim_member_card_account (scd2_is_current=1)\n- 废单排除: WHERE is_delete = 0(RLS 视图基于 dwd_assistant_service_log 基表,\n 使用 is_delete 而非 _ex 表的 is_trash)\n\n列名映射说明(design.md 理想名 → 实际视图列名):\n app.v_dwd_assistant_service_log:\n assistant_id → site_assistant_id | member_id → tenant_member_id\n is_trash → is_delete (int, 0=正常) | settle_time → create_time\n service_hours → income_seconds/3600 | items_sum → ledger_amount\n course_type → skill_name | table_name → site_table_id (仅 ID)\n app.v_dws_assistant_salary_calc:\n calc_month → salary_month (date) | coach_level → assistant_level_name\n tier_index → tier_id | basic_hours → base_hours\n total_hours → effective_hours | total_income → gross_salary\n basic_rate → base_course_price | incentive_rate → bonus_course_price\n\"\"\"\n\nfrom __future__ import annotations\n\nimport logging\nfrom contextlib import contextmanager\nfrom decimal import Decimal\nfrom typing import Any\n\nlogger = logging.getLogger(__name__)\n\n\ndef _get_etl_connection(site_id: int):\n \"\"\"延迟导入 get_etl_readonly_connection,避免模块级导入失败。\"\"\"\n from app.database import get_etl_readonly_connection\n\n return get_etl_readonly_connection(site_id)\n\n\n@contextmanager\ndef _fdw_context(conn: Any, site_id: int):\n \"\"\"\n 上下文管理器:直连 ETL 库 + SET LOCAL app.current_site_id。\n\n ⚠️ 不使用 zqyy_app 的 fdw_etl.* foreign table,而是直连 ETL 库\n 查询 app.v_* RLS 视图。原因:postgres_fdw 不传递自定义 GUC 参数\n 到远端连接,导致 RLS 视图的 current_setting('app.current_site_id')\n 在远端未设置而报错。\n\n conn 参数保留但不用于 FDW 查询(调用方可能还需要它查 biz.* 表)。\n ETL 连接在 yield 后自动关闭。\n \"\"\"\n etl_conn = _get_etl_connection(site_id)\n try:\n with etl_conn.cursor() as cur:\n cur.execute(\"BEGIN\")\n cur.execute(\"SET LOCAL app.current_site_id = %s\", (str(site_id),))\n yield cur\n etl_conn.commit()\n finally:\n etl_conn.close()\n\n\ndef get_member_info(\n conn: Any, site_id: int, member_ids: list[int]\n) -> dict[int, dict]:\n \"\"\"\n 批量查询会员信息(昵称、手机号)。\n\n ⚠️ DQ-6: 通过 member_id 查询 app.v_dim_member,取 scd2_is_current=1,\n 禁止使用 settlement_head.member_phone/member_name。\n\n 返回 {member_id: {\"nickname\": str, \"mobile\": str}} 映射。\n \"\"\"\n if not member_ids:\n return {}\n\n result: dict[int, dict] = {}\n with _fdw_context(conn, site_id) as cur:\n cur.execute(\n \"\"\"\n SELECT member_id, nickname, mobile\n FROM app.v_dim_member\n WHERE member_id = ANY(%s) AND scd2_is_current = 1\n \"\"\",\n (member_ids,),\n )\n for row in cur.fetchall():\n result[row[0]] = {\"nickname\": row[1], \"mobile\": row[2]}\n\n return result\n\n\ndef get_member_balance(\n conn: Any, site_id: int, member_ids: list[int]\n) -> dict[int, Decimal]:\n \"\"\"\n 批量查询会员储值卡余额。\n\n ⚠️ DQ-7: 通过 tenant_member_id 关联 app.v_dim_member_card_account,\n 取 scd2_is_current=1,禁止使用 settlement_head.member_card_type_name。\n\n 返回 {member_id: balance} 映射。\n \"\"\"\n if not member_ids:\n return {}\n\n result: dict[int, Decimal] = {}\n with _fdw_context(conn, site_id) as cur:\n cur.execute(\n \"\"\"\n SELECT tenant_member_id AS member_id, balance\n FROM app.v_dim_member_card_account\n WHERE tenant_member_id = ANY(%s) AND scd2_is_current = 1\n \"\"\",\n (member_ids,),\n )\n for row in cur.fetchall():\n result[row[0]] = Decimal(str(row[1])) if row[1] is not None else Decimal(\"0\")\n\n return result\n\n\ndef get_last_visit_days(\n conn: Any, site_id: int, member_ids: list[int]\n) -> dict[int, int | None]:\n \"\"\"\n 批量查询客户距上次到店天数。\n\n 来源: app.v_dwd_assistant_service_log。\n 废单排除: is_delete = 0(RLS 视图使用 is_delete 而非 is_trash)。\n 时间字段: create_time(对应 design.md 中的 settle_time)。\n 会员字段: tenant_member_id(对应 design.md 中的 member_id)。\n\n 返回 {member_id: days_since_visit} 映射,无记录的会员不在结果中。\n \"\"\"\n if not member_ids:\n return {}\n\n result: dict[int, int | None] = {}\n with _fdw_context(conn, site_id) as cur:\n cur.execute(\n \"\"\"\n SELECT tenant_member_id,\n CURRENT_DATE - MAX(create_time::date) AS days_since_visit\n FROM app.v_dwd_assistant_service_log\n WHERE tenant_member_id = ANY(%s) AND is_delete = 0\n GROUP BY tenant_member_id\n \"\"\",\n (member_ids,),\n )\n for row in cur.fetchall():\n result[row[0]] = row[1]\n\n return result\n\n\ndef get_salary_calc(\n conn: Any, site_id: int, assistant_id: int, year: int, month: int\n) -> dict | None:\n \"\"\"\n 查询助教绩效/档位/收入数据。\n\n 来源: app.v_dws_assistant_salary_calc。\n 列名映射:\n salary_month (date, 存储为 YYYY-MM-01) → calc_month\n assistant_level_name → coach_level\n tier_id → tier_index\n base_hours → basic_hours\n effective_hours → total_hours\n gross_salary → total_income\n base_course_price → basic_rate\n bonus_course_price → incentive_rate\n sprint_bonus → bonus_money\n base_income → assistant_pd_money_total\n bonus_income → assistant_cx_money_total\n\n 不存在的字段使用默认值:\n tier_nodes → [] | total_customers → 0\n next_tier_* → 0 | tier_completed → False\n\n 返回包含档位、工时、收入等字段的 dict,无数据时返回 None。\n \"\"\"\n # salary_month 是 date 类型,存储为每月 1 号\n calc_month = f\"{year}-{month:02d}-01\"\n\n with _fdw_context(conn, site_id) as cur:\n cur.execute(\n \"\"\"\n SELECT salary_month,\n assistant_level_name,\n tier_id,\n base_hours,\n bonus_hours,\n effective_hours,\n gross_salary,\n base_course_price,\n bonus_course_price,\n sprint_bonus,\n base_income,\n bonus_income,\n room_hours,\n room_income,\n total_course_income,\n total_bonus\n FROM app.v_dws_assistant_salary_calc\n WHERE assistant_id = %s AND salary_month = %s::date\n \"\"\",\n (assistant_id, calc_month),\n )\n row = cur.fetchone()\n\n if not row:\n return None\n\n return {\n \"calc_month\": str(row[0]) if row[0] else calc_month,\n \"coach_level\": row[1] or \"\",\n \"tier_index\": row[2] or 0,\n # 视图无 tier_nodes,由 coach_service._build_tier_nodes() 从 cfg_performance_tier 读取\n \"tier_nodes\": [],\n \"basic_hours\": float(row[3]) if row[3] is not None else 0.0,\n \"bonus_hours\": float(row[4]) if row[4] is not None else 0.0,\n \"total_hours\": float(row[5]) if row[5] is not None else 0.0,\n \"total_income\": float(row[6]) if row[6] is not None else 0.0,\n # 视图无 total_customers,需要从服务记录单独统计\n \"total_customers\": 0,\n \"basic_rate\": float(row[7]) if row[7] is not None else 0.0,\n \"incentive_rate\": float(row[8]) if row[8] is not None else 0.0,\n # 视图无 next_tier 信息,由 coach_service 从 tier_nodes 推算\n \"next_tier_basic_rate\": 0.0,\n \"next_tier_incentive_rate\": 0.0,\n \"next_tier_hours\": 0.0,\n \"tier_completed\": False,\n \"bonus_money\": float(row[9]) if row[9] is not None else 0.0,\n \"assistant_pd_money_total\": float(row[10]) if row[10] is not None else 0.0,\n \"assistant_cx_money_total\": float(row[11]) if row[11] is not None else 0.0,\n # 额外字段:视图中有但 design.md 未列出,保留供后续使用\n \"room_hours\": float(row[12]) if row[12] is not None else 0.0,\n \"room_income\": float(row[13]) if row[13] is not None else 0.0,\n \"total_course_income\": float(row[14]) if row[14] is not None else 0.0,\n \"total_bonus\": float(row[15]) if row[15] is not None else 0.0,\n }\n\ndef get_performance_tiers(\n conn: Any, site_id: int\n) -> list[dict]:\n \"\"\"\n 查询当前有效的绩效档位配置。\n\n 来源: app.v_cfg_performance_tier(RLS 视图)。\n 按 tier_level 升序返回,仅包含当前日期有效的档位。\n\n ⚠️ feiqiu-data-rules 规则 6: 绩效档位必须从配置表读取,禁止硬编码。\n\n 返回 [{tier_id, tier_code, tier_name, tier_level, min_hours, max_hours}, ...]。\n \"\"\"\n with _fdw_context(conn, site_id) as cur:\n cur.execute(\n \"\"\"\n SELECT tier_id, tier_code, tier_name, tier_level,\n min_hours, max_hours\n FROM app.v_cfg_performance_tier\n WHERE effective_from <= CURRENT_DATE\n AND effective_to >= CURRENT_DATE\n ORDER BY tier_level\n \"\"\"\n )\n rows = cur.fetchall()\n\n return [\n {\n \"tier_id\": r[0],\n \"tier_code\": r[1],\n \"tier_name\": r[2],\n \"tier_level\": r[3],\n \"min_hours\": float(r[4]) if r[4] is not None else 0.0,\n \"max_hours\": float(r[5]) if r[5] is not None else None,\n }\n for r in rows\n ]\n\n\ndef get_level_map(conn: Any, site_id: int) -> dict[int, str]:\n \"\"\"\n 从 cfg_assistant_level_price 动态读取 level_code → level_name 映射。\n\n ⚠️ feiqiu-data-rules 规则 6: 等级名称必须从配置表读取,禁止硬编码。\n\n 返回 {8: \"助教管理\", 10: \"初级\", 20: \"中级\", 30: \"高级\", 40: \"星级\"}。\n 查询失败时返回空 dict(调用方应优雅降级)。\n \"\"\"\n try:\n with _fdw_context(conn, site_id) as cur:\n cur.execute(\n \"\"\"\n SELECT DISTINCT level_code, level_name\n FROM app.v_cfg_assistant_level_price\n WHERE effective_from <= CURRENT_DATE\n AND effective_to >= CURRENT_DATE\n ORDER BY level_code\n \"\"\"\n )\n return {row[0]: row[1] for row in cur.fetchall()}\n except Exception:\n return {}\n\n\ndef get_service_records(\n conn: Any,\n site_id: int,\n assistant_id: int,\n year: int,\n month: int,\n limit: int,\n offset: int,\n) -> list[dict]:\n \"\"\"\n 查询助教服务记录明细。\n\n 来源: app.v_dwd_assistant_service_log。\n 列名映射:\n assistant_service_id → id\n site_assistant_id → assistant_id(WHERE 过滤用)\n tenant_member_id → member_id\n is_delete = 0 → 废单排除(RLS 视图用 is_delete 而非 is_trash)\n create_time → settle_time\n start_use_time → start_time\n last_use_time → end_time\n income_seconds / 3600.0 → service_hours\n real_use_seconds / 3600.0 → service_hours_raw\n ledger_amount → income(items_sum 口径)\n skill_name → course_type\n\n ⚠️ DQ-6: 客户姓名通过 tenant_member_id LEFT JOIN v_dim_member (scd2_is_current=1)。\n\n 返回按 create_time DESC 排序的记录列表。\n \"\"\"\n start_date = f\"{year}-{month:02d}-01\"\n if month == 12:\n end_date = f\"{year + 1}-01-01\"\n else:\n end_date = f\"{year}-{month + 1:02d}-01\"\n\n records: list[dict] = []\n with _fdw_context(conn, site_id) as cur:\n cur.execute(\n \"\"\"\n SELECT sl.assistant_service_id,\n dm.nickname AS customer_name,\n sl.tenant_member_id,\n sl.create_time,\n sl.start_use_time,\n sl.last_use_time,\n sl.income_seconds / 3600.0 AS service_hours,\n sl.real_use_seconds / 3600.0 AS service_hours_raw,\n sl.skill_name,\n sl.site_table_id,\n sl.ledger_amount AS income\n FROM app.v_dwd_assistant_service_log sl\n LEFT JOIN app.v_dim_member dm\n ON sl.tenant_member_id = dm.member_id\n AND dm.scd2_is_current = 1\n WHERE sl.site_assistant_id = %s AND sl.is_delete = 0\n AND sl.create_time >= %s::timestamptz\n AND sl.create_time < %s::timestamptz\n ORDER BY sl.create_time DESC\n LIMIT %s OFFSET %s\n \"\"\",\n (assistant_id, start_date, end_date, limit, offset),\n )\n for row in cur.fetchall():\n records.append({\n \"id\": row[0],\n \"customer_name\": row[1],\n \"member_id\": row[2],\n \"settle_time\": row[3],\n \"start_time\": row[4],\n \"end_time\": row[5],\n \"service_hours\": float(row[6]) if row[6] is not None else 0.0,\n \"service_hours_raw\": float(row[7]) if row[7] is not None else 0.0,\n \"course_type\": row[8],\n \"table_name\": str(row[9]) if row[9] is not None else None,\n \"income\": float(row[10]) if row[10] is not None else 0.0,\n # is_estimate 不存在于视图中,默认 False\n \"is_estimate\": False,\n })\n\n return records\n\n\ndef get_service_records_for_task(\n conn: Any,\n site_id: int,\n assistant_id: int,\n member_id: int,\n limit: int,\n) -> list[dict]:\n \"\"\"\n 查询特定客户的服务记录(TASK-2 用)。\n\n 类似 get_service_records,但按 tenant_member_id 过滤,不限月份范围。\n ⚠️ 废单排除: WHERE is_delete = 0。\n ⚠️ DQ-6: 客户姓名通过 tenant_member_id LEFT JOIN v_dim_member (scd2_is_current=1)。\n\n 返回按 create_time DESC 排序的记录列表,最多 limit 条。\n \"\"\"\n records: list[dict] = []\n with _fdw_context(conn, site_id) as cur:\n cur.execute(\n \"\"\"\n SELECT sl.assistant_service_id,\n dm.nickname AS customer_name,\n sl.tenant_member_id,\n sl.create_time,\n sl.start_use_time,\n sl.last_use_time,\n sl.income_seconds / 3600.0 AS service_hours,\n sl.real_use_seconds / 3600.0 AS service_hours_raw,\n sl.skill_name,\n sl.site_table_id,\n sl.ledger_amount AS income\n FROM app.v_dwd_assistant_service_log sl\n LEFT JOIN app.v_dim_member dm\n ON sl.tenant_member_id = dm.member_id\n AND dm.scd2_is_current = 1\n WHERE sl.site_assistant_id = %s\n AND sl.tenant_member_id = %s\n AND sl.is_delete = 0\n ORDER BY sl.create_time DESC\n LIMIT %s\n \"\"\",\n (assistant_id, member_id, limit),\n )\n for row in cur.fetchall():\n records.append({\n \"id\": row[0],\n \"customer_name\": row[1],\n \"member_id\": row[2],\n \"settle_time\": row[3],\n \"start_time\": row[4],\n \"end_time\": row[5],\n \"service_hours\": float(row[6]) if row[6] is not None else 0.\n[TRUNCATED: apps/backend/app/services/fdw_queries.py diff too long]\n--- /dev/null\n+++ b/apps/etl/connectors/feiqiu/tasks/dws/finance_recharge_task.py\n@@ -0,0 +1 @@\n# -*- coding: utf-8 -*-\n\"\"\"\n充值统计任务\n\n功能说明:\n 以\"日期\"为粒度,统计充值数据\n \n数据来源:\n - dwd_recharge_order: 充值订单\n - dim_member_card_account: 会员卡账户(余额快照)\n\n目标表:\n dws.dws_finance_recharge_summary\n\n更新策略:\n - 更新频率:每日更新\n - 幂等方式:delete-before-insert(按日期)\n \n业务规则:\n - 首充/续充:通过 is_first 字段区分\n - 现金/赠送:通过 pay_amount/point_amount 区分\n - 卡余额:区分储值卡和赠送卡\n\n作者:ETL团队\n创建日期:2026-02-01\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom datetime import date\nfrom decimal import Decimal\nfrom typing import Any, Dict, List\n\nfrom neozqyy_shared.datetime_utils import biz_date_sql_expr\n\nfrom .base_dws_task import TaskContext\nfrom .finance_base_task import FinanceBaseTask\n\n\n# CHANGE 2025-07-16 | task 2.3: 继承 FinanceBaseTask,复用 _extract_recharge_summary\nclass FinanceRechargeTask(FinanceBaseTask):\n \"\"\"\n 充值统计任务\n \"\"\"\n\n # CHANGE 2025-07-15 | task 1.3: 声明日期列,供基类默认 load() 使用\n DATE_COL = \"stat_date\"\n \n def get_task_code(self) -> str:\n return \"DWS_FINANCE_RECHARGE\"\n \n def get_target_table(self) -> str:\n return \"dws_finance_recharge_summary\"\n \n def get_primary_keys(self) -> List[str]:\n return [\"site_id\", \"stat_date\"]\n \n def extract(self, context: TaskContext) -> Dict[str, Any]:\n start_date = context.window_start.date() if hasattr(context.window_start, 'date') else context.window_start\n end_date = context.window_end.date() if hasattr(context.window_end, 'date') else context.window_end\n site_id = context.store_id\n \n recharge_summary = self._extract_recharge_summary(site_id, start_date, end_date)\n card_balances = self._extract_card_balances(site_id, end_date)\n # CHANGE 2026-07-18 | task 4.1: 调用赠送卡新增充值拆分方法\n gift_recharge_breakdown = self._extract_gift_recharge_breakdown(site_id, start_date, end_date)\n \n return {\n 'recharge_summary': recharge_summary,\n 'card_balances': card_balances,\n 'gift_recharge_breakdown': gift_recharge_breakdown,\n 'start_date': start_date,\n 'end_date': end_date,\n 'site_id': site_id\n }\n \n def transform(self, extracted: Dict[str, Any], context: TaskContext) -> List[Dict[str, Any]]:\n recharge_summary = extracted['recharge_summary']\n card_balances = extracted['card_balances']\n # CHANGE 2026-07-18 | task 4.2: 提取赠送卡新增充值拆分数据\n gift_recharge_breakdown = extracted.get('gift_recharge_breakdown', {})\n site_id = extracted['site_id']\n \n results = []\n for recharge in recharge_summary:\n stat_date = recharge.get('stat_date')\n \n # 仅有当前快照时,统一写入(避免窗口内其他日期为0)\n balance = card_balances\n \n record = {\n 'site_id': site_id,\n 'tenant_id': self.config.get(\"app.tenant_id\", site_id),\n 'stat_date': stat_date,\n 'recharge_count': self.safe_int(recharge.get('recharge_count', 0)),\n 'recharge_total': self.safe_decimal(recharge.get('recharge_total', 0)),\n 'recharge_cash': self.safe_decimal(recharge.get('recharge_cash', 0)),\n 'recharge_gift': self.safe_decimal(recharge.get('recharge_gift', 0)),\n 'first_recharge_count': self.safe_int(recharge.get('first_recharge_count', 0)),\n 'first_recharge_cash': self.safe_decimal(recharge.get('first_recharge_cash', 0)),\n 'first_recharge_gift': self.safe_decimal(recharge.get('first_recharge_gift', 0)),\n 'first_recharge_total': self.safe_decimal(recharge.get('first_recharge_total', 0)),\n 'renewal_count': self.safe_int(recharge.get('renewal_count', 0)),\n 'renewal_cash': self.safe_decimal(recharge.get('renewal_cash', 0)),\n 'renewal_gift': self.safe_decimal(recharge.get('renewal_gift', 0)),\n 'renewal_total': self.safe_decimal(recharge.get('renewal_total', 0)),\n 'recharge_member_count': self.safe_int(recharge.get('recharge_member_count', 0)),\n 'new_member_count': self.safe_int(recharge.get('new_member_count', 0)),\n 'total_card_balance': self.safe_decimal(balance.get('total_balance', 0)),\n 'cash_card_balance': self.safe_decimal(balance.get('cash_balance', 0)),\n 'gift_card_balance': self.safe_decimal(balance.get('gift_balance', 0)),\n # CHANGE 2026-07-18 | task 4.2: 赠送卡细分余额(来自 card_balances)\n 'gift_liquor_balance': self.safe_decimal(balance.get('gift_liquor_balance', 0)),\n 'gift_table_fee_balance': self.safe_decimal(balance.get('gift_table_fee_balance', 0)),\n 'gift_voucher_balance': self.safe_decimal(balance.get('gift_voucher_balance', 0)),\n # CHANGE 2026-07-18 | task 4.2: 赠送卡细分新增充值(来自 gift_recharge_breakdown)\n 'gift_liquor_recharge': self.safe_decimal(gift_recharge_breakdown.get('gift_liquor_recharge', 0)),\n 'gift_table_fee_recharge': self.safe_decimal(gift_recharge_breakdown.get('gift_table_fee_recharge', 0)),\n 'gift_voucher_recharge': self.safe_decimal(gift_recharge_breakdown.get('gift_voucher_recharge', 0)),\n }\n results.append(record)\n \n return results\n \n # load() 已移除——使用 BaseDwsTask 默认实现(DATE_COL=\"stat_date\")\n\n def _extract_recharge_summary(self, site_id: int, start_date: date, end_date: date) -> List[Dict[str, Any]]:\n # CHANGE 2026-02-21 | BUG 8: dwd_recharge_order 无 pay_money/gift_money,实际字段为 pay_amount/point_amount\n cutoff = self.config.get(\"app.business_day_start_hour\", 8)\n biz_expr = biz_date_sql_expr(\"pay_time\", cutoff)\n sql = f\"\"\"\n SELECT \n {biz_expr} AS stat_date,\n COUNT(*) AS recharge_count,\n SUM(pay_amount + point_amount) AS recharge_total,\n SUM(pay_amount) AS recharge_cash,\n SUM(point_amount) AS recharge_gift,\n COUNT(CASE WHEN is_first = 1 THEN 1 END) AS first_recharge_count,\n SUM(CASE WHEN is_first = 1 THEN pay_amount ELSE 0 END) AS first_recharge_cash,\n SUM(CASE WHEN is_first = 1 THEN point_amount ELSE 0 END) AS first_recharge_gift,\n SUM(CASE WHEN is_first = 1 THEN pay_amount + point_amount ELSE 0 END) AS first_recharge_total,\n COUNT(CASE WHEN is_first != 1 OR is_first IS NULL THEN 1 END) AS renewal_count,\n SUM(CASE WHEN is_first != 1 OR is_first IS NULL THEN pay_amount ELSE 0 END) AS renewal_cash,\n SUM(CASE WHEN is_first != 1 OR is_first IS NULL THEN point_amount ELSE 0 END) AS renewal_gift,\n SUM(CASE WHEN is_first != 1 OR is_first IS NULL THEN pay_amount + point_amount ELSE 0 END) AS renewal_total,\n COUNT(DISTINCT member_id) AS recharge_member_count,\n COUNT(DISTINCT CASE WHEN is_first = 1 THEN member_id END) AS new_member_count\n FROM dwd.dwd_recharge_order\n WHERE site_id = %s AND {biz_expr} >= %s AND {biz_expr} <= %s\n GROUP BY {biz_expr}\n \"\"\"\n rows = self.db.query(sql, (site_id, start_date, end_date))\n return [dict(row) for row in rows] if rows else []\n \n # CHANGE 2026-07-17 | task 2.1: card_type_id → 细分余额字段名映射\n GIFT_TYPE_FIELD_MAP = {\n 2794699703437125: 'gift_liquor_balance', # 酒水卡\n 2791990152417157: 'gift_table_fee_balance', # 台费卡\n 2793266846533445: 'gift_voucher_balance', # 抵用券\n }\n\n # CHANGE 2026-07-18 | task 3.1: card_type_id → 细分充值字段名映射\n GIFT_RECHARGE_FIELD_MAP = {\n 2794699703437125: 'gift_liquor_recharge', # 酒水卡\n 2791990152417157: 'gift_table_fee_recharge', # 台费卡\n 2793266846533445: 'gift_voucher_recharge', # 抵用券\n }\n\n # CHANGE 2026-07-18 | task 3.1: 按 card_type_id 拆分赠送卡新增充值\n def _extract_gift_recharge_breakdown(self, site_id: int, start_date: date, end_date: date) -> Dict[str, Decimal]:\n \"\"\"按卡类型拆分赠送卡新增充值(JOIN 充值订单与会员卡账户维度表)\"\"\"\n cutoff = self.config.get(\"app.business_day_start_hour\", 8)\n biz_expr = biz_date_sql_expr(\"ro.pay_time\", cutoff)\n sql = f\"\"\"\n SELECT dca.card_type_id, SUM(ro.point_amount) AS gift_recharge\n FROM dwd.dwd_recharge_order ro\n JOIN dwd.dim_member_card_account dca\n ON ro.tenant_member_card_id = dca.tenant_member_id\n WHERE ro.site_id = %s\n AND {biz_expr} >= %s AND {biz_expr} <= %s\n AND dca.card_type_id IN (2794699703437125, 2791990152417157, 2793266846533445)\n AND dca.scd2_is_current = 1\n AND COALESCE(dca.is_delete, 0) = 0\n GROUP BY dca.card_type_id\n \"\"\"\n rows = self.db.query(sql, (site_id, start_date, end_date))\n\n result: Dict[str, Decimal] = {\n field: Decimal('0') for field in self.GIFT_RECHARGE_FIELD_MAP.values()\n }\n for row in (rows or []):\n field_name = self.GIFT_RECHARGE_FIELD_MAP.get(row['card_type_id'])\n if field_name:\n result[field_name] = self.safe_decimal(row['gift_recharge'])\n return result\n\n def _extract_card_balances(self, site_id: int, stat_date: date) -> Dict[str, Decimal]:\n CASH_CARD_TYPE_ID = 2793249295533893\n GIFT_CARD_TYPE_IDS = [2791990152417157, 2793266846533445, 2794699703437125]\n \n # CHANGE 2026-02-21 | dim_member_card_account 无 site_id 字段,改用 register_site_id\n # CHANGE 2026-02-22 | 需求 B:通过事实表反查,支持跨店消费会员\n sql = \"\"\"\n SELECT card_type_id, SUM(balance) AS total_balance\n FROM dwd.dim_member_card_account\n WHERE tenant_member_id IN (\n SELECT DISTINCT member_id\n FROM dwd.dwd_recharge_order\n WHERE site_id = %s\n AND member_id IS NOT NULL\n AND member_id != 0\n ) AND scd2_is_current = 1\n AND COALESCE(is_delete, 0) = 0\n GROUP BY card_type_id\n \"\"\"\n rows = self.db.query(sql, (site_id,))\n \n cash_balance = Decimal('0')\n gift_balance = Decimal('0')\n # CHANGE 2026-07-17 | task 2.1: 按 card_type_id 拆分赠送卡余额\n gift_breakdown: Dict[str, Decimal] = {\n field: Decimal('0') for field in self.GIFT_TYPE_FIELD_MAP.values()\n }\n \n for row in (rows or []):\n card_type_id = row['card_type_id']\n balance = self.safe_decimal(row['total_balance'])\n if card_type_id == CASH_CARD_TYPE_ID:\n cash_balance += balance\n elif card_type_id in GIFT_CARD_TYPE_IDS:\n gift_balance += balance\n # 写入细分字段(未知 card_type_id 不会命中 GIFT_TYPE_FIELD_MAP)\n field_name = self.GIFT_TYPE_FIELD_MAP.get(card_type_id)\n if field_name:\n gift_breakdown[field_name] += balance\n \n return {\n 'cash_balance': cash_balance,\n 'gift_balance': gift_balance,\n 'total_balance': cash_balance + gift_balance,\n **gift_breakdown,\n }\n\n\n__all__ = ['FinanceRechargeTask']\n\n--- /dev/null\n+++ b/apps/miniprogram/miniprogram/pages/board-finance/board-finance.ts\n@@ -0,0 +1 @@\n// 财务看板页 — 忠于 H5 原型结构\n// CHANGE 2026-07-22 | Prompt: gift-card-breakdown Task 9.1 | 直接原因: 赠送卡矩阵 giftRows 从 mock 替换为真实 API 数据\n\nimport { getRandomAiColor } from '../../utils/ai-color'\nimport { fetchBoardFinance } from '../../services/api'\nimport { formatMoney } from '../../utils/money'\n\n/** 目录板块定义 */\ninterface TocItem {\n emoji: string\n title: string\n sectionId: string\n}\n\n/** 指标解释映射 */\nconst tipContents: Record<string, { title: string; content: string }> = {\n occurrence: {\n title: '发生额/正价',\n content: '所有消费项目按标价计算的总金额,不扣除任何优惠。\\n\\n即\"如果没有任何折扣,客户应付多少\"。',\n },\n discount: {\n title: '总优惠',\n content: '包含会员折扣、赠送卡抵扣、团购差价等所有优惠金额。\\n\\n优惠越高,实际收入越低。',\n },\n confirmed: {\n title: '成交/确认收入',\n content: '发生额减去总优惠后的实际入账金额。\\n\\n成交收入 = 发生额 - 总优惠',\n },\n cashIn: {\n title: '实收/现金流入',\n content: '实际到账的现金,包含消费直接支付和储值充值。\\n\\n往期为已结算金额,本期为截至当前的发生额。',\n },\n cashOut: {\n title: '现金支出',\n content: '包含人工、房租、水电、进货等所有经营支出。',\n },\n balance: {\n title: '现金结余',\n content: '现金流入减去现金支出。\\n\\n现金结余 = 现金流入 - 现金支出',\n },\n rechargeActual: {\n title: '储值卡充值实收',\n content: '会员储值卡首充和续费的实际到账金额。\\n\\n不含赠送金额。',\n },\n firstCharge: {\n title: '首充',\n content: '新会员首次充值的金额。',\n },\n renewCharge: {\n title: '续费',\n content: '老会员续费充值的金额。',\n },\n consume: {\n title: '消耗',\n content: '会员使用储值卡消费的金额。',\n },\n cardBalance: {\n title: '储值卡总余额',\n content: '所有储值卡的剩余可用余额。',\n },\n allCardBalance: {\n title: '全类别会员卡余额合计',\n content: '储值卡 + 赠送卡(酒水卡、台费卡、抵用券)的总余额。\\n\\n仅供经营参考,非财务属性。',\n },\n}\n\nPage({\n data: {\n pageState: 'normal' as 'loading' | 'empty' | 'error' | 'normal',\n\n /** AI 配色 */\n aiColorClass: '',\n\n /** 时间筛选 */\n selectedTime: 'month',\n selectedTimeText: '本月',\n timeOptions: [\n { value: 'month', text: '本月' },\n { value: 'lastMonth', text: '上月' },\n { value: 'week', text: '本周' },\n { value: 'lastWeek', text: '上周' },\n { value: 'quarter3', text: '前3个月 不含本月' },\n { value: 'quarter', text: '本季度' },\n { value: 'lastQuarter', text: '上季度' },\n { value: 'half6', text: '最近6个月不含本月' },\n ],\n\n /** 区域筛选 */\n selectedArea: 'all',\n selectedAreaText: '全部区域',\n areaOptions: [\n { value: 'all', text: '全部区域' },\n { value: 'hall', text: '大厅' },\n { value: 'hallA', text: 'A区' },\n { value: 'hallB', text: 'B区' },\n { value: 'hallC', text: 'C区' },\n { value: 'mahjong', text: '麻将房' },\n { value: 'teamBuilding', text: '团建房' },\n ],\n\n /** 环比开关 */\n compareEnabled: false,\n\n /** 目录导航 */\n tocVisible: false,\n tocItems: [\n { emoji: '📈', title: '经营一览', sectionId: 'section-overview' },\n { emoji: '💳', title: '预收资产', sectionId: 'section-recharge' },\n { emoji: '💰', title: '应计收入确认', sectionId: 'section-revenue' },\n { emoji: '🧾', title: '现金流入', sectionId: 'section-cashflow' },\n { emoji: '📤', title: '现金流出', sectionId: 'section-expense' },\n { emoji: '🎱', title: '助教分析', sectionId: 'section-coach' },\n ] as TocItem[],\n currentSectionIndex: 0,\n\n /** P1: 吸顶板块头(H5: scaleX 从左滑入,同时筛选按钮 opacity 淡出) */\n stickyHeaderVisible: false,\n stickyHeaderEmoji: '',\n stickyHeaderTitle: '',\n stickyHeaderDesc: '',\n\n /** 提示弹窗 */\n tipVisible: false,\n tipTitle: '',\n tipContent: '',\n\n /** 经营一览 */\n overview: {\n occurrence: '¥823,456',\n occurrenceCompare: '12.5%',\n discount: '-¥113,336',\n discountCompare: '3.2%',\n discountRate: '13.8%',\n discountRateCompare: '1.5%',\n confirmedRevenue: '¥710,120',\n confirmedCompare: '8.7%',\n cashIn: '¥698,500',\n cashInCompare: '5.3%',\n cashOut: '¥472,300',\n cashOutCompare: '2.1%',\n cashBalance: '¥226,200',\n cashBalanceCompare: '15.2%',\n balanceRate: '32.4%',\n balanceRateCompare: '3.8%',\n },\n\n /** 预收资产 */\n recharge: {\n actualIncome: '¥352,800',\n actualCompare: '18.5%',\n firstCharge: '¥188,500',\n firstChargeCompare: '12.3%',\n renewCharge: '¥164,300',\n renewChargeCompare: '8.7%',\n consumed: '¥238,200',\n consumedCompare: '5.2%',\n cardBalance: '¥642,600',\n cardBalanceCompare: '11.4%',\n giftRows: [] as Array<{\n label: string; total: string; totalCompare: string;\n wine: string; wineCompare: string;\n table: string; tableCompare: string;\n coupon: string; couponCompare: string;\n }>,\n allCardBalance: '¥586,500',\n allCardBalanceCompare: '6.2%',\n },\n\n /** 应计收入确认 */\n revenue: {\n structureRows: [\n { id: 'table', name: '开台与包厢', amount: '¥358,600', discount: '-¥45,200', booked: '¥313,400', bookedCompare: '9.2%' },\n { id: 'area-a', name: 'A区', amount: '¥118,200', discount: '-¥11,600', booked: '¥106,600', bookedCompare: '12.1%', isSub: true },\n { id: 'area-b', name: 'B区', amount: '¥95,800', discount: '-¥11,200', booked: '¥84,600', bookedCompare: '8.5%', isSub: true },\n { id: 'area-c', name: 'C区', amount: '¥72,600', discount: '-¥11,100', booked: '¥61,500', bookedCompare: '6.3%', isSub: true },\n { id: 'team', name: '团建区', amount: '¥48,200', discount: '-¥6,800', booked: '¥41,400', bookedCompare: '5.8%', isSub: true },\n { id: 'mahjong', name: '麻将区', amount: '¥23,800', discount: '-¥4,500', booked: '¥19,300', bookedCompare: '-2.1%', isSub: true },\n { id: 'coach-basic', name: '助教', desc: '基础课', amount: '¥232,500', discount: '-', booked: '¥232,500', bookedCompare: '15.3%' },\n { id: 'coach-incentive', name: '助教', desc: '激励课', amount: '¥112,800', discount: '-', booked: '¥112,800', bookedCompare: '8.2%' },\n { id: 'food', name: '食品酒水', amount: '¥119,556', discount: '-¥68,136', booked: '¥51,420', bookedCompare: '6.5%' },\n ],\n priceItems: [\n { name: '开台消费', value: '¥358,600', compare: '9.2%' },\n { name: '酒水商品', value: '¥186,420', compare: '18.5%' },\n { name: '包厢费用', value: '¥165,636', compare: '12.1%' },\n { name: '助教服务', value: '¥112,800', compare: '15.3%' },\n ],\n totalOccurrence: '¥823,456',\n totalOccurrenceCompare: '12.5%',\n discountItems: [\n { name: '团购优惠', value: '-¥56,200', compare: '5.2%' },\n { name: '手动调整 + 大客户优惠', value: '-¥34,800', compare: '3.1%' },\n { name: '赠送卡抵扣', desc: '台桌卡+酒水卡+抵用券', value: '-¥22,336', compare: '8.6%' },\n { name: '其他优惠', desc: '免单+抹零', value: '-¥0', compare: '' },\n ],\n confirmedTotal: '¥710,120',\n confirmedTotalCompare: '8.7%',\n channelItems: [\n { name: '储值卡结算冲销', value: '¥238,200', compare: '11.2%' },\n { name: '现金/线上支付', value: '¥345,800', compare: '7.8%' },\n { name: '团购核销确认收入', desc: '团购成交价', value: '¥126,120', compare: '5.3%' },\n ],\n },\n\n /** 现金流入 */\n cashflow: {\n consumeItems: [\n { name: '纸币现金', desc: '柜台现金收款', value: '¥85,600', compare: '12.3%', isDown: true },\n { name: '线上收款', desc: '微信/支付宝/刷卡 已扣除平台服务费', value: '¥260,200', compare: '8.5%', isDown: false },\n { name: '团购平台', desc: '美团/抖音回款 已扣除平台服务费', value: '¥126,120', compare: '15.2%', isDown: false },\n ],\n rechargeItems: [\n { name: '会员充值到账', desc: '首充/续费实收', value: '¥352,800', compare: '18.5%' },\n ],\n total: '¥824,720',\n totalCompare: '10.2%',\n },\n\n /** 现金流出 */\n expense: {\n operationItems: [\n { name: '食品饮料', value: '¥108,200', compare: '4.5%', isDown: false },\n { name: '耗材', value: '¥21,850', compare: '2.1%', isDown: true },\n { name: '报销', value: '¥10,920', compare: '6.8%', isDown: false },\n ],\n fixedItems: [\n { name: '房租', value: '¥125,000', compare: '持平', isFlat: true },\n { name: '水电', value: '¥24,200', compare: '3.2%', isFlat: false },\n { name: '物业', value: '¥11,500', compare: '持平', isFlat: true },\n { name: '人员工资', value: '¥112,000', compare: '持平', isFlat: true },\n ],\n coachItems: [\n { name: '基础课分成', value: '¥116,250', compare: '8.2%', isDown: false },\n { name: '激励课分成', value: '¥23,840', compare: '5.6%', isDown: false },\n { name: '充值提成', value: '¥12,640', compare: '12.3%', isDown: false },\n { name: '额外奖金', value: '¥11,500', compare: '3.1%', isDown: true },\n ],\n platformItems: [\n { name: '汇来米', value: '¥10,680', compare: '1.5%' },\n { name: '美团', value: '¥11,240', compare: '2.8%' },\n { name: '抖音', value: '¥10,580', compare: '3.5%' },\n ],\n total: '¥600,400',\n totalCompare: '2.1%',\n },\n\n /** 助教分析 */\n coachAnalysis: {\n basic: {\n totalPay: '¥232,500',\n totalPayCompare: '15.3%',\n totalShare: '¥116,250',\n totalShareCompare: '15.3%',\n avgHourly: '¥25/h',\n avgHourlyCompare: '4.2%',\n rows: [\n { level: '初级', pay: '¥68,600', payCompare: '12.5%', share: '¥34,300', shareCompare: '12.5%', hourly: '¥20/h', hourlyCompare: '持平', hourlyFlat: true },\n { level: '中级', pay: '¥82,400', payCompare: '18.2%', share: '¥41,200', shareCompare: '18.2%', hourly: '¥25/h', hourlyCompare: '8.7%' },\n { level: '高级', pay: '¥57,800', payCompare: '14.6%', share: '¥28,900', shareCompare: '14.6%', hourly: '¥30/h', hourlyCompare: '持平', hourlyFlat: true },\n { level: '星级', pay: '¥23,700', payCompare: '3.2%', payDown: true, share: '¥11,850', shareCompare: '3.2%', shareDown: true, hourly: '¥35/h', hourlyCompare: '持平', hourlyFlat: true },\n ],\n },\n incentive: {\n totalPay: '¥112,800',\n totalPayCompare: '8.2%',\n totalShare: '¥33,840',\n totalShareCompare: '8.2%',\n avgHourly: '¥15/h',\n avgHourlyCompare: '2.1%',\n rows: [\n { level: '初级', pay: '¥32,400', payCompare: '6.8%', share: '¥9,720', shareCompare: '6.8%', hourly: '¥12/h', hourlyCompare: '持平', hourlyFlat: true },\n { level: '中级', pay: '¥38,600', payCompare: '10.5%', share: '¥11,580', shareCompare: '10.5%', hourly: '¥15/h', hourlyCompare: '5.2%' },\n { level: '高级', pay: '¥28,200', payCompare: '7.3%', share: '¥8,460', shareCompare: '7.3%', hourly: '¥18/h', hourlyCompare: '持平', hourlyFlat: true },\n { level: '星级', pay: '¥13,600', payCompare: '2.1%', payDown: true, share: '¥4,080', shareCompare: '2.1%', shareDown: true, hourly: '¥22/h', hourlyCompare: '持平', hourlyFlat: true },\n ],\n },\n },\n },\n\n onLoad() {\n // P5: AI 配色\n const aiColor = getRandomAiColor()\n this.setData({ aiColorClass: aiColor.className })\n },\n\n onShow() {\n // 同步 custom-tab-bar 选中态\n const tabBar = this.getTabBar?.()\n if (tabBar) tabBar.setData({ active: 'board' })\n // 加载赠送卡矩阵真实数据\n this._loadGiftRows()\n },\n\n onReady() {\n // P1: 缓存各 section 的 top 位置\n this._cacheSectionPositions()\n },\n\n /** P1/P2: 页面滚动监听(节流 100ms)— 匹配 H5 原型行为 */\n /* CHANGE 2026-03-13 | intent: H5 原型下滑→显示吸顶头+隐藏筛选按钮,上滑→隐藏吸顶头+恢复筛选按钮;不再使用独立的 filterBarHidden 状态 */\n onPageScroll(e: { scrollTop: number }) {\n const now = Date.now()\n if (now - this._lastScrollTime < 100) return\n this._lastScrollTime = now\n\n const scrollTop = e.scrollTop\n const isScrollingDown = scrollTop > this._lastScrollTop\n this._lastScrollTop = scrollTop\n\n // P1: 吸顶板块头 — 与 H5 updateStickyHeader 逻辑对齐\n if (this._sectionTops.length === 0) return\n\n // 偏移量:tabs(~78rpx) + filter-bar(~70rpx) 约 148rpx ≈ 93px,取 100 作为阈值\n const offset = 100\n let currentIdx = 0\n for (let i = this._sectionTops.length - 1; i >= 0; i--) {\n if (scrollTop + offset >= this._sectionTops[i]) {\n currentIdx = i\n break\n }\n }\n\n // H5: scrollY < 80 时隐藏吸顶头\n if (scrollTop < 80) {\n if (this.data.stickyHeaderVisible) {\n this.setData({ stickyHeaderVisible: false })\n }\n return\n }\n\n const toc = this.data.tocItems[currentIdx]\n\n if (isScrollingDown && !this.data.stickyHeaderVisible) {\n // H5: 下滑且吸顶头未显示 → 显示吸顶头(筛选按钮通过 CSS opacity 自动淡出)\n this.setData({\n stickyHeaderVisible: true,\n stickyHeaderEmoji: toc?.emoji || '',\n stickyHeaderTitle: toc?.title || '',\n stickyHeaderDesc: toc ? (this._getSectionDesc(currentIdx) || '') : '',\n currentSectionIndex: currentIdx,\n })\n } else if (!isScrollingDown && this.data.stickyHeaderVisible) {\n // H5: 上滑且吸顶头显示 → 隐藏吸顶头(筛选按钮通过 CSS opacity 自动恢复)\n this.setData({ stickyHeaderVisible: false })\n } else if (this.data.stickyHeaderVisible && currentIdx !== this.data.currentSectionIndex) {\n // H5: 吸顶头显示时板块切换 → 更新内容\n this.setData({\n stickyHeaderEmoji: toc?.emoji || '',\n stickyHeaderTitle: toc?.title || '',\n stickyHeaderDesc: toc ? (this._getSectionDesc(currentIdx) || '') : '',\n currentSectionIndex: currentIdx,\n })\n }\n },\n\n /** 缓存 section 位置(私有) */\n _sectionTops: [] as number[],\n _lastScrollTop: 0,\n _lastScrollTime: 0,\n\n /** H5 原型吸顶头包含板块描述,从 data-section-desc 映射 */\n _sectionDescs: [\n '快速了解收入与现金流的整体健康度',\n '会员卡充值与余额 掌握资金沉淀',\n '从发生额到入账收入的全流程',\n '实际到账的资金来源明细',\n '清晰呈现各类开销与结构',\n '全部助教服务收入与分成的平均值',\n ] as string[],\n\n _getSectionDesc(index: number): string {\n return this._sectionDescs[index] || ''\n },\n\n _cacheSectionPositions() {\n const sectionIds = this.data.tocItems.map(item => item.sectionId)\n const query = wx.createSelectorQuery().in(this)\n sectionIds.forEach(id => {\n query.select(`#${id}`).boundingClientRect()\n })\n query.exec((results: Array<WechatMiniprogram.BoundingClientRectCallbackResult | null>) => {\n if (!results) return\n this._sectionTops = results.map(r => (r ? r.top : 0))\n })\n },\n\n /**\n * 从 Finance_Board_API 加载赠送卡矩阵数据\n * 字段映射:liquor→wine、tableFee→table、voucher→coupon\n */\n async _loadGiftRows() {\n try {\n const data = await fetchBoardFinance({\n time: this.data.selectedTime,\n area: this.data.selectedArea,\n compare: this.data.compareEnabled ? 1 : 0,\n })\n // API 返回 camelCase:giftRows[].liquor/tableFee/voucher,每个是 GiftCell {value, compare?, down?, flat?}\n const rechargePanel = data.rechargePanel || data\n const apiRows = rechargePanel.giftRows || []\n const giftRows = apiRows.map((row: any) => ({\n label: row.label || '',\n total: formatMoney(row.total?.value),\n totalCompare: row.total?.compare || '',\n wine: formatMoney(row.liquor?.value),\n wineCompare: row.liquor?.compare || '',\n table: formatMoney(row.tableFee?.value),\n tableCompare: row.tableFee?.compare || '',\n coupon: formatMoney(row.voucher?.value),\n couponCompare: row.voucher?.compare || '',\n }))\n this.setData({ 'recharge.giftRows': giftRows })\n } catch (err) {\n console.error('[board-finance] 赠送卡数据加载失败', err)\n // 加载失败时清空 giftRows,不显示 mock 数据\n this.setData({ 'recharge.giftRows': [] })\n wx.showToast({ title: '赠送卡数据加载失败', icon: 'none', duration: 2000 })\n }\n },\n\n onPullDownRefresh() {\n this._loadGiftRows()\n setTimeout(() => w\n[TRUNCATED: apps/miniprogram/miniprogram/pages/board-finance/board-finance.ts diff too long]\n\n[TRUNCATED: diff exceeds 30KB]",
|
||
"latest_prompt_log": "- [P20260320-032539] 2026-03-20 03:25:39 +0800\n - summary: DWD-DOC/DWS-DOC 权威文档涉及字段等,也都遵循了?\n - prompt:\n```text\nDWD-DOC/DWS-DOC 权威文档涉及字段等,也都遵循了?\n```\n"
|
||
} |