feat(P17B-3,P17B-4): 增加采购归档前端与审批链路E2E
这个提交包含在:
@@ -90,6 +90,27 @@ export type PurchaseAttachment = {
|
|||||||
} | null;
|
} | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PurchaseArchivedFile = {
|
||||||
|
storagePath: string | null;
|
||||||
|
originalName: string | null;
|
||||||
|
mimeType: string | null;
|
||||||
|
fileSize: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PurchaseOrderArchive = {
|
||||||
|
id: string;
|
||||||
|
poNo: string;
|
||||||
|
contractNo: string | null;
|
||||||
|
archivedAt: string | null;
|
||||||
|
archivedBy: string;
|
||||||
|
archivedByEmployee: {
|
||||||
|
id: string;
|
||||||
|
employeeNo: string;
|
||||||
|
name: string;
|
||||||
|
} | null;
|
||||||
|
archivedFiles: PurchaseArchivedFile[];
|
||||||
|
};
|
||||||
|
|
||||||
export type PurchaseRequestDetail = PurchaseRequestSummary & {
|
export type PurchaseRequestDetail = PurchaseRequestSummary & {
|
||||||
items: PurchaseRequestItem[];
|
items: PurchaseRequestItem[];
|
||||||
attachments: PurchaseAttachment[];
|
attachments: PurchaseAttachment[];
|
||||||
@@ -105,10 +126,12 @@ export type PurchaseRequestDetail = PurchaseRequestSummary & {
|
|||||||
assigneeRole: string | null;
|
assigneeRole: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
} | null;
|
} | null;
|
||||||
|
archiveOrder: PurchaseOrderArchive | null;
|
||||||
permissions: {
|
permissions: {
|
||||||
canEditDraft: boolean;
|
canEditDraft: boolean;
|
||||||
canSubmit: boolean;
|
canSubmit: boolean;
|
||||||
canWithdraw: boolean;
|
canWithdraw: boolean;
|
||||||
|
canArchiveOrder: boolean;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -197,6 +220,10 @@ export async function submitPurchaseRequest(purchaseRequestId: string) {
|
|||||||
currentTaskId: string;
|
currentTaskId: string;
|
||||||
budgetPoolId: string | null;
|
budgetPoolId: string | null;
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
|
budgetReservation: {
|
||||||
|
applied: boolean;
|
||||||
|
reservedAmount: string;
|
||||||
|
};
|
||||||
}>(`/api/purchase-requests/${purchaseRequestId}/submit`, {
|
}>(`/api/purchase-requests/${purchaseRequestId}/submit`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
@@ -208,12 +235,34 @@ export async function withdrawPurchaseRequest(purchaseRequestId: string) {
|
|||||||
purchaseRequestId: string;
|
purchaseRequestId: string;
|
||||||
status: "draft";
|
status: "draft";
|
||||||
workflowInstanceId: string | null;
|
workflowInstanceId: string | null;
|
||||||
|
budgetRelease: {
|
||||||
|
applied: boolean;
|
||||||
|
items: Array<{ budgetPoolId: string; amount: string }>;
|
||||||
|
};
|
||||||
}>(`/api/purchase-requests/${purchaseRequestId}/withdraw`, {
|
}>(`/api/purchase-requests/${purchaseRequestId}/withdraw`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchPurchaseOrderArchive(purchaseRequestId: string) {
|
||||||
|
return apiRequestData<{ archiveOrder: PurchaseOrderArchive | null }>(`/api/purchase-requests/${purchaseRequestId}/archive-order`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function archivePurchaseOrder(
|
||||||
|
purchaseRequestId: string,
|
||||||
|
payload: {
|
||||||
|
poNo: string;
|
||||||
|
contractNo?: string | null;
|
||||||
|
archivedFiles?: PurchaseArchivedFile[];
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return apiRequestData<{ archiveOrder: PurchaseOrderArchive }>(`/api/purchase-requests/${purchaseRequestId}/archive-order`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function uploadPurchaseAttachment(purchaseRequestId: string, file: File) {
|
export async function uploadPurchaseAttachment(purchaseRequestId: string, file: File) {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { ComponentPropsWithoutRef } from "react";
|
||||||
// 状态气泡组件
|
// 状态气泡组件
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
@@ -26,15 +27,15 @@ export function StatusBadge({
|
|||||||
status,
|
status,
|
||||||
label,
|
label,
|
||||||
className,
|
className,
|
||||||
}: {
|
...props
|
||||||
|
}: ComponentPropsWithoutRef<"span"> & {
|
||||||
status: string;
|
status: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
className?: string;
|
|
||||||
}) {
|
}) {
|
||||||
const config = STATUS_MAP[status] || { label: status, className: "badge-neutral" };
|
const config = STATUS_MAP[status] || { label: status, className: "badge-neutral" };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={cn(config.className, className)}>
|
<span className={cn(config.className, className)} {...props}>
|
||||||
{label ?? config.label}
|
{label ?? config.label}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import { toast } from "sonner";
|
|||||||
import { isApiError } from "../api/types";
|
import { isApiError } from "../api/types";
|
||||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||||
import {
|
import {
|
||||||
|
archivePurchaseOrder,
|
||||||
createPurchaseRequest,
|
createPurchaseRequest,
|
||||||
deletePurchaseAttachment,
|
deletePurchaseAttachment,
|
||||||
|
fetchPurchaseOrderArchive,
|
||||||
fetchPurchaseRequestDetail,
|
fetchPurchaseRequestDetail,
|
||||||
fetchPurchaseRequests,
|
fetchPurchaseRequests,
|
||||||
issuePurchaseAttachmentSignedUrl,
|
issuePurchaseAttachmentSignedUrl,
|
||||||
@@ -90,9 +92,10 @@ export function PurchaseRequestsPage() {
|
|||||||
const { permissions } = useAuth();
|
const { permissions } = useAuth();
|
||||||
|
|
||||||
const canCreate = hasPermission(permissions, "procurement.create");
|
const canCreate = hasPermission(permissions, "procurement.create");
|
||||||
|
const canManage = hasPermission(permissions, "procurement.manage");
|
||||||
const canViewAll =
|
const canViewAll =
|
||||||
hasPermission(permissions, "procurement.view_all") ||
|
hasPermission(permissions, "procurement.view_all") ||
|
||||||
hasPermission(permissions, "procurement.manage");
|
canManage;
|
||||||
|
|
||||||
const [status, setStatus] = useState<PurchaseRequestStatus | "">("draft");
|
const [status, setStatus] = useState<PurchaseRequestStatus | "">("draft");
|
||||||
const [mine, setMine] = useState(true);
|
const [mine, setMine] = useState(true);
|
||||||
@@ -112,6 +115,8 @@ export function PurchaseRequestsPage() {
|
|||||||
const [documentCategoryId, setDocumentCategoryId] = useState("");
|
const [documentCategoryId, setDocumentCategoryId] = useState("");
|
||||||
const [expectedDeliveryDate, setExpectedDeliveryDate] = useState("");
|
const [expectedDeliveryDate, setExpectedDeliveryDate] = useState("");
|
||||||
const [items, setItems] = useState<DraftItem[]>([]);
|
const [items, setItems] = useState<DraftItem[]>([]);
|
||||||
|
const [archivePoNo, setArchivePoNo] = useState("");
|
||||||
|
const [archiveContractNo, setArchiveContractNo] = useState("");
|
||||||
|
|
||||||
const suppliersQuery = useQuery({
|
const suppliersQuery = useQuery({
|
||||||
queryKey: ["purchase-requests", "suppliers"],
|
queryKey: ["purchase-requests", "suppliers"],
|
||||||
@@ -153,14 +158,27 @@ export function PurchaseRequestsPage() {
|
|||||||
queryFn: () => fetchPurchaseRequestDetail(selectedId),
|
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(() => {
|
useEffect(() => {
|
||||||
const currentIds = new Set((listQuery.data?.items ?? []).map((item) => item.id));
|
if (selectedId) {
|
||||||
if (selectedId && currentIds.has(selectedId)) {
|
if (detailQuery.isLoading) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!detailQuery.isError) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const nextId = listQuery.data?.items?.[0]?.id ?? "";
|
const nextId = listQuery.data?.items?.[0]?.id ?? "";
|
||||||
|
if (nextId !== selectedId) {
|
||||||
setSelectedId(nextId);
|
setSelectedId(nextId);
|
||||||
}, [listQuery.data?.items, selectedId]);
|
}
|
||||||
|
}, [detailQuery.isError, detailQuery.isLoading, listQuery.data?.items, selectedId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!detailQuery.data) {
|
if (!detailQuery.data) {
|
||||||
@@ -184,9 +202,20 @@ export function PurchaseRequestsPage() {
|
|||||||
isAssetCandidate: item.isAssetCandidate,
|
isAssetCandidate: item.isAssetCandidate,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
setArchivePoNo(detailQuery.data.archiveOrder?.poNo || "");
|
||||||
|
setArchiveContractNo(detailQuery.data.archiveOrder?.contractNo || "");
|
||||||
setFormError("");
|
setFormError("");
|
||||||
}, [detailQuery.data]);
|
}, [detailQuery.data]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!archiveOrderQuery.data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setArchivePoNo(archiveOrderQuery.data.archiveOrder?.poNo || "");
|
||||||
|
setArchiveContractNo(archiveOrderQuery.data.archiveOrder?.contractNo || "");
|
||||||
|
}, [archiveOrderQuery.data]);
|
||||||
|
|
||||||
const filteredCostCenters = useMemo(() => {
|
const filteredCostCenters = useMemo(() => {
|
||||||
if (!legalEntityId) {
|
if (!legalEntityId) {
|
||||||
return [];
|
return [];
|
||||||
@@ -334,6 +363,7 @@ export function PurchaseRequestsPage() {
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: ["purchase-requests"] }),
|
queryClient.invalidateQueries({ queryKey: ["purchase-requests"] }),
|
||||||
queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }),
|
queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "archive-order", selectedId] }),
|
||||||
]);
|
]);
|
||||||
setFormError("");
|
setFormError("");
|
||||||
toast.success("采购申请已撤回到草稿");
|
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({
|
const uploadMutation = useMutation({
|
||||||
mutationFn: async (file: File) => {
|
mutationFn: async (file: File) => {
|
||||||
if (!selectedId) {
|
if (!selectedId) {
|
||||||
@@ -396,9 +454,12 @@ export function PurchaseRequestsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const detail = detailQuery.data;
|
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 canEditDraft = Boolean(detail?.permissions.canEditDraft);
|
||||||
const canSubmit = Boolean(detail?.permissions.canSubmit);
|
const canSubmit = Boolean(detail?.permissions.canSubmit);
|
||||||
const canWithdraw = Boolean(detail?.permissions.canWithdraw);
|
const canWithdraw = Boolean(detail?.permissions.canWithdraw);
|
||||||
|
const canArchiveOrder = Boolean(detail?.permissions.canArchiveOrder && canManage);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -607,8 +668,9 @@ export function PurchaseRequestsPage() {
|
|||||||
<p className="text-sm text-zinc-500">申请人:{detail.applicant?.name ?? "-"}</p>
|
<p className="text-sm text-zinc-500">申请人:{detail.applicant?.name ?? "-"}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<StatusBadge status={detail.status} />
|
<StatusBadge data-testid="purchase-detail-status" status={detail.status} />
|
||||||
<Button
|
<Button
|
||||||
|
data-testid="purchase-save"
|
||||||
type="button"
|
type="button"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
disabled={!canEditDraft || saveMutation.isPending}
|
disabled={!canEditDraft || saveMutation.isPending}
|
||||||
@@ -617,6 +679,7 @@ export function PurchaseRequestsPage() {
|
|||||||
{saveMutation.isPending ? "保存中..." : "保存草稿"}
|
{saveMutation.isPending ? "保存中..." : "保存草稿"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
data-testid="purchase-submit"
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!canSubmit || submitMutation.isPending}
|
disabled={!canSubmit || submitMutation.isPending}
|
||||||
onClick={() => submitMutation.mutate()}
|
onClick={() => submitMutation.mutate()}
|
||||||
@@ -624,6 +687,7 @@ export function PurchaseRequestsPage() {
|
|||||||
{submitMutation.isPending ? "提交中..." : "提交审批"}
|
{submitMutation.isPending ? "提交中..." : "提交审批"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
data-testid="purchase-withdraw"
|
||||||
type="button"
|
type="button"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
disabled={!canWithdraw || withdrawMutation.isPending}
|
disabled={!canWithdraw || withdrawMutation.isPending}
|
||||||
@@ -637,7 +701,7 @@ export function PurchaseRequestsPage() {
|
|||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-zinc-700">标题</label>
|
<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>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-zinc-700">币种</label>
|
<label className="text-sm font-medium text-zinc-700">币种</label>
|
||||||
@@ -646,6 +710,7 @@ export function PurchaseRequestsPage() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-zinc-700">法体</label>
|
<label className="text-sm font-medium text-zinc-700">法体</label>
|
||||||
<select
|
<select
|
||||||
|
data-testid="purchase-legal-entity"
|
||||||
className="select-std"
|
className="select-std"
|
||||||
value={legalEntityId}
|
value={legalEntityId}
|
||||||
disabled={!canEditDraft}
|
disabled={!canEditDraft}
|
||||||
@@ -667,7 +732,7 @@ export function PurchaseRequestsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-zinc-700">成本中心</label>
|
<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>
|
<option value="">请选择成本中心</option>
|
||||||
{filteredCostCenters.map((item) => (
|
{filteredCostCenters.map((item) => (
|
||||||
<option key={item.id} value={item.id}>
|
<option key={item.id} value={item.id}>
|
||||||
@@ -678,7 +743,7 @@ export function PurchaseRequestsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-zinc-700">供应商</label>
|
<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>
|
<option value="">请选择供应商</option>
|
||||||
{(suppliersQuery.data?.items ?? []).map((item) => (
|
{(suppliersQuery.data?.items ?? []).map((item) => (
|
||||||
<option key={item.id} value={item.id}>
|
<option key={item.id} value={item.id}>
|
||||||
@@ -689,7 +754,7 @@ export function PurchaseRequestsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-zinc-700">单据分类</label>
|
<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>
|
<option value="">请选择单据分类</option>
|
||||||
{(categoriesQuery.data?.items ?? []).map((item) => (
|
{(categoriesQuery.data?.items ?? []).map((item) => (
|
||||||
<option key={item.id} value={item.id}>
|
<option key={item.id} value={item.id}>
|
||||||
@@ -700,7 +765,7 @@ export function PurchaseRequestsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-zinc-700">期望到货日期</label>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -718,7 +783,7 @@ export function PurchaseRequestsPage() {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h4 className="text-base font-semibold text-zinc-900">采购明细</h4>
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -741,6 +806,7 @@ export function PurchaseRequestsPage() {
|
|||||||
<TableRow key={item.id}>
|
<TableRow key={item.id}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Input
|
<Input
|
||||||
|
data-testid={`purchase-item-description-${item.id}`}
|
||||||
value={item.description}
|
value={item.description}
|
||||||
disabled={!canEditDraft}
|
disabled={!canEditDraft}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
@@ -767,6 +833,7 @@ export function PurchaseRequestsPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Input
|
<Input
|
||||||
|
data-testid={`purchase-item-quantity-${item.id}`}
|
||||||
value={item.quantity}
|
value={item.quantity}
|
||||||
disabled={!canEditDraft}
|
disabled={!canEditDraft}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
@@ -780,6 +847,7 @@ export function PurchaseRequestsPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Input
|
<Input
|
||||||
|
data-testid={`purchase-item-unit-price-${item.id}`}
|
||||||
value={item.unitPrice}
|
value={item.unitPrice}
|
||||||
disabled={!canEditDraft}
|
disabled={!canEditDraft}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
@@ -895,6 +963,70 @@ export function PurchaseRequestsPage() {
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
</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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -189,6 +189,7 @@ export function WorkflowTaskDetailPage() {
|
|||||||
{detail.purchaseRequest ? (
|
{detail.purchaseRequest ? (
|
||||||
<>
|
<>
|
||||||
<div>采购单号:{detail.purchaseRequest.requestNo ?? detail.purchaseRequest.id}</div>
|
<div>采购单号:{detail.purchaseRequest.requestNo ?? detail.purchaseRequest.id}</div>
|
||||||
|
<div>标题:{detail.purchaseRequest.title || "-"}</div>
|
||||||
<div>申请人:{detail.purchaseRequest.applicant?.name ?? "-"}</div>
|
<div>申请人:{detail.purchaseRequest.applicant?.name ?? "-"}</div>
|
||||||
<div>供应商:{detail.purchaseRequest.supplier?.name ?? "-"}</div>
|
<div>供应商:{detail.purchaseRequest.supplier?.name ?? "-"}</div>
|
||||||
<div>法体:{detail.purchaseRequest.legalEntity?.name ?? "-"}</div>
|
<div>法体:{detail.purchaseRequest.legalEntity?.name ?? "-"}</div>
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { expect, test, type Locator, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
const EMPLOYEE = { identifier: "[email protected]", password: "EmployeePassword1234", name: "Employee" };
|
||||||
|
const MANAGER = { identifier: "[email protected]", password: "ManagerPassword1234", name: "Manager" };
|
||||||
|
const FINANCE_MGR = { identifier: "[email protected]", password: "FinanceMgrPassword1234", name: "Finance Manager" };
|
||||||
|
|
||||||
|
async function login(page: Page, user: { identifier: string; password: string }) {
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.getByTestId("login-identifier").fill(user.identifier);
|
||||||
|
await page.getByTestId("login-password").fill(user.password);
|
||||||
|
await page.getByTestId("login-submit").click();
|
||||||
|
await expect(page).toHaveURL(/\/dashboard/);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout(page: Page, userDisplayName: string) {
|
||||||
|
await page.getByRole("button", { name: userDisplayName }).click();
|
||||||
|
await page.getByRole("menuitem", { name: "退出登录" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectFirstBusinessOption(locator: Locator) {
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const optionValues = await locator.locator("option").evaluateAll((options) =>
|
||||||
|
options
|
||||||
|
.map((option) => ({ value: (option as HTMLOptionElement).value, disabled: (option as HTMLOptionElement).disabled }))
|
||||||
|
.filter((option) => option.value && !option.disabled)
|
||||||
|
.map((option) => option.value),
|
||||||
|
);
|
||||||
|
return optionValues[0] ?? "";
|
||||||
|
})
|
||||||
|
.not.toBe("");
|
||||||
|
|
||||||
|
const optionValues = await locator.locator("option").evaluateAll((options) =>
|
||||||
|
options
|
||||||
|
.map((option) => ({ value: (option as HTMLOptionElement).value, disabled: (option as HTMLOptionElement).disabled }))
|
||||||
|
.filter((option) => option.value && !option.disabled)
|
||||||
|
.map((option) => option.value),
|
||||||
|
);
|
||||||
|
|
||||||
|
await locator.selectOption(optionValues[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("E2E 采购主链路:员工提单→经理审批→财务归档", async ({ page }) => {
|
||||||
|
const title = `Playwright 采购 ${Date.now()}`;
|
||||||
|
|
||||||
|
await login(page, EMPLOYEE);
|
||||||
|
await page.getByRole("link", { name: "采购申请" }).click();
|
||||||
|
await page.getByTestId("purchase-requests-create").click();
|
||||||
|
|
||||||
|
await expect(page.getByTestId("purchase-title")).toBeVisible();
|
||||||
|
await page.getByTestId("purchase-title").fill(title);
|
||||||
|
await selectFirstBusinessOption(page.getByTestId("purchase-legal-entity"));
|
||||||
|
await selectFirstBusinessOption(page.getByTestId("purchase-cost-center"));
|
||||||
|
await selectFirstBusinessOption(page.getByTestId("purchase-supplier"));
|
||||||
|
await selectFirstBusinessOption(page.getByTestId("purchase-document-category"));
|
||||||
|
await page.getByTestId("purchase-add-item").click();
|
||||||
|
|
||||||
|
await page.locator('[data-testid^="purchase-item-description-"]').first().fill("Playwright Laptop");
|
||||||
|
await page.locator('[data-testid^="purchase-item-quantity-"]').first().fill("2");
|
||||||
|
await page.locator('[data-testid^="purchase-item-unit-price-"]').first().fill("100");
|
||||||
|
|
||||||
|
const submitResponsePromise = page.waitForResponse(
|
||||||
|
(response) => response.request().method() === "POST" && /\/api\/purchase-requests\/[^/]+\/submit$/.test(new URL(response.url()).pathname),
|
||||||
|
);
|
||||||
|
await page.getByTestId("purchase-submit").click();
|
||||||
|
const submitResponse = await submitResponsePromise;
|
||||||
|
const submitPayload = (await submitResponse.json()) as {
|
||||||
|
data: { currentTaskId: string };
|
||||||
|
};
|
||||||
|
const currentTaskId = submitPayload.data.currentTaskId;
|
||||||
|
await expect(page.getByTestId("purchase-detail-status")).toHaveText("审批中", { timeout: 30_000 });
|
||||||
|
|
||||||
|
await logout(page, EMPLOYEE.name);
|
||||||
|
|
||||||
|
await login(page, MANAGER);
|
||||||
|
await page.getByRole("link", { name: "待我处理" }).click();
|
||||||
|
const managerTaskRow = page.getByTestId("workflow-task-row").filter({ hasText: currentTaskId }).first();
|
||||||
|
await expect(managerTaskRow).toBeVisible({ timeout: 30_000 });
|
||||||
|
await managerTaskRow.getByTestId("workflow-task-detail-link").click();
|
||||||
|
await expect(page.getByTestId("workflow-task-summary")).toContainText(title);
|
||||||
|
await page.getByTestId("workflow-task-approve").click();
|
||||||
|
await expect(page.getByTestId("workflow-task-summary")).toContainText("状态:已完成");
|
||||||
|
|
||||||
|
await logout(page, MANAGER.name);
|
||||||
|
|
||||||
|
await login(page, FINANCE_MGR);
|
||||||
|
await page.getByRole("link", { name: "采购申请" }).click();
|
||||||
|
await page.getByRole("button", { name: "全范围" }).click();
|
||||||
|
await page.getByRole("combobox").first().selectOption("approved");
|
||||||
|
const purchaseRow = page.getByRole("row").filter({ hasText: title }).first();
|
||||||
|
await expect(purchaseRow).toBeVisible({ timeout: 30_000 });
|
||||||
|
await purchaseRow.click();
|
||||||
|
|
||||||
|
await expect(page.getByTestId("purchase-archive-po-no")).toBeVisible();
|
||||||
|
await page.getByTestId("purchase-archive-po-no").fill("PO-E2E-001");
|
||||||
|
await page.getByTestId("purchase-archive-contract-no").fill("CT-E2E-001");
|
||||||
|
await page.getByTestId("purchase-archive-save").click();
|
||||||
|
|
||||||
|
await expect(page.getByTestId("purchase-archive-po-no")).toHaveValue("PO-E2E-001");
|
||||||
|
await expect(page.getByTestId("purchase-archive-contract-no")).toHaveValue("CT-E2E-001");
|
||||||
|
|
||||||
|
await logout(page, FINANCE_MGR.name);
|
||||||
|
});
|
||||||
在新工单中引用
屏蔽一个用户