fix: 退回报销支持留痕删除(09-04-reimbursement-state-gap-finance-view)

这个提交包含在:
2026-09-10 16:38:37 +08:00
父节点 e5685f18b2
当前提交 73fcd5a65a
共修改 27 个文件,包含 696 行新增和 94 行删除
+1
查看文件
@@ -128,6 +128,7 @@ export type ReimbursementApprovalHistoryItem = {
};
export type ReimbursementDetail = {
deletedAt?: string | null;
id: string;
formNumber: string | null;
submitterId: string;
@@ -30,6 +30,7 @@ const STATUS_MAP: Record<string, { label: string; className: string }> = {
rejected: { label: "已驳回", className: "badge-error" },
ocr_failed: { label: "识别失败", className: "badge-error" },
cancelled: { label: "已取消", className: "badge-neutral" },
deleted: { label: "已删除", className: "badge-neutral" },
archived: { label: "已归档", className: "badge-neutral" },
skipped: { label: "已跳过", className: "badge-neutral" },
revoked: { label: "已撤销", className: "badge-neutral" },
@@ -208,7 +208,7 @@ export function MobileReimbursementDetailPage() {
const canMaintainReimbursement = Boolean(canEdit && isParticipant);
const canOpenInvoiceDialog = Boolean(canMaintainReimbursement && canUploadInvoice && canViewOwnInvoice);
const canUnbindInvoices = canMaintainReimbursement;
const canDeleteDraft = Boolean(detail?.status === "draft" && canDeleteExpense && isParticipant);
const canDeleteDraft = Boolean(detail && ["draft", "rejected"].includes(detail.status) && !detail.deletedAt && canDeleteExpense && isParticipant);
const selectedInvoiceEntries = Object.entries(selectedInvoices);
const reimbursementPrecheckQuery = useQuery({
@@ -389,7 +389,7 @@ export function MobileReimbursementDetailPage() {
mutationFn: () => deleteReimbursement(reimbursementId),
onSuccess: async () => {
await invalidateDetail();
toast.success("草稿已删除");
toast.success("报销单已删除");
navigate("/mobile/reimbursements");
},
onError: (error) => {
@@ -938,6 +938,7 @@ export function MobileReimbursementDetailPage() {
};
const workspaceHeaderDetails = (
<>
{detail.deletedAt ? <p role="status">已删除,仅供查看历史记录</p> : null}
{canMaintainReimbursement ? <ReimbursementBatchClassification key={reimbursementId} reimbursementId={reimbursementId} items={detail.items} expenseTypes={expenseTypes} /> : null}
{!isParticipant ? <p className="text-xs text-zinc-500">只读查看{detail.status === "draft" ? " · 等待报销人完善" : detail.status === "rejected" ? " · 等待报销人修改后重提" : ""}</p> : null}
{canMaintainReimbursement ? <ReimbursementHeaderEditor reimbursementId={reimbursementId} reimbursementDate={detail.reimbursementDate} description={detail.description} editable mobile showDescription={!isTravelReimbursement(detail)} /> : null}
@@ -978,7 +979,7 @@ export function MobileReimbursementDetailPage() {
<>
{(detail.budgetHint?.exceedsThreshold || detail.riskFlags.length) ? <section className="mobile-card space-y-3"><h2 className="text-base font-semibold text-zinc-900">需要关注</h2>{detail.budgetHint?.exceedsThreshold ? <p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">预算 {detail.budgetHint.budgetCode}:提交后预计使用 {detail.budgetHint.nextUsagePercent.toFixed(2)}%,已达到预警阈值。</p> : null}{detail.riskFlags.length ? <RiskFlagsCard items={detail.riskFlags} title="待处理风险项" /> : null}</section> : null}
<section className="mobile-card"><h2 className="mb-3 text-base font-semibold text-zinc-900">处理进度</h2><ProcessingProgress progress={detail.processingProgress ?? { stages: [] }} ariaLabel="报销处理进度" testId="mobile-reimbursement-processing-progress" targetLinkLabel="查看所在付款批次" mobile /></section>
{canDeleteDraft ? <section className="mobile-card"><Button className="w-full" disabled={deleteReimbursementMutation.isPending} onClick={() => setConfirmDeleteReimbursement(true)} type="button" variant="destructive">删除草稿</Button></section> : null}
{canDeleteDraft ? <section className="mobile-card"><details><summary className="cursor-pointer py-3 text-sm" data-testid="mobile-reimbursement-more">更多</summary><Button className="w-full" disabled={deleteReimbursementMutation.isPending} onClick={() => setConfirmDeleteReimbursement(true)} type="button" variant="destructive">删除报销单</Button></details></section> : null}
</>
);
const mobileActions = showSubmitAction ? <MobileStickyActionBar testId="mobile-reimbursement-sticky-actions"><Button className="min-h-12 w-full" data-testid={TEST_IDS.reimbursement.submit} disabled={submitMutation.isPending} onClick={() => void handleSubmit()} type="button">{submitMutation.isPending ? "提交中..." : "提交审批"}</Button></MobileStickyActionBar> : null;
@@ -986,7 +987,7 @@ export function MobileReimbursementDetailPage() {
return (
<>
{downloadReasonDialog}
<ReimbursementMobileWorkspaceShell model={workspaceModel} capabilities={workspaceCapabilities} fallbackTo={reimbursementFallback(detail.status)} slots={{ status: <span className={cn("rounded-full px-2.5 py-1 text-xs font-medium", STATUS_TONE[detail.status])}>{STATUS_LABEL[detail.status]}</span>, headerDetails: workspaceHeaderDetails, beforeEvidence: workspaceBeforeEvidence, afterEvidence: workspaceAfterEvidence, mobileActions }} />
<ReimbursementMobileWorkspaceShell model={workspaceModel} capabilities={workspaceCapabilities} fallbackTo={reimbursementFallback(detail.status)} slots={{ status: <span className={cn("rounded-full px-2.5 py-1 text-xs font-medium", STATUS_TONE[detail.status])}>{detail.deletedAt ? "已删除" : STATUS_LABEL[detail.status]}</span>, headerDetails: workspaceHeaderDetails, beforeEvidence: workspaceBeforeEvidence, afterEvidence: workspaceAfterEvidence, mobileActions }} />
<Dialog open={isInvoiceDialogOpen} onOpenChange={closeInvoiceDialog}>
<DialogContent
className="left-0 top-0 w-screen max-w-none translate-x-0 translate-y-0 overflow-y-auto rounded-none p-4"
@@ -1301,10 +1302,10 @@ export function MobileReimbursementDetailPage() {
<ConfirmDialog
open={confirmDeleteReimbursement}
onOpenChange={setConfirmDeleteReimbursement}
title="删除草稿?"
description="删除后无法恢复,已添加发票会回到可添加状态。"
title="删除报销单?"
description="删除后,此单不再出现在日常列表中,符合条件的发票可重新报销;已发生的审批记录仍保留备查。"
variant="destructive"
confirmLabel="删除草稿"
confirmLabel="删除报销单"
onConfirm={() => deleteReimbursementMutation.mutate()}
/>
@@ -136,7 +136,7 @@ function ReimbursementCard({
type="button"
variant="destructive"
>
删除草稿
删除报销单
</Button>
</div>
</details>
@@ -240,7 +240,7 @@ export function MobileReimbursementsPage() {
queryClient.invalidateQueries({ queryKey: ["reimbursements"] }),
]);
setDeleteTarget(null);
toast.success("草稿已删除");
toast.success("报销单已删除");
},
onError: (error) => {
toast.error(resolveErrorMessage(error));
@@ -358,7 +358,7 @@ export function MobileReimbursementsPage() {
) : null}
{visibleItems.map((item) => (
<ReimbursementCard
canDeleteDraft={canDeleteExpense && scope === "own" && item.status === "draft"}
canDeleteDraft={canDeleteExpense && scope === "own" && ["draft", "rejected"].includes(item.status)}
currentMobileSource={currentMobileSource}
isDeleting={deleteMutation.isPending}
item={item}
@@ -376,10 +376,10 @@ export function MobileReimbursementsPage() {
setDeleteTarget(null);
}
}}
title="删除草稿?"
description="删除后无法恢复,已添加发票会回到可用状态。"
title="删除报销单?"
description="删除后,此单不再出现在日常列表中,符合条件的发票可重新报销;已发生的审批记录仍保留备查。"
variant="destructive"
confirmLabel="删除草稿"
confirmLabel="删除报销单"
onConfirm={async () => {
if (!deleteTarget) {
return;
+9 -7
查看文件
@@ -351,11 +351,11 @@ export function ReimbursementDetailPage() {
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["reimbursements"] });
toast.success("草稿已删除");
toast.success("报销单已删除");
navigate("/reimbursements?status=draft");
},
onError: (error) => {
const message = buildActionErrorMessage(error, "删除草稿失败");
const message = buildActionErrorMessage(error, "删除报销单失败");
setActionError(message);
toast.error(message);
},
@@ -782,7 +782,8 @@ export function ReimbursementDetailPage() {
canViewOwnInvoice;
const canUnbindInvoices = canMaintainReimbursement;
const canDeleteReimbursement =
detail.status === "draft" &&
["draft", "rejected"].includes(detail.status) &&
!detail.deletedAt &&
canDeleteExpense &&
isReimbursementParticipant;
const precheckStandardChecks = reimbursementPrecheckQuery.data?.standardChecks ?? [];
@@ -939,6 +940,7 @@ export function ReimbursementDetailPage() {
stage.state === "current" || stage.state === "exception",
);
const nextStepText = (() => {
if (detail.deletedAt) return "已删除,仅供查看历史记录";
if (["draft", "rejected"].includes(detail.status)) {
if (!isReimbursementParticipant) {
return detail.status === "rejected" ? "等待报销人修改后重提" : "等待报销人完善";
@@ -1107,7 +1109,7 @@ export function ReimbursementDetailPage() {
</>
);
const workspaceActionPanel = (
<BusinessDetailActionPanel status={<StatusBadge status={detail.status} />} primaryAction={primaryAction} secondaryActions={secondaryActions} moreActions={showSecondaryDetail ? <div className="grid gap-2" data-testid="reimbursement-secondary-detail">{detail.status !== "paid" ? <Button asChild variant="secondary"><Link to={"/reimbursements/" + detail.id + "/print"}>打印 / PDF</Link></Button> : null}{canDeleteReimbursement ? <Button data-testid="reimbursement-delete-draft" disabled={deleteReimbursementMutation.isPending} onClick={() => setConfirmDeleteReimbursement(true)} type="button" variant="destructive">删除草稿</Button> : null}</div> : null} summary={<><div>下一步:{nextStepText}</div>{workspaceModel.issueAnchors.map((issue) => <a className="block hover:underline" href={"#" + issue.id} key={issue.label}>{issue.label}</a>)}</>} testId="reimbursement-action-panel">
<BusinessDetailActionPanel status={<StatusBadge status={detail.deletedAt ? "deleted" : detail.status} />} primaryAction={primaryAction} secondaryActions={secondaryActions} moreActions={showSecondaryDetail ? <div className="grid gap-2" data-testid="reimbursement-secondary-detail">{detail.status !== "paid" ? <Button asChild variant="secondary"><Link to={"/reimbursements/" + detail.id + "/print"}>打印 / PDF</Link></Button> : null}{canDeleteReimbursement ? <Button data-testid="reimbursement-delete-draft" disabled={deleteReimbursementMutation.isPending} onClick={() => setConfirmDeleteReimbursement(true)} type="button" variant="destructive">删除报销单</Button> : null}</div> : null} summary={<><div>下一步:{nextStepText}</div>{workspaceModel.issueAnchors.map((issue) => <a className="block hover:underline" href={"#" + issue.id} key={issue.label}>{issue.label}</a>)}</>} testId="reimbursement-action-panel">
<ReimbursementActions status={detail.status} showSubmitAction={false} showWithdrawAction={isReimbursementParticipant} actionError={["reviewing", "approved"].includes(detail.status) ? actionError : ""} submitDisabled={blockStandardChecks.length > 0} submitHint="" submitNotice="" isPending={mutation.isPending} onSubmit={() => void handleSubmitAction()} onWithdraw={(comment) => mutation.mutate({ action: "withdraw", comment })} />
</BusinessDetailActionPanel>
);
@@ -1115,7 +1117,7 @@ export function ReimbursementDetailPage() {
return (
<>
{downloadReasonDialog}
<ReimbursementDesktopWorkspaceShell model={workspaceModel} capabilities={workspaceCapabilities} slots={{ status: <StatusBadge status={detail.status} />, backAction: <Button asChild className="min-h-11 px-2" data-testid="reimbursement-back" variant="ghost"><Link replace to={backSource}><ArrowLeft aria-hidden="true" className="size-4" />{backLabel}</Link></Button>, headerDetails: workspaceHeaderDetails, beforeEvidence: workspaceBeforeEvidence, afterEvidence: workspaceAfterEvidence, actionPanel: workspaceActionPanel }} />
<ReimbursementDesktopWorkspaceShell model={workspaceModel} capabilities={workspaceCapabilities} slots={{ status: <StatusBadge status={detail.deletedAt ? "deleted" : detail.status} />, backAction: <Button asChild className="min-h-11 px-2" data-testid="reimbursement-back" variant="ghost"><Link replace to={backSource}><ArrowLeft aria-hidden="true" className="size-4" />{backLabel}</Link></Button>, headerDetails: workspaceHeaderDetails, beforeEvidence: workspaceBeforeEvidence, afterEvidence: workspaceAfterEvidence, actionPanel: workspaceActionPanel }} />
<Dialog open={isInvoiceDialogOpen} onOpenChange={handleInvoiceDialogOpenChange}>
<DialogContent
className="max-h-[85vh] max-w-3xl overflow-y-auto"
@@ -1369,9 +1371,9 @@ export function ReimbursementDetailPage() {
open={confirmDeleteReimbursement}
onOpenChange={setConfirmDeleteReimbursement}
title={`确认删除 ${displayCode}?`}
description="删除后无法恢复,已添加发票会回到可添加状态。"
description="删除后,此单不再出现在日常列表中,符合条件的发票可重新报销;已发生的审批记录仍保留备查。"
variant="destructive"
confirmLabel="删除草稿"
confirmLabel="删除报销单"
onConfirm={() => { deleteReimbursementMutation.mutate(); }}
/>
<AttachmentDeleteConfirmDialog
+1 -1
查看文件
@@ -156,7 +156,7 @@ export function ReimbursementPrintPage() {
<div className="grid gap-3 text-sm">
<div className="flex justify-between gap-4">
<span className="text-zinc-500">当前状态</span>
<span className="font-medium text-zinc-900">{detail.status}</span>
<span className="font-medium text-zinc-900">{detail.deletedAt ? "已删除(历史记录)" : detail.status}</span>
</div>
<div className="flex justify-between gap-4">
<span className="text-zinc-500">提交人</span>
+6 -6
查看文件
@@ -240,10 +240,10 @@ export function ReimbursementsPage() {
mutationFn: (reimbursementId: string) => deleteReimbursement(reimbursementId),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["reimbursements"] });
toast.success("草稿已删除");
toast.success("报销单已删除");
},
onError: (error) => {
toast.error(resolveErrorMessage(error, "删除草稿失败"));
toast.error(resolveErrorMessage(error, "删除报销单失败"));
},
});
@@ -502,7 +502,7 @@ export function ReimbursementsPage() {
items.map((item) => {
const listAction = resolveListAction(item);
const displayCode = resolveReimbursementDisplayCode(item);
const canDeleteDraft = item.status === "draft" && canDeleteExpense;
const canDeleteDraft = ["draft", "rejected"].includes(item.status) && canDeleteExpense && Boolean(me?.id && [item.owner?.id, item.submitter?.id].includes(me.id));
const workflowHint = resolveWorkflowHint(item);
return (
@@ -615,7 +615,7 @@ export function ReimbursementsPage() {
items.map((item) => {
const listAction = resolveListAction(item);
const displayCode = resolveReimbursementDisplayCode(item);
const canDeleteDraft = item.status === "draft" && canDeleteExpense;
const canDeleteDraft = ["draft", "rejected"].includes(item.status) && canDeleteExpense && Boolean(me?.id && [item.owner?.id, item.submitter?.id].includes(me.id));
const workflowHint = resolveWorkflowHint(item);
return (
@@ -684,7 +684,7 @@ export function ReimbursementsPage() {
className="text-red-600"
onSelect={() => handleDeleteDraft(item)}
>
删除草稿
删除报销单
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -732,7 +732,7 @@ export function ReimbursementsPage() {
}}
kind="delete"
targetName={deleteTarget ? resolveReimbursementDisplayCode(deleteTarget) : undefined}
description="删除后无法恢复,已提交或审批中的报销单不会受到影响。"
description="删除后,此单不再出现在日常列表中,符合条件的发票可重新报销;已发生的审批记录仍保留备查。"
onConfirm={async () => {
if (deleteTarget) {
deleteMutation.mutate(deleteTarget.id);
@@ -658,15 +658,15 @@ test.describe("R7E 全站高风险操作二次确认", () => {
await expect(page.getByTestId("reimbursements-table")).toBeVisible();
await page.getByTestId("reimbursement-row").getByRole("button", { name: "更多" }).click();
await page.getByRole("menuitem", { name: "删除草稿" }).click();
await expect(page.getByTestId("action-confirm-dialog")).toContainText("删除后无法恢复");
await page.getByRole("menuitem", { name: "删除报销单" }).click();
await expect(page.getByTestId("action-confirm-dialog")).toContainText("已发生的审批记录仍保留备查");
expect(calls.deleteReimbursement).toBe(0);
await page.getByTestId("action-confirm-cancel").click();
expect(calls.deleteReimbursement).toBe(0);
await page.getByTestId("reimbursement-row").getByRole("button", { name: "更多" }).click();
await page.getByRole("menuitem", { name: "删除草稿" }).click();
await page.getByRole("menuitem", { name: "删除报销单" }).click();
await page.getByTestId("action-confirm-submit").click();
await expect.poll(() => calls.deleteReimbursement).toBe(1);
@@ -698,7 +698,7 @@ test.describe("R7E 全站高风险操作二次确认", () => {
await page.goto(`/reimbursements/${reimbursementId}`);
await page.getByTestId("reimbursement-more-toggle").click();
await page.getByTestId("reimbursement-delete-draft").click();
await expect(page.getByTestId("action-confirm-dialog")).toContainText("删除后无法恢复");
await expect(page.getByTestId("action-confirm-dialog")).toContainText("已发生的审批记录仍保留备查");
expect(deleteCalls.deleteReimbursement).toBe(0);
await page.getByTestId("action-confirm-submit").click();
await expect.poll(() => deleteCalls.deleteReimbursement).toBe(1);