✨ 完善客户业务员关联和同步

This commit is contained in:
caojia
2026-10-28 22:19:47 +08:00
parent 1688cddac7
commit 5f082d905d
14 changed files with 344 additions and 66 deletions
@@ -7,9 +7,9 @@ import java.util.Date;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Data;
import org.apache.commons.lang3.builder.ToStringBuilder;
import org.apache.commons.lang3.builder.ToStringStyle;
import lombok.ToString;
import com.bms.common.annotation.Excel;
import com.bms.common.core.domain.BaseEntity;
@@ -52,16 +52,20 @@ public class BmsCustomer extends BaseEntity
private String isEnable;
/** 业务员用户ID */
@Excel(name = "业务员用户ID")
private String salesUserId;
/** 业务员姓名 */
@Excel(name = "业务员姓名")
/** 业务员姓名(查询结果只回填已关联系统用户的姓名) */
private String salesUser;
/** 业务员昵称(join sys_user.nick_name,sales_user_id 存工号=sys_user.user_name) */
/** 业务员昵称(关联 sys_user.nick_name) */
@Excel(name = "业务员")
private String salesUserNickName;
/** 客户同步源中的原始业务员工号,仅供内部同步和迁移使用。 */
@JsonIgnore
@ToString.Exclude
private String sourceSalesCode;
/** 资金额度(元) */
@Excel(name = "资金额度", readConverterExp = "元=")
private BigDecimal ed;
@@ -34,6 +34,9 @@ public interface BmsCustomerMapper
*/
public List<BmsCustomer> selectBmsCustomerList(BmsCustomer bmsCustomer);
/** 校验业务员用户ID仍对应未删除的系统用户。 */
public int countValidSalesUserId(@org.apache.ibatis.annotations.Param("salesUserId") String salesUserId);
/**
* 新增客户主
*
@@ -11,16 +11,15 @@ import com.bms.customer.customerinfo.domain.BmsCustomer;
*/
public interface CustomerSyncMapper
{
/**
* 库中未删除的全部客户编码,用于判定新增/更新
*/
/** 库中未删除的全部客户编码,用于判定新增/更新。 */
List<String> selectExistingCodes();
/**
* 未删除用户的 工号(user_name)->用户ID(user_id) 映射,用于 TMS BELONG_SALER 工号转换
*/
/** 未删除用户映射,用于唯一工号匹配。 */
List<SysUser> selectUserLoginMap();
/** 读取存量客户的人工业务员选择和上次同步的源工号。 */
List<BmsCustomer> selectExistingSalesAssignments();
/**
* 新增客户(同步专用,字段集与老系统导入脚本一致)
*/
@@ -587,7 +587,7 @@ public class BmsCustomerLogServiceImpl implements IBmsCustomerLogService
return s == null ? "" : s;
}
/** 人员字段展示:姓名(工号),缺姓名时只显示工号 */
/** 人员字段展示:姓名(系统用户ID),缺姓名时只显示系统用户ID。 */
private static String userDisplay(String code, String name)
{
String c = trimToNull(code);
@@ -82,7 +82,7 @@ public class BmsCustomerServiceImpl implements IBmsCustomerService
* @return 客户主
*/
@Override
// sales_user_id 存的是员工工号(=sys_user.user_name),仅本人/本部门均通过 sys_user 别名 su 关联匹配
// sales_user_id 只存系统 user_id;数据权限通过该用户的部门和用户范围判断。
@DataScope(deptAlias = "su", deptField = "dept_id", userAlias = "su", userField = "user_id")
public List<BmsCustomer> selectBmsCustomerList(BmsCustomer bmsCustomer)
{
@@ -275,6 +275,7 @@ public class BmsCustomerServiceImpl implements IBmsCustomerService
public int insertBmsCustomer(BmsCustomer bmsCustomer)
{
checkContractDate(bmsCustomer.getContractStartDate(), bmsCustomer.getContractEndDate());
normalizeAndValidateSalesUserId(bmsCustomer);
SysUser user = SecurityUtils.getLoginUser().getUser();
bmsCustomer.setCreateTime(DateUtils.getNowDate());
bmsCustomer.setCreateBy(user.getUserName());
@@ -303,6 +304,7 @@ public class BmsCustomerServiceImpl implements IBmsCustomerService
public int updateBmsCustomer(BmsCustomer bmsCustomer)
{
checkContractDate(bmsCustomer.getContractStartDate(), bmsCustomer.getContractEndDate());
normalizeAndValidateSalesUserId(bmsCustomer);
SysUser user = SecurityUtils.getLoginUser().getUser();
bmsCustomer.setUpdateTime(DateUtils.getNowDate());
bmsCustomer.setUpdateBy(user.getUserName());
@@ -518,4 +520,21 @@ public class BmsCustomerServiceImpl implements IBmsCustomerService
throw new ServiceException("合同结束时间不能早于开始时间");
}
}
/** 客户编辑仅允许写入真实存在的系统用户ID,不接受工号作为兼容值。 */
private void normalizeAndValidateSalesUserId(BmsCustomer customer)
{
String salesUserId = customer.getSalesUserId();
if (salesUserId == null || salesUserId.trim().isEmpty())
{
customer.setSalesUserId(null);
return;
}
salesUserId = salesUserId.trim();
if (bmsCustomerMapper.countValidSalesUserId(salesUserId) == 0)
{
throw new ServiceException("业务员不存在或已删除,请从业务员选择器中重新选择");
}
customer.setSalesUserId(salesUserId);
}
}
@@ -9,6 +9,7 @@ import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -23,7 +24,7 @@ import com.bms.customer.customerinfo.mapper.CustomerSyncMapper;
* 老系统 Oracle TAB_CUSTOMER -> ctr_customer 每日同步。
*
* 字段映射、字典转换与 D:\\生产客户数据\\scripts\\import_customers.py 完全一致:
* 按 customer_code 判定新增/更新;源为空的字段不写(走默认值/保留原值)。
* 按 customer_code 判定新增/更新;普通资料的源空字段不写,业务员归属则按匹配结果明确写入ID或NULL。
*
* @author bms
*/
@@ -59,7 +60,6 @@ public class CustomerSyncService
private static final int LEN_CODE = 50;
private static final int LEN_NAME = 100;
private static final int LEN_CODE_DW = 50;
private static final int LEN_USER_ID = 64;
private static final int LEN_USER = 50;
private static final int LEN_REMARK = 500;
/** 告警明细最多打印条数 */
@@ -86,8 +86,15 @@ public class CustomerSyncService
Set<String> existingCodes = new HashSet<>(customerSyncMapper.selectExistingCodes());
// 业务员归属转本系统用户ID:数据权限按 user_id 控制,工号后续修改不影响归属
Map<String, String> userIdByLogin = loadUserIdByLogin();
// 源工号只用于匹配;归属字段始终保存系统 user_id。
Map<String, List<String>> userIdsByLogin = loadUserIdsByLogin();
Set<String> validUserIds = new HashSet<>();
userIdsByLogin.values().forEach(validUserIds::addAll);
Map<String, BmsCustomer> existingSalesAssignments = new HashMap<>();
for (BmsCustomer existing : customerSyncMapper.selectExistingSalesAssignments())
{
existingSalesAssignments.put(existing.getCustomerCode(), existing);
}
List<BmsCustomer> inserts = new ArrayList<>();
List<BmsCustomer> updates = new ArrayList<>();
@@ -123,7 +130,8 @@ public class CustomerSyncService
continue;
}
BmsCustomer c = mapRow(row, code, name, userIdByLogin, warnings);
BmsCustomer c = mapRow(row, code, name, userIdsByLogin,
validUserIds, existingSalesAssignments.get(code), warnings);
if (existingCodes.contains(code))
{
c.setUpdateBy(SYNC_MARKER);
@@ -161,7 +169,8 @@ public class CustomerSyncService
* 单行映射(逻辑对齐 import_customers.py 的 build_row)
*/
private BmsCustomer mapRow(Map<String, Object> row, String code, String name,
Map<String, String> userIdByLogin, List<String> warnings)
Map<String, List<String>> userIdsByLogin, Set<String> validUserIds,
BmsCustomer existingAssignment, List<String> warnings)
{
BmsCustomer c = new BmsCustomer();
c.setCustomerCode(truncate(code, LEN_CODE, code, "customer_code", warnings));
@@ -169,7 +178,10 @@ public class CustomerSyncService
c.setDelFlag("0");
c.setCustomerCodeDw(truncate(getStr(row, "CUSTOMER_OUTER_CODE"), LEN_CODE_DW, code, "customer_code_dw", warnings));
c.setSalesUserId(resolveSalesUserId(clean(getStr(row, "BELONG_SALER")), userIdByLogin, code, warnings));
String sourceSalesCode = clean(getStr(row, "BELONG_SALER"));
c.setSourceSalesCode(sourceSalesCode);
c.setSalesUserId(resolveSalesUserId(sourceSalesCode, userIdsByLogin,
validUserIds, existingAssignment, code, warnings));
c.setSalesUser(truncate(getStr(row, "BELONG_SALER_NAME"), LEN_USER, code, "sales_user", warnings));
c.setSettlementUser(truncate(getStr(row, "SETTLEMENT_MAN"), LEN_USER, code, "settlement_user", warnings));
c.setServiceUser(truncate(getStr(row, "SERVICE_MAN"), LEN_USER, code, "service_user", warnings));
@@ -231,40 +243,56 @@ public class CustomerSyncService
return ("1".equals(s) || "1.0".equals(s)) ? "0" : "1";
}
/** 加载未删除用户的 工号(user_name)->用户ID(user_id) 映射 */
private Map<String, String> loadUserIdByLogin()
/** 加载未删除用户的工号映射;保留重复项以便将歧义匹配按未关联处理。 */
private Map<String, List<String>> loadUserIdsByLogin()
{
Map<String, String> map = new HashMap<>();
Map<String, List<String>> map = new HashMap<>();
for (SysUser u : customerSyncMapper.selectUserLoginMap())
{
if (u != null && u.getUserName() != null && u.getUserId() != null
&& !map.containsKey(u.getUserName()))
if (u != null && clean(u.getUserName()) != null && u.getUserId() != null)
{
map.put(u.getUserName(), String.valueOf(u.getUserId()));
map.computeIfAbsent(u.getUserName().trim(), key -> new ArrayList<>())
.add(String.valueOf(u.getUserId()));
}
}
return map;
}
/**
* 业务员工号 -> 本系统 sys_user.user_id 转换(数据权限按 user_id 控制,工号修改不影响归属)。
* 工号在系统用户中不存在时保留原工号并告警(常见于该用户尚未同步),避免归属信息丢失。
* 只在唯一匹配时关联系统用户。
* 源工号不变时保留客户页面人工选择的有效业务员;工号为空时也保留有效业务员。
*/
private String resolveSalesUserId(String salerCode, Map<String, String> userIdByLogin,
private String resolveSalesUserId(String salerCode, Map<String, List<String>> userIdsByLogin,
Set<String> validUserIds, BmsCustomer existingAssignment,
String code, List<String> warnings)
{
String s = truncate(salerCode, LEN_USER_ID, code, "sales_user_id", warnings);
if (s == null)
String existingUserId = existingAssignment == null ? null : clean(existingAssignment.getSalesUserId());
boolean existingUserValid = existingUserId != null && validUserIds.contains(existingUserId);
if (salerCode == null)
{
return null;
return existingUserValid ? existingUserId : null;
}
String userId = userIdByLogin.get(s);
if (userId != null)
boolean sourceCodeUnchanged = existingAssignment != null
&& Objects.equals(salerCode, clean(existingAssignment.getSourceSalesCode()));
boolean sourceCodeUnknown = existingAssignment != null
&& clean(existingAssignment.getSourceSalesCode()) == null;
if (existingUserValid && (sourceCodeUnchanged || sourceCodeUnknown))
{
return userId;
// 历史有效用户ID可能尚无可恢复的源工号;首次同步建立源工号基线时不覆盖已有人工归属。
return existingUserId;
}
warnings.add(code + ": 业务员工号 " + s + " 在系统用户中不存在,sales_user_id 保留原工号");
return s;
List<String> matchedUserIds = userIdsByLogin.get(salerCode);
if (matchedUserIds != null && matchedUserIds.size() == 1)
{
return matchedUserIds.get(0);
}
warnings.add(code + (matchedUserIds == null || matchedUserIds.isEmpty()
? ": 业务员工号未匹配到系统用户,客户保持未关联"
: ": 业务员工号匹配到多个系统用户,客户保持未关联"));
return null;
}
private static String clean(String v)
@@ -45,7 +45,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<!-- 信用额度=总额度+额外额度-已用额度-->
<sql id="selectBmsCustomerVo">
select c.customer_id, c.customer_code, c.customer_name, c.customer_code_dw, c.credit_code, c.customer_type,
c.is_enable, c.sales_user_id, c.sales_user, c.ed, c.zq, c.settlement_user_id, c.settlement_user,
c.is_enable,
case when su.user_id is not null then cast(su.user_id as char) else null end as sales_user_id,
su.nick_name as sales_user, c.ed, c.zq, c.settlement_user_id, c.settlement_user,
c.service_user_id, c.service_user, c.settlement_type, c.temp_ed,
(select coalesce(sum(ca.balance), 0) from ctr_customer_account ca
where ca.customer_id = c.customer_id and ca.currency = 'CNY' and ca.del_flag = '0') as account_balance,
@@ -61,11 +63,10 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
su.nick_name as sales_user_nick_name,
cps.price_set_id, ps.price_set_name
from ctr_customer c
<!-- sales_user_id 统一存本系统 user_id(同步时由工号转换、手工录入选人即存ID),
数据权限按 su.user_id / su.dept_id 控制;保留 user_name 匹配仅作历史未转换工号兜底 -->
<!-- sales_user_id 只保存 sys_user.user_id;源工号不参与客户查询和权限匹配。 -->
left join sys_user su
on su.del_flag = '0'
and (su.user_id = c.sales_user_id + 0 or su.user_name = c.sales_user_id)
and cast(su.user_id as char) = c.sales_user_id
<!-- 客户套餐绑定:uk_customer_one_set 保证一客户至多一条,不会放大行数 -->
left join prc_customer_price_set cps on cps.customer_id = c.customer_id
left join prc_price_set ps on ps.price_set_id = cps.price_set_id
@@ -92,7 +93,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="customerType != null and customerType != ''"> and c.customer_type = #{customerType}</if>
<if test="isEnable != null and isEnable != ''"> and c.is_enable = #{isEnable}</if>
<if test="salesUserId != null and salesUserId != ''"> and c.sales_user_id = #{salesUserId}</if>
<if test="salesUser != null and salesUser != ''"> and c.sales_user = #{salesUser}</if>
<if test="salesUser != null and salesUser != ''"> and su.nick_name = #{salesUser}</if>
<if test="ed != null "> and c.ed = #{ed}</if>
<if test="zq != null "> and c.zq = #{zq}</if>
<if test="settlementUserId != null and settlementUserId != ''"> and c.settlement_user_id = #{settlementUserId}</if>
@@ -111,6 +112,13 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
order by c.customer_id desc
</select>
<select id="countValidSalesUserId" resultType="int">
select count(1)
from sys_user
where user_id = #{salesUserId}
and del_flag = '0'
</select>
<select id="selectBmsCustomerByCustomerId" parameterType="Long" resultMap="BmsCustomerResult">
<include refid="selectBmsCustomerVo"/>
where c.customer_id = #{customerId} and c.del_flag = '0'
@@ -8,22 +8,28 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
SELECT customer_code FROM ctr_customer WHERE del_flag = '0'
</select>
<!-- 工号->用户ID 映射(列别名显式映射,项目未开下划线转驼峰) -->
<!-- 工号->用户ID 候选;Java 层检测重复工号,不任意取一条。 -->
<select id="selectUserLoginMap" resultType="com.bms.common.core.domain.entity.SysUser">
SELECT user_id AS userId, user_name AS userName
FROM sys_user
WHERE del_flag = '0'
</select>
<!-- 字段集与 scripts/import_customers.py 的 MAP_COLUMNS 保持一致;
源行为空的字段不写,走数据库默认值 -->
<select id="selectExistingSalesAssignments" resultType="com.bms.customer.customerinfo.domain.BmsCustomer">
SELECT customer_code AS customerCode,
sales_user_id AS salesUserId,
source_sales_code AS sourceSalesCode
FROM ctr_customer
WHERE del_flag = '0'
</select>
<!-- 普通客户资料沿用源空值不覆盖;业务员ID和源工号始终按本次匹配结果写入,允许明确清空。 -->
<insert id="insertSyncCustomer" parameterType="com.bms.customer.customerinfo.domain.BmsCustomer">
INSERT INTO ctr_customer (
customer_code, customer_name, is_enable,
del_flag, create_by
del_flag, create_by, sales_user_id, source_sales_code
<if test="customerCodeDw != null">, customer_code_dw</if>
<if test="customerType != null">, customer_type</if>
<if test="salesUserId != null">, sales_user_id</if>
<if test="salesUser != null">, sales_user</if>
<if test="settlementUser != null">, settlement_user</if>
<if test="serviceUser != null">, service_user</if>
@@ -37,10 +43,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="createTime != null">, create_time</if>
) VALUES (
#{customerCode}, #{customerName}, #{isEnable},
'0', #{createBy}
'0', #{createBy}, #{salesUserId}, #{sourceSalesCode}
<if test="customerCodeDw != null">, #{customerCodeDw}</if>
<if test="customerType != null">, #{customerType}</if>
<if test="salesUserId != null">, #{salesUserId}</if>
<if test="salesUser != null">, #{salesUser}</if>
<if test="settlementUser != null">, #{settlementUser}</if>
<if test="serviceUser != null">, #{serviceUser}</if>
@@ -61,7 +66,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="customerName != null">, customer_name = #{customerName}</if>
<if test="customerCodeDw != null">, customer_code_dw = #{customerCodeDw}</if>
<if test="customerType != null">, customer_type = #{customerType}</if>
<if test="salesUserId != null">, sales_user_id = #{salesUserId}</if>
, sales_user_id = #{salesUserId}
, source_sales_code = #{sourceSalesCode}
<if test="salesUser != null">, sales_user = #{salesUser}</if>
<if test="settlementUser != null">, settlement_user = #{settlementUser}</if>
<if test="serviceUser != null">, service_user = #{serviceUser}</if>
@@ -93,8 +93,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
AND ifnull(c.del_flag, '0') = '0'
INNER JOIN sys_user su
ON su.del_flag = '0'
AND (su.user_name = c.sales_user_id COLLATE utf8mb4_general_ci
OR su.user_id = c.sales_user_id + 0)
AND cast(su.user_id as char) = c.sales_user_id
WHERE cps.price_set_id = s.price_set_id
${params.dataScope}
)
@@ -1,10 +1,4 @@
-- 客户归属销售员由"工号"改存本系统用户ID(数据权限按 user_id 控制,工号后续修改不影响归属)
-- 同步链路已在 CustomerSyncService.resolveSalesUserId 做同样转换,本脚本只处理存量数据。
-- 仅转换"含非数字字符"的工号值(工号形如 WS0052/WSC0184/wsadmin);
-- 已是数字(user_id)的行不触碰,脚本可重复执行。
UPDATE ctr_customer c
JOIN sys_user u ON u.del_flag = '0' AND u.user_name = c.sales_user_id
SET c.sales_user_id = CONVERT(u.user_id, CHAR)
WHERE c.del_flag = '0'
AND c.sales_user_id IS NOT NULL
AND c.sales_user_id REGEXP '[^0-9]';
-- DEPRECATED: 不要执行本文件。
-- 旧版脚本仅按非数字判断工号并直接 UPDATE,无法安全处理重复工号和数字工号/user_id 冲突。
-- 请先执行 customer+supplier/20261028_customer_sales_user_classification.sql 查看清单,
-- 再按上线顺序执行 customer+supplier/20261028_customer_sales_user_migration.sql。
+2 -1
View File
@@ -356,7 +356,8 @@ CREATE TABLE `ctr_customer` (
`credit_code` varchar(18) DEFAULT '' COMMENT '统一社会信用代码',
`customer_type` char(1) DEFAULT '0' COMMENT '业务类型(0直客 1同行 2集运)',
`is_enable` char(1) DEFAULT '0' COMMENT '启用状态(0正常 1停用)',
`sales_user_id` varchar(64) DEFAULT '' COMMENT '业务员用户ID',
`sales_user_id` varchar(64) DEFAULT NULL COMMENT '业务员系统用户ID(sys_user.user_id)',
`source_sales_code` varchar(255) DEFAULT NULL COMMENT '客户同步源业务员工号(内部字段)',
`sales_user` varchar(50) DEFAULT '' COMMENT '业务员姓名',
`ed` decimal(15,2) DEFAULT '0.00' COMMENT '资金额度(元)',
`zq` int(11) DEFAULT '0' COMMENT '回款账期(天)',
@@ -0,0 +1,75 @@
-- MySQL 5.7
-- 客户业务员存量迁移前分类清单(只读,不修改数据)。
-- 请先执行本文件,检查 ID/工号冲突,再执行 20261028_customer_sales_user_migration.sql。
-- 分类依据:精确匹配 sys_user.user_id;或按区分大小写的 sys_user.user_name 唯一匹配。
-- 分类汇总:VALID_USER_ID 保留;UNIQUE_USERNAME 可安全转换;其余分类写入冲突/未匹配清单。
SELECT classified.classification, COUNT(*) AS customer_count
FROM (
SELECT c.customer_id,
CASE
WHEN c.sales_user_id IS NULL OR TRIM(c.sales_user_id) = '' THEN 'EMPTY'
WHEN id_match.match_count > 0 AND name_match.match_count > 0 THEN 'ID_USERNAME_COLLISION'
WHEN id_match.match_count > 0 THEN 'VALID_USER_ID'
WHEN name_match.match_count = 1 THEN 'UNIQUE_USERNAME'
WHEN name_match.match_count > 1 THEN 'AMBIGUOUS_USERNAME'
ELSE 'NO_MATCH'
END AS classification
FROM ctr_customer c
LEFT JOIN (
SELECT CAST(user_id AS CHAR) AS lookup_value, COUNT(*) AS match_count
FROM sys_user
WHERE del_flag = '0'
GROUP BY CAST(user_id AS CHAR)
) id_match ON BINARY id_match.lookup_value = BINARY TRIM(c.sales_user_id)
LEFT JOIN (
SELECT BINARY TRIM(user_name) AS lookup_value, COUNT(*) AS match_count
FROM sys_user
WHERE del_flag = '0'
AND user_name IS NOT NULL
AND user_name != ''
GROUP BY BINARY TRIM(user_name)
) name_match ON name_match.lookup_value = BINARY TRIM(c.sales_user_id)
WHERE c.del_flag = '0'
) classified
GROUP BY classified.classification
ORDER BY classified.classification;
-- 逐条分类。冲突项会列出原值、user_id 候选和 user_name 候选,供管理员人工核实。
SELECT c.customer_id,
c.customer_code,
c.customer_name,
c.sales_user_id AS original_sales_user_id,
CASE
WHEN c.sales_user_id IS NULL OR TRIM(c.sales_user_id) = '' THEN 'EMPTY'
WHEN id_match.match_count > 0 AND name_match.match_count > 0 THEN 'ID_USERNAME_COLLISION'
WHEN id_match.match_count > 0 THEN 'VALID_USER_ID'
WHEN name_match.match_count = 1 THEN 'UNIQUE_USERNAME'
WHEN name_match.match_count > 1 THEN 'AMBIGUOUS_USERNAME'
ELSE 'NO_MATCH'
END AS classification,
COALESCE(id_match.match_count, 0) AS user_id_match_count,
COALESCE(name_match.match_count, 0) AS user_name_match_count,
id_match.candidate_user_ids AS user_id_candidates,
name_match.candidate_user_ids AS user_name_candidates
FROM ctr_customer c
LEFT JOIN (
SELECT CAST(user_id AS CHAR) AS lookup_value,
COUNT(*) AS match_count,
GROUP_CONCAT(CAST(user_id AS CHAR) ORDER BY user_id SEPARATOR ',') AS candidate_user_ids
FROM sys_user
WHERE del_flag = '0'
GROUP BY CAST(user_id AS CHAR)
) id_match ON BINARY id_match.lookup_value = BINARY TRIM(c.sales_user_id)
LEFT JOIN (
SELECT BINARY TRIM(user_name) AS lookup_value,
COUNT(*) AS match_count,
GROUP_CONCAT(CAST(user_id AS CHAR) ORDER BY user_id SEPARATOR ',') AS candidate_user_ids
FROM sys_user
WHERE del_flag = '0'
AND user_name IS NOT NULL
AND user_name != ''
GROUP BY BINARY TRIM(user_name)
) name_match ON name_match.lookup_value = BINARY TRIM(c.sales_user_id)
WHERE c.del_flag = '0'
ORDER BY classification, c.customer_id;
@@ -0,0 +1,142 @@
-- MySQL 5.7
-- 客户业务员工号 -> 系统用户ID 存量迁移。
-- 上线前先执行 20261028_customer_sales_user_classification.sql 并核对 ID_USERNAME_COLLISION。
-- 自动转换仅处理唯一 user_name 匹配;未匹配、重复匹配和 ID/工号冲突会备份原值、清空 sales_user_id,留给管理员在客户编辑页处理。
-- 本脚本不连接数据库执行;可重复运行。完成标记写入后,重复运行不会再次处理存量或改写备份。
-- 新增内部源工号列(兼容 MySQL 5.7,不依赖 ADD COLUMN IF NOT EXISTS)。
SET @add_source_sales_code_sql = (
SELECT IF(COUNT(*) = 0,
'ALTER TABLE ctr_customer ADD COLUMN source_sales_code varchar(255) DEFAULT NULL COMMENT ''客户同步源业务员工号(内部字段)'' AFTER sales_user_id',
'SELECT 1')
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'ctr_customer'
AND column_name = 'source_sales_code'
);
PREPARE add_source_sales_code_stmt FROM @add_source_sales_code_sql;
EXECUTE add_source_sales_code_stmt;
DEALLOCATE PREPARE add_source_sales_code_stmt;
-- 未匹配时使用 NULL,避免空字符串伪装成用户ID。
ALTER TABLE ctr_customer
MODIFY COLUMN sales_user_id varchar(64) DEFAULT NULL COMMENT '业务员系统用户ID(sys_user.user_id)';
-- 备份表同时保留分类结果和候选ID;PRIMARY KEY + INSERT IGNORE 保证原始值不会被后续重跑覆盖。
CREATE TABLE IF NOT EXISTS ctr_customer_sales_user_migration_backup (
customer_id bigint(20) NOT NULL COMMENT '客户ID',
customer_code varchar(50) NOT NULL COMMENT '客户编码',
original_sales_user_id varchar(64) DEFAULT NULL COMMENT '迁移前 sales_user_id 原值',
classification varchar(40) NOT NULL COMMENT '迁移前分类',
user_id_match_count int NOT NULL DEFAULT 0 COMMENT '精确 user_id 匹配数',
user_name_match_count int NOT NULL DEFAULT 0 COMMENT '精确 user_name 匹配数',
user_id_candidates text COMMENT 'user_id 匹配候选',
user_name_candidates text COMMENT 'user_name 匹配候选',
backup_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '备份时间',
PRIMARY KEY (customer_id),
KEY idx_customer_sales_user_backup_classification (classification)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客户业务员ID迁移原值及分类备份';
-- 防止成功执行后重跑时把后来新建/人工维护的客户重新当作存量数据处理。
CREATE TABLE IF NOT EXISTS ctr_customer_sales_user_migration_control (
migration_key varchar(64) NOT NULL,
status varchar(16) NOT NULL DEFAULT 'PENDING',
completed_at datetime DEFAULT NULL,
PRIMARY KEY (migration_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客户业务员ID迁移执行状态';
INSERT IGNORE INTO ctr_customer_sales_user_migration_control (migration_key, status)
VALUES ('20261028_customer_sales_user', 'PENDING');
START TRANSACTION;
-- 首次运行时先保留旧值和候选;后续事务失败可安全重跑,成功后由 control 表阻止再次处理。
INSERT IGNORE INTO ctr_customer_sales_user_migration_backup (
customer_id, customer_code, original_sales_user_id, classification,
user_id_match_count, user_name_match_count, user_id_candidates, user_name_candidates
)
SELECT c.customer_id,
c.customer_code,
c.sales_user_id,
CASE
WHEN c.sales_user_id IS NULL OR TRIM(c.sales_user_id) = '' THEN 'EMPTY'
WHEN id_match.match_count > 0 AND name_match.match_count > 0 THEN 'ID_USERNAME_COLLISION'
WHEN id_match.match_count > 0 THEN 'VALID_USER_ID'
WHEN name_match.match_count = 1 THEN 'UNIQUE_USERNAME'
WHEN name_match.match_count > 1 THEN 'AMBIGUOUS_USERNAME'
ELSE 'NO_MATCH'
END,
COALESCE(id_match.match_count, 0),
COALESCE(name_match.match_count, 0),
id_match.candidate_user_ids,
name_match.candidate_user_ids
FROM ctr_customer c
JOIN ctr_customer_sales_user_migration_control migration
ON migration.migration_key = '20261028_customer_sales_user'
AND migration.status = 'PENDING'
LEFT JOIN (
SELECT CAST(user_id AS CHAR) AS lookup_value,
COUNT(*) AS match_count,
GROUP_CONCAT(CAST(user_id AS CHAR) ORDER BY user_id SEPARATOR ',') AS candidate_user_ids
FROM sys_user
WHERE del_flag = '0'
GROUP BY CAST(user_id AS CHAR)
) id_match ON BINARY id_match.lookup_value = BINARY TRIM(c.sales_user_id)
LEFT JOIN (
SELECT BINARY TRIM(user_name) AS lookup_value,
COUNT(*) AS match_count,
GROUP_CONCAT(CAST(user_id AS CHAR) ORDER BY user_id SEPARATOR ',') AS candidate_user_ids
FROM sys_user
WHERE del_flag = '0'
AND user_name IS NOT NULL
AND user_name != ''
GROUP BY BINARY TRIM(user_name)
) name_match ON name_match.lookup_value = BINARY TRIM(c.sales_user_id)
WHERE c.del_flag = '0'
AND c.source_sales_code IS NULL;
-- 唯一工号映射到系统用户ID;不唯一、不匹配或存在ID/工号冲突的项不猜测,清空关联并保留源值。
UPDATE ctr_customer c
JOIN ctr_customer_sales_user_migration_backup b
ON b.customer_id = c.customer_id
JOIN ctr_customer_sales_user_migration_control migration
ON migration.migration_key = '20261028_customer_sales_user'
AND migration.status = 'PENDING'
SET c.source_sales_code = b.original_sales_user_id,
c.sales_user_id = CASE
WHEN b.classification = 'UNIQUE_USERNAME'
THEN b.user_name_candidates
ELSE NULL
END
WHERE c.del_flag = '0'
AND c.source_sales_code IS NULL
AND b.classification IN (
'UNIQUE_USERNAME',
'AMBIGUOUS_USERNAME',
'NO_MATCH',
'ID_USERNAME_COLLISION'
);
-- 将旧空字符串归一为 NULL,已确认是系统用户ID的行保持不变。
UPDATE ctr_customer c
JOIN ctr_customer_sales_user_migration_control migration
ON migration.migration_key = '20261028_customer_sales_user'
AND migration.status = 'PENDING'
SET c.sales_user_id = NULL
WHERE c.del_flag = '0'
AND c.sales_user_id IS NOT NULL
AND TRIM(c.sales_user_id) = '';
UPDATE ctr_customer_sales_user_migration_control
SET status = 'DONE', completed_at = NOW()
WHERE migration_key = '20261028_customer_sales_user'
AND status = 'PENDING';
COMMIT;
-- 执行后核对人工处理清单;原值和候选均保存在备份表中。
SELECT customer_id, customer_code, original_sales_user_id, classification,
user_id_candidates, user_name_candidates
FROM ctr_customer_sales_user_migration_backup
WHERE classification IN ('ID_USERNAME_COLLISION', 'AMBIGUOUS_USERNAME', 'NO_MATCH')
ORDER BY classification, customer_id;
@@ -148,7 +148,7 @@
</template>
</el-table-column>
<el-table-column v-if="isColVisible('salesUserId')" label="业务员" align="center" prop="salesUserId">
<template #default="scope">{{ scope.row.salesUserNickName || scope.row.salesUser || scope.row.salesUserId }}</template>
<template #default="scope">{{ scope.row.salesUserNickName || '' }}</template>
</el-table-column>
<!-- <el-table-column v-if="isColVisible('ed')" label="资金额度" align="center" prop="ed" />-->
@@ -393,7 +393,7 @@ const copyColumns = computed(() => [
{ key: 'creditCode', label: '统一社会信用代码', get: r => r.creditCode || '' },
{ key: 'customerType', label: '业务类型', get: r => proxy.selectDictLabel(customer_type.value, r.customerType) },
{ key: 'isEnable', label: '启用状态', get: r => proxy.selectDictLabel(sys_normal_disable.value, r.isEnable) },
{ key: 'salesUserId', label: '业务员', get: r => r.salesUserNickName || r.salesUser || r.salesUserId || '' },
{ key: 'salesUserId', label: '业务员', get: r => r.salesUserNickName || '' },
{ key: 'zq', label: '回款账期', get: r => (r.zq === null || r.zq === undefined ? '' : r.zq) },
{ key: 'settlementUserId', label: '结算员', get: r => getUserName(r.settlementUserId) },
{ key: 'serviceUserId', label: '客服', get: r => getUserName(r.serviceUserId) },