feat(P17A-3,P17A-4): 实现采购申请接口附件与前端入口
这个提交包含在:
@@ -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<string, string | number | boolean | undefined>) {
|
||||||
|
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<PurchaseRequestListResponse>(
|
||||||
|
`/api/purchase-requests${toQueryString(query as Record<string, string | number | boolean | undefined>)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPurchaseRequest() {
|
||||||
|
return apiRequestData<{ purchaseRequest: PurchaseRequestDetail }>("/api/purchase-requests", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPurchaseRequestDetail(purchaseRequestId: string) {
|
||||||
|
return apiRequestData<PurchaseRequestDetail>(`/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({}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -101,6 +101,41 @@ export type WorkflowTaskDetail = {
|
|||||||
name: string;
|
name: string;
|
||||||
} | null;
|
} | null;
|
||||||
} | 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">;
|
allowedActions: Array<"approve" | "reject" | "return" | "transfer">;
|
||||||
history: WorkflowHistoryItem[];
|
history: WorkflowHistoryItem[];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,11 +24,17 @@ export const NAV_ITEMS: NavItem[] = [
|
|||||||
icon: "event_available",
|
icon: "event_available",
|
||||||
anyOf: ["leave.view_own", "leave.view_all"],
|
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",
|
to: "/workflow/tasks",
|
||||||
label: "待我处理",
|
label: "待我处理",
|
||||||
icon: "fact_check",
|
icon: "fact_check",
|
||||||
anyOf: ["expense.approve", "leave.approve"],
|
anyOf: ["expense.approve", "leave.approve", "procurement.approve"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: "/invoice-pool",
|
to: "/invoice-pool",
|
||||||
@@ -46,7 +52,7 @@ export const NAV_ITEMS: NavItem[] = [
|
|||||||
to: "/mobile/approvals",
|
to: "/mobile/approvals",
|
||||||
label: "移动审批",
|
label: "移动审批",
|
||||||
icon: "mobile_friendly",
|
icon: "mobile_friendly",
|
||||||
anyOf: ["expense.approve", "leave.approve"],
|
anyOf: ["expense.approve", "leave.approve", "procurement.approve"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: "/budgets",
|
to: "/budgets",
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ function entityLabel(entityType: string | undefined) {
|
|||||||
if (entityType === "reimbursement") {
|
if (entityType === "reimbursement") {
|
||||||
return "报销";
|
return "报销";
|
||||||
}
|
}
|
||||||
|
if (entityType === "purchase_request") {
|
||||||
|
return "采购";
|
||||||
|
}
|
||||||
return entityType || "未知";
|
return entityType || "未知";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,7 +110,9 @@ export function MobileApprovalsPage() {
|
|||||||
|
|
||||||
const pendingTasks = tasksQuery.data?.items ?? [];
|
const pendingTasks = tasksQuery.data?.items ?? [];
|
||||||
const selectedTask = detailQuery.data;
|
const selectedTask = detailQuery.data;
|
||||||
const isLeaveTask = selectedTask?.workflowInstance?.entityType === "leave";
|
const canTransfer =
|
||||||
|
selectedTask?.workflowInstance?.entityType === "reimbursement" &&
|
||||||
|
selectedTask.allowedActions.includes("transfer");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -176,6 +181,14 @@ export function MobileApprovalsPage() {
|
|||||||
<div>请假天数:{selectedTask.leaveRequest.requestedDays}</div>
|
<div>请假天数:{selectedTask.leaveRequest.requestedDays}</div>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
{selectedTask.purchaseRequest ? (
|
||||||
|
<>
|
||||||
|
<div>采购单号:{selectedTask.purchaseRequest.requestNo ?? selectedTask.purchaseRequest.id}</div>
|
||||||
|
<div>申请人:{selectedTask.purchaseRequest.applicant?.name ?? "-"}</div>
|
||||||
|
<div>供应商:{selectedTask.purchaseRequest.supplier?.name ?? "-"}</div>
|
||||||
|
<div>金额:{selectedTask.purchaseRequest.totalAmount ?? "-"}</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Textarea
|
<Textarea
|
||||||
@@ -215,7 +228,7 @@ export function MobileApprovalsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isLeaveTask && selectedTask.allowedActions.includes("transfer") ? (
|
{canTransfer ? (
|
||||||
<div className="space-y-2 rounded-lg border border-zinc-200 p-3 text-sm">
|
<div className="space-y-2 rounded-lg border border-zinc-200 p-3 text-sm">
|
||||||
<p className="font-medium text-zinc-800">转审</p>
|
<p className="font-medium text-zinc-800">转审</p>
|
||||||
<select
|
<select
|
||||||
|
|||||||
@@ -0,0 +1,904 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { isApiError } from "../api/types";
|
||||||
|
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||||
|
import {
|
||||||
|
createPurchaseRequest,
|
||||||
|
deletePurchaseAttachment,
|
||||||
|
fetchPurchaseRequestDetail,
|
||||||
|
fetchPurchaseRequests,
|
||||||
|
issuePurchaseAttachmentSignedUrl,
|
||||||
|
submitPurchaseRequest,
|
||||||
|
updatePurchaseRequest,
|
||||||
|
uploadPurchaseAttachment,
|
||||||
|
withdrawPurchaseRequest,
|
||||||
|
type PurchaseRequestItemPayload,
|
||||||
|
type PurchaseRequestStatus,
|
||||||
|
} from "../api/purchaseRequests";
|
||||||
|
import {
|
||||||
|
fetchCostCenters,
|
||||||
|
fetchDocumentCategories,
|
||||||
|
fetchLegalEntities,
|
||||||
|
fetchSuppliers,
|
||||||
|
} from "../api/masterData";
|
||||||
|
import { useAuth } from "../auth/AuthContext";
|
||||||
|
import { hasPermission } from "../auth/permissions";
|
||||||
|
import { PageHeader } from "../components/common/PageHeader";
|
||||||
|
import { Pagination } from "../components/common/Pagination";
|
||||||
|
import { StatusBadge } from "../components/common/StatusBadge";
|
||||||
|
import { TableSkeleton } from "../components/common/TableSkeleton";
|
||||||
|
import { Button } from "../components/ui/button";
|
||||||
|
import { Input } from "../components/ui/input";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||||
|
import { Textarea } from "../components/ui/textarea";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
type DraftItem = {
|
||||||
|
id: string;
|
||||||
|
description: string;
|
||||||
|
specification: string;
|
||||||
|
quantity: string;
|
||||||
|
unitPrice: string;
|
||||||
|
isAssetCandidate: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveError(error: unknown) {
|
||||||
|
if (isApiError(error)) {
|
||||||
|
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
||||||
|
}
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
return "请求失败,请稍后重试。";
|
||||||
|
}
|
||||||
|
|
||||||
|
function moneyPreview(quantity: string, unitPrice: string) {
|
||||||
|
const quantityNumber = Number(quantity);
|
||||||
|
const unitPriceNumber = Number(unitPrice);
|
||||||
|
if (!Number.isFinite(quantityNumber) || !Number.isFinite(unitPriceNumber)) {
|
||||||
|
return "0.00";
|
||||||
|
}
|
||||||
|
return (quantityNumber * unitPriceNumber).toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyDraftItem(): DraftItem {
|
||||||
|
return {
|
||||||
|
id: `draft-${crypto.randomUUID()}`,
|
||||||
|
description: "",
|
||||||
|
specification: "",
|
||||||
|
quantity: "1.00",
|
||||||
|
unitPrice: "0.00",
|
||||||
|
isAssetCandidate: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusOptions(): Array<{ value: PurchaseRequestStatus | ""; label: string }> {
|
||||||
|
return [
|
||||||
|
{ value: "", label: "全部状态" },
|
||||||
|
{ value: "draft", label: "草稿" },
|
||||||
|
{ value: "reviewing", label: "审批中" },
|
||||||
|
{ value: "approved", label: "已通过" },
|
||||||
|
{ value: "rejected", label: "已驳回" },
|
||||||
|
{ value: "cancelled", label: "已取消" },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PurchaseRequestsPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { permissions } = useAuth();
|
||||||
|
|
||||||
|
const canCreate = hasPermission(permissions, "procurement.create");
|
||||||
|
const canViewAll =
|
||||||
|
hasPermission(permissions, "procurement.view_all") ||
|
||||||
|
hasPermission(permissions, "procurement.manage");
|
||||||
|
|
||||||
|
const [status, setStatus] = useState<PurchaseRequestStatus | "">("draft");
|
||||||
|
const [mine, setMine] = useState(true);
|
||||||
|
const [supplierFilter, setSupplierFilter] = useState("");
|
||||||
|
const [legalEntityFilter, setLegalEntityFilter] = useState("");
|
||||||
|
const [costCenterFilter, setCostCenterFilter] = useState("");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [selectedId, setSelectedId] = useState("");
|
||||||
|
const [formError, setFormError] = useState("");
|
||||||
|
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [reason, setReason] = useState("");
|
||||||
|
const [currencyCode, setCurrencyCode] = useState("CNY");
|
||||||
|
const [legalEntityId, setLegalEntityId] = useState("");
|
||||||
|
const [costCenterId, setCostCenterId] = useState("");
|
||||||
|
const [supplierId, setSupplierId] = useState("");
|
||||||
|
const [documentCategoryId, setDocumentCategoryId] = useState("");
|
||||||
|
const [expectedDeliveryDate, setExpectedDeliveryDate] = useState("");
|
||||||
|
const [items, setItems] = useState<DraftItem[]>([]);
|
||||||
|
|
||||||
|
const suppliersQuery = useQuery({
|
||||||
|
queryKey: ["purchase-requests", "suppliers"],
|
||||||
|
queryFn: () => fetchSuppliers({ enabled: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const legalEntitiesQuery = useQuery({
|
||||||
|
queryKey: ["purchase-requests", "legal-entities"],
|
||||||
|
queryFn: () => fetchLegalEntities({ enabled: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const costCentersQuery = useQuery({
|
||||||
|
queryKey: ["purchase-requests", "cost-centers"],
|
||||||
|
queryFn: () => fetchCostCenters({ enabled: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const categoriesQuery = useQuery({
|
||||||
|
queryKey: ["purchase-requests", "document-categories"],
|
||||||
|
queryFn: () => fetchDocumentCategories({ entityType: "purchase_request", enabled: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const listQuery = useQuery({
|
||||||
|
queryKey: ["purchase-requests", "list", status, mine, supplierFilter, legalEntityFilter, costCenterFilter, page],
|
||||||
|
queryFn: () =>
|
||||||
|
fetchPurchaseRequests({
|
||||||
|
status: status || undefined,
|
||||||
|
mine: canViewAll ? mine : true,
|
||||||
|
supplierId: supplierFilter || undefined,
|
||||||
|
legalEntityId: legalEntityFilter || undefined,
|
||||||
|
costCenterId: costCenterFilter || undefined,
|
||||||
|
page,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const detailQuery = useQuery({
|
||||||
|
enabled: Boolean(selectedId),
|
||||||
|
queryKey: ["purchase-requests", "detail", selectedId],
|
||||||
|
queryFn: () => fetchPurchaseRequestDetail(selectedId),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const currentIds = new Set((listQuery.data?.items ?? []).map((item) => item.id));
|
||||||
|
if (selectedId && currentIds.has(selectedId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextId = listQuery.data?.items?.[0]?.id ?? "";
|
||||||
|
setSelectedId(nextId);
|
||||||
|
}, [listQuery.data?.items, selectedId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!detailQuery.data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTitle(detailQuery.data.title || "");
|
||||||
|
setReason(detailQuery.data.reason || "");
|
||||||
|
setCurrencyCode(detailQuery.data.currencyCode || "CNY");
|
||||||
|
setLegalEntityId(detailQuery.data.legalEntityId || "");
|
||||||
|
setCostCenterId(detailQuery.data.costCenterId || "");
|
||||||
|
setSupplierId(detailQuery.data.supplierId || "");
|
||||||
|
setDocumentCategoryId(detailQuery.data.documentCategoryId || "");
|
||||||
|
setExpectedDeliveryDate(detailQuery.data.expectedDeliveryDate || "");
|
||||||
|
setItems(
|
||||||
|
detailQuery.data.items.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
description: item.description,
|
||||||
|
specification: item.specification || "",
|
||||||
|
quantity: item.quantity,
|
||||||
|
unitPrice: item.unitPrice,
|
||||||
|
isAssetCandidate: item.isAssetCandidate,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
setFormError("");
|
||||||
|
}, [detailQuery.data]);
|
||||||
|
|
||||||
|
const filteredCostCenters = useMemo(() => {
|
||||||
|
if (!legalEntityId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return (costCentersQuery.data?.items ?? []).filter((item) => item.legalEntityId === legalEntityId);
|
||||||
|
}, [costCentersQuery.data?.items, legalEntityId]);
|
||||||
|
|
||||||
|
const filterCostCenters = useMemo(() => {
|
||||||
|
if (!legalEntityFilter) {
|
||||||
|
return costCentersQuery.data?.items ?? [];
|
||||||
|
}
|
||||||
|
return (costCentersQuery.data?.items ?? []).filter((item) => item.legalEntityId === legalEntityFilter);
|
||||||
|
}, [costCentersQuery.data?.items, legalEntityFilter]);
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE));
|
||||||
|
const totalAmountPreview = useMemo(
|
||||||
|
() => items.reduce((sum, item) => sum + Number(moneyPreview(item.quantity, item.unitPrice)), 0).toFixed(2),
|
||||||
|
[items],
|
||||||
|
);
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: () => createPurchaseRequest(),
|
||||||
|
onSuccess: async (data) => {
|
||||||
|
setSelectedId(data.purchaseRequest.id);
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["purchase-requests", "list"] });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["purchase-requests", "detail"] });
|
||||||
|
toast.success("采购草稿已创建");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
const message = resolveError(error);
|
||||||
|
setFormError(message);
|
||||||
|
toast.error(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildPayload(): {
|
||||||
|
title: string;
|
||||||
|
reason: string | null;
|
||||||
|
currencyCode: string;
|
||||||
|
legalEntityId: string | null;
|
||||||
|
costCenterId: string | null;
|
||||||
|
supplierId: string | null;
|
||||||
|
documentCategoryId: string | null;
|
||||||
|
expectedDeliveryDate: string | null;
|
||||||
|
items: PurchaseRequestItemPayload[];
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
title: title.trim(),
|
||||||
|
reason: reason.trim() || null,
|
||||||
|
currencyCode: currencyCode.trim().toUpperCase() || "CNY",
|
||||||
|
legalEntityId: legalEntityId || null,
|
||||||
|
costCenterId: costCenterId || null,
|
||||||
|
supplierId: supplierId || null,
|
||||||
|
documentCategoryId: documentCategoryId || null,
|
||||||
|
expectedDeliveryDate: expectedDeliveryDate || null,
|
||||||
|
items: items.map((item) => ({
|
||||||
|
description: item.description.trim(),
|
||||||
|
specification: item.specification.trim() || null,
|
||||||
|
quantity: item.quantity || "0.00",
|
||||||
|
unitPrice: item.unitPrice || "0.00",
|
||||||
|
isAssetCandidate: item.isAssetCandidate,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateBeforeSubmit() {
|
||||||
|
if (!supplierId) {
|
||||||
|
throw new Error("提交前请选择供应商");
|
||||||
|
}
|
||||||
|
if (!legalEntityId) {
|
||||||
|
throw new Error("提交前请选择法体");
|
||||||
|
}
|
||||||
|
if (!costCenterId) {
|
||||||
|
throw new Error("提交前请选择成本中心");
|
||||||
|
}
|
||||||
|
if (!documentCategoryId) {
|
||||||
|
throw new Error("提交前请选择单据分类");
|
||||||
|
}
|
||||||
|
if (!items.length) {
|
||||||
|
throw new Error("提交前至少添加一条明细");
|
||||||
|
}
|
||||||
|
if (items.some((item) => !item.description.trim())) {
|
||||||
|
throw new Error("请补齐所有明细描述");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!selectedId) {
|
||||||
|
throw new Error("请先选择采购单");
|
||||||
|
}
|
||||||
|
return updatePurchaseRequest(selectedId, buildPayload());
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "list"] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "detail", selectedId] }),
|
||||||
|
]);
|
||||||
|
setFormError("");
|
||||||
|
toast.success("采购草稿已保存");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
const message = resolveError(error);
|
||||||
|
setFormError(message);
|
||||||
|
toast.error(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const submitMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!selectedId) {
|
||||||
|
throw new Error("请先选择采购单");
|
||||||
|
}
|
||||||
|
validateBeforeSubmit();
|
||||||
|
await updatePurchaseRequest(selectedId, buildPayload());
|
||||||
|
return submitPurchaseRequest(selectedId);
|
||||||
|
},
|
||||||
|
onSuccess: async (data) => {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["purchase-requests"] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }),
|
||||||
|
]);
|
||||||
|
setFormError("");
|
||||||
|
toast.success(
|
||||||
|
data.warnings.includes("missing_budget_pool")
|
||||||
|
? "采购申请已提交(未匹配预算池)"
|
||||||
|
: "采购申请已提交",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
const message = resolveError(error);
|
||||||
|
setFormError(message);
|
||||||
|
toast.error(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const withdrawMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!selectedId) {
|
||||||
|
throw new Error("请先选择采购单");
|
||||||
|
}
|
||||||
|
return withdrawPurchaseRequest(selectedId);
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["purchase-requests"] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }),
|
||||||
|
]);
|
||||||
|
setFormError("");
|
||||||
|
toast.success("采购申请已撤回到草稿");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
const message = resolveError(error);
|
||||||
|
setFormError(message);
|
||||||
|
toast.error(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadMutation = useMutation({
|
||||||
|
mutationFn: async (file: File) => {
|
||||||
|
if (!selectedId) {
|
||||||
|
throw new Error("请先选择采购单");
|
||||||
|
}
|
||||||
|
return uploadPurchaseAttachment(selectedId, file);
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["purchase-requests", "detail", selectedId] });
|
||||||
|
toast.success("附件已上传");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
const message = resolveError(error);
|
||||||
|
setFormError(message);
|
||||||
|
toast.error(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteAttachmentMutation = useMutation({
|
||||||
|
mutationFn: async (attachmentId: string) => {
|
||||||
|
if (!selectedId) {
|
||||||
|
throw new Error("请先选择采购单");
|
||||||
|
}
|
||||||
|
return deletePurchaseAttachment(selectedId, attachmentId);
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["purchase-requests", "detail", selectedId] });
|
||||||
|
toast.success("附件已删除");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
const message = resolveError(error);
|
||||||
|
setFormError(message);
|
||||||
|
toast.error(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleDownloadAttachment(attachmentId: string) {
|
||||||
|
if (!selectedId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await issuePurchaseAttachmentSignedUrl(selectedId, attachmentId);
|
||||||
|
window.open(response.signedUrl, "_blank", "noopener,noreferrer");
|
||||||
|
} catch (error) {
|
||||||
|
const message = resolveError(error);
|
||||||
|
setFormError(message);
|
||||||
|
toast.error(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const detail = detailQuery.data;
|
||||||
|
const canEditDraft = Boolean(detail?.permissions.canEditDraft);
|
||||||
|
const canSubmit = Boolean(detail?.permissions.canSubmit);
|
||||||
|
const canWithdraw = Boolean(detail?.permissions.canWithdraw);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
title="采购申请"
|
||||||
|
subtitle="支持草稿、附件、提交、撤回与经理单步审批的轻采购流程。"
|
||||||
|
actions={
|
||||||
|
canCreate ? (
|
||||||
|
<Button
|
||||||
|
data-testid="purchase-requests-create"
|
||||||
|
disabled={createMutation.isPending}
|
||||||
|
onClick={() => createMutation.mutate()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{createMutation.isPending ? "创建中..." : "新建采购"}
|
||||||
|
</Button>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{formError ? (
|
||||||
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||||
|
{formError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="card-std space-y-4 p-6">
|
||||||
|
<div className="grid gap-3 md:grid-cols-5">
|
||||||
|
<select
|
||||||
|
className="select-std"
|
||||||
|
value={status}
|
||||||
|
onChange={(event) => {
|
||||||
|
setStatus(event.target.value as PurchaseRequestStatus | "");
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{statusOptions().map((item) => (
|
||||||
|
<option key={item.label} value={item.value}>
|
||||||
|
{item.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
className="select-std"
|
||||||
|
value={supplierFilter}
|
||||||
|
onChange={(event) => {
|
||||||
|
setSupplierFilter(event.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">全部供应商</option>
|
||||||
|
{(suppliersQuery.data?.items ?? []).map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
className="select-std"
|
||||||
|
value={legalEntityFilter}
|
||||||
|
onChange={(event) => {
|
||||||
|
const nextValue = event.target.value;
|
||||||
|
setLegalEntityFilter(nextValue);
|
||||||
|
if (costCenterFilter && !filterCostCenters.some((item) => item.id === costCenterFilter && item.legalEntityId === nextValue)) {
|
||||||
|
setCostCenterFilter("");
|
||||||
|
}
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">全部法体</option>
|
||||||
|
{(legalEntitiesQuery.data?.items ?? []).map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
className="select-std"
|
||||||
|
value={costCenterFilter}
|
||||||
|
onChange={(event) => {
|
||||||
|
setCostCenterFilter(event.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">全部成本中心</option>
|
||||||
|
{filterCostCenters.map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{canViewAll ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={mine ? "default" : "secondary"}
|
||||||
|
onClick={() => {
|
||||||
|
setMine(true);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
仅自己
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={!mine ? "default" : "secondary"}
|
||||||
|
onClick={() => {
|
||||||
|
setMine(false);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
全范围
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 xl:grid-cols-[420px_minmax(0,1fr)]">
|
||||||
|
<div className="card-std space-y-4 p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-base font-semibold text-zinc-900">采购单列表</h3>
|
||||||
|
<span className="text-sm text-zinc-500">共 {listQuery.data?.total ?? 0} 条</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{listQuery.isLoading ? <TableSkeleton columns={4} rows={6} /> : null}
|
||||||
|
|
||||||
|
{listQuery.isError ? (
|
||||||
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||||
|
{resolveError(listQuery.error)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!listQuery.isLoading && !listQuery.isError ? (
|
||||||
|
<div className="rounded-lg border border-zinc-200">
|
||||||
|
<Table data-testid="purchase-requests-table">
|
||||||
|
<TableHeader>
|
||||||
|
<tr>
|
||||||
|
<TableHead>单据</TableHead>
|
||||||
|
<TableHead>申请人</TableHead>
|
||||||
|
<TableHead>状态</TableHead>
|
||||||
|
<TableHead>金额</TableHead>
|
||||||
|
</tr>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{(listQuery.data?.items ?? []).length ? (
|
||||||
|
listQuery.data?.items.map((item) => (
|
||||||
|
<TableRow
|
||||||
|
key={item.id}
|
||||||
|
className={item.id === selectedId ? "bg-zinc-50" : undefined}
|
||||||
|
onClick={() => setSelectedId(item.id)}
|
||||||
|
>
|
||||||
|
<TableCell className="cursor-pointer">
|
||||||
|
<div className="font-medium text-zinc-900">{item.requestNo || item.title || item.id}</div>
|
||||||
|
<div className="text-xs text-zinc-500">{item.title || "未命名采购申请"}</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{item.applicant?.name ?? "-"}</TableCell>
|
||||||
|
<TableCell><StatusBadge status={item.status} /></TableCell>
|
||||||
|
<TableCell>{item.totalAmount}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="py-4 text-zinc-500" colSpan={4}>
|
||||||
|
暂无采购申请
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
page={page}
|
||||||
|
totalPages={totalPages}
|
||||||
|
onPageChange={setPage}
|
||||||
|
testIdPrefix="purchase-requests"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-std space-y-5 p-6">
|
||||||
|
{!selectedId ? (
|
||||||
|
<div className="rounded-lg border border-dashed border-zinc-300 p-8 text-center text-sm text-zinc-500">
|
||||||
|
请先在左侧选择采购单,或新建一张采购草稿。
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{selectedId && detailQuery.isLoading ? <TableSkeleton columns={3} rows={8} /> : null}
|
||||||
|
|
||||||
|
{selectedId && detailQuery.isError ? (
|
||||||
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||||
|
{resolveError(detailQuery.error)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{selectedId && detail ? (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-zinc-900">{detail.requestNo ?? "采购草稿"}</h3>
|
||||||
|
<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} />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={!canEditDraft || saveMutation.isPending}
|
||||||
|
onClick={() => saveMutation.mutate()}
|
||||||
|
>
|
||||||
|
{saveMutation.isPending ? "保存中..." : "保存草稿"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={!canSubmit || submitMutation.isPending}
|
||||||
|
onClick={() => submitMutation.mutate()}
|
||||||
|
>
|
||||||
|
{submitMutation.isPending ? "提交中..." : "提交审批"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={!canWithdraw || withdrawMutation.isPending}
|
||||||
|
onClick={() => withdrawMutation.mutate()}
|
||||||
|
>
|
||||||
|
{withdrawMutation.isPending ? "撤回中..." : "撤回"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-zinc-700">币种</label>
|
||||||
|
<Input value={currencyCode} onChange={(event) => setCurrencyCode(event.target.value.toUpperCase())} disabled={!canEditDraft} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-zinc-700">法体</label>
|
||||||
|
<select
|
||||||
|
className="select-std"
|
||||||
|
value={legalEntityId}
|
||||||
|
disabled={!canEditDraft}
|
||||||
|
onChange={(event) => {
|
||||||
|
const nextValue = event.target.value;
|
||||||
|
setLegalEntityId(nextValue);
|
||||||
|
if (costCenterId && !filteredCostCenters.some((item) => item.id === costCenterId && item.legalEntityId === nextValue)) {
|
||||||
|
setCostCenterId("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">请选择法体</option>
|
||||||
|
{(legalEntitiesQuery.data?.items ?? []).map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</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)}>
|
||||||
|
<option value="">请选择成本中心</option>
|
||||||
|
{filteredCostCenters.map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</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)}>
|
||||||
|
<option value="">请选择供应商</option>
|
||||||
|
{(suppliersQuery.data?.items ?? []).map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</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)}>
|
||||||
|
<option value="">请选择单据分类</option>
|
||||||
|
{(categoriesQuery.data?.items ?? []).map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.name} ({item.code})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</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)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-zinc-700">申请说明</label>
|
||||||
|
<Textarea rows={4} value={reason} disabled={!canEditDraft} onChange={(event) => setReason(event.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-zinc-200 p-4 text-sm text-zinc-600">
|
||||||
|
<div>预算池:{detail.budgetPool?.name ?? "待提交时自动解析"}</div>
|
||||||
|
<div>待办节点:{detail.pendingWorkflow?.task?.stepName ?? "-"}</div>
|
||||||
|
<div>总金额:<span className="font-semibold text-zinc-900">{totalAmountPreview}</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-zinc-200">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<tr>
|
||||||
|
<TableHead>描述</TableHead>
|
||||||
|
<TableHead>规格</TableHead>
|
||||||
|
<TableHead>数量</TableHead>
|
||||||
|
<TableHead>单价</TableHead>
|
||||||
|
<TableHead>金额</TableHead>
|
||||||
|
<TableHead>资产候选</TableHead>
|
||||||
|
<TableHead className="w-20">操作</TableHead>
|
||||||
|
</tr>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.length ? (
|
||||||
|
items.map((item) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
<TableCell>
|
||||||
|
<Input
|
||||||
|
value={item.description}
|
||||||
|
disabled={!canEditDraft}
|
||||||
|
onChange={(event) =>
|
||||||
|
setItems((current) =>
|
||||||
|
current.map((entry) =>
|
||||||
|
entry.id === item.id ? { ...entry, description: event.target.value } : entry,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Input
|
||||||
|
value={item.specification}
|
||||||
|
disabled={!canEditDraft}
|
||||||
|
onChange={(event) =>
|
||||||
|
setItems((current) =>
|
||||||
|
current.map((entry) =>
|
||||||
|
entry.id === item.id ? { ...entry, specification: event.target.value } : entry,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Input
|
||||||
|
value={item.quantity}
|
||||||
|
disabled={!canEditDraft}
|
||||||
|
onChange={(event) =>
|
||||||
|
setItems((current) =>
|
||||||
|
current.map((entry) =>
|
||||||
|
entry.id === item.id ? { ...entry, quantity: event.target.value } : entry,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Input
|
||||||
|
value={item.unitPrice}
|
||||||
|
disabled={!canEditDraft}
|
||||||
|
onChange={(event) =>
|
||||||
|
setItems((current) =>
|
||||||
|
current.map((entry) =>
|
||||||
|
entry.id === item.id ? { ...entry, unitPrice: event.target.value } : entry,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{moneyPreview(item.quantity, item.unitPrice)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={item.isAssetCandidate}
|
||||||
|
disabled={!canEditDraft}
|
||||||
|
onChange={(event) =>
|
||||||
|
setItems((current) =>
|
||||||
|
current.map((entry) =>
|
||||||
|
entry.id === item.id ? { ...entry, isAssetCandidate: event.target.checked } : entry,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={!canEditDraft}
|
||||||
|
onClick={() => setItems((current) => current.filter((entry) => entry.id !== item.id))}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="py-4 text-zinc-500" colSpan={7}>
|
||||||
|
暂无明细,请先新增至少一条采购项。
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="text-base font-semibold text-zinc-900">附件</h4>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
disabled={!canEditDraft || uploadMutation.isPending}
|
||||||
|
onChange={(event) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
uploadMutation.mutate(file);
|
||||||
|
}
|
||||||
|
event.currentTarget.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-zinc-200">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<tr>
|
||||||
|
<TableHead>文件名</TableHead>
|
||||||
|
<TableHead>上传人</TableHead>
|
||||||
|
<TableHead>时间</TableHead>
|
||||||
|
<TableHead className="w-40">操作</TableHead>
|
||||||
|
</tr>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{detail.attachments.length ? (
|
||||||
|
detail.attachments.map((attachment) => (
|
||||||
|
<TableRow key={attachment.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="font-medium text-zinc-900">{attachment.originalName}</div>
|
||||||
|
<div className="text-xs text-zinc-500">{attachment.mimeType} / {attachment.fileSize} bytes</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{attachment.uploader?.name ?? "-"}</TableCell>
|
||||||
|
<TableCell>{attachment.createdAt ? new Date(attachment.createdAt).toLocaleString() : "-"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button type="button" size="sm" variant="secondary" onClick={() => void handleDownloadAttachment(attachment.id)}>
|
||||||
|
下载
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={!canEditDraft || deleteAttachmentMutation.isPending}
|
||||||
|
onClick={() => deleteAttachmentMutation.mutate(attachment.id)}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="py-4 text-zinc-500" colSpan={4}>
|
||||||
|
暂无附件
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -27,6 +27,9 @@ function entityTitle(entityType: string | undefined) {
|
|||||||
if (entityType === "reimbursement") {
|
if (entityType === "reimbursement") {
|
||||||
return "报销单";
|
return "报销单";
|
||||||
}
|
}
|
||||||
|
if (entityType === "purchase_request") {
|
||||||
|
return "采购申请";
|
||||||
|
}
|
||||||
return entityType || "业务单据";
|
return entityType || "业务单据";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,7 +145,7 @@ export function WorkflowTaskDetailPage() {
|
|||||||
|
|
||||||
const detail = detailQuery.data;
|
const detail = detailQuery.data;
|
||||||
const isLeaveTask = detail.workflowInstance?.entityType === "leave";
|
const isLeaveTask = detail.workflowInstance?.entityType === "leave";
|
||||||
const canTransfer = allowedActions.has("transfer");
|
const canTransfer = allowedActions.has("transfer") && detail.workflowInstance?.entityType === "reimbursement";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -183,6 +186,19 @@ export function WorkflowTaskDetailPage() {
|
|||||||
<div>附件:{detail.leaveRequest.attachment?.originalName ?? "-"}</div>
|
<div>附件:{detail.leaveRequest.attachment?.originalName ?? "-"}</div>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
{detail.purchaseRequest ? (
|
||||||
|
<>
|
||||||
|
<div>采购单号:{detail.purchaseRequest.requestNo ?? detail.purchaseRequest.id}</div>
|
||||||
|
<div>申请人:{detail.purchaseRequest.applicant?.name ?? "-"}</div>
|
||||||
|
<div>供应商:{detail.purchaseRequest.supplier?.name ?? "-"}</div>
|
||||||
|
<div>法体:{detail.purchaseRequest.legalEntity?.name ?? "-"}</div>
|
||||||
|
<div>成本中心:{detail.purchaseRequest.costCenter?.name ?? "-"}</div>
|
||||||
|
<div>单据分类:{detail.purchaseRequest.documentCategory?.name ?? "-"}</div>
|
||||||
|
<div>金额:{detail.purchaseRequest.totalAmount ?? "-"} {detail.purchaseRequest.currencyCode}</div>
|
||||||
|
<div>预计到货:{detail.purchaseRequest.expectedDeliveryDate ?? "-"}</div>
|
||||||
|
<div>说明:{detail.purchaseRequest.reason ?? "-"}</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card-std space-y-4 p-6">
|
<div className="card-std space-y-4 p-6">
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ function entityLabel(entityType: string | undefined) {
|
|||||||
if (entityType === "reimbursement") {
|
if (entityType === "reimbursement") {
|
||||||
return "报销";
|
return "报销";
|
||||||
}
|
}
|
||||||
|
if (entityType === "purchase_request") {
|
||||||
|
return "采购";
|
||||||
|
}
|
||||||
return entityType || "-";
|
return entityType || "-";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,7 +48,7 @@ export function WorkflowTasksPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader title="待我处理" subtitle="审批待办列表,覆盖报销与请假流程。" />
|
<PageHeader title="待我处理" subtitle="审批待办列表,覆盖报销、请假与采购流程。" />
|
||||||
|
|
||||||
<div className="card-std p-6 space-y-4">
|
<div className="card-std p-6 space-y-4">
|
||||||
<Tabs
|
<Tabs
|
||||||
|
|||||||
+13
-3
@@ -17,6 +17,7 @@ import { InvoicePoolPage } from "../pages/InvoicePoolPage";
|
|||||||
import { LoginPage } from "../pages/LoginPage";
|
import { LoginPage } from "../pages/LoginPage";
|
||||||
import { LeaveRequestPage } from "../pages/LeaveRequestPage";
|
import { LeaveRequestPage } from "../pages/LeaveRequestPage";
|
||||||
import { NotificationsPage } from "../pages/NotificationsPage";
|
import { NotificationsPage } from "../pages/NotificationsPage";
|
||||||
|
import { PurchaseRequestsPage } from "../pages/PurchaseRequestsPage";
|
||||||
import { NotFoundPage } from "../pages/NotFoundPage";
|
import { NotFoundPage } from "../pages/NotFoundPage";
|
||||||
import { ProfilePage } from "../pages/ProfilePage";
|
import { ProfilePage } from "../pages/ProfilePage";
|
||||||
import { ReimbursementDetailPage } from "../pages/ReimbursementDetailPage";
|
import { ReimbursementDetailPage } from "../pages/ReimbursementDetailPage";
|
||||||
@@ -98,10 +99,19 @@ export function AppRouter() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="purchase-requests"
|
||||||
|
element={
|
||||||
|
<RequirePermission anyOf={["procurement.create", "procurement.view_own", "procurement.view_all", "procurement.manage"]}>
|
||||||
|
<PurchaseRequestsPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="workflow/tasks"
|
path="workflow/tasks"
|
||||||
element={
|
element={
|
||||||
<RequirePermission anyOf={["expense.approve", "leave.approve"]}>
|
<RequirePermission anyOf={["expense.approve", "leave.approve", "procurement.approve"]}>
|
||||||
<WorkflowTasksPage />
|
<WorkflowTasksPage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
@@ -110,7 +120,7 @@ export function AppRouter() {
|
|||||||
<Route
|
<Route
|
||||||
path="workflow/tasks/:taskId"
|
path="workflow/tasks/:taskId"
|
||||||
element={
|
element={
|
||||||
<RequirePermission anyOf={["expense.approve", "leave.approve"]}>
|
<RequirePermission anyOf={["expense.approve", "leave.approve", "procurement.approve"]}>
|
||||||
<WorkflowTaskDetailPage />
|
<WorkflowTaskDetailPage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
@@ -137,7 +147,7 @@ export function AppRouter() {
|
|||||||
<Route
|
<Route
|
||||||
path="mobile/approvals"
|
path="mobile/approvals"
|
||||||
element={
|
element={
|
||||||
<RequirePermission anyOf={["expense.approve", "leave.approve"]}>
|
<RequirePermission anyOf={["expense.approve", "leave.approve", "procurement.approve"]}>
|
||||||
<MobileApprovalsPage />
|
<MobileApprovalsPage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ const notificationsRoutes = require("./routes/notifications");
|
|||||||
const exportsRoutes = require("./routes/exports");
|
const exportsRoutes = require("./routes/exports");
|
||||||
const dashboardRoutes = require("./routes/dashboard");
|
const dashboardRoutes = require("./routes/dashboard");
|
||||||
const budgetsRoutes = require("./routes/budgets");
|
const budgetsRoutes = require("./routes/budgets");
|
||||||
|
const purchaseRequestRoutes = require("./routes/purchaseRequests");
|
||||||
const integrationsRoutes = require("./routes/integrations");
|
const integrationsRoutes = require("./routes/integrations");
|
||||||
const travelOrdersRoutes = require("./routes/travelOrders");
|
const travelOrdersRoutes = require("./routes/travelOrders");
|
||||||
const { startJobs } = require("./jobs/jobSystem");
|
const { startJobs } = require("./jobs/jobSystem");
|
||||||
@@ -295,6 +296,7 @@ async function buildApp() {
|
|||||||
await app.register(notificationsRoutes, { prefix: "/api" });
|
await app.register(notificationsRoutes, { prefix: "/api" });
|
||||||
await app.register(dashboardRoutes, { prefix: "/api" });
|
await app.register(dashboardRoutes, { prefix: "/api" });
|
||||||
await app.register(budgetsRoutes, { prefix: "/api" });
|
await app.register(budgetsRoutes, { prefix: "/api" });
|
||||||
|
await app.register(purchaseRequestRoutes, { prefix: "/api" });
|
||||||
await app.register(integrationsRoutes, { prefix: "/api" });
|
await app.register(integrationsRoutes, { prefix: "/api" });
|
||||||
await app.register(travelOrdersRoutes, { prefix: "/api" });
|
await app.register(travelOrdersRoutes, { prefix: "/api" });
|
||||||
await app.register(exportsRoutes, { prefix: "/api" });
|
await app.register(exportsRoutes, { prefix: "/api" });
|
||||||
|
|||||||
文件差异内容过多而无法显示
加载差异
@@ -638,6 +638,53 @@ async function workflowRoutes(fastify) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
purchase_request: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
request_no: true,
|
||||||
|
status: true,
|
||||||
|
title: true,
|
||||||
|
reason: true,
|
||||||
|
currency_code: true,
|
||||||
|
total_amount: true,
|
||||||
|
expected_delivery_date: true,
|
||||||
|
applicant: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
employee_no: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
supplier: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
code: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
legal_entity: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
code: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cost_center: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
code: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
document_category: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
code: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -784,6 +831,53 @@ async function workflowRoutes(fastify) {
|
|||||||
: null,
|
: null,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
|
purchaseRequest: task.workflow_instance?.purchase_request
|
||||||
|
? {
|
||||||
|
id: task.workflow_instance.purchase_request.id,
|
||||||
|
requestNo: task.workflow_instance.purchase_request.request_no ?? null,
|
||||||
|
status: task.workflow_instance.purchase_request.status,
|
||||||
|
title: task.workflow_instance.purchase_request.title,
|
||||||
|
reason: task.workflow_instance.purchase_request.reason ?? null,
|
||||||
|
currencyCode: task.workflow_instance.purchase_request.currency_code,
|
||||||
|
totalAmount: toMoneyString(task.workflow_instance.purchase_request.total_amount),
|
||||||
|
expectedDeliveryDate: formatDateOnly(task.workflow_instance.purchase_request.expected_delivery_date),
|
||||||
|
applicant: task.workflow_instance.purchase_request.applicant
|
||||||
|
? {
|
||||||
|
id: task.workflow_instance.purchase_request.applicant.id,
|
||||||
|
employeeNo: task.workflow_instance.purchase_request.applicant.employee_no,
|
||||||
|
name: task.workflow_instance.purchase_request.applicant.name,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
supplier: task.workflow_instance.purchase_request.supplier
|
||||||
|
? {
|
||||||
|
id: task.workflow_instance.purchase_request.supplier.id,
|
||||||
|
code: task.workflow_instance.purchase_request.supplier.code,
|
||||||
|
name: task.workflow_instance.purchase_request.supplier.name,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
legalEntity: task.workflow_instance.purchase_request.legal_entity
|
||||||
|
? {
|
||||||
|
id: task.workflow_instance.purchase_request.legal_entity.id,
|
||||||
|
code: task.workflow_instance.purchase_request.legal_entity.code,
|
||||||
|
name: task.workflow_instance.purchase_request.legal_entity.name,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
costCenter: task.workflow_instance.purchase_request.cost_center
|
||||||
|
? {
|
||||||
|
id: task.workflow_instance.purchase_request.cost_center.id,
|
||||||
|
code: task.workflow_instance.purchase_request.cost_center.code,
|
||||||
|
name: task.workflow_instance.purchase_request.cost_center.name,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
documentCategory: task.workflow_instance.purchase_request.document_category
|
||||||
|
? {
|
||||||
|
id: task.workflow_instance.purchase_request.document_category.id,
|
||||||
|
code: task.workflow_instance.purchase_request.document_category.code,
|
||||||
|
name: task.workflow_instance.purchase_request.document_category.name,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
allowedActions: listAllowedActionsForTask(request, task, actionable),
|
allowedActions: listAllowedActionsForTask(request, task, actionable),
|
||||||
history: historyItems.map((item) => ({
|
history: historyItems.map((item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
@@ -1068,6 +1162,14 @@ async function workflowRoutes(fastify) {
|
|||||||
createdBy: request.employee.id,
|
createdBy: request.employee.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} else if (workflowInstance.entity_type === "purchase_request") {
|
||||||
|
await tx.purchaseRequest.update({
|
||||||
|
where: { id: workflowInstance.entity_id },
|
||||||
|
data: {
|
||||||
|
status: "approved",
|
||||||
|
updated_by: request.employee.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await tx.workflowHistory.create({
|
await tx.workflowHistory.create({
|
||||||
@@ -1130,6 +1232,7 @@ async function workflowRoutes(fastify) {
|
|||||||
workflowInstanceId: workflowInstance.id,
|
workflowInstanceId: workflowInstance.id,
|
||||||
nextTaskId: null,
|
nextTaskId: null,
|
||||||
reimbursementStatus: workflowInstance.entity_type === "reimbursement" ? "approved" : null,
|
reimbursementStatus: workflowInstance.entity_type === "reimbursement" ? "approved" : null,
|
||||||
|
purchaseRequestStatus: workflowInstance.entity_type === "purchase_request" ? "approved" : null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1240,6 +1343,14 @@ async function workflowRoutes(fastify) {
|
|||||||
createdBy: request.employee.id,
|
createdBy: request.employee.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} else if (workflowInstance.entity_type === "purchase_request") {
|
||||||
|
await tx.purchaseRequest.update({
|
||||||
|
where: { id: workflowInstance.entity_id },
|
||||||
|
data: {
|
||||||
|
status: "rejected",
|
||||||
|
updated_by: request.employee.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await tx.workflowHistory.create({
|
await tx.workflowHistory.create({
|
||||||
@@ -1301,6 +1412,7 @@ async function workflowRoutes(fastify) {
|
|||||||
outcome: "rejected",
|
outcome: "rejected",
|
||||||
workflowInstanceId: workflowInstance.id,
|
workflowInstanceId: workflowInstance.id,
|
||||||
reimbursementStatus: workflowInstance.entity_type === "reimbursement" ? "rejected" : null,
|
reimbursementStatus: workflowInstance.entity_type === "reimbursement" ? "rejected" : null,
|
||||||
|
purchaseRequestStatus: workflowInstance.entity_type === "purchase_request" ? "rejected" : null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1411,6 +1523,14 @@ async function workflowRoutes(fastify) {
|
|||||||
createdBy: request.employee.id,
|
createdBy: request.employee.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} else if (workflowInstance.entity_type === "purchase_request") {
|
||||||
|
await tx.purchaseRequest.update({
|
||||||
|
where: { id: workflowInstance.entity_id },
|
||||||
|
data: {
|
||||||
|
status: "draft",
|
||||||
|
updated_by: request.employee.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await tx.workflowHistory.create({
|
await tx.workflowHistory.create({
|
||||||
@@ -1472,6 +1592,7 @@ async function workflowRoutes(fastify) {
|
|||||||
outcome: "returned",
|
outcome: "returned",
|
||||||
workflowInstanceId: workflowInstance.id,
|
workflowInstanceId: workflowInstance.id,
|
||||||
reimbursementStatus: workflowInstance.entity_type === "reimbursement" ? "draft" : null,
|
reimbursementStatus: workflowInstance.entity_type === "reimbursement" ? "draft" : null,
|
||||||
|
purchaseRequestStatus: workflowInstance.entity_type === "purchase_request" ? "draft" : null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,19 @@ describe("Workflow Route Permissions", () => {
|
|||||||
expect(actions).toEqual([]);
|
expect(actions).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("allows procurement approvers to act on purchase tasks", () => {
|
||||||
|
const actions = listAllowedActionsForTask(
|
||||||
|
{ employee: { permissionCodes: ["procurement.approve"] } },
|
||||||
|
{
|
||||||
|
status: "pending",
|
||||||
|
workflow_instance: { entity_type: "purchase_request" },
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(actions).toEqual(["approve", "reject", "return"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps transfer action limited to reimbursement tasks", () => {
|
it("keeps transfer action limited to reimbursement tasks", () => {
|
||||||
const reimbursementActions = listAllowedActionsForTask(
|
const reimbursementActions = listAllowedActionsForTask(
|
||||||
{ employee: { permissionCodes: ["expense.approve", "expense.transfer"] } },
|
{ employee: { permissionCodes: ["expense.approve", "expense.transfer"] } },
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户