R53 A1-A6:ERP 集成运维可视化与业务同步状态接入

- ErpIntegrationPage Tab 化:主数据同步 / Outbox 队列管理 / ID 映射查看器
- Outbox 分页查询、详情、重试与统计端点
- IntegrationBinding 分页查询、routePath 推导与采购/资产深链
- 报销/采购/资产详情区接入 ERP 同步状态卡片
- 单测覆盖 Outbox 与 Binding 端点
这个提交包含在:
2026-04-07 10:33:58 +08:00
父节点 64d9ded024
当前提交 7cc6dc3397
共修改 9 个文件,包含 1989 行新增和 146 行删除
+132
查看文件
@@ -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<string, unknown> | 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<string, string | number | boolean | undefined>) {
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<IntegrationOutboxListResponse>(
`/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<IntegrationOutboxStats>("/api/integrations/outbox/stats");
}
export async function fetchIntegrationBindings(query: IntegrationBindingQuery = {}) {
return apiRequestData<IntegrationBindingListResponse>(
`/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;
+46 -13
查看文件
@@ -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() {
</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>
{selectedAssetId && !isCreatingAsset && canIntegrationManage ? (
<SectionPanel
title="ERP 同步状态"
description="仅同步资产法体、成本中心与退役/归档等财务台账摘要,不接管实物流转。"
>
{assetErpTrackQuery.isLoading ? (
<p className="rounded-lg bg-zinc-50 px-3 py-2 text-sm text-zinc-500">ERP 轨道加载中...</p>
<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 ? (
@@ -984,7 +1017,7 @@ export function AssetRegistryPage() {
</div>
) : null}
{canIntegrationManage && assetErpTrack?.permissions.canRetry && assetErpActionLabel ? (
{assetErpTrack?.permissions.canRetry && assetErpActionLabel ? (
<div className="flex justify-end">
<Button
type="button"
@@ -1001,7 +1034,7 @@ export function AssetRegistryPage() {
) : (
<p className="rounded-lg bg-zinc-50 px-3 py-2 text-sm text-zinc-500">暂无 ERP 轨道信息</p>
)}
</div>
</SectionPanel>
) : null}
{selectedAssetId && !isCreatingAsset && currentAsset ? (
+578 -87
查看文件
@@ -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<ErpMasterDataResourceType, string> = {
legal_entity: "法体",
@@ -18,6 +39,29 @@ const RESOURCE_LABELS: Record<ErpMasterDataResourceType, string> = {
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 <span className="break-all text-zinc-600">{item.localId}</span>;
}
return (
<Link className="break-all text-primary hover:underline" to={item.routePath}>
{item.localId}
</Link>
);
}
export function ErpIntegrationPage() {
const queryClient = useQueryClient();
const [tab, setTab] = useState<ErpTab>("master-data");
const [error, setError] = useState("");
const [outboxPage, setOutboxPage] = useState(1);
const [outboxStatus, setOutboxStatus] = useState<IntegrationOutboxStatus | "">("");
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<ErpMasterDataResourceType, ErpMasterDataSyncItem>();
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 (
<div className="space-y-6">
<PageHeader
title="ERP 集成"
subtitle="管理 ERP 主数据同步状态。报销付款凭证导出入口保留在报销详情页,不在本页直接手输单号导出。"
subtitle="管理主数据同步、Outbox 队列和本地与 ERP 的 ID 映射。报销、采购和资产的具体同步动作仍保留在业务详情区。"
actions={
<Button onClick={() => syncMutation.mutate(undefined)} disabled={syncMutation.isPending}>
{syncMutation.isPending ? "同步中..." : "同步全部"}
</Button>
tab === "master-data" ? (
<Button onClick={() => syncMutation.mutate(undefined)} disabled={syncMutation.isPending}>
{syncMutation.isPending ? "同步中..." : "同步全部"}
</Button>
) : null
}
/>
@@ -94,98 +276,407 @@ export function ErpIntegrationPage() {
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</p>
) : null}
<div className="grid gap-4 lg:grid-cols-4">
<div className="card-std p-5 space-y-2 lg:col-span-4">
<h3 className="text-base font-semibold text-zinc-900">ERP 状态</h3>
{statusQuery.isLoading ? <p className="text-sm text-zinc-500">加载中...</p> : null}
{statusQuery.isError ? (
<p className="text-sm text-red-600">{resolveError(statusQuery.error)}</p>
) : null}
{gate ? (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4 text-sm text-zinc-700">
<div>
<div className="text-zinc-500">Env 总闸</div>
<div className="font-medium">{gate.envEnabled ? "已开启" : "已关闭"}</div>
</div>
<div>
<div className="text-zinc-500">功能开关</div>
<div className="font-medium">{gate.effectiveEnabled ? "允许" : "阻断"}</div>
<div className="text-xs text-zinc-500">{formatGateReason(gate.effectiveReason)}</div>
</div>
<div>
<div className="text-zinc-500">Provider / 主数据端点</div>
<div className="font-medium">{gate.provider}</div>
<div className="text-xs text-zinc-500">
{gate.masterDataEndpointConfigured ? "主数据端点已配置" : "主数据端点未配置"}
</div>
</div>
<div>
<div className="text-zinc-500">凭证端点 / Cron</div>
<div className="font-medium">
{gate.journalEndpointConfigured ? "凭证端点已配置" : "凭证端点未配置"}
</div>
<div className="text-xs text-zinc-500">{gate.masterDataSyncCron || "未启用自动同步"}</div>
</div>
</div>
) : null}
</div>
<Tabs value={tab} onValueChange={(value) => setTab(value as ErpTab)}>
<TabsList className="flex w-full justify-start gap-1">
<TabsTrigger value="master-data">主数据同步</TabsTrigger>
<TabsTrigger value="outbox">Outbox 队列</TabsTrigger>
<TabsTrigger value="bindings">ID 映射</TabsTrigger>
</TabsList>
{(["legal_entity", "cost_center", "supplier"] as const).map((resourceType) => {
const item = itemsByType.get(resourceType) ?? null;
return (
<div key={resourceType} className="card-std p-5 space-y-3">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-base font-semibold text-zinc-900">{RESOURCE_LABELS[resourceType]}</h3>
<p className="text-sm text-zinc-500">状态:{item?.status ?? "idle"}</p>
</div>
<Button
variant="secondary"
onClick={() => syncMutation.mutate([resourceType])}
disabled={syncMutation.isPending}
>
仅同步{RESOURCE_LABELS[resourceType]}
</Button>
</div>
<div className="space-y-1 text-sm text-zinc-700">
<div>最近开始:{formatDateTime(item?.lastStartedAt ?? null)}</div>
<div>最近完成:{formatDateTime(item?.lastFinishedAt ?? null)}</div>
<div>最近成功:{formatDateTime(item?.lastSuccessAt ?? null)}</div>
<div className="break-all">最近错误:{item?.lastError ?? "-"}</div>
</div>
{item?.lastResult ? (
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3 text-sm text-zinc-700 space-y-1">
<TabsContent value="master-data" className="space-y-6">
<div className="grid gap-4 lg:grid-cols-4">
<div className="card-std p-5 space-y-2 lg:col-span-4">
<h3 className="text-base font-semibold text-zinc-900">ERP 状态</h3>
{statusQuery.isLoading ? <p className="text-sm text-zinc-500">加载中...</p> : null}
{statusQuery.isError ? (
<p className="text-sm text-red-600">{resolveError(statusQuery.error)}</p>
) : null}
{gate ? (
<div className="grid gap-3 text-sm text-zinc-700 md:grid-cols-2 xl:grid-cols-4">
<div>
请求 {item.lastResult.requested} / 新增 {item.lastResult.created} / 更新 {item.lastResult.updated}
<div className="text-zinc-500">Env 总闸</div>
<div className="font-medium">{gate.envEnabled ? "已开启" : "已关闭"}</div>
</div>
<div>
归档 {item.lastResult.archived} / 跳过 {item.lastResult.skipped}
<div className="text-zinc-500">功能开关</div>
<div className="font-medium">{gate.effectiveEnabled ? "允许" : "阻断"}</div>
<div className="text-xs text-zinc-500">{formatGateReason(gate.effectiveReason)}</div>
</div>
{item.lastResult.sampleSkipped.length ? (
<div className="text-xs text-zinc-500 break-all">
示例跳过:
{item.lastResult.sampleSkipped
.map((sample) => `${sample.key}:${sample.reason}`)
.join(";")}
<div>
<div className="text-zinc-500">Provider / 主数据端点</div>
<div className="font-medium">{gate.provider}</div>
<div className="text-xs text-zinc-500">
{gate.masterDataEndpointConfigured ? "主数据端点已配置" : "主数据端点未配置"}
</div>
) : null}
</div>
<div>
<div className="text-zinc-500">凭证端点 / Cron</div>
<div className="font-medium">
{gate.journalEndpointConfigured ? "凭证端点已配置" : "凭证端点未配置"}
</div>
<div className="text-xs text-zinc-500">{gate.masterDataSyncCron || "未启用自动同步"}</div>
</div>
</div>
) : (
<p className="text-sm text-zinc-500">暂无同步记录。</p>
)}
) : null}
</div>
);
})}
</div>
<div className="card-std p-5 space-y-2 text-sm text-zinc-600">
<h3 className="text-base font-semibold text-zinc-900">说明</h3>
<p>- 本页只负责 ERP 主数据同步,不承担报销凭证的手工单号输入导出。</p>
<p>- 报销付款凭证导出请前往已付款报销详情页执行,系统会按标准凭证切片通过 Outbox 异步发送。</p>
<p>- 当前阶段不包含采购、资产、盘点的真实 ERP 过账。</p>
</div>
{(["legal_entity", "cost_center", "supplier"] as const).map((resourceType) => {
const item = itemsByType.get(resourceType) ?? null;
return (
<div key={resourceType} className="card-std space-y-3 p-5">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-base font-semibold text-zinc-900">{RESOURCE_LABELS[resourceType]}</h3>
<p className="text-sm text-zinc-500">状态:{item?.status ?? "idle"}</p>
</div>
<Button
variant="secondary"
onClick={() => syncMutation.mutate([resourceType])}
disabled={syncMutation.isPending}
>
仅同步{RESOURCE_LABELS[resourceType]}
</Button>
</div>
<div className="space-y-1 text-sm text-zinc-700">
<div>最近开始:{formatDateTime(item?.lastStartedAt ?? null)}</div>
<div>最近完成:{formatDateTime(item?.lastFinishedAt ?? null)}</div>
<div>最近成功:{formatDateTime(item?.lastSuccessAt ?? null)}</div>
<div className="break-all">最近错误:{item?.lastError ?? "-"}</div>
</div>
{item?.lastResult ? (
<div className="space-y-1 rounded-lg border border-zinc-200 bg-zinc-50 p-3 text-sm text-zinc-700">
<div>
请求 {item.lastResult.requested} / 新增 {item.lastResult.created} / 更新 {item.lastResult.updated}
</div>
<div>
归档 {item.lastResult.archived} / 跳过 {item.lastResult.skipped}
</div>
{item.lastResult.sampleSkipped.length ? (
<div className="break-all text-xs text-zinc-500">
示例跳过:
{item.lastResult.sampleSkipped
.map((sample) => `${sample.key}:${sample.reason}`)
.join(";")}
</div>
) : null}
</div>
) : (
<p className="text-sm text-zinc-500">暂无同步记录。</p>
)}
</div>
);
})}
</div>
<div className="card-std space-y-2 p-5 text-sm text-zinc-600">
<h3 className="text-base font-semibold text-zinc-900">说明</h3>
<p>- 本页只负责 ERP 主数据同步、Outbox 队列和 ID 映射,不承担业务单据本身的录入。</p>
<p>- 报销、采购和资产的 ERP 同步动作请前往对应业务详情区执行。</p>
<p>- 当前阶段不接入真实 ERP Provider,仍以 mock Provider 为准。</p>
</div>
</TabsContent>
<TabsContent value="outbox" className="space-y-6">
<div className="grid gap-4 md:grid-cols-5">
{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 (
<div
key={status}
className={`card-std space-y-2 p-4 ${status === "failed" || status === "dead_letter" ? "border-red-200 bg-red-50/60" : ""}`}
>
<div className="text-sm text-zinc-500">{outboxStatusLabel(status)}</div>
<div className="text-2xl font-semibold text-zinc-900">
{outboxStatsQuery.isLoading ? "-" : count ?? 0}
</div>
</div>
);
})}
</div>
<div className="card-std space-y-4 p-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div>
<h3 className="text-base font-semibold text-zinc-900">Outbox 事件列表</h3>
<p className="text-sm text-zinc-500">支持按状态和事件类型筛选,失败和死信事件可在此重试。</p>
</div>
<div className="flex flex-col gap-3 md:flex-row">
<label className="space-y-1 text-sm text-zinc-600">
<span>状态</span>
<select
className="select-std min-w-40"
value={outboxStatus}
onChange={(event) => {
setOutboxStatus(event.target.value as IntegrationOutboxStatus | "");
setOutboxPage(1);
}}
>
{OUTBOX_STATUS_OPTIONS.map((item) => (
<option key={item.label} value={item.value}>
{item.label}
</option>
))}
</select>
</label>
<label className="space-y-1 text-sm text-zinc-600">
<span>事件类型</span>
<Input
className="min-w-56"
placeholder="例如 erp.journal.export"
value={outboxEventType}
onChange={(event) => {
setOutboxEventType(event.target.value);
setOutboxPage(1);
}}
/>
</label>
</div>
</div>
{outboxQuery.isLoading ? <TableSkeleton columns={7} rows={6} /> : null}
{outboxQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveError(outboxQuery.error)}
</p>
) : null}
{outboxQuery.data ? (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>事件类型</TableHead>
<TableHead>摘要标识</TableHead>
<TableHead>状态</TableHead>
<TableHead>尝试</TableHead>
<TableHead>下次重试</TableHead>
<TableHead>创建时间</TableHead>
<TableHead>处理时间</TableHead>
<TableHead className="w-40">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{outboxQuery.data.items.length ? (
outboxQuery.data.items.map((item) => (
<TableRow key={item.id}>
<TableCell className="font-medium text-zinc-900">{item.eventType}</TableCell>
<TableCell>
<div className="space-y-1 text-sm">
<div className="font-medium text-zinc-900">{renderOutboxSummary(item)}</div>
<div className="text-xs text-zinc-500">
{item.payloadSummary.entityType ?? "-"}
{item.payloadSummary.sourceEventName ? ` · ${item.payloadSummary.sourceEventName}` : ""}
</div>
</div>
</TableCell>
<TableCell>
<span className={`inline-flex rounded-full border px-2 py-1 text-xs font-medium ${outboxStatusClassName(item.status)}`}>
{outboxStatusLabel(item.status)}
</span>
</TableCell>
<TableCell>
{item.attempts} / {item.maxAttempts}
</TableCell>
<TableCell>{formatDateTime(item.nextRetryAt)}</TableCell>
<TableCell>{formatDateTime(item.createdAt)}</TableCell>
<TableCell>{formatDateTime(item.processedAt)}</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => setSelectedOutboxId(item.id)}
>
查看详情
</Button>
{item.status === "failed" || item.status === "dead_letter" ? (
<Button
type="button"
size="sm"
disabled={retryMutation.isPending}
onClick={() => retryMutation.mutate(item.id)}
>
重试
</Button>
) : null}
</div>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={8} className="py-6 text-center text-zinc-500">
当前筛选下暂无 Outbox 事件
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<Pagination page={outboxPage} totalPages={outboxTotalPages} onPageChange={setOutboxPage} />
</>
) : null}
</div>
</TabsContent>
<TabsContent value="bindings" className="space-y-6">
<div className="card-std space-y-4 p-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div>
<h3 className="text-base font-semibold text-zinc-900">IntegrationBinding 列表</h3>
<p className="text-sm text-zinc-500">查看本地单据与 ERP 外部 ID/编号的映射关系,并支持深链回到业务页。</p>
</div>
<div className="flex flex-col gap-3 md:flex-row">
<label className="space-y-1 text-sm text-zinc-600">
<span>Provider</span>
<select
className="select-std min-w-40"
value={bindingProvider}
onChange={(event) => {
setBindingProvider(event.target.value);
setBindingsPage(1);
}}
>
<option value="">全部 Provider</option>
<option value="erp">erp</option>
</select>
</label>
<label className="space-y-1 text-sm text-zinc-600">
<span>实体类型</span>
<select
className="select-std min-w-56"
value={bindingEntityType}
onChange={(event) => {
setBindingEntityType(event.target.value);
setBindingsPage(1);
}}
>
{BINDING_ENTITY_OPTIONS.map((item) => (
<option key={item.label} value={item.value}>
{item.label}
</option>
))}
</select>
</label>
</div>
</div>
{bindingsQuery.isLoading ? <TableSkeleton columns={6} rows={6} /> : null}
{bindingsQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveError(bindingsQuery.error)}
</p>
) : null}
{bindingsQuery.data ? (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>Provider</TableHead>
<TableHead>实体类型</TableHead>
<TableHead>本地 ID</TableHead>
<TableHead>外部 ID</TableHead>
<TableHead>外部编号</TableHead>
<TableHead>最后同步</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{bindingsQuery.data.items.length ? (
bindingsQuery.data.items.map((item) => (
<TableRow key={item.id}>
<TableCell>{item.provider}</TableCell>
<TableCell>{bindingEntityLabel(item.entityType)}</TableCell>
<TableCell>
<BindingLocalIdCell item={item} />
</TableCell>
<TableCell className="break-all">{item.externalId ?? "-"}</TableCell>
<TableCell className="break-all">{item.externalCode ?? "-"}</TableCell>
<TableCell title={formatDateTime(item.lastSyncedAt)}>{formatRelativeTime(item.lastSyncedAt)}</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={6} className="py-6 text-center text-zinc-500">
当前筛选下暂无 ID 映射记录
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<Pagination page={bindingsPage} totalPages={bindingsTotalPages} onPageChange={setBindingsPage} />
</>
) : null}
</div>
</TabsContent>
</Tabs>
<Dialog
open={Boolean(selectedOutboxId)}
onOpenChange={(open) => {
if (!open) {
setSelectedOutboxId("");
}
}}
>
<DialogContent className="max-h-[85vh] max-w-4xl overflow-y-auto">
<DialogHeader>
<DialogTitle>Outbox 事件详情</DialogTitle>
<DialogDescription>查看完整 payload、错误摘要和当前队列状态。</DialogDescription>
</DialogHeader>
{outboxDetailQuery.isLoading ? <p className="text-sm text-zinc-500">详情加载中...</p> : null}
{outboxDetailQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveError(outboxDetailQuery.error)}
</p>
) : null}
{outboxDetailQuery.data ? (
<div className="space-y-4">
<div className="grid gap-3 rounded-lg border border-zinc-200 bg-zinc-50 p-4 text-sm text-zinc-700 md:grid-cols-2">
<div>Outbox ID:{outboxDetailQuery.data.id}</div>
<div>事件类型:{outboxDetailQuery.data.eventType}</div>
<div>状态:{outboxStatusLabel(outboxDetailQuery.data.status)}</div>
<div>尝试次数:{outboxDetailQuery.data.attempts} / {outboxDetailQuery.data.maxAttempts}</div>
<div>创建时间:{formatDateTime(outboxDetailQuery.data.createdAt)}</div>
<div>处理时间:{formatDateTime(outboxDetailQuery.data.processedAt)}</div>
<div>下次重试:{formatDateTime(outboxDetailQuery.data.nextRetryAt)}</div>
<div>死信时间:{formatDateTime(outboxDetailQuery.data.deadLetterAt)}</div>
</div>
<div className="space-y-2">
<div className="text-sm font-medium text-zinc-900">最近错误</div>
<div className="rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-600">
{outboxDetailQuery.data.lastError ?? "-"}
</div>
</div>
<div className="space-y-2">
<div className="text-sm font-medium text-zinc-900">Payload</div>
<pre className="max-h-[45vh] overflow-auto rounded-lg border border-zinc-200 bg-zinc-950 p-4 text-xs text-zinc-100">
{JSON.stringify(outboxDetailQuery.data.payload ?? null, null, 2)}
</pre>
</div>
</div>
) : null}
</DialogContent>
</Dialog>
</div>
);
}
+37 -13
查看文件
@@ -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() {
</div>
) : null}
{showArchiveSection ? (
<div className="space-y-3 rounded-lg border border-zinc-200 p-4">
<div className="flex items-center justify-between">
<div>
<h4 className="text-base font-semibold text-zinc-900">采购桥接轨道</h4>
<p className="text-sm text-zinc-500">展示 Commitment / Accrual / PO/合同摘要三段外部桥接状态,失败后可在此重试。</p>
</div>
</div>
{showArchiveSection && canIntegrationManage ? (
<SectionPanel
title="ERP 同步状态"
description="展示 Commitment / Accrual / PO/合同摘要三段同步状态,失败或未触发时可在此补发或重试。"
>
{erpTrackQuery.isLoading ? (
<p className="rounded-lg bg-zinc-50 px-3 py-2 text-sm text-zinc-500">桥接轨道加载中...</p>
<p className="rounded-lg bg-zinc-50 px-3 py-2 text-sm text-zinc-500">ERP 同步轨道加载中...</p>
) : erpTrackQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{resolveError(erpTrackQuery.error)}</p>
) : erpTrack ? (
@@ -1297,7 +1321,7 @@ export function PurchaseRequestsPage() {
<div className="rounded bg-red-50 px-2 py-1 text-xs text-red-600">{stage.outbox.lastError}</div>
) : null}
{canIntegrationManage && (externalCode || externalId) ? (
{externalCode || externalId ? (
<div className="rounded bg-zinc-50 px-2 py-1 text-xs text-zinc-600">
ERP 编号:{externalCode ?? "-"}
{externalId ? ` · ${externalId}` : ""}
@@ -1324,7 +1348,7 @@ export function PurchaseRequestsPage() {
) : (
<p className="rounded-lg bg-zinc-50 px-3 py-2 text-sm text-zinc-500">暂无 ERP 轨道信息</p>
)}
</div>
</SectionPanel>
) : null}
<AiSuggestionCard targetType="purchase_request" targetId={detail.id} />
+120 -33
查看文件
@@ -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<ItemEditorState>(null);
const [itemError, setItemError] = useState("");
const [confirmDeleteItemId, setConfirmDeleteItemId] = useState("");
const [isErpPreviewOpen, setIsErpPreviewOpen] = useState(false);
const [editorInitialValues, setEditorInitialValues] = useState<ItemEditorInitialValues>({
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() {
<RiskFlagsCard items={detail.riskFlags} title="待处理风险项" />
<AiSuggestionCard targetType="reimbursement" targetId={detail.id} />
{canManageIntegration && detail.status === "paid" ? (
<div className="space-y-4 rounded-lg border border-zinc-200 p-4" data-testid="reimbursement-erp-export">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="space-y-1">
<h3 className="text-base font-semibold text-zinc-900">财务桥接</h3>
<p className="text-sm text-zinc-500">已付款报销单会按标准凭证切片通过 Outbox 异步发送到财务桥接出口。</p>
{canManageIntegration ? (
<SectionPanel
title="ERP 同步状态"
description="已付款报销单会按标准凭证切片通过 Outbox 异步发送到 ERP,同步状态、错误摘要和凭证预览入口统一收口在此。"
actions={
<div className="flex gap-2">
<Button
type="button"
variant="secondary"
disabled={!isPaidReimbursement || erpPreviewQuery.isLoading || Boolean(erpPreviewQuery.isError) || !erpPreviewQuery.data}
onClick={() => setIsErpPreviewOpen(true)}
>
预览凭证
</Button>
<Button type="button" disabled={!isPaidReimbursement || erpExportMutation.isPending} onClick={() => erpExportMutation.mutate()}>
{erpExportMutation.isPending ? "导出中..." : "导出到 ERP"}
</Button>
</div>
<Button onClick={() => erpExportMutation.mutate()} disabled={erpExportMutation.isPending}>
{erpExportMutation.isPending ? "出队中..." : "发送到财务桥接"}
</Button>
</div>
}
>
{!isPaidReimbursement ? (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-700">
未满足导出条件,仅已付款报销可导出到 ERP。
</p>
) : null}
{erpPreviewQuery.isLoading ? <p className="text-sm text-zinc-500">正在加载凭证切片预览...</p> : null}
{erpPreviewQuery.isLoading ? <p className="text-sm text-zinc-500">正在加载 ERP 凭证预览...</p> : null}
{erpPreviewQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{isApiError(erpPreviewQuery.error)
@@ -655,33 +705,35 @@ export function ReimbursementDetailPage() {
erpPreviewQuery.error.details,
erpPreviewQuery.error.message,
)}`
: "加载财务桥接预览失败"}
: "加载 ERP 凭证预览失败"}
</p>
) : null}
{erpPreviewQuery.data ? (
<div className="grid gap-4 text-sm lg:grid-cols-2">
<div className="rounded-lg border border-zinc-200 p-4 space-y-2">
<h4 className="font-medium text-zinc-900">凭证切片</h4>
<div>类型:{erpPreviewQuery.data.journal.journalType}</div>
<div>单号:{erpPreviewQuery.data.journal.documentNo ?? "-"}</div>
<div>金额:{erpPreviewQuery.data.journal.totalAmount ?? "-"}</div>
<div>法体:{erpPreviewQuery.data.journal.dimensions.legalEntityCode ?? "-"}</div>
<div>成本中心:{erpPreviewQuery.data.journal.dimensions.costCenterCode ?? "-"}</div>
<div>付款凭证号:{erpPreviewQuery.data.journal.paymentVoucherNo ?? "-"}</div>
<div>付款日期:{erpPreviewQuery.data.journal.paymentDate ?? "-"}</div>
</div>
<div className="rounded-lg border border-zinc-200 p-4 space-y-2">
<h4 className="font-medium text-zinc-900">导出状态</h4>
<div>外部ID:{erpPreviewQuery.data.exportState.externalId ?? "-"}</div>
<div>Outbox:{erpPreviewQuery.data.exportState.lastOutboxId ?? "-"}</div>
<div>队列状态:{erpPreviewQuery.data.exportState.lastOutboxStatus ?? "-"}</div>
<div>最近导出:{formatDateTime(erpPreviewQuery.data.exportState.exportedAt)}</div>
<div className="break-all">最近错误:{erpPreviewQuery.data.exportState.lastError ?? "-"}</div>
</div>
<div className="grid gap-3 text-sm md:grid-cols-2 xl:grid-cols-4" data-testid="reimbursement-erp-export">
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3">
<div className="text-xs text-zinc-500">同步状态</div>
<div className="mt-1 font-medium text-zinc-900">{erpSyncStatus}</div>
</div>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3">
<div className="text-xs text-zinc-500">最后同步</div>
<div className="mt-1 font-medium text-zinc-900">{formatDateTime(erpPreviewQuery.data?.exportState.exportedAt ?? null)}</div>
</div>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3">
<div className="text-xs text-zinc-500">Outbox</div>
<div className="mt-1 break-all font-medium text-zinc-900">{erpPreviewQuery.data?.exportState.lastOutboxId ?? "-"}</div>
</div>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3">
<div className="text-xs text-zinc-500">外部 ID</div>
<div className="mt-1 break-all font-medium text-zinc-900">{erpPreviewQuery.data?.exportState.externalId ?? "-"}</div>
</div>
</div>
{erpPreviewQuery.data?.exportState.lastError ? (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
最近错误:{erpPreviewQuery.data.exportState.lastError}
</div>
) : null}
</div>
</SectionPanel>
) : null}
</div>
</SectionPanel>
@@ -741,6 +793,41 @@ export function ReimbursementDetailPage() {
</div>
</div>
<Dialog open={isErpPreviewOpen} onOpenChange={setIsErpPreviewOpen}>
<DialogContent className="max-h-[85vh] max-w-3xl overflow-y-auto">
<DialogHeader>
<DialogTitle>ERP 凭证预览</DialogTitle>
<DialogDescription>展示当前报销单将发送到 ERP 的凭证切片与最近一次导出状态。</DialogDescription>
</DialogHeader>
{erpPreviewQuery.data ? (
<div className="grid gap-4 text-sm lg:grid-cols-2">
<div className="space-y-2 rounded-lg border border-zinc-200 p-4">
<h4 className="font-medium text-zinc-900">凭证切片</h4>
<div>类型:{erpPreviewQuery.data.journal.journalType}</div>
<div>单号:{erpPreviewQuery.data.journal.documentNo ?? "-"}</div>
<div>金额:{erpPreviewQuery.data.journal.totalAmount ?? "-"}</div>
<div>法体:{erpPreviewQuery.data.journal.dimensions.legalEntityCode ?? "-"}</div>
<div>成本中心:{erpPreviewQuery.data.journal.dimensions.costCenterCode ?? "-"}</div>
<div>付款凭证号:{erpPreviewQuery.data.journal.paymentVoucherNo ?? "-"}</div>
<div>付款日期:{erpPreviewQuery.data.journal.paymentDate ?? "-"}</div>
</div>
<div className="space-y-2 rounded-lg border border-zinc-200 p-4">
<h4 className="font-medium text-zinc-900">导出状态</h4>
<div>同步状态:{erpSyncStatus}</div>
<div>外部 ID:{erpPreviewQuery.data.exportState.externalId ?? "-"}</div>
<div>Outbox:{erpPreviewQuery.data.exportState.lastOutboxId ?? "-"}</div>
<div>队列状态:{erpPreviewQuery.data.exportState.lastOutboxStatus ?? "-"}</div>
<div>最近导出:{formatDateTime(erpPreviewQuery.data.exportState.exportedAt)}</div>
<div className="break-all">最近错误:{erpPreviewQuery.data.exportState.lastError ?? "-"}</div>
</div>
</div>
) : (
<p className="text-sm text-zinc-500">暂无可预览的 ERP 凭证数据。</p>
)}
</DialogContent>
</Dialog>
<ConfirmDialog
open={Boolean(confirmDeleteItemId)}
onOpenChange={(open) => { if (!open) setConfirmDeleteItemId(""); }}