R53 A1-A6:ERP 集成运维可视化与业务同步状态接入
- ErpIntegrationPage Tab 化:主数据同步 / Outbox 队列管理 / ID 映射查看器 - Outbox 分页查询、详情、重试与统计端点 - IntegrationBinding 分页查询、routePath 推导与采购/资产深链 - 报销/采购/资产详情区接入 ERP 同步状态卡片 - 单测覆盖 Outbox 与 Binding 端点
这个提交包含在:
@@ -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(""); }}
|
||||
|
||||
在新工单中引用
屏蔽一个用户