feat: add stable store customer master
This commit is contained in:
parent
3f1bfffbed
commit
0cf6178288
@ -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 <host> -u <user> -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)已轮换
|
||||
|
||||
## 安全说明
|
||||
|
||||
138
db/migrations/20260801_create_store_customer.sql
Normal file
138
db/migrations/20260801_create_store_customer.sql
Normal file
@ -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');
|
||||
@ -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 后续任务;接入前不得把本目录误当成自动执行目录。
|
||||
|
||||
78
src/main/java/com/petstore/entity/StoreCustomer.java
Normal file
78
src/main/java/com/petstore/entity/StoreCustomer.java
Normal file
@ -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;
|
||||
|
||||
/**
|
||||
* 门店与客户的稳定关系主档。
|
||||
*
|
||||
* <p>它不是会员资产表;余额、积分、次卡不属于本聚合。
|
||||
* 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;
|
||||
}
|
||||
16
src/main/java/com/petstore/mapper/StoreCustomerMapper.java
Normal file
16
src/main/java/com/petstore/mapper/StoreCustomerMapper.java
Normal file
@ -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<StoreCustomer, Long> {
|
||||
|
||||
List<StoreCustomer> findByStoreIdAndDeletedFalseOrderByLastContactAtDesc(Long storeId);
|
||||
|
||||
Optional<StoreCustomer> findFirstByStoreIdAndCustomerUserId(Long storeId, Long customerUserId);
|
||||
|
||||
Optional<StoreCustomer> findFirstByStoreIdAndPhone(Long storeId, String phone);
|
||||
}
|
||||
@ -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<String, Object> list(
|
||||
Long storeId,
|
||||
@ -59,40 +62,16 @@ public class AdminServiceCustomerService {
|
||||
List<Appointment> appointments = appointmentMapper.findByStoreIdAndDeletedFalse(storeId);
|
||||
List<ReportLead> leads = reportLeadMapper.findByStoreIdOrderByCreateTimeDesc(storeId);
|
||||
List<Report> reports = reportMapper.findByStoreIdAndDeletedFalseOrderByCreateTimeDesc(storeId);
|
||||
List<StoreCustomer> masters = storeCustomerMapper
|
||||
.findByStoreIdAndDeletedFalseOrderByLastContactAtDesc(storeId);
|
||||
|
||||
Map<Long, Appointment> appointmentsById = appointments.stream()
|
||||
.filter(a -> a.getId() != null)
|
||||
.collect(Collectors.toMap(Appointment::getId, a -> a, (a, b) -> a));
|
||||
Set<Long> userIds = appointments.stream()
|
||||
.map(Appointment::resolvedCustomerUserId)
|
||||
Set<Long> userIds = masters.stream()
|
||||
.map(StoreCustomer::getCustomerUserId)
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.collect(Collectors.toSet());
|
||||
Map<String, User> 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<Long, User> users = new HashMap<>();
|
||||
if (!userIds.isEmpty()) {
|
||||
@ -103,11 +82,11 @@ public class AdminServiceCustomerService {
|
||||
}
|
||||
}
|
||||
|
||||
// 新字段优先;legacy user_id 只有确认为 customer 角色时才纳入,避免把代客员工当客户。
|
||||
// 只聚合已经进入本店客户主档的 customer;迁移脚本负责历史回填。
|
||||
Map<Long, List<Appointment>> 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<String, CustomerAgg> aggs = new LinkedHashMap<>();
|
||||
|
||||
for (Map.Entry<Long, List<Appointment>> e : apptsByUser.entrySet()) {
|
||||
Long userId = e.getKey();
|
||||
User user = users.get(userId);
|
||||
if (user == null) {
|
||||
continue;
|
||||
List<CustomerAgg> 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<Pet> 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<ReportLead> 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<String, List<ReportLead>> 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<CustomerAgg> 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<String> petNames = new LinkedHashSet<>();
|
||||
final Set<String> 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<String, Object> toRow() {
|
||||
Map<String, Object> 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());
|
||||
|
||||
@ -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<Appointment> 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<String, Object> ok = new HashMap<>();
|
||||
ok.put("code", 200);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
206
src/main/java/com/petstore/service/StoreCustomerService.java
Normal file
206
src/main/java/com/petstore/service/StoreCustomerService.java
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
@ -68,6 +68,7 @@ class AdminServiceCustomerControllerTest {
|
||||
void responseHasNoBalanceFields() {
|
||||
CurrentUserContext.set(new CurrentUser(1L, 10L, "boss"));
|
||||
Map<String, Object> 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<String, Object> summary = (Map<String, Object>) data.get("summary");
|
||||
assertFalse(summary.containsKey("totalBalance"));
|
||||
|
||||
@ -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("小白");
|
||||
|
||||
@ -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(), "同一客户的预约与留资应合并为一条主档");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<String, Object> data = service.list(10L, null, null, null, null, null, 1, 20);
|
||||
List<Map<String, Object>> rows = (List<Map<String, Object>>) data.get("list");
|
||||
|
||||
assertEquals(1, rows.size());
|
||||
Map<String, Object> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
143
src/test/java/com/petstore/service/StoreCustomerServiceTest.java
Normal file
143
src/test/java/com/petstore/service/StoreCustomerServiceTest.java
Normal file
@ -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<StoreCustomer> 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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user