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: null, request_no: null, 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]), 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" })), }, }; 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("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(); }); });