Files
xiaotutongxue/scripts/e2e-m1-task-delete.py
lovife 8f80744d63 feat(task): 规则模板 FORM 真正可套用 + 已归档任务可删(客户确认的两项)
一、规则模板的 FORM 部分(F-57/F-64)—— 此前只能「跳过并说明」,等于装饰
  · FormSchemaService.saveRawVersion:用已存在的 schemaJson 存为新版本。
    与手工保存的区别:内容不再校验/归一化(它当初就是按本服务规则存出来的,
    再走一遍解析→序列化只会丢未建模字段);状态检查与版本管理保留 —— 版本化
    逻辑只有一份
  · RuleTemplateService 的 FORM 分支接上它(不再 skipped)
  · 顺带补上另一半:saveAsTemplate 此前不存表单(存模板不存、套模板用不上,
    两头各缺一半)—— 现在把源任务的当前表单一并带走;没存过 = null =
    套用时按「用系统默认,不覆盖」跳过

二、已归档任务可删(F-14 / §17.2 D-9)—— 此前「已发布即删不掉」,
   演示/测试任务只能改名挪开,库只增不减
  · DELETE /tasks/{id} 支持 status=3:收集中/已截止仍拒(归档是最后的后悔药),
    拒绝的话说清「先归档」
  · 子表级联物理删 14 张表(TaskCascadeMapper 集中放置,表清单来自
    information_schema 的事实核对)—— 大头是名册(证件号密文)、材料、审核记录
  · ★ 任务行本身**软删**:它必须占住 task_code —— maxSeqByCodePrefix 靠
    「含软删行的最大序号」保证编码永不复用(编码同时是文件目录名,重号会把
    两批任务的文件混进同一目录)。第一版做成了物理删,e2e-m1-task 的
    「编码递增」断言当场抓到重号 —— 这条回归现在被 e2e-m1-task-delete
    的「删除后新建不重号」断言守住
  · 物理文件尽力删(先取路径再删行,顺序反了文件就永远留在磁盘上;
    删不掉只记日志不阻断);全程留痕(sys_operation_log 记 DELETE_TASK +
    行数/文件数)

验证:e2e-m7-taskconfig → 75/75(新增 FORM 完整链路 7 条断言)
      e2e-m1-task-delete(新增)→ 20/20(拒删/删净/占号软删/留痕/再删报错/草稿直删)
      e2e-m1-task → 71/71(旧口径断言更新为新口径)
2026-09-30 12:38:47 +08:00

257 lines
12 KiB
Python
Raw Permalink 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 -*-
"""
已归档任务可删(§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())