Files
xiaotutongxue/scripts/e2e-m5-audit.py
T
lovife 5213bc214f feat(audit): 审核闭环后端(M5-01~05)—— 「谁能审谁」比「怎么审」重要
一、落地
   module/audit:entity(CollectAuditRecord) + 4 个 mapper + AuditService
                 + RejectReasonCatalog(附录 C 的 10 条,写在代码里,理由同权限码)
   两端接口:PC /api/v1/audit/*(§11)、小程序 /api/v1/mp/teacher/audit/*(§10.2~10.5,只做初审)
   待办的 MP 与 PC **共用一份 VO**(契约里那两份重合九成):做两份迟早分叉,
   而"同一个学生在手机上是红、在电脑上是绿"最伤信任。

二、★ 三件必须守住的事
   ① 权限 ≠ 范围:全校几十个班主任都有 audit:first,但只有这个班的那位能审这个学生。
      初审查询强制带 org 范围,且**空列表 = 看不见任何单子**(SQL 里显式 1 = 0,
      不是"不过滤")—— 那是这类查询最经典也最严重的写法错误。
      E2E 专门造了个"同样是班主任但没被指定"的 T2:看不到待办、打不开详情、点不动通过。
   ② 有红问题不许通过(40032),且校验是**现场重算**的:提交时落库的 red_count 是快照,
      而 OCR/识别是异步的、名册与材料规则也可能被改。"放过一份有问题材料"不可撤销。
   ③ 批量通过只带绿色项,红黄一律排除(onlyClean 服务端强制 true,传 false 不报错也不生效),
      且跳过的每一条都要说清原因 —— 否则用户只会反复点那个大按钮。

三、红黄计数:列表用快照,动作/详情现场重算并**回写快照**(自己会收敛,最多错一次)
   ⚠️ 因此 detail() 不能标 readOnly=true —— 它要写回写。标了 PG 直接拒绝 UPDATE,
   而这个 bug 只在"现场值与快照不一致"时炸,也就是最需要它工作的那一次。已踩并修。

四、⚠️ 三处文档与代码不一致,需要文档侧对齐(详见交接摘要 §一·二十七)
   1. 撤回的动作码:DDL 注释 3=批量通过,而接口设计 §11.4 写"action = 3(撤回)"。
      1~6 沿用 DDL,**撤回另取 7** —— 时间轴最不能混淆:把撤回显示成批量通过,
      会让人以为这份单子被通过了。
   2. 撤回的适用状态:§11.4 只列了状态 3;额外允许**状态 2**("班主任点错了通过"正是这个状态)。
      状态机自洽:撤初审 → 1,撤终审 → 2。**需客户确认**。
   3. 文件 source 的语义:DDL(2 班主任代传/3 管理员代传/4 整理器导入) 与 §10.6(source=4 代传)
      冲突,以 DDL 为准;代传接口(M5-06)未做,时间轴已按 source∈{2,3} 预留。

五、顺带补掉两个"没人写过"的窟窿
   · 名册行的 submit_status **全仓库只有读没有写** → 名册列表状态列恒为"未提交",
     而 REPLACE 护栏只靠 bind_user_id 在生效。现在学生侧与审核侧都写它。
     它**不是单调的**:被驳回(4)后重交必须回到 2,否则班主任一直以为他还欠一份。
   · collect_submission 少映射了 class_audit_time / admin_audit_time(DDL 里一直有)。
     口径是"**本轮**审核的状态"(驳回时清空,历史在 collect_audit_record 里一条不少)。

六、审核端看材料走单独通道
   MaterialFileService 抽出 urlOf/contentOf(调用方授权),学生端仍 /mp/files/*、
   审核端 /audit/files/*。不要为了"让管理员也能看"在学生那个方法里加 or 条件——那一刻学生端就漏了。

七、一个把「取值口径」讲清楚了的坑
   SubmissionValidator 原本走 FillService.getForm(taskId)(学生视角:先过门禁、再拿自己的草稿),
   审核人必然撞 40007 且拿到的是自己那份草稿。已抽 assembleFor(task, roster, sub),两条路径共用同一段装配。
   紧接着暴露第二层:**改提交单的列不一定改变"组装出来的值"** —— 学生没动过的字段值会**回退到名册**。
   想造红问题要用**名册里没有的字段**(E2E 最后用"本职业工作年限填 999"→ VAL-17)。

八、E2E:scripts/e2e-m5-audit.py → 87/87
   复用了既有的两个脚本工具(import e2e-m3-submit 拿学生侧造数、import e2e-m2-roster 拿服务端工具),
   不复制第二份 mint/upload。覆盖:范围隔离、详情与策略、40032 现场重算、幂等回放、
   终审、批量通过的三种跳过、驳回与升级、学生重交、撤回的 40033/40034/40301 与"不能撤两次"。
   夹具教训:身份证材料的目录默认长边下限是 1400,小图必判「清晰度偏低」=黄色警告,
   于是"绿色单"前提不成立、批量断言集体失败 —— 失败的样子像功能坏了,其实只是夹具不满足前提。

九、没做的(都在计划内):代传(M5-06)、统计(M5-09)、通知(M5-12)、批量驳回/转交(P1)。
   因为没有通知渠道,驳回响应**如实**回 notifySent=false + needManualNotice=true ——
   不能为了让契约好看而写 true,那会让"学生到底收到没有"变成没人能回答的问题。

十、回归:全量 **753 条断言、0 失败**(新增 87);verify-docs 19 项通过。
    下一步:M5 前端(班主任卡片流只右滑通过 / 学生详情 / 驳回页 / 一键通过确认 / PC 终审)。
2026-09-29 22:29:00 +08:00

640 lines
37 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
审核闭环 E2E(M5-01 ~ M5-05):待办列表 / 详情 / 通过 / 驳回 / 批量通过 / 撤回。
**这个脚本要守住的三件事**(按重要性):
① **谁都不能审别人班的学生**。全校几十个班主任都有 `audit:first`,
但只有这个班的班主任能审这个学生 —— "权限"与"范围"是两回事,
只查权限等于把全校学生的身份证照片交给每一个班主任。
所以这里专门造一个**没被指定**的班主任 T2:他既看不到待办、也点不动通过。
② **有红问题不许通过**(40032),而且必须**现场重算**、不是拿提交时的快照。
做法:提交成功后用 SQL 把「在读学校」清空,造出一个 VAL-16 红问题
——如果服务端读的是快照(red_count=0),这次通过就会成功,而这正是最危险的 bug。
③ **批量通过只带绿色项**:红黄一律排除,且**黄的单也要被跳过并说明原因**
(原型上"一键通过"是个大按钮,最容易被手快全选)。
跑法:python scripts/e2e-m5-audit.py
前提:后端 8080 在跑、xt-pg 在跑。脚本自己造任务/老师/学生,跑完自己清理。
"""
from __future__ import annotations
import argparse
import importlib.util
import json
import os
import subprocess
import sys
import uuid
sys.stdout.reconfigure(encoding="utf-8")
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PG_CONTAINER = os.environ.get("XT_PG_CONTAINER", "xt-pg")
PASSED: list[str] = []
FAILED: list[tuple[str, str]] = []
# 两个"学生"(MP 端只认 token 里的 sub,不要求 sys_user 里真有这个人 —— 与 e2e-m3-* 同口径)
UID_A = 992201
UID_B = 992202
def _load(module_name: str, filename: str):
"""
直接 import 既有的 E2E 脚本,复用它们的工具与夹具。
自己再写一份 `mint`/`upload`/`form_values` 就等着两边分叉:
一边改了签名一边没改,表现是"这个脚本单独跑得过、一起跑就红"。
"""
path = os.path.join(ROOT, "scripts", filename)
spec = importlib.util.spec_from_file_location(module_name, path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
S = _load("e2e_m3_submit", "e2e-m3-submit.py") # 学生侧:造草稿 / 传材料 / 提交
R = _load("e2e_m2_roster", "e2e-m2-roster.py") # 服务端工具:登录 / 导名册 / 校验位
CREATED_TASK_IDS: list[int] = []
CREATED_USER_IDS: list[int] = []
TMP_FILES: list[str] = []
def ok(name: str) -> None:
PASSED.append(name)
print(f" [OK] {name}")
def fail(name: str, detail: str) -> None:
FAILED.append((name, detail))
print(f" [FAIL] {name}\n {detail}")
def check(name: str, cond: bool, detail: str = "") -> bool:
if cond:
ok(name)
else:
fail(name, detail or "断言不成立")
return cond
def sql(stmt: str) -> str:
r = subprocess.run(
["docker", "exec", "-e", "PGPASSWORD=fill_dev_pwd", PG_CONTAINER,
"psql", "-U", "fill", "-d", "filldb", "-t", "-A", "-c", stmt],
capture_output=True, text=True, timeout=40)
return (r.stdout or r.stderr).strip()
def call(base: str, method: str, path: str, token: str | None = None,
body: dict | None = None) -> dict:
"""PC 侧调用:R.call 返回 (status, body),这里只要 body。"""
_, b = R.call(method, f"{base}{path}", token, body)
return b
def build_roster(path: str, rows: list[tuple[str, str]]) -> None:
"""两行名册(姓名 + 身份证号),报考三项按任务声明填好。"""
from openpyxl import Workbook
wb = Workbook()
ws = wb.active
ws.title = "24中技数控1班"
header = ["序号", "姓名", "身份证号", "学历", "联系电话", "工作单位",
"报考职业", "报考工种", "报考等级", "是否补考",
"补考理论", "补考实操", "补考综合", "是否在校生",
"在读学校", "所学专业", "入学时间"]
for c, t in enumerate(header, start=1):
ws.cell(row=1, column=c, value=t)
for i, (name, idc) in enumerate(rows):
vals = [i + 1, name, idc, "中专", f"138001388{i}", "无",
"车工", "数控车工", "四级", "否", "", "", "", "是",
"示范职业学校", "数控加工", "2024-09-01"]
for c, v in enumerate(vals, start=1):
if v != "":
ws.cell(row=i + 2, column=c, value=v)
wb.save(path)
def big_jpeg() -> bytes:
"""
一张**长边够长**的测试图。
⚠️ 不能用 e2e-m3-submit 里那张 900×1200 的小图:身份证材料项的目录默认
长边下限是 **1400**(`MaterialCatalog` 的档位默认,接口传 minLongSide 也压不过它),
低于它会被判「清晰度偏低」——那是**黄色警告**,
于是"干净单"这个前提不成立,批量通过的断言会集体失败,
而失败的样子像功能坏了。夹具不满足业务前提,是这类误判最常见的来源。
"""
from PIL import Image
import io
buf = io.BytesIO()
Image.new("RGB", (1600, 1400), (200, 210, 220)).save(buf, "JPEG", quality=90)
return buf.getvalue()
def prepare_student(tok: str, sid: int, task_id: int, fill: dict, dup_first_file: bool) -> dict:
"""
把一个学生推到"已提交"。
- 填表(只填可改字段;锁定字段的值来自名册)
- 按**表单里当前必传且条件成立**的材料逐项上传(不写死材料码)
- dup_first_file:第一项材料再传一份同一张图 → 制造一条可预期的黄色警告(VAL-09)
"""
S.call("PUT", f"/api/v1/mp/submissions/{sid}", tok, {"formData": fill})
img = big_jpeg()
values, mats = S.form_values(tok, task_id)
need = [m for m in mats if m.get("required") and S.visible(m.get("visibleWhen"), values)]
for i, m in enumerate(need):
code = m.get("materialCode")
up = S.upload(tok, sid, code, f"{m.get('materialName') or code}.jpg", img)
if up.get("code") != 0:
return {"_upload_error": up}
S.call("POST", f"/api/v1/mp/files/{up['data']['id']}/process", tok)
if dup_first_file and i == 0:
up2 = S.upload(tok, sid, code, f"{m.get('materialName') or code}-重复.jpg", img)
if up2.get("code") == 0:
S.call("POST", f"/api/v1/mp/files/{up2['data']['id']}/process", tok)
return S.sub(tok, sid, True)
def main() -> int:
ap = argparse.ArgumentParser(description="审核闭环 E2E")
ap.add_argument("--base", default=os.environ.get("XT_BASE", "http://localhost:8080"))
ap.add_argument("--tenant", default=os.environ.get("XT_TENANT", "demo"))
ap.add_argument("--password", default="Admin@123")
args = ap.parse_args()
base = args.base.rstrip("/")
suffix = uuid.uuid4().hex[:6]
print("=" * 76)
print(f"审核闭环 E2E(M5-01~05) base={base}")
print("=" * 76)
# ── 0. 夹具 ────────────────────────────────────────
print("\n[0] 准备:任务 + 名册 + 两个班主任(一个被指定、一个没被指定)")
_, b = R.login(base, "admin", args.password, args.tenant)
if b.get("code") != 0:
fail("管理员登录", f"{b}")
return 1
admin = b["data"]["token"]
ok("管理员登录")
_, tree = R.call("GET", f"{base}/api/v1/orgs/tree", admin)
classes = []
def walk(nodes):
for n in nodes or []:
if n.get("orgType") == 3:
classes.append(n)
walk(n.get("children"))
walk((tree.get("data") or []))
if not classes:
fail("取一个班级", "demo 租户里没有 orgType=3 的组织")
return 1
org_id, org_name = classes[0]["id"], classes[0]["orgName"]
ok(f"取到班级:{org_name}(id={org_id})")
teachers = {}
for i, tag in enumerate(("t1", "t2")):
uname = f"e2e{tag}{suffix}"
body = call(base, "POST", "/api/v1/users", admin, {
"username": uname, "realName": f"E2E{suffix}{tag}",
# 手机号要过唯一约束:用 suffix 算出来,别用 hash()(它是进程随机的)
"phone": "13" + str(int(suffix, 16) % 10 ** 8 + i).zfill(9),
"roleCodes": ["HEAD_TEACHER"], "userType": 3,
})
if body.get("code") != 0:
fail(f"建班主任 {tag}", f"{body}")
return 1
uid = body["data"]["id"]
pwd = body["data"]["initialPassword"]
CREATED_USER_IDS.append(uid)
_, lb = R.login(base, uname, pwd, args.tenant)
if lb.get("code") != 0:
fail(f"班主任 {tag} 登录", f"{lb}")
return 1
teachers[tag] = {"id": uid, "token": lb["data"]["token"], "name": f"E2E{suffix}{tag}"}
ok(f"两个班主任账号已建(T1 id={teachers['t1']['id']} 被指定为初审人,T2 id={teachers['t2']['id']} 不指定)")
body = call(base, "POST", "/api/v1/tasks", admin, {
"taskName": f"E2E审核 {suffix}", "collectMode": 1, "batchNo": "2007",
"majorName": "数控加工", "jobName": "车工", "certType": 1,
"startTime": "2026-09-28", "endTime": "2026-12-31",
})
if body.get("code") != 0:
fail("建任务", f"{body}")
return 1
task_id = body["data"]["id"]
CREATED_TASK_IDS.append(task_id)
fix = os.path.join(ROOT, "scripts", f"_tmp_audit_{suffix}.xlsx")
TMP_FILES.append(fix)
rows = [("审核甲", R.with_checksum("361029" + "20080101" + "011")),
("审核乙", R.with_checksum("361029" + "20080202" + "022"))]
build_roster(fix, rows)
# ⚠️ 必须显式带 orgId:名册导入的 orgId 是**整批归到哪个班**(为空则名册行不留 org_id)。
# 留空的后果不是"少个字段",而是提交单没有班级 → 审核范围判不出来(40001/40301)。
body = R.upload_roster(base, admin, task_id, fix,
{"dryRun": "false", "orgId": str(org_id)})
check("名册导入 2 人", (body.get("code") == 0 and (body.get("data") or {}).get("created") == 2),
f"{body}")
check("名册行带上了班级(审核范围要靠它)",
sql(f"SELECT count(*) FROM collect_roster WHERE task_id={task_id} "
f"AND org_id={org_id} AND deleted=0") == "2",
sql(f"SELECT org_id FROM collect_roster WHERE task_id={task_id} AND deleted=0"))
body = call(base, "PUT", f"/api/v1/tasks/{task_id}/orgs", admin, {
"orgs": [{"orgId": org_id, "headUserId": teachers["t1"]["id"], "expectedCount": 2}],
})
check("覆盖班级 + 指定初审人 = T1", body.get("code") == 0, f"{body}")
body = call(base, "POST", f"/api/v1/tasks/{task_id}/materials", admin, {
"materialCode": "IDCARD", "materialName": "身份证", "required": True,
"fileTypes": ["jpg", "jpeg", "png"], "maxSizeKb": 1024, "maxCount": 2,
# ⚠️ 把下限调到 1KB / 长边 100:E2E 用的小图(17KB、900×1200)会被
# 材料目录的默认下限判成「体积偏小 / 清晰度偏低」——那是**黄色警告**,
# 于是"绿色单"这个前提不成立,批量通过的断言全部跟着错。
# 这坑很值得记:断言失败的样子像功能坏了,其实只是夹具不满足前提。
"minSizeKb": 1, "minLongSide": 100,
})
check("配必传材料(身份证;下限调到 1KB 免得小图被判「体积偏小」)",
body.get("code") == 0, f"{body}")
body = call(base, "POST", f"/api/v1/tasks/{task_id}/publish", admin, {"force": True})
check("发布任务", body.get("code") == 0, f"{body}")
ids = sql(f"SELECT id, real_name FROM collect_roster WHERE task_id={task_id} AND deleted=0 "
f"ORDER BY id")
pairs = [ln.split("|") for ln in ids.splitlines() if ln]
if len(pairs) != 2:
fail("取名册行", ids)
return 1
rid_a, rid_b = int(pairs[0][0]), int(pairs[1][0])
cfg = S.read_cfg()
tok_a = S.mint(cfg["jwt"], cfg["issuer"], UID_A, 1)
tok_b = S.mint(cfg["jwt"], cfg["issuer"], UID_B, 1)
try:
# ── 1. 两个学生提交 ────────────────────────────
print("\n[1] 学生提交:甲(干净)/ 乙(有一处黄色警告)")
sql(f"UPDATE collect_roster SET bind_user_id={UID_A}, bind_type=1 WHERE id={rid_a}")
r = S.call("POST", "/api/v1/mp/submissions", tok_a, {"taskId": task_id})
sid_a = r["data"]["id"]
# 刻意改一个老师填过的字段(在读学校)→ 审核端要能看出"这不是老师填的那份"
rep_a = prepare_student(tok_a, sid_a, task_id, {
"work_years": "3", "school": "我校(学生改过)",
}, dup_first_file=False)
check("甲:提交成功", (rep_a.get("data") or {}).get("submitted") is True, f"{rep_a}")
sql(f"UPDATE collect_roster SET bind_user_id={UID_B}, bind_type=1 WHERE id={rid_b}")
r = S.call("POST", "/api/v1/mp/submissions", tok_b, {"taskId": task_id})
sid_b = r["data"]["id"]
rep_b = prepare_student(tok_b, sid_b, task_id, {"work_years": "5"}, dup_first_file=True)
check("乙:带着黄色警告提交成功(学生确认过)",
(rep_b.get("data") or {}).get("submitted") is True, f"{rep_b}")
check("乙:确实是「有黄无红」——这才轮得到批量通过的排除逻辑",
((rep_b.get("data") or {}).get("yellowCount") or 0) > 0
and ((rep_b.get("data") or {}).get("redCount") or 0) == 0, f"{rep_b.get('data')}")
st = sql(f"SELECT status || '|' || red_count || '|' || yellow_count || '|' || "
f"submit_status FROM collect_submission s "
f"JOIN collect_roster r ON r.id = s.roster_id "
f"WHERE s.id={sid_a} AND s.deleted=0").split("|")
check("甲:状态 1(待初审)、红黄都是 0", st[0] == "1" and st[1] == "0" and st[2] == "0", f"{st}")
check("**名册行的提交状态跟着走到 2(待审)**——以前没人写过它,名册列表恒显示「未提交」",
st[3] == "2", f"submit_status={st[3]}")
# ── 2. 待办列表与范围隔离 ──────────────────────
print("\n[2] 待办列表:权限 ≠ 范围")
t1, t2 = teachers["t1"]["token"], teachers["t2"]["token"]
b1 = call(base, "GET", f"/api/v1/audit/todos?node=1&taskId={task_id}", t1)
d1 = b1.get("data") or {}
check("T1(被指定的初审人)看得到 2 份", d1.get("total") == 2, f"{b1}")
check("counts.all = 2", (d1.get("counts") or {}).get("all") == 2, f"{d1.get('counts')}")
check("counts.clean = 1(只有甲是绿色)",
(d1.get("counts") or {}).get("clean") == 1, f"{d1.get('counts')}")
check("counts.abnormal = 1(乙有黄色)",
(d1.get("counts") or {}).get("abnormal") == 1, f"{d1.get('counts')}")
rows1 = {x.get("submissionId"): x for x in (d1.get("list") or [])}
ca, cb = rows1.get(sid_a) or {}, rows1.get(sid_b) or {}
check("甲 batchApprovable=true(服务端判定,前端不许自己算)",
ca.get("batchApprovable") is True, f"{ca}")
check("乙 batchApprovable=false", cb.get("batchApprovable") is False, f"{cb}")
check("卡片带姓名/班级/材料数/缩略图",
ca.get("realName") and ca.get("orgName") and (ca.get("materialCount") or 0) >= 1
and len(ca.get("materialThumbs") or []) >= 1, f"{ca}")
check("甲带「学生改过」的字段(changedFields 非空)",
"school" in (ca.get("changedFields") or []), f"{ca.get('changedFields')}")
check("等待小时数是算出来的(0.x 小时)",
isinstance(ca.get("waitHours"), (int, float)) and ca.get("waitHours") < 1, f"{ca.get('waitHours')}")
b2 = call(base, "GET", f"/api/v1/audit/todos?node=1&taskId={task_id}", t2)
check("**T2(同样是班主任、但没被指定)→ 一份都看不到**",
(b2.get("data") or {}).get("total") == 0, f"{b2}")
b3 = call(base, "GET", f"/api/v1/audit/todos?node=2&taskId={task_id}", t1)
check("T1 没有终审权限 → 403(不是空列表)", b3.get("code") == 40301, f"{b3}")
# ── 3. 详情 ────────────────────────────────────
print("\n[3] 审核详情:材料预览 + 改动明细 + 时间轴 + 能不能通过")
dt = call(base, "GET", f"/api/v1/audit/submissions/{sid_a}", t1)
d = dt.get("data") or {}
check("T1 打得开", dt.get("code") == 0, f"{dt}")
dt2 = call(base, "GET", f"/api/v1/audit/submissions/{sid_a}", t2)
check("**T2 打不开同一份(403)**", dt2.get("code") == 40301, f"{dt2}")
check("姓名与脱敏证件号(不给完整号)",
d.get("realName") == "审核甲" and "****" in (d.get("idCardMask") or ""), f"{d.get('realName')}/{d.get('idCardMask')}")
mats = d.get("materials") or []
idcard = next((m for m in mats if m.get("materialCode") == "IDCARD"), None)
check("材料按定义分组(缺的那项也会出现,不是只看传了什么)",
idcard is not None and idcard.get("done") is True, f"{mats}")
files = (idcard or {}).get("files") or []
check("文件带**预览链接**(七牛签名 URL 或本地走审核端下载通道,都行)",
bool(files) and ("token=" in (files[0].get("previewUrl") or "")
or "/api/v1/audit/files/" in (files[0].get("previewUrl") or "")),
f"{files}")
cd = d.get("changedDetail") or []
check("改动明细说得出「老师填的 vs 学生改的」",
len(cd) == 1 and cd[0].get("field") == "school"
and cd[0].get("rosterValue") == "示范职业学校"
and cd[0].get("studentValue") == "我校(学生改过)", f"{cd}")
check("approvePolicy.canApprove = true(无红)",
(d.get("approvePolicy") or {}).get("canApprove") is True, f"{d.get('approvePolicy')}")
check("approvePolicy.canBatch = true(无红无黄)",
(d.get("approvePolicy") or {}).get("canBatch") is True, f"{d.get('approvePolicy')}")
check("revokePolicy:还没有可撤回的结论",
(d.get("revokePolicy") or {}).get("canRevoke") is False, f"{d.get('revokePolicy')}")
tl = d.get("timeline") or []
check("时间轴是**倒序**:最近的在最前,最早的一格是「学生提交」",
tl and tl[-1].get("node") == 0 and tl[-1].get("nodeText") == "学生提交", f"{tl}")
db = call(base, "GET", f"/api/v1/audit/submissions/{sid_b}", t1)
check("乙的 canBatch = false(有黄色)",
((db.get("data") or {}).get("approvePolicy") or {}).get("canBatch") is False,
f"{(db.get('data') or {}).get('approvePolicy')}")
# ── 4. 红问题不许通过(现场重算)───────────────
print("\n[4] 有红问题不许通过 —— 而且必须**现场重算**,不是提交时的快照")
sql(f"UPDATE collect_submission SET school=NULL WHERE id={sid_a}") # VAL-16:在校生缺在读学校
ra = call(base, "POST", f"/api/v1/audit/submissions/{sid_a}/approve", t1,
{"actionSource": "BUTTON"})
check("**清空必填项后通过 → 40032**(若读快照会误判为可过)",
ra.get("code") == 40032, f"{ra}")
check("提示里说了是哪一类问题", "在读学校" in (ra.get("msg") or ""), f"{ra.get('msg')}")
d4b = call(base, "GET", f"/api/v1/audit/submissions/{sid_a}", t1)
d4 = d4b.get("data") or {}
check("详情里的红计数也变成 1(现场值与快照已对齐)",
d4.get("redCount") == 1, f"{d4b}")
check("canApprove 变成 false,并给出原因",
(d4.get("approvePolicy") or {}).get("canApprove") is False
and "红色" in ((d4.get("approvePolicy") or {}).get("reason") or ""),
f"{d4.get('approvePolicy')}")
check("**详情这一读顺手把快照回写了**(列表下次读到的是现场值)",
sql(f"SELECT red_count FROM collect_submission WHERE id={sid_a}") == "1",
sql(f"SELECT red_count FROM collect_submission WHERE id={sid_a}"))
sql(f"UPDATE collect_submission SET school='示范职业学校' WHERE id={sid_a}")
# ── 5. 初审通过 ────────────────────────────────
print("\n[5] 初审通过:1 → 2,并留痕")
ra = call(base, "POST", f"/api/v1/audit/submissions/{sid_a}/approve", t1,
{"actionSource": "SWIPE", "remark": "材料齐全"})
da = ra.get("data") or {}
check("通过成功", ra.get("code") == 0, f"{ra}")
check("状态 → 2 且说明下一步是「等待管理员终审」",
da.get("status") == 2 and da.get("nextNode") == "ADMIN_AUDIT", f"{da}")
check("replayed=false(这是真审了一次)", da.get("replayed") is False, f"{da}")
sql_status = sql(f"SELECT status || '|' || (class_audit_by IS NOT NULL)::text || '|' || "
f"(class_audit_time IS NOT NULL)::text FROM collect_submission WHERE id={sid_a}")
check("库里:状态 2 + 初审人/时间都写了", sql_status == "2|true|true", f"{sql_status}")
check("审核记录写了一条(audit_node=CLASS_AUDIT, action=1)",
sql(f"SELECT audit_node || '|' || action || '|' || auditor_id FROM collect_audit_record "
f"WHERE submission_id={sid_a} ORDER BY id DESC LIMIT 1")
== f"CLASS_AUDIT|1|{teachers['t1']['id']}",
sql(f"SELECT audit_node || '|' || action FROM collect_audit_record WHERE submission_id={sid_a}"))
ra2 = call(base, "POST", f"/api/v1/audit/submissions/{sid_a}/approve", t1, {})
check("**重复点通过是幂等的**(返回 replayed=true,不报错、不重复写记录)",
ra2.get("code") == 0 and (ra2.get("data") or {}).get("replayed") is True, f"{ra2}")
check("幂等回放没有多写一条记录",
sql(f"SELECT count(*) FROM collect_audit_record WHERE submission_id={sid_a}") == "1",
sql(f"SELECT count(*) FROM collect_audit_record WHERE submission_id={sid_a}"))
# ── 6. 终审 ────────────────────────────────────
print("\n[6] 终审:2 → 3")
bt = call(base, "GET", f"/api/v1/audit/todos?node=2&taskId={task_id}", admin)
check("管理员终审列表看到 1 份", (bt.get("data") or {}).get("total") == 1, f"{bt}")
rfin = call(base, "POST", f"/api/v1/audit/submissions/{sid_a}/approve", admin,
{"finalOpinion": "同意认定", "needArchive": False})
check("终审通过 → 3", (rfin.get("data") or {}).get("status") == 3, f"{rfin}")
st = sql(f"SELECT status || '|' || (admin_audit_by IS NOT NULL)::text FROM collect_submission "
f"WHERE id={sid_a}")
check("库里状态 3 + 终审人已写", st.startswith("3|"), f"{st}")
check("名册行 → 3(已通过)",
sql(f"SELECT submit_status FROM collect_roster WHERE id={rid_a}") == "3",
sql(f"SELECT submit_status FROM collect_roster WHERE id={rid_a}"))
check("库里有 2 条审核记录(初审 + 终审);时间轴上的「学生提交」是合成条目",
sql(f"SELECT count(*) FROM collect_audit_record WHERE submission_id={sid_a}") == "2",
sql(f"SELECT count(*) FROM collect_audit_record WHERE submission_id={sid_a}"))
# ── 7. 批量通过:只带绿色项 ────────────────────
print("\n[7] 批量通过:红黄一律排除(铁律)")
# ① 乙此刻是「有黄无红」→ 必须被跳过,且要说清是黄色
rb = call(base, "POST", "/api/v1/audit/submissions/batch-approve", t1, {
"submissionIds": [sid_b], "onlyClean": False,
})
db_ = rb.get("data") or {}
check("onlyClean 传 false 也不生效(服务端强制为 true)", rb.get("code") == 0, f"{rb}")
details = {x.get("submissionId"): x for x in (db_.get("details") or [])}
check("**有黄的单被跳过**:approved=0",
db_.get("approved") == 0 and db_.get("skipped") == 1, f"{db_}")
check("跳过原因说清了「黄色提醒需单独确认」(不是一句「跳过」)",
"黄色" in (details.get(sid_b, {}).get("reason") or ""), f"{details.get(sid_b)}")
# ② 把那份重复件停用 → 乙变成绿色 → 这次应该真的通过
sql(f"UPDATE collect_file SET is_current=0 WHERE submission_id={sid_b} AND id IN "
f"(SELECT id FROM collect_file WHERE submission_id={sid_b} ORDER BY id DESC LIMIT 1)")
rb2 = call(base, "POST", "/api/v1/audit/submissions/batch-approve", t1, {
"submissionIds": [sid_b], "onlyClean": True,
})
db2 = rb2.get("data") or {}
check("清掉重复件后:approved=1(乙过初审)", db2.get("approved") == 1, f"{rb2}")
check("批量通过记的是 action=3(批量通过),不是 1",
sql(f"SELECT action FROM collect_audit_record WHERE submission_id={sid_b} "
f"ORDER BY id DESC LIMIT 1") == "3",
sql(f"SELECT action FROM collect_audit_record WHERE submission_id={sid_b}"))
check("批量通过后状态 → 2",
sql(f"SELECT status FROM collect_submission WHERE id={sid_b}") == "2",
sql(f"SELECT status FROM collect_submission WHERE id={sid_b}"))
# ③ 把乙弄成红色 → 红色单同样必须被跳过
# ⚠️ 两个坑,都值得记:
# ① 先把状态放回 1:批量通过之后乙已经在"待终审"了,那时初审人再点它,
# 跳过原因会是「你没有终审权限」——正确但**不对题**,会掩盖这条用例要验的东西;
# ② 红问题要用**名册里没有的字段**来造(这里用「本职业工作年限」填 999 → VAL-17)。
# 一开始我用「在读学校」清空列,结果**根本不会触发**——
# 因为组装表单值时"学生没动过"的字段会**回退到名册值**(见 FillService.assemble),
# 提交单里那列是不是 null 并不重要。这正是"校验收敛到同一份取值口径"的体现。
sql(f"UPDATE collect_submission SET status=1, work_years=999 WHERE id={sid_b}")
rb3 = call(base, "POST", "/api/v1/audit/submissions/batch-approve", t1,
{"submissionIds": [sid_b]})
details3 = {x.get("submissionId"): x for x in ((rb3.get("data") or {}).get("details") or [])}
check("**有红的单被跳过**:approved=0",
(rb3.get("data") or {}).get("approved") == 0, f"{rb3}")
check("跳过原因说清了「存在红色问题,需单独审核」",
"红色" in (details3.get(sid_b, {}).get("reason") or ""), f"{details3.get(sid_b)}")
sql(f"UPDATE collect_submission SET work_years=5, status=2 WHERE id={sid_b}")
# ── 8. 驳回 ────────────────────────────────────
print("\n[8] 驳回:原因必填 + 升级 + 学生重交")
# 让乙回到待初审:管理员先驳回(终审驳回直接生效,可越过初审)
sql(f"UPDATE collect_submission SET status=2 WHERE id={sid_b}")
r_no = call(base, "POST", f"/api/v1/audit/submissions/{sid_b}/reject", admin, {})
check("不填原因 → 拒绝", r_no.get("code") != 0, f"{r_no}")
r_bad = call(base, "POST", f"/api/v1/audit/submissions/{sid_b}/reject", admin,
{"reasonCode": "R404"})
check("原因码不在字典里 → 拒绝", r_bad.get("code") != 0, f"{r_bad}")
r_999 = call(base, "POST", f"/api/v1/audit/submissions/{sid_b}/reject", admin,
{"reasonCode": "R999"})
check("R999 不填说明 → 拒绝(学生收到一条「其他」等于没收到)",
r_999.get("code") != 0, f"{r_999}")
rej = call(base, "POST", f"/api/v1/audit/submissions/{sid_b}/reject", admin,
{"reasonCode": "R002", "reasonText": "名册上是 2008 年", "materialCodes": ["IDCARD"]})
dj = rej.get("data") or {}
check("驳回成功 → 状态 4", dj.get("status") == 4, f"{rej}")
check("notifySent=false 且 needManualNotice=true(一期没有通知渠道,**如实说**)",
dj.get("notifySent") is False and dj.get("needManualNotice") is True, f"{dj}")
check("第 1 次驳回,escalated=false", dj.get("escalated") is False, f"{dj}")
st = sql(f"SELECT status || '|' || reject_count || '|' || last_reject_reason || '|' || "
f"(SELECT submit_status FROM collect_roster WHERE id={rid_b}) "
f"FROM collect_submission WHERE id={sid_b}")
check("库里:状态 4 + 次数 1 + 原因 JSON(含码与文案)",
st.startswith("4|1|") and "R002" in st and "名册上是 2008 年" in st, f"{st}")
check("名册行 → 4(已驳回)", st.rstrip().endswith("|4"), f"{st}")
check("驳回记录写了一条(action=2 + 原因码 + 材料项)",
sql(f"SELECT action || '|' || reason_code || '|' || material_codes "
f"FROM collect_audit_record WHERE submission_id={sid_b} ORDER BY id DESC LIMIT 1")
== "2|R002|IDCARD",
sql(f"SELECT action || '|' || reason_code FROM collect_audit_record WHERE submission_id={sid_b}"))
# 升级:把次数改成 2,再驳一次就是第 3 次
sql(f"UPDATE collect_submission SET status=2, reject_count=2 WHERE id={sid_b}")
rej2 = call(base, "POST", f"/api/v1/audit/submissions/{sid_b}/reject", admin,
{"reasonCode": "R005"})
check("第 3 次驳回 → escalated=true(达上限要升级给管理员)",
(rej2.get("data") or {}).get("escalated") is True, f"{rej2}")
# 学生重新提交 → 回到待初审,名册状态也要回来
sql(f"UPDATE collect_submission SET reject_count=3 WHERE id={sid_b}")
r_re = S.sub(tok_b, sid_b, True)
check("学生被驳回后可以重新提交", (r_re.get("data") or {}).get("submitted") is True, f"{r_re}")
st = sql(f"SELECT status || '|' || (SELECT submit_status FROM collect_roster WHERE id={rid_b}) "
f"FROM collect_submission WHERE id={sid_b}")
check("**状态回到 1,名册行回到 2(待审)**——不回退的话班主任会一直以为他还欠一份",
st == "1|2", f"{st}")
# ── 9. 撤回 ────────────────────────────────────
print("\n[9] 撤回:只能撤最近一次通过;已导出/已归档不可撤")
d9 = call(base, "GET", f"/api/v1/audit/submissions/{sid_a}", admin).get("data") or {}
rp = d9.get("revokePolicy") or {}
check("管理员看得到可撤回的结论(最近一条 = 终审那条)",
rp.get("canRevoke") is True and rp.get("auditRecordId"), f"{rp}")
admin_rec = rp.get("auditRecordId")
t2_rev = call(base, "POST", f"/api/v1/audit/submissions/{sid_a}/revoke", t2,
{"auditRecordId": admin_rec, "reason": "越权试试"})
check("T2(无撤回权限)→ 403", t2_rev.get("code") == 40301, f"{t2_rev}")
# 导出过 → 40033
sql(f"INSERT INTO collect_export_task (tenant_id, task_id, export_name, status, "
f"create_time) VALUES (1, {task_id}, 'E2E假导出', 2, now())")
r_exp = call(base, "POST", f"/api/v1/audit/submissions/{sid_a}/revoke", admin,
{"auditRecordId": admin_rec, "reason": "导出后还想撤"})
check("**已导出打包 → 40033**(已交付的数据不可追溯修改)",
r_exp.get("code") == 40033, f"{r_exp}")
sql(f"DELETE FROM collect_export_task WHERE task_id={task_id} AND export_name='E2E假导出'")
# 归档 → 40034
sql(f"UPDATE collect_task SET status=3 WHERE id={task_id}")
r_arc = call(base, "POST", f"/api/v1/audit/submissions/{sid_a}/revoke", admin,
{"auditRecordId": admin_rec, "reason": "归档后还想撤"})
check("已归档 → 40034", r_arc.get("code") == 40034, f"{r_arc}")
sql(f"UPDATE collect_task SET status=1 WHERE id={task_id}")
r_ok = call(base, "POST", f"/api/v1/audit/submissions/{sid_a}/revoke", admin,
{"auditRecordId": admin_rec, "reason": "发错了人,撤回重审"})
check("撤回终审结论 → 回到 2(待终审)",
(r_ok.get("data") or {}).get("status") == 2, f"{r_ok}")
check("撤回也留痕:多了一条 action=7(撤回)的记录",
sql(f"SELECT count(*) FROM collect_audit_record WHERE submission_id={sid_a} "
f"AND action=7") == "1",
sql(f"SELECT action FROM collect_audit_record WHERE submission_id={sid_a} ORDER BY id"))
check("时间轴里能看出「谁撤回了哪一条」",
"撤回记录 #" in sql(f"SELECT reason_text FROM collect_audit_record WHERE "
f"submission_id={sid_a} AND action=7"),
sql(f"SELECT reason_text FROM collect_audit_record WHERE submission_id={sid_a} AND action=7"))
# 撤回后再撤回同一条 → 已经是最新的那条被撤了,不能重复撤
r_twice = call(base, "POST", f"/api/v1/audit/submissions/{sid_a}/revoke", admin,
{"auditRecordId": admin_rec, "reason": "再撤一次"})
check("同一条不能撤两次(它已经不是「最近一次结论」了)",
r_twice.get("code") != 0, f"{r_twice}")
# ── 10. 字典与边界 ─────────────────────────────
print("\n[10] 驳回原因字典与边界")
rc = call(base, "GET", "/api/v1/audit/reason-codes", t1)
items = (rc.get("data") or [])
check("10 条原因码(PRD 附录 C)", len(items) == 10, f"{len(items)}")
check("R999 标了 requiresText=true",
any(x.get("code") == "R999" and x.get("requiresText") is True for x in items), f"{items[:2]}")
check("文案都是人话(不是 R001 这种码,也不是空)",
all(x.get("text") and x.get("text") != x.get("code") for x in items), f"{items}")
r_over = call(base, "POST", "/api/v1/audit/submissions/batch-approve", t1,
{"submissionIds": list(range(1, 300))})
check("批量超过 200 条 → 40011", r_over.get("code") == 40011, f"{r_over}")
r_none = call(base, "POST", "/api/v1/audit/submissions/batch-approve", t1,
{"submissionIds": []})
check("空列表 → 明确报错(不是静默成功)", r_none.get("code") != 0, f"{r_none}")
# 学生不该能碰审核接口
r_stu = S.call("GET", "/api/v1/audit/todos?node=1", tok_a)
check("学生拿自己的 token 调审核接口 → 40301", r_stu.get("code") == 40301, f"{r_stu}")
finally:
print("\n[11] 清理")
for tid in CREATED_TASK_IDS:
sql(f"DELETE FROM collect_audit_record WHERE task_id={tid}")
sql(f"DELETE FROM collect_file WHERE task_id={tid}")
sql(f"DELETE FROM collect_submission WHERE task_id={tid}")
sql(f"DELETE FROM collect_roster WHERE task_id={tid}")
sql(f"DELETE FROM collect_material_def WHERE task_id={tid}")
sql(f"DELETE FROM collect_task_org WHERE task_id={tid}")
sql(f"DELETE FROM collect_export_task WHERE task_id={tid}")
sql(f"DELETE FROM collect_task WHERE id={tid}")
for uid in CREATED_USER_IDS:
sql(f"DELETE FROM sys_user_role WHERE user_id={uid}")
sql(f"DELETE FROM sys_user WHERE id={uid}")
for p in TMP_FILES:
try:
os.remove(p)
except OSError:
pass
left = sql(f"SELECT count(*) FROM collect_task WHERE task_name LIKE 'E2E审核%'")
ok(f"已清理(残留任务 {left} 个)")
print("\n" + "=" * 76)
print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}")
for n, dd in FAILED:
print(f" · {n}\n {dd}")
print("=" * 76)
return 1 if FAILED else 0
if __name__ == "__main__":
raise SystemExit(main())