异常
{financeExceptionCount}
diff --git a/client/src/pages/PortalHomePage.tsx b/client/src/pages/PortalHomePage.tsx
index fa44092d..cfd02772 100644
--- a/client/src/pages/PortalHomePage.tsx
+++ b/client/src/pages/PortalHomePage.tsx
@@ -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 }) {
{item.title}
+ {item.showReason ? {item.reason} : null}
{item.meta}
{item.actionLabel}
@@ -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(() => {
diff --git a/client/tests/e2e/visual-portal.spec.ts b/client/tests/e2e/visual-portal.spec.ts
index f3b54546..3620659a 100644
--- a/client/tests/e2e/visual-portal.spec.ts
+++ b/client/tests/e2e/visual-portal.spec.ts
@@ -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: "admin@hyinda.com",
@@ -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
["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["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 });
diff --git a/scripts/qa/test-invoice-first-http.cjs b/scripts/qa/test-invoice-first-http.cjs
index a898d9b2..a8796bf2 100644
--- a/scripts/qa/test-invoice-first-http.cjs
+++ b/scripts/qa/test-invoice-first-http.cjs
@@ -96,6 +96,11 @@ async function main() {
if (process.env.RUN_PAYMENT_EXPERIENCE_TEST === "1") {
await app.register(serverRequire("./src/routes/paymentBatches"), { prefix: "/api" });
}
+ if (process.env.RUN_HANDOFF_PROJECTION_TEST === "1") {
+ app.config.portalHomeEnabled = true;
+ await prisma.featureFlag.create({ data: { flag_key: "portal_home_v1", override_mode: "force_on", rollout_type: "global", department_ids: [] } });
+ await app.register(serverRequire("./src/routes/portal"), { prefix: "/api" });
+ }
if (process.env.RUN_JOB_QUEUE === "1") {
Object.assign(app.config, { jobDriver: "pgboss", pgBossSchema: "synthetic_jobs", ocrMaxAttempts: 3, ocrPollCron: "0 0 1 1 *", delegationRevokeCron: "0 0 1 1 *", invoiceArchiveCron: "0 0 1 1 *", integrationOutboxCron: "0 0 1 1 *" });
await serverRequire("./src/jobs/pgBossJobs").startPgBossJobs(app);
@@ -115,6 +120,21 @@ async function main() {
const response = await fetch(`${origin}/api${url}`, { method, headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, body: JSON.stringify(body), signal: AbortSignal.timeout(15000) });
return { status: response.status, body: await response.json() };
};
+ const handoffCheck = async (actor, kind, entityId, expectedCount) => {
+ if (process.env.RUN_HANDOFF_PROJECTION_TEST !== "1") return;
+ const auditCount = await prisma.auditLog.count();
+ const getHandoffs = async () => {
+ const response = await fetch(`${origin}/api/portal/home`, { headers: { authorization: `Bearer ${tokens.get(actor.id)}` }, signal: AbortSignal.timeout(15000) });
+ assert.equal(response.status, 200, "authorized portal handoff projection");
+ const payload = (await response.json()).data.businessHandoffs;
+ assert.equal(payload.total, payload.items.length);
+ assert.equal(new Set(payload.items.map((item) => item.key)).size, payload.total);
+ assert.equal(payload.items.filter((item) => item.kind === kind && item.key.endsWith(`:${entityId}`)).length, expectedCount);
+ return payload;
+ };
+ assert.deepEqual(await getHandoffs(), await getHandoffs());
+ assert.equal(await prisma.auditLog.count(), auditCount, "handoff reads do not create audit or workflow writes");
+ };
const bind = (invoice, form, actor) => post(`/invoice-pool/${invoice.id}/bind`, { reimbursementId: form.id, createItem: true }, actor);
const objects = [
"<< /Type /Catalog /Pages 2 0 R >>",
@@ -261,10 +281,13 @@ async function main() {
assert.equal((await post(`/workflow/tasks/${firstTask.id}/approve`, {})).status, 403);
assert.equal((await post(`/workflow/tasks/${firstTask.id}/return`, { comment: "synthetic correction required" }, manager.id)).status, 200);
assert.equal((await prisma.reimbursementForm.findUnique({ where: { id: form.id } })).status, "draft");
+ await handoffCheck(employee, "reimbursement_edit", form.id, 1);
+ await handoffCheck(approvers.finance, "reimbursement_edit", form.id, 0);
assert.equal((await prisma.invoicePool.findUnique({ where: { id: invoice.id } })).linked_reimbursement_id, form.id);
assert.equal((await post(`/reimbursements/${form.id}/items/${items[0].id}`, { description: "synthetic corrected purpose" }, null, "PATCH")).status, 200);
const returnedResubmit = await post(`/reimbursements/${form.id}/submit`, {});
assert.equal(returnedResubmit.status, 200);
+ await handoffCheck(employee, "reimbursement_edit", form.id, 0);
const rejectTask = await prisma.workflowTask.findFirst({ where: { instance_id: returnedResubmit.body.data.workflowInstanceId, status: "pending" } });
assert.equal((await post(`/workflow/tasks/${rejectTask.id}/reject`, { comment: "synthetic rejected correction" }, manager.id)).status, 200);
assert.equal((await prisma.reimbursementForm.findUnique({ where: { id: form.id } })).status, "rejected");
@@ -319,7 +342,7 @@ async function main() {
assert.equal((await prisma.workflowInstance.findUnique({ where: { id: newWorkflowId } })).current_status, "approved");
passed.push("owner cannot self-approve; return/edit/resubmit and manager/ceo/finance approval complete without payment");
if (process.env.RUN_PAYMENT_EXPERIENCE_TEST === "1") {
- await require("./test-payment-experience.cjs")({ app, prisma, employee, approvers, form, post, origin, tokens, serverRequire, passed, browser, preview, loginPassword });
+ await require("./test-payment-experience.cjs")({ app, prisma, employee, approvers, form, post, origin, tokens, serverRequire, passed, browser, preview, loginPassword, handoffCheck });
}
const competingForms = await Promise.all([makeForm(), makeForm()]);
@@ -401,7 +424,7 @@ async function main() {
await prisma.employee.update({ where: { id: employee.id }, data: { token_version: { increment: 1 } } });
assert.equal((await fetch(`${origin}/api/me`, { headers: { authorization: `Bearer ${tokens.get(employee.id)}` } })).status, 401);
passed.push("revoked token version rejected by real auth plugin");
- console.log(JSON.stringify({ status: "PASS_REAL_DB_HTTP", passed, authentication: "REAL_PASSWORD_LOGIN_JWT_SYNTHETIC_ACCOUNTS", liveOcr: "NOT_TESTED", ocrWorker: process.env.RUN_JOB_QUEUE === "1" ? "REAL_PGBOSS_MOCK_PROVIDER" : process.env.RUN_BROWSER_TEST === "1" ? "REAL_WORKER_MOCK_PROVIDER_MANUAL_DISPATCH" : "NOT_TESTED", approvalFlow: "RETURN_RESUBMIT_THREE_STEP_APPROVAL", payment: process.env.RUN_PAYMENT_EXPERIENCE_TEST === "1" ? "CREATE_REVIEW_DOWNLOAD_STILL_UNPAID" : "NOT_TESTED" }, null, 2));
+ console.log(JSON.stringify({ status: "PASS_REAL_DB_HTTP", passed, authentication: "REAL_PASSWORD_LOGIN_JWT_SYNTHETIC_ACCOUNTS", liveOcr: "NOT_TESTED", ocrWorker: process.env.RUN_JOB_QUEUE === "1" ? "REAL_PGBOSS_MOCK_PROVIDER" : process.env.RUN_BROWSER_TEST === "1" ? "REAL_WORKER_MOCK_PROVIDER_MANUAL_DISPATCH" : "NOT_TESTED", approvalFlow: "RETURN_RESUBMIT_THREE_STEP_APPROVAL", payment: process.env.RUN_HANDOFF_PROJECTION_TEST === "1" ? "SYNTHETIC_RECEIPT_RECORDED_PAID_NO_BANK_CONNECTION" : process.env.RUN_PAYMENT_EXPERIENCE_TEST === "1" ? "CREATE_REVIEW_DOWNLOAD_STILL_UNPAID" : "NOT_TESTED" }, null, 2));
} finally {
if (browser) await browser.close().catch(() => { process.exitCode = 1; console.error("Browser cleanup failed"); });
if (preview) await new Promise((resolve, reject) => preview.httpServer.close((error) => error ? reject(error) : resolve())).catch(() => { process.exitCode = 1; console.error("Preview cleanup failed"); });
diff --git a/scripts/qa/test-payment-experience.cjs b/scripts/qa/test-payment-experience.cjs
index add01888..3d6be482 100644
--- a/scripts/qa/test-payment-experience.cjs
+++ b/scripts/qa/test-payment-experience.cjs
@@ -5,7 +5,7 @@ const { createRequire } = require("node:module");
const path = require("node:path");
module.exports = async function verifyPaymentExperience({
- app, prisma, employee, approvers, form, post, origin, tokens, serverRequire, passed, browser, preview, loginPassword,
+ app, prisma, employee, approvers, form, post, origin, tokens, serverRequire, passed, browser, preview, loginPassword, handoffCheck,
}) {
app.config.fieldEncryptionKey = randomBytes(32).toString("base64");
app.config.paymentBatchEnabled = true;
@@ -23,6 +23,8 @@ module.exports = async function verifyPaymentExperience({
account_number_encrypted: encryptStringOrThrow(app.config, `6217${"0".repeat(8)}1234`),
} });
await prisma.featureFlag.create({ data: { flag_key: "payment_batch_v1", override_mode: "force_on", rollout_type: "global", department_ids: [] } });
+ await handoffCheck?.(approvers.finance, "reimbursement_batch", form.id, 1);
+ await handoffCheck?.(approvers.ceo, "reimbursement_batch", form.id, 0);
const readProgress = async (actor) => {
const response = await fetch(`${origin}/api/reimbursements/${form.id}`, {
headers: { authorization: `Bearer ${tokens.get(actor.id)}` }, signal: AbortSignal.timeout(15000),
@@ -36,6 +38,9 @@ module.exports = async function verifyPaymentExperience({
}, approvers.finance.id);
assert.equal(created.status, 201, `create payment batch: ${created.body.error?.details?.reasonCode ?? created.status}`);
const batch = created.body.data.item;
+ await handoffCheck?.(approvers.finance, "reimbursement_batch", form.id, 0);
+ await handoffCheck?.(approvers.ceo, "payment_review", batch.id, 1);
+ await handoffCheck?.(approvers.finance, "payment_review", batch.id, 0);
const reviewStage = (await readProgress(approvers.finance)).stages.find((stage) => stage.key === "payment_batch_review");
assert.equal(reviewStage.state, "current");
assert.equal(reviewStage.detail, "待非制单人复核付款");
@@ -46,6 +51,9 @@ module.exports = async function verifyPaymentExperience({
assert.equal((await download(approvers.finance)).status, 409);
assert.equal((await post(`/payment-batches/${batch.id}/review`, {}, approvers.finance.id)).status, 403);
assert.equal((await post(`/payment-batches/${batch.id}/review`, {}, approvers.ceo.id)).status, 200);
+ await handoffCheck?.(approvers.ceo, "payment_review", batch.id, 0);
+ await handoffCheck?.(approvers.finance, "payment_execute", batch.id, 1);
+ await handoffCheck?.(approvers.ceo, "payment_execute", batch.id, 0);
assert.equal((await readProgress(approvers.finance)).stages.find((stage) => stage.key === "payment_and_receipt").state, "current");
assert.equal((await download(approvers.ceo)).status, 403);
assert.equal((await download(employee)).status, 403);
@@ -125,4 +133,21 @@ module.exports = async function verifyPaymentExperience({
passed.push("payment: real authenticated desktop/mobile reimbursement-to-batch download and return agree on stage, unique link, action-before-progress; no reason input; repeat warning retained");
}
passed.push("payment: real finance create, CEO review, unauthorized export refused, Chinese BIFF8 first/re-download without reason, audited and still unpaid");
+ if (process.env.RUN_HANDOFF_PROJECTION_TEST === "1") {
+ await handoffCheck(approvers.finance, "payment_execute", batch.id, 1);
+ assert.equal((await post(`/payment-batches/${batch.id}/execute`, {}, approvers.finance.id)).status, 409);
+ const receipt = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jD1sAAAAASUVORK5CYII=", "base64");
+ const upload = new FormData();
+ upload.append("files", new Blob([receipt], { type: "image/png" }), "synthetic-receipt.png");
+ const uploaded = await fetch(`${origin}/api/payment-batches/${batch.id}/receipt-attachments`, {
+ method: "POST", headers: { authorization: `Bearer ${tokens.get(approvers.finance.id)}` }, body: upload, signal: AbortSignal.timeout(15000),
+ });
+ assert.equal(uploaded.status, 200, "synthetic receipt upload");
+ const executed = await post(`/payment-batches/${batch.id}/execute`, {}, approvers.finance.id);
+ assert.equal(executed.status, 200);
+ assert.equal((await prisma.reimbursementForm.findUnique({ where: { id: form.id } })).status, "paid");
+ await handoffCheck(approvers.finance, "payment_execute", batch.id, 0);
+ await handoffCheck(approvers.finance, "reimbursement_batch", form.id, 0);
+ passed.push("handoffs: real read-only portal return/resubmit/create/review/export/synthetic receipt/paid lifecycle; unique stable counts, no self-review, no bank connection");
+ }
};
diff --git a/server/src/routes/portal.js b/server/src/routes/portal.js
index bc630aeb..f9c762bd 100644
--- a/server/src/routes/portal.js
+++ b/server/src/routes/portal.js
@@ -7,6 +7,7 @@ const { sendData } = require("../utils/reply");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const { getWorkspaceSetting } = require("../services/workspaceSettings");
const { currentDateString, parseDateOnlyString } = require("../utils/date");
+const { loadReimbursementPaymentHandoffs } = require("../services/reimbursementPaymentHandoffs");
const PORTAL_RECENT_ACTIVITY_LIMIT = 15;
const PORTAL_RECENT_ACTIVITY_SOURCE_TAKE = 24;
@@ -455,6 +456,7 @@ async function portalRoutes(fastify) {
await ensureFeatureEnabled(fastify, request);
const workspace = await getWorkspaceSetting(fastify.prisma);
+ const businessHandoffs = await loadReimbursementPaymentHandoffs(fastify, request.employee);
const today = currentDateString(fastify.config.appTimezone);
const currentWeekStart = resolveWeekStart(today);
const assigneeFilters = await buildTaskVisibilityFilters(fastify.prisma, request);
@@ -761,6 +763,7 @@ async function portalRoutes(fastify) {
{ domain: "reimbursement", count: reimbursementsCount, href: "/reimbursements" },
{ domain: "leave", count: leaveCount, href: "/leave-requests" },
],
+ businessHandoffs,
riskSummary: riskCount > 0 ? { openCount: riskCount, href: "/ap-operations" } : null,
announcements: announcementRows.map((item) => ({
id: item.id,
diff --git a/server/src/services/reimbursementPaymentHandoffs.js b/server/src/services/reimbursementPaymentHandoffs.js
new file mode 100644
index 00000000..4a875809
--- /dev/null
+++ b/server/src/services/reimbursementPaymentHandoffs.js
@@ -0,0 +1,92 @@
+const { evaluateFeatureFlag, FEATURE_FLAG_KEYS } = require("./featureFlags");
+const { buildPaymentProcessingSemantics } = require("./paymentProcessingProgress");
+
+async function loadReimbursementPaymentHandoffs(fastify, employee) {
+ return fastify.prisma.$transaction(async (tx) => {
+ await tx.$executeRawUnsafe("SET TRANSACTION READ ONLY");
+ const items = [];
+ const canManage = fastify.hasPermission(employee, "payment.batch.manage");
+ const canReview = fastify.hasPermission(employee, "payment.batch.review")
+ && (employee.roleCodes ?? []).some((role) => ["manager", "ceo"].includes(role));
+ if (fastify.hasPermission(employee, "expense.submit") && fastify.hasPermission(employee, "expense.view_own")) {
+ const returned = await tx.reimbursementForm.findMany({
+ where: { deleted_at: null, paid_at: null, status: { in: ["draft", "rejected"] }, OR: [{ owner_id: employee.id }, { submitter_id: employee.id }] },
+ select: { id: true, form_number: true, workflow_instances: {
+ orderBy: [{ created_at: "desc" }, { id: "desc" }], take: 1,
+ select: { workflow_history: { orderBy: [{ created_at: "desc" }, { id: "desc" }], take: 1, select: { action: true } } },
+ } },
+ orderBy: [{ updated_at: "asc" }, { id: "asc" }],
+ });
+ for (const form of returned) {
+ if (!["return", "reject"].includes(form.workflow_instances[0]?.workflow_history[0]?.action)) continue;
+ items.push({ key: `reimbursement-edit:${form.id}`, kind: "reimbursement_edit", reference: form.form_number,
+ title: "报销退回待修改", nextStep: "修改后重新提交,也可删除后重新填报", ownerLabel: "报销人或代填人",
+ href: `/reimbursements/${form.id}`, mobileHref: `/mobile/reimbursements/${form.id}` });
+ }
+ }
+ if (!canManage && !canReview) return { total: items.length, items };
+ const gate = await evaluateFeatureFlag({ prisma: tx, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.paymentBatch, strictDepartment: false });
+ if (!gate.enabled) return { total: items.length, items };
+ const departmentGates = new Map();
+ const departmentEnabled = async (departmentId) => {
+ const key = departmentId ?? null;
+ if (!departmentGates.has(key)) departmentGates.set(key, evaluateFeatureFlag({
+ prisma: tx, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.paymentBatch, departmentId: key, strictDepartment: true,
+ }).then((result) => result.enabled));
+ return departmentGates.get(key);
+ };
+ if (canManage) {
+ const approved = await tx.reimbursementForm.findMany({
+ where: { status: "approved", paid_at: null, deleted_at: null },
+ select: { id: true, form_number: true, department_id: true },
+ orderBy: [{ updated_at: "asc" }, { id: "asc" }],
+ });
+ const reservations = approved.length ? await tx.paymentBatchItem.findMany({
+ where: { entity_id: { in: approved.map((form) => form.id) }, payment_batch: { entity_type: "reimbursement", status: { not: "cancelled" } } },
+ select: { entity_id: true },
+ }) : [];
+ const reserved = new Set(reservations.map((item) => item.entity_id));
+ for (const form of approved) {
+ if (reserved.has(form.id) || !await departmentEnabled(form.department_id)) continue;
+ items.push({ key: `reimbursement-batch:${form.id}`, kind: "reimbursement_batch", reference: form.form_number,
+ title: "报销待组建付款批次", nextStep: "核对付款资料后组批,资料缺项由付款页说明", ownerLabel: "财务",
+ href: `/reimbursements/${form.id}`, mobileHref: `/mobile/reimbursements/${form.id}` });
+ }
+ }
+ const batches = await tx.paymentBatch.findMany({
+ where: { entity_type: "reimbursement", OR: [
+ ...(canReview ? [{ status: "draft", reviewed_at: null, created_by: { not: employee.id } }] : []),
+ ...(canManage ? [{ status: { in: ["reviewed", "failed"] } }] : []),
+ ] },
+ select: { id: true, batch_no: true, status: true, created_by: true, payment_order_export_count: true, default_payment_channel: true,
+ items: { select: { entity_id: true, status: true, payment_channel_override: true } },
+ _count: { select: { receipt_attachments: { where: { archived_at: null } } } },
+ },
+ orderBy: [{ created_at: "asc" }, { id: "asc" }],
+ });
+ const sourceIds = [...new Set(batches.flatMap((batch) => batch.items.map((item) => item.entity_id)))];
+ const sources = sourceIds.length ? await tx.reimbursementForm.findMany({
+ where: { id: { in: sourceIds }, deleted_at: null }, select: { id: true, department_id: true },
+ }) : [];
+ const sourceDepartments = new Map(sources.map((source) => [source.id, source.department_id]));
+ for (const batch of batches) {
+ if (!batch.items.length || !batch.created_by) continue;
+ if (batch.status === "draft" && batch.items.some((item) => item.status !== "pending")) continue;
+ const readable = await Promise.all(batch.items.map((item) => sourceDepartments.has(item.entity_id) && departmentEnabled(sourceDepartments.get(item.entity_id))));
+ if (readable.some((enabled) => !enabled)) continue;
+ const review = batch.status === "draft";
+ const semantics = buildPaymentProcessingSemantics({ status: batch.status,
+ requiresBankReceipt: batch.items.some((item) => ["pending", "failed"].includes(item.status)
+ && (item.payment_channel_override ?? batch.default_payment_channel ?? "bank") === "bank"),
+ paymentOrderExportCount: batch.payment_order_export_count, receiptCount: batch._count.receipt_attachments,
+ });
+ items.push({ key: `payment-${review ? "review" : "execute"}:${batch.id}`, kind: review ? "payment_review" : "payment_execute", reference: batch.batch_no,
+ title: review ? "付款批次待复核" : "付款与回单待处理", nextStep: (review ? semantics.review : semantics.payment).detail,
+ ownerLabel: review ? "非制单复核人" : "财务", href: `/payment-batches?id=${batch.id}`, mobileHref: `/mobile/payment-batches?id=${batch.id}` });
+ }
+ const unique = [...new Map(items.map((item) => [item.key, item])).values()];
+ return { total: unique.length, items: unique };
+ }, { isolationLevel: "RepeatableRead", maxWait: 5000, timeout: 10000 });
+}
+
+module.exports = { loadReimbursementPaymentHandoffs };
diff --git a/server/tests/unit/routes/portal.test.js b/server/tests/unit/routes/portal.test.js
index 72b2ee5e..e6077274 100644
--- a/server/tests/unit/routes/portal.test.js
+++ b/server/tests/unit/routes/portal.test.js
@@ -177,6 +177,7 @@ async function buildApp(options = {}) {
},
reimbursementForm: {
count: vi.fn(async () => 2),
+ findMany: vi.fn(async () => []),
},
leaveRequest: {
count: vi.fn(async () => 1),
@@ -192,6 +193,7 @@ async function buildApp(options = {}) {
},
paymentBatch: {
count: vi.fn(async () => options.pendingFinanceBatchCount ?? 0),
+ findMany: vi.fn(async () => []),
},
paymentBatchItem: {
count: vi.fn(async () => options.failedFinanceItemCount ?? 0),
@@ -293,6 +295,8 @@ async function buildApp(options = {}) {
};
const app = Fastify();
+ prisma.$executeRawUnsafe = vi.fn(async () => 0);
+ prisma.$transaction = vi.fn(async (work) => work(prisma));
app.decorate("prisma", prisma);
app.decorate("config", {
appTimezone: "Asia/Shanghai",
diff --git a/server/tests/unit/services/reimbursementPaymentHandoffs.test.js b/server/tests/unit/services/reimbursementPaymentHandoffs.test.js
new file mode 100644
index 00000000..6b93eb96
--- /dev/null
+++ b/server/tests/unit/services/reimbursementPaymentHandoffs.test.js
@@ -0,0 +1,91 @@
+const { loadReimbursementPaymentHandoffs } = require("../../../src/services/reimbursementPaymentHandoffs");
+
+const gates = { global: true, disabled: [] };
+
+function setup({ permissions = [], roles = [], returned = [], approved = [], reservations = [], batches = [], sources = [] } = {}) {
+ const tx = {
+ $executeRawUnsafe: vi.fn(async () => 0),
+ featureFlag: { findUnique: vi.fn(async () => ({ override_mode: gates.global ? "force_on" : "force_off",
+ rollout_type: gates.disabled.length ? "department_allowlist" : "global", department_ids: gates.disabled.length ? ["free"] : [],
+ })) },
+ reimbursementForm: { findMany: vi.fn(async ({ where }) => where.OR ? returned : where.status === "approved" ? approved : sources) },
+ paymentBatchItem: { findMany: vi.fn(async () => reservations) },
+ paymentBatch: { findMany: vi.fn(async ({ where }) => batches.filter((batch) => where.OR.some((filter) =>
+ filter.status === "draft" ? batch.status === "draft" && batch.created_by !== "actor" : filter.status.in.includes(batch.status)))) },
+ };
+ const fastify = { config: { paymentBatchEnabled: true }, prisma: { $transaction: vi.fn(async (work) => work(tx)) }, hasPermission: (_, permission) => permissions.includes(permission) };
+ const employee = { id: "actor", roleCodes: roles };
+ return { tx, fastify, run: () => loadReimbursementPaymentHandoffs(fastify, employee) };
+}
+
+function batch(status = "draft", creator = "maker") {
+ return { id: "batch", batch_no: "PB-SYN", status, created_by: creator, default_payment_channel: "bank", payment_order_export_count: 0,
+ items: [{ entity_id: "form", status: "pending" }], _count: { receipt_attachments: 0 } };
+}
+
+describe("报销付款四类只读交接", () => {
+ beforeEach(() => { gates.global = true; gates.disabled = []; });
+ it("无权限不读取业务来源,事务始终只读", async () => {
+ const { run, tx, fastify } = setup();
+ expect(await run()).toEqual({ total: 0, items: [] });
+ expect(tx.reimbursementForm.findMany).not.toHaveBeenCalled();
+ expect(tx.paymentBatch.findMany).not.toHaveBeenCalled();
+ expect(tx.$executeRawUnsafe).toHaveBeenCalledWith("SET TRANSACTION READ ONLY");
+ expect(fastify.prisma.$transaction).toHaveBeenCalledWith(expect.any(Function), { isolationLevel: "RepeatableRead", maxWait: 5000, timeout: 10000 });
+ });
+
+ it("只认最新流程退回/驳回,不把新草稿、已撤回或旧退回历史当待修改", async () => {
+ const returned = ["return", "reject", "withdraw", null].map((action, index) => ({ id: `form-${index}`, form_number: `BX-${index}`,
+ workflow_instances: action ? [{ workflow_history: [{ action }] }] : [] }));
+ const { run, tx } = setup({ permissions: ["expense.submit", "expense.view_own"], returned });
+ const result = await run();
+ expect(result.total).toBe(2);
+ expect(result.items.map((item) => item.kind)).toEqual(["reimbursement_edit", "reimbursement_edit"]);
+ expect(tx.reimbursementForm.findMany.mock.calls[0][0].where).toEqual({ deleted_at: null, paid_at: null, status: { in: ["draft", "rejected"] }, OR: [{ owner_id: "actor" }, { submitter_id: "actor" }] });
+ });
+
+ it("待组批排除活动占用和部门停用,但不读取或解密银行资料", async () => {
+ gates.disabled = ["disabled"];
+ const approved = ["free", "reserved", "disabled"].map((id) => ({ id, form_number: id, department_id: id }));
+ const { run, tx } = setup({ permissions: ["payment.batch.manage"], approved, reservations: [{ entity_id: "reserved" }] });
+ const result = await run();
+ expect(result.items.map((item) => item.reference)).toEqual(["free"]);
+ expect(tx.reimbursementForm.findMany.mock.calls[0][0].where).toEqual({ status: "approved", paid_at: null, deleted_at: null });
+ expect(tx.paymentBatchItem.findMany.mock.calls[0][0].where.payment_batch.status).toEqual({ not: "cancelled" });
+ expect(JSON.stringify(tx.reimbursementForm.findMany.mock.calls)).not.toMatch(/bank|total_amount|description/);
+ });
+
+ it.each([[[], false], [["admin"], false], [["manager"], true], [["ceo"], true]])("复核需要真实业务角色 %j,不用通配冒充", async (roles, expected) => {
+ const { run } = setup({ roles, permissions: ["payment.batch.review"], batches: [batch()], sources: [{ id: "form", department_id: "department" }] });
+ expect((await run()).total).toBe(expected ? 1 : 0);
+ });
+
+ it("多角色制单人也不能收到自审任务", async () => {
+ const { run } = setup({ roles: ["ceo", "finance"], permissions: ["payment.batch.review", "payment.batch.manage"], batches: [batch("draft", "actor")] });
+ expect((await run()).total).toBe(0);
+ });
+
+ it.each(["reviewed", "failed"])("%s 交给财务,不交给单独 CEO;导出不意味着付款", async (status) => {
+ const data = { batches: [{ ...batch(status), payment_order_export_count: 1 }], sources: [{ id: "form", department_id: "department" }] };
+ const result = await setup({ ...data, permissions: ["payment.batch.manage"] }).run();
+ expect(result.total).toBe(1);
+ expect(result.items[0].kind).toBe("payment_execute");
+ expect(result.items[0].nextStep).toContain(status === "failed" ? "付款登记失败" : "已生成银行制单");
+ expect((await setup({ ...data, permissions: ["payment.batch.review"], roles: ["ceo"] }).run()).total).toBe(0);
+ });
+
+ it("批次已取消/完成、来源已删除/缺失、部门停用不继续投影", async () => {
+ const base = { permissions: ["payment.batch.manage"], batches: [batch("executed"), batch("cancelled")] };
+ expect((await setup(base).run()).total).toBe(0);
+ expect((await setup({ ...base, batches: [batch("reviewed")] }).run()).total).toBe(0);
+ gates.disabled = ["department"];
+ expect((await setup({ ...base, batches: [batch("reviewed")], sources: [{ id: "form", department_id: "department" }] }).run()).total).toBe(0);
+ });
+
+ it("反复加载不重复,通知设施不可用也不丢待办;全局停用不暴露批次", async () => {
+ const { run } = setup({ permissions: ["payment.batch.manage"], approved: [{ id: "free", department_id: "department" }] });
+ expect(await run()).toEqual(await run());
+ gates.global = false;
+ expect((await run()).total).toBe(0);
+ });
+});