From 7613d7d912647e00f1795edc3e51b50026cf9903 Mon Sep 17 00:00:00 2001 From: shunli Date: Mon, 9 Mar 2026 11:15:21 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(P19ERP3-0):=20=E8=B5=84=E4=BA=A7ERP?= =?UTF-8?q?=E8=B4=A2=E5=8A=A1=E5=8F=B0=E8=B4=A6=E6=89=A7=E8=A1=8C=E5=90=88?= =?UTF-8?q?=E5=90=8C=E5=BB=BA=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...6-03-09_00-10-00-p19-erp-3-l5-asset-ledger-summary.csv | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 issues/2026-03-09_00-10-00-p19-erp-3-l5-asset-ledger-summary.csv diff --git a/issues/2026-03-09_00-10-00-p19-erp-3-l5-asset-ledger-summary.csv b/issues/2026-03-09_00-10-00-p19-erp-3-l5-asset-ledger-summary.csv new file mode 100644 index 00000000..a70b0652 --- /dev/null +++ b/issues/2026-03-09_00-10-00-p19-erp-3-l5-asset-ledger-summary.csv @@ -0,0 +1,8 @@ +ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes +P19ERP3-0,P19ERP3执行合同建档,创建并校验资产ERP台账摘要出站执行合同CSV,CSV通过校验且字段完整,python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-03-09_00-10-00-p19-erp-3-l5-asset-ledger-summary.csv,manual,DONE,DONE,DONE,issues/2026-03-09_00-10-00-p19-erp-3-l5-asset-ledger-summary.csv,[],implementation_plan_erp3.md.resolved不存在且以仓库事实和当前实施计划为准 +P19ERP3-1,资产ERP出站契约与provider扩展,新增erp.asset.export契约并扩展provider dispatcher与binding回写,资产摘要可通过现有dispatcher/outbox处理且成功后写入asset_erp_card binding,cd server && npm run test,manual,DONE,DONE,DONE,server/src/services/integrations/erpAsset.js | server/src/services/integrations/erp.js | server/src/services/integrations/dispatcher.js | server/tests,"[""P19ERP3-0""]",一期只做asset ledger summary export 不含金额折旧字段 +P19ERP3-2,资产建卡与财务维度变更自动出站,在资产建卡与法体成本中心退役归档变更时自动写资产ERP摘要Outbox并处理gate skip,建卡成功和财务维度patch后业务事务不受ERP失败影响 且同一资产仅维护一条可重置outbox,cd server && npm run test && ./scripts/regression-integrations.sh,manual,DONE,DONE,DONE,server/src/routes/assets.js | server/src/services/integrations/outbox.js | server/tests,"[""P19ERP3-1""]",领用归还位置调拨不触发ERP 自动出站与手动requeue均按strictDepartment=false解释erp_sync +P19ERP3-3,资产ERP轨道查询与重试接口,新增资产ERP track与requeue接口支持状态查看补发和失败重试,资产可读用户可看状态 integration.manage可看外部编号并重试且gate关闭时正确阻断,cd server && npm run test && ./scripts/regression-integrations.sh,manual,DONE,DONE,DONE,server/src/routes/integrations.js | server/tests,"[""P19ERP3-2""]",track接口对非integration.manage脱敏 externalId externalCode 并禁止重试 +P19ERP3-4,资产详情ERP轨道挂件,在资产详情区域新增ERP同步轨道挂件与重试交互,资产详情可展示ERP同步状态预览与权限边界一致,cd client && npm run typecheck && npm run build,manual,DONE,DONE,DONE,client/src/api/assets.ts | client/src/pages/AssetRegistryPage.tsx,"[""P19ERP3-3""]",挂件位于资产流转卡片下方 保存按钮上方 仅详情态显示 +P19ERP3-5,文档事件与矩阵同步,同步ERP-3边界 事件映射 Feature Matrix与校验器,矩阵标题更新为P5-P19+IM-1+ERP-3且总数为220,python3 scripts/validate-feature-matrix.py,manual,DONE,DONE,DONE,docs/integration-boundaries.md | docs/domain-events.md | docs/feature-matrix.md | scripts/validate-feature-matrix.py | issues/2026-03-09_00-10-00-p19-erp-3-l5-asset-ledger-summary.csv,"[""P19ERP3-4""]",不新增公共资产事件名 只补ERP-3消费映射与事实源 +P19ERP3-6,P19ERP3回归与验收收口,完成资产ERP摘要出站 详情挂件与回归验证后收口,资产ERP happy path与失败重试通过且报销采购IM资产主流程零回归,cd server && npm run prisma:validate && npm run test && ./scripts/regression-integrations.sh && OCR_PROVIDER=mock JOBS_ENABLED=false IM_NOTIFIER_ENABLED=true APP_ORIGIN='http://127.0.0.1:3000' ./scripts/regression-all.sh && cd ../client && npm run typecheck && npm run build,manual,DONE,DONE,DONE,issues/2026-03-09_00-10-00-p19-erp-3-l5-asset-ledger-summary.csv | server/scripts/regression-integrations.sh | client/src/pages/AssetRegistryPage.tsx,"[""P19ERP3-5""]",重点核对同一资产摘要outbox覆盖同步 gate阻断 非财务字段patch不出站与非管理员脱敏 From 0168ebfe2e4edb989dbc1cc43580470eff17ce09 Mon Sep 17 00:00:00 2001 From: shunli Date: Mon, 9 Mar 2026 11:15:21 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat(P19ERP3-1,P19ERP3-2):=20=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E8=B5=84=E4=BA=A7ERP=E6=91=98=E8=A6=81=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E4=B8=8E=E5=BB=BA=E5=8D=A1/=E6=B5=81=E8=BD=AC?= =?UTF-8?q?=E5=87=BA=E7=AB=99=E8=81=94=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/config.js | 4 + server/src/jobs/integrationOutboxJob.js | 5 + server/src/routes/assets.js | 120 ++++ .../src/services/integrations/dispatcher.js | 23 +- server/src/services/integrations/erp.js | 57 ++ server/src/services/integrations/erpAsset.js | 552 ++++++++++++++++++ .../unit/integrations.dispatcher.test.js | 32 + server/tests/unit/routes/assets.erp.test.js | 148 +++++ server/tests/unit/services/erpAsset.test.js | 138 +++++ .../unit/services/integrations/erp.test.js | 53 ++ 10 files changed, 1128 insertions(+), 4 deletions(-) create mode 100644 server/src/services/integrations/erpAsset.js create mode 100644 server/tests/unit/routes/assets.erp.test.js create mode 100644 server/tests/unit/services/erpAsset.test.js diff --git a/server/src/config.js b/server/src/config.js index 9b25141a..ed789056 100644 --- a/server/src/config.js +++ b/server/src/config.js @@ -110,11 +110,15 @@ function getConfig() { erpSyncEndpoint: process.env.ERP_SYNC_ENDPOINT ?? "", erpJournalExportEndpoint: process.env.ERP_JOURNAL_EXPORT_ENDPOINT ?? "", erpProcurementExportEndpoint: process.env.ERP_PROCUREMENT_EXPORT_ENDPOINT ?? "", + erpAssetExportEndpoint: process.env.ERP_ASSET_EXPORT_ENDPOINT ?? "", erpMasterDataEndpoint: process.env.ERP_MASTER_DATA_ENDPOINT ?? "", erpSyncTimeoutMs: Number(process.env.ERP_SYNC_TIMEOUT_MS ?? 5000), erpProcurementTimeoutMs: Number( process.env.ERP_PROCUREMENT_TIMEOUT_MS ?? process.env.ERP_SYNC_TIMEOUT_MS ?? 5000, ), + erpAssetTimeoutMs: Number( + process.env.ERP_ASSET_TIMEOUT_MS ?? process.env.ERP_SYNC_TIMEOUT_MS ?? 5000, + ), erpMasterDataTimeoutMs: Number( process.env.ERP_MASTER_DATA_TIMEOUT_MS ?? process.env.ERP_SYNC_TIMEOUT_MS ?? 5000, ), diff --git a/server/src/jobs/integrationOutboxJob.js b/server/src/jobs/integrationOutboxJob.js index 3a1d6a8f..b065f696 100644 --- a/server/src/jobs/integrationOutboxJob.js +++ b/server/src/jobs/integrationOutboxJob.js @@ -52,6 +52,11 @@ async function runIntegrationOutboxOnce(prisma, config, logger) { journalType: item.payload?.journalType ?? null, poNo: item.payload?.poNo ?? item.payload?.references?.poNo ?? null, contractNo: item.payload?.contractNo ?? item.payload?.references?.contractNo ?? null, + assetId: + item.payload?.assetId ?? + (item.payload?.entityType === 'asset_card' ? item.payload?.entityId ?? null : null), + assetNo: item.payload?.assetNo ?? null, + changeType: item.payload?.changeType ?? null, entityType: item.payload?.entityType ?? null, entityId: item.payload?.entityId ?? null, channelId: item.payload?.channelId ?? null, diff --git a/server/src/routes/assets.js b/server/src/routes/assets.js index 5c83e6b4..9154dda8 100644 --- a/server/src/routes/assets.js +++ b/server/src/routes/assets.js @@ -4,6 +4,12 @@ const { AppError } = require("../utils/AppError"); const { buildAuditLogData } = require("../utils/audit"); const { parseDateOnlyString } = require("../utils/date"); const { issueDocumentNumber } = require("../services/documentSequence"); +const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require('../services/featureFlags'); +const { + ASSET_ERP_TARGET, + enqueueAssetLedgerSummaryExport, + resolveAssetLedgerSummaryChange, +} = require('../services/integrations/erpAsset'); const { sendData } = require("../utils/reply"); const UUID_SCHEMA = z.string().uuid(); @@ -557,6 +563,92 @@ async function ensureApprovedPurchaseRequest(tx, purchaseRequestId) { return record; } +async function handleAssetLedgerSummaryErpSync({ + tx, + config, + request, + actorId, + asset, + sourceEventName, + changeType, + changedFields, + evaluateGate = evaluateFeatureFlag, + enqueueExport = enqueueAssetLedgerSummaryExport, +}) { + const gate = await evaluateGate({ + prisma: tx, + config, + flagKey: FEATURE_FLAG_KEYS.erpSync, + departmentId: null, + strictDepartment: false, + }); + + if (!gate.enabled) { + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId, + action: 'ERP_SYNC_SKIPPED_BY_FEATURE_FLAG', + targetType: 'asset_card', + targetId: asset.id, + extra: { + erpTarget: ASSET_ERP_TARGET, + eventType: 'erp.asset.export', + assetId: asset.id, + assetNo: asset.asset_no, + changeType, + gateReason: gate.reason, + featureFlag: FEATURE_FLAG_KEYS.erpSync, + }, + }), + }); + + return { + skipped: true, + gate, + asset, + }; + } + + const enqueued = await enqueueExport(tx, config, { + asset, + sourceEventName, + changeType, + changedFields, + occurredAt: new Date(), + actorId, + requeueOnFailure: false, + }); + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId, + action: 'ERP_SYNC_ENQUEUED', + targetType: 'asset_card', + targetId: asset.id, + extra: { + erpTarget: ASSET_ERP_TARGET, + eventType: enqueued.eventType, + assetId: asset.id, + assetNo: asset.asset_no, + outboxId: enqueued.id, + duplicated: enqueued.duplicated, + requeued: enqueued.requeued, + changeType, + changedFields: Array.isArray(changedFields) ? changedFields : [], + gateReason: gate.reason, + featureFlag: FEATURE_FLAG_KEYS.erpSync, + }, + }), + }); + + return { + skipped: false, + gate, + asset, + enqueued, + }; +} + async function assetsRoutes(fastify) { fastify.get( "/asset-categories", @@ -843,6 +935,17 @@ async function assetsRoutes(fastify) { }), }); + await handleAssetLedgerSummaryErpSync({ + tx, + config: fastify.config, + request, + actorId: request.employee.id, + asset: created, + sourceEventName: 'asset.card.created', + changeType: 'created', + changedFields: [], + }); + return created; }); @@ -953,6 +1056,20 @@ async function assetsRoutes(fastify) { }), }); + const assetLedgerSummaryChange = resolveAssetLedgerSummaryChange(current, updated); + if (assetLedgerSummaryChange) { + await handleAssetLedgerSummaryErpSync({ + tx, + config: fastify.config, + request, + actorId: request.employee.id, + asset: updated, + sourceEventName: assetLedgerSummaryChange.sourceEventName, + changeType: assetLedgerSummaryChange.changeType, + changedFields: assetLedgerSummaryChange.changedFields, + }); + } + return updated; }); @@ -962,3 +1079,6 @@ async function assetsRoutes(fastify) { } module.exports = assetsRoutes; +module.exports.__testables = { + handleAssetLedgerSummaryErpSync, +}; diff --git a/server/src/services/integrations/dispatcher.js b/server/src/services/integrations/dispatcher.js index 14c54041..1ed15b31 100644 --- a/server/src/services/integrations/dispatcher.js +++ b/server/src/services/integrations/dispatcher.js @@ -1,5 +1,6 @@ const { pushPaidReimbursementToErp, + exportAssetToErp, exportJournalToErp, exportProcurementToErp, } = require('./erp'); @@ -136,6 +137,17 @@ function resolveBindingSpecs(eventType, payload, response) { return specs.filter(Boolean); } + if (eventType === 'erp.asset.export') { + return [ + createBindingSpec( + 'asset_erp_card', + payload?.entityId, + extractResponseValue(response, 'externalAssetId') ?? extractResponseValue(response, 'externalId'), + extractResponseValue(response, 'externalAssetNo') ?? extractResponseValue(response, 'externalCode') ?? payload?.assetNo ?? null, + ), + ].filter(Boolean); + } + return []; } @@ -179,9 +191,11 @@ async function dispatchErpEvent({ prisma, config, eventType, payload }) { const result = eventType === 'erp.procurement.export' ? await exportProcurementToErp(config, payload) - : eventType === 'erp.journal.export' - ? await exportJournalToErp(config, payload) - : await pushPaidReimbursementToErp(config, payload); + : eventType === 'erp.asset.export' + ? await exportAssetToErp(config, payload) + : eventType === 'erp.journal.export' + ? await exportJournalToErp(config, payload) + : await pushPaidReimbursementToErp(config, payload); if (!result.ok) { throw toDispatchError(result); @@ -200,7 +214,8 @@ async function dispatchOutboxEvent({ prisma, config, eventType, payload }) { if ( eventType === 'erp.reimbursement.paid' || eventType === 'erp.journal.export' || - eventType === 'erp.procurement.export' + eventType === 'erp.procurement.export' || + eventType === 'erp.asset.export' ) { return dispatchErpEvent({ prisma, config, eventType, payload }); } diff --git a/server/src/services/integrations/erp.js b/server/src/services/integrations/erp.js index d93c320c..5b2f69c6 100644 --- a/server/src/services/integrations/erp.js +++ b/server/src/services/integrations/erp.js @@ -41,6 +41,13 @@ function resolveProcurementExportEndpoint(config) { ); } +function resolveAssetExportEndpoint(config) { + return ( + String(config.erpAssetExportEndpoint ?? '').trim() || + String(config.erpSyncEndpoint ?? '').trim() + ); +} + async function parseResponseBody(response) { const contentType = response.headers.get('content-type') || ''; if (contentType.includes('application/json')) { @@ -236,6 +243,55 @@ async function exportJournalToErp(config, payload) { }; } +async function exportAssetToErp(config, payload) { + if (!config.erpSyncEnabled) { + return { + ok: false, + code: 'erp_sync_disabled', + message: 'ERP sync disabled', + retryable: false, + }; + } + + const provider = normalizeProvider(config); + if (provider === 'mock') { + const identity = payload?.assetNo ?? payload?.entityId ?? 'UNKNOWN'; + return { + ok: true, + code: 'mock_synced', + message: 'mock ERP asset synced', + response: { + externalAssetId: `ERP-ASSET-${identity}`, + externalAssetNo: `ERP-ASSET-${identity}`, + }, + }; + } + + if (provider === 'http') { + return postJsonToErp(config, { + endpoint: resolveAssetExportEndpoint(config), + timeoutMs: coerceTimeout(config.erpAssetTimeoutMs, config.erpSyncTimeoutMs), + payload, + successCode: 'http_synced', + successMessage: 'ERP asset synced', + missingEndpointCode: 'erp_asset_endpoint_missing', + missingEndpointMessage: 'ERP asset endpoint missing', + http4xxCode: 'erp_asset_http_4xx', + http5xxCode: 'erp_asset_http_5xx', + timeoutCode: 'erp_asset_timeout', + networkCode: 'erp_asset_network_error', + messagePrefix: 'ERP asset sync', + }); + } + + return { + ok: false, + code: 'erp_sync_provider_invalid', + message: `Unsupported ERP sync provider: ${provider}`, + retryable: false, + }; +} + async function exportProcurementToErp(config, payload) { if (!config.erpSyncEnabled) { return { @@ -299,6 +355,7 @@ async function pushPaidReimbursementToErp(config, payload) { module.exports = { buildErpPayload, + exportAssetToErp, exportJournalToErp, exportProcurementToErp, normalizeProvider, diff --git a/server/src/services/integrations/erpAsset.js b/server/src/services/integrations/erpAsset.js new file mode 100644 index 00000000..ca5caf1f --- /dev/null +++ b/server/src/services/integrations/erpAsset.js @@ -0,0 +1,552 @@ +const crypto = require('node:crypto'); + +const { enqueueIntegrationEvent } = require('./outbox'); + +const ASSET_ERP_EVENT_TYPE = 'erp.asset.export'; +const ASSET_ERP_BINDING_ENTITY_TYPE = 'asset_erp_card'; +const ASSET_ERP_TARGET = 'asset_ledger_summary'; +const ASSET_TRACK_STATUSES = Object.freeze({ + notTriggered: 'not_triggered', + skipped: 'skipped', +}); +const ASSET_TRACK_ACTIONS = Object.freeze({ + none: 'none', + backfill: 'backfill', + retry: 'retry', + resync: 'resync', +}); +const ASSET_EXPORTABLE_STATUS_CHANGES = new Set(['retired', 'archived']); + +const ASSET_ERP_CONTEXT_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: { + select: { + code: true, + }, + }, + }, + }, + legal_entity: { + select: { + id: true, + code: true, + name: true, + }, + }, + cost_center: { + select: { + id: true, + code: true, + name: true, + }, + }, + purchase_request: { + select: { + id: true, + request_no: true, + title: true, + status: true, + }, + }, + assignee: { + select: { + id: true, + employee_no: true, + name: true, + }, + }, +}; + +function normalizeString(value) { + const text = String(value ?? '').trim(); + return text || null; +} + +function toIsoString(value) { + if (!value) { + return null; + } + + try { + return new Date(value).toISOString(); + } catch { + return null; + } +} + +function toDateOnly(value) { + const iso = toIsoString(value); + return iso ? iso.slice(0, 10) : null; +} + +function stableNormalizeJson(value) { + if (Array.isArray(value)) { + return value.map((item) => stableNormalizeJson(item)); + } + + if (value && typeof value === 'object' && value.constructor === Object) { + return Object.keys(value) + .sort() + .reduce((result, key) => { + result[key] = stableNormalizeJson(value[key]); + return result; + }, {}); + } + + return value; +} + +function stableStringifyJson(value) { + return JSON.stringify(stableNormalizeJson(value)); +} + +function hashAssetPayload(payload) { + return crypto.createHash('sha256').update(stableStringifyJson(payload)).digest('hex'); +} + +function buildAssetLedgerSummaryDedupeKey(assetId) { + return `asset_card:${assetId}:ledger_summary`; +} + +function resolveAssetChangeType(status, changedFields) { + const normalizedStatus = normalizeString(status); + if (normalizedStatus === 'retired') { + return 'retired'; + } + if (normalizedStatus === 'archived') { + return 'archived'; + } + return changedFields?.length ? 'financial_dimensions_changed' : 'created'; +} + +function buildAssetLedgerSummaryPayload(input) { + const asset = input?.asset ?? {}; + const changedFields = Array.isArray(input?.changedFields) + ? Array.from(new Set(input.changedFields.filter(Boolean))) + : []; + const sourceEventName = normalizeString(input?.sourceEventName) ?? 'asset.card.created'; + const changeType = normalizeString(input?.changeType) ?? resolveAssetChangeType(asset.status, changedFields); + + return { + exportType: 'asset_ledger_summary', + sourceEventName, + changeType, + entityType: 'asset_card', + entityId: asset.id ?? null, + assetNo: asset.asset_no ?? null, + name: asset.name ?? null, + status: asset.status ?? null, + location: normalizeString(asset.location), + activatedAt: toDateOnly(asset.activated_at), + retiredAt: toDateOnly(asset.retired_at), + occurredAt: + toIsoString(input?.occurredAt) ?? + toIsoString(asset.updated_at) ?? + toIsoString(asset.created_at), + changedFields, + category: asset.category + ? { + id: asset.category.id, + code: asset.category.code ?? null, + name: asset.category.name ?? null, + documentCategoryCode: asset.category.document_category?.code ?? null, + } + : { + id: asset.category_id ?? null, + code: null, + name: null, + documentCategoryCode: null, + }, + legalEntity: asset.legal_entity + ? { + id: asset.legal_entity.id, + code: asset.legal_entity.code ?? null, + name: asset.legal_entity.name ?? null, + } + : { + id: asset.legal_entity_id ?? null, + code: null, + name: null, + }, + costCenter: asset.cost_center + ? { + id: asset.cost_center.id, + code: asset.cost_center.code ?? null, + name: asset.cost_center.name ?? null, + } + : { + id: asset.cost_center_id ?? null, + code: null, + name: null, + }, + purchaseRequest: asset.purchase_request + ? { + id: asset.purchase_request.id, + requestNo: asset.purchase_request.request_no ?? null, + title: asset.purchase_request.title ?? null, + } + : { + id: asset.purchase_request_id ?? null, + requestNo: null, + title: null, + }, + assignee: asset.assignee + ? { + id: asset.assignee.id, + employeeNo: asset.assignee.employee_no ?? null, + name: asset.assignee.name ?? null, + } + : { + id: asset.assignee_employee_id ?? null, + employeeNo: null, + name: null, + }, + }; +} + +function resolveAssetLedgerSummaryChange(previousAsset, nextAsset) { + if (!previousAsset || !nextAsset) { + return null; + } + + const changedFields = []; + if (previousAsset.legal_entity_id !== nextAsset.legal_entity_id) { + changedFields.push('legalEntityId'); + } + if (previousAsset.cost_center_id !== nextAsset.cost_center_id) { + changedFields.push('costCenterId'); + } + + const nextStatus = normalizeString(nextAsset.status); + if (previousAsset.status !== nextAsset.status && ASSET_EXPORTABLE_STATUS_CHANGES.has(nextStatus)) { + changedFields.push('status'); + } + + if (!changedFields.length) { + return null; + } + + return { + sourceEventName: 'asset.ledger.summary.updated', + changeType: resolveAssetChangeType(nextAsset.status, changedFields), + changedFields, + }; +} + +function resolveAssetLedgerSummaryDrift(asset, previousPayload) { + if (!asset || !previousPayload) { + return null; + } + + const changedFields = []; + const previousLegalEntityId = previousPayload.legalEntity?.id ?? null; + const previousCostCenterId = previousPayload.costCenter?.id ?? null; + const previousStatus = previousPayload.status ?? null; + + if ((asset.legal_entity_id ?? null) !== previousLegalEntityId) { + changedFields.push('legalEntityId'); + } + if ((asset.cost_center_id ?? null) !== previousCostCenterId) { + changedFields.push('costCenterId'); + } + + const assetStatus = normalizeString(asset.status); + if (ASSET_EXPORTABLE_STATUS_CHANGES.has(assetStatus) && assetStatus !== previousStatus) { + changedFields.push('status'); + } + + if (!changedFields.length) { + return null; + } + + return { + sourceEventName: 'asset.ledger.summary.updated', + changeType: resolveAssetChangeType(asset.status, changedFields), + changedFields, + }; +} + +function mapIntegrationBinding(record) { + if (!record) { + return null; + } + + return { + id: record.id, + provider: record.provider, + entityType: record.entity_type, + localId: record.local_id, + externalId: record.external_id ?? null, + externalCode: record.external_code ?? null, + lastSyncedAt: record.last_synced_at?.toISOString?.() ?? null, + }; +} + +function redactBinding(binding, canManage) { + if (!binding) { + return null; + } + + if (canManage) { + return binding; + } + + return { + ...binding, + externalId: null, + externalCode: null, + }; +} + +function mapIntegrationOutboxState(record) { + if (!record) { + return null; + } + + return { + id: record.id, + eventType: record.event_type, + status: record.status, + attempts: Number(record.attempts ?? 0), + lastError: record.last_error ?? null, + processedAt: record.processed_at?.toISOString?.() ?? null, + updatedAt: record.updated_at?.toISOString?.() ?? null, + }; +} + +async function findOutboxByKey(prisma, eventType, dedupeKey) { + if (!prisma || !eventType || !dedupeKey) { + return null; + } + + return prisma.integrationOutbox.findUnique({ + where: { + event_type_dedupe_key: { + event_type: eventType, + dedupe_key: dedupeKey, + }, + }, + }); +} + +async function findBindingByLocalId(prisma, entityType, localId) { + if (!prisma || !entityType || !localId) { + return null; + } + + return prisma.integrationBinding.findUnique({ + where: { + provider_entity_type_local_id: { + provider: 'erp', + entity_type: entityType, + local_id: localId, + }, + }, + }); +} + +async function enqueueAssetLedgerSummaryExport(prisma, config, input) { + const payload = + input?.payload ?? + buildAssetLedgerSummaryPayload({ + asset: input?.asset, + sourceEventName: input?.sourceEventName, + changeType: input?.changeType, + occurredAt: input?.occurredAt, + changedFields: input?.changedFields, + }); + + const enqueued = await enqueueIntegrationEvent(prisma, { + eventType: ASSET_ERP_EVENT_TYPE, + payload, + dedupeKey: buildAssetLedgerSummaryDedupeKey(payload.entityId), + maxAttempts: config.integrationOutboxMaxAttempts, + actorId: input?.actorId ?? null, + requeueOnFailure: input?.requeueOnFailure === true, + resetOnPayloadChange: true, + }); + + return { + ...enqueued, + eventType: ASSET_ERP_EVENT_TYPE, + erpTarget: ASSET_ERP_TARGET, + payload, + }; +} + +async function loadAssetErpContext(prisma, assetId) { + const asset = await prisma.assetCard.findUnique({ + where: { id: assetId }, + select: ASSET_ERP_CONTEXT_SELECT, + }); + + if (!asset) { + return null; + } + + return { asset }; +} + +function buildAssetTrackSummary(context) { + const asset = context?.asset ?? {}; + return { + id: asset.id, + assetNo: asset.asset_no ?? null, + name: asset.name ?? null, + status: asset.status ?? null, + legalEntity: asset.legal_entity + ? { + id: asset.legal_entity.id, + code: asset.legal_entity.code ?? null, + name: asset.legal_entity.name ?? null, + } + : null, + costCenter: asset.cost_center + ? { + id: asset.cost_center.id, + code: asset.cost_center.code ?? null, + name: asset.cost_center.name ?? null, + } + : null, + purchaseRequest: asset.purchase_request + ? { + id: asset.purchase_request.id, + requestNo: asset.purchase_request.request_no ?? null, + title: asset.purchase_request.title ?? null, + } + : null, + }; +} + +function buildAssetTrackPreview(asset, options = {}) { + return { + changeType: options.changeType ?? 'created', + sourceEventName: options.sourceEventName ?? 'asset.card.created', + categoryCode: asset?.category?.code ?? null, + legalEntityCode: asset?.legal_entity?.code ?? null, + costCenterCode: asset?.cost_center?.code ?? null, + status: asset?.status ?? null, + }; +} + +function resolveTrackStatus({ outbox, binding, gateEnabled }) { + if (outbox?.status) { + return outbox.status; + } + if (binding) { + return 'succeeded'; + } + if (!gateEnabled) { + return ASSET_TRACK_STATUSES.skipped; + } + return ASSET_TRACK_STATUSES.notTriggered; +} + +function resolveTrackAction({ canManage, gateEnabled, outbox, binding, drift }) { + if (!canManage || !gateEnabled) { + return ASSET_TRACK_ACTIONS.none; + } + + if (outbox && ['failed', 'dead_letter'].includes(outbox.status)) { + return ASSET_TRACK_ACTIONS.retry; + } + + if (!outbox && !binding) { + return ASSET_TRACK_ACTIONS.backfill; + } + + if (drift) { + return ASSET_TRACK_ACTIONS.resync; + } + + return ASSET_TRACK_ACTIONS.none; +} + +async function buildAssetErpTrack(prisma, config, assetId, options = {}) { + const context = options.context ?? (await loadAssetErpContext(prisma, assetId)); + if (!context?.asset) { + return null; + } + + const asset = context.asset; + const canManage = Boolean(options.canManage); + const gateEnabled = Boolean(options.gate?.effectiveEnabled ?? options.gate?.enabled ?? true); + const dedupeKey = buildAssetLedgerSummaryDedupeKey(asset.id); + + const [outboxRow, bindingRow] = await Promise.all([ + findOutboxByKey(prisma, ASSET_ERP_EVENT_TYPE, dedupeKey), + findBindingByLocalId(prisma, ASSET_ERP_BINDING_ENTITY_TYPE, asset.id), + ]); + + const outbox = mapIntegrationOutboxState(outboxRow); + const binding = redactBinding(mapIntegrationBinding(bindingRow), canManage); + const drift = resolveAssetLedgerSummaryDrift(asset, outboxRow?.payload ?? null); + const preview = buildAssetTrackPreview(asset, { + changeType: drift?.changeType ?? outboxRow?.payload?.changeType ?? 'created', + sourceEventName: + drift?.sourceEventName ?? + outboxRow?.payload?.sourceEventName ?? + 'asset.card.created', + }); + + return { + asset: buildAssetTrackSummary(context), + stage: { + eligible: true, + reason: !gateEnabled && !outboxRow && !bindingRow ? (options.gate?.effectiveReason ?? options.gate?.reason ?? 'erp_sync_disabled') : null, + deliveryStatus: resolveTrackStatus({ outbox, binding: bindingRow, gateEnabled }), + actionHint: resolveTrackAction({ + canManage, + gateEnabled, + outbox, + binding: bindingRow, + drift, + }), + preview, + outbox, + binding, + }, + permissions: { + canRetry: Boolean(canManage && gateEnabled), + canSeeExternalRefs: canManage, + }, + }; +} + +module.exports = { + ASSET_ERP_BINDING_ENTITY_TYPE, + ASSET_ERP_CONTEXT_SELECT, + ASSET_ERP_EVENT_TYPE, + ASSET_ERP_TARGET, + ASSET_TRACK_ACTIONS, + ASSET_TRACK_STATUSES, + buildAssetErpTrack, + buildAssetLedgerSummaryDedupeKey, + buildAssetLedgerSummaryPayload, + buildAssetTrackPreview, + enqueueAssetLedgerSummaryExport, + hashAssetPayload, + loadAssetErpContext, + resolveAssetLedgerSummaryChange, + resolveAssetLedgerSummaryDrift, + stableStringifyJson, +}; diff --git a/server/tests/unit/integrations.dispatcher.test.js b/server/tests/unit/integrations.dispatcher.test.js index 7a37ca7c..9faa3d68 100644 --- a/server/tests/unit/integrations.dispatcher.test.js +++ b/server/tests/unit/integrations.dispatcher.test.js @@ -177,6 +177,38 @@ describe('dispatchOutboxEvent', () => { ); }); + it('records asset binding for asset export', async () => { + const prisma = { + integrationBinding: { + upsert: vi.fn().mockResolvedValue({ id: 'binding-asset-1' }), + }, + }; + + await dispatchOutboxEvent({ + prisma, + config: { erpSyncEnabled: true, erpSyncProvider: 'mock' }, + eventType: 'erp.asset.export', + payload: { + exportType: 'asset_ledger_summary', + entityType: 'asset_card', + entityId: 'asset-1', + assetNo: 'ZC-001', + }, + }); + + expect(prisma.integrationBinding.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + provider_entity_type_local_id: { + provider: 'erp', + entity_type: 'asset_erp_card', + local_id: 'asset-1', + }, + }, + }), + ); + }); + it('loads IM channel and dispatches mock wecom message', async () => { const prisma = { notificationChannel: { diff --git a/server/tests/unit/routes/assets.erp.test.js b/server/tests/unit/routes/assets.erp.test.js new file mode 100644 index 00000000..8a543f83 --- /dev/null +++ b/server/tests/unit/routes/assets.erp.test.js @@ -0,0 +1,148 @@ +const assetsRoutes = require('../../../src/routes/assets'); +const { resolveAssetLedgerSummaryChange } = require('../../../src/services/integrations/erpAsset'); + +describe('asset ERP helper', () => { + const { handleAssetLedgerSummaryErpSync } = assetsRoutes.__testables; + + function createRequest() { + return { + id: 'req-1', + ip: '127.0.0.1', + headers: {}, + employee: { + id: 'actor-1', + permissionCodes: ['asset.manage'], + }, + }; + } + + function createTx() { + return { + auditLog: { + create: vi.fn().mockResolvedValue({ id: 'audit-1' }), + }, + }; + } + + function createAsset(overrides = {}) { + return { + id: 'asset-1', + asset_no: 'ZC-001', + legal_entity_id: 'le-1', + cost_center_id: 'cc-1', + status: 'in_use', + ...overrides, + }; + } + + it('auto enqueues asset export when gate is enabled', async () => { + const tx = createTx(); + const evaluateGate = vi.fn().mockResolvedValue({ + enabled: true, + reason: 'department_deferred', + }); + const enqueueExport = vi.fn().mockResolvedValue({ + id: 'outbox-1', + status: 'pending', + duplicated: false, + requeued: false, + eventType: 'erp.asset.export', + }); + + const result = await handleAssetLedgerSummaryErpSync({ + tx, + config: {}, + request: createRequest(), + actorId: 'actor-1', + asset: createAsset(), + sourceEventName: 'asset.card.created', + changeType: 'created', + changedFields: [], + evaluateGate, + enqueueExport, + }); + + expect(enqueueExport).toHaveBeenCalledWith( + tx, + {}, + expect.objectContaining({ + actorId: 'actor-1', + requeueOnFailure: false, + }), + ); + expect(tx.auditLog.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + action: 'ERP_SYNC_ENQUEUED', + target_type: 'asset_card', + target_id: 'asset-1', + }), + }), + ); + expect(result.skipped).toBe(false); + }); + + it('writes skip audit when asset ERP gate is disabled', async () => { + const tx = createTx(); + const evaluateGate = vi.fn().mockResolvedValue({ enabled: false, reason: 'flag_force_off' }); + const enqueueExport = vi.fn(); + + const result = await handleAssetLedgerSummaryErpSync({ + tx, + config: {}, + request: createRequest(), + actorId: 'actor-1', + asset: createAsset(), + sourceEventName: 'asset.card.created', + changeType: 'created', + changedFields: [], + evaluateGate, + enqueueExport, + }); + + expect(enqueueExport).not.toHaveBeenCalled(); + expect(tx.auditLog.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + action: 'ERP_SYNC_SKIPPED_BY_FEATURE_FLAG', + target_type: 'asset_card', + target_id: 'asset-1', + }), + }), + ); + expect(result.skipped).toBe(true); + }); + + it('only marks financial field and retired status changes as ERP-relevant', () => { + const current = createAsset(); + + expect( + resolveAssetLedgerSummaryChange(current, { + ...current, + name: '新资产名称', + }), + ).toBeNull(); + + expect( + resolveAssetLedgerSummaryChange(current, { + ...current, + legal_entity_id: 'le-2', + }), + ).toEqual({ + sourceEventName: 'asset.ledger.summary.updated', + changeType: 'financial_dimensions_changed', + changedFields: ['legalEntityId'], + }); + + expect( + resolveAssetLedgerSummaryChange(current, { + ...current, + status: 'retired', + }), + ).toEqual({ + sourceEventName: 'asset.ledger.summary.updated', + changeType: 'retired', + changedFields: ['status'], + }); + }); +}); diff --git a/server/tests/unit/services/erpAsset.test.js b/server/tests/unit/services/erpAsset.test.js new file mode 100644 index 00000000..36a4cfcc --- /dev/null +++ b/server/tests/unit/services/erpAsset.test.js @@ -0,0 +1,138 @@ +const { + buildAssetLedgerSummaryDedupeKey, + buildAssetLedgerSummaryPayload, + enqueueAssetLedgerSummaryExport, + hashAssetPayload, + resolveAssetLedgerSummaryChange, +} = require('../../../src/services/integrations/erpAsset'); + +describe('ERP Asset Service', () => { + function createAsset(overrides = {}) { + return { + id: 'asset-1', + asset_no: 'ZC-001', + name: 'ThinkPad X1', + status: 'in_use', + location: '上海办公室-A区', + activated_at: '2026-03-09T00:00:00.000Z', + retired_at: null, + created_at: '2026-03-09T08:00:00.000Z', + updated_at: '2026-03-09T08:00:00.000Z', + legal_entity_id: 'le-1', + cost_center_id: 'cc-1', + purchase_request_id: 'pr-1', + assignee_employee_id: 'emp-1', + category: { + id: 'cat-1', + code: 'LAPTOP', + name: '笔记本电脑', + document_category: { code: 'ASSET_STD' }, + }, + legal_entity: { id: 'le-1', code: 'HY', name: '海音达' }, + cost_center: { id: 'cc-1', code: 'IT001', name: 'IT中心' }, + purchase_request: { id: 'pr-1', request_no: 'PR-001', title: '办公电脑采购' }, + assignee: { id: 'emp-1', employee_no: 'E0001', name: '张三' }, + ...overrides, + }; + } + + it('builds asset ledger summary payload for create event', () => { + const payload = buildAssetLedgerSummaryPayload({ + asset: createAsset(), + sourceEventName: 'asset.card.created', + changeType: 'created', + changedFields: [], + occurredAt: '2026-03-09T08:00:00.000Z', + }); + + expect(payload.exportType).toBe('asset_ledger_summary'); + expect(payload.sourceEventName).toBe('asset.card.created'); + expect(payload.changeType).toBe('created'); + expect(payload.assetNo).toBe('ZC-001'); + expect(payload.category.code).toBe('LAPTOP'); + expect(payload.legalEntity.code).toBe('HY'); + expect(payload.costCenter.code).toBe('IT001'); + expect(payload.purchaseRequest.requestNo).toBe('PR-001'); + expect(payload.assignee.employeeNo).toBe('E0001'); + }); + + it('detects financial-dimension changes only', () => { + const previousAsset = createAsset(); + + expect( + resolveAssetLedgerSummaryChange(previousAsset, { + ...previousAsset, + name: 'ThinkPad X1 Carbon Gen 12', + location: '上海办公室-B区', + }), + ).toBeNull(); + + expect( + resolveAssetLedgerSummaryChange(previousAsset, { + ...previousAsset, + cost_center_id: 'cc-2', + }), + ).toEqual({ + sourceEventName: 'asset.ledger.summary.updated', + changeType: 'financial_dimensions_changed', + changedFields: ['costCenterId'], + }); + + expect( + resolveAssetLedgerSummaryChange(previousAsset, { + ...previousAsset, + status: 'retired', + }), + ).toEqual({ + sourceEventName: 'asset.ledger.summary.updated', + changeType: 'retired', + changedFields: ['status'], + }); + }); + + it('builds deterministic dedupe key and payload hash', () => { + const asset = createAsset(); + const payload = buildAssetLedgerSummaryPayload({ asset, changedFields: [] }); + const changedPayload = buildAssetLedgerSummaryPayload({ + asset: createAsset({ cost_center: { id: 'cc-2', code: 'IT002', name: 'IT二部' }, cost_center_id: 'cc-2' }), + sourceEventName: 'asset.ledger.summary.updated', + changeType: 'financial_dimensions_changed', + changedFields: ['costCenterId'], + }); + + expect(buildAssetLedgerSummaryDedupeKey('asset-1')).toBe('asset_card:asset-1:ledger_summary'); + expect(hashAssetPayload(payload)).not.toBe(hashAssetPayload(changedPayload)); + }); + + it('resets same outbox when asset payload changes', async () => { + const prisma = { + integrationOutbox: { + findUnique: vi.fn().mockResolvedValue({ + id: 'outbox-1', + status: 'succeeded', + payload: { assetNo: 'ZC-001', costCenter: { id: 'cc-1', code: 'IT001' } }, + }), + update: vi.fn().mockResolvedValue({ id: 'outbox-1', status: 'pending' }), + }, + }; + + const result = await enqueueAssetLedgerSummaryExport( + prisma, + { integrationOutboxMaxAttempts: 5 }, + { + actorId: 'actor-1', + asset: createAsset({ + cost_center_id: 'cc-2', + cost_center: { id: 'cc-2', code: 'IT002', name: 'IT二部' }, + }), + sourceEventName: 'asset.ledger.summary.updated', + changeType: 'financial_dimensions_changed', + changedFields: ['costCenterId'], + }, + ); + + expect(prisma.integrationOutbox.update).toHaveBeenCalled(); + expect(result.eventType).toBe('erp.asset.export'); + expect(result.requeued).toBe(true); + }); +}); diff --git a/server/tests/unit/services/integrations/erp.test.js b/server/tests/unit/services/integrations/erp.test.js index 616b5933..b23df1d0 100644 --- a/server/tests/unit/services/integrations/erp.test.js +++ b/server/tests/unit/services/integrations/erp.test.js @@ -167,6 +167,59 @@ describe('ERP Sync Services', () => { expect(result.retryable).toBe(true); }); + it('returns mock asset export result when provider is mock', async () => { + const result = await erp.exportAssetToErp( + { erpSyncEnabled: true, erpSyncProvider: 'mock' }, + { assetNo: 'ZC-001', entityId: 'asset-1' }, + ); + + expect(result.ok).toBe(true); + expect(result.code).toBe('mock_synced'); + expect(result.response.externalAssetId).toContain('ZC-001'); + expect(result.response.externalAssetNo).toContain('ZC-001'); + }); + + it('handles http asset export 4xx errors', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 400, + headers: { get: () => 'application/json' }, + json: vi.fn().mockResolvedValue({ code: 'bad_request' }), + }); + + const result = await erp.exportAssetToErp( + { + erpSyncEnabled: true, + erpSyncProvider: 'http', + erpAssetExportEndpoint: 'https://example.com/assets', + erpAssetTimeoutMs: 500, + }, + { exportType: 'asset_ledger_summary', assetNo: 'ZC-001' }, + ); + + expect(result.ok).toBe(false); + expect(result.code).toBe('erp_asset_http_4xx'); + expect(result.retryable).toBe(false); + }); + + it('handles http asset export timeout', async () => { + global.fetch = vi.fn().mockRejectedValue({ name: 'AbortError' }); + + const result = await erp.exportAssetToErp( + { + erpSyncEnabled: true, + erpSyncProvider: 'http', + erpAssetExportEndpoint: 'https://example.com/assets', + erpAssetTimeoutMs: 500, + }, + { exportType: 'asset_ledger_summary', assetNo: 'ZC-001' }, + ); + + expect(result.ok).toBe(false); + expect(result.code).toBe('erp_asset_timeout'); + expect(result.retryable).toBe(true); + }); + it('returns invalid provider if unsupported', async () => { const result = await erp.exportJournalToErp( { erpSyncEnabled: true, erpSyncProvider: 'invalid_xyz' }, From 07fac7d2d271ba162cfdb0e9599b68b93d22fce7 Mon Sep 17 00:00:00 2001 From: shunli Date: Mon, 9 Mar 2026 11:15:21 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat(P19ERP3-3,P19ERP3-4):=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E8=B5=84=E4=BA=A7ERP=E8=BD=A8=E9=81=93=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E4=B8=8E=E5=89=8D=E7=AB=AF=E5=8D=A1=E7=89=87=E6=8C=82?= =?UTF-8?q?=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 ? (