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: "法体", cost_center: "成本中心", 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)}`; } if (error instanceof Error) { return error.message; } return "请求失败,请稍后重试。"; } function formatDateTime(value: string | null) { if (!value) return "-"; 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 "功能开关强制关闭"; if (reason === "department_deferred") return "部门灰度在管理端按全局集成动作放行"; if (reason === "env_inherit_global_on") return "Env 继承开启"; if (reason === "flag_force_on_global") return "功能开关强制开启"; 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) => { await queryClient.invalidateQueries({ queryKey: ["erp-master-data-status"] }); setError(""); const finished = result.items .map((item) => `${RESOURCE_LABELS[item.resourceType]}:${item.status}`) .join(" / "); toast.success(`主数据同步完成:${finished}`); }, onError: (mutationError) => { const message = resolveError(mutationError); setError(message); toast.error(message); }, }); 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("同步任务已重置为待处理"); }, onError: (mutationError) => { const message = resolveError(mutationError); toast.error(message); }, }); const itemsByType = useMemo(() => { const map = new Map(); for (const item of statusQuery.data?.items ?? []) { map.set(item.resourceType, item); } return map; }, [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 ? "同步中..." : "同步全部"} ) : null } /> {error ? (

{error}

) : null} setTab(value as ErpTab)}> 主数据同步 同步队列 ID 映射

ERP 状态

{statusQuery.isLoading ?

加载中...

: null} {statusQuery.isError ? (

{resolveError(statusQuery.error)}

) : null} {gate ? (
Env 总闸
{gate.envEnabled ? "已开启" : "已关闭"}
功能开关
{gate.effectiveEnabled ? "允许" : "阻断"}
{formatGateReason(gate.effectiveReason)}
同步服务 / 主数据端点
{gate.provider}
{gate.masterDataEndpointConfigured ? "主数据端点已配置" : "主数据端点未配置"}
凭证端点 / 自动同步
{gate.journalEndpointConfigured ? "凭证端点已配置" : "凭证端点未配置"}
{gate.masterDataSyncCron || "未启用自动同步"}
) : null}
{(["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}
) : (

暂无同步记录。

)}
); })}

说明

- 本页只负责主数据同步、业务同步队列和 ID 映射。

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

- 当前仍使用演示同步服务。

{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}
); })}

同步任务列表

失败任务可重试。

{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}
)) ) : ( 当前筛选下暂无同步任务 )}
) : null}

IntegrationBinding 列表

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

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

{resolveError(bindingsQuery.error)}

) : null} {bindingsQuery.data ? ( <> 同步服务 实体类型 本地 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(""); } }} > 同步任务详情 查看数据内容、错误摘要和当前队列状态。 {outboxDetailQuery.isLoading ?

详情加载中...

: null} {outboxDetailQuery.isError ? (

{resolveError(outboxDetailQuery.error)}

) : null} {outboxDetailQuery.data ? (
队列 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 ?? "-"}
数据内容
                  {JSON.stringify(outboxDetailQuery.data.payload ?? null, null, 2)}
                
) : null}
); }