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:
2026-08-17 09:31:12 +08:00
46 changed files with 2682 additions and 213 deletions
+13 -5
View File
@@ -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())
+7
View File
@@ -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):
+24 -19
View File
@@ -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_(
+24
View File
@@ -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),
}
+40
View File
@@ -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