From 7f1c3e842d75f19ecbbec93cbc1a99c71daa6d24 Mon Sep 17 00:00:00 2001 From: shunli Date: Sat, 7 Mar 2026 20:59:42 +0800 Subject: [PATCH] =?UTF-8?q?feat(P17A-3,P17A-4):=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E9=87=87=E8=B4=AD=E7=94=B3=E8=AF=B7=E6=8E=A5=E5=8F=A3=E9=99=84?= =?UTF-8?q?=E4=BB=B6=E4=B8=8E=E5=89=8D=E7=AB=AF=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/api/purchaseRequests.ts | 243 +++ client/src/api/workflow.ts | 35 + client/src/components/navigation/NavLinks.tsx | 10 +- client/src/pages/MobileApprovalsPage.tsx | 17 +- client/src/pages/PurchaseRequestsPage.tsx | 904 +++++++++ client/src/pages/WorkflowTaskDetailPage.tsx | 18 +- client/src/pages/WorkflowTasksPage.tsx | 5 +- client/src/router/index.tsx | 16 +- server/src/app.js | 2 + server/src/routes/purchaseRequests.js | 1725 +++++++++++++++++ server/src/routes/workflow.js | 121 ++ .../unit/routes/workflow.permissions.test.js | 13 + 12 files changed, 3100 insertions(+), 9 deletions(-) create mode 100644 client/src/api/purchaseRequests.ts create mode 100644 client/src/pages/PurchaseRequestsPage.tsx create mode 100644 server/src/routes/purchaseRequests.js diff --git a/client/src/api/purchaseRequests.ts b/client/src/api/purchaseRequests.ts new file mode 100644 index 00000000..789b6251 --- /dev/null +++ b/client/src/api/purchaseRequests.ts @@ -0,0 +1,243 @@ +import { apiRequestData } from "./http"; + +export type PurchaseRequestStatus = "draft" | "reviewing" | "approved" | "rejected" | "cancelled"; + +export type PurchaseRequestSummary = { + id: string; + requestNo: string | null; + applicantId: string; + submitterId: string | null; + departmentId: string; + legalEntityId: string | null; + costCenterId: string | null; + supplierId: string | null; + documentCategoryId: string | null; + budgetPoolId: string | null; + title: string; + reason: string | null; + currencyCode: string; + totalAmount: string; + expectedDeliveryDate: string | null; + status: PurchaseRequestStatus; + createdAt: string | null; + updatedAt: string | null; + applicant: { + id: string; + employeeNo: string; + name: string; + } | null; + submitter: { + id: string; + employeeNo: string; + name: string; + } | null; + department: { + id: string; + name: string; + } | null; + legalEntity: { + id: string; + code: string; + name: string; + } | null; + costCenter: { + id: string; + code: string; + name: string; + legalEntityId: string; + } | null; + supplier: { + id: string; + code: string; + name: string; + } | null; + documentCategory: { + id: string; + code: string; + name: string; + entityType: string; + } | null; + budgetPool: { + id: string; + code: string; + name: string; + } | null; +}; + +export type PurchaseRequestItem = { + id: string; + description: string; + specification: string | null; + quantity: string; + unitPrice: string; + lineAmount: string; + isAssetCandidate: boolean; + createdAt: string | null; + updatedAt: string | null; +}; + +export type PurchaseAttachment = { + id: string; + originalName: string; + mimeType: string; + fileSize: number; + uploadedBy: string; + createdAt: string | null; + uploader: { + id: string; + employeeNo: string; + name: string; + } | null; +}; + +export type PurchaseRequestDetail = PurchaseRequestSummary & { + items: PurchaseRequestItem[]; + attachments: PurchaseAttachment[]; + pendingWorkflow: { + instanceId: string; + currentStatus: string; + currentStepId: string; + task: { + id: string; + stepId: string; + stepName: string; + assigneeId: string | null; + assigneeRole: string | null; + } | null; + } | null; + permissions: { + canEditDraft: boolean; + canSubmit: boolean; + canWithdraw: boolean; + }; +}; + +export type PurchaseRequestListResponse = { + page: number; + pageSize: number; + total: number; + mine: boolean; + items: PurchaseRequestSummary[]; +}; + +export type PurchaseRequestListQuery = { + status?: PurchaseRequestStatus; + page?: number; + pageSize?: number; + mine?: boolean; + supplierId?: string; + legalEntityId?: string; + costCenterId?: string; +}; + +export type PurchaseRequestItemPayload = { + description: string; + specification?: string | null; + quantity: string; + unitPrice: string; + isAssetCandidate?: boolean; +}; + +function toQueryString(query: Record) { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) { + if (value == null || value === "") { + continue; + } + params.set(key, typeof value === "boolean" ? (value ? "1" : "0") : String(value)); + } + const encoded = params.toString(); + return encoded ? `?${encoded}` : ""; +} + +export async function fetchPurchaseRequests(query: PurchaseRequestListQuery = {}) { + return apiRequestData( + `/api/purchase-requests${toQueryString(query as Record)}`, + ); +} + +export async function createPurchaseRequest() { + return apiRequestData<{ purchaseRequest: PurchaseRequestDetail }>("/api/purchase-requests", { + method: "POST", + body: JSON.stringify({}), + }); +} + +export async function fetchPurchaseRequestDetail(purchaseRequestId: string) { + return apiRequestData(`/api/purchase-requests/${purchaseRequestId}`); +} + +export async function updatePurchaseRequest( + purchaseRequestId: string, + payload: Partial<{ + title: string; + reason: string | null; + currencyCode: string; + legalEntityId: string | null; + costCenterId: string | null; + supplierId: string | null; + documentCategoryId: string | null; + budgetPoolId: string | null; + expectedDeliveryDate: string | null; + items: PurchaseRequestItemPayload[]; + }>, +) { + return apiRequestData<{ purchaseRequest: PurchaseRequestDetail }>(`/api/purchase-requests/${purchaseRequestId}`, { + method: "PATCH", + body: JSON.stringify(payload), + }); +} + +export async function submitPurchaseRequest(purchaseRequestId: string) { + return apiRequestData<{ + purchaseRequestId: string; + requestNo: string; + status: "reviewing"; + workflowInstanceId: string; + currentTaskId: string; + budgetPoolId: string | null; + warnings: string[]; + }>(`/api/purchase-requests/${purchaseRequestId}/submit`, { + method: "POST", + body: JSON.stringify({}), + }); +} + +export async function withdrawPurchaseRequest(purchaseRequestId: string) { + return apiRequestData<{ + purchaseRequestId: string; + status: "draft"; + workflowInstanceId: string | null; + }>(`/api/purchase-requests/${purchaseRequestId}/withdraw`, { + method: "POST", + body: JSON.stringify({}), + }); +} + +export async function uploadPurchaseAttachment(purchaseRequestId: string, file: File) { + const formData = new FormData(); + formData.append("file", file); + return apiRequestData<{ attachment: PurchaseAttachment }>(`/api/purchase-requests/${purchaseRequestId}/attachments`, { + method: "POST", + body: formData, + }); +} + +export async function deletePurchaseAttachment(purchaseRequestId: string, attachmentId: string) { + return apiRequestData<{ outcome: "deleted"; attachmentId: string }>( + `/api/purchase-requests/${purchaseRequestId}/attachments/${attachmentId}`, + { + method: "DELETE", + }, + ); +} + +export async function issuePurchaseAttachmentSignedUrl(purchaseRequestId: string, attachmentId: string) { + return apiRequestData<{ signedUrl: string; expiresAt: string }>( + `/api/purchase-requests/${purchaseRequestId}/attachments/${attachmentId}/signed-url`, + { + method: "POST", + body: JSON.stringify({}), + }, + ); +} diff --git a/client/src/api/workflow.ts b/client/src/api/workflow.ts index be41f413..07059f9f 100644 --- a/client/src/api/workflow.ts +++ b/client/src/api/workflow.ts @@ -101,6 +101,41 @@ export type WorkflowTaskDetail = { name: string; } | null; } | null; + purchaseRequest: { + id: string; + requestNo: string | null; + status: string; + title: string; + reason: string | null; + currencyCode: string; + totalAmount: string | null; + expectedDeliveryDate: string | null; + applicant: { + id: string; + employeeNo: string; + name: string; + } | null; + supplier: { + id: string; + code: string; + name: string; + } | null; + legalEntity: { + id: string; + code: string; + name: string; + } | null; + costCenter: { + id: string; + code: string; + name: string; + } | null; + documentCategory: { + id: string; + code: string; + name: string; + } | null; + } | null; allowedActions: Array<"approve" | "reject" | "return" | "transfer">; history: WorkflowHistoryItem[]; }; diff --git a/client/src/components/navigation/NavLinks.tsx b/client/src/components/navigation/NavLinks.tsx index 3173ae1a..b7ad613b 100644 --- a/client/src/components/navigation/NavLinks.tsx +++ b/client/src/components/navigation/NavLinks.tsx @@ -24,11 +24,17 @@ export const NAV_ITEMS: NavItem[] = [ icon: "event_available", anyOf: ["leave.view_own", "leave.view_all"], }, + { + to: "/purchase-requests", + label: "采购申请", + icon: "shopping_cart", + anyOf: ["procurement.create", "procurement.view_own", "procurement.view_all", "procurement.manage"], + }, { to: "/workflow/tasks", label: "待我处理", icon: "fact_check", - anyOf: ["expense.approve", "leave.approve"], + anyOf: ["expense.approve", "leave.approve", "procurement.approve"], }, { to: "/invoice-pool", @@ -46,7 +52,7 @@ export const NAV_ITEMS: NavItem[] = [ to: "/mobile/approvals", label: "移动审批", icon: "mobile_friendly", - anyOf: ["expense.approve", "leave.approve"], + anyOf: ["expense.approve", "leave.approve", "procurement.approve"], }, { to: "/budgets", diff --git a/client/src/pages/MobileApprovalsPage.tsx b/client/src/pages/MobileApprovalsPage.tsx index a10bd268..3bbbffbd 100644 --- a/client/src/pages/MobileApprovalsPage.tsx +++ b/client/src/pages/MobileApprovalsPage.tsx @@ -26,6 +26,9 @@ function entityLabel(entityType: string | undefined) { if (entityType === "reimbursement") { return "报销"; } + if (entityType === "purchase_request") { + return "采购"; + } return entityType || "未知"; } @@ -107,7 +110,9 @@ export function MobileApprovalsPage() { const pendingTasks = tasksQuery.data?.items ?? []; const selectedTask = detailQuery.data; - const isLeaveTask = selectedTask?.workflowInstance?.entityType === "leave"; + const canTransfer = + selectedTask?.workflowInstance?.entityType === "reimbursement" && + selectedTask.allowedActions.includes("transfer"); return (
@@ -176,6 +181,14 @@ export function MobileApprovalsPage() {
请假天数:{selectedTask.leaveRequest.requestedDays}
) : null} + {selectedTask.purchaseRequest ? ( + <> +
采购单号:{selectedTask.purchaseRequest.requestNo ?? selectedTask.purchaseRequest.id}
+
申请人:{selectedTask.purchaseRequest.applicant?.name ?? "-"}
+
供应商:{selectedTask.purchaseRequest.supplier?.name ?? "-"}
+
金额:{selectedTask.purchaseRequest.totalAmount ?? "-"}
+ + ) : null}