R53 A1-A6:ERP 集成运维可视化与业务同步状态接入
- ErpIntegrationPage Tab 化:主数据同步 / Outbox 队列管理 / ID 映射查看器 - Outbox 分页查询、详情、重试与统计端点 - IntegrationBinding 分页查询、routePath 推导与采购/资产深链 - 报销/采购/资产详情区接入 ERP 同步状态卡片 - 单测覆盖 Outbox 与 Binding 端点
这个提交包含在:
@@ -66,6 +66,99 @@ export type ReimbursementJournalPreview = {
|
||||
};
|
||||
};
|
||||
|
||||
export type IntegrationOutboxStatus =
|
||||
| "pending"
|
||||
| "processing"
|
||||
| "succeeded"
|
||||
| "failed"
|
||||
| "dead_letter";
|
||||
|
||||
export type IntegrationOutboxPayloadSummary = {
|
||||
entityType: string | null;
|
||||
entityId: string | null;
|
||||
sourceEventName: string | null;
|
||||
journalType: string | null;
|
||||
exportType: string | null;
|
||||
identityCode: string | null;
|
||||
};
|
||||
|
||||
export type IntegrationOutboxItem = {
|
||||
id: string;
|
||||
eventType: string;
|
||||
status: IntegrationOutboxStatus | string;
|
||||
payloadSummary: IntegrationOutboxPayloadSummary;
|
||||
attempts: number;
|
||||
maxAttempts: number;
|
||||
nextRetryAt: string | null;
|
||||
createdAt: string | null;
|
||||
processedAt: string | null;
|
||||
deadLetterAt: string | null;
|
||||
};
|
||||
|
||||
export type IntegrationOutboxDetail = IntegrationOutboxItem & {
|
||||
payload: Record<string, unknown> | null;
|
||||
lastError: string | null;
|
||||
};
|
||||
|
||||
export type IntegrationOutboxListResponse = {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
items: IntegrationOutboxItem[];
|
||||
};
|
||||
|
||||
export type IntegrationOutboxStats = {
|
||||
pending: number;
|
||||
processing: number;
|
||||
succeeded: number;
|
||||
failed: number;
|
||||
deadLetter: number;
|
||||
};
|
||||
|
||||
export type IntegrationOutboxQuery = {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
status?: IntegrationOutboxStatus;
|
||||
eventType?: string;
|
||||
};
|
||||
|
||||
export type IntegrationBindingItem = {
|
||||
id: string;
|
||||
provider: string;
|
||||
entityType: string;
|
||||
localId: string;
|
||||
externalId: string | null;
|
||||
externalCode: string | null;
|
||||
lastSyncedAt: string | null;
|
||||
routePath: string | null;
|
||||
};
|
||||
|
||||
export type IntegrationBindingListResponse = {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
items: IntegrationBindingItem[];
|
||||
};
|
||||
|
||||
export type IntegrationBindingQuery = {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
provider?: string;
|
||||
entityType?: string;
|
||||
};
|
||||
|
||||
function toQueryString(query: Record<string, string | number | boolean | undefined>) {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value == null || value === "") {
|
||||
continue;
|
||||
}
|
||||
params.set(key, typeof value === "boolean" ? (value ? "1" : "0") : String(value));
|
||||
}
|
||||
const encoded = params.toString();
|
||||
return encoded ? `?${encoded}` : "";
|
||||
}
|
||||
|
||||
export async function triggerErpSync(reimbursementId: string, runNow = true) {
|
||||
return apiRequestData<{
|
||||
outboxId: string;
|
||||
@@ -107,6 +200,45 @@ export async function exportReimbursementJournal(reimbursementId: string, runNow
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchIntegrationOutbox(query: IntegrationOutboxQuery = {}) {
|
||||
return apiRequestData<IntegrationOutboxListResponse>(
|
||||
`/api/integrations/outbox${toQueryString({
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
status: query.status,
|
||||
event_type: query.eventType,
|
||||
})}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchIntegrationOutboxDetail(outboxId: string) {
|
||||
const response = await apiRequestData<{ item: IntegrationOutboxDetail }>(`/api/integrations/outbox/${outboxId}`);
|
||||
return response.item;
|
||||
}
|
||||
|
||||
export async function retryIntegrationOutbox(outboxId: string) {
|
||||
const response = await apiRequestData<{ item: IntegrationOutboxDetail }>(`/api/integrations/outbox/${outboxId}/retry`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
return response.item;
|
||||
}
|
||||
|
||||
export async function fetchIntegrationOutboxStats() {
|
||||
return apiRequestData<IntegrationOutboxStats>("/api/integrations/outbox/stats");
|
||||
}
|
||||
|
||||
export async function fetchIntegrationBindings(query: IntegrationBindingQuery = {}) {
|
||||
return apiRequestData<IntegrationBindingListResponse>(
|
||||
`/api/integrations/bindings${toQueryString({
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
provider: query.provider,
|
||||
entity_type: query.entityType,
|
||||
})}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function importTravelOrders(payload: {
|
||||
orders: Array<{
|
||||
source: string;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
@@ -31,6 +31,7 @@ import { PageHeader } from "../components/common/PageHeader";
|
||||
import { AiSuggestionCard } from "../components/ai/AiSuggestionCard";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { TableSkeleton } from "../components/common/TableSkeleton";
|
||||
import { SectionPanel } from "../components/patterns/SectionPanel";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||
@@ -114,6 +115,7 @@ function resolveAssetErpActionLabel(actionHint: AssetErpTrack["stage"]["actionHi
|
||||
|
||||
export function AssetRegistryPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const { permissions } = useAuth();
|
||||
|
||||
@@ -133,7 +135,7 @@ export function AssetRegistryPage() {
|
||||
const [assigneeFilter, setAssigneeFilter] = useState("");
|
||||
const [mine, setMine] = useState(!canViewAll);
|
||||
|
||||
const [selectedAssetId, setSelectedAssetId] = useState("");
|
||||
const [selectedAssetId, setSelectedAssetId] = useState(searchParams.get("id") ?? "");
|
||||
const [isCreatingAsset, setIsCreatingAsset] = useState(false);
|
||||
const [assetForm, setAssetForm] = useState(initialAssetForm);
|
||||
const [assetFormError, setAssetFormError] = useState("");
|
||||
@@ -167,7 +169,7 @@ export function AssetRegistryPage() {
|
||||
});
|
||||
|
||||
const assetErpTrackQuery = useQuery({
|
||||
enabled: Boolean(selectedAssetId) && !isCreatingAsset,
|
||||
enabled: canIntegrationManage && Boolean(selectedAssetId) && !isCreatingAsset,
|
||||
queryKey: ["assets", "erp-track", selectedAssetId],
|
||||
queryFn: () => fetchAssetErpTrack(selectedAssetId),
|
||||
});
|
||||
@@ -199,6 +201,13 @@ export function AssetRegistryPage() {
|
||||
queryFn: () => fetchPurchaseRequests({ status: "approved", mine: false, page: 1, pageSize: 100 }),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const searchSelectedId = searchParams.get("id") ?? "";
|
||||
if (searchSelectedId && searchSelectedId !== selectedAssetId) {
|
||||
setSelectedAssetId(searchSelectedId);
|
||||
}
|
||||
}, [searchParams, selectedAssetId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isCreatingAsset) {
|
||||
return;
|
||||
@@ -217,6 +226,32 @@ export function AssetRegistryPage() {
|
||||
}
|
||||
}, [assetDetailQuery.isError, assetDetailQuery.isLoading, assetsQuery.data?.items, isCreatingAsset, selectedAssetId]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentId = searchParams.get("id") ?? "";
|
||||
if (isCreatingAsset) {
|
||||
if (!currentId) {
|
||||
return;
|
||||
}
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.delete("id");
|
||||
setSearchParams(next, { replace: true });
|
||||
return;
|
||||
}
|
||||
if (!selectedAssetId && !currentId) {
|
||||
return;
|
||||
}
|
||||
if (selectedAssetId === currentId) {
|
||||
return;
|
||||
}
|
||||
const next = new URLSearchParams(searchParams);
|
||||
if (selectedAssetId) {
|
||||
next.set("id", selectedAssetId);
|
||||
} else {
|
||||
next.delete("id");
|
||||
}
|
||||
setSearchParams(next, { replace: true });
|
||||
}, [isCreatingAsset, searchParams, selectedAssetId, setSearchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!assetDetailQuery.data?.asset) {
|
||||
return;
|
||||
@@ -937,15 +972,13 @@ export function AssetRegistryPage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selectedAssetId && !isCreatingAsset ? (
|
||||
<div className="space-y-3 rounded-xl border border-zinc-200 p-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-zinc-900">资产 ERP 同步轨道</h4>
|
||||
<p className="text-xs text-zinc-500">仅同步资产法体、成本中心与退役/归档等财务台账摘要,不接管实物流转。</p>
|
||||
</div>
|
||||
|
||||
{selectedAssetId && !isCreatingAsset && canIntegrationManage ? (
|
||||
<SectionPanel
|
||||
title="ERP 同步状态"
|
||||
description="仅同步资产法体、成本中心与退役/归档等财务台账摘要,不接管实物流转。"
|
||||
>
|
||||
{assetErpTrackQuery.isLoading ? (
|
||||
<p className="rounded-lg bg-zinc-50 px-3 py-2 text-sm text-zinc-500">ERP 轨道加载中...</p>
|
||||
<p className="rounded-lg bg-zinc-50 px-3 py-2 text-sm text-zinc-500">ERP 同步轨道加载中...</p>
|
||||
) : assetErpTrackQuery.isError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{resolveError(assetErpTrackQuery.error)}</p>
|
||||
) : assetErpStage ? (
|
||||
@@ -984,7 +1017,7 @@ export function AssetRegistryPage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canIntegrationManage && assetErpTrack?.permissions.canRetry && assetErpActionLabel ? (
|
||||
{assetErpTrack?.permissions.canRetry && assetErpActionLabel ? (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
@@ -1001,7 +1034,7 @@ export function AssetRegistryPage() {
|
||||
) : (
|
||||
<p className="rounded-lg bg-zinc-50 px-3 py-2 text-sm text-zinc-500">暂无 ERP 轨道信息</p>
|
||||
)}
|
||||
</div>
|
||||
</SectionPanel>
|
||||
) : null}
|
||||
|
||||
{selectedAssetId && !isCreatingAsset && currentAsset ? (
|
||||
|
||||
@@ -1,16 +1,37 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
fetchErpMasterDataStatus,
|
||||
fetchIntegrationBindings,
|
||||
fetchIntegrationOutbox,
|
||||
fetchIntegrationOutboxDetail,
|
||||
fetchIntegrationOutboxStats,
|
||||
retryIntegrationOutbox,
|
||||
syncErpMasterData,
|
||||
type ErpMasterDataResourceType,
|
||||
type ErpMasterDataSyncItem,
|
||||
type IntegrationBindingItem,
|
||||
type IntegrationOutboxItem,
|
||||
type IntegrationOutboxStatus,
|
||||
} from "../api/integrations";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { isApiError } from "../api/types";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { Pagination } from "../components/common/Pagination";
|
||||
import { TableSkeleton } from "../components/common/TableSkeleton";
|
||||
import { Button } from "../components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../components/ui/dialog";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs";
|
||||
|
||||
const RESOURCE_LABELS: Record<ErpMasterDataResourceType, string> = {
|
||||
legal_entity: "法体",
|
||||
@@ -18,6 +39,29 @@ const RESOURCE_LABELS: Record<ErpMasterDataResourceType, string> = {
|
||||
supplier: "供应商",
|
||||
};
|
||||
|
||||
const OUTBOX_STATUS_OPTIONS: Array<{ value: IntegrationOutboxStatus | ""; label: string }> = [
|
||||
{ value: "", label: "全部状态" },
|
||||
{ value: "pending", label: "待处理" },
|
||||
{ value: "processing", label: "处理中" },
|
||||
{ value: "succeeded", label: "已成功" },
|
||||
{ value: "failed", label: "失败" },
|
||||
{ value: "dead_letter", label: "死信" },
|
||||
];
|
||||
|
||||
const BINDING_ENTITY_OPTIONS: Array<{ value: string; label: string }> = [
|
||||
{ value: "", label: "全部类型" },
|
||||
{ value: "reimbursement_journal", label: "报销凭证" },
|
||||
{ value: "purchase_request_commitment_journal", label: "采购 Commitment" },
|
||||
{ value: "purchase_order_accrual_journal", label: "采购 Accrual" },
|
||||
{ value: "purchase_order_po", label: "采购 PO 映射" },
|
||||
{ value: "purchase_order_contract", label: "采购合同映射" },
|
||||
{ value: "asset_erp_card", label: "资产台账摘要" },
|
||||
{ value: "travel_payable_summary", label: "差旅应付摘要" },
|
||||
{ value: "travel_settlement_batch", label: "差旅结算批次" },
|
||||
];
|
||||
|
||||
type ErpTab = "master-data" | "outbox" | "bindings";
|
||||
|
||||
function resolveError(error: unknown) {
|
||||
if (isApiError(error)) {
|
||||
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
||||
@@ -33,6 +77,27 @@ function formatDateTime(value: string | null) {
|
||||
return value.slice(0, 19).replace("T", " ");
|
||||
}
|
||||
|
||||
function formatRelativeTime(value: string | null) {
|
||||
if (!value) return "-";
|
||||
const target = new Date(value).getTime();
|
||||
if (!Number.isFinite(target)) return "-";
|
||||
const diffSeconds = Math.round((target - Date.now()) / 1000);
|
||||
const abs = Math.abs(diffSeconds);
|
||||
if (abs < 60) {
|
||||
return diffSeconds >= 0 ? `${abs} 秒后` : `${abs} 秒前`;
|
||||
}
|
||||
const diffMinutes = Math.round(diffSeconds / 60);
|
||||
if (Math.abs(diffMinutes) < 60) {
|
||||
return diffMinutes >= 0 ? `${diffMinutes} 分钟后` : `${Math.abs(diffMinutes)} 分钟前`;
|
||||
}
|
||||
const diffHours = Math.round(diffMinutes / 60);
|
||||
if (Math.abs(diffHours) < 24) {
|
||||
return diffHours >= 0 ? `${diffHours} 小时后` : `${Math.abs(diffHours)} 小时前`;
|
||||
}
|
||||
const diffDays = Math.round(diffHours / 24);
|
||||
return diffDays >= 0 ? `${diffDays} 天后` : `${Math.abs(diffDays)} 天前`;
|
||||
}
|
||||
|
||||
function formatGateReason(reason: string) {
|
||||
if (reason === "env_hard_off") return "Env 总闸关闭";
|
||||
if (reason === "flag_force_off") return "功能开关强制关闭";
|
||||
@@ -42,15 +107,112 @@ function formatGateReason(reason: string) {
|
||||
return reason;
|
||||
}
|
||||
|
||||
function outboxStatusLabel(status: string) {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return "待处理";
|
||||
case "processing":
|
||||
return "处理中";
|
||||
case "succeeded":
|
||||
return "已成功";
|
||||
case "failed":
|
||||
return "失败";
|
||||
case "dead_letter":
|
||||
return "死信";
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
function outboxStatusClassName(status: string) {
|
||||
if (status === "failed" || status === "dead_letter") {
|
||||
return "border-red-200 bg-red-50 text-red-700";
|
||||
}
|
||||
if (status === "succeeded") {
|
||||
return "border-emerald-200 bg-emerald-50 text-emerald-700";
|
||||
}
|
||||
if (status === "processing") {
|
||||
return "border-amber-200 bg-amber-50 text-amber-700";
|
||||
}
|
||||
return "border-zinc-200 bg-zinc-50 text-zinc-700";
|
||||
}
|
||||
|
||||
function renderOutboxSummary(item: IntegrationOutboxItem) {
|
||||
const summary = item.payloadSummary;
|
||||
return summary.identityCode ?? summary.entityId ?? "-";
|
||||
}
|
||||
|
||||
function bindingEntityLabel(value: string) {
|
||||
return BINDING_ENTITY_OPTIONS.find((item) => item.value === value)?.label ?? value;
|
||||
}
|
||||
|
||||
function BindingLocalIdCell({ item }: { item: IntegrationBindingItem }) {
|
||||
if (!item.routePath) {
|
||||
return <span className="break-all text-zinc-600">{item.localId}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link className="break-all text-primary hover:underline" to={item.routePath}>
|
||||
{item.localId}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErpIntegrationPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [tab, setTab] = useState<ErpTab>("master-data");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const [outboxPage, setOutboxPage] = useState(1);
|
||||
const [outboxStatus, setOutboxStatus] = useState<IntegrationOutboxStatus | "">("");
|
||||
const [outboxEventType, setOutboxEventType] = useState("");
|
||||
const [selectedOutboxId, setSelectedOutboxId] = useState("");
|
||||
|
||||
const [bindingsPage, setBindingsPage] = useState(1);
|
||||
const [bindingProvider, setBindingProvider] = useState("");
|
||||
const [bindingEntityType, setBindingEntityType] = useState("");
|
||||
|
||||
const statusQuery = useQuery({
|
||||
queryKey: ["erp-master-data-status"],
|
||||
queryFn: () => fetchErpMasterDataStatus(),
|
||||
});
|
||||
|
||||
const outboxStatsQuery = useQuery({
|
||||
enabled: tab === "outbox",
|
||||
queryKey: ["integration-outbox-stats"],
|
||||
queryFn: () => fetchIntegrationOutboxStats(),
|
||||
});
|
||||
|
||||
const outboxQuery = useQuery({
|
||||
enabled: tab === "outbox",
|
||||
queryKey: ["integration-outbox", outboxPage, outboxStatus, outboxEventType],
|
||||
queryFn: () =>
|
||||
fetchIntegrationOutbox({
|
||||
page: outboxPage,
|
||||
pageSize: 20,
|
||||
status: outboxStatus || undefined,
|
||||
eventType: outboxEventType.trim() || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
const outboxDetailQuery = useQuery({
|
||||
enabled: Boolean(selectedOutboxId),
|
||||
queryKey: ["integration-outbox-detail", selectedOutboxId],
|
||||
queryFn: () => fetchIntegrationOutboxDetail(selectedOutboxId),
|
||||
});
|
||||
|
||||
const bindingsQuery = useQuery({
|
||||
enabled: tab === "bindings",
|
||||
queryKey: ["integration-bindings", bindingsPage, bindingProvider, bindingEntityType],
|
||||
queryFn: () =>
|
||||
fetchIntegrationBindings({
|
||||
page: bindingsPage,
|
||||
pageSize: 20,
|
||||
provider: bindingProvider || undefined,
|
||||
entityType: bindingEntityType || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
const syncMutation = useMutation({
|
||||
mutationFn: async (resources?: ErpMasterDataResourceType[]) => syncErpMasterData({ resources }),
|
||||
onSuccess: async (result) => {
|
||||
@@ -68,6 +230,22 @@ export function ErpIntegrationPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const retryMutation = useMutation({
|
||||
mutationFn: retryIntegrationOutbox,
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["integration-outbox"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["integration-outbox-stats"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["integration-outbox-detail"] }),
|
||||
]);
|
||||
toast.success("Outbox 事件已重置为待处理");
|
||||
},
|
||||
onError: (mutationError) => {
|
||||
const message = resolveError(mutationError);
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const itemsByType = useMemo(() => {
|
||||
const map = new Map<ErpMasterDataResourceType, ErpMasterDataSyncItem>();
|
||||
for (const item of statusQuery.data?.items ?? []) {
|
||||
@@ -77,16 +255,20 @@ export function ErpIntegrationPage() {
|
||||
}, [statusQuery.data?.items]);
|
||||
|
||||
const gate = statusQuery.data?.gate ?? null;
|
||||
const outboxTotalPages = Math.max(1, Math.ceil((outboxQuery.data?.total ?? 0) / 20));
|
||||
const bindingsTotalPages = Math.max(1, Math.ceil((bindingsQuery.data?.total ?? 0) / 20));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="ERP 集成"
|
||||
subtitle="管理 ERP 主数据同步状态。报销付款凭证导出入口保留在报销详情页,不在本页直接手输单号导出。"
|
||||
subtitle="管理主数据同步、Outbox 队列和本地与 ERP 的 ID 映射。报销、采购和资产的具体同步动作仍保留在业务详情区。"
|
||||
actions={
|
||||
<Button onClick={() => syncMutation.mutate(undefined)} disabled={syncMutation.isPending}>
|
||||
{syncMutation.isPending ? "同步中..." : "同步全部"}
|
||||
</Button>
|
||||
tab === "master-data" ? (
|
||||
<Button onClick={() => syncMutation.mutate(undefined)} disabled={syncMutation.isPending}>
|
||||
{syncMutation.isPending ? "同步中..." : "同步全部"}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -94,98 +276,407 @@ export function ErpIntegrationPage() {
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</p>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-4">
|
||||
<div className="card-std p-5 space-y-2 lg:col-span-4">
|
||||
<h3 className="text-base font-semibold text-zinc-900">ERP 状态</h3>
|
||||
{statusQuery.isLoading ? <p className="text-sm text-zinc-500">加载中...</p> : null}
|
||||
{statusQuery.isError ? (
|
||||
<p className="text-sm text-red-600">{resolveError(statusQuery.error)}</p>
|
||||
) : null}
|
||||
{gate ? (
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4 text-sm text-zinc-700">
|
||||
<div>
|
||||
<div className="text-zinc-500">Env 总闸</div>
|
||||
<div className="font-medium">{gate.envEnabled ? "已开启" : "已关闭"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-zinc-500">功能开关</div>
|
||||
<div className="font-medium">{gate.effectiveEnabled ? "允许" : "阻断"}</div>
|
||||
<div className="text-xs text-zinc-500">{formatGateReason(gate.effectiveReason)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-zinc-500">Provider / 主数据端点</div>
|
||||
<div className="font-medium">{gate.provider}</div>
|
||||
<div className="text-xs text-zinc-500">
|
||||
{gate.masterDataEndpointConfigured ? "主数据端点已配置" : "主数据端点未配置"}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-zinc-500">凭证端点 / Cron</div>
|
||||
<div className="font-medium">
|
||||
{gate.journalEndpointConfigured ? "凭证端点已配置" : "凭证端点未配置"}
|
||||
</div>
|
||||
<div className="text-xs text-zinc-500">{gate.masterDataSyncCron || "未启用自动同步"}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<Tabs value={tab} onValueChange={(value) => setTab(value as ErpTab)}>
|
||||
<TabsList className="flex w-full justify-start gap-1">
|
||||
<TabsTrigger value="master-data">主数据同步</TabsTrigger>
|
||||
<TabsTrigger value="outbox">Outbox 队列</TabsTrigger>
|
||||
<TabsTrigger value="bindings">ID 映射</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{(["legal_entity", "cost_center", "supplier"] as const).map((resourceType) => {
|
||||
const item = itemsByType.get(resourceType) ?? null;
|
||||
return (
|
||||
<div key={resourceType} className="card-std p-5 space-y-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-zinc-900">{RESOURCE_LABELS[resourceType]}</h3>
|
||||
<p className="text-sm text-zinc-500">状态:{item?.status ?? "idle"}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => syncMutation.mutate([resourceType])}
|
||||
disabled={syncMutation.isPending}
|
||||
>
|
||||
仅同步{RESOURCE_LABELS[resourceType]}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 text-sm text-zinc-700">
|
||||
<div>最近开始:{formatDateTime(item?.lastStartedAt ?? null)}</div>
|
||||
<div>最近完成:{formatDateTime(item?.lastFinishedAt ?? null)}</div>
|
||||
<div>最近成功:{formatDateTime(item?.lastSuccessAt ?? null)}</div>
|
||||
<div className="break-all">最近错误:{item?.lastError ?? "-"}</div>
|
||||
</div>
|
||||
|
||||
{item?.lastResult ? (
|
||||
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3 text-sm text-zinc-700 space-y-1">
|
||||
<TabsContent value="master-data" className="space-y-6">
|
||||
<div className="grid gap-4 lg:grid-cols-4">
|
||||
<div className="card-std p-5 space-y-2 lg:col-span-4">
|
||||
<h3 className="text-base font-semibold text-zinc-900">ERP 状态</h3>
|
||||
{statusQuery.isLoading ? <p className="text-sm text-zinc-500">加载中...</p> : null}
|
||||
{statusQuery.isError ? (
|
||||
<p className="text-sm text-red-600">{resolveError(statusQuery.error)}</p>
|
||||
) : null}
|
||||
{gate ? (
|
||||
<div className="grid gap-3 text-sm text-zinc-700 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div>
|
||||
请求 {item.lastResult.requested} / 新增 {item.lastResult.created} / 更新 {item.lastResult.updated}
|
||||
<div className="text-zinc-500">Env 总闸</div>
|
||||
<div className="font-medium">{gate.envEnabled ? "已开启" : "已关闭"}</div>
|
||||
</div>
|
||||
<div>
|
||||
归档 {item.lastResult.archived} / 跳过 {item.lastResult.skipped}
|
||||
<div className="text-zinc-500">功能开关</div>
|
||||
<div className="font-medium">{gate.effectiveEnabled ? "允许" : "阻断"}</div>
|
||||
<div className="text-xs text-zinc-500">{formatGateReason(gate.effectiveReason)}</div>
|
||||
</div>
|
||||
{item.lastResult.sampleSkipped.length ? (
|
||||
<div className="text-xs text-zinc-500 break-all">
|
||||
示例跳过:
|
||||
{item.lastResult.sampleSkipped
|
||||
.map((sample) => `${sample.key}:${sample.reason}`)
|
||||
.join(";")}
|
||||
<div>
|
||||
<div className="text-zinc-500">Provider / 主数据端点</div>
|
||||
<div className="font-medium">{gate.provider}</div>
|
||||
<div className="text-xs text-zinc-500">
|
||||
{gate.masterDataEndpointConfigured ? "主数据端点已配置" : "主数据端点未配置"}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-zinc-500">凭证端点 / Cron</div>
|
||||
<div className="font-medium">
|
||||
{gate.journalEndpointConfigured ? "凭证端点已配置" : "凭证端点未配置"}
|
||||
</div>
|
||||
<div className="text-xs text-zinc-500">{gate.masterDataSyncCron || "未启用自动同步"}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-zinc-500">暂无同步记录。</p>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="card-std p-5 space-y-2 text-sm text-zinc-600">
|
||||
<h3 className="text-base font-semibold text-zinc-900">说明</h3>
|
||||
<p>- 本页只负责 ERP 主数据同步,不承担报销凭证的手工单号输入导出。</p>
|
||||
<p>- 报销付款凭证导出请前往已付款报销详情页执行,系统会按标准凭证切片通过 Outbox 异步发送。</p>
|
||||
<p>- 当前阶段不包含采购、资产、盘点的真实 ERP 过账。</p>
|
||||
</div>
|
||||
{(["legal_entity", "cost_center", "supplier"] as const).map((resourceType) => {
|
||||
const item = itemsByType.get(resourceType) ?? null;
|
||||
return (
|
||||
<div key={resourceType} className="card-std space-y-3 p-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-zinc-900">{RESOURCE_LABELS[resourceType]}</h3>
|
||||
<p className="text-sm text-zinc-500">状态:{item?.status ?? "idle"}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => syncMutation.mutate([resourceType])}
|
||||
disabled={syncMutation.isPending}
|
||||
>
|
||||
仅同步{RESOURCE_LABELS[resourceType]}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 text-sm text-zinc-700">
|
||||
<div>最近开始:{formatDateTime(item?.lastStartedAt ?? null)}</div>
|
||||
<div>最近完成:{formatDateTime(item?.lastFinishedAt ?? null)}</div>
|
||||
<div>最近成功:{formatDateTime(item?.lastSuccessAt ?? null)}</div>
|
||||
<div className="break-all">最近错误:{item?.lastError ?? "-"}</div>
|
||||
</div>
|
||||
|
||||
{item?.lastResult ? (
|
||||
<div className="space-y-1 rounded-lg border border-zinc-200 bg-zinc-50 p-3 text-sm text-zinc-700">
|
||||
<div>
|
||||
请求 {item.lastResult.requested} / 新增 {item.lastResult.created} / 更新 {item.lastResult.updated}
|
||||
</div>
|
||||
<div>
|
||||
归档 {item.lastResult.archived} / 跳过 {item.lastResult.skipped}
|
||||
</div>
|
||||
{item.lastResult.sampleSkipped.length ? (
|
||||
<div className="break-all text-xs text-zinc-500">
|
||||
示例跳过:
|
||||
{item.lastResult.sampleSkipped
|
||||
.map((sample) => `${sample.key}:${sample.reason}`)
|
||||
.join(";")}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-zinc-500">暂无同步记录。</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="card-std space-y-2 p-5 text-sm text-zinc-600">
|
||||
<h3 className="text-base font-semibold text-zinc-900">说明</h3>
|
||||
<p>- 本页只负责 ERP 主数据同步、Outbox 队列和 ID 映射,不承担业务单据本身的录入。</p>
|
||||
<p>- 报销、采购和资产的 ERP 同步动作请前往对应业务详情区执行。</p>
|
||||
<p>- 当前阶段不接入真实 ERP Provider,仍以 mock Provider 为准。</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="outbox" className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-5">
|
||||
{OUTBOX_STATUS_OPTIONS.filter((item) => item.value).map((item) => {
|
||||
const status = item.value as IntegrationOutboxStatus;
|
||||
const count =
|
||||
status === "pending"
|
||||
? outboxStatsQuery.data?.pending
|
||||
: status === "processing"
|
||||
? outboxStatsQuery.data?.processing
|
||||
: status === "succeeded"
|
||||
? outboxStatsQuery.data?.succeeded
|
||||
: status === "failed"
|
||||
? outboxStatsQuery.data?.failed
|
||||
: outboxStatsQuery.data?.deadLetter;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={status}
|
||||
className={`card-std space-y-2 p-4 ${status === "failed" || status === "dead_letter" ? "border-red-200 bg-red-50/60" : ""}`}
|
||||
>
|
||||
<div className="text-sm text-zinc-500">{outboxStatusLabel(status)}</div>
|
||||
<div className="text-2xl font-semibold text-zinc-900">
|
||||
{outboxStatsQuery.isLoading ? "-" : count ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="card-std space-y-4 p-5">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-zinc-900">Outbox 事件列表</h3>
|
||||
<p className="text-sm text-zinc-500">支持按状态和事件类型筛选,失败和死信事件可在此重试。</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 md:flex-row">
|
||||
<label className="space-y-1 text-sm text-zinc-600">
|
||||
<span>状态</span>
|
||||
<select
|
||||
className="select-std min-w-40"
|
||||
value={outboxStatus}
|
||||
onChange={(event) => {
|
||||
setOutboxStatus(event.target.value as IntegrationOutboxStatus | "");
|
||||
setOutboxPage(1);
|
||||
}}
|
||||
>
|
||||
{OUTBOX_STATUS_OPTIONS.map((item) => (
|
||||
<option key={item.label} value={item.value}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1 text-sm text-zinc-600">
|
||||
<span>事件类型</span>
|
||||
<Input
|
||||
className="min-w-56"
|
||||
placeholder="例如 erp.journal.export"
|
||||
value={outboxEventType}
|
||||
onChange={(event) => {
|
||||
setOutboxEventType(event.target.value);
|
||||
setOutboxPage(1);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{outboxQuery.isLoading ? <TableSkeleton columns={7} rows={6} /> : null}
|
||||
{outboxQuery.isError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{resolveError(outboxQuery.error)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{outboxQuery.data ? (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>事件类型</TableHead>
|
||||
<TableHead>摘要标识</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>尝试</TableHead>
|
||||
<TableHead>下次重试</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead>处理时间</TableHead>
|
||||
<TableHead className="w-40">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{outboxQuery.data.items.length ? (
|
||||
outboxQuery.data.items.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="font-medium text-zinc-900">{item.eventType}</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="font-medium text-zinc-900">{renderOutboxSummary(item)}</div>
|
||||
<div className="text-xs text-zinc-500">
|
||||
{item.payloadSummary.entityType ?? "-"}
|
||||
{item.payloadSummary.sourceEventName ? ` · ${item.payloadSummary.sourceEventName}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className={`inline-flex rounded-full border px-2 py-1 text-xs font-medium ${outboxStatusClassName(item.status)}`}>
|
||||
{outboxStatusLabel(item.status)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{item.attempts} / {item.maxAttempts}
|
||||
</TableCell>
|
||||
<TableCell>{formatDateTime(item.nextRetryAt)}</TableCell>
|
||||
<TableCell>{formatDateTime(item.createdAt)}</TableCell>
|
||||
<TableCell>{formatDateTime(item.processedAt)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => setSelectedOutboxId(item.id)}
|
||||
>
|
||||
查看详情
|
||||
</Button>
|
||||
{item.status === "failed" || item.status === "dead_letter" ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={retryMutation.isPending}
|
||||
onClick={() => retryMutation.mutate(item.id)}
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="py-6 text-center text-zinc-500">
|
||||
当前筛选下暂无 Outbox 事件
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<Pagination page={outboxPage} totalPages={outboxTotalPages} onPageChange={setOutboxPage} />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="bindings" className="space-y-6">
|
||||
<div className="card-std space-y-4 p-5">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-zinc-900">IntegrationBinding 列表</h3>
|
||||
<p className="text-sm text-zinc-500">查看本地单据与 ERP 外部 ID/编号的映射关系,并支持深链回到业务页。</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 md:flex-row">
|
||||
<label className="space-y-1 text-sm text-zinc-600">
|
||||
<span>Provider</span>
|
||||
<select
|
||||
className="select-std min-w-40"
|
||||
value={bindingProvider}
|
||||
onChange={(event) => {
|
||||
setBindingProvider(event.target.value);
|
||||
setBindingsPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">全部 Provider</option>
|
||||
<option value="erp">erp</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1 text-sm text-zinc-600">
|
||||
<span>实体类型</span>
|
||||
<select
|
||||
className="select-std min-w-56"
|
||||
value={bindingEntityType}
|
||||
onChange={(event) => {
|
||||
setBindingEntityType(event.target.value);
|
||||
setBindingsPage(1);
|
||||
}}
|
||||
>
|
||||
{BINDING_ENTITY_OPTIONS.map((item) => (
|
||||
<option key={item.label} value={item.value}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{bindingsQuery.isLoading ? <TableSkeleton columns={6} rows={6} /> : null}
|
||||
{bindingsQuery.isError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{resolveError(bindingsQuery.error)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{bindingsQuery.data ? (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Provider</TableHead>
|
||||
<TableHead>实体类型</TableHead>
|
||||
<TableHead>本地 ID</TableHead>
|
||||
<TableHead>外部 ID</TableHead>
|
||||
<TableHead>外部编号</TableHead>
|
||||
<TableHead>最后同步</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{bindingsQuery.data.items.length ? (
|
||||
bindingsQuery.data.items.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>{item.provider}</TableCell>
|
||||
<TableCell>{bindingEntityLabel(item.entityType)}</TableCell>
|
||||
<TableCell>
|
||||
<BindingLocalIdCell item={item} />
|
||||
</TableCell>
|
||||
<TableCell className="break-all">{item.externalId ?? "-"}</TableCell>
|
||||
<TableCell className="break-all">{item.externalCode ?? "-"}</TableCell>
|
||||
<TableCell title={formatDateTime(item.lastSyncedAt)}>{formatRelativeTime(item.lastSyncedAt)}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-6 text-center text-zinc-500">
|
||||
当前筛选下暂无 ID 映射记录
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<Pagination page={bindingsPage} totalPages={bindingsTotalPages} onPageChange={setBindingsPage} />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<Dialog
|
||||
open={Boolean(selectedOutboxId)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setSelectedOutboxId("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-h-[85vh] max-w-4xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Outbox 事件详情</DialogTitle>
|
||||
<DialogDescription>查看完整 payload、错误摘要和当前队列状态。</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{outboxDetailQuery.isLoading ? <p className="text-sm text-zinc-500">详情加载中...</p> : null}
|
||||
{outboxDetailQuery.isError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{resolveError(outboxDetailQuery.error)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{outboxDetailQuery.data ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 rounded-lg border border-zinc-200 bg-zinc-50 p-4 text-sm text-zinc-700 md:grid-cols-2">
|
||||
<div>Outbox ID:{outboxDetailQuery.data.id}</div>
|
||||
<div>事件类型:{outboxDetailQuery.data.eventType}</div>
|
||||
<div>状态:{outboxStatusLabel(outboxDetailQuery.data.status)}</div>
|
||||
<div>尝试次数:{outboxDetailQuery.data.attempts} / {outboxDetailQuery.data.maxAttempts}</div>
|
||||
<div>创建时间:{formatDateTime(outboxDetailQuery.data.createdAt)}</div>
|
||||
<div>处理时间:{formatDateTime(outboxDetailQuery.data.processedAt)}</div>
|
||||
<div>下次重试:{formatDateTime(outboxDetailQuery.data.nextRetryAt)}</div>
|
||||
<div>死信时间:{formatDateTime(outboxDetailQuery.data.deadLetterAt)}</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium text-zinc-900">最近错误</div>
|
||||
<div className="rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-600">
|
||||
{outboxDetailQuery.data.lastError ?? "-"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium text-zinc-900">Payload</div>
|
||||
<pre className="max-h-[45vh] overflow-auto rounded-lg border border-zinc-200 bg-zinc-950 p-4 text-xs text-zinc-100">
|
||||
{JSON.stringify(outboxDetailQuery.data.payload ?? null, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { isApiError } from "../api/types";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
@@ -43,6 +44,7 @@ import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||
import { Textarea } from "../components/ui/textarea";
|
||||
import { SectionPanel } from "../components/patterns/SectionPanel";
|
||||
import { buildPrecheckDialogMessage } from "../lib/precheck";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
@@ -200,6 +202,7 @@ function resolveStageAction(stageKey: PurchaseErpStageKey, stage: PurchaseReques
|
||||
export function PurchaseRequestsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { permissions } = useAuth();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const canCreate = hasPermission(permissions, "procurement.create");
|
||||
const canManage = hasPermission(permissions, "procurement.manage");
|
||||
@@ -214,7 +217,7 @@ export function PurchaseRequestsPage() {
|
||||
const [legalEntityFilter, setLegalEntityFilter] = useState("");
|
||||
const [costCenterFilter, setCostCenterFilter] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [selectedId, setSelectedId] = useState("");
|
||||
const [selectedId, setSelectedId] = useState(searchParams.get("id") ?? "");
|
||||
const [hydratedDetailId, setHydratedDetailId] = useState("");
|
||||
const [formError, setFormError] = useState("");
|
||||
|
||||
@@ -278,12 +281,20 @@ export function PurchaseRequestsPage() {
|
||||
|
||||
const erpTrackQuery = useQuery({
|
||||
enabled:
|
||||
canIntegrationManage &&
|
||||
Boolean(selectedId) &&
|
||||
(detailQuery.data?.status === "approved" || Boolean(detailQuery.data?.archiveOrder)),
|
||||
queryKey: ["purchase-requests", "erp-track", selectedId],
|
||||
queryFn: () => fetchPurchaseRequestErpTrack(selectedId),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const searchSelectedId = searchParams.get("id") ?? "";
|
||||
if (searchSelectedId && searchSelectedId !== selectedId) {
|
||||
setSelectedId(searchSelectedId);
|
||||
}
|
||||
}, [searchParams, selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedId) {
|
||||
if (detailQuery.isLoading) {
|
||||
@@ -300,6 +311,23 @@ export function PurchaseRequestsPage() {
|
||||
}
|
||||
}, [detailQuery.isError, detailQuery.isLoading, listQuery.data?.items, selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentId = searchParams.get("id") ?? "";
|
||||
if (!selectedId && !currentId) {
|
||||
return;
|
||||
}
|
||||
if (selectedId === currentId) {
|
||||
return;
|
||||
}
|
||||
const next = new URLSearchParams(searchParams);
|
||||
if (selectedId) {
|
||||
next.set("id", selectedId);
|
||||
} else {
|
||||
next.delete("id");
|
||||
}
|
||||
setSearchParams(next, { replace: true });
|
||||
}, [searchParams, selectedId, setSearchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!detailQuery.data || detailQuery.data.id !== selectedId || hydratedDetailId === detailQuery.data.id) {
|
||||
return;
|
||||
@@ -1240,17 +1268,13 @@ export function PurchaseRequestsPage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showArchiveSection ? (
|
||||
<div className="space-y-3 rounded-lg border border-zinc-200 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="text-base font-semibold text-zinc-900">采购桥接轨道</h4>
|
||||
<p className="text-sm text-zinc-500">展示 Commitment / Accrual / PO/合同摘要三段外部桥接状态,失败后可在此重试。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showArchiveSection && canIntegrationManage ? (
|
||||
<SectionPanel
|
||||
title="ERP 同步状态"
|
||||
description="展示 Commitment / Accrual / PO/合同摘要三段同步状态,失败或未触发时可在此补发或重试。"
|
||||
>
|
||||
{erpTrackQuery.isLoading ? (
|
||||
<p className="rounded-lg bg-zinc-50 px-3 py-2 text-sm text-zinc-500">桥接轨道加载中...</p>
|
||||
<p className="rounded-lg bg-zinc-50 px-3 py-2 text-sm text-zinc-500">ERP 同步轨道加载中...</p>
|
||||
) : erpTrackQuery.isError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{resolveError(erpTrackQuery.error)}</p>
|
||||
) : erpTrack ? (
|
||||
@@ -1297,7 +1321,7 @@ export function PurchaseRequestsPage() {
|
||||
<div className="rounded bg-red-50 px-2 py-1 text-xs text-red-600">{stage.outbox.lastError}</div>
|
||||
) : null}
|
||||
|
||||
{canIntegrationManage && (externalCode || externalId) ? (
|
||||
{externalCode || externalId ? (
|
||||
<div className="rounded bg-zinc-50 px-2 py-1 text-xs text-zinc-600">
|
||||
ERP 编号:{externalCode ?? "-"}
|
||||
{externalId ? ` · ${externalId}` : ""}
|
||||
@@ -1324,7 +1348,7 @@ export function PurchaseRequestsPage() {
|
||||
) : (
|
||||
<p className="rounded-lg bg-zinc-50 px-3 py-2 text-sm text-zinc-500">暂无 ERP 轨道信息</p>
|
||||
)}
|
||||
</div>
|
||||
</SectionPanel>
|
||||
) : null}
|
||||
|
||||
<AiSuggestionCard targetType="purchase_request" targetId={detail.id} />
|
||||
|
||||
@@ -27,6 +27,13 @@ import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { AiSuggestionCard } from "../components/ai/AiSuggestionCard";
|
||||
import { Button } from "../components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../components/ui/dialog";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||
import { DetailSideMeta } from "../components/patterns/DetailSideMeta";
|
||||
import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero";
|
||||
@@ -48,6 +55,32 @@ function formatDateTime(value: string | null) {
|
||||
return value.slice(0, 19).replace("T", " ");
|
||||
}
|
||||
|
||||
function resolveErpSyncStatus(
|
||||
exportState:
|
||||
| {
|
||||
exportedAt: string | null;
|
||||
lastOutboxStatus: string | null;
|
||||
}
|
||||
| null,
|
||||
) {
|
||||
if (!exportState) {
|
||||
return "未同步";
|
||||
}
|
||||
if (exportState.lastOutboxStatus === "pending") {
|
||||
return "已入队";
|
||||
}
|
||||
if (exportState.lastOutboxStatus === "processing") {
|
||||
return "处理中";
|
||||
}
|
||||
if (exportState.lastOutboxStatus === "failed" || exportState.lastOutboxStatus === "dead_letter") {
|
||||
return "已失败";
|
||||
}
|
||||
if (exportState.lastOutboxStatus === "succeeded" || exportState.exportedAt) {
|
||||
return "已成功";
|
||||
}
|
||||
return "未同步";
|
||||
}
|
||||
|
||||
export function ReimbursementDetailPage() {
|
||||
const { reimbursementId = "" } = useParams();
|
||||
const { permissions } = useAuth();
|
||||
@@ -58,6 +91,7 @@ export function ReimbursementDetailPage() {
|
||||
const [itemEditor, setItemEditor] = useState<ItemEditorState>(null);
|
||||
const [itemError, setItemError] = useState("");
|
||||
const [confirmDeleteItemId, setConfirmDeleteItemId] = useState("");
|
||||
const [isErpPreviewOpen, setIsErpPreviewOpen] = useState(false);
|
||||
const [editorInitialValues, setEditorInitialValues] = useState<ItemEditorInitialValues>({
|
||||
expenseTypeId: "",
|
||||
expenseDate: "",
|
||||
@@ -310,6 +344,8 @@ export function ReimbursementDetailPage() {
|
||||
}
|
||||
|
||||
const detail = detailQuery.data;
|
||||
const isPaidReimbursement = detail.status === "paid";
|
||||
const erpSyncStatus = resolveErpSyncStatus(erpPreviewQuery.data?.exportState ?? null);
|
||||
const budgetHint = detail.budgetHint;
|
||||
const canPay = hasPermission(permissions, "expense.finance.pay");
|
||||
const canEditItems = ["draft", "rejected"].includes(detail.status);
|
||||
@@ -634,19 +670,33 @@ export function ReimbursementDetailPage() {
|
||||
<RiskFlagsCard items={detail.riskFlags} title="待处理风险项" />
|
||||
<AiSuggestionCard targetType="reimbursement" targetId={detail.id} />
|
||||
|
||||
{canManageIntegration && detail.status === "paid" ? (
|
||||
<div className="space-y-4 rounded-lg border border-zinc-200 p-4" data-testid="reimbursement-erp-export">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-base font-semibold text-zinc-900">财务桥接</h3>
|
||||
<p className="text-sm text-zinc-500">已付款报销单会按标准凭证切片通过 Outbox 异步发送到财务桥接出口。</p>
|
||||
{canManageIntegration ? (
|
||||
<SectionPanel
|
||||
title="ERP 同步状态"
|
||||
description="已付款报销单会按标准凭证切片通过 Outbox 异步发送到 ERP,同步状态、错误摘要和凭证预览入口统一收口在此。"
|
||||
actions={
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={!isPaidReimbursement || erpPreviewQuery.isLoading || Boolean(erpPreviewQuery.isError) || !erpPreviewQuery.data}
|
||||
onClick={() => setIsErpPreviewOpen(true)}
|
||||
>
|
||||
预览凭证
|
||||
</Button>
|
||||
<Button type="button" disabled={!isPaidReimbursement || erpExportMutation.isPending} onClick={() => erpExportMutation.mutate()}>
|
||||
{erpExportMutation.isPending ? "导出中..." : "导出到 ERP"}
|
||||
</Button>
|
||||
</div>
|
||||
<Button onClick={() => erpExportMutation.mutate()} disabled={erpExportMutation.isPending}>
|
||||
{erpExportMutation.isPending ? "出队中..." : "发送到财务桥接"}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{!isPaidReimbursement ? (
|
||||
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-700">
|
||||
未满足导出条件,仅已付款报销可导出到 ERP。
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{erpPreviewQuery.isLoading ? <p className="text-sm text-zinc-500">正在加载凭证切片预览...</p> : null}
|
||||
{erpPreviewQuery.isLoading ? <p className="text-sm text-zinc-500">正在加载 ERP 凭证预览...</p> : null}
|
||||
{erpPreviewQuery.isError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{isApiError(erpPreviewQuery.error)
|
||||
@@ -655,33 +705,35 @@ export function ReimbursementDetailPage() {
|
||||
erpPreviewQuery.error.details,
|
||||
erpPreviewQuery.error.message,
|
||||
)}`
|
||||
: "加载财务桥接预览失败"}
|
||||
: "加载 ERP 凭证预览失败"}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{erpPreviewQuery.data ? (
|
||||
<div className="grid gap-4 text-sm lg:grid-cols-2">
|
||||
<div className="rounded-lg border border-zinc-200 p-4 space-y-2">
|
||||
<h4 className="font-medium text-zinc-900">凭证切片</h4>
|
||||
<div>类型:{erpPreviewQuery.data.journal.journalType}</div>
|
||||
<div>单号:{erpPreviewQuery.data.journal.documentNo ?? "-"}</div>
|
||||
<div>金额:{erpPreviewQuery.data.journal.totalAmount ?? "-"}</div>
|
||||
<div>法体:{erpPreviewQuery.data.journal.dimensions.legalEntityCode ?? "-"}</div>
|
||||
<div>成本中心:{erpPreviewQuery.data.journal.dimensions.costCenterCode ?? "-"}</div>
|
||||
<div>付款凭证号:{erpPreviewQuery.data.journal.paymentVoucherNo ?? "-"}</div>
|
||||
<div>付款日期:{erpPreviewQuery.data.journal.paymentDate ?? "-"}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-200 p-4 space-y-2">
|
||||
<h4 className="font-medium text-zinc-900">导出状态</h4>
|
||||
<div>外部ID:{erpPreviewQuery.data.exportState.externalId ?? "-"}</div>
|
||||
<div>Outbox:{erpPreviewQuery.data.exportState.lastOutboxId ?? "-"}</div>
|
||||
<div>队列状态:{erpPreviewQuery.data.exportState.lastOutboxStatus ?? "-"}</div>
|
||||
<div>最近导出:{formatDateTime(erpPreviewQuery.data.exportState.exportedAt)}</div>
|
||||
<div className="break-all">最近错误:{erpPreviewQuery.data.exportState.lastError ?? "-"}</div>
|
||||
</div>
|
||||
<div className="grid gap-3 text-sm md:grid-cols-2 xl:grid-cols-4" data-testid="reimbursement-erp-export">
|
||||
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3">
|
||||
<div className="text-xs text-zinc-500">同步状态</div>
|
||||
<div className="mt-1 font-medium text-zinc-900">{erpSyncStatus}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3">
|
||||
<div className="text-xs text-zinc-500">最后同步</div>
|
||||
<div className="mt-1 font-medium text-zinc-900">{formatDateTime(erpPreviewQuery.data?.exportState.exportedAt ?? null)}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3">
|
||||
<div className="text-xs text-zinc-500">Outbox</div>
|
||||
<div className="mt-1 break-all font-medium text-zinc-900">{erpPreviewQuery.data?.exportState.lastOutboxId ?? "-"}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3">
|
||||
<div className="text-xs text-zinc-500">外部 ID</div>
|
||||
<div className="mt-1 break-all font-medium text-zinc-900">{erpPreviewQuery.data?.exportState.externalId ?? "-"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{erpPreviewQuery.data?.exportState.lastError ? (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
最近错误:{erpPreviewQuery.data.exportState.lastError}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</SectionPanel>
|
||||
) : null}
|
||||
</div>
|
||||
</SectionPanel>
|
||||
@@ -741,6 +793,41 @@ export function ReimbursementDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={isErpPreviewOpen} onOpenChange={setIsErpPreviewOpen}>
|
||||
<DialogContent className="max-h-[85vh] max-w-3xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>ERP 凭证预览</DialogTitle>
|
||||
<DialogDescription>展示当前报销单将发送到 ERP 的凭证切片与最近一次导出状态。</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{erpPreviewQuery.data ? (
|
||||
<div className="grid gap-4 text-sm lg:grid-cols-2">
|
||||
<div className="space-y-2 rounded-lg border border-zinc-200 p-4">
|
||||
<h4 className="font-medium text-zinc-900">凭证切片</h4>
|
||||
<div>类型:{erpPreviewQuery.data.journal.journalType}</div>
|
||||
<div>单号:{erpPreviewQuery.data.journal.documentNo ?? "-"}</div>
|
||||
<div>金额:{erpPreviewQuery.data.journal.totalAmount ?? "-"}</div>
|
||||
<div>法体:{erpPreviewQuery.data.journal.dimensions.legalEntityCode ?? "-"}</div>
|
||||
<div>成本中心:{erpPreviewQuery.data.journal.dimensions.costCenterCode ?? "-"}</div>
|
||||
<div>付款凭证号:{erpPreviewQuery.data.journal.paymentVoucherNo ?? "-"}</div>
|
||||
<div>付款日期:{erpPreviewQuery.data.journal.paymentDate ?? "-"}</div>
|
||||
</div>
|
||||
<div className="space-y-2 rounded-lg border border-zinc-200 p-4">
|
||||
<h4 className="font-medium text-zinc-900">导出状态</h4>
|
||||
<div>同步状态:{erpSyncStatus}</div>
|
||||
<div>外部 ID:{erpPreviewQuery.data.exportState.externalId ?? "-"}</div>
|
||||
<div>Outbox:{erpPreviewQuery.data.exportState.lastOutboxId ?? "-"}</div>
|
||||
<div>队列状态:{erpPreviewQuery.data.exportState.lastOutboxStatus ?? "-"}</div>
|
||||
<div>最近导出:{formatDateTime(erpPreviewQuery.data.exportState.exportedAt)}</div>
|
||||
<div className="break-all">最近错误:{erpPreviewQuery.data.exportState.lastError ?? "-"}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-zinc-500">暂无可预览的 ERP 凭证数据。</p>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(confirmDeleteItemId)}
|
||||
onOpenChange={(open) => { if (!open) setConfirmDeleteItemId(""); }}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
|
||||
"A1","R53 执行合同建档与范围冻结","创建并校验 R53 ERP 集成运维可视化与业务同步状态接入执行合同 CSV,冻结范围仅包含 ERP 集成页 Tab 化、Outbox 队列管理、IntegrationBinding 查看器、报销/采购/资产页 ERP 同步状态卡片统一;明确本轮不新增 Prisma 迁移、不修改 erp.js/dispatcher.js/outbox.js、不接真实 ERP 端点。","issues/2026-04-07_r53-erp-integration-dashboard.csv 列完整、Dependencies 为 JSON 数组字符串、Test_Method 可复现;范围仅覆盖 R53 A1-A6,不混入差旅 ERP 新页面、真实 ERP Provider 接入或 ERP 核心派发逻辑改造。","命令:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-04-07_r53-erp-integration-dashboard.csv。检查:1) 对照 AGENTS.md 与 issues/README.md 确认 12 列完整;2) A2-A6 依赖链符合 backend/frontend→reviewer→qa 收口;3) 方案明确采购/资产复用现有页面并采用 ?id= 深链,不新增详情页。","manual","DONE","DONE","DONE","issues/2026-04-07_r53-erp-integration-dashboard.csv","[]","回滚路径:删除本次 CSV 不影响业务。2026-04-07:Issue CSV 已创建并通过 validate_issues_csv.py 校验,范围与依赖已冻结。"
|
||||
"A2","Outbox 队列后端端点、前端 Tab 与单测","在 integrations.js 新增 Outbox 分页、详情、统计与重试端点,并在 ERP 集成页新增 Outbox 队列 Tab,支持筛选、分页、详情弹窗和失败/死信重试。","GET /api/integrations/outbox 支持 page/pageSize/status/event_type;GET /api/integrations/outbox/:id 返回完整 payload;GET /api/integrations/outbox/stats 返回五种状态计数;POST /api/integrations/outbox/:id/retry 仅允许 failed/dead_letter 重置为 pending 并写审计;前端 Tab 支持筛选、详情和重试,成功后刷新列表与统计。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && npx prisma validate && node --check src/routes/integrations.js && npm test -- server/tests/unit/routes/integrations.outbox.test.js && cd ../client && npm run typecheck && npm run build。业务:1) ERP 页 Outbox Tab 支持分页、状态筛选、事件类型筛选;2) failed/dead_letter 行可重试;3) 非法状态重试返回 409;4) 详情弹窗仅在 integration.manage 下可见并展示完整 payload JSON。","manual","DONE","DONE","DONE","server/src/routes/integrations.js|server/tests/unit/routes/integrations.outbox.test.js|client/src/api/integrations.ts|client/src/pages/ErpIntegrationPage.tsx","[""A1""]","回滚路径:移除 Outbox 运维端点和前端 Tab,恢复 ERP 集成页仅主数据同步视图。2026-04-07:Outbox 分页/详情/统计/重试端点与 ERP 页 Outbox Tab 已完成,prisma validate、node --check、integrations.outbox.test.js、client typecheck/build 通过。"
|
||||
"A3","IntegrationBinding 查看器与采购/资产深链","在 integrations.js 新增 Binding 分页查询端点和 routePath 推导,在 ERP 集成页新增 ID 映射 Tab,并为采购页、资产页补 ?id= 深链选中能力。","GET /api/integrations/bindings 支持 page/pageSize/provider/entity_type,返回需求字段与 routePath;routePath 可将报销、采购、资产相关绑定跳转到现有业务页并选中目标记录;ERP 集成页 ID 映射 Tab 支持 provider/entity_type 筛选,local_id 在 routePath 存在时可点击。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && node --check src/routes/integrations.js && npm test -- server/tests/unit/routes/integrations.bindings.test.js && cd ../client && npm run typecheck && npm run build。业务:1) 绑定列表可分页与筛选;2) reimbursement_journal / purchase_request_* / purchase_order_* / asset_erp_card 可正确跳转;3) PurchaseRequestsPage 与 AssetRegistryPage 支持 ?id= 深链选中且不回归原有首项默认行为。","manual","DONE","DONE","DONE","server/src/routes/integrations.js|server/tests/unit/routes/integrations.bindings.test.js|client/src/api/integrations.ts|client/src/pages/ErpIntegrationPage.tsx|client/src/pages/PurchaseRequestsPage.tsx|client/src/pages/AssetRegistryPage.tsx","[""A1""]","回滚路径:移除 Binding 查询端点和 ID 映射 Tab,撤销采购/资产 ?id= 深链选中能力。2026-04-07:Binding 查询、routePath 推导、采购/资产 ?id= 深链选中已完成,integrations.bindings.test.js、client typecheck/build 通过。"
|
||||
"A4","报销、采购、资产详情区 ERP 同步状态卡片统一","在报销详情页、采购列表详情区、资产列表详情区统一接入 SectionPanel 版 ERP 同步状态卡片,复用现有 preview/track/requeue/export API,不新增业务页面。","ReimbursementDetailPage 仅 integration.manage 用户可见 ERP 同步状态卡片,未 paid 时不触发 preview 请求并显示禁用说明,paid 时可预览凭证并导出到 ERP;PurchaseRequestsPage 与 AssetRegistryPage 仅 integration.manage 用户触发 track 查询并渲染 ERP 同步状态卡片,保留现有三阶段或单阶段动作逻辑。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build。业务:1) 报销详情页已付款单据可看到状态摘要、最后同步时间、错误摘要、预览和导出入口;2) 未付款单据不误发 ERP preview 请求;3) 采购页和资产页无 integration.manage 权限用户不再触发 ERP track 请求;4) 有权限用户可继续补发/重试。","manual","DONE","DONE","DONE","client/src/pages/ReimbursementDetailPage.tsx|client/src/pages/PurchaseRequestsPage.tsx|client/src/pages/AssetRegistryPage.tsx","[""A1""]","回滚路径:恢复三处页面原 ERP 区块形态与查询启用条件。2026-04-07:三处业务页 ERP 同步状态卡片已统一为 SectionPanel 形态并完成权限收口,client typecheck/build 通过。"
|
||||
"A5","R53 reviewer 权限与运维边界复核","按 reviewer 清单复核 R53 的权限模型、状态流和敏感信息暴露边界,重点检查 Outbox retry 限制、详情 payload 暴露范围和无权限用户页面副作用。","review 结论覆盖 integration.manage 权限、Outbox retry 只允许 failed/dead_letter→pending、列表摘要不回传完整 payload、业务页 ERP 卡片仅对有权限用户渲染且无额外请求、副作用回滚路径仅需代码回退;发现的问题已修复或在 Notes 留痕。","检查:1) Outbox/Binding 新端点是否统一使用 integration.manage;2) retry 是否未允许 processing/succeeded 跳转;3) Outbox 列表 payloadSummary 是否只含摘要字段;4) 报销/采购/资产页无 integration.manage 时是否不触发 ERP 查询;5) 无 Prisma 迁移,回滚仅需代码回退。若发现问题,修复后重跑相关单测与前端构建。","manual","DONE","DONE","DONE","issues/2026-04-07_r53-erp-integration-dashboard.csv|server/src/routes/integrations.js|client/src/pages/ErpIntegrationPage.tsx|client/src/pages/ReimbursementDetailPage.tsx|client/src/pages/PurchaseRequestsPage.tsx|client/src/pages/AssetRegistryPage.tsx","[""A2"",""A3"",""A4""]","回滚路径:若 reviewer 发现权限或敏感信息问题,先下线对应入口,再回退相关代码。2026-04-07:已完成 reviewer 风格自检,确认 Outbox/Binding 新端点统一使用 integration.manage,retry 仅允许 failed/dead_letter,业务页无权限时不触发 ERP 查询。"
|
||||
"A6","QA、状态回填与提交收口","执行 R53 post-check 与业务回归,回填 CSV 状态并完成提交收口,确保 ERP 集成页新能力和业务页 ERP 卡片不回归。","后端与前端校验全部通过;Outbox Tab、ID 映射 Tab、报销/采购/资产 ERP 卡片、采购/资产 ?id= 深链回归完成;issues/2026-04-07_r53-erp-integration-dashboard.csv 的 Dev_Status、Review1_Status、Regression_Status 按测试结果回填。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && npx prisma validate && node --check src/routes/integrations.js && npm test -- server/tests/unit/routes/integrations.outbox.test.js server/tests/unit/routes/integrations.bindings.test.js && cd ../client && npm run typecheck && npm run build。业务:1) /system/integrations/erp 可在主数据同步、Outbox 队列、ID 映射三个 Tab 间切换;2) Outbox Tab 支持详情和重试;3) ID 映射可跳转到报销、采购、资产目标记录;4) 三个业务页的 ERP 同步状态卡片和权限收口正常。","manual","DONE","DONE","DONE","issues/2026-04-07_r53-erp-integration-dashboard.csv|server/src/routes/integrations.js|client/src/api/integrations.ts|client/src/pages/ErpIntegrationPage.tsx|client/src/pages/ReimbursementDetailPage.tsx|client/src/pages/PurchaseRequestsPage.tsx|client/src/pages/AssetRegistryPage.tsx","[""A5""]","回滚路径:若 QA 发现关键回归,暂停提交并回退对应代码变更。2026-04-07:本地 post-check、git diff --check、两组后端单测、client typecheck/build 通过,R53 状态已回填。"
|
||||
|
@@ -15,6 +15,7 @@ const {
|
||||
normalizeRequestedResources,
|
||||
syncErpMasterData,
|
||||
} = require('../services/integrations/erpMasterData');
|
||||
const { OUTBOX_STATUSES } = require('../services/integrations/outbox');
|
||||
const {
|
||||
buildReimbursementJournalPreview,
|
||||
enqueueReimbursementJournalExport,
|
||||
@@ -57,6 +58,155 @@ const PROCUREMENT_REQUEUE_TARGET_ENUM = z.enum([
|
||||
const PROCUREMENT_REQUEUE_BODY_SCHEMA = z.object({
|
||||
targets: z.array(PROCUREMENT_REQUEUE_TARGET_ENUM).min(1).max(3),
|
||||
});
|
||||
const OUTBOX_STATUS_ENUM = z.enum([
|
||||
OUTBOX_STATUSES.pending,
|
||||
OUTBOX_STATUSES.processing,
|
||||
OUTBOX_STATUSES.succeeded,
|
||||
OUTBOX_STATUSES.failed,
|
||||
OUTBOX_STATUSES.dead_letter,
|
||||
]);
|
||||
const OUTBOX_LIST_QUERY_SCHEMA = z.object({
|
||||
page: z.coerce.number().int().min(1).optional(),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).optional(),
|
||||
status: OUTBOX_STATUS_ENUM.optional(),
|
||||
event_type: z.string().trim().max(64).optional(),
|
||||
});
|
||||
const BINDINGS_LIST_QUERY_SCHEMA = z.object({
|
||||
page: z.coerce.number().int().min(1).optional(),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).optional(),
|
||||
provider: z.string().trim().max(32).optional(),
|
||||
entity_type: z.string().trim().max(64).optional(),
|
||||
});
|
||||
|
||||
const OUTBOX_SUMMARY_SCHEMA = {
|
||||
type: 'object',
|
||||
required: ['entityType', 'entityId', 'sourceEventName', 'journalType', 'exportType', 'identityCode'],
|
||||
properties: {
|
||||
entityType: { anyOf: [{ type: 'string' }, { type: 'null' }] },
|
||||
entityId: { anyOf: [{ type: 'string' }, { type: 'null' }] },
|
||||
sourceEventName: { anyOf: [{ type: 'string' }, { type: 'null' }] },
|
||||
journalType: { anyOf: [{ type: 'string' }, { type: 'null' }] },
|
||||
exportType: { anyOf: [{ type: 'string' }, { type: 'null' }] },
|
||||
identityCode: { anyOf: [{ type: 'string' }, { type: 'null' }] },
|
||||
},
|
||||
};
|
||||
|
||||
const OUTBOX_ITEM_SCHEMA = {
|
||||
type: 'object',
|
||||
required: [
|
||||
'id',
|
||||
'eventType',
|
||||
'status',
|
||||
'payloadSummary',
|
||||
'attempts',
|
||||
'maxAttempts',
|
||||
'nextRetryAt',
|
||||
'createdAt',
|
||||
'processedAt',
|
||||
'deadLetterAt',
|
||||
],
|
||||
properties: {
|
||||
id: { type: 'string', format: 'uuid' },
|
||||
eventType: { type: 'string' },
|
||||
status: { type: 'string' },
|
||||
payloadSummary: OUTBOX_SUMMARY_SCHEMA,
|
||||
attempts: { type: 'integer' },
|
||||
maxAttempts: { type: 'integer' },
|
||||
nextRetryAt: { anyOf: [{ type: 'string', format: 'date-time' }, { type: 'null' }] },
|
||||
createdAt: { type: 'string', format: 'date-time' },
|
||||
processedAt: { anyOf: [{ type: 'string', format: 'date-time' }, { type: 'null' }] },
|
||||
deadLetterAt: { anyOf: [{ type: 'string', format: 'date-time' }, { type: 'null' }] },
|
||||
},
|
||||
};
|
||||
|
||||
const OUTBOX_DETAIL_SCHEMA = {
|
||||
type: 'object',
|
||||
required: ['item'],
|
||||
properties: {
|
||||
item: {
|
||||
allOf: [
|
||||
OUTBOX_ITEM_SCHEMA,
|
||||
{
|
||||
type: 'object',
|
||||
required: ['payload', 'lastError'],
|
||||
properties: {
|
||||
payload: {
|
||||
anyOf: [
|
||||
{ type: 'object', additionalProperties: true },
|
||||
{ type: 'null' },
|
||||
],
|
||||
},
|
||||
lastError: { anyOf: [{ type: 'string' }, { type: 'null' }] },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const OUTBOX_LIST_RESPONSE_SCHEMA = {
|
||||
type: 'object',
|
||||
required: ['page', 'pageSize', 'total', 'items'],
|
||||
properties: {
|
||||
page: { type: 'integer' },
|
||||
pageSize: { type: 'integer' },
|
||||
total: { type: 'integer' },
|
||||
items: {
|
||||
type: 'array',
|
||||
items: OUTBOX_ITEM_SCHEMA,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const OUTBOX_STATS_RESPONSE_SCHEMA = {
|
||||
type: 'object',
|
||||
required: ['pending', 'processing', 'succeeded', 'failed', 'deadLetter'],
|
||||
properties: {
|
||||
pending: { type: 'integer' },
|
||||
processing: { type: 'integer' },
|
||||
succeeded: { type: 'integer' },
|
||||
failed: { type: 'integer' },
|
||||
deadLetter: { type: 'integer' },
|
||||
},
|
||||
};
|
||||
|
||||
const BINDING_ITEM_SCHEMA = {
|
||||
type: 'object',
|
||||
required: [
|
||||
'id',
|
||||
'provider',
|
||||
'entityType',
|
||||
'localId',
|
||||
'externalId',
|
||||
'externalCode',
|
||||
'lastSyncedAt',
|
||||
'routePath',
|
||||
],
|
||||
properties: {
|
||||
id: { type: 'string', format: 'uuid' },
|
||||
provider: { type: 'string' },
|
||||
entityType: { type: 'string' },
|
||||
localId: { type: 'string', format: 'uuid' },
|
||||
externalId: { anyOf: [{ type: 'string' }, { type: 'null' }] },
|
||||
externalCode: { anyOf: [{ type: 'string' }, { type: 'null' }] },
|
||||
lastSyncedAt: { anyOf: [{ type: 'string', format: 'date-time' }, { type: 'null' }] },
|
||||
routePath: { anyOf: [{ type: 'string' }, { type: 'null' }] },
|
||||
},
|
||||
};
|
||||
|
||||
const BINDINGS_LIST_RESPONSE_SCHEMA = {
|
||||
type: 'object',
|
||||
required: ['page', 'pageSize', 'total', 'items'],
|
||||
properties: {
|
||||
page: { type: 'integer' },
|
||||
pageSize: { type: 'integer' },
|
||||
total: { type: 'integer' },
|
||||
items: {
|
||||
type: 'array',
|
||||
items: BINDING_ITEM_SCHEMA,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const ERP_SYNC_RESPONSE_SCHEMA = {
|
||||
type: 'object',
|
||||
@@ -125,6 +275,195 @@ function parseRunNow(query) {
|
||||
);
|
||||
}
|
||||
|
||||
function buildValidationError(details, statusCode = 400) {
|
||||
return new AppError({
|
||||
code: 'VALID_001',
|
||||
message: '参数校验失败',
|
||||
statusCode,
|
||||
details,
|
||||
});
|
||||
}
|
||||
|
||||
function parseOutboxListQuery(query) {
|
||||
const parsed = OUTBOX_LIST_QUERY_SCHEMA.safeParse(query ?? {});
|
||||
if (!parsed.success) {
|
||||
throw buildValidationError(parsed.error.flatten());
|
||||
}
|
||||
|
||||
return {
|
||||
page: parsed.data.page ?? 1,
|
||||
pageSize: parsed.data.pageSize ?? 20,
|
||||
status: parsed.data.status ?? null,
|
||||
eventType: String(parsed.data.event_type ?? '').trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
function parseBindingsListQuery(query) {
|
||||
const parsed = BINDINGS_LIST_QUERY_SCHEMA.safeParse(query ?? {});
|
||||
if (!parsed.success) {
|
||||
throw buildValidationError(parsed.error.flatten());
|
||||
}
|
||||
|
||||
return {
|
||||
page: parsed.data.page ?? 1,
|
||||
pageSize: parsed.data.pageSize ?? 20,
|
||||
provider: String(parsed.data.provider ?? '').trim() || null,
|
||||
entityType: String(parsed.data.entity_type ?? '').trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
function parseUuidParam(value, fieldName) {
|
||||
const parsed = UUID_SCHEMA.safeParse(value);
|
||||
if (!parsed.success) {
|
||||
throw buildValidationError({ [fieldName]: 'invalid_uuid' });
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function toIsoString(value) {
|
||||
return value?.toISOString?.() ?? null;
|
||||
}
|
||||
|
||||
function resolveOutboxIdentityCode(payload) {
|
||||
return (
|
||||
payload?.documentNo ??
|
||||
payload?.formNumber ??
|
||||
payload?.assetNo ??
|
||||
payload?.batchNo ??
|
||||
payload?.poNo ??
|
||||
payload?.contractNo ??
|
||||
payload?.requestNo ??
|
||||
payload?.externalOrderNo ??
|
||||
payload?.references?.poNo ??
|
||||
payload?.references?.contractNo ??
|
||||
payload?.references?.requestNo ??
|
||||
payload?.references?.externalOrderNo ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function buildOutboxPayloadSummary(payload) {
|
||||
return {
|
||||
entityType: typeof payload?.entityType === 'string' ? payload.entityType : null,
|
||||
entityId: typeof payload?.entityId === 'string' ? payload.entityId : null,
|
||||
sourceEventName: typeof payload?.sourceEventName === 'string' ? payload.sourceEventName : null,
|
||||
journalType: typeof payload?.journalType === 'string' ? payload.journalType : null,
|
||||
exportType: typeof payload?.exportType === 'string' ? payload.exportType : null,
|
||||
identityCode: resolveOutboxIdentityCode(payload),
|
||||
};
|
||||
}
|
||||
|
||||
function mapOutboxItem(record) {
|
||||
return {
|
||||
id: record.id,
|
||||
eventType: record.event_type,
|
||||
status: record.status,
|
||||
payloadSummary: buildOutboxPayloadSummary(record.payload ?? null),
|
||||
attempts: Number(record.attempts ?? 0),
|
||||
maxAttempts: Number(record.max_attempts ?? 0),
|
||||
nextRetryAt: toIsoString(record.next_retry_at),
|
||||
createdAt: toIsoString(record.created_at),
|
||||
processedAt: toIsoString(record.processed_at),
|
||||
deadLetterAt: toIsoString(record.dead_letter_at),
|
||||
};
|
||||
}
|
||||
|
||||
function mapOutboxDetail(record) {
|
||||
return {
|
||||
...mapOutboxItem(record),
|
||||
payload: record.payload ?? null,
|
||||
lastError: record.last_error ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function mapIntegrationBindingListItem(record, routePath) {
|
||||
return {
|
||||
id: record.id,
|
||||
provider: record.provider,
|
||||
entityType: record.entity_type,
|
||||
localId: record.local_id,
|
||||
externalId: record.external_id ?? null,
|
||||
externalCode: record.external_code ?? null,
|
||||
lastSyncedAt: toIsoString(record.last_synced_at),
|
||||
routePath: routePath ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveBindingRoutePaths(prisma, rows) {
|
||||
const archiveEntityTypes = new Set([
|
||||
'purchase_order_accrual_journal',
|
||||
'purchase_order_po',
|
||||
'purchase_order_contract',
|
||||
]);
|
||||
const archiveIds = Array.from(
|
||||
new Set(
|
||||
(rows ?? [])
|
||||
.filter((row) => archiveEntityTypes.has(row.entity_type))
|
||||
.map((row) => row.local_id)
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
|
||||
const purchaseRequestByArchiveId = new Map();
|
||||
|
||||
if (archiveIds.length && prisma?.purchaseOrderArchive?.findMany) {
|
||||
const archiveRows = await prisma.purchaseOrderArchive.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: archiveIds,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
purchase_request_id: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const item of archiveRows) {
|
||||
purchaseRequestByArchiveId.set(item.id, item.purchase_request_id);
|
||||
}
|
||||
}
|
||||
|
||||
const routePathByBindingId = new Map();
|
||||
for (const row of rows ?? []) {
|
||||
let routePath = null;
|
||||
|
||||
if (row.entity_type === 'reimbursement_journal') {
|
||||
routePath = `/reimbursements/${row.local_id}`;
|
||||
} else if (row.entity_type === 'purchase_request_commitment_journal') {
|
||||
routePath = `/purchase-requests?id=${row.local_id}`;
|
||||
} else if (archiveEntityTypes.has(row.entity_type)) {
|
||||
const purchaseRequestId = purchaseRequestByArchiveId.get(row.local_id) ?? null;
|
||||
routePath = purchaseRequestId ? `/purchase-requests?id=${purchaseRequestId}` : null;
|
||||
} else if (row.entity_type === 'asset_erp_card') {
|
||||
routePath = `/assets?id=${row.local_id}`;
|
||||
}
|
||||
|
||||
routePathByBindingId.set(row.id, routePath);
|
||||
}
|
||||
|
||||
return routePathByBindingId;
|
||||
}
|
||||
|
||||
async function buildOutboxStats(prisma) {
|
||||
const [pending, processing, succeeded, failed, deadLetter] = await Promise.all([
|
||||
prisma.integrationOutbox.count({ where: { status: OUTBOX_STATUSES.pending } }),
|
||||
prisma.integrationOutbox.count({ where: { status: OUTBOX_STATUSES.processing } }),
|
||||
prisma.integrationOutbox.count({ where: { status: OUTBOX_STATUSES.succeeded } }),
|
||||
prisma.integrationOutbox.count({ where: { status: OUTBOX_STATUSES.failed } }),
|
||||
prisma.integrationOutbox.count({ where: { status: OUTBOX_STATUSES.dead_letter } }),
|
||||
]);
|
||||
|
||||
return {
|
||||
pending,
|
||||
processing,
|
||||
succeeded,
|
||||
failed,
|
||||
deadLetter,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveErpGate(prisma, config) {
|
||||
const evaluation = await evaluateFeatureFlag({
|
||||
prisma,
|
||||
@@ -565,6 +904,253 @@ function normalizeResourcesInput(value) {
|
||||
}
|
||||
|
||||
async function integrationsRoutes(fastify) {
|
||||
fastify.get(
|
||||
'/integrations/outbox/stats',
|
||||
{
|
||||
preHandler: fastify.requirePermission('integration.manage'),
|
||||
schema: {
|
||||
tags: ['integrations'],
|
||||
summary: '查询 ERP Outbox 队列状态统计',
|
||||
security: [{ bearerAuth: [] }],
|
||||
response: {
|
||||
200: buildDataEnvelopeSchema(OUTBOX_STATS_RESPONSE_SCHEMA, 'ERP Outbox 状态统计'),
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const stats = await buildOutboxStats(fastify.prisma);
|
||||
return sendData(reply, request, stats);
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
'/integrations/outbox',
|
||||
{
|
||||
preHandler: fastify.requirePermission('integration.manage'),
|
||||
schema: {
|
||||
tags: ['integrations'],
|
||||
summary: '分页查询 ERP Outbox 事件',
|
||||
security: [{ bearerAuth: [] }],
|
||||
response: {
|
||||
200: buildDataEnvelopeSchema(OUTBOX_LIST_RESPONSE_SCHEMA, 'ERP Outbox 列表'),
|
||||
400: buildErrorEnvelopeSchema({
|
||||
description: '参数校验失败',
|
||||
codeExample: 'VALID_001',
|
||||
messageExample: '参数校验失败',
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const query = parseOutboxListQuery(request.query);
|
||||
const skip = (query.page - 1) * query.pageSize;
|
||||
const where = {};
|
||||
|
||||
if (query.status) {
|
||||
where.status = query.status;
|
||||
}
|
||||
if (query.eventType) {
|
||||
where.event_type = query.eventType;
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
fastify.prisma.integrationOutbox.findMany({
|
||||
where,
|
||||
orderBy: [{ created_at: 'desc' }],
|
||||
skip,
|
||||
take: query.pageSize,
|
||||
}),
|
||||
fastify.prisma.integrationOutbox.count({ where }),
|
||||
]);
|
||||
|
||||
return sendData(reply, request, {
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
total,
|
||||
items: items.map(mapOutboxItem),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
'/integrations/outbox/:id',
|
||||
{
|
||||
preHandler: fastify.requirePermission('integration.manage'),
|
||||
schema: {
|
||||
tags: ['integrations'],
|
||||
summary: '查询单条 ERP Outbox 事件详情',
|
||||
security: [{ bearerAuth: [] }],
|
||||
response: {
|
||||
200: buildDataEnvelopeSchema(OUTBOX_DETAIL_SCHEMA, 'ERP Outbox 详情'),
|
||||
400: buildErrorEnvelopeSchema({
|
||||
description: '参数校验失败',
|
||||
codeExample: 'VALID_001',
|
||||
messageExample: '参数校验失败',
|
||||
}),
|
||||
404: buildErrorEnvelopeSchema({
|
||||
description: 'Outbox 事件不存在',
|
||||
codeExample: 'SYS_404',
|
||||
messageExample: 'Not Found',
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const outboxId = parseUuidParam(request.params?.id, 'id');
|
||||
const item = await fastify.prisma.integrationOutbox.findUnique({
|
||||
where: { id: outboxId },
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
throw new AppError({
|
||||
code: 'SYS_404',
|
||||
message: 'Not Found',
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
return sendData(reply, request, {
|
||||
item: mapOutboxDetail(item),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
'/integrations/outbox/:id/retry',
|
||||
{
|
||||
preHandler: fastify.requirePermission('integration.manage'),
|
||||
schema: {
|
||||
tags: ['integrations'],
|
||||
summary: '重试 failed/dead_letter 的 ERP Outbox 事件',
|
||||
security: [{ bearerAuth: [] }],
|
||||
response: {
|
||||
200: buildDataEnvelopeSchema(OUTBOX_DETAIL_SCHEMA, 'ERP Outbox 已重置为 pending'),
|
||||
400: buildErrorEnvelopeSchema({
|
||||
description: '参数校验失败',
|
||||
codeExample: 'VALID_001',
|
||||
messageExample: '参数校验失败',
|
||||
}),
|
||||
404: buildErrorEnvelopeSchema({
|
||||
description: 'Outbox 事件不存在',
|
||||
codeExample: 'SYS_404',
|
||||
messageExample: 'Not Found',
|
||||
}),
|
||||
409: buildErrorEnvelopeSchema({
|
||||
description: '当前状态不允许重试',
|
||||
codeExample: 'VALID_001',
|
||||
messageExample: '参数校验失败',
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const outboxId = parseUuidParam(request.params?.id, 'id');
|
||||
const existed = await fastify.prisma.integrationOutbox.findUnique({
|
||||
where: { id: outboxId },
|
||||
});
|
||||
|
||||
if (!existed) {
|
||||
throw new AppError({
|
||||
code: 'SYS_404',
|
||||
message: 'Not Found',
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
if (![OUTBOX_STATUSES.failed, OUTBOX_STATUSES.dead_letter].includes(existed.status)) {
|
||||
throw buildValidationError(
|
||||
{
|
||||
outbox: 'integration_outbox_retry_status_invalid',
|
||||
status: existed.status,
|
||||
},
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
const retried = await fastify.prisma.integrationOutbox.update({
|
||||
where: { id: outboxId },
|
||||
data: {
|
||||
status: OUTBOX_STATUSES.pending,
|
||||
attempts: 0,
|
||||
last_error: null,
|
||||
next_retry_at: new Date(),
|
||||
processed_at: null,
|
||||
dead_letter_at: null,
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
});
|
||||
|
||||
await fastify.prisma.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: 'ERP_OUTBOX_RETRIED',
|
||||
targetType: 'integration_outbox',
|
||||
targetId: retried.id,
|
||||
extra: {
|
||||
previousStatus: existed.status,
|
||||
nextStatus: retried.status,
|
||||
eventType: retried.event_type,
|
||||
outboxId: retried.id,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
item: mapOutboxDetail(retried),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
'/integrations/bindings',
|
||||
{
|
||||
preHandler: fastify.requirePermission('integration.manage'),
|
||||
schema: {
|
||||
tags: ['integrations'],
|
||||
summary: '分页查询 ERP IntegrationBinding',
|
||||
security: [{ bearerAuth: [] }],
|
||||
response: {
|
||||
200: buildDataEnvelopeSchema(BINDINGS_LIST_RESPONSE_SCHEMA, 'ERP Binding 列表'),
|
||||
400: buildErrorEnvelopeSchema({
|
||||
description: '参数校验失败',
|
||||
codeExample: 'VALID_001',
|
||||
messageExample: '参数校验失败',
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const query = parseBindingsListQuery(request.query);
|
||||
const skip = (query.page - 1) * query.pageSize;
|
||||
const where = {};
|
||||
|
||||
if (query.provider) {
|
||||
where.provider = query.provider;
|
||||
}
|
||||
if (query.entityType) {
|
||||
where.entity_type = query.entityType;
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
fastify.prisma.integrationBinding.findMany({
|
||||
where,
|
||||
orderBy: [{ last_synced_at: { sort: 'desc', nulls: 'last' } }, { created_at: 'desc' }],
|
||||
skip,
|
||||
take: query.pageSize,
|
||||
}),
|
||||
fastify.prisma.integrationBinding.count({ where }),
|
||||
]);
|
||||
const routePathByBindingId = await resolveBindingRoutePaths(fastify.prisma, items);
|
||||
|
||||
return sendData(reply, request, {
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
total,
|
||||
items: items.map((item) => mapIntegrationBindingListItem(item, routePathByBindingId.get(item.id) ?? null)),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
'/integrations/erp/master-data/status',
|
||||
{
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
const Fastify = require('fastify');
|
||||
|
||||
const integrationsRoutes = require('../../../src/routes/integrations');
|
||||
const { AppError } = require('../../../src/utils/AppError');
|
||||
|
||||
const ACTOR_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
|
||||
|
||||
function buildBinding(overrides = {}) {
|
||||
return {
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
provider: 'erp',
|
||||
entity_type: 'purchase_order_po',
|
||||
local_id: '22222222-2222-4222-8222-222222222222',
|
||||
external_id: 'ERP-PO-001',
|
||||
external_code: 'PO-001',
|
||||
last_synced_at: new Date('2026-04-07T10:00:00.000Z'),
|
||||
created_at: new Date('2026-04-07T09:00:00.000Z'),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildApp(options = {}) {
|
||||
const app = Fastify();
|
||||
const actor = options.actor ?? {
|
||||
id: ACTOR_ID,
|
||||
permissionCodes: ['integration.manage'],
|
||||
};
|
||||
const findMany = options.findMany ?? vi.fn().mockResolvedValue([
|
||||
buildBinding(),
|
||||
buildBinding({
|
||||
id: '33333333-3333-4333-8333-333333333333',
|
||||
entity_type: 'reimbursement_journal',
|
||||
local_id: '44444444-4444-4444-8444-444444444444',
|
||||
external_id: 'ERP-BX-001',
|
||||
external_code: 'BX-001',
|
||||
}),
|
||||
]);
|
||||
const count = options.count ?? vi.fn().mockResolvedValue(2);
|
||||
const archiveFindMany = options.archiveFindMany ?? vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: '22222222-2222-4222-8222-222222222222',
|
||||
purchase_request_id: '55555555-5555-4555-8555-555555555555',
|
||||
},
|
||||
]);
|
||||
|
||||
app.decorate('authenticate', async (request) => {
|
||||
request.employee = actor;
|
||||
});
|
||||
app.decorate('requirePermission', (permissionCode) => async (request) => {
|
||||
request.employee = actor;
|
||||
const permissionCodes = actor.permissionCodes ?? [];
|
||||
if (!permissionCodes.includes('*') && !permissionCodes.includes(permissionCode)) {
|
||||
throw new AppError({ code: 'PERM_001', message: '无权限', statusCode: 403 });
|
||||
}
|
||||
});
|
||||
app.decorate('hasPermission', (employee, permissionCode) => {
|
||||
const permissionCodes = employee.permissionCodes ?? [];
|
||||
return permissionCodes.includes('*') || permissionCodes.includes(permissionCode);
|
||||
});
|
||||
app.decorate('config', {
|
||||
erpSyncEnabled: true,
|
||||
erpSyncProvider: 'mock',
|
||||
erpMasterDataEndpoint: 'http://example.test/master-data',
|
||||
erpJournalExportEndpoint: 'http://example.test/journal',
|
||||
erpSyncEndpoint: 'http://example.test/journal',
|
||||
erpMasterDataSyncCron: '0 * * * *',
|
||||
});
|
||||
app.decorate('prisma', {
|
||||
featureFlag: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
id: 'flag-erp',
|
||||
flag_key: 'erp_sync',
|
||||
override_mode: 'force_on',
|
||||
rollout_type: 'global',
|
||||
department_ids: [],
|
||||
}),
|
||||
},
|
||||
integrationBinding: {
|
||||
findMany,
|
||||
count,
|
||||
},
|
||||
purchaseOrderArchive: {
|
||||
findMany: archiveFindMany,
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
});
|
||||
app.setErrorHandler((error, request, reply) => {
|
||||
reply.status(error.statusCode || 500).send({
|
||||
requestId: request.id,
|
||||
error: {
|
||||
code: error.code || 'SYS_001',
|
||||
message: error.message,
|
||||
details: error.details ?? null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await app.register(integrationsRoutes, { prefix: '/api' });
|
||||
return { app, findMany, count, archiveFindMany };
|
||||
}
|
||||
|
||||
describe('integrations binding routes', () => {
|
||||
it('lists bindings with filters and route paths', async () => {
|
||||
const { app, findMany, count, archiveFindMany } = await buildApp();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/integrations/bindings?page=2&pageSize=10&provider=erp&entity_type=purchase_order_po',
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
provider: 'erp',
|
||||
entity_type: 'purchase_order_po',
|
||||
},
|
||||
orderBy: [{ last_synced_at: { sort: 'desc', nulls: 'last' } }, { created_at: 'desc' }],
|
||||
skip: 10,
|
||||
take: 10,
|
||||
}),
|
||||
);
|
||||
expect(count).toHaveBeenCalledWith({
|
||||
where: {
|
||||
provider: 'erp',
|
||||
entity_type: 'purchase_order_po',
|
||||
},
|
||||
});
|
||||
expect(archiveFindMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: {
|
||||
in: ['22222222-2222-4222-8222-222222222222'],
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
purchase_request_id: true,
|
||||
},
|
||||
});
|
||||
|
||||
const payload = response.json();
|
||||
expect(payload.data.items[0]).toMatchObject({
|
||||
entityType: 'purchase_order_po',
|
||||
localId: '22222222-2222-4222-8222-222222222222',
|
||||
routePath: '/purchase-requests?id=55555555-5555-4555-8555-555555555555',
|
||||
});
|
||||
expect(payload.data.items[1]).toMatchObject({
|
||||
entityType: 'reimbursement_journal',
|
||||
routePath: '/reimbursements/44444444-4444-4444-8444-444444444444',
|
||||
});
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('returns null routePath for unsupported entity types', async () => {
|
||||
const { app } = await buildApp({
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
buildBinding({
|
||||
entity_type: 'travel_payable_summary',
|
||||
local_id: '66666666-6666-4666-8666-666666666666',
|
||||
}),
|
||||
]),
|
||||
count: vi.fn().mockResolvedValue(1),
|
||||
archiveFindMany: vi.fn().mockResolvedValue([]),
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/integrations/bindings',
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.items[0].routePath).toBe(null);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('blocks users without integration.manage', async () => {
|
||||
const { app } = await buildApp({
|
||||
actor: {
|
||||
id: ACTOR_ID,
|
||||
permissionCodes: [],
|
||||
},
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/integrations/bindings',
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
expect(response.json().error.code).toBe('PERM_001');
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,285 @@
|
||||
const Fastify = require('fastify');
|
||||
|
||||
const integrationsRoutes = require('../../../src/routes/integrations');
|
||||
const { AppError } = require('../../../src/utils/AppError');
|
||||
|
||||
const ACTOR_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
|
||||
const OUTBOX_ID = '11111111-1111-4111-8111-111111111111';
|
||||
|
||||
function buildOutbox(overrides = {}) {
|
||||
return {
|
||||
id: OUTBOX_ID,
|
||||
event_type: 'erp.journal.export',
|
||||
status: 'failed',
|
||||
payload: {
|
||||
entityType: 'reimbursement_form',
|
||||
entityId: '22222222-2222-4222-8222-222222222222',
|
||||
sourceEventName: 'reimbursement.paid',
|
||||
journalType: 'reimbursement_paid',
|
||||
documentNo: 'BX-20260407-001',
|
||||
token: 'sanitized-value',
|
||||
},
|
||||
attempts: 2,
|
||||
max_attempts: 5,
|
||||
last_error: 'HTTP 500 | timeout',
|
||||
next_retry_at: new Date('2026-04-07T10:00:00.000Z'),
|
||||
created_at: new Date('2026-04-07T09:00:00.000Z'),
|
||||
processed_at: null,
|
||||
dead_letter_at: null,
|
||||
updated_at: new Date('2026-04-07T09:30:00.000Z'),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildApp(options = {}) {
|
||||
const app = Fastify();
|
||||
const actor = options.actor ?? {
|
||||
id: ACTOR_ID,
|
||||
permissionCodes: ['integration.manage'],
|
||||
};
|
||||
const findMany = options.findMany ?? vi.fn().mockResolvedValue([buildOutbox()]);
|
||||
const findUnique = options.findUnique ?? vi.fn().mockResolvedValue(buildOutbox());
|
||||
const update = options.update ?? vi.fn().mockImplementation(async ({ where, data }) => ({
|
||||
...buildOutbox({ id: where.id }),
|
||||
...data,
|
||||
updated_at: new Date('2026-04-07T10:30:00.000Z'),
|
||||
}));
|
||||
const count = options.count ?? vi.fn().mockImplementation(async ({ where } = {}) => {
|
||||
if (where?.status === 'pending') return 3;
|
||||
if (where?.status === 'processing') return 1;
|
||||
if (where?.status === 'succeeded') return 8;
|
||||
if (where?.status === 'failed') return 2;
|
||||
if (where?.status === 'dead_letter') return 1;
|
||||
return 1;
|
||||
});
|
||||
const auditCreate = options.auditCreate ?? vi.fn().mockResolvedValue({});
|
||||
|
||||
app.decorate('authenticate', async (request) => {
|
||||
request.employee = actor;
|
||||
});
|
||||
app.decorate('requirePermission', (permissionCode) => async (request) => {
|
||||
request.employee = actor;
|
||||
const permissionCodes = actor.permissionCodes ?? [];
|
||||
if (!permissionCodes.includes('*') && !permissionCodes.includes(permissionCode)) {
|
||||
throw new AppError({ code: 'PERM_001', message: '无权限', statusCode: 403 });
|
||||
}
|
||||
});
|
||||
app.decorate('hasPermission', (employee, permissionCode) => {
|
||||
const permissionCodes = employee.permissionCodes ?? [];
|
||||
return permissionCodes.includes('*') || permissionCodes.includes(permissionCode);
|
||||
});
|
||||
app.decorate('config', {
|
||||
erpSyncEnabled: true,
|
||||
erpSyncProvider: 'mock',
|
||||
erpMasterDataEndpoint: 'http://example.test/master-data',
|
||||
erpJournalExportEndpoint: 'http://example.test/journal',
|
||||
erpSyncEndpoint: 'http://example.test/journal',
|
||||
erpMasterDataSyncCron: '0 * * * *',
|
||||
});
|
||||
app.decorate('prisma', {
|
||||
featureFlag: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
id: 'flag-erp',
|
||||
flag_key: 'erp_sync',
|
||||
override_mode: 'force_on',
|
||||
rollout_type: 'global',
|
||||
department_ids: [],
|
||||
}),
|
||||
},
|
||||
integrationOutbox: {
|
||||
findMany,
|
||||
findUnique,
|
||||
count,
|
||||
update,
|
||||
},
|
||||
auditLog: {
|
||||
create: auditCreate,
|
||||
},
|
||||
});
|
||||
app.setErrorHandler((error, request, reply) => {
|
||||
reply.status(error.statusCode || 500).send({
|
||||
requestId: request.id,
|
||||
error: {
|
||||
code: error.code || 'SYS_001',
|
||||
message: error.message,
|
||||
details: error.details ?? null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await app.register(integrationsRoutes, { prefix: '/api' });
|
||||
return { app, findMany, findUnique, count, update, auditCreate };
|
||||
}
|
||||
|
||||
describe('integrations outbox routes', () => {
|
||||
it('lists outbox events with filters and payload summary', async () => {
|
||||
const { app, findMany, count } = await buildApp();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/integrations/outbox?page=2&pageSize=10&status=failed&event_type=erp.journal.export',
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
status: 'failed',
|
||||
event_type: 'erp.journal.export',
|
||||
},
|
||||
orderBy: [{ created_at: 'desc' }],
|
||||
skip: 10,
|
||||
take: 10,
|
||||
}),
|
||||
);
|
||||
expect(count).toHaveBeenCalledWith({
|
||||
where: {
|
||||
status: 'failed',
|
||||
event_type: 'erp.journal.export',
|
||||
},
|
||||
});
|
||||
|
||||
const payload = response.json();
|
||||
expect(payload.data.page).toBe(2);
|
||||
expect(payload.data.items[0]).toMatchObject({
|
||||
id: OUTBOX_ID,
|
||||
eventType: 'erp.journal.export',
|
||||
status: 'failed',
|
||||
payloadSummary: {
|
||||
entityType: 'reimbursement_form',
|
||||
entityId: '22222222-2222-4222-8222-222222222222',
|
||||
sourceEventName: 'reimbursement.paid',
|
||||
journalType: 'reimbursement_paid',
|
||||
exportType: null,
|
||||
identityCode: 'BX-20260407-001',
|
||||
},
|
||||
});
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('returns stats with missing buckets defaulted to zero', async () => {
|
||||
const { app } = await buildApp({
|
||||
count: vi.fn().mockImplementation(async ({ where } = {}) => {
|
||||
if (where?.status === 'succeeded') return 5;
|
||||
return 0;
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/integrations/outbox/stats',
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data).toEqual({
|
||||
pending: 0,
|
||||
processing: 0,
|
||||
succeeded: 5,
|
||||
failed: 0,
|
||||
deadLetter: 0,
|
||||
});
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('returns outbox detail with full payload', async () => {
|
||||
const { app, findUnique } = await buildApp();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/integrations/outbox/${OUTBOX_ID}`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(findUnique).toHaveBeenCalledWith({ where: { id: OUTBOX_ID } });
|
||||
expect(response.json().data.item.payload).toMatchObject({
|
||||
documentNo: 'BX-20260407-001',
|
||||
token: 'sanitized-value',
|
||||
});
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('retries failed or dead-letter outbox events by resetting status to pending', async () => {
|
||||
const { app, update, auditCreate } = await buildApp({
|
||||
findUnique: vi.fn().mockResolvedValue(buildOutbox({ status: 'dead_letter', dead_letter_at: new Date('2026-04-07T09:40:00.000Z') })),
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/integrations/outbox/${OUTBOX_ID}/retry`,
|
||||
payload: {},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: OUTBOX_ID },
|
||||
data: expect.objectContaining({
|
||||
status: 'pending',
|
||||
attempts: 0,
|
||||
last_error: null,
|
||||
next_retry_at: expect.any(Date),
|
||||
processed_at: null,
|
||||
dead_letter_at: null,
|
||||
updated_by: ACTOR_ID,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(auditCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
action: 'ERP_OUTBOX_RETRIED',
|
||||
target_type: 'integration_outbox',
|
||||
target_id: OUTBOX_ID,
|
||||
extra: expect.objectContaining({
|
||||
previousStatus: 'dead_letter',
|
||||
nextStatus: 'pending',
|
||||
eventType: 'erp.journal.export',
|
||||
outboxId: OUTBOX_ID,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('rejects retry for succeeded items with 409', async () => {
|
||||
const { app, update, auditCreate } = await buildApp({
|
||||
findUnique: vi.fn().mockResolvedValue(buildOutbox({ status: 'succeeded', last_error: null })),
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/integrations/outbox/${OUTBOX_ID}/retry`,
|
||||
payload: {},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
expect(auditCreate).not.toHaveBeenCalled();
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('blocks users without integration.manage', async () => {
|
||||
const { app } = await buildApp({
|
||||
actor: {
|
||||
id: ACTOR_ID,
|
||||
permissionCodes: [],
|
||||
},
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/integrations/outbox',
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
expect(response.json().error.code).toBe('PERM_001');
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户