From fb60e653222214b824d3d199f2bd19a04010a86c Mon Sep 17 00:00:00 2001 From: shunli Date: Tue, 28 Apr 2026 19:40:30 +0800 Subject: [PATCH] =?UTF-8?q?R72-A2=20=E4=BF=AE=E5=A4=8D=E6=8A=A5=E9=94=80?= =?UTF-8?q?=E6=92=A4=E5=9B=9E=E9=87=8A=E6=94=BE=E7=BB=91=E5=AE=9A=E5=8F=91?= =?UTF-8?q?=E7=A5=A8=E5=B9=B6=E8=A1=A5=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/routes/reimbursements.js | 14 + .../routes/reimbursements.withdraw.test.js | 305 ++++++++++++++++++ 2 files changed, 319 insertions(+) create mode 100644 server/tests/unit/routes/reimbursements.withdraw.test.js diff --git a/server/src/routes/reimbursements.js b/server/src/routes/reimbursements.js index 9343c7be..f5a120fe 100644 --- a/server/src/routes/reimbursements.js +++ b/server/src/routes/reimbursements.js @@ -3075,6 +3075,19 @@ async function reimbursementRoutes(fastify) { }, }); + const unbound = await tx.invoicePool.updateMany({ + where: { + linked_reimbursement_id: reimbursementId, + status: "bound", + }, + data: { + status: "available", + linked_reimbursement_id: null, + linked_item_id: null, + updated_by: request.employee.id, + }, + }); + await tx.reimbursementForm.update({ where: { id: reimbursementId }, data: { @@ -3100,6 +3113,7 @@ async function reimbursementRoutes(fastify) { outcome: "withdrawn", workflowInstanceId: activeInstance.id, reimbursementStatus: "draft", + unboundInvoiceCount: unbound.count, }; }); diff --git a/server/tests/unit/routes/reimbursements.withdraw.test.js b/server/tests/unit/routes/reimbursements.withdraw.test.js new file mode 100644 index 00000000..0bec1899 --- /dev/null +++ b/server/tests/unit/routes/reimbursements.withdraw.test.js @@ -0,0 +1,305 @@ +const Fastify = require("fastify"); + +const { AppError } = require("../../../src/utils/AppError"); +const reimbursementRoutes = require("../../../src/routes/reimbursements"); +const invoicePoolRoutes = require("../../../src/routes/invoicePool"); + +const REIMBURSEMENT_ID = "11111111-1111-4111-8111-111111111111"; +const OWNER_ID = "22222222-2222-4222-8222-222222222222"; +const SUBMITTER_ID = "33333333-3333-4333-8333-333333333333"; +const ACTIVE_INSTANCE_ID = "44444444-4444-4444-8444-444444444444"; +const INVOICE_ID = "55555555-5555-4555-8555-555555555555"; +const ITEM_ID = "66666666-6666-4666-8666-666666666666"; + +function pickSelect(source, select) { + if (!select) { + return { ...source }; + } + + return Object.fromEntries( + Object.entries(select) + .filter(([, enabled]) => Boolean(enabled)) + .map(([key]) => [key, source[key]]), + ); +} + +function buildQueryRawHandler(state) { + return vi.fn(async (strings, ...values) => { + const sql = Array.isArray(strings) ? strings.join("?") : String(strings); + const entityId = values[0] ? String(values[0]) : null; + + if (sql.includes('FROM "reimbursement_forms"')) { + if (!state.reimbursement || (entityId && state.reimbursement.id !== entityId)) { + return []; + } + return [state.reimbursement]; + } + + if (sql.includes('FROM "invoice_pool"')) { + if (!state.invoice || (entityId && state.invoice.id !== entityId)) { + return []; + } + return [{ id: state.invoice.id }]; + } + + return []; + }); +} + +function buildPrisma({ processedTaskCount = 0 } = {}) { + const state = { + reimbursement: { + id: REIMBURSEMENT_ID, + owner_id: OWNER_ID, + submitter_id: SUBMITTER_ID, + status: "reviewing", + }, + workflowInstance: { + id: ACTIVE_INSTANCE_ID, + entity_type: "reimbursement", + entity_id: REIMBURSEMENT_ID, + current_status: "pending", + }, + invoice: { + id: INVOICE_ID, + status: "bound", + linked_reimbursement_id: REIMBURSEMENT_ID, + linked_item_id: ITEM_ID, + travel_order_id: null, + total_amount: "1683.00", + }, + processedTaskCount, + }; + + const tx = { + $queryRaw: buildQueryRawHandler(state), + workflowInstance: { + findFirst: vi.fn(async () => { + if ( + state.workflowInstance && + state.workflowInstance.entity_id === REIMBURSEMENT_ID && + state.workflowInstance.current_status === "pending" + ) { + return { id: state.workflowInstance.id }; + } + return null; + }), + update: vi.fn(async ({ data }) => { + state.workflowInstance = { ...state.workflowInstance, ...data }; + return state.workflowInstance; + }), + }, + workflowTask: { + count: vi.fn(async () => state.processedTaskCount), + updateMany: vi.fn(async () => ({ count: 1 })), + }, + reimbursementForm: { + update: vi.fn(async ({ data }) => { + state.reimbursement = { ...state.reimbursement, ...data }; + return state.reimbursement; + }), + }, + reimbursementItem: { + findFirst: vi.fn(async ({ where }) => { + if (where.id === ITEM_ID && where.reimbursement_id === REIMBURSEMENT_ID) { + return { id: ITEM_ID }; + } + return null; + }), + }, + workflowHistory: { + create: vi.fn(async () => ({ id: "history-1" })), + }, + invoicePool: { + findUnique: vi.fn(async ({ where, select }) => { + if (!state.invoice || where.id !== state.invoice.id) { + return null; + } + return pickSelect(state.invoice, select); + }), + updateMany: vi.fn(async ({ where, data }) => { + const matches = + state.invoice && + state.invoice.linked_reimbursement_id === where.linked_reimbursement_id && + state.invoice.status === where.status; + if (!matches) { + return { count: 0 }; + } + state.invoice = { ...state.invoice, ...data }; + return { count: 1 }; + }), + update: vi.fn(async ({ where, data, select }) => { + if (!state.invoice || where.id !== state.invoice.id) { + throw new Error("invoice not found"); + } + state.invoice = { ...state.invoice, ...data }; + return pickSelect(state.invoice, select); + }), + }, + auditLog: { + create: vi.fn(async () => ({ id: "audit-1" })), + }, + }; + + const prisma = { + ...tx, + $transaction: vi.fn(async (callback) => callback(tx)), + }; + + return { prisma, tx, state }; +} + +async function buildApp(options = {}) { + const { prisma, tx, state } = buildPrisma(options); + const permissionCodes = options.permissionCodes ?? ["expense.withdraw", "invoice.upload"]; + const app = Fastify(); + + app.decorate("prisma", prisma); + app.decorate("config", { + storageDir: "/tmp", + appOrigin: "http://localhost:5173", + maxUploadSizeBytes: 10 * 1024 * 1024, + invoiceArchiveUnusedDays: 30, + budgetControlMode: "warn", + appTimezone: "Asia/Shanghai", + }); + app.decorate("authenticate", async (request) => { + if (!request.headers.authorization) { + throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 }); + } + request.employee = { + id: OWNER_ID, + department_id: "dept-1", + roleCodes: [], + permissionCodes, + }; + }); + app.decorate( + "hasPermission", + (employee, permissionCode) => + (employee?.permissionCodes ?? []).includes("*") || + (employee?.permissionCodes ?? []).includes(permissionCode), + ); + app.decorate("requirePermission", (permissionCode) => async (request) => { + await app.authenticate(request); + if (!app.hasPermission(request.employee, permissionCode)) { + throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); + } + }); + app.decorate("assertEntityPermission", () => {}); + app.setErrorHandler((error, request, reply) => { + if (error instanceof AppError) { + return reply.status(error.statusCode).send({ + requestId: request.id, + error: { + code: error.code, + message: error.message, + details: error.details ?? {}, + }, + }); + } + + return reply.status(500).send({ + requestId: request.id, + error: { code: "SYS_001", message: "服务器内部错误", details: {} }, + }); + }); + + await app.register(reimbursementRoutes, { prefix: "/api" }); + await app.register(invoicePoolRoutes, { prefix: "/api" }); + + return { app, prisma, tx, state }; +} + +describe("POST /api/reimbursements/:id/withdraw invoice release", () => { + it("releases bound invoice on withdraw and allows rebinding to the draft reimbursement", async () => { + const { app, tx, state } = await buildApp(); + + const withdrawResponse = await app.inject({ + method: "POST", + url: `/api/reimbursements/${REIMBURSEMENT_ID}/withdraw`, + headers: { authorization: "Bearer test" }, + payload: { comment: "撤回补充说明" }, + }); + + expect(withdrawResponse.statusCode).toBe(200); + expect(withdrawResponse.json().data).toMatchObject({ + reimbursementId: REIMBURSEMENT_ID, + outcome: "withdrawn", + workflowInstanceId: ACTIVE_INSTANCE_ID, + reimbursementStatus: "draft", + unboundInvoiceCount: 1, + }); + expect(tx.invoicePool.updateMany).toHaveBeenCalledWith({ + where: { + linked_reimbursement_id: REIMBURSEMENT_ID, + status: "bound", + }, + data: { + status: "available", + linked_reimbursement_id: null, + linked_item_id: null, + updated_by: OWNER_ID, + }, + }); + expect(state.reimbursement.status).toBe("draft"); + expect(state.invoice).toMatchObject({ + status: "available", + linked_reimbursement_id: null, + linked_item_id: null, + }); + + const rebindResponse = await app.inject({ + method: "POST", + url: `/api/invoice-pool/${INVOICE_ID}/bind`, + headers: { authorization: "Bearer test" }, + payload: { + reimbursementId: REIMBURSEMENT_ID, + linkedItemId: ITEM_ID, + }, + }); + + expect(rebindResponse.statusCode).toBe(200); + expect(rebindResponse.json().data).toMatchObject({ + id: INVOICE_ID, + status: "bound", + linkedReimbursementId: REIMBURSEMENT_ID, + linkedItemId: ITEM_ID, + }); + expect(state.invoice).toMatchObject({ + status: "bound", + linked_reimbursement_id: REIMBURSEMENT_ID, + linked_item_id: ITEM_ID, + }); + + await app.close(); + }); + + it("does not release invoice when withdraw is blocked after approver already processed the workflow", async () => { + const { app, tx, state } = await buildApp({ processedTaskCount: 1 }); + + const response = await app.inject({ + method: "POST", + url: `/api/reimbursements/${REIMBURSEMENT_ID}/withdraw`, + headers: { authorization: "Bearer test" }, + payload: {}, + }); + + expect(response.statusCode).toBe(409); + expect(response.json().error).toMatchObject({ + code: "VALID_001", + details: { + withdraw: "already_processed_by_approver", + }, + }); + expect(tx.invoicePool.updateMany).not.toHaveBeenCalled(); + expect(state.reimbursement.status).toBe("reviewing"); + expect(state.invoice).toMatchObject({ + status: "bound", + linked_reimbursement_id: REIMBURSEMENT_ID, + linked_item_id: ITEM_ID, + }); + + await app.close(); + }); +});