修复非参与人报销只读边界(08-28-reimbursement-nonparticipant-readonly-projection)
这个提交包含在:
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
在新工单中引用
屏蔽一个用户