feat(P19ERP1-3,P19ERP1-4): 实现ERP凭证导出与管理入口
这个提交包含在:
@@ -1,5 +1,71 @@
|
||||
import { apiRequestData } from "./http";
|
||||
|
||||
export type ErpMasterDataResourceType = "legal_entity" | "cost_center" | "supplier";
|
||||
|
||||
export type ErpGateStatus = {
|
||||
envEnabled: boolean;
|
||||
effectiveEnabled: boolean;
|
||||
effectiveReason: string;
|
||||
provider: string;
|
||||
masterDataEndpointConfigured: boolean;
|
||||
journalEndpointConfigured: boolean;
|
||||
masterDataSyncCron: string | null;
|
||||
};
|
||||
|
||||
export type ErpMasterDataSyncSummary = {
|
||||
requested: number;
|
||||
created: number;
|
||||
updated: number;
|
||||
archived: number;
|
||||
skipped: number;
|
||||
sampleSkipped: Array<{ key: string; reason: string }>;
|
||||
};
|
||||
|
||||
export type ErpMasterDataSyncItem = {
|
||||
resourceType: ErpMasterDataResourceType;
|
||||
status: string;
|
||||
lastStartedAt: string | null;
|
||||
lastFinishedAt: string | null;
|
||||
lastSuccessAt: string | null;
|
||||
lastError: string | null;
|
||||
lastResult: ErpMasterDataSyncSummary | null;
|
||||
};
|
||||
|
||||
export type ErpMasterDataStatusResponse = {
|
||||
gate: ErpGateStatus;
|
||||
items: ErpMasterDataSyncItem[];
|
||||
triggerMode?: string;
|
||||
};
|
||||
|
||||
export type ReimbursementJournalPreview = {
|
||||
journal: {
|
||||
journalType: string;
|
||||
sourceEventName: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
documentNo: string | null;
|
||||
currencyCode: string;
|
||||
totalAmount: string | null;
|
||||
occurredAt: string | null;
|
||||
paymentVoucherNo: string | null;
|
||||
paymentDate: string | null;
|
||||
dimensions: {
|
||||
departmentId: string | null;
|
||||
ownerEmployeeId: string | null;
|
||||
submitterEmployeeId: string | null;
|
||||
legalEntityCode: string | null;
|
||||
costCenterCode: string | null;
|
||||
};
|
||||
};
|
||||
exportState: {
|
||||
externalId: string | null;
|
||||
lastOutboxId: string | null;
|
||||
lastOutboxStatus: string | null;
|
||||
lastError: string | null;
|
||||
exportedAt: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export async function triggerErpSync(reimbursementId: string, runNow = true) {
|
||||
return apiRequestData<{
|
||||
outboxId: string;
|
||||
@@ -11,6 +77,36 @@ export async function triggerErpSync(reimbursementId: string, runNow = true) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchErpMasterDataStatus() {
|
||||
return apiRequestData<ErpMasterDataStatusResponse>("/api/integrations/erp/master-data/status");
|
||||
}
|
||||
|
||||
export async function syncErpMasterData(payload: { resources?: ErpMasterDataResourceType[] }) {
|
||||
return apiRequestData<ErpMasterDataStatusResponse>("/api/integrations/erp/master-data/sync", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function previewReimbursementJournal(reimbursementId: string) {
|
||||
return apiRequestData<ReimbursementJournalPreview>(
|
||||
`/api/integrations/erp/journals/reimbursements/${reimbursementId}/preview`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function exportReimbursementJournal(reimbursementId: string, runNow = true) {
|
||||
return apiRequestData<{
|
||||
outboxId: string;
|
||||
status: string;
|
||||
duplicated: boolean;
|
||||
requeued: boolean;
|
||||
eventType: string;
|
||||
}>(`/api/integrations/erp/journals/reimbursements/${reimbursementId}/export?runNow=${runNow ? "1" : "0"}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function importTravelOrders(payload: {
|
||||
orders: Array<{
|
||||
source: string;
|
||||
|
||||
@@ -108,6 +108,12 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
icon: "notifications_active",
|
||||
anyOf: ["notification.channel.manage"],
|
||||
},
|
||||
{
|
||||
to: "/system/integrations/erp",
|
||||
label: "ERP 集成",
|
||||
icon: "hub",
|
||||
anyOf: ["integration.manage"],
|
||||
},
|
||||
{
|
||||
to: "/system/approval-rules",
|
||||
label: "审批规则",
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
fetchErpMasterDataStatus,
|
||||
syncErpMasterData,
|
||||
type ErpMasterDataResourceType,
|
||||
type ErpMasterDataSyncItem,
|
||||
} from "../api/integrations";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { isApiError } from "../api/types";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { Button } from "../components/ui/button";
|
||||
|
||||
const RESOURCE_LABELS: Record<ErpMasterDataResourceType, string> = {
|
||||
legal_entity: "法体",
|
||||
cost_center: "成本中心",
|
||||
supplier: "供应商",
|
||||
};
|
||||
|
||||
function resolveError(error: unknown) {
|
||||
if (isApiError(error)) {
|
||||
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return "请求失败,请稍后重试。";
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null) {
|
||||
if (!value) return "-";
|
||||
return value.slice(0, 19).replace("T", " ");
|
||||
}
|
||||
|
||||
function formatGateReason(reason: string) {
|
||||
if (reason === "env_hard_off") return "Env 总闸关闭";
|
||||
if (reason === "flag_force_off") return "功能开关强制关闭";
|
||||
if (reason === "department_deferred") return "部门灰度在管理端按全局集成动作放行";
|
||||
if (reason === "env_inherit_global_on") return "Env 继承开启";
|
||||
if (reason === "flag_force_on_global") return "功能开关强制开启";
|
||||
return reason;
|
||||
}
|
||||
|
||||
export function ErpIntegrationPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const statusQuery = useQuery({
|
||||
queryKey: ["erp-master-data-status"],
|
||||
queryFn: () => fetchErpMasterDataStatus(),
|
||||
});
|
||||
|
||||
const syncMutation = useMutation({
|
||||
mutationFn: async (resources?: ErpMasterDataResourceType[]) => syncErpMasterData({ resources }),
|
||||
onSuccess: async (result) => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["erp-master-data-status"] });
|
||||
setError("");
|
||||
const finished = result.items
|
||||
.map((item) => `${RESOURCE_LABELS[item.resourceType]}:${item.status}`)
|
||||
.join(" / ");
|
||||
toast.success(`主数据同步完成:${finished}`);
|
||||
},
|
||||
onError: (mutationError) => {
|
||||
const message = resolveError(mutationError);
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const itemsByType = useMemo(() => {
|
||||
const map = new Map<ErpMasterDataResourceType, ErpMasterDataSyncItem>();
|
||||
for (const item of statusQuery.data?.items ?? []) {
|
||||
map.set(item.resourceType, item);
|
||||
}
|
||||
return map;
|
||||
}, [statusQuery.data?.items]);
|
||||
|
||||
const gate = statusQuery.data?.gate ?? null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="ERP 集成"
|
||||
subtitle="管理 ERP 主数据同步状态。报销付款凭证导出入口保留在报销详情页,不在本页直接手输单号导出。"
|
||||
actions={
|
||||
<Button onClick={() => syncMutation.mutate(undefined)} disabled={syncMutation.isPending}>
|
||||
{syncMutation.isPending ? "同步中..." : "同步全部"}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</p>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-4">
|
||||
<div className="card-std p-5 space-y-2 lg:col-span-4">
|
||||
<h3 className="text-base font-semibold text-zinc-900">ERP 状态</h3>
|
||||
{statusQuery.isLoading ? <p className="text-sm text-zinc-500">加载中...</p> : null}
|
||||
{statusQuery.isError ? (
|
||||
<p className="text-sm text-red-600">{resolveError(statusQuery.error)}</p>
|
||||
) : null}
|
||||
{gate ? (
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4 text-sm text-zinc-700">
|
||||
<div>
|
||||
<div className="text-zinc-500">Env 总闸</div>
|
||||
<div className="font-medium">{gate.envEnabled ? "已开启" : "已关闭"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-zinc-500">功能开关</div>
|
||||
<div className="font-medium">{gate.effectiveEnabled ? "允许" : "阻断"}</div>
|
||||
<div className="text-xs text-zinc-500">{formatGateReason(gate.effectiveReason)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-zinc-500">Provider / 主数据端点</div>
|
||||
<div className="font-medium">{gate.provider}</div>
|
||||
<div className="text-xs text-zinc-500">
|
||||
{gate.masterDataEndpointConfigured ? "主数据端点已配置" : "主数据端点未配置"}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-zinc-500">凭证端点 / Cron</div>
|
||||
<div className="font-medium">
|
||||
{gate.journalEndpointConfigured ? "凭证端点已配置" : "凭证端点未配置"}
|
||||
</div>
|
||||
<div className="text-xs text-zinc-500">{gate.masterDataSyncCron || "未启用自动同步"}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{(["legal_entity", "cost_center", "supplier"] as const).map((resourceType) => {
|
||||
const item = itemsByType.get(resourceType) ?? null;
|
||||
return (
|
||||
<div key={resourceType} className="card-std p-5 space-y-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-zinc-900">{RESOURCE_LABELS[resourceType]}</h3>
|
||||
<p className="text-sm text-zinc-500">状态:{item?.status ?? "idle"}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => syncMutation.mutate([resourceType])}
|
||||
disabled={syncMutation.isPending}
|
||||
>
|
||||
仅同步{RESOURCE_LABELS[resourceType]}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 text-sm text-zinc-700">
|
||||
<div>最近开始:{formatDateTime(item?.lastStartedAt ?? null)}</div>
|
||||
<div>最近完成:{formatDateTime(item?.lastFinishedAt ?? null)}</div>
|
||||
<div>最近成功:{formatDateTime(item?.lastSuccessAt ?? null)}</div>
|
||||
<div className="break-all">最近错误:{item?.lastError ?? "-"}</div>
|
||||
</div>
|
||||
|
||||
{item?.lastResult ? (
|
||||
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3 text-sm text-zinc-700 space-y-1">
|
||||
<div>
|
||||
请求 {item.lastResult.requested} / 新增 {item.lastResult.created} / 更新 {item.lastResult.updated}
|
||||
</div>
|
||||
<div>
|
||||
归档 {item.lastResult.archived} / 跳过 {item.lastResult.skipped}
|
||||
</div>
|
||||
{item.lastResult.sampleSkipped.length ? (
|
||||
<div className="text-xs text-zinc-500 break-all">
|
||||
示例跳过:
|
||||
{item.lastResult.sampleSkipped
|
||||
.map((sample) => `${sample.key}:${sample.reason}`)
|
||||
.join(";")}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-zinc-500">暂无同步记录。</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="card-std p-5 space-y-2 text-sm text-zinc-600">
|
||||
<h3 className="text-base font-semibold text-zinc-900">说明</h3>
|
||||
<p>- 本页只负责 ERP 主数据同步,不承担报销凭证的手工单号输入导出。</p>
|
||||
<p>- 报销付款凭证导出请前往已付款报销详情页执行,系统会按标准凭证切片通过 Outbox 异步发送。</p>
|
||||
<p>- 当前阶段不包含采购、资产、盘点的真实 ERP 过账。</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { isApiError } from "../api/types";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { fetchExpenseTypes } from "../api/expenseTypes";
|
||||
import { exportReimbursementJournal, previewReimbursementJournal } from "../api/integrations";
|
||||
import {
|
||||
createReimbursementItem,
|
||||
deleteReimbursementItem,
|
||||
@@ -31,6 +32,11 @@ import {
|
||||
|
||||
type ItemEditorState = { mode: "create" } | { mode: "edit"; itemId: string } | null;
|
||||
|
||||
function formatDateTime(value: string | null) {
|
||||
if (!value) return "-";
|
||||
return value.slice(0, 19).replace("T", " ");
|
||||
}
|
||||
|
||||
export function ReimbursementDetailPage() {
|
||||
const { reimbursementId = "" } = useParams();
|
||||
const { permissions } = useAuth();
|
||||
@@ -59,6 +65,15 @@ export function ReimbursementDetailPage() {
|
||||
queryFn: () => fetchExpenseTypes({ enabled: true }),
|
||||
});
|
||||
|
||||
|
||||
const canManageIntegration = hasPermission(permissions, "integration.manage");
|
||||
|
||||
const erpPreviewQuery = useQuery({
|
||||
enabled: Boolean(reimbursementId) && canManageIntegration && detailQuery.data?.status === "paid",
|
||||
queryKey: ["reimbursements", reimbursementId, "erp-journal-preview"],
|
||||
queryFn: () => previewReimbursementJournal(reimbursementId),
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (params: { action: "submit" } | { action: "withdraw"; comment: string } | { action: "pay"; payload: PayPayload }) => {
|
||||
if (!reimbursementId) {
|
||||
@@ -192,6 +207,41 @@ export function ReimbursementDetailPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const erpExportMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!reimbursementId) {
|
||||
throw new Error("missing reimbursementId");
|
||||
}
|
||||
return exportReimbursementJournal(reimbursementId, true);
|
||||
},
|
||||
onSuccess: async (result) => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId, "erp-journal-preview"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
|
||||
]);
|
||||
setActionError("");
|
||||
const suffix = result.requeued ? "已重入队" : result.duplicated ? "已复用现有队列" : "已入队";
|
||||
toast.success(`ERP 导出${suffix}`);
|
||||
},
|
||||
onError: (error) => {
|
||||
if (isApiError(error)) {
|
||||
const msg = `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
||||
setActionError(msg);
|
||||
toast.error(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
setActionError(error.message);
|
||||
toast.error(error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
setActionError("ERP 导出失败,请稍后重试");
|
||||
toast.error("ERP 导出失败,请稍后重试");
|
||||
},
|
||||
});
|
||||
|
||||
if (!reimbursementId) {
|
||||
return (
|
||||
<div className="card-std p-6">
|
||||
@@ -312,6 +362,57 @@ export function ReimbursementDetailPage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
{canManageIntegration && detail.status === "paid" ? (
|
||||
<div className="card-std space-y-4 p-6" data-testid="reimbursement-erp-export">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-base font-semibold text-zinc-900">ERP 导出</h3>
|
||||
<p className="text-sm text-zinc-500">已付款报销单会按标准凭证切片通过 Outbox 异步发送到 ERP。</p>
|
||||
</div>
|
||||
<Button onClick={() => erpExportMutation.mutate()} disabled={erpExportMutation.isPending}>
|
||||
{erpExportMutation.isPending ? "导出中..." : "导出到 ERP"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{erpPreviewQuery.isLoading ? <p className="text-sm text-zinc-500">正在加载凭证切片预览...</p> : null}
|
||||
{erpPreviewQuery.isError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{isApiError(erpPreviewQuery.error)
|
||||
? `${erpPreviewQuery.error.code}: ${mapApiErrorMessage(
|
||||
erpPreviewQuery.error.code,
|
||||
erpPreviewQuery.error.details,
|
||||
erpPreviewQuery.error.message,
|
||||
)}`
|
||||
: "加载 ERP 导出预览失败"}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{erpPreviewQuery.data ? (
|
||||
<div className="grid gap-4 lg:grid-cols-2 text-sm">
|
||||
<div className="rounded-lg border border-zinc-200 p-4 space-y-2">
|
||||
<h4 className="font-medium text-zinc-900">凭证切片</h4>
|
||||
<div>类型:{erpPreviewQuery.data.journal.journalType}</div>
|
||||
<div>单号:{erpPreviewQuery.data.journal.documentNo ?? "-"}</div>
|
||||
<div>金额:{erpPreviewQuery.data.journal.totalAmount ?? "-"}</div>
|
||||
<div>法体:{erpPreviewQuery.data.journal.dimensions.legalEntityCode ?? "-"}</div>
|
||||
<div>成本中心:{erpPreviewQuery.data.journal.dimensions.costCenterCode ?? "-"}</div>
|
||||
<div>付款凭证号:{erpPreviewQuery.data.journal.paymentVoucherNo ?? "-"}</div>
|
||||
<div>付款日期:{erpPreviewQuery.data.journal.paymentDate ?? "-"}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-200 p-4 space-y-2">
|
||||
<h4 className="font-medium text-zinc-900">导出状态</h4>
|
||||
<div>外部ID:{erpPreviewQuery.data.exportState.externalId ?? "-"}</div>
|
||||
<div>Outbox:{erpPreviewQuery.data.exportState.lastOutboxId ?? "-"}</div>
|
||||
<div>队列状态:{erpPreviewQuery.data.exportState.lastOutboxStatus ?? "-"}</div>
|
||||
<div>最近导出:{formatDateTime(erpPreviewQuery.data.exportState.exportedAt)}</div>
|
||||
<div className="break-all">最近错误:{erpPreviewQuery.data.exportState.lastError ?? "-"}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="card-std space-y-4 p-6" data-testid="reimbursement-items">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="space-y-1">
|
||||
|
||||
@@ -18,6 +18,7 @@ import { LoginPage } from "../pages/LoginPage";
|
||||
import { LeaveRequestPage } from "../pages/LeaveRequestPage";
|
||||
import { NotificationsPage } from "../pages/NotificationsPage";
|
||||
import { NotificationChannelsPage } from "../pages/NotificationChannelsPage";
|
||||
import { ErpIntegrationPage } from "../pages/ErpIntegrationPage";
|
||||
import { PurchaseRequestsPage } from "../pages/PurchaseRequestsPage";
|
||||
import { AssetRegistryPage } from "../pages/AssetRegistryPage";
|
||||
import { InventoryChecksPage } from "../pages/InventoryChecksPage";
|
||||
@@ -230,6 +231,15 @@ export function AppRouter() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="system/integrations/erp"
|
||||
element={
|
||||
<RequirePermission anyOf={["integration.manage"]}>
|
||||
<ErpIntegrationPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="system/approval-rules"
|
||||
element={
|
||||
|
||||
@@ -38,6 +38,11 @@ async function runIntegrationOutboxOnce(prisma, config, logger) {
|
||||
retryable,
|
||||
outboxId: item.id,
|
||||
eventType: item.event_type,
|
||||
reimbursementId:
|
||||
item.payload?.reimbursementId ??
|
||||
(item.payload?.entityType === 'reimbursement_form' ? item.payload?.entityId ?? null : null),
|
||||
entityType: item.payload?.entityType ?? null,
|
||||
entityId: item.payload?.entityId ?? null,
|
||||
channelId: item.payload?.channelId ?? null,
|
||||
channelType: item.payload?.channelType ?? null,
|
||||
notificationType: item.payload?.notificationType ?? null,
|
||||
|
||||
+492
-115
@@ -1,178 +1,550 @@
|
||||
const { z } = require("zod");
|
||||
const { z } = require('zod');
|
||||
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const { buildAuditLogData } = require("../utils/audit");
|
||||
const { AppError } = require('../utils/AppError');
|
||||
const { sendData } = require('../utils/reply');
|
||||
const { buildAuditLogData } = require('../utils/audit');
|
||||
const {
|
||||
buildDataEnvelopeSchema,
|
||||
buildErrorEnvelopeSchema,
|
||||
} = require("../utils/openapi");
|
||||
const { enqueueIntegrationEvent } = require("../services/integrations/outbox");
|
||||
const { buildErpPayload } = require("../services/integrations/erp");
|
||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||
const { runIntegrationOutboxOnce } = require("../jobs/integrationOutboxJob");
|
||||
} = require('../utils/openapi');
|
||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require('../services/featureFlags');
|
||||
const { runIntegrationOutboxOnce } = require('../jobs/integrationOutboxJob');
|
||||
const {
|
||||
ERP_MASTER_DATA_RESOURCES,
|
||||
listMasterDataSyncStates,
|
||||
normalizeRequestedResources,
|
||||
syncErpMasterData,
|
||||
} = require('../services/integrations/erpMasterData');
|
||||
const {
|
||||
buildReimbursementJournalPreview,
|
||||
enqueueReimbursementJournalExport,
|
||||
} = require('../services/integrations/erpJournal');
|
||||
|
||||
const UUID_SCHEMA = z.string().uuid();
|
||||
const MASTER_DATA_RESOURCE_ENUM = z.enum(['legal_entity', 'cost_center', 'supplier']);
|
||||
const MASTER_DATA_SYNC_BODY_SCHEMA = z.object({
|
||||
resources: z.array(MASTER_DATA_RESOURCE_ENUM).max(3).optional(),
|
||||
});
|
||||
|
||||
const ERP_SYNC_RESPONSE_SCHEMA = {
|
||||
type: "object",
|
||||
required: ["outboxId", "status", "duplicated"],
|
||||
type: 'object',
|
||||
required: ['outboxId', 'status', 'duplicated'],
|
||||
properties: {
|
||||
outboxId: { type: "string", format: "uuid" },
|
||||
status: { type: "string" },
|
||||
duplicated: { type: "boolean" },
|
||||
outboxId: { type: 'string', format: 'uuid' },
|
||||
status: { type: 'string' },
|
||||
duplicated: { type: 'boolean' },
|
||||
},
|
||||
};
|
||||
|
||||
const ERP_JOURNAL_EXPORT_RESPONSE_SCHEMA = {
|
||||
type: 'object',
|
||||
required: ['outboxId', 'status', 'duplicated', 'requeued', 'eventType'],
|
||||
properties: {
|
||||
outboxId: { type: 'string', format: 'uuid' },
|
||||
status: { type: 'string' },
|
||||
duplicated: { type: 'boolean' },
|
||||
requeued: { type: 'boolean' },
|
||||
eventType: { type: 'string' },
|
||||
},
|
||||
};
|
||||
|
||||
const ERP_MASTER_DATA_STATUS_SCHEMA = {
|
||||
type: 'object',
|
||||
required: ['items', 'gate'],
|
||||
properties: {
|
||||
gate: {
|
||||
type: 'object',
|
||||
required: ['envEnabled', 'effectiveEnabled', 'effectiveReason', 'provider'],
|
||||
properties: {
|
||||
envEnabled: { type: 'boolean' },
|
||||
effectiveEnabled: { type: 'boolean' },
|
||||
effectiveReason: { type: 'string' },
|
||||
provider: { type: 'string' },
|
||||
masterDataEndpointConfigured: { type: 'boolean' },
|
||||
journalEndpointConfigured: { type: 'boolean' },
|
||||
masterDataSyncCron: { anyOf: [{ type: 'string' }, { type: 'null' }] },
|
||||
},
|
||||
},
|
||||
items: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['resourceType', 'status'],
|
||||
properties: {
|
||||
resourceType: { type: 'string' },
|
||||
status: { type: 'string' },
|
||||
lastStartedAt: { anyOf: [{ type: 'string', format: 'date-time' }, { type: 'null' }] },
|
||||
lastFinishedAt: { anyOf: [{ type: 'string', format: 'date-time' }, { type: 'null' }] },
|
||||
lastSuccessAt: { anyOf: [{ type: 'string', format: 'date-time' }, { type: 'null' }] },
|
||||
lastError: { anyOf: [{ type: 'string' }, { type: 'null' }] },
|
||||
lastResult: { anyOf: [{ type: 'object' }, { type: 'null' }] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function parseRunNow(query) {
|
||||
return (
|
||||
query?.runNow === '1' ||
|
||||
query?.runNow === 1 ||
|
||||
query?.runNow === true ||
|
||||
query?.runNow === 'true'
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveErpGate(prisma, config) {
|
||||
const evaluation = await evaluateFeatureFlag({
|
||||
prisma,
|
||||
config,
|
||||
flagKey: FEATURE_FLAG_KEYS.erpSync,
|
||||
departmentId: null,
|
||||
strictDepartment: false,
|
||||
});
|
||||
|
||||
const envEnabled = Boolean(config.erpSyncEnabled);
|
||||
const featureFlagEnabled = Boolean(evaluation.enabled);
|
||||
|
||||
return {
|
||||
envEnabled,
|
||||
featureFlagEnabled,
|
||||
effectiveEnabled: envEnabled && featureFlagEnabled,
|
||||
effectiveReason: envEnabled ? evaluation.reason : 'env_disabled',
|
||||
provider: config.erpSyncProvider,
|
||||
masterDataEndpointConfigured: Boolean(String(config.erpMasterDataEndpoint ?? '').trim()),
|
||||
journalEndpointConfigured: Boolean(
|
||||
String(config.erpJournalExportEndpoint ?? config.erpSyncEndpoint ?? '').trim(),
|
||||
),
|
||||
masterDataSyncCron: String(config.erpMasterDataSyncCron ?? '').trim() || null,
|
||||
featureFlag: FEATURE_FLAG_KEYS.erpSync,
|
||||
};
|
||||
}
|
||||
|
||||
async function requireErpManagementGate(prisma, config) {
|
||||
const gate = await resolveErpGate(prisma, config);
|
||||
if (gate.effectiveEnabled) {
|
||||
return gate;
|
||||
}
|
||||
|
||||
throw new AppError({
|
||||
code: 'VALID_001',
|
||||
message: '参数校验失败',
|
||||
statusCode: 409,
|
||||
details: {
|
||||
integration: gate.envEnabled ? 'feature_flag_blocked' : 'erp_sync_disabled',
|
||||
featureFlag: gate.featureFlag,
|
||||
reason: gate.effectiveReason,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchReimbursementForErp(prisma, reimbursementId) {
|
||||
const reimbursement = await prisma.reimbursementForm.findUnique({
|
||||
where: { id: reimbursementId },
|
||||
select: {
|
||||
id: true,
|
||||
form_number: true,
|
||||
owner_id: true,
|
||||
submitter_id: true,
|
||||
department_id: true,
|
||||
status: true,
|
||||
total_amount: true,
|
||||
payment_voucher_no: true,
|
||||
payment_date: true,
|
||||
paid_at: true,
|
||||
owner: {
|
||||
select: {
|
||||
legal_entity: {
|
||||
select: {
|
||||
code: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
submitter: {
|
||||
select: {
|
||||
legal_entity: {
|
||||
select: {
|
||||
code: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!reimbursement) {
|
||||
throw new AppError({
|
||||
code: 'SYS_404',
|
||||
message: 'Not Found',
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
return reimbursement;
|
||||
}
|
||||
|
||||
function ensurePaidReimbursement(reimbursement) {
|
||||
if (reimbursement.status !== 'paid') {
|
||||
throw new AppError({
|
||||
code: 'VALID_001',
|
||||
message: '参数校验失败',
|
||||
statusCode: 409,
|
||||
details: { reimbursement: 'erp_sync_requires_paid' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function enqueueReimbursementExportAndMaybeRun({
|
||||
prisma,
|
||||
config,
|
||||
logger,
|
||||
reimbursement,
|
||||
actorId,
|
||||
runNow,
|
||||
}) {
|
||||
const enqueued = await enqueueReimbursementJournalExport(prisma, config, {
|
||||
reimbursement,
|
||||
actorId,
|
||||
});
|
||||
|
||||
if (runNow) {
|
||||
await runIntegrationOutboxOnce(prisma, config, logger);
|
||||
}
|
||||
|
||||
return enqueued;
|
||||
}
|
||||
|
||||
function normalizeResourcesInput(value) {
|
||||
const parsed = MASTER_DATA_SYNC_BODY_SCHEMA.safeParse(value ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: 'VALID_001',
|
||||
message: '参数校验失败',
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
return normalizeRequestedResources(parsed.data.resources ?? []);
|
||||
}
|
||||
|
||||
async function integrationsRoutes(fastify) {
|
||||
fastify.post(
|
||||
"/integrations/erp/sync/:reimbursementId",
|
||||
fastify.get(
|
||||
'/integrations/erp/master-data/status',
|
||||
{
|
||||
preHandler: fastify.requirePermission("integration.manage"),
|
||||
preHandler: fastify.requirePermission('integration.manage'),
|
||||
schema: {
|
||||
tags: ["integrations"],
|
||||
summary: "手动触发 ERP 同步",
|
||||
tags: ['integrations'],
|
||||
summary: '查询 ERP 主数据同步状态',
|
||||
security: [{ bearerAuth: [] }],
|
||||
response: {
|
||||
200: buildDataEnvelopeSchema(ERP_MASTER_DATA_STATUS_SCHEMA, 'ERP 主数据同步状态'),
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const [items, gate] = await Promise.all([
|
||||
listMasterDataSyncStates(fastify.prisma),
|
||||
resolveErpGate(fastify.prisma, fastify.config),
|
||||
]);
|
||||
return sendData(reply, request, { gate, items });
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
'/integrations/erp/master-data/sync',
|
||||
{
|
||||
preHandler: fastify.requirePermission('integration.manage'),
|
||||
schema: {
|
||||
tags: ['integrations'],
|
||||
summary: '手动触发 ERP 主数据同步',
|
||||
security: [{ bearerAuth: [] }],
|
||||
response: {
|
||||
200: buildDataEnvelopeSchema(ERP_MASTER_DATA_STATUS_SCHEMA, 'ERP 主数据同步结果'),
|
||||
400: buildErrorEnvelopeSchema({
|
||||
description: '参数校验失败',
|
||||
codeExample: 'VALID_001',
|
||||
messageExample: '参数校验失败',
|
||||
}),
|
||||
409: buildErrorEnvelopeSchema({
|
||||
description: 'ERP 总闸关闭或同步失败',
|
||||
codeExample: 'VALID_001',
|
||||
messageExample: '参数校验失败',
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const resources = normalizeResourcesInput(request.body);
|
||||
const gate = await requireErpManagementGate(fastify.prisma, fastify.config);
|
||||
const result = await syncErpMasterData(fastify.prisma, fastify.config, {
|
||||
resources,
|
||||
actorId: request.employee.id,
|
||||
logger: fastify.log,
|
||||
});
|
||||
|
||||
await fastify.prisma.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: 'ERP_MASTER_DATA_SYNC_TRIGGERED',
|
||||
targetType: 'integration_master_data',
|
||||
extra: {
|
||||
resources,
|
||||
gateReason: gate.effectiveReason,
|
||||
provider: gate.provider,
|
||||
ok: result.ok,
|
||||
errorCode: result.error?.code ?? null,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
throw new AppError({
|
||||
code: 'VALID_001',
|
||||
message: '参数校验失败',
|
||||
statusCode: 409,
|
||||
details: {
|
||||
integration: result.error?.code ?? 'erp_master_data_sync_failed',
|
||||
reason: result.error?.message ?? 'erp_master_data_sync_failed',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return sendData(reply, request, {
|
||||
gate,
|
||||
items: result.items.filter((item) => resources.includes(item.resourceType)),
|
||||
triggerMode: 'manual',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
'/integrations/erp/journals/reimbursements/:reimbursementId/preview',
|
||||
{
|
||||
preHandler: fastify.requirePermission('integration.manage'),
|
||||
schema: {
|
||||
tags: ['integrations'],
|
||||
summary: '预览报销付款凭证切片',
|
||||
security: [{ bearerAuth: [] }],
|
||||
params: {
|
||||
type: "object",
|
||||
required: ["reimbursementId"],
|
||||
type: 'object',
|
||||
required: ['reimbursementId'],
|
||||
properties: {
|
||||
reimbursementId: { type: "string", format: "uuid" },
|
||||
reimbursementId: { type: 'string', format: 'uuid' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const parsedId = UUID_SCHEMA.safeParse(request.params?.reimbursementId);
|
||||
if (!parsedId.success) {
|
||||
throw new AppError({
|
||||
code: 'VALID_001',
|
||||
message: '参数校验失败',
|
||||
statusCode: 400,
|
||||
details: { reimbursementId: 'invalid_uuid' },
|
||||
});
|
||||
}
|
||||
|
||||
const reimbursement = await fetchReimbursementForErp(fastify.prisma, parsedId.data);
|
||||
ensurePaidReimbursement(reimbursement);
|
||||
const preview = await buildReimbursementJournalPreview(fastify.prisma, reimbursement);
|
||||
return sendData(reply, request, {
|
||||
journal: preview.journal,
|
||||
exportState: preview.exportState,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
'/integrations/erp/journals/reimbursements/:reimbursementId/export',
|
||||
{
|
||||
preHandler: fastify.requirePermission('integration.manage'),
|
||||
schema: {
|
||||
tags: ['integrations'],
|
||||
summary: '导出报销付款凭证到 ERP',
|
||||
security: [{ bearerAuth: [] }],
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['reimbursementId'],
|
||||
properties: {
|
||||
reimbursementId: { type: 'string', format: 'uuid' },
|
||||
},
|
||||
},
|
||||
querystring: {
|
||||
type: "object",
|
||||
type: 'object',
|
||||
properties: {
|
||||
runNow: {
|
||||
anyOf: [{ type: "string", enum: ["1", "true"] }, { type: "boolean" }],
|
||||
anyOf: [
|
||||
{ type: 'string', enum: ['0', '1', 'false', 'true'] },
|
||||
{ type: 'boolean' },
|
||||
],
|
||||
},
|
||||
},
|
||||
additionalProperties: true,
|
||||
},
|
||||
response: {
|
||||
200: buildDataEnvelopeSchema(ERP_SYNC_RESPONSE_SCHEMA, "ERP 同步任务已入队"),
|
||||
200: buildDataEnvelopeSchema(ERP_JOURNAL_EXPORT_RESPONSE_SCHEMA, 'ERP 导出任务已入队'),
|
||||
400: buildErrorEnvelopeSchema({
|
||||
description: "参数校验失败",
|
||||
codeExample: "VALID_001",
|
||||
messageExample: "参数校验失败",
|
||||
description: '参数校验失败',
|
||||
codeExample: 'VALID_001',
|
||||
messageExample: '参数校验失败',
|
||||
}),
|
||||
404: buildErrorEnvelopeSchema({
|
||||
description: "报销单不存在",
|
||||
codeExample: "SYS_404",
|
||||
messageExample: "Not Found",
|
||||
description: '报销单不存在',
|
||||
codeExample: 'SYS_404',
|
||||
messageExample: 'Not Found',
|
||||
}),
|
||||
409: buildErrorEnvelopeSchema({
|
||||
description: "状态不满足或开关阻断",
|
||||
codeExample: "VALID_001",
|
||||
messageExample: "参数校验失败",
|
||||
description: '状态不满足或开关阻断',
|
||||
codeExample: 'VALID_001',
|
||||
messageExample: '参数校验失败',
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const parsedId = UUID_SCHEMA.safeParse(request.params?.reimbursementId);
|
||||
if (!parsedId.success) {
|
||||
throw new AppError({
|
||||
code: 'VALID_001',
|
||||
message: '参数校验失败',
|
||||
statusCode: 400,
|
||||
details: { reimbursementId: 'invalid_uuid' },
|
||||
});
|
||||
}
|
||||
|
||||
const reimbursement = await fetchReimbursementForErp(fastify.prisma, parsedId.data);
|
||||
ensurePaidReimbursement(reimbursement);
|
||||
const gate = await requireErpManagementGate(fastify.prisma, fastify.config);
|
||||
const enqueued = await enqueueReimbursementExportAndMaybeRun({
|
||||
prisma: fastify.prisma,
|
||||
config: fastify.config,
|
||||
logger: fastify.log,
|
||||
reimbursement,
|
||||
actorId: request.employee.id,
|
||||
runNow: parseRunNow(request.query),
|
||||
});
|
||||
|
||||
await fastify.prisma.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: 'ERP_SYNC_ENQUEUED',
|
||||
targetType: 'reimbursement_form',
|
||||
targetId: reimbursement.id,
|
||||
extra: {
|
||||
outboxId: enqueued.id,
|
||||
duplicated: enqueued.duplicated,
|
||||
requeued: enqueued.requeued,
|
||||
eventType: enqueued.eventType,
|
||||
featureFlag: FEATURE_FLAG_KEYS.erpSync,
|
||||
featureFlagReason: gate.effectiveReason,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
outboxId: enqueued.id,
|
||||
status: enqueued.status,
|
||||
duplicated: enqueued.duplicated,
|
||||
requeued: enqueued.requeued,
|
||||
eventType: enqueued.eventType,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
'/integrations/erp/sync/:reimbursementId',
|
||||
{
|
||||
preHandler: fastify.requirePermission('integration.manage'),
|
||||
schema: {
|
||||
tags: ['integrations'],
|
||||
summary: '手动触发 ERP 同步',
|
||||
security: [{ bearerAuth: [] }],
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['reimbursementId'],
|
||||
properties: {
|
||||
reimbursementId: { type: 'string', format: 'uuid' },
|
||||
},
|
||||
},
|
||||
querystring: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
runNow: {
|
||||
anyOf: [
|
||||
{ type: 'string', enum: ['0', '1', 'false', 'true'] },
|
||||
{ type: 'boolean' },
|
||||
],
|
||||
},
|
||||
},
|
||||
additionalProperties: true,
|
||||
},
|
||||
response: {
|
||||
200: buildDataEnvelopeSchema(ERP_SYNC_RESPONSE_SCHEMA, 'ERP 同步任务已入队'),
|
||||
400: buildErrorEnvelopeSchema({
|
||||
description: '参数校验失败',
|
||||
codeExample: 'VALID_001',
|
||||
messageExample: '参数校验失败',
|
||||
}),
|
||||
404: buildErrorEnvelopeSchema({
|
||||
description: '报销单不存在',
|
||||
codeExample: 'SYS_404',
|
||||
messageExample: 'Not Found',
|
||||
}),
|
||||
409: buildErrorEnvelopeSchema({
|
||||
description: '状态不满足或开关阻断',
|
||||
codeExample: 'VALID_001',
|
||||
messageExample: '参数校验失败',
|
||||
detailsExample: {
|
||||
integration: "feature_flag_blocked",
|
||||
featureFlag: "erp_sync",
|
||||
reason: "department_not_allowed",
|
||||
integration: 'feature_flag_blocked',
|
||||
featureFlag: 'erp_sync',
|
||||
reason: 'department_not_allowed',
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const parsedId = z.string().uuid().safeParse(request.params?.reimbursementId);
|
||||
const parsedId = UUID_SCHEMA.safeParse(request.params?.reimbursementId);
|
||||
if (!parsedId.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
code: 'VALID_001',
|
||||
message: '参数校验失败',
|
||||
statusCode: 400,
|
||||
details: { reimbursementId: "invalid_uuid" },
|
||||
details: { reimbursementId: 'invalid_uuid' },
|
||||
});
|
||||
}
|
||||
|
||||
const reimbursementId = parsedId.data;
|
||||
|
||||
const reimbursement = await fastify.prisma.reimbursementForm.findUnique({
|
||||
where: { id: reimbursementId },
|
||||
select: {
|
||||
id: true,
|
||||
form_number: true,
|
||||
owner_id: true,
|
||||
submitter_id: true,
|
||||
department_id: true,
|
||||
status: true,
|
||||
total_amount: true,
|
||||
payment_voucher_no: true,
|
||||
payment_date: true,
|
||||
paid_at: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!reimbursement) {
|
||||
throw new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
if (reimbursement.status !== "paid") {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { reimbursement: "erp_sync_requires_paid" },
|
||||
});
|
||||
}
|
||||
|
||||
const erpGate = await evaluateFeatureFlag({
|
||||
const reimbursement = await fetchReimbursementForErp(fastify.prisma, parsedId.data);
|
||||
ensurePaidReimbursement(reimbursement);
|
||||
const gate = await requireErpManagementGate(fastify.prisma, fastify.config);
|
||||
const enqueued = await enqueueReimbursementExportAndMaybeRun({
|
||||
prisma: fastify.prisma,
|
||||
config: fastify.config,
|
||||
flagKey: FEATURE_FLAG_KEYS.erpSync,
|
||||
departmentId: reimbursement.department_id ?? request.employee.department_id ?? null,
|
||||
strictDepartment: true,
|
||||
});
|
||||
|
||||
if (!erpGate.enabled) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: {
|
||||
integration: "feature_flag_blocked",
|
||||
featureFlag: FEATURE_FLAG_KEYS.erpSync,
|
||||
reason: erpGate.reason,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const payload = buildErpPayload({ reimbursement });
|
||||
const enqueued = await enqueueIntegrationEvent(fastify.prisma, {
|
||||
eventType: "erp.reimbursement.paid",
|
||||
payload,
|
||||
dedupeKey: reimbursement.id,
|
||||
maxAttempts: fastify.config.integrationOutboxMaxAttempts,
|
||||
logger: fastify.log,
|
||||
reimbursement,
|
||||
actorId: request.employee.id,
|
||||
runNow: parseRunNow(request.query),
|
||||
});
|
||||
|
||||
await fastify.prisma.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "ERP_SYNC_ENQUEUED",
|
||||
targetType: "reimbursement_form",
|
||||
targetId: reimbursementId,
|
||||
action: 'ERP_SYNC_ENQUEUED',
|
||||
targetType: 'reimbursement_form',
|
||||
targetId: reimbursement.id,
|
||||
extra: {
|
||||
outboxId: enqueued.id,
|
||||
duplicated: enqueued.duplicated,
|
||||
requeued: enqueued.requeued,
|
||||
featureFlag: FEATURE_FLAG_KEYS.erpSync,
|
||||
departmentId: reimbursement.department_id ?? null,
|
||||
featureFlagReason: erpGate.reason,
|
||||
featureFlagReason: gate.effectiveReason,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const runNow =
|
||||
request.query?.runNow === "1" ||
|
||||
request.query?.runNow === 1 ||
|
||||
request.query?.runNow === true ||
|
||||
request.query?.runNow === "true";
|
||||
if (runNow) {
|
||||
await runIntegrationOutboxOnce(fastify.prisma, fastify.config, fastify.log);
|
||||
}
|
||||
|
||||
return sendData(reply, request, {
|
||||
outboxId: enqueued.id,
|
||||
status: enqueued.status,
|
||||
@@ -182,4 +554,9 @@ async function integrationsRoutes(fastify) {
|
||||
);
|
||||
}
|
||||
|
||||
integrationsRoutes.__testables = {
|
||||
normalizeResourcesInput,
|
||||
parseRunNow,
|
||||
};
|
||||
|
||||
module.exports = integrationsRoutes;
|
||||
|
||||
@@ -11,8 +11,7 @@ const {
|
||||
const { enqueueImNotifications } = require("../services/notifications/imEnqueue");
|
||||
const { NOTIFICATION_TYPES } = require("../constants/notificationTypes");
|
||||
const { sendNotificationEmailSafe } = require("../services/email");
|
||||
const { enqueueIntegrationEvent } = require("../services/integrations/outbox");
|
||||
const { buildErpPayload } = require("../services/integrations/erp");
|
||||
const { enqueueReimbursementJournalExport } = require("../services/integrations/erpJournal");
|
||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||
const { resolveApplicableBudgetPoolByContext } = require("../services/budgetPool");
|
||||
const { issueDocumentNumber } = require("../services/documentSequence");
|
||||
@@ -1771,6 +1770,24 @@ async function reimbursementRoutes(fastify) {
|
||||
payment_voucher_no: true,
|
||||
payment_date: true,
|
||||
paid_at: true,
|
||||
owner: {
|
||||
select: {
|
||||
legal_entity: {
|
||||
select: {
|
||||
code: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
submitter: {
|
||||
select: {
|
||||
legal_entity: {
|
||||
select: {
|
||||
code: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1786,11 +1803,8 @@ async function reimbursementRoutes(fastify) {
|
||||
});
|
||||
|
||||
if (erpGate.enabled) {
|
||||
await enqueueIntegrationEvent(tx, {
|
||||
eventType: "erp.reimbursement.paid",
|
||||
dedupeKey: refreshed.id,
|
||||
payload: buildErpPayload({ reimbursement: refreshed }),
|
||||
maxAttempts: fastify.config.integrationOutboxMaxAttempts,
|
||||
await enqueueReimbursementJournalExport(tx, fastify.config, {
|
||||
reimbursement: refreshed,
|
||||
actorId: request.employee.id,
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { pushPaidReimbursementToErp } = require('./erp');
|
||||
const { pushPaidReimbursementToErp, exportJournalToErp } = require('./erp');
|
||||
const { sendInstantMessageByChannel } = require('../notifications/channels');
|
||||
|
||||
class IntegrationDispatchError extends Error {
|
||||
@@ -22,18 +22,82 @@ function toDispatchError(result) {
|
||||
});
|
||||
}
|
||||
|
||||
async function dispatchOutboxEvent({ prisma, config, eventType, payload }) {
|
||||
if (eventType === 'erp.reimbursement.paid') {
|
||||
const result = await pushPaidReimbursementToErp(config, payload);
|
||||
if (!result.ok) {
|
||||
throw toDispatchError(result);
|
||||
}
|
||||
function extractExternalId(response) {
|
||||
if (!response) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
channel: 'erp',
|
||||
code: result.code,
|
||||
response: result.response ?? null,
|
||||
};
|
||||
if (typeof response.externalId === 'string' && response.externalId.trim()) {
|
||||
return response.externalId.trim();
|
||||
}
|
||||
|
||||
if (
|
||||
typeof response.body?.externalId === 'string' &&
|
||||
response.body.externalId.trim()
|
||||
) {
|
||||
return response.body.externalId.trim();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function upsertErpBindingForPayload(prisma, payload, response) {
|
||||
const entityType = String(payload?.entityType ?? '').trim();
|
||||
const entityId = String(payload?.entityId ?? '').trim();
|
||||
if (!prisma || !entityType || !entityId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (entityType !== 'reimbursement_form') {
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.integrationBinding.upsert({
|
||||
where: {
|
||||
provider_entity_type_local_id: {
|
||||
provider: 'erp',
|
||||
entity_type: 'reimbursement_journal',
|
||||
local_id: entityId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
provider: 'erp',
|
||||
entity_type: 'reimbursement_journal',
|
||||
local_id: entityId,
|
||||
external_id: extractExternalId(response),
|
||||
external_code: payload?.documentNo ?? payload?.formNumber ?? null,
|
||||
last_synced_at: new Date(),
|
||||
},
|
||||
update: {
|
||||
external_id: extractExternalId(response),
|
||||
external_code: payload?.documentNo ?? payload?.formNumber ?? null,
|
||||
last_synced_at: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function dispatchErpEvent({ prisma, config, eventType, payload }) {
|
||||
const result =
|
||||
eventType === 'erp.journal.export'
|
||||
? await exportJournalToErp(config, payload)
|
||||
: await pushPaidReimbursementToErp(config, payload);
|
||||
|
||||
if (!result.ok) {
|
||||
throw toDispatchError(result);
|
||||
}
|
||||
|
||||
await upsertErpBindingForPayload(prisma, payload, result.response ?? null);
|
||||
|
||||
return {
|
||||
channel: 'erp',
|
||||
code: result.code,
|
||||
response: result.response ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function dispatchOutboxEvent({ prisma, config, eventType, payload }) {
|
||||
if (eventType === 'erp.reimbursement.paid' || eventType === 'erp.journal.export') {
|
||||
return dispatchErpEvent({ prisma, config, eventType, payload });
|
||||
}
|
||||
|
||||
if (eventType === 'notification.im') {
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
const { enqueueIntegrationEvent } = require('./outbox');
|
||||
|
||||
const ERP_JOURNAL_EVENT_TYPES = Object.freeze(['erp.journal.export', 'erp.reimbursement.paid']);
|
||||
const REIMBURSEMENT_JOURNAL_DEDUPE_PREFIX = 'reimbursement';
|
||||
|
||||
function toDateOnly(value) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new Date(value).toISOString().slice(0, 10);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function toIsoString(value) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new Date(value).toISOString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveLegalEntityCode(reimbursement) {
|
||||
return (
|
||||
reimbursement?.owner?.legal_entity?.code ??
|
||||
reimbursement?.submitter?.legal_entity?.code ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function buildReimbursementJournalPayload(input) {
|
||||
const reimbursement = input?.reimbursement ?? {};
|
||||
const occurredAt =
|
||||
toIsoString(reimbursement.paid_at) ??
|
||||
(reimbursement.payment_date ? `${toDateOnly(reimbursement.payment_date)}T00:00:00.000Z` : null);
|
||||
|
||||
const payload = {
|
||||
journalType: 'reimbursement_paid',
|
||||
sourceEventName: 'reimbursement.paid',
|
||||
entityType: 'reimbursement_form',
|
||||
entityId: reimbursement.id,
|
||||
documentNo: reimbursement.form_number ?? null,
|
||||
currencyCode: 'CNY',
|
||||
totalAmount: reimbursement.total_amount?.toString?.() ?? null,
|
||||
occurredAt,
|
||||
paymentVoucherNo: reimbursement.payment_voucher_no ?? null,
|
||||
paymentDate: toDateOnly(reimbursement.payment_date),
|
||||
dimensions: {
|
||||
departmentId: reimbursement.department_id ?? null,
|
||||
ownerEmployeeId: reimbursement.owner_id ?? null,
|
||||
submitterEmployeeId: reimbursement.submitter_id ?? null,
|
||||
legalEntityCode: resolveLegalEntityCode(reimbursement),
|
||||
costCenterCode: null,
|
||||
},
|
||||
reimbursementId: reimbursement.id,
|
||||
formNumber: reimbursement.form_number ?? null,
|
||||
};
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
function buildReimbursementJournalDedupeKey(reimbursementId) {
|
||||
return `${REIMBURSEMENT_JOURNAL_DEDUPE_PREFIX}:${reimbursementId}:paid`;
|
||||
}
|
||||
|
||||
async function enqueueReimbursementJournalExport(prisma, config, input) {
|
||||
const payload =
|
||||
input?.payload ?? buildReimbursementJournalPayload({ reimbursement: input?.reimbursement });
|
||||
|
||||
const enqueued = await enqueueIntegrationEvent(prisma, {
|
||||
eventType: 'erp.journal.export',
|
||||
payload,
|
||||
dedupeKey: buildReimbursementJournalDedupeKey(payload.entityId),
|
||||
maxAttempts: config.integrationOutboxMaxAttempts,
|
||||
actorId: input?.actorId ?? null,
|
||||
requeueOnFailure: true,
|
||||
});
|
||||
|
||||
return {
|
||||
...enqueued,
|
||||
eventType: 'erp.journal.export',
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
function mapIntegrationBinding(record) {
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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: record.last_synced_at?.toISOString?.() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function mapIntegrationOutboxState(record) {
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: record.id,
|
||||
eventType: record.event_type,
|
||||
status: record.status,
|
||||
lastError: record.last_error ?? null,
|
||||
processedAt: record.processed_at?.toISOString?.() ?? null,
|
||||
updatedAt: record.updated_at?.toISOString?.() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function findLatestReimbursementJournalOutbox(prisma, reimbursementId) {
|
||||
const current = await prisma.integrationOutbox.findFirst({
|
||||
where: {
|
||||
event_type: 'erp.journal.export',
|
||||
dedupe_key: buildReimbursementJournalDedupeKey(reimbursementId),
|
||||
},
|
||||
orderBy: { updated_at: 'desc' },
|
||||
});
|
||||
|
||||
if (current) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return prisma.integrationOutbox.findFirst({
|
||||
where: {
|
||||
event_type: 'erp.reimbursement.paid',
|
||||
dedupe_key: reimbursementId,
|
||||
},
|
||||
orderBy: { updated_at: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async function buildReimbursementJournalPreview(prisma, reimbursement) {
|
||||
const [binding, outbox] = await Promise.all([
|
||||
prisma.integrationBinding.findUnique({
|
||||
where: {
|
||||
provider_entity_type_local_id: {
|
||||
provider: 'erp',
|
||||
entity_type: 'reimbursement_journal',
|
||||
local_id: reimbursement.id,
|
||||
},
|
||||
},
|
||||
}),
|
||||
findLatestReimbursementJournalOutbox(prisma, reimbursement.id),
|
||||
]);
|
||||
|
||||
const journal = buildReimbursementJournalPayload({ reimbursement });
|
||||
const exportState = {
|
||||
externalId: binding?.external_id ?? null,
|
||||
lastOutboxId: outbox?.id ?? null,
|
||||
lastOutboxStatus: outbox?.status ?? null,
|
||||
lastError: outbox?.last_error ?? null,
|
||||
exportedAt: binding?.last_synced_at?.toISOString?.() ?? outbox?.processed_at?.toISOString?.() ?? null,
|
||||
};
|
||||
|
||||
return {
|
||||
journal,
|
||||
exportState,
|
||||
binding: mapIntegrationBinding(binding),
|
||||
outbox: mapIntegrationOutboxState(outbox),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ERP_JOURNAL_EVENT_TYPES,
|
||||
buildReimbursementJournalDedupeKey,
|
||||
buildReimbursementJournalPayload,
|
||||
buildReimbursementJournalPreview,
|
||||
enqueueReimbursementJournalExport,
|
||||
findLatestReimbursementJournalOutbox,
|
||||
};
|
||||
@@ -1,9 +1,9 @@
|
||||
const OUTBOX_STATUSES = {
|
||||
pending: "pending",
|
||||
processing: "processing",
|
||||
succeeded: "succeeded",
|
||||
failed: "failed",
|
||||
dead_letter: "dead_letter",
|
||||
pending: 'pending',
|
||||
processing: 'processing',
|
||||
succeeded: 'succeeded',
|
||||
failed: 'failed',
|
||||
dead_letter: 'dead_letter',
|
||||
};
|
||||
|
||||
function nextRetryAt(config, attempts) {
|
||||
@@ -15,7 +15,7 @@ function nextRetryAt(config, attempts) {
|
||||
function formatDispatchError(error) {
|
||||
const code = error?.code ? String(error.code) : null;
|
||||
const statusCode = Number.isFinite(error?.statusCode) ? Number(error.statusCode) : null;
|
||||
const message = String(error?.message ?? error ?? "integration_outbox_error").trim();
|
||||
const message = String(error?.message ?? error ?? 'integration_outbox_error').trim();
|
||||
|
||||
const parts = [];
|
||||
if (code) {
|
||||
@@ -29,10 +29,10 @@ function formatDispatchError(error) {
|
||||
}
|
||||
|
||||
if (!parts.length) {
|
||||
return "integration_outbox_error";
|
||||
return 'integration_outbox_error';
|
||||
}
|
||||
|
||||
return parts.join(" | ");
|
||||
return parts.join(' | ');
|
||||
}
|
||||
|
||||
async function enqueueIntegrationEvent(prisma, input) {
|
||||
@@ -52,10 +52,42 @@ async function enqueueIntegrationEvent(prisma, input) {
|
||||
: null;
|
||||
|
||||
if (existed) {
|
||||
if (
|
||||
input.requeueOnFailure &&
|
||||
[OUTBOX_STATUSES.failed, OUTBOX_STATUSES.dead_letter].includes(existed.status)
|
||||
) {
|
||||
const reset = await prisma.integrationOutbox.update({
|
||||
where: { id: existed.id },
|
||||
data: {
|
||||
status: OUTBOX_STATUSES.pending,
|
||||
payload: input.payload,
|
||||
attempts: 0,
|
||||
max_attempts: input.maxAttempts,
|
||||
last_error: null,
|
||||
next_retry_at: null,
|
||||
processed_at: null,
|
||||
dead_letter_at: null,
|
||||
updated_by: input.actorId ?? null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: reset.id,
|
||||
status: reset.status,
|
||||
duplicated: false,
|
||||
requeued: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: existed.id,
|
||||
status: existed.status,
|
||||
duplicated: true,
|
||||
requeued: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -79,6 +111,7 @@ async function enqueueIntegrationEvent(prisma, input) {
|
||||
id: created.id,
|
||||
status: created.status,
|
||||
duplicated: false,
|
||||
requeued: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -93,7 +126,7 @@ async function fetchPendingOutboxEvents(prisma, options) {
|
||||
},
|
||||
OR: [{ next_retry_at: null }, { next_retry_at: { lte: now } }],
|
||||
},
|
||||
orderBy: [{ created_at: "asc" }],
|
||||
orderBy: [{ created_at: 'asc' }],
|
||||
take: batchSize,
|
||||
});
|
||||
}
|
||||
@@ -128,9 +161,9 @@ async function markOutboxFailed(prisma, config, item, error, actorId, options =
|
||||
);
|
||||
|
||||
const retryable =
|
||||
typeof options.retryable === "boolean"
|
||||
typeof options.retryable === 'boolean'
|
||||
? options.retryable
|
||||
: typeof error?.retryable === "boolean"
|
||||
: typeof error?.retryable === 'boolean'
|
||||
? error.retryable
|
||||
: true;
|
||||
|
||||
|
||||
@@ -29,6 +29,41 @@ describe("dispatchOutboxEvent", () => {
|
||||
expect(result.response.externalId).toContain("BX-001");
|
||||
});
|
||||
|
||||
it("records integration binding for standard journal export", async () => {
|
||||
const prisma = {
|
||||
integrationBinding: {
|
||||
upsert: vi.fn().mockResolvedValue({ id: 'binding-1' }),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await dispatchOutboxEvent({
|
||||
prisma,
|
||||
config: {
|
||||
erpSyncEnabled: true,
|
||||
erpSyncProvider: 'mock',
|
||||
},
|
||||
eventType: 'erp.journal.export',
|
||||
payload: {
|
||||
entityType: 'reimbursement_form',
|
||||
entityId: 'rb-1',
|
||||
documentNo: 'BX-20260308-0001',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.channel).toBe('erp');
|
||||
expect(prisma.integrationBinding.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
provider_entity_type_local_id: {
|
||||
provider: 'erp',
|
||||
entity_type: 'reimbursement_journal',
|
||||
local_id: 'rb-1',
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("loads IM channel and dispatches mock wecom message", async () => {
|
||||
const prisma = {
|
||||
notificationChannel: {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
const integrationsRoutes = require('../../../src/routes/integrations');
|
||||
|
||||
describe('Integrations ERP route helpers', () => {
|
||||
const { normalizeResourcesInput, parseRunNow } = integrationsRoutes.__testables;
|
||||
|
||||
it('normalizes and orders resources', () => {
|
||||
expect(normalizeResourcesInput({ resources: ['supplier', 'legal_entity', 'supplier'] })).toEqual([
|
||||
'legal_entity',
|
||||
'supplier',
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects invalid resource values', () => {
|
||||
expect(() => normalizeResourcesInput({ resources: ['unknown'] })).toThrow();
|
||||
});
|
||||
|
||||
it('parses runNow truthy flags', () => {
|
||||
expect(parseRunNow({ runNow: '1' })).toBe(true);
|
||||
expect(parseRunNow({ runNow: true })).toBe(true);
|
||||
expect(parseRunNow({ runNow: '0' })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
const {
|
||||
buildReimbursementJournalDedupeKey,
|
||||
buildReimbursementJournalPayload,
|
||||
enqueueReimbursementJournalExport,
|
||||
} = require('../../../src/services/integrations/erpJournal');
|
||||
|
||||
describe('ERP Journal Service', () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('prefers owner legal entity code and keeps cost center null', () => {
|
||||
const payload = buildReimbursementJournalPayload({
|
||||
reimbursement: {
|
||||
id: 'rb-1',
|
||||
form_number: 'BX-001',
|
||||
owner_id: 'owner-1',
|
||||
submitter_id: 'submitter-1',
|
||||
department_id: 'dept-1',
|
||||
total_amount: { toString: () => '88.80' },
|
||||
payment_date: '2026-03-08T00:00:00.000Z',
|
||||
paid_at: '2026-03-08T12:00:00.000Z',
|
||||
owner: { legal_entity: { code: 'OWNER_LE' } },
|
||||
submitter: { legal_entity: { code: 'SUBMITTER_LE' } },
|
||||
},
|
||||
});
|
||||
|
||||
expect(payload.dimensions.legalEntityCode).toBe('OWNER_LE');
|
||||
expect(payload.dimensions.costCenterCode).toBeNull();
|
||||
expect(payload.currencyCode).toBe('CNY');
|
||||
});
|
||||
|
||||
it('falls back to submitter legal entity code', () => {
|
||||
const payload = buildReimbursementJournalPayload({
|
||||
reimbursement: {
|
||||
id: 'rb-1',
|
||||
form_number: 'BX-001',
|
||||
owner: { legal_entity: null },
|
||||
submitter: { legal_entity: { code: 'SUBMITTER_LE' } },
|
||||
},
|
||||
});
|
||||
|
||||
expect(payload.dimensions.legalEntityCode).toBe('SUBMITTER_LE');
|
||||
});
|
||||
|
||||
it('builds dedupe key and enqueues standard export event', async () => {
|
||||
const prisma = {
|
||||
integrationOutbox: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
create: vi.fn().mockResolvedValue({ id: 'outbox-1', status: 'pending' }),
|
||||
},
|
||||
};
|
||||
const config = { integrationOutboxMaxAttempts: 5 };
|
||||
const reimbursement = {
|
||||
id: 'rb-1',
|
||||
form_number: 'BX-001',
|
||||
owner_id: 'owner-1',
|
||||
submitter_id: 'submitter-1',
|
||||
department_id: 'dept-1',
|
||||
};
|
||||
|
||||
const result = await enqueueReimbursementJournalExport(prisma, config, {
|
||||
reimbursement,
|
||||
actorId: 'actor-1',
|
||||
});
|
||||
|
||||
expect(buildReimbursementJournalDedupeKey('rb-1')).toBe('reimbursement:rb-1:paid');
|
||||
expect(prisma.integrationOutbox.findUnique).toHaveBeenCalledWith({
|
||||
where: {
|
||||
event_type_dedupe_key: {
|
||||
event_type: 'erp.journal.export',
|
||||
dedupe_key: 'reimbursement:rb-1:paid',
|
||||
},
|
||||
},
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
expect(prisma.integrationOutbox.create).toHaveBeenCalled();
|
||||
expect(result.eventType).toBe('erp.journal.export');
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户