实现报销费用政策与票据明细门禁(08-29-reimbursement-expense-policy-invoice-guard)
这个提交包含在:
@@ -137,6 +137,7 @@ INTEGRATION_OUTBOX_RETRY_SECONDS="120"
|
||||
|
||||
# R12 lite feature gates
|
||||
BUDGET_PRECHECK_ENABLED=false
|
||||
REIMBURSEMENT_INVOICE_GUARD_ENABLED=false
|
||||
PAYMENT_BATCH_ENABLED=false
|
||||
PORTAL_HOME_ENABLED=false
|
||||
GLOBAL_SEARCH_ENABLED=false
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
ALTER TABLE "expense_types"
|
||||
ADD COLUMN "requires_invoice" BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
ALTER TABLE "reimbursement_items"
|
||||
ADD COLUMN "asset_prior_approval_basis" TEXT;
|
||||
|
||||
UPDATE "expense_types"
|
||||
SET "requires_invoice" = false
|
||||
WHERE "code" IN ('travel_meal', 'travel_local_transport_allowance');
|
||||
@@ -892,6 +892,7 @@ model ExpenseType {
|
||||
category ExpenseCategory
|
||||
sort_order Int
|
||||
account_code_default String?
|
||||
requires_invoice Boolean @default(true)
|
||||
is_system_default Boolean @default(false)
|
||||
enabled Boolean @default(true)
|
||||
|
||||
@@ -1500,6 +1501,7 @@ model ReimbursementItem {
|
||||
expense_date DateTime @db.Date
|
||||
amount Decimal @db.Decimal(14, 2)
|
||||
description String
|
||||
asset_prior_approval_basis String?
|
||||
|
||||
created_at DateTime @default(now())
|
||||
created_by String? @db.Uuid
|
||||
|
||||
@@ -249,6 +249,7 @@ function getConfig() {
|
||||
|
||||
budgetControlMode: process.env.BUDGET_CONTROL_MODE ?? "warn",
|
||||
budgetPrecheckEnabled: process.env.BUDGET_PRECHECK_ENABLED === "true",
|
||||
reimbursementInvoiceGuardEnabled: process.env.REIMBURSEMENT_INVOICE_GUARD_ENABLED === "true",
|
||||
paymentBatchEnabled: process.env.PAYMENT_BATCH_ENABLED === "true",
|
||||
portalHomeEnabled: process.env.PORTAL_HOME_ENABLED === "true",
|
||||
globalSearchEnabled: process.env.GLOBAL_SEARCH_ENABLED === "true",
|
||||
|
||||
@@ -15,6 +15,7 @@ const CREATE_BODY_SCHEMA = z.object({
|
||||
category: z.enum(["travel", "rd", "production", "office", "business", "assets", "other"]),
|
||||
sortOrder: z.number().int().min(0).max(10000),
|
||||
accountCodeDefault: z.string().trim().max(50).optional(),
|
||||
requiresInvoice: z.boolean().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
});
|
||||
|
||||
@@ -79,6 +80,7 @@ function mapExpenseType(item) {
|
||||
category: item.category,
|
||||
sortOrder: item.sort_order,
|
||||
accountCodeDefault: item.account_code_default,
|
||||
requiresInvoice: item.requires_invoice,
|
||||
isSystemDefault: item.is_system_default,
|
||||
enabled: item.enabled,
|
||||
createdAt: item.created_at?.toISOString?.() ?? null,
|
||||
@@ -108,6 +110,7 @@ async function expenseTypesRoutes(fastify) {
|
||||
category: true,
|
||||
sort_order: true,
|
||||
account_code_default: true,
|
||||
requires_invoice: true,
|
||||
is_system_default: true,
|
||||
enabled: true,
|
||||
created_at: true,
|
||||
@@ -142,6 +145,7 @@ async function expenseTypesRoutes(fastify) {
|
||||
category: body.category,
|
||||
sort_order: body.sortOrder,
|
||||
account_code_default: body.accountCodeDefault ?? null,
|
||||
requires_invoice: body.requiresInvoice ?? true,
|
||||
enabled: body.enabled ?? true,
|
||||
created_by: request.employee.id,
|
||||
updated_by: request.employee.id,
|
||||
@@ -159,6 +163,7 @@ async function expenseTypesRoutes(fastify) {
|
||||
extra: {
|
||||
code: expenseType.code,
|
||||
name: expenseType.name,
|
||||
requiresInvoice: expenseType.requires_invoice,
|
||||
enabled: expenseType.enabled,
|
||||
},
|
||||
}),
|
||||
@@ -241,6 +246,9 @@ async function expenseTypesRoutes(fastify) {
|
||||
if (typeof body.enabled === "boolean" && body.enabled !== current.enabled) {
|
||||
changes.enabled = { before: current.enabled, after: body.enabled };
|
||||
}
|
||||
if (typeof body.requiresInvoice === "boolean" && body.requiresInvoice !== current.requires_invoice) {
|
||||
changes.requiresInvoice = { before: current.requires_invoice, after: body.requiresInvoice };
|
||||
}
|
||||
|
||||
const expenseType = await tx.expenseType.update({
|
||||
where: { id: expenseTypeId },
|
||||
@@ -253,6 +261,9 @@ async function expenseTypesRoutes(fastify) {
|
||||
? { account_code_default: body.accountCodeDefault }
|
||||
: {}),
|
||||
...(typeof body.enabled === "boolean" ? { enabled: body.enabled } : {}),
|
||||
...(typeof body.requiresInvoice === "boolean"
|
||||
? { requires_invoice: body.requiresInvoice }
|
||||
: {}),
|
||||
updated_at: now,
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
@@ -293,4 +304,3 @@ async function expenseTypesRoutes(fastify) {
|
||||
}
|
||||
|
||||
module.exports = expenseTypesRoutes;
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ const { registerInvoicePoolReadRoutes } = require("./invoicePool/read");
|
||||
const { normalizeInvoiceResponse } = require("./invoicePool/shared/mappers");
|
||||
const { buildInvoiceDetailSelect } = require("./invoicePool/shared/selects");
|
||||
const { writeBuffer } = require("../services/storage");
|
||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||
const {
|
||||
mapInvoiceUnavailableReason,
|
||||
resolveInvoiceAvailability,
|
||||
@@ -1573,7 +1574,7 @@ async function invoicePoolRoutes(fastify) {
|
||||
|
||||
const { reimbursementId, linkedItemId } = parsedBody.data;
|
||||
const reimbursementRows = await tx.$queryRaw`
|
||||
SELECT id, owner_id, submitter_id, status, reimbursement_date
|
||||
SELECT id, owner_id, submitter_id, department_id, status, reimbursement_date
|
||||
FROM "reimbursement_forms"
|
||||
WHERE id = ${reimbursementId}::uuid
|
||||
FOR UPDATE
|
||||
@@ -1616,6 +1617,22 @@ async function invoicePoolRoutes(fastify) {
|
||||
});
|
||||
}
|
||||
|
||||
const invoiceGuardGate = await evaluateFeatureFlag({
|
||||
prisma: tx,
|
||||
config: fastify.config,
|
||||
flagKey: FEATURE_FLAG_KEYS.reimbursementInvoiceGuard,
|
||||
departmentId: reimbursement.department_id ?? null,
|
||||
strictDepartment: true,
|
||||
});
|
||||
if (invoiceGuardGate.enabled && !linkedItemId) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { linkedItemId: "required_when_invoice_guard_enabled" },
|
||||
});
|
||||
}
|
||||
|
||||
const resolvedLink = await resolveInvoiceLinkedItem(tx, {
|
||||
reimbursementId,
|
||||
reimbursementDate: reimbursement.reimbursement_date,
|
||||
|
||||
+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: "参数校验失败",
|
||||
|
||||
@@ -3,54 +3,69 @@ require("dotenv/config");
|
||||
const { createPrismaClient } = require("../prismaClient");
|
||||
|
||||
const ENABLED_EXPENSE_TYPES = [
|
||||
{ code: "travel_airfare", name: "机票", category: "travel" },
|
||||
{ code: "travel_train", name: "火车票", category: "travel" },
|
||||
{ code: "travel_taxi", name: "打车费", category: "travel" },
|
||||
{ code: "travel_hotel", name: "住宿费", category: "travel" },
|
||||
{ code: "travel_bus", name: "大巴车票", category: "travel" },
|
||||
{ code: "travel_meal", name: "伙食补助费", category: "travel" },
|
||||
{ code: "travel_airfare", name: "机票", category: "travel", description: "出差乘坐飞机发生的费用;请上传正式票据并填写对应行程。" },
|
||||
{ code: "travel_train", name: "火车票", category: "travel", description: "出差乘坐火车发生的费用;请上传正式票据并填写对应行程。" },
|
||||
{ code: "travel_taxi", name: "打车费", category: "travel", description: "出差期间必要的出租车或网约车费用;请上传正式票据并说明行程。" },
|
||||
{ code: "travel_hotel", name: "住宿费", category: "travel", description: "出差住宿费用;请上传正式票据并填写住宿日期。" },
|
||||
{ code: "travel_bus", name: "大巴车票", category: "travel", description: "出差乘坐城际大巴或客车发生的费用;请上传正式票据并填写对应行程。" },
|
||||
{ code: "travel_meal", name: "伙食补助费", category: "travel", description: "按符合条件的出差天数计算,无需发票;说明中填写日期范围、天数、标准和算式。", requiresInvoice: false },
|
||||
{
|
||||
code: "travel_local_transport_allowance",
|
||||
name: "市内交通补助费",
|
||||
category: "travel",
|
||||
description: "按符合条件的出差天数计算,无需发票;说明中填写日期范围、天数、标准和算式。",
|
||||
requiresInvoice: false,
|
||||
},
|
||||
{ code: "travel_fuel", name: "油费", category: "travel" },
|
||||
{ code: "travel_toll", name: "过路费", category: "travel" },
|
||||
{ code: "travel_parking", name: "停车费", category: "travel" },
|
||||
{ code: "travel_visa", name: "签证费", category: "travel" },
|
||||
{ code: "rd_materials", name: "研发材料", category: "rd" },
|
||||
{ code: "rd_testing", name: "测试费用", category: "rd" },
|
||||
{ code: "rd_prototype", name: "样机制作", category: "rd" },
|
||||
{ code: "rd_software", name: "软件订阅", category: "rd" },
|
||||
{ code: "rd_external_service", name: "外部技术服务", category: "rd" },
|
||||
{ code: "production_raw_material", name: "生产原材料", category: "production" },
|
||||
{ code: "production_processing", name: "加工费", category: "production" },
|
||||
{ code: "production_quality", name: "质量检测", category: "production" },
|
||||
{ code: "production_maintenance", name: "设备维护", category: "production" },
|
||||
{ code: "production_energy", name: "生产能耗", category: "production" },
|
||||
{ code: "office_stationery", name: "文具耗材", category: "office" },
|
||||
{ code: "office_supplies", name: "办公用品", category: "office" },
|
||||
{ code: "office_property_fee", name: "物业费用", category: "office" },
|
||||
{ code: "office_communication", name: "通讯费用", category: "office" },
|
||||
{ code: "office_postal", name: "邮寄快递", category: "office" },
|
||||
{ code: "office_cleaning", name: "保洁服务", category: "office" },
|
||||
{ code: "business_client_meal", name: "客户招待", category: "business" },
|
||||
{ code: "business_gift", name: "商务礼品", category: "business" },
|
||||
{ code: "business_event", name: "活动执行", category: "business" },
|
||||
{ code: "business_marketing", name: "营销投放", category: "business" },
|
||||
{ code: "business_training", name: "培训会议", category: "business" },
|
||||
{ code: "assets_equipment", name: "设备采购", category: "assets" },
|
||||
{ code: "assets_furniture", name: "家具采购", category: "assets" },
|
||||
{ code: "assets_it_hardware", name: "IT 硬件", category: "assets" },
|
||||
{ code: "utilities", name: "水电费", category: "other" },
|
||||
{ code: "rent", name: "房租租赁费", category: "other" },
|
||||
{ code: "other_misc", name: "其他杂项", category: "other" },
|
||||
{ code: "travel_fuel", name: "油费", category: "travel", description: "经批准自驾或公务用车发生的燃油费用;请上传正式票据并说明行程。" },
|
||||
{ code: "travel_toll", name: "过路费", category: "travel", description: "经批准自驾或公务用车发生的通行费用;请上传正式票据并说明行程。" },
|
||||
{ code: "travel_parking", name: "停车费", category: "travel", description: "出差或公务期间发生的停车费用;请上传正式票据并说明事由。" },
|
||||
{ code: "travel_visa", name: "签证费", category: "travel", description: "经批准的出境公务签证费用;请上传正式票据并说明对应事项。" },
|
||||
{ code: "rd_materials", name: "研发材料", category: "rd", description: "研发活动直接使用的材料费用;请上传正式票据并说明项目和用途。" },
|
||||
{ code: "rd_testing", name: "测试费用", category: "rd", description: "研发测试、检验或认证费用;请上传正式票据并说明项目和测试内容。" },
|
||||
{ code: "rd_prototype", name: "样机制作", category: "rd", description: "研发样机、试制件制作费用;请上传正式票据并说明项目和用途。" },
|
||||
{ code: "rd_software", name: "软件订阅", category: "rd", description: "研发使用的软件许可或订阅费用;请上传正式票据并说明使用期限和用途。" },
|
||||
{ code: "rd_external_service", name: "外部技术服务", category: "rd", description: "研发相关的外部技术服务费用;请上传正式票据并说明项目、服务内容和验收依据。" },
|
||||
{ code: "production_raw_material", name: "生产原材料", category: "production", description: "生产直接使用的原材料费用;请上传正式票据并说明用途。" },
|
||||
{ code: "production_processing", name: "加工费", category: "production", description: "委外加工或生产工序费用;请上传正式票据并说明加工内容。" },
|
||||
{ code: "production_quality", name: "质量检测", category: "production", description: "生产质量检测、检验或认证费用;请上传正式票据并说明检测对象。" },
|
||||
{ code: "production_maintenance", name: "设备维护", category: "production", description: "生产设备维修、保养或维护费用;请上传正式票据并说明设备和事项。" },
|
||||
{ code: "production_energy", name: "生产能耗", category: "production", description: "可归属生产活动的能源费用;请上传正式票据并说明期间和用途。" },
|
||||
{ code: "office_stationery", name: "文具耗材", category: "office", description: "日常办公文具和消耗品;请上传正式票据并说明用途。" },
|
||||
{
|
||||
code: "office_supplies",
|
||||
name: "办公用品",
|
||||
category: "office",
|
||||
description: "单条明细金额低于 2,000 元的办公用品或低值配件;多件同类物品请分开记载。2,000 元及以上通常选择办公电子设备。",
|
||||
},
|
||||
{ code: "office_property_fee", name: "物业费用", category: "office", description: "办公场所物业管理等费用;请上传正式票据并说明费用期间。" },
|
||||
{ code: "office_communication", name: "通讯费用", category: "office", description: "经批准的办公通讯费用;请上传正式票据并说明使用人和期间。" },
|
||||
{ code: "office_postal", name: "邮寄快递", category: "office", description: "公务文件或物品的邮寄快递费用;请上传正式票据并说明寄送事项。" },
|
||||
{ code: "office_cleaning", name: "保洁服务", category: "office", description: "办公场所保洁服务费用;请上传正式票据并说明服务期间。" },
|
||||
{ code: "business_client_meal", name: "业务招待费", category: "business", description: "因业务接洽发生的招待费用;请上传正式票据并说明事由和参与对象。" },
|
||||
{ code: "business_gift", name: "商务礼品", category: "business", description: "经事前批准的商务礼品费用;请上传正式票据并说明用途和对象。" },
|
||||
{ code: "business_event", name: "活动执行", category: "business", description: "经批准的业务活动执行费用;请上传正式票据并说明活动和用途。" },
|
||||
{ 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_office_electronics",
|
||||
name: "办公电子设备",
|
||||
category: "assets",
|
||||
description: "单条明细金额达到 2,000 元的办公电子设备通常选择本类型,并填写事前批准依据。该门槛不等同于会计固定资产确认标准。",
|
||||
},
|
||||
{ code: "utilities", name: "水电费", category: "other", description: "办公或生产经营相关的水电费用;请上传正式票据并说明费用期间。" },
|
||||
{ code: "rent", name: "房租租赁费", category: "other", description: "经批准的场地、设备或其他租赁费用;请上传正式票据并说明租赁事项和期间。" },
|
||||
{ code: "other_misc", name: "其他杂项", category: "other", description: "仅用于无法归入现有类型的必要支出;请上传正式票据并完整说明事由。" },
|
||||
];
|
||||
|
||||
const DISABLED_LEGACY_EXPENSE_TYPES = [
|
||||
{ code: "transport", name: "legacy-交通费", category: "travel" },
|
||||
{ code: "meal", name: "legacy-餐费", category: "business" },
|
||||
{ code: "rd", name: "legacy-研发费", category: "rd" },
|
||||
{ code: "business_training", name: "培训会议", category: "business" },
|
||||
{ code: "assets_it_hardware", name: "IT 硬件", category: "assets" },
|
||||
];
|
||||
|
||||
const DEFAULT_APPROVAL_RULES = [
|
||||
@@ -281,6 +296,13 @@ const DEFAULT_FEATURE_FLAGS = [
|
||||
department_ids: [],
|
||||
note: "bootstrap default: force_off",
|
||||
},
|
||||
{
|
||||
flag_key: "reimbursement_invoice_guard_v1",
|
||||
override_mode: "force_off",
|
||||
rollout_type: "global",
|
||||
department_ids: [],
|
||||
note: "bootstrap default: force_off",
|
||||
},
|
||||
{
|
||||
flag_key: "payment_batch_v1",
|
||||
override_mode: "force_on",
|
||||
@@ -540,10 +562,10 @@ async function seedExpenseTypes(prisma) {
|
||||
where: { code: item.code },
|
||||
update: {
|
||||
name: item.name,
|
||||
description: null,
|
||||
description: item.description ?? null,
|
||||
category: item.category,
|
||||
sort_order: index + 1,
|
||||
account_code_default: null,
|
||||
requires_invoice: item.requiresInvoice ?? true,
|
||||
is_system_default: true,
|
||||
enabled: item.enabled,
|
||||
updated_at: now,
|
||||
@@ -552,10 +574,11 @@ async function seedExpenseTypes(prisma) {
|
||||
create: {
|
||||
code: item.code,
|
||||
name: item.name,
|
||||
description: null,
|
||||
description: item.description ?? null,
|
||||
category: item.category,
|
||||
sort_order: index + 1,
|
||||
account_code_default: null,
|
||||
requires_invoice: item.requiresInvoice ?? true,
|
||||
is_system_default: true,
|
||||
enabled: item.enabled,
|
||||
created_at: now,
|
||||
|
||||
@@ -4,6 +4,7 @@ const FEATURE_FLAG_KEYS = {
|
||||
invoiceVerification: "invoice_verification",
|
||||
leaveHolidayCalendar: "leave_holiday_calendar",
|
||||
budgetPrecheck: "budget_precheck_v1",
|
||||
reimbursementInvoiceGuard: "reimbursement_invoice_guard_v1",
|
||||
paymentBatch: "payment_batch_v1",
|
||||
portalHome: "portal_home_v1",
|
||||
globalSearch: "global_search_v1",
|
||||
@@ -98,6 +99,10 @@ function resolveEnvGate(config, flagKey) {
|
||||
return Boolean(config.budgetPrecheckEnabled);
|
||||
}
|
||||
|
||||
if (flagKey === FEATURE_FLAG_KEYS.reimbursementInvoiceGuard) {
|
||||
return Boolean(config.reimbursementInvoiceGuardEnabled);
|
||||
}
|
||||
|
||||
if (flagKey === FEATURE_FLAG_KEYS.paymentBatch) {
|
||||
return Boolean(config.paymentBatchEnabled);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
const Fastify = require("fastify");
|
||||
|
||||
const { AppError } = require("../../../src/utils/AppError");
|
||||
const expenseTypesRoutes = require("../../../src/routes/expenseTypes");
|
||||
|
||||
const ACTOR_ID = "11111111-1111-4111-8111-111111111111";
|
||||
const EXPENSE_TYPE_ID = "22222222-2222-4222-8222-222222222222";
|
||||
|
||||
function buildRecord(overrides = {}) {
|
||||
return {
|
||||
id: EXPENSE_TYPE_ID,
|
||||
code: "travel_meal",
|
||||
name: "伙食补助费",
|
||||
description: "按公司标准填写补助算式",
|
||||
category: "travel",
|
||||
sort_order: 10,
|
||||
account_code_default: null,
|
||||
requires_invoice: false,
|
||||
is_system_default: true,
|
||||
enabled: true,
|
||||
created_at: new Date("2026-08-29T00:00:00.000Z"),
|
||||
updated_at: new Date("2026-08-29T00:00:00.000Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildApp() {
|
||||
const app = Fastify();
|
||||
let record = buildRecord();
|
||||
const auditRows = [];
|
||||
const tx = {
|
||||
expenseType: {
|
||||
create: vi.fn(async ({ data }) => {
|
||||
record = buildRecord({ ...data, id: EXPENSE_TYPE_ID, is_system_default: false });
|
||||
return record;
|
||||
}),
|
||||
findUnique: vi.fn(async () => record),
|
||||
update: vi.fn(async ({ data }) => {
|
||||
record = { ...record, ...data };
|
||||
return record;
|
||||
}),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn(async ({ data }) => {
|
||||
auditRows.push(data);
|
||||
return { id: `audit-${auditRows.length}` };
|
||||
}),
|
||||
},
|
||||
};
|
||||
const prisma = {
|
||||
expenseType: {
|
||||
findMany: vi.fn(async () => [record]),
|
||||
},
|
||||
$transaction: vi.fn(async (callback) => callback(tx)),
|
||||
};
|
||||
|
||||
app.decorate("prisma", prisma);
|
||||
app.decorate("authenticate", async (request) => {
|
||||
request.employee = { id: ACTOR_ID, permissionCodes: ["system.settings"] };
|
||||
});
|
||||
app.decorate("requirePermission", () => async (request) => {
|
||||
request.employee = { id: ACTOR_ID, permissionCodes: ["system.settings"] };
|
||||
});
|
||||
app.setErrorHandler((error, request, reply) => {
|
||||
if (error instanceof AppError) {
|
||||
return reply.status(error.statusCode).send({
|
||||
requestId: request.id,
|
||||
error: { code: error.code, message: error.message, details: error.details ?? null },
|
||||
});
|
||||
}
|
||||
return reply.status(500).send({ requestId: request.id, error: { code: "SYS_001" } });
|
||||
});
|
||||
await app.register(expenseTypesRoutes, { prefix: "/api" });
|
||||
return { app, prisma, tx, auditRows, getRecord: () => record };
|
||||
}
|
||||
|
||||
describe("expense type invoice requirement contract", () => {
|
||||
it("maps requiresInvoice in list responses", async () => {
|
||||
const { app, prisma } = await buildApp();
|
||||
const response = await app.inject({ method: "GET", url: "/api/expense-types?enabled=true" });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.items).toEqual([
|
||||
expect.objectContaining({ code: "travel_meal", requiresInvoice: false }),
|
||||
]);
|
||||
expect(prisma.expenseType.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
select: expect.objectContaining({ requires_invoice: true }),
|
||||
}));
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("persists and audits requiresInvoice on create and update", async () => {
|
||||
const { app, tx, auditRows, getRecord } = await buildApp();
|
||||
const createResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/expense-types",
|
||||
payload: {
|
||||
code: "travel_meal",
|
||||
name: "伙食补助费",
|
||||
category: "travel",
|
||||
sortOrder: 10,
|
||||
requiresInvoice: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(createResponse.statusCode).toBe(200);
|
||||
expect(createResponse.json().data.requiresInvoice).toBe(false);
|
||||
expect(tx.expenseType.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({ requires_invoice: false }),
|
||||
}));
|
||||
expect(auditRows[0].extra).toEqual(expect.objectContaining({ requiresInvoice: false }));
|
||||
|
||||
const updateResponse = await app.inject({
|
||||
method: "PATCH",
|
||||
url: `/api/expense-types/${EXPENSE_TYPE_ID}`,
|
||||
payload: { requiresInvoice: true },
|
||||
});
|
||||
expect(updateResponse.statusCode).toBe(200);
|
||||
expect(updateResponse.json().data.requiresInvoice).toBe(true);
|
||||
expect(getRecord().requires_invoice).toBe(true);
|
||||
expect(auditRows[1].extra.changes.requiresInvoice).toEqual({ before: false, after: true });
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -80,6 +80,19 @@ function buildPrisma({
|
||||
auditLog: {
|
||||
create: vi.fn().mockResolvedValue({ id: "audit-1" }),
|
||||
},
|
||||
featureFlag: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
id: "99999999-9999-4999-8999-999999999999",
|
||||
flag_key: "reimbursement_invoice_guard_v1",
|
||||
override_mode: "force_on",
|
||||
rollout_type: "global",
|
||||
department_ids: [],
|
||||
note: "test",
|
||||
version: 1,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
}),
|
||||
},
|
||||
$queryRaw: buildQueryRawHandler(state),
|
||||
$transaction: vi.fn().mockImplementation(async (callback) => callback(prisma)),
|
||||
};
|
||||
@@ -87,7 +100,7 @@ function buildPrisma({
|
||||
return { prisma, state };
|
||||
}
|
||||
|
||||
async function buildApp({ prisma, permissionCodes = ["travel.payment.manage"], employeeId = "actor-1" }) {
|
||||
async function buildApp({ prisma, permissionCodes = ["travel.payment.manage"], employeeId = "actor-1", invoiceGuardEnabled = false }) {
|
||||
const app = Fastify();
|
||||
|
||||
app.decorate("prisma", prisma);
|
||||
@@ -96,6 +109,7 @@ async function buildApp({ prisma, permissionCodes = ["travel.payment.manage"], e
|
||||
appOrigin: "http://localhost:5173",
|
||||
maxUploadSizeBytes: 10 * 1024 * 1024,
|
||||
invoiceArchiveUnusedDays: 30,
|
||||
reimbursementInvoiceGuardEnabled: invoiceGuardEnabled,
|
||||
});
|
||||
app.decorate("authenticate", async (request) => {
|
||||
if (!request.headers.authorization) {
|
||||
@@ -133,6 +147,47 @@ async function buildApp({ prisma, permissionCodes = ["travel.payment.manage"], e
|
||||
}
|
||||
|
||||
describe("invoicePool travel-order bind routes", () => {
|
||||
it("requires an explicit reimbursement item while the invoice guard is enabled", async () => {
|
||||
const reimbursementId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||
const { prisma, state } = buildPrisma({
|
||||
invoice: {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
status: "available",
|
||||
linked_reimbursement_id: null,
|
||||
linked_item_id: null,
|
||||
travel_order_id: null,
|
||||
total_amount: "1683.00",
|
||||
},
|
||||
reimbursement: {
|
||||
id: reimbursementId,
|
||||
owner_id: "actor-1",
|
||||
submitter_id: "actor-1",
|
||||
status: "draft",
|
||||
reimbursement_date: new Date("2026-05-08T00:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
const app = await buildApp({
|
||||
prisma,
|
||||
permissionCodes: ["invoice.upload"],
|
||||
invoiceGuardEnabled: true,
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/invoice-pool/11111111-1111-4111-8111-111111111111/bind",
|
||||
headers: { authorization: "Bearer test" },
|
||||
payload: { reimbursementId },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error.details).toEqual({
|
||||
linkedItemId: "required_when_invoice_guard_enabled",
|
||||
});
|
||||
expect(state.invoice.status).toBe("available");
|
||||
expect(prisma.reimbursementItem.create).not.toHaveBeenCalled();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects a view-all nonparticipant before binding a reimbursement invoice", async () => {
|
||||
const reimbursementId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||
const { prisma, state } = buildPrisma({
|
||||
|
||||
@@ -30,6 +30,7 @@ const TRAIN_TYPE_ID = "22222222-2222-4222-8222-222222222222";
|
||||
const BUS_TYPE_ID = "33333333-3333-4333-8333-333333333333";
|
||||
const TAXI_TYPE_ID = "44444444-4444-4444-8444-444444444444";
|
||||
const TRANSPORT_TYPE_ID = "55555555-5555-4555-8555-555555555555";
|
||||
const MEAL_ALLOWANCE_TYPE_ID = "66666666-6666-4666-8666-666666666666";
|
||||
|
||||
function cloneDate(value) {
|
||||
return value ? new Date(value) : value;
|
||||
@@ -130,6 +131,7 @@ async function buildApp(options = {}) {
|
||||
{ id: BUS_TYPE_ID, code: "travel_bus", name: "大巴车票", category: "travel", enabled: true },
|
||||
{ id: TAXI_TYPE_ID, code: "travel_taxi", name: "打车费", category: "travel", enabled: true },
|
||||
{ id: TRANSPORT_TYPE_ID, code: "transport", name: "legacy-交通费", category: "travel", enabled: true },
|
||||
{ id: MEAL_ALLOWANCE_TYPE_ID, code: "travel_meal", name: "伙食补助费", category: "travel", enabled: true, requires_invoice: false },
|
||||
],
|
||||
travelRequests: (options.travelRequests ?? [buildTravelRequest()]).map((item) => cloneTravelRequest(item)),
|
||||
auditLogs: [],
|
||||
@@ -195,6 +197,9 @@ async function buildApp(options = {}) {
|
||||
if (where?.enabled === true && !item.enabled) {
|
||||
return false;
|
||||
}
|
||||
if (where?.requires_invoice === true && item.requires_invoice !== true) {
|
||||
return false;
|
||||
}
|
||||
if (ids && !ids.includes(item.id)) {
|
||||
return false;
|
||||
}
|
||||
@@ -464,6 +469,34 @@ describe("reimbursement create enhancements", () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("keeps invoice-free allowance types available to reimbursement templates", async () => {
|
||||
const { app, state } = await buildApp({
|
||||
templates: [buildTemplate({
|
||||
items: [{
|
||||
expenseTypeId: MEAL_ALLOWANCE_TYPE_ID,
|
||||
description: "伙食补助",
|
||||
defaultAmount: "300.00",
|
||||
}],
|
||||
})],
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/reimbursements",
|
||||
payload: { templateId: TEMPLATE_ID, reimbursementDate: "2026-04-09" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.items).toEqual([
|
||||
expect.objectContaining({
|
||||
expenseTypeId: MEAL_ALLOWANCE_TYPE_ID,
|
||||
amount: "300.00",
|
||||
}),
|
||||
]);
|
||||
expect(state.reimbursementItems).toHaveLength(1);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects templateId and linkedTravelRequestId when both are provided", async () => {
|
||||
const { app } = await buildApp();
|
||||
|
||||
|
||||
@@ -97,13 +97,22 @@ async function buildApp(options = {}) {
|
||||
}),
|
||||
},
|
||||
invoicePool: {
|
||||
count: vi.fn(async () => 1),
|
||||
count: vi.fn(async ({ where } = {}) => (
|
||||
where?.linked_item_id === null
|
||||
? (options.unlinkedBoundInvoiceCount ?? 0)
|
||||
: (options.boundInvoiceCount ?? 1)
|
||||
)),
|
||||
},
|
||||
reimbursementItem: {
|
||||
findFirst: vi.fn(async () => (options.travelItem ? { id: "travel-item" } : null)),
|
||||
findMany: vi.fn(async () => options.reimbursementItems ?? [
|
||||
findMany: vi.fn(async () => (options.reimbursementItems ?? [
|
||||
{ id: "item-1", amount: reimbursement.total_amount },
|
||||
]),
|
||||
]).map((item) => ({
|
||||
asset_prior_approval_basis: null,
|
||||
expense_type: { code: "office_supplies", category: "office", requires_invoice: true },
|
||||
invoice_pool_items: [{ id: "invoice-1", total_amount: item.amount }],
|
||||
...item,
|
||||
}))),
|
||||
aggregate: vi.fn(async () => ({
|
||||
_sum: { amount: options.authoritativeTotal ?? reimbursement.total_amount },
|
||||
})),
|
||||
@@ -169,6 +178,19 @@ async function buildApp(options = {}) {
|
||||
auditLog: {
|
||||
create: vi.fn(async () => ({ id: "audit-1" })),
|
||||
},
|
||||
featureFlag: {
|
||||
findUnique: vi.fn(async () => options.invoiceGuardEnabled ? {
|
||||
id: "88888888-8888-4888-8888-888888888888",
|
||||
flag_key: "reimbursement_invoice_guard_v1",
|
||||
override_mode: "force_on",
|
||||
rollout_type: "global",
|
||||
department_ids: [],
|
||||
note: "test",
|
||||
version: 1,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
} : null),
|
||||
},
|
||||
};
|
||||
|
||||
const prisma = {
|
||||
@@ -194,6 +216,7 @@ async function buildApp(options = {}) {
|
||||
budgetControlMode: "warn",
|
||||
imNotifierEnabled: false,
|
||||
emailEnabled: false,
|
||||
reimbursementInvoiceGuardEnabled: options.invoiceGuardEnabled ?? false,
|
||||
});
|
||||
app.decorate("hasPermission", (employee, permissionCode) => {
|
||||
const permissionCodes = employee?.permissionCodes ?? [];
|
||||
@@ -306,6 +329,142 @@ describe("reimbursements routes expense standard integration", () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("requires prior approval basis for assets items", async () => {
|
||||
const { app, tx } = await buildApp({
|
||||
reimbursementItems: [{
|
||||
id: "asset-item",
|
||||
amount: "2200.00",
|
||||
expense_type: { code: "assets_office_electronics", category: "assets", requires_invoice: true },
|
||||
}],
|
||||
});
|
||||
|
||||
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();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("skips invoice amount checks for invoice-free allowance items when the guard is on", async () => {
|
||||
resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck());
|
||||
const { app } = await buildApp({
|
||||
invoiceGuardEnabled: true,
|
||||
boundInvoiceCount: 0,
|
||||
reimbursementItems: [{
|
||||
id: "allowance-item",
|
||||
amount: "180.00",
|
||||
expense_type: { code: "travel_meal", category: "travel", requires_invoice: false },
|
||||
invoice_pool_items: [],
|
||||
}],
|
||||
});
|
||||
|
||||
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
|
||||
expect(response.statusCode).toBe(200);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("allows an invoice-free allowance-only reimbursement when the guard is off", async () => {
|
||||
resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck());
|
||||
const { app } = await buildApp({
|
||||
invoiceGuardEnabled: false,
|
||||
boundInvoiceCount: 0,
|
||||
reimbursementItems: [{
|
||||
id: "allowance-item",
|
||||
amount: "180.00",
|
||||
expense_type: { code: "travel_meal", category: "travel", requires_invoice: false },
|
||||
invoice_pool_items: [],
|
||||
}],
|
||||
});
|
||||
|
||||
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
|
||||
expect(response.statusCode).toBe(200);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("keeps the legacy whole-form invoice requirement for mixed or required items when the guard is off", async () => {
|
||||
const { app, tx } = await buildApp({
|
||||
invoiceGuardEnabled: false,
|
||||
boundInvoiceCount: 0,
|
||||
reimbursementItems: [
|
||||
{
|
||||
id: "allowance-item",
|
||||
amount: "180.00",
|
||||
expense_type: { code: "travel_meal", category: "travel", requires_invoice: false },
|
||||
invoice_pool_items: [],
|
||||
},
|
||||
{
|
||||
id: "required-item",
|
||||
amount: "100.00",
|
||||
expense_type: { code: "office_supplies", category: "office", requires_invoice: true },
|
||||
invoice_pool_items: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error.details).toEqual({ invoices: "bound_invoice_required" });
|
||||
expect(tx.workflowInstance.create).not.toHaveBeenCalled();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("blocks required-invoice items without linked invoices when the guard is on", async () => {
|
||||
const { app, tx } = await buildApp({
|
||||
invoiceGuardEnabled: true,
|
||||
reimbursementItems: [{ id: "missing-invoice", amount: "180.00", invoice_pool_items: [] }],
|
||||
});
|
||||
|
||||
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error.details).toEqual(expect.objectContaining({
|
||||
items: "invoice_item_guard_failed",
|
||||
missingInvoiceItemIds: ["missing-invoice"],
|
||||
}));
|
||||
expect(tx.workflowInstance.create).not.toHaveBeenCalled();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("blocks bound invoices that have no explicit item link when the guard is on", async () => {
|
||||
const { app, tx } = await buildApp({
|
||||
invoiceGuardEnabled: true,
|
||||
unlinkedBoundInvoiceCount: 1,
|
||||
});
|
||||
|
||||
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error.details).toEqual(expect.objectContaining({
|
||||
items: "invoice_item_guard_failed",
|
||||
unlinkedInvoiceCount: 1,
|
||||
}));
|
||||
expect(tx.workflowInstance.create).not.toHaveBeenCalled();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("blocks declared amounts above the linked invoice total when the guard is on", async () => {
|
||||
const { app, tx } = await buildApp({
|
||||
invoiceGuardEnabled: true,
|
||||
reimbursementItems: [{
|
||||
id: "amount-over",
|
||||
amount: "180.00",
|
||||
invoice_pool_items: [{ id: "invoice-low", total_amount: "120.00" }],
|
||||
}],
|
||||
});
|
||||
|
||||
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error.details.amountExceededItems).toEqual([{
|
||||
itemId: "amount-over",
|
||||
declaredAmount: "180.00",
|
||||
invoiceTotal: "120.00",
|
||||
}]);
|
||||
expect(tx.workflowInstance.create).not.toHaveBeenCalled();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("recomputes a stale total before selecting the approval rule", async () => {
|
||||
resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck());
|
||||
const { app, tx } = await buildApp({ authoritativeTotal: "320.00" });
|
||||
|
||||
@@ -42,7 +42,7 @@ describe("environment recovery classification contract", () => {
|
||||
|
||||
it("classifies every env contract key exactly once", async () => {
|
||||
const result = await validate();
|
||||
expect(result.stdout).toContain("环境恢复分类键数: 165");
|
||||
expect(result.stdout).toContain("环境恢复分类键数: 166");
|
||||
});
|
||||
|
||||
it("skips tracked paths deleted in the current worktree before reading metadata", async () => {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const MIGRATION_PATH = path.resolve(
|
||||
__dirname,
|
||||
"../../../prisma/migrations/20260829120000_reimbursement_expense_policy_invoice_guard/migration.sql",
|
||||
);
|
||||
|
||||
describe("reimbursement expense policy migration", () => {
|
||||
it("adds backward-compatible defaults before applying allowance overrides", () => {
|
||||
const source = fs.readFileSync(MIGRATION_PATH, "utf8");
|
||||
|
||||
const requiresInvoiceColumn = source.indexOf(
|
||||
'ADD COLUMN "requires_invoice" BOOLEAN NOT NULL DEFAULT true',
|
||||
);
|
||||
const approvalBasisColumn = source.indexOf(
|
||||
'ADD COLUMN "asset_prior_approval_basis" TEXT',
|
||||
);
|
||||
const allowanceOverride = source.indexOf(
|
||||
'SET "requires_invoice" = false',
|
||||
);
|
||||
|
||||
expect(requiresInvoiceColumn).toBeGreaterThanOrEqual(0);
|
||||
expect(approvalBasisColumn).toBeGreaterThanOrEqual(0);
|
||||
expect(allowanceOverride).toBeGreaterThan(approvalBasisColumn);
|
||||
expect(source).toContain("'travel_meal'");
|
||||
expect(source).toContain("'travel_local_transport_allowance'");
|
||||
expect(source).not.toMatch(/DROP\s+(TABLE|COLUMN)/i);
|
||||
expect(source).not.toMatch(/ALTER\s+COLUMN\s+[^;]+NOT\s+NULL/i);
|
||||
});
|
||||
});
|
||||
@@ -48,6 +48,49 @@ describe("default travel expense types", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("reimbursement expense policy defaults", () => {
|
||||
it("keeps only the two allowances invoice-free and replaces ambiguous selectable types", () => {
|
||||
expect(ENABLED_EXPENSE_TYPES).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ code: "business_client_meal", name: "业务招待费" }),
|
||||
expect.objectContaining({ code: "business_meeting", name: "会议费" }),
|
||||
expect.objectContaining({ code: "business_training_fee", name: "培训费" }),
|
||||
expect.objectContaining({ code: "assets_office_electronics", name: "办公电子设备" }),
|
||||
expect.objectContaining({ code: "travel_meal", requiresInvoice: false }),
|
||||
expect.objectContaining({ code: "travel_local_transport_allowance", requiresInvoice: false }),
|
||||
]));
|
||||
expect(ENABLED_EXPENSE_TYPES.some((item) => item.code === "business_training")).toBe(false);
|
||||
expect(ENABLED_EXPENSE_TYPES.some((item) => item.code === "assets_it_hardware")).toBe(false);
|
||||
});
|
||||
|
||||
it("provides employee-readable guidance for every selectable expense type", () => {
|
||||
for (const expenseType of ENABLED_EXPENSE_TYPES) {
|
||||
expect(expenseType.description, expenseType.code).toEqual(expect.any(String));
|
||||
expect(expenseType.description.trim().length, expenseType.code).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("seeds the invoice guard disabled by default", () => {
|
||||
expect(DEFAULT_FEATURE_FLAGS).toContainEqual({
|
||||
flag_key: "reimbursement_invoice_guard_v1",
|
||||
override_mode: "force_off",
|
||||
rollout_type: "global",
|
||||
department_ids: [],
|
||||
note: "bootstrap default: force_off",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves finance-maintained account mappings during repeated init", async () => {
|
||||
const upsert = vi.fn().mockResolvedValue({});
|
||||
|
||||
await seedExpenseTypes({ expenseType: { upsert } });
|
||||
|
||||
for (const call of upsert.mock.calls) {
|
||||
expect(call[0].update).not.toHaveProperty("account_code_default");
|
||||
expect(call[0].create).toEqual(expect.objectContaining({ account_code_default: null }));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("small-team default reimbursement approval rules", () => {
|
||||
it("uses finance for every owner fallback and finance review node", () => {
|
||||
expect(DEFAULT_APPROVAL_RULES).toEqual([
|
||||
|
||||
在新工单中引用
屏蔽一个用户