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

这个提交包含在:
2026-08-30 12:44:58 +08:00
父节点 0f9f40389e
当前提交 36c86f1dca
共修改 17 个文件,包含 489 行新增和 130 行删除
+2 -40
查看文件
@@ -3667,15 +3667,6 @@ async function reimbursementRoutes(fastify) {
details: { expenseTypeId: "invalid_or_disabled" },
});
}
if (expenseType.category === "assets" && !body.assetPriorApprovalBasis?.trim()) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { assetPriorApprovalBasis: "required_for_assets" },
});
}
const created = await tx.reimbursementItem.create({
data: {
reimbursement_id: reimbursementId,
@@ -3684,7 +3675,7 @@ async function reimbursementRoutes(fastify) {
amount: body.amount,
description: body.description,
asset_prior_approval_basis:
expenseType.category === "assets" ? body.assetPriorApprovalBasis.trim() : null,
expenseType.category === "assets" ? body.assetPriorApprovalBasis?.trim() || null : null,
created_by: request.employee.id,
updated_by: request.employee.id,
},
@@ -3827,15 +3818,6 @@ async function reimbursementRoutes(fastify) {
parsedBody.data.assetPriorApprovalBasis === undefined
? item.asset_prior_approval_basis
: parsedBody.data.assetPriorApprovalBasis;
if (expenseType.category === "assets" && !(targetApprovalBasis ?? "").trim()) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { assetPriorApprovalBasis: "required_for_assets" },
});
}
const updated = await tx.reimbursementItem.update({
where: { id: parsedItemId.data },
data: {
@@ -3853,7 +3835,7 @@ async function reimbursementRoutes(fastify) {
: {}),
asset_prior_approval_basis:
expenseType.category === "assets"
? targetApprovalBasis.trim()
? targetApprovalBasis?.trim() || null
: null,
updated_by: request.employee.id,
},
@@ -4029,7 +4011,6 @@ async function reimbursementRoutes(fastify) {
select: {
id: true,
amount: true,
asset_prior_approval_basis: true,
expense_type: {
select: { code: true, category: true, requires_invoice: true },
},
@@ -4058,25 +4039,6 @@ async function reimbursementRoutes(fastify) {
details: { items: "positive_amount_required", itemIds: invalidAmountItems },
});
}
const missingAssetApprovalItemIds = reimbursementItems
.filter(
(item) =>
item.expense_type.category === "assets" &&
!item.asset_prior_approval_basis?.trim(),
)
.map((item) => item.id);
if (missingAssetApprovalItemIds.length) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: {
items: "asset_prior_approval_basis_required",
itemIds: missingAssetApprovalItemIds,
},
});
}
const invoiceGuardGate = await evaluateFeatureFlag({
prisma: tx,
config: fastify.config,
+3 -3
查看文件
@@ -47,13 +47,13 @@ const ENABLED_EXPENSE_TYPES = [
{ code: "business_marketing", name: "营销投放", category: "business", description: "经批准的营销推广或广告投放费用;请上传正式票据并说明渠道和期间。" },
{ code: "business_meeting", name: "会议费", category: "business", description: "经批准举办或参加会议发生的费用;请上传正式票据并说明会议事项。" },
{ code: "business_training_fee", name: "培训费", category: "business", description: "经批准参加或组织培训发生的费用;请上传正式票据并说明培训事项。" },
{ code: "assets_equipment", name: "设备采购", category: "assets", description: "经事前批准由个人垫付的通用设备采购;请填写批准人、批准时间和可核验依据。" },
{ code: "assets_furniture", name: "家具采购", category: "assets", description: "经事前批准由个人垫付的办公家具采购;请填写批准人、批准时间和可核验依据。" },
{ code: "assets_equipment", name: "设备采购", category: "assets", description: "通用设备采购;请在明细说明中写明物品名称、实际使用人或责任人、存放位置,便于资产登记。" },
{ code: "assets_furniture", name: "家具采购", category: "assets", description: "办公家具采购;请在明细说明中写明物品名称、实际使用人或责任人、存放位置,便于资产登记。" },
{
code: "assets_office_electronics",
name: "办公电子设备",
category: "assets",
description: "单条明细金额达到 2,000 元的办公电子设备通常选择本类型,并填写事前批准依据。该门槛不等同于会计固定资产确认标准。",
description: "单条明细金额达到 2,000 元的办公电子设备通常选择本类型;请在明细说明中写明物品名称、实际使用人或责任人、存放位置,便于资产登记。该门槛不等同于会计固定资产确认标准。",
},
{ code: "utilities", name: "水电费", category: "other", description: "办公或生产经营相关的水电费用;请上传正式票据并说明费用期间。" },
{ code: "rent", name: "房租租赁费", category: "other", description: "经批准的场地、设备或其他租赁费用;请上传正式票据并说明租赁事项和期间。" },
@@ -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();
});
@@ -69,6 +69,23 @@ describe("reimbursement expense policy defaults", () => {
}
});
it("uses asset-registration guidance without inventing a prior-approval policy", () => {
const assetTypes = ENABLED_EXPENSE_TYPES.filter((item) => item.category === "assets");
expect(assetTypes.map((item) => item.code)).toEqual([
"assets_equipment",
"assets_furniture",
"assets_office_electronics",
]);
for (const expenseType of assetTypes) {
expect(expenseType.description).toContain("物品名称");
expect(expenseType.description).toContain("实际使用人或责任人");
expect(expenseType.description).toContain("存放位置");
expect(expenseType.description).not.toMatch(/事前批准|批准人|批准时间|批准依据/);
}
expect(assetTypes.find((item) => item.code === "assets_office_electronics")?.description)
.toContain("不等同于会计固定资产确认标准");
});
it("seeds the invoice guard disabled by default", () => {
expect(DEFAULT_FEATURE_FLAGS).toContainEqual({
flag_key: "reimbursement_invoice_guard_v1",