feat(P19ERP1-3,P19ERP1-4): 实现ERP凭证导出与管理入口
这个提交包含在:
@@ -0,0 +1,191 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
fetchErpMasterDataStatus,
|
||||
syncErpMasterData,
|
||||
type ErpMasterDataResourceType,
|
||||
type ErpMasterDataSyncItem,
|
||||
} from "../api/integrations";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { isApiError } from "../api/types";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { Button } from "../components/ui/button";
|
||||
|
||||
const RESOURCE_LABELS: Record<ErpMasterDataResourceType, string> = {
|
||||
legal_entity: "法体",
|
||||
cost_center: "成本中心",
|
||||
supplier: "供应商",
|
||||
};
|
||||
|
||||
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 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;
|
||||
}
|
||||
|
||||
export function ErpIntegrationPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const statusQuery = useQuery({
|
||||
queryKey: ["erp-master-data-status"],
|
||||
queryFn: () => fetchErpMasterDataStatus(),
|
||||
});
|
||||
|
||||
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 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;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="ERP 集成"
|
||||
subtitle="管理 ERP 主数据同步状态。报销付款凭证导出入口保留在报销详情页,不在本页直接手输单号导出。"
|
||||
actions={
|
||||
<Button onClick={() => syncMutation.mutate(undefined)} disabled={syncMutation.isPending}>
|
||||
{syncMutation.isPending ? "同步中..." : "同步全部"}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<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>
|
||||
|
||||
{(["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">
|
||||
<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="text-xs text-zinc-500 break-all">
|
||||
示例跳过:
|
||||
{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 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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户