修复非参与人报销只读边界(08-28-reimbursement-nonparticipant-readonly-projection)

这个提交包含在:
2026-08-29 08:22:23 +08:00
父节点 174cb7acde
当前提交 e8d676634f
共修改 24 个文件,包含 891 行新增和 62 行删除
@@ -198,12 +198,12 @@ export function MobileReimbursementDetailPage() {
const detail = detailQuery.data;
const canUploadInvoice = hasPermission(permissions, "invoice.upload");
const canViewOwnInvoice = hasPermission(permissions, "invoice.view_own");
const canViewAllExpense = hasPermission(permissions, "expense.view_all");
const canDeleteExpense = hasPermission(permissions, "expense.delete_own");
const canSubmitExpense = hasPermission(permissions, "expense.submit");
const canAccessAssets = hasAnyPermission(permissions, ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"]);
const isParticipant = Boolean(detail && me?.id && [detail.ownerId, detail.submitterId].includes(me.id));
const canEdit = isEditableStatus(detail?.status);
const canMaintainReimbursement = Boolean(canEdit && isParticipant);
const pendingTaskId = detail?.pendingWorkflow?.task?.id ?? null;
const canRemindPendingWorkflow = Boolean(
detail?.status === "reviewing" &&
@@ -211,14 +211,14 @@ export function MobileReimbursementDetailPage() {
isParticipant &&
detail?.pendingWorkflow?.task?.assignee_id !== me?.id,
);
const canOpenInvoiceDialog = Boolean(canEdit && isParticipant && canUploadInvoice && canViewOwnInvoice);
const canUnbindInvoices = Boolean(canEdit && (isParticipant || canViewAllExpense));
const canDeleteDraft = Boolean(detail?.status === "draft" && canDeleteExpense && (isParticipant || canViewAllExpense));
const canManageAttachments = Boolean(canEdit && (isParticipant || canViewAllExpense));
const canOpenInvoiceDialog = Boolean(canMaintainReimbursement && canUploadInvoice && canViewOwnInvoice);
const canUnbindInvoices = canMaintainReimbursement;
const canDeleteDraft = Boolean(detail?.status === "draft" && canDeleteExpense && isParticipant);
const canManageAttachments = canMaintainReimbursement;
const selectedInvoiceEntries = Object.entries(selectedInvoices);
const reimbursementPrecheckQuery = useQuery({
enabled: Boolean(reimbursementId && canSubmitExpense && detail),
enabled: Boolean(reimbursementId && canSubmitExpense && canMaintainReimbursement),
queryKey: ["reimbursements", reimbursementId, "precheck"],
queryFn: () => precheckReimbursement(reimbursementId),
});
@@ -834,10 +834,10 @@ export function MobileReimbursementDetailPage() {
const resolveLinkedItemLabel = (linkedItemId: string | null) => {
if (!linkedItemId) {
return "自动生成或匹配明细";
return "未关联";
}
const item = detail?.items.find((candidate) => candidate.id === linkedItemId);
return item ? itemLabel(item) : "明细";
return item ? itemLabel(item) : "无法识别";
};
const expenseTypes = expenseTypesQuery.data?.items ?? [];
@@ -874,7 +874,7 @@ export function MobileReimbursementDetailPage() {
}
const displayCode = resolveReimbursementDisplayCode(detail);
const showSubmitAction = Boolean(canEdit && canSubmitExpense);
const showSubmitAction = Boolean(canMaintainReimbursement && canSubmitExpense);
const showStickyActions = showSubmitAction;
const showSecondaryActions = Boolean(actionError || canDeleteDraft);
@@ -903,6 +903,16 @@ export function MobileReimbursementDetailPage() {
<div className="mobile-section-header mobile-section-header--top">
<div>
<h2 className="mobile-section-title">报销概况</h2>
{!isParticipant ? (
<p className="mt-1 text-xs text-zinc-500">
只读查看
{detail.status === "draft"
? " · 等待报销人完善"
: detail.status === "rejected"
? " · 等待报销人修改后重提"
: ""}
</p>
) : null}
</div>
</div>
<div data-testid="mobile-reimbursement-summary">
@@ -990,7 +1000,7 @@ export function MobileReimbursementDetailPage() {
<section className="mobile-card" data-testid="mobile-reimbursement-items">
<div className="flex items-center justify-between gap-3">
<h2 className="text-base font-semibold text-zinc-900">报销明细</h2>
{canEdit ? (
{canMaintainReimbursement ? (
<Button size="sm" variant="secondary" type="button" onClick={startCreateItem}>
新增
</Button>
@@ -1076,7 +1086,7 @@ export function MobileReimbursementDetailPage() {
<div className="shrink-0 text-base font-semibold text-zinc-900">{formatMoney(item.amount)}</div>
</div>
{item.description ? <p className="mt-2 text-sm leading-5 text-zinc-600">{item.description}</p> : null}
{canEdit ? (
{canMaintainReimbursement ? (
<div className="mobile-two-action-grid mt-3">
<Button size="sm" variant="secondary" type="button" onClick={() => startEditItem(item)}>
编辑
@@ -1220,7 +1230,9 @@ export function MobileReimbursementDetailPage() {
<div className="mt-3 space-y-2">
<div data-testid={TEST_IDS.reimbursement.invoices} className="space-y-2">
{detail.invoices.map((invoice) => {
const invoiceHint = resolveInvoiceNextStepText(invoice);
const invoiceHint = invoice.status === "bound" && !canMaintainReimbursement
? "已关联"
: resolveInvoiceNextStepText(invoice);
return (
<article key={invoice.id} className="mobile-list-card">
<button
+37 -16
查看文件
@@ -158,6 +158,12 @@ export function ReimbursementDetailPage() {
});
const projectsQuery = useQuery({
enabled: Boolean(
detailQuery.data &&
me?.id &&
[detailQuery.data.ownerId, detailQuery.data.submitterId].includes(me.id) &&
["draft", "rejected"].includes(detailQuery.data.status),
),
queryKey: ["projects", "reimbursement-link"],
queryFn: () => fetchProjects({ mine: true }),
});
@@ -165,12 +171,18 @@ export function ReimbursementDetailPage() {
const canSubmitExpense = hasPermission(permissions, "expense.submit");
const canUploadInvoice = hasPermission(permissions, "invoice.upload");
const canViewOwnInvoice = hasPermission(permissions, "invoice.view_own");
const canViewAllExpense = hasPermission(permissions, "expense.view_all");
const canDeleteExpense = hasPermission(permissions, "expense.delete_own");
const canAccessAssets = hasAnyPermission(permissions, ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"]);
const reimbursementPrecheckQuery = useQuery({
enabled: Boolean(reimbursementId) && canSubmitExpense && Boolean(detailQuery.data),
enabled: Boolean(
reimbursementId &&
canSubmitExpense &&
detailQuery.data &&
me?.id &&
[detailQuery.data.ownerId, detailQuery.data.submitterId].includes(me.id) &&
["draft", "rejected"].includes(detailQuery.data.status),
),
queryKey: ["reimbursements", reimbursementId, "precheck"],
queryFn: () => precheckReimbursement(reimbursementId),
});
@@ -724,6 +736,7 @@ export function ReimbursementDetailPage() {
const budgetHint = detail.budgetHint;
const canEditItems = ["draft", "rejected"].includes(detail.status);
const isReimbursementParticipant = Boolean(me?.id) && [detail.ownerId, detail.submitterId].includes(me?.id ?? "");
const canMaintainReimbursement = canEditItems && isReimbursementParticipant;
const pendingTaskId = detail.pendingWorkflow?.task?.id ?? null;
const canRemindPendingWorkflow = Boolean(
detail.status === "reviewing" &&
@@ -736,16 +749,16 @@ export function ReimbursementDetailPage() {
isReimbursementParticipant &&
canUploadInvoice &&
canViewOwnInvoice;
const canUnbindInvoices = canEditItems && (isReimbursementParticipant || canViewAllExpense);
const canUnbindInvoices = canMaintainReimbursement;
const canDeleteReimbursement =
detail.status === "draft" &&
canDeleteExpense &&
(isReimbursementParticipant || canViewAllExpense);
isReimbursementParticipant;
const precheckStandardChecks = reimbursementPrecheckQuery.data?.standardChecks ?? [];
const blockStandardChecks = precheckStandardChecks.filter((item) => item.exceeded && item.controlMode === "block");
const warnStandardChecks = precheckStandardChecks.filter((item) => item.exceeded && item.controlMode === "warn");
const missingInvoiceSubmitMessage = "提交前需先添加至少 1 张发票。";
const submitInvoiceNotice = canEditItems && detail.invoices.length < 1 ? missingInvoiceSubmitMessage : "";
const submitInvoiceNotice = canMaintainReimbursement && detail.invoices.length < 1 ? missingInvoiceSubmitMessage : "";
const selectedInvoiceEntries = Object.entries(selectedInvoices);
function handleInvoicePdfSelection(files: FileList | null) {
@@ -886,12 +899,12 @@ export function ReimbursementDetailPage() {
const resolveLinkedItemLabel = (linkedItemId: string | null) => {
if (!linkedItemId) {
return "自动生成或匹配明细";
return "未关联";
}
const linkedItem = detail.items.find((item) => item.id === linkedItemId);
if (!linkedItem) {
return "未识别明细";
return "无法识别";
}
return `${linkedItem.expenseType?.name ?? "未识别费用类型"} · ${linkedItem.description ?? "未填写说明"}`;
@@ -909,6 +922,9 @@ export function ReimbursementDetailPage() {
: detail.status === "paid" ? "已完成" : "待处理";
const nextStepText = (() => {
if (["draft", "rejected"].includes(detail.status)) {
if (!isReimbursementParticipant) {
return detail.status === "rejected" ? "等待报销人修改后重提" : "等待报销人完善";
}
if (!itemCount) {
return "补报销明细";
}
@@ -933,7 +949,7 @@ export function ReimbursementDetailPage() {
})();
const showReviewHints = Boolean(budgetHint || detail.riskFlags.length);
const primaryAction = (() => {
if (["draft", "rejected"].includes(detail.status) && canEditItems) {
if (canMaintainReimbursement) {
if (!itemCount) {
return (
<Button
@@ -1021,6 +1037,9 @@ export function ReimbursementDetailPage() {
meta={(
<>
<StatusBadge status={detail.status} />
{!isReimbursementParticipant ? (
<span className="rounded-full bg-zinc-100 px-2.5 py-1 text-xs font-medium text-zinc-600">只读查看</span>
) : null}
</>
)}
/>
@@ -1078,7 +1097,7 @@ export function ReimbursementDetailPage() {
editable={Boolean(canEditItems && isReimbursementParticipant)}
embedded
/>
{canEditItems || detail.project || detail.linkedTravelRequest ? (
{canMaintainReimbursement || detail.project || detail.linkedTravelRequest ? (
<div className="mt-4 border-t border-zinc-200 pt-4 text-sm" data-testid="reimbursement-related-records">
<div className="flex flex-wrap items-center justify-between gap-3">
<span className="font-medium text-zinc-900">关联事项</span>
@@ -1088,7 +1107,7 @@ export function ReimbursementDetailPage() {
</Link>
) : null}
</div>
{canEditItems ? (
{canMaintainReimbursement ? (
<select
className="select-std mt-3 max-w-xl"
value={detail.projectId ?? ""}
@@ -1155,7 +1174,7 @@ export function ReimbursementDetailPage() {
<SectionPanel title="报销明细">
<div className="space-y-4">
<div className="space-y-4" data-testid="reimbursement-items">
{canEditItems ? (
{canMaintainReimbursement ? (
<div className="flex justify-end">
<Button
data-testid="reimbursement-item-add"
@@ -1178,7 +1197,7 @@ export function ReimbursementDetailPage() {
</p>
) : null}
{reimbursementPrecheckQuery.isLoading && canEditItems ? (
{reimbursementPrecheckQuery.isLoading && canMaintainReimbursement ? (
<p className="text-sm text-zinc-500">正在刷新费用标准校验结果...</p>
) : null}
@@ -1241,7 +1260,7 @@ export function ReimbursementDetailPage() {
))}
</TableCell>
<TableCell>
{canEditItems ? (
{canMaintainReimbursement ? (
<div className="flex min-w-36 items-center gap-2">
<Button
data-testid="reimbursement-item-edit"
@@ -1358,7 +1377,9 @@ export function ReimbursementDetailPage() {
<div className="space-y-2" data-testid={TEST_IDS.reimbursement.invoices}>
{detail.invoices.map((invoice) => {
const invoiceHint = resolveInvoiceNextStepText(invoice);
const invoiceHint = invoice.status === "bound" && !canMaintainReimbursement
? "已关联"
: resolveInvoiceNextStepText(invoice);
return (
<div className="detail-evidence-card flex flex-col gap-3 md:flex-row md:items-center" key={invoice.id}>
<button
@@ -1397,7 +1418,7 @@ export function ReimbursementDetailPage() {
</div>
<div className="space-y-4" data-testid="reimbursement-attachments">
{canEditItems ? (
{canMaintainReimbursement ? (
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<p className="text-xs text-zinc-500">支持 PDF/JPG/PNG/WebP,单次最多 5 个。</p>
<div className="flex flex-col gap-2 md:flex-row md:items-center">
@@ -1461,7 +1482,7 @@ export function ReimbursementDetailPage() {
</span>
<ChevronRight aria-hidden="true" className="size-4 shrink-0 text-zinc-400" />
</button>
{canEditItems ? (
{canMaintainReimbursement ? (
<Button
type="button"
variant="destructive"
@@ -452,7 +452,7 @@ test.describe("R102 reimbursement main flow", () => {
await expect(page.getByTestId("mobile-reimbursement-items")).toContainText("报销合计 ¥168.30");
});
test("管理员查看他人报销时隐藏撤回并可在移动端切换全部报销", async ({ page }) => {
test("管理员查看他人报销时保持只读并可在移动端切换全部报销", async ({ page }) => {
const adminMe = {
...me,
id: "admin-r102",
@@ -475,11 +475,25 @@ test.describe("R102 reimbursement main flow", () => {
...reimbursementDetail(),
id: otherReimbursementId,
formNumber: "BX-20260731-0002",
status: "reviewing",
status: "draft",
ownerId: "employee-other-r102",
submitterId: "employee-other-r102",
owner: { id: "employee-other-r102", employee_no: "E103", name: "其他员工" },
submitter: { id: "employee-other-r102", employee_no: "E103", name: "其他员工" },
invoices: [{
id: "invoice-other-r102",
status: "bound",
invoiceCode: "SYNTHETIC",
invoiceNumber: "R102-OTHER",
invoiceDate: "2026-07-31",
sellerName: "合成供应方",
buyerName: "合成购买方",
totalAmount: "168.30",
originalName: "合成发票.pdf",
mimeType: "application/pdf",
fileSize: 1024,
linkedItemId: null,
}],
});
});
await page.route((url) => url.pathname === "/api/reimbursements", async (route) => {
@@ -515,6 +529,13 @@ test.describe("R102 reimbursement main flow", () => {
await expect(page.getByTestId("reimbursement-overview")).toBeVisible();
await expect(page.getByTestId("reimbursement-withdraw")).toHaveCount(0);
await expect(page.getByTestId("reimbursement-withdraw-comment")).toHaveCount(0);
await expect(page.getByText("只读查看")).toBeVisible();
await expect(page.getByText("等待报销人完善")).toBeVisible();
await expect(page.getByTestId("reimbursement-item-add")).toHaveCount(0);
await expect(page.getByRole("combobox", { name: "关联项目" })).toHaveCount(0);
await expect(page.getByTestId("reimbursement-invoices")).toContainText("明细:未关联");
await expect(page.getByTestId("reimbursement-invoices")).toContainText("已关联");
await expect(page.getByText("草稿或驳回状态可移除")).toHaveCount(0);
await page.setViewportSize({ width: 375, height: 812 });
await page.goto("/mobile/reimbursements?status=reviewing");
@@ -543,6 +564,11 @@ test.describe("R102 reimbursement main flow", () => {
await page.getByText("其他员工").click();
await expect(page).toHaveURL(new RegExp(`/mobile/reimbursements/${otherReimbursementId}$`));
await expect(page.getByText("只读查看")).toBeVisible();
await expect(page.getByTestId("mobile-reimbursement-items").getByRole("button", { name: "新增" })).toHaveCount(0);
await expect(page.getByTestId("reimbursement-invoices")).toContainText("明细:未关联");
await expect(page.getByTestId("reimbursement-invoices")).toContainText("已关联");
await expect(page.getByRole("button", { name: "移除" })).toHaveCount(0);
const routeState = await page.evaluate(() => window.history.state?.usr ?? null);
expect(routeState?.from).toContain("status=reviewing");
expect(routeState?.from).toContain("scope=all");