Merge branch 'feature/p19-erp-3-asset-ledger-summary' into main

这个提交包含在:
2026-03-09 11:15:21 +08:00
当前提交 38543021b3
共修改 20 个文件,包含 2038 行新增和 28 行删除
+93
查看文件
@@ -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({}),
});
}
+137
查看文件
@@ -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()}>
+11 -8
查看文件
@@ -1,7 +1,7 @@
# 领域事件清单(P19 / P19+IM-1 / P19+ERP-2)
# 领域事件清单(P19 / P19+IM-1 / P19+ERP-3)
**快照日期**:2026-03-08
**目标**:冻结 `hyb-oa` 当前采购与资产域的标准领域事件名,并补充 `P19+IM-1` 已实际消费的 IM 通知类型清单,以及 `P19+ERP-1 / ERP-2` 已实际落地的 ERP 消费映射,作为后续 ERP / IM / AI 适配的事实源。
**快照日期**:2026-03-09
**目标**:冻结 `hyb-oa` 当前采购与资产域的标准领域事件名,并补充 `P19+IM-1` 已实际消费的 IM 通知类型清单,以及 `P19+ERP-1 / ERP-3` 已实际落地的 ERP 消费映射,作为后续 ERP / IM / AI 适配的事实源。
## 1. 使用原则
- 本文档同时维护两类事实:
@@ -22,7 +22,7 @@
| `asset.card.created` | 资产卡片创建成功后 | `asset_card` | `assetId`, `assetNo`, `name`, `categoryId`, `legalEntityId`, `costCenterId`, `purchaseRequestId`, `status` | ERP、IM、AI | 资产域不接 workflow |
| `asset.assigned` | 资产领用成功后 | `asset_card` | `assetId`, `assetNo`, `employeeId`, `assignedAt`, `location`, `status` | IM、AI | 对应新增 open assignment |
| `asset.returned` | 资产归还成功后 | `asset_card` | `assetId`, `assetNo`, `employeeId`, `returnedAt`, `location`, `status` | IM、AI | 对应关闭 open assignment |
| `asset.transferred` | 资产调拨成功后 | `asset_card` | `assetId`, `assetNo`, `fromEmployeeId`, `toEmployeeId`, `fromLocation`, `toLocation`, `transferredAt`, `status` | ERP、IM、AI | 可只改位置,不一定改归属人 |
| `asset.transferred` | 资产调拨成功后 | `asset_card` | `assetId`, `assetNo`, `fromEmployeeId`, `toEmployeeId`, `fromLocation`, `toLocation`, `transferredAt`, `status` | IM、AI | 可只改位置,不一定改归属人;ERP-3 一期不消费 |
| `inventory_check.completed` | 盘点任务完成后 | `inventory_check` | `inventoryCheckId`, `checkNo`, `scopeType`, `scopeRefId`, `completedAt`, `itemCount`, `summary` | ERP、IM、AI | `summary` 建议包含 matched / missing / extra / damaged 计数 |
## 3. 事件名与运行时对象映射
@@ -63,7 +63,7 @@
## 7. 推荐消费场景
- IM:采购审批结果、报销结果、资产领用归还、盘点完成提醒
- ERP:采购审批 commitment、PO/合同归档 accrual / summary、资产卡片新增摘要、盘点完成摘要
- ERP:采购审批 commitment、PO/合同归档 accrual / summary、资产卡片 ledger summary、盘点完成摘要
- AI:审批摘要、异常提示、主数据重复建议、盘点结果总结
## 8. P19+IM-1 已消费通知类型
@@ -93,12 +93,15 @@
这些事件名称仍保留在长期蓝图中,但不属于 `P19+IM-1` 的首批协同覆盖范围。
## 10. P19+ERP-1 / ERP-2 已实现消费映射
## 10. P19+ERP-1 / ERP-3 已实现消费映射
- `P19+ERP-1` 没有新增新的领域事件名;ERP 一期真正落地消费的既有业务事实是 `reimbursement.paid`,运行时会先构建标准化 `ReimbursementJournalSlice`,再通过 `erp.journal.export` 写入 `IntegrationOutbox` 异步派发。
- `ReimbursementJournalSlice.sourceEventName` 固定为 `reimbursement.paid`,用于标记该凭证切片来源于“报销付款完成”这一既有业务事实;历史 `erp.reimbursement.paid` outbox 仍继续兼容。
- `P19+ERP-2` 正式冻结新增领域事件名 `purchase_order.archived`,并消费两类采购事实:
- `purchase_request.approved` → `PurchaseCommitmentJournalSlice` → `erp.journal.export`
- `purchase_order.archived` → `PurchaseAccrualJournalSlice` + `purchase_order_archive` summary → `erp.journal.export` / `erp.procurement.export`
- 采购 ERP 成功派发后,运行时统一把外部 ERP 编号回写到 `integration_bindings`,分别对应 `purchase_request_commitment_journal`、`purchase_order_accrual_journal`、`purchase_order_po`、`purchase_order_contract`。
- ERP 主数据同步(`LegalEntity` / `CostCenter` / `Supplier`)仍属于主动 Pull,不作为新的领域事件名纳入本表;`IntegrationSyncState` 也继续只承载主数据资源级同步状态,而不承载采购单据轨道。
- `asset.card.created`、`inventory_check.completed` 等事件继续只冻结名称与最小载荷;真实 ERP 导出留待后续 `P19+ERP-3`。
- ERP 主数据同步(`LegalEntity` / `CostCenter` / `Supplier`)仍属于主动 Pull,不作为新的领域事件名纳入本表;`IntegrationSyncState` 也继续只承载主数据资源级同步状态,而不承载采购或资产单据轨道。
- `P19+ERP-3` 一期已落地资产消费映射:
- `asset.card.created` → `AssetLedgerSummaryPayload` → `erp.asset.export`
- `PATCH /api/assets/:id` 导致 `legalEntityId / costCenterId / status(retired|archived)` 变化 → 资产 ERP 内部集成事实 → `erp.asset.export`
- 资产 ERP 成功派发后,运行时统一把外部 ERP 资产编号回写到 `integration_bindings(provider=erp, entity_type=asset_erp_card)`;领用 / 归还 / 位置调拨仍不进入 ERP 消费映射。
+12 -4
查看文件
@@ -1,8 +1,8 @@
# hyb-oa 实现状态矩阵(P5-P19+IM-1+ERP-2)
# hyb-oa 实现状态矩阵(P5-P19+IM-1+ERP-3)
**快照日期**:2026-03-08
**范围**:`F1-F42`(含 `F19b/F19c/F19d`)+ `R1-S2` + `P9A1-P9D1` + `P10E1-P10G1` + `G0-G6` + `H1` + `M1-M4` + `P13-0-P13-8` + `P14-0-P14-8` + `P15A-0-P15A-5` + `P15B-0-P15B-6` + `P16A-0-P16A-6` + `P16B-0-P16B-6` + `P16C-0-P16C-6` + `R9-0-R9-3` + `G2-0-G2-6` + `P17A-0-P17A-6` + `P17B-0-P17B-6` + `P18A-0-P18A-6` + `P18B-0-P18B-6` + `P19-0-P19-6` + `P19IM1-0-P19IM1-6` + `P19ERP1-0-P19ERP1-6`,共 206 项。
**事实源**:`issues/2026-02-09_13-39-02-p5-blueprint-v3.csv` + `issues/2026-02-09_23-30-00-p6-p8-remaining-work-master.csv` + `issues/2026-02-10_09-42-31-p9-maintenance-performance-matrix.csv` + `issues/2026-02-10_10-35-30-p10-production-hardening.csv` + `issues/2026-02-11-p11-p12-governance-mobile.csv` + `issues/2026-02-12_01-09-41-p13-l5-governance.csv` + `issues/2026-02-13_00-11-56-p14-l5-operationalization.csv` + `issues/2026-03-06_14-30-00-p15a-l5-permission-decouple.csv` + `issues/2026-03-06_14-30-00-p15b-l5-leave-v1.csv` + `issues/2026-03-07_13-47-05-p16a-l5-master-data-core.csv` + `issues/2026-03-07_16-22-07-p16b-l5-holiday-calendar.csv` + `issues/2026-03-07_17-14-39-p16c-l5-suppliers.csv` + `issues/2026-03-07_18-15-03-r9-g2-p19-roadmap-refresh.csv` + `issues/2026-03-07_18-16-00-g2-l5-cross-domain-bridge.csv` + `issues/2026-03-07_19-33-09-p17a-l5-procurement-core.csv` + `issues/2026-03-07_21-00-37-p17b-l5-procurement-budget-archive.csv` + `issues/2026-03-07_22-02-09-p18a-l5-asset-registry-core.csv` + `issues/2026-03-07_22-34-03-p18b-l5-asset-movements-inventory.csv` + `issues/2026-03-07_23-22-00-p19-l4-integration-readiness.csv` + `issues/2026-03-08_12-18-10-p19-im-1-l5-collaboration-adapter.csv` + `issues/2026-03-08_14-10-53-p19-erp-1-l5-masterdata-journal-bridge.csv` + `issues/2026-03-08_22-46-51-p19-erp-2-l5-procurement-commitment-archive.csv`(执行合同)
**快照日期**:2026-03-09
**范围**:`F1-F42`(含 `F19b/F19c/F19d`)+ `R1-S2` + `P9A1-P9D1` + `P10E1-P10G1` + `G0-G6` + `H1` + `M1-M4` + `P13-0-P13-8` + `P14-0-P14-8` + `P15A-0-P15A-5` + `P15B-0-P15B-6` + `P16A-0-P16A-6` + `P16B-0-P16B-6` + `P16C-0-P16C-6` + `R9-0-R9-3` + `G2-0-G2-6` + `P17A-0-P17A-6` + `P17B-0-P17B-6` + `P18A-0-P18A-6` + `P18B-0-P18B-6` + `P19-0-P19-6` + `P19IM1-0-P19IM1-6` + `P19ERP1-0-P19ERP1-6` + `P19ERP2-0-P19ERP2-6` + `P19ERP3-0-P19ERP3-6`,共 220 项。
**事实源**:`issues/2026-02-09_13-39-02-p5-blueprint-v3.csv` + `issues/2026-02-09_23-30-00-p6-p8-remaining-work-master.csv` + `issues/2026-02-10_09-42-31-p9-maintenance-performance-matrix.csv` + `issues/2026-02-10_10-35-30-p10-production-hardening.csv` + `issues/2026-02-11-p11-p12-governance-mobile.csv` + `issues/2026-02-12_01-09-41-p13-l5-governance.csv` + `issues/2026-02-13_00-11-56-p14-l5-operationalization.csv` + `issues/2026-03-06_14-30-00-p15a-l5-permission-decouple.csv` + `issues/2026-03-06_14-30-00-p15b-l5-leave-v1.csv` + `issues/2026-03-07_13-47-05-p16a-l5-master-data-core.csv` + `issues/2026-03-07_16-22-07-p16b-l5-holiday-calendar.csv` + `issues/2026-03-07_17-14-39-p16c-l5-suppliers.csv` + `issues/2026-03-07_18-15-03-r9-g2-p19-roadmap-refresh.csv` + `issues/2026-03-07_18-16-00-g2-l5-cross-domain-bridge.csv` + `issues/2026-03-07_19-33-09-p17a-l5-procurement-core.csv` + `issues/2026-03-07_21-00-37-p17b-l5-procurement-budget-archive.csv` + `issues/2026-03-07_22-02-09-p18a-l5-asset-registry-core.csv` + `issues/2026-03-07_22-34-03-p18b-l5-asset-movements-inventory.csv` + `issues/2026-03-07_23-22-00-p19-l4-integration-readiness.csv` + `issues/2026-03-08_12-18-10-p19-im-1-l5-collaboration-adapter.csv` + `issues/2026-03-08_14-10-53-p19-erp-1-l5-masterdata-journal-bridge.csv` + `issues/2026-03-08_22-46-51-p19-erp-2-l5-procurement-commitment-archive.csv` + `issues/2026-03-09_00-10-00-p19-erp-3-l5-asset-ledger-summary.csv`(执行合同)
**说明**:
- 本矩阵用于全局读图(审计/交接/回归覆盖追踪),不替代 `issues/*.csv` 的执行合同职责。
@@ -247,3 +247,11 @@
| P19ERP2-5 | 文档事件与矩阵同步 | N/A | 2026-03-08_22-46-51-p19-erp-2-l5-procurement-commitment-archive.csv | 2026-03-08_22-46-51-p19-erp-2-l5-procurement-commitment-archive.csv | 2026-03-08 | DONE | validate-feature-matrix | 集成边界、领域事件、Feature Matrix 与校验器同步到 `P5-P19+IM-1+ERP-2`,并纳入 ERP-2 合同事实源 |
| P19ERP2-6 | P19ERP2回归与验收收口 | N/A | 2026-03-08_22-46-51-p19-erp-2-l5-procurement-commitment-archive.csv | 2026-03-08_22-46-51-p19-erp-2-l5-procurement-commitment-archive.csv | 2026-03-08 | DONE | server:test + regression-integrations + regression-all + client:typecheck/build | 采购双阶段 journal、archive summary 覆盖同步、详情挂件、权限边界与既有 ERP / IM 主流程回归全部通过 |
| P19ERP3-0 | P19ERP3执行合同建档 | N/A | 2026-03-09_00-10-00-p19-erp-3-l5-asset-ledger-summary.csv | 2026-03-09_00-10-00-p19-erp-3-l5-asset-ledger-summary.csv | 2026-03-09 | DONE | CSV 校验 | 资产 ERP 台账摘要执行合同建档,冻结仅做 asset ledger summary export 与仓库事实对齐 |
| P19ERP3-1 | 资产ERP出站契约与provider扩展 | N/A | 2026-03-09_00-10-00-p19-erp-3-l5-asset-ledger-summary.csv | 2026-03-09_00-10-00-p19-erp-3-l5-asset-ledger-summary.csv | 2026-03-09 | DONE | server:test | 新增 `erp.asset.export` 契约、`exportAssetToErp` provider 与 dispatcher / binding 回写规则,不新增核心表 ERP 字段 |
| P19ERP3-2 | 资产建卡与财务维度变更自动出站 | N/A | 2026-03-09_00-10-00-p19-erp-3-l5-asset-ledger-summary.csv | 2026-03-09_00-10-00-p19-erp-3-l5-asset-ledger-summary.csv | 2026-03-09 | DONE | server:test + regression-integrations | `POST /api/assets` 与财务维度 patch 自动写入资产 ERP 摘要 outbox,gate 关闭时仅 skip 不阻断业务 |
| P19ERP3-3 | 资产ERP轨道查询与重试接口 | N/A | 2026-03-09_00-10-00-p19-erp-3-l5-asset-ledger-summary.csv | 2026-03-09_00-10-00-p19-erp-3-l5-asset-ledger-summary.csv | 2026-03-09 | DONE | server:test + regression-integrations | 新增资产 ERP `track / requeue` 接口,支持脱敏查看、历史补发与失败重试 |
| P19ERP3-4 | 资产详情ERP轨道挂件 | N/A | 2026-03-09_00-10-00-p19-erp-3-l5-asset-ledger-summary.csv | 2026-03-09_00-10-00-p19-erp-3-l5-asset-ledger-summary.csv | 2026-03-09 | DONE | client:typecheck/build | `AssetRegistryPage` 详情侧新增资产 ERP 同步轨道挂件,按权限区分状态查看与补发操作 |
| P19ERP3-5 | 文档事件与矩阵同步 | N/A | 2026-03-09_00-10-00-p19-erp-3-l5-asset-ledger-summary.csv | 2026-03-09_00-10-00-p19-erp-3-l5-asset-ledger-summary.csv | 2026-03-09 | DONE | validate-feature-matrix | 集成边界、领域事件、Feature Matrix 与校验器同步到 `P5-P19+IM-1+ERP-3` 并纳入 ERP-3 合同事实源 |
| P19ERP3-6 | P19ERP3回归与验收收口 | N/A | 2026-03-09_00-10-00-p19-erp-3-l5-asset-ledger-summary.csv | 2026-03-09_00-10-00-p19-erp-3-l5-asset-ledger-summary.csv | 2026-03-09 | DONE | server:test + regression-integrations + regression-all + client:typecheck/build | 资产 ERP 摘要出站、详情挂件、脱敏权限、同一 outbox 覆盖同步与既有采购 / 报销 / IM 主流程回归全部通过 |
+13 -5
查看文件
@@ -1,7 +1,7 @@
# 集成边界冻结(P19 / P19+IM-1 / P19+ERP-2)
# 集成边界冻结(P19 / P19+IM-1 / P19+ERP-3)
**快照日期**:2026-03-08
**当前基线**:`P19+IM-1`、`P19+ERP-1` 与 `P19+ERP-2` 已完成;当前运行时已落地企微群机器人 + 深链 + Outbox 异步派发的一期协同适配,以及 ERP 主数据 Pull、报销付款凭证切片、采购双阶段 journal 与归档摘要出站。
**快照日期**:2026-03-09
**当前基线**:`P19+IM-1`、`P19+ERP-1`、`P19+ERP-2` 与 `P19+ERP-3` 已完成;当前运行时已落地企微群机器人 + 深链 + Outbox 异步派发的一期协同适配,以及 ERP 主数据 Pull、报销付款凭证切片、采购双阶段 journal / 归档摘要出站、资产台账摘要出站。
**目标**:在保持核心域稳定的前提下,冻结 ERP / IM / AI / MCP 的长期边界,并记录 IM 一期与 ERP 两期的已实现范围、禁入规则和后续扩展位。
## 1. 总体原则
@@ -28,7 +28,7 @@
### 2.1 允许进入适配层的对象
- 主数据:`LegalEntity`、`CostCenter`、`Supplier`
- 采购:`purchase_request` 审批结果、预算占用确认结果、PO / 合同归档摘要
- 资产:`asset_card` 基础卡片信息、归属变更摘要、盘点完成摘要
- 资产:`asset_card` 基础卡片信息、财务维度变更摘要、盘点完成摘要
### 2.2 推荐适配方式
- **主数据同步**:
@@ -49,7 +49,7 @@
### 2.4 建议后续专项
- `P19+ERP-1`:主数据同步协议
- `P19+ERP-2`:采购双阶段 journal 与归档摘要出站
- `P19+ERP-3`:资产台账摘要导出协议
- `P19+ERP-3`:资产台账摘要导出协议(已完成一期)
### 2.5 P19+ERP-1 已实现范围
- 已新增 `IntegrationBinding` 与 `IntegrationSyncState` 两张集成层私有表,用于维护 ERP 映射与同步状态,不向核心事务表注入 ERP 私有字段。
@@ -66,6 +66,14 @@
- 已明确 `IntegrationSyncState` 继续只表示资源级主数据同步状态,不承载采购单据级轨道;采购详情轨道只允许由 `IntegrationOutbox + IntegrationBinding + 当前单据事实` 推导。
- 已将采购 ERP 轨道挂件固定在采购详情 / 归档区:所有可读用户仅查看阶段状态,`integration.manage` 才能查看 ERP 外部编号并执行手动补发 / 重试。
### 2.7 P19+ERP-3 已实现范围
- 已新增 `erp.asset.export` 单一资产 ERP 事件,固定用于 `asset_card` 的 ledger summary export,不进入 `erp.journal.export`,也不向核心资产表注入 ERP 私有字段。
- 已冻结资产一期触发边界:仅 `POST /api/assets` 建卡成功,以及 `PATCH /api/assets/:id` 导致 `legalEntityId / costCenterId / status(retired|archived)` 发生变化时自动出站。
- 已明确 `POST /api/assets/:id/assign`、`POST /api/assets/:id/return`、`POST /api/assets/:id/transfer` 与仅 `name / location / serialNo / activatedAt` 变化不触发 ERP,同步边界仅覆盖财务台账摘要。
- 已为单资产固定 `asset_card:{assetId}:ledger_summary` dedupe key,并采用“同一条 outbox 覆盖同步”语义:payload 无变化时返回历史记录,payload 有变化或失败 / 死信时仅重置同一条记录。
- 已在 `integration_bindings` 中固定资产映射 `asset_erp_card`;成功派发后回写 ERP 资产外部编号与最近同步时间。
- 已将资产 ERP 轨道挂件固定在资产详情侧边栏:所有可读用户查看同步状态,`integration.manage` 才能查看 ERP 外部编号并执行补发 / 重试;`IntegrationSyncState` 继续不承载资产单据级轨道。
## 3. IM / 协同适配边界
### 3.1 一期已实现能力(P19+IM-1)
@@ -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不出站与非管理员脱敏
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
2 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不存在且以仓库事实和当前实施计划为准
3 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 不含金额折旧字段
4 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
5 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 并禁止重试
6 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"] 挂件位于资产流转卡片下方 保存按钮上方 仅详情态显示
7 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消费映射与事实源
8 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不出站与非管理员脱敏
+1
查看文件
@@ -41,6 +41,7 @@ CSV_FACT_SOURCES = [
ROOT / "issues" / "2026-03-08_12-18-10-p19-im-1-l5-collaboration-adapter.csv",
ROOT / "issues" / "2026-03-08_14-10-53-p19-erp-1-l5-masterdata-journal-bridge.csv",
ROOT / "issues" / "2026-03-08_22-46-51-p19-erp-2-l5-procurement-commitment-archive.csv",
ROOT / "issues" / "2026-03-09_00-10-00-p19-erp-3-l5-asset-ledger-summary.csv",
]
ID_PATTERN = re.compile(r"^[A-Z][0-9][0-9A-Za-z-]*$")
+261 -7
查看文件
@@ -271,6 +271,54 @@ const server = http.createServer(async (req, res) => {
});
}
if (req.method === 'POST' && url.pathname === '/erp/assets/export') {
const mode = url.searchParams.get('mode') || 'success';
const chunks = [];
for await (const chunk of req) {
chunks.push(chunk);
}
let parsedBody = null;
try {
parsedBody = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
} catch {
parsedBody = null;
}
if (mode === 'timeout') {
setTimeout(() => {
sendJson(res, 200, {
ok: true,
externalAssetId: `ERP-ASSET-${Date.now()}`,
externalAssetNo: `ERP-ASSET-${parsedBody?.assetNo || Date.now()}`,
});
}, 1500);
return;
}
if (mode === 'client') {
return sendJson(res, 422, {
ok: false,
code: 'invalid_payload',
message: 'mock asset client error',
});
}
if (mode === 'server') {
return sendJson(res, 503, {
ok: false,
code: 'service_unavailable',
message: 'mock asset server error',
});
}
return sendJson(res, 200, {
ok: true,
externalAssetId: `ERP-ASSET-${parsedBody?.assetNo || parsedBody?.entityId || Date.now()}`,
externalAssetNo: `ERP-ASSET-${parsedBody?.assetNo || parsedBody?.entityId || Date.now()}`,
});
}
sendJson(res, 404, { ok: false, message: 'not_found' });
});
@@ -304,9 +352,11 @@ start_app_server() {
ERP_MASTER_DATA_ENDPOINT="http://127.0.0.1:$MOCK_ERP_PORT/erp/master-data/pull?mode=success" \
ERP_JOURNAL_EXPORT_ENDPOINT="http://127.0.0.1:$MOCK_ERP_PORT/erp/journals/export?mode=success" \
ERP_PROCUREMENT_EXPORT_ENDPOINT="http://127.0.0.1:$MOCK_ERP_PORT/erp/procurement/export?mode=success" \
ERP_ASSET_EXPORT_ENDPOINT="http://127.0.0.1:$MOCK_ERP_PORT/erp/assets/export?mode=success" \
ERP_SYNC_ENDPOINT="http://127.0.0.1:$MOCK_ERP_PORT/erp/reimbursements/paid?mode=success" \
ERP_SYNC_TIMEOUT_MS="$ERP_SYNC_TIMEOUT_MS" \
ERP_PROCUREMENT_TIMEOUT_MS="$ERP_SYNC_TIMEOUT_MS" \
ERP_ASSET_TIMEOUT_MS="$ERP_SYNC_TIMEOUT_MS" \
ERP_MASTER_DATA_TIMEOUT_MS="$ERP_SYNC_TIMEOUT_MS" \
ERP_SYNC_AUTH_TOKEN="" \
node src/index.js >"$TMP_DIR/app.log" 2>&1 &
@@ -404,6 +454,39 @@ const { createPrismaClient } = require('./src/prismaClient');
NODE
}
reset_erp_integration_state() {
DATABASE_URL="$DATABASE_URL" node <<'NODE'
require('dotenv/config');
const { createPrismaClient } = require('./src/prismaClient');
(async () => {
const { prisma, pool } = createPrismaClient();
await prisma.$connect();
try {
await prisma.integrationOutbox.deleteMany({
where: {
event_type: {
in: ['erp.reimbursement.paid', 'erp.journal.export', 'erp.procurement.export', 'erp.asset.export'],
},
},
});
await prisma.integrationBinding.deleteMany({
where: { provider: 'erp' },
});
await prisma.integrationSyncState.deleteMany({
where: { provider: 'erp' },
});
} finally {
await prisma.$disconnect();
await pool.end();
}
})().catch((error) => {
console.error(error);
process.exit(1);
});
NODE
}
create_paid_reimbursement() {
DATABASE_URL="$DATABASE_URL" RUN_SUFFIX="$RUN_SUFFIX" node <<'NODE'
require('dotenv/config');
@@ -762,7 +845,8 @@ NODE
run_outbox_once() {
local journal_mode="$1"
local procurement_mode="${2:-$journal_mode}"
DATABASE_URL="$DATABASE_URL" INTEGRATION_OUTBOX_BATCH_SIZE=500 ERP_SYNC_ENABLED=true ERP_SYNC_PROVIDER=http ERP_JOURNAL_EXPORT_ENDPOINT="http://127.0.0.1:$MOCK_ERP_PORT/erp/journals/export?mode=$journal_mode" ERP_PROCUREMENT_EXPORT_ENDPOINT="http://127.0.0.1:$MOCK_ERP_PORT/erp/procurement/export?mode=$procurement_mode" ERP_SYNC_ENDPOINT="http://127.0.0.1:$MOCK_ERP_PORT/erp/reimbursements/paid?mode=$journal_mode" ERP_SYNC_TIMEOUT_MS="$ERP_SYNC_TIMEOUT_MS" ERP_PROCUREMENT_TIMEOUT_MS="$ERP_SYNC_TIMEOUT_MS" ERP_SYNC_AUTH_TOKEN="" npm run --silent job:integration-outbox-once >"$TMP_DIR/outbox-once.log"
local asset_mode="${3:-$journal_mode}"
DATABASE_URL="$DATABASE_URL" INTEGRATION_OUTBOX_BATCH_SIZE=500 ERP_SYNC_ENABLED=true ERP_SYNC_PROVIDER=http ERP_JOURNAL_EXPORT_ENDPOINT="http://127.0.0.1:$MOCK_ERP_PORT/erp/journals/export?mode=$journal_mode" ERP_PROCUREMENT_EXPORT_ENDPOINT="http://127.0.0.1:$MOCK_ERP_PORT/erp/procurement/export?mode=$procurement_mode" ERP_ASSET_EXPORT_ENDPOINT="http://127.0.0.1:$MOCK_ERP_PORT/erp/assets/export?mode=$asset_mode" ERP_SYNC_ENDPOINT="http://127.0.0.1:$MOCK_ERP_PORT/erp/reimbursements/paid?mode=$journal_mode" ERP_SYNC_TIMEOUT_MS="$ERP_SYNC_TIMEOUT_MS" ERP_PROCUREMENT_TIMEOUT_MS="$ERP_SYNC_TIMEOUT_MS" ERP_ASSET_TIMEOUT_MS="$ERP_SYNC_TIMEOUT_MS" ERP_SYNC_AUTH_TOKEN="" npm run --silent job:integration-outbox-once >"$TMP_DIR/outbox-once.log"
}
assert_pull_mode() {
@@ -863,6 +947,92 @@ archive_purchase_order() {
jq -er '.data.archiveOrder.id' "$TMP_DIR/${tmp_prefix}-archive.json"
}
resolve_asset_fixture_ids() {
DATABASE_URL="$DATABASE_URL" LEGAL_ENTITY_CODE="$LEGAL_ENTITY_CODE" COST_CENTER_CODE="$COST_CENTER_CODE" node - <<'NODE'
require('dotenv/config');
const { createPrismaClient } = require('./src/prismaClient');
(async () => {
const { prisma, pool } = createPrismaClient();
await prisma.$connect();
try {
const employee = await prisma.employee.findUnique({
where: { email: process.env.SEED_EMPLOYEE_EMAIL ?? '[email protected]' },
select: { id: true, legal_entity_id: true },
});
const primaryCostCenter = await prisma.costCenter.findFirst({
where: {
enabled: true,
archived_at: null,
legal_entity_id: employee.legal_entity_id,
},
orderBy: { created_at: 'asc' },
select: { id: true },
});
const syncedLegalEntity = await prisma.legalEntity.findUnique({
where: { code: process.env.LEGAL_ENTITY_CODE },
select: { id: true },
});
const syncedCostCenter = await prisma.costCenter.findFirst({
where: {
code: process.env.COST_CENTER_CODE,
legal_entity_id: syncedLegalEntity?.id ?? undefined,
},
select: { id: true },
});
process.stdout.write([
employee.id,
employee.legal_entity_id,
primaryCostCenter?.id ?? '',
syncedLegalEntity?.id ?? '',
syncedCostCenter?.id ?? '',
].join('\n'));
} finally {
await prisma.$disconnect();
await pool.end();
}
})().catch((error) => {
console.error(error);
process.exit(1);
});
NODE
}
create_asset_category() {
local token="$1"
local tmp_prefix="$2"
local category_code="AST-${RUN_SUFFIX_NUMERIC:0:8}-${tmp_prefix}"
local status
status="$(api_json_auth POST "/api/asset-categories" "$token" "$TMP_DIR/${tmp_prefix}-asset-category.json" "$(jq -nc --arg code "$category_code" --arg name "资产分类-${tmp_prefix}" '{code:$code, name:$name, enabled:true}')")"
assert_status "$status" "200" "create asset category ${tmp_prefix}" "$TMP_DIR/${tmp_prefix}-asset-category.json"
jq -er '.data.assetCategory.id' "$TMP_DIR/${tmp_prefix}-asset-category.json"
}
create_asset_card() {
local token="$1"
local category_id="$2"
local legal_entity_id="$3"
local cost_center_id="$4"
local assignee_employee_id="$5"
local tmp_prefix="$6"
local body
body="$(jq -nc \
--arg name "资产ERP-${tmp_prefix}" \
--arg categoryId "$category_id" \
--arg legalEntityId "$legal_entity_id" \
--arg costCenterId "$cost_center_id" \
--arg assigneeEmployeeId "$assignee_employee_id" \
--arg location "上海办公室-A区-${tmp_prefix}" \
'{name:$name, categoryId:$categoryId, legalEntityId:$legalEntityId, costCenterId:$costCenterId, assigneeEmployeeId:($assigneeEmployeeId|select(length>0)), location:$location, activatedAt:"2026-03-09"}')"
local status
status="$(api_json_auth POST "/api/assets" "$token" "$TMP_DIR/${tmp_prefix}-asset-create.json" "$body")"
assert_status "$status" "200" "create asset ${tmp_prefix}" "$TMP_DIR/${tmp_prefix}-asset-create.json"
jq -er '.data.asset.id' "$TMP_DIR/${tmp_prefix}-asset-create.json"
}
assert_export_mode_semantics() {
local mode="$1"
local state_json="$2"
@@ -935,6 +1105,9 @@ run_export_mode_case() {
echo "[0/9] Ensure ERP feature flag force_on"
set_erp_flag "force_on"
echo "[0.5/9] Reset ERP integration artifacts"
reset_erp_integration_state
echo "[1/9] Start embedded mock ERP server"
start_mock_erp_server
@@ -1013,6 +1186,13 @@ SUPPLIER_ID="${FIXTURE_IDS[2]}"
DOCUMENT_CATEGORY_ID="${FIXTURE_IDS[3]}"
BUDGET_POOL_ID="${FIXTURE_IDS[4]}"
PROC_SUCCESS_PO="PO-P19ERP2-${RUN_SUFFIX_NUMERIC}-001"
PROC_SUCCESS_CONTRACT_1="CT-P19ERP2-${RUN_SUFFIX_NUMERIC}-001"
PROC_SUCCESS_CONTRACT_2="CT-P19ERP2-${RUN_SUFFIX_NUMERIC}-002"
PROC_SUCCESS_CONTRACT_3="CT-P19ERP2-${RUN_SUFFIX_NUMERIC}-003"
PROC_SKIPPED_PO="PO-P19ERP2-${RUN_SUFFIX_NUMERIC}-101"
PROC_SKIPPED_CONTRACT="CT-P19ERP2-${RUN_SUFFIX_NUMERIC}-101"
mapfile -t PROCUREMENT_APPROVED < <(create_and_approve_purchase "$EMPLOYEE_TOKEN" "$MANAGER_TOKEN" "procurement erp success" "proc-success")
PURCHASE_ID_APPROVED="${PROCUREMENT_APPROVED[0]}"
COMMITMENT_DEDUPE="purchase_request:${PURCHASE_ID_APPROVED}:commitment"
@@ -1024,7 +1204,7 @@ jq -e '.status == "succeeded"' <<<"$commitment_state_done" >/dev/null
commitment_binding="$(query_binding_by_key 'purchase_request_commitment_journal' "$PURCHASE_ID_APPROVED")"
jq -e '.externalCode != null' <<<"$commitment_binding" >/dev/null
ARCHIVE_ID_APPROVED="$(archive_purchase_order "$FINANCE_MGR_TOKEN" "$PURCHASE_ID_APPROVED" 'PO-P19ERP2-001' 'CT-P19ERP2-001' 'proc-success')"
ARCHIVE_ID_APPROVED="$(archive_purchase_order "$FINANCE_MGR_TOKEN" "$PURCHASE_ID_APPROVED" "$PROC_SUCCESS_PO" "$PROC_SUCCESS_CONTRACT_1" 'proc-success')"
ACCRUAL_DEDUPE="purchase_order_archive:${ARCHIVE_ID_APPROVED}:accrual"
SUMMARY_DEDUPE="purchase_order_archive:${ARCHIVE_ID_APPROVED}:summary"
accrual_state="$(query_outbox_by_key 'erp.journal.export' "$ACCRUAL_DEDUPE")"
@@ -1044,14 +1224,14 @@ jq -e '.externalCode != null' <<<"$po_binding" >/dev/null
jq -e '.externalCode != null' <<<"$contract_binding" >/dev/null
echo "[10/13] Archive update only resets summary"
ARCHIVE_UPDATE_STATUS="$(api_json_auth POST "/api/purchase-requests/$PURCHASE_ID_APPROVED/archive-order" "$FINANCE_MGR_TOKEN" "$TMP_DIR/proc-success-archive-update.json" '{"poNo":"PO-P19ERP2-001","contractNo":"CT-P19ERP2-002","archivedFiles":[]}')"
ARCHIVE_UPDATE_STATUS="$(api_json_auth POST "/api/purchase-requests/$PURCHASE_ID_APPROVED/archive-order" "$FINANCE_MGR_TOKEN" "$TMP_DIR/proc-success-archive-update.json" "$(jq -nc --arg po "$PROC_SUCCESS_PO" --arg contract "$PROC_SUCCESS_CONTRACT_2" '{poNo:$po, contractNo:$contract, archivedFiles:[]}')")"
assert_status "$ARCHIVE_UPDATE_STATUS" "200" "archive update" "$TMP_DIR/proc-success-archive-update.json"
accrual_state_after_update="$(query_outbox_by_key 'erp.journal.export' "$ACCRUAL_DEDUPE")"
summary_state_after_update="$(query_outbox_by_key 'erp.procurement.export' "$SUMMARY_DEDUPE")"
ACCRUAL_OUTBOX_ID="$(jq -er '.id' <<<"$accrual_state_done")"
SUMMARY_OUTBOX_ID="$(jq -er '.id' <<<"$summary_state_done")"
jq -e --arg id "$ACCRUAL_OUTBOX_ID" '.id == $id and .status == "succeeded"' <<<"$accrual_state_after_update" >/dev/null
jq -e --arg id "$SUMMARY_OUTBOX_ID" '.id == $id and .status == "pending" and .payload.contractNo == "CT-P19ERP2-002"' <<<"$summary_state_after_update" >/dev/null
jq -e --arg id "$SUMMARY_OUTBOX_ID" --arg contract "$PROC_SUCCESS_CONTRACT_2" '.id == $id and .status == "pending" and .payload.contractNo == $contract' <<<"$summary_state_after_update" >/dev/null
run_outbox_once success success
contract_binding_updated="$(query_binding_by_key 'purchase_order_contract' "$ARCHIVE_ID_APPROVED")"
jq -e '.externalCode != null' <<<"$contract_binding_updated" >/dev/null
@@ -1062,7 +1242,7 @@ mapfile -t PROCUREMENT_SKIPPED < <(create_and_approve_purchase "$EMPLOYEE_TOKEN"
PURCHASE_ID_SKIPPED="${PROCUREMENT_SKIPPED[0]}"
COMMITMENT_DEDUPE_SKIPPED="purchase_request:${PURCHASE_ID_SKIPPED}:commitment"
assert_outbox_absent_by_key 'erp.journal.export' "$COMMITMENT_DEDUPE_SKIPPED"
ARCHIVE_ID_SKIPPED="$(archive_purchase_order "$FINANCE_MGR_TOKEN" "$PURCHASE_ID_SKIPPED" 'PO-P19ERP2-101' 'CT-P19ERP2-101' 'proc-skipped')"
ARCHIVE_ID_SKIPPED="$(archive_purchase_order "$FINANCE_MGR_TOKEN" "$PURCHASE_ID_SKIPPED" "$PROC_SKIPPED_PO" "$PROC_SKIPPED_CONTRACT" 'proc-skipped')"
ACCRUAL_DEDUPE_SKIPPED="purchase_order_archive:${ARCHIVE_ID_SKIPPED}:accrual"
SUMMARY_DEDUPE_SKIPPED="purchase_order_archive:${ARCHIVE_ID_SKIPPED}:summary"
assert_outbox_absent_by_key 'erp.journal.export' "$ACCRUAL_DEDUPE_SKIPPED"
@@ -1077,7 +1257,7 @@ jq -e '.status == "succeeded"' <<<"$(query_outbox_by_key 'erp.journal.export' "$
jq -e '.status == "succeeded"' <<<"$(query_outbox_by_key 'erp.procurement.export' "$SUMMARY_DEDUPE_SKIPPED")" >/dev/null
echo "[12/13] Archive summary dead-letter can requeue same outbox"
PROC_SUMMARY_FAIL_STATUS="$(api_json_auth POST "/api/purchase-requests/$PURCHASE_ID_APPROVED/archive-order" "$FINANCE_MGR_TOKEN" "$TMP_DIR/proc-summary-fail-archive.json" '{"poNo":"PO-P19ERP2-001","contractNo":"CT-P19ERP2-003","archivedFiles":[]}')"
PROC_SUMMARY_FAIL_STATUS="$(api_json_auth POST "/api/purchase-requests/$PURCHASE_ID_APPROVED/archive-order" "$FINANCE_MGR_TOKEN" "$TMP_DIR/proc-summary-fail-archive.json" "$(jq -nc --arg po "$PROC_SUCCESS_PO" --arg contract "$PROC_SUCCESS_CONTRACT_3" '{poNo:$po, contractNo:$contract, archivedFiles:[]}')")"
assert_status "$PROC_SUMMARY_FAIL_STATUS" "200" "archive update for summary failure" "$TMP_DIR/proc-summary-fail-archive.json"
run_outbox_once success client
summary_dead_state="$(query_outbox_by_key 'erp.procurement.export' "$SUMMARY_DEDUPE")"
@@ -1090,7 +1270,79 @@ run_outbox_once success success
summary_requeue_state="$(query_outbox_by_key 'erp.procurement.export' "$SUMMARY_DEDUPE")"
jq -e --arg id "$SUMMARY_DEAD_ID" '.id == $id and .status == "succeeded"' <<<"$summary_requeue_state" >/dev/null
echo "[13/13] ERP env gate off blocks sync"
echo "[13/18] Asset ledger summary auto export + binding"
mapfile -t ASSET_FIXTURE_IDS < <(resolve_asset_fixture_ids)
ASSET_ASSIGNEE_ID="${ASSET_FIXTURE_IDS[0]}"
ASSET_BASE_LEGAL_ENTITY_ID="${ASSET_FIXTURE_IDS[1]}"
ASSET_BASE_COST_CENTER_ID="${ASSET_FIXTURE_IDS[2]}"
ASSET_SYNCED_LEGAL_ENTITY_ID="${ASSET_FIXTURE_IDS[3]}"
ASSET_SYNCED_COST_CENTER_ID="${ASSET_FIXTURE_IDS[4]}"
ASSET_CATEGORY_ID="$(create_asset_category "$FINANCE_MGR_TOKEN" 'asset-success')"
ASSET_ID_APPROVED="$(create_asset_card "$FINANCE_MGR_TOKEN" "$ASSET_CATEGORY_ID" "$ASSET_BASE_LEGAL_ENTITY_ID" "$ASSET_BASE_COST_CENTER_ID" "$ASSET_ASSIGNEE_ID" 'asset-success')"
ASSET_DEDUPE="asset_card:${ASSET_ID_APPROVED}:ledger_summary"
asset_state_created="$(query_outbox_by_key 'erp.asset.export' "$ASSET_DEDUPE")"
jq -e '.status == "pending" and .payload.exportType == "asset_ledger_summary" and .payload.changeType == "created"' <<<"$asset_state_created" >/dev/null
run_outbox_once success success success
asset_state_done="$(query_outbox_by_key 'erp.asset.export' "$ASSET_DEDUPE")"
jq -e '.status == "succeeded"' <<<"$asset_state_done" >/dev/null
asset_binding="$(query_binding_by_key 'asset_erp_card' "$ASSET_ID_APPROVED")"
jq -e '.externalCode != null and .lastSyncedAt != null' <<<"$asset_binding" >/dev/null
ASSET_OUTBOX_ID="$(jq -er '.id' <<<"$asset_state_done")"
ASSET_TRACK_EMPLOYEE_CODE="$(api_json_auth GET "/api/integrations/erp/assets/$ASSET_ID_APPROVED/track" "$EMPLOYEE_TOKEN" "$TMP_DIR/asset-track-employee.json")"
assert_status "$ASSET_TRACK_EMPLOYEE_CODE" "200" "employee asset erp track" "$TMP_DIR/asset-track-employee.json"
jq -e '.data.stage.deliveryStatus == "succeeded" and .data.stage.binding.externalCode == null and .data.permissions.canRetry == false' "$TMP_DIR/asset-track-employee.json" >/dev/null
ASSET_REQUEUE_FORBIDDEN_CODE="$(api_json_auth POST "/api/integrations/erp/assets/$ASSET_ID_APPROVED/requeue?runNow=0" "$EMPLOYEE_TOKEN" "$TMP_DIR/asset-requeue-forbidden.json" '{}')"
assert_status "$ASSET_REQUEUE_FORBIDDEN_CODE" "403" "asset requeue forbidden" "$TMP_DIR/asset-requeue-forbidden.json"
echo "[14/18] Non-financial patch does not reset asset outbox"
ASSET_PATCH_NON_FIN_CODE="$(api_json_auth PATCH "/api/assets/$ASSET_ID_APPROVED" "$FINANCE_MGR_TOKEN" "$TMP_DIR/asset-non-financial.json" '{"name":"资产ERP-asset-success-v2","location":"上海办公室-B区"}')"
assert_status "$ASSET_PATCH_NON_FIN_CODE" "200" "asset non financial patch" "$TMP_DIR/asset-non-financial.json"
asset_state_after_non_fin="$(query_outbox_by_key 'erp.asset.export' "$ASSET_DEDUPE")"
jq -e --arg id "$ASSET_OUTBOX_ID" '.id == $id and .status == "succeeded"' <<<"$asset_state_after_non_fin" >/dev/null
echo "[15/18] Financial patch resets same asset outbox"
ASSET_PATCH_FIN_CODE="$(api_json_auth PATCH "/api/assets/$ASSET_ID_APPROVED" "$FINANCE_MGR_TOKEN" "$TMP_DIR/asset-financial.json" "$(jq -nc --arg le "$ASSET_SYNCED_LEGAL_ENTITY_ID" --arg cc "$ASSET_SYNCED_COST_CENTER_ID" '{legalEntityId:$le, costCenterId:$cc}')")"
assert_status "$ASSET_PATCH_FIN_CODE" "200" "asset financial patch" "$TMP_DIR/asset-financial.json"
asset_state_after_fin="$(query_outbox_by_key 'erp.asset.export' "$ASSET_DEDUPE")"
jq -e --arg id "$ASSET_OUTBOX_ID" --arg le "$ASSET_SYNCED_LEGAL_ENTITY_ID" --arg cc "$ASSET_SYNCED_COST_CENTER_ID" '.id == $id and .status == "pending" and .payload.changeType == "financial_dimensions_changed" and .payload.legalEntity.id == $le and .payload.costCenter.id == $cc' <<<"$asset_state_after_fin" >/dev/null
run_outbox_once success success success
asset_state_after_fin_done="$(query_outbox_by_key 'erp.asset.export' "$ASSET_DEDUPE")"
jq -e '.status == "succeeded"' <<<"$asset_state_after_fin_done" >/dev/null
echo "[16/18] Retired asset dead-letter can requeue same outbox"
ASSET_PATCH_RETIRED_CODE="$(api_json_auth PATCH "/api/assets/$ASSET_ID_APPROVED" "$FINANCE_MGR_TOKEN" "$TMP_DIR/asset-retired.json" '{"status":"retired","retiredAt":"2026-03-09"}')"
assert_status "$ASSET_PATCH_RETIRED_CODE" "200" "asset retired patch" "$TMP_DIR/asset-retired.json"
asset_state_retired_pending="$(query_outbox_by_key 'erp.asset.export' "$ASSET_DEDUPE")"
jq -e --arg id "$ASSET_OUTBOX_ID" '.id == $id and .status == "pending" and .payload.changeType == "retired"' <<<"$asset_state_retired_pending" >/dev/null
run_outbox_once success success client
asset_state_dead="$(query_outbox_by_key 'erp.asset.export' "$ASSET_DEDUPE")"
jq -e '.status == "dead_letter" and .attempts == 1 and (.lastError | tostring | test("erp_asset_http_4xx|422"))' <<<"$asset_state_dead" >/dev/null
ASSET_DEAD_OUTBOX_ID="$(jq -er '.id' <<<"$asset_state_dead")"
ASSET_REQUEUE_CODE="$(api_json_auth POST "/api/integrations/erp/assets/$ASSET_ID_APPROVED/requeue?runNow=0" "$FINANCE_MGR_TOKEN" "$TMP_DIR/asset-requeue.json" '{}')"
assert_status "$ASSET_REQUEUE_CODE" "200" "asset requeue" "$TMP_DIR/asset-requeue.json"
jq -e --arg id "$ASSET_DEAD_OUTBOX_ID" '.data.outboxId == $id and .data.requeued == true' "$TMP_DIR/asset-requeue.json" >/dev/null
run_outbox_once success success success
asset_state_requeued_done="$(query_outbox_by_key 'erp.asset.export' "$ASSET_DEDUPE")"
jq -e --arg id "$ASSET_DEAD_OUTBOX_ID" '.id == $id and .status == "succeeded"' <<<"$asset_state_requeued_done" >/dev/null
echo "[17/18] Force-off skips asset auto export and manual requeue backfills historical资产"
set_erp_flag "force_off"
ASSET_CATEGORY_SKIPPED_ID="$(create_asset_category "$FINANCE_MGR_TOKEN" 'asset-skipped')"
ASSET_ID_SKIPPED="$(create_asset_card "$FINANCE_MGR_TOKEN" "$ASSET_CATEGORY_SKIPPED_ID" "$ASSET_BASE_LEGAL_ENTITY_ID" "$ASSET_BASE_COST_CENTER_ID" "$ASSET_ASSIGNEE_ID" 'asset-skipped')"
ASSET_DEDUPE_SKIPPED="asset_card:${ASSET_ID_SKIPPED}:ledger_summary"
assert_outbox_absent_by_key 'erp.asset.export' "$ASSET_DEDUPE_SKIPPED"
ASSET_PATCH_SKIPPED_CODE="$(api_json_auth PATCH "/api/assets/$ASSET_ID_SKIPPED" "$FINANCE_MGR_TOKEN" "$TMP_DIR/asset-skipped-patch.json" "$(jq -nc --arg le "$ASSET_SYNCED_LEGAL_ENTITY_ID" --arg cc "$ASSET_SYNCED_COST_CENTER_ID" '{legalEntityId:$le, costCenterId:$cc}')")"
assert_status "$ASSET_PATCH_SKIPPED_CODE" "200" "asset skipped patch" "$TMP_DIR/asset-skipped-patch.json"
assert_outbox_absent_by_key 'erp.asset.export' "$ASSET_DEDUPE_SKIPPED"
set_erp_flag "force_on"
ASSET_BACKFILL_CODE="$(api_json_auth POST "/api/integrations/erp/assets/$ASSET_ID_SKIPPED/requeue?runNow=0" "$FINANCE_MGR_TOKEN" "$TMP_DIR/asset-backfill.json" '{}')"
assert_status "$ASSET_BACKFILL_CODE" "200" "asset backfill" "$TMP_DIR/asset-backfill.json"
jq -e '.data.duplicated == false and .data.status == "pending"' "$TMP_DIR/asset-backfill.json" >/dev/null
run_outbox_once success success success
asset_backfill_state="$(query_outbox_by_key 'erp.asset.export' "$ASSET_DEDUPE_SKIPPED")"
jq -e '.status == "succeeded"' <<<"$asset_backfill_state" >/dev/null
echo "[18/18] ERP env gate off blocks sync"
if kill -0 "$APP_SERVER_PID" >/dev/null 2>&1; then
kill "$APP_SERVER_PID" >/dev/null 2>&1 || true
wait "$APP_SERVER_PID" >/dev/null 2>&1 || true
@@ -1105,8 +1357,10 @@ ERP_SYNC_PROVIDER=http \
ERP_MASTER_DATA_ENDPOINT="http://127.0.0.1:$MOCK_ERP_PORT/erp/master-data/pull?mode=success" \
ERP_JOURNAL_EXPORT_ENDPOINT="http://127.0.0.1:$MOCK_ERP_PORT/erp/journals/export?mode=success" \
ERP_PROCUREMENT_EXPORT_ENDPOINT="http://127.0.0.1:$MOCK_ERP_PORT/erp/procurement/export?mode=success" \
ERP_ASSET_EXPORT_ENDPOINT="http://127.0.0.1:$MOCK_ERP_PORT/erp/assets/export?mode=success" \
ERP_SYNC_TIMEOUT_MS="$ERP_SYNC_TIMEOUT_MS" \
ERP_PROCUREMENT_TIMEOUT_MS="$ERP_SYNC_TIMEOUT_MS" \
ERP_ASSET_TIMEOUT_MS="$ERP_SYNC_TIMEOUT_MS" \
ERP_MASTER_DATA_TIMEOUT_MS="$ERP_SYNC_TIMEOUT_MS" \
ERP_SYNC_AUTH_TOKEN="" \
node src/index.js >"$TMP_DIR/app-gate-off.log" 2>&1 &
+4
查看文件
@@ -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,
),
+5
查看文件
@@ -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,
+120
查看文件
@@ -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,
};
+235
查看文件
@@ -27,6 +27,15 @@ const {
enqueuePurchaseCommitmentJournalExport,
loadPurchaseRequestErpContext,
} = 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 MASTER_DATA_RESOURCE_ENUM = z.enum(['legal_entity', 'cost_center', 'supplier']);
@@ -268,6 +277,98 @@ async function fetchReadablePurchaseRequestContext(prisma, purchaseRequestId, re
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) {
const purchaseRequest = context?.purchaseRequest;
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(
'/integrations/erp/sync/:reimbursementId',
{
@@ -852,7 +1085,9 @@ async function integrationsRoutes(fastify) {
}
integrationsRoutes.__testables = {
canReadAsset,
canReadPurchaseRequest,
enqueueAssetExportAndMaybeRun,
ensureProcurementTargetEligible,
normalizeProcurementTargets,
normalizeResourcesInput,
+19 -4
查看文件
@@ -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 });
}
+57
查看文件
@@ -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,
@@ -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,
};
@@ -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: {
+148
查看文件
@@ -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'],
});
});
});
@@ -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);
});
});
+138
查看文件
@@ -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);
});
});
@@ -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' },