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.api.upload import is_owned_upload_key from app.utils.edit_log import compute_diff, append_entry, init_entry router = APIRouter(prefix="/visits", tags=["Visits"]) def _validate_photo_keys(photo_keys: list[str], user_id: str, existing_keys: list[str] | None = None) -> None: if len(photo_keys) > 9: raise HTTPException(status_code=400, detail="A visit may contain at most 9 photos") existing = set(existing_keys or []) for key in photo_keys: if key not in existing and not is_owned_upload_key(key, user_id): raise HTTPException(status_code=400, detail="Invalid or unauthorized photo reference") 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() # Resolve companion UUIDs to names companion_names_resolved = list(visit.companion_names or []) if visit.companions: comp_result = await db.execute(select(User.name).where(User.id.in_(visit.companions))) companion_names_resolved = [n for n, in comp_result.all()] + companion_names_resolved 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, "companion_names": visit.companion_names or [], "companion_names_resolved": companion_names_resolved, "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.""" _validate_photo_keys(data.photos, current_user["user_id"]) 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, companion_names=data.companion_names, 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") if data.photos is not None: _validate_photo_keys(data.photos, current_user["user_id"], visit.photos or []) # 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") customer_id = visit.customer_id manager_id = visit.manager_id # Clean up photos in MinIO if visit.photos: delete_objects(visit.photos) await db.delete(visit) # Recalculate customer's last_visit_date from remaining visits latest = await db.execute( select(func.max(Visit.visit_date)).where( Visit.customer_id == customer_id, ) ) new_latest = latest.scalar() cust = await db.get(Customer, customer_id) if cust: if new_latest: cust.last_visit_date = new_latest # Keep the existing manager if date unchanged, or find who made the latest visit latest_visit = await db.execute( select(Visit).where( Visit.customer_id == customer_id, Visit.visit_date == new_latest, ).order_by(Visit.created_at.desc()).limit(1) ) lv = latest_visit.scalar_one_or_none() if lv: cust.last_visit_manager_id = lv.manager_id else: cust.last_visit_date = None cust.last_visit_manager_id = None await db.commit() return {"detail": "deleted"}