This commit is contained in:
zjf committed 2026-10-28 19:26:01 +08:00
1 parent a2dc29722b
commit 22a4b940dd
10 files changed
+337 -151

No files matched your search

@@ -97,4 +97,7 @@ public class SmHotelDownstreamChannelConfigDTO {
@Schema(description = "是否参与资源日更定时推送;false=仅手动/变更推,默认 false")
private Boolean resourcePushJoinSchedule;
@Schema(description = "本地改价/库存/房态后是否自动推本伙伴;大批量补数可临时 false,默认 true(未配则跟进程级)")
private Boolean resourceAutoPushEnabled;
}
@@ -177,4 +177,14 @@ public class SmHotelOrderEntity extends Model<SmHotelOrderEntity> {
@TableField(exist = false)
@Schema(description = "酒店归属部门名称")
private String deptName;
/** 列表/详情:产品可否取消 1/0(非表字段,来自产品) */
@TableField(exist = false)
@Schema(description = "产品可否取消")
private Integer productCanCancel;
/** 列表/详情:取消规则可读文案(非表字段,供人工核对) */
@TableField(exist = false)
@Schema(description = "取消规则说明")
private String cancelRuleHint;
}
@@ -130,11 +130,14 @@ public class HotelDownstreamProperties {
/**
* 本地/回调变更后是否自动推下游(防抖)。
* false:只靠手动推与日更。
* <ul>
* <li>进程级(Nacos {@code hotel-downstream.resource-auto-push-enabled}):总开关,false 时所有伙伴都不自动推</li>
* <li>伙伴 YAML {@code resource-auto-push-enabled}:可覆盖本伙伴;大批量补数时对该家临时 false</li>
* </ul>
*/
private boolean resourceAutoPushEnabled = true;
/** 变更后防抖秒数(同酒店多次变更合并为一次推送) */
/** 变更后防抖秒数(同酒店多次变更合并为一次推送);伙伴 YAML 可覆盖 */
private int resourceAutoPushDebounceSeconds = 90;
/**
@@ -80,7 +80,7 @@ public class SmHotelOrderController {
}
@SysLog("本地酒店同意取消申请")
@Operation(summary = "本地:同意下游取消申请(PENDING_CANCEL)")
@Operation(summary = "同意下游取消申请(PENDING_CANCEL,人工核对取消规则)")
@PostMapping("/confirmCancel/{id}")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelOrder_cancel') or @pms.hasPermission('supermarket_smHotel_edit')")
public R<Boolean> confirmCancel(@PathVariable Long id, @RequestBody(required = false) Map<String, Object> body) {
@@ -88,8 +88,8 @@ public class SmHotelOrderController {
return orderService.confirmCancelLocalByAdmin(id, reason);
}
@SysLog("本地酒店拒绝取消申请")
@Operation(summary = "本地:拒绝下游取消申请,恢复预订成功")
@SysLog("拒绝下游取消申请")
@Operation(summary = "拒绝下游取消申请,恢复挂起前状态")
@PostMapping("/rejectCancel/{id}")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelOrder_cancel') or @pms.hasPermission('supermarket_smHotel_edit')")
public R<Boolean> rejectCancel(@PathVariable Long id, @RequestBody(required = false) Map<String, Object> body) {
@@ -3,6 +3,7 @@ package com.bsd.pjcloud.supermarket.service;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.bsd.pjcloud.supermarket.constant.SmHotelDataSource;
import com.bsd.pjcloud.supermarket.entity.SmHotelProductEntity;
import java.time.LocalDate;
@@ -14,21 +15,133 @@ import java.time.format.DateTimeFormatter;
* 上游取消规则的<strong>统一落库模型</strong>与截止时间判定。
* <p>
* 各上游渠道原始结构不同,解析由 {@code HotelUpstreamCancelPolicy} 按 sheet_name 插拔;
* 同步时尽量归一到 cancelRule JSON,下游取消 / 后台取消只认本模型。
* 同步时尽量归一到 cancelRule JSON;本地酒店默认「预订后 30 分钟内可取消」。
* <p>
* cancelRule 推荐:
* {@code {"channel":"shangyou","isCanCancel":true,"lastCancelDay":1,"lastCancelTime":"18:00","cancelDesc":"..."}}
* 本地默认:
* {@code {"channel":"local","isCanCancel":true,"cancelWithinMinutes":30,"cancelDesc":"预订后30分钟内可免费取消"}}
*/
public final class SmHotelCancelPolicy {
private static final DateTimeFormatter HM = DateTimeFormatter.ofPattern("H:mm");
/** 本地酒店默认:下单后可取消窗口(分钟) */
public static final int LOCAL_CANCEL_WITHIN_MINUTES = 30;
public static final String LOCAL_CANCEL_DESC = "预订后30分钟内可免费取消";
private SmHotelCancelPolicy() {
}
/** 本地酒店默认取消规则(推下游用可取消,不再一律不可取消) */
public static Policy localDefault() {
Policy p = new Policy();
p.channel = SmHotelDataSource.LOCAL;
p.canCancel = true;
p.cancelWithinMinutes = LOCAL_CANCEL_WITHIN_MINUTES;
p.desc = LOCAL_CANCEL_DESC;
// 仅支持「入住前天数」的下游:映射为当日可取消,文案以 cancelWithinMinutes 为准
p.lastCancelDay = 0;
p.lastCancelTime = LocalTime.of(23, 59);
return p;
}
/** 本地产品未填取消规则时写入默认值 */
public static void ensureLocalProductDefaults(SmHotelProductEntity product) {
if (product == null) {
return;
}
if (product.getCanCancel() != null || StrUtil.isNotBlank(product.getCancelRule())) {
return;
}
product.setCanCancel(1);
product.setCancelRule(toLocalDefaultCancelRuleJson());
}
public static String toLocalDefaultCancelRuleJson() {
JSONObject o = new JSONObject();
o.set("channel", SmHotelDataSource.LOCAL);
o.set("isCanCancel", true);
o.set("cancelWithinMinutes", LOCAL_CANCEL_WITHIN_MINUTES);
o.set("lastCancelDay", 0);
o.set("lastCancelTime", "23:59");
o.set("cancelDesc", LOCAL_CANCEL_DESC);
return o.toString();
}
/**
* 按酒店来源解析规则:本地无配置时用 30 分钟默认;上游走已落库/渠道 SPI。
*/
public static Policy resolveForHotel(String hotelSheetName, SmHotelProductEntity product) {
Policy stored = resolveStored(product);
if (!SmHotelDataSource.isLocal(hotelSheetName)) {
return stored;
}
// 本地明确不可取消
if (product != null && Integer.valueOf(0).equals(product.getCanCancel())) {
if (stored == null) {
Policy p = new Policy();
p.channel = SmHotelDataSource.LOCAL;
p.canCancel = false;
p.desc = "不可取消";
return p;
}
return stored;
}
if (stored == null) {
return localDefault();
}
if (stored.canCancel && stored.cancelWithinMinutes <= 0) {
stored.cancelWithinMinutes = LOCAL_CANCEL_WITHIN_MINUTES;
if (StrUtil.isBlank(stored.desc)) {
stored.desc = LOCAL_CANCEL_DESC;
}
if (StrUtil.isBlank(stored.channel)) {
stored.channel = SmHotelDataSource.LOCAL;
}
if (stored.lastCancelTime == null && stored.lastCancelDay == 0) {
stored.lastCancelTime = LocalTime.of(23, 59);
}
}
return stored;
}
/**
* 人工核对用文案:可否取消 + 最晚时点 + 说明。无规则返回空串。
*/
public static String formatDisplay(Policy p) {
if (p == null) {
return "";
}
StringBuilder sb = new StringBuilder();
sb.append(p.canCancel ? "可取消" : "不可取消");
if (p.canCancel && p.cancelWithinMinutes > 0) {
sb.append(";预订后").append(p.cancelWithinMinutes).append("分钟内");
}
else if (p.canCancel && (p.lastCancelDay > 0 || p.lastCancelTime != null)) {
String hm = p.lastCancelTime == null ? "23:59"
: p.lastCancelTime.format(DateTimeFormatter.ofPattern("HH:mm"));
sb.append(";最晚:入住前").append(Math.max(0, p.lastCancelDay)).append("天 ").append(hm);
}
if (StrUtil.isNotBlank(p.desc)) {
sb.append(";").append(p.desc.trim());
}
if (StrUtil.isNotBlank(p.channel)) {
sb.append("(").append(p.channel).append(")");
}
return sb.toString();
}
public static String formatDisplay(SmHotelProductEntity product) {
return formatDisplay(resolveStored(product));
}
/**
* @return null=允许取消;非空=拒绝原因
* @deprecated 各上游/下游规则不一,取消改为人工确认;保留供推送映射等只读场景
*/
@Deprecated
public static String denyReason(Policy p, LocalDate checkIn, LocalDateTime now) {
if (p == null) {
return null;
@@ -93,6 +206,11 @@ public final class SmHotelCancelPolicy {
/** 同步时写入统一 cancelRule JSON。 */
public static String toCancelRuleJson(String channel, Boolean isCanCancel, Integer lastCancelDay,
String lastCancelTime, String cancelDesc) {
return toCancelRuleJson(channel, isCanCancel, lastCancelDay, lastCancelTime, null, cancelDesc);
}
public static String toCancelRuleJson(String channel, Boolean isCanCancel, Integer lastCancelDay,
String lastCancelTime, Integer cancelWithinMinutes, String cancelDesc) {
JSONObject o = new JSONObject();
if (StrUtil.isNotBlank(channel)) {
o.set("channel", channel.trim().toLowerCase());
@@ -104,6 +222,9 @@ public final class SmHotelCancelPolicy {
if (StrUtil.isNotBlank(lastCancelTime)) {
o.set("lastCancelTime", lastCancelTime.trim());
}
if (cancelWithinMinutes != null && cancelWithinMinutes > 0) {
o.set("cancelWithinMinutes", cancelWithinMinutes);
}
if (StrUtil.isNotBlank(cancelDesc)) {
o.set("cancelDesc", cancelDesc.trim());
}
@@ -152,6 +273,8 @@ public final class SmHotelCancelPolicy {
Integer day = o.getInt("lastCancelDay");
p.lastCancelDay = day == null ? 0 : Math.max(0, day);
p.lastCancelTime = parseHm(o.getStr("lastCancelTime"));
Integer within = o.getInt("cancelWithinMinutes");
p.cancelWithinMinutes = within == null ? 0 : Math.max(0, within);
p.desc = StrUtil.blankToDefault(o.getStr("cancelDesc"), o.getStr("desc"));
p.channel = o.getStr("channel");
return p;
@@ -214,11 +337,76 @@ public final class SmHotelCancelPolicy {
}
public static final class Policy {
/** 来源渠道,如 shangyou;可空 */
/** 来源渠道,如 shangyou / local;可空 */
public String channel;
public boolean canCancel;
public int lastCancelDay;
public LocalTime lastCancelTime;
/** 本地:预订后 N 分钟内可取消;0=未使用该维度 */
public int cancelWithinMinutes;
public String desc;
}
/**
* 环启产品同步:canCancel + cancelRule(可取消时必填文案)。
*/
public static void putHuanqiProductCancel(java.util.Map<String, Object> body, Policy p) {
if (body == null) {
return;
}
boolean ok = p != null && p.canCancel;
body.put("canCancel", ok ? 1 : 0);
if (ok) {
String rule = formatDisplay(p);
body.put("cancelRule", StrUtil.blankToDefault(rule, LOCAL_CANCEL_DESC));
}
}
/**
* 慧订每日价态:cancelType / daySelect / timeSelect。
* <ul>
* <li>不可取消:cancelType=13(沿用现网)</li>
* <li>可取消:cancelType=2(限时取消),daySelect=入住前几天,timeSelect=最晚整点小时</li>
* </ul>
*/
public static void putHuidingNightCancel(java.util.Map<String, Object> night, Policy p) {
if (night == null) {
return;
}
if (p == null || !p.canCancel) {
night.put("cancelType", 13);
night.put("daySelect", 0);
night.put("timeSelect", 18);
night.put("cutValue", 0);
return;
}
night.put("cancelType", 2);
night.put("daySelect", Math.max(0, p.lastCancelDay));
int hour = 18;
if (p.lastCancelTime != null) {
hour = p.lastCancelTime.getHour();
}
night.put("timeSelect", Math.min(23, Math.max(0, hour)));
night.put("cutValue", 0);
}
/**
* 圣诺计划:is_cancel 1不可/2可;cancel_advance_day + cancel_specific_date(HH:mm)。
*/
public static void putShengnuoPlanCancel(java.util.Map<String, Object> plan, Policy p) {
if (plan == null) {
return;
}
boolean ok = p != null && p.canCancel;
int advance = p == null ? 0 : Math.max(0, p.lastCancelDay);
String specific = "18:00";
if (p != null && p.lastCancelTime != null) {
specific = p.lastCancelTime.format(DateTimeFormatter.ofPattern("HH:mm"));
}
plan.put("is_cancel", ok ? 2 : 1);
plan.put("cancel_advance_day", advance);
plan.put("cancel_specific_date", specific);
plan.put("cancel_cut_type", 3);
plan.put("cancel_amount", 0);
}
}
@@ -102,15 +102,15 @@ public interface SmHotelOrderService {
R<Boolean> rejectLocalByAdmin(Long id, String reason);
/**
* 下游取消入站:本地已确认订单挂起为 PENDING_CANCEL 待人工;其余即时取消。
* data.parked=true 表示已挂起;false 表示已即时取消。
* 下游取消入站:一律挂起 PENDING_CANCEL 待人工(取消规则仅展示核对,不硬拦)。
* data.parked=true 表示已挂起。
*/
R<Map<String, Object>> cancelOrParkForDownstream(Long id, String reason);
/** 本地:同意下游取消申请(PENDING_CANCEL → 关单并回写下游取消确认) */
/** 同意下游取消申请(PENDING_CANCEL → 关单/退款并回写下游取消确认) */
R<Boolean> confirmCancelLocalByAdmin(Long id, String reason);
/** 本地:拒绝下游取消申请(PENDING_CANCEL → 恢复预订成功,并回写下游仍接单) */
/** 拒绝下游取消申请(PENDING_CANCEL → 恢复挂起前状态,并回写下游仍接单) */
R<Boolean> rejectCancelLocalByAdmin(Long id, String reason);
/** Worker:扫描到期补偿任务 */
@@ -386,6 +386,9 @@ public class SmHotelChannelConfigServiceImpl extends ServiceImpl<SmHotelChannelC
if (dto.getResourcePushJoinSchedule() != null) {
target.setResourcePushJoinSchedule(dto.getResourcePushJoinSchedule());
}
if (dto.getResourceAutoPushEnabled() != null) {
target.setResourceAutoPushEnabled(dto.getResourceAutoPushEnabled());
}
}
private void overlayShangyou(ShangyouHotelProperties target, SmHotelShangyouChannelConfigDTO dto) {
@@ -80,7 +80,8 @@ public class SmHotelDownstreamAutoPushServiceImpl implements SmHotelDownstreamAu
if (hotelId == null) {
return;
}
if (!isAutoPushEnabled()) {
// 进程级总开关;各伙伴还可在 YAML 单独关(见 resolveAutoPushPartners)
if (!isGlobalAutoPushEnabled()) {
return;
}
if (TransactionSynchronizationManager.isSynchronizationActive()) {
@@ -105,7 +106,7 @@ public class SmHotelDownstreamAutoPushServiceImpl implements SmHotelDownstreamAu
if (hotel == null) {
return;
}
List<String> partners = resolvePartners(hotel);
List<String> partners = resolveScopePartners(hotel);
if (CollUtil.isEmpty(partners)) {
return;
}
@@ -156,10 +157,11 @@ public class SmHotelDownstreamAutoPushServiceImpl implements SmHotelDownstreamAu
@Override
public String autoPushScheduleHint() {
if (!isAutoPushEnabled()) {
return "自动推下游已关闭,请到酒店列表手动「推下游」";
if (!isGlobalAutoPushEnabled()) {
return "进程级自动推已关闭(hotel-downstream.resource-auto-push-enabled),请手动「推下游」";
}
return "约" + resolveDebounceSeconds() + "秒后自动推已授权下游(圣诺/环启等),无需再点手动推";
return "约" + resolveDebounceSeconds() + "秒后自动推「已开 resource-auto-push」的下游;"
+ "大批量补数可在该伙伴 YAML 临时设 resource-auto-push-enabled: false";
}
private void scheduleDebounced(Long hotelId) {
@@ -191,12 +193,18 @@ public class SmHotelDownstreamAutoPushServiceImpl implements SmHotelDownstreamAu
if (hotel == null) {
return;
}
List<String> partners = resolvePartners(hotel);
if (CollUtil.isEmpty(partners)) {
List<String> scopePartners = resolveScopePartners(hotel);
if (CollUtil.isEmpty(scopePartners)) {
// 提到 info:改价后「没推出去」最常见原因是渠道范围未命中
log.info("|{}|无命中下游范围,跳过自动推|hotelId={}|deptId={}|", TAG, hotelId, hotel.getDeptId());
return;
}
List<String> partners = filterAutoPushEnabledPartners(scopePartners);
if (CollUtil.isEmpty(partners)) {
log.info("|{}|命中范围但伙伴均关闭 resource-auto-push,跳过|hotelId={}|scoped={}|", TAG, hotelId,
scopePartners);
return;
}
for (String partner : partners) {
long t0 = System.currentTimeMillis();
try {
@@ -249,7 +257,8 @@ public class SmHotelDownstreamAutoPushServiceImpl implements SmHotelDownstreamAu
}
}
private List<String> resolvePartners(SmHotelEntity hotel) {
/** 渠道范围命中的启用伙伴(不含自动推开关过滤) */
private List<String> resolveScopePartners(SmHotelEntity hotel) {
List<String> enabled = channelConfigService.listEnabledDownstreamPartners();
if (CollUtil.isEmpty(enabled)) {
return List.of();
@@ -265,7 +274,27 @@ public class SmHotelDownstreamAutoPushServiceImpl implements SmHotelDownstreamAu
return matched.stream().filter(enabled::contains).distinct().toList();
}
private boolean isAutoPushEnabled() {
/** 再按各伙伴 YAML/运行时的 resource-auto-push-enabled 过滤 */
private List<String> filterAutoPushEnabledPartners(List<String> partners) {
if (CollUtil.isEmpty(partners)) {
return List.of();
}
return partners.stream().filter(this::isPartnerAutoPushEnabled).distinct().toList();
}
private boolean isPartnerAutoPushEnabled(String partnerCode) {
try {
HotelDownstreamProperties props = channelConfigService.resolveDownstreamRuntime(partnerCode);
return props != null && props.isResourceAutoPushEnabled();
}
catch (Exception e) {
log.warn("|{}|读伙伴自动推开关失败,按关闭处理|partner={}|err={}|", TAG, partnerCode, e.getMessage());
return false;
}
}
/** 进程级总开关(Nacos hotel-downstream);false 时 schedule 直接不排 */
private boolean isGlobalAutoPushEnabled() {
HotelDownstreamProperties def = channelConfigService.downstreamYamlDefaultsSnapshot();
if (def == null) {
def = yamlDefaults;
@@ -705,22 +705,10 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
List<Map<String, Object>> planCode = new ArrayList<>(1);
Map<String, Object> plan = new LinkedHashMap<>();
plan.put("breakfast", breakfast);
// 圣诺:is_cancel 1不可取消 2可取消;规则按酒店上游渠道解析后映射
// 圣诺:把上游(如飞猪)取消规则映射过去,避免对方可取消而我方不可取消
SmHotelCancelPolicy.Policy cancelPol = resolveUpstreamCancelPolicy(hotel, p);
boolean canCancel = cancelPol != null ? cancelPol.canCancel
: Integer.valueOf(1).equals(p.getCanCancel());
int advanceDay = cancelPol == null ? 0 : Math.max(0, cancelPol.lastCancelDay);
String specific = "18:00";
if (cancelPol != null && cancelPol.lastCancelTime != null) {
specific = cancelPol.lastCancelTime
.format(java.time.format.DateTimeFormatter.ofPattern("HH:mm"));
}
plan.put("is_cancel", canCancel ? 2 : 1);
SmHotelCancelPolicy.putShengnuoPlanCancel(plan, cancelPol);
plan.put("sale", 1);
plan.put("cancel_advance_day", advanceDay);
plan.put("cancel_specific_date", specific);
plan.put("cancel_cut_type", 3);
plan.put("cancel_amount", 0);
planCode.add(plan);
body.put("planCode", planCode);
try {
@@ -1066,7 +1054,8 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
Map<String, Object> staticEnvelope = buildHuidingStaticEnvelope(ds, hotel, rooms);
downstreamClient.hotelSync(partnerCode, hotelId, supplierHotelId, staticEnvelope);
List<Map<String, Object>> roomPriceInfoList = buildHuidingRoomPriceInfoList(supplierHotelId, products, dailies);
List<Map<String, Object>> roomPriceInfoList = buildHuidingRoomPriceInfoList(hotel, supplierHotelId, products,
dailies);
Map<String, Object> dateEnvelope = buildHuidingDatePushEnvelope(ds, hotel, dailyFrom, dailyTo, roomPriceInfoList);
downstreamClient.dailySync(partnerCode, hotelId, supplierHotelId, dateEnvelope);
@@ -1154,7 +1143,7 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
/**
* 按产品聚合日历;慧订 roomTypeCode 全局唯一;ratePlanCode 按早餐建议值。
*/
private List<Map<String, Object>> buildHuidingRoomPriceInfoList(String supplierHotelId,
private List<Map<String, Object>> buildHuidingRoomPriceInfoList(SmHotelEntity hotel, String supplierHotelId,
List<SmHotelProductEntity> products, List<SmHotelDailyEntity> dailies) {
Map<String, SmHotelProductEntity> productByCode = new LinkedHashMap<>();
for (SmHotelProductEntity p : products) {
@@ -1203,10 +1192,8 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
night.put("price", price);
night.put("roomCount", remain);
night.put("status", status);
night.put("cancelType", 13);
night.put("daySelect", 0);
night.put("timeSelect", 18);
night.put("cutValue", 0);
// 慧订:按酒店来源取消规则推送(本地默认 30 分钟可取消)
SmHotelCancelPolicy.putHuidingNightCancel(night, resolveUpstreamCancelPolicy(hotel, p));
planRateList.add(night);
}
if (planRateList.isEmpty()) {
@@ -1568,8 +1555,8 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
}
/*
* 产品:不看取消规则(本地无可靠取消数据,环启 canCancel=0 时也不必填)。
* 同房型+同早餐只能推 1 条:在有库存的候选里取价格最低的。
* 产品:上游(飞猪等)有取消规则则推 canCancel+cancelRule,让下游先自拦;
* 本地无规则时 canCancel=0。同房型+同早餐只推 1 条(有库存里最低价)。
*/
List<String> productErrors = new ArrayList<>();
int productOk = 0;
@@ -1607,8 +1594,7 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
body.put("breakfastNumber", breakfast);
body.put("apartmentType", p.getApartmentType() == null ? 1 : p.getApartmentType());
body.put("productType", p.getProductType() == null ? 2 : p.getProductType());
// 不传取消规则,固定不可取消,避免环启要求 cancelRule
body.put("canCancel", 0);
SmHotelCancelPolicy.putHuanqiProductCancel(body, resolveUpstreamCancelPolicy(hotel, p));
BigDecimal price = p.getSellPrice() != null ? p.getSellPrice() : p.getCostPrice();
body.put("homePrice", price == null || price.compareTo(BigDecimal.ZERO) <= 0 ? BigDecimal.ONE : price);
body.put("homeNumber", roomQtyMap.getOrDefault(roomCode, 1));
@@ -2105,6 +2091,8 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
product.setGroupCode(hotel.getGroupCode());
product.setProductCode(product.getProductCode().trim());
product.setRoomTypeCode(product.getRoomTypeCode().trim());
// 本地默认:预订后 30 分钟内可取消,推下游不再一律不可取消
SmHotelCancelPolicy.ensureLocalProductDefaults(product);
SmHotelRoomEntity room = roomMapper.selectOne(Wrappers.<SmHotelRoomEntity>lambdaQuery()
.eq(SmHotelRoomEntity::getHotelId, hotelId)
.eq(SmHotelRoomEntity::getRoomTypeCode, product.getRoomTypeCode())
@@ -2408,12 +2396,12 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
return daily;
}
/** 按酒店 sheet_name 选上游取消规则;无 SPI 时仅用已归一 cancelRule。 */
/** 按酒店 sheet_name 选取消规则;本地无配置时默认预订后 30 分钟可取消。 */
private SmHotelCancelPolicy.Policy resolveUpstreamCancelPolicy(SmHotelEntity hotel, SmHotelProductEntity product) {
String sheet = hotel == null ? null : hotel.getSheetName();
return upstreamCancelPolicyRegistry.find(sheet)
.map(pol -> pol.resolve(product))
.orElseGet(() -> SmHotelCancelPolicy.resolveStored(product));
.orElseGet(() -> SmHotelCancelPolicy.resolveForHotel(sheet, product));
}
private SmHotelEntity requireLocalHotel(Long hotelId) {
@@ -413,7 +413,15 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
}
}
if (product != null) {
o.setProductName(product.getProductName());
SmHotelProductEntity prod = product;
o.setProductName(prod.getProductName());
o.setProductCanCancel(prod.getCanCancel());
String sheet = hotel == null ? null : hotel.getSheetName();
SmHotelCancelPolicy.Policy pol = upstreamCancelPolicyRegistry.find(sheet)
.map(p -> p.resolve(prod))
.orElseGet(() -> SmHotelCancelPolicy.resolveForHotel(sheet, prod));
String hint = SmHotelCancelPolicy.formatDisplay(pol);
o.setCancelRuleHint(StrUtil.isBlank(hint) ? null : hint);
}
String roomTypeCode = StrUtil.blankToDefault(o.getRoomTypeCode(),
product == null ? null : product.getRoomTypeCode());
@@ -650,24 +658,36 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
.last("limit 1"));
}
if (product != null) {
SmHotelProductEntity prod = product;
SmHotelOrderDetailVo.ProductBrief pb = new SmHotelOrderDetailVo.ProductBrief();
pb.setId(product.getId());
pb.setProductCode(product.getProductCode());
pb.setProductName(product.getProductName());
pb.setRoomTypeCode(product.getRoomTypeCode());
pb.setPriceCode(product.getPriceCode());
pb.setCancelCode(product.getCancelCode());
pb.setMealType(product.getMealType());
pb.setMealCount(product.getMealCount());
pb.setCostPrice(product.getCostPrice());
pb.setSellPrice(product.getSellPrice());
pb.setStock(product.getStock());
pb.setCanCancel(product.getCanCancel());
pb.setCancelRule(product.getCancelRule());
pb.setProductType(product.getProductType());
pb.setApartmentType(product.getApartmentType());
pb.setId(prod.getId());
pb.setProductCode(prod.getProductCode());
pb.setProductName(prod.getProductName());
pb.setRoomTypeCode(prod.getRoomTypeCode());
pb.setPriceCode(prod.getPriceCode());
pb.setCancelCode(prod.getCancelCode());
pb.setMealType(prod.getMealType());
pb.setMealCount(prod.getMealCount());
pb.setCostPrice(prod.getCostPrice());
pb.setSellPrice(prod.getSellPrice());
pb.setStock(prod.getStock());
pb.setCanCancel(prod.getCanCancel());
pb.setCancelRule(prod.getCancelRule());
pb.setProductType(prod.getProductType());
pb.setApartmentType(prod.getApartmentType());
vo.setProduct(pb);
order.setProductName(product.getProductName());
order.setProductName(prod.getProductName());
order.setProductCanCancel(prod.getCanCancel());
String sheet = hotel == null ? null : hotel.getSheetName();
SmHotelCancelPolicy.Policy pol = upstreamCancelPolicyRegistry.find(sheet)
.map(p -> p.resolve(prod))
.orElseGet(() -> SmHotelCancelPolicy.resolveForHotel(sheet, prod));
String hint = SmHotelCancelPolicy.formatDisplay(pol);
order.setCancelRuleHint(StrUtil.isBlank(hint) ? null : hint);
// 详情展示用可读文案覆盖原始 JSON,便于人工核对
if (StrUtil.isNotBlank(hint)) {
pb.setCancelRule(hint);
}
}
String roomTypeCode = StrUtil.blankToDefault(order.getRoomTypeCode(),
@@ -2058,6 +2078,9 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
return R.ok(true, "已拒绝并取消");
}
/** 挂起取消前状态写入 lastCompensateMsg,拒绝时恢复 */
private static final String PENDING_CANCEL_FROM_PREFIX = "PENDING_CANCEL_FROM:";
@Override
@Transactional(rollbackFor = Exception.class)
public R<Map<String, Object>> cancelOrParkForDownstream(Long id, String reason) {
@@ -2077,36 +2100,20 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
parked.put("orderStatus", "PENDING_CANCEL");
return R.ok(parked, "取消申请处理中");
}
// 按酒店上游渠道规则拦截(各家结构不同,经 SPI 归一后判定)
String policyDeny = denyCancelByUpstreamPolicy(order, reason);
if (policyDeny != null) {
return R.failed(policyDeny);
}
SmHotelEntity hotel = order.getHotelId() == null ? null : hotelMapper.selectById(order.getHotelId());
boolean local = hotel != null && SmHotelDataSource.isLocal(hotel.getSheetName());
boolean confirmed = "UPSTREAM_OK".equals(order.getOrderStatus()) || "CHECKED_IN".equals(order.getOrderStatus());
// 本地已确认:挂起待人工同意/拒绝取消(圣诺取消中 / 慧订稍后取消确认)
if (local && confirmed) {
String tip = StrUtil.blankToDefault(reason, "下游申请取消");
order.setOrderStatus("PENDING_CANCEL");
order.setFailMsg(StrUtil.maxLength(tip + ",待人工确认是否同意取消", 500));
order.setLastCompensateMsg(StrUtil.maxLength("下游取消申请待确认", 200));
baseMapper.updateById(order);
Map<String, Object> parked = new LinkedHashMap<>(4);
parked.put("parked", true);
parked.put("orderStatus", "PENDING_CANCEL");
parked.put("orderId", order.getId());
log.info("|酒店订单|下游取消挂起待确认|orderId={}|channelNo={}|", order.getId(), order.getChannelNo());
return R.ok(parked, "取消申请已受理,待人工确认");
}
R<Boolean> r = cancelByAdmin(id, StrUtil.blankToDefault(reason, "下游取消"));
if (r == null || r.getCode() != 0) {
return R.failed(r == null ? "取消失败" : StrUtil.blankToDefault(r.getMsg(), "取消失败"));
}
Map<String, Object> done = new LinkedHashMap<>(4);
done.put("parked", false);
done.put("orderStatus", "CANCELLED");
return R.ok(done, "取消成功");
// 各上游/下游取消规则不一,不硬拦:一律挂起待人工核对产品取消规则后同意/拒绝
String prevStatus = StrUtil.blankToDefault(order.getOrderStatus(), "UPSTREAM_OK");
String tip = StrUtil.blankToDefault(reason, "下游申请取消");
order.setOrderStatus("PENDING_CANCEL");
order.setFailMsg(StrUtil.maxLength(tip + ",待人工确认是否同意取消", 500));
order.setLastCompensateMsg(StrUtil.maxLength(PENDING_CANCEL_FROM_PREFIX + prevStatus, 200));
baseMapper.updateById(order);
Map<String, Object> parked = new LinkedHashMap<>(4);
parked.put("parked", true);
parked.put("orderStatus", "PENDING_CANCEL");
parked.put("orderId", order.getId());
log.info("|酒店订单|下游取消挂起待确认|orderId={}|fromStatus={}|channelNo={}|", order.getId(), prevStatus,
order.getChannelNo());
return R.ok(parked, "取消申请已受理,待人工确认");
}
@Override
@@ -2126,10 +2133,6 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
if (!"PENDING_CANCEL".equals(order.getOrderStatus())) {
return R.failed("仅「待取消确认」订单可同意取消,当前状态=" + order.getOrderStatus());
}
SmHotelEntity hotel = order.getHotelId() == null ? null : hotelMapper.selectById(order.getHotelId());
if (hotel == null || !SmHotelDataSource.isLocal(hotel.getSheetName())) {
return R.failed("仅本地数据来源酒店订单可操作取消确认");
}
String tip = StrUtil.blankToDefault(reason, "人工同意取消");
// 先关单成功,再回写下游取消确认,避免下游已确认我方仍失败
if (PAY_PAID.equals(order.getPayStatus())) {
@@ -2164,17 +2167,24 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
if (!"PENDING_CANCEL".equals(order.getOrderStatus())) {
return R.failed("仅「待取消确认」订单可拒绝取消申请,当前状态=" + order.getOrderStatus());
}
SmHotelEntity hotel = order.getHotelId() == null ? null : hotelMapper.selectById(order.getHotelId());
if (hotel == null || !SmHotelDataSource.isLocal(hotel.getSheetName())) {
return R.failed("仅本地数据来源酒店订单可操作取消确认");
}
order.setOrderStatus("UPSTREAM_OK");
String resume = parsePendingCancelFromStatus(order.getLastCompensateMsg());
order.setOrderStatus(resume);
order.setFailMsg(null);
order.setLastCompensateMsg(StrUtil.maxLength(
StrUtil.blankToDefault(reason, "人工拒绝下游取消申请,订单继续有效"), 500));
baseMapper.updateById(order);
notifyDownstreamAfterLocalCancelDecision(order, false);
return R.ok(true, "已拒绝取消申请,订单保持预订成功");
return R.ok(true, "已拒绝取消申请,订单恢复为「" + resume + "」");
}
private static String parsePendingCancelFromStatus(String lastCompensateMsg) {
if (StrUtil.isNotBlank(lastCompensateMsg) && lastCompensateMsg.startsWith(PENDING_CANCEL_FROM_PREFIX)) {
String s = lastCompensateMsg.substring(PENDING_CANCEL_FROM_PREFIX.length()).trim();
if (StrUtil.isNotBlank(s) && !"PENDING_CANCEL".equals(s) && !"CANCELLED".equals(s)) {
return s;
}
}
return "UPSTREAM_OK";
}
/** 确认/拒绝时校验订单酒店在当前用户 DataScope 内(无酒店 ID 则拒绝) */
@@ -2808,10 +2818,6 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
|| ShangyouBookStatus.X.equalsIgnoreCase(order.getBookStatus())) {
return R.ok(true, "订单已取消");
}
String policyDeny = denyCancelByUpstreamPolicy(order, reason);
if (policyDeny != null) {
return R.failed(policyDeny);
}
if (SmHotelInventoryStatus.isApplied(order.getInventoryStatus())
|| (order.getPlatformNo() != null && order.getPlatformNo().startsWith("LOCAL-"))) {
restoreLocalInventory(order);
@@ -3234,50 +3240,6 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
StrUtil.blankToDefault(reason, "上游下单失败自动退款")));
}
/**
* 非本地酒店:按 hotel.sheet_name 对应上游渠道的取消规则判断。
* 本地酒店、未履约未支付、系统超时关单等跳过;无规则信息时不拦。
*
* @return null=允许;非空=拒绝文案
*/
private String denyCancelByUpstreamPolicy(SmHotelOrderEntity order, String reason) {
if (order == null) {
return null;
}
String tip = StrUtil.blankToDefault(reason, "");
if (tip.contains("支付超时") || tip.contains("系统自动") || tip.contains("自动关单")) {
return null;
}
if (PAY_UNPAID.equals(order.getPayStatus())
|| "CREATED".equals(order.getOrderStatus())
|| "PENDING_CONFIRM".equals(order.getOrderStatus())
|| StrUtil.isBlank(order.getPlatformNo())) {
return null;
}
SmHotelEntity hotel = order.getHotelId() == null ? null : hotelMapper.selectById(order.getHotelId());
if (hotel != null && SmHotelDataSource.isLocal(hotel.getSheetName())) {
return null;
}
Long hotelId = hotel != null ? hotel.getId() : order.getHotelId();
SmHotelProductEntity product = null;
if (StrUtil.isNotBlank(order.getProductCode())) {
product = productMapper.selectOne(Wrappers.<SmHotelProductEntity>lambdaQuery()
.eq(hotelId != null, SmHotelProductEntity::getHotelId, hotelId)
.eq(SmHotelProductEntity::getProductCode, order.getProductCode())
.last("limit 1"));
}
String sheet = hotel == null ? null : hotel.getSheetName();
SmHotelCancelPolicy.Policy policy = upstreamCancelPolicyRegistry.find(sheet)
.map(pol -> pol.resolve(product))
.orElseGet(() -> SmHotelCancelPolicy.resolveStored(product));
String deny = SmHotelCancelPolicy.denyReason(policy, order.getCheckInDate(), LocalDateTime.now());
if (deny != null) {
log.info("|酒店订单|取消被上游规则拒绝|orderId={}|channel={}|product={}|msg={}|", order.getId(),
upstreamCancelPolicyRegistry.resolveChannelKey(sheet), order.getProductCode(), deny);
}
return deny;
}
private R<Boolean> cancelUpstreamOrder(SmHotelOrderEntity order, String reason) {
SmHotelEntity hotel = order.getHotelId() == null ? null : hotelMapper.selectById(order.getHotelId());
Optional<HotelUpstreamOrderGateway> gatewayOpt = hotel == null ? Optional.empty()