feat(P19ERP1-3,P19ERP1-4): 实现ERP凭证导出与管理入口
这个提交包含在:
@@ -1,5 +1,71 @@
|
||||
import { apiRequestData } from "./http";
|
||||
|
||||
export type ErpMasterDataResourceType = "legal_entity" | "cost_center" | "supplier";
|
||||
|
||||
export type ErpGateStatus = {
|
||||
envEnabled: boolean;
|
||||
effectiveEnabled: boolean;
|
||||
effectiveReason: string;
|
||||
provider: string;
|
||||
masterDataEndpointConfigured: boolean;
|
||||
journalEndpointConfigured: boolean;
|
||||
masterDataSyncCron: string | null;
|
||||
};
|
||||
|
||||
export type ErpMasterDataSyncSummary = {
|
||||
requested: number;
|
||||
created: number;
|
||||
updated: number;
|
||||
archived: number;
|
||||
skipped: number;
|
||||
sampleSkipped: Array<{ key: string; reason: string }>;
|
||||
};
|
||||
|
||||
export type ErpMasterDataSyncItem = {
|
||||
resourceType: ErpMasterDataResourceType;
|
||||
status: string;
|
||||
lastStartedAt: string | null;
|
||||
lastFinishedAt: string | null;
|
||||
lastSuccessAt: string | null;
|
||||
lastError: string | null;
|
||||
lastResult: ErpMasterDataSyncSummary | null;
|
||||
};
|
||||
|
||||
export type ErpMasterDataStatusResponse = {
|
||||
gate: ErpGateStatus;
|
||||
items: ErpMasterDataSyncItem[];
|
||||
triggerMode?: string;
|
||||
};
|
||||
|
||||
export type ReimbursementJournalPreview = {
|
||||
journal: {
|
||||
journalType: string;
|
||||
sourceEventName: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
documentNo: string | null;
|
||||
currencyCode: string;
|
||||
totalAmount: string | null;
|
||||
occurredAt: string | null;
|
||||
paymentVoucherNo: string | null;
|
||||
paymentDate: string | null;
|
||||
dimensions: {
|
||||
departmentId: string | null;
|
||||
ownerEmployeeId: string | null;
|
||||
submitterEmployeeId: string | null;
|
||||
legalEntityCode: string | null;
|
||||
costCenterCode: string | null;
|
||||
};
|
||||
};
|
||||
exportState: {
|
||||
externalId: string | null;
|
||||
lastOutboxId: string | null;
|
||||
lastOutboxStatus: string | null;
|
||||
lastError: string | null;
|
||||
exportedAt: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export async function triggerErpSync(reimbursementId: string, runNow = true) {
|
||||
return apiRequestData<{
|
||||
outboxId: string;
|
||||
@@ -11,6 +77,36 @@ export async function triggerErpSync(reimbursementId: string, runNow = true) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchErpMasterDataStatus() {
|
||||
return apiRequestData<ErpMasterDataStatusResponse>("/api/integrations/erp/master-data/status");
|
||||
}
|
||||
|
||||
export async function syncErpMasterData(payload: { resources?: ErpMasterDataResourceType[] }) {
|
||||
return apiRequestData<ErpMasterDataStatusResponse>("/api/integrations/erp/master-data/sync", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function previewReimbursementJournal(reimbursementId: string) {
|
||||
return apiRequestData<ReimbursementJournalPreview>(
|
||||
`/api/integrations/erp/journals/reimbursements/${reimbursementId}/preview`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function exportReimbursementJournal(reimbursementId: string, runNow = true) {
|
||||
return apiRequestData<{
|
||||
outboxId: string;
|
||||
status: string;
|
||||
duplicated: boolean;
|
||||
requeued: boolean;
|
||||
eventType: string;
|
||||
}>(`/api/integrations/erp/journals/reimbursements/${reimbursementId}/export?runNow=${runNow ? "1" : "0"}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function importTravelOrders(payload: {
|
||||
orders: Array<{
|
||||
source: string;
|
||||
|
||||
@@ -108,6 +108,12 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
icon: "notifications_active",
|
||||
anyOf: ["notification.channel.manage"],
|
||||
},
|
||||
{
|
||||
to: "/system/integrations/erp",
|
||||
label: "ERP 集成",
|
||||
icon: "hub",
|
||||
anyOf: ["integration.manage"],
|
||||
},
|
||||
{
|
||||
to: "/system/approval-rules",
|
||||
label: "审批规则",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { isApiError } from "../api/types";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { fetchExpenseTypes } from "../api/expenseTypes";
|
||||
import { exportReimbursementJournal, previewReimbursementJournal } from "../api/integrations";
|
||||
import {
|
||||
createReimbursementItem,
|
||||
deleteReimbursementItem,
|
||||
@@ -31,6 +32,11 @@ import {
|
||||
|
||||
type ItemEditorState = { mode: "create" } | { mode: "edit"; itemId: string } | null;
|
||||
|
||||
function formatDateTime(value: string | null) {
|
||||
if (!value) return "-";
|
||||
return value.slice(0, 19).replace("T", " ");
|
||||
}
|
||||
|
||||
export function ReimbursementDetailPage() {
|
||||
const { reimbursementId = "" } = useParams();
|
||||
const { permissions } = useAuth();
|
||||
@@ -59,6 +65,15 @@ export function ReimbursementDetailPage() {
|
||||
queryFn: () => fetchExpenseTypes({ enabled: true }),
|
||||
});
|
||||
|
||||
|
||||
const canManageIntegration = hasPermission(permissions, "integration.manage");
|
||||
|
||||
const erpPreviewQuery = useQuery({
|
||||
enabled: Boolean(reimbursementId) && canManageIntegration && detailQuery.data?.status === "paid",
|
||||
queryKey: ["reimbursements", reimbursementId, "erp-journal-preview"],
|
||||
queryFn: () => previewReimbursementJournal(reimbursementId),
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (params: { action: "submit" } | { action: "withdraw"; comment: string } | { action: "pay"; payload: PayPayload }) => {
|
||||
if (!reimbursementId) {
|
||||
@@ -192,6 +207,41 @@ 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, "erp-journal-preview"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
|
||||
]);
|
||||
setActionError("");
|
||||
const suffix = result.requeued ? "已重入队" : result.duplicated ? "已复用现有队列" : "已入队";
|
||||
toast.success(`ERP 导出${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("ERP 导出失败,请稍后重试");
|
||||
toast.error("ERP 导出失败,请稍后重试");
|
||||
},
|
||||
});
|
||||
|
||||
if (!reimbursementId) {
|
||||
return (
|
||||
<div className="card-std p-6">
|
||||
@@ -312,6 +362,57 @@ export function ReimbursementDetailPage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
{canManageIntegration && detail.status === "paid" ? (
|
||||
<div className="card-std space-y-4 p-6" 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">ERP 导出</h3>
|
||||
<p className="text-sm text-zinc-500">已付款报销单会按标准凭证切片通过 Outbox 异步发送到 ERP。</p>
|
||||
</div>
|
||||
<Button onClick={() => erpExportMutation.mutate()} disabled={erpExportMutation.isPending}>
|
||||
{erpExportMutation.isPending ? "导出中..." : "导出到 ERP"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{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,
|
||||
)}`
|
||||
: "加载 ERP 导出预览失败"}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{erpPreviewQuery.data ? (
|
||||
<div className="grid gap-4 lg:grid-cols-2 text-sm">
|
||||
<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>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="card-std space-y-4 p-6" data-testid="reimbursement-items">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="space-y-1">
|
||||
|
||||
@@ -18,6 +18,7 @@ import { LoginPage } from "../pages/LoginPage";
|
||||
import { LeaveRequestPage } from "../pages/LeaveRequestPage";
|
||||
import { NotificationsPage } from "../pages/NotificationsPage";
|
||||
import { NotificationChannelsPage } from "../pages/NotificationChannelsPage";
|
||||
import { ErpIntegrationPage } from "../pages/ErpIntegrationPage";
|
||||
import { PurchaseRequestsPage } from "../pages/PurchaseRequestsPage";
|
||||
import { AssetRegistryPage } from "../pages/AssetRegistryPage";
|
||||
import { InventoryChecksPage } from "../pages/InventoryChecksPage";
|
||||
@@ -230,6 +231,15 @@ export function AppRouter() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="system/integrations/erp"
|
||||
element={
|
||||
<RequirePermission anyOf={["integration.manage"]}>
|
||||
<ErpIntegrationPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="system/approval-rules"
|
||||
element={
|
||||
|
||||
在新工单中引用
屏蔽一个用户