chore: 保存进行中的未提交改动(企微定向推送+搜索筛选UI等)

保存合并前工作树中遗留的进行中改动,避免分支合并时丢失:
- scheduler: 填报汇总改为定向推送给支局长/领导(而非广播@all)
- router: JWT token 校验修复,bind token(UUID)不被误剥离
- 工作计划/计划列表: 搜索+筛选+分页 UI
- 各列表 API 增加 search 参数支持
- docker-compose.backend.yml + backend/.dockerignore 纳入版本管理

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-17 09:17:03 +08:00
parent c9df6066f5
commit 1ec20ee53e
25 changed files with 494 additions and 261 deletions
+25
View File
@@ -0,0 +1,25 @@
__pycache__
*.pyc
*.pyo
*.egg-info
.eggs
dist
build
.venv
venv
env
.pytest_cache
tests
htmlcov
.coverage
node_modules
.vite
.git
.gitignore
.vscode
.idea
.env
.env.*
.ruff_cache
.DS_Store
Thumbs.db
+3
View File
@@ -1,5 +1,8 @@
FROM python:3.12-slim
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
WORKDIR /app
COPY requirements.txt .
+1 -1
View File
@@ -19,7 +19,7 @@ async def dashboard_stats(
):
"""Get dashboard card statistics. Pass reference_date (YYYY-MM-DD) for historical weeks."""
ref = date.fromisoformat(reference_date) if reference_date else None
stats = await get_dashboard_stats(db, ref)
stats = await get_dashboard_stats(db, ref, current_user["user_id"], current_user["role"])
return stats
+2
View File
@@ -103,6 +103,8 @@ async def update_key_visit(
old_snapshot = {"customer_id": str(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}
update_data = data.model_dump(exclude_unset=True)
if "manager_id" in update_data and update_data["manager_id"]:
update_data["manager_id"] = uuid.UUID(update_data["manager_id"])
for key, v in update_data.items():
setattr(k, key, v)
new_snapshot = {"customer_id": str(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}
+2
View File
@@ -101,6 +101,8 @@ async def update_mini_business(
old_snapshot = {"customer_id": str(m.customer_id), "product_type": m.product_type, "amount": m.amount, "follow_up_detail": m.follow_up_detail, "status": m.status, "expected_revenue_date": m.expected_revenue_date}
update_data = data.model_dump(exclude_unset=True)
if "manager_id" in update_data and update_data["manager_id"]:
update_data["manager_id"] = uuid.UUID(update_data["manager_id"])
for k, v in update_data.items():
setattr(m, k, v)
new_snapshot = {"customer_id": str(m.customer_id), "product_type": m.product_type, "amount": m.amount, "follow_up_detail": m.follow_up_detail, "status": m.status, "expected_revenue_date": m.expected_revenue_date}
+5 -1
View File
@@ -247,11 +247,14 @@ async def update_visit(
"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 "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)
@@ -262,6 +265,7 @@ async def update_visit(
"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:
@@ -282,7 +286,7 @@ async def update_visit(
# 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([c["field"] for c in changes]) if changes else "")
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)
return await _enrich_visit(visit, db)
+3 -2
View File
@@ -332,8 +332,9 @@ async def oauth_callback(
)
user = result.scalar_one_or_none()
if not user:
# Not bound — redirect to bind page
bind_url = f"https://qj.dhdx.fun/wecom-bind?wecom_userid={wecom_userid}"
# Not bound — generate a one-time bind token and redirect to bind page
bind_token = await store_bind_token(db, wecom_userid)
bind_url = f"https://qj.dhdx.fun/wecom-bind?token={bind_token}&wecom_userid={wecom_userid}"
from fastapi.responses import RedirectResponse
return RedirectResponse(url=bind_url)
+2
View File
@@ -117,6 +117,8 @@ async def update_work_plan(
update_data = data.model_dump(exclude_unset=True)
if "plan_date" in update_data and update_data["plan_date"]:
update_data["plan_date"] = parse_date(update_data["plan_date"])
if "manager_id" in update_data and update_data["manager_id"]:
update_data["manager_id"] = uuid.UUID(update_data["manager_id"])
for k, v in update_data.items():
setattr(wp, k, v)
new_snapshot = {"customer_id": str(wp.customer_id), "plan_content": wp.plan_content, "plan_date": str(wp.plan_date), "status": wp.status}
+1
View File
@@ -21,6 +21,7 @@ class KeyVisitUpdate(BaseModel):
planned_date: Optional[str] = None
planned_visitor: Optional[str] = None
visit_target: Optional[str] = None
manager_id: Optional[str] = None
class KeyVisitOut(BaseModel):
+1
View File
@@ -19,6 +19,7 @@ class MiniBusinessUpdate(BaseModel):
follow_up_detail: Optional[str] = None
status: Optional[str] = None
expected_revenue_date: Optional[str] = None
manager_id: Optional[str] = None
class MiniBusinessOut(BaseModel):
+1
View File
@@ -30,6 +30,7 @@ class VisitUpdate(BaseModel):
companions: Optional[list[uuid.UUID]] = None
companion_names: Optional[list[str]] = None
photos: Optional[list[str]] = None
manager_id: Optional[str] = None
class VisitOut(BaseModel):
+1
View File
@@ -16,6 +16,7 @@ class WorkPlanUpdate(BaseModel):
plan_content: Optional[str] = None
plan_date: Optional[str] = None
status: Optional[str] = None
manager_id: Optional[str] = None
class WorkPlanOut(BaseModel):
+24 -4
View File
@@ -12,7 +12,7 @@ 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
from app.utils.timezone import today_cst, is_working_day, parse_date
def get_week_range(reference_date: date | None = None):
@@ -22,7 +22,7 @@ def get_week_range(reference_date: date | None = None):
return monday, sunday
async def get_dashboard_stats(db: AsyncSession, reference_date: date | None = None) -> dict:
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()
@@ -51,12 +51,22 @@ async def get_dashboard_stats(db: AsyncSession, reference_date: date | None = No
)
)).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),
}
@@ -180,8 +190,18 @@ async def get_weekly_report(
visits_data = []
for gid, gvisits in groups.items():
# Determine the "primary" — first record in group (the creator's)
primary = gvisits[0]
# 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 [])
+24 -24
View File
@@ -80,33 +80,33 @@ async def check_daily_reporting(db: AsyncSession) -> dict:
f"📋 今日填报提醒\n\n{desc}\n未填报:{names_text}\n\n请尽快完成填报 🙏\nhttps://qj.dhdx.fun/m",
)
# 2. Send summary to director
# 2. Send summary to directors/leaders only (not broadcast to @all)
directors = await db.execute(
select(User).where(User.role.in_(["director", "leader"]), User.wecom_userid.isnot(None))
)
for d in directors.scalars().all():
if managers:
effective_total = len(managers) - len(on_leave_names)
if effective_total > 0:
pct = len(reported_map) / effective_total * 100
else:
pct = 100.0
leave_note = ""
if on_leave_names:
leave_note = f"\n> 请假中(已豁免):{len(on_leave_names)}\n"
leave_note += "".join(f"- {n} (请假)\n" for n in on_leave_names)
summary = (
f"## 📊 今日填报汇总\n\n"
f"> 日期:{today}\n"
f"> 填报率:**{pct:.0f}%** ({len(reported_map)}/{effective_total})\n"
)
if leave_note:
summary += leave_note + "\n"
if not_reported:
summary += "**未填报:**\n" + "".join(f"- {m.name}\n" for m in not_reported)
else:
summary += "✅ 全体已完成今日填报"
await wecom_client.send_markdown_message(summary) # broadcast for director visibility
director_ids = [d.wecom_userid for d in directors.scalars().all()]
if managers and director_ids:
effective_total = len(managers) - len(on_leave_names)
if effective_total > 0:
pct = len(reported_map) / effective_total * 100
else:
pct = 100.0
leave_note = ""
if on_leave_names:
leave_note = f"\n> 请假中(已豁免):{len(on_leave_names)}\n"
leave_note += "".join(f"- {n} (请假)\n" for n in on_leave_names)
summary = (
f"## 📊 今日填报汇总\n\n"
f"> 日期:{today}\n"
f"> 填报率:**{pct:.0f}%** ({len(reported_map)}/{effective_total})\n"
)
if leave_note:
summary += leave_note + "\n"
if not_reported:
summary += "**未填报:**\n" + "".join(f"- {m.name}\n" for m in not_reported)
else:
summary += "✅ 全体已完成今日填报"
await wecom_client.send_markdown_message(summary, user_ids=director_ids)
return {
"status": "ok",
+4 -4
View File
@@ -9,7 +9,7 @@ def get_scheduler() -> AsyncIOScheduler:
"""Lazy-init and return the global scheduler."""
global _scheduler
if _scheduler is None:
_scheduler = AsyncIOScheduler()
_scheduler = AsyncIOScheduler(timezone="Asia/Shanghai")
return _scheduler
@@ -28,7 +28,7 @@ async def reschedule_daily_check(hour: int, minute: int):
async with async_session() as db:
await check_daily_reporting(db)
sched.add_job(_wrapper, "cron", hour=hour, minute=minute, id="daily_check")
sched.add_job(_wrapper, "cron", hour=hour, minute=minute, id="daily_check", timezone="Asia/Shanghai")
def start_scheduler(notification_hour: int = 17, notification_minute: int = 30):
@@ -46,8 +46,8 @@ def start_scheduler(notification_hour: int = 17, notification_minute: int = 30):
async with async_session() as db:
await check_overdue_plans(db)
sched.add_job(_daily, "cron", hour=notification_hour, minute=notification_minute, id="daily_check")
sched.add_job(_overdue, "cron", hour=9, minute=0, id="overdue_check")
sched.add_job(_daily, "cron", hour=notification_hour, minute=notification_minute, id="daily_check", timezone="Asia/Shanghai")
sched.add_job(_overdue, "cron", hour=9, minute=0, id="overdue_check", timezone="Asia/Shanghai")
sched.start()
+4 -3
View File
@@ -139,13 +139,14 @@ class WecomClient:
except Exception:
return False
async def send_markdown_message(self, content: str) -> bool:
"""Send a markdown message to all users in the app (broadcast)."""
async def send_markdown_message(self, content: str, user_ids: list[str] | None = None) -> bool:
"""Send a markdown message. If user_ids is provided, send to those users; otherwise broadcast to @all."""
if not settings.WECOM_AGENT_ID:
return False
try:
touser = "|".join(user_ids) if user_ids else "@all"
body = {
"touser": "@all",
"touser": touser,
"msgtype": "markdown",
"agentid": int(settings.WECOM_AGENT_ID),
"markdown": {"content": content},