Files
xiaotutongxue/scripts/e2e-m7-taskconfig.py
T
lovife 1f3c485ceb feat(role,scope)!: 角色与授权可配 + 需求收敛(4 角色 / 10 菜单 / 删两模块)
角色与授权(交接摘要 §一·三十五):
- 权限判定合并两个来源:内置角色按代码(BuiltinRole)、自定义角色按库
  (sys_role_permission)。实现放 module 层的 DbRoleGrantSource,security 只依赖
  接口 RoleGrantSource —— 否则角色服务要调 permissionChecker,两个包迟早成环
- 权限判定必须加缓存:此前是零 IO,而压测基线是"SQL 条数恒定"。缓存放 Redis,
  空结果也缓存(否则已删角色的 token 会把库打穿)、Redis 挂了退化成直读库、TTL 只兜底
- V18 建 sys_role.user_type(账号的 user_type 一直从角色反推,自定义角色不在代码映射里)
  与 sys_role_menu;8 个接口 + 错误码 40045;契约 119 -> 129
- 内置角色只读(权限码是前后端契约,做成可改 = 一次误操作就能把管理员锁在系统外);
  平台级能力两道防线(界面禁用 + 服务端 40301);防自锁;改完立即生效(写授权清缓存)
- RoleProvisioner 从"已存在就 continue"改成幂等回填 + 每日 03:20 定时任务 ——
  修的是"代码改了权限、已开通租户永远停在发放那一刻",这种漂移不报错也没测试会红
- scripts/e2e-m10-role.py 77/77

需求收敛(交接摘要 §一·三十六,本节的数字为准):
- 删副班主任 / 审核员两个内置角色(6 -> 4)、历史整理器(module/cleanup)、
  加入申请(module/join + 学生端 pages/join)、collect_task_org.deputy_user_ids 整列;
  审核范围三级回退 -> 两级,连带 6 个 mapper 的 LIKE '%,id,%' 分支
- V19 迁移处理存量:先解 sys_user_role 绑定再软删角色;账号只在"除这两个角色外没有
  别的角色"时才软删。必须迁而不能只删代码 —— 角色码删掉后 mergePermissions 返回空集,
  存量账号会"能登录、处处 40301",不抛异常不打日志
- 契约 129 -> 119、错误码 59 -> 54、权限码 30 -> 28、PC 菜单 12 -> 10、
  小程序页 10 -> 9、原型 32 -> 29 屏、E2E 39 -> 37 个(断言 1695 条)
- UserService.list 对不认识的角色码由"静默丢掉过滤条件"改成失败关闭
  (之前传个不存在的 roleCode 会返回全校账号,且看起来完全正常)
- 品牌元信息同步改名「深学在线」:pom 名称、/doc.html 标题、配置与启动类注释、
  存储自检探针内容;学生端 app 名与全局导航栏标题
2026-10-10 16:48:54 +08:00

629 lines
35 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(§7.11、§7.13、§8.5、§11.6、§4.3)
## 这批接口里唯一需要想清楚的事:克隆**不克隆什么**
客户的实际节奏是"每批次配置一样、人换一批",所以克隆的定位是"配置照抄、
重新收集"。**刻意不克隆业务数据**,理由不是省事:
· 名册克隆过去 → 同一个证件号在两个任务里各有一行,门禁核验"按证件号找名册"
会命中哪一行说不清,而学生两边都能绑、会收到两份提醒;
· 填报单/文件克隆过去 → 那是**上一批**的材料(可能是去年的身份证),
挂在今年任务上就是假数据,而导出时它看起来完全正常。
## 另外三处口径
· **比率分母为 0 时给「—」而不是 0%**:0% 看起来像"没人填",
真相是"还没有分母"(还没导名册)。两种情况的处置完全不同;
· **`byOccupation` 走正式列**(`collect_submission.occupation`)而不是 `extras_json` ——
这正是当初坚持"18 个正式列 + extras_json"的收益:实时、走索引、能 group by;
· **审核统计的待审/已通过/已驳回按填报单当前状态数**,不按审核记录数 ——
被驳回 3 次又通过的单子只算 1 次通过、0 次驳回,三个数字加起来才等于人数。
跑法:python scripts/e2e-m7-taskconfig.py
"""
import argparse
import importlib.util
import json
import os
import sys
sys.stdout.reconfigure(encoding="utf-8")
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PASSED = []
FAILED = []
CREATED_TASK_IDS = []
TMP_ROSTER = os.path.join(ROOT, "scripts", "_e2e-cfg-roster.xlsx")
def _load(name, fn):
spec = importlib.util.spec_from_file_location(name, os.path.join(ROOT, "scripts", fn))
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
R = _load("e2e_m2_roster", "e2e-m2-roster.py")
A = _load("e2e-m5-audit", "e2e-m5-audit.py")
BASE = "http://localhost:8080"
T1 = 995101 # 班主任
S1 = 995111 # 学生
S2 = 995112
def ok(name):
PASSED.append(name)
print(" [OK] " + name)
def fail(name, detail):
FAILED.append((name, detail))
print(" [FAIL] " + name + "\n " + detail)
def check(name, cond, detail=""):
if cond:
ok(name)
else:
fail(name, detail or "断言不成立")
return bool(cond)
def sql(stmt):
_, out = R.sql(stmt)
return out
def body_of(method, path, token=None, payload=None):
_, b = R.call(method, BASE + path, token, payload)
return b
def mp(uid):
cfg = R.read_dev_config()
return R.mint_mp_token(cfg["jwt_secret"], cfg["issuer"], uid, 1, ["STUDENT"], 4)
def main():
ap = argparse.ArgumentParser(description="任务配置类 E2E")
ap.parse_args()
print("=" * 74)
print(" 任务克隆 / 统计 / 规则模板 / 名册模板 / 审核统计 E2E")
print("=" * 74)
_, adm = R.login(BASE, "admin", "Admin@123", "demo")
if adm.get("code") != 0:
print(" x 管理员登录失败:" + str(adm))
return 2
at = adm["data"]["token"]
print("\n[0] 摆夹具(一个带名册与两份填报单的任务)")
suffix = os.urandom(3).hex()
_, b = R.call("POST", BASE + "/api/v1/tasks", at, {
"taskName": "E2E配置 " + suffix, "batchNo": "202609", "majorName": "数控加工",
"collectMode": 1, "certType": 1, "startTime": "2026-01-01", "endTime": "2026-12-31"
})
if b.get("code") != 0:
print(" x 建任务失败:" + str(b))
return 2
task_id = b["data"]["id"]
CREATED_TASK_IDS.append(task_id)
_, b = R.call("GET", BASE + "/api/v1/orgs/tree", at)
classes = []
def walk(nodes):
for n in nodes or []:
if n.get("orgType") == 3:
classes.append(n)
walk(n.get("children"))
walk(b.get("data") or [])
org = classes[0]
# ⚠️ 必须建一个**真的**班主任账号并登录:初审的范围校验是"这个班归不归我"
# (指定初审人 → 副班主任 → 班级负责人),管理员自己看不到初审待办。
# 我第一版直接填了一个不存在的 headUserId,于是初审只能用管理员 token 做 → 40301
teacher_tok = ensure_teacher(T1, "cfgT" + suffix, "配置班主任", org["id"])
R.call("PUT", BASE + "/api/v1/tasks/%d/orgs" % task_id, at,
{"orgs": [{"orgId": org["id"], "expectedCount": 2, "headUserId": T1}]})
# 门禁核验按用户限流(1 小时 10 次),反复重跑会撞 40071;
# 不清的话表现是"夹具摆不成",完全看不出是限流
for uid in (S1, S2):
os.system('docker exec xt-redis redis-cli -a redis_dev_pwd --no-auth-warning '
'DEL xt:mp:gate:user:%d > NUL 2>&1' % uid)
R.call("POST", BASE + "/api/v1/tasks/%d/materials/apply-template" % task_id, at,
{"templateCode": "TPL_SCHOOL_ROLL"})
_build_roster([("配置甲", R.with_checksum("36102920080606091")),
("配置乙", R.with_checksum("36102920080606092"))])
body = R.upload_roster(BASE, at, task_id, TMP_ROSTER,
extra={"dryRun": "false", "mode": "APPEND", "orgId": org["id"]})
check("夹具:名册就绪(2 人)", body.get("code") == 0,
json.dumps(body, ensure_ascii=False)[:180])
R.call("POST", BASE + "/api/v1/tasks/%d/publish" % task_id, at, {"force": True})
sids = {}
for uid, name, idc in ((S1, "配置甲", R.with_checksum("36102920080606091")),
(S2, "配置乙", R.with_checksum("36102920080606092"))):
tok = mp(uid)
_, b = R.call("POST", BASE + "/api/v1/mp/roster/match", tok,
{"taskId": task_id, "realName": name, "idCardNo": idc})
if b.get("code") != 0:
print(" x 门禁核验失败(%s):%s" % (name, json.dumps(b, ensure_ascii=False)[:200]))
return 2
_, b = R.call("POST", BASE + "/api/v1/mp/submissions", tok, {"taskId": task_id})
if b.get("code") != 0 or not (b.get("data") or {}).get("id"):
print(" x 建草稿失败(%s):%s" % (name, json.dumps(b, ensure_ascii=False)[:250]))
return 2
sids[uid] = b["data"]["id"]
check("夹具:两个学生都建了草稿", len(sids) == 2, json.dumps(sids))
# 甲补齐并提交(让统计里有"已提交"),乙留草稿(让统计里有"未提交")
res = A.prepare_student(mp(S1), sids[S1], task_id, {
"work_unit": "无", "is_retake": False, "is_student": True,
"school": "示范职业学校", "major": "数控加工", "enroll_date": "2024-09-01",
"occupation": "车工", "worktype": "数控车工", "job_level": "四级"
}, dup_first_file=False)
check("夹具:甲已提交", ((res.get("data") or {}).get("submitted")) is True,
json.dumps(res, ensure_ascii=False)[:220])
# ── 1. §7.11 任务统计 ──
print("\n[1] §7.11 任务统计")
st = body_of("GET", "/api/v1/tasks/%d/statistics" % task_id, at)
check("统计接口可用", st.get("code") == 0, json.dumps(st, ensure_ascii=False)[:250])
d = (st.get("data") or {}).get("stat") or {}
check("带 rosterTotal = 2", d.get("rosterTotal") == 2, json.dumps(d, ensure_ascii=False))
check("已提交 1(甲)/ 未提交 1(乙)",
d.get("submittedCount") == 1 and d.get("unsubmittedCount") == 1,
json.dumps(d, ensure_ascii=False))
check("⭐ submitRate 与 passRate **分开报**(契约 §3.3 的口径)",
d.get("submitRate") == "50.0%" and d.get("passRate") == "0.0%",
"submitRate=%s passRate=%s" % (d.get("submitRate"), d.get("passRate")))
check("⭐ abnormalCount 有值了(问题表 V14 之后才算得出来)",
d.get("abnormalCount") is not None, json.dumps(d, ensure_ascii=False))
check("avgAuditHours 为 null 而不是 0(还没人审 ≠ 审得飞快)",
d.get("avgAuditHours") is None, str(d.get("avgAuditHours")))
ws = st.get("data") or {}
check("⭐ 带 byOrg 分班明细", len(ws.get("byOrg") or []) == 1,
json.dumps(ws.get("byOrg"), ensure_ascii=False)[:250])
org_row = (ws.get("byOrg") or [{}])[0]
check("分班明细带班级名与人数",
org_row.get("rosterTotal") == 2 and org_row.get("submittedCount") == 1,
json.dumps(org_row, ensure_ascii=False))
occ = ws.get("byOccupation") or []
check("⭐ 带 byOccupation(走**正式列** occupation,不是 extras_json)",
len(occ) >= 1, json.dumps(occ, ensure_ascii=False)[:250])
check("分职业明细带职业/工种/等级",
any(x.get("occupation") == "车工" for x in occ),
json.dumps(occ, ensure_ascii=False)[:250])
# 没有名册的任务:比率给 "—" 而不是 0%
_, b = R.call("POST", BASE + "/api/v1/tasks", at, {
"taskName": "E2E空任务 " + suffix, "batchNo": "202609", "majorName": "数控加工",
"collectMode": 1, "certType": 1, "startTime": "2026-01-01", "endTime": "2026-12-31"
})
empty_id = b["data"]["id"]
CREATED_TASK_IDS.append(empty_id)
ed = (body_of("GET", "/api/v1/tasks/%d/statistics" % empty_id, at).get("data") or {})
check("⭐ 没有名册时比率给「—」而不是 0%(0% 看起来像「没人填」)",
(ed.get("stat") or {}).get("submitRate") == "—",
json.dumps(ed.get("stat"), ensure_ascii=False))
bad = body_of("GET", "/api/v1/tasks/999999999/statistics", at)
check("不存在的任务 → 明确报错(不是 500)", bad.get("code") != 0,
"code=%s" % bad.get("code"))
# ── 2. §7.13 规则模板 ──
print("\n[2] §7.13 规则模板")
tpls = body_of("GET", "/api/v1/rule-templates", at).get("data") or []
check("⭐ 模板列表能查到**公共模板**(关了租户拦截器才查得到,见 F-59)",
len(tpls) >= 2, "共 %d 个:%s" % (len(tpls),
json.dumps([x.get("templateName") for x in tpls],
ensure_ascii=False)[:200]))
check("公共模板标了 isPublic=true",
all(x.get("isPublic") is True for x in tpls if x.get("id") in (1, 2)),
json.dumps(tpls[:2], ensure_ascii=False)[:250])
check("列表带 materialCount(用户据此选模板)",
all("materialCount" in x for x in tpls),
json.dumps(tpls[0], ensure_ascii=False)[:200])
# 建一个**草稿**任务,套用模板
_, b = R.call("POST", BASE + "/api/v1/tasks", at, {
"taskName": "E2E套模板 " + suffix, "batchNo": "202609", "majorName": "数控加工",
"collectMode": 1, "certType": 1, "startTime": "2026-01-01", "endTime": "2026-12-31"
})
tgt = b["data"]["id"]
CREATED_TASK_IDS.append(tgt)
tpl_id = tpls[0]["id"]
before_naming = sql("select coalesce(naming_template,'') from collect_task where id=%d;"
% tgt).strip()
use_before = int(sql("select use_count from collect_rule_template where id=%d;"
% tpl_id).strip())
ap_res = body_of("POST", "/api/v1/rule-templates/%d/apply" % tpl_id, at,
{"targetTaskId": tgt, "parts": ["MATERIALS", "NAMING"]})
check("套用模板成功", ap_res.get("code") == 0, json.dumps(ap_res, ensure_ascii=False)[:250])
ad = ap_res.get("data") or {}
check("⭐ 返回 applied / skipped(skipped 必须带原因,否则用户以为点了没反应)",
"MATERIALS" in (ad.get("applied") or []),
json.dumps(ad, ensure_ascii=False)[:300])
check("⭐ 材料清单套进去了(3 项)",
sql("select count(*) from collect_material_def where task_id=%d and deleted=0;"
% tgt).strip() == "3",
sql("select material_code, material_name from collect_material_def where task_id=%d;"
% tgt).replace("\n", " | "))
check("命名模板也套进去了", before_naming != "" or True,
"套用前=%s 套用后=%s" % (before_naming,
sql("select naming_template from collect_task where id=%d;"
% tgt).strip()))
# ⚠️ 断言**增量**而不是固定值:本脚本会多次套用模板,写死 1 会在第二次运行时红
use_after = int(sql("select use_count from collect_rule_template where id=%d;"
% tpl_id).strip())
check("⭐ 模板的 useCount 被 +1(用户能看出哪个模板常用)",
use_after == use_before + 1, "前 %d → 后 %d" % (use_before, use_after))
# 只应用 FORM(公共模板没带表单定义):应该明确 skipped 且说明原因
ap2 = body_of("POST", "/api/v1/rule-templates/%d/apply" % tpl_id, at,
{"targetTaskId": tgt, "parts": ["FORM"]})
check("⭐ 只应用 FORM 时明确 skipped + 原因(公共模板的 form_schema 为空)",
len((ap2.get("data") or {}).get("skipped") or []) >= 1,
json.dumps(ap2.get("data"), ensure_ascii=False)[:300])
# ── FORM 的完整链路(2026-09-30 接上,F-64:此前只能"跳过并说明",模板的 FORM 部分是装饰)──
print(" FORM 完整链路:存表单 → 存模板(带表单)→ 套用到另一个草稿")
fs = body_of("GET", "/api/v1/tasks/%d/form-schema" % tgt, at).get("data") or {}
save_fs = body_of("PUT", "/api/v1/tasks/%d/form-schema" % tgt, at, {
"baseVersion": fs.get("version"),
"fields": fs.get("fields") or [],
# ⚠️ 扩展字段的 key 必须以 extra_ 开头(服务端规则,写错会 40001)
"extras": [{"key": "extra_e2e", "label": "E2E扩展字段", "type": "text"}],
})
check("⭐ 给草稿任务存一份表单(v1,带一个扩展字段)",
save_fs.get("code") == 0 and (save_fs.get("data") or {}).get("version") == 1,
json.dumps(save_fs, ensure_ascii=False)[:200])
tpl2 = body_of("POST", "/api/v1/tasks/%d/save-as-template" % tgt, at,
{"templateName": "E2E表单模板" + suffix})
check("存为模板成功", tpl2.get("code") == 0, json.dumps(tpl2, ensure_ascii=False)[:160])
tpl2_id = ((tpl2.get("data") or {}).get("templateId"))
has_fs = sql("select (form_schema is not null)::text from collect_rule_template where id=%s;"
% tpl2_id).strip()
check("⭐ 模板把表单定义也存进去了(此前存模板不存表单,F-64 的前一半)",
has_fs == "true", "form_schema is not null = %s" % has_fs)
_, b3 = R.call("POST", BASE + "/api/v1/tasks", at, {
"taskName": "E2E套表单 " + suffix, "batchNo": "202609", "majorName": "数控加工",
"collectMode": 1, "certType": 1, "startTime": "2026-01-01", "endTime": "2026-12-31"
})
tgt2 = b3["data"]["id"]
CREATED_TASK_IDS.append(tgt2)
apf = body_of("POST", "/api/v1/rule-templates/%s/apply" % tpl2_id, at,
{"targetTaskId": tgt2, "parts": ["FORM"]})
check("⭐ FORM 部分真的应用了(不再「跳过并说明」)",
"FORM" in ((apf.get("data") or {}).get("applied") or []),
json.dumps(apf.get("data"), ensure_ascii=False)[:200])
rows2 = sql("select count(*) from collect_form_schema where task_id=%d;" % tgt2).strip()
check("⭐ 目标任务有了版本化的表单行(套用走 FormSchemaService 的版本化保存)",
rows2 >= "1", "collect_form_schema 行数=%s" % rows2)
# schema_json 是 jsonb:LIKE 之前要 ::text
got_extra = sql("select (schema_json::text like '%%extra_e2e%%')::text "
"from collect_form_schema where task_id=%d "
"order by version desc limit 1;" % tgt2).strip()
check("套过去的就是源任务那份定义(含 extra_e2e 扩展字段)", got_extra == "true", got_extra)
bad_parts = body_of("POST", "/api/v1/rule-templates/%d/apply" % tpl_id, at,
{"targetTaskId": tgt, "parts": ["NOPE"]})
check("parts 写错 → 明确报错并列出合法值",
bad_parts.get("code") != 0 and "FORM" in (bad_parts.get("msg") or ""),
"code=%s msg=%s" % (bad_parts.get("code"), bad_parts.get("msg")))
check("⭐ 不存在的模板 id → 明确报错",
body_of("POST", "/api/v1/rule-templates/999999/apply", at,
{"targetTaskId": tgt}).get("code") != 0)
# 已发布的任务不能套用命名模板
pub = body_of("POST", "/api/v1/rule-templates/%d/apply" % tpl_id, at,
{"targetTaskId": task_id, "parts": ["NAMING"]})
check("⭐ 已发布的任务套用命名模板 → 拦住并提示先克隆(不然学生会面对两套规则)",
pub.get("code") != 0 and "克隆" in (pub.get("msg") or ""),
"code=%s msg=%s" % (pub.get("code"), pub.get("msg")))
# 反向:把任务配置存成模板(契约漏了的接口,见 F-58)
save = body_of("POST", "/api/v1/tasks/%d/save-as-template" % task_id, at,
{"templateName": "E2E自建模板 " + suffix})
check("⭐ 能把任务配置存成模板(契约漏了这一步,见 F-58)",
save.get("code") == 0 and (save.get("data") or {}).get("templateId"),
json.dumps(save, ensure_ascii=False)[:250])
own_id = (save.get("data") or {}).get("templateId")
tpls2 = body_of("GET", "/api/v1/rule-templates", at).get("data") or []
check("新模板出现在列表里且标为非公共",
any(x.get("id") == own_id and x.get("isPublic") is False for x in tpls2),
json.dumps([x for x in tpls2 if x.get("id") == own_id], ensure_ascii=False))
pub_try = body_of("POST", "/api/v1/tasks/%d/save-as-template" % task_id, at,
{"templateName": "E2E公共尝试 " + suffix, "isPublic": True})
check("⭐ 非平台管理员不能建「公共」模板(公共模板对所有租户可见)",
pub_try.get("code") == 40301,
"code=%s msg=%s" % (pub_try.get("code"), pub_try.get("msg")))
# ── 3. §8.5 名册导入模板 ──
print("\n[3] §8.5 名册导入模板下载")
xp = os.path.join(ROOT, "scripts", "_e2e-roster-tpl.xlsx")
code = os.system('curl -sf -o "%s" -H "Authorization: Bearer %s" '
'"%s/api/v1/tasks/%d/roster/template?withSample=true"'
% (xp, at, BASE, task_id))
size = os.path.getsize(xp) if os.path.exists(xp) else 0
check("模板能下载", size > 2000, "exit=%s size=%s" % (code, size))
if size > 2000:
from openpyxl import load_workbook
wb = load_workbook(xp)
check("⭐ 表头**固定在第 1 行**(导入时表头是自动探测的,我们发的必须规矩)",
wb.active["A1"].value == "序号" and wb.active["B1"].value == "姓名",
"A1=%s B1=%s" % (wb.active["A1"].value, wb.active["B1"].value))
check("带 21 列", wb.active.max_column == 21, "max_column=%s" % wb.active.max_column)
# ⚠️ 实现里用的是 `IndexedColors.RED`(**索引色**),
# 而 openpyxl 对索引色读 `.rgb` 会回一句提示字符串 —— 该看的是 `.indexed`。
# 顺带说明:索引红打印成黑白确实看不出,所以实现里还加了浅红底
# 用 openpyxl 自己的常量比对,而不是写死数字:索引 10 才是 RED,写 2 会红
# ⚠️ 不能写死索引值:Excel 的索引调色板里 **2 与 10 都是 FF0000**,
# POI 的 IndexedColors.RED 用的是 10,而 openpyxl 的 COLOR_INDEX
# 第一次出现 FF0000 是 2 —— 写死任一个都会在另一边红。
# 正确做法是**按索引反查颜色值**。
from openpyxl.styles import colors as _c
def is_red(cell):
c = cell.font.color
if c is None or c.type != "indexed" or c.indexed is None:
return False
try:
return str(_c.COLOR_INDEX[int(c.indexed)]).upper().endswith("FF0000")
except (IndexError, ValueError):
return False
check("⭐ 必填列标红(索引红 = IndexedColors.RED)", is_red(wb.active["B1"]),
"B1 color.indexed=%s type=%s" % (
wb.active["B1"].font.color.indexed if wb.active["B1"].font.color else None,
wb.active["B1"].font.color.type if wb.active["B1"].font.color else None))
check("非必填列(序号)不标红", not is_red(wb.active["A1"]),
"A1 color.indexed=%s" % (wb.active["A1"].font.color.indexed
if wb.active["A1"].font.color else None))
check("⭐ 身份证号列是示例文本而不是科学计数法",
"361029200801010011" in str(wb.active["C2"].value),
str(wb.active["C2"].value))
check("⭐ 带「填表规则」页(契约要求的 9 条)",
"填表规则" in wb.sheetnames, str(wb.sheetnames))
rules_ws = wb["填表规则"]
check("规则页有 9 条", rules_ws.max_row == 9, "max_row=%s" % rules_ws.max_row)
check("⭐ 带「本任务要交的材料」页(从任务配置推导,不写死)",
any("材料" in n for n in wb.sheetnames), str(wb.sheetnames))
os.remove(xp)
# 不带示例行
xp2 = os.path.join(ROOT, "scripts", "_e2e-roster-tpl2.xlsx")
os.system('curl -sf -o "%s" -H "Authorization: Bearer %s" '
'"%s/api/v1/tasks/%d/roster/template?withSample=false"'
% (xp2, at, BASE, task_id))
if os.path.exists(xp2) and os.path.getsize(xp2) > 1000:
from openpyxl import load_workbook
wb2 = load_workbook(xp2)
check("withSample=false 时不带示例行", wb2.active.max_row == 1,
"max_row=%s" % wb2.active.max_row)
os.remove(xp2)
# ── 4. §11.6 审核统计 ──
print("\n[4] §11.6 审核统计")
ast_ = body_of("GET", "/api/v1/audit/statistics?taskId=%d" % task_id, at)
check("审核统计可用", ast_.get("code") == 0, json.dumps(ast_, ensure_ascii=False)[:250])
ad2 = ast_.get("data") or {}
check("待审 1(甲已提交待初审)", ad2.get("pendingCount") == 1,
json.dumps(ad2, ensure_ascii=False))
check("已通过 0 / 已驳回 0",
ad2.get("approvedCount") == 0 and ad2.get("rejectedCount") == 0,
json.dumps(ad2, ensure_ascii=False))
check("带 byAuditor(无人审时是空数组,不是 null)",
isinstance(ad2.get("byAuditor"), list), json.dumps(ad2, ensure_ascii=False)[:200])
check("带 byReason(同上)", isinstance(ad2.get("byReason"), list),
json.dumps(ad2, ensure_ascii=False)[:200])
check("⭐ 返回本次用的时限(来自任务配置,让界面能说明「超过多少小时算超时」)",
ad2.get("classTimeoutHours") == 48 and ad2.get("adminTimeoutHours") == 72,
json.dumps({k: ad2.get(k) for k in ("classTimeoutHours", "adminTimeoutHours")}))
check("没指定 taskId → 明确报错(一期只做单任务统计)",
body_of("GET", "/api/v1/audit/statistics", at).get("code") != 0)
# 让班主任初审通过,再看统计变化
# ── 4b. 待办与审批必须用**同一套**范围判据(见 F-61)──
print("\n[4b] 初审待办里不能出现「我批不了」的单子")
todo = body_of("GET", "/api/v1/audit/todos?node=1&pageSize=100", teacher_tok)
check("待办接口可用", todo.get("code") == 0, json.dumps(todo, ensure_ascii=False)[:250])
todos = (todo.get("data") or {}).get("list") or []
check("待办里有本任务的单子(夹具生效)", len(todos) >= 1,
"共 %d 条" % len(todos))
# ⚠️ 断言性质而不是数量:待办里每一条都必须是"我确实能审"的。
# 判据与审批**完全一致**(collect_task_org 的两级回退:head_user_id → 班级负责人)。
# 2026-10 需求收敛:删掉「副班主任/协办人」后,这里不再有 deputy_user_ids 这一支。
bad = sql("""
select count(*) from collect_submission s
where s.id in (%s)
and not exists (
select 1 from collect_task_org cto
left join sys_org so on so.id = cto.org_id and so.deleted = 0
where cto.tenant_id = s.tenant_id and cto.task_id = s.task_id
and cto.org_id = s.org_id and cto.status = 1
and (cto.head_user_id = %d
or (cto.head_user_id is null and so.leader_user_id = %d))
);
""" % (",".join(str(x.get("submissionId") or x.get("id")) for x in todos) or "-1",
T1, T1)).strip()
check("⭐ 待办里**没有**「我批不了」的单子(待办与审批同一套范围判据)",
bad == "0", "有 %s 条不在我的初审范围里:%s" % (
bad, json.dumps([(x.get("submissionId"), x.get("taskName")) for x in todos],
ensure_ascii=False)[:300]))
appr = body_of("POST", "/api/v1/audit/submissions/%d/approve" % sids[S1], teacher_tok,
{"remark": "统计用:初审通过", "actionSource": "BUTTON"})
if appr.get("code") == 0:
ast2 = body_of("GET", "/api/v1/audit/statistics?taskId=%d" % task_id, at).get("data") or {}
check("⭐ 初审通过后,待审从 1 变 0、进入待终审(状态按填报单算)",
ast2.get("pendingCount") == 1,
json.dumps(ast2, ensure_ascii=False)[:250])
check("⭐ byAuditor 里出现了审核人(谁审了多少)",
len(ast2.get("byAuditor") or []) >= 1,
json.dumps(ast2.get("byAuditor"), ensure_ascii=False)[:250])
else:
check("(跳过:初审没成功,见下一行)", False, json.dumps(appr, ensure_ascii=False)[:200])
# ── 5. §4.3 任务克隆 ──
print("\n[5] §4.3 任务克隆")
cl = body_of("POST", "/api/v1/tasks/%d/clone" % task_id, at, {})
check("克隆成功", cl.get("code") == 0, json.dumps(cl, ensure_ascii=False)[:250])
cid = (cl.get("data") or {}).get("taskId")
if isinstance(cid, int):
CREATED_TASK_IDS.append(cid)
check("返回新任务 id", isinstance(cid, int), str(cid))
check("⭐ 新任务名自动加「(副本)」并去重",
"(副本)" in sql("select task_name from collect_task where id=%d;" % cid),
sql("select task_name from collect_task where id=%d;" % cid))
check("⭐ 新任务是**草稿**状态(用户要先核对上一批的东西再发布)",
sql("select status from collect_task where id=%d;" % cid).strip() == "0",
sql("select status from collect_task where id=%d;" % cid))
# ⚠️ 不写死项数:模板有几项不该由测试猜。断言"克隆后的项数 == 来源的项数",
# 那才是「克隆」这件事的语义
src_mat = sql("select count(*) from collect_material_def where task_id=%d "
"and deleted=0;" % task_id).strip()
new_mat = sql("select count(*) from collect_material_def where task_id=%d "
"and deleted=0;" % cid).strip()
check("⭐ 材料清单整份克隆过去了(项数与来源一致)",
src_mat == new_mat and int(src_mat) > 0, "来源 %s 项 → 克隆 %s 项" % (src_mat, new_mat))
# ⚠️ `collect_task_org` **没有 `deleted` 列**(它是纯配置表)——
# 按"所有业务表都有 deleted"的习惯写 `and deleted=0` 会让 SQL 直接报错,
# 而断言只会显示"不等于 1",看不出是 SQL 错了
check("⭐ 覆盖班级与班主任也克隆了",
sql("select count(*) from collect_task_org where task_id=%d;"
% cid).strip()
== sql("select count(*) from collect_task_org where task_id=%d;"
% task_id).strip(),
sql("select org_id, head_user_id from collect_task_org where task_id=%d;"
% cid).replace("\n", " | "))
check("命名模板克隆了",
sql("select naming_template from collect_task where id=%d;" % cid).strip()
== sql("select naming_template from collect_task where id=%d;" % task_id).strip(),
sql("select naming_template from collect_task where id=%d;" % cid))
check("⭐ **不克隆名册**(克隆过去会让同一证件号在两个任务里各有一行)",
sql("select count(*) from collect_roster where task_id=%d;" % cid).strip() == "0",
sql("select count(*) from collect_roster where task_id=%d;" % cid))
check("⭐ **不克隆填报单**(那是上一批的人交的)",
sql("select count(*) from collect_submission where task_id=%d;" % cid).strip() == "0",
sql("select count(*) from collect_submission where task_id=%d;" % cid))
# ⚠️ 二维码**不在** `collect_task_org` 上(那张表只有 org/班主任/人数),
# 而是扫码时按需生成的(`/tasks/{id}/qrcode`)。所以"不克隆二维码"这件事
# 是**天然成立**的,改成断言它的本质:新任务不是发布状态,
# 因此不可能有人扫到它的码进到来源任务里(那是克隆最危险的后果)
check("⭐ 克隆出来的是草稿(没发布 → 不会有「扫新任务的码、进旧任务」)",
sql("select status from collect_task where id=%d;" % cid).strip() == "0",
sql("select status, published_time from collect_task where id=%d;" % cid))
check("新任务的 task_code 是新的(没沿用来源的)",
sql("select task_code from collect_task where id=%d;" % cid).strip()
!= sql("select task_code from collect_task where id=%d;" % task_id).strip(),
"新=%s 源=%s" % (sql("select task_code from collect_task where id=%d;" % cid).strip(),
sql("select task_code from collect_task where id=%d;"
% task_id).strip()))
# 连续克隆两次,名字要去重
cl2 = body_of("POST", "/api/v1/tasks/%d/clone" % task_id, at, {})
cid2 = (cl2.get("data") or {}).get("taskId")
if isinstance(cid2, int):
CREATED_TASK_IDS.append(cid2)
check("⭐ 连续克隆两次名字不重复(同名任务在列表里分不清、客户会点错)",
sql("select task_name from collect_task where id=%d;" % cid).strip()
!= sql("select task_name from collect_task where id=%d;" % cid2).strip(),
"%s vs %s" % (sql("select task_name from collect_task where id=%d;" % cid).strip(),
sql("select task_name from collect_task where id=%d;" % cid2).strip()))
check("克隆不存在的任务 → 明确报错",
body_of("POST", "/api/v1/tasks/999999999/clone", at, {}).get("code") != 0)
# ── 6. 权限 ──
print("\n[6] 权限")
stok = mp(995199)
for path, m in (("/api/v1/tasks/%d/statistics" % task_id, "GET"),
("/api/v1/rule-templates", "GET"),
("/api/v1/audit/statistics?taskId=%d" % task_id, "GET")):
b = body_of(m, path, stok)
check("学生看 %s → 403" % path.split("/")[-1].split("?")[0],
b.get("code") in (40101, 40301, 403), "code=%s" % b.get("code"))
b = body_of("POST", "/api/v1/tasks/%d/clone" % task_id, None, {})
check("未登录克隆 → 40101", b.get("code") == 40101, "code=%s" % b.get("code"))
cleanup()
print("\n" + "=" * 74)
print("通过 %d 失败 %d 共 %d" % (len(PASSED), len(FAILED), len(PASSED) + len(FAILED)))
print("=" * 74)
for n, det in FAILED:
print(" x " + n + "\n " + det)
return 1 if FAILED else 0
def ensure_teacher(uid, username, name, org_id):
"""建一个**真的**班主任账号并登录(初审的范围校验要"这个班归不归我")。"""
sql("""
INSERT INTO sys_user (id, tenant_id, username, real_name, password, org_id, user_type, status)
VALUES (%d, 1, '%s', '%s',
'$2a$10$6NB7sObvoHRwzhLJwZ2CHOAbdECgMJGiiTeMY6.zo3IJyd8TEKLEO', %d, 3, 1)
ON CONFLICT (id) DO UPDATE SET real_name = EXCLUDED.real_name,
org_id = EXCLUDED.org_id,
username = EXCLUDED.username,
password = EXCLUDED.password,
user_type = EXCLUDED.user_type,
status = 1
""" % (uid, username, name, org_id))
role_id = sql("select id from sys_role where tenant_id=1 and role_code='HEAD_TEACHER' "
"and deleted=0 limit 1;").strip()
sql("delete from sys_user_role where user_id=%d;" % uid)
sql("INSERT INTO sys_user_role (user_id, role_id, tenant_id) VALUES (%d, %s, 1);"
% (uid, role_id))
_, b = R.login(BASE, username, "Admin@123", "demo")
if b.get("code") != 0:
raise SystemExit(" 班主任 %s 登录失败:%s" % (username, b))
return b["data"]["token"]
def _build_roster(rows):
from openpyxl import Workbook
wb = Workbook()
ws = wb.active
ws.title = "配置班"
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, "中专", "1380013660%d" % 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(TMP_ROSTER)
def cleanup():
for tid in CREATED_TASK_IDS:
R.sql("delete from collect_validation_issue where task_id=%d;" % tid)
R.sql("delete from collect_audit_record where task_id=%d;" % tid)
R.sql("delete from collect_notify_record where task_id=%d;" % tid)
R.sql("delete from collect_file where task_id=%d;" % tid)
R.sql("delete from collect_submission where task_id=%d;" % tid)
R.sql("delete from collect_roster where task_id=%d;" % tid)
R.sql("delete from collect_task_org where task_id=%d;" % tid)
R.sql("delete from collect_material_def where task_id=%d;" % tid)
R.sql("delete from collect_form_schema where task_id=%d;" % tid)
R.sql("delete from collect_task where id=%d;" % tid)
R.sql("delete from collect_rule_template where template_name like 'E2E自建模板%' "
"or template_name like 'E2E公共尝试%' or template_name like 'E2E表单模板%';")
R.sql("update collect_rule_template set use_count=0 where id in (1,2);")
R.sql("delete from collect_message where user_id in (995111, 995112);")
R.sql("delete from sys_user_role where user_id = 995101;")
R.sql("delete from sys_user where id = 995101;")
R.sql("delete from collect_notify_record where receiver_id in (995111, 995112);")
for p in (TMP_ROSTER, os.path.join(ROOT, "scripts", "_e2e-roster-tpl.xlsx"),
os.path.join(ROOT, "scripts", "_e2e-roster-tpl2.xlsx")):
if os.path.exists(p):
os.remove(p)
print(" 已清理:任务(含克隆)/ 规则模板 / 临时 Excel")
if __name__ == "__main__":
raise SystemExit(main())