feat: 补齐报销付款待办交接(09-11-reimbursement-payment-handoffs)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-09-11 09:43:37 +08:00
父节点 f9aee4c932
当前提交 fedcaed1a1
共修改 15 个文件,包含 486 行新增和 15 行删除
+25 -2
查看文件
@@ -96,6 +96,11 @@ async function main() {
if (process.env.RUN_PAYMENT_EXPERIENCE_TEST === "1") {
await app.register(serverRequire("./src/routes/paymentBatches"), { prefix: "/api" });
}
if (process.env.RUN_HANDOFF_PROJECTION_TEST === "1") {
app.config.portalHomeEnabled = true;
await prisma.featureFlag.create({ data: { flag_key: "portal_home_v1", override_mode: "force_on", rollout_type: "global", department_ids: [] } });
await app.register(serverRequire("./src/routes/portal"), { prefix: "/api" });
}
if (process.env.RUN_JOB_QUEUE === "1") {
Object.assign(app.config, { jobDriver: "pgboss", pgBossSchema: "synthetic_jobs", ocrMaxAttempts: 3, ocrPollCron: "0 0 1 1 *", delegationRevokeCron: "0 0 1 1 *", invoiceArchiveCron: "0 0 1 1 *", integrationOutboxCron: "0 0 1 1 *" });
await serverRequire("./src/jobs/pgBossJobs").startPgBossJobs(app);
@@ -115,6 +120,21 @@ async function main() {
const response = await fetch(`${origin}/api${url}`, { method, headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, body: JSON.stringify(body), signal: AbortSignal.timeout(15000) });
return { status: response.status, body: await response.json() };
};
const handoffCheck = async (actor, kind, entityId, expectedCount) => {
if (process.env.RUN_HANDOFF_PROJECTION_TEST !== "1") return;
const auditCount = await prisma.auditLog.count();
const getHandoffs = async () => {
const response = await fetch(`${origin}/api/portal/home`, { headers: { authorization: `Bearer ${tokens.get(actor.id)}` }, signal: AbortSignal.timeout(15000) });
assert.equal(response.status, 200, "authorized portal handoff projection");
const payload = (await response.json()).data.businessHandoffs;
assert.equal(payload.total, payload.items.length);
assert.equal(new Set(payload.items.map((item) => item.key)).size, payload.total);
assert.equal(payload.items.filter((item) => item.kind === kind && item.key.endsWith(`:${entityId}`)).length, expectedCount);
return payload;
};
assert.deepEqual(await getHandoffs(), await getHandoffs());
assert.equal(await prisma.auditLog.count(), auditCount, "handoff reads do not create audit or workflow writes");
};
const bind = (invoice, form, actor) => post(`/invoice-pool/${invoice.id}/bind`, { reimbursementId: form.id, createItem: true }, actor);
const objects = [
"<< /Type /Catalog /Pages 2 0 R >>",
@@ -261,10 +281,13 @@ async function main() {
assert.equal((await post(`/workflow/tasks/${firstTask.id}/approve`, {})).status, 403);
assert.equal((await post(`/workflow/tasks/${firstTask.id}/return`, { comment: "synthetic correction required" }, manager.id)).status, 200);
assert.equal((await prisma.reimbursementForm.findUnique({ where: { id: form.id } })).status, "draft");
await handoffCheck(employee, "reimbursement_edit", form.id, 1);
await handoffCheck(approvers.finance, "reimbursement_edit", form.id, 0);
assert.equal((await prisma.invoicePool.findUnique({ where: { id: invoice.id } })).linked_reimbursement_id, form.id);
assert.equal((await post(`/reimbursements/${form.id}/items/${items[0].id}`, { description: "synthetic corrected purpose" }, null, "PATCH")).status, 200);
const returnedResubmit = await post(`/reimbursements/${form.id}/submit`, {});
assert.equal(returnedResubmit.status, 200);
await handoffCheck(employee, "reimbursement_edit", form.id, 0);
const rejectTask = await prisma.workflowTask.findFirst({ where: { instance_id: returnedResubmit.body.data.workflowInstanceId, status: "pending" } });
assert.equal((await post(`/workflow/tasks/${rejectTask.id}/reject`, { comment: "synthetic rejected correction" }, manager.id)).status, 200);
assert.equal((await prisma.reimbursementForm.findUnique({ where: { id: form.id } })).status, "rejected");
@@ -319,7 +342,7 @@ async function main() {
assert.equal((await prisma.workflowInstance.findUnique({ where: { id: newWorkflowId } })).current_status, "approved");
passed.push("owner cannot self-approve; return/edit/resubmit and manager/ceo/finance approval complete without payment");
if (process.env.RUN_PAYMENT_EXPERIENCE_TEST === "1") {
await require("./test-payment-experience.cjs")({ app, prisma, employee, approvers, form, post, origin, tokens, serverRequire, passed, browser, preview, loginPassword });
await require("./test-payment-experience.cjs")({ app, prisma, employee, approvers, form, post, origin, tokens, serverRequire, passed, browser, preview, loginPassword, handoffCheck });
}
const competingForms = await Promise.all([makeForm(), makeForm()]);
@@ -401,7 +424,7 @@ async function main() {
await prisma.employee.update({ where: { id: employee.id }, data: { token_version: { increment: 1 } } });
assert.equal((await fetch(`${origin}/api/me`, { headers: { authorization: `Bearer ${tokens.get(employee.id)}` } })).status, 401);
passed.push("revoked token version rejected by real auth plugin");
console.log(JSON.stringify({ status: "PASS_REAL_DB_HTTP", passed, authentication: "REAL_PASSWORD_LOGIN_JWT_SYNTHETIC_ACCOUNTS", liveOcr: "NOT_TESTED", ocrWorker: process.env.RUN_JOB_QUEUE === "1" ? "REAL_PGBOSS_MOCK_PROVIDER" : process.env.RUN_BROWSER_TEST === "1" ? "REAL_WORKER_MOCK_PROVIDER_MANUAL_DISPATCH" : "NOT_TESTED", approvalFlow: "RETURN_RESUBMIT_THREE_STEP_APPROVAL", payment: process.env.RUN_PAYMENT_EXPERIENCE_TEST === "1" ? "CREATE_REVIEW_DOWNLOAD_STILL_UNPAID" : "NOT_TESTED" }, null, 2));
console.log(JSON.stringify({ status: "PASS_REAL_DB_HTTP", passed, authentication: "REAL_PASSWORD_LOGIN_JWT_SYNTHETIC_ACCOUNTS", liveOcr: "NOT_TESTED", ocrWorker: process.env.RUN_JOB_QUEUE === "1" ? "REAL_PGBOSS_MOCK_PROVIDER" : process.env.RUN_BROWSER_TEST === "1" ? "REAL_WORKER_MOCK_PROVIDER_MANUAL_DISPATCH" : "NOT_TESTED", approvalFlow: "RETURN_RESUBMIT_THREE_STEP_APPROVAL", payment: process.env.RUN_HANDOFF_PROJECTION_TEST === "1" ? "SYNTHETIC_RECEIPT_RECORDED_PAID_NO_BANK_CONNECTION" : process.env.RUN_PAYMENT_EXPERIENCE_TEST === "1" ? "CREATE_REVIEW_DOWNLOAD_STILL_UNPAID" : "NOT_TESTED" }, null, 2));
} finally {
if (browser) await browser.close().catch(() => { process.exitCode = 1; console.error("Browser cleanup failed"); });
if (preview) await new Promise((resolve, reject) => preview.httpServer.close((error) => error ? reject(error) : resolve())).catch(() => { process.exitCode = 1; console.error("Preview cleanup failed"); });
+26 -1
查看文件
@@ -5,7 +5,7 @@ const { createRequire } = require("node:module");
const path = require("node:path");
module.exports = async function verifyPaymentExperience({
app, prisma, employee, approvers, form, post, origin, tokens, serverRequire, passed, browser, preview, loginPassword,
app, prisma, employee, approvers, form, post, origin, tokens, serverRequire, passed, browser, preview, loginPassword, handoffCheck,
}) {
app.config.fieldEncryptionKey = randomBytes(32).toString("base64");
app.config.paymentBatchEnabled = true;
@@ -23,6 +23,8 @@ module.exports = async function verifyPaymentExperience({
account_number_encrypted: encryptStringOrThrow(app.config, `6217${"0".repeat(8)}1234`),
} });
await prisma.featureFlag.create({ data: { flag_key: "payment_batch_v1", override_mode: "force_on", rollout_type: "global", department_ids: [] } });
await handoffCheck?.(approvers.finance, "reimbursement_batch", form.id, 1);
await handoffCheck?.(approvers.ceo, "reimbursement_batch", form.id, 0);
const readProgress = async (actor) => {
const response = await fetch(`${origin}/api/reimbursements/${form.id}`, {
headers: { authorization: `Bearer ${tokens.get(actor.id)}` }, signal: AbortSignal.timeout(15000),
@@ -36,6 +38,9 @@ module.exports = async function verifyPaymentExperience({
}, approvers.finance.id);
assert.equal(created.status, 201, `create payment batch: ${created.body.error?.details?.reasonCode ?? created.status}`);
const batch = created.body.data.item;
await handoffCheck?.(approvers.finance, "reimbursement_batch", form.id, 0);
await handoffCheck?.(approvers.ceo, "payment_review", batch.id, 1);
await handoffCheck?.(approvers.finance, "payment_review", batch.id, 0);
const reviewStage = (await readProgress(approvers.finance)).stages.find((stage) => stage.key === "payment_batch_review");
assert.equal(reviewStage.state, "current");
assert.equal(reviewStage.detail, "待非制单人复核付款");
@@ -46,6 +51,9 @@ module.exports = async function verifyPaymentExperience({
assert.equal((await download(approvers.finance)).status, 409);
assert.equal((await post(`/payment-batches/${batch.id}/review`, {}, approvers.finance.id)).status, 403);
assert.equal((await post(`/payment-batches/${batch.id}/review`, {}, approvers.ceo.id)).status, 200);
await handoffCheck?.(approvers.ceo, "payment_review", batch.id, 0);
await handoffCheck?.(approvers.finance, "payment_execute", batch.id, 1);
await handoffCheck?.(approvers.ceo, "payment_execute", batch.id, 0);
assert.equal((await readProgress(approvers.finance)).stages.find((stage) => stage.key === "payment_and_receipt").state, "current");
assert.equal((await download(approvers.ceo)).status, 403);
assert.equal((await download(employee)).status, 403);
@@ -125,4 +133,21 @@ module.exports = async function verifyPaymentExperience({
passed.push("payment: real authenticated desktop/mobile reimbursement-to-batch download and return agree on stage, unique link, action-before-progress; no reason input; repeat warning retained");
}
passed.push("payment: real finance create, CEO review, unauthorized export refused, Chinese BIFF8 first/re-download without reason, audited and still unpaid");
if (process.env.RUN_HANDOFF_PROJECTION_TEST === "1") {
await handoffCheck(approvers.finance, "payment_execute", batch.id, 1);
assert.equal((await post(`/payment-batches/${batch.id}/execute`, {}, approvers.finance.id)).status, 409);
const receipt = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jD1sAAAAASUVORK5CYII=", "base64");
const upload = new FormData();
upload.append("files", new Blob([receipt], { type: "image/png" }), "synthetic-receipt.png");
const uploaded = await fetch(`${origin}/api/payment-batches/${batch.id}/receipt-attachments`, {
method: "POST", headers: { authorization: `Bearer ${tokens.get(approvers.finance.id)}` }, body: upload, signal: AbortSignal.timeout(15000),
});
assert.equal(uploaded.status, 200, "synthetic receipt upload");
const executed = await post(`/payment-batches/${batch.id}/execute`, {}, approvers.finance.id);
assert.equal(executed.status, 200);
assert.equal((await prisma.reimbursementForm.findUnique({ where: { id: form.id } })).status, "paid");
await handoffCheck(approvers.finance, "payment_execute", batch.id, 0);
await handoffCheck(approvers.finance, "reimbursement_batch", form.id, 0);
passed.push("handoffs: real read-only portal return/resubmit/create/review/export/synthetic receipt/paid lifecycle; unique stable counts, no self-review, no bank connection");
}
};