From 07fac7d2d271ba162cfdb0e9599b68b93d22fce7 Mon Sep 17 00:00:00 2001 From: shunli Date: Mon, 9 Mar 2026 11:15:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(P19ERP3-3,P19ERP3-4):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E8=B5=84=E4=BA=A7ERP=E8=BD=A8=E9=81=93=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E4=B8=8E=E5=89=8D=E7=AB=AF=E5=8D=A1=E7=89=87=E6=8C=82=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/api/assets.ts | 93 +++++++ client/src/pages/AssetRegistryPage.tsx | 137 ++++++++++ server/src/routes/integrations.js | 235 ++++++++++++++++++ .../routes/integrations.erp.asset.test.js | 139 +++++++++++ 4 files changed, 604 insertions(+) create mode 100644 server/tests/unit/routes/integrations.erp.asset.test.js diff --git a/client/src/api/assets.ts b/client/src/api/assets.ts index d34bde0d..c0318374 100644 --- a/client/src/api/assets.ts +++ b/client/src/api/assets.ts @@ -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) { 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(`/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({}), + }); +} diff --git a/client/src/pages/AssetRegistryPage.tsx b/client/src/pages/AssetRegistryPage.tsx index 682b9f36..257d5b74 100644 --- a/client/src/pages/AssetRegistryPage.tsx +++ b/client/src/pages/AssetRegistryPage.tsx @@ -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() { ) : null} + {selectedAssetId && !isCreatingAsset ? ( +
+
+

资产 ERP 同步轨道

+

仅同步资产法体、成本中心与退役/归档等财务台账摘要,不接管实物流转。

+
+ + {assetErpTrackQuery.isLoading ? ( +

ERP 轨道加载中...

+ ) : assetErpTrackQuery.isError ? ( +

{resolveError(assetErpTrackQuery.error)}

+ ) : assetErpStage ? ( +
+
+
+
+ 最近更新时间:{resolveAssetErpTimestamp(assetErpStage) ? new Date(resolveAssetErpTimestamp(assetErpStage) as string).toLocaleString() : "尚无同步记录"} +
+
变更来源:{assetErpStage.preview.sourceEventName ?? "-"}
+
+ +
+ +
+
资产编号:{assetErpTrack?.asset.assetNo ?? currentAsset?.assetNo ?? "-"}
+
最近 changeType:{assetErpStage.preview.changeType ?? "-"}
+
分类:{assetErpStage.preview.categoryCode ?? "-"}
+
资产状态:{assetErpStage.preview.status ?? assetErpTrack?.asset.status ?? "-"}
+
法体:{assetErpStage.preview.legalEntityCode ?? "-"}
+
成本中心:{assetErpStage.preview.costCenterCode ?? "-"}
+
+ + {formatAssetErpReason(assetErpStage.reason) ? ( +
{formatAssetErpReason(assetErpStage.reason)}
+ ) : null} + + {assetErpStage.outbox?.lastError ? ( +
{assetErpStage.outbox.lastError}
+ ) : null} + + {assetErpTrack?.permissions.canSeeExternalRefs && (assetErpStage.binding?.externalCode || assetErpStage.binding?.externalId) ? ( +
+ ERP 资产编号:{assetErpStage.binding?.externalCode ?? "-"} + {assetErpStage.binding?.externalId ? ` · ${assetErpStage.binding.externalId}` : ""} +
+ ) : null} + + {canIntegrationManage && assetErpTrack?.permissions.canRetry && assetErpActionLabel ? ( +
+ +
+ ) : null} +
+ ) : ( +

暂无 ERP 轨道信息

+ )} +
+ ) : null} +
{canManage ? (