diff --git a/client/src/api/integrations.ts b/client/src/api/integrations.ts index 688f15c1..ea29d420 100644 --- a/client/src/api/integrations.ts +++ b/client/src/api/integrations.ts @@ -66,6 +66,99 @@ export type ReimbursementJournalPreview = { }; }; +export type IntegrationOutboxStatus = + | "pending" + | "processing" + | "succeeded" + | "failed" + | "dead_letter"; + +export type IntegrationOutboxPayloadSummary = { + entityType: string | null; + entityId: string | null; + sourceEventName: string | null; + journalType: string | null; + exportType: string | null; + identityCode: string | null; +}; + +export type IntegrationOutboxItem = { + id: string; + eventType: string; + status: IntegrationOutboxStatus | string; + payloadSummary: IntegrationOutboxPayloadSummary; + attempts: number; + maxAttempts: number; + nextRetryAt: string | null; + createdAt: string | null; + processedAt: string | null; + deadLetterAt: string | null; +}; + +export type IntegrationOutboxDetail = IntegrationOutboxItem & { + payload: Record | null; + lastError: string | null; +}; + +export type IntegrationOutboxListResponse = { + page: number; + pageSize: number; + total: number; + items: IntegrationOutboxItem[]; +}; + +export type IntegrationOutboxStats = { + pending: number; + processing: number; + succeeded: number; + failed: number; + deadLetter: number; +}; + +export type IntegrationOutboxQuery = { + page?: number; + pageSize?: number; + status?: IntegrationOutboxStatus; + eventType?: string; +}; + +export type IntegrationBindingItem = { + id: string; + provider: string; + entityType: string; + localId: string; + externalId: string | null; + externalCode: string | null; + lastSyncedAt: string | null; + routePath: string | null; +}; + +export type IntegrationBindingListResponse = { + page: number; + pageSize: number; + total: number; + items: IntegrationBindingItem[]; +}; + +export type IntegrationBindingQuery = { + page?: number; + pageSize?: number; + provider?: string; + entityType?: string; +}; + +function toQueryString(query: Record) { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) { + if (value == null || value === "") { + continue; + } + params.set(key, typeof value === "boolean" ? (value ? "1" : "0") : String(value)); + } + const encoded = params.toString(); + return encoded ? `?${encoded}` : ""; +} + export async function triggerErpSync(reimbursementId: string, runNow = true) { return apiRequestData<{ outboxId: string; @@ -107,6 +200,45 @@ export async function exportReimbursementJournal(reimbursementId: string, runNow }); } +export async function fetchIntegrationOutbox(query: IntegrationOutboxQuery = {}) { + return apiRequestData( + `/api/integrations/outbox${toQueryString({ + page: query.page, + pageSize: query.pageSize, + status: query.status, + event_type: query.eventType, + })}`, + ); +} + +export async function fetchIntegrationOutboxDetail(outboxId: string) { + const response = await apiRequestData<{ item: IntegrationOutboxDetail }>(`/api/integrations/outbox/${outboxId}`); + return response.item; +} + +export async function retryIntegrationOutbox(outboxId: string) { + const response = await apiRequestData<{ item: IntegrationOutboxDetail }>(`/api/integrations/outbox/${outboxId}/retry`, { + method: "POST", + body: JSON.stringify({}), + }); + return response.item; +} + +export async function fetchIntegrationOutboxStats() { + return apiRequestData("/api/integrations/outbox/stats"); +} + +export async function fetchIntegrationBindings(query: IntegrationBindingQuery = {}) { + return apiRequestData( + `/api/integrations/bindings${toQueryString({ + page: query.page, + pageSize: query.pageSize, + provider: query.provider, + entity_type: query.entityType, + })}`, + ); +} + export async function importTravelOrders(payload: { orders: Array<{ source: string; diff --git a/client/src/pages/AssetRegistryPage.tsx b/client/src/pages/AssetRegistryPage.tsx index d5103624..49d03ae8 100644 --- a/client/src/pages/AssetRegistryPage.tsx +++ b/client/src/pages/AssetRegistryPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { useNavigate, useSearchParams } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { mapApiErrorMessage } from "../api/errorMessages"; @@ -31,6 +31,7 @@ import { PageHeader } from "../components/common/PageHeader"; import { AiSuggestionCard } from "../components/ai/AiSuggestionCard"; import { StatusBadge } from "../components/common/StatusBadge"; import { TableSkeleton } from "../components/common/TableSkeleton"; +import { SectionPanel } from "../components/patterns/SectionPanel"; import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table"; @@ -114,6 +115,7 @@ function resolveAssetErpActionLabel(actionHint: AssetErpTrack["stage"]["actionHi export function AssetRegistryPage() { const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); const queryClient = useQueryClient(); const { permissions } = useAuth(); @@ -133,7 +135,7 @@ export function AssetRegistryPage() { const [assigneeFilter, setAssigneeFilter] = useState(""); const [mine, setMine] = useState(!canViewAll); - const [selectedAssetId, setSelectedAssetId] = useState(""); + const [selectedAssetId, setSelectedAssetId] = useState(searchParams.get("id") ?? ""); const [isCreatingAsset, setIsCreatingAsset] = useState(false); const [assetForm, setAssetForm] = useState(initialAssetForm); const [assetFormError, setAssetFormError] = useState(""); @@ -167,7 +169,7 @@ export function AssetRegistryPage() { }); const assetErpTrackQuery = useQuery({ - enabled: Boolean(selectedAssetId) && !isCreatingAsset, + enabled: canIntegrationManage && Boolean(selectedAssetId) && !isCreatingAsset, queryKey: ["assets", "erp-track", selectedAssetId], queryFn: () => fetchAssetErpTrack(selectedAssetId), }); @@ -199,6 +201,13 @@ export function AssetRegistryPage() { queryFn: () => fetchPurchaseRequests({ status: "approved", mine: false, page: 1, pageSize: 100 }), }); + useEffect(() => { + const searchSelectedId = searchParams.get("id") ?? ""; + if (searchSelectedId && searchSelectedId !== selectedAssetId) { + setSelectedAssetId(searchSelectedId); + } + }, [searchParams, selectedAssetId]); + useEffect(() => { if (isCreatingAsset) { return; @@ -217,6 +226,32 @@ export function AssetRegistryPage() { } }, [assetDetailQuery.isError, assetDetailQuery.isLoading, assetsQuery.data?.items, isCreatingAsset, selectedAssetId]); + useEffect(() => { + const currentId = searchParams.get("id") ?? ""; + if (isCreatingAsset) { + if (!currentId) { + return; + } + const next = new URLSearchParams(searchParams); + next.delete("id"); + setSearchParams(next, { replace: true }); + return; + } + if (!selectedAssetId && !currentId) { + return; + } + if (selectedAssetId === currentId) { + return; + } + const next = new URLSearchParams(searchParams); + if (selectedAssetId) { + next.set("id", selectedAssetId); + } else { + next.delete("id"); + } + setSearchParams(next, { replace: true }); + }, [isCreatingAsset, searchParams, selectedAssetId, setSearchParams]); + useEffect(() => { if (!assetDetailQuery.data?.asset) { return; @@ -937,15 +972,13 @@ export function AssetRegistryPage() { ) : null} - {selectedAssetId && !isCreatingAsset ? ( -
-
-

资产 ERP 同步轨道

-

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

-
- + {selectedAssetId && !isCreatingAsset && canIntegrationManage ? ( + {assetErpTrackQuery.isLoading ? ( -

ERP 轨道加载中...

+

ERP 同步轨道加载中...

) : assetErpTrackQuery.isError ? (

{resolveError(assetErpTrackQuery.error)}

) : assetErpStage ? ( @@ -984,7 +1017,7 @@ export function AssetRegistryPage() {
) : null} - {canIntegrationManage && assetErpTrack?.permissions.canRetry && assetErpActionLabel ? ( + {assetErpTrack?.permissions.canRetry && assetErpActionLabel ? (
+ ) : null} {selectedAssetId && !isCreatingAsset && currentAsset ? ( diff --git a/client/src/pages/ErpIntegrationPage.tsx b/client/src/pages/ErpIntegrationPage.tsx index e297ebb8..1f6b0517 100644 --- a/client/src/pages/ErpIntegrationPage.tsx +++ b/client/src/pages/ErpIntegrationPage.tsx @@ -1,16 +1,37 @@ import { useMemo, useState } from "react"; +import { Link } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { fetchErpMasterDataStatus, + fetchIntegrationBindings, + fetchIntegrationOutbox, + fetchIntegrationOutboxDetail, + fetchIntegrationOutboxStats, + retryIntegrationOutbox, syncErpMasterData, type ErpMasterDataResourceType, type ErpMasterDataSyncItem, + type IntegrationBindingItem, + type IntegrationOutboxItem, + type IntegrationOutboxStatus, } from "../api/integrations"; import { mapApiErrorMessage } from "../api/errorMessages"; import { isApiError } from "../api/types"; import { PageHeader } from "../components/common/PageHeader"; +import { Pagination } from "../components/common/Pagination"; +import { TableSkeleton } from "../components/common/TableSkeleton"; import { Button } from "../components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "../components/ui/dialog"; +import { Input } from "../components/ui/input"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs"; const RESOURCE_LABELS: Record = { legal_entity: "法体", @@ -18,6 +39,29 @@ const RESOURCE_LABELS: Record = { supplier: "供应商", }; +const OUTBOX_STATUS_OPTIONS: Array<{ value: IntegrationOutboxStatus | ""; label: string }> = [ + { value: "", label: "全部状态" }, + { value: "pending", label: "待处理" }, + { value: "processing", label: "处理中" }, + { value: "succeeded", label: "已成功" }, + { value: "failed", label: "失败" }, + { value: "dead_letter", label: "死信" }, +]; + +const BINDING_ENTITY_OPTIONS: Array<{ value: string; label: string }> = [ + { value: "", label: "全部类型" }, + { value: "reimbursement_journal", label: "报销凭证" }, + { value: "purchase_request_commitment_journal", label: "采购 Commitment" }, + { value: "purchase_order_accrual_journal", label: "采购 Accrual" }, + { value: "purchase_order_po", label: "采购 PO 映射" }, + { value: "purchase_order_contract", label: "采购合同映射" }, + { value: "asset_erp_card", label: "资产台账摘要" }, + { value: "travel_payable_summary", label: "差旅应付摘要" }, + { value: "travel_settlement_batch", label: "差旅结算批次" }, +]; + +type ErpTab = "master-data" | "outbox" | "bindings"; + function resolveError(error: unknown) { if (isApiError(error)) { return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`; @@ -33,6 +77,27 @@ function formatDateTime(value: string | null) { return value.slice(0, 19).replace("T", " "); } +function formatRelativeTime(value: string | null) { + if (!value) return "-"; + const target = new Date(value).getTime(); + if (!Number.isFinite(target)) return "-"; + const diffSeconds = Math.round((target - Date.now()) / 1000); + const abs = Math.abs(diffSeconds); + if (abs < 60) { + return diffSeconds >= 0 ? `${abs} 秒后` : `${abs} 秒前`; + } + const diffMinutes = Math.round(diffSeconds / 60); + if (Math.abs(diffMinutes) < 60) { + return diffMinutes >= 0 ? `${diffMinutes} 分钟后` : `${Math.abs(diffMinutes)} 分钟前`; + } + const diffHours = Math.round(diffMinutes / 60); + if (Math.abs(diffHours) < 24) { + return diffHours >= 0 ? `${diffHours} 小时后` : `${Math.abs(diffHours)} 小时前`; + } + const diffDays = Math.round(diffHours / 24); + return diffDays >= 0 ? `${diffDays} 天后` : `${Math.abs(diffDays)} 天前`; +} + function formatGateReason(reason: string) { if (reason === "env_hard_off") return "Env 总闸关闭"; if (reason === "flag_force_off") return "功能开关强制关闭"; @@ -42,15 +107,112 @@ function formatGateReason(reason: string) { return reason; } +function outboxStatusLabel(status: string) { + switch (status) { + case "pending": + return "待处理"; + case "processing": + return "处理中"; + case "succeeded": + return "已成功"; + case "failed": + return "失败"; + case "dead_letter": + return "死信"; + default: + return status; + } +} + +function outboxStatusClassName(status: string) { + if (status === "failed" || status === "dead_letter") { + return "border-red-200 bg-red-50 text-red-700"; + } + if (status === "succeeded") { + return "border-emerald-200 bg-emerald-50 text-emerald-700"; + } + if (status === "processing") { + return "border-amber-200 bg-amber-50 text-amber-700"; + } + return "border-zinc-200 bg-zinc-50 text-zinc-700"; +} + +function renderOutboxSummary(item: IntegrationOutboxItem) { + const summary = item.payloadSummary; + return summary.identityCode ?? summary.entityId ?? "-"; +} + +function bindingEntityLabel(value: string) { + return BINDING_ENTITY_OPTIONS.find((item) => item.value === value)?.label ?? value; +} + +function BindingLocalIdCell({ item }: { item: IntegrationBindingItem }) { + if (!item.routePath) { + return {item.localId}; + } + + return ( + + {item.localId} + + ); +} + export function ErpIntegrationPage() { const queryClient = useQueryClient(); + const [tab, setTab] = useState("master-data"); const [error, setError] = useState(""); + const [outboxPage, setOutboxPage] = useState(1); + const [outboxStatus, setOutboxStatus] = useState(""); + const [outboxEventType, setOutboxEventType] = useState(""); + const [selectedOutboxId, setSelectedOutboxId] = useState(""); + + const [bindingsPage, setBindingsPage] = useState(1); + const [bindingProvider, setBindingProvider] = useState(""); + const [bindingEntityType, setBindingEntityType] = useState(""); + const statusQuery = useQuery({ queryKey: ["erp-master-data-status"], queryFn: () => fetchErpMasterDataStatus(), }); + const outboxStatsQuery = useQuery({ + enabled: tab === "outbox", + queryKey: ["integration-outbox-stats"], + queryFn: () => fetchIntegrationOutboxStats(), + }); + + const outboxQuery = useQuery({ + enabled: tab === "outbox", + queryKey: ["integration-outbox", outboxPage, outboxStatus, outboxEventType], + queryFn: () => + fetchIntegrationOutbox({ + page: outboxPage, + pageSize: 20, + status: outboxStatus || undefined, + eventType: outboxEventType.trim() || undefined, + }), + }); + + const outboxDetailQuery = useQuery({ + enabled: Boolean(selectedOutboxId), + queryKey: ["integration-outbox-detail", selectedOutboxId], + queryFn: () => fetchIntegrationOutboxDetail(selectedOutboxId), + }); + + const bindingsQuery = useQuery({ + enabled: tab === "bindings", + queryKey: ["integration-bindings", bindingsPage, bindingProvider, bindingEntityType], + queryFn: () => + fetchIntegrationBindings({ + page: bindingsPage, + pageSize: 20, + provider: bindingProvider || undefined, + entityType: bindingEntityType || undefined, + }), + }); + const syncMutation = useMutation({ mutationFn: async (resources?: ErpMasterDataResourceType[]) => syncErpMasterData({ resources }), onSuccess: async (result) => { @@ -68,6 +230,22 @@ export function ErpIntegrationPage() { }, }); + const retryMutation = useMutation({ + mutationFn: retryIntegrationOutbox, + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["integration-outbox"] }), + queryClient.invalidateQueries({ queryKey: ["integration-outbox-stats"] }), + queryClient.invalidateQueries({ queryKey: ["integration-outbox-detail"] }), + ]); + toast.success("Outbox 事件已重置为待处理"); + }, + onError: (mutationError) => { + const message = resolveError(mutationError); + toast.error(message); + }, + }); + const itemsByType = useMemo(() => { const map = new Map(); for (const item of statusQuery.data?.items ?? []) { @@ -77,16 +255,20 @@ export function ErpIntegrationPage() { }, [statusQuery.data?.items]); const gate = statusQuery.data?.gate ?? null; + const outboxTotalPages = Math.max(1, Math.ceil((outboxQuery.data?.total ?? 0) / 20)); + const bindingsTotalPages = Math.max(1, Math.ceil((bindingsQuery.data?.total ?? 0) / 20)); return (
syncMutation.mutate(undefined)} disabled={syncMutation.isPending}> - {syncMutation.isPending ? "同步中..." : "同步全部"} - + tab === "master-data" ? ( + + ) : null } /> @@ -94,98 +276,407 @@ export function ErpIntegrationPage() {

{error}

) : null} -
-
-

ERP 状态

- {statusQuery.isLoading ?

加载中...

: null} - {statusQuery.isError ? ( -

{resolveError(statusQuery.error)}

- ) : null} - {gate ? ( -
-
-
Env 总闸
-
{gate.envEnabled ? "已开启" : "已关闭"}
-
-
-
功能开关
-
{gate.effectiveEnabled ? "允许" : "阻断"}
-
{formatGateReason(gate.effectiveReason)}
-
-
-
Provider / 主数据端点
-
{gate.provider}
-
- {gate.masterDataEndpointConfigured ? "主数据端点已配置" : "主数据端点未配置"} -
-
-
-
凭证端点 / Cron
-
- {gate.journalEndpointConfigured ? "凭证端点已配置" : "凭证端点未配置"} -
-
{gate.masterDataSyncCron || "未启用自动同步"}
-
-
- ) : null} -
+ setTab(value as ErpTab)}> + + 主数据同步 + Outbox 队列 + ID 映射 + - {(["legal_entity", "cost_center", "supplier"] as const).map((resourceType) => { - const item = itemsByType.get(resourceType) ?? null; - return ( -
-
-
-

{RESOURCE_LABELS[resourceType]}

-

状态:{item?.status ?? "idle"}

-
- -
- -
-
最近开始:{formatDateTime(item?.lastStartedAt ?? null)}
-
最近完成:{formatDateTime(item?.lastFinishedAt ?? null)}
-
最近成功:{formatDateTime(item?.lastSuccessAt ?? null)}
-
最近错误:{item?.lastError ?? "-"}
-
- - {item?.lastResult ? ( -
+ +
+
+

ERP 状态

+ {statusQuery.isLoading ?

加载中...

: null} + {statusQuery.isError ? ( +

{resolveError(statusQuery.error)}

+ ) : null} + {gate ? ( +
- 请求 {item.lastResult.requested} / 新增 {item.lastResult.created} / 更新 {item.lastResult.updated} +
Env 总闸
+
{gate.envEnabled ? "已开启" : "已关闭"}
- 归档 {item.lastResult.archived} / 跳过 {item.lastResult.skipped} +
功能开关
+
{gate.effectiveEnabled ? "允许" : "阻断"}
+
{formatGateReason(gate.effectiveReason)}
- {item.lastResult.sampleSkipped.length ? ( -
- 示例跳过: - {item.lastResult.sampleSkipped - .map((sample) => `${sample.key}:${sample.reason}`) - .join(";")} +
+
Provider / 主数据端点
+
{gate.provider}
+
+ {gate.masterDataEndpointConfigured ? "主数据端点已配置" : "主数据端点未配置"}
- ) : null} +
+
+
凭证端点 / Cron
+
+ {gate.journalEndpointConfigured ? "凭证端点已配置" : "凭证端点未配置"} +
+
{gate.masterDataSyncCron || "未启用自动同步"}
+
- ) : ( -

暂无同步记录。

- )} + ) : null}
- ); - })} -
-
-

说明

-

- 本页只负责 ERP 主数据同步,不承担报销凭证的手工单号输入导出。

-

- 报销付款凭证导出请前往已付款报销详情页执行,系统会按标准凭证切片通过 Outbox 异步发送。

-

- 当前阶段不包含采购、资产、盘点的真实 ERP 过账。

-
+ {(["legal_entity", "cost_center", "supplier"] as const).map((resourceType) => { + const item = itemsByType.get(resourceType) ?? null; + return ( +
+
+
+

{RESOURCE_LABELS[resourceType]}

+

状态:{item?.status ?? "idle"}

+
+ +
+ +
+
最近开始:{formatDateTime(item?.lastStartedAt ?? null)}
+
最近完成:{formatDateTime(item?.lastFinishedAt ?? null)}
+
最近成功:{formatDateTime(item?.lastSuccessAt ?? null)}
+
最近错误:{item?.lastError ?? "-"}
+
+ + {item?.lastResult ? ( +
+
+ 请求 {item.lastResult.requested} / 新增 {item.lastResult.created} / 更新 {item.lastResult.updated} +
+
+ 归档 {item.lastResult.archived} / 跳过 {item.lastResult.skipped} +
+ {item.lastResult.sampleSkipped.length ? ( +
+ 示例跳过: + {item.lastResult.sampleSkipped + .map((sample) => `${sample.key}:${sample.reason}`) + .join(";")} +
+ ) : null} +
+ ) : ( +

暂无同步记录。

+ )} +
+ ); + })} +
+ +
+

说明

+

- 本页只负责 ERP 主数据同步、Outbox 队列和 ID 映射,不承担业务单据本身的录入。

+

- 报销、采购和资产的 ERP 同步动作请前往对应业务详情区执行。

+

- 当前阶段不接入真实 ERP Provider,仍以 mock Provider 为准。

+
+
+ + +
+ {OUTBOX_STATUS_OPTIONS.filter((item) => item.value).map((item) => { + const status = item.value as IntegrationOutboxStatus; + const count = + status === "pending" + ? outboxStatsQuery.data?.pending + : status === "processing" + ? outboxStatsQuery.data?.processing + : status === "succeeded" + ? outboxStatsQuery.data?.succeeded + : status === "failed" + ? outboxStatsQuery.data?.failed + : outboxStatsQuery.data?.deadLetter; + + return ( +
+
{outboxStatusLabel(status)}
+
+ {outboxStatsQuery.isLoading ? "-" : count ?? 0} +
+
+ ); + })} +
+ +
+
+
+

Outbox 事件列表

+

支持按状态和事件类型筛选,失败和死信事件可在此重试。

+
+
+ + +
+
+ + {outboxQuery.isLoading ? : null} + {outboxQuery.isError ? ( +

+ {resolveError(outboxQuery.error)} +

+ ) : null} + + {outboxQuery.data ? ( + <> + + + + 事件类型 + 摘要标识 + 状态 + 尝试 + 下次重试 + 创建时间 + 处理时间 + 操作 + + + + {outboxQuery.data.items.length ? ( + outboxQuery.data.items.map((item) => ( + + {item.eventType} + +
+
{renderOutboxSummary(item)}
+
+ {item.payloadSummary.entityType ?? "-"} + {item.payloadSummary.sourceEventName ? ` · ${item.payloadSummary.sourceEventName}` : ""} +
+
+
+ + + {outboxStatusLabel(item.status)} + + + + {item.attempts} / {item.maxAttempts} + + {formatDateTime(item.nextRetryAt)} + {formatDateTime(item.createdAt)} + {formatDateTime(item.processedAt)} + +
+ + {item.status === "failed" || item.status === "dead_letter" ? ( + + ) : null} +
+
+
+ )) + ) : ( + + + 当前筛选下暂无 Outbox 事件 + + + )} +
+
+ + + + ) : null} +
+
+ + +
+
+
+

IntegrationBinding 列表

+

查看本地单据与 ERP 外部 ID/编号的映射关系,并支持深链回到业务页。

+
+
+ + +
+
+ + {bindingsQuery.isLoading ? : null} + {bindingsQuery.isError ? ( +

+ {resolveError(bindingsQuery.error)} +

+ ) : null} + + {bindingsQuery.data ? ( + <> + + + + Provider + 实体类型 + 本地 ID + 外部 ID + 外部编号 + 最后同步 + + + + {bindingsQuery.data.items.length ? ( + bindingsQuery.data.items.map((item) => ( + + {item.provider} + {bindingEntityLabel(item.entityType)} + + + + {item.externalId ?? "-"} + {item.externalCode ?? "-"} + {formatRelativeTime(item.lastSyncedAt)} + + )) + ) : ( + + + 当前筛选下暂无 ID 映射记录 + + + )} + +
+ + + + ) : null} +
+
+ + + { + if (!open) { + setSelectedOutboxId(""); + } + }} + > + + + Outbox 事件详情 + 查看完整 payload、错误摘要和当前队列状态。 + + + {outboxDetailQuery.isLoading ?

详情加载中...

: null} + {outboxDetailQuery.isError ? ( +

+ {resolveError(outboxDetailQuery.error)} +

+ ) : null} + + {outboxDetailQuery.data ? ( +
+
+
Outbox ID:{outboxDetailQuery.data.id}
+
事件类型:{outboxDetailQuery.data.eventType}
+
状态:{outboxStatusLabel(outboxDetailQuery.data.status)}
+
尝试次数:{outboxDetailQuery.data.attempts} / {outboxDetailQuery.data.maxAttempts}
+
创建时间:{formatDateTime(outboxDetailQuery.data.createdAt)}
+
处理时间:{formatDateTime(outboxDetailQuery.data.processedAt)}
+
下次重试:{formatDateTime(outboxDetailQuery.data.nextRetryAt)}
+
死信时间:{formatDateTime(outboxDetailQuery.data.deadLetterAt)}
+
+ +
+
最近错误
+
+ {outboxDetailQuery.data.lastError ?? "-"} +
+
+ +
+
Payload
+
+                  {JSON.stringify(outboxDetailQuery.data.payload ?? null, null, 2)}
+                
+
+
+ ) : null} +
+
); } diff --git a/client/src/pages/PurchaseRequestsPage.tsx b/client/src/pages/PurchaseRequestsPage.tsx index 358fef83..77bb533e 100644 --- a/client/src/pages/PurchaseRequestsPage.tsx +++ b/client/src/pages/PurchaseRequestsPage.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useSearchParams } from "react-router-dom"; import { toast } from "sonner"; import { isApiError } from "../api/types"; import { mapApiErrorMessage } from "../api/errorMessages"; @@ -43,6 +44,7 @@ import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table"; import { Textarea } from "../components/ui/textarea"; +import { SectionPanel } from "../components/patterns/SectionPanel"; import { buildPrecheckDialogMessage } from "../lib/precheck"; const PAGE_SIZE = 20; @@ -200,6 +202,7 @@ function resolveStageAction(stageKey: PurchaseErpStageKey, stage: PurchaseReques export function PurchaseRequestsPage() { const queryClient = useQueryClient(); const { permissions } = useAuth(); + const [searchParams, setSearchParams] = useSearchParams(); const canCreate = hasPermission(permissions, "procurement.create"); const canManage = hasPermission(permissions, "procurement.manage"); @@ -214,7 +217,7 @@ export function PurchaseRequestsPage() { const [legalEntityFilter, setLegalEntityFilter] = useState(""); const [costCenterFilter, setCostCenterFilter] = useState(""); const [page, setPage] = useState(1); - const [selectedId, setSelectedId] = useState(""); + const [selectedId, setSelectedId] = useState(searchParams.get("id") ?? ""); const [hydratedDetailId, setHydratedDetailId] = useState(""); const [formError, setFormError] = useState(""); @@ -278,12 +281,20 @@ export function PurchaseRequestsPage() { const erpTrackQuery = useQuery({ enabled: + canIntegrationManage && Boolean(selectedId) && (detailQuery.data?.status === "approved" || Boolean(detailQuery.data?.archiveOrder)), queryKey: ["purchase-requests", "erp-track", selectedId], queryFn: () => fetchPurchaseRequestErpTrack(selectedId), }); + useEffect(() => { + const searchSelectedId = searchParams.get("id") ?? ""; + if (searchSelectedId && searchSelectedId !== selectedId) { + setSelectedId(searchSelectedId); + } + }, [searchParams, selectedId]); + useEffect(() => { if (selectedId) { if (detailQuery.isLoading) { @@ -300,6 +311,23 @@ export function PurchaseRequestsPage() { } }, [detailQuery.isError, detailQuery.isLoading, listQuery.data?.items, selectedId]); + useEffect(() => { + const currentId = searchParams.get("id") ?? ""; + if (!selectedId && !currentId) { + return; + } + if (selectedId === currentId) { + return; + } + const next = new URLSearchParams(searchParams); + if (selectedId) { + next.set("id", selectedId); + } else { + next.delete("id"); + } + setSearchParams(next, { replace: true }); + }, [searchParams, selectedId, setSearchParams]); + useEffect(() => { if (!detailQuery.data || detailQuery.data.id !== selectedId || hydratedDetailId === detailQuery.data.id) { return; @@ -1240,17 +1268,13 @@ export function PurchaseRequestsPage() {
) : null} - {showArchiveSection ? ( -
-
-
-

采购桥接轨道

-

展示 Commitment / Accrual / PO/合同摘要三段外部桥接状态,失败后可在此重试。

-
-
- + {showArchiveSection && canIntegrationManage ? ( + {erpTrackQuery.isLoading ? ( -

桥接轨道加载中...

+

ERP 同步轨道加载中...

) : erpTrackQuery.isError ? (

{resolveError(erpTrackQuery.error)}

) : erpTrack ? ( @@ -1297,7 +1321,7 @@ export function PurchaseRequestsPage() {
{stage.outbox.lastError}
) : null} - {canIntegrationManage && (externalCode || externalId) ? ( + {externalCode || externalId ? (
ERP 编号:{externalCode ?? "-"} {externalId ? ` · ${externalId}` : ""} @@ -1324,7 +1348,7 @@ export function PurchaseRequestsPage() { ) : (

暂无 ERP 轨道信息

)} -
+
) : null} diff --git a/client/src/pages/ReimbursementDetailPage.tsx b/client/src/pages/ReimbursementDetailPage.tsx index 455a1129..81be89d3 100644 --- a/client/src/pages/ReimbursementDetailPage.tsx +++ b/client/src/pages/ReimbursementDetailPage.tsx @@ -27,6 +27,13 @@ import { RiskFlagsCard } from "../components/common/RiskFlagsCard"; import { StatusBadge } from "../components/common/StatusBadge"; import { AiSuggestionCard } from "../components/ai/AiSuggestionCard"; import { Button } from "../components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "../components/ui/dialog"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table"; import { DetailSideMeta } from "../components/patterns/DetailSideMeta"; import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero"; @@ -48,6 +55,32 @@ function formatDateTime(value: string | null) { return value.slice(0, 19).replace("T", " "); } +function resolveErpSyncStatus( + exportState: + | { + exportedAt: string | null; + lastOutboxStatus: string | null; + } + | null, +) { + if (!exportState) { + return "未同步"; + } + if (exportState.lastOutboxStatus === "pending") { + return "已入队"; + } + if (exportState.lastOutboxStatus === "processing") { + return "处理中"; + } + if (exportState.lastOutboxStatus === "failed" || exportState.lastOutboxStatus === "dead_letter") { + return "已失败"; + } + if (exportState.lastOutboxStatus === "succeeded" || exportState.exportedAt) { + return "已成功"; + } + return "未同步"; +} + export function ReimbursementDetailPage() { const { reimbursementId = "" } = useParams(); const { permissions } = useAuth(); @@ -58,6 +91,7 @@ export function ReimbursementDetailPage() { const [itemEditor, setItemEditor] = useState(null); const [itemError, setItemError] = useState(""); const [confirmDeleteItemId, setConfirmDeleteItemId] = useState(""); + const [isErpPreviewOpen, setIsErpPreviewOpen] = useState(false); const [editorInitialValues, setEditorInitialValues] = useState({ expenseTypeId: "", expenseDate: "", @@ -310,6 +344,8 @@ export function ReimbursementDetailPage() { } const detail = detailQuery.data; + const isPaidReimbursement = detail.status === "paid"; + const erpSyncStatus = resolveErpSyncStatus(erpPreviewQuery.data?.exportState ?? null); const budgetHint = detail.budgetHint; const canPay = hasPermission(permissions, "expense.finance.pay"); const canEditItems = ["draft", "rejected"].includes(detail.status); @@ -634,19 +670,33 @@ export function ReimbursementDetailPage() { - {canManageIntegration && detail.status === "paid" ? ( -
-
-
-

财务桥接

-

已付款报销单会按标准凭证切片通过 Outbox 异步发送到财务桥接出口。

+ {canManageIntegration ? ( + + +
- -
+ } + > + {!isPaidReimbursement ? ( +

+ 未满足导出条件,仅已付款报销可导出到 ERP。 +

+ ) : null} - {erpPreviewQuery.isLoading ?

正在加载凭证切片预览...

: null} + {erpPreviewQuery.isLoading ?

正在加载 ERP 凭证预览...

: null} {erpPreviewQuery.isError ? (

{isApiError(erpPreviewQuery.error) @@ -655,33 +705,35 @@ export function ReimbursementDetailPage() { erpPreviewQuery.error.details, erpPreviewQuery.error.message, )}` - : "加载财务桥接预览失败"} + : "加载 ERP 凭证预览失败"}

) : null} - {erpPreviewQuery.data ? ( -
-
-

凭证切片

-
类型:{erpPreviewQuery.data.journal.journalType}
-
单号:{erpPreviewQuery.data.journal.documentNo ?? "-"}
-
金额:{erpPreviewQuery.data.journal.totalAmount ?? "-"}
-
法体:{erpPreviewQuery.data.journal.dimensions.legalEntityCode ?? "-"}
-
成本中心:{erpPreviewQuery.data.journal.dimensions.costCenterCode ?? "-"}
-
付款凭证号:{erpPreviewQuery.data.journal.paymentVoucherNo ?? "-"}
-
付款日期:{erpPreviewQuery.data.journal.paymentDate ?? "-"}
-
-
-

导出状态

-
外部ID:{erpPreviewQuery.data.exportState.externalId ?? "-"}
-
Outbox:{erpPreviewQuery.data.exportState.lastOutboxId ?? "-"}
-
队列状态:{erpPreviewQuery.data.exportState.lastOutboxStatus ?? "-"}
-
最近导出:{formatDateTime(erpPreviewQuery.data.exportState.exportedAt)}
-
最近错误:{erpPreviewQuery.data.exportState.lastError ?? "-"}
-
+
+
+
同步状态
+
{erpSyncStatus}
+
+
+
最后同步
+
{formatDateTime(erpPreviewQuery.data?.exportState.exportedAt ?? null)}
+
+
+
Outbox
+
{erpPreviewQuery.data?.exportState.lastOutboxId ?? "-"}
+
+
+
外部 ID
+
{erpPreviewQuery.data?.exportState.externalId ?? "-"}
+
+
+ + {erpPreviewQuery.data?.exportState.lastError ? ( +
+ 最近错误:{erpPreviewQuery.data.exportState.lastError}
) : null} -
+ ) : null}
@@ -741,6 +793,41 @@ export function ReimbursementDetailPage() {
+ + + + ERP 凭证预览 + 展示当前报销单将发送到 ERP 的凭证切片与最近一次导出状态。 + + + {erpPreviewQuery.data ? ( +
+
+

凭证切片

+
类型:{erpPreviewQuery.data.journal.journalType}
+
单号:{erpPreviewQuery.data.journal.documentNo ?? "-"}
+
金额:{erpPreviewQuery.data.journal.totalAmount ?? "-"}
+
法体:{erpPreviewQuery.data.journal.dimensions.legalEntityCode ?? "-"}
+
成本中心:{erpPreviewQuery.data.journal.dimensions.costCenterCode ?? "-"}
+
付款凭证号:{erpPreviewQuery.data.journal.paymentVoucherNo ?? "-"}
+
付款日期:{erpPreviewQuery.data.journal.paymentDate ?? "-"}
+
+
+

导出状态

+
同步状态:{erpSyncStatus}
+
外部 ID:{erpPreviewQuery.data.exportState.externalId ?? "-"}
+
Outbox:{erpPreviewQuery.data.exportState.lastOutboxId ?? "-"}
+
队列状态:{erpPreviewQuery.data.exportState.lastOutboxStatus ?? "-"}
+
最近导出:{formatDateTime(erpPreviewQuery.data.exportState.exportedAt)}
+
最近错误:{erpPreviewQuery.data.exportState.lastError ?? "-"}
+
+
+ ) : ( +

暂无可预览的 ERP 凭证数据。

+ )} +
+
+ { if (!open) setConfirmDeleteItemId(""); }} diff --git a/issues/2026-04-07_r53-erp-integration-dashboard.csv b/issues/2026-04-07_r53-erp-integration-dashboard.csv new file mode 100644 index 00000000..1ff99c1c --- /dev/null +++ b/issues/2026-04-07_r53-erp-integration-dashboard.csv @@ -0,0 +1,7 @@ +ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes +"A1","R53 执行合同建档与范围冻结","创建并校验 R53 ERP 集成运维可视化与业务同步状态接入执行合同 CSV,冻结范围仅包含 ERP 集成页 Tab 化、Outbox 队列管理、IntegrationBinding 查看器、报销/采购/资产页 ERP 同步状态卡片统一;明确本轮不新增 Prisma 迁移、不修改 erp.js/dispatcher.js/outbox.js、不接真实 ERP 端点。","issues/2026-04-07_r53-erp-integration-dashboard.csv 列完整、Dependencies 为 JSON 数组字符串、Test_Method 可复现;范围仅覆盖 R53 A1-A6,不混入差旅 ERP 新页面、真实 ERP Provider 接入或 ERP 核心派发逻辑改造。","命令:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-04-07_r53-erp-integration-dashboard.csv。检查:1) 对照 AGENTS.md 与 issues/README.md 确认 12 列完整;2) A2-A6 依赖链符合 backend/frontend→reviewer→qa 收口;3) 方案明确采购/资产复用现有页面并采用 ?id= 深链,不新增详情页。","manual","DONE","DONE","DONE","issues/2026-04-07_r53-erp-integration-dashboard.csv","[]","回滚路径:删除本次 CSV 不影响业务。2026-04-07:Issue CSV 已创建并通过 validate_issues_csv.py 校验,范围与依赖已冻结。" +"A2","Outbox 队列后端端点、前端 Tab 与单测","在 integrations.js 新增 Outbox 分页、详情、统计与重试端点,并在 ERP 集成页新增 Outbox 队列 Tab,支持筛选、分页、详情弹窗和失败/死信重试。","GET /api/integrations/outbox 支持 page/pageSize/status/event_type;GET /api/integrations/outbox/:id 返回完整 payload;GET /api/integrations/outbox/stats 返回五种状态计数;POST /api/integrations/outbox/:id/retry 仅允许 failed/dead_letter 重置为 pending 并写审计;前端 Tab 支持筛选、详情和重试,成功后刷新列表与统计。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && npx prisma validate && node --check src/routes/integrations.js && npm test -- server/tests/unit/routes/integrations.outbox.test.js && cd ../client && npm run typecheck && npm run build。业务:1) ERP 页 Outbox Tab 支持分页、状态筛选、事件类型筛选;2) failed/dead_letter 行可重试;3) 非法状态重试返回 409;4) 详情弹窗仅在 integration.manage 下可见并展示完整 payload JSON。","manual","DONE","DONE","DONE","server/src/routes/integrations.js|server/tests/unit/routes/integrations.outbox.test.js|client/src/api/integrations.ts|client/src/pages/ErpIntegrationPage.tsx","[""A1""]","回滚路径:移除 Outbox 运维端点和前端 Tab,恢复 ERP 集成页仅主数据同步视图。2026-04-07:Outbox 分页/详情/统计/重试端点与 ERP 页 Outbox Tab 已完成,prisma validate、node --check、integrations.outbox.test.js、client typecheck/build 通过。" +"A3","IntegrationBinding 查看器与采购/资产深链","在 integrations.js 新增 Binding 分页查询端点和 routePath 推导,在 ERP 集成页新增 ID 映射 Tab,并为采购页、资产页补 ?id= 深链选中能力。","GET /api/integrations/bindings 支持 page/pageSize/provider/entity_type,返回需求字段与 routePath;routePath 可将报销、采购、资产相关绑定跳转到现有业务页并选中目标记录;ERP 集成页 ID 映射 Tab 支持 provider/entity_type 筛选,local_id 在 routePath 存在时可点击。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && node --check src/routes/integrations.js && npm test -- server/tests/unit/routes/integrations.bindings.test.js && cd ../client && npm run typecheck && npm run build。业务:1) 绑定列表可分页与筛选;2) reimbursement_journal / purchase_request_* / purchase_order_* / asset_erp_card 可正确跳转;3) PurchaseRequestsPage 与 AssetRegistryPage 支持 ?id= 深链选中且不回归原有首项默认行为。","manual","DONE","DONE","DONE","server/src/routes/integrations.js|server/tests/unit/routes/integrations.bindings.test.js|client/src/api/integrations.ts|client/src/pages/ErpIntegrationPage.tsx|client/src/pages/PurchaseRequestsPage.tsx|client/src/pages/AssetRegistryPage.tsx","[""A1""]","回滚路径:移除 Binding 查询端点和 ID 映射 Tab,撤销采购/资产 ?id= 深链选中能力。2026-04-07:Binding 查询、routePath 推导、采购/资产 ?id= 深链选中已完成,integrations.bindings.test.js、client typecheck/build 通过。" +"A4","报销、采购、资产详情区 ERP 同步状态卡片统一","在报销详情页、采购列表详情区、资产列表详情区统一接入 SectionPanel 版 ERP 同步状态卡片,复用现有 preview/track/requeue/export API,不新增业务页面。","ReimbursementDetailPage 仅 integration.manage 用户可见 ERP 同步状态卡片,未 paid 时不触发 preview 请求并显示禁用说明,paid 时可预览凭证并导出到 ERP;PurchaseRequestsPage 与 AssetRegistryPage 仅 integration.manage 用户触发 track 查询并渲染 ERP 同步状态卡片,保留现有三阶段或单阶段动作逻辑。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build。业务:1) 报销详情页已付款单据可看到状态摘要、最后同步时间、错误摘要、预览和导出入口;2) 未付款单据不误发 ERP preview 请求;3) 采购页和资产页无 integration.manage 权限用户不再触发 ERP track 请求;4) 有权限用户可继续补发/重试。","manual","DONE","DONE","DONE","client/src/pages/ReimbursementDetailPage.tsx|client/src/pages/PurchaseRequestsPage.tsx|client/src/pages/AssetRegistryPage.tsx","[""A1""]","回滚路径:恢复三处页面原 ERP 区块形态与查询启用条件。2026-04-07:三处业务页 ERP 同步状态卡片已统一为 SectionPanel 形态并完成权限收口,client typecheck/build 通过。" +"A5","R53 reviewer 权限与运维边界复核","按 reviewer 清单复核 R53 的权限模型、状态流和敏感信息暴露边界,重点检查 Outbox retry 限制、详情 payload 暴露范围和无权限用户页面副作用。","review 结论覆盖 integration.manage 权限、Outbox retry 只允许 failed/dead_letter→pending、列表摘要不回传完整 payload、业务页 ERP 卡片仅对有权限用户渲染且无额外请求、副作用回滚路径仅需代码回退;发现的问题已修复或在 Notes 留痕。","检查:1) Outbox/Binding 新端点是否统一使用 integration.manage;2) retry 是否未允许 processing/succeeded 跳转;3) Outbox 列表 payloadSummary 是否只含摘要字段;4) 报销/采购/资产页无 integration.manage 时是否不触发 ERP 查询;5) 无 Prisma 迁移,回滚仅需代码回退。若发现问题,修复后重跑相关单测与前端构建。","manual","DONE","DONE","DONE","issues/2026-04-07_r53-erp-integration-dashboard.csv|server/src/routes/integrations.js|client/src/pages/ErpIntegrationPage.tsx|client/src/pages/ReimbursementDetailPage.tsx|client/src/pages/PurchaseRequestsPage.tsx|client/src/pages/AssetRegistryPage.tsx","[""A2"",""A3"",""A4""]","回滚路径:若 reviewer 发现权限或敏感信息问题,先下线对应入口,再回退相关代码。2026-04-07:已完成 reviewer 风格自检,确认 Outbox/Binding 新端点统一使用 integration.manage,retry 仅允许 failed/dead_letter,业务页无权限时不触发 ERP 查询。" +"A6","QA、状态回填与提交收口","执行 R53 post-check 与业务回归,回填 CSV 状态并完成提交收口,确保 ERP 集成页新能力和业务页 ERP 卡片不回归。","后端与前端校验全部通过;Outbox Tab、ID 映射 Tab、报销/采购/资产 ERP 卡片、采购/资产 ?id= 深链回归完成;issues/2026-04-07_r53-erp-integration-dashboard.csv 的 Dev_Status、Review1_Status、Regression_Status 按测试结果回填。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && npx prisma validate && node --check src/routes/integrations.js && npm test -- server/tests/unit/routes/integrations.outbox.test.js server/tests/unit/routes/integrations.bindings.test.js && cd ../client && npm run typecheck && npm run build。业务:1) /system/integrations/erp 可在主数据同步、Outbox 队列、ID 映射三个 Tab 间切换;2) Outbox Tab 支持详情和重试;3) ID 映射可跳转到报销、采购、资产目标记录;4) 三个业务页的 ERP 同步状态卡片和权限收口正常。","manual","DONE","DONE","DONE","issues/2026-04-07_r53-erp-integration-dashboard.csv|server/src/routes/integrations.js|client/src/api/integrations.ts|client/src/pages/ErpIntegrationPage.tsx|client/src/pages/ReimbursementDetailPage.tsx|client/src/pages/PurchaseRequestsPage.tsx|client/src/pages/AssetRegistryPage.tsx","[""A5""]","回滚路径:若 QA 发现关键回归,暂停提交并回退对应代码变更。2026-04-07:本地 post-check、git diff --check、两组后端单测、client typecheck/build 通过,R53 状态已回填。" diff --git a/server/src/routes/integrations.js b/server/src/routes/integrations.js index f69b87f5..41234639 100644 --- a/server/src/routes/integrations.js +++ b/server/src/routes/integrations.js @@ -15,6 +15,7 @@ const { normalizeRequestedResources, syncErpMasterData, } = require('../services/integrations/erpMasterData'); +const { OUTBOX_STATUSES } = require('../services/integrations/outbox'); const { buildReimbursementJournalPreview, enqueueReimbursementJournalExport, @@ -57,6 +58,155 @@ const PROCUREMENT_REQUEUE_TARGET_ENUM = z.enum([ const PROCUREMENT_REQUEUE_BODY_SCHEMA = z.object({ targets: z.array(PROCUREMENT_REQUEUE_TARGET_ENUM).min(1).max(3), }); +const OUTBOX_STATUS_ENUM = z.enum([ + OUTBOX_STATUSES.pending, + OUTBOX_STATUSES.processing, + OUTBOX_STATUSES.succeeded, + OUTBOX_STATUSES.failed, + OUTBOX_STATUSES.dead_letter, +]); +const OUTBOX_LIST_QUERY_SCHEMA = z.object({ + page: z.coerce.number().int().min(1).optional(), + pageSize: z.coerce.number().int().min(1).max(100).optional(), + status: OUTBOX_STATUS_ENUM.optional(), + event_type: z.string().trim().max(64).optional(), +}); +const BINDINGS_LIST_QUERY_SCHEMA = z.object({ + page: z.coerce.number().int().min(1).optional(), + pageSize: z.coerce.number().int().min(1).max(100).optional(), + provider: z.string().trim().max(32).optional(), + entity_type: z.string().trim().max(64).optional(), +}); + +const OUTBOX_SUMMARY_SCHEMA = { + type: 'object', + required: ['entityType', 'entityId', 'sourceEventName', 'journalType', 'exportType', 'identityCode'], + properties: { + entityType: { anyOf: [{ type: 'string' }, { type: 'null' }] }, + entityId: { anyOf: [{ type: 'string' }, { type: 'null' }] }, + sourceEventName: { anyOf: [{ type: 'string' }, { type: 'null' }] }, + journalType: { anyOf: [{ type: 'string' }, { type: 'null' }] }, + exportType: { anyOf: [{ type: 'string' }, { type: 'null' }] }, + identityCode: { anyOf: [{ type: 'string' }, { type: 'null' }] }, + }, +}; + +const OUTBOX_ITEM_SCHEMA = { + type: 'object', + required: [ + 'id', + 'eventType', + 'status', + 'payloadSummary', + 'attempts', + 'maxAttempts', + 'nextRetryAt', + 'createdAt', + 'processedAt', + 'deadLetterAt', + ], + properties: { + id: { type: 'string', format: 'uuid' }, + eventType: { type: 'string' }, + status: { type: 'string' }, + payloadSummary: OUTBOX_SUMMARY_SCHEMA, + attempts: { type: 'integer' }, + maxAttempts: { type: 'integer' }, + nextRetryAt: { anyOf: [{ type: 'string', format: 'date-time' }, { type: 'null' }] }, + createdAt: { type: 'string', format: 'date-time' }, + processedAt: { anyOf: [{ type: 'string', format: 'date-time' }, { type: 'null' }] }, + deadLetterAt: { anyOf: [{ type: 'string', format: 'date-time' }, { type: 'null' }] }, + }, +}; + +const OUTBOX_DETAIL_SCHEMA = { + type: 'object', + required: ['item'], + properties: { + item: { + allOf: [ + OUTBOX_ITEM_SCHEMA, + { + type: 'object', + required: ['payload', 'lastError'], + properties: { + payload: { + anyOf: [ + { type: 'object', additionalProperties: true }, + { type: 'null' }, + ], + }, + lastError: { anyOf: [{ type: 'string' }, { type: 'null' }] }, + }, + }, + ], + }, + }, +}; + +const OUTBOX_LIST_RESPONSE_SCHEMA = { + type: 'object', + required: ['page', 'pageSize', 'total', 'items'], + properties: { + page: { type: 'integer' }, + pageSize: { type: 'integer' }, + total: { type: 'integer' }, + items: { + type: 'array', + items: OUTBOX_ITEM_SCHEMA, + }, + }, +}; + +const OUTBOX_STATS_RESPONSE_SCHEMA = { + type: 'object', + required: ['pending', 'processing', 'succeeded', 'failed', 'deadLetter'], + properties: { + pending: { type: 'integer' }, + processing: { type: 'integer' }, + succeeded: { type: 'integer' }, + failed: { type: 'integer' }, + deadLetter: { type: 'integer' }, + }, +}; + +const BINDING_ITEM_SCHEMA = { + type: 'object', + required: [ + 'id', + 'provider', + 'entityType', + 'localId', + 'externalId', + 'externalCode', + 'lastSyncedAt', + 'routePath', + ], + properties: { + id: { type: 'string', format: 'uuid' }, + provider: { type: 'string' }, + entityType: { type: 'string' }, + localId: { type: 'string', format: 'uuid' }, + externalId: { anyOf: [{ type: 'string' }, { type: 'null' }] }, + externalCode: { anyOf: [{ type: 'string' }, { type: 'null' }] }, + lastSyncedAt: { anyOf: [{ type: 'string', format: 'date-time' }, { type: 'null' }] }, + routePath: { anyOf: [{ type: 'string' }, { type: 'null' }] }, + }, +}; + +const BINDINGS_LIST_RESPONSE_SCHEMA = { + type: 'object', + required: ['page', 'pageSize', 'total', 'items'], + properties: { + page: { type: 'integer' }, + pageSize: { type: 'integer' }, + total: { type: 'integer' }, + items: { + type: 'array', + items: BINDING_ITEM_SCHEMA, + }, + }, +}; const ERP_SYNC_RESPONSE_SCHEMA = { type: 'object', @@ -125,6 +275,195 @@ function parseRunNow(query) { ); } +function buildValidationError(details, statusCode = 400) { + return new AppError({ + code: 'VALID_001', + message: '参数校验失败', + statusCode, + details, + }); +} + +function parseOutboxListQuery(query) { + const parsed = OUTBOX_LIST_QUERY_SCHEMA.safeParse(query ?? {}); + if (!parsed.success) { + throw buildValidationError(parsed.error.flatten()); + } + + return { + page: parsed.data.page ?? 1, + pageSize: parsed.data.pageSize ?? 20, + status: parsed.data.status ?? null, + eventType: String(parsed.data.event_type ?? '').trim() || null, + }; +} + +function parseBindingsListQuery(query) { + const parsed = BINDINGS_LIST_QUERY_SCHEMA.safeParse(query ?? {}); + if (!parsed.success) { + throw buildValidationError(parsed.error.flatten()); + } + + return { + page: parsed.data.page ?? 1, + pageSize: parsed.data.pageSize ?? 20, + provider: String(parsed.data.provider ?? '').trim() || null, + entityType: String(parsed.data.entity_type ?? '').trim() || null, + }; +} + +function parseUuidParam(value, fieldName) { + const parsed = UUID_SCHEMA.safeParse(value); + if (!parsed.success) { + throw buildValidationError({ [fieldName]: 'invalid_uuid' }); + } + + return parsed.data; +} + +function toIsoString(value) { + return value?.toISOString?.() ?? null; +} + +function resolveOutboxIdentityCode(payload) { + return ( + payload?.documentNo ?? + payload?.formNumber ?? + payload?.assetNo ?? + payload?.batchNo ?? + payload?.poNo ?? + payload?.contractNo ?? + payload?.requestNo ?? + payload?.externalOrderNo ?? + payload?.references?.poNo ?? + payload?.references?.contractNo ?? + payload?.references?.requestNo ?? + payload?.references?.externalOrderNo ?? + null + ); +} + +function buildOutboxPayloadSummary(payload) { + return { + entityType: typeof payload?.entityType === 'string' ? payload.entityType : null, + entityId: typeof payload?.entityId === 'string' ? payload.entityId : null, + sourceEventName: typeof payload?.sourceEventName === 'string' ? payload.sourceEventName : null, + journalType: typeof payload?.journalType === 'string' ? payload.journalType : null, + exportType: typeof payload?.exportType === 'string' ? payload.exportType : null, + identityCode: resolveOutboxIdentityCode(payload), + }; +} + +function mapOutboxItem(record) { + return { + id: record.id, + eventType: record.event_type, + status: record.status, + payloadSummary: buildOutboxPayloadSummary(record.payload ?? null), + attempts: Number(record.attempts ?? 0), + maxAttempts: Number(record.max_attempts ?? 0), + nextRetryAt: toIsoString(record.next_retry_at), + createdAt: toIsoString(record.created_at), + processedAt: toIsoString(record.processed_at), + deadLetterAt: toIsoString(record.dead_letter_at), + }; +} + +function mapOutboxDetail(record) { + return { + ...mapOutboxItem(record), + payload: record.payload ?? null, + lastError: record.last_error ?? null, + }; +} + +function mapIntegrationBindingListItem(record, routePath) { + 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: toIsoString(record.last_synced_at), + routePath: routePath ?? null, + }; +} + +async function resolveBindingRoutePaths(prisma, rows) { + const archiveEntityTypes = new Set([ + 'purchase_order_accrual_journal', + 'purchase_order_po', + 'purchase_order_contract', + ]); + const archiveIds = Array.from( + new Set( + (rows ?? []) + .filter((row) => archiveEntityTypes.has(row.entity_type)) + .map((row) => row.local_id) + .filter(Boolean), + ), + ); + + const purchaseRequestByArchiveId = new Map(); + + if (archiveIds.length && prisma?.purchaseOrderArchive?.findMany) { + const archiveRows = await prisma.purchaseOrderArchive.findMany({ + where: { + id: { + in: archiveIds, + }, + }, + select: { + id: true, + purchase_request_id: true, + }, + }); + + for (const item of archiveRows) { + purchaseRequestByArchiveId.set(item.id, item.purchase_request_id); + } + } + + const routePathByBindingId = new Map(); + for (const row of rows ?? []) { + let routePath = null; + + if (row.entity_type === 'reimbursement_journal') { + routePath = `/reimbursements/${row.local_id}`; + } else if (row.entity_type === 'purchase_request_commitment_journal') { + routePath = `/purchase-requests?id=${row.local_id}`; + } else if (archiveEntityTypes.has(row.entity_type)) { + const purchaseRequestId = purchaseRequestByArchiveId.get(row.local_id) ?? null; + routePath = purchaseRequestId ? `/purchase-requests?id=${purchaseRequestId}` : null; + } else if (row.entity_type === 'asset_erp_card') { + routePath = `/assets?id=${row.local_id}`; + } + + routePathByBindingId.set(row.id, routePath); + } + + return routePathByBindingId; +} + +async function buildOutboxStats(prisma) { + const [pending, processing, succeeded, failed, deadLetter] = await Promise.all([ + prisma.integrationOutbox.count({ where: { status: OUTBOX_STATUSES.pending } }), + prisma.integrationOutbox.count({ where: { status: OUTBOX_STATUSES.processing } }), + prisma.integrationOutbox.count({ where: { status: OUTBOX_STATUSES.succeeded } }), + prisma.integrationOutbox.count({ where: { status: OUTBOX_STATUSES.failed } }), + prisma.integrationOutbox.count({ where: { status: OUTBOX_STATUSES.dead_letter } }), + ]); + + return { + pending, + processing, + succeeded, + failed, + deadLetter, + }; +} + async function resolveErpGate(prisma, config) { const evaluation = await evaluateFeatureFlag({ prisma, @@ -565,6 +904,253 @@ function normalizeResourcesInput(value) { } async function integrationsRoutes(fastify) { + fastify.get( + '/integrations/outbox/stats', + { + preHandler: fastify.requirePermission('integration.manage'), + schema: { + tags: ['integrations'], + summary: '查询 ERP Outbox 队列状态统计', + security: [{ bearerAuth: [] }], + response: { + 200: buildDataEnvelopeSchema(OUTBOX_STATS_RESPONSE_SCHEMA, 'ERP Outbox 状态统计'), + }, + }, + }, + async (request, reply) => { + const stats = await buildOutboxStats(fastify.prisma); + return sendData(reply, request, stats); + }, + ); + + fastify.get( + '/integrations/outbox', + { + preHandler: fastify.requirePermission('integration.manage'), + schema: { + tags: ['integrations'], + summary: '分页查询 ERP Outbox 事件', + security: [{ bearerAuth: [] }], + response: { + 200: buildDataEnvelopeSchema(OUTBOX_LIST_RESPONSE_SCHEMA, 'ERP Outbox 列表'), + 400: buildErrorEnvelopeSchema({ + description: '参数校验失败', + codeExample: 'VALID_001', + messageExample: '参数校验失败', + }), + }, + }, + }, + async (request, reply) => { + const query = parseOutboxListQuery(request.query); + const skip = (query.page - 1) * query.pageSize; + const where = {}; + + if (query.status) { + where.status = query.status; + } + if (query.eventType) { + where.event_type = query.eventType; + } + + const [items, total] = await Promise.all([ + fastify.prisma.integrationOutbox.findMany({ + where, + orderBy: [{ created_at: 'desc' }], + skip, + take: query.pageSize, + }), + fastify.prisma.integrationOutbox.count({ where }), + ]); + + return sendData(reply, request, { + page: query.page, + pageSize: query.pageSize, + total, + items: items.map(mapOutboxItem), + }); + }, + ); + + fastify.get( + '/integrations/outbox/:id', + { + preHandler: fastify.requirePermission('integration.manage'), + schema: { + tags: ['integrations'], + summary: '查询单条 ERP Outbox 事件详情', + security: [{ bearerAuth: [] }], + response: { + 200: buildDataEnvelopeSchema(OUTBOX_DETAIL_SCHEMA, 'ERP Outbox 详情'), + 400: buildErrorEnvelopeSchema({ + description: '参数校验失败', + codeExample: 'VALID_001', + messageExample: '参数校验失败', + }), + 404: buildErrorEnvelopeSchema({ + description: 'Outbox 事件不存在', + codeExample: 'SYS_404', + messageExample: 'Not Found', + }), + }, + }, + }, + async (request, reply) => { + const outboxId = parseUuidParam(request.params?.id, 'id'); + const item = await fastify.prisma.integrationOutbox.findUnique({ + where: { id: outboxId }, + }); + + if (!item) { + throw new AppError({ + code: 'SYS_404', + message: 'Not Found', + statusCode: 404, + }); + } + + return sendData(reply, request, { + item: mapOutboxDetail(item), + }); + }, + ); + + fastify.post( + '/integrations/outbox/:id/retry', + { + preHandler: fastify.requirePermission('integration.manage'), + schema: { + tags: ['integrations'], + summary: '重试 failed/dead_letter 的 ERP Outbox 事件', + security: [{ bearerAuth: [] }], + response: { + 200: buildDataEnvelopeSchema(OUTBOX_DETAIL_SCHEMA, 'ERP Outbox 已重置为 pending'), + 400: buildErrorEnvelopeSchema({ + description: '参数校验失败', + codeExample: 'VALID_001', + messageExample: '参数校验失败', + }), + 404: buildErrorEnvelopeSchema({ + description: 'Outbox 事件不存在', + codeExample: 'SYS_404', + messageExample: 'Not Found', + }), + 409: buildErrorEnvelopeSchema({ + description: '当前状态不允许重试', + codeExample: 'VALID_001', + messageExample: '参数校验失败', + }), + }, + }, + }, + async (request, reply) => { + const outboxId = parseUuidParam(request.params?.id, 'id'); + const existed = await fastify.prisma.integrationOutbox.findUnique({ + where: { id: outboxId }, + }); + + if (!existed) { + throw new AppError({ + code: 'SYS_404', + message: 'Not Found', + statusCode: 404, + }); + } + + if (![OUTBOX_STATUSES.failed, OUTBOX_STATUSES.dead_letter].includes(existed.status)) { + throw buildValidationError( + { + outbox: 'integration_outbox_retry_status_invalid', + status: existed.status, + }, + 409, + ); + } + + const retried = await fastify.prisma.integrationOutbox.update({ + where: { id: outboxId }, + data: { + status: OUTBOX_STATUSES.pending, + attempts: 0, + last_error: null, + next_retry_at: new Date(), + processed_at: null, + dead_letter_at: null, + updated_by: request.employee.id, + }, + }); + + await fastify.prisma.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: 'ERP_OUTBOX_RETRIED', + targetType: 'integration_outbox', + targetId: retried.id, + extra: { + previousStatus: existed.status, + nextStatus: retried.status, + eventType: retried.event_type, + outboxId: retried.id, + }, + }), + }); + + return sendData(reply, request, { + item: mapOutboxDetail(retried), + }); + }, + ); + + fastify.get( + '/integrations/bindings', + { + preHandler: fastify.requirePermission('integration.manage'), + schema: { + tags: ['integrations'], + summary: '分页查询 ERP IntegrationBinding', + security: [{ bearerAuth: [] }], + response: { + 200: buildDataEnvelopeSchema(BINDINGS_LIST_RESPONSE_SCHEMA, 'ERP Binding 列表'), + 400: buildErrorEnvelopeSchema({ + description: '参数校验失败', + codeExample: 'VALID_001', + messageExample: '参数校验失败', + }), + }, + }, + }, + async (request, reply) => { + const query = parseBindingsListQuery(request.query); + const skip = (query.page - 1) * query.pageSize; + const where = {}; + + if (query.provider) { + where.provider = query.provider; + } + if (query.entityType) { + where.entity_type = query.entityType; + } + + const [items, total] = await Promise.all([ + fastify.prisma.integrationBinding.findMany({ + where, + orderBy: [{ last_synced_at: { sort: 'desc', nulls: 'last' } }, { created_at: 'desc' }], + skip, + take: query.pageSize, + }), + fastify.prisma.integrationBinding.count({ where }), + ]); + const routePathByBindingId = await resolveBindingRoutePaths(fastify.prisma, items); + + return sendData(reply, request, { + page: query.page, + pageSize: query.pageSize, + total, + items: items.map((item) => mapIntegrationBindingListItem(item, routePathByBindingId.get(item.id) ?? null)), + }); + }, + ); + fastify.get( '/integrations/erp/master-data/status', { diff --git a/server/tests/unit/routes/integrations.bindings.test.js b/server/tests/unit/routes/integrations.bindings.test.js new file mode 100644 index 00000000..92364fcb --- /dev/null +++ b/server/tests/unit/routes/integrations.bindings.test.js @@ -0,0 +1,198 @@ +const Fastify = require('fastify'); + +const integrationsRoutes = require('../../../src/routes/integrations'); +const { AppError } = require('../../../src/utils/AppError'); + +const ACTOR_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + +function buildBinding(overrides = {}) { + return { + id: '11111111-1111-4111-8111-111111111111', + provider: 'erp', + entity_type: 'purchase_order_po', + local_id: '22222222-2222-4222-8222-222222222222', + external_id: 'ERP-PO-001', + external_code: 'PO-001', + last_synced_at: new Date('2026-04-07T10:00:00.000Z'), + created_at: new Date('2026-04-07T09:00:00.000Z'), + ...overrides, + }; +} + +async function buildApp(options = {}) { + const app = Fastify(); + const actor = options.actor ?? { + id: ACTOR_ID, + permissionCodes: ['integration.manage'], + }; + const findMany = options.findMany ?? vi.fn().mockResolvedValue([ + buildBinding(), + buildBinding({ + id: '33333333-3333-4333-8333-333333333333', + entity_type: 'reimbursement_journal', + local_id: '44444444-4444-4444-8444-444444444444', + external_id: 'ERP-BX-001', + external_code: 'BX-001', + }), + ]); + const count = options.count ?? vi.fn().mockResolvedValue(2); + const archiveFindMany = options.archiveFindMany ?? vi.fn().mockResolvedValue([ + { + id: '22222222-2222-4222-8222-222222222222', + purchase_request_id: '55555555-5555-4555-8555-555555555555', + }, + ]); + + app.decorate('authenticate', async (request) => { + request.employee = actor; + }); + app.decorate('requirePermission', (permissionCode) => async (request) => { + request.employee = actor; + const permissionCodes = actor.permissionCodes ?? []; + if (!permissionCodes.includes('*') && !permissionCodes.includes(permissionCode)) { + throw new AppError({ code: 'PERM_001', message: '无权限', statusCode: 403 }); + } + }); + app.decorate('hasPermission', (employee, permissionCode) => { + const permissionCodes = employee.permissionCodes ?? []; + return permissionCodes.includes('*') || permissionCodes.includes(permissionCode); + }); + app.decorate('config', { + erpSyncEnabled: true, + erpSyncProvider: 'mock', + erpMasterDataEndpoint: 'http://example.test/master-data', + erpJournalExportEndpoint: 'http://example.test/journal', + erpSyncEndpoint: 'http://example.test/journal', + erpMasterDataSyncCron: '0 * * * *', + }); + app.decorate('prisma', { + featureFlag: { + findUnique: vi.fn().mockResolvedValue({ + id: 'flag-erp', + flag_key: 'erp_sync', + override_mode: 'force_on', + rollout_type: 'global', + department_ids: [], + }), + }, + integrationBinding: { + findMany, + count, + }, + purchaseOrderArchive: { + findMany: archiveFindMany, + }, + auditLog: { + create: vi.fn().mockResolvedValue({}), + }, + }); + app.setErrorHandler((error, request, reply) => { + reply.status(error.statusCode || 500).send({ + requestId: request.id, + error: { + code: error.code || 'SYS_001', + message: error.message, + details: error.details ?? null, + }, + }); + }); + + await app.register(integrationsRoutes, { prefix: '/api' }); + return { app, findMany, count, archiveFindMany }; +} + +describe('integrations binding routes', () => { + it('lists bindings with filters and route paths', async () => { + const { app, findMany, count, archiveFindMany } = await buildApp(); + + const response = await app.inject({ + method: 'GET', + url: '/api/integrations/bindings?page=2&pageSize=10&provider=erp&entity_type=purchase_order_po', + }); + + expect(response.statusCode).toBe(200); + expect(findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + provider: 'erp', + entity_type: 'purchase_order_po', + }, + orderBy: [{ last_synced_at: { sort: 'desc', nulls: 'last' } }, { created_at: 'desc' }], + skip: 10, + take: 10, + }), + ); + expect(count).toHaveBeenCalledWith({ + where: { + provider: 'erp', + entity_type: 'purchase_order_po', + }, + }); + expect(archiveFindMany).toHaveBeenCalledWith({ + where: { + id: { + in: ['22222222-2222-4222-8222-222222222222'], + }, + }, + select: { + id: true, + purchase_request_id: true, + }, + }); + + const payload = response.json(); + expect(payload.data.items[0]).toMatchObject({ + entityType: 'purchase_order_po', + localId: '22222222-2222-4222-8222-222222222222', + routePath: '/purchase-requests?id=55555555-5555-4555-8555-555555555555', + }); + expect(payload.data.items[1]).toMatchObject({ + entityType: 'reimbursement_journal', + routePath: '/reimbursements/44444444-4444-4444-8444-444444444444', + }); + + await app.close(); + }); + + it('returns null routePath for unsupported entity types', async () => { + const { app } = await buildApp({ + findMany: vi.fn().mockResolvedValue([ + buildBinding({ + entity_type: 'travel_payable_summary', + local_id: '66666666-6666-4666-8666-666666666666', + }), + ]), + count: vi.fn().mockResolvedValue(1), + archiveFindMany: vi.fn().mockResolvedValue([]), + }); + + const response = await app.inject({ + method: 'GET', + url: '/api/integrations/bindings', + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data.items[0].routePath).toBe(null); + + await app.close(); + }); + + it('blocks users without integration.manage', async () => { + const { app } = await buildApp({ + actor: { + id: ACTOR_ID, + permissionCodes: [], + }, + }); + + const response = await app.inject({ + method: 'GET', + url: '/api/integrations/bindings', + }); + + expect(response.statusCode).toBe(403); + expect(response.json().error.code).toBe('PERM_001'); + + await app.close(); + }); +}); diff --git a/server/tests/unit/routes/integrations.outbox.test.js b/server/tests/unit/routes/integrations.outbox.test.js new file mode 100644 index 00000000..4239134f --- /dev/null +++ b/server/tests/unit/routes/integrations.outbox.test.js @@ -0,0 +1,285 @@ +const Fastify = require('fastify'); + +const integrationsRoutes = require('../../../src/routes/integrations'); +const { AppError } = require('../../../src/utils/AppError'); + +const ACTOR_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; +const OUTBOX_ID = '11111111-1111-4111-8111-111111111111'; + +function buildOutbox(overrides = {}) { + return { + id: OUTBOX_ID, + event_type: 'erp.journal.export', + status: 'failed', + payload: { + entityType: 'reimbursement_form', + entityId: '22222222-2222-4222-8222-222222222222', + sourceEventName: 'reimbursement.paid', + journalType: 'reimbursement_paid', + documentNo: 'BX-20260407-001', + token: 'sanitized-value', + }, + attempts: 2, + max_attempts: 5, + last_error: 'HTTP 500 | timeout', + next_retry_at: new Date('2026-04-07T10:00:00.000Z'), + created_at: new Date('2026-04-07T09:00:00.000Z'), + processed_at: null, + dead_letter_at: null, + updated_at: new Date('2026-04-07T09:30:00.000Z'), + ...overrides, + }; +} + +async function buildApp(options = {}) { + const app = Fastify(); + const actor = options.actor ?? { + id: ACTOR_ID, + permissionCodes: ['integration.manage'], + }; + const findMany = options.findMany ?? vi.fn().mockResolvedValue([buildOutbox()]); + const findUnique = options.findUnique ?? vi.fn().mockResolvedValue(buildOutbox()); + const update = options.update ?? vi.fn().mockImplementation(async ({ where, data }) => ({ + ...buildOutbox({ id: where.id }), + ...data, + updated_at: new Date('2026-04-07T10:30:00.000Z'), + })); + const count = options.count ?? vi.fn().mockImplementation(async ({ where } = {}) => { + if (where?.status === 'pending') return 3; + if (where?.status === 'processing') return 1; + if (where?.status === 'succeeded') return 8; + if (where?.status === 'failed') return 2; + if (where?.status === 'dead_letter') return 1; + return 1; + }); + const auditCreate = options.auditCreate ?? vi.fn().mockResolvedValue({}); + + app.decorate('authenticate', async (request) => { + request.employee = actor; + }); + app.decorate('requirePermission', (permissionCode) => async (request) => { + request.employee = actor; + const permissionCodes = actor.permissionCodes ?? []; + if (!permissionCodes.includes('*') && !permissionCodes.includes(permissionCode)) { + throw new AppError({ code: 'PERM_001', message: '无权限', statusCode: 403 }); + } + }); + app.decorate('hasPermission', (employee, permissionCode) => { + const permissionCodes = employee.permissionCodes ?? []; + return permissionCodes.includes('*') || permissionCodes.includes(permissionCode); + }); + app.decorate('config', { + erpSyncEnabled: true, + erpSyncProvider: 'mock', + erpMasterDataEndpoint: 'http://example.test/master-data', + erpJournalExportEndpoint: 'http://example.test/journal', + erpSyncEndpoint: 'http://example.test/journal', + erpMasterDataSyncCron: '0 * * * *', + }); + app.decorate('prisma', { + featureFlag: { + findUnique: vi.fn().mockResolvedValue({ + id: 'flag-erp', + flag_key: 'erp_sync', + override_mode: 'force_on', + rollout_type: 'global', + department_ids: [], + }), + }, + integrationOutbox: { + findMany, + findUnique, + count, + update, + }, + auditLog: { + create: auditCreate, + }, + }); + app.setErrorHandler((error, request, reply) => { + reply.status(error.statusCode || 500).send({ + requestId: request.id, + error: { + code: error.code || 'SYS_001', + message: error.message, + details: error.details ?? null, + }, + }); + }); + + await app.register(integrationsRoutes, { prefix: '/api' }); + return { app, findMany, findUnique, count, update, auditCreate }; +} + +describe('integrations outbox routes', () => { + it('lists outbox events with filters and payload summary', async () => { + const { app, findMany, count } = await buildApp(); + + const response = await app.inject({ + method: 'GET', + url: '/api/integrations/outbox?page=2&pageSize=10&status=failed&event_type=erp.journal.export', + }); + + expect(response.statusCode).toBe(200); + expect(findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + status: 'failed', + event_type: 'erp.journal.export', + }, + orderBy: [{ created_at: 'desc' }], + skip: 10, + take: 10, + }), + ); + expect(count).toHaveBeenCalledWith({ + where: { + status: 'failed', + event_type: 'erp.journal.export', + }, + }); + + const payload = response.json(); + expect(payload.data.page).toBe(2); + expect(payload.data.items[0]).toMatchObject({ + id: OUTBOX_ID, + eventType: 'erp.journal.export', + status: 'failed', + payloadSummary: { + entityType: 'reimbursement_form', + entityId: '22222222-2222-4222-8222-222222222222', + sourceEventName: 'reimbursement.paid', + journalType: 'reimbursement_paid', + exportType: null, + identityCode: 'BX-20260407-001', + }, + }); + + await app.close(); + }); + + it('returns stats with missing buckets defaulted to zero', async () => { + const { app } = await buildApp({ + count: vi.fn().mockImplementation(async ({ where } = {}) => { + if (where?.status === 'succeeded') return 5; + return 0; + }), + }); + + const response = await app.inject({ + method: 'GET', + url: '/api/integrations/outbox/stats', + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data).toEqual({ + pending: 0, + processing: 0, + succeeded: 5, + failed: 0, + deadLetter: 0, + }); + + await app.close(); + }); + + it('returns outbox detail with full payload', async () => { + const { app, findUnique } = await buildApp(); + + const response = await app.inject({ + method: 'GET', + url: `/api/integrations/outbox/${OUTBOX_ID}`, + }); + + expect(response.statusCode).toBe(200); + expect(findUnique).toHaveBeenCalledWith({ where: { id: OUTBOX_ID } }); + expect(response.json().data.item.payload).toMatchObject({ + documentNo: 'BX-20260407-001', + token: 'sanitized-value', + }); + + await app.close(); + }); + + it('retries failed or dead-letter outbox events by resetting status to pending', async () => { + const { app, update, auditCreate } = await buildApp({ + findUnique: vi.fn().mockResolvedValue(buildOutbox({ status: 'dead_letter', dead_letter_at: new Date('2026-04-07T09:40:00.000Z') })), + }); + + const response = await app.inject({ + method: 'POST', + url: `/api/integrations/outbox/${OUTBOX_ID}/retry`, + payload: {}, + }); + + expect(response.statusCode).toBe(200); + expect(update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: OUTBOX_ID }, + data: expect.objectContaining({ + status: 'pending', + attempts: 0, + last_error: null, + next_retry_at: expect.any(Date), + processed_at: null, + dead_letter_at: null, + updated_by: ACTOR_ID, + }), + }), + ); + expect(auditCreate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + action: 'ERP_OUTBOX_RETRIED', + target_type: 'integration_outbox', + target_id: OUTBOX_ID, + extra: expect.objectContaining({ + previousStatus: 'dead_letter', + nextStatus: 'pending', + eventType: 'erp.journal.export', + outboxId: OUTBOX_ID, + }), + }), + }), + ); + + await app.close(); + }); + + it('rejects retry for succeeded items with 409', async () => { + const { app, update, auditCreate } = await buildApp({ + findUnique: vi.fn().mockResolvedValue(buildOutbox({ status: 'succeeded', last_error: null })), + }); + + const response = await app.inject({ + method: 'POST', + url: `/api/integrations/outbox/${OUTBOX_ID}/retry`, + payload: {}, + }); + + expect(response.statusCode).toBe(409); + expect(update).not.toHaveBeenCalled(); + expect(auditCreate).not.toHaveBeenCalled(); + + await app.close(); + }); + + it('blocks users without integration.manage', async () => { + const { app } = await buildApp({ + actor: { + id: ACTOR_ID, + permissionCodes: [], + }, + }); + + const response = await app.inject({ + method: 'GET', + url: '/api/integrations/outbox', + }); + + expect(response.statusCode).toBe(403); + expect(response.json().error.code).toBe('PERM_001'); + + await app.close(); + }); +});