Files
xiaotutongxue/scripts/e2e-m7-taskconfig.py
T
lovife 30c3714d35 test(scripts): 36 个 E2E 与 6 个机械校验工具(1548 条断言)+ 一键交付自检
新增:check-api-parity(契约 121 接口 ↔ 控制器,自带「必须恰好 121」自检)、
check-entity-columns(基类要求的列 vs 迁移建表列,F-35 那个坑踩了四次之后上的工具)、
check-manual(手册引用的错误码与提示文案对得上代码)、check-test-profile(test profile 真起一次)、
e2e-prodguard、load-test(四场景、按端点分别统计)、verify-all(一条命令跑完交付自检)、
dev-seed-audit(界面验收用的演示数据),以及 M1~M9 各轮 E2E。

压测抓到一个真缺陷并修掉(F-92):门禁 IP 限流原来计「所有请求」,于是一个班连同一个
WiFi 时 60 个不同学生同时核验只过了 20 个 → 改为只计失败 + 单独的成功上限,20/60 → 60/60。

度量工具的偏差会伪装成产品缺陷(F-60/F-77/F-97),所以每个脚本都断言「动作自己的结论字段」
(submitted / created / canExport),而不是 HTTP 200 + code 0。

验证:verify-all.py --quick 5/5;--with-e2e 见 docs/09 §8。
2026-09-30 09:02:57 +08:00

588 lines
32 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])
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 / deputy / 班级负责人)
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.deputy_user_ids like '%%,%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, 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_task where id=%d;" % tid)
R.sql("delete from collect_rule_template where 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())