from datetime import date, timedelta from uuid import UUID from sqlalchemy import select, func, and_ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import aliased from app.models.customer import Customer from app.models.user import User from app.models.visit import Visit from app.models.work_plan import WorkPlan from app.models.mini_business import MiniBusiness from app.models.key_visit import KeyVisit from app.models.daily_note import DailyNote from app.models.leave import Leave from app.services.holidays import load_holiday_sets from app.utils.timezone import today_cst, is_working_day, parse_date def get_week_range(reference_date: date | None = None): today = reference_date or date.today() monday = today - timedelta(days=today.weekday()) sunday = monday + timedelta(days=6) return monday, sunday async def get_dashboard_stats(db: AsyncSession, reference_date: date | None = None, user_id: str = "", role: str = "") -> dict: """Get dashboard statistics for a given week (defaults to current).""" monday, sunday = get_week_range(reference_date) today = date.today() visits_count = (await db.execute( select(func.count(func.distinct(func.coalesce(Visit.visit_group_id, Visit.id)))) .where(Visit.visit_date >= monday, Visit.visit_date <= sunday) )).scalar() or 0 plans_count = (await db.execute( select(func.count(WorkPlan.id)) )).scalar() or 0 mini_biz_count = (await db.execute( select(func.count(MiniBusiness.id)) )).scalar() or 0 key_visit_count = (await db.execute( select(func.count(KeyVisit.id)) )).scalar() or 0 # Week leave count — distinct managers on leave this week leaves_count = (await db.execute( select(func.count(func.distinct(Leave.manager_id))).where( and_(Leave.start_date <= sunday, Leave.end_date >= monday) ) )).scalar() or 0 # Overdue plans (status=计划中, plan_date < today). Managers see only own. overdue_q = select(func.count(WorkPlan.id)).where( WorkPlan.status == "计划中", WorkPlan.plan_date < today, ) if role == "manager": overdue_q = overdue_q.where(WorkPlan.manager_id == UUID(user_id)) overdue_plans = (await db.execute(overdue_q)).scalar() or 0 return { "week_visits": visits_count, "work_plans": plans_count, "mini_business": mini_biz_count, "key_visits": key_visit_count, "week_leaves": leaves_count, "overdue_plans": overdue_plans, "week_start": str(monday), "week_end": str(sunday), } async def get_reporting_progress(db: AsyncSession, reference_date: date | None = None, user_id: str = "", role: str = "") -> list[dict]: """Get per-manager reporting progress. Managers only see themselves.""" monday, sunday = get_week_range(reference_date) # Get all users who need to report (any role, filtered by require_report) managers_result = await db.execute(select(User).where(User.require_report == True)) all_reporters = managers_result.scalars().all() # Filter: managers only see themselves reporters = all_reporters if role in ("director", "leader") else [m for m in all_reporters if str(m.id) == user_id] # Get visit counts per manager this week visits_result = await db.execute( select(Visit.manager_id, func.count(Visit.id)) .where(Visit.visit_date >= monday, Visit.visit_date <= sunday) .group_by(Visit.manager_id) ) visit_map = {str(uid): cnt for uid, cnt in visits_result.all()} # Load holiday data (auto-refreshes from API if not cached) holidays, workdays = await load_holiday_sets(db) # Query leave records for today — build a set of managers on leave today = today_cst() is_rest = not is_working_day(today, holidays, workdays) leaves_result = await db.execute( select(Leave).where(and_( Leave.start_date <= today, Leave.end_date >= today, )) ) on_leave_today = {str(r.manager_id): r for r in leaves_result.scalars().all()} progress = [] for m in reporters: count = visit_map.get(str(m.id), 0) # Calculate expected working days (Mon-Fri) days_passed = min((date.today() - monday).days + 1, 5) expected = days_passed # At least 1 visit per working day mid = str(m.id) leave = on_leave_today.get(mid) progress.append({ "manager_id": mid, "manager_name": m.name, "department": m.department, "visit_count": count, "expected": expected, "completed": count >= expected, "has_reported_today": False, # Will be set below "on_leave": leave is not None, "is_rest_day": is_rest, "leave_info": { "leave_type": leave.leave_type, "start_date": str(leave.start_date), "end_date": str(leave.end_date), } if leave else None, }) # Check today's reporting — visits (direct + companions) OR daily notes from sqlalchemy import union_all today_direct = select(Visit.manager_id).where(Visit.visit_date == today) today_companion = select(func.unnest(Visit.companions).label("manager_id")).where( Visit.visit_date == today, Visit.companions.isnot(None) ) today_combined = union_all(today_direct, today_companion).subquery() today_visits = await db.execute(select(today_combined.c.manager_id)) today_notes = await db.execute( select(DailyNote.manager_id).where(DailyNote.note_date == today) ) reported_today = {str(uid) for uid, in today_visits.all()} | {str(uid) for uid, in today_notes.all()} for p in progress: p["has_reported_today"] = p["manager_id"] in reported_today return progress async def get_weekly_report( db: AsyncSession, user_id: UUID, role: str, filter_manager_id: UUID | None = None, filter_customer_id: UUID | None = None, reference_date: date | None = None, ) -> dict: """Get full weekly report data organized by module.""" monday, sunday = get_week_range(reference_date) # Base filters respecting role visibility customer_map = {} user_map = {} customers_result = await db.execute(select(Customer.id, Customer.name)) customer_map = {c.id: c.name for c in customers_result.all()} users_result = await db.execute(select(User.id, User.name)) user_map = {u.id: u.name for u in users_result.all()} def build_manager_filter(existing_filter=None): """If role is manager, only see own data. Otherwise optionally filter by manager_id.""" if role == "manager": return str(user_id) return str(filter_manager_id) if filter_manager_id else None # ── Visits ── visit_query = select(Visit).where(Visit.visit_date >= monday, Visit.visit_date <= sunday) if role == "manager": visit_query = visit_query.where( (Visit.manager_id == user_id) | (Visit.companions.any(user_id)) ) elif filter_manager_id: visit_query = visit_query.where( (Visit.manager_id == filter_manager_id) | (Visit.companions.any(filter_manager_id)) ) if filter_customer_id: visit_query = visit_query.where(Visit.customer_id == filter_customer_id) visit_query = visit_query.order_by(Visit.visit_date.desc()) visits_result = await db.execute(visit_query) visits = visits_result.scalars().all() # Group visits by visit_group_id (or id for solo visits) groups: dict[str, list] = {} for v in visits: gid = str(v.visit_group_id) if v.visit_group_id else str(v.id) groups.setdefault(gid, []).append(v) visits_data = [] for gid, gvisits in groups.items(): # Determine the "primary" — the creator's record (not a companion copy) def _is_companion_copy(v) -> bool: content = (v.communication_content or "").strip() # New-style: has "(协同XXX)" suffix if content.endswith(")") and "(协同" in content: return True # Old-style: empty draft created for companion if not content: return True return False creator_records = [v for v in gvisits if not _is_companion_copy(v)] primary = creator_records[0] if creator_records else gvisits[0] companion_names_resolved = [user_map.get(c, str(c)) for c in (primary.companions or [])] companion_names_resolved.extend(primary.companion_names or []) merged = { "id": str(primary.id), "customer_id": str(primary.customer_id), "customer_name": customer_map.get(primary.customer_id, ""), "visit_date": str(primary.visit_date), "visit_method": primary.visit_method, "time_range": primary.time_range, "visitor_name": primary.visitor_name or "", "visitor_phone": primary.visitor_phone or "", "visit_group_id": gid if len(gvisits) > 1 else None, "companions": [str(c) for c in (primary.companions or [])], "companion_names": primary.companion_names or [], "companion_names_resolved": companion_names_resolved, "communication_content": primary.communication_content, "customer_demand": primary.customer_demand, "photos": primary.photos or [], "manager_id": str(primary.manager_id), "manager_name": user_map.get(primary.manager_id, ""), "edit_log": primary.edit_log or [], "created_at": str(primary.created_at), "participants": [ { "manager_id": str(v.manager_id), "manager_name": user_map.get(v.manager_id, ""), "role": "primary" if v is primary else "companion", "communication_content": v.communication_content, "customer_demand": v.customer_demand, "photos": v.photos or [], } for v in gvisits ], } visits_data.append(merged) # ── Work Plans ── wp_query = select(WorkPlan) if role == "manager": wp_query = wp_query.where(WorkPlan.manager_id == user_id) elif filter_manager_id: wp_query = wp_query.where(WorkPlan.manager_id == filter_manager_id) if filter_customer_id: wp_query = wp_query.where(WorkPlan.customer_id == filter_customer_id) wp_result = await db.execute(wp_query) work_plans_data = [] for w in wp_result.scalars(): work_plans_data.append({ "id": str(w.id), "customer_id": str(w.customer_id), "customer_name": customer_map.get(w.customer_id, ""), "plan_content": w.plan_content, "plan_date": str(w.plan_date), "manager_id": str(w.manager_id), "manager_name": user_map.get(w.manager_id, ""), "status": w.status, }) # ── Mini Business ── mb_query = select(MiniBusiness) if role == "manager": mb_query = mb_query.where(MiniBusiness.manager_id == user_id) elif filter_manager_id: mb_query = mb_query.where(MiniBusiness.manager_id == filter_manager_id) if filter_customer_id: mb_query = mb_query.where(MiniBusiness.customer_id == filter_customer_id) mb_result = await db.execute(mb_query) mini_biz_data = [] for m in mb_result.scalars(): mini_biz_data.append({ "id": str(m.id), "customer_id": str(m.customer_id), "customer_name": customer_map.get(m.customer_id, ""), "product_type": m.product_type, "amount": m.amount, "follow_up_detail": m.follow_up_detail, "status": m.status, "manager_id": str(m.manager_id), "manager_name": user_map.get(m.manager_id, ""), "expected_revenue_date": m.expected_revenue_date, }) # ── Key Visits ── kv_query = select(KeyVisit) if role == "manager": kv_query = kv_query.where(KeyVisit.manager_id == user_id) elif filter_manager_id: kv_query = kv_query.where(KeyVisit.manager_id == filter_manager_id) if filter_customer_id: kv_query = kv_query.where(KeyVisit.customer_id == filter_customer_id) kv_result = await db.execute(kv_query) key_visits_data = [] for k in kv_result.scalars(): key_visits_data.append({ "id": str(k.id), "customer_id": str(k.customer_id), "customer_name": customer_map.get(k.customer_id, ""), "urgency_level": k.urgency_level, "description": k.description, "progress_status": k.progress_status, "planned_date": k.planned_date, "planned_visitor": k.planned_visitor, "visit_target": k.visit_target, "manager_id": str(k.manager_id), "manager_name": user_map.get(k.manager_id, ""), }) # ── Daily Notes ── dn_query = select(DailyNote).where(DailyNote.note_date >= monday, DailyNote.note_date <= sunday) if role == "manager": dn_query = dn_query.where(DailyNote.manager_id == user_id) elif filter_manager_id: dn_query = dn_query.where(DailyNote.manager_id == filter_manager_id) dn_query = dn_query.order_by(DailyNote.note_date.desc()) dn_result = await db.execute(dn_query) daily_notes_data = [] for d in dn_result.scalars(): daily_notes_data.append({ "id": str(d.id), "note_date": str(d.note_date), "category": d.category, "content": d.content, "time_range": d.time_range, "manager_id": str(d.manager_id), "manager_name": user_map.get(d.manager_id, ""), }) return { "week_start": str(monday), "week_end": str(sunday), "visits": visits_data, "daily_notes": daily_notes_data, "work_plans": work_plans_data, "mini_business": mini_biz_data, "key_visits": key_visits_data, }