# PRD 差距收尾 — 设计文档

> 日期：2026-07-07
> 范围：PRD v1.0 审计后剩余的全部 ⚠️ 部分实现 / ❌ 未实现项 + 1 个确认 bug，共 12 项，拆为 8 个工作包（WP）。
> 执行方式：实现工作全部派发给 `deepseek/deepseek-v4-pro` 子代理，每个 WP 独立验收，最后统一构建验证。

## 背景

基于 6 路并行代码审计（2026-07-07），PRD 的 P0/P1 主体功能已完成。剩余缺口：

| 类别 | 项 |
|---|---|
| Bug | 今日出勤率恒为 0 |
| ⚠️ 部分实现 | 钉钉兜底匹配、档案脱敏、多班型封面、PDF 专业课对比、面板角色差异化、organization 残留、增量同步、账单外部推送 |
| ❌ 未实现 | 入住 enrollment 预填、入住时间线、排课单双周 |

## WP1 — Bug：今日出勤率恒为 0

**问题**：`apps/server/src/dashboard/dashboard.service.ts` `getStats()` 在 ~L92 计算了 `todayAttendanceRate`，但 L153-173 的 return 对象漏掉该字段；前端 `apps/admin/src/pages/Dashboard/index.tsx:386` 读 `stats?.todayAttendanceRate` 恒为 undefined，指标卡显示 0。

**修复**：return 对象补 `todayAttendanceRate`。无其他改动。

**验收**：GET /dashboard/stats 响应含 `todayAttendanceRate` 字段（有当日考勤数据时为非零字符串）。

## WP2 — 钉钉考勤兜底匹配

**现状**：`apps/server/src/attendance/attendance.service.ts` `autoMatchDingRecords()` 仅按姓名匹配；PRD 23.3 要求手机号、身份证号兜底。

**设计**：

1. 匹配链（顺序执行，命中即停）：
   - `students.resource_user_id = raw.ding_user_id`
   - 经 users 同步表由 dingUserId 取 phone → `students.phone` 精确匹配
   - 同上取到的身份信息 → `students.id_card` 精确匹配（若钉钉侧有）
   - 姓名精确匹配（现有逻辑保留，作为最后一级）
2. 任一级命中**多个**候选学生 → 不自动绑定，保持 `match_status=待匹配`（人工处理页兜底）。
3. `ding_attendance_raw` 实体新增可空 `phone` 列；打卡事件落库时若能从已同步 User 拿到手机号则冗余写入，便于人工匹配页展示与排查。SQLite 开发库用 `synchronize` 自动加列；不需要手写迁移（生产 MySQL 上线时统一走 schema 同步流程）。
4. 人工匹配 API（POST /ding-attendance-raw/:id/match）不变。

**验收**：构造 4 条 raw 记录（分别只能由 resourceUserId / phone / idCard / name 命中），autoMatch 后均绑定正确 student；构造重名两学生 → 该记录保持待匹配。

## WP3 — 学生档案三项

### 3a 敏感信息脱敏（PRD 1.3）

**现状**：`apps/admin/src/pages/Students/index.tsx` 已实现脱敏+二次确认+后端日志；`apps/admin/src/components/StudentProfileContent/index.tsx` 直接明文展示 phone/idNumber。

**设计**：复用 Students 页的既有模式（同一确认弹窗文案、同一后端敏感查看日志端点），StudentProfileContent 中 phone/idNumber 默认脱敏（`138****1234` / `110***********1234`），点击"查看"→ Modal.confirm → 调日志端点 → 显示明文。**不新造第二套脱敏组件**：若 Students 页逻辑是内联的，抽为共享工具/组件后两处复用。

### 3b 多班型封面（PRD 2.1）

**现状**：档案封面 enrollment 卡片只展示前 2 个。

**设计**：改为全部展示，卡片区 flex-wrap 布局；3+ 班型时自动换行。

### 3c PDF 报表专业课对比（PRD 2.2）

**现状**：`apps/server/src/archive/archive-report.service.ts` `buildExamDetail` 仅渲染文化课考试。

**设计**：镜像文化课渲染逻辑，增加专业课成绩独立表格 + 趋势区块；按 exam 记录的课程类别（文化课/专业课）分组，专业课组为空时该区块不渲染（不出空表）。

**验收**：3a 档案页 phone/idNumber 默认脱敏、确认后显示且 operation_logs 有记录；3b 造 3 个 enrollment 的学生封面全部可见；3c 有专业课成绩的学生报表 HTML 含专业课对比表，无专业课成绩的学生报表不含空区块。

## WP4 — 数据面板角色差异化（PRD 23.6）

**现状**：Dashboard 仅有 CampusScope 部门过滤，无角色差异化。

**设计**：

1. 后端 `getStats()`（及 class-attendance-ranking 等端点）注入当前用户：
   - 用户为班主任类角色（存在 ClassTeacher 关联且非 super_admin/staff 系管理角色）→ 考勤指标、班级出勤排行仅统计其所带班级（ClassTeacher → classId 过滤）。
   - 用户无 `bill`/`deposit` 相关权限 → return 对象**不含** `monthlyIncome`、`pendingDeposits`、`incomeTrend`、`billStats` 字段。
2. 前端指标卡/图表按字段有无条件渲染：字段 undefined 即不渲染该卡片/图。
3. super_admin 与 staff 系角色行为完全不变。
4. 判定依据用现有 RBAC 权限节点（请求上下文里已有用户权限），不新增权限节点。

**验收**：以班主任账号请求 /dashboard/stats → 无收入/押金字段、考勤数只含本班；超管请求 → 字段齐全，与改动前一致。

## WP5 — organization → tenant_id 收尾（PRD 1.1）

**现状**：`students.tenant_id` 外键与筛选已生效，旧 `organization` 文本列共存。

**设计**（干净切换）：

1. 回填逻辑放在 StudentsService（或专用 backfill service）的 `onModuleInit` 中幂等执行（项目约定：`synchronize: true` + 服务内幂等 seed，参照 `expense-types.service.ts` 的 `seedDefaults`）：对 `organization` 非空且 `tenant_id` 为空的学生，按名称 find-or-create Tenant（新建的给默认颜色），回填 `tenantId`。注意：`organization` 列从 entity 删除后 TypeORM synchronize 不会自动删物理列（SQLite/MySQL 均保留孤列），回填需在删除 entity 字段前用 raw query 读取该列，保证幂等重跑安全。
2. 迁移完成后删除 `organization`：entity 字段、DTO、导入导出模板列、前端表单/表格列全部清除。
3. Excel 导入模板中原"机构"列改为按 Tenant 名称解析（find-or-create 同上），保持导入体验不回退。

**验收**：迁移后无 `organization` 引用（grep 为零，实体除外的历史注释可留）；导入含机构名的 Excel 仍能正确挂 tenant；学生列表机构筛选正常。

## WP6 — 第三方集成两项

### 6a 增量同步（INT.3）

**约束**：钉钉/企微组织架构 API 不支持"按变更时间查询"，真增量不可行。

**设计**：全量拉取 + 本地 diff 写入：
- `syncAll()` 拉取后与库内记录逐条比对，仅对有实际字段变化的记录执行 update，新记录 insert，无变化 skip。
- 同步日志（sync_log）记录 `created/updated/skipped` 三个数量；`lastSyncAt` 保留用于展示。
- 移除 `_lastSyncAt` 假形参（要么真用于日志，要么删除），不留误导性签名。

### 6b 账单外部推送（10.2）

**设计**：
- 账单**确认**（confirmed）时触发（draft 不推，避免打扰）：对账单学生查 `resource_user_id`，钉钉侧发工作通知、企微侧发应用消息（走 integration 模块现有 token 机制，新增发消息方法）。
- 凭据未配置或学生无 resourceUserId → 静默跳过，仅站内 SSE 通知（现状保留），不抛错不阻塞账单流程。
- 推送结果写操作日志（module=bills, action=notify）。

**验收**：6a 连续两次同步，第二次日志 skipped≈全部、updated=0；6b 无凭据环境下确认账单不报错且站内通知正常，推送代码路径有单测覆盖（mock integration service 验证调用参数）。

## WP7 — 入住管理两项

### 7a enrollment 预填（8.1）

入住弹窗选中学生后，调现有档案/enrollment 查询接口拉取该生当前在读 enrollment，在表单内只读展示班级/班型/班主任提示信息。不改数据模型、不落库。

### 7b 入住历史时间线（8.3）

入住列表行加"历史"按钮 → Drawer 内 AntD `Timeline` 按时间倒序展示该学生全部 occupancy 记录（入住/换房/退住节点，含房间号、日期、退住原因）。数据用现有 occupancies 按 studentId 查询的 API（如无该筛选参数则补上）。

**验收**：7a 选择有 enrollment 的学生显示班级提示、无 enrollment 学生不报错；7b 多次入住的学生时间线节点完整有序。

## WP8 — 排课单双周

**设计**：

1. `class_schedule` 新增 `week_parity` 列：`'all' | 'odd' | 'even'`，默认 `'all'`；奇偶按 **ISO 8601 周数**判定。
2. 冲突检测（`apps/server/src/schedules/schedules.service.ts`）：同教室同 weekDay 时间重叠时，`odd` vs `even` 不冲突；`all` 与任何值冲突。RENTAL 类型视为 `all`。
3. 前端排课表单加"周次"选择（默认每周）；周视图格子对 odd/even 排课显示"单/双"角标；月视图按具体日期的 ISO 周奇偶过滤显示。
4. 排课→考勤自动生成（`attendance.service.ts` generateFromSchedules）：按目标日期 ISO 周奇偶跳过不匹配的排课。
5. 班级课表（/classes/:id/schedule）响应带 weekParity 字段，前端同样标注。

**验收**：同教室同时段创建 odd+even 两条排课成功、再建 all 报冲突；单周排课在双周日期不生成考勤记录；周视图角标正确。

## 横切约束

- 所有新增写操作接入 OperationLogsService（沿现有各 controller 模式）。
- 不引入新依赖；ISO 周数计算用 dayjs 现有插件或自实现纯函数。
- 各 WP 不跑项目级 lint/test/build——由主控最后统一验证。
- 前端遵循现有页面结构与 AntD 6 组件用法；后端遵循 entity/dto/service/controller 模块结构。

## 工作包依赖

全部 WP 相互独立、可并行。WP8 内部排课字段与考勤生成改动同包完成，无跨包依赖。

## 测试策略

- WP2、WP6b、WP8 冲突检测：Jest 单测（新增/修改的 service 方法）。
- 其余 WP：以 API 响应/页面行为验收，主控统一 `turbo build` + 相关单测。
