From c0771b34ff4ffef5e5c65f088e7fb2a1f93154de Mon Sep 17 00:00:00 2001 From: malei <> Date: Sun, 2 Aug 2026 02:11:28 +0800 Subject: [PATCH] feat: add follow-up task lifecycle and rebook attribution --- README.md | 13 +- .../20260802_create_follow_up_task.sql | 164 ++++++ db/migrations/README.md | 2 + .../ProductionDatabasePreflightRunner.java | 77 +++ .../AdminFollowUpTaskController.java | 117 ++++ .../controller/AppointmentController.java | 9 +- .../com/petstore/entity/FollowUpTask.java | 114 ++++ .../petstore/mapper/FollowUpTaskMapper.java | 38 ++ .../com/petstore/mapper/ReportLeadMapper.java | 8 + .../service/AdminBusinessEventService.java | 5 +- .../service/AdminWorkbenchService.java | 62 +- .../petstore/service/AppointmentService.java | 23 + .../service/BusinessEventService.java | 137 +++++ .../petstore/service/FollowUpTaskService.java | 535 ++++++++++++++++++ .../petstore/service/ReportLeadService.java | 11 +- .../service/StoreCustomerService.java | 21 + .../service/StoreCustomerTimelineService.java | 74 ++- .../AdminFollowUpTaskControllerTest.java | 64 +++ .../AppointmentControllerIdentityTest.java | 25 + .../mapper/FollowUpTaskMigrationTest.java | 118 ++++ .../AdminBusinessEventServiceTest.java | 9 +- .../service/AdminWorkbenchServiceTest.java | 24 +- .../service/AppointmentServiceTest.java | 74 +++ .../service/BusinessEventServiceTest.java | 25 + .../service/FollowUpTaskServiceTest.java | 266 +++++++++ .../ReportLeadServiceStoreCustomerTest.java | 9 +- .../StoreCustomerTimelineServiceTest.java | 38 ++ 27 files changed, 2024 insertions(+), 38 deletions(-) create mode 100644 db/migrations/20260802_create_follow_up_task.sql create mode 100644 src/main/java/com/petstore/controller/AdminFollowUpTaskController.java create mode 100644 src/main/java/com/petstore/entity/FollowUpTask.java create mode 100644 src/main/java/com/petstore/mapper/FollowUpTaskMapper.java create mode 100644 src/main/java/com/petstore/service/FollowUpTaskService.java create mode 100644 src/test/java/com/petstore/controller/AdminFollowUpTaskControllerTest.java create mode 100644 src/test/java/com/petstore/mapper/FollowUpTaskMigrationTest.java create mode 100644 src/test/java/com/petstore/service/FollowUpTaskServiceTest.java diff --git a/README.md b/README.md index ee48de3..aca987c 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,12 @@ curl http://localhost:8080/api/store/list ``` 脚本为被合并的软删除客户投影增加同店 canonical 指针。既有 `BusinessEvent` 保持不可变,时间线通过主档与别名共同读取历史事实。末尾两项别名状态/目标验证必须全部为 0。 +11. **可执行回访任务与真实再次预约归因**:客户时间线迁移验证完成后执行: + ```bash + mysql -h -u -p petstore < db/migrations/20260802_create_follow_up_task.sql + ``` + 脚本仅为仍为 `pending` 的历史留资建立开放任务,并写入低敏 `follow_up_created` 事实;不会猜测已发送、已取消或已退订线索的处理结果。末尾五项客户归属、状态机、开放任务唯一性和事件完整性验证必须全部为 0。 + ### production profile ```bash @@ -134,14 +140,14 @@ production profile 下: ### 生产只读预检 -完成备份和七个迁移后,以最终生产环境变量运行: +完成备份和八个迁移后,以最终生产环境变量运行: ```bash chmod +x deploy/release-preflight.sh deploy/production-smoke.sh BUILD_FIRST=1 deploy/release-preflight.sh ``` -脚本先校验生产配置、上传目录、Java 与 FFmpeg,再启动无 Web 的 `production` profile,执行 JPA schema `validate` 和 26 项只读数据不变量检查,通过后退出。它不会执行迁移,也不会修复数据。 +脚本先校验生产配置、上传目录、Java 与 FFmpeg,再启动无 Web 的 `production` profile,执行 JPA schema `validate` 和 31 项只读数据不变量检查,通过后退出。它不会执行迁移,也不会修复数据。 ## 测试 @@ -199,6 +205,8 @@ API_ORIGIN=https:// backend/deploy/production-smoke.sh - [ ] 已执行 `20260801_create_booking_capacity.sql`,容量与时长验证计数均为 0 - [ ] 已执行 `20260801_create_report_send_status.sql`,历史 `unknown` 与三项回执一致性验证均为 0 - [ ] 已执行 `20260802_create_store_onboarding.sql`,历史开通状态与六项邀请/回执验证均为 0 +- [ ] 已执行 `20260802_create_store_customer_timeline.sql`,两项 StoreCustomer 别名不变量均为 0 +- [ ] 已执行 `20260802_create_follow_up_task.sql`,五项回访任务与事件验证计数均为 0 - [ ] `CORS_ALLOWED_ORIGINS` 仅包含本次发布的显式 HTTPS Web 源 - [ ] `deploy/release-preflight.sh` 已以最终环境变量通过并留存输出 - [ ] readiness 与上线后只读冒烟全部通过 @@ -211,6 +219,7 @@ API_ORIGIN=https:// backend/deploy/production-smoke.sh - **跨店/跨用户边界**:所有 protected 接口从 `CurrentUserContext` 派生 `userId/storeId/role`,不信任请求体里的身份字段。 - **公开报告页**:`GET /api/report/get?token=` 不返回 top-level `userId/storeId/reportToken`;日志只记 token SHA-256 前 8 位 hash。 - **回访池脱敏**:`/api/report/leads` 不返回完整 `wechatOpenid/wechatUnionid`,只返回 `wechatBound` + 脱敏 id。 +- **回访执行权限**:`/api/admin/follow-up-tasks` 仅本店 boss/staff;列表中的完整手机号只用于已认证员工实际联系,不写入 BusinessEvent、AuditLog、时间线或工作台预览。 - **上传路径保护**:`FileController` 归一化路径 + 扩展名/MIME 校验,拒绝 `..` 逃逸。 - **员工邀请**:原始 256-bit token 只在创建响应返回一次,数据库只存 SHA-256;邀请绑定微信核验手机号、限时、一次使用、可撤销。 - **操作审计**:门店开通/设置和员工权限操作写入不可变低敏 AuditLog,metadata 禁止 PII、凭证和原始请求体。 diff --git a/db/migrations/20260802_create_follow_up_task.sql b/db/migrations/20260802_create_follow_up_task.sql new file mode 100644 index 0000000..c71054e --- /dev/null +++ b/db/migrations/20260802_create_follow_up_task.sql @@ -0,0 +1,164 @@ +-- Petstore Phase 1:可执行回访任务与再次预约归因 +-- 前置:20260802_create_store_customer_timeline.sql 已执行。 +-- ReportLead 保留宠主同意/来源事实;FollowUpTask 承载门店操作状态。 + +CREATE TABLE t_follow_up_task ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + task_id VARCHAR(36) NOT NULL, + store_id BIGINT NOT NULL, + store_customer_id BIGINT NOT NULL, + source_lead_id BIGINT NOT NULL, + source_report_id BIGINT NULL, + status VARCHAR(16) NOT NULL COMMENT 'pending | in_progress | completed | canceled', + outcome VARCHAR(24) NULL COMMENT 'rebooked | not_interested | invalid_contact | unsubscribed', + due_date DATE NOT NULL, + assigned_user_id BIGINT NULL, + attempt_count INT NOT NULL DEFAULT 0, + last_attempt_outcome VARCHAR(24) NULL COMMENT 'no_answer | follow_later', + last_contacted_at DATETIME NULL, + last_contacted_by_user_id BIGINT NULL, + closed_at DATETIME NULL, + closed_by_user_id BIGINT NULL, + rebooked_appointment_id BIGINT NULL, + create_time DATETIME NOT NULL, + update_time DATETIME NOT NULL, + version BIGINT NOT NULL DEFAULT 0, + CONSTRAINT uk_follow_up_task_public_id UNIQUE (task_id), + INDEX idx_follow_up_store_status_due (store_id, status, due_date), + INDEX idx_follow_up_customer_status (store_customer_id, status), + INDEX idx_follow_up_lead_status (source_lead_id, status), + INDEX idx_follow_up_rebook (rebooked_appointment_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='门店回访执行任务'; + +-- 只为仍待处理的历史留资建立开放任务;不得猜测已发送/取消记录的真实结果。 +INSERT INTO t_follow_up_task ( + task_id, store_id, store_customer_id, source_lead_id, source_report_id, + status, outcome, due_date, assigned_user_id, attempt_count, + last_attempt_outcome, last_contacted_at, last_contacted_by_user_id, + closed_at, closed_by_user_id, rebooked_appointment_id, + create_time, update_time, version +) +SELECT + UUID(), + l.store_id, + sc.id, + l.id, + l.report_id, + 'pending', + NULL, + COALESCE(l.remind_date, CURRENT_DATE), + NULL, + 0, + NULL, + NULL, + NULL, + NULL, + NULL, + NULL, + COALESCE(l.create_time, l.update_time, NOW()), + COALESCE(l.update_time, l.create_time, NOW()), + 0 +FROM t_report_lead l +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.remind_status = 'pending'; + +-- 历史开放任务形成可靠的 task_created 事实;不复制手机号、IP、token 或微信标识。 +INSERT INTO t_business_event ( + event_id, event_type, event_version, store_id, store_customer_id, + aggregate_type, aggregate_id, actor_user_id, actor_role, source, + occurred_at, metadata_json, idempotency_key, create_time +) +SELECT + UUID(), + 'follow_up_created', + 1, + t.store_id, + t.store_customer_id, + 'follow_up_task', + t.id, + NULL, + NULL, + 'migration', + t.create_time, + CONCAT('{"dueDate":"', t.due_date, '"}'), + CONCAT('follow_up_created:', t.id), + NOW() +FROM t_follow_up_task t; + +-- 验证:以下五项必须全部为 0。 +SELECT COUNT(*) AS pending_leads_without_open_task_count +FROM t_report_lead l +LEFT JOIN t_follow_up_task t + ON t.source_lead_id = l.id + AND t.status IN ('pending', 'in_progress') +WHERE l.store_id IS NOT NULL + AND l.remind_status = 'pending' + AND t.id IS NULL; + +SELECT COUNT(*) AS invalid_follow_up_customer_scope_count +FROM t_follow_up_task t +LEFT JOIN t_store_customer sc + ON sc.id = t.store_customer_id + AND sc.store_id = t.store_id +LEFT JOIN t_store_customer canonical + ON canonical.id = CASE + WHEN sc.deleted = 0 THEN sc.id + ELSE sc.merged_into_store_customer_id + END + AND canonical.store_id = t.store_id + AND canonical.deleted = 0 + AND canonical.merged_into_store_customer_id IS NULL +WHERE sc.id IS NULL OR canonical.id IS NULL; + +SELECT COUNT(*) AS invalid_follow_up_state_count +FROM t_follow_up_task t +WHERE t.status NOT IN ('pending', 'in_progress', 'completed', 'canceled') + OR (t.status IN ('pending', 'in_progress') + AND (t.outcome IS NOT NULL OR t.closed_at IS NOT NULL OR t.closed_by_user_id IS NOT NULL + OR t.rebooked_appointment_id IS NOT NULL)) + OR (t.status = 'pending' AND t.assigned_user_id IS NOT NULL) + OR (t.status = 'in_progress' AND t.assigned_user_id IS NULL) + OR (t.status = 'completed' + AND (COALESCE(t.outcome, '') NOT IN ('rebooked', 'not_interested', 'invalid_contact') + OR t.closed_at IS NULL OR t.closed_by_user_id IS NULL + OR t.last_contacted_at IS NULL OR t.last_contacted_by_user_id IS NULL + OR t.attempt_count < 1)) + OR (t.status = 'canceled' + AND (COALESCE(t.outcome, '') <> 'unsubscribed' OR t.closed_at IS NULL + OR t.closed_by_user_id IS NOT NULL)) + OR (t.last_attempt_outcome IS NOT NULL + AND (t.last_attempt_outcome NOT IN ('no_answer', 'follow_later') + OR t.last_contacted_at IS NULL OR t.last_contacted_by_user_id IS NULL)) + OR (t.outcome = 'rebooked' AND t.rebooked_appointment_id IS NULL) + OR (COALESCE(t.outcome, '') <> 'rebooked' AND t.rebooked_appointment_id IS NOT NULL) + OR (t.rebooked_appointment_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 + FROM t_appointment a + WHERE a.id = t.rebooked_appointment_id + AND a.store_id = t.store_id + AND a.deleted = 0 + )) + OR t.attempt_count < 0; + +SELECT COUNT(*) AS duplicate_open_follow_up_task_count +FROM ( + SELECT source_lead_id + FROM t_follow_up_task + WHERE status IN ('pending', 'in_progress') + GROUP BY source_lead_id + HAVING COUNT(*) > 1 +) duplicated; + +SELECT COUNT(*) AS follow_up_tasks_without_created_event_count +FROM t_follow_up_task t +LEFT JOIN t_business_event e + ON e.idempotency_key = CONCAT('follow_up_created:', t.id) + AND e.event_type = 'follow_up_created' + AND e.store_id = t.store_id + AND e.aggregate_type = 'follow_up_task' + AND e.aggregate_id = t.id +WHERE e.id IS NULL; diff --git a/db/migrations/README.md b/db/migrations/README.md index 4c374d7..bf9cc88 100644 --- a/db/migrations/README.md +++ b/db/migrations/README.md @@ -22,4 +22,6 @@ `20260802_create_store_customer_timeline.sql` 必须在门店开通迁移之后执行:为被合并的软删除 StoreCustomer 投影增加同店 canonical 指针。既有 BusinessEvent 不改写,事实时间线同时读取 canonical 与别名的历史事件;脚本末尾两个验证计数必须为 0。 +`20260802_create_follow_up_task.sql` 必须在客户时间线迁移之后执行:建立可领取、改期、关闭和真实再次预约归因的 `t_follow_up_task`。只为仍待处理的历史留资建立开放任务,不猜测其他历史结果;脚本末尾五个验证计数必须为 0。 + 正式迁移框架仍属于 Phase 0 后续任务;接入前不得把本目录误当成自动执行目录。 diff --git a/src/main/java/com/petstore/config/ProductionDatabasePreflightRunner.java b/src/main/java/com/petstore/config/ProductionDatabasePreflightRunner.java index b908385..f93ae10 100644 --- a/src/main/java/com/petstore/config/ProductionDatabasePreflightRunner.java +++ b/src/main/java/com/petstore/config/ProductionDatabasePreflightRunner.java @@ -76,6 +76,83 @@ public class ProductionDatabasePreflightRunner implements ApplicationRunner { WHERE sc_alias.merged_into_store_customer_id IS NOT NULL AND canonical.id IS NULL """), + new Invariant("pending_leads_without_open_follow_up_task", """ + SELECT COUNT(*) FROM t_report_lead l + LEFT JOIN t_follow_up_task t + ON t.source_lead_id = l.id + AND t.status IN ('pending', 'in_progress') + WHERE l.store_id IS NOT NULL + AND l.remind_status = 'pending' + AND t.id IS NULL + """), + new Invariant("invalid_follow_up_customer_scope", """ + SELECT COUNT(*) FROM t_follow_up_task t + LEFT JOIN t_store_customer sc + ON sc.id = t.store_customer_id + AND sc.store_id = t.store_id + LEFT JOIN t_store_customer canonical + ON canonical.id = CASE + WHEN sc.deleted = 0 THEN sc.id + ELSE sc.merged_into_store_customer_id + END + AND canonical.store_id = t.store_id + AND canonical.deleted = 0 + AND canonical.merged_into_store_customer_id IS NULL + WHERE sc.id IS NULL OR canonical.id IS NULL + """), + new Invariant("invalid_follow_up_state", """ + SELECT COUNT(*) FROM t_follow_up_task t + WHERE t.status NOT IN ('pending', 'in_progress', 'completed', 'canceled') + OR (t.status IN ('pending', 'in_progress') + AND (t.outcome IS NOT NULL OR t.closed_at IS NOT NULL OR t.closed_by_user_id IS NOT NULL + OR t.rebooked_appointment_id IS NOT NULL)) + OR (t.status = 'pending' AND t.assigned_user_id IS NOT NULL) + OR (t.status = 'in_progress' AND t.assigned_user_id IS NULL) + OR (t.status = 'completed' + AND (COALESCE(t.outcome, '') NOT IN ('rebooked', 'not_interested', 'invalid_contact') + OR t.closed_at IS NULL OR t.closed_by_user_id IS NULL + OR t.last_contacted_at IS NULL OR t.last_contacted_by_user_id IS NULL + OR t.attempt_count < 1)) + OR (t.status = 'canceled' + AND (COALESCE(t.outcome, '') <> 'unsubscribed' OR t.closed_at IS NULL + OR t.closed_by_user_id IS NOT NULL)) + OR (t.last_attempt_outcome IS NOT NULL + AND (t.last_attempt_outcome NOT IN ('no_answer', 'follow_later') + OR t.last_contacted_at IS NULL OR t.last_contacted_by_user_id IS NULL)) + OR (t.outcome = 'rebooked' AND t.rebooked_appointment_id IS NULL) + OR (COALESCE(t.outcome, '') <> 'rebooked' AND t.rebooked_appointment_id IS NOT NULL) + OR (t.rebooked_appointment_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM t_appointment a + WHERE a.id = t.rebooked_appointment_id + AND a.store_id = t.store_id + AND a.deleted = 0 + )) + OR t.attempt_count < 0 + """), + new Invariant("duplicate_open_follow_up_task", """ + SELECT COUNT(*) FROM ( + SELECT source_lead_id FROM t_follow_up_task + WHERE status IN ('pending', 'in_progress') + GROUP BY source_lead_id HAVING COUNT(*) > 1 + ) duplicated + """), + new Invariant("follow_up_tasks_without_events", """ + SELECT COUNT(*) FROM t_follow_up_task t + LEFT JOIN t_business_event created + ON created.idempotency_key = CONCAT('follow_up_created:', t.id) + AND created.event_type = 'follow_up_created' + AND created.store_id = t.store_id + AND created.aggregate_type = 'follow_up_task' + AND created.aggregate_id = t.id + LEFT JOIN t_business_event rebooked + ON rebooked.idempotency_key = CONCAT('rebook_created:', t.rebooked_appointment_id) + AND rebooked.event_type = 'rebook_created' + AND rebooked.store_id = t.store_id + AND rebooked.aggregate_type = 'appointment' + AND rebooked.aggregate_id = t.rebooked_appointment_id + WHERE created.id IS NULL + OR (t.outcome = 'rebooked' AND rebooked.id IS NULL) + """), new Invariant("appointments_without_created_event", """ SELECT COUNT(*) FROM t_appointment a LEFT JOIN t_business_event e diff --git a/src/main/java/com/petstore/controller/AdminFollowUpTaskController.java b/src/main/java/com/petstore/controller/AdminFollowUpTaskController.java new file mode 100644 index 0000000..988a63f --- /dev/null +++ b/src/main/java/com/petstore/controller/AdminFollowUpTaskController.java @@ -0,0 +1,117 @@ +package com.petstore.controller; + +import com.petstore.auth.CurrentUser; +import com.petstore.auth.CurrentUserContext; +import com.petstore.service.FlowException; +import com.petstore.service.FollowUpTaskService; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDate; +import java.util.Map; + +/** 门店 Web 后台回访任务池;所有 storeId/actor 均从会话派生。 */ +@RestController +@RequestMapping("/api/admin/follow-up-tasks") +@RequiredArgsConstructor +@CrossOrigin +public class AdminFollowUpTaskController { + + private final FollowUpTaskService followUpTaskService; + + @GetMapping("") + public Map list( + @RequestParam(required = false, defaultValue = "open") String status, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate dueDateTo, + @RequestParam(required = false, defaultValue = "1") int page, + @RequestParam(required = false, defaultValue = "20") int pageSize + ) { + CurrentUser user = CurrentUserContext.require(); + if (!isStoreUser(user)) return forbidden(); + try { + return ok(followUpTaskService.list( + user.storeId(), status, dueDateTo, page, pageSize, user.userId() + )); + } catch (FlowException e) { + return error(e); + } + } + + @PostMapping("/{taskId}/start") + public Map start(@PathVariable String taskId) { + CurrentUser user = CurrentUserContext.require(); + if (!isStoreUser(user)) return forbidden(); + try { + return ok(followUpTaskService.start( + user.storeId(), taskId, user.userId(), user.role() + )); + } catch (FlowException e) { + return error(e); + } + } + + @PostMapping("/{taskId}/reschedule") + public Map reschedule( + @PathVariable String taskId, + @RequestBody Map body + ) { + CurrentUser user = CurrentUserContext.require(); + if (!isStoreUser(user)) return forbidden(); + try { + return ok(followUpTaskService.reschedule( + user.storeId(), + taskId, + text(body.get("dueDate")), + text(body.get("reason")), + user.userId(), + user.role() + )); + } catch (FlowException e) { + return error(e); + } + } + + @PostMapping("/{taskId}/close") + public Map close( + @PathVariable String taskId, + @RequestBody Map body + ) { + CurrentUser user = CurrentUserContext.require(); + if (!isStoreUser(user)) return forbidden(); + try { + return ok(followUpTaskService.close( + user.storeId(), taskId, text(body.get("outcome")), user.userId(), user.role() + )); + } catch (FlowException e) { + return error(e); + } + } + + private static boolean isStoreUser(CurrentUser user) { + return user.isStoreUser() && user.storeId() != null; + } + + private static Map ok(Object data) { + return Map.of("code", 200, "data", data); + } + + private static Map error(FlowException e) { + return Map.of("code", e.code(), "message", e.getMessage(), "bizCode", e.bizCode()); + } + + private static Map forbidden() { + return Map.of("code", 403, "message", "仅门店员工可处理回访任务", "bizCode", "FORBIDDEN"); + } + + private static String text(Object value) { + return value == null ? null : value.toString().trim(); + } +} diff --git a/src/main/java/com/petstore/controller/AppointmentController.java b/src/main/java/com/petstore/controller/AppointmentController.java index 73e18ae..80aea71 100644 --- a/src/main/java/com/petstore/controller/AppointmentController.java +++ b/src/main/java/com/petstore/controller/AppointmentController.java @@ -176,7 +176,14 @@ public class AppointmentController { appointment.setPetId(Long.valueOf(params.get("petId").toString())); } - return appointmentService.createBooking(appointment); + String followUpTaskId = optionalText(params.get("followUpTaskId")); + try { + return followUpTaskId.isBlank() + ? appointmentService.createBooking(appointment) + : appointmentService.createBooking(appointment, followUpTaskId); + } catch (com.petstore.service.FlowException e) { + return Map.of("code", e.code(), "message", e.getMessage(), "bizCode", e.bizCode()); + } } private static Long optionalLong(Object value) { diff --git a/src/main/java/com/petstore/entity/FollowUpTask.java b/src/main/java/com/petstore/entity/FollowUpTask.java new file mode 100644 index 0000000..b6bbe83 --- /dev/null +++ b/src/main/java/com/petstore/entity/FollowUpTask.java @@ -0,0 +1,114 @@ +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 jakarta.persistence.Version; +import lombok.Data; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 门店回访执行任务。ReportLead 保留宠主同意与来源事实,本实体承载可领取、改期和闭环的操作状态。 + */ +@Data +@Entity +@Table( + name = "t_follow_up_task", + uniqueConstraints = @UniqueConstraint(name = "uk_follow_up_task_public_id", columnNames = "task_id"), + indexes = { + @Index(name = "idx_follow_up_store_status_due", columnList = "store_id,status,due_date"), + @Index(name = "idx_follow_up_customer_status", columnList = "store_customer_id,status"), + @Index(name = "idx_follow_up_lead_status", columnList = "source_lead_id,status"), + @Index(name = "idx_follow_up_rebook", columnList = "rebooked_appointment_id") + } +) +public class FollowUpTask { + + public static final String STATUS_PENDING = "pending"; + public static final String STATUS_IN_PROGRESS = "in_progress"; + public static final String STATUS_COMPLETED = "completed"; + public static final String STATUS_CANCELED = "canceled"; + + public static final String ATTEMPT_NO_ANSWER = "no_answer"; + public static final String ATTEMPT_FOLLOW_LATER = "follow_later"; + + public static final String OUTCOME_REBOOKED = "rebooked"; + public static final String OUTCOME_NOT_INTERESTED = "not_interested"; + public static final String OUTCOME_INVALID_CONTACT = "invalid_contact"; + public static final String OUTCOME_UNSUBSCRIBED = "unsubscribed"; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** 对管理端暴露的随机 ID,不使用自增 ID 承担跨请求标识。 */ + @Column(name = "task_id", nullable = false, length = 36) + private String taskId; + + @Column(name = "store_id", nullable = false) + private Long storeId; + + @Column(name = "store_customer_id", nullable = false) + private Long storeCustomerId; + + @Column(name = "source_lead_id", nullable = false) + private Long sourceLeadId; + + @Column(name = "source_report_id") + private Long sourceReportId; + + /** pending / in_progress / completed / canceled */ + @Column(nullable = false, length = 16) + private String status; + + /** 仅终态使用:rebooked / not_interested / invalid_contact / unsubscribed。 */ + @Column(length = 24) + private String outcome; + + @Column(name = "due_date", nullable = false) + private LocalDate dueDate; + + @Column(name = "assigned_user_id") + private Long assignedUserId; + + @Column(name = "attempt_count", nullable = false) + private Integer attemptCount = 0; + + /** 最近一次非终态联系结果:no_answer / follow_later。 */ + @Column(name = "last_attempt_outcome", length = 24) + private String lastAttemptOutcome; + + @Column(name = "last_contacted_at") + private LocalDateTime lastContactedAt; + + @Column(name = "last_contacted_by_user_id") + private Long lastContactedByUserId; + + @Column(name = "closed_at") + private LocalDateTime closedAt; + + @Column(name = "closed_by_user_id") + private Long closedByUserId; + + /** 只能由真实 Appointment 创建成功后写入。 */ + @Column(name = "rebooked_appointment_id") + private Long rebookedAppointmentId; + + @Column(name = "create_time", nullable = false) + private LocalDateTime createTime; + + @Column(name = "update_time", nullable = false) + private LocalDateTime updateTime; + + /** 防止两个员工同时处理同一任务时发生静默覆盖。 */ + @Version + @Column(nullable = false) + private Long version = 0L; +} diff --git a/src/main/java/com/petstore/mapper/FollowUpTaskMapper.java b/src/main/java/com/petstore/mapper/FollowUpTaskMapper.java new file mode 100644 index 0000000..b5860b7 --- /dev/null +++ b/src/main/java/com/petstore/mapper/FollowUpTaskMapper.java @@ -0,0 +1,38 @@ +package com.petstore.mapper; + +import com.petstore.entity.FollowUpTask; +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface FollowUpTaskMapper + extends JpaRepository, JpaSpecificationExecutor { + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select t from FollowUpTask t where t.taskId = :taskId and t.storeId = :storeId") + Optional findByTaskIdAndStoreIdForUpdate( + @Param("taskId") String taskId, + @Param("storeId") Long storeId + ); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select t from FollowUpTask t where t.sourceLeadId = :sourceLeadId and t.status in :statuses order by t.createTime desc") + List findOpenBySourceLeadIdForUpdate( + @Param("sourceLeadId") Long sourceLeadId, + @Param("statuses") Collection statuses + ); + + List findByStoreIdAndStatusInAndDueDateLessThanEqualOrderByDueDateAscIdAsc( + Long storeId, + Collection statuses, + LocalDate dueDate + ); +} diff --git a/src/main/java/com/petstore/mapper/ReportLeadMapper.java b/src/main/java/com/petstore/mapper/ReportLeadMapper.java index c7abaaf..6a729fa 100644 --- a/src/main/java/com/petstore/mapper/ReportLeadMapper.java +++ b/src/main/java/com/petstore/mapper/ReportLeadMapper.java @@ -1,7 +1,11 @@ package com.petstore.mapper; import com.petstore.entity.ReportLead; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; import java.util.Optional; @@ -13,6 +17,10 @@ public interface ReportLeadMapper extends JpaRepository { Optional findFirstByUnsubscribeToken(String unsubscribeToken); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select l from ReportLead l where l.id = :id") + Optional findByIdForUpdate(@Param("id") Long id); + /** 老板回访池:按状态倒查、按提醒日期升序 */ List findByStoreIdAndRemindStatusOrderByRemindDateAsc(Long storeId, String remindStatus); diff --git a/src/main/java/com/petstore/service/AdminBusinessEventService.java b/src/main/java/com/petstore/service/AdminBusinessEventService.java index b0c8819..f34cfa0 100644 --- a/src/main/java/com/petstore/service/AdminBusinessEventService.java +++ b/src/main/java/com/petstore/service/AdminBusinessEventService.java @@ -34,6 +34,7 @@ public class AdminBusinessEventService { to ); long leads = count(storeId, BusinessEventService.LEAD_SUBMITTED, from, to); + long rebooked = count(storeId, BusinessEventService.REBOOK_CREATED, from, to); Map data = new LinkedHashMap<>(); data.put("date", day.toString()); @@ -41,9 +42,9 @@ public class AdminBusinessEventService { data.put("reportSentCount", sent); data.put("reportOpenedCount", opened); data.put("leadSubmittedCount", leads); - data.put("rebookCreatedCount", null); + data.put("rebookCreatedCount", rebooked); data.put("reportSentTrackingReady", true); - data.put("rebookTrackingReady", false); + data.put("rebookTrackingReady", true); return data; } diff --git a/src/main/java/com/petstore/service/AdminWorkbenchService.java b/src/main/java/com/petstore/service/AdminWorkbenchService.java index 98a71b6..22812a2 100644 --- a/src/main/java/com/petstore/service/AdminWorkbenchService.java +++ b/src/main/java/com/petstore/service/AdminWorkbenchService.java @@ -1,9 +1,11 @@ package com.petstore.service; import com.petstore.entity.Appointment; +import com.petstore.entity.FollowUpTask; import com.petstore.entity.Report; import com.petstore.entity.ReportLead; import com.petstore.mapper.AppointmentMapper; +import com.petstore.mapper.FollowUpTaskMapper; import com.petstore.mapper.ReportLeadMapper; import com.petstore.mapper.ReportMapper; import lombok.RequiredArgsConstructor; @@ -15,6 +17,9 @@ import java.util.ArrayList; import java.util.Comparator; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; /** 单店今日行动读模型:准确计数、低敏预览,不返回手机号、报告 token 或媒体地址。 */ @Service @@ -32,6 +37,7 @@ public class AdminWorkbenchService { private final AppointmentMapper appointmentMapper; private final ReportMapper reportMapper; + private final FollowUpTaskMapper followUpTaskMapper; private final ReportLeadMapper reportLeadMapper; private final AdminBusinessEventService adminBusinessEventService; @@ -81,11 +87,20 @@ public class AdminWorkbenchService { ) ); - List dueFollowUps = safeLeads( - reportLeadMapper.findByStoreIdAndRemindStatusOrderByRemindDateAsc(storeId, "pending") - ).stream() - .filter(lead -> lead.getRemindDate() == null || !lead.getRemindDate().isAfter(day)) - .toList(); + List dueFollowUps = followUpTaskMapper + .findByStoreIdAndStatusInAndDueDateLessThanEqualOrderByDueDateAscIdAsc( + storeId, + Set.of(FollowUpTask.STATUS_PENDING, FollowUpTask.STATUS_IN_PROGRESS), + day + ); + if (dueFollowUps == null) dueFollowUps = List.of(); + Map dueLeads = reportLeadMapper.findAllById( + dueFollowUps.stream() + .map(FollowUpTask::getSourceLeadId) + .filter(java.util.Objects::nonNull) + .collect(Collectors.toSet()) + ).stream() + .collect(Collectors.toMap(ReportLead::getId, Function.identity(), (a, b) -> a)); long todayAppointments = appointmentMapper .countByStoreIdAndDeletedFalseAndAppointmentTimeGreaterThanEqualAndAppointmentTimeLessThan( @@ -111,7 +126,7 @@ public class AdminWorkbenchService { addAppointmentGroup(actions, UPCOMING_TODAY, "normal", upcomingToday, now); addReportGroup(actions, highlightAnomalies); addUnsentReportGroup(actions, unsentReports); - addLeadGroup(actions, dueFollowUps); + addFollowUpGroup(actions, dueFollowUps, dueLeads); Map reportFunnel = adminBusinessEventService.reportFunnel(storeId, day); return new WorkbenchData(day.toString(), now, metrics, List.copyOf(actions), reportFunnel); @@ -129,10 +144,6 @@ public class AdminWorkbenchService { return reports == null ? List.of() : reports; } - private static List safeLeads(List leads) { - return leads == null ? List.of() : leads; - } - private static boolean isHighlightAnomaly(Report report, LocalDateTime staleBefore) { if ("failed".equals(report.getHighlightVideoStatus())) { return true; @@ -204,21 +215,28 @@ public class AdminWorkbenchService { groups.add(new ActionGroup(UNSENT_REPORT, "high", reports.size(), items)); } - private static void addLeadGroup(List groups, List leads) { - if (leads.isEmpty()) return; - List items = leads.stream() + private static void addFollowUpGroup( + List groups, + List tasks, + Map leads + ) { + if (tasks.isEmpty()) return; + List items = tasks.stream() .limit(PREVIEW_LIMIT) - .map(lead -> new ActionItem( - "lead", - lead.getId(), - lead.getPetName(), - lead.getServiceType(), - lead.getRemindDate() == null ? null : lead.getRemindDate().toString(), - lead.getRemindStatus(), + .map(task -> { + ReportLead lead = leads.get(task.getSourceLeadId()); + return new ActionItem( + "follow_up_task", + task.getId(), + lead == null ? null : lead.getPetName(), + lead == null ? null : lead.getServiceType(), + task.getDueDate() == null ? null : task.getDueDate().toString(), + task.getStatus(), true - )) + ); + }) .toList(); - groups.add(new ActionGroup(FOLLOW_UP_DUE, "normal", leads.size(), items)); + groups.add(new ActionGroup(FOLLOW_UP_DUE, "normal", tasks.size(), items)); } public record WorkbenchData( diff --git a/src/main/java/com/petstore/service/AppointmentService.java b/src/main/java/com/petstore/service/AppointmentService.java index f2cad8b..efdb8b8 100644 --- a/src/main/java/com/petstore/service/AppointmentService.java +++ b/src/main/java/com/petstore/service/AppointmentService.java @@ -43,6 +43,7 @@ public class AppointmentService { private final BusinessEventService businessEventService; private final ServiceTypeService serviceTypeService; private final BookingCapacityService bookingCapacityService; + private final FollowUpTaskService followUpTaskService; // 宠主查看归属于自己的预约;兼容 customer_user_id 尚未回填的旧数据。 public List getByUserId(Long customerUserId) { @@ -163,6 +164,11 @@ public class AppointmentService { */ @Transactional public Map createBooking(Appointment appointment) { + return createBooking(appointment, null); + } + + @Transactional + public Map createBooking(Appointment appointment, String followUpTaskId) { if (appointment.getAppointmentTime() == null) { return Map.of("code", 400, "message", "请填写预约时间"); } @@ -253,12 +259,29 @@ public class AppointmentService { "bizCode", "CUSTOMER_IDENTITY_CONFLICT" ); } + com.petstore.entity.FollowUpTask followUpTask = null; + if (followUpTaskId != null && !followUpTaskId.isBlank()) { + followUpTask = followUpTaskService.prepareRebook( + appointment.getStoreId(), + followUpTaskId, + storeCustomer.getId(), + appointment.getCreatedByUserId() + ); + } Appointment saved = appointmentMapper.save(appointment); businessEventService.recordAppointmentCreated( saved, storeCustomer, resolveActorRole(saved.getCreatedByUserId()) ); + if (followUpTask != null) { + followUpTaskService.markRebooked( + followUpTask, + saved, + saved.getCreatedByUserId(), + resolveActorRole(saved.getCreatedByUserId()) + ); + } Map ok = new HashMap<>(); ok.put("code", 200); ok.put("message", "创建成功"); diff --git a/src/main/java/com/petstore/service/BusinessEventService.java b/src/main/java/com/petstore/service/BusinessEventService.java index faf1778..cad5377 100644 --- a/src/main/java/com/petstore/service/BusinessEventService.java +++ b/src/main/java/com/petstore/service/BusinessEventService.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.petstore.entity.Appointment; import com.petstore.entity.BusinessEvent; +import com.petstore.entity.FollowUpTask; import com.petstore.entity.Report; import com.petstore.entity.ReportLead; import com.petstore.entity.StoreCustomer; @@ -36,6 +37,12 @@ public class BusinessEventService { public static final String REPORT_OPENED = "report_opened"; public static final String REPORT_REOPENED = "report_reopened"; public static final String LEAD_SUBMITTED = "lead_submitted"; + public static final String FOLLOW_UP_CREATED = "follow_up_created"; + public static final String FOLLOW_UP_STARTED = "follow_up_started"; + public static final String FOLLOW_UP_RESCHEDULED = "follow_up_rescheduled"; + public static final String FOLLOW_UP_COMPLETED = "follow_up_completed"; + public static final String FOLLOW_UP_CANCELED = "follow_up_canceled"; + public static final String REBOOK_CREATED = "rebook_created"; public static final String STORE_REGISTERED = "store_registered"; private static final Set FORBIDDEN_METADATA_FRAGMENTS = Set.of( @@ -210,6 +217,132 @@ public class BusinessEventService { )); } + @Transactional + public BusinessEvent recordFollowUpCreated(FollowUpTask task) { + return record(new EventCommand( + FOLLOW_UP_CREATED, + task.getStoreId(), + task.getStoreCustomerId(), + "follow_up_task", + task.getId(), + null, + null, + "system", + task.getCreateTime(), + "follow_up_created:" + task.getId(), + Map.of("dueDate", task.getDueDate().toString()) + )); + } + + @Transactional + public BusinessEvent recordFollowUpStarted( + FollowUpTask task, + Long actorUserId, + String actorRole + ) { + return record(new EventCommand( + FOLLOW_UP_STARTED, + task.getStoreId(), + task.getStoreCustomerId(), + "follow_up_task", + task.getId(), + actorUserId, + actorRole, + "admin", + task.getUpdateTime(), + "follow_up_started:" + task.getId() + ":" + safeAttemptCount(task), + Map.of("attemptCount", safeAttemptCount(task)) + )); + } + + @Transactional + public BusinessEvent recordFollowUpRescheduled( + FollowUpTask task, + Long actorUserId, + String actorRole + ) { + return record(new EventCommand( + FOLLOW_UP_RESCHEDULED, + task.getStoreId(), + task.getStoreCustomerId(), + "follow_up_task", + task.getId(), + actorUserId, + actorRole, + "admin", + task.getLastContactedAt(), + "follow_up_rescheduled:" + task.getId() + ":" + safeAttemptCount(task), + Map.of( + "attemptOutcome", task.getLastAttemptOutcome(), + "dueDate", task.getDueDate().toString(), + "attemptCount", safeAttemptCount(task) + ) + )); + } + + @Transactional + public BusinessEvent recordFollowUpCompleted( + FollowUpTask task, + Long actorUserId, + String actorRole + ) { + return record(new EventCommand( + FOLLOW_UP_COMPLETED, + task.getStoreId(), + task.getStoreCustomerId(), + "follow_up_task", + task.getId(), + actorUserId, + actorRole, + "admin", + task.getClosedAt(), + "follow_up_completed:" + task.getId(), + Map.of( + "outcome", task.getOutcome(), + "attemptCount", safeAttemptCount(task) + ) + )); + } + + @Transactional + public BusinessEvent recordFollowUpCanceled(FollowUpTask task) { + return record(new EventCommand( + FOLLOW_UP_CANCELED, + task.getStoreId(), + task.getStoreCustomerId(), + "follow_up_task", + task.getId(), + null, + null, + "public_report", + task.getClosedAt(), + "follow_up_canceled:" + task.getId(), + Map.of("outcome", task.getOutcome()) + )); + } + + @Transactional + public BusinessEvent recordRebookCreated( + FollowUpTask task, + Appointment appointment, + Long actorUserId, + String actorRole + ) { + return record(new EventCommand( + REBOOK_CREATED, + task.getStoreId(), + task.getStoreCustomerId(), + "appointment", + appointment.getId(), + actorUserId, + actorRole, + "admin", + appointment.getCreateTime(), + "rebook_created:" + appointment.getId(), + Map.of("bookingOrigin", "follow_up") + )); + } + /** * 公开报告打开事件。无效 token 静默忽略,避免借埋点接口枚举报告;绝不持久化 token/hash。 */ @@ -278,6 +411,10 @@ public class BusinessEventService { .orElse(null); } + private static int safeAttemptCount(FollowUpTask task) { + return task.getAttemptCount() == null ? 0 : Math.max(task.getAttemptCount(), 0); + } + private String serializeMetadata(Map metadata) { Map safe = new LinkedHashMap<>(); if (metadata != null) { diff --git a/src/main/java/com/petstore/service/FollowUpTaskService.java b/src/main/java/com/petstore/service/FollowUpTaskService.java new file mode 100644 index 0000000..3e04f9c --- /dev/null +++ b/src/main/java/com/petstore/service/FollowUpTaskService.java @@ -0,0 +1,535 @@ +package com.petstore.service; + +import com.petstore.entity.Appointment; +import com.petstore.entity.FollowUpTask; +import com.petstore.entity.ReportLead; +import com.petstore.entity.StoreCustomer; +import com.petstore.entity.User; +import com.petstore.mapper.FollowUpTaskMapper; +import com.petstore.mapper.ReportLeadMapper; +import com.petstore.mapper.StoreCustomerMapper; +import com.petstore.mapper.UserMapper; +import jakarta.persistence.criteria.Predicate; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** FollowUpTask 状态机、同店权限、操作审计与真实再次预约归因。 */ +@Service +@RequiredArgsConstructor +public class FollowUpTaskService { + + private static final Set OPEN_STATUSES = Set.of( + FollowUpTask.STATUS_PENDING, + FollowUpTask.STATUS_IN_PROGRESS + ); + private static final Set RESCHEDULE_REASONS = Set.of( + FollowUpTask.ATTEMPT_NO_ANSWER, + FollowUpTask.ATTEMPT_FOLLOW_LATER + ); + private static final Set CLOSE_OUTCOMES = Set.of( + FollowUpTask.OUTCOME_NOT_INTERESTED, + FollowUpTask.OUTCOME_INVALID_CONTACT + ); + + private final FollowUpTaskMapper followUpTaskMapper; + private final ReportLeadMapper reportLeadMapper; + private final StoreCustomerMapper storeCustomerMapper; + private final UserMapper userMapper; + private final StoreCustomerService storeCustomerService; + private final BusinessEventService businessEventService; + private final AuditLogService auditLogService; + + /** 留资成功后确保存在且只存在一个开放任务;终态后的再次提交会形成新一轮任务。 */ + @Transactional + public FollowUpTask ensureForLead(ReportLead lead, StoreCustomer storeCustomer) { + if (lead == null || lead.getId() == null || lead.getStoreId() == null + || storeCustomer == null || storeCustomer.getId() == null + || !Objects.equals(lead.getStoreId(), storeCustomer.getStoreId()) + || Boolean.TRUE.equals(storeCustomer.getDeleted())) { + throw new IllegalArgumentException("创建回访任务缺少稳定归属"); + } + ReportLead lockedLead = reportLeadMapper.findByIdForUpdate(lead.getId()) + .orElseThrow(() -> new IllegalArgumentException("留资记录不存在")); + if ("unsubscribed".equals(lockedLead.getRemindStatus())) { + throw new IllegalArgumentException("该提醒已退订"); + } + List open = followUpTaskMapper.findOpenBySourceLeadIdForUpdate( + lockedLead.getId(), OPEN_STATUSES + ); + if (!open.isEmpty()) { + FollowUpTask existing = open.get(0); + boolean changed = false; + if (FollowUpTask.STATUS_PENDING.equals(existing.getStatus()) + && lockedLead.getRemindDate() != null + && !lockedLead.getRemindDate().equals(existing.getDueDate())) { + existing.setDueDate(lockedLead.getRemindDate()); + changed = true; + } + if (!Objects.equals(existing.getStoreCustomerId(), storeCustomer.getId())) { + existing.setStoreCustomerId(storeCustomer.getId()); + changed = true; + } + if (changed) { + existing.setUpdateTime(LocalDateTime.now()); + return followUpTaskMapper.save(existing); + } + return existing; + } + + LocalDateTime now = LocalDateTime.now(); + FollowUpTask task = new FollowUpTask(); + task.setTaskId(UUID.randomUUID().toString()); + task.setStoreId(lockedLead.getStoreId()); + task.setStoreCustomerId(storeCustomer.getId()); + task.setSourceLeadId(lockedLead.getId()); + task.setSourceReportId(lockedLead.getReportId()); + task.setStatus(FollowUpTask.STATUS_PENDING); + task.setDueDate(lockedLead.getRemindDate() == null ? LocalDate.now() : lockedLead.getRemindDate()); + task.setAttemptCount(0); + task.setCreateTime(now); + task.setUpdateTime(now); + task.setVersion(0L); + task = followUpTaskMapper.save(task); + businessEventService.recordFollowUpCreated(task); + return task; + } + + @Transactional(readOnly = true) + public TaskPage list( + Long storeId, + String status, + LocalDate dueDateTo, + int page, + int pageSize, + Long currentUserId + ) { + requireStore(storeId); + String normalizedStatus = normalizeFilterStatus(status); + int safePage = Math.max(page, 1); + int safeSize = Math.min(Math.max(pageSize, 1), 100); + + Specification spec = (root, query, cb) -> { + List predicates = new ArrayList<>(); + predicates.add(cb.equal(root.get("storeId"), storeId)); + if ("open".equals(normalizedStatus)) { + predicates.add(root.get("status").in(OPEN_STATUSES)); + } else if (normalizedStatus != null) { + predicates.add(cb.equal(root.get("status"), normalizedStatus)); + } + if (dueDateTo != null) { + predicates.add(cb.lessThanOrEqualTo(root.get("dueDate"), dueDateTo)); + } + return cb.and(predicates.toArray(Predicate[]::new)); + }; + PageRequest pageable = PageRequest.of( + safePage - 1, + safeSize, + Sort.by(Sort.Order.asc("dueDate"), Sort.Order.asc("id")) + ); + Page result = followUpTaskMapper.findAll(spec, pageable); + List views = views(result.getContent(), currentUserId); + return new TaskPage( + result.getTotalElements(), + safePage, + safeSize, + safePage < result.getTotalPages(), + views + ); + } + + @Transactional + public TaskView start(Long storeId, String taskId, Long actorUserId, String actorRole) { + FollowUpTask task = taskForUpdate(storeId, taskId); + if (FollowUpTask.STATUS_IN_PROGRESS.equals(task.getStatus())) { + if (Objects.equals(task.getAssignedUserId(), actorUserId)) { + return view(task, actorUserId); + } + throw new FlowException(409, "FOLLOW_UP_ALREADY_CLAIMED", "该任务已由其他员工领取"); + } + if (!FollowUpTask.STATUS_PENDING.equals(task.getStatus())) { + throw stateConflict(); + } + task.setStatus(FollowUpTask.STATUS_IN_PROGRESS); + task.setAssignedUserId(actorUserId); + task.setUpdateTime(LocalDateTime.now()); + task = followUpTaskMapper.save(task); + businessEventService.recordFollowUpStarted(task, actorUserId, actorRole); + audit(task, actorUserId, actorRole, "follow_up_started", Map.of()); + return view(task, actorUserId); + } + + @Transactional + public TaskView reschedule( + Long storeId, + String taskId, + String dueDateText, + String reason, + Long actorUserId, + String actorRole + ) { + FollowUpTask task = taskForUpdate(storeId, taskId); + requireInProgressOwner(task, actorUserId); + String normalizedReason = normalizeRequired(reason); + if (!RESCHEDULE_REASONS.contains(normalizedReason)) { + throw new FlowException(400, "FOLLOW_UP_REASON_INVALID", "改期原因仅支持未接通或稍后联系"); + } + LocalDate dueDate = parseDueDate(dueDateText); + if (dueDate.isBefore(LocalDate.now())) { + throw new FlowException(400, "FOLLOW_UP_DUE_DATE_INVALID", "下次回访日期不能早于今天"); + } + LocalDateTime now = LocalDateTime.now(); + task.setStatus(FollowUpTask.STATUS_PENDING); + task.setOutcome(null); + task.setDueDate(dueDate); + task.setAttemptCount(safeAttempts(task) + 1); + task.setLastAttemptOutcome(normalizedReason); + task.setLastContactedAt(now); + task.setLastContactedByUserId(actorUserId); + task.setAssignedUserId(null); + task.setUpdateTime(now); + task = followUpTaskMapper.save(task); + markLeadStatus(task.getSourceLeadId(), "pending"); + businessEventService.recordFollowUpRescheduled(task, actorUserId, actorRole); + audit(task, actorUserId, actorRole, "follow_up_rescheduled", Map.of( + "reason", normalizedReason, + "dueDate", dueDate.toString(), + "attemptCount", task.getAttemptCount() + )); + return view(task, actorUserId); + } + + @Transactional + public TaskView close( + Long storeId, + String taskId, + String outcome, + Long actorUserId, + String actorRole + ) { + FollowUpTask task = taskForUpdate(storeId, taskId); + requireInProgressOwner(task, actorUserId); + String normalizedOutcome = normalizeRequired(outcome); + if (!CLOSE_OUTCOMES.contains(normalizedOutcome)) { + throw new FlowException(400, "FOLLOW_UP_OUTCOME_INVALID", "关闭结果不受支持"); + } + LocalDateTime now = LocalDateTime.now(); + task.setStatus(FollowUpTask.STATUS_COMPLETED); + task.setOutcome(normalizedOutcome); + task.setAttemptCount(safeAttempts(task) + 1); + task.setLastAttemptOutcome(null); + task.setLastContactedAt(now); + task.setLastContactedByUserId(actorUserId); + task.setClosedAt(now); + task.setClosedByUserId(actorUserId); + task.setUpdateTime(now); + task = followUpTaskMapper.save(task); + markLeadStatus(task.getSourceLeadId(), "canceled"); + businessEventService.recordFollowUpCompleted(task, actorUserId, actorRole); + audit(task, actorUserId, actorRole, "follow_up_closed", Map.of( + "outcome", normalizedOutcome, + "attemptCount", task.getAttemptCount() + )); + return view(task, actorUserId); + } + + /** Appointment 保存前调用:校验任务开放、领取人和 canonical 客户归属。 */ + @Transactional + public FollowUpTask prepareRebook( + Long storeId, + String taskId, + Long canonicalStoreCustomerId, + Long actorUserId + ) { + FollowUpTask task = taskForUpdate(storeId, taskId); + requireInProgressOwner(task, actorUserId); + StoreCustomer taskCustomer = storeCustomerService.resolveCanonical(storeId, task.getStoreCustomerId()); + if (taskCustomer == null || !Objects.equals(taskCustomer.getId(), canonicalStoreCustomerId)) { + throw new FlowException(409, "FOLLOW_UP_CUSTOMER_MISMATCH", "回访任务与本次预约宠主不一致"); + } + task.setStoreCustomerId(taskCustomer.getId()); + return task; + } + + /** Appointment 已真实保存后调用;同一事务内形成终态和 rebook_created 事实。 */ + @Transactional + public void markRebooked( + FollowUpTask task, + Appointment appointment, + Long actorUserId, + String actorRole + ) { + if (task == null || appointment == null || appointment.getId() == null) { + throw new FlowException(409, "FOLLOW_UP_REBOOK_INVALID", "再次预约归因缺少真实预约"); + } + requireInProgressOwner(task, actorUserId); + if (!Objects.equals(task.getStoreId(), appointment.getStoreId()) + || Boolean.TRUE.equals(appointment.getDeleted())) { + throw new FlowException(409, "FOLLOW_UP_REBOOK_INVALID", "再次预约与回访任务归属不一致"); + } + LocalDateTime now = LocalDateTime.now(); + task.setStatus(FollowUpTask.STATUS_COMPLETED); + task.setOutcome(FollowUpTask.OUTCOME_REBOOKED); + task.setAttemptCount(safeAttempts(task) + 1); + task.setLastAttemptOutcome(null); + task.setLastContactedAt(now); + task.setLastContactedByUserId(actorUserId); + task.setClosedAt(now); + task.setClosedByUserId(actorUserId); + task.setRebookedAppointmentId(appointment.getId()); + task.setUpdateTime(now); + followUpTaskMapper.save(task); + markLeadStatus(task.getSourceLeadId(), "sent"); + businessEventService.recordFollowUpCompleted(task, actorUserId, actorRole); + businessEventService.recordRebookCreated(task, appointment, actorUserId, actorRole); + audit(task, actorUserId, actorRole, "follow_up_rebooked", Map.of( + "appointmentId", appointment.getId(), + "attemptCount", task.getAttemptCount() + )); + } + + /** 宠主退订时取消该留资下全部开放任务;不增加员工联系次数。 */ + @Transactional + public void cancelForUnsubscribe(ReportLead lead) { + if (lead == null || lead.getId() == null) return; + List open = followUpTaskMapper.findOpenBySourceLeadIdForUpdate(lead.getId(), OPEN_STATUSES); + LocalDateTime now = LocalDateTime.now(); + for (FollowUpTask task : open) { + task.setStatus(FollowUpTask.STATUS_CANCELED); + task.setOutcome(FollowUpTask.OUTCOME_UNSUBSCRIBED); + task.setAssignedUserId(null); + task.setLastAttemptOutcome(null); + task.setClosedAt(now); + task.setClosedByUserId(null); + task.setUpdateTime(now); + followUpTaskMapper.save(task); + businessEventService.recordFollowUpCanceled(task); + } + } + + private List views(List tasks, Long currentUserId) { + if (tasks.isEmpty()) return List.of(); + Map leads = reportLeadMapper.findAllById(ids(tasks, FollowUpTask::getSourceLeadId)).stream() + .collect(Collectors.toMap(ReportLead::getId, Function.identity(), (a, b) -> a)); + Map customers = loadCustomers(tasks); + Map users = userMapper.findAllById(ids(tasks, FollowUpTask::getAssignedUserId)).stream() + .collect(Collectors.toMap(User::getId, Function.identity(), (a, b) -> a)); + return tasks.stream() + .map(task -> toView( + task, + leads.get(task.getSourceLeadId()), + canonicalFromMap(task, customers), + users.get(task.getAssignedUserId()), + currentUserId + )) + .toList(); + } + + private TaskView view(FollowUpTask task, Long currentUserId) { + return views(List.of(task), currentUserId).get(0); + } + + private Map loadCustomers(List tasks) { + Map customers = storeCustomerMapper.findAllById( + ids(tasks, FollowUpTask::getStoreCustomerId) + ).stream() + .collect(Collectors.toMap(StoreCustomer::getId, Function.identity(), (a, b) -> a)); + Set canonicalIds = customers.values().stream() + .map(StoreCustomer::getMergedIntoStoreCustomerId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + if (!canonicalIds.isEmpty()) { + for (StoreCustomer canonical : storeCustomerMapper.findAllById(canonicalIds)) { + customers.put(canonical.getId(), canonical); + } + } + return customers; + } + + private static StoreCustomer canonicalFromMap(FollowUpTask task, Map customers) { + StoreCustomer customer = customers.get(task.getStoreCustomerId()); + if (customer != null && Boolean.TRUE.equals(customer.getDeleted()) + && customer.getMergedIntoStoreCustomerId() != null) { + return customers.get(customer.getMergedIntoStoreCustomerId()); + } + return customer; + } + + private static TaskView toView( + FollowUpTask task, + ReportLead lead, + StoreCustomer customer, + User assigned, + Long currentUserId + ) { + String phone = firstNonBlank( + customer == null ? null : customer.getPhone(), + lead == null ? null : lead.getPhone() + ); + String displayName = firstNonBlank(customer == null ? null : customer.getDisplayName(), "客户"); + boolean open = OPEN_STATUSES.contains(task.getStatus()); + return new TaskView( + task.getTaskId(), + customer == null ? task.getStoreCustomerId() : customer.getId(), + task.getSourceLeadId(), + task.getSourceReportId(), + displayName, + open ? phone : null, + AdminServiceCustomerService.maskPhone(phone), + lead == null ? null : lead.getPetName(), + lead == null ? null : lead.getServiceType(), + task.getStatus(), + task.getOutcome(), + task.getDueDate(), + safeAttempts(task), + task.getLastAttemptOutcome(), + task.getAssignedUserId(), + assigned == null ? null : assigned.getName(), + task.getAssignedUserId() != null && Objects.equals(task.getAssignedUserId(), currentUserId), + task.getLastContactedAt(), + task.getClosedAt(), + task.getRebookedAppointmentId(), + open && task.getDueDate() != null && task.getDueDate().isBefore(LocalDate.now()), + task.getCreateTime(), + task.getUpdateTime() + ); + } + + private FollowUpTask taskForUpdate(Long storeId, String taskId) { + requireStore(storeId); + String normalized = normalizeRequired(taskId); + return followUpTaskMapper.findByTaskIdAndStoreIdForUpdate(normalized, storeId) + .orElseThrow(() -> new FlowException(404, "FOLLOW_UP_TASK_NOT_FOUND", "回访任务不存在")); + } + + private static void requireInProgressOwner(FollowUpTask task, Long actorUserId) { + if (!FollowUpTask.STATUS_IN_PROGRESS.equals(task.getStatus())) { + throw stateConflict(); + } + if (task.getAssignedUserId() == null || !Objects.equals(task.getAssignedUserId(), actorUserId)) { + throw new FlowException(409, "FOLLOW_UP_ALREADY_CLAIMED", "该任务已由其他员工领取"); + } + } + + private void markLeadStatus(Long leadId, String status) { + if (leadId == null) return; + ReportLead lead = reportLeadMapper.findByIdForUpdate(leadId).orElse(null); + if (lead == null || "unsubscribed".equals(lead.getRemindStatus())) return; + lead.setRemindStatus(status); + lead.setUpdateTime(LocalDateTime.now()); + reportLeadMapper.save(lead); + } + + private void audit( + FollowUpTask task, + Long actorUserId, + String actorRole, + String action, + Map metadata + ) { + auditLogService.record( + task.getStoreId(), actorUserId, actorRole, action, + "follow_up_task", task.getTaskId(), "success", metadata + ); + } + + private static String normalizeFilterStatus(String status) { + if (status == null || status.isBlank() || "open".equalsIgnoreCase(status)) return "open"; + String value = status.trim().toLowerCase(); + if ("all".equals(value)) return null; + if (!Set.of( + FollowUpTask.STATUS_PENDING, + FollowUpTask.STATUS_IN_PROGRESS, + FollowUpTask.STATUS_COMPLETED, + FollowUpTask.STATUS_CANCELED + ).contains(value)) { + throw new FlowException(400, "FOLLOW_UP_STATUS_INVALID", "回访任务状态不受支持"); + } + return value; + } + + private static LocalDate parseDueDate(String text) { + try { + return LocalDate.parse(normalizeRequired(text)); + } catch (Exception e) { + throw new FlowException(400, "FOLLOW_UP_DUE_DATE_INVALID", "回访日期格式应为 yyyy-MM-dd"); + } + } + + private static String normalizeRequired(String value) { + String normalized = value == null ? "" : value.trim().toLowerCase(); + if (normalized.isBlank()) { + throw new FlowException(400, "FOLLOW_UP_PARAMETER_REQUIRED", "缺少回访任务参数"); + } + return normalized; + } + + private static int safeAttempts(FollowUpTask task) { + return task.getAttemptCount() == null ? 0 : Math.max(task.getAttemptCount(), 0); + } + + private static void requireStore(Long storeId) { + if (storeId == null) { + throw new FlowException(403, "FORBIDDEN", "仅门店员工可处理回访任务"); + } + } + + private static FlowException stateConflict() { + return new FlowException(409, "FOLLOW_UP_STATE_CONFLICT", "回访任务状态已变化,请刷新后重试"); + } + + private static Set ids(Collection rows, Function getter) { + return rows.stream().map(getter).filter(Objects::nonNull).collect(Collectors.toSet()); + } + + private static String firstNonBlank(String... values) { + for (String value : values) { + if (value != null && !value.isBlank()) return value.trim(); + } + return null; + } + + public record TaskPage(long total, int page, int pageSize, boolean hasMore, List list) {} + + public record TaskView( + String taskId, + Long storeCustomerId, + Long sourceLeadId, + Long reportId, + String displayName, + String phone, + String phoneMasked, + String petName, + String serviceType, + String status, + String outcome, + LocalDate dueDate, + int attemptCount, + String lastAttemptOutcome, + Long assignedUserId, + String assignedUserName, + boolean assignedToCurrentUser, + LocalDateTime lastContactedAt, + LocalDateTime closedAt, + Long rebookedAppointmentId, + boolean overdue, + LocalDateTime createTime, + LocalDateTime updateTime + ) {} +} diff --git a/src/main/java/com/petstore/service/ReportLeadService.java b/src/main/java/com/petstore/service/ReportLeadService.java index a1f3355..f51a270 100644 --- a/src/main/java/com/petstore/service/ReportLeadService.java +++ b/src/main/java/com/petstore/service/ReportLeadService.java @@ -13,6 +13,7 @@ import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; import java.time.LocalDateTime; @@ -34,6 +35,7 @@ public class ReportLeadService { private final WechatMiniProgramService wechatMiniProgramService; private final StoreCustomerService storeCustomerService; private final BusinessEventService businessEventService; + private final FollowUpTaskService followUpTaskService; /** 留资提交结果:记录 + 是否已将微信 openid 绑定到宠主账号(S3→S4)+ 是否同报告同手机号再次提交(幂等更新) */ public record LeadSubmitOutcome(ReportLead lead, boolean wechatBound, boolean repeatSubmit) {} @@ -116,6 +118,7 @@ public class ReportLeadService { * * @param loginCode 小程序 uni.login 的 code,服务端 jscode2session 取 openid/unionid 写入留资;H5 可空。 */ + @Transactional public LeadSubmitOutcome submit(String reportToken, String phone, String consentIp, String loginCode) { Report report = reportMapper.findFirstByReportTokenAndDeletedFalse(reportToken).orElse(null); if (report == null) { @@ -213,20 +216,26 @@ public class ReportLeadService { StoreCustomer storeCustomer = storeCustomerService.touchLead(report.getStoreId(), phone, null, now); businessEventService.recordLeadSubmitted(lead, storeCustomer, repeatSubmit); + followUpTaskService.ensureForLead(lead, storeCustomer); return new LeadSubmitOutcome(lead, wechatBound, repeatSubmit); } /** 退订:按 unsubscribeToken 找记录并置状态 */ + @Transactional public boolean unsubscribe(String unsubscribeToken) { if (unsubscribeToken == null || unsubscribeToken.isBlank()) return false; Optional opt = reportLeadMapper.findFirstByUnsubscribeToken(unsubscribeToken); if (opt.isEmpty()) return false; ReportLead lead = opt.get(); - if ("unsubscribed".equals(lead.getRemindStatus())) return true; + if ("unsubscribed".equals(lead.getRemindStatus())) { + followUpTaskService.cancelForUnsubscribe(lead); + return true; + } lead.setRemindStatus("unsubscribed"); lead.setUpdateTime(LocalDateTime.now()); reportLeadMapper.save(lead); + followUpTaskService.cancelForUnsubscribe(lead); return true; } diff --git a/src/main/java/com/petstore/service/StoreCustomerService.java b/src/main/java/com/petstore/service/StoreCustomerService.java index 19ecb52..c8ff5d6 100644 --- a/src/main/java/com/petstore/service/StoreCustomerService.java +++ b/src/main/java/com/petstore/service/StoreCustomerService.java @@ -150,6 +150,27 @@ public class StoreCustomerService { return storeCustomerMapper.save(target); } + /** 同店内把活动主档或软删除合并别名解析为 canonical 主档;不存在/失效时返回 null。 */ + @Transactional(readOnly = true) + public StoreCustomer resolveCanonical(Long storeId, Long storeCustomerId) { + if (storeId == null || storeCustomerId == null) { + return null; + } + StoreCustomer requested = storeCustomerMapper.findFirstByIdAndStoreId(storeCustomerId, storeId).orElse(null); + if (requested == null) { + return null; + } + if (!Boolean.TRUE.equals(requested.getDeleted())) { + return requested; + } + if (requested.getMergedIntoStoreCustomerId() == null) { + return null; + } + return storeCustomerMapper.findFirstByIdAndStoreIdAndDeletedFalse( + requested.getMergedIntoStoreCustomerId(), storeId + ).orElse(null); + } + private static void mergeContactWindow(StoreCustomer target, StoreCustomer duplicate) { if (duplicate.getFirstContactAt() != null && (target.getFirstContactAt() == null || duplicate.getFirstContactAt().isBefore(target.getFirstContactAt()))) { diff --git a/src/main/java/com/petstore/service/StoreCustomerTimelineService.java b/src/main/java/com/petstore/service/StoreCustomerTimelineService.java index 5eba529..e5ef9fc 100644 --- a/src/main/java/com/petstore/service/StoreCustomerTimelineService.java +++ b/src/main/java/com/petstore/service/StoreCustomerTimelineService.java @@ -4,12 +4,14 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.petstore.entity.Appointment; import com.petstore.entity.BusinessEvent; +import com.petstore.entity.FollowUpTask; 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.BusinessEventMapper; +import com.petstore.mapper.FollowUpTaskMapper; import com.petstore.mapper.ReportLeadMapper; import com.petstore.mapper.ReportMapper; import com.petstore.mapper.StoreCustomerMapper; @@ -45,6 +47,7 @@ public class StoreCustomerTimelineService { private final AppointmentMapper appointmentMapper; private final ReportMapper reportMapper; private final ReportLeadMapper reportLeadMapper; + private final FollowUpTaskMapper followUpTaskMapper; private final UserMapper userMapper; private final ObjectMapper objectMapper; @@ -77,6 +80,7 @@ public class StoreCustomerTimelineService { Map appointments = loadAppointments(storeId, events); Map reports = loadReports(storeId, events); Map leads = loadLeads(storeId, events); + Map followUpTasks = loadFollowUpTasks(storeId, events); Map actors = loadActors(events); User customerUser = customer.getCustomerUserId() == null ? null : userMapper.findByIdAndDeletedFalse(customer.getCustomerUserId()).orElse(null); @@ -90,6 +94,8 @@ public class StoreCustomerTimelineService { ? reports.get(event.getAggregateId()) : null, "report_lead".equals(event.getAggregateType()) ? leads.get(event.getAggregateId()) : null, + "follow_up_task".equals(event.getAggregateType()) + ? followUpTasks.get(event.getAggregateId()) : null, event.getActorUserId() == null ? null : actors.get(event.getActorUserId()) )) .toList(); @@ -141,6 +147,14 @@ public class StoreCustomerTimelineService { .collect(Collectors.toMap(ReportLead::getId, Function.identity(), (a, b) -> a)); } + private Map loadFollowUpTasks(Long storeId, List events) { + Set ids = aggregateIds(events, "follow_up_task"); + if (ids.isEmpty()) return Map.of(); + return followUpTaskMapper.findAllById(ids).stream() + .filter(task -> Objects.equals(storeId, task.getStoreId())) + .collect(Collectors.toMap(FollowUpTask::getId, Function.identity(), (a, b) -> a)); + } + private Map loadActors(List events) { Set ids = events.stream() .map(BusinessEvent::getActorUserId) @@ -167,6 +181,7 @@ public class StoreCustomerTimelineService { Appointment appointment, Report report, ReportLead lead, + FollowUpTask followUpTask, User actor ) { Map metadata = safeMetadata(event.getMetadataJson()); @@ -188,6 +203,9 @@ public class StoreCustomerTimelineService { if (lead != null && "report_lead".equals(event.getAggregateType())) { item.put("lead", leadView(lead)); } + if (followUpTask != null && "follow_up_task".equals(event.getAggregateType())) { + item.put("followUpTask", followUpTaskView(followUpTask)); + } return item; } @@ -242,6 +260,17 @@ public class StoreCustomerTimelineService { return view; } + private static Map followUpTaskView(FollowUpTask task) { + Map view = new LinkedHashMap<>(); + view.put("taskId", task.getTaskId()); + view.put("status", task.getStatus()); + view.put("outcome", task.getOutcome()); + view.put("dueDate", task.getDueDate() == null ? null : task.getDueDate().toString()); + view.put("attemptCount", task.getAttemptCount()); + view.put("rebookedAppointmentId", task.getRebookedAppointmentId()); + return view; + } + private static String title(String eventType, Map metadata) { if (BusinessEventService.APPOINTMENT_CREATED.equals(eventType)) return "预约已创建"; if (BusinessEventService.APPOINTMENT_STATUS_CHANGED.equals(eventType)) { @@ -259,6 +288,12 @@ public class StoreCustomerTimelineService { if (BusinessEventService.REPORT_OPENED.equals(eventType)) return "宠主首次打开报告"; if (BusinessEventService.REPORT_REOPENED.equals(eventType)) return "宠主再次打开报告"; if (BusinessEventService.LEAD_SUBMITTED.equals(eventType)) return "宠主提交下次服务提醒"; + if (BusinessEventService.FOLLOW_UP_CREATED.equals(eventType)) return "已生成回访任务"; + if (BusinessEventService.FOLLOW_UP_STARTED.equals(eventType)) return "员工开始回访"; + if (BusinessEventService.FOLLOW_UP_RESCHEDULED.equals(eventType)) return "回访已改期"; + if (BusinessEventService.FOLLOW_UP_COMPLETED.equals(eventType)) return "回访已完成"; + if (BusinessEventService.FOLLOW_UP_CANCELED.equals(eventType)) return "回访已取消"; + if (BusinessEventService.REBOOK_CREATED.equals(eventType)) return "回访后已再次预约"; return "客户业务动态"; } @@ -291,6 +326,14 @@ public class StoreCustomerTimelineService { } else if (BusinessEventService.LEAD_SUBMITTED.equals(event.getEventType()) && Boolean.TRUE.equals(metadata.get("repeatSubmit"))) { add(parts, "重复提交已合并"); + } else if (BusinessEventService.FOLLOW_UP_CREATED.equals(event.getEventType())) { + addPrefixed(parts, "计划 ", textValue(metadata.get("dueDate"))); + } else if (BusinessEventService.FOLLOW_UP_RESCHEDULED.equals(event.getEventType())) { + add(parts, attemptOutcomeLabel(text(metadata.get("attemptOutcome")))); + addPrefixed(parts, "下次 ", textValue(metadata.get("dueDate"))); + } else if (BusinessEventService.FOLLOW_UP_COMPLETED.equals(event.getEventType()) + || BusinessEventService.FOLLOW_UP_CANCELED.equals(event.getEventType())) { + add(parts, followUpOutcomeLabel(text(metadata.get("outcome")))); } return parts.isEmpty() ? "已记录业务事实" : String.join(" · ", parts); } @@ -300,7 +343,10 @@ public class StoreCustomerTimelineService { try { Map parsed = objectMapper.readValue(json, METADATA_TYPE); Map safe = new LinkedHashMap<>(); - for (String key : List.of("fromStatus", "toStatus", "channel", "repeatSubmit")) { + for (String key : List.of( + "fromStatus", "toStatus", "channel", "repeatSubmit", + "attemptOutcome", "outcome", "dueDate", "attemptCount", "bookingOrigin" + )) { Object value = parsed.get(key); if (value instanceof String || value instanceof Boolean || value instanceof Number) { safe.put(key, value); @@ -327,6 +373,10 @@ public class StoreCustomerTimelineService { if (value != null && !value.isBlank()) parts.add(value); } + private static void addPrefixed(List parts, String prefix, String value) { + if (value != null && !value.isBlank()) parts.add(prefix + value); + } + private static String iso(LocalDateTime value) { return value == null ? null : value.toString(); } @@ -354,4 +404,26 @@ public class StoreCustomerTimelineService { default -> channel.isBlank() ? null : channel; }; } + + private static String attemptOutcomeLabel(String outcome) { + return switch (outcome) { + case FollowUpTask.ATTEMPT_NO_ANSWER -> "未接通"; + case FollowUpTask.ATTEMPT_FOLLOW_LATER -> "宠主希望稍后联系"; + default -> outcome.isBlank() ? null : outcome; + }; + } + + private static String followUpOutcomeLabel(String outcome) { + return switch (outcome) { + case FollowUpTask.OUTCOME_REBOOKED -> "已再次预约"; + case FollowUpTask.OUTCOME_NOT_INTERESTED -> "本轮暂无意向"; + case FollowUpTask.OUTCOME_INVALID_CONTACT -> "联系方式无效"; + case FollowUpTask.OUTCOME_UNSUBSCRIBED -> "宠主已退订"; + default -> outcome.isBlank() ? null : outcome; + }; + } + + private static String textValue(Object value) { + return value == null ? "" : value.toString().trim(); + } } diff --git a/src/test/java/com/petstore/controller/AdminFollowUpTaskControllerTest.java b/src/test/java/com/petstore/controller/AdminFollowUpTaskControllerTest.java new file mode 100644 index 0000000..c327928 --- /dev/null +++ b/src/test/java/com/petstore/controller/AdminFollowUpTaskControllerTest.java @@ -0,0 +1,64 @@ +package com.petstore.controller; + +import com.petstore.auth.CurrentUser; +import com.petstore.auth.CurrentUserContext; +import com.petstore.service.FollowUpTaskService; +import org.junit.jupiter.api.AfterEach; +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.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.mock; + +@ExtendWith(MockitoExtension.class) +class AdminFollowUpTaskControllerTest { + + @Mock private FollowUpTaskService followUpTaskService; + @InjectMocks private AdminFollowUpTaskController controller; + + @AfterEach + void clear() { + CurrentUserContext.clear(); + } + + @Test + void customerCannotReadOrMutateTasks() { + CurrentUserContext.set(new CurrentUser(99L, null, "customer")); + + Map list = controller.list("open", null, 1, 20); + Map start = controller.start("task-1"); + + assertEquals(403, list.get("code")); + assertEquals(403, start.get("code")); + verifyNoInteractions(followUpTaskService); + } + + @Test + void staffScopeAndActorAlwaysComeFromSession() { + CurrentUserContext.set(new CurrentUser(7L, 10L, "staff")); + FollowUpTaskService.TaskPage page = new FollowUpTaskService.TaskPage(0, 1, 20, false, List.of()); + when(followUpTaskService.list(10L, "open", null, 1, 20, 7L)).thenReturn(page); + when(followUpTaskService.reschedule( + 10L, "task-1", "2026-09-01", "no_answer", 7L, "staff" + )).thenReturn(mock(FollowUpTaskService.TaskView.class)); + + Map result = controller.list("open", null, 1, 20); + controller.reschedule("task-1", Map.of("dueDate", "2026-09-01", "reason", "no_answer")); + + assertEquals(200, result.get("code")); + verify(followUpTaskService).list(10L, "open", null, 1, 20, 7L); + verify(followUpTaskService).reschedule( + eq(10L), eq("task-1"), eq("2026-09-01"), eq("no_answer"), eq(7L), eq("staff") + ); + } +} diff --git a/src/test/java/com/petstore/controller/AppointmentControllerIdentityTest.java b/src/test/java/com/petstore/controller/AppointmentControllerIdentityTest.java index 35e7763..ec0c02f 100644 --- a/src/test/java/com/petstore/controller/AppointmentControllerIdentityTest.java +++ b/src/test/java/com/petstore/controller/AppointmentControllerIdentityTest.java @@ -88,6 +88,31 @@ class AppointmentControllerIdentityTest { assertEquals(10L, appointment.getStoreId()); } + @Test + void staffRebookForwardsTaskIdWithoutAcceptingStoreOrActorOverrides() { + CurrentUserContext.set(new CurrentUser(2L, 10L, "staff")); + User customer = new User(); + customer.setId(88L); + customer.setRole("customer"); + when(userService.resolveBookingCustomer(null, "13800138000", "小白妈妈")) + .thenReturn(customer); + when(appointmentService.createBooking(any(), eq("task-1"))).thenReturn(Map.of("code", 200)); + + Map body = validBody(); + body.put("customerPhone", "13800138000"); + body.put("customerName", "小白妈妈"); + body.put("followUpTaskId", "task-1"); + body.put("storeId", 999L); + + Map result = controller.create(body); + + assertEquals(200, result.get("code")); + ArgumentCaptor captor = ArgumentCaptor.forClass(Appointment.class); + verify(appointmentService).createBooking(captor.capture(), eq("task-1")); + assertEquals(10L, captor.getValue().getStoreId()); + assertEquals(2L, captor.getValue().getCreatedByUserId()); + } + private static Map validBody() { Map body = new HashMap<>(); body.put("petName", "小白"); diff --git a/src/test/java/com/petstore/mapper/FollowUpTaskMigrationTest.java b/src/test/java/com/petstore/mapper/FollowUpTaskMigrationTest.java new file mode 100644 index 0000000..b22a024 --- /dev/null +++ b/src/test/java/com/petstore/mapper/FollowUpTaskMigrationTest.java @@ -0,0 +1,118 @@ +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; +import static org.junit.jupiter.api.Assertions.assertFalse; + +class FollowUpTaskMigrationTest { + + @Test + void migrationBackfillsOnlyActionableLeadAndCreatesLowSensitivityFact() throws Exception { + try (Connection connection = DriverManager.getConnection( + "jdbc:h2:mem:follow-up-task-migration;MODE=MySQL;DATABASE_TO_LOWER=TRUE", + "sa", + "" + )) { + createPrerequisites(connection); + try (Reader reader = Files.newBufferedReader( + Path.of("db/migrations/20260802_create_follow_up_task.sql") + )) { + RunScript.execute(connection, reader); + } + + try (Statement statement = connection.createStatement(); + ResultSet task = statement.executeQuery(""" + SELECT status, due_date, store_customer_id, attempt_count + FROM t_follow_up_task + """)) { + task.next(); + assertEquals("pending", task.getString("status")); + assertEquals("2026-09-01", task.getDate("due_date").toString()); + assertEquals(50L, task.getLong("store_customer_id")); + assertEquals(0, task.getInt("attempt_count")); + assertFalse(task.next()); + } + try (Statement statement = connection.createStatement(); + ResultSet event = statement.executeQuery(""" + SELECT event_type, aggregate_type, metadata_json + FROM t_business_event + WHERE event_type = 'follow_up_created' + """)) { + event.next(); + assertEquals("follow_up_task", event.getString("aggregate_type")); + assertEquals("{\"dueDate\":\"2026-09-01\"}", event.getString("metadata_json")); + assertFalse(event.getString("metadata_json").contains("13800138001")); + } + } + } + + private static void createPrerequisites(Connection connection) throws Exception { + try (Statement statement = connection.createStatement()) { + statement.execute(""" + CREATE TABLE t_store_customer ( + id BIGINT PRIMARY KEY, + store_id BIGINT NOT NULL, + phone VARCHAR(20), + deleted TINYINT NOT NULL DEFAULT 0, + merged_into_store_customer_id BIGINT + ) + """); + statement.execute(""" + CREATE TABLE t_report_lead ( + id BIGINT PRIMARY KEY, + report_id BIGINT, + store_id BIGINT, + phone VARCHAR(20), + remind_date DATE, + remind_status VARCHAR(16), + create_time DATETIME, + update_time DATETIME + ) + """); + statement.execute(""" + CREATE TABLE t_business_event ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + event_id VARCHAR(36) NOT NULL, + event_type VARCHAR(64) NOT NULL, + event_version INT NOT NULL, + store_id BIGINT NOT NULL, + store_customer_id BIGINT, + aggregate_type VARCHAR(32) NOT NULL, + aggregate_id BIGINT NOT NULL, + actor_user_id BIGINT, + actor_role VARCHAR(16), + source VARCHAR(32) NOT NULL, + occurred_at DATETIME NOT NULL, + metadata_json TEXT NOT NULL, + idempotency_key VARCHAR(128), + create_time DATETIME NOT NULL + ) + """); + statement.execute(""" + CREATE TABLE t_appointment ( + id BIGINT PRIMARY KEY, + store_id BIGINT, + deleted TINYINT NOT NULL DEFAULT 0 + ) + """); + statement.execute("INSERT INTO t_store_customer VALUES (50, 10, '13800138001', 0, NULL)"); + statement.execute(""" + INSERT INTO t_report_lead VALUES + (70, 9, 10, '13800138001', '2026-09-01', 'pending', + '2026-08-01 10:00:00', '2026-08-01 10:00:00'), + (71, 9, 10, '13800138001', '2026-09-01', 'unsubscribed', + '2026-08-01 10:00:00', '2026-08-01 10:00:00') + """); + } + } +} diff --git a/src/test/java/com/petstore/service/AdminBusinessEventServiceTest.java b/src/test/java/com/petstore/service/AdminBusinessEventServiceTest.java index 38d52df..2e86a58 100644 --- a/src/test/java/com/petstore/service/AdminBusinessEventServiceTest.java +++ b/src/test/java/com/petstore/service/AdminBusinessEventServiceTest.java @@ -13,8 +13,6 @@ import java.util.Collection; import java.util.Map; 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.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; @@ -40,6 +38,9 @@ class AdminBusinessEventServiceTest { when(businessEventMapper.countDistinctAggregates( eq(10L), eq(BusinessEventService.LEAD_SUBMITTED), any(LocalDateTime.class), any(LocalDateTime.class) )).thenReturn(3L); + when(businessEventMapper.countDistinctAggregates( + eq(10L), eq(BusinessEventService.REBOOK_CREATED), any(LocalDateTime.class), any(LocalDateTime.class) + )).thenReturn(2L); Map data = service.reportFunnel(10L, date); @@ -48,8 +49,8 @@ class AdminBusinessEventServiceTest { assertEquals(6L, data.get("reportOpenedCount")); assertEquals(3L, data.get("leadSubmittedCount")); assertEquals(7L, data.get("reportSentCount")); - assertNull(data.get("rebookCreatedCount")); + assertEquals(2L, data.get("rebookCreatedCount")); assertEquals(true, data.get("reportSentTrackingReady")); - assertFalse((Boolean) data.get("rebookTrackingReady")); + assertEquals(true, data.get("rebookTrackingReady")); } } diff --git a/src/test/java/com/petstore/service/AdminWorkbenchServiceTest.java b/src/test/java/com/petstore/service/AdminWorkbenchServiceTest.java index ce50405..df6124a 100644 --- a/src/test/java/com/petstore/service/AdminWorkbenchServiceTest.java +++ b/src/test/java/com/petstore/service/AdminWorkbenchServiceTest.java @@ -2,9 +2,11 @@ package com.petstore.service; import com.fasterxml.jackson.databind.ObjectMapper; import com.petstore.entity.Appointment; +import com.petstore.entity.FollowUpTask; import com.petstore.entity.Report; import com.petstore.entity.ReportLead; import com.petstore.mapper.AppointmentMapper; +import com.petstore.mapper.FollowUpTaskMapper; import com.petstore.mapper.ReportLeadMapper; import com.petstore.mapper.ReportMapper; import org.junit.jupiter.api.Test; @@ -32,6 +34,7 @@ class AdminWorkbenchServiceTest { @Mock private AppointmentMapper appointmentMapper; @Mock private ReportMapper reportMapper; + @Mock private FollowUpTaskMapper followUpTaskMapper; @Mock private ReportLeadMapper reportLeadMapper; @Mock private AdminBusinessEventService adminBusinessEventService; @InjectMocks private AdminWorkbenchService service; @@ -75,9 +78,12 @@ class AdminWorkbenchServiceTest { ReportLead noDate = lead(30L, "无日期线索", null, "pending"); ReportLead due = lead(31L, "今日回访", LocalDate.of(2026, 8, 1), "pending"); - ReportLead future = lead(32L, "未来回访", LocalDate.of(2026, 8, 2), "pending"); - when(reportLeadMapper.findByStoreIdAndRemindStatusOrderByRemindDateAsc(10L, "pending")) - .thenReturn(List.of(noDate, due, future)); + FollowUpTask noDateTask = followUpTask(40L, 30L, LocalDate.of(2026, 8, 1)); + FollowUpTask dueTask = followUpTask(41L, 31L, LocalDate.of(2026, 8, 1)); + when(followUpTaskMapper.findByStoreIdAndStatusInAndDueDateLessThanEqualOrderByDueDateAscIdAsc( + eq(10L), any(), eq(LocalDate.of(2026, 8, 1)))) + .thenReturn(List.of(noDateTask, dueTask)); + when(reportLeadMapper.findAllById(any())).thenReturn(List.of(noDate, due)); when(adminBusinessEventService.reportFunnel(10L, LocalDate.of(2026, 8, 1))) .thenReturn(Map.of("reportSubmittedCount", 2L)); @@ -127,7 +133,8 @@ class AdminWorkbenchServiceTest { when(reportMapper.findByStoreIdAndDeletedFalseAndHighlightVideoStatusInOrderByUpdateTimeAsc( 10L, List.of("failed", "processing"))) .thenReturn(List.of()); - when(reportLeadMapper.findByStoreIdAndRemindStatusOrderByRemindDateAsc(10L, "pending")) + when(followUpTaskMapper.findByStoreIdAndStatusInAndDueDateLessThanEqualOrderByDueDateAscIdAsc( + eq(10L), any(), eq(LocalDate.of(2026, 8, 1)))) .thenReturn(List.of()); when(adminBusinessEventService.reportFunnel(10L, LocalDate.of(2026, 8, 1))).thenReturn(Map.of()); @@ -178,4 +185,13 @@ class AdminWorkbenchServiceTest { lead.setRemindStatus(status); return lead; } + + private static FollowUpTask followUpTask(Long id, Long leadId, LocalDate dueDate) { + FollowUpTask task = new FollowUpTask(); + task.setId(id); + task.setSourceLeadId(leadId); + task.setDueDate(dueDate); + task.setStatus(FollowUpTask.STATUS_PENDING); + return task; + } } diff --git a/src/test/java/com/petstore/service/AppointmentServiceTest.java b/src/test/java/com/petstore/service/AppointmentServiceTest.java index c3706cb..2077d70 100644 --- a/src/test/java/com/petstore/service/AppointmentServiceTest.java +++ b/src/test/java/com/petstore/service/AppointmentServiceTest.java @@ -1,8 +1,10 @@ package com.petstore.service; import com.petstore.entity.Appointment; +import com.petstore.entity.FollowUpTask; import com.petstore.entity.ServiceType; import com.petstore.entity.Store; +import com.petstore.entity.StoreCustomer; import com.petstore.mapper.AppointmentMapper; import com.petstore.mapper.PetMapper; import com.petstore.mapper.ReportMapper; @@ -17,6 +19,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; +import java.util.Map; import java.util.Optional; import static org.junit.jupiter.api.Assertions.*; @@ -39,6 +42,7 @@ class AppointmentServiceTest { @Mock private BusinessEventService businessEventService; @Mock private ServiceTypeService serviceTypeService; @Mock private BookingCapacityService bookingCapacityService; + @Mock private FollowUpTaskService followUpTaskService; @InjectMocks private AppointmentService appointmentService; @@ -156,6 +160,76 @@ class AppointmentServiceTest { verify(businessEventService).recordAppointmentCreated(eq(appointment), isNull(), isNull()); } + @Test + void followUpRebookCompletesOnlyAfterAppointmentIsSaved() { + LocalDateTime appointmentTime = LocalDate.now().plusDays(2).atTime(10, 0); + Appointment appointment = new Appointment(); + appointment.setStoreId(10L); + appointment.setCustomerUserId(88L); + appointment.setCreatedByUserId(7L); + appointment.setAppointmentTime(appointmentTime); + appointment.setPetName("豆豆"); + appointment.setServiceType("洗澡"); + Store store = new Store(); + store.setId(10L); + store.setBookingDayStart(LocalTime.of(9, 0)); + store.setBookingLastSlotStart(LocalTime.of(21, 30)); + ServiceType serviceType = new ServiceType(); + serviceType.setName("洗澡"); + serviceType.setDurationMinutes(60); + StoreCustomer customer = new StoreCustomer(); + customer.setId(50L); + FollowUpTask task = new FollowUpTask(); + task.setTaskId("task-1"); + + when(storeService.findByIdForUpdate(10L)).thenReturn(store); + when(serviceTypeService.resolveForStore(10L, "洗澡")).thenReturn(serviceType); + when(bookingCapacityService.evaluateAppointment(any(), eq(60), eq(1), anyList(), anyList())) + .thenReturn(new BookingCapacityService.CapacityResult(true, null, 0)); + when(storeCustomerService.touchBooking(eq(10L), eq(88L), eq(7L), any())).thenReturn(customer); + when(followUpTaskService.prepareRebook(10L, "task-1", 50L, 7L)).thenReturn(task); + when(appointmentMapper.save(any(Appointment.class))).thenAnswer(inv -> { + Appointment saved = inv.getArgument(0); + saved.setId(100L); + return saved; + }); + + Map result = appointmentService.createBooking(appointment, "task-1"); + + assertEquals(200, result.get("code")); + verify(followUpTaskService).markRebooked(task, appointment, 7L, null); + } + + @Test + void capacityFailureLeavesFollowUpTaskUntouched() { + Appointment appointment = new Appointment(); + appointment.setStoreId(10L); + appointment.setCustomerUserId(88L); + appointment.setCreatedByUserId(7L); + appointment.setAppointmentTime(LocalDate.now().plusDays(2).atTime(10, 0)); + appointment.setPetName("豆豆"); + appointment.setServiceType("洗澡"); + Store store = new Store(); + store.setId(10L); + store.setBookingDayStart(LocalTime.of(9, 0)); + store.setBookingLastSlotStart(LocalTime.of(21, 30)); + ServiceType serviceType = new ServiceType(); + serviceType.setName("洗澡"); + serviceType.setDurationMinutes(60); + + when(storeService.findByIdForUpdate(10L)).thenReturn(store); + when(serviceTypeService.resolveForStore(10L, "洗澡")).thenReturn(serviceType); + when(bookingCapacityService.evaluateAppointment(any(), eq(60), eq(1), anyList(), anyList())) + .thenReturn(new BookingCapacityService.CapacityResult(false, "该时段容量已满", 1)); + + Map result = appointmentService.createBooking(appointment, "task-1"); + + assertEquals(409, result.get("code")); + assertEquals("CAPACITY_FULL", result.get("bizCode")); + verifyNoInteractions(followUpTaskService); + verify(appointmentMapper, never()).save(any()); + } + private Appointment newAppointment(Long id, String status) { Appointment a = new Appointment(); a.setId(id); diff --git a/src/test/java/com/petstore/service/BusinessEventServiceTest.java b/src/test/java/com/petstore/service/BusinessEventServiceTest.java index 59f422e..f1c880c 100644 --- a/src/test/java/com/petstore/service/BusinessEventServiceTest.java +++ b/src/test/java/com/petstore/service/BusinessEventServiceTest.java @@ -3,6 +3,7 @@ package com.petstore.service; import com.fasterxml.jackson.databind.ObjectMapper; import com.petstore.entity.Appointment; import com.petstore.entity.BusinessEvent; +import com.petstore.entity.FollowUpTask; import com.petstore.entity.Report; import com.petstore.entity.StoreCustomer; import com.petstore.mapper.BusinessEventMapper; @@ -164,4 +165,28 @@ class BusinessEventServiceTest { assertFalse(event.getMetadataJson().contains("token")); assertEquals(7L, event.getActorUserId()); } + + @Test + void rebookFactUsesRealAppointmentAndContainsNoContactData() { + FollowUpTask task = new FollowUpTask(); + task.setId(50L); + task.setStoreId(10L); + task.setStoreCustomerId(30L); + Appointment appointment = new Appointment(); + appointment.setId(100L); + appointment.setStoreId(10L); + appointment.setCreateTime(LocalDateTime.of(2026, 8, 2, 10, 0)); + when(businessEventMapper.findFirstByIdempotencyKey("rebook_created:100")) + .thenReturn(Optional.empty()); + when(businessEventMapper.save(any(BusinessEvent.class))).thenAnswer(inv -> inv.getArgument(0)); + + BusinessEvent event = service.recordRebookCreated(task, appointment, 7L, "staff"); + + assertEquals(BusinessEventService.REBOOK_CREATED, event.getEventType()); + assertEquals("appointment", event.getAggregateType()); + assertEquals(100L, event.getAggregateId()); + assertEquals(30L, event.getStoreCustomerId()); + assertEquals("{\"bookingOrigin\":\"follow_up\"}", event.getMetadataJson()); + assertFalse(event.getMetadataJson().contains("phone")); + } } diff --git a/src/test/java/com/petstore/service/FollowUpTaskServiceTest.java b/src/test/java/com/petstore/service/FollowUpTaskServiceTest.java new file mode 100644 index 0000000..6164e30 --- /dev/null +++ b/src/test/java/com/petstore/service/FollowUpTaskServiceTest.java @@ -0,0 +1,266 @@ +package com.petstore.service; + +import com.petstore.entity.Appointment; +import com.petstore.entity.FollowUpTask; +import com.petstore.entity.ReportLead; +import com.petstore.entity.StoreCustomer; +import com.petstore.mapper.FollowUpTaskMapper; +import com.petstore.mapper.ReportLeadMapper; +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.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class FollowUpTaskServiceTest { + + @Mock private FollowUpTaskMapper followUpTaskMapper; + @Mock private ReportLeadMapper reportLeadMapper; + @Mock private StoreCustomerMapper storeCustomerMapper; + @Mock private UserMapper userMapper; + @Mock private StoreCustomerService storeCustomerService; + @Mock private BusinessEventService businessEventService; + @Mock private AuditLogService auditLogService; + @InjectMocks private FollowUpTaskService service; + + @Test + void leadCreatesOnePendingTaskAndBusinessFact() { + ReportLead lead = lead(); + StoreCustomer customer = customer(); + when(reportLeadMapper.findByIdForUpdate(70L)).thenReturn(Optional.of(lead)); + when(followUpTaskMapper.findOpenBySourceLeadIdForUpdate(eq(70L), any())).thenReturn(List.of()); + when(followUpTaskMapper.save(any(FollowUpTask.class))).thenAnswer(inv -> { + FollowUpTask task = inv.getArgument(0); + task.setId(90L); + return task; + }); + + FollowUpTask task = service.ensureForLead(lead, customer); + + assertEquals(FollowUpTask.STATUS_PENDING, task.getStatus()); + assertEquals(LocalDate.of(2026, 9, 1), task.getDueDate()); + assertEquals(50L, task.getStoreCustomerId()); + assertEquals(0, task.getAttemptCount()); + verify(businessEventService).recordFollowUpCreated(task); + } + + @Test + void sameOpenLeadDoesNotCreateDuplicateTask() { + ReportLead lead = lead(); + FollowUpTask existing = task(FollowUpTask.STATUS_PENDING, null); + existing.setDueDate(lead.getRemindDate()); + when(reportLeadMapper.findByIdForUpdate(70L)).thenReturn(Optional.of(lead)); + when(followUpTaskMapper.findOpenBySourceLeadIdForUpdate(eq(70L), any())) + .thenReturn(List.of(existing)); + + FollowUpTask result = service.ensureForLead(lead, customer()); + + assertEquals(existing, result); + assertNull(result.getOutcome()); + } + + @Test + void claimThenNoAnswerReturnsTaskToPendingAndIncrementsAttempt() { + FollowUpTask task = task(FollowUpTask.STATUS_PENDING, null); + stubTaskView(task); + when(followUpTaskMapper.findByTaskIdAndStoreIdForUpdate("task-1", 10L)) + .thenReturn(Optional.of(task)); + when(followUpTaskMapper.save(any(FollowUpTask.class))).thenAnswer(inv -> inv.getArgument(0)); + + service.start(10L, "task-1", 7L, "staff"); + FollowUpTaskService.TaskView result = service.reschedule( + 10L, "task-1", LocalDate.now().plusDays(2).toString(), "no_answer", 7L, "staff" + ); + + assertEquals(FollowUpTask.STATUS_PENDING, result.status()); + assertEquals(1, result.attemptCount()); + assertEquals(FollowUpTask.ATTEMPT_NO_ANSWER, result.lastAttemptOutcome()); + assertNull(task.getAssignedUserId()); + verify(businessEventService).recordFollowUpStarted(task, 7L, "staff"); + verify(businessEventService).recordFollowUpRescheduled(task, 7L, "staff"); + } + + @Test + void taskClaimedByOtherStaffCannotBeMutated() { + FollowUpTask task = task(FollowUpTask.STATUS_IN_PROGRESS, null); + task.setAssignedUserId(8L); + when(followUpTaskMapper.findByTaskIdAndStoreIdForUpdate("task-1", 10L)) + .thenReturn(Optional.of(task)); + + FlowException error = assertThrows( + FlowException.class, + () -> service.close(10L, "task-1", "not_interested", 7L, "staff") + ); + + assertEquals("FOLLOW_UP_ALREADY_CLAIMED", error.bizCode()); + } + + @Test + void taskFromAnotherStoreIsIndistinguishableFromMissingTask() { + when(followUpTaskMapper.findByTaskIdAndStoreIdForUpdate("task-1", 11L)) + .thenReturn(Optional.empty()); + + FlowException error = assertThrows( + FlowException.class, + () -> service.start(11L, "task-1", 7L, "staff") + ); + + assertEquals(404, error.code()); + assertEquals("FOLLOW_UP_TASK_NOT_FOUND", error.bizCode()); + } + + @Test + void pendingTaskCannotBeClosedWithoutBeingClaimed() { + FollowUpTask task = task(FollowUpTask.STATUS_PENDING, null); + when(followUpTaskMapper.findByTaskIdAndStoreIdForUpdate("task-1", 10L)) + .thenReturn(Optional.of(task)); + + FlowException error = assertThrows( + FlowException.class, + () -> service.close(10L, "task-1", "not_interested", 7L, "staff") + ); + + assertEquals("FOLLOW_UP_STATE_CONFLICT", error.bizCode()); + } + + @Test + void onlyRealAppointmentCanCompleteRebookOutcome() { + FollowUpTask task = task(FollowUpTask.STATUS_IN_PROGRESS, null); + task.setAssignedUserId(7L); + StoreCustomer canonical = customer(); + when(followUpTaskMapper.findByTaskIdAndStoreIdForUpdate("task-1", 10L)) + .thenReturn(Optional.of(task)); + when(storeCustomerService.resolveCanonical(10L, 50L)).thenReturn(canonical); + when(followUpTaskMapper.save(any(FollowUpTask.class))).thenAnswer(inv -> inv.getArgument(0)); + when(reportLeadMapper.findByIdForUpdate(70L)).thenReturn(Optional.of(lead())); + when(reportLeadMapper.save(any(ReportLead.class))).thenAnswer(inv -> inv.getArgument(0)); + Appointment appointment = new Appointment(); + appointment.setId(100L); + appointment.setStoreId(10L); + appointment.setCreateTime(LocalDateTime.now()); + + FollowUpTask prepared = service.prepareRebook(10L, "task-1", 50L, 7L); + service.markRebooked(prepared, appointment, 7L, "staff"); + + assertEquals(FollowUpTask.STATUS_COMPLETED, task.getStatus()); + assertEquals(FollowUpTask.OUTCOME_REBOOKED, task.getOutcome()); + assertEquals(100L, task.getRebookedAppointmentId()); + assertTrue(task.getAttemptCount() > 0); + verify(businessEventService).recordRebookCreated(task, appointment, 7L, "staff"); + } + + @Test + void crossStoreAppointmentCannotCompleteRebookOutcome() { + FollowUpTask task = task(FollowUpTask.STATUS_IN_PROGRESS, null); + task.setAssignedUserId(7L); + Appointment appointment = new Appointment(); + appointment.setId(100L); + appointment.setStoreId(11L); + + FlowException error = assertThrows( + FlowException.class, + () -> service.markRebooked(task, appointment, 7L, "staff") + ); + + assertEquals("FOLLOW_UP_REBOOK_INVALID", error.bizCode()); + assertEquals(FollowUpTask.STATUS_IN_PROGRESS, task.getStatus()); + assertNull(task.getOutcome()); + } + + @Test + void terminalTaskResponseKeepsOnlyMaskedPhone() { + FollowUpTask task = task(FollowUpTask.STATUS_IN_PROGRESS, null); + task.setAssignedUserId(7L); + stubTaskView(task); + when(followUpTaskMapper.findByTaskIdAndStoreIdForUpdate("task-1", 10L)) + .thenReturn(Optional.of(task)); + when(followUpTaskMapper.save(any(FollowUpTask.class))).thenAnswer(inv -> inv.getArgument(0)); + + FollowUpTaskService.TaskView result = service.close( + 10L, "task-1", "not_interested", 7L, "staff" + ); + + assertNull(result.phone()); + assertEquals("138****8001", result.phoneMasked()); + } + + @Test + void unsubscribeCancelsOpenTaskWithoutInventingContactAttempt() { + FollowUpTask task = task(FollowUpTask.STATUS_PENDING, null); + when(followUpTaskMapper.findOpenBySourceLeadIdForUpdate(eq(70L), any())) + .thenReturn(List.of(task)); + when(followUpTaskMapper.save(any(FollowUpTask.class))).thenAnswer(inv -> inv.getArgument(0)); + + service.cancelForUnsubscribe(lead()); + + assertEquals(FollowUpTask.STATUS_CANCELED, task.getStatus()); + assertEquals(FollowUpTask.OUTCOME_UNSUBSCRIBED, task.getOutcome()); + assertEquals(0, task.getAttemptCount()); + verify(businessEventService).recordFollowUpCanceled(task); + } + + private void stubTaskView(FollowUpTask task) { + when(reportLeadMapper.findAllById(any())).thenReturn(List.of(lead())); + when(storeCustomerMapper.findAllById(any())).thenReturn(List.of(customer())); + when(userMapper.findAllById(any())).thenReturn(List.of()); + when(reportLeadMapper.findByIdForUpdate(70L)).thenReturn(Optional.of(lead())); + when(reportLeadMapper.save(any(ReportLead.class))).thenAnswer(inv -> inv.getArgument(0)); + } + + private static ReportLead lead() { + ReportLead lead = new ReportLead(); + lead.setId(70L); + lead.setReportId(9L); + lead.setStoreId(10L); + lead.setPhone("13800138001"); + lead.setPetName("豆豆"); + lead.setServiceType("洗护"); + lead.setRemindDate(LocalDate.of(2026, 9, 1)); + lead.setRemindStatus("pending"); + return lead; + } + + private static StoreCustomer customer() { + StoreCustomer customer = new StoreCustomer(); + customer.setId(50L); + customer.setStoreId(10L); + customer.setPhone("13800138001"); + customer.setDisplayName("豆豆家长"); + customer.setDeleted(false); + return customer; + } + + private static FollowUpTask task(String status, String outcome) { + FollowUpTask task = new FollowUpTask(); + task.setId(90L); + task.setTaskId("task-1"); + task.setStoreId(10L); + task.setStoreCustomerId(50L); + task.setSourceLeadId(70L); + task.setSourceReportId(9L); + task.setStatus(status); + task.setOutcome(outcome); + task.setDueDate(LocalDate.now()); + task.setAttemptCount(0); + task.setCreateTime(LocalDateTime.now()); + task.setUpdateTime(LocalDateTime.now()); + return task; + } +} diff --git a/src/test/java/com/petstore/service/ReportLeadServiceStoreCustomerTest.java b/src/test/java/com/petstore/service/ReportLeadServiceStoreCustomerTest.java index 35bdde1..aa74415 100644 --- a/src/test/java/com/petstore/service/ReportLeadServiceStoreCustomerTest.java +++ b/src/test/java/com/petstore/service/ReportLeadServiceStoreCustomerTest.java @@ -2,6 +2,7 @@ package com.petstore.service; import com.petstore.entity.Report; import com.petstore.entity.ReportLead; +import com.petstore.entity.StoreCustomer; import com.petstore.mapper.ReportLeadMapper; import com.petstore.mapper.ReportMapper; import com.petstore.mapper.ServiceIntervalMapper; @@ -32,6 +33,7 @@ class ReportLeadServiceStoreCustomerTest { @Mock private WechatMiniProgramService wechatMiniProgramService; @Mock private StoreCustomerService storeCustomerService; @Mock private BusinessEventService businessEventService; + @Mock private FollowUpTaskService followUpTaskService; @InjectMocks private ReportLeadService reportLeadService; @Test @@ -52,6 +54,10 @@ class ReportLeadServiceStoreCustomerTest { return lead; }); when(userMapper.findByPhoneAndDeletedFalse("13800138001")).thenReturn(null); + StoreCustomer storeCustomer = new StoreCustomer(); + storeCustomer.setId(50L); + when(storeCustomerService.touchLead(eq(10L), eq("13800138001"), eq(null), any(LocalDateTime.class))) + .thenReturn(storeCustomer); ReportLeadService.LeadSubmitOutcome outcome = reportLeadService.submit( "token", "13800138001", "127.0.0.1", "" @@ -59,6 +65,7 @@ class ReportLeadServiceStoreCustomerTest { assertFalse(outcome.repeatSubmit()); verify(storeCustomerService).touchLead(eq(10L), eq("13800138001"), eq(null), any(LocalDateTime.class)); - verify(businessEventService).recordLeadSubmitted(any(ReportLead.class), eq(null), eq(false)); + verify(businessEventService).recordLeadSubmitted(any(ReportLead.class), eq(storeCustomer), eq(false)); + verify(followUpTaskService).ensureForLead(any(ReportLead.class), eq(storeCustomer)); } } diff --git a/src/test/java/com/petstore/service/StoreCustomerTimelineServiceTest.java b/src/test/java/com/petstore/service/StoreCustomerTimelineServiceTest.java index 482e8b1..4c53edc 100644 --- a/src/test/java/com/petstore/service/StoreCustomerTimelineServiceTest.java +++ b/src/test/java/com/petstore/service/StoreCustomerTimelineServiceTest.java @@ -3,12 +3,14 @@ package com.petstore.service; import com.fasterxml.jackson.databind.ObjectMapper; import com.petstore.entity.Appointment; import com.petstore.entity.BusinessEvent; +import com.petstore.entity.FollowUpTask; 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.BusinessEventMapper; +import com.petstore.mapper.FollowUpTaskMapper; import com.petstore.mapper.ReportLeadMapper; import com.petstore.mapper.ReportMapper; import com.petstore.mapper.StoreCustomerMapper; @@ -46,6 +48,7 @@ class StoreCustomerTimelineServiceTest { @Mock private AppointmentMapper appointmentMapper; @Mock private ReportMapper reportMapper; @Mock private ReportLeadMapper reportLeadMapper; + @Mock private FollowUpTaskMapper followUpTaskMapper; @Mock private UserMapper userMapper; private StoreCustomerTimelineService service; @@ -58,6 +61,7 @@ class StoreCustomerTimelineServiceTest { appointmentMapper, reportMapper, reportLeadMapper, + followUpTaskMapper, userMapper, new ObjectMapper() ); @@ -201,6 +205,40 @@ class StoreCustomerTimelineServiceTest { ); } + @SuppressWarnings("unchecked") + @Test + void followUpFactExposesOnlyOperationalSummary() { + StoreCustomer customer = customer(); + BusinessEvent completed = event( + 5L, "event-follow-up", BusinessEventService.FOLLOW_UP_COMPLETED, + "follow_up_task", 400L, "{\"outcome\":\"not_interested\",\"phone\":\"13800138001\"}" + ); + FollowUpTask task = new FollowUpTask(); + task.setId(400L); + task.setTaskId("public-task-id"); + task.setStoreId(10L); + task.setStatus(FollowUpTask.STATUS_COMPLETED); + task.setOutcome(FollowUpTask.OUTCOME_NOT_INTERESTED); + task.setDueDate(LocalDate.of(2026, 8, 2)); + task.setAttemptCount(2); + + when(storeCustomerMapper.findFirstByIdAndStoreId(50L, 10L)).thenReturn(Optional.of(customer)); + when(storeCustomerMapper.findByStoreIdAndMergedIntoStoreCustomerId(10L, 50L)).thenReturn(List.of()); + when(businessEventMapper.findByStoreIdAndStoreCustomerIdIn(any(), any(), any(Pageable.class))) + .thenAnswer(invocation -> new PageImpl<>(List.of(completed), invocation.getArgument(2), 1)); + when(followUpTaskMapper.findAllById(any())).thenReturn(List.of(task)); + when(userMapper.findByIdAndDeletedFalse(88L)).thenReturn(Optional.empty()); + + Map result = service.timeline(10L, 50L, 1, 20); + + List> items = (List>) result.get("list"); + assertEquals("回访已完成", items.get(0).get("title")); + assertEquals("本轮暂无意向", items.get(0).get("description")); + Map taskView = (Map) items.get(0).get("followUpTask"); + assertEquals(2, taskView.get("attemptCount")); + assertFalse(result.toString().contains("13800138001")); + } + private static StoreCustomer customer() { StoreCustomer customer = new StoreCustomer(); customer.setId(50L);