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 from app.utils.audit import log_audit 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, "visit_group_id": str(visit.visit_group_id) if visit.visit_group_id else None, "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 full copies for them.""" _validate_photo_keys(data.photos, current_user["user_id"]) group_id = uuid.uuid4() 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"]), visit_group_id=group_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 full copies for companions (not blank drafts) creator_name = current_user["name"] for companion_id in data.companions: if companion_id != uuid.UUID(current_user["user_id"]): # Build companion's companions list: the creator + other companions excluding self comp_companions = [uuid.UUID(current_user["user_id"])] for cid in data.companions: if cid != companion_id and cid not in comp_companions: comp_companions.append(cid) draft = Visit( customer_id=data.customer_id, visit_date=parse_date(data.visit_date), visit_method=data.visit_method, time_range=data.time_range, visitor_name=data.visitor_name, visitor_phone=data.visitor_phone, communication_content=(data.communication_content or "") + f"(协同{creator_name})", customer_demand=data.customer_demand, companions=comp_companions, companion_names=[], photos=data.photos, manager_id=companion_id, visit_group_id=group_id, ) init_entry(draft, current_user["name"]) db.add(draft) # Audit log (before commit — part of same transaction) cust = await db.execute(select(Customer.name).where(Customer.id == visit.customer_id)) await log_audit(db, "visit", visit.id, f"{cust.scalar_one_or_none() or ''} ({data.visit_date})", "create", current_user["user_id"], current_user["name"]) 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)) # Auto-complete matching work plans when visit date or customer changes from app.models.work_plan import WorkPlan plans_result = await db.execute( select(WorkPlan).where( WorkPlan.customer_id == visit.customer_id, WorkPlan.status == "计划中", WorkPlan.plan_date <= visit.visit_date, ) ) for plan in plans_result.scalars().all(): plan.status = "已完成" append_entry(plan, current_user["name"], [{"field": "status", "from": "计划中", "to": "已完成", "reason": "拜访更新自动完成"}]) # Audit log (before commit — part of same transaction) cust = await db.execute(select(Customer.name).where(Customer.id == visit.customer_id)) await log_audit(db, "visit", visit.id, f"{cust.scalar_one_or_none() or ''} ({visit.visit_date})", "update", current_user["user_id"], current_user["name"], ", ".join([c["field"] for c in changes]) if changes else "") 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) # Snapshot entity name before deletion cust_name_result = await db.execute(select(Customer.name).where(Customer.id == customer_id)) entity_name = f"{cust_name_result.scalar_one_or_none() or ''} ({visit.visit_date})" 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 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 # Audit log (before final commit — part of same transaction) await log_audit(db, "visit", uuid.UUID(visit_id), entity_name, "delete", current_user["user_id"], current_user["name"]) await db.commit() return {"detail": "deleted"}