ab20bc5a1f
# Conflicts: # backend/app/api/key_visits.py # backend/app/api/mini_business.py # backend/app/api/visits.py # backend/app/api/work_plans.py # backend/app/main.py # backend/app/models/__init__.py # backend/app/schemas/key_visit.py # backend/app/schemas/mini_business.py # backend/app/schemas/work_plan.py # backend/app/services/light_board.py # frontend/src/components/DesktopLayout.vue # frontend/src/stores/theme.ts # frontend/src/views/desktop/ManagerWorkspace.vue # frontend/src/views/desktop/WorkPlans.vue # frontend/src/views/mobile/KeyVisitForm.vue # frontend/src/views/mobile/LeaveForm.vue # frontend/src/views/mobile/PlansList.vue # frontend/src/views/mobile/VisitForm.vue # frontend/src/views/mobile/WorkPlanForm.vue
350 lines
14 KiB
Python
350 lines
14 KiB
Python
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,
|
||
}
|