实现报销费用政策与票据明细门禁(08-29-reimbursement-expense-policy-invoice-guard)

这个提交包含在:
2026-08-29 23:47:22 +08:00
父节点 9e2aea5f70
当前提交 6e999d5bf9
共修改 34 个文件,包含 1458 行新增和 92 行删除
+1
查看文件
@@ -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",
+11 -1
查看文件
@@ -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;
+18 -1
查看文件
@@ -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: "参数校验失败",
+63 -40
查看文件
@@ -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);
}