R55 A1-A6:报销统计仪表盘与批量审批
这个提交包含在:
@@ -53,6 +53,17 @@ export type ReimbursementListItem = {
|
|||||||
projectNo: string;
|
projectNo: string;
|
||||||
name: string;
|
name: string;
|
||||||
} | null;
|
} | null;
|
||||||
|
pendingWorkflow: {
|
||||||
|
instanceId: string;
|
||||||
|
stepId: string;
|
||||||
|
allowedActions: Array<"approve" | "reject" | "return" | "transfer">;
|
||||||
|
task: {
|
||||||
|
id: string;
|
||||||
|
stepName: string;
|
||||||
|
assigneeId: string | null;
|
||||||
|
assigneeRole: string | null;
|
||||||
|
} | null;
|
||||||
|
} | null;
|
||||||
createdAt: string | null;
|
createdAt: string | null;
|
||||||
updatedAt: string | null;
|
updatedAt: string | null;
|
||||||
};
|
};
|
||||||
@@ -147,6 +158,49 @@ export type ReimbursementListQuery = {
|
|||||||
scope?: "own" | "all";
|
scope?: "own" | "all";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ReimbursementStatsQuery = {
|
||||||
|
dateFrom?: string;
|
||||||
|
dateTo?: string;
|
||||||
|
departmentId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReimbursementStatsGranularity = "month" | "week";
|
||||||
|
|
||||||
|
export type ReimbursementStatsSummary = {
|
||||||
|
totalCount: number;
|
||||||
|
totalAmount: string;
|
||||||
|
byStatus: Record<
|
||||||
|
ReimbursementStatus,
|
||||||
|
{
|
||||||
|
count: number;
|
||||||
|
totalAmount: string;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
avgProcessingDays: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReimbursementStatsTrendItem = {
|
||||||
|
period: string | null;
|
||||||
|
count: number;
|
||||||
|
amount: string;
|
||||||
|
paidCount: number;
|
||||||
|
paidAmount: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReimbursementTopExpenseTypeItem = {
|
||||||
|
expenseTypeId: string;
|
||||||
|
expenseTypeName: string;
|
||||||
|
count: number;
|
||||||
|
totalAmount: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReimbursementApprovalBottleneckItem = {
|
||||||
|
assigneeId: string | null;
|
||||||
|
assigneeName: string;
|
||||||
|
pendingCount: number;
|
||||||
|
oldestPendingDays: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type ReimbursementPayPayload = {
|
export type ReimbursementPayPayload = {
|
||||||
paymentVoucherNo?: string;
|
paymentVoucherNo?: string;
|
||||||
paymentDate?: string;
|
paymentDate?: string;
|
||||||
@@ -214,6 +268,34 @@ export async function fetchReimbursements(query: ReimbursementListQuery = {}) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchReimbursementStatsSummary(query: ReimbursementStatsQuery = {}) {
|
||||||
|
return apiRequestData<ReimbursementStatsSummary>(
|
||||||
|
`/api/reimbursements/stats/summary${toQueryString(query as Record<string, string | number | undefined>)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchReimbursementStatsTrend(
|
||||||
|
query: ReimbursementStatsQuery & { granularity?: ReimbursementStatsGranularity } = {},
|
||||||
|
) {
|
||||||
|
return apiRequestData<{ items: ReimbursementStatsTrendItem[] }>(
|
||||||
|
`/api/reimbursements/stats/trend${toQueryString(query as Record<string, string | number | undefined>)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchReimbursementTopExpenseTypes(
|
||||||
|
query: ReimbursementStatsQuery & { limit?: number } = {},
|
||||||
|
) {
|
||||||
|
return apiRequestData<{ items: ReimbursementTopExpenseTypeItem[] }>(
|
||||||
|
`/api/reimbursements/stats/top-expense-types${toQueryString(query as Record<string, string | number | undefined>)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchReimbursementApprovalBottleneck(query: ReimbursementStatsQuery = {}) {
|
||||||
|
return apiRequestData<{ items: ReimbursementApprovalBottleneckItem[] }>(
|
||||||
|
`/api/reimbursements/stats/approval-bottleneck${toQueryString(query as Record<string, string | number | undefined>)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchReimbursementDetail(reimbursementId: string) {
|
export async function fetchReimbursementDetail(reimbursementId: string) {
|
||||||
return apiRequestData<ReimbursementDetail>(`/api/reimbursements/${reimbursementId}`);
|
return apiRequestData<ReimbursementDetail>(`/api/reimbursements/${reimbursementId}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -201,6 +201,24 @@ export type WorkflowApproverQuery = {
|
|||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type WorkflowBatchActionResult = {
|
||||||
|
taskId: string;
|
||||||
|
outcome: "approved" | "rejected" | "skipped" | "error";
|
||||||
|
reason?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkflowBatchActionSummary = {
|
||||||
|
approved: number;
|
||||||
|
rejected?: number;
|
||||||
|
skipped: number;
|
||||||
|
errored: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkflowBatchActionResponse = {
|
||||||
|
results: WorkflowBatchActionResult[];
|
||||||
|
summary: WorkflowBatchActionSummary;
|
||||||
|
};
|
||||||
|
|
||||||
function toQueryString(query: Record<string, string | number | undefined>) {
|
function toQueryString(query: Record<string, string | number | undefined>) {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
for (const [key, value] of Object.entries(query)) {
|
for (const [key, value] of Object.entries(query)) {
|
||||||
@@ -255,3 +273,17 @@ export async function transferWorkflowTask(taskId: string, toApproverId: string,
|
|||||||
body: JSON.stringify({ toApproverId, reason }),
|
body: JSON.stringify({ toApproverId, reason }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function batchApproveWorkflowTasks(payload: { taskIds: string[]; comment?: string }) {
|
||||||
|
return apiRequestData<WorkflowBatchActionResponse>("/api/workflow/batch-approve", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function batchRejectWorkflowTasks(payload: { taskIds: string[]; comment: string }) {
|
||||||
|
return apiRequestData<WorkflowBatchActionResponse>("/api/workflow/batch-reject", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,343 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { fetchDepartments } from "../api/departments";
|
||||||
|
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||||
|
import {
|
||||||
|
fetchReimbursementApprovalBottleneck,
|
||||||
|
fetchReimbursementStatsSummary,
|
||||||
|
fetchReimbursementStatsTrend,
|
||||||
|
fetchReimbursementTopExpenseTypes,
|
||||||
|
type ReimbursementStatsQuery,
|
||||||
|
} from "../api/reimbursements";
|
||||||
|
import { isApiError } from "../api/types";
|
||||||
|
import { PageHeader } from "../components/common/PageHeader";
|
||||||
|
import { SectionPanel } from "../components/patterns/SectionPanel";
|
||||||
|
import { Button } from "../components/ui/button";
|
||||||
|
|
||||||
|
function currentDateString() {
|
||||||
|
return new Date().toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultDateFromString() {
|
||||||
|
const now = new Date();
|
||||||
|
now.setDate(1);
|
||||||
|
now.setMonth(now.getMonth() - 2);
|
||||||
|
return now.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 formatCurrency(value: string | number | null | undefined) {
|
||||||
|
const numeric = Number(value ?? 0);
|
||||||
|
return new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY" }).format(
|
||||||
|
Number.isFinite(numeric) ? numeric : 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAvgDays(value: number | null) {
|
||||||
|
if (value == null) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
const normalized = Number(value);
|
||||||
|
if (!Number.isFinite(normalized)) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return `${normalized.toFixed(1)} 天`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPeriod(value: string | null) {
|
||||||
|
if (!value) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return value.length === 7 ? value : value.replace("T", " ").slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBarWidth(current: number, max: number) {
|
||||||
|
if (max <= 0 || current <= 0) {
|
||||||
|
return "0%";
|
||||||
|
}
|
||||||
|
return `${Math.max(8, Math.min(100, (current / max) * 100))}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReimbursementDashboardPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [dateFrom, setDateFrom] = useState(defaultDateFromString());
|
||||||
|
const [dateTo, setDateTo] = useState(currentDateString());
|
||||||
|
const [departmentId, setDepartmentId] = useState("");
|
||||||
|
|
||||||
|
const filters = useMemo<ReimbursementStatsQuery>(
|
||||||
|
() => ({
|
||||||
|
dateFrom: dateFrom || undefined,
|
||||||
|
dateTo: dateTo || undefined,
|
||||||
|
departmentId: departmentId || undefined,
|
||||||
|
}),
|
||||||
|
[dateFrom, dateTo, departmentId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const departmentsQuery = useQuery({
|
||||||
|
queryKey: ["departments", "reimbursements-dashboard"],
|
||||||
|
queryFn: () => fetchDepartments({ includeArchived: false }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const summaryQuery = useQuery({
|
||||||
|
queryKey: ["reimbursements", "stats", "summary", filters],
|
||||||
|
queryFn: () => fetchReimbursementStatsSummary(filters),
|
||||||
|
});
|
||||||
|
|
||||||
|
const trendQuery = useQuery({
|
||||||
|
queryKey: ["reimbursements", "stats", "trend", filters],
|
||||||
|
queryFn: () => fetchReimbursementStatsTrend({ ...filters, granularity: "month" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const topExpenseTypesQuery = useQuery({
|
||||||
|
queryKey: ["reimbursements", "stats", "top-expense-types", filters],
|
||||||
|
queryFn: () => fetchReimbursementTopExpenseTypes({ ...filters, limit: 10 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const bottleneckQuery = useQuery({
|
||||||
|
queryKey: ["reimbursements", "stats", "approval-bottleneck", filters],
|
||||||
|
queryFn: () => fetchReimbursementApprovalBottleneck(filters),
|
||||||
|
});
|
||||||
|
|
||||||
|
const loading =
|
||||||
|
summaryQuery.isLoading ||
|
||||||
|
trendQuery.isLoading ||
|
||||||
|
topExpenseTypesQuery.isLoading ||
|
||||||
|
bottleneckQuery.isLoading;
|
||||||
|
|
||||||
|
const firstError =
|
||||||
|
summaryQuery.error ??
|
||||||
|
trendQuery.error ??
|
||||||
|
topExpenseTypesQuery.error ??
|
||||||
|
bottleneckQuery.error ??
|
||||||
|
departmentsQuery.error;
|
||||||
|
|
||||||
|
const summary = summaryQuery.data;
|
||||||
|
const trendItems = trendQuery.data?.items ?? [];
|
||||||
|
const topExpenseTypes = topExpenseTypesQuery.data?.items ?? [];
|
||||||
|
const bottlenecks = bottleneckQuery.data?.items ?? [];
|
||||||
|
|
||||||
|
const maxTrendCount = Math.max(1, ...trendItems.map((item) => Number(item.count ?? 0)));
|
||||||
|
const maxTrendAmount = Math.max(1, ...trendItems.map((item) => Number(item.amount ?? 0)));
|
||||||
|
const maxExpenseTypeAmount = Math.max(1, ...topExpenseTypes.map((item) => Number(item.totalAmount ?? 0)));
|
||||||
|
|
||||||
|
async function handleRefresh() {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["reimbursements", "stats"] });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
title="报销统计分析"
|
||||||
|
subtitle="按时间范围与部门查看报销规模、处理效率、费用结构和审批瓶颈。"
|
||||||
|
actions={
|
||||||
|
<Button type="button" variant="secondary" onClick={() => void handleRefresh()}>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{firstError ? (
|
||||||
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||||
|
{resolveErrorMessage(firstError)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="card-std space-y-4 p-6">
|
||||||
|
<div className="grid gap-3 md:grid-cols-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs text-zinc-600" htmlFor="reimbursement-dashboard-date-from">
|
||||||
|
开始日期
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="reimbursement-dashboard-date-from"
|
||||||
|
className="input-std"
|
||||||
|
type="date"
|
||||||
|
value={dateFrom}
|
||||||
|
onChange={(event) => setDateFrom(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs text-zinc-600" htmlFor="reimbursement-dashboard-date-to">
|
||||||
|
结束日期
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="reimbursement-dashboard-date-to"
|
||||||
|
className="input-std"
|
||||||
|
type="date"
|
||||||
|
value={dateTo}
|
||||||
|
onChange={(event) => setDateTo(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs text-zinc-600" htmlFor="reimbursement-dashboard-department">
|
||||||
|
部门
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="reimbursement-dashboard-department"
|
||||||
|
className="select-std"
|
||||||
|
value={departmentId}
|
||||||
|
onChange={(event) => setDepartmentId(event.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">全部部门</option>
|
||||||
|
{(departmentsQuery.data?.items ?? []).map((department) => (
|
||||||
|
<option key={department.id} value={department.id}>
|
||||||
|
{department.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end">
|
||||||
|
<div className="rounded-xl border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm text-zinc-600">
|
||||||
|
默认窗口:近 3 个自然月
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<div className="card-std p-5">
|
||||||
|
<div className="text-xs text-zinc-500">报销总数</div>
|
||||||
|
<div className="mt-2 text-3xl font-semibold text-zinc-900">
|
||||||
|
{loading ? "..." : summary?.totalCount ?? 0}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card-std p-5">
|
||||||
|
<div className="text-xs text-zinc-500">报销总金额</div>
|
||||||
|
<div className="mt-2 text-3xl font-semibold text-zinc-900">
|
||||||
|
{loading ? "..." : formatCurrency(summary?.totalAmount)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card-std p-5">
|
||||||
|
<div className="text-xs text-zinc-500">平均处理天数</div>
|
||||||
|
<div className="mt-2 text-3xl font-semibold text-zinc-900">
|
||||||
|
{loading ? "..." : formatAvgDays(summary?.avgProcessingDays ?? null)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card-std p-5">
|
||||||
|
<div className="text-xs text-zinc-500">待审批数</div>
|
||||||
|
<div className="mt-2 text-3xl font-semibold text-zinc-900">
|
||||||
|
{loading ? "..." : summary?.byStatus.reviewing.count ?? 0}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SectionPanel
|
||||||
|
title="报销趋势"
|
||||||
|
description="按月观察报销数量与金额的变化,帮助判断高峰月份和支付节奏。"
|
||||||
|
>
|
||||||
|
{trendItems.length ? (
|
||||||
|
<div className="grid gap-3 lg:grid-cols-2">
|
||||||
|
{trendItems.map((item) => (
|
||||||
|
<div key={item.period ?? "unknown"} className="rounded-2xl border border-zinc-200 bg-white p-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="text-sm font-medium text-zinc-900">{formatPeriod(item.period)}</div>
|
||||||
|
<div className="text-xs text-zinc-500">已支付 {item.paidCount} 单</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 flex items-center justify-between text-xs text-zinc-500">
|
||||||
|
<span>数量</span>
|
||||||
|
<span>{item.count} 单</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 rounded-full bg-zinc-100">
|
||||||
|
<div
|
||||||
|
className="h-2 rounded-full bg-zinc-900"
|
||||||
|
style={{ width: normalizeBarWidth(Number(item.count ?? 0), maxTrendCount) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 flex items-center justify-between text-xs text-zinc-500">
|
||||||
|
<span>金额</span>
|
||||||
|
<span>{formatCurrency(item.amount)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 rounded-full bg-emerald-100">
|
||||||
|
<div
|
||||||
|
className="h-2 rounded-full bg-emerald-600"
|
||||||
|
style={{ width: normalizeBarWidth(Number(item.amount ?? 0), maxTrendAmount) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-zinc-500">当前筛选下暂无趋势数据。</div>
|
||||||
|
)}
|
||||||
|
</SectionPanel>
|
||||||
|
|
||||||
|
<div className="grid gap-6 xl:grid-cols-2">
|
||||||
|
<SectionPanel
|
||||||
|
title="费用类型排行"
|
||||||
|
description="按金额查看最主要的费用类型,帮助财务快速定位结构变化。"
|
||||||
|
>
|
||||||
|
{topExpenseTypes.length ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{topExpenseTypes.map((item, index) => (
|
||||||
|
<div key={item.expenseTypeId} className="rounded-2xl border border-zinc-200 bg-white p-4">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-zinc-900">
|
||||||
|
{index + 1}. {item.expenseTypeName}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-zinc-500">{item.count} 条报销明细</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium text-zinc-700">{formatCurrency(item.totalAmount)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 h-2 rounded-full bg-zinc-100">
|
||||||
|
<div
|
||||||
|
className="h-2 rounded-full bg-amber-500"
|
||||||
|
style={{ width: normalizeBarWidth(Number(item.totalAmount ?? 0), maxExpenseTypeAmount) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-zinc-500">当前筛选下暂无费用类型排行。</div>
|
||||||
|
)}
|
||||||
|
</SectionPanel>
|
||||||
|
|
||||||
|
<SectionPanel
|
||||||
|
title="审批瓶颈"
|
||||||
|
description="观察 reviewing 中的待处理堆积情况,帮助尽快发现卡点。"
|
||||||
|
>
|
||||||
|
{bottlenecks.length ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{bottlenecks.map((item) => {
|
||||||
|
const isStale = item.oldestPendingDays > 3;
|
||||||
|
return (
|
||||||
|
<div key={`${item.assigneeId ?? "role"}-${item.assigneeName}`} className="rounded-2xl border border-zinc-200 bg-white p-4">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-zinc-900">{item.assigneeName}</div>
|
||||||
|
<div className="mt-1 text-xs text-zinc-500">
|
||||||
|
待处理 {item.pendingCount} 单
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={`text-sm font-medium ${isStale ? "text-red-600" : "text-zinc-700"}`}>
|
||||||
|
最长等待 {item.oldestPendingDays} 天
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-zinc-500">当前筛选下暂无审批瓶颈。</div>
|
||||||
|
)}
|
||||||
|
</SectionPanel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Link, useSearchParams } from "react-router-dom";
|
import { Link, useSearchParams } from "react-router-dom";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { isApiError } from "../api/types";
|
import { isApiError } from "../api/types";
|
||||||
@@ -6,6 +6,11 @@ import { mapApiErrorMessage } from "../api/errorMessages";
|
|||||||
import { fetchMyDelegations } from "../api/delegations";
|
import { fetchMyDelegations } from "../api/delegations";
|
||||||
import { exportReimbursements } from "../api/exports";
|
import { exportReimbursements } from "../api/exports";
|
||||||
import { createReimbursement, fetchReimbursements, type ReimbursementStatus } from "../api/reimbursements";
|
import { createReimbursement, fetchReimbursements, type ReimbursementStatus } from "../api/reimbursements";
|
||||||
|
import {
|
||||||
|
batchApproveWorkflowTasks,
|
||||||
|
batchRejectWorkflowTasks,
|
||||||
|
type WorkflowBatchActionResponse,
|
||||||
|
} from "../api/workflow";
|
||||||
import { useAuth } from "../auth/AuthContext";
|
import { useAuth } from "../auth/AuthContext";
|
||||||
import { hasPermission } from "../auth/permissions";
|
import { hasPermission } from "../auth/permissions";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -17,31 +22,82 @@ import { TableSkeleton } from "../components/common/TableSkeleton";
|
|||||||
import { FilterBar, ListWorkspace, PrimaryTable } from "../components/patterns/ListWorkspace";
|
import { FilterBar, ListWorkspace, PrimaryTable } from "../components/patterns/ListWorkspace";
|
||||||
import { MetricCard } from "../components/patterns/MetricCard";
|
import { MetricCard } from "../components/patterns/MetricCard";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "../components/ui/dialog";
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../components/ui/dropdown-menu";
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../components/ui/dropdown-menu";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
|
||||||
|
import { Textarea } from "../components/ui/textarea";
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
type BatchAction = "approve" | "reject";
|
||||||
|
|
||||||
|
type BatchResultState = {
|
||||||
|
action: BatchAction;
|
||||||
|
summary: WorkflowBatchActionResponse["summary"];
|
||||||
|
results: Array<
|
||||||
|
WorkflowBatchActionResponse["results"][number] & {
|
||||||
|
reimbursementId: string | null;
|
||||||
|
formNumber: string | null;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
|
||||||
function formatCurrency(value: number) {
|
function formatCurrency(value: number) {
|
||||||
return new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY" }).format(value);
|
return new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY" }).format(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveErrorMessage(error: unknown, fallback: string) {
|
||||||
|
if (isApiError(error)) {
|
||||||
|
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
||||||
|
}
|
||||||
|
return error instanceof Error ? error.message : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBatchOutcome(action: BatchAction, outcome: string) {
|
||||||
|
if (outcome === "approved") {
|
||||||
|
return "已通过";
|
||||||
|
}
|
||||||
|
if (outcome === "rejected") {
|
||||||
|
return "已驳回";
|
||||||
|
}
|
||||||
|
if (outcome === "skipped") {
|
||||||
|
return "已跳过";
|
||||||
|
}
|
||||||
|
if (outcome === "error") {
|
||||||
|
return "失败";
|
||||||
|
}
|
||||||
|
return action === "approve" ? "已通过" : "已驳回";
|
||||||
|
}
|
||||||
|
|
||||||
export function ReimbursementsPage() {
|
export function ReimbursementsPage() {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const { permissions, me } = useAuth();
|
const { permissions, me } = useAuth();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [exportError, setExportError] = useState("");
|
const [exportError, setExportError] = useState("");
|
||||||
|
const [batchError, setBatchError] = useState("");
|
||||||
|
const [proxyOwnerEmployeeId, setProxyOwnerEmployeeId] = useState("");
|
||||||
|
const [batchMode, setBatchMode] = useState(false);
|
||||||
|
const [selectedReimbursementIds, setSelectedReimbursementIds] = useState<string[]>([]);
|
||||||
|
const [batchDialogAction, setBatchDialogAction] = useState<BatchAction | null>(null);
|
||||||
|
const [batchComment, setBatchComment] = useState("");
|
||||||
|
const [batchResult, setBatchResult] = useState<BatchResultState | null>(null);
|
||||||
|
|
||||||
const status = (searchParams.get("status") as ReimbursementStatus | null) ?? "draft";
|
const status = (searchParams.get("status") as ReimbursementStatus | null) ?? "draft";
|
||||||
const page = Number(searchParams.get("page") ?? "1") || 1;
|
const page = Number(searchParams.get("page") ?? "1") || 1;
|
||||||
const scope = (searchParams.get("scope") as "own" | "all" | null) ?? "own";
|
const scope = (searchParams.get("scope") as "own" | "all" | null) ?? "own";
|
||||||
const canViewAll = hasPermission(permissions, "expense.view_all");
|
const canViewAll = hasPermission(permissions, "expense.view_all");
|
||||||
|
const canApproveExpense = hasPermission(permissions, "expense.approve");
|
||||||
const canProxySubmit = hasPermission(permissions, "expense.proxy_submit");
|
const canProxySubmit = hasPermission(permissions, "expense.proxy_submit");
|
||||||
const isAdministration = (me?.roleCodes ?? []).includes("administration");
|
const isAdministration = (me?.roleCodes ?? []).includes("administration");
|
||||||
const showProxyOwnerSelector = canProxySubmit && isAdministration;
|
const showProxyOwnerSelector = canProxySubmit && isAdministration;
|
||||||
|
|
||||||
const [proxyOwnerEmployeeId, setProxyOwnerEmployeeId] = useState("");
|
|
||||||
|
|
||||||
const listQuery = useQuery({
|
const listQuery = useQuery({
|
||||||
queryKey: ["reimbursements", "list", status, page, scope],
|
queryKey: ["reimbursements", "list", status, page, scope],
|
||||||
queryFn: () => fetchReimbursements({ status, page, pageSize: PAGE_SIZE, scope }),
|
queryFn: () => fetchReimbursements({ status, page, pageSize: PAGE_SIZE, scope }),
|
||||||
@@ -67,6 +123,33 @@ export function ReimbursementsPage() {
|
|||||||
);
|
);
|
||||||
}, [items]);
|
}, [items]);
|
||||||
|
|
||||||
|
const actionableItems = useMemo(
|
||||||
|
() =>
|
||||||
|
items.filter(
|
||||||
|
(item) =>
|
||||||
|
Boolean(item.pendingWorkflow?.task?.id) &&
|
||||||
|
Boolean(item.pendingWorkflow?.allowedActions.includes("approve")),
|
||||||
|
),
|
||||||
|
[items],
|
||||||
|
);
|
||||||
|
const actionableIdSet = useMemo(
|
||||||
|
() => new Set(actionableItems.map((item) => item.id)),
|
||||||
|
[actionableItems],
|
||||||
|
);
|
||||||
|
const canShowBatchEntry =
|
||||||
|
status === "reviewing" && scope === "all" && canApproveExpense && actionableItems.length > 0;
|
||||||
|
const selectedItems = actionableItems.filter((item) => selectedReimbursementIds.includes(item.id));
|
||||||
|
const allActionableSelected =
|
||||||
|
actionableItems.length > 0 && actionableItems.every((item) => selectedReimbursementIds.includes(item.id));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedReimbursementIds((current) => current.filter((id) => actionableIdSet.has(id)));
|
||||||
|
if (!canShowBatchEntry) {
|
||||||
|
setBatchMode(false);
|
||||||
|
setBatchDialogAction(null);
|
||||||
|
}
|
||||||
|
}, [actionableIdSet, canShowBatchEntry]);
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
createReimbursement({
|
createReimbursement({
|
||||||
@@ -76,12 +159,8 @@ export function ReimbursementsPage() {
|
|||||||
await queryClient.invalidateQueries({ queryKey: ["reimbursements"] });
|
await queryClient.invalidateQueries({ queryKey: ["reimbursements"] });
|
||||||
toast.success("报销单已创建");
|
toast.success("报销单已创建");
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (error) => {
|
||||||
toast.error(
|
toast.error(resolveErrorMessage(error, "创建报销单失败"));
|
||||||
isApiError(err)
|
|
||||||
? `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`
|
|
||||||
: "创建报销单失败",
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -92,20 +171,128 @@ export function ReimbursementsPage() {
|
|||||||
setExportError("");
|
setExportError("");
|
||||||
toast.success("导出完成");
|
toast.success("导出完成");
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (error) => {
|
||||||
if (isApiError(err)) {
|
const message = resolveErrorMessage(error, "导出失败");
|
||||||
setExportError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
|
setExportError(message);
|
||||||
toast.error(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
|
toast.error(message);
|
||||||
return;
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const batchMutation = useMutation({
|
||||||
|
mutationFn: async (input: { action: BatchAction; comment: string; selectedItems: typeof actionableItems }) => {
|
||||||
|
const taskIds = input.selectedItems
|
||||||
|
.map((item) => item.pendingWorkflow?.task?.id ?? null)
|
||||||
|
.filter((taskId): taskId is string => Boolean(taskId));
|
||||||
|
|
||||||
|
if (!taskIds.length) {
|
||||||
|
throw new Error("请先选择至少一条可处理的报销待办。");
|
||||||
}
|
}
|
||||||
const msg = err instanceof Error ? err.message : "导出失败";
|
|
||||||
setExportError(msg);
|
const response =
|
||||||
toast.error(msg);
|
input.action === "approve"
|
||||||
|
? await batchApproveWorkflowTasks({
|
||||||
|
taskIds,
|
||||||
|
comment: input.comment.trim() || undefined,
|
||||||
|
})
|
||||||
|
: await batchRejectWorkflowTasks({
|
||||||
|
taskIds,
|
||||||
|
comment: input.comment.trim(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
action: input.action,
|
||||||
|
response,
|
||||||
|
selectedItems: input.selectedItems.map((item) => ({
|
||||||
|
reimbursementId: item.id,
|
||||||
|
formNumber: item.formNumber,
|
||||||
|
taskId: item.pendingWorkflow?.task?.id ?? null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
onSuccess: async (payload) => {
|
||||||
|
const formByTaskId = new Map(
|
||||||
|
payload.selectedItems
|
||||||
|
.filter((item) => item.taskId)
|
||||||
|
.map((item) => [item.taskId!, item]),
|
||||||
|
);
|
||||||
|
|
||||||
|
setBatchResult({
|
||||||
|
action: payload.action,
|
||||||
|
summary: payload.response.summary,
|
||||||
|
results: payload.response.results.map((item) => {
|
||||||
|
const related = formByTaskId.get(item.taskId);
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
reimbursementId: related?.reimbursementId ?? null,
|
||||||
|
formNumber: related?.formNumber ?? null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
setBatchDialogAction(null);
|
||||||
|
setBatchComment("");
|
||||||
|
setBatchMode(false);
|
||||||
|
setSelectedReimbursementIds([]);
|
||||||
|
setBatchError("");
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["reimbursements"] });
|
||||||
|
toast.success(payload.action === "approve" ? "批量通过已执行" : "批量驳回已执行");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
const message = resolveErrorMessage(error, "批量处理失败");
|
||||||
|
setBatchError(message);
|
||||||
|
toast.error(message);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE));
|
const totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE));
|
||||||
|
|
||||||
|
function toggleBatchSelection(reimbursementId: string) {
|
||||||
|
setSelectedReimbursementIds((current) =>
|
||||||
|
current.includes(reimbursementId)
|
||||||
|
? current.filter((id) => id !== reimbursementId)
|
||||||
|
: [...current, reimbursementId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelectAllActionable() {
|
||||||
|
if (allActionableSelected) {
|
||||||
|
setSelectedReimbursementIds([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedReimbursementIds(actionableItems.map((item) => item.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBatchDialog(action: BatchAction) {
|
||||||
|
if (!selectedItems.length) {
|
||||||
|
setBatchError("请先选择至少一条可批量处理的报销待办。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBatchError("");
|
||||||
|
setBatchDialogAction(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCloseBatchDialog(open: boolean) {
|
||||||
|
if (!open) {
|
||||||
|
setBatchDialogAction(null);
|
||||||
|
setBatchComment("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitBatchAction() {
|
||||||
|
if (!batchDialogAction) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (batchDialogAction === "reject" && !batchComment.trim()) {
|
||||||
|
setBatchError("批量驳回必须填写统一理由。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
batchMutation.mutate({
|
||||||
|
action: batchDialogAction,
|
||||||
|
comment: batchComment,
|
||||||
|
selectedItems,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListWorkspace>
|
<ListWorkspace>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -131,6 +318,12 @@ export function ReimbursementsPage() {
|
|||||||
</select>
|
</select>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{canViewAll ? (
|
||||||
|
<Button asChild variant="secondary" type="button">
|
||||||
|
<Link to="/reimbursements/dashboard">统计分析</Link>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
data-testid="reimbursements-create"
|
data-testid="reimbursements-create"
|
||||||
disabled={createMutation.isPending}
|
disabled={createMutation.isPending}
|
||||||
@@ -187,6 +380,12 @@ export function ReimbursementsPage() {
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{batchError ? (
|
||||||
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||||
|
{batchError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
<MetricCard title="当前列表" value={stats.currentPageCount} note={`共 ${listQuery.data?.total ?? 0} 条记录`} />
|
<MetricCard title="当前列表" value={stats.currentPageCount} note={`共 ${listQuery.data?.total ?? 0} 条记录`} />
|
||||||
<MetricCard title="当前金额" value={formatCurrency(stats.currentPageAmount)} note="按当前页与筛选统计" />
|
<MetricCard title="当前金额" value={formatCurrency(stats.currentPageAmount)} note="按当前页与筛选统计" />
|
||||||
@@ -221,8 +420,9 @@ export function ReimbursementsPage() {
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{canViewAll ? (
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{canViewAll ? (
|
||||||
|
<>
|
||||||
<Button
|
<Button
|
||||||
data-testid="reimbursements-scope-own"
|
data-testid="reimbursements-scope-own"
|
||||||
variant={scope === "own" ? "default" : "secondary"}
|
variant={scope === "own" ? "default" : "secondary"}
|
||||||
@@ -239,12 +439,35 @@ export function ReimbursementsPage() {
|
|||||||
>
|
>
|
||||||
全范围
|
全范围
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{canShowBatchEntry ? (
|
||||||
|
<Button
|
||||||
|
variant={batchMode ? "default" : "secondary"}
|
||||||
|
onClick={() => {
|
||||||
|
setBatchMode((current) => !current);
|
||||||
|
setSelectedReimbursementIds([]);
|
||||||
|
setBatchDialogAction(null);
|
||||||
|
setBatchComment("");
|
||||||
|
setBatchError("");
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{batchMode ? "退出批量模式" : "批量操作"}
|
||||||
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{batchMode ? (
|
||||||
|
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||||
|
当前只可勾选你本人可处理的 reviewing 报销待办。批量通过可不填意见,批量驳回必须填写统一理由。
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</FilterBar>
|
</FilterBar>
|
||||||
|
|
||||||
{listQuery.isLoading ? <TableSkeleton columns={6} rows={5} /> : null}
|
{listQuery.isLoading ? <TableSkeleton columns={batchMode ? 7 : 6} rows={5} /> : null}
|
||||||
|
|
||||||
{listQuery.isError ? (
|
{listQuery.isError ? (
|
||||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||||
@@ -261,9 +484,10 @@ export function ReimbursementsPage() {
|
|||||||
{!listQuery.isLoading && !listQuery.isError ? (
|
{!listQuery.isLoading && !listQuery.isError ? (
|
||||||
<>
|
<>
|
||||||
<PrimaryTable>
|
<PrimaryTable>
|
||||||
<table className="w-full text-left border-collapse" data-testid="reimbursements-table">
|
<table className="w-full border-collapse text-left" data-testid="reimbursements-table">
|
||||||
<thead className="table-header">
|
<thead className="table-header">
|
||||||
<tr>
|
<tr>
|
||||||
|
{batchMode ? <th className="table-th w-14">选择</th> : null}
|
||||||
<th className="table-th">单号</th>
|
<th className="table-th">单号</th>
|
||||||
<th className="table-th">状态</th>
|
<th className="table-th">状态</th>
|
||||||
<th className="table-th">金额</th>
|
<th className="table-th">金额</th>
|
||||||
@@ -274,24 +498,56 @@ export function ReimbursementsPage() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-zinc-50">
|
<tbody className="divide-y divide-zinc-50">
|
||||||
{items.length ? (
|
{items.length ? (
|
||||||
items.map((item) => (
|
items.map((item) => {
|
||||||
|
const canSelectRow = Boolean(
|
||||||
|
item.pendingWorkflow?.task?.id && item.pendingWorkflow.allowedActions.includes("approve"),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
<tr className="table-row" data-testid="reimbursement-row" key={item.id}>
|
<tr className="table-row" data-testid="reimbursement-row" key={item.id}>
|
||||||
|
{batchMode ? (
|
||||||
|
<td className="table-td">
|
||||||
|
{canSelectRow ? (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedReimbursementIds.includes(item.id)}
|
||||||
|
onChange={() => toggleBatchSelection(item.id)}
|
||||||
|
aria-label={`选择报销单 ${item.formNumber ?? item.id}`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-zinc-400">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
) : null}
|
||||||
<td className="table-td">
|
<td className="table-td">
|
||||||
<div className="font-semibold text-zinc-900">{item.formNumber || item.id}</div>
|
<div className="font-semibold text-zinc-900">{item.formNumber || item.id}</div>
|
||||||
{item.formNumber && item.formNumber !== item.id && (
|
{item.formNumber && item.formNumber !== item.id ? (
|
||||||
<div className="text-xs text-zinc-500">{item.id}</div>
|
<div className="text-xs text-zinc-500">{item.id}</div>
|
||||||
)}
|
) : null}
|
||||||
{item.project ? <div className="text-xs text-zinc-500">项目:{item.project.projectNo} · {item.project.name}</div> : null}
|
{item.project ? (
|
||||||
|
<div className="text-xs text-zinc-500">
|
||||||
|
项目:{item.project.projectNo} · {item.project.name}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{item.pendingWorkflow?.task?.id ? (
|
||||||
|
<div className="text-xs text-amber-700">
|
||||||
|
当前待办:{item.pendingWorkflow.task.stepName} · task {item.pendingWorkflow.task.id}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
<td className="table-td">
|
||||||
|
<StatusBadge status={item.status} />
|
||||||
</td>
|
</td>
|
||||||
<td className="table-td"><StatusBadge status={item.status} /></td>
|
|
||||||
<td className="table-td-strong">
|
<td className="table-td-strong">
|
||||||
{item.totalAmount != null
|
{item.totalAmount != null
|
||||||
? new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY" }).format(Number(item.totalAmount))
|
? new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY" }).format(
|
||||||
|
Number(item.totalAmount),
|
||||||
|
)
|
||||||
: "-"}
|
: "-"}
|
||||||
</td>
|
</td>
|
||||||
<td className="table-td">
|
<td className="table-td">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="size-6 rounded-full bg-zinc-100 flex items-center justify-center text-[10px] font-bold text-zinc-700 uppercase">
|
<div className="flex size-6 items-center justify-center rounded-full bg-zinc-100 text-[10px] font-bold uppercase text-zinc-700">
|
||||||
{(item.owner?.name ?? "U").charAt(0)}
|
{(item.owner?.name ?? "U").charAt(0)}
|
||||||
</div>
|
</div>
|
||||||
<span className="font-medium text-zinc-900">{item.owner?.name ?? "-"}</span>
|
<span className="font-medium text-zinc-900">{item.owner?.name ?? "-"}</span>
|
||||||
@@ -304,10 +560,11 @@ export function ReimbursementsPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))
|
);
|
||||||
|
})
|
||||||
) : (
|
) : (
|
||||||
<tr>
|
<tr>
|
||||||
<td className="py-8 text-center text-sm text-zinc-500" colSpan={6}>
|
<td className="py-8 text-center text-sm text-zinc-500" colSpan={batchMode ? 7 : 6}>
|
||||||
暂无数据
|
暂无数据
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -326,6 +583,118 @@ export function ReimbursementsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{batchMode ? (
|
||||||
|
<div className="fixed bottom-6 left-1/2 z-40 w-[min(760px,calc(100vw-2rem))] -translate-x-1/2">
|
||||||
|
<div className="rounded-2xl border border-zinc-200 bg-white p-4 shadow-lg">
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className="text-sm text-zinc-700">已选 {selectedItems.length} 项可处理报销待办</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button type="button" variant="secondary" onClick={toggleSelectAllActionable}>
|
||||||
|
{allActionableSelected ? "取消全选" : "全选可处理项"}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => openBatchDialog("approve")}>
|
||||||
|
批量通过
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="destructive" onClick={() => openBatchDialog("reject")}>
|
||||||
|
批量驳回
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Dialog open={Boolean(batchDialogAction)} onOpenChange={handleCloseBatchDialog}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{batchDialogAction === "approve" ? "批量通过报销待办" : "批量驳回报销待办"}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{batchDialogAction === "approve"
|
||||||
|
? "统一意见可空。所有选中待办会按当前顺序逐条调用在线审批接口。"
|
||||||
|
: "驳回必须填写统一理由,所有选中待办将复用这条说明。"}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-2xl border border-zinc-200 bg-zinc-50 p-4 text-sm text-zinc-700">
|
||||||
|
共 {selectedItems.length} 项:
|
||||||
|
<div className="mt-2 max-h-40 space-y-1 overflow-auto">
|
||||||
|
{selectedItems.map((item) => (
|
||||||
|
<div key={item.id}>{item.formNumber ?? item.id}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-zinc-900" htmlFor="reimbursements-batch-comment">
|
||||||
|
{batchDialogAction === "reject" ? "统一驳回理由" : "统一审批意见"}
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
id="reimbursements-batch-comment"
|
||||||
|
value={batchComment}
|
||||||
|
onChange={(event) => setBatchComment(event.target.value)}
|
||||||
|
placeholder={batchDialogAction === "reject" ? "请输入批量驳回理由" : "可选填写统一审批意见"}
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="secondary" type="button" onClick={() => handleCloseBatchDialog(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={batchDialogAction === "reject" ? "destructive" : "default"}
|
||||||
|
disabled={batchMutation.isPending || (batchDialogAction === "reject" && !batchComment.trim())}
|
||||||
|
onClick={submitBatchAction}
|
||||||
|
>
|
||||||
|
{batchMutation.isPending
|
||||||
|
? "处理中..."
|
||||||
|
: batchDialogAction === "approve"
|
||||||
|
? "确认批量通过"
|
||||||
|
: "确认批量驳回"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={Boolean(batchResult)} onOpenChange={(open) => !open && setBatchResult(null)}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{batchResult?.action === "approve" ? "批量通过结果" : "批量驳回结果"}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{batchResult
|
||||||
|
? `成功 ${batchResult.summary.approved ?? 0},驳回 ${batchResult.summary.rejected ?? 0},跳过 ${batchResult.summary.skipped},失败 ${batchResult.summary.errored}`
|
||||||
|
: "查看批量操作逐条执行结果。"}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="max-h-[60vh] space-y-2 overflow-auto rounded-2xl border border-zinc-200 bg-zinc-50 p-4">
|
||||||
|
{batchResult?.results.map((item) => (
|
||||||
|
<div key={`${item.taskId}-${item.outcome}`} className="rounded-xl border border-zinc-200 bg-white px-4 py-3">
|
||||||
|
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-zinc-900">{item.formNumber ?? item.reimbursementId ?? "-"}</div>
|
||||||
|
<div className="text-xs text-zinc-500">taskId: {item.taskId}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium text-zinc-700">
|
||||||
|
{formatBatchOutcome(batchResult.action, item.outcome)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{item.reason ? <div className="mt-2 text-sm text-red-600">{item.reason}</div> : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" onClick={() => setBatchResult(null)}>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</ListWorkspace>
|
</ListWorkspace>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import {
|
|||||||
TodosPage,
|
TodosPage,
|
||||||
PurchaseRequestsPage,
|
PurchaseRequestsPage,
|
||||||
ReimbursementDetailPage,
|
ReimbursementDetailPage,
|
||||||
|
ReimbursementDashboardPage,
|
||||||
ReimbursementsPage,
|
ReimbursementsPage,
|
||||||
TravelRequestsPage,
|
TravelRequestsPage,
|
||||||
UserManagementPage,
|
UserManagementPage,
|
||||||
@@ -147,6 +148,15 @@ export function AppRouter() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="reimbursements/dashboard"
|
||||||
|
element={
|
||||||
|
<RequirePermission anyOf={["expense.view_all"]}>
|
||||||
|
{withRouteFallback(<ReimbursementDashboardPage />)}
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="reimbursements/:reimbursementId"
|
path="reimbursements/:reimbursementId"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { ReimbursementDashboardPage as default } from "../../pages/ReimbursementDashboardPage";
|
||||||
@@ -40,6 +40,7 @@ export const ProjectDetailPage = lazy(() => import("./ProjectDetailPage"));
|
|||||||
export const ContractsRegistryPage = lazy(() => import("./ContractsRegistryPage"));
|
export const ContractsRegistryPage = lazy(() => import("./ContractsRegistryPage"));
|
||||||
export const PurchaseRequestsPage = lazy(() => import("./PurchaseRequestsPage"));
|
export const PurchaseRequestsPage = lazy(() => import("./PurchaseRequestsPage"));
|
||||||
export const ReimbursementDetailPage = lazy(() => import("./ReimbursementDetailPage"));
|
export const ReimbursementDetailPage = lazy(() => import("./ReimbursementDetailPage"));
|
||||||
|
export const ReimbursementDashboardPage = lazy(() => import("./ReimbursementDashboardPage"));
|
||||||
export const ReimbursementsPage = lazy(() => import("./ReimbursementsPage"));
|
export const ReimbursementsPage = lazy(() => import("./ReimbursementsPage"));
|
||||||
export const TravelRequestsPage = lazy(() => import("./TravelRequestsPage"));
|
export const TravelRequestsPage = lazy(() => import("./TravelRequestsPage"));
|
||||||
export const UserManagementPage = lazy(() => import("./UserManagementPage"));
|
export const UserManagementPage = lazy(() => import("./UserManagementPage"));
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
|
||||||
|
"A1","R55 执行合同建档与范围冻结","创建并校验 R55 报销统计仪表盘与批量审批执行合同 CSV,冻结范围仅包含报销统计四端点、报销仪表盘页面、报销批量审批/驳回端点与列表批量交互;明确本轮不新增 Prisma 迁移、不修改报销状态机、不改变现有单条 approve/reject 行为。","issues/2026-04-08_r55-reimbursement-enhancements.csv 列完整、Dependencies 为 JSON 数组字符串、Test_Method 可复现;范围仅覆盖 R55 A1-A6,不混入报销状态机调整、工作流核心逻辑重写、离线批处理或 Prisma schema 变更。","命令:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-04-08_r55-reimbursement-enhancements.csv。检查:1) 对照 AGENTS.md 与 issues/README.md 确认 12 列完整;2) A2-A6 依赖链符合 backend/frontend→reviewer→qa 收口;3) 方案明确批量接口仅处理 reimbursement 任务,仪表盘默认时间窗为当前月往前 2 个自然月的 1 号到今天。","manual","DONE","DONE","DONE","issues/2026-04-08_r55-reimbursement-enhancements.csv","[]","回滚路径:删除本次 CSV 不影响业务。2026-04-08:Issue CSV 已创建并通过 validate_issues_csv.py 校验,范围与依赖已冻结。"
|
||||||
|
"A2","报销统计四端点与聚合单测","在 reimbursements.js 新增 summary、trend、top-expense-types、approval-bottleneck 四个统计端点,并补 route 单测覆盖时间/部门筛选、空数据和权限边界。","4 个统计端点均要求 expense.view_all 权限并沿用现有本人/下属范围过滤;summary 返回 totalCount、totalAmount、零补齐 byStatus 与 avgProcessingDays;trend 用 SQL date_trunc 聚合返回 count/amount/paidCount/paidAmount;top-expense-types 返回费用类型前 N;approval-bottleneck 返回 reviewing 中待办审批人聚合且角色分配任务可用 assigneeName 兜底。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && npx prisma validate && node --check src/routes/reimbursements.js && npm test -- tests/unit/routes/reimbursements.stats.test.js。业务:1) 使用 dateFrom/dateTo/departmentId 可筛出不同统计结果;2) 无数据时 byStatus 零补齐、avgProcessingDays 为 null、其他列表为空;3) 非 expense.view_all 用户访问返回 403。","manual","DONE","DONE","DONE","server/src/routes/reimbursements.js|server/tests/unit/routes/reimbursements.stats.test.js","[""A1""]","回滚路径:移除统计端点与对应单测,不影响既有报销 CRUD。2026-04-08:4 个统计端点与 reimbursements.stats.test.js 已完成,prisma validate、node --check、路由单测通过。"
|
||||||
|
"A3","报销统计仪表盘页面与路由入口","新增 ReimbursementDashboardPage、前端统计 API、懒加载路由,并在 ReimbursementsPage 顶部加入统计分析入口。","/reimbursements/dashboard 仅 expense.view_all 用户可访问;页面包含近 3 个自然月默认时间窗、部门筛选、刷新按钮、4 张摘要卡片、按月趋势双 bar、Top 10 费用类型排行和审批瓶颈列表;切换筛选后 4 组查询联动刷新,空数据有明确空态。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build。业务:1) 财务用户从报销列表点击“统计分析”进入仪表盘;2) 默认看到近 3 个月摘要、趋势、排行、瓶颈;3) 切换日期与部门后数据刷新且平均处理天数无样本时显示 -。","manual","DONE","DONE","DONE","client/src/pages/ReimbursementDashboardPage.tsx|client/src/api/reimbursements.ts|client/src/pages/ReimbursementsPage.tsx|client/src/router/index.tsx|client/src/router/lazy/ReimbursementDashboardPage.ts|client/src/router/lazy/index.ts","[""A1"",""A2""]","回滚路径:移除 dashboard 路由/页面/API 与报销列表入口按钮。2026-04-08:仪表盘页面、懒加载路由、统计分析入口与前端统计 API 已完成,client typecheck/build 通过。"
|
||||||
|
"A4","报销批量审批与驳回端点","在 workflow.js 抽出单任务 approve/reject 共享 helper,并新增 batch-approve 与 batch-reject 端点,限制仅处理 reimbursement 任务且每个 task 独立事务执行。","POST /api/workflow/batch-approve 与 /api/workflow/batch-reject 仅 expense.approve 可访问,taskIds 上限 50;仅处理 reimbursement 的 pending 任务;每个 task 复用单条审批/驳回逻辑与通知副作用;已非 pending 返回 skipped;单条失败只记 error,不回滚整批。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && node --check src/routes/workflow.js && npm test -- tests/unit/routes/workflow.batch.test.js。业务:1) 批量通过可同时处理多条报销待办;2) 批量驳回必须带 comment;3) 混入已处理 task 返回 skipped;4) 混入非 reimbursement task 返回 error;5) 超过 50 条返回 400。","manual","DONE","DONE","DONE","server/src/routes/workflow.js|server/tests/unit/routes/workflow.batch.test.js","[""A1""]","回滚路径:移除 batch 端点并保留共享 helper 给单条端点,或整体回退至改单前版本。2026-04-08:共享 approve/reject helper 与 batch 端点已完成,workflow.batch.test.js、node --check 通过。"
|
||||||
|
"A5","报销列表批量模式与结果回显","增强 ReimbursementsPage 的 reviewing/all 视图,支持基于当前用户可操作 pendingWorkflow 的多选批量通过/驳回、确认弹窗与结果弹窗。","当 status=reviewing 且 scope=all 且用户有 expense.approve 且列表中存在可操作 pendingWorkflow 时,页面可进入批量模式;仅可勾选有 pendingWorkflow.task.id 的行;批量通过意见可空、批量驳回理由必填;结果弹窗按报销单逐条展示 formNumber、taskId、outcome、reason,关闭后刷新列表。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build。业务:1) 经理在 reviewing/all 视图进入批量模式只能勾选自己可处理的报销;2) 批量通过后已处理项消失或状态更新;3) 批量驳回无理由不可提交;4) 批量结果弹窗能区分 approved/skipped/error。","manual","DONE","DONE","DONE","client/src/pages/ReimbursementsPage.tsx|client/src/api/workflow.ts|client/src/api/reimbursements.ts","[""A1"",""A4""]","回滚路径:移除报销列表批量模式,恢复单条详情页处理。2026-04-08:ReimbursementsPage 批量模式、确认弹窗、结果弹窗与批量 API 接入已完成,client typecheck/build 通过。"
|
||||||
|
"A6","Reviewer、QA、状态回填与提交收口","执行 reviewer 自检、post-check 与业务回归,回填 CSV 所有状态并完成提交收口。","review 结论覆盖统计端点只读聚合、批量端点复用单条逻辑、单 task 失败不回滚整批、50 条上限有效、统计依赖现有索引、无 Prisma 迁移;post-check 与业务回归均通过;issues/2026-04-08_r55-reimbursement-enhancements.csv 的 Dev_Status/Review1_Status/Regression_Status 依据实测结果回填。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && npx prisma validate && node --check src/routes/reimbursements.js && node --check src/routes/workflow.js && npm test -- tests/unit/routes/reimbursements.stats.test.js tests/unit/routes/workflow.batch.test.js && cd ../client && npm run typecheck && npm run build。业务:1) 财务用户可访问仪表盘并看到摘要、趋势、排行、瓶颈;2) 经理可在报销列表完成批量通过/驳回;3) reviewer 必查点均有结论;4) QA 后状态已回填。","manual","DONE","DONE","DONE","issues/2026-04-08_r55-reimbursement-enhancements.csv|server/src/routes/reimbursements.js|server/src/routes/workflow.js|server/tests/unit/routes/reimbursements.stats.test.js|server/tests/unit/routes/workflow.batch.test.js|client/src/api/reimbursements.ts|client/src/api/workflow.ts|client/src/pages/ReimbursementsPage.tsx|client/src/pages/ReimbursementDashboardPage.tsx|client/src/router/index.tsx|client/src/router/lazy/ReimbursementDashboardPage.ts|client/src/router/lazy/index.ts","[""A2"",""A3"",""A4"",""A5""]","回滚路径:若 QA 发现关键回归,暂停提交并回退对应代码。2026-04-08:reviewer 自检确认统计端点仅 SELECT 聚合、批量审批复用 executeApprove/executeReject 共享 helper、单 task 失败不回滚整批、50 条上限生效、reimbursement_date/status/workflow_tasks 现有索引可用、无 Prisma 迁移;post-check 全部通过。"
|
||||||
|
@@ -1,9 +1,11 @@
|
|||||||
|
const { Prisma } = require("@prisma/client");
|
||||||
const { z } = require("zod");
|
const { z } = require("zod");
|
||||||
const { AppError } = require("../utils/AppError");
|
const { AppError } = require("../utils/AppError");
|
||||||
const { sendData } = require("../utils/reply");
|
const { sendData } = require("../utils/reply");
|
||||||
const { buildAuditLogData } = require("../utils/audit");
|
const { buildAuditLogData } = require("../utils/audit");
|
||||||
const { currentDateString, parseDateOnlyString } = require("../utils/date");
|
const { currentDateString, parseDateOnlyString } = require("../utils/date");
|
||||||
const { findActiveDelegationForDelegate } = require("../utils/delegation");
|
const { findActiveDelegationForDelegate, listActiveDelegationPrincipals } = require("../utils/delegation");
|
||||||
|
const { hasPermission, resolveEntityPermission } = require("../constants/permissions");
|
||||||
const {
|
const {
|
||||||
createNotificationsWithRecords,
|
createNotificationsWithRecords,
|
||||||
resolveRecipientsForAssignee,
|
resolveRecipientsForAssignee,
|
||||||
@@ -60,6 +62,30 @@ const REIMBURSEMENT_ITEM_BODY_SCHEMA = z.object({
|
|||||||
description: z.string().trim().min(1).max(500),
|
description: z.string().trim().min(1).max(500),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const DATE_ONLY_QUERY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
||||||
|
const REIMBURSEMENT_STATS_QUERY_SCHEMA = z.object({
|
||||||
|
dateFrom: DATE_ONLY_QUERY_SCHEMA.optional(),
|
||||||
|
dateTo: DATE_ONLY_QUERY_SCHEMA.optional(),
|
||||||
|
departmentId: z.string().uuid().optional(),
|
||||||
|
});
|
||||||
|
const REIMBURSEMENT_TREND_QUERY_SCHEMA = REIMBURSEMENT_STATS_QUERY_SCHEMA.extend({
|
||||||
|
granularity: z.enum(["month", "week"]).optional(),
|
||||||
|
});
|
||||||
|
const REIMBURSEMENT_TOP_EXPENSE_TYPES_QUERY_SCHEMA = REIMBURSEMENT_STATS_QUERY_SCHEMA.extend({
|
||||||
|
limit: z.coerce.number().int().min(1).max(50).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const REIMBURSEMENT_STATUS_VALUES = ["draft", "reviewing", "approved", "paid", "rejected", "cancelled"];
|
||||||
|
const ROLE_LABELS = {
|
||||||
|
manager: "主管",
|
||||||
|
ceo: "CEO",
|
||||||
|
finance: "财务",
|
||||||
|
finance_mgr: "财务经理",
|
||||||
|
administration: "行政",
|
||||||
|
admin: "管理员",
|
||||||
|
hr: "HR",
|
||||||
|
};
|
||||||
|
|
||||||
function ensureOwnOrCanViewAll(fastify, request, reimbursement) {
|
function ensureOwnOrCanViewAll(fastify, request, reimbursement) {
|
||||||
const canViewAll = fastify.hasPermission(request.employee, "expense.view_all");
|
const canViewAll = fastify.hasPermission(request.employee, "expense.view_all");
|
||||||
if (canViewAll) {
|
if (canViewAll) {
|
||||||
@@ -191,6 +217,206 @@ function parseReimbursementUpdateBody(body) {
|
|||||||
return parsed.data;
|
return parsed.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseQuery(schema, query) {
|
||||||
|
const parsed = schema.safeParse(query ?? {});
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "VALID_001",
|
||||||
|
message: "参数校验失败",
|
||||||
|
statusCode: 400,
|
||||||
|
details: parsed.error.flatten(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStatsQuery(query) {
|
||||||
|
const parsed = parseQuery(REIMBURSEMENT_STATS_QUERY_SCHEMA, query);
|
||||||
|
const dateFrom = parsed.dateFrom ? parseDateOnlyString(parsed.dateFrom) : null;
|
||||||
|
const dateTo = parsed.dateTo ? parseDateOnlyString(parsed.dateTo) : null;
|
||||||
|
|
||||||
|
if (dateFrom && dateTo && dateFrom > dateTo) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "VALID_001",
|
||||||
|
message: "参数校验失败",
|
||||||
|
statusCode: 400,
|
||||||
|
details: { dateTo: "must_be_after_dateFrom" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
|
dateFromRaw: parsed.dateFrom ?? null,
|
||||||
|
dateToRaw: parsed.dateTo ?? null,
|
||||||
|
departmentId: parsed.departmentId ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTrendQuery(query) {
|
||||||
|
const parsed = parseQuery(REIMBURSEMENT_TREND_QUERY_SCHEMA, query);
|
||||||
|
const base = parseStatsQuery(parsed);
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
granularity: parsed.granularity ?? "month",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTopExpenseTypesQuery(query) {
|
||||||
|
const parsed = parseQuery(REIMBURSEMENT_TOP_EXPENSE_TYPES_QUERY_SCHEMA, query);
|
||||||
|
const base = parseStatsQuery(parsed);
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
limit: parsed.limit ?? 10,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sqlIdentifier(alias, column) {
|
||||||
|
return Prisma.raw(`"${alias}"."${column}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReimbursementStatsConditions({
|
||||||
|
request,
|
||||||
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
|
departmentId,
|
||||||
|
formAlias = "rf",
|
||||||
|
ownerAlias = "owner_employee",
|
||||||
|
}) {
|
||||||
|
const conditions = [Prisma.sql`1 = 1`];
|
||||||
|
|
||||||
|
if (dateFrom) {
|
||||||
|
conditions.push(Prisma.sql`${sqlIdentifier(formAlias, "reimbursement_date")} >= ${dateFrom}`);
|
||||||
|
}
|
||||||
|
if (dateTo) {
|
||||||
|
conditions.push(Prisma.sql`${sqlIdentifier(formAlias, "reimbursement_date")} <= ${dateTo}`);
|
||||||
|
}
|
||||||
|
if (departmentId) {
|
||||||
|
conditions.push(Prisma.sql`${sqlIdentifier(formAlias, "department_id")} = ${departmentId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isGlobalViewRole(request.employee.roleCodes ?? [])) {
|
||||||
|
conditions.push(
|
||||||
|
Prisma.sql`(
|
||||||
|
${sqlIdentifier(formAlias, "owner_id")} = ${request.employee.id}
|
||||||
|
OR ${sqlIdentifier(formAlias, "submitter_id")} = ${request.employee.id}
|
||||||
|
OR ${sqlIdentifier(ownerAlias, "direct_manager_id")} = ${request.employee.id}
|
||||||
|
)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return conditions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNumericValue(value) {
|
||||||
|
const numeric = Number(value ?? 0);
|
||||||
|
return Number.isFinite(numeric) ? numeric : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNullableNumber(value) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numeric = Number(value);
|
||||||
|
return Number.isFinite(numeric) ? numeric : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toFixedMoneyString(value) {
|
||||||
|
return toNumericValue(value).toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTrendPeriod(value, granularity) {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateValue = value instanceof Date ? value : new Date(value);
|
||||||
|
if (Number.isNaN(dateValue.getTime())) {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return granularity === "month"
|
||||||
|
? dateValue.toISOString().slice(0, 7)
|
||||||
|
: dateValue.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRoleLabel(roleCode) {
|
||||||
|
if (!roleCode) {
|
||||||
|
return "未分配";
|
||||||
|
}
|
||||||
|
|
||||||
|
return ROLE_LABELS[roleCode] ?? roleCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAllowedActionsForPendingWorkflow(request, entityType) {
|
||||||
|
const approvePermission = resolveEntityPermission(entityType, "approve");
|
||||||
|
const canApprove =
|
||||||
|
approvePermission && hasPermission(request.employee.permissionCodes ?? [], approvePermission);
|
||||||
|
|
||||||
|
if (!canApprove) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const actions = ["approve", "reject", "return"];
|
||||||
|
if (hasPermission(request.employee.permissionCodes ?? [], "expense.transfer")) {
|
||||||
|
actions.push("transfer");
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPendingWorkflowTaskActionable(request, task, delegatedPrincipalIds) {
|
||||||
|
if (!task || task.status !== "pending") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleCodes = request.employee.roleCodes ?? [];
|
||||||
|
if (!task.assignee_id && !task.assignee_role) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (task.assignee_id && task.assignee_id === request.employee.id) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (task.assignee_role && roleCodes.includes(task.assignee_role)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Boolean(task.assignee_id && delegatedPrincipalIds.includes(task.assignee_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPendingWorkflowSummary(item, request, delegatedPrincipalIds) {
|
||||||
|
const pendingInstance = item.workflow_instances?.[0] ?? null;
|
||||||
|
const pendingTask = pendingInstance?.workflow_tasks?.[0] ?? null;
|
||||||
|
if (!pendingInstance || !pendingTask) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPendingWorkflowTaskActionable(request, pendingTask, delegatedPrincipalIds)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedActions = buildAllowedActionsForPendingWorkflow(request, "reimbursement");
|
||||||
|
if (!allowedActions.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
instanceId: pendingInstance.id,
|
||||||
|
stepId: pendingInstance.current_step_id,
|
||||||
|
allowedActions,
|
||||||
|
task: {
|
||||||
|
id: pendingTask.id,
|
||||||
|
stepName: pendingTask.step_name,
|
||||||
|
assigneeId: pendingTask.assignee_id,
|
||||||
|
assigneeRole: pendingTask.assignee_role,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function parseItemBody(body) {
|
function parseItemBody(body) {
|
||||||
const parsed = REIMBURSEMENT_ITEM_BODY_SCHEMA.safeParse(body ?? {});
|
const parsed = REIMBURSEMENT_ITEM_BODY_SCHEMA.safeParse(body ?? {});
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
@@ -312,6 +538,7 @@ function mapReimbursementSummary(item) {
|
|||||||
name: item.project.name,
|
name: item.project.name,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
|
pendingWorkflow: item.pending_workflow ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -815,6 +1042,10 @@ async function reimbursementRoutes(fastify) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const skip = (query.page - 1) * query.pageSize;
|
const skip = (query.page - 1) * query.pageSize;
|
||||||
|
const delegatedPrincipalIds = await listActiveDelegationPrincipals(fastify.prisma, {
|
||||||
|
delegateEmployeeId: request.employee.id,
|
||||||
|
allowedScopes: ["all"],
|
||||||
|
});
|
||||||
const where = {
|
const where = {
|
||||||
...(query.status ? { status: query.status } : {}),
|
...(query.status ? { status: query.status } : {}),
|
||||||
...(scope === "all"
|
...(scope === "all"
|
||||||
@@ -849,6 +1080,26 @@ async function reimbursementRoutes(fastify) {
|
|||||||
submitter: {
|
submitter: {
|
||||||
select: { id: true, employee_no: true, name: true },
|
select: { id: true, employee_no: true, name: true },
|
||||||
},
|
},
|
||||||
|
workflow_instances: {
|
||||||
|
where: { current_status: "pending" },
|
||||||
|
orderBy: { created_at: "desc" },
|
||||||
|
take: 1,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
current_step_id: true,
|
||||||
|
workflow_tasks: {
|
||||||
|
where: { status: "pending" },
|
||||||
|
take: 1,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
step_name: true,
|
||||||
|
assignee_id: true,
|
||||||
|
assignee_role: true,
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
fastify.prisma.reimbursementForm.count({ where }),
|
fastify.prisma.reimbursementForm.count({ where }),
|
||||||
@@ -859,7 +1110,243 @@ async function reimbursementRoutes(fastify) {
|
|||||||
pageSize: query.pageSize,
|
pageSize: query.pageSize,
|
||||||
total,
|
total,
|
||||||
scope,
|
scope,
|
||||||
items: items.map(mapReimbursementSummary),
|
items: items.map((item) =>
|
||||||
|
mapReimbursementSummary({
|
||||||
|
...item,
|
||||||
|
pending_workflow: buildPendingWorkflowSummary(item, request, delegatedPrincipalIds),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
fastify.get(
|
||||||
|
"/reimbursements/stats/summary",
|
||||||
|
{
|
||||||
|
schema: { tags: ["Reimbursements"], summary: "Get reimbursement stats summary" },
|
||||||
|
preHandler: fastify.requirePermission("expense.view_all"),
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const query = parseStatsQuery(request.query);
|
||||||
|
const conditions = buildReimbursementStatsConditions({
|
||||||
|
request,
|
||||||
|
dateFrom: query.dateFrom,
|
||||||
|
dateTo: query.dateTo,
|
||||||
|
departmentId: query.departmentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [statusRows, avgRow] = await Promise.all([
|
||||||
|
fastify.prisma.$queryRaw(
|
||||||
|
Prisma.sql`
|
||||||
|
SELECT
|
||||||
|
rf.status::text AS status,
|
||||||
|
COUNT(*)::int AS count,
|
||||||
|
COALESCE(SUM(rf.total_amount), 0)::text AS total_amount
|
||||||
|
FROM "reimbursement_forms" rf
|
||||||
|
LEFT JOIN "employees" owner_employee ON owner_employee.id = rf.owner_id
|
||||||
|
WHERE ${Prisma.join(conditions, " AND ")}
|
||||||
|
GROUP BY rf.status
|
||||||
|
`,
|
||||||
|
),
|
||||||
|
fastify.prisma.$queryRaw(
|
||||||
|
Prisma.sql`
|
||||||
|
SELECT
|
||||||
|
ROUND(AVG(EXTRACT(EPOCH FROM (rf.paid_at - submit_info.submitted_at)) / 86400.0)::numeric, 2)::float8
|
||||||
|
AS avg_processing_days
|
||||||
|
FROM "reimbursement_forms" rf
|
||||||
|
LEFT JOIN "employees" owner_employee ON owner_employee.id = rf.owner_id
|
||||||
|
JOIN (
|
||||||
|
SELECT reimbursement_id, MAX(created_at) AS submitted_at
|
||||||
|
FROM "workflow_instances"
|
||||||
|
WHERE entity_type = 'reimbursement' AND reimbursement_id IS NOT NULL
|
||||||
|
GROUP BY reimbursement_id
|
||||||
|
) submit_info ON submit_info.reimbursement_id = rf.id
|
||||||
|
WHERE ${Prisma.join(
|
||||||
|
[
|
||||||
|
...conditions,
|
||||||
|
Prisma.sql`rf.status = 'paid'`,
|
||||||
|
Prisma.sql`rf.paid_at IS NOT NULL`,
|
||||||
|
],
|
||||||
|
" AND ",
|
||||||
|
)}
|
||||||
|
`,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const byStatus = Object.fromEntries(
|
||||||
|
REIMBURSEMENT_STATUS_VALUES.map((status) => [
|
||||||
|
status,
|
||||||
|
{
|
||||||
|
count: 0,
|
||||||
|
totalAmount: toFixedMoneyString(0),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const row of statusRows) {
|
||||||
|
byStatus[row.status] = {
|
||||||
|
count: Number(row.count ?? 0),
|
||||||
|
totalAmount: toFixedMoneyString(row.total_amount ?? 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalCount = REIMBURSEMENT_STATUS_VALUES.reduce(
|
||||||
|
(sum, status) => sum + Number(byStatus[status]?.count ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const totalAmountNumber = REIMBURSEMENT_STATUS_VALUES.reduce(
|
||||||
|
(sum, status) => sum + toNumericValue(byStatus[status]?.totalAmount),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
return sendData(reply, request, {
|
||||||
|
totalCount,
|
||||||
|
totalAmount: toFixedMoneyString(totalAmountNumber),
|
||||||
|
byStatus,
|
||||||
|
avgProcessingDays: toNullableNumber(avgRow?.[0]?.avg_processing_days),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
fastify.get(
|
||||||
|
"/reimbursements/stats/trend",
|
||||||
|
{
|
||||||
|
schema: { tags: ["Reimbursements"], summary: "Get reimbursement stats trend" },
|
||||||
|
preHandler: fastify.requirePermission("expense.view_all"),
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const query = parseTrendQuery(request.query);
|
||||||
|
const conditions = buildReimbursementStatsConditions({
|
||||||
|
request,
|
||||||
|
dateFrom: query.dateFrom,
|
||||||
|
dateTo: query.dateTo,
|
||||||
|
departmentId: query.departmentId,
|
||||||
|
});
|
||||||
|
const periodExpression =
|
||||||
|
query.granularity === "week"
|
||||||
|
? Prisma.sql`date_trunc('week', rf.reimbursement_date)`
|
||||||
|
: Prisma.sql`date_trunc('month', rf.reimbursement_date)`;
|
||||||
|
|
||||||
|
const rows = await fastify.prisma.$queryRaw(
|
||||||
|
Prisma.sql`
|
||||||
|
SELECT
|
||||||
|
${periodExpression} AS period_start,
|
||||||
|
COUNT(*)::int AS count,
|
||||||
|
COALESCE(SUM(rf.total_amount), 0)::text AS amount,
|
||||||
|
COUNT(*) FILTER (WHERE rf.status = 'paid')::int AS paid_count,
|
||||||
|
COALESCE(SUM(rf.total_amount) FILTER (WHERE rf.status = 'paid'), 0)::text AS paid_amount
|
||||||
|
FROM "reimbursement_forms" rf
|
||||||
|
LEFT JOIN "employees" owner_employee ON owner_employee.id = rf.owner_id
|
||||||
|
WHERE ${Prisma.join(conditions, " AND ")}
|
||||||
|
GROUP BY period_start
|
||||||
|
ORDER BY period_start ASC
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return sendData(reply, request, {
|
||||||
|
items: rows.map((row) => ({
|
||||||
|
period: formatTrendPeriod(row.period_start, query.granularity),
|
||||||
|
count: Number(row.count ?? 0),
|
||||||
|
amount: toFixedMoneyString(row.amount ?? 0),
|
||||||
|
paidCount: Number(row.paid_count ?? 0),
|
||||||
|
paidAmount: toFixedMoneyString(row.paid_amount ?? 0),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
fastify.get(
|
||||||
|
"/reimbursements/stats/top-expense-types",
|
||||||
|
{
|
||||||
|
schema: { tags: ["Reimbursements"], summary: "Get reimbursement top expense types" },
|
||||||
|
preHandler: fastify.requirePermission("expense.view_all"),
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const query = parseTopExpenseTypesQuery(request.query);
|
||||||
|
const conditions = buildReimbursementStatsConditions({
|
||||||
|
request,
|
||||||
|
dateFrom: query.dateFrom,
|
||||||
|
dateTo: query.dateTo,
|
||||||
|
departmentId: query.departmentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = await fastify.prisma.$queryRaw(
|
||||||
|
Prisma.sql`
|
||||||
|
SELECT
|
||||||
|
et.id::text AS expense_type_id,
|
||||||
|
et.name::text AS expense_type_name,
|
||||||
|
COUNT(ri.id)::int AS count,
|
||||||
|
COALESCE(SUM(ri.amount), 0)::text AS total_amount
|
||||||
|
FROM "reimbursement_items" ri
|
||||||
|
INNER JOIN "reimbursement_forms" rf ON rf.id = ri.reimbursement_id
|
||||||
|
LEFT JOIN "employees" owner_employee ON owner_employee.id = rf.owner_id
|
||||||
|
INNER JOIN "expense_types" et ON et.id = ri.expense_type_id
|
||||||
|
WHERE ${Prisma.join(conditions, " AND ")}
|
||||||
|
GROUP BY et.id, et.name
|
||||||
|
ORDER BY SUM(ri.amount) DESC, COUNT(ri.id) DESC, et.name ASC
|
||||||
|
LIMIT ${query.limit}
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return sendData(reply, request, {
|
||||||
|
items: rows.map((row) => ({
|
||||||
|
expenseTypeId: row.expense_type_id,
|
||||||
|
expenseTypeName: row.expense_type_name,
|
||||||
|
count: Number(row.count ?? 0),
|
||||||
|
totalAmount: toFixedMoneyString(row.total_amount ?? 0),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
fastify.get(
|
||||||
|
"/reimbursements/stats/approval-bottleneck",
|
||||||
|
{
|
||||||
|
schema: { tags: ["Reimbursements"], summary: "Get reimbursement approval bottleneck" },
|
||||||
|
preHandler: fastify.requirePermission("expense.view_all"),
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const query = parseStatsQuery(request.query);
|
||||||
|
const conditions = buildReimbursementStatsConditions({
|
||||||
|
request,
|
||||||
|
dateFrom: query.dateFrom,
|
||||||
|
dateTo: query.dateTo,
|
||||||
|
departmentId: query.departmentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = await fastify.prisma.$queryRaw(
|
||||||
|
Prisma.sql`
|
||||||
|
SELECT
|
||||||
|
wt.assignee_id::text AS assignee_id,
|
||||||
|
wt.assignee_role::text AS assignee_role,
|
||||||
|
approver.name::text AS assignee_name,
|
||||||
|
COUNT(*)::int AS pending_count,
|
||||||
|
FLOOR(MAX(EXTRACT(EPOCH FROM (NOW() - wt.created_at)) / 86400.0))::int AS oldest_pending_days
|
||||||
|
FROM "workflow_tasks" wt
|
||||||
|
INNER JOIN "workflow_instances" wi ON wi.id = wt.instance_id AND wi.entity_type = 'reimbursement'
|
||||||
|
INNER JOIN "reimbursement_forms" rf ON rf.id = wi.reimbursement_id
|
||||||
|
LEFT JOIN "employees" owner_employee ON owner_employee.id = rf.owner_id
|
||||||
|
LEFT JOIN "employees" approver ON approver.id = wt.assignee_id
|
||||||
|
WHERE ${Prisma.join(
|
||||||
|
[
|
||||||
|
...conditions,
|
||||||
|
Prisma.sql`wt.status = 'pending'`,
|
||||||
|
Prisma.sql`rf.status = 'reviewing'`,
|
||||||
|
],
|
||||||
|
" AND ",
|
||||||
|
)}
|
||||||
|
GROUP BY wt.assignee_id, wt.assignee_role, approver.name
|
||||||
|
ORDER BY pending_count DESC, oldest_pending_days DESC, approver.name ASC NULLS LAST, wt.assignee_role ASC NULLS LAST
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return sendData(reply, request, {
|
||||||
|
items: rows.map((row) => ({
|
||||||
|
assigneeId: row.assignee_id,
|
||||||
|
assigneeName: row.assignee_name ?? formatRoleLabel(row.assignee_role),
|
||||||
|
pendingCount: Number(row.pending_count ?? 0),
|
||||||
|
oldestPendingDays: Number(row.oldest_pending_days ?? 0),
|
||||||
|
})),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
+688
-473
文件差异内容过多而无法显示
加载差异
@@ -0,0 +1,226 @@
|
|||||||
|
const Fastify = require("fastify");
|
||||||
|
|
||||||
|
const reimbursementRoutes = require("../../../src/routes/reimbursements");
|
||||||
|
const { AppError } = require("../../../src/utils/AppError");
|
||||||
|
|
||||||
|
const ACTOR_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||||
|
const DEPARTMENT_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
|
||||||
|
|
||||||
|
async function buildApp(options = {}) {
|
||||||
|
const app = Fastify();
|
||||||
|
const actor = options.actor ?? {
|
||||||
|
id: ACTOR_ID,
|
||||||
|
roleCodes: ["manager"],
|
||||||
|
permissionCodes: ["expense.view_all"],
|
||||||
|
};
|
||||||
|
const queryRaw =
|
||||||
|
options.queryRaw ??
|
||||||
|
vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{ status: "draft", count: 2, total_amount: "100.00" },
|
||||||
|
{ status: "paid", count: 1, total_amount: "80.00" },
|
||||||
|
])
|
||||||
|
.mockResolvedValueOnce([{ avg_processing_days: 3.5 }]);
|
||||||
|
|
||||||
|
app.decorate("authenticate", async (request) => {
|
||||||
|
request.employee = actor;
|
||||||
|
});
|
||||||
|
app.decorate("hasPermission", (employee, permissionCode) => {
|
||||||
|
const permissionCodes = employee.permissionCodes ?? [];
|
||||||
|
return permissionCodes.includes("*") || permissionCodes.includes(permissionCode);
|
||||||
|
});
|
||||||
|
app.decorate("requirePermission", (permissionCode) => async (request) => {
|
||||||
|
request.employee = actor;
|
||||||
|
if (!app.hasPermission(actor, permissionCode)) {
|
||||||
|
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
app.decorate("config", { appTimezone: "Asia/Shanghai" });
|
||||||
|
app.decorate("prisma", {
|
||||||
|
$queryRaw: queryRaw,
|
||||||
|
});
|
||||||
|
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, queryRaw };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("reimbursements stats routes", () => {
|
||||||
|
it("returns summary with filters and zero-filled status buckets", async () => {
|
||||||
|
const { app, queryRaw } = await buildApp();
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/api/reimbursements/stats/summary?dateFrom=2026-01-01&dateTo=2026-03-31&departmentId=${DEPARTMENT_ID}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(queryRaw).toHaveBeenCalledTimes(2);
|
||||||
|
expect(queryRaw.mock.calls[0][0].values).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
new Date("2026-01-01T00:00:00.000Z"),
|
||||||
|
new Date("2026-03-31T00:00:00.000Z"),
|
||||||
|
DEPARTMENT_ID,
|
||||||
|
ACTOR_ID,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(response.json().data).toEqual({
|
||||||
|
totalCount: 3,
|
||||||
|
totalAmount: "180.00",
|
||||||
|
byStatus: {
|
||||||
|
draft: { count: 2, totalAmount: "100.00" },
|
||||||
|
reviewing: { count: 0, totalAmount: "0.00" },
|
||||||
|
approved: { count: 0, totalAmount: "0.00" },
|
||||||
|
paid: { count: 1, totalAmount: "80.00" },
|
||||||
|
rejected: { count: 0, totalAmount: "0.00" },
|
||||||
|
cancelled: { count: 0, totalAmount: "0.00" },
|
||||||
|
},
|
||||||
|
avgProcessingDays: 3.5,
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty summary when there is no matching data", async () => {
|
||||||
|
const { app } = await buildApp({
|
||||||
|
queryRaw: vi.fn().mockResolvedValueOnce([]).mockResolvedValueOnce([{ avg_processing_days: null }]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/reimbursements/stats/summary",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json().data).toMatchObject({
|
||||||
|
totalCount: 0,
|
||||||
|
totalAmount: "0.00",
|
||||||
|
avgProcessingDays: null,
|
||||||
|
byStatus: {
|
||||||
|
draft: { count: 0, totalAmount: "0.00" },
|
||||||
|
reviewing: { count: 0, totalAmount: "0.00" },
|
||||||
|
approved: { count: 0, totalAmount: "0.00" },
|
||||||
|
paid: { count: 0, totalAmount: "0.00" },
|
||||||
|
rejected: { count: 0, totalAmount: "0.00" },
|
||||||
|
cancelled: { count: 0, totalAmount: "0.00" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns monthly trends with count and paid metrics", async () => {
|
||||||
|
const { app } = await buildApp({
|
||||||
|
queryRaw: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
period_start: new Date("2026-02-01T00:00:00.000Z"),
|
||||||
|
count: 4,
|
||||||
|
amount: "220.00",
|
||||||
|
paid_count: 2,
|
||||||
|
paid_amount: "120.00",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/reimbursements/stats/trend?granularity=month",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json().data.items).toEqual([
|
||||||
|
{
|
||||||
|
period: "2026-02",
|
||||||
|
count: 4,
|
||||||
|
amount: "220.00",
|
||||||
|
paidCount: 2,
|
||||||
|
paidAmount: "120.00",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns top expense types and approval bottlenecks", async () => {
|
||||||
|
const queryRaw = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
expense_type_id: "type-1",
|
||||||
|
expense_type_name: "差旅交通",
|
||||||
|
count: 5,
|
||||||
|
total_amount: "520.00",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
assignee_id: null,
|
||||||
|
assignee_role: "manager",
|
||||||
|
assignee_name: null,
|
||||||
|
pending_count: 3,
|
||||||
|
oldest_pending_days: 5,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const { app } = await buildApp({ queryRaw });
|
||||||
|
|
||||||
|
const topResponse = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/reimbursements/stats/top-expense-types?limit=5",
|
||||||
|
});
|
||||||
|
const bottleneckResponse = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/reimbursements/stats/approval-bottleneck",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(topResponse.statusCode).toBe(200);
|
||||||
|
expect(topResponse.json().data.items).toEqual([
|
||||||
|
{
|
||||||
|
expenseTypeId: "type-1",
|
||||||
|
expenseTypeName: "差旅交通",
|
||||||
|
count: 5,
|
||||||
|
totalAmount: "520.00",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(bottleneckResponse.statusCode).toBe(200);
|
||||||
|
expect(bottleneckResponse.json().data.items).toEqual([
|
||||||
|
{
|
||||||
|
assigneeId: null,
|
||||||
|
assigneeName: "主管",
|
||||||
|
pendingCount: 3,
|
||||||
|
oldestPendingDays: 5,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects users without expense.view_all permission", async () => {
|
||||||
|
const { app } = await buildApp({
|
||||||
|
actor: {
|
||||||
|
id: ACTOR_ID,
|
||||||
|
roleCodes: ["employee"],
|
||||||
|
permissionCodes: ["expense.view_own"],
|
||||||
|
},
|
||||||
|
queryRaw: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/reimbursements/stats/summary",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(403);
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
const Fastify = require("fastify");
|
||||||
|
|
||||||
|
const workflowRoutes = require("../../../src/routes/workflow");
|
||||||
|
const { AppError } = require("../../../src/utils/AppError");
|
||||||
|
|
||||||
|
const ACTOR_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||||
|
|
||||||
|
function buildTask(taskId, overrides = {}) {
|
||||||
|
return {
|
||||||
|
id: taskId,
|
||||||
|
instance_id: taskId,
|
||||||
|
step_id: "approval_1_manager",
|
||||||
|
step_name: "经理审批",
|
||||||
|
assignee_id: ACTOR_ID,
|
||||||
|
assignee_role: null,
|
||||||
|
status: "pending",
|
||||||
|
outcome: null,
|
||||||
|
comment: null,
|
||||||
|
created_at: new Date("2026-04-08T08:00:00.000Z"),
|
||||||
|
completed_at: null,
|
||||||
|
completed_by: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWorkflowInstance(taskId, entityType = "reimbursement") {
|
||||||
|
return {
|
||||||
|
id: taskId,
|
||||||
|
entity_type: entityType,
|
||||||
|
entity_id: taskId,
|
||||||
|
current_step_id: "approval_1_manager",
|
||||||
|
context_data: {},
|
||||||
|
workflow_definition: {
|
||||||
|
definition_json: {
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
id: "approval_1_manager",
|
||||||
|
name: "经理审批",
|
||||||
|
assignee_rule: { type: "role", value: "manager" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildApp(options = {}) {
|
||||||
|
const app = Fastify();
|
||||||
|
const actor = options.actor ?? {
|
||||||
|
id: ACTOR_ID,
|
||||||
|
roleCodes: ["manager"],
|
||||||
|
permissionCodes: ["expense.approve"],
|
||||||
|
};
|
||||||
|
const state = {
|
||||||
|
tasks: new Map(
|
||||||
|
(options.tasks ?? [
|
||||||
|
buildTask("11111111-1111-4111-8111-111111111111"),
|
||||||
|
buildTask("22222222-2222-4222-8222-222222222222", { status: "completed", outcome: "approved" }),
|
||||||
|
buildTask("33333333-3333-4333-8333-333333333333"),
|
||||||
|
buildTask("44444444-4444-4444-8444-444444444444"),
|
||||||
|
]).map((item) => [item.id, { ...item }]),
|
||||||
|
),
|
||||||
|
instances: new Map(
|
||||||
|
(options.instances ?? [
|
||||||
|
buildWorkflowInstance("11111111-1111-4111-8111-111111111111", "reimbursement"),
|
||||||
|
buildWorkflowInstance("22222222-2222-4222-8222-222222222222", "reimbursement"),
|
||||||
|
buildWorkflowInstance("33333333-3333-4333-8333-333333333333", "leave"),
|
||||||
|
buildWorkflowInstance("44444444-4444-4444-8444-444444444444", "reimbursement"),
|
||||||
|
]).map((item) => [item.id, { ...item }]),
|
||||||
|
),
|
||||||
|
history: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const tx = {
|
||||||
|
$queryRaw: vi.fn(async (_strings, ...values) => {
|
||||||
|
const taskId = String(values[0] ?? "");
|
||||||
|
const task = state.tasks.get(taskId);
|
||||||
|
return task ? [{ ...task }] : [];
|
||||||
|
}),
|
||||||
|
workflowInstance: {
|
||||||
|
findUnique: vi.fn(async ({ where }) => {
|
||||||
|
return state.instances.get(String(where.id)) ?? null;
|
||||||
|
}),
|
||||||
|
update: vi.fn(async ({ where, data }) => {
|
||||||
|
const current = state.instances.get(String(where.id));
|
||||||
|
if (!current) return null;
|
||||||
|
state.instances.set(String(where.id), { ...current, ...data });
|
||||||
|
return state.instances.get(String(where.id));
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
workflowTask: {
|
||||||
|
update: vi.fn(async ({ where, data }) => {
|
||||||
|
if (options.updateErrorTaskId && where.id === options.updateErrorTaskId) {
|
||||||
|
throw new Error("update_failed");
|
||||||
|
}
|
||||||
|
const current = state.tasks.get(where.id);
|
||||||
|
if (!current) return null;
|
||||||
|
state.tasks.set(where.id, { ...current, ...data });
|
||||||
|
return state.tasks.get(where.id);
|
||||||
|
}),
|
||||||
|
updateMany: vi.fn(async () => ({ count: 0 })),
|
||||||
|
create: vi.fn(async () => {
|
||||||
|
throw new Error("unexpected_create");
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
workflowHistory: {
|
||||||
|
create: vi.fn(async ({ data }) => {
|
||||||
|
state.history.push(data);
|
||||||
|
return { id: `history-${state.history.length}` };
|
||||||
|
}),
|
||||||
|
count: vi.fn(async () => 0),
|
||||||
|
},
|
||||||
|
reimbursementForm: {
|
||||||
|
update: vi.fn(async () => ({})),
|
||||||
|
findUnique: vi.fn(async () => null),
|
||||||
|
},
|
||||||
|
leaveRequest: {
|
||||||
|
findUnique: vi.fn(async () => null),
|
||||||
|
update: vi.fn(async () => ({})),
|
||||||
|
},
|
||||||
|
leaveLedger: {
|
||||||
|
findFirst: vi.fn(async () => null),
|
||||||
|
},
|
||||||
|
purchaseRequest: {
|
||||||
|
update: vi.fn(async () => ({})),
|
||||||
|
},
|
||||||
|
travelRequest: {
|
||||||
|
update: vi.fn(async () => ({})),
|
||||||
|
},
|
||||||
|
notificationChannel: {
|
||||||
|
findMany: vi.fn(async () => []),
|
||||||
|
},
|
||||||
|
employee: {
|
||||||
|
findMany: vi.fn(async () => []),
|
||||||
|
findUnique: vi.fn(async () => ({
|
||||||
|
id: "target",
|
||||||
|
status: "active",
|
||||||
|
roles: [{ role_code: "manager" }],
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
notificationDelivery: {
|
||||||
|
create: vi.fn(async () => ({ id: "delivery-1" })),
|
||||||
|
update: vi.fn(async () => ({})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const prisma = {
|
||||||
|
$transaction: vi.fn(async (callback) => callback(tx)),
|
||||||
|
};
|
||||||
|
|
||||||
|
app.decorate("prisma", prisma);
|
||||||
|
app.decorate("config", {
|
||||||
|
imNotifierEnabled: false,
|
||||||
|
emailEnabled: false,
|
||||||
|
});
|
||||||
|
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("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, state, tx };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("workflow batch routes", () => {
|
||||||
|
it("approves reimbursement tasks and skips already processed ones", async () => {
|
||||||
|
const { app, prisma, state } = await buildApp();
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/workflow/batch-approve",
|
||||||
|
payload: {
|
||||||
|
taskIds: [
|
||||||
|
"11111111-1111-4111-8111-111111111111",
|
||||||
|
"22222222-2222-4222-8222-222222222222",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(prisma.$transaction).toHaveBeenCalledTimes(2);
|
||||||
|
expect(response.json().data).toEqual({
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
taskId: "11111111-1111-4111-8111-111111111111",
|
||||||
|
outcome: "approved",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
taskId: "22222222-2222-4222-8222-222222222222",
|
||||||
|
outcome: "skipped",
|
||||||
|
reason: "already_processed",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
summary: {
|
||||||
|
approved: 1,
|
||||||
|
rejected: 0,
|
||||||
|
skipped: 1,
|
||||||
|
errored: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(state.tasks.get("11111111-1111-4111-8111-111111111111").status).toBe("completed");
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects reimbursement tasks with required shared comment", async () => {
|
||||||
|
const { app, state } = await buildApp();
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/workflow/batch-reject",
|
||||||
|
payload: {
|
||||||
|
taskIds: ["44444444-4444-4444-8444-444444444444"],
|
||||||
|
comment: "材料不完整,请补充说明后重新提交。",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json().data).toEqual({
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
taskId: "44444444-4444-4444-8444-444444444444",
|
||||||
|
outcome: "rejected",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
summary: {
|
||||||
|
approved: 0,
|
||||||
|
rejected: 1,
|
||||||
|
skipped: 0,
|
||||||
|
errored: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(state.tasks.get("44444444-4444-4444-8444-444444444444").status).toBe("rejected");
|
||||||
|
|
||||||
|
const invalidResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/workflow/batch-reject",
|
||||||
|
payload: {
|
||||||
|
taskIds: ["44444444-4444-4444-8444-444444444444"],
|
||||||
|
comment: " ",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(invalidResponse.statusCode).toBe(400);
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not roll back successful tasks when one task fails or has unsupported entity type", async () => {
|
||||||
|
const { app, state } = await buildApp({
|
||||||
|
updateErrorTaskId: "44444444-4444-4444-8444-444444444444",
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/workflow/batch-approve",
|
||||||
|
payload: {
|
||||||
|
taskIds: [
|
||||||
|
"11111111-1111-4111-8111-111111111111",
|
||||||
|
"33333333-3333-4333-8333-333333333333",
|
||||||
|
"44444444-4444-4444-8444-444444444444",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json().data).toEqual({
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
taskId: "11111111-1111-4111-8111-111111111111",
|
||||||
|
outcome: "approved",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
taskId: "33333333-3333-4333-8333-333333333333",
|
||||||
|
outcome: "error",
|
||||||
|
reason: "VALID_001: 参数校验失败",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
taskId: "44444444-4444-4444-8444-444444444444",
|
||||||
|
outcome: "error",
|
||||||
|
reason: "update_failed",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
summary: {
|
||||||
|
approved: 1,
|
||||||
|
rejected: 0,
|
||||||
|
skipped: 0,
|
||||||
|
errored: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(state.tasks.get("11111111-1111-4111-8111-111111111111").status).toBe("completed");
|
||||||
|
expect(state.tasks.get("44444444-4444-4444-8444-444444444444").status).toBe("pending");
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects oversized batch payloads and missing permissions", async () => {
|
||||||
|
const { app } = await buildApp();
|
||||||
|
const oversizedPayload = {
|
||||||
|
taskIds: Array.from({ length: 51 }, (_, index) =>
|
||||||
|
`00000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}`,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const invalidResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/workflow/batch-approve",
|
||||||
|
payload: oversizedPayload,
|
||||||
|
});
|
||||||
|
expect(invalidResponse.statusCode).toBe(400);
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
|
||||||
|
const noPermission = await buildApp({
|
||||||
|
actor: {
|
||||||
|
id: ACTOR_ID,
|
||||||
|
roleCodes: ["employee"],
|
||||||
|
permissionCodes: ["expense.view_own"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const forbiddenResponse = await noPermission.app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/workflow/batch-approve",
|
||||||
|
payload: {
|
||||||
|
taskIds: ["11111111-1111-4111-8111-111111111111"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(forbiddenResponse.statusCode).toBe(403);
|
||||||
|
|
||||||
|
await noPermission.app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
在新工单中引用
屏蔽一个用户