feat(P19ERP3-3,P19ERP3-4): 新增资产ERP轨道接口与前端卡片挂件
这个提交包含在:
@@ -134,6 +134,81 @@ export type AssetMovementResponse = {
|
||||
transferId?: string | null;
|
||||
};
|
||||
|
||||
export type AssetErpTrackStatus =
|
||||
| "not_triggered"
|
||||
| "pending"
|
||||
| "processing"
|
||||
| "succeeded"
|
||||
| "failed"
|
||||
| "dead_letter"
|
||||
| "skipped";
|
||||
|
||||
export type AssetErpBinding = {
|
||||
id: string;
|
||||
provider: string;
|
||||
entityType: string;
|
||||
localId: string;
|
||||
externalId: string | null;
|
||||
externalCode: string | null;
|
||||
lastSyncedAt: string | null;
|
||||
};
|
||||
|
||||
export type AssetErpOutbox = {
|
||||
id: string;
|
||||
eventType: string;
|
||||
status: string;
|
||||
attempts: number;
|
||||
lastError: string | null;
|
||||
processedAt: string | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
export type AssetErpStage = {
|
||||
eligible: boolean;
|
||||
reason: string | null;
|
||||
deliveryStatus: AssetErpTrackStatus | string;
|
||||
actionHint: "none" | "backfill" | "retry" | "resync";
|
||||
preview: {
|
||||
changeType: string | null;
|
||||
sourceEventName: string | null;
|
||||
categoryCode: string | null;
|
||||
legalEntityCode: string | null;
|
||||
costCenterCode: string | null;
|
||||
status: string | null;
|
||||
};
|
||||
outbox: AssetErpOutbox | null;
|
||||
binding: AssetErpBinding | null;
|
||||
};
|
||||
|
||||
export type AssetErpTrack = {
|
||||
asset: {
|
||||
id: string;
|
||||
assetNo: string | null;
|
||||
name: string | null;
|
||||
status: string | null;
|
||||
legalEntity: {
|
||||
id: string;
|
||||
code: string | null;
|
||||
name: string | null;
|
||||
} | null;
|
||||
costCenter: {
|
||||
id: string;
|
||||
code: string | null;
|
||||
name: string | null;
|
||||
} | null;
|
||||
purchaseRequest: {
|
||||
id: string;
|
||||
requestNo: string | null;
|
||||
title: string | null;
|
||||
} | null;
|
||||
};
|
||||
stage: AssetErpStage;
|
||||
permissions: {
|
||||
canRetry: boolean;
|
||||
canSeeExternalRefs: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
function toQueryString(query: Record<string, string | boolean | undefined>) {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
@@ -232,3 +307,21 @@ export async function transferAsset(
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchAssetErpTrack(assetId: string) {
|
||||
return apiRequestData<AssetErpTrack>(`/api/integrations/erp/assets/${assetId}/track`);
|
||||
}
|
||||
|
||||
export async function requeueAssetErpSync(assetId: string, runNow = true) {
|
||||
const query = runNow ? '?runNow=1' : '?runNow=0';
|
||||
return apiRequestData<{
|
||||
outboxId: string;
|
||||
status: string;
|
||||
duplicated: boolean;
|
||||
requeued: boolean;
|
||||
eventType: string;
|
||||
}>(`/api/integrations/erp/assets/${assetId}/requeue${query}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,12 +9,16 @@ import {
|
||||
fetchAssetAssignees,
|
||||
fetchAssetCategories,
|
||||
fetchAssetDetail,
|
||||
fetchAssetErpTrack,
|
||||
fetchAssets,
|
||||
patchAsset,
|
||||
patchAssetCategory,
|
||||
requeueAssetErpSync,
|
||||
returnAsset,
|
||||
transferAsset,
|
||||
type AssetCategoryItem,
|
||||
type AssetErpStage,
|
||||
type AssetErpTrack,
|
||||
type AssetStatus,
|
||||
} from "../api/assets";
|
||||
import { fetchPurchaseRequests } from "../api/purchaseRequests";
|
||||
@@ -75,12 +79,44 @@ function assetStatusOptions(): Array<{ value: AssetStatus | ""; label: string }>
|
||||
];
|
||||
}
|
||||
|
||||
function formatAssetErpReason(reason: string | null) {
|
||||
switch (reason) {
|
||||
case "erp_sync_disabled":
|
||||
case "env_hard_off":
|
||||
return "ERP 总闸当前关闭";
|
||||
case "flag_force_off":
|
||||
return "ERP 功能开关当前强制关闭";
|
||||
case "missing_flag_record":
|
||||
return "ERP 功能开关记录缺失";
|
||||
default:
|
||||
return reason ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAssetErpTimestamp(stage: AssetErpStage) {
|
||||
return stage.outbox?.updatedAt ?? stage.outbox?.processedAt ?? stage.binding?.lastSyncedAt ?? null;
|
||||
}
|
||||
|
||||
function resolveAssetErpActionLabel(actionHint: AssetErpTrack["stage"]["actionHint"]) {
|
||||
switch (actionHint) {
|
||||
case "backfill":
|
||||
return "补发";
|
||||
case "retry":
|
||||
return "重试同步";
|
||||
case "resync":
|
||||
return "重新同步摘要";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function AssetRegistryPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { permissions } = useAuth();
|
||||
|
||||
const canManage = hasPermission(permissions, "asset.manage");
|
||||
const canTransfer = hasPermission(permissions, "asset.transfer");
|
||||
const canIntegrationManage = hasPermission(permissions, "integration.manage");
|
||||
const canViewAll = hasAnyPermission(permissions, ["asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"]);
|
||||
const canReadProcurementSources = canManage && hasAnyPermission(permissions, ["procurement.view_all", "procurement.manage"]);
|
||||
|
||||
@@ -127,6 +163,12 @@ export function AssetRegistryPage() {
|
||||
queryFn: () => fetchAssetDetail(selectedAssetId),
|
||||
});
|
||||
|
||||
const assetErpTrackQuery = useQuery({
|
||||
enabled: Boolean(selectedAssetId) && !isCreatingAsset,
|
||||
queryKey: ["assets", "erp-track", selectedAssetId],
|
||||
queryFn: () => fetchAssetErpTrack(selectedAssetId),
|
||||
});
|
||||
|
||||
const legalEntitiesQuery = useQuery({
|
||||
queryKey: ["assets", "legal-entities"],
|
||||
queryFn: () => fetchLegalEntities({ enabled: true }),
|
||||
@@ -212,6 +254,9 @@ export function AssetRegistryPage() {
|
||||
}, [costCentersQuery.data?.items, legalEntityFilter]);
|
||||
|
||||
const currentAsset = assetDetailQuery.data?.asset ?? null;
|
||||
const assetErpTrack = assetErpTrackQuery.data ?? null;
|
||||
const assetErpStage = assetErpTrack?.stage ?? null;
|
||||
const assetErpActionLabel = assetErpStage ? resolveAssetErpActionLabel(assetErpStage.actionHint) : null;
|
||||
const canEditCurrent = canManage && (isCreatingAsset || currentAsset?.permissions.canEdit);
|
||||
|
||||
function resetCategoryEditor(category?: AssetCategoryItem) {
|
||||
@@ -322,6 +367,7 @@ export function AssetRegistryPage() {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["assets", "list"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["assets", "detail"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["assets", "erp-track"] }),
|
||||
]);
|
||||
setAssetFormError("");
|
||||
setSelectedAssetId(data.asset.id);
|
||||
@@ -336,6 +382,30 @@ export function AssetRegistryPage() {
|
||||
});
|
||||
|
||||
|
||||
const requeueAssetErpMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!currentAsset) {
|
||||
throw new Error("请先选择资产卡片");
|
||||
}
|
||||
return requeueAssetErpSync(currentAsset.id, true);
|
||||
},
|
||||
onSuccess: async (data) => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["assets", "erp-track"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["assets", "detail"] }),
|
||||
]);
|
||||
const message = data.duplicated
|
||||
? "ERP 摘要已是最新状态"
|
||||
: data.requeued
|
||||
? "资产 ERP 同步已重新入队"
|
||||
: "资产 ERP 同步已入队";
|
||||
toast.success(message);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(resolveError(error));
|
||||
},
|
||||
});
|
||||
|
||||
const assignAssetMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!currentAsset) {
|
||||
@@ -859,6 +929,73 @@ export function AssetRegistryPage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selectedAssetId && !isCreatingAsset ? (
|
||||
<div className="space-y-3 rounded-xl border border-zinc-200 p-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-zinc-900">资产 ERP 同步轨道</h4>
|
||||
<p className="text-xs text-zinc-500">仅同步资产法体、成本中心与退役/归档等财务台账摘要,不接管实物流转。</p>
|
||||
</div>
|
||||
|
||||
{assetErpTrackQuery.isLoading ? (
|
||||
<p className="rounded-lg bg-zinc-50 px-3 py-2 text-sm text-zinc-500">ERP 轨道加载中...</p>
|
||||
) : assetErpTrackQuery.isError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{resolveError(assetErpTrackQuery.error)}</p>
|
||||
) : assetErpStage ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="space-y-1 text-xs text-zinc-500">
|
||||
<div>
|
||||
最近更新时间:{resolveAssetErpTimestamp(assetErpStage) ? new Date(resolveAssetErpTimestamp(assetErpStage) as string).toLocaleString() : "尚无同步记录"}
|
||||
</div>
|
||||
<div>变更来源:{assetErpStage.preview.sourceEventName ?? "-"}</div>
|
||||
</div>
|
||||
<StatusBadge status={assetErpStage.deliveryStatus} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600 md:grid-cols-2">
|
||||
<div>资产编号:{assetErpTrack?.asset.assetNo ?? currentAsset?.assetNo ?? "-"}</div>
|
||||
<div>最近 changeType:{assetErpStage.preview.changeType ?? "-"}</div>
|
||||
<div>分类:{assetErpStage.preview.categoryCode ?? "-"}</div>
|
||||
<div>资产状态:{assetErpStage.preview.status ?? assetErpTrack?.asset.status ?? "-"}</div>
|
||||
<div>法体:{assetErpStage.preview.legalEntityCode ?? "-"}</div>
|
||||
<div>成本中心:{assetErpStage.preview.costCenterCode ?? "-"}</div>
|
||||
</div>
|
||||
|
||||
{formatAssetErpReason(assetErpStage.reason) ? (
|
||||
<div className="rounded bg-zinc-50 px-3 py-2 text-xs text-zinc-500">{formatAssetErpReason(assetErpStage.reason)}</div>
|
||||
) : null}
|
||||
|
||||
{assetErpStage.outbox?.lastError ? (
|
||||
<div className="rounded bg-red-50 px-3 py-2 text-xs text-red-600">{assetErpStage.outbox.lastError}</div>
|
||||
) : null}
|
||||
|
||||
{assetErpTrack?.permissions.canSeeExternalRefs && (assetErpStage.binding?.externalCode || assetErpStage.binding?.externalId) ? (
|
||||
<div className="rounded bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
|
||||
ERP 资产编号:{assetErpStage.binding?.externalCode ?? "-"}
|
||||
{assetErpStage.binding?.externalId ? ` · ${assetErpStage.binding.externalId}` : ""}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canIntegrationManage && assetErpTrack?.permissions.canRetry && assetErpActionLabel ? (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={requeueAssetErpMutation.isPending}
|
||||
onClick={() => requeueAssetErpMutation.mutate()}
|
||||
>
|
||||
{requeueAssetErpMutation.isPending ? "处理中..." : assetErpActionLabel}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<p className="rounded-lg bg-zinc-50 px-3 py-2 text-sm text-zinc-500">暂无 ERP 轨道信息</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{canManage ? (
|
||||
<Button type="button" data-testid="asset-save" disabled={saveAssetMutation.isPending || !canEditCurrent} onClick={() => saveAssetMutation.mutate()}>
|
||||
|
||||
在新工单中引用
屏蔽一个用户