feat: track explicit report delivery
This commit is contained in:
parent
0ad8a4d596
commit
1887864d38
11
README.md
11
README.md
@ -101,6 +101,12 @@ curl http://localhost:8080/api/store/list
|
|||||||
```
|
```
|
||||||
脚本新增门店并发容量、服务时长、预约时长快照和连续排班占用。末尾容量、时长和快照验证计数必须全部为 0。
|
脚本新增门店并发容量、服务时长、预约时长快照和连续排班占用。末尾容量、时长和快照验证计数必须全部为 0。
|
||||||
|
|
||||||
|
8. **报告显式确认发送状态**:预约容量验证完成后执行:
|
||||||
|
```bash
|
||||||
|
mysql -h <host> -u <user> -p petstore < db/migrations/20260801_create_report_send_status.sql
|
||||||
|
```
|
||||||
|
脚本把历史报告统一标记为 `unknown`,迁移后的新报告默认 `unsent`;不得推测历史发送事实。末尾三项状态与回执一致性验证必须全部为 0。
|
||||||
|
|
||||||
### production profile
|
### production profile
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@ -116,14 +122,14 @@ production profile 下:
|
|||||||
|
|
||||||
### 生产只读预检
|
### 生产只读预检
|
||||||
|
|
||||||
完成备份和四个迁移后,以最终生产环境变量运行:
|
完成备份和五个迁移后,以最终生产环境变量运行:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
chmod +x deploy/release-preflight.sh deploy/production-smoke.sh
|
chmod +x deploy/release-preflight.sh deploy/production-smoke.sh
|
||||||
BUILD_FIRST=1 deploy/release-preflight.sh
|
BUILD_FIRST=1 deploy/release-preflight.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
脚本先校验生产配置、上传目录、Java 与 FFmpeg,再启动无 Web 的 `production` profile,执行 JPA schema `validate` 和 15 项只读数据不变量检查,通过后退出。它不会执行迁移,也不会修复数据。
|
脚本先校验生产配置、上传目录、Java 与 FFmpeg,再启动无 Web 的 `production` profile,执行 JPA schema `validate` 和 18 项只读数据不变量检查,通过后退出。它不会执行迁移,也不会修复数据。
|
||||||
|
|
||||||
## 测试
|
## 测试
|
||||||
|
|
||||||
@ -179,6 +185,7 @@ API_ORIGIN=https://<api-domain> backend/deploy/production-smoke.sh
|
|||||||
- [ ] 已按顺序执行 `20260801_create_store_customer.sql`,三项主档验证计数均为 0
|
- [ ] 已按顺序执行 `20260801_create_store_customer.sql`,三项主档验证计数均为 0
|
||||||
- [ ] 已执行 `20260801_create_business_event.sql`,四项事件回填验证计数均为 0
|
- [ ] 已执行 `20260801_create_business_event.sql`,四项事件回填验证计数均为 0
|
||||||
- [ ] 已执行 `20260801_create_booking_capacity.sql`,容量与时长验证计数均为 0
|
- [ ] 已执行 `20260801_create_booking_capacity.sql`,容量与时长验证计数均为 0
|
||||||
|
- [ ] 已执行 `20260801_create_report_send_status.sql`,历史 `unknown` 与三项回执一致性验证均为 0
|
||||||
- [ ] `CORS_ALLOWED_ORIGINS` 仅包含本次发布的显式 HTTPS Web 源
|
- [ ] `CORS_ALLOWED_ORIGINS` 仅包含本次发布的显式 HTTPS Web 源
|
||||||
- [ ] `deploy/release-preflight.sh` 已以最终环境变量通过并留存输出
|
- [ ] `deploy/release-preflight.sh` 已以最终环境变量通过并留存输出
|
||||||
- [ ] readiness 与上线后只读冒烟全部通过
|
- [ ] readiness 与上线后只读冒烟全部通过
|
||||||
|
|||||||
40
db/migrations/20260801_create_report_send_status.sql
Normal file
40
db/migrations/20260801_create_report_send_status.sql
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
-- Petstore Phase 0:报告显式确认发送状态
|
||||||
|
-- 前置:20260801_create_booking_capacity.sql 已执行。
|
||||||
|
-- 语义:历史报告无法可靠证明是否发送,统一标记 unknown;迁移后新报告默认 unsent。
|
||||||
|
|
||||||
|
ALTER TABLE t_report
|
||||||
|
ADD COLUMN send_status VARCHAR(16) NULL COMMENT 'unknown | unsent | sent';
|
||||||
|
|
||||||
|
ALTER TABLE t_report
|
||||||
|
ADD COLUMN sent_at DATETIME NULL COMMENT '首次显式确认发送时间';
|
||||||
|
|
||||||
|
ALTER TABLE t_report
|
||||||
|
ADD COLUMN sent_by_user_id BIGINT NULL COMMENT '首次确认发送的门店员工';
|
||||||
|
|
||||||
|
ALTER TABLE t_report
|
||||||
|
ADD COLUMN send_channel VARCHAR(16) NULL COMMENT 'wechat | qr | other';
|
||||||
|
|
||||||
|
UPDATE t_report
|
||||||
|
SET send_status = 'unknown';
|
||||||
|
|
||||||
|
ALTER TABLE t_report
|
||||||
|
MODIFY COLUMN send_status VARCHAR(16) NOT NULL DEFAULT 'unsent' COMMENT 'unknown | unsent | sent';
|
||||||
|
|
||||||
|
CREATE INDEX idx_report_store_send_time
|
||||||
|
ON t_report (store_id, send_status, create_time);
|
||||||
|
|
||||||
|
-- 验证:以下三项必须全部为 0。
|
||||||
|
SELECT COUNT(*) AS invalid_report_send_status_count
|
||||||
|
FROM t_report
|
||||||
|
WHERE send_status IS NULL OR send_status NOT IN ('unknown', 'unsent', 'sent');
|
||||||
|
|
||||||
|
SELECT COUNT(*) AS sent_report_without_receipt_count
|
||||||
|
FROM t_report
|
||||||
|
WHERE send_status = 'sent'
|
||||||
|
AND (sent_at IS NULL OR sent_by_user_id IS NULL
|
||||||
|
OR send_channel IS NULL OR send_channel NOT IN ('wechat', 'qr', 'other'));
|
||||||
|
|
||||||
|
SELECT COUNT(*) AS unconfirmed_report_with_receipt_count
|
||||||
|
FROM t_report
|
||||||
|
WHERE send_status IN ('unknown', 'unsent')
|
||||||
|
AND (sent_at IS NOT NULL OR sent_by_user_id IS NOT NULL OR send_channel IS NOT NULL);
|
||||||
@ -16,4 +16,6 @@
|
|||||||
|
|
||||||
`20260801_create_booking_capacity.sql` 必须在业务事件迁移之后执行:为服务项目、预约快照和手动占用补充时长,为门店补充并发接待数。历史预约按服务名称采用稳定默认值回填;脚本末尾四个验证计数必须为 0。
|
`20260801_create_booking_capacity.sql` 必须在业务事件迁移之后执行:为服务项目、预约快照和手动占用补充时长,为门店补充并发接待数。历史预约按服务名称采用稳定默认值回填;脚本末尾四个验证计数必须为 0。
|
||||||
|
|
||||||
|
`20260801_create_report_send_status.sql` 必须在预约容量迁移之后执行:新增报告显式确认发送状态与首次确认回执。历史报告统一标记为 `unknown`,不得推测为未发送或已发送;迁移后的新报告默认 `unsent`。脚本末尾三个验证计数必须为 0。
|
||||||
|
|
||||||
正式迁移框架仍属于 Phase 0 后续任务;接入前不得把本目录误当成自动执行目录。
|
正式迁移框架仍属于 Phase 0 后续任务;接入前不得把本目录误当成自动执行目录。
|
||||||
|
|||||||
@ -102,6 +102,21 @@ public class ProductionDatabasePreflightRunner implements ApplicationRunner {
|
|||||||
WHERE deleted = 0 AND appointment_id IS NOT NULL
|
WHERE deleted = 0 AND appointment_id IS NOT NULL
|
||||||
GROUP BY appointment_id HAVING COUNT(*) > 1
|
GROUP BY appointment_id HAVING COUNT(*) > 1
|
||||||
) duplicated
|
) duplicated
|
||||||
|
"""),
|
||||||
|
new Invariant("invalid_report_send_status", """
|
||||||
|
SELECT COUNT(*) FROM t_report
|
||||||
|
WHERE send_status IS NULL OR send_status NOT IN ('unknown', 'unsent', 'sent')
|
||||||
|
"""),
|
||||||
|
new Invariant("sent_report_without_receipt", """
|
||||||
|
SELECT COUNT(*) FROM t_report
|
||||||
|
WHERE send_status = 'sent'
|
||||||
|
AND (sent_at IS NULL OR sent_by_user_id IS NULL
|
||||||
|
OR send_channel IS NULL OR send_channel NOT IN ('wechat', 'qr', 'other'))
|
||||||
|
"""),
|
||||||
|
new Invariant("unconfirmed_report_with_receipt", """
|
||||||
|
SELECT COUNT(*) FROM t_report
|
||||||
|
WHERE send_status IN ('unknown', 'unsent')
|
||||||
|
AND (sent_at IS NOT NULL OR sent_by_user_id IS NOT NULL OR send_channel IS NOT NULL)
|
||||||
""")
|
""")
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -207,6 +207,45 @@ public class ReportController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 门店员工在真实完成发送后显式确认。复制链接、二维码和预览不会自动调用本接口。
|
||||||
|
*/
|
||||||
|
@PostMapping("/confirm-sent")
|
||||||
|
public Map<String, Object> confirmSent(@RequestBody Map<String, Object> body) {
|
||||||
|
CurrentUser u = CurrentUserContext.require();
|
||||||
|
if (!u.isStoreUser()) {
|
||||||
|
return Map.of("code", 403, "message", "仅门店员工可确认报告发送", "bizCode", "FORBIDDEN");
|
||||||
|
}
|
||||||
|
Object rawReportId = body == null ? null : body.get("reportId");
|
||||||
|
if (rawReportId == null) {
|
||||||
|
return Map.of("code", 400, "message", "reportId 必填", "bizCode", "REPORT_ID_REQUIRED");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Long reportId = Long.valueOf(rawReportId.toString());
|
||||||
|
String channel = body.get("channel") == null ? "" : body.get("channel").toString();
|
||||||
|
ReportService.ConfirmSentOutcome outcome = reportService.confirmSent(
|
||||||
|
reportId, u.storeId(), u.userId(), u.role(), channel
|
||||||
|
);
|
||||||
|
if (outcome == null) {
|
||||||
|
return Map.of("code", 404, "message", "报告不存在", "bizCode", "NOT_FOUND");
|
||||||
|
}
|
||||||
|
Report report = outcome.report();
|
||||||
|
Map<String, Object> data = new LinkedHashMap<>();
|
||||||
|
data.put("reportId", report.getId());
|
||||||
|
data.put("sendStatus", report.getSendStatus());
|
||||||
|
data.put("sentAt", report.getSentAt());
|
||||||
|
data.put("sendChannel", report.getSendChannel());
|
||||||
|
data.put("alreadyConfirmed", outcome.alreadyConfirmed());
|
||||||
|
return Map.of("code", 200, "message", "已确认发送", "data", data);
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return Map.of("code", 400, "message", "reportId 格式错误", "bizCode", "INVALID_REPORT_ID");
|
||||||
|
} catch (SecurityException e) {
|
||||||
|
return Map.of("code", 403, "message", e.getMessage(), "bizCode", "FORBIDDEN");
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return Map.of("code", 400, "message", e.getMessage(), "bizCode", "INVALID_SEND_CHANNEL");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 报告是否含有指定类型(before / after)的有效照片:url 非空且 mediaType 非 video。
|
* 报告是否含有指定类型(before / after)的有效照片:url 非空且 mediaType 非 video。
|
||||||
*/
|
*/
|
||||||
@ -224,11 +263,20 @@ public class ReportController {
|
|||||||
|
|
||||||
@GetMapping("/list")
|
@GetMapping("/list")
|
||||||
public Map<String, Object> list(@RequestParam(required = false) Integer page,
|
public Map<String, Object> list(@RequestParam(required = false) Integer page,
|
||||||
@RequestParam(required = false) Integer pageSize) {
|
@RequestParam(required = false) Integer pageSize,
|
||||||
|
@RequestParam(required = false) String sendStatus) {
|
||||||
// 身份边界从上下文派生:boss/staff 查本店;customer 查自己相关报告
|
// 身份边界从上下文派生:boss/staff 查本店;customer 查自己相关报告
|
||||||
CurrentUser u = CurrentUserContext.require();
|
CurrentUser u = CurrentUserContext.require();
|
||||||
Long scopedStoreId = u.isStoreUser() ? u.storeId() : null;
|
Long scopedStoreId = u.isStoreUser() ? u.storeId() : null;
|
||||||
Long scopedUserId = u.isCustomer() ? u.userId() : null;
|
Long scopedUserId = u.isCustomer() ? u.userId() : null;
|
||||||
|
String scopedSendStatus = null;
|
||||||
|
if (u.isStoreUser() && sendStatus != null && !sendStatus.isBlank()) {
|
||||||
|
scopedSendStatus = sendStatus.trim().toLowerCase(Locale.ROOT);
|
||||||
|
if (!Set.of(Report.SEND_STATUS_UNKNOWN, Report.SEND_STATUS_UNSENT, Report.SEND_STATUS_SENT)
|
||||||
|
.contains(scopedSendStatus)) {
|
||||||
|
return Map.of("code", 400, "message", "发送状态无效", "bizCode", "INVALID_SEND_STATUS");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
boolean usePaging = page != null || pageSize != null;
|
boolean usePaging = page != null || pageSize != null;
|
||||||
List<Report> reports;
|
List<Report> reports;
|
||||||
@ -238,10 +286,10 @@ public class ReportController {
|
|||||||
if (usePaging) {
|
if (usePaging) {
|
||||||
pageNo = Math.max(page == null ? 1 : page, 1);
|
pageNo = Math.max(page == null ? 1 : page, 1);
|
||||||
size = Math.min(Math.max(pageSize == null ? 20 : pageSize, 1), 200);
|
size = Math.min(Math.max(pageSize == null ? 20 : pageSize, 1), 200);
|
||||||
paged = reportService.page(scopedStoreId, scopedUserId, pageNo - 1, size);
|
paged = reportService.page(scopedStoreId, scopedUserId, scopedSendStatus, pageNo - 1, size);
|
||||||
reports = paged.getContent();
|
reports = paged.getContent();
|
||||||
} else {
|
} else {
|
||||||
reports = reportService.list(scopedStoreId, scopedUserId);
|
reports = reportService.list(scopedStoreId, scopedUserId, scopedSendStatus);
|
||||||
}
|
}
|
||||||
// 附加技师名称,并补全图片URL
|
// 附加技师名称,并补全图片URL
|
||||||
List<Map<String, Object>> data = reports.stream().map(r -> {
|
List<Map<String, Object>> data = reports.stream().map(r -> {
|
||||||
@ -259,6 +307,9 @@ public class ReportController {
|
|||||||
item.put("userId", r.getUserId());
|
item.put("userId", r.getUserId());
|
||||||
item.put("customerUserId", r.getCustomerUserId());
|
item.put("customerUserId", r.getCustomerUserId());
|
||||||
item.put("authorStaffId", r.resolvedAuthorStaffId());
|
item.put("authorStaffId", r.resolvedAuthorStaffId());
|
||||||
|
if (u.isStoreUser()) {
|
||||||
|
putSendFields(item, r, true);
|
||||||
|
}
|
||||||
// 图片列表
|
// 图片列表
|
||||||
List<ReportImage> imgs = r.getImages();
|
List<ReportImage> imgs = r.getImages();
|
||||||
List<String> beforePhotos = new ArrayList<>();
|
List<String> beforePhotos = new ArrayList<>();
|
||||||
@ -386,6 +437,7 @@ public class ReportController {
|
|||||||
data.put("authorStaffId", report.resolvedAuthorStaffId());
|
data.put("authorStaffId", report.resolvedAuthorStaffId());
|
||||||
data.put("storeId", report.getStoreId());
|
data.put("storeId", report.getStoreId());
|
||||||
data.put("reportToken", report.getReportToken());
|
data.put("reportToken", report.getReportToken());
|
||||||
|
putSendFields(data, report, true);
|
||||||
}
|
}
|
||||||
// 公开 token 请求:不返回 top-level userId/storeId/reportToken(已在上方跳过)
|
// 公开 token 请求:不返回 top-level userId/storeId/reportToken(已在上方跳过)
|
||||||
result.put("code", 200);
|
result.put("code", 200);
|
||||||
@ -397,6 +449,15 @@ public class ReportController {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void putSendFields(Map<String, Object> target, Report report, boolean includeActor) {
|
||||||
|
target.put("sendStatus", report.getSendStatus());
|
||||||
|
target.put("sentAt", report.getSentAt());
|
||||||
|
target.put("sendChannel", report.getSendChannel());
|
||||||
|
if (includeActor) {
|
||||||
|
target.put("sentByUserId", report.getSentByUserId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@DeleteMapping("/delete")
|
@DeleteMapping("/delete")
|
||||||
public Map<String, Object> delete(@RequestParam Long id) {
|
public Map<String, Object> delete(@RequestParam Long id) {
|
||||||
CurrentUser u = CurrentUserContext.require();
|
CurrentUser u = CurrentUserContext.require();
|
||||||
|
|||||||
@ -17,13 +17,18 @@ import java.util.List;
|
|||||||
@Index(name = "idx_report_token", columnList = "report_token"),
|
@Index(name = "idx_report_token", columnList = "report_token"),
|
||||||
@Index(name = "idx_report_store_user_time", columnList = "store_id,user_id,create_time"),
|
@Index(name = "idx_report_store_user_time", columnList = "store_id,user_id,create_time"),
|
||||||
@Index(name = "idx_report_customer_time", columnList = "customer_user_id,create_time"),
|
@Index(name = "idx_report_customer_time", columnList = "customer_user_id,create_time"),
|
||||||
@Index(name = "idx_report_author_time", columnList = "author_staff_id,create_time")
|
@Index(name = "idx_report_author_time", columnList = "author_staff_id,create_time"),
|
||||||
|
@Index(name = "idx_report_store_send_time", columnList = "store_id,send_status,create_time")
|
||||||
},
|
},
|
||||||
uniqueConstraints = {
|
uniqueConstraints = {
|
||||||
@UniqueConstraint(name = "uk_report_appointment", columnNames = "appointment_id")
|
@UniqueConstraint(name = "uk_report_appointment", columnNames = "appointment_id")
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
public class Report {
|
public class Report {
|
||||||
|
public static final String SEND_STATUS_UNKNOWN = "unknown";
|
||||||
|
public static final String SEND_STATUS_UNSENT = "unsent";
|
||||||
|
public static final String SEND_STATUS_SENT = "sent";
|
||||||
|
|
||||||
@Id
|
@Id
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
private Long id;
|
private Long id;
|
||||||
@ -74,6 +79,22 @@ public class Report {
|
|||||||
@Column(name = "update_time")
|
@Column(name = "update_time")
|
||||||
private LocalDateTime updateTime;
|
private LocalDateTime updateTime;
|
||||||
|
|
||||||
|
/** unknown=历史不可确认;unsent=新报告待发送;sent=员工已显式确认发送。 */
|
||||||
|
@Column(name = "send_status", nullable = false, length = 16)
|
||||||
|
private String sendStatus = SEND_STATUS_UNSENT;
|
||||||
|
|
||||||
|
/** 首次显式确认发送时间;复制链接、展示二维码或预览均不写入。 */
|
||||||
|
@Column(name = "sent_at")
|
||||||
|
private LocalDateTime sentAt;
|
||||||
|
|
||||||
|
/** 首次确认发送的门店员工。 */
|
||||||
|
@Column(name = "sent_by_user_id")
|
||||||
|
private Long sentByUserId;
|
||||||
|
|
||||||
|
/** 首次确认渠道:wechat | qr | other。 */
|
||||||
|
@Column(name = "send_channel", length = 16)
|
||||||
|
private String sendChannel;
|
||||||
|
|
||||||
@Column(nullable = false, columnDefinition = "TINYINT(1) DEFAULT 0")
|
@Column(nullable = false, columnDefinition = "TINYINT(1) DEFAULT 0")
|
||||||
private Boolean deleted = false;
|
private Boolean deleted = false;
|
||||||
|
|
||||||
|
|||||||
@ -3,12 +3,14 @@ package com.petstore.mapper;
|
|||||||
import com.petstore.entity.Report;
|
import com.petstore.entity.Report;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.data.jpa.repository.Lock;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import jakarta.persistence.LockModeType;
|
||||||
|
|
||||||
public interface ReportMapper extends JpaRepository<Report, Long> {
|
public interface ReportMapper extends JpaRepository<Report, Long> {
|
||||||
Optional<Report> findFirstByAppointmentIdAndDeletedFalseOrderByCreateTimeDesc(Long appointmentId);
|
Optional<Report> findFirstByAppointmentIdAndDeletedFalseOrderByCreateTimeDesc(Long appointmentId);
|
||||||
@ -23,6 +25,12 @@ public interface ReportMapper extends JpaRepository<Report, Long> {
|
|||||||
List<Report> findByStoreIdAndDeletedFalseAndHighlightVideoStatusInOrderByUpdateTimeAsc(
|
List<Report> findByStoreIdAndDeletedFalseAndHighlightVideoStatusInOrderByUpdateTimeAsc(
|
||||||
Long storeId, List<String> highlightVideoStatuses);
|
Long storeId, List<String> highlightVideoStatuses);
|
||||||
|
|
||||||
|
List<Report> findByStoreIdAndSendStatusAndDeletedFalseOrderByCreateTimeAsc(
|
||||||
|
Long storeId, String sendStatus);
|
||||||
|
|
||||||
|
List<Report> findByStoreIdAndSendStatusAndDeletedFalseOrderByCreateTimeDesc(
|
||||||
|
Long storeId, String sendStatus);
|
||||||
|
|
||||||
/** @deprecated user_id 表示旧报告作者,不再用于客户归属查询。 */
|
/** @deprecated user_id 表示旧报告作者,不再用于客户归属查询。 */
|
||||||
@Deprecated
|
@Deprecated
|
||||||
List<Report> findByUserIdAndDeletedFalseOrderByCreateTimeDesc(Long userId);
|
List<Report> findByUserIdAndDeletedFalseOrderByCreateTimeDesc(Long userId);
|
||||||
@ -34,6 +42,7 @@ public interface ReportMapper extends JpaRepository<Report, Long> {
|
|||||||
List<Report> findAllByDeletedFalseOrderByCreateTimeDesc();
|
List<Report> findAllByDeletedFalseOrderByCreateTimeDesc();
|
||||||
|
|
||||||
Page<Report> findByStoreIdAndDeletedFalse(Long storeId, Pageable pageable);
|
Page<Report> findByStoreIdAndDeletedFalse(Long storeId, Pageable pageable);
|
||||||
|
Page<Report> findByStoreIdAndSendStatusAndDeletedFalse(Long storeId, String sendStatus, Pageable pageable);
|
||||||
|
|
||||||
/** @deprecated user_id 表示旧报告作者,不再用于客户归属查询。 */
|
/** @deprecated user_id 表示旧报告作者,不再用于客户归属查询。 */
|
||||||
@Deprecated
|
@Deprecated
|
||||||
@ -62,4 +71,8 @@ public interface ReportMapper extends JpaRepository<Report, Long> {
|
|||||||
Page<Report> findByDeletedFalse(Pageable pageable);
|
Page<Report> findByDeletedFalse(Pageable pageable);
|
||||||
Optional<Report> findByIdAndDeletedFalse(Long id);
|
Optional<Report> findByIdAndDeletedFalse(Long id);
|
||||||
|
|
||||||
|
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||||
|
@Query("SELECT r FROM Report r WHERE r.id = :id AND r.deleted = false")
|
||||||
|
Optional<Report> findByIdAndDeletedFalseForUpdate(@Param("id") Long id);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,6 +26,7 @@ public class AdminBusinessEventService {
|
|||||||
LocalDateTime to = day.plusDays(1).atStartOfDay();
|
LocalDateTime to = day.plusDays(1).atStartOfDay();
|
||||||
|
|
||||||
long submitted = count(storeId, BusinessEventService.REPORT_SUBMITTED, from, to);
|
long submitted = count(storeId, BusinessEventService.REPORT_SUBMITTED, from, to);
|
||||||
|
long sent = count(storeId, BusinessEventService.REPORT_SENT, from, to);
|
||||||
long opened = businessEventMapper.countDistinctAggregatesByTypes(
|
long opened = businessEventMapper.countDistinctAggregatesByTypes(
|
||||||
storeId,
|
storeId,
|
||||||
List.of(BusinessEventService.REPORT_OPENED, BusinessEventService.REPORT_REOPENED),
|
List.of(BusinessEventService.REPORT_OPENED, BusinessEventService.REPORT_REOPENED),
|
||||||
@ -37,11 +38,11 @@ public class AdminBusinessEventService {
|
|||||||
Map<String, Object> data = new LinkedHashMap<>();
|
Map<String, Object> data = new LinkedHashMap<>();
|
||||||
data.put("date", day.toString());
|
data.put("date", day.toString());
|
||||||
data.put("reportSubmittedCount", submitted);
|
data.put("reportSubmittedCount", submitted);
|
||||||
data.put("reportSentCount", null);
|
data.put("reportSentCount", sent);
|
||||||
data.put("reportOpenedCount", opened);
|
data.put("reportOpenedCount", opened);
|
||||||
data.put("leadSubmittedCount", leads);
|
data.put("leadSubmittedCount", leads);
|
||||||
data.put("rebookCreatedCount", null);
|
data.put("rebookCreatedCount", null);
|
||||||
data.put("reportSentTrackingReady", false);
|
data.put("reportSentTrackingReady", true);
|
||||||
data.put("rebookTrackingReady", false);
|
data.put("rebookTrackingReady", false);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,6 +27,7 @@ public class AdminWorkbenchService {
|
|||||||
public static final String IN_SERVICE = "in_service";
|
public static final String IN_SERVICE = "in_service";
|
||||||
public static final String UPCOMING_TODAY = "upcoming_today";
|
public static final String UPCOMING_TODAY = "upcoming_today";
|
||||||
public static final String HIGHLIGHT_ANOMALY = "highlight_anomaly";
|
public static final String HIGHLIGHT_ANOMALY = "highlight_anomaly";
|
||||||
|
public static final String UNSENT_REPORT = "unsent_report";
|
||||||
public static final String FOLLOW_UP_DUE = "follow_up_due";
|
public static final String FOLLOW_UP_DUE = "follow_up_due";
|
||||||
|
|
||||||
private final AppointmentMapper appointmentMapper;
|
private final AppointmentMapper appointmentMapper;
|
||||||
@ -74,6 +75,12 @@ public class AdminWorkbenchService {
|
|||||||
Comparator.nullsFirst(Comparator.naturalOrder())))
|
Comparator.nullsFirst(Comparator.naturalOrder())))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
|
List<Report> unsentReports = safeReports(
|
||||||
|
reportMapper.findByStoreIdAndSendStatusAndDeletedFalseOrderByCreateTimeAsc(
|
||||||
|
storeId, Report.SEND_STATUS_UNSENT
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
List<ReportLead> dueFollowUps = safeLeads(
|
List<ReportLead> dueFollowUps = safeLeads(
|
||||||
reportLeadMapper.findByStoreIdAndRemindStatusOrderByRemindDateAsc(storeId, "pending")
|
reportLeadMapper.findByStoreIdAndRemindStatusOrderByRemindDateAsc(storeId, "pending")
|
||||||
).stream()
|
).stream()
|
||||||
@ -94,6 +101,7 @@ public class AdminWorkbenchService {
|
|||||||
inService.size(),
|
inService.size(),
|
||||||
upcomingToday.size(),
|
upcomingToday.size(),
|
||||||
highlightAnomalies.size(),
|
highlightAnomalies.size(),
|
||||||
|
unsentReports.size(),
|
||||||
dueFollowUps.size()
|
dueFollowUps.size()
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -102,6 +110,7 @@ public class AdminWorkbenchService {
|
|||||||
addAppointmentGroup(actions, IN_SERVICE, "high", inService, now);
|
addAppointmentGroup(actions, IN_SERVICE, "high", inService, now);
|
||||||
addAppointmentGroup(actions, UPCOMING_TODAY, "normal", upcomingToday, now);
|
addAppointmentGroup(actions, UPCOMING_TODAY, "normal", upcomingToday, now);
|
||||||
addReportGroup(actions, highlightAnomalies);
|
addReportGroup(actions, highlightAnomalies);
|
||||||
|
addUnsentReportGroup(actions, unsentReports);
|
||||||
addLeadGroup(actions, dueFollowUps);
|
addLeadGroup(actions, dueFollowUps);
|
||||||
|
|
||||||
Map<String, Object> reportFunnel = adminBusinessEventService.reportFunnel(storeId, day);
|
Map<String, Object> reportFunnel = adminBusinessEventService.reportFunnel(storeId, day);
|
||||||
@ -178,6 +187,23 @@ public class AdminWorkbenchService {
|
|||||||
groups.add(new ActionGroup(HIGHLIGHT_ANOMALY, "high", reports.size(), items));
|
groups.add(new ActionGroup(HIGHLIGHT_ANOMALY, "high", reports.size(), items));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void addUnsentReportGroup(List<ActionGroup> groups, List<Report> reports) {
|
||||||
|
if (reports.isEmpty()) return;
|
||||||
|
List<ActionItem> items = reports.stream()
|
||||||
|
.limit(PREVIEW_LIMIT)
|
||||||
|
.map(report -> new ActionItem(
|
||||||
|
"report",
|
||||||
|
report.getId(),
|
||||||
|
report.getPetName(),
|
||||||
|
report.getServiceType(),
|
||||||
|
report.getCreateTime() == null ? null : report.getCreateTime().toString(),
|
||||||
|
report.getSendStatus(),
|
||||||
|
true
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
groups.add(new ActionGroup(UNSENT_REPORT, "high", reports.size(), items));
|
||||||
|
}
|
||||||
|
|
||||||
private static void addLeadGroup(List<ActionGroup> groups, List<ReportLead> leads) {
|
private static void addLeadGroup(List<ActionGroup> groups, List<ReportLead> leads) {
|
||||||
if (leads.isEmpty()) return;
|
if (leads.isEmpty()) return;
|
||||||
List<ActionItem> items = leads.stream()
|
List<ActionItem> items = leads.stream()
|
||||||
@ -210,6 +236,7 @@ public class AdminWorkbenchService {
|
|||||||
long inServiceCount,
|
long inServiceCount,
|
||||||
long upcomingTodayCount,
|
long upcomingTodayCount,
|
||||||
long highlightAnomalyCount,
|
long highlightAnomalyCount,
|
||||||
|
long unsentReportCount,
|
||||||
long dueFollowUpCount) {
|
long dueFollowUpCount) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -31,6 +31,7 @@ public class BusinessEventService {
|
|||||||
public static final String SERVICE_STARTED = "service_started";
|
public static final String SERVICE_STARTED = "service_started";
|
||||||
public static final String SERVICE_COMPLETED = "service_completed";
|
public static final String SERVICE_COMPLETED = "service_completed";
|
||||||
public static final String REPORT_SUBMITTED = "report_submitted";
|
public static final String REPORT_SUBMITTED = "report_submitted";
|
||||||
|
public static final String REPORT_SENT = "report_sent";
|
||||||
public static final String REPORT_OPENED = "report_opened";
|
public static final String REPORT_OPENED = "report_opened";
|
||||||
public static final String REPORT_REOPENED = "report_reopened";
|
public static final String REPORT_REOPENED = "report_reopened";
|
||||||
public static final String LEAD_SUBMITTED = "lead_submitted";
|
public static final String LEAD_SUBMITTED = "lead_submitted";
|
||||||
@ -152,6 +153,23 @@ public class BusinessEventService {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public BusinessEvent recordReportSent(Report report, String actorRole) {
|
||||||
|
return record(new EventCommand(
|
||||||
|
REPORT_SENT,
|
||||||
|
report.getStoreId(),
|
||||||
|
resolveStoreCustomerId(report.getStoreId(), report.getCustomerUserId()),
|
||||||
|
"report",
|
||||||
|
report.getId(),
|
||||||
|
report.getSentByUserId(),
|
||||||
|
actorRole,
|
||||||
|
"admin",
|
||||||
|
report.getSentAt(),
|
||||||
|
"report_sent:" + report.getId(),
|
||||||
|
Map.of("channel", report.getSendChannel())
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public BusinessEvent recordLeadSubmitted(
|
public BusinessEvent recordLeadSubmitted(
|
||||||
ReportLead lead,
|
ReportLead lead,
|
||||||
|
|||||||
@ -12,11 +12,15 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class ReportService {
|
public class ReportService {
|
||||||
|
private static final Set<String> SEND_CHANNELS = Set.of("wechat", "qr", "other");
|
||||||
|
|
||||||
private final ReportMapper reportMapper;
|
private final ReportMapper reportMapper;
|
||||||
private final ReportImageMapper reportImageMapper;
|
private final ReportImageMapper reportImageMapper;
|
||||||
private final AppointmentMapper appointmentMapper;
|
private final AppointmentMapper appointmentMapper;
|
||||||
@ -94,6 +98,10 @@ public class ReportService {
|
|||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
report.setCreateTime(now);
|
report.setCreateTime(now);
|
||||||
report.setUpdateTime(now);
|
report.setUpdateTime(now);
|
||||||
|
report.setSendStatus(Report.SEND_STATUS_UNSENT);
|
||||||
|
report.setSentAt(null);
|
||||||
|
report.setSentByUserId(null);
|
||||||
|
report.setSendChannel(null);
|
||||||
report.setDeleted(false);
|
report.setDeleted(false);
|
||||||
|
|
||||||
// 先保存 Report 以获取生成的 ID
|
// 先保存 Report 以获取生成的 ID
|
||||||
@ -123,6 +131,54 @@ public class ReportService {
|
|||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录门店员工已经完成的发送事实。首次确认不可覆盖;重复确认幂等成功。
|
||||||
|
* 复制链接、展示二维码和预览报告都不得调用本方法。
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public ConfirmSentOutcome confirmSent(
|
||||||
|
Long reportId,
|
||||||
|
Long storeId,
|
||||||
|
Long actorUserId,
|
||||||
|
String actorRole,
|
||||||
|
String channel
|
||||||
|
) {
|
||||||
|
if (reportId == null) {
|
||||||
|
throw new IllegalArgumentException("reportId 必填");
|
||||||
|
}
|
||||||
|
if (storeId == null || actorUserId == null
|
||||||
|
|| !("boss".equalsIgnoreCase(actorRole) || "staff".equalsIgnoreCase(actorRole))) {
|
||||||
|
throw new SecurityException("仅门店员工可确认报告发送");
|
||||||
|
}
|
||||||
|
String normalizedChannel = channel == null ? "" : channel.trim().toLowerCase(Locale.ROOT);
|
||||||
|
if (!SEND_CHANNELS.contains(normalizedChannel)) {
|
||||||
|
throw new IllegalArgumentException("发送渠道仅支持 wechat、qr 或 other");
|
||||||
|
}
|
||||||
|
|
||||||
|
Report report = reportMapper.findByIdAndDeletedFalseForUpdate(reportId).orElse(null);
|
||||||
|
if (report == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!storeId.equals(report.getStoreId())) {
|
||||||
|
throw new SecurityException("无权确认他店报告发送");
|
||||||
|
}
|
||||||
|
if (Report.SEND_STATUS_SENT.equals(report.getSendStatus())) {
|
||||||
|
return new ConfirmSentOutcome(report, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
report.setSendStatus(Report.SEND_STATUS_SENT);
|
||||||
|
report.setSentAt(now);
|
||||||
|
report.setSentByUserId(actorUserId);
|
||||||
|
report.setSendChannel(normalizedChannel);
|
||||||
|
Report saved = reportMapper.save(report);
|
||||||
|
businessEventService.recordReportSent(saved, actorRole);
|
||||||
|
return new ConfirmSentOutcome(saved, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ConfirmSentOutcome(Report report, boolean alreadyConfirmed) {
|
||||||
|
}
|
||||||
|
|
||||||
private String resolveActorRole(Long userId) {
|
private String resolveActorRole(Long userId) {
|
||||||
if (userId == null) {
|
if (userId == null) {
|
||||||
return null;
|
return null;
|
||||||
@ -168,11 +224,19 @@ public class ReportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public List<Report> list(Long storeId, Long customerUserId) {
|
public List<Report> list(Long storeId, Long customerUserId) {
|
||||||
|
return list(storeId, customerUserId, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Report> list(Long storeId, Long customerUserId, String sendStatus) {
|
||||||
List<Report> reports;
|
List<Report> reports;
|
||||||
if (storeId != null && customerUserId != null) {
|
if (storeId != null && customerUserId != null) {
|
||||||
reports = reportMapper.findForCustomer(customerUserId).stream()
|
reports = reportMapper.findForCustomer(customerUserId).stream()
|
||||||
.filter(r -> storeId.equals(r.getStoreId()))
|
.filter(r -> storeId.equals(r.getStoreId()))
|
||||||
.toList();
|
.toList();
|
||||||
|
} else if (storeId != null && sendStatus != null) {
|
||||||
|
reports = reportMapper.findByStoreIdAndSendStatusAndDeletedFalseOrderByCreateTimeDesc(
|
||||||
|
storeId, sendStatus
|
||||||
|
);
|
||||||
} else if (storeId != null) {
|
} else if (storeId != null) {
|
||||||
reports = reportMapper.findByStoreIdAndDeletedFalseOrderByCreateTimeDesc(storeId);
|
reports = reportMapper.findByStoreIdAndDeletedFalseOrderByCreateTimeDesc(storeId);
|
||||||
} else if (customerUserId != null) {
|
} else if (customerUserId != null) {
|
||||||
@ -184,6 +248,11 @@ public class ReportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public Page<Report> page(Long storeId, Long customerUserId, int pageNo, int pageSize) {
|
public Page<Report> page(Long storeId, Long customerUserId, int pageNo, int pageSize) {
|
||||||
|
return page(storeId, customerUserId, null, pageNo, pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Page<Report> page(
|
||||||
|
Long storeId, Long customerUserId, String sendStatus, int pageNo, int pageSize) {
|
||||||
Pageable pageable = PageRequest.of(
|
Pageable pageable = PageRequest.of(
|
||||||
Math.max(pageNo, 0),
|
Math.max(pageNo, 0),
|
||||||
Math.max(pageSize, 1),
|
Math.max(pageSize, 1),
|
||||||
@ -193,6 +262,8 @@ public class ReportService {
|
|||||||
if (storeId != null && customerUserId != null) {
|
if (storeId != null && customerUserId != null) {
|
||||||
// 当前角色模型不会同时出现 store/customer scope;保守按客户归属查询。
|
// 当前角色模型不会同时出现 store/customer scope;保守按客户归属查询。
|
||||||
paged = reportMapper.pageForCustomer(customerUserId, pageable);
|
paged = reportMapper.pageForCustomer(customerUserId, pageable);
|
||||||
|
} else if (storeId != null && sendStatus != null) {
|
||||||
|
paged = reportMapper.findByStoreIdAndSendStatusAndDeletedFalse(storeId, sendStatus, pageable);
|
||||||
} else if (storeId != null) {
|
} else if (storeId != null) {
|
||||||
paged = reportMapper.findByStoreIdAndDeletedFalse(storeId, pageable);
|
paged = reportMapper.findByStoreIdAndDeletedFalse(storeId, pageable);
|
||||||
} else if (customerUserId != null) {
|
} else if (customerUserId != null) {
|
||||||
|
|||||||
@ -74,7 +74,7 @@ class AdminBusinessEventControllerTest {
|
|||||||
AdminWorkbenchService.WorkbenchData data = new AdminWorkbenchService.WorkbenchData(
|
AdminWorkbenchService.WorkbenchData data = new AdminWorkbenchService.WorkbenchData(
|
||||||
"2026-08-01",
|
"2026-08-01",
|
||||||
LocalDateTime.of(2026, 8, 1, 10, 0),
|
LocalDateTime.of(2026, 8, 1, 10, 0),
|
||||||
new AdminWorkbenchService.WorkbenchMetrics(3, 1, 1, 1, 1, 0, 0),
|
new AdminWorkbenchService.WorkbenchMetrics(3, 1, 1, 1, 1, 0, 0, 0),
|
||||||
List.of(),
|
List.of(),
|
||||||
Map.of()
|
Map.of()
|
||||||
);
|
);
|
||||||
|
|||||||
@ -101,6 +101,9 @@ class ReportControllerTest {
|
|||||||
assertFalse(data.containsKey("authorStaffId"), "公开请求不应返回报告作者 ID");
|
assertFalse(data.containsKey("authorStaffId"), "公开请求不应返回报告作者 ID");
|
||||||
assertFalse(data.containsKey("storeId"), "公开请求不应返回 top-level storeId");
|
assertFalse(data.containsKey("storeId"), "公开请求不应返回 top-level storeId");
|
||||||
assertFalse(data.containsKey("reportToken"), "公开请求不应返回 reportToken");
|
assertFalse(data.containsKey("reportToken"), "公开请求不应返回 reportToken");
|
||||||
|
assertFalse(data.containsKey("sendStatus"), "公开请求不应返回门店发送状态");
|
||||||
|
assertFalse(data.containsKey("sentAt"), "公开请求不应返回门店发送回执");
|
||||||
|
assertFalse(data.containsKey("sentByUserId"), "公开请求不应返回确认员工 ID");
|
||||||
// 但 store 对象内应包含 id(用于预约跳转深链)
|
// 但 store 对象内应包含 id(用于预约跳转深链)
|
||||||
Map<String, Object> storeInfo = (Map<String, Object>) data.get("store");
|
Map<String, Object> storeInfo = (Map<String, Object>) data.get("store");
|
||||||
assertNotNull(storeInfo);
|
assertNotNull(storeInfo);
|
||||||
@ -290,6 +293,73 @@ class ReportControllerTest {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
@Test
|
||||||
|
void confirmSentUsesSessionScopeAndReturnsIdempotencyFlag() {
|
||||||
|
setCurrentUserBoss();
|
||||||
|
Report report = new Report();
|
||||||
|
report.setId(9L);
|
||||||
|
report.setSendStatus(Report.SEND_STATUS_SENT);
|
||||||
|
report.setSentAt(java.time.LocalDateTime.of(2026, 8, 1, 10, 0));
|
||||||
|
report.setSentByUserId(1L);
|
||||||
|
report.setSendChannel("wechat");
|
||||||
|
when(reportService.confirmSent(9L, 10L, 1L, "boss", "wechat"))
|
||||||
|
.thenReturn(new ReportService.ConfirmSentOutcome(report, false));
|
||||||
|
|
||||||
|
Map<String, Object> result = controller.confirmSent(Map.of("reportId", 9L, "channel", "wechat"));
|
||||||
|
|
||||||
|
assertEquals(200, result.get("code"));
|
||||||
|
Map<String, Object> data = (Map<String, Object>) result.get("data");
|
||||||
|
assertEquals("sent", data.get("sendStatus"));
|
||||||
|
assertEquals(false, data.get("alreadyConfirmed"));
|
||||||
|
assertFalse(data.containsKey("sentByUserId"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void customerCannotConfirmSent() {
|
||||||
|
CurrentUserContext.set(new CurrentUser(99L, null, "customer"));
|
||||||
|
|
||||||
|
Map<String, Object> result = controller.confirmSent(Map.of("reportId", 9L, "channel", "wechat"));
|
||||||
|
|
||||||
|
assertEquals(403, result.get("code"));
|
||||||
|
assertEquals("FORBIDDEN", result.get("bizCode"));
|
||||||
|
verify(reportService, never()).confirmSent(any(), any(), any(), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void crossStoreConfirmSentReturnsForbidden() {
|
||||||
|
setCurrentUserBoss();
|
||||||
|
when(reportService.confirmSent(9L, 10L, 1L, "boss", "qr"))
|
||||||
|
.thenThrow(new SecurityException("无权确认他店报告发送"));
|
||||||
|
|
||||||
|
Map<String, Object> result = controller.confirmSent(Map.of("reportId", 9L, "channel", "qr"));
|
||||||
|
|
||||||
|
assertEquals(403, result.get("code"));
|
||||||
|
assertEquals("FORBIDDEN", result.get("bizCode"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void storeReportListFiltersBySendStatusServerSide() {
|
||||||
|
setCurrentUserBoss();
|
||||||
|
when(reportService.list(10L, null, Report.SEND_STATUS_UNSENT)).thenReturn(List.of());
|
||||||
|
|
||||||
|
Map<String, Object> result = controller.list(null, null, "unsent");
|
||||||
|
|
||||||
|
assertEquals(200, result.get("code"));
|
||||||
|
verify(reportService).list(10L, null, Report.SEND_STATUS_UNSENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reportListRejectsInvalidSendStatus() {
|
||||||
|
setCurrentUserBoss();
|
||||||
|
|
||||||
|
Map<String, Object> result = controller.list(null, null, "delivered");
|
||||||
|
|
||||||
|
assertEquals(400, result.get("code"));
|
||||||
|
assertEquals("INVALID_SEND_STATUS", result.get("bizCode"));
|
||||||
|
verify(reportService, never()).list(any(), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
private void setCurrentUserBoss() {
|
private void setCurrentUserBoss() {
|
||||||
CurrentUserContext.set(new CurrentUser(1L, 10L, "boss"));
|
CurrentUserContext.set(new CurrentUser(1L, 10L, "boss"));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,69 @@
|
|||||||
|
package com.petstore.mapper;
|
||||||
|
|
||||||
|
import org.h2.tools.RunScript;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.io.Reader;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.DriverManager;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.Statement;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
class ReportSendMigrationTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void migrationKeepsHistoryUnknownAndDefaultsNewReportsToUnsent() throws Exception {
|
||||||
|
try (Connection connection = DriverManager.getConnection(
|
||||||
|
"jdbc:h2:mem:report-send-migration;MODE=MySQL;DATABASE_TO_LOWER=TRUE",
|
||||||
|
"sa",
|
||||||
|
""
|
||||||
|
)) {
|
||||||
|
try (Statement statement = connection.createStatement()) {
|
||||||
|
statement.execute("""
|
||||||
|
CREATE TABLE t_report (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
store_id BIGINT,
|
||||||
|
create_time DATETIME,
|
||||||
|
deleted BOOLEAN DEFAULT FALSE
|
||||||
|
)
|
||||||
|
""");
|
||||||
|
statement.execute("INSERT INTO t_report (store_id, create_time) VALUES (10, '2026-08-01 10:00:00')");
|
||||||
|
}
|
||||||
|
|
||||||
|
try (Reader reader = Files.newBufferedReader(
|
||||||
|
Path.of("db/migrations/20260801_create_report_send_status.sql")
|
||||||
|
)) {
|
||||||
|
RunScript.execute(connection, reader);
|
||||||
|
}
|
||||||
|
|
||||||
|
assertText(connection, "SELECT send_status FROM t_report WHERE id = 1", "unknown");
|
||||||
|
try (Statement statement = connection.createStatement()) {
|
||||||
|
statement.execute("INSERT INTO t_report (store_id, create_time) VALUES (10, '2026-08-01 11:00:00')");
|
||||||
|
}
|
||||||
|
assertText(connection, "SELECT send_status FROM t_report WHERE id = 2", "unsent");
|
||||||
|
assertScalar(connection, """
|
||||||
|
SELECT COUNT(*) FROM t_report
|
||||||
|
WHERE send_status IN ('unknown', 'unsent')
|
||||||
|
AND (sent_at IS NOT NULL OR sent_by_user_id IS NOT NULL OR send_channel IS NOT NULL)
|
||||||
|
""", 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertText(Connection connection, String sql, String expected) throws Exception {
|
||||||
|
try (Statement statement = connection.createStatement(); ResultSet result = statement.executeQuery(sql)) {
|
||||||
|
result.next();
|
||||||
|
assertEquals(expected, result.getString(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertScalar(Connection connection, String sql, int expected) throws Exception {
|
||||||
|
try (Statement statement = connection.createStatement(); ResultSet result = statement.executeQuery(sql)) {
|
||||||
|
result.next();
|
||||||
|
assertEquals(expected, result.getInt(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -26,11 +26,14 @@ class AdminBusinessEventServiceTest {
|
|||||||
@InjectMocks private AdminBusinessEventService service;
|
@InjectMocks private AdminBusinessEventService service;
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void reportFunnelCountsDistinctFactsAndMarksUntrackedStages() {
|
void reportFunnelCountsDistinctFactsAndKeepsOnlyRebookUntracked() {
|
||||||
LocalDate date = LocalDate.of(2026, 8, 1);
|
LocalDate date = LocalDate.of(2026, 8, 1);
|
||||||
when(businessEventMapper.countDistinctAggregates(
|
when(businessEventMapper.countDistinctAggregates(
|
||||||
eq(10L), eq(BusinessEventService.REPORT_SUBMITTED), any(LocalDateTime.class), any(LocalDateTime.class)
|
eq(10L), eq(BusinessEventService.REPORT_SUBMITTED), any(LocalDateTime.class), any(LocalDateTime.class)
|
||||||
)).thenReturn(8L);
|
)).thenReturn(8L);
|
||||||
|
when(businessEventMapper.countDistinctAggregates(
|
||||||
|
eq(10L), eq(BusinessEventService.REPORT_SENT), any(LocalDateTime.class), any(LocalDateTime.class)
|
||||||
|
)).thenReturn(7L);
|
||||||
when(businessEventMapper.countDistinctAggregatesByTypes(
|
when(businessEventMapper.countDistinctAggregatesByTypes(
|
||||||
eq(10L), any(Collection.class), any(LocalDateTime.class), any(LocalDateTime.class)
|
eq(10L), any(Collection.class), any(LocalDateTime.class), any(LocalDateTime.class)
|
||||||
)).thenReturn(6L);
|
)).thenReturn(6L);
|
||||||
@ -44,9 +47,9 @@ class AdminBusinessEventServiceTest {
|
|||||||
assertEquals(8L, data.get("reportSubmittedCount"));
|
assertEquals(8L, data.get("reportSubmittedCount"));
|
||||||
assertEquals(6L, data.get("reportOpenedCount"));
|
assertEquals(6L, data.get("reportOpenedCount"));
|
||||||
assertEquals(3L, data.get("leadSubmittedCount"));
|
assertEquals(3L, data.get("leadSubmittedCount"));
|
||||||
assertNull(data.get("reportSentCount"));
|
assertEquals(7L, data.get("reportSentCount"));
|
||||||
assertNull(data.get("rebookCreatedCount"));
|
assertNull(data.get("rebookCreatedCount"));
|
||||||
assertFalse((Boolean) data.get("reportSentTrackingReady"));
|
assertEquals(true, data.get("reportSentTrackingReady"));
|
||||||
assertFalse((Boolean) data.get("rebookTrackingReady"));
|
assertFalse((Boolean) data.get("rebookTrackingReady"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,6 +25,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
|
|||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
class AdminWorkbenchServiceTest {
|
class AdminWorkbenchServiceTest {
|
||||||
@ -65,6 +66,12 @@ class AdminWorkbenchServiceTest {
|
|||||||
when(reportMapper.findByStoreIdAndDeletedFalseAndHighlightVideoStatusInOrderByUpdateTimeAsc(
|
when(reportMapper.findByStoreIdAndDeletedFalseAndHighlightVideoStatusInOrderByUpdateTimeAsc(
|
||||||
10L, List.of("failed", "processing")))
|
10L, List.of("failed", "processing")))
|
||||||
.thenReturn(List.of(stale, fresh, failed));
|
.thenReturn(List.of(stale, fresh, failed));
|
||||||
|
Report unsent = report(23L, "报告待发送", "洗护", null, now.minusMinutes(2));
|
||||||
|
unsent.setCreateTime(now.minusMinutes(2));
|
||||||
|
unsent.setSendStatus(Report.SEND_STATUS_UNSENT);
|
||||||
|
when(reportMapper.findByStoreIdAndSendStatusAndDeletedFalseOrderByCreateTimeAsc(
|
||||||
|
10L, Report.SEND_STATUS_UNSENT))
|
||||||
|
.thenReturn(List.of(unsent));
|
||||||
|
|
||||||
ReportLead noDate = lead(30L, "无日期线索", null, "pending");
|
ReportLead noDate = lead(30L, "无日期线索", null, "pending");
|
||||||
ReportLead due = lead(31L, "今日回访", LocalDate.of(2026, 8, 1), "pending");
|
ReportLead due = lead(31L, "今日回访", LocalDate.of(2026, 8, 1), "pending");
|
||||||
@ -82,23 +89,28 @@ class AdminWorkbenchServiceTest {
|
|||||||
assertEquals(1L, data.metrics().inServiceCount());
|
assertEquals(1L, data.metrics().inServiceCount());
|
||||||
assertEquals(1L, data.metrics().upcomingTodayCount());
|
assertEquals(1L, data.metrics().upcomingTodayCount());
|
||||||
assertEquals(2L, data.metrics().highlightAnomalyCount());
|
assertEquals(2L, data.metrics().highlightAnomalyCount());
|
||||||
|
assertEquals(1L, data.metrics().unsentReportCount());
|
||||||
assertEquals(2L, data.metrics().dueFollowUpCount());
|
assertEquals(2L, data.metrics().dueFollowUpCount());
|
||||||
assertEquals(List.of(
|
assertEquals(List.of(
|
||||||
AdminWorkbenchService.OVERDUE_APPOINTMENT,
|
AdminWorkbenchService.OVERDUE_APPOINTMENT,
|
||||||
AdminWorkbenchService.IN_SERVICE,
|
AdminWorkbenchService.IN_SERVICE,
|
||||||
AdminWorkbenchService.UPCOMING_TODAY,
|
AdminWorkbenchService.UPCOMING_TODAY,
|
||||||
AdminWorkbenchService.HIGHLIGHT_ANOMALY,
|
AdminWorkbenchService.HIGHLIGHT_ANOMALY,
|
||||||
|
AdminWorkbenchService.UNSENT_REPORT,
|
||||||
AdminWorkbenchService.FOLLOW_UP_DUE
|
AdminWorkbenchService.FOLLOW_UP_DUE
|
||||||
),
|
),
|
||||||
data.actions().stream().map(AdminWorkbenchService.ActionGroup::kind).toList());
|
data.actions().stream().map(AdminWorkbenchService.ActionGroup::kind).toList());
|
||||||
assertEquals("迟到的豆豆", data.actions().get(0).items().get(0).petName());
|
assertEquals("迟到的豆豆", data.actions().get(0).items().get(0).petName());
|
||||||
assertEquals("report", data.actions().get(3).items().get(0).resourceType());
|
assertEquals("report", data.actions().get(3).items().get(0).resourceType());
|
||||||
|
assertEquals("unsent", data.actions().get(4).items().get(0).status());
|
||||||
assertEquals(2L, data.reportFunnel().get("reportSubmittedCount"));
|
assertEquals(2L, data.reportFunnel().get("reportSubmittedCount"));
|
||||||
|
|
||||||
String json = new ObjectMapper().findAndRegisterModules().writeValueAsString(data);
|
String json = new ObjectMapper().findAndRegisterModules().writeValueAsString(data);
|
||||||
assertFalse(json.contains("13800000000"));
|
assertFalse(json.contains("13800000000"));
|
||||||
assertFalse(json.contains("report-secret-token"));
|
assertFalse(json.contains("report-secret-token"));
|
||||||
assertFalse(json.contains("/private/video.mp4"));
|
assertFalse(json.contains("/private/video.mp4"));
|
||||||
|
verify(reportMapper).findByStoreIdAndSendStatusAndDeletedFalseOrderByCreateTimeAsc(
|
||||||
|
10L, Report.SEND_STATUS_UNSENT);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@ -140,4 +140,28 @@ class BusinessEventServiceTest {
|
|||||||
assertEquals(50L, result.getId());
|
assertEquals(50L, result.getId());
|
||||||
verify(businessEventMapper, never()).save(any());
|
verify(businessEventMapper, never()).save(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reportSentIsIdempotentAndStoresOnlyChannelMetadata() {
|
||||||
|
Report report = new Report();
|
||||||
|
report.setId(9L);
|
||||||
|
report.setStoreId(10L);
|
||||||
|
report.setCustomerUserId(88L);
|
||||||
|
report.setSentByUserId(7L);
|
||||||
|
report.setSentAt(LocalDateTime.of(2026, 8, 1, 10, 0));
|
||||||
|
report.setSendChannel("wechat");
|
||||||
|
when(storeCustomerMapper.findFirstByStoreIdAndCustomerUserId(10L, 88L))
|
||||||
|
.thenReturn(Optional.empty());
|
||||||
|
when(businessEventMapper.findFirstByIdempotencyKey("report_sent:9"))
|
||||||
|
.thenReturn(Optional.empty());
|
||||||
|
when(businessEventMapper.save(any(BusinessEvent.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
BusinessEvent event = service.recordReportSent(report, "staff");
|
||||||
|
|
||||||
|
assertEquals(BusinessEventService.REPORT_SENT, event.getEventType());
|
||||||
|
assertEquals("report_sent:9", event.getIdempotencyKey());
|
||||||
|
assertEquals("{\"channel\":\"wechat\"}", event.getMetadataJson());
|
||||||
|
assertFalse(event.getMetadataJson().contains("token"));
|
||||||
|
assertEquals(7L, event.getActorUserId());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -100,6 +100,10 @@ class ReportServiceTest {
|
|||||||
assertEquals(5L, created.getUserId());
|
assertEquals(5L, created.getUserId());
|
||||||
assertEquals("技师甲", created.getStaffName());
|
assertEquals("技师甲", created.getStaffName());
|
||||||
assertNotNull(created.getReportToken());
|
assertNotNull(created.getReportToken());
|
||||||
|
assertEquals(Report.SEND_STATUS_UNSENT, created.getSendStatus());
|
||||||
|
assertNull(created.getSentAt());
|
||||||
|
assertNull(created.getSentByUserId());
|
||||||
|
assertNull(created.getSendChannel());
|
||||||
verify(businessEventService).recordAppointmentStatusChanged(
|
verify(businessEventService).recordAppointmentStatusChanged(
|
||||||
eq(appt), eq("doing"), eq("done"), eq(5L), isNull(), any(LocalDateTime.class)
|
eq(appt), eq("doing"), eq("done"), eq(5L), isNull(), any(LocalDateTime.class)
|
||||||
);
|
);
|
||||||
@ -166,4 +170,85 @@ class ReportServiceTest {
|
|||||||
verify(reportMapper).findForCustomer(88L);
|
verify(reportMapper).findForCustomer(88L);
|
||||||
verify(reportMapper, never()).findByUserIdAndDeletedFalseOrderByCreateTimeDesc(anyLong());
|
verify(reportMapper, never()).findByUserIdAndDeletedFalseOrderByCreateTimeDesc(anyLong());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void storeListCanFilterByCanonicalSendStatus() {
|
||||||
|
when(reportMapper.findByStoreIdAndSendStatusAndDeletedFalseOrderByCreateTimeDesc(
|
||||||
|
10L, Report.SEND_STATUS_UNSENT)).thenReturn(List.of());
|
||||||
|
|
||||||
|
assertTrue(reportService.list(10L, null, Report.SEND_STATUS_UNSENT).isEmpty());
|
||||||
|
|
||||||
|
verify(reportMapper).findByStoreIdAndSendStatusAndDeletedFalseOrderByCreateTimeDesc(
|
||||||
|
10L, Report.SEND_STATUS_UNSENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void firstConfirmSentPersistsImmutableReceiptAndEvent() {
|
||||||
|
Report report = new Report();
|
||||||
|
report.setId(9L);
|
||||||
|
report.setStoreId(10L);
|
||||||
|
report.setCustomerUserId(88L);
|
||||||
|
report.setSendStatus(Report.SEND_STATUS_UNSENT);
|
||||||
|
LocalDateTime priorUpdateTime = LocalDateTime.of(2026, 8, 1, 9, 55);
|
||||||
|
report.setUpdateTime(priorUpdateTime);
|
||||||
|
when(reportMapper.findByIdAndDeletedFalseForUpdate(9L)).thenReturn(Optional.of(report));
|
||||||
|
when(reportMapper.save(report)).thenReturn(report);
|
||||||
|
|
||||||
|
ReportService.ConfirmSentOutcome outcome = reportService.confirmSent(
|
||||||
|
9L, 10L, 7L, "staff", "wechat"
|
||||||
|
);
|
||||||
|
|
||||||
|
assertNotNull(outcome);
|
||||||
|
assertFalse(outcome.alreadyConfirmed());
|
||||||
|
assertEquals(Report.SEND_STATUS_SENT, report.getSendStatus());
|
||||||
|
assertEquals(7L, report.getSentByUserId());
|
||||||
|
assertEquals("wechat", report.getSendChannel());
|
||||||
|
assertNotNull(report.getSentAt());
|
||||||
|
assertEquals(priorUpdateTime, report.getUpdateTime(), "发送回执不得重置成片处理超时计时");
|
||||||
|
verify(reportMapper).save(report);
|
||||||
|
verify(businessEventService).recordReportSent(report, "staff");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void repeatedConfirmSentIsIdempotentAndPreservesFirstReceipt() {
|
||||||
|
LocalDateTime firstSentAt = LocalDateTime.of(2026, 8, 1, 10, 0);
|
||||||
|
Report report = new Report();
|
||||||
|
report.setId(9L);
|
||||||
|
report.setStoreId(10L);
|
||||||
|
report.setSendStatus(Report.SEND_STATUS_SENT);
|
||||||
|
report.setSentAt(firstSentAt);
|
||||||
|
report.setSentByUserId(7L);
|
||||||
|
report.setSendChannel("wechat");
|
||||||
|
when(reportMapper.findByIdAndDeletedFalseForUpdate(9L)).thenReturn(Optional.of(report));
|
||||||
|
|
||||||
|
ReportService.ConfirmSentOutcome outcome = reportService.confirmSent(
|
||||||
|
9L, 10L, 8L, "boss", "qr"
|
||||||
|
);
|
||||||
|
|
||||||
|
assertTrue(outcome.alreadyConfirmed());
|
||||||
|
assertEquals(firstSentAt, report.getSentAt());
|
||||||
|
assertEquals(7L, report.getSentByUserId());
|
||||||
|
assertEquals("wechat", report.getSendChannel());
|
||||||
|
verify(reportMapper, never()).save(any(Report.class));
|
||||||
|
verify(businessEventService, never()).recordReportSent(any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void confirmSentRejectsCrossStore() {
|
||||||
|
Report report = new Report();
|
||||||
|
report.setId(9L);
|
||||||
|
report.setStoreId(20L);
|
||||||
|
when(reportMapper.findByIdAndDeletedFalseForUpdate(9L)).thenReturn(Optional.of(report));
|
||||||
|
|
||||||
|
assertThrows(SecurityException.class,
|
||||||
|
() -> reportService.confirmSent(9L, 10L, 7L, "staff", "wechat"));
|
||||||
|
verify(reportMapper, never()).save(any(Report.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void confirmSentRejectsUnknownChannel() {
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> reportService.confirmSent(9L, 10L, 7L, "staff", "clipboard"));
|
||||||
|
verify(reportMapper, never()).findByIdAndDeletedFalseForUpdate(anyLong());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user