feat: batch update - gift card breakdown spec, backend APIs, miniprogram pages, ETL finance recharge, docs & migrations
This commit is contained in:
@@ -9,6 +9,13 @@ from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
from app.middleware.response_wrapper import (
|
||||
ResponseWrapperMiddleware,
|
||||
http_exception_handler,
|
||||
unhandled_exception_handler,
|
||||
)
|
||||
|
||||
from app import config
|
||||
# CHANGE 2026-02-19 | 新增 xcx_test 路由(MVP 验证)+ wx_callback 路由(微信消息推送)
|
||||
@@ -19,7 +26,10 @@ from app import config
|
||||
# CHANGE 2026-02-27 | 新增 xcx_tasks / xcx_notes 路由(小程序核心业务)
|
||||
# CHANGE 2026-03-09 | 新增 xcx_ai_chat 路由(AI SSE 对话 + 历史对话)
|
||||
# CHANGE 2026-03-09 | 新增 xcx_ai_cache 路由(AI 缓存查询)
|
||||
from app.routers import auth, execution, schedules, tasks, env_config, db_viewer, etl_status, xcx_test, wx_callback, member_retention_clue, ops_panel, xcx_auth, admin_applications, business_day, xcx_tasks, xcx_notes, xcx_ai_chat, xcx_ai_cache
|
||||
# CHANGE 2026-03-18 | 新增 xcx_customers 路由(CUST-1 客户详情、CUST-2 客户服务记录)
|
||||
# CHANGE 2026-03-19 | 新增 xcx_coaches 路由(COACH-1 助教详情)
|
||||
# CHANGE 2026-03-19 | 新增 xcx_board / xcx_config 路由(RNS1.3 三看板 + 技能类型配置)
|
||||
from app.routers import auth, execution, schedules, tasks, env_config, db_viewer, etl_status, xcx_test, wx_callback, member_retention_clue, ops_panel, xcx_auth, admin_applications, business_day, xcx_tasks, xcx_notes, xcx_ai_chat, xcx_ai_cache, xcx_performance, xcx_customers, xcx_coaches, xcx_board, xcx_config
|
||||
from app.services.scheduler import scheduler
|
||||
from app.services.task_queue import task_queue
|
||||
from app.ws.logs import ws_router
|
||||
@@ -103,6 +113,14 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# ---- 全局响应包装中间件(在 CORS 之后添加,执行顺序在 CORS 内层) ----
|
||||
# CHANGE 2026-03-16 | RNS1.0 T0-1: 全局响应包装 + 异常处理器
|
||||
app.add_middleware(ResponseWrapperMiddleware)
|
||||
|
||||
# ---- 全局异常处理器 ----
|
||||
app.add_exception_handler(StarletteHTTPException, http_exception_handler)
|
||||
app.add_exception_handler(Exception, unhandled_exception_handler)
|
||||
|
||||
# ---- 路由注册 ----
|
||||
app.include_router(auth.router)
|
||||
app.include_router(tasks.router)
|
||||
@@ -123,6 +141,11 @@ app.include_router(xcx_tasks.router)
|
||||
app.include_router(xcx_notes.router)
|
||||
app.include_router(xcx_ai_chat.router)
|
||||
app.include_router(xcx_ai_cache.router)
|
||||
app.include_router(xcx_performance.router)
|
||||
app.include_router(xcx_customers.router)
|
||||
app.include_router(xcx_coaches.router)
|
||||
app.include_router(xcx_board.router)
|
||||
app.include_router(xcx_config.router)
|
||||
|
||||
|
||||
@app.get("/health", tags=["系统"])
|
||||
|
||||
196
apps/backend/app/middleware/response_wrapper.py
Normal file
196
apps/backend/app/middleware/response_wrapper.py
Normal file
@@ -0,0 +1,196 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
全局响应包装中间件 + 异常处理器。
|
||||
|
||||
ResponseWrapperMiddleware(ASGI 中间件):
|
||||
对 JSON 成功响应(2xx + application/json)自动包装为 { "code": 0, "data": <原始body> }。
|
||||
跳过条件:text/event-stream(SSE)、非 application/json、非 2xx 状态码。
|
||||
|
||||
ExceptionHandler 函数(http_exception_handler / unhandled_exception_handler):
|
||||
统一格式化错误响应为 { "code": <status_code>, "message": <detail> }。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ResponseWrapperMiddleware:
|
||||
"""ASGI 中间件:全局响应包装。
|
||||
|
||||
拦截 http.response.start / http.response.body,对满足条件的响应
|
||||
包装为 { "code": 0, "data": <原始body> }。
|
||||
|
||||
跳过条件(透传原始响应):
|
||||
1. content-type 为 text/event-stream(SSE 端点)
|
||||
2. content-type 不包含 application/json(文件下载等)
|
||||
3. HTTP 状态码非 2xx(错误响应已由 ExceptionHandler 格式化)
|
||||
"""
|
||||
|
||||
def __init__(self, app: ASGIApp) -> None:
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
# 仅处理 HTTP 请求,WebSocket / lifespan 等直接透传
|
||||
if scope["type"] != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
# 用于在 send 回调间共享状态
|
||||
should_wrap = False
|
||||
status_code = 0
|
||||
# 缓存 start message,包装时需要修改 content-length
|
||||
cached_start: Message | None = None
|
||||
# 收集所有 body 分片(more_body 场景)
|
||||
body_parts: list[bytes] = []
|
||||
|
||||
async def send_wrapper(message: Message) -> None:
|
||||
nonlocal should_wrap, status_code, cached_start
|
||||
|
||||
if message["type"] == "http.response.start":
|
||||
status_code = message.get("status", 200)
|
||||
headers = dict(
|
||||
(k.lower(), v)
|
||||
for k, v in (
|
||||
(k if isinstance(k, bytes) else k.encode(),
|
||||
v if isinstance(v, bytes) else v.encode())
|
||||
for k, v in message.get("headers", [])
|
||||
)
|
||||
)
|
||||
content_type = headers.get(b"content-type", b"").decode("latin-1").lower()
|
||||
|
||||
# 判断是否需要包装
|
||||
is_2xx = 200 <= status_code < 300
|
||||
is_json = "application/json" in content_type
|
||||
is_sse = "text/event-stream" in content_type
|
||||
|
||||
if is_2xx and is_json and not is_sse:
|
||||
should_wrap = True
|
||||
# 缓存 start message,等 body 完整后再发送(需要更新 content-length)
|
||||
cached_start = message
|
||||
else:
|
||||
# 不包装,直接透传
|
||||
should_wrap = False
|
||||
await send(message)
|
||||
return
|
||||
|
||||
if message["type"] == "http.response.body":
|
||||
if not should_wrap:
|
||||
# 不包装,直接透传
|
||||
await send(message)
|
||||
return
|
||||
|
||||
# 收集 body 分片
|
||||
body_parts.append(message.get("body", b""))
|
||||
more_body = message.get("more_body", False)
|
||||
|
||||
if not more_body:
|
||||
# body 完整,执行包装
|
||||
original_body = b"".join(body_parts)
|
||||
try:
|
||||
wrapped = _wrap_success_body(original_body)
|
||||
except Exception:
|
||||
# 包装失败(如 JSON 解析错误),透传原始响应
|
||||
logger.debug(
|
||||
"响应包装失败,透传原始响应",
|
||||
exc_info=True,
|
||||
)
|
||||
if cached_start is not None:
|
||||
await send(cached_start)
|
||||
await send({
|
||||
"type": "http.response.body",
|
||||
"body": original_body,
|
||||
})
|
||||
return
|
||||
|
||||
# 更新 content-length 并发送
|
||||
if cached_start is not None:
|
||||
new_headers = _update_content_length(
|
||||
cached_start.get("headers", []),
|
||||
len(wrapped),
|
||||
)
|
||||
await send({
|
||||
"type": "http.response.start",
|
||||
"status": cached_start.get("status", 200),
|
||||
"headers": new_headers,
|
||||
})
|
||||
await send({
|
||||
"type": "http.response.body",
|
||||
"body": wrapped,
|
||||
})
|
||||
# 如果 more_body=True,继续收集,不发送
|
||||
return
|
||||
|
||||
try:
|
||||
await self.app(scope, receive, send_wrapper)
|
||||
except Exception:
|
||||
# 中间件自身异常不应阻塞请求,但这里是 app 内部异常,
|
||||
# 正常情况下由 ExceptionHandler 处理,此处仅做兜底日志
|
||||
logger.exception("ResponseWrapperMiddleware 捕获到未处理异常")
|
||||
raise
|
||||
|
||||
|
||||
def _wrap_success_body(original_body: bytes) -> bytes:
|
||||
"""将原始 JSON body 包装为 { "code": 0, "data": <parsed_body> }。
|
||||
|
||||
如果原始 body 为空,data 设为 null。
|
||||
"""
|
||||
if not original_body or original_body.strip() == b"":
|
||||
data: Any = None
|
||||
else:
|
||||
data = json.loads(original_body)
|
||||
|
||||
wrapped = {"code": 0, "data": data}
|
||||
return json.dumps(wrapped, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||
|
||||
|
||||
def _update_content_length(
|
||||
headers: list[tuple[bytes, bytes] | list],
|
||||
new_length: int,
|
||||
) -> list[list[bytes]]:
|
||||
"""替换 headers 中的 content-length 为新值。"""
|
||||
new_headers: list[list[bytes]] = []
|
||||
found = False
|
||||
for pair in headers:
|
||||
k = pair[0] if isinstance(pair[0], bytes) else pair[0].encode()
|
||||
v = pair[1] if isinstance(pair[1], bytes) else pair[1].encode()
|
||||
if k.lower() == b"content-length":
|
||||
new_headers.append([k, str(new_length).encode()])
|
||||
found = True
|
||||
else:
|
||||
new_headers.append([k, v])
|
||||
if not found:
|
||||
new_headers.append([b"content-length", str(new_length).encode()])
|
||||
return new_headers
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exception Handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
|
||||
"""HTTPException → { code: <status_code>, message: <detail> }"""
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={"code": exc.status_code, "message": exc.detail},
|
||||
)
|
||||
|
||||
|
||||
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||
"""未捕获异常 → { code: 500, message: "Internal Server Error" }
|
||||
完整堆栈写入服务端日志。"""
|
||||
logger.exception("未捕获异常: %s", exc)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"code": 500, "message": "Internal Server Error"},
|
||||
)
|
||||
74
apps/backend/app/routers/xcx_board.py
Normal file
74
apps/backend/app/routers/xcx_board.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
看板路由:BOARD-1(助教)、BOARD-2(客户)、BOARD-3(财务)。
|
||||
|
||||
前缀 /api/xcx/board,由 main.py 注册。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.auth.dependencies import CurrentUser
|
||||
from app.middleware.permission import require_permission
|
||||
from app.schemas.xcx_board import (
|
||||
AreaFilterEnum,
|
||||
BoardTimeEnum,
|
||||
CoachBoardResponse,
|
||||
CoachSortEnum,
|
||||
CustomerBoardResponse,
|
||||
CustomerDimensionEnum,
|
||||
FinanceBoardResponse,
|
||||
FinanceTimeEnum,
|
||||
ProjectFilterEnum,
|
||||
SkillFilterEnum,
|
||||
)
|
||||
from app.services import board_service
|
||||
|
||||
router = APIRouter(prefix="/api/xcx/board", tags=["xcx-board"])
|
||||
|
||||
|
||||
@router.get("/coaches", response_model=CoachBoardResponse)
|
||||
async def get_coach_board(
|
||||
sort: CoachSortEnum = Query(default=CoachSortEnum.perf_desc),
|
||||
skill: SkillFilterEnum = Query(default=SkillFilterEnum.all),
|
||||
time: BoardTimeEnum = Query(default=BoardTimeEnum.month),
|
||||
user: CurrentUser = Depends(require_permission("view_board_coach")),
|
||||
):
|
||||
"""助教看板(BOARD-1)。"""
|
||||
return await board_service.get_coach_board(
|
||||
sort=sort.value, skill=skill.value, time=time.value,
|
||||
site_id=user.site_id,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/customers", response_model=CustomerBoardResponse)
|
||||
async def get_customer_board(
|
||||
dimension: CustomerDimensionEnum = Query(default=CustomerDimensionEnum.recall),
|
||||
project: ProjectFilterEnum = Query(default=ProjectFilterEnum.all),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
user: CurrentUser = Depends(require_permission("view_board_customer")),
|
||||
):
|
||||
"""客户看板(BOARD-2)。"""
|
||||
return await board_service.get_customer_board(
|
||||
dimension=dimension.value, project=project.value,
|
||||
page=page, page_size=page_size, site_id=user.site_id,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/finance",
|
||||
response_model=FinanceBoardResponse,
|
||||
response_model_exclude_none=True,
|
||||
)
|
||||
async def get_finance_board(
|
||||
time: FinanceTimeEnum = Query(default=FinanceTimeEnum.month),
|
||||
area: AreaFilterEnum = Query(default=AreaFilterEnum.all),
|
||||
compare: int = Query(default=0, ge=0, le=1),
|
||||
user: CurrentUser = Depends(require_permission("view_board_finance")),
|
||||
):
|
||||
"""财务看板(BOARD-3)。"""
|
||||
return await board_service.get_finance_board(
|
||||
time=time.value, area=area.value, compare=compare,
|
||||
site_id=user.site_id,
|
||||
)
|
||||
31
apps/backend/app/routers/xcx_coaches.py
Normal file
31
apps/backend/app/routers/xcx_coaches.py
Normal file
@@ -0,0 +1,31 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
小程序助教路由 —— 助教详情(COACH-1)。
|
||||
|
||||
端点清单:
|
||||
- GET /api/xcx/coaches/{coach_id} — 助教详情(COACH-1)
|
||||
|
||||
所有端点均需 JWT(approved 状态)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.auth.dependencies import CurrentUser
|
||||
from app.middleware.permission import require_approved
|
||||
from app.schemas.xcx_coaches import CoachDetailResponse
|
||||
from app.services import coach_service
|
||||
|
||||
router = APIRouter(prefix="/api/xcx/coaches", tags=["小程序助教"])
|
||||
|
||||
|
||||
@router.get("/{coach_id}", response_model=CoachDetailResponse)
|
||||
async def get_coach_detail(
|
||||
coach_id: int,
|
||||
user: CurrentUser = Depends(require_approved()),
|
||||
):
|
||||
"""助教详情(COACH-1)。"""
|
||||
return await coach_service.get_coach_detail(
|
||||
coach_id, user.site_id
|
||||
)
|
||||
37
apps/backend/app/routers/xcx_config.py
Normal file
37
apps/backend/app/routers/xcx_config.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
配置路由:CONFIG-1 技能类型。
|
||||
|
||||
前缀 /api/xcx/config,由 main.py 注册。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.auth.dependencies import CurrentUser
|
||||
from app.middleware.permission import require_approved
|
||||
from app.schemas.xcx_config import SkillTypeItem
|
||||
from app.services import fdw_queries
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/xcx/config", tags=["xcx-config"])
|
||||
|
||||
|
||||
@router.get("/skill-types", response_model=list[SkillTypeItem])
|
||||
async def get_skill_types(
|
||||
user: CurrentUser = Depends(require_approved()),
|
||||
):
|
||||
"""技能类型配置(CONFIG-1)。查询失败降级返回空数组。"""
|
||||
try:
|
||||
from app.database import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
return fdw_queries.get_skill_types(conn, user.site_id)
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception:
|
||||
logger.warning("CONFIG-1 技能类型查询失败,降级为空数组", exc_info=True)
|
||||
return []
|
||||
51
apps/backend/app/routers/xcx_customers.py
Normal file
51
apps/backend/app/routers/xcx_customers.py
Normal file
@@ -0,0 +1,51 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
小程序客户路由 —— 客户详情(CUST-1)、客户服务记录(CUST-2)。
|
||||
|
||||
端点清单:
|
||||
- GET /api/xcx/customers/{customer_id} — 客户详情(CUST-1)
|
||||
- GET /api/xcx/customers/{customer_id}/records — 客户服务记录(CUST-2)
|
||||
|
||||
所有端点均需 JWT(approved 状态)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.auth.dependencies import CurrentUser
|
||||
from app.middleware.permission import require_approved
|
||||
from app.schemas.xcx_customers import (
|
||||
CustomerDetailResponse,
|
||||
CustomerRecordsResponse,
|
||||
)
|
||||
from app.services import customer_service
|
||||
|
||||
router = APIRouter(prefix="/api/xcx/customers", tags=["小程序客户"])
|
||||
|
||||
|
||||
@router.get("/{customer_id}", response_model=CustomerDetailResponse)
|
||||
async def get_customer_detail(
|
||||
customer_id: int,
|
||||
user: CurrentUser = Depends(require_approved()),
|
||||
):
|
||||
"""客户详情(CUST-1)。"""
|
||||
return await customer_service.get_customer_detail(
|
||||
customer_id, user.site_id
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{customer_id}/records", response_model=CustomerRecordsResponse)
|
||||
async def get_customer_records(
|
||||
customer_id: int,
|
||||
year: int = Query(...),
|
||||
month: int = Query(..., ge=1, le=12),
|
||||
table: str | None = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
user: CurrentUser = Depends(require_approved()),
|
||||
):
|
||||
"""客户服务记录(CUST-2)。"""
|
||||
return await customer_service.get_customer_records(
|
||||
customer_id, user.site_id, year, month, table, page, page_size
|
||||
)
|
||||
50
apps/backend/app/routers/xcx_performance.py
Normal file
50
apps/backend/app/routers/xcx_performance.py
Normal file
@@ -0,0 +1,50 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
小程序绩效路由 —— 绩效概览、绩效明细。
|
||||
|
||||
端点清单:
|
||||
- GET /api/xcx/performance — 绩效概览(PERF-1)
|
||||
- GET /api/xcx/performance/records — 绩效明细(PERF-2)
|
||||
|
||||
所有端点均需 JWT(approved 状态)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.auth.dependencies import CurrentUser
|
||||
from app.middleware.permission import require_approved
|
||||
from app.schemas.xcx_performance import (
|
||||
PerformanceOverviewResponse,
|
||||
PerformanceRecordsResponse,
|
||||
)
|
||||
from app.services import performance_service
|
||||
|
||||
router = APIRouter(prefix="/api/xcx/performance", tags=["小程序绩效"])
|
||||
|
||||
|
||||
@router.get("", response_model=PerformanceOverviewResponse)
|
||||
async def get_performance_overview(
|
||||
year: int = Query(...),
|
||||
month: int = Query(..., ge=1, le=12),
|
||||
user: CurrentUser = Depends(require_approved()),
|
||||
):
|
||||
"""绩效概览(PERF-1)。"""
|
||||
return await performance_service.get_overview(
|
||||
user.user_id, user.site_id, year, month
|
||||
)
|
||||
|
||||
|
||||
@router.get("/records", response_model=PerformanceRecordsResponse)
|
||||
async def get_performance_records(
|
||||
year: int = Query(...),
|
||||
month: int = Query(..., ge=1, le=12),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
user: CurrentUser = Depends(require_approved()),
|
||||
):
|
||||
"""绩效明细(PERF-2)。"""
|
||||
return await performance_service.get_records(
|
||||
user.user_id, user.site_id, year, month, page, page_size
|
||||
)
|
||||
@@ -1,35 +1,56 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
小程序任务路由 —— 任务列表、置顶、放弃、取消放弃。
|
||||
小程序任务路由 —— 任务列表、任务详情、置顶、放弃、取消放弃。
|
||||
|
||||
端点清单:
|
||||
- GET /api/xcx/tasks — 获取活跃任务列表
|
||||
- GET /api/xcx/tasks — 获取任务列表 + 绩效概览(TASK-1)
|
||||
- GET /api/xcx/tasks/{task_id} — 获取任务详情完整版(TASK-2)
|
||||
- POST /api/xcx/tasks/{id}/pin — 置顶任务
|
||||
- POST /api/xcx/tasks/{id}/unpin — 取消置顶
|
||||
- POST /api/xcx/tasks/{id}/abandon — 放弃任务
|
||||
- POST /api/xcx/tasks/{id}/cancel-abandon — 取消放弃
|
||||
- POST /api/xcx/tasks/{id}/restore — 恢复任务
|
||||
|
||||
所有端点均需 JWT(approved 状态)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.auth.dependencies import CurrentUser
|
||||
from app.middleware.permission import require_approved
|
||||
from app.schemas.xcx_tasks import AbandonRequest, TaskListItem
|
||||
from app.schemas.xcx_tasks import (
|
||||
AbandonRequest,
|
||||
TaskDetailResponse,
|
||||
TaskListResponse,
|
||||
)
|
||||
from app.services import task_manager
|
||||
|
||||
router = APIRouter(prefix="/api/xcx/tasks", tags=["小程序任务"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[TaskListItem])
|
||||
@router.get("", response_model=TaskListResponse)
|
||||
async def get_tasks(
|
||||
status: str = Query("pending", pattern="^(pending|completed|abandoned)$"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
user: CurrentUser = Depends(require_approved()),
|
||||
):
|
||||
"""获取当前助教的活跃任务列表。"""
|
||||
return await task_manager.get_task_list(user.user_id, user.site_id)
|
||||
"""获取任务列表 + 绩效概览。"""
|
||||
return await task_manager.get_task_list_v2(
|
||||
user.user_id, user.site_id, status, page, page_size
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{task_id}", response_model=TaskDetailResponse)
|
||||
async def get_task_detail(
|
||||
task_id: int,
|
||||
user: CurrentUser = Depends(require_approved()),
|
||||
):
|
||||
"""获取任务详情完整版。"""
|
||||
return await task_manager.get_task_detail(
|
||||
task_id, user.user_id, user.site_id
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{task_id}/pin")
|
||||
@@ -38,7 +59,8 @@ async def pin_task(
|
||||
user: CurrentUser = Depends(require_approved()),
|
||||
):
|
||||
"""置顶任务。"""
|
||||
return await task_manager.pin_task(task_id, user.user_id, user.site_id)
|
||||
result = await task_manager.pin_task(task_id, user.user_id, user.site_id)
|
||||
return {"is_pinned": result["is_pinned"]}
|
||||
|
||||
|
||||
@router.post("/{task_id}/unpin")
|
||||
@@ -47,7 +69,8 @@ async def unpin_task(
|
||||
user: CurrentUser = Depends(require_approved()),
|
||||
):
|
||||
"""取消置顶。"""
|
||||
return await task_manager.unpin_task(task_id, user.user_id, user.site_id)
|
||||
result = await task_manager.unpin_task(task_id, user.user_id, user.site_id)
|
||||
return {"is_pinned": result["is_pinned"]}
|
||||
|
||||
|
||||
@router.post("/{task_id}/abandon")
|
||||
@@ -62,8 +85,8 @@ async def abandon_task(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{task_id}/cancel-abandon")
|
||||
async def cancel_abandon(
|
||||
@router.post("/{task_id}/restore")
|
||||
async def restore_task(
|
||||
task_id: int,
|
||||
user: CurrentUser = Depends(require_approved()),
|
||||
):
|
||||
|
||||
17
apps/backend/app/schemas/base.py
Normal file
17
apps/backend/app/schemas/base.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic.alias_generators import to_camel
|
||||
|
||||
|
||||
class CamelModel(BaseModel):
|
||||
"""所有小程序 API 响应 schema 的基类。
|
||||
|
||||
- alias_generator=to_camel:JSON 输出字段名自动转 camelCase
|
||||
- populate_by_name=True:同时接受 snake_case 和 camelCase 输入
|
||||
- from_attributes=True:支持从 ORM 对象/dict 构造
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
alias_generator=to_camel,
|
||||
populate_by_name=True,
|
||||
from_attributes=True,
|
||||
)
|
||||
@@ -6,17 +6,19 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import Field
|
||||
|
||||
from app.schemas.base import CamelModel
|
||||
|
||||
|
||||
# ── 微信登录 ──────────────────────────────────────────────
|
||||
|
||||
class WxLoginRequest(BaseModel):
|
||||
class WxLoginRequest(CamelModel):
|
||||
"""微信登录请求。"""
|
||||
code: str = Field(..., min_length=1, description="微信临时登录凭证")
|
||||
|
||||
|
||||
class WxLoginResponse(BaseModel):
|
||||
class WxLoginResponse(CamelModel):
|
||||
"""微信登录响应。"""
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
@@ -25,7 +27,7 @@ class WxLoginResponse(BaseModel):
|
||||
user_id: int
|
||||
|
||||
|
||||
class DevLoginRequest(BaseModel):
|
||||
class DevLoginRequest(CamelModel):
|
||||
"""开发模式 mock 登录请求(仅 WX_DEV_MODE=true 时可用)。"""
|
||||
openid: str = Field(..., min_length=1, description="模拟的微信 openid")
|
||||
status: str | None = Field(None, description="模拟的用户状态;为空时保留已有用户的当前状态,新用户默认 new")
|
||||
@@ -33,24 +35,24 @@ class DevLoginRequest(BaseModel):
|
||||
|
||||
# ── 开发调试端点(仅 WX_DEV_MODE=true) ─────────────────
|
||||
|
||||
class DevSwitchRoleRequest(BaseModel):
|
||||
class DevSwitchRoleRequest(CamelModel):
|
||||
"""切换角色请求。替换当前用户在当前门店下的所有角色为指定角色。"""
|
||||
role_code: str = Field(..., description="目标角色 code(coach/staff/site_admin/tenant_admin)")
|
||||
|
||||
|
||||
class DevSwitchStatusRequest(BaseModel):
|
||||
class DevSwitchStatusRequest(CamelModel):
|
||||
"""切换用户状态请求。"""
|
||||
status: str = Field(..., description="目标状态(new/pending/approved/rejected/disabled)")
|
||||
|
||||
|
||||
class DevSwitchBindingRequest(BaseModel):
|
||||
class DevSwitchBindingRequest(CamelModel):
|
||||
"""切换人员绑定请求。"""
|
||||
binding_type: str = Field(..., description="绑定类型(assistant/staff/manager)")
|
||||
assistant_id: int | None = Field(None, description="助教 ID(binding_type=assistant 时必填)")
|
||||
staff_id: int | None = Field(None, description="员工 ID(binding_type=staff/manager 时必填)")
|
||||
|
||||
|
||||
class DevContextResponse(BaseModel):
|
||||
class DevContextResponse(CamelModel):
|
||||
"""开发调试上下文信息。"""
|
||||
user_id: int
|
||||
openid: str | None = None
|
||||
@@ -67,7 +69,7 @@ class DevLoginRequest(BaseModel):
|
||||
|
||||
# ── 用户申请 ──────────────────────────────────────────────
|
||||
|
||||
class ApplicationRequest(BaseModel):
|
||||
class ApplicationRequest(CamelModel):
|
||||
"""用户申请提交请求。"""
|
||||
site_code: str = Field(..., pattern=r"^[A-Za-z]{2}\d{3}$", description="球房ID")
|
||||
applied_role_text: str = Field(..., min_length=1, max_length=100, description="申请身份")
|
||||
@@ -76,7 +78,7 @@ class ApplicationRequest(BaseModel):
|
||||
nickname: str | None = Field(None, max_length=50, description="昵称")
|
||||
|
||||
|
||||
class ApplicationResponse(BaseModel):
|
||||
class ApplicationResponse(CamelModel):
|
||||
"""申请记录响应。"""
|
||||
id: int
|
||||
site_code: str
|
||||
@@ -89,7 +91,7 @@ class ApplicationResponse(BaseModel):
|
||||
|
||||
# ── 用户状态 ──────────────────────────────────────────────
|
||||
|
||||
class UserStatusResponse(BaseModel):
|
||||
class UserStatusResponse(CamelModel):
|
||||
"""用户状态查询响应。"""
|
||||
user_id: int
|
||||
status: str
|
||||
@@ -99,28 +101,28 @@ class UserStatusResponse(BaseModel):
|
||||
|
||||
# ── 店铺 ──────────────────────────────────────────────────
|
||||
|
||||
class SiteInfo(BaseModel):
|
||||
class SiteInfo(CamelModel):
|
||||
"""店铺信息。"""
|
||||
site_id: int
|
||||
site_name: str
|
||||
roles: list[dict] = []
|
||||
|
||||
|
||||
class SwitchSiteRequest(BaseModel):
|
||||
class SwitchSiteRequest(CamelModel):
|
||||
"""切换店铺请求。"""
|
||||
site_id: int
|
||||
|
||||
|
||||
# ── 刷新令牌 ──────────────────────────────────────────────
|
||||
|
||||
class RefreshTokenRequest(BaseModel):
|
||||
class RefreshTokenRequest(CamelModel):
|
||||
"""刷新令牌请求。"""
|
||||
refresh_token: str = Field(..., min_length=1, description="刷新令牌")
|
||||
|
||||
|
||||
# ── 人员匹配 ──────────────────────────────────────────────
|
||||
|
||||
class MatchCandidate(BaseModel):
|
||||
class MatchCandidate(CamelModel):
|
||||
"""匹配候选人。"""
|
||||
source_type: str # assistant / staff
|
||||
id: int
|
||||
@@ -131,38 +133,38 @@ class MatchCandidate(BaseModel):
|
||||
|
||||
# ── 管理端审核 ────────────────────────────────────────────
|
||||
|
||||
class ApproveRequest(BaseModel):
|
||||
class ApproveRequest(CamelModel):
|
||||
"""批准申请请求。"""
|
||||
role_id: int
|
||||
binding: dict | None = None # {"assistant_id": ..., "staff_id": ..., "binding_type": ...}
|
||||
review_note: str | None = None
|
||||
|
||||
|
||||
class RejectRequest(BaseModel):
|
||||
class RejectRequest(CamelModel):
|
||||
"""拒绝申请请求。"""
|
||||
review_note: str = Field(..., min_length=1, description="拒绝原因")
|
||||
|
||||
|
||||
# ── 开发调试端点(仅 WX_DEV_MODE=true) ─────────────────
|
||||
|
||||
class DevSwitchRoleRequest(BaseModel):
|
||||
class DevSwitchRoleRequest(CamelModel):
|
||||
"""切换角色请求。替换当前用户在当前门店下的所有角色为指定角色。"""
|
||||
role_code: str = Field(..., description="目标角色 code(coach/staff/site_admin/tenant_admin)")
|
||||
|
||||
|
||||
class DevSwitchStatusRequest(BaseModel):
|
||||
class DevSwitchStatusRequest(CamelModel):
|
||||
"""切换用户状态请求。"""
|
||||
status: str = Field(..., description="目标状态(new/pending/approved/rejected/disabled)")
|
||||
|
||||
|
||||
class DevSwitchBindingRequest(BaseModel):
|
||||
class DevSwitchBindingRequest(CamelModel):
|
||||
"""切换人员绑定请求。"""
|
||||
binding_type: str = Field(..., description="绑定类型(assistant/staff/manager)")
|
||||
assistant_id: int | None = Field(None, description="助教 ID(binding_type=assistant 时必填)")
|
||||
staff_id: int | None = Field(None, description="员工 ID(binding_type=staff/manager 时必填)")
|
||||
|
||||
|
||||
class DevContextResponse(BaseModel):
|
||||
class DevContextResponse(CamelModel):
|
||||
"""开发调试上下文信息。"""
|
||||
user_id: int
|
||||
openid: str | None = None
|
||||
@@ -178,24 +180,24 @@ class DevContextResponse(BaseModel):
|
||||
|
||||
# ── 开发调试端点(仅 WX_DEV_MODE=true) ─────────────────
|
||||
|
||||
class DevSwitchRoleRequest(BaseModel):
|
||||
class DevSwitchRoleRequest(CamelModel):
|
||||
"""切换角色请求。替换当前用户在当前门店下的所有角色为指定角色。"""
|
||||
role_code: str = Field(..., description="目标角色 code(coach/staff/site_admin/tenant_admin)")
|
||||
|
||||
|
||||
class DevSwitchStatusRequest(BaseModel):
|
||||
class DevSwitchStatusRequest(CamelModel):
|
||||
"""切换用户状态请求。"""
|
||||
status: str = Field(..., description="目标状态(new/pending/approved/rejected/disabled)")
|
||||
|
||||
|
||||
class DevSwitchBindingRequest(BaseModel):
|
||||
class DevSwitchBindingRequest(CamelModel):
|
||||
"""切换人员绑定请求。"""
|
||||
binding_type: str = Field(..., description="绑定类型(assistant/staff/manager)")
|
||||
assistant_id: int | None = Field(None, description="助教 ID(binding_type=assistant 时必填)")
|
||||
staff_id: int | None = Field(None, description="员工 ID(binding_type=staff/manager 时必填)")
|
||||
|
||||
|
||||
class DevContextResponse(BaseModel):
|
||||
class DevContextResponse(CamelModel):
|
||||
"""开发调试上下文信息。"""
|
||||
user_id: int
|
||||
openid: str | None = None
|
||||
@@ -207,4 +209,3 @@ class DevContextResponse(BaseModel):
|
||||
permissions: list[str] = []
|
||||
binding: dict | None = None
|
||||
all_sites: list[dict] = []
|
||||
|
||||
|
||||
433
apps/backend/app/schemas/xcx_board.py
Normal file
433
apps/backend/app/schemas/xcx_board.py
Normal file
@@ -0,0 +1,433 @@
|
||||
"""三看板接口 Pydantic Schema(BOARD-1/2/3 请求参数枚举 + 响应模型)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
from app.schemas.base import CamelModel
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 请求参数枚举(Task 2.1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class CoachSortEnum(str, Enum):
|
||||
"""BOARD-1 排序维度。"""
|
||||
perf_desc = "perf_desc"
|
||||
perf_asc = "perf_asc"
|
||||
salary_desc = "salary_desc"
|
||||
salary_asc = "salary_asc"
|
||||
sv_desc = "sv_desc"
|
||||
task_desc = "task_desc"
|
||||
|
||||
|
||||
class SkillFilterEnum(str, Enum):
|
||||
"""BOARD-1 技能筛选。"""
|
||||
all = "all"
|
||||
chinese = "chinese"
|
||||
snooker = "snooker"
|
||||
mahjong = "mahjong"
|
||||
karaoke = "karaoke"
|
||||
|
||||
|
||||
class BoardTimeEnum(str, Enum):
|
||||
"""BOARD-1 时间范围。"""
|
||||
month = "month"
|
||||
quarter = "quarter"
|
||||
last_month = "last_month"
|
||||
last_3m = "last_3m"
|
||||
last_quarter = "last_quarter"
|
||||
last_6m = "last_6m"
|
||||
|
||||
|
||||
class CustomerDimensionEnum(str, Enum):
|
||||
"""BOARD-2 客户维度。"""
|
||||
recall = "recall"
|
||||
potential = "potential"
|
||||
balance = "balance"
|
||||
recharge = "recharge"
|
||||
recent = "recent"
|
||||
spend60 = "spend60"
|
||||
freq60 = "freq60"
|
||||
loyal = "loyal"
|
||||
|
||||
|
||||
class ProjectFilterEnum(str, Enum):
|
||||
"""BOARD-2 项目筛选。"""
|
||||
all = "all"
|
||||
chinese = "chinese"
|
||||
snooker = "snooker"
|
||||
mahjong = "mahjong"
|
||||
karaoke = "karaoke"
|
||||
|
||||
|
||||
class FinanceTimeEnum(str, Enum):
|
||||
"""BOARD-3 时间范围。"""
|
||||
month = "month"
|
||||
lastMonth = "lastMonth"
|
||||
week = "week"
|
||||
lastWeek = "lastWeek"
|
||||
quarter3 = "quarter3"
|
||||
quarter = "quarter"
|
||||
lastQuarter = "lastQuarter"
|
||||
half6 = "half6"
|
||||
|
||||
|
||||
class AreaFilterEnum(str, Enum):
|
||||
"""BOARD-3 区域筛选。"""
|
||||
all = "all"
|
||||
hall = "hall"
|
||||
hallA = "hallA"
|
||||
hallB = "hallB"
|
||||
hallC = "hallC"
|
||||
mahjong = "mahjong"
|
||||
teamBuilding = "teamBuilding"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BOARD-1 响应 Schema(Task 2.2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class CoachSkillItem(CamelModel):
|
||||
text: str
|
||||
cls: str
|
||||
|
||||
|
||||
class CoachBoardItem(CamelModel):
|
||||
"""助教看板单条记录(扁平结构,包含所有维度字段)。"""
|
||||
|
||||
# 基础字段(所有维度共享)
|
||||
id: int
|
||||
name: str
|
||||
initial: str
|
||||
avatar_gradient: str
|
||||
level: str # star/senior/middle/junior
|
||||
skills: list[CoachSkillItem]
|
||||
top_customers: list[str] # ["💖 王先生", "💛 李女士"]
|
||||
|
||||
# perf 维度
|
||||
perf_hours: float = 0.0
|
||||
perf_hours_before: float | None = None
|
||||
perf_gap: str | None = None # "距升档 13.8h" 或 None
|
||||
perf_reached: bool = False
|
||||
|
||||
# salary 维度
|
||||
salary: float = 0.0
|
||||
salary_perf_hours: float = 0.0
|
||||
salary_perf_before: float | None = None
|
||||
|
||||
# sv 维度
|
||||
sv_amount: float = 0.0
|
||||
sv_customer_count: int = 0
|
||||
sv_consume: float = 0.0
|
||||
|
||||
# task 维度
|
||||
task_recall: int = 0
|
||||
task_callback: int = 0
|
||||
|
||||
|
||||
class CoachBoardResponse(CamelModel):
|
||||
items: list[CoachBoardItem]
|
||||
dim_type: str # perf/salary/sv/task
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BOARD-2 响应 Schema(Task 2.3)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class CustomerAssistant(CamelModel):
|
||||
name: str
|
||||
cls: str
|
||||
heart_score: float
|
||||
badge: str | None = None
|
||||
badge_cls: str | None = None
|
||||
|
||||
|
||||
class CustomerBoardItemBase(CamelModel):
|
||||
"""客户看板基础字段(所有维度共享)。"""
|
||||
id: int
|
||||
name: str
|
||||
initial: str
|
||||
avatar_cls: str
|
||||
assistants: list[CustomerAssistant]
|
||||
|
||||
|
||||
class RecallItem(CustomerBoardItemBase):
|
||||
ideal_days: int
|
||||
elapsed_days: int
|
||||
overdue_days: int
|
||||
visits_30d: int
|
||||
balance: str
|
||||
recall_index: float
|
||||
|
||||
|
||||
class PotentialTag(CamelModel):
|
||||
text: str
|
||||
theme: str
|
||||
|
||||
|
||||
class PotentialItem(CustomerBoardItemBase):
|
||||
potential_tags: list[PotentialTag]
|
||||
spend_30d: float
|
||||
avg_visits: float
|
||||
avg_spend: float
|
||||
|
||||
|
||||
class BalanceItem(CustomerBoardItemBase):
|
||||
balance: str
|
||||
last_visit: str # "3天前"
|
||||
monthly_consume: float
|
||||
available_months: str # "约0.8个月"
|
||||
|
||||
|
||||
class RechargeItem(CustomerBoardItemBase):
|
||||
last_recharge: str
|
||||
recharge_amount: float
|
||||
recharges_60d: int
|
||||
current_balance: str
|
||||
|
||||
|
||||
class RecentItem(CustomerBoardItemBase):
|
||||
days_ago: int
|
||||
visit_freq: str # "6.2次/月"
|
||||
ideal_days: int
|
||||
visits_30d: int
|
||||
avg_spend: float
|
||||
|
||||
|
||||
class Spend60Item(CustomerBoardItemBase):
|
||||
spend_60d: float
|
||||
visits_60d: int
|
||||
high_spend_tag: bool
|
||||
avg_spend: float
|
||||
|
||||
|
||||
class WeeklyVisit(CamelModel):
|
||||
val: int
|
||||
pct: int # 0-100
|
||||
|
||||
|
||||
class Freq60Item(CustomerBoardItemBase):
|
||||
visits_60d: int
|
||||
avg_interval: str # "5.0天"
|
||||
weekly_visits: list[WeeklyVisit] # 固定长度 8
|
||||
spend_60d: float
|
||||
|
||||
|
||||
class CoachDetail(CamelModel):
|
||||
name: str
|
||||
cls: str
|
||||
heart_score: float
|
||||
badge: str | None = None
|
||||
avg_duration: str
|
||||
service_count: int
|
||||
coach_spend: float
|
||||
relation_idx: float
|
||||
|
||||
|
||||
class LoyalItem(CustomerBoardItemBase):
|
||||
intimacy: float
|
||||
top_coach_name: str
|
||||
top_coach_heart: float
|
||||
top_coach_score: float
|
||||
coach_name: str
|
||||
coach_ratio: str # "78%"
|
||||
coach_details: list[CoachDetail]
|
||||
|
||||
|
||||
class CustomerBoardResponse(CamelModel):
|
||||
items: list[dict] # 实际类型取决于 dimension
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BOARD-3 响应 Schema(Task 2.4)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class OverviewPanel(CamelModel):
|
||||
"""经营一览:8 项核心指标 + 各 3 个环比字段(Optional,compare=0 时为 None)。"""
|
||||
occurrence: float
|
||||
discount: float # 负值
|
||||
discount_rate: float
|
||||
confirmed_revenue: float
|
||||
cash_in: float
|
||||
cash_out: float
|
||||
cash_balance: float
|
||||
balance_rate: float
|
||||
# occurrence 环比
|
||||
occurrence_compare: str | None = None
|
||||
occurrence_down: bool | None = None
|
||||
occurrence_flat: bool | None = None
|
||||
# discount 环比
|
||||
discount_compare: str | None = None
|
||||
discount_down: bool | None = None
|
||||
discount_flat: bool | None = None
|
||||
# discount_rate 环比
|
||||
discount_rate_compare: str | None = None
|
||||
discount_rate_down: bool | None = None
|
||||
discount_rate_flat: bool | None = None
|
||||
# confirmed_revenue 环比
|
||||
confirmed_revenue_compare: str | None = None
|
||||
confirmed_revenue_down: bool | None = None
|
||||
confirmed_revenue_flat: bool | None = None
|
||||
# cash_in 环比
|
||||
cash_in_compare: str | None = None
|
||||
cash_in_down: bool | None = None
|
||||
cash_in_flat: bool | None = None
|
||||
# cash_out 环比
|
||||
cash_out_compare: str | None = None
|
||||
cash_out_down: bool | None = None
|
||||
cash_out_flat: bool | None = None
|
||||
# cash_balance 环比
|
||||
cash_balance_compare: str | None = None
|
||||
cash_balance_down: bool | None = None
|
||||
cash_balance_flat: bool | None = None
|
||||
# balance_rate 环比
|
||||
balance_rate_compare: str | None = None
|
||||
balance_rate_down: bool | None = None
|
||||
balance_rate_flat: bool | None = None
|
||||
|
||||
|
||||
class GiftCell(CamelModel):
|
||||
value: float
|
||||
compare: str | None = None
|
||||
down: bool | None = None
|
||||
flat: bool | None = None
|
||||
|
||||
|
||||
class GiftRow(CamelModel):
|
||||
"""赠送卡矩阵一行:合计 / 酒水卡 / 台费卡 / 抵用券。"""
|
||||
label: str # "新增" / "消费" / "余额"
|
||||
total: GiftCell
|
||||
liquor: GiftCell
|
||||
table_fee: GiftCell
|
||||
voucher: GiftCell
|
||||
|
||||
|
||||
class RechargePanel(CamelModel):
|
||||
"""预收资产板块:储值卡 5 指标 + 赠送卡 3×4 矩阵 + 全卡余额。"""
|
||||
actual_income: float
|
||||
first_charge: float
|
||||
renew_charge: float
|
||||
consumed: float
|
||||
card_balance: float
|
||||
gift_rows: list[GiftRow] # 3 行
|
||||
all_card_balance: float
|
||||
# 储值卡各项环比字段
|
||||
actual_income_compare: str | None = None
|
||||
actual_income_down: bool | None = None
|
||||
actual_income_flat: bool | None = None
|
||||
first_charge_compare: str | None = None
|
||||
first_charge_down: bool | None = None
|
||||
first_charge_flat: bool | None = None
|
||||
renew_charge_compare: str | None = None
|
||||
renew_charge_down: bool | None = None
|
||||
renew_charge_flat: bool | None = None
|
||||
consumed_compare: str | None = None
|
||||
consumed_down: bool | None = None
|
||||
consumed_flat: bool | None = None
|
||||
card_balance_compare: str | None = None
|
||||
card_balance_down: bool | None = None
|
||||
card_balance_flat: bool | None = None
|
||||
|
||||
|
||||
class RevenueStructureRow(CamelModel):
|
||||
id: str
|
||||
name: str
|
||||
desc: str | None = None
|
||||
is_sub: bool = False
|
||||
amount: float
|
||||
discount: float
|
||||
booked: float
|
||||
booked_compare: str | None = None
|
||||
|
||||
|
||||
class RevenueItem(CamelModel):
|
||||
label: str
|
||||
amount: float
|
||||
|
||||
|
||||
class ChannelItem(CamelModel):
|
||||
label: str
|
||||
amount: float
|
||||
|
||||
|
||||
class RevenuePanel(CamelModel):
|
||||
structure_rows: list[RevenueStructureRow]
|
||||
price_items: list[RevenueItem] # 4 项
|
||||
total_occurrence: float
|
||||
discount_items: list[RevenueItem] # 4 项
|
||||
confirmed_total: float
|
||||
channel_items: list[ChannelItem] # 3 项
|
||||
|
||||
|
||||
class CashflowItem(CamelModel):
|
||||
label: str
|
||||
amount: float
|
||||
|
||||
|
||||
class CashflowPanel(CamelModel):
|
||||
consume_items: list[CashflowItem] # 3 项
|
||||
recharge_items: list[CashflowItem] # 1 项
|
||||
total: float
|
||||
|
||||
|
||||
class ExpenseItem(CamelModel):
|
||||
label: str
|
||||
amount: float
|
||||
compare: str | None = None
|
||||
down: bool | None = None
|
||||
flat: bool | None = None
|
||||
|
||||
|
||||
class ExpensePanel(CamelModel):
|
||||
operation_items: list[ExpenseItem] # 3 项
|
||||
fixed_items: list[ExpenseItem] # 4 项
|
||||
coach_items: list[ExpenseItem] # 4 项
|
||||
platform_items: list[ExpenseItem] # 3 项
|
||||
total: float
|
||||
total_compare: str | None = None
|
||||
total_down: bool | None = None
|
||||
total_flat: bool | None = None
|
||||
|
||||
|
||||
class CoachAnalysisRow(CamelModel):
|
||||
level: str
|
||||
pay: float
|
||||
share: float
|
||||
hourly: float
|
||||
pay_compare: str | None = None
|
||||
pay_down: bool | None = None
|
||||
share_compare: str | None = None
|
||||
share_down: bool | None = None
|
||||
hourly_compare: str | None = None
|
||||
hourly_flat: bool | None = None
|
||||
|
||||
|
||||
class CoachAnalysisTable(CamelModel):
|
||||
total_pay: float
|
||||
total_share: float
|
||||
avg_hourly: float
|
||||
total_pay_compare: str | None = None
|
||||
total_pay_down: bool | None = None
|
||||
total_share_compare: str | None = None
|
||||
total_share_down: bool | None = None
|
||||
avg_hourly_compare: str | None = None
|
||||
avg_hourly_flat: bool | None = None
|
||||
rows: list[CoachAnalysisRow] # 4 行:初级/中级/高级/星级
|
||||
|
||||
|
||||
class CoachAnalysisPanel(CamelModel):
|
||||
basic: CoachAnalysisTable # 基础课/陪打
|
||||
incentive: CoachAnalysisTable # 激励课/超休
|
||||
|
||||
|
||||
class FinanceBoardResponse(CamelModel):
|
||||
overview: OverviewPanel
|
||||
recharge: RechargePanel | None # area≠all 时为 null
|
||||
revenue: RevenuePanel
|
||||
cashflow: CashflowPanel
|
||||
expense: ExpensePanel
|
||||
coach_analysis: CoachAnalysisPanel
|
||||
116
apps/backend/app/schemas/xcx_coaches.py
Normal file
116
apps/backend/app/schemas/xcx_coaches.py
Normal file
@@ -0,0 +1,116 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.schemas.base import CamelModel
|
||||
|
||||
|
||||
class PerformanceMetrics(CamelModel):
|
||||
monthly_hours: float
|
||||
monthly_salary: float
|
||||
customer_balance: float
|
||||
tasks_completed: int
|
||||
perf_current: float
|
||||
perf_target: float
|
||||
|
||||
|
||||
class IncomeItem(CamelModel):
|
||||
label: str
|
||||
amount: str
|
||||
color: str
|
||||
|
||||
|
||||
class IncomeSection(CamelModel):
|
||||
this_month: list[IncomeItem] = []
|
||||
last_month: list[IncomeItem] = []
|
||||
|
||||
|
||||
class CoachTaskItem(CamelModel):
|
||||
type_label: str
|
||||
type_class: str
|
||||
customer_name: str
|
||||
customer_id: int | None = None
|
||||
note_count: int = 0
|
||||
pinned: bool = False
|
||||
notes: list[dict] | None = None
|
||||
|
||||
|
||||
class AbandonedTask(CamelModel):
|
||||
customer_name: str
|
||||
reason: str
|
||||
|
||||
|
||||
class TopCustomer(CamelModel):
|
||||
id: int
|
||||
name: str
|
||||
initial: str
|
||||
avatar_gradient: str
|
||||
heart_emoji: str # ❤️ / 💛 / 🤍
|
||||
score: str
|
||||
score_color: str
|
||||
service_count: int
|
||||
balance: str
|
||||
consume: str
|
||||
|
||||
|
||||
class CoachServiceRecord(CamelModel):
|
||||
customer_id: int | None = None
|
||||
customer_name: str
|
||||
initial: str
|
||||
avatar_gradient: str
|
||||
type: str
|
||||
type_class: str
|
||||
table: str | None = None
|
||||
duration: str
|
||||
income: str
|
||||
date: str
|
||||
perf_hours: str | None = None
|
||||
|
||||
|
||||
class HistoryMonth(CamelModel):
|
||||
month: str
|
||||
estimated: bool
|
||||
customers: str
|
||||
hours: str
|
||||
salary: str
|
||||
callback_done: int
|
||||
recall_done: int
|
||||
|
||||
|
||||
class CoachNoteItem(CamelModel):
|
||||
id: int
|
||||
content: str
|
||||
timestamp: str
|
||||
score: int | None = None
|
||||
customer_name: str
|
||||
tag_label: str
|
||||
created_at: str
|
||||
|
||||
|
||||
class CoachDetailResponse(CamelModel):
|
||||
"""COACH-1 响应。"""
|
||||
# 基础信息
|
||||
id: int
|
||||
name: str
|
||||
avatar: str
|
||||
level: str
|
||||
skills: list[str] = []
|
||||
work_years: float = 0
|
||||
customer_count: int = 0
|
||||
hire_date: str | None = None
|
||||
# 绩效
|
||||
performance: PerformanceMetrics
|
||||
# 收入
|
||||
income: IncomeSection
|
||||
# 档位
|
||||
tier_nodes: list[float] = []
|
||||
# 任务分组
|
||||
visible_tasks: list[CoachTaskItem] = []
|
||||
hidden_tasks: list[CoachTaskItem] = []
|
||||
abandoned_tasks: list[AbandonedTask] = []
|
||||
# TOP 客户
|
||||
top_customers: list[TopCustomer] = []
|
||||
# 近期服务记录
|
||||
service_records: list[CoachServiceRecord] = []
|
||||
# 历史月份
|
||||
history_months: list[HistoryMonth] = []
|
||||
# 备注
|
||||
notes: list[CoachNoteItem] = []
|
||||
12
apps/backend/app/schemas/xcx_config.py
Normal file
12
apps/backend/app/schemas/xcx_config.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""CONFIG-1 技能类型响应 Schema。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.schemas.base import CamelModel
|
||||
|
||||
|
||||
class SkillTypeItem(CamelModel):
|
||||
key: str # chinese/snooker/mahjong/karaoke
|
||||
label: str # 中文标签
|
||||
emoji: str # 表情符号
|
||||
cls: str # 前端样式类
|
||||
124
apps/backend/app/schemas/xcx_customers.py
Normal file
124
apps/backend/app/schemas/xcx_customers.py
Normal file
@@ -0,0 +1,124 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.schemas.base import CamelModel
|
||||
|
||||
|
||||
class AiStrategy(CamelModel):
|
||||
color: str
|
||||
text: str
|
||||
|
||||
class AiInsight(CamelModel):
|
||||
summary: str = ""
|
||||
strategies: list[AiStrategy] = []
|
||||
|
||||
class MetricItem(CamelModel):
|
||||
label: str
|
||||
value: str
|
||||
color: str | None = None
|
||||
|
||||
class CoachTask(CamelModel):
|
||||
name: str
|
||||
level: str # star / senior / middle / junior
|
||||
level_color: str
|
||||
task_type: str
|
||||
task_color: str
|
||||
bg_class: str
|
||||
status: str
|
||||
last_service: str | None = None
|
||||
metrics: list[MetricItem] = []
|
||||
|
||||
class FavoriteCoach(CamelModel):
|
||||
emoji: str # 💖 / 💛
|
||||
name: str
|
||||
relation_index: str
|
||||
index_color: str
|
||||
bg_class: str
|
||||
stats: list[MetricItem] = []
|
||||
|
||||
class CoachServiceItem(CamelModel):
|
||||
name: str
|
||||
level: str
|
||||
level_color: str
|
||||
course_type: str # "基础课" / "激励课"
|
||||
hours: float
|
||||
perf_hours: float | None = None
|
||||
fee: float
|
||||
|
||||
class ConsumptionRecord(CamelModel):
|
||||
id: str
|
||||
type: str # table / shop / recharge
|
||||
date: str
|
||||
table_name: str | None = None
|
||||
start_time: str | None = None
|
||||
end_time: str | None = None
|
||||
duration: int | None = None
|
||||
table_fee: float | None = None
|
||||
table_orig_price: float | None = None
|
||||
coaches: list[CoachServiceItem] = []
|
||||
food_amount: float | None = None
|
||||
food_orig_price: float | None = None
|
||||
total_amount: float
|
||||
total_orig_price: float
|
||||
pay_method: str
|
||||
recharge_amount: float | None = None
|
||||
|
||||
class RetentionClue(CamelModel):
|
||||
type: str
|
||||
text: str
|
||||
|
||||
class CustomerNote(CamelModel):
|
||||
id: int
|
||||
tag_label: str
|
||||
created_at: str
|
||||
content: str
|
||||
|
||||
class CustomerDetailResponse(CamelModel):
|
||||
"""CUST-1 响应。"""
|
||||
# 基础信息
|
||||
id: int
|
||||
name: str
|
||||
phone: str
|
||||
phone_full: str
|
||||
avatar: str
|
||||
member_level: str
|
||||
relation_index: str
|
||||
tags: list[str] = []
|
||||
# Banner 概览
|
||||
balance: float | None = None
|
||||
consumption_60d: float | None = None
|
||||
ideal_interval: int | None = None
|
||||
days_since_visit: int | None = None
|
||||
# 扩展模块
|
||||
ai_insight: AiInsight = AiInsight()
|
||||
coach_tasks: list[CoachTask] = []
|
||||
favorite_coaches: list[FavoriteCoach] = []
|
||||
retention_clues: list[RetentionClue] = []
|
||||
consumption_records: list[ConsumptionRecord] = []
|
||||
notes: list[CustomerNote] = []
|
||||
|
||||
class ServiceRecordItem(CamelModel):
|
||||
id: str
|
||||
date: str
|
||||
time_range: str | None = None
|
||||
table: str | None = None
|
||||
type: str
|
||||
type_class: str
|
||||
record_type: str | None = None # course / recharge
|
||||
duration: float
|
||||
duration_raw: float | None = None
|
||||
income: float
|
||||
is_estimate: bool = False
|
||||
drinks: str | None = None
|
||||
|
||||
class CustomerRecordsResponse(CamelModel):
|
||||
"""CUST-2 响应。"""
|
||||
customer_name: str
|
||||
customer_phone: str
|
||||
customer_phone_full: str
|
||||
relation_index: str
|
||||
tables: list[dict] = []
|
||||
total_service_count: int
|
||||
month_count: int
|
||||
month_hours: float
|
||||
records: list[ServiceRecordItem] = []
|
||||
has_more: bool = False
|
||||
@@ -6,21 +6,23 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import Field
|
||||
|
||||
from app.schemas.base import CamelModel
|
||||
|
||||
|
||||
class NoteCreateRequest(BaseModel):
|
||||
"""创建备注请求(含星星评分,评分 1-5 范围约束)。"""
|
||||
class NoteCreateRequest(CamelModel):
|
||||
"""创建备注请求(含手动评分:再次服务意愿 + 再来店可能性,各 1-5)。"""
|
||||
|
||||
target_type: str = Field(default="member")
|
||||
target_id: int
|
||||
content: str = Field(..., min_length=1)
|
||||
task_id: int | None = None
|
||||
rating_service_willingness: int | None = Field(None, ge=1, le=5)
|
||||
rating_revisit_likelihood: int | None = Field(None, ge=1, le=5)
|
||||
rating_service_willingness: int | None = Field(None, ge=1, le=5, description="再次服务意愿(1-5)")
|
||||
rating_revisit_likelihood: int | None = Field(None, ge=1, le=5, description="再来店可能性(1-5)")
|
||||
|
||||
|
||||
class NoteOut(BaseModel):
|
||||
class NoteOut(CamelModel):
|
||||
"""备注输出模型(含评分 + AI 评分)。"""
|
||||
|
||||
id: int
|
||||
|
||||
122
apps/backend/app/schemas/xcx_performance.py
Normal file
122
apps/backend/app/schemas/xcx_performance.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""
|
||||
小程序绩效相关 Pydantic 模型。
|
||||
|
||||
覆盖:绩效概览(PERF-1)、绩效明细(PERF-2)响应结构。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.schemas.base import CamelModel
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 绩效通用模型
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class DateGroupRecord(CamelModel):
|
||||
"""按日期分组的单条服务记录。"""
|
||||
|
||||
customer_name: str
|
||||
avatar_char: str | None = None # PERF-1 返回,PERF-2 不返回
|
||||
avatar_color: str | None = None # PERF-1 返回,PERF-2 不返回
|
||||
time_range: str
|
||||
hours: str
|
||||
course_type: str
|
||||
course_type_class: str # 'basic' | 'vip' | 'tip'
|
||||
location: str
|
||||
income: str
|
||||
|
||||
|
||||
class DateGroup(CamelModel):
|
||||
"""按日期分组的服务记录组。"""
|
||||
|
||||
date: str
|
||||
total_hours: str
|
||||
total_income: str
|
||||
records: list[DateGroupRecord]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PERF-1 绩效概览
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TierInfo(CamelModel):
|
||||
"""档位信息(含基础/激励费率)。"""
|
||||
|
||||
basic_rate: float
|
||||
incentive_rate: float
|
||||
|
||||
|
||||
class IncomeItem(CamelModel):
|
||||
"""收入明细项。"""
|
||||
|
||||
icon: str
|
||||
label: str
|
||||
desc: str
|
||||
value: str
|
||||
|
||||
|
||||
class CustomerSummary(CamelModel):
|
||||
"""客户摘要(新客/常客基类)。"""
|
||||
|
||||
name: str
|
||||
avatar_char: str
|
||||
avatar_color: str
|
||||
|
||||
|
||||
class NewCustomer(CustomerSummary):
|
||||
"""新客户。"""
|
||||
|
||||
last_service: str
|
||||
count: int
|
||||
|
||||
|
||||
class RegularCustomer(CustomerSummary):
|
||||
"""常客。"""
|
||||
|
||||
hours: float
|
||||
income: str
|
||||
count: int
|
||||
|
||||
|
||||
class PerformanceOverviewResponse(CamelModel):
|
||||
"""PERF-1 响应。"""
|
||||
|
||||
coach_name: str
|
||||
coach_role: str
|
||||
store_name: str
|
||||
monthly_income: str
|
||||
last_month_income: str
|
||||
current_tier: TierInfo
|
||||
next_tier: TierInfo
|
||||
upgrade_hours_needed: float
|
||||
upgrade_bonus: float
|
||||
income_items: list[IncomeItem]
|
||||
monthly_total: str
|
||||
this_month_records: list[DateGroup]
|
||||
new_customers: list[NewCustomer]
|
||||
regular_customers: list[RegularCustomer]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PERF-2 绩效明细
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class RecordsSummary(CamelModel):
|
||||
"""月度汇总。"""
|
||||
|
||||
total_count: int
|
||||
total_hours: float
|
||||
total_hours_raw: float
|
||||
total_income: float
|
||||
|
||||
|
||||
class PerformanceRecordsResponse(CamelModel):
|
||||
"""PERF-2 响应。"""
|
||||
|
||||
summary: RecordsSummary
|
||||
date_groups: list[DateGroup]
|
||||
has_more: bool
|
||||
@@ -1,15 +1,17 @@
|
||||
"""
|
||||
小程序任务相关 Pydantic 模型。
|
||||
|
||||
覆盖:任务列表项、放弃请求等场景。
|
||||
覆盖:任务列表项、任务详情、绩效概览、放弃请求等场景。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import Field
|
||||
|
||||
from app.schemas.base import CamelModel
|
||||
|
||||
|
||||
class TaskListItem(BaseModel):
|
||||
class TaskListItem(CamelModel):
|
||||
"""任务列表项(含客户信息 + RS 指数 + 爱心 icon)。"""
|
||||
|
||||
id: int
|
||||
@@ -30,7 +32,138 @@ class TaskListItem(BaseModel):
|
||||
abandon_reason: str | None = None
|
||||
|
||||
|
||||
class AbandonRequest(BaseModel):
|
||||
class AbandonRequest(CamelModel):
|
||||
"""放弃任务请求(reason 必填)。"""
|
||||
|
||||
reason: str = Field(..., min_length=1, description="放弃原因(必填)")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RNS1.1 扩展模型
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PerformanceSummary(CamelModel):
|
||||
"""绩效概览(附带在任务列表响应中)。"""
|
||||
|
||||
total_hours: float
|
||||
total_income: float
|
||||
total_customers: int
|
||||
month_label: str
|
||||
tier_nodes: list[float]
|
||||
basic_hours: float
|
||||
bonus_hours: float
|
||||
current_tier: int
|
||||
next_tier_hours: float
|
||||
tier_completed: bool
|
||||
bonus_money: float
|
||||
income_trend: str
|
||||
income_trend_dir: str # 'up' | 'down'
|
||||
prev_month: str
|
||||
current_tier_label: str
|
||||
|
||||
|
||||
class TaskItem(CamelModel):
|
||||
"""任务列表项(扩展版)。"""
|
||||
|
||||
id: int
|
||||
customer_name: str
|
||||
customer_avatar: str
|
||||
task_type: str
|
||||
task_type_label: str
|
||||
deadline: str | None
|
||||
heart_score: float
|
||||
hobbies: list[str]
|
||||
is_pinned: bool
|
||||
has_note: bool
|
||||
status: str
|
||||
last_visit_days: int | None = None
|
||||
balance: float | None = None
|
||||
ai_suggestion: str | None = None
|
||||
|
||||
|
||||
class TaskListResponse(CamelModel):
|
||||
"""TASK-1 响应。"""
|
||||
|
||||
items: list[TaskItem]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
performance: PerformanceSummary
|
||||
|
||||
|
||||
class RetentionClue(CamelModel):
|
||||
"""维客线索。"""
|
||||
|
||||
tag: str
|
||||
tag_color: str
|
||||
emoji: str
|
||||
text: str
|
||||
source: str # 'manual' | 'ai_consumption' | 'ai_note'
|
||||
desc: str | None = None
|
||||
|
||||
|
||||
class ServiceRecord(CamelModel):
|
||||
"""服务记录。"""
|
||||
|
||||
table: str | None = None
|
||||
type: str
|
||||
type_class: str # 'basic' | 'vip' | 'tip' | 'recharge' | 'incentive'
|
||||
record_type: str | None = None # 'course' | 'recharge'
|
||||
duration: float
|
||||
duration_raw: float | None = None
|
||||
income: float
|
||||
is_estimate: bool | None = None
|
||||
drinks: str | None = None
|
||||
date: str
|
||||
|
||||
|
||||
class AiAnalysis(CamelModel):
|
||||
"""AI 分析结果。"""
|
||||
|
||||
summary: str
|
||||
suggestions: list[str]
|
||||
|
||||
|
||||
class NoteItem(CamelModel):
|
||||
"""备注项。"""
|
||||
|
||||
id: int
|
||||
content: str
|
||||
tag_type: str
|
||||
tag_label: str
|
||||
created_at: str
|
||||
score: int | None = None
|
||||
|
||||
|
||||
class ServiceSummary(CamelModel):
|
||||
"""服务记录摘要。"""
|
||||
|
||||
total_hours: float
|
||||
total_income: float
|
||||
avg_income: float
|
||||
|
||||
|
||||
class TaskDetailResponse(CamelModel):
|
||||
"""TASK-2 响应。"""
|
||||
|
||||
# 基础信息
|
||||
id: int
|
||||
customer_name: str
|
||||
customer_avatar: str
|
||||
task_type: str
|
||||
task_type_label: str
|
||||
deadline: str | None
|
||||
heart_score: float
|
||||
hobbies: list[str]
|
||||
is_pinned: bool
|
||||
has_note: bool
|
||||
status: str
|
||||
customer_id: int
|
||||
# 扩展模块
|
||||
retention_clues: list[RetentionClue]
|
||||
talking_points: list[str]
|
||||
service_summary: ServiceSummary
|
||||
service_records: list[ServiceRecord]
|
||||
ai_analysis: AiAnalysis
|
||||
notes: list[NoteItem]
|
||||
|
||||
663
apps/backend/app/services/board_service.py
Normal file
663
apps/backend/app/services/board_service.py
Normal file
@@ -0,0 +1,663 @@
|
||||
# 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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 通用工具函数
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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()
|
||||
|
||||
# --- 当月 ---
|
||||
if time_enum == "month":
|
||||
start = today.replace(day=1)
|
||||
end = today.replace(day=calendar.monthrange(today.year, today.month)[1])
|
||||
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())
|
||||
sunday = monday + timedelta(days=6)
|
||||
return monday, sunday
|
||||
|
||||
# --- 上周 ---
|
||||
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
|
||||
|
||||
# --- 本季度 ---
|
||||
if time_enum == "quarter":
|
||||
q_start_month = (today.month - 1) // 3 * 3 + 1
|
||||
start = date(today.year, q_start_month, 1)
|
||||
q_end_month = q_start_month + 2
|
||||
end = date(today.year, q_end_month, calendar.monthrange(today.year, q_end_month)[1])
|
||||
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(start_date: date, end_date: date) -> tuple[date, date]:
|
||||
"""
|
||||
根据当期范围计算上期日期范围。
|
||||
|
||||
上期长度等于当期长度,prev_end = start_date - 1 天。
|
||||
"""
|
||||
period_length = (end_date - start_date).days + 1
|
||||
prev_end = start_date - timedelta(days=1)
|
||||
prev_start = prev_end - timedelta(days=period_length - 1)
|
||||
return prev_start, prev_end
|
||||
|
||||
|
||||
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
|
||||
|
||||
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),
|
||||
}
|
||||
|
||||
_SORT_DIM_MAP = {
|
||||
"perf_desc": "perf", "perf_asc": "perf",
|
||||
"salary_desc": "salary", "salary_asc": "salary",
|
||||
"sv_desc": "sv", "task_desc": "task",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BOARD-1 助教看板
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def get_coach_board(
|
||||
sort: str, skill: str, time: str, 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个月不支持客源储值排序",
|
||||
)
|
||||
|
||||
start_date, end_date = _calc_date_range(time)
|
||||
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. 组装扁平响应
|
||||
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 = sal.get("effective_hours", 0.0)
|
||||
salary_val = sal.get("gross_salary", 0.0)
|
||||
task_recall = tasks.get("recall", 0)
|
||||
task_callback = tasks.get("callback", 0)
|
||||
|
||||
items.append({
|
||||
"id": aid,
|
||||
"name": name,
|
||||
"initial": initial,
|
||||
"avatar_gradient": "",
|
||||
"level": sal.get("level_name", a.get("level", "")),
|
||||
"skills": [], # CHANGE 2026-03-20 | v_dim_assistant 无 skill 列,暂返回空
|
||||
"top_customers": top_custs,
|
||||
"perf_hours": perf_hours,
|
||||
"perf_hours_before": None,
|
||||
"perf_gap": None,
|
||||
"perf_reached": False,
|
||||
"salary": salary_val,
|
||||
"salary_perf_hours": perf_hours,
|
||||
"salary_perf_before": None,
|
||||
"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. 排序
|
||||
sort_key, sort_desc = _SORT_KEY_MAP.get(sort, ("perf_hours", True))
|
||||
items.sort(key=lambda x: x.get(sort_key, 0), reverse=sort_desc)
|
||||
|
||||
# 移除内部排序字段
|
||||
for item in items:
|
||||
item.pop("task_total", None)
|
||||
|
||||
return {
|
||||
"items": items,
|
||||
"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 维度)。
|
||||
|
||||
来源: biz.coach_tasks,按 task_type 分类统计 recall/callback。
|
||||
"""
|
||||
if not assistant_ids:
|
||||
return {}
|
||||
|
||||
result: dict[int, dict] = {}
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT assistant_id,
|
||||
COUNT(*) FILTER (WHERE task_type = 'recall') AS recall_count,
|
||||
COUNT(*) FILTER (WHERE task_type = 'callback') AS callback_count
|
||||
FROM biz.coach_tasks
|
||||
WHERE assistant_id = ANY(%s)
|
||||
AND site_id = %s
|
||||
AND completed_at >= %s::date
|
||||
AND completed_at <= %s::date
|
||||
AND status = 'completed'
|
||||
GROUP BY assistant_id
|
||||
""",
|
||||
(assistant_ids, site_id, start_date, end_date),
|
||||
)
|
||||
for row in cur.fetchall():
|
||||
result[row[0]] = {
|
||||
"recall": row[1] or 0,
|
||||
"callback": row[2] or 0,
|
||||
}
|
||||
conn.commit()
|
||||
except Exception:
|
||||
logger.warning("BOARD-1 任务查询失败,降级为空", exc_info=True)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BOARD-2 客户看板
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# 维度 → 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",
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
|
||||
# 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, [])
|
||||
|
||||
return {
|
||||
"items": items,
|
||||
"total": result["total"],
|
||||
"page": result["page"],
|
||||
"page_size": result["page_size"],
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BOARD-3 财务看板
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def get_finance_board(
|
||||
time: str, area: str, compare: int, site_id: int
|
||||
) -> dict:
|
||||
"""
|
||||
BOARD-3:财务看板。6 板块独立查询、独立降级。
|
||||
|
||||
area≠all 时 recharge 返回 null。
|
||||
compare=1 时计算上期范围并调用 calc_compare。
|
||||
compare=0 时环比字段为 None(序列化时排除)。
|
||||
"""
|
||||
start_date, end_date = _calc_date_range(time)
|
||||
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(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)
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
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,
|
||||
) -> dict:
|
||||
"""经营一览板块。"""
|
||||
try:
|
||||
data = fdw_queries.get_finance_overview(conn, site_id, start, end)
|
||||
except Exception:
|
||||
logger.warning("overview 查询失败,降级为空", exc_info=True)
|
||||
return _empty_overview()
|
||||
|
||||
result = {**data}
|
||||
|
||||
if compare == 1 and prev_start and prev_end:
|
||||
try:
|
||||
prev = fdw_queries.get_finance_overview(conn, site_id, prev_start, prev_end)
|
||||
_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",
|
||||
])
|
||||
# 赠送卡矩阵环比
|
||||
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,
|
||||
) -> dict:
|
||||
"""应计收入板块。"""
|
||||
try:
|
||||
return fdw_queries.get_finance_revenue(conn, site_id, start, end, area)
|
||||
except Exception:
|
||||
logger.warning("revenue 查询失败,降级为空", exc_info=True)
|
||||
return _empty_revenue()
|
||||
|
||||
|
||||
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}
|
||||
|
||||
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,
|
||||
) -> dict:
|
||||
"""助教分析板块。"""
|
||||
try:
|
||||
data = fdw_queries.get_finance_coach_analysis(conn, site_id, start, end)
|
||||
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:
|
||||
prev = fdw_queries.get_finance_coach_analysis(
|
||||
conn, site_id, prev_start, prev_end
|
||||
)
|
||||
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",
|
||||
])
|
||||
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": [],
|
||||
"confirmed_total": 0.0,
|
||||
"channel_items": [],
|
||||
}
|
||||
708
apps/backend/app/services/coach_service.py
Normal file
708
apps/backend/app/services/coach_service.py
Normal file
@@ -0,0 +1,708 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
助教服务 —— COACH-1 助教详情。
|
||||
|
||||
数据来源:
|
||||
- ETL 直连(fdw_queries):助教信息、绩效、TOP 客户、服务记录、历史月份
|
||||
- 业务库(biz.*):助教任务、备注
|
||||
|
||||
⚠️ 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 datetime
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── 颜色/样式映射 ──────────────────────────────────────────
|
||||
|
||||
LEVEL_COLOR_MAP = {
|
||||
"星级": "#FF6B6B",
|
||||
"高级": "#FFA726",
|
||||
"中级": "#42A5F5",
|
||||
"初级": "#66BB6A",
|
||||
}
|
||||
|
||||
TASK_TYPE_MAP = {
|
||||
"follow_up_visit": {"label": "回访", "class": "tag-callback"},
|
||||
"high_priority_recall": {"label": "紧急召回", "class": "tag-recall"},
|
||||
"priority_recall": {"label": "优先召回", "class": "tag-recall"},
|
||||
}
|
||||
|
||||
# 头像渐变色池(循环使用)
|
||||
AVATAR_GRADIENTS = [
|
||||
"from-blue-400 to-blue-600",
|
||||
"from-green-400 to-green-600",
|
||||
"from-purple-400 to-purple-600",
|
||||
"from-orange-400 to-orange-600",
|
||||
"from-pink-400 to-pink-600",
|
||||
]
|
||||
|
||||
# CHANGE 2026-03-19 | feiqiu-data-rules 规则 6 修复 | 删除硬编码 DEFAULT_TIER_NODES,
|
||||
# 档位节点改为从 cfg_performance_tier 配置表动态读取。
|
||||
# 旧值 [0, 100, 130, 160, 190, 220] 与配置表实际值 [0, 120, 150, 180, 210] 不一致。
|
||||
_FALLBACK_TIER_NODES: list[float] = [0, 120, 150, 180, 210] # 仅在配置表查询失败时使用
|
||||
|
||||
|
||||
def _get_biz_connection():
|
||||
"""延迟导入业务库连接。"""
|
||||
from app.database import get_connection
|
||||
return get_connection()
|
||||
|
||||
|
||||
def _get_initial(name: str) -> str:
|
||||
"""取姓氏首字作为头像文字。"""
|
||||
return name[0] if name else "?"
|
||||
|
||||
|
||||
def _get_avatar_gradient(index: int) -> str:
|
||||
"""根据索引循环分配头像渐变色。"""
|
||||
return AVATAR_GRADIENTS[index % len(AVATAR_GRADIENTS)]
|
||||
|
||||
|
||||
def _format_currency(amount: float) -> str:
|
||||
"""格式化金额:¥6,950。"""
|
||||
if amount >= 10000:
|
||||
return f"¥{amount:,.0f}"
|
||||
return f"¥{amount:,.0f}"
|
||||
|
||||
|
||||
# ── 6.1 核心函数 ──────────────────────────────────────────
|
||||
|
||||
|
||||
async def get_coach_detail(coach_id: int, site_id: int) -> dict:
|
||||
"""
|
||||
助教详情(COACH-1)。
|
||||
|
||||
核心字段查询失败 → 500;扩展模块查询失败 → 空默认值(优雅降级)。
|
||||
"""
|
||||
conn = _get_biz_connection()
|
||||
try:
|
||||
# ── 核心字段(失败直接抛 500)──
|
||||
assistant_info = fdw_queries.get_assistant_info(conn, site_id, coach_id)
|
||||
if not assistant_info:
|
||||
raise HTTPException(status_code=404, detail="助教不存在")
|
||||
|
||||
now = datetime.date.today()
|
||||
|
||||
# 绩效数据(当月)
|
||||
salary_this = fdw_queries.get_salary_calc(
|
||||
conn, site_id, coach_id, now.year, now.month
|
||||
)
|
||||
if not salary_this:
|
||||
salary_this = {}
|
||||
|
||||
# customerBalance:该助教所有客户余额合计
|
||||
customer_balance = 0.0
|
||||
try:
|
||||
top_custs = fdw_queries.get_coach_top_customers(conn, site_id, coach_id, limit=1000)
|
||||
member_ids = [c["member_id"] for c in top_custs if c.get("member_id")]
|
||||
if member_ids:
|
||||
balance_map = fdw_queries.get_member_balance(conn, site_id, member_ids)
|
||||
customer_balance = sum(float(v) for v in balance_map.values())
|
||||
except Exception:
|
||||
logger.warning("查询 customerBalance 失败,降级为 0", exc_info=True)
|
||||
|
||||
# tasksCompleted:当月已完成任务数
|
||||
tasks_completed = 0
|
||||
try:
|
||||
month_start = now.replace(day=1)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM biz.coach_tasks
|
||||
WHERE assistant_id = %s
|
||||
AND status = 'completed'
|
||||
AND updated_at >= %s
|
||||
""",
|
||||
(coach_id, month_start),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
tasks_completed = row[0] if row else 0
|
||||
except Exception:
|
||||
logger.warning("查询 tasksCompleted 失败,降级为 0", exc_info=True)
|
||||
|
||||
# customerCount:不重复客户数(从 top_customers 获取)
|
||||
customer_count = 0
|
||||
try:
|
||||
cc_map = fdw_queries.get_monthly_customer_count(
|
||||
conn, site_id, coach_id, [now.strftime("%Y-%m-01")]
|
||||
)
|
||||
customer_count = sum(cc_map.values())
|
||||
except Exception:
|
||||
logger.warning("查询 customerCount 失败,降级为 0", exc_info=True)
|
||||
|
||||
performance = {
|
||||
"monthly_hours": salary_this.get("total_hours", 0.0),
|
||||
"monthly_salary": salary_this.get("total_income", 0.0),
|
||||
"customer_balance": customer_balance,
|
||||
"tasks_completed": tasks_completed,
|
||||
"perf_current": salary_this.get("total_hours", 0.0),
|
||||
# CHANGE 2026-03-19 | perf_target 从 tier_nodes 推算,不再依赖 salary_calc 的硬编码 0
|
||||
"perf_target": 0.0, # 占位,下方用 tier_nodes 覆盖
|
||||
}
|
||||
|
||||
# ── 扩展模块(独立 try/except 优雅降级)──
|
||||
|
||||
# 收入明细 + 档位
|
||||
try:
|
||||
income = _build_income(conn, site_id, coach_id, now)
|
||||
except Exception:
|
||||
logger.warning("构建 income 失败,降级为空", exc_info=True)
|
||||
income = {"this_month": [], "last_month": []}
|
||||
|
||||
try:
|
||||
tier_nodes = _build_tier_nodes(conn, site_id)
|
||||
except Exception:
|
||||
logger.warning("构建 tierNodes 失败,降级为 fallback", exc_info=True)
|
||||
tier_nodes = list(_FALLBACK_TIER_NODES)
|
||||
|
||||
# CHANGE 2026-03-19 | 用 tier_nodes 推算 perf_target(下一档 min_hours)
|
||||
current_hours = performance["perf_current"]
|
||||
perf_target = tier_nodes[-1] if tier_nodes else 0.0 # 默认最高档
|
||||
for node in tier_nodes:
|
||||
if node > current_hours:
|
||||
perf_target = node
|
||||
break
|
||||
performance["perf_target"] = perf_target
|
||||
|
||||
# TOP 客户
|
||||
try:
|
||||
top_customers = _build_top_customers(conn, site_id, coach_id)
|
||||
except Exception:
|
||||
logger.warning("构建 topCustomers 失败,降级为空列表", exc_info=True)
|
||||
top_customers = []
|
||||
|
||||
# 近期服务记录
|
||||
try:
|
||||
service_records = _build_service_records(conn, site_id, coach_id)
|
||||
except Exception:
|
||||
logger.warning("构建 serviceRecords 失败,降级为空列表", exc_info=True)
|
||||
service_records = []
|
||||
|
||||
# 任务分组
|
||||
try:
|
||||
task_groups = _build_task_groups(coach_id, site_id, conn)
|
||||
except Exception:
|
||||
logger.warning("构建 taskGroups 失败,降级为空", exc_info=True)
|
||||
task_groups = {"visible_tasks": [], "hidden_tasks": [], "abandoned_tasks": []}
|
||||
|
||||
# 备注
|
||||
try:
|
||||
notes = _build_notes(coach_id, site_id, conn)
|
||||
except Exception:
|
||||
logger.warning("构建 notes 失败,降级为空列表", exc_info=True)
|
||||
notes = []
|
||||
|
||||
# 历史月份
|
||||
try:
|
||||
history_months = _build_history_months(coach_id, site_id, conn)
|
||||
except Exception:
|
||||
logger.warning("构建 historyMonths 失败,降级为空列表", exc_info=True)
|
||||
history_months = []
|
||||
|
||||
return {
|
||||
# 基础信息
|
||||
"id": coach_id,
|
||||
"name": assistant_info.get("name", ""),
|
||||
"avatar": assistant_info.get("avatar", ""),
|
||||
"level": salary_this.get("coach_level", assistant_info.get("level", "")),
|
||||
"skills": assistant_info.get("skills", []),
|
||||
"work_years": assistant_info.get("work_years", 0.0),
|
||||
"customer_count": customer_count,
|
||||
"hire_date": assistant_info.get("hire_date"),
|
||||
# 绩效
|
||||
"performance": performance,
|
||||
# 收入
|
||||
"income": income,
|
||||
# 档位
|
||||
"tier_nodes": tier_nodes,
|
||||
# 任务分组
|
||||
"visible_tasks": task_groups["visible_tasks"],
|
||||
"hidden_tasks": task_groups["hidden_tasks"],
|
||||
"abandoned_tasks": task_groups["abandoned_tasks"],
|
||||
# TOP 客户
|
||||
"top_customers": top_customers,
|
||||
# 近期服务记录
|
||||
"service_records": service_records,
|
||||
# 历史月份
|
||||
"history_months": history_months,
|
||||
# 备注
|
||||
"notes": notes,
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ── 6.2 收入明细 + 档位 ──────────────────────────────────
|
||||
|
||||
|
||||
def _build_income(
|
||||
conn, site_id: int, coach_id: int, now: datetime.date
|
||||
) -> dict:
|
||||
"""
|
||||
构建 income 模块。
|
||||
|
||||
thisMonth/lastMonth 各含 4 项:
|
||||
- 基础课时费(base_income / assistant_pd_money)
|
||||
- 激励课时费(bonus_income / assistant_cx_money)
|
||||
- 充值提成
|
||||
- 酒水提成
|
||||
|
||||
⚠️ DWD-DOC 规则 2: 使用 assistant_pd_money + assistant_cx_money 拆分。
|
||||
"""
|
||||
# 当月
|
||||
salary_this = fdw_queries.get_salary_calc(
|
||||
conn, site_id, coach_id, now.year, now.month
|
||||
) or {}
|
||||
|
||||
# 上月
|
||||
if now.month == 1:
|
||||
last_year, last_month = now.year - 1, 12
|
||||
else:
|
||||
last_year, last_month = now.year, now.month - 1
|
||||
|
||||
salary_last = fdw_queries.get_salary_calc(
|
||||
conn, site_id, coach_id, last_year, last_month
|
||||
) or {}
|
||||
|
||||
def _make_items(salary: dict) -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"label": "基础课时费",
|
||||
"amount": f"¥{salary.get('assistant_pd_money_total', 0.0):,.0f}",
|
||||
"color": "#42A5F5",
|
||||
},
|
||||
{
|
||||
"label": "激励课时费",
|
||||
"amount": f"¥{salary.get('assistant_cx_money_total', 0.0):,.0f}",
|
||||
"color": "#FFA726",
|
||||
},
|
||||
{
|
||||
"label": "充值提成",
|
||||
"amount": f"¥{salary.get('bonus_money', 0.0):,.0f}",
|
||||
"color": "#66BB6A",
|
||||
},
|
||||
{
|
||||
"label": "酒水提成",
|
||||
"amount": f"¥{salary.get('room_income', 0.0):,.0f}",
|
||||
"color": "#AB47BC",
|
||||
},
|
||||
]
|
||||
|
||||
return {
|
||||
"this_month": _make_items(salary_this),
|
||||
"last_month": _make_items(salary_last),
|
||||
}
|
||||
|
||||
|
||||
def _build_tier_nodes(conn: Any, site_id: int) -> list[float]:
|
||||
"""
|
||||
从 cfg_performance_tier 配置表构建 tierNodes 档位节点数组。
|
||||
|
||||
⚠️ feiqiu-data-rules 规则 6: 绩效档位必须从配置表读取,禁止硬编码。
|
||||
"""
|
||||
# CHANGE 2026-03-19 | feiqiu-data-rules 规则 6 修复 | 从配置表动态读取档位节点
|
||||
try:
|
||||
tiers = fdw_queries.get_performance_tiers(conn, site_id)
|
||||
if tiers:
|
||||
return [t["min_hours"] for t in tiers]
|
||||
except Exception:
|
||||
logger.warning("查询 cfg_performance_tier 失败,使用 fallback", exc_info=True)
|
||||
return list(_FALLBACK_TIER_NODES)
|
||||
|
||||
|
||||
# ── 6.3 TOP 客户 + 近期服务记录 ──────────────────────────
|
||||
|
||||
|
||||
def _build_top_customers(
|
||||
conn, site_id: int, coach_id: int
|
||||
) -> list[dict]:
|
||||
"""
|
||||
构建 topCustomers 模块(最多 20 条)。
|
||||
|
||||
⚠️ DQ-6: 客户姓名通过 v_dim_member 获取。
|
||||
⚠️ DQ-7: 余额通过 v_dim_member_card_account 获取。
|
||||
⚠️ DWD-DOC 规则 1: consume 使用 ledger_amount(items_sum 口径)。
|
||||
|
||||
heartEmoji 四级映射(P6 AC3,rs_display 0-10 刻度):
|
||||
- score > 8.5 → "💖"
|
||||
- score > 7 → "🧡"
|
||||
- score > 5 → "💛"
|
||||
- score ≤ 5 → "💙"
|
||||
"""
|
||||
raw = fdw_queries.get_coach_top_customers(conn, site_id, coach_id, limit=20)
|
||||
if not raw:
|
||||
return []
|
||||
|
||||
# 获取关系指数(用于 heartEmoji)
|
||||
# 批量获取所有客户的关系指数
|
||||
member_ids = [c["member_id"] for c in raw if c.get("member_id")]
|
||||
relation_map: dict[int, float] = {}
|
||||
for mid in member_ids:
|
||||
try:
|
||||
rels = fdw_queries.get_relation_index(conn, site_id, mid)
|
||||
for r in rels:
|
||||
if r.get("assistant_id") == coach_id:
|
||||
relation_map[mid] = r.get("relation_index", 0.0)
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
result = []
|
||||
for i, cust in enumerate(raw):
|
||||
mid = cust.get("member_id")
|
||||
name = cust.get("customer_name", "")
|
||||
score = relation_map.get(mid, 0.0)
|
||||
|
||||
# 四级 heart icon 映射(P6 AC3,rs_display 0-10 刻度)
|
||||
heart_emoji = compute_heart_icon(Decimal(str(score)))
|
||||
if score > 8.5:
|
||||
score_color = "#FF6B6B"
|
||||
elif score > 7:
|
||||
score_color = "#FF8C00"
|
||||
elif score > 5:
|
||||
score_color = "#FFA726"
|
||||
else:
|
||||
score_color = "#5B9BD5"
|
||||
|
||||
balance = cust.get("customer_balance", 0.0)
|
||||
consume = cust.get("total_consume", 0.0)
|
||||
|
||||
result.append({
|
||||
"id": mid or 0,
|
||||
"name": name,
|
||||
"initial": _get_initial(name),
|
||||
"avatar_gradient": _get_avatar_gradient(i),
|
||||
"heart_emoji": heart_emoji,
|
||||
"relation_score": f"{score:.2f}",
|
||||
"score_color": score_color,
|
||||
"service_count": cust.get("service_count", 0),
|
||||
"balance": _format_currency(balance),
|
||||
"consume": _format_currency(consume),
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _build_service_records(
|
||||
conn, site_id: int, coach_id: int
|
||||
) -> list[dict]:
|
||||
"""
|
||||
构建 serviceRecords 模块。
|
||||
|
||||
⚠️ DWD-DOC 规则 1: income 使用 ledger_amount。
|
||||
⚠️ 废单排除: is_delete = 0。
|
||||
"""
|
||||
raw = fdw_queries.get_coach_service_records(
|
||||
conn, site_id, coach_id, limit=20, offset=0
|
||||
)
|
||||
if not raw:
|
||||
return []
|
||||
|
||||
result = []
|
||||
for i, rec in enumerate(raw):
|
||||
name = rec.get("customer_name", "")
|
||||
course_type = rec.get("course_type", "")
|
||||
|
||||
# type_class 映射
|
||||
if "激励" in course_type or "超休" in course_type:
|
||||
type_class = "tag-bonus"
|
||||
else:
|
||||
type_class = "tag-base"
|
||||
|
||||
create_time = rec.get("create_time")
|
||||
date_str = create_time.strftime("%Y-%m-%d %H:%M") if create_time else ""
|
||||
|
||||
hours = rec.get("service_hours", 0.0)
|
||||
income = rec.get("income", 0.0)
|
||||
|
||||
result.append({
|
||||
"customer_id": rec.get("member_id"),
|
||||
"customer_name": name,
|
||||
"initial": _get_initial(name),
|
||||
"avatar_gradient": _get_avatar_gradient(i),
|
||||
"type": course_type or "课程",
|
||||
"type_class": type_class,
|
||||
"table": str(rec.get("table_id")) if rec.get("table_id") else None,
|
||||
"duration": f"{hours:.1f}h",
|
||||
"income": _format_currency(income),
|
||||
"date": date_str,
|
||||
"perf_hours": None,
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ── 6.4 任务分组 + 备注 ──────────────────────────────────
|
||||
|
||||
|
||||
def _build_task_groups(
|
||||
coach_id: int, site_id: int, conn
|
||||
) -> dict:
|
||||
"""
|
||||
构建任务分组。
|
||||
|
||||
1. 查询 biz.coach_tasks WHERE assistant_id=coach_id
|
||||
2. 按 status 分组:active→visibleTasks, inactive→hiddenTasks, abandoned→abandonedTasks
|
||||
3. visible/hidden:关联 biz.notes 获取备注列表
|
||||
4. abandoned:取 abandon_reason
|
||||
"""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, member_id, task_type, status, abandon_reason
|
||||
FROM biz.coach_tasks
|
||||
WHERE assistant_id = %s
|
||||
AND status IN ('active', 'inactive', 'abandoned')
|
||||
ORDER BY created_at DESC
|
||||
""",
|
||||
(coach_id,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
if not rows:
|
||||
return {"visible_tasks": [], "hidden_tasks": [], "abandoned_tasks": []}
|
||||
|
||||
# 收集客户 ID 批量查询姓名
|
||||
member_ids = list({r[1] for r in rows if r[1]})
|
||||
member_name_map: dict[int, str] = {}
|
||||
if member_ids:
|
||||
try:
|
||||
info_map = fdw_queries.get_member_info(conn, site_id, member_ids)
|
||||
for mid, info in info_map.items():
|
||||
member_name_map[mid] = info.get("nickname", "")
|
||||
except Exception:
|
||||
logger.warning("批量查询客户姓名失败", exc_info=True)
|
||||
|
||||
# 收集任务 ID 批量查询备注
|
||||
task_ids = [r[0] for r in rows if r[3] in ("active", "inactive")]
|
||||
task_notes_map: dict[int, list[dict]] = {}
|
||||
if task_ids:
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT task_id, is_pinned, content, created_at
|
||||
FROM biz.notes
|
||||
WHERE task_id = ANY(%s)
|
||||
ORDER BY created_at DESC
|
||||
""",
|
||||
(task_ids,),
|
||||
)
|
||||
for nr in cur.fetchall():
|
||||
tid = nr[0]
|
||||
if tid not in task_notes_map:
|
||||
task_notes_map[tid] = []
|
||||
task_notes_map[tid].append({
|
||||
"pinned": bool(nr[1]),
|
||||
"text": nr[2] or "",
|
||||
"date": nr[3].isoformat() if nr[3] else "",
|
||||
})
|
||||
except Exception:
|
||||
logger.warning("批量查询任务备注失败", exc_info=True)
|
||||
|
||||
visible_tasks = []
|
||||
hidden_tasks = []
|
||||
abandoned_tasks = []
|
||||
|
||||
for row in rows:
|
||||
task_id, member_id, task_type, status, abandon_reason = row
|
||||
customer_name = member_name_map.get(member_id, "")
|
||||
task_meta = TASK_TYPE_MAP.get(task_type, {"label": task_type or "", "class": "tag-default"})
|
||||
|
||||
if status == "abandoned":
|
||||
abandoned_tasks.append({
|
||||
"customer_name": customer_name,
|
||||
"reason": abandon_reason or "",
|
||||
})
|
||||
else:
|
||||
notes_list = task_notes_map.get(task_id, [])
|
||||
item = {
|
||||
"type_label": task_meta["label"],
|
||||
"type_class": task_meta["class"],
|
||||
"customer_name": customer_name,
|
||||
"customer_id": member_id,
|
||||
"note_count": len(notes_list),
|
||||
"pinned": any(n.get("pinned") for n in notes_list),
|
||||
"notes": notes_list if notes_list else None,
|
||||
}
|
||||
if status == "active":
|
||||
visible_tasks.append(item)
|
||||
else:
|
||||
hidden_tasks.append(item)
|
||||
|
||||
return {
|
||||
"visible_tasks": visible_tasks,
|
||||
"hidden_tasks": hidden_tasks,
|
||||
"abandoned_tasks": abandoned_tasks,
|
||||
}
|
||||
|
||||
|
||||
def _build_notes(coach_id: int, site_id: int, conn) -> list[dict]:
|
||||
"""
|
||||
构建助教相关备注列表(最多 20 条)。
|
||||
|
||||
查询 biz.notes 中与该助教任务关联的备注,按 created_at 倒序。
|
||||
⚠️ DQ-6: 客户姓名通过 member_id JOIN v_dim_member。
|
||||
"""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT n.id, n.content, n.created_at, n.ai_score,
|
||||
n.type AS tag_label,
|
||||
ct.member_id
|
||||
FROM biz.notes n
|
||||
LEFT JOIN biz.coach_tasks ct ON n.task_id = ct.id
|
||||
WHERE ct.assistant_id = %s
|
||||
ORDER BY n.created_at DESC
|
||||
LIMIT 20
|
||||
""",
|
||||
(coach_id,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
if not rows:
|
||||
return []
|
||||
|
||||
# 批量获取客户姓名(DQ-6)
|
||||
member_ids = list({r[5] for r in rows if r[5]})
|
||||
member_name_map: dict[int, str] = {}
|
||||
if member_ids:
|
||||
try:
|
||||
info_map = fdw_queries.get_member_info(conn, site_id, member_ids)
|
||||
for mid, info in info_map.items():
|
||||
member_name_map[mid] = info.get("nickname", "")
|
||||
except Exception:
|
||||
logger.warning("查询备注客户姓名失败", exc_info=True)
|
||||
|
||||
result = []
|
||||
for r in rows:
|
||||
result.append({
|
||||
"id": r[0],
|
||||
"content": r[1] or "",
|
||||
"timestamp": r[2].isoformat() if r[2] else "",
|
||||
"ai_score": r[3],
|
||||
"customer_name": member_name_map.get(r[5], ""),
|
||||
"tag_label": r[4] or "",
|
||||
"created_at": r[2].isoformat() if r[2] else "",
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ── 6.5 历史月份统计(T2-6)──────────────────────────────
|
||||
|
||||
|
||||
def _build_history_months(
|
||||
coach_id: int, site_id: int, conn
|
||||
) -> list[dict]:
|
||||
"""
|
||||
构建 historyMonths 模块。
|
||||
|
||||
1. fdw_queries.get_salary_calc_multi_months() → 最近 6 个月工时/工资
|
||||
2. fdw_queries.get_monthly_customer_count() → 各月客户数
|
||||
3. biz.coach_tasks → 各月回访/召回完成数
|
||||
4. 本月 estimated=True,历史月份 estimated=False
|
||||
5. 格式化:customers→"22人",hours→"87.5h",salary→"¥6,950"
|
||||
"""
|
||||
now = datetime.date.today()
|
||||
|
||||
# 生成最近 6 个月的月份列表(含本月)
|
||||
months: list[str] = []
|
||||
for i in range(6):
|
||||
y = now.year
|
||||
m = now.month - i
|
||||
while m <= 0:
|
||||
m += 12
|
||||
y -= 1
|
||||
months.append(f"{y}-{m:02d}-01")
|
||||
|
||||
# 批量查询绩效数据
|
||||
salary_map = fdw_queries.get_salary_calc_multi_months(
|
||||
conn, site_id, coach_id, months
|
||||
)
|
||||
|
||||
# 批量查询各月客户数
|
||||
customer_count_map = fdw_queries.get_monthly_customer_count(
|
||||
conn, site_id, coach_id, months
|
||||
)
|
||||
|
||||
# 查询各月回访/召回完成数
|
||||
callback_map: dict[str, int] = {}
|
||||
recall_map: dict[str, int] = {}
|
||||
try:
|
||||
six_months_ago = months[-1] # 最早的月份
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT DATE_TRUNC('month', updated_at)::date AS month,
|
||||
task_type,
|
||||
COUNT(*) AS cnt
|
||||
FROM biz.coach_tasks
|
||||
WHERE assistant_id = %s
|
||||
AND status = 'completed'
|
||||
AND updated_at >= %s::date
|
||||
GROUP BY DATE_TRUNC('month', updated_at)::date, task_type
|
||||
""",
|
||||
(coach_id, six_months_ago),
|
||||
)
|
||||
for row in cur.fetchall():
|
||||
month_key = str(row[0])
|
||||
task_type = row[1]
|
||||
cnt = row[2] or 0
|
||||
if task_type == "follow_up_visit":
|
||||
callback_map[month_key] = callback_map.get(month_key, 0) + cnt
|
||||
elif task_type in ("high_priority_recall", "priority_recall"):
|
||||
recall_map[month_key] = recall_map.get(month_key, 0) + cnt
|
||||
except Exception:
|
||||
logger.warning("查询回访/召回完成数失败", exc_info=True)
|
||||
|
||||
# 构建结果
|
||||
current_month_str = now.strftime("%Y-%m-01")
|
||||
result = []
|
||||
for i, month_str in enumerate(months):
|
||||
salary = salary_map.get(month_str, {})
|
||||
customers = customer_count_map.get(month_str, 0)
|
||||
hours = salary.get("effective_hours", 0.0)
|
||||
salary_amount = salary.get("gross_salary", 0.0)
|
||||
callback_done = callback_map.get(month_str, 0)
|
||||
recall_done = recall_map.get(month_str, 0)
|
||||
|
||||
# 月份标签
|
||||
if i == 0:
|
||||
month_label = "本月"
|
||||
elif i == 1:
|
||||
month_label = "上月"
|
||||
else:
|
||||
# 提取月份数字
|
||||
m_num = int(month_str.split("-")[1])
|
||||
month_label = f"{m_num}月"
|
||||
|
||||
result.append({
|
||||
"month": month_label,
|
||||
"estimated": month_str == current_month_str,
|
||||
"customers": f"{customers}人",
|
||||
"hours": f"{hours:.1f}h",
|
||||
"salary": _format_currency(salary_amount),
|
||||
"callback_done": callback_done,
|
||||
"recall_done": recall_done,
|
||||
})
|
||||
|
||||
return result
|
||||
660
apps/backend/app/services/customer_service.py
Normal file
660
apps/backend/app/services/customer_service.py
Normal file
@@ -0,0 +1,660 @@
|
||||
# -*- 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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── 颜色/样式映射 ──────────────────────────────────────────
|
||||
|
||||
LEVEL_COLOR_MAP = {
|
||||
"星级": "#FF6B6B",
|
||||
"高级": "#FFA726",
|
||||
"中级": "#42A5F5",
|
||||
"初级": "#66BB6A",
|
||||
}
|
||||
|
||||
TASK_TYPE_MAP = {
|
||||
"follow_up_visit": {"label": "回访", "color": "#42A5F5", "bg_class": "bg-blue"},
|
||||
"high_priority_recall": {"label": "紧急召回", "color": "#FF6B6B", "bg_class": "bg-red"},
|
||||
"priority_recall": {"label": "优先召回", "color": "#FFA726", "bg_class": "bg-orange"},
|
||||
}
|
||||
|
||||
LEVEL_BG_MAP = {
|
||||
"星级": "bg-red",
|
||||
"高级": "bg-orange",
|
||||
"中级": "bg-blue",
|
||||
"初级": "bg-green",
|
||||
}
|
||||
|
||||
|
||||
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()
|
||||
|
||||
|
||||
# ── 3.1 核心函数 ──────────────────────────────────────────
|
||||
|
||||
async def get_customer_detail(customer_id: int, site_id: int) -> dict:
|
||||
"""
|
||||
客户详情(CUST-1)。
|
||||
|
||||
核心字段查询失败 → 500;扩展模块查询失败 → 空默认值(优雅降级)。
|
||||
"""
|
||||
conn = _get_biz_connection()
|
||||
try:
|
||||
# ── 核心字段(失败直接抛 500)──
|
||||
member_info_map = fdw_queries.get_member_info(conn, site_id, [customer_id])
|
||||
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])
|
||||
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)
|
||||
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])
|
||||
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)
|
||||
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 = []
|
||||
|
||||
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": None,
|
||||
"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:
|
||||
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,按 created_at 倒序。
|
||||
"""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT clue_type, clue_text
|
||||
FROM public.member_retention_clue
|
||||
WHERE member_id = %s
|
||||
ORDER BY created_at DESC
|
||||
""",
|
||||
(customer_id,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
return [{"type": r[0] or "", "text": r[1] or ""} for r in rows]
|
||||
|
||||
|
||||
def _build_notes(customer_id: int, conn) -> list[dict]:
|
||||
"""
|
||||
构建 notes 模块。
|
||||
|
||||
查询 biz.notes WHERE target_type='member',最多 20 条,按 created_at 倒序。
|
||||
"""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, type, created_at, content
|
||||
FROM biz.notes
|
||||
WHERE target_type = 'member'
|
||||
AND target_id = %s
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 20
|
||||
""",
|
||||
(customer_id,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": r[0],
|
||||
"tag_label": r[1] or "",
|
||||
"created_at": r[2].isoformat() if r[2] else "",
|
||||
"content": r[3] or "",
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
# ── 3.3 消费记录 ──────────────────────────────────────────
|
||||
|
||||
|
||||
def _build_consumption_records(
|
||||
customer_id: int, site_id: int, conn
|
||||
) -> list[dict]:
|
||||
"""
|
||||
构建 consumptionRecords 模块。
|
||||
|
||||
调用 fdw_queries.get_consumption_records() 获取结算单列表。
|
||||
⚠️ DWD-DOC 规则 1: totalAmount 使用 ledger_amount(items_sum 口径)。
|
||||
⚠️ DWD-DOC 规则 2: coaches fee 使用 assistant_pd_money / assistant_cx_money。
|
||||
⚠️ 废单排除: is_delete = 0,正向交易: settle_type IN (1, 3)。
|
||||
"""
|
||||
raw_records = fdw_queries.get_consumption_records(
|
||||
conn, site_id, customer_id, limit=50, offset=0
|
||||
)
|
||||
|
||||
result = []
|
||||
for rec in raw_records:
|
||||
# 构建 coaches 子数组
|
||||
coaches = []
|
||||
pd_money = rec.get("assistant_pd_money", 0.0)
|
||||
cx_money = rec.get("assistant_cx_money", 0.0)
|
||||
|
||||
if pd_money:
|
||||
coaches.append({
|
||||
"name": rec.get("assistant_name", ""),
|
||||
"level": rec.get("level", ""),
|
||||
"level_color": LEVEL_COLOR_MAP.get(rec.get("level", ""), ""),
|
||||
"course_type": "基础课",
|
||||
"hours": rec.get("service_hours", 0.0),
|
||||
"perf_hours": None,
|
||||
"fee": pd_money,
|
||||
})
|
||||
if cx_money:
|
||||
coaches.append({
|
||||
"name": rec.get("assistant_name", ""),
|
||||
"level": rec.get("level", ""),
|
||||
"level_color": LEVEL_COLOR_MAP.get(rec.get("level", ""), ""),
|
||||
"course_type": "激励课",
|
||||
"hours": 0.0,
|
||||
"perf_hours": None,
|
||||
"fee": cx_money,
|
||||
})
|
||||
|
||||
settle_time = rec.get("settle_time")
|
||||
date_str = settle_time.strftime("%Y-%m-%d") if settle_time else ""
|
||||
start_str = rec.get("start_time")
|
||||
end_str = rec.get("end_time")
|
||||
|
||||
result.append({
|
||||
"id": rec.get("id", ""),
|
||||
"type": "table",
|
||||
"date": date_str,
|
||||
"table_name": str(rec.get("table_id")) if rec.get("table_id") else None,
|
||||
"start_time": start_str.isoformat() if start_str else None,
|
||||
"end_time": end_str.isoformat() if end_str else None,
|
||||
"duration": int(rec.get("service_hours", 0) * 60),
|
||||
"table_fee": rec.get("table_charge_money", 0.0),
|
||||
"table_orig_price": None,
|
||||
"coaches": coaches,
|
||||
"food_amount": rec.get("goods_money", 0.0),
|
||||
"food_orig_price": None,
|
||||
"total_amount": rec.get("total_amount", 0.0),
|
||||
"total_orig_price": rec.get("total_amount", 0.0),
|
||||
"pay_method": "",
|
||||
"recharge_amount": None,
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ── 3.4 关联助教任务(T2-2)──────────────────────────────
|
||||
|
||||
|
||||
def _build_coach_tasks(
|
||||
customer_id: int, site_id: int, conn
|
||||
) -> list[dict]:
|
||||
"""
|
||||
构建 coachTasks 模块。
|
||||
|
||||
1. 查询 biz.coach_tasks WHERE member_id=customer_id
|
||||
2. 对每位助教:fdw_queries 获取等级、近 60 天统计
|
||||
3. 映射 levelColor/taskColor/bgClass
|
||||
"""
|
||||
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 []
|
||||
|
||||
# 收集所有助教 ID,批量查询信息
|
||||
assistant_ids = list({r[1] for r in rows if r[1]})
|
||||
|
||||
# 获取助教等级(通过 salary_calc)
|
||||
import datetime
|
||||
now = datetime.date.today()
|
||||
salary_map = {}
|
||||
for aid in assistant_ids:
|
||||
try:
|
||||
sc = fdw_queries.get_salary_calc(conn, site_id, aid, now.year, now.month)
|
||||
if sc:
|
||||
salary_map[aid] = sc
|
||||
except Exception:
|
||||
logger.warning("查询助教 %s 绩效失败", aid, exc_info=True)
|
||||
|
||||
# 获取助教姓名
|
||||
assistant_info_map = {}
|
||||
for aid in assistant_ids:
|
||||
try:
|
||||
info = fdw_queries.get_assistant_info(conn, site_id, aid)
|
||||
if info:
|
||||
assistant_info_map[aid] = info
|
||||
except Exception:
|
||||
logger.warning("查询助教 %s 信息失败", aid, exc_info=True)
|
||||
|
||||
result = []
|
||||
for row in rows:
|
||||
task_id, assistant_id, task_type, status, updated_at = row
|
||||
a_info = assistant_info_map.get(assistant_id, {})
|
||||
sc = salary_map.get(assistant_id, {})
|
||||
level = sc.get("coach_level", a_info.get("level", ""))
|
||||
name = a_info.get("name", "")
|
||||
|
||||
task_meta = TASK_TYPE_MAP.get(task_type, {
|
||||
"label": task_type or "",
|
||||
"color": "#999",
|
||||
"bg_class": "bg-gray",
|
||||
})
|
||||
|
||||
# 近 60 天统计
|
||||
try:
|
||||
stats = fdw_queries.get_coach_60d_stats(
|
||||
conn, site_id, assistant_id, customer_id
|
||||
)
|
||||
except Exception:
|
||||
stats = {"service_count": 0, "total_hours": 0.0, "avg_hours": 0.0}
|
||||
|
||||
metrics = [
|
||||
{"label": "服务次数", "value": str(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},
|
||||
]
|
||||
|
||||
result.append({
|
||||
"name": name,
|
||||
"level": level,
|
||||
"level_color": LEVEL_COLOR_MAP.get(level, ""),
|
||||
"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": updated_at.isoformat() if updated_at else None,
|
||||
"metrics": metrics,
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ── 3.5 最亲密助教(T2-3)──────────────────────────────
|
||||
|
||||
|
||||
def _build_favorite_coaches(
|
||||
customer_id: int, site_id: int, conn
|
||||
) -> list[dict]:
|
||||
"""
|
||||
构建 favoriteCoaches 模块。
|
||||
|
||||
1. fdw_queries.get_relation_index() → 关系指数列表(已按降序排列,rs_display 0-10 刻度)
|
||||
2. emoji 四级映射(P6 AC3):>8.5→💖 / >7→🧡 / >5→💛 / ≤5→💙
|
||||
3. stats 4 项指标
|
||||
"""
|
||||
relations = fdw_queries.get_relation_index(conn, site_id, customer_id)
|
||||
if not relations:
|
||||
return []
|
||||
|
||||
# 获取助教姓名
|
||||
assistant_ids = [r["assistant_id"] for r in relations if r.get("assistant_id")]
|
||||
assistant_info_map = {}
|
||||
for aid in assistant_ids:
|
||||
try:
|
||||
info = fdw_queries.get_assistant_info(conn, site_id, aid)
|
||||
if info:
|
||||
assistant_info_map[aid] = info
|
||||
except Exception:
|
||||
logger.warning("查询助教 %s 信息失败", aid, 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, {})
|
||||
|
||||
# 4-level heart icon 映射(P6 AC3,rs_display 0-10 刻度)
|
||||
emoji = compute_heart_icon(Decimal(str(ri)))
|
||||
if ri > 8.5:
|
||||
index_color, bg_class = "#FF6B6B", "bg-red"
|
||||
elif ri > 7:
|
||||
index_color, bg_class = "#FF8C00", "bg-orange"
|
||||
elif ri > 5:
|
||||
index_color, bg_class = "#FFA726", "bg-yellow"
|
||||
else:
|
||||
index_color, bg_class = "#5B9BD5", "bg-blue"
|
||||
|
||||
stats = [
|
||||
{"label": "基础课时", "value": f"¥{rel.get('total_income', 0):.0f}", "color": None},
|
||||
{"label": "激励课时", "value": "¥0", "color": None},
|
||||
{"label": "上课次数", "value": str(rel.get("service_count", 0)), "color": None},
|
||||
{"label": "总时长", "value": f"{rel.get('total_hours', 0):.1f}h", "color": None},
|
||||
]
|
||||
|
||||
result.append({
|
||||
"emoji": emoji,
|
||||
"name": a_info.get("name", ""),
|
||||
"relation_index": f"{ri:.2f}",
|
||||
"index_color": index_color,
|
||||
"bg_class": bg_class,
|
||||
"stats": stats,
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ── CUST-2 客户服务记录(T2-4)──────────────────────────
|
||||
|
||||
|
||||
async def get_customer_records(
|
||||
customer_id: int,
|
||||
site_id: int,
|
||||
year: int,
|
||||
month: int,
|
||||
table: str | None,
|
||||
page: int,
|
||||
page_size: int,
|
||||
) -> dict:
|
||||
"""
|
||||
客户服务记录(CUST-2)。
|
||||
|
||||
1. fdw_queries.get_member_info() → customerName/customerPhone(DQ-6)
|
||||
2. fdw_queries.get_customer_service_records() → 按月分页记录 + total_count
|
||||
3. 聚合 monthCount/monthHours(从 total_count 和记录工时)
|
||||
4. fdw_queries.get_total_service_count() → totalServiceCount(跨月)
|
||||
5. 构建 ServiceRecordItem 列表,含 recordType/isEstimate
|
||||
6. hasMore = total_count > page * page_size
|
||||
"""
|
||||
conn = _get_biz_connection()
|
||||
try:
|
||||
# ── 客户基础信息(DQ-6)──
|
||||
member_info_map = fdw_queries.get_member_info(conn, site_id, [customer_id])
|
||||
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,
|
||||
)
|
||||
|
||||
# ── 月度统计汇总(从全量 total_count + 当页记录工时聚合)──
|
||||
# monthCount = 当月总记录数(不是当页),monthHours = 当月总工时
|
||||
# 需要单独查询当月汇总,因为分页记录只是子集
|
||||
month_count, month_hours = _get_month_aggregation(
|
||||
conn, site_id, customer_id, year, month, table
|
||||
)
|
||||
|
||||
# ── 累计服务总次数(跨所有月份)──
|
||||
total_service_count = fdw_queries.get_total_service_count(
|
||||
conn, site_id, customer_id
|
||||
)
|
||||
|
||||
# ── 构建记录列表 ──
|
||||
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": str(rec.get("table_id")) if rec.get("table_id") else 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": None,
|
||||
})
|
||||
|
||||
has_more = total_count > page * page_size
|
||||
|
||||
return {
|
||||
"customer_name": customer_name,
|
||||
"customer_phone": phone,
|
||||
"customer_phone_full": phone_full,
|
||||
"relation_index": "",
|
||||
"tables": [],
|
||||
"total_service_count": total_service_count,
|
||||
"month_count": month_count,
|
||||
"month_hours": round(month_hours, 2),
|
||||
"records": records,
|
||||
"has_more": has_more,
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _get_month_aggregation(
|
||||
conn, site_id: int, customer_id: int,
|
||||
year: int, month: int, table: str | None,
|
||||
) -> tuple[int, float]:
|
||||
"""
|
||||
查询当月汇总统计(monthCount + monthHours)。
|
||||
|
||||
复用 fdw_queries 的 _fdw_context 直连 ETL 库。
|
||||
⚠️ 废单排除: is_delete = 0。
|
||||
"""
|
||||
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 table:
|
||||
base_where += " AND site_table_id::text = %s"
|
||||
params.append(table)
|
||||
|
||||
with fdw_queries._fdw_context(conn, site_id) 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
|
||||
2309
apps/backend/app/services/fdw_queries.py
Normal file
2309
apps/backend/app/services/fdw_queries.py
Normal file
File diff suppressed because it is too large
Load Diff
519
apps/backend/app/services/performance_service.py
Normal file
519
apps/backend/app/services/performance_service.py
Normal file
@@ -0,0 +1,519 @@
|
||||
"""
|
||||
绩效服务
|
||||
|
||||
负责绩效概览(PERF-1)和绩效明细(PERF-2)的业务逻辑。
|
||||
所有 FDW 查询通过 fdw_queries 模块执行,本模块不直接操作 SQL。
|
||||
|
||||
RNS1.1 组件 5。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.services import fdw_queries
|
||||
from app.services.task_manager import (
|
||||
_get_assistant_id,
|
||||
compute_income_trend,
|
||||
map_course_type_class,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_connection():
|
||||
"""延迟导入 get_connection,避免纯函数测试时触发模块级导入失败。"""
|
||||
from app.database import get_connection
|
||||
|
||||
return get_connection()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 纯函数:可被属性测试直接调用
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# 头像颜色预定义集合
|
||||
_AVATAR_COLORS = [
|
||||
"#0052d9", "#e34d59", "#00a870", "#ed7b2f",
|
||||
"#0594fa", "#a25eb5", "#f6c244", "#2ba471",
|
||||
]
|
||||
|
||||
|
||||
def avatar_char_color(name: str) -> tuple[str, str]:
|
||||
"""从客户姓名计算 avatarChar 和 avatarColor。"""
|
||||
if not name:
|
||||
return ("?", _AVATAR_COLORS[0])
|
||||
char = name[0]
|
||||
color = _AVATAR_COLORS[ord(char) % len(_AVATAR_COLORS)]
|
||||
return (char, color)
|
||||
|
||||
|
||||
def format_income_desc(rate: float, hours: float) -> str:
|
||||
"""
|
||||
格式化收入明细描述。
|
||||
|
||||
格式: "{rate}元/h × {hours}h"
|
||||
"""
|
||||
# 去除不必要的尾零
|
||||
rate_str = f"{rate:g}"
|
||||
hours_str = f"{hours:g}"
|
||||
return f"{rate_str}元/h × {hours_str}h"
|
||||
|
||||
|
||||
def group_records_by_date(
|
||||
records: list[dict], *, include_avatar: bool = False
|
||||
) -> list[dict]:
|
||||
"""
|
||||
将服务记录按日期分组为 DateGroup 结构。
|
||||
|
||||
参数:
|
||||
records: 服务记录列表(已按 settle_time DESC 排序)
|
||||
include_avatar: 是否包含 avatarChar/avatarColor(PERF-1 需要,PERF-2 不需要)
|
||||
|
||||
返回按日期倒序排列的 DateGroup 列表。
|
||||
"""
|
||||
groups: dict[str, list[dict]] = defaultdict(list)
|
||||
|
||||
for rec in records:
|
||||
settle_time = rec.get("settle_time")
|
||||
if settle_time is None:
|
||||
continue
|
||||
|
||||
# 提取日期字符串
|
||||
if hasattr(settle_time, "strftime"):
|
||||
date_key = settle_time.strftime("%Y-%m-%d")
|
||||
else:
|
||||
date_key = str(settle_time)[:10]
|
||||
|
||||
# 时间范围
|
||||
start_time = rec.get("start_time")
|
||||
end_time = rec.get("end_time")
|
||||
time_range = _format_time_range(start_time, end_time)
|
||||
|
||||
raw_course_type = rec.get("course_type", "")
|
||||
type_class = map_course_type_class(raw_course_type)
|
||||
customer_name = rec.get("customer_name") or "未知客户"
|
||||
|
||||
record_item: dict = {
|
||||
"customer_name": customer_name,
|
||||
"time_range": time_range,
|
||||
"hours": f"{rec.get('service_hours', 0.0):g}",
|
||||
"course_type": raw_course_type or "基础课",
|
||||
"course_type_class": type_class,
|
||||
"location": rec.get("table_name") or "",
|
||||
"income": f"{rec.get('income', 0.0):.2f}",
|
||||
}
|
||||
|
||||
if include_avatar:
|
||||
char, color = avatar_char_color(customer_name)
|
||||
record_item["avatar_char"] = char
|
||||
record_item["avatar_color"] = color
|
||||
|
||||
groups[date_key].append(record_item)
|
||||
|
||||
# 按日期倒序排列
|
||||
sorted_dates = sorted(groups.keys(), reverse=True)
|
||||
result = []
|
||||
for date_key in sorted_dates:
|
||||
recs = groups[date_key]
|
||||
total_hours = sum(float(r["hours"]) for r in recs)
|
||||
total_income = sum(float(r["income"]) for r in recs)
|
||||
result.append({
|
||||
"date": date_key,
|
||||
"total_hours": f"{total_hours:g}",
|
||||
"total_income": f"{total_income:.2f}",
|
||||
"records": recs,
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def paginate_records(
|
||||
records: list[dict], page: int, page_size: int
|
||||
) -> tuple[list[dict], bool]:
|
||||
"""
|
||||
对记录列表进行分页。
|
||||
|
||||
返回 (当前页记录, has_more)。
|
||||
"""
|
||||
total = len(records)
|
||||
start = (page - 1) * page_size
|
||||
end = start + page_size
|
||||
page_records = records[start:end]
|
||||
has_more = total > page * page_size
|
||||
return page_records, has_more
|
||||
|
||||
|
||||
def compute_summary(records: list[dict]) -> dict:
|
||||
"""
|
||||
计算月度汇总。
|
||||
|
||||
返回 { total_count, total_hours, total_hours_raw, total_income }。
|
||||
"""
|
||||
total_count = len(records)
|
||||
total_hours = sum(r.get("service_hours", 0.0) for r in records)
|
||||
total_hours_raw = sum(r.get("service_hours_raw", 0.0) for r in records)
|
||||
total_income = sum(r.get("income", 0.0) for r in records)
|
||||
return {
|
||||
"total_count": total_count,
|
||||
"total_hours": round(total_hours, 2),
|
||||
"total_hours_raw": round(total_hours_raw, 2),
|
||||
"total_income": round(total_income, 2),
|
||||
}
|
||||
|
||||
|
||||
def _format_time_range(start_time, end_time) -> str:
|
||||
"""格式化时间范围为 "HH:MM-HH:MM" 格式。"""
|
||||
parts = []
|
||||
for t in (start_time, end_time):
|
||||
if t is None:
|
||||
parts.append("--:--")
|
||||
elif hasattr(t, "strftime"):
|
||||
parts.append(t.strftime("%H:%M"))
|
||||
else:
|
||||
s = str(t)
|
||||
# 尝试提取 HH:MM
|
||||
if len(s) >= 16:
|
||||
parts.append(s[11:16])
|
||||
else:
|
||||
parts.append(str(t))
|
||||
return f"{parts[0]}-{parts[1]}"
|
||||
|
||||
|
||||
def _format_date_label(dt) -> str:
|
||||
"""格式化日期为 "M月D日" 格式。"""
|
||||
if dt is None:
|
||||
return ""
|
||||
if hasattr(dt, "strftime"):
|
||||
return f"{dt.month}月{dt.day}日"
|
||||
s = str(dt)[:10]
|
||||
try:
|
||||
d = datetime.strptime(s, "%Y-%m-%d")
|
||||
return f"{d.month}月{d.day}日"
|
||||
except (ValueError, TypeError):
|
||||
return s
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PERF-1: 绩效概览
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def get_overview(
|
||||
user_id: int, site_id: int, year: int, month: int
|
||||
) -> dict:
|
||||
"""
|
||||
绩效概览(PERF-1)。
|
||||
|
||||
1. 获取 assistant_id
|
||||
2. fdw_queries.get_salary_calc() → 档位/收入/费率
|
||||
3. fdw_queries.get_service_records() → 按日期分组为 DateGroup(含 avatarChar/avatarColor)
|
||||
4. 聚合新客/常客列表
|
||||
5. 计算 incomeItems(含 desc 费率描述)
|
||||
6. 查询上月收入 lastMonthIncome
|
||||
"""
|
||||
conn = _get_connection()
|
||||
try:
|
||||
assistant_id = _get_assistant_id(conn, user_id, site_id)
|
||||
|
||||
# ── 1. 当月绩效数据 ──
|
||||
salary = fdw_queries.get_salary_calc(conn, site_id, assistant_id, year, month)
|
||||
|
||||
# ── 2. 上月绩效数据(用于 lastMonthIncome) ──
|
||||
prev_year, prev_month = (year, month - 1) if month > 1 else (year - 1, 12)
|
||||
prev_salary = None
|
||||
try:
|
||||
prev_salary = fdw_queries.get_salary_calc(
|
||||
conn, site_id, assistant_id, prev_year, prev_month
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("FDW 查询上月绩效失败", exc_info=True)
|
||||
|
||||
last_month_income = prev_salary["total_income"] if prev_salary else 0.0
|
||||
|
||||
# ── 3. 服务记录(全量,用于 DateGroup + 新客/常客) ──
|
||||
# 获取全部记录(不分页)
|
||||
all_records = fdw_queries.get_service_records(
|
||||
conn, site_id, assistant_id, year, month,
|
||||
limit=10000, offset=0,
|
||||
)
|
||||
|
||||
# 按日期分组(含 avatar)
|
||||
date_groups = group_records_by_date(all_records, include_avatar=True)
|
||||
|
||||
# ── 4. 新客/常客列表 ──
|
||||
new_customers, regular_customers = _build_customer_lists(
|
||||
conn, site_id, assistant_id, year, month, all_records
|
||||
)
|
||||
|
||||
# ── 5. 构建响应 ──
|
||||
# 助教信息(从 salary 或默认值)
|
||||
coach_name = ""
|
||||
coach_role = ""
|
||||
store_name = ""
|
||||
# ⚠️ auth 表结构: users(nickname), user_assistant_binding(binding_type)
|
||||
# auth.sites 不存在; users 无 display_name; uab 无 role_label
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT u.nickname, uab.binding_type
|
||||
FROM auth.user_assistant_binding uab
|
||||
JOIN auth.users u ON uab.user_id = u.id
|
||||
WHERE uab.assistant_id = %s AND uab.site_id = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(assistant_id, site_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
coach_name = row[0] or ""
|
||||
coach_role = row[1] or ""
|
||||
conn.commit()
|
||||
except Exception:
|
||||
logger.warning("查询助教信息失败", exc_info=True)
|
||||
|
||||
current_income = salary["total_income"] if salary else 0.0
|
||||
basic_rate = salary["basic_rate"] if salary else 0.0
|
||||
incentive_rate = salary["incentive_rate"] if salary else 0.0
|
||||
basic_hours = salary["basic_hours"] if salary else 0.0
|
||||
bonus_hours = salary["bonus_hours"] if salary else 0.0
|
||||
pd_money = salary["assistant_pd_money_total"] if salary else 0.0
|
||||
cx_money = salary["assistant_cx_money_total"] if salary else 0.0
|
||||
|
||||
# 收入明细项
|
||||
income_items = _build_income_items(
|
||||
basic_rate, incentive_rate, basic_hours, bonus_hours,
|
||||
pd_money, cx_money,
|
||||
)
|
||||
|
||||
# 档位信息
|
||||
next_basic_rate = salary["next_tier_basic_rate"] if salary else 0.0
|
||||
next_incentive_rate = salary["next_tier_incentive_rate"] if salary else 0.0
|
||||
upgrade_hours = salary["next_tier_hours"] if salary else 0.0
|
||||
total_hours = salary["total_hours"] if salary else 0.0
|
||||
upgrade_hours_needed = max(0.0, upgrade_hours - total_hours)
|
||||
tier_completed = salary["tier_completed"] if salary else False
|
||||
upgrade_bonus = 0.0 if tier_completed else (salary["bonus_money"] if salary else 0.0)
|
||||
|
||||
return {
|
||||
"coach_name": coach_name,
|
||||
"coach_role": coach_role,
|
||||
"store_name": store_name,
|
||||
"monthly_income": f"¥{current_income:,.0f}",
|
||||
"last_month_income": f"¥{last_month_income:,.0f}",
|
||||
"current_tier": {
|
||||
"basic_rate": basic_rate,
|
||||
"incentive_rate": incentive_rate,
|
||||
},
|
||||
"next_tier": {
|
||||
"basic_rate": next_basic_rate,
|
||||
"incentive_rate": next_incentive_rate,
|
||||
},
|
||||
"upgrade_hours_needed": round(upgrade_hours_needed, 2),
|
||||
"upgrade_bonus": upgrade_bonus,
|
||||
"income_items": income_items,
|
||||
"monthly_total": f"¥{current_income:,.2f}",
|
||||
"this_month_records": date_groups,
|
||||
"new_customers": new_customers,
|
||||
"regular_customers": regular_customers,
|
||||
}
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _build_income_items(
|
||||
basic_rate: float,
|
||||
incentive_rate: float,
|
||||
basic_hours: float,
|
||||
bonus_hours: float,
|
||||
pd_money: float,
|
||||
cx_money: float,
|
||||
) -> list[dict]:
|
||||
"""构建收入明细项列表。"""
|
||||
items = []
|
||||
|
||||
# 基础课收入
|
||||
if basic_hours > 0 or pd_money > 0:
|
||||
items.append({
|
||||
"icon": "💰",
|
||||
"label": "基础课收入",
|
||||
"desc": format_income_desc(basic_rate, basic_hours),
|
||||
"value": f"¥{pd_money:,.2f}",
|
||||
})
|
||||
|
||||
# 激励课收入
|
||||
if bonus_hours > 0 or cx_money > 0:
|
||||
items.append({
|
||||
"icon": "🎯",
|
||||
"label": "激励课收入",
|
||||
"desc": format_income_desc(incentive_rate, bonus_hours),
|
||||
"value": f"¥{cx_money:,.2f}",
|
||||
})
|
||||
|
||||
return items
|
||||
|
||||
|
||||
def _build_customer_lists(
|
||||
conn,
|
||||
site_id: int,
|
||||
assistant_id: int,
|
||||
year: int,
|
||||
month: int,
|
||||
all_records: list[dict],
|
||||
) -> tuple[list[dict], list[dict]]:
|
||||
"""
|
||||
构建新客和常客列表。
|
||||
|
||||
新客: 本月有服务记录但本月之前无记录的客户
|
||||
常客: 本月服务次数 ≥ 2 的客户
|
||||
"""
|
||||
if not all_records:
|
||||
return [], []
|
||||
|
||||
# 按 member_id 聚合本月记录
|
||||
member_stats: dict[int, dict] = {}
|
||||
for rec in all_records:
|
||||
mid = rec.get("member_id")
|
||||
if mid is None:
|
||||
continue
|
||||
if mid not in member_stats:
|
||||
member_stats[mid] = {
|
||||
"customer_name": rec.get("customer_name") or "未知客户",
|
||||
"count": 0,
|
||||
"total_hours": 0.0,
|
||||
"total_income": 0.0,
|
||||
"last_service": rec.get("settle_time"),
|
||||
}
|
||||
stats = member_stats[mid]
|
||||
stats["count"] += 1
|
||||
stats["total_hours"] += rec.get("service_hours", 0.0)
|
||||
stats["total_income"] += rec.get("income", 0.0)
|
||||
# 更新最后服务时间(记录已按 settle_time DESC 排序,第一条即最新)
|
||||
if stats["last_service"] is None:
|
||||
stats["last_service"] = rec.get("settle_time")
|
||||
|
||||
member_ids = list(member_stats.keys())
|
||||
|
||||
# 查询历史记录(本月之前是否有服务记录)
|
||||
# ⚠️ 直连 ETL 库查询 app.v_dwd_assistant_service_log RLS 视图
|
||||
# 列名映射: assistant_id → site_assistant_id, member_id → tenant_member_id,
|
||||
# is_trash → is_delete (int, 0=正常), settle_time → create_time
|
||||
historical_members: set[int] = set()
|
||||
try:
|
||||
start_date = f"{year}-{month:02d}-01"
|
||||
with fdw_queries._fdw_context(conn, site_id) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT DISTINCT tenant_member_id
|
||||
FROM app.v_dwd_assistant_service_log
|
||||
WHERE site_assistant_id = %s
|
||||
AND is_delete = 0
|
||||
AND create_time < %s::timestamptz
|
||||
AND tenant_member_id = ANY(%s)
|
||||
""",
|
||||
(assistant_id, start_date, member_ids),
|
||||
)
|
||||
for row in cur.fetchall():
|
||||
historical_members.add(row[0])
|
||||
except Exception:
|
||||
logger.warning("查询历史客户记录失败", exc_info=True)
|
||||
|
||||
new_customers = []
|
||||
regular_customers = []
|
||||
|
||||
for mid, stats in member_stats.items():
|
||||
name = stats["customer_name"]
|
||||
char, color = avatar_char_color(name)
|
||||
|
||||
# 新客:历史无记录
|
||||
if mid not in historical_members:
|
||||
last_service_dt = stats["last_service"]
|
||||
new_customers.append({
|
||||
"name": name,
|
||||
"avatar_char": char,
|
||||
"avatar_color": color,
|
||||
"last_service": _format_date_label(last_service_dt),
|
||||
"count": stats["count"],
|
||||
})
|
||||
|
||||
# 常客:本月 ≥ 2 次
|
||||
if stats["count"] >= 2:
|
||||
regular_customers.append({
|
||||
"name": name,
|
||||
"avatar_char": char,
|
||||
"avatar_color": color,
|
||||
"hours": round(stats["total_hours"], 2),
|
||||
"income": f"¥{stats['total_income']:,.2f}",
|
||||
"count": stats["count"],
|
||||
})
|
||||
|
||||
# 新客按最后服务时间倒序
|
||||
new_customers.sort(key=lambda x: x.get("last_service", ""), reverse=True)
|
||||
# 常客按收入倒序
|
||||
regular_customers.sort(
|
||||
key=lambda x: float(x.get("income", "¥0").replace("¥", "").replace(",", "")),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
return new_customers, regular_customers
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PERF-2: 绩效明细
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def get_records(
|
||||
user_id: int, site_id: int,
|
||||
year: int, month: int, page: int, page_size: int,
|
||||
) -> dict:
|
||||
"""
|
||||
绩效明细(PERF-2)。
|
||||
|
||||
1. 获取 assistant_id
|
||||
2. fdw_queries.get_service_records() 带分页
|
||||
3. 按日期分组为 dateGroups(不含 avatarChar/avatarColor)
|
||||
4. 计算 summary 汇总
|
||||
5. 返回 { summary, dateGroups, hasMore }
|
||||
"""
|
||||
conn = _get_connection()
|
||||
try:
|
||||
assistant_id = _get_assistant_id(conn, user_id, site_id)
|
||||
|
||||
# 先获取全量记录用于 summary 计算
|
||||
all_records = fdw_queries.get_service_records(
|
||||
conn, site_id, assistant_id, year, month,
|
||||
limit=100000, offset=0,
|
||||
)
|
||||
|
||||
# 计算月度汇总
|
||||
summary = compute_summary(all_records)
|
||||
|
||||
# 分页获取记录
|
||||
offset = (page - 1) * page_size
|
||||
page_records = fdw_queries.get_service_records(
|
||||
conn, site_id, assistant_id, year, month,
|
||||
limit=page_size, offset=offset,
|
||||
)
|
||||
|
||||
# 判断 hasMore
|
||||
has_more = len(all_records) > page * page_size
|
||||
|
||||
# 按日期分组(不含 avatar)
|
||||
date_groups = group_records_by_date(page_records, include_avatar=False)
|
||||
|
||||
return {
|
||||
"summary": summary,
|
||||
"date_groups": date_groups,
|
||||
"has_more": has_more,
|
||||
}
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -3,14 +3,18 @@
|
||||
|
||||
负责任务 CRUD、置顶、放弃、取消放弃等操作。
|
||||
通过 FDW 读取客户信息和 RS 指数,计算爱心 icon 档位。
|
||||
|
||||
RNS1.1 扩展:get_task_list_v2(TASK-1)、get_task_detail(TASK-2)。
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.services import fdw_queries
|
||||
from app.services.task_generator import compute_heart_icon
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -399,3 +403,638 @@ async def cancel_abandon(task_id: int, user_id: int, site_id: int) -> dict:
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RNS1.1 扩展:辅助常量与工具函数
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# 任务类型 → 中文标签
|
||||
_TASK_TYPE_LABEL_MAP: dict[str, str] = {
|
||||
"high_priority_recall": "高优先召回",
|
||||
"priority_recall": "优先召回",
|
||||
"follow_up_visit": "客户回访",
|
||||
"relationship_building": "关系构建",
|
||||
}
|
||||
|
||||
# 课程类型 → courseTypeClass 枚举映射(design.md 定义)
|
||||
_COURSE_TYPE_CLASS_MAP: dict[str, str] = {
|
||||
"basic": "basic",
|
||||
"陪打": "basic",
|
||||
"基础课": "basic",
|
||||
"vip": "vip",
|
||||
"包厢": "vip",
|
||||
"包厢课": "vip",
|
||||
"tip": "tip",
|
||||
"超休": "tip",
|
||||
"激励课": "tip",
|
||||
"recharge": "recharge",
|
||||
"充值": "recharge",
|
||||
"incentive": "incentive",
|
||||
"激励": "incentive",
|
||||
}
|
||||
|
||||
# 维客线索 category → tag_color 映射
|
||||
_CATEGORY_COLOR_MAP: dict[str, str] = {
|
||||
"客户基础": "#0052d9",
|
||||
"客户基础信息": "#0052d9",
|
||||
"消费习惯": "#e34d59",
|
||||
"玩法偏好": "#00a870",
|
||||
"促销偏好": "#ed7b2f",
|
||||
"促销接受": "#ed7b2f",
|
||||
"社交关系": "#0594fa",
|
||||
"重要反馈": "#a25eb5",
|
||||
}
|
||||
|
||||
|
||||
def map_course_type_class(raw_course_type: str | None) -> str:
|
||||
"""将原始课程类型映射为统一枚举值(不带 tag- 前缀)。"""
|
||||
if not raw_course_type:
|
||||
return "basic"
|
||||
return _COURSE_TYPE_CLASS_MAP.get(raw_course_type.strip(), "basic")
|
||||
|
||||
|
||||
def compute_income_trend(current_income: float, prev_income: float) -> tuple[str, str]:
|
||||
"""
|
||||
计算收入趋势。
|
||||
|
||||
返回 (income_trend, income_trend_dir)。
|
||||
如 (1000, 800) → ("↑200", "up")
|
||||
"""
|
||||
diff = current_income - prev_income
|
||||
direction = "up" if diff >= 0 else "down"
|
||||
arrow = "↑" if diff >= 0 else "↓"
|
||||
trend = f"{arrow}{abs(diff):.0f}"
|
||||
return trend, direction
|
||||
|
||||
|
||||
def sanitize_tag(raw_tag: str | None) -> str:
|
||||
"""去除 tag 中的换行符,多行标签使用空格分隔。"""
|
||||
if not raw_tag:
|
||||
return ""
|
||||
return raw_tag.replace("\n", " ").strip()
|
||||
|
||||
|
||||
def _extract_emoji_and_text(summary: str | None) -> tuple[str, str]:
|
||||
"""
|
||||
从 summary 中提取 emoji 前缀和正文。
|
||||
|
||||
AI 写入格式: "📅 偏好周末下午时段消费" → ("📅", "偏好周末下午时段消费")
|
||||
手动写入无 emoji: "喜欢打中式" → ("", "喜欢打中式")
|
||||
"""
|
||||
if not summary:
|
||||
return "", ""
|
||||
# 检查第一个字符是否为 emoji(非 ASCII 且非中文常用范围)
|
||||
first_char = summary[0]
|
||||
if ord(first_char) > 0x2600 and summary[1:2] == " ":
|
||||
return first_char, summary[2:].strip()
|
||||
return "", summary.strip()
|
||||
|
||||
|
||||
def _format_time(dt: datetime | None) -> str | None:
|
||||
"""格式化时间为 ISO 字符串。"""
|
||||
if dt is None:
|
||||
return None
|
||||
return dt.isoformat() if hasattr(dt, "isoformat") else str(dt)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RNS1.1:get_task_list_v2(TASK-1 扩展版任务列表)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def get_task_list_v2(
|
||||
user_id: int,
|
||||
site_id: int,
|
||||
status: str,
|
||||
page: int,
|
||||
page_size: int,
|
||||
) -> dict:
|
||||
"""
|
||||
扩展版任务列表(TASK-1)。
|
||||
|
||||
返回 { items, total, page, pageSize, performance }。
|
||||
|
||||
逻辑:
|
||||
1. _get_assistant_id() 获取 assistant_id
|
||||
2. 查询 coach_tasks 带分页(LIMIT/OFFSET + COUNT(*))
|
||||
3. fdw_queries 批量获取会员信息、余额、lastVisitDays
|
||||
4. fdw_queries.get_salary_calc() 获取绩效概览
|
||||
5. 查询 ai_cache 获取 aiSuggestion
|
||||
6. 组装 TaskListResponse
|
||||
|
||||
扩展字段(lastVisitDays/balance/aiSuggestion)采用优雅降级。
|
||||
"""
|
||||
conn = _get_connection()
|
||||
try:
|
||||
assistant_id = _get_assistant_id(conn, user_id, site_id)
|
||||
|
||||
# ── 1. 查询任务列表(带分页 + 总数) ──
|
||||
# 状态映射:前端 pending → active
|
||||
db_status = "active" if status == "pending" else status
|
||||
|
||||
with conn.cursor() as cur:
|
||||
# 总数
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM biz.coach_tasks
|
||||
WHERE site_id = %s AND assistant_id = %s AND status = %s
|
||||
""",
|
||||
(site_id, assistant_id, db_status),
|
||||
)
|
||||
total = cur.fetchone()[0]
|
||||
|
||||
# 分页查询
|
||||
offset = (page - 1) * page_size
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, task_type, status, priority_score, is_pinned,
|
||||
expires_at, created_at, member_id, abandon_reason
|
||||
FROM biz.coach_tasks
|
||||
WHERE site_id = %s AND assistant_id = %s AND status = %s
|
||||
ORDER BY is_pinned DESC,
|
||||
priority_score DESC NULLS LAST,
|
||||
created_at ASC
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
(site_id, assistant_id, db_status, page_size, offset),
|
||||
)
|
||||
tasks = cur.fetchall()
|
||||
conn.commit()
|
||||
|
||||
if not tasks:
|
||||
# 即使无任务也需要返回绩效概览
|
||||
performance = _build_performance_summary(conn, site_id, assistant_id)
|
||||
return {
|
||||
"items": [],
|
||||
"total": 0,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"performance": performance,
|
||||
}
|
||||
|
||||
member_ids = list({t[7] for t in tasks})
|
||||
|
||||
# ── 2. FDW 批量查询会员信息 ──
|
||||
member_info_map: dict[int, dict] = {}
|
||||
try:
|
||||
member_info_map = fdw_queries.get_member_info(conn, site_id, member_ids)
|
||||
except Exception:
|
||||
logger.warning("FDW 查询会员信息失败", exc_info=True)
|
||||
|
||||
# ── 3. FDW 批量查询余额(优雅降级) ──
|
||||
balance_map: dict[int, Decimal] = {}
|
||||
try:
|
||||
balance_map = fdw_queries.get_member_balance(conn, site_id, member_ids)
|
||||
except Exception:
|
||||
logger.warning("FDW 查询余额失败", exc_info=True)
|
||||
|
||||
# ── 4. FDW 批量查询 lastVisitDays(优雅降级) ──
|
||||
last_visit_map: dict[int, int | None] = {}
|
||||
try:
|
||||
last_visit_map = fdw_queries.get_last_visit_days(conn, site_id, member_ids)
|
||||
except Exception:
|
||||
logger.warning("FDW 查询 lastVisitDays 失败", exc_info=True)
|
||||
|
||||
# ── 5. RS 指数(用于 heart_score) ──
|
||||
rs_map: dict[int, Decimal] = {}
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("BEGIN")
|
||||
cur.execute(
|
||||
"SET LOCAL app.current_site_id = %s", (str(site_id),)
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT member_id, COALESCE(rs_display, 0)
|
||||
FROM fdw_etl.v_dws_member_assistant_relation_index
|
||||
WHERE assistant_id = %s AND member_id = ANY(%s)
|
||||
""",
|
||||
(assistant_id, member_ids),
|
||||
)
|
||||
for row in cur.fetchall():
|
||||
rs_map[row[0]] = Decimal(str(row[1]))
|
||||
conn.commit()
|
||||
except Exception:
|
||||
logger.warning("FDW 查询 RS 指数失败", exc_info=True)
|
||||
try:
|
||||
conn.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── 6. 查询 ai_cache 获取 aiSuggestion(优雅降级) ──
|
||||
ai_suggestion_map: dict[int, str] = {}
|
||||
try:
|
||||
member_id_strs = [str(mid) for mid in member_ids]
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT target_id, result_json
|
||||
FROM biz.ai_cache
|
||||
WHERE site_id = %s
|
||||
AND target_id = ANY(%s)
|
||||
AND cache_type = 'app4_analysis'
|
||||
ORDER BY created_at DESC
|
||||
""",
|
||||
(site_id, member_id_strs),
|
||||
)
|
||||
seen: set[str] = set()
|
||||
for row in cur.fetchall():
|
||||
target_id_str = str(row[0])
|
||||
if target_id_str not in seen:
|
||||
seen.add(target_id_str)
|
||||
result = row[1] if isinstance(row[1], dict) else {}
|
||||
summary = result.get("summary", "")
|
||||
if summary:
|
||||
ai_suggestion_map[int(target_id_str)] = summary
|
||||
conn.commit()
|
||||
except Exception:
|
||||
logger.warning("查询 ai_cache aiSuggestion 失败", exc_info=True)
|
||||
|
||||
# ── 7. 查询备注存在性(has_note) ──
|
||||
task_ids = [t[0] for t in tasks]
|
||||
has_note_set: set[int] = set()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT DISTINCT task_id
|
||||
FROM biz.notes
|
||||
WHERE task_id = ANY(%s)
|
||||
""",
|
||||
(task_ids,),
|
||||
)
|
||||
for row in cur.fetchall():
|
||||
has_note_set.add(row[0])
|
||||
conn.commit()
|
||||
except Exception:
|
||||
logger.warning("查询备注存在性失败", exc_info=True)
|
||||
|
||||
# ── 8. 绩效概览 ──
|
||||
performance = _build_performance_summary(conn, site_id, assistant_id)
|
||||
|
||||
# ── 9. 组装 items ──
|
||||
items = []
|
||||
for task_row in tasks:
|
||||
(task_id, task_type, task_status, priority_score,
|
||||
is_pinned, expires_at, created_at, member_id, abandon_reason) = task_row
|
||||
|
||||
info = member_info_map.get(member_id, {})
|
||||
customer_name = info.get("nickname") or info.get("member_name") or "未知客户"
|
||||
rs_score = rs_map.get(member_id, Decimal("0"))
|
||||
balance = balance_map.get(member_id)
|
||||
|
||||
items.append({
|
||||
"id": task_id,
|
||||
"customer_name": customer_name,
|
||||
"customer_avatar": "/assets/images/avatar-default.png",
|
||||
"task_type": task_type,
|
||||
"task_type_label": _TASK_TYPE_LABEL_MAP.get(task_type, task_type),
|
||||
"deadline": _format_time(expires_at),
|
||||
"heart_score": float(rs_score),
|
||||
"hobbies": [], # 暂无数据源,返回空数组
|
||||
"is_pinned": bool(is_pinned),
|
||||
"has_note": task_id in has_note_set,
|
||||
"status": task_status,
|
||||
"last_visit_days": last_visit_map.get(member_id),
|
||||
"balance": float(balance) if balance is not None else None,
|
||||
"ai_suggestion": ai_suggestion_map.get(member_id),
|
||||
})
|
||||
|
||||
return {
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"performance": performance,
|
||||
}
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _build_performance_summary(conn, site_id: int, assistant_id: int) -> dict:
|
||||
"""
|
||||
构建绩效概览(PerformanceSummary)。
|
||||
|
||||
从 fdw_queries.get_salary_calc 获取当月和上月数据,
|
||||
计算收入趋势。
|
||||
"""
|
||||
now = datetime.now()
|
||||
year, month = now.year, now.month
|
||||
|
||||
# 当月绩效
|
||||
salary = None
|
||||
try:
|
||||
salary = fdw_queries.get_salary_calc(conn, site_id, assistant_id, year, month)
|
||||
except Exception:
|
||||
logger.warning("FDW 查询当月绩效失败", exc_info=True)
|
||||
|
||||
# 上月绩效(用于收入趋势)
|
||||
prev_year, prev_month = (year, month - 1) if month > 1 else (year - 1, 12)
|
||||
prev_salary = None
|
||||
try:
|
||||
prev_salary = fdw_queries.get_salary_calc(conn, site_id, assistant_id, prev_year, prev_month)
|
||||
except Exception:
|
||||
logger.warning("FDW 查询上月绩效失败", exc_info=True)
|
||||
|
||||
current_income = salary["total_income"] if salary else 0.0
|
||||
prev_income = prev_salary["total_income"] if prev_salary else 0.0
|
||||
income_trend, income_trend_dir = compute_income_trend(current_income, prev_income)
|
||||
|
||||
tier_nodes = salary["tier_nodes"] if salary and salary.get("tier_nodes") else [0]
|
||||
# tier_nodes 可能是 JSON 字符串或列表
|
||||
if isinstance(tier_nodes, str):
|
||||
try:
|
||||
tier_nodes = json.loads(tier_nodes)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
tier_nodes = [0]
|
||||
|
||||
return {
|
||||
"total_hours": salary["total_hours"] if salary else 0.0,
|
||||
"total_income": current_income,
|
||||
"total_customers": salary["total_customers"] if salary else 0,
|
||||
"month_label": f"{month}月",
|
||||
"tier_nodes": [float(n) for n in tier_nodes] if tier_nodes else [0],
|
||||
"basic_hours": salary["basic_hours"] if salary else 0.0,
|
||||
"bonus_hours": salary["bonus_hours"] if salary else 0.0,
|
||||
"current_tier": salary["tier_index"] if salary else 0,
|
||||
"next_tier_hours": salary["next_tier_hours"] if salary else 0.0,
|
||||
"tier_completed": salary["tier_completed"] if salary else False,
|
||||
"bonus_money": 0.0 if (salary and salary.get("tier_completed")) else (salary["bonus_money"] if salary else 0.0),
|
||||
"income_trend": income_trend,
|
||||
"income_trend_dir": income_trend_dir,
|
||||
"prev_month": f"{prev_month}月",
|
||||
"current_tier_label": salary["coach_level"] if salary else "",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RNS1.1:get_task_detail(TASK-2 任务详情完整版)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def get_task_detail(
|
||||
task_id: int,
|
||||
user_id: int,
|
||||
site_id: int,
|
||||
) -> dict:
|
||||
"""
|
||||
任务详情完整版(TASK-2)。
|
||||
|
||||
返回基础信息 + retentionClues + talkingPoints + serviceSummary
|
||||
+ serviceRecords + aiAnalysis + notes + customerId。
|
||||
|
||||
权限校验:任务不存在 → 404,不属于当前助教 → 403。
|
||||
"""
|
||||
conn = _get_connection()
|
||||
try:
|
||||
assistant_id = _get_assistant_id(conn, user_id, site_id)
|
||||
|
||||
# ── 1. 查询任务基础信息 ──
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, task_type, status, priority_score, is_pinned,
|
||||
expires_at, created_at, member_id, abandon_reason,
|
||||
assistant_id, site_id
|
||||
FROM biz.coach_tasks
|
||||
WHERE id = %s
|
||||
""",
|
||||
(task_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
|
||||
task_assistant_id = row[9]
|
||||
task_site_id = row[10]
|
||||
if task_site_id != site_id or task_assistant_id != assistant_id:
|
||||
raise HTTPException(status_code=403, detail="无权访问该任务")
|
||||
|
||||
member_id = row[7]
|
||||
task_type = row[1]
|
||||
task_status = row[2]
|
||||
is_pinned = row[4]
|
||||
expires_at = row[5]
|
||||
|
||||
# ── 2. FDW 查询会员信息 ──
|
||||
member_info_map: dict[int, dict] = {}
|
||||
try:
|
||||
member_info_map = fdw_queries.get_member_info(conn, site_id, [member_id])
|
||||
except Exception:
|
||||
logger.warning("FDW 查询会员信息失败", exc_info=True)
|
||||
|
||||
info = member_info_map.get(member_id, {})
|
||||
customer_name = info.get("nickname") or "未知客户"
|
||||
|
||||
# RS 指数
|
||||
rs_score = Decimal("0")
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("BEGIN")
|
||||
cur.execute(
|
||||
"SET LOCAL app.current_site_id = %s", (str(site_id),)
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COALESCE(rs_display, 0)
|
||||
FROM fdw_etl.v_dws_member_assistant_relation_index
|
||||
WHERE assistant_id = %s AND member_id = %s
|
||||
""",
|
||||
(assistant_id, member_id),
|
||||
)
|
||||
rs_row = cur.fetchone()
|
||||
if rs_row:
|
||||
rs_score = Decimal(str(rs_row[0]))
|
||||
conn.commit()
|
||||
except Exception:
|
||||
logger.warning("FDW 查询 RS 指数失败", exc_info=True)
|
||||
try:
|
||||
conn.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── 3. 查询维客线索 ──
|
||||
retention_clues = []
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, category, summary, detail, source
|
||||
FROM public.member_retention_clue
|
||||
WHERE member_id = %s AND site_id = %s
|
||||
ORDER BY recorded_at DESC
|
||||
""",
|
||||
(member_id, site_id),
|
||||
)
|
||||
for clue_row in cur.fetchall():
|
||||
category = clue_row[1] or ""
|
||||
summary_raw = clue_row[2] or ""
|
||||
detail = clue_row[3]
|
||||
source = clue_row[4] or "manual"
|
||||
|
||||
emoji, text = _extract_emoji_and_text(summary_raw)
|
||||
tag = sanitize_tag(category)
|
||||
tag_color = _CATEGORY_COLOR_MAP.get(tag, "#999999")
|
||||
|
||||
retention_clues.append({
|
||||
"tag": tag,
|
||||
"tag_color": tag_color,
|
||||
"emoji": emoji,
|
||||
"text": text,
|
||||
"source": source,
|
||||
"desc": detail,
|
||||
})
|
||||
conn.commit()
|
||||
except Exception:
|
||||
logger.warning("查询维客线索失败", exc_info=True)
|
||||
|
||||
# ── 4. 查询 AI 缓存(talkingPoints + aiAnalysis) ──
|
||||
talking_points: list[str] = []
|
||||
ai_analysis = {"summary": "", "suggestions": []}
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT cache_type, result_json
|
||||
FROM biz.ai_cache
|
||||
WHERE target_id = %s AND site_id = %s
|
||||
AND cache_type IN ('app4_analysis', 'app5_talking_points')
|
||||
ORDER BY created_at DESC
|
||||
""",
|
||||
(str(member_id), site_id),
|
||||
)
|
||||
seen_types: set[str] = set()
|
||||
for cache_row in cur.fetchall():
|
||||
cache_type = cache_row[0]
|
||||
if cache_type in seen_types:
|
||||
continue
|
||||
seen_types.add(cache_type)
|
||||
|
||||
result = cache_row[1] if isinstance(cache_row[1], dict) else {}
|
||||
|
||||
if cache_type == "app5_talking_points":
|
||||
# talkingPoints: 话术列表
|
||||
points = result.get("talking_points", [])
|
||||
if isinstance(points, list):
|
||||
talking_points = [str(p) for p in points]
|
||||
elif cache_type == "app4_analysis":
|
||||
# aiAnalysis: summary + suggestions
|
||||
ai_analysis = {
|
||||
"summary": result.get("summary", ""),
|
||||
"suggestions": result.get("suggestions", []),
|
||||
}
|
||||
conn.commit()
|
||||
except Exception:
|
||||
logger.warning("查询 AI 缓存失败", exc_info=True)
|
||||
|
||||
# ── 5. FDW 查询服务记录(最多 20 条) ──
|
||||
service_records_raw: list[dict] = []
|
||||
try:
|
||||
service_records_raw = fdw_queries.get_service_records_for_task(
|
||||
conn, site_id, assistant_id, member_id, limit=20
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("FDW 查询服务记录失败", exc_info=True)
|
||||
|
||||
service_records = []
|
||||
total_hours = 0.0
|
||||
total_income = 0.0
|
||||
for rec in service_records_raw:
|
||||
hours = rec.get("service_hours", 0.0)
|
||||
income = rec.get("income", 0.0)
|
||||
total_hours += hours
|
||||
total_income += income
|
||||
|
||||
# 时间格式化
|
||||
settle_time = rec.get("settle_time")
|
||||
date_str = ""
|
||||
if settle_time:
|
||||
if hasattr(settle_time, "strftime"):
|
||||
date_str = settle_time.strftime("%Y-%m-%d")
|
||||
else:
|
||||
date_str = str(settle_time)[:10]
|
||||
|
||||
raw_course_type = rec.get("course_type", "")
|
||||
type_class = map_course_type_class(raw_course_type)
|
||||
|
||||
service_records.append({
|
||||
"table": rec.get("table_name"),
|
||||
"type": raw_course_type or "基础课",
|
||||
"type_class": type_class,
|
||||
"record_type": "recharge" if type_class == "recharge" else "course",
|
||||
"duration": hours,
|
||||
"duration_raw": rec.get("service_hours_raw"),
|
||||
"income": income,
|
||||
"is_estimate": rec.get("is_estimate"),
|
||||
"drinks": None,
|
||||
"date": date_str,
|
||||
})
|
||||
|
||||
avg_income = total_income / len(service_records) if service_records else 0.0
|
||||
service_summary = {
|
||||
"total_hours": round(total_hours, 2),
|
||||
"total_income": round(total_income, 2),
|
||||
"avg_income": round(avg_income, 2),
|
||||
}
|
||||
|
||||
# ── 6. 查询备注(最多 20 条) ──
|
||||
notes: list[dict] = []
|
||||
has_note = False
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, content, type, ai_score, created_at
|
||||
FROM biz.notes
|
||||
WHERE task_id = %s
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 20
|
||||
""",
|
||||
(task_id,),
|
||||
)
|
||||
for note_row in cur.fetchall():
|
||||
has_note = True
|
||||
note_type = note_row[2] or "normal"
|
||||
# type → tag_type/tag_label 映射
|
||||
tag_label = "回访" if note_type == "follow_up" else "普通"
|
||||
|
||||
notes.append({
|
||||
"id": note_row[0],
|
||||
"content": note_row[1] or "",
|
||||
"tag_type": note_type,
|
||||
"tag_label": tag_label,
|
||||
"created_at": _format_time(note_row[4]) or "",
|
||||
"score": note_row[3],
|
||||
})
|
||||
conn.commit()
|
||||
except Exception:
|
||||
logger.warning("查询备注失败", exc_info=True)
|
||||
|
||||
# ── 7. 组装 TaskDetailResponse ──
|
||||
return {
|
||||
"id": task_id,
|
||||
"customer_name": customer_name,
|
||||
"customer_avatar": "/assets/images/avatar-default.png",
|
||||
"task_type": task_type,
|
||||
"task_type_label": _TASK_TYPE_LABEL_MAP.get(task_type, task_type),
|
||||
"deadline": _format_time(expires_at),
|
||||
"heart_score": float(rs_score),
|
||||
"hobbies": [],
|
||||
"is_pinned": bool(is_pinned),
|
||||
"has_note": has_note,
|
||||
"status": task_status,
|
||||
"customer_id": member_id,
|
||||
"retention_clues": retention_clues,
|
||||
"talking_points": talking_points,
|
||||
"service_summary": service_summary,
|
||||
"service_records": service_records,
|
||||
"ai_analysis": ai_analysis,
|
||||
"notes": notes,
|
||||
}
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
Reference in New Issue
Block a user