diff --git a/README.md b/README.md index 04a53d7..b691344 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,12 @@ curl http://localhost:8080/api/store/list ``` 脚本新增 `customer_user_id`、`created_by_user_id`、`author_staff_id` 并回填可确定的历史数据。末尾三个验证查询必须留档;`unresolved_assisted_appointments` 非 0 时需人工确认,禁止把旧员工 `user_id` 冒认成客户。 +5. **门店客户主档**:身份拆分验证完成后,紧接着执行: + ```bash + mysql -h -u -p petstore < db/migrations/20260801_create_store_customer.sql + ``` + 脚本创建 `t_store_customer`,从预约和报告留资回填本店客户关系。末尾 `appointments_without_store_customer`、`leads_without_store_customer`、`store_customers_linked_to_non_customer` 必须全部为 0。 + ### production profile ```bash @@ -141,6 +147,7 @@ npm --prefix frontend run build:mp-weixin - [ ] `uk_report_appointment` 唯一约束上线前已清理重复数据 - [ ] 历史图片 URL 已执行一次性修复 SQL - [ ] 已执行 `20260801_split_service_identity.sql`,并留存未解析预约/报告验证结果 +- [ ] 已按顺序执行 `20260801_create_store_customer.sql`,三项主档验证计数均为 0 - [ ] 已暴露凭据(DB 密码、微信 AppSecret)已轮换 ## 安全说明 diff --git a/db/migrations/20260801_create_store_customer.sql b/db/migrations/20260801_create_store_customer.sql new file mode 100644 index 0000000..3729211 --- /dev/null +++ b/db/migrations/20260801_create_store_customer.sql @@ -0,0 +1,138 @@ +-- Petstore Phase 0: create stable store-customer relationship master. +-- Target: MySQL. Run after 20260801_split_service_identity.sql. +-- One-time migration; back up the database before execution. + +CREATE TABLE t_store_customer ( + id BIGINT NOT NULL AUTO_INCREMENT, + store_id BIGINT NOT NULL COMMENT '门店 ID', + customer_user_id BIGINT NULL COMMENT '全局 customer 账号,留资客户可空', + phone VARCHAR(20) NULL COMMENT '本店最后确认的联系手机号', + display_name VARCHAR(64) NULL COMMENT '门店视角客户称呼', + source VARCHAR(32) NOT NULL COMMENT '首次来源', + first_contact_at DATETIME NULL COMMENT '首次接触时间', + last_contact_at DATETIME NULL COMMENT '最近接触时间', + create_time DATETIME NOT NULL, + update_time DATETIME NOT NULL, + deleted TINYINT(1) NOT NULL DEFAULT 0, + PRIMARY KEY (id), + UNIQUE KEY uk_store_customer_user (store_id, customer_user_id), + UNIQUE KEY uk_store_customer_phone (store_id, phone), + KEY idx_store_customer_active_update (store_id, deleted, update_time), + KEY idx_store_customer_last_contact (store_id, last_contact_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='门店客户关系主档'; + +-- 历史预约客户建档。旧数据无法精确确定首次是自助或代客,统一记为 appointment。 +INSERT INTO t_store_customer ( + store_id, + customer_user_id, + phone, + display_name, + source, + first_contact_at, + last_contact_at, + create_time, + update_time, + deleted +) +SELECT + a.store_id, + a.customer_user_id, + MAX(u.phone), + MAX(COALESCE(NULLIF(u.name, ''), NULLIF(u.username, ''), '客户')), + 'appointment', + MIN(COALESCE(a.create_time, a.appointment_time, NOW())), + MAX(COALESCE(a.update_time, a.create_time, a.appointment_time, NOW())), + NOW(), + NOW(), + 0 +FROM t_appointment a +JOIN t_user u + ON u.id = a.customer_user_id + AND u.deleted = 0 + AND u.role = 'customer' +WHERE a.deleted = 0 + AND a.store_id IS NOT NULL + AND a.customer_user_id IS NOT NULL +GROUP BY a.store_id, a.customer_user_id; + +-- 报告留资建档。若同店同手机已由预约建档,只更新最近接触时间并保留首次来源。 +INSERT INTO t_store_customer ( + store_id, + customer_user_id, + phone, + display_name, + source, + first_contact_at, + last_contact_at, + create_time, + update_time, + deleted +) +SELECT + l.store_id, + MAX(CASE WHEN u.role = 'customer' AND u.deleted = 0 THEN u.id ELSE NULL END), + l.phone, + MAX(CASE + WHEN u.role = 'customer' AND u.deleted = 0 + THEN COALESCE(NULLIF(u.name, ''), NULLIF(u.username, ''), '留资客户') + ELSE '留资客户' + END), + 'report_lead', + MIN(COALESCE(l.create_time, NOW())), + MAX(COALESCE(l.update_time, l.create_time, NOW())), + NOW(), + NOW(), + 0 +FROM t_report_lead l +LEFT JOIN t_user u + ON u.phone = l.phone +WHERE l.store_id IS NOT NULL + AND l.phone IS NOT NULL + AND l.phone <> '' +GROUP BY l.store_id, l.phone +ON DUPLICATE KEY UPDATE + customer_user_id = COALESCE(t_store_customer.customer_user_id, VALUES(customer_user_id)), + phone = COALESCE(t_store_customer.phone, VALUES(phone)), + display_name = CASE + WHEN t_store_customer.display_name IS NULL OR t_store_customer.display_name = '' + THEN VALUES(display_name) + ELSE t_store_customer.display_name + END, + first_contact_at = LEAST( + COALESCE(t_store_customer.first_contact_at, VALUES(first_contact_at)), + VALUES(first_contact_at) + ), + last_contact_at = GREATEST( + COALESCE(t_store_customer.last_contact_at, VALUES(last_contact_at)), + VALUES(last_contact_at) + ), + update_time = NOW(), + deleted = 0; + +-- Verification queries. All three counts should be 0 before application release. +SELECT COUNT(*) AS appointments_without_store_customer +FROM t_appointment a +LEFT JOIN t_store_customer sc + ON sc.store_id = a.store_id + AND sc.customer_user_id = a.customer_user_id + AND sc.deleted = 0 +WHERE a.deleted = 0 + AND a.customer_user_id IS NOT NULL + AND sc.id IS NULL; + +SELECT COUNT(*) AS leads_without_store_customer +FROM t_report_lead l +LEFT JOIN t_store_customer sc + ON sc.store_id = l.store_id + AND sc.phone = l.phone + AND sc.deleted = 0 +WHERE l.store_id IS NOT NULL + AND l.phone IS NOT NULL + AND l.phone <> '' + AND sc.id IS NULL; + +SELECT COUNT(*) AS store_customers_linked_to_non_customer +FROM t_store_customer sc +JOIN t_user u ON u.id = sc.customer_user_id +WHERE sc.deleted = 0 + AND (u.deleted <> 0 OR u.role <> 'customer'); diff --git a/db/migrations/README.md b/db/migrations/README.md index c5affb5..5de6b8a 100644 --- a/db/migrations/README.md +++ b/db/migrations/README.md @@ -10,4 +10,6 @@ `20260801_split_service_identity.sql` 为身份语义拆分迁移:新增客户、创建人和报告作者字段,保留 `user_id` / `assigned_user_id` 兼容旧版本。 +`20260801_create_store_customer.sql` 必须在上述身份迁移之后执行:建立 `t_store_customer` 门店客户主档,并从预约与报告留资回填历史关系。脚本末尾三个验证计数必须为 0。 + 正式迁移框架仍属于 Phase 0 后续任务;接入前不得把本目录误当成自动执行目录。 diff --git a/src/main/java/com/petstore/entity/StoreCustomer.java b/src/main/java/com/petstore/entity/StoreCustomer.java new file mode 100644 index 0000000..dfccc03 --- /dev/null +++ b/src/main/java/com/petstore/entity/StoreCustomer.java @@ -0,0 +1,78 @@ +package com.petstore.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 门店与客户的稳定关系主档。 + * + *

它不是会员资产表;余额、积分、次卡不属于本聚合。 + * customerUserId 允许为空,用于承接尚未登录/建号的报告留资客户。 + */ +@Data +@Entity +@Table( + name = "t_store_customer", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_store_customer_user", + columnNames = {"store_id", "customer_user_id"} + ), + @UniqueConstraint( + name = "uk_store_customer_phone", + columnNames = {"store_id", "phone"} + ) + }, + indexes = { + @Index(name = "idx_store_customer_active_update", columnList = "store_id,deleted,update_time"), + @Index(name = "idx_store_customer_last_contact", columnList = "store_id,last_contact_at") + } +) +public class StoreCustomer { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "store_id", nullable = false) + private Long storeId; + + /** 全局 customer 账号;留资客户可先为空,后续再绑定。 */ + @Column(name = "customer_user_id") + private Long customerUserId; + + /** 本店最后确认的联系手机号;API 默认只返回脱敏值。 */ + @Column(length = 20) + private String phone; + + /** 门店视角的客户称呼。 */ + @Column(name = "display_name", length = 64) + private String displayName; + + /** 首次关系来源:customer_booking / assisted_booking / report_lead。 */ + @Column(length = 32, nullable = false) + private String source; + + @Column(name = "first_contact_at") + private LocalDateTime firstContactAt; + + @Column(name = "last_contact_at") + private LocalDateTime lastContactAt; + + @Column(name = "create_time", nullable = false) + private LocalDateTime createTime; + + @Column(name = "update_time", nullable = false) + private LocalDateTime updateTime; + + @Column(nullable = false, columnDefinition = "TINYINT(1) DEFAULT 0") + private Boolean deleted = false; +} diff --git a/src/main/java/com/petstore/mapper/StoreCustomerMapper.java b/src/main/java/com/petstore/mapper/StoreCustomerMapper.java new file mode 100644 index 0000000..79e3b1b --- /dev/null +++ b/src/main/java/com/petstore/mapper/StoreCustomerMapper.java @@ -0,0 +1,16 @@ +package com.petstore.mapper; + +import com.petstore.entity.StoreCustomer; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface StoreCustomerMapper extends JpaRepository { + + List findByStoreIdAndDeletedFalseOrderByLastContactAtDesc(Long storeId); + + Optional findFirstByStoreIdAndCustomerUserId(Long storeId, Long customerUserId); + + Optional findFirstByStoreIdAndPhone(Long storeId, String phone); +} diff --git a/src/main/java/com/petstore/service/AdminServiceCustomerService.java b/src/main/java/com/petstore/service/AdminServiceCustomerService.java index d52dfd6..5a38821 100644 --- a/src/main/java/com/petstore/service/AdminServiceCustomerService.java +++ b/src/main/java/com/petstore/service/AdminServiceCustomerService.java @@ -4,11 +4,13 @@ import com.petstore.entity.Appointment; import com.petstore.entity.Pet; import com.petstore.entity.Report; import com.petstore.entity.ReportLead; +import com.petstore.entity.StoreCustomer; import com.petstore.entity.User; import com.petstore.mapper.AppointmentMapper; import com.petstore.mapper.PetMapper; import com.petstore.mapper.ReportLeadMapper; import com.petstore.mapper.ReportMapper; +import com.petstore.mapper.StoreCustomerMapper; import com.petstore.mapper.UserMapper; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -27,8 +29,8 @@ import java.util.Set; import java.util.stream.Collectors; /** - * 门店后台「服务客户」聚合:本店预约客户 + 报告留资手机号投影。 - * 无独立表;不做余额/次卡/会员字段。 + * 门店后台「服务客户」读模型:StoreCustomer 稳定主档 + 预约/宠物/报告/留资动态事实。 + * 不做余额/次卡/会员字段。 */ @Service @RequiredArgsConstructor @@ -39,6 +41,7 @@ public class AdminServiceCustomerService { private final PetMapper petMapper; private final ReportMapper reportMapper; private final ReportLeadMapper reportLeadMapper; + private final StoreCustomerMapper storeCustomerMapper; public Map list( Long storeId, @@ -59,40 +62,16 @@ public class AdminServiceCustomerService { List appointments = appointmentMapper.findByStoreIdAndDeletedFalse(storeId); List leads = reportLeadMapper.findByStoreIdOrderByCreateTimeDesc(storeId); List reports = reportMapper.findByStoreIdAndDeletedFalseOrderByCreateTimeDesc(storeId); + List masters = storeCustomerMapper + .findByStoreIdAndDeletedFalseOrderByLastContactAtDesc(storeId); Map appointmentsById = appointments.stream() .filter(a -> a.getId() != null) .collect(Collectors.toMap(Appointment::getId, a -> a, (a, b) -> a)); - Set userIds = appointments.stream() - .map(Appointment::resolvedCustomerUserId) + Set userIds = masters.stream() + .map(StoreCustomer::getCustomerUserId) .filter(java.util.Objects::nonNull) .collect(Collectors.toSet()); - Map usersByPhone = new HashMap<>(); - if (!userIds.isEmpty()) { - for (User u : userMapper.findAllById(userIds)) { - if (Boolean.TRUE.equals(u.getDeleted()) || !"customer".equals(u.getRole())) { - continue; - } - if (u.getPhone() != null && !u.getPhone().isBlank()) { - usersByPhone.put(u.getPhone(), u); - } - } - } - - // 留资手机号可能尚未有预约 user:按手机号补齐用户 - for (ReportLead lead : leads) { - if (lead.getPhone() == null || lead.getPhone().isBlank()) { - continue; - } - if (usersByPhone.containsKey(lead.getPhone())) { - continue; - } - User byPhone = userMapper.findByPhoneAndDeletedFalse(lead.getPhone()); - if (byPhone != null && "customer".equals(byPhone.getRole())) { - usersByPhone.put(lead.getPhone(), byPhone); - userIds.add(byPhone.getId()); - } - } Map users = new HashMap<>(); if (!userIds.isEmpty()) { @@ -103,11 +82,11 @@ public class AdminServiceCustomerService { } } - // 新字段优先;legacy user_id 只有确认为 customer 角色时才纳入,避免把代客员工当客户。 + // 只聚合已经进入本店客户主档的 customer;迁移脚本负责历史回填。 Map> apptsByUser = new LinkedHashMap<>(); for (Appointment appointment : appointments) { Long customerUserId = appointment.resolvedCustomerUserId(); - if (customerUserId == null || !users.containsKey(customerUserId)) { + if (customerUserId == null || !userIds.contains(customerUserId)) { continue; } apptsByUser.computeIfAbsent(customerUserId, ignored -> new ArrayList<>()).add(appointment); @@ -127,7 +106,7 @@ public class AdminServiceCustomerService { customerUserId = appointment.resolvedCustomerUserId(); } } - if (customerUserId == null || !users.containsKey(customerUserId)) { + if (customerUserId == null || !userIds.contains(customerUserId)) { continue; } latestReportByUser.putIfAbsent(customerUserId, r); @@ -137,25 +116,28 @@ public class AdminServiceCustomerService { .filter(l -> l.getPhone() != null && !l.getPhone().isBlank()) .collect(Collectors.groupingBy(ReportLead::getPhone)); - // key: userId 或 "phone:" + phone(无用户) - Map aggs = new LinkedHashMap<>(); - - for (Map.Entry> e : apptsByUser.entrySet()) { - Long userId = e.getKey(); - User user = users.get(userId); - if (user == null) { - continue; + List aggs = new ArrayList<>(); + LocalDateTime now = LocalDateTime.now(); + for (StoreCustomer master : masters) { + Long userId = master.getCustomerUserId(); + User user = userId == null ? null : users.get(userId); + CustomerAgg agg = CustomerAgg.fromMaster(master, user); + if (isBookingSource(master.getSource())) { + agg.sources.add("appointment"); + } else if (StoreCustomerService.SOURCE_REPORT_LEAD.equals(master.getSource())) { + agg.sources.add("lead"); } - CustomerAgg agg = aggs.computeIfAbsent("u:" + userId, k -> CustomerAgg.fromUser(user)); - agg.sources.add("appointment"); - for (Appointment a : e.getValue()) { - agg.touchVisit(a.getAppointmentTime()); + + for (Appointment a : apptsByUser.getOrDefault(userId, List.of())) { + agg.sources.add("appointment"); + if (isActualVisit(a, now)) { + agg.touchVisit(a.getAppointmentTime()); + } if (a.getPetName() != null && !a.getPetName().isBlank()) { agg.petNames.add(a.getPetName().trim()); } } - List pets = petsByOwner.getOrDefault(userId, List.of()); - for (Pet p : pets) { + for (Pet p : petsByOwner.getOrDefault(userId, List.of())) { if (p.getName() != null && !p.getName().isBlank()) { agg.petNames.add(p.getName().trim()); } @@ -164,25 +146,10 @@ public class AdminServiceCustomerService { if (latest != null) { agg.lastReportId = latest.getId(); } - if (user.getPhone() != null) { - List phoneLeads = leadsByPhone.getOrDefault(user.getPhone(), List.of()); - applyLeads(agg, phoneLeads); + if (agg.phone != null) { + applyLeads(agg, leadsByPhone.getOrDefault(agg.phone, List.of())); } - } - - for (Map.Entry> e : leadsByPhone.entrySet()) { - String phone = e.getKey(); - User user = usersByPhone.get(phone); - if (user != null && aggs.containsKey("u:" + user.getId())) { - continue; - } - if (user != null) { - CustomerAgg agg = aggs.computeIfAbsent("u:" + user.getId(), k -> CustomerAgg.fromUser(user)); - applyLeads(agg, e.getValue()); - continue; - } - CustomerAgg agg = aggs.computeIfAbsent("p:" + phone, k -> CustomerAgg.fromPhone(phone)); - applyLeads(agg, e.getValue()); + aggs.add(agg); } String sourceNorm = source == null || source.isBlank() || "all".equalsIgnoreCase(source) @@ -191,7 +158,7 @@ public class AdminServiceCustomerService { String qNorm = q == null ? "" : q.trim().toLowerCase(Locale.ROOT); List filtered = new ArrayList<>(); - for (CustomerAgg agg : aggs.values()) { + for (CustomerAgg agg : aggs) { if (!matchSource(agg, sourceNorm)) { continue; } @@ -278,6 +245,21 @@ public class AdminServiceCustomerService { return true; } + private static boolean isBookingSource(String source) { + return StoreCustomerService.SOURCE_CUSTOMER_BOOKING.equals(source) + || StoreCustomerService.SOURCE_ASSISTED_BOOKING.equals(source) + || "appointment".equals(source); + } + + /** 「到店」只认已开始/已完成的服务,不把未来预约或过期未履约 new 单计为到店。 */ + private static boolean isActualVisit(Appointment appointment, LocalDateTime now) { + if (appointment.getAppointmentTime() == null || appointment.getAppointmentTime().isAfter(now)) { + return false; + } + String status = appointment.getStatus() == null ? "" : appointment.getStatus().trim().toLowerCase(Locale.ROOT); + return "doing".equals(status) || "done".equals(status); + } + static String maskPhone(String phone) { if (phone == null || phone.isBlank()) { return null; @@ -293,10 +275,14 @@ public class AdminServiceCustomerService { } private static final class CustomerAgg { + Long storeCustomerId; Long userId; String displayName; String phone; String phoneMasked; + String originSource; + LocalDateTime firstContactAt; + LocalDateTime lastContactAt; final Set petNames = new LinkedHashSet<>(); final Set sources = new LinkedHashSet<>(); LocalDateTime lastVisitAt; @@ -304,24 +290,31 @@ public class AdminServiceCustomerService { String leadStatus; boolean hasPendingLead; - static CustomerAgg fromUser(User user) { + static CustomerAgg fromMaster(StoreCustomer master, User user) { CustomerAgg a = new CustomerAgg(); - a.userId = user.getId(); - a.displayName = user.getName() != null && !user.getName().isBlank() - ? user.getName() - : (user.getUsername() != null ? user.getUsername() : "客户"); - a.phone = user.getPhone(); - a.phoneMasked = maskPhone(user.getPhone()); + a.storeCustomerId = master.getId(); + a.userId = master.getCustomerUserId(); + a.displayName = firstNonBlank( + master.getDisplayName(), + user == null ? null : user.getName(), + user == null ? null : user.getUsername(), + "留资客户" + ); + a.phone = firstNonBlank(master.getPhone(), user == null ? null : user.getPhone()); + a.phoneMasked = maskPhone(a.phone); + a.originSource = master.getSource(); + a.firstContactAt = master.getFirstContactAt(); + a.lastContactAt = master.getLastContactAt(); return a; } - static CustomerAgg fromPhone(String phone) { - CustomerAgg a = new CustomerAgg(); - a.userId = null; - a.displayName = "留资客户"; - a.phone = phone; - a.phoneMasked = maskPhone(phone); - return a; + private static String firstNonBlank(String... values) { + for (String value : values) { + if (value != null && !value.isBlank()) { + return value; + } + } + return null; } void touchVisit(LocalDateTime t) { @@ -353,9 +346,13 @@ public class AdminServiceCustomerService { Map toRow() { Map row = new LinkedHashMap<>(); + row.put("storeCustomerId", storeCustomerId); row.put("userId", userId); row.put("displayName", displayName); row.put("phoneMasked", phoneMasked); + row.put("originSource", originSource); + row.put("firstContactAt", firstContactAt == null ? null : firstContactAt.toString()); + row.put("lastContactAt", lastContactAt == null ? null : lastContactAt.toString()); row.put("pets", petNames.stream() .map(n -> Map.of("name", n)) .toList()); diff --git a/src/main/java/com/petstore/service/AppointmentService.java b/src/main/java/com/petstore/service/AppointmentService.java index ae6805d..d13375e 100644 --- a/src/main/java/com/petstore/service/AppointmentService.java +++ b/src/main/java/com/petstore/service/AppointmentService.java @@ -38,6 +38,7 @@ public class AppointmentService { private final UserMapper userMapper; private final ReportMapper reportMapper; private final PetMapper petMapper; + private final StoreCustomerService storeCustomerService; // 宠主查看归属于自己的预约;兼容 customer_user_id 尚未回填的旧数据。 public List getByUserId(Long customerUserId) { @@ -200,6 +201,20 @@ public class AppointmentService { appointment.setCreateTime(LocalDateTime.now()); appointment.setUpdateTime(LocalDateTime.now()); appointment.setDeleted(false); + try { + storeCustomerService.touchBooking( + appointment.getStoreId(), + appointment.getCustomerUserId(), + appointment.getCreatedByUserId(), + appointment.getCreateTime() + ); + } catch (IllegalArgumentException e) { + return Map.of( + "code", 409, + "message", e.getMessage(), + "bizCode", "CUSTOMER_IDENTITY_CONFLICT" + ); + } Appointment saved = appointmentMapper.save(appointment); Map ok = new HashMap<>(); ok.put("code", 200); diff --git a/src/main/java/com/petstore/service/ReportLeadService.java b/src/main/java/com/petstore/service/ReportLeadService.java index cdfe760..69f0f4d 100644 --- a/src/main/java/com/petstore/service/ReportLeadService.java +++ b/src/main/java/com/petstore/service/ReportLeadService.java @@ -31,6 +31,7 @@ public class ReportLeadService { private final UserMapper userMapper; private final UserService userService; private final WechatMiniProgramService wechatMiniProgramService; + private final StoreCustomerService storeCustomerService; /** 留资提交结果:记录 + 是否已将微信 openid 绑定到宠主账号(S3→S4)+ 是否同报告同手机号再次提交(幂等更新) */ public record LeadSubmitOutcome(ReportLead lead, boolean wechatBound, boolean repeatSubmit) {} @@ -208,6 +209,8 @@ public class ReportLeadService { } } + storeCustomerService.touchLead(report.getStoreId(), phone, null, now); + return new LeadSubmitOutcome(lead, wechatBound, repeatSubmit); } diff --git a/src/main/java/com/petstore/service/StoreCustomerService.java b/src/main/java/com/petstore/service/StoreCustomerService.java new file mode 100644 index 0000000..3375622 --- /dev/null +++ b/src/main/java/com/petstore/service/StoreCustomerService.java @@ -0,0 +1,206 @@ +package com.petstore.service; + +import com.petstore.entity.StoreCustomer; +import com.petstore.entity.User; +import com.petstore.mapper.StoreCustomerMapper; +import com.petstore.mapper.UserMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Objects; + +/** 门店客户主档建档与身份合并。 */ +@Service +@RequiredArgsConstructor +public class StoreCustomerService { + + public static final String SOURCE_CUSTOMER_BOOKING = "customer_booking"; + public static final String SOURCE_ASSISTED_BOOKING = "assisted_booking"; + public static final String SOURCE_REPORT_LEAD = "report_lead"; + + private final StoreCustomerMapper storeCustomerMapper; + private final UserMapper userMapper; + + @Transactional + public StoreCustomer touchBooking( + Long storeId, + Long customerUserId, + Long createdByUserId, + LocalDateTime occurredAt + ) { + User customer = customerUserId == null + ? null + : userMapper.findByIdAndDeletedFalse(customerUserId).orElse(null); + if (customer == null || !"customer".equals(customer.getRole())) { + throw new IllegalArgumentException("预约客户不是有效宠主账号"); + } + String source = Objects.equals(customerUserId, createdByUserId) + ? SOURCE_CUSTOMER_BOOKING + : SOURCE_ASSISTED_BOOKING; + return upsert( + storeId, + customerUserId, + customer.getPhone(), + preferredName(customer), + source, + occurredAt + ); + } + + @Transactional + public StoreCustomer touchLead( + Long storeId, + String phone, + String displayName, + LocalDateTime occurredAt + ) { + String phoneNorm = normalizePhone(phone); + User matchedUser = userMapper.findByPhoneAndDeletedFalse(phoneNorm); + User customer = matchedUser != null && "customer".equals(matchedUser.getRole()) ? matchedUser : null; + Long customerUserId = customer == null ? null : customer.getId(); + String resolvedName = displayName; + if ((resolvedName == null || resolvedName.isBlank()) && customer != null) { + resolvedName = preferredName(customer); + } + if (resolvedName == null || resolvedName.isBlank()) { + resolvedName = "留资客户"; + } + return upsert(storeId, customerUserId, phoneNorm, resolvedName, SOURCE_REPORT_LEAD, occurredAt); + } + + private StoreCustomer upsert( + Long storeId, + Long customerUserId, + String phone, + String displayName, + String source, + LocalDateTime occurredAt + ) { + if (storeId == null) { + throw new IllegalArgumentException("门店不能为空"); + } + String phoneNorm = normalizeOptionalPhone(phone); + if (customerUserId == null && phoneNorm == null) { + throw new IllegalArgumentException("客户账号与手机号不能同时为空"); + } + LocalDateTime at = occurredAt == null ? LocalDateTime.now() : occurredAt; + + StoreCustomer byUser = customerUserId == null + ? null + : storeCustomerMapper.findFirstByStoreIdAndCustomerUserId(storeId, customerUserId).orElse(null); + StoreCustomer byPhone = phoneNorm == null + ? null + : storeCustomerMapper.findFirstByStoreIdAndPhone(storeId, phoneNorm).orElse(null); + + if (byPhone != null + && byPhone.getCustomerUserId() != null + && customerUserId != null + && !customerUserId.equals(byPhone.getCustomerUserId())) { + throw new IllegalArgumentException("该手机号已关联其他宠主"); + } + + StoreCustomer target; + if (byUser != null && byPhone != null && !Objects.equals(byUser.getId(), byPhone.getId())) { + // 历史上先按账号、后按手机号产生两条时,保留账号主档,软删除无账号的留资投影。 + if (byPhone.getCustomerUserId() != null) { + throw new IllegalArgumentException("客户账号与手机号主档冲突"); + } + mergeContactWindow(byUser, byPhone); + byPhone.setPhone(null); + byPhone.setDeleted(true); + byPhone.setUpdateTime(LocalDateTime.now()); + storeCustomerMapper.saveAndFlush(byPhone); + target = byUser; + } else { + target = byUser != null ? byUser : byPhone; + } + + boolean creating = target == null; + if (creating) { + target = new StoreCustomer(); + target.setStoreId(storeId); + target.setSource(source); + target.setFirstContactAt(at); + target.setCreateTime(LocalDateTime.now()); + } + target.setDeleted(false); + if (customerUserId != null) { + target.setCustomerUserId(customerUserId); + } + if (phoneNorm != null) { + target.setPhone(phoneNorm); + } + if (isUsefulName(displayName) && (!isUsefulName(target.getDisplayName()) || isGenericName(target.getDisplayName()))) { + target.setDisplayName(truncate(displayName.trim(), 64)); + } + if (target.getSource() == null || target.getSource().isBlank() || "migration".equals(target.getSource())) { + target.setSource(source); + } + if (target.getFirstContactAt() == null || at.isBefore(target.getFirstContactAt())) { + target.setFirstContactAt(at); + } + if (target.getLastContactAt() == null || at.isAfter(target.getLastContactAt())) { + target.setLastContactAt(at); + } + target.setUpdateTime(LocalDateTime.now()); + return storeCustomerMapper.save(target); + } + + private static void mergeContactWindow(StoreCustomer target, StoreCustomer duplicate) { + if (duplicate.getFirstContactAt() != null + && (target.getFirstContactAt() == null || duplicate.getFirstContactAt().isBefore(target.getFirstContactAt()))) { + target.setFirstContactAt(duplicate.getFirstContactAt()); + } + if (duplicate.getLastContactAt() != null + && (target.getLastContactAt() == null || duplicate.getLastContactAt().isAfter(target.getLastContactAt()))) { + target.setLastContactAt(duplicate.getLastContactAt()); + } + if ((!isUsefulName(target.getDisplayName()) || isGenericName(target.getDisplayName())) + && isUsefulName(duplicate.getDisplayName())) { + target.setDisplayName(duplicate.getDisplayName()); + } + } + + private static String preferredName(User user) { + if (user.getName() != null && !user.getName().isBlank()) { + return user.getName(); + } + if (user.getUsername() != null && !user.getUsername().isBlank()) { + return user.getUsername(); + } + return "客户"; + } + + private static String normalizePhone(String phone) { + String normalized = phone == null ? "" : phone.trim(); + if (!normalized.matches("^1[3-9]\\d{9}$")) { + throw new IllegalArgumentException("手机号格式不正确"); + } + return normalized; + } + + private static String normalizeOptionalPhone(String phone) { + if (phone == null || phone.isBlank()) { + return null; + } + return normalizePhone(phone); + } + + private static boolean isUsefulName(String name) { + return name != null && !name.isBlank(); + } + + private static boolean isGenericName(String name) { + return name == null + || name.isBlank() + || name.startsWith("留资客户") + || name.startsWith("微信用户") + || name.startsWith("到店宠主"); + } + + private static String truncate(String value, int maxLength) { + return value.length() <= maxLength ? value : value.substring(0, maxLength); + } +} diff --git a/src/test/java/com/petstore/controller/AdminServiceCustomerControllerTest.java b/src/test/java/com/petstore/controller/AdminServiceCustomerControllerTest.java index 5275836..6470432 100644 --- a/src/test/java/com/petstore/controller/AdminServiceCustomerControllerTest.java +++ b/src/test/java/com/petstore/controller/AdminServiceCustomerControllerTest.java @@ -68,6 +68,7 @@ class AdminServiceCustomerControllerTest { void responseHasNoBalanceFields() { CurrentUserContext.set(new CurrentUser(1L, 10L, "boss")); Map row = Map.of( + "storeCustomerId", 20L, "userId", 5L, "displayName", "张三", "phoneMasked", "138****8001", @@ -93,6 +94,8 @@ class AdminServiceCustomerControllerTest { assertFalse(first.containsKey("balance")); assertFalse(first.containsKey("points")); assertFalse(first.containsKey("membershipLevel")); + assertFalse(first.containsKey("phone")); + assertEquals(20L, first.get("storeCustomerId")); @SuppressWarnings("unchecked") Map summary = (Map) data.get("summary"); assertFalse(summary.containsKey("totalBalance")); diff --git a/src/test/java/com/petstore/mapper/IdentityOwnershipQueryTest.java b/src/test/java/com/petstore/mapper/IdentityOwnershipQueryTest.java index e89bce6..03d0ccf 100644 --- a/src/test/java/com/petstore/mapper/IdentityOwnershipQueryTest.java +++ b/src/test/java/com/petstore/mapper/IdentityOwnershipQueryTest.java @@ -2,6 +2,7 @@ package com.petstore.mapper; import com.petstore.entity.Appointment; import com.petstore.entity.Report; +import com.petstore.entity.StoreCustomer; import com.petstore.service.ServiceTypeService; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -27,6 +28,7 @@ class IdentityOwnershipQueryTest { @Autowired private AppointmentMapper appointmentMapper; @Autowired private ReportMapper reportMapper; + @Autowired private StoreCustomerMapper storeCustomerMapper; @MockBean private ServiceTypeService serviceTypeService; @Test @@ -56,6 +58,26 @@ class IdentityOwnershipQueryTest { assertEquals(1, reportMapper.findForCustomer(66L).size()); } + @Test + void storeCustomerCanBeResolvedByStableUserAndPhoneKeys() { + StoreCustomer customer = new StoreCustomer(); + customer.setStoreId(10L); + customer.setCustomerUserId(88L); + customer.setPhone("13800138001"); + customer.setDisplayName("小白妈妈"); + customer.setSource("assisted_booking"); + customer.setFirstContactAt(LocalDateTime.of(2026, 8, 1, 10, 0)); + customer.setLastContactAt(LocalDateTime.of(2026, 8, 1, 10, 0)); + customer.setCreateTime(LocalDateTime.of(2026, 8, 1, 10, 0)); + customer.setUpdateTime(LocalDateTime.of(2026, 8, 1, 10, 0)); + storeCustomerMapper.saveAndFlush(customer); + + assertEquals(customer.getId(), storeCustomerMapper + .findFirstByStoreIdAndCustomerUserId(10L, 88L).orElseThrow().getId()); + assertEquals(customer.getId(), storeCustomerMapper + .findFirstByStoreIdAndPhone(10L, "13800138001").orElseThrow().getId()); + } + private Appointment appointment(Long customerUserId, Long legacyUserId) { Appointment appointment = new Appointment(); appointment.setPetName("小白"); diff --git a/src/test/java/com/petstore/mapper/StoreCustomerMigrationTest.java b/src/test/java/com/petstore/mapper/StoreCustomerMigrationTest.java new file mode 100644 index 0000000..c628edc --- /dev/null +++ b/src/test/java/com/petstore/mapper/StoreCustomerMigrationTest.java @@ -0,0 +1,90 @@ +package com.petstore.mapper; + +import org.h2.tools.RunScript; +import org.junit.jupiter.api.Test; + +import java.io.Reader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.Statement; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class StoreCustomerMigrationTest { + + @Test + void migrationCreatesAndMergesAppointmentAndLeadMaster() throws Exception { + try (Connection connection = DriverManager.getConnection( + "jdbc:h2:mem:store-customer-migration;MODE=MySQL;DATABASE_TO_LOWER=TRUE", + "sa", + "" + )) { + try (Statement statement = connection.createStatement()) { + statement.execute(""" + CREATE TABLE t_user ( + id BIGINT PRIMARY KEY, + phone VARCHAR(20), + name VARCHAR(64), + username VARCHAR(64), + role VARCHAR(32), + deleted TINYINT NOT NULL DEFAULT 0 + ) + """); + statement.execute(""" + CREATE TABLE t_appointment ( + id BIGINT PRIMARY KEY, + store_id BIGINT, + customer_user_id BIGINT, + appointment_time DATETIME, + create_time DATETIME, + update_time DATETIME, + deleted TINYINT NOT NULL DEFAULT 0 + ) + """); + statement.execute(""" + CREATE TABLE t_report_lead ( + id BIGINT PRIMARY KEY, + store_id BIGINT, + phone VARCHAR(20), + create_time DATETIME, + update_time DATETIME + ) + """); + statement.execute(""" + INSERT INTO t_user VALUES + (88, '13800138001', '小白妈妈', '13800138001', 'customer', 0) + """); + statement.execute(""" + INSERT INTO t_appointment VALUES + (1, 10, 88, '2026-08-01 10:00:00', '2026-07-28 09:00:00', '2026-08-01 11:00:00', 0) + """); + statement.execute(""" + INSERT INTO t_report_lead VALUES + (9, 10, '13800138001', '2026-08-01 11:30:00', '2026-08-01 11:30:00') + """); + } + + try (Reader reader = Files.newBufferedReader( + Path.of("db/migrations/20260801_create_store_customer.sql") + )) { + RunScript.execute(connection, reader); + } + + try (Statement statement = connection.createStatement(); + ResultSet rows = statement.executeQuery(""" + SELECT customer_user_id, phone, source + FROM t_store_customer + WHERE store_id = 10 AND deleted = 0 + """)) { + rows.next(); + assertEquals(88L, rows.getLong("customer_user_id")); + assertEquals("13800138001", rows.getString("phone")); + assertEquals("appointment", rows.getString("source")); + assertEquals(false, rows.next(), "同一客户的预约与留资应合并为一条主档"); + } + } + } +} diff --git a/src/test/java/com/petstore/service/AdminServiceCustomerServiceTest.java b/src/test/java/com/petstore/service/AdminServiceCustomerServiceTest.java index 4c73e0e..407dd94 100644 --- a/src/test/java/com/petstore/service/AdminServiceCustomerServiceTest.java +++ b/src/test/java/com/petstore/service/AdminServiceCustomerServiceTest.java @@ -1,12 +1,40 @@ package com.petstore.service; +import com.petstore.entity.Appointment; +import com.petstore.entity.StoreCustomer; +import com.petstore.entity.User; +import com.petstore.mapper.AppointmentMapper; +import com.petstore.mapper.PetMapper; +import com.petstore.mapper.ReportLeadMapper; +import com.petstore.mapper.ReportMapper; +import com.petstore.mapper.StoreCustomerMapper; +import com.petstore.mapper.UserMapper; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +@ExtendWith(MockitoExtension.class) class AdminServiceCustomerServiceTest { + @Mock private AppointmentMapper appointmentMapper; + @Mock private UserMapper userMapper; + @Mock private PetMapper petMapper; + @Mock private ReportMapper reportMapper; + @Mock private ReportLeadMapper reportLeadMapper; + @Mock private StoreCustomerMapper storeCustomerMapper; + @InjectMocks private AdminServiceCustomerService service; + @Test void maskPhoneElevenDigits() { assertEquals("138****8001", AdminServiceCustomerService.maskPhone("13800138001")); @@ -16,4 +44,62 @@ class AdminServiceCustomerServiceTest { void maskPhoneNull() { assertNull(AdminServiceCustomerService.maskPhone(null)); } + + @SuppressWarnings("unchecked") + @Test + void listUsesStableMasterAndDoesNotCountFutureBookingAsVisit() { + LocalDateTime actualVisit = LocalDateTime.now().minusDays(2).withNano(0); + LocalDateTime futureBooking = LocalDateTime.now().plusDays(2).withNano(0); + + StoreCustomer master = new StoreCustomer(); + master.setId(501L); + master.setStoreId(10L); + master.setCustomerUserId(88L); + master.setPhone("13800138001"); + master.setDisplayName("小白妈妈"); + master.setSource(StoreCustomerService.SOURCE_ASSISTED_BOOKING); + master.setFirstContactAt(LocalDateTime.now().minusMonths(1)); + master.setLastContactAt(LocalDateTime.now().minusDays(1)); + master.setDeleted(false); + + User user = new User(); + user.setId(88L); + user.setRole("customer"); + user.setPhone("13800138001"); + user.setDeleted(false); + + Appointment done = appointment(1L, 88L, "done", actualVisit, "小白"); + Appointment future = appointment(2L, 88L, "new", futureBooking, "小白"); + + when(storeCustomerMapper.findByStoreIdAndDeletedFalseOrderByLastContactAtDesc(10L)) + .thenReturn(List.of(master)); + when(appointmentMapper.findByStoreIdAndDeletedFalse(10L)).thenReturn(List.of(done, future)); + when(reportLeadMapper.findByStoreIdOrderByCreateTimeDesc(10L)).thenReturn(List.of()); + when(reportMapper.findByStoreIdAndDeletedFalseOrderByCreateTimeDesc(10L)).thenReturn(List.of()); + when(userMapper.findAllById(any())).thenReturn(List.of(user)); + when(petMapper.findByOwnerUserIdAndDeletedFalseOrderByUpdateTimeDesc(88L)).thenReturn(List.of()); + + Map data = service.list(10L, null, null, null, null, null, 1, 20); + List> rows = (List>) data.get("list"); + + assertEquals(1, rows.size()); + Map row = rows.get(0); + assertEquals(501L, row.get("storeCustomerId")); + assertEquals(88L, row.get("userId")); + assertEquals("138****8001", row.get("phoneMasked")); + assertEquals(StoreCustomerService.SOURCE_ASSISTED_BOOKING, row.get("originSource")); + assertEquals(actualVisit.toString(), row.get("lastVisitAt")); + assertEquals("appointment", row.get("source")); + } + + private Appointment appointment(Long id, Long customerUserId, String status, LocalDateTime time, String petName) { + Appointment appointment = new Appointment(); + appointment.setId(id); + appointment.setCustomerUserId(customerUserId); + appointment.setStatus(status); + appointment.setAppointmentTime(time); + appointment.setPetName(petName); + appointment.setDeleted(false); + return appointment; + } } diff --git a/src/test/java/com/petstore/service/AppointmentServiceTest.java b/src/test/java/com/petstore/service/AppointmentServiceTest.java index fc4281d..374d843 100644 --- a/src/test/java/com/petstore/service/AppointmentServiceTest.java +++ b/src/test/java/com/petstore/service/AppointmentServiceTest.java @@ -1,6 +1,7 @@ package com.petstore.service; import com.petstore.entity.Appointment; +import com.petstore.entity.Store; import com.petstore.mapper.AppointmentMapper; import com.petstore.mapper.PetMapper; import com.petstore.mapper.ReportMapper; @@ -12,6 +13,9 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; import java.util.Optional; import static org.junit.jupiter.api.Assertions.*; @@ -30,6 +34,7 @@ class AppointmentServiceTest { @Mock private UserMapper userMapper; @Mock private ReportMapper reportMapper; @Mock private PetMapper petMapper; + @Mock private StoreCustomerService storeCustomerService; @InjectMocks private AppointmentService appointmentService; @@ -108,6 +113,30 @@ class AppointmentServiceTest { assertEquals("NOT_FOUND", result.get("bizCode")); } + @Test + void createBookingTouchesStoreCustomerMaster() { + LocalDateTime appointmentTime = LocalDate.now().plusDays(1).atTime(10, 0); + Appointment appointment = new Appointment(); + appointment.setStoreId(10L); + appointment.setCustomerUserId(88L); + appointment.setCreatedByUserId(7L); + appointment.setAppointmentTime(appointmentTime); + appointment.setPetName("小白"); + + Store store = new Store(); + store.setId(10L); + store.setBookingDayStart(LocalTime.of(9, 0)); + store.setBookingLastSlotStart(LocalTime.of(21, 30)); + when(storeService.findById(10L)).thenReturn(store); + when(appointmentMapper.save(any(Appointment.class))).thenAnswer(inv -> inv.getArgument(0)); + + var result = appointmentService.createBooking(appointment); + + assertEquals(200, result.get("code")); + verify(storeCustomerService).touchBooking(eq(10L), eq(88L), eq(7L), any(LocalDateTime.class)); + verify(appointmentMapper).save(appointment); + } + private Appointment newAppointment(Long id, String status) { Appointment a = new Appointment(); a.setId(id); diff --git a/src/test/java/com/petstore/service/ReportLeadServiceStoreCustomerTest.java b/src/test/java/com/petstore/service/ReportLeadServiceStoreCustomerTest.java new file mode 100644 index 0000000..dc3f1e5 --- /dev/null +++ b/src/test/java/com/petstore/service/ReportLeadServiceStoreCustomerTest.java @@ -0,0 +1,62 @@ +package com.petstore.service; + +import com.petstore.entity.Report; +import com.petstore.entity.ReportLead; +import com.petstore.mapper.ReportLeadMapper; +import com.petstore.mapper.ReportMapper; +import com.petstore.mapper.ServiceIntervalMapper; +import com.petstore.mapper.UserMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ReportLeadServiceStoreCustomerTest { + + @Mock private ReportLeadMapper reportLeadMapper; + @Mock private ReportMapper reportMapper; + @Mock private ServiceIntervalMapper serviceIntervalMapper; + @Mock private UserMapper userMapper; + @Mock private UserService userService; + @Mock private WechatMiniProgramService wechatMiniProgramService; + @Mock private StoreCustomerService storeCustomerService; + @InjectMocks private ReportLeadService reportLeadService; + + @Test + void submittingLeadTouchesStoreCustomerMaster() { + Report report = new Report(); + report.setId(9L); + report.setStoreId(10L); + report.setServiceType("洗澡"); + report.setPetName("小白"); + report.setAppointmentTime(LocalDateTime.of(2026, 8, 1, 10, 0)); + when(reportMapper.findFirstByReportTokenAndDeletedFalse("token")).thenReturn(Optional.of(report)); + when(serviceIntervalMapper.findFirstByStoreIdAndServiceName(10L, "洗澡")).thenReturn(Optional.empty()); + when(serviceIntervalMapper.findFirstByStoreIdIsNullAndServiceName("洗澡")).thenReturn(Optional.empty()); + when(reportLeadMapper.findFirstByReportIdAndPhone(9L, "13800138001")).thenReturn(Optional.empty()); + when(reportLeadMapper.save(any(ReportLead.class))).thenAnswer(inv -> { + ReportLead lead = inv.getArgument(0); + lead.setId(100L); + return lead; + }); + when(userMapper.findByPhoneAndDeletedFalse("13800138001")).thenReturn(null); + + ReportLeadService.LeadSubmitOutcome outcome = reportLeadService.submit( + "token", "13800138001", "127.0.0.1", "" + ); + + assertFalse(outcome.repeatSubmit()); + verify(storeCustomerService).touchLead(eq(10L), eq("13800138001"), eq(null), any(LocalDateTime.class)); + } +} diff --git a/src/test/java/com/petstore/service/StoreCustomerServiceTest.java b/src/test/java/com/petstore/service/StoreCustomerServiceTest.java new file mode 100644 index 0000000..539eb0d --- /dev/null +++ b/src/test/java/com/petstore/service/StoreCustomerServiceTest.java @@ -0,0 +1,143 @@ +package com.petstore.service; + +import com.petstore.entity.StoreCustomer; +import com.petstore.entity.User; +import com.petstore.mapper.StoreCustomerMapper; +import com.petstore.mapper.UserMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class StoreCustomerServiceTest { + + @Mock private StoreCustomerMapper storeCustomerMapper; + @Mock private UserMapper userMapper; + @InjectMocks private StoreCustomerService service; + + @Test + void assistedBookingCreatesStableStoreCustomer() { + User customer = customer(88L, "13800138001", "小白妈妈"); + when(userMapper.findByIdAndDeletedFalse(88L)).thenReturn(Optional.of(customer)); + when(storeCustomerMapper.findFirstByStoreIdAndCustomerUserId(10L, 88L)).thenReturn(Optional.empty()); + when(storeCustomerMapper.findFirstByStoreIdAndPhone(10L, "13800138001")).thenReturn(Optional.empty()); + when(storeCustomerMapper.save(any(StoreCustomer.class))).thenAnswer(inv -> inv.getArgument(0)); + + LocalDateTime at = LocalDateTime.of(2026, 8, 2, 9, 0); + StoreCustomer saved = service.touchBooking(10L, 88L, 7L, at); + + assertEquals(10L, saved.getStoreId()); + assertEquals(88L, saved.getCustomerUserId()); + assertEquals("13800138001", saved.getPhone()); + assertEquals("小白妈妈", saved.getDisplayName()); + assertEquals(StoreCustomerService.SOURCE_ASSISTED_BOOKING, saved.getSource()); + assertEquals(at, saved.getFirstContactAt()); + assertEquals(at, saved.getLastContactAt()); + } + + @Test + void leadPhoneMasterBindsToCustomerAccountLater() { + User customer = customer(88L, "13800138001", "张三"); + StoreCustomer phoneOnly = new StoreCustomer(); + phoneOnly.setId(5L); + phoneOnly.setStoreId(10L); + phoneOnly.setPhone("13800138001"); + phoneOnly.setDisplayName("留资客户"); + phoneOnly.setSource(StoreCustomerService.SOURCE_REPORT_LEAD); + phoneOnly.setDeleted(false); + + when(userMapper.findByPhoneAndDeletedFalse("13800138001")).thenReturn(customer); + when(storeCustomerMapper.findFirstByStoreIdAndCustomerUserId(10L, 88L)).thenReturn(Optional.empty()); + when(storeCustomerMapper.findFirstByStoreIdAndPhone(10L, "13800138001")).thenReturn(Optional.of(phoneOnly)); + when(storeCustomerMapper.save(any(StoreCustomer.class))).thenAnswer(inv -> inv.getArgument(0)); + + StoreCustomer saved = service.touchLead(10L, "13800138001", null, LocalDateTime.now()); + + assertEquals(5L, saved.getId()); + assertEquals(88L, saved.getCustomerUserId()); + assertEquals("张三", saved.getDisplayName()); + assertEquals(StoreCustomerService.SOURCE_REPORT_LEAD, saved.getSource(), "首次来源不应被后续触达覆盖"); + } + + @Test + void conflictingPhoneOwnershipIsRejected() { + User customer = customer(88L, "13800138001", "张三"); + StoreCustomer other = new StoreCustomer(); + other.setId(9L); + other.setStoreId(10L); + other.setPhone("13800138001"); + other.setCustomerUserId(99L); + + when(userMapper.findByPhoneAndDeletedFalse("13800138001")).thenReturn(customer); + when(storeCustomerMapper.findFirstByStoreIdAndCustomerUserId(10L, 88L)).thenReturn(Optional.empty()); + when(storeCustomerMapper.findFirstByStoreIdAndPhone(10L, "13800138001")).thenReturn(Optional.of(other)); + + IllegalArgumentException error = assertThrows( + IllegalArgumentException.class, + () -> service.touchLead(10L, "13800138001", null, LocalDateTime.now()) + ); + assertTrue(error.getMessage().contains("其他宠主")); + } + + @Test + void accountMasterAbsorbsUnboundPhoneProjection() { + User customer = customer(88L, "13800138001", "张三"); + StoreCustomer accountMaster = new StoreCustomer(); + accountMaster.setId(1L); + accountMaster.setStoreId(10L); + accountMaster.setCustomerUserId(88L); + accountMaster.setSource(StoreCustomerService.SOURCE_CUSTOMER_BOOKING); + accountMaster.setFirstContactAt(LocalDateTime.of(2026, 8, 2, 10, 0)); + accountMaster.setDeleted(false); + + StoreCustomer phoneProjection = new StoreCustomer(); + phoneProjection.setId(2L); + phoneProjection.setStoreId(10L); + phoneProjection.setPhone("13800138001"); + phoneProjection.setSource(StoreCustomerService.SOURCE_REPORT_LEAD); + phoneProjection.setFirstContactAt(LocalDateTime.of(2026, 8, 1, 10, 0)); + phoneProjection.setDeleted(false); + + when(userMapper.findByIdAndDeletedFalse(88L)).thenReturn(Optional.of(customer)); + when(storeCustomerMapper.findFirstByStoreIdAndCustomerUserId(10L, 88L)).thenReturn(Optional.of(accountMaster)); + when(storeCustomerMapper.findFirstByStoreIdAndPhone(10L, "13800138001")).thenReturn(Optional.of(phoneProjection)); + when(storeCustomerMapper.save(any(StoreCustomer.class))).thenAnswer(inv -> inv.getArgument(0)); + + StoreCustomer saved = service.touchBooking(10L, 88L, 88L, LocalDateTime.of(2026, 8, 3, 10, 0)); + + assertEquals(1L, saved.getId()); + assertEquals(LocalDateTime.of(2026, 8, 1, 10, 0), saved.getFirstContactAt()); + assertEquals("13800138001", saved.getPhone()); + assertTrue(phoneProjection.getDeleted()); + assertNull(phoneProjection.getPhone()); + verify(storeCustomerMapper).saveAndFlush(phoneProjection); + ArgumentCaptor captor = ArgumentCaptor.forClass(StoreCustomer.class); + verify(storeCustomerMapper).save(captor.capture()); + assertFalse(captor.getValue().getDeleted()); + } + + private User customer(Long id, String phone, String name) { + User user = new User(); + user.setId(id); + user.setPhone(phone); + user.setName(name); + user.setRole("customer"); + user.setDeleted(false); + return user; + } +}