c9df6066f5
- 原查询用 outerjoin(Visit, visit_date == MAX(visit_date)) 获取拜访方式, 当同一天有多条拜访(协同拜访)时会扩展成多行,导致亮灯表同一客户显示多张卡片 - 改用 DISTINCT ON (manager_id, customer_id) 子查询,保证每对(经理,客户) 只取最新一条拜访,消除重复 - 移除不再使用的 func 导入 Co-Authored-By: Claude <noreply@anthropic.com>
270 lines
9.5 KiB
Python
270 lines
9.5 KiB
Python
"""Customer light board — visit coverage matrix per manager.
|
|
|
|
Status (rolling 30-day window):
|
|
green — visited within last 30 days (亮灯)
|
|
yellow — visited 31-60 days ago (临期)
|
|
red — visited 61+ days ago, or never visited (灭灯+警示)
|
|
gray — customer has no assigned primary manager (未分配)
|
|
"""
|
|
|
|
from datetime import date, timedelta
|
|
from uuid import UUID
|
|
from sqlalchemy import select, and_
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from app.models.user import User
|
|
from app.models.customer import Customer
|
|
from app.models.customer_assignment import CustomerAssignment
|
|
from app.models.visit import Visit
|
|
from app.models.work_plan import WorkPlan
|
|
from app.utils.timezone import today_cst
|
|
|
|
|
|
def month_start(ref: date) -> date:
|
|
return ref.replace(day=1)
|
|
|
|
|
|
def month_end(ref: date) -> date:
|
|
nxt = ref.replace(day=28) + timedelta(days=4)
|
|
return nxt - timedelta(days=nxt.day)
|
|
|
|
|
|
def classify(last_visit_date: date | None, ref: date) -> tuple[str, int]:
|
|
"""Return (status, consecutive_missed_periods).
|
|
|
|
30-day rolling window:
|
|
green — visited within 30 days
|
|
yellow — visited 31-60 days ago (临期)
|
|
red — 61+ days ago, or never visited
|
|
"""
|
|
if last_visit_date is None:
|
|
return ("red", 99) # never visited
|
|
|
|
days_since = (ref - last_visit_date).days
|
|
|
|
if days_since <= 30:
|
|
return ("green", 0)
|
|
elif days_since <= 60:
|
|
return ("yellow", 0)
|
|
|
|
# Each 30-day block beyond 60 days counts as one missed period
|
|
periods = days_since // 30
|
|
return ("red", min(periods, 99))
|
|
|
|
|
|
async def get_light_board(
|
|
db: AsyncSession,
|
|
reference_date: date | None = None,
|
|
user_id: UUID | None = None,
|
|
role: str = "manager",
|
|
) -> dict:
|
|
"""Get per-manager customer visit coverage for the light board.
|
|
|
|
Directors/leaders see all managers; managers see only themselves.
|
|
"""
|
|
ref = reference_date or today_cst()
|
|
|
|
# ── Managers (filtered by role) ──
|
|
managers_result = await db.execute(select(User).where(User.role == "manager"))
|
|
all_managers = managers_result.scalars().all()
|
|
# Filter: managers only see themselves
|
|
visible_managers = all_managers if role in ("director", "leader") else [
|
|
m for m in all_managers if m.id == user_id
|
|
]
|
|
|
|
manager_map: dict[UUID, dict] = {
|
|
m.id: {
|
|
"manager_id": str(m.id),
|
|
"manager_name": m.name,
|
|
"total_customers": 0,
|
|
"visited_this_month": 0,
|
|
"visited_last_month_only": 0,
|
|
"not_visited_2months": 0,
|
|
"coverage_rate": 0.0,
|
|
"customers": [],
|
|
}
|
|
for m in visible_managers
|
|
}
|
|
|
|
# ── All customers with their last visit per manager ──
|
|
# Subquery: latest visit (date + method) per (manager_id, customer_id).
|
|
# DISTINCT ON keeps exactly one row per pair even when multiple visits
|
|
# share the same latest date (e.g. companion/collaborative visits),
|
|
# preventing fan-out that would duplicate customer cards on the board.
|
|
latest_visit = (
|
|
select(
|
|
Visit.manager_id,
|
|
Visit.customer_id,
|
|
Visit.visit_date.label("last_visit_date"),
|
|
Visit.visit_method.label("last_visit_method"),
|
|
)
|
|
.distinct(Visit.manager_id, Visit.customer_id)
|
|
.order_by(
|
|
Visit.manager_id,
|
|
Visit.customer_id,
|
|
Visit.visit_date.desc(),
|
|
Visit.id.desc(),
|
|
)
|
|
.subquery()
|
|
)
|
|
|
|
# Join: assignments → customers → latest_visit (date + method)
|
|
rows = await db.execute(
|
|
select(
|
|
CustomerAssignment.manager_id,
|
|
CustomerAssignment.customer_id,
|
|
Customer.id.label("c_id"),
|
|
Customer.name.label("c_name"),
|
|
Customer.industry,
|
|
Customer.in_use_services,
|
|
Customer.monthly_fee,
|
|
latest_visit.c.last_visit_date,
|
|
latest_visit.c.last_visit_method,
|
|
)
|
|
.join(Customer, Customer.id == CustomerAssignment.customer_id)
|
|
.outerjoin(latest_visit, and_(
|
|
latest_visit.c.manager_id == CustomerAssignment.manager_id,
|
|
latest_visit.c.customer_id == CustomerAssignment.customer_id,
|
|
))
|
|
.where(CustomerAssignment.role == "primary")
|
|
.order_by(Customer.name)
|
|
)
|
|
|
|
assigned_customer_ids: set[UUID] = set()
|
|
|
|
for row in rows.all():
|
|
mgr_id, cust_id, c_id, c_name, industry, services, fee, lvd, method = row
|
|
assigned_customer_ids.add(c_id)
|
|
|
|
status, missed = classify(lvd, ref)
|
|
cust_entry = {
|
|
"customer_id": str(c_id),
|
|
"customer_name": c_name,
|
|
"industry": industry or "",
|
|
"in_use_services": services or "",
|
|
"monthly_fee": str(fee) if fee else "",
|
|
"last_visit_date": str(lvd) if lvd else None,
|
|
"last_visit_method": method or "",
|
|
"status": status,
|
|
"consecutive_missed_months": missed,
|
|
}
|
|
|
|
mgr_entry = manager_map.get(mgr_id)
|
|
if mgr_entry:
|
|
mgr_entry["customers"].append(cust_entry)
|
|
mgr_entry["total_customers"] += 1
|
|
if status == "green":
|
|
mgr_entry["visited_this_month"] += 1
|
|
elif status == "yellow":
|
|
mgr_entry["visited_last_month_only"] += 1
|
|
else:
|
|
mgr_entry["not_visited_2months"] += 1
|
|
|
|
# ── Active work plans per customer ──
|
|
active_plans_result = await db.execute(
|
|
select(
|
|
WorkPlan.customer_id,
|
|
WorkPlan.plan_content,
|
|
WorkPlan.plan_date,
|
|
WorkPlan.status,
|
|
WorkPlan.manager_id,
|
|
).where(
|
|
WorkPlan.customer_id.in_(assigned_customer_ids),
|
|
WorkPlan.status == "计划中",
|
|
).order_by(WorkPlan.plan_date)
|
|
)
|
|
plans_map: dict[UUID, list] = {}
|
|
for row in active_plans_result.all():
|
|
cid, content, pdate, pstatus, pmanager = row
|
|
plans_map.setdefault(cid, []).append({
|
|
"plan_content": content,
|
|
"plan_date": str(pdate),
|
|
"status": pstatus,
|
|
"plan_overdue": pdate < ref,
|
|
"manager_id": str(pmanager) if pmanager else None,
|
|
})
|
|
|
|
# ── Recent visits for green/yellow customers ──
|
|
recent_visits_result = await db.execute(
|
|
select(
|
|
Visit.customer_id,
|
|
Visit.visit_date,
|
|
Visit.visit_method,
|
|
Visit.communication_content,
|
|
Visit.manager_id,
|
|
).where(
|
|
Visit.customer_id.in_(assigned_customer_ids),
|
|
).order_by(Visit.visit_date.desc()).limit(500)
|
|
)
|
|
visits_map: dict[UUID, list] = {}
|
|
for row in recent_visits_result.all():
|
|
cid, vdate, vmethod, vcontent, vmanager = row
|
|
visits_map.setdefault(cid, []).append({
|
|
"visit_date": str(vdate),
|
|
"visit_method": vmethod,
|
|
"content": (vcontent or "")[:120],
|
|
"manager_id": str(vmanager) if vmanager else None,
|
|
})
|
|
|
|
# ── Merge plans & visits into customer entries ──
|
|
for mgr_entry in manager_map.values():
|
|
for c in mgr_entry["customers"]:
|
|
cid = UUID(c["customer_id"])
|
|
c["plans"] = plans_map.get(cid, [])
|
|
c["recent_visits"] = (visits_map.get(cid, []) or [])[:2]
|
|
|
|
# ── Calculate coverage rates ──
|
|
for mgr_entry in manager_map.values():
|
|
total = mgr_entry["total_customers"]
|
|
if total > 0:
|
|
mgr_entry["coverage_rate"] = round(mgr_entry["visited_this_month"] / total, 3)
|
|
|
|
# Sort managers: lowest coverage first (most problematic first)
|
|
manager_list = sorted(manager_map.values(), key=lambda m: m["coverage_rate"])
|
|
|
|
# ── Unassigned customers (no primary manager) ──
|
|
# Only visible to directors/leaders — not actionable for individual managers
|
|
unassigned = []
|
|
if role in ("director", "leader"):
|
|
unassigned_rows = await db.execute(
|
|
select(Customer)
|
|
.outerjoin(CustomerAssignment, and_(
|
|
CustomerAssignment.customer_id == Customer.id,
|
|
CustomerAssignment.role == "primary",
|
|
))
|
|
.where(CustomerAssignment.id == None)
|
|
.order_by(Customer.name)
|
|
)
|
|
for c in unassigned_rows.scalars():
|
|
unassigned.append({
|
|
"customer_id": str(c.id),
|
|
"customer_name": c.name,
|
|
"industry": c.industry or "",
|
|
"in_use_services": c.in_use_services or "",
|
|
"monthly_fee": str(c.monthly_fee) if c.monthly_fee else "",
|
|
"last_visit_date": None,
|
|
"last_visit_method": "",
|
|
"status": "gray",
|
|
"consecutive_missed_months": 0,
|
|
})
|
|
|
|
# ── Team summary ──
|
|
all_total = sum(m["total_customers"] for m in manager_list)
|
|
all_green = sum(m["visited_this_month"] for m in manager_list)
|
|
all_yellow = sum(m["visited_last_month_only"] for m in manager_list)
|
|
all_red = sum(m["not_visited_2months"] for m in manager_list)
|
|
team_summary = {
|
|
"total_customers": all_total,
|
|
"visited_this_month": all_green,
|
|
"visited_last_month_only": all_yellow,
|
|
"not_visited_2months": all_red,
|
|
"unassigned": len(unassigned),
|
|
"coverage_rate": round(all_green / all_total, 3) if all_total > 0 else 0.0,
|
|
}
|
|
|
|
return {
|
|
"reference_month": ref.strftime("%Y-%m"),
|
|
"managers": manager_list,
|
|
"unassigned_customers": unassigned,
|
|
"team_summary": team_summary,
|
|
}
|