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。
This commit is contained in:
@@ -0,0 +1,411 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""摆一份**可用于界面验收的审核台数据**(开发用,不属于 E2E,不会清理)。
|
||||
|
||||
为什么要这么一个脚本
|
||||
--------------------
|
||||
审核台(PC)与班主任端页面验收时需要"名单里有人已提交"的状态,而 E2E 脚本
|
||||
跑完会**清掉名册绑定**。手工在页面上造:建任务 → 配班级 → 配材料 → 导名册 →
|
||||
三个学生各自核验 + 填表 + 传图 + 提交 —— 一次十几分钟,而且每次都要重来。
|
||||
|
||||
它做的全部事情都是**走真实接口**(建任务 / 配班级 / 应用材料模板 / 导名册 /
|
||||
门禁核验 / 填表 / 传材料 / 提交 / 初审通过),所以摆出来的状态与真实使用完全一致。
|
||||
|
||||
跑法
|
||||
----
|
||||
python scripts/dev-seed-audit.py # 建演示任务 + 3 个学生 + 3 份提交单
|
||||
python scripts/dev-seed-audit.py --reset # 先删掉旧的演示任务再重建
|
||||
python scripts/dev-seed-audit.py --clean # 只清理演示任务
|
||||
|
||||
产出(浏览器里怎么用)
|
||||
----------------------
|
||||
· 任务「【演示】审核台」→ 名单管理:3 人,3 人已提交
|
||||
· 审核 → 初审:**演示甲(干净,可批量通过)/ 演示乙(有黄色警告)**
|
||||
· 审核 → 终审:**演示丙(已初审通过,等待终审)**
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import time
|
||||
import importlib.util
|
||||
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"
|
||||
TASK_NAME = "【演示】审核台"
|
||||
TMP_ROSTER = os.path.join(ROOT, "scripts", "_seed-audit-roster.xlsx")
|
||||
|
||||
# 演示班主任:**密码写死**,因为这个账号的用途就是"登录进去看初审那一屏"。
|
||||
# 生产当然不能这么干,但它是 dev 演示数据(`--clean` 会连任务一起删)。
|
||||
TEACHER_USERNAME = "demot1"
|
||||
TEACHER_PASSWORD = "Demo1234"
|
||||
TEACHER_NAME = "演示班主任"
|
||||
TEACHER_PHONE = "13900139001"
|
||||
|
||||
|
||||
def _load(module_name: str, filename: str):
|
||||
"""
|
||||
复用既有 E2E 脚本的工具(登录 / 造名册 / 造学生 / 校验位计算)。
|
||||
|
||||
⚠️ 不自己再写一份 `call`/`upload`/`mint`:两边一改一漏,
|
||||
表现是"这个脚本单独跑得过、和 E2E 一起跑就红",而根因极难找。
|
||||
"""
|
||||
path = os.path.join(ROOT, "scripts", filename)
|
||||
spec = importlib.util.spec_from_file_location(module_name, path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
R = _load("e2e_m2_roster", "e2e-m2-roster.py") # 服务端工具:登录 / 导入名册 / SQL
|
||||
S = _load("e2e_m3_submit", "e2e-m3-submit.py") # 学生侧:填表 / 传材料 / 提交
|
||||
A = _load("e2e_m5_audit", "e2e-m5-audit.py") # big_jpeg / prepare_student
|
||||
|
||||
# 三个学生:姓名必须与名册一致;证件号用 R.with_checksum 生成(校验位合法)
|
||||
STUDENTS = [
|
||||
(993101, "演示甲", R.with_checksum("36102920080101001"), True), # 干净 → 留作初审待办
|
||||
(993102, "演示乙", R.with_checksum("36102920080202002"), False), # 重复件 → 黄色警告
|
||||
(993103, "演示丙", R.with_checksum("36102920080303003"), False), # 干净但先被初审通过
|
||||
]
|
||||
|
||||
|
||||
def login() -> str:
|
||||
st, body = R.login(BASE, "admin", "Admin@123", "demo")
|
||||
if st != 200 or body.get("code") != 0:
|
||||
raise SystemExit(f"登录失败:status={st} body={body}")
|
||||
return body["data"]["token"]
|
||||
|
||||
|
||||
def find_demo_tasks(token: str) -> list[dict]:
|
||||
"""找出所有演示任务(**只认干净名字**,被清理过的那些已改名,不会再被选中)。"""
|
||||
st, body = R.call("GET", f"{BASE}/api/v1/tasks?pageNum=1&pageSize=200", token)
|
||||
return [t for t in ((body.get("data") or {}).get("list") or [])
|
||||
if t.get("taskName") == TASK_NAME]
|
||||
|
||||
|
||||
def clean(token: str, naming_template: bool = True) -> None:
|
||||
"""
|
||||
把上一次的演示数据清空并"挪开",好让本轮能重新用干净的任务名。
|
||||
|
||||
⚠️ **已发布的任务既删不掉也改不了名**(`40001`,产品口径:只有草稿能删/改)——
|
||||
所以这里只能:
|
||||
· 用 SQL 清掉它的**提交单 / 审核记录 / 材料文件 / 名册行**(测试夹具性质的操作,
|
||||
与 E2E 用 SQL 造夹具同口径);
|
||||
· 把任务名改成 `【演示·旧】审核台 {id}`,让 `find_demo_tasks` 不再选中它。
|
||||
|
||||
为什么非要清不可:演示数据必须每次都能**从零重建**,否则第二次跑就会与上一次
|
||||
的数据纠缠("同一个学生有两份提交单"),而那看起来像功能坏了。
|
||||
"""
|
||||
tasks = find_demo_tasks(token)
|
||||
if not tasks:
|
||||
print(" 没有找到演示任务,无需清理")
|
||||
return
|
||||
for t in tasks:
|
||||
tid = t["id"]
|
||||
sql(f"delete from collect_audit_record where submission_id in "
|
||||
f"(select id from collect_submission where task_id = {tid});")
|
||||
sql(f"delete from collect_file where submission_id in "
|
||||
f"(select id from collect_submission where task_id = {tid});")
|
||||
sql(f"delete from collect_submission where task_id = {tid};")
|
||||
sql(f"delete from collect_roster where task_id = {tid};")
|
||||
if naming_template:
|
||||
sql(f"update collect_task set task_name = '【演示·旧】审核台 {tid}' where id = {tid};")
|
||||
print(f" 旧的演示任务 {tid} 已清空{'并改名' if naming_template else ''}")
|
||||
|
||||
|
||||
def sql(stmt: str) -> None:
|
||||
"""开发库上的夹具操作(与 E2E 脚本同一口径:`docker exec psql`)。"""
|
||||
R.sql(stmt)
|
||||
|
||||
|
||||
def _redis_del(key: str) -> None:
|
||||
"""删一个 Redis 键(限流计数用)。
|
||||
|
||||
容器名/端口与 `deploy/docker-compose.yml` 一致:`xt-redis`(宿主 16379)。
|
||||
⚠️ 本机还有别的项目的 redis(6379),所以这里**显式指定容器**,不用默认端口。
|
||||
|
||||
⚠️ **必须带密码**:`xt-redis` 开了 requirepass(dev 是 `redis_dev_pwd`,
|
||||
见 `application-dev.yml`)。不传密码时 redis-cli 回 `NOAUTH ...`,
|
||||
而我第一版用 `capture_output=True` 又**不看返回码** —— 于是表现为
|
||||
"清了但没清掉",最后还是撞 40071,排查时完全看不出是密码问题。
|
||||
"""
|
||||
import subprocess
|
||||
r = subprocess.run(["docker", "exec", "xt-redis", "redis-cli",
|
||||
"-a", "redis_dev_pwd", "--no-auth-warning", "DEL", key],
|
||||
capture_output=True, text=True, timeout=30)
|
||||
out = (r.stdout or "").strip()
|
||||
if r.returncode != 0 or "NOAUTH" in out or "WRONGPASS" in out:
|
||||
print(f" ⚠️ 清 Redis 键失败({key}):{out or r.stderr.strip()[:160]}")
|
||||
return None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="摆审核台演示数据")
|
||||
ap.add_argument("--clean", action="store_true", help="只清理演示任务")
|
||||
ap.add_argument("--reset", action="store_true", help="先清理再重建")
|
||||
ap.add_argument("--reset-gate-limit", action="store_true",
|
||||
help="先清掉演示学生的门禁限流计数(Redis)。"
|
||||
"限流是按用户 1 小时 10 次,反复重跑会撞 40071。\n"
|
||||
"⚠️ 只清演示学生的键,不动全局限流配置")
|
||||
ap.add_argument("--approve-one", action="store_true",
|
||||
help="再走一次真实终审,让演示丙变成「已通过」"
|
||||
"(导出功能需要至少一个已通过的人;否则两种范围都导不出来)")
|
||||
args = ap.parse_args()
|
||||
|
||||
token = login()
|
||||
print(" 管理员登录 ok")
|
||||
|
||||
if args.clean or args.reset:
|
||||
clean(token)
|
||||
if args.clean:
|
||||
return 0
|
||||
|
||||
if find_demo_tasks(token):
|
||||
print(f" 演示任务已存在({TASK_NAME})—— 要重建请加 --reset")
|
||||
return 0
|
||||
|
||||
# ── ① 建任务(草稿)────────────────────────────────
|
||||
st, body = R.call("POST", f"{BASE}/api/v1/tasks", token, {
|
||||
"taskName": TASK_NAME,
|
||||
"batchNo": "202609",
|
||||
"majorName": "数控加工",
|
||||
"jobName": "车工",
|
||||
"collectMode": 1,
|
||||
"certType": 1,
|
||||
"startTime": "2026-01-01",
|
||||
"endTime": "2026-12-31"
|
||||
})
|
||||
if body.get("code") != 0:
|
||||
raise SystemExit(f"建任务失败:{body}")
|
||||
task_id = body["data"]["id"]
|
||||
print(f" 建任务 id={task_id}")
|
||||
|
||||
# 班级:从组织树里找一个班级(演示租户已有)
|
||||
st, body = R.call("GET", f"{BASE}/api/v1/orgs/tree", token)
|
||||
classes = []
|
||||
|
||||
def walk(nodes):
|
||||
for n in nodes or []:
|
||||
if n.get("orgType") == 3:
|
||||
classes.append(n)
|
||||
walk(n.get("children"))
|
||||
|
||||
walk(body.get("data") or [])
|
||||
if not classes:
|
||||
raise SystemExit("演示租户里没有班级节点,请先在「组织架构」里建一个班")
|
||||
org = classes[0]
|
||||
print(f" 用班级:{org.get('orgName')}(id={org.get('id')})")
|
||||
|
||||
st, body = R.call("PUT", f"{BASE}/api/v1/tasks/{task_id}/orgs", token, {
|
||||
"orgs": [{"orgId": org["id"], "expectedCount": len(STUDENTS)}]
|
||||
})
|
||||
if body.get("code") != 0:
|
||||
raise SystemExit(f"配置班级失败:{body}")
|
||||
|
||||
# ── 门禁限流计数:只在显式要求时清 ──
|
||||
#
|
||||
# ⚠️ 门禁按用户 1 小时 10 次限流(`MpRosterService` 的 `xt:mp:gate:user:{uid}`),
|
||||
# 而演示脚本每次都用同一批演示学生 uid —— 一小时内跑到第 4 次就撞 40071,
|
||||
# 整脚本退出,而调用它的 E2E 只会报"演示数据没摆成",看不出是限流。
|
||||
# 做成**显式开关**而不是每次自动清:限流是生产行为,不该被脚本顺手改掉。
|
||||
if args.reset_gate_limit:
|
||||
uids = [u for u, _, _, _ in STUDENTS]
|
||||
for uid_ in uids:
|
||||
_redis_del(f"xt:mp:gate:user:{uid_}")
|
||||
print(f" 已清门禁限流计数:{uids}")
|
||||
|
||||
# ── 演示班主任:**必须显式指定初审人**,否则这一屏验不了 ──
|
||||
#
|
||||
# 初审的授权口径是"这个班的初审人 / 副班主任 / 班级负责人"(`AuditScopeMapper`
|
||||
# 的三级回退),而不是"有 audit:first 权限就行"。所以管理员自己**看不到初审待办** ——
|
||||
# 这是设计(权限 ≠ 范围),但演示时必须有一个真实的班主任才能把初审那一屏走通。
|
||||
teacher_id, teacher_name = ensure_teacher(token, org["id"])
|
||||
st, body = R.call("PUT", f"{BASE}/api/v1/tasks/{task_id}/orgs", token, {
|
||||
"orgs": [{
|
||||
"orgId": org["id"],
|
||||
"expectedCount": len(STUDENTS),
|
||||
"headUserId": teacher_id,
|
||||
"deputyUserIds": []
|
||||
}]
|
||||
})
|
||||
if body.get("code") != 0:
|
||||
raise SystemExit(f"指定初审人失败:{body}")
|
||||
# ⚠️ PUT 返回的是 `ApiResult<Void>`(data 为空),**不能拿它回显校验** ——
|
||||
# 必须 GET 回来确认真的落库了。否则"接口成功但字段没写进去"会被当成通过。
|
||||
st, body = R.call("GET", f"{BASE}/api/v1/tasks/{task_id}/orgs", token)
|
||||
got = ((body.get("data") or [{}])[0]).get("headUserId")
|
||||
if got != teacher_id:
|
||||
raise SystemExit(f"指定初审人没生效:期望 {teacher_id},实际 {got}")
|
||||
print(f" 初审人:{teacher_name}(id={teacher_id})")
|
||||
|
||||
# 材料模板(身份证 / 学籍表 / 一寸照片 都在 TPL_SCHOOL_ROLL 里)
|
||||
st, body = R.call("POST", f"{BASE}/api/v1/tasks/{task_id}/materials/apply-template", token,
|
||||
{"templateCode": "TPL_SCHOOL_ROLL"})
|
||||
if body.get("code") != 0:
|
||||
raise SystemExit(f"应用材料模板失败:{body}")
|
||||
print(f" 材料项:{len(body.get('data') or [])} 项")
|
||||
|
||||
# ── ② 导名册(走真实导入接口)──────────────────────
|
||||
#
|
||||
# ⚠️ **必须带 orgId**:官方模板里没有班级列,不指定的话名册行没有班级,
|
||||
# 于是填报单也没有班级 → 初审的范围校验无从判定,直接 `40301
|
||||
# 这份填报单没有班级信息,无法确认审核范围`(这正是 e2e-m5-audit 里那条断言的口径)。
|
||||
_build_roster([(name, idc) for _, name, idc, _ in STUDENTS])
|
||||
body = R.upload_roster(BASE, token, task_id, TMP_ROSTER,
|
||||
extra={"dryRun": "false", "mode": "APPEND", "orgId": org["id"]})
|
||||
if body.get("code") != 0:
|
||||
raise SystemExit(f"导入名册失败:{body}")
|
||||
rep = body.get("data") or {}
|
||||
print(f" 名册导入:created={rep.get('created')} failed={rep.get('failed')}")
|
||||
for e in (rep.get("errors") or [])[:5]:
|
||||
print(f" ✗ 第 {e.get('row')} 行 {e.get('col')}:{e.get('reason')}")
|
||||
|
||||
# ── ③ 发布(这样学生端才允许提交)──────────────────
|
||||
st, body = R.call("POST", f"{BASE}/api/v1/tasks/{task_id}/publish", token, {"force": True})
|
||||
if body.get("code") != 0:
|
||||
raise SystemExit(f"发布失败:{body}")
|
||||
print(f" 任务已发布(状态={(body.get('data') or {}).get('status')})")
|
||||
|
||||
# ── ④ 逐个学生:门禁核验 → 填表 → 传图 → 提交 ──────
|
||||
cfg = R.read_dev_config()
|
||||
subs: dict[int, int] = {}
|
||||
for uid, name, idc, _ in STUDENTS:
|
||||
tok = R.mint_mp_token(cfg["jwt_secret"], cfg["issuer"], uid, 1, ["STUDENT"], 4)
|
||||
|
||||
# 门禁核验:服务端按姓名 + 证件号匹配名册并**原子绑定**(真实链路)
|
||||
#
|
||||
# ⚠️ 门禁按 IP 限流(40071)。连续跑两次种子脚本(全量回归里它被多个 E2E
|
||||
# 各调一次)就会撞上,而表现是"演示数据没摆成",完全看不出是限流。
|
||||
# 所以只对 40071 退避重试;**其它错误照旧立刻退出**,不掩盖真问题。
|
||||
body = {}
|
||||
for attempt in range(3):
|
||||
st, body = R.call("POST", f"{BASE}/api/v1/mp/roster/match", tok,
|
||||
{"taskId": task_id, "realName": name, "idCardNo": idc})
|
||||
if body.get("code") != 40071:
|
||||
break
|
||||
wait = 5 * (attempt + 1)
|
||||
print(f" · 门禁限流(40071),{wait} 秒后重试第 {attempt + 2} 次…")
|
||||
time.sleep(wait)
|
||||
if body.get("code") != 0:
|
||||
raise SystemExit(f"门禁核验失败({name}):{body}")
|
||||
roster_id = (body.get("data") or {}).get("rosterId")
|
||||
|
||||
st, body = R.call("POST", f"{BASE}/api/v1/mp/submissions", tok, {"taskId": task_id})
|
||||
if body.get("code") != 0:
|
||||
raise SystemExit(f"建草稿失败({name}):{body}")
|
||||
sid = body["data"]["id"]
|
||||
|
||||
fill = {"work_unit": "无", "is_retake": False, "is_student": True,
|
||||
"school": "示范职业学校", "major": "数控加工", "enroll_date": "2024-09-01"}
|
||||
res = A.prepare_student(tok, sid, task_id, fill, dup_first_file=(uid == 993102))
|
||||
if res.get("_upload_error"):
|
||||
raise SystemExit(f"上传失败({name}):{res['_upload_error']}")
|
||||
d = res.get("data") or {}
|
||||
print(f" {name}:红={d.get('redCount')} 黄={d.get('yellowCount')} "
|
||||
f"submitted={d.get('submitted')} rosterId={roster_id}")
|
||||
subs[uid] = sid
|
||||
|
||||
# ── ⑤ 丙:由**班主任**先初审通过,给终审节点留一份 ──
|
||||
#
|
||||
# ⚠️ 这一步不能用管理员的 token:初审的范围校验按"这个班归不归我"判定,
|
||||
# 管理员既不带头衔也不在初审人名单里 → `40301`。
|
||||
# 这正是"权限 ≠ 范围"在真实链路上的体现(`e2e-m5-audit.py` 专门守着它)。
|
||||
st, body = R.login(BASE, TEACHER_USERNAME, TEACHER_PASSWORD, "demo")
|
||||
if body.get("code") != 0:
|
||||
raise SystemExit(f"班主任登录失败:{body}")
|
||||
teacher_tok = body["data"]["token"]
|
||||
st, body = R.call("POST", f"{BASE}/api/v1/audit/submissions/{subs[993103]}/approve",
|
||||
teacher_tok,
|
||||
{"remark": "演示数据:初审通过,留一份待终审", "actionSource": "BUTTON"})
|
||||
print(f" 丙 初审 → code={body.get('code')} status={(body.get('data') or {}).get('status')}"
|
||||
f" msg={body.get('msg')}")
|
||||
|
||||
# ⑥ 可选:把丙推到**已通过**,让导出功能有东西可导。
|
||||
# 必须走真实终审链路(管理员 token),不能直接改状态 ——
|
||||
# 否则"导出的数据真的审过"这件事就没法再靠这套夹具验了。
|
||||
if args.approve_one:
|
||||
st, body = R.call("POST", f"{BASE}/api/v1/audit/submissions/{subs[993103]}/approve",
|
||||
token,
|
||||
{"finalOpinion": "演示数据:终审通过", "actionSource": "BUTTON"})
|
||||
code = body.get("code")
|
||||
status = (body.get("data") or {}).get("status")
|
||||
print(f" 丙 终审 → code={code} status={status} msg={body.get('msg')}")
|
||||
if code != 0 or status != 3:
|
||||
print(" ⚠️ 终审没有把丙推到「已通过」—— 导出演示会拿不到候选数据")
|
||||
|
||||
if os.path.exists(TMP_ROSTER):
|
||||
os.remove(TMP_ROSTER)
|
||||
|
||||
print("\n 完成。浏览器里:")
|
||||
print(" · 收集任务 → 【演示】审核台 → 名单管理:3 人")
|
||||
print(" · 审核 → 初审:演示甲(干净)/ 演示乙(黄色警告)")
|
||||
print(" · 审核 → 终审:演示丙(已初审,待终审)" if not args.approve_one
|
||||
else " · 导出中心:演示丙已终审通过,可直接导出(另有 2 人未审完会被 40035 拦)")
|
||||
return 0
|
||||
|
||||
|
||||
def ensure_teacher(token: str, org_id: int) -> tuple[int, str]:
|
||||
"""
|
||||
准备一个演示班主任(已存在就复用),返回 (id, 姓名)。
|
||||
|
||||
为什么必须有一个**真实的班主任账号**:
|
||||
初审的范围校验是"这个班归不归我"(指定初审人 → 副班主任 → 班级负责人),
|
||||
管理员自己**看不到初审待办** —— 这是设计,不是缺陷。
|
||||
所以演示/验收初审那一屏时,必须有一个真账号登进去看。
|
||||
|
||||
密码用 `resetPassword(mode=CUSTOM)` 设成固定值:新增账号返回的是**一次性**
|
||||
初始密码,而演示脚本下次跑要能直接登录。
|
||||
"""
|
||||
st, body = R.call("GET", f"{BASE}/api/v1/users?keyword={TEACHER_USERNAME}&pageNum=1&pageSize=5",
|
||||
token)
|
||||
for u in (body.get("data") or {}).get("list") or []:
|
||||
if u.get("username") == TEACHER_USERNAME:
|
||||
st2, b2 = R.call("POST", f"{BASE}/api/v1/users/{u['id']}/reset-password", token,
|
||||
{"mode": "CUSTOM", "newPassword": TEACHER_PASSWORD})
|
||||
print(f" 复用已存在的演示班主任 id={u['id']}(重置密码 code={b2.get('code')})")
|
||||
return u["id"], u.get("realName") or TEACHER_NAME
|
||||
|
||||
st, body = R.call("POST", f"{BASE}/api/v1/users", token, {
|
||||
"username": TEACHER_USERNAME,
|
||||
"realName": TEACHER_NAME,
|
||||
"phone": TEACHER_PHONE,
|
||||
"orgId": org_id,
|
||||
"roleCodes": ["HEAD_TEACHER"],
|
||||
"userType": 3
|
||||
})
|
||||
if body.get("code") != 0:
|
||||
raise SystemExit(f"建演示班主任失败:{body}")
|
||||
uid = body["data"]["id"]
|
||||
st2, b2 = R.call("POST", f"{BASE}/api/v1/users/{uid}/reset-password", token,
|
||||
{"mode": "CUSTOM", "newPassword": TEACHER_PASSWORD})
|
||||
if b2.get("code") != 0:
|
||||
raise SystemExit(f"设置演示班主任密码失败:{b2}")
|
||||
return uid, TEACHER_NAME
|
||||
|
||||
|
||||
def _build_roster(rows: list[tuple[str, str]]) -> None:
|
||||
"""演示名册:表头 + 报考三项按任务声明(车工 / 数控车工 / 四级)填好。"""
|
||||
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, "中专", f"138001399{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)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user