# -*- coding: utf-8 -*- """ 已归档任务可删(§17.2 D-9 / F-14,2026-09-30 客户确认)的 E2E。 ## 背景 原口径"已发布的任务删不掉也改不了名(40001)"—— 于是演示/测试任务只能改名挪开, 任务列表只增不减。现在:**草稿直接删;已归档的显式删(级联软删 + 尽力删文件 + 留痕); 收集中/已截止仍然拒绝**(归档是"最后的后悔药")。 ## 本脚本验什么 1. 收集中 → 删被拒,提示要说清"先归档"(不是一句笼统的 40001); 2. 已截止 → 仍然拒(活数据不提供删除口子); 3. 已归档 → 删成功:**子表级联物理删净**(名册/材料/覆盖班级/审核记录——大头清掉), **任务行本身软删**(占住 `task_code` 不复用:编码是文件目录名,重号会混两批文件), 且 `sys_operation_log` 有一条 `DELETE_TASK`; 4. **删除后新建任务不重号**(编码不复用 —— 第一版做成物理删时,就是这条被 `e2e-m1-task` 的"编码递增"断言抓到的); 5. 删完再删 → 明确报错(不存在),不是 500; 6. 草稿 → 直接删(回归原行为)。 跑法:python scripts/e2e-m1-task-delete.py(后端 8080 + xt-pg 在跑) """ 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__))) BASE = "http://localhost:8080" SUFFIX = os.urandom(3).hex() TMP_ROSTER = os.path.join(ROOT, "scripts", "_e2e-roster-del-%s.xlsx" % SUFFIX) PASSED = [] FAILED = [] CREATED = [] 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") 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 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, "中专", "1380013770%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 main(): argparse.ArgumentParser(description="已归档任务删除 E2E").parse_args() print("=" * 74) print(" 已归档任务删除(§17.2 D-9 / F-14)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"] # ── 0. 夹具:一个已发布的任务(材料 + 班级 + 名册 2 人)──────── print("\n[0] 摆夹具") _, b = R.call("POST", BASE + "/api/v1/tasks", at, { "taskName": "E2E删除 %s" % SUFFIX, "batchNo": "202609", "majorName": "数控加工", "collectMode": 1, "certType": 1, "startTime": "2026-01-01", "endTime": "2099-12-31" }) # ⚠️ 每一步都查 code:夹具不成立时越早报清楚,越好排查(F-96/F-99 的教训) if b.get("code") != 0: print(" x 建任务失败:" + json.dumps(b, ensure_ascii=False)[:200]) return 2 tid = b["data"]["id"] CREATED.append(tid) _, b = R.call("GET", BASE + "/api/v1/orgs/tree", at) classes = [] def walk(nodes): # 班级(orgType=3)在树的**下层**,只扫顶层会一个都找不到 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] body_of("PUT", "/api/v1/tasks/%d/orgs" % tid, at, {"orgs": [{"orgId": org["id"], "expectedCount": 2}]}) body_of("POST", "/api/v1/tasks/%d/materials/apply-template" % tid, at, {"templateCode": "TPL_SCHOOL_ROLL"}) build_roster([("删除甲", R.with_checksum("36102920080909031")), ("删除乙", R.with_checksum("36102920080909032"))]) body = R.upload_roster(BASE, at, tid, TMP_ROSTER, extra={"dryRun": "false", "mode": "APPEND", "orgId": org["id"]}) check("夹具:名册 2 人导入", body.get("code") == 0 and (body.get("data") or {}).get("created") == 2, json.dumps(body, ensure_ascii=False)[:200]) body_of("POST", "/api/v1/tasks/%d/publish" % tid, at, {"force": True}) st = sql("select status from collect_task where id=%d;" % tid).strip() check("夹具:任务已发布(status=1)", st == "1", "status=%s" % st) # ── 1. 收集中:删除被拒,且提示要说清怎么办 ───────────────── print("\n[1] 收集中 → 拒删") d1 = body_of("DELETE", "/api/v1/tasks/%d" % tid, at) check("收集中删除被拒", d1.get("code") != 0, json.dumps(d1, ensure_ascii=False)[:200]) check("⭐ 拒绝的话说清「先归档」(不是一句笼统的失败)", "归档" in (d1.get("msg") or ""), "msg=%s" % d1.get("msg")) check("任务还在(拒绝不是误删)", sql("select count(*) from collect_task where id=%d and deleted=0;" % tid).strip() == "1") # ── 2. 截止(status=2):仍然拒 ──────────────────────────── print("\n[2] 已截止 → 仍拒(活数据不给删除口子)") body_of("POST", "/api/v1/tasks/%d/close" % tid, at, {}) st2 = sql("select status from collect_task where id=%d;" % tid).strip() check("截止成功(status=2)", st2 == "2", "status=%s" % st2) d2 = body_of("DELETE", "/api/v1/tasks/%d" % tid, at) check("已截止仍然拒删", d2.get("code") != 0, json.dumps(d2, ensure_ascii=False)[:200]) # ── 3. 归档(status=3)→ 删除成功且级联 ──────────────────── print("\n[3] 已归档 → 删除成功(级联软删 + 留痕)") body_of("POST", "/api/v1/tasks/%d/archive" % tid, at, {}) st3 = sql("select status from collect_task where id=%d;" % tid).strip() check("归档成功(status=3)", st3 == "3", "status=%s" % st3) d3 = body_of("DELETE", "/api/v1/tasks/%d" % tid, at) check("删除成功", d3.get("code") == 0, json.dumps(d3, ensure_ascii=False)[:200]) # ★ 任务行**软删**而不是物理删:它要继续占住 task_code —— # maxSeqByCodePrefix 靠"含软删行的最大序号"保证编码不复用(编码是文件目录名)。 # (第一版做成了物理删,e2e-m1-task 的"编码递增"断言当场抓到重号 —— 这就是那条回归) check("⭐ 任务行软删(deleted=1,占住编码不复用;子表才是物理删)", sql("select deleted from collect_task where id=%d;" % tid).strip() == "1") check("⭐ 名册级联删净(含证件号密文的学生数据不留残留)", sql("select count(*) from collect_roster where task_id=%d;" % tid).strip() == "0", "残留=%s" % sql("select count(*) from collect_roster where task_id=%d;" % tid).strip()) check("⭐ 材料清单级联删净", sql("select count(*) from collect_material_def where task_id=%d;" % tid).strip() == "0") check("⭐ 覆盖班级级联删净", sql("select count(*) from collect_task_org where task_id=%d;" % tid).strip() == "0") check("⭐ 审核记录也一并删净(这些表没有 deleted 列,物理删;留痕在操作日志)", sql("select count(*) from collect_audit_record where task_id=%d;" % tid).strip() == "0") log_row = sql("select count(*) from sys_operation_log where action='DELETE_TASK' " "and target_id=%d;" % tid).strip() check("⭐ 留痕:sys_operation_log 有 DELETE_TASK(谁删的、删了多少,要答得出来)", log_row >= "1", "行数=%s" % log_row) # ── 3b. 编码不复用(删除腾出来的号不能发给下一个任务)──────── old_code = sql("select task_code from collect_task where id=%d;" % tid).strip() _, nb = R.call("POST", BASE + "/api/v1/tasks", at, { "taskName": "E2E删除后新建 %s" % SUFFIX, "batchNo": "202609", "majorName": "数控加工", "collectMode": 1, "certType": 1, "startTime": "2026-01-01", "endTime": "2026-12-31" }) new_code = ((nb.get("data") or {}).get("taskCode") or "") check("⭐ 删除后新建任务不重号(编码不复用 —— 它是文件目录名,重号会混两批文件)", nb.get("code") == 0 and new_code != "" and new_code != old_code, f"删的={old_code} 新的={new_code}") if (nb.get("data") or {}).get("id"): CREATED.append(nb["data"]["id"]) # ── 4. 删完再删 → 明确报错,不是 500 ────────────────────── print("\n[4] 删完再删") d4 = body_of("DELETE", "/api/v1/tasks/%d" % tid, at) check("再删 → 明确报错(不存在/已删)", d4.get("code") != 0, json.dumps(d4, ensure_ascii=False)[:200]) check("报的不是系统错误(50000)", d4.get("code") != 50000, "code=%s" % d4.get("code")) # ── 5. 草稿:直接删(回归原行为)───────────────────────── print("\n[5] 草稿 → 直接删") _, b5 = R.call("POST", BASE + "/api/v1/tasks", at, { "taskName": "E2E删除草稿 %s" % SUFFIX, "batchNo": "202609", "majorName": "数控加工", "collectMode": 1, "certType": 1, "startTime": "2026-01-01", "endTime": "2026-12-31" }) did = (b5.get("data") or {}).get("id") check("草稿建好", did is not None, json.dumps(b5, ensure_ascii=False)[:160]) d5 = body_of("DELETE", "/api/v1/tasks/%d" % did, at) check("草稿直接删成功", d5.get("code") == 0, json.dumps(d5, ensure_ascii=False)[:160]) cleanup() print("\n" + "=" * 74) print(" 通过 %d 失败 %d 共 %d" % (len(PASSED), len(FAILED), len(PASSED) + len(FAILED))) print("=" * 74) return 1 if FAILED else 0 def cleanup(): # 正常路径已经把任务删干净了;这里兜底失败/中断的残留 for tid in CREATED: for tbl in ("collect_validation_issue", "collect_audit_record", "collect_notify_record", "collect_file", "collect_submission", "collect_roster", "collect_task_org", "collect_material_def"): R.sql("delete from %s where task_id=%d;" % (tbl, tid)) R.sql("delete from collect_task where id=%d;" % tid) if CREATED: R.sql("delete from sys_operation_log where action='DELETE_TASK' " "and target_id in (%s);" % ",".join(str(t) for t in CREATED)) if os.path.exists(TMP_ROSTER): os.remove(TMP_ROSTER) print(" 已清理:任务与级联行 / 操作日志 / 临时 Excel") if __name__ == "__main__": raise SystemExit(main())