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
+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},