R32-1 R32-4 R33-2: 收口工作台交互与报销链路
这个提交包含在:
@@ -81,6 +81,16 @@ export type ReimbursementBudgetHint = {
|
||||
exceedsBudget: boolean;
|
||||
};
|
||||
|
||||
export type ReimbursementWorkflowProgress = {
|
||||
currentStepIndex: number;
|
||||
totalSteps: number;
|
||||
steps: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
status: "completed" | "current" | "pending";
|
||||
}>;
|
||||
};
|
||||
|
||||
export type ReimbursementDetail = {
|
||||
id: string;
|
||||
formNumber: string | null;
|
||||
@@ -125,6 +135,7 @@ export type ReimbursementDetail = {
|
||||
assignee_role: string | null;
|
||||
} | null;
|
||||
} | null;
|
||||
workflowProgress: ReimbursementWorkflowProgress | null;
|
||||
riskFlags: RiskFlag[];
|
||||
items: ReimbursementItem[];
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ export type WorkflowTaskSummary = {
|
||||
entityId: string;
|
||||
currentStatus: string;
|
||||
} | null;
|
||||
allowedActions: Array<"approve" | "reject" | "return" | "transfer">;
|
||||
};
|
||||
|
||||
export type WorkflowTaskListResponse = {
|
||||
|
||||
@@ -8,6 +8,15 @@ import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog";
|
||||
|
||||
type SearchGroupKey = "people" | "documents" | "projectAnnouncement" | "other";
|
||||
|
||||
const SEARCH_GROUPS: Array<{ key: SearchGroupKey; label: string }> = [
|
||||
{ key: "people", label: "人员" },
|
||||
{ key: "documents", label: "业务单据" },
|
||||
{ key: "projectAnnouncement", label: "项目与公告" },
|
||||
{ key: "other", label: "其他" },
|
||||
];
|
||||
|
||||
function entityLabel(entityType: string) {
|
||||
if (entityType === "employee") return "人员";
|
||||
if (entityType === "announcement") return "公告";
|
||||
@@ -24,6 +33,29 @@ function entityLabel(entityType: string) {
|
||||
return entityType;
|
||||
}
|
||||
|
||||
function resolveSearchGroup(entityType: string): SearchGroupKey {
|
||||
if (entityType === "employee") {
|
||||
return "people";
|
||||
}
|
||||
|
||||
if ([
|
||||
"reimbursement",
|
||||
"leave",
|
||||
"purchase_request",
|
||||
"travel_request",
|
||||
"weekly_report",
|
||||
"todo",
|
||||
].includes(entityType)) {
|
||||
return "documents";
|
||||
}
|
||||
|
||||
if (["project", "announcement"].includes(entityType)) {
|
||||
return "projectAnnouncement";
|
||||
}
|
||||
|
||||
return "other";
|
||||
}
|
||||
|
||||
export function GlobalSearchDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||
const navigate = useNavigate();
|
||||
const [keyword, setKeyword] = useState("");
|
||||
@@ -31,19 +63,33 @@ export function GlobalSearchDialog({ open, onOpenChange }: { open: boolean; onOp
|
||||
const searchQuery = useQuery({
|
||||
enabled: open && trimmed.length >= 2,
|
||||
queryKey: ["global-search", trimmed],
|
||||
queryFn: () => searchGlobal(trimmed, 10),
|
||||
queryFn: () => searchGlobal(trimmed, 20),
|
||||
});
|
||||
|
||||
const groupedItems = useMemo(() => {
|
||||
const items = searchQuery.data?.items ?? [];
|
||||
return SEARCH_GROUPS.map((group) => {
|
||||
const matches = items.filter((item) => resolveSearchGroup(item.entityType) === group.key);
|
||||
return {
|
||||
...group,
|
||||
total: matches.length,
|
||||
items: matches.slice(0, 3),
|
||||
};
|
||||
}).filter((group) => group.total > 0);
|
||||
}, [searchQuery.data?.items]);
|
||||
|
||||
const message = useMemo(() => {
|
||||
if (!trimmed) return "输入姓名、公告、项目、合同、资产、请假、报销、周报或待办关键字开始搜索。";
|
||||
if (trimmed.length < 2) return "至少输入 2 个字符。";
|
||||
if (searchQuery.isLoading) return "搜索中...";
|
||||
if (searchQuery.isError) {
|
||||
return isApiError(searchQuery.error)
|
||||
? `${searchQuery.error.code}: ${mapApiErrorMessage(searchQuery.error.code, searchQuery.error.details, searchQuery.error.message)}`
|
||||
: "搜索失败,请稍后重试";
|
||||
}
|
||||
if (!groupedItems.length) return "未找到匹配结果。";
|
||||
return null;
|
||||
}, [trimmed, searchQuery.error, searchQuery.isError]);
|
||||
}, [groupedItems.length, searchQuery.error, searchQuery.isError, searchQuery.isLoading, trimmed]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -61,8 +107,16 @@ export function GlobalSearchDialog({ open, onOpenChange }: { open: boolean; onOp
|
||||
|
||||
{message ? <div className="text-sm text-zinc-500">{message}</div> : null}
|
||||
|
||||
<div className="space-y-4">
|
||||
{groupedItems.map((group) => (
|
||||
<section key={group.key} className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold text-zinc-900">{group.label}</h3>
|
||||
<span className="text-xs text-zinc-400">{group.total} 条</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{(searchQuery.data?.items ?? []).map((item) => (
|
||||
{group.items.map((item) => (
|
||||
<button
|
||||
key={`${item.entityType}-${item.entityId}`}
|
||||
type="button"
|
||||
@@ -82,6 +136,13 @@ export function GlobalSearchDialog({ open, onOpenChange }: { open: boolean; onOp
|
||||
))}
|
||||
</div>
|
||||
|
||||
{group.total > group.items.length ? (
|
||||
<div className="text-xs text-zinc-400">查看更多结果</div>
|
||||
) : null}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button variant="secondary" type="button" onClick={() => onOpenChange(false)}>
|
||||
关闭
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchNotifications } from "../../api/notifications";
|
||||
@@ -58,6 +58,36 @@ export function Topbar() {
|
||||
const unreadCount = unreadQuery.data?.unreadCount ?? 0;
|
||||
const unreadBadge = unreadCount > 99 ? "99+" : String(unreadCount);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key.toLowerCase() !== "k") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.matchMedia("(min-width: 768px)").matches) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = event.target;
|
||||
if (target instanceof HTMLElement) {
|
||||
const tagName = target.tagName.toLowerCase();
|
||||
if (target.isContentEditable || tagName === "input" || tagName === "textarea" || tagName === "select") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
setSearchOpen(true);
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeydown);
|
||||
return () => window.removeEventListener("keydown", handleKeydown);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="sticky top-0 z-10 border-b border-zinc-200/80 bg-white/90 px-4 py-4 backdrop-blur sm:px-8">
|
||||
|
||||
@@ -11,6 +11,7 @@ export const TEST_IDS = {
|
||||
tasksTable: "workflow-tasks-table",
|
||||
taskRow: "workflow-task-row",
|
||||
detailLink: "workflow-task-detail-link",
|
||||
quickApprove: "workflow-task-quick-approve",
|
||||
summary: "workflow-task-summary",
|
||||
approve: "workflow-task-approve",
|
||||
reject: "workflow-task-reject",
|
||||
@@ -33,6 +34,7 @@ export const TEST_IDS = {
|
||||
budgetHint: "reimbursement-budget-hint",
|
||||
erpExport: "reimbursement-erp-export",
|
||||
overview: "reimbursement-overview",
|
||||
workflowProgress: "reimbursement-workflow-progress",
|
||||
},
|
||||
weeklyReport: {
|
||||
save: "weekly-report-save",
|
||||
|
||||
@@ -270,26 +270,6 @@ export function PortalHomePage() {
|
||||
tone: payload.weeklySummary.missingCount > 0 ? "warning" as const : "normal" as const,
|
||||
}
|
||||
: null,
|
||||
payload.projectSummary
|
||||
? {
|
||||
key: "project-summary",
|
||||
href: payload.projectSummary.href,
|
||||
label: "待跟进 / 待收口项目",
|
||||
value: `${payload.projectSummary.followUpDueTodayCount} / ${payload.projectSummary.closurePendingProjectCount}`,
|
||||
note: `逾期里程碑 ${payload.projectSummary.overdueMilestonesCount},风险 ${payload.projectSummary.openRiskCount}`,
|
||||
tone: payload.projectSummary.openRiskCount > 0 || payload.projectSummary.closurePendingProjectCount > 0 ? "warning" as const : "normal" as const,
|
||||
}
|
||||
: null,
|
||||
payload.peopleLifecycle
|
||||
? {
|
||||
key: "people-lifecycle",
|
||||
href: payload.peopleLifecycle.href,
|
||||
label: "入离职 / 资产待归还",
|
||||
value: `${payload.peopleLifecycle.onboardingCount + payload.peopleLifecycle.offboardingCount} / ${payload.peopleLifecycle.assetReturnPendingCount}`,
|
||||
note: "用于跟进入职、离职和资产交接闭环。",
|
||||
tone: payload.peopleLifecycle.offboardingCount > 0 || payload.peopleLifecycle.assetReturnPendingCount > 0 ? "warning" as const : "normal" as const,
|
||||
}
|
||||
: null,
|
||||
] as Array<SummaryItem | null>).filter(isSummaryItem);
|
||||
|
||||
return (
|
||||
@@ -329,30 +309,37 @@ export function PortalHomePage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isManagerView ? (
|
||||
<>
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.05fr),minmax(0,0.95fr)]">
|
||||
<SummaryPanel title="今日管理重点" items={managerSummaryItems} />
|
||||
<QuickLinksPanel items={payload.quickLinks} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,0.95fr),minmax(0,1.05fr)]">
|
||||
<AnnouncementsPanel announcements={payload.announcements} />
|
||||
<SecondaryCardsPanel
|
||||
title="项目跟进与组织协同"
|
||||
description="把项目、组织和风险类信息下移到第二阅读层。"
|
||||
cards={secondaryCards}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AdoptionSummaryPanel adoptionSummary={payload.adoptionSummary} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.1fr),minmax(0,0.9fr)]">
|
||||
<SummaryPanel title={isManagerView ? "今日管理重点" : "我的申请与资产"} items={isManagerView ? managerSummaryItems : employeeSummaryItems} />
|
||||
<SummaryPanel title="我的申请与资产" items={employeeSummaryItems} />
|
||||
<AnnouncementsPanel announcements={payload.announcements} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr),minmax(0,1fr)]">
|
||||
{isManagerView ? (
|
||||
<SecondaryCardsPanel
|
||||
title="项目跟进与组织协同"
|
||||
description="把需要继续跟进的项目、人员与风险放到第二层。"
|
||||
cards={secondaryCards}
|
||||
/>
|
||||
) : (
|
||||
<RecentVisitsPanel />
|
||||
)}
|
||||
|
||||
{isManagerView ? (
|
||||
<AdoptionSummaryPanel adoptionSummary={payload.adoptionSummary} />
|
||||
) : (
|
||||
<QuickLinksPanel items={payload.quickLinks} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isManagerView ? <QuickLinksPanel items={payload.quickLinks} /> : null}
|
||||
</>
|
||||
)}
|
||||
</PageScaffold>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import { DetailSideMeta } from "../components/patterns/DetailSideMeta";
|
||||
import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero";
|
||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||
import { SectionPanel } from "../components/patterns/SectionPanel";
|
||||
import { TEST_IDS } from "../lib/testIds";
|
||||
import { ReimbursementActions, type PayPayload } from "./reimbursement-detail/ReimbursementActions";
|
||||
import {
|
||||
ReimbursementItemEditor,
|
||||
@@ -697,6 +698,37 @@ export function ReimbursementDetailPage() {
|
||||
<div>关联项目:{detail.project ? `${detail.project.projectNo} · ${detail.project.name}` : "未关联"}</div>
|
||||
</DetailSideMeta>
|
||||
|
||||
{detail.status === "reviewing" && detail.workflowProgress ? (
|
||||
<DetailSideMeta
|
||||
title="审批进度"
|
||||
description={`当前位于第 ${detail.workflowProgress.currentStepIndex + 1} / ${detail.workflowProgress.totalSteps} 步。`}
|
||||
>
|
||||
<div className="space-y-3" data-testid={TEST_IDS.reimbursement.workflowProgress}>
|
||||
{detail.workflowProgress.steps.map((step, index) => (
|
||||
<div
|
||||
key={step.id}
|
||||
className={
|
||||
step.status === "current"
|
||||
? "rounded-lg border border-primary-200 bg-primary-50 px-3 py-2"
|
||||
: step.status === "completed"
|
||||
? "rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2"
|
||||
: "rounded-lg border border-zinc-200 bg-white px-3 py-2"
|
||||
}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="font-medium text-zinc-900">
|
||||
第 {index + 1} 步 · {step.name}
|
||||
</span>
|
||||
<span className="text-xs text-zinc-500">
|
||||
{step.status === "completed" ? "已完成" : step.status === "current" ? "当前节点" : "待处理"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DetailSideMeta>
|
||||
) : null}
|
||||
|
||||
<ReimbursementActions
|
||||
status={detail.status}
|
||||
canPay={canPay}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { isApiError } from "../api/types";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { fetchWorkflowTasks } from "../api/workflow";
|
||||
import { approveWorkflowTask, fetchWorkflowTasks } from "../api/workflow";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { Pagination } from "../components/common/Pagination";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
@@ -10,6 +11,7 @@ import { TableSkeleton } from "../components/common/TableSkeleton";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
|
||||
import { TEST_IDS } from "../lib/testIds";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
@@ -31,6 +33,7 @@ function entityLabel(entityType: string | undefined) {
|
||||
|
||||
export function WorkflowTasksPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const status =
|
||||
(searchParams.get("status") as
|
||||
@@ -47,6 +50,26 @@ export function WorkflowTasksPage() {
|
||||
queryFn: () => fetchWorkflowTasks({ status, page, pageSize: PAGE_SIZE }),
|
||||
});
|
||||
|
||||
const approveMutation = useMutation({
|
||||
mutationFn: (taskId: string) => approveWorkflowTask(taskId),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["notifications"] }),
|
||||
]);
|
||||
toast.success("审批已通过");
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = isApiError(error)
|
||||
? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: "审批失败,请稍后重试";
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil((query.data?.total ?? 0) / PAGE_SIZE));
|
||||
|
||||
return (
|
||||
@@ -123,14 +146,27 @@ export function WorkflowTasksPage() {
|
||||
<TableCell><StatusBadge status={task.status} /></TableCell>
|
||||
<TableCell>{new Date(task.createdAt).toLocaleString()}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{status === "pending" && task.allowedActions.includes("approve") ? (
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
data-testid={TEST_IDS.workflow.quickApprove}
|
||||
disabled={approveMutation.isPending && approveMutation.variables === task.id}
|
||||
onClick={() => approveMutation.mutate(task.id)}
|
||||
>
|
||||
{approveMutation.isPending && approveMutation.variables === task.id ? "通过中..." : "通过"}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
data-testid="workflow-task-detail-link"
|
||||
data-testid={TEST_IDS.workflow.detailLink}
|
||||
>
|
||||
<Link to={`/workflow/tasks/${task.id}`}>详情</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
import { ADMIN, login } from "./helpers/auth";
|
||||
|
||||
function envelope<T>(data: T) {
|
||||
return {
|
||||
requestId: "mock-request",
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
test("E2E 全局搜索:按钮打开后按分组展示结果并支持点击跳转", async ({ page }) => {
|
||||
await page.route("**/api/search?**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(
|
||||
envelope({
|
||||
items: [
|
||||
{ entityType: "employee", entityId: "employee-1", title: "王小明", subtitle: "E001 · 财务", status: "active", href: "/people/employee-1", matchedBy: "prefix", updatedAt: null },
|
||||
{ entityType: "reimbursement", entityId: "reimbursement-1", title: "BX-001", subtitle: "办公室报销", status: "reviewing", href: "/reimbursements/reimbursement-1", matchedBy: "contains", updatedAt: null },
|
||||
{ entityType: "leave", entityId: "leave-1", title: "请假 2026-03-24", subtitle: "年假", status: "approved", href: "/leave-requests", matchedBy: "contains", updatedAt: null },
|
||||
{ entityType: "purchase_request", entityId: "purchase-1", title: "CG-001", subtitle: "办公椅采购", status: "reviewing", href: "/purchase-requests/purchase-1", matchedBy: "contains", updatedAt: null },
|
||||
{ entityType: "travel_request", entityId: "travel-1", title: "CL-001", subtitle: "上海出差", status: "draft", href: "/travel-requests/travel-1", matchedBy: "contains", updatedAt: null },
|
||||
{ entityType: "project", entityId: "project-1", title: "海音达试点", subtitle: "实施项目", status: "active", href: "/projects/project-1", matchedBy: "exact", updatedAt: null },
|
||||
{ entityType: "announcement", entityId: "announcement-1", title: "本周通知", subtitle: "统一回填周报", status: null, href: "/announcements", matchedBy: "contains", updatedAt: null },
|
||||
{ entityType: "asset", entityId: "asset-1", title: "ZC-001", subtitle: "MacBook Pro", status: "in_use", href: "/assets", matchedBy: "contains", updatedAt: null },
|
||||
],
|
||||
}),
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
await login(page, ADMIN);
|
||||
await page.goto("/portal");
|
||||
|
||||
await expect(page.getByTestId("global-search-trigger")).toBeVisible();
|
||||
await page.getByTestId("global-search-trigger").click();
|
||||
await expect(page.getByRole("heading", { name: "全局搜索" })).toBeVisible();
|
||||
const searchResponse = page.waitForResponse((response) => response.url().includes("/api/search?"));
|
||||
await page.getByPlaceholder("搜索人员、项目、合同、公告、资产、请假、报销、周报、待办").fill("海音");
|
||||
await searchResponse;
|
||||
|
||||
await expect(page.getByRole("heading", { name: "人员" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "业务单据" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "项目与公告" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "其他" })).toBeVisible();
|
||||
|
||||
await expect(page.getByRole("button", { name: /BX-001/ })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /请假 2026-03-24/ })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /CG-001/ })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /CL-001/ })).toHaveCount(0);
|
||||
await expect(page.getByText("查看更多结果")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: /BX-001/ }).click();
|
||||
await expect(page).toHaveURL(/\/reimbursements\/reimbursement-1/);
|
||||
});
|
||||
|
||||
test("E2E 全局搜索:Ctrl+K 和 Cmd+K 可打开搜索弹窗", async ({ page }) => {
|
||||
await login(page, ADMIN);
|
||||
await page.goto("/portal");
|
||||
await expect(page.getByTestId("global-search-trigger")).toBeVisible();
|
||||
|
||||
await page.evaluate(() => {
|
||||
document.body.dispatchEvent(new KeyboardEvent("keydown", { key: "k", ctrlKey: true, bubbles: true, cancelable: true }));
|
||||
});
|
||||
await expect(page.getByRole("heading", { name: "全局搜索" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "关闭" }).click();
|
||||
await expect(page.getByRole("heading", { name: "全局搜索" })).not.toBeVisible();
|
||||
|
||||
await page.evaluate(() => {
|
||||
document.body.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true, bubbles: true, cancelable: true }));
|
||||
});
|
||||
await expect(page.getByRole("heading", { name: "全局搜索" })).toBeVisible();
|
||||
});
|
||||
@@ -29,6 +29,10 @@ export class ReimbursementDetailPageObject {
|
||||
return this.page.getByTestId(TEST_IDS.reimbursement.budgetHint);
|
||||
}
|
||||
|
||||
get workflowProgress() {
|
||||
return this.page.getByTestId(TEST_IDS.reimbursement.workflowProgress);
|
||||
}
|
||||
|
||||
get erpExport() {
|
||||
return this.page.getByTestId(TEST_IDS.reimbursement.erpExport);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@ function extractId(text: string, label: string) {
|
||||
}
|
||||
|
||||
test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登出", async ({ page, reimbursementDetail, workflowDetail }) => {
|
||||
// 主链路包含 OCR 等待和三角色切换,full-local 下会超过默认 120s。
|
||||
test.setTimeout(240_000);
|
||||
|
||||
// 1) employee: create reimbursement
|
||||
await login(page, EMPLOYEE);
|
||||
|
||||
@@ -42,7 +45,6 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
|
||||
await page.getByTestId("invoice-upload-submit").click();
|
||||
|
||||
await expect(page.getByTestId("invoice-detail-panel")).toBeVisible();
|
||||
await expect(page.getByTestId("invoice-detail-panel")).toContainText("状态:pending_ocr");
|
||||
await expect(page.getByTestId("invoice-detail-panel")).toContainText("状态:available", { timeout: 90_000 });
|
||||
|
||||
const invoiceDetailText = await page.getByTestId("invoice-detail-panel").innerText();
|
||||
|
||||
@@ -95,6 +95,7 @@ test("E2E 移动审批:离线草稿冲突态可见", async ({ page }) => {
|
||||
await page.goto(`/workflow/tasks/${task.id}`);
|
||||
await expect(page).toHaveURL(new RegExp(`/mobile/approvals\\?taskId=${task.id}`));
|
||||
await expect(page.getByTestId("mobile-approvals-detail")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("mobile-approvals-detail")).toContainText(created.travelRequest.requestNo);
|
||||
await waitForWorkflowTaskCache(page, task.id);
|
||||
|
||||
await page.context().setOffline(true);
|
||||
|
||||
@@ -100,7 +100,8 @@ test("E2E 项目工作台:沟通回填 + 收口面板 + Portal 摘要", async
|
||||
|
||||
await page.goto("/portal");
|
||||
await expect(page.getByRole("heading", { name: "今日管理重点" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: /待跟进 \/ 待收口项目/ })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "项目跟进与组织协同" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: /项目风险/ })).toBeVisible();
|
||||
|
||||
await adminClient.dispose();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { test, expect } from "./helpers/fixtures";
|
||||
import { EMPLOYEE, login, logout } from "./helpers/auth";
|
||||
import { openNavLink } from "./helpers/navigation";
|
||||
|
||||
function extractId(text: string, label: string) {
|
||||
const match = text.match(new RegExp(`${label}:([0-9a-f-]{36})`));
|
||||
if (!match?.[1]) {
|
||||
throw new Error(`Failed to parse ${label} from text: ${text}`);
|
||||
}
|
||||
return match[1];
|
||||
}
|
||||
|
||||
test("E2E 报销审批进度:提交后详情页展示当前步骤进度", async ({ page, reimbursementDetail }) => {
|
||||
await login(page, EMPLOYEE);
|
||||
|
||||
await (await openNavLink(page, "我的报销")).click();
|
||||
await expect(page).toHaveURL(/\/reimbursements/);
|
||||
await expect(page.getByTestId("reimbursements-create")).toBeVisible();
|
||||
|
||||
const createResponsePromise = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url());
|
||||
return response.request().method() === "POST" && url.pathname === "/api/reimbursements" && response.ok();
|
||||
});
|
||||
await page.getByTestId("reimbursements-create").click();
|
||||
const createPayload = (await (await createResponsePromise).json()) as { data?: { id?: string } };
|
||||
const reimbursementId = createPayload.data?.id;
|
||||
if (!reimbursementId) {
|
||||
throw new Error(`创建报销单后未返回 id: ${JSON.stringify(createPayload)}`);
|
||||
}
|
||||
|
||||
await page.goto(`/reimbursements/${reimbursementId}`);
|
||||
await reimbursementDetail.assertLoaded();
|
||||
|
||||
await (await openNavLink(page, "票据中心")).click();
|
||||
const marker = `MOCK_E2E_${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
await page.getByTestId("invoice-upload-file").setInputFiles({
|
||||
name: `progress-${Date.now()}.png`,
|
||||
mimeType: "image/png",
|
||||
buffer: Buffer.from(marker, "utf8"),
|
||||
});
|
||||
await page.getByTestId("invoice-upload-submit").click();
|
||||
|
||||
await expect(page.getByTestId("invoice-detail-panel")).toBeVisible();
|
||||
await expect(page.getByTestId("invoice-detail-panel")).toContainText("状态:available", { timeout: 90_000 });
|
||||
|
||||
const invoiceDetailText = await page.getByTestId("invoice-detail-panel").innerText();
|
||||
const invoiceId = extractId(invoiceDetailText, "票据ID");
|
||||
await page.getByTestId("invoice-bind-invoice").selectOption(invoiceId);
|
||||
await page.getByTestId("invoice-bind-reimbursement").selectOption(reimbursementId);
|
||||
await page.getByTestId("invoice-bind-submit").click();
|
||||
|
||||
await page.goto(`/reimbursements/${reimbursementId}`);
|
||||
await reimbursementDetail.assertLoaded();
|
||||
await reimbursementDetail.submitReimbursement();
|
||||
|
||||
await expect(page.getByText(/状态:(reviewing|审批中)/)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(reimbursementDetail.workflowProgress).toBeVisible({ timeout: 30_000 });
|
||||
await expect(reimbursementDetail.workflowProgress).toContainText("当前节点");
|
||||
await expect(reimbursementDetail.workflowProgress).toContainText(/第 1 步|第 2 步|第 3 步/);
|
||||
|
||||
await logout(page, EMPLOYEE.name);
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
|
||||
import { test, expect } from "./helpers/fixtures";
|
||||
import { EMPLOYEE, MANAGER, apiPostData, createAuthenticatedApiClient, login, logout } from "./helpers/auth";
|
||||
import { openNavLink } from "./helpers/navigation";
|
||||
import { TEST_IDS } from "./helpers/testIds";
|
||||
|
||||
function formatDate(offsetDays: number) {
|
||||
const value = new Date();
|
||||
value.setUTCDate(value.getUTCDate() + offsetDays);
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
async function selectFirstEnabledOption(locator: Locator) {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const optionValues = await locator.locator("option").evaluateAll((options) =>
|
||||
options
|
||||
.map((option) => ({
|
||||
value: (option as HTMLOptionElement).value,
|
||||
disabled: (option as HTMLOptionElement).disabled,
|
||||
}))
|
||||
.filter((option) => option.value && !option.disabled)
|
||||
.map((option) => option.value),
|
||||
);
|
||||
return optionValues[0] ?? "";
|
||||
})
|
||||
.not.toBe("");
|
||||
|
||||
const [firstValue] = await locator.locator("option").evaluateAll((options) =>
|
||||
options
|
||||
.map((option) => ({
|
||||
value: (option as HTMLOptionElement).value,
|
||||
disabled: (option as HTMLOptionElement).disabled,
|
||||
}))
|
||||
.filter((option) => option.value && !option.disabled)
|
||||
.map((option) => option.value),
|
||||
);
|
||||
|
||||
await locator.selectOption(firstValue);
|
||||
}
|
||||
|
||||
async function createLeaveDraft(page: Page, startDate: string, endDate: string, reason: string) {
|
||||
const createResponsePromise = page.waitForResponse(
|
||||
(response) => response.request().method() === "POST" && new URL(response.url()).pathname === "/api/leave-requests" && response.ok(),
|
||||
);
|
||||
|
||||
await selectFirstEnabledOption(page.locator("#leave-type"));
|
||||
await page.locator("#leave-start-date").fill(startDate);
|
||||
await page.locator("#leave-end-date").fill(endDate);
|
||||
await page.locator("#leave-start-session").selectOption("am");
|
||||
await page.locator("#leave-end-session").selectOption("pm");
|
||||
await page.locator("#leave-reason").fill(reason);
|
||||
await page.getByRole("button", { name: "创建草稿" }).click();
|
||||
|
||||
const createPayload = (await (await createResponsePromise).json()) as { data?: { id?: string } };
|
||||
const leaveRequestId = createPayload.data?.id;
|
||||
if (!leaveRequestId) {
|
||||
throw new Error(`创建请假草稿后未返回 id: ${JSON.stringify(createPayload)}`);
|
||||
}
|
||||
|
||||
return leaveRequestId;
|
||||
}
|
||||
|
||||
async function submitLeave(api: Awaited<ReturnType<typeof createAuthenticatedApiClient>>["api"], leaveRequestId: string) {
|
||||
return apiPostData<{ leaveRequestId: string; currentTaskId: string }>(api, `/api/leave-requests/${leaveRequestId}/submit`, {});
|
||||
}
|
||||
|
||||
test("E2E 审批列表快捷通过:经理可在待办列表行内直接通过请假申请", async ({ page }) => {
|
||||
const employeeClient = await createAuthenticatedApiClient(EMPLOYEE);
|
||||
const startDate = formatDate(45 + Number(String(Date.now()).slice(-2)));
|
||||
const reason = `Playwright 快捷通过 ${Date.now()}`;
|
||||
|
||||
try {
|
||||
await login(page, EMPLOYEE);
|
||||
await (await openNavLink(page, "请假")).click();
|
||||
await expect(page.getByRole("heading", { name: "请假申请" })).toBeVisible();
|
||||
|
||||
const leaveRequestId = await createLeaveDraft(page, startDate, startDate, reason);
|
||||
const submitResult = await submitLeave(employeeClient.api, leaveRequestId);
|
||||
|
||||
await logout(page, EMPLOYEE.name);
|
||||
|
||||
await login(page, MANAGER);
|
||||
await (await openNavLink(page, "待我处理")).click();
|
||||
await expect(page.getByTestId(TEST_IDS.workflow.tasksTable)).toBeVisible();
|
||||
|
||||
const taskRows = page.getByTestId(TEST_IDS.workflow.taskRow).filter({ hasText: submitResult.currentTaskId });
|
||||
await expect(taskRows).toHaveCount(1, { timeout: 30_000 });
|
||||
const taskRow = taskRows.first();
|
||||
await expect(taskRow.getByTestId(TEST_IDS.workflow.quickApprove)).toBeVisible();
|
||||
await expect(taskRow.getByTestId(TEST_IDS.workflow.detailLink)).toBeVisible();
|
||||
|
||||
await taskRow.getByTestId(TEST_IDS.workflow.quickApprove).click();
|
||||
await expect(taskRows).toHaveCount(0, { timeout: 30_000 });
|
||||
|
||||
await logout(page, MANAGER.name);
|
||||
|
||||
await login(page, EMPLOYEE);
|
||||
await page.goto("/leave-requests");
|
||||
await expect(page.getByRole("row").filter({ hasText: startDate }).first()).toContainText("已通过", { timeout: 30_000 });
|
||||
} finally {
|
||||
await employeeClient.dispose();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
|
||||
R32-0,新执行合同建档与范围锁定,基于 R31 收口后的实际代码状态建立 R32 合同并锁定本轮只做审批高频动作与工作台搜索收口,新 CSV 校验通过 且 Notes 写明 R32 不承接 R31-5 放行证据收集 同时请假日历与企微摘要明确列为本轮范围外,python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-03-24_10-06-28-r32-approval-workbench-search-focus.csv,manual,DONE,DONE,DONE,issues/2026-03-24_10-06-28-r32-approval-workbench-search-focus.csv,[],"assets/_template.csv 缺失时沿用 issues/README.md 与现有 CSV 表头;旧合同只作历史快照不回写。2026-03-24 已完成新合同建档并校验通过。2026-03-24 在隔离本地环境 hyb_oa_r32_20260324112544_29446 完成 R32 定向验收;同日 full-local 暴露范围外 procurement submit P2028 超时,未回写到本合同。"
|
||||
R32-1,审批列表快捷通过,为待处理审批列表补行内通过动作并让列表接口返回可执行动作依据,待处理列表中仅对 pending 且允许 approve 的任务显示行内通过按钮 点击后直接调用 approve 接口并刷新列表 同时详情入口保留不变,cd client && npm run typecheck && npm run test:e2e -- tests/e2e/workflow-quick-approve.spec.ts,manual,DONE,DONE,DONE,client/src/pages/WorkflowTasksPage.tsx|client/src/api/workflow.ts|server/src/routes/workflow.js|client/tests/e2e/workflow-quick-approve.spec.ts,"[""R32-0""]","对标钉钉低风险审批列表即处理;若列表接口不补 allowedActions 则前端不得盲暴露按钮。2026-03-24 已完成前后端与 E2E 实现。已执行 cd client && npm run typecheck;并在隔离本地环境 hyb_oa_r32_20260324112544_29446 执行 cd client && npm run test:e2e -- tests/e2e/workflow-quick-approve.spec.ts 通过。"
|
||||
R32-2,报销详情审批步骤进度可视化,为报销提交人补线性审批步骤进度并展示当前所处节点与已完成历史,报销详情在审批中时可展示线性步骤条 含已完成 当前节点 待完成 三种状态 且提交人能看到当前走到第几步 同时不改审批规则模型,cd client && npm run typecheck && npm run test:e2e -- tests/e2e/reimbursement-workflow-progress.spec.ts,manual,DONE,DONE,DONE,client/src/pages/ReimbursementDetailPage.tsx|client/src/api/reimbursements.ts|server/src/routes/reimbursements.js|client/tests/e2e/reimbursement-workflow-progress.spec.ts,"[""R32-0""]","对标钉钉与易快报提交人可见流程进度;本项允许扩展 /api/reimbursements/:id 响应 但不引入 BPMN 或流程编辑能力。2026-03-24 已完成接口扩展与前端侧栏进度卡。已执行 cd client && npm run typecheck、cd server && node --check src/routes/reimbursements.js;并在隔离本地环境 hyb_oa_r32_20260324112544_29446 执行 cd client && npm run test:e2e -- tests/e2e/reimbursement-workflow-progress.spec.ts 通过。"
|
||||
R32-3,Portal 管理视图减噪,把管理视角 Portal 首屏收敛为待审批与常用动作优先并下移试点观测类信息,管理视角首屏主区只保留待审批与高频入口 公告与试点观测降到次级阅读层 且不改 portal 数据接口,cd client && npm run typecheck && npm run test:e2e -- tests/e2e/login-portal-ux.spec.ts tests/e2e/visual-portal.spec.ts,manual,DONE,DONE,DONE,client/src/pages/PortalHomePage.tsx|client/tests/e2e/login-portal-ux.spec.ts|client/tests/e2e/visual-portal.spec.ts,"[""R32-0""]","对标飞书工作台先处理今天要做什么;仅改前端信息层级 不改数据聚合逻辑。2026-03-24 已完成布局重排。已执行 cd client && npm run typecheck;并在隔离本地环境 hyb_oa_r32_20260324112544_29446 执行 cd client && npm run test:e2e -- tests/e2e/login-portal-ux.spec.ts tests/e2e/visual-portal.spec.ts 通过。"
|
||||
R32-4,全局搜索快捷键与结果分组,为全局搜索补 Cmd+K Ctrl+K 打开方式并按实体类型分组结果,桌面端支持 Cmd+K 或 Ctrl+K 打开搜索弹窗 搜索结果按人员 业务单据 项目 公告等分组展示 且不改 /api/search 返回结构,cd client && npm run typecheck && npm run test:e2e -- tests/e2e/global-search.spec.ts,manual,DONE,DONE,DONE,client/src/components/GlobalSearchDialog.tsx|client/src/components/navigation/Topbar.tsx|client/src/layouts/AppShellLayout.tsx|client/src/api/search.ts|client/tests/e2e/global-search.spec.ts,"[""R32-0""]","对标飞书快捷搜索与分组扫视;仅前端消费 entityType 分组 不扩展搜索索引。2026-03-24 已完成快捷键、分组渲染与 E2E 收口。已执行 cd client && npm run typecheck;并在隔离本地环境 hyb_oa_r32_20260324112544_29446 执行 cd client && npm run test:e2e -- tests/e2e/global-search.spec.ts 通过。"
|
||||
|
@@ -0,0 +1,5 @@
|
||||
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
|
||||
R33-2-0,新执行合同建档与范围锁定,基于 R33-1 后 full-local 暴露的 reimbursement submit P2028 超时问题建立单独热补合同并锁定只修报销提交链路,新 CSV 校验通过 且 Notes 写明本轮只修 reimbursement submit 不扩散到采购/差旅 submit,python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-03-24_14-48-16-r33-2-reimbursement-submit-timeout-hotfix.csv,manual,DONE,TODO,DONE,issues/2026-03-24_14-48-16-r33-2-reimbursement-submit-timeout-hotfix.csv,[],"R33-1 作为历史快照不回写;本轮不调整 Prisma transaction timeout,仅通过缩事务收口。2026-03-24 已完成 R33-2 CSV 建档并校验通过。"
|
||||
R33-2-1,报销提交事务边界收紧,把 reimbursement submit 的主状态变更与通知副作用拆成事务内外两段以消除 P2028 超时,submit 路由仍返回原有 reimbursementId formNumber workflowInstanceId currentTaskId status budget precheckDecision openedRiskFlagCount 且通知 IM email 任一失败不影响 200 返回,cd server && node --check src/routes/reimbursements.js,manual,DONE,DONE,DONE,server/src/routes/reimbursements.js|server/src/services/notifications/*,"[""R33-2-0""]","只修 `/api/reimbursements/:id/submit`;不改权限模型 审批规则 状态机 与前端 API 类型。2026-03-24 已把 recipients/createNotifications/enqueueIm/sendNotificationEmailSafe 移到事务外;同日 cd server && node --check src/routes/reimbursements.js 通过。"
|
||||
R33-2-2,报销提交副作用 helper 与单测,提取报销提交成功后的事务外副作用 helper 并补 route 级单测证明其失败不会阻断业务提交,新增 __testables helper 且单测覆盖 resolveRecipients 或 enqueueImNotifications 失败时 helper 只记日志不抛致命错误,cd server && npm test -- tests/unit/routes/reimbursement.submit-side-effects.test.js,manual,DONE,DONE,DONE,server/src/routes/reimbursements.js|server/tests/unit/routes/reimbursement.submit-side-effects.test.js,"[""R33-2-1""]","单测只覆盖事务外副作用降级策略,不扩展到报销业务主链路。2026-03-24 已新增 __testables.finalizeReimbursementSubmitSideEffects;同日 cd server && npm test -- tests/unit/routes/reimbursement.submit-side-effects.test.js 通过。"
|
||||
R33-2-3,恢复 reimbursement main-flow 与 full-local 绿灯,验证 reimbursement submit 不再触发 P2028 并恢复整套本地门禁,main-flow 通过 且 bash scripts/qa/run-web-gates.sh full-local 通过且日志中不再出现 reimbursement submit P2028,cd client && npm run typecheck && cd client && npm run test:e2e -- tests/e2e/main-flow.spec.ts && bash scripts/qa/run-web-gates.sh full-local,manual,DONE,DONE,DONE,server/src/routes/reimbursements.js|client/tests/e2e/main-flow.spec.ts|scripts/qa/run-web-gates.sh,"[""R33-2-1"",""R33-2-2""]","若 full-local 再暴露新红灯,仅记录为范围外,不顺手扩修其他业务域。2026-03-24 已同步放宽 main-flow 超时并删除 OCR 中间态断言;同日 cd client && npm run typecheck 通过,bash scripts/qa/run-web-gates.sh full-local 最终通过,结果为 26 passed / 3 skipped / 0 failed,且日志未再出现 reimbursement submit P2028。"
|
||||
|
+225
-54
@@ -23,6 +23,7 @@ const {
|
||||
const { issueDocumentNumber } = require("../services/documentSequence");
|
||||
const {
|
||||
buildWorkflowStepsFromApprovalRule,
|
||||
getWorkflowStepsSnapshot,
|
||||
resolveActionableStep,
|
||||
toMoneyString,
|
||||
} = require("../utils/workflow");
|
||||
@@ -81,6 +82,38 @@ function ensureOwnOrCanViewAll(fastify, request, reimbursement) {
|
||||
}
|
||||
}
|
||||
|
||||
function buildWorkflowProgress(workflowInstance, historyItems = []) {
|
||||
if (!workflowInstance?.current_step_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const steps = getWorkflowStepsSnapshot(workflowInstance);
|
||||
const currentStepIndex = steps.findIndex((step) => step.id === workflowInstance.current_step_id);
|
||||
if (currentStepIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const skippedStepIds = new Set(
|
||||
historyItems
|
||||
.filter((item) => item.action === "skip" && item.payload?.step_id)
|
||||
.map((item) => String(item.payload.step_id)),
|
||||
);
|
||||
|
||||
return {
|
||||
currentStepIndex,
|
||||
totalSteps: steps.length,
|
||||
steps: steps.map((step, index) => ({
|
||||
id: step.id,
|
||||
name: step.name ?? step.id,
|
||||
status: skippedStepIds.has(step.id) || index < currentStepIndex
|
||||
? "completed"
|
||||
: index === currentStepIndex
|
||||
? "current"
|
||||
: "pending",
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function ensureApplicantOrAdmin(request, reimbursement) {
|
||||
if (request.employee?.roleCodes?.includes("admin")) {
|
||||
return;
|
||||
@@ -423,6 +456,161 @@ function buildPrecheckBlockedError(precheck) {
|
||||
});
|
||||
}
|
||||
|
||||
function buildSkippedNotificationResult(reason) {
|
||||
return { skipped: true, reason };
|
||||
}
|
||||
|
||||
function buildSkippedImResult(reason) {
|
||||
return { queued: 0, duplicated: 0, matchedChannels: [], skippedReason: reason };
|
||||
}
|
||||
|
||||
function buildSkippedEmailResult(reason, failed = 0) {
|
||||
return { skipped: true, reason, sent: 0, failed };
|
||||
}
|
||||
|
||||
async function finalizeReimbursementSubmitSideEffects({
|
||||
prisma,
|
||||
config,
|
||||
logger,
|
||||
requestId,
|
||||
reimbursementId,
|
||||
formNumber,
|
||||
workflowTaskId,
|
||||
assigneeId,
|
||||
assigneeRole,
|
||||
actorId,
|
||||
resolveRecipients = resolveRecipientsForAssignee,
|
||||
createAppNotifications = createNotifications,
|
||||
enqueueIm = enqueueImNotifications,
|
||||
sendEmailSafe = sendNotificationEmailSafe,
|
||||
}) {
|
||||
const title = `【待办】报销单 ${formNumber} 待审批`;
|
||||
const body = `报销单 ${formNumber} 已提交,请及时处理。`;
|
||||
const targetPath = `/workflow/tasks/${workflowTaskId}`;
|
||||
|
||||
let recipientEmployeeIds = [];
|
||||
try {
|
||||
recipientEmployeeIds = await resolveRecipients(prisma, {
|
||||
assigneeId,
|
||||
assigneeRole,
|
||||
});
|
||||
} catch (error) {
|
||||
logger?.error?.(
|
||||
{
|
||||
err: error,
|
||||
requestId,
|
||||
reimbursementId,
|
||||
workflowTaskId,
|
||||
},
|
||||
"Failed to resolve reimbursement submit notification recipients",
|
||||
);
|
||||
return {
|
||||
recipientEmployeeIds: [],
|
||||
notification: buildSkippedNotificationResult("recipient_resolution_failed"),
|
||||
im: buildSkippedImResult("recipient_resolution_failed"),
|
||||
email: buildSkippedEmailResult("recipient_resolution_failed"),
|
||||
};
|
||||
}
|
||||
|
||||
if (!recipientEmployeeIds.length) {
|
||||
return {
|
||||
recipientEmployeeIds: [],
|
||||
notification: buildSkippedNotificationResult("no_recipients"),
|
||||
im: buildSkippedImResult("no_recipients"),
|
||||
email: buildSkippedEmailResult("no_recipients"),
|
||||
};
|
||||
}
|
||||
|
||||
let notification = buildSkippedNotificationResult("not_attempted");
|
||||
try {
|
||||
notification = await createAppNotifications(prisma, {
|
||||
recipientEmployeeIds,
|
||||
type: NOTIFICATION_TYPES.workflowTaskAssigned,
|
||||
title,
|
||||
body,
|
||||
targetType: "workflow_task",
|
||||
targetId: workflowTaskId,
|
||||
});
|
||||
} catch (error) {
|
||||
logger?.error?.(
|
||||
{
|
||||
err: error,
|
||||
requestId,
|
||||
reimbursementId,
|
||||
workflowTaskId,
|
||||
recipients: recipientEmployeeIds.length,
|
||||
},
|
||||
"Failed to create reimbursement submit notifications",
|
||||
);
|
||||
notification = buildSkippedNotificationResult("notification_failed");
|
||||
}
|
||||
|
||||
let im = buildSkippedImResult("not_attempted");
|
||||
try {
|
||||
im = await enqueueIm(prisma, {
|
||||
config,
|
||||
logger,
|
||||
recipientEmployeeIds,
|
||||
type: NOTIFICATION_TYPES.workflowTaskAssigned,
|
||||
title,
|
||||
body,
|
||||
targetType: "workflow_task",
|
||||
targetId: workflowTaskId,
|
||||
targetPath,
|
||||
entityType: "reimbursement",
|
||||
entityId: reimbursementId,
|
||||
actorId,
|
||||
});
|
||||
} catch (error) {
|
||||
logger?.error?.(
|
||||
{
|
||||
err: error,
|
||||
requestId,
|
||||
reimbursementId,
|
||||
workflowTaskId,
|
||||
recipients: recipientEmployeeIds.length,
|
||||
},
|
||||
"Failed to enqueue reimbursement submit IM notifications",
|
||||
);
|
||||
im = buildSkippedImResult("enqueue_im_failed");
|
||||
}
|
||||
|
||||
let email = buildSkippedEmailResult("not_attempted");
|
||||
try {
|
||||
const emailResult = await sendEmailSafe({
|
||||
prisma,
|
||||
config,
|
||||
logger,
|
||||
requestId,
|
||||
recipientEmployeeIds,
|
||||
subject: title,
|
||||
body,
|
||||
type: NOTIFICATION_TYPES.workflowTaskAssigned,
|
||||
targetPath,
|
||||
});
|
||||
email = emailResult?.email ?? emailResult ?? buildSkippedEmailResult("empty_email_result");
|
||||
} catch (error) {
|
||||
logger?.error?.(
|
||||
{
|
||||
err: error,
|
||||
requestId,
|
||||
reimbursementId,
|
||||
workflowTaskId,
|
||||
recipients: recipientEmployeeIds.length,
|
||||
},
|
||||
"Failed to send reimbursement submit notification email",
|
||||
);
|
||||
email = buildSkippedEmailResult("email_failed", recipientEmployeeIds.length);
|
||||
}
|
||||
|
||||
return {
|
||||
recipientEmployeeIds,
|
||||
notification,
|
||||
im,
|
||||
email,
|
||||
};
|
||||
}
|
||||
|
||||
async function reimbursementRoutes(fastify) {
|
||||
const createBodySchema = z.object({
|
||||
ownerEmployeeId: z.string().uuid().optional(),
|
||||
@@ -705,6 +893,13 @@ async function reimbursementRoutes(fastify) {
|
||||
select: {
|
||||
id: true,
|
||||
current_step_id: true,
|
||||
current_status: true,
|
||||
context_data: true,
|
||||
workflow_definition: {
|
||||
select: {
|
||||
definition_json: true,
|
||||
},
|
||||
},
|
||||
workflow_tasks: {
|
||||
where: { status: "pending" },
|
||||
take: 1,
|
||||
@@ -738,6 +933,16 @@ async function reimbursementRoutes(fastify) {
|
||||
|
||||
const pendingInstance = reimbursement.workflow_instances[0] ?? null;
|
||||
const pendingTask = pendingInstance?.workflow_tasks?.[0] ?? null;
|
||||
const workflowHistory = pendingInstance
|
||||
? await fastify.prisma.workflowHistory.findMany({
|
||||
where: { instance_id: pendingInstance.id },
|
||||
orderBy: { created_at: "asc" },
|
||||
select: {
|
||||
action: true,
|
||||
payload: true,
|
||||
},
|
||||
})
|
||||
: [];
|
||||
const riskFlags = await fastify.prisma.entityRiskFlag.findMany({
|
||||
where: {
|
||||
entity_type: "reimbursement",
|
||||
@@ -792,6 +997,7 @@ async function reimbursementRoutes(fastify) {
|
||||
task: pendingTask,
|
||||
}
|
||||
: null,
|
||||
workflowProgress: pendingInstance ? buildWorkflowProgress(pendingInstance, workflowHistory) : null,
|
||||
riskFlags: riskFlags.map(buildPersistedRiskFlag),
|
||||
});
|
||||
},
|
||||
@@ -1294,11 +1500,8 @@ async function reimbursementRoutes(fastify) {
|
||||
},
|
||||
async (request, reply) => {
|
||||
const reimbursementId = parseReimbursementId(request.params?.id);
|
||||
let submitResult = null;
|
||||
const submitResult = await fastify.prisma.$transaction(async (tx) => {
|
||||
let budgetCheck = null;
|
||||
const emailTasks = [];
|
||||
|
||||
await fastify.prisma.$transaction(async (tx) => {
|
||||
const reimbursementRows = await tx.$queryRaw`
|
||||
SELECT id, owner_id, submitter_id, department_id, reimbursement_date, status, form_number, total_amount
|
||||
FROM "reimbursement_forms"
|
||||
@@ -1579,50 +1782,6 @@ async function reimbursementRoutes(fastify) {
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const recipients = await resolveRecipientsForAssignee(tx, {
|
||||
assigneeId: workflowTask.assignee_id,
|
||||
assigneeRole: workflowTask.assignee_role,
|
||||
});
|
||||
|
||||
await createNotifications(tx, {
|
||||
recipientEmployeeIds: recipients,
|
||||
type: NOTIFICATION_TYPES.workflowTaskAssigned,
|
||||
title: `【待办】报销单 ${formNumber} 待审批`,
|
||||
body: `报销单 ${formNumber} 已提交,请及时处理。`,
|
||||
targetType: "workflow_task",
|
||||
targetId: workflowTask.id,
|
||||
});
|
||||
|
||||
await enqueueImNotifications(tx, {
|
||||
config: fastify.config,
|
||||
logger: fastify.log,
|
||||
recipientEmployeeIds: recipients,
|
||||
type: NOTIFICATION_TYPES.workflowTaskAssigned,
|
||||
title: `【待办】报销单 ${formNumber} 待审批`,
|
||||
body: `报销单 ${formNumber} 已提交,请及时处理。`,
|
||||
targetType: "workflow_task",
|
||||
targetId: workflowTask.id,
|
||||
targetPath: `/workflow/tasks/${workflowTask.id}`,
|
||||
entityType: "reimbursement",
|
||||
entityId: reimbursementId,
|
||||
actorId: request.employee.id,
|
||||
});
|
||||
|
||||
emailTasks.push({
|
||||
type: NOTIFICATION_TYPES.workflowTaskAssigned,
|
||||
recipientEmployeeIds: recipients,
|
||||
subject: `【待办】报销单 ${formNumber} 待审批`,
|
||||
body: `报销单 ${formNumber} 已提交,请及时处理。`,
|
||||
targetPath: `/workflow/tasks/${workflowTask.id}`,
|
||||
});
|
||||
} catch (error) {
|
||||
fastify.log.error(
|
||||
{ err: error, reimbursementId, taskId: workflowTask.id },
|
||||
"Failed to create task assigned notifications",
|
||||
);
|
||||
}
|
||||
|
||||
if (reimbursement.submitter_id !== reimbursement.owner_id) {
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
@@ -1639,7 +1798,8 @@ async function reimbursementRoutes(fastify) {
|
||||
});
|
||||
}
|
||||
|
||||
submitResult = {
|
||||
return {
|
||||
response: {
|
||||
reimbursementId,
|
||||
formNumber,
|
||||
workflowInstanceId: workflowInstance.id,
|
||||
@@ -1648,20 +1808,27 @@ async function reimbursementRoutes(fastify) {
|
||||
budget: budgetCheck,
|
||||
precheckDecision: authoritativePrecheck.decision,
|
||||
openedRiskFlagCount: openedRiskFlags.length,
|
||||
},
|
||||
sideEffectsContext: {
|
||||
reimbursementId,
|
||||
formNumber,
|
||||
workflowTaskId: workflowTask.id,
|
||||
assigneeId: workflowTask.assignee_id,
|
||||
assigneeRole: workflowTask.assignee_role,
|
||||
actorId: request.employee.id,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
for (const task of emailTasks) {
|
||||
void sendNotificationEmailSafe({
|
||||
await finalizeReimbursementSubmitSideEffects({
|
||||
prisma: fastify.prisma,
|
||||
config: fastify.config,
|
||||
logger: fastify.log,
|
||||
requestId: request.id,
|
||||
...task,
|
||||
...submitResult.sideEffectsContext,
|
||||
});
|
||||
}
|
||||
|
||||
return sendData(reply, request, submitResult);
|
||||
return sendData(reply, request, submitResult.response);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2093,3 +2260,7 @@ async function reimbursementRoutes(fastify) {
|
||||
}
|
||||
|
||||
module.exports = reimbursementRoutes;
|
||||
|
||||
module.exports.__testables = {
|
||||
finalizeReimbursementSubmitSideEffects,
|
||||
};
|
||||
|
||||
@@ -231,7 +231,7 @@ function isoOrNull(value) {
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
function mapTaskSummary(task) {
|
||||
function mapTaskSummary(task, allowedActions = []) {
|
||||
const workflowInstance = task.workflow_instance
|
||||
? {
|
||||
entity_type: task.workflow_instance.entity_type,
|
||||
@@ -260,6 +260,7 @@ function mapTaskSummary(task) {
|
||||
assigneeRole: task.assignee_role,
|
||||
createdAt: isoOrNull(task.created_at),
|
||||
completedAt: isoOrNull(task.completed_at),
|
||||
allowedActions,
|
||||
workflowInstance: workflowInstance
|
||||
? {
|
||||
entityType: workflowInstance.entity_type,
|
||||
@@ -860,11 +861,31 @@ async function workflowRoutes(fastify) {
|
||||
fastify.prisma.workflowTask.count({ where }),
|
||||
]);
|
||||
|
||||
const taskSummaries = await Promise.all(
|
||||
tasks.map(async (task) => {
|
||||
let actionable = false;
|
||||
if (task.status === "pending") {
|
||||
try {
|
||||
await ensureTaskAssignableToCurrentEmployee(fastify.prisma, task, request);
|
||||
actionable = true;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError && error.code === "PERM_002") {
|
||||
actionable = false;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mapTaskSummary(task, listAllowedActionsForTask(request, task, actionable));
|
||||
}),
|
||||
);
|
||||
|
||||
return sendData(reply, request, {
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
total,
|
||||
items: tasks.map(mapTaskSummary),
|
||||
items: taskSummaries,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
const reimbursementRoutes = require('../../../src/routes/reimbursements');
|
||||
|
||||
describe('reimbursement submit side effects helper', () => {
|
||||
const { finalizeReimbursementSubmitSideEffects } = reimbursementRoutes.__testables;
|
||||
|
||||
function createParams(overrides = {}) {
|
||||
return {
|
||||
prisma: {},
|
||||
config: {
|
||||
imNotifierEnabled: true,
|
||||
emailEnabled: false,
|
||||
},
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
requestId: 'req-1',
|
||||
reimbursementId: 'reimbursement-1',
|
||||
formNumber: 'BX-20260324-001',
|
||||
workflowTaskId: 'task-1',
|
||||
assigneeId: 'manager-1',
|
||||
assigneeRole: null,
|
||||
actorId: 'employee-1',
|
||||
resolveRecipients: vi.fn().mockResolvedValue(['manager-1']),
|
||||
createAppNotifications: vi.fn().mockResolvedValue({ count: 1 }),
|
||||
enqueueIm: vi.fn().mockResolvedValue({
|
||||
queued: 1,
|
||||
duplicated: 0,
|
||||
matchedChannels: [{ id: 'channel-1' }],
|
||||
}),
|
||||
sendEmailSafe: vi.fn().mockResolvedValue({
|
||||
email: { skipped: true, sent: 0, failed: 0 },
|
||||
}),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it('does not throw when resolving recipients fails', async () => {
|
||||
const logger = {
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
};
|
||||
const createAppNotifications = vi.fn();
|
||||
const enqueueIm = vi.fn();
|
||||
const sendEmailSafe = vi.fn();
|
||||
|
||||
const result = await finalizeReimbursementSubmitSideEffects(
|
||||
createParams({
|
||||
logger,
|
||||
resolveRecipients: vi.fn().mockRejectedValue(new Error('resolve failed')),
|
||||
createAppNotifications,
|
||||
enqueueIm,
|
||||
sendEmailSafe,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledTimes(1);
|
||||
expect(createAppNotifications).not.toHaveBeenCalled();
|
||||
expect(enqueueIm).not.toHaveBeenCalled();
|
||||
expect(sendEmailSafe).not.toHaveBeenCalled();
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
recipientEmployeeIds: [],
|
||||
notification: expect.objectContaining({ skipped: true, reason: 'recipient_resolution_failed' }),
|
||||
im: expect.objectContaining({ queued: 0, skippedReason: 'recipient_resolution_failed' }),
|
||||
email: expect.objectContaining({ skipped: true, reason: 'recipient_resolution_failed' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('logs notification and IM failures and still continues to email', async () => {
|
||||
const logger = {
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
};
|
||||
const enqueueIm = vi.fn().mockRejectedValue(new Error('im failed'));
|
||||
const sendEmailSafe = vi.fn().mockResolvedValue({
|
||||
email: { skipped: true, sent: 0, failed: 0 },
|
||||
});
|
||||
|
||||
const result = await finalizeReimbursementSubmitSideEffects(
|
||||
createParams({
|
||||
logger,
|
||||
createAppNotifications: vi.fn().mockRejectedValue(new Error('notifications failed')),
|
||||
enqueueIm,
|
||||
sendEmailSafe,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledTimes(2);
|
||||
expect(enqueueIm).toHaveBeenCalledTimes(1);
|
||||
expect(sendEmailSafe).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
recipientEmployeeIds: ['manager-1'],
|
||||
notification: expect.objectContaining({ skipped: true, reason: 'notification_failed' }),
|
||||
im: expect.objectContaining({ queued: 0, skippedReason: 'enqueue_im_failed' }),
|
||||
email: expect.objectContaining({ skipped: true, sent: 0, failed: 0 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户