修正 ERP 边界与资产页面一致性(05-30-fix-erp-boundary-asset-consistency)

这个提交包含在:
2026-05-30 23:06:02 +08:00
父节点 16fd9bdf52
当前提交 f299ceeb74
共修改 47 个文件,包含 476 行新增和 1633 行删除
+1 -1
查看文件
@@ -358,7 +358,7 @@ export function mapApiErrorMessage(
}
if (details?.integration === "erp_sync_disabled") {
return "ERP 同步功能未开启。";
return "该同步功能未开启。";
}
if (details?.integration === "travel_sync_disabled") {
@@ -56,7 +56,6 @@ const MANAGEMENT_ACTIVE_PATHS = [
"/system/reimbursement-templates",
"/system/master-data",
"/system/notification-channels",
"/system/integrations/erp",
"/system/users",
"/system/departments",
"/system/cutover",
+5 -3
查看文件
@@ -178,7 +178,9 @@ export function Topbar() {
</span>
) : null}
</div>
<div className="mt-0.5 text-xs text-[var(--color-text-secondary)]">{me ? `${me.name} · 当前工作区` : "海音达 OA"}</div>
<div className="mt-0.5 text-xs text-[var(--color-text-secondary)]">
{me ? `${me.name} · ${me.employeeNo}` : "海音达 OA"}
</div>
</div>
</div>
@@ -293,8 +295,8 @@ export function Topbar() {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button data-testid="topbar-profile-trigger" variant="secondary" type="button" className="max-w-[160px] truncate">
{me?.name ?? "账号"}
<Button data-testid="topbar-profile-trigger" variant="secondary" type="button" className="max-w-[190px] truncate">
{me ? `${me.name} · ${me.employeeNo}` : "账号"}
</Button>
</DropdownMenuTrigger>
@@ -58,7 +58,7 @@ export function TravelOrderMarkPaidDialog({
<DialogHeader>
<DialogTitle>标记差旅订单已支付</DialogTitle>
<DialogDescription>
确认订单 `{order?.externalOrderNo ?? "-"}` 已完成对公支付;系统会按幂等键复用现有 ERP journal 出站记录。
确认订单 `{order?.externalOrderNo ?? "-"}` 已完成对公支付;系统会按幂等键复用现有财务交接记录。
</DialogDescription>
</DialogHeader>
@@ -66,7 +66,7 @@ function erpStageLabel(order: TravelPaymentOrder) {
if (order.erpStage.binding?.externalId) {
return "已同步";
}
return "ERP 出站未开启";
return "摘要导出未开启";
}
type TravelPaymentTrackCardProps = {
@@ -178,7 +178,7 @@ export function TravelPaymentTrackCard({ travelRequestId }: TravelPaymentTrackCa
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-base font-semibold text-zinc-900">对公支付轨道</h3>
<p className="mt-1 text-sm text-zinc-500">授权、交易、发票绑定与 ERP 支付出站在此统一查看。</p>
<p className="mt-1 text-sm text-zinc-500">授权、交易、发票绑定与财务交接导出在此统一查看。</p>
</div>
{canManagePayments ? <span className="text-xs text-zinc-500">具备支付管理权限</span> : null}
</div>
@@ -257,7 +257,7 @@ export function TravelPaymentTrackCard({ travelRequestId }: TravelPaymentTrackCa
<div>金额:{order.amount} {order.currency}</div>
<div>付款凭证:{order.paymentVoucherNo ?? "-"}</div>
<div>付款日期:{order.paymentDate ?? "-"} · 渠道:{paymentChannelLabel(order.paymentChannel)}</div>
<div>ERP 状态:{erpStageLabel(order)}</div>
<div>导出状态:{erpStageLabel(order)}</div>
</div>
</div>
{canManagePayments ? (
@@ -244,7 +244,7 @@ export function TravelReconciliationTab({ selectedBatchId, onSelectBatch }: Trav
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['travel-reconciliation', 'erp-track', selectedBatchId] });
toast.success('ERP 摘要桥已重新入队');
toast.success('交接摘要已重新入队');
},
onError: (error) => {
toast.error(resolveError(error));
@@ -418,7 +418,7 @@ export function TravelReconciliationTab({ selectedBatchId, onSelectBatch }: Trav
) : null}
{!selectedBatchId ? (
<EmptyStateCard title="请选择对账批次" description="从左侧选择一个批次查看 KPI、异常项与 ERP 摘要桥轨道。" />
<EmptyStateCard title="请选择对账批次" description="从左侧选择一个批次查看 KPI、异常项与交接摘要。" />
) : null}
{detail ? (
@@ -445,7 +445,7 @@ export function TravelReconciliationTab({ selectedBatchId, onSelectBatch }: Trav
) : null}
{track?.permissions.canRetry ? (
<Button type="button" variant="secondary" onClick={() => requeueMutation.mutate()} disabled={requeueMutation.isPending}>
{requeueMutation.isPending ? '重试中...' : 'Requeue ERP'}
{requeueMutation.isPending ? '重试中...' : '重试导出'}
</Button>
) : null}
</div>
@@ -473,14 +473,14 @@ export function TravelReconciliationTab({ selectedBatchId, onSelectBatch }: Trav
<div className="card-std space-y-4 p-6">
<div>
<h3 className="text-base font-semibold text-zinc-900">ERP 摘要桥</h3>
<p className="mt-1 text-sm text-zinc-500">批次级 `erp.travel.export` 轨道,不会回改订单、申请或预算原值。</p>
<h3 className="text-base font-semibold text-zinc-900">交接摘要</h3>
<p className="mt-1 text-sm text-zinc-500">批次级摘要导出轨道,不会回改订单、申请或预算原值。</p>
</div>
{trackQuery.isLoading ? <CardSkeleton lines={4} /> : null}
{trackQuery.isError ? (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-700">
ERP 轨道加载失败:{resolveError(trackQuery.error)}
摘要轨道加载失败:{resolveError(trackQuery.error)}
</p>
) : null}
-1
查看文件
@@ -46,7 +46,6 @@ const PAGE_TITLES: Array<{ path: string; title: string; group: string }> = [
{ path: "/system/settings", title: "管理", group: "管理" },
{ path: "/system/feature-flags", title: "功能管理", group: "管理" },
{ path: "/system/cutover", title: "切换控制台", group: "更多" },
{ path: "/system/integrations/erp", title: "外部系统", group: "更多" },
{ path: "/dashboard", title: "数据看板", group: "更多" },
{ path: "/audit-logs", title: "审计日志", group: "管理" },
];
+192 -353
查看文件
@@ -10,16 +10,12 @@ import {
fetchAssetAssignees,
fetchAssetCategories,
fetchAssetDetail,
fetchAssetErpTrack,
fetchAssets,
patchAsset,
patchAssetCategory,
requeueAssetErpSync,
returnAsset,
transferAsset,
type AssetCategoryItem,
type AssetErpStage,
type AssetErpTrack,
type AssetItem,
type AssetSensitivityLevel,
type AssetStatus,
@@ -30,11 +26,12 @@ import { fetchCostCenters, fetchDocumentCategories, fetchLegalEntities } from ".
import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission, hasPermission } from "../auth/permissions";
import { AiSuggestionCard } from "../components/ai/AiSuggestionCard";
import { DateTextInput } from "../components/common/DateTextInput";
import { StatusBadge } from "../components/common/StatusBadge";
import { TableSkeleton } from "../components/common/TableSkeleton";
import { DetailSideMeta } from "../components/patterns/DetailSideMeta";
import { FilterBar, ListWorkspace, PrimaryTable, WorkspaceHeader } from "../components/patterns/ListWorkspace";
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";
@@ -117,16 +114,6 @@ function assetStatusLabel(value: AssetStatus | string | null | undefined) {
return assetStatusOptions().find((item) => item.value === value)?.label ?? "未知状态";
}
function assetErpChangeTypeLabel(value: string | null | undefined) {
const labels: Record<string, string> = {
created: "新建资产卡",
updated: "资产信息更新",
retired: "资产退役",
archived: "资产归档",
};
return value ? labels[value] ?? "资产变更" : "-";
}
function sensitivityOptions(): Array<{ value: AssetSensitivityLevel | ""; label: string }> {
return [
{ value: "", label: "全部敏感级别" },
@@ -137,38 +124,12 @@ function sensitivityOptions(): Array<{ value: AssetSensitivityLevel | ""; label:
];
}
function typeHint(assetType: AssetType) {
switch (assetType) {
case "electronic_document":
return "电子资料只登记摘要、hash、敏感级别和归档位置;S3/S4 原文和完整路径不上传公网 OA。";
case "electronic_system":
return "电子系统登记负责人、入口、到期和备份策略;账号密码、token、Secret 不进 OA。";
case "code_repository":
return "代码仓库继续由 Git 管理,OA 只登记仓库元数据、负责人和备份策略。";
case "backup_media":
return "备份介质记录保管人、加密状态、备份范围和最近恢复验证时间。";
default:
return "固定资产支持领用、归还、调拨、盘点和移动扫码。";
}
}
function isElectronicAssetType(assetType: AssetType) {
return assetType !== "fixed_asset";
}
function assetNextStep(item: Pick<AssetItem, "assetType" | "assignee" | "owner" | "status" | "lastVerifiedAt" | "expiresAt">) {
if (item.status === "archived") return "已归档留存";
if (item.status === "retired") return "核对处置记录";
if (item.status === "under_repair") return "跟进维修结果";
if (item.assetType === "fixed_asset") {
if (!item.assignee) return "待领用或确认保管";
if (!item.lastVerifiedAt) return "待首次盘点";
return "按季度盘点";
}
if (!item.owner) return "补负责人";
if (!item.lastVerifiedAt) return "待验证责任/备份";
if (item.expiresAt && item.expiresAt < new Date().toISOString().slice(0, 10)) return "检查到期续费";
return "按期验证权限";
function formatEmployeeSummary(employee: { name: string; employeeNo: string } | null | undefined) {
return employee ? `${employee.name} · ${employee.employeeNo}` : "-";
}
function CompactAssetNo({ value, copyable = true }: { value: string; copyable?: boolean }) {
@@ -231,37 +192,6 @@ function unsafeAssetTextReason(value: string) {
return "";
}
function formatAssetErpReason(reason: string | null) {
switch (reason) {
case "erp_sync_disabled":
case "env_hard_off":
return "ERP 总闸当前关闭";
case "flag_force_off":
return "ERP 功能开关当前强制关闭";
case "missing_flag_record":
return "ERP 功能开关记录缺失";
default:
return reason ?? null;
}
}
function resolveAssetErpTimestamp(stage: AssetErpStage) {
return stage.outbox?.updatedAt ?? stage.outbox?.processedAt ?? stage.binding?.lastSyncedAt ?? null;
}
function resolveAssetErpActionLabel(actionHint: AssetErpTrack["stage"]["actionHint"]) {
switch (actionHint) {
case "backfill":
return "补发";
case "retry":
return "重试同步";
case "resync":
return "重新同步摘要";
default:
return null;
}
}
export function AssetRegistryPage() {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
@@ -270,7 +200,6 @@ export function AssetRegistryPage() {
const canManage = hasPermission(permissions, "asset.manage");
const canTransfer = hasPermission(permissions, "asset.transfer");
const canIntegrationManage = hasPermission(permissions, "integration.manage");
const canViewAll = hasAnyPermission(permissions, ["asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"]);
const canReadProcurementSources = canManage && hasAnyPermission(permissions, ["procurement.view_all", "procurement.manage"]);
@@ -324,12 +253,6 @@ export function AssetRegistryPage() {
queryFn: () => fetchAssetDetail(selectedAssetId),
});
const assetErpTrackQuery = useQuery({
enabled: canIntegrationManage && Boolean(selectedAssetId) && !isCreatingAsset,
queryKey: ["assets", "erp-track", selectedAssetId],
queryFn: () => fetchAssetErpTrack(selectedAssetId),
});
const legalEntitiesQuery = useQuery({
queryKey: ["assets", "legal-entities"],
queryFn: () => fetchLegalEntities({ enabled: true }),
@@ -442,9 +365,6 @@ export function AssetRegistryPage() {
}, [costCentersQuery.data?.items, legalEntityFilter]);
const currentAsset = assetDetailQuery.data?.asset ?? null;
const assetErpTrack = assetErpTrackQuery.data ?? null;
const assetErpStage = assetErpTrack?.stage ?? null;
const assetErpActionLabel = assetErpStage ? resolveAssetErpActionLabel(assetErpStage.actionHint) : null;
const canEditCurrent = canManage && (isCreatingAsset || currentAsset?.permissions.canEdit);
function resetCategoryEditor(category?: AssetCategoryItem) {
@@ -612,7 +532,6 @@ export function AssetRegistryPage() {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["assets", "list"] }),
queryClient.invalidateQueries({ queryKey: ["assets", "detail"] }),
queryClient.invalidateQueries({ queryKey: ["assets", "erp-track"] }),
]);
setAssetFormError("");
setSelectedAssetId(data.asset.id);
@@ -627,30 +546,6 @@ export function AssetRegistryPage() {
});
const requeueAssetErpMutation = useMutation({
mutationFn: async () => {
if (!currentAsset) {
throw new Error("请先选择资产卡片");
}
return requeueAssetErpSync(currentAsset.id, true);
},
onSuccess: async (data) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["assets", "erp-track"] }),
queryClient.invalidateQueries({ queryKey: ["assets", "detail"] }),
]);
const message = data.duplicated
? "ERP 摘要已是最新状态"
: data.requeued
? "资产 ERP 同步已重新入队"
: "资产 ERP 同步已入队";
toast.success(message);
},
onError: (error) => {
toast.error(resolveError(error));
},
});
const assignAssetMutation = useMutation({
mutationFn: async () => {
if (!currentAsset) {
@@ -819,6 +714,11 @@ export function AssetRegistryPage() {
</Button>
) : (
<>
{canManage ? (
<Button type="button" data-testid="asset-create" onClick={resetAssetEditor}>
新增资产
</Button>
) : null}
<Button type="button" variant="secondary" onClick={() => navigate("/mobile/assets/scan") }>
移动扫码查找
</Button>
@@ -835,15 +735,6 @@ export function AssetRegistryPage() {
<div className="grid gap-6">
{showLedgerTools ? (
<div className="card-std space-y-4 p-4 md:p-5" data-testid="asset-ledger-tools">
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm text-slate-600">
<div className="font-medium text-slate-900">台账工具</div>
{canManage ? (
<Button type="button" className="mt-3" data-testid="asset-create" onClick={resetAssetEditor}>
新建资产卡片
</Button>
) : null}
</div>
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold text-zinc-900">资产分类</h3>
{canManage ? (
@@ -1087,58 +978,61 @@ export function AssetRegistryPage() {
<div className="rounded-lg border border-zinc-200 p-4 text-sm text-zinc-500">暂无资产卡片</div>
)}
</div>
<PrimaryTable className="hidden md:block">
<Table>
<TableHeader>
<tr>
<TableHead>资产</TableHead>
<TableHead>类型</TableHead>
<TableHead>责任链</TableHead>
<TableHead>位置</TableHead>
<TableHead>状态</TableHead>
<TableHead className="w-24 text-right">操作</TableHead>
</tr>
</TableHeader>
<TableBody>
{orderedAssetItems.length ? (
orderedAssetItems.map((item) => (
<TableRow data-testid="asset-row" key={item.id} onClick={() => {
selectAsset(item.id);
}} className="cursor-pointer">
<TableCell>
<div className="font-medium text-zinc-900">
<CompactAssetNo value={item.assetNo} />
</div>
<div className="text-xs text-zinc-500" title={item.name}>
{formatTextWithCompactLongNumbers(item.name)}
</div>
</TableCell>
<TableCell>{assetTypeLabel(item.assetType)}</TableCell>
<TableCell>
<div>{item.owner?.name ?? item.assignee?.name ?? "-"}</div>
<div className="text-xs text-zinc-500">保管 {item.keeper?.name ?? "-"}</div>
</TableCell>
<TableCell title={item.location ?? undefined}>
<div>{formatTextWithCompactLongNumbers(item.location ?? "-")}</div>
<div className="text-xs text-zinc-500">盘点 {item.lastVerifiedAt ? formatDisplayDateTime(item.lastVerifiedAt) : "未登记"}</div>
</TableCell>
<TableCell><StatusBadge status={item.status} /></TableCell>
<TableCell className="text-right">
<Button type="button" size="sm" variant="secondary" onClick={() => selectAsset(item.id)}>
查看详情
</Button>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={6} className="py-4 text-zinc-500">
暂无资产卡片
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<PrimaryTable className="hidden md:block ops-table-focus">
<table className="w-full border-collapse text-left" data-testid="assets-table">
<thead className="table-header">
<tr>
<th className="table-th">资产</th>
<th className="table-th">状态</th>
<th className="table-th">责任人</th>
<th className="table-th">保管/位置</th>
<th className="table-th">盘点</th>
<th className="table-th w-24">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-50">
{orderedAssetItems.length ? (
orderedAssetItems.map((item) => (
<tr className="table-row" data-testid="asset-row" key={item.id}>
<td className="table-td">
<div className="font-semibold text-zinc-900">
<CompactAssetNo value={item.assetNo} />
</div>
<div className="text-xs text-zinc-500" title={item.name}>
{formatTextWithCompactLongNumbers(item.name)}
</div>
<div className="text-xs text-zinc-500">{assetTypeLabel(item.assetType)}</div>
</td>
<td className="table-td">
<StatusBadge status={item.status} />
</td>
<td className="table-td">
<div className="font-medium text-zinc-900">{formatEmployeeSummary(item.owner ?? item.assignee)}</div>
<div className="text-xs text-zinc-500">归属 {formatEmployeeSummary(item.assignee)}</div>
</td>
<td className="table-td" title={item.location ?? undefined}>
<div className="font-medium text-zinc-900">{formatEmployeeSummary(item.keeper)}</div>
<div className="text-xs text-zinc-500">{formatTextWithCompactLongNumbers(item.location ?? "-")}</div>
</td>
<td className="table-td">
{item.lastVerifiedAt ? formatDisplayDateTime(item.lastVerifiedAt) : "未登记"}
</td>
<td className="table-td">
<Button type="button" size="sm" variant="secondary" onClick={() => selectAsset(item.id)}>
查看详情
</Button>
</td>
</tr>
))
) : (
<tr>
<td colSpan={6} className="py-8 text-center text-sm text-zinc-500">
暂无资产卡片
</td>
</tr>
)}
</tbody>
</table>
</PrimaryTable>
</>
) : null}
@@ -1146,7 +1040,8 @@ export function AssetRegistryPage() {
</div>
</div>
) : (
<div className="card-std space-y-4 p-4 md:p-5">
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_320px]">
<div className="card-std space-y-4 p-4 md:p-5">
{selectedAssetId && !isCreatingAsset && assetDetailQuery.isLoading ? <TableSkeleton columns={2} rows={8} /> : null}
{selectedAssetId && !isCreatingAsset && assetDetailQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{resolveError(assetDetailQuery.error)}</p>
@@ -1182,19 +1077,19 @@ export function AssetRegistryPage() {
<div className="grid gap-3 md:grid-cols-4" data-testid="asset-detail-summary">
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3 text-sm">
<div className="text-xs text-zinc-500">责任人</div>
<div className="mt-1 font-medium text-zinc-900">{currentAsset.owner?.name ?? currentAsset.assignee?.name ?? "未指定"}</div>
<div className="mt-1 font-medium text-zinc-900">{formatEmployeeSummary(currentAsset.owner ?? currentAsset.assignee)}</div>
</div>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3 text-sm">
<div className="text-xs text-zinc-500">保管/位置</div>
<div className="mt-1 font-medium text-zinc-900">{currentAsset.keeper?.name ?? currentAsset.location ?? "待确认"}</div>
<div className="mt-1 font-medium text-zinc-900">{currentAsset.keeper ? formatEmployeeSummary(currentAsset.keeper) : currentAsset.location ?? "待确认"}</div>
</div>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3 text-sm">
<div className="text-xs text-zinc-500">{isElectronicAssetType(currentAsset.assetType) ? "最近验证" : "最近盘点"}</div>
<div className="mt-1 font-medium text-zinc-900">{currentAsset.lastVerifiedAt ? formatDisplayDateTime(currentAsset.lastVerifiedAt) : "未登记"}</div>
</div>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3 text-sm">
<div className="text-xs text-zinc-500">下一步</div>
<div className="mt-1 font-medium text-zinc-900">{assetNextStep(currentAsset)}</div>
<div className="text-xs text-zinc-500">状态</div>
<div className="mt-1"><StatusBadge status={currentAsset.status} /></div>
</div>
</div>
) : null}
@@ -1203,7 +1098,6 @@ export function AssetRegistryPage() {
<section className="space-y-4">
<div>
<h4 className="text-sm font-semibold text-zinc-900">基础信息</h4>
<p className="text-xs text-zinc-500">维护资产主标识、类型、分类和组织归属。</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
@@ -1219,7 +1113,6 @@ export function AssetRegistryPage() {
</option>
))}
</select>
<p className="text-xs text-zinc-500">{typeHint(assetForm.assetType)}</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">资产状态</label>
@@ -1300,7 +1193,6 @@ export function AssetRegistryPage() {
<section className="space-y-4 border-t border-zinc-200 pt-5">
<div>
<h4 className="text-sm font-semibold text-zinc-900">责任与归档</h4>
<p className="text-xs text-zinc-500">明确领用、负责、保管、位置和生命周期日期。</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
@@ -1371,7 +1263,7 @@ export function AssetRegistryPage() {
<section className="space-y-4 border-t border-zinc-200 pt-5">
<div>
<h4 className="text-sm font-semibold text-zinc-900">电子资产安全字段</h4>
<p className="text-xs text-zinc-500">只登记必要摘要,密码、密钥、源码包、原始正文和完整路径不得进入 OA。</p>
<p className="text-xs text-zinc-500">不填写密码、密钥、源码包、原始正文或完整路径。</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
@@ -1403,183 +1295,130 @@ export function AssetRegistryPage() {
</section>
) : null}
</div>
{currentAsset && canTransfer && !isCreatingAsset && currentAsset.assetType === "fixed_asset" ? (
<div className="rounded-xl border border-zinc-200 bg-zinc-50 p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<h4 className="text-sm font-semibold text-zinc-900">资产流转</h4>
<p className="text-xs text-zinc-500">通过受控动作执行领用、归还和调拨,并写入审计记录。</p>
</div>
<div className="text-xs text-zinc-500">
当前归属人:{currentAsset.assignee?.name ?? "未分配"} · 位置:{currentAsset.location ?? "未设置"}
</div>
</div>
{movementError ? (
<p className="mb-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{movementError}</p>
) : null}
<div className="grid gap-4 md:grid-cols-3">
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">目标归属人</label>
<select
data-testid="asset-movement-assignee"
className="select-std"
value={movementEmployeeId}
onChange={(event) => setMovementEmployeeId(event.target.value)}
>
<option value="">保持当前归属人</option>
{(assigneesQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
{item.name} ({item.employeeNo})
</option>
))}
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">目标位置</label>
<Input
data-testid="asset-movement-location"
value={movementLocation}
onChange={(event) => setMovementLocation(event.target.value)}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">备注</label>
<Input
data-testid="asset-movement-note"
value={movementNote}
onChange={(event) => setMovementNote(event.target.value)}
/>
</div>
</div>
<div className="mt-4 flex flex-wrap items-center gap-2">
<Button
type="button"
data-testid="asset-assign-submit"
disabled={assignAssetMutation.isPending || Boolean(currentAsset.assigneeEmployeeId) || !movementEmployeeId}
onClick={() => assignAssetMutation.mutate()}
>
{assignAssetMutation.isPending ? "领用中..." : "资产领用"}
</Button>
<Button
type="button"
variant="secondary"
data-testid="asset-return-submit"
disabled={returnAssetMutation.isPending || !currentAsset.assigneeEmployeeId}
onClick={() => returnAssetMutation.mutate()}
>
{returnAssetMutation.isPending ? "归还中..." : "资产归还"}
</Button>
<Button
type="button"
variant="secondary"
data-testid="asset-transfer-submit"
disabled={
transferAssetMutation.isPending ||
(!movementEmployeeId && movementLocation.trim() === (currentAsset.location ?? ""))
}
onClick={() => transferAssetMutation.mutate()}
>
{transferAssetMutation.isPending ? "调拨中..." : "资产调拨"}
</Button>
</div>
</div>
) : null}
{selectedAssetId && !isCreatingAsset && canIntegrationManage ? (
<details className="card-std p-4 md:p-5">
<summary className="flex cursor-pointer list-none items-start justify-between gap-3">
<div>
<div className="text-base font-semibold text-zinc-900">ERP 同步状态</div>
<div className="mt-1 text-sm text-zinc-500">高级轨道,默认收起。</div>
</div>
<span className="text-sm font-medium text-primary">展开</span>
</summary>
<div className="mt-4">
{assetErpTrackQuery.isLoading ? (
<p className="rounded-lg bg-zinc-50 px-3 py-2 text-sm text-zinc-500">ERP 同步轨道加载中...</p>
) : assetErpTrackQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{resolveError(assetErpTrackQuery.error)}</p>
) : assetErpStage ? (
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="space-y-1 text-xs text-zinc-500">
<div>
最近更新时间:{resolveAssetErpTimestamp(assetErpStage) ? formatDisplayDateTime(resolveAssetErpTimestamp(assetErpStage)) : "尚无同步记录"}
</div>
<div>变更来源:{assetErpStage.preview.sourceEventName ?? "-"}</div>
</div>
<StatusBadge status={assetErpStage.deliveryStatus} />
</div>
<div className="grid gap-2 rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600 md:grid-cols-2">
<div>资产编号:{formatTextWithCompactLongNumbers(assetErpTrack?.asset.assetNo ?? currentAsset?.assetNo ?? "-")}</div>
<div>最近变更:{assetErpChangeTypeLabel(assetErpStage.preview.changeType)}</div>
<div>分类:{formatTextWithCompactLongNumbers(assetErpStage.preview.categoryCode ?? "-")}</div>
<div>资产状态:{assetStatusLabel(assetErpStage.preview.status ?? assetErpTrack?.asset.status)}</div>
<div>法体:{assetErpStage.preview.legalEntityCode ?? "-"}</div>
<div>成本中心:{assetErpStage.preview.costCenterCode ?? "-"}</div>
</div>
{formatAssetErpReason(assetErpStage.reason) ? (
<div className="rounded bg-zinc-50 px-3 py-2 text-xs text-zinc-500">{formatAssetErpReason(assetErpStage.reason)}</div>
) : null}
{assetErpStage.outbox?.lastError ? (
<div className="rounded bg-red-50 px-3 py-2 text-xs text-red-600">{assetErpStage.outbox.lastError}</div>
) : null}
{assetErpTrack?.permissions.canSeeExternalRefs && (assetErpStage.binding?.externalCode || assetErpStage.binding?.externalId) ? (
<div className="rounded bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
ERP 资产编号:{assetErpStage.binding?.externalCode ?? "-"}
{assetErpStage.binding?.externalId ? ` · ${assetErpStage.binding.externalId}` : ""}
</div>
) : null}
{assetErpTrack?.permissions.canRetry && assetErpActionLabel ? (
<div className="flex justify-end">
<Button
type="button"
size="sm"
variant="secondary"
disabled={requeueAssetErpMutation.isPending}
onClick={() => requeueAssetErpMutation.mutate()}
>
{requeueAssetErpMutation.isPending ? "处理中..." : assetErpActionLabel}
</Button>
</div>
) : null}
</div>
) : (
<p className="rounded-lg bg-zinc-50 px-3 py-2 text-sm text-zinc-500">暂无 ERP 轨道信息</p>
)}
</div>
</details>
) : null}
{selectedAssetId && !isCreatingAsset && currentAsset ? (
<AiSuggestionCard targetType="asset_card" targetId={currentAsset.id} />
) : null}
<div className="flex items-center gap-2">
{canManage ? (
<Button type="button" data-testid="asset-save" disabled={saveAssetMutation.isPending || !canEditCurrent} onClick={() => saveAssetMutation.mutate()}>
{saveAssetMutation.isPending ? "保存中..." : isCreatingAsset ? "创建资产卡片" : "保存资产卡片"}
</Button>
) : null}
{currentAsset?.purchaseRequest ? (
<span className="text-sm text-zinc-500">
采购来源:{formatTextWithCompactLongNumbers(currentAsset.purchaseRequest.requestNo ?? currentAsset.purchaseRequest.id)}
</span>
) : null}
</div>
</>
) : null}
</div>
{(isCreatingAsset || currentAsset) && !assetDetailQuery.isLoading ? (
<div className="space-y-5">
<SectionPanel title="操作" className="xl:sticky xl:top-24">
<div className="space-y-4">
{canManage ? (
<Button
type="button"
data-testid="asset-save"
disabled={saveAssetMutation.isPending || !canEditCurrent}
onClick={() => saveAssetMutation.mutate()}
>
{saveAssetMutation.isPending ? "保存中..." : isCreatingAsset ? "创建资产卡片" : "保存资产卡片"}
</Button>
) : null}
{currentAsset && canTransfer && !isCreatingAsset && currentAsset.assetType === "fixed_asset" ? (
<div className="space-y-3 border-t border-zinc-200 pt-4">
<div className="text-sm font-semibold text-zinc-900">资产流转</div>
<div className="rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
归属人:{formatEmployeeSummary(currentAsset.assignee)} · 位置:{currentAsset.location ?? "未设置"}
</div>
{movementError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{movementError}</p>
) : null}
<div className="space-y-3">
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">目标归属人</label>
<select
data-testid="asset-movement-assignee"
className="select-std"
value={movementEmployeeId}
onChange={(event) => setMovementEmployeeId(event.target.value)}
>
<option value="">保持当前归属人</option>
{(assigneesQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
{item.name} ({item.employeeNo})
</option>
))}
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">目标位置</label>
<Input
data-testid="asset-movement-location"
value={movementLocation}
onChange={(event) => setMovementLocation(event.target.value)}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">备注</label>
<Input
data-testid="asset-movement-note"
value={movementNote}
onChange={(event) => setMovementNote(event.target.value)}
/>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
size="sm"
data-testid="asset-assign-submit"
disabled={assignAssetMutation.isPending || Boolean(currentAsset.assigneeEmployeeId) || !movementEmployeeId}
onClick={() => assignAssetMutation.mutate()}
>
{assignAssetMutation.isPending ? "领用中..." : "领用"}
</Button>
<Button
type="button"
size="sm"
variant="secondary"
data-testid="asset-return-submit"
disabled={returnAssetMutation.isPending || !currentAsset.assigneeEmployeeId}
onClick={() => returnAssetMutation.mutate()}
>
{returnAssetMutation.isPending ? "归还中..." : "归还"}
</Button>
<Button
type="button"
size="sm"
variant="secondary"
data-testid="asset-transfer-submit"
disabled={
transferAssetMutation.isPending ||
(!movementEmployeeId && movementLocation.trim() === (currentAsset.location ?? ""))
}
onClick={() => transferAssetMutation.mutate()}
>
{transferAssetMutation.isPending ? "调拨中..." : "调拨"}
</Button>
</div>
</div>
) : null}
</div>
</SectionPanel>
<DetailSideMeta title="资产摘要">
<div>状态:{isCreatingAsset ? "新建中" : assetStatusLabel(currentAsset?.status)}</div>
<div>类型:{assetTypeLabel(assetForm.assetType)}</div>
<div>责任人:{formatEmployeeSummary(currentAsset?.owner ?? currentAsset?.assignee)}</div>
<div>归属人:{formatEmployeeSummary(currentAsset?.assignee)}</div>
<div>保管人:{formatEmployeeSummary(currentAsset?.keeper)}</div>
<div>位置:{(currentAsset?.location ?? assetForm.location) || "-"}</div>
<div>
{isElectronicAssetType(assetForm.assetType) ? "最近验证" : "最近盘点"}:
{currentAsset?.lastVerifiedAt ? formatDisplayDateTime(currentAsset.lastVerifiedAt) : assetForm.lastVerifiedAt || "-"}
</div>
<div>
采购来源:
{currentAsset?.purchaseRequest
? formatTextWithCompactLongNumbers(currentAsset.purchaseRequest.requestNo ?? currentAsset.purchaseRequest.id)
: "-"}
</div>
</DetailSideMeta>
</div>
) : null}
</div>
)}
</ListWorkspace>
);
-686
查看文件
@@ -1,686 +0,0 @@
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 { WorkspaceNotice } from "../components/patterns/ListWorkspace";
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: "法体",
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 <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) => {
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<ErpMasterDataResourceType, ErpMasterDataSyncItem>();
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 (
<div className="space-y-6">
<PageHeader
title="外部系统"
subtitle="主数据同步、业务队列和 ID 映射。"
actions={
tab === "master-data" ? (
<Button onClick={() => syncMutation.mutate(undefined)} disabled={syncMutation.isPending}>
{syncMutation.isPending ? "同步中..." : "同步全部"}
</Button>
) : null
}
/>
<WorkspaceNotice title="ERP 写入后置" tone="warning">
试运行期间不把 ERP 写入作为主路径;本页用于管理员查看同步状态和队列排障。
</WorkspaceNotice>
{error ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</p>
) : null}
<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">同步队列</TabsTrigger>
<TabsTrigger value="bindings">ID 映射</TabsTrigger>
</TabsList>
<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>
<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">同步服务 / 主数据端点</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">凭证端点 / 自动同步</div>
<div className="font-medium">
{gate.journalEndpointConfigured ? "凭证端点已配置" : "凭证端点未配置"}
</div>
<div className="text-xs text-zinc-500">{gate.masterDataSyncCron || "未启用自动同步"}</div>
</div>
</div>
) : null}
</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>- 本页只负责主数据同步、业务同步队列和 ID 映射。</p>
<p>- 报销、采购和资产的同步动作在对应业务详情区执行。</p>
<p>- 当前仍使用演示同步服务。</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">同步任务列表</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">
当前筛选下暂无同步任务
</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>同步服务</span>
<select
className="select-std min-w-40"
value={bindingProvider}
onChange={(event) => {
setBindingProvider(event.target.value);
setBindingsPage(1);
}}
>
<option value="">全部同步服务</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>同步服务</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>同步任务详情</DialogTitle>
<DialogDescription>查看数据内容、错误摘要和当前队列状态。</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>队列 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">数据内容</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>
);
}
+2 -3
查看文件
@@ -46,7 +46,6 @@ const ROLLOUT_TYPE_OPTIONS: Array<{ value: FeatureFlagRolloutType; label: string
function toDisplayName(flagKey: string) {
if (flagKey === "ai_assist") return "AI 辅助";
if (flagKey === "erp_sync") return "ERP 同步";
if (flagKey === "invoice_verification") return "发票验真";
if (flagKey === "leave_holiday_calendar") return "请假日历增强";
if (flagKey === "budget_precheck_v1") return "预算预校验";
@@ -259,7 +258,7 @@ export function FeatureFlagsPage() {
return map;
}, [departmentsQuery.data?.items]);
const items = flagsQuery.data?.items ?? [];
const items = (flagsQuery.data?.items ?? []).filter((item) => item.flagKey !== "erp_sync");
const flagStats = {
total: items.length,
effectiveOn: items.filter((item) => item.effectiveEnabled).length,
@@ -325,7 +324,7 @@ export function FeatureFlagsPage() {
<ListWorkspace className={batchMode ? "pb-32" : undefined}>
<WorkspaceHeader
title="功能管理"
subtitle="外部桥接、验真、灰度和试运行边界。"
subtitle="验真、灰度和试运行边界。"
/>
<WorkspaceNotice title="安全摘要" tone="warning">
Env 仍是硬闸;批量开启、关闭或灰度变更会影响试运行边界,执行前必须保留变更说明。
-251
查看文件
@@ -11,19 +11,14 @@ import {
deletePurchaseAttachment,
fetchPurchaseOrderArchive,
fetchPurchaseRequestDetail,
fetchPurchaseRequestErpTrack,
fetchPurchaseRequests,
issuePurchaseAttachmentSignedUrl,
precheckPurchaseRequest,
requeuePurchaseRequestErpSync,
submitPurchaseRequest,
updatePurchaseRequest,
uploadPurchaseAttachment,
withdrawPurchaseRequest,
type PurchaseErpBinding,
type PurchaseRequestDetail,
type PurchaseRequestErpRequeueTarget,
type PurchaseRequestErpTrack,
type PurchaseRequestItemPayload,
type PurchaseRequestStatus,
} from "../api/purchaseRequests";
@@ -265,106 +260,6 @@ function CompactBusinessCode({ value, copyable = true }: { value: string; copyab
);
}
type PurchaseErpStageKey = keyof PurchaseRequestErpTrack["stages"];
const PURCHASE_ERP_STAGE_META: Record<
PurchaseErpStageKey,
{ title: string; target: PurchaseRequestErpRequeueTarget }
> = {
commitmentJournal: { title: "Commitment", target: "commitment_journal" },
accrualJournal: { title: "Accrual", target: "accrual_journal" },
archiveSummary: { title: "PO/合同同步", target: "archive_summary" },
};
function formatStageReason(reason: string | null) {
switch (reason) {
case "purchase_request_not_approved":
return "当前采购单尚未处于已通过状态";
case "purchase_order_archive_missing":
return "尚未完成 PO/合同归档";
default:
return reason ?? null;
}
}
function resolveStageTimestamp(stage: PurchaseRequestErpTrack["stages"][PurchaseErpStageKey]) {
const binding = stage.binding as PurchaseErpBinding | null | undefined;
const poBinding = stage.bindings?.po ?? null;
const contractBinding = stage.bindings?.contract ?? null;
return (
stage.outbox?.updatedAt ??
stage.outbox?.processedAt ??
binding?.lastSyncedAt ??
poBinding?.lastSyncedAt ??
contractBinding?.lastSyncedAt ??
null
);
}
function resolveStageExternalCode(stage: PurchaseRequestErpTrack["stages"][PurchaseErpStageKey]) {
const binding = stage.binding as PurchaseErpBinding | null | undefined;
return binding?.externalCode ?? stage.bindings?.po?.externalCode ?? stage.bindings?.contract?.externalCode ?? null;
}
function resolveStageExternalId(stage: PurchaseRequestErpTrack["stages"][PurchaseErpStageKey]) {
const binding = stage.binding as PurchaseErpBinding | null | undefined;
return binding?.externalId ?? stage.bindings?.po?.externalId ?? stage.bindings?.contract?.externalId ?? null;
}
function resolveStageSummary(stageKey: PurchaseErpStageKey, stage: PurchaseRequestErpTrack["stages"][PurchaseErpStageKey]) {
const preview = stage.preview as {
documentNo?: string | null;
requestNo?: string | null;
poNo?: string | null;
contractNo?: string | null;
totalAmount?: string | null;
supplier?: {
code?: string | null;
};
dimensions?: {
legalEntityCode?: string | null;
costCenterCode?: string | null;
supplierCode?: string | null;
};
};
return {
documentNo:
preview.documentNo ??
preview.requestNo ??
(stageKey === "archiveSummary" ? preview.poNo ?? null : null),
totalAmount: preview.totalAmount ?? null,
legalEntityCode: preview.dimensions?.legalEntityCode ?? null,
costCenterCode: preview.dimensions?.costCenterCode ?? null,
supplierCode: preview.dimensions?.supplierCode ?? preview.supplier?.code ?? null,
poNo: preview.poNo ?? null,
contractNo: preview.contractNo ?? null,
};
}
function resolveStageAction(stageKey: PurchaseErpStageKey, stage: PurchaseRequestErpTrack["stages"][PurchaseErpStageKey]) {
if (!stage.eligible) {
return null;
}
if (stage.status === "failed" || stage.status === "dead_letter") {
return {
label: stageKey === "archiveSummary" ? "重试同步摘要" : "重试同步",
target: PURCHASE_ERP_STAGE_META[stageKey].target,
};
}
if (stage.status === "not_triggered" || stage.status === "skipped") {
return {
label: stageKey === "archiveSummary" ? "补发摘要" : "补发",
target: PURCHASE_ERP_STAGE_META[stageKey].target,
};
}
return null;
}
export function PurchaseRequestsPage() {
const queryClient = useQueryClient();
const { me, permissions } = useAuth();
@@ -372,7 +267,6 @@ export function PurchaseRequestsPage() {
const canCreate = hasPermission(permissions, "procurement.create");
const canManage = hasPermission(permissions, "procurement.manage");
const canIntegrationManage = hasPermission(permissions, "integration.manage");
const canViewAll =
hasPermission(permissions, "procurement.view_all") ||
canManage;
@@ -402,7 +296,6 @@ export function PurchaseRequestsPage() {
const [archiveContractNo, setArchiveContractNo] = useState("");
const [selectedAttachmentName, setSelectedAttachmentName] = useState("");
const [submitPrecheckConfirm, setSubmitPrecheckConfirm] = useState<string | null>(null);
const [erpRequeueConfirm, setErpRequeueConfirm] = useState<{ target: PurchaseRequestErpRequeueTarget; label: string } | null>(null);
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
const suppliersQuery = useQuery({
@@ -456,15 +349,6 @@ export function PurchaseRequestsPage() {
queryFn: () => fetchPurchaseOrderArchive(selectedId),
});
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) {
@@ -687,7 +571,6 @@ export function PurchaseRequestsPage() {
queryClient.invalidateQueries({ queryKey: ["purchase-requests"] }),
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "detail", selectedId] }),
queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }),
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "erp-track", selectedId] }),
]);
setFormError("");
toast.success(
@@ -715,7 +598,6 @@ export function PurchaseRequestsPage() {
queryClient.invalidateQueries({ queryKey: ["purchase-requests"] }),
queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }),
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "archive-order", selectedId] }),
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "erp-track", selectedId] }),
]);
setFormError("");
toast.success("采购申请已撤回到草稿");
@@ -780,7 +662,6 @@ export function PurchaseRequestsPage() {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "detail", selectedId] }),
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "archive-order", selectedId] }),
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "erp-track", selectedId] }),
]);
toast.success("PO/合同归档已保存");
},
@@ -790,39 +671,6 @@ export function PurchaseRequestsPage() {
},
});
const requeueErpMutation = useMutation({
mutationFn: async (payload: { target: PurchaseRequestErpRequeueTarget; label: string }) => {
if (!selectedId) {
throw new Error("请先选择采购单");
}
return {
label: payload.label,
response: await requeuePurchaseRequestErpSync(selectedId, { targets: [payload.target] }),
};
},
onSuccess: async ({ label, response }) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "detail", selectedId] }),
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "archive-order", selectedId] }),
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "erp-track", selectedId] }),
]);
const firstItem = response.items[0];
if (firstItem?.duplicated) {
toast.success(`${label} 已存在待处理记录`);
return;
}
if (firstItem?.requeued) {
toast.success(`${label} 已重新入队`);
return;
}
toast.success(`${label} 已加入同步队列`);
},
onError: (error) => {
const message = resolveError(error);
toast.error(message);
},
});
const uploadMutation = useMutation({
mutationFn: async (file: File) => {
if (!selectedId) {
@@ -916,8 +764,6 @@ export function PurchaseRequestsPage() {
}
};
const erpTrack = erpTrackQuery.data ?? null;
const canRetryErp = Boolean(canIntegrationManage && erpTrack?.permissions.canRetry);
const showArchiveSection = Boolean(detail && (detail.status === "approved" || archiveOrder));
const canEditDraft = Boolean(detail?.permissions.canEditDraft);
const canSubmit = Boolean(detail?.permissions.canSubmit);
@@ -1963,89 +1809,6 @@ export function PurchaseRequestsPage() {
</div>
) : null}
{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">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 ? (
<div className="grid gap-3 xl:grid-cols-3">
{(Object.entries(PURCHASE_ERP_STAGE_META) as Array<
[PurchaseErpStageKey, (typeof PURCHASE_ERP_STAGE_META)[PurchaseErpStageKey]]
>).map(([stageKey, meta]) => {
const stage = erpTrack.stages[stageKey];
const summary = resolveStageSummary(stageKey, stage);
const action = canRetryErp ? resolveStageAction(stageKey, stage) : null;
const stageTime = resolveStageTimestamp(stage);
const externalCode = resolveStageExternalCode(stage);
const externalId = resolveStageExternalId(stage);
const reasonText = formatStageReason(stage.reason);
const isSubmittingAction = requeueErpMutation.isPending && requeueErpMutation.variables?.target === action?.target;
return (
<div key={stageKey} className="space-y-3 rounded-lg border border-zinc-200 p-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-semibold text-zinc-900">{meta.title}</div>
<div className="text-xs text-zinc-500">
{stageTime ? `最近更新时间:${formatDisplayDateTime(stageTime)}` : "尚无同步记录"}
</div>
</div>
<StatusBadge status={stage.status} />
</div>
<div className="space-y-1 text-xs text-zinc-600">
<div>单号:{summary.documentNo ?? "-"}</div>
<div>金额:{summary.totalAmount ?? "-"}</div>
<div>法体 / 成本中心:{summary.legalEntityCode ?? "-"} / {summary.costCenterCode ?? "-"}</div>
<div>供应商:{summary.supplierCode ?? "-"}</div>
{stageKey !== "commitmentJournal" ? (
<div>PO / 合同:{summary.poNo ?? "-"} / {summary.contractNo ?? "-"}</div>
) : null}
</div>
{reasonText ? (
<div className="rounded bg-zinc-50 px-2 py-1 text-xs text-zinc-500">{reasonText}</div>
) : null}
{stage.outbox?.lastError ? (
<div className="rounded bg-red-50 px-2 py-1 text-xs text-red-600">{stage.outbox.lastError}</div>
) : null}
{externalCode || externalId ? (
<div className="rounded bg-zinc-50 px-2 py-1 text-xs text-zinc-600">
ERP 编号:{externalCode ?? "-"}
{externalId ? ` · ${externalId}` : ""}
</div>
) : null}
{action ? (
<div className="flex justify-end">
<Button
type="button"
size="sm"
variant="secondary"
disabled={requeueErpMutation.isPending}
onClick={() => setErpRequeueConfirm({ target: action.target, label: meta.title })}
>
{isSubmittingAction ? "处理中..." : action.label}
</Button>
</div>
) : null}
</div>
);
})}
</div>
) : (
<p className="rounded-lg bg-zinc-50 px-3 py-2 text-sm text-zinc-500">暂无 ERP 轨道信息</p>
)}
</SectionPanel>
) : null}
<SectionPanel title="审批记录" description="展示最近审批动作、退回或驳回说明,便于申请人按意见修改。">
{detailApprovalHistory.length ? (
<div className="space-y-2" data-testid="purchase-approval-history">
@@ -2083,20 +1846,6 @@ export function PurchaseRequestsPage() {
submitMutation.mutate();
}}
/>
<ActionConfirmDialog
open={Boolean(erpRequeueConfirm)}
onOpenChange={(open) => {
if (!open) setErpRequeueConfirm(null);
}}
kind="retry"
targetName={erpRequeueConfirm?.label}
description="确认后会重新补发或重试 ERP 同步消息,并追加新的同步处理记录。"
onConfirm={async () => {
if (erpRequeueConfirm) {
requeueErpMutation.mutate(erpRequeueConfirm);
}
}}
/>
</ListWorkspace>
);
}
@@ -5,7 +5,6 @@ import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { fetchExpenseTypes } from "../api/expenseTypes";
import type { ExpenseStandardCheck, PrecheckResponse } from "../api/feeControl";
import { exportReimbursementJournal, previewReimbursementJournal } from "../api/integrations";
import { fetchProjects } from "../api/projects";
import {
createReimbursementItem,
@@ -108,32 +107,6 @@ function workflowActionLabel(action: string) {
return labels[action] ?? action;
}
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 "未导出";
}
function budgetControlModeLabel(controlMode: string | null | undefined) {
const labels: Record<string, string> = {
block: "超额阻断",
@@ -180,7 +153,6 @@ export function ReimbursementDetailPage() {
const [itemError, setItemError] = useState("");
const [confirmDeleteItemId, setConfirmDeleteItemId] = useState("");
const [confirmDeleteReimbursement, setConfirmDeleteReimbursement] = useState(false);
const [isErpPreviewOpen, setIsErpPreviewOpen] = useState(false);
const [showSecondaryDetail, setShowSecondaryDetail] = useState(false);
const [submitConfirmPrecheck, setSubmitConfirmPrecheck] = useState<PrecheckResponse | null>(null);
const [isInvoiceDialogOpen, setIsInvoiceDialogOpen] = useState(false);
@@ -215,8 +187,6 @@ export function ReimbursementDetailPage() {
queryFn: () => fetchProjects({ mine: true }),
});
const showExternalSyncUi = import.meta.env.VITE_SHOW_ERP_UI === "true";
const canManageIntegration = hasPermission(permissions, "integration.manage") && showExternalSyncUi;
const canSubmitExpense = hasPermission(permissions, "expense.submit");
const canUploadInvoice = hasPermission(permissions, "invoice.upload");
const canViewOwnInvoice = hasPermission(permissions, "invoice.view_own");
@@ -229,12 +199,6 @@ export function ReimbursementDetailPage() {
queryFn: () => precheckReimbursement(reimbursementId),
});
const erpPreviewQuery = useQuery({
enabled: Boolean(reimbursementId) && canManageIntegration && detailQuery.data?.status === "paid",
queryKey: ["reimbursements", reimbursementId, "integration-journal-preview"],
queryFn: () => previewReimbursementJournal(reimbursementId),
});
const bindableInvoicesQuery = useQuery({
enabled:
Boolean(reimbursementId) &&
@@ -461,41 +425,6 @@ export function ReimbursementDetailPage() {
},
});
const erpExportMutation = useMutation({
mutationFn: async () => {
if (!reimbursementId) {
throw new Error("missing reimbursementId");
}
return exportReimbursementJournal(reimbursementId, true);
},
onSuccess: async (result) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId, "integration-journal-preview"] }),
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
]);
setActionError("");
const suffix = result.requeued ? "已重入队" : result.duplicated ? "已复用现有队列" : "已入队";
toast.success(`财务桥接出队${suffix}`);
},
onError: (error) => {
if (isApiError(error)) {
const msg = `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
setActionError(msg);
toast.error(msg);
return;
}
if (error instanceof Error) {
setActionError(error.message);
toast.error(error.message);
return;
}
setActionError("财务桥接出队失败,请稍后重试");
toast.error("财务桥接出队失败,请稍后重试");
},
});
const bindInvoicesMutation = useMutation({
mutationFn: async (invoiceSelections: Array<{ invoiceId: string; linkedItemId?: string }>) => {
if (!reimbursementId) {
@@ -809,8 +738,6 @@ export function ReimbursementDetailPage() {
paidBy: null,
};
const displayCode = resolveReimbursementDisplayCode(detail);
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);
@@ -1732,72 +1659,6 @@ export function ReimbursementDetailPage() {
<RiskFlagsCard items={detail.riskFlags} title="待处理风险项" />
<AiSuggestionCard targetType="reimbursement" targetId={detail.id} />
{canManageIntegration ? (
<SectionPanel
title="财务导出状态"
description="付款后可生成财务导出记录。"
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 ? "导出中..." : "生成导出"}
</Button>
</div>
}
>
{!isPaidReimbursement ? (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-700">
未满足导出条件,仅已付款报销可生成财务导出记录。
</p>
) : null}
{erpPreviewQuery.isLoading ? <p className="text-sm text-zinc-500">正在加载财务凭证预览...</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)
? `${erpPreviewQuery.error.code}: ${mapApiErrorMessage(
erpPreviewQuery.error.code,
erpPreviewQuery.error.details,
erpPreviewQuery.error.message,
)}`
: "加载财务凭证预览失败"}
</p>
) : null}
<div className="detail-fact-grid text-sm xl:grid-cols-4" data-testid="reimbursement-erp-export">
<div className="detail-fact-card">
<div className="detail-fact-card__label">导出状态</div>
<div className="detail-fact-card__value">{erpSyncStatus}</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">最近导出</div>
<div className="detail-fact-card__value">{formatDateTime(erpPreviewQuery.data?.exportState.exportedAt ?? null)}</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">导出任务</div>
<div className="detail-fact-card__value break-all">{erpPreviewQuery.data?.exportState.lastOutboxId ?? "-"}</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">外部凭证号</div>
<div className="detail-fact-card__value break-all">{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}
</SectionPanel>
) : null}
</div>
</SectionPanel>
@@ -2058,41 +1919,6 @@ export function ReimbursementDetailPage() {
</DialogContent>
</Dialog>
<Dialog open={isErpPreviewOpen} onOpenChange={setIsErpPreviewOpen}>
<DialogContent className="max-h-[85vh] max-w-3xl overflow-y-auto">
<DialogHeader>
<DialogTitle>财务凭证预览</DialogTitle>
<DialogDescription>查看凭证内容和最近导出状态。</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>外部凭证号:{erpPreviewQuery.data.exportState.externalId ?? "-"}</div>
<div>任务编号:{erpPreviewQuery.data.exportState.lastOutboxId ?? "-"}</div>
<div>任务状态:{erpSyncStatus}</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">暂无可预览的财务凭证数据。</p>
)}
</DialogContent>
</Dialog>
<Dialog
open={Boolean(submitConfirmPrecheck)}
onOpenChange={(open) => {
+1 -2
查看文件
@@ -26,7 +26,6 @@ const ITEMS: ManagementItem[] = [
{ to: "/system/master-data", icon: "dataset", title: "主数据", description: "法体、成本中心、供应商等基础数据。", group: "功能管理", anyOf: ["masterdata.view", "masterdata.manage"] },
{ to: "/system/notification-channels", icon: "notifications_active", title: "通知渠道", description: "企业微信、邮件等补充通知。", group: "功能管理", anyOf: ["notification.channel.manage"] },
{ to: "/system/feature-flags", icon: "toggle_on", title: "功能管理", description: "功能开关、灰度和试运行边界。", group: "功能管理", anyOf: ["system.settings"] },
{ to: "/system/integrations/erp", icon: "hub", title: "外部系统", description: "财务、ERP 和外部系统连接。", group: "功能管理", anyOf: ["integration.manage"] },
];
const GROUPS: ManagementItem["group"][] = ["人员与组织", "流程与规则", "系统与审计", "功能管理"];
@@ -43,7 +42,7 @@ export function SystemSettingsPage() {
<ListWorkspace>
<WorkspaceHeader
title="管理"
subtitle="人员、组织、角色、审批规则、主数据、通知渠道、外部系统、功能开关和审计统一在这里处理。"
subtitle="人员、组织、角色、审批规则、主数据、通知渠道、功能开关和审计统一在这里处理。"
/>
<div className="finance-status-strip" data-testid="system-settings-summary">
<div className="finance-metric-card">
+5 -3
查看文件
@@ -42,7 +42,6 @@ const MODULE_LABELS: Record<string, string> = {
invoicePool: "票据",
inventoryChecks: "资产盘点",
cutover: "切换控制台",
erpIntegrations: "外部系统",
profile: "个人设置",
};
@@ -83,9 +82,12 @@ export function WorkspaceSetupPage() {
const workspace = workspaceQuery.data;
const rolePresets = navigationQuery.data?.recommendedRolePresets ?? [];
const moduleEntries = useMemo(() => Object.entries(workspace?.moduleVisibility ?? {}), [workspace?.moduleVisibility]);
const moduleEntries = useMemo(
() => Object.entries(workspace?.moduleVisibility ?? {}).filter(([key]) => key !== "erpIntegrations"),
[workspace?.moduleVisibility],
);
const workspaceStats = useMemo(() => {
const entries = Object.entries(workspace?.moduleVisibility ?? {});
const entries = Object.entries(workspace?.moduleVisibility ?? {}).filter(([key]) => key !== "erpIntegrations");
return {
total: entries.length,
main: entries.filter(([, mode]) => mode === "main").length,
-10
查看文件
@@ -22,7 +22,6 @@ import {
EmployeeProfilePage,
DelegationsPage,
DepartmentsPage,
ErpIntegrationPage,
ExpenseStandardsPage,
FeatureFlagsPage,
InventoryChecksPage,
@@ -563,15 +562,6 @@ export function AppRouter() {
}
/>
<Route
path="system/integrations/erp"
element={
<RequirePermission anyOf={["integration.manage"]}>
{withRouteFallback(<ErpIntegrationPage />)}
</RequirePermission>
}
/>
<Route
path="system/workspace"
element={
@@ -1 +0,0 @@
export { ErpIntegrationPage as default } from "../../pages/ErpIntegrationPage";
-1
查看文件
@@ -8,7 +8,6 @@ export const AssetRegistryPage = lazy(() => import("./AssetRegistryPage"));
export const CutoverConsolePage = lazy(() => import("./CutoverConsolePage"));
export const DelegationsPage = lazy(() => import("./DelegationsPage"));
export const DepartmentsPage = lazy(() => import("./DepartmentsPage"));
export const ErpIntegrationPage = lazy(() => import("./ErpIntegrationPage"));
export const ExpenseStandardsPage = lazy(() => import("./ExpenseStandardsPage"));
export const FeatureFlagsPage = lazy(() => import("./FeatureFlagsPage"));
export const InventoryChecksPage = lazy(() => import("./InventoryChecksPage"));