const { enableNumbering } = require("../helpers/activeNumbering"); const Fastify = require("fastify"); const { AppError } = require("../../../src/utils/AppError"); const budgetPool = require("../../../src/services/budgetPool"); const feeControl = require("../../../src/services/feeControl"); const notifications = require("../../../src/services/notifications"); const resolveApplicableBudgetPoolByContextMock = vi.fn(); const resolveEntityPrecheckMock = vi.fn(); const createRiskFlagsFromPrecheckMock = vi.fn(); const resolveRecipientsForAssigneeMock = vi.fn(); const originalResolveApplicableBudgetPoolByContext = budgetPool.resolveApplicableBudgetPoolByContext; const originalResolveEntityPrecheck = feeControl.resolveEntityPrecheck; const originalCreateRiskFlagsFromPrecheck = feeControl.createRiskFlagsFromPrecheck; const originalResolveRecipientsForAssignee = notifications.resolveRecipientsForAssignee; budgetPool.resolveApplicableBudgetPoolByContext = resolveApplicableBudgetPoolByContextMock; feeControl.resolveEntityPrecheck = resolveEntityPrecheckMock; feeControl.createRiskFlagsFromPrecheck = createRiskFlagsFromPrecheckMock; notifications.resolveRecipientsForAssignee = resolveRecipientsForAssigneeMock; delete require.cache[require.resolve("../../../src/routes/purchaseRequests")]; const purchaseRequestRoutes = require("../../../src/routes/purchaseRequests"); const PURCHASE_REQUEST_ID = "11111111-1111-4111-8111-111111111111"; const EMPLOYEE_ID = "22222222-2222-4222-8222-222222222222"; const DEPARTMENT_ID = "33333333-3333-4333-8333-333333333333"; const LEGAL_ENTITY_ID = "44444444-4444-4444-8444-444444444444"; const COST_CENTER_ID = "55555555-5555-4555-8555-555555555555"; const SUPPLIER_ID = "66666666-6666-4666-8666-666666666666"; const DOCUMENT_CATEGORY_ID = "77777777-7777-4777-8777-777777777777"; function buildPrecheck(overrides = {}) { return { entityType: "purchase_request", entityId: PURCHASE_REQUEST_ID, decision: "pass", matchedBudgetControlRule: null, budgetHint: null, standardChecks: [], warnings: [], blockingReasons: [], riskFlags: [], nextActions: ["可直接提交"], ...overrides, }; } async function buildApp(options = {}) { const app = Fastify(); const actor = { id: EMPLOYEE_ID, department_id: DEPARTMENT_ID, roleCodes: [], permissionCodes: ["procurement.submit"], }; const purchaseRequest = { id: PURCHASE_REQUEST_ID, applicant_id: EMPLOYEE_ID, submitter_id: null, department_id: DEPARTMENT_ID, legal_entity_id: LEGAL_ENTITY_ID, cost_center_id: COST_CENTER_ID, supplier_id: SUPPLIER_ID, document_category_id: DOCUMENT_CATEGORY_ID, budget_pool_id: options.explicitPool ? options.pool.id : null, request_no: null, numbering_version: options.numberingVersion ?? 1, title: "R6 采购提交热修测试", reason: null, currency_code: "CNY", total_amount: "99.00", expected_delivery_date: null, status: "draft", created_at: new Date("2026-05-12T00:00:00.000Z"), updated_at: new Date("2026-05-12T00:00:00.000Z"), items: [{ id: "item-1" }], applicant: { id: EMPLOYEE_ID, name: "申请人" }, document_category: { id: DOCUMENT_CATEGORY_ID, code: "PR_STANDARD", name: "标准采购申请", entity_type: "purchase_request", enabled: true, archived_at: null, }, }; const state = { workflowInstanceCreatePayload: null, }; const tx = { $queryRaw: vi.fn(async () => [purchaseRequest]), budgetPool: { findUnique: vi.fn(async () => options.pool ?? null), update: vi.fn() }, budgetLedgerEntry: { findMany: vi.fn(async () => []), create: vi.fn() }, legalEntity: { findUnique: vi.fn(async () => ({ id: LEGAL_ENTITY_ID, code: "LE", name: "测试法体", enabled: true, archived_at: null, })), }, costCenter: { findUnique: vi.fn(async () => ({ id: COST_CENTER_ID, code: "CC", name: "测试成本中心", legal_entity_id: LEGAL_ENTITY_ID, enabled: true, archived_at: null, })), }, supplier: { findUnique: vi.fn(async () => ({ id: SUPPLIER_ID, code: "SUP", name: "测试供应商", legal_entity_id: LEGAL_ENTITY_ID, enabled: true, archived_at: null, })), }, documentCategory: { findUnique: vi.fn(async () => purchaseRequest.document_category), findFirst: vi.fn(async () => purchaseRequest.document_category), }, documentSequence: { findUnique: vi.fn(async () => null), upsert: vi.fn(async () => ({ document_type: "purchase_request", prefix: "CG", date_format: "YYYYMMDD", separator: "-", number_length: 4, reset_period: "yearly", current_number: 1, last_reset_date: null, updated_at: new Date("2026-05-12T00:00:00.000Z"), updated_by: EMPLOYEE_ID, })), }, purchaseRequestItem: { aggregate: vi.fn(async () => ({ _sum: { line_amount: "99.00" }, })), }, purchaseRequest: { findUnique: vi.fn(async () => purchaseRequest), update: vi.fn(async ({ data }) => ({ ...purchaseRequest, ...data, })), }, workflowInstance: { create: vi.fn(async ({ data }) => { state.workflowInstanceCreatePayload = data; return { id: "88888888-8888-4888-8888-888888888888" }; }), }, workflowTask: { create: vi.fn(async () => ({ id: "99999999-9999-4999-8999-999999999999", assignee_id: null, assignee_role: "manager", })), }, workflowHistory: { create: vi.fn(async () => ({ id: "history-1" })), }, auditLog: { create: vi.fn(async () => ({ id: "audit-1" })), }, }; if (options.activeNumbering) enableNumbering(tx); const prisma = { $transaction: vi.fn(async (callback) => callback(tx)), auditLog: { create: vi.fn(async () => ({ id: "audit-outer" })), }, purchaseRequest: { findUnique: vi.fn(async () => purchaseRequest), }, workflowDefinition: { findFirst: vi.fn(async () => ({ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", definition_json: { steps: [ { id: "approval_1_manager", name: "主管审批", type: "approval", assignee_rule: { type: "role", value: "manager" }, }, ], }, })), }, }; app.decorate("prisma", prisma); app.decorate("config", { appTimezone: "Asia/Shanghai", budgetControlMode: options.budgetControlMode ?? "warn", budgetPrecheckEnabled: options.budgetPrecheckEnabled ?? false, cutoverConsoleEnabled: false, imNotifierEnabled: false, emailEnabled: false, }); app.decorate("hasPermission", (employee, permissionCode) => { const permissionCodes = employee?.permissionCodes ?? []; return permissionCodes.includes("*") || permissionCodes.includes(permissionCode); }); app.decorate("requirePermission", (permissionCode) => async (request) => { request.employee = actor; if (!app.hasPermission(actor, permissionCode)) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, }); } }); app.setErrorHandler((error, request, reply) => { reply.status(error.statusCode || 500).send({ requestId: request.id, error: { code: error.code || "SYS_001", message: error.message, details: error.details ?? null, }, }); }); await app.register(purchaseRequestRoutes, { prefix: "/api" }); return { app, prisma, tx, state }; } describe("purchase request submit budget precheck gate", () => { afterAll(() => { budgetPool.resolveApplicableBudgetPoolByContext = originalResolveApplicableBudgetPoolByContext; feeControl.resolveEntityPrecheck = originalResolveEntityPrecheck; feeControl.createRiskFlagsFromPrecheck = originalCreateRiskFlagsFromPrecheck; notifications.resolveRecipientsForAssignee = originalResolveRecipientsForAssignee; }); beforeEach(() => { vi.clearAllMocks(); resolveApplicableBudgetPoolByContextMock.mockResolvedValue(null); createRiskFlagsFromPrecheckMock.mockResolvedValue([]); resolveRecipientsForAssigneeMock.mockResolvedValue([]); }); it.each([false, true].flatMap(enabled => [false, true].map(explicitPool => ({ enabled, explicitPool }))))( 'blocks the latest pool usage inside the submit transaction: %j', async ({ enabled, explicitPool }) => { const pool = { id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', status: 'active', archived_at: null, allocated_amount: '100.00', used_amount: '60.00' }; resolveApplicableBudgetPoolByContextMock.mockResolvedValue(pool); resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck(enabled ? { decision: 'block', blockingReasons: [{ code: 'budget_exceeded' }] } : {})); const { app, tx } = await buildApp({ pool, explicitPool, budgetControlMode: 'block', budgetPrecheckEnabled: enabled }); try { const response = await app.inject({ method: 'POST', url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}/submit` }); expect(response.statusCode).toBe(409); expect(response.json().error.details.reasonCode).toBe('purchase_request.precheck_blocked'); expect(resolveEntityPrecheckMock).toHaveBeenCalledWith(expect.objectContaining({ prisma: tx, budgetPoolForWrite: pool })); expect(resolveApplicableBudgetPoolByContextMock).toHaveBeenCalledWith(tx, expect.any(Object), { forUpdate: true, additionalPoolIds: [explicitPool ? pool.id : null] }); expect(tx.purchaseRequestItem.aggregate.mock.invocationCallOrder[0]).toBeLessThan(resolveEntityPrecheckMock.mock.invocationCallOrder[0]); expect(tx.workflowInstance.create).not.toHaveBeenCalled(); expect(tx.budgetLedgerEntry.create).not.toHaveBeenCalled(); expect(tx.budgetPool.update).not.toHaveBeenCalled(); } finally { await app.close(); } }); it("does not 500 when no budget pool exists and budget precheck is disabled", async () => { resolveEntityPrecheckMock.mockResolvedValue( buildPrecheck({ budgetHint: { controlMode: "block", }, }), ); const { app, state } = await buildApp({ budgetControlMode: "warn", budgetPrecheckEnabled: false, }); const response = await app.inject({ method: "POST", url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}/submit`, }); expect(response.statusCode).toBe(200); expect(response.json().data).toEqual( expect.objectContaining({ purchaseRequestId: PURCHASE_REQUEST_ID, status: "reviewing", budgetPoolId: null, warnings: ["missing_budget_pool"], }), ); expect(state.workflowInstanceCreatePayload).toEqual( expect.objectContaining({ entity_type: "purchase_request", entity_id: PURCHASE_REQUEST_ID, }), ); await app.close(); }); it("runs the submit transaction with a 10s budget instead of Prisma's 5s default", async () => { resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck({ budgetHint: { controlMode: "block" } })); const { app, prisma } = await buildApp({ budgetControlMode: "warn", budgetPrecheckEnabled: false }); const response = await app.inject({ method: "POST", url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}/submit` }); expect(response.statusCode).toBe(200); expect(prisma.$transaction).toHaveBeenCalledWith(expect.any(Function), { maxWait: 5000, timeout: 10000 }); await app.close(); }); }); it.each([1, 2])("submits a stored v%s purchase draft under active policy without reclassifying it", async (version) => { const { app, tx } = await buildApp({ activeNumbering: true, numberingVersion: version }); try { const response = await app.inject({ method: "POST", url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}/submit` }); expect(response.statusCode).toBe(200); const data = tx.purchaseRequest.update.mock.calls.find(([arg]) => arg.data.request_no)?.[0].data; expect(data.request_no).toMatch(version === 1 ? /^CG-\d{8}-0001$/ : /^CG-\d{4}-0001$/); expect(tx.businessNumberPolicy.findUnique).not.toHaveBeenCalled(); } finally { await app.close(); } });