feat(P17B-3,P17B-4): 增加采购归档前端与审批链路E2E

这个提交包含在:
2026-03-07 22:01:06 +08:00
父节点 033cfd6833
当前提交 408186644c
共修改 5 个文件,包含 303 行新增和 16 行删除
+145 -13
查看文件
@@ -4,8 +4,10 @@ import { toast } from "sonner";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import {
archivePurchaseOrder,
createPurchaseRequest,
deletePurchaseAttachment,
fetchPurchaseOrderArchive,
fetchPurchaseRequestDetail,
fetchPurchaseRequests,
issuePurchaseAttachmentSignedUrl,
@@ -90,9 +92,10 @@ export function PurchaseRequestsPage() {
const { permissions } = useAuth();
const canCreate = hasPermission(permissions, "procurement.create");
const canManage = hasPermission(permissions, "procurement.manage");
const canViewAll =
hasPermission(permissions, "procurement.view_all") ||
hasPermission(permissions, "procurement.manage");
canManage;
const [status, setStatus] = useState<PurchaseRequestStatus | "">("draft");
const [mine, setMine] = useState(true);
@@ -112,6 +115,8 @@ export function PurchaseRequestsPage() {
const [documentCategoryId, setDocumentCategoryId] = useState("");
const [expectedDeliveryDate, setExpectedDeliveryDate] = useState("");
const [items, setItems] = useState<DraftItem[]>([]);
const [archivePoNo, setArchivePoNo] = useState("");
const [archiveContractNo, setArchiveContractNo] = useState("");
const suppliersQuery = useQuery({
queryKey: ["purchase-requests", "suppliers"],
@@ -153,14 +158,27 @@ export function PurchaseRequestsPage() {
queryFn: () => fetchPurchaseRequestDetail(selectedId),
});
const archiveOrderQuery = useQuery({
enabled: Boolean(selectedId) && (detailQuery.data?.status === "approved" || Boolean(detailQuery.data?.archiveOrder)),
queryKey: ["purchase-requests", "archive-order", selectedId],
queryFn: () => fetchPurchaseOrderArchive(selectedId),
});
useEffect(() => {
const currentIds = new Set((listQuery.data?.items ?? []).map((item) => item.id));
if (selectedId && currentIds.has(selectedId)) {
return;
if (selectedId) {
if (detailQuery.isLoading) {
return;
}
if (!detailQuery.isError) {
return;
}
}
const nextId = listQuery.data?.items?.[0]?.id ?? "";
setSelectedId(nextId);
}, [listQuery.data?.items, selectedId]);
if (nextId !== selectedId) {
setSelectedId(nextId);
}
}, [detailQuery.isError, detailQuery.isLoading, listQuery.data?.items, selectedId]);
useEffect(() => {
if (!detailQuery.data) {
@@ -184,9 +202,20 @@ export function PurchaseRequestsPage() {
isAssetCandidate: item.isAssetCandidate,
})),
);
setArchivePoNo(detailQuery.data.archiveOrder?.poNo || "");
setArchiveContractNo(detailQuery.data.archiveOrder?.contractNo || "");
setFormError("");
}, [detailQuery.data]);
useEffect(() => {
if (!archiveOrderQuery.data) {
return;
}
setArchivePoNo(archiveOrderQuery.data.archiveOrder?.poNo || "");
setArchiveContractNo(archiveOrderQuery.data.archiveOrder?.contractNo || "");
}, [archiveOrderQuery.data]);
const filteredCostCenters = useMemo(() => {
if (!legalEntityId) {
return [];
@@ -334,6 +363,7 @@ export function PurchaseRequestsPage() {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["purchase-requests"] }),
queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }),
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "archive-order", selectedId] }),
]);
setFormError("");
toast.success("采购申请已撤回到草稿");
@@ -345,6 +375,34 @@ export function PurchaseRequestsPage() {
},
});
const archiveMutation = useMutation({
mutationFn: async () => {
if (!selectedId) {
throw new Error("请先选择采购单");
}
if (!archivePoNo.trim()) {
throw new Error("请填写 PO 号");
}
return archivePurchaseOrder(selectedId, {
poNo: archivePoNo.trim(),
contractNo: archiveContractNo.trim() || null,
archivedFiles: archiveOrderQuery.data?.archiveOrder?.archivedFiles ?? [],
});
},
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "detail", selectedId] }),
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "archive-order", selectedId] }),
]);
toast.success("PO/合同归档已保存");
},
onError: (error) => {
const message = resolveError(error);
toast.error(message);
},
});
const uploadMutation = useMutation({
mutationFn: async (file: File) => {
if (!selectedId) {
@@ -396,9 +454,12 @@ export function PurchaseRequestsPage() {
}
const detail = detailQuery.data;
const archiveOrder = archiveOrderQuery.data?.archiveOrder ?? detail?.archiveOrder ?? null;
const showArchiveSection = Boolean(detail && (detail.status === "approved" || archiveOrder));
const canEditDraft = Boolean(detail?.permissions.canEditDraft);
const canSubmit = Boolean(detail?.permissions.canSubmit);
const canWithdraw = Boolean(detail?.permissions.canWithdraw);
const canArchiveOrder = Boolean(detail?.permissions.canArchiveOrder && canManage);
return (
<div className="space-y-6">
@@ -607,8 +668,9 @@ export function PurchaseRequestsPage() {
<p className="text-sm text-zinc-500">申请人:{detail.applicant?.name ?? "-"}</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<StatusBadge status={detail.status} />
<StatusBadge data-testid="purchase-detail-status" status={detail.status} />
<Button
data-testid="purchase-save"
type="button"
variant="secondary"
disabled={!canEditDraft || saveMutation.isPending}
@@ -617,6 +679,7 @@ export function PurchaseRequestsPage() {
{saveMutation.isPending ? "保存中..." : "保存草稿"}
</Button>
<Button
data-testid="purchase-submit"
type="button"
disabled={!canSubmit || submitMutation.isPending}
onClick={() => submitMutation.mutate()}
@@ -624,6 +687,7 @@ export function PurchaseRequestsPage() {
{submitMutation.isPending ? "提交中..." : "提交审批"}
</Button>
<Button
data-testid="purchase-withdraw"
type="button"
variant="secondary"
disabled={!canWithdraw || withdrawMutation.isPending}
@@ -637,7 +701,7 @@ export function PurchaseRequestsPage() {
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">标题</label>
<Input value={title} onChange={(event) => setTitle(event.target.value)} disabled={!canEditDraft} />
<Input data-testid="purchase-title" value={title} onChange={(event) => setTitle(event.target.value)} disabled={!canEditDraft} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">币种</label>
@@ -646,6 +710,7 @@ export function PurchaseRequestsPage() {
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">法体</label>
<select
data-testid="purchase-legal-entity"
className="select-std"
value={legalEntityId}
disabled={!canEditDraft}
@@ -667,7 +732,7 @@ export function PurchaseRequestsPage() {
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">成本中心</label>
<select className="select-std" value={costCenterId} disabled={!canEditDraft} onChange={(event) => setCostCenterId(event.target.value)}>
<select data-testid="purchase-cost-center" className="select-std" value={costCenterId} disabled={!canEditDraft} onChange={(event) => setCostCenterId(event.target.value)}>
<option value="">请选择成本中心</option>
{filteredCostCenters.map((item) => (
<option key={item.id} value={item.id}>
@@ -678,7 +743,7 @@ export function PurchaseRequestsPage() {
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">供应商</label>
<select className="select-std" value={supplierId} disabled={!canEditDraft} onChange={(event) => setSupplierId(event.target.value)}>
<select data-testid="purchase-supplier" className="select-std" value={supplierId} disabled={!canEditDraft} onChange={(event) => setSupplierId(event.target.value)}>
<option value="">请选择供应商</option>
{(suppliersQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
@@ -689,7 +754,7 @@ export function PurchaseRequestsPage() {
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">单据分类</label>
<select className="select-std" value={documentCategoryId} disabled={!canEditDraft} onChange={(event) => setDocumentCategoryId(event.target.value)}>
<select data-testid="purchase-document-category" className="select-std" value={documentCategoryId} disabled={!canEditDraft} onChange={(event) => setDocumentCategoryId(event.target.value)}>
<option value="">请选择单据分类</option>
{(categoriesQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
@@ -700,7 +765,7 @@ export function PurchaseRequestsPage() {
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">期望到货日期</label>
<Input type="date" value={expectedDeliveryDate} disabled={!canEditDraft} onChange={(event) => setExpectedDeliveryDate(event.target.value)} />
<Input data-testid="purchase-expected-delivery-date" type="date" value={expectedDeliveryDate} disabled={!canEditDraft} onChange={(event) => setExpectedDeliveryDate(event.target.value)} />
</div>
</div>
@@ -718,7 +783,7 @@ export function PurchaseRequestsPage() {
<div className="space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-base font-semibold text-zinc-900">采购明细</h4>
<Button type="button" variant="secondary" disabled={!canEditDraft} onClick={() => setItems((current) => [...current, emptyDraftItem()])}>
<Button data-testid="purchase-add-item" type="button" variant="secondary" disabled={!canEditDraft} onClick={() => setItems((current) => [...current, emptyDraftItem()])}>
新增明细
</Button>
</div>
@@ -741,6 +806,7 @@ export function PurchaseRequestsPage() {
<TableRow key={item.id}>
<TableCell>
<Input
data-testid={`purchase-item-description-${item.id}`}
value={item.description}
disabled={!canEditDraft}
onChange={(event) =>
@@ -767,6 +833,7 @@ export function PurchaseRequestsPage() {
</TableCell>
<TableCell>
<Input
data-testid={`purchase-item-quantity-${item.id}`}
value={item.quantity}
disabled={!canEditDraft}
onChange={(event) =>
@@ -780,6 +847,7 @@ export function PurchaseRequestsPage() {
</TableCell>
<TableCell>
<Input
data-testid={`purchase-item-unit-price-${item.id}`}
value={item.unitPrice}
disabled={!canEditDraft}
onChange={(event) =>
@@ -895,6 +963,70 @@ export function PurchaseRequestsPage() {
</Table>
</div>
</div>
{showArchiveSection ? (
<div className="space-y-3 rounded-lg border border-zinc-200 p-4">
<div className="flex items-center justify-between">
<div>
<h4 className="text-base font-semibold text-zinc-900">PO/合同归档</h4>
<p className="text-sm text-zinc-500">仅已通过采购单可归档,当前阶段保留编号与文件元数据锚点。</p>
</div>
{archiveOrder ? (
<div className="text-right text-xs text-zinc-500">
<div>归档时间:{archiveOrder.archivedAt ? new Date(archiveOrder.archivedAt).toLocaleString() : "-"}</div>
<div>归档人:{archiveOrder.archivedByEmployee?.name ?? "-"}</div>
</div>
) : null}
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">PO号</label>
<Input
data-testid="purchase-archive-po-no"
value={archivePoNo}
disabled={!canArchiveOrder || archiveMutation.isPending}
onChange={(event) => setArchivePoNo(event.target.value)}
placeholder="请输入 PO 号"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">合同号</label>
<Input
data-testid="purchase-archive-contract-no"
value={archiveContractNo}
disabled={!canArchiveOrder || archiveMutation.isPending}
onChange={(event) => setArchiveContractNo(event.target.value)}
placeholder="可选"
/>
</div>
</div>
<div className="flex items-center justify-between rounded-lg bg-zinc-50 px-3 py-2 text-sm text-zinc-600">
<span>已记录归档文件:{archiveOrder?.archivedFiles.length ?? 0} 个</span>
<Button
type="button"
size="sm"
data-testid="purchase-archive-save"
disabled={!canArchiveOrder || archiveMutation.isPending}
onClick={() => archiveMutation.mutate()}
>
{archiveMutation.isPending ? "保存中..." : "保存归档"}
</Button>
</div>
{archiveOrder?.archivedFiles.length ? (
<div className="space-y-2 text-sm text-zinc-600">
{archiveOrder.archivedFiles.map((file, index) => (
<div key={`${file.storagePath ?? file.originalName ?? index}`} className="rounded border border-zinc-200 px-3 py-2">
<div className="font-medium text-zinc-900">{file.originalName ?? `归档文件 ${index + 1}`}</div>
<div className="text-xs text-zinc-500">{file.mimeType ?? "application/octet-stream"} / {file.fileSize} bytes</div>
</div>
))}
</div>
) : null}
</div>
) : null}
</>
) : null}
</div>
@@ -189,6 +189,7 @@ export function WorkflowTaskDetailPage() {
{detail.purchaseRequest ? (
<>
<div>采购单号:{detail.purchaseRequest.requestNo ?? detail.purchaseRequest.id}</div>
<div>标题:{detail.purchaseRequest.title || "-"}</div>
<div>申请人:{detail.purchaseRequest.applicant?.name ?? "-"}</div>
<div>供应商:{detail.purchaseRequest.supplier?.name ?? "-"}</div>
<div>法体:{detail.purchaseRequest.legalEntity?.name ?? "-"}</div>