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

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

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

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

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

View File

@@ -0,0 +1,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}`, {});
}

View File

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

View File

@@ -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);
}
}

View File

@@ -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 &gt; (${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);
}

View File

@@ -30,4 +30,9 @@ public interface ICustomerService {
* 获取所有品牌列表
*/
List<CustomerVO> getBrands();
/**
* 客户选择列表用于CRM选择器支持多公司过滤
*/
TableDataInfo<CustomerVO> queryCustomerSelectList(int pageNum, int pageSize, String keyword, String companyIds);
}

View File

@@ -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);
}
}

View File

@@ -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}

View File

@@ -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();
}
}

View File

@@ -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());
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -175,4 +175,9 @@ public class CrmDealerVo implements Serializable {
* 更新时间
*/
private Date updateTime;
/**
* 是否有待处理预警(扩展字段,非数据库字段)
*/
private Boolean hasPendingAlerts;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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> {
}

View File

@@ -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> {
}

View File

@@ -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;
}
}

View File

@@ -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客户详情
*

View File

@@ -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(","));
}
}

View File

@@ -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);
}

View File

@@ -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));
}
}

View File

@@ -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");
}
}

View File

@@ -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;
}

View File

@@ -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> {
}

View File

@@ -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}

View 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(), '');
-- ========================================
-- 完成
-- ========================================
-- 执行完成后,请检查表结构、字典数据和菜单配置是否正确创建