R57 A1-A6:报销模板与差旅申请关联

这个提交包含在:
2026-04-08 21:16:30 +08:00
父节点 14c2cbd6da
当前提交 88c987ae8f
共修改 17 个文件,包含 2886 行新增和 76 行删除
@@ -0,0 +1,28 @@
CREATE TABLE "reimbursement_templates" (
"id" UUID NOT NULL,
"name" VARCHAR(100) NOT NULL,
"description" TEXT,
"items" JSONB NOT NULL DEFAULT '[]',
"sort_order" INTEGER NOT NULL DEFAULT 0,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_by" UUID,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_by" UUID,
CONSTRAINT "reimbursement_templates_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "reimbursement_templates_enabled_sort_order_idx"
ON "reimbursement_templates"("enabled", "sort_order");
ALTER TABLE "reimbursement_forms"
ADD COLUMN "linked_travel_request_id" UUID;
CREATE INDEX "idx_reimbursement_forms_linked_travel_request"
ON "reimbursement_forms"("linked_travel_request_id");
ALTER TABLE "reimbursement_forms"
ADD CONSTRAINT "reimbursement_forms_linked_travel_request_id_fkey"
FOREIGN KEY ("linked_travel_request_id") REFERENCES "travel_requests"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
+21
查看文件
@@ -842,6 +842,23 @@ model ExpenseStandard {
@@map("expense_standards")
}
model ReimbursementTemplate {
id String @id @default(uuid()) @db.Uuid
name String @db.VarChar(100)
description String?
items Json @default("[]")
sort_order Int @default(0)
enabled Boolean @default(true)
created_at DateTime @default(now())
created_by String? @db.Uuid
updated_at DateTime @default(now()) @updatedAt
updated_by String? @db.Uuid
@@index([enabled, sort_order])
@@map("reimbursement_templates")
}
model DocumentSequence {
document_type String @id
prefix String
@@ -1269,6 +1286,7 @@ model ReimbursementForm {
paid_at DateTime?
paid_by String? @db.Uuid
project_id String? @db.Uuid
linked_travel_request_id String? @db.Uuid
created_at DateTime @default(now())
created_by String? @db.Uuid
@@ -1280,6 +1298,7 @@ model ReimbursementForm {
paid_by_employee Employee? @relation("ReimbursementPaidBy", fields: [paid_by], references: [id], onDelete: SetNull)
department Department @relation(fields: [department_id], references: [id], onDelete: Restrict)
project Project? @relation(fields: [project_id], references: [id], onDelete: SetNull)
travel_request TravelRequest? @relation(fields: [linked_travel_request_id], references: [id], onDelete: SetNull)
items ReimbursementItem[]
invoice_pool_items InvoicePool[]
@@ -1293,6 +1312,7 @@ model ReimbursementForm {
@@index([department_id])
@@index([paid_by])
@@index([project_id], map: "idx_reimbursement_forms_project")
@@index([linked_travel_request_id], map: "idx_reimbursement_forms_linked_travel_request")
@@index([status])
@@index([reimbursement_date])
@@index([owner_id, status, updated_at], map: "idx_reimbursement_owner_status_updated_at")
@@ -2479,6 +2499,7 @@ model TravelRequest {
segments TravelRequestSegment[]
travel_orders TravelOrder[]
linked_reimbursements ReimbursementForm[]
reconciliation_items TravelReconciliationItem[]
card_authorizations CardAuthorization[]
card_transactions CardTransaction[]
+2
查看文件
@@ -35,6 +35,7 @@ const performanceRoutes = require("./routes/performance");
const payrollRoutes = require("./routes/payroll");
const expenseTypesRoutes = require("./routes/expenseTypes");
const expenseStandardsRoutes = require("./routes/expenseStandards");
const reimbursementTemplatesRoutes = require("./routes/reimbursementTemplates");
const configReadRoutes = require("./routes/configRead");
const approvalRulesRoutes = require("./routes/approvalRules");
const featureFlagsRoutes = require("./routes/featureFlags");
@@ -324,6 +325,7 @@ async function buildApp() {
await app.register(masterDataSuppliersRoutes, { prefix: "/api" });
await app.register(expenseTypesRoutes, { prefix: "/api" });
await app.register(expenseStandardsRoutes, { prefix: "/api" });
await app.register(reimbursementTemplatesRoutes, { prefix: "/api" });
await app.register(configReadRoutes, { prefix: "/api" });
await app.register(approvalRulesRoutes, { prefix: "/api" });
await app.register(featureFlagsRoutes, { prefix: "/api" });
+417
查看文件
@@ -0,0 +1,417 @@
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { buildAuditLogData } = require("../utils/audit");
const { sendData } = require("../utils/reply");
const UUID_SCHEMA = z.string().uuid();
const MONEY_STRING_SCHEMA = z.string().trim().regex(/^\d+(?:\.\d{1,2})?$/);
const TEMPLATE_ITEM_SCHEMA = z
.object({
expenseTypeId: UUID_SCHEMA,
description: z.string().trim().min(1).max(500),
defaultAmount: MONEY_STRING_SCHEMA.optional(),
})
.strict()
.superRefine((value, ctx) => {
if (value.defaultAmount != null && Number(value.defaultAmount) <= 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "must_be_positive",
path: ["defaultAmount"],
});
}
});
const LIST_QUERY_SCHEMA = z
.object({
enabled: z.enum(["true", "false", "1", "0"]).optional(),
})
.strict();
const CREATE_BODY_SCHEMA = z
.object({
name: z.string().trim().min(1).max(100),
description: z.string().trim().max(500).nullable().optional(),
items: z.array(TEMPLATE_ITEM_SCHEMA).max(50).optional(),
sortOrder: z.number().int().min(0).max(10000).optional(),
enabled: z.boolean().optional(),
})
.strict();
const UPDATE_BODY_SCHEMA = z
.object({
name: z.string().trim().min(1).max(100).optional(),
description: z.string().trim().max(500).nullable().optional(),
items: z.array(TEMPLATE_ITEM_SCHEMA).max(50).optional(),
sortOrder: z.number().int().min(0).max(10000).optional(),
enabled: z.boolean().optional(),
})
.strict();
function buildValidationError(details, statusCode = 400) {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode,
details,
});
}
function parseTemplateId(value) {
const parsed = UUID_SCHEMA.safeParse(value);
if (!parsed.success) {
throw buildValidationError({ id: "invalid_uuid" });
}
return parsed.data;
}
function parseListQuery(query) {
const parsed = LIST_QUERY_SCHEMA.safeParse(query ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
}
const enabledRaw = parsed.data.enabled;
const enabled =
enabledRaw == null
? null
: enabledRaw === "true" || enabledRaw === "1";
return { enabled };
}
function parseBody(schema, body) {
const parsed = schema.safeParse(body ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
}
return parsed.data;
}
function normalizeTemplateItems(items) {
return (items ?? []).map((item) => ({
expenseTypeId: item.expenseTypeId,
description: item.description.trim(),
...(item.defaultAmount == null ? {} : { defaultAmount: item.defaultAmount.trim() }),
}));
}
async function ensureExpenseTypesEnabled(tx, items) {
if (!items.length) {
return;
}
const expenseTypeIds = Array.from(new Set(items.map((item) => item.expenseTypeId)));
const expenseTypes = await tx.expenseType.findMany({
where: {
id: { in: expenseTypeIds },
enabled: true,
},
select: {
id: true,
enabled: true,
},
});
const enabledIds = new Set(expenseTypes.map((item) => item.id));
const invalidItems = items
.map((item, index) => ({ index, expenseTypeId: item.expenseTypeId }))
.filter((item) => !enabledIds.has(item.expenseTypeId));
if (invalidItems.length) {
throw buildValidationError(
{
items: invalidItems.map((item) => ({
index: item.index,
expenseTypeId: item.expenseTypeId,
reason: "expense_type_not_found_or_disabled",
})),
},
409,
);
}
}
function mapTemplate(record) {
return {
id: record.id,
name: record.name,
description: record.description ?? null,
items: Array.isArray(record.items) ? record.items : [],
sortOrder: record.sort_order,
enabled: record.enabled,
createdAt: record.created_at?.toISOString?.() ?? null,
updatedAt: record.updated_at?.toISOString?.() ?? null,
};
}
function normalizeSnapshot(record) {
if (!record) {
return null;
}
return {
id: record.id,
name: record.name,
description: record.description ?? null,
items: Array.isArray(record.items) ? record.items : [],
sortOrder: record.sort_order,
enabled: record.enabled,
};
}
async function reimbursementTemplatesRoutes(fastify) {
fastify.get(
"/reimbursement-templates",
{
preHandler: fastify.authenticate,
},
async (request, reply) => {
const query = parseListQuery(request.query);
const items = await fastify.prisma.reimbursementTemplate.findMany({
where: {
...(query.enabled == null ? {} : { enabled: query.enabled }),
},
orderBy: [{ sort_order: "asc" }, { created_at: "asc" }, { name: "asc" }],
select: {
id: true,
name: true,
description: true,
items: true,
sort_order: true,
enabled: true,
created_at: true,
updated_at: true,
},
});
return sendData(reply, request, {
enabled: query.enabled,
items: items.map(mapTemplate),
});
},
);
fastify.post(
"/reimbursement-templates",
{
preHandler: fastify.requirePermission("system.settings"),
},
async (request, reply) => {
const body = parseBody(CREATE_BODY_SCHEMA, request.body);
const items = normalizeTemplateItems(body.items);
await ensureExpenseTypesEnabled(fastify.prisma, items);
const now = new Date();
const created = await fastify.prisma.$transaction(async (tx) => {
const record = await tx.reimbursementTemplate.create({
data: {
name: body.name.trim(),
description: body.description?.trim?.() || null,
items,
sort_order: body.sortOrder ?? 0,
enabled: body.enabled ?? true,
created_at: now,
created_by: request.employee.id,
updated_at: now,
updated_by: request.employee.id,
},
select: {
id: true,
name: true,
description: true,
items: true,
sort_order: true,
enabled: true,
created_at: true,
updated_at: true,
},
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "REIMBURSEMENT_TEMPLATE_CREATED",
targetType: "reimbursement_template",
targetId: record.id,
extra: {
after: normalizeSnapshot(record),
},
}),
});
return record;
});
return sendData(reply, request, mapTemplate(created));
},
);
fastify.patch(
"/reimbursement-templates/:id",
{
preHandler: fastify.requirePermission("system.settings"),
},
async (request, reply) => {
const templateId = parseTemplateId(request.params?.id);
const body = parseBody(UPDATE_BODY_SCHEMA, request.body);
if (!Object.keys(body).length) {
throw buildValidationError({ body: "empty" });
}
const now = new Date();
const updated = await fastify.prisma.$transaction(async (tx) => {
const current = await tx.reimbursementTemplate.findUnique({
where: { id: templateId },
select: {
id: true,
name: true,
description: true,
items: true,
sort_order: true,
enabled: true,
created_at: true,
updated_at: true,
},
});
if (!current) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const nextItems =
body.items == null
? Array.isArray(current.items)
? current.items
: []
: normalizeTemplateItems(body.items);
await ensureExpenseTypesEnabled(tx, nextItems);
const record = await tx.reimbursementTemplate.update({
where: { id: templateId },
data: {
...(body.name === undefined ? {} : { name: body.name.trim() }),
...(body.description === undefined ? {} : { description: body.description?.trim?.() || null }),
...(body.items === undefined ? {} : { items: nextItems }),
...(body.sortOrder === undefined ? {} : { sort_order: body.sortOrder }),
...(body.enabled === undefined ? {} : { enabled: body.enabled }),
updated_at: now,
updated_by: request.employee.id,
},
select: {
id: true,
name: true,
description: true,
items: true,
sort_order: true,
enabled: true,
created_at: true,
updated_at: true,
},
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "REIMBURSEMENT_TEMPLATE_UPDATED",
targetType: "reimbursement_template",
targetId: record.id,
extra: {
before: normalizeSnapshot(current),
after: normalizeSnapshot(record),
},
}),
});
return record;
});
return sendData(reply, request, mapTemplate(updated));
},
);
fastify.delete(
"/reimbursement-templates/:id",
{
preHandler: fastify.requirePermission("system.settings"),
},
async (request, reply) => {
const templateId = parseTemplateId(request.params?.id);
const now = new Date();
const deleted = await fastify.prisma.$transaction(async (tx) => {
const current = await tx.reimbursementTemplate.findUnique({
where: { id: templateId },
select: {
id: true,
name: true,
description: true,
items: true,
sort_order: true,
enabled: true,
created_at: true,
updated_at: true,
},
});
if (!current) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
if (!current.enabled) {
return current;
}
const record = await tx.reimbursementTemplate.update({
where: { id: templateId },
data: {
enabled: false,
updated_at: now,
updated_by: request.employee.id,
},
select: {
id: true,
name: true,
description: true,
items: true,
sort_order: true,
enabled: true,
created_at: true,
updated_at: true,
},
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "REIMBURSEMENT_TEMPLATE_DELETED",
targetType: "reimbursement_template",
targetId: record.id,
extra: {
before: normalizeSnapshot(current),
after: normalizeSnapshot(record),
},
}),
});
return record;
});
return sendData(reply, request, mapTemplate(deleted));
},
);
}
module.exports = reimbursementTemplatesRoutes;
+546 -53
查看文件
@@ -55,6 +55,30 @@ const REIMBURSEMENT_UPDATE_BODY_SCHEMA = z.object({
description: z.string().trim().max(500).nullable().optional(),
});
const REIMBURSEMENT_CREATE_BODY_SCHEMA = z
.object({
ownerEmployeeId: z.string().uuid().optional(),
reimbursementDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
description: z.string().max(500).optional(),
templateId: z.string().uuid().optional(),
linkedTravelRequestId: z.string().uuid().optional(),
})
.strict()
.superRefine((value, ctx) => {
if (value.templateId && value.linkedTravelRequestId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "mutually_exclusive",
path: ["templateId"],
});
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "mutually_exclusive",
path: ["linkedTravelRequestId"],
});
}
});
const REIMBURSEMENT_ITEM_BODY_SCHEMA = z.object({
expenseTypeId: z.string().uuid(),
expenseDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
@@ -74,6 +98,11 @@ const REIMBURSEMENT_TREND_QUERY_SCHEMA = REIMBURSEMENT_STATS_QUERY_SCHEMA.extend
const REIMBURSEMENT_TOP_EXPENSE_TYPES_QUERY_SCHEMA = REIMBURSEMENT_STATS_QUERY_SCHEMA.extend({
limit: z.coerce.number().int().min(1).max(50).optional(),
});
const LINKABLE_TRAVEL_REQUEST_QUERY_SCHEMA = z
.object({
ownerEmployeeId: UUID_SCHEMA.optional(),
})
.strict();
const REIMBURSEMENT_STATUS_VALUES = ["draft", "reviewing", "approved", "paid", "rejected", "cancelled"];
const ROLE_LABELS = {
@@ -86,6 +115,28 @@ const ROLE_LABELS = {
hr: "HR",
};
const TRAVEL_SEGMENT_ROLE_LABELS = {
outbound: "去程",
return: "返程",
connection: "中转",
};
const TRAVEL_TRANSPORT_LABELS = {
flight: "航班",
train: "火车",
bus: "大巴",
car: "汽车",
other: "其他",
};
const TRAVEL_TRANSPORT_EXPENSE_TYPE_CODES = {
flight: "travel_airfare",
train: "travel_train",
bus: "travel_bus",
car: "travel_taxi",
other: "transport",
};
function ensureOwnOrCanViewAll(fastify, request, reimbursement) {
const canViewAll = fastify.hasPermission(request.employee, "expense.view_all");
if (canViewAll) {
@@ -254,6 +305,36 @@ function parseStatsQuery(query) {
};
}
function parseCreateBody(body) {
const parsed = REIMBURSEMENT_CREATE_BODY_SCHEMA.safeParse(body ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
return parsed.data;
}
function parseLinkableTravelRequestsQuery(query) {
const parsed = LINKABLE_TRAVEL_REQUEST_QUERY_SCHEMA.safeParse(query ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
return {
ownerEmployeeId: parsed.data.ownerEmployeeId ?? null,
};
}
function parseTrendQuery(query) {
const parsed = parseQuery(REIMBURSEMENT_TREND_QUERY_SCHEMA, query);
const base = parseStatsQuery(parsed);
@@ -554,6 +635,255 @@ function toDateOnlyString(dateValue) {
return new Date(dateValue).toISOString().slice(0, 10);
}
function mapLinkedTravelRequestSummary(travelRequest) {
if (!travelRequest) {
return null;
}
return {
id: travelRequest.id,
requestNo: travelRequest.request_no ?? null,
purpose: travelRequest.purpose ?? null,
startDate: toDateOnlyString(travelRequest.start_date),
endDate: toDateOnlyString(travelRequest.end_date),
estimatedAmount: toMoneyString(travelRequest.estimated_amount ?? 0),
};
}
function mapCreatedReimbursementItem(item) {
return {
id: item.id,
expenseTypeId: item.expense_type_id,
expenseDate: toDateOnlyString(item.expense_date),
amount: toMoneyString(item.amount),
description: item.description ?? null,
expenseType: item.expense_type
? {
id: item.expense_type.id,
code: item.expense_type.code,
name: item.expense_type.name,
category: item.expense_type.category,
enabled: item.expense_type.enabled,
}
: null,
};
}
function normalizeTemplateSeedItems(rawItems) {
if (!Array.isArray(rawItems)) {
return [];
}
return rawItems.map((item, index) => {
if (!item || typeof item !== "object") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { template: { itemIndex: index, reason: "invalid_item_shape" } },
});
}
if (typeof item.expenseTypeId !== "string" || typeof item.description !== "string") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { template: { itemIndex: index, reason: "invalid_item_shape" } },
});
}
return {
expenseTypeId: item.expenseTypeId,
description: item.description,
defaultAmount: typeof item.defaultAmount === "string" ? item.defaultAmount : null,
};
});
}
async function resolveEnabledExpenseTypesByIds(tx, expenseTypeIds) {
if (!expenseTypeIds.length) {
return new Map();
}
const items = await tx.expenseType.findMany({
where: {
id: { in: expenseTypeIds },
enabled: true,
},
select: {
id: true,
code: true,
name: true,
category: true,
enabled: true,
},
});
return new Map(items.map((item) => [item.id, item]));
}
async function resolveTravelExpenseTypeMap(tx) {
const codes = Array.from(new Set(Object.values(TRAVEL_TRANSPORT_EXPENSE_TYPE_CODES)));
const items = await tx.expenseType.findMany({
where: {
code: { in: codes },
enabled: true,
},
select: {
id: true,
code: true,
name: true,
category: true,
enabled: true,
},
});
return new Map(items.map((item) => [item.code, item]));
}
function buildTravelSegmentDescription(segment) {
const segmentRoleLabel = TRAVEL_SEGMENT_ROLE_LABELS[segment.segment_role] ?? segment.segment_role;
const transportLabel = TRAVEL_TRANSPORT_LABELS[segment.transport_type] ?? segment.transport_type;
return `${segmentRoleLabel} ${transportLabel} ${segment.from_city}→${segment.to_city}`;
}
async function resolveReimbursementOwnerContext(tx, fastify, request, ownerEmployeeId) {
const isProxyCreate = ownerEmployeeId !== request.employee.id;
if (!isProxyCreate) {
return {
ownerEmployeeId,
ownerEmployee: {
id: request.employee.id,
department_id: request.employee.department_id ?? null,
status: "active",
},
departmentId: request.employee.department_id ?? null,
delegationGrantId: null,
isProxyCreate: false,
};
}
const isAdministration = request.employee.roleCodes?.includes("administration");
const canProxySubmit = fastify.hasPermission(request.employee, "expense.proxy_submit");
if (!isAdministration || !canProxySubmit) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
const ownerEmployee = await tx.employee.findUnique({
where: { id: ownerEmployeeId },
select: { id: true, status: true, department_id: true },
});
if (!ownerEmployee || ownerEmployee.status !== "active") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { ownerEmployeeId: "invalid_owner" },
});
}
const delegation = await findActiveDelegationForDelegate(tx, {
principalEmployeeId: ownerEmployeeId,
delegateEmployeeId: request.employee.id,
allowedScopes: ["reimbursement_submit", "all"],
now: new Date(),
});
if (!delegation) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
details: { delegation: "missing_active_grant" },
});
}
return {
ownerEmployeeId,
ownerEmployee,
departmentId: ownerEmployee.department_id,
delegationGrantId: delegation.id,
isProxyCreate: true,
};
}
async function loadLinkableTravelRequests(tx, ownerEmployeeId) {
return tx.travelRequest.findMany({
where: {
applicant_employee_id: ownerEmployeeId,
status: "approved",
linked_reimbursements: {
none: {},
},
travel_orders: {
none: {
linked_reimbursement_id: {
not: null,
},
},
},
},
orderBy: [{ start_date: "desc" }, { created_at: "desc" }],
select: {
id: true,
request_no: true,
purpose: true,
start_date: true,
end_date: true,
estimated_amount: true,
},
});
}
async function loadTravelRequestForLinking(tx, linkedTravelRequestId) {
return tx.travelRequest.findUnique({
where: { id: linkedTravelRequestId },
select: {
id: true,
request_no: true,
purpose: true,
start_date: true,
end_date: true,
estimated_amount: true,
applicant_employee_id: true,
status: true,
segments: {
orderBy: [{ sort_order: "asc" }, { created_at: "asc" }],
select: {
id: true,
segment_role: true,
transport_type: true,
from_city: true,
to_city: true,
},
},
linked_reimbursements: {
take: 1,
select: {
id: true,
},
},
travel_orders: {
where: {
linked_reimbursement_id: {
not: null,
},
},
take: 1,
select: {
id: true,
linked_reimbursement_id: true,
},
},
},
});
}
function ensureNonNegativeAmount(totalAmount) {
const asNumber = Number(totalAmount);
if (!Number.isFinite(asNumber)) {
@@ -895,12 +1225,6 @@ async function finalizeReimbursementSubmitSideEffects({
}
async function reimbursementRoutes(fastify) {
const createBodySchema = z.object({
ownerEmployeeId: z.string().uuid().optional(),
reimbursementDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
description: z.string().max(500).optional(),
});
fastify.post(
"/reimbursements",
{
@@ -915,76 +1239,148 @@ async function reimbursementRoutes(fastify) {
domain: "expense",
departmentId: request.employee.department_id ?? null,
});
const parsed = createBodySchema.safeParse(request.body ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
const body = parseCreateBody(request.body);
const reimbursementDate =
parsed.data.reimbursementDate ?? currentDateString(fastify.config.appTimezone);
body.reimbursementDate ?? currentDateString(fastify.config.appTimezone);
const ownerEmployeeId = parsed.data.ownerEmployeeId ?? request.employee.id;
const isProxyCreate = ownerEmployeeId !== request.employee.id;
const ownerEmployeeId = body.ownerEmployeeId ?? request.employee.id;
const reimbursement = await fastify.prisma.$transaction(async (tx) => {
let departmentId = request.employee.department_id;
let delegationGrantId = null;
const ownerContext = await resolveReimbursementOwnerContext(tx, fastify, request, ownerEmployeeId);
if (isProxyCreate) {
const isAdministration = request.employee.roleCodes?.includes("administration");
const canProxySubmit = fastify.hasPermission(request.employee, "expense.proxy_submit");
if (!isAdministration || !canProxySubmit) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
let linkedTravelRequestRecord = null;
let generatedItemSeeds = [];
let resolvedDescription = body.description ?? null;
const ownerEmployee = await tx.employee.findUnique({
where: { id: ownerEmployeeId },
select: { id: true, status: true, department_id: true },
if (body.templateId) {
const template = await tx.reimbursementTemplate.findFirst({
where: {
id: body.templateId,
enabled: true,
},
select: {
id: true,
name: true,
items: true,
},
});
if (!ownerEmployee || ownerEmployee.status !== "active") {
if (!template) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { ownerEmployeeId: "invalid_owner" },
details: { templateId: "invalid_template" },
});
}
const delegation = await findActiveDelegationForDelegate(tx, {
principalEmployeeId: ownerEmployeeId,
delegateEmployeeId: request.employee.id,
allowedScopes: ["reimbursement_submit", "all"],
now: new Date(),
});
if (!delegation) {
const templateItems = normalizeTemplateSeedItems(template.items);
const expenseTypeMap = await resolveEnabledExpenseTypesByIds(
tx,
Array.from(new Set(templateItems.map((item) => item.expenseTypeId))),
);
const invalidItem = templateItems.find((item) => !expenseTypeMap.has(item.expenseTypeId));
if (invalidItem) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
details: { delegation: "missing_active_grant" },
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { templateId: "contains_disabled_expense_type" },
});
}
delegationGrantId = delegation.id;
departmentId = ownerEmployee.department_id;
generatedItemSeeds = templateItems.map((item) => ({
expense_type_id: item.expenseTypeId,
expense_date: parseDateOnlyString(reimbursementDate),
amount: item.defaultAmount ?? "0.00",
description: item.description,
created_by: request.employee.id,
updated_by: request.employee.id,
}));
}
if (body.linkedTravelRequestId) {
linkedTravelRequestRecord = await loadTravelRequestForLinking(tx, body.linkedTravelRequestId);
if (!linkedTravelRequestRecord) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { linkedTravelRequestId: "not_found" },
});
}
if (linkedTravelRequestRecord.status !== "approved") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { linkedTravelRequestId: "not_approved" },
});
}
if (linkedTravelRequestRecord.applicant_employee_id !== ownerContext.ownerEmployeeId) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { linkedTravelRequestId: "owner_mismatch" },
});
}
if (
linkedTravelRequestRecord.linked_reimbursements?.length ||
linkedTravelRequestRecord.travel_orders?.length
) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { linkedTravelRequestId: "already_linked" },
});
}
if (body.description == null) {
resolvedDescription = `差旅报销 - ${linkedTravelRequestRecord.request_no ?? linkedTravelRequestRecord.id} ${linkedTravelRequestRecord.purpose}`;
}
const expenseTypeMap = await resolveTravelExpenseTypeMap(tx);
generatedItemSeeds = linkedTravelRequestRecord.segments.map((segment) => {
const expenseTypeCode = TRAVEL_TRANSPORT_EXPENSE_TYPE_CODES[segment.transport_type];
const expenseType = expenseTypeMap.get(expenseTypeCode);
if (!expenseType) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: {
linkedTravelRequestId: "missing_expense_type_mapping",
transportType: segment.transport_type,
expenseTypeCode,
},
});
}
return {
expense_type_id: expenseType.id,
expense_date: parseDateOnlyString(reimbursementDate),
amount: "0.00",
description: buildTravelSegmentDescription(segment),
created_by: request.employee.id,
updated_by: request.employee.id,
};
});
}
const created = await tx.reimbursementForm.create({
data: {
submitter_id: request.employee.id,
owner_id: ownerEmployeeId,
department_id: departmentId,
department_id: ownerContext.departmentId,
reimbursement_date: parseDateOnlyString(reimbursementDate),
description: parsed.data.description ?? null,
description: resolvedDescription,
linked_travel_request_id: linkedTravelRequestRecord?.id ?? null,
created_by: request.employee.id,
updated_by: request.employee.id,
},
@@ -997,10 +1393,54 @@ async function reimbursementRoutes(fastify) {
form_number: true,
owner_id: true,
submitter_id: true,
linked_travel_request_id: true,
travel_request: {
select: {
id: true,
request_no: true,
purpose: true,
start_date: true,
end_date: true,
estimated_amount: true,
},
},
},
});
if (isProxyCreate) {
if (generatedItemSeeds.length) {
await tx.reimbursementItem.createMany({
data: generatedItemSeeds.map((item) => ({
reimbursement_id: created.id,
...item,
})),
});
created.total_amount = await recomputeTotalAmount(tx, created.id, request.employee.id);
}
const createdItems = generatedItemSeeds.length
? await tx.reimbursementItem.findMany({
where: { reimbursement_id: created.id },
orderBy: [{ created_at: "asc" }, { id: "asc" }],
select: {
id: true,
expense_type_id: true,
expense_date: true,
amount: true,
description: true,
expense_type: {
select: {
id: true,
code: true,
name: true,
category: true,
enabled: true,
},
},
},
})
: [];
if (ownerContext.isProxyCreate) {
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
@@ -1010,13 +1450,18 @@ async function reimbursementRoutes(fastify) {
extra: {
ownerEmployeeId,
submitterEmployeeId: request.employee.id,
delegationGrantId,
delegationGrantId: ownerContext.delegationGrantId,
...(body.templateId ? { templateId: body.templateId } : {}),
...(linkedTravelRequestRecord ? { linkedTravelRequestId: linkedTravelRequestRecord.id } : {}),
},
}),
});
}
return created;
return {
...created,
items: createdItems,
};
});
return sendData(reply, request, {
@@ -1030,6 +1475,9 @@ async function reimbursementRoutes(fastify) {
projectId: reimbursement.project_id ?? null,
project: reimbursement.project ? { id: reimbursement.project.id, projectNo: reimbursement.project.project_no, name: reimbursement.project.name } : null,
totalAmount: toMoneyString(reimbursement.total_amount),
linkedTravelRequestId: reimbursement.linked_travel_request_id ?? null,
linkedTravelRequest: mapLinkedTravelRequestSummary(reimbursement.travel_request),
items: Array.isArray(reimbursement.items) ? reimbursement.items.map(mapCreatedReimbursementItem) : [],
});
},
);
@@ -1372,6 +1820,38 @@ async function reimbursementRoutes(fastify) {
},
);
fastify.get(
"/reimbursements/linkable-travel-requests",
{
schema: { tags: ["Reimbursements"], summary: "List approved travel requests that can be linked to reimbursement" },
preHandler: fastify.requirePermission("expense.create"),
},
async (request, reply) => {
const query = parseLinkableTravelRequestsQuery(request.query);
const ownerEmployeeId = query.ownerEmployeeId ?? request.employee.id;
const ownerContext = await resolveReimbursementOwnerContext(
fastify.prisma,
fastify,
request,
ownerEmployeeId,
);
const items = await loadLinkableTravelRequests(fastify.prisma, ownerContext.ownerEmployeeId);
return sendData(reply, request, {
ownerEmployeeId: ownerContext.ownerEmployeeId,
items: items.map((item) => ({
id: item.id,
requestNo: item.request_no ?? null,
purpose: item.purpose,
startDate: toDateOnlyString(item.start_date),
endDate: toDateOnlyString(item.end_date),
estimatedAmount: toMoneyString(item.estimated_amount ?? 0),
})),
});
},
);
fastify.get(
"/reimbursements/:id",
{
@@ -1394,7 +1874,18 @@ async function reimbursementRoutes(fastify) {
status: true,
description: true,
project_id: true,
linked_travel_request_id: true,
project: { select: { id: true, project_no: true, name: true } },
travel_request: {
select: {
id: true,
request_no: true,
purpose: true,
start_date: true,
end_date: true,
estimated_amount: true,
},
},
owner: {
select: { id: true, employee_no: true, name: true },
},
@@ -1507,6 +1998,8 @@ async function reimbursementRoutes(fastify) {
description: reimbursement.description,
projectId: reimbursement.project_id ?? null,
project: reimbursement.project ? { id: reimbursement.project.id, projectNo: reimbursement.project.project_no, name: reimbursement.project.name } : null,
linkedTravelRequestId: reimbursement.linked_travel_request_id ?? null,
linkedTravelRequest: mapLinkedTravelRequestSummary(reimbursement.travel_request),
owner: reimbursement.owner,
submitter: reimbursement.submitter,
items: reimbursement.items.map((item) => ({
@@ -0,0 +1,353 @@
const Fastify = require("fastify");
const reimbursementTemplatesRoutes = require("../../../src/routes/reimbursementTemplates");
const { AppError } = require("../../../src/utils/AppError");
const ACTOR_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const TEMPLATE_ID = "11111111-1111-4111-8111-111111111111";
const EXPENSE_TYPE_ID = "22222222-2222-4222-8222-222222222222";
const DISABLED_EXPENSE_TYPE_ID = "33333333-3333-4333-8333-333333333333";
function cloneTemplate(item) {
return {
...item,
items: Array.isArray(item.items) ? item.items.map((entry) => ({ ...entry })) : [],
created_at: new Date(item.created_at),
updated_at: new Date(item.updated_at),
};
}
function buildTemplate(overrides = {}) {
return {
id: TEMPLATE_ID,
name: "国内差旅报销",
description: "机票、火车票与市内交通占位模板",
items: [
{
expenseTypeId: EXPENSE_TYPE_ID,
description: "默认交通费",
defaultAmount: "88.80",
},
],
sort_order: 10,
enabled: true,
created_at: new Date("2026-04-08T12:00:00.000Z"),
updated_at: new Date("2026-04-08T12:00:00.000Z"),
...overrides,
};
}
async function buildApp(options = {}) {
const app = Fastify();
const actor = options.actor ?? {
id: ACTOR_ID,
permissionCodes: ["system.settings"],
};
const state = {
templates: (options.templates ?? [buildTemplate()]).map((item) => cloneTemplate(item)),
expenseTypes: options.expenseTypes ?? [
{
id: EXPENSE_TYPE_ID,
code: "travel_airfare",
enabled: true,
},
{
id: DISABLED_EXPENSE_TYPE_ID,
code: "transport",
enabled: false,
},
],
auditLogs: [],
};
function buildPrismaLike(stateRef) {
return {
reimbursementTemplate: {
findMany: vi.fn(async ({ where, orderBy } = {}) => {
let items = stateRef.templates.filter((item) => {
if (typeof where?.enabled === "boolean" && item.enabled !== where.enabled) {
return false;
}
return true;
});
if (Array.isArray(orderBy)) {
items = items.sort((left, right) => {
if (left.sort_order !== right.sort_order) {
return left.sort_order - right.sort_order;
}
return left.name.localeCompare(right.name, "zh-CN");
});
}
return items.map((item) => cloneTemplate(item));
}),
findUnique: vi.fn(async ({ where }) => {
const found = stateRef.templates.find((item) => item.id === where.id) ?? null;
return found ? cloneTemplate(found) : null;
}),
create: vi.fn(async ({ data }) => {
const record = buildTemplate({
id: `66666666-6666-4666-8666-${String(stateRef.templates.length + 1).padStart(12, "0")}`,
name: data.name,
description: data.description ?? null,
items: data.items ?? [],
sort_order: data.sort_order ?? 0,
enabled: data.enabled ?? true,
created_at: data.created_at,
updated_at: data.updated_at,
});
stateRef.templates.push(record);
return cloneTemplate(record);
}),
update: vi.fn(async ({ where, data }) => {
const index = stateRef.templates.findIndex((item) => item.id === where.id);
if (index < 0) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
const next = {
...stateRef.templates[index],
...data,
};
stateRef.templates[index] = next;
return cloneTemplate(next);
}),
findFirst: vi.fn(async ({ where }) => {
const found = stateRef.templates.find((item) => item.id === where.id && item.enabled === where.enabled) ?? null;
return found ? cloneTemplate(found) : null;
}),
},
expenseType: {
findMany: vi.fn(async ({ where }) => {
const ids = where?.id?.in ?? [];
return stateRef.expenseTypes.filter(
(item) => ids.includes(item.id) && item.enabled === where.enabled,
);
}),
},
auditLog: {
create: vi.fn(async ({ data }) => {
stateRef.auditLogs.push(data);
return { id: `audit-${stateRef.auditLogs.length}` };
}),
},
};
}
const prisma = buildPrismaLike(state);
prisma.$transaction = vi.fn(async (callback) => {
const txState = {
templates: state.templates.map((item) => cloneTemplate(item)),
expenseTypes: state.expenseTypes.map((item) => ({ ...item })),
auditLogs: state.auditLogs.map((item) => ({ ...item })),
};
const tx = buildPrismaLike(txState);
const result = await callback(tx);
state.templates = txState.templates;
state.expenseTypes = txState.expenseTypes;
state.auditLogs = txState.auditLogs;
return result;
});
app.decorate("authenticate", async (request) => {
request.employee = actor;
});
app.decorate("requirePermission", (permissionCode) => async (request) => {
request.employee = actor;
const permissionCodes = actor.permissionCodes ?? [];
if (!permissionCodes.includes("*") && !permissionCodes.includes(permissionCode)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
});
app.decorate("prisma", prisma);
app.setErrorHandler((error, request, reply) => {
reply.status(error.statusCode || 500).send({
requestId: request.id,
error: {
code: error.code || "SYS_001",
message: error.message,
details: error.details ?? null,
},
});
});
await app.register(reimbursementTemplatesRoutes, { prefix: "/api" });
return { app, state, prisma };
}
describe("reimbursement template routes", () => {
it("allows authenticated users to list templates with enabled filter", async () => {
const { app } = await buildApp({
actor: {
id: ACTOR_ID,
permissionCodes: [],
},
templates: [
buildTemplate(),
buildTemplate({
id: "44444444-4444-4444-8444-444444444444",
name: "停用模板",
enabled: false,
}),
],
});
const response = await app.inject({
method: "GET",
url: "/api/reimbursement-templates?enabled=true",
});
expect(response.statusCode).toBe(200);
expect(response.json().data).toEqual({
enabled: true,
items: [
expect.objectContaining({
id: TEMPLATE_ID,
name: "国内差旅报销",
enabled: true,
}),
],
});
await app.close();
});
it("creates, updates and soft deletes a template with audit logs", async () => {
const { app, state } = await buildApp();
const createResponse = await app.inject({
method: "POST",
url: "/api/reimbursement-templates",
payload: {
name: "办公用品报销",
description: "默认办公用品采购占位",
items: [
{
expenseTypeId: EXPENSE_TYPE_ID,
description: "办公耗材",
defaultAmount: "123.40",
},
],
sortOrder: 5,
enabled: true,
},
});
expect(createResponse.statusCode).toBe(200);
const createdId = createResponse.json().data.id;
expect(state.auditLogs).toHaveLength(1);
expect(state.auditLogs[0].action).toBe("REIMBURSEMENT_TEMPLATE_CREATED");
const updateResponse = await app.inject({
method: "PATCH",
url: `/api/reimbursement-templates/${createdId}`,
payload: {
name: "办公用品报销模板",
enabled: false,
},
});
expect(updateResponse.statusCode).toBe(200);
expect(updateResponse.json().data).toEqual(
expect.objectContaining({
id: createdId,
name: "办公用品报销模板",
enabled: false,
}),
);
const deleteResponse = await app.inject({
method: "DELETE",
url: `/api/reimbursement-templates/${createdId}`,
});
expect(deleteResponse.statusCode).toBe(200);
expect(deleteResponse.json().data).toEqual(
expect.objectContaining({
id: createdId,
enabled: false,
}),
);
expect(state.auditLogs.map((item) => item.action)).toEqual([
"REIMBURSEMENT_TEMPLATE_CREATED",
"REIMBURSEMENT_TEMPLATE_UPDATED",
]);
await app.close();
});
it("rejects invalid item schema and disabled expense types", async () => {
const { app } = await buildApp();
const invalidSchemaResponse = await app.inject({
method: "POST",
url: "/api/reimbursement-templates",
payload: {
name: "坏模板",
items: [
{
expenseTypeId: EXPENSE_TYPE_ID,
description: "无效金额",
defaultAmount: 18.8,
},
],
},
});
expect(invalidSchemaResponse.statusCode).toBe(400);
const invalidExpenseTypeResponse = await app.inject({
method: "POST",
url: "/api/reimbursement-templates",
payload: {
name: "禁用费用类型模板",
items: [
{
expenseTypeId: DISABLED_EXPENSE_TYPE_ID,
description: "旧费用类型",
defaultAmount: "9.90",
},
],
},
});
expect(invalidExpenseTypeResponse.statusCode).toBe(409);
expect(invalidExpenseTypeResponse.json().error).toEqual(
expect.objectContaining({
code: "VALID_001",
details: expect.objectContaining({
items: [
expect.objectContaining({
expenseTypeId: DISABLED_EXPENSE_TYPE_ID,
reason: "expense_type_not_found_or_disabled",
}),
],
}),
}),
);
await app.close();
});
it("blocks management actions without system.settings", async () => {
const { app } = await buildApp({
actor: {
id: ACTOR_ID,
permissionCodes: [],
},
});
const response = await app.inject({
method: "POST",
url: "/api/reimbursement-templates",
payload: {
name: "无权限模板",
},
});
expect(response.statusCode).toBe(403);
await app.close();
});
});
@@ -0,0 +1,681 @@
const Fastify = require("fastify");
const { AppError } = require("../../../src/utils/AppError");
const cutover = require("../../../src/services/cutover");
const delegationUtils = require("../../../src/utils/delegation");
const ensureCutoverAllowedMock = vi.fn();
const findActiveDelegationForDelegateMock = vi.fn();
const listActiveDelegationPrincipalsMock = vi.fn();
const originalEnsureCutoverAllowed = cutover.ensureCutoverAllowed;
const originalFindActiveDelegationForDelegate = delegationUtils.findActiveDelegationForDelegate;
const originalListActiveDelegationPrincipals = delegationUtils.listActiveDelegationPrincipals;
cutover.ensureCutoverAllowed = ensureCutoverAllowedMock;
delegationUtils.findActiveDelegationForDelegate = findActiveDelegationForDelegateMock;
delegationUtils.listActiveDelegationPrincipals = listActiveDelegationPrincipalsMock;
delete require.cache[require.resolve("../../../src/routes/reimbursements")];
const reimbursementRoutes = require("../../../src/routes/reimbursements");
const ACTOR_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const OWNER_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
const DEPARTMENT_ID = "cccccccc-cccc-4ccc-8ccc-cccccccccccc";
const TEMPLATE_ID = "dddddddd-dddd-4ddd-8ddd-dddddddddddd";
const TRAVEL_ID = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee";
const ALT_TRAVEL_ID = "ffffffff-ffff-4fff-8fff-ffffffffffff";
const AIRFARE_TYPE_ID = "11111111-1111-4111-8111-111111111111";
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";
function cloneDate(value) {
return value ? new Date(value) : value;
}
function cloneTravelRequest(item) {
return {
...item,
start_date: cloneDate(item.start_date),
end_date: cloneDate(item.end_date),
created_at: cloneDate(item.created_at),
segments: (item.segments ?? []).map((segment) => ({ ...segment, created_at: cloneDate(segment.created_at) })),
linked_reimbursements: (item.linked_reimbursements ?? []).map((record) => ({ ...record })),
travel_orders: (item.travel_orders ?? []).map((record) => ({ ...record })),
};
}
function buildTemplate(overrides = {}) {
return {
id: TEMPLATE_ID,
name: "国内差旅模板",
enabled: true,
items: [
{
expenseTypeId: AIRFARE_TYPE_ID,
description: "机票占位",
defaultAmount: "888.80",
},
{
expenseTypeId: BUS_TYPE_ID,
description: "市内交通占位",
},
],
...overrides,
};
}
function buildTravelRequest(overrides = {}) {
return cloneTravelRequest({
id: TRAVEL_ID,
request_no: "TR-20260408-001",
purpose: "上海客户拜访",
applicant_employee_id: ACTOR_ID,
status: "approved",
start_date: new Date("2026-04-10T00:00:00.000Z"),
end_date: new Date("2026-04-12T00:00:00.000Z"),
estimated_amount: "3600.00",
created_at: new Date("2026-04-08T12:00:00.000Z"),
segments: [
{
id: "segment-1",
sort_order: 1,
segment_role: "outbound",
transport_type: "flight",
from_city: "上海",
to_city: "深圳",
created_at: new Date("2026-04-08T12:00:00.000Z"),
},
{
id: "segment-2",
sort_order: 2,
segment_role: "return",
transport_type: "bus",
from_city: "深圳",
to_city: "上海",
created_at: new Date("2026-04-08T12:01:00.000Z"),
},
],
linked_reimbursements: [],
travel_orders: [],
...overrides,
});
}
async function buildApp(options = {}) {
const app = Fastify();
const actor = options.actor ?? {
id: ACTOR_ID,
department_id: DEPARTMENT_ID,
roleCodes: [],
permissionCodes: ["expense.create"],
};
const state = {
reimbursements: [],
reimbursementItems: [],
templates: (options.templates ?? [buildTemplate()]).map((item) => ({
...item,
items: Array.isArray(item.items) ? item.items.map((entry) => ({ ...entry })) : [],
})),
employees: options.employees ?? [
{ id: ACTOR_ID, department_id: DEPARTMENT_ID, status: "active" },
{ id: OWNER_ID, department_id: DEPARTMENT_ID, status: "active" },
],
expenseTypes: options.expenseTypes ?? [
{ id: AIRFARE_TYPE_ID, code: "travel_airfare", name: "机票", category: "travel", enabled: true },
{ id: TRAIN_TYPE_ID, code: "travel_train", name: "火车票", category: "travel", enabled: true },
{ 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 },
],
travelRequests: (options.travelRequests ?? [buildTravelRequest()]).map((item) => cloneTravelRequest(item)),
auditLogs: [],
};
function buildReimbursementSelect(record, select) {
if (!select) {
return record;
}
return {
...(select.id ? { id: record.id } : {}),
...(select.status ? { status: record.status } : {}),
...(select.reimbursement_date ? { reimbursement_date: record.reimbursement_date } : {}),
...(select.description ? { description: record.description } : {}),
...(select.total_amount ? { total_amount: record.total_amount } : {}),
...(select.form_number ? { form_number: record.form_number } : {}),
...(select.owner_id ? { owner_id: record.owner_id } : {}),
...(select.submitter_id ? { submitter_id: record.submitter_id } : {}),
...(select.linked_travel_request_id ? { linked_travel_request_id: record.linked_travel_request_id ?? null } : {}),
...(select.travel_request
? {
travel_request: record.linked_travel_request_id
? (() => {
const travelRequest = state.travelRequests.find((item) => item.id === record.linked_travel_request_id);
return travelRequest
? {
id: travelRequest.id,
request_no: travelRequest.request_no,
purpose: travelRequest.purpose,
start_date: travelRequest.start_date,
end_date: travelRequest.end_date,
estimated_amount: travelRequest.estimated_amount,
}
: null;
})()
: null,
}
: {}),
};
}
function buildExpenseTypeMap() {
return new Map(state.expenseTypes.map((item) => [item.id, item]));
}
function buildPrismaLike(stateRef) {
return {
employee: {
findUnique: vi.fn(async ({ where }) => stateRef.employees.find((item) => item.id === where.id) ?? null),
},
reimbursementTemplate: {
findFirst: vi.fn(async ({ where }) => {
return stateRef.templates.find((item) => item.id === where.id && item.enabled === where.enabled) ?? null;
}),
},
expenseType: {
findMany: vi.fn(async ({ where }) => {
const ids = where?.id?.in ?? null;
const codes = where?.code?.in ?? null;
return stateRef.expenseTypes.filter((item) => {
if (where?.enabled === true && !item.enabled) {
return false;
}
if (ids && !ids.includes(item.id)) {
return false;
}
if (codes && !codes.includes(item.code)) {
return false;
}
return true;
});
}),
},
travelRequest: {
findMany: vi.fn(async ({ where }) => {
return stateRef.travelRequests
.filter((item) => {
if (where?.applicant_employee_id && item.applicant_employee_id !== where.applicant_employee_id) {
return false;
}
if (where?.status && item.status !== where.status) {
return false;
}
if (where?.linked_reimbursements?.none && item.linked_reimbursements.length > 0) {
return false;
}
if (where?.travel_orders?.none) {
return item.travel_orders.every((order) => order.linked_reimbursement_id == null);
}
return true;
})
.map((item) => ({
id: item.id,
request_no: item.request_no,
purpose: item.purpose,
start_date: item.start_date,
end_date: item.end_date,
estimated_amount: item.estimated_amount,
}));
}),
findUnique: vi.fn(async ({ where }) => {
const found = stateRef.travelRequests.find((item) => item.id === where.id) ?? null;
return found ? cloneTravelRequest(found) : null;
}),
},
reimbursementForm: {
create: vi.fn(async ({ data, select }) => {
const record = {
id: `reimbursement-${stateRef.reimbursements.length + 1}`,
form_number: null,
submitter_id: data.submitter_id,
owner_id: data.owner_id,
department_id: data.department_id,
reimbursement_date: cloneDate(data.reimbursement_date),
total_amount: "0.00",
status: "draft",
description: data.description ?? null,
linked_travel_request_id: data.linked_travel_request_id ?? null,
};
stateRef.reimbursements.push(record);
return buildReimbursementSelect(record, select);
}),
update: vi.fn(async ({ where, data }) => {
const target = stateRef.reimbursements.find((item) => item.id === where.id);
if (!target) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
Object.assign(target, data);
return { ...target };
}),
},
reimbursementItem: {
createMany: vi.fn(async ({ data }) => {
const baseLength = stateRef.reimbursementItems.length;
data.forEach((item, index) => {
stateRef.reimbursementItems.push({
id: `item-${baseLength + index + 1}`,
reimbursement_id: item.reimbursement_id,
expense_type_id: item.expense_type_id,
expense_date: cloneDate(item.expense_date),
amount: item.amount,
description: item.description,
created_at: new Date(`2026-04-08T12:00:${String(baseLength + index).padStart(2, "0")}.000Z`),
});
});
return { count: data.length };
}),
findMany: vi.fn(async ({ where }) => {
const expenseTypeMap = buildExpenseTypeMap();
return stateRef.reimbursementItems
.filter((item) => item.reimbursement_id === where.reimbursement_id)
.map((item) => ({
id: item.id,
expense_type_id: item.expense_type_id,
expense_date: item.expense_date,
amount: item.amount,
description: item.description,
expense_type: expenseTypeMap.get(item.expense_type_id) ?? null,
}));
}),
aggregate: vi.fn(async ({ where }) => {
const total = stateRef.reimbursementItems
.filter((item) => item.reimbursement_id === where.reimbursement_id)
.reduce((sum, item) => sum + Number(item.amount ?? 0), 0);
return { _sum: { amount: total.toFixed(2) } };
}),
},
auditLog: {
create: vi.fn(async ({ data }) => {
stateRef.auditLogs.push(data);
return { id: `audit-${stateRef.auditLogs.length}` };
}),
},
};
}
const prisma = buildPrismaLike(state);
prisma.$transaction = vi.fn(async (callback) => {
const txState = {
reimbursements: state.reimbursements.map((item) => ({ ...item, reimbursement_date: cloneDate(item.reimbursement_date) })),
reimbursementItems: state.reimbursementItems.map((item) => ({ ...item, expense_date: cloneDate(item.expense_date), created_at: cloneDate(item.created_at) })),
templates: state.templates.map((item) => ({ ...item, items: item.items.map((entry) => ({ ...entry })) })),
employees: state.employees.map((item) => ({ ...item })),
expenseTypes: state.expenseTypes.map((item) => ({ ...item })),
travelRequests: state.travelRequests.map((item) => cloneTravelRequest(item)),
auditLogs: state.auditLogs.map((item) => ({ ...item })),
};
const tx = buildPrismaLike(txState);
const result = await callback(tx);
state.reimbursements = txState.reimbursements;
state.reimbursementItems = txState.reimbursementItems;
state.templates = txState.templates;
state.employees = txState.employees;
state.expenseTypes = txState.expenseTypes;
state.travelRequests = txState.travelRequests;
state.auditLogs = txState.auditLogs;
return result;
});
app.decorate("prisma", prisma);
app.decorate("config", {
appTimezone: "Asia/Shanghai",
budgetControlMode: "warn",
imNotifierEnabled: false,
emailEnabled: false,
});
app.decorate("authenticate", async (request) => {
request.employee = actor;
});
app.decorate("hasPermission", (employee, permissionCode) => {
const permissionCodes = employee?.permissionCodes ?? [];
return permissionCodes.includes("*") || permissionCodes.includes(permissionCode);
});
app.decorate("requirePermission", (permissionCode) => async (request) => {
request.employee = actor;
if (!app.hasPermission(actor, permissionCode)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
});
app.setErrorHandler((error, request, reply) => {
reply.status(error.statusCode || 500).send({
requestId: request.id,
error: {
code: error.code || "SYS_001",
message: error.message,
details: error.details ?? null,
},
});
});
await app.register(reimbursementRoutes, { prefix: "/api" });
return { app, state, prisma };
}
describe("reimbursement create enhancements", () => {
afterAll(() => {
cutover.ensureCutoverAllowed = originalEnsureCutoverAllowed;
delegationUtils.findActiveDelegationForDelegate = originalFindActiveDelegationForDelegate;
delegationUtils.listActiveDelegationPrincipals = originalListActiveDelegationPrincipals;
});
beforeEach(() => {
vi.clearAllMocks();
ensureCutoverAllowedMock.mockResolvedValue(undefined);
findActiveDelegationForDelegateMock.mockResolvedValue(null);
listActiveDelegationPrincipalsMock.mockResolvedValue([]);
});
it("keeps blank reimbursement creation working", async () => {
const { app, state } = await buildApp();
const response = await app.inject({
method: "POST",
url: "/api/reimbursements",
payload: {
description: "空白报销",
},
});
expect(response.statusCode).toBe(200);
expect(response.json().data).toEqual(
expect.objectContaining({
description: "空白报销",
linkedTravelRequestId: null,
items: [],
totalAmount: "0.00",
}),
);
expect(state.reimbursementItems).toHaveLength(0);
await app.close();
});
it("creates reimbursement items from template", async () => {
const { app, state } = await buildApp();
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).toEqual(
expect.objectContaining({
totalAmount: "888.80",
items: [
expect.objectContaining({
expenseTypeId: AIRFARE_TYPE_ID,
amount: "888.80",
expenseDate: "2026-04-09",
}),
expect.objectContaining({
expenseTypeId: BUS_TYPE_ID,
amount: "0.00",
expenseDate: "2026-04-09",
}),
],
}),
);
expect(state.reimbursementItems).toHaveLength(2);
await app.close();
});
it("rejects templateId and linkedTravelRequestId when both are provided", async () => {
const { app } = await buildApp();
const response = await app.inject({
method: "POST",
url: "/api/reimbursements",
payload: {
templateId: TEMPLATE_ID,
linkedTravelRequestId: TRAVEL_ID,
},
});
expect(response.statusCode).toBe(400);
await app.close();
});
it("creates reimbursement linked to approved travel request with generated items", async () => {
const { app, state } = await buildApp();
const response = await app.inject({
method: "POST",
url: "/api/reimbursements",
payload: {
linkedTravelRequestId: TRAVEL_ID,
reimbursementDate: "2026-04-11",
},
});
expect(response.statusCode).toBe(200);
expect(response.json().data).toEqual(
expect.objectContaining({
description: "差旅报销 - TR-20260408-001 上海客户拜访",
linkedTravelRequestId: TRAVEL_ID,
linkedTravelRequest: expect.objectContaining({
requestNo: "TR-20260408-001",
estimatedAmount: "3600.00",
}),
items: [
expect.objectContaining({
expenseTypeId: AIRFARE_TYPE_ID,
description: "去程 航班 上海→深圳",
amount: "0.00",
}),
expect.objectContaining({
expenseTypeId: BUS_TYPE_ID,
description: "返程 大巴 深圳→上海",
amount: "0.00",
}),
],
}),
);
expect(state.reimbursements[0].linked_travel_request_id).toBe(TRAVEL_ID);
await app.close();
});
it("rejects travel request linking when request is not approved, not owned, or already linked", async () => {
const { app } = await buildApp({
travelRequests: [
buildTravelRequest({ id: TRAVEL_ID, status: "draft" }),
buildTravelRequest({ id: ALT_TRAVEL_ID, applicant_employee_id: OWNER_ID }),
buildTravelRequest({
id: "99999999-9999-4999-8999-999999999999",
linked_reimbursements: [{ id: "reimbursement-x" }],
}),
buildTravelRequest({
id: "88888888-8888-4888-8888-888888888888",
travel_orders: [{ id: "travel-order-1", linked_reimbursement_id: "legacy-linked-reimbursement" }],
}),
],
});
const notApprovedResponse = await app.inject({
method: "POST",
url: "/api/reimbursements",
payload: {
linkedTravelRequestId: TRAVEL_ID,
},
});
const notOwnedResponse = await app.inject({
method: "POST",
url: "/api/reimbursements",
payload: {
linkedTravelRequestId: ALT_TRAVEL_ID,
},
});
const linkedByNewColumnResponse = await app.inject({
method: "POST",
url: "/api/reimbursements",
payload: {
linkedTravelRequestId: "99999999-9999-4999-8999-999999999999",
},
});
const linkedByLegacyOrderResponse = await app.inject({
method: "POST",
url: "/api/reimbursements",
payload: {
linkedTravelRequestId: "88888888-8888-4888-8888-888888888888",
},
});
expect(notApprovedResponse.statusCode).toBe(409);
expect(notOwnedResponse.statusCode).toBe(409);
expect(linkedByNewColumnResponse.statusCode).toBe(409);
expect(linkedByLegacyOrderResponse.statusCode).toBe(409);
await app.close();
});
it("rejects travel request linking when mapped expense type is missing", async () => {
const { app } = await buildApp({
travelRequests: [
buildTravelRequest({
segments: [
{
id: "segment-x",
sort_order: 1,
segment_role: "outbound",
transport_type: "other",
from_city: "上海",
to_city: "杭州",
created_at: new Date("2026-04-08T12:02:00.000Z"),
},
],
}),
],
expenseTypes: [
{ id: AIRFARE_TYPE_ID, code: "travel_airfare", name: "机票", category: "travel", enabled: true },
],
});
const response = await app.inject({
method: "POST",
url: "/api/reimbursements",
payload: {
linkedTravelRequestId: TRAVEL_ID,
},
});
expect(response.statusCode).toBe(409);
expect(response.json().error.details).toEqual(
expect.objectContaining({
linkedTravelRequestId: "missing_expense_type_mapping",
expenseTypeCode: "transport",
}),
);
await app.close();
});
it("lists only linkable approved travel requests and supports proxy owner", async () => {
findActiveDelegationForDelegateMock.mockResolvedValue({
id: "delegation-1",
});
const { app } = await buildApp({
actor: {
id: ACTOR_ID,
department_id: DEPARTMENT_ID,
roleCodes: ["administration"],
permissionCodes: ["expense.create", "expense.proxy_submit"],
},
travelRequests: [
buildTravelRequest({
id: TRAVEL_ID,
applicant_employee_id: OWNER_ID,
request_no: "TR-OWNER-001",
}),
buildTravelRequest({
id: ALT_TRAVEL_ID,
applicant_employee_id: OWNER_ID,
linked_reimbursements: [{ id: "linked-reimbursement" }],
}),
buildTravelRequest({
id: "77777777-7777-4777-8777-777777777777",
applicant_employee_id: OWNER_ID,
travel_orders: [{ id: "travel-order-2", linked_reimbursement_id: "legacy-link" }],
}),
],
});
const response = await app.inject({
method: "GET",
url: `/api/reimbursements/linkable-travel-requests?ownerEmployeeId=${OWNER_ID}`,
});
expect(response.statusCode).toBe(200);
expect(response.json().data).toEqual({
ownerEmployeeId: OWNER_ID,
items: [
expect.objectContaining({
id: TRAVEL_ID,
requestNo: "TR-OWNER-001",
}),
],
});
await app.close();
});
it("supports proxy owner when linking travel request during creation", async () => {
findActiveDelegationForDelegateMock.mockResolvedValue({
id: "delegation-2",
});
const { app } = await buildApp({
actor: {
id: ACTOR_ID,
department_id: DEPARTMENT_ID,
roleCodes: ["administration"],
permissionCodes: ["expense.create", "expense.proxy_submit"],
},
travelRequests: [
buildTravelRequest({
applicant_employee_id: OWNER_ID,
}),
],
});
const response = await app.inject({
method: "POST",
url: "/api/reimbursements",
payload: {
ownerEmployeeId: OWNER_ID,
linkedTravelRequestId: TRAVEL_ID,
},
});
expect(response.statusCode).toBe(200);
expect(response.json().data).toEqual(
expect.objectContaining({
ownerId: OWNER_ID,
linkedTravelRequestId: TRAVEL_ID,
}),
);
await app.close();
});
});