e85a25a97f
第 2 步。在此之前「配置 → 任务就绪」缺了**名册**那一环:班级能配、材料能配、
表单能配,但名册只能靠 SQL 造,于是任务在界面上**根本发布不了**(40016 名册非空)。
一、新增(frontend-pc/)
src/apis/xt/roster.ts 导入(multipart)+ 分页 + 类型 + 状态字典
src/views/task/TaskRosterDrawer.vue 「名单管理」抽屉:导入 → 体检报告 → 名册列表
views/task/index.vue 加「名单管理」入口;已截止/已归档只读仍可查看
config/xt-routes.ts 顶层「名单管理」菜单说明改为指向任务行
二、★ 验证了"发布真的通"(e2e-m2-roster.py 第 8 节)
配覆盖班级 → 配必传材料 → **发布成功** → 状态变「收集中」→ stat 仍带分母。
force=true 只跳过 P1 提醒,那 5 项阻断级校验 force 也绕不过 —— 所以是真链路。
三、界面上五个刻意的设计
1. 先体检再导入(两步):实测表头在第 1 行或第 6 行,一步导入只会看到
"成功 0 条",然后以为文件坏了
2. 明说「前面那 5 行是标题与说明,已跳过,这不是导错了」—— 不解释的话
用户会去改自己那份本来正确的表格
3. 行号 + 列字母 + 原值 + 原因四样一起给,用户拿着就能回去改 Excel
4. dryRun 默认 true(服务端也默认 true):不显式要求就不写库
5. 阻断行与警告行分两张表:混在一起会让人以为整批都没进
原因后面跟**机器码**而不是中文标签(第一版给中文,和上面那句原因重复了一遍)
四、顺带修掉一处「界面在说谎」
名册导进来了,任务列表却还显示「待导入名册」—— TaskStatVO 恒为 null 是 M1 的
既有缺口(注释写着"名册为空时返回 null,由界面显示待导入名册")。现在名册有了:
· TaskService.fillStats():一次算一页(两条批量查询,不逐任务查)
· **名册为空时仍返回 null**,口径没有放宽 —— 显示 0% 看起来像"没人填",
而真相是"还没有分母"
· abnormalCount / avgAuditHours 仍为 null(要 M4 的校验表 / M5 的审核记录)
· e2e-m1-task 那条断言改成措辞更准的"名册为空时 stat 为 null"(意图不变),
并在 e2e-m2-roster 补 4 条"名册有值后 stat 该有值"
这条也是第 2 步**自己制造**的问题:能导名册之后,"待导入名册"从诚实变成说谎。
五、为什么顶层「名单管理」菜单还是"待建"
名册是**按任务**的,独立页得先选任务(多一步且容易选错);它和「配置班级」
「材料规则」「表单设计」是同一层的配置。报考范围确认(M2-04/05)会接着做进
同一个抽屉,那时也不需要新页面。所以改的是那句已过期的"对应里程碑 M2"。
六、浏览器验证(真链路,只绕开"手指点选")
文件框没法自动化,但可以把夹具放到 frontend-pc/public/ 再喂进去:
fetch 字节 → new File → DataTransfer → input.files → dispatch change
实测:3 人被正确导入、脱敏证件号、报考三项、扩展字段、状态列、搜索全对。
夹具用完即删。
七、回归
全量 622 条断言、0 失败(名册 E2E 62 → 67)
frontend-pc: vue-tsc 0 错、eslint 目标文件 0 错
verify-docs.py 19 项全通过
八、两个流程教训(已写进交接摘要)
· `Test-Path jar` 不能当作"打包成功":这次 mvn 失败而旧 jar 还在,
于是检查得到"打包 ✓",测试对着旧 jar 跑,四条新断言全红。
必须看退出码 / BUILD SUCCESS。
· `call()` 返回的是 (status, body) 元组不是字典 —— 我新写的那节忘了解包。
与第 1 步"已存在判断写反"同类:新写的代码最容易在细节上错。
九、下一步(第 3 步)
报考范围聚合与一致性体检(M2-04/05)—— 产品的核心增量。
先确认:sys_eval_plan 那几张"评价计划"表还没落地(FR-311 未做),
需要先定"计划从哪来"。
570 lines
29 KiB
Python
570 lines
29 KiB
Python
#!/usr/bin/env python
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
M1-01 收集任务(CRUD + 状态机)验收测试。
|
||
|
||
覆盖《03-接口设计》§7.1~§7.5:
|
||
[x] 建任务:默认值、编码生成、一期范围限制(40044)
|
||
[x] 改任务:草稿可改模式,已发布不可改
|
||
[x] 状态机:草稿→发布→暂停⇄恢复→截止→归档,幂等与错误码
|
||
[x] 发布前置校验:5 项全部阻断,force 无法绕过
|
||
[x] 可见范围:班主任只看自己带的班级的任务
|
||
[x] 删除:仅草稿
|
||
|
||
⚠️ 关于「发布」的可达性
|
||
发布要求名册非空 + ≥1 项必传材料 + 已设置覆盖班级,而这三样分别属于 M2 / M1-04 / M1-02。
|
||
所以本脚本用**直接写库**的方式造前置数据(这是测试夹具,不是产品捷径):
|
||
只有这样才能真正走到 已发布/暂停/恢复/截止/归档 那几个状态并验证它们。
|
||
|
||
用法
|
||
python scripts/e2e-m1-task.py
|
||
python scripts/e2e-m1-task.py --base http://localhost:8080
|
||
|
||
依赖:cryptography;且需要能 `docker exec xt-pg`(写测试夹具)
|
||
退出码:0 = 全部通过
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import base64
|
||
import json
|
||
import os
|
||
import subprocess
|
||
import sys
|
||
import urllib.error
|
||
import urllib.parse
|
||
import urllib.request
|
||
import uuid
|
||
|
||
if hasattr(sys.stdout, "reconfigure"):
|
||
sys.stdout.reconfigure(encoding="utf-8")
|
||
|
||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
PUB_KEY = os.path.join(ROOT, "deploy", "rsa-public-key-dev.pem")
|
||
|
||
PG_CONTAINER = os.environ.get("XT_PG_CONTAINER", "xt-pg")
|
||
PG_USER = os.environ.get("PG_USER", "fill")
|
||
PG_DB = os.environ.get("PG_DB", "filldb")
|
||
PG_PWD = os.environ.get("PG_PASSWORD", "fill_dev_pwd")
|
||
|
||
PASSED: list[str] = []
|
||
FAILED: list[tuple[str, str]] = []
|
||
|
||
CREATED_TASK_IDS: list[int] = []
|
||
CREATED_USER_IDS: list[int] = []
|
||
|
||
|
||
# ── 断言 ──────────────────────────────────────────────
|
||
|
||
def ok(name: str) -> None:
|
||
PASSED.append(name)
|
||
print(f" [OK] {name}")
|
||
|
||
|
||
def fail(name: str, detail: str) -> None:
|
||
FAILED.append((name, detail))
|
||
print(f" [FAIL] {name}\n {detail}")
|
||
|
||
|
||
def check(name: str, cond: bool, detail: str = "") -> bool:
|
||
if cond:
|
||
ok(name)
|
||
else:
|
||
fail(name, detail or "断言不成立")
|
||
return cond
|
||
|
||
|
||
def show(body: dict, indent: int = 9) -> None:
|
||
pad = " " * indent
|
||
text = json.dumps(body, ensure_ascii=False)
|
||
print(f"{pad}完整响应:{text[:400]}{'…' if len(text) > 400 else ''}")
|
||
|
||
|
||
# ── SQL(测试夹具)────────────────────────────────────
|
||
|
||
def sql(stmt: str) -> tuple[bool, str]:
|
||
"""直接执行 SQL —— 只用于造/清测试夹具。"""
|
||
try:
|
||
r = subprocess.run(
|
||
["docker", "exec", "-e", f"PGPASSWORD={PG_PWD}", PG_CONTAINER,
|
||
"psql", "-U", PG_USER, "-d", PG_DB, "-t", "-A", "-c", stmt],
|
||
capture_output=True, text=True, timeout=40)
|
||
return r.returncode == 0, (r.stdout or r.stderr).strip()
|
||
except Exception as e:
|
||
return False, str(e)
|
||
|
||
|
||
# ── HTTP ────────────────────────────────────────────
|
||
|
||
def rsa_encrypt(plain: str) -> str:
|
||
from cryptography.hazmat.primitives import serialization
|
||
from cryptography.hazmat.primitives.asymmetric import padding
|
||
with open(PUB_KEY, "rb") as f:
|
||
pub = serialization.load_pem_public_key(f.read())
|
||
return base64.b64encode(pub.encrypt(plain.encode(), padding.PKCS1v15())).decode()
|
||
|
||
|
||
def call(method: str, url: str, token: str | None = None,
|
||
body: dict | None = None, timeout: int = 30) -> tuple[int, dict]:
|
||
headers = {"Content-Type": "application/json; charset=utf-8"}
|
||
if token:
|
||
headers["Authorization"] = f"Bearer {token}"
|
||
data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None
|
||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||
try:
|
||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||
return resp.status, json.loads(resp.read().decode("utf-8"))
|
||
except urllib.error.HTTPError as e:
|
||
raw = e.read().decode("utf-8")
|
||
try:
|
||
return e.code, json.loads(raw)
|
||
except json.JSONDecodeError:
|
||
return e.code, {"raw": raw}
|
||
|
||
|
||
def login(base: str, username: str, password: str, tenant: str | None = None):
|
||
payload = {"username": username, "password": rsa_encrypt(password)}
|
||
if tenant:
|
||
payload["tenantCode"] = tenant
|
||
return call("POST", f"{base}/api/v1/auth/login", body=payload)
|
||
|
||
|
||
def main() -> int:
|
||
ap = argparse.ArgumentParser(description="M1-01 收集任务 E2E")
|
||
ap.add_argument("--base", default=os.environ.get("XT_BASE", "http://localhost:8080"))
|
||
ap.add_argument("--tenant", default=os.environ.get("XT_TENANT", "demo"))
|
||
ap.add_argument("--admin", default="admin")
|
||
ap.add_argument("--password", default="Admin@123")
|
||
args = ap.parse_args()
|
||
base = args.base.rstrip("/")
|
||
|
||
print("=" * 72)
|
||
print(f"M1-01 收集任务 E2E base={base} 租户={args.tenant}")
|
||
print("=" * 72)
|
||
|
||
suffix = uuid.uuid4().hex[:6]
|
||
|
||
# ── 0. 准备 ────────────────────────────────────────
|
||
print("\n[0] 准备")
|
||
status, body = login(base, args.admin, args.password, args.tenant)
|
||
if body.get("code") != 0:
|
||
fail("管理员登录", f"HTTP {status} code={body.get('code')} msg={body.get('msg')}")
|
||
return 1
|
||
token = body["data"]["token"]
|
||
tenant_id = (body["data"].get("tenant") or {}).get("id")
|
||
ok(f"管理员登录(租户 id={tenant_id})")
|
||
|
||
st, bd = call("GET", f"{base}/api/v1/orgs/tree", token)
|
||
def first_class(nodes):
|
||
for n in nodes or []:
|
||
if n.get("orgType") == 3:
|
||
return n
|
||
hit = first_class(n.get("children"))
|
||
if hit:
|
||
return hit
|
||
return None
|
||
cls = first_class(bd.get("data"))
|
||
if not cls:
|
||
fail("取一个班级组织", "demo 租户里没有 orgType=3 的组织,E2E 无法继续")
|
||
return 1
|
||
ok(f"取到班级组织:{cls['orgName']}(id={cls['id']})")
|
||
|
||
# ── 1. 建任务:校验与默认值 ─────────────────────────
|
||
print("\n[1] 建任务")
|
||
status, body = call("POST", f"{base}/api/v1/tasks", token, {
|
||
"taskName": f"E2E 任务 {suffix}", "collectMode": 2, "batchNo": "202609",
|
||
"majorName": "数控加工", "certType": 1,
|
||
"startTime": "2026-09-28", "endTime": "2026-10-15",
|
||
})
|
||
check("collectMode=2(社会收集)被拒 40044", body.get("code") == 40044,
|
||
f"code={body.get('code')} msg={body.get('msg')}")
|
||
|
||
status, body = call("POST", f"{base}/api/v1/tasks", token, {
|
||
"taskName": f"E2E 任务 {suffix}", "collectMode": 1, "batchNo": "202609",
|
||
"majorName": "数控加工", "certType": 3,
|
||
"startTime": "2026-09-28", "endTime": "2026-10-15",
|
||
})
|
||
check("certType=3(专项职业能力)被拒 40044", body.get("code") == 40044,
|
||
f"code={body.get('code')} msg={body.get('msg')}")
|
||
|
||
status, body = call("POST", f"{base}/api/v1/tasks", token, {
|
||
"taskName": f"E2E 任务 {suffix}", "collectMode": 1, "batchNo": "2026-09",
|
||
"majorName": "数控加工", "certType": 1,
|
||
"startTime": "2026-09-28", "endTime": "2026-10-15",
|
||
})
|
||
check("批次号格式非法被拒", body.get("code") != 0,
|
||
f"code={body.get('code')} msg={body.get('msg')}")
|
||
|
||
status, body = call("POST", f"{base}/api/v1/tasks", token, {
|
||
"taskName": f"E2E 任务 {suffix}", "collectMode": 1, "batchNo": "202609",
|
||
"majorName": "数控加工", "certType": 1,
|
||
"startTime": "2026-10-15", "endTime": "2026-09-28",
|
||
})
|
||
check("截止早于开始被拒", body.get("code") != 0,
|
||
f"code={body.get('code')} msg={body.get('msg')}")
|
||
|
||
# 命名模板:缺必备变量
|
||
status, body = call("POST", f"{base}/api/v1/tasks", token, {
|
||
"taskName": f"E2E 任务 {suffix}", "collectMode": 1, "batchNo": "202609",
|
||
"majorName": "数控加工", "certType": 1,
|
||
"startTime": "2026-09-28", "endTime": "2026-10-15",
|
||
"namingTemplate": "{姓名}-{材料名}",
|
||
})
|
||
check("命名模板缺 {证件号} 被拒 40020", body.get("code") == 40020,
|
||
f"code={body.get('code')} msg={body.get('msg')}")
|
||
|
||
# 命名模板:未知变量
|
||
status, body = call("POST", f"{base}/api/v1/tasks", token, {
|
||
"taskName": f"E2E 任务 {suffix}", "collectMode": 1, "batchNo": "202609",
|
||
"majorName": "数控加工", "certType": 1,
|
||
"startTime": "2026-09-28", "endTime": "2026-10-15",
|
||
"namingTemplate": "{姓名}-{身证号}-{材料名}",
|
||
})
|
||
check("命名模板含未知变量被拒 40020", body.get("code") == 40020,
|
||
f"code={body.get('code')} msg={body.get('msg')}")
|
||
|
||
batch = "20" + suffix[:4] if suffix[:4].isdigit() else "2099"
|
||
|
||
# 顺带验证 application.yml 的 spring.jackson.* 真的生效。
|
||
# Boot 4 把 Jackson 自动配置挪到了 spring-boot-starter-jackson,
|
||
# 没引它时这些配置是**静默失效**的:看着配了,其实一行都没读。
|
||
status, body = call("POST", f"{base}/api/v1/tasks", token, {
|
||
"taskName": f"E2E未知字段 {suffix}", "collectMode": 1, "batchNo": batch,
|
||
"majorName": "数控加工", "certType": 1,
|
||
"startTime": "2026-09-28", "endTime": "2026-10-15",
|
||
"thisFieldDoesNotExist": "fail-on-unknown-properties=false 生效时才不会报错",
|
||
})
|
||
check("请求带未知字段也能通过(spring.jackson 配置真的生效)",
|
||
body.get("code") == 0,
|
||
f"code={body.get('code')} msg={body.get('msg')}"
|
||
" —— 若为 40001/50000,说明 spring-boot-starter-jackson 没生效")
|
||
if body.get("code") == 0:
|
||
CREATED_TASK_IDS.append(body["data"]["id"])
|
||
|
||
# 合法创建
|
||
status, body = call("POST", f"{base}/api/v1/tasks", token, {
|
||
"taskName": f"E2E 任务 {suffix}", "collectMode": 1, "batchNo": batch,
|
||
"majorName": "数控加工", "certType": 1,
|
||
"startTime": "2026-09-28", "endTime": "2026-10-15",
|
||
})
|
||
if not check("建任务成功", body.get("code") == 0,
|
||
f"code={body.get('code')} msg={body.get('msg')}"):
|
||
show(body)
|
||
return 1
|
||
task_id = body["data"]["id"]
|
||
task_code = body["data"]["taskCode"]
|
||
CREATED_TASK_IDS.append(task_id)
|
||
print(f" → id={task_id} taskCode={task_code} status={body['data']['status']}")
|
||
check("返回的状态是草稿(0)", body["data"]["status"] == 0, f"status={body['data']['status']}")
|
||
check("任务编码 = 批次号 + 4 位序号",
|
||
task_code.startswith(batch) and len(task_code) == len(batch) + 4 and task_code[len(batch):].isdigit(),
|
||
f"batch={batch} taskCode={task_code}")
|
||
|
||
# ── 2. 详情与默认值 ─────────────────────────────────
|
||
print("\n[2] 详情与默认值")
|
||
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}", token)
|
||
d = body.get("data") or {}
|
||
ac = d.get("auditConfig") or {}
|
||
cc = d.get("compressConfig") or {}
|
||
check("rosterRequired 恒为 true", d.get("rosterRequired") is True,
|
||
f"rosterRequired={d.get('rosterRequired')}")
|
||
check("名册为空时 stat 为 null(没有分母,不能假装是 0%)",
|
||
d.get("stat") is None, f"stat={d.get('stat')}")
|
||
check("auditConfig 默认:需班主任初审 + 需终审",
|
||
ac.get("needClassAudit") is True and ac.get("needAdminAudit") is True,
|
||
f"auditConfig={ac}")
|
||
check("auditConfig 默认:48 / 72 / 3",
|
||
ac.get("classAuditTimeoutHours") == 48 and ac.get("adminAuditTimeoutHours") == 72
|
||
and ac.get("maxRejectCount") == 3,
|
||
f"auditConfig={ac}")
|
||
check("auditConfig:批量通过只带无问题项恒为 true",
|
||
ac.get("batchApproveOnlyGreen") is True, f"batchApproveOnlyGreen={ac.get('batchApproveOnlyGreen')}")
|
||
check("compressConfig 默认:1024KB / 300dpi / q85",
|
||
cc.get("defaultMaxSizeKb") == 1024 and cc.get("defaultTargetDpi") == 300
|
||
and cc.get("defaultQuality") == 85,
|
||
f"compressConfig={cc}")
|
||
check("compressConfig 默认:minQuality=70 / hardFloorDpi=200",
|
||
cc.get("minQuality") == 70 and cc.get("hardFloorDpi") == 200,
|
||
f"compressConfig={cc}")
|
||
check("compressConfig:截图保 PNG 恒为 true",
|
||
cc.get("keepPngForScreenshot") is True, f"keepPngForScreenshot={cc.get('keepPngForScreenshot')}")
|
||
|
||
# 铁律:批量通过只带无问题项不可关闭
|
||
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}", token, {
|
||
"taskName": f"E2E 任务 {suffix}", "collectMode": 1, "batchNo": batch,
|
||
"majorName": "数控加工改", "certType": 1,
|
||
"startTime": "2026-09-28", "endTime": "2026-10-20",
|
||
"auditConfig": {"batchApproveOnlyGreen": False, "needAdminAudit": False},
|
||
})
|
||
check("修改草稿任务成功", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}")
|
||
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}", token)
|
||
d = body.get("data") or {}
|
||
check("客户端传 batchApproveOnlyGreen=false 被服务端扳回 true(铁律不可关)",
|
||
(d.get("auditConfig") or {}).get("batchApproveOnlyGreen") is True,
|
||
f"batchApproveOnlyGreen={(d.get('auditConfig') or {}).get('batchApproveOnlyGreen')}")
|
||
check("needsAdminAudit=false 被采纳(它是可配的)",
|
||
(d.get("auditConfig") or {}).get("needAdminAudit") is False,
|
||
f"needAdminAudit={(d.get('auditConfig') or {}).get('needAdminAudit')}")
|
||
check("专业已改为「数控加工改」", d.get("majorName") == "数控加工改", f"majorName={d.get('majorName')}")
|
||
|
||
# ── 3. 列表与过滤 ───────────────────────────────────
|
||
print("\n[3] 列表与过滤")
|
||
status, body = call("GET", f"{base}/api/v1/tasks?pageNum=1&pageSize=5", token)
|
||
dd = body.get("data") or {}
|
||
check("列表返回 {list,total,pageNum,pageSize}",
|
||
isinstance(dd.get("list"), list) and dd.get("pageNum") == 1 and dd.get("pageSize") == 5,
|
||
f"keys={list(dd.keys())}")
|
||
|
||
status, body = call("GET", f"{base}/api/v1/tasks?"
|
||
+ urllib.parse.urlencode({"keyword": f"E2E 任务 {suffix}"}), token)
|
||
hits = (body.get("data") or {}).get("list") or []
|
||
check("按关键字能搜到", any(h["id"] == task_id for h in hits), f"命中 {len(hits)} 条")
|
||
|
||
status, body = call("GET", f"{base}/api/v1/tasks?"
|
||
+ urllib.parse.urlencode({"status": 0, "batchNo": batch}), token)
|
||
hits = (body.get("data") or {}).get("list") or []
|
||
check("按状态+批次过滤能搜到", any(h["id"] == task_id for h in hits), f"命中 {len(hits)} 条")
|
||
|
||
status, body = call("GET", f"{base}/api/v1/tasks?status=3", token)
|
||
hits = (body.get("data") or {}).get("list") or []
|
||
check("按状态过滤排除了草稿任务", not any(h["id"] == task_id for h in hits),
|
||
f"状态=3 却命中了草稿任务")
|
||
|
||
# ── 4. 发布前置校验(全部阻断)──────────────────────
|
||
print("\n[4] 发布前置校验(阻断级,force 绕不过)")
|
||
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/publish", token, {"force": True})
|
||
check("空任务发布被拒 40016(名册为空)", body.get("code") == 40016,
|
||
f"code={body.get('code')} msg={body.get('msg')}")
|
||
msg = body.get("msg") or ""
|
||
check("一次把所有缺项都列出来了(不是撞到第一个就返回)",
|
||
("材料" in msg) and ("班级" in msg),
|
||
f"msg={msg}")
|
||
check("force=true 也无法绕过阻断级校验", body.get("code") == 40016,
|
||
f"code={body.get('code')}")
|
||
|
||
# ── 5. 造前置数据后发布 ─────────────────────────────
|
||
print("\n[5] 造前置数据(SQL 夹具)后发布")
|
||
okk, out = sql(
|
||
f"INSERT INTO collect_roster (tenant_id, task_id, org_id, real_name, id_card_no, id_card_hash) "
|
||
f"VALUES ({tenant_id}, {task_id}, {cls['id']}, 'E2E学生A', 'seed', 'seed')")
|
||
check("夹具:写入 1 行名册", okk, out)
|
||
okk, out = sql(
|
||
f"INSERT INTO collect_material_def (tenant_id, task_id, material_code, material_name, required) "
|
||
f"VALUES ({tenant_id}, {task_id}, 'IDCARD', '身份证', 1)")
|
||
check("夹具:写入 1 项必传材料", okk, out)
|
||
okk, out = sql(
|
||
f"INSERT INTO collect_task_org (tenant_id, task_id, org_id, status) "
|
||
f"VALUES ({tenant_id}, {task_id}, {cls['id']}, 1)")
|
||
check("夹具:写入 1 条覆盖班级(不指定初审人 → 会回退到班级负责人)", okk, out)
|
||
|
||
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/publish", token, {})
|
||
check("班级未指定初审人 → 只给 P1 提醒,不阻断", body.get("code") != 0 and "确认" in (body.get("msg") or ""),
|
||
f"code={body.get('code')} msg={body.get('msg')}")
|
||
|
||
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/publish", token, {"force": True})
|
||
if not check("带 force 发布成功", body.get("code") == 0,
|
||
f"code={body.get('code')} msg={body.get('msg')}"):
|
||
show(body)
|
||
else:
|
||
check("发布后状态=收集中(1)", (body.get("data") or {}).get("status") == 1,
|
||
f"status={(body.get('data') or {}).get('status')}")
|
||
check("发布了时间", bool((body.get("data") or {}).get("publishedTime")),
|
||
"publishedTime 为空")
|
||
|
||
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/publish", token, {"force": True})
|
||
check("已发布再发布被拒", body.get("code") != 0, f"code={body.get('code')}")
|
||
|
||
# 已发布不可改模式
|
||
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}", token, {
|
||
"taskName": f"E2E 任务 {suffix}", "collectMode": 2, "batchNo": batch,
|
||
"majorName": "数控加工改", "certType": 1,
|
||
"startTime": "2026-09-28", "endTime": "2026-10-20",
|
||
})
|
||
check("已发布不可改 collectMode", body.get("code") != 0,
|
||
f"code={body.get('code')} msg={body.get('msg')}")
|
||
|
||
# ── 6. 暂停 / 恢复(幂等)──────────────────────────
|
||
print("\n[6] 暂停 / 恢复")
|
||
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/pause", token,
|
||
{"reason": "名册需补充 3 人"})
|
||
check("暂停成功", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}")
|
||
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}", token)
|
||
check("暂停后状态=已暂停(4)", (body.get("data") or {}).get("status") == 4,
|
||
f"status={(body.get('data') or {}).get('status')}")
|
||
|
||
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/pause", token, {})
|
||
check("重复暂停幂等(返回成功,不报错)", body.get("code") == 0,
|
||
f"code={body.get('code')} msg={body.get('msg')}")
|
||
|
||
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/resume", token)
|
||
check("恢复成功", body.get("code") == 0, f"code={body.get('code')}")
|
||
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}", token)
|
||
check("恢复后状态=收集中(1)", (body.get("data") or {}).get("status") == 1,
|
||
f"status={(body.get('data') or {}).get('status')}")
|
||
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/resume", token)
|
||
check("重复恢复幂等", body.get("code") == 0, f"code={body.get('code')}")
|
||
|
||
# ── 7. 截止 / 归档 ─────────────────────────────────
|
||
print("\n[7] 截止 / 归档")
|
||
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/close", token, {})
|
||
check("截止成功", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}")
|
||
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}", token)
|
||
check("截止后状态=已截止(2)", (body.get("data") or {}).get("status") == 2,
|
||
f"status={(body.get('data') or {}).get('status')}")
|
||
|
||
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/pause", token, {})
|
||
check("已截止不可暂停(40021)", body.get("code") == 40021,
|
||
f"code={body.get('code')} msg={body.get('msg')}")
|
||
|
||
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/archive", token,
|
||
{"archiveRemark": "E2E 归档"})
|
||
check("归档成功", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}")
|
||
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}", token)
|
||
check("归档后状态=已归档(3)", (body.get("data") or {}).get("status") == 3,
|
||
f"status={(body.get('data') or {}).get('status')}")
|
||
|
||
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/resume", token)
|
||
check("已归档不可恢复(40022)", body.get("code") == 40022,
|
||
f"code={body.get('code')} msg={body.get('msg')}")
|
||
|
||
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}", token, {
|
||
"taskName": "改名试试", "collectMode": 1, "batchNo": batch,
|
||
"majorName": "数控加工改", "certType": 1,
|
||
"startTime": "2026-09-28", "endTime": "2026-10-20",
|
||
})
|
||
check("已归档仍可改名称(只锁模式与认定类型)", body.get("code") == 0,
|
||
f"code={body.get('code')} msg={body.get('msg')}")
|
||
|
||
status, body = call("DELETE", f"{base}/api/v1/tasks/{task_id}", token)
|
||
check("已归档不可删除", body.get("code") != 0, f"code={body.get('code')}")
|
||
|
||
# ── 8. 草稿路径与删除 ───────────────────────────────
|
||
print("\n[8] 草稿路径与删除")
|
||
status, body = call("POST", f"{base}/api/v1/tasks", token, {
|
||
"taskName": f"E2E 草稿 {suffix}", "collectMode": 1, "batchNo": batch,
|
||
"majorName": "数控加工", "certType": 1,
|
||
"startTime": "2026-09-28", "endTime": "2026-10-15",
|
||
})
|
||
draft_id = (body.get("data") or {}).get("id")
|
||
if check("再建一个草稿任务", body.get("code") == 0, f"code={body.get('code')}"):
|
||
CREATED_TASK_IDS.append(draft_id)
|
||
check("任务编码递增(同一批次不重号)",
|
||
(body["data"]["taskCode"] != task_code),
|
||
f"两次都是 {task_code}")
|
||
|
||
status, body = call("POST", f"{base}/api/v1/tasks", token, {
|
||
"taskName": f"E2E草稿2 {suffix}", "collectMode": 1, "batchNo": batch,
|
||
"majorName": "数控加工", "certType": 1,
|
||
"startTime": "2026-09-28", "endTime": "2026-10-15",
|
||
})
|
||
if check("删掉草稿后再建(同批次)", body.get("code") == 0,
|
||
f"code={body.get('code')} msg={body.get('msg')}"):
|
||
third_id = body["data"]["id"]
|
||
third_code = body["data"]["taskCode"]
|
||
CREATED_TASK_IDS.append(third_id)
|
||
# 回归护栏:软删掉的任务仍然占着唯一约束,编码不能重用,
|
||
# 否则会报 duplicate key(且编码同时是文件目录名,复用会串目录)
|
||
check("软删后不改用旧编码(唯一约束不看软删标记)",
|
||
third_code not in (task_code,),
|
||
f"复用了编码 {third_code}")
|
||
check("新编码序号继续递增", third_code > task_code,
|
||
f"{task_code} → {third_code}")
|
||
status, body = call("DELETE", f"{base}/api/v1/tasks/{third_id}", token)
|
||
check("再删掉它", body.get("code") == 0, f"code={body.get('code')}")
|
||
|
||
status, body = call("POST", f"{base}/api/v1/tasks/{draft_id}/pause", token, {})
|
||
check("草稿不可暂停(40021)", body.get("code") == 40021,
|
||
f"code={body.get('code')} msg={body.get('msg')}")
|
||
status, body = call("POST", f"{base}/api/v1/tasks/{draft_id}/resume", token)
|
||
check("草稿不可恢复(40022)", body.get("code") == 40022,
|
||
f"code={body.get('code')} msg={body.get('msg')}")
|
||
status, body = call("POST", f"{base}/api/v1/tasks/{draft_id}/archive", token, {})
|
||
check("草稿不可归档", body.get("code") != 0, f"code={body.get('code')}")
|
||
|
||
status, body = call("DELETE", f"{base}/api/v1/tasks/{draft_id}", token)
|
||
check("草稿可以删除", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}")
|
||
status, body = call("GET", f"{base}/api/v1/tasks/{draft_id}", token)
|
||
check("删除后详情查不到", body.get("code") != 0, f"code={body.get('code')}")
|
||
|
||
# ── 9. 可见范围(班主任只看自己带的班)───────────────
|
||
print("\n[9] 可见范围:班主任只看自己带的班级")
|
||
ht_user = f"htt{suffix}"
|
||
status, body = call("POST", f"{base}/api/v1/users", token, {
|
||
"username": ht_user, "realName": f"E2E班主任{suffix}",
|
||
"phone": "13900139901", "roleCodes": ["HEAD_TEACHER"], "userType": 3,
|
||
})
|
||
if check("造一个班主任账号", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}"):
|
||
ht_id = body["data"]["id"]
|
||
ht_pwd = body["data"]["initialPassword"]
|
||
CREATED_USER_IDS.append(ht_id)
|
||
|
||
# 建两个任务:一个把他设为初审人,一个不设
|
||
mine, others = [], []
|
||
for tag in ("mine", "other"):
|
||
status, body = call("POST", f"{base}/api/v1/tasks", token, {
|
||
"taskName": f"E2E可见性-{tag}-{suffix}", "collectMode": 1, "batchNo": batch,
|
||
"majorName": "数控加工", "certType": 1,
|
||
"startTime": "2026-09-28", "endTime": "2026-10-15",
|
||
})
|
||
if body.get("code") != 0:
|
||
fail(f"造可见性任务 {tag}", f"code={body.get('code')} msg={body.get('msg')}")
|
||
continue
|
||
tid = body["data"]["id"]
|
||
CREATED_TASK_IDS.append(tid)
|
||
(mine if tag == "mine" else others).append(tid)
|
||
if tag == "mine":
|
||
sql(f"INSERT INTO collect_task_org (tenant_id, task_id, org_id, head_user_id, status) "
|
||
f"VALUES ({tenant_id}, {tid}, {cls['id']}, {ht_id}, 1)")
|
||
else:
|
||
sql(f"INSERT INTO collect_task_org (tenant_id, task_id, org_id, status) "
|
||
f"VALUES ({tenant_id}, {tid}, {cls['id']}, 1)")
|
||
|
||
status, body = login(base, ht_user, ht_pwd, args.tenant)
|
||
if check("班主任能登录", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}"):
|
||
ht_token = body["data"]["token"]
|
||
status, body = call("GET", f"{base}/api/v1/tasks?pageSize=200", token=ht_token)
|
||
seen = {t["id"] for t in (body.get("data") or {}).get("list") or []}
|
||
check("班主任能看到「自己带的班」的任务",
|
||
all(t in seen for t in mine), f"应看到 {mine},实际 {sorted(seen)}")
|
||
check("班主任看不到「不是自己带的班」的任务",
|
||
all(t not in seen for t in others), f"不该看到 {others},实际 {sorted(seen)}")
|
||
|
||
status, body = call("POST", f"{base}/api/v1/tasks", ht_token, {
|
||
"taskName": "班主任不该能建任务", "collectMode": 1, "batchNo": batch,
|
||
"majorName": "x", "certType": 1,
|
||
"startTime": "2026-09-28", "endTime": "2026-10-15",
|
||
})
|
||
check("班主任建任务被拒(无 task:manage)", body.get("code") == 40301,
|
||
f"code={body.get('code')}")
|
||
|
||
# ── 10. 清理 ───────────────────────────────────────
|
||
print("\n[10] 清理测试数据")
|
||
if CREATED_TASK_IDS:
|
||
ids = ",".join(str(i) for i in CREATED_TASK_IDS)
|
||
for stmt in [
|
||
f"DELETE FROM collect_task_org WHERE task_id IN ({ids})",
|
||
f"DELETE FROM collect_material_def WHERE task_id IN ({ids})",
|
||
f"DELETE FROM collect_roster WHERE task_id IN ({ids})",
|
||
f"DELETE FROM collect_task WHERE id IN ({ids})",
|
||
]:
|
||
okk, out = sql(stmt)
|
||
if not okk:
|
||
print(f" ⚠ {stmt[:60]}… → {out}")
|
||
for uid in CREATED_USER_IDS:
|
||
call("DELETE", f"{base}/api/v1/users/{uid}", token)
|
||
ok(f"已清理 {len(CREATED_TASK_IDS)} 个任务、{len(CREATED_USER_IDS)} 个账号及其夹具数据")
|
||
|
||
# ── 汇总 ───────────────────────────────────────────
|
||
print("\n" + "=" * 72)
|
||
print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}")
|
||
if FAILED:
|
||
print("\n失败项:")
|
||
for name, detail in FAILED:
|
||
print(f" · {name}\n {detail}")
|
||
print("=" * 72)
|
||
return 1 if FAILED else 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|