feat: add follow-up task lifecycle and rebook attribution
This commit is contained in:
parent
07bad405cd
commit
c0771b34ff
13
README.md
13
README.md
@ -119,6 +119,12 @@ curl http://localhost:8080/api/store/list
|
||||
```
|
||||
脚本为被合并的软删除客户投影增加同店 canonical 指针。既有 `BusinessEvent` 保持不可变,时间线通过主档与别名共同读取历史事实。末尾两项别名状态/目标验证必须全部为 0。
|
||||
|
||||
11. **可执行回访任务与真实再次预约归因**:客户时间线迁移验证完成后执行:
|
||||
```bash
|
||||
mysql -h <host> -u <user> -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://<api-domain> 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://<api-domain> 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、凭证和原始请求体。
|
||||
|
||||
164
db/migrations/20260802_create_follow_up_task.sql
Normal file
164
db/migrations/20260802_create_follow_up_task.sql
Normal file
@ -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;
|
||||
@ -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 后续任务;接入前不得把本目录误当成自动执行目录。
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<String, Object> 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<String, Object> 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<String, Object> reschedule(
|
||||
@PathVariable String taskId,
|
||||
@RequestBody Map<String, Object> 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<String, Object> close(
|
||||
@PathVariable String taskId,
|
||||
@RequestBody Map<String, Object> 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<String, Object> ok(Object data) {
|
||||
return Map.of("code", 200, "data", data);
|
||||
}
|
||||
|
||||
private static Map<String, Object> error(FlowException e) {
|
||||
return Map.of("code", e.code(), "message", e.getMessage(), "bizCode", e.bizCode());
|
||||
}
|
||||
|
||||
private static Map<String, Object> forbidden() {
|
||||
return Map.of("code", 403, "message", "仅门店员工可处理回访任务", "bizCode", "FORBIDDEN");
|
||||
}
|
||||
|
||||
private static String text(Object value) {
|
||||
return value == null ? null : value.toString().trim();
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
114
src/main/java/com/petstore/entity/FollowUpTask.java
Normal file
114
src/main/java/com/petstore/entity/FollowUpTask.java
Normal file
@ -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;
|
||||
}
|
||||
38
src/main/java/com/petstore/mapper/FollowUpTaskMapper.java
Normal file
38
src/main/java/com/petstore/mapper/FollowUpTaskMapper.java
Normal file
@ -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<FollowUpTask, Long>, JpaSpecificationExecutor<FollowUpTask> {
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select t from FollowUpTask t where t.taskId = :taskId and t.storeId = :storeId")
|
||||
Optional<FollowUpTask> 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<FollowUpTask> findOpenBySourceLeadIdForUpdate(
|
||||
@Param("sourceLeadId") Long sourceLeadId,
|
||||
@Param("statuses") Collection<String> statuses
|
||||
);
|
||||
|
||||
List<FollowUpTask> findByStoreIdAndStatusInAndDueDateLessThanEqualOrderByDueDateAscIdAsc(
|
||||
Long storeId,
|
||||
Collection<String> statuses,
|
||||
LocalDate dueDate
|
||||
);
|
||||
}
|
||||
@ -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<ReportLead, Long> {
|
||||
|
||||
Optional<ReportLead> findFirstByUnsubscribeToken(String unsubscribeToken);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select l from ReportLead l where l.id = :id")
|
||||
Optional<ReportLead> findByIdForUpdate(@Param("id") Long id);
|
||||
|
||||
/** 老板回访池:按状态倒查、按提醒日期升序 */
|
||||
List<ReportLead> findByStoreIdAndRemindStatusOrderByRemindDateAsc(Long storeId, String remindStatus);
|
||||
|
||||
|
||||
@ -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<String, Object> 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;
|
||||
}
|
||||
|
||||
|
||||
@ -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<ReportLead> dueFollowUps = safeLeads(
|
||||
reportLeadMapper.findByStoreIdAndRemindStatusOrderByRemindDateAsc(storeId, "pending")
|
||||
List<FollowUpTask> dueFollowUps = followUpTaskMapper
|
||||
.findByStoreIdAndStatusInAndDueDateLessThanEqualOrderByDueDateAscIdAsc(
|
||||
storeId,
|
||||
Set.of(FollowUpTask.STATUS_PENDING, FollowUpTask.STATUS_IN_PROGRESS),
|
||||
day
|
||||
);
|
||||
if (dueFollowUps == null) dueFollowUps = List.of();
|
||||
Map<Long, ReportLead> dueLeads = reportLeadMapper.findAllById(
|
||||
dueFollowUps.stream()
|
||||
.map(FollowUpTask::getSourceLeadId)
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.collect(Collectors.toSet())
|
||||
).stream()
|
||||
.filter(lead -> lead.getRemindDate() == null || !lead.getRemindDate().isAfter(day))
|
||||
.toList();
|
||||
.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<String, Object> 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<ReportLead> safeLeads(List<ReportLead> 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<ActionGroup> groups, List<ReportLead> leads) {
|
||||
if (leads.isEmpty()) return;
|
||||
List<ActionItem> items = leads.stream()
|
||||
private static void addFollowUpGroup(
|
||||
List<ActionGroup> groups,
|
||||
List<FollowUpTask> tasks,
|
||||
Map<Long, ReportLead> leads
|
||||
) {
|
||||
if (tasks.isEmpty()) return;
|
||||
List<ActionItem> 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(
|
||||
|
||||
@ -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<Appointment> getByUserId(Long customerUserId) {
|
||||
@ -163,6 +164,11 @@ public class AppointmentService {
|
||||
*/
|
||||
@Transactional
|
||||
public Map<String, Object> createBooking(Appointment appointment) {
|
||||
return createBooking(appointment, null);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> 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<String, Object> ok = new HashMap<>();
|
||||
ok.put("code", 200);
|
||||
ok.put("message", "创建成功");
|
||||
|
||||
@ -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<String> 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<String, Object> metadata) {
|
||||
Map<String, Object> safe = new LinkedHashMap<>();
|
||||
if (metadata != null) {
|
||||
|
||||
535
src/main/java/com/petstore/service/FollowUpTaskService.java
Normal file
535
src/main/java/com/petstore/service/FollowUpTaskService.java
Normal file
@ -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<String> OPEN_STATUSES = Set.of(
|
||||
FollowUpTask.STATUS_PENDING,
|
||||
FollowUpTask.STATUS_IN_PROGRESS
|
||||
);
|
||||
private static final Set<String> RESCHEDULE_REASONS = Set.of(
|
||||
FollowUpTask.ATTEMPT_NO_ANSWER,
|
||||
FollowUpTask.ATTEMPT_FOLLOW_LATER
|
||||
);
|
||||
private static final Set<String> 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<FollowUpTask> 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<FollowUpTask> spec = (root, query, cb) -> {
|
||||
List<Predicate> 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<FollowUpTask> result = followUpTaskMapper.findAll(spec, pageable);
|
||||
List<TaskView> 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<FollowUpTask> 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<TaskView> views(List<FollowUpTask> tasks, Long currentUserId) {
|
||||
if (tasks.isEmpty()) return List.of();
|
||||
Map<Long, ReportLead> leads = reportLeadMapper.findAllById(ids(tasks, FollowUpTask::getSourceLeadId)).stream()
|
||||
.collect(Collectors.toMap(ReportLead::getId, Function.identity(), (a, b) -> a));
|
||||
Map<Long, StoreCustomer> customers = loadCustomers(tasks);
|
||||
Map<Long, User> 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<Long, StoreCustomer> loadCustomers(List<FollowUpTask> tasks) {
|
||||
Map<Long, StoreCustomer> customers = storeCustomerMapper.findAllById(
|
||||
ids(tasks, FollowUpTask::getStoreCustomerId)
|
||||
).stream()
|
||||
.collect(Collectors.toMap(StoreCustomer::getId, Function.identity(), (a, b) -> a));
|
||||
Set<Long> 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<Long, StoreCustomer> 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<String, Object> 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 <T> Set<Long> ids(Collection<T> rows, Function<T, Long> 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<TaskView> 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
|
||||
) {}
|
||||
}
|
||||
@ -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<ReportLead> 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;
|
||||
}
|
||||
|
||||
|
||||
@ -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()))) {
|
||||
|
||||
@ -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<Long, Appointment> appointments = loadAppointments(storeId, events);
|
||||
Map<Long, Report> reports = loadReports(storeId, events);
|
||||
Map<Long, ReportLead> leads = loadLeads(storeId, events);
|
||||
Map<Long, FollowUpTask> followUpTasks = loadFollowUpTasks(storeId, events);
|
||||
Map<Long, User> 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<Long, FollowUpTask> loadFollowUpTasks(Long storeId, List<BusinessEvent> events) {
|
||||
Set<Long> 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<Long, User> loadActors(List<BusinessEvent> events) {
|
||||
Set<Long> ids = events.stream()
|
||||
.map(BusinessEvent::getActorUserId)
|
||||
@ -167,6 +181,7 @@ public class StoreCustomerTimelineService {
|
||||
Appointment appointment,
|
||||
Report report,
|
||||
ReportLead lead,
|
||||
FollowUpTask followUpTask,
|
||||
User actor
|
||||
) {
|
||||
Map<String, Object> 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<String, Object> followUpTaskView(FollowUpTask task) {
|
||||
Map<String, Object> 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<String, Object> 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<String, Object> parsed = objectMapper.readValue(json, METADATA_TYPE);
|
||||
Map<String, Object> 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<String> 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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<String, Object> list = controller.list("open", null, 1, 20);
|
||||
Map<String, Object> 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<String, Object> 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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<String, Object> body = validBody();
|
||||
body.put("customerPhone", "13800138000");
|
||||
body.put("customerName", "小白妈妈");
|
||||
body.put("followUpTaskId", "task-1");
|
||||
body.put("storeId", 999L);
|
||||
|
||||
Map<String, Object> result = controller.create(body);
|
||||
|
||||
assertEquals(200, result.get("code"));
|
||||
ArgumentCaptor<Appointment> 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<String, Object> validBody() {
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("petName", "小白");
|
||||
|
||||
118
src/test/java/com/petstore/mapper/FollowUpTaskMigrationTest.java
Normal file
118
src/test/java/com/petstore/mapper/FollowUpTaskMigrationTest.java
Normal file
@ -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')
|
||||
""");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<String, Object> 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"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<String, Object> 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<String, Object> 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);
|
||||
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
266
src/test/java/com/petstore/service/FollowUpTaskServiceTest.java
Normal file
266
src/test/java/com/petstore/service/FollowUpTaskServiceTest.java
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<String, Object> result = service.timeline(10L, 50L, 1, 20);
|
||||
|
||||
List<Map<String, Object>> items = (List<Map<String, Object>>) result.get("list");
|
||||
assertEquals("回访已完成", items.get(0).get("title"));
|
||||
assertEquals("本轮暂无意向", items.get(0).get("description"));
|
||||
Map<String, Object> taskView = (Map<String, Object>) 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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user