实现M002 V4持久化基线
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.common.dto;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Reservation V4 订单任务创建草稿。用于按 source_message_id + order_ref 幂等创建订单任务聚合。
|
||||
*/
|
||||
public record ReservationV4OrderTaskDraft(
|
||||
/** 酒店 ID。 */
|
||||
String hotelId,
|
||||
/** SourceMessage Inbox 内部 ID。 */
|
||||
Long sourceMessageId,
|
||||
/** AI 回调批次 ID。 */
|
||||
Long aiBatchId,
|
||||
/** V4 包内订单引用。 */
|
||||
String orderRef,
|
||||
/** order_contexts[] 中的一基序号。 */
|
||||
Integer orderContextIndex,
|
||||
/** 绑定的本地订单投影 ID,归属未解决时可为空。 */
|
||||
Long orderId,
|
||||
/** 目标订单业务类型,例如 GROUP、FIT。 */
|
||||
String targetBookingType,
|
||||
/** 目标订单定位类型。 */
|
||||
String targetLocatorType,
|
||||
/** 目标订单定位值。 */
|
||||
String targetLocatorValue,
|
||||
/** 订单归属解析状态。 */
|
||||
String targetResolutionStatus,
|
||||
/** 订单任务状态快照。 */
|
||||
String orderTaskStatus,
|
||||
/** 来源邮件接收 UTC 时间。 */
|
||||
LocalDateTime sourceReceivedAt,
|
||||
/** 当前写入 UTC 时间。 */
|
||||
LocalDateTime now
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.common.dto;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Reservation V4 订单任务快照。屏蔽 Entity 细节供 Service 和后续查询接口使用。
|
||||
*/
|
||||
public record ReservationV4OrderTaskSnapshot(
|
||||
/** V4 订单任务 ID。 */
|
||||
Long id,
|
||||
/** 酒店 ID。 */
|
||||
String hotelId,
|
||||
/** SourceMessage Inbox 内部 ID。 */
|
||||
Long sourceMessageId,
|
||||
/** AI 回调批次 ID。 */
|
||||
Long aiBatchId,
|
||||
/** V4 包内订单引用。 */
|
||||
String orderRef,
|
||||
/** order_contexts[] 中的一基序号。 */
|
||||
Integer orderContextIndex,
|
||||
/** 绑定的本地订单投影 ID,归属未解决时可为空。 */
|
||||
Long orderId,
|
||||
/** 目标订单业务类型。 */
|
||||
String targetBookingType,
|
||||
/** 目标订单定位类型。 */
|
||||
String targetLocatorType,
|
||||
/** 目标订单定位值。 */
|
||||
String targetLocatorValue,
|
||||
/** 订单归属解析状态。 */
|
||||
String targetResolutionStatus,
|
||||
/** 订单任务状态快照。 */
|
||||
String orderTaskStatus,
|
||||
/** 来源邮件接收 UTC 时间。 */
|
||||
LocalDateTime sourceReceivedAt,
|
||||
/** 乐观锁版本。 */
|
||||
Long version,
|
||||
/** 记录创建 UTC 时间。 */
|
||||
LocalDateTime createdAt,
|
||||
/** 记录更新 UTC 时间。 */
|
||||
LocalDateTime updatedAt
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.common.dto;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Reservation V4 来源通知创建草稿。第一版用于 S10 纯通知入库。
|
||||
*/
|
||||
public record ReservationV4SourceNotificationDraft(
|
||||
/** 酒店 ID。 */
|
||||
String hotelId,
|
||||
/** SourceMessage Inbox 内部 ID。 */
|
||||
Long sourceMessageId,
|
||||
/** AI 回调批次 ID。 */
|
||||
Long aiBatchId,
|
||||
/** AI transition ID,S10 第一版可为空。 */
|
||||
Long aiTransitionId,
|
||||
/** 包级路由码,第一版用于 S10。 */
|
||||
String routeCode,
|
||||
/** 通知状态。 */
|
||||
String notificationStatus,
|
||||
/** 来源通知原始 AI 片段或包级摘要 JSON。 */
|
||||
String rawPayloadJson,
|
||||
/** 来源邮件接收 UTC 时间。 */
|
||||
LocalDateTime sourceReceivedAt,
|
||||
/** 当前写入 UTC 时间。 */
|
||||
LocalDateTime now
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.common.dto;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Reservation V4 来源通知快照。用于后续 S10 通知详情和确认接口。
|
||||
*/
|
||||
public record ReservationV4SourceNotificationSnapshot(
|
||||
/** V4 来源通知 ID。 */
|
||||
Long id,
|
||||
/** 酒店 ID。 */
|
||||
String hotelId,
|
||||
/** SourceMessage Inbox 内部 ID。 */
|
||||
Long sourceMessageId,
|
||||
/** AI 回调批次 ID。 */
|
||||
Long aiBatchId,
|
||||
/** AI transition ID,S10 第一版可为空。 */
|
||||
Long aiTransitionId,
|
||||
/** 包级路由码。 */
|
||||
String routeCode,
|
||||
/** 通知状态。 */
|
||||
String notificationStatus,
|
||||
/** 来源通知原始 AI 片段或包级摘要 JSON。 */
|
||||
String rawPayloadJson,
|
||||
/** 确认处理人标识。 */
|
||||
String ackBy,
|
||||
/** 确认处理 UTC 时间。 */
|
||||
LocalDateTime ackAt,
|
||||
/** 来源邮件接收 UTC 时间。 */
|
||||
LocalDateTime sourceReceivedAt,
|
||||
/** 乐观锁版本。 */
|
||||
Long version,
|
||||
/** 记录创建 UTC 时间。 */
|
||||
LocalDateTime createdAt,
|
||||
/** 记录更新 UTC 时间。 */
|
||||
LocalDateTime updatedAt
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.common.dto;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Reservation V4 任务卡创建草稿。非 event 卡 source_event_index 允许为空,由 Repository 固定写入 0。
|
||||
*/
|
||||
public record ReservationV4TaskCardDraft(
|
||||
/** 酒店 ID。 */
|
||||
String hotelId,
|
||||
/** V4 订单任务 ID。 */
|
||||
Long v4OrderTaskId,
|
||||
/** SourceMessage Inbox 内部 ID。 */
|
||||
Long sourceMessageId,
|
||||
/** AI transition ID,非 event 卡可为空。 */
|
||||
Long aiTransitionId,
|
||||
/** 卡片类型。 */
|
||||
String cardType,
|
||||
/** 业务事件类型,非 event 卡为空。 */
|
||||
String eventType,
|
||||
/** message_events[] 中的一基序号;非 event 卡为空并写入 0。 */
|
||||
Integer sourceEventIndex,
|
||||
/** 同一订单任务内卡片排序。 */
|
||||
Integer cardSortOrder,
|
||||
/** 卡片状态。 */
|
||||
String cardStatus,
|
||||
/** 人工复核状态,非复核卡为空。 */
|
||||
String reviewStatus,
|
||||
/** AI 原始卡片 payload JSON 快照。 */
|
||||
String aiPayloadJson,
|
||||
/** 前端展示用 payload JSON 快照。 */
|
||||
String displayPayloadJson,
|
||||
/** 第一版后端校验错误 JSON。 */
|
||||
String validationErrorsJson,
|
||||
/** 当前写入 UTC 时间。 */
|
||||
LocalDateTime now
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.common.dto;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Reservation V4 任务卡快照。保存 AI 原始卡、展示卡、确认卡和复核结果的当前数据库视图。
|
||||
*/
|
||||
public record ReservationV4TaskCardSnapshot(
|
||||
/** V4 任务卡 ID。 */
|
||||
Long id,
|
||||
/** 酒店 ID。 */
|
||||
String hotelId,
|
||||
/** V4 订单任务 ID。 */
|
||||
Long v4OrderTaskId,
|
||||
/** SourceMessage Inbox 内部 ID。 */
|
||||
Long sourceMessageId,
|
||||
/** AI transition ID,非 event 卡可为空。 */
|
||||
Long aiTransitionId,
|
||||
/** 卡片类型。 */
|
||||
String cardType,
|
||||
/** 业务事件类型,非 event 卡为空。 */
|
||||
String eventType,
|
||||
/** message_events[] 中的一基序号;非 event 卡固定为 0。 */
|
||||
Integer sourceEventIndex,
|
||||
/** 同一订单任务内卡片排序。 */
|
||||
Integer cardSortOrder,
|
||||
/** 卡片状态。 */
|
||||
String cardStatus,
|
||||
/** 人工复核状态,非复核卡为空。 */
|
||||
String reviewStatus,
|
||||
/** AI 原始卡片 payload JSON 快照。 */
|
||||
String aiPayloadJson,
|
||||
/** 前端展示用 payload JSON 快照。 */
|
||||
String displayPayloadJson,
|
||||
/** 用户最终确认 payload JSON。 */
|
||||
String confirmedPayloadJson,
|
||||
/** 人工复核解阻结果 JSON。 */
|
||||
String reviewResolutionJson,
|
||||
/** 第一版后端校验错误 JSON。 */
|
||||
String validationErrorsJson,
|
||||
/** 最终确认人标识。 */
|
||||
String confirmedBy,
|
||||
/** 最终确认 UTC 时间。 */
|
||||
LocalDateTime confirmedAt,
|
||||
/** 乐观锁版本。 */
|
||||
Long version,
|
||||
/** 记录创建 UTC 时间。 */
|
||||
LocalDateTime createdAt,
|
||||
/** 记录更新 UTC 时间。 */
|
||||
LocalDateTime updatedAt
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.common.enums;
|
||||
|
||||
/**
|
||||
* Reservation V4 任务卡状态。
|
||||
*/
|
||||
public enum ReservationV4CardStatus {
|
||||
/** 只读展示卡,不允许用户编辑或确认。 */
|
||||
READONLY,
|
||||
/** 待用户确认的业务卡。 */
|
||||
PENDING_CONFIRM,
|
||||
/** 待人工复核解阻的业务卡。 */
|
||||
REVIEW_REQUIRED,
|
||||
/** 已由用户确认完成的业务卡。 */
|
||||
CONFIRMED
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.common.enums;
|
||||
|
||||
/**
|
||||
* Reservation V4 任务卡类型。
|
||||
*/
|
||||
public enum ReservationV4CardType {
|
||||
/** 来源邮件展示卡。 */
|
||||
SOURCE_MESSAGE_DISPLAY,
|
||||
/** 订单级 Basic Information 卡。 */
|
||||
BASIC_INFORMATION,
|
||||
/** 房型、房晚和房数等客房信息卡。 */
|
||||
ROOM_INFORMATION,
|
||||
/** 预订备注追踪卡。 */
|
||||
TRACE_RESERVATION_NOTES,
|
||||
/** Rooming List 业务卡。 */
|
||||
ROOMING_LIST,
|
||||
/** Payment 业务卡。 */
|
||||
PAYMENT
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.common.enums;
|
||||
|
||||
/**
|
||||
* Reservation V4 来源通知状态。
|
||||
*/
|
||||
public enum ReservationV4NotificationStatus {
|
||||
/** 需要用户确认已读或已处理。 */
|
||||
ACK_REQUIRED,
|
||||
/** 用户已经确认处理。 */
|
||||
ACKED
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.common.enums;
|
||||
|
||||
/**
|
||||
* Reservation V4 订单任务状态快照。
|
||||
*/
|
||||
public enum ReservationV4OrderTaskStatus {
|
||||
/** 订单任务仍有待处理卡片。 */
|
||||
OPEN,
|
||||
/** 订单任务下所有需要处理的卡片均已完成。 */
|
||||
COMPLETED
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.common.enums;
|
||||
|
||||
/**
|
||||
* Reservation V4 订单归属解析状态。
|
||||
*/
|
||||
public enum ReservationV4TargetResolutionStatus {
|
||||
/** 已明确匹配到目标订单或可创建的新订单上下文。 */
|
||||
RESOLVED,
|
||||
/** 缺少足够信息,等待人工确认订单归属。 */
|
||||
UNRESOLVED,
|
||||
/** 匹配到多个候选订单,需要人工处理冲突。 */
|
||||
CONFLICT
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.domain;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Reservation V4 订单任务实体。承载同一 SourceMessage + order_ref 下的订单任务聚合。
|
||||
*/
|
||||
@TableName("workflow_reservation_v4_order_task")
|
||||
public class ReservationV4OrderTaskEntity {
|
||||
|
||||
/** V4 订单任务 ID。 */
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
/** 酒店 ID。 */
|
||||
private String hotelId;
|
||||
/** SourceMessage Inbox 内部 ID。 */
|
||||
private Long sourceMessageId;
|
||||
/** AI 回调批次 ID。 */
|
||||
private Long aiBatchId;
|
||||
/** V4 包内订单引用。 */
|
||||
private String orderRef;
|
||||
/** order_contexts[] 中的一基序号。 */
|
||||
private Integer orderContextIndex;
|
||||
/** 绑定的本地订单投影 ID,归属未解决时可为空。 */
|
||||
private Long orderId;
|
||||
/** 目标订单业务类型,例如 GROUP、FIT。 */
|
||||
private String targetBookingType;
|
||||
/** 目标订单定位类型。 */
|
||||
private String targetLocatorType;
|
||||
/** 目标订单定位值。 */
|
||||
private String targetLocatorValue;
|
||||
/** 订单归属解析状态。 */
|
||||
private String targetResolutionStatus;
|
||||
/** 订单任务状态快照。 */
|
||||
private String orderTaskStatus;
|
||||
/** 来源邮件接收 UTC 时间。 */
|
||||
private LocalDateTime sourceReceivedAt;
|
||||
/** 乐观锁版本。 */
|
||||
private Long version;
|
||||
/** 记录创建 UTC 时间。 */
|
||||
private LocalDateTime createdAt;
|
||||
/** 记录更新 UTC 时间。 */
|
||||
private LocalDateTime updatedAt;
|
||||
/** 逻辑删除 UTC 时间。 */
|
||||
private LocalDateTime logicDeletedAt;
|
||||
/** 逻辑删除原因。 */
|
||||
private String logicDeletedReason;
|
||||
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long id) { this.id = id; }
|
||||
public String getHotelId() { return hotelId; }
|
||||
public void setHotelId(String hotelId) { this.hotelId = hotelId; }
|
||||
public Long getSourceMessageId() { return sourceMessageId; }
|
||||
public void setSourceMessageId(Long sourceMessageId) { this.sourceMessageId = sourceMessageId; }
|
||||
public Long getAiBatchId() { return aiBatchId; }
|
||||
public void setAiBatchId(Long aiBatchId) { this.aiBatchId = aiBatchId; }
|
||||
public String getOrderRef() { return orderRef; }
|
||||
public void setOrderRef(String orderRef) { this.orderRef = orderRef; }
|
||||
public Integer getOrderContextIndex() { return orderContextIndex; }
|
||||
public void setOrderContextIndex(Integer orderContextIndex) { this.orderContextIndex = orderContextIndex; }
|
||||
public Long getOrderId() { return orderId; }
|
||||
public void setOrderId(Long orderId) { this.orderId = orderId; }
|
||||
public String getTargetBookingType() { return targetBookingType; }
|
||||
public void setTargetBookingType(String targetBookingType) { this.targetBookingType = targetBookingType; }
|
||||
public String getTargetLocatorType() { return targetLocatorType; }
|
||||
public void setTargetLocatorType(String targetLocatorType) { this.targetLocatorType = targetLocatorType; }
|
||||
public String getTargetLocatorValue() { return targetLocatorValue; }
|
||||
public void setTargetLocatorValue(String targetLocatorValue) { this.targetLocatorValue = targetLocatorValue; }
|
||||
public String getTargetResolutionStatus() { return targetResolutionStatus; }
|
||||
public void setTargetResolutionStatus(String targetResolutionStatus) { this.targetResolutionStatus = targetResolutionStatus; }
|
||||
public String getOrderTaskStatus() { return orderTaskStatus; }
|
||||
public void setOrderTaskStatus(String orderTaskStatus) { this.orderTaskStatus = orderTaskStatus; }
|
||||
public LocalDateTime getSourceReceivedAt() { return sourceReceivedAt; }
|
||||
public void setSourceReceivedAt(LocalDateTime sourceReceivedAt) { this.sourceReceivedAt = sourceReceivedAt; }
|
||||
public Long getVersion() { return version; }
|
||||
public void setVersion(Long version) { this.version = version; }
|
||||
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||
public LocalDateTime getUpdatedAt() { return updatedAt; }
|
||||
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||
public LocalDateTime getLogicDeletedAt() { return logicDeletedAt; }
|
||||
public void setLogicDeletedAt(LocalDateTime logicDeletedAt) { this.logicDeletedAt = logicDeletedAt; }
|
||||
public String getLogicDeletedReason() { return logicDeletedReason; }
|
||||
public void setLogicDeletedReason(String logicDeletedReason) { this.logicDeletedReason = logicDeletedReason; }
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.domain;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Reservation V4 来源通知实体。第一版用于保存 S10 纯通知,不挂订单任务。
|
||||
*/
|
||||
@TableName("workflow_reservation_v4_source_notification")
|
||||
public class ReservationV4SourceNotificationEntity {
|
||||
|
||||
/** V4 来源通知 ID。 */
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
/** 酒店 ID。 */
|
||||
private String hotelId;
|
||||
/** SourceMessage Inbox 内部 ID。 */
|
||||
private Long sourceMessageId;
|
||||
/** AI 回调批次 ID。 */
|
||||
private Long aiBatchId;
|
||||
/** AI transition ID,S10 第一版可为空。 */
|
||||
private Long aiTransitionId;
|
||||
/** 包级路由码。 */
|
||||
private String routeCode;
|
||||
/** 通知状态。 */
|
||||
private String notificationStatus;
|
||||
/** 来源通知原始 AI 片段或包级摘要 JSON。 */
|
||||
private String rawPayloadJson;
|
||||
/** 确认处理人标识。 */
|
||||
private String ackBy;
|
||||
/** 确认处理 UTC 时间。 */
|
||||
private LocalDateTime ackAt;
|
||||
/** 来源邮件接收 UTC 时间。 */
|
||||
private LocalDateTime sourceReceivedAt;
|
||||
/** 乐观锁版本。 */
|
||||
private Long version;
|
||||
/** 记录创建 UTC 时间。 */
|
||||
private LocalDateTime createdAt;
|
||||
/** 记录更新 UTC 时间。 */
|
||||
private LocalDateTime updatedAt;
|
||||
/** 逻辑删除 UTC 时间。 */
|
||||
private LocalDateTime logicDeletedAt;
|
||||
/** 逻辑删除原因。 */
|
||||
private String logicDeletedReason;
|
||||
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long id) { this.id = id; }
|
||||
public String getHotelId() { return hotelId; }
|
||||
public void setHotelId(String hotelId) { this.hotelId = hotelId; }
|
||||
public Long getSourceMessageId() { return sourceMessageId; }
|
||||
public void setSourceMessageId(Long sourceMessageId) { this.sourceMessageId = sourceMessageId; }
|
||||
public Long getAiBatchId() { return aiBatchId; }
|
||||
public void setAiBatchId(Long aiBatchId) { this.aiBatchId = aiBatchId; }
|
||||
public Long getAiTransitionId() { return aiTransitionId; }
|
||||
public void setAiTransitionId(Long aiTransitionId) { this.aiTransitionId = aiTransitionId; }
|
||||
public String getRouteCode() { return routeCode; }
|
||||
public void setRouteCode(String routeCode) { this.routeCode = routeCode; }
|
||||
public String getNotificationStatus() { return notificationStatus; }
|
||||
public void setNotificationStatus(String notificationStatus) { this.notificationStatus = notificationStatus; }
|
||||
public String getRawPayloadJson() { return rawPayloadJson; }
|
||||
public void setRawPayloadJson(String rawPayloadJson) { this.rawPayloadJson = rawPayloadJson; }
|
||||
public String getAckBy() { return ackBy; }
|
||||
public void setAckBy(String ackBy) { this.ackBy = ackBy; }
|
||||
public LocalDateTime getAckAt() { return ackAt; }
|
||||
public void setAckAt(LocalDateTime ackAt) { this.ackAt = ackAt; }
|
||||
public LocalDateTime getSourceReceivedAt() { return sourceReceivedAt; }
|
||||
public void setSourceReceivedAt(LocalDateTime sourceReceivedAt) { this.sourceReceivedAt = sourceReceivedAt; }
|
||||
public Long getVersion() { return version; }
|
||||
public void setVersion(Long version) { this.version = version; }
|
||||
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||
public LocalDateTime getUpdatedAt() { return updatedAt; }
|
||||
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||
public LocalDateTime getLogicDeletedAt() { return logicDeletedAt; }
|
||||
public void setLogicDeletedAt(LocalDateTime logicDeletedAt) { this.logicDeletedAt = logicDeletedAt; }
|
||||
public String getLogicDeletedReason() { return logicDeletedReason; }
|
||||
public void setLogicDeletedReason(String logicDeletedReason) { this.logicDeletedReason = logicDeletedReason; }
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.domain;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Reservation V4 任务卡实体。承载来源邮件展示卡、Basic Information 卡和具体业务卡。
|
||||
*/
|
||||
@TableName("workflow_reservation_v4_task_card")
|
||||
public class ReservationV4TaskCardEntity {
|
||||
|
||||
/** V4 任务卡 ID。 */
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
/** 酒店 ID。 */
|
||||
private String hotelId;
|
||||
/** V4 订单任务 ID。 */
|
||||
private Long v4OrderTaskId;
|
||||
/** SourceMessage Inbox 内部 ID。 */
|
||||
private Long sourceMessageId;
|
||||
/** AI transition ID,非 event 卡可为空。 */
|
||||
private Long aiTransitionId;
|
||||
/** 卡片类型。 */
|
||||
private String cardType;
|
||||
/** 业务事件类型,非 event 卡为空。 */
|
||||
private String eventType;
|
||||
/** message_events[] 中的一基序号;非 event 卡固定为 0。 */
|
||||
private Integer sourceEventIndex;
|
||||
/** 同一订单任务内卡片排序。 */
|
||||
private Integer cardSortOrder;
|
||||
/** 卡片状态。 */
|
||||
private String cardStatus;
|
||||
/** 人工复核状态,非复核卡为空。 */
|
||||
private String reviewStatus;
|
||||
/** AI 原始卡片 payload JSON 快照。 */
|
||||
private String aiPayloadJson;
|
||||
/** 前端展示用 payload JSON 快照。 */
|
||||
private String displayPayloadJson;
|
||||
/** 用户最终确认 payload JSON。 */
|
||||
private String confirmedPayloadJson;
|
||||
/** 人工复核解阻结果 JSON。 */
|
||||
private String reviewResolutionJson;
|
||||
/** 第一版后端校验错误 JSON。 */
|
||||
private String validationErrorsJson;
|
||||
/** 最终确认人标识。 */
|
||||
private String confirmedBy;
|
||||
/** 最终确认 UTC 时间。 */
|
||||
private LocalDateTime confirmedAt;
|
||||
/** 乐观锁版本。 */
|
||||
private Long version;
|
||||
/** 记录创建 UTC 时间。 */
|
||||
private LocalDateTime createdAt;
|
||||
/** 记录更新 UTC 时间。 */
|
||||
private LocalDateTime updatedAt;
|
||||
/** 逻辑删除 UTC 时间。 */
|
||||
private LocalDateTime logicDeletedAt;
|
||||
/** 逻辑删除原因。 */
|
||||
private String logicDeletedReason;
|
||||
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long id) { this.id = id; }
|
||||
public String getHotelId() { return hotelId; }
|
||||
public void setHotelId(String hotelId) { this.hotelId = hotelId; }
|
||||
public Long getV4OrderTaskId() { return v4OrderTaskId; }
|
||||
public void setV4OrderTaskId(Long v4OrderTaskId) { this.v4OrderTaskId = v4OrderTaskId; }
|
||||
public Long getSourceMessageId() { return sourceMessageId; }
|
||||
public void setSourceMessageId(Long sourceMessageId) { this.sourceMessageId = sourceMessageId; }
|
||||
public Long getAiTransitionId() { return aiTransitionId; }
|
||||
public void setAiTransitionId(Long aiTransitionId) { this.aiTransitionId = aiTransitionId; }
|
||||
public String getCardType() { return cardType; }
|
||||
public void setCardType(String cardType) { this.cardType = cardType; }
|
||||
public String getEventType() { return eventType; }
|
||||
public void setEventType(String eventType) { this.eventType = eventType; }
|
||||
public Integer getSourceEventIndex() { return sourceEventIndex; }
|
||||
public void setSourceEventIndex(Integer sourceEventIndex) { this.sourceEventIndex = sourceEventIndex; }
|
||||
public Integer getCardSortOrder() { return cardSortOrder; }
|
||||
public void setCardSortOrder(Integer cardSortOrder) { this.cardSortOrder = cardSortOrder; }
|
||||
public String getCardStatus() { return cardStatus; }
|
||||
public void setCardStatus(String cardStatus) { this.cardStatus = cardStatus; }
|
||||
public String getReviewStatus() { return reviewStatus; }
|
||||
public void setReviewStatus(String reviewStatus) { this.reviewStatus = reviewStatus; }
|
||||
public String getAiPayloadJson() { return aiPayloadJson; }
|
||||
public void setAiPayloadJson(String aiPayloadJson) { this.aiPayloadJson = aiPayloadJson; }
|
||||
public String getDisplayPayloadJson() { return displayPayloadJson; }
|
||||
public void setDisplayPayloadJson(String displayPayloadJson) { this.displayPayloadJson = displayPayloadJson; }
|
||||
public String getConfirmedPayloadJson() { return confirmedPayloadJson; }
|
||||
public void setConfirmedPayloadJson(String confirmedPayloadJson) { this.confirmedPayloadJson = confirmedPayloadJson; }
|
||||
public String getReviewResolutionJson() { return reviewResolutionJson; }
|
||||
public void setReviewResolutionJson(String reviewResolutionJson) { this.reviewResolutionJson = reviewResolutionJson; }
|
||||
public String getValidationErrorsJson() { return validationErrorsJson; }
|
||||
public void setValidationErrorsJson(String validationErrorsJson) { this.validationErrorsJson = validationErrorsJson; }
|
||||
public String getConfirmedBy() { return confirmedBy; }
|
||||
public void setConfirmedBy(String confirmedBy) { this.confirmedBy = confirmedBy; }
|
||||
public LocalDateTime getConfirmedAt() { return confirmedAt; }
|
||||
public void setConfirmedAt(LocalDateTime confirmedAt) { this.confirmedAt = confirmedAt; }
|
||||
public Long getVersion() { return version; }
|
||||
public void setVersion(Long version) { this.version = version; }
|
||||
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||
public LocalDateTime getUpdatedAt() { return updatedAt; }
|
||||
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||
public LocalDateTime getLogicDeletedAt() { return logicDeletedAt; }
|
||||
public void setLogicDeletedAt(LocalDateTime logicDeletedAt) { this.logicDeletedAt = logicDeletedAt; }
|
||||
public String getLogicDeletedReason() { return logicDeletedReason; }
|
||||
public void setLogicDeletedReason(String logicDeletedReason) { this.logicDeletedReason = logicDeletedReason; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.mapper;
|
||||
|
||||
import cn.nianxx.thhotel.workflows.reservation.domain.ReservationV4OrderTaskEntity;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* Reservation V4 订单任务 Mapper。自带 CRUD 由 MyBatis-Plus 提供。
|
||||
*/
|
||||
@Mapper
|
||||
public interface ReservationV4OrderTaskMapper extends BaseMapper<ReservationV4OrderTaskEntity> {
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.mapper;
|
||||
|
||||
import cn.nianxx.thhotel.workflows.reservation.domain.ReservationV4SourceNotificationEntity;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* Reservation V4 来源通知 Mapper。自带 CRUD 由 MyBatis-Plus 提供。
|
||||
*/
|
||||
@Mapper
|
||||
public interface ReservationV4SourceNotificationMapper extends BaseMapper<ReservationV4SourceNotificationEntity> {
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.mapper;
|
||||
|
||||
import cn.nianxx.thhotel.workflows.reservation.domain.ReservationV4TaskCardEntity;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* Reservation V4 任务卡 Mapper。自带 CRUD 由 MyBatis-Plus 提供。
|
||||
*/
|
||||
@Mapper
|
||||
public interface ReservationV4TaskCardMapper extends BaseMapper<ReservationV4TaskCardEntity> {
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.repository;
|
||||
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4SourceNotificationDraft;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4SourceNotificationSnapshot;
|
||||
import cn.nianxx.thhotel.workflows.reservation.domain.ReservationV4SourceNotificationEntity;
|
||||
import cn.nianxx.thhotel.workflows.reservation.mapper.ReservationV4SourceNotificationMapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
/**
|
||||
* Reservation V4 来源通知 MyBatis-Plus 持久化实现。
|
||||
*/
|
||||
@Repository
|
||||
public class MybatisReservationV4SourceNotificationRepository implements ReservationV4SourceNotificationRepository {
|
||||
|
||||
private final ReservationV4SourceNotificationMapper sourceNotificationMapper;
|
||||
|
||||
/**
|
||||
* 注入来源通知 Mapper,Repository 负责幂等和快照转换。
|
||||
*/
|
||||
public MybatisReservationV4SourceNotificationRepository(
|
||||
ReservationV4SourceNotificationMapper sourceNotificationMapper) {
|
||||
this.sourceNotificationMapper = sourceNotificationMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按来源通知 ID 查询当前快照。
|
||||
*/
|
||||
@Override
|
||||
public Optional<ReservationV4SourceNotificationSnapshot> findSourceNotificationById(
|
||||
String hotelId,
|
||||
Long notificationId) {
|
||||
ReservationV4SourceNotificationEntity entity = sourceNotificationMapper.selectOne(
|
||||
Wrappers.<ReservationV4SourceNotificationEntity>lambdaQuery()
|
||||
.eq(ReservationV4SourceNotificationEntity::getHotelId, hotelId)
|
||||
.eq(ReservationV4SourceNotificationEntity::getId, notificationId)
|
||||
.isNull(ReservationV4SourceNotificationEntity::getLogicDeletedAt)
|
||||
.last("LIMIT 1"));
|
||||
return Optional.ofNullable(entity).map(this::toSnapshot);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 SourceMessage 和 AI 批次查询来源通知,用于幂等判断。
|
||||
*/
|
||||
@Override
|
||||
public Optional<ReservationV4SourceNotificationSnapshot> findSourceNotificationBySourceMessageAndBatch(
|
||||
String hotelId,
|
||||
Long sourceMessageId,
|
||||
Long aiBatchId) {
|
||||
ReservationV4SourceNotificationEntity entity = sourceNotificationMapper.selectOne(
|
||||
Wrappers.<ReservationV4SourceNotificationEntity>lambdaQuery()
|
||||
.eq(ReservationV4SourceNotificationEntity::getHotelId, hotelId)
|
||||
.eq(ReservationV4SourceNotificationEntity::getSourceMessageId, sourceMessageId)
|
||||
.eq(ReservationV4SourceNotificationEntity::getAiBatchId, aiBatchId)
|
||||
.isNull(ReservationV4SourceNotificationEntity::getLogicDeletedAt)
|
||||
.last("LIMIT 1"));
|
||||
return Optional.ofNullable(entity).map(this::toSnapshot);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 SourceMessage 查询来源通知列表,用于后续邮件通知详情和工作台展示。
|
||||
*/
|
||||
@Override
|
||||
public List<ReservationV4SourceNotificationSnapshot> findSourceNotificationsBySourceMessageId(
|
||||
String hotelId,
|
||||
Long sourceMessageId) {
|
||||
return sourceNotificationMapper.selectList(Wrappers.<ReservationV4SourceNotificationEntity>lambdaQuery()
|
||||
.eq(ReservationV4SourceNotificationEntity::getHotelId, hotelId)
|
||||
.eq(ReservationV4SourceNotificationEntity::getSourceMessageId, sourceMessageId)
|
||||
.isNull(ReservationV4SourceNotificationEntity::getLogicDeletedAt)
|
||||
.orderByAsc(ReservationV4SourceNotificationEntity::getSourceReceivedAt)
|
||||
.orderByAsc(ReservationV4SourceNotificationEntity::getId))
|
||||
.stream()
|
||||
.map(this::toSnapshot)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 幂等创建 S10 来源通知;并发重复插入时返回已存在记录。
|
||||
*/
|
||||
@Override
|
||||
public ReservationV4SourceNotificationSnapshot findOrCreateSourceNotification(
|
||||
ReservationV4SourceNotificationDraft draft) {
|
||||
Optional<ReservationV4SourceNotificationSnapshot> existing =
|
||||
findSourceNotificationBySourceMessageAndBatch(draft.hotelId(), draft.sourceMessageId(), draft.aiBatchId());
|
||||
if (existing.isPresent()) {
|
||||
return existing.get();
|
||||
}
|
||||
ReservationV4SourceNotificationEntity entity = new ReservationV4SourceNotificationEntity();
|
||||
entity.setHotelId(draft.hotelId());
|
||||
entity.setSourceMessageId(draft.sourceMessageId());
|
||||
entity.setAiBatchId(draft.aiBatchId());
|
||||
entity.setAiTransitionId(draft.aiTransitionId());
|
||||
entity.setRouteCode(draft.routeCode());
|
||||
entity.setNotificationStatus(draft.notificationStatus());
|
||||
entity.setRawPayloadJson(draft.rawPayloadJson());
|
||||
entity.setSourceReceivedAt(draft.sourceReceivedAt());
|
||||
entity.setVersion(0L);
|
||||
entity.setCreatedAt(draft.now());
|
||||
entity.setUpdatedAt(draft.now());
|
||||
try {
|
||||
sourceNotificationMapper.insert(entity);
|
||||
return toSnapshot(entity);
|
||||
} catch (DuplicateKeyException ex) {
|
||||
return findSourceNotificationBySourceMessageAndBatch(
|
||||
draft.hotelId(),
|
||||
draft.sourceMessageId(),
|
||||
draft.aiBatchId()).orElseThrow(() -> ex);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 version 乐观锁更新来源通知状态,常用于 S10 确认已读 / 已处理。
|
||||
*/
|
||||
@Override
|
||||
public boolean updateNotificationStatusWithVersion(
|
||||
String hotelId,
|
||||
Long notificationId,
|
||||
Long expectedVersion,
|
||||
String notificationStatus,
|
||||
String ackBy,
|
||||
LocalDateTime ackAt) {
|
||||
int updated = sourceNotificationMapper.update(Wrappers.<ReservationV4SourceNotificationEntity>lambdaUpdate()
|
||||
.set(ReservationV4SourceNotificationEntity::getNotificationStatus, notificationStatus)
|
||||
.set(ReservationV4SourceNotificationEntity::getAckBy, ackBy)
|
||||
.set(ReservationV4SourceNotificationEntity::getAckAt, ackAt)
|
||||
.set(ReservationV4SourceNotificationEntity::getUpdatedAt, ackAt)
|
||||
.setSql("version = version + 1")
|
||||
.eq(ReservationV4SourceNotificationEntity::getHotelId, hotelId)
|
||||
.eq(ReservationV4SourceNotificationEntity::getId, notificationId)
|
||||
.eq(ReservationV4SourceNotificationEntity::getVersion, expectedVersion)
|
||||
.isNull(ReservationV4SourceNotificationEntity::getLogicDeletedAt));
|
||||
return updated == 1;
|
||||
}
|
||||
|
||||
private ReservationV4SourceNotificationSnapshot toSnapshot(ReservationV4SourceNotificationEntity entity) {
|
||||
return new ReservationV4SourceNotificationSnapshot(
|
||||
entity.getId(),
|
||||
entity.getHotelId(),
|
||||
entity.getSourceMessageId(),
|
||||
entity.getAiBatchId(),
|
||||
entity.getAiTransitionId(),
|
||||
entity.getRouteCode(),
|
||||
entity.getNotificationStatus(),
|
||||
entity.getRawPayloadJson(),
|
||||
entity.getAckBy(),
|
||||
entity.getAckAt(),
|
||||
entity.getSourceReceivedAt(),
|
||||
entity.getVersion(),
|
||||
entity.getCreatedAt(),
|
||||
entity.getUpdatedAt());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.repository;
|
||||
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4OrderTaskDraft;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4OrderTaskSnapshot;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4TaskCardDraft;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4TaskCardSnapshot;
|
||||
import cn.nianxx.thhotel.workflows.reservation.domain.ReservationV4OrderTaskEntity;
|
||||
import cn.nianxx.thhotel.workflows.reservation.domain.ReservationV4TaskCardEntity;
|
||||
import cn.nianxx.thhotel.workflows.reservation.mapper.ReservationV4OrderTaskMapper;
|
||||
import cn.nianxx.thhotel.workflows.reservation.mapper.ReservationV4TaskCardMapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
/**
|
||||
* Reservation V4 订单任务和多卡 MyBatis-Plus 持久化实现。
|
||||
*/
|
||||
@Repository
|
||||
public class MybatisReservationV4WorkflowRepository implements ReservationV4WorkflowRepository {
|
||||
|
||||
private static final int NON_EVENT_SOURCE_EVENT_INDEX = 0;
|
||||
|
||||
private final ReservationV4OrderTaskMapper orderTaskMapper;
|
||||
private final ReservationV4TaskCardMapper taskCardMapper;
|
||||
|
||||
/**
|
||||
* 注入 V4 Mapper,Repository 负责幂等、乐观锁和快照转换。
|
||||
*/
|
||||
public MybatisReservationV4WorkflowRepository(
|
||||
ReservationV4OrderTaskMapper orderTaskMapper,
|
||||
ReservationV4TaskCardMapper taskCardMapper) {
|
||||
this.orderTaskMapper = orderTaskMapper;
|
||||
this.taskCardMapper = taskCardMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 SourceMessage 和 order_ref 查询 V4 订单任务,用于幂等创建和回调重放判断。
|
||||
*/
|
||||
@Override
|
||||
public Optional<ReservationV4OrderTaskSnapshot> findOrderTaskBySourceMessageAndOrderRef(
|
||||
String hotelId,
|
||||
Long sourceMessageId,
|
||||
String orderRef) {
|
||||
ReservationV4OrderTaskEntity entity = orderTaskMapper.selectOne(
|
||||
Wrappers.<ReservationV4OrderTaskEntity>lambdaQuery()
|
||||
.eq(ReservationV4OrderTaskEntity::getHotelId, hotelId)
|
||||
.eq(ReservationV4OrderTaskEntity::getSourceMessageId, sourceMessageId)
|
||||
.eq(ReservationV4OrderTaskEntity::getOrderRef, orderRef)
|
||||
.isNull(ReservationV4OrderTaskEntity::getLogicDeletedAt)
|
||||
.last("LIMIT 1"));
|
||||
return Optional.ofNullable(entity).map(this::toOrderTaskSnapshot);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按订单任务 ID 查询 V4 订单任务。
|
||||
*/
|
||||
@Override
|
||||
public Optional<ReservationV4OrderTaskSnapshot> findOrderTaskById(String hotelId, Long orderTaskId) {
|
||||
ReservationV4OrderTaskEntity entity = orderTaskMapper.selectOne(
|
||||
Wrappers.<ReservationV4OrderTaskEntity>lambdaQuery()
|
||||
.eq(ReservationV4OrderTaskEntity::getHotelId, hotelId)
|
||||
.eq(ReservationV4OrderTaskEntity::getId, orderTaskId)
|
||||
.isNull(ReservationV4OrderTaskEntity::getLogicDeletedAt)
|
||||
.last("LIMIT 1"));
|
||||
return Optional.ofNullable(entity).map(this::toOrderTaskSnapshot);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 SourceMessage 查询全部 V4 订单任务,保留同一邮件内多个 order_context 的顺序。
|
||||
*/
|
||||
@Override
|
||||
public List<ReservationV4OrderTaskSnapshot> findOrderTasksBySourceMessageId(String hotelId, Long sourceMessageId) {
|
||||
return orderTaskMapper.selectList(Wrappers.<ReservationV4OrderTaskEntity>lambdaQuery()
|
||||
.eq(ReservationV4OrderTaskEntity::getHotelId, hotelId)
|
||||
.eq(ReservationV4OrderTaskEntity::getSourceMessageId, sourceMessageId)
|
||||
.isNull(ReservationV4OrderTaskEntity::getLogicDeletedAt)
|
||||
.orderByAsc(ReservationV4OrderTaskEntity::getOrderContextIndex)
|
||||
.orderByAsc(ReservationV4OrderTaskEntity::getId))
|
||||
.stream()
|
||||
.map(this::toOrderTaskSnapshot)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 幂等创建 V4 订单任务;唯一键冲突时返回已存在记录。
|
||||
*/
|
||||
@Override
|
||||
public ReservationV4OrderTaskSnapshot findOrCreateOrderTask(ReservationV4OrderTaskDraft draft) {
|
||||
Optional<ReservationV4OrderTaskSnapshot> existing = findOrderTaskBySourceMessageAndOrderRef(
|
||||
draft.hotelId(),
|
||||
draft.sourceMessageId(),
|
||||
draft.orderRef());
|
||||
if (existing.isPresent()) {
|
||||
return existing.get();
|
||||
}
|
||||
ReservationV4OrderTaskEntity entity = new ReservationV4OrderTaskEntity();
|
||||
entity.setHotelId(draft.hotelId());
|
||||
entity.setSourceMessageId(draft.sourceMessageId());
|
||||
entity.setAiBatchId(draft.aiBatchId());
|
||||
entity.setOrderRef(draft.orderRef());
|
||||
entity.setOrderContextIndex(requirePositiveOrderContextIndex(draft.orderContextIndex()));
|
||||
entity.setOrderId(draft.orderId());
|
||||
entity.setTargetBookingType(draft.targetBookingType());
|
||||
entity.setTargetLocatorType(draft.targetLocatorType());
|
||||
entity.setTargetLocatorValue(draft.targetLocatorValue());
|
||||
entity.setTargetResolutionStatus(draft.targetResolutionStatus());
|
||||
entity.setOrderTaskStatus(draft.orderTaskStatus());
|
||||
entity.setSourceReceivedAt(draft.sourceReceivedAt());
|
||||
entity.setVersion(0L);
|
||||
entity.setCreatedAt(draft.now());
|
||||
entity.setUpdatedAt(draft.now());
|
||||
try {
|
||||
orderTaskMapper.insert(entity);
|
||||
return toOrderTaskSnapshot(entity);
|
||||
} catch (DuplicateKeyException ex) {
|
||||
return findOrderTaskBySourceMessageAndOrderRef(
|
||||
draft.hotelId(),
|
||||
draft.sourceMessageId(),
|
||||
draft.orderRef()).orElseThrow(() -> ex);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 V4 任务卡;非 event 卡 source_event_index 固定写入 0。
|
||||
*/
|
||||
@Override
|
||||
public ReservationV4TaskCardSnapshot insertTaskCard(ReservationV4TaskCardDraft draft) {
|
||||
ReservationV4TaskCardEntity entity = new ReservationV4TaskCardEntity();
|
||||
entity.setHotelId(draft.hotelId());
|
||||
entity.setV4OrderTaskId(draft.v4OrderTaskId());
|
||||
entity.setSourceMessageId(draft.sourceMessageId());
|
||||
entity.setAiTransitionId(draft.aiTransitionId());
|
||||
entity.setCardType(draft.cardType());
|
||||
entity.setEventType(draft.eventType());
|
||||
entity.setSourceEventIndex(normalizeSourceEventIndex(draft));
|
||||
entity.setCardSortOrder(draft.cardSortOrder());
|
||||
entity.setCardStatus(draft.cardStatus());
|
||||
entity.setReviewStatus(draft.reviewStatus());
|
||||
entity.setAiPayloadJson(draft.aiPayloadJson());
|
||||
entity.setDisplayPayloadJson(draft.displayPayloadJson());
|
||||
entity.setValidationErrorsJson(draft.validationErrorsJson());
|
||||
entity.setVersion(0L);
|
||||
entity.setCreatedAt(draft.now());
|
||||
entity.setUpdatedAt(draft.now());
|
||||
taskCardMapper.insert(entity);
|
||||
return toTaskCardSnapshot(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按任务卡 ID 查询 V4 任务卡。
|
||||
*/
|
||||
@Override
|
||||
public Optional<ReservationV4TaskCardSnapshot> findTaskCardById(String hotelId, Long taskCardId) {
|
||||
ReservationV4TaskCardEntity entity = taskCardMapper.selectOne(
|
||||
Wrappers.<ReservationV4TaskCardEntity>lambdaQuery()
|
||||
.eq(ReservationV4TaskCardEntity::getHotelId, hotelId)
|
||||
.eq(ReservationV4TaskCardEntity::getId, taskCardId)
|
||||
.isNull(ReservationV4TaskCardEntity::getLogicDeletedAt)
|
||||
.last("LIMIT 1"));
|
||||
return Optional.ofNullable(entity).map(this::toTaskCardSnapshot);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询订单任务下全部 V4 任务卡,并按卡片排序和 event index 返回。
|
||||
*/
|
||||
@Override
|
||||
public List<ReservationV4TaskCardSnapshot> findTaskCardsByOrderTaskId(String hotelId, Long orderTaskId) {
|
||||
return taskCardMapper.selectList(Wrappers.<ReservationV4TaskCardEntity>lambdaQuery()
|
||||
.eq(ReservationV4TaskCardEntity::getHotelId, hotelId)
|
||||
.eq(ReservationV4TaskCardEntity::getV4OrderTaskId, orderTaskId)
|
||||
.isNull(ReservationV4TaskCardEntity::getLogicDeletedAt)
|
||||
.orderByAsc(ReservationV4TaskCardEntity::getCardSortOrder)
|
||||
.orderByAsc(ReservationV4TaskCardEntity::getSourceEventIndex)
|
||||
.orderByAsc(ReservationV4TaskCardEntity::getId))
|
||||
.stream()
|
||||
.map(this::toTaskCardSnapshot)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 version 乐观锁更新 V4 任务卡状态。
|
||||
*/
|
||||
@Override
|
||||
public boolean updateTaskCardStatusWithVersion(
|
||||
String hotelId,
|
||||
Long taskCardId,
|
||||
Long expectedVersion,
|
||||
String cardStatus,
|
||||
LocalDateTime now) {
|
||||
int updated = taskCardMapper.update(Wrappers.<ReservationV4TaskCardEntity>lambdaUpdate()
|
||||
.set(ReservationV4TaskCardEntity::getCardStatus, cardStatus)
|
||||
.set(ReservationV4TaskCardEntity::getUpdatedAt, now)
|
||||
.setSql("version = version + 1")
|
||||
.eq(ReservationV4TaskCardEntity::getHotelId, hotelId)
|
||||
.eq(ReservationV4TaskCardEntity::getId, taskCardId)
|
||||
.eq(ReservationV4TaskCardEntity::getVersion, expectedVersion)
|
||||
.isNull(ReservationV4TaskCardEntity::getLogicDeletedAt));
|
||||
return updated == 1;
|
||||
}
|
||||
|
||||
private ReservationV4OrderTaskSnapshot toOrderTaskSnapshot(ReservationV4OrderTaskEntity entity) {
|
||||
return new ReservationV4OrderTaskSnapshot(
|
||||
entity.getId(),
|
||||
entity.getHotelId(),
|
||||
entity.getSourceMessageId(),
|
||||
entity.getAiBatchId(),
|
||||
entity.getOrderRef(),
|
||||
entity.getOrderContextIndex(),
|
||||
entity.getOrderId(),
|
||||
entity.getTargetBookingType(),
|
||||
entity.getTargetLocatorType(),
|
||||
entity.getTargetLocatorValue(),
|
||||
entity.getTargetResolutionStatus(),
|
||||
entity.getOrderTaskStatus(),
|
||||
entity.getSourceReceivedAt(),
|
||||
entity.getVersion(),
|
||||
entity.getCreatedAt(),
|
||||
entity.getUpdatedAt());
|
||||
}
|
||||
|
||||
private Integer requirePositiveOrderContextIndex(Integer orderContextIndex) {
|
||||
if (orderContextIndex == null || orderContextIndex <= 0) {
|
||||
throw new IllegalArgumentException("V4 order_context_index 必须是一基正整数。");
|
||||
}
|
||||
return orderContextIndex;
|
||||
}
|
||||
|
||||
private Integer normalizeSourceEventIndex(ReservationV4TaskCardDraft draft) {
|
||||
boolean eventCard = draft.aiTransitionId() != null || hasText(draft.eventType());
|
||||
Integer sourceEventIndex = draft.sourceEventIndex();
|
||||
if (eventCard) {
|
||||
if (sourceEventIndex == null || sourceEventIndex <= 0) {
|
||||
throw new IllegalArgumentException("V4 event 卡 source_event_index 必须是一基正整数。");
|
||||
}
|
||||
return sourceEventIndex;
|
||||
}
|
||||
if (sourceEventIndex != null && !sourceEventIndex.equals(NON_EVENT_SOURCE_EVENT_INDEX)) {
|
||||
throw new IllegalArgumentException("V4 非 event 卡 source_event_index 必须固定为 0。");
|
||||
}
|
||||
return NON_EVENT_SOURCE_EVENT_INDEX;
|
||||
}
|
||||
|
||||
private boolean hasText(String value) {
|
||||
return value != null && !value.isBlank();
|
||||
}
|
||||
|
||||
private ReservationV4TaskCardSnapshot toTaskCardSnapshot(ReservationV4TaskCardEntity entity) {
|
||||
return new ReservationV4TaskCardSnapshot(
|
||||
entity.getId(),
|
||||
entity.getHotelId(),
|
||||
entity.getV4OrderTaskId(),
|
||||
entity.getSourceMessageId(),
|
||||
entity.getAiTransitionId(),
|
||||
entity.getCardType(),
|
||||
entity.getEventType(),
|
||||
entity.getSourceEventIndex(),
|
||||
entity.getCardSortOrder(),
|
||||
entity.getCardStatus(),
|
||||
entity.getReviewStatus(),
|
||||
entity.getAiPayloadJson(),
|
||||
entity.getDisplayPayloadJson(),
|
||||
entity.getConfirmedPayloadJson(),
|
||||
entity.getReviewResolutionJson(),
|
||||
entity.getValidationErrorsJson(),
|
||||
entity.getConfirmedBy(),
|
||||
entity.getConfirmedAt(),
|
||||
entity.getVersion(),
|
||||
entity.getCreatedAt(),
|
||||
entity.getUpdatedAt());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.repository;
|
||||
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4SourceNotificationDraft;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4SourceNotificationSnapshot;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Reservation V4 来源通知持久化边界。第一版用于 S10 纯通知的幂等创建、查询和确认。
|
||||
*/
|
||||
public interface ReservationV4SourceNotificationRepository {
|
||||
|
||||
/**
|
||||
* 按来源通知 ID 查询当前快照。
|
||||
*/
|
||||
Optional<ReservationV4SourceNotificationSnapshot> findSourceNotificationById(
|
||||
String hotelId,
|
||||
Long notificationId);
|
||||
|
||||
/**
|
||||
* 按 SourceMessage 和 AI 批次查询来源通知,用于幂等判断。
|
||||
*/
|
||||
Optional<ReservationV4SourceNotificationSnapshot> findSourceNotificationBySourceMessageAndBatch(
|
||||
String hotelId,
|
||||
Long sourceMessageId,
|
||||
Long aiBatchId);
|
||||
|
||||
/**
|
||||
* 按 SourceMessage 查询来源通知列表,用于后续邮件通知详情和工作台展示。
|
||||
*/
|
||||
List<ReservationV4SourceNotificationSnapshot> findSourceNotificationsBySourceMessageId(
|
||||
String hotelId,
|
||||
Long sourceMessageId);
|
||||
|
||||
/**
|
||||
* 幂等创建 S10 来源通知;并发重复插入时返回已存在记录。
|
||||
*/
|
||||
ReservationV4SourceNotificationSnapshot findOrCreateSourceNotification(ReservationV4SourceNotificationDraft draft);
|
||||
|
||||
/**
|
||||
* 按 version 乐观锁更新来源通知状态,常用于 S10 确认已读 / 已处理。
|
||||
*/
|
||||
boolean updateNotificationStatusWithVersion(
|
||||
String hotelId,
|
||||
Long notificationId,
|
||||
Long expectedVersion,
|
||||
String notificationStatus,
|
||||
String ackBy,
|
||||
LocalDateTime ackAt);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.repository;
|
||||
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4OrderTaskDraft;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4OrderTaskSnapshot;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4TaskCardDraft;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4TaskCardSnapshot;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Reservation V4 订单任务和多卡持久化边界。Service 不直接依赖 Mapper 或 Entity。
|
||||
*/
|
||||
public interface ReservationV4WorkflowRepository {
|
||||
|
||||
/**
|
||||
* 按 SourceMessage 和 order_ref 查询 V4 订单任务,用于幂等创建和回调重放判断。
|
||||
*/
|
||||
Optional<ReservationV4OrderTaskSnapshot> findOrderTaskBySourceMessageAndOrderRef(
|
||||
String hotelId,
|
||||
Long sourceMessageId,
|
||||
String orderRef);
|
||||
|
||||
/**
|
||||
* 按订单任务 ID 查询 V4 订单任务。
|
||||
*/
|
||||
Optional<ReservationV4OrderTaskSnapshot> findOrderTaskById(String hotelId, Long orderTaskId);
|
||||
|
||||
/**
|
||||
* 按 SourceMessage 查询全部 V4 订单任务,保留同一邮件内多个 order_context 的顺序。
|
||||
*/
|
||||
List<ReservationV4OrderTaskSnapshot> findOrderTasksBySourceMessageId(String hotelId, Long sourceMessageId);
|
||||
|
||||
/**
|
||||
* 幂等创建 V4 订单任务;唯一键冲突时返回已存在记录。
|
||||
*/
|
||||
ReservationV4OrderTaskSnapshot findOrCreateOrderTask(ReservationV4OrderTaskDraft draft);
|
||||
|
||||
/**
|
||||
* 创建 V4 任务卡;非 event 卡 source_event_index 固定写入 0。
|
||||
*/
|
||||
ReservationV4TaskCardSnapshot insertTaskCard(ReservationV4TaskCardDraft draft);
|
||||
|
||||
/**
|
||||
* 按任务卡 ID 查询 V4 任务卡。
|
||||
*/
|
||||
Optional<ReservationV4TaskCardSnapshot> findTaskCardById(String hotelId, Long taskCardId);
|
||||
|
||||
/**
|
||||
* 查询订单任务下全部 V4 任务卡,并按卡片排序和 event index 返回。
|
||||
*/
|
||||
List<ReservationV4TaskCardSnapshot> findTaskCardsByOrderTaskId(String hotelId, Long orderTaskId);
|
||||
|
||||
/**
|
||||
* 按 version 乐观锁更新 V4 任务卡状态。
|
||||
*/
|
||||
boolean updateTaskCardStatusWithVersion(
|
||||
String hotelId,
|
||||
Long taskCardId,
|
||||
Long expectedVersion,
|
||||
String cardStatus,
|
||||
LocalDateTime now);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
CREATE TABLE workflow_reservation_v4_order_task (
|
||||
id BIGINT NOT NULL COMMENT 'V4 订单任务 ID',
|
||||
hotel_id VARCHAR(64) NOT NULL COMMENT '酒店 ID',
|
||||
source_message_id BIGINT NOT NULL COMMENT 'SourceMessage Inbox 内部 ID',
|
||||
ai_batch_id BIGINT NOT NULL COMMENT 'AI 回调批次 ID',
|
||||
order_ref VARCHAR(128) NOT NULL COMMENT 'V4 包内订单引用,同一 source_message_id 下用于聚合同一订单任务',
|
||||
order_context_index INT NOT NULL COMMENT 'order_contexts[] 中的一基序号,用于同一 SourceMessage 下稳定排序',
|
||||
order_id BIGINT NULL COMMENT '绑定的本地订单投影 ID,归属未解决时可为空',
|
||||
target_booking_type VARCHAR(32) NULL COMMENT '目标订单业务类型,例如 GROUP、FIT',
|
||||
target_locator_type VARCHAR(64) NULL COMMENT '目标订单定位类型,例如 GROUP_CODE、BOOKING_CODE、CONFIRMATION_NUMBER',
|
||||
target_locator_value VARCHAR(128) NULL COMMENT '目标订单定位值,未解决时为空',
|
||||
target_resolution_status VARCHAR(32) NOT NULL COMMENT '订单归属解析状态:RESOLVED、UNRESOLVED、CONFLICT',
|
||||
order_task_status VARCHAR(32) NOT NULL COMMENT '订单任务状态快照:OPEN、COMPLETED;阻塞状态由查询实时派生',
|
||||
source_received_at DATETIME(6) NOT NULL COMMENT '来源邮件接收 UTC 时间,用于排序',
|
||||
version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
|
||||
created_at DATETIME(6) NOT NULL COMMENT '记录创建 UTC 时间',
|
||||
updated_at DATETIME(6) NOT NULL COMMENT '记录更新 UTC 时间',
|
||||
logic_deleted_at DATETIME(6) NULL COMMENT '逻辑删除 UTC 时间,第一版不主动使用',
|
||||
logic_deleted_reason VARCHAR(512) NULL COMMENT '逻辑删除原因,第一版不主动使用',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_reservation_v4_order_task_source_ref (hotel_id, source_message_id, order_ref),
|
||||
UNIQUE KEY uk_reservation_v4_order_task_source_index (hotel_id, source_message_id, order_context_index),
|
||||
KEY idx_reservation_v4_order_task_order (hotel_id, order_id, source_received_at),
|
||||
KEY idx_reservation_v4_order_task_status (hotel_id, order_task_status, source_received_at),
|
||||
KEY idx_reservation_v4_order_task_source (hotel_id, source_message_id),
|
||||
KEY idx_reservation_v4_order_task_locator (hotel_id, target_locator_type, target_locator_value)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='Reservation V4 订单任务聚合表';
|
||||
|
||||
CREATE TABLE workflow_reservation_v4_task_card (
|
||||
id BIGINT NOT NULL COMMENT 'V4 任务卡 ID',
|
||||
hotel_id VARCHAR(64) NOT NULL COMMENT '酒店 ID',
|
||||
v4_order_task_id BIGINT NOT NULL COMMENT 'V4 订单任务 ID',
|
||||
source_message_id BIGINT NOT NULL COMMENT 'SourceMessage Inbox 内部 ID',
|
||||
ai_transition_id BIGINT NULL COMMENT 'AI transition ID,非 event 卡可为空',
|
||||
card_type VARCHAR(64) NOT NULL COMMENT '卡片类型,例如 SOURCE_MESSAGE_DISPLAY、BASIC_INFORMATION、ROOM_INFORMATION',
|
||||
event_type VARCHAR(64) NULL COMMENT '业务事件类型,非 event 卡为空',
|
||||
source_event_index INT NOT NULL DEFAULT 0 COMMENT 'message_events[] 中的一基序号;非 event 卡固定为 0',
|
||||
card_sort_order INT NOT NULL COMMENT '同一订单任务内卡片展示和处理排序',
|
||||
card_status VARCHAR(32) NOT NULL COMMENT '卡片状态:READONLY、PENDING_CONFIRM、REVIEW_REQUIRED、CONFIRMED',
|
||||
review_status VARCHAR(32) NULL COMMENT '人工复核状态,非复核卡为空',
|
||||
ai_payload_json LONGTEXT NOT NULL COMMENT 'AI 原始卡片 payload JSON 快照',
|
||||
display_payload_json LONGTEXT NULL COMMENT '前端展示用 payload JSON 快照',
|
||||
confirmed_payload_json LONGTEXT NULL COMMENT '用户最终确认 payload JSON',
|
||||
review_resolution_json LONGTEXT NULL COMMENT '人工复核解阻结果 JSON',
|
||||
validation_errors_json LONGTEXT NULL COMMENT '第一版后端校验错误 JSON',
|
||||
confirmed_by VARCHAR(128) NULL COMMENT '最终确认人标识',
|
||||
confirmed_at DATETIME(6) NULL COMMENT '最终确认 UTC 时间',
|
||||
version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
|
||||
created_at DATETIME(6) NOT NULL COMMENT '记录创建 UTC 时间',
|
||||
updated_at DATETIME(6) NOT NULL COMMENT '记录更新 UTC 时间',
|
||||
logic_deleted_at DATETIME(6) NULL COMMENT '逻辑删除 UTC 时间,第一版不主动使用',
|
||||
logic_deleted_reason VARCHAR(512) NULL COMMENT '逻辑删除原因,第一版不主动使用',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_reservation_v4_task_card_slot (hotel_id, v4_order_task_id, card_sort_order, source_event_index),
|
||||
KEY idx_reservation_v4_task_card_task (hotel_id, v4_order_task_id, card_sort_order),
|
||||
KEY idx_reservation_v4_task_card_source (hotel_id, source_message_id),
|
||||
KEY idx_reservation_v4_task_card_transition (hotel_id, ai_transition_id),
|
||||
KEY idx_reservation_v4_task_card_status (hotel_id, card_status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='Reservation V4 多卡任务表';
|
||||
|
||||
CREATE TABLE workflow_reservation_v4_source_notification (
|
||||
id BIGINT NOT NULL COMMENT 'V4 来源通知 ID',
|
||||
hotel_id VARCHAR(64) NOT NULL COMMENT '酒店 ID',
|
||||
source_message_id BIGINT NOT NULL COMMENT 'SourceMessage Inbox 内部 ID',
|
||||
ai_batch_id BIGINT NOT NULL COMMENT 'AI 回调批次 ID',
|
||||
ai_transition_id BIGINT NULL COMMENT 'AI transition ID,S10 第一版可为空',
|
||||
route_code VARCHAR(32) NOT NULL COMMENT '包级路由码,第一版用于 S10 来源通知',
|
||||
notification_status VARCHAR(32) NOT NULL COMMENT '通知状态:ACK_REQUIRED、ACKED',
|
||||
raw_payload_json LONGTEXT NOT NULL COMMENT '来源通知原始 AI 片段或包级摘要 JSON',
|
||||
ack_by VARCHAR(128) NULL COMMENT '确认处理人标识',
|
||||
ack_at DATETIME(6) NULL COMMENT '确认处理 UTC 时间',
|
||||
source_received_at DATETIME(6) NOT NULL COMMENT '来源邮件接收 UTC 时间,用于排序',
|
||||
version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
|
||||
created_at DATETIME(6) NOT NULL COMMENT '记录创建 UTC 时间',
|
||||
updated_at DATETIME(6) NOT NULL COMMENT '记录更新 UTC 时间',
|
||||
logic_deleted_at DATETIME(6) NULL COMMENT '逻辑删除 UTC 时间,第一版不主动使用',
|
||||
logic_deleted_reason VARCHAR(512) NULL COMMENT '逻辑删除原因,第一版不主动使用',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_reservation_v4_notification_message_batch (hotel_id, source_message_id, ai_batch_id),
|
||||
KEY idx_reservation_v4_notification_status (hotel_id, notification_status, source_received_at),
|
||||
KEY idx_reservation_v4_notification_source (hotel_id, source_message_id),
|
||||
KEY idx_reservation_v4_notification_transition (hotel_id, ai_transition_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='Reservation V4 S10 来源通知表';
|
||||
@@ -0,0 +1,274 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.repository;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import cn.nianxx.thhotel.ThHotelApplication;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4OrderTaskDraft;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4OrderTaskSnapshot;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4SourceNotificationDraft;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4SourceNotificationSnapshot;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4TaskCardDraft;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4TaskCardSnapshot;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4CardStatus;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4CardType;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4NotificationStatus;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4OrderTaskStatus;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4TargetResolutionStatus;
|
||||
import java.time.LocalDateTime;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
@SpringBootTest(
|
||||
classes = ThHotelApplication.class,
|
||||
properties = {
|
||||
"spring.datasource.url=jdbc:h2:mem:m002_v4_repository;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE",
|
||||
"auth.bootstrap.admin.username=",
|
||||
"auth.bootstrap.admin.password=",
|
||||
"auth.bootstrap.default-hotel-id="
|
||||
})
|
||||
@ActiveProfiles("test")
|
||||
class MybatisReservationV4WorkflowRepositoryTest {
|
||||
|
||||
private static final String HOTEL_ID = "HOTEL-TEST";
|
||||
|
||||
@Autowired
|
||||
private ReservationV4WorkflowRepository workflowRepository;
|
||||
|
||||
@Autowired
|
||||
private ReservationV4SourceNotificationRepository sourceNotificationRepository;
|
||||
|
||||
@Test
|
||||
void shouldCreateOrderTaskIdempotentlyBySourceMessageAndOrderRef() {
|
||||
ReservationV4OrderTaskDraft draft = orderTaskDraft(1001L, "order-1", 501L, now());
|
||||
|
||||
ReservationV4OrderTaskSnapshot first = workflowRepository.findOrCreateOrderTask(draft);
|
||||
ReservationV4OrderTaskSnapshot second = workflowRepository.findOrCreateOrderTask(draft);
|
||||
|
||||
assertThat(second.id()).isEqualTo(first.id());
|
||||
assertThat(workflowRepository.findOrderTaskBySourceMessageAndOrderRef(HOTEL_ID, 1001L, "order-1"))
|
||||
.get()
|
||||
.satisfies(snapshot -> {
|
||||
assertThat(snapshot.hotelId()).isEqualTo(HOTEL_ID);
|
||||
assertThat(snapshot.orderRef()).isEqualTo("order-1");
|
||||
assertThat(snapshot.orderContextIndex()).isEqualTo(1);
|
||||
assertThat(snapshot.orderTaskStatus()).isEqualTo(ReservationV4OrderTaskStatus.OPEN.name());
|
||||
assertThat(snapshot.targetResolutionStatus())
|
||||
.isEqualTo(ReservationV4TargetResolutionStatus.RESOLVED.name());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFindOrderTasksBySourceMessageInOrderContextIndexOrder() {
|
||||
workflowRepository.findOrCreateOrderTask(orderTaskDraft(1010L, "order-later", 510L, 2, now()));
|
||||
workflowRepository.findOrCreateOrderTask(orderTaskDraft(1010L, "order-earlier", 511L, 1, now()));
|
||||
|
||||
assertThat(workflowRepository.findOrderTasksBySourceMessageId(HOTEL_ID, 1010L))
|
||||
.extracting(ReservationV4OrderTaskSnapshot::orderRef)
|
||||
.containsExactly("order-earlier", "order-later");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldStoreNonEventCardWithZeroSourceEventIndexAndRejectDuplicateSlot() {
|
||||
ReservationV4OrderTaskSnapshot orderTask = workflowRepository.findOrCreateOrderTask(
|
||||
orderTaskDraft(1002L, "order-2", 502L, now()));
|
||||
ReservationV4TaskCardDraft draft = sourceMessageDisplayCardDraft(orderTask.id(), 1002L, now());
|
||||
|
||||
ReservationV4TaskCardSnapshot card = workflowRepository.insertTaskCard(draft);
|
||||
|
||||
assertThat(card.sourceEventIndex()).isZero();
|
||||
assertThat(card.version()).isZero();
|
||||
assertThatThrownBy(() -> workflowRepository.insertTaskCard(draft))
|
||||
.isInstanceOf(DataIntegrityViolationException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectEventCardWithoutPositiveSourceEventIndex() {
|
||||
ReservationV4OrderTaskSnapshot orderTask = workflowRepository.findOrCreateOrderTask(
|
||||
orderTaskDraft(1011L, "order-11", 511L, now()));
|
||||
|
||||
assertThatThrownBy(() -> workflowRepository.insertTaskCard(
|
||||
eventCardDraftWithoutSourceEventIndex(orderTask.id(), 1011L, 2011L, now())))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("source_event_index");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldUpdateTaskCardStatusWithExpectedVersion() {
|
||||
ReservationV4OrderTaskSnapshot orderTask = workflowRepository.findOrCreateOrderTask(
|
||||
orderTaskDraft(1003L, "order-3", 503L, now()));
|
||||
ReservationV4TaskCardSnapshot card = workflowRepository.insertTaskCard(
|
||||
businessCardDraft(orderTask.id(), 1003L, 2003L, now()));
|
||||
LocalDateTime updatedAt = now().plusMinutes(1);
|
||||
|
||||
boolean updated = workflowRepository.updateTaskCardStatusWithVersion(
|
||||
HOTEL_ID,
|
||||
card.id(),
|
||||
0L,
|
||||
ReservationV4CardStatus.CONFIRMED.name(),
|
||||
updatedAt);
|
||||
|
||||
assertThat(updated).isTrue();
|
||||
ReservationV4TaskCardSnapshot afterUpdate = workflowRepository.findTaskCardById(HOTEL_ID, card.id()).orElseThrow();
|
||||
assertThat(afterUpdate.cardStatus()).isEqualTo(ReservationV4CardStatus.CONFIRMED.name());
|
||||
assertThat(afterUpdate.version()).isEqualTo(1L);
|
||||
assertThat(workflowRepository.updateTaskCardStatusWithVersion(
|
||||
HOTEL_ID,
|
||||
card.id(),
|
||||
0L,
|
||||
ReservationV4CardStatus.PENDING_CONFIRM.name(),
|
||||
updatedAt.plusMinutes(1))).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCreateSourceNotificationIdempotentlyAndAckWithVersion() {
|
||||
ReservationV4SourceNotificationDraft draft = sourceNotificationDraft(1004L, 704L, now());
|
||||
|
||||
ReservationV4SourceNotificationSnapshot first = sourceNotificationRepository.findOrCreateSourceNotification(draft);
|
||||
ReservationV4SourceNotificationSnapshot second = sourceNotificationRepository.findOrCreateSourceNotification(draft);
|
||||
LocalDateTime ackAt = now().plusMinutes(2);
|
||||
|
||||
assertThat(second.id()).isEqualTo(first.id());
|
||||
assertThat(first.notificationStatus()).isEqualTo(ReservationV4NotificationStatus.ACK_REQUIRED.name());
|
||||
assertThat(first.version()).isZero();
|
||||
assertThat(sourceNotificationRepository.updateNotificationStatusWithVersion(
|
||||
HOTEL_ID,
|
||||
first.id(),
|
||||
0L,
|
||||
ReservationV4NotificationStatus.ACKED.name(),
|
||||
"user-1",
|
||||
ackAt)).isTrue();
|
||||
assertThat(sourceNotificationRepository.updateNotificationStatusWithVersion(
|
||||
HOTEL_ID,
|
||||
first.id(),
|
||||
0L,
|
||||
ReservationV4NotificationStatus.ACK_REQUIRED.name(),
|
||||
"user-2",
|
||||
ackAt.plusMinutes(1))).isFalse();
|
||||
|
||||
ReservationV4SourceNotificationSnapshot afterAck =
|
||||
sourceNotificationRepository.findSourceNotificationById(HOTEL_ID, first.id()).orElseThrow();
|
||||
assertThat(afterAck.notificationStatus()).isEqualTo(ReservationV4NotificationStatus.ACKED.name());
|
||||
assertThat(afterAck.ackBy()).isEqualTo("user-1");
|
||||
assertThat(afterAck.ackAt()).isEqualTo(ackAt);
|
||||
assertThat(afterAck.version()).isEqualTo(1L);
|
||||
}
|
||||
|
||||
private ReservationV4OrderTaskDraft orderTaskDraft(
|
||||
Long sourceMessageId,
|
||||
String orderRef,
|
||||
Long orderId,
|
||||
LocalDateTime now) {
|
||||
return orderTaskDraft(sourceMessageId, orderRef, orderId, 1, now);
|
||||
}
|
||||
|
||||
private ReservationV4OrderTaskDraft orderTaskDraft(
|
||||
Long sourceMessageId,
|
||||
String orderRef,
|
||||
Long orderId,
|
||||
Integer orderContextIndex,
|
||||
LocalDateTime now) {
|
||||
return new ReservationV4OrderTaskDraft(
|
||||
HOTEL_ID,
|
||||
sourceMessageId,
|
||||
9001L,
|
||||
orderRef,
|
||||
orderContextIndex,
|
||||
orderId,
|
||||
"GROUP",
|
||||
"GROUP_CODE",
|
||||
"GRP-" + orderRef,
|
||||
ReservationV4TargetResolutionStatus.RESOLVED.name(),
|
||||
ReservationV4OrderTaskStatus.OPEN.name(),
|
||||
now.minusMinutes(5),
|
||||
now);
|
||||
}
|
||||
|
||||
private ReservationV4TaskCardDraft sourceMessageDisplayCardDraft(
|
||||
Long orderTaskId,
|
||||
Long sourceMessageId,
|
||||
LocalDateTime now) {
|
||||
return new ReservationV4TaskCardDraft(
|
||||
HOTEL_ID,
|
||||
orderTaskId,
|
||||
sourceMessageId,
|
||||
null,
|
||||
ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name(),
|
||||
null,
|
||||
null,
|
||||
10,
|
||||
ReservationV4CardStatus.READONLY.name(),
|
||||
null,
|
||||
"{\"source\":\"ai\"}",
|
||||
"{\"display\":\"mail\"}",
|
||||
null,
|
||||
now);
|
||||
}
|
||||
|
||||
private ReservationV4TaskCardDraft eventCardDraftWithoutSourceEventIndex(
|
||||
Long orderTaskId,
|
||||
Long sourceMessageId,
|
||||
Long transitionId,
|
||||
LocalDateTime now) {
|
||||
return new ReservationV4TaskCardDraft(
|
||||
HOTEL_ID,
|
||||
orderTaskId,
|
||||
sourceMessageId,
|
||||
transitionId,
|
||||
ReservationV4CardType.ROOM_INFORMATION.name(),
|
||||
"NEW_BOOKING",
|
||||
null,
|
||||
30,
|
||||
ReservationV4CardStatus.PENDING_CONFIRM.name(),
|
||||
null,
|
||||
"{\"event\":\"new_booking\"}",
|
||||
"{\"display\":\"room\"}",
|
||||
null,
|
||||
now);
|
||||
}
|
||||
|
||||
private ReservationV4TaskCardDraft businessCardDraft(
|
||||
Long orderTaskId,
|
||||
Long sourceMessageId,
|
||||
Long transitionId,
|
||||
LocalDateTime now) {
|
||||
return new ReservationV4TaskCardDraft(
|
||||
HOTEL_ID,
|
||||
orderTaskId,
|
||||
sourceMessageId,
|
||||
transitionId,
|
||||
ReservationV4CardType.ROOM_INFORMATION.name(),
|
||||
"NEW_BOOKING",
|
||||
1,
|
||||
30,
|
||||
ReservationV4CardStatus.PENDING_CONFIRM.name(),
|
||||
null,
|
||||
"{\"event\":\"new_booking\"}",
|
||||
"{\"display\":\"room\"}",
|
||||
null,
|
||||
now);
|
||||
}
|
||||
|
||||
private ReservationV4SourceNotificationDraft sourceNotificationDraft(
|
||||
Long sourceMessageId,
|
||||
Long transitionId,
|
||||
LocalDateTime now) {
|
||||
return new ReservationV4SourceNotificationDraft(
|
||||
HOTEL_ID,
|
||||
sourceMessageId,
|
||||
9004L,
|
||||
transitionId,
|
||||
"S10",
|
||||
ReservationV4NotificationStatus.ACK_REQUIRED.name(),
|
||||
"{\"route_code\":\"S10\"}",
|
||||
now.minusMinutes(3),
|
||||
now);
|
||||
}
|
||||
|
||||
private LocalDateTime now() {
|
||||
return LocalDateTime.of(2026, 7, 18, 8, 30, 0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user