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:
116
hzhub-admin/apps/web-antd/src/api/crm/sync.ts
Normal file
116
hzhub-admin/apps/web-antd/src/api/crm/sync.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
enum Api {
|
||||
config = '/crm/sync/config',
|
||||
start = '/crm/sync/start',
|
||||
stop = '/crm/sync/stop',
|
||||
cron = '/crm/sync/cron',
|
||||
execute = '/crm/sync/execute',
|
||||
logs = '/crm/sync/logs',
|
||||
alerts = '/crm/sync/alerts',
|
||||
stats = '/crm/sync/stats',
|
||||
tenants = '/crm/sync/tenants',
|
||||
tenantCompanies = '/crm/sync/tenant-companies',
|
||||
erpCompanies = '/crm/sync/erp-companies',
|
||||
bindCompany = '/crm/sync/tenant-companies/bind',
|
||||
unbindCompany = '/crm/sync/tenant-companies/unbind',
|
||||
resolveAlert = '/crm/sync/alerts',
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取同步配置
|
||||
*/
|
||||
export function getSyncConfig() {
|
||||
return requestClient.get<{ enabled: boolean; cron: string }>(Api.config);
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动定时任务
|
||||
*/
|
||||
export function startSyncTask() {
|
||||
return requestClient.post<string>(Api.start, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止定时任务
|
||||
*/
|
||||
export function stopSyncTask() {
|
||||
return requestClient.post<string>(Api.stop, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置Cron表达式
|
||||
*/
|
||||
export function setSyncCron(cron: string) {
|
||||
return requestClient.put<string>(Api.cron, null, { params: { cron } });
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动触发同步
|
||||
*/
|
||||
export function executeSync() {
|
||||
return requestClient.post<any>(Api.execute, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询同步日志
|
||||
*/
|
||||
export function getSyncLogs(params: { pageNum: number; pageSize: number }) {
|
||||
return requestClient.get<{ rows: any[]; total: number }>(Api.logs, { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询预警列表
|
||||
*/
|
||||
export function getSyncAlerts(params: { pageNum: number; pageSize: number; status?: string }) {
|
||||
return requestClient.get<{ rows: any[]; total: number }>(Api.alerts, { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理预警
|
||||
*/
|
||||
export function resolveAlert(alertId: number, action: string, note?: string) {
|
||||
return requestClient.put<string>(`${Api.resolveAlert}/${alertId}`, null, { params: { action, note } });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取同步统计数据
|
||||
*/
|
||||
export function getSyncStats() {
|
||||
return requestClient.get<any>(Api.stats);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取租户列表(用于选择器)
|
||||
*/
|
||||
export function getTenants() {
|
||||
return requestClient.get<any[]>(Api.tenants);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有租户的ERP公司映射列表
|
||||
*/
|
||||
export function getTenantCompanies() {
|
||||
return requestClient.get<any[]>(Api.tenantCompanies);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取ERP所有公司列表
|
||||
*/
|
||||
export function getErpCompanies() {
|
||||
return requestClient.get<any[]>(Api.erpCompanies);
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定ERP公司到租户
|
||||
*/
|
||||
export function bindCompany(tenantId: string, tenantName?: string, erpCompanyId?: string, erpCompanyName?: string) {
|
||||
return requestClient.post<string>(Api.bindCompany, null, { params: { tenantId, tenantName, erpCompanyId, erpCompanyName } });
|
||||
}
|
||||
|
||||
/**
|
||||
* 解绑ERP公司
|
||||
*/
|
||||
export function unbindCompany(id: number) {
|
||||
return requestClient.post<string>(`${Api.unbindCompany}/${id}`, {});
|
||||
}
|
||||
586
hzhub-admin/apps/web-antd/src/views/crm/sync/index.vue
Normal file
586
hzhub-admin/apps/web-antd/src/views/crm/sync/index.vue
Normal 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>
|
||||
@@ -79,4 +79,17 @@ public class CustomerController extends BaseController {
|
||||
public R<List<CustomerVO>> brands() {
|
||||
return R.ok(customerService.getBrands());
|
||||
}
|
||||
|
||||
/**
|
||||
* 客户选择列表(用于CRM选择器)
|
||||
* 支持按公司过滤(companyIds逗号分隔)
|
||||
*/
|
||||
@GetMapping("/select")
|
||||
public TableDataInfo<CustomerVO> select(
|
||||
@RequestParam(defaultValue = "1") int pageNum,
|
||||
@RequestParam(defaultValue = "20") int pageSize,
|
||||
@RequestParam(required = false) String keyword,
|
||||
@RequestParam(required = false) String companyIds) {
|
||||
return customerService.queryCustomerSelectList(pageNum, pageSize, keyword, companyIds);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,4 +143,60 @@ public interface CustomerMapper extends BaseMapper<CustomerGeneral> {
|
||||
"WHERE BRAND IS NOT NULL AND BRANDNAME IS NOT NULL " +
|
||||
"ORDER BY BRAND")
|
||||
List<CustomerVO> selectBrands();
|
||||
|
||||
/**
|
||||
* 客户选择列表(用于CRM选择器,支持多公司过滤)
|
||||
*/
|
||||
@Select("<script>" +
|
||||
"SELECT TOP ${pageSize} * FROM (" +
|
||||
" SELECT ROW_NUMBER() OVER (ORDER BY CLTCODE) AS rn, " +
|
||||
" CLTCODE AS customerCode, " +
|
||||
" CLTNAME AS customerName, " +
|
||||
" COMPANY_ID AS companyCode, " +
|
||||
" COMPANY_NAME AS companyName, " +
|
||||
" LINKMAN AS contactName, " +
|
||||
" TEL1 AS phone, " +
|
||||
" province, city, ISSTOP AS isStop " +
|
||||
" FROM SCLTGENERAL " +
|
||||
" WHERE 1=1 " +
|
||||
" <if test='keyword != null and keyword != \"\"'>" +
|
||||
" AND (CLTCODE LIKE '%' + #{keyword} + '%' " +
|
||||
" OR CLTNAME LIKE '%' + #{keyword} + '%' " +
|
||||
" OR LINKMAN LIKE '%' + #{keyword} + '%') " +
|
||||
" </if>" +
|
||||
" <if test='companyIds != null and companyIds != \"\"'>" +
|
||||
" AND COMPANY_ID IN " +
|
||||
" <foreach collection='companyIdList' item='id' open='(' separator=',' close=')'>" +
|
||||
" #{id}" +
|
||||
" </foreach>" +
|
||||
" </if>" +
|
||||
") t WHERE rn > (${pageNum} - 1) * ${pageSize} ORDER BY rn" +
|
||||
"</script>")
|
||||
List<CustomerVO> selectCustomerSelectPage(@Param("pageNum") int pageNum,
|
||||
@Param("pageSize") int pageSize,
|
||||
@Param("keyword") String keyword,
|
||||
@Param("companyIds") String companyIds,
|
||||
@Param("companyIdList") List<String> companyIdList);
|
||||
|
||||
/**
|
||||
* 客户选择总数(用于CRM选择器,支持多公司过滤)
|
||||
*/
|
||||
@Select("<script>" +
|
||||
"SELECT COUNT(*) FROM SCLTGENERAL " +
|
||||
"WHERE 1=1 " +
|
||||
"<if test='keyword != null and keyword != \"\"'>" +
|
||||
" AND (CLTCODE LIKE '%' + #{keyword} + '%' " +
|
||||
" OR CLTNAME LIKE '%' + #{keyword} + '%' " +
|
||||
" OR LINKMAN LIKE '%' + #{keyword} + '%') " +
|
||||
"</if>" +
|
||||
"<if test='companyIds != null and companyIds != \"\"'>" +
|
||||
" AND COMPANY_ID IN " +
|
||||
" <foreach collection='companyIdList' item='id' open='(' separator=',' close=')'>" +
|
||||
" #{id}" +
|
||||
" </foreach>" +
|
||||
"</if>" +
|
||||
"</script>")
|
||||
long selectCustomerSelectCount(@Param("keyword") String keyword,
|
||||
@Param("companyIds") String companyIds,
|
||||
@Param("companyIdList") List<String> companyIdList);
|
||||
}
|
||||
|
||||
@@ -30,4 +30,9 @@ public interface ICustomerService {
|
||||
* 获取所有品牌列表
|
||||
*/
|
||||
List<CustomerVO> getBrands();
|
||||
|
||||
/**
|
||||
* 客户选择列表(用于CRM选择器,支持多公司过滤)
|
||||
*/
|
||||
TableDataInfo<CustomerVO> queryCustomerSelectList(int pageNum, int pageSize, String keyword, String companyIds);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import org.hzhub.erp.mapper.SalesOrganizationMapper;
|
||||
import org.hzhub.erp.service.ICustomerService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
@@ -43,4 +45,17 @@ public class CustomerServiceImpl implements ICustomerService {
|
||||
public List<CustomerVO> getBrands() {
|
||||
return customerMapper.selectBrands();
|
||||
}
|
||||
|
||||
@Override
|
||||
public TableDataInfo<CustomerVO> queryCustomerSelectList(int pageNum, int pageSize, String keyword, String companyIds) {
|
||||
// 解析companyIds为列表
|
||||
List<String> companyIdList = Collections.emptyList();
|
||||
if (companyIds != null && !companyIds.isBlank()) {
|
||||
companyIdList = Arrays.asList(companyIds.split(","));
|
||||
}
|
||||
|
||||
long total = customerMapper.selectCustomerSelectCount(keyword, companyIds, companyIdList);
|
||||
List<CustomerVO> list = customerMapper.selectCustomerSelectPage(pageNum, pageSize, keyword, companyIds, companyIdList);
|
||||
return new TableDataInfo<>(list, total);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,16 +127,47 @@ public class DynamicApiExecutor {
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理SQL模板(将 #{param} 转换为 ? 占位符,并提取参数值)
|
||||
* 处理SQL模板(将 #{param} 转换为 ? 占位符,${param} 直接替换为参数值)
|
||||
*
|
||||
* @param sqlTemplate SQL模板
|
||||
* @param params 参数Map
|
||||
* @return 处理后的SQL和参数值列表
|
||||
*/
|
||||
private ProcessedSql processSqlTemplate(String sqlTemplate, Map<String, Object> params) {
|
||||
// 正则表达式匹配 #{paramName}
|
||||
log.info("处理SQL模板: params={}", params);
|
||||
|
||||
// 1. 先处理 ${param} 字符串替换(用于IN条件等)
|
||||
Pattern stringPattern = Pattern.compile("\\$\\{(\\w+)\\}");
|
||||
Matcher stringMatcher = stringPattern.matcher(sqlTemplate);
|
||||
|
||||
StringBuffer intermediateSql = new StringBuffer();
|
||||
while (stringMatcher.find()) {
|
||||
String paramName = stringMatcher.group(1);
|
||||
Object paramValue = params.get(paramName);
|
||||
|
||||
log.info("处理${}参数: paramName={}, paramValue={}", paramName, paramValue);
|
||||
|
||||
if (paramValue != null) {
|
||||
// 直接替换参数值(字符串拼接)
|
||||
// 安全检查:只允许包含数字、字母、逗号、单引号的值
|
||||
String valueStr = paramValue.toString();
|
||||
if (!isValidStringValue(valueStr)) {
|
||||
throw new SecurityException("参数值包含非法字符: " + paramName);
|
||||
}
|
||||
stringMatcher.appendReplacement(intermediateSql, valueStr);
|
||||
} else {
|
||||
// 参数为空时,替换为空字符串(可能导致SQL语法问题,需注意)
|
||||
log.warn("参数{}为空,替换为空字符串", paramName);
|
||||
stringMatcher.appendReplacement(intermediateSql, "");
|
||||
}
|
||||
}
|
||||
stringMatcher.appendTail(intermediateSql);
|
||||
|
||||
log.info("字符串替换后SQL: {}", intermediateSql);
|
||||
|
||||
// 2. 再处理 #{param} 预编译参数
|
||||
Pattern pattern = Pattern.compile("#\\{(\\w+)\\}");
|
||||
Matcher matcher = pattern.matcher(sqlTemplate);
|
||||
Matcher matcher = pattern.matcher(intermediateSql.toString());
|
||||
|
||||
List<Object> paramValues = new ArrayList<>();
|
||||
StringBuffer processedSql = new StringBuffer();
|
||||
@@ -160,6 +191,14 @@ public class DynamicApiExecutor {
|
||||
return new ProcessedSql(finalSql, paramValues);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证字符串值是否安全(只允许数字、字母、逗号、单引号)
|
||||
*/
|
||||
private boolean isValidStringValue(String value) {
|
||||
// 只允许:数字、字母、逗号、单引号、空格
|
||||
return value.matches("^[0-9a-zA-Z,\\'\\s]+$");
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 WHERE 条件中的动态逻辑(IS NOT NULL THEN ...)
|
||||
* 例如:WHERE #{customerCode} IS NOT NULL THEN customer_code = #{customerCode}
|
||||
|
||||
@@ -2,11 +2,21 @@ package org.hzhub.crm.controller;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.hzhub.common.core.domain.R;
|
||||
import org.hzhub.common.mybatis.core.page.PageQuery;
|
||||
import org.hzhub.common.mybatis.core.page.TableDataInfo;
|
||||
import org.hzhub.crm.domain.vo.CrmDealerVo;
|
||||
import org.hzhub.crm.domain.vo.CrmSyncAlertVo;
|
||||
import org.hzhub.crm.domain.vo.InstantSyncResult;
|
||||
import org.hzhub.crm.service.ICrmDealerService;
|
||||
import org.hzhub.crm.service.CustomerSyncService;
|
||||
import org.hzhub.crm.service.ErpIntegrationService;
|
||||
import org.hzhub.crm.service.TenantCompanyService;
|
||||
import org.hzhub.crm.domain.bo.CrmDealerBo;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* CRM经销商管理 Controller
|
||||
@@ -23,6 +33,9 @@ import java.util.List;
|
||||
public class CrmDealerController {
|
||||
|
||||
private final ICrmDealerService dealerService;
|
||||
private final CustomerSyncService customerSyncService;
|
||||
private final ErpIntegrationService erpIntegrationService;
|
||||
private final TenantCompanyService tenantCompanyService;
|
||||
|
||||
/**
|
||||
* 员工门户经销商选择器列表
|
||||
@@ -33,4 +46,79 @@ public class CrmDealerController {
|
||||
List<CrmDealerVo> dealerList = dealerService.selectDealerList(keyword);
|
||||
return R.ok(dealerList);
|
||||
}
|
||||
|
||||
/**
|
||||
* CRM经销商分页列表
|
||||
*/
|
||||
@GetMapping("/portal/list")
|
||||
public TableDataInfo<CrmDealerVo> portalList(CrmDealerBo dealer, PageQuery pageQuery) {
|
||||
return dealerService.selectPageDealerList(dealer, pageQuery);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取经销商详情(含同步状态)
|
||||
*/
|
||||
@GetMapping("/portal/detail/{dealerId}")
|
||||
public R<CrmDealerVo> portalDetail(@PathVariable Long dealerId) {
|
||||
CrmDealerVo dealer = dealerService.selectDealerById(dealerId);
|
||||
if (dealer != null) {
|
||||
// 检查是否有待处理预警
|
||||
List<CrmSyncAlertVo> alerts = customerSyncService.getPendingAlerts(dealerId);
|
||||
dealer.setHasPendingAlerts(!alerts.isEmpty());
|
||||
}
|
||||
return R.ok(dealer);
|
||||
}
|
||||
|
||||
/**
|
||||
* ERP客户选择器数据(用于线索转化/经销商创建)
|
||||
* 只返回当前租户关联公司下的客户
|
||||
*/
|
||||
@GetMapping("/portal/erp-select")
|
||||
public R<List<Map<String, Object>>> erpSelect(
|
||||
@RequestParam(required = false) String keyword,
|
||||
@RequestParam(defaultValue = "1") int pageNum,
|
||||
@RequestParam(defaultValue = "20") int pageSize) {
|
||||
// 获取当前租户关联的ERP公司ID
|
||||
String companyIds = tenantCompanyService.getCompanyFilterForCurrentTenant();
|
||||
List<Map<String, Object>> customers = erpIntegrationService.getCustomerSelectList(keyword, companyIds);
|
||||
return R.ok(customers);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验ERP客户编码是否存在
|
||||
*/
|
||||
@GetMapping("/portal/validate-code")
|
||||
public R<Boolean> validateCustomerCode(@RequestParam String customerCode) {
|
||||
boolean valid = customerSyncService.validateCustomerCode(customerCode);
|
||||
return R.ok(valid);
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动同步经销商(获取ERP最新信息)
|
||||
*/
|
||||
@PostMapping("/portal/sync/{dealerId}")
|
||||
public R<InstantSyncResult> manualSync(@PathVariable Long dealerId) {
|
||||
InstantSyncResult result = customerSyncService.instantSync(dealerId);
|
||||
return R.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取经销商的待处理预警列表
|
||||
*/
|
||||
@GetMapping("/portal/alerts/{dealerId}")
|
||||
public R<List<CrmSyncAlertVo>> getAlerts(@PathVariable Long dealerId) {
|
||||
List<CrmSyncAlertVo> alerts = customerSyncService.getPendingAlerts(dealerId);
|
||||
return R.ok(alerts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理预警
|
||||
*/
|
||||
@PutMapping("/portal/alerts/{alertId}")
|
||||
public R<Void> resolveAlert(@PathVariable Long alertId,
|
||||
@RequestParam String action,
|
||||
@RequestParam(required = false) String note) {
|
||||
customerSyncService.resolveAlert(alertId, action, note);
|
||||
return R.ok();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package org.hzhub.crm.controller;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.hzhub.common.core.domain.R;
|
||||
import org.hzhub.common.mybatis.core.page.PageQuery;
|
||||
import org.hzhub.common.mybatis.core.page.TableDataInfo;
|
||||
import org.hzhub.crm.domain.vo.CrmSyncAlertVo;
|
||||
import org.hzhub.crm.domain.vo.CrmSyncLogVo;
|
||||
import org.hzhub.crm.service.CustomerSyncService;
|
||||
import org.hzhub.crm.task.CustomerSyncTask;
|
||||
import org.hzhub.system.domain.SysTenantCompany;
|
||||
import org.hzhub.system.domain.vo.SysTenantVo;
|
||||
import org.hzhub.system.service.ISysTenantService;
|
||||
import org.hzhub.crm.service.TenantCompanyService;
|
||||
import org.hzhub.crm.service.ErpIntegrationService;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* CRM同步管理 Controller
|
||||
* 管理后台版本(需要Sa-Token权限注解)
|
||||
*
|
||||
* @author hzhub
|
||||
*/
|
||||
@Validated
|
||||
@RequiredArgsConstructor
|
||||
@RestController
|
||||
@RequestMapping("/sync")
|
||||
public class CrmSyncController {
|
||||
|
||||
private final CustomerSyncService customerSyncService;
|
||||
private final CustomerSyncTask customerSyncTask;
|
||||
private final TenantCompanyService tenantCompanyService;
|
||||
private final ErpIntegrationService erpIntegrationService;
|
||||
private final ISysTenantService sysTenantService;
|
||||
|
||||
/**
|
||||
* 获取同步配置
|
||||
*/
|
||||
@SaCheckPermission("crm:sync:config")
|
||||
@GetMapping("/config")
|
||||
public R<Map<String, Object>> getConfig() {
|
||||
Map<String, Object> config = new java.util.HashMap<>();
|
||||
config.put("enabled", customerSyncTask.isRunning());
|
||||
config.put("cron", customerSyncTask.getCurrentCron());
|
||||
return R.ok(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动定时任务
|
||||
*/
|
||||
@SaCheckPermission("crm:sync:config")
|
||||
@PostMapping("/start")
|
||||
public R<Void> start() {
|
||||
customerSyncTask.start();
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止定时任务
|
||||
*/
|
||||
@SaCheckPermission("crm:sync:config")
|
||||
@PostMapping("/stop")
|
||||
public R<Void> stop() {
|
||||
customerSyncTask.stop();
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置Cron表达式
|
||||
*/
|
||||
@SaCheckPermission("crm:sync:config")
|
||||
@PutMapping("/cron")
|
||||
public R<Void> setCron(@RequestParam String cron) {
|
||||
customerSyncTask.setCron(cron);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动触发全量同步
|
||||
*/
|
||||
@SaCheckPermission("crm:sync:execute")
|
||||
@PostMapping("/execute")
|
||||
public R<CrmSyncLogVo> execute() {
|
||||
CrmSyncLogVo log = new CrmSyncLogVo();
|
||||
org.hzhub.crm.domain.CrmSyncLog syncLog = customerSyncTask.executeManualSync();
|
||||
if (syncLog != null) {
|
||||
log.setId(syncLog.getId());
|
||||
log.setStatus(syncLog.getStatus());
|
||||
log.setTotalCount(syncLog.getTotalCount());
|
||||
log.setSyncedCount(syncLog.getSyncedCount());
|
||||
log.setUpdatedCount(syncLog.getUpdatedCount());
|
||||
log.setAlertCount(syncLog.getAlertCount());
|
||||
log.setErrorCount(syncLog.getErrorCount());
|
||||
log.setStartTime(syncLog.getStartTime());
|
||||
log.setEndTime(syncLog.getEndTime());
|
||||
log.setDuration(syncLog.getDuration());
|
||||
}
|
||||
return R.ok(log);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步日志列表
|
||||
*/
|
||||
@SaCheckPermission("crm:sync:log")
|
||||
@GetMapping("/logs")
|
||||
public TableDataInfo<CrmSyncLogVo> getLogs(PageQuery pageQuery) {
|
||||
return customerSyncService.listSyncLogs(pageQuery);
|
||||
}
|
||||
|
||||
/**
|
||||
* 预警列表(全局)
|
||||
*/
|
||||
@SaCheckPermission("crm:sync:alert")
|
||||
@GetMapping("/alerts")
|
||||
public TableDataInfo<CrmSyncAlertVo> getAlerts(
|
||||
@RequestParam(required = false) String status,
|
||||
PageQuery pageQuery) {
|
||||
return customerSyncService.listAlerts(status, pageQuery);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理预警
|
||||
*/
|
||||
@SaCheckPermission("crm:sync:alert")
|
||||
@PutMapping("/alerts/{alertId}")
|
||||
public R<Void> resolveAlert(@PathVariable Long alertId,
|
||||
@RequestParam String action,
|
||||
@RequestParam(required = false) String note) {
|
||||
customerSyncService.resolveAlert(alertId, action, note);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步统计面板数据
|
||||
*/
|
||||
@SaCheckPermission("crm:sync:stats")
|
||||
@GetMapping("/stats")
|
||||
public R<Map<String, Object>> getStats() {
|
||||
return R.ok(customerSyncService.getSyncStats());
|
||||
}
|
||||
|
||||
// ========== 租户-公司映射管理(全局) ==========
|
||||
|
||||
/**
|
||||
* 获取租户列表(用于选择器)
|
||||
*/
|
||||
@SaCheckPermission("crm:sync:company")
|
||||
@GetMapping("/tenants")
|
||||
public R<List<SysTenantVo>> getTenants() {
|
||||
return R.ok(sysTenantService.queryList(new org.hzhub.system.domain.bo.SysTenantBo()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有租户的ERP公司映射列表(全局管理)
|
||||
*/
|
||||
@SaCheckPermission("crm:sync:company")
|
||||
@GetMapping("/tenant-companies")
|
||||
public R<List<SysTenantCompany>> getAllTenantCompanies() {
|
||||
return R.ok(tenantCompanyService.getAllTenantCompanies());
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定ERP公司到租户(全局管理)
|
||||
*/
|
||||
@SaCheckPermission("crm:sync:company")
|
||||
@PostMapping("/tenant-companies/bind")
|
||||
public R<Void> bindCompany(@RequestParam String tenantId,
|
||||
@RequestParam(required = false) String tenantName,
|
||||
@RequestParam String erpCompanyId,
|
||||
@RequestParam(required = false) String erpCompanyName) {
|
||||
tenantCompanyService.bindCompany(tenantId, tenantName, erpCompanyId, erpCompanyName);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 解绑ERP公司
|
||||
*/
|
||||
@SaCheckPermission("crm:sync:company")
|
||||
@PostMapping("/tenant-companies/unbind/{id}")
|
||||
public R<Void> unbindCompany(@PathVariable Long id) {
|
||||
tenantCompanyService.unbindCompany(id);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取ERP所有公司列表(用于选择器)
|
||||
*/
|
||||
@SaCheckPermission("crm:sync:company")
|
||||
@GetMapping("/erp-companies")
|
||||
public R<List<Map<String, Object>>> getErpCompanies() {
|
||||
return R.ok(erpIntegrationService.getCompanyList());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package org.hzhub.crm.domain;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* CRM同步预警对象 crm_sync_alert
|
||||
*
|
||||
* @author hzhub
|
||||
*/
|
||||
@Data
|
||||
@TableName("crm_sync_alert")
|
||||
public class CrmSyncAlert {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 主键ID
|
||||
*/
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 租户编号
|
||||
*/
|
||||
private String tenantId;
|
||||
|
||||
/**
|
||||
* 关联同步日志ID
|
||||
*/
|
||||
private Long syncLogId;
|
||||
|
||||
/**
|
||||
* 经销商ID
|
||||
*/
|
||||
private Long dealerId;
|
||||
|
||||
/**
|
||||
* ERP客户编码
|
||||
*/
|
||||
private String customerCode;
|
||||
|
||||
/**
|
||||
* 预警类型: CONTACT_DIFF联系人差异/ADDRESS_DIFF地址详情差异/STATUS_DIFF状态差异
|
||||
*/
|
||||
private String alertType;
|
||||
|
||||
/**
|
||||
* CRM当前值(JSON格式)
|
||||
*/
|
||||
private String crmValue;
|
||||
|
||||
/**
|
||||
* ERP当前值(JSON格式)
|
||||
*/
|
||||
private String erpValue;
|
||||
|
||||
/**
|
||||
* 预警描述
|
||||
*/
|
||||
private String alertMessage;
|
||||
|
||||
/**
|
||||
* 处理状态: PENDING待处理/ACKNOWLEDGED已确认/RESOLVED已处理/IGNORED已忽略
|
||||
*/
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 处理人ID
|
||||
*/
|
||||
private Long resolvedBy;
|
||||
|
||||
/**
|
||||
* 处理时间
|
||||
*/
|
||||
private Date resolvedTime;
|
||||
|
||||
/**
|
||||
* 处理备注
|
||||
*/
|
||||
private String resolvedNote;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
private Date createTime;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package org.hzhub.crm.domain;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.hzhub.common.tenant.core.TenantEntity;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* CRM同步日志对象 crm_sync_log
|
||||
*
|
||||
* @author hzhub
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@TableName("crm_sync_log")
|
||||
public class CrmSyncLog extends TenantEntity {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 主键ID
|
||||
*/
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 同步类型: SCHEDULED定时/MANUAL手动/ON_EDIT编辑触发
|
||||
*/
|
||||
private String syncType;
|
||||
|
||||
/**
|
||||
* 同步方向: ERP_TO_CRM
|
||||
*/
|
||||
private String syncDirection;
|
||||
|
||||
/**
|
||||
* 同步状态: RUNNING运行中/COMPLETED已完成/FAILED失败
|
||||
*/
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 开始时间
|
||||
*/
|
||||
private Date startTime;
|
||||
|
||||
/**
|
||||
* 结束时间
|
||||
*/
|
||||
private Date endTime;
|
||||
|
||||
/**
|
||||
* 耗时(秒)
|
||||
*/
|
||||
private Integer duration;
|
||||
|
||||
/**
|
||||
* 扫描总数(绑定customerCode的经销商)
|
||||
*/
|
||||
private Integer totalCount;
|
||||
|
||||
/**
|
||||
* 已同步数量
|
||||
*/
|
||||
private Integer syncedCount;
|
||||
|
||||
/**
|
||||
* 更新数量(ERP覆盖字段)
|
||||
*/
|
||||
private Integer updatedCount;
|
||||
|
||||
/**
|
||||
* 预警数量(CRM差异字段)
|
||||
*/
|
||||
private Integer alertCount;
|
||||
|
||||
/**
|
||||
* 错误数量
|
||||
*/
|
||||
private Integer errorCount;
|
||||
|
||||
/**
|
||||
* 错误信息
|
||||
*/
|
||||
private String errorMsg;
|
||||
|
||||
/**
|
||||
* 操作人
|
||||
*/
|
||||
private String operator;
|
||||
}
|
||||
@@ -175,4 +175,9 @@ public class CrmDealerVo implements Serializable {
|
||||
* 更新时间
|
||||
*/
|
||||
private Date updateTime;
|
||||
|
||||
/**
|
||||
* 是否有待处理预警(扩展字段,非数据库字段)
|
||||
*/
|
||||
private Boolean hasPendingAlerts;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package org.hzhub.crm.domain.vo;
|
||||
|
||||
import io.github.linpeilie.annotations.AutoMapper;
|
||||
import lombok.Data;
|
||||
import org.hzhub.crm.domain.CrmSyncAlert;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* CRM同步预警视图对象
|
||||
*
|
||||
* @author hzhub
|
||||
*/
|
||||
@Data
|
||||
@AutoMapper(target = CrmSyncAlert.class)
|
||||
public class CrmSyncAlertVo {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private Long id;
|
||||
private String tenantId;
|
||||
private Long syncLogId;
|
||||
private Long dealerId;
|
||||
private String customerCode;
|
||||
private String alertType;
|
||||
private String crmValue;
|
||||
private String erpValue;
|
||||
private String alertMessage;
|
||||
private String status;
|
||||
private Long resolvedBy;
|
||||
private Date resolvedTime;
|
||||
private String resolvedNote;
|
||||
private Date createTime;
|
||||
|
||||
// 扩展字段
|
||||
private String dealerName;
|
||||
private String alertTypeName;
|
||||
private String statusName;
|
||||
private String resolvedByName;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package org.hzhub.crm.domain.vo;
|
||||
|
||||
import io.github.linpeilie.annotations.AutoMapper;
|
||||
import lombok.Data;
|
||||
import org.hzhub.crm.domain.CrmSyncLog;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* CRM同步日志视图对象
|
||||
*
|
||||
* @author hzhub
|
||||
*/
|
||||
@Data
|
||||
@AutoMapper(target = CrmSyncLog.class)
|
||||
public class CrmSyncLogVo {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private Long id;
|
||||
private String tenantId;
|
||||
private String syncType;
|
||||
private String syncDirection;
|
||||
private String status;
|
||||
private Date startTime;
|
||||
private Date endTime;
|
||||
private Integer duration;
|
||||
private Integer totalCount;
|
||||
private Integer syncedCount;
|
||||
private Integer updatedCount;
|
||||
private Integer alertCount;
|
||||
private Integer errorCount;
|
||||
private String errorMsg;
|
||||
private String operator;
|
||||
private Date createTime;
|
||||
|
||||
// 扩展字段
|
||||
private String syncTypeName;
|
||||
private String statusName;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package org.hzhub.crm.domain.vo;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 即时同步结果
|
||||
*
|
||||
* @author hzhub
|
||||
*/
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public class InstantSyncResult {
|
||||
|
||||
/**
|
||||
* 是否成功
|
||||
*/
|
||||
private boolean success;
|
||||
|
||||
/**
|
||||
* 消息
|
||||
*/
|
||||
private String message;
|
||||
|
||||
/**
|
||||
* 是否有更新(ERP覆盖字段)
|
||||
*/
|
||||
private boolean updated;
|
||||
|
||||
/**
|
||||
* 预警列表(CRM差异字段)
|
||||
*/
|
||||
private List<CrmSyncAlertVo> alerts;
|
||||
|
||||
/**
|
||||
* ERP客户信息
|
||||
*/
|
||||
private Map<String, Object> erpInfo;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.hzhub.crm.mapper;
|
||||
|
||||
import org.hzhub.common.mybatis.core.mapper.BaseMapperPlus;
|
||||
import org.hzhub.crm.domain.CrmSyncAlert;
|
||||
import org.hzhub.crm.domain.vo.CrmSyncAlertVo;
|
||||
|
||||
/**
|
||||
* CRM同步预警Mapper
|
||||
*
|
||||
* @author hzhub
|
||||
*/
|
||||
public interface CrmSyncAlertMapper extends BaseMapperPlus<CrmSyncAlert, CrmSyncAlertVo> {
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.hzhub.crm.mapper;
|
||||
|
||||
import org.hzhub.common.mybatis.core.mapper.BaseMapperPlus;
|
||||
import org.hzhub.crm.domain.CrmSyncLog;
|
||||
import org.hzhub.crm.domain.vo.CrmSyncLogVo;
|
||||
|
||||
/**
|
||||
* CRM同步日志Mapper
|
||||
*
|
||||
* @author hzhub
|
||||
*/
|
||||
public interface CrmSyncLogMapper extends BaseMapperPlus<CrmSyncLog, CrmSyncLogVo> {
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
package org.hzhub.crm.service;
|
||||
|
||||
import cn.hutool.core.bean.BeanUtil;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.hzhub.common.core.constant.SystemConstants;
|
||||
import org.hzhub.common.core.exception.ServiceException;
|
||||
import org.hzhub.common.core.utils.StringUtils;
|
||||
import org.hzhub.common.mybatis.core.page.PageQuery;
|
||||
import org.hzhub.common.mybatis.core.page.TableDataInfo;
|
||||
import org.hzhub.common.satoken.utils.LoginHelper;
|
||||
import org.hzhub.common.tenant.helper.TenantHelper;
|
||||
import org.hzhub.crm.domain.CrmDealer;
|
||||
import org.hzhub.crm.domain.CrmSyncAlert;
|
||||
import org.hzhub.crm.domain.CrmSyncLog;
|
||||
import org.hzhub.crm.domain.vo.CrmSyncAlertVo;
|
||||
import org.hzhub.crm.domain.vo.CrmSyncLogVo;
|
||||
import org.hzhub.crm.domain.vo.InstantSyncResult;
|
||||
import org.hzhub.crm.mapper.CrmDealerMapper;
|
||||
import org.hzhub.crm.mapper.CrmSyncAlertMapper;
|
||||
import org.hzhub.crm.mapper.CrmSyncLogMapper;
|
||||
import org.hzhub.system.domain.SysTenantCompany;
|
||||
import org.hzhub.system.domain.SysUser;
|
||||
import org.hzhub.system.mapper.SysUserMapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* CRM-ERP客户同步服务
|
||||
*
|
||||
* 同步流程:
|
||||
* 1. 创建同步日志
|
||||
* 2. 获取所有ERP公司ID列表(从租户-公司映射配置,不限当前租户)
|
||||
* 3. 获取ERP系统中这些公司ID下的所有客户清单
|
||||
* 4. 扫描CRM中所有绑定了customerCode的经销商(用于匹配)
|
||||
* 5. 逐个ERP客户同步:
|
||||
* - CRM有匹配(customerCode相同) → 更新CRM字段
|
||||
* - CRM无匹配且ERP未停用 → 新增经销商记录
|
||||
* 6. 更新同步日志完成状态
|
||||
*
|
||||
* @author hzhub
|
||||
*/
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
@Service
|
||||
public class CustomerSyncService {
|
||||
|
||||
private final CrmDealerMapper dealerMapper;
|
||||
private final CrmSyncLogMapper syncLogMapper;
|
||||
private final CrmSyncAlertMapper alertMapper;
|
||||
private final ErpIntegrationService erpIntegrationService;
|
||||
private final TenantCompanyService tenantCompanyService;
|
||||
private final SysUserMapper userMapper;
|
||||
|
||||
/**
|
||||
* 执行同步(定时任务或手动触发)
|
||||
*
|
||||
* @param syncType 同步类型 SCHEDULED/MANUAL
|
||||
* @param operator 操作人
|
||||
* @return 同步日志
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public CrmSyncLog executeSync(String syncType, String operator) {
|
||||
// 1. 创建同步日志
|
||||
CrmSyncLog syncLog = createSyncLog(syncType, operator);
|
||||
|
||||
try {
|
||||
// 2. 获取所有ERP公司ID列表(从租户-公司映射配置,不限当前租户)
|
||||
List<SysTenantCompany> tenantCompanies = tenantCompanyService.getAllTenantCompanies();
|
||||
List<String> companyIds = tenantCompanies.stream()
|
||||
.map(SysTenantCompany::getErpCompanyId)
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// 构建公司ID -> 租户ID的映射(用于新增经销商时确定租户归属)
|
||||
Map<String, String> companyToTenantMap = tenantCompanies.stream()
|
||||
.filter(tc -> StringUtils.isNotBlank(tc.getErpCompanyId()) && StringUtils.isNotBlank(tc.getTenantId()))
|
||||
.collect(Collectors.toMap(
|
||||
SysTenantCompany::getErpCompanyId,
|
||||
SysTenantCompany::getTenantId,
|
||||
(v1, v2) -> v1 // 如果同一公司映射多个租户,取第一个
|
||||
));
|
||||
|
||||
if (companyIds.isEmpty()) {
|
||||
log.warn("未配置租户-公司映射,同步终止");
|
||||
syncLog.setStatus("COMPLETED");
|
||||
syncLog.setErrorMsg("未配置租户-公司映射");
|
||||
syncLog.setTotalCount(0);
|
||||
finishSyncLog(syncLog);
|
||||
return syncLog;
|
||||
}
|
||||
|
||||
// 3. 获取ERP系统中这些公司ID下的所有客户清单
|
||||
String companyIdsParam = companyIds.stream().collect(Collectors.joining(","));
|
||||
List<Map<String, Object>> erpCustomers = erpIntegrationService.getCustomerSelectList(null, companyIdsParam);
|
||||
syncLog.setTotalCount(erpCustomers.size());
|
||||
log.info("获取ERP客户清单: companyIds={}, count={}", companyIdsParam, erpCustomers.size());
|
||||
|
||||
// 4. 扫描CRM中所有绑定了customerCode的经销商(忽略租户过滤)
|
||||
Map<String, CrmDealer> crmDealerMap = TenantHelper.ignore(() -> {
|
||||
List<CrmDealer> dealers = dealerMapper.selectList(
|
||||
new LambdaQueryWrapper<CrmDealer>()
|
||||
.eq(CrmDealer::getDelFlag, SystemConstants.NORMAL)
|
||||
.isNotNull(CrmDealer::getCustomerCode)
|
||||
.ne(CrmDealer::getCustomerCode, "")
|
||||
);
|
||||
return dealers.stream()
|
||||
.collect(Collectors.toMap(CrmDealer::getCustomerCode, d -> d, (d1, d2) -> d1));
|
||||
});
|
||||
|
||||
// 5. 预加载用户昵称 -> 用户ID映射(用于匹配负责人)
|
||||
Map<String, Long> nicknameToUserIdMap = loadUserNicknameMap();
|
||||
|
||||
int syncedCount = 0, updatedCount = 0, newCount = 0, alertCount = 0, errorCount = 0;
|
||||
|
||||
// 6. 逐个ERP客户同步
|
||||
for (Map<String, Object> erpCustomer : erpCustomers) {
|
||||
try {
|
||||
String customerCode = (String) erpCustomer.get("customerCode");
|
||||
if (StringUtils.isBlank(customerCode)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Integer isStop = (Integer) erpCustomer.get("isStop");
|
||||
boolean isErpStopped = isStop != null && isStop == 1;
|
||||
|
||||
CrmDealer existingDealer = crmDealerMap.get(customerCode);
|
||||
|
||||
if (existingDealer != null) {
|
||||
// CRM已有匹配记录 -> 更新
|
||||
SyncResult result = syncExistingDealer(existingDealer, erpCustomer, syncLog.getId());
|
||||
syncedCount++;
|
||||
if (result.updated) updatedCount++;
|
||||
if (result.hasAlert) alertCount++;
|
||||
} else if (!isErpStopped) {
|
||||
// CRM无匹配且ERP未停用 -> 新增
|
||||
String erpCompanyCode = (String) erpCustomer.get("companyCode");
|
||||
String targetTenantId = companyToTenantMap.get(erpCompanyCode);
|
||||
|
||||
if (StringUtils.isBlank(targetTenantId)) {
|
||||
log.warn("ERP客户{}的公司{}未找到租户映射,跳过新增", customerCode, erpCompanyCode);
|
||||
continue;
|
||||
}
|
||||
|
||||
SyncResult result = createNewDealer(erpCustomer, targetTenantId, nicknameToUserIdMap);
|
||||
if (result.created) newCount++;
|
||||
syncedCount++;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("同步ERP客户失败: customerCode={}", erpCustomer.get("customerCode"), e);
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 7. 更新日志完成状态
|
||||
syncLog.setStatus("COMPLETED");
|
||||
syncLog.setSyncedCount(syncedCount);
|
||||
syncLog.setUpdatedCount(updatedCount);
|
||||
syncLog.setAlertCount(alertCount);
|
||||
syncLog.setErrorCount(errorCount);
|
||||
|
||||
log.info("同步完成: total={}, synced={}, updated={}, new={}, alerts={}, errors={}",
|
||||
syncLog.getTotalCount(), syncedCount, updatedCount, newCount, alertCount, errorCount);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("同步执行失败", e);
|
||||
syncLog.setStatus("FAILED");
|
||||
syncLog.setErrorMsg(e.getMessage());
|
||||
}
|
||||
|
||||
finishSyncLog(syncLog);
|
||||
return syncLog;
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步已存在的经销商(更新模式)
|
||||
*/
|
||||
private SyncResult syncExistingDealer(CrmDealer dealer, Map<String, Object> erpCustomer, Long syncLogId) {
|
||||
SyncResult result = new SyncResult();
|
||||
boolean needsUpdate = false;
|
||||
|
||||
// ERP覆盖字段同步
|
||||
// 名称
|
||||
String erpName = (String) erpCustomer.get("customerName");
|
||||
if (StringUtils.isNotBlank(erpName) && !Objects.equals(dealer.getDealerName(), erpName)) {
|
||||
dealer.setDealerName(erpName);
|
||||
needsUpdate = true;
|
||||
result.updated = true;
|
||||
}
|
||||
|
||||
// 省份
|
||||
String erpProvince = (String) erpCustomer.get("province");
|
||||
if (StringUtils.isNotBlank(erpProvince) && !Objects.equals(dealer.getProvince(), erpProvince)) {
|
||||
dealer.setProvince(erpProvince);
|
||||
needsUpdate = true;
|
||||
result.updated = true;
|
||||
}
|
||||
|
||||
// 城市
|
||||
String erpCity = (String) erpCustomer.get("city");
|
||||
if (StringUtils.isNotBlank(erpCity) && !Objects.equals(dealer.getCity(), erpCity)) {
|
||||
dealer.setCity(erpCity);
|
||||
needsUpdate = true;
|
||||
result.updated = true;
|
||||
}
|
||||
|
||||
// 状态(ERP停用映射到CRM生命周期)
|
||||
Integer erpIsStop = (Integer) erpCustomer.get("isStop");
|
||||
String targetLifecycle = (erpIsStop != null && erpIsStop == 1) ? "churn" : "active";
|
||||
if (!Objects.equals(dealer.getLifecycle(), targetLifecycle)) {
|
||||
dealer.setLifecycle(targetLifecycle);
|
||||
needsUpdate = true;
|
||||
result.updated = true;
|
||||
}
|
||||
|
||||
// CRM维护字段差异检测 -> 生成预警
|
||||
checkAndCreateAlert(dealer, erpCustomer, "contactName", "CONTACT_DIFF", syncLogId, result);
|
||||
checkAndCreateAlert(dealer, erpCustomer, "phone", "CONTACT_DIFF", syncLogId, result);
|
||||
|
||||
// 执行更新(忽略租户过滤)
|
||||
if (needsUpdate) {
|
||||
dealer.setUpdateTime(new Date());
|
||||
TenantHelper.ignore(() -> dealerMapper.updateById(dealer));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增经销商
|
||||
*/
|
||||
private SyncResult createNewDealer(Map<String, Object> erpCustomer, String tenantId, Map<String, Long> nicknameToUserIdMap) {
|
||||
SyncResult result = new SyncResult();
|
||||
|
||||
CrmDealer dealer = new CrmDealer();
|
||||
dealer.setTenantId(tenantId);
|
||||
dealer.setCustomerCode((String) erpCustomer.get("customerCode"));
|
||||
dealer.setDealerName((String) erpCustomer.get("customerName"));
|
||||
dealer.setDealerCode((String) erpCustomer.get("customerCode")); // 暂用customerCode作为dealerCode
|
||||
dealer.setContactName((String) erpCustomer.get("contactName"));
|
||||
dealer.setMobile((String) erpCustomer.get("phone"));
|
||||
dealer.setProvince((String) erpCustomer.get("province"));
|
||||
dealer.setCity((String) erpCustomer.get("city"));
|
||||
dealer.setLifecycle("active"); // 新增默认为活跃状态(ERP未停用的才会新增)
|
||||
dealer.setDelFlag(0); // 正常状态
|
||||
dealer.setCreateTime(new Date());
|
||||
|
||||
// 匹配负责人(ERP的salesName匹配用户昵称)
|
||||
String salesName = (String) erpCustomer.get("salesPersonName");
|
||||
if (StringUtils.isNotBlank(salesName) && nicknameToUserIdMap.containsKey(salesName)) {
|
||||
dealer.setOwnerUserId(nicknameToUserIdMap.get(salesName));
|
||||
}
|
||||
|
||||
// 在指定租户下插入(使用动态租户)
|
||||
TenantHelper.dynamic(tenantId, () -> dealerMapper.insert(dealer));
|
||||
|
||||
result.created = true;
|
||||
log.info("新增经销商: tenantId={}, customerCode={}, dealerName={}", tenantId, dealer.getCustomerCode(), dealer.getDealerName());
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载用户昵称 -> 用户ID映射
|
||||
*/
|
||||
private Map<String, Long> loadUserNicknameMap() {
|
||||
return TenantHelper.ignore(() -> {
|
||||
List<SysUser> users = userMapper.selectList(
|
||||
new LambdaQueryWrapper<SysUser>()
|
||||
.eq(SysUser::getStatus, SystemConstants.NORMAL)
|
||||
.isNotNull(SysUser::getNickName)
|
||||
);
|
||||
return users.stream()
|
||||
.filter(u -> StringUtils.isNotBlank(u.getNickName()))
|
||||
.collect(Collectors.toMap(
|
||||
SysUser::getNickName,
|
||||
SysUser::getUserId,
|
||||
(v1, v2) -> v1 // 昵称相同取第一个
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测差异并生成预警
|
||||
*/
|
||||
private void checkAndCreateAlert(CrmDealer dealer, Map<String, Object> erpCustomer,
|
||||
String fieldName, String alertType, Long syncLogId, SyncResult result) {
|
||||
String crmValue = getDealerFieldValue(dealer, fieldName);
|
||||
String erpValue = getErpFieldValue(erpCustomer, fieldName);
|
||||
|
||||
// CRM有值且与ERP不一致时生成预警
|
||||
if (StringUtils.isNotBlank(crmValue) && !Objects.equals(crmValue, erpValue)) {
|
||||
CrmSyncAlert alert = new CrmSyncAlert();
|
||||
alert.setTenantId(dealer.getTenantId());
|
||||
alert.setSyncLogId(syncLogId);
|
||||
alert.setDealerId(dealer.getDealerId());
|
||||
alert.setCustomerCode(dealer.getCustomerCode());
|
||||
alert.setAlertType(alertType);
|
||||
alert.setCrmValue(crmValue);
|
||||
alert.setErpValue(erpValue != null ? erpValue : "");
|
||||
alert.setAlertMessage(buildAlertMessage(fieldName, crmValue, erpValue));
|
||||
alert.setStatus("PENDING");
|
||||
alert.setCreateTime(new Date());
|
||||
|
||||
alertMapper.insert(alert);
|
||||
result.hasAlert = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 即时同步(编辑时调用)
|
||||
*/
|
||||
public InstantSyncResult instantSync(Long dealerId) {
|
||||
CrmDealer dealer = TenantHelper.ignore(() -> dealerMapper.selectById(dealerId));
|
||||
if (dealer == null) {
|
||||
return new InstantSyncResult(false, "经销商不存在", false, null, null);
|
||||
}
|
||||
|
||||
if (StringUtils.isBlank(dealer.getCustomerCode())) {
|
||||
return new InstantSyncResult(false, "未绑定ERP客户编码", false, null, null);
|
||||
}
|
||||
|
||||
Map<String, Object> erpCustomer = erpIntegrationService.getCustomerDetail(dealer.getCustomerCode());
|
||||
if (erpCustomer == null) {
|
||||
return new InstantSyncResult(false, "ERP客户不存在: " + dealer.getCustomerCode(), false, null, null);
|
||||
}
|
||||
|
||||
// 执行同步
|
||||
SyncResult result = syncExistingDealer(dealer, erpCustomer, null);
|
||||
|
||||
// 获取预警列表
|
||||
List<CrmSyncAlertVo> alerts = getPendingAlerts(dealerId);
|
||||
|
||||
return new InstantSyncResult(
|
||||
true,
|
||||
result.updated ? "同步成功,已更新ERP最新信息" : "数据已是最新",
|
||||
result.updated,
|
||||
alerts,
|
||||
erpCustomer
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验ERP客户编码是否有效
|
||||
*/
|
||||
public boolean validateCustomerCode(String customerCode) {
|
||||
if (StringUtils.isBlank(customerCode)) {
|
||||
return false;
|
||||
}
|
||||
Map<String, Object> customer = erpIntegrationService.getCustomerDetail(customerCode);
|
||||
return customer != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取经销商待处理预警
|
||||
*/
|
||||
public List<CrmSyncAlertVo> getPendingAlerts(Long dealerId) {
|
||||
LambdaQueryWrapper<CrmSyncAlert> wrapper = new LambdaQueryWrapper<>();
|
||||
wrapper.eq(CrmSyncAlert::getDealerId, dealerId)
|
||||
.eq(CrmSyncAlert::getStatus, "PENDING")
|
||||
.orderByDesc(CrmSyncAlert::getCreateTime);
|
||||
|
||||
List<CrmSyncAlert> alerts = alertMapper.selectList(wrapper);
|
||||
return alerts.stream()
|
||||
.map(a -> BeanUtil.copyProperties(a, CrmSyncAlertVo.class))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理预警
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void resolveAlert(Long alertId, String action, String note) {
|
||||
CrmSyncAlert alert = alertMapper.selectById(alertId);
|
||||
if (alert == null) {
|
||||
throw new ServiceException("预警不存在");
|
||||
}
|
||||
|
||||
String newStatus;
|
||||
switch (action) {
|
||||
case "acknowledge":
|
||||
newStatus = "ACKNOWLEDGED";
|
||||
break;
|
||||
case "resolve":
|
||||
newStatus = "RESOLVED";
|
||||
updateDealerFromAlert(alert);
|
||||
break;
|
||||
case "ignore":
|
||||
newStatus = "IGNORED";
|
||||
break;
|
||||
default:
|
||||
throw new ServiceException("无效的处理操作");
|
||||
}
|
||||
|
||||
alert.setStatus(newStatus);
|
||||
alert.setResolvedBy(LoginHelper.getUserId());
|
||||
alert.setResolvedTime(new Date());
|
||||
alert.setResolvedNote(note);
|
||||
|
||||
alertMapper.updateById(alert);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据预警更新经销商字段
|
||||
*/
|
||||
private void updateDealerFromAlert(CrmSyncAlert alert) {
|
||||
TenantHelper.ignore(() -> {
|
||||
CrmDealer dealer = dealerMapper.selectById(alert.getDealerId());
|
||||
if (dealer == null) {
|
||||
return;
|
||||
}
|
||||
// CRM维护字段由用户手动处理,此方法仅记录更新时间
|
||||
dealer.setUpdateTime(new Date());
|
||||
dealerMapper.updateById(dealer);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询同步日志
|
||||
*/
|
||||
public TableDataInfo<CrmSyncLogVo> listSyncLogs(PageQuery pageQuery) {
|
||||
LambdaQueryWrapper<CrmSyncLog> wrapper = new LambdaQueryWrapper<>();
|
||||
wrapper.orderByDesc(CrmSyncLog::getStartTime);
|
||||
|
||||
Page<CrmSyncLogVo> page = syncLogMapper.selectVoPage(pageQuery.build(), wrapper);
|
||||
return TableDataInfo.build(page);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询预警列表(全局)
|
||||
*/
|
||||
public TableDataInfo<CrmSyncAlertVo> listAlerts(String status, PageQuery pageQuery) {
|
||||
LambdaQueryWrapper<CrmSyncAlert> wrapper = new LambdaQueryWrapper<>();
|
||||
if (StringUtils.isNotBlank(status)) {
|
||||
wrapper.eq(CrmSyncAlert::getStatus, status);
|
||||
}
|
||||
wrapper.orderByDesc(CrmSyncAlert::getCreateTime);
|
||||
|
||||
Page<CrmSyncAlertVo> page = alertMapper.selectVoPage(pageQuery.build(), wrapper);
|
||||
return TableDataInfo.build(page);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取同步统计数据
|
||||
*/
|
||||
public Map<String, Object> getSyncStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
|
||||
// 最近一次同步
|
||||
LambdaQueryWrapper<CrmSyncLog> lastWrapper = new LambdaQueryWrapper<>();
|
||||
lastWrapper.eq(CrmSyncLog::getStatus, "COMPLETED")
|
||||
.orderByDesc(CrmSyncLog::getStartTime)
|
||||
.last("LIMIT 1");
|
||||
CrmSyncLog lastSync = syncLogMapper.selectOne(lastWrapper);
|
||||
|
||||
if (lastSync != null) {
|
||||
stats.put("lastSyncTime", lastSync.getStartTime());
|
||||
stats.put("lastSyncDuration", lastSync.getDuration());
|
||||
stats.put("lastSyncCount", lastSync.getSyncedCount());
|
||||
stats.put("lastSyncUpdated", lastSync.getUpdatedCount());
|
||||
stats.put("lastSyncAlerts", lastSync.getAlertCount());
|
||||
stats.put("lastSyncErrors", lastSync.getErrorCount());
|
||||
}
|
||||
|
||||
// 待处理预警数量
|
||||
Long pendingAlerts = alertMapper.selectCount(
|
||||
new LambdaQueryWrapper<CrmSyncAlert>().eq(CrmSyncAlert::getStatus, "PENDING"));
|
||||
stats.put("pendingAlerts", pendingAlerts);
|
||||
|
||||
// 绑定ERP编码的经销商数量(全局统计)
|
||||
Long boundDealers = TenantHelper.ignore(() -> dealerMapper.selectCount(
|
||||
new LambdaQueryWrapper<CrmDealer>()
|
||||
.eq(CrmDealer::getDelFlag, SystemConstants.NORMAL)
|
||||
.isNotNull(CrmDealer::getCustomerCode)
|
||||
.ne(CrmDealer::getCustomerCode, "")));
|
||||
stats.put("boundDealers", boundDealers);
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
// ========== 辅助方法 ==========
|
||||
|
||||
private CrmSyncLog createSyncLog(String syncType, String operator) {
|
||||
CrmSyncLog log = new CrmSyncLog();
|
||||
log.setSyncType(syncType);
|
||||
log.setSyncDirection("ERP_TO_CRM");
|
||||
log.setStatus("RUNNING");
|
||||
log.setStartTime(new Date());
|
||||
log.setOperator(operator);
|
||||
syncLogMapper.insert(log);
|
||||
return log;
|
||||
}
|
||||
|
||||
private void finishSyncLog(CrmSyncLog syncLog) {
|
||||
syncLog.setEndTime(new Date());
|
||||
if (syncLog.getStartTime() != null) {
|
||||
syncLog.setDuration((int) ((syncLog.getEndTime().getTime() - syncLog.getStartTime().getTime()) / 1000));
|
||||
}
|
||||
syncLogMapper.updateById(syncLog);
|
||||
}
|
||||
|
||||
private String getDealerFieldValue(CrmDealer dealer, String fieldName) {
|
||||
switch (fieldName) {
|
||||
case "contactName":
|
||||
return dealer.getContactName();
|
||||
case "phone":
|
||||
return dealer.getMobile();
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String getErpFieldValue(Map<String, Object> erpCustomer, String fieldName) {
|
||||
switch (fieldName) {
|
||||
case "contactName":
|
||||
return (String) erpCustomer.get("contactName");
|
||||
case "phone":
|
||||
return (String) erpCustomer.get("phone");
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String buildAlertMessage(String fieldName, String crmValue, String erpValue) {
|
||||
String fieldLabel;
|
||||
switch (fieldName) {
|
||||
case "contactName":
|
||||
fieldLabel = "联系人";
|
||||
break;
|
||||
case "phone":
|
||||
fieldLabel = "电话";
|
||||
break;
|
||||
default:
|
||||
fieldLabel = fieldName;
|
||||
}
|
||||
return String.format("%s与ERP不一致:CRM=%s, ERP=%s", fieldLabel,
|
||||
crmValue != null ? crmValue : "空",
|
||||
erpValue != null ? erpValue : "空");
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步结果内部类
|
||||
*/
|
||||
private static class SyncResult {
|
||||
boolean updated = false;
|
||||
boolean created = false;
|
||||
boolean hasAlert = false;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
@@ -26,6 +28,95 @@ public class ErpIntegrationService {
|
||||
|
||||
private final RestTemplate restTemplate;
|
||||
|
||||
/**
|
||||
* 获取ERP公司列表
|
||||
*
|
||||
* @return 公司列表
|
||||
*/
|
||||
public List<Map<String, Object>> getCompanyList() {
|
||||
try {
|
||||
String url = erpBaseUrl + "/erp/dynamic/v1/company/list";
|
||||
log.info("调用ERP服务获取公司列表: {}", url);
|
||||
|
||||
R<List<Map<String, Object>>> response = restTemplate.getForObject(url, R.class);
|
||||
if (response != null && response.getCode() == 200) {
|
||||
return response.getData();
|
||||
}
|
||||
|
||||
log.warn("ERP公司列表获取失败: response={}", response);
|
||||
return List.of();
|
||||
} catch (Exception e) {
|
||||
log.error("调用ERP服务获取公司列表异常", e);
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取ERP公司详情
|
||||
*
|
||||
* @param companyCode 公司编码
|
||||
* @return 公司信息
|
||||
*/
|
||||
public Map<String, Object> getCompanyDetail(String companyCode) {
|
||||
if (StringUtils.isBlank(companyCode)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
String url = erpBaseUrl + "/erp/dynamic/v1/company/detail?companyCode=" + companyCode;
|
||||
log.info("调用ERP服务获取公司详情: {}", url);
|
||||
|
||||
R<Map<String, Object>> response = restTemplate.getForObject(url, R.class);
|
||||
if (response != null && response.getCode() == 200) {
|
||||
return response.getData();
|
||||
}
|
||||
|
||||
log.warn("ERP公司详情获取失败: companyCode={}, response={}", companyCode, response);
|
||||
return null;
|
||||
} catch (Exception e) {
|
||||
log.error("调用ERP服务获取公司详情异常: companyCode={}", companyCode, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取ERP客户选择列表(按公司过滤,用于同步)
|
||||
*
|
||||
* @param keyword 关键词(可选)
|
||||
* @param companyIds 公司ID列表(逗号分隔)
|
||||
* @return 客户列表
|
||||
*/
|
||||
public List<Map<String, Object>> getCustomerSelectList(String keyword, String companyIds) {
|
||||
if (StringUtils.isBlank(companyIds)) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
// 分别查询每个公司的客户数据,然后合并
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
String[] companyIdArray = companyIds.split(",");
|
||||
|
||||
for (String companyId : companyIdArray) {
|
||||
if (StringUtils.isBlank(companyId)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
// 直接使用固定公司ID的SQL查询
|
||||
String url = erpBaseUrl + "/erp/dynamic/v1/customer/select?companyId=" + companyId.trim();
|
||||
log.info("调用ERP服务获取客户列表: companyId={}", companyId);
|
||||
|
||||
R<List<Map<String, Object>>> response = restTemplate.getForObject(url, R.class);
|
||||
if (response != null && response.getCode() == 200 && response.getData() != null) {
|
||||
result.addAll(response.getData());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("调用ERP服务获取公司{}客户列表失败", companyId, e);
|
||||
}
|
||||
}
|
||||
|
||||
log.info("ERP客户列表获取完成: 共{}条", result.size());
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取ERP客户详情
|
||||
*
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
package org.hzhub.crm.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.hzhub.common.core.exception.ServiceException;
|
||||
import org.hzhub.common.core.utils.StringUtils;
|
||||
import org.hzhub.common.satoken.utils.LoginHelper;
|
||||
import org.hzhub.system.domain.SysTenantCompany;
|
||||
import org.hzhub.system.mapper.SysTenantCompanyMapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 租户与ERP公司映射服务
|
||||
*
|
||||
* @author hzhub
|
||||
*/
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
@Service
|
||||
public class TenantCompanyService {
|
||||
|
||||
private final SysTenantCompanyMapper tenantCompanyMapper;
|
||||
private final ErpIntegrationService erpIntegrationService;
|
||||
|
||||
/**
|
||||
* 获取当前租户关联的所有ERP公司ID列表
|
||||
*
|
||||
* @return ERP公司ID列表
|
||||
*/
|
||||
public List<String> getCurrentTenantCompanyIds() {
|
||||
String tenantId = LoginHelper.getTenantId();
|
||||
return getTenantCompanyIds(tenantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定租户关联的所有ERP公司ID列表
|
||||
*
|
||||
* @param tenantId 租户编号
|
||||
* @return ERP公司ID列表
|
||||
*/
|
||||
public List<String> getTenantCompanyIds(String tenantId) {
|
||||
if (StringUtils.isBlank(tenantId)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
List<SysTenantCompany> mappings = tenantCompanyMapper.selectList(
|
||||
new LambdaQueryWrapper<SysTenantCompany>()
|
||||
.eq(SysTenantCompany::getTenantId, tenantId)
|
||||
.eq(SysTenantCompany::getStatus, "0")
|
||||
.orderByAsc(SysTenantCompany::getSort)
|
||||
);
|
||||
|
||||
return mappings.stream()
|
||||
.map(SysTenantCompany::getErpCompanyId)
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前租户关联的所有ERP公司映射列表
|
||||
*
|
||||
* @return 映射列表
|
||||
*/
|
||||
public List<SysTenantCompany> getCurrentTenantCompanies() {
|
||||
String tenantId = LoginHelper.getTenantId();
|
||||
return tenantCompanyMapper.selectList(
|
||||
new LambdaQueryWrapper<SysTenantCompany>()
|
||||
.eq(SysTenantCompany::getTenantId, tenantId)
|
||||
.orderByAsc(SysTenantCompany::getSort)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有租户的ERP公司映射列表(全局管理)
|
||||
*
|
||||
* @return 映射列表
|
||||
*/
|
||||
public List<SysTenantCompany> getAllTenantCompanies() {
|
||||
return tenantCompanyMapper.selectList(
|
||||
new LambdaQueryWrapper<SysTenantCompany>()
|
||||
.orderByAsc(SysTenantCompany::getTenantId)
|
||||
.orderByAsc(SysTenantCompany::getSort)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定租户与ERP公司
|
||||
*
|
||||
* @param tenantId 租户编号
|
||||
* @param tenantName 租户名称(可选)
|
||||
* @param erpCompanyId ERP公司ID
|
||||
* @param erpCompanyName ERP公司名称(可选)
|
||||
* @return 绑定结果
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public int bindCompany(String tenantId, String tenantName, String erpCompanyId, String erpCompanyName) {
|
||||
if (StringUtils.isBlank(tenantId) || StringUtils.isBlank(erpCompanyId)) {
|
||||
throw new ServiceException("租户编号和ERP公司ID不能为空");
|
||||
}
|
||||
|
||||
// 检查是否已绑定
|
||||
SysTenantCompany existing = tenantCompanyMapper.selectOne(
|
||||
new LambdaQueryWrapper<SysTenantCompany>()
|
||||
.eq(SysTenantCompany::getTenantId, tenantId)
|
||||
.eq(SysTenantCompany::getErpCompanyId, erpCompanyId)
|
||||
);
|
||||
|
||||
if (existing != null) {
|
||||
if ("0".equals(existing.getStatus())) {
|
||||
throw new ServiceException("该ERP公司已绑定到该租户");
|
||||
}
|
||||
// 恢复已停用的绑定
|
||||
existing.setStatus("0");
|
||||
if (StringUtils.isNotBlank(erpCompanyName)) {
|
||||
existing.setErpCompanyName(erpCompanyName);
|
||||
}
|
||||
if (StringUtils.isNotBlank(tenantName)) {
|
||||
existing.setTenantName(tenantName);
|
||||
}
|
||||
return tenantCompanyMapper.updateById(existing);
|
||||
}
|
||||
|
||||
// 新建绑定
|
||||
SysTenantCompany mapping = new SysTenantCompany();
|
||||
mapping.setTenantId(tenantId);
|
||||
mapping.setTenantName(tenantName);
|
||||
mapping.setErpCompanyId(erpCompanyId);
|
||||
mapping.setErpCompanyName(erpCompanyName);
|
||||
mapping.setStatus("0");
|
||||
mapping.setSort(0);
|
||||
|
||||
return tenantCompanyMapper.insert(mapping);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解绑租户与ERP公司(停用状态)
|
||||
*
|
||||
* @param id 映射ID
|
||||
* @return 解绑结果
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public int unbindCompany(Long id) {
|
||||
SysTenantCompany mapping = tenantCompanyMapper.selectById(id);
|
||||
if (mapping == null) {
|
||||
throw new ServiceException("映射关系不存在");
|
||||
}
|
||||
|
||||
mapping.setStatus("1");
|
||||
return tenantCompanyMapper.updateById(mapping);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除租户与ERP公司映射
|
||||
*
|
||||
* @param id 映射ID
|
||||
* @return 删除结果
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public int deleteMapping(Long id) {
|
||||
return tenantCompanyMapper.deleteById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验ERP客户是否属于当前租户关联的公司
|
||||
*
|
||||
* @param customerCode ERP客户编码
|
||||
* @return 是否属于
|
||||
*/
|
||||
public boolean isCustomerBelongsToCurrentTenant(String customerCode) {
|
||||
if (StringUtils.isBlank(customerCode)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
List<String> companyIds = getCurrentTenantCompanyIds();
|
||||
if (companyIds.isEmpty()) {
|
||||
// 如果租户没有配置公司映射,暂时允许(后续可改为拒绝)
|
||||
log.warn("租户[{}]未配置ERP公司映射,暂时允许所有客户", LoginHelper.getTenantId());
|
||||
return true;
|
||||
}
|
||||
|
||||
// 获取ERP客户详情,检查其companyCode
|
||||
var customerDetail = erpIntegrationService.getCustomerDetail(customerCode);
|
||||
if (customerDetail == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String customerCompanyCode = (String) customerDetail.get("companyCode");
|
||||
return companyIds.contains(customerCompanyCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取租户的ERP公司筛选条件(用于ERP客户查询)
|
||||
*
|
||||
* @return companyIds字符串(逗号分隔),如果未配置返回null
|
||||
*/
|
||||
public String getCompanyFilterForCurrentTenant() {
|
||||
List<String> companyIds = getCurrentTenantCompanyIds();
|
||||
if (companyIds.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return companyIds.stream().collect(Collectors.joining(","));
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,8 @@ import org.hzhub.crm.domain.bo.CrmDealerBo;
|
||||
import org.hzhub.crm.domain.vo.CrmDealerVo;
|
||||
import org.hzhub.crm.mapper.CrmDealerMapper;
|
||||
import org.hzhub.crm.service.ICrmDealerService;
|
||||
import org.hzhub.crm.service.CustomerSyncService;
|
||||
import org.hzhub.crm.service.ErpIntegrationService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
@@ -33,6 +35,8 @@ import java.util.Map;
|
||||
public class CrmDealerServiceImpl implements ICrmDealerService {
|
||||
|
||||
private final CrmDealerMapper dealerMapper;
|
||||
private final CustomerSyncService customerSyncService;
|
||||
private final ErpIntegrationService erpIntegrationService;
|
||||
|
||||
@Override
|
||||
public TableDataInfo<CrmDealerVo> selectPageDealerList(CrmDealerBo dealer, PageQuery pageQuery) {
|
||||
@@ -73,7 +77,32 @@ public class CrmDealerServiceImpl implements ICrmDealerService {
|
||||
throw new ServiceException("经销商编码已存在");
|
||||
}
|
||||
|
||||
// 【新增】校验customerCode必填且存在
|
||||
if (StringUtils.isBlank(dealer.getCustomerCode())) {
|
||||
throw new ServiceException("经销商必须绑定ERP客户编码");
|
||||
}
|
||||
|
||||
if (!customerSyncService.validateCustomerCode(dealer.getCustomerCode())) {
|
||||
throw new ServiceException("ERP客户不存在或不属于当前租户: " + dealer.getCustomerCode());
|
||||
}
|
||||
|
||||
CrmDealer crmDealer = MapstructUtils.convert(dealer, CrmDealer.class);
|
||||
|
||||
// 【新增】从ERP拉取名称和区域信息覆盖
|
||||
var erpCustomer = erpIntegrationService.getCustomerDetail(dealer.getCustomerCode());
|
||||
if (erpCustomer != null) {
|
||||
String erpName = (String) erpCustomer.get("customerName");
|
||||
if (StringUtils.isNotBlank(erpName)) {
|
||||
crmDealer.setDealerName(erpName);
|
||||
}
|
||||
crmDealer.setProvince((String) erpCustomer.get("province"));
|
||||
crmDealer.setCity((String) erpCustomer.get("city"));
|
||||
|
||||
// 状态映射
|
||||
Integer erpIsStop = (Integer) erpCustomer.get("isStop");
|
||||
crmDealer.setLifecycle((erpIsStop != null && erpIsStop == 1) ? "churn" : "active");
|
||||
}
|
||||
|
||||
return dealerMapper.insert(crmDealer);
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import org.hzhub.crm.domain.bo.CrmLeadConvertBo;
|
||||
import org.hzhub.crm.domain.bo.CrmLeadFollowBo;
|
||||
import org.hzhub.crm.domain.vo.CrmDealerVo;
|
||||
import org.hzhub.crm.domain.vo.CrmLeadFollowVo;
|
||||
import org.hzhub.crm.domain.vo.CrmLeadStatsVo;
|
||||
import org.hzhub.crm.domain.vo.CrmLeadVo;
|
||||
import org.hzhub.crm.mapper.CrmDealerMapper;
|
||||
import org.hzhub.crm.mapper.CrmLeadFollowMapper;
|
||||
@@ -31,9 +32,13 @@ import org.hzhub.crm.mapper.CrmLeadMapper;
|
||||
import org.hzhub.crm.service.ErpIntegrationService;
|
||||
import org.hzhub.crm.service.ICrmLeadService;
|
||||
import org.hzhub.crm.service.ICrmOpportunityService;
|
||||
import org.hzhub.crm.service.CustomerSyncService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -53,6 +58,7 @@ public class CrmLeadServiceImpl implements ICrmLeadService {
|
||||
private final CrmDealerMapper dealerMapper;
|
||||
private final ErpIntegrationService erpIntegrationService;
|
||||
private final ICrmOpportunityService opportunityService;
|
||||
private final CustomerSyncService customerSyncService;
|
||||
|
||||
@Override
|
||||
public TableDataInfo<CrmLeadVo> selectPageLeadList(CrmLeadBo lead, PageQuery pageQuery) {
|
||||
@@ -91,6 +97,67 @@ public class CrmLeadServiceImpl implements ICrmLeadService {
|
||||
return lead;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CrmLeadStatsVo getLeadStats() {
|
||||
String tenantId = LoginHelper.getTenantId();
|
||||
|
||||
CrmLeadStatsVo stats = new CrmLeadStatsVo();
|
||||
|
||||
// 总数统计
|
||||
Long totalCount = leadMapper.countTotal(tenantId);
|
||||
stats.setTotalCount(totalCount);
|
||||
|
||||
// 高意向线索数量
|
||||
Long highIntentCount = leadMapper.countHighIntent(tenantId);
|
||||
stats.setHighIntentCount(highIntentCount);
|
||||
|
||||
// 本月/上月时间计算
|
||||
LocalDate now = LocalDate.now();
|
||||
int currentYear = now.getYear();
|
||||
int currentMonth = now.getMonthValue();
|
||||
int lastMonth = currentMonth == 1 ? 12 : currentMonth - 1;
|
||||
int lastMonthYear = currentMonth == 1 ? currentYear - 1 : currentYear;
|
||||
|
||||
// 本月新增
|
||||
Long monthlyNewCount = leadMapper.countMonthlyNew(tenantId, currentYear, currentMonth);
|
||||
stats.setMonthlyNewCount(monthlyNewCount);
|
||||
|
||||
// 上月新增
|
||||
Long lastMonthNewCount = leadMapper.countMonthlyNew(tenantId, lastMonthYear, lastMonth);
|
||||
stats.setLastMonthNewCount(lastMonthNewCount);
|
||||
|
||||
// 已转化数量
|
||||
Long convertedCount = leadMapper.countConverted(tenantId);
|
||||
stats.setConvertedCount(convertedCount);
|
||||
|
||||
// 转化率
|
||||
if (totalCount > 0) {
|
||||
BigDecimal rate = BigDecimal.valueOf(convertedCount * 100)
|
||||
.divide(BigDecimal.valueOf(totalCount), 2, RoundingMode.HALF_UP);
|
||||
stats.setConversionRate(rate);
|
||||
} else {
|
||||
stats.setConversionRate(BigDecimal.ZERO);
|
||||
}
|
||||
|
||||
// 本月转化
|
||||
Long monthlyConvertedCount = leadMapper.countMonthlyConverted(tenantId, currentYear, currentMonth);
|
||||
stats.setMonthlyConvertedCount(monthlyConvertedCount);
|
||||
|
||||
// 上月转化
|
||||
Long lastMonthConvertedCount = leadMapper.countMonthlyConverted(tenantId, lastMonthYear, lastMonth);
|
||||
stats.setLastMonthConvertedCount(lastMonthConvertedCount);
|
||||
|
||||
// 本月高意向
|
||||
Long monthlyHighIntentCount = leadMapper.countMonthlyHighIntent(tenantId, currentYear, currentMonth);
|
||||
stats.setMonthlyHighIntentCount(monthlyHighIntentCount);
|
||||
|
||||
// 上月高意向
|
||||
Long lastMonthHighIntentCount = leadMapper.countMonthlyHighIntent(tenantId, lastMonthYear, lastMonth);
|
||||
stats.setLastMonthHighIntentCount(lastMonthHighIntentCount);
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public int insertLead(CrmLeadBo lead) {
|
||||
@@ -261,17 +328,42 @@ public class CrmLeadServiceImpl implements ICrmLeadService {
|
||||
throw new ServiceException("经销商编码已存在");
|
||||
}
|
||||
|
||||
// 3. 创建经销商
|
||||
// 3. 【新增】校验ERP客户编码必须存在且属于当前租户
|
||||
if (StringUtils.isBlank(convert.getCustomerCode())) {
|
||||
throw new ServiceException("经销商必须绑定ERP客户编码,请选择ERP客户");
|
||||
}
|
||||
|
||||
if (!customerSyncService.validateCustomerCode(convert.getCustomerCode())) {
|
||||
throw new ServiceException("ERP客户不存在或不属于当前租户: " + convert.getCustomerCode());
|
||||
}
|
||||
|
||||
// 4. 创建经销商
|
||||
CrmDealer dealer = new CrmDealer();
|
||||
dealer.setCustomerCode(convert.getCustomerCode());
|
||||
dealer.setDealerName(convert.getDealerName());
|
||||
dealer.setCustomerCode(convert.getCustomerCode()); // 必填
|
||||
|
||||
// 【新增】从ERP拉取客户信息覆盖名称和区域
|
||||
Map<String, Object> erpCustomer = erpIntegrationService.getCustomerDetail(convert.getCustomerCode());
|
||||
if (erpCustomer != null) {
|
||||
// 名称使用ERP值
|
||||
String erpName = (String) erpCustomer.get("customerName");
|
||||
dealer.setDealerName(StringUtils.isNotBlank(erpName) ? erpName : convert.getDealerName());
|
||||
|
||||
// 区域使用ERP值
|
||||
dealer.setProvince((String) erpCustomer.get("province"));
|
||||
dealer.setCity((String) erpCustomer.get("city"));
|
||||
|
||||
// 状态映射
|
||||
Integer erpIsStop = (Integer) erpCustomer.get("isStop");
|
||||
dealer.setLifecycle((erpIsStop != null && erpIsStop == 1) ? "churn" : "active");
|
||||
} else {
|
||||
dealer.setDealerName(convert.getDealerName());
|
||||
dealer.setLifecycle("active");
|
||||
}
|
||||
|
||||
dealer.setDealerCode(convert.getDealerCode());
|
||||
dealer.setContactName(lead.getContactName());
|
||||
dealer.setMobile(lead.getMobile());
|
||||
dealer.setProvince(lead.getProvince());
|
||||
dealer.setCity(lead.getCity());
|
||||
dealer.setLevel(convert.getLevel() != null ? convert.getLevel() : "C");
|
||||
dealer.setLifecycle("active");
|
||||
|
||||
// 处理签约时间
|
||||
if (convert.getSignedAt() != null) {
|
||||
@@ -290,14 +382,72 @@ public class CrmLeadServiceImpl implements ICrmLeadService {
|
||||
throw new ServiceException("创建经销商失败");
|
||||
}
|
||||
|
||||
// 4. 更新线索状态
|
||||
// 5. 更新线索状态
|
||||
lead.setLeadStatus("converted");
|
||||
lead.setConvertedDealerId(dealer.getDealerId());
|
||||
int leadResult = leadMapper.updateById(lead);
|
||||
|
||||
log.info("线索转化成功: leadId={}, dealerId={}, dealerCode={}",
|
||||
lead.getLeadId(), dealer.getDealerId(), dealer.getDealerCode());
|
||||
log.info("线索转化成功: leadId={}, dealerId={}, dealerCode={}, customerCode={}",
|
||||
lead.getLeadId(), dealer.getDealerId(), dealer.getDealerCode(), dealer.getCustomerCode());
|
||||
|
||||
return leadResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* 作废线索
|
||||
*
|
||||
* @param leadId 线索ID
|
||||
* @return 结果
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public int invalidateLead(Long leadId) {
|
||||
// 查询线索
|
||||
CrmLead lead = leadMapper.selectById(leadId);
|
||||
if (lead == null) {
|
||||
throw new ServiceException("线索不存在");
|
||||
}
|
||||
if ("converted".equals(lead.getLeadStatus())) {
|
||||
throw new ServiceException("线索已转化,不能作废");
|
||||
}
|
||||
if ("invalid".equals(lead.getLeadStatus())) {
|
||||
throw new ServiceException("线索已作废");
|
||||
}
|
||||
|
||||
// 更新状态为作废
|
||||
return leadMapper.update(null,
|
||||
new LambdaUpdateWrapper<CrmLead>()
|
||||
.set(CrmLead::getLeadStatus, "invalid")
|
||||
.set(CrmLead::getUpdateTime, new Date())
|
||||
.set(CrmLead::getUpdateBy, LoginHelper.getUserId())
|
||||
.eq(CrmLead::getLeadId, leadId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复线索
|
||||
*
|
||||
* @param leadId 线索ID
|
||||
* @return 结果
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public int restoreLead(Long leadId) {
|
||||
// 查询线索
|
||||
CrmLead lead = leadMapper.selectById(leadId);
|
||||
if (lead == null) {
|
||||
throw new ServiceException("线索不存在");
|
||||
}
|
||||
if (!"invalid".equals(lead.getLeadStatus())) {
|
||||
throw new ServiceException("只有已作废的线索才能恢复");
|
||||
}
|
||||
|
||||
// 恢复状态为新线索或跟进中(根据是否有负责人)
|
||||
String newStatus = lead.getOwnerUserId() != null ? "following" : "new";
|
||||
return leadMapper.update(null,
|
||||
new LambdaUpdateWrapper<CrmLead>()
|
||||
.set(CrmLead::getLeadStatus, newStatus)
|
||||
.set(CrmLead::getUpdateTime, new Date())
|
||||
.set(CrmLead::getUpdateBy, LoginHelper.getUserId())
|
||||
.eq(CrmLead::getLeadId, leadId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package org.hzhub.crm.task;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.hzhub.common.tenant.helper.TenantHelper;
|
||||
import org.hzhub.crm.domain.CrmSyncLog;
|
||||
import org.hzhub.crm.service.CustomerSyncService;
|
||||
import org.hzhub.crm.service.TenantCompanyService;
|
||||
import org.hzhub.system.domain.SysTenant;
|
||||
import org.hzhub.system.mapper.SysTenantMapper;
|
||||
import org.hzhub.system.service.ISysConfigService;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.scheduling.TaskScheduler;
|
||||
import org.springframework.scheduling.support.CronTrigger;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* CRM-ERP客户同步定时任务(动态调度,多租户独立)
|
||||
* <p>支持运行时启动/停止/修改频率,配置存储在 sys_config 表中</p>
|
||||
* <p>每个租户独立执行,未配置ERP公司映射的租户自动跳过</p>
|
||||
*
|
||||
* @author hzhub
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class CustomerSyncTask {
|
||||
|
||||
private static final String CRON_CONFIG_KEY = "crm.sync.cron";
|
||||
private static final String ENABLED_CONFIG_KEY = "crm.sync.enabled";
|
||||
|
||||
private final CustomerSyncService customerSyncService;
|
||||
private final TenantCompanyService tenantCompanyService;
|
||||
private final ISysConfigService configService;
|
||||
private final SysTenantMapper tenantMapper;
|
||||
private final TaskScheduler taskScheduler;
|
||||
|
||||
private volatile ScheduledFuture<?> scheduledFuture;
|
||||
|
||||
/**
|
||||
* 应用启动完成后,如果配置为启用则自动启动定时任务
|
||||
*/
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
public void init() {
|
||||
try {
|
||||
String enabled = configService.selectConfigByKey(ENABLED_CONFIG_KEY);
|
||||
if ("true".equals(enabled)) {
|
||||
start();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("初始化CRM同步定时任务失败: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动定时任务
|
||||
*/
|
||||
public synchronized void start() {
|
||||
if (scheduledFuture != null && !scheduledFuture.isCancelled()) {
|
||||
log.info("CRM同步定时任务已在运行中");
|
||||
return;
|
||||
}
|
||||
|
||||
String cron = configService.selectConfigByKey(CRON_CONFIG_KEY);
|
||||
if (cron == null || cron.isBlank()) {
|
||||
cron = "0 0 2 * * ?"; // 默认每日凌晨2点
|
||||
}
|
||||
|
||||
log.info("启动CRM同步定时任务, cron: {}", cron);
|
||||
scheduledFuture = taskScheduler.schedule(() -> {
|
||||
try {
|
||||
log.info("[定时任务] 开始执行CRM-ERP客户同步(多租户)");
|
||||
syncAllTenants();
|
||||
log.info("[定时任务] CRM客户同步全部完成");
|
||||
} catch (Exception e) {
|
||||
log.error("[定时任务] CRM客户同步异常", e);
|
||||
}
|
||||
}, new CronTrigger(cron));
|
||||
|
||||
configService.updateConfigByKey(ENABLED_CONFIG_KEY, "true");
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止定时任务
|
||||
*/
|
||||
public synchronized void stop() {
|
||||
if (scheduledFuture == null) {
|
||||
log.info("CRM同步定时任务未启动");
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("停止CRM同步定时任务");
|
||||
scheduledFuture.cancel(false);
|
||||
scheduledFuture = null;
|
||||
configService.updateConfigByKey(ENABLED_CONFIG_KEY, "false");
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否正在运行
|
||||
*/
|
||||
public boolean isRunning() {
|
||||
return scheduledFuture != null && !scheduledFuture.isCancelled();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前 cron 表达式
|
||||
*/
|
||||
public String getCurrentCron() {
|
||||
return configService.selectConfigByKey(CRON_CONFIG_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 cron 表达式
|
||||
*/
|
||||
public synchronized void setCron(String cron) {
|
||||
configService.updateConfigByKey(CRON_CONFIG_KEY, cron);
|
||||
// 如果正在运行,重启以应用新 cron
|
||||
if (isRunning()) {
|
||||
stop();
|
||||
start();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 遍历所有租户,依次执行同步
|
||||
*/
|
||||
private void syncAllTenants() {
|
||||
List<SysTenant> tenants = tenantMapper.selectList(
|
||||
new LambdaQueryWrapper<SysTenant>()
|
||||
.eq(SysTenant::getStatus, "0")
|
||||
);
|
||||
|
||||
log.info("共发现 {} 个有效租户,开始逐个执行CRM同步", tenants.size());
|
||||
|
||||
AtomicInteger successCount = new AtomicInteger(0);
|
||||
AtomicInteger skipCount = new AtomicInteger(0);
|
||||
AtomicInteger errorCount = new AtomicInteger(0);
|
||||
|
||||
for (SysTenant tenant : tenants) {
|
||||
final String tenantId = tenant.getTenantId();
|
||||
try {
|
||||
TenantHelper.dynamic(tenantId, () -> {
|
||||
// 检查租户是否配置了ERP公司映射
|
||||
List<String> companyIds = tenantCompanyService.getTenantCompanyIds(tenantId);
|
||||
if (companyIds.isEmpty()) {
|
||||
log.info("租户[{}]未配置ERP公司映射,跳过同步", tenantId);
|
||||
skipCount.incrementAndGet();
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("租户[{}]开始CRM-ERP客户同步,关联{}家公司", tenantId, companyIds.size());
|
||||
try {
|
||||
CrmSyncLog syncLog = customerSyncService.executeSync("SCHEDULED", "SYSTEM");
|
||||
log.info("租户[{}]同步完成: 扫描={}, 同步={}, 更新={}, 预警={}, 错误={}",
|
||||
tenantId, syncLog.getTotalCount(), syncLog.getSyncedCount(),
|
||||
syncLog.getUpdatedCount(), syncLog.getAlertCount(), syncLog.getErrorCount());
|
||||
successCount.incrementAndGet();
|
||||
} catch (Exception e) {
|
||||
log.error("租户[{}]CRM同步失败: {}", tenantId, e.getMessage(), e);
|
||||
errorCount.incrementAndGet();
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
log.error("切换租户[{}]上下文失败", tenantId, e);
|
||||
errorCount.incrementAndGet();
|
||||
}
|
||||
}
|
||||
|
||||
log.info("[定时任务] 多租户CRM同步完成: 成功={}, 跳过={}, 失败={}", successCount.get(), skipCount.get(), errorCount.get());
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动触发一次同步(管理员调用)
|
||||
*/
|
||||
public CrmSyncLog executeManualSync() {
|
||||
return customerSyncService.executeSync("MANUAL", "ADMIN");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package org.hzhub.system.domain;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.hzhub.common.mybatis.core.domain.BaseEntity;
|
||||
|
||||
import java.io.Serial;
|
||||
|
||||
/**
|
||||
* 租户与ERP公司映射对象 sys_tenant_company
|
||||
*
|
||||
* @author hzhub
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@TableName("sys_tenant_company")
|
||||
public class SysTenantCompany extends BaseEntity {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 主键ID
|
||||
*/
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 租户编号
|
||||
*/
|
||||
private String tenantId;
|
||||
|
||||
/**
|
||||
* 租户名称
|
||||
*/
|
||||
private String tenantName;
|
||||
|
||||
/**
|
||||
* ERP公司ID(companyid)
|
||||
*/
|
||||
private String erpCompanyId;
|
||||
|
||||
/**
|
||||
* ERP公司名称
|
||||
*/
|
||||
private String erpCompanyName;
|
||||
|
||||
/**
|
||||
* 排序
|
||||
*/
|
||||
private Integer sort;
|
||||
|
||||
/**
|
||||
* 状态(0正常 1停用)
|
||||
*/
|
||||
private String status;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package org.hzhub.system.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.hzhub.system.domain.SysTenantCompany;
|
||||
|
||||
/**
|
||||
* 租户与ERP公司映射Mapper
|
||||
*
|
||||
* @author hzhub
|
||||
*/
|
||||
public interface SysTenantCompanyMapper extends BaseMapper<SysTenantCompany> {
|
||||
}
|
||||
@@ -124,6 +124,7 @@ tenant:
|
||||
- sys_menu
|
||||
- sys_tenant
|
||||
- sys_tenant_package
|
||||
- sys_tenant_company
|
||||
- sys_role_dept
|
||||
- sys_role_menu
|
||||
- sys_user_post
|
||||
@@ -131,8 +132,8 @@ tenant:
|
||||
- sys_client
|
||||
- sys_oss_config
|
||||
- flow_spel
|
||||
- sys_dict_type
|
||||
- sys_dict_data
|
||||
- crm_sync_log
|
||||
- crm_sync_alert
|
||||
|
||||
# MyBatis-Plus 配置
|
||||
mybatis-plus:
|
||||
@@ -255,3 +256,7 @@ gen:
|
||||
# 企业微信配置(corpid/corpsecret 等租户级配置已迁移至数据库 wecom_tenant_config 表)
|
||||
wecom:
|
||||
api-base-url: https://qyapi.weixin.qq.com
|
||||
|
||||
# ERP服务配置
|
||||
erp:
|
||||
base-url: ${ERP_BASE_URL:http://localhost:8082}
|
||||
|
||||
150
hzhub-system/src/main/resources/db/crm_sync_init.sql
Normal file
150
hzhub-system/src/main/resources/db/crm_sync_init.sql
Normal file
@@ -0,0 +1,150 @@
|
||||
-- CRM与ERP客户同步功能初始化脚本
|
||||
-- 执行说明:在MySQL中执行此脚本创建同步相关表
|
||||
|
||||
-- ========================================
|
||||
-- 0. 租户与ERP公司映射表 (sys_tenant_company)
|
||||
-- 用于配置租户与ERP companyid的一对多关系
|
||||
-- ========================================
|
||||
CREATE TABLE IF NOT EXISTS `sys_tenant_company` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
`tenant_id` VARCHAR(20) NOT NULL COMMENT '租户编号',
|
||||
`erp_company_id` VARCHAR(50) NOT NULL COMMENT 'ERP公司ID(companyid)',
|
||||
`erp_company_name` VARCHAR(100) DEFAULT NULL COMMENT 'ERP公司名称',
|
||||
`sort` INT DEFAULT 0 COMMENT '排序',
|
||||
`status` CHAR(1) DEFAULT '0' COMMENT '状态(0正常 1停用)',
|
||||
`create_dept` BIGINT DEFAULT NULL COMMENT '创建部门',
|
||||
`create_by` BIGINT DEFAULT NULL COMMENT '创建者',
|
||||
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_by` BIGINT DEFAULT NULL COMMENT '更新者',
|
||||
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`remark` VARCHAR(500) DEFAULT NULL COMMENT '备注',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_tenant_company` (`tenant_id`, `erp_company_id`),
|
||||
KEY `idx_tenant_id` (`tenant_id`),
|
||||
KEY `idx_erp_company_id` (`erp_company_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='租户与ERP公司映射表';
|
||||
|
||||
-- ========================================
|
||||
-- 1. 同步日志表 (crm_sync_log)
|
||||
-- ========================================
|
||||
CREATE TABLE IF NOT EXISTS `crm_sync_log` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
`tenant_id` VARCHAR(20) DEFAULT '000000' COMMENT '租户编号',
|
||||
`sync_type` VARCHAR(20) NOT NULL COMMENT '同步类型: SCHEDULED定时/MANUAL手动/ON_EDIT编辑触发',
|
||||
`sync_direction` VARCHAR(20) NOT NULL COMMENT '同步方向: ERP_TO_CRM',
|
||||
`status` VARCHAR(20) DEFAULT 'RUNNING' COMMENT '同步状态: RUNNING运行中/COMPLETED已完成/FAILED失败',
|
||||
`start_time` DATETIME NOT NULL COMMENT '开始时间',
|
||||
`end_time` DATETIME DEFAULT NULL COMMENT '结束时间',
|
||||
`duration` INT DEFAULT NULL COMMENT '耗时(秒)',
|
||||
`total_count` INT DEFAULT 0 COMMENT '扫描总数(绑定customerCode的经销商)',
|
||||
`synced_count` INT DEFAULT 0 COMMENT '已同步数量',
|
||||
`updated_count` INT DEFAULT 0 COMMENT '更新数量(ERP覆盖字段)',
|
||||
`alert_count` INT DEFAULT 0 COMMENT '预警数量(CRM差异字段)',
|
||||
`error_count` INT DEFAULT 0 COMMENT '错误数量',
|
||||
`error_msg` TEXT DEFAULT NULL COMMENT '错误信息',
|
||||
`operator` VARCHAR(64) DEFAULT NULL COMMENT '操作人',
|
||||
`create_dept` BIGINT DEFAULT NULL COMMENT '创建部门',
|
||||
`create_by` BIGINT DEFAULT NULL COMMENT '创建者',
|
||||
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_by` BIGINT DEFAULT NULL COMMENT '更新者',
|
||||
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_tenant_id` (`tenant_id`),
|
||||
KEY `idx_sync_type` (`sync_type`),
|
||||
KEY `idx_start_time` (`start_time`),
|
||||
KEY `idx_status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='CRM同步日志表';
|
||||
|
||||
-- ========================================
|
||||
-- 2. 同步预警表 (crm_sync_alert)
|
||||
-- ========================================
|
||||
CREATE TABLE IF NOT EXISTS `crm_sync_alert` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
`tenant_id` VARCHAR(20) DEFAULT '000000' COMMENT '租户编号',
|
||||
`sync_log_id` BIGINT NOT NULL COMMENT '关联同步日志ID',
|
||||
`dealer_id` BIGINT NOT NULL COMMENT '经销商ID',
|
||||
`customer_code` VARCHAR(100) NOT NULL COMMENT 'ERP客户编码',
|
||||
`alert_type` VARCHAR(20) NOT NULL COMMENT '预警类型: CONTACT_DIFF联系人差异/ADDRESS_DIFF地址详情差异/STATUS_DIFF状态差异',
|
||||
`crm_value` TEXT DEFAULT NULL COMMENT 'CRM当前值(JSON格式)',
|
||||
`erp_value` TEXT DEFAULT NULL COMMENT 'ERP当前值(JSON格式)',
|
||||
`alert_message` VARCHAR(500) DEFAULT NULL COMMENT '预警描述',
|
||||
`status` VARCHAR(20) DEFAULT 'PENDING' COMMENT '处理状态: PENDING待处理/ACKNOWLEDGED已确认/RESOLVED已处理/IGNORED已忽略',
|
||||
`resolved_by` BIGINT DEFAULT NULL COMMENT '处理人ID',
|
||||
`resolved_time` DATETIME DEFAULT NULL COMMENT '处理时间',
|
||||
`resolved_note` VARCHAR(500) DEFAULT NULL COMMENT '处理备注',
|
||||
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_tenant_id` (`tenant_id`),
|
||||
KEY `idx_sync_log_id` (`sync_log_id`),
|
||||
KEY `idx_dealer_id` (`dealer_id`),
|
||||
KEY `idx_customer_code` (`customer_code`),
|
||||
KEY `idx_status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='CRM同步预警表';
|
||||
|
||||
-- ========================================
|
||||
-- 3. 系统配置项 (sys_config)
|
||||
-- ========================================
|
||||
INSERT INTO `sys_config` (`config_id`, `config_name`, `config_key`, `config_value`, `config_type`, `create_dept`, `create_by`, `create_time`, `remark`) VALUES
|
||||
(2046502099104342019, 'CRM同步定时任务启用', 'crm.sync.enabled', 'false', 'Y', 103, 1, NOW(), 'CRM-ERP客户同步定时任务是否启用'),
|
||||
(2046502099104342020, 'CRM同步定时任务Cron表达式', 'crm.sync.cron', '0 0 2 * * ?', 'Y', 103, 1, NOW(), 'CRM-ERP客户同步定时任务执行时间,默认每日凌晨2点');
|
||||
|
||||
-- ========================================
|
||||
-- 4. 字典类型 (sys_dict_type + sys_dict_data)
|
||||
-- ========================================
|
||||
-- 同步类型字典
|
||||
INSERT INTO `sys_dict_type` (`dict_id`, `dict_name`, `dict_type`, `create_dept`, `create_by`, `create_time`, `remark`) VALUES
|
||||
(2056922814748053507, 'CRM同步类型', 'crm_sync_type', 103, 1, NOW(), 'CRM-ERP客户同步类型');
|
||||
|
||||
INSERT INTO `sys_dict_data` (`dict_code`, `dict_sort`, `dict_label`, `dict_value`, `dict_type`, `css_class`, `list_class`, `is_default`, `create_dept`, `create_by`, `create_time`, `remark`) VALUES
|
||||
(2056922815037460485, 1, '定时同步', 'SCHEDULED', 'crm_sync_type', '', 'primary', 'Y', 103, 1, NOW(), '定时任务自动执行'),
|
||||
(2056922815037460486, 2, '手动同步', 'MANUAL', 'crm_sync_type', '', 'success', 'N', 103, 1, NOW(), '管理员手动触发'),
|
||||
(2056922815037460487, 3, '编辑触发', 'ON_EDIT', 'crm_sync_type', '', 'info', 'N', 103, 1, NOW(), '编辑保存时触发校验');
|
||||
|
||||
-- 同步状态字典
|
||||
INSERT INTO `sys_dict_type` (`dict_id`, `dict_name`, `dict_type`, `create_dept`, `create_by`, `create_time`, `remark`) VALUES
|
||||
(2056922814748053508, 'CRM同步状态', 'crm_sync_status', 103, 1, NOW(), 'CRM-ERP客户同步执行状态');
|
||||
|
||||
INSERT INTO `sys_dict_data` (`dict_code`, `dict_sort`, `dict_label`, `dict_value`, `dict_type`, `css_class`, `list_class`, `is_default`, `create_dept`, `create_by`, `create_time`, `remark`) VALUES
|
||||
(2056922815037460488, 1, '运行中', 'RUNNING', 'crm_sync_status', '', 'primary', 'Y', 103, 1, NOW(), '同步任务正在执行'),
|
||||
(2056922815037460489, 2, '已完成', 'COMPLETED', 'crm_sync_status', '', 'success', 'N', 103, 1, NOW(), '同步任务执行完成'),
|
||||
(2056922815037460490, 3, '失败', 'FAILED', 'crm_sync_status', '', 'danger', 'N', 103, 1, NOW(), '同步任务执行失败');
|
||||
|
||||
-- 预警类型字典
|
||||
INSERT INTO `sys_dict_type` (`dict_id`, `dict_name`, `dict_type`, `create_dept`, `create_by`, `create_time`, `remark`) VALUES
|
||||
(2056922814748053509, 'CRM同步预警类型', 'crm_sync_alert_type', 103, 1, NOW(), 'CRM-ERP客户同步预警类型');
|
||||
|
||||
INSERT INTO `sys_dict_data` (`dict_code`, `dict_sort`, `dict_label`, `dict_value`, `dict_type`, `css_class`, `list_class`, `is_default`, `create_dept`, `create_by`, `create_time`, `remark`) VALUES
|
||||
(2056922815037460491, 1, '联系人差异', 'CONTACT_DIFF', 'crm_sync_alert_type', '', 'warning', 'N', 103, 1, NOW(), '联系人/电话与ERP不一致'),
|
||||
(2056922815037460492, 2, '地址差异', 'ADDRESS_DIFF', 'crm_sync_alert_type', '', 'warning', 'N', 103, 1, NOW(), '地址详情与ERP不一致'),
|
||||
(2056922815037460493, 3, '状态差异', 'STATUS_DIFF', 'crm_sync_alert_type', '', 'danger', 'N', 103, 1, NOW(), 'ERP客户已停用');
|
||||
|
||||
-- 预警处理状态字典
|
||||
INSERT INTO `sys_dict_type` (`dict_id`, `dict_name`, `dict_type`, `create_dept`, `create_by`, `create_time`, `remark`) VALUES
|
||||
(2056922814748053510, 'CRM预警处理状态', 'crm_sync_alert_status', 103, 1, NOW(), 'CRM-ERP客户同步预警处理状态');
|
||||
|
||||
INSERT INTO `sys_dict_data` (`dict_code`, `dict_sort`, `dict_label`, `dict_value`, `dict_type`, `css_class`, `list_class`, `is_default`, `create_dept`, `create_by`, `create_time`, `remark`) VALUES
|
||||
(2056922815037460494, 1, '待处理', 'PENDING', 'crm_sync_alert_status', '', 'warning', 'Y', 103, 1, NOW(), '预警待人工处理'),
|
||||
(2056922815037460495, 2, '已确认', 'ACKNOWLEDGED', 'crm_sync_alert_status', '', 'info', 'N', 103, 1, NOW(), '已确认差异,保持CRM值'),
|
||||
(2056922815037460496, 3, '已处理', 'RESOLVED', 'crm_sync_alert_status', '', 'success', 'N', 103, 1, NOW(), '已同步ERP值到CRM'),
|
||||
(2056922815037460497, 4, '已忽略', 'IGNORED', 'crm_sync_alert_status', '', 'default', 'N', 103, 1, NOW(), '忽略此差异');
|
||||
|
||||
-- ========================================
|
||||
-- 5. 菜单配置 (sys_menu)
|
||||
-- ========================================
|
||||
-- CRM同步管理菜单(放在CRM模块下)
|
||||
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query_param`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_dept`, `create_by`, `create_time`, `remark`) VALUES
|
||||
(2045402164711710722, '同步管理', 0, 6, 'crm/sync', 'crm/sync/index', NULL, 1, 0, 'C', '0', '0', '', 'sync', 103, 1, NOW(), 'CRM-ERP客户同步管理');
|
||||
|
||||
-- 同步管理子菜单
|
||||
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query_param`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_dept`, `create_by`, `create_time`, `remark`) VALUES
|
||||
(2045402164711710723, '同步配置', 2045402164711710722, 1, 'config', NULL, NULL, 1, 0, 'F', '0', '0', 'crm:sync:config', '#', 103, 1, NOW(), ''),
|
||||
(2045402164711710724, '执行同步', 2045402164711710722, 2, 'execute', NULL, NULL, 1, 0, 'F', '0', '0', 'crm:sync:execute', '#', 103, 1, NOW(), ''),
|
||||
(2045402164711710725, '同步日志', 2045402164711710722, 3, 'logs', NULL, NULL, 1, 0, 'F', '0', '0', 'crm:sync:log', '#', 103, 1, NOW(), ''),
|
||||
(2045402164711710726, '预警管理', 2045402164711710722, 4, 'alerts', NULL, NULL, 1, 0, 'F', '0', '0', 'crm:sync:alert', '#', 103, 1, NOW(), ''),
|
||||
(2045402164711710727, '公司映射', 2045402164711710722, 5, 'company', NULL, NULL, 1, 0, 'F', '0', '0', 'crm:sync:company', '#', 103, 1, NOW(), ''),
|
||||
(2045402164711710728, '统计面板', 2045402164711710722, 6, 'stats', NULL, NULL, 1, 0, 'F', '0', '0', 'crm:sync:stats', '#', 103, 1, NOW(), '');
|
||||
|
||||
-- ========================================
|
||||
-- 完成
|
||||
-- ========================================
|
||||
-- 执行完成后,请检查表结构、字典数据和菜单配置是否正确创建
|
||||
Reference in New Issue
Block a user