fix: 财务本人报销由总经理独立审批(09-04-reimbursement-state-gap-finance-view)
这个提交包含在:
@@ -13,6 +13,9 @@ const docker = (args, input) => execFileSync("docker", args, { input, encoding:
|
||||
|
||||
async function main() {
|
||||
assert.equal(process.env.RUN_ISOLATED_DB_TEST, "1", "Explicit disposable database opt-in required");
|
||||
const managerIsCeo = process.env.RUN_MANAGER_IS_CEO_TEST === "1";
|
||||
const ownerApprovalTest = process.env.RUN_OWNER_APPROVAL_TEST === "1";
|
||||
assert.ok(!ownerApprovalTest || managerIsCeo, "Owner approval checks require the small-team fixture");
|
||||
const password = randomUUID();
|
||||
let container;
|
||||
let prisma;
|
||||
@@ -22,6 +25,7 @@ async function main() {
|
||||
let browser;
|
||||
let preview;
|
||||
const passed = [];
|
||||
const businessBlockers = [];
|
||||
try {
|
||||
container = docker(["run", "-d", "--rm", "--pull=never", "--tmpfs", "/var/lib/postgresql/data", "-p", "127.0.0.1::5432", "-e", `POSTGRES_PASSWORD=${password}`, "postgres:16-alpine"]);
|
||||
let ready = false;
|
||||
@@ -45,13 +49,12 @@ async function main() {
|
||||
bank_account_encrypted: "synthetic-not-account", bank_name: "synthetic", password_hash: "not-a-login-hash",
|
||||
} });
|
||||
const expenseType = await prisma.expenseType.create({ data: { code: "synthetic_http", name: "synthetic", category: "office", sort_order: 1 } });
|
||||
const manager = await prisma.employee.create({ data: {
|
||||
let manager = managerIsCeo ? null : await prisma.employee.create({ data: {
|
||||
name: "synthetic-manager", email: "[email protected]", employee_no: "synthetic-manager",
|
||||
department_id: department.id, title: "synthetic", hire_date: new Date("2026-09-09"),
|
||||
bank_account_encrypted: "synthetic-not-account", bank_name: "synthetic", password_hash: "not-a-login-hash",
|
||||
} });
|
||||
await prisma.employee.update({ where: { id: employee.id }, data: { direct_manager_id: manager.id } });
|
||||
const approvers = { manager };
|
||||
const approvers = manager ? { manager } : {};
|
||||
for (const role of ["ceo", "finance"]) {
|
||||
approvers[role] = await prisma.employee.create({ data: {
|
||||
name: `synthetic-${role}`, email: `invoice-${role}@example.invalid`, employee_no: `synthetic-${role}`,
|
||||
@@ -62,12 +65,24 @@ async function main() {
|
||||
for (const [role, actor] of Object.entries(approvers)) {
|
||||
await prisma.employeeRole.create({ data: { employee_id: actor.id, role_code: role } });
|
||||
}
|
||||
if (managerIsCeo) {
|
||||
manager = approvers.ceo;
|
||||
approvers.manager = manager;
|
||||
for (const actor of [approvers.ceo, approvers.finance]) {
|
||||
await prisma.employeeRole.create({ data: { employee_id: actor.id, role_code: "employee" } });
|
||||
}
|
||||
await prisma.employee.update({ where: { id: approvers.finance.id }, data: { direct_manager_id: manager.id } });
|
||||
assert.equal(await prisma.employeeRole.count({ where: { role_code: "manager" } }), 0);
|
||||
assert.equal(await prisma.employee.count(), 3);
|
||||
passed.push("small team: employee and finance report to the same CEO; no manager role or separate manager account");
|
||||
}
|
||||
await prisma.employee.update({ where: { id: employee.id }, data: { direct_manager_id: manager.id } });
|
||||
await prisma.employeeRole.create({ data: { employee_id: employee.id, role_code: "employee" } });
|
||||
const loginPassword = `Synthetic!${randomUUID()}`;
|
||||
const passwordHash = await serverRequire("./src/utils/crypto").hashPassword(loginPassword);
|
||||
await prisma.employee.updateMany({ data: { password_hash: passwordHash, must_change_password: false } });
|
||||
await prisma.workflowDefinition.create({ data: { name: "synthetic-reimbursement", entity_type: "reimbursement", definition_json: { steps: [] }, version: 1 } });
|
||||
await prisma.approvalRule.create({ data: { name: "synthetic-three-step", min_amount: 0, level1_role: "manager", level2_role: "ceo", level3_role: "finance", priority: 1 } });
|
||||
const approvalRule = await prisma.approvalRule.create({ data: { name: "synthetic-three-step", min_amount: 0, level1_role: "manager", level2_role: "ceo", level3_role: "finance", priority: 1 } });
|
||||
await prisma.featureFlag.create({ data: { flag_key: "reimbursement_invoice_guard_v1", override_mode: "force_on", rollout_type: "global", department_ids: [] } });
|
||||
const makeForm = () => prisma.reimbursementForm.create({ data: { form_number: `SYN-${randomUUID()}`, owner_id: employee.id, submitter_id: employee.id, department_id: department.id, reimbursement_date: new Date("2026-09-09") } });
|
||||
const makeInvoice = (overrides = {}) => prisma.invoicePool.create({ data: {
|
||||
@@ -110,7 +125,7 @@ async function main() {
|
||||
}
|
||||
const origin = await app.listen({ host: "127.0.0.1", port: 0 });
|
||||
const tokens = new Map();
|
||||
for (const actor of [employee, ...Object.values(approvers)]) {
|
||||
for (const actor of new Map([employee, ...Object.values(approvers)].map((actor) => [actor.id, actor])).values()) {
|
||||
const login = await fetch(`${origin}/api/auth/login`, { method: "POST", headers: { "content-type": "application/json", "x-device-fingerprint": `synthetic-${actor.id}` }, body: JSON.stringify({ identifier: actor.email, password: loginPassword }) });
|
||||
assert.equal(login.status, 200, "password login");
|
||||
tokens.set(actor.id, (await login.json()).data.accessToken);
|
||||
@@ -371,6 +386,7 @@ async function main() {
|
||||
assert.equal(resubmit.status, 200);
|
||||
const newWorkflowId = resubmit.body.data.workflowInstanceId;
|
||||
assert.notEqual(newWorkflowId, workflow.id);
|
||||
const approvalActors = [];
|
||||
for (const role of ["manager", "ceo", "finance"]) {
|
||||
const task = await prisma.workflowTask.findFirst({ where: { instance_id: newWorkflowId, status: "pending" } });
|
||||
assert.ok(task, `pending task for ${role}`);
|
||||
@@ -410,12 +426,17 @@ async function main() {
|
||||
}
|
||||
const approval = await post(`/workflow/tasks/${task.id}/approve`, {}, approvers[role].id);
|
||||
assert.equal(approval.status, 200, `${role} approval: ${approval.status} ${approval.body.error?.code ?? ""}`);
|
||||
approvalActors.push(approvers[role].id);
|
||||
assert.equal((await post(`/workflow/tasks/${task.id}/approve`, {}, approvers[role].id)).status, 409);
|
||||
}
|
||||
assert.equal((await prisma.reimbursementForm.findUnique({ where: { id: form.id } })).status, "approved");
|
||||
assert.equal(await prisma.workflowTask.count({ where: { instance_id: newWorkflowId, status: "pending" } }), 0);
|
||||
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 (managerIsCeo) {
|
||||
assert.deepEqual(approvalActors, [approvers.ceo.id, approvers.ceo.id, approvers.finance.id]);
|
||||
passed.push("small team: same CEO completes separate manager and CEO steps; no automatic person-level deduplication");
|
||||
}
|
||||
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, handoffCheck });
|
||||
}
|
||||
@@ -496,10 +517,207 @@ async function main() {
|
||||
root, browser, preview, loginPassword,
|
||||
});
|
||||
}
|
||||
if (ownerApprovalTest) {
|
||||
await prisma.approvalRule.update({ where: { id: approvalRule.id }, data: { owner_self_fallback_role: "finance" } });
|
||||
const prepareOwnerForm = async (owner) => {
|
||||
const ownedForm = await makeForm();
|
||||
const ownedInvoice = await makeInvoice();
|
||||
assert.equal((await bind(ownedInvoice, ownedForm)).status, 200);
|
||||
const item = await prisma.reimbursementItem.findFirst({ where: { reimbursement_id: ownedForm.id } });
|
||||
await prisma.reimbursementItem.update({ where: { id: item.id }, data: { expense_type_id: expenseType.id, description: "synthetic owner approval" } });
|
||||
await prisma.invoicePool.update({ where: { id: ownedInvoice.id }, data: { owner_employee_id: owner.id, uploaded_by_employee_id: owner.id } });
|
||||
await prisma.reimbursementForm.update({ where: { id: ownedForm.id }, data: { owner_id: owner.id, submitter_id: owner.id } });
|
||||
return ownedForm;
|
||||
};
|
||||
const ceoForm = await prepareOwnerForm(approvers.ceo);
|
||||
const ceoSubmitted = await post(`/reimbursements/${ceoForm.id}/submit`, {}, approvers.ceo.id);
|
||||
assert.equal(ceoSubmitted.status, 200, "CEO owner submits with explicit finance fallback");
|
||||
const instanceId = ceoSubmitted.body.data.workflowInstanceId;
|
||||
const tasks = await prisma.workflowTask.findMany({ where: { instance_id: instanceId } });
|
||||
assert.equal(tasks.length, 1, "CEO own chain collapses to one finance step");
|
||||
assert.equal(tasks[0].assignee_role, "finance");
|
||||
assert.equal((await post(`/workflow/tasks/${tasks[0].id}/approve`, {}, approvers.ceo.id)).status, 403);
|
||||
assert.equal((await post(`/workflow/tasks/${tasks[0].id}/approve`, {}, approvers.finance.id)).status, 200);
|
||||
assert.equal((await prisma.reimbursementForm.findUnique({ where: { id: ceoForm.id } })).status, "approved");
|
||||
passed.push("CEO owner with finance fallback: exactly one finance task, owner rejected, finance approves");
|
||||
const financeForm = await prepareOwnerForm(approvers.finance);
|
||||
const financeSubmitted = await post(`/reimbursements/${financeForm.id}/submit`, {}, approvers.finance.id);
|
||||
assert.equal(financeSubmitted.status, 200, "finance owner submits for one CEO approval");
|
||||
const originalFinanceInstanceId = financeSubmitted.body.data.workflowInstanceId;
|
||||
const returnedTask = await prisma.workflowTask.findFirst({ where: { instance_id: originalFinanceInstanceId, status: "pending" } });
|
||||
assert.equal(returnedTask.assignee_role, "ceo");
|
||||
assert.equal((await post(`/workflow/tasks/${returnedTask.id}/return`, { comment: "synthetic finance correction" }, approvers.ceo.id)).status, 200);
|
||||
const financeResubmitted = await post(`/reimbursements/${financeForm.id}/submit`, {}, approvers.finance.id);
|
||||
assert.equal(financeResubmitted.status, 200);
|
||||
const financeInstanceId = financeResubmitted.body.data.workflowInstanceId;
|
||||
assert.notEqual(financeInstanceId, originalFinanceInstanceId);
|
||||
assert.equal(await prisma.workflowTask.count({ where: { instance_id: originalFinanceInstanceId } }), 1);
|
||||
const selfTask = await prisma.workflowTask.findFirst({ where: { instance_id: financeInstanceId, status: "pending" } });
|
||||
assert.ok(selfTask);
|
||||
assert.equal(selfTask.assignee_role, "ceo");
|
||||
assert.equal(await prisma.workflowTask.count({ where: { instance_id: financeInstanceId } }), 1);
|
||||
const selfApproval = await post(`/workflow/tasks/${selfTask.id}/approve`, {}, approvers.finance.id);
|
||||
assert.equal(selfApproval.status, 403);
|
||||
assert.equal((await post(`/workflow/tasks/${selfTask.id}/approve`, {}, approvers.ceo.id)).status, 200);
|
||||
assert.equal((await post(`/workflow/tasks/${selfTask.id}/approve`, {}, approvers.ceo.id)).status, 409);
|
||||
assert.equal(await prisma.workflowTask.count({ where: { instance_id: financeInstanceId, status: "pending" } }), 0);
|
||||
assert.equal((await prisma.reimbursementForm.findUnique({ where: { id: financeForm.id } })).status, "approved");
|
||||
passed.push("finance owner: return/resubmit preserves history; one CEO approval completes reimbursement, owner denied, replay denied, no finance task");
|
||||
if (process.env.RUN_PAYMENT_EXPERIENCE_TEST === "1") {
|
||||
await require("./test-payment-experience.cjs")({
|
||||
app, prisma, employee, approvers, form: financeForm, payee: approvers.finance,
|
||||
fixtureCode: "SYN-PAY-FINANCE", post, origin, tokens, serverRequire, passed,
|
||||
browser, preview, loginPassword, handoffCheck,
|
||||
});
|
||||
passed.push("finance owner payment: finance creates own batch, cannot review, CEO separately reviews, synthetic export/receipt only");
|
||||
}
|
||||
for (const unavailable of ["inactive", "archived", "submitter", "missing_role"]) {
|
||||
const blockedForm = await prepareOwnerForm(approvers.finance);
|
||||
try {
|
||||
if (unavailable === "inactive") await prisma.employee.update({ where: { id: approvers.ceo.id }, data: { status: "archived" } });
|
||||
if (unavailable === "archived") await prisma.employee.update({ where: { id: approvers.ceo.id }, data: { archived_at: new Date() } });
|
||||
if (unavailable === "submitter") await prisma.reimbursementForm.update({ where: { id: blockedForm.id }, data: { submitter_id: approvers.ceo.id } });
|
||||
if (unavailable === "missing_role") await prisma.employeeRole.deleteMany({ where: { employee_id: approvers.ceo.id, role_code: "ceo" } });
|
||||
for (const budgetEnabled of [false, true]) {
|
||||
await prisma.featureFlag.upsert({
|
||||
where: { flag_key: "budget_precheck_v1" },
|
||||
create: { flag_key: "budget_precheck_v1", override_mode: budgetEnabled ? "force_on" : "force_off", rollout_type: "global", department_ids: [] },
|
||||
update: { override_mode: budgetEnabled ? "force_on" : "force_off" },
|
||||
});
|
||||
const precheck = await post(`/reimbursements/${blockedForm.id}/precheck`, {}, approvers.finance.id);
|
||||
assert.equal(precheck.status, 200);
|
||||
assert.equal(precheck.body.data.decision, "block");
|
||||
assert.ok(precheck.body.data.blockingReasons.some((reason) => reason.code === "finance_owner_ceo_unavailable"));
|
||||
assert.equal((await post(`/reimbursements/${blockedForm.id}/submit`, {}, approvers.finance.id)).status, 409);
|
||||
assert.equal(await prisma.workflowInstance.count({ where: { reimbursement_id: blockedForm.id } }), 0);
|
||||
assert.equal((await prisma.reimbursementForm.findUnique({ where: { id: blockedForm.id } })).status, "draft");
|
||||
}
|
||||
} finally {
|
||||
await prisma.employee.update({ where: { id: approvers.ceo.id }, data: { status: "active", archived_at: null } });
|
||||
if (unavailable === "missing_role") await prisma.employeeRole.create({ data: { employee_id: approvers.ceo.id, role_code: "ceo" } });
|
||||
await prisma.featureFlag.update({ where: { flag_key: "budget_precheck_v1" }, data: { override_mode: "force_off" } });
|
||||
}
|
||||
}
|
||||
passed.push("finance owner: inactive/archived/submitter/missing-role CEO blocks precheck and submission without pending workflow");
|
||||
for (const additionalRole of ["second_finance", "owner_ceo"]) {
|
||||
const actorId = additionalRole === "second_finance" ? employee.id : approvers.finance.id;
|
||||
const roleCode = additionalRole === "second_finance" ? "finance" : "ceo";
|
||||
await prisma.employeeRole.create({ data: { employee_id: actorId, role_code: roleCode } });
|
||||
try {
|
||||
const extraForm = await prepareOwnerForm(approvers.finance);
|
||||
const submitted = await post(`/reimbursements/${extraForm.id}/submit`, {}, approvers.finance.id);
|
||||
assert.equal(submitted.status, 200);
|
||||
const extraTasks = await prisma.workflowTask.findMany({ where: { instance_id: submitted.body.data.workflowInstanceId } });
|
||||
assert.equal(extraTasks.length, 1);
|
||||
assert.equal(extraTasks[0].assignee_role, "ceo");
|
||||
if (additionalRole === "owner_ceo") {
|
||||
const denied = await post(`/workflow/tasks/${extraTasks[0].id}/approve`, {}, approvers.finance.id);
|
||||
assert.equal(denied.status, 409);
|
||||
assert.equal(denied.body.error.details.workflow, "self_approval_not_allowed");
|
||||
} else {
|
||||
assert.equal((await post(`/workflow/tasks/${extraTasks[0].id}/approve`, {}, employee.id)).status, 403);
|
||||
}
|
||||
assert.equal((await post(`/workflow/tasks/${extraTasks[0].id}/approve`, {}, approvers.ceo.id)).status, 200);
|
||||
assert.equal((await prisma.reimbursementForm.findUnique({ where: { id: extraForm.id } })).status, "approved");
|
||||
} finally {
|
||||
await prisma.employeeRole.deleteMany({ where: { employee_id: actorId, role_code: roleCode } });
|
||||
}
|
||||
}
|
||||
passed.push("finance owner: second finance does not change CEO-only rule; finance+CEO applicant still denied self-approval");
|
||||
if (browser && preview) {
|
||||
const { expect } = createRequire(path.join(root, "client/package.json"))("@playwright/test");
|
||||
const uiOrigin = `http://127.0.0.1:${preview.httpServer.address().port}`;
|
||||
for (const mobile of [false, true]) {
|
||||
const browserForm = await prepareOwnerForm(approvers.finance);
|
||||
for (const actor of [approvers.finance, approvers.ceo]) {
|
||||
const context = await browser.newContext({ serviceWorkers: "block", viewport: mobile ? { width: 390, height: 844 } : { width: 1440, height: 960 } });
|
||||
try {
|
||||
const fingerprint = `synthetic-owner-${mobile}-${actor.id}`;
|
||||
await context.addCookies([{ name: "hyb_oa_device_fingerprint", value: fingerprint, url: uiOrigin }]);
|
||||
const login = await context.request.post(`${uiOrigin}/api/auth/login`, {
|
||||
headers: { "x-device-fingerprint": fingerprint },
|
||||
data: { identifier: actor.email, password: loginPassword },
|
||||
});
|
||||
assert.equal(login.status(), 200);
|
||||
const page = await context.newPage();
|
||||
if (actor.id === approvers.finance.id) {
|
||||
await page.goto(`${uiOrigin}${mobile ? "/mobile" : ""}/reimbursements/${browserForm.id}`);
|
||||
const response = page.waitForResponse((result) => result.url().endsWith(`/reimbursements/${browserForm.id}/submit`) && result.request().method() === "POST");
|
||||
const precheckResponse = page.waitForResponse((result) => result.url().endsWith(`/reimbursements/${browserForm.id}/precheck`) && result.request().method() === "POST");
|
||||
await page.getByTestId("reimbursement-submit").click();
|
||||
const precheck = await (await precheckResponse).json();
|
||||
assert.notEqual(precheck.data.decision, "block");
|
||||
if (precheck.data.decision !== "pass") {
|
||||
const confirm = page.getByRole("button", { name: "确认提交", exact: true });
|
||||
await expect(confirm).toBeVisible();
|
||||
await confirm.click();
|
||||
}
|
||||
assert.equal((await response).status(), 200);
|
||||
} else {
|
||||
const workflow = await prisma.workflowInstance.findFirst({ where: { reimbursement_id: browserForm.id, current_status: "pending" } });
|
||||
const task = await prisma.workflowTask.findFirst({ where: { instance_id: workflow.id, status: "pending" } });
|
||||
assert.equal(task.assignee_role, "ceo");
|
||||
await page.goto(`${uiOrigin}${mobile ? "/mobile/approvals" : "/workflow/tasks"}/${task.id}`);
|
||||
await page.getByTestId(mobile ? "mobile-approvals-approve" : "workflow-task-approve").click();
|
||||
await expect(page.getByTestId("workflow-action-confirm-dialog")).toBeVisible();
|
||||
const response = page.waitForResponse((result) => result.url().endsWith(`/workflow/tasks/${task.id}/approve`) && result.request().method() === "POST");
|
||||
await page.getByTestId("workflow-action-confirm-submit").click();
|
||||
assert.equal((await response).status(), 200);
|
||||
assert.equal((await prisma.reimbursementForm.findUnique({ where: { id: browserForm.id } })).status, "approved");
|
||||
assert.equal(await prisma.workflowTask.count({ where: { instance_id: workflow.id } }), 1);
|
||||
}
|
||||
} finally { await context.close(); }
|
||||
}
|
||||
}
|
||||
passed.push("finance owner: real desktop/mobile finance submits after precheck, CEO approves once via UI and no further task is created");
|
||||
}
|
||||
const transferForm = await prepareOwnerForm(approvers.finance);
|
||||
const transferSubmitted = await post(`/reimbursements/${transferForm.id}/submit`, {}, approvers.finance.id);
|
||||
assert.equal(transferSubmitted.status, 200);
|
||||
const transferTask = await prisma.workflowTask.findFirst({ where: { instance_id: transferSubmitted.body.data.workflowInstanceId, status: "pending" } });
|
||||
const assertNoPendingActions = async (actor) => {
|
||||
const response = await fetch(`${origin}/api/reimbursements?scope=all&pageSize=100`, {
|
||||
headers: { authorization: `Bearer ${tokens.get(actor.id)}` }, signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
const source = (await response.json()).data.items.find((item) => item.id === transferForm.id);
|
||||
assert.ok(source);
|
||||
assert.equal(source.pendingWorkflow, null, "non-CEO has no reimbursement list approval prompt");
|
||||
};
|
||||
await prisma.employeeRole.create({ data: { employee_id: employee.id, role_code: "finance" } });
|
||||
const transferBody = { toApproverId: employee.id, reason: "synthetic transfer safety validation" };
|
||||
const deniedTransfer = await post(`/workflow/tasks/${transferTask.id}/transfer`, transferBody, approvers.ceo.id);
|
||||
assert.equal(deniedTransfer.status, 403);
|
||||
assert.equal(deniedTransfer.body.error.details.reasonCode, "reimbursement.finance_owner_ceo_required");
|
||||
assert.equal((await prisma.workflowTask.findUnique({ where: { id: transferTask.id } })).assignee_role, "ceo");
|
||||
await prisma.employeeRole.create({ data: { employee_id: employee.id, role_code: "ceo" } });
|
||||
assert.equal((await post(`/workflow/tasks/${transferTask.id}/transfer`, transferBody, approvers.ceo.id)).status, 200);
|
||||
await prisma.employeeRole.deleteMany({ where: { employee_id: employee.id, role_code: "ceo" } });
|
||||
await assertNoPendingActions(employee);
|
||||
assert.equal((await post(`/workflow/tasks/${transferTask.id}/approve`, {}, employee.id)).status, 403);
|
||||
const delegatedGrant = await prisma.delegationGrant.create({ data: {
|
||||
principal_employee_id: employee.id, delegate_employee_id: approvers.ceo.id, scope: "all",
|
||||
valid_from: new Date(Date.now() - 60000),
|
||||
} });
|
||||
assert.equal((await post(`/workflow/tasks/${transferTask.id}/approve`, {}, approvers.ceo.id)).status, 403);
|
||||
await prisma.employeeRole.create({ data: { employee_id: employee.id, role_code: "ceo" } });
|
||||
await prisma.employeeRole.create({ data: { employee_id: approvers.ceo.id, role_code: "finance" } });
|
||||
await prisma.employeeRole.deleteMany({ where: { employee_id: approvers.ceo.id, role_code: "ceo" } });
|
||||
await assertNoPendingActions(approvers.ceo);
|
||||
assert.equal((await post(`/workflow/tasks/${transferTask.id}/approve`, {}, approvers.ceo.id)).status, 403);
|
||||
await prisma.employeeRole.create({ data: { employee_id: approvers.ceo.id, role_code: "ceo" } });
|
||||
await prisma.employeeRole.deleteMany({ where: { employee_id: approvers.ceo.id, role_code: "finance" } });
|
||||
assert.equal((await post(`/workflow/tasks/${transferTask.id}/approve`, {}, approvers.ceo.id)).status, 200);
|
||||
assert.equal((await prisma.reimbursementForm.findUnique({ where: { id: transferForm.id } })).status, "approved");
|
||||
await prisma.delegationGrant.delete({ where: { id: delegatedGrant.id } });
|
||||
await prisma.employeeRole.deleteMany({ where: { employee_id: employee.id, role_code: { in: ["finance", "ceo"] } } });
|
||||
passed.push("finance owner: transfer only to CEO, stale assignee or principal and non-CEO delegate rejected; eligible CEO delegation approves");
|
||||
}
|
||||
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_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));
|
||||
console.log(JSON.stringify({ status: businessBlockers.length ? "BLOCKED_OWNER_APPROVAL" : "PASS_REAL_DB_HTTP", passed, businessBlockers, 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));
|
||||
if (businessBlockers.length) process.exitCode = 1;
|
||||
} 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"); });
|
||||
@@ -517,4 +735,4 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => { console.error("Isolated HTTP verification failed:", error.code ?? error.name, error.code === "ERR_ASSERTION" ? error.message : "", error.stack?.match(/test-(?:invoice-first-http|reimbursement-retained-delete)\.cjs:\d+:\d+/)?.[0] ?? ""); process.exitCode = 1; });
|
||||
main().catch((error) => { console.error("Isolated HTTP verification failed:", error.code ?? error.name, error.code === "ERR_ASSERTION" ? error.message : "", error.stack?.match(/test-(?:invoice-first-http|reimbursement-retained-delete|payment-experience)\.cjs:\d+:\d+/)?.[0] ?? ""); process.exitCode = 1; });
|
||||
|
||||
@@ -6,15 +6,16 @@ const path = require("node:path");
|
||||
|
||||
module.exports = async function verifyPaymentExperience({
|
||||
app, prisma, employee, approvers, form, post, origin, tokens, serverRequire, passed, browser, preview, loginPassword, handoffCheck,
|
||||
payee = employee, fixtureCode = "SYN-PAY",
|
||||
}) {
|
||||
app.config.fieldEncryptionKey = randomBytes(32).toString("base64");
|
||||
app.config.fieldEncryptionKey ??= randomBytes(32).toString("base64");
|
||||
app.config.paymentBatchEnabled = true;
|
||||
const { encryptStringOrThrow } = serverRequire("./src/services/crypto");
|
||||
const company = await prisma.legalEntity.create({ data: { code: "SYN-PAY", name: "合成测试主体" } });
|
||||
const company = await prisma.legalEntity.create({ data: { code: fixtureCode, name: "合成测试主体" } });
|
||||
const bank = await prisma.bankInstitution.upsert({
|
||||
where: { code: "CCB" }, create: { code: "CCB", name: "中国建设银行" }, update: {},
|
||||
});
|
||||
await prisma.employee.update({ where: { id: employee.id }, data: {
|
||||
await prisma.employee.update({ where: { id: payee.id }, data: {
|
||||
legal_entity_id: company.id, bank_institution_id: bank.id, bank_name: bank.name,
|
||||
bank_account_encrypted: encryptStringOrThrow(app.config, `6222${"0".repeat(8)}5678`),
|
||||
} });
|
||||
@@ -22,7 +23,11 @@ module.exports = async function verifyPaymentExperience({
|
||||
legal_entity_id: company.id, bank_institution_id: bank.id, account_name: "合成测试主体",
|
||||
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 prisma.featureFlag.upsert({
|
||||
where: { flag_key: "payment_batch_v1" },
|
||||
create: { flag_key: "payment_batch_v1", override_mode: "force_on", rollout_type: "global", department_ids: [] },
|
||||
update: {},
|
||||
});
|
||||
await handoffCheck?.(approvers.finance, "reimbursement_batch", form.id, 1);
|
||||
await handoffCheck?.(approvers.ceo, "reimbursement_batch", form.id, 0);
|
||||
const readProgress = async (actor) => {
|
||||
@@ -73,7 +78,15 @@ module.exports = async function verifyPaymentExperience({
|
||||
assert.equal((await readProgress(approvers.finance)).stages.find((stage) => stage.key === "payment_and_receipt").detail,
|
||||
"已生成银行制单,等待付款完成并上传回单,非审批环节");
|
||||
}
|
||||
assert.equal((await readProgress(employee)).stages.some((stage) => stage.targetHref), false);
|
||||
if (payee.id === employee.id) {
|
||||
assert.equal((await readProgress(employee)).stages.some((stage) => stage.targetHref), false);
|
||||
} else {
|
||||
const unrelatedRead = await fetch(`${origin}/api/reimbursements/${form.id}`, {
|
||||
headers: { authorization: `Bearer ${tokens.get(employee.id)}` },
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
assert.equal(unrelatedRead.status, 403);
|
||||
}
|
||||
const audits = await prisma.auditLog.findMany({ where: { target_id: batch.id, action: { in: ["PAYMENT_ORDER_EXPORTED", "PAYMENT_ORDER_REEXPORTED"] } } });
|
||||
assert.equal(audits.length, 2);
|
||||
for (const audit of audits) {
|
||||
@@ -106,8 +119,10 @@ module.exports = async function verifyPaymentExperience({
|
||||
const actions = page.getByTestId(mobile ? "payment-batch-mobile-actions" : "payment-batch-action-panel");
|
||||
await expect(actions.getByRole("button", { name: "上传银行回单" })).toBeVisible();
|
||||
await actions.getByText("更多", { exact: true }).click();
|
||||
if (mobile) await page.getByRole("menuitem", { name: "重新下载制单" }).click();
|
||||
else await actions.getByTestId("payment-batch-action-redownloadOrder").click();
|
||||
const redownload = page.getByRole("menuitem", { name: "重新下载制单", exact: true });
|
||||
await expect(redownload).toHaveCount(1);
|
||||
await expect(redownload).toBeVisible();
|
||||
await redownload.click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog).toContainText("请勿重复提交");
|
||||
await expect(dialog.getByRole("textbox")).toHaveCount(0);
|
||||
|
||||
@@ -160,17 +160,22 @@ module.exports = async function verifyRetainedDeletion(context) {
|
||||
await page.goto(target);
|
||||
if (!mobile) await page.getByTestId("reimbursement-more-toggle").click();
|
||||
else await page.getByTestId("mobile-reimbursement-more").click();
|
||||
await page.getByRole("button", { name: "删除报销单", exact: true }).click();
|
||||
const deleteAction = page.getByRole(mobile ? "button" : "menuitem", { name: "删除报销单", exact: true });
|
||||
await expect(deleteAction).toHaveCount(1);
|
||||
await deleteAction.click();
|
||||
await expect(page.getByTestId("action-confirm-dialog")).toContainText("审批记录仍保留");
|
||||
await page.getByTestId("action-confirm-cancel").click();
|
||||
assert.equal((await prisma.reimbursementForm.findUnique({ where: { id: fixture.form.id } })).deleted_at, null);
|
||||
await page.getByRole("button", { name: "删除报销单", exact: true }).click();
|
||||
if (!mobile) await page.getByTestId("reimbursement-more-toggle").click();
|
||||
await deleteAction.click();
|
||||
await page.getByTestId("action-confirm-submit").click();
|
||||
await expect.poll(async () => Boolean((await prisma.reimbursementForm.findUnique({ where: { id: fixture.form.id } })).deleted_at)).toBe(true);
|
||||
await page.goto(target);
|
||||
await expect(page.getByText("已删除", { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByTestId("reimbursement-submit")).toHaveCount(0);
|
||||
await expect(page.getByTestId("reimbursement-invoice-add")).toHaveCount(0);
|
||||
if (!mobile && await page.getByTestId("reimbursement-more-toggle").count()) await page.getByTestId("reimbursement-more-toggle").click();
|
||||
await expect(page.getByRole("menuitem", { name: "删除报销单", exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "删除报销单", exact: true })).toHaveCount(0);
|
||||
await verify(fixture, "reject");
|
||||
} finally { await browserContext.close(); }
|
||||
|
||||
在新工单中引用
屏蔽一个用户