feat(P17B-3,P17B-4): 增加采购归档前端与审批链路E2E
这个提交包含在:
@@ -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>
|
||||
|
||||
在新工单中引用
屏蔽一个用户