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