Merge branch 'main' into develop
# 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
This commit is contained in:
@@ -132,10 +132,14 @@ async def get_reporting_progress(db: AsyncSession, reference_date: date | None =
|
||||
} if leave else None,
|
||||
})
|
||||
|
||||
# Check today's reporting — visits OR daily notes
|
||||
today_visits = await db.execute(
|
||||
select(Visit.manager_id).where(Visit.visit_date == today)
|
||||
# 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)
|
||||
)
|
||||
@@ -173,9 +177,13 @@ async def get_weekly_report(
|
||||
# ── 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_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_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())
|
||||
|
||||
@@ -203,6 +203,13 @@ async def import_from_excel(db: AsyncSession, file_bytes: bytes, manager_id: uui
|
||||
db.add(visit)
|
||||
stats["visits"] += 1
|
||||
|
||||
# Auto-complete matching work plans
|
||||
from app.services.visits import auto_complete_work_plans
|
||||
await auto_complete_work_plans(
|
||||
db, customer_id, visit_date,
|
||||
mgr_name, "旧周报导入自动完成",
|
||||
)
|
||||
|
||||
# Update customer's last_visit_date for light board
|
||||
cust = await db.get(Customer, customer_id)
|
||||
if cust and (not cust.last_visit_date or visit_date > cust.last_visit_date):
|
||||
|
||||
@@ -9,7 +9,7 @@ Status (rolling 30-day window):
|
||||
|
||||
from datetime import date, timedelta
|
||||
from uuid import UUID
|
||||
from sqlalchemy import select, and_
|
||||
from sqlalchemy import select, func, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.models.user import User
|
||||
from app.models.customer import Customer
|
||||
@@ -85,29 +85,34 @@ async def get_light_board(
|
||||
for m in visible_managers
|
||||
}
|
||||
|
||||
# ── All customers with their last visit per manager ──
|
||||
# Subquery: latest visit (date + method) per (manager_id, customer_id).
|
||||
# DISTINCT ON keeps exactly one row per pair even when multiple visits
|
||||
# share the same latest date (e.g. companion/collaborative visits),
|
||||
# preventing fan-out that would duplicate customer cards on the board.
|
||||
# ── All customers with their last visit per manager (including companions) ──
|
||||
# UNION direct + companions (unnested), then DISTINCT ON to get the latest per (manager, customer)
|
||||
from sqlalchemy import union_all, distinct
|
||||
direct = select(
|
||||
Visit.manager_id, Visit.customer_id, Visit.visit_date, Visit.visit_method,
|
||||
)
|
||||
companion = select(
|
||||
func.unnest(Visit.companions).label("manager_id"),
|
||||
Visit.customer_id,
|
||||
Visit.visit_date,
|
||||
Visit.visit_method,
|
||||
).where(Visit.companions.isnot(None))
|
||||
combined = union_all(direct, companion).subquery()
|
||||
|
||||
# Get latest visit (date + method) per (manager_id, customer_id) using DISTINCT ON
|
||||
latest_visit = (
|
||||
select(
|
||||
Visit.manager_id,
|
||||
Visit.customer_id,
|
||||
Visit.visit_date.label("last_visit_date"),
|
||||
Visit.visit_method.label("last_visit_method"),
|
||||
)
|
||||
.distinct(Visit.manager_id, Visit.customer_id)
|
||||
.order_by(
|
||||
Visit.manager_id,
|
||||
Visit.customer_id,
|
||||
Visit.visit_date.desc(),
|
||||
Visit.id.desc(),
|
||||
combined.c.manager_id,
|
||||
combined.c.customer_id,
|
||||
combined.c.visit_date.label("last_visit_date"),
|
||||
combined.c.visit_method,
|
||||
)
|
||||
.distinct(combined.c.manager_id, combined.c.customer_id)
|
||||
.order_by(combined.c.manager_id, combined.c.customer_id, combined.c.visit_date.desc())
|
||||
.subquery()
|
||||
)
|
||||
|
||||
# Join: assignments → customers → latest_visit (date + method)
|
||||
# Join: assignments → customers → latest_visit
|
||||
rows = await db.execute(
|
||||
select(
|
||||
CustomerAssignment.manager_id,
|
||||
@@ -118,7 +123,7 @@ async def get_light_board(
|
||||
Customer.in_use_services,
|
||||
Customer.monthly_fee,
|
||||
latest_visit.c.last_visit_date,
|
||||
latest_visit.c.last_visit_method,
|
||||
latest_visit.c.visit_method,
|
||||
)
|
||||
.join(Customer, Customer.id == CustomerAssignment.customer_id)
|
||||
.outerjoin(latest_visit, and_(
|
||||
|
||||
@@ -168,9 +168,33 @@ async def check_overdue_plans(db: AsyncSession) -> dict:
|
||||
|
||||
await wecom_client.send_text_message([user.wecom_userid], content)
|
||||
|
||||
# ── Auto-cancel overdue plans that have no matching visit ──
|
||||
from app.utils.edit_log import append_entry as append_edit_log
|
||||
auto_cancelled = 0
|
||||
for plan in overdue:
|
||||
has_visit = await db.execute(
|
||||
select(Visit).where(
|
||||
Visit.customer_id == plan.customer_id,
|
||||
Visit.visit_date >= plan.plan_date,
|
||||
)
|
||||
)
|
||||
if not has_visit.scalar():
|
||||
plan.status = "已取消"
|
||||
append_edit_log(plan, "系统", [{
|
||||
"field": "status",
|
||||
"from": "计划中",
|
||||
"to": "已取消",
|
||||
"reason": "逾期自动取消",
|
||||
}])
|
||||
auto_cancelled += 1
|
||||
|
||||
if auto_cancelled:
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"date": str(today),
|
||||
"overdue": len(overdue),
|
||||
"auto_cancelled": auto_cancelled,
|
||||
"managers_affected": len(by_manager),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Visit service — shared logic for visit creation, update, and import."""
|
||||
|
||||
from uuid import UUID
|
||||
from datetime import date
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.models.work_plan import WorkPlan
|
||||
from app.utils.edit_log import append_entry
|
||||
|
||||
|
||||
async def auto_complete_work_plans(
|
||||
db: AsyncSession,
|
||||
customer_id: UUID,
|
||||
visit_date: date,
|
||||
editor_name: str,
|
||||
reason: str = "拜访自动完成",
|
||||
) -> int:
|
||||
"""Auto-complete matching work plans when a visit is created/updated/imported.
|
||||
|
||||
Matches by: same customer_id + status=="计划中" + plan_date <= visit_date.
|
||||
Returns the number of plans completed.
|
||||
"""
|
||||
plans_result = await db.execute(
|
||||
select(WorkPlan).where(
|
||||
WorkPlan.customer_id == customer_id,
|
||||
WorkPlan.status == "计划中",
|
||||
WorkPlan.plan_date <= visit_date,
|
||||
)
|
||||
)
|
||||
count = 0
|
||||
for plan in plans_result.scalars().all():
|
||||
plan.status = "已完成"
|
||||
append_entry(plan, editor_name, [{
|
||||
"field": "status",
|
||||
"from": "计划中",
|
||||
"to": "已完成",
|
||||
"reason": reason,
|
||||
}])
|
||||
count += 1
|
||||
return count
|
||||
Reference in New Issue
Block a user