R35-2 审批通知补结构化摘要

这个提交包含在:
2026-03-25 11:38:00 +08:00
父节点 a962f4b074
当前提交 6fba13573e
共修改 12 个文件,包含 1243 行新增和 266 行删除
+2
查看文件
@@ -88,6 +88,8 @@ export type LeaveListQuery = {
scope?: "own" | "all";
page?: number;
pageSize?: number;
dateFrom?: string;
dateTo?: string;
};
function toQueryString(query: Record<string, string | number | undefined>) {
+381 -134
查看文件
@@ -9,6 +9,7 @@ import {
fetchLeaveTypes,
submitLeaveRequest,
withdrawLeaveRequest,
type LeaveRequestItem,
type LeaveRequestStatus,
type LeaveSession,
} from "../api/leave";
@@ -29,8 +30,109 @@ import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
import { Textarea } from "../components/ui/textarea";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs";
const PAGE_SIZE = 10;
const CALENDAR_PAGE_SIZE = 100;
const WEEKDAY_LABELS = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"];
type LeaveCalendarMode = "week" | "month";
type LeavePageView = "records" | "calendar";
function startOfUtcDay(value: Date) {
return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate()));
}
function addUtcDays(value: Date, days: number) {
const next = startOfUtcDay(value);
next.setUTCDate(next.getUTCDate() + days);
return next;
}
function startOfUtcWeek(value: Date) {
const normalized = startOfUtcDay(value);
const weekday = normalized.getUTCDay();
const offset = weekday === 0 ? -6 : 1 - weekday;
return addUtcDays(normalized, offset);
}
function startOfUtcMonth(value: Date) {
return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), 1));
}
function endOfUtcMonth(value: Date) {
return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth() + 1, 0));
}
function toDateKey(value: Date) {
return startOfUtcDay(value).toISOString().slice(0, 10);
}
function isSameUtcMonth(left: Date, right: Date) {
return left.getUTCFullYear() === right.getUTCFullYear() && left.getUTCMonth() === right.getUTCMonth();
}
function formatCalendarRequestedDays(value: number) {
return Number.isInteger(value) ? String(value) : value.toFixed(1);
}
function buildCalendarWindow(anchorDate: Date, mode: LeaveCalendarMode) {
const safeAnchor = startOfUtcDay(anchorDate);
if (mode === "week") {
const start = startOfUtcWeek(safeAnchor);
const dates = Array.from({ length: 7 }, (_, index) => addUtcDays(start, index));
return {
title: `${toDateKey(start)} ~ ${toDateKey(dates[dates.length - 1])}`,
dates,
queryFrom: toDateKey(start),
queryTo: toDateKey(dates[dates.length - 1]),
anchorMonth: safeAnchor,
};
}
const monthStart = startOfUtcMonth(safeAnchor);
const monthEnd = endOfUtcMonth(safeAnchor);
const gridStart = startOfUtcWeek(monthStart);
const gridEnd = addUtcDays(startOfUtcWeek(monthEnd), 6);
const dates: Date[] = [];
for (let cursor = gridStart; cursor <= gridEnd; cursor = addUtcDays(cursor, 1)) {
dates.push(cursor);
}
return {
title: `${safeAnchor.getUTCFullYear()}年${safeAnchor.getUTCMonth() + 1}月`,
dates,
queryFrom: toDateKey(gridStart),
queryTo: toDateKey(gridEnd),
anchorMonth: monthStart,
};
}
function leaveRequestIntersectsDate(item: LeaveRequestItem, dateKey: string) {
return item.startDate <= dateKey && item.endDate >= dateKey;
}
function formatCalendarEntryLabel(item: LeaveRequestItem, dateKey: string) {
const ownerName = item.owner?.name ?? "未知成员";
const leaveTypeName = item.leaveType?.name ?? "请假";
if (item.startDate === item.endDate) {
if (item.startSession === item.endSession) {
return `${ownerName} · ${leaveTypeName} · ${item.startSession === "am" ? "上午" : "下午"}`;
}
return `${ownerName} · ${leaveTypeName} · 全天`;
}
if (item.startDate === dateKey) {
return `${ownerName} · ${leaveTypeName} · ${item.startSession === "pm" ? "下午开始" : "开始"}`;
}
if (item.endDate === dateKey) {
return `${ownerName} · ${leaveTypeName} · ${item.endSession === "am" ? "上午结束" : "结束"}`;
}
return `${ownerName} · ${leaveTypeName}`;
}
function resolveError(error: unknown) {
if (isApiError(error)) {
@@ -90,6 +192,9 @@ export function LeaveRequestPage() {
const [statusFilter, setStatusFilter] = useState<LeaveRequestStatus | "">("");
const [scope, setScope] = useState<"own" | "all">("own");
const [page, setPage] = useState(1);
const [pageView, setPageView] = useState<LeavePageView>("records");
const [calendarMode, setCalendarMode] = useState<LeaveCalendarMode>("week");
const [calendarAnchorDate] = useState(() => new Date());
const [leaveTypeId, setLeaveTypeId] = useState("");
const [startDate, setStartDate] = useState("");
@@ -128,6 +233,24 @@ export function LeaveRequestPage() {
}),
});
const calendarWindow = useMemo(
() => buildCalendarWindow(calendarAnchorDate, calendarMode),
[calendarAnchorDate, calendarMode],
);
const calendarRequestsQuery = useQuery({
queryKey: ["leave", "calendar", calendarMode, calendarWindow.queryFrom, calendarWindow.queryTo],
enabled: canViewAll && pageView === "calendar",
queryFn: () =>
fetchLeaveRequests({
scope: "all",
page: 1,
pageSize: CALENDAR_PAGE_SIZE,
dateFrom: calendarWindow.queryFrom,
dateTo: calendarWindow.queryTo,
}),
});
const selectedLeaveType = useMemo(() => {
return (leaveTypesQuery.data?.items ?? []).find((item) => item.id === leaveTypeId) ?? null;
}, [leaveTypeId, leaveTypesQuery.data?.items]);
@@ -144,6 +267,19 @@ export function LeaveRequestPage() {
);
}, [balancesQuery.data?.items]);
const calendarItemsByDate = useMemo(() => {
const items = calendarRequestsQuery.data?.items ?? [];
return new Map(
calendarWindow.dates.map((date) => {
const dateKey = toDateKey(date);
return [
dateKey,
items.filter((item) => leaveRequestIntersectsDate(item, dateKey)),
] as const;
}),
);
}, [calendarRequestsQuery.data?.items, calendarWindow.dates]);
const createMutation = useMutation({
mutationFn: async () => {
if (!leaveTypeId) {
@@ -182,6 +318,7 @@ export function LeaveRequestPage() {
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["leave", "requests"] }),
queryClient.invalidateQueries({ queryKey: ["leave", "calendar"] }),
queryClient.invalidateQueries({ queryKey: ["leave", "balances"] }),
]);
setLeaveTypeId("");
@@ -206,6 +343,7 @@ export function LeaveRequestPage() {
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["leave", "requests"] }),
queryClient.invalidateQueries({ queryKey: ["leave", "calendar"] }),
queryClient.invalidateQueries({ queryKey: ["leave", "balances"] }),
]);
toast.success("请假申请已提交");
@@ -220,6 +358,7 @@ export function LeaveRequestPage() {
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["leave", "requests"] }),
queryClient.invalidateQueries({ queryKey: ["leave", "calendar"] }),
queryClient.invalidateQueries({ queryKey: ["leave", "balances"] }),
]);
toast.success("请假申请已撤回");
@@ -231,6 +370,232 @@ export function LeaveRequestPage() {
});
const totalPages = Math.max(1, Math.ceil((requestsQuery.data?.total ?? 0) / PAGE_SIZE));
const calendarIsTruncated = (calendarRequestsQuery.data?.total ?? 0) > (calendarRequestsQuery.data?.items.length ?? 0);
const leaveRecordsPanel = (
<SectionPanel title="请假记录" description="支持按状态和范围收敛列表,草稿与驳回单可再次提交。">
<div className="space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="text-sm text-zinc-500">最近一次筛选会保留在当前页面状态中。</div>
<div className="flex flex-wrap gap-2">
{canViewAll ? (
<select
className="select-std"
value={scope}
onChange={(event) => {
setScope(event.target.value as "own" | "all");
setPage(1);
}}
>
<option value="own">只看我的</option>
<option value="all">查看全员</option>
</select>
) : null}
<select
className="select-std"
value={statusFilter}
onChange={(event) => {
setStatusFilter(event.target.value as LeaveRequestStatus | "");
setPage(1);
}}
>
<option value="">全部状态</option>
<option value="draft">草稿</option>
<option value="reviewing">审批中</option>
<option value="approved">已通过</option>
<option value="rejected">已驳回</option>
<option value="cancelled">已取消</option>
</select>
<Button type="button" variant="secondary" onClick={() => queryClient.invalidateQueries({ queryKey: ["leave", "requests"] })}>
刷新
</Button>
</div>
</div>
{requestsQuery.isLoading ? <TableSkeleton columns={7} rows={5} /> : null}
{requestsQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveError(requestsQuery.error)}
</p>
) : null}
{!requestsQuery.isLoading && !requestsQuery.isError ? (
<>
<PrimaryTable>
<Table>
<TableHeader>
<TableRow>
<TableHead>假种 / 申请人</TableHead>
<TableHead>时间段</TableHead>
<TableHead>天数</TableHead>
<TableHead>状态</TableHead>
<TableHead>附件</TableHead>
<TableHead>更新时间</TableHead>
<TableHead className="w-44">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(requestsQuery.data?.items ?? []).length ? (
requestsQuery.data?.items.map((item) => {
const isOwner = item.ownerId === me?.id;
const canSubmit = isOwner && ["draft", "rejected"].includes(item.status);
const canWithdraw = isOwner && item.status === "reviewing";
return (
<TableRow key={item.id}>
<TableCell>
<div className="font-medium text-zinc-900">{item.leaveType?.name ?? "-"}</div>
<div className="text-xs text-zinc-500">
{item.owner?.name ?? "-"}
{item.owner?.employeeNo ? ` (${item.owner.employeeNo})` : ""}
</div>
</TableCell>
<TableCell>{formatRangeLabel(item)}</TableCell>
<TableCell>{item.requestedDays}</TableCell>
<TableCell><StatusBadge status={item.status} /></TableCell>
<TableCell className="text-xs text-zinc-500">
{item.attachment?.originalName ?? "-"}
</TableCell>
<TableCell>{item.updatedAt ? new Date(item.updatedAt).toLocaleString() : "-"}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
type="button"
disabled={!canSubmit || submitMutation.isPending}
onClick={() => submitMutation.mutate(item.id)}
>
提交
</Button>
<Button
size="sm"
variant="secondary"
type="button"
disabled={!canWithdraw || withdrawMutation.isPending}
onClick={() =>
setWithdrawTarget({
id: item.id,
label: `${item.leaveType?.name ?? "请假"} · ${formatRangeLabel(item)}`,
})
}
>
撤回
</Button>
</div>
</TableCell>
</TableRow>
);
})
) : (
<TableRow>
<TableCell className="py-4 text-zinc-500" colSpan={7}>
暂无请假记录
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</PrimaryTable>
<Pagination
page={page}
totalPages={totalPages}
onPageChange={setPage}
testIdPrefix="leave-requests"
/>
</>
) : null}
</div>
</SectionPanel>
);
const leaveTeamCalendarPanel = (
<SectionPanel title="团队日历" description="按当前周/月窗口查看团队请假安排,帮助审批与排班判断。">
<div className="space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<div className="text-sm font-semibold text-zinc-900">{calendarWindow.title}</div>
<div className="text-xs text-zinc-500">固定拉取当前可见窗口内的请假记录;若超出 100 条会提示截断。</div>
</div>
<Tabs value={calendarMode} onValueChange={(value) => setCalendarMode(value as LeaveCalendarMode)} data-testid="leave-calendar-mode-tabs">
<TabsList className="flex w-full flex-wrap justify-start gap-1 md:w-auto">
<TabsTrigger value="week" data-testid="leave-calendar-mode-week">周视图</TabsTrigger>
<TabsTrigger value="month" data-testid="leave-calendar-mode-month">月视图</TabsTrigger>
</TabsList>
</Tabs>
</div>
{calendarIsTruncated ? (
<p
className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-700"
data-testid="leave-team-calendar-truncation"
>
仅显示前 {calendarRequestsQuery.data?.items.length ?? 0} 条,共 {calendarRequestsQuery.data?.total ?? 0} 条。
</p>
) : null}
{calendarRequestsQuery.isLoading ? <TableSkeleton columns={7} rows={calendarMode === "week" ? 1 : 5} /> : null}
{calendarRequestsQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveError(calendarRequestsQuery.error)}
</p>
) : null}
{!calendarRequestsQuery.isLoading && !calendarRequestsQuery.isError ? (
<div className="space-y-3">
<div className="grid grid-cols-7 gap-3 text-xs font-medium text-zinc-500">
{WEEKDAY_LABELS.map((label) => (
<div className="px-1" key={label}>
{label}
</div>
))}
</div>
<div className="grid grid-cols-7 gap-3" data-testid="leave-team-calendar-grid">
{calendarWindow.dates.map((date) => {
const dateKey = toDateKey(date);
const items = calendarItemsByDate.get(dateKey) ?? [];
const isMuted = calendarMode === "month" && !isSameUtcMonth(date, calendarWindow.anchorMonth);
return (
<div
className={`min-h-36 rounded-xl border p-3 ${isMuted ? "border-zinc-200 bg-zinc-50/80 text-zinc-400" : "border-zinc-200 bg-white"}`}
data-testid="leave-team-calendar-cell"
key={dateKey}
>
<div className="flex items-center justify-between gap-2">
<div className={`text-sm font-semibold ${isMuted ? "text-zinc-400" : "text-zinc-900"}`}>
{date.getUTCMonth() + 1}/{date.getUTCDate()}
</div>
<div className="text-[11px] text-zinc-500">{items.length ? `${items.length} 条` : ""}</div>
</div>
<div className="mt-3 space-y-2">
{items.map((item) => (
<div
className="rounded-lg border border-primary-100 bg-primary-50 px-2 py-1 text-xs text-primary-900"
data-testid="leave-team-calendar-entry"
key={`${dateKey}-${item.id}`}
>
<div className="font-medium">{formatCalendarEntryLabel(item, dateKey)}</div>
<div className="mt-1 text-[11px] text-primary-700">
{formatRangeLabel(item)} · {formatCalendarRequestedDays(item.requestedDays)} 天
</div>
</div>
))}
</div>
</div>
);
})}
</div>
</div>
) : null}
</div>
</SectionPanel>
);
return (
<ListWorkspace>
@@ -409,141 +774,23 @@ export function LeaveRequestPage() {
</SectionPanel>
</div>
<SectionPanel title="请假记录" description="支持按状态和范围收敛列表,草稿与驳回单可再次提交。">
<div className="space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="text-sm text-zinc-500">最近一次筛选会保留在当前页面状态中。</div>
<div className="flex flex-wrap gap-2">
{canViewAll ? (
<select
className="select-std"
value={scope}
onChange={(event) => {
setScope(event.target.value as "own" | "all");
setPage(1);
}}
>
<option value="own">只看我的</option>
<option value="all">查看全员</option>
</select>
) : null}
<select
className="select-std"
value={statusFilter}
onChange={(event) => {
setStatusFilter(event.target.value as LeaveRequestStatus | "");
setPage(1);
}}
>
<option value="">全部状态</option>
<option value="draft">草稿</option>
<option value="reviewing">审批中</option>
<option value="approved">已通过</option>
<option value="rejected">已驳回</option>
<option value="cancelled">已取消</option>
</select>
<Button type="button" variant="secondary" onClick={() => queryClient.invalidateQueries({ queryKey: ["leave", "requests"] })}>
刷新
</Button>
</div>
</div>
{canViewAll ? (
<Tabs data-testid="leave-page-view-tabs" value={pageView} onValueChange={(value) => setPageView(value as LeavePageView)}>
<TabsList className="flex w-full flex-wrap justify-start gap-1">
<TabsTrigger data-testid="leave-view-records" value="records">请假记录</TabsTrigger>
<TabsTrigger data-testid="leave-view-calendar" value="calendar">团队日历</TabsTrigger>
</TabsList>
{requestsQuery.isLoading ? <TableSkeleton columns={7} rows={5} /> : null}
{requestsQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveError(requestsQuery.error)}
</p>
) : null}
{!requestsQuery.isLoading && !requestsQuery.isError ? (
<>
<PrimaryTable>
<Table>
<TableHeader>
<TableRow>
<TableHead>假种 / 申请人</TableHead>
<TableHead>时间段</TableHead>
<TableHead>天数</TableHead>
<TableHead>状态</TableHead>
<TableHead>附件</TableHead>
<TableHead>更新时间</TableHead>
<TableHead className="w-44">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(requestsQuery.data?.items ?? []).length ? (
requestsQuery.data?.items.map((item) => {
const isOwner = item.ownerId === me?.id;
const canSubmit = isOwner && ["draft", "rejected"].includes(item.status);
const canWithdraw = isOwner && item.status === "reviewing";
return (
<TableRow key={item.id}>
<TableCell>
<div className="font-medium text-zinc-900">{item.leaveType?.name ?? "-"}</div>
<div className="text-xs text-zinc-500">
{item.owner?.name ?? "-"}
{item.owner?.employeeNo ? ` (${item.owner.employeeNo})` : ""}
</div>
</TableCell>
<TableCell>{formatRangeLabel(item)}</TableCell>
<TableCell>{item.requestedDays}</TableCell>
<TableCell><StatusBadge status={item.status} /></TableCell>
<TableCell className="text-xs text-zinc-500">
{item.attachment?.originalName ?? "-"}
</TableCell>
<TableCell>{item.updatedAt ? new Date(item.updatedAt).toLocaleString() : "-"}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
type="button"
disabled={!canSubmit || submitMutation.isPending}
onClick={() => submitMutation.mutate(item.id)}
>
提交
</Button>
<Button
size="sm"
variant="secondary"
type="button"
disabled={!canWithdraw || withdrawMutation.isPending}
onClick={() =>
setWithdrawTarget({
id: item.id,
label: `${item.leaveType?.name ?? "请假"} · ${formatRangeLabel(item)}`,
})
}
>
撤回
</Button>
</div>
</TableCell>
</TableRow>
);
})
) : (
<TableRow>
<TableCell className="py-4 text-zinc-500" colSpan={7}>
暂无请假记录
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</PrimaryTable>
<Pagination
page={page}
totalPages={totalPages}
onPageChange={setPage}
testIdPrefix="leave-requests"
/>
</>
) : null}
</div>
</SectionPanel>
<TabsContent value="records">
{leaveRecordsPanel}
</TabsContent>
<TabsContent value="calendar">
{leaveTeamCalendarPanel}
</TabsContent>
</Tabs>
) : (
leaveRecordsPanel
)}
<ConfirmDialog
open={Boolean(withdrawTarget)}
@@ -0,0 +1,243 @@
import { expect, test, type Page } from "@playwright/test";
import { EMPLOYEE, login, type E2EUser } from "./helpers/auth";
const LEAVE_CALENDAR_MANAGER: E2EUser = {
identifier: "[email protected]",
password: "HrPassword1234",
name: "HR",
};
function envelope<T>(data: T) {
return {
requestId: "mock-request",
data,
};
}
function startOfUtcDay(value: Date) {
return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate()));
}
function addUtcDays(value: Date, days: number) {
const next = startOfUtcDay(value);
next.setUTCDate(next.getUTCDate() + days);
return next;
}
function toDateKey(value: Date) {
return startOfUtcDay(value).toISOString().slice(0, 10);
}
function buildCalendarItems(dateFrom: string, dateTo: string) {
const start = startOfUtcDay(new Date(`${dateFrom}T00:00:00.000Z`));
const end = startOfUtcDay(new Date(`${dateTo}T00:00:00.000Z`));
const maxOffset = Math.max(0, Math.round((end.getTime() - start.getTime()) / 86400000));
const second = addUtcDays(start, Math.min(1, maxOffset));
const third = addUtcDays(start, Math.min(2, maxOffset));
return [
{
id: "leave-calendar-1",
ownerId: "employee-manager-1",
departmentId: "department-1",
status: "approved",
startDate: toDateKey(start),
endDate: toDateKey(start),
startSession: "am",
endSession: "pm",
requestedDays: 1,
reason: "家庭事务",
attachment: null,
leaveType: {
id: "leave-type-annual",
code: "annual",
name: "年假",
requiresAttachment: false,
attachmentMimeTypes: [],
},
owner: {
id: "employee-manager-1",
employeeNo: "E1001",
name: "王小明",
},
createdAt: "2026-03-25T08:00:00.000Z",
updatedAt: "2026-03-25T08:00:00.000Z",
},
{
id: "leave-calendar-2",
ownerId: "employee-manager-2",
departmentId: "department-1",
status: "reviewing",
startDate: toDateKey(second),
endDate: toDateKey(third.getTime() > end.getTime() ? second : third),
startSession: "am",
endSession: "pm",
requestedDays: third.getTime() > end.getTime() ? 1 : 2,
reason: "出差调休",
attachment: null,
leaveType: {
id: "leave-type-sick",
code: "sick",
name: "病假",
requiresAttachment: false,
attachmentMimeTypes: [],
},
owner: {
id: "employee-manager-2",
employeeNo: "E1002",
name: "李四",
},
createdAt: "2026-03-25T08:00:00.000Z",
updatedAt: "2026-03-25T08:00:00.000Z",
},
{
id: "leave-calendar-3",
ownerId: "employee-manager-3",
departmentId: "department-1",
status: "approved",
startDate: toDateKey(end),
endDate: toDateKey(end),
startSession: "pm",
endSession: "pm",
requestedDays: 0.5,
reason: "下午就医",
attachment: null,
leaveType: {
id: "leave-type-personal",
code: "personal",
name: "事假",
requiresAttachment: false,
attachmentMimeTypes: [],
},
owner: {
id: "employee-manager-3",
employeeNo: "E1003",
name: "赵六",
},
createdAt: "2026-03-25T08:00:00.000Z",
updatedAt: "2026-03-25T08:00:00.000Z",
},
{
id: "leave-calendar-4",
ownerId: "employee-manager-4",
departmentId: "department-1",
status: "approved",
startDate: toDateKey(second),
endDate: toDateKey(second),
startSession: "am",
endSession: "am",
requestedDays: 0.5,
reason: "半天外出",
attachment: null,
leaveType: {
id: "leave-type-annual",
code: "annual",
name: "年假",
requiresAttachment: false,
attachmentMimeTypes: [],
},
owner: {
id: "employee-manager-4",
employeeNo: "E1004",
name: "孙七",
},
createdAt: "2026-03-25T08:00:00.000Z",
updatedAt: "2026-03-25T08:00:00.000Z",
},
];
}
async function mockLeaveRequests(page: Page) {
const observedCalendarQueries: Array<{ dateFrom: string; dateTo: string; pageSize: string }> = [];
await page.route("**/api/leave-requests?**", async (route) => {
const url = new URL(route.request().url());
const scope = url.searchParams.get("scope") ?? "own";
const pageSize = url.searchParams.get("pageSize") ?? "10";
const dateFrom = url.searchParams.get("dateFrom");
const dateTo = url.searchParams.get("dateTo");
if (scope === "all" && dateFrom && dateTo) {
observedCalendarQueries.push({ dateFrom, dateTo, pageSize });
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(
envelope({
page: 1,
pageSize: Number(pageSize),
total: 5,
scope: "all",
items: buildCalendarItems(dateFrom, dateTo),
}),
),
});
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(
envelope({
page: 1,
pageSize: Number(pageSize),
total: 0,
scope,
items: [],
}),
),
});
});
return observedCalendarQueries;
}
test("E2E 请假团队日历:具备团队查看权限的用户可见周/月视图并显示截断提示", async ({ page }) => {
const observedCalendarQueries = await mockLeaveRequests(page);
await login(page, LEAVE_CALENDAR_MANAGER);
await page.goto("/leave-requests");
await expect(page.getByRole("heading", { name: "请假申请" })).toBeVisible();
await expect(page.getByTestId("leave-view-calendar")).toBeVisible();
const weekResponse = page.waitForResponse((response) => {
const url = new URL(response.url());
return response.ok() && url.pathname === "/api/leave-requests" && url.searchParams.get("scope") === "all";
});
await page.getByTestId("leave-view-calendar").click();
await weekResponse;
await expect(page.getByTestId("leave-calendar-mode-week")).toHaveAttribute("data-state", "active");
await expect(page.getByTestId("leave-team-calendar-grid")).toBeVisible();
await expect(page.getByTestId("leave-team-calendar-entry").filter({ hasText: "王小明" }).first()).toBeVisible();
await expect(page.getByTestId("leave-team-calendar-entry").filter({ hasText: "李四" }).first()).toBeVisible();
await expect(page.getByTestId("leave-team-calendar-truncation")).toContainText("仅显示前 4 条,共 5 条");
await expect(page.getByTestId("leave-team-calendar-cell")).toHaveCount(7);
const monthResponse = page.waitForResponse((response) => {
const url = new URL(response.url());
return response.ok() && url.pathname === "/api/leave-requests" && url.searchParams.get("scope") === "all";
});
await page.getByTestId("leave-calendar-mode-month").click();
await monthResponse;
await expect(page.getByTestId("leave-calendar-mode-month")).toHaveAttribute("data-state", "active");
await expect.poll(() => page.getByTestId("leave-team-calendar-cell").count()).toBeGreaterThan(7);
await expect.poll(() => observedCalendarQueries.length).toBeGreaterThanOrEqual(2);
expect(observedCalendarQueries.every((query) => Boolean(query.dateFrom && query.dateTo))).toBe(true);
expect(observedCalendarQueries.every((query) => query.pageSize === "100")).toBe(true);
});
test("E2E 请假团队日历:员工视角不显示团队日历 Tab", async ({ page }) => {
await mockLeaveRequests(page);
await login(page, EMPLOYEE);
await page.goto("/leave-requests");
await expect(page.getByRole("heading", { name: "请假申请" })).toBeVisible();
await expect(page.getByTestId("leave-view-calendar")).toHaveCount(0);
await expect(page.getByTestId("leave-page-view-tabs")).toHaveCount(0);
});
+24
查看文件
@@ -42,6 +42,8 @@ const leaveListQuerySchema = z.object({
scope: z.enum(["own", "all"]).optional(),
page: z.coerce.number().int().min(1).optional(),
pageSize: z.coerce.number().int().min(1).max(100).optional(),
dateFrom: DATE_ONLY_SCHEMA.optional(),
dateTo: DATE_ONLY_SCHEMA.optional(),
});
const leaveBalanceQuerySchema = z.object({
@@ -107,11 +109,25 @@ function parseLeaveListQuery(query) {
throw buildValidationError(parsed.error.flatten());
}
const dateFrom = parsed.data.dateFrom ? new Date(`${parsed.data.dateFrom}T00:00:00.000Z`) : null;
const dateTo = parsed.data.dateTo ? new Date(`${parsed.data.dateTo}T00:00:00.000Z`) : null;
if (dateFrom && Number.isNaN(dateFrom.getTime())) {
throw buildValidationError({ dateFrom: "invalid" });
}
if (dateTo && Number.isNaN(dateTo.getTime())) {
throw buildValidationError({ dateTo: "invalid" });
}
if (dateFrom && dateTo && dateFrom > dateTo) {
throw buildValidationError({ dateTo: "must_be_after_dateFrom" });
}
return {
status: parsed.data.status,
scope: parsed.data.scope ?? "own",
page: parsed.data.page ?? 1,
pageSize: parsed.data.pageSize ?? 20,
dateFrom,
dateTo,
};
}
@@ -415,6 +431,14 @@ async function leaveRoutes(fastify) {
const where = {
...(query.status ? { status: query.status } : {}),
...(query.scope === "all" && canViewAll ? {} : { owner_id: request.employee.id }),
...(query.dateFrom || query.dateTo
? {
AND: [
...(query.dateFrom ? [{ end_date: { gte: query.dateFrom } }] : []),
...(query.dateTo ? [{ start_date: { lte: query.dateTo } }] : []),
],
}
: {}),
};
const [items, total] = await Promise.all([
+49 -2
查看文件
@@ -27,6 +27,10 @@ const {
const { reserveBudgetForEntity, releaseReservedBudgetIfAny } = require("../services/purchaseBudget");
const { sendNotificationEmailSafe } = require("../services/email");
const { createNotifications, resolveRecipientsForAssignee } = require("../services/notifications");
const {
buildPendingTaskCopy,
buildWorkflowEntityMeta,
} = require("../services/notifications/workflowCopy");
const { enqueueImNotifications } = require("../services/notifications/imEnqueue");
const { NOTIFICATION_TYPES } = require("../constants/notificationTypes");
const { openReadStream, StorageNotFoundError, writeBuffer } = require("../services/storage");
@@ -914,6 +918,23 @@ function buildSkippedEmailResult(reason, failed = 0) {
return { skipped: true, reason, sent: 0, failed };
}
async function loadPurchaseSubmitSummary(prisma, purchaseRequestId) {
return prisma.purchaseRequest.findUnique({
where: { id: purchaseRequestId },
select: {
id: true,
request_no: true,
total_amount: true,
applicant: {
select: {
id: true,
name: true,
},
},
},
});
}
async function finalizePurchaseSubmitSideEffects({
prisma,
config,
@@ -925,13 +946,39 @@ async function finalizePurchaseSubmitSideEffects({
assigneeId,
assigneeRole,
actorId,
loadSummary = loadPurchaseSubmitSummary,
resolveRecipients = resolveRecipientsForAssignee,
createAppNotifications = createNotifications,
enqueueIm = enqueueImNotifications,
sendEmailSafe = sendNotificationEmailSafe,
}) {
const title = `【待办】采购申请 ${requestNo} 待审批`;
const body = `采购申请 ${requestNo} 已提交,请及时处理。`;
let copy = buildPendingTaskCopy(
buildWorkflowEntityMeta("purchase_request", { request_no: requestNo }, purchaseRequestId),
);
try {
const summary = await loadSummary(prisma, purchaseRequestId);
copy = buildPendingTaskCopy(
buildWorkflowEntityMeta(
"purchase_request",
{
...summary,
request_no: summary?.request_no ?? requestNo,
},
purchaseRequestId,
),
);
} catch (error) {
logger?.error?.(
{
err: error,
requestId,
purchaseRequestId,
},
"Failed to load purchase submit notification summary",
);
}
const { title, body } = copy;
const targetPath = `/workflow/tasks/${workflowTaskId}`;
let recipientEmployeeIds = [];
+49 -2
查看文件
@@ -8,6 +8,10 @@ const {
createNotifications,
resolveRecipientsForAssignee,
} = require("../services/notifications");
const {
buildPendingTaskCopy,
buildWorkflowEntityMeta,
} = require("../services/notifications/workflowCopy");
const { enqueueImNotifications } = require("../services/notifications/imEnqueue");
const { NOTIFICATION_TYPES } = require("../constants/notificationTypes");
const { sendNotificationEmailSafe } = require("../services/email");
@@ -468,6 +472,23 @@ function buildSkippedEmailResult(reason, failed = 0) {
return { skipped: true, reason, sent: 0, failed };
}
async function loadReimbursementSubmitSummary(prisma, reimbursementId) {
return prisma.reimbursementForm.findUnique({
where: { id: reimbursementId },
select: {
id: true,
form_number: true,
total_amount: true,
owner: {
select: {
id: true,
name: true,
},
},
},
});
}
async function finalizeReimbursementSubmitSideEffects({
prisma,
config,
@@ -479,13 +500,39 @@ async function finalizeReimbursementSubmitSideEffects({
assigneeId,
assigneeRole,
actorId,
loadSummary = loadReimbursementSubmitSummary,
resolveRecipients = resolveRecipientsForAssignee,
createAppNotifications = createNotifications,
enqueueIm = enqueueImNotifications,
sendEmailSafe = sendNotificationEmailSafe,
}) {
const title = `【待办】报销单 ${formNumber} 待审批`;
const body = `报销单 ${formNumber} 已提交,请及时处理。`;
let copy = buildPendingTaskCopy(
buildWorkflowEntityMeta("reimbursement", { form_number: formNumber }, reimbursementId),
);
try {
const summary = await loadSummary(prisma, reimbursementId);
copy = buildPendingTaskCopy(
buildWorkflowEntityMeta(
"reimbursement",
{
...summary,
form_number: summary?.form_number ?? formNumber,
},
reimbursementId,
),
);
} catch (error) {
logger?.error?.(
{
err: error,
requestId,
reimbursementId,
},
"Failed to load reimbursement submit notification summary",
);
}
const { title, body } = copy;
const targetPath = `/workflow/tasks/${workflowTaskId}`;
let recipientEmployeeIds = [];
+22 -2
查看文件
@@ -10,6 +10,10 @@ const { reserveBudgetForEntity, releaseReservedBudgetIfAny } = require("../servi
const { parseWorkflowSteps, resolveActionableStep } = require("../utils/workflow");
const { sendNotificationEmailSafe } = require("../services/email");
const { createNotifications, resolveRecipientsForAssignee } = require("../services/notifications");
const {
buildPendingTaskCopy,
buildWorkflowEntityMeta,
} = require("../services/notifications/workflowCopy");
const { enqueueImNotifications } = require("../services/notifications/imEnqueue");
const { buildTravelPaymentTrack } = require("../services/travelPayments");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
@@ -589,6 +593,12 @@ async function loadTravelRequestForUpdate(tx, travelRequestId) {
end_date: true,
estimated_amount: true,
status: true,
applicant: {
select: {
id: true,
name: true,
},
},
segments: {
orderBy: [{ sort_order: "asc" }, { created_at: "asc" }],
select: { id: true },
@@ -1126,8 +1136,18 @@ async function createTravelRequestsRoutes(fastify) {
assigneeId: workflowTask.assignee_id,
assigneeRole: workflowTask.assignee_role,
});
const title = `【待办】差旅申请 ${requestNo} 待审批`;
const body = `差旅申请 ${requestNo} 已提交,请及时处理。`;
const pendingCopy = buildPendingTaskCopy(
buildWorkflowEntityMeta(
"travel_request",
{
request_no: requestNo,
estimated_amount: travelRequest.estimated_amount,
applicant: travelRequest.applicant,
},
travelRequestId,
),
);
const { title, body } = pendingCopy;
await createNotifications(tx, {
recipientEmployeeIds: recipients,
+95 -123
查看文件
@@ -16,6 +16,14 @@ const {
createNotifications,
resolveRecipientsForAssignee,
} = require("../services/notifications");
const {
buildApprovedCopy,
buildPendingTaskCopy,
buildRejectedCopy,
buildReturnedCopy,
buildWorkflowEntityMeta,
normalizeWorkflowEntityType,
} = require("../services/notifications/workflowCopy");
const { enqueueImNotifications } = require("../services/notifications/imEnqueue");
const { sendNotificationEmailSafe } = require("../services/email");
const { NOTIFICATION_TYPES } = require("../constants/notificationTypes");
@@ -423,92 +431,19 @@ async function loadReimbursementNotifyContext(tx, reimbursementId) {
select: {
id: true,
form_number: true,
total_amount: true,
owner_id: true,
submitter_id: true,
owner: {
select: {
id: true,
name: true,
},
},
},
});
}
function getWorkflowEntityMeta(entityType, entityRecord, entityId) {
const normalizedEntityType = entityType === "procurement" ? "purchase_request" : entityType;
if (normalizedEntityType === "reimbursement") {
return {
label: "报销单",
reference: entityRecord?.form_number ?? entityId,
targetType: "reimbursement_form",
detailPath: `/reimbursements/${entityId}`,
};
}
if (normalizedEntityType === "leave") {
return {
label: "请假单",
reference: entityId,
targetType: "leave_request",
detailPath: "/leave-requests",
};
}
if (normalizedEntityType === "purchase_request") {
return {
label: "采购申请",
reference: entityRecord?.request_no ?? entityId,
targetType: "purchase_request",
detailPath: "/purchase-requests",
};
}
if (normalizedEntityType === "travel_request") {
return {
label: "差旅申请",
reference: entityRecord?.request_no ?? entityId,
targetType: "travel_request",
detailPath: `/travel-requests?id=${entityId}`,
};
}
return {
label: "审批单",
reference: entityId,
targetType: normalizedEntityType,
detailPath: `/workflow/tasks/${entityId}`,
};
}
function buildPendingTaskCopy(entityMeta, { transfer = false } = {}) {
return {
title: `【待办】${entityMeta.label} ${entityMeta.reference} ${transfer ? "转审待处理" : "待审批"}`,
body: transfer
? `${entityMeta.label} ${entityMeta.reference} 已转审给你,请及时处理。`
: `${entityMeta.label} ${entityMeta.reference} 已流转到你,请及时处理。`,
};
}
function buildApprovedCopy(entityMeta) {
return {
title: `【已批准】${entityMeta.label} ${entityMeta.reference}`,
body:
entityMeta.targetType === "reimbursement_form"
? `${entityMeta.label} ${entityMeta.reference} 已审批通过,等待财务付款。`
: `${entityMeta.label} ${entityMeta.reference} 已审批通过。`,
};
}
function buildRejectedCopy(entityMeta, comment) {
return {
title: `【已驳回】${entityMeta.label} ${entityMeta.reference}`,
body: `${entityMeta.label} ${entityMeta.reference} 已被驳回:${comment}`,
};
}
function buildReturnedCopy(entityMeta, comment) {
return {
title: `【已退回】${entityMeta.label} ${entityMeta.reference}`,
body: `${entityMeta.label} ${entityMeta.reference} 已退回:${comment}`,
};
}
function uniq(values) {
return Array.from(new Set((values ?? []).filter(Boolean)));
}
@@ -519,6 +454,22 @@ async function loadLeaveNotifyContext(tx, leaveRequestId) {
select: {
id: true,
owner_id: true,
start_date: true,
end_date: true,
requested_days: true,
leave_type: {
select: {
id: true,
code: true,
name: true,
},
},
owner: {
select: {
id: true,
name: true,
},
},
},
});
}
@@ -529,8 +480,15 @@ async function loadPurchaseNotifyContext(tx, purchaseRequestId) {
select: {
id: true,
request_no: true,
total_amount: true,
applicant_id: true,
submitter_id: true,
applicant: {
select: {
id: true,
name: true,
},
},
},
});
}
@@ -541,14 +499,21 @@ async function loadTravelNotifyContext(tx, travelRequestId) {
select: {
id: true,
request_no: true,
estimated_amount: true,
applicant_employee_id: true,
submitter_id: true,
applicant: {
select: {
id: true,
name: true,
},
},
},
});
}
async function loadWorkflowNotifyContext(tx, entityType, entityId) {
const normalizedEntityType = entityType === 'procurement' ? 'purchase_request' : entityType;
const normalizedEntityType = normalizeWorkflowEntityType(entityType);
if (normalizedEntityType === 'reimbursement') {
const record = await loadReimbursementNotifyContext(tx, entityId);
@@ -589,7 +554,7 @@ async function loadWorkflowNotifyContext(tx, entityType, entityId) {
}
function resolveWorkflowOutcomeNotificationType(entityType, outcome) {
const normalizedEntityType = entityType === 'procurement' ? 'purchase_request' : entityType;
const normalizedEntityType = normalizeWorkflowEntityType(entityType);
if (outcome === 'approved') {
if (normalizedEntityType === 'reimbursement') return NOTIFICATION_TYPES.reimbursementApproved;
@@ -673,7 +638,7 @@ async function queueWorkflowOutcomeNotifications({
outcome,
comment = null,
}) {
const normalizedEntityType = workflowInstance.entity_type === 'procurement' ? 'purchase_request' : workflowInstance.entity_type;
const normalizedEntityType = normalizeWorkflowEntityType(workflowInstance.entity_type);
const notificationType = resolveWorkflowOutcomeNotificationType(normalizedEntityType, outcome);
if (!notificationType) {
return;
@@ -685,7 +650,7 @@ async function queueWorkflowOutcomeNotifications({
return;
}
const entityMeta = getWorkflowEntityMeta(normalizedEntityType, context.record, workflowInstance.entity_id);
const entityMeta = buildWorkflowEntityMeta(normalizedEntityType, context.record, workflowInstance.entity_id);
const copy =
outcome === 'approved'
? buildApprovedCopy(entityMeta)
@@ -1476,42 +1441,41 @@ async function workflowRoutes(fastify) {
},
});
if (workflowInstance.entity_type === "reimbursement") {
try {
const reimbursement = await loadReimbursementNotifyContext(
tx,
workflowInstance.entity_id,
);
const entityMeta = getWorkflowEntityMeta(
workflowInstance.entity_type,
reimbursement,
workflowInstance.entity_id,
);
const pendingCopy = buildPendingTaskCopy(entityMeta);
const recipients = await resolveRecipientsForAssignee(tx, {
assigneeId: actionable.assigneeId,
assigneeRole: actionable.assigneeRole,
});
try {
const notificationContext = await loadWorkflowNotifyContext(
tx,
workflowInstance.entity_type,
workflowInstance.entity_id,
);
const entityMeta = buildWorkflowEntityMeta(
workflowInstance.entity_type,
notificationContext.record,
workflowInstance.entity_id,
);
const pendingCopy = buildPendingTaskCopy(entityMeta);
const recipients = await resolveRecipientsForAssignee(tx, {
assigneeId: actionable.assigneeId,
assigneeRole: actionable.assigneeRole,
});
await queueTaskAssignedNotifications({
tx,
config: fastify.config,
logger: fastify.log,
actorId: request.employee.id,
emailTasks,
recipientEmployeeIds: recipients,
title: pendingCopy.title,
body: pendingCopy.body,
taskId: nextTask.id,
entityType: workflowInstance.entity_type,
entityId: workflowInstance.entity_id,
});
} catch (error) {
fastify.log.error(
{ err: error, taskId: nextTask.id },
"Failed to create task assigned notifications",
);
}
await queueTaskAssignedNotifications({
tx,
config: fastify.config,
logger: fastify.log,
actorId: request.employee.id,
emailTasks,
recipientEmployeeIds: recipients,
title: pendingCopy.title,
body: pendingCopy.body,
taskId: nextTask.id,
entityType: workflowInstance.entity_type,
entityId: workflowInstance.entity_id,
});
} catch (error) {
fastify.log.error(
{ err: error, taskId: nextTask.id },
"Failed to create task assigned notifications",
);
}
result = {
@@ -2138,10 +2102,14 @@ async function workflowRoutes(fastify) {
});
try {
const reimbursement = await loadReimbursementNotifyContext(tx, workflowInstance.entity_id);
const entityMeta = getWorkflowEntityMeta(
const notificationContext = await loadWorkflowNotifyContext(
tx,
workflowInstance.entity_type,
reimbursement,
workflowInstance.entity_id,
);
const entityMeta = buildWorkflowEntityMeta(
workflowInstance.entity_type,
notificationContext.record,
workflowInstance.entity_id,
);
const pendingCopy = buildPendingTaskCopy(entityMeta, { transfer: true });
@@ -2195,8 +2163,12 @@ async function workflowRoutes(fastify) {
module.exports = workflowRoutes;
module.exports.__testables = {
buildApprovedCopy,
buildPendingTaskCopy,
buildRejectedCopy,
buildReturnedCopy,
buildWorkflowEntityMeta,
handlePurchaseApprovalErpSync,
listAllowedActionsForTask,
getWorkflowEntityMeta,
resolveWorkflowOutcomeNotificationType,
};
@@ -0,0 +1,212 @@
const { formatDateOnly } = require("../leave");
const { toMoneyString } = require("../../utils/workflow");
function normalizeWorkflowEntityType(entityType) {
return entityType === "procurement" ? "purchase_request" : entityType;
}
function formatAmount(value) {
return toMoneyString(value) ?? "-";
}
function formatRequestedDays(value) {
const numeric = Number(value ?? 0);
if (!Number.isFinite(numeric)) {
return "-";
}
return Number.isInteger(numeric) ? String(numeric) : numeric.toFixed(1);
}
function formatDateRange(startDate, endDate) {
const start = startDate ? formatDateOnly(startDate) : null;
const end = endDate ? formatDateOnly(endDate) : null;
if (start && end) {
return start === end ? start : `${start} ~ ${end}`;
}
return start ?? end ?? null;
}
function buildReference(normalizedEntityType, entityRecord, entityId) {
if (normalizedEntityType === "reimbursement") {
return entityRecord?.form_number ?? entityId;
}
if (normalizedEntityType === "leave") {
return formatDateRange(entityRecord?.start_date, entityRecord?.end_date) ?? entityId;
}
if (normalizedEntityType === "purchase_request") {
return entityRecord?.request_no ?? entityId;
}
if (normalizedEntityType === "travel_request") {
return entityRecord?.request_no ?? entityId;
}
return entityId;
}
function buildWorkflowEntityMeta(entityType, entityRecord, entityId) {
const normalizedEntityType = normalizeWorkflowEntityType(entityType);
if (normalizedEntityType === "reimbursement") {
return {
entityType: normalizedEntityType,
label: "报销单",
reference: buildReference(normalizedEntityType, entityRecord, entityId),
targetType: "reimbursement_form",
detailPath: `/reimbursements/${entityId}`,
summary: {
applicantName: entityRecord?.owner?.name ?? null,
amount: formatAmount(entityRecord?.total_amount),
},
};
}
if (normalizedEntityType === "leave") {
return {
entityType: normalizedEntityType,
label: "请假单",
reference: buildReference(normalizedEntityType, entityRecord, entityId),
targetType: "leave_request",
detailPath: "/leave-requests",
summary: {
applicantName: entityRecord?.owner?.name ?? null,
leaveTypeName: entityRecord?.leave_type?.name ?? null,
requestedDays: formatRequestedDays(entityRecord?.requested_days),
dateRange: formatDateRange(entityRecord?.start_date, entityRecord?.end_date) ?? "-",
},
};
}
if (normalizedEntityType === "purchase_request") {
return {
entityType: normalizedEntityType,
label: "采购申请",
reference: buildReference(normalizedEntityType, entityRecord, entityId),
targetType: "purchase_request",
detailPath: "/purchase-requests",
summary: {
applicantName: entityRecord?.applicant?.name ?? null,
amount: formatAmount(entityRecord?.total_amount),
},
};
}
if (normalizedEntityType === "travel_request") {
return {
entityType: normalizedEntityType,
label: "差旅申请",
reference: buildReference(normalizedEntityType, entityRecord, entityId),
targetType: "travel_request",
detailPath: `/travel-requests?id=${entityId}`,
summary: {
applicantName: entityRecord?.applicant?.name ?? null,
estimatedAmount: formatAmount(entityRecord?.estimated_amount),
},
};
}
return {
entityType: normalizedEntityType,
label: "审批单",
reference: entityId,
targetType: normalizedEntityType,
detailPath: `/workflow/tasks/${entityId}`,
summary: {},
};
}
function buildSummaryLines(entityMeta) {
if (entityMeta.entityType === "reimbursement") {
return [
`申请人:${entityMeta.summary.applicantName ?? "未知申请人"}`,
`金额:${entityMeta.summary.amount ?? "-"}`,
`单号:${entityMeta.reference}`,
];
}
if (entityMeta.entityType === "leave") {
return [
`申请人:${entityMeta.summary.applicantName ?? "未知申请人"}`,
`假种:${entityMeta.summary.leaveTypeName ?? "请假"}`,
`天数:${entityMeta.summary.requestedDays ?? "-"} 天`,
`起止日期:${entityMeta.summary.dateRange ?? "-"}`,
];
}
if (entityMeta.entityType === "purchase_request") {
return [
`申请人:${entityMeta.summary.applicantName ?? "未知申请人"}`,
`金额:${entityMeta.summary.amount ?? "-"}`,
`单号:${entityMeta.reference}`,
];
}
if (entityMeta.entityType === "travel_request") {
return [
`申请人:${entityMeta.summary.applicantName ?? "未知申请人"}`,
`预估金额:${entityMeta.summary.estimatedAmount ?? "-"}`,
`单号:${entityMeta.reference}`,
];
}
return [`单据:${entityMeta.reference}`];
}
function buildSubject(entityMeta) {
return entityMeta.reference ? `${entityMeta.label} ${entityMeta.reference}` : entityMeta.label;
}
function buildBody(entityMeta, resultLine) {
return [...buildSummaryLines(entityMeta), resultLine].join("\n");
}
function buildPendingTaskCopy(entityMeta, { transfer = false } = {}) {
return {
title: `【待办】${buildSubject(entityMeta)} ${transfer ? "转审待处理" : "待审批"}`,
body: buildBody(
entityMeta,
transfer ? "动作:该审批已转审给你,请及时处理。" : "动作:请及时处理审批任务。",
),
};
}
function buildApprovedCopy(entityMeta) {
return {
title: `【已批准】${buildSubject(entityMeta)}`,
body: buildBody(
entityMeta,
entityMeta.targetType === "reimbursement_form"
? "结果:已审批通过,等待财务付款。"
: "结果:已审批通过。",
),
};
}
function buildRejectedCopy(entityMeta, comment) {
const safeComment = String(comment ?? "").trim() || "无";
return {
title: `【已驳回】${buildSubject(entityMeta)}`,
body: buildBody(entityMeta, `结果:已驳回:${safeComment}`),
};
}
function buildReturnedCopy(entityMeta, comment) {
const safeComment = String(comment ?? "").trim() || "无";
return {
title: `【已退回】${buildSubject(entityMeta)}`,
body: buildBody(entityMeta, `结果:已退回:${safeComment}`),
};
}
module.exports = {
buildApprovedCopy,
buildPendingTaskCopy,
buildRejectedCopy,
buildReturnedCopy,
buildWorkflowEntityMeta,
normalizeWorkflowEntityType,
};
@@ -21,6 +21,15 @@ describe('purchase submit side effects helper', () => {
assigneeId: 'manager-1',
assigneeRole: null,
actorId: 'employee-1',
loadSummary: vi.fn().mockResolvedValue({
id: 'purchase-1',
request_no: 'CG-20260324-001',
total_amount: '5600.00',
applicant: {
id: 'employee-1',
name: '李经理',
},
}),
resolveRecipients: vi.fn().mockResolvedValue(['manager-1']),
createAppNotifications: vi.fn().mockResolvedValue({ count: 1 }),
enqueueIm: vi.fn().mockResolvedValue({
@@ -99,4 +108,45 @@ describe('purchase submit side effects helper', () => {
}),
);
});
it('builds structured purchase copy for app notification, IM and email', async () => {
const createAppNotifications = vi.fn().mockResolvedValue({ count: 1 });
const enqueueIm = vi.fn().mockResolvedValue({
queued: 1,
duplicated: 0,
matchedChannels: [{ id: 'channel-1' }],
});
const sendEmailSafe = vi.fn().mockResolvedValue({
email: { skipped: true, sent: 0, failed: 0 },
});
await finalizePurchaseSubmitSideEffects(
createParams({
createAppNotifications,
enqueueIm,
sendEmailSafe,
}),
);
expect(createAppNotifications).toHaveBeenCalledWith(
{},
expect.objectContaining({
title: '【待办】采购申请 CG-20260324-001 待审批',
body: '申请人:李经理\n金额:5600.00\n单号:CG-20260324-001\n动作:请及时处理审批任务。',
}),
);
expect(enqueueIm).toHaveBeenCalledWith(
{},
expect.objectContaining({
title: '【待办】采购申请 CG-20260324-001 待审批',
body: '申请人:李经理\n金额:5600.00\n单号:CG-20260324-001\n动作:请及时处理审批任务。',
}),
);
expect(sendEmailSafe).toHaveBeenCalledWith(
expect.objectContaining({
subject: '【待办】采购申请 CG-20260324-001 待审批',
body: '申请人:李经理\n金额:5600.00\n单号:CG-20260324-001\n动作:请及时处理审批任务。',
}),
);
});
});
@@ -21,6 +21,15 @@ describe('reimbursement submit side effects helper', () => {
assigneeId: 'manager-1',
assigneeRole: null,
actorId: 'employee-1',
loadSummary: vi.fn().mockResolvedValue({
id: 'reimbursement-1',
form_number: 'BX-20260324-001',
total_amount: '1288.00',
owner: {
id: 'employee-1',
name: '王小明',
},
}),
resolveRecipients: vi.fn().mockResolvedValue(['manager-1']),
createAppNotifications: vi.fn().mockResolvedValue({ count: 1 }),
enqueueIm: vi.fn().mockResolvedValue({
@@ -99,4 +108,45 @@ describe('reimbursement submit side effects helper', () => {
}),
);
});
it('builds structured reimbursement copy for app notification, IM and email', async () => {
const createAppNotifications = vi.fn().mockResolvedValue({ count: 1 });
const enqueueIm = vi.fn().mockResolvedValue({
queued: 1,
duplicated: 0,
matchedChannels: [{ id: 'channel-1' }],
});
const sendEmailSafe = vi.fn().mockResolvedValue({
email: { skipped: true, sent: 0, failed: 0 },
});
await finalizeReimbursementSubmitSideEffects(
createParams({
createAppNotifications,
enqueueIm,
sendEmailSafe,
}),
);
expect(createAppNotifications).toHaveBeenCalledWith(
{},
expect.objectContaining({
title: '【待办】报销单 BX-20260324-001 待审批',
body: '申请人:王小明\n金额:1288.00\n单号:BX-20260324-001\n动作:请及时处理审批任务。',
}),
);
expect(enqueueIm).toHaveBeenCalledWith(
{},
expect.objectContaining({
title: '【待办】报销单 BX-20260324-001 待审批',
body: '申请人:王小明\n金额:1288.00\n单号:BX-20260324-001\n动作:请及时处理审批任务。',
}),
);
expect(sendEmailSafe).toHaveBeenCalledWith(
expect.objectContaining({
subject: '【待办】报销单 BX-20260324-001 待审批',
body: '申请人:王小明\n金额:1288.00\n单号:BX-20260324-001\n动作:请及时处理审批任务。',
}),
);
});
});
@@ -2,12 +2,25 @@ const { NOTIFICATION_TYPES } = require("../../../src/constants/notificationTypes
const workflowRoutes = require("../../../src/routes/workflow");
describe("workflow travel helpers", () => {
const { getWorkflowEntityMeta, resolveWorkflowOutcomeNotificationType } = workflowRoutes.__testables;
const {
buildApprovedCopy,
buildPendingTaskCopy,
buildRejectedCopy,
buildReturnedCopy,
buildWorkflowEntityMeta,
resolveWorkflowOutcomeNotificationType,
} = workflowRoutes.__testables;
it("builds travel entity meta with deep-link detail path", () => {
const meta = getWorkflowEntityMeta(
const meta = buildWorkflowEntityMeta(
"travel_request",
{ request_no: "CL-20260310-0001" },
{
request_no: "CL-20260310-0001",
estimated_amount: "4200.00",
applicant: {
name: "张差旅",
},
},
"11111111-1111-4111-8111-111111111111",
);
@@ -19,6 +32,56 @@ describe("workflow travel helpers", () => {
detailPath: "/travel-requests?id=11111111-1111-4111-8111-111111111111",
}),
);
const pendingCopy = buildPendingTaskCopy(meta);
expect(pendingCopy).toEqual({
title: "【待办】差旅申请 CL-20260310-0001 待审批",
body: "申请人:张差旅\n预估金额:4200.00\n单号:CL-20260310-0001\n动作:请及时处理审批任务。",
});
});
it("builds structured workflow outcome copies for leave and reimbursement", () => {
const leaveMeta = buildWorkflowEntityMeta(
"leave",
{
start_date: new Date("2026-03-25T00:00:00.000Z"),
end_date: new Date("2026-03-26T00:00:00.000Z"),
requested_days: "1.5",
leave_type: {
name: "年假",
},
owner: {
name: "王请假",
},
},
"leave-1",
);
expect(buildRejectedCopy(leaveMeta, "请补充交接安排")).toEqual({
title: "【已驳回】请假单 2026-03-25 ~ 2026-03-26",
body: "申请人:王请假\n假种:年假\n天数:1.5 天\n起止日期:2026-03-25 ~ 2026-03-26\n结果:已驳回:请补充交接安排",
});
const reimbursementMeta = buildWorkflowEntityMeta(
"reimbursement",
{
form_number: "BX-20260325-001",
total_amount: "880.00",
owner: {
name: "刘报销",
},
},
"reimbursement-1",
);
expect(buildApprovedCopy(reimbursementMeta)).toEqual({
title: "【已批准】报销单 BX-20260325-001",
body: "申请人:刘报销\n金额:880.00\n单号:BX-20260325-001\n结果:已审批通过,等待财务付款。",
});
expect(buildReturnedCopy(reimbursementMeta, "请补发票")).toEqual({
title: "【已退回】报销单 BX-20260325-001",
body: "申请人:刘报销\n金额:880.00\n单号:BX-20260325-001\n结果:已退回:请补发票",
});
});
it("resolves approved and rejected notification types for travel workflows", () => {