diff --git a/client/src/api/purchaseRequests.ts b/client/src/api/purchaseRequests.ts index 789b6251..1cb85901 100644 --- a/client/src/api/purchaseRequests.ts +++ b/client/src/api/purchaseRequests.ts @@ -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); diff --git a/client/src/components/common/StatusBadge.tsx b/client/src/components/common/StatusBadge.tsx index d30b42ce..899a7f60 100644 --- a/client/src/components/common/StatusBadge.tsx +++ b/client/src/components/common/StatusBadge.tsx @@ -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 ( - + {label ?? config.label} ); diff --git a/client/src/pages/PurchaseRequestsPage.tsx b/client/src/pages/PurchaseRequestsPage.tsx index 47904045..a3cbcd86 100644 --- a/client/src/pages/PurchaseRequestsPage.tsx +++ b/client/src/pages/PurchaseRequestsPage.tsx @@ -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("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([]); + 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 (
@@ -607,8 +668,9 @@ export function PurchaseRequestsPage() {

申请人:{detail.applicant?.name ?? "-"}

- +
@@ -741,6 +806,7 @@ export function PurchaseRequestsPage() { @@ -767,6 +833,7 @@ export function PurchaseRequestsPage() { @@ -780,6 +847,7 @@ export function PurchaseRequestsPage() { @@ -895,6 +963,70 @@ export function PurchaseRequestsPage() { + + {showArchiveSection ? ( +
+
+
+

PO/合同归档

+

仅已通过采购单可归档,当前阶段保留编号与文件元数据锚点。

+
+ {archiveOrder ? ( +
+
归档时间:{archiveOrder.archivedAt ? new Date(archiveOrder.archivedAt).toLocaleString() : "-"}
+
归档人:{archiveOrder.archivedByEmployee?.name ?? "-"}
+
+ ) : null} +
+ +
+
+ + setArchivePoNo(event.target.value)} + placeholder="请输入 PO 号" + /> +
+
+ + setArchiveContractNo(event.target.value)} + placeholder="可选" + /> +
+
+ +
+ 已记录归档文件:{archiveOrder?.archivedFiles.length ?? 0} 个 + +
+ + {archiveOrder?.archivedFiles.length ? ( +
+ {archiveOrder.archivedFiles.map((file, index) => ( +
+
{file.originalName ?? `归档文件 ${index + 1}`}
+
{file.mimeType ?? "application/octet-stream"} / {file.fileSize} bytes
+
+ ))} +
+ ) : null} +
+ ) : null} ) : null} diff --git a/client/src/pages/WorkflowTaskDetailPage.tsx b/client/src/pages/WorkflowTaskDetailPage.tsx index 34b9bbce..e18dbe21 100644 --- a/client/src/pages/WorkflowTaskDetailPage.tsx +++ b/client/src/pages/WorkflowTaskDetailPage.tsx @@ -189,6 +189,7 @@ export function WorkflowTaskDetailPage() { {detail.purchaseRequest ? ( <>
采购单号:{detail.purchaseRequest.requestNo ?? detail.purchaseRequest.id}
+
标题:{detail.purchaseRequest.title || "-"}
申请人:{detail.purchaseRequest.applicant?.name ?? "-"}
供应商:{detail.purchaseRequest.supplier?.name ?? "-"}
法体:{detail.purchaseRequest.legalEntity?.name ?? "-"}
diff --git a/client/tests/e2e/procurement-flow.spec.ts b/client/tests/e2e/procurement-flow.spec.ts new file mode 100644 index 00000000..14e2a558 --- /dev/null +++ b/client/tests/e2e/procurement-flow.spec.ts @@ -0,0 +1,104 @@ +import { expect, test, type Locator, type Page } from "@playwright/test"; + +const EMPLOYEE = { identifier: "employee@hyinda.com", password: "EmployeePassword1234", name: "Employee" }; +const MANAGER = { identifier: "manager@hyinda.com", password: "ManagerPassword1234", name: "Manager" }; +const FINANCE_MGR = { identifier: "finance_mgr@hyinda.com", 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); +});