feat(task): M1-02 覆盖班级与班主任配置
补上发布前置校验的最后一环(40019),同时确立任务的数据隔离边界 ——
班主任能看到哪些任务、能审哪些单子,全由 collect_task_org 决定。
后端 GET/PUT /tasks/{id}/orgs(整体替换语义 + 软移除 + 历史行复用);
前端「配置班级」抽屉:组织树只允许勾选班级,主班主任留空时显式提示
「将由该班负责人初审」而非留白。发布被拦时给出「去哪里补」的指引
(BLOCKING_HINTS),而不是只说"未设置覆盖班级"。
三条守住的规则:
1) 班级与初审人都必须在本租户内。跨租户的 id 一律当作"不存在或已删除",
不透露"它存在但不属于你" —— 漏了的后果是 A 校能把 B 校的班挂到自己
的任务上,B 校的班主任就出现在 A 校的审批流里。
2) 初审人必须持有班主任/副班主任角色。指定初审人本质上是一次授权,
那个人将能看到该班学生的身份证号与学籍材料;不校验角色就等于把
"谁能看学生隐私"变成任意授权。报错要给出下一步,不能只说"校验失败"。
3) 移除是软移除(status=0),且「移除再加回」必须复用原行。
后者不是优化而是必须:uk_task_org UNIQUE (task_id, org_id) 不带 status
条件,一个班在同一任务下只能有一行,直接 insert 会违反唯一约束。
本轮修掉的 3 个真问题:
1) 我此前误判 uk_task_org 不存在 —— grep 只匹配含表名的行,而
CONSTRAINT uk_task_org UNIQUE (task_id, org_id) 那行没有表名。
基于这个错误前提,我写了一个多余的部分唯一索引 uk_task_org_active
和一段基于错误前提的代码,已全部撤掉并更正注释。
教训:查约束要看 CREATE TABLE 整块,不能只 grep 表名。
2) collect_task_org.expected_count 是 NOT NULL DEFAULT 0,与接口契约矛盾:
DDL 注释说它是"由名册汇总"(系统算的),但《03-接口设计》§7.6 把它放在
PUT 请求体里、说"用于与名册实际人数比对" —— 只有"管理员填的期望值 vs
名册实际值"才需要比对。而 0 是有意义的业务值("预期 0 人"),拿它兼职
表示"没填"会让每个没填的班都触发"应填 0 人、实际 31 人"的假告警;告警
变噪音就没人看了,真正"应填 28、名册却有 31"(名册导错)反而被漏掉,
而这条告警的全部价值就在那一次。已由 V4 迁移改为可空 + 去掉默认值,
并同步更正 docs/db/schema-postgresql.sql 与 PRD 里的内联 DDL。
3) 删掉迁移文件后必须 mvn clean,否则它会"复活":我只跑 mvn package,
target/classes 里的旧副本被打进 jar,Flyway 发现历史里没有 v4 就又执行
了一遍 —— 索引回来了、历史行填回去了。之后又建了同名版本号的新 V4,
于是"两个 v4"→"checksum 冲突",排查绕了好几圈。
顺带修掉:
· 任务列表操作列从 3 个链接涨到 4 个,160px 会挤成竖排 —— 把「进度」并进
「状态」列腾出 40px,操作列给到 200px,合计 820px(可用约 824px)。
· defineEmits 必须用函数调用签名(frontend-pc/.agents/skills/code-vue)。
我此前写的 5 个组件都是被禁的 tuple 形式,一并改正:
OrgFormModal / TaskFormModal / TaskOrgDrawer / UserFormModal / UserImportModal。
· e2e-m1-orgs.py 的清理抽成幂等的 cleanup_all(),并在 __main__ 的 finally
里兜一次 —— 该脚本首次运行时崩在准备阶段、清理段没执行,把两个测试账号
留在了库里(是在浏览器里看到下拉框里有个「E2E班主任」才发现的)。
其余三条 E2E 脚本仍是老样子,下一轮一并改。
验收:e2e-m1-orgs.py 48/48(含跨租户隔离);e2e-m1-task.py 71/71、
e2e-m0-user.py 55/55、e2e-m0.py 36/36 回归通过,共 210 项断言;
前端 typecheck / eslint / lint:style 0 错、build 通过;
浏览器实测抽屉的开合、树的勾选规则与保存落库。
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
* 分页 `{ list, total }`(适配层会补一份 `records`)。
|
||||
*/
|
||||
import http from '@/utils/http'
|
||||
import { getUserList } from './user'
|
||||
|
||||
/** 审核流配置(§3.2) */
|
||||
export interface AuditConfig {
|
||||
@@ -217,3 +218,64 @@ export const NAMING_VARIABLES = [
|
||||
|
||||
/** 默认命名模板(与 DDL 的列默认值一致) */
|
||||
export const DEFAULT_NAMING_TEMPLATE = '{姓名}-{证件号}-{材料名}'
|
||||
|
||||
// ── 覆盖班级与班主任(§7.6)──────────────────────────
|
||||
|
||||
/** 任务覆盖的一个班 */
|
||||
export interface TaskOrgVO {
|
||||
orgId: number
|
||||
orgName: string
|
||||
/**
|
||||
* 该班初审人。
|
||||
*
|
||||
* ⚠️ 为空<b>不是</b>「没人审」,而是「取该班负责人」(`sys_org.leader_user_id`)。
|
||||
* 界面上要把这个区别讲清楚,否则管理员会以为漏配了,
|
||||
* 然后在几百个班上机械地点几百次 —— 而它们本来就等于班级负责人。
|
||||
*/
|
||||
headUserId: number | null
|
||||
headUserName: string | null
|
||||
deputyUserIds: number[]
|
||||
deputyUserNames: string[]
|
||||
/** 应填人数(期望值)。null = 未设置,与 0(预期 0 人)含义不同 */
|
||||
expectedCount: number | null
|
||||
/** 恒为 1(只返回生效行) */
|
||||
status: number
|
||||
}
|
||||
|
||||
/** 一个班的提交项。PUT 是整体替换语义:请求体就是最终答案 */
|
||||
export interface TaskOrgItem {
|
||||
orgId: number
|
||||
headUserId?: number | null
|
||||
deputyUserIds?: number[]
|
||||
/** 不传或传 null 表示"未设置",**不要传 0** —— 那是"预期 0 人" */
|
||||
expectedCount?: number | null
|
||||
}
|
||||
|
||||
export function getTaskOrgs(taskId: number) {
|
||||
return http.get<TaskOrgVO[]>(`/tasks/${taskId}/orgs`)
|
||||
}
|
||||
|
||||
/** 设置覆盖班级与班主任。不在列表里的班级会被软移除 */
|
||||
export function saveTaskOrgs(taskId: number, orgs: TaskOrgItem[]) {
|
||||
return http.put<void>(`/tasks/${taskId}/orgs`, { orgs })
|
||||
}
|
||||
|
||||
/**
|
||||
* 可被指定为初审人的账号。
|
||||
*
|
||||
* 拉两种角色再合并:后端要求初审人持有班主任**或**副班主任角色,
|
||||
* 所以两边的账号都是合法选项。只取启用状态 —— 停用的账号会被后端拒掉,
|
||||
* 列在选项里只会让人白选一次。
|
||||
*/
|
||||
export async function getTeacherOptions(): Promise<Array<{ id: number, label: string }>> {
|
||||
const [head, deputy] = await Promise.all([
|
||||
getUserList({ roleCode: 'HEAD_TEACHER', status: 1, pageSize: 200 }),
|
||||
getUserList({ roleCode: 'DEPUTY_TEACHER', status: 1, pageSize: 200 })
|
||||
])
|
||||
const merged = new Map<number, string>()
|
||||
for (const u of [...(head.data?.list ?? []), ...(deputy.data?.list ?? [])]) {
|
||||
// 带账号:学校里同名同姓很常见,只显示姓名选错了就是把别人的班交给了错误的人
|
||||
merged.set(u.id, u.username ? `${u.realName}(${u.username})` : u.realName)
|
||||
}
|
||||
return Array.from(merged.entries(), ([id, label]) => ({ id, label }))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user