Files
qiji/backend/app/api/visits.py
T
v6ole b343970ecc feat: 三项联动 — 计划过期提醒 + 拜访自动关闭 + 批量制定
1. 计划过期自动提醒:
   - scheduler.py 新增 check_overdue_plans()
   - 每日 9:00 检查过期计划(plan_date < today, status=计划中)
   - 按经理汇总 → 企微推送提醒消息

2. 拜访后自动关闭计划:
   - create_visit 后自动将匹配的计划标记为「已完成」
   - 条件: customer_id 匹配 + status=计划中 + plan_date <= visit_date

3. 亮灯表批量制定:
   - 红灯/黄灯卡片右上角 ☐ 复选框
   - 勾选后出现「批量制定计划」按钮
   - 弹窗统一设置日期+内容 → 批量 POST

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-27 12:28:36 +08:00

255 lines
9.2 KiB
Python

import uuid
from datetime import date, datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.database import get_db
from app.middleware.auth import get_current_user, require_any_role
from app.models.visit import Visit
from app.models.customer import Customer
from app.models.user import User
from app.schemas.visit import VisitCreate, VisitUpdate, VisitOut, VisitListOut
from app.utils.timezone import today_cst, parse_date
from app.services.minio_client import delete_objects
from app.utils.edit_log import compute_diff, append_entry, init_entry
router = APIRouter(prefix="/visits", tags=["Visits"])
async def _enrich_visit(visit: Visit, db: AsyncSession) -> dict:
"""Enrich a visit record with customer/manager names."""
customer_name = None
manager_name = None
if visit.customer_id:
cust_result = await db.execute(select(Customer.name).where(Customer.id == visit.customer_id))
customer_name = cust_result.scalar_one_or_none()
if visit.manager_id:
mgr_result = await db.execute(select(User.name).where(User.id == visit.manager_id))
manager_name = mgr_result.scalar_one_or_none()
return {
"id": str(visit.id),
"customer_id": str(visit.customer_id),
"customer_name": customer_name,
"visit_date": visit.visit_date,
"visit_method": visit.visit_method,
"time_range": visit.time_range,
"visitor_name": visit.visitor_name or "",
"visitor_phone": visit.visitor_phone or "",
"communication_content": visit.communication_content,
"customer_demand": visit.customer_demand,
"companions": visit.companions,
"photos": visit.photos,
"manager_id": str(visit.manager_id),
"manager_name": manager_name,
"edit_log": visit.edit_log or [],
"created_at": str(visit.created_at),
"updated_at": str(visit.updated_at),
}
@router.get("/")
async def list_visits(
date_from: Optional[str] = Query(None),
date_to: Optional[str] = Query(None),
customer_id: Optional[str] = Query(None),
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""List visits. Managers see only their own, directors/leaders see all."""
query = select(Visit)
if current_user["role"] == "manager":
query = query.where(Visit.manager_id == uuid.UUID(current_user["user_id"]))
if date_from:
query = query.where(Visit.visit_date >= parse_date(date_from))
if date_to:
query = query.where(Visit.visit_date <= parse_date(date_to))
if customer_id:
query = query.where(Visit.customer_id == uuid.UUID(customer_id))
query = query.order_by(Visit.visit_date.desc(), Visit.created_at.desc()).limit(200)
result = await db.execute(query)
visits = result.scalars().all()
# Enrich
enriched = []
for v in visits:
enriched.append(await _enrich_visit(v, db))
return enriched
@router.get("/today")
async def list_today_visits(
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Get today's visits for the current user's mobile home screen."""
query = select(Visit).where(Visit.visit_date == today_cst())
if current_user["role"] == "manager":
query = query.where(Visit.manager_id == uuid.UUID(current_user["user_id"]))
query = query.order_by(Visit.created_at.desc())
result = await db.execute(query)
visits = result.scalars().all()
enriched = []
for v in visits:
enriched.append(await _enrich_visit(v, db))
count = len(enriched)
return {"count": count, "visits": enriched}
@router.get("/{visit_id}")
async def get_visit(
visit_id: str,
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Visit).where(Visit.id == visit_id))
visit = result.scalar_one_or_none()
if not visit:
raise HTTPException(status_code=404, detail="Visit not found")
# Permission check
if current_user["role"] == "manager" and str(visit.manager_id) != current_user["user_id"]:
raise HTTPException(status_code=403, detail="Access denied")
return await _enrich_visit(visit, db)
@router.post("/")
async def create_visit(
data: VisitCreate,
current_user: dict = Depends(require_any_role),
db: AsyncSession = Depends(get_db),
):
"""Create a visit record. If companions are selected, creates draft copies for them."""
visit = Visit(
customer_id=data.customer_id,
visit_date=parse_date(data.visit_date),
visit_method=data.visit_method,
time_range=data.time_range,
communication_content=data.communication_content,
customer_demand=data.customer_demand,
companions=data.companions,
photos=data.photos,
manager_id=uuid.UUID(current_user["user_id"]),
)
init_entry(visit, current_user["name"])
db.add(visit)
# Update customer's last visit tracking
cust_result = await db.execute(select(Customer).where(Customer.id == data.customer_id))
customer = cust_result.scalar_one_or_none()
if customer:
customer.last_visit_date = parse_date(data.visit_date)
customer.last_visit_manager_id = uuid.UUID(current_user["user_id"])
db.add(customer)
# Auto-complete matching work plans for this customer
from app.models.work_plan import WorkPlan
plans_result = await db.execute(
select(WorkPlan).where(
WorkPlan.customer_id == data.customer_id,
WorkPlan.status == "计划中",
WorkPlan.plan_date <= parse_date(data.visit_date),
)
)
for plan in plans_result.scalars().all():
plan.status = "已完成"
append_entry(plan, current_user["name"], [{"field": "status", "from": "计划中", "to": "已完成", "reason": "拜访自动完成"}])
# Create draft copies for companions
for companion_id in data.companions:
if companion_id != uuid.UUID(current_user["user_id"]):
draft = Visit(
customer_id=data.customer_id,
visit_date=parse_date(data.visit_date),
visit_method=data.visit_method,
time_range=data.time_range,
communication_content="", # Leave blank for companion to fill
customer_demand="",
companions=[],
photos=[],
manager_id=companion_id,
)
db.add(draft)
await db.commit()
await db.refresh(visit)
return await _enrich_visit(visit, db)
@router.put("/{visit_id}")
async def update_visit(
visit_id: str,
data: VisitUpdate,
current_user: dict = Depends(require_any_role),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Visit).where(Visit.id == visit_id))
visit = result.scalar_one_or_none()
if not visit:
raise HTTPException(status_code=404, detail="Visit not found")
if current_user["role"] == "manager" and str(visit.manager_id) != current_user["user_id"]:
raise HTTPException(status_code=403, detail="Access denied")
# Snapshot old values for diff
old_snapshot = {
"customer_id": str(visit.customer_id), "visit_date": str(visit.visit_date),
"visit_method": visit.visit_method, "time_range": visit.time_range,
"visitor_name": visit.visitor_name or "", "visitor_phone": visit.visitor_phone or "",
"communication_content": visit.communication_content, "customer_demand": visit.customer_demand,
}
update_data = data.model_dump(exclude_unset=True)
if "visit_date" in update_data and update_data["visit_date"]:
update_data["visit_date"] = parse_date(update_data["visit_date"])
for key, value in update_data.items():
setattr(visit, key, value)
# Compute diff and append to edit_log
new_snapshot = {
"customer_id": str(visit.customer_id), "visit_date": str(visit.visit_date),
"visit_method": visit.visit_method, "time_range": visit.time_range,
"visitor_name": visit.visitor_name or "", "visitor_phone": visit.visitor_phone or "",
"communication_content": visit.communication_content, "customer_demand": visit.customer_demand,
}
changes = compute_diff(old_snapshot, new_snapshot)
if changes:
append_entry(visit, current_user["name"], changes, getattr(data, "edit_reason", None))
await db.commit()
await db.refresh(visit)
return await _enrich_visit(visit, db)
@router.delete("/{visit_id}")
async def delete_visit(
visit_id: str,
current_user: dict = Depends(require_any_role),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Visit).where(Visit.id == visit_id))
visit = result.scalar_one_or_none()
if not visit:
raise HTTPException(status_code=404, detail="Visit not found")
if current_user["role"] == "manager" and str(visit.manager_id) != current_user["user_id"]:
raise HTTPException(status_code=403, detail="Access denied")
# Clean up photos in MinIO
if visit.photos:
delete_objects(visit.photos)
await db.delete(visit)
await db.commit()
return {"detail": "deleted"}