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:
@@ -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
|
||||
@@ -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 .
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 [])
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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},
|
||||
|
||||
Reference in New Issue
Block a user