diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelOrderController.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelOrderController.java index 1473ec98e..255990ef1 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelOrderController.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelOrderController.java @@ -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 confirmCancel(@PathVariable Long id, @RequestBody(required = false) Map 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 rejectCancel(@PathVariable Long id, @RequestBody(required = false) Map body) { + String reason = body == null ? null : String.valueOf(body.getOrDefault("reason", "人工拒绝取消申请")); + return orderService.rejectCancelLocalByAdmin(id, reason); + } + @SysLog("后台退款酒店订单") @Operation(summary = "后台退款:已支付订单原路退款并关闭预订") @PostMapping("/refund/{id}") diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/hotel/upstream/HotelUpstreamCancelPolicy.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/hotel/upstream/HotelUpstreamCancelPolicy.java new file mode 100644 index 000000000..28bfa2919 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/hotel/upstream/HotelUpstreamCancelPolicy.java @@ -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; + +/** + * 酒店上游取消规则解析(按数据来源渠道插拔)。 + *

+ * 新增第三方:实现本接口并注册为 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 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) { + } +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/hotel/upstream/HotelUpstreamCancelPolicyRegistry.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/hotel/upstream/HotelUpstreamCancelPolicyRegistry.java new file mode 100644 index 000000000..bb2db0806 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/hotel/upstream/HotelUpstreamCancelPolicyRegistry.java @@ -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)解析上游取消规则实现。 + *

    + *
  • local:无上游规则
  • + *
  • 空白历史:兼容映射到商游
  • + *
  • 已注册渠道:返回对应实现
  • + *
  • 未注册:empty,取消判定仅用已归一的 cancelRule/canCancel
  • + *
+ */ +@Component +public class HotelUpstreamCancelPolicyRegistry { + + private final Map byChannel = new HashMap<>(); + + public HotelUpstreamCancelPolicyRegistry(List 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 find(String hotelSheetName) { + if (SmHotelDataSource.isLocal(hotelSheetName)) { + return Optional.empty(); + } + String key = resolveChannelKey(hotelSheetName); + return Optional.ofNullable(byChannel.get(key.toLowerCase())); + } +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/hotel/upstream/HotelUpstreamOrderGateway.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/hotel/upstream/HotelUpstreamOrderGateway.java index 4ea2e0f84..d7d06b611 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/hotel/upstream/HotelUpstreamOrderGateway.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/hotel/upstream/HotelUpstreamOrderGateway.java @@ -5,7 +5,8 @@ import com.alibaba.fastjson.JSONObject; /** * 酒店上游订单网关(按数据来源渠道插拔)。 *

- * 新增第三方:实现本接口并注册为 Spring Bean,字典 {@code sm_hotel_data_source} 增加对应 item_value 即可。 + * 新增第三方:实现本接口并注册为 Spring Bean,字典 {@code sm_hotel_data_source} 增加对应 item_value 即可; + * 取消规则请同步实现 {@link HotelUpstreamCancelPolicy},对方原始结构归一后再做下游/后台取消判定。 */ public interface HotelUpstreamOrderGateway { diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/hotel/upstream/ShangyouHotelUpstreamCancelPolicy.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/hotel/upstream/ShangyouHotelUpstreamCancelPolicy.java new file mode 100644 index 000000000..1e0807426 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/hotel/upstream/ShangyouHotelUpstreamCancelPolicy.java @@ -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 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; + } + } +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelCancelPolicy.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelCancelPolicy.java new file mode 100644 index 000000000..7939fa03e --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelCancelPolicy.java @@ -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; + +/** + * 上游取消规则的统一落库模型与截止时间判定。 + *

+ * 各上游渠道原始结构不同,解析由 {@code HotelUpstreamCancelPolicy} 按 sheet_name 插拔; + * 同步时尽量归一到 cancelRule JSON,下游取消 / 后台取消只认本模型。 + *

+ * 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; + } +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelHuidingOrderService.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelHuidingOrderService.java index 2355bb789..60a8233c0 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelHuidingOrderService.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelHuidingOrderService.java @@ -34,4 +34,7 @@ public interface SmHotelHuidingOrderService { /** 本地酒店人工拒绝后回写慧订 operationOrder=2 */ void notifyLocalOrderRejected(SmHotelOrderEntity order); + + /** 本地同意取消后回写慧订 operationOrder=3(取消确认) */ + void notifyLocalCancelConfirmed(SmHotelOrderEntity order); } diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelOrderService.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelOrderService.java index 33bd84c14..5d5b73697 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelOrderService.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelOrderService.java @@ -101,6 +101,18 @@ public interface SmHotelOrderService { */ R rejectLocalByAdmin(Long id, String reason); + /** + * 下游取消入站:本地已确认订单挂起为 PENDING_CANCEL 待人工;其余即时取消。 + * data.parked=true 表示已挂起;false 表示已即时取消。 + */ + R> cancelOrParkForDownstream(Long id, String reason); + + /** 本地:同意下游取消申请(PENDING_CANCEL → 关单并回写下游取消确认) */ + R confirmCancelLocalByAdmin(Long id, String reason); + + /** 本地:拒绝下游取消申请(PENDING_CANCEL → 恢复预订成功,并回写下游仍接单) */ + R rejectCancelLocalByAdmin(Long id, String reason); + /** Worker:扫描到期补偿任务 */ int processDueCompensate(int limit); diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelShengnuoOrderService.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelShengnuoOrderService.java index 8344244a5..f77ed521a 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelShengnuoOrderService.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelShengnuoOrderService.java @@ -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); } diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelHuidingOrderServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelHuidingOrderServiceImpl.java index 488a7e3cc..c2d3595f4 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelHuidingOrderServiceImpl.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelHuidingOrderServiceImpl.java @@ -281,13 +281,20 @@ public class SmHotelHuidingOrderServiceImpl implements SmHotelHuidingOrderServic && !partnerCode.equals(order.getPartnerCode())) { return fail200("订单不属于当前伙伴"); } - R r = hotelOrderService.cancelByAdmin(order.getId(), "慧订取消"); + R> 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 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 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; diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelMidServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelMidServiceImpl.java index f1837438a..92e8041f4 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelMidServiceImpl.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelMidServiceImpl.java @@ -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> planCode = new ArrayList<>(1); Map 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 normalizedCancel = upstreamCancelPolicyRegistry + .find(SmHotelDataSource.SHANGYOU) + .flatMap(pol -> pol.normalizeFromUpstreamRate(rate)); + if (normalizedCancel.isPresent()) { + hasCancel = true; + canCancel = normalizedCancel.get().canCancel(); + cancelRule = normalizedCancel.get().cancelRuleJson(); } Map> 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); diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelOrderServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelOrderServiceImpl.java index d0e2e1fd8..bbb65fe38 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelOrderServiceImpl.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelOrderServiceImpl.java @@ -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> 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 done = new LinkedHashMap<>(4); + done.put("parked", false); + done.put("orderStatus", order.getOrderStatus()); + return R.ok(done, "订单已取消"); + } + if ("PENDING_CANCEL".equals(order.getOrderStatus())) { + Map 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 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 r = cancelByAdmin(id, StrUtil.blankToDefault(reason, "下游取消")); + if (r == null || r.getCode() != 0) { + return R.failed(r == null ? "取消失败" : StrUtil.blankToDefault(r.getMsg(), "取消失败")); + } + Map done = new LinkedHashMap<>(4); + done.put("parked", false); + done.put("orderStatus", "CANCELLED"); + return R.ok(done, "取消成功"); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public R 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 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 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 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 ServiceImpllambdaQuery() + .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 cancelUpstreamOrder(SmHotelOrderEntity order, String reason) { SmHotelEntity hotel = order.getHotelId() == null ? null : hotelMapper.selectById(order.getHotelId()); Optional gatewayOpt = hotel == null ? Optional.empty() diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelShengnuoOrderServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelShengnuoOrderServiceImpl.java index fa806d131..2cb81e4cd 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelShengnuoOrderServiceImpl.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelShengnuoOrderServiceImpl.java @@ -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 r = hotelOrderService.cancelByAdmin(order.getId(), "圣诺取消"); + R> 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 ok(String msg, Long localId) { Map m = new LinkedHashMap<>(6); m.put("code", 0);