This commit is contained in:
zjf committed 2026-10-28 18:04:03 +08:00
1 parent c339e5df09
commit a2dc29722b
13 files changed
+747 -19

No files matched your search

@@ -79,6 +79,24 @@ public class SmHotelOrderController {
return orderService.rejectLocalByAdmin(id, reason);
}
@SysLog("本地酒店同意取消申请")
@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) {
String reason = body == null ? null : String.valueOf(body.getOrDefault("reason", "人工同意取消"));
return orderService.confirmCancelLocalByAdmin(id, reason);
}
@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) {
String reason = body == null ? null : String.valueOf(body.getOrDefault("reason", "人工拒绝取消申请"));
return orderService.rejectCancelLocalByAdmin(id, reason);
}
@SysLog("后台退款酒店订单")
@Operation(summary = "后台退款:已支付订单原路退款并关闭预订")
@PostMapping("/refund/{id}")
@@ -0,0 +1,44 @@
package com.bsd.pjcloud.supermarket.integration.hotel.upstream;
import com.alibaba.fastjson.JSONObject;
import com.bsd.pjcloud.supermarket.entity.SmHotelProductEntity;
import com.bsd.pjcloud.supermarket.service.SmHotelCancelPolicy;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Optional;
/**
* 酒店上游取消规则解析(按数据来源渠道插拔)。
* <p>
* 新增第三方:实现本接口并注册为 Spring Bean,将对方原始取消结构归一到
* {@link SmHotelCancelPolicy};字典 {@code sm_hotel_data_source} 增加对应 item_value 即可。
* 下游取消 / 后台取消只依赖统一模型,不感知各家字段差异。
*/
public interface HotelUpstreamCancelPolicy {
/** 渠道标识,与 hotel.sheet_name / 字典值一致,如 shangyou */
String channel();
/**
* 从已落库产品解析统一规则(可兼容本渠道历史 rawJson)。
*
* @return null=无规则信息不拦截
*/
SmHotelCancelPolicy.Policy resolve(SmHotelProductEntity product);
/**
* 上游房价/产品原始 JSON → 统一 canCancel + cancelRule,供同步落库。
*
* @return empty=本条无取消信息
*/
Optional<NormalizedCancel> normalizeFromUpstreamRate(JSONObject rate);
default String denyReason(SmHotelProductEntity product, LocalDate checkIn, LocalDateTime now) {
return SmHotelCancelPolicy.denyReason(resolve(product), checkIn, now);
}
/** 同步落库用的归一结果 */
record NormalizedCancel(int canCancel, String cancelRuleJson) {
}
}
@@ -0,0 +1,55 @@
package com.bsd.pjcloud.supermarket.integration.hotel.upstream;
import cn.hutool.core.util.StrUtil;
import com.bsd.pjcloud.supermarket.constant.SmHotelDataSource;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* 按酒店数据来源(sheet_name)解析上游取消规则实现。
* <ul>
* <li>local:无上游规则</li>
* <li>空白历史:兼容映射到商游</li>
* <li>已注册渠道:返回对应实现</li>
* <li>未注册:empty,取消判定仅用已归一的 cancelRule/canCancel</li>
* </ul>
*/
@Component
public class HotelUpstreamCancelPolicyRegistry {
private final Map<String, HotelUpstreamCancelPolicy> byChannel = new HashMap<>();
public HotelUpstreamCancelPolicyRegistry(List<HotelUpstreamCancelPolicy> policies) {
if (policies != null) {
for (HotelUpstreamCancelPolicy p : policies) {
if (p == null || StrUtil.isBlank(p.channel())) {
continue;
}
byChannel.put(SmHotelDataSource.normalize(p.channel()).toLowerCase(), p);
}
}
}
public String resolveChannelKey(String hotelSheetName) {
if (SmHotelDataSource.isLocal(hotelSheetName)) {
return SmHotelDataSource.LOCAL;
}
String ds = SmHotelDataSource.normalize(hotelSheetName);
if (ds.isEmpty()) {
return SmHotelDataSource.SHANGYOU;
}
return ds.toLowerCase();
}
public Optional<HotelUpstreamCancelPolicy> find(String hotelSheetName) {
if (SmHotelDataSource.isLocal(hotelSheetName)) {
return Optional.empty();
}
String key = resolveChannelKey(hotelSheetName);
return Optional.ofNullable(byChannel.get(key.toLowerCase()));
}
}
@@ -5,7 +5,8 @@ import com.alibaba.fastjson.JSONObject;
/**
* 酒店上游订单网关(按数据来源渠道插拔)。
* <p>
* 新增第三方:实现本接口并注册为 Spring Bean,字典 {@code sm_hotel_data_source} 增加对应 item_value 即可。
* 新增第三方:实现本接口并注册为 Spring Bean,字典 {@code sm_hotel_data_source} 增加对应 item_value 即可;
* 取消规则请同步实现 {@link HotelUpstreamCancelPolicy},对方原始结构归一后再做下游/后台取消判定。
*/
public interface HotelUpstreamOrderGateway {
@@ -0,0 +1,77 @@
package com.bsd.pjcloud.supermarket.integration.hotel.upstream;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import com.alibaba.fastjson.JSONObject;
import com.bsd.pjcloud.supermarket.constant.SmHotelDataSource;
import com.bsd.pjcloud.supermarket.entity.SmHotelProductEntity;
import com.bsd.pjcloud.supermarket.service.SmHotelCancelPolicy;
import org.springframework.stereotype.Component;
import java.util.Optional;
/**
* 商游/飞猪取消规则:对方房价里的 {@code cancelInfo}
* (isCanCancel / lastCancelDay / lastCancelTime / cancelDesc)。
*/
@Component
public class ShangyouHotelUpstreamCancelPolicy implements HotelUpstreamCancelPolicy {
@Override
public String channel() {
return SmHotelDataSource.SHANGYOU;
}
@Override
public SmHotelCancelPolicy.Policy resolve(SmHotelProductEntity product) {
if (product == null) {
return null;
}
SmHotelCancelPolicy.Policy fromRule = SmHotelCancelPolicy.parseStoredCancelRule(product.getCancelRule());
SmHotelCancelPolicy.Policy fromRaw = parseFliggyCancelInfo(product.getRawJson());
SmHotelCancelPolicy.Policy merged = SmHotelCancelPolicy.merge(fromRule, fromRaw);
if (merged == null) {
return SmHotelCancelPolicy.resolveStored(product);
}
merged.channel = StrUtil.blankToDefault(merged.channel, channel());
return SmHotelCancelPolicy.applyCanCancelField(merged, product.getCanCancel());
}
@Override
public Optional<NormalizedCancel> normalizeFromUpstreamRate(JSONObject rate) {
if (rate == null) {
return Optional.empty();
}
JSONObject cancelInfo = rate.getJSONObject("cancelInfo");
if (cancelInfo == null || cancelInfo.isEmpty()) {
return Optional.empty();
}
Boolean can = cancelInfo.getBoolean("isCanCancel");
int canCancel = Boolean.TRUE.equals(can) ? 1 : 0;
String rule = SmHotelCancelPolicy.toCancelRuleJson(channel(), can, cancelInfo.getInteger("lastCancelDay"),
cancelInfo.getString("lastCancelTime"), cancelInfo.getString("cancelDesc"));
return Optional.of(new NormalizedCancel(canCancel, rule));
}
/** 兼容尚未刷新 cancelRule 的旧产品:从 rawJson.cancelInfo 补齐。 */
private SmHotelCancelPolicy.Policy parseFliggyCancelInfo(String rawJson) {
if (StrUtil.isBlank(rawJson)) {
return null;
}
try {
cn.hutool.json.JSONObject rate = JSONUtil.parseObj(rawJson);
cn.hutool.json.JSONObject cancelInfo = rate.getJSONObject("cancelInfo");
if (cancelInfo == null || cancelInfo.isEmpty()) {
return null;
}
SmHotelCancelPolicy.Policy p = SmHotelCancelPolicy.fromUnifiedJson(cancelInfo);
if (p != null) {
p.channel = channel();
}
return p;
}
catch (Exception e) {
return null;
}
}
}
@@ -0,0 +1,224 @@
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.entity.SmHotelProductEntity;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
/**
* 上游取消规则的<strong>统一落库模型</strong>与截止时间判定。
* <p>
* 各上游渠道原始结构不同,解析由 {@code HotelUpstreamCancelPolicy} 按 sheet_name 插拔;
* 同步时尽量归一到 cancelRule JSON,下游取消 / 后台取消只认本模型。
* <p>
* cancelRule 推荐:
* {@code {"channel":"shangyou","isCanCancel":true,"lastCancelDay":1,"lastCancelTime":"18:00","cancelDesc":"..."}}
*/
public final class SmHotelCancelPolicy {
private static final DateTimeFormatter HM = DateTimeFormatter.ofPattern("H:mm");
private SmHotelCancelPolicy() {
}
/**
* @return null=允许取消;非空=拒绝原因
*/
public static String denyReason(Policy p, LocalDate checkIn, LocalDateTime now) {
if (p == null) {
return null;
}
if (!p.canCancel) {
return "按供应商取消规则不可取消"
+ (StrUtil.isNotBlank(p.desc) ? ":" + p.desc : "");
}
if (checkIn == null) {
return null;
}
LocalDateTime at = now == null ? LocalDateTime.now() : now;
int days = Math.max(0, p.lastCancelDay);
LocalTime time = p.lastCancelTime == null ? LocalTime.of(23, 59, 59) : p.lastCancelTime;
LocalDateTime deadline = checkIn.minusDays(days).atTime(time);
if (at.isAfter(deadline)) {
String when = days + "天 " + time.format(DateTimeFormatter.ofPattern("HH:mm"));
return "已过最晚取消时间(入住前" + when + ")"
+ (StrUtil.isNotBlank(p.desc) ? ";" + p.desc : "");
}
return null;
}
/**
* 仅读已归一的 cancelRule + canCancel(无渠道 rawJson)。
* 无渠道 SPI 或本地产品时的兜底。
*/
public static Policy resolveStored(SmHotelProductEntity product) {
if (product == null) {
return null;
}
Policy fromRule = parseStoredCancelRule(product.getCancelRule());
if (fromRule == null) {
if (product.getCanCancel() == null && StrUtil.isBlank(product.getCancelRule())) {
return null;
}
fromRule = new Policy();
fromRule.canCancel = Integer.valueOf(1).equals(product.getCanCancel());
fromRule.desc = StrUtil.trim(product.getCancelRule());
if (looksLikeJson(fromRule.desc)) {
fromRule.desc = null;
}
}
else if (product.getCanCancel() != null) {
fromRule.canCancel = Integer.valueOf(1).equals(product.getCanCancel());
}
return fromRule;
}
/** @deprecated 请按酒店 sheet_name 走渠道 SPI;保留给仅有归一字段的推送场景 */
@Deprecated
public static Policy resolve(SmHotelProductEntity product) {
return resolveStored(product);
}
/** @deprecated 使用 {@link #denyReason(Policy, LocalDate, LocalDateTime)} */
@Deprecated
public static String denyReason(SmHotelProductEntity product, LocalDate checkIn, LocalDateTime now) {
return denyReason(resolveStored(product), checkIn, now);
}
/** 同步时写入统一 cancelRule JSON。 */
public static String toCancelRuleJson(String channel, Boolean isCanCancel, Integer lastCancelDay,
String lastCancelTime, String cancelDesc) {
JSONObject o = new JSONObject();
if (StrUtil.isNotBlank(channel)) {
o.set("channel", channel.trim().toLowerCase());
}
o.set("isCanCancel", Boolean.TRUE.equals(isCanCancel));
if (lastCancelDay != null) {
o.set("lastCancelDay", lastCancelDay);
}
if (StrUtil.isNotBlank(lastCancelTime)) {
o.set("lastCancelTime", lastCancelTime.trim());
}
if (StrUtil.isNotBlank(cancelDesc)) {
o.set("cancelDesc", cancelDesc.trim());
}
return o.toString();
}
/** 兼容旧调用(无 channel)。 */
public static String toCancelRuleJson(Boolean isCanCancel, Integer lastCancelDay, String lastCancelTime,
String cancelDesc) {
return toCancelRuleJson(null, isCanCancel, lastCancelDay, lastCancelTime, cancelDesc);
}
public static Policy parseStoredCancelRule(String raw) {
if (StrUtil.isBlank(raw)) {
return null;
}
String s = raw.trim();
if (!looksLikeJson(s)) {
Policy p = new Policy();
p.desc = s;
p.canCancel = true;
return p;
}
try {
return fromUnifiedJson(JSONUtil.parseObj(s));
}
catch (Exception e) {
Policy p = new Policy();
p.desc = s;
p.canCancel = true;
return p;
}
}
public static Policy fromUnifiedJson(JSONObject o) {
if (o == null || o.isEmpty()) {
return null;
}
Policy p = new Policy();
if (o.containsKey("isCanCancel")) {
p.canCancel = Boolean.TRUE.equals(o.getBool("isCanCancel"));
}
else {
p.canCancel = true;
}
Integer day = o.getInt("lastCancelDay");
p.lastCancelDay = day == null ? 0 : Math.max(0, day);
p.lastCancelTime = parseHm(o.getStr("lastCancelTime"));
p.desc = StrUtil.blankToDefault(o.getStr("cancelDesc"), o.getStr("desc"));
p.channel = o.getStr("channel");
return p;
}
public static Policy merge(Policy a, Policy b) {
if (a == null) {
return b;
}
if (b == null) {
return a;
}
Policy p = new Policy();
boolean aTimed = a.lastCancelDay > 0 || a.lastCancelTime != null;
boolean bTimed = b.lastCancelDay > 0 || b.lastCancelTime != null;
if (aTimed) {
p.canCancel = a.canCancel;
}
else if (bTimed) {
p.canCancel = b.canCancel;
}
else {
p.canCancel = a.canCancel;
}
p.lastCancelDay = a.lastCancelDay > 0 ? a.lastCancelDay : b.lastCancelDay;
p.lastCancelTime = a.lastCancelTime != null ? a.lastCancelTime : b.lastCancelTime;
p.desc = StrUtil.blankToDefault(a.desc, b.desc);
p.channel = StrUtil.blankToDefault(a.channel, b.channel);
return p;
}
public static Policy applyCanCancelField(Policy base, Integer canCancel) {
if (base == null) {
return null;
}
if (canCancel != null) {
base.canCancel = Integer.valueOf(1).equals(canCancel);
}
return base;
}
public static LocalTime parseHm(String raw) {
if (StrUtil.isBlank(raw)) {
return null;
}
String s = raw.trim();
try {
if (s.length() >= 8 && s.charAt(2) == ':') {
return LocalTime.parse(s.substring(0, 5), HM);
}
return LocalTime.parse(s, HM);
}
catch (Exception e) {
return null;
}
}
public static boolean looksLikeJson(String s) {
return s != null && s.length() > 1 && s.charAt(0) == '{';
}
public static final class Policy {
/** 来源渠道,如 shangyou;可空 */
public String channel;
public boolean canCancel;
public int lastCancelDay;
public LocalTime lastCancelTime;
public String desc;
}
}
@@ -34,4 +34,7 @@ public interface SmHotelHuidingOrderService {
/** 本地酒店人工拒绝后回写慧订 operationOrder=2 */
void notifyLocalOrderRejected(SmHotelOrderEntity order);
/** 本地同意取消后回写慧订 operationOrder=3(取消确认) */
void notifyLocalCancelConfirmed(SmHotelOrderEntity order);
}
@@ -101,6 +101,18 @@ public interface SmHotelOrderService {
*/
R<Boolean> rejectLocalByAdmin(Long id, String reason);
/**
* 下游取消入站:本地已确认订单挂起为 PENDING_CANCEL 待人工;其余即时取消。
* data.parked=true 表示已挂起;false 表示已即时取消。
*/
R<Map<String, Object>> cancelOrParkForDownstream(Long id, String reason);
/** 本地:同意下游取消申请(PENDING_CANCEL → 关单并回写下游取消确认) */
R<Boolean> confirmCancelLocalByAdmin(Long id, String reason);
/** 本地:拒绝下游取消申请(PENDING_CANCEL → 恢复预订成功,并回写下游仍接单) */
R<Boolean> rejectCancelLocalByAdmin(Long id, String reason);
/** Worker:扫描到期补偿任务 */
int processDueCompensate(int limit);
@@ -22,4 +22,13 @@ public interface SmHotelShengnuoOrderService {
/** 本地酒店人工拒绝后 orderChange=已取消(5) */
void notifyLocalOrderRejected(SmHotelOrderEntity order);
/** 本地同意取消后 orderChange=已取消(5) */
void notifyLocalCancelConfirmed(SmHotelOrderEntity order);
/** 本地拒绝取消申请后 orderChange=已接单(2) */
void notifyLocalCancelRejected(SmHotelOrderEntity order);
/** 本地挂起取消申请时 orderChange=取消中(4) */
void notifyLocalCancelPending(SmHotelOrderEntity order);
}
@@ -281,13 +281,20 @@ public class SmHotelHuidingOrderServiceImpl implements SmHotelHuidingOrderServic
&& !partnerCode.equals(order.getPartnerCode())) {
return fail200("订单不属于当前伙伴");
}
R<Boolean> r = hotelOrderService.cancelByAdmin(order.getId(), "慧订取消");
R<Map<String, Object>> r = hotelOrderService.cancelOrParkForDownstream(order.getId(), "慧订取消");
if (r == null || r.getCode() != 0) {
return fail200(r == null ? "取消失败" : r.getMsg());
}
boolean parked = r.getData() != null && Boolean.TRUE.equals(r.getData().get("parked"));
Map<String, Object> outer = ok200();
if (parked) {
// 本地已确认:挂起待人工,暂不推取消确认(3)
outer.put("msg", "取消申请已受理,待确认");
outer.put("data", true);
return outer;
}
SmHotelOrderEntity latest = orderMapper.selectById(order.getId());
pushOperationOrder(partnerCode, latest != null ? latest : order, OP_CANCEL_CONFIRM);
Map<String, Object> outer = ok200();
outer.put("msg", "取消申请已处理");
outer.put("data", true);
return outer;
@@ -439,9 +446,9 @@ public class SmHotelHuidingOrderServiceImpl implements SmHotelHuidingOrderServic
}
}
/** 上游/人工已明确订成(不含待确认) */
/** 上游/人工已明确订成(不含待确认/待取消确认) */
private static boolean isBookConfirmed(SmHotelOrderEntity order) {
if (order == null || isPendingConfirm(order)) {
if (order == null || isPendingConfirm(order) || "PENDING_CANCEL".equals(order.getOrderStatus())) {
return false;
}
if ("UPSTREAM_OK".equals(order.getOrderStatus()) || "CHECKED_IN".equals(order.getOrderStatus())) {
@@ -450,7 +457,8 @@ public class SmHotelHuidingOrderServiceImpl implements SmHotelHuidingOrderServic
return StrUtil.isNotBlank(order.getPlatformNo())
&& !"UPSTREAM_FAIL".equals(order.getOrderStatus())
&& !"NEED_MANUAL".equals(order.getOrderStatus())
&& !"CANCELLED".equals(order.getOrderStatus());
&& !"CANCELLED".equals(order.getOrderStatus())
&& !"PENDING_CANCEL".equals(order.getOrderStatus());
}
private static boolean isPendingConfirm(SmHotelOrderEntity order) {
@@ -473,6 +481,14 @@ public class SmHotelHuidingOrderServiceImpl implements SmHotelHuidingOrderServic
pushOperationOrder(order.getPartnerCode(), order, OP_REJECT);
}
@Override
public void notifyLocalCancelConfirmed(SmHotelOrderEntity order) {
if (order == null || StrUtil.isBlank(order.getPartnerCode())) {
return;
}
pushOperationOrder(order.getPartnerCode(), order, OP_CANCEL_CONFIRM);
}
private String resolvePartnerByNotifyPass(String pass) {
if (StrUtil.isBlank(pass)) {
return null;
@@ -17,6 +17,8 @@ import com.bsd.pjcloud.supermarket.constant.SmHotelDownstreamPushKind;
import com.bsd.pjcloud.supermarket.integration.downstream.huiding.HuidingDownstreamHotelAdapter;
import com.bsd.pjcloud.supermarket.integration.downstream.shengnuo.ShengnuoDownstreamHotelAdapter;
import com.bsd.pjcloud.supermarket.integration.downstream.shengnuo.ShengnuoIdMappingCache;
import com.bsd.pjcloud.supermarket.integration.hotel.upstream.HotelUpstreamCancelPolicy;
import com.bsd.pjcloud.supermarket.integration.hotel.upstream.HotelUpstreamCancelPolicyRegistry;
import com.bsd.pjcloud.supermarket.dto.SmHotelLocalDailyBatchDTO;
import com.bsd.pjcloud.supermarket.entity.*;
import com.bsd.pjcloud.supermarket.integration.downstream.DownstreamHotelClient;
@@ -25,6 +27,7 @@ import com.bsd.pjcloud.supermarket.integration.shangyou.ShangyouHotelApi;
import com.bsd.pjcloud.supermarket.integration.shangyou.ShangyouHotelClient;
import com.bsd.pjcloud.supermarket.mapper.*;
import com.bsd.pjcloud.supermarket.service.SmHotelAvailabilityNotifyService;
import com.bsd.pjcloud.supermarket.service.SmHotelCancelPolicy;
import com.bsd.pjcloud.supermarket.service.SmHotelChannelConfigService;
import com.bsd.pjcloud.supermarket.service.SmHotelChannelDeptService;
import com.bsd.pjcloud.supermarket.service.SmHotelDownstreamAutoPushService;
@@ -115,6 +118,9 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
private final ShengnuoIdMappingCache shengnuoIdMappingCache;
/** 按上游渠道解析取消规则(商游/后续其它家) */
private final HotelUpstreamCancelPolicyRegistry upstreamCancelPolicyRegistry;
public SmHotelMidServiceImpl(ShangyouHotelClient upstreamClient, ShangyouHotelProperties upstreamProperties,
DownstreamHotelClient downstreamClient, HotelDownstreamProperties downstreamProperties,
SmHotelPriceConfigService priceConfigService, SmHotelChannelConfigService channelConfigService,
@@ -124,12 +130,14 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
@Lazy SmHotelAvailabilityNotifyService availabilityNotifyService, SmHotelMapper hotelMapper,
SmHotelRoomMapper roomMapper, SmHotelProductMapper productMapper, SmHotelDailyMapper dailyMapper,
StringRedisTemplate stringRedisTemplate, ShengnuoDownstreamHotelAdapter shengnuoAdapter,
ShengnuoIdMappingCache shengnuoIdMappingCache) {
ShengnuoIdMappingCache shengnuoIdMappingCache,
HotelUpstreamCancelPolicyRegistry upstreamCancelPolicyRegistry) {
this.upstreamClient = upstreamClient;
this.upstreamProperties = upstreamProperties;
this.downstreamClient = downstreamClient;
this.shengnuoAdapter = shengnuoAdapter;
this.shengnuoIdMappingCache = shengnuoIdMappingCache;
this.upstreamCancelPolicyRegistry = upstreamCancelPolicyRegistry;
this.downstreamProperties = downstreamProperties;
this.priceConfigService = priceConfigService;
this.channelConfigService = channelConfigService;
@@ -697,10 +705,20 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
List<Map<String, Object>> planCode = new ArrayList<>(1);
Map<String, Object> plan = new LinkedHashMap<>();
plan.put("breakfast", breakfast);
plan.put("is_cancel", 1);
// 圣诺: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);
plan.put("sale", 1);
plan.put("cancel_advance_day", 0);
plan.put("cancel_specific_date", "18:00");
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);
@@ -2390,6 +2408,14 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
return daily;
}
/** 按酒店 sheet_name 选上游取消规则;无 SPI 时仅用已归一 cancelRule。 */
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));
}
private SmHotelEntity requireLocalHotel(Long hotelId) {
// 手工维护房型/产品/日历:本地来源 + 部门数据隔离
SmHotelEntity hotel = requireHotelInScope(hotelId);
@@ -2567,11 +2593,15 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
int breakfast = resolveBreakfast(rate);
int canCancel = 0;
String cancelRule = null;
JSONObject cancelInfo = rate.getJSONObject("cancelInfo");
if (cancelInfo != null) {
Boolean can = cancelInfo.getBoolean("isCanCancel");
canCancel = Boolean.TRUE.equals(can) ? 1 : 0;
cancelRule = cancelInfo.getString("cancelDesc");
boolean hasCancel = false;
// 商游拉价路径:按渠道 SPI 归一取消规则(后续其它上游在各自拉取处同样调用 registry)
Optional<HotelUpstreamCancelPolicy.NormalizedCancel> normalizedCancel = upstreamCancelPolicyRegistry
.find(SmHotelDataSource.SHANGYOU)
.flatMap(pol -> pol.normalizeFromUpstreamRate(rate));
if (normalizedCancel.isPresent()) {
hasCancel = true;
canCancel = normalizedCancel.get().canCancel();
cancelRule = normalizedCancel.get().cancelRuleJson();
}
Map<String, Map<String, Integer>> stockByRoomDate = new HashMap<>();
@@ -2649,6 +2679,12 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
if (stock > (product.getStock() == null ? 0 : product.getStock())) {
product.setStock(stock);
}
// 取消规则随房价刷新,避免旧产品仍用过期规则
if (hasCancel) {
product.setCanCancel(canCancel);
product.setCancelRule(cancelRule);
product.setRawJson(rate.toJSONString());
}
}
if (roomTypesOut != null) {
roomTypesOut.add(roomTypeId);
@@ -16,12 +16,14 @@ import com.bsd.pjcloud.supermarket.constant.SmHotelStayDates;
import com.bsd.pjcloud.supermarket.entity.*;
import com.bsd.pjcloud.supermarket.event.HotelUpstreamFailRefundEvent;
import com.bsd.pjcloud.supermarket.integration.downstream.DownstreamOpenCallbackClient;
import com.bsd.pjcloud.supermarket.integration.hotel.upstream.HotelUpstreamCancelPolicyRegistry;
import com.bsd.pjcloud.supermarket.integration.hotel.upstream.HotelUpstreamOrderGateway;
import com.bsd.pjcloud.supermarket.integration.hotel.upstream.HotelUpstreamOrderGatewayRegistry;
import com.bsd.pjcloud.supermarket.integration.hotel.upstream.HotelUpstreamOrderResult;
import com.bsd.pjcloud.supermarket.integration.shangyou.ShangyouBookStatus;
import com.bsd.pjcloud.supermarket.mapper.*;
import com.bsd.pjcloud.supermarket.service.SmHotelAvailabilityNotifyService;
import com.bsd.pjcloud.supermarket.service.SmHotelCancelPolicy;
import com.bsd.pjcloud.supermarket.service.SmHotelChannelConfigService;
import com.bsd.pjcloud.supermarket.service.SmHotelChannelDeptService;
import com.bsd.pjcloud.supermarket.service.SmHotelDownstreamAutoPushService;
@@ -103,6 +105,8 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
private final HotelUpstreamOrderGatewayRegistry upstreamGatewayRegistry;
private final HotelUpstreamCancelPolicyRegistry upstreamCancelPolicyRegistry;
private final SmHotelOrderUpstreamLogService upstreamLogService;
private final ApplicationEventPublisher eventPublisher;
@@ -2054,6 +2058,125 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
return R.ok(true, "已拒绝并取消");
}
@Override
@Transactional(rollbackFor = Exception.class)
public R<Map<String, Object>> cancelOrParkForDownstream(Long id, String reason) {
SmHotelOrderEntity order = baseMapper.selectById(id);
if (order == null) {
return R.failed("订单不存在");
}
if ("CANCELLED".equals(order.getOrderStatus()) || PAY_REFUNDED.equals(order.getPayStatus())) {
Map<String, Object> done = new LinkedHashMap<>(4);
done.put("parked", false);
done.put("orderStatus", order.getOrderStatus());
return R.ok(done, "订单已取消");
}
if ("PENDING_CANCEL".equals(order.getOrderStatus())) {
Map<String, Object> parked = new LinkedHashMap<>(4);
parked.put("parked", true);
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, "取消成功");
}
@Override
@Transactional(rollbackFor = Exception.class)
public R<Boolean> confirmCancelLocalByAdmin(Long id, String reason) {
SmHotelOrderEntity order = baseMapper.selectById(id);
if (order == null) {
return R.failed("订单不存在");
}
String scopeErr = assertOrderHotelInScope(order);
if (scopeErr != null) {
return R.failed(scopeErr);
}
if ("CANCELLED".equals(order.getOrderStatus()) || PAY_REFUNDED.equals(order.getPayStatus())) {
return R.ok(true, "订单已取消");
}
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())) {
R<Boolean> refundR = refundByAdmin(id, tip);
if (refundR == null || !refundR.isOk()) {
return R.failed(refundR == null ? "同意取消失败:退款异常" : refundR.getMsg());
}
order = baseMapper.selectById(id);
notifyDownstreamAfterLocalCancelDecision(order, true);
return R.ok(true, "已同意取消并退款");
}
R<Boolean> cancelR = cancelByAdmin(id, tip);
if (cancelR == null || !cancelR.isOk()) {
return R.failed(cancelR == null ? "同意取消失败" : cancelR.getMsg());
}
order = baseMapper.selectById(id);
notifyDownstreamAfterLocalCancelDecision(order, true);
return R.ok(true, "已同意取消");
}
@Override
@Transactional(rollbackFor = Exception.class)
public R<Boolean> rejectCancelLocalByAdmin(Long id, String reason) {
SmHotelOrderEntity order = baseMapper.selectById(id);
if (order == null) {
return R.failed("订单不存在");
}
String scopeErr = assertOrderHotelInScope(order);
if (scopeErr != null) {
return R.failed(scopeErr);
}
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");
order.setFailMsg(null);
order.setLastCompensateMsg(StrUtil.maxLength(
StrUtil.blankToDefault(reason, "人工拒绝下游取消申请,订单继续有效"), 500));
baseMapper.updateById(order);
notifyDownstreamAfterLocalCancelDecision(order, false);
return R.ok(true, "已拒绝取消申请,订单保持预订成功");
}
/** 确认/拒绝时校验订单酒店在当前用户 DataScope 内(无酒店 ID 则拒绝) */
private String assertOrderHotelInScope(SmHotelOrderEntity order) {
if (order == null || order.getHotelId() == null) {
@@ -2102,6 +2225,36 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
}
}
/** 按伙伴协议回写下游:同意取消 / 拒绝取消申请 */
private void notifyDownstreamAfterLocalCancelDecision(SmHotelOrderEntity order, boolean cancelApproved) {
if (order == null || StrUtil.isBlank(order.getPartnerCode())) {
return;
}
try {
HotelDownstreamProperties props = channelConfigService.resolveDownstreamRuntime(order.getPartnerCode());
String protocol = props == null ? null : props.getProtocol();
if (SmHotelDownstreamProtocol.isHuiding(protocol)) {
if (cancelApproved) {
huidingOrderService.notifyLocalCancelConfirmed(order);
}
// 慧订无「拒绝取消」专用码:不推 operationOrder,订单继续有效即可
return;
}
if (SmHotelDownstreamProtocol.isShengnuo(protocol)) {
if (cancelApproved) {
shengnuoOrderService.notifyLocalCancelConfirmed(order);
}
else {
shengnuoOrderService.notifyLocalCancelRejected(order);
}
}
}
catch (Exception e) {
log.warn("|本地取消确认|下游通知失败|orderId={}|approved={}|{}", order.getId(), cancelApproved,
e.getMessage());
}
}
/**
* 对占用日历日 CAS 增加 sold(过夜 [checkIn, checkOut);钟点房占入住当天)。
*
@@ -2655,6 +2808,10 @@ 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);
@@ -3077,6 +3234,50 @@ 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()
@@ -54,6 +54,8 @@ public class SmHotelShengnuoOrderServiceImpl implements SmHotelShengnuoOrderServ
private static final int ST_ACCEPTED = 2;
private static final int ST_CANCELING = 4;
private static final int ST_CANCELLED = 5;
private static final DateTimeFormatter DAY = DateTimeFormatter.ISO_LOCAL_DATE;
@@ -235,12 +237,17 @@ public class SmHotelShengnuoOrderServiceImpl implements SmHotelShengnuoOrderServ
&& !partnerCode.equals(order.getPartnerCode())) {
return fail("订单不属于当前伙伴");
}
R<Boolean> r = hotelOrderService.cancelByAdmin(order.getId(), "圣诺取消");
R<Map<String, Object>> r = hotelOrderService.cancelOrParkForDownstream(order.getId(), "圣诺取消");
if (r == null || r.getCode() != 0) {
// 拒绝取消:回写已接单(2)
pushOrderChange(partnerCode, orderId, ST_ACCEPTED);
return fail(r == null ? "取消失败" : r.getMsg());
}
boolean parked = r.getData() != null && Boolean.TRUE.equals(r.getData().get("parked"));
if (parked) {
// 本地已确认:取消中,待人工同意/拒绝
pushOrderChange(partnerCode, orderId, ST_CANCELING);
return ok("取消申请已受理,待确认", order.getId());
}
pushOrderChange(partnerCode, orderId, ST_CANCELLED);
return ok("取消成功", order.getId());
}
@@ -581,7 +588,7 @@ public class SmHotelShengnuoOrderServiceImpl implements SmHotelShengnuoOrderServ
}
private static boolean isBookConfirmed(SmHotelOrderEntity order) {
if (order == null || isPendingConfirm(order)) {
if (order == null || isPendingConfirm(order) || "PENDING_CANCEL".equals(order.getOrderStatus())) {
return false;
}
if ("UPSTREAM_OK".equals(order.getOrderStatus()) || "CHECKED_IN".equals(order.getOrderStatus())) {
@@ -590,7 +597,8 @@ public class SmHotelShengnuoOrderServiceImpl implements SmHotelShengnuoOrderServ
return StrUtil.isNotBlank(order.getPlatformNo())
&& !"UPSTREAM_FAIL".equals(order.getOrderStatus())
&& !"NEED_MANUAL".equals(order.getOrderStatus())
&& !"CANCELLED".equals(order.getOrderStatus());
&& !"CANCELLED".equals(order.getOrderStatus())
&& !"PENDING_CANCEL".equals(order.getOrderStatus());
}
private static boolean isPendingConfirm(SmHotelOrderEntity order) {
@@ -613,6 +621,30 @@ public class SmHotelShengnuoOrderServiceImpl implements SmHotelShengnuoOrderServ
pushOrderChange(order.getPartnerCode(), order.getChannelNo(), ST_CANCELLED);
}
@Override
public void notifyLocalCancelConfirmed(SmHotelOrderEntity order) {
if (order == null || StrUtil.isBlank(order.getPartnerCode()) || StrUtil.isBlank(order.getChannelNo())) {
return;
}
pushOrderChange(order.getPartnerCode(), order.getChannelNo(), ST_CANCELLED);
}
@Override
public void notifyLocalCancelRejected(SmHotelOrderEntity order) {
if (order == null || StrUtil.isBlank(order.getPartnerCode()) || StrUtil.isBlank(order.getChannelNo())) {
return;
}
pushOrderChange(order.getPartnerCode(), order.getChannelNo(), ST_ACCEPTED);
}
@Override
public void notifyLocalCancelPending(SmHotelOrderEntity order) {
if (order == null || StrUtil.isBlank(order.getPartnerCode()) || StrUtil.isBlank(order.getChannelNo())) {
return;
}
pushOrderChange(order.getPartnerCode(), order.getChannelNo(), ST_CANCELING);
}
private static Map<String, Object> ok(String msg, Long localId) {
Map<String, Object> m = new LinkedHashMap<>(6);
m.put("code", 0);