feat(P18A-3,P18A-4): 实现资产台账接口与页面入口
这个提交包含在:
@@ -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<string, string | 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") : value);
|
||||
}
|
||||
const encoded = params.toString();
|
||||
return encoded ? `?${encoded}` : "";
|
||||
}
|
||||
|
||||
export async function fetchAssetCategories(params: { includeArchived?: boolean; enabled?: boolean } = {}) {
|
||||
return apiRequestData<AssetCategoryListResponse>(
|
||||
`/api/asset-categories${toQueryString(params as Record<string, string | boolean | undefined>)}`,
|
||||
);
|
||||
}
|
||||
|
||||
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<string, string | boolean | undefined>)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchAssets(params: AssetListQuery = {}) {
|
||||
return apiRequestData<AssetListResponse>(
|
||||
`/api/assets${toQueryString(params as Record<string, string | boolean | undefined>)}`,
|
||||
);
|
||||
}
|
||||
|
||||
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<Omit<AssetPayload, "purchaseRequestId">>) {
|
||||
return apiRequestData<{ asset: AssetItem }>(`/api/assets/${assetId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
@@ -11,6 +11,10 @@ const STATUS_MAP: Record<string, { label: string; className: string }> = {
|
||||
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" },
|
||||
|
||||
@@ -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: "票据中心",
|
||||
|
||||
@@ -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<AssetStatus | "">("");
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="资产台账"
|
||||
subtitle="维护资产分类与资产卡片,支持法体、成本中心和采购来源引用。"
|
||||
actions={
|
||||
canManage ? (
|
||||
<Button type="button" onClick={resetAssetEditor}>
|
||||
新建资产卡片
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[380px_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>
|
||||
{canManage ? (
|
||||
<Button type="button" variant="secondary" onClick={() => resetCategoryEditor()}>
|
||||
新建分类
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-700">分类编码</label>
|
||||
<Input value={categoryForm.code} disabled={!canManage} onChange={(event) => setCategoryForm((current) => ({ ...current, code: event.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-700">分类名称</label>
|
||||
<Input value={categoryForm.name} disabled={!canManage} onChange={(event) => setCategoryForm((current) => ({ ...current, name: event.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-700">单据分类</label>
|
||||
<select className="select-std" disabled={!canManage} value={categoryForm.documentCategoryId} onChange={(event) => setCategoryForm((current) => ({ ...current, documentCategoryId: event.target.value }))}>
|
||||
<option value="">使用系统默认</option>
|
||||
{(documentCategoriesQuery.data?.items ?? []).map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name} ({item.code})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-zinc-700">
|
||||
<input type="checkbox" className="h-4 w-4 rounded border-zinc-300" checked={categoryForm.enabled} disabled={!canManage} onChange={(event) => setCategoryForm((current) => ({ ...current, enabled: event.target.checked }))} />
|
||||
启用分类
|
||||
</label>
|
||||
{canManage ? (
|
||||
<Button type="button" onClick={() => saveCategoryMutation.mutate()} disabled={saveCategoryMutation.isPending}>
|
||||
{saveCategoryMutation.isPending ? "保存中..." : editingCategoryId ? "保存分类" : "创建分类"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{categoriesQuery.isLoading ? <TableSkeleton columns={3} rows={5} /> : null}
|
||||
{categoriesQuery.isError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{resolveError(categoriesQuery.error)}</p>
|
||||
) : null}
|
||||
{!categoriesQuery.isLoading && !categoriesQuery.isError ? (
|
||||
<div className="rounded-lg border border-zinc-200">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<tr>
|
||||
<TableHead>编码</TableHead>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
</tr>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(categoriesQuery.data?.items ?? []).map((item) => (
|
||||
<TableRow key={item.id} onClick={() => resetCategoryEditor(item)} className="cursor-pointer">
|
||||
<TableCell className="font-medium text-zinc-900">{item.code}</TableCell>
|
||||
<TableCell>
|
||||
<div>{item.name}</div>
|
||||
<div className="text-xs text-zinc-500">{item.documentCategory?.name ?? "系统默认"}</div>
|
||||
</TableCell>
|
||||
<TableCell>{item.enabled ? "启用" : "停用"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="card-std space-y-4 p-6">
|
||||
<div className="grid gap-3 md:grid-cols-3 xl:grid-cols-6">
|
||||
<select className="select-std" value={statusFilter} onChange={(event) => setStatusFilter(event.target.value as AssetStatus | "") }>
|
||||
{assetStatusOptions().map((item) => (
|
||||
<option key={item.value || "all"} value={item.value}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select className="select-std" value={categoryFilter} onChange={(event) => setCategoryFilter(event.target.value)}>
|
||||
<option value="">全部分类</option>
|
||||
{(categoriesQuery.data?.items ?? []).map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select className="select-std" value={legalEntityFilter} onChange={(event) => {
|
||||
setLegalEntityFilter(event.target.value);
|
||||
setCostCenterFilter("");
|
||||
}}>
|
||||
<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)}>
|
||||
<option value="">全部成本中心</option>
|
||||
{filteredFilterCostCenters.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select className="select-std" value={assigneeFilter} onChange={(event) => setAssigneeFilter(event.target.value)} disabled={!canViewAll}>
|
||||
<option value="">全部归属人</option>
|
||||
{(assigneesQuery.data?.items ?? []).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)}>
|
||||
仅自己
|
||||
</Button>
|
||||
<Button type="button" variant={!mine ? "default" : "secondary"} onClick={() => setMine(false)}>
|
||||
全范围
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{assetsQuery.isLoading ? <TableSkeleton columns={4} rows={6} /> : null}
|
||||
{assetsQuery.isError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{resolveError(assetsQuery.error)}</p>
|
||||
) : null}
|
||||
{!assetsQuery.isLoading && !assetsQuery.isError ? (
|
||||
<div className="rounded-lg border border-zinc-200">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<tr>
|
||||
<TableHead>资产</TableHead>
|
||||
<TableHead>分类</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>归属人</TableHead>
|
||||
</tr>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(assetsQuery.data?.items ?? []).length ? (
|
||||
assetsQuery.data?.items.map((item) => (
|
||||
<TableRow key={item.id} onClick={() => {
|
||||
setIsCreatingAsset(false);
|
||||
setSelectedAssetId(item.id);
|
||||
}} className={item.id === selectedAssetId && !isCreatingAsset ? "bg-zinc-50" : "cursor-pointer"}>
|
||||
<TableCell>
|
||||
<div className="font-medium text-zinc-900">{item.assetNo}</div>
|
||||
<div className="text-xs text-zinc-500">{item.name}</div>
|
||||
</TableCell>
|
||||
<TableCell>{item.category?.name ?? "-"}</TableCell>
|
||||
<TableCell><StatusBadge status={item.status} /></TableCell>
|
||||
<TableCell>{item.assignee?.name ?? "-"}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="py-4 text-zinc-500">
|
||||
暂无资产卡片
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="card-std space-y-4 p-6">
|
||||
{!selectedAssetId && !isCreatingAsset ? (
|
||||
<div className="rounded-lg border border-dashed border-zinc-300 p-8 text-center text-sm text-zinc-500">
|
||||
请先从列表选择资产卡片,或点击“新建资产卡片”。
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selectedAssetId && !isCreatingAsset && assetDetailQuery.isLoading ? <TableSkeleton columns={2} rows={8} /> : null}
|
||||
{selectedAssetId && !isCreatingAsset && assetDetailQuery.isError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{resolveError(assetDetailQuery.error)}</p>
|
||||
) : null}
|
||||
|
||||
{(isCreatingAsset || currentAsset) && !assetDetailQuery.isLoading ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-zinc-900">{isCreatingAsset ? "新建资产卡片" : currentAsset?.assetNo ?? "资产卡片"}</h3>
|
||||
<p className="text-sm text-zinc-500">{isCreatingAsset ? "创建新的资产台账卡片。" : currentAsset?.name}</p>
|
||||
</div>
|
||||
{!isCreatingAsset && currentAsset ? <StatusBadge status={currentAsset.status} /> : null}
|
||||
</div>
|
||||
|
||||
{assetFormError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{assetFormError}</p>
|
||||
) : null}
|
||||
|
||||
<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={assetForm.name} disabled={!canEditCurrent} onChange={(event) => setAssetForm((current) => ({ ...current, name: event.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-700">资产状态</label>
|
||||
<select className="select-std" value={assetForm.status} disabled={!canEditCurrent} onChange={(event) => setAssetForm((current) => ({ ...current, status: event.target.value as AssetStatus }))}>
|
||||
{assetStatusOptions().filter((item) => item.value).map((item) => (
|
||||
<option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-700">资产分类</label>
|
||||
<select className="select-std" value={assetForm.categoryId} disabled={!canEditCurrent} onChange={(event) => setAssetForm((current) => ({ ...current, categoryId: event.target.value }))}>
|
||||
<option value="">请选择资产分类</option>
|
||||
{(categoriesQuery.data?.items ?? []).filter((item) => item.enabled).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={assetForm.legalEntityId} disabled={!canEditCurrent} onChange={(event) => {
|
||||
const legalEntityId = event.target.value;
|
||||
setAssetForm((current) => ({
|
||||
...current,
|
||||
legalEntityId,
|
||||
costCenterId: current.costCenterId && filteredAssetCostCenters.some((item) => item.id === current.costCenterId && item.legalEntityId === legalEntityId)
|
||||
? current.costCenterId
|
||||
: "",
|
||||
}));
|
||||
}}>
|
||||
<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={assetForm.costCenterId} disabled={!canEditCurrent} onChange={(event) => setAssetForm((current) => ({ ...current, costCenterId: event.target.value }))}>
|
||||
<option value="">请选择成本中心</option>
|
||||
{filteredAssetCostCenters.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={assetForm.assigneeEmployeeId} disabled={!canEditCurrent || !canManage} onChange={(event) => setAssetForm((current) => ({ ...current, assigneeEmployeeId: event.target.value }))}>
|
||||
<option value="">暂不分配</option>
|
||||
{(assigneesQuery.data?.items ?? []).map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name} ({item.employeeNo})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-700">采购来源</label>
|
||||
<select className="select-std" value={assetForm.purchaseRequestId} disabled={!canEditCurrent || !canReadProcurementSources || !isCreatingAsset} onChange={(event) => setAssetForm((current) => ({ ...current, purchaseRequestId: event.target.value }))}>
|
||||
<option value="">无采购来源</option>
|
||||
{purchaseOptions.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{(item.requestNo ?? item.id)} · {item.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-700">序列号</label>
|
||||
<Input value={assetForm.serialNo} disabled={!canEditCurrent} onChange={(event) => setAssetForm((current) => ({ ...current, serialNo: event.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-700">存放位置</label>
|
||||
<Input value={assetForm.location} disabled={!canEditCurrent} onChange={(event) => setAssetForm((current) => ({ ...current, location: event.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-700">启用日期</label>
|
||||
<Input type="date" value={assetForm.activatedAt} disabled={!canEditCurrent} onChange={(event) => setAssetForm((current) => ({ ...current, activatedAt: event.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-700">退役日期</label>
|
||||
<Input type="date" value={assetForm.retiredAt} disabled={!canEditCurrent} onChange={(event) => setAssetForm((current) => ({ ...current, retiredAt: event.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{canManage ? (
|
||||
<Button type="button" disabled={saveAssetMutation.isPending || !canEditCurrent} onClick={() => saveAssetMutation.mutate()}>
|
||||
{saveAssetMutation.isPending ? "保存中..." : isCreatingAsset ? "创建资产卡片" : "保存资产卡片"}
|
||||
</Button>
|
||||
) : null}
|
||||
{currentAsset?.purchaseRequest ? (
|
||||
<span className="text-sm text-zinc-500">采购来源:{currentAsset.purchaseRequest.requestNo ?? currentAsset.purchaseRequest.id}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="assets"
|
||||
element={
|
||||
<RequirePermission anyOf={["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"]}>
|
||||
<AssetRegistryPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="workflow/tasks"
|
||||
element={
|
||||
|
||||
@@ -39,6 +39,7 @@ const exportsRoutes = require("./routes/exports");
|
||||
const dashboardRoutes = require("./routes/dashboard");
|
||||
const budgetsRoutes = require("./routes/budgets");
|
||||
const purchaseRequestRoutes = require("./routes/purchaseRequests");
|
||||
const assetsRoutes = require("./routes/assets");
|
||||
const integrationsRoutes = require("./routes/integrations");
|
||||
const travelOrdersRoutes = require("./routes/travelOrders");
|
||||
const { startJobs } = require("./jobs/jobSystem");
|
||||
@@ -297,6 +298,7 @@ async function buildApp() {
|
||||
await app.register(dashboardRoutes, { prefix: "/api" });
|
||||
await app.register(budgetsRoutes, { prefix: "/api" });
|
||||
await app.register(purchaseRequestRoutes, { prefix: "/api" });
|
||||
await app.register(assetsRoutes, { prefix: "/api" });
|
||||
await app.register(integrationsRoutes, { prefix: "/api" });
|
||||
await app.register(travelOrdersRoutes, { prefix: "/api" });
|
||||
await app.register(exportsRoutes, { prefix: "/api" });
|
||||
|
||||
@@ -0,0 +1,956 @@
|
||||
const { z } = require("zod");
|
||||
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { buildAuditLogData } = require("../utils/audit");
|
||||
const { parseDateOnlyString } = require("../utils/date");
|
||||
const { issueDocumentNumber } = require("../services/documentSequence");
|
||||
const { sendData } = require("../utils/reply");
|
||||
|
||||
const UUID_SCHEMA = z.string().uuid();
|
||||
const BOOLEAN_QUERY_SCHEMA = z.enum(["0", "1", "true", "false"]).optional();
|
||||
const DATE_ONLY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
||||
const ASSET_STATUS_VALUES = ["in_stock", "in_use", "under_repair", "retired", "archived"];
|
||||
|
||||
const CATEGORY_LIST_QUERY_SCHEMA = z.object({
|
||||
includeArchived: BOOLEAN_QUERY_SCHEMA,
|
||||
enabled: BOOLEAN_QUERY_SCHEMA,
|
||||
});
|
||||
|
||||
const ASSET_LIST_QUERY_SCHEMA = z.object({
|
||||
status: z.enum(ASSET_STATUS_VALUES).optional(),
|
||||
categoryId: UUID_SCHEMA.optional(),
|
||||
assigneeId: UUID_SCHEMA.optional(),
|
||||
legalEntityId: UUID_SCHEMA.optional(),
|
||||
costCenterId: UUID_SCHEMA.optional(),
|
||||
mine: BOOLEAN_QUERY_SCHEMA,
|
||||
});
|
||||
|
||||
const ASSIGNEE_LIST_QUERY_SCHEMA = z.object({
|
||||
q: z.string().trim().max(100).optional(),
|
||||
});
|
||||
|
||||
const ASSET_CATEGORY_CREATE_SCHEMA = z
|
||||
.object({
|
||||
code: z.string().trim().min(2).max(64),
|
||||
name: z.string().trim().min(1).max(120),
|
||||
documentCategoryId: UUID_SCHEMA.nullable().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const ASSET_CATEGORY_PATCH_SCHEMA = ASSET_CATEGORY_CREATE_SCHEMA.partial();
|
||||
|
||||
const ASSET_CREATE_SCHEMA = z
|
||||
.object({
|
||||
name: z.string().trim().min(1).max(200),
|
||||
categoryId: UUID_SCHEMA,
|
||||
legalEntityId: UUID_SCHEMA,
|
||||
costCenterId: UUID_SCHEMA,
|
||||
purchaseRequestId: UUID_SCHEMA.nullable().optional(),
|
||||
serialNo: z.string().trim().max(120).nullable().optional(),
|
||||
location: z.string().trim().max(200).nullable().optional(),
|
||||
status: z.enum(ASSET_STATUS_VALUES).optional(),
|
||||
assigneeEmployeeId: UUID_SCHEMA.nullable().optional(),
|
||||
activatedAt: DATE_ONLY_SCHEMA.nullable().optional(),
|
||||
retiredAt: DATE_ONLY_SCHEMA.nullable().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const ASSET_PATCH_SCHEMA = ASSET_CREATE_SCHEMA.omit({ purchaseRequestId: true }).partial();
|
||||
|
||||
const ASSET_CATEGORY_SELECT = {
|
||||
id: true,
|
||||
code: true,
|
||||
name: true,
|
||||
document_category_id: true,
|
||||
enabled: true,
|
||||
archived_at: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
document_category: {
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
name: true,
|
||||
entity_type: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const ASSET_CARD_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,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
category: {
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
legal_entity: {
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
cost_center: {
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
name: true,
|
||||
legal_entity_id: true,
|
||||
},
|
||||
},
|
||||
purchase_request: {
|
||||
select: {
|
||||
id: true,
|
||||
request_no: true,
|
||||
title: true,
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
assignee: {
|
||||
select: {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
department: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function buildValidationError(details, statusCode = 400) {
|
||||
return new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode,
|
||||
details,
|
||||
});
|
||||
}
|
||||
|
||||
function parseBooleanQueryValue(rawValue) {
|
||||
if (rawValue == null) {
|
||||
return null;
|
||||
}
|
||||
return rawValue === "1" || rawValue === "true";
|
||||
}
|
||||
|
||||
function parseUuid(fieldName, value) {
|
||||
const parsed = UUID_SCHEMA.safeParse(value);
|
||||
if (!parsed.success) {
|
||||
throw buildValidationError({ [fieldName]: "invalid_uuid" });
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function parseCategoryListQuery(query) {
|
||||
const parsed = CATEGORY_LIST_QUERY_SCHEMA.safeParse(query ?? {});
|
||||
if (!parsed.success) {
|
||||
throw buildValidationError(parsed.error.flatten());
|
||||
}
|
||||
return {
|
||||
includeArchived: parseBooleanQueryValue(parsed.data.includeArchived) ?? false,
|
||||
enabled: parseBooleanQueryValue(parsed.data.enabled),
|
||||
};
|
||||
}
|
||||
|
||||
function parseAssetListQuery(query) {
|
||||
const parsed = ASSET_LIST_QUERY_SCHEMA.safeParse(query ?? {});
|
||||
if (!parsed.success) {
|
||||
throw buildValidationError(parsed.error.flatten());
|
||||
}
|
||||
return {
|
||||
status: parsed.data.status,
|
||||
categoryId: parsed.data.categoryId,
|
||||
assigneeId: parsed.data.assigneeId,
|
||||
legalEntityId: parsed.data.legalEntityId,
|
||||
costCenterId: parsed.data.costCenterId,
|
||||
mine: parseBooleanQueryValue(parsed.data.mine),
|
||||
};
|
||||
}
|
||||
|
||||
function parseAssigneeListQuery(query) {
|
||||
const parsed = ASSIGNEE_LIST_QUERY_SCHEMA.safeParse(query ?? {});
|
||||
if (!parsed.success) {
|
||||
throw buildValidationError(parsed.error.flatten());
|
||||
}
|
||||
return { q: parsed.data.q?.trim() ?? "" };
|
||||
}
|
||||
|
||||
function parseCreateBody(schema, body) {
|
||||
const parsed = schema.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
throw buildValidationError(parsed.error.flatten());
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function parsePatchBody(schema, body) {
|
||||
const parsed = schema.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
throw buildValidationError(parsed.error.flatten());
|
||||
}
|
||||
if (!Object.keys(parsed.data).length) {
|
||||
throw buildValidationError({ body: "empty" });
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function normalizeAssetCategoryPayload(body) {
|
||||
return {
|
||||
code: typeof body.code === "string" ? body.code.trim().toUpperCase() : undefined,
|
||||
name: typeof body.name === "string" ? body.name.trim() : undefined,
|
||||
documentCategoryId: Object.prototype.hasOwnProperty.call(body, "documentCategoryId")
|
||||
? body.documentCategoryId ?? null
|
||||
: undefined,
|
||||
enabled: body.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAssetPayload(body) {
|
||||
return {
|
||||
name: typeof body.name === "string" ? body.name.trim() : undefined,
|
||||
categoryId: body.categoryId,
|
||||
legalEntityId: body.legalEntityId,
|
||||
costCenterId: body.costCenterId,
|
||||
purchaseRequestId: Object.prototype.hasOwnProperty.call(body, "purchaseRequestId")
|
||||
? body.purchaseRequestId ?? null
|
||||
: undefined,
|
||||
serialNo: Object.prototype.hasOwnProperty.call(body, "serialNo")
|
||||
? body.serialNo?.trim()
|
||||
? body.serialNo.trim()
|
||||
: null
|
||||
: undefined,
|
||||
location: Object.prototype.hasOwnProperty.call(body, "location")
|
||||
? body.location?.trim()
|
||||
? body.location.trim()
|
||||
: null
|
||||
: undefined,
|
||||
status: body.status,
|
||||
assigneeEmployeeId: Object.prototype.hasOwnProperty.call(body, "assigneeEmployeeId")
|
||||
? body.assigneeEmployeeId ?? null
|
||||
: undefined,
|
||||
activatedAt: Object.prototype.hasOwnProperty.call(body, "activatedAt") ? body.activatedAt ?? null : undefined,
|
||||
retiredAt: Object.prototype.hasOwnProperty.call(body, "retiredAt") ? body.retiredAt ?? null : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function canReadAssets(fastify, employee) {
|
||||
return ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"].some((permission) =>
|
||||
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;
|
||||
在新工单中引用
屏蔽一个用户