R56 A1-A6:费用标准管控
这个提交包含在:
@@ -0,0 +1,90 @@
|
|||||||
|
import { apiRequestData } from "./http";
|
||||||
|
|
||||||
|
export type ExpenseStandardPerUnit = "item" | "day" | "trip";
|
||||||
|
export type ExpenseStandardControlMode = "warn" | "block";
|
||||||
|
export type ExpenseStandardScopeType = "global" | "department";
|
||||||
|
|
||||||
|
export type ExpenseStandard = {
|
||||||
|
id: string;
|
||||||
|
expenseTypeId: string;
|
||||||
|
expenseTypeName: string | null;
|
||||||
|
name: string;
|
||||||
|
maxAmount: string;
|
||||||
|
perUnit: ExpenseStandardPerUnit;
|
||||||
|
controlMode: ExpenseStandardControlMode;
|
||||||
|
scopeType: ExpenseStandardScopeType;
|
||||||
|
scopeKey: string | null;
|
||||||
|
scopeLabel: string;
|
||||||
|
enabled: boolean;
|
||||||
|
note: string | null;
|
||||||
|
createdAt: string | null;
|
||||||
|
updatedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExpenseStandardListResponse = {
|
||||||
|
expenseTypeId: string | null;
|
||||||
|
scopeType: ExpenseStandardScopeType | null;
|
||||||
|
enabled: boolean | null;
|
||||||
|
items: ExpenseStandard[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExpenseStandardListQuery = {
|
||||||
|
expenseTypeId?: string;
|
||||||
|
scopeType?: ExpenseStandardScopeType;
|
||||||
|
enabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateExpenseStandardPayload = {
|
||||||
|
expenseTypeId: string;
|
||||||
|
name: string;
|
||||||
|
maxAmount: string;
|
||||||
|
perUnit?: ExpenseStandardPerUnit;
|
||||||
|
controlMode?: ExpenseStandardControlMode;
|
||||||
|
scopeType?: ExpenseStandardScopeType;
|
||||||
|
scopeKey?: string | null;
|
||||||
|
enabled?: boolean;
|
||||||
|
note?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateExpenseStandardPayload = Partial<CreateExpenseStandardPayload>;
|
||||||
|
|
||||||
|
function toQueryString(query: Record<string, string | number | boolean | undefined>) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(query)) {
|
||||||
|
if (value == null || value === "") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
params.set(key, String(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoded = params.toString();
|
||||||
|
return encoded ? `?${encoded}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchExpenseStandards(query: ExpenseStandardListQuery = {}) {
|
||||||
|
return apiRequestData<ExpenseStandardListResponse>(
|
||||||
|
`/api/expense-standards${toQueryString(query as Record<string, string | number | boolean | undefined>)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createExpenseStandard(payload: CreateExpenseStandardPayload) {
|
||||||
|
return apiRequestData<ExpenseStandard>("/api/expense-standards", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateExpenseStandard(standardId: string, payload: UpdateExpenseStandardPayload) {
|
||||||
|
return apiRequestData<ExpenseStandard>(`/api/expense-standards/${standardId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteExpenseStandard(standardId: string) {
|
||||||
|
return apiRequestData<ExpenseStandard>(`/api/expense-standards/${standardId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -19,6 +19,22 @@ export type RiskFlag = {
|
|||||||
|
|
||||||
export type PrecheckDecision = "pass" | "warn" | "manual_review" | "block";
|
export type PrecheckDecision = "pass" | "warn" | "manual_review" | "block";
|
||||||
|
|
||||||
|
export type ExpenseStandardCheck = {
|
||||||
|
itemId: string;
|
||||||
|
expenseTypeId: string;
|
||||||
|
expenseTypeName: string;
|
||||||
|
amount: string;
|
||||||
|
standardId: string;
|
||||||
|
standardName: string;
|
||||||
|
maxAmount: string;
|
||||||
|
perUnit: "item" | "day" | "trip";
|
||||||
|
controlMode: "warn" | "block";
|
||||||
|
scopeType: "global" | "department";
|
||||||
|
scopeKey: string | null;
|
||||||
|
exceeded: boolean;
|
||||||
|
exceededAmount: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type PrecheckResponse = {
|
export type PrecheckResponse = {
|
||||||
entityType: "reimbursement" | "purchase_request" | "travel_request";
|
entityType: "reimbursement" | "purchase_request" | "travel_request";
|
||||||
entityId: string;
|
entityId: string;
|
||||||
@@ -29,6 +45,7 @@ export type PrecheckResponse = {
|
|||||||
controlMode: "warn" | "block" | "manual_review";
|
controlMode: "warn" | "block" | "manual_review";
|
||||||
} | null;
|
} | null;
|
||||||
budgetHint: Record<string, unknown> | null;
|
budgetHint: Record<string, unknown> | null;
|
||||||
|
standardChecks: ExpenseStandardCheck[];
|
||||||
warnings: Array<{ code: string; title: string; detail: string }>;
|
warnings: Array<{ code: string; title: string; detail: string }>;
|
||||||
blockingReasons: Array<{ code: string; title: string; detail: string }>;
|
blockingReasons: Array<{ code: string; title: string; detail: string }>;
|
||||||
riskFlags: Array<{ code: string; level: "low" | "medium" | "high"; title: string; detail: string; willOpenOnSubmit: boolean }>;
|
riskFlags: Array<{ code: string; level: "low" | "medium" | "high"; title: string; detail: string; willOpenOnSubmit: boolean }>;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { apiRequestData } from "./http";
|
import { apiRequestData } from "./http";
|
||||||
import type { PrecheckResponse, RiskFlag } from "./feeControl";
|
import type { ExpenseStandardCheck, PrecheckResponse, RiskFlag } from "./feeControl";
|
||||||
|
|
||||||
export type ReimbursementStatus =
|
export type ReimbursementStatus =
|
||||||
| "draft"
|
| "draft"
|
||||||
@@ -245,6 +245,7 @@ export type ReimbursementSubmitResponse = {
|
|||||||
exceededBudget: boolean;
|
exceededBudget: boolean;
|
||||||
} | null;
|
} | null;
|
||||||
precheckDecision: "pass" | "warn" | "manual_review";
|
precheckDecision: "pass" | "warn" | "manual_review";
|
||||||
|
standardWarnings: ExpenseStandardCheck[];
|
||||||
openedRiskFlagCount: number;
|
openedRiskFlagCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { apiRequestData } from "./http";
|
import { apiRequestData } from "./http";
|
||||||
|
import type { ExpenseStandardCheck } from "./feeControl";
|
||||||
|
|
||||||
export type WorkflowTaskSummary = {
|
export type WorkflowTaskSummary = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -57,6 +58,9 @@ export type WorkflowTaskDetail = {
|
|||||||
entityType: string;
|
entityType: string;
|
||||||
currentStatus: string;
|
currentStatus: string;
|
||||||
currentStepId: string;
|
currentStepId: string;
|
||||||
|
contextData: {
|
||||||
|
standardViolations: ExpenseStandardCheck[];
|
||||||
|
};
|
||||||
startedAt: string | null;
|
startedAt: string | null;
|
||||||
completedAt: string | null;
|
completedAt: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
|
|||||||
@@ -0,0 +1,521 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { fetchDepartments } from "../api/departments";
|
||||||
|
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||||
|
import { fetchExpenseTypes } from "../api/expenseTypes";
|
||||||
|
import {
|
||||||
|
createExpenseStandard,
|
||||||
|
deleteExpenseStandard,
|
||||||
|
fetchExpenseStandards,
|
||||||
|
updateExpenseStandard,
|
||||||
|
type ExpenseStandard,
|
||||||
|
type ExpenseStandardControlMode,
|
||||||
|
type ExpenseStandardPerUnit,
|
||||||
|
type ExpenseStandardScopeType,
|
||||||
|
} from "../api/expenseStandards";
|
||||||
|
import { isApiError } from "../api/types";
|
||||||
|
import { PageHeader } from "../components/common/PageHeader";
|
||||||
|
import { TableSkeleton } from "../components/common/TableSkeleton";
|
||||||
|
import { ConfirmDialog } from "../components/common/ConfirmDialog";
|
||||||
|
import { Button } from "../components/ui/button";
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../components/ui/dialog";
|
||||||
|
import { Input } from "../components/ui/input";
|
||||||
|
import { Label } from "../components/ui/label";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||||
|
|
||||||
|
type FilterEnabled = "all" | "enabled" | "disabled";
|
||||||
|
type FormState = {
|
||||||
|
expenseTypeId: string;
|
||||||
|
name: string;
|
||||||
|
maxAmount: string;
|
||||||
|
perUnit: ExpenseStandardPerUnit;
|
||||||
|
controlMode: ExpenseStandardControlMode;
|
||||||
|
scopeType: ExpenseStandardScopeType;
|
||||||
|
scopeKey: string;
|
||||||
|
enabled: boolean;
|
||||||
|
note: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PER_UNIT_OPTIONS: Array<{ value: ExpenseStandardPerUnit; label: string }> = [
|
||||||
|
{ value: "item", label: "每项" },
|
||||||
|
{ value: "day", label: "每天" },
|
||||||
|
{ value: "trip", label: "每次" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const CONTROL_MODE_OPTIONS: Array<{ value: ExpenseStandardControlMode; label: string }> = [
|
||||||
|
{ value: "warn", label: "预警" },
|
||||||
|
{ value: "block", label: "阻断" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function emptyForm(expenseTypeId = ""): FormState {
|
||||||
|
return {
|
||||||
|
expenseTypeId,
|
||||||
|
name: "",
|
||||||
|
maxAmount: "",
|
||||||
|
perUnit: "item",
|
||||||
|
controlMode: "warn",
|
||||||
|
scopeType: "global",
|
||||||
|
scopeKey: "",
|
||||||
|
enabled: true,
|
||||||
|
note: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toForm(item: ExpenseStandard): FormState {
|
||||||
|
return {
|
||||||
|
expenseTypeId: item.expenseTypeId,
|
||||||
|
name: item.name,
|
||||||
|
maxAmount: item.maxAmount,
|
||||||
|
perUnit: item.perUnit,
|
||||||
|
controlMode: item.controlMode,
|
||||||
|
scopeType: item.scopeType,
|
||||||
|
scopeKey: item.scopeKey ?? "",
|
||||||
|
enabled: item.enabled,
|
||||||
|
note: item.note ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveErrorMessage(error: unknown) {
|
||||||
|
if (isApiError(error)) {
|
||||||
|
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
||||||
|
}
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
return "请求失败,请稍后重试";
|
||||||
|
}
|
||||||
|
|
||||||
|
function toneClassForControlMode(mode: ExpenseStandardControlMode) {
|
||||||
|
return mode === "block"
|
||||||
|
? "border-red-200 bg-red-50 text-red-700"
|
||||||
|
: "border-amber-200 bg-amber-50 text-amber-700";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExpenseStandardsPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [selectedExpenseTypeId, setSelectedExpenseTypeId] = useState("");
|
||||||
|
const [enabledFilter, setEnabledFilter] = useState<FilterEnabled>("all");
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [editingItem, setEditingItem] = useState<ExpenseStandard | null>(null);
|
||||||
|
const [form, setForm] = useState<FormState>(emptyForm());
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState<ExpenseStandard | null>(null);
|
||||||
|
|
||||||
|
const expenseTypesQuery = useQuery({
|
||||||
|
queryKey: ["expense-types", "expense-standards"],
|
||||||
|
queryFn: () => fetchExpenseTypes(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const departmentsQuery = useQuery({
|
||||||
|
queryKey: ["departments", "expense-standards", "active"],
|
||||||
|
queryFn: () => fetchDepartments({ includeArchived: false }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const standardsQuery = useQuery({
|
||||||
|
queryKey: ["expense-standards", selectedExpenseTypeId, enabledFilter],
|
||||||
|
queryFn: () =>
|
||||||
|
fetchExpenseStandards({
|
||||||
|
expenseTypeId: selectedExpenseTypeId || undefined,
|
||||||
|
enabled:
|
||||||
|
enabledFilter === "all"
|
||||||
|
? undefined
|
||||||
|
: enabledFilter === "enabled",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dialogOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editingItem) {
|
||||||
|
setForm(toForm(editingItem));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackExpenseTypeId = selectedExpenseTypeId || expenseTypesQuery.data?.items?.[0]?.id || "";
|
||||||
|
setForm(emptyForm(fallbackExpenseTypeId));
|
||||||
|
}, [dialogOpen, editingItem, expenseTypesQuery.data?.items, selectedExpenseTypeId]);
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const payload = {
|
||||||
|
expenseTypeId: form.expenseTypeId,
|
||||||
|
name: form.name.trim(),
|
||||||
|
maxAmount: form.maxAmount.trim(),
|
||||||
|
perUnit: form.perUnit,
|
||||||
|
controlMode: form.controlMode,
|
||||||
|
scopeType: form.scopeType,
|
||||||
|
scopeKey: form.scopeType === "department" ? form.scopeKey || null : null,
|
||||||
|
enabled: form.enabled,
|
||||||
|
note: form.note.trim() || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingItem) {
|
||||||
|
return updateExpenseStandard(editingItem.id, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
return createExpenseStandard(payload);
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["expense-standards"] });
|
||||||
|
setDialogOpen(false);
|
||||||
|
setEditingItem(null);
|
||||||
|
setError("");
|
||||||
|
toast.success(editingItem ? "标准已更新" : "标准已创建");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
const message = resolveErrorMessage(error);
|
||||||
|
setError(message);
|
||||||
|
toast.error(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (standardId: string) => deleteExpenseStandard(standardId),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["expense-standards"] });
|
||||||
|
setConfirmDelete(null);
|
||||||
|
setError("");
|
||||||
|
toast.success("标准已停用");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
const message = resolveErrorMessage(error);
|
||||||
|
setError(message);
|
||||||
|
toast.error(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = standardsQuery.data?.items ?? [];
|
||||||
|
const expenseTypeOptions = expenseTypesQuery.data?.items ?? [];
|
||||||
|
const departmentOptions = departmentsQuery.data?.items ?? [];
|
||||||
|
const canSubmit =
|
||||||
|
form.expenseTypeId.trim().length > 0 &&
|
||||||
|
form.name.trim().length > 0 &&
|
||||||
|
form.maxAmount.trim().length > 0 &&
|
||||||
|
(form.scopeType !== "department" || form.scopeKey.trim().length > 0);
|
||||||
|
|
||||||
|
const selectedExpenseTypeName = useMemo(() => {
|
||||||
|
return expenseTypeOptions.find((item) => item.id === selectedExpenseTypeId)?.name ?? "全部费用类型";
|
||||||
|
}, [expenseTypeOptions, selectedExpenseTypeId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
title="费用标准管理"
|
||||||
|
subtitle="维护费用类型的通用报销上限与 warn/block 管控模式。"
|
||||||
|
actions={
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingItem(null);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
新增标准
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="card-std space-y-4 p-6">
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="expense-standard-filter-type">费用类型</Label>
|
||||||
|
<select
|
||||||
|
id="expense-standard-filter-type"
|
||||||
|
className="select-std"
|
||||||
|
value={selectedExpenseTypeId}
|
||||||
|
onChange={(event) => setSelectedExpenseTypeId(event.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">全部费用类型</option>
|
||||||
|
{expenseTypeOptions.map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="expense-standard-filter-enabled">启用状态</Label>
|
||||||
|
<select
|
||||||
|
id="expense-standard-filter-enabled"
|
||||||
|
className="select-std"
|
||||||
|
value={enabledFilter}
|
||||||
|
onChange={(event) => setEnabledFilter(event.target.value as FilterEnabled)}
|
||||||
|
>
|
||||||
|
<option value="all">全部</option>
|
||||||
|
<option value="enabled">启用</option>
|
||||||
|
<option value="disabled">停用</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm text-zinc-500">
|
||||||
|
当前筛选:{selectedExpenseTypeName} / {enabledFilter === "all" ? "全部状态" : enabledFilter === "enabled" ? "仅启用" : "仅停用"}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{standardsQuery.isLoading ? (
|
||||||
|
<TableSkeleton rows={5} />
|
||||||
|
) : standardsQuery.isError ? (
|
||||||
|
<p className="text-sm text-red-600">费用标准加载失败,请刷新页面重试。</p>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border border-zinc-200">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>费用类型</TableHead>
|
||||||
|
<TableHead>标准名称</TableHead>
|
||||||
|
<TableHead>上限金额</TableHead>
|
||||||
|
<TableHead>计量单位</TableHead>
|
||||||
|
<TableHead>管控模式</TableHead>
|
||||||
|
<TableHead>适用范围</TableHead>
|
||||||
|
<TableHead>启用状态</TableHead>
|
||||||
|
<TableHead className="w-32">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.length ? (
|
||||||
|
items.map((item) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="font-medium text-zinc-900">{item.expenseTypeName ?? item.expenseTypeId}</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="font-medium text-zinc-900">{item.name}</div>
|
||||||
|
<div className="text-xs text-zinc-500">{item.note || "-"}</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>¥{item.maxAmount}</TableCell>
|
||||||
|
<TableCell>{PER_UNIT_OPTIONS.find((option) => option.value === item.perUnit)?.label ?? item.perUnit}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${toneClassForControlMode(item.controlMode)}`}>
|
||||||
|
{item.controlMode === "block" ? "阻断" : "预警"}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{item.scopeLabel}</TableCell>
|
||||||
|
<TableCell>{item.enabled ? "启用" : "停用"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingItem(item);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setConfirmDelete(item)}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} className="py-6 text-center text-sm text-zinc-500">
|
||||||
|
当前筛选下暂无费用标准。
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={dialogOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setDialogOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
setEditingItem(null);
|
||||||
|
setError("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingItem ? "编辑费用标准" : "新增费用标准"}</DialogTitle>
|
||||||
|
<DialogDescription>维护单条明细的金额上限,以及超标后是预警还是阻断提交。</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="expense-standard-type">费用类型</Label>
|
||||||
|
<select
|
||||||
|
id="expense-standard-type"
|
||||||
|
className="select-std"
|
||||||
|
value={form.expenseTypeId}
|
||||||
|
onChange={(event) => setForm((current) => ({ ...current, expenseTypeId: event.target.value }))}
|
||||||
|
disabled={Boolean(editingItem)}
|
||||||
|
>
|
||||||
|
<option value="">请选择费用类型</option>
|
||||||
|
{expenseTypeOptions.map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="expense-standard-name">标准名称</Label>
|
||||||
|
<Input
|
||||||
|
id="expense-standard-name"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))}
|
||||||
|
placeholder="例如:日常交通费上限"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="expense-standard-amount">上限金额</Label>
|
||||||
|
<Input
|
||||||
|
id="expense-standard-amount"
|
||||||
|
value={form.maxAmount}
|
||||||
|
onChange={(event) => setForm((current) => ({ ...current, maxAmount: event.target.value }))}
|
||||||
|
placeholder="例如 100.00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="expense-standard-per-unit">计量单位</Label>
|
||||||
|
<select
|
||||||
|
id="expense-standard-per-unit"
|
||||||
|
className="select-std"
|
||||||
|
value={form.perUnit}
|
||||||
|
onChange={(event) => setForm((current) => ({ ...current, perUnit: event.target.value as ExpenseStandardPerUnit }))}
|
||||||
|
>
|
||||||
|
{PER_UNIT_OPTIONS.map((item) => (
|
||||||
|
<option key={item.value} value={item.value}>
|
||||||
|
{item.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="expense-standard-control-mode">管控模式</Label>
|
||||||
|
<select
|
||||||
|
id="expense-standard-control-mode"
|
||||||
|
className="select-std"
|
||||||
|
value={form.controlMode}
|
||||||
|
onChange={(event) => setForm((current) => ({ ...current, controlMode: event.target.value as ExpenseStandardControlMode }))}
|
||||||
|
>
|
||||||
|
{CONTROL_MODE_OPTIONS.map((item) => (
|
||||||
|
<option key={item.value} value={item.value}>
|
||||||
|
{item.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="expense-standard-scope-type">适用范围</Label>
|
||||||
|
<select
|
||||||
|
id="expense-standard-scope-type"
|
||||||
|
className="select-std"
|
||||||
|
value={form.scopeType}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
scopeType: event.target.value as ExpenseStandardScopeType,
|
||||||
|
scopeKey: event.target.value === "department" ? current.scopeKey : "",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="global">全局</option>
|
||||||
|
<option value="department">指定部门</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{form.scopeType === "department" ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="expense-standard-department">部门</Label>
|
||||||
|
<select
|
||||||
|
id="expense-standard-department"
|
||||||
|
className="select-std"
|
||||||
|
value={form.scopeKey}
|
||||||
|
onChange={(event) => setForm((current) => ({ ...current, scopeKey: event.target.value }))}
|
||||||
|
>
|
||||||
|
<option value="">请选择部门</option>
|
||||||
|
{departmentOptions.map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="space-y-1 md:col-span-2">
|
||||||
|
<Label htmlFor="expense-standard-note">备注</Label>
|
||||||
|
<Input
|
||||||
|
id="expense-standard-note"
|
||||||
|
value={form.note}
|
||||||
|
onChange={(event) => setForm((current) => ({ ...current, note: event.target.value }))}
|
||||||
|
placeholder="可选填写规则说明"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1 md:col-span-2">
|
||||||
|
<Label htmlFor="expense-standard-enabled">启用状态</Label>
|
||||||
|
<select
|
||||||
|
id="expense-standard-enabled"
|
||||||
|
className="select-std max-w-xs"
|
||||||
|
value={form.enabled ? "true" : "false"}
|
||||||
|
onChange={(event) => setForm((current) => ({ ...current, enabled: event.target.value === "true" }))}
|
||||||
|
>
|
||||||
|
<option value="true">启用</option>
|
||||||
|
<option value="false">停用</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setDialogOpen(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button type="button" disabled={!canSubmit || saveMutation.isPending} onClick={() => saveMutation.mutate()}>
|
||||||
|
{saveMutation.isPending ? "保存中..." : editingItem ? "保存修改" : "创建标准"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={Boolean(confirmDelete)}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setConfirmDelete(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title="确认删除该费用标准?"
|
||||||
|
description={confirmDelete ? `删除后会将“${confirmDelete.name}”停用,不会物理移除历史记录。` : undefined}
|
||||||
|
confirmLabel="删除"
|
||||||
|
variant="destructive"
|
||||||
|
onConfirm={async () => {
|
||||||
|
if (confirmDelete) {
|
||||||
|
await deleteMutation.mutateAsync(confirmDelete.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ExpenseStandardsPage;
|
||||||
@@ -4,6 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { isApiError } from "../api/types";
|
import { isApiError } from "../api/types";
|
||||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||||
import { fetchExpenseTypes } from "../api/expenseTypes";
|
import { fetchExpenseTypes } from "../api/expenseTypes";
|
||||||
|
import type { ExpenseStandardCheck, PrecheckResponse } from "../api/feeControl";
|
||||||
import { exportReimbursementJournal, previewReimbursementJournal } from "../api/integrations";
|
import { exportReimbursementJournal, previewReimbursementJournal } from "../api/integrations";
|
||||||
import { fetchProjects } from "../api/projects";
|
import { fetchProjects } from "../api/projects";
|
||||||
import {
|
import {
|
||||||
@@ -92,6 +93,7 @@ export function ReimbursementDetailPage() {
|
|||||||
const [itemError, setItemError] = useState("");
|
const [itemError, setItemError] = useState("");
|
||||||
const [confirmDeleteItemId, setConfirmDeleteItemId] = useState("");
|
const [confirmDeleteItemId, setConfirmDeleteItemId] = useState("");
|
||||||
const [isErpPreviewOpen, setIsErpPreviewOpen] = useState(false);
|
const [isErpPreviewOpen, setIsErpPreviewOpen] = useState(false);
|
||||||
|
const [submitConfirmPrecheck, setSubmitConfirmPrecheck] = useState<PrecheckResponse | null>(null);
|
||||||
const [editorInitialValues, setEditorInitialValues] = useState<ItemEditorInitialValues>({
|
const [editorInitialValues, setEditorInitialValues] = useState<ItemEditorInitialValues>({
|
||||||
expenseTypeId: "",
|
expenseTypeId: "",
|
||||||
expenseDate: "",
|
expenseDate: "",
|
||||||
@@ -116,6 +118,13 @@ export function ReimbursementDetailPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const canManageIntegration = hasPermission(permissions, "integration.manage");
|
const canManageIntegration = hasPermission(permissions, "integration.manage");
|
||||||
|
const canSubmitExpense = hasPermission(permissions, "expense.submit");
|
||||||
|
|
||||||
|
const reimbursementPrecheckQuery = useQuery({
|
||||||
|
enabled: Boolean(reimbursementId) && canSubmitExpense && ["draft", "rejected"].includes(detailQuery.data?.status ?? ""),
|
||||||
|
queryKey: ["reimbursements", reimbursementId, "precheck"],
|
||||||
|
queryFn: () => precheckReimbursement(reimbursementId),
|
||||||
|
});
|
||||||
|
|
||||||
const erpPreviewQuery = useQuery({
|
const erpPreviewQuery = useQuery({
|
||||||
enabled: Boolean(reimbursementId) && canManageIntegration && detailQuery.data?.status === "paid",
|
enabled: Boolean(reimbursementId) && canManageIntegration && detailQuery.data?.status === "paid",
|
||||||
@@ -147,8 +156,12 @@ export function ReimbursementDetailPage() {
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: ["reimbursements"] }),
|
queryClient.invalidateQueries({ queryKey: ["reimbursements"] }),
|
||||||
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
|
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId, "precheck"] }),
|
||||||
]);
|
]);
|
||||||
setActionError("");
|
setActionError("");
|
||||||
|
if (params.action === "submit") {
|
||||||
|
setSubmitConfirmPrecheck(null);
|
||||||
|
}
|
||||||
const actionMessages: Record<string, string> = { submit: "已提交", withdraw: "已撤回", pay: "付款成功" };
|
const actionMessages: Record<string, string> = { submit: "已提交", withdraw: "已撤回", pay: "付款成功" };
|
||||||
toast.success(actionMessages[params.action] ?? "操作成功");
|
toast.success(actionMessages[params.action] ?? "操作成功");
|
||||||
},
|
},
|
||||||
@@ -197,6 +210,7 @@ export function ReimbursementDetailPage() {
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: ["reimbursements"] }),
|
queryClient.invalidateQueries({ queryKey: ["reimbursements"] }),
|
||||||
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
|
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId, "precheck"] }),
|
||||||
]);
|
]);
|
||||||
setItemEditor(null);
|
setItemEditor(null);
|
||||||
setItemError("");
|
setItemError("");
|
||||||
@@ -233,6 +247,7 @@ export function ReimbursementDetailPage() {
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: ["reimbursements"] }),
|
queryClient.invalidateQueries({ queryKey: ["reimbursements"] }),
|
||||||
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
|
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId, "precheck"] }),
|
||||||
]);
|
]);
|
||||||
setItemError("");
|
setItemError("");
|
||||||
toast.success("明细已删除");
|
toast.success("明细已删除");
|
||||||
@@ -349,6 +364,9 @@ export function ReimbursementDetailPage() {
|
|||||||
const budgetHint = detail.budgetHint;
|
const budgetHint = detail.budgetHint;
|
||||||
const canPay = hasPermission(permissions, "expense.finance.pay");
|
const canPay = hasPermission(permissions, "expense.finance.pay");
|
||||||
const canEditItems = ["draft", "rejected"].includes(detail.status);
|
const canEditItems = ["draft", "rejected"].includes(detail.status);
|
||||||
|
const precheckStandardChecks = reimbursementPrecheckQuery.data?.standardChecks ?? [];
|
||||||
|
const blockStandardChecks = precheckStandardChecks.filter((item) => item.exceeded && item.controlMode === "block");
|
||||||
|
const warnStandardChecks = precheckStandardChecks.filter((item) => item.exceeded && item.controlMode === "warn");
|
||||||
|
|
||||||
const expenseTypes = expenseTypesQuery.data?.items ?? [];
|
const expenseTypes = expenseTypesQuery.data?.items ?? [];
|
||||||
|
|
||||||
@@ -406,7 +424,8 @@ export function ReimbursementDetailPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((precheck.decision === "warn" || precheck.decision === "manual_review") && !window.confirm(message)) {
|
if (precheck.decision === "warn" || precheck.decision === "manual_review") {
|
||||||
|
setSubmitConfirmPrecheck(precheck);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -546,6 +565,10 @@ export function ReimbursementDetailPage() {
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{reimbursementPrecheckQuery.isLoading && canEditItems ? (
|
||||||
|
<p className="text-sm text-zinc-500">正在刷新费用标准校验结果...</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="rounded-lg border border-zinc-200">
|
<div className="rounded-lg border border-zinc-200">
|
||||||
<Table data-testid="reimbursement-items-table">
|
<Table data-testid="reimbursement-items-table">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@@ -560,7 +583,17 @@ export function ReimbursementDetailPage() {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{detail.items.length ? (
|
{detail.items.length ? (
|
||||||
detail.items.map((item) => (
|
detail.items.map((item) => (
|
||||||
<TableRow data-testid="reimbursement-item-row" key={item.id}>
|
<TableRow
|
||||||
|
data-testid="reimbursement-item-row"
|
||||||
|
key={item.id}
|
||||||
|
className={
|
||||||
|
blockStandardChecks.some((check) => check.itemId === item.id)
|
||||||
|
? "bg-red-50/70"
|
||||||
|
: warnStandardChecks.some((check) => check.itemId === item.id)
|
||||||
|
? "bg-amber-50/70"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="font-medium text-zinc-900">
|
<div className="font-medium text-zinc-900">
|
||||||
{item.expenseType?.name ?? item.expenseTypeId}
|
{item.expenseType?.name ?? item.expenseTypeId}
|
||||||
@@ -568,8 +601,33 @@ export function ReimbursementDetailPage() {
|
|||||||
<div className="text-xs text-zinc-500">{item.expenseType?.code ?? "-"}</div>
|
<div className="text-xs text-zinc-500">{item.expenseType?.code ?? "-"}</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{item.expenseDate ?? "-"}</TableCell>
|
<TableCell>{item.expenseDate ?? "-"}</TableCell>
|
||||||
<TableCell>{item.amount}</TableCell>
|
<TableCell>
|
||||||
<TableCell>{item.description ?? "-"}</TableCell>
|
<div>{item.amount}</div>
|
||||||
|
{precheckStandardChecks
|
||||||
|
.filter((check) => check.itemId === item.id)
|
||||||
|
.map((check) => (
|
||||||
|
<div
|
||||||
|
key={check.standardId}
|
||||||
|
className={`mt-1 text-xs ${check.exceeded ? (check.controlMode === "block" ? "text-red-600" : "text-amber-600") : "text-zinc-500"}`}
|
||||||
|
>
|
||||||
|
标准 ¥{check.maxAmount}/{check.perUnit}
|
||||||
|
{check.exceeded ? `,超出 ¥${check.exceededAmount}` : ""}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div>{item.description ?? "-"}</div>
|
||||||
|
{precheckStandardChecks
|
||||||
|
.filter((check) => check.itemId === item.id && check.exceeded)
|
||||||
|
.map((check) => (
|
||||||
|
<div
|
||||||
|
key={`${check.standardId}-warning`}
|
||||||
|
className={`mt-1 text-xs ${check.controlMode === "block" ? "text-red-600" : "text-amber-600"}`}
|
||||||
|
>
|
||||||
|
{check.standardName}:当前金额超出费用标准。
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{canEditItems ? (
|
{canEditItems ? (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
@@ -614,6 +672,7 @@ export function ReimbursementDetailPage() {
|
|||||||
expenseTypes={expenseTypes}
|
expenseTypes={expenseTypes}
|
||||||
expenseTypesLoading={expenseTypesQuery.isLoading}
|
expenseTypesLoading={expenseTypesQuery.isLoading}
|
||||||
expenseTypesError={expenseTypesQuery.isError}
|
expenseTypesError={expenseTypesQuery.isError}
|
||||||
|
departmentId={detail.departmentId}
|
||||||
initialValues={editorInitialValues}
|
initialValues={editorInitialValues}
|
||||||
onSave={handleItemSave}
|
onSave={handleItemSave}
|
||||||
onCancel={handleItemCancel}
|
onCancel={handleItemCancel}
|
||||||
@@ -785,6 +844,8 @@ export function ReimbursementDetailPage() {
|
|||||||
status={detail.status}
|
status={detail.status}
|
||||||
canPay={canPay}
|
canPay={canPay}
|
||||||
actionError={actionError}
|
actionError={actionError}
|
||||||
|
submitDisabled={blockStandardChecks.length > 0}
|
||||||
|
submitHint={blockStandardChecks.length > 0 ? "请修改超标明细后提交" : ""}
|
||||||
isPending={mutation.isPending}
|
isPending={mutation.isPending}
|
||||||
onSubmit={() => void handleSubmitAction()}
|
onSubmit={() => void handleSubmitAction()}
|
||||||
onWithdraw={(comment) => mutation.mutate({ action: "withdraw", comment })}
|
onWithdraw={(comment) => mutation.mutate({ action: "withdraw", comment })}
|
||||||
@@ -828,6 +889,60 @@ export function ReimbursementDetailPage() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={Boolean(submitConfirmPrecheck)}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setSubmitConfirmPrecheck(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="max-h-[85vh] max-w-2xl overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>确认提交报销单?</DialogTitle>
|
||||||
|
<DialogDescription>以下校验项不会阻止提交,但会作为预警或复核信息进入审批流程。</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{submitConfirmPrecheck ? (
|
||||||
|
<div className="space-y-4 text-sm">
|
||||||
|
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-4 whitespace-pre-wrap">
|
||||||
|
{buildPrecheckDialogMessage(submitConfirmPrecheck)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{submitConfirmPrecheck.standardChecks.some((item) => item.exceeded && item.controlMode === "warn") ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="font-medium text-zinc-900">超出费用标准的明细</h4>
|
||||||
|
{submitConfirmPrecheck.standardChecks
|
||||||
|
.filter((item) => item.exceeded && item.controlMode === "warn")
|
||||||
|
.map((item: ExpenseStandardCheck) => (
|
||||||
|
<div key={`${item.itemId}-${item.standardId}`} className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-amber-700">
|
||||||
|
<div className="font-medium">{item.expenseTypeName}</div>
|
||||||
|
<div>
|
||||||
|
当前 ¥{item.amount} / 标准 ¥{item.maxAmount}/{item.perUnit} / 超出 ¥{item.exceededAmount}
|
||||||
|
</div>
|
||||||
|
<div>{item.standardName}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setSubmitConfirmPrecheck(null)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
onClick={() => mutation.mutate({ action: "submit" })}
|
||||||
|
>
|
||||||
|
{mutation.isPending ? "提交中..." : "确认提交"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={Boolean(confirmDeleteItemId)}
|
open={Boolean(confirmDeleteItemId)}
|
||||||
onOpenChange={(open) => { if (!open) setConfirmDeleteItemId(""); }}
|
onOpenChange={(open) => { if (!open) setConfirmDeleteItemId(""); }}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const ITEMS = [
|
|||||||
{ to: "/system/workspace", title: "工作区设置", description: "调整小团队模式的导航与模块显隐。" },
|
{ to: "/system/workspace", title: "工作区设置", description: "调整小团队模式的导航与模块显隐。" },
|
||||||
{ to: "/people", title: "人员管理", description: "处理入职、转正、调岗、离职与档案。" },
|
{ to: "/people", title: "人员管理", description: "处理入职、转正、调岗、离职与档案。" },
|
||||||
{ to: "/system/approval-rules", title: "审批规则", description: "维护仍在使用的简单审批链与规则。" },
|
{ to: "/system/approval-rules", title: "审批规则", description: "维护仍在使用的简单审批链与规则。" },
|
||||||
|
{ to: "/system/expense-standards", title: "费用标准", description: "维护报销费用类型上限与 warn/block 管控模式。" },
|
||||||
{ to: "/system/master-data", title: "主数据", description: "维护费用、法体、成本中心等主数据。" },
|
{ to: "/system/master-data", title: "主数据", description: "维护费用、法体、成本中心等主数据。" },
|
||||||
{ to: "/system/notification-channels", title: "通知渠道", description: "继续保留 IM/邮件等补充通知渠道。" },
|
{ to: "/system/notification-channels", title: "通知渠道", description: "继续保留 IM/邮件等补充通知渠道。" },
|
||||||
{ to: "/system/integrations/erp", title: "外部系统桥接", description: "查看财务与外部系统桥接能力及出入口。" },
|
{ to: "/system/integrations/erp", title: "外部系统桥接", description: "查看财务与外部系统桥接能力及出入口。" },
|
||||||
|
|||||||
@@ -289,6 +289,31 @@ export function WorkflowTaskDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</SectionPanel>
|
</SectionPanel>
|
||||||
|
|
||||||
|
{detail.workflowInstance?.entityType === "reimbursement" &&
|
||||||
|
detail.workflowInstance.contextData.standardViolations.length ? (
|
||||||
|
<SectionPanel title="费用标准提示" description="以下明细在提交时已被标记为超出费用标准,供审批判断时参考。">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{detail.workflowInstance.contextData.standardViolations.map((item) => (
|
||||||
|
<div
|
||||||
|
key={`${item.itemId}-${item.standardId}`}
|
||||||
|
className={
|
||||||
|
item.controlMode === "block"
|
||||||
|
? "rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700"
|
||||||
|
: "rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-700"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="font-medium text-zinc-900">{item.expenseTypeName}</div>
|
||||||
|
<div>
|
||||||
|
当前 ¥{item.amount} / 标准 ¥{item.maxAmount}/{item.perUnit}
|
||||||
|
</div>
|
||||||
|
<div>超出 ¥{item.exceededAmount} · {item.standardName}</div>
|
||||||
|
<div>管控模式:{item.controlMode === "block" ? "阻断" : "预警"}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SectionPanel>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<SectionPanel title="审批动作" description="把通过、驳回、退回和转审统一收口在同一块操作区。">
|
<SectionPanel title="审批动作" description="把通过、驳回、退回和转审统一收口在同一块操作区。">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{actionError ? (
|
{actionError ? (
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export interface ReimbursementActionsProps {
|
|||||||
status: ReimbursementStatus;
|
status: ReimbursementStatus;
|
||||||
canPay: boolean;
|
canPay: boolean;
|
||||||
actionError: string;
|
actionError: string;
|
||||||
|
submitDisabled?: boolean;
|
||||||
|
submitHint?: string;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
onSubmit: () => void;
|
onSubmit: () => void;
|
||||||
onWithdraw: (comment: string) => void;
|
onWithdraw: (comment: string) => void;
|
||||||
@@ -26,6 +28,8 @@ export function ReimbursementActions({
|
|||||||
status,
|
status,
|
||||||
canPay,
|
canPay,
|
||||||
actionError,
|
actionError,
|
||||||
|
submitDisabled = false,
|
||||||
|
submitHint = "",
|
||||||
isPending,
|
isPending,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onWithdraw,
|
onWithdraw,
|
||||||
@@ -52,13 +56,14 @@ export function ReimbursementActions({
|
|||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button
|
<Button
|
||||||
data-testid="reimbursement-submit"
|
data-testid="reimbursement-submit"
|
||||||
disabled={isPending || status !== "draft"}
|
disabled={isPending || !["draft", "rejected"].includes(status) || submitDisabled}
|
||||||
onClick={onSubmit}
|
onClick={onSubmit}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
提交报销
|
提交报销
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{submitHint ? <p className="text-sm text-zinc-500">{submitHint}</p> : null}
|
||||||
|
|
||||||
<div className="space-y-2 rounded-lg border border-zinc-200 p-4">
|
<div className="space-y-2 rounded-lg border border-zinc-200 p-4">
|
||||||
<p className="text-sm font-medium text-zinc-800">撤回报销</p>
|
<p className="text-sm font-medium text-zinc-800">撤回报销</p>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import type { ExpenseType } from "../../api/expenseTypes";
|
import type { ExpenseType } from "../../api/expenseTypes";
|
||||||
|
import { fetchExpenseStandards } from "../../api/expenseStandards";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import { Input } from "../../components/ui/input";
|
import { Input } from "../../components/ui/input";
|
||||||
import { Textarea } from "../../components/ui/textarea";
|
import { Textarea } from "../../components/ui/textarea";
|
||||||
@@ -25,6 +27,7 @@ export interface ReimbursementItemEditorProps {
|
|||||||
expenseTypes: ExpenseType[];
|
expenseTypes: ExpenseType[];
|
||||||
expenseTypesLoading: boolean;
|
expenseTypesLoading: boolean;
|
||||||
expenseTypesError: boolean;
|
expenseTypesError: boolean;
|
||||||
|
departmentId: string;
|
||||||
initialValues: ItemEditorInitialValues;
|
initialValues: ItemEditorInitialValues;
|
||||||
onSave: (data: ItemEditorSaveData) => void;
|
onSave: (data: ItemEditorSaveData) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
@@ -36,6 +39,7 @@ export function ReimbursementItemEditor({
|
|||||||
expenseTypes,
|
expenseTypes,
|
||||||
expenseTypesLoading,
|
expenseTypesLoading,
|
||||||
expenseTypesError,
|
expenseTypesError,
|
||||||
|
departmentId,
|
||||||
initialValues,
|
initialValues,
|
||||||
onSave,
|
onSave,
|
||||||
onCancel,
|
onCancel,
|
||||||
@@ -54,6 +58,30 @@ export function ReimbursementItemEditor({
|
|||||||
setDescription(initialValues.description);
|
setDescription(initialValues.description);
|
||||||
}, [initialValues.expenseTypeId, initialValues.expenseDate, initialValues.amount, initialValues.description]);
|
}, [initialValues.expenseTypeId, initialValues.expenseDate, initialValues.amount, initialValues.description]);
|
||||||
|
|
||||||
|
const standardsQuery = useQuery({
|
||||||
|
enabled: Boolean(expenseTypeId),
|
||||||
|
queryKey: ["expense-standards", "editor", expenseTypeId],
|
||||||
|
queryFn: () =>
|
||||||
|
fetchExpenseStandards({
|
||||||
|
expenseTypeId,
|
||||||
|
enabled: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeStandard = useMemo(() => {
|
||||||
|
const items = standardsQuery.data?.items ?? [];
|
||||||
|
return (
|
||||||
|
items.find((item) => item.scopeType === "department" && item.scopeKey === departmentId) ??
|
||||||
|
items.find((item) => item.scopeType === "global") ??
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}, [departmentId, standardsQuery.data?.items]);
|
||||||
|
|
||||||
|
const numericAmount = Number(amount || 0);
|
||||||
|
const numericMaxAmount = Number(activeStandard?.maxAmount ?? 0);
|
||||||
|
const isExceeded = Boolean(activeStandard) && Number.isFinite(numericAmount) && numericAmount > numericMaxAmount;
|
||||||
|
const exceededAmount = isExceeded ? (numericAmount - numericMaxAmount).toFixed(2) : "0.00";
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
onSave({ expenseTypeId, expenseDate, amount, description });
|
onSave({ expenseTypeId, expenseDate, amount, description });
|
||||||
};
|
};
|
||||||
@@ -120,6 +148,19 @@ export function ReimbursementItemEditor({
|
|||||||
placeholder="例如 123.45"
|
placeholder="例如 123.45"
|
||||||
value={amount}
|
value={amount}
|
||||||
/>
|
/>
|
||||||
|
{standardsQuery.isLoading ? (
|
||||||
|
<p className="text-xs text-zinc-500">正在加载费用标准...</p>
|
||||||
|
) : null}
|
||||||
|
{activeStandard ? (
|
||||||
|
<p className="text-xs text-zinc-500">
|
||||||
|
该费用类型上限 ¥{activeStandard.maxAmount}/{activeStandard.perUnit}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{isExceeded ? (
|
||||||
|
<p className={`text-xs ${activeStandard?.controlMode === "block" ? "text-red-600" : "text-amber-600"}`}>
|
||||||
|
超出费用标准 ¥{activeStandard?.maxAmount},超出 ¥{exceededAmount}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1 md:col-span-4">
|
<div className="space-y-1 md:col-span-4">
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
DelegationsPage,
|
DelegationsPage,
|
||||||
DepartmentsPage,
|
DepartmentsPage,
|
||||||
ErpIntegrationPage,
|
ErpIntegrationPage,
|
||||||
|
ExpenseStandardsPage,
|
||||||
FeatureFlagsPage,
|
FeatureFlagsPage,
|
||||||
InventoryChecksPage,
|
InventoryChecksPage,
|
||||||
InvoicePoolPage,
|
InvoicePoolPage,
|
||||||
@@ -441,6 +442,15 @@ export function AppRouter() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="system/expense-standards"
|
||||||
|
element={
|
||||||
|
<RequirePermission anyOf={["system.settings"]}>
|
||||||
|
{withRouteFallback(<ExpenseStandardsPage />)}
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="system/feature-flags"
|
path="system/feature-flags"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { ExpenseStandardsPage as default } from "../../pages/ExpenseStandardsPage";
|
||||||
@@ -8,6 +8,7 @@ export const CutoverConsolePage = lazy(() => import("./CutoverConsolePage"));
|
|||||||
export const DelegationsPage = lazy(() => import("./DelegationsPage"));
|
export const DelegationsPage = lazy(() => import("./DelegationsPage"));
|
||||||
export const DepartmentsPage = lazy(() => import("./DepartmentsPage"));
|
export const DepartmentsPage = lazy(() => import("./DepartmentsPage"));
|
||||||
export const ErpIntegrationPage = lazy(() => import("./ErpIntegrationPage"));
|
export const ErpIntegrationPage = lazy(() => import("./ErpIntegrationPage"));
|
||||||
|
export const ExpenseStandardsPage = lazy(() => import("./ExpenseStandardsPage"));
|
||||||
export const FeatureFlagsPage = lazy(() => import("./FeatureFlagsPage"));
|
export const FeatureFlagsPage = lazy(() => import("./FeatureFlagsPage"));
|
||||||
export const InventoryChecksPage = lazy(() => import("./InventoryChecksPage"));
|
export const InventoryChecksPage = lazy(() => import("./InventoryChecksPage"));
|
||||||
export const InvoicePoolPage = lazy(() => import("./InvoicePoolPage"));
|
export const InvoicePoolPage = lazy(() => import("./InvoicePoolPage"));
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
|
||||||
|
"A1","R56 执行合同建档与范围冻结","创建并校验 R56 费用标准管控执行合同 CSV,冻结范围仅包含 ExpenseStandard 模型与 CRUD、报销提交与 precheck 标准校验、费用标准管理页、报销与审批侧超标提示;明确本轮允许 1 个 Prisma 迁移,不改报销状态机与 approve/reject 主流程。","issues/2026-04-08_r56-expense-standards.csv 列完整、Dependencies 为 JSON 数组字符串、Test_Method 可复现;范围仅覆盖 R56 A1-A6,不混入城市/职级维度、工作流主流程改造、历史标准版本回放或导出增强。","命令:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-04-08_r56-expense-standards.csv。检查:1) 对照 AGENTS.md 与 issues/README.md 确认 12 列完整;2) A2-A6 依赖链符合 backend/frontend→reviewer→qa 收口;3) 方案明确 global/department 严格唯一、DELETE 仅软删除、费用标准校验不受 budget_precheck_v1 开关影响。","manual","DONE","DONE","DONE","issues/2026-04-08_r56-expense-standards.csv","[]","回滚路径:删除本次 CSV 不影响业务。2026-04-08:Issue CSV 已创建并通过 validate_issues_csv.py 校验,范围与依赖已冻结。"
|
||||||
|
"A2","ExpenseStandard 模型、迁移与 CRUD 路由","新增 ExpenseStandard Prisma 模型、严格唯一索引迁移、expense standards CRUD 路由与审计日志,支撑费用类型 × 全局/部门标准维护。","schema.prisma 含 ExpenseStandard 与 ExpenseType.standards relation;迁移目录为 20260408090000_r56_expense_standards,含 global/department 严格唯一索引;/api/expense-standards 支持列表、创建、更新、软删除,权限统一 system.settings,冲突返回 409 VALID_001,并记录 EXPENSE_STANDARD_CREATED/UPDATED/DELETED 审计。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && npx prisma validate && npx prisma migrate status && node --check src/routes/expenseStandards.js && npm test -- tests/unit/routes/expenseStandards.test.js。业务:1) 可创建 global 与 department 标准;2) global 重复或同部门重复被拒绝;3) 删除仅将 enabled 置为 false;4) 非 system.settings 用户访问返回 403。","manual","DONE","DONE","DONE","server/prisma/schema.prisma|server/prisma/migrations/20260408090000_r56_expense_standards/*|server/src/routes/expenseStandards.js|server/src/app.js|server/tests/unit/routes/expenseStandards.test.js","[""A1""]","回滚路径:回退路由与注册,并在回滚迁移中 drop expense_standards 表及部分唯一索引。2026-04-08:ExpenseStandard 模型、迁移、CRUD 路由与 10 条路由单测已完成,临时校验库 migrate status up to date。"
|
||||||
|
"A3","报销费用标准校验接入 precheck 与 submit","在 feeControl 中新增费用标准匹配与超标判断,并接入报销 precheck、submit 与工作流 context_data.standardViolations。","checkExpenseStandards 仅对报销生效,按 department > global 匹配启用标准,并返回 standardChecks;reimbursement precheck 与 fee-control precheck 在 reimbursement 时附带 standardChecks;submit 在 block 超标时返回 400 + standardViolations,warn 超标继续提交并返回 standardWarnings,同时将 exceeded 项写入 workflow_instance.context_data.standardViolations。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && node --check src/routes/reimbursements.js && node --check src/services/feeControl.js && npm test -- tests/unit/services/expenseStandards.check.test.js tests/unit/routes/reimbursements.expense-standards.test.js tests/unit/routes/workflow.task-detail.test.js。业务:1) department 标准覆盖 global;2) warn 超标仅预警不阻断;3) block 超标阻断提交;4) 无标准的明细不受影响;5) warn 提交成功后 workflow task detail 可读到 standardViolations。","manual","DONE","DONE","DONE","server/src/services/feeControl.js|server/src/routes/reimbursements.js|server/src/routes/feeControl.js|server/src/routes/workflow.js|server/tests/unit/services/expenseStandards.check.test.js|server/tests/unit/routes/reimbursements.expense-standards.test.js|server/tests/unit/routes/workflow.task-detail.test.js","[""A1"",""A2""]","回滚路径:删除费用标准检查 helper 与 submit/precheck 增强,保留现有预算 precheck 行为。2026-04-08:费用标准检查已接入 reimbursement precheck/submit/workflow detail,6 条服务单测与 4 条路由级回归通过。"
|
||||||
|
"A4","费用标准管理页、API、路由与系统设置入口","新增费用标准前端 API、管理页面、系统设置入口与受保护路由。","/system/expense-standards 仅 system.settings 用户可访问;页面包含费用类型/启用状态筛选、标准表格、新增/编辑弹窗与软删除确认;前端通过独立 expenseStandards API 调用后端 CRUD,并在 SystemSettingsPage 显示入口卡片。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build。业务:1) 可在系统设置进入费用标准页;2) 可新增、编辑、软删除标准;3) department 范围可选择部门;4) 列表中 warn/block badge 与适用范围文案正确。","manual","DONE","DONE","DONE","client/src/api/expenseStandards.ts|client/src/pages/ExpenseStandardsPage.tsx|client/src/pages/SystemSettingsPage.tsx|client/src/router/index.tsx|client/src/router/lazy/ExpenseStandardsPage.ts|client/src/router/lazy/index.ts","[""A1"",""A2""]","回滚路径:移除新页面、API、路由和系统设置入口卡片。2026-04-08:费用标准管理页、系统设置入口与路由懒加载已完成,client typecheck/build 通过。"
|
||||||
|
"A5","报销明细与审批侧超标提示","增强报销明细编辑器、报销详情页和审批任务详情页,展示费用标准提示、超标样式与提交流程确认。","ReimbursementItemEditor 会基于费用类型与部门显示当前有效标准和 warn/block 超标提示;ReimbursementDetailPage 在 draft/rejected 阶段展示 precheck standardChecks、对超标明细加橙/红边框、block 时禁用提交、warn 时展示确认弹窗;WorkflowTaskDetailPage 对 reimbursement 任务展示 standardViolations 高亮信息。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build。业务:1) 编辑明细时可见标准上限与实时超标提示;2) block 超标时提交按钮禁用且提示修改;3) warn 超标时提交前需确认;4) 审批人可在任务详情中看到超标明细、标准与超出金额。","manual","DONE","DONE","DONE","client/src/pages/reimbursement-detail/ReimbursementItemEditor.tsx|client/src/pages/reimbursement-detail/ReimbursementActions.tsx|client/src/pages/ReimbursementDetailPage.tsx|client/src/pages/WorkflowTaskDetailPage.tsx|client/src/api/reimbursements.ts|client/src/api/feeControl.ts|client/src/api/workflow.ts","[""A1"",""A2"",""A3"",""A4""]","回滚路径:移除前端 standardChecks/standardViolations 展示与提交确认逻辑。2026-04-08:编辑器实时提示、详情页阻断/确认交互与审批任务详情展示已完成。"
|
||||||
|
"A6","Reviewer、QA、CSV 回填与提交收口","执行 reviewer 自检、post-check、远端迁移核验项准备与 CSV 回填,完成提交收口。","review 结论覆盖严格唯一索引、department 优先级、block/warn 行为、context_data.standardViolations 写入、不依赖 budget_precheck_v1、无标准时跳过与迁移回滚;post-check 全部通过;issues/2026-04-08_r56-expense-standards.csv 的 Dev_Status/Review1_Status/Regression_Status 依据实测结果回填;完成 git commit。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && npx prisma validate && npx prisma migrate status && node --check src/routes/reimbursements.js && node --check src/routes/expenseStandards.js && node --check src/services/feeControl.js && npm test -- tests/unit/routes/expenseStandards.test.js tests/unit/services/expenseStandards.check.test.js tests/unit/routes/reimbursements.expense-standards.test.js tests/unit/routes/workflow.task-detail.test.js && cd ../client && npm run typecheck && npm run build。部署准备:记录远端需执行 docker compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env exec -T server npx prisma migrate status。业务:1) warn/block 标准分别完成提交流程验证;2) 审批任务详情可见超标信息;3) CSV 状态已回填。","manual","DONE","DONE","DONE","issues/2026-04-08_r56-expense-standards.csv|server/prisma/schema.prisma|server/prisma/migrations/20260408090000_r56_expense_standards/*|server/src/routes/expenseStandards.js|server/src/services/feeControl.js|server/src/routes/reimbursements.js|server/src/routes/workflow.js|server/tests/unit/routes/expenseStandards.test.js|server/tests/unit/services/expenseStandards.check.test.js|server/tests/unit/routes/reimbursements.expense-standards.test.js|server/tests/unit/routes/workflow.task-detail.test.js|client/src/api/expenseStandards.ts|client/src/pages/ExpenseStandardsPage.tsx|client/src/pages/ReimbursementDetailPage.tsx|client/src/pages/reimbursement-detail/ReimbursementItemEditor.tsx|client/src/pages/reimbursement-detail/ReimbursementActions.tsx|client/src/pages/WorkflowTaskDetailPage.tsx|client/src/pages/SystemSettingsPage.tsx|client/src/router/index.tsx|client/src/router/lazy/ExpenseStandardsPage.ts|client/src/router/lazy/index.ts|client/src/api/reimbursements.ts|client/src/api/feeControl.ts|client/src/api/workflow.ts","[""A2"",""A3"",""A4"",""A5""]","回滚路径:若 QA 发现关键回归,暂停提交并回退对应代码与迁移。2026-04-08:reviewer 自检确认严格唯一由部分唯一索引保证、department 优先于 global、block 以 400 阻断提交、warn 仅附加 standardWarnings 与 workflow context_data.standardViolations、预算开关关闭时标准校验仍生效、无标准明细自动跳过;post-check 已在临时校验库 hyb_oa_r56_check 上通过,新增 precheck/submit/task detail 路由回归 4 条通过。"
|
||||||
|
@@ -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;
|
||||||
@@ -812,12 +812,36 @@ model ExpenseType {
|
|||||||
updated_at DateTime @default(now()) @updatedAt
|
updated_at DateTime @default(now()) @updatedAt
|
||||||
updated_by String? @db.Uuid
|
updated_by String? @db.Uuid
|
||||||
|
|
||||||
|
standards ExpenseStandard[]
|
||||||
reimbursement_items ReimbursementItem[]
|
reimbursement_items ReimbursementItem[]
|
||||||
|
|
||||||
@@index([enabled, sort_order])
|
@@index([enabled, sort_order])
|
||||||
@@map("expense_types")
|
@@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 {
|
model DocumentSequence {
|
||||||
document_type String @id
|
document_type String @id
|
||||||
prefix String
|
prefix String
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ const employeesRoutes = require("./routes/employees");
|
|||||||
const performanceRoutes = require("./routes/performance");
|
const performanceRoutes = require("./routes/performance");
|
||||||
const payrollRoutes = require("./routes/payroll");
|
const payrollRoutes = require("./routes/payroll");
|
||||||
const expenseTypesRoutes = require("./routes/expenseTypes");
|
const expenseTypesRoutes = require("./routes/expenseTypes");
|
||||||
|
const expenseStandardsRoutes = require("./routes/expenseStandards");
|
||||||
const configReadRoutes = require("./routes/configRead");
|
const configReadRoutes = require("./routes/configRead");
|
||||||
const approvalRulesRoutes = require("./routes/approvalRules");
|
const approvalRulesRoutes = require("./routes/approvalRules");
|
||||||
const featureFlagsRoutes = require("./routes/featureFlags");
|
const featureFlagsRoutes = require("./routes/featureFlags");
|
||||||
@@ -322,6 +323,7 @@ async function buildApp() {
|
|||||||
await app.register(masterDataHolidayCalendarsRoutes, { prefix: "/api" });
|
await app.register(masterDataHolidayCalendarsRoutes, { prefix: "/api" });
|
||||||
await app.register(masterDataSuppliersRoutes, { prefix: "/api" });
|
await app.register(masterDataSuppliersRoutes, { prefix: "/api" });
|
||||||
await app.register(expenseTypesRoutes, { prefix: "/api" });
|
await app.register(expenseTypesRoutes, { prefix: "/api" });
|
||||||
|
await app.register(expenseStandardsRoutes, { prefix: "/api" });
|
||||||
await app.register(configReadRoutes, { prefix: "/api" });
|
await app.register(configReadRoutes, { prefix: "/api" });
|
||||||
await app.register(approvalRulesRoutes, { prefix: "/api" });
|
await app.register(approvalRulesRoutes, { prefix: "/api" });
|
||||||
await app.register(featureFlagsRoutes, { prefix: "/api" });
|
await app.register(featureFlagsRoutes, { prefix: "/api" });
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -663,16 +663,37 @@ function buildBudgetHint(budgetPool, reimbursementAmount, controlMode) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildPrecheckBlockedError(precheck) {
|
function buildPrecheckBlockedError(precheck) {
|
||||||
|
const standardViolations = Array.isArray(precheck?.standardChecks)
|
||||||
|
? precheck.standardChecks.filter((item) => item.exceeded && item.controlMode === "block")
|
||||||
|
: [];
|
||||||
|
|
||||||
return new AppError({
|
return new AppError({
|
||||||
code: "VALID_001",
|
code: "VALID_001",
|
||||||
message: "参数校验失败",
|
message: "参数校验失败",
|
||||||
statusCode: 409,
|
statusCode: standardViolations.length ? 400 : 409,
|
||||||
details: {
|
details: {
|
||||||
precheck: precheck.blockingReasons,
|
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) {
|
function buildSkippedNotificationResult(reason) {
|
||||||
return { skipped: true, reason };
|
return { skipped: true, reason };
|
||||||
}
|
}
|
||||||
@@ -2079,6 +2100,8 @@ async function reimbursementRoutes(fastify) {
|
|||||||
if (authoritativePrecheck.decision === "block") {
|
if (authoritativePrecheck.decision === "block") {
|
||||||
throw buildPrecheckBlockedError(authoritativePrecheck);
|
throw buildPrecheckBlockedError(authoritativePrecheck);
|
||||||
}
|
}
|
||||||
|
const standardViolations = listExceededStandardViolations(authoritativePrecheck);
|
||||||
|
const standardWarnings = listExceededStandardViolations(authoritativePrecheck, "warn");
|
||||||
const openedRiskFlags = await createRiskFlagsFromPrecheck(tx, {
|
const openedRiskFlags = await createRiskFlagsFromPrecheck(tx, {
|
||||||
entityType: "reimbursement",
|
entityType: "reimbursement",
|
||||||
entityId: reimbursementId,
|
entityId: reimbursementId,
|
||||||
@@ -2238,6 +2261,7 @@ async function reimbursementRoutes(fastify) {
|
|||||||
department_id: reimbursement.department_id,
|
department_id: reimbursement.department_id,
|
||||||
workflow_steps_snapshot: workflowSteps,
|
workflow_steps_snapshot: workflowSteps,
|
||||||
approval_rule_id: approvalRule.id,
|
approval_rule_id: approvalRule.id,
|
||||||
|
standardViolations,
|
||||||
},
|
},
|
||||||
context_department_id: reimbursement.department_id,
|
context_department_id: reimbursement.department_id,
|
||||||
created_by: request.employee.id,
|
created_by: request.employee.id,
|
||||||
@@ -2319,6 +2343,7 @@ async function reimbursementRoutes(fastify) {
|
|||||||
status: "reviewing",
|
status: "reviewing",
|
||||||
budget: budgetCheck,
|
budget: budgetCheck,
|
||||||
precheckDecision: authoritativePrecheck.decision,
|
precheckDecision: authoritativePrecheck.decision,
|
||||||
|
standardWarnings,
|
||||||
openedRiskFlagCount: openedRiskFlags.length,
|
openedRiskFlagCount: openedRiskFlags.length,
|
||||||
},
|
},
|
||||||
sideEffectsContext: {
|
sideEffectsContext: {
|
||||||
|
|||||||
@@ -1450,6 +1450,7 @@ async function workflowRoutes(fastify) {
|
|||||||
entity_id: true,
|
entity_id: true,
|
||||||
current_status: true,
|
current_status: true,
|
||||||
current_step_id: true,
|
current_step_id: true,
|
||||||
|
context_data: true,
|
||||||
started_at: true,
|
started_at: true,
|
||||||
completed_at: true,
|
completed_at: true,
|
||||||
reimbursement_form: {
|
reimbursement_form: {
|
||||||
@@ -1676,6 +1677,11 @@ async function workflowRoutes(fastify) {
|
|||||||
entityType: task.workflow_instance.entity_type,
|
entityType: task.workflow_instance.entity_type,
|
||||||
currentStatus: task.workflow_instance.current_status,
|
currentStatus: task.workflow_instance.current_status,
|
||||||
currentStepId: task.workflow_instance.current_step_id,
|
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),
|
startedAt: isoOrNull(task.workflow_instance.started_at),
|
||||||
completedAt: isoOrNull(task.workflow_instance.completed_at),
|
completedAt: isoOrNull(task.workflow_instance.completed_at),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ function buildBasePrecheck(entityType, entityId) {
|
|||||||
decision: "pass",
|
decision: "pass",
|
||||||
matchedBudgetControlRule: null,
|
matchedBudgetControlRule: null,
|
||||||
budgetHint: null,
|
budgetHint: null,
|
||||||
|
standardChecks: [],
|
||||||
warnings: [],
|
warnings: [],
|
||||||
blockingReasons: [],
|
blockingReasons: [],
|
||||||
riskFlags: [],
|
riskFlags: [],
|
||||||
@@ -70,6 +71,7 @@ function buildDisabledPrecheck(entityType, entityId) {
|
|||||||
decision: "pass",
|
decision: "pass",
|
||||||
matchedBudgetControlRule: null,
|
matchedBudgetControlRule: null,
|
||||||
budgetHint: null,
|
budgetHint: null,
|
||||||
|
standardChecks: [],
|
||||||
warnings: [],
|
warnings: [],
|
||||||
blockingReasons: [],
|
blockingReasons: [],
|
||||||
riskFlags: [],
|
riskFlags: [],
|
||||||
@@ -272,6 +274,165 @@ function appendBlockingReasons(response, issues) {
|
|||||||
return response;
|
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" }) {
|
async function createRiskFlagsFromPrecheck(tx, { entityType, entityId, actorId, precheck, source = "precheck" }) {
|
||||||
if (precheck.decision !== "manual_review" || !precheck.riskFlags.length) {
|
if (precheck.decision !== "manual_review" || !precheck.riskFlags.length) {
|
||||||
return [];
|
return [];
|
||||||
@@ -308,6 +469,7 @@ async function createRiskFlagsFromPrecheck(tx, { entityType, entityId, actorId,
|
|||||||
openedByDecision: precheck.decision,
|
openedByDecision: precheck.decision,
|
||||||
matchedBudgetControlRule: precheck.matchedBudgetControlRule,
|
matchedBudgetControlRule: precheck.matchedBudgetControlRule,
|
||||||
budgetHint: precheck.budgetHint,
|
budgetHint: precheck.budgetHint,
|
||||||
|
standardChecks: precheck.standardChecks ?? [],
|
||||||
},
|
},
|
||||||
created_by: actorId ?? null,
|
created_by: actorId ?? null,
|
||||||
},
|
},
|
||||||
@@ -843,15 +1005,27 @@ async function resolveEntityPrecheck({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!gate.enabled) {
|
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,
|
entityType,
|
||||||
entityId,
|
entityId,
|
||||||
config,
|
config,
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (entityType !== "reimbursement") {
|
||||||
|
return precheck;
|
||||||
|
}
|
||||||
|
|
||||||
|
const standardChecks = await checkExpenseStandards(prisma, { reimbursementId: entityId });
|
||||||
|
return mergeReimbursementPrecheck(precheck, standardChecks);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
@@ -870,6 +1044,8 @@ module.exports = {
|
|||||||
buildReimbursementPrecheck,
|
buildReimbursementPrecheck,
|
||||||
buildPurchaseRequestPrecheck,
|
buildPurchaseRequestPrecheck,
|
||||||
buildTravelRequestPrecheck,
|
buildTravelRequestPrecheck,
|
||||||
|
checkExpenseStandards,
|
||||||
loadPrecheckEntityContext,
|
loadPrecheckEntityContext,
|
||||||
|
mergeReimbursementPrecheck,
|
||||||
resolveEntityPrecheck,
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
在新工单中引用
屏蔽一个用户