/** * 全流程业务门禁联调:人员状态必须随流程推进,未过节点不可“假通过”。 * 用法:node scripts/e2e-biz-gates-full.js */ const http = require("http"); const asserts = []; function check(name, cond, detail) { asserts.push({ name, pass: !!cond, detail: detail || "" }); console.log((cond ? "PASS" : "FAIL") + " | " + name + (detail ? " | " + detail : "")); return !!cond; } function req(method, path, body) { return new Promise((resolve) => { const data = body == null ? null : JSON.stringify(body); const headers = { Accept: "application/json" }; if (data) { headers["Content-Type"] = "application/json"; headers["Content-Length"] = Buffer.byteLength(data); } const r = http.request( { hostname: "localhost", port: 8088, path: "/sjnmtybt" + path, method, headers }, (res) => { let raw = ""; res.on("data", (c) => (raw += c)); res.on("end", () => { try { resolve(JSON.parse(raw)); } catch (e) { resolve({ code: res.statusCode, raw: String(raw).slice(0, 240) }); } }); } ); r.on("error", (e) => resolve({ code: 0, error: e.message })); if (data) r.write(data); r.end(); }); } async function getPerson(id) { const r = await req("GET", "/api/personnel/" + encodeURIComponent(id)); return r.data || null; } async function createPerson(name, extras) { const body = Object.assign( { name, idNumber: "31011719900101" + String(Date.now()).slice(-4), townId: "T_YEXIE", villageId: "V_YX_001", bankCardNumber: "622848003000" + String(Date.now()).slice(-7), monthlyStandard: 1280, enjoyStartMonth: "202601", }, extras || {} ); // 避免身份证撞号 await new Promise((r) => setTimeout(r, 5)); body.idNumber = "3101171990" + String(Date.now()).slice(-8); const r = await req("POST", "/api/personnel", body); return r; } async function ensureBatch(payMonth) { return req( "POST", "/api/batches/ensure-current-month?townId=T_YEXIE&villageId=V_YX_001&payMonth=" + encodeURIComponent(payMonth) ); } async function purgeMonth(payMonth) { const page = await req("POST", "/api/batches/page", { pageNum: 1, pageSize: 50, townId: "T_YEXIE", villageId: "V_YX_001", payMonth, batchLevel: "VILLAGE", }); for (const b of page.data?.list || []) { if (b?.id && b.status !== "ARCHIVED") { await req("POST", "/api/batches/" + encodeURIComponent(b.id) + "/purge"); } } } (async () => { const payA = "23" + String(Date.now()).slice(-4); const payB = "24" + String(Date.now()).slice(-4); console.log("\n========== 场景A:正常闭环 + 状态门禁 =========="); console.log("payMonth=" + payA); await purgeMonth(payA); // A0 空人选不可提交(创建草稿不带人员;ensure 会默认选人故不用 ensure) const emptyCreate = await req("POST", "/api/batches", { batchLevel: "VILLAGE", payMonth: payA, townId: "T_YEXIE", villageId: "V_YX_001", batchNo: "GATE-EMPTY-" + payA, }); let bid = emptyCreate.data?.id; check( "A0 建草稿批", !!bid && emptyCreate.data?.status === "DRAFT", emptyCreate.data?.status || emptyCreate.message ); const emptySubmit = await req("POST", "/api/batches/" + encodeURIComponent(bid) + "/submit"); check("A0 未选人禁止提交初审", emptySubmit.code !== 200, emptySubmit.code + " " + emptySubmit.message); // A1 建正常人员 const pOk = await createPerson("门禁正常员"); const pidOk = pOk.data?.id; check("A1 建人员", !!pidOk, pidOk); let person = await getPerson(pidOk); check("A1 初始草稿", person?.bizStatus === "DRAFT", person?.bizStatus); check( "A1 初始居保未匹配", !person?.insuranceMatchResult || person.insuranceMatchResult === "PENDING", person?.insuranceMatchResult ); // A2 选人提交(复用空草稿批) const put = await req("PUT", "/api/batches/" + encodeURIComponent(bid) + "/members", { personnelIds: [pidOk], }); check("A2 选人", put.code === 200, put.message); const submit = await req("POST", "/api/batches/" + encodeURIComponent(bid) + "/submit"); check( "A2 提交初审", submit.code === 200 && submit.data?.status === "PENDING_TOWN_APPROVE", submit.data?.status || submit.message ); person = await getPerson(pidOk); check("A2 提交后人员仍草稿(待镇审)", person?.bizStatus === "DRAFT", person?.bizStatus); // A3 未镇审不可居保推进领导 / 不可出盘 / 不可提交领导 const earlyLeader = await req("POST", "/api/batches/" + encodeURIComponent(bid) + "/submit-leader"); check("A3 未居保不可提交领导", earlyLeader.code !== 200, earlyLeader.code + " " + earlyLeader.message); const earlyExport = await req("POST", "/api/payments/export-disk", { batchId: bid, bypassDeadline: true, }); check("A3 未领导审不可出盘", earlyExport.code !== 200, earlyExport.code + " " + earlyExport.message); // A4 镇审通过 → 待居保匹配 const town = await req("POST", "/api/batches/town-approve", { bizId: bid, action: "PASS", opinion: "资格材料齐全", }); check( "A4 镇审通过", town.code === 200 && town.data?.status === "PENDING_INSURANCE_MATCH", town.data?.status ); person = await getPerson(pidOk); check( "A4 人员→待居保匹配", person?.bizStatus === "PENDING_INSURANCE_MATCH", person?.bizStatus ); // A5 居保前仍不可提交领导(若状态仍是待居保且未匹配) const beforeMatchLeader = await req( "POST", "/api/batches/" + encodeURIComponent(bid) + "/submit-leader" ); // 允许:若系统要求先 trigger;或仍是 PENDING_INSURANCE_MATCH 且未匹配应拒绝 person = await getPerson(pidOk); if (!person?.insuranceMatchResult || person.insuranceMatchResult === "PENDING") { check( "A5 未匹配禁止提交领导", beforeMatchLeader.code !== 200, beforeMatchLeader.code + " " + beforeMatchLeader.message ); } // A6 居保匹配成功 const match = await req("POST", "/api/insurance-match/trigger", { batchId: bid, operator: "业务", }); check("A6 居保触发", match.code === 200, JSON.stringify(match.data).slice(0, 120)); check("A6 居保无 FAIL", Number(match.data?.failCount || 0) === 0, "fail=" + match.data?.failCount); person = await getPerson(pidOk); check("A6 人员→正常在库", person?.bizStatus === "NORMAL", person?.bizStatus); check( "A6 居保→SUCCESS", person?.insuranceMatchResult === "SUCCESS", person?.insuranceMatchResult ); check( "A6 批次进入待领导或可提交", match.data?.nextStatus === "PENDING_LEADER_APPROVE" || match.data?.nextStatus === "PENDING_STATUS_MAINTAIN", match.data?.nextStatus ); // A7 提交领导(幂等或推进) const toLeader = await req("POST", "/api/batches/" + encodeURIComponent(bid) + "/submit-leader"); check( "A7 提交领导", toLeader.code === 200 && toLeader.data?.status === "PENDING_LEADER_APPROVE", toLeader.message + " " + toLeader.data?.status ); // A8 领导驳回 → 不可出盘 const leaderReject = await req("POST", "/api/batches/leader-approve", { bizId: bid, action: "REJECT", opinion: "清单需调整", }); check("A8 领导驳回", leaderReject.code === 200, leaderReject.data?.status); const exportAfterReject = await req("POST", "/api/payments/export-disk", { batchId: bid, bypassDeadline: true, }); check( "A8 驳回后禁止出盘", exportAfterReject.code !== 200, exportAfterReject.code + " " + exportAfterReject.message ); // A9 再提交领导并通过 // 驳回后状态可能是 REJECTED / PENDING_STATUS_MAINTAIN 等,再走 submit-leader 或直接 leader PASS(按实现) let st = (await req("GET", "/api/batches/" + encodeURIComponent(bid))).data?.status; if (st === "REJECTED" || st === "PENDING_STATUS_MAINTAIN" || st === "PENDING_INSURANCE_MATCH") { const again = await req("POST", "/api/batches/" + encodeURIComponent(bid) + "/submit-leader"); if (again.code !== 200) { // 部分实现驳回后需重新居保 await req("POST", "/api/insurance-match/trigger", { batchId: bid }); await req("POST", "/api/batches/" + encodeURIComponent(bid) + "/submit-leader"); } } const leaderPass = await req("POST", "/api/batches/leader-approve", { bizId: bid, action: "PASS", opinion: "同意出盘", }); check( "A9 领导通过", leaderPass.code === 200 && (leaderPass.data?.status === "READY_EXPORT" || leaderPass.data?.status === "EXPORTED"), leaderPass.data?.status ); person = await getPerson(pidOk); check("A9 通过后人员仍正常", person?.bizStatus === "NORMAL", person?.bizStatus); // A10 出盘回盘归档 const exp = await req("POST", "/api/payments/export-disk", { batchId: bid, bypassDeadline: true, }); check("A10 出盘", exp.code === 200, exp.data?.fileName || exp.message); const ret = await req("POST", "/api/return-disk/confirm", { batchId: bid, manualAllSuccess: true, operator: "业务", }); check("A10 回盘", ret.code === 200, ret.message); const ret2 = await req("POST", "/api/return-disk/confirm", { batchId: bid, manualAllSuccess: true, }); check("A10 重复回盘拒绝", ret2.code !== 200, ret2.message); const arch = await req("POST", "/api/dashboard/archive/" + encodeURIComponent(bid)); check("A10 归档", arch.code === 200 && arch.data?.status === "ARCHIVED", arch.data?.status); console.log("\n========== 场景B:居保 FAIL 不可跳过 =========="); console.log("payMonth=" + payB); await purgeMonth(payB); const pFail = await createPerson("门禁失败员", { remarks: "INSURANCE_FORCE_FAIL|联调强制失败", }); const pidFail = pFail.data?.id; check("B1 建 FAIL 人员", !!pidFail, pidFail); const ensB = await ensureBatch(payB); const bidB = ensB.data?.id; await req("PUT", "/api/batches/" + encodeURIComponent(bidB) + "/members", { personnelIds: [pidFail], }); await req("POST", "/api/batches/" + encodeURIComponent(bidB) + "/submit"); await req("POST", "/api/batches/town-approve", { bizId: bidB, action: "PASS", opinion: "先过初审", }); const matchFail = await req("POST", "/api/insurance-match/trigger", { batchId: bidB }); check( "B2 居保产生 FAIL", matchFail.code === 200 && Number(matchFail.data?.failCount || 0) > 0, "fail=" + matchFail.data?.failCount + " next=" + matchFail.data?.nextStatus ); check( "B2 批次停在待状态维护", matchFail.data?.nextStatus === "PENDING_STATUS_MAINTAIN", matchFail.data?.nextStatus ); let pf = await getPerson(pidFail); check("B2 人员异常待处理", pf?.bizStatus === "ABNORMAL", pf?.bizStatus); check("B2 居保结果 FAIL", pf?.insuranceMatchResult === "FAIL", pf?.insuranceMatchResult); const skipLeader = await req("POST", "/api/batches/" + encodeURIComponent(bidB) + "/submit-leader"); check("B3 FAIL 禁止提交领导", skipLeader.code !== 200, skipLeader.message); const skipLeaderApprove = await req("POST", "/api/batches/leader-approve", { bizId: bidB, action: "PASS", opinion: "强行通过", }); check( "B3 FAIL 禁止领导直接通过", skipLeaderApprove.code !== 200, skipLeaderApprove.code + " " + skipLeaderApprove.message ); // 人工确认后方可提交领导 const manual = await req("POST", "/api/insurance-match/manual-confirm", { personnelId: pidFail, batchId: bidB, pass: true, operator: "业务", }); check("B4 人工确认", manual.code === 200, manual.message); pf = await getPerson(pidFail); check("B4 人工确认后 NORMAL", pf?.bizStatus === "NORMAL", pf?.bizStatus); check( "B4 人工确认后 MANUAL_PASS", pf?.insuranceMatchResult === "MANUAL_PASS", pf?.insuranceMatchResult ); const afterManual = await req("POST", "/api/batches/" + encodeURIComponent(bidB) + "/submit-leader"); check( "B4 确认后可提交领导", afterManual.code === 200 && afterManual.data?.status === "PENDING_LEADER_APPROVE", afterManual.data?.status ); console.log("\n========== 场景C:镇审驳回 + 暂停人员默认不纳入 =========="); const payC = "25" + String(Date.now()).slice(-4); await purgeMonth(payC); const pDraft = await createPerson("驳回测员"); const pidDraft = pDraft.data?.id; const ensC = await ensureBatch(payC); const bidC = ensC.data?.id; await req("PUT", "/api/batches/" + encodeURIComponent(bidC) + "/members", { personnelIds: [pidDraft], }); await req("POST", "/api/batches/" + encodeURIComponent(bidC) + "/submit"); const townReject = await req("POST", "/api/batches/town-approve", { bizId: bidC, action: "REJECT", opinion: "材料不全", }); check( "C1 镇审驳回", townReject.code === 200 && townReject.data?.status === "REJECTED", townReject.data?.status ); const rejectLeader = await req("POST", "/api/batches/" + encodeURIComponent(bidC) + "/submit-leader"); check("C1 驳回后不可提交领导", rejectLeader.code !== 200, rejectLeader.message); // 暂停人员:创建暂停特殊业务并审批后,默认不应再被 ensure 全选 const pPause = await createPerson("暂停测员C"); const pidPause = pPause.data?.id; // 先置为 NORMAL 再暂停(特殊业务要求) await req("PUT", "/api/personnel/status", { personnelId: pidPause, bizStatus: "NORMAL", payThisMonth: true, }); const pauseBiz = await req("POST", "/api/special-biz", { bizType: "PAUSE", personnelId: pidPause, reason: "外出", }); check("C2 创建暂停单", pauseBiz.code === 200, pauseBiz.message); if (pauseBiz.data?.id) { await req("POST", "/api/special-biz/leader-approve", { bizId: pauseBiz.data.id, action: "PASS", opinion: "同意暂停", }); } const paused = await getPerson(pidPause); check("C2 暂停后 PAUSED", paused?.bizStatus === "PAUSED", paused?.bizStatus); const payD = "26" + String(Date.now()).slice(-4); await purgeMonth(payD); // 同时放入正常人 + 暂停人,选人应允许,但 ensure 默认不应带暂停人 const ensD = await ensureBatch(payD); const bidD = ensD.data?.id; // 查默认明细是否含暂停人 const pays = await req("POST", "/api/payments/details/page", { pageNum: 1, pageSize: 200, batchId: bidD, }); const ids = (pays.data?.list || []).map((x) => x.personnelId); check( "C3 ensure 默认不含暂停人", !ids.includes(pidPause), "ids=" + ids.slice(0, 8).join(",") ); console.log("\n========== 汇总 =========="); const fail = asserts.filter((a) => !a.pass); console.log("PASS=" + (asserts.length - fail.length) + " FAIL=" + fail.length + " TOTAL=" + asserts.length); for (const f of fail) { console.log(" - " + f.name + (f.detail ? " | " + f.detail : "")); } process.exit(fail.length ? 1 : 0); })().catch((e) => { console.error(e); process.exit(1); });