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, or_ 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), customer_type: Optional[str] = Query(None), search: 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)) if customer_type: query = query.join(Customer, Visit.customer_id == Customer.id).where(Customer.customer_type == customer_type) if search: query = query.outerjoin(Customer, Visit.customer_id == Customer.id).where( or_(Visit.communication_content.ilike(f"%{search}%"), Visit.customer_demand.ilike(f"%{search}%"), Visit.visitor_name.ilike(f"%{search}%"), Customer.name.ilike(f"%{search}%")) ) 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=data.manager_id if (current_user["role"] in ("director", "leader") and data.manager_id) else 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.services.visits import auto_complete_work_plans await auto_complete_work_plans( db, data.customer_id, parse_date(data.visit_date), current_user["name"], "拜访自动完成", ) # Auto-create follow-up logs for active mini business opportunities from app.models.mini_business import MiniBusiness from app.models.mini_business_log import MiniBusinessLog method_map = {"上门": "上门", "电话": "电话", "微信": "微信", "出差": "其他"} active_biz = await db.execute( select(MiniBusiness).where( MiniBusiness.customer_id == data.customer_id, MiniBusiness.status == "跟进中", ) ) # 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) # Auto-create follow-up logs for active mini business opportunities for biz in active_biz.scalars().all(): parts = [] if data.communication_content and data.communication_content.strip(): parts.append(f"沟通内容:{data.communication_content.strip()}") if data.customer_demand and data.customer_demand.strip(): parts.append(f"客户需求:{data.customer_demand.strip()}") log_content = "\n".join(parts) if parts else "拜访跟进" follow_method = method_map.get(data.visit_method, "其他") log = MiniBusinessLog( business_id=biz.id, log_date=parse_date(data.visit_date), method=follow_method, content=log_content, created_by=uuid.UUID(current_user["user_id"]), ) db.add(log) # 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, "manager_id": str(visit.manager_id) if visit.manager_id else "", } update_data = data.model_dump(exclude_unset=True) if current_user["role"] not in ("director", "leader"): update_data.pop("manager_id", None) if "visit_date" in update_data and update_data["visit_date"]: update_data["visit_date"] = parse_date(update_data["visit_date"]) if "manager_id" in update_data and update_data["manager_id"]: update_data["manager_id"] = uuid.UUID(update_data["manager_id"]) 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, "manager_id": str(visit.manager_id) if visit.manager_id else "", } 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(changes.keys()) if changes else "") await db.commit() await db.refresh(visit) # Auto-complete matching work plans after visit update from app.services.visits import auto_complete_work_plans await auto_complete_work_plans( db, visit.customer_id, visit.visit_date, current_user["name"], "拜访更新自动完成", ) await db.commit() 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"}