8f80744d63
一、规则模板的 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(旧口径断言更新为新口径)
257 lines
12 KiB
Python
257 lines
12 KiB
Python
# -*- 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())
|