feat: 补齐报销付款待办交接(09-11-reimbursement-payment-handoffs)
这个提交包含在:
@@ -79,6 +79,19 @@ export type PortalHomePayload = {
|
||||
canViewTeam: boolean;
|
||||
isAdminOnly: boolean;
|
||||
};
|
||||
businessHandoffs?: {
|
||||
total: number;
|
||||
items: Array<{
|
||||
key: string;
|
||||
kind: "reimbursement_edit" | "reimbursement_batch" | "payment_review" | "payment_execute";
|
||||
reference: string | null;
|
||||
title: string;
|
||||
nextStep: string;
|
||||
ownerLabel: string;
|
||||
href: string;
|
||||
mobileHref: string;
|
||||
}>;
|
||||
};
|
||||
financeSummary?: {
|
||||
pendingBatchCount: number;
|
||||
failedItemCount: number;
|
||||
|
||||
@@ -131,6 +131,9 @@ export function MobilePortalPage() {
|
||||
enabled: canUseFinance,
|
||||
queryKey: ["mobile", "portal", "home"],
|
||||
queryFn: () => fetchPortalHome(),
|
||||
staleTime: 0,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -167,13 +170,14 @@ export function MobilePortalPage() {
|
||||
.filter((item) => !(canAccessApprovals && item.type.startsWith("workflow.task_")))
|
||||
.slice(0, 3);
|
||||
const financeSummary = portalHomeQuery.data?.financeSummary ?? null;
|
||||
const businessHandoffs = portalHomeQuery.data?.businessHandoffs;
|
||||
const paymentBatchEnabled = Boolean(financeSummary?.paymentBatchEnabled);
|
||||
const financePendingCount = financeSummary?.pendingBatchCount ?? 0;
|
||||
const financeExceptionCount = (financeSummary?.failedItemCount ?? 0) + (financeSummary?.riskCount ?? 0);
|
||||
const financeExceptionCount = (businessHandoffs ? 0 : financeSummary?.failedItemCount ?? 0) + (financeSummary?.riskCount ?? 0);
|
||||
const shouldShowFinanceCard = canUseFinance &&
|
||||
!portalHomeQuery.isLoading &&
|
||||
!portalHomeQuery.isError &&
|
||||
(paymentBatchEnabled || financePendingCount > 0 || financeExceptionCount > 0);
|
||||
(businessHandoffs ? financeExceptionCount > 0 : paymentBatchEnabled || financePendingCount > 0 || financeExceptionCount > 0);
|
||||
const financeCardBadge = paymentBatchEnabled ? "复核与付款" : financePendingCount > 0 ? "待付款" : "异常与复核";
|
||||
|
||||
async function openHomeNotification(item: NotificationItem) {
|
||||
@@ -298,6 +302,21 @@ export function MobilePortalPage() {
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{portalHomeQuery.isError ? <section className="mobile-card space-y-3" role="alert"><p>业务待办加载失败,请重试。</p><Button onClick={() => void portalHomeQuery.refetch()} type="button" variant="secondary">重试加载待办</Button></section> : null}
|
||||
|
||||
{!portalHomeQuery.isError && businessHandoffs && businessHandoffs.total > 0 ? (
|
||||
<section className="mobile-card mobile-page-stack" data-testid="mobile-business-handoffs">
|
||||
<div className="mobile-section-header"><h2 className="mobile-section-title">业务待办</h2><span>{businessHandoffs.total} 项</span></div>
|
||||
{businessHandoffs.items.map((item) => (
|
||||
<Link className="mobile-list-card block" key={item.key} to={item.mobileHref}>
|
||||
<div className="mobile-list-card__title">{item.title}{item.reference ? ` · ${item.reference}` : ""}</div>
|
||||
<p className="mobile-list-card__subtitle mt-1">{item.nextStep}</p>
|
||||
<p className="mt-1 text-xs text-zinc-500">{item.ownerLabel} · 查看处理</p>
|
||||
</Link>
|
||||
))}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{shouldShowFinanceCard ? (
|
||||
<section id="finance" tabIndex={-1} className="mobile-card mobile-page-stack" data-testid="mobile-finance-summary">
|
||||
<div className="mobile-section-header">
|
||||
@@ -305,10 +324,10 @@ export function MobilePortalPage() {
|
||||
<span className="badge-neutral">{financeCardBadge}</span>
|
||||
</div>
|
||||
<div className="mobile-metric-grid text-sm">
|
||||
<div className="rounded-lg bg-zinc-50 px-3 py-2">
|
||||
{!businessHandoffs ? <div className="rounded-lg bg-zinc-50 px-3 py-2">
|
||||
<div className="text-xs text-zinc-500">待付款</div>
|
||||
<div className="mt-1 text-xl font-semibold text-zinc-900">{financePendingCount}</div>
|
||||
</div>
|
||||
</div> : null}
|
||||
<div className="rounded-lg bg-zinc-50 px-3 py-2">
|
||||
<div className="text-xs text-zinc-500">异常</div>
|
||||
<div className="mt-1 text-xl font-semibold text-zinc-900">{financeExceptionCount}</div>
|
||||
|
||||
@@ -28,6 +28,7 @@ type WorkItem = {
|
||||
icon: string;
|
||||
priority: number;
|
||||
count: number;
|
||||
showReason?: boolean;
|
||||
};
|
||||
|
||||
type EmptyAction = {
|
||||
@@ -107,6 +108,11 @@ function uniqueWorkItems(items: WorkItem[]) {
|
||||
|
||||
function buildTodayActionItems(payload: PortalHomePayload, roleCapabilities: RoleCapabilities) {
|
||||
const items: WorkItem[] = [];
|
||||
for (const handoff of payload.businessHandoffs?.items ?? []) {
|
||||
items.push({ key: handoff.key, title: [handoff.title, handoff.reference].filter(Boolean).join(" · "),
|
||||
reason: handoff.nextStep, meta: handoff.ownerLabel, href: handoff.href, actionLabel: "查看处理",
|
||||
severity: "warning", icon: "assignment", priority: 98, count: 1, showReason: true });
|
||||
}
|
||||
|
||||
if (payload.pendingTasks.count > 0) {
|
||||
items.push({
|
||||
@@ -158,7 +164,7 @@ function buildTodayActionItems(payload: PortalHomePayload, roleCapabilities: Rol
|
||||
}
|
||||
|
||||
if (roleCapabilities.canUseFinance && payload.financeSummary) {
|
||||
if (payload.financeSummary.failedItemCount > 0) {
|
||||
if (!payload.businessHandoffs && payload.financeSummary.failedItemCount > 0) {
|
||||
items.push({
|
||||
key: "finance-failed-payment",
|
||||
title: `异常付款 ${payload.financeSummary.failedItemCount} 项`,
|
||||
@@ -188,7 +194,7 @@ function buildTodayActionItems(payload: PortalHomePayload, roleCapabilities: Rol
|
||||
});
|
||||
}
|
||||
|
||||
if (payload.financeSummary.pendingBatchCount > 0) {
|
||||
if (!payload.businessHandoffs && payload.financeSummary.pendingBatchCount > 0) {
|
||||
items.push({
|
||||
key: "finance-pending-batch",
|
||||
title: `待付款批次 ${payload.financeSummary.pendingBatchCount} 个`,
|
||||
@@ -234,7 +240,7 @@ function buildTodayActionItems(payload: PortalHomePayload, roleCapabilities: Rol
|
||||
});
|
||||
}
|
||||
|
||||
return uniqueWorkItems(items).slice(0, 8);
|
||||
return uniqueWorkItems(items);
|
||||
}
|
||||
|
||||
function buildProgressItems(payload: PortalHomePayload) {
|
||||
@@ -579,6 +585,7 @@ function WorkItemRow({ item }: { item: WorkItem }) {
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="portal-task-row__title">{item.title}</span>
|
||||
{item.showReason ? <span className="mt-1 block text-sm text-zinc-600">{item.reason}</span> : null}
|
||||
</span>
|
||||
<span className="portal-task-row__meta">{item.meta}</span>
|
||||
<span className="portal-task-row__action">{item.actionLabel}</span>
|
||||
@@ -680,7 +687,7 @@ function ConditionalWorkPanel({
|
||||
export function PortalHomePage() {
|
||||
const { me, permissions } = useAuth();
|
||||
const [showWelcome, setShowWelcome] = useState(false);
|
||||
const portalQuery = useQuery({ queryKey: ["portal-home"], queryFn: () => fetchPortalHome() });
|
||||
const portalQuery = useQuery({ queryKey: ["portal-home"], queryFn: () => fetchPortalHome(), staleTime: 0, refetchOnMount: true, refetchOnWindowFocus: true });
|
||||
const canSetupWorkspace = hasAnyPermission(permissions, ["system.settings"]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -3,6 +3,9 @@ import path from "node:path";
|
||||
import { expect, test, type Page, type TestInfo } from "@playwright/test";
|
||||
|
||||
import { EMPLOYEE, FINANCE, login, type E2EUser } from "./helpers/auth";
|
||||
import type { PortalHomePayload } from "../../src/api/portal";
|
||||
|
||||
test.use({ serviceWorkers: "block" });
|
||||
|
||||
const ADMIN: E2EUser = {
|
||||
identifier: "[email protected]",
|
||||
@@ -109,7 +112,7 @@ function portalPayload(mode: "employee" | "admin" | "finance") {
|
||||
};
|
||||
}
|
||||
|
||||
async function mockVisualPortal(page: Page, mode: "employee" | "admin" | "finance") {
|
||||
async function mockVisualPortal(page: Page, mode: "employee" | "admin" | "finance", handoffs?: () => PortalHomePayload["businessHandoffs"]) {
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.removeItem("hyb_oa_show_welcome");
|
||||
window.localStorage.removeItem("hyb_oa_onboarding_active");
|
||||
@@ -131,8 +134,8 @@ async function mockVisualPortal(page: Page, mode: "employee" | "admin" | "financ
|
||||
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope({ enabled: false, entryEnabled: false, oauthEnabled: false, corpId: "", agentId: "", loginUrl: null, bindEnabled: false, defaultReturnTo: "/portal", status: "disabled", statusReasons: [] })) });
|
||||
return;
|
||||
}
|
||||
if (pathName === "/api/auth/login") {
|
||||
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope({ accessToken: "visual-token", expiresIn: 900 })) });
|
||||
if (pathName === "/api/auth/login" || pathName === "/api/auth/refresh") {
|
||||
await route.fulfill({ status: 200, contentType: "application/json", headers: { "set-cookie": "has_session=1; Path=/; SameSite=Lax" }, body: JSON.stringify(envelope({ accessToken: "visual-token", expiresIn: 900 })) });
|
||||
return;
|
||||
}
|
||||
if (pathName === "/api/me") {
|
||||
@@ -178,7 +181,12 @@ async function mockVisualPortal(page: Page, mode: "employee" | "admin" | "financ
|
||||
return;
|
||||
}
|
||||
if (pathName === "/api/portal/home") {
|
||||
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope(portalPayload(mode))) });
|
||||
const payload = portalPayload(mode);
|
||||
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope(handoffs ? {
|
||||
...payload, businessHandoffs: handoffs(), pendingTasks: { count: 0, href: "/workflow/tasks" },
|
||||
todoSummary: null, weeklyReport: null, projectSummary: null,
|
||||
financeSummary: mode === "finance" ? { ...payload.financeSummary, pendingBatchCount: 9, failedItemCount: 1, riskCount: 0 } : null,
|
||||
} : payload)) });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -187,6 +195,56 @@ async function mockVisualPortal(page: Page, mode: "employee" | "admin" | "financ
|
||||
}
|
||||
|
||||
test.describe("visual portal", () => {
|
||||
for (const mobile of [false, true]) {
|
||||
test(`业务交接${mobile ? "手机" : "PC"}显示真实事项并在处理后刷新消失`, async ({ page }, testInfo) => {
|
||||
test.skip(process.env.E2E_MODE === "live", "仅本地合成事项");
|
||||
let items: NonNullable<PortalHomePayload["businessHandoffs"]>["items"] = [
|
||||
{ key: "reimbursement-batch:synthetic", kind: "reimbursement_batch", reference: "BX-SYN", title: "报销待组建付款批次", nextStep: "核对付款资料后组批", ownerLabel: "财务", href: "/reimbursements/synthetic", mobileHref: "/mobile/reimbursements/synthetic" },
|
||||
{ key: "payment-execute:synthetic", kind: "payment_execute", reference: "PB-SYN", title: "付款与回单待处理", nextStep: "已生成银行制单,等待付款完成并上传回单", ownerLabel: "财务", href: "/payment-batches?id=synthetic", mobileHref: "/mobile/payment-batches?id=synthetic" },
|
||||
];
|
||||
await mockVisualPortal(page, "finance", () => ({ total: items.length, items }));
|
||||
await page.setViewportSize(mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 });
|
||||
await login(page, FINANCE);
|
||||
await page.goto(mobile ? "/mobile" : "/portal");
|
||||
await expect(page.getByText("报销待组建付款批次 · BX-SYN", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("付款与回单待处理 · PB-SYN", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("待付款批次 9 个", { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByText("异常付款 1 项", { exact: true })).toHaveCount(0);
|
||||
if (mobile) await expect(page.getByTestId("mobile-business-handoffs")).toContainText("2 项");
|
||||
else await expect(page.getByTestId("portal-work-summary")).toContainText("2");
|
||||
await page.screenshot({ path: testInfo.outputPath("handoffs.png"), fullPage: true });
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 2)).toBe(true);
|
||||
items = [];
|
||||
await page.reload();
|
||||
await expect(page.getByText("付款与回单待处理 · PB-SYN", { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByText("待付款批次 9 个", { exact: true })).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
|
||||
test("员工退回待修改直接进入本人的报销,不依赖通知未读数", async ({ page }) => {
|
||||
test.skip(process.env.E2E_MODE === "live", "仅本地合成事项");
|
||||
await mockVisualPortal(page, "employee", () => ({ total: 1, items: [{ key: "reimbursement-edit:synthetic", kind: "reimbursement_edit", reference: "BX-SYN", title: "报销退回待修改", nextStep: "修改后重新提交", ownerLabel: "报销人", href: "/reimbursements/synthetic", mobileHref: "/mobile/reimbursements/synthetic" }] }));
|
||||
await login(page, EMPLOYEE);
|
||||
await page.goto("/portal");
|
||||
const row = page.getByRole("link").filter({ hasText: "报销退回待修改" });
|
||||
await expect(row).toHaveCount(1);
|
||||
await expect(row).toHaveAttribute("href", "/reimbursements/synthetic");
|
||||
});
|
||||
|
||||
test("超过八条业务事项时计数与展示不截断", async ({ page }) => {
|
||||
test.skip(process.env.E2E_MODE === "live", "仅本地合成事项");
|
||||
const items: NonNullable<PortalHomePayload["businessHandoffs"]>["items"] = Array.from({ length: 9 }, (_, index) => ({
|
||||
key: `reimbursement-batch:synthetic-${index}`, kind: "reimbursement_batch", reference: `BX-SYN-${index}`,
|
||||
title: "报销待组建付款批次", nextStep: "核对付款资料后组批", ownerLabel: "财务",
|
||||
href: `/reimbursements/synthetic-${index}`, mobileHref: `/mobile/reimbursements/synthetic-${index}`,
|
||||
}));
|
||||
await mockVisualPortal(page, "finance", () => ({ total: items.length, items }));
|
||||
await login(page, FINANCE);
|
||||
await page.goto("/portal");
|
||||
await expect(page.getByRole("link").filter({ hasText: "报销待组建付款批次" })).toHaveCount(9);
|
||||
await expect(page.getByTestId("portal-work-summary")).toContainText("9");
|
||||
});
|
||||
|
||||
test("员工 Portal 视觉基线", async ({ page }, testInfo) => {
|
||||
await mockVisualPortal(page, "employee");
|
||||
await page.setViewportSize({ width: 1440, height: 1200 });
|
||||
|
||||
在新工单中引用
屏蔽一个用户