取消报销资产事前批准强制(08-30-reimbursement-assets-prior-approval-correction)
这个提交包含在:
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
在新工单中引用
屏蔽一个用户