feat: 完成CRM-ERP客户同步管理模块

主要功能:
- 新增同步管理后台界面,支持全局管理(不限租户)
- 实现租户-ERP公司映射配置,支持多公司绑定
- 实现定时同步任务,可配置Cron表达式
- 实现手动同步功能,从ERP拉取客户数据更新CRM
- 支持新增经销商(ERP客户在CRM不存在时)
- 新增同步日志和预警管理

技术实现:
- 新增 crm_sync_log、crm_sync_alert、sys_tenant_company 表
- 使用 TenantHelper.ignore() 实现全局查询(忽略租户过滤)
- ERP动态API支持 ${param} 字符串替换用于IN条件
- 新增公司列表动态API配置

同步流程:
1. 获取租户-公司映射配置
2. 从ERP获取各公司客户列表
3. 匹配CRM已有经销商并更新
4. 未匹配且ERP未停用的客户新增为经销商
5. 生成差异预警(CRM维护字段)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
大壮
2026-05-22 09:40:35 +00:00
parent d42ad5e1e1
commit 5cb9e367df
27 changed files with 2923 additions and 14 deletions

View File

@@ -0,0 +1,586 @@
<script setup lang="ts">
import { h, onMounted, onUnmounted, ref } from 'vue';
import { Page } from '@vben/common-ui';
import {
Button,
Card,
Modal,
Select,
Space,
Switch,
Table,
Tag,
Tabs,
message,
Popconfirm,
Badge,
Statistic,
Row,
Col,
} from 'ant-design-vue';
import { ExclamationCircleOutlined, SyncOutlined, DeleteOutlined } from '@ant-design/icons-vue';
import {
getSyncConfig,
startSyncTask,
stopSyncTask,
setSyncCron,
executeSync,
getSyncLogs,
getSyncAlerts,
resolveAlert,
getSyncStats,
getTenants,
getTenantCompanies,
getErpCompanies,
bindCompany,
unbindCompany,
} from '#/api/crm/sync';
defineOptions({ name: 'CrmSyncManage' });
// 同步执行状态
const syncing = ref(false);
const loading = ref(false);
const logs = ref<any[]>([]);
const alerts = ref<any[]>([]);
const totalLogs = ref(0);
const totalAlerts = ref(0);
const currentPageLogs = ref(1);
const currentPageAlerts = ref(1);
const pageSize = ref(10);
const alertStatusFilter = ref('PENDING');
const pollingTimer = ref<ReturnType<typeof setInterval> | null>(null);
// 统计数据
const stats = ref<any>({
lastSyncTime: null,
lastSyncDuration: null,
lastSyncCount: 0,
lastSyncUpdated: 0,
lastSyncAlerts: 0,
lastSyncErrors: 0,
pendingAlerts: 0,
boundDealers: 0,
});
// 定时任务状态
const taskRunning = ref(false);
const currentCron = ref('0 0 2 * * ?');
const taskLoading = ref(false);
// 租户公司映射(全局管理)
const tenants = ref<any[]>([]);
const tenantCompanies = ref<any[]>([]);
const erpCompanies = ref<any[]>([]);
const companyLoading = ref(false);
const tenantLoading = ref(false);
const selectedTenantId = ref<string>('');
const selectedTenantName = ref<string>('');
const selectedCompanyId = ref<string>('');
const selectedCompanyName = ref<string>('');
// 状态映射
const statusMap: Record<string, { text: string; color: string }> = {
RUNNING: { text: '同步中', color: 'processing' },
COMPLETED: { text: '已完成', color: 'success' },
FAILED: { text: '失败', color: 'error' },
};
const syncTypeMap: Record<string, { text: string; color: string }> = {
SCHEDULED: { text: '定时同步', color: 'blue' },
MANUAL: { text: '手动同步', color: 'orange' },
};
const alertStatusMap: Record<string, { text: string; color: string }> = {
PENDING: { text: '待处理', color: 'warning' },
ACKNOWLEDGED: { text: '已确认', color: 'default' },
RESOLVED: { text: '已处理', color: 'success' },
IGNORED: { text: '已忽略', color: 'default' },
};
const alertTypeMap: Record<string, { text: string }> = {
CONTACT_DIFF: { text: '联系人差异' },
ADDRESS_DIFF: { text: '地址差异' },
STATUS_DIFF: { text: '状态差异' },
};
const cronOptions = [
{ label: '每 1 小时', value: '0 0 * * * ?' },
{ label: '每 2 小时', value: '0 0 0/2 * * ?' },
{ label: '每日凌晨 1 点', value: '0 0 1 * * ?' },
{ label: '每日凌晨 2 点', value: '0 0 2 * * ?' },
{ label: '每日凌晨 3 点', value: '0 0 3 * * ?' },
];
// 同步日志表格列
const logColumns = [
{
title: '状态',
key: 'status',
width: 100,
customRender: ({ record }: any) => {
const info = statusMap[record.status] || { text: '未知', color: 'default' };
return h(Tag, { color: info.color }, () => info.text);
},
},
{
title: '同步类型',
key: 'syncType',
width: 100,
customRender: ({ record }: any) => {
const info = syncTypeMap[record.syncType] || { text: record.syncType, color: 'default' };
return h(Tag, { color: info.color }, () => info.text);
},
},
{ title: '开始时间', dataIndex: 'startTime', key: 'startTime', width: 180 },
{ title: '结束时间', dataIndex: 'endTime', key: 'endTime', width: 180 },
{ title: '耗时(秒)', dataIndex: 'duration', key: 'duration', width: 80 },
{ title: '扫描', dataIndex: 'totalCount', key: 'totalCount', width: 80 },
{ title: '同步', dataIndex: 'syncedCount', key: 'syncedCount', width: 80 },
{ title: '更新', dataIndex: 'updatedCount', key: 'updatedCount', width: 80 },
{ title: '预警', dataIndex: 'alertCount', key: 'alertCount', width: 80 },
{ title: '错误', dataIndex: 'errorCount', key: 'errorCount', width: 80 },
{ title: '操作人', dataIndex: 'operator', key: 'operator', width: 100 },
];
// 预警表格列
const alertColumns = [
{
title: '状态',
key: 'status',
width: 100,
customRender: ({ record }: any) => {
const info = alertStatusMap[record.status] || { text: record.status, color: 'default' };
return h(Tag, { color: info.color }, () => info.text);
},
},
{
title: '类型',
key: 'alertType',
width: 100,
customRender: ({ record }: any) => {
const info = alertTypeMap[record.alertType] || { text: record.alertType };
return info.text;
},
},
{ title: '经销商', dataIndex: 'dealerName', key: 'dealerName', width: 150 },
{ title: 'ERP编码', dataIndex: 'customerCode', key: 'customerCode', width: 120 },
{ title: 'CRM值', dataIndex: 'crmValue', key: 'crmValue', width: 150 },
{ title: 'ERP值', dataIndex: 'erpValue', key: 'erpValue', width: 150 },
{ title: '描述', dataIndex: 'alertMessage', key: 'alertMessage', width: 200 },
{ title: '创建时间', dataIndex: 'createTime', key: 'createTime', width: 180 },
{
title: '操作',
key: 'action',
width: 180,
customRender: ({ record }: any) => {
if (record.status !== 'PENDING') return null;
return h(Space, {}, () => [
h(Button, { type: 'link', size: 'small', onClick: () => handleResolveAlert(record.id, 'acknowledge') }, () => '确认'),
h(Button, { type: 'link', size: 'small', onClick: () => handleResolveAlert(record.id, 'ignore') }, () => '忽略'),
]);
},
},
];
// 手动同步
async function handleExecuteSync() {
Modal.confirm({
title: '确认手动同步',
icon: () => h(ExclamationCircleOutlined),
content: '将同步所有绑定ERP编码的经销商数据后台异步执行。确定继续吗',
okText: '确定同步',
cancelText: '取消',
async onOk() {
syncing.value = true;
try {
await executeSync();
message.success('已启动同步,请查看同步日志');
await loadLogs();
await loadStats();
startPolling();
} catch {
message.error('启动同步失败');
} finally {
syncing.value = false;
}
},
});
}
// 轮询检查同步状态
function startPolling() {
stopPolling();
pollingTimer.value = setInterval(async () => {
await loadLogs();
const runningLog = logs.value.find((l: any) => l.status === 'RUNNING');
if (!runningLog) {
stopPolling();
await loadStats();
}
}, 3000);
}
function stopPolling() {
if (pollingTimer.value) {
clearInterval(pollingTimer.value);
pollingTimer.value = null;
}
}
// 加载同步日志
async function loadLogs() {
loading.value = true;
try {
const res = await getSyncLogs({ pageNum: currentPageLogs.value, pageSize: pageSize.value });
logs.value = res?.rows ?? [];
totalLogs.value = res?.total ?? 0;
} catch (error: any) {
message.error('加载同步日志失败');
} finally {
loading.value = false;
}
}
// 加载预警列表
async function loadAlerts() {
try {
const res = await getSyncAlerts({ pageNum: currentPageAlerts.value, pageSize: pageSize.value, status: alertStatusFilter.value });
alerts.value = res?.rows ?? [];
totalAlerts.value = res?.total ?? 0;
} catch {
message.error('加载预警列表失败');
}
}
// 加载统计数据
async function loadStats() {
try {
const res = await getSyncStats();
stats.value = res ?? {};
} catch { /* ignore */ }
}
// 加载任务状态
async function loadTaskStatus() {
try {
const config = await getSyncConfig();
taskRunning.value = config.enabled;
currentCron.value = config.cron || '0 0 2 * * ?';
} catch { /* ignore */ }
}
// 加载租户公司映射
async function loadTenantCompanies() {
try {
tenantCompanies.value = await getTenantCompanies() ?? [];
} catch {
message.error('加载租户公司映射失败');
}
}
// 加载租户列表
async function loadTenants() {
tenantLoading.value = true;
try {
tenants.value = await getTenants() ?? [];
} catch {
message.error('加载租户列表失败');
} finally {
tenantLoading.value = false;
}
}
// 加载ERP公司列表
async function loadErpCompanies() {
companyLoading.value = true;
try {
erpCompanies.value = await getErpCompanies() ?? [];
} catch {
message.error('加载ERP公司列表失败');
} finally {
companyLoading.value = false;
}
}
// 启动/停止定时任务
async function handleTaskToggle(checked: boolean) {
taskLoading.value = true;
try {
if (checked) {
await startSyncTask();
message.success('定时任务已启动');
} else {
await stopSyncTask();
message.success('定时任务已停止');
}
await loadTaskStatus();
} catch {
message.error(checked ? '启动失败' : '停止失败');
} finally {
taskLoading.value = false;
}
}
// 设置Cron
async function handleCronChange(value: string) {
try {
await setSyncCron(value);
message.success('同步频率已更新');
currentCron.value = value;
} catch {
message.error('更新频率失败');
}
}
// 处理预警
async function handleResolveAlert(alertId: number, action: string) {
try {
await resolveAlert(alertId, action);
message.success('预警已处理');
await loadAlerts();
await loadStats();
} catch {
message.error('处理预警失败');
}
}
// 绑定ERP公司
async function handleBindCompany() {
if (!selectedTenantId.value) {
message.warning('请选择租户');
return;
}
if (!selectedCompanyId.value) {
message.warning('请选择要绑定的ERP公司');
return;
}
try {
await bindCompany(selectedTenantId.value, selectedTenantName.value, selectedCompanyId.value, selectedCompanyName.value);
message.success('绑定成功');
await loadTenantCompanies();
selectedTenantId.value = '';
selectedTenantName.value = '';
selectedCompanyId.value = '';
selectedCompanyName.value = '';
} catch {
message.error('绑定失败');
}
}
// 解绑ERP公司
async function handleUnbindCompany(id: number) {
try {
await unbindCompany(id);
message.success('已解绑');
await loadTenantCompanies();
} catch {
message.error('解绑失败');
}
}
// 选择租户
function handleTenantSelect(value: string) {
selectedTenantId.value = value;
const tenant = tenants.value.find((t: any) => t.tenantId === value);
selectedTenantName.value = tenant?.companyName ?? '';
}
// 选择公司
function handleCompanySelect(value: string) {
selectedCompanyId.value = value;
const company = erpCompanies.value.find((c: any) => c.companyCode === value);
selectedCompanyName.value = company?.companyName ?? '';
}
// 分页处理
function handleLogPageChange(pagination: any) {
currentPageLogs.value = pagination.current ?? 1;
pageSize.value = pagination.pageSize ?? 10;
loadLogs();
}
function handleAlertPageChange(pagination: any) {
currentPageAlerts.value = pagination.current ?? 1;
pageSize.value = pagination.pageSize ?? 10;
loadAlerts();
}
// 预警状态筛选
function handleAlertStatusChange(value: string) {
alertStatusFilter.value = value;
currentPageAlerts.value = 1;
loadAlerts();
}
onMounted(() => {
loadLogs();
loadAlerts();
loadStats();
loadTaskStatus();
loadTenants();
loadTenantCompanies();
loadErpCompanies();
});
onUnmounted(() => {
stopPolling();
});
</script>
<template>
<Page :auto-content-height="true">
<div style="overflow-y: auto; padding: 16px">
<!-- 统计卡片 -->
<Card title="同步概览" :bordered="false" style="margin-bottom: 16px">
<Row :gutter="16">
<Col :span="4">
<Statistic title="绑定经销商" :value="stats.boundDealers" />
</Col>
<Col :span="4">
<Statistic title="待处理预警" :value="stats.pendingAlerts">
<template #suffix>
<Badge v-if="stats.pendingAlerts > 0" color="warning" />
</template>
</Statistic>
</Col>
<Col :span="4">
<Statistic title="最近同步" :value="stats.lastSyncCount" suffix="条" />
</Col>
<Col :span="4">
<Statistic title="更新数量" :value="stats.lastSyncUpdated" suffix="条" />
</Col>
<Col :span="4">
<Statistic title="新增预警" :value="stats.lastSyncAlerts" suffix="条" />
</Col>
<Col :span="4">
<Statistic title="耗时" :value="stats.lastSyncDuration" suffix="秒" />
</Col>
</Row>
</Card>
<!-- 操作区 -->
<Card title="同步操作" :bordered="false" style="margin-bottom: 16px">
<Space>
<Button type="primary" :loading="syncing" @click="handleExecuteSync">
<template #icon><SyncOutlined /></template>
手动同步
</Button>
<span style="margin-left: 24px">启用定时同步</span>
<Switch
v-model:checked="taskRunning"
:loading="taskLoading"
checked-children=""
un-checked-children=""
@change="handleTaskToggle"
/>
<span style="margin-left: 24px">同步频率</span>
<Select
:value="currentCron"
style="width: 180px"
:options="cronOptions"
@change="handleCronChange"
/>
<Tag :color="taskRunning ? 'green' : 'default'">
{{ taskRunning ? '运行中' : '已停止' }}
</Tag>
</Space>
</Card>
<!-- 租户公司映射 -->
<Card title="租户-ERP公司映射" :bordered="false" style="margin-bottom: 16px">
<Space style="margin-bottom: 16px">
<Select
v-model:value="selectedTenantId"
placeholder="选择租户"
style="width: 250px"
show-search
:loading="tenantLoading"
:options="tenants.map((t: any) => ({ label: `${t.companyName} (${t.tenantId})`, value: t.tenantId }))"
@change="handleTenantSelect"
/>
<Select
v-model:value="selectedCompanyId"
placeholder="选择ERP公司"
style="width: 300px"
show-search
:loading="companyLoading"
:options="erpCompanies.map((c: any) => ({ label: `${c.companyName} (${c.companyCode})`, value: c.companyCode }))"
@change="handleCompanySelect"
/>
<Button type="primary" @click="handleBindCompany">绑定</Button>
</Space>
<Table
:columns="[
{ title: '租户编号', dataIndex: 'tenantId', width: 120 },
{ title: '租户名称', dataIndex: 'tenantName', width: 150 },
{ title: 'ERP公司ID', dataIndex: 'erpCompanyId', width: 150 },
{ title: 'ERP公司名称', dataIndex: 'erpCompanyName', width: 200 },
{ title: '状态', dataIndex: 'status', width: 100, customRender: ({ record }: any) => h(Tag, { color: record.status === '0' ? 'green' : 'default' }, () => record.status === '0' ? '正常' : '停用') },
{ title: '操作', key: 'action', width: 100, customRender: ({ record }: any) => h(Popconfirm, { title: '确定解绑?', onConfirm: () => handleUnbindCompany(record.id) }, () => h(Button, { type: 'link', danger: true, size: 'small' }, () => '解绑')) },
]"
:data-source="tenantCompanies"
row-key="id"
:pagination="false"
size="small"
/>
</Card>
<!-- 日志和预警Tab -->
<Card :bordered="false">
<Tabs>
<Tabs.TabPane key="logs" tab="同步日志">
<Table
:columns="logColumns"
:data-source="logs"
:loading="loading"
:pagination="{
current: currentPageLogs,
pageSize: pageSize,
total: totalLogs,
showSizeChanger: true,
showTotal: (t: number) => `共 ${t} 条`,
}"
row-key="id"
@change="handleLogPageChange"
/>
</Tabs.TabPane>
<Tabs.TabPane key="alerts" tab="预警管理">
<Space style="margin-bottom: 16px">
<span>状态筛选</span>
<Select
v-model:value="alertStatusFilter"
style="width: 150px"
:options="[
{ label: '全部', value: '' },
{ label: '待处理', value: 'PENDING' },
{ label: '已确认', value: 'ACKNOWLEDGED' },
{ label: '已处理', value: 'RESOLVED' },
{ label: '已忽略', value: 'IGNORED' },
]"
@change="handleAlertStatusChange"
/>
</Space>
<Table
:columns="alertColumns"
:data-source="alerts"
:loading="loading"
:pagination="{
current: currentPageAlerts,
pageSize: pageSize,
total: totalAlerts,
showSizeChanger: true,
showTotal: (t: number) => `共 ${t} 条`,
}"
row-key="id"
@change="handleAlertPageChange"
/>
</Tabs.TabPane>
</Tabs>
</Card>
</div>
</Page>
</template>