收敛报销退回动作与审批布局 [09-04-approval-return-reject-actions]

这个提交包含在:
2026-09-04 10:31:32 +08:00
父节点 59df0594bb
当前提交 8551601818
共修改 17 个文件,包含 520 行新增和 56 行删除
@@ -11,7 +11,7 @@ export function resolveReimbursementApprovalBlockReason({
online: boolean;
snapshotParseStatus: string | null | undefined;
}) {
if (!online) return "报销审批需联网核对完整报销内容;当前不能通过、退回、驳回或转审。";
if (!online) return "报销审批需联网核对完整报销内容;当前不能通过、退回修改或转审。";
if (amountIntegrity && amountIntegrity !== "ready") {
return reimbursementAmountIntegrityMessage(amountIntegrity);
}
@@ -6,7 +6,6 @@ import type { WorkflowTaskDetail } from "../../api/workflow";
import {
approveWorkflowTask,
fetchWorkflowApprovers,
rejectWorkflowTask,
returnWorkflowTask,
transferWorkflowTask,
} from "../../api/workflow";
@@ -40,10 +39,11 @@ import { resolveReimbursementApprovalBlockReason } from "../../lib/reimbursement
import { buildReimbursementWorkspaceModel } from "../../lib/reimbursementWorkspace";
import { resolveErrorMessage as resolveUserErrorMessage } from "../../lib/resolveErrorMessage";
import { isWecomWebView } from "../../lib/runtimeContext";
import { getWorkflowConfirmCopy, type WorkflowConfirmAction } from "../../lib/workflowActionConfirm";
import { getWorkflowConfirmCopy } from "../../lib/workflowActionConfirm";
import { openFileInBrowser, previewFileInWecom } from "../../lib/wecomFilePreview";
type SecondaryWorkflowAction = "reject" | "return" | "transfer";
type SecondaryWorkflowAction = "return" | "transfer";
type ReimbursementConfirmAction = "approve" | "return";
type Props = {
detail: WorkflowTaskDetail;
@@ -84,13 +84,12 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task
const { permissions } = useAuth();
const backTarget = resolveApprovalBackTarget((location.state as { from?: unknown } | null)?.from, layout);
const reimbursementId = detail.reimbursement?.id ?? "";
const [rejectComment, setRejectComment] = useState("");
const [returnComment, setReturnComment] = useState("");
const [transferReason, setTransferReason] = useState("");
const [selectedApprover, setSelectedApprover] = useState("");
const [actionError, setActionError] = useState("");
const [activeSecondaryAction, setActiveSecondaryAction] = useState<SecondaryWorkflowAction | null>(null);
const [confirmAction, setConfirmAction] = useState<WorkflowConfirmAction | null>(null);
const [confirmAction, setConfirmAction] = useState<ReimbursementConfirmAction | null>(null);
const [transferConfirmOpen, setTransferConfirmOpen] = useState(false);
const reimbursementDetailQuery = useQuery({
@@ -168,12 +167,11 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task
});
const actionMutation = useMutation({
mutationFn: async (action: "approve" | "reject" | "return" | "transfer") => {
mutationFn: async (action: "approve" | "return" | "transfer") => {
if (!decisionReady) {
throw new Error(decisionBlockReason);
}
if (action === "approve") return approveWorkflowTask(taskId);
if (action === "reject") return rejectWorkflowTask(taskId, rejectComment.trim());
if (action === "return") return returnWorkflowTask(taskId, returnComment.trim());
if (!selectedApprover) throw new Error("请选择转审目标");
return transferWorkflowTask(taskId, selectedApprover, transferReason.trim());
@@ -188,7 +186,7 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task
setActiveSecondaryAction(null);
setConfirmAction(null);
setTransferConfirmOpen(false);
toast.success({ approve: "已通过", reject: "已驳回", return: "已退回", transfer: "已转审" }[action]);
toast.success({ approve: "已通过", return: "已退回", transfer: "已转审" }[action]);
},
onError: (error) => {
if (isAlreadyProcessedTaskError(error)) void refetchTask();
@@ -198,14 +196,14 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task
},
});
function requestAction(action: WorkflowConfirmAction) {
function requestAction(action: ReimbursementConfirmAction) {
if (!decisionReady) {
setActionError(decisionBlockReason);
return;
}
const comment = action === "reject" ? rejectComment : action === "return" ? returnComment : "";
if ((action === "reject" || action === "return") && !comment.trim()) {
setActionError(`${action === "reject" ? "驳回" : "退回"}必须填写意见`);
const comment = action === "return" ? returnComment : "";
if (action === "return" && !comment.trim()) {
setActionError("退回修改必须填写意见");
return;
}
setActionError("");
@@ -235,8 +233,7 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task
const disabledActions = (
<div className="grid grid-cols-2 gap-2" data-testid="reimbursement-approval-fail-closed-actions">
<Button data-testid={layout === "mobile" ? "mobile-approvals-approve" : "workflow-task-approve"} disabled type="button">通过</Button>
<Button data-testid={layout === "mobile" ? "mobile-approvals-return" : "workflow-task-return-toggle"} disabled type="button" variant="secondary">退回</Button>
<Button data-testid={layout === "mobile" ? "mobile-approvals-reject" : "workflow-task-reject-toggle"} disabled type="button" variant="secondary">驳回</Button>
<Button data-testid={layout === "mobile" ? "mobile-approvals-return" : "workflow-task-return-toggle"} disabled type="button" variant="secondary">退回修改</Button>
<Button data-testid={layout === "mobile" ? "mobile-approvals-transfer" : "workflow-task-transfer-menu-item"} disabled type="button" variant="secondary">转审</Button>
</div>
);
@@ -250,7 +247,10 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task
);
}
const confirmCopy = confirmAction ? getWorkflowConfirmCopy(confirmAction) : null;
const baseConfirmCopy = confirmAction ? getWorkflowConfirmCopy(confirmAction) : null;
const confirmCopy = confirmAction === "return" && baseConfirmCopy
? { ...baseConfirmCopy, title: "确认退回修改?", confirmLabel: "确认退回修改" }
: baseConfirmCopy;
const actionDisabled = actionMutation.isPending || !decisionReady;
const visibleIssueAnchors = model.issueAnchors.filter((issue) => issue.label !== decisionBlockReason);
const issueAnchors = visibleIssueAnchors.length ? (
@@ -279,19 +279,11 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task
{actionError ? <p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700" role="alert">{actionError}</p> : null}
{activeSecondaryAction === "return" ? (
<div className="space-y-2">
<Label htmlFor={`${layout}-reimbursement-return-comment`}>退回意见</Label>
<Label htmlFor={`${layout}-reimbursement-return-comment`}>退回修改意见</Label>
<p className="text-xs text-zinc-500">退回后原单可修改并重新提交,单号和审批历史会保留。</p>
<Textarea data-testid={layout === "desktop" ? "workflow-task-return-comment" : "mobile-approvals-comment"} id={`${layout}-reimbursement-return-comment`} value={returnComment} onChange={(event) => setReturnComment(event.target.value)} />
<ReimbursementReturnReferencePicker evidence={model.evidence} onReference={appendReturnReference} />
<Button className="min-h-11 w-full" data-testid={layout === "desktop" ? "workflow-task-return" : "mobile-approvals-return-submit"} disabled={actionDisabled || !returnComment.trim()} onClick={() => requestAction("return")} type="button" variant="secondary">确认退回</Button>
</div>
) : null}
{activeSecondaryAction === "reject" ? (
<div className="space-y-2">
<Label htmlFor={`${layout}-reimbursement-reject-comment`}>驳回意见</Label>
<p className="text-xs text-zinc-500">驳回会终止本次流程,申请人通常需要重新发起。</p>
<Textarea data-testid={layout === "desktop" ? "workflow-task-reject-comment" : "mobile-approvals-comment"} id={`${layout}-reimbursement-reject-comment`} value={rejectComment} onChange={(event) => setRejectComment(event.target.value)} />
<Button className="min-h-11 w-full" data-testid={layout === "desktop" ? "workflow-task-reject" : "mobile-approvals-reject-submit"} disabled={actionDisabled || !rejectComment.trim()} onClick={() => requestAction("reject")} type="button" variant="secondary">确认驳回</Button>
<Button className="min-h-11 w-full" data-testid={layout === "desktop" ? "workflow-task-return" : "mobile-approvals-return-submit"} disabled={actionDisabled || !returnComment.trim()} onClick={() => requestAction("return")} type="button" variant="secondary">确认退回修改</Button>
</div>
) : null}
{activeSecondaryAction === "transfer" ? (
@@ -365,13 +357,10 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task
{offlineNotice}
{issueAnchors}
{commentEditor}
<div className="grid grid-cols-2 gap-2">
{allowedActions.has("return") || !online ? <Button className="min-h-11" data-testid="mobile-approvals-return" disabled={actionDisabled} onClick={() => setActiveSecondaryAction(activeSecondaryAction === "return" ? null : "return")} type="button" variant="secondary">退回</Button> : null}
{allowedActions.has("reject") || !online ? <Button className="min-h-11" data-testid="mobile-approvals-reject" disabled={actionDisabled} onClick={() => setActiveSecondaryAction(activeSecondaryAction === "reject" ? null : "reject")} type="button" variant="secondary">驳回</Button> : null}
{canTransfer || !online ? <Button className="min-h-11" data-testid="mobile-approvals-transfer" disabled={actionDisabled} onClick={() => setActiveSecondaryAction(activeSecondaryAction === "transfer" ? null : "transfer")} type="button" variant="secondary">转审</Button> : null}
</div>
{canTransfer || !online ? <Button className="min-h-11 w-full" data-testid="mobile-approvals-transfer" disabled={actionDisabled} onClick={() => setActiveSecondaryAction(activeSecondaryAction === "transfer" ? null : "transfer")} type="button" variant="ghost">转审</Button> : null}
</section>
<MobileStickyActionBar testId="mobile-approvals-sticky-actions">
<MobileStickyActionBar contentClassName="grid-cols-2" testId="mobile-approvals-sticky-actions">
{allowedActions.has("return") || !online ? <Button className="min-h-12 w-full" data-testid="mobile-approvals-return" disabled={actionDisabled} onClick={() => setActiveSecondaryAction(activeSecondaryAction === "return" ? null : "return")} type="button" variant="secondary">退回修改</Button> : null}
<Button className="min-h-12 w-full" data-testid="mobile-approvals-approve" disabled={actionDisabled || !allowedActions.has("approve")} onClick={() => requestAction("approve")} type="button">通过</Button>
</MobileStickyActionBar>
</>
@@ -399,13 +388,13 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task
<BusinessDetailActionPanel
title="审批决定"
beforeActions={actionPanelBeforeActions}
primaryAction={allowedActions.has("approve") ? <Button data-testid="workflow-task-approve" disabled={actionDisabled} onClick={() => requestAction("approve")} type="button">通过</Button> : null}
secondaryActions={(
<>
{allowedActions.has("return") ? <Button data-testid="workflow-task-return-toggle" disabled={actionDisabled} onClick={() => setActiveSecondaryAction(activeSecondaryAction === "return" ? null : "return")} type="button" variant="secondary">退回</Button> : null}
{allowedActions.has("reject") ? <Button data-testid="workflow-task-reject-toggle" disabled={actionDisabled} onClick={() => setActiveSecondaryAction(activeSecondaryAction === "reject" ? null : "reject")} type="button" variant="secondary">驳回</Button> : null}
</>
)}
primaryAction={allowedActions.has("approve") || allowedActions.has("return") ? (
<div className="grid grid-cols-2 gap-2" data-testid="reimbursement-approval-primary-actions">
{allowedActions.has("approve") ? <Button className="min-h-11 w-full" data-testid="workflow-task-approve" disabled={actionDisabled} onClick={() => requestAction("approve")} type="button">通过</Button> : null}
{allowedActions.has("return") ? <Button className="min-h-11 w-full" data-testid="workflow-task-return-toggle" disabled={actionDisabled} onClick={() => setActiveSecondaryAction(activeSecondaryAction === "return" ? null : "return")} type="button" variant="secondary">退回修改</Button> : null}
</div>
) : null}
secondaryActions={null}
moreActions={canTransfer ? <Button className="w-full" data-testid="workflow-task-transfer-menu-item" disabled={actionDisabled} onClick={() => setActiveSecondaryAction(activeSecondaryAction === "transfer" ? null : "transfer")} type="button" variant="ghost">转审</Button> : null}
summary={null}
testId="workflow-task-action-panel"
@@ -231,7 +231,7 @@ test("E2E 移动审批:报销证据不进入 IndexedDB 且离线动作 fail cl
attendanceCorrection: null,
purchaseRequest: null,
travelRequest: null,
allowedActions: ["approve", "reject", "return"],
allowedActions: ["approve", "return"],
history: [],
};
const reimbursementDetail = {
@@ -351,9 +351,9 @@ test("E2E 移动审批:报销证据不进入 IndexedDB 且离线动作 fail cl
}
await page.context().setOffline(true);
await expect(page.getByText("报销审批需联网核对完整报销内容;当前不能通过、退回、驳回或转审。")).toBeVisible();
await expect(page.getByText("报销审批需联网核对完整报销内容;当前不能通过、退回修改或转审。")).toBeVisible();
await expect(page.getByTestId("mobile-approvals-approve")).toBeDisabled();
await expect(page.getByTestId("mobile-approvals-return")).toBeDisabled();
await expect(page.getByTestId("mobile-approvals-reject")).toBeDisabled();
await expect(page.getByTestId("mobile-approvals-reject")).toHaveCount(0);
await expect(page.getByTestId("mobile-approvals-transfer")).toBeDisabled();
});
@@ -77,7 +77,7 @@ const workflowTask = {
duration: null,
businessStatus: "reviewing",
},
allowedActions: ["approve", "reject", "return", "transfer"],
allowedActions: ["approve", "return", "transfer"],
};
const reimbursementDetail = {
@@ -52,7 +52,7 @@ test("报销审批五类阻断原因由同一投影给出且正常态保持安
};
expect(resolveReimbursementApprovalBlockReason(base)).toBeNull();
expect(resolveReimbursementApprovalBlockReason({ ...base, online: false })).toBe("报销审批需联网核对完整报销内容;当前不能通过、退回、驳回或转审。");
expect(resolveReimbursementApprovalBlockReason({ ...base, online: false })).toBe("报销审批需联网核对完整报销内容;当前不能通过、退回修改或转审。");
expect(resolveReimbursementApprovalBlockReason({ ...base, detailState: "loading" })).toBe("正在加载完整报销内容和票据;加载完成前不能作出审批决定。");
expect(resolveReimbursementApprovalBlockReason({ ...base, detailState: "error" })).toBe("报销内容未加载完整,暂不能审批,请刷新后重试");
expect(resolveReimbursementApprovalBlockReason({ ...base, snapshotParseStatus: "invalid" })).toBe("提交时检查记录无法解析,暂时不能审批;请联系系统管理员核对。");
@@ -73,6 +73,13 @@ test("动作面板复用唯一阻断投影且预算三态判断保持原合同",
expect(adapter).not.toContain("内容未就绪,审批动作已停用");
expect(adapter).not.toContain("statusSummary");
expect(adapter).toContain("actionError || activeSecondaryAction ?");
expect(adapter).toContain('data-testid="reimbursement-approval-primary-actions"');
expect(adapter).toContain('className="grid grid-cols-2 gap-2"');
expect(adapter).toContain("退回修改");
expect(adapter).not.toContain("rejectWorkflowTask");
expect(adapter).not.toContain('allowedActions.has("reject")');
expect(adapter).not.toContain("workflow-task-reject-toggle");
expect(adapter).not.toContain("mobile-approvals-reject");
});
test("旧审批层叠 class 与更多菜单样式无定义或消费者", () => {
@@ -458,6 +458,7 @@ test.describe("R7E 审批动作二次确认", () => {
submitter: { id: "owner", employeeNo: "E001", name: "申请人" },
},
leaveRequest: null,
allowedActions: ["approve", "return", "transfer"],
history: Array.from({ length: 6 }, (_, index) => ({
id: `history-${index + 1}`,
action: index === 0 ? "submit" : "approve",
@@ -515,8 +516,18 @@ test.describe("R7E 审批动作二次确认", () => {
await expect(actionPanel.getByText(/^审批角色:/)).toHaveCount(0);
await expect(page.getByTestId("workflow-task-approve")).toBeEnabled();
await expect(page.getByTestId("workflow-task-return-toggle")).toBeEnabled();
await expect(page.getByTestId("workflow-task-reject-toggle")).toBeEnabled();
await expect(page.getByTestId("workflow-task-reject-toggle")).toHaveCount(0);
await expect(page.getByTestId("workflow-task-transfer-menu-item")).toBeEnabled();
const primaryActions = page.getByTestId("reimbursement-approval-primary-actions");
await expect(primaryActions.getByRole("button")).toHaveCount(2);
await expect(primaryActions.getByRole("button", { name: "通过" })).toBeVisible();
await expect(primaryActions.getByRole("button", { name: "退回修改" })).toBeVisible();
const approveBox = await page.getByTestId("workflow-task-approve").boundingBox();
const returnBox = await page.getByTestId("workflow-task-return-toggle").boundingBox();
expect(approveBox).not.toBeNull();
expect(returnBox).not.toBeNull();
expect(Math.abs(approveBox!.height - returnBox!.height)).toBeLessThanOrEqual(1);
expect(approveBox!.y).toBe(returnBox!.y);
await expect(page.getByTestId("reimbursement-workflow-history").locator(".detail-timeline-item")).toHaveCount(6);
await expect(page.getByRole("heading", { name: "处理历史" })).toHaveCount(1);
const actionBox = await actionPanel.boundingBox();
@@ -544,7 +555,7 @@ test.describe("R7E 审批动作二次确认", () => {
await expect(page.getByTestId("workflow-task-return-comment")).toHaveValue(/【明细:交通费 · 2026-09-01 · ¥180\.00】/);
});
test("PC 报销内容加载失败时四类审批入口 fail closed", async ({ page }) => {
test("PC 报销内容加载失败时全部可见审批入口 fail closed", async ({ page }) => {
const taskDetail = {
...pendingTaskDetail,
workflowInstance: {
@@ -558,6 +569,7 @@ test.describe("R7E 审批动作二次确认", () => {
owner: { id: "owner", employeeNo: "E001", name: "申请人" }, submitter: { id: "owner", employeeNo: "E001", name: "申请人" },
},
leaveRequest: null,
allowedActions: ["approve", "return", "transfer"],
};
await mockWorkflowConfirmApis(page, { taskDetail, reimbursementDetailFailure: true });
@@ -566,7 +578,7 @@ test.describe("R7E 审批动作二次确认", () => {
await expect(page.getByRole("alert")).toContainText("报销内容未加载完整,暂不能审批,请刷新后重试");
await expect(page.getByTestId("workflow-task-approve")).toBeDisabled();
await expect(page.getByTestId("workflow-task-return-toggle")).toBeDisabled();
await expect(page.getByTestId("workflow-task-reject-toggle")).toBeDisabled();
await expect(page.getByTestId("workflow-task-reject-toggle")).toHaveCount(0);
await expect(page.getByTestId("workflow-task-transfer-menu-item")).toBeDisabled();
});
@@ -590,6 +602,7 @@ test.describe("R7E 审批动作二次确认", () => {
submitter: { id: "owner", employeeNo: "E001", name: "申请人" },
},
leaveRequest: null,
allowedActions: ["approve", "return", "transfer"],
history: Array.from({ length: 6 }, (_, index) => ({
id: `mobile-history-${index + 1}`,
action: index === 0 ? "submit" : "approve",
@@ -627,6 +640,10 @@ test.describe("R7E 审批动作二次确认", () => {
await expect(page.getByTestId("reimbursement-amount-summary")).toContainText("¥100.00");
await expect(page.locator("#reimbursement-workspace-snapshot")).toHaveCount(0);
await expect(page.getByTestId("reimbursement-workflow-history").locator(".detail-timeline-item")).toHaveCount(6);
const stickyActions = page.getByTestId("mobile-approvals-sticky-actions");
await expect(stickyActions.getByRole("button", { name: "退回修改" })).toBeVisible();
await expect(stickyActions.getByRole("button", { name: "通过" })).toBeVisible();
await expect(page.getByTestId("mobile-approvals-reject")).toHaveCount(0);
const assertMobileGeometry = async () => {
expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth + 2)).toBe(true);
const lastHistory = page.getByTestId("reimbursement-workflow-history").locator(".detail-timeline-item").last();
@@ -657,7 +674,7 @@ test.describe("R7E 审批动作二次确认", () => {
expectedMessage: "明细金额无法完整核对,请联系管理员",
},
]) {
test(`PC 报销${scenario.name}时四类审批入口 fail closed`, async ({ page }) => {
test(`PC 报销${scenario.name}时全部可见审批入口 fail closed`, async ({ page }) => {
const reimbursementId = `reimbursement-integrity-${scenario.itemAmount === "invalid" ? "unavailable" : "mismatch"}`;
const taskDetail = {
...pendingTaskDetail,
@@ -677,6 +694,7 @@ test.describe("R7E 审批动作二次确认", () => {
submitter: { id: "owner", employeeNo: "E001", name: "申请人" },
},
leaveRequest: null,
allowedActions: ["approve", "return", "transfer"],
};
const reimbursementDetail = {
id: reimbursementId,
@@ -716,7 +734,7 @@ test.describe("R7E 审批动作二次确认", () => {
await expect(page.getByText("差额", { exact: true })).toHaveCount(0);
await expect(page.getByTestId("workflow-task-approve")).toBeDisabled();
await expect(page.getByTestId("workflow-task-return-toggle")).toBeDisabled();
await expect(page.getByTestId("workflow-task-reject-toggle")).toBeDisabled();
await expect(page.getByTestId("workflow-task-reject-toggle")).toHaveCount(0);
await expect(page.getByTestId("workflow-task-transfer-menu-item")).toBeDisabled();
});
}