R56 A1-A6:费用标准管控

这个提交包含在:
2026-04-08 12:47:40 +08:00
父节点 40c3b7632a
当前提交 845558ab17
共修改 25 个文件,包含 2993 行新增和 10 行删除
@@ -0,0 +1,34 @@
CREATE TABLE "expense_standards" (
"id" UUID NOT NULL,
"expense_type_id" UUID NOT NULL,
"name" VARCHAR(100) NOT NULL,
"max_amount" DECIMAL(14,2) NOT NULL,
"per_unit" VARCHAR(20) NOT NULL DEFAULT 'item',
"control_mode" VARCHAR(16) NOT NULL DEFAULT 'warn',
"scope_type" VARCHAR(32) NOT NULL DEFAULT 'global',
"scope_key" UUID,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"note" TEXT,
"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 "expense_standards_pkey" PRIMARY KEY ("id")
);
ALTER TABLE "expense_standards"
ADD CONSTRAINT "expense_standards_expense_type_id_fkey"
FOREIGN KEY ("expense_type_id") REFERENCES "expense_types"("id")
ON DELETE RESTRICT ON UPDATE CASCADE;
CREATE INDEX "idx_expense_standards_enabled_type"
ON "expense_standards"("enabled", "expense_type_id");
CREATE UNIQUE INDEX "expense_standards_type_scope_global_ux"
ON "expense_standards"("expense_type_id", "scope_type")
WHERE "scope_key" IS NULL;
CREATE UNIQUE INDEX "expense_standards_type_scope_scoped_ux"
ON "expense_standards"("expense_type_id", "scope_type", "scope_key")
WHERE "scope_key" IS NOT NULL;
+24
查看文件
@@ -812,12 +812,36 @@ model ExpenseType {
updated_at DateTime @default(now()) @updatedAt
updated_by String? @db.Uuid
standards ExpenseStandard[]
reimbursement_items ReimbursementItem[]
@@index([enabled, sort_order])
@@map("expense_types")
}
model ExpenseStandard {
id String @id @default(uuid()) @db.Uuid
expense_type_id String @db.Uuid
name String @db.VarChar(100)
max_amount Decimal @db.Decimal(14, 2)
per_unit String @default("item") @db.VarChar(20)
control_mode String @default("warn") @db.VarChar(16)
scope_type String @default("global") @db.VarChar(32)
scope_key String? @db.Uuid
enabled Boolean @default(true)
note String?
created_at DateTime @default(now())
created_by String? @db.Uuid
updated_at DateTime @default(now()) @updatedAt
updated_by String? @db.Uuid
expense_type ExpenseType @relation(fields: [expense_type_id], references: [id], onDelete: Restrict)
@@index([enabled, expense_type_id], map: "idx_expense_standards_enabled_type")
@@map("expense_standards")
}
model DocumentSequence {
document_type String @id
prefix String
+2
查看文件
@@ -34,6 +34,7 @@ const employeesRoutes = require("./routes/employees");
const performanceRoutes = require("./routes/performance");
const payrollRoutes = require("./routes/payroll");
const expenseTypesRoutes = require("./routes/expenseTypes");
const expenseStandardsRoutes = require("./routes/expenseStandards");
const configReadRoutes = require("./routes/configRead");
const approvalRulesRoutes = require("./routes/approvalRules");
const featureFlagsRoutes = require("./routes/featureFlags");
@@ -322,6 +323,7 @@ async function buildApp() {
await app.register(masterDataHolidayCalendarsRoutes, { prefix: "/api" });
await app.register(masterDataSuppliersRoutes, { prefix: "/api" });
await app.register(expenseTypesRoutes, { prefix: "/api" });
await app.register(expenseStandardsRoutes, { prefix: "/api" });
await app.register(configReadRoutes, { prefix: "/api" });
await app.register(approvalRulesRoutes, { prefix: "/api" });
await app.register(featureFlagsRoutes, { prefix: "/api" });
+617
查看文件
@@ -0,0 +1,617 @@
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_SCHEMA = z.string().trim().regex(/^\d+(\.\d{1,2})?$/);
const PER_UNIT_VALUES = ["item", "day", "trip"];
const CONTROL_MODE_VALUES = ["warn", "block"];
const SCOPE_TYPE_VALUES = ["global", "department"];
const LIST_QUERY_SCHEMA = z
.object({
expenseTypeId: UUID_SCHEMA.optional(),
expense_type_id: UUID_SCHEMA.optional(),
scopeType: z.enum(SCOPE_TYPE_VALUES).optional(),
scope_type: z.enum(SCOPE_TYPE_VALUES).optional(),
enabled: z.enum(["true", "false", "1", "0"]).optional(),
})
.strict();
const CREATE_BODY_SCHEMA = z
.object({
expenseTypeId: UUID_SCHEMA,
name: z.string().trim().min(1).max(100),
maxAmount: MONEY_SCHEMA,
perUnit: z.enum(PER_UNIT_VALUES).optional(),
controlMode: z.enum(CONTROL_MODE_VALUES).optional(),
scopeType: z.enum(SCOPE_TYPE_VALUES).optional(),
scopeKey: UUID_SCHEMA.nullish(),
enabled: z.boolean().optional(),
note: z.string().trim().max(500).nullable().optional(),
})
.strict();
const UPDATE_BODY_SCHEMA = z
.object({
name: z.string().trim().min(1).max(100).optional(),
maxAmount: MONEY_SCHEMA.optional(),
perUnit: z.enum(PER_UNIT_VALUES).optional(),
controlMode: z.enum(CONTROL_MODE_VALUES).optional(),
scopeType: z.enum(SCOPE_TYPE_VALUES).optional(),
scopeKey: UUID_SCHEMA.nullish().optional(),
enabled: z.boolean().optional(),
note: z.string().trim().max(500).nullable().optional(),
})
.strict();
function buildValidationError(details, statusCode = 400) {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode,
details,
});
}
function toMoneyString(value) {
const numeric = Number(value ?? 0);
if (!Number.isFinite(numeric)) {
return "0.00";
}
return numeric.toFixed(2);
}
function parseExpenseStandardId(params) {
const parsed = UUID_SCHEMA.safeParse(params?.id);
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
: ["true", "1"].includes(enabledRaw)
? true
: false;
return {
expenseTypeId: parsed.data.expenseTypeId ?? parsed.data.expense_type_id ?? null,
scopeType: parsed.data.scopeType ?? parsed.data.scope_type ?? null,
enabled,
};
}
function parseBody(schema, body) {
const parsed = schema.safeParse(body ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
}
const data = parsed.data;
if ("maxAmount" in data && data.maxAmount != null && Number(data.maxAmount) <= 0) {
throw buildValidationError({ maxAmount: "must_be_positive" });
}
return data;
}
async function ensureExpenseTypeExists(tx, expenseTypeId) {
const expenseType = await tx.expenseType.findUnique({
where: { id: expenseTypeId },
select: {
id: true,
code: true,
name: true,
},
});
if (!expenseType) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { expenseTypeId: "not_found" },
});
}
return expenseType;
}
async function ensureScopeKeyValid(tx, scopeType, scopeKey) {
if (scopeType === "global") {
return null;
}
if (!scopeKey) {
throw buildValidationError({ scopeKey: "required_for_department_scope" });
}
const department = await tx.department.findUnique({
where: { id: scopeKey },
select: {
id: true,
name: true,
archived_at: true,
},
});
if (!department || department.archived_at) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { scopeKey: "invalid_department" },
});
}
return department;
}
function buildStandardPayload(body, actorId) {
const scopeType = body.scopeType ?? "global";
return {
...(typeof body.name === "string" ? { name: body.name } : {}),
...(typeof body.maxAmount === "string" ? { max_amount: body.maxAmount } : {}),
...(typeof body.perUnit === "string" ? { per_unit: body.perUnit } : {}),
...(typeof body.controlMode === "string" ? { control_mode: body.controlMode } : {}),
scope_type: scopeType,
scope_key: scopeType === "department" ? body.scopeKey ?? null : null,
...(typeof body.enabled === "boolean" ? { enabled: body.enabled } : {}),
...(Object.prototype.hasOwnProperty.call(body, "note") ? { note: body.note?.trim?.() || null } : {}),
updated_by: actorId,
};
}
function buildScopeLabel(item, departmentMap) {
if (item.scope_type === "global") {
return "全局";
}
return departmentMap.get(item.scope_key) ?? item.scope_key ?? "指定部门";
}
function mapExpenseStandard(item, departmentMap) {
return {
id: item.id,
expenseTypeId: item.expense_type_id,
expenseTypeName: item.expense_type?.name ?? null,
name: item.name,
maxAmount: toMoneyString(item.max_amount),
perUnit: item.per_unit,
controlMode: item.control_mode,
scopeType: item.scope_type,
scopeKey: item.scope_key ?? null,
scopeLabel: buildScopeLabel(item, departmentMap),
enabled: item.enabled,
note: item.note ?? null,
createdAt: item.created_at?.toISOString?.() ?? null,
updatedAt: item.updated_at?.toISOString?.() ?? null,
};
}
function normalizeRecordSnapshot(item) {
if (!item) {
return null;
}
return {
id: item.id,
expenseTypeId: item.expense_type_id,
name: item.name,
maxAmount: toMoneyString(item.max_amount),
perUnit: item.per_unit,
controlMode: item.control_mode,
scopeType: item.scope_type,
scopeKey: item.scope_key ?? null,
enabled: item.enabled,
note: item.note ?? null,
};
}
async function buildDepartmentMap(prisma, items) {
const departmentIds = Array.from(
new Set(
items
.filter((item) => item.scope_type === "department" && item.scope_key)
.map((item) => item.scope_key),
),
);
if (!departmentIds.length) {
return new Map();
}
const departments = await prisma.department.findMany({
where: {
id: { in: departmentIds },
},
select: {
id: true,
name: true,
},
});
return new Map(departments.map((item) => [item.id, item.name]));
}
async function expenseStandardsRoutes(fastify) {
fastify.get(
"/expense-standards",
{
preHandler: fastify.authenticate,
},
async (request, reply) => {
const query = parseListQuery(request.query);
const canManage = fastify.hasPermission(request.employee, "system.settings");
if (!canManage && (!query.expenseTypeId || query.enabled !== true)) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
const items = await fastify.prisma.expenseStandard.findMany({
where: {
...(query.expenseTypeId ? { expense_type_id: query.expenseTypeId } : {}),
...(query.scopeType ? { scope_type: query.scopeType } : {}),
...(query.enabled == null ? {} : { enabled: query.enabled }),
},
orderBy: [
{ expense_type: { sort_order: "asc" } },
{ expense_type: { code: "asc" } },
{ scope_type: "asc" },
{ name: "asc" },
{ created_at: "asc" },
],
select: {
id: true,
expense_type_id: true,
name: true,
max_amount: true,
per_unit: true,
control_mode: true,
scope_type: true,
scope_key: true,
enabled: true,
note: true,
created_at: true,
updated_at: true,
expense_type: {
select: {
id: true,
name: true,
code: true,
},
},
},
});
const departmentMap = await buildDepartmentMap(fastify.prisma, items);
return sendData(reply, request, {
expenseTypeId: query.expenseTypeId,
scopeType: query.scopeType,
enabled: query.enabled,
items: items.map((item) => mapExpenseStandard(item, departmentMap)),
});
},
);
fastify.post(
"/expense-standards",
{
preHandler: fastify.requirePermission("system.settings"),
},
async (request, reply) => {
const body = parseBody(CREATE_BODY_SCHEMA, request.body);
const now = new Date();
let created = null;
try {
created = await fastify.prisma.$transaction(async (tx) => {
const expenseType = await ensureExpenseTypeExists(tx, body.expenseTypeId);
const scopeType = body.scopeType ?? "global";
const department = await ensureScopeKeyValid(tx, scopeType, body.scopeKey ?? null);
const record = await tx.expenseStandard.create({
data: {
expense_type_id: body.expenseTypeId,
name: body.name,
max_amount: body.maxAmount,
per_unit: body.perUnit ?? "item",
control_mode: body.controlMode ?? "warn",
scope_type: scopeType,
scope_key: scopeType === "department" ? department?.id ?? body.scopeKey ?? null : null,
enabled: body.enabled ?? true,
note: body.note?.trim?.() || null,
created_at: now,
created_by: request.employee.id,
updated_at: now,
updated_by: request.employee.id,
},
select: {
id: true,
expense_type_id: true,
name: true,
max_amount: true,
per_unit: true,
control_mode: true,
scope_type: true,
scope_key: true,
enabled: true,
note: true,
created_at: true,
updated_at: true,
expense_type: {
select: {
id: true,
name: true,
},
},
},
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "EXPENSE_STANDARD_CREATED",
targetType: "expense_standard",
targetId: record.id,
extra: {
expenseTypeId: expenseType.id,
expenseTypeName: expenseType.name,
scopeType: record.scope_type,
scopeKey: record.scope_key,
controlMode: record.control_mode,
maxAmount: toMoneyString(record.max_amount),
after: normalizeRecordSnapshot(record),
},
}),
});
return record;
});
} catch (error) {
if (error?.code === "P2002") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { duplicate: "expense_type_scope_conflict" },
});
}
throw error;
}
const departmentMap = await buildDepartmentMap(fastify.prisma, [created]);
return sendData(reply, request, mapExpenseStandard(created, departmentMap));
},
);
fastify.patch(
"/expense-standards/:id",
{
preHandler: fastify.requirePermission("system.settings"),
},
async (request, reply) => {
const standardId = parseExpenseStandardId(request.params);
const body = parseBody(UPDATE_BODY_SCHEMA, request.body);
if (!Object.keys(body).length) {
throw buildValidationError({ body: "empty" });
}
const now = new Date();
let updated = null;
try {
updated = await fastify.prisma.$transaction(async (tx) => {
const current = await tx.expenseStandard.findUnique({
where: { id: standardId },
select: {
id: true,
expense_type_id: true,
name: true,
max_amount: true,
per_unit: true,
control_mode: true,
scope_type: true,
scope_key: true,
enabled: true,
note: true,
},
});
if (!current) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const mergedBody = {
...body,
scopeType: body.scopeType ?? current.scope_type,
scopeKey:
Object.prototype.hasOwnProperty.call(body, "scopeKey")
? body.scopeKey ?? null
: current.scope_key ?? null,
};
await ensureScopeKeyValid(tx, mergedBody.scopeType, mergedBody.scopeKey);
const record = await tx.expenseStandard.update({
where: { id: standardId },
data: {
...buildStandardPayload(mergedBody, request.employee.id),
updated_at: now,
},
select: {
id: true,
expense_type_id: true,
name: true,
max_amount: true,
per_unit: true,
control_mode: true,
scope_type: true,
scope_key: true,
enabled: true,
note: true,
created_at: true,
updated_at: true,
expense_type: {
select: {
id: true,
name: true,
},
},
},
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "EXPENSE_STANDARD_UPDATED",
targetType: "expense_standard",
targetId: standardId,
extra: {
expenseTypeId: record.expense_type_id,
scopeType: record.scope_type,
scopeKey: record.scope_key,
controlMode: record.control_mode,
maxAmount: toMoneyString(record.max_amount),
before: normalizeRecordSnapshot(current),
after: normalizeRecordSnapshot(record),
},
}),
});
return record;
});
} catch (error) {
if (error?.code === "P2002") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { duplicate: "expense_type_scope_conflict" },
});
}
throw error;
}
const departmentMap = await buildDepartmentMap(fastify.prisma, [updated]);
return sendData(reply, request, mapExpenseStandard(updated, departmentMap));
},
);
fastify.delete(
"/expense-standards/:id",
{
preHandler: fastify.requirePermission("system.settings"),
},
async (request, reply) => {
const standardId = parseExpenseStandardId(request.params);
const now = new Date();
const deleted = await fastify.prisma.$transaction(async (tx) => {
const current = await tx.expenseStandard.findUnique({
where: { id: standardId },
select: {
id: true,
expense_type_id: true,
name: true,
max_amount: true,
per_unit: true,
control_mode: true,
scope_type: true,
scope_key: true,
enabled: true,
note: true,
created_at: true,
updated_at: true,
expense_type: {
select: {
id: true,
name: true,
},
},
},
});
if (!current) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const record = await tx.expenseStandard.update({
where: { id: standardId },
data: {
enabled: false,
updated_at: now,
updated_by: request.employee.id,
},
select: {
id: true,
expense_type_id: true,
name: true,
max_amount: true,
per_unit: true,
control_mode: true,
scope_type: true,
scope_key: true,
enabled: true,
note: true,
created_at: true,
updated_at: true,
expense_type: {
select: {
id: true,
name: true,
},
},
},
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "EXPENSE_STANDARD_DELETED",
targetType: "expense_standard",
targetId: standardId,
extra: {
expenseTypeId: record.expense_type_id,
scopeType: record.scope_type,
scopeKey: record.scope_key,
controlMode: record.control_mode,
maxAmount: toMoneyString(record.max_amount),
before: normalizeRecordSnapshot(current),
after: normalizeRecordSnapshot(record),
},
}),
});
return record;
});
const departmentMap = await buildDepartmentMap(fastify.prisma, [deleted]);
return sendData(reply, request, mapExpenseStandard(deleted, departmentMap));
},
);
}
module.exports = expenseStandardsRoutes;
+26 -1
查看文件
@@ -663,16 +663,37 @@ function buildBudgetHint(budgetPool, reimbursementAmount, controlMode) {
}
function buildPrecheckBlockedError(precheck) {
const standardViolations = Array.isArray(precheck?.standardChecks)
? precheck.standardChecks.filter((item) => item.exceeded && item.controlMode === "block")
: [];
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
statusCode: standardViolations.length ? 400 : 409,
details: {
precheck: precheck.blockingReasons,
...(standardViolations.length ? { standardViolations } : {}),
},
});
}
function listExceededStandardViolations(precheck, controlMode = null) {
if (!Array.isArray(precheck?.standardChecks)) {
return [];
}
return precheck.standardChecks.filter((item) => {
if (!item.exceeded) {
return false;
}
if (!controlMode) {
return true;
}
return item.controlMode === controlMode;
});
}
function buildSkippedNotificationResult(reason) {
return { skipped: true, reason };
}
@@ -2079,6 +2100,8 @@ async function reimbursementRoutes(fastify) {
if (authoritativePrecheck.decision === "block") {
throw buildPrecheckBlockedError(authoritativePrecheck);
}
const standardViolations = listExceededStandardViolations(authoritativePrecheck);
const standardWarnings = listExceededStandardViolations(authoritativePrecheck, "warn");
const openedRiskFlags = await createRiskFlagsFromPrecheck(tx, {
entityType: "reimbursement",
entityId: reimbursementId,
@@ -2238,6 +2261,7 @@ async function reimbursementRoutes(fastify) {
department_id: reimbursement.department_id,
workflow_steps_snapshot: workflowSteps,
approval_rule_id: approvalRule.id,
standardViolations,
},
context_department_id: reimbursement.department_id,
created_by: request.employee.id,
@@ -2319,6 +2343,7 @@ async function reimbursementRoutes(fastify) {
status: "reviewing",
budget: budgetCheck,
precheckDecision: authoritativePrecheck.decision,
standardWarnings,
openedRiskFlagCount: openedRiskFlags.length,
},
sideEffectsContext: {
+6
查看文件
@@ -1450,6 +1450,7 @@ async function workflowRoutes(fastify) {
entity_id: true,
current_status: true,
current_step_id: true,
context_data: true,
started_at: true,
completed_at: true,
reimbursement_form: {
@@ -1676,6 +1677,11 @@ async function workflowRoutes(fastify) {
entityType: task.workflow_instance.entity_type,
currentStatus: task.workflow_instance.current_status,
currentStepId: task.workflow_instance.current_step_id,
contextData: {
standardViolations: Array.isArray(task.workflow_instance.context_data?.standardViolations)
? task.workflow_instance.context_data.standardViolations
: [],
},
startedAt: isoOrNull(task.workflow_instance.started_at),
completedAt: isoOrNull(task.workflow_instance.completed_at),
}
+178 -2
查看文件
@@ -56,6 +56,7 @@ function buildBasePrecheck(entityType, entityId) {
decision: "pass",
matchedBudgetControlRule: null,
budgetHint: null,
standardChecks: [],
warnings: [],
blockingReasons: [],
riskFlags: [],
@@ -70,6 +71,7 @@ function buildDisabledPrecheck(entityType, entityId) {
decision: "pass",
matchedBudgetControlRule: null,
budgetHint: null,
standardChecks: [],
warnings: [],
blockingReasons: [],
riskFlags: [],
@@ -272,6 +274,165 @@ function appendBlockingReasons(response, issues) {
return response;
}
function buildStandardIssueDetail(check) {
return `标准:${check.standardName},上限 ¥${check.maxAmount}/${check.perUnit},当前 ¥${check.amount},超出 ¥${check.exceededAmount}`;
}
function buildStandardIssue(check) {
return buildIssue(
`expense_standard_${check.controlMode}_${check.itemId}`,
`${check.expenseTypeName} 超出费用标准`,
buildStandardIssueDetail(check),
);
}
async function checkExpenseStandards(tx, { reimbursementId }) {
const reimbursement = await tx.reimbursementForm.findUnique({
where: { id: reimbursementId },
select: {
id: true,
department_id: true,
items: {
orderBy: [{ created_at: "asc" }, { id: "asc" }],
select: {
id: true,
expense_type_id: true,
amount: true,
expense_type: {
select: {
id: true,
name: true,
},
},
},
},
},
});
if (!reimbursement) {
throw buildNotFoundError();
}
const expenseTypeIds = Array.from(
new Set(reimbursement.items.map((item) => item.expense_type_id).filter(Boolean)),
);
if (!expenseTypeIds.length) {
return [];
}
const standards = await tx.expenseStandard.findMany({
where: {
enabled: true,
expense_type_id: { in: expenseTypeIds },
OR: [
{ scope_type: "global" },
...(reimbursement.department_id
? [{ scope_type: "department", scope_key: reimbursement.department_id }]
: []),
],
},
orderBy: [{ created_at: "asc" }, { id: "asc" }],
select: {
id: true,
expense_type_id: true,
name: true,
max_amount: true,
per_unit: true,
control_mode: true,
scope_type: true,
scope_key: true,
},
});
const standardMap = new Map();
for (const standard of standards) {
const current = standardMap.get(standard.expense_type_id);
if (!current) {
standardMap.set(standard.expense_type_id, standard);
continue;
}
const currentPriority = current.scope_type === "department" ? 2 : 1;
const nextPriority = standard.scope_type === "department" ? 2 : 1;
if (nextPriority > currentPriority) {
standardMap.set(standard.expense_type_id, standard);
}
}
return reimbursement.items
.map((item) => {
const standard = standardMap.get(item.expense_type_id);
if (!standard) {
return null;
}
const amount = toMoneyNumber(item.amount);
const maxAmount = toMoneyNumber(standard.max_amount);
const exceededAmount = Math.max(0, toMoneyNumber(amount - maxAmount));
return {
itemId: item.id,
expenseTypeId: item.expense_type_id,
expenseTypeName: item.expense_type?.name ?? item.expense_type_id,
amount: toMoneyString(amount),
standardId: standard.id,
standardName: standard.name,
maxAmount: toMoneyString(maxAmount),
perUnit: standard.per_unit,
controlMode: standard.control_mode,
scopeType: standard.scope_type,
scopeKey: standard.scope_key ?? null,
exceeded: amount > maxAmount,
exceededAmount: toMoneyString(exceededAmount),
};
})
.filter(Boolean);
}
function mergeReimbursementPrecheck(precheck, standardChecks) {
const response = {
...precheck,
warnings: [...precheck.warnings],
blockingReasons: [...precheck.blockingReasons],
riskFlags: [...precheck.riskFlags],
nextActions: [...precheck.nextActions],
standardChecks,
};
const warnChecks = standardChecks.filter((item) => item.exceeded && item.controlMode === "warn");
const blockChecks = standardChecks.filter((item) => item.exceeded && item.controlMode === "block");
if (warnChecks.length) {
response.warnings.push(...warnChecks.map((item) => buildStandardIssue(item)));
}
if (blockChecks.length) {
response.blockingReasons.push(...blockChecks.map((item) => buildStandardIssue(item)));
}
if (response.blockingReasons.length) {
response.decision = "block";
response.nextActions = ["修正阻断项后再提交"];
return response;
}
if (response.riskFlags.length) {
response.decision = "manual_review";
response.nextActions = ["允许提交,但会生成待复核风险项"];
return response;
}
if (response.warnings.length) {
response.decision = "warn";
response.nextActions = ["确认提示后可继续提交"];
return response;
}
response.decision = "pass";
response.nextActions = ["可直接提交"];
return response;
}
async function createRiskFlagsFromPrecheck(tx, { entityType, entityId, actorId, precheck, source = "precheck" }) {
if (precheck.decision !== "manual_review" || !precheck.riskFlags.length) {
return [];
@@ -308,6 +469,7 @@ async function createRiskFlagsFromPrecheck(tx, { entityType, entityId, actorId,
openedByDecision: precheck.decision,
matchedBudgetControlRule: precheck.matchedBudgetControlRule,
budgetHint: precheck.budgetHint,
standardChecks: precheck.standardChecks ?? [],
},
created_by: actorId ?? null,
},
@@ -843,15 +1005,27 @@ async function resolveEntityPrecheck({
});
if (!gate.enabled) {
return buildDisabledPrecheck(entityType, entityId);
const disabledPrecheck = buildDisabledPrecheck(entityType, entityId);
if (entityType !== "reimbursement") {
return disabledPrecheck;
}
const standardChecks = await checkExpenseStandards(prisma, { reimbursementId: entityId });
return mergeReimbursementPrecheck(disabledPrecheck, standardChecks);
}
return buildEntityPrecheck(prisma, {
const precheck = await buildEntityPrecheck(prisma, {
entityType,
entityId,
config,
logger,
});
if (entityType !== "reimbursement") {
return precheck;
}
const standardChecks = await checkExpenseStandards(prisma, { reimbursementId: entityId });
return mergeReimbursementPrecheck(precheck, standardChecks);
}
module.exports = {
@@ -870,6 +1044,8 @@ module.exports = {
buildReimbursementPrecheck,
buildPurchaseRequestPrecheck,
buildTravelRequestPrecheck,
checkExpenseStandards,
loadPrecheckEntityContext,
mergeReimbursementPrecheck,
resolveEntityPrecheck,
};
@@ -0,0 +1,457 @@
const Fastify = require("fastify");
const expenseStandardsRoutes = require("../../../src/routes/expenseStandards");
const { AppError } = require("../../../src/utils/AppError");
const ACTOR_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const EXPENSE_TYPE_ID = "11111111-1111-4111-8111-111111111111";
const DEPARTMENT_ID = "22222222-2222-4222-8222-222222222222";
const STANDARD_ID = "33333333-3333-4333-8333-333333333333";
function cloneStandard(item) {
return {
...item,
created_at: new Date(item.created_at),
updated_at: new Date(item.updated_at),
expense_type: item.expense_type ? { ...item.expense_type } : null,
};
}
function buildStandard(overrides = {}) {
return {
id: STANDARD_ID,
expense_type_id: EXPENSE_TYPE_ID,
name: "日常交通费上限",
max_amount: "120.00",
per_unit: "item",
control_mode: "warn",
scope_type: "global",
scope_key: null,
enabled: true,
note: "默认提示",
created_at: new Date("2026-04-08T09:00:00.000Z"),
updated_at: new Date("2026-04-08T09:00:00.000Z"),
expense_type: {
id: EXPENSE_TYPE_ID,
code: "traffic",
name: "交通费",
},
...overrides,
};
}
function findDuplicate(items, candidate, currentId = null) {
return items.find((item) => {
if (currentId && item.id === currentId) {
return false;
}
return (
item.expense_type_id === candidate.expense_type_id &&
item.scope_type === candidate.scope_type &&
(item.scope_key ?? null) === (candidate.scope_key ?? null)
);
});
}
function makeSelectStandard(state, item) {
const expenseType = state.expenseTypes.find((record) => record.id === item.expense_type_id) ?? null;
return {
...item,
expense_type: expenseType
? {
id: expenseType.id,
code: expenseType.code,
name: expenseType.name,
}
: null,
};
}
async function buildApp(options = {}) {
const app = Fastify();
const actor = options.actor ?? {
id: ACTOR_ID,
permissionCodes: ["system.settings"],
department_id: DEPARTMENT_ID,
};
const state = {
expenseTypes: options.expenseTypes ?? [
{
id: EXPENSE_TYPE_ID,
code: "traffic",
name: "交通费",
},
],
departments: options.departments ?? [
{
id: DEPARTMENT_ID,
name: "财务部",
archived_at: null,
},
],
standards: (options.standards ?? [buildStandard()]).map((item) => cloneStandard(item)),
auditLogs: [],
};
function buildPrismaLike(stateRef) {
return {
expenseStandard: {
findMany: vi.fn(async ({ where } = {}) => {
return stateRef.standards
.filter((item) => {
if (where?.expense_type_id && item.expense_type_id !== where.expense_type_id) {
return false;
}
if (where?.scope_type && item.scope_type !== where.scope_type) {
return false;
}
if (typeof where?.enabled === "boolean" && item.enabled !== where.enabled) {
return false;
}
return true;
})
.map((item) => makeSelectStandard(stateRef, cloneStandard(item)));
}),
findUnique: vi.fn(async ({ where }) => {
const found = stateRef.standards.find((item) => item.id === where.id) ?? null;
return found ? makeSelectStandard(stateRef, cloneStandard(found)) : null;
}),
create: vi.fn(async ({ data }) => {
if (findDuplicate(stateRef.standards, data)) {
const error = new Error("duplicate");
error.code = "P2002";
throw error;
}
const record = buildStandard({
id: `standard-${stateRef.standards.length + 1}`,
expense_type_id: data.expense_type_id,
name: data.name,
max_amount: data.max_amount,
per_unit: data.per_unit,
control_mode: data.control_mode,
scope_type: data.scope_type,
scope_key: data.scope_key ?? null,
enabled: data.enabled,
note: data.note ?? null,
created_at: data.created_at,
updated_at: data.updated_at,
});
stateRef.standards.push(record);
return makeSelectStandard(stateRef, cloneStandard(record));
}),
update: vi.fn(async ({ where, data }) => {
const index = stateRef.standards.findIndex((item) => item.id === where.id);
if (index < 0) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
const next = {
...stateRef.standards[index],
...data,
};
if (findDuplicate(stateRef.standards, next, where.id)) {
const error = new Error("duplicate");
error.code = "P2002";
throw error;
}
stateRef.standards[index] = next;
return makeSelectStandard(stateRef, cloneStandard(next));
}),
},
expenseType: {
findUnique: vi.fn(async ({ where }) => stateRef.expenseTypes.find((item) => item.id === where.id) ?? null),
},
department: {
findUnique: vi.fn(async ({ where }) => stateRef.departments.find((item) => item.id === where.id) ?? null),
findMany: vi.fn(async ({ where }) => {
const ids = where?.id?.in ?? [];
return stateRef.departments.filter((item) => ids.includes(item.id));
}),
},
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 = {
expenseTypes: state.expenseTypes.map((item) => ({ ...item })),
departments: state.departments.map((item) => ({ ...item })),
standards: state.standards.map((item) => cloneStandard(item)),
auditLogs: state.auditLogs.map((item) => ({ ...item })),
};
const tx = buildPrismaLike(txState);
const result = await callback(tx);
state.expenseTypes = txState.expenseTypes;
state.departments = txState.departments;
state.standards = txState.standards;
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("hasPermission", (employee, permissionCode) => {
const permissionCodes = employee.permissionCodes ?? [];
return permissionCodes.includes("*") || permissionCodes.includes(permissionCode);
});
app.decorate("prisma", prisma);
app.decorate("config", {});
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(expenseStandardsRoutes, { prefix: "/api" });
return { app, state, prisma };
}
describe("expense standards routes", () => {
it("lists standards with filters and scope label", async () => {
const { app } = await buildApp({
standards: [
buildStandard(),
buildStandard({
id: "44444444-4444-4444-8444-444444444444",
scope_type: "department",
scope_key: DEPARTMENT_ID,
control_mode: "block",
}),
],
});
const response = await app.inject({
method: "GET",
url: `/api/expense-standards?expenseTypeId=${EXPENSE_TYPE_ID}&enabled=true`,
});
expect(response.statusCode).toBe(200);
const payload = response.json();
expect(payload.data.items).toHaveLength(2);
expect(payload.data.items[0]).toMatchObject({
expenseTypeId: EXPENSE_TYPE_ID,
expenseTypeName: "交通费",
scopeLabel: expect.any(String),
});
await app.close();
});
it("allows authenticated non-admin to read scoped enabled standards for item editor", async () => {
const { app } = await buildApp({
actor: {
id: ACTOR_ID,
permissionCodes: ["expense.submit"],
department_id: DEPARTMENT_ID,
},
});
const response = await app.inject({
method: "GET",
url: `/api/expense-standards?expenseTypeId=${EXPENSE_TYPE_ID}&enabled=true`,
});
expect(response.statusCode).toBe(200);
await app.close();
});
it("rejects broad list read for non-admin users", async () => {
const { app } = await buildApp({
actor: {
id: ACTOR_ID,
permissionCodes: ["expense.submit"],
department_id: DEPARTMENT_ID,
},
});
const response = await app.inject({
method: "GET",
url: "/api/expense-standards",
});
expect(response.statusCode).toBe(403);
await app.close();
});
it("creates a department scoped standard and writes audit log", async () => {
const { app, state } = await buildApp({ standards: [] });
const response = await app.inject({
method: "POST",
url: "/api/expense-standards",
payload: {
expenseTypeId: EXPENSE_TYPE_ID,
name: "出差餐补上限",
maxAmount: "180.00",
perUnit: "day",
controlMode: "block",
scopeType: "department",
scopeKey: DEPARTMENT_ID,
note: "财务要求",
},
});
expect(response.statusCode).toBe(200);
expect(state.standards).toHaveLength(1);
expect(state.standards[0]).toMatchObject({
scope_type: "department",
scope_key: DEPARTMENT_ID,
control_mode: "block",
per_unit: "day",
});
expect(state.auditLogs).toHaveLength(1);
expect(state.auditLogs[0]).toMatchObject({
action: "EXPENSE_STANDARD_CREATED",
target_type: "expense_standard",
});
await app.close();
});
it("rejects create when expense type does not exist", async () => {
const { app } = await buildApp({ standards: [] });
const response = await app.inject({
method: "POST",
url: "/api/expense-standards",
payload: {
expenseTypeId: "55555555-5555-4555-8555-555555555555",
name: "无效标准",
maxAmount: "50.00",
},
});
expect(response.statusCode).toBe(409);
expect(response.json().error.details).toEqual({ expenseTypeId: "not_found" });
await app.close();
});
it("rejects department scope without scopeKey", async () => {
const { app } = await buildApp({ standards: [] });
const response = await app.inject({
method: "POST",
url: "/api/expense-standards",
payload: {
expenseTypeId: EXPENSE_TYPE_ID,
name: "部门标准",
maxAmount: "88.00",
scopeType: "department",
},
});
expect(response.statusCode).toBe(400);
expect(response.json().error.details).toEqual({ scopeKey: "required_for_department_scope" });
await app.close();
});
it("rejects duplicate type and scope via unique conflict", async () => {
const { app } = await buildApp();
const response = await app.inject({
method: "POST",
url: "/api/expense-standards",
payload: {
expenseTypeId: EXPENSE_TYPE_ID,
name: "重复 global",
maxAmount: "88.00",
},
});
expect(response.statusCode).toBe(409);
expect(response.json().error.details).toEqual({ duplicate: "expense_type_scope_conflict" });
await app.close();
});
it("updates a standard and writes before/after audit snapshot", async () => {
const { app, state } = await buildApp();
const response = await app.inject({
method: "PATCH",
url: `/api/expense-standards/${STANDARD_ID}`,
payload: {
maxAmount: "150.00",
controlMode: "block",
note: "已升级为阻断",
},
});
expect(response.statusCode).toBe(200);
expect(state.standards[0]).toMatchObject({
max_amount: "150.00",
control_mode: "block",
note: "已升级为阻断",
});
expect(state.auditLogs.at(-1)).toMatchObject({
action: "EXPENSE_STANDARD_UPDATED",
extra: expect.objectContaining({
before: expect.any(Object),
after: expect.any(Object),
}),
});
await app.close();
});
it("soft deletes by setting enabled=false and writes delete audit", async () => {
const { app, state } = await buildApp();
const response = await app.inject({
method: "DELETE",
url: `/api/expense-standards/${STANDARD_ID}`,
});
expect(response.statusCode).toBe(200);
expect(state.standards[0].enabled).toBe(false);
expect(state.auditLogs.at(-1)).toMatchObject({
action: "EXPENSE_STANDARD_DELETED",
});
await app.close();
});
it("blocks write operations without system.settings permission", async () => {
const { app } = await buildApp({
actor: {
id: ACTOR_ID,
permissionCodes: ["expense.submit"],
},
});
const response = await app.inject({
method: "POST",
url: "/api/expense-standards",
payload: {
expenseTypeId: EXPENSE_TYPE_ID,
name: "受限写操作",
maxAmount: "30.00",
},
});
expect(response.statusCode).toBe(403);
await app.close();
});
});
@@ -0,0 +1,363 @@
const Fastify = require("fastify");
const { AppError } = require("../../../src/utils/AppError");
const budgetPool = require("../../../src/services/budgetPool");
const feeControl = require("../../../src/services/feeControl");
const notifications = require("../../../src/services/notifications");
const resolveApplicableBudgetPoolByContextMock = vi.fn();
const resolveEntityPrecheckMock = vi.fn();
const createRiskFlagsFromPrecheckMock = vi.fn();
const resolveRecipientsForAssigneeMock = vi.fn();
const createNotificationsWithRecordsMock = vi.fn(async () => []);
const originalResolveApplicableBudgetPoolByContext = budgetPool.resolveApplicableBudgetPoolByContext;
const originalResolveEntityPrecheck = feeControl.resolveEntityPrecheck;
const originalCreateRiskFlagsFromPrecheck = feeControl.createRiskFlagsFromPrecheck;
const originalResolveRecipientsForAssignee = notifications.resolveRecipientsForAssignee;
const originalCreateNotificationsWithRecords = notifications.createNotificationsWithRecords;
budgetPool.resolveApplicableBudgetPoolByContext = resolveApplicableBudgetPoolByContextMock;
feeControl.resolveEntityPrecheck = resolveEntityPrecheckMock;
feeControl.createRiskFlagsFromPrecheck = createRiskFlagsFromPrecheckMock;
notifications.resolveRecipientsForAssignee = resolveRecipientsForAssigneeMock;
notifications.createNotificationsWithRecords = createNotificationsWithRecordsMock;
delete require.cache[require.resolve("../../../src/routes/reimbursements")];
const reimbursementRoutes = require("../../../src/routes/reimbursements");
const REIMBURSEMENT_ID = "11111111-1111-4111-8111-111111111111";
const EMPLOYEE_ID = "22222222-2222-4222-8222-222222222222";
const DEPARTMENT_ID = "33333333-3333-4333-8333-333333333333";
function buildPrecheck(overrides = {}) {
return {
entityType: "reimbursement",
entityId: REIMBURSEMENT_ID,
decision: "pass",
matchedBudgetControlRule: null,
budgetHint: null,
standardChecks: [],
warnings: [],
blockingReasons: [],
riskFlags: [],
nextActions: ["可直接提交"],
...overrides,
};
}
async function buildApp(options = {}) {
const app = Fastify();
const actor = options.actor ?? {
id: EMPLOYEE_ID,
department_id: DEPARTMENT_ID,
roleCodes: [],
permissionCodes: ["expense.submit"],
};
const reimbursement = options.reimbursement ?? {
id: REIMBURSEMENT_ID,
owner_id: EMPLOYEE_ID,
submitter_id: EMPLOYEE_ID,
department_id: DEPARTMENT_ID,
reimbursement_date: new Date("2026-04-08T00:00:00.000Z"),
status: "draft",
form_number: "BX-20260408-001",
total_amount: "180.00",
};
const state = {
workflowInstanceCreatePayload: null,
};
const tx = {
$queryRaw: vi.fn(async () => [reimbursement]),
workflowInstance: {
findFirst: vi.fn(async () => null),
create: vi.fn(async ({ data, select }) => {
state.workflowInstanceCreatePayload = data;
return {
id: "44444444-4444-4444-8444-444444444444",
current_step_id: data.current_step_id,
...(select?.current_step_id ? {} : {}),
};
}),
},
invoicePool: {
count: vi.fn(async () => 1),
},
budgetLedgerEntry: {
create: vi.fn(async () => ({ id: "budget-ledger-1" })),
},
budgetPool: {
update: vi.fn(async () => ({ id: "budget-pool-1" })),
},
approvalRule: {
findFirst: vi.fn(async () => ({
id: "55555555-5555-4555-8555-555555555555",
level1_role: "finance",
level2_role: null,
level3_role: null,
priority: 1,
})),
},
workflowDefinition: {
findFirst: vi.fn(async () => ({
id: "66666666-6666-4666-8666-666666666666",
})),
},
reimbursementForm: {
update: vi.fn(async () => ({
id: reimbursement.id,
})),
},
workflowTask: {
create: vi.fn(async () => ({
id: "77777777-7777-4777-8777-777777777777",
assignee_id: null,
assignee_role: "finance",
})),
},
workflowHistory: {
create: vi.fn(async () => ({ id: "history-1" })),
},
auditLog: {
create: vi.fn(async () => ({ id: "audit-1" })),
},
};
const prisma = {
$transaction: vi.fn(async (callback) => callback(tx)),
reimbursementForm: {
findUnique: vi.fn(async () => ({
id: reimbursement.id,
form_number: reimbursement.form_number,
total_amount: reimbursement.total_amount,
owner: {
id: EMPLOYEE_ID,
name: "申请人",
},
})),
},
};
app.decorate("prisma", prisma);
app.decorate("config", {
appTimezone: "Asia/Shanghai",
budgetControlMode: "warn",
imNotifierEnabled: false,
emailEnabled: false,
});
app.decorate("hasPermission", (employee, permissionCode) => {
const permissionCodes = employee?.permissionCodes ?? [];
return permissionCodes.includes("*") || permissionCodes.includes(permissionCode);
});
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.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, prisma, tx, state };
}
describe("reimbursements routes expense standard integration", () => {
afterAll(() => {
budgetPool.resolveApplicableBudgetPoolByContext = originalResolveApplicableBudgetPoolByContext;
feeControl.resolveEntityPrecheck = originalResolveEntityPrecheck;
feeControl.createRiskFlagsFromPrecheck = originalCreateRiskFlagsFromPrecheck;
notifications.resolveRecipientsForAssignee = originalResolveRecipientsForAssignee;
notifications.createNotificationsWithRecords = originalCreateNotificationsWithRecords;
});
beforeEach(() => {
vi.clearAllMocks();
resolveApplicableBudgetPoolByContextMock.mockResolvedValue(null);
createRiskFlagsFromPrecheckMock.mockResolvedValue([]);
resolveRecipientsForAssigneeMock.mockResolvedValue([]);
});
it("returns standardChecks from reimbursement precheck", async () => {
resolveEntityPrecheckMock.mockResolvedValue(
buildPrecheck({
decision: "warn",
standardChecks: [
{
itemId: "item-1",
expenseTypeId: "88888888-8888-4888-8888-888888888888",
expenseTypeName: "交通费",
amount: "180.00",
standardId: "99999999-9999-4999-8999-999999999999",
standardName: "交通费上限",
maxAmount: "120.00",
perUnit: "item",
controlMode: "warn",
scopeType: "global",
scopeKey: null,
exceeded: true,
exceededAmount: "60.00",
},
],
}),
);
const { app } = await buildApp();
const response = await app.inject({
method: "POST",
url: `/api/reimbursements/${REIMBURSEMENT_ID}/precheck`,
});
expect(response.statusCode).toBe(200);
expect(response.json().data.standardChecks).toEqual([
expect.objectContaining({
itemId: "item-1",
controlMode: "warn",
exceededAmount: "60.00",
}),
]);
expect(resolveEntityPrecheckMock).toHaveBeenCalledWith(
expect.objectContaining({
entityType: "reimbursement",
entityId: REIMBURSEMENT_ID,
actorDepartmentId: DEPARTMENT_ID,
}),
);
await app.close();
});
it("blocks submit with 400 and standardViolations when block standard is exceeded", async () => {
resolveEntityPrecheckMock.mockResolvedValue(
buildPrecheck({
decision: "block",
blockingReasons: [
{
code: "expense_standard_blocked",
title: "费用标准超限",
detail: "交通费超出上限",
},
],
standardChecks: [
{
itemId: "item-block",
expenseTypeId: "88888888-8888-4888-8888-888888888888",
expenseTypeName: "交通费",
amount: "260.00",
standardId: "99999999-9999-4999-8999-999999999999",
standardName: "交通费阻断上限",
maxAmount: "120.00",
perUnit: "item",
controlMode: "block",
scopeType: "global",
scopeKey: null,
exceeded: true,
exceededAmount: "140.00",
},
],
}),
);
const { app, tx } = await buildApp();
const response = await app.inject({
method: "POST",
url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit`,
});
expect(response.statusCode).toBe(400);
expect(response.json().error).toEqual(
expect.objectContaining({
code: "VALID_001",
details: expect.objectContaining({
standardViolations: [
expect.objectContaining({
itemId: "item-block",
controlMode: "block",
}),
],
}),
}),
);
expect(tx.workflowInstance.create).not.toHaveBeenCalled();
await app.close();
});
it("allows warn submit and persists standardViolations into workflow context", async () => {
resolveEntityPrecheckMock.mockResolvedValue(
buildPrecheck({
decision: "warn",
warnings: [
{
code: "expense_standard_warning",
title: "费用标准预警",
detail: "交通费超出上限",
},
],
standardChecks: [
{
itemId: "item-warn",
expenseTypeId: "88888888-8888-4888-8888-888888888888",
expenseTypeName: "交通费",
amount: "180.00",
standardId: "99999999-9999-4999-8999-999999999999",
standardName: "交通费预警上限",
maxAmount: "120.00",
perUnit: "item",
controlMode: "warn",
scopeType: "department",
scopeKey: DEPARTMENT_ID,
exceeded: true,
exceededAmount: "60.00",
},
],
}),
);
const { app, state } = await buildApp();
const response = await app.inject({
method: "POST",
url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit`,
});
expect(response.statusCode).toBe(200);
expect(response.json().data).toEqual(
expect.objectContaining({
reimbursementId: REIMBURSEMENT_ID,
status: "reviewing",
precheckDecision: "warn",
standardWarnings: [
expect.objectContaining({
itemId: "item-warn",
controlMode: "warn",
}),
],
}),
);
expect(state.workflowInstanceCreatePayload?.context_data?.standardViolations).toEqual([
expect.objectContaining({
itemId: "item-warn",
exceededAmount: "60.00",
}),
]);
await app.close();
});
});
@@ -0,0 +1,134 @@
const Fastify = require("fastify");
const workflowRoutes = require("../../../src/routes/workflow");
const TASK_ID = "11111111-1111-4111-8111-111111111111";
const EMPLOYEE_ID = "22222222-2222-4222-8222-222222222222";
async function buildApp(options = {}) {
const app = Fastify();
const actor = options.actor ?? {
id: EMPLOYEE_ID,
roleCodes: [],
permissionCodes: [],
};
const prisma = {
delegationGrant: {
findMany: vi.fn(async () => []),
},
workflowTask: {
findFirst: vi.fn(async () => ({
id: TASK_ID,
instance_id: "33333333-3333-4333-8333-333333333333",
step_id: "approval_1_finance",
step_name: "财务审批",
assignee_id: EMPLOYEE_ID,
assignee_role: null,
status: "completed",
outcome: "approved",
comment: "已核验",
created_at: new Date("2026-04-08T08:00:00.000Z"),
completed_at: new Date("2026-04-08T09:00:00.000Z"),
workflow_instance: {
id: "33333333-3333-4333-8333-333333333333",
entity_type: "reimbursement",
entity_id: "44444444-4444-4444-8444-444444444444",
current_status: "pending",
current_step_id: "approval_1_finance",
context_data: {
standardViolations: [
{
itemId: "item-1",
expenseTypeName: "交通费",
amount: "180.00",
standardName: "交通费上限",
maxAmount: "120.00",
exceededAmount: "60.00",
controlMode: "warn",
},
],
},
started_at: new Date("2026-04-08T08:00:00.000Z"),
completed_at: null,
reimbursement_form: {
id: "44444444-4444-4444-8444-444444444444",
form_number: "BX-20260408-001",
status: "reviewing",
total_amount: "180.00",
reimbursement_date: new Date("2026-04-08T00:00:00.000Z"),
description: "测试报销",
owner: {
id: EMPLOYEE_ID,
employee_no: "E001",
name: "申请人",
},
submitter: {
id: EMPLOYEE_ID,
employee_no: "E001",
name: "申请人",
},
items: [],
},
leave_request: null,
purchase_request: null,
travel_request: null,
},
})),
findUnique: vi.fn(async () => ({
id: TASK_ID,
})),
},
workflowHistory: {
findMany: vi.fn(async () => []),
},
};
app.decorate("prisma", prisma);
app.decorate("config", {
imNotifierEnabled: false,
emailEnabled: false,
});
app.decorate("authenticate", async (request) => {
request.employee = actor;
});
app.decorate("requirePermission", () => async (request) => {
request.employee = actor;
});
app.decorate("assertEntityPermission", () => {});
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(workflowRoutes, { prefix: "/api" });
return { app, prisma };
}
describe("workflow task detail route", () => {
it("returns reimbursement workflow contextData.standardViolations", async () => {
const { app } = await buildApp();
const response = await app.inject({
method: "GET",
url: `/api/workflow/tasks/${TASK_ID}`,
});
expect(response.statusCode).toBe(200);
expect(response.json().data.workflowInstance.contextData.standardViolations).toEqual([
expect.objectContaining({
itemId: "item-1",
controlMode: "warn",
exceededAmount: "60.00",
}),
]);
await app.close();
});
});
@@ -0,0 +1,306 @@
const {
checkExpenseStandards,
mergeReimbursementPrecheck,
} = require("../../../src/services/feeControl");
const REIMBURSEMENT_ID = "11111111-1111-4111-8111-111111111111";
const EXPENSE_TYPE_ID = "22222222-2222-4222-8222-222222222222";
const OTHER_EXPENSE_TYPE_ID = "33333333-3333-4333-8333-333333333333";
const DEPARTMENT_ID = "44444444-4444-4444-8444-444444444444";
function buildTx(options = {}) {
return {
reimbursementForm: {
findUnique: vi.fn().mockResolvedValue(
options.reimbursement ?? {
id: REIMBURSEMENT_ID,
department_id: DEPARTMENT_ID,
items: [
{
id: "item-1",
expense_type_id: EXPENSE_TYPE_ID,
amount: "150.00",
expense_type: {
id: EXPENSE_TYPE_ID,
name: "交通费",
},
},
],
},
),
},
expenseStandard: {
findMany: vi.fn().mockResolvedValue(
options.standards ?? [
{
id: "standard-global",
expense_type_id: EXPENSE_TYPE_ID,
name: "交通费全局上限",
max_amount: "120.00",
per_unit: "item",
control_mode: "warn",
scope_type: "global",
scope_key: null,
created_at: new Date("2026-04-08T09:00:00.000Z"),
},
],
),
},
};
}
describe("checkExpenseStandards", () => {
it("prefers department scoped standard over global standard", async () => {
const tx = buildTx({
standards: [
{
id: "standard-global",
expense_type_id: EXPENSE_TYPE_ID,
name: "交通费全局上限",
max_amount: "120.00",
per_unit: "item",
control_mode: "warn",
scope_type: "global",
scope_key: null,
created_at: new Date("2026-04-08T09:00:00.000Z"),
},
{
id: "standard-department",
expense_type_id: EXPENSE_TYPE_ID,
name: "交通费部门上限",
max_amount: "100.00",
per_unit: "item",
control_mode: "block",
scope_type: "department",
scope_key: DEPARTMENT_ID,
created_at: new Date("2026-04-08T10:00:00.000Z"),
},
],
});
const result = await checkExpenseStandards(tx, { reimbursementId: REIMBURSEMENT_ID });
expect(result).toEqual([
expect.objectContaining({
standardId: "standard-department",
standardName: "交通费部门上限",
controlMode: "block",
exceeded: true,
exceededAmount: "50.00",
}),
]);
});
it("returns warn level check without blocking when amount only exceeds warn standard", async () => {
const tx = buildTx();
const result = await checkExpenseStandards(tx, { reimbursementId: REIMBURSEMENT_ID });
const merged = mergeReimbursementPrecheck(
{
entityType: "reimbursement",
entityId: REIMBURSEMENT_ID,
decision: "pass",
matchedBudgetControlRule: null,
budgetHint: null,
standardChecks: [],
warnings: [],
blockingReasons: [],
riskFlags: [],
nextActions: ["可直接提交"],
},
result,
);
expect(result[0]).toMatchObject({
controlMode: "warn",
exceeded: true,
});
expect(merged.decision).toBe("warn");
expect(merged.warnings).toHaveLength(1);
expect(merged.blockingReasons).toHaveLength(0);
});
it("turns decision into block when exceeded item hits block standard", async () => {
const tx = buildTx({
standards: [
{
id: "standard-block",
expense_type_id: EXPENSE_TYPE_ID,
name: "交通费阻断上限",
max_amount: "120.00",
per_unit: "item",
control_mode: "block",
scope_type: "global",
scope_key: null,
created_at: new Date("2026-04-08T09:00:00.000Z"),
},
],
});
const result = await checkExpenseStandards(tx, { reimbursementId: REIMBURSEMENT_ID });
const merged = mergeReimbursementPrecheck(
{
entityType: "reimbursement",
entityId: REIMBURSEMENT_ID,
decision: "pass",
matchedBudgetControlRule: null,
budgetHint: null,
standardChecks: [],
warnings: [],
blockingReasons: [],
riskFlags: [],
nextActions: ["可直接提交"],
},
result,
);
expect(result[0].controlMode).toBe("block");
expect(merged.decision).toBe("block");
expect(merged.blockingReasons).toHaveLength(1);
});
it("skips items that do not match any standard", async () => {
const tx = buildTx({
reimbursement: {
id: REIMBURSEMENT_ID,
department_id: DEPARTMENT_ID,
items: [
{
id: "item-2",
expense_type_id: OTHER_EXPENSE_TYPE_ID,
amount: "88.00",
expense_type: {
id: OTHER_EXPENSE_TYPE_ID,
name: "办公费",
},
},
],
},
standards: [
{
id: "standard-global",
expense_type_id: EXPENSE_TYPE_ID,
name: "交通费全局上限",
max_amount: "120.00",
per_unit: "item",
control_mode: "warn",
scope_type: "global",
scope_key: null,
created_at: new Date("2026-04-08T09:00:00.000Z"),
},
],
});
const result = await checkExpenseStandards(tx, { reimbursementId: REIMBURSEMENT_ID });
expect(result).toEqual([]);
});
it("keeps perUnit as display metadata without any day or trip aggregation", async () => {
const tx = buildTx({
reimbursement: {
id: REIMBURSEMENT_ID,
department_id: DEPARTMENT_ID,
items: [
{
id: "item-1",
expense_type_id: EXPENSE_TYPE_ID,
amount: "300.00",
expense_type: {
id: EXPENSE_TYPE_ID,
name: "交通费",
},
},
{
id: "item-2",
expense_type_id: EXPENSE_TYPE_ID,
amount: "300.00",
expense_type: {
id: EXPENSE_TYPE_ID,
name: "交通费",
},
},
],
},
standards: [
{
id: "standard-trip",
expense_type_id: EXPENSE_TYPE_ID,
name: "差旅交通上限",
max_amount: "250.00",
per_unit: "trip",
control_mode: "warn",
scope_type: "global",
scope_key: null,
created_at: new Date("2026-04-08T09:00:00.000Z"),
},
],
});
const result = await checkExpenseStandards(tx, { reimbursementId: REIMBURSEMENT_ID });
expect(result).toHaveLength(2);
expect(result[0]).toMatchObject({
perUnit: "trip",
exceededAmount: "50.00",
});
expect(result[1]).toMatchObject({
perUnit: "trip",
exceededAmount: "50.00",
});
});
it("preserves manual_review decision priority over warn and under block", () => {
const mergedWarn = mergeReimbursementPrecheck(
{
entityType: "reimbursement",
entityId: REIMBURSEMENT_ID,
decision: "manual_review",
matchedBudgetControlRule: null,
budgetHint: null,
standardChecks: [],
warnings: [],
blockingReasons: [],
riskFlags: [{ code: "budget_exceeded", level: "high", title: "预算超用", detail: "需要复核" }],
nextActions: ["允许提交,但会生成待复核风险项"],
},
[
{
itemId: "item-1",
expenseTypeId: EXPENSE_TYPE_ID,
expenseTypeName: "交通费",
amount: "150.00",
standardId: "standard-global",
standardName: "交通费全局上限",
maxAmount: "120.00",
perUnit: "item",
controlMode: "warn",
scopeType: "global",
scopeKey: null,
exceeded: true,
exceededAmount: "30.00",
},
],
);
const mergedBlock = mergeReimbursementPrecheck(mergedWarn, [
{
itemId: "item-2",
expenseTypeId: EXPENSE_TYPE_ID,
expenseTypeName: "交通费",
amount: "200.00",
standardId: "standard-block",
standardName: "交通费阻断上限",
maxAmount: "120.00",
perUnit: "item",
controlMode: "block",
scopeType: "global",
scopeKey: null,
exceeded: true,
exceededAmount: "80.00",
},
]);
expect(mergedWarn.decision).toBe("manual_review");
expect(mergedBlock.decision).toBe("block");
});
});