feat(P18B-3,P18B-4): 实现资产盘点页面与流转前端入口
这个提交包含在:
+41
-3
@@ -106,7 +106,7 @@ export type AssetListQuery = {
|
||||
mine?: boolean;
|
||||
};
|
||||
|
||||
export type AssetPayload = {
|
||||
export type CreateAssetPayload = {
|
||||
name: string;
|
||||
categoryId: string;
|
||||
legalEntityId: string;
|
||||
@@ -120,6 +120,20 @@ export type AssetPayload = {
|
||||
retiredAt?: string | null;
|
||||
};
|
||||
|
||||
export type PatchAssetPayload = Partial<
|
||||
Omit<CreateAssetPayload, "purchaseRequestId" | "assigneeEmployeeId">
|
||||
>;
|
||||
|
||||
export type AssetMovementResponse = {
|
||||
assetId: string;
|
||||
assetNo: string;
|
||||
status: AssetStatus;
|
||||
assigneeEmployeeId: string | null;
|
||||
location: string | null;
|
||||
assignmentId?: string | null;
|
||||
transferId?: string | null;
|
||||
};
|
||||
|
||||
function toQueryString(query: Record<string, string | boolean | undefined>) {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
@@ -181,16 +195,40 @@ export async function fetchAssetDetail(assetId: string) {
|
||||
return apiRequestData<{ asset: AssetItem }>(`/api/assets/${assetId}`);
|
||||
}
|
||||
|
||||
export async function createAsset(payload: AssetPayload) {
|
||||
export async function createAsset(payload: CreateAssetPayload) {
|
||||
return apiRequestData<{ asset: AssetItem }>("/api/assets", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function patchAsset(assetId: string, payload: Partial<Omit<AssetPayload, "purchaseRequestId">>) {
|
||||
export async function patchAsset(assetId: string, payload: PatchAssetPayload) {
|
||||
return apiRequestData<{ asset: AssetItem }>(`/api/assets/${assetId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function assignAsset(assetId: string, payload: { employeeId: string; note?: string | null }) {
|
||||
return apiRequestData<AssetMovementResponse>(`/api/assets/${assetId}/assign`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function returnAsset(assetId: string, payload: { note?: string | null } = {}) {
|
||||
return apiRequestData<AssetMovementResponse>(`/api/assets/${assetId}/return`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function transferAsset(
|
||||
assetId: string,
|
||||
payload: { toEmployeeId?: string | null; toLocation?: string | null; note?: string | null },
|
||||
) {
|
||||
return apiRequestData<AssetMovementResponse>(`/api/assets/${assetId}/transfer`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { apiRequestData } from "./http";
|
||||
import type { AssetStatus } from "./assets";
|
||||
|
||||
export type InventoryCheckScopeType = "all" | "legal_entity" | "cost_center" | "location";
|
||||
export type InventoryCheckStatus = "draft" | "counting" | "completed";
|
||||
export type InventoryCheckResult = "matched" | "missing" | "extra" | "damaged";
|
||||
|
||||
export type InventoryCheckItem = {
|
||||
id: string;
|
||||
assetId: string;
|
||||
result: InventoryCheckResult;
|
||||
note: string | null;
|
||||
asset: {
|
||||
id: string;
|
||||
assetNo: string;
|
||||
name: string;
|
||||
status: AssetStatus;
|
||||
legalEntityId: string;
|
||||
costCenterId: string;
|
||||
location: string | null;
|
||||
assigneeEmployeeId: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type InventoryCheck = {
|
||||
id: string;
|
||||
checkNo: string;
|
||||
scopeType: InventoryCheckScopeType;
|
||||
scopeRefId: string | null;
|
||||
status: InventoryCheckStatus;
|
||||
startedAt: string | null;
|
||||
completedAt: string | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
items: InventoryCheckItem[];
|
||||
};
|
||||
|
||||
function toQueryString(query: Record<string, string | undefined>) {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (!value) {
|
||||
continue;
|
||||
}
|
||||
params.set(key, value);
|
||||
}
|
||||
const encoded = params.toString();
|
||||
return encoded ? `?${encoded}` : "";
|
||||
}
|
||||
|
||||
export async function fetchInventoryChecks(params: { status?: InventoryCheckStatus; scopeType?: InventoryCheckScopeType } = {}) {
|
||||
return apiRequestData<{ items: InventoryCheck[] }>(
|
||||
`/api/inventory-checks${toQueryString(params as Record<string, string | undefined>)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function createInventoryCheck(payload: { scopeType: InventoryCheckScopeType; scopeRefId?: string | null }) {
|
||||
return apiRequestData<{ inventoryCheck: InventoryCheck }>("/api/inventory-checks", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function startInventoryCheck(inventoryCheckId: string) {
|
||||
return apiRequestData<{ inventoryCheck: InventoryCheck }>(`/api/inventory-checks/${inventoryCheckId}/start`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export async function recordInventoryItems(
|
||||
inventoryCheckId: string,
|
||||
payload: { items: Array<{ assetId: string; result: InventoryCheckResult; note?: string | null }> },
|
||||
) {
|
||||
return apiRequestData<{ inventoryCheck: InventoryCheck }>(`/api/inventory-checks/${inventoryCheckId}/items`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function completeInventoryCheck(inventoryCheckId: string) {
|
||||
return apiRequestData<{ inventoryCheck: InventoryCheck }>(`/api/inventory-checks/${inventoryCheckId}/complete`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
@@ -42,6 +42,12 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
icon: "inventory_2",
|
||||
anyOf: ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"],
|
||||
},
|
||||
{
|
||||
to: "/inventory-checks",
|
||||
label: "资产盘点",
|
||||
icon: "fact_check",
|
||||
anyOf: ["asset.inventory"],
|
||||
},
|
||||
{
|
||||
to: "/invoice-pool",
|
||||
label: "票据中心",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import {
|
||||
assignAsset,
|
||||
createAsset,
|
||||
createAssetCategory,
|
||||
fetchAssetAssignees,
|
||||
@@ -11,6 +12,8 @@ import {
|
||||
fetchAssets,
|
||||
patchAsset,
|
||||
patchAssetCategory,
|
||||
returnAsset,
|
||||
transferAsset,
|
||||
type AssetCategoryItem,
|
||||
type AssetStatus,
|
||||
} from "../api/assets";
|
||||
@@ -77,6 +80,7 @@ export function AssetRegistryPage() {
|
||||
const { permissions } = useAuth();
|
||||
|
||||
const canManage = hasPermission(permissions, "asset.manage");
|
||||
const canTransfer = hasPermission(permissions, "asset.transfer");
|
||||
const canViewAll = hasAnyPermission(permissions, ["asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"]);
|
||||
const canReadProcurementSources = canManage && hasAnyPermission(permissions, ["procurement.view_all", "procurement.manage"]);
|
||||
|
||||
@@ -94,6 +98,10 @@ export function AssetRegistryPage() {
|
||||
const [isCreatingAsset, setIsCreatingAsset] = useState(false);
|
||||
const [assetForm, setAssetForm] = useState(initialAssetForm);
|
||||
const [assetFormError, setAssetFormError] = useState("");
|
||||
const [movementEmployeeId, setMovementEmployeeId] = useState("");
|
||||
const [movementLocation, setMovementLocation] = useState("");
|
||||
const [movementNote, setMovementNote] = useState("");
|
||||
const [movementError, setMovementError] = useState("");
|
||||
|
||||
const categoriesQuery = useQuery({
|
||||
queryKey: ["assets", "categories", canManage],
|
||||
@@ -182,6 +190,10 @@ export function AssetRegistryPage() {
|
||||
activatedAt: asset.activatedAt ?? "",
|
||||
retiredAt: asset.retiredAt ?? "",
|
||||
});
|
||||
setMovementEmployeeId("");
|
||||
setMovementLocation(asset.location ?? "");
|
||||
setMovementNote("");
|
||||
setMovementError("");
|
||||
setAssetFormError("");
|
||||
}, [assetDetailQuery.data]);
|
||||
|
||||
@@ -224,7 +236,7 @@ export function AssetRegistryPage() {
|
||||
setAssetFormError("");
|
||||
}
|
||||
|
||||
function buildAssetPayload() {
|
||||
function buildCreateAssetPayload() {
|
||||
return {
|
||||
name: assetForm.name.trim(),
|
||||
categoryId: assetForm.categoryId,
|
||||
@@ -240,6 +252,20 @@ export function AssetRegistryPage() {
|
||||
};
|
||||
}
|
||||
|
||||
function buildPatchAssetPayload() {
|
||||
return {
|
||||
name: assetForm.name.trim(),
|
||||
categoryId: assetForm.categoryId,
|
||||
legalEntityId: assetForm.legalEntityId,
|
||||
costCenterId: assetForm.costCenterId,
|
||||
serialNo: assetForm.serialNo.trim() || null,
|
||||
location: assetForm.location.trim() || null,
|
||||
status: assetForm.status,
|
||||
activatedAt: assetForm.activatedAt || null,
|
||||
retiredAt: assetForm.retiredAt || null,
|
||||
};
|
||||
}
|
||||
|
||||
function validateAssetForm() {
|
||||
if (!assetForm.name.trim()) {
|
||||
throw new Error("请填写资产名称");
|
||||
@@ -284,14 +310,13 @@ export function AssetRegistryPage() {
|
||||
const saveAssetMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
validateAssetForm();
|
||||
const payload = buildAssetPayload();
|
||||
if (isCreatingAsset) {
|
||||
return createAsset(payload);
|
||||
return createAsset(buildCreateAssetPayload());
|
||||
}
|
||||
if (!selectedAssetId) {
|
||||
throw new Error("请先选择资产卡片");
|
||||
}
|
||||
return patchAsset(selectedAssetId, payload);
|
||||
return patchAsset(selectedAssetId, buildPatchAssetPayload());
|
||||
},
|
||||
onSuccess: async (data) => {
|
||||
await Promise.all([
|
||||
@@ -310,6 +335,94 @@ export function AssetRegistryPage() {
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
const assignAssetMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!currentAsset) {
|
||||
throw new Error("请先选择资产卡片");
|
||||
}
|
||||
if (!movementEmployeeId) {
|
||||
throw new Error("请选择领用人");
|
||||
}
|
||||
return assignAsset(currentAsset.id, {
|
||||
employeeId: movementEmployeeId,
|
||||
note: movementNote.trim() || null,
|
||||
});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["assets", "list"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["assets", "detail"] }),
|
||||
]);
|
||||
setMovementEmployeeId("");
|
||||
setMovementNote("");
|
||||
setMovementError("");
|
||||
toast.success("资产已领用");
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = resolveError(error);
|
||||
setMovementError(message);
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const returnAssetMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!currentAsset) {
|
||||
throw new Error("请先选择资产卡片");
|
||||
}
|
||||
return returnAsset(currentAsset.id, { note: movementNote.trim() || null });
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["assets", "list"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["assets", "detail"] }),
|
||||
]);
|
||||
setMovementEmployeeId("");
|
||||
setMovementNote("");
|
||||
setMovementError("");
|
||||
toast.success("资产已归还");
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = resolveError(error);
|
||||
setMovementError(message);
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const transferAssetMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!currentAsset) {
|
||||
throw new Error("请先选择资产卡片");
|
||||
}
|
||||
const nextLocation = movementLocation.trim() || null;
|
||||
const nextEmployeeId = movementEmployeeId || undefined;
|
||||
if (!nextEmployeeId && nextLocation === (currentAsset.location ?? null)) {
|
||||
throw new Error("请至少调整归属人或位置");
|
||||
}
|
||||
return transferAsset(currentAsset.id, {
|
||||
toEmployeeId: nextEmployeeId,
|
||||
toLocation: nextLocation,
|
||||
note: movementNote.trim() || null,
|
||||
});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["assets", "list"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["assets", "detail"] }),
|
||||
]);
|
||||
setMovementEmployeeId("");
|
||||
setMovementNote("");
|
||||
setMovementError("");
|
||||
toast.success("资产调拨已完成");
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = resolveError(error);
|
||||
setMovementError(message);
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const purchaseOptions = useMemo(() => {
|
||||
const items = approvedPurchasesQuery.data?.items ?? [];
|
||||
const currentPurchase = currentAsset?.purchaseRequest;
|
||||
@@ -356,7 +469,7 @@ export function AssetRegistryPage() {
|
||||
subtitle="维护资产分类与资产卡片,支持法体、成本中心和采购来源引用。"
|
||||
actions={
|
||||
canManage ? (
|
||||
<Button type="button" onClick={resetAssetEditor}>
|
||||
<Button type="button" data-testid="asset-create" onClick={resetAssetEditor}>
|
||||
新建资产卡片
|
||||
</Button>
|
||||
) : null
|
||||
@@ -368,7 +481,7 @@ export function AssetRegistryPage() {
|
||||
<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 type="button" data-testid="asset-category-reset" variant="secondary" onClick={() => resetCategoryEditor()}>
|
||||
新建分类
|
||||
</Button>
|
||||
) : null}
|
||||
@@ -377,11 +490,11 @@ export function AssetRegistryPage() {
|
||||
<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 }))} />
|
||||
<Input data-testid="asset-category-code" 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 }))} />
|
||||
<Input data-testid="asset-category-name" 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>
|
||||
@@ -399,7 +512,7 @@ export function AssetRegistryPage() {
|
||||
启用分类
|
||||
</label>
|
||||
{canManage ? (
|
||||
<Button type="button" onClick={() => saveCategoryMutation.mutate()} disabled={saveCategoryMutation.isPending}>
|
||||
<Button data-testid="asset-category-save" type="button" onClick={() => saveCategoryMutation.mutate()} disabled={saveCategoryMutation.isPending}>
|
||||
{saveCategoryMutation.isPending ? "保存中..." : editingCategoryId ? "保存分类" : "创建分类"}
|
||||
</Button>
|
||||
) : null}
|
||||
@@ -511,7 +624,7 @@ export function AssetRegistryPage() {
|
||||
<TableBody>
|
||||
{(assetsQuery.data?.items ?? []).length ? (
|
||||
assetsQuery.data?.items.map((item) => (
|
||||
<TableRow key={item.id} onClick={() => {
|
||||
<TableRow data-testid="asset-row" key={item.id} onClick={() => {
|
||||
setIsCreatingAsset(false);
|
||||
setSelectedAssetId(item.id);
|
||||
}} className={item.id === selectedAssetId && !isCreatingAsset ? "bg-zinc-50" : "cursor-pointer"}>
|
||||
@@ -566,11 +679,11 @@ export function AssetRegistryPage() {
|
||||
<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 }))} />
|
||||
<Input data-testid="asset-name" 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 }))}>
|
||||
<select data-testid="asset-status" 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}
|
||||
@@ -580,7 +693,7 @@ export function AssetRegistryPage() {
|
||||
</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 }))}>
|
||||
<select data-testid="asset-category" 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}>
|
||||
@@ -591,7 +704,7 @@ export function AssetRegistryPage() {
|
||||
</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) => {
|
||||
<select data-testid="asset-legal-entity" className="select-std" value={assetForm.legalEntityId} disabled={!canEditCurrent} onChange={(event) => {
|
||||
const legalEntityId = event.target.value;
|
||||
setAssetForm((current) => ({
|
||||
...current,
|
||||
@@ -611,7 +724,7 @@ export function AssetRegistryPage() {
|
||||
</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 }))}>
|
||||
<select data-testid="asset-cost-center" 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}>
|
||||
@@ -622,7 +735,7 @@ export function AssetRegistryPage() {
|
||||
</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 }))}>
|
||||
<select data-testid="asset-assignee" className="select-std" value={assetForm.assigneeEmployeeId} disabled={!canManage || !isCreatingAsset} onChange={(event) => setAssetForm((current) => ({ ...current, assigneeEmployeeId: event.target.value }))}>
|
||||
<option value="">暂不分配</option>
|
||||
{(assigneesQuery.data?.items ?? []).map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
@@ -644,11 +757,11 @@ export function AssetRegistryPage() {
|
||||
</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 }))} />
|
||||
<Input data-testid="asset-serial-no" 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 }))} />
|
||||
<Input data-testid="asset-location" 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>
|
||||
@@ -660,9 +773,95 @@ export function AssetRegistryPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{currentAsset && canTransfer && !isCreatingAsset ? (
|
||||
<div className="rounded-xl border border-zinc-200 bg-zinc-50 p-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-zinc-900">资产流转</h4>
|
||||
<p className="text-xs text-zinc-500">通过受控动作执行领用、归还和调拨,并写入审计记录。</p>
|
||||
</div>
|
||||
<div className="text-xs text-zinc-500">
|
||||
当前归属人:{currentAsset.assignee?.name ?? "未分配"} · 位置:{currentAsset.location ?? "未设置"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{movementError ? (
|
||||
<p className="mb-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{movementError}</p>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-700">目标归属人</label>
|
||||
<select
|
||||
data-testid="asset-movement-assignee"
|
||||
className="select-std"
|
||||
value={movementEmployeeId}
|
||||
onChange={(event) => setMovementEmployeeId(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>
|
||||
<Input
|
||||
data-testid="asset-movement-location"
|
||||
value={movementLocation}
|
||||
onChange={(event) => setMovementLocation(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-700">备注</label>
|
||||
<Input
|
||||
data-testid="asset-movement-note"
|
||||
value={movementNote}
|
||||
onChange={(event) => setMovementNote(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
data-testid="asset-assign-submit"
|
||||
disabled={assignAssetMutation.isPending || Boolean(currentAsset.assigneeEmployeeId) || !movementEmployeeId}
|
||||
onClick={() => assignAssetMutation.mutate()}
|
||||
>
|
||||
{assignAssetMutation.isPending ? "领用中..." : "资产领用"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
data-testid="asset-return-submit"
|
||||
disabled={returnAssetMutation.isPending || !currentAsset.assigneeEmployeeId}
|
||||
onClick={() => returnAssetMutation.mutate()}
|
||||
>
|
||||
{returnAssetMutation.isPending ? "归还中..." : "资产归还"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
data-testid="asset-transfer-submit"
|
||||
disabled={
|
||||
transferAssetMutation.isPending ||
|
||||
(!movementEmployeeId && movementLocation.trim() === (currentAsset.location ?? ""))
|
||||
}
|
||||
onClick={() => transferAssetMutation.mutate()}
|
||||
>
|
||||
{transferAssetMutation.isPending ? "调拨中..." : "资产调拨"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{canManage ? (
|
||||
<Button type="button" disabled={saveAssetMutation.isPending || !canEditCurrent} onClick={() => saveAssetMutation.mutate()}>
|
||||
<Button type="button" data-testid="asset-save" disabled={saveAssetMutation.isPending || !canEditCurrent} onClick={() => saveAssetMutation.mutate()}>
|
||||
{saveAssetMutation.isPending ? "保存中..." : isCreatingAsset ? "创建资产卡片" : "保存资产卡片"}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { fetchAssets } from "../api/assets";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import {
|
||||
completeInventoryCheck,
|
||||
createInventoryCheck,
|
||||
fetchInventoryChecks,
|
||||
recordInventoryItems,
|
||||
startInventoryCheck,
|
||||
type InventoryCheckResult,
|
||||
type InventoryCheckScopeType,
|
||||
} from "../api/inventoryChecks";
|
||||
import { fetchCostCenters, fetchLegalEntities } from "../api/masterData";
|
||||
import { isApiError } from "../api/types";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
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 scopeLabel(scopeType: InventoryCheckScopeType) {
|
||||
switch (scopeType) {
|
||||
case "all":
|
||||
return "全量盘点";
|
||||
case "legal_entity":
|
||||
return "法体盘点";
|
||||
case "cost_center":
|
||||
return "成本中心盘点";
|
||||
case "location":
|
||||
return "位置盘点";
|
||||
default:
|
||||
return scopeType;
|
||||
}
|
||||
}
|
||||
|
||||
function resultLabel(result: InventoryCheckResult) {
|
||||
switch (result) {
|
||||
case "matched":
|
||||
return "一致";
|
||||
case "missing":
|
||||
return "缺失";
|
||||
case "extra":
|
||||
return "盘盈";
|
||||
case "damaged":
|
||||
return "损坏";
|
||||
default:
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(status: string) {
|
||||
switch (status) {
|
||||
case "draft":
|
||||
return "草稿";
|
||||
case "counting":
|
||||
return "盘点中";
|
||||
case "completed":
|
||||
return "已完成";
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
export function InventoryChecksPage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [scopeType, setScopeType] = useState<InventoryCheckScopeType>("all");
|
||||
const [scopeRefId, setScopeRefId] = useState("");
|
||||
const [selectedCheckId, setSelectedCheckId] = useState("");
|
||||
const [recordAssetId, setRecordAssetId] = useState("");
|
||||
const [recordResult, setRecordResult] = useState<InventoryCheckResult>("matched");
|
||||
const [recordNote, setRecordNote] = useState("");
|
||||
|
||||
const inventoryChecksQuery = useQuery({
|
||||
queryKey: ["inventory-checks"],
|
||||
queryFn: () => fetchInventoryChecks(),
|
||||
});
|
||||
|
||||
const legalEntitiesQuery = useQuery({
|
||||
queryKey: ["inventory-checks", "legal-entities"],
|
||||
queryFn: () => fetchLegalEntities({ enabled: true }),
|
||||
});
|
||||
|
||||
const costCentersQuery = useQuery({
|
||||
queryKey: ["inventory-checks", "cost-centers"],
|
||||
queryFn: () => fetchCostCenters({ enabled: true }),
|
||||
});
|
||||
|
||||
const assetsQuery = useQuery({
|
||||
queryKey: ["inventory-checks", "assets"],
|
||||
queryFn: () => fetchAssets({ mine: false }),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const nextId = inventoryChecksQuery.data?.items?.[0]?.id ?? "";
|
||||
if (!selectedCheckId && nextId) {
|
||||
setSelectedCheckId(nextId);
|
||||
}
|
||||
if (selectedCheckId && inventoryChecksQuery.data?.items?.some((item) => item.id === selectedCheckId)) {
|
||||
return;
|
||||
}
|
||||
if (nextId !== selectedCheckId) {
|
||||
setSelectedCheckId(nextId);
|
||||
}
|
||||
}, [inventoryChecksQuery.data?.items, selectedCheckId]);
|
||||
|
||||
const selectedCheck = useMemo(
|
||||
() => (inventoryChecksQuery.data?.items ?? []).find((item) => item.id === selectedCheckId) ?? null,
|
||||
[inventoryChecksQuery.data?.items, selectedCheckId],
|
||||
);
|
||||
|
||||
const scopedAssets = useMemo(() => {
|
||||
const items = assetsQuery.data?.items ?? [];
|
||||
const scope = selectedCheck ?? { scopeType, scopeRefId: scopeRefId || null };
|
||||
if (scope.scopeType === "all") {
|
||||
return items;
|
||||
}
|
||||
if (scope.scopeType === "legal_entity") {
|
||||
return items.filter((item) => item.legalEntityId === scope.scopeRefId);
|
||||
}
|
||||
if (scope.scopeType === "cost_center") {
|
||||
return items.filter((item) => item.costCenterId === scope.scopeRefId);
|
||||
}
|
||||
return items.filter((item) => (item.location ?? "") === (scope.scopeRefId ?? ""));
|
||||
}, [assetsQuery.data?.items, scopeRefId, scopeType, selectedCheck]);
|
||||
|
||||
const createCheckMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const payload = {
|
||||
scopeType,
|
||||
scopeRefId: scopeType === "all" ? null : scopeRefId || null,
|
||||
};
|
||||
return createInventoryCheck(payload);
|
||||
},
|
||||
onSuccess: async (data) => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["inventory-checks"] });
|
||||
setSelectedCheckId(data.inventoryCheck.id);
|
||||
toast.success("盘点任务已创建");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(resolveError(error));
|
||||
},
|
||||
});
|
||||
|
||||
const startCheckMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!selectedCheck) {
|
||||
throw new Error("请先选择盘点任务");
|
||||
}
|
||||
return startInventoryCheck(selectedCheck.id);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["inventory-checks"] });
|
||||
toast.success("盘点任务已启动");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(resolveError(error));
|
||||
},
|
||||
});
|
||||
|
||||
const recordItemMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!selectedCheck) {
|
||||
throw new Error("请先选择盘点任务");
|
||||
}
|
||||
if (!recordAssetId) {
|
||||
throw new Error("请选择资产卡片");
|
||||
}
|
||||
return recordInventoryItems(selectedCheck.id, {
|
||||
items: [
|
||||
{
|
||||
assetId: recordAssetId,
|
||||
result: recordResult,
|
||||
note: recordNote.trim() || null,
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["inventory-checks"] });
|
||||
setRecordAssetId("");
|
||||
setRecordNote("");
|
||||
toast.success("盘点结果已记录");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(resolveError(error));
|
||||
},
|
||||
});
|
||||
|
||||
const completeCheckMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!selectedCheck) {
|
||||
throw new Error("请先选择盘点任务");
|
||||
}
|
||||
return completeInventoryCheck(selectedCheck.id);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["inventory-checks"] });
|
||||
toast.success("盘点任务已完成");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(resolveError(error));
|
||||
},
|
||||
});
|
||||
|
||||
const scopeOptions = useMemo(() => {
|
||||
if (scopeType === "legal_entity") {
|
||||
return (legalEntitiesQuery.data?.items ?? []).map((item) => ({ id: item.id, label: item.name }));
|
||||
}
|
||||
if (scopeType === "cost_center") {
|
||||
return (costCentersQuery.data?.items ?? []).map((item) => ({ id: item.id, label: item.name }));
|
||||
}
|
||||
return [];
|
||||
}, [costCentersQuery.data?.items, legalEntitiesQuery.data?.items, scopeType]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="资产盘点" subtitle="创建、启动、记录并完成资产盘点任务。" />
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[360px_minmax(0,1fr)]">
|
||||
<div className="card-std space-y-4 p-6">
|
||||
<h3 className="text-base font-semibold text-zinc-900">新建盘点任务</h3>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-700">盘点范围</label>
|
||||
<select data-testid="inventory-scope-type" className="select-std" value={scopeType} onChange={(event) => {
|
||||
setScopeType(event.target.value as InventoryCheckScopeType);
|
||||
setScopeRefId("");
|
||||
}}>
|
||||
<option value="all">全量盘点</option>
|
||||
<option value="legal_entity">法体盘点</option>
|
||||
<option value="cost_center">成本中心盘点</option>
|
||||
<option value="location">位置盘点</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{scopeType === "location" ? (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-700">位置</label>
|
||||
<Input data-testid="inventory-scope-ref" value={scopeRefId} onChange={(event) => setScopeRefId(event.target.value)} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{scopeType !== "all" && scopeType !== "location" ? (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-700">范围对象</label>
|
||||
<select data-testid="inventory-scope-ref" className="select-std" value={scopeRefId} onChange={(event) => setScopeRefId(event.target.value)}>
|
||||
<option value="">请选择</option>
|
||||
{scopeOptions.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Button data-testid="inventory-create" type="button" disabled={createCheckMutation.isPending} onClick={() => createCheckMutation.mutate()}>
|
||||
{createCheckMutation.isPending ? "创建中..." : "创建盘点任务"}
|
||||
</Button>
|
||||
|
||||
<div className="rounded-lg border border-zinc-200">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<tr>
|
||||
<TableHead>单号</TableHead>
|
||||
<TableHead>范围</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
</tr>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(inventoryChecksQuery.data?.items ?? []).map((item) => (
|
||||
<TableRow
|
||||
data-testid="inventory-row"
|
||||
key={item.id}
|
||||
className={item.id === selectedCheckId ? "bg-zinc-50 cursor-pointer" : "cursor-pointer"}
|
||||
onClick={() => setSelectedCheckId(item.id)}
|
||||
>
|
||||
<TableCell className="font-medium text-zinc-900">{item.checkNo}</TableCell>
|
||||
<TableCell>{scopeLabel(item.scopeType)}</TableCell>
|
||||
<TableCell>{statusLabel(item.status)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{!inventoryChecksQuery.isLoading && !(inventoryChecksQuery.data?.items ?? []).length ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="py-4 text-zinc-500">
|
||||
暂无盘点任务
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-std space-y-4 p-6">
|
||||
{inventoryChecksQuery.isLoading ? <TableSkeleton columns={3} rows={6} /> : null}
|
||||
{inventoryChecksQuery.isError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{resolveError(inventoryChecksQuery.error)}</p>
|
||||
) : null}
|
||||
{!inventoryChecksQuery.isLoading && !selectedCheck ? (
|
||||
<div className="rounded-lg border border-dashed border-zinc-300 p-8 text-center text-sm text-zinc-500">
|
||||
请先创建或选择盘点任务。
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selectedCheck ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-zinc-900">{selectedCheck.checkNo}</h3>
|
||||
<p className="text-sm text-zinc-500">
|
||||
{scopeLabel(selectedCheck.scopeType)} · {selectedCheck.scopeRefId ?? "全量"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span data-testid="inventory-selected-status" className="rounded-full bg-zinc-100 px-3 py-1 text-xs text-zinc-700">{statusLabel(selectedCheck.status)}</span>
|
||||
{selectedCheck.status === "draft" ? (
|
||||
<Button data-testid="inventory-start" type="button" onClick={() => startCheckMutation.mutate()} disabled={startCheckMutation.isPending}>
|
||||
{startCheckMutation.isPending ? "启动中..." : "启动盘点"}
|
||||
</Button>
|
||||
) : null}
|
||||
{selectedCheck.status === "counting" ? (
|
||||
<Button data-testid="inventory-complete" type="button" variant="secondary" onClick={() => completeCheckMutation.mutate()} disabled={completeCheckMutation.isPending}>
|
||||
{completeCheckMutation.isPending ? "完成中..." : "完成盘点"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedCheck.status === "counting" ? (
|
||||
<div className="rounded-xl border border-zinc-200 bg-zinc-50 p-4">
|
||||
<h4 className="mb-3 text-sm font-semibold text-zinc-900">记录盘点结果</h4>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-700">资产卡片</label>
|
||||
<select data-testid="inventory-item-asset" className="select-std" value={recordAssetId} onChange={(event) => setRecordAssetId(event.target.value)}>
|
||||
<option value="">请选择资产</option>
|
||||
{scopedAssets.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.assetNo} · {item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-700">盘点结果</label>
|
||||
<select data-testid="inventory-item-result" className="select-std" value={recordResult} onChange={(event) => setRecordResult(event.target.value as InventoryCheckResult)}>
|
||||
<option value="matched">一致</option>
|
||||
<option value="missing">缺失</option>
|
||||
<option value="extra">盘盈</option>
|
||||
<option value="damaged">损坏</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-700">备注</label>
|
||||
<Input data-testid="inventory-item-note" value={recordNote} onChange={(event) => setRecordNote(event.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Button data-testid="inventory-item-save" type="button" onClick={() => recordItemMutation.mutate()} disabled={recordItemMutation.isPending}>
|
||||
{recordItemMutation.isPending ? "记录中..." : "记录盘点结果"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-lg border border-zinc-200">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<tr>
|
||||
<TableHead>资产</TableHead>
|
||||
<TableHead>结果</TableHead>
|
||||
<TableHead>备注</TableHead>
|
||||
</tr>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{selectedCheck.items.length ? (
|
||||
selectedCheck.items.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium text-zinc-900">{item.asset?.assetNo ?? item.assetId}</div>
|
||||
<div className="text-xs text-zinc-500">{item.asset?.name ?? "未知资产"}</div>
|
||||
</TableCell>
|
||||
<TableCell>{resultLabel(item.result)}</TableCell>
|
||||
<TableCell>{item.note ?? "-"}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="py-4 text-zinc-500">
|
||||
暂无盘点结果
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { LeaveRequestPage } from "../pages/LeaveRequestPage";
|
||||
import { NotificationsPage } from "../pages/NotificationsPage";
|
||||
import { PurchaseRequestsPage } from "../pages/PurchaseRequestsPage";
|
||||
import { AssetRegistryPage } from "../pages/AssetRegistryPage";
|
||||
import { InventoryChecksPage } from "../pages/InventoryChecksPage";
|
||||
import { NotFoundPage } from "../pages/NotFoundPage";
|
||||
import { ProfilePage } from "../pages/ProfilePage";
|
||||
import { ReimbursementDetailPage } from "../pages/ReimbursementDetailPage";
|
||||
@@ -118,6 +119,15 @@ export function AppRouter() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="inventory-checks"
|
||||
element={
|
||||
<RequirePermission anyOf={["asset.inventory"]}>
|
||||
<InventoryChecksPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="workflow/tasks"
|
||||
element={
|
||||
|
||||
在新工单中引用
屏蔽一个用户