汇总已落盘的借款本金与还款、兼容历史编号的周期化编号、付款责任与回单核验、报销进度和排序、PWA/采购事务、错误原因与日期校验及ERP退役。 统一共享预算池锁序和锁后判断,恢复差旅原预算选择,补原因码固定夹具及反向完整性检查。 纳入配套迁移、测试、受管QA、分层验收合同、规约及任务记录;原始证据留在ignored artifacts。 关联任务:09-17-company-loan-principal-repayment、09-17-erp-exclusive-retirement、09-18-reimbursement-periodic-numbering、09-08-reimbursement-payment-closeout、09-18-local-concurrency-product-fixes、09-19-budget-pool-concurrency、09-05-error-explainability-payment-readiness。 预算补审由Claude定向验证2文件928项通过,原4446/28及r6结果仍仅对应各自历史输入;本轮未重跑测试。 完整新候选门禁、R1真实入口接入与12项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
138 行
9.4 KiB
JavaScript
138 行
9.4 KiB
JavaScript
const Fastify = require("fastify");
|
|
const { AppError } = require("../../../src/utils/AppError");
|
|
const rulesRoutes = require("../../../src/routes/budgetControlRules");
|
|
const budgetsRoutes = require("../../../src/routes/budgets");
|
|
const { resolveBudgetScope, selectBudgetPoolCandidate, resolveApplicableBudgetPoolByContext } = require("../../../src/services/budgetPool");
|
|
const ID = "11111111-1111-4111-8111-111111111111";
|
|
const OTHER_ID = "22222222-2222-4222-8222-222222222222";
|
|
const input = { name: "合成控制规则", entityType: "reimbursement", controlMode: "warn" };
|
|
const apps = [];
|
|
afterEach(async () => { await Promise.all(apps.splice(0).map((app) => app.close())); });
|
|
|
|
async function buildApp(allowed = true) {
|
|
const app = Fastify(); apps.push(app);
|
|
const prisma = {
|
|
budgetControlRule: {
|
|
findMany: vi.fn().mockResolvedValue([]), findUnique: vi.fn().mockResolvedValue({ id: ID, ...input }),
|
|
create: vi.fn(async ({ data }) => ({ id: ID, ...data })), update: vi.fn(async ({ data }) => ({ id: ID, ...data })),
|
|
},
|
|
budgetPool: { create: vi.fn() }, department: { findUnique: vi.fn() },
|
|
legalEntity: { findUnique: vi.fn() }, costCenter: { findUnique: vi.fn() },
|
|
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
|
};
|
|
app.decorate("prisma", prisma);
|
|
app.decorate("requirePermission", () => async (request) => {
|
|
request.employee = { id: OTHER_ID };
|
|
if (!allowed) throw new AppError({ code: "PERM_001", statusCode: 403, message: "无权限" });
|
|
});
|
|
app.setErrorHandler((error, request, reply) => reply.code(error.statusCode ?? 500).send({ error: { code: error.code, details: error.details } }));
|
|
await app.register(rulesRoutes, { prefix: "/api" });
|
|
await app.register(budgetsRoutes, { prefix: "/api" });
|
|
return { app, prisma };
|
|
}
|
|
|
|
describe("shared budget matching reasons", () => {
|
|
it.each([
|
|
[{ costCenterId: ID }, 400, "budget.company_required", { costCenterId: "legalEntityId_required" }],
|
|
[{ legalEntityId: ID }, 400, "budget.cost_center_required", { costCenterId: "required_with_legalEntityId" }],
|
|
[{ departmentId: ID, legalEntityId: ID }, 409, "budget.scope_mixed", { scope: "mixed_dimensions_not_allowed" }],
|
|
])("rejects scope %j with unchanged details", async (scope, statusCode, reasonCode, details) => {
|
|
expect(() => resolveBudgetScope(scope)).toThrowError(expect.objectContaining({ code: "VALID_001", statusCode, details: { ...details, reasonCode } }));
|
|
const { app, prisma } = await buildApp();
|
|
const response = await app.inject({ method: "POST", url: "/api/budgets", payload: { code: "SYNTHETIC", name: "合成预算", periodStart: "2024-02-29", periodEnd: "2024-03-31", allocatedAmount: "100.00", ...scope } });
|
|
expect(response.statusCode).toBe(statusCode);
|
|
expect(response.json().error).toEqual({ code: "VALID_001", details: { ...details, reasonCode } });
|
|
for (const model of ["department", "legalEntity", "costCenter"]) expect(prisma[model].findUnique).not.toHaveBeenCalled();
|
|
expect(prisma.budgetPool.create).not.toHaveBeenCalled();
|
|
expect(prisma.auditLog.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps the company guard for selection callers", () => {
|
|
expect(() => selectBudgetPoolCandidate([], { costCenterId: ID })).toThrowError(expect.objectContaining({ statusCode: 400, details: { costCenterId: "legalEntityId_required", reasonCode: "budget.company_required" } }));
|
|
});
|
|
|
|
it("explains ambiguous scoped pools without changing query bounds", async () => {
|
|
const records = [ID, OTHER_ID].map((id) => ({ id, legal_entity_id: ID, cost_center_id: ID, department_id: null }));
|
|
const tx = { budgetPool: { findMany: vi.fn().mockResolvedValue(records) } };
|
|
const effectiveDate = new Date("2024-02-29T00:00:00Z");
|
|
await expect(resolveApplicableBudgetPoolByContext(tx, { legalEntityId: ID, costCenterId: ID, effectiveDate })).rejects.toMatchObject({ code: "VALID_001", statusCode: 409, details: { budget: "budget_pool_ambiguous", scopeType: "cost_center_scoped", reasonCode: "budget.pool_ambiguous" } });
|
|
expect(tx.budgetPool.findMany).toHaveBeenCalledWith({ where: { status: "active", archived_at: null, period_start: { lte: effectiveDate }, period_end: { gte: effectiveDate } }, orderBy: [{ created_at: "asc" }, { code: "asc" }] });
|
|
});
|
|
|
|
it.each([null, "2024-02-29", new Date(NaN)])("rejects invalid effective dates before querying", async (effectiveDate) => {
|
|
const tx = { budgetPool: { findMany: vi.fn() } };
|
|
await expect(resolveApplicableBudgetPoolByContext(tx, { effectiveDate })).rejects.toMatchObject({ statusCode: 400, details: { effectiveDate: "invalid_date", reasonCode: "budget.effective_date_invalid" } });
|
|
expect(tx.budgetPool.findMany).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("preserves first-match legacy fallbacks and no-match null", () => {
|
|
const global = [{ id: ID }, { id: OTHER_ID }];
|
|
const departments = [{ id: ID, department_id: ID }, { id: OTHER_ID, department_id: ID }];
|
|
expect(selectBudgetPoolCandidate(global)).toBe(global[0]);
|
|
expect(selectBudgetPoolCandidate(departments, { departmentId: ID })).toBe(departments[0]);
|
|
expect(selectBudgetPoolCandidate(departments, { departmentId: OTHER_ID })).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("budget control rules error contracts", () => {
|
|
it.each([
|
|
["GET", "/budget-control-rules?enabled=invalid", undefined, "budget_rule.query_invalid"],
|
|
["POST", "/budget-control-rules", { ...input, controlMode: "invalid" }, "budget_rule.create_input_invalid"],
|
|
["PATCH", `/budget-control-rules/${ID}`, {}, "budget_rule.update_input_invalid"],
|
|
])("validates %s %s before business access", async (method, path, payload, reasonCode) => {
|
|
const { app, prisma } = await buildApp();
|
|
const response = await app.inject({ method, url: `/api${path}`, ...(payload ? { payload } : {}) });
|
|
expect(response.statusCode).toBe(400);
|
|
expect(response.json().error).toMatchObject({ code: "VALID_001", details: { reasonCode, formErrors: expect.any(Array), fieldErrors: expect.any(Object) } });
|
|
for (const fn of Object.values(prisma.budgetControlRule)) expect(fn).not.toHaveBeenCalled();
|
|
expect(prisma.auditLog.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each(["PATCH", "POST"])("validates the rule id for %s", async (method) => {
|
|
const { app, prisma } = await buildApp();
|
|
const response = await app.inject({ method, url: `/api/budget-control-rules/bad${method === "POST" ? "/archive" : ""}`, ...(method === "PATCH" ? { payload: { name: "合成" } } : {}) });
|
|
expect(response.statusCode).toBe(400);
|
|
expect(response.json().error.details).toEqual({ id: "invalid_uuid", reasonCode: "budget_rule.id_invalid" });
|
|
expect(prisma.budgetControlRule.findUnique).not.toHaveBeenCalled();
|
|
expect(prisma.auditLog.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
["POST", { ...input, costCenterId: ID }, "budget_rule.company_required", { costCenterId: "legalEntityId_required" }],
|
|
["PATCH", { costCenterId: ID }, "budget_rule.company_required", { costCenterId: "legalEntityId_required" }],
|
|
["POST", { ...input, entityType: "purchase_request", expenseTypeId: ID }, "budget_rule.expense_type_unsupported", { expenseTypeId: "only_reimbursement_supported" }],
|
|
["PATCH", { expenseTypeId: ID }, "budget_rule.expense_type_unsupported", { expenseTypeId: "only_reimbursement_supported" }],
|
|
])("preserves body-scoped rejection for %s %j", async (method, payload, reasonCode, details) => {
|
|
const { app, prisma } = await buildApp();
|
|
const response = await app.inject({ method, url: `/api/budget-control-rules${method === "PATCH" ? `/${ID}` : ""}`, payload });
|
|
expect(response.statusCode).toBe(409);
|
|
expect(response.json().error).toEqual({ code: "VALID_001", details: { ...details, reasonCode } });
|
|
for (const fn of Object.values(prisma.budgetControlRule)) expect(fn).not.toHaveBeenCalled();
|
|
expect(prisma.auditLog.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("preserves successful create defaults, paired patch and archive", async () => {
|
|
const { app, prisma } = await buildApp();
|
|
expect((await app.inject({ method: "POST", url: "/api/budget-control-rules", payload: input })).statusCode).toBe(201);
|
|
expect(prisma.budgetControlRule.create.mock.calls[0][0].data).toMatchObject({ priority: 100, enabled: true, control_mode: "warn" });
|
|
expect((await app.inject({ method: "PATCH", url: `/api/budget-control-rules/${ID}`, payload: { entityType: "reimbursement", expenseTypeId: ID, legalEntityId: ID, costCenterId: ID } })).statusCode).toBe(200);
|
|
expect((await app.inject({ method: "POST", url: `/api/budget-control-rules/${ID}/archive` })).statusCode).toBe(200);
|
|
expect(prisma.auditLog.create).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it("preserves a missing rule as 404", async () => {
|
|
const { app, prisma } = await buildApp(); prisma.budgetControlRule.findUnique.mockResolvedValue(null);
|
|
const response = await app.inject({ method: "PATCH", url: `/api/budget-control-rules/${ID}`, payload: { name: "合成" } });
|
|
expect(response.statusCode).toBe(404);
|
|
expect(prisma.budgetControlRule.update).not.toHaveBeenCalled();
|
|
expect(prisma.auditLog.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("preserves permission rejection before validation or writes", async () => {
|
|
const { app, prisma } = await buildApp(false);
|
|
expect((await app.inject({ method: "POST", url: "/api/budget-control-rules", payload: {} })).statusCode).toBe(403);
|
|
for (const fn of Object.values(prisma.budgetControlRule)) expect(fn).not.toHaveBeenCalled();
|
|
expect(prisma.auditLog.create).not.toHaveBeenCalled();
|
|
});
|
|
});
|