# -*- coding: utf-8 -*-
"""
客户服务 —— CUST-1 客户详情、CUST-2 客户服务记录。

数据来源：
- ETL 直连（fdw_queries）：会员信息、余额、消费、关系指数、服务记录
- 业务库（biz.*）：AI 缓存、维客线索、备注、助教任务

⚠️ DWD-DOC 强制规则：
- 规则 1: 金额使用 items_sum 口径（ledger_amount），禁止 consume_money
- 规则 2: 助教费用使用 assistant_pd_money + assistant_cx_money，禁止 service_fee
- DQ-6: 会员信息通过 member_id JOIN v_dim_member (scd2_is_current=1)
- DQ-7: 余额通过 member_id JOIN v_dim_member_card_account (scd2_is_current=1)
- 废单排除: is_delete = 0
"""

from __future__ import annotations

import json
import logging

from fastapi import HTTPException

from decimal import Decimal

from app.services import fdw_queries
from app.services.task_generator import compute_heart_icon
from app.trace.decorators import trace_service

logger = logging.getLogger(__name__)


# ── 颜色/样式映射 ──────────────────────────────────────────

LEVEL_COLOR_MAP = {
    "星级": "#FF6B6B",
    "高级": "#FFA726",
    "中级": "#42A5F5",
    "初级": "#66BB6A",
}

TASK_TYPE_MAP = {
    "follow_up_visit": {"label": "回访", "color": "teal", "bg_class": "coach-card-teal"},
    "high_priority_recall": {"label": "高优先召回", "color": "red", "bg_class": "coach-card-red"},
    "priority_recall": {"label": "优先召回", "color": "orange", "bg_class": "coach-card-orange"},
    "relationship_building": {"label": "关系构建", "color": "pink", "bg_class": "coach-card-pink"},
}

LEVEL_BG_MAP = {
    "星级": "coach-card-red",
    "高级": "coach-card-orange",
    "中级": "coach-card-teal",
    "初级": "coach-card-pink",
}


def _mask_phone(phone: str | None) -> str:
    """手机号脱敏：139****5678 格式。"""
    if not phone or len(phone) < 7:
        return phone or ""
    return f"{phone[:3]}****{phone[-4:]}"


def _get_biz_connection():
    """延迟导入业务库连接。"""
    from app.database import get_connection
    return get_connection()


def _get_assistant_id(conn, user_id: int, site_id: int) -> int | None:
    """从 user_assistant_binding 获取当前用户的 assistant_id。"""
    with conn.cursor() as cur:
        cur.execute(
            """
            SELECT assistant_id
            FROM auth.user_assistant_binding
            WHERE user_id = %s AND site_id = %s AND is_removed = false
            LIMIT 1
            """,
            (user_id, site_id),
        )
        row = cur.fetchone()
    if not row or row[0] is None:
        # CHANGE 2026-03-27 | E3: assistant_id 缺失时记录警告，便于排查到手收入为 0 的问题
        logger.warning(
            "assistant_id 未找到或为 NULL: user_id=%s, site_id=%s, row=%s → salary_calc 将跳过，到手收入=0",
            user_id, site_id, row,
        )
        return None
    return row[0]


# ── 3.1 核心函数 ──────────────────────────────────────────

@trace_service("获取客户详情", "Get customer detail")
async def get_customer_detail(customer_id: int, site_id: int) -> dict:
    """
    客户详情（CUST-1）。

    核心字段查询失败 → 500；扩展模块查询失败 → 空默认值（优雅降级）。
    """
    conn = _get_biz_connection()
    try:
        # CHANGE 2026-03-27 | E1: ETL 连接复用，一次请求只建一个 ETL 连接
        from app.database import get_etl_readonly_connection
        etl_conn = get_etl_readonly_connection(site_id)
        try:
            # ── 核心字段（失败直接抛 500）──
            member_info_map = fdw_queries.get_member_info(conn, site_id, [customer_id], etl_conn=etl_conn)
            if customer_id not in member_info_map:
                raise HTTPException(status_code=404, detail="客户不存在")

            info = member_info_map[customer_id]
            phone_full = info.get("mobile") or ""
            phone = _mask_phone(phone_full)
            name = info.get("nickname") or ""

            # Banner 字段：查询失败返回 null（需求 1.7）
            balance = None
            try:
                balance_map = fdw_queries.get_member_balance(conn, site_id, [customer_id], etl_conn=etl_conn)
                if customer_id in balance_map:
                    balance = float(balance_map[customer_id])
            except Exception:
                logger.warning("查询 balance 失败，降级为 null", exc_info=True)

            consumption_60d = None
            try:
                val = fdw_queries.get_consumption_60d(conn, site_id, customer_id, etl_conn=etl_conn)
                if val is not None:
                    consumption_60d = float(val)
            except Exception:
                logger.warning("查询 consumption_60d 失败，降级为 null", exc_info=True)

            days_since_visit = None
            try:
                visit_map = fdw_queries.get_last_visit_days(conn, site_id, [customer_id], etl_conn=etl_conn)
                if customer_id in visit_map:
                    days_since_visit = visit_map[customer_id]
            except Exception:
                logger.warning("查询 daysSinceVisit 失败，降级为 null", exc_info=True)

            # ── 扩展模块（独立 try/except 优雅降级）──
            try:
                ai_insight = _build_ai_insight(customer_id, conn)
            except Exception:
                logger.warning("构建 aiInsight 失败，降级为空", exc_info=True)
                ai_insight = {"summary": "", "strategies": []}

            try:
                retention_clues = _build_retention_clues(customer_id, conn)
            except Exception:
                logger.warning("构建 retentionClues 失败，降级为空列表", exc_info=True)
                retention_clues = []

            try:
                notes = _build_notes(customer_id, conn)
            except Exception:
                logger.warning("构建 notes 失败，降级为空列表", exc_info=True)
                notes = []

            try:
                consumption_records = _build_consumption_records(customer_id, site_id, conn, etl_conn=etl_conn)
            except Exception:
                logger.warning("构建 consumptionRecords 失败，降级为空列表", exc_info=True)
                consumption_records = []

            try:
                coach_tasks = _build_coach_tasks(customer_id, site_id, conn)
            except Exception:
                logger.warning("构建 coachTasks 失败，降级为空列表", exc_info=True)
                coach_tasks = []

            try:
                favorite_coaches = _build_favorite_coaches(customer_id, site_id, conn)
            except Exception:
                logger.warning("构建 favoriteCoaches 失败，降级为空列表", exc_info=True)
                favorite_coaches = []

            # ideal_interval 从 winback_index 查询
            ideal_interval = None
            try:
                with fdw_queries._fdw_context(conn, site_id, etl_conn=etl_conn) as cur:
                    cur.execute(
                        "SELECT ideal_interval_days FROM app.v_dws_member_winback_index WHERE member_id = %s",
                        (customer_id,),
                    )
                    row = cur.fetchone()
                    if row and row[0] is not None:
                        ideal_interval = int(row[0])
            except Exception:
                logger.warning("查询 idealInterval 失败，降级为 null", exc_info=True)

            return {
                "id": customer_id,
                "name": name,
                "phone": phone,
                "phone_full": phone_full,
                "avatar": "",
                "member_level": "",
                "relation_index": "",
                "tags": [],
                # Banner
                "balance": balance,
                "consumption_60d": consumption_60d,
                "ideal_interval": ideal_interval,
                "days_since_visit": days_since_visit,
                # 扩展模块
                "ai_insight": ai_insight,
                "coach_tasks": coach_tasks,
                "favorite_coaches": favorite_coaches,
                "retention_clues": retention_clues,
                "consumption_records": consumption_records,
                "notes": notes,
            }
        finally:
            etl_conn.close()
    finally:
        conn.close()


# ── 3.2 AI 洞察 / 维客线索 / 备注 ──────────────────────────


def _build_ai_insight(customer_id: int, conn) -> dict:
    """
    构建 aiInsight 模块。

    查询 biz.ai_cache WHERE cache_type='app4_analysis' AND target_id=customerId，
    解析 result_json JSON。无缓存时返回空默认值。
    """
    with conn.cursor() as cur:
        cur.execute(
            """
            SELECT result_json
            FROM biz.ai_cache
            WHERE cache_type = 'app4_analysis'
              AND target_id = %s
            ORDER BY created_at DESC
            LIMIT 1
            """,
            (str(customer_id),),
        )
        row = cur.fetchone()

    if not row or not row[0]:
        return {"summary": "", "strategies": []}

    try:
        data = json.loads(row[0]) if isinstance(row[0], str) else row[0]
    except (json.JSONDecodeError, TypeError):
        return {"summary": "", "strategies": []}

    summary = data.get("summary", "")
    strategies_raw = data.get("strategies", [])
    strategies = []
    for s in strategies_raw:
        if isinstance(s, dict):
            strategies.append({
                "color": s.get("color", ""),
                "text": s.get("text", ""),
            })

    return {"summary": summary, "strategies": strategies}


def _build_retention_clues(customer_id: int, conn) -> list[dict]:
    """
    构建 retentionClues 模块。

    查询 public.member_retention_clue，按 recorded_at 倒序。
    """
    # CHANGE 2026-03-23 | BUG: clue_type/clue_text 列不存在，应为 category/summary；created_at → recorded_at
    with conn.cursor() as cur:
        cur.execute(
            """
            SELECT category, summary
            FROM public.member_retention_clue
            WHERE member_id = %s
              AND is_hidden = false
            ORDER BY recorded_at DESC
            """,
            (customer_id,),
        )
        rows = cur.fetchall()

    return [{"type": r[0] or "", "text": r[1] or ""} for r in rows]


NOTE_TYPE_LABELS = {"normal": "备注", "follow_up": "回访", "system": "系统", "ai": "AI"}


def _build_notes(customer_id: int, conn) -> list[dict]:
    """
    构建 notes 模块。

    查询 biz.notes WHERE target_type='member'，最多 20 条，按 created_at 倒序。
    JOIN auth.users 获取创建者名称，JOIN auth.user_site_roles + auth.roles 获取角色。
    """
    with conn.cursor() as cur:
        cur.execute(
            """
            SELECT n.id, n.type, n.created_at, n.content,
                   COALESCE(u.nickname, '') AS creator_name,
                   COALESCE(r.name, '') AS role_name
            FROM biz.notes n
            LEFT JOIN auth.users u ON n.user_id = u.id
            LEFT JOIN auth.user_site_roles usr
                ON n.user_id = usr.user_id
               AND usr.is_removed = false
            LEFT JOIN auth.roles r ON usr.role_id = r.id
            WHERE n.target_type = 'member'
              AND n.target_id = %s
            ORDER BY n.created_at DESC
            LIMIT 20
            """,
            (customer_id,),
        )
        rows = cur.fetchall()

    return [
        {
            "id": r[0],
            "tag_label": NOTE_TYPE_LABELS.get(r[1], r[1] or "备注"),
            "creator_name": r[4] or "",
            "creator_role": r[5] or "",
            "created_at": r[2].strftime("%Y-%m-%d %H:%M") if r[2] else "",
            "content": r[3] or "",
        }
        for r in rows
    ]


# ── 3.3 消费记录 ──────────────────────────────────────────


def _build_coaches_from_json(coaches_json: list, level_map: dict) -> list[dict]:
    """从 SQL json_agg 结果构建 coaches 子数组。"""
    coaches = []
    for c in coaches_json:
        level_code = c.get("assistant_level")
        level_name = level_map.get(level_code, "") if level_code else ""
        hrs = float(c.get("service_hours") or 0)
        fee = float(c.get("ledger_amount") or 0)
        if fee or hrs:
            coaches.append({
                "name": c.get("assistant_name", ""),
                "level": level_name,
                "level_color": LEVEL_COLOR_MAP.get(level_name, ""),
                "course_type": c.get("course_type") or "基础课",
                "hours": f"{hrs:.1f}h",
                "perf_hours": None,
                "fee": fee,
            })
    return coaches


def _build_settlement_card(rec: dict, table_name_map: dict, level_map: dict) -> dict:
    """从一条结算单级记录构建前端卡片数据。"""
    import json as _json
    coaches_json = rec.get("coaches_json") or []
    if isinstance(coaches_json, str):
        coaches_json = _json.loads(coaches_json)
    coaches = _build_coaches_from_json(coaches_json, level_map)

    settle_time = rec.get("settle_time")
    date_str = settle_time.strftime("%Y-%m-%d") if settle_time else ""
    start_raw = rec.get("start_time")
    end_raw = rec.get("end_time")
    start_str = start_raw.strftime("%H:%M") if start_raw else None
    end_str = end_raw.strftime("%H:%M") if end_raw else None

    svc_hours = rec.get("service_hours", 0.0)
    dur_h = int(svc_hours)
    dur_m = int((svc_hours - dur_h) * 60)
    duration_str = f"{dur_h}h {dur_m}min" if dur_h > 0 else f"{dur_m}min"

    table_fee = rec.get("table_charge_money", 0.0)
    adjust = rec.get("adjust_amount", 0.0)
    table_orig = None
    if adjust > 0.01:
        table_orig = round(table_fee + adjust, 2)

    total_actual = rec.get("total_amount", 0.0)
    consume_orig = rec.get("consume_money", 0.0)
    total_orig = consume_orig if consume_orig > total_actual + 0.01 else None

    return {
        "id": rec.get("id", ""),
        "type": "table",
        "date": date_str,
        "table_name": table_name_map.get(rec.get("table_id"), str(rec.get("table_id", ""))),
        "start_time": start_str,
        "end_time": end_str,
        "duration": duration_str,
        "table_fee": table_fee,
        "table_orig_price": table_orig,
        "coaches": coaches,
        "food_amount": rec.get("goods_money", 0.0),
        "food_orig_price": None,
        "food_detail": rec.get("drinks"),
        "total_amount": total_actual,
        "total_orig_price": total_orig,
        "pay_method": "",
        "recharge_amount": None,
    }


def _build_consumption_records(
    customer_id: int, site_id: int, conn, *, etl_conn: Any = None
) -> list[dict]:
    """
    构建 consumptionRecords 模块。

    按结算单粒度返回，同一结算单下多个助教聚合到 coaches 数组。
    ⚠️ DWD-DOC 规则 1: totalAmount 使用 SUM(ledger_amount)（items_sum 口径）。
    ⚠️ 废单排除: is_delete = 0，正向交易: settle_type IN (1, 3)。
    """
    raw_records = fdw_queries.get_consumption_records(
        conn, site_id, customer_id, limit=5, offset=0, etl_conn=etl_conn
    )

    # 批量查询台桌名称
    table_ids = list({rec.get("table_id") for rec in raw_records if rec.get("table_id")})
    table_name_map: dict = {}
    if table_ids and etl_conn:
        try:
            with fdw_queries._fdw_context(conn, site_id, etl_conn=etl_conn) as cur:
                cur.execute(
                    """
                    SELECT table_id, table_name FROM app.v_dim_table
                    WHERE table_id = ANY(%s) AND scd2_is_current = 1
                    """,
                    (table_ids,),
                )
                for row in cur.fetchall():
                    table_name_map[row[0]] = row[1] or str(row[0])
        except Exception:
            logger.warning("批量查询台桌名称失败", exc_info=True)

    # 获取等级映射
    level_map = {}
    try:
        level_map = fdw_queries.get_level_map(conn, site_id)
    except Exception:
        pass

    return [_build_settlement_card(rec, table_name_map, level_map) for rec in raw_records]


# ── 3.4 关联助教任务（T2-2）──────────────────────────────


def _build_coach_tasks(
    customer_id: int, site_id: int, conn
) -> list[dict]:
    """
    构建 coachTasks 模块。

    CHANGE 2026-03-29 | 性能优化：所有助教信息改为批量查询，消除 N+1
    """
    with conn.cursor() as cur:
        cur.execute(
            """
            SELECT id, assistant_id, task_type, status, updated_at
            FROM biz.coach_tasks
            WHERE member_id = %s
              AND status IN ('active', 'inactive')
            ORDER BY created_at DESC
            """,
            (customer_id,),
        )
        rows = cur.fetchall()

    if not rows:
        return []

    assistant_ids = list({r[1] for r in rows if r[1]})

    # 批量查询助教姓名和等级（一次 FDW 查询）
    assistant_info_map: dict = {}
    try:
        with fdw_queries._fdw_context(conn, site_id) as cur:
            cur.execute(
                """
                SELECT da.assistant_id,
                       COALESCE(da.nickname, da.real_name, '') AS name,
                       da.level
                FROM app.v_dim_assistant da
                WHERE da.assistant_id = ANY(%s) AND da.scd2_is_current = 1
                """,
                (assistant_ids,),
            )
            level_map = fdw_queries.get_level_map(conn, site_id)
            for row in cur.fetchall():
                assistant_info_map[row[0]] = {
                    "name": row[1],
                    "level": level_map.get(row[2], "") if row[2] else "",
                }
    except Exception:
        logger.warning("批量查询助教信息失败", exc_info=True)

    # 批量查询 60 天统计（一次 FDW 查询）
    # CHANGE 2026-05-02 | 用 business_date 替代 CURRENT_DATE，沙箱不读「未来」
    from app.services.runtime_context import as_runtime_today_param
    ref_date = as_runtime_today_param(site_id, conn=conn)
    stats_map: dict = {}
    try:
        with fdw_queries._fdw_context(conn, site_id) as cur:
            cur.execute(
                """
                SELECT site_assistant_id,
                       COUNT(*) AS service_count,
                       SUM(income_seconds) / 3600.0 AS total_hours
                FROM app.v_dwd_assistant_service_log
                WHERE tenant_member_id = %s
                  AND site_assistant_id = ANY(%s)
                  AND is_delete = 0
                  AND create_time >= (%s::date - INTERVAL '60 days')::timestamptz
                  AND create_time <  (%s::date + INTERVAL '1 day')::timestamptz
                GROUP BY site_assistant_id
                """,
                (customer_id, assistant_ids, ref_date, ref_date),
            )
            for row in cur.fetchall():
                svc = int(row[1]) if row[1] else 0
                hrs = float(row[2]) if row[2] else 0.0
                stats_map[row[0]] = {
                    "service_count": svc,
                    "total_hours": round(hrs, 1),
                    "avg_hours": round(hrs / svc, 1) if svc > 0 else 0.0,
                }
    except Exception:
        logger.warning("批量查询 60 天统计失败", exc_info=True)

    # 批量查询 RSI（关系指数，用于爱心标识）
    rsi_map: dict = {}
    try:
        with fdw_queries._fdw_context(conn, site_id) as cur:
            cur.execute(
                """
                SELECT assistant_id, rs_display
                FROM app.v_dws_member_assistant_relation_index
                WHERE member_id = %s AND assistant_id = ANY(%s)
                """,
                (customer_id, assistant_ids),
            )
            for row in cur.fetchall():
                rsi_map[row[0]] = float(row[1]) if row[1] is not None else 0.0
    except Exception:
        logger.warning("批量查询 RSI 失败", exc_info=True)

    result = []
    for row in rows:
        task_id, assistant_id, task_type, status, updated_at = row

        # CHANGE 2026-03-29 | relationship_building 任务按 RSI 过滤（与任务列表页规则一致）
        # 规则：1 < RS < 6 才显示，RS ≤ 1 或 RS ≥ 6 不显示
        if task_type == "relationship_building":
            rs = rsi_map.get(assistant_id, 0.0)
            if rs <= 1.0 or rs >= 6.0:
                continue

        a_info = assistant_info_map.get(assistant_id, {})
        level = a_info.get("level", "")
        name = a_info.get("name", "")
        stats = stats_map.get(assistant_id, {"service_count": 0, "total_hours": 0.0, "avg_hours": 0.0})

        task_meta = TASK_TYPE_MAP.get(task_type, {
            "label": task_type or "",
            "color": "#999",
            "bg_class": "bg-gray",
        })

        metrics = [
            {"label": "近60天次数", "value": f"{stats['service_count']}次", "color": None},
            {"label": "总时长", "value": f"{stats['total_hours']:.1f}h", "color": None},
            {"label": "次均时长", "value": f"{stats['avg_hours']:.1f}h", "color": None},
        ]

        # 格式化上次服务时间：MM-DD HH:mm
        last_svc = ""
        if updated_at:
            last_svc = updated_at.strftime("%m-%d %H:%M")

        result.append({
            "name": name,
            "level": level,
            "level_color": LEVEL_COLOR_MAP.get(level, ""),
            "heart_score": rsi_map.get(assistant_id, 0.0),
            "task_type": task_meta["label"],
            "task_color": task_meta["color"],
            "bg_class": LEVEL_BG_MAP.get(level, task_meta["bg_class"]),
            "status": status,
            "last_service": last_svc,
            "metrics": metrics,
        })

    return result


# ── 3.5 最亲密助教（T2-3）──────────────────────────────


def _build_favorite_coaches(
    customer_id: int, site_id: int, conn
) -> list[dict]:
    """
    构建 favoriteCoaches 模块。

    CHANGE 2026-03-29 | 性能优化：助教信息改为批量查询，避免 N+1 串行查询
    """
    relations = fdw_queries.get_relation_index(conn, site_id, customer_id)
    if not relations:
        return []

    # 批量获取助教姓名（一次查询替代 N 次）
    assistant_ids = [r["assistant_id"] for r in relations if r.get("assistant_id")]
    assistant_info_map: dict = {}
    if assistant_ids:
        try:
            with fdw_queries._fdw_context(conn, site_id) as cur:
                cur.execute(
                    """
                    SELECT assistant_id, COALESCE(nickname, real_name, '') AS name, level
                    FROM app.v_dim_assistant
                    WHERE assistant_id = ANY(%s) AND scd2_is_current = 1
                    """,
                    (assistant_ids,),
                )
                level_map = fdw_queries.get_level_map(conn, site_id)
                for row in cur.fetchall():
                    assistant_info_map[row[0]] = {
                        "name": row[1],
                        "level": level_map.get(row[2], "") if row[2] else "",
                    }
        except Exception:
            logger.warning("批量查询助教信息失败", exc_info=True)

    result = []
    for rel in relations:
        ri = rel.get("relation_index", 0.0)
        aid = rel.get("assistant_id")
        a_info = assistant_info_map.get(aid, {})

        emoji = compute_heart_icon(Decimal(str(ri)))
        if ri > 8.5:
            index_color, bg_class = "#FF6B6B", "fav-card-pink"
        elif ri > 7:
            index_color, bg_class = "#FF8C00", "fav-card-amber"
        elif ri > 5:
            index_color, bg_class = "#FFA726", "fav-card-amber"
        else:
            index_color, bg_class = "#5B9BD5", "fav-card-blue"

        svc_count = rel.get("service_count", 0)
        total_hrs = rel.get("total_hours", 0)
        stats = [
            {"label": "基础", "value": f"{total_hrs:.0f}h", "color": None},
            {"label": "激励", "value": "0h", "color": None},
            {"label": "上课", "value": f"{svc_count}次", "color": None},
            {"label": "总时长", "value": f"{total_hrs:.1f}h", "color": None},
        ]

        result.append({
            "emoji": emoji,
            "name": a_info.get("name", ""),
            "heart_score": ri,
            "level": a_info.get("level", ""),
            "relation_index": f"{ri:.1f}",
            "index_color": index_color,
            "bg_class": bg_class,
            "stats": stats,
        })

    return result


# ── CUST-2 客户服务记录（T2-4）──────────────────────────


@trace_service("获取客户服务记录", "Get customer records")
async def get_customer_records(
    customer_id: int,
    site_id: int,
    user_id: int,
    year: int,
    month: int,
    table: str | None,
    page: int,
    page_size: int,
) -> dict:
    """
    客户服务记录（CUST-2）。

    CHANGE 2026-03-27 | 按助教过滤 + 到手收入计算
    1. 从 user_id 获取 assistant_id（auth.user_assistant_binding）
    2. fdw_queries.get_member_info() → customerName/customerPhone（DQ-6）
    3. fdw_queries.get_customer_service_records() → 按月分页记录（按 assistant_id 过滤）
    4. 聚合 monthCount/monthHours/monthIncome（到手收入）
    5. fdw_queries.get_total_service_count() → totalServiceCount（当前助教对该客户的全部历史）
    6. 构建 ServiceRecordItem 列表，含 recordType/isEstimate/到手收入
    7. hasMore = total_count > page * page_size
    """
    conn = _get_biz_connection()
    try:
        # ── 获取当前用户的 assistant_id ──
        assistant_id = _get_assistant_id(conn, user_id, site_id)

        # CHANGE 2026-03-26 | ETL 连接复用：一次请求只建一个 ETL 连接
        from app.database import get_etl_readonly_connection
        etl_conn = get_etl_readonly_connection(site_id)
        try:
            # CHANGE 2026-03-27 | 统一卡片数据：到手收入已在 fdw_queries SQL 层通过
            # salary_calc JOIN 计算，不再需要 Python 层单独查费率参数

            # ── 客户基础信息（DQ-6）──
            member_info_map = fdw_queries.get_member_info(
                conn, site_id, [customer_id], etl_conn=etl_conn,
            )
            if customer_id not in member_info_map:
                raise HTTPException(status_code=404, detail="客户不存在")

            info = member_info_map[customer_id]
            phone_full = info.get("mobile") or ""
            phone = _mask_phone(phone_full)
            customer_name = info.get("nickname") or ""

            # ── 按月分页服务记录 ──
            offset = (page - 1) * page_size
            records_raw, total_count = fdw_queries.get_customer_service_records(
                conn, site_id, customer_id,
                year, month, table,
                limit=page_size, offset=offset,
                etl_conn=etl_conn,
                assistant_id=assistant_id,
            )

            # ── 月度统计汇总（从全量 total_count + 当页记录工时聚合）──
            # monthCount = 当月总记录数（不是当页），monthHours = 当月总工时
            # 需要单独查询当月汇总，因为分页记录只是子集
            month_count, month_hours = _get_month_aggregation(
                conn, site_id, customer_id, year, month, table,
                etl_conn=etl_conn,
                assistant_id=assistant_id,
            )

            # ── 累计服务总次数（跨所有月份）──
            total_service_count = fdw_queries.get_total_service_count(
                conn, site_id, customer_id, etl_conn=etl_conn,
                assistant_id=assistant_id,
            )

            # ── 构建记录列表 ──
            # CHANGE 2026-03-27 | 统一卡片数据：fdw_queries 已返回 table_name/income(到手)/drinks
            # 不再需要 Python 层计算到手收入和台桌名称映射
            records = []
            for rec in records_raw:
                create_time = rec.get("create_time")
                date_str = create_time.strftime("%Y-%m-%d") if create_time else ""
                start_time = rec.get("start_time")
                end_time = rec.get("end_time")

                # 时间范围格式化
                time_range = None
                if start_time and end_time:
                    time_range = f"{start_time.strftime('%H:%M')}-{end_time.strftime('%H:%M')}"

                # recordType: 根据 course_type 判断
                course_type = rec.get("course_type", "")
                record_type = "recharge" if "充值" in course_type else "course"

                # type / type_class 映射
                if record_type == "recharge":
                    type_label = "充值"
                    type_class = "tag-recharge"
                else:
                    type_label = course_type or "课程"
                    type_class = "tag-course"

                records.append({
                    "id": str(rec.get("id", "")),
                    "date": date_str,
                    "time_range": time_range,
                    "table": rec.get("table_name") or None,
                    "type": type_label,
                    "type_class": type_class,
                    "record_type": record_type,
                    "duration": rec.get("service_hours", 0.0),
                    "duration_raw": rec.get("service_hours_raw"),
                    "income": rec.get("income", 0.0),
                    "is_estimate": rec.get("is_estimate", False),
                    "drinks": rec.get("drinks"),
                })

            has_more = total_count > page * page_size

            # CHANGE 2026-03-27 | 月度到手收入汇总
            month_income = round(sum(r["income"] for r in records), 2)
            # 如果是分页的，需要用全量数据计算（当前只有当页数据）
            # 但月度汇总已经有 _get_month_aggregation，这里用当页记录的到手收入
            # 后续可优化为 SQL 层面计算

            # CHANGE 2026-03-27 | 关系指数：查询当前助教与该客户的 RS 指数
            relation_index_str = ""
            if assistant_id:
                relations = fdw_queries.get_relation_index(
                    conn, site_id, customer_id,
                )
                for rel in relations:
                    if rel.get("assistant_id") == assistant_id:
                        ri = rel.get("relation_index", 0.0)
                        relation_index_str = f"{ri:.1f}" if ri else ""
                        break

            return {
                "customer_name": customer_name,
                "customer_phone": phone,
                "customer_phone_full": phone_full,
                "relation_index": relation_index_str,
                "tables": [],
                "total_service_count": total_service_count,
                "month_count": month_count,
                "month_hours": round(month_hours, 2),
                "month_income": month_income,
                "records": records,
                "has_more": has_more,
            }
        finally:
            etl_conn.close()
    finally:
        conn.close()


def _get_month_aggregation(
    conn, site_id: int, customer_id: int,
    year: int, month: int, table: str | None,
    *, etl_conn: Any = None,
    assistant_id: int | None = None,
) -> tuple[int, float]:
    """
    查询当月汇总统计（monthCount + monthHours）。

    复用 fdw_queries 的 _fdw_context 直连 ETL 库。
    ⚠️ 废单排除: is_delete = 0。
    CHANGE 2026-03-27 | 新增 assistant_id 过滤。
    """
    start_date = f"{year}-{month:02d}-01"
    if month == 12:
        end_date = f"{year + 1}-01-01"
    else:
        end_date = f"{year}-{month + 1:02d}-01"

    base_where = """
        tenant_member_id = %s
        AND is_delete = 0
        AND create_time >= %s::timestamptz
        AND create_time < %s::timestamptz
    """
    params: list = [customer_id, start_date, end_date]
    if assistant_id:
        base_where += " AND site_assistant_id = %s"
        params.append(assistant_id)
    if table:
        base_where += " AND site_table_id::text = %s"
        params.append(table)

    with fdw_queries._fdw_context(conn, site_id, etl_conn=etl_conn) as cur:
        cur.execute(
            f"""
            SELECT COUNT(*) AS month_count,
                   COALESCE(SUM(income_seconds / 3600.0), 0) AS month_hours
            FROM app.v_dwd_assistant_service_log
            WHERE {base_where}
            """,
            params,
        )
        row = cur.fetchone()

    if not row:
        return 0, 0.0
    return row[0] or 0, float(row[1]) if row[1] is not None else 0.0


# ── CUST-3: 客户消费记录（按月）──────────────────────────────


@trace_service("获取客户消费记录", "Get customer consumption records")
async def get_customer_consumption_records(
    customer_id: int,
    site_id: int,
    year: int,
    month: int,
) -> dict:
    """
    客户消费记录（CUST-3）。

    按月份过滤，复用 _build_consumption_records 的逻辑。
    返回 banner 数据 + 月度汇总（到店次数/消费总额/充值总额）+ 消费记录列表。
    ⚠️ DWD-DOC 规则 1: totalAmount 使用 ledger_amount（items_sum 口径）。
    ⚠️ 废单排除: is_delete = 0，正向交易: settle_type IN (1, 3)。
    """
    conn = _get_biz_connection()
    try:
        from app.database import get_etl_readonly_connection
        etl_conn = get_etl_readonly_connection(site_id)
        try:
            # ── Banner 数据（复用 CUST-1 逻辑）──
            member_info_map = fdw_queries.get_member_info(
                conn, site_id, [customer_id], etl_conn=etl_conn,
            )
            if customer_id not in member_info_map:
                raise HTTPException(status_code=404, detail="客户不存在")

            info = member_info_map[customer_id]
            phone_full = info.get("mobile") or ""
            phone = _mask_phone(phone_full)
            name = info.get("nickname") or ""

            balance = None
            try:
                balance_map = fdw_queries.get_member_balance(
                    conn, site_id, [customer_id], etl_conn=etl_conn,
                )
                if customer_id in balance_map:
                    balance = float(balance_map[customer_id])
            except Exception:
                logger.warning("查询 balance 失败，降级为 null", exc_info=True)

            consumption_60d = None
            try:
                val = fdw_queries.get_consumption_60d(
                    conn, site_id, customer_id, etl_conn=etl_conn,
                )
                if val is not None:
                    consumption_60d = float(val)
            except Exception:
                logger.warning("查询 consumption_60d 失败，降级为 null", exc_info=True)

            days_since_visit = None
            try:
                visit_map = fdw_queries.get_last_visit_days(
                    conn, site_id, [customer_id], etl_conn=etl_conn,
                )
                if customer_id in visit_map:
                    days_since_visit = visit_map[customer_id]
            except Exception:
                logger.warning("查询 daysSinceVisit 失败，降级为 null", exc_info=True)

            ideal_interval = None
            try:
                with fdw_queries._fdw_context(conn, site_id, etl_conn=etl_conn) as cur:
                    cur.execute(
                        "SELECT ideal_interval_days FROM app.v_dws_member_winback_index WHERE member_id = %s",
                        (customer_id,),
                    )
                    row = cur.fetchone()
                    if row and row[0] is not None:
                        ideal_interval = int(row[0])
            except Exception:
                logger.warning("查询 idealInterval 失败，降级为 null", exc_info=True)

            # ── 月度消费记录（带月份过滤）──
            start_date = f"{year}-{month:02d}-01"
            if month == 12:
                end_date = f"{year + 1}-01-01"
            else:
                end_date = f"{year}-{month + 1:02d}-01"

            raw_records = _get_consumption_records_by_month(
                conn, site_id, customer_id, start_date, end_date,
                etl_conn=etl_conn,
            )

            # ── 月度汇总（从 settlement_head 聚合）──
            month_summary = _get_consumption_month_summary(
                conn, site_id, customer_id, start_date, end_date,
                etl_conn=etl_conn,
            )

            return {
                # Banner
                "id": customer_id,
                "name": name,
                "phone": phone,
                "phone_full": phone_full,
                "balance": balance,
                "consumption_60d": consumption_60d,
                "ideal_interval": ideal_interval,
                "days_since_visit": days_since_visit,
                # 月度汇总
                "visit_count": month_summary["visit_count"],
                "consume_total": month_summary["consume_total"],
                "recharge_total": month_summary["recharge_total"],
                # 消费记录
                "records": raw_records,
            }
        finally:
            etl_conn.close()
    finally:
        conn.close()


def _get_consumption_records_by_month(
    conn, site_id: int, customer_id: int,
    start_date: str, end_date: str,
    *, etl_conn=None,
) -> list[dict]:
    """
    按月份过滤的消费记录，复用 _build_settlement_card 构建逻辑。

    ⚠️ DWD-DOC 规则 1: totalAmount 使用 SUM(ledger_amount)（items_sum 口径）。
    """
    raw_records = fdw_queries.get_consumption_records(
        conn, site_id, customer_id, limit=200, offset=0, etl_conn=etl_conn,
        start_date=start_date, end_date=end_date,
    )

    # 批量查询台桌名称
    table_ids = list({rec.get("table_id") for rec in raw_records if rec.get("table_id")})
    table_name_map: dict = {}
    if table_ids and etl_conn:
        try:
            with fdw_queries._fdw_context(conn, site_id, etl_conn=etl_conn) as cur:
                cur.execute(
                    """
                    SELECT table_id, table_name FROM app.v_dim_table
                    WHERE table_id = ANY(%s) AND scd2_is_current = 1
                    """,
                    (table_ids,),
                )
                for row in cur.fetchall():
                    table_name_map[row[0]] = row[1] or str(row[0])
        except Exception:
            logger.warning("批量查询台桌名称失败", exc_info=True)

    # 获取等级映射
    level_map = {}
    try:
        level_map = fdw_queries.get_level_map(conn, site_id)
    except Exception:
        pass

    return [_build_settlement_card(rec, table_name_map, level_map) for rec in raw_records]


def _get_consumption_month_summary(
    conn, site_id: int, customer_id: int,
    start_date: str, end_date: str,
    *, etl_conn=None,
) -> dict:
    """
    月度消费汇总：到店次数、消费总额、充值总额。

    到店次数：当月不同日期的结算单数（去重 settle_date）。
    消费总额：当月 ledger_amount 合计（items_sum 口径）。
    充值总额：当月 recharge_card_amount 合计（从 settlement_head）。
    ⚠️ 废单排除: is_delete = 0，正向交易: settle_type IN (1, 3)。
    """
    visit_count = 0
    consume_total = 0.0
    recharge_total = 0.0

    try:
        with fdw_queries._fdw_context(conn, site_id, etl_conn=etl_conn) as cur:
            # 到店次数 + 消费总额（从 service_log）
            cur.execute(
                """
                SELECT COUNT(DISTINCT sl.create_time::date) AS visit_days,
                       COALESCE(SUM(sl.ledger_amount), 0) AS consume_total
                FROM app.v_dwd_assistant_service_log sl
                LEFT JOIN app.v_dwd_settlement_head sh
                    ON sl.order_settle_id = sh.order_settle_id
                WHERE sl.tenant_member_id = %s
                  AND sl.is_delete = 0
                  AND sh.settle_type IN (1, 3)
                  AND sl.create_time >= %s::timestamptz
                  AND sl.create_time < %s::timestamptz
                """,
                (customer_id, start_date, end_date),
            )
            row = cur.fetchone()
            if row:
                visit_count = row[0] or 0
                consume_total = float(row[1]) if row[1] else 0.0

            # 充值总额（从 settlement_head 的 recharge_card_amount）
            cur.execute(
                """
                SELECT COALESCE(SUM(sh.recharge_card_amount), 0)
                FROM app.v_dwd_settlement_head sh
                WHERE sh.tenant_member_id = %s
                  AND sh.is_delete = 0
                  AND sh.recharge_card_amount > 0
                  AND sh.settle_time >= %s::timestamptz
                  AND sh.settle_time < %s::timestamptz
                """,
                (customer_id, start_date, end_date),
            )
            row = cur.fetchone()
            if row:
                recharge_total = float(row[0]) if row[0] else 0.0
    except Exception:
        logger.warning("查询月度消费汇总失败", exc_info=True)

    return {
        "visit_count": visit_count,
        "consume_total": round(consume_total, 2),
        "recharge_total": round(recharge_total, 2),
    }
