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

这个提交包含在:
2026-03-07 22:01:06 +08:00
父节点 033cfd6833
当前提交 408186644c
共修改 5 个文件,包含 303 行新增和 16 行删除
+49
查看文件
@@ -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);
+4 -3
查看文件
@@ -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>
); );
+143 -11
查看文件
@@ -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>
+104
查看文件
@@ -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);
});