# AI_CHANGELOG
# - 2026-03-20 | Prompt: RNS1.3 E2E 修复 | _build_recharge 环比比较逻辑修正（compare 参数传递），
#   _empty_revenue 新增确保包含所有必需字段，skills 暂返回空列表

"""
看板业务逻辑服务层。

提供 BOARD-1（助教看板）、BOARD-2（客户看板）、BOARD-3（财务看板）的
日期范围计算、环比计算等通用工具函数，以及各看板的编排函数。
"""

from __future__ import annotations

import calendar
from datetime import date, timedelta
from decimal import Decimal, ROUND_HALF_UP

from app.trace.decorators import trace_service


# ---------------------------------------------------------------------------
# 通用工具函数
# ---------------------------------------------------------------------------


def _calc_date_range(
    time_enum: str, ref_date: date | None = None
) -> tuple[date, date]:
    """
    根据时间枚举计算当期日期范围。

    支持 BOARD-1 的 6 种枚举（snake_case）和 BOARD-3 的 8 种枚举（camelCase），
    同义枚举（如 last_month / lastMonth）映射到相同逻辑。

    返回 (start_date, end_date)，均为 date 类型，end_date 为区间末日（含）。
    """
    today = ref_date or date.today()

    # --- 当月（cap 到今天）---
    if time_enum == "month":
        start = today.replace(day=1)
        end = today
        return start, end

    # --- 上月 ---
    if time_enum in ("last_month", "lastMonth"):
        first_of_this_month = today.replace(day=1)
        last_month_end = first_of_this_month - timedelta(days=1)
        last_month_start = last_month_end.replace(day=1)
        return last_month_start, last_month_end

    # --- 本周（周一 ~ 今天）---
    if time_enum == "week":
        monday = today - timedelta(days=today.weekday())
        end = today
        return monday, end

    # --- 上周 ---
    if time_enum == "lastWeek":
        this_monday = today - timedelta(days=today.weekday())
        last_sunday = this_monday - timedelta(days=1)
        last_monday = last_sunday - timedelta(days=6)
        return last_monday, last_sunday

    # --- 本季度（cap 到今天）---
    if time_enum == "quarter":
        q_start_month = (today.month - 1) // 3 * 3 + 1
        start = date(today.year, q_start_month, 1)
        end = today
        return start, end

    # --- 上季度 ---
    if time_enum in ("last_quarter", "lastQuarter"):
        q_start_month = (today.month - 1) // 3 * 3 + 1
        # 上季度末日 = 本季度首日前一天
        this_q_start = date(today.year, q_start_month, 1)
        prev_q_end = this_q_start - timedelta(days=1)
        prev_q_start_month = (prev_q_end.month - 1) // 3 * 3 + 1
        prev_q_start = date(prev_q_end.year, prev_q_start_month, 1)
        return prev_q_start, prev_q_end

    # --- 前 3 个月（不含本月）---
    if time_enum in ("last_3m", "quarter3"):
        # end = 上月末日
        first_of_this_month = today.replace(day=1)
        end = first_of_this_month - timedelta(days=1)
        # start = 往前推 3 个月的首日
        start = _month_offset(first_of_this_month, -3)
        return start, end

    # --- 前 6 个月（不含本月）---
    if time_enum in ("last_6m", "half6"):
        first_of_this_month = today.replace(day=1)
        end = first_of_this_month - timedelta(days=1)
        start = _month_offset(first_of_this_month, -6)
        return start, end

    raise ValueError(f"不支持的时间枚举: {time_enum}")


def _month_offset(d: date, months: int) -> date:
    """将日期 d 偏移 months 个月，保持 day=1。仅用于内部月份偏移计算。"""
    # d 应为某月 1 日
    total_months = d.year * 12 + (d.month - 1) + months
    y, m = divmod(total_months, 12)
    m += 1
    return date(y, m, 1)


def _calc_prev_range(
    time_enum: str, start_date: date, end_date: date
) -> tuple[date, date]:
    """
    根据当期范围和周期类型计算上期同期日期范围。

    CHANGE 2026-03-28 | 环比改为同期对比：
    - month: 当期 3/1~3/28 → 上期 2/1~2/28（上月同日）
    - week: 当期 周一~周四 → 上期 上周一~上周四（上周同天数）
    - quarter: 当期 1/1~3/28 → 上期 去年10/1~10/28 对应天数
    - lastMonth: 2/1~2/28 → 1/1~1/28（再上月同天数）
    - lastWeek: 上周一~上周日 → 再上周一~再上周日
    - lastQuarter: 上季度完整 → 再上季度完整
    - quarter3/half6: 往前推等长天数（无明确"同期"概念）
    """
    elapsed_days = (end_date - start_date).days  # 当期已过天数（0-indexed）

    # 月度类：上月1日 + 同样天数
    if time_enum in ("month", "last_month", "lastMonth"):
        prev_start = _month_offset(start_date, -1)
        # 上月同日，但不超过上月末日
        prev_end_day = min(end_date.day, calendar.monthrange(prev_start.year, prev_start.month)[1])
        prev_end = prev_start.replace(day=prev_end_day)
        return prev_start, prev_end

    # 周度类：往前推 7 天
    if time_enum in ("week", "lastWeek"):
        prev_start = start_date - timedelta(days=7)
        prev_end = end_date - timedelta(days=7)
        return prev_start, prev_end

    # 季度类：上季度首日 + 同样天数
    if time_enum in ("quarter", "last_quarter", "lastQuarter"):
        prev_q_start = _month_offset(start_date, -3)
        prev_end = prev_q_start + timedelta(days=elapsed_days)
        # 不超过上季度末日
        prev_q_end_month = prev_q_start.month + 2
        prev_q_end_max = date(prev_q_start.year, prev_q_end_month,
                              calendar.monthrange(prev_q_start.year, prev_q_end_month)[1])
        if prev_end > prev_q_end_max:
            prev_end = prev_q_end_max
        return prev_q_start, prev_end

    # 其他（quarter3/half6）：往前推等长天数
    period_length = elapsed_days + 1
    prev_end = start_date - timedelta(days=1)
    prev_start = prev_end - timedelta(days=period_length - 1)
    return prev_start, prev_end


@trace_service(description_zh="计算对比数据", description_en="Calc Compare")
def calc_compare(current: Decimal, previous: Decimal) -> dict:
    """
    统一环比计算。

    返回:
      - compare: str  — "12.5%" / "新增" / "持平"
      - is_down: bool — 是否下降
      - is_flat: bool — 是否持平

    规则:
      - previous=0, current≠0 → "新增", is_down=False, is_flat=False
      - previous=0, current=0 → "持平", is_down=False, is_flat=True
      - 正常计算: (current - previous) / previous × 100%
      - 正值 → is_down=False; 负值 → is_down=True; 零 → is_flat=True
    """
    if previous == 0:
        if current != 0:
            return {"compare": "新增", "is_down": False, "is_flat": False}
        return {"compare": "持平", "is_down": False, "is_flat": True}

    diff = current - previous
    pct = (diff / previous * 100).quantize(Decimal("0.1"), rounding=ROUND_HALF_UP)

    if pct > 0:
        return {"compare": f"{pct}%", "is_down": False, "is_flat": False}
    if pct < 0:
        return {"compare": f"{abs(pct)}%", "is_down": True, "is_flat": False}
    # pct == 0（当期 == 上期）
    return {"compare": "持平", "is_down": False, "is_flat": True}


import logging
from typing import Any

from fastapi import HTTPException

from app.services import fdw_queries
from app.services.runtime_context import get_runtime_context, task_runtime_filter

logger = logging.getLogger(__name__)


def _get_connection():
    """延迟导入 get_connection，避免纯函数测试时触发模块级导入失败。"""
    from app.database import get_connection
    return get_connection()


# ---------------------------------------------------------------------------
# 排序映射
# ---------------------------------------------------------------------------

_SORT_KEY_MAP = {
    "perf_desc": ("perf_hours", True),
    "perf_asc": ("perf_hours", False),
    "salary_desc": ("salary", True),
    "salary_asc": ("salary", False),
    "sv_desc": ("sv_amount", True),
    "task_desc": ("task_total", True),
}

# 项目标签 category_code → 前端显示文本 / CSS 类名
_SKILL_DISPLAY = {
    "BILLIARD": "🎱",
    "SNOOKER": "斯",
    "MAHJONG": "🀄",
    "KTV": "🎤",
}
_SKILL_CLS = {
    "BILLIARD": "skill--chinese",
    "SNOOKER": "skill--snooker",
    "MAHJONG": "skill--mahjong",
    "KTV": "skill--karaoke",
}

_SORT_DIM_MAP = {
    "perf_desc": "perf", "perf_asc": "perf",
    "salary_desc": "salary", "salary_asc": "salary",
    "sv_desc": "sv", "task_desc": "task",
}


# ---------------------------------------------------------------------------
# BOARD-1 助教看板
# ---------------------------------------------------------------------------


@trace_service("获取助教看板", "Get coach board")
async def get_coach_board(
    sort: str, skill: str, time: str, page: int, page_size: int, site_id: int
) -> dict:
    """
    BOARD-1：助教看板。扁平返回所有维度字段。

    参数互斥：time=last_6m + sort=sv_desc → HTTP 400。
    """
    # 参数互斥校验
    if time == "last_6m" and sort == "sv_desc":
        raise HTTPException(
            status_code=400,
            detail="最近6个月不支持客源储值排序",
        )

    runtime_ctx = get_runtime_context(site_id)
    start_date, end_date = _calc_date_range(time, ref_date=runtime_ctx.business_date)
    start_str = str(start_date)
    end_str = str(end_date)

    conn = _get_connection()
    try:
        # 1. 助教列表
        assistants = fdw_queries.get_all_assistants(conn, site_id, skill)
        if not assistants:
            return {"items": [], "dim_type": _SORT_DIM_MAP.get(sort, "perf")}

        aid_list = [a["assistant_id"] for a in assistants]

        # 2. 批量查询绩效
        salary_map = fdw_queries.get_salary_calc_batch(
            conn, site_id, aid_list, start_str, end_str
        )

        # 3. Top 客户（降级为空）
        top_map: dict[int, list[str]] = {}
        try:
            top_map = fdw_queries.get_top_customers_for_coaches(
                conn, site_id, aid_list
            )
        except Exception:
            logger.warning("BOARD-1 topCustomers 查询失败，降级为空", exc_info=True)

        # 4. 储值数据
        sv_map: dict[int, dict] = {}
        try:
            sv_map = fdw_queries.get_coach_sv_data(
                conn, site_id, aid_list, start_str, end_str
            )
        except Exception:
            logger.warning("BOARD-1 sv 数据查询失败，降级为空", exc_info=True)

        # 5. 任务数据
        task_map = _query_coach_tasks(conn, site_id, aid_list, start_str, end_str)

        # 6. 查询档位配置，计算距升档（仅本月/上月有意义）
        tier_nodes: list[float] = []
        show_perf_gap = time in ("month", "last_month")
        if show_perf_gap:
            try:
                tiers = fdw_queries.get_performance_tiers(conn, site_id)
                tier_nodes = [float(t["min_hours"]) for t in tiers] if tiers else []
            except Exception as e:
                logger.warning("BOARD-1 档位配置查询失败: %s", e, exc_info=True)

        # 7. 组装扁平响应
        items = []
        for a in assistants:
            aid = a["assistant_id"]
            sal = salary_map.get(aid, {})
            sv = sv_map.get(aid, {})
            tasks = task_map.get(aid, {})
            top_custs = top_map.get(aid, [])

            name = a["name"]
            initial = name[0] if name else ""

            perf_hours = float(sal.get("effective_hours", 0) or 0)
            salary_val = float(sal.get("gross_salary", 0) or 0)
            task_recall = tasks.get("recall", 0)
            task_callback = tasks.get("callback", 0)

            # 折前课时：当 effective_hours != raw_hours 时显示（惩罚扣减导致的差异）
            # 惩罚规则：同台 >2 助教重叠，per_hour_contribution < 24 元时按比例扣减
            raw_hours = float(sal.get("raw_hours", 0) or 0)
            perf_hours_before = None
            if abs(perf_hours - raw_hours) > 0.01:
                perf_hours_before = raw_hours

            # 计算距升档差距
            perf_gap = None
            perf_reached = False
            if tier_nodes and perf_hours is not None:
                # 找到下一个未达到的档位
                for threshold in tier_nodes:
                    if perf_hours < threshold:
                        gap = threshold - perf_hours
                        perf_gap = f"距升档 {gap:.1f}h"
                        break
                else:
                    perf_reached = True  # 已达到最高档

            items.append({
                "id": aid,
                "name": name,
                "initial": initial,
                "avatar_gradient": "",
                "level": sal.get("level_name", a.get("level", "")),
                # CHANGE 2026-03-29 | 从 get_all_assistants 返回的 skill 字段取项目标签
                # Schema 要求 list[CoachSkillItem]（{text, cls}），不是纯字符串
                # text 映射为中文短名 + emoji，cls 映射为 CSS 类名
                "skills": [
                    {"text": _SKILL_DISPLAY.get(s, s), "cls": _SKILL_CLS.get(s, "")}
                    for s in (a.get("skill") or "").split(",") if s
                ],
                "top_customers": top_custs,
                "perf_hours": perf_hours,
                "perf_hours_before": perf_hours_before,
                "perf_gap": perf_gap,
                "perf_reached": perf_reached,
                "salary": salary_val,
                "salary_perf_hours": perf_hours,
                "salary_perf_before": perf_hours_before,
                "sv_amount": sv.get("sv_amount", 0.0),
                "sv_customer_count": sv.get("sv_customer_count", 0),
                "sv_consume": sv.get("sv_consume", 0.0),
                "task_recall": task_recall,
                "task_callback": task_callback,
                "task_total": task_recall + task_callback,
            })

        # 7. 排序（id 作 tiebreaker 保证分页稳定）
        sort_key, sort_desc = _SORT_KEY_MAP.get(sort, ("perf_hours", True))
        items.sort(key=lambda x: (x.get(sort_key, 0), x.get("id", 0)), reverse=sort_desc)

        # 移除内部排序字段
        for item in items:
            item.pop("task_total", None)

        # 8. 分页
        total = len(items)
        start = (page - 1) * page_size
        items = items[start : start + page_size]

        return {
            "items": items,
            "total": total,
            "page": page,
            "page_size": page_size,
            "dim_type": _SORT_DIM_MAP.get(sort, "perf"),
        }
    finally:
        conn.close()


def _query_coach_tasks(
    conn: Any, site_id: int, assistant_ids: list[int],
    start_date: str, end_date: str,
) -> dict[int, dict]:
    """
    查询助教任务完成数（BOARD-1 task 维度）。

    CHANGE 2026-04-08 | Fix-13 改造
    CHANGE 2026-04-13 | 狭义召回：recall 改为从 coach_tasks 统计 status='completed'，
    不再使用 recall_events（广义）。recall + callback 统一口径。
    """
    if not assistant_ids:
        return {}

    result: dict[int, dict] = {}
    try:
        runtime_clause, runtime_params = task_runtime_filter(site_id, conn=conn)
        with conn.cursor() as cur:
            # 狭义召回+回访完成数：均从 coach_tasks 统计，status='completed' 表示助教亲自完成
            cur.execute(
                f"""
                SELECT assistant_id, task_type, COUNT(*) AS cnt
                FROM biz.coach_tasks
                WHERE assistant_id = ANY(%s)
                  AND site_id = %s
                  {runtime_clause}
                  AND completed_at >= %s::date
                  AND completed_at < (%s::date + INTERVAL '1 day')::timestamptz
                  AND status = 'completed'
                GROUP BY assistant_id, task_type
                """,
                [assistant_ids, site_id, *runtime_params, start_date, end_date],
            )
            for row in cur.fetchall():
                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:
        logger.warning("BOARD-1 任务查询失败，降级为空", exc_info=True)

    return result


# ---------------------------------------------------------------------------
# BOARD-2 客户看板
# ---------------------------------------------------------------------------

def _batch_ideal_days(conn: Any, site_id: int, member_ids: list[int]) -> dict[int, int]:
    """批量查询客户理想到店间隔天数（balance/recharge 维度头部用）。"""
    from app.services.fdw_queries import _fdw_context
    result: dict[int, int] = {}
    try:
        with _fdw_context(conn, site_id) as cur:
            cur.execute(
                """
                SELECT member_id, COALESCE(ideal_interval_days, 0)
                FROM app.v_dws_member_winback_index
                WHERE member_id = ANY(%s)
                """,
                (member_ids,),
            )
            for row in cur.fetchall():
                result[row[0]] = int(row[1]) if row[1] is not None else 0
    except Exception:
        logger.warning("_batch_ideal_days 查询失败", exc_info=True)
    return result


def _batch_coach_details(
    conn: Any,
    site_id: int,
    member_ids: list[int],
    *,
    ref_date: date | None = None,
) -> dict[int, list[dict]]:
    """批量查询客户-助教服务明细（loyal 维度 coachDetails 用）。每个客户前 5 个。

    ref_date 默认从 RuntimeContext 取业务日，用于把 60 天消费窗口的上界落到 ``ref_date`` 上，
    避免 sandbox 模式下读到 sandbox_date 之后的真实消费。
    """
    from app.services.fdw_queries import _fdw_context
    from app.services.runtime_context import as_runtime_today_param

    ref = ref_date or as_runtime_today_param(site_id, conn=conn)
    result: dict[int, list[dict]] = {mid: [] for mid in member_ids}
    try:
        with _fdw_context(conn, site_id) as cur:
            # CHANGE 2026-03-29 | coach_spend 改为从 dwd_assistant_service_log 聚合 60 天消费
            # CHANGE 2026-05-02 | 用 ref_date（业务日）替代 CURRENT_DATE，沙箱不读「未来」
            cur.execute(
                """
                SELECT ri.member_id,
                       COALESCE(da.nickname, da.real_name, '') AS name,
                       ri.rs_display,
                       ri.session_count,
                       ri.total_duration_minutes,
                       COALESCE(s60.spend_60d, 0) AS spend_60d
                FROM app.v_dws_member_assistant_relation_index ri
                LEFT JOIN app.v_dim_assistant da
                    ON ri.assistant_id = da.assistant_id AND da.scd2_is_current = 1
                LEFT JOIN (
                    SELECT tenant_member_id, site_assistant_id,
                           SUM(ledger_amount) AS spend_60d
                    FROM app.v_dwd_assistant_service_log
                    WHERE is_delete = 0
                      AND create_time >= (%s::date - INTERVAL '60 days')
                      AND create_time <  (%s::date + INTERVAL '1 day')
                      AND tenant_member_id = ANY(%s)
                    GROUP BY tenant_member_id, site_assistant_id
                ) s60 ON ri.member_id = s60.tenant_member_id
                     AND ri.assistant_id = s60.site_assistant_id
                WHERE ri.member_id = ANY(%s)
                  AND (da.leave_status IS NULL OR da.leave_status = 0)
                ORDER BY ri.member_id, ri.rs_display DESC
                """,
                (ref, ref, member_ids, member_ids),
            )
            for row in cur.fetchall():
                mid = row[0]
                if mid in result and len(result[mid]) < 5:
                    svc_count = row[3] or 0
                    total_mins = float(row[4]) if row[4] else 0.0
                    avg_dur = round(total_mins / 60 / svc_count, 1) if svc_count > 0 else 0.0
                    result[mid].append({
                        "name": row[1] or "",
                        "cls": "",
                        "heart_score": float(row[2]) if row[2] is not None else 0.0,
                        "avg_duration": f"{avg_dur}h",
                        "service_count": str(svc_count),
                        "coach_spend": float(row[5]) if row[5] is not None else 0.0,
                        "relation_idx": float(row[2]) if row[2] is not None else 0.0,
                    })
    except Exception:
        logger.warning("_batch_coach_details 查询失败", exc_info=True)
    return result


def _batch_member_projects(conn: Any, site_id: int, member_ids: list[int]) -> dict[int, list[str]]:
    """批量查询客户项目标签（BOARD-2 用）。通过 FDW 视图查询。"""
    from app.services.fdw_queries import _fdw_context
    result: dict[int, list[str]] = {mid: [] for mid in member_ids}
    try:
        with _fdw_context(conn, site_id) as cur:
            cur.execute(
                """
                SELECT member_id, array_agg(DISTINCT category_code)
                FROM app.v_dws_member_project_tag
                WHERE member_id = ANY(%s) AND is_tagged = true
                GROUP BY member_id
                """,
                (member_ids,),
            )
            for row in cur.fetchall():
                mid = row[0]
                codes = row[1] or []
                if mid in result:
                    result[mid] = [c for c in codes if c]
    except Exception:
        logger.warning("_batch_member_projects 查询失败", exc_info=True)
    return result


# 维度 → FDW 查询函数映射
_DIMENSION_QUERY_MAP = {
    "recall": "get_customer_board_recall",
    "potential": "get_customer_board_potential",
    "balance": "get_customer_board_balance",
    "recharge": "get_customer_board_recharge",
    "recent": "get_customer_board_recent",
    "spend60": "get_customer_board_spend60",
    "freq60": "get_customer_board_freq60",
    "loyal": "get_customer_board_loyal",
}


@trace_service("获取客户看板", "Get customer board")
async def get_customer_board(
    dimension: str, project: str, page: int, page_size: int, site_id: int
) -> dict:
    """
    BOARD-2：客户看板。按维度返回专属字段 + 分页。
    """
    query_fn_name = _DIMENSION_QUERY_MAP.get(dimension)
    if not query_fn_name:
        raise HTTPException(status_code=400, detail=f"不支持的维度: {dimension}")

    query_fn = getattr(fdw_queries, query_fn_name)

    conn = _get_connection()
    try:
        # 1. 按维度查询分页数据
        result = query_fn(conn, site_id, project, page, page_size)
        items = result["items"]

        # 2. 批量查询客户关联助教
        member_ids = [item["member_id"] for item in items if item.get("member_id")]
        assistants_map: dict[int, list[dict]] = {}
        if member_ids:
            try:
                assistants_map = fdw_queries.get_customer_assistants(
                    conn, site_id, member_ids
                )
            except Exception:
                logger.warning("BOARD-2 客户助教查询失败，降级为空", exc_info=True)

        # 2b. 批量查询客户项目标签
        member_projects: dict[int, list[str]] = {}
        if member_ids:
            try:
                member_projects = _batch_member_projects(conn, site_id, member_ids)
            except Exception:
                logger.warning("BOARD-2 客户项目标签查询失败，降级为空", exc_info=True)

        # 2c. balance/recharge 维度：补充 ideal_days
        if dimension in ("balance", "recharge") and member_ids:
            try:
                ideal_map = _batch_ideal_days(conn, site_id, member_ids)
                for item in items:
                    mid = item.get("member_id", 0)
                    if item.get("ideal_days") is None:
                        item["ideal_days"] = ideal_map.get(mid, 0)
            except Exception:
                logger.warning("BOARD-2 ideal_days 查询失败", exc_info=True)

        # 3. 组装响应（添加基础字段 + assistants）
        for item in items:
            mid = item.get("member_id", 0)
            name = item.get("name", "")
            item["id"] = mid
            item["initial"] = name[0] if name else ""
            item["avatar_cls"] = ""
            item["assistants"] = assistants_map.get(mid, [])
            item["projects"] = member_projects.get(mid, [])

        # 3b. loyal 维度：为每个客户补充 coach_details（前 5 个助教的服务明细）
        if dimension == "loyal" and member_ids:
            try:
                coach_details_map = _batch_coach_details(conn, site_id, member_ids)
                for item in items:
                    mid = item.get("member_id", 0)
                    item["coach_details"] = coach_details_map.get(mid, [])
            except Exception:
                logger.warning("BOARD-2 loyal coachDetails 查询失败", exc_info=True)
                for item in items:
                    item["coach_details"] = []

        # CHANGE 2026-03-28 | P5 联调修复：items 是 list[dict]，Pydantic CamelModel
        # 不会自动转换内部 dict 的 key。手动 snake_case → camelCase。
        # CHANGE 2026-03-29 | 递归处理嵌套 list[dict]（如 assistants 数组）
        def _to_camel(key: str) -> str:
            parts = key.split("_")
            return parts[0] + "".join(p.capitalize() for p in parts[1:])

        def _camel_dict(d: dict) -> dict:
            result = {}
            for k, v in d.items():
                ck = _to_camel(k)
                if isinstance(v, list):
                    result[ck] = [_camel_dict(i) if isinstance(i, dict) else i for i in v]
                elif isinstance(v, dict):
                    result[ck] = _camel_dict(v)
                else:
                    result[ck] = v
            return result

        camel_items = [_camel_dict(item) for item in items]

        return {
            "items": camel_items,
            "total": result["total"],
            "page": result["page"],
            "page_size": result["page_size"],
        }
    finally:
        conn.close()


# ---------------------------------------------------------------------------
# BOARD-3 财务看板
# ---------------------------------------------------------------------------


# CHANGE 2026-04-01 | board-finance-dws-area-refactor 9.1 | 缓存/日粒度查询路由
COMPLETED_PERIODS = {"lastMonth", "lastWeek", "lastQuarter", "quarter3", "half6"}
CURRENT_PERIODS = {"month", "week", "quarter"}


@trace_service("获取财务看板", "Get finance board")
async def get_finance_board(
    time: str, area: str, compare: int, site_id: int
) -> dict:
    """
    BOARD-3：财务看板。6 板块独立查询、独立降级。

    CHANGE 2026-04-01 | board-finance-dws-area-refactor 9.1 |
    - 已完成周期先查缓存 → 未命中从日粒度表 SUM → 写缓存
    - 当期周期直接从日粒度表 SUM，不查缓存
    - overview/revenue 改为从 dws_finance_area_daily 按 area_code 查询
    - cashflow/expense/coach_analysis 不变（始终用全局数据）
    - area≠all 时 recharge 返回 null
    - area≠all 时 overview 覆盖逻辑保留
    - compare=1 时对上期执行同样缓存/日粒度逻辑
    """
    runtime_ctx = get_runtime_context(site_id)
    start_date, end_date = _calc_date_range(time, ref_date=runtime_ctx.business_date)
    start_str = str(start_date)
    end_str = str(end_date)

    prev_start_str = None
    prev_end_str = None
    if compare == 1:
        prev_start, prev_end = _calc_prev_range(time, start_date, end_date)
        prev_start_str = str(prev_start)
        prev_end_str = str(prev_end)

    conn = _get_connection()
    try:
        # 各板块独立 try/except
        overview = _build_overview(conn, site_id, start_str, end_str,
                                   prev_start_str, prev_end_str, compare, area)

        recharge = None
        if area == "all":
            recharge = _build_recharge(conn, site_id, start_str, end_str,
                                       prev_start_str, prev_end_str, compare)

        revenue = _build_revenue(conn, site_id, start_str, end_str, area,
                                 prev_start_str, prev_end_str, compare)

        # CHANGE 2026-03-28 | 非全部区域时，用 revenue 的数据覆盖 overview 的发生额/优惠/确认收入
        if area != "all" and revenue:
            overview["occurrence"] = revenue.get("total_occurrence", 0.0)
            overview["discount"] = revenue.get("discount_total", 0.0)
            overview["confirmed_revenue"] = revenue.get("confirmed_total", 0.0)
            # discount_rate 重算
            occ = overview["occurrence"]
            overview["discount_rate"] = (overview["discount"] / occ) if occ > 0 else 0.0
            # CHANGE 2026-03-29 | area≠all 时隐藏实收流水（现金流 4 项无法按区域拆分）
            overview["cash_in"] = None
            overview["cash_out"] = None
            overview["cash_balance"] = None
            overview["balance_rate"] = None
            # 移除现金流环比字段（如有）
            for f in ("cash_in", "cash_out", "cash_balance", "balance_rate"):
                overview.pop(f"{f}_compare", None)
                overview.pop(f"{f}_down", None)
                overview.pop(f"{f}_flat", None)

        # CHANGE 2026-03-29 | area≠all 时隐藏现金流入和现金流出板块
        cashflow = None
        expense = None
        if area == "all":
            cashflow = _build_cashflow(conn, site_id, start_str, end_str,
                                       prev_start_str, prev_end_str, compare)

            expense = _build_expense(conn, site_id, start_str, end_str,
                                     prev_start_str, prev_end_str, compare)

        coach_analysis = _build_coach_analysis(conn, site_id, start_str, end_str,
                                               prev_start_str, prev_end_str, compare, area)

        return {
            "overview": overview,
            "recharge": recharge,
            "revenue": revenue,
            "cashflow": cashflow,
            "expense": expense,
            "coach_analysis": coach_analysis,
        }
    finally:
        conn.close()


def _build_overview(
    conn: Any, site_id: int, start: str, end: str,
    prev_start: str | None, prev_end: str | None, compare: int,
    area: str = "all",
) -> dict:
    """经营一览板块。

    CHANGE 2026-04-08 | P0 修复发生额失真 |
    area=all 时回退到 dws_finance_daily_summary（get_finance_overview），
    仅 area≠all 时走 dws_finance_area_daily（get_finance_overview_area）。
    原因：area_daily 的 all 行只聚合有桌台映射的结算单，漏算无桌台单据约 12%。
    """
    try:
        if area == "all":
            data = fdw_queries.get_finance_overview(conn, site_id, start, end)
        else:
            data = fdw_queries.get_finance_overview_area(conn, site_id, start, end, area)
    except Exception:
        logger.warning("overview 查询失败，降级为空", exc_info=True)
        return _empty_overview()

    result = {**data}

    if compare == 1 and prev_start and prev_end:
        try:
            if area == "all":
                prev = fdw_queries.get_finance_overview(conn, site_id, prev_start, prev_end)
            else:
                prev = fdw_queries.get_finance_overview_area(conn, site_id, prev_start, prev_end, area)
            _attach_compare(result, data, prev, [
                "occurrence", "discount", "discount_rate", "confirmed_revenue",
                "cash_in", "cash_out", "cash_balance", "balance_rate",
            ])
        except Exception:
            logger.warning("overview 环比查询失败", exc_info=True)

    return result


def _build_recharge(
    conn: Any, site_id: int, start: str, end: str,
    prev_start: str | None, prev_end: str | None, compare: int,
) -> dict | None:
    """预收资产板块。"""
    try:
        data = fdw_queries.get_finance_recharge(conn, site_id, start, end)
    except Exception:
        logger.warning("recharge 查询失败，降级为 null", exc_info=True)
        return None

    if compare == 1 and prev_start and prev_end:
        try:
            prev = fdw_queries.get_finance_recharge(conn, site_id, prev_start, prev_end)
            _attach_compare(data, data, prev, [
                "actual_income", "first_charge", "renew_charge",
                "consumed", "card_balance", "all_card_balance",
            ])
            # 赠送卡矩阵环比
            for i, row in enumerate(data.get("gift_rows", [])):
                prev_row = prev.get("gift_rows", [{}] * 3)[i] if i < len(prev.get("gift_rows", [])) else {}
                for key in ["total", "liquor", "table_fee", "voucher"]:
                    # gift_rows 的 cell 是 GiftCell dict（{"value": float}）
                    cur_cell = row.get(key, {})
                    prev_cell = prev_row.get(key, {})
                    cur_val = Decimal(str(cur_cell.get("value", 0) if isinstance(cur_cell, dict) else cur_cell))
                    prev_val = Decimal(str(prev_cell.get("value", 0) if isinstance(prev_cell, dict) else prev_cell))
                    cmp = calc_compare(cur_val, prev_val)
                    if isinstance(cur_cell, dict):
                        cur_cell["compare"] = cmp["compare"]
                        cur_cell["down"] = cmp["is_down"]
                        cur_cell["flat"] = cmp["is_flat"]
                    else:
                        row[key] = {"value": float(cur_val), "compare": cmp["compare"], "down": cmp["is_down"], "flat": cmp["is_flat"]}
        except Exception:
            logger.warning("recharge 环比查询失败", exc_info=True)

    return data


def _build_revenue(
    conn: Any, site_id: int, start: str, end: str, area: str,
    prev_start: str | None = None, prev_end: str | None = None, compare: int = 0,
) -> dict:
    """应计收入板块。

    CHANGE 2026-04-01 | board-finance-dws-area-refactor 9.1 |
    改为从 dws_finance_area_daily 按 area_code 查询（通过 get_finance_revenue_area），
    然后在 Python 层构建 structure_rows / discount_items / channel_items 保持返回结构不变。
    """
    try:
        if area == "all":
            # CHANGE 2026-03-29 | area=all 走旧版查询，保留收入结构的区域子行拆分
            data = fdw_queries.get_finance_revenue(conn, site_id, start, end, area)
        else:
            raw = fdw_queries.get_finance_revenue_area(conn, site_id, start, end, area)
            data = _format_revenue_from_area(raw, conn, site_id, start, end, area)
    except Exception:
        logger.warning("revenue 查询失败，降级为空", exc_info=True)
        return _empty_revenue()

    if compare == 1 and prev_start and prev_end:
        try:
            if area == "all":
                prev = fdw_queries.get_finance_revenue(conn, site_id, prev_start, prev_end, area)
            else:
                prev_raw = fdw_queries.get_finance_revenue_area(conn, site_id, prev_start, prev_end, area)
                prev = _format_revenue_from_area(prev_raw, conn, site_id, prev_start, prev_end, area)
            # 总计环比
            _attach_compare(data, data, prev, [
                "total_occurrence", "discount_total", "confirmed_total",
            ])
            # structure_rows 行级环比（按 id 匹配）
            prev_struct = {r["id"]: r for r in prev.get("structure_rows", [])}
            for row in data.get("structure_rows", []):
                prev_row = prev_struct.get(row["id"], {})
                cmp = calc_compare(
                    Decimal(str(row.get("booked", 0))),
                    Decimal(str(prev_row.get("booked", 0))),
                )
                row["booked_compare"] = cmp["compare"]
            # price_items 行级环比（按 label 匹配）
            prev_prices = {r["label"]: r for r in prev.get("price_items", [])}
            for item in data.get("price_items", []):
                prev_item = prev_prices.get(item["label"], {})
                cmp = calc_compare(
                    Decimal(str(item.get("amount", 0))),
                    Decimal(str(prev_item.get("amount", 0))),
                )
                item["compare"] = cmp["compare"]
            # discount_items 行级环比（按 label 匹配）
            prev_discounts = {r["label"]: r for r in prev.get("discount_items", [])}
            for item in data.get("discount_items", []):
                prev_item = prev_discounts.get(item["label"], {})
                cmp = calc_compare(
                    Decimal(str(item.get("amount", 0))),
                    Decimal(str(prev_item.get("amount", 0))),
                )
                item["compare"] = cmp["compare"]
            # channel_items 行级环比（按 label 匹配）
            prev_channels = {r["label"]: r for r in prev.get("channel_items", [])}
            for item in data.get("channel_items", []):
                prev_item = prev_channels.get(item["label"], {})
                cmp = calc_compare(
                    Decimal(str(item.get("amount", 0))),
                    Decimal(str(prev_item.get("amount", 0))),
                )
                item["compare"] = cmp["compare"]
        except Exception:
            logger.warning("revenue 环比查询失败", exc_info=True)

    return data


def _format_revenue_from_area(
    raw: dict, conn: Any, site_id: int, start: str, end: str, area: str,
) -> dict:
    """将 get_finance_revenue_area 的原始聚合数据格式化为前端期望的 revenue 结构。

    CHANGE 2026-04-01 | board-finance-dws-area-refactor 9.1 |
    从 dws_finance_area_daily 聚合数据构建 structure_rows / discount_items / channel_items，
    保持与旧 get_finance_revenue 返回结构完全一致。
    """
    total_table_charge = raw.get("table_fee_amount", 0.0)
    total_goods = raw.get("goods_amount", 0.0)
    total_pd = raw.get("assistant_pd_amount", 0.0)
    total_cx = raw.get("assistant_cx_amount", 0.0)
    total_income = raw.get("total_occurrence", 0.0)

    # 构建 structure_rows（简化版：不再按物理区域拆分子行，因为 area_daily 已按 area_code 聚合）
    structure_rows = [
        {"id": "table_charge", "name": "开台与包厢", "desc": None,
         "is_sub": False, "amount": total_table_charge,
         "discount": 0.0, "booked": total_table_charge},
        {"id": "assistant_pd", "name": "助教 基础课", "desc": None,
         "is_sub": False, "amount": total_pd,
         "discount": 0.0, "booked": total_pd},
        {"id": "assistant_cx", "name": "助教 激励课", "desc": None,
         "is_sub": False, "amount": total_cx,
         "discount": 0.0, "booked": total_cx},
        {"id": "goods", "name": "食品酒水", "desc": None,
         "is_sub": False, "amount": total_goods,
         "discount": 0.0, "booked": total_goods},
    ]

    # 发生额构成
    price_items = [
        {"label": "开台消费", "amount": total_table_charge},
        {"label": "酒水商品", "amount": total_goods},
        {"label": "助教服务", "amount": total_pd + total_cx},
    ]

    # 优惠拆分（5 项，与旧逻辑一致）
    groupbuy_d = raw.get("discount_groupbuy", 0.0)
    vip_d = raw.get("discount_vip", 0.0)
    manual_d = raw.get("discount_manual", 0.0)
    gift_card_d = raw.get("discount_gift_card", 0.0)
    # 其他 = discount_rounding + discount_other
    rounding_d = raw.get("discount_rounding", 0.0)
    other_d = raw.get("discount_other", 0.0)

    discount_items = [
        {"label": "团购优惠", "amount": groupbuy_d},
        {"label": "会员折扣", "amount": vip_d},
        {"label": "手动调整", "amount": manual_d + other_d},
        {"label": "赠送卡抵扣", "desc": "台桌卡+酒水卡+抵用券", "amount": gift_card_d},
        {"label": "其他优惠", "desc": "免单+抹零", "amount": rounding_d},
    ]
    total_discount = raw.get("discount_total", 0.0)

    # 回填收入结构表的优惠分摊
    if total_table_charge > 0 and total_discount > 0:
        for row in structure_rows:
            if row["id"] == "table_charge":
                row["discount"] = total_discount
                row["booked"] = total_table_charge - total_discount

    # 渠道分布（从 dws_finance_area_daily 的 all 行获取，因为渠道数据仅 all 有值）
    # 需要额外查询 all 行的渠道数据
    try:
        channel_data = _get_channel_items(conn, site_id, start, end)
    except Exception:
        logger.warning("revenue 渠道数据查询失败，降级为空", exc_info=True)
        channel_data = [
            {"label": "储值卡结算冲销", "amount": 0.0},
            {"label": "现金/线上支付", "amount": 0.0},
            {"label": "团购核销确认收入", "desc": "团购成交价", "amount": 0.0},
        ]

    confirmed_total = total_income - abs(total_discount)
    return {
        "structure_rows": structure_rows,
        "price_items": price_items,
        "total_occurrence": total_income,
        "discount_items": discount_items,
        "discount_total": total_discount,
        "confirmed_total": confirmed_total,
        "channel_items": channel_data,
    }


def _get_channel_items(conn: Any, site_id: int, start: str, end: str) -> list[dict]:
    """从 v_dws_finance_daily_summary 获取渠道分布数据（全局数据，不按区域拆分）。"""
    from app.services.fdw_queries import _fdw_context
    with _fdw_context(conn, site_id) as cur:
        cur.execute(
            """
            SELECT COALESCE(SUM(cash_pay_amount), 0) AS cash_pay,
                   COALESCE(SUM(groupbuy_pay_amount), 0) AS groupbuy_pay,
                   COALESCE(SUM(cash_card_consume), 0) AS cash_card,
                   COALESCE(SUM(gift_card_consume), 0) AS gift_card
            FROM app.v_dws_finance_daily_summary
            WHERE stat_date >= %s::date AND stat_date <= %s::date
            """,
            (start, end),
        )
        ch = cur.fetchone()
    cash_pay = float(ch[0]) if ch and ch[0] is not None else 0.0
    groupbuy_pay = float(ch[1]) if ch and ch[1] is not None else 0.0
    cash_card = float(ch[2]) if ch and ch[2] is not None else 0.0
    gift_card_consume = float(ch[3]) if ch and ch[3] is not None else 0.0

    return [
        {"label": "储值卡结算冲销", "amount": cash_card + gift_card_consume},
        {"label": "现金/线上支付", "amount": cash_pay},
        {"label": "团购核销确认收入", "desc": "团购成交价", "amount": groupbuy_pay},
    ]


def _build_cashflow(
    conn: Any, site_id: int, start: str, end: str,
    prev_start: str | None, prev_end: str | None, compare: int,
) -> dict:
    """现金流入板块。"""
    try:
        data = fdw_queries.get_finance_cashflow(conn, site_id, start, end)
    except Exception:
        logger.warning("cashflow 查询失败，降级为空", exc_info=True)
        return {"consume_items": [], "recharge_items": [], "total": 0.0}

    if compare == 1 and prev_start and prev_end:
        try:
            prev = fdw_queries.get_finance_cashflow(conn, site_id, prev_start, prev_end)
            total_cmp = calc_compare(
                Decimal(str(data["total"])), Decimal(str(prev["total"]))
            )
            data["total_compare"] = total_cmp["compare"]
            data["total_down"] = total_cmp["is_down"]
            data["total_flat"] = total_cmp["is_flat"]
            # consume_items 行级环比（按 label 匹配）
            prev_consumes = {r["label"]: r for r in prev.get("consume_items", [])}
            for item in data.get("consume_items", []):
                prev_item = prev_consumes.get(item["label"], {})
                cmp = calc_compare(
                    Decimal(str(item.get("amount", 0))),
                    Decimal(str(prev_item.get("amount", 0))),
                )
                item["compare"] = cmp["compare"]
                item["down"] = cmp["is_down"]
            # recharge_items 行级环比（按 label 匹配）
            prev_recharges = {r["label"]: r for r in prev.get("recharge_items", [])}
            for item in data.get("recharge_items", []):
                prev_item = prev_recharges.get(item["label"], {})
                cmp = calc_compare(
                    Decimal(str(item.get("amount", 0))),
                    Decimal(str(prev_item.get("amount", 0))),
                )
                item["compare"] = cmp["compare"]
        except Exception:
            logger.warning("cashflow 环比查询失败", exc_info=True)

    return data


def _build_expense(
    conn: Any, site_id: int, start: str, end: str,
    prev_start: str | None, prev_end: str | None, compare: int,
) -> dict:
    """现金流出板块。"""
    try:
        data = fdw_queries.get_finance_expense(conn, site_id, start, end)
    except Exception:
        logger.warning("expense 查询失败，降级为空", exc_info=True)
        return {
            "operation_items": [], "fixed_items": [],
            "coach_items": [], "platform_items": [], "total": 0.0,
        }

    if compare == 1 and prev_start and prev_end:
        try:
            prev = fdw_queries.get_finance_expense(conn, site_id, prev_start, prev_end)
            total_cmp = calc_compare(
                Decimal(str(data["total"])), Decimal(str(prev["total"]))
            )
            data["total_compare"] = total_cmp["compare"]
            data["total_down"] = total_cmp["is_down"]
            data["total_flat"] = total_cmp["is_flat"]
        except Exception:
            logger.warning("expense 环比查询失败", exc_info=True)

    return data


def _build_coach_analysis(
    conn: Any, site_id: int, start: str, end: str,
    prev_start: str | None, prev_end: str | None, compare: int,
    area: str = "all",
) -> dict:
    """助教分析板块。

    CHANGE 2026-03-29 | Prompt: 助教分析按区域细化 |
    area=all 走现有 salary_calc 查询，area≠all 走 coach_area_hours JOIN salary_calc。
    """
    try:
        if area == "all":
            data = fdw_queries.get_finance_coach_analysis(conn, site_id, start, end)
        else:
            data = fdw_queries.get_finance_coach_analysis_area(conn, site_id, start, end, area)
    except Exception:
        logger.warning("coachAnalysis 查询失败，降级为空", exc_info=True)
        empty_table = {"total_pay": 0.0, "total_share": 0.0, "avg_hourly": 0.0, "rows": []}
        return {"basic": empty_table, "incentive": {**empty_table}}

    if compare == 1 and prev_start and prev_end:
        try:
            if area == "all":
                prev = fdw_queries.get_finance_coach_analysis(
                    conn, site_id, prev_start, prev_end
                )
            else:
                prev = fdw_queries.get_finance_coach_analysis_area(
                    conn, site_id, prev_start, prev_end, area
                )
            for key in ("basic", "incentive"):
                cur_t = data[key]
                prev_t = prev[key]
                _attach_compare(cur_t, cur_t, prev_t, [
                    "total_pay", "total_share", "avg_hourly",
                ])
                # 行级环比（按 level 匹配）
                prev_rows = {r["level"]: r for r in prev_t.get("rows", [])}
                for row in cur_t.get("rows", []):
                    prev_row = prev_rows.get(row["level"], {})
                    pay_cmp = calc_compare(Decimal(str(row.get("pay", 0))), Decimal(str(prev_row.get("pay", 0))))
                    row["pay_compare"] = pay_cmp["compare"]
                    row["pay_down"] = pay_cmp["is_down"]
                    share_cmp = calc_compare(Decimal(str(row.get("share", 0))), Decimal(str(prev_row.get("share", 0))))
                    row["share_compare"] = share_cmp["compare"]
                    row["share_down"] = share_cmp["is_down"]
                    hourly_cmp = calc_compare(Decimal(str(row.get("hourly", 0))), Decimal(str(prev_row.get("hourly", 0))))
                    row["hourly_compare"] = hourly_cmp["compare"]
                    row["hourly_flat"] = hourly_cmp["is_flat"]
        except Exception:
            logger.warning("coachAnalysis 环比查询失败", exc_info=True)

    return data


# ---------------------------------------------------------------------------
# 环比辅助
# ---------------------------------------------------------------------------


def _attach_compare(
    target: dict, current: dict, previous: dict, fields: list[str]
) -> None:
    """为 target dict 中的指定字段附加环比三元组。"""
    for field in fields:
        cur_val = Decimal(str(current.get(field, 0)))
        prev_val = Decimal(str(previous.get(field, 0)))
        cmp = calc_compare(cur_val, prev_val)
        target[f"{field}_compare"] = cmp["compare"]
        target[f"{field}_down"] = cmp["is_down"]
        target[f"{field}_flat"] = cmp["is_flat"]


# ---------------------------------------------------------------------------
# 空默认值工厂（优雅降级用）
# ---------------------------------------------------------------------------


def _empty_overview() -> dict:
    return {
        "occurrence": 0.0, "discount": 0.0, "discount_rate": 0.0,
        "confirmed_revenue": 0.0, "cash_in": 0.0, "cash_out": 0.0,
        "cash_balance": 0.0, "balance_rate": 0.0,
    }


def _empty_revenue() -> dict:
    """应计收入空默认值（优雅降级用）。

    CHANGE 2026-03-20 | 新增，确保包含所有必需字段：
    price_items, total_occurrence, confirmed_total, channel_items
    """
    return {
        "structure_rows": [],
        "price_items": [],
        "total_occurrence": 0.0,
        "discount_items": [],
        "discount_total": 0.0,
        "confirmed_total": 0.0,
        "channel_items": [],
    }
