取消报销资产事前批准强制(08-30-reimbursement-assets-prior-approval-correction)

这个提交包含在:
2026-08-30 12:44:58 +08:00
父节点 0f9f40389e
当前提交 36c86f1dca
共修改 17 个文件,包含 489 行新增和 130 行删除
@@ -175,20 +175,49 @@ async function buildApp({
],
paymentBatchItem = null,
paymentActors = [],
itemRecord = {
id: "77777777-7777-4777-8777-777777777777",
expense_type_id: "88888888-8888-4888-8888-888888888888",
asset_prior_approval_basis: null,
},
expenseType = {
id: "88888888-8888-4888-8888-888888888888",
enabled: true,
category: "assets",
},
} = {}) {
const app = Fastify();
const tx = {
$queryRaw: vi.fn(async () => [reimbursement]),
reimbursementForm: {
update: vi.fn(),
update: vi.fn(async () => ({ id: REIMBURSEMENT_ID })),
delete: vi.fn(),
},
expenseType: {
findUnique: vi.fn(async () => expenseType),
},
invoicePool: {
updateMany: vi.fn(),
},
reimbursementItem: {
create: vi.fn(),
update: vi.fn(),
findFirst: vi.fn(async () => itemRecord),
aggregate: vi.fn(async () => ({ _sum: { amount: "10.00" } })),
create: vi.fn(async ({ data }) => ({
id: itemRecord.id,
expense_type_id: data.expense_type_id,
expense_date: data.expense_date,
amount: data.amount,
description: data.description ?? null,
asset_prior_approval_basis: data.asset_prior_approval_basis,
})),
update: vi.fn(async ({ data }) => ({
id: itemRecord.id,
expense_type_id: data.expense_type_id ?? itemRecord.expense_type_id,
expense_date: data.expense_date ?? new Date("2026-04-10T00:00:00.000Z"),
amount: data.amount ?? "10.00",
description: data.description ?? "合成明细",
asset_prior_approval_basis: data.asset_prior_approval_basis,
})),
deleteMany: vi.fn(),
},
auditLog: {
@@ -1020,3 +1049,139 @@ describe("reimbursement maintenance authorization", () => {
await app.close();
});
});
describe("reimbursement asset item nullable approval-basis compatibility", () => {
const itemId = "77777777-7777-4777-8777-777777777777";
const assetExpenseTypeId = "88888888-8888-4888-8888-888888888888";
const officeExpenseTypeId = "99999999-9999-4999-8999-999999999999";
it("creates an assets item with an omitted basis and persists null", async () => {
const { app, tx } = await buildApp({
permissionCodes: ["expense.edit_own"],
reimbursement: buildReimbursementRecord({ status: "draft" }),
});
const response = await app.inject({
method: "POST",
url: `/api/reimbursements/${REIMBURSEMENT_ID}/items`,
headers: { authorization: "Bearer test" },
payload: {
expenseTypeId: assetExpenseTypeId,
expenseDate: "2026-04-10",
amount: "10.00",
description: "合成设备",
},
});
expect(response.statusCode, response.body).toBe(200);
expect(tx.reimbursementItem.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ asset_prior_approval_basis: null }),
}));
expect(response.json().data.item.assetPriorApprovalBasis).toBeNull();
await app.close();
});
it("edits an assets item whose stored basis is null while omitted and keeps null", async () => {
const { app, tx } = await buildApp({
permissionCodes: ["expense.edit_own"],
reimbursement: buildReimbursementRecord({ status: "draft" }),
itemRecord: {
id: itemId,
expense_type_id: assetExpenseTypeId,
asset_prior_approval_basis: null,
},
});
const response = await app.inject({
method: "PATCH",
url: `/api/reimbursements/${REIMBURSEMENT_ID}/items/${itemId}`,
headers: { authorization: "Bearer test" },
payload: { amount: "20.00" },
});
expect(response.statusCode, response.body).toBe(200);
expect(tx.reimbursementItem.update).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ asset_prior_approval_basis: null }),
}));
await app.close();
});
it("preserves an existing assets basis when the field is omitted", async () => {
const { app, tx } = await buildApp({
permissionCodes: ["expense.edit_own"],
reimbursement: buildReimbursementRecord({ status: "draft" }),
itemRecord: {
id: itemId,
expense_type_id: assetExpenseTypeId,
asset_prior_approval_basis: "历史依据",
},
});
const response = await app.inject({
method: "PATCH",
url: `/api/reimbursements/${REIMBURSEMENT_ID}/items/${itemId}`,
headers: { authorization: "Bearer test" },
payload: { amount: "20.00" },
});
expect(response.statusCode, response.body).toBe(200);
expect(tx.reimbursementItem.update).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ asset_prior_approval_basis: "历史依据" }),
}));
await app.close();
});
it.each([null, " "])("clears an assets basis when explicitly sent as %j", async (assetPriorApprovalBasis) => {
const { app, tx } = await buildApp({
permissionCodes: ["expense.edit_own"],
reimbursement: buildReimbursementRecord({ status: "draft" }),
itemRecord: {
id: itemId,
expense_type_id: assetExpenseTypeId,
asset_prior_approval_basis: "历史依据",
},
});
const response = await app.inject({
method: "PATCH",
url: `/api/reimbursements/${REIMBURSEMENT_ID}/items/${itemId}`,
headers: { authorization: "Bearer test" },
payload: { assetPriorApprovalBasis },
});
expect(response.statusCode, response.body).toBe(200);
expect(tx.reimbursementItem.update).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ asset_prior_approval_basis: null }),
}));
await app.close();
});
it("clears an existing basis when switching to a non-assets type", async () => {
const { app, tx } = await buildApp({
permissionCodes: ["expense.edit_own"],
reimbursement: buildReimbursementRecord({ status: "draft" }),
itemRecord: {
id: itemId,
expense_type_id: assetExpenseTypeId,
asset_prior_approval_basis: "历史依据",
},
expenseType: { id: officeExpenseTypeId, enabled: true, category: "office" },
});
const response = await app.inject({
method: "PATCH",
url: `/api/reimbursements/${REIMBURSEMENT_ID}/items/${itemId}`,
headers: { authorization: "Bearer test" },
payload: { expenseTypeId: officeExpenseTypeId },
});
expect(response.statusCode, response.body).toBe(200);
expect(tx.reimbursementItem.update).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({
expense_type_id: officeExpenseTypeId,
asset_prior_approval_basis: null,
}),
}));
await app.close();
});
});
@@ -329,7 +329,8 @@ describe("reimbursements routes expense standard integration", () => {
await app.close();
});
it("requires prior approval basis for assets items", async () => {
it("allows assets items without an approval basis and continues submission", async () => {
resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck());
const { app, tx } = await buildApp({
reimbursementItems: [{
id: "asset-item",
@@ -340,12 +341,8 @@ describe("reimbursements routes expense standard integration", () => {
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
expect(response.statusCode).toBe(400);
expect(response.json().error.details).toEqual({
items: "asset_prior_approval_basis_required",
itemIds: ["asset-item"],
});
expect(tx.workflowInstance.create).not.toHaveBeenCalled();
expect(response.statusCode, response.body).toBe(200);
expect(tx.workflowInstance.create).toHaveBeenCalledTimes(1);
await app.close();
});