# -*- 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())