feat(P19ERP3-3,P19ERP3-4): 新增资产ERP轨道接口与前端卡片挂件
这个提交包含在:
@@ -134,6 +134,81 @@ export type AssetMovementResponse = {
|
|||||||
transferId?: string | null;
|
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>) {
|
function toQueryString(query: Record<string, string | boolean | undefined>) {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
for (const [key, value] of Object.entries(query)) {
|
for (const [key, value] of Object.entries(query)) {
|
||||||
@@ -232,3 +307,21 @@ export async function transferAsset(
|
|||||||
body: JSON.stringify(payload),
|
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,
|
fetchAssetAssignees,
|
||||||
fetchAssetCategories,
|
fetchAssetCategories,
|
||||||
fetchAssetDetail,
|
fetchAssetDetail,
|
||||||
|
fetchAssetErpTrack,
|
||||||
fetchAssets,
|
fetchAssets,
|
||||||
patchAsset,
|
patchAsset,
|
||||||
patchAssetCategory,
|
patchAssetCategory,
|
||||||
|
requeueAssetErpSync,
|
||||||
returnAsset,
|
returnAsset,
|
||||||
transferAsset,
|
transferAsset,
|
||||||
type AssetCategoryItem,
|
type AssetCategoryItem,
|
||||||
|
type AssetErpStage,
|
||||||
|
type AssetErpTrack,
|
||||||
type AssetStatus,
|
type AssetStatus,
|
||||||
} from "../api/assets";
|
} from "../api/assets";
|
||||||
import { fetchPurchaseRequests } from "../api/purchaseRequests";
|
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() {
|
export function AssetRegistryPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { permissions } = useAuth();
|
const { permissions } = useAuth();
|
||||||
|
|
||||||
const canManage = hasPermission(permissions, "asset.manage");
|
const canManage = hasPermission(permissions, "asset.manage");
|
||||||
const canTransfer = hasPermission(permissions, "asset.transfer");
|
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 canViewAll = hasAnyPermission(permissions, ["asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"]);
|
||||||
const canReadProcurementSources = canManage && hasAnyPermission(permissions, ["procurement.view_all", "procurement.manage"]);
|
const canReadProcurementSources = canManage && hasAnyPermission(permissions, ["procurement.view_all", "procurement.manage"]);
|
||||||
|
|
||||||
@@ -127,6 +163,12 @@ export function AssetRegistryPage() {
|
|||||||
queryFn: () => fetchAssetDetail(selectedAssetId),
|
queryFn: () => fetchAssetDetail(selectedAssetId),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const assetErpTrackQuery = useQuery({
|
||||||
|
enabled: Boolean(selectedAssetId) && !isCreatingAsset,
|
||||||
|
queryKey: ["assets", "erp-track", selectedAssetId],
|
||||||
|
queryFn: () => fetchAssetErpTrack(selectedAssetId),
|
||||||
|
});
|
||||||
|
|
||||||
const legalEntitiesQuery = useQuery({
|
const legalEntitiesQuery = useQuery({
|
||||||
queryKey: ["assets", "legal-entities"],
|
queryKey: ["assets", "legal-entities"],
|
||||||
queryFn: () => fetchLegalEntities({ enabled: true }),
|
queryFn: () => fetchLegalEntities({ enabled: true }),
|
||||||
@@ -212,6 +254,9 @@ export function AssetRegistryPage() {
|
|||||||
}, [costCentersQuery.data?.items, legalEntityFilter]);
|
}, [costCentersQuery.data?.items, legalEntityFilter]);
|
||||||
|
|
||||||
const currentAsset = assetDetailQuery.data?.asset ?? null;
|
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);
|
const canEditCurrent = canManage && (isCreatingAsset || currentAsset?.permissions.canEdit);
|
||||||
|
|
||||||
function resetCategoryEditor(category?: AssetCategoryItem) {
|
function resetCategoryEditor(category?: AssetCategoryItem) {
|
||||||
@@ -322,6 +367,7 @@ export function AssetRegistryPage() {
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: ["assets", "list"] }),
|
queryClient.invalidateQueries({ queryKey: ["assets", "list"] }),
|
||||||
queryClient.invalidateQueries({ queryKey: ["assets", "detail"] }),
|
queryClient.invalidateQueries({ queryKey: ["assets", "detail"] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["assets", "erp-track"] }),
|
||||||
]);
|
]);
|
||||||
setAssetFormError("");
|
setAssetFormError("");
|
||||||
setSelectedAssetId(data.asset.id);
|
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({
|
const assignAssetMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
if (!currentAsset) {
|
if (!currentAsset) {
|
||||||
@@ -859,6 +929,73 @@ export function AssetRegistryPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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">
|
<div className="flex items-center gap-2">
|
||||||
{canManage ? (
|
{canManage ? (
|
||||||
<Button type="button" data-testid="asset-save" disabled={saveAssetMutation.isPending || !canEditCurrent} onClick={() => saveAssetMutation.mutate()}>
|
<Button type="button" data-testid="asset-save" disabled={saveAssetMutation.isPending || !canEditCurrent} onClick={() => saveAssetMutation.mutate()}>
|
||||||
|
|||||||
@@ -27,6 +27,15 @@ const {
|
|||||||
enqueuePurchaseCommitmentJournalExport,
|
enqueuePurchaseCommitmentJournalExport,
|
||||||
loadPurchaseRequestErpContext,
|
loadPurchaseRequestErpContext,
|
||||||
} = require('../services/integrations/erpProcurement');
|
} = require('../services/integrations/erpProcurement');
|
||||||
|
const {
|
||||||
|
ASSET_ERP_EVENT_TYPE,
|
||||||
|
ASSET_ERP_TARGET,
|
||||||
|
buildAssetErpTrack,
|
||||||
|
buildAssetLedgerSummaryDedupeKey,
|
||||||
|
enqueueAssetLedgerSummaryExport,
|
||||||
|
loadAssetErpContext,
|
||||||
|
resolveAssetLedgerSummaryDrift,
|
||||||
|
} = require('../services/integrations/erpAsset');
|
||||||
|
|
||||||
const UUID_SCHEMA = z.string().uuid();
|
const UUID_SCHEMA = z.string().uuid();
|
||||||
const MASTER_DATA_RESOURCE_ENUM = z.enum(['legal_entity', 'cost_center', 'supplier']);
|
const MASTER_DATA_RESOURCE_ENUM = z.enum(['legal_entity', 'cost_center', 'supplier']);
|
||||||
@@ -268,6 +277,98 @@ async function fetchReadablePurchaseRequestContext(prisma, purchaseRequestId, re
|
|||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canReadAsset(request, asset) {
|
||||||
|
if (!asset) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissionCodes = request?.employee?.permissionCodes ?? [];
|
||||||
|
if (
|
||||||
|
permissionCodes.includes('*') ||
|
||||||
|
permissionCodes.includes('asset.view_all') ||
|
||||||
|
permissionCodes.includes('asset.manage') ||
|
||||||
|
permissionCodes.includes('asset.transfer') ||
|
||||||
|
permissionCodes.includes('asset.inventory')
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
permissionCodes.includes('asset.view_own') &&
|
||||||
|
asset.assignee_employee_id === request?.employee?.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchReadableAssetContext(prisma, assetId, request) {
|
||||||
|
const context = await loadAssetErpContext(prisma, assetId);
|
||||||
|
if (!context?.asset) {
|
||||||
|
throw new AppError({
|
||||||
|
code: 'SYS_404',
|
||||||
|
message: 'Not Found',
|
||||||
|
statusCode: 404,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canReadAsset(request, context.asset)) {
|
||||||
|
throw new AppError({
|
||||||
|
code: 'PERM_001',
|
||||||
|
message: '无权限',
|
||||||
|
statusCode: 403,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findAssetOutboxByDedupeKey(prisma, assetId) {
|
||||||
|
return prisma.integrationOutbox.findUnique({
|
||||||
|
where: {
|
||||||
|
event_type_dedupe_key: {
|
||||||
|
event_type: ASSET_ERP_EVENT_TYPE,
|
||||||
|
dedupe_key: buildAssetLedgerSummaryDedupeKey(assetId),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
status: true,
|
||||||
|
payload: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enqueueAssetExportAndMaybeRun({
|
||||||
|
prisma,
|
||||||
|
config,
|
||||||
|
logger,
|
||||||
|
context,
|
||||||
|
actorId,
|
||||||
|
runNow,
|
||||||
|
findExistingOutbox = findAssetOutboxByDedupeKey,
|
||||||
|
enqueueExport = enqueueAssetLedgerSummaryExport,
|
||||||
|
runOutboxOnce = runIntegrationOutboxOnce,
|
||||||
|
}) {
|
||||||
|
const asset = context?.asset;
|
||||||
|
const existingOutbox = await findExistingOutbox(prisma, asset.id);
|
||||||
|
const drift = resolveAssetLedgerSummaryDrift(asset, existingOutbox?.payload ?? null);
|
||||||
|
|
||||||
|
const enqueued = await enqueueExport(prisma, config, {
|
||||||
|
asset,
|
||||||
|
payload: existingOutbox?.payload && !drift ? existingOutbox.payload : undefined,
|
||||||
|
sourceEventName: drift?.sourceEventName,
|
||||||
|
changeType: drift?.changeType,
|
||||||
|
changedFields: drift?.changedFields,
|
||||||
|
occurredAt: new Date(),
|
||||||
|
actorId,
|
||||||
|
requeueOnFailure: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (runNow && (!enqueued.duplicated || enqueued.requeued)) {
|
||||||
|
await runOutboxOnce(prisma, config, logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
return enqueued;
|
||||||
|
}
|
||||||
|
|
||||||
function ensureProcurementTargetEligible(target, context) {
|
function ensureProcurementTargetEligible(target, context) {
|
||||||
const purchaseRequest = context?.purchaseRequest;
|
const purchaseRequest = context?.purchaseRequest;
|
||||||
const archive = purchaseRequest?.order_archive ?? null;
|
const archive = purchaseRequest?.order_archive ?? null;
|
||||||
@@ -751,6 +852,138 @@ async function integrationsRoutes(fastify) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
fastify.get(
|
||||||
|
'/integrations/erp/assets/:assetId/track',
|
||||||
|
{
|
||||||
|
preHandler: fastify.authenticate,
|
||||||
|
schema: {
|
||||||
|
tags: ['integrations'],
|
||||||
|
summary: '查询资产 ERP 同步轨道',
|
||||||
|
security: [{ bearerAuth: [] }],
|
||||||
|
params: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['assetId'],
|
||||||
|
properties: {
|
||||||
|
assetId: { type: 'string', format: 'uuid' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const parsedId = UUID_SCHEMA.safeParse(request.params?.assetId);
|
||||||
|
if (!parsedId.success) {
|
||||||
|
throw new AppError({
|
||||||
|
code: 'VALID_001',
|
||||||
|
message: '参数校验失败',
|
||||||
|
statusCode: 400,
|
||||||
|
details: { assetId: 'invalid_uuid' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const [context, gate] = await Promise.all([
|
||||||
|
fetchReadableAssetContext(fastify.prisma, parsedId.data, request),
|
||||||
|
resolveErpGate(fastify.prisma, fastify.config),
|
||||||
|
]);
|
||||||
|
const canManage = fastify.hasPermission(request.employee, 'integration.manage');
|
||||||
|
const track = await buildAssetErpTrack(fastify.prisma, fastify.config, parsedId.data, {
|
||||||
|
context,
|
||||||
|
canManage,
|
||||||
|
gate,
|
||||||
|
});
|
||||||
|
|
||||||
|
return sendData(reply, request, track);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
fastify.post(
|
||||||
|
'/integrations/erp/assets/:assetId/requeue',
|
||||||
|
{
|
||||||
|
preHandler: fastify.requirePermission('integration.manage'),
|
||||||
|
schema: {
|
||||||
|
tags: ['integrations'],
|
||||||
|
summary: '补发或重试资产 ERP 同步',
|
||||||
|
security: [{ bearerAuth: [] }],
|
||||||
|
params: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['assetId'],
|
||||||
|
properties: {
|
||||||
|
assetId: { type: 'string', format: 'uuid' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
querystring: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
runNow: {
|
||||||
|
anyOf: [
|
||||||
|
{ type: 'string', enum: ['0', '1', 'false', 'true'] },
|
||||||
|
{ type: 'boolean' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
additionalProperties: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const parsedId = UUID_SCHEMA.safeParse(request.params?.assetId);
|
||||||
|
if (!parsedId.success) {
|
||||||
|
throw new AppError({
|
||||||
|
code: 'VALID_001',
|
||||||
|
message: '参数校验失败',
|
||||||
|
statusCode: 400,
|
||||||
|
details: { assetId: 'invalid_uuid' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const gate = await requireErpManagementGate(fastify.prisma, fastify.config);
|
||||||
|
const context = await loadAssetErpContext(fastify.prisma, parsedId.data);
|
||||||
|
if (!context?.asset) {
|
||||||
|
throw new AppError({
|
||||||
|
code: 'SYS_404',
|
||||||
|
message: 'Not Found',
|
||||||
|
statusCode: 404,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const enqueued = await enqueueAssetExportAndMaybeRun({
|
||||||
|
prisma: fastify.prisma,
|
||||||
|
config: fastify.config,
|
||||||
|
logger: fastify.log,
|
||||||
|
context,
|
||||||
|
actorId: request.employee.id,
|
||||||
|
runNow: parseRunNow(request.query),
|
||||||
|
});
|
||||||
|
|
||||||
|
await fastify.prisma.auditLog.create({
|
||||||
|
data: buildAuditLogData(request, {
|
||||||
|
actorId: request.employee.id,
|
||||||
|
action: 'ERP_SYNC_ENQUEUED',
|
||||||
|
targetType: 'asset_card',
|
||||||
|
targetId: context.asset.id,
|
||||||
|
extra: {
|
||||||
|
erpTarget: ASSET_ERP_TARGET,
|
||||||
|
eventType: enqueued.eventType,
|
||||||
|
assetId: context.asset.id,
|
||||||
|
assetNo: context.asset.asset_no ?? null,
|
||||||
|
outboxId: enqueued.id,
|
||||||
|
duplicated: enqueued.duplicated,
|
||||||
|
requeued: enqueued.requeued,
|
||||||
|
gateReason: gate.effectiveReason,
|
||||||
|
featureFlag: FEATURE_FLAG_KEYS.erpSync,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return sendData(reply, request, {
|
||||||
|
outboxId: enqueued.id,
|
||||||
|
status: enqueued.status,
|
||||||
|
duplicated: enqueued.duplicated,
|
||||||
|
requeued: enqueued.requeued,
|
||||||
|
eventType: enqueued.eventType,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
fastify.post(
|
fastify.post(
|
||||||
'/integrations/erp/sync/:reimbursementId',
|
'/integrations/erp/sync/:reimbursementId',
|
||||||
{
|
{
|
||||||
@@ -852,7 +1085,9 @@ async function integrationsRoutes(fastify) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
integrationsRoutes.__testables = {
|
integrationsRoutes.__testables = {
|
||||||
|
canReadAsset,
|
||||||
canReadPurchaseRequest,
|
canReadPurchaseRequest,
|
||||||
|
enqueueAssetExportAndMaybeRun,
|
||||||
ensureProcurementTargetEligible,
|
ensureProcurementTargetEligible,
|
||||||
normalizeProcurementTargets,
|
normalizeProcurementTargets,
|
||||||
normalizeResourcesInput,
|
normalizeResourcesInput,
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
const integrationsRoutes = require('../../../src/routes/integrations');
|
||||||
|
|
||||||
|
describe('Integrations asset ERP route helpers', () => {
|
||||||
|
const { canReadAsset, enqueueAssetExportAndMaybeRun, parseRunNow } = integrationsRoutes.__testables;
|
||||||
|
|
||||||
|
function createAsset(overrides = {}) {
|
||||||
|
return {
|
||||||
|
id: 'asset-1',
|
||||||
|
asset_no: 'ZC-001',
|
||||||
|
assignee_employee_id: 'employee-1',
|
||||||
|
legal_entity_id: 'le-1',
|
||||||
|
cost_center_id: 'cc-1',
|
||||||
|
status: 'in_use',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('respects asset read permission rules', () => {
|
||||||
|
const asset = createAsset();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
canReadAsset(
|
||||||
|
{ employee: { id: 'employee-1', permissionCodes: ['asset.view_own'] } },
|
||||||
|
asset,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
canReadAsset(
|
||||||
|
{ employee: { id: 'other', permissionCodes: ['asset.view_own'] } },
|
||||||
|
asset,
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
canReadAsset(
|
||||||
|
{ employee: { id: 'other', permissionCodes: ['asset.view_all'] } },
|
||||||
|
asset,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries same asset payload without triggering runNow when duplicated', async () => {
|
||||||
|
const enqueueExport = vi.fn().mockResolvedValue({
|
||||||
|
id: 'outbox-1',
|
||||||
|
status: 'succeeded',
|
||||||
|
duplicated: true,
|
||||||
|
requeued: false,
|
||||||
|
eventType: 'erp.asset.export',
|
||||||
|
});
|
||||||
|
const runOutboxOnce = vi.fn();
|
||||||
|
|
||||||
|
const result = await enqueueAssetExportAndMaybeRun({
|
||||||
|
prisma: {},
|
||||||
|
config: {},
|
||||||
|
logger: {},
|
||||||
|
context: { asset: createAsset() },
|
||||||
|
actorId: 'actor-1',
|
||||||
|
runNow: true,
|
||||||
|
findExistingOutbox: vi.fn().mockResolvedValue({
|
||||||
|
id: 'outbox-1',
|
||||||
|
status: 'succeeded',
|
||||||
|
payload: {
|
||||||
|
exportType: 'asset_ledger_summary',
|
||||||
|
entityId: 'asset-1',
|
||||||
|
assetNo: 'ZC-001',
|
||||||
|
legalEntity: { id: 'le-1' },
|
||||||
|
costCenter: { id: 'cc-1' },
|
||||||
|
status: 'in_use',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
enqueueExport,
|
||||||
|
runOutboxOnce,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(enqueueExport).toHaveBeenCalledWith(
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
expect.objectContaining({
|
||||||
|
payload: expect.objectContaining({
|
||||||
|
assetNo: 'ZC-001',
|
||||||
|
status: 'in_use',
|
||||||
|
}),
|
||||||
|
requeueOnFailure: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(runOutboxOnce).not.toHaveBeenCalled();
|
||||||
|
expect(result.duplicated).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resyncs current asset snapshot when financial drift exists', async () => {
|
||||||
|
const enqueueExport = vi.fn().mockResolvedValue({
|
||||||
|
id: 'outbox-1',
|
||||||
|
status: 'pending',
|
||||||
|
duplicated: false,
|
||||||
|
requeued: true,
|
||||||
|
eventType: 'erp.asset.export',
|
||||||
|
});
|
||||||
|
const runOutboxOnce = vi.fn().mockResolvedValue({ total: 1, succeeded: 1, failed: 0 });
|
||||||
|
|
||||||
|
const result = await enqueueAssetExportAndMaybeRun({
|
||||||
|
prisma: {},
|
||||||
|
config: {},
|
||||||
|
logger: {},
|
||||||
|
context: { asset: createAsset({ cost_center_id: 'cc-2', status: 'retired' }) },
|
||||||
|
actorId: 'actor-1',
|
||||||
|
runNow: true,
|
||||||
|
findExistingOutbox: vi.fn().mockResolvedValue({
|
||||||
|
id: 'outbox-1',
|
||||||
|
status: 'succeeded',
|
||||||
|
payload: {
|
||||||
|
exportType: 'asset_ledger_summary',
|
||||||
|
entityId: 'asset-1',
|
||||||
|
assetNo: 'ZC-001',
|
||||||
|
legalEntity: { id: 'le-1' },
|
||||||
|
costCenter: { id: 'cc-1' },
|
||||||
|
status: 'in_use',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
enqueueExport,
|
||||||
|
runOutboxOnce,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(enqueueExport).toHaveBeenCalledWith(
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
expect.objectContaining({
|
||||||
|
payload: undefined,
|
||||||
|
changeType: 'retired',
|
||||||
|
changedFields: expect.arrayContaining(['costCenterId', 'status']),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(runOutboxOnce).toHaveBeenCalled();
|
||||||
|
expect(result.requeued).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses runNow flags for asset requeue route', () => {
|
||||||
|
expect(parseRunNow({ runNow: '1' })).toBe(true);
|
||||||
|
expect(parseRunNow({ runNow: '0' })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
在新工单中引用
屏蔽一个用户