From e17536b3e41380c060cb4e56a1aed6675d628181 Mon Sep 17 00:00:00 2001 From: v6ole Date: Sun, 12 Jul 2026 12:52:44 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E8=AF=B7=E5=81=87=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E8=AE=A1=E5=88=92=20(11=20tasks)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude --- .../plans/2026-07-12-leave-management-plan.md | 1904 +++++++++++++++++ 1 file changed, 1904 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-12-leave-management-plan.md diff --git a/docs/superpowers/plans/2026-07-12-leave-management-plan.md b/docs/superpowers/plans/2026-07-12-leave-management-plan.md new file mode 100644 index 0000000..af65d1c --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-leave-management-plan.md @@ -0,0 +1,1904 @@ +# 客户经理请假功能 — 实现计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 为政企周报管理系统添加完整的请假功能(PC + 移动端),包含数据模型、CRUD API、填报进度豁免、催办过滤、AI摘要注入、仪表盘统计卡。 + +**Architecture:** 新增 `leaves` 表 + 完整的后端模型/服务/API 层;修改 dashboard/scheduler/ai_summary 三个服务模块注入请假数据;PC 端新增 Leaves.vue 管理页 + 修改 Dashboard/侧边栏/路由;移动端新增 LeavesList.vue + LeaveForm.vue + 修改 Home 快捷入口。 + +**Tech Stack:** FastAPI + SQLAlchemy 2.0 async + PostgreSQL + Vue 3 + TypeScript + Element Plus + +**Spec:** `docs/superpowers/specs/2026-07-12-leave-management-design.md` + +## Global Constraints + +- 按天请假粒度(start_date ~ end_date) +- 无需审批,提交即生效 +- 请假类型:年假 / 事假 / 病假 / 调休 / 其他 +- 权限:director/leader 看全员,manager 看自己 +- 无 edit_log 列 +- 所有日期判断使用 `today_cst()` (Asia/Shanghai) +- 代码风格匹配现有模式(Mapped 类型注解、el-table + el-dialog、editorial form header) + +--- + +### Task 1: 后端 — Leave 数据模型 + +**Files:** +- Create: `backend/app/models/leave.py` + +**Interfaces:** +- Produces: `Leave` SQLAlchemy model class with columns: `id` (UUID PK), `manager_id` (FK→users.id), `leave_type` (str), `start_date` (date), `end_date` (date), `reason` (str), `submitted_by` (FK→users.id), `created_at` (datetime), `updated_at` (datetime) + +- [ ] **Step 1: 创建 Leave 模型文件** + +```python +# backend/app/models/leave.py +import uuid +from datetime import date, datetime +from sqlalchemy import String, Date, DateTime, ForeignKey, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column +from app.database import Base + + +class Leave(Base): + __tablename__ = "leaves" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + manager_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id"), index=True + ) + leave_type: Mapped[str] = mapped_column(String(20), default="事假") + start_date: Mapped[date] = mapped_column(Date) + end_date: Mapped[date] = mapped_column(Date) + reason: Mapped[str] = mapped_column(String(500), default="") + submitted_by: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id") + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) +``` + +- [ ] **Step 2: 在 models/__init__.py 中注册模型** + +```python +# backend/app/models/__init__.py — 新增一行 import +from app.models.leave import Leave + +# 并在 __all__ 列表中添加 "Leave" +``` + +- [ ] **Step 3: 在 main.py lifespan 中添加 ALTER TABLE 迁移** + +```python +# backend/app/main.py — 在 lifespan startup 中现有 ALTER TABLE 块末尾新增: +await conn.run_sync(lambda c: c.exec_driver_sql( + "CREATE TABLE IF NOT EXISTS leaves (" + " id UUID PRIMARY KEY DEFAULT gen_random_uuid()," + " manager_id UUID NOT NULL REFERENCES users(id)," + " leave_type VARCHAR(20) NOT NULL DEFAULT '事假'," + " start_date DATE NOT NULL," + " end_date DATE NOT NULL," + " reason VARCHAR(500) DEFAULT ''," + " submitted_by UUID NOT NULL REFERENCES users(id)," + " created_at TIMESTAMPTZ DEFAULT now()," + " updated_at TIMESTAMPTZ DEFAULT now()" + ")" +)) +await conn.run_sync(lambda c: c.exec_driver_sql( + "CREATE INDEX IF NOT EXISTS idx_leaves_manager_id ON leaves(manager_id)" +)) +await conn.run_sync(lambda c: c.exec_driver_sql( + "CREATE INDEX IF NOT EXISTS idx_leaves_dates ON leaves(start_date, end_date)" +)) +``` + +- [ ] **Step 4: 验证 — 重启后端确认无 ImportError** + +Run: +```bash +cd /home/v6ole/pyproject/qiji/backend && python -c "from app.models.leave import Leave; print('OK:', Leave.__tablename__)" +``` +Expected: `OK: leaves` + +- [ ] **Step 5: 重启后端并检查 health** + +Run: +```bash +curl -s http://localhost:8002/health +``` +Expected: `{"status":"ok","app":"政企周报管理系统"}` + +- [ ] **Step 6: 验证表已创建** + +Run: +```bash +PGPASSWORD=xxx psql -h 10.10.10.14 -U postgres -d qiji -c "\d leaves" +``` +Expected: 显示 leaves 表结构 + +- [ ] **Step 7: Commit** + +```bash +git add backend/app/models/leave.py backend/app/models/__init__.py backend/app/main.py +git commit -m "feat: add Leave model with auto-migration + +Co-Authored-By: Claude " +``` + +--- + +### Task 2: 后端 — Leave Schema + +**Files:** +- Create: `backend/app/schemas/leave.py` + +**Interfaces:** +- Produces: `LeaveCreate` (Pydantic), `LeaveUpdate` (Pydantic), `LeaveOut` (Pydantic, includes computed `days`), `LeaveOverview` (Pydantic) + +- [ ] **Step 1: 创建 Schema 文件** + +```python +# backend/app/schemas/leave.py +from datetime import date, datetime +from uuid import UUID +from pydantic import BaseModel, Field + + +LEAVE_TYPES = ["年假", "事假", "病假", "调休", "其他"] + + +class LeaveCreate(BaseModel): + manager_id: UUID + leave_type: str = Field(default="事假", pattern="^(年假|事假|病假|调休|其他)$") + start_date: date + end_date: date + reason: str = "" + + +class LeaveUpdate(BaseModel): + manager_id: UUID | None = None + leave_type: str | None = Field(default=None, pattern="^(年假|事假|病假|调休|其他)$") + start_date: date | None = None + end_date: date | None = None + reason: str | None = None + + +class LeaveOut(BaseModel): + id: UUID + manager_id: UUID + manager_name: str = "" + leave_type: str + start_date: date + end_date: date + days: int = 0 + reason: str = "" + submitted_by: UUID + submitted_by_name: str = "" + created_at: datetime | None = None + updated_at: datetime | None = None + + model_config = {"from_attributes": True} + + +class LeaveOverviewItem(BaseModel): + manager_id: UUID + manager_name: str + leave_type: str + start_date: date + end_date: date + days: int + + +class LeaveOverview(BaseModel): + week_start: str + week_end: str + total_on_leave: int + leave_list: list[LeaveOverviewItem] +``` + +- [ ] **Step 2: 验证 schema 可导入** + +Run: +```bash +cd /home/v6ole/pyproject/qiji/backend && python -c "from app.schemas.leave import LeaveCreate, LeaveOut, LeaveOverview; print('OK')" +``` +Expected: `OK` + +- [ ] **Step 3: Commit** + +```bash +git add backend/app/schemas/leave.py +git commit -m "feat: add Leave Pydantic schemas + +Co-Authored-By: Claude " +``` + +--- + +### Task 3: 后端 — Leave Service + +**Files:** +- Create: `backend/app/services/leaves.py` + +**Interfaces:** +- Produces: `get_leaves(db, user_id, role, manager_id, status, page, page_size) → dict`, `get_leave_by_id(db, leave_id) → Leave | None`, `create_leave(db, data, submitted_by, role) → Leave`, `update_leave(db, leave_id, data, user_id, role) → Leave`, `delete_leave(db, leave_id, user_id, role) → bool`, `get_leave_overview(db, user_id, role, reference_date) → dict` +- Consumes: `Leave` (from Task 1), `LeaveCreate/LeaveUpdate/LeaveOut/LeaveOverviewItem/LeaveOverview` (from Task 2), `User`, `today_cst`, `get_week_range` + +- [ ] **Step 1: 创建 Service 文件** + +```python +# backend/app/services/leaves.py +from datetime import date +from uuid import UUID +from sqlalchemy import select, func, and_ +from sqlalchemy.ext.asyncio import AsyncSession +from app.models.leave import Leave +from app.models.user import User +from app.utils.timezone import today_cst +from app.services.dashboard import get_week_range + + +async def get_leaves( + db: AsyncSession, + user_id: str, + role: str, + manager_id: str | None = None, + status: str | None = None, + page: int = 1, + page_size: int = 25, +) -> dict: + """List leaves. Managers see only their own.""" + today = today_cst() + + query = select(Leave) + count_q = select(func.count(Leave.id)) + + # Role filter + if role == "manager": + query = query.where(Leave.manager_id == user_id) + count_q = count_q.where(Leave.manager_id == user_id) + elif manager_id: + query = query.where(Leave.manager_id == manager_id) + count_q = count_q.where(Leave.manager_id == manager_id) + + # Status filter + if status == "active": + query = query.where(and_(Leave.start_date <= today, Leave.end_date >= today)) + count_q = count_q.where(and_(Leave.start_date <= today, Leave.end_date >= today)) + elif status == "upcoming": + query = query.where(Leave.start_date > today) + count_q = count_q.where(Leave.start_date > today) + elif status == "past": + query = query.where(Leave.end_date < today) + count_q = count_q.where(Leave.end_date < today) + + total = (await db.execute(count_q)).scalar() or 0 + + query = query.order_by(Leave.start_date.desc()) + query = query.offset((page - 1) * page_size).limit(page_size) + result = await db.execute(query) + rows = result.scalars().all() + + # Resolve names + user_ids = set() + for r in rows: + user_ids.add(r.manager_id) + user_ids.add(r.submitted_by) + users_result = await db.execute(select(User).where(User.id.in_(user_ids))) + user_map = {u.id: u.name for u in users_result.scalars().all()} + + items = [] + for r in rows: + days = ((r.end_date - r.start_date).days + 1) if r.start_date and r.end_date else 0 + items.append({ + "id": r.id, + "manager_id": r.manager_id, + "manager_name": user_map.get(r.manager_id, ""), + "leave_type": r.leave_type, + "start_date": str(r.start_date), + "end_date": str(r.end_date), + "days": days, + "reason": r.reason or "", + "submitted_by": r.submitted_by, + "submitted_by_name": user_map.get(r.submitted_by, ""), + "created_at": str(r.created_at) if r.created_at else None, + "updated_at": str(r.updated_at) if r.updated_at else None, + }) + + return {"items": items, "total": total, "page": page, "page_size": page_size} + + +async def get_leave_by_id(db: AsyncSession, leave_id: UUID) -> Leave | None: + result = await db.execute(select(Leave).where(Leave.id == leave_id)) + return result.scalar() + + +async def create_leave( + db: AsyncSession, + data: dict, + submitted_by: UUID, + role: str, +) -> Leave: + """Create leave. Manager can only create for self; director can create for anyone.""" + if role == "manager": + if str(data.get("manager_id")) != str(submitted_by): + raise PermissionError("客户经理只能为自己提交请假") + + if data["start_date"] > data["end_date"]: + raise ValueError("结束日期不能早于开始日期") + + leave = Leave( + manager_id=data["manager_id"], + leave_type=data.get("leave_type", "事假"), + start_date=data["start_date"], + end_date=data["end_date"], + reason=data.get("reason", ""), + submitted_by=submitted_by, + ) + db.add(leave) + await db.commit() + await db.refresh(leave) + return leave + + +async def update_leave( + db: AsyncSession, + leave_id: UUID, + data: dict, + user_id: UUID, + role: str, +) -> Leave: + """Update leave. Director can edit any; manager can edit own submitted leaves.""" + leave = await get_leave_by_id(db, leave_id) + if not leave: + raise ValueError("请假记录不存在") + + if role == "manager": + if str(leave.submitted_by) != str(user_id): + raise PermissionError("客户经理只能编辑自己提交的请假") + + for field in ("manager_id", "leave_type", "start_date", "end_date", "reason"): + if field in data and data[field] is not None: + setattr(leave, field, data[field]) + + if leave.start_date > leave.end_date: + raise ValueError("结束日期不能早于开始日期") + + await db.commit() + await db.refresh(leave) + return leave + + +async def delete_leave( + db: AsyncSession, + leave_id: UUID, + user_id: UUID, + role: str, +) -> bool: + """Delete leave. Director can delete any; manager can delete own submitted leaves.""" + leave = await get_leave_by_id(db, leave_id) + if not leave: + return False + + if role == "manager": + if str(leave.submitted_by) != str(user_id): + raise PermissionError("客户经理只能删除自己提交的请假") + + await db.delete(leave) + await db.commit() + return True + + +async def get_leave_overview( + db: AsyncSession, + user_id: str, + role: str, + reference_date: date | None = None, +) -> dict: + """Get leave overview for the dashboard card. Returns leaves overlapping with current week.""" + monday, sunday = get_week_range(reference_date) + + query = select(Leave).where( + and_(Leave.start_date <= sunday, Leave.end_date >= monday) + ) + if role == "manager": + query = query.where(Leave.manager_id == user_id) + + result = await db.execute(query.order_by(Leave.start_date)) + rows = result.scalars().all() + + # Deduplicate by manager_id (one manager may have multiple leave records) + user_ids = set(r.manager_id for r in rows) + users_result = await db.execute(select(User).where(User.id.in_(user_ids))) + user_map = {u.id: u.name for u in users_result.scalars().all()} + + manager_set = set() + leave_list = [] + for r in rows: + days = ((r.end_date - r.start_date).days + 1) if r.start_date and r.end_date else 0 + leave_list.append({ + "manager_id": str(r.manager_id), + "manager_name": user_map.get(r.manager_id, ""), + "leave_type": r.leave_type, + "start_date": str(r.start_date), + "end_date": str(r.end_date), + "days": days, + }) + manager_set.add(str(r.manager_id)) + + return { + "week_start": str(monday), + "week_end": str(sunday), + "total_on_leave": len(manager_set), + "leave_list": leave_list, + } +``` + +- [ ] **Step 2: 验证 Service 可导入** + +Run: +```bash +cd /home/v6ole/pyproject/qiji/backend && python -c "from app.services.leaves import get_leaves, create_leave, get_leave_overview; print('OK')" +``` +Expected: `OK` + +- [ ] **Step 3: Commit** + +```bash +git add backend/app/services/leaves.py +git commit -m "feat: add Leave service layer + +Co-Authored-By: Claude " +``` + +--- + +### Task 4: 后端 — Leave API + +**Files:** +- Create: `backend/app/api/leaves.py` + +**Interfaces:** +- Produces: 5 REST endpoints — `GET /api/leaves`, `POST /api/leaves`, `PUT /api/leaves/{id}`, `DELETE /api/leaves/{id}`, `GET /api/leaves/overview` +- Consumes: `LeaveCreate/LeaveUpdate/LeaveOut/LeaveOverview` (from Task 2), service functions (from Task 3), `get_current_user` dependency, `RoleChecker` + +- [ ] **Step 1: 创建 API 路由文件** + +Read the existing auth middleware to get exact import paths: +```bash +grep -n "get_current_user\|RoleChecker" /home/v6ole/pyproject/qiji/backend/app/middleware/auth.py | head -5 +``` + +- [ ] **Step 2: 编写 API 路由** + +```python +# backend/app/api/leaves.py +from uuid import UUID +from fastapi import APIRouter, Depends, Query, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from app.database import get_db +from app.middleware.auth import get_current_user, RoleChecker +from app.schemas.leave import LeaveCreate, LeaveUpdate +from app.services import leaves as leave_service + +router = APIRouter(prefix="/api/leaves", tags=["Leaves"]) + + +@router.get("") +async def list_leaves( + manager_id: str | None = Query(None), + status: str | None = Query(None), + page: int = Query(1, ge=1), + page_size: int = Query(25, ge=1, le=100), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """List leave records. Managers see only their own.""" + return await leave_service.get_leaves( + db=db, + user_id=current_user["user_id"], + role=current_user["role"], + manager_id=manager_id, + status=status, + page=page, + page_size=page_size, + ) + + +@router.post("") +async def create_leave( + data: LeaveCreate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Create a leave record. Director can create for anyone; manager only for self.""" + try: + leave = await leave_service.create_leave( + db=db, + data=data.model_dump(), + submitted_by=UUID(current_user["user_id"]), + role=current_user["role"], + ) + return { + "id": str(leave.id), + "manager_id": str(leave.manager_id), + "leave_type": leave.leave_type, + "start_date": str(leave.start_date), + "end_date": str(leave.end_date), + "reason": leave.reason, + "submitted_by": str(leave.submitted_by), + } + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.put("/{leave_id}") +async def update_leave( + leave_id: UUID, + data: LeaveUpdate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Update a leave record.""" + try: + leave = await leave_service.update_leave( + db=db, + leave_id=leave_id, + data=data.model_dump(exclude_none=True), + user_id=UUID(current_user["user_id"]), + role=current_user["role"], + ) + return { + "id": str(leave.id), + "manager_id": str(leave.manager_id), + "leave_type": leave.leave_type, + "start_date": str(leave.start_date), + "end_date": str(leave.end_date), + "reason": leave.reason, + } + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) + except ValueError as e: + raise HTTPException(status_code=404 if "不存在" in str(e) else 400, detail=str(e)) + + +@router.delete("/{leave_id}") +async def delete_leave( + leave_id: UUID, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Delete a leave record.""" + try: + ok = await leave_service.delete_leave( + db=db, + leave_id=leave_id, + user_id=UUID(current_user["user_id"]), + role=current_user["role"], + ) + if not ok: + raise HTTPException(status_code=404, detail="请假记录不存在") + return {"status": "deleted"} + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) + + +@router.get("/overview") +async def leave_overview( + reference_date: str | None = Query(None), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Get weekly leave overview for dashboard card.""" + from datetime import date + ref = date.fromisoformat(reference_date) if reference_date else None + return await leave_service.get_leave_overview( + db=db, + user_id=current_user["user_id"], + role=current_user["role"], + reference_date=ref, + ) +``` + +- [ ] **Step 3: 在 main.py 中注册路由** + +```python +# backend/app/main.py — 在 import 行新增 +from app.api import leaves + +# 在路由注册区域新增(放在 dashboard 之后) +app.include_router(leaves.router, prefix="/api") +``` + +- [ ] **Step 4: 创建前端 API 模块** + +```typescript +// frontend/src/api/leaves.ts +import api from './index' + +export const leavesApi = { + list(params?: Record) { + return api.get('/leaves/', { params }) + }, + create(data: Record) { + return api.post('/leaves/', data) + }, + update(id: string, data: Record) { + return api.put(`/leaves/${id}`, data) + }, + delete(id: string) { + return api.delete(`/leaves/${id}`) + }, + overview(params?: Record) { + return api.get('/leaves/overview', { params }) + }, +} +``` + +- [ ] **Step 5: 验证 API — 创建测试请假** + +Run: +```bash +# 先获取一个 manager 和 director 的 user_id +TOKEN=$(curl -s -X POST http://localhost:8002/api/auth/login -H 'Content-Type: application/json' -d '{"username":"admin","password":"admin"}' | python3 -c "import sys,json; print(json.load(sys.stdin).get('access_token',''))") + +# 创建请假记录 +curl -s -X POST http://localhost:8002/api/leaves/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"manager_id":"","leave_type":"事假","start_date":"2026-07-13","end_date":"2026-07-15","reason":"测试"}' | python3 -m json.tool + +# 查询列表 +curl -s http://localhost:8002/api/leaves/ -H "Authorization: Bearer $TOKEN" | python3 -m json.tool + +# 查询概览 +curl -s http://localhost:8002/api/leaves/overview -H "Authorization: Bearer $TOKEN" | python3 -m json.tool +``` + +- [ ] **Step 6: Commit** + +```bash +git add backend/app/api/leaves.py backend/app/main.py frontend/src/api/leaves.ts +git commit -m "feat: add Leave API endpoints + frontend API module + +Co-Authored-By: Claude " +``` + +--- + +### Task 5: 后端 — 修改填报进度 + 仪表盘统计 + +**Files:** +- Modify: `backend/app/services/dashboard.py` + +**Interfaces:** +- Consumes: `Leave` model (Task 1), `get_leave_overview` pattern +- Produces: Modified `get_reporting_progress()` returning `on_leave` + `leave_info` fields; Modified `get_dashboard_stats()` returning `week_leaves` count + +- [ ] **Step 1: 修改 `get_reporting_progress` — 新增请假查询和 on_leave 状态** + +```python +# backend/app/services/dashboard.py — 新增 import +from app.models.leave import Leave + +# 修改 get_reporting_progress 函数,在 "Check today's reporting" 之前插入请假查询: + +async def get_reporting_progress(db: AsyncSession, reference_date: date | None = None, user_id: str = "", role: str = "") -> list[dict]: + """Get per-manager reporting progress. Managers only see themselves.""" + monday, sunday = get_week_range(reference_date) + + # Get all users who need to report (any role, filtered by require_report) + managers_result = await db.execute(select(User).where(User.require_report == True)) + all_reporters = managers_result.scalars().all() + reporters = all_reporters if role in ("director", "leader") else [m for m in all_reporters if str(m.id) == user_id] + + # Get visit counts per manager this week + visits_result = await db.execute( + select(Visit.manager_id, func.count(Visit.id)) + .where(Visit.visit_date >= monday, Visit.visit_date <= sunday) + .group_by(Visit.manager_id) + ) + visit_map = {str(uid): cnt for uid, cnt in visits_result.all()} + + # ── NEW: Query leave records for today ── + today = today_cst() + leaves_result = await db.execute( + select(Leave).where(and_( + Leave.start_date <= today, + Leave.end_date >= today, + )) + ) + on_leave_today = {str(r.manager_id): r for r in leaves_result.scalars().all()} + + progress = [] + for m in reporters: + count = visit_map.get(str(m.id), 0) + days_passed = min((date.today() - monday).days + 1, 5) + expected = days_passed + + mid = str(m.id) + leave = on_leave_today.get(mid) + + progress.append({ + "manager_id": mid, + "manager_name": m.name, + "department": m.department, + "visit_count": count, + "expected": expected, + "completed": count >= expected, + "has_reported_today": False, + "on_leave": leave is not None, + "leave_info": { + "leave_type": leave.leave_type, + "start_date": str(leave.start_date), + "end_date": str(leave.end_date), + } 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) + ) + today_notes = await db.execute( + select(DailyNote.manager_id).where(DailyNote.note_date == today) + ) + reported_today = {str(uid) for uid, in today_visits.all()} | {str(uid) for uid, in today_notes.all()} + for p in progress: + p["has_reported_today"] = p["manager_id"] in reported_today + + return progress +``` + +- [ ] **Step 2: 修改 `get_dashboard_stats` — 新增 week_leaves 统计** + +```python +# 在 get_dashboard_stats 中,现有 stats 后新增: + + # ── NEW: Week leave count ── + leaves_count = (await db.execute( + select(func.count(func.distinct(Leave.manager_id))).where( + and_(Leave.start_date <= sunday, Leave.end_date >= monday) + ) + )).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, # ← 新增 + "week_start": str(monday), + "week_end": str(sunday), + } +``` + +- [ ] **Step 3: 验证 — 调用 API 确认新字段** + +Run: +```bash +curl -s http://localhost:8002/api/dashboard/progress -H "Authorization: Bearer $TOKEN" | python3 -c "import sys,json; d=json.load(sys.stdin); [print(p.get('manager_name'), 'on_leave:', p.get('on_leave')) for p in d]" +``` + +- [ ] **Step 4: Commit** + +```bash +git add backend/app/services/dashboard.py +git commit -m "feat: add on_leave status to reporting progress + week_leaves stat + +Co-Authored-By: Claude " +``` + +--- + +### Task 6: 后端 — 修改催办提醒 + AI 摘要 + +**Files:** +- Modify: `backend/app/services/scheduler.py` +- Modify: `backend/app/services/ai_summary.py` + +**Interfaces:** +- Consumes: `Leave` model (Task 1) +- Produces: `check_daily_reporting` skips on-leave managers; `build_summary_prompt` includes leave section + +- [ ] **Step 1: 修改 `check_daily_reporting` — 过滤请假人员** + +```python +# backend/app/services/scheduler.py — 新增 import +from app.models.leave import Leave +from sqlalchemy import and_ + +# 在 check_daily_reporting 中,获取 managers 之后、检查填报之前插入: + + # ── NEW: Exclude managers on leave today ── + leaves_today = await db.execute( + select(Leave.manager_id).where(and_( + Leave.start_date <= today, + Leave.end_date >= today, + )) + ) + on_leave_ids = {str(uid) for uid, in leaves_today.all()} + + not_reported = [] + reported_names = [] + on_leave_names = [] + for m in managers: + if str(m.id) in on_leave_ids: + on_leave_names.append(m.name) + continue # skip — exempt from reporting + if str(m.id) in reported_map: + reported_names.append(m.name) + else: + not_reported.append(m) +``` + +修改催办汇总消息,添加请假标注: + +```python + # 在 send summary to director 部分,汇总消息开头新增请假信息 + 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 插入 leave_note + summary = ( + f"## 📊 今日填报汇总\n\n" + f"> 日期:{today}\n" + f"> 填报率:**{pct:.0f}%** ({len(reported_map)}/{len(managers) - len(on_leave_names)})\n" + ) + if leave_note: + summary += leave_note + "\n" +``` + +- [ ] **Step 2: 修改 `build_summary_prompt` — 注入请假数据** + +```python +# backend/app/services/ai_summary.py — 在 build_summary_prompt 函数开头新增参数 +# 修改函数签名以接受 leaves 数据 + +def build_summary_prompt( + weekly_report: dict, + light_board: dict, + period: str, + reference_date: str, + leaves: list | None = None, # ← 新增参数 +) -> str: + # ... existing code ... + + # 在 light board 数据块之后、return 之前新增: + + # ── Leave data ── + if leaves: + data_block += "\n## 本周请假情况\n" + for lv in leaves: + data_block += ( + f"- {lv.get('manager_name', '未知')} ({lv.get('leave_type', '请假')}): " + f"{lv.get('start_date', '')} ~ {lv.get('end_date', '')} " + f"({lv.get('days', 0)}天)\n" + ) + if not leaves: + data_block += "无请假记录\n" + else: + data_block += "\n## 本周请假情况\n无请假记录\n" + + return data_block +``` + +- [ ] **Step 3: 修改 `generate_summary` — 传入 leaves 数据** + +```python +# 在 generate_summary 中,获取 light_board 后新增: + from app.services.leaves import get_leave_overview + leave_overview = await get_leave_overview(db, str(user_id), role, ref) + user_prompt = build_summary_prompt( + weekly_report, light_board, period, str(ref), + leaves=leave_overview.get("leave_list", []), # ← 传入 + ) +``` + +- [ ] **Step 4: Commit** + +```bash +git add backend/app/services/scheduler.py backend/app/services/ai_summary.py +git commit -m "feat: scheduler skips on-leave managers; AI summary includes leave data + +Co-Authored-By: Claude " +``` + +--- + +### Task 7: 前端 — PC 端请假管理页 + +**Files:** +- Create: `frontend/src/views/desktop/Leaves.vue` + +**Interfaces:** +- Consumes: `leavesApi` (Task 4), `auth` store, `getMgrStyle`/`loadManagerColors` utils, `useScreenshot` +- Produces: Full CRUD page at `/leaves` route + +- [ ] **Step 1: 创建 Leaves.vue** + +This is the largest single file. The page follows the `WorkPlans.vue` pattern with: +- Page head (title + desc + gold rule) +- Manager summary bar (chips by leave_type counts) +- Filter bar (manager select + status select) +- `el-card` with `el-table` showing: 序号, 姓名, 类型(color chip), 日期范围, 天数, 原因摘要, 提交人, 操作(编辑/删除) +- Active leave rows have a blue left-border via `row-class-name` +- `el-dialog` for create/edit with: manager select (director only), leave_type chip buttons, date range picker, reason textarea +- Create button in page-head-actions + +```vue + + + + + + +``` + +- [ ] **Step 2: 验证前端编译** + +Run: +```bash +cd /home/v6ole/pyproject/qiji/frontend && npx vue-tsc --noEmit 2>&1 | head -20 +``` +Expected: No new TypeScript errors related to leaves. + +- [ ] **Step 3: Commit** + +```bash +git add frontend/src/views/desktop/Leaves.vue +git commit -m "feat: add Leaves management page (PC) + +Co-Authored-By: Claude " +``` + +--- + +### Task 8: 前端 — 修改侧边栏 + 路由 + 仪表盘 + +**Files:** +- Modify: `frontend/src/components/DesktopLayout.vue` +- Modify: `frontend/src/router/index.ts` +- Modify: `frontend/src/views/desktop/Dashboard.vue` + +**Interfaces:** +- Consumes: `leavesApi` (Task 4), `Leaves.vue` (Task 7) + +- [ ] **Step 1: 侧边栏新增「请假管理」入口** + +```typescript +// frontend/src/components/DesktopLayout.vue — 在「工作」分组 items 数组新增一项: + label: '工作', + items: [ + { path: '/work-plans', label: '工作计划', icon: '...' }, + { path: '/mini-business', label: '商机跟单', icon: '...' }, + { path: '/key-visits', label: '要客拜访', icon: '...' }, + { path: '/leaves', label: '请假管理', icon: '' }, // ← 新增 + ], +``` + +- [ ] **Step 2: 路由新增 `/leaves`** + +```typescript +// frontend/src/router/index.ts — 在 desktop children 中新增: +{ path: 'leaves', name: 'Leaves', component: () => import('@/views/desktop/Leaves.vue') }, +``` + +- [ ] **Step 3: 仪表盘新增第五张统计卡 + on_leave 状态** + +在 Dashboard.vue 中: + +```typescript +// stats 响应新增 week_leaves 字段 +const stats = ref({ week_visits: 0, work_plans: 0, mini_business: 0, key_visits: 0, week_leaves: 0 }) + +// rowState 函数扩展为四态 +function rowState(p: any): 'full' | 'catching' | 'missing' | 'on_leave' { + if (p.on_leave) return 'on_leave' + if (p.has_reported_today && p.completed) return 'full' + if (p.has_reported_today && !p.completed) return 'catching' + return 'missing' +} + +// 印章文字扩展 +function sealChar(p: any): string { + if (p.on_leave) return '假' + if (p.has_reported_today && p.completed) return '满' + if (p.has_reported_today && !p.completed) return '追' + return '未' +} +``` + +在模板中: + +```html + +
+
+ +
+
+ {{ stats.week_leaves }} + 本周请假 +
+
+ + +
+ +
{{ sealChar(p) }}
+ + + 请假中 +
+``` + +CSS 新增: + +```css +.progress-row--on_leave { border-left-color: #5B7FA5; } +.progress-row--on_leave .progress-seal { color: #5B7FA5; border-color: #5B7FA5; } +.stat-card--slate:hover { border-color: #5B7FA5; } +.stat-glyph--slate { color: #5B7FA5; } +.leave-text { font-size: 13px; color: #5B7FA5; font-family: var(--font-body); } +``` + +- [ ] **Step 4: 验证编译** + +Run: +```bash +cd /home/v6ole/pyproject/qiji/frontend && npx vite build 2>&1 | tail -5 +``` +Expected: `✓ built in ...` + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/components/DesktopLayout.vue frontend/src/router/index.ts frontend/src/views/desktop/Dashboard.vue +git commit -m "feat: add sidebar entry + route + dashboard leave card + on_leave status + +Co-Authored-By: Claude " +``` + +--- + +### Task 9: 前端 — 移动端请假列表页 + +**Files:** +- Create: `frontend/src/views/mobile/LeavesList.vue` + +**Interfaces:** +- Consumes: `leavesApi` (Task 4) + +- [ ] **Step 1: 创建 LeavesList.vue** + +```vue + + + + + + +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/src/views/mobile/LeavesList.vue +git commit -m "feat: add mobile leave list page + +Co-Authored-By: Claude " +``` + +--- + +### Task 10: 前端 — 移动端请假表单页 + Home 入口 + 路由 + +**Files:** +- Create: `frontend/src/views/mobile/LeaveForm.vue` +- Modify: `frontend/src/views/mobile/Home.vue` +- Modify: `frontend/src/router/index.ts` + +**Interfaces:** +- Consumes: `leavesApi` (Task 4), `router` for navigation + +- [ ] **Step 1: 创建 LeaveForm.vue (create + edit 复用)** + +```vue + + + + + + +``` + +- [ ] **Step 2: 修改移动端 Home.vue — 快捷入口新增「请假」chip** + +```html + + +``` + +- [ ] **Step 3: 修改路由 — 新增移动端路由** + +```typescript +// frontend/src/router/index.ts — 在 mobile children 中新增: +{ path: 'leaves', name: 'LeavesList', component: () => import('@/views/mobile/LeavesList.vue') }, +{ path: 'leave/new', name: 'LeaveForm', component: () => import('@/views/mobile/LeaveForm.vue') }, +{ path: 'leave/:id/edit', name: 'LeaveEdit', component: () => import('@/views/mobile/LeaveForm.vue') }, +``` + +- [ ] **Step 4: 验证前端构建** + +Run: +```bash +cd /home/v6ole/pyproject/qiji/frontend && npx vite build 2>&1 | tail -10 +``` +Expected: `✓ built in ...` + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/views/mobile/LeaveForm.vue frontend/src/views/mobile/Home.vue frontend/src/router/index.ts +git commit -m "feat: add mobile leave form + list + home entry + routes + +Co-Authored-By: Claude " +``` + +--- + +### Task 11: 端到端验证 + +- [ ] **Step 1: 重启后端并验证 health** + +```bash +# Kill and restart backend on port 8002 +kill $(pgrep -f "uvicorn app.main:app --host 0.0.0.0 --port 8002") 2>/dev/null +sleep 1 +cd /home/v6ole/pyproject/qiji/backend && nohup uvicorn app.main:app --host 0.0.0.0 --port 8002 > /tmp/backend.log 2>&1 & +sleep 3 +curl -s http://localhost:8002/health +``` +Expected: `{"status":"ok","app":"政企周报管理系统"}` + +- [ ] **Step 2: 验证 leaves 表已创建** + +```bash +curl -s http://localhost:8002/api/leaves/ -H "Authorization: Bearer $TOKEN" | python3 -m json.tool +``` +Expected: `{"items": [], "total": 0, ...}` + +- [ ] **Step 3: 创建请假 → 查列表 → 编辑 → 删除 全流程测试** + +```bash +# 1. Create +curl -s -X POST http://localhost:8002/api/leaves/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"manager_id":"","leave_type":"事假","start_date":"2026-07-13","end_date":"2026-07-15","reason":"测试"}' | python3 -m json.tool + +# 2. Verify in progress +curl -s "http://localhost:8002/api/dashboard/progress" -H "Authorization: Bearer $TOKEN" | python3 -c "import sys,json; d=json.load(sys.stdin); [print(p['manager_name'],'on_leave:',p['on_leave'],'leave_info:',p.get('leave_info')) for p in d]" + +# 3. Verify overview +curl -s "http://localhost:8002/api/leaves/overview" -H "Authorization: Bearer $TOKEN" | python3 -m json.tool + +# 4. Verify stats +curl -s "http://localhost:8002/api/dashboard/stats" -H "Authorization: Bearer $TOKEN" | python3 -m json.tool +``` + +- [ ] **Step 4: 验证前端页面可访问** + +```bash +# 确认 Vite dev server 运行中,页面可以加载 +curl -s -o /dev/null -w "%{http_code}" http://localhost:5173/leaves +``` +Expected: `200` + +- [ ] **Step 5: Commit 最终验证** + +```bash +git add -A +git commit -m "chore: final verification — leave feature ready + +Co-Authored-By: Claude " +``` + +--- + +## Self-Review Checklist + +- [x] **Spec coverage:** 数据模型(T1) ✓, Schema(T2) ✓, Service(T3) ✓, API(T4) ✓, 填报进度(T5) ✓, 催办调度(T6) ✓, AI摘要(T6) ✓, 仪表盘卡片(T8) ✓, PC请假页(T7) ✓, 侧边栏(T8) ✓, 路由(T8+T10) ✓, 移动端列表(T9) ✓, 移动端表单(T10) ✓, 移动端入口(T10) ✓ +- [x] **No placeholders:** 所有任务步骤都有精确代码,无 TBD/TODO/占位符 +- [x] **Type consistency:** `LeaveOut.days` computed in service matches schema field type (int); `on_leave` bool consistent across api/service/frontend; `leave_info` object shape matches everywhere +- [x] **File paths:** All paths are absolute and verified against actual project structure