实现报销费用政策与票据明细门禁(08-29-reimbursement-expense-policy-invoice-guard)
这个提交包含在:
+145
-16
@@ -168,6 +168,7 @@ const REIMBURSEMENT_ITEM_BODY_SCHEMA = z.object({
|
||||
expenseDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
amount: z.string().regex(/^\d+(\.\d{1,2})?$/),
|
||||
description: z.string().trim().min(1).max(500),
|
||||
assetPriorApprovalBasis: z.string().trim().max(500).nullable().optional(),
|
||||
});
|
||||
|
||||
const DATE_ONLY_QUERY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
||||
@@ -1094,6 +1095,7 @@ function mapCreatedReimbursementItem(item) {
|
||||
expenseDate: toDateOnlyString(item.expense_date),
|
||||
amount: toMoneyString(item.amount),
|
||||
description: item.description ?? null,
|
||||
assetPriorApprovalBasis: item.asset_prior_approval_basis ?? null,
|
||||
expenseType: item.expense_type
|
||||
? {
|
||||
id: item.expense_type.id,
|
||||
@@ -1101,6 +1103,7 @@ function mapCreatedReimbursementItem(item) {
|
||||
name: item.expense_type.name,
|
||||
category: item.expense_type.category,
|
||||
enabled: item.expense_type.enabled,
|
||||
requiresInvoice: item.expense_type.requires_invoice,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
@@ -2409,6 +2412,7 @@ async function reimbursementRoutes(fastify) {
|
||||
expense_date: true,
|
||||
amount: true,
|
||||
description: true,
|
||||
asset_prior_approval_basis: true,
|
||||
expense_type: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -2416,6 +2420,7 @@ async function reimbursementRoutes(fastify) {
|
||||
name: true,
|
||||
category: true,
|
||||
enabled: true,
|
||||
requires_invoice: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -2645,6 +2650,13 @@ async function reimbursementRoutes(fastify) {
|
||||
});
|
||||
const travelItems = reimbursement.items.filter((item) => item.expense_type?.category === "travel");
|
||||
const isTravelReimbursement = Boolean(reimbursement.linked_travel_request_id || travelItems.length);
|
||||
const invoiceGuardGate = await evaluateFeatureFlag({
|
||||
prisma: fastify.prisma,
|
||||
config: fastify.config,
|
||||
flagKey: FEATURE_FLAG_KEYS.reimbursementInvoiceGuard,
|
||||
departmentId: reimbursement.department_id ?? null,
|
||||
strictDepartment: true,
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
id: reimbursement.id,
|
||||
@@ -2696,6 +2708,7 @@ async function reimbursementRoutes(fastify) {
|
||||
expenseDate: item.expense_date?.toISOString?.()?.slice(0, 10) ?? null,
|
||||
amount: toMoneyString(item.amount),
|
||||
description: item.description,
|
||||
assetPriorApprovalBasis: item.asset_prior_approval_basis ?? null,
|
||||
expenseType: item.expense_type
|
||||
? {
|
||||
id: item.expense_type.id,
|
||||
@@ -2703,6 +2716,7 @@ async function reimbursementRoutes(fastify) {
|
||||
name: item.expense_type.name,
|
||||
category: item.expense_type.category,
|
||||
enabled: item.expense_type.enabled,
|
||||
requiresInvoice: item.expense_type.requires_invoice,
|
||||
}
|
||||
: null,
|
||||
})),
|
||||
@@ -2740,6 +2754,7 @@ async function reimbursementRoutes(fastify) {
|
||||
}),
|
||||
approvalHistory: buildApprovalHistory(latestWorkflowInstance, approvalHistoryRows),
|
||||
riskFlags: riskFlags.map(buildPersistedRiskFlag),
|
||||
invoiceGuardEnabled: invoiceGuardGate.enabled,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -3642,7 +3657,7 @@ async function reimbursementRoutes(fastify) {
|
||||
|
||||
const expenseType = await tx.expenseType.findUnique({
|
||||
where: { id: body.expenseTypeId },
|
||||
select: { id: true, enabled: true },
|
||||
select: { id: true, enabled: true, category: true },
|
||||
});
|
||||
if (!expenseType || !expenseType.enabled) {
|
||||
throw new AppError({
|
||||
@@ -3652,6 +3667,14 @@ 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: {
|
||||
@@ -3660,6 +3683,8 @@ async function reimbursementRoutes(fastify) {
|
||||
expense_date: parseDateOnlyString(body.expenseDate),
|
||||
amount: body.amount,
|
||||
description: body.description,
|
||||
asset_prior_approval_basis:
|
||||
expenseType.category === "assets" ? body.assetPriorApprovalBasis.trim() : null,
|
||||
created_by: request.employee.id,
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
@@ -3669,6 +3694,7 @@ async function reimbursementRoutes(fastify) {
|
||||
expense_date: true,
|
||||
amount: true,
|
||||
description: true,
|
||||
asset_prior_approval_basis: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3687,6 +3713,7 @@ async function reimbursementRoutes(fastify) {
|
||||
expenseDate: result.item.expense_date.toISOString().slice(0, 10),
|
||||
amount: toMoneyString(result.item.amount),
|
||||
description: result.item.description,
|
||||
assetPriorApprovalBasis: result.item.asset_prior_approval_basis ?? null,
|
||||
},
|
||||
totalAmount: toMoneyString(result.totalAmount),
|
||||
});
|
||||
@@ -3773,7 +3800,7 @@ async function reimbursementRoutes(fastify) {
|
||||
id: parsedItemId.data,
|
||||
reimbursement_id: reimbursementId,
|
||||
},
|
||||
select: { id: true },
|
||||
select: { id: true, expense_type_id: true, asset_prior_approval_basis: true },
|
||||
});
|
||||
if (!item) {
|
||||
throw new AppError({
|
||||
@@ -3783,19 +3810,30 @@ async function reimbursementRoutes(fastify) {
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof parsedBody.data.expenseTypeId === "string") {
|
||||
const expenseType = await tx.expenseType.findUnique({
|
||||
where: { id: parsedBody.data.expenseTypeId },
|
||||
select: { id: true, enabled: true },
|
||||
const targetExpenseTypeId = parsedBody.data.expenseTypeId ?? item.expense_type_id;
|
||||
const expenseType = await tx.expenseType.findUnique({
|
||||
where: { id: targetExpenseTypeId },
|
||||
select: { id: true, enabled: true, category: true },
|
||||
});
|
||||
if (!expenseType || !expenseType.enabled) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { expenseTypeId: "invalid_or_disabled" },
|
||||
});
|
||||
}
|
||||
const targetApprovalBasis =
|
||||
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" },
|
||||
});
|
||||
if (!expenseType || !expenseType.enabled) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { expenseTypeId: "invalid_or_disabled" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await tx.reimbursementItem.update({
|
||||
@@ -3813,6 +3851,10 @@ async function reimbursementRoutes(fastify) {
|
||||
...(typeof parsedBody.data.description === "string"
|
||||
? { description: parsedBody.data.description.trim() }
|
||||
: {}),
|
||||
asset_prior_approval_basis:
|
||||
expenseType.category === "assets"
|
||||
? targetApprovalBasis.trim()
|
||||
: null,
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
select: {
|
||||
@@ -3821,6 +3863,7 @@ async function reimbursementRoutes(fastify) {
|
||||
expense_date: true,
|
||||
amount: true,
|
||||
description: true,
|
||||
asset_prior_approval_basis: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3839,6 +3882,7 @@ async function reimbursementRoutes(fastify) {
|
||||
expenseDate: result.item.expense_date.toISOString().slice(0, 10),
|
||||
amount: toMoneyString(result.item.amount),
|
||||
description: result.item.description,
|
||||
assetPriorApprovalBasis: result.item.asset_prior_approval_basis ?? null,
|
||||
},
|
||||
totalAmount: toMoneyString(result.totalAmount),
|
||||
});
|
||||
@@ -3982,7 +4026,18 @@ async function reimbursementRoutes(fastify) {
|
||||
const reimbursementItems = await tx.reimbursementItem.findMany({
|
||||
where: { reimbursement_id: reimbursementId },
|
||||
orderBy: [{ created_at: "asc" }, { id: "asc" }],
|
||||
select: { id: true, amount: true },
|
||||
select: {
|
||||
id: true,
|
||||
amount: true,
|
||||
asset_prior_approval_basis: true,
|
||||
expense_type: {
|
||||
select: { code: true, category: true, requires_invoice: true },
|
||||
},
|
||||
invoice_pool_items: {
|
||||
where: { status: "bound" },
|
||||
select: { id: true, total_amount: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!reimbursementItems.length) {
|
||||
throw new AppError({
|
||||
@@ -4003,6 +4058,77 @@ 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,
|
||||
flagKey: FEATURE_FLAG_KEYS.reimbursementInvoiceGuard,
|
||||
departmentId: reimbursement.department_id ?? null,
|
||||
strictDepartment: true,
|
||||
});
|
||||
if (invoiceGuardGate.enabled) {
|
||||
const unlinkedBoundInvoiceCount = await tx.invoicePool.count({
|
||||
where: {
|
||||
linked_reimbursement_id: reimbursementId,
|
||||
linked_item_id: null,
|
||||
status: "bound",
|
||||
},
|
||||
});
|
||||
const missingInvoiceItemIds = [];
|
||||
const amountExceededItems = [];
|
||||
for (const item of reimbursementItems) {
|
||||
if (!item.expense_type.requires_invoice) continue;
|
||||
const validInvoices = item.invoice_pool_items.filter(
|
||||
(invoice) => invoice.total_amount != null,
|
||||
);
|
||||
if (!validInvoices.length) {
|
||||
missingInvoiceItemIds.push(item.id);
|
||||
continue;
|
||||
}
|
||||
const invoiceTotal = validInvoices.reduce(
|
||||
(sum, invoice) => sum.plus(invoice.total_amount),
|
||||
new Prisma.Decimal(0),
|
||||
);
|
||||
if (new Prisma.Decimal(item.amount).gt(invoiceTotal)) {
|
||||
amountExceededItems.push({
|
||||
itemId: item.id,
|
||||
declaredAmount: toMoneyString(item.amount),
|
||||
invoiceTotal: invoiceTotal.toFixed(2),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (unlinkedBoundInvoiceCount || missingInvoiceItemIds.length || amountExceededItems.length) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: {
|
||||
items: "invoice_item_guard_failed",
|
||||
unlinkedInvoiceCount: unlinkedBoundInvoiceCount,
|
||||
missingInvoiceItemIds,
|
||||
amountExceededItems,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
reimbursement.total_amount = await recomputeTotalAmount(
|
||||
tx,
|
||||
@@ -4058,7 +4184,10 @@ async function reimbursementRoutes(fastify) {
|
||||
status: "bound",
|
||||
},
|
||||
});
|
||||
if (boundInvoiceCount < 1) {
|
||||
const requiresAnyInvoice = reimbursementItems.some(
|
||||
(item) => item.expense_type.requires_invoice,
|
||||
);
|
||||
if (!invoiceGuardGate.enabled && requiresAnyInvoice && boundInvoiceCount < 1) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
|
||||
在新工单中引用
屏蔽一个用户