实现V4数据库目录与Lookup接口

This commit is contained in:
andy
2026-07-19 14:36:11 +07:00
parent 145511620c
commit 88f98a0eb6
39 changed files with 2075 additions and 167 deletions

View File

@@ -1,7 +1,7 @@
package cn.nianxx.thhotel.workflows.reservation.common.dto;
/**
* Reservation V4 第一版 Account 目录项。当前为后端固定种子,后续可替换为酒店目录表
* Reservation V4 Account 目录项。当前酒店数据库目录快照转换而来
*
* @param accountCode Account 稳定代码SuperAgent 与前端提交均使用该值
* @param accountName Account 显示名称

View File

@@ -0,0 +1,33 @@
package cn.nianxx.thhotel.workflows.reservation.common.dto;
import java.time.LocalDateTime;
/**
* Reservation V4 Account 目录快照。Repository 输出快照,避免 Service 直接依赖 Entity。
*
* @param id 目录记录 ID
* @param hotelId 酒店 ID
* @param accountCode Account 稳定代码
* @param accountName Account 显示名称
* @param marketCode 关联 Market 代码
* @param sourceCode 关联 Source 代码
* @param status 目录状态
* @param sourceSystem 目录来源
* @param catalogVersion 目录版本
* @param lastSyncedAt 最近同步 UTC 时间
* @param metadataJson 扩展属性 JSON
*/
public record ReservationV4CatalogAccountSnapshot(
Long id,
String hotelId,
String accountCode,
String accountName,
String marketCode,
String sourceCode,
String status,
String sourceSystem,
String catalogVersion,
LocalDateTime lastSyncedAt,
String metadataJson
) {
}

View File

@@ -0,0 +1,33 @@
package cn.nianxx.thhotel.workflows.reservation.common.dto;
import java.time.LocalDateTime;
/**
* Reservation V4 通用代码目录快照。覆盖 Room Type、Rate Code、Market、Source。
*
* @param id 目录记录 ID
* @param hotelId 酒店 ID
* @param catalogType 目录类型
* @param code 稳定代码
* @param displayName 显示名称
* @param status 目录状态
* @param sourceSystem 目录来源
* @param sortOrder 展示排序
* @param catalogVersion 目录版本
* @param lastSyncedAt 最近同步 UTC 时间
* @param metadataJson 扩展属性 JSON
*/
public record ReservationV4CatalogCodeSnapshot(
Long id,
String hotelId,
String catalogType,
String code,
String displayName,
String status,
String sourceSystem,
Integer sortOrder,
String catalogVersion,
LocalDateTime lastSyncedAt,
String metadataJson
) {
}

View File

@@ -0,0 +1,10 @@
package cn.nianxx.thhotel.workflows.reservation.common.enums;
/**
* Reservation V4 目录来源稳定代码。前端可用该值判断目录是否来自固定初始化、后台维护或未来 PMS 同步。
*/
public enum ReservationV4CatalogSourceSystem {
FIXED_SEED_IMPORT,
SYSTEM_MANAGED,
PMS_SYNC
}

View File

@@ -0,0 +1,9 @@
package cn.nianxx.thhotel.workflows.reservation.common.enums;
/**
* Reservation V4 目录状态。第一版 lookup 和字段校验只接受 ACTIVE 目录。
*/
public enum ReservationV4CatalogStatus {
ACTIVE,
DISABLED
}

View File

@@ -0,0 +1,12 @@
package cn.nianxx.thhotel.workflows.reservation.common.enums;
/**
* Reservation V4 目录类型。Account 单独成表,其余稳定代码放在通用目录表。
*/
public enum ReservationV4CatalogType {
ACCOUNT,
ROOM_TYPE,
RATE_CODE,
MARKET,
SOURCE
}

View File

@@ -0,0 +1,19 @@
package cn.nianxx.thhotel.workflows.reservation.common.request;
/**
* Reservation V4 目录 lookup 查询请求。Controller 负责承接 HTTP 参数Service 负责酒店隔离和分页默认值。
*
* @param hotelId 请求酒店 ID未传时使用当前登录用户默认酒店
* @param catalogType 目录类型
* @param keyword 代码或名称关键字
* @param pageNum 页码,从 1 开始
* @param pageSize 每页数量
*/
public record ReservationV4CatalogLookupRequest(
String hotelId,
String catalogType,
String keyword,
Integer pageNum,
Integer pageSize
) {
}

View File

@@ -0,0 +1,41 @@
package cn.nianxx.thhotel.workflows.reservation.common.result;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* Reservation V4 lookup 单条目录返回项。不同目录类型共享该结构,未适用字段返回 null。
*
* @param code 目录稳定代码
* @param displayName 目录显示名称
* @param status 目录状态
* @param catalogSource 单条目录来源
* @param marketCode Account 关联 Market 代码
* @param marketName Account 关联 Market 显示名称
* @param sourceCode Account 关联 Source 代码
* @param sourceName Account 关联 Source 显示名称
* @param adultCapacity 房型成人容量,未配置时为空
* @param pricingAvailable Rate Code 是否已有价格能力,第一版固定为 false
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public record ReservationV4CatalogLookupItemResult(
String code,
@JsonProperty("display_name")
String displayName,
String status,
@JsonProperty("catalog_source")
String catalogSource,
@JsonProperty("market_code")
String marketCode,
@JsonProperty("market_name")
String marketName,
@JsonProperty("source_code")
String sourceCode,
@JsonProperty("source_name")
String sourceName,
@JsonProperty("adult_capacity")
Integer adultCapacity,
@JsonProperty("pricing_available")
Boolean pricingAvailable
) {
}

View File

@@ -0,0 +1,32 @@
package cn.nianxx.thhotel.workflows.reservation.common.result;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
/**
* Reservation V4 lookup 统一 wrapper。前端所有目录下拉都使用同一层级读取。
*
* @param hotelId 酒店 ID
* @param catalogType 目录类型
* @param catalogSource 本页目录主要来源
* @param catalogVersion 本页目录版本
* @param stale 是否为过期或兜底数据
* @param items 目录项列表
* @param page 分页元数据
* @param warnings 安全提示或兜底提示
*/
public record ReservationV4CatalogLookupResult(
@JsonProperty("hotel_id")
String hotelId,
@JsonProperty("catalog_type")
String catalogType,
@JsonProperty("catalog_source")
String catalogSource,
@JsonProperty("catalog_version")
String catalogVersion,
boolean stale,
List<ReservationV4CatalogLookupItemResult> items,
ReservationPaginationResult page,
List<String> warnings
) {
}

View File

@@ -16,6 +16,7 @@ import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice(assignableTypes = {
ReservationTaskController.class,
ReservationFrontendQueryController.class,
ReservationV4CatalogLookupController.class,
ReservationV4QueryController.class,
ReservationV4CommandController.class,
ReservationDemoDataController.class,

View File

@@ -0,0 +1,87 @@
package cn.nianxx.thhotel.workflows.reservation.control;
import cn.nianxx.thhotel.platform.access.common.enums.PlatformPermissionCode;
import cn.nianxx.thhotel.platform.security.service.FrontendAuthorizationService;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4CatalogLookupRequest;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4CatalogLookupResult;
import cn.nianxx.thhotel.workflows.reservation.service.ReservationV4CatalogLookupService;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* Reservation V4 目录 lookup Controller。为前端字段控件提供 Account、Room Type、Rate Code 下拉数据。
*/
@RestController
@RequestMapping("/api/reservation/lookups")
public class ReservationV4CatalogLookupController {
private final ReservationV4CatalogLookupService lookupService;
private final FrontendAuthorizationService authorizationService;
/**
* 注入目录 lookup 服务和前端鉴权服务Controller 只负责 HTTP 边界和权限入口。
*/
public ReservationV4CatalogLookupController(
ReservationV4CatalogLookupService lookupService,
FrontendAuthorizationService authorizationService) {
this.lookupService = lookupService;
this.authorizationService = authorizationService;
}
/**
* 查询 Account 目录,用于 Basic Information 的 Account 选择控件。
*/
@GetMapping(value = "/accounts", produces = MediaType.APPLICATION_JSON_VALUE)
public ReservationV4CatalogLookupResult listAccounts(
@RequestParam(name = "hotel_id", required = false) String hotelId,
@RequestParam(required = false) String keyword,
@RequestParam(name = "page_num", required = false) Integer pageNum,
@RequestParam(name = "page_size", required = false) Integer pageSize) {
authorizationService.requirePermission(PlatformPermissionCode.RESERVATION_TASK_READ.name());
return lookupService.listAccounts(new ReservationV4CatalogLookupRequest(
hotelId,
"ACCOUNT",
keyword,
pageNum,
pageSize));
}
/**
* 查询 Room Type 目录,用于房型选择控件和 V4 字段校验。
*/
@GetMapping(value = "/room-types", produces = MediaType.APPLICATION_JSON_VALUE)
public ReservationV4CatalogLookupResult listRoomTypes(
@RequestParam(name = "hotel_id", required = false) String hotelId,
@RequestParam(required = false) String keyword,
@RequestParam(name = "page_num", required = false) Integer pageNum,
@RequestParam(name = "page_size", required = false) Integer pageSize) {
authorizationService.requirePermission(PlatformPermissionCode.RESERVATION_TASK_READ.name());
return lookupService.listRoomTypes(new ReservationV4CatalogLookupRequest(
hotelId,
"ROOM_TYPE",
keyword,
pageNum,
pageSize));
}
/**
* 查询 Rate Code 目录,用于 Rate Code 选择控件和 V4 字段校验。
*/
@GetMapping(value = "/rate-codes", produces = MediaType.APPLICATION_JSON_VALUE)
public ReservationV4CatalogLookupResult listRateCodes(
@RequestParam(name = "hotel_id", required = false) String hotelId,
@RequestParam(required = false) String keyword,
@RequestParam(name = "page_num", required = false) Integer pageNum,
@RequestParam(name = "page_size", required = false) Integer pageSize) {
authorizationService.requirePermission(PlatformPermissionCode.RESERVATION_TASK_READ.name());
return lookupService.listRateCodes(new ReservationV4CatalogLookupRequest(
hotelId,
"RATE_CODE",
keyword,
pageNum,
pageSize));
}
}

View File

@@ -0,0 +1,84 @@
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 Account 目录实体。承载酒店维度的客户/旅行社 Account 代码。
*/
@TableName("workflow_reservation_catalog_account")
public class ReservationCatalogAccountEntity {
/** 目录记录 ID。 */
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/** 酒店 ID。 */
private String hotelId;
/** Account 稳定代码。 */
private String accountCode;
/** Account 显示名称。 */
private String accountName;
/** Account 关联 Market 代码。 */
private String marketCode;
/** Account 关联 Source 代码。 */
private String sourceCode;
/** 目录状态。 */
private String status;
/** 目录来源。 */
private String sourceSystem;
/** 外部系统 Account ID。 */
private String externalAccountId;
/** 目录版本。 */
private String catalogVersion;
/** 最近同步 UTC 时间。 */
private LocalDateTime lastSyncedAt;
/** Account 扩展属性 JSON。 */
private String metadataJson;
/** 乐观锁版本。 */
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 String getAccountCode() { return accountCode; }
public void setAccountCode(String accountCode) { this.accountCode = accountCode; }
public String getAccountName() { return accountName; }
public void setAccountName(String accountName) { this.accountName = accountName; }
public String getMarketCode() { return marketCode; }
public void setMarketCode(String marketCode) { this.marketCode = marketCode; }
public String getSourceCode() { return sourceCode; }
public void setSourceCode(String sourceCode) { this.sourceCode = sourceCode; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public String getSourceSystem() { return sourceSystem; }
public void setSourceSystem(String sourceSystem) { this.sourceSystem = sourceSystem; }
public String getExternalAccountId() { return externalAccountId; }
public void setExternalAccountId(String externalAccountId) { this.externalAccountId = externalAccountId; }
public String getCatalogVersion() { return catalogVersion; }
public void setCatalogVersion(String catalogVersion) { this.catalogVersion = catalogVersion; }
public LocalDateTime getLastSyncedAt() { return lastSyncedAt; }
public void setLastSyncedAt(LocalDateTime lastSyncedAt) { this.lastSyncedAt = lastSyncedAt; }
public String getMetadataJson() { return metadataJson; }
public void setMetadataJson(String metadataJson) { this.metadataJson = metadataJson; }
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; }
}

View File

@@ -0,0 +1,84 @@
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 通用代码目录实体。承载 Room Type、Rate Code、Market、Source 等酒店目录。
*/
@TableName("workflow_reservation_catalog_code")
public class ReservationCatalogCodeEntity {
/** 目录记录 ID。 */
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/** 酒店 ID。 */
private String hotelId;
/** 目录类型。 */
private String catalogType;
/** 目录稳定代码。 */
private String code;
/** 目录显示名称。 */
private String displayName;
/** 目录状态。 */
private String status;
/** 目录来源。 */
private String sourceSystem;
/** 外部系统目录 ID。 */
private String externalId;
/** 展示排序。 */
private Integer sortOrder;
/** 目录版本。 */
private String catalogVersion;
/** 最近同步 UTC 时间。 */
private LocalDateTime lastSyncedAt;
/** 目录扩展属性 JSON。 */
private String metadataJson;
/** 乐观锁版本。 */
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 String getCatalogType() { return catalogType; }
public void setCatalogType(String catalogType) { this.catalogType = catalogType; }
public String getCode() { return code; }
public void setCode(String code) { this.code = code; }
public String getDisplayName() { return displayName; }
public void setDisplayName(String displayName) { this.displayName = displayName; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public String getSourceSystem() { return sourceSystem; }
public void setSourceSystem(String sourceSystem) { this.sourceSystem = sourceSystem; }
public String getExternalId() { return externalId; }
public void setExternalId(String externalId) { this.externalId = externalId; }
public Integer getSortOrder() { return sortOrder; }
public void setSortOrder(Integer sortOrder) { this.sortOrder = sortOrder; }
public String getCatalogVersion() { return catalogVersion; }
public void setCatalogVersion(String catalogVersion) { this.catalogVersion = catalogVersion; }
public LocalDateTime getLastSyncedAt() { return lastSyncedAt; }
public void setLastSyncedAt(LocalDateTime lastSyncedAt) { this.lastSyncedAt = lastSyncedAt; }
public String getMetadataJson() { return metadataJson; }
public void setMetadataJson(String metadataJson) { this.metadataJson = metadataJson; }
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; }
}

View File

@@ -0,0 +1,12 @@
package cn.nianxx.thhotel.workflows.reservation.mapper;
import cn.nianxx.thhotel.workflows.reservation.domain.ReservationCatalogAccountEntity;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
/**
* Reservation Account 目录 Mapper。自带 CRUD 由 MyBatis-Plus 提供。
*/
@Mapper
public interface ReservationCatalogAccountMapper extends BaseMapper<ReservationCatalogAccountEntity> {
}

View File

@@ -0,0 +1,12 @@
package cn.nianxx.thhotel.workflows.reservation.mapper;
import cn.nianxx.thhotel.workflows.reservation.domain.ReservationCatalogCodeEntity;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
/**
* Reservation 通用代码目录 Mapper。自带 CRUD 由 MyBatis-Plus 提供。
*/
@Mapper
public interface ReservationCatalogCodeMapper extends BaseMapper<ReservationCatalogCodeEntity> {
}

View File

@@ -0,0 +1,171 @@
package cn.nianxx.thhotel.workflows.reservation.repository;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationPageSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4CatalogAccountSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4CatalogCodeSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4CatalogStatus;
import cn.nianxx.thhotel.workflows.reservation.domain.ReservationCatalogAccountEntity;
import cn.nianxx.thhotel.workflows.reservation.domain.ReservationCatalogCodeEntity;
import cn.nianxx.thhotel.workflows.reservation.mapper.ReservationCatalogAccountMapper;
import cn.nianxx.thhotel.workflows.reservation.mapper.ReservationCatalogCodeMapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import java.util.List;
import java.util.Optional;
import org.springframework.stereotype.Repository;
/**
* Reservation V4 目录 MyBatis-Plus 持久化实现。所有读取默认排除逻辑删除和非 ACTIVE 目录。
*/
@Repository
public class MybatisReservationV4CatalogRepository implements ReservationV4CatalogRepository {
private final ReservationCatalogAccountMapper accountMapper;
private final ReservationCatalogCodeMapper codeMapper;
/**
* 注入目录 MapperRepository 统一处理查询条件和快照转换。
*/
public MybatisReservationV4CatalogRepository(
ReservationCatalogAccountMapper accountMapper,
ReservationCatalogCodeMapper codeMapper) {
this.accountMapper = accountMapper;
this.codeMapper = codeMapper;
}
/**
* 按酒店和 Account Code 查询 ACTIVE Account 目录。
*/
@Override
public Optional<ReservationV4CatalogAccountSnapshot> findActiveAccount(String hotelId, String accountCode) {
String normalizedCode = trimToNull(accountCode);
if (trimToNull(hotelId) == null || normalizedCode == null) {
return Optional.empty();
}
ReservationCatalogAccountEntity entity = accountMapper.selectOne(
Wrappers.<ReservationCatalogAccountEntity>lambdaQuery()
.eq(ReservationCatalogAccountEntity::getHotelId, hotelId)
.eq(ReservationCatalogAccountEntity::getAccountCode, normalizedCode)
.eq(ReservationCatalogAccountEntity::getStatus, ReservationV4CatalogStatus.ACTIVE.name())
.isNull(ReservationCatalogAccountEntity::getLogicDeletedAt)
.last("LIMIT 1"));
return Optional.ofNullable(entity).map(this::toAccountSnapshot);
}
/**
* 按酒店、目录类型和代码查询 ACTIVE 通用目录。
*/
@Override
public Optional<ReservationV4CatalogCodeSnapshot> findActiveCatalogCode(
String hotelId,
String catalogType,
String code) {
String normalizedType = trimToNull(catalogType);
String normalizedCode = trimToNull(code);
if (trimToNull(hotelId) == null || normalizedType == null || normalizedCode == null) {
return Optional.empty();
}
ReservationCatalogCodeEntity entity = codeMapper.selectOne(
Wrappers.<ReservationCatalogCodeEntity>lambdaQuery()
.eq(ReservationCatalogCodeEntity::getHotelId, hotelId)
.eq(ReservationCatalogCodeEntity::getCatalogType, normalizedType)
.eq(ReservationCatalogCodeEntity::getCode, normalizedCode)
.eq(ReservationCatalogCodeEntity::getStatus, ReservationV4CatalogStatus.ACTIVE.name())
.isNull(ReservationCatalogCodeEntity::getLogicDeletedAt)
.last("LIMIT 1"));
return Optional.ofNullable(entity).map(this::toCodeSnapshot);
}
/**
* 分页查询 Account 目录,用于前端 lookup 下拉。
*/
@Override
public ReservationPageSnapshot<ReservationV4CatalogAccountSnapshot> queryActiveAccounts(
String hotelId,
String keyword,
int pageNum,
int pageSize) {
LambdaQueryWrapper<ReservationCatalogAccountEntity> query = Wrappers.<ReservationCatalogAccountEntity>lambdaQuery()
.eq(ReservationCatalogAccountEntity::getHotelId, hotelId)
.eq(ReservationCatalogAccountEntity::getStatus, ReservationV4CatalogStatus.ACTIVE.name())
.isNull(ReservationCatalogAccountEntity::getLogicDeletedAt)
.and(trimToNull(keyword) != null, wrapper -> wrapper
.like(ReservationCatalogAccountEntity::getAccountCode, trimToNull(keyword))
.or()
.like(ReservationCatalogAccountEntity::getAccountName, trimToNull(keyword)))
.orderByAsc(ReservationCatalogAccountEntity::getAccountCode)
.orderByAsc(ReservationCatalogAccountEntity::getId);
Page<ReservationCatalogAccountEntity> page = accountMapper.selectPage(Page.of(pageNum, pageSize), query);
List<ReservationV4CatalogAccountSnapshot> items = page.getRecords().stream()
.map(this::toAccountSnapshot)
.toList();
return new ReservationPageSnapshot<>(items, page.getTotal(), pageNum, pageSize);
}
/**
* 分页查询通用代码目录,用于前端 Room Type、Rate Code lookup 下拉。
*/
@Override
public ReservationPageSnapshot<ReservationV4CatalogCodeSnapshot> queryActiveCatalogCodes(
String hotelId,
String catalogType,
String keyword,
int pageNum,
int pageSize) {
LambdaQueryWrapper<ReservationCatalogCodeEntity> query = Wrappers.<ReservationCatalogCodeEntity>lambdaQuery()
.eq(ReservationCatalogCodeEntity::getHotelId, hotelId)
.eq(ReservationCatalogCodeEntity::getCatalogType, catalogType)
.eq(ReservationCatalogCodeEntity::getStatus, ReservationV4CatalogStatus.ACTIVE.name())
.isNull(ReservationCatalogCodeEntity::getLogicDeletedAt)
.and(trimToNull(keyword) != null, wrapper -> wrapper
.like(ReservationCatalogCodeEntity::getCode, trimToNull(keyword))
.or()
.like(ReservationCatalogCodeEntity::getDisplayName, trimToNull(keyword)))
.orderByAsc(ReservationCatalogCodeEntity::getSortOrder)
.orderByAsc(ReservationCatalogCodeEntity::getCode)
.orderByAsc(ReservationCatalogCodeEntity::getId);
Page<ReservationCatalogCodeEntity> page = codeMapper.selectPage(Page.of(pageNum, pageSize), query);
List<ReservationV4CatalogCodeSnapshot> items = page.getRecords().stream()
.map(this::toCodeSnapshot)
.toList();
return new ReservationPageSnapshot<>(items, page.getTotal(), pageNum, pageSize);
}
private ReservationV4CatalogAccountSnapshot toAccountSnapshot(ReservationCatalogAccountEntity entity) {
return new ReservationV4CatalogAccountSnapshot(
entity.getId(),
entity.getHotelId(),
entity.getAccountCode(),
entity.getAccountName(),
entity.getMarketCode(),
entity.getSourceCode(),
entity.getStatus(),
entity.getSourceSystem(),
entity.getCatalogVersion(),
entity.getLastSyncedAt(),
entity.getMetadataJson());
}
private ReservationV4CatalogCodeSnapshot toCodeSnapshot(ReservationCatalogCodeEntity entity) {
return new ReservationV4CatalogCodeSnapshot(
entity.getId(),
entity.getHotelId(),
entity.getCatalogType(),
entity.getCode(),
entity.getDisplayName(),
entity.getStatus(),
entity.getSourceSystem(),
entity.getSortOrder(),
entity.getCatalogVersion(),
entity.getLastSyncedAt(),
entity.getMetadataJson());
}
private String trimToNull(String value) {
if (value == null || value.trim().isEmpty()) {
return null;
}
return value.trim();
}
}

View File

@@ -0,0 +1,41 @@
package cn.nianxx.thhotel.workflows.reservation.repository;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationPageSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4CatalogAccountSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4CatalogCodeSnapshot;
import java.util.Optional;
/**
* Reservation V4 目录持久化边界。封装 Account、Room Type、Rate Code 等数据库目录读取。
*/
public interface ReservationV4CatalogRepository {
/**
* 按酒店和 Account Code 查询 ACTIVE Account 目录。
*/
Optional<ReservationV4CatalogAccountSnapshot> findActiveAccount(String hotelId, String accountCode);
/**
* 按酒店、目录类型和代码查询 ACTIVE 通用目录。
*/
Optional<ReservationV4CatalogCodeSnapshot> findActiveCatalogCode(String hotelId, String catalogType, String code);
/**
* 分页查询 Account 目录,用于前端 lookup 下拉。
*/
ReservationPageSnapshot<ReservationV4CatalogAccountSnapshot> queryActiveAccounts(
String hotelId,
String keyword,
int pageNum,
int pageSize);
/**
* 分页查询通用代码目录,用于前端 Room Type、Rate Code lookup 下拉。
*/
ReservationPageSnapshot<ReservationV4CatalogCodeSnapshot> queryActiveCatalogCodes(
String hotelId,
String catalogType,
String keyword,
int pageNum,
int pageSize);
}

View File

@@ -0,0 +1,25 @@
package cn.nianxx.thhotel.workflows.reservation.service;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4CatalogLookupRequest;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4CatalogLookupResult;
/**
* Reservation V4 目录 lookup 查询服务。面向前端下拉、选择控件提供酒店隔离后的目录数据。
*/
public interface ReservationV4CatalogLookupService {
/**
* 查询 Account 目录下拉数据。
*/
ReservationV4CatalogLookupResult listAccounts(ReservationV4CatalogLookupRequest request);
/**
* 查询 Room Type 目录下拉数据。
*/
ReservationV4CatalogLookupResult listRoomTypes(ReservationV4CatalogLookupRequest request);
/**
* 查询 Rate Code 目录下拉数据。
*/
ReservationV4CatalogLookupResult listRateCodes(ReservationV4CatalogLookupRequest request);
}

View File

@@ -4,22 +4,22 @@ import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4AccountCa
import java.util.Optional;
/**
* Reservation V4 第一版目录服务。封装 Account、房型和 Rate Code 的目录边界。
* Reservation V4 目录服务。封装 Account、房型和 Rate Code 的酒店维度目录边界。
*/
public interface ReservationV4DirectoryService {
/**
* 按 Account Code 查询目录项;不存在时返回空,用于触发人工复核或字段校验错误。
* 按酒店和 Account Code 查询目录项;不存在时返回空,用于触发人工复核或字段校验错误。
*/
Optional<ReservationV4AccountCatalogItem> findAccount(String accountCode);
Optional<ReservationV4AccountCatalogItem> findAccount(String hotelId, String accountCode);
/**
* 判断房型代码是否在第一版固定目录中;空值由业务字段必填规则处理。
* 判断房型代码是否在当前酒店目录中;空值由业务字段必填规则处理。
*/
boolean isKnownRoomTypeCode(String roomTypeCode);
boolean isKnownRoomTypeCode(String hotelId, String roomTypeCode);
/**
* 判断 Rate Code 是否在第一版固定目录中;空值由业务字段必填规则处理。
* 判断 Rate Code 是否在当前酒店目录中;空值由业务字段必填规则处理。
*/
boolean isKnownRateCode(String rateCode);
boolean isKnownRateCode(String hotelId, String rateCode);
}

View File

@@ -1,70 +0,0 @@
package cn.nianxx.thhotel.workflows.reservation.service.impl;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4AccountCatalogItem;
import cn.nianxx.thhotel.workflows.reservation.service.ReservationV4DirectoryService;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import org.springframework.stereotype.Service;
/**
* Reservation V4 固定种子目录实现。开发阶段先用代码种子,后续可替换为数据库目录。
*/
@Service
public class FixedReservationV4DirectoryServiceImpl implements ReservationV4DirectoryService {
private static final Map<String, ReservationV4AccountCatalogItem> ACCOUNT_CATALOG = Map.of(
"QBD_TRAVEL", new ReservationV4AccountCatalogItem(
"QBD_TRAVEL",
"Q.B.D. TRAVEL GROUP CO., LTD",
"LEISURE",
"TRAVEL_AGENT"),
"LIAN_TAI", new ReservationV4AccountCatalogItem(
"LIAN_TAI",
"LIAN TAI TRAVEL (THAILAND) CO., LTD.",
"LEISURE",
"TRAVEL_AGENT"),
"HANATOUR_TD", new ReservationV4AccountCatalogItem(
"HANATOUR_TD",
"HANATOUR TD CO., LTD.",
"LEISURE",
"TRAVEL_AGENT")
);
private static final Set<String> ROOM_TYPE_CODES = Set.of("TWN", "KING", "DBL", "SGL", "TRP", "RM1", "RM2", "RM3");
private static final Set<String> RATE_CODES = Set.of("BAR", "RACK", "PACKAGE", "GROUP", "FIT");
/**
* 按 Account Code 查询固定种子目录,比较保持大小写敏感。
*/
@Override
public Optional<ReservationV4AccountCatalogItem> findAccount(String accountCode) {
String normalized = trimToNull(accountCode);
return normalized == null ? Optional.empty() : Optional.ofNullable(ACCOUNT_CATALOG.get(normalized));
}
/**
* 判断房型代码是否存在于第一版固定目录。
*/
@Override
public boolean isKnownRoomTypeCode(String roomTypeCode) {
String normalized = trimToNull(roomTypeCode);
return normalized != null && ROOM_TYPE_CODES.contains(normalized);
}
/**
* 判断 Rate Code 是否存在于第一版固定目录。
*/
@Override
public boolean isKnownRateCode(String rateCode) {
String normalized = trimToNull(rateCode);
return normalized != null && RATE_CODES.contains(normalized);
}
private String trimToNull(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
}

View File

@@ -0,0 +1,296 @@
package cn.nianxx.thhotel.workflows.reservation.service.impl;
import cn.nianxx.thhotel.platform.hotel.service.HotelContextException;
import cn.nianxx.thhotel.platform.hotel.service.HotelContextService;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationPageSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4CatalogAccountSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4CatalogCodeSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4CatalogType;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4CatalogLookupRequest;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationPaginationResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4CatalogLookupItemResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4CatalogLookupResult;
import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4CatalogRepository;
import cn.nianxx.thhotel.workflows.reservation.service.ReservationV4CatalogLookupService;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.List;
import org.springframework.stereotype.Service;
/**
* Reservation V4 目录 lookup 查询服务实现。查询数据库目录并转换为前端统一 wrapper。
*/
@Service
public class ReservationV4CatalogLookupServiceImpl implements ReservationV4CatalogLookupService {
private static final int DEFAULT_PAGE_NUM = 1;
private static final int DEFAULT_PAGE_SIZE = 20;
private static final int MAX_PAGE_NUM = 1000;
private static final int MAX_PAGE_SIZE = 100;
private static final String EMPTY_CATALOG_SOURCE = "DATABASE_EMPTY";
private static final String EMPTY_CATALOG_VERSION = "unknown";
private final ReservationV4CatalogRepository catalogRepository;
private final HotelContextService hotelContextService;
private final ObjectMapper objectMapper;
/**
* 注入目录 Repository、酒店上下文和 JSON 解析器Service 负责权限后的酒店隔离和响应组装。
*/
public ReservationV4CatalogLookupServiceImpl(
ReservationV4CatalogRepository catalogRepository,
HotelContextService hotelContextService,
ObjectMapper objectMapper) {
this.catalogRepository = catalogRepository;
this.hotelContextService = hotelContextService;
this.objectMapper = objectMapper;
}
/**
* 查询 Account 目录下拉数据。
*/
@Override
public ReservationV4CatalogLookupResult listAccounts(ReservationV4CatalogLookupRequest request) {
String hotelId = resolveHotelId(request == null ? null : request.hotelId());
int pageNum = normalizePageNum(request == null ? null : request.pageNum());
int pageSize = normalizePageSize(request == null ? null : request.pageSize());
ReservationPageSnapshot<ReservationV4CatalogAccountSnapshot> page = catalogRepository.queryActiveAccounts(
hotelId,
request == null ? null : request.keyword(),
pageNum,
pageSize);
List<ReservationV4CatalogLookupItemResult> items = page.items().stream()
.map(this::accountItem)
.toList();
return new ReservationV4CatalogLookupResult(
hotelId,
ReservationV4CatalogType.ACCOUNT.name(),
sourceSystem(page.items()),
catalogVersion(page.items()),
false,
items,
new ReservationPaginationResult(page.pageNum(), page.pageSize(), page.total()),
warnings(page.items()));
}
/**
* 查询 Room Type 目录下拉数据。
*/
@Override
public ReservationV4CatalogLookupResult listRoomTypes(ReservationV4CatalogLookupRequest request) {
return listCatalogCodes(request, ReservationV4CatalogType.ROOM_TYPE);
}
/**
* 查询 Rate Code 目录下拉数据。
*/
@Override
public ReservationV4CatalogLookupResult listRateCodes(ReservationV4CatalogLookupRequest request) {
return listCatalogCodes(request, ReservationV4CatalogType.RATE_CODE);
}
/**
* 查询 Room Type / Rate Code 等通用代码目录,并包装为统一 lookup 响应。
*/
private ReservationV4CatalogLookupResult listCatalogCodes(
ReservationV4CatalogLookupRequest request,
ReservationV4CatalogType catalogType) {
String hotelId = resolveHotelId(request == null ? null : request.hotelId());
int pageNum = normalizePageNum(request == null ? null : request.pageNum());
int pageSize = normalizePageSize(request == null ? null : request.pageSize());
ReservationPageSnapshot<ReservationV4CatalogCodeSnapshot> page = catalogRepository.queryActiveCatalogCodes(
hotelId,
catalogType.name(),
request == null ? null : request.keyword(),
pageNum,
pageSize);
List<ReservationV4CatalogLookupItemResult> items = page.items().stream()
.map(snapshot -> codeItem(catalogType, snapshot))
.toList();
return new ReservationV4CatalogLookupResult(
hotelId,
catalogType.name(),
sourceSystem(page.items()),
catalogVersion(page.items()),
false,
items,
new ReservationPaginationResult(page.pageNum(), page.pageSize(), page.total()),
warnings(page.items()));
}
/**
* 将 Account 快照转换为前端统一目录项,并补齐 Market / Source 显示名。
*/
private ReservationV4CatalogLookupItemResult accountItem(ReservationV4CatalogAccountSnapshot snapshot) {
String marketName = catalogRepository.findActiveCatalogCode(
snapshot.hotelId(),
ReservationV4CatalogType.MARKET.name(),
snapshot.marketCode())
.map(ReservationV4CatalogCodeSnapshot::displayName)
.orElse(snapshot.marketCode());
String sourceName = catalogRepository.findActiveCatalogCode(
snapshot.hotelId(),
ReservationV4CatalogType.SOURCE.name(),
snapshot.sourceCode())
.map(ReservationV4CatalogCodeSnapshot::displayName)
.orElse(snapshot.sourceCode());
return new ReservationV4CatalogLookupItemResult(
snapshot.accountCode(),
snapshot.accountName(),
snapshot.status(),
snapshot.sourceSystem(),
snapshot.marketCode(),
marketName,
snapshot.sourceCode(),
sourceName,
null,
null);
}
/**
* 将通用代码快照转换为前端统一目录项,并按目录类型解析扩展 metadata。
*/
private ReservationV4CatalogLookupItemResult codeItem(
ReservationV4CatalogType catalogType,
ReservationV4CatalogCodeSnapshot snapshot) {
return new ReservationV4CatalogLookupItemResult(
snapshot.code(),
snapshot.displayName(),
snapshot.status(),
snapshot.sourceSystem(),
null,
null,
null,
null,
catalogType == ReservationV4CatalogType.ROOM_TYPE ? metadataInteger(snapshot.metadataJson(), "adult_capacity") : null,
catalogType == ReservationV4CatalogType.RATE_CODE ? metadataBoolean(snapshot.metadataJson(), "pricing_available", false) : null);
}
/**
* 从当前页第一条目录项推导目录来源;空页返回数据库空目录标记。
*/
private String sourceSystem(List<?> items) {
if (items == null || items.isEmpty()) {
return EMPTY_CATALOG_SOURCE;
}
Object first = items.get(0);
if (first instanceof ReservationV4CatalogAccountSnapshot account) {
return account.sourceSystem();
}
if (first instanceof ReservationV4CatalogCodeSnapshot code) {
return code.sourceSystem();
}
return EMPTY_CATALOG_SOURCE;
}
/**
* 从当前页第一条目录项推导目录版本;空页返回 unknown提示前端不可缓存为正式版本。
*/
private String catalogVersion(List<?> items) {
if (items == null || items.isEmpty()) {
return EMPTY_CATALOG_VERSION;
}
Object first = items.get(0);
if (first instanceof ReservationV4CatalogAccountSnapshot account) {
return account.catalogVersion();
}
if (first instanceof ReservationV4CatalogCodeSnapshot code) {
return code.catalogVersion();
}
return EMPTY_CATALOG_VERSION;
}
/**
* 根据目录来源生成前端可展示的非阻塞提示。
*/
private List<String> warnings(List<?> items) {
if (items == null || items.isEmpty()) {
return List.of("当前酒店暂无可用目录数据。");
}
if ("FIXED_SEED_IMPORT".equals(sourceSystem(items))) {
return List.of("当前目录来自固定种子初始化,后续接入真实 PMS 或后台维护后会替换。");
}
return List.of();
}
/**
* 从目录 metadata JSON 中读取整数扩展字段,格式异常时安全降级为空。
*/
private Integer metadataInteger(String metadataJson, String fieldName) {
JsonNode node = metadataNode(metadataJson);
if (node == null || !node.has(fieldName) || !node.get(fieldName).canConvertToInt()) {
return null;
}
return node.get(fieldName).asInt();
}
/**
* 从目录 metadata JSON 中读取布尔扩展字段,缺失或格式异常时返回默认值。
*/
private Boolean metadataBoolean(String metadataJson, String fieldName, boolean defaultValue) {
JsonNode node = metadataNode(metadataJson);
if (node == null || !node.has(fieldName)) {
return defaultValue;
}
return node.get(fieldName).asBoolean(defaultValue);
}
/**
* 解析目录 metadata JSON坏数据不向前端透出异常细节。
*/
private JsonNode metadataNode(String metadataJson) {
String normalized = trimToNull(metadataJson);
if (normalized == null) {
return null;
}
try {
return objectMapper.readTree(normalized);
} catch (Exception ignored) {
return null;
}
}
/**
* 解析当前请求酒店并套用登录用户的酒店访问权校验。
*/
private String resolveHotelId(String hotelId) {
try {
return hotelContextService.resolveCurrentHotelId(hotelId);
} catch (HotelContextException exception) {
throw new ReservationTaskWorkflowException(
exception.getStatus(),
exception.getErrorCode(),
exception.getMessage());
}
}
/**
* 归一化页码,避免异常大页码或非法页码影响查询稳定性。
*/
private int normalizePageNum(Integer pageNum) {
if (pageNum == null || pageNum < 1) {
return DEFAULT_PAGE_NUM;
}
return Math.min(pageNum, MAX_PAGE_NUM);
}
/**
* 归一化分页大小,第一版限制单次最多返回 100 条。
*/
private int normalizePageSize(Integer pageSize) {
if (pageSize == null || pageSize < 1) {
return DEFAULT_PAGE_SIZE;
}
return Math.min(pageSize, MAX_PAGE_SIZE);
}
/**
* 去除字符串首尾空白,空白值统一视为 null。
*/
private String trimToNull(String value) {
if (value == null || value.trim().isEmpty()) {
return null;
}
return value.trim();
}
}

View File

@@ -142,7 +142,10 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
ensureNoPriorOrderTaskBlocking(orderTask);
ensureBasicInformationConfirmed(orderTask, card);
String confirmedPayloadJson = confirmedPayloadJson(card, request == null ? null : request.confirmedPayload());
String confirmedPayloadJson = confirmedPayloadJson(
orderTask.hotelId(),
card,
request == null ? null : request.confirmedPayload());
String actorId = actorIdentifier(actor);
boolean updated = workflowRepository.confirmTaskCardWithVersion(
orderTask.hotelId(),
@@ -186,7 +189,7 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
card,
confirmedPayload,
request == null ? null : request.fieldOverrides());
validateAndEnrichConfirmedPayload(card, confirmedPayload);
validateAndEnrichConfirmedPayload(orderTask.hotelId(), card, confirmedPayload);
String actorId = actorIdentifier(actor);
String confirmedPayloadJson = toJson(confirmedPayload);
String reviewResolutionJson = reviewResolutionJson(
@@ -469,9 +472,9 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
throw error(HttpStatus.CONFLICT, "V4_CARD_VERSION_CONFLICT", "任务卡版本已变化,请刷新后重试。");
}
private String confirmedPayloadJson(ReservationV4TaskCardSnapshot card, JsonNode confirmedPayload) {
private String confirmedPayloadJson(String hotelId, ReservationV4TaskCardSnapshot card, JsonNode confirmedPayload) {
ObjectNode payload = confirmedPayloadObject(card, confirmedPayload);
validateAndEnrichConfirmedPayload(card, payload);
validateAndEnrichConfirmedPayload(hotelId, card, payload);
return toJson(payload);
}
@@ -610,12 +613,12 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
return wrappedBusinessFields || BUSINESS_WRITABLE_ROOT_FIELDS.contains(path.get(0));
}
private void validateAndEnrichConfirmedPayload(ReservationV4TaskCardSnapshot card, ObjectNode payload) {
private void validateAndEnrichConfirmedPayload(String hotelId, ReservationV4TaskCardSnapshot card, ObjectNode payload) {
List<String> details = new ArrayList<>();
if (ReservationV4CardType.BASIC_INFORMATION.name().equals(card.cardType())) {
validateAndEnrichBasicInformation(payload, details);
validateAndEnrichBasicInformation(hotelId, payload, details);
} else {
validateBusinessCardPayload(payload, details);
validateBusinessCardPayload(hotelId, payload, details);
}
if (!details.isEmpty()) {
throw new ReservationTaskWorkflowException(
@@ -626,14 +629,14 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
}
}
private void validateAndEnrichBasicInformation(ObjectNode payload, List<String> details) {
private void validateAndEnrichBasicInformation(String hotelId, ObjectNode payload, List<String> details) {
ObjectNode basicInformation = ensureBasicInformationObject(payload);
String accountCode = textAt(basicInformation, "account_code");
if (accountCode == null) {
details.add("basic_information.account_code: Account Code 不能为空。");
return;
}
ReservationV4AccountCatalogItem account = directoryService.findAccount(accountCode).orElse(null);
ReservationV4AccountCatalogItem account = directoryService.findAccount(hotelId, accountCode).orElse(null);
if (account == null) {
details.add("basic_information.account_code: Account Code 不在信息系统目录中。");
return;
@@ -663,13 +666,13 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
}
}
private void validateBusinessCardPayload(ObjectNode payload, List<String> details) {
private void validateBusinessCardPayload(String hotelId, ObjectNode payload, List<String> details) {
JsonNode businessFields = payload.path("business_fields");
JsonNode fieldRoot = businessFields.isObject() ? businessFields : payload;
validateBusinessCatalogFields(fieldRoot, "business_fields", details);
validateBusinessCatalogFields(hotelId, fieldRoot, "business_fields", details);
}
private void validateBusinessCatalogFields(JsonNode node, String fieldPath, List<String> details) {
private void validateBusinessCatalogFields(String hotelId, JsonNode node, String fieldPath, List<String> details) {
if (node == null || node.isMissingNode() || node.isNull()) {
return;
}
@@ -679,30 +682,39 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
Map.Entry<String, JsonNode> field = fields.next();
String childPath = fieldPath + "." + field.getKey();
if ("rate_code".equals(field.getKey())) {
validateRateCode(field.getValue(), childPath, details);
validateRateCode(hotelId, field.getValue(), childPath, details);
} else if ("target_room_type_code".equals(field.getKey())) {
validateRoomTypeCode(hotelId, field.getValue(), childPath, details);
} else if ("room_items".equals(field.getKey())) {
validateRoomItems(field.getValue(), childPath, details);
validateRoomItems(hotelId, field.getValue(), childPath, details);
} else {
validateBusinessCatalogFields(field.getValue(), childPath, details);
validateBusinessCatalogFields(hotelId, field.getValue(), childPath, details);
}
}
return;
}
if (node.isArray()) {
for (int index = 0; index < node.size(); index++) {
validateBusinessCatalogFields(node.get(index), fieldPath + "." + index, details);
validateBusinessCatalogFields(hotelId, node.get(index), fieldPath + "." + index, details);
}
}
}
private void validateRateCode(JsonNode rateCode, String fieldPath, List<String> details) {
private void validateRateCode(String hotelId, JsonNode rateCode, String fieldPath, List<String> details) {
String code = textValue(rateCode);
if (code != null && !directoryService.isKnownRateCode(code)) {
if (code != null && !directoryService.isKnownRateCode(hotelId, code)) {
details.add(fieldPath + ": Rate Code 不在第一版目录中。");
}
}
private void validateRoomItems(JsonNode roomItems, String fieldPath, List<String> details) {
private void validateRoomTypeCode(String hotelId, JsonNode roomTypeCode, String fieldPath, List<String> details) {
String code = textValue(roomTypeCode);
if (code != null && !directoryService.isKnownRoomTypeCode(hotelId, code)) {
details.add(fieldPath + ": 房型代码不在第一版目录中。");
}
}
private void validateRoomItems(String hotelId, JsonNode roomItems, String fieldPath, List<String> details) {
if (roomItems == null || roomItems.isMissingNode() || roomItems.isNull()) {
return;
}
@@ -716,10 +728,7 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
details.add(fieldPath + "." + index + ": 房型明细必须是对象。");
continue;
}
String roomTypeCode = textAt(item, "room_type_code");
if (roomTypeCode != null && !directoryService.isKnownRoomTypeCode(roomTypeCode)) {
details.add(fieldPath + "." + index + ".room_type_code: 房型代码不在第一版目录中。");
}
validateRoomTypeCode(hotelId, item.get("room_type_code"), fieldPath + "." + index + ".room_type_code", details);
}
}

View File

@@ -0,0 +1,59 @@
package cn.nianxx.thhotel.workflows.reservation.service.impl;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4AccountCatalogItem;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4CatalogType;
import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4CatalogRepository;
import cn.nianxx.thhotel.workflows.reservation.service.ReservationV4DirectoryService;
import java.util.Optional;
import org.springframework.stereotype.Service;
/**
* Reservation V4 数据库目录服务实现。优先使用酒店维度目录表,不再把固定种子作为全局运行时事实。
*/
@Service
public class ReservationV4DatabaseDirectoryServiceImpl implements ReservationV4DirectoryService {
private final ReservationV4CatalogRepository catalogRepository;
/**
* 注入目录 Repository目录服务负责把持久化快照转换为业务校验需要的稳定目录项。
*/
public ReservationV4DatabaseDirectoryServiceImpl(ReservationV4CatalogRepository catalogRepository) {
this.catalogRepository = catalogRepository;
}
/**
* 按酒店和 Account Code 查询目录项;不存在时返回空,用于触发人工复核或字段校验错误。
*/
@Override
public Optional<ReservationV4AccountCatalogItem> findAccount(String hotelId, String accountCode) {
return catalogRepository.findActiveAccount(hotelId, accountCode)
.map(snapshot -> new ReservationV4AccountCatalogItem(
snapshot.accountCode(),
snapshot.accountName(),
snapshot.marketCode(),
snapshot.sourceCode()));
}
/**
* 判断房型代码是否在当前酒店 ACTIVE Room Type 目录中。
*/
@Override
public boolean isKnownRoomTypeCode(String hotelId, String roomTypeCode) {
return catalogRepository.findActiveCatalogCode(
hotelId,
ReservationV4CatalogType.ROOM_TYPE.name(),
roomTypeCode).isPresent();
}
/**
* 判断 Rate Code 是否在当前酒店 ACTIVE Rate Code 目录中。
*/
@Override
public boolean isKnownRateCode(String hotelId, String rateCode) {
return catalogRepository.findActiveCatalogCode(
hotelId,
ReservationV4CatalogType.RATE_CODE.name(),
rateCode).isPresent();
}
}

View File

@@ -802,7 +802,7 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
return List.of();
}
if (ReservationV4CardType.BASIC_INFORMATION.name().equals(card.cardType())) {
return basicInformationFields(displayPayload, validationErrors, availability);
return basicInformationFields(card.hotelId(), displayPayload, validationErrors, availability);
}
if (ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name().equals(card.cardType())
|| ReservationV4CardType.SOURCE_MESSAGE_NOTIFICATION.name().equals(card.cardType())) {
@@ -812,12 +812,13 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
}
private List<ReservationV4TaskCardFieldResult> basicInformationFields(
String hotelId,
JsonNode displayPayload,
JsonNode validationErrors,
ReservationV4ActionAvailabilityResult availability) {
JsonNode basicInformation = basicInformationNode(displayPayload);
String accountCode = textAt(basicInformation, "account_code");
Optional<ReservationV4AccountCatalogItem> account = directoryService.findAccount(accountCode);
Optional<ReservationV4AccountCatalogItem> account = directoryService.findAccount(hotelId, accountCode);
String marketCode = firstText(textAt(basicInformation, "market_code"), account.map(ReservationV4AccountCatalogItem::marketCode).orElse(null));
String sourceCode = firstText(textAt(basicInformation, "source_code"), account.map(ReservationV4AccountCatalogItem::sourceCode).orElse(null));
List<ReservationV4TaskCardFieldResult> fields = new ArrayList<>();
@@ -834,7 +835,7 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
"reservation_v4_account_catalog",
false,
validationMessages(validationErrors, "/basic_information/account_code", "basic_information.account_code"),
"第一版 Account 使用后端固定目录,不能自由输入。"));
"Account 使用酒店目录 lookup,不能自由输入。"));
fields.add(field(
"basic_information.market_code",
"/basic_information/market_code",

View File

@@ -182,7 +182,7 @@ public class ReservationV4TaskIntakeServiceImpl implements ReservationV4TaskInta
ObjectNode safeBasicInformation = basicInformation.isObject()
? ((ObjectNode) basicInformation).deepCopy()
: objectMapper.createObjectNode();
List<String> validationDetails = validateAndEnrichBasicInformation(safeBasicInformation);
List<String> validationDetails = validateAndEnrichBasicInformation(sourceMessage.hotelId(), safeBasicInformation);
boolean reviewRequired = manualReviewRequired || !validationDetails.isEmpty();
ObjectNode displayPayload = objectMapper.createObjectNode();
displayPayload.put("card_type", ReservationV4CardType.BASIC_INFORMATION.name());
@@ -215,7 +215,7 @@ public class ReservationV4TaskIntakeServiceImpl implements ReservationV4TaskInta
ReservationV4AcceptedEventDraft acceptedEvent,
LocalDateTime now) {
ObjectNode displayPayload = objectJson(acceptedEvent.displayPayloadJson());
List<String> validationDetails = validateBusinessCardDisplayPayload(displayPayload);
List<String> validationDetails = validateBusinessCardDisplayPayload(sourceMessage.hotelId(), displayPayload);
boolean reviewRequired = acceptedEvent.manualReviewRequired() || !validationDetails.isEmpty();
workflowRepository.insertTaskCard(new ReservationV4TaskCardDraft(
sourceMessage.hotelId(),
@@ -376,14 +376,14 @@ public class ReservationV4TaskIntakeServiceImpl implements ReservationV4TaskInta
/**
* 校验并派生 Basic Information 的 Account 目录字段,错误只写展示卡,不改 AI 原始 payload。
*/
private List<String> validateAndEnrichBasicInformation(ObjectNode basicInformation) {
private List<String> validateAndEnrichBasicInformation(String hotelId, ObjectNode basicInformation) {
List<String> details = new ArrayList<>();
String accountCode = trimToNull(textAt(basicInformation, "account_code"));
if (accountCode == null) {
details.add("basic_information.account_code: Account Code 不能为空。");
return details;
}
ReservationV4AccountCatalogItem account = directoryService.findAccount(accountCode).orElse(null);
ReservationV4AccountCatalogItem account = directoryService.findAccount(hotelId, accountCode).orElse(null);
if (account == null) {
details.add("basic_information.account_code: Account Code 不在信息系统目录中。");
return details;
@@ -397,15 +397,15 @@ public class ReservationV4TaskIntakeServiceImpl implements ReservationV4TaskInta
/**
* 校验 V4 业务卡展示字段中的第一版目录值,失败时卡片进入人工复核。
*/
private List<String> validateBusinessCardDisplayPayload(ObjectNode displayPayload) {
private List<String> validateBusinessCardDisplayPayload(String hotelId, ObjectNode displayPayload) {
List<String> details = new ArrayList<>();
JsonNode businessFields = displayPayload.path("business_fields");
JsonNode fieldRoot = businessFields.isObject() ? businessFields : displayPayload;
validateBusinessCatalogFields(fieldRoot, "business_fields", details);
validateBusinessCatalogFields(hotelId, fieldRoot, "business_fields", details);
return details;
}
private void validateBusinessCatalogFields(JsonNode node, String fieldPath, List<String> details) {
private void validateBusinessCatalogFields(String hotelId, JsonNode node, String fieldPath, List<String> details) {
if (node == null || node.isMissingNode() || node.isNull()) {
return;
}
@@ -415,30 +415,39 @@ public class ReservationV4TaskIntakeServiceImpl implements ReservationV4TaskInta
Map.Entry<String, JsonNode> field = fields.next();
String childPath = fieldPath + "." + field.getKey();
if ("rate_code".equals(field.getKey())) {
validateRateCode(field.getValue(), childPath, details);
validateRateCode(hotelId, field.getValue(), childPath, details);
} else if ("target_room_type_code".equals(field.getKey())) {
validateRoomTypeCode(hotelId, field.getValue(), childPath, details);
} else if ("room_items".equals(field.getKey())) {
validateRoomItems(field.getValue(), childPath, details);
validateRoomItems(hotelId, field.getValue(), childPath, details);
} else {
validateBusinessCatalogFields(field.getValue(), childPath, details);
validateBusinessCatalogFields(hotelId, field.getValue(), childPath, details);
}
}
return;
}
if (node.isArray()) {
for (int index = 0; index < node.size(); index++) {
validateBusinessCatalogFields(node.get(index), fieldPath + "." + index, details);
validateBusinessCatalogFields(hotelId, node.get(index), fieldPath + "." + index, details);
}
}
}
private void validateRateCode(JsonNode rateCode, String fieldPath, List<String> details) {
private void validateRateCode(String hotelId, JsonNode rateCode, String fieldPath, List<String> details) {
String code = trimToNull(rateCode == null || !rateCode.isTextual() ? null : rateCode.asText());
if (code != null && !directoryService.isKnownRateCode(code)) {
if (code != null && !directoryService.isKnownRateCode(hotelId, code)) {
details.add(fieldPath + ": Rate Code 不在第一版目录中。");
}
}
private void validateRoomItems(JsonNode roomItems, String fieldPath, List<String> details) {
private void validateRoomTypeCode(String hotelId, JsonNode roomTypeCode, String fieldPath, List<String> details) {
String code = trimToNull(roomTypeCode == null || !roomTypeCode.isTextual() ? null : roomTypeCode.asText());
if (code != null && !directoryService.isKnownRoomTypeCode(hotelId, code)) {
details.add(fieldPath + ": 房型代码不在第一版目录中。");
}
}
private void validateRoomItems(String hotelId, JsonNode roomItems, String fieldPath, List<String> details) {
if (roomItems == null || roomItems.isMissingNode() || roomItems.isNull()) {
return;
}
@@ -452,10 +461,7 @@ public class ReservationV4TaskIntakeServiceImpl implements ReservationV4TaskInta
details.add(fieldPath + "." + index + ": 房型明细必须是对象。");
continue;
}
String roomTypeCode = trimToNull(textAt(item, "room_type_code"));
if (roomTypeCode != null && !directoryService.isKnownRoomTypeCode(roomTypeCode)) {
details.add(fieldPath + "." + index + ".room_type_code: 房型代码不在第一版目录中。");
}
validateRoomTypeCode(hotelId, item.get("room_type_code"), fieldPath + "." + index + ".room_type_code", details);
}
}