fix: 优化报销制单下载与付款进度(09-08-reimbursement-payment-closeout)
这个提交包含在:
@@ -85,7 +85,7 @@ async function main() {
|
||||
await app.register(serverRequire("./src/routes/auth"), { prefix: "/api/auth" });
|
||||
app.setErrorHandler((error, request, reply) => reply.status(error.statusCode ?? 500).send({ error: { code: error.code, details: error.details } }));
|
||||
const envelope = (data) => ({ requestId: "synthetic-http", data });
|
||||
app.get("/api/me", { preHandler: app.authenticate }, async () => envelope({ id: employee.id, employeeNo: employee.employee_no, name: "synthetic", email: employee.email, departmentId: department.id, directManagerId: manager.id, status: "active", roleCodes: ["employee"], notificationPushEnabled: false }));
|
||||
app.get("/api/me", { preHandler: app.authenticate }, async (request) => envelope({ id: request.employee.id, employeeNo: request.employee.employee_no, name: "synthetic", email: request.employee.email, departmentId: department.id, directManagerId: manager.id, status: "active", roleCodes: request.employee.roleCodes, notificationPushEnabled: false }));
|
||||
app.get("/api/system/navigation", async () => envelope({ workspace: { id: "synthetic", profile: "small_team_internal", orgMode: "single_company", moduleVisibility: {} }, sections: { main: [], admin: [], backup: [] }, recommendedRolePresets: [] }));
|
||||
app.get("/api/notifications", async () => envelope({ items: [], total: 0, unreadCount: 0, page: 1, pageSize: 20 }));
|
||||
await app.register(serverRequire("./src/routes/expenseTypes"), { prefix: "/api" });
|
||||
@@ -93,6 +93,9 @@ async function main() {
|
||||
await app.register(serverRequire("./src/routes/invoicePool"), { prefix: "/api" });
|
||||
await app.register(serverRequire("./src/routes/reimbursements"), { prefix: "/api" });
|
||||
await app.register(serverRequire("./src/routes/workflow"), { prefix: "/api" });
|
||||
if (process.env.RUN_PAYMENT_EXPERIENCE_TEST === "1") {
|
||||
await app.register(serverRequire("./src/routes/paymentBatches"), { 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);
|
||||
@@ -283,6 +286,9 @@ async function main() {
|
||||
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 (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 });
|
||||
}
|
||||
|
||||
const competingForms = await Promise.all([makeForm(), makeForm()]);
|
||||
const sharedInvoice = await makeInvoice();
|
||||
@@ -363,7 +369,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: "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_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"); });
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const { randomBytes } = require("node:crypto");
|
||||
const { readFileSync } = require("node:fs");
|
||||
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.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 bank = await prisma.bankInstitution.upsert({
|
||||
where: { code: "CCB" }, create: { code: "CCB", name: "中国建设银行" }, update: {},
|
||||
});
|
||||
await prisma.employee.update({ where: { id: employee.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`),
|
||||
} });
|
||||
await prisma.paymentAccount.create({ data: {
|
||||
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: [] } });
|
||||
const created = await post("/payment-batches", {
|
||||
entityType: "reimbursement", entityIds: [form.id], defaultPaymentChannel: "bank",
|
||||
}, approvers.finance.id);
|
||||
assert.equal(created.status, 201, `create payment batch: ${created.body.error?.details?.reasonCode ?? created.status}`);
|
||||
const batch = created.body.data.item;
|
||||
const download = (actor) => fetch(`${origin}/api/payment-batches/${batch.id}/payment-order`, {
|
||||
method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${tokens.get(actor.id)}` },
|
||||
body: "{}", signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
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);
|
||||
assert.equal((await download(approvers.ceo)).status, 403);
|
||||
assert.equal((await download(employee)).status, 403);
|
||||
for (const count of [1, 2]) {
|
||||
const response = await download(approvers.finance);
|
||||
assert.equal(response.status, 200);
|
||||
assert.ok(decodeURIComponent(response.headers.get("content-disposition")).includes(`报销制单_${batch.batchNo}.xls`));
|
||||
assert.equal(response.headers.get("cache-control"), "no-store");
|
||||
const workbook = Buffer.from(await response.arrayBuffer());
|
||||
assert.equal(workbook.subarray(0, 8).toString("hex"), "d0cf11e0a1b11ae1");
|
||||
const current = await prisma.paymentBatch.findUnique({ where: { id: batch.id } });
|
||||
assert.equal(current.payment_order_export_count, count);
|
||||
assert.equal(current.status, "reviewed");
|
||||
const source = await prisma.reimbursementForm.findUnique({ where: { id: form.id } });
|
||||
assert.equal(source.status, "approved");
|
||||
assert.equal(source.paid_at, null);
|
||||
}
|
||||
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) {
|
||||
assert.equal(audit.extra.purpose, "reimbursement_bank_transfer");
|
||||
assert.equal(audit.extra.purposeSource, "system");
|
||||
assert.equal(Object.hasOwn(audit.extra, "reason"), false);
|
||||
}
|
||||
if (browser && preview) {
|
||||
const clientRequire = createRequire(path.resolve(__dirname, "../../client/package.json"));
|
||||
const { expect } = clientRequire("@playwright/test");
|
||||
const uiOrigin = `http://127.0.0.1:${preview.httpServer.address().port}`;
|
||||
for (const mobile of [false, true]) {
|
||||
const context = await browser.newContext({ serviceWorkers: "block", viewport: mobile ? { width: 390, height: 844 } : { width: 1440, height: 960 } });
|
||||
try {
|
||||
const fingerprint = `synthetic-payment-${mobile}`;
|
||||
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: approvers.finance.email, password: loginPassword } });
|
||||
assert.equal(login.status(), 200);
|
||||
const page = await context.newPage();
|
||||
await page.goto(`${uiOrigin}/payment-batches?id=${batch.id}`);
|
||||
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 dialog = page.getByRole("dialog");
|
||||
await expect(dialog).toContainText("请勿重复提交");
|
||||
await expect(dialog.getByRole("textbox")).toHaveCount(0);
|
||||
const received = page.waitForEvent("download");
|
||||
await dialog.getByRole("button", { name: "确认下载" }).click();
|
||||
const downloaded = await received;
|
||||
assert.equal(downloaded.suggestedFilename(), `报销制单_${batch.batchNo}.xls`);
|
||||
assert.equal(readFileSync(await downloaded.path()).subarray(0, 8).toString("hex"), "d0cf11e0a1b11ae1");
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(page.getByTestId("payment-batch-processing-panel")).toBeVisible();
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), true);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
passed.push("payment: real authenticated desktop/mobile browser downloads Chinese BIFF8 with no reason input; repeat warning and receipt action retained");
|
||||
}
|
||||
passed.push("payment: real finance create, CEO review, unauthorized export refused, Chinese BIFF8 first/re-download without reason, audited and still unpaid");
|
||||
};
|
||||
在新工单中引用
屏蔽一个用户