实现业务附件受控入库 V1(05-28-business-attachment-governance-v1)

这个提交包含在:
2026-05-28 19:44:15 +08:00
父节点 2c067866ef
当前提交 52e5284954
共修改 25 个文件,包含 1119 行新增和 21 行删除
@@ -2,6 +2,7 @@ import { useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { fetchPurchaseRequestDetail } from "../api/purchaseRequests";
import { MobileDetailHeader } from "../components/mobile/MobileDetailHeader";
import { AttachmentGovernanceBadges } from "../components/common/AttachmentGovernanceBadges";
import { StatusBadge } from "../components/common/StatusBadge";
import { PageScaffold } from "../components/patterns/PageScaffold";
import {
@@ -161,6 +162,7 @@ export function MobilePurchaseRequestDetailPage() {
<div className="mt-1 text-xs text-zinc-500">
{joinBusinessMeta([formatBytes(attachment.fileSize), attachment.uploader?.name, formatDate(attachment.createdAt)])}
</div>
<AttachmentGovernanceBadges attachment={attachment} className="mt-2" />
</article>
)) : <div className="mobile-empty-state">暂无附件</div>}
</div>
@@ -31,6 +31,8 @@ import { remindWorkflowTask } from "../api/workflow";
import type { PrecheckResponse } from "../api/feeControl";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { AttachmentGovernanceBadges } from "../components/common/AttachmentGovernanceBadges";
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
import { ConfirmDialog } from "../components/common/ConfirmDialog";
import { MobileIcon } from "../components/mobile/MobileIcon";
import { MobileDetailHeader } from "../components/mobile/MobileDetailHeader";
@@ -55,6 +57,7 @@ import {
} from "../lib/invoiceStatus";
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
import type { AttachmentGovernanceFields } from "../lib/attachmentGovernance";
import { TEST_IDS } from "../lib/testIds";
import { withUploadTimeout } from "../lib/uploadTimeout";
import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker";
@@ -671,8 +674,11 @@ export function MobileReimbursementDetailPage() {
},
});
const { requestDownloadReason, downloadReasonDialog } = useAttachmentDownloadReasonDialog();
const openAttachmentMutation = useMutation({
mutationFn: (attachmentId: string) => downloadReimbursementAttachment(reimbursementId, attachmentId),
mutationFn: ({ attachmentId, reason }: { attachmentId: string; reason?: string }) =>
downloadReimbursementAttachment(reimbursementId, attachmentId, reason),
onSuccess: (result) => {
const url = URL.createObjectURL(result.blob);
window.open(url, "_blank", "noopener,noreferrer");
@@ -683,6 +689,14 @@ export function MobileReimbursementDetailPage() {
},
});
async function handleOpenAttachment(attachment: { id: string; originalName?: string } & AttachmentGovernanceFields) {
const reason = await requestDownloadReason(attachment);
if (reason === null) {
return;
}
openAttachmentMutation.mutate({ attachmentId: attachment.id, reason });
}
const handleSubmit = async () => {
if (!detail) {
return;
@@ -811,6 +825,7 @@ export function MobileReimbursementDetailPage() {
)}
data-testid="mobile-reimbursement-detail"
>
{downloadReasonDialog}
<MobileDetailHeader
title={displayCode}
subtitle="报销详情"
@@ -1078,6 +1093,7 @@ export function MobileReimbursementDetailPage() {
<div className="mt-1 text-xs text-zinc-500">
{Math.ceil(attachment.fileSize / 1024)} KB · {formatDateTime(attachment.createdAt)}
</div>
<AttachmentGovernanceBadges attachment={attachment} className="mt-2" />
</div>
<div className="mobile-two-action-grid mt-3">
<Button
@@ -1085,7 +1101,7 @@ export function MobileReimbursementDetailPage() {
variant="secondary"
type="button"
disabled={openAttachmentMutation.isPending}
onClick={() => openAttachmentMutation.mutate(attachment.id)}
onClick={() => void handleOpenAttachment(attachment)}
>
查看
</Button>
+17 -3
查看文件
@@ -23,6 +23,8 @@ import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace";
import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog";
import { AttachmentGovernanceBadges } from "../components/common/AttachmentGovernanceBadges";
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
@@ -301,12 +303,14 @@ export function PaymentBatchesPage() {
},
});
const { requestDownloadReason, downloadReasonDialog } = useAttachmentDownloadReasonDialog();
const downloadReceiptMutation = useMutation({
mutationFn: async (attachment: PaymentBatchReceiptAttachment) => {
mutationFn: async ({ attachment, reason }: { attachment: PaymentBatchReceiptAttachment; reason?: string }) => {
if (!selectedBatch) {
throw new Error("请先选择付款批次");
}
const response = await downloadPaymentBatchReceiptAttachment(selectedBatch.id, attachment.id);
const response = await downloadPaymentBatchReceiptAttachment(selectedBatch.id, attachment.id, reason);
return { response, attachment };
},
onSuccess: ({ response, attachment }) => {
@@ -319,6 +323,14 @@ export function PaymentBatchesPage() {
},
});
async function handleDownloadReceipt(attachment: PaymentBatchReceiptAttachment) {
const reason = await requestDownloadReason(attachment);
if (reason === null) {
return;
}
downloadReceiptMutation.mutate({ attachment, reason });
}
const deleteReceiptMutation = useMutation({
mutationFn: async (attachmentId: string) => {
if (!selectedBatch) {
@@ -340,6 +352,7 @@ export function PaymentBatchesPage() {
return (
<ListWorkspace>
{downloadReasonDialog}
<WorkspaceHeader title="付款批次" subtitle="复核、回单和已付款标记集中处理。" />
{error ? <div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</div> : null}
@@ -607,6 +620,7 @@ export function PaymentBatchesPage() {
{attachment.mimeType} · {formatFileSize(attachment.fileSize)} · {formatDisplayDateTime(attachment.createdAt)}
{attachment.uploadedBy ? ` · ${attachment.uploadedBy.name}` : ""}
</div>
<AttachmentGovernanceBadges attachment={attachment} className="mt-2" />
</div>
<div className="flex flex-wrap gap-2">
<Button
@@ -614,7 +628,7 @@ export function PaymentBatchesPage() {
size="sm"
variant="secondary"
disabled={downloadReceiptMutation.isPending}
onClick={() => downloadReceiptMutation.mutate(attachment)}
onClick={() => void handleDownloadReceipt(attachment)}
>
下载
</Button>
+16 -4
查看文件
@@ -39,6 +39,8 @@ import { hasPermission } from "../auth/permissions";
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
import { AiSuggestionCard } from "../components/ai/AiSuggestionCard";
import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog";
import { AttachmentGovernanceBadges } from "../components/common/AttachmentGovernanceBadges";
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
import { DateTextInput } from "../components/common/DateTextInput";
import { Pagination } from "../components/common/Pagination";
import { StatusBadge } from "../components/common/StatusBadge";
@@ -49,6 +51,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from ".
import { Textarea } from "../components/ui/textarea";
import { FilterBar, ListWorkspace, PrimaryTable, WorkspaceHeader } from "../components/patterns/ListWorkspace";
import { SectionPanel } from "../components/patterns/SectionPanel";
import type { AttachmentGovernanceFields } from "../lib/attachmentGovernance";
import { formatCompactIdentifier, formatDisplayDateTime, formatTextWithCompactLongNumbers, isCompactedIdentifier } from "../lib/displayFormat";
import { buildPrecheckDialogMessage } from "../lib/precheck";
import {
@@ -744,12 +747,18 @@ export function PurchaseRequestsPage() {
},
});
async function handleDownloadAttachment(attachmentId: string) {
const { requestDownloadReason, downloadReasonDialog } = useAttachmentDownloadReasonDialog();
async function handleDownloadAttachment(attachment: { id: string; originalName?: string } & AttachmentGovernanceFields) {
if (!selectedId) {
return;
}
const reason = await requestDownloadReason(attachment);
if (reason === null) {
return;
}
try {
const response = await issuePurchaseAttachmentSignedUrl(selectedId, attachmentId);
const response = await issuePurchaseAttachmentSignedUrl(selectedId, attachment.id, reason);
window.open(response.signedUrl, "_blank", "noopener,noreferrer");
} catch (error) {
const message = resolveError(error);
@@ -814,6 +823,7 @@ export function PurchaseRequestsPage() {
return (
<ListWorkspace>
{downloadReasonDialog}
<WorkspaceHeader
title="采购申请"
subtitle="草稿、附件、审批。"
@@ -1576,6 +1586,7 @@ export function PurchaseRequestsPage() {
<div key={attachment.id} className="rounded-lg border border-zinc-200 bg-white p-4">
<div className="font-medium text-zinc-900">{attachment.originalName}</div>
<div className="mt-1 text-xs text-zinc-500">{attachment.mimeType} / {attachment.fileSize} bytes</div>
<AttachmentGovernanceBadges attachment={attachment} className="mt-2" />
<div className="mt-3 grid grid-cols-2 gap-2 text-xs text-zinc-600">
<div>
<div className="text-zinc-400">上传人</div>
@@ -1589,7 +1600,7 @@ export function PurchaseRequestsPage() {
</div>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Button type="button" size="sm" variant="secondary" onClick={() => void handleDownloadAttachment(attachment.id)}>
<Button type="button" size="sm" variant="secondary" onClick={() => void handleDownloadAttachment(attachment)}>
下载
</Button>
<Button
@@ -1625,12 +1636,13 @@ export function PurchaseRequestsPage() {
<TableCell>
<div className="font-medium text-zinc-900">{attachment.originalName}</div>
<div className="text-xs text-zinc-500">{attachment.mimeType} / {attachment.fileSize} bytes</div>
<AttachmentGovernanceBadges attachment={attachment} className="mt-2" />
</TableCell>
<TableCell>{attachment.uploader?.name ?? "-"}</TableCell>
<TableCell>{formatDisplayDateTime(attachment.createdAt)}</TableCell>
<TableCell>
<div className="flex gap-2">
<Button type="button" size="sm" variant="secondary" onClick={() => void handleDownloadAttachment(attachment.id)}>
<Button type="button" size="sm" variant="secondary" onClick={() => void handleDownloadAttachment(attachment)}>
下载
</Button>
<Button
+18 -2
查看文件
@@ -35,6 +35,8 @@ import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { CardSkeleton } from "../components/common/CardSkeleton";
import { ConfirmDialog } from "../components/common/ConfirmDialog";
import { AttachmentGovernanceBadges } from "../components/common/AttachmentGovernanceBadges";
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
import { StatusBadge } from "../components/common/StatusBadge";
import { AiSuggestionCard } from "../components/ai/AiSuggestionCard";
@@ -62,6 +64,7 @@ import {
} from "../lib/invoiceStatus";
import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker";
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
import type { AttachmentGovernanceFields } from "../lib/attachmentGovernance";
import { formatDisplayDate } from "../lib/displayFormat";
import { TEST_IDS } from "../lib/testIds";
import { PDF_FILE_ACCEPT, rejectedPdfMessage, splitPdfFiles } from "../lib/pdfFiles";
@@ -726,8 +729,11 @@ export function ReimbursementDetailPage() {
},
});
const { requestDownloadReason, downloadReasonDialog } = useAttachmentDownloadReasonDialog();
const openAttachmentMutation = useMutation({
mutationFn: (attachmentId: string) => downloadReimbursementAttachment(reimbursementId, attachmentId),
mutationFn: ({ attachmentId, reason }: { attachmentId: string; reason?: string }) =>
downloadReimbursementAttachment(reimbursementId, attachmentId, reason),
onSuccess: (result) => {
const url = URL.createObjectURL(result.blob);
window.open(url, "_blank", "noopener,noreferrer");
@@ -738,6 +744,14 @@ export function ReimbursementDetailPage() {
},
});
async function handleOpenAttachment(attachment: { id: string; originalName?: string } & AttachmentGovernanceFields) {
const reason = await requestDownloadReason(attachment);
if (reason === null) {
return;
}
openAttachmentMutation.mutate({ attachmentId: attachment.id, reason });
}
if (!reimbursementId) {
return (
<div className="card-std p-5">
@@ -959,6 +973,7 @@ export function ReimbursementDetailPage() {
return (
<PageScaffold>
{downloadReasonDialog}
<EntityOverviewHero
title={`报销详情 ${displayCode}`}
subtitle="状态、票据、审批和付款集中处理。"
@@ -1197,6 +1212,7 @@ export function ReimbursementDetailPage() {
<div className="text-xs text-zinc-500">
{Math.ceil(attachment.fileSize / 1024)} KB · {formatDateTime(attachment.createdAt)}
</div>
<AttachmentGovernanceBadges attachment={attachment} className="mt-2" />
</div>
<div className="flex gap-2">
<Button
@@ -1204,7 +1220,7 @@ export function ReimbursementDetailPage() {
variant="secondary"
size="sm"
disabled={openAttachmentMutation.isPending}
onClick={() => openAttachmentMutation.mutate(attachment.id)}
onClick={() => void handleOpenAttachment(attachment)}
>
查看
</Button>