Files
Neo-ZQYY/.kiro/state/.audit_context.json
2026-03-20 03:26:43 +08:00

145 lines
54 KiB
JSON
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"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 = 0RLS 视图基于 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 = 0RLS 视图使用 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_tierRLS 视图)。\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_idWHERE 过滤用)\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 → incomeitems_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 返回 camelCasegiftRows[].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"
}