diff --git a/client/src/api/assets.ts b/client/src/api/assets.ts new file mode 100644 index 00000000..93050cd1 --- /dev/null +++ b/client/src/api/assets.ts @@ -0,0 +1,196 @@ +import { apiRequestData } from "./http"; + +export type AssetStatus = "in_stock" | "in_use" | "under_repair" | "retired" | "archived"; + +export type AssetCategoryItem = { + id: string; + code: string; + name: string; + documentCategoryId: string | null; + enabled: boolean; + archivedAt: string | null; + createdAt: string | null; + updatedAt: string | null; + documentCategory: { + id: string; + code: string; + name: string; + entityType: string; + } | null; +}; + +export type AssetAssigneeOption = { + id: string; + employeeNo: string; + name: string; + department: { + id: string; + name: string; + } | null; +}; + +export type AssetItem = { + id: string; + assetNo: string; + name: string; + categoryId: string; + legalEntityId: string; + costCenterId: string; + purchaseRequestId: string | null; + serialNo: string | null; + location: string | null; + status: AssetStatus; + assigneeEmployeeId: string | null; + activatedAt: string | null; + retiredAt: string | null; + createdAt: string | null; + updatedAt: string | null; + category: { + id: string; + code: string; + name: string; + documentCategoryId: string | null; + documentCategory: { + id: string; + code: string; + name: string; + } | null; + } | null; + legalEntity: { + id: string; + code: string; + name: string; + } | null; + costCenter: { + id: string; + code: string; + name: string; + legalEntityId: string; + } | null; + purchaseRequest: { + id: string; + requestNo: string | null; + title: string; + status: string; + } | null; + assignee: { + id: string; + employeeNo: string; + name: string; + department: { + id: string; + name: string; + } | null; + } | null; + permissions: { + canEdit: boolean; + }; +}; + +export type AssetCategoryListResponse = { + includeArchived: boolean; + items: AssetCategoryItem[]; +}; + +export type AssetListResponse = { + mine: boolean; + items: AssetItem[]; +}; + +export type AssetListQuery = { + status?: AssetStatus; + categoryId?: string; + assigneeId?: string; + legalEntityId?: string; + costCenterId?: string; + mine?: boolean; +}; + +export type AssetPayload = { + name: string; + categoryId: string; + legalEntityId: string; + costCenterId: string; + purchaseRequestId?: string | null; + serialNo?: string | null; + location?: string | null; + status?: AssetStatus; + assigneeEmployeeId?: string | null; + activatedAt?: string | null; + retiredAt?: string | null; +}; + +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") : value); + } + const encoded = params.toString(); + return encoded ? `?${encoded}` : ""; +} + +export async function fetchAssetCategories(params: { includeArchived?: boolean; enabled?: boolean } = {}) { + return apiRequestData( + `/api/asset-categories${toQueryString(params as Record)}`, + ); +} + +export async function createAssetCategory(payload: { + code: string; + name: string; + documentCategoryId?: string | null; + enabled?: boolean; +}) { + return apiRequestData<{ assetCategory: AssetCategoryItem }>("/api/asset-categories", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function patchAssetCategory( + assetCategoryId: string, + payload: Partial<{ + code: string; + name: string; + documentCategoryId: string | null; + enabled: boolean; + }>, +) { + return apiRequestData<{ assetCategory: AssetCategoryItem }>(`/api/asset-categories/${assetCategoryId}`, { + method: "PATCH", + body: JSON.stringify(payload), + }); +} + +export async function fetchAssetAssignees(params: { q?: string } = {}) { + return apiRequestData<{ items: AssetAssigneeOption[] }>( + `/api/assets/assignees${toQueryString(params as Record)}`, + ); +} + +export async function fetchAssets(params: AssetListQuery = {}) { + return apiRequestData( + `/api/assets${toQueryString(params as Record)}`, + ); +} + +export async function fetchAssetDetail(assetId: string) { + return apiRequestData<{ asset: AssetItem }>(`/api/assets/${assetId}`); +} + +export async function createAsset(payload: AssetPayload) { + return apiRequestData<{ asset: AssetItem }>("/api/assets", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function patchAsset(assetId: string, payload: Partial>) { + return apiRequestData<{ asset: AssetItem }>(`/api/assets/${assetId}`, { + method: "PATCH", + body: JSON.stringify(payload), + }); +} diff --git a/client/src/components/common/StatusBadge.tsx b/client/src/components/common/StatusBadge.tsx index 899a7f60..ba8ff1ea 100644 --- a/client/src/components/common/StatusBadge.tsx +++ b/client/src/components/common/StatusBadge.tsx @@ -11,6 +11,10 @@ const STATUS_MAP: Record = { completed: { label: "已完成", className: "badge-success" }, available: { label: "可用", className: "badge-success" }, active: { label: "生效中", className: "badge-success" }, + in_stock: { label: "库存中", className: "badge-primary" }, + in_use: { label: "使用中", className: "badge-success" }, + under_repair: { label: "维修中", className: "badge-warning" }, + retired: { label: "已退役", className: "badge-neutral" }, paid: { label: "已支付", className: "badge-success" }, bound: { label: "已绑定", className: "badge-success" }, rejected: { label: "已驳回", className: "badge-error" }, diff --git a/client/src/components/navigation/NavLinks.tsx b/client/src/components/navigation/NavLinks.tsx index b7ad613b..1524bc1f 100644 --- a/client/src/components/navigation/NavLinks.tsx +++ b/client/src/components/navigation/NavLinks.tsx @@ -36,6 +36,12 @@ export const NAV_ITEMS: NavItem[] = [ icon: "fact_check", anyOf: ["expense.approve", "leave.approve", "procurement.approve"], }, + { + to: "/assets", + label: "资产台账", + icon: "inventory_2", + anyOf: ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"], + }, { to: "/invoice-pool", label: "票据中心", diff --git a/client/src/pages/AssetRegistryPage.tsx b/client/src/pages/AssetRegistryPage.tsx new file mode 100644 index 00000000..7b97e5e5 --- /dev/null +++ b/client/src/pages/AssetRegistryPage.tsx @@ -0,0 +1,680 @@ +import { useEffect, useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { mapApiErrorMessage } from "../api/errorMessages"; +import { + createAsset, + createAssetCategory, + fetchAssetAssignees, + fetchAssetCategories, + fetchAssetDetail, + fetchAssets, + patchAsset, + patchAssetCategory, + type AssetCategoryItem, + type AssetStatus, +} from "../api/assets"; +import { fetchPurchaseRequests } from "../api/purchaseRequests"; +import { fetchCostCenters, fetchDocumentCategories, fetchLegalEntities } from "../api/masterData"; +import { isApiError } from "../api/types"; +import { useAuth } from "../auth/AuthContext"; +import { hasAnyPermission, hasPermission } from "../auth/permissions"; +import { PageHeader } from "../components/common/PageHeader"; +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"; + +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 initialCategoryForm() { + return { + code: "", + name: "", + documentCategoryId: "", + enabled: true, + }; +} + +function initialAssetForm() { + return { + name: "", + categoryId: "", + legalEntityId: "", + costCenterId: "", + purchaseRequestId: "", + serialNo: "", + location: "", + status: "in_stock" as AssetStatus, + assigneeEmployeeId: "", + activatedAt: "", + retiredAt: "", + }; +} + +function assetStatusOptions(): Array<{ value: AssetStatus | ""; label: string }> { + return [ + { value: "", label: "全部状态" }, + { value: "in_stock", label: "库存中" }, + { value: "in_use", label: "使用中" }, + { value: "under_repair", label: "维修中" }, + { value: "retired", label: "已退役" }, + { value: "archived", label: "已归档" }, + ]; +} + +export function AssetRegistryPage() { + const queryClient = useQueryClient(); + const { permissions } = useAuth(); + + const canManage = hasPermission(permissions, "asset.manage"); + const canViewAll = hasAnyPermission(permissions, ["asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"]); + const canReadProcurementSources = canManage && hasAnyPermission(permissions, ["procurement.view_all", "procurement.manage"]); + + const [categoryForm, setCategoryForm] = useState(initialCategoryForm); + const [editingCategoryId, setEditingCategoryId] = useState(""); + + const [statusFilter, setStatusFilter] = useState(""); + const [categoryFilter, setCategoryFilter] = useState(""); + const [legalEntityFilter, setLegalEntityFilter] = useState(""); + const [costCenterFilter, setCostCenterFilter] = useState(""); + const [assigneeFilter, setAssigneeFilter] = useState(""); + const [mine, setMine] = useState(!canViewAll); + + const [selectedAssetId, setSelectedAssetId] = useState(""); + const [isCreatingAsset, setIsCreatingAsset] = useState(false); + const [assetForm, setAssetForm] = useState(initialAssetForm); + const [assetFormError, setAssetFormError] = useState(""); + + const categoriesQuery = useQuery({ + queryKey: ["assets", "categories", canManage], + queryFn: () => fetchAssetCategories({ includeArchived: canManage, enabled: canManage ? undefined : true }), + }); + + const assetsQuery = useQuery({ + queryKey: ["assets", "list", statusFilter, categoryFilter, legalEntityFilter, costCenterFilter, assigneeFilter, mine, canViewAll], + queryFn: () => + fetchAssets({ + status: statusFilter || undefined, + categoryId: categoryFilter || undefined, + legalEntityId: legalEntityFilter || undefined, + costCenterId: costCenterFilter || undefined, + assigneeId: assigneeFilter || undefined, + mine: canViewAll ? mine : true, + }), + }); + + const assetDetailQuery = useQuery({ + enabled: Boolean(selectedAssetId) && !isCreatingAsset, + queryKey: ["assets", "detail", selectedAssetId], + queryFn: () => fetchAssetDetail(selectedAssetId), + }); + + const legalEntitiesQuery = useQuery({ + queryKey: ["assets", "legal-entities"], + queryFn: () => fetchLegalEntities({ enabled: true }), + }); + + const costCentersQuery = useQuery({ + queryKey: ["assets", "cost-centers"], + queryFn: () => fetchCostCenters({ enabled: true }), + }); + + const documentCategoriesQuery = useQuery({ + queryKey: ["assets", "document-categories"], + queryFn: () => fetchDocumentCategories({ entityType: "asset_card", enabled: true }), + }); + + const assigneesQuery = useQuery({ + enabled: canManage, + queryKey: ["assets", "assignees"], + queryFn: () => fetchAssetAssignees(), + }); + + const approvedPurchasesQuery = useQuery({ + enabled: canReadProcurementSources, + queryKey: ["assets", "approved-purchases"], + queryFn: () => fetchPurchaseRequests({ status: "approved", mine: false, page: 1, pageSize: 100 }), + }); + + useEffect(() => { + if (isCreatingAsset) { + return; + } + if (selectedAssetId) { + if (assetDetailQuery.isLoading) { + return; + } + if (!assetDetailQuery.isError) { + return; + } + } + const nextId = assetsQuery.data?.items?.[0]?.id ?? ""; + if (nextId !== selectedAssetId) { + setSelectedAssetId(nextId); + } + }, [assetDetailQuery.isError, assetDetailQuery.isLoading, assetsQuery.data?.items, isCreatingAsset, selectedAssetId]); + + useEffect(() => { + if (!assetDetailQuery.data?.asset) { + return; + } + const asset = assetDetailQuery.data.asset; + setAssetForm({ + name: asset.name, + categoryId: asset.categoryId, + legalEntityId: asset.legalEntityId, + costCenterId: asset.costCenterId, + purchaseRequestId: asset.purchaseRequestId ?? "", + serialNo: asset.serialNo ?? "", + location: asset.location ?? "", + status: asset.status, + assigneeEmployeeId: asset.assigneeEmployeeId ?? "", + activatedAt: asset.activatedAt ?? "", + retiredAt: asset.retiredAt ?? "", + }); + setAssetFormError(""); + }, [assetDetailQuery.data]); + + const filteredAssetCostCenters = useMemo(() => { + if (!assetForm.legalEntityId) { + return []; + } + return (costCentersQuery.data?.items ?? []).filter((item) => item.legalEntityId === assetForm.legalEntityId); + }, [assetForm.legalEntityId, costCentersQuery.data?.items]); + + const filteredFilterCostCenters = useMemo(() => { + if (!legalEntityFilter) { + return costCentersQuery.data?.items ?? []; + } + return (costCentersQuery.data?.items ?? []).filter((item) => item.legalEntityId === legalEntityFilter); + }, [costCentersQuery.data?.items, legalEntityFilter]); + + const currentAsset = assetDetailQuery.data?.asset ?? null; + const canEditCurrent = canManage && (isCreatingAsset || currentAsset?.permissions.canEdit); + + function resetCategoryEditor(category?: AssetCategoryItem) { + if (!category) { + setEditingCategoryId(""); + setCategoryForm(initialCategoryForm()); + return; + } + setEditingCategoryId(category.id); + setCategoryForm({ + code: category.code, + name: category.name, + documentCategoryId: category.documentCategoryId ?? "", + enabled: category.enabled, + }); + } + + function resetAssetEditor() { + setIsCreatingAsset(true); + setSelectedAssetId(""); + setAssetForm(initialAssetForm()); + setAssetFormError(""); + } + + function buildAssetPayload() { + return { + name: assetForm.name.trim(), + categoryId: assetForm.categoryId, + legalEntityId: assetForm.legalEntityId, + costCenterId: assetForm.costCenterId, + purchaseRequestId: assetForm.purchaseRequestId || null, + serialNo: assetForm.serialNo.trim() || null, + location: assetForm.location.trim() || null, + status: assetForm.status, + assigneeEmployeeId: assetForm.assigneeEmployeeId || null, + activatedAt: assetForm.activatedAt || null, + retiredAt: assetForm.retiredAt || null, + }; + } + + function validateAssetForm() { + if (!assetForm.name.trim()) { + throw new Error("请填写资产名称"); + } + if (!assetForm.categoryId) { + throw new Error("请选择资产分类"); + } + if (!assetForm.legalEntityId) { + throw new Error("请选择法体"); + } + if (!assetForm.costCenterId) { + throw new Error("请选择成本中心"); + } + } + + const saveCategoryMutation = useMutation({ + mutationFn: async () => { + if (!categoryForm.code.trim()) { + throw new Error("请填写分类编码"); + } + if (!categoryForm.name.trim()) { + throw new Error("请填写分类名称"); + } + const payload = { + code: categoryForm.code.trim(), + name: categoryForm.name.trim(), + documentCategoryId: categoryForm.documentCategoryId || null, + enabled: categoryForm.enabled, + }; + return editingCategoryId ? patchAssetCategory(editingCategoryId, payload) : createAssetCategory(payload); + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["assets", "categories"] }); + toast.success(editingCategoryId ? "资产分类已更新" : "资产分类已创建"); + resetCategoryEditor(); + }, + onError: (error) => { + toast.error(resolveError(error)); + }, + }); + + const saveAssetMutation = useMutation({ + mutationFn: async () => { + validateAssetForm(); + const payload = buildAssetPayload(); + if (isCreatingAsset) { + return createAsset(payload); + } + if (!selectedAssetId) { + throw new Error("请先选择资产卡片"); + } + return patchAsset(selectedAssetId, payload); + }, + onSuccess: async (data) => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["assets", "list"] }), + queryClient.invalidateQueries({ queryKey: ["assets", "detail"] }), + ]); + setAssetFormError(""); + setSelectedAssetId(data.asset.id); + setIsCreatingAsset(false); + toast.success(isCreatingAsset ? "资产卡片已创建" : "资产卡片已更新"); + }, + onError: (error) => { + const message = resolveError(error); + setAssetFormError(message); + toast.error(message); + }, + }); + + const purchaseOptions = useMemo(() => { + const items = approvedPurchasesQuery.data?.items ?? []; + const currentPurchase = currentAsset?.purchaseRequest; + if (currentPurchase && !items.some((item) => item.id === currentPurchase.id)) { + return [ + ...items, + { + id: currentPurchase.id, + requestNo: currentPurchase.requestNo, + title: currentPurchase.title, + status: currentPurchase.status, + applicantId: "", + submitterId: null, + departmentId: "", + legalEntityId: null, + costCenterId: null, + supplierId: null, + documentCategoryId: null, + budgetPoolId: null, + reason: null, + currencyCode: "CNY", + totalAmount: "0.00", + expectedDeliveryDate: null, + createdAt: null, + updatedAt: null, + applicant: null, + submitter: null, + department: null, + legalEntity: null, + costCenter: null, + supplier: null, + documentCategory: null, + budgetPool: null, + }, + ]; + } + return items; + }, [approvedPurchasesQuery.data?.items, currentAsset?.purchaseRequest]); + + return ( +
+ + 新建资产卡片 + + ) : null + } + /> + +
+
+
+

资产分类

+ {canManage ? ( + + ) : null} +
+ +
+
+ + setCategoryForm((current) => ({ ...current, code: event.target.value }))} /> +
+
+ + setCategoryForm((current) => ({ ...current, name: event.target.value }))} /> +
+
+ + +
+ + {canManage ? ( + + ) : null} +
+ + {categoriesQuery.isLoading ? : null} + {categoriesQuery.isError ? ( +

{resolveError(categoriesQuery.error)}

+ ) : null} + {!categoriesQuery.isLoading && !categoriesQuery.isError ? ( +
+ + + + 编码 + 名称 + 状态 + + + + {(categoriesQuery.data?.items ?? []).map((item) => ( + resetCategoryEditor(item)} className="cursor-pointer"> + {item.code} + +
{item.name}
+
{item.documentCategory?.name ?? "系统默认"}
+
+ {item.enabled ? "启用" : "停用"} +
+ ))} +
+
+
+ ) : null} +
+ +
+
+
+ + + + + + {canViewAll ? ( +
+ + +
+ ) : null} +
+ + {assetsQuery.isLoading ? : null} + {assetsQuery.isError ? ( +

{resolveError(assetsQuery.error)}

+ ) : null} + {!assetsQuery.isLoading && !assetsQuery.isError ? ( +
+ + + + 资产 + 分类 + 状态 + 归属人 + + + + {(assetsQuery.data?.items ?? []).length ? ( + assetsQuery.data?.items.map((item) => ( + { + setIsCreatingAsset(false); + setSelectedAssetId(item.id); + }} className={item.id === selectedAssetId && !isCreatingAsset ? "bg-zinc-50" : "cursor-pointer"}> + +
{item.assetNo}
+
{item.name}
+
+ {item.category?.name ?? "-"} + + {item.assignee?.name ?? "-"} +
+ )) + ) : ( + + + 暂无资产卡片 + + + )} +
+
+
+ ) : null} +
+ +
+ {!selectedAssetId && !isCreatingAsset ? ( +
+ 请先从列表选择资产卡片,或点击“新建资产卡片”。 +
+ ) : null} + + {selectedAssetId && !isCreatingAsset && assetDetailQuery.isLoading ? : null} + {selectedAssetId && !isCreatingAsset && assetDetailQuery.isError ? ( +

{resolveError(assetDetailQuery.error)}

+ ) : null} + + {(isCreatingAsset || currentAsset) && !assetDetailQuery.isLoading ? ( + <> +
+
+

{isCreatingAsset ? "新建资产卡片" : currentAsset?.assetNo ?? "资产卡片"}

+

{isCreatingAsset ? "创建新的资产台账卡片。" : currentAsset?.name}

+
+ {!isCreatingAsset && currentAsset ? : null} +
+ + {assetFormError ? ( +

{assetFormError}

+ ) : null} + +
+
+ + setAssetForm((current) => ({ ...current, name: event.target.value }))} /> +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + setAssetForm((current) => ({ ...current, serialNo: event.target.value }))} /> +
+
+ + setAssetForm((current) => ({ ...current, location: event.target.value }))} /> +
+
+ + setAssetForm((current) => ({ ...current, activatedAt: event.target.value }))} /> +
+
+ + setAssetForm((current) => ({ ...current, retiredAt: event.target.value }))} /> +
+
+ +
+ {canManage ? ( + + ) : null} + {currentAsset?.purchaseRequest ? ( + 采购来源:{currentAsset.purchaseRequest.requestNo ?? currentAsset.purchaseRequest.id} + ) : null} +
+ + ) : null} +
+
+
+
+ ); +} diff --git a/client/src/router/index.tsx b/client/src/router/index.tsx index 00e46207..c30983e8 100644 --- a/client/src/router/index.tsx +++ b/client/src/router/index.tsx @@ -18,6 +18,7 @@ import { LoginPage } from "../pages/LoginPage"; import { LeaveRequestPage } from "../pages/LeaveRequestPage"; import { NotificationsPage } from "../pages/NotificationsPage"; import { PurchaseRequestsPage } from "../pages/PurchaseRequestsPage"; +import { AssetRegistryPage } from "../pages/AssetRegistryPage"; import { NotFoundPage } from "../pages/NotFoundPage"; import { ProfilePage } from "../pages/ProfilePage"; import { ReimbursementDetailPage } from "../pages/ReimbursementDetailPage"; @@ -108,6 +109,15 @@ export function AppRouter() { } /> + + + + } + /> + + fastify.hasPermission(employee, permission), + ); +} + +function canViewAllAssets(fastify, employee) { + return ["asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"].some((permission) => + fastify.hasPermission(employee, permission), + ); +} + +function canManageAssets(fastify, employee) { + return fastify.hasPermission(employee, "asset.manage"); +} + +function ensureAssetReadPermission(fastify, request) { + if (!canReadAssets(fastify, request.employee)) { + throw new AppError({ + code: "PERM_001", + message: "无权限", + statusCode: 403, + details: { permission: "asset.view_own" }, + }); + } +} + +function ensureAssetViewerOwnsRecord(fastify, request, assetRecord) { + if (canViewAllAssets(fastify, request.employee)) { + return; + } + if (assetRecord.assignee_employee_id !== request.employee.id) { + throw new AppError({ + code: "PERM_001", + message: "无权限", + statusCode: 403, + details: { permission: "asset.view_own" }, + }); + } +} + +function mapAssetCategory(record) { + return { + id: record.id, + code: record.code, + name: record.name, + documentCategoryId: record.document_category_id ?? null, + enabled: Boolean(record.enabled), + archivedAt: record.archived_at?.toISOString?.() ?? null, + createdAt: record.created_at?.toISOString?.() ?? null, + updatedAt: record.updated_at?.toISOString?.() ?? null, + documentCategory: record.document_category + ? { + id: record.document_category.id, + code: record.document_category.code, + name: record.document_category.name, + entityType: record.document_category.entity_type, + } + : null, + }; +} + +function mapAsset(record, request, fastify) { + return { + id: record.id, + assetNo: record.asset_no, + name: record.name, + categoryId: record.category_id, + legalEntityId: record.legal_entity_id, + costCenterId: record.cost_center_id, + purchaseRequestId: record.purchase_request_id ?? null, + serialNo: record.serial_no ?? null, + location: record.location ?? null, + status: record.status, + assigneeEmployeeId: record.assignee_employee_id ?? null, + activatedAt: record.activated_at?.toISOString?.().slice(0, 10) ?? null, + retiredAt: record.retired_at?.toISOString?.().slice(0, 10) ?? null, + createdAt: record.created_at?.toISOString?.() ?? null, + updatedAt: record.updated_at?.toISOString?.() ?? null, + category: record.category + ? { + id: record.category.id, + code: record.category.code, + name: record.category.name, + documentCategoryId: record.category.document_category_id ?? null, + documentCategory: record.category.document_category + ? { + id: record.category.document_category.id, + code: record.category.document_category.code, + name: record.category.document_category.name, + } + : null, + } + : null, + legalEntity: record.legal_entity + ? { + id: record.legal_entity.id, + code: record.legal_entity.code, + name: record.legal_entity.name, + } + : null, + costCenter: record.cost_center + ? { + id: record.cost_center.id, + code: record.cost_center.code, + name: record.cost_center.name, + legalEntityId: record.cost_center.legal_entity_id, + } + : null, + purchaseRequest: record.purchase_request + ? { + id: record.purchase_request.id, + requestNo: record.purchase_request.request_no ?? null, + title: record.purchase_request.title, + status: record.purchase_request.status, + } + : null, + assignee: record.assignee + ? { + id: record.assignee.id, + employeeNo: record.assignee.employee_no, + name: record.assignee.name, + department: record.assignee.department + ? { + id: record.assignee.department.id, + name: record.assignee.department.name, + } + : null, + } + : null, + permissions: { + canEdit: canManageAssets(fastify, request.employee), + }, + }; +} + +function mapAssignee(record) { + return { + id: record.id, + employeeNo: record.employee_no, + name: record.name, + department: record.department + ? { + id: record.department.id, + name: record.department.name, + } + : null, + }; +} + +async function ensureDocumentCategoryAvailable(tx, documentCategoryId) { + if (!documentCategoryId) { + return null; + } + const record = await tx.documentCategory.findUnique({ + where: { id: documentCategoryId }, + select: { + id: true, + code: true, + name: true, + entity_type: true, + enabled: true, + archived_at: true, + }, + }); + if (!record) { + throw buildValidationError({ documentCategoryId: "not_found" }, 404); + } + if (record.entity_type !== "asset_card") { + throw buildValidationError({ documentCategoryId: "unsupported_entity_type" }, 409); + } + if (!record.enabled || record.archived_at) { + throw buildValidationError({ documentCategoryId: "inactive" }, 409); + } + return record; +} + +async function ensureAssetCategoryExists(tx, categoryId, { requireActive = true } = {}) { + const record = await tx.assetCategory.findUnique({ + where: { id: categoryId }, + select: { + id: true, + code: true, + name: true, + document_category_id: true, + enabled: true, + archived_at: true, + document_category: { + select: { + id: true, + code: true, + name: true, + entity_type: true, + enabled: true, + archived_at: true, + }, + }, + }, + }); + if (!record) { + throw buildValidationError({ categoryId: "not_found" }, 404); + } + if (requireActive && (!record.enabled || record.archived_at)) { + throw buildValidationError({ categoryId: "inactive" }, 409); + } + if (record.document_category && (record.document_category.entity_type !== "asset_card" || !record.document_category.enabled || record.document_category.archived_at)) { + throw buildValidationError({ categoryId: "invalid_document_category" }, 409); + } + return record; +} + +async function ensureLegalEntityAvailable(tx, legalEntityId) { + const record = await tx.legalEntity.findUnique({ + where: { id: legalEntityId }, + select: { id: true, code: true, name: true, enabled: true, archived_at: true }, + }); + if (!record) { + throw buildValidationError({ legalEntityId: "not_found" }, 404); + } + if (!record.enabled || record.archived_at) { + throw buildValidationError({ legalEntityId: "inactive" }, 409); + } + return record; +} + +async function ensureCostCenterAvailable(tx, costCenterId, legalEntityId) { + const record = await tx.costCenter.findUnique({ + where: { id: costCenterId }, + select: { + id: true, + code: true, + name: true, + legal_entity_id: true, + enabled: true, + archived_at: true, + }, + }); + if (!record) { + throw buildValidationError({ costCenterId: "not_found" }, 404); + } + if (!record.enabled || record.archived_at) { + throw buildValidationError({ costCenterId: "inactive" }, 409); + } + if (record.legal_entity_id !== legalEntityId) { + throw buildValidationError({ costCenterId: "legal_entity_mismatch" }, 409); + } + return record; +} + +async function ensureAssigneeEmployee(tx, assigneeEmployeeId) { + if (!assigneeEmployeeId) { + return null; + } + const record = await tx.employee.findUnique({ + where: { id: assigneeEmployeeId }, + select: { + id: true, + employee_no: true, + name: true, + status: true, + }, + }); + if (!record) { + throw buildValidationError({ assigneeEmployeeId: "not_found" }, 404); + } + if (record.status !== "active") { + throw buildValidationError({ assigneeEmployeeId: "inactive" }, 409); + } + return record; +} + +async function ensureApprovedPurchaseRequest(tx, purchaseRequestId) { + if (!purchaseRequestId) { + return null; + } + const record = await tx.purchaseRequest.findUnique({ + where: { id: purchaseRequestId }, + select: { + id: true, + request_no: true, + title: true, + status: true, + }, + }); + if (!record) { + throw buildValidationError({ purchaseRequestId: "not_found" }, 404); + } + if (record.status !== "approved") { + throw buildValidationError({ purchaseRequestId: "not_approved" }, 409); + } + return record; +} + +async function assetsRoutes(fastify) { + fastify.get( + "/asset-categories", + { + schema: { tags: ["Assets"], summary: "List Asset Categories" }, + preHandler: fastify.authenticate, + }, + async (request, reply) => { + ensureAssetReadPermission(fastify, request); + const { includeArchived, enabled } = parseCategoryListQuery(request.query); + const where = {}; + if (!includeArchived) { + where.archived_at = null; + } + if (enabled !== null) { + where.enabled = enabled; + } + const items = await fastify.prisma.assetCategory.findMany({ + where, + orderBy: [{ enabled: "desc" }, { code: "asc" }], + select: ASSET_CATEGORY_SELECT, + }); + return sendData(reply, request, { + includeArchived, + items: items.map(mapAssetCategory), + }); + }, + ); + + fastify.post( + "/asset-categories", + { + schema: { tags: ["Assets"], summary: "Create Asset Category" }, + preHandler: fastify.requirePermission("asset.manage"), + }, + async (request, reply) => { + const payload = normalizeAssetCategoryPayload(parseCreateBody(ASSET_CATEGORY_CREATE_SCHEMA, request.body)); + const assetCategory = await fastify.prisma.$transaction(async (tx) => { + if (payload.documentCategoryId) { + await ensureDocumentCategoryAvailable(tx, payload.documentCategoryId); + } + + const created = await tx.assetCategory.create({ + data: { + code: payload.code, + name: payload.name, + document_category_id: payload.documentCategoryId ?? null, + enabled: payload.enabled ?? true, + created_by: request.employee.id, + updated_by: request.employee.id, + }, + select: ASSET_CATEGORY_SELECT, + }); + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "ASSET_CATEGORY_CREATED", + targetType: "asset_category", + targetId: created.id, + extra: { + code: created.code, + documentCategoryId: created.document_category_id ?? null, + }, + }), + }); + + return created; + }); + + return sendData(reply, request, { assetCategory: mapAssetCategory(assetCategory) }); + }, + ); + + fastify.patch( + "/asset-categories/:id", + { + schema: { tags: ["Assets"], summary: "Update Asset Category" }, + preHandler: fastify.requirePermission("asset.manage"), + }, + async (request, reply) => { + const assetCategoryId = parseUuid("id", request.params?.id); + const payload = normalizeAssetCategoryPayload(parsePatchBody(ASSET_CATEGORY_PATCH_SCHEMA, request.body)); + + const assetCategory = await fastify.prisma.$transaction(async (tx) => { + const existing = await tx.assetCategory.findUnique({ + where: { id: assetCategoryId }, + select: { id: true }, + }); + if (!existing) { + throw buildValidationError({ id: "not_found" }, 404); + } + + if (payload.documentCategoryId) { + await ensureDocumentCategoryAvailable(tx, payload.documentCategoryId); + } + + const updated = await tx.assetCategory.update({ + where: { id: assetCategoryId }, + data: { + ...(payload.code !== undefined ? { code: payload.code } : {}), + ...(payload.name !== undefined ? { name: payload.name } : {}), + ...(payload.documentCategoryId !== undefined ? { document_category_id: payload.documentCategoryId } : {}), + ...(payload.enabled !== undefined ? { enabled: payload.enabled } : {}), + updated_by: request.employee.id, + }, + select: ASSET_CATEGORY_SELECT, + }); + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "ASSET_CATEGORY_UPDATED", + targetType: "asset_category", + targetId: updated.id, + extra: { + code: updated.code, + enabled: updated.enabled, + }, + }), + }); + + return updated; + }); + + return sendData(reply, request, { assetCategory: mapAssetCategory(assetCategory) }); + }, + ); + + fastify.get( + "/assets/assignees", + { + schema: { tags: ["Assets"], summary: "List Asset Assignee Options" }, + preHandler: fastify.authenticate, + }, + async (request, reply) => { + if (!canManageAssets(fastify, request.employee)) { + throw new AppError({ + code: "PERM_001", + message: "无权限", + statusCode: 403, + details: { permission: "asset.manage" }, + }); + } + const { q } = parseAssigneeListQuery(request.query); + const items = await fastify.prisma.employee.findMany({ + where: { + status: "active", + archived_at: null, + ...(q + ? { + OR: [ + { name: { contains: q, mode: "insensitive" } }, + { employee_no: { contains: q, mode: "insensitive" } }, + ], + } + : {}), + }, + orderBy: [{ name: "asc" }, { employee_no: "asc" }], + take: 200, + select: { + id: true, + employee_no: true, + name: true, + department: { + select: { + id: true, + name: true, + }, + }, + }, + }); + return sendData(reply, request, { items: items.map(mapAssignee) }); + }, + ); + + fastify.get( + "/assets", + { + schema: { tags: ["Assets"], summary: "List Asset Cards" }, + preHandler: fastify.authenticate, + }, + async (request, reply) => { + ensureAssetReadPermission(fastify, request); + const query = parseAssetListQuery(request.query); + const mine = !canViewAllAssets(fastify, request.employee) || query.mine === true; + const where = {}; + if (query.status) { + where.status = query.status; + } + if (query.categoryId) { + where.category_id = query.categoryId; + } + if (query.legalEntityId) { + where.legal_entity_id = query.legalEntityId; + } + if (query.costCenterId) { + where.cost_center_id = query.costCenterId; + } + if (mine) { + where.assignee_employee_id = request.employee.id; + } else if (query.assigneeId) { + where.assignee_employee_id = query.assigneeId; + } + + const items = await fastify.prisma.assetCard.findMany({ + where, + orderBy: [{ updated_at: "desc" }, { created_at: "desc" }], + select: ASSET_CARD_SELECT, + }); + + return sendData(reply, request, { + mine, + items: items.map((item) => mapAsset(item, request, fastify)), + }); + }, + ); + + fastify.post( + "/assets", + { + schema: { tags: ["Assets"], summary: "Create Asset Card" }, + preHandler: fastify.requirePermission("asset.manage"), + }, + async (request, reply) => { + const payload = normalizeAssetPayload(parseCreateBody(ASSET_CREATE_SCHEMA, request.body)); + const asset = await fastify.prisma.$transaction(async (tx) => { + const category = await ensureAssetCategoryExists(tx, payload.categoryId, { requireActive: true }); + await ensureLegalEntityAvailable(tx, payload.legalEntityId); + await ensureCostCenterAvailable(tx, payload.costCenterId, payload.legalEntityId); + await ensureAssigneeEmployee(tx, payload.assigneeEmployeeId ?? null); + const purchaseRequest = await ensureApprovedPurchaseRequest(tx, payload.purchaseRequestId ?? null); + + const issued = await issueDocumentNumber(tx, { + documentType: "asset_card", + documentCategoryCode: category.document_category?.code ?? null, + actorId: request.employee.id, + }); + + const created = await tx.assetCard.create({ + data: { + asset_no: issued.documentNumber, + name: payload.name, + category_id: payload.categoryId, + legal_entity_id: payload.legalEntityId, + cost_center_id: payload.costCenterId, + purchase_request_id: payload.purchaseRequestId ?? null, + serial_no: payload.serialNo ?? null, + location: payload.location ?? null, + status: payload.status ?? "in_stock", + assignee_employee_id: payload.assigneeEmployeeId ?? null, + activated_at: payload.activatedAt ? parseDateOnlyString(payload.activatedAt) : null, + retired_at: payload.retiredAt ? parseDateOnlyString(payload.retiredAt) : null, + created_by: request.employee.id, + updated_by: request.employee.id, + }, + select: ASSET_CARD_SELECT, + }); + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "ASSET_CARD_CREATED", + targetType: "asset_card", + targetId: created.id, + extra: { + assetNo: created.asset_no, + purchaseRequestId: purchaseRequest?.id ?? null, + assigneeEmployeeId: created.assignee_employee_id ?? null, + }, + }), + }); + + return created; + }); + + return sendData(reply, request, { asset: mapAsset(asset, request, fastify) }); + }, + ); + + fastify.get( + "/assets/:id", + { + schema: { tags: ["Assets"], summary: "Get Asset Card Detail" }, + preHandler: fastify.authenticate, + }, + async (request, reply) => { + ensureAssetReadPermission(fastify, request); + const assetId = parseUuid("id", request.params?.id); + const asset = await fastify.prisma.assetCard.findUnique({ + where: { id: assetId }, + select: ASSET_CARD_SELECT, + }); + if (!asset) { + throw buildValidationError({ id: "not_found" }, 404); + } + ensureAssetViewerOwnsRecord(fastify, request, asset); + return sendData(reply, request, { asset: mapAsset(asset, request, fastify) }); + }, + ); + + fastify.patch( + "/assets/:id", + { + schema: { tags: ["Assets"], summary: "Update Asset Card" }, + preHandler: fastify.requirePermission("asset.manage"), + }, + async (request, reply) => { + const assetId = parseUuid("id", request.params?.id); + const payload = normalizeAssetPayload(parsePatchBody(ASSET_PATCH_SCHEMA, request.body)); + + const asset = await fastify.prisma.$transaction(async (tx) => { + const current = await tx.assetCard.findUnique({ + where: { id: assetId }, + select: { + id: true, + asset_no: true, + name: true, + category_id: true, + legal_entity_id: true, + cost_center_id: true, + purchase_request_id: true, + serial_no: true, + location: true, + status: true, + assignee_employee_id: true, + activated_at: true, + retired_at: true, + }, + }); + if (!current) { + throw buildValidationError({ id: "not_found" }, 404); + } + + const nextCategoryId = payload.categoryId ?? current.category_id; + const nextLegalEntityId = payload.legalEntityId ?? current.legal_entity_id; + const nextCostCenterId = payload.costCenterId ?? current.cost_center_id; + const nextAssigneeId = payload.assigneeEmployeeId === undefined ? current.assignee_employee_id : payload.assigneeEmployeeId; + + if (payload.categoryId !== undefined) { + await ensureAssetCategoryExists(tx, nextCategoryId, { requireActive: true }); + } + if (payload.legalEntityId !== undefined) { + await ensureLegalEntityAvailable(tx, nextLegalEntityId); + } + if (payload.costCenterId !== undefined || payload.legalEntityId !== undefined) { + await ensureCostCenterAvailable(tx, nextCostCenterId, nextLegalEntityId); + } + if (payload.assigneeEmployeeId !== undefined) { + await ensureAssigneeEmployee(tx, nextAssigneeId); + } + + const updated = await tx.assetCard.update({ + where: { id: assetId }, + data: { + ...(payload.name !== undefined ? { name: payload.name } : {}), + ...(payload.categoryId !== undefined ? { category_id: payload.categoryId } : {}), + ...(payload.legalEntityId !== undefined ? { legal_entity_id: payload.legalEntityId } : {}), + ...(payload.costCenterId !== undefined ? { cost_center_id: payload.costCenterId } : {}), + ...(payload.serialNo !== undefined ? { serial_no: payload.serialNo } : {}), + ...(payload.location !== undefined ? { location: payload.location } : {}), + ...(payload.status !== undefined ? { status: payload.status } : {}), + ...(payload.assigneeEmployeeId !== undefined ? { assignee_employee_id: payload.assigneeEmployeeId } : {}), + ...(payload.activatedAt !== undefined + ? { activated_at: payload.activatedAt ? parseDateOnlyString(payload.activatedAt) : null } + : {}), + ...(payload.retiredAt !== undefined + ? { retired_at: payload.retiredAt ? parseDateOnlyString(payload.retiredAt) : null } + : {}), + updated_by: request.employee.id, + }, + select: ASSET_CARD_SELECT, + }); + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "ASSET_CARD_UPDATED", + targetType: "asset_card", + targetId: updated.id, + extra: { + assetNo: current.asset_no, + status: updated.status, + assigneeEmployeeId: updated.assignee_employee_id ?? null, + }, + }), + }); + + return updated; + }); + + return sendData(reply, request, { asset: mapAsset(asset, request, fastify) }); + }, + ); +} + +module.exports = assetsRoutes;