From 6fba13573edc85254be64ff9e35e2fb900c036d7 Mon Sep 17 00:00:00 2001 From: shunli Date: Wed, 25 Mar 2026 11:38:00 +0800 Subject: [PATCH] =?UTF-8?q?R35-2=20=E5=AE=A1=E6=89=B9=E9=80=9A=E7=9F=A5?= =?UTF-8?q?=E8=A1=A5=E7=BB=93=E6=9E=84=E5=8C=96=E6=91=98=E8=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/api/leave.ts | 2 + client/src/pages/LeaveRequestPage.tsx | 515 +++++++++++++----- client/tests/e2e/leave-team-calendar.spec.ts | 243 +++++++++ server/src/routes/leave.js | 24 + server/src/routes/purchaseRequests.js | 51 +- server/src/routes/reimbursements.js | 51 +- server/src/routes/travelRequests.js | 24 +- server/src/routes/workflow.js | 218 ++++---- .../services/notifications/workflowCopy.js | 212 +++++++ .../purchase.submit-side-effects.test.js | 50 ++ .../reimbursement.submit-side-effects.test.js | 50 ++ .../tests/unit/routes/workflow.travel.test.js | 69 ++- 12 files changed, 1243 insertions(+), 266 deletions(-) create mode 100644 client/tests/e2e/leave-team-calendar.spec.ts create mode 100644 server/src/services/notifications/workflowCopy.js diff --git a/client/src/api/leave.ts b/client/src/api/leave.ts index 90f41eee..37534651 100644 --- a/client/src/api/leave.ts +++ b/client/src/api/leave.ts @@ -88,6 +88,8 @@ export type LeaveListQuery = { scope?: "own" | "all"; page?: number; pageSize?: number; + dateFrom?: string; + dateTo?: string; }; function toQueryString(query: Record) { diff --git a/client/src/pages/LeaveRequestPage.tsx b/client/src/pages/LeaveRequestPage.tsx index 1664b193..e5ae9108 100644 --- a/client/src/pages/LeaveRequestPage.tsx +++ b/client/src/pages/LeaveRequestPage.tsx @@ -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(""); const [scope, setScope] = useState<"own" | "all">("own"); const [page, setPage] = useState(1); + const [pageView, setPageView] = useState("records"); + const [calendarMode, setCalendarMode] = useState("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 = ( + +
+
+
最近一次筛选会保留在当前页面状态中。
+
+ {canViewAll ? ( + + ) : null} + + +
+
+ + {requestsQuery.isLoading ? : null} + + {requestsQuery.isError ? ( +

+ {resolveError(requestsQuery.error)} +

+ ) : null} + + {!requestsQuery.isLoading && !requestsQuery.isError ? ( + <> + + + + + 假种 / 申请人 + 时间段 + 天数 + 状态 + 附件 + 更新时间 + 操作 + + + + {(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 ( + + +
{item.leaveType?.name ?? "-"}
+
+ {item.owner?.name ?? "-"} + {item.owner?.employeeNo ? ` (${item.owner.employeeNo})` : ""} +
+
+ {formatRangeLabel(item)} + {item.requestedDays} + + + {item.attachment?.originalName ?? "-"} + + {item.updatedAt ? new Date(item.updatedAt).toLocaleString() : "-"} + +
+ + +
+
+
+ ); + }) + ) : ( + + + 暂无请假记录 + + + )} +
+
+
+ + + + ) : null} +
+
+ ); + + const leaveTeamCalendarPanel = ( + +
+
+
+
{calendarWindow.title}
+
固定拉取当前可见窗口内的请假记录;若超出 100 条会提示截断。
+
+ + setCalendarMode(value as LeaveCalendarMode)} data-testid="leave-calendar-mode-tabs"> + + 周视图 + 月视图 + + +
+ + {calendarIsTruncated ? ( +

+ 仅显示前 {calendarRequestsQuery.data?.items.length ?? 0} 条,共 {calendarRequestsQuery.data?.total ?? 0} 条。 +

+ ) : null} + + {calendarRequestsQuery.isLoading ? : null} + + {calendarRequestsQuery.isError ? ( +

+ {resolveError(calendarRequestsQuery.error)} +

+ ) : null} + + {!calendarRequestsQuery.isLoading && !calendarRequestsQuery.isError ? ( +
+
+ {WEEKDAY_LABELS.map((label) => ( +
+ {label} +
+ ))} +
+ +
+ {calendarWindow.dates.map((date) => { + const dateKey = toDateKey(date); + const items = calendarItemsByDate.get(dateKey) ?? []; + const isMuted = calendarMode === "month" && !isSameUtcMonth(date, calendarWindow.anchorMonth); + + return ( +
+
+
+ {date.getUTCMonth() + 1}/{date.getUTCDate()} +
+
{items.length ? `${items.length} 条` : ""}
+
+ +
+ {items.map((item) => ( +
+
{formatCalendarEntryLabel(item, dateKey)}
+
+ {formatRangeLabel(item)} · {formatCalendarRequestedDays(item.requestedDays)} 天 +
+
+ ))} +
+
+ ); + })} +
+
+ ) : null} +
+
+ ); return ( @@ -409,141 +774,23 @@ export function LeaveRequestPage() { - -
-
-
最近一次筛选会保留在当前页面状态中。
-
- {canViewAll ? ( - - ) : null} - - -
-
+ {canViewAll ? ( + setPageView(value as LeavePageView)}> + + 请假记录 + 团队日历 + - {requestsQuery.isLoading ? : null} - - {requestsQuery.isError ? ( -

- {resolveError(requestsQuery.error)} -

- ) : null} - - {!requestsQuery.isLoading && !requestsQuery.isError ? ( - <> - - - - - 假种 / 申请人 - 时间段 - 天数 - 状态 - 附件 - 更新时间 - 操作 - - - - {(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 ( - - -
{item.leaveType?.name ?? "-"}
-
- {item.owner?.name ?? "-"} - {item.owner?.employeeNo ? ` (${item.owner.employeeNo})` : ""} -
-
- {formatRangeLabel(item)} - {item.requestedDays} - - - {item.attachment?.originalName ?? "-"} - - {item.updatedAt ? new Date(item.updatedAt).toLocaleString() : "-"} - -
- - -
-
-
- ); - }) - ) : ( - - - 暂无请假记录 - - - )} -
-
-
- - - - ) : null} -
-
+ + {leaveRecordsPanel} + + + {leaveTeamCalendarPanel} + + + ) : ( + leaveRecordsPanel + )} (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); +}); diff --git a/server/src/routes/leave.js b/server/src/routes/leave.js index 8e29d709..f20120cd 100644 --- a/server/src/routes/leave.js +++ b/server/src/routes/leave.js @@ -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([ diff --git a/server/src/routes/purchaseRequests.js b/server/src/routes/purchaseRequests.js index 726113c5..4e85a6bb 100644 --- a/server/src/routes/purchaseRequests.js +++ b/server/src/routes/purchaseRequests.js @@ -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 = []; diff --git a/server/src/routes/reimbursements.js b/server/src/routes/reimbursements.js index 4fa5bb97..ab0582f7 100644 --- a/server/src/routes/reimbursements.js +++ b/server/src/routes/reimbursements.js @@ -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 = []; diff --git a/server/src/routes/travelRequests.js b/server/src/routes/travelRequests.js index 51bbeab8..fc0708bd 100644 --- a/server/src/routes/travelRequests.js +++ b/server/src/routes/travelRequests.js @@ -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, diff --git a/server/src/routes/workflow.js b/server/src/routes/workflow.js index 5fe2d35f..0a811f32 100644 --- a/server/src/routes/workflow.js +++ b/server/src/routes/workflow.js @@ -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, }; diff --git a/server/src/services/notifications/workflowCopy.js b/server/src/services/notifications/workflowCopy.js new file mode 100644 index 00000000..eb38988d --- /dev/null +++ b/server/src/services/notifications/workflowCopy.js @@ -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, +}; diff --git a/server/tests/unit/routes/purchase.submit-side-effects.test.js b/server/tests/unit/routes/purchase.submit-side-effects.test.js index 9906fbd9..d9a2bf45 100644 --- a/server/tests/unit/routes/purchase.submit-side-effects.test.js +++ b/server/tests/unit/routes/purchase.submit-side-effects.test.js @@ -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动作:请及时处理审批任务。', + }), + ); + }); }); diff --git a/server/tests/unit/routes/reimbursement.submit-side-effects.test.js b/server/tests/unit/routes/reimbursement.submit-side-effects.test.js index 3c415239..de67e6e7 100644 --- a/server/tests/unit/routes/reimbursement.submit-side-effects.test.js +++ b/server/tests/unit/routes/reimbursement.submit-side-effects.test.js @@ -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动作:请及时处理审批任务。', + }), + ); + }); }); diff --git a/server/tests/unit/routes/workflow.travel.test.js b/server/tests/unit/routes/workflow.travel.test.js index 08e40c44..0a91f8c0 100644 --- a/server/tests/unit/routes/workflow.travel.test.js +++ b/server/tests/unit/routes/workflow.travel.test.js @@ -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", () => {