完善借款、编号、付款与预算并发及验收(09-06-release-gate-integrity)
汇总已落盘的借款本金与还款、兼容历史编号的周期化编号、付款责任与回单核验、报销进度和排序、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项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
这个提交包含在:
@@ -0,0 +1,106 @@
|
||||
const Fastify = require("fastify");
|
||||
const { AppError } = require("../../../src/utils/AppError");
|
||||
const ID = "11111111-1111-4111-8111-111111111111";
|
||||
const TYPE = "22222222-2222-4222-8222-222222222222";
|
||||
const DEPARTMENT = "33333333-3333-4333-8333-333333333333";
|
||||
const domains = [
|
||||
{ file: "expenseStandards", path: "/expense-standards", model: "expenseStandard", reason: "expense_standard", payload: { expenseTypeId: TYPE, name: "合成标准", maxAmount: "12.50" }, empty: true, remove: true },
|
||||
{ file: "expenseTypes", path: "/expense-types", model: "expenseType", reason: "expense_type", payload: { code: "synthetic", name: "合成类型", category: "other", sortOrder: 1 }, empty: true },
|
||||
{ file: "leave-types", path: "/leave-types", model: "leaveType", reason: "leave_type", payload: { code: "synthetic", name: "合成假别", annualQuotaDays: 1.5 } },
|
||||
{ file: "reimbursementTemplates", path: "/reimbursement-templates", model: "reimbursementTemplate", reason: "reimbursement_template", payload: { name: "合成模板", items: [{ expenseTypeId: TYPE, description: "合成项目", defaultAmount: "12.50" }] }, empty: true, remove: true },
|
||||
];
|
||||
const apps = [];
|
||||
afterEach(async () => { await Promise.all(apps.splice(0).map((app) => app.close())); });
|
||||
async function buildApp({ allowed = true, canManage = true } = {}) {
|
||||
const app = Fastify(); apps.push(app);
|
||||
const records = {};
|
||||
const prisma = { auditLog: { create: vi.fn().mockResolvedValue({}) }, department: { findUnique: vi.fn().mockResolvedValue({ id: DEPARTMENT, name: "合成部门", archived_at: null }), findMany: vi.fn().mockResolvedValue([]) } };
|
||||
for (const { model } of domains) {
|
||||
records[model] = { id: ID, name: "合成", code: "synthetic", enabled: true, expense_type_id: TYPE, max_amount: "12.50", scope_type: "global", scope_key: null, items: [], leave_policy: { id: TYPE, annual_quota_days: 1.5, enabled: true } };
|
||||
prisma[model] = { findUnique: vi.fn(async () => records[model]), findMany: vi.fn(async () => [records[model]]), create: vi.fn(async ({ data }) => ({ ...records[model], ...data })), update: vi.fn(async ({ data }) => ({ ...records[model], ...data })), findFirst: vi.fn(async () => records[model]) };
|
||||
}
|
||||
prisma.expenseType.findMany.mockResolvedValue([{ id: TYPE, enabled: true }]);
|
||||
prisma.$transaction = vi.fn(async (callback) => callback(prisma));
|
||||
app.decorate("prisma", prisma); app.decorate("config", {});
|
||||
app.decorate("authenticate", async (request) => { request.employee = { id: ID, department_id: DEPARTMENT }; });
|
||||
const guard = () => async (request) => { await app.authenticate(request); if (!allowed) throw new AppError({ code: "PERM_001", statusCode: 403, message: "无权限" }); };
|
||||
app.decorate("requirePermission", guard); app.decorate("requireEntityPermission", guard); app.decorate("hasPermission", () => canManage);
|
||||
app.setErrorHandler((error, request, reply) => reply.code(error.statusCode ?? 500).send({ error: { code: error.code, details: error.details } }));
|
||||
for (const { file } of domains) await app.register(require(`../../../src/routes/${file}`), { prefix: "/api" });
|
||||
return { app, prisma, records };
|
||||
}
|
||||
function noWrites(prisma) { for (const { model } of domains) { expect(prisma[model].create).not.toHaveBeenCalled(); expect(prisma[model].update).not.toHaveBeenCalled(); } expect(prisma.auditLog.create).not.toHaveBeenCalled(); }
|
||||
function noReads(prisma) { for (const { model } of domains) { expect(prisma[model].findUnique).not.toHaveBeenCalled(); expect(prisma[model].findMany).not.toHaveBeenCalled(); } expect(prisma.$transaction).not.toHaveBeenCalled(); }
|
||||
function expectError(response, status, reasonCode, details) { expect(response.statusCode).toBe(status); expect(response.json().error).toEqual({ code: "VALID_001", details: { ...details, reasonCode } }); }
|
||||
function request(entity, method, payload, suffix) { return { method, url: `/api${entity.path}${suffix ?? (method === "PATCH" ? `/${ID}` : "")}`, ...(payload ? { payload } : {}) }; }
|
||||
for (const entity of domains) {
|
||||
describe(entity.path, () => {
|
||||
it.each([["GET", undefined, "query_invalid", "?enabled=bad"], ["POST", {}, "create_input_invalid", ""], ["PATCH", { name: "" }, "update_input_invalid", `/${ID}`]])("explains %s invalid input before database access", async (method, payload, reason, suffix) => {
|
||||
const { app, prisma } = await buildApp(); const response = await app.inject(request(entity, method, payload, suffix)); expect(response.statusCode).toBe(400); expect(response.json().error.details).toMatchObject({ reasonCode: `${entity.reason}.${reason}`, formErrors: [], fieldErrors: expect.any(Object) }); noWrites(prisma); noReads(prisma);
|
||||
});
|
||||
it.each(entity.remove ? ["PATCH", "DELETE"] : ["PATCH"])("explains invalid id on %s", async (method) => {
|
||||
const { app, prisma } = await buildApp(); expectError(await app.inject(request(entity, method, method === "PATCH" ? { name: "合成" } : undefined, "/bad")), 400, `${entity.reason}.id_invalid`, { id: "invalid_uuid" }); noWrites(prisma); noReads(prisma);
|
||||
});
|
||||
if (entity.empty) it("rejects empty patch before reads or writes", async () => {
|
||||
const { app, prisma } = await buildApp(); expectError(await app.inject(request(entity, "PATCH", {})), 400, `${entity.reason}.update_empty`, { body: "empty" }); noWrites(prisma); noReads(prisma);
|
||||
});
|
||||
it.each(["POST", "PATCH"])("keeps permission guard before %s validation", async (method) => {
|
||||
const { app, prisma } = await buildApp({ allowed: false }); const response = await app.inject(request(entity, method, {})); expect(response.statusCode).toBe(403); expect(response.json().error.code).toBe("PERM_001"); noWrites(prisma); noReads(prisma);
|
||||
});
|
||||
it("preserves missing record 404", async () => {
|
||||
const { app, prisma } = await buildApp(); prisma[entity.model].findUnique.mockResolvedValue(null); const response = await app.inject(request(entity, "PATCH", { name: "合成" })); expect(response.statusCode).toBe(404); expect(response.json().error.code).toBe("SYS_404"); noWrites(prisma);
|
||||
});
|
||||
});
|
||||
}
|
||||
it.each([domains[0], domains[1], domains[2]].flatMap((entity) => ["POST", "PATCH"].map((method) => [entity, method])))("preserves duplicate rejection on $0.path %s", async (entity, method) => {
|
||||
const { app, prisma } = await buildApp(); prisma[entity.model][method === "POST" ? "create" : "update"].mockRejectedValue({ code: "P2002", meta: { target: ["code"] } });
|
||||
const payload = method === "POST" ? entity.payload : { name: "合成修改" };
|
||||
expectError(await app.inject(request(entity, method, payload)), 409, `${entity.reason}.${entity.model === "expenseStandard" ? "scope_conflict" : "code_duplicate"}`, entity.model === "expenseStandard" ? { duplicate: "expense_type_scope_conflict" } : { code: "duplicate" }); expect(prisma.auditLog.create).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each(["POST", "PATCH"])("preserves positive standard amount on %s", async (method) => {
|
||||
const { app, prisma } = await buildApp(); expectError(await app.inject(request(domains[0], method, { ...(method === "POST" ? domains[0].payload : {}), maxAmount: "0.00" })), 400, "expense_standard.amount_not_positive", { maxAmount: "must_be_positive" }); noWrites(prisma); noReads(prisma);
|
||||
});
|
||||
it("preserves missing expense type before standard create", async () => {
|
||||
const { app, prisma } = await buildApp(); prisma.expenseType.findUnique.mockResolvedValue(null);
|
||||
expectError(await app.inject(request(domains[0], "POST", domains[0].payload)), 409, "expense_standard.expense_type_not_found", { expenseTypeId: "not_found" }); noWrites(prisma); expect(prisma.department.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each(["POST", "PATCH"].flatMap((method) => ["missing-key", "missing-department", "archived-department"].map((state) => [method, state])))("preserves %s standard scope %s", async (method, state) => {
|
||||
const { app, prisma } = await buildApp();
|
||||
if (state === "missing-department") prisma.department.findUnique.mockResolvedValue(null);
|
||||
if (state === "archived-department") prisma.department.findUnique.mockResolvedValue({ id: DEPARTMENT, archived_at: new Date("2024-01-01T00:00:00Z") });
|
||||
const payload = { ...(method === "POST" ? domains[0].payload : {}), scopeType: "department", ...(state === "missing-key" ? {} : { scopeKey: DEPARTMENT }) };
|
||||
expectError(await app.inject(request(domains[0], method, payload)), state === "missing-key" ? 400 : 409, `expense_standard.${state === "missing-key" ? "department_required" : "department_unavailable"}`, { scopeKey: state === "missing-key" ? "required_for_department_scope" : "invalid_department" }); noWrites(prisma);
|
||||
});
|
||||
it("keeps retained department scope during standard patch", async () => {
|
||||
const { app, prisma, records } = await buildApp(); records.expenseStandard.scope_type = "department"; records.expenseStandard.scope_key = DEPARTMENT;
|
||||
const response = await app.inject(request(domains[0], "PATCH", { name: "合成修改" })); expect(response.statusCode).toBe(200); expect(prisma.department.findUnique).toHaveBeenCalledWith(expect.objectContaining({ where: { id: DEPARTMENT } }));
|
||||
expect(prisma.expenseStandard.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ scope_type: "department", scope_key: DEPARTMENT }) }));
|
||||
});
|
||||
it.each(["POST", "PATCH"])("keeps invalid template type indices in %s without partial writes", async (method) => {
|
||||
const { app, prisma } = await buildApp();
|
||||
const items = [{ expenseTypeId: TYPE, description: "有效" }, { expenseTypeId: DEPARTMENT, description: "无效" }, { expenseTypeId: DEPARTMENT, description: "无效重复" }];
|
||||
expectError(await app.inject(request(domains[3], method, { name: "合成", items })), 409, "reimbursement_template.expense_types_unavailable", { items: [1, 2].map((index) => ({ index, expenseTypeId: DEPARTMENT, reason: "expense_type_not_found_or_disabled" })) });
|
||||
expect(prisma.expenseType.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { id: { in: [TYPE, DEPARTMENT] }, enabled: true } })); noWrites(prisma);
|
||||
});
|
||||
it.each(["POST", "PATCH"].flatMap((method) => ["over-limit", "zero-amount"].map((state) => [method, state])))("keeps %s template %s rejection before type lookup", async (method, state) => {
|
||||
const { app, prisma } = await buildApp(); const item = { expenseTypeId: TYPE, description: "合成", defaultAmount: state === "zero-amount" ? "0.00" : "1.00" };
|
||||
const response = await app.inject(request(domains[3], method, { name: "合成", items: Array.from({ length: state === "over-limit" ? 51 : 1 }, () => item) })); expect(response.statusCode).toBe(400); expect(response.json().error.details.reasonCode).toBe(`reimbursement_template.${method === "POST" ? "create" : "update"}_input_invalid`); noWrites(prisma); noReads(prisma);
|
||||
});
|
||||
it.each(["POST", "PATCH"])("keeps half-day quota schema on %s", async (method) => {
|
||||
const { app, prisma } = await buildApp(); const response = await app.inject(request(domains[2], method, { ...(method === "POST" ? domains[2].payload : {}), annualQuotaDays: 0.25 })); expect(response.statusCode).toBe(400); expect(response.json().error.details.reasonCode).toBe(`leave_type.${method === "POST" ? "create" : "update"}_input_invalid`); noWrites(prisma); noReads(prisma);
|
||||
});
|
||||
it("preserves leave-type lowercase normalization, defaults and nested policy creation", async () => {
|
||||
const { app, prisma } = await buildApp(); const response = await app.inject(request(domains[2], "POST", { ...domains[2].payload, code: " SYNTHETIC " })); expect(response.statusCode).toBe(200);
|
||||
expect(prisma.leaveType.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ code: "synthetic", enabled: true, requires_attachment: false, leave_policy: { create: { annual_quota_days: 1.5, enabled: true, description: null, created_by: ID, updated_by: ID } } }) })); expect(prisma.auditLog.create).not.toHaveBeenCalled();
|
||||
});
|
||||
it("preserves leave-type empty patch and optional policy update", async () => {
|
||||
const { app, prisma } = await buildApp(); expect((await app.inject(request(domains[2], "PATCH", {}))).statusCode).toBe(200); expect(prisma.leaveType.update).toHaveBeenLastCalledWith(expect.objectContaining({ data: { updated_by: ID, leave_policy: undefined } }));
|
||||
expect((await app.inject(request(domains[2], "PATCH", { annualQuotaDays: 2.5, enabled: false, policyDescription: null }))).statusCode).toBe(200);
|
||||
expect(prisma.leaveType.update).toHaveBeenLastCalledWith(expect.objectContaining({ data: { enabled: false, updated_by: ID, leave_policy: { update: { annual_quota_days: 2.5, enabled: false, description: null, updated_by: ID } } } })); expect(prisma.auditLog.create).not.toHaveBeenCalled();
|
||||
});
|
||||
it("keeps non-manager leave-type list limited to enabled entries", async () => {
|
||||
const { app, prisma } = await buildApp({ canManage: false }); expect((await app.inject(request(domains[2], "GET", undefined, "?enabled=false"))).statusCode).toBe(200); expect(prisma.leaveType.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { enabled: true } })); noWrites(prisma);
|
||||
});
|
||||
it("preserves unrelated unique constraint exceptions for leave types", async () => {
|
||||
const { app, prisma } = await buildApp(); prisma.leaveType.create.mockRejectedValue({ code: "P2002", meta: { target: ["unrelated"] } }); expect((await app.inject(request(domains[2], "POST", domains[2].payload))).statusCode).toBe(500); expect(prisma.auditLog.create).not.toHaveBeenCalled();
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户