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;
};
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 & {
items: PurchaseRequestItem[];
attachments: PurchaseAttachment[];
@@ -105,10 +126,12 @@ export type PurchaseRequestDetail = PurchaseRequestSummary & {
assigneeRole: string | null;
} | null;
} | null;
archiveOrder: PurchaseOrderArchive | null;
permissions: {
canEditDraft: boolean;
canSubmit: boolean;
canWithdraw: boolean;
canArchiveOrder: boolean;
};
};
@@ -197,6 +220,10 @@ export async function submitPurchaseRequest(purchaseRequestId: string) {
currentTaskId: string;
budgetPoolId: string | null;
warnings: string[];
budgetReservation: {
applied: boolean;
reservedAmount: string;
};
}>(`/api/purchase-requests/${purchaseRequestId}/submit`, {
method: "POST",
body: JSON.stringify({}),
@@ -208,12 +235,34 @@ export async function withdrawPurchaseRequest(purchaseRequestId: string) {
purchaseRequestId: string;
status: "draft";
workflowInstanceId: string | null;
budgetRelease: {
applied: boolean;
items: Array<{ budgetPoolId: string; amount: string }>;
};
}>(`/api/purchase-requests/${purchaseRequestId}/withdraw`, {
method: "POST",
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) {
const formData = new FormData();
formData.append("file", file);
+4 -3
查看文件
@@ -1,3 +1,4 @@
import type { ComponentPropsWithoutRef } from "react";
// 状态气泡组件
import { cn } from "../../lib/utils";
@@ -26,15 +27,15 @@ export function StatusBadge({
status,
label,
className,
}: {
...props
}: ComponentPropsWithoutRef<"span"> & {
status: string;
label?: string;
className?: string;
}) {
const config = STATUS_MAP[status] || { label: status, className: "badge-neutral" };
return (
<span className={cn(config.className, className)}>
<span className={cn(config.className, className)} {...props}>
{label ?? config.label}
</span>
);
+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>
+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);
});