R35-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);
|
||||
});
|
||||
@@ -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([
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
在新工单中引用
屏蔽一个用户