酒店
This commit is contained in:
1 parent
8ef2f8ccd6
commit
7a8210aeb4
12 files changed
+857
-53
No files matched your search
+7
@@ -28,4 +28,11 @@ public interface RemoteSmHotelOrderService {
|
||||
@NoToken
|
||||
@PostMapping("/inner/sm-hotel-order/pay-success")
|
||||
R<Boolean> paySuccess(@RequestParam("orderId") Long orderId);
|
||||
|
||||
/**
|
||||
* 第三方退款成功后置:标记已退款并关闭预订(幂等)。
|
||||
*/
|
||||
@NoToken
|
||||
@PostMapping("/inner/sm-hotel-order/refund-success")
|
||||
R<Boolean> refundSuccess(@RequestParam("orderId") Long orderId);
|
||||
}
|
||||
+5
@@ -114,6 +114,11 @@ public class SmHotelOrderEntity extends Model<SmHotelOrderEntity> {
|
||||
@Schema(description = "酒店名称")
|
||||
private String hotelName;
|
||||
|
||||
/** 详情展示:产品名称(非表字段) */
|
||||
@TableField(exist = false)
|
||||
@Schema(description = "产品名称")
|
||||
private String productName;
|
||||
|
||||
/** 列表展示:下单来源 APP/ADMIN/DOWNSTREAM(非表字段,由 channelNo 推断) */
|
||||
@TableField(exist = false)
|
||||
@Schema(description = "下单来源")
|
||||
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
package com.bsd.pjcloud.supermarket.vo;
|
||||
|
||||
import com.bsd.pjcloud.supermarket.entity.SmHotelDailyEntity;
|
||||
import com.bsd.pjcloud.supermarket.entity.SmHotelOrderEntity;
|
||||
import com.bsd.pjcloud.supermarket.entity.SmHotelProductEntity;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 后台酒店订单详情:订单 + 酒店基础信息 + 产品 + 入住日历 + 入住人。
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "酒店订单详情(后台)")
|
||||
public class SmHotelOrderDetailVo {
|
||||
|
||||
@Schema(description = "订单主体")
|
||||
private SmHotelOrderEntity order;
|
||||
|
||||
@Schema(description = "酒店基础信息")
|
||||
private HotelBrief hotel;
|
||||
|
||||
@Schema(description = "下单产品")
|
||||
private ProductBrief product;
|
||||
|
||||
@Schema(description = "房型名称")
|
||||
private String roomTypeName;
|
||||
|
||||
@Schema(description = "入住区间日历明细(含当日价库存)")
|
||||
private List<SmHotelDailyEntity> dailyList;
|
||||
|
||||
@Schema(description = "入住人列表(解析自 guestJson)")
|
||||
private Object guests;
|
||||
|
||||
@Schema(description = "下单请求快照(解析自 reqJson)")
|
||||
private Object reqSnapshot;
|
||||
|
||||
@Schema(description = "上游响应快照(解析自 upstreamRespJson)")
|
||||
private Object upstreamResp;
|
||||
|
||||
@Data
|
||||
@Schema(description = "酒店摘要")
|
||||
public static class HotelBrief {
|
||||
|
||||
private Long id;
|
||||
|
||||
private String hotelCode;
|
||||
|
||||
private String hotelName;
|
||||
|
||||
private String groupCode;
|
||||
|
||||
/** local / shangyou */
|
||||
private String dataSource;
|
||||
|
||||
private String upstreamStatus;
|
||||
|
||||
private String coverImage;
|
||||
|
||||
private String address;
|
||||
|
||||
private String district;
|
||||
|
||||
private String bizArea;
|
||||
|
||||
private Integer starLevel;
|
||||
|
||||
private BigDecimal score;
|
||||
|
||||
private String phone;
|
||||
|
||||
private String intro;
|
||||
|
||||
private String facilityTags;
|
||||
|
||||
private BigDecimal longitude;
|
||||
|
||||
private BigDecimal latitude;
|
||||
|
||||
private Long deptId;
|
||||
}
|
||||
|
||||
@Data
|
||||
@Schema(description = "产品摘要")
|
||||
public static class ProductBrief {
|
||||
|
||||
private Long id;
|
||||
|
||||
private String productCode;
|
||||
|
||||
private String productName;
|
||||
|
||||
private String roomTypeCode;
|
||||
|
||||
private String priceCode;
|
||||
|
||||
private String cancelCode;
|
||||
|
||||
private Integer mealType;
|
||||
|
||||
private Integer mealCount;
|
||||
|
||||
private BigDecimal costPrice;
|
||||
|
||||
private BigDecimal sellPrice;
|
||||
|
||||
private Integer stock;
|
||||
|
||||
private Integer canCancel;
|
||||
|
||||
private String cancelRule;
|
||||
|
||||
private Integer productType;
|
||||
|
||||
private Integer apartmentType;
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,12 @@
|
||||
<artifactId>pjcloud-wallet-api</artifactId>
|
||||
<version>5.7.0</version>
|
||||
</dependency>
|
||||
<!-- 酒店订单后台退款:复用第三方支付 remoterRefund -->
|
||||
<dependency>
|
||||
<groupId>com.bsd</groupId>
|
||||
<artifactId>pjcloud-thirdparty-api</artifactId>
|
||||
<version>5.7.0</version>
|
||||
</dependency>
|
||||
<!--必备: log 依赖-->
|
||||
<dependency>
|
||||
<groupId>com.bsd</groupId>
|
||||
|
||||
+6
@@ -69,6 +69,12 @@ public class ShangyouHotelProperties {
|
||||
/** HTTP 超时毫秒 */
|
||||
private int timeoutMs = 60000;
|
||||
|
||||
/**
|
||||
* App 待支付订单超时分钟数(按 createTime 推算,不落库)。
|
||||
* 超时自动取消;本地酒店会释放创单时预占的日历库存。默认 10 分钟。
|
||||
*/
|
||||
private int appPayTimeoutMinutes = 10;
|
||||
|
||||
/** token 提前失效秒数;默认 120 → 缓存约 5 小时 58 分(21600-120),与文档建议一致 */
|
||||
private int tokenSkewSeconds = 120;
|
||||
|
||||
|
||||
+40
@@ -25,6 +25,7 @@ import org.springdoc.core.annotations.ParameterObject;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
@@ -190,9 +191,33 @@ public class SmHotelAppController {
|
||||
if ("OFFLINE".equalsIgnoreCase(r.getData().getHotel().getUpstreamStatus())) {
|
||||
return R.failed("酒店已下架");
|
||||
}
|
||||
// App 侧:没有未来日历的产品无法预订,不展示
|
||||
hideProductsWithoutCalendar(r.getData());
|
||||
return r;
|
||||
}
|
||||
|
||||
/**
|
||||
* 过滤无未来日历的产品。无日历则无法计价/占库存,列表展示无意义。
|
||||
*/
|
||||
private void hideProductsWithoutCalendar(SmHotelDetailVO vo) {
|
||||
if (vo == null || vo.getHotel() == null || vo.getHotel().getId() == null
|
||||
|| vo.getProducts() == null || vo.getProducts().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<SmHotelDailyEntity> rows = Db.lambdaQuery(SmHotelDailyEntity.class)
|
||||
.eq(SmHotelDailyEntity::getHotelId, vo.getHotel().getId())
|
||||
.ge(SmHotelDailyEntity::getStayDate, LocalDate.now())
|
||||
.select(SmHotelDailyEntity::getProductCode)
|
||||
.list();
|
||||
Set<String> withDaily = rows.stream()
|
||||
.map(SmHotelDailyEntity::getProductCode)
|
||||
.filter(StrUtil::isNotBlank)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
vo.setProducts(vo.getProducts().stream()
|
||||
.filter(p -> p != null && withDaily.contains(p.getProductCode()))
|
||||
.collect(Collectors.toList()));
|
||||
}
|
||||
|
||||
@Operation(summary = "酒店日历分页")
|
||||
@GetMapping("/detail/{id}/daily")
|
||||
public R<Page<SmHotelDailyEntity>> daily(@PathVariable Long id,
|
||||
@@ -218,6 +243,8 @@ public class SmHotelAppController {
|
||||
Map<String, Object> req = body == null ? new HashMap<>() : new HashMap<>(body);
|
||||
// 渠道单号绑定用户 ID:APP{userId}{ts},列表/详情/取消均按此前缀认主(与用户名无关)
|
||||
req.put("channelNo", "APP" + user.getId() + System.currentTimeMillis());
|
||||
// 供待支付幂等复用:同用户同房同日期未付单直接返回,避免连点重复创单
|
||||
req.put("appUserId", user.getId());
|
||||
if (StrUtil.isBlank(str(req.get("contactName")))) {
|
||||
req.put("contactName", StrUtil.blankToDefault(user.getName(), user.getUsername()));
|
||||
}
|
||||
@@ -287,9 +314,12 @@ public class SmHotelAppController {
|
||||
row.put("sellAmount", o.getSellAmount());
|
||||
row.put("orderStatus", o.getOrderStatus());
|
||||
row.put("bookStatus", o.getBookStatus());
|
||||
row.put("payStatus", o.getPayStatus());
|
||||
row.put("contactName", o.getContactName());
|
||||
row.put("contactPhone", o.getContactPhone());
|
||||
row.put("createTime", o.getCreateTime());
|
||||
row.put("payExpireTime", orderService.calcAppPayExpireTime(o));
|
||||
row.put("remainSeconds", orderService.calcAppPayRemainSeconds(o));
|
||||
return row;
|
||||
}).toList());
|
||||
return R.ok(out);
|
||||
@@ -309,6 +339,12 @@ public class SmHotelAppController {
|
||||
if (!orderService.isOwnedByAppUser(order, user)) {
|
||||
return R.failed("无权查看该订单");
|
||||
}
|
||||
// 详情懒清理超时未付
|
||||
orderService.cancelIfAppPayExpired(order);
|
||||
order = orderService.getDetail(id);
|
||||
if (order == null) {
|
||||
return R.failed("订单不存在");
|
||||
}
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("id", order.getId());
|
||||
row.put("channelNo", order.getChannelNo());
|
||||
@@ -323,10 +359,13 @@ public class SmHotelAppController {
|
||||
row.put("sellAmount", order.getSellAmount());
|
||||
row.put("orderStatus", order.getOrderStatus());
|
||||
row.put("bookStatus", order.getBookStatus());
|
||||
row.put("payStatus", order.getPayStatus());
|
||||
row.put("contactName", order.getContactName());
|
||||
row.put("contactPhone", order.getContactPhone());
|
||||
row.put("createTime", order.getCreateTime());
|
||||
row.put("failMsg", order.getFailMsg());
|
||||
row.put("payExpireTime", orderService.calcAppPayExpireTime(order));
|
||||
row.put("remainSeconds", orderService.calcAppPayRemainSeconds(order));
|
||||
if (order.getHotelId() != null) {
|
||||
SmHotelEntity hotel = hotelService.getById(order.getHotelId());
|
||||
if (hotel != null) {
|
||||
@@ -334,6 +373,7 @@ public class SmHotelAppController {
|
||||
row.put("coverImage", hotel.getCoverImage());
|
||||
row.put("address", hotel.getAddress());
|
||||
row.put("phone", hotel.getPhone());
|
||||
row.put("hotelDeptId", hotel.getDeptId());
|
||||
}
|
||||
if (StrUtil.isNotBlank(order.getProductCode())) {
|
||||
SmHotelProductEntity p = Db.lambdaQuery(SmHotelProductEntity.class)
|
||||
|
||||
+17
-3
@@ -3,6 +3,7 @@ package com.bsd.pjcloud.supermarket.controller;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.bsd.pjcloud.supermarket.entity.SmHotelOrderEntity;
|
||||
import com.bsd.pjcloud.supermarket.service.SmHotelOrderService;
|
||||
import com.bsd.pjcloud.supermarket.vo.SmHotelOrderDetailVo;
|
||||
import com.bsd.pjcloud.common.core.util.R;
|
||||
import com.bsd.pjcloud.common.log.annotation.SysLog;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
@@ -33,11 +34,15 @@ public class SmHotelOrderController {
|
||||
return R.ok(orderService.pageOrders(page, query));
|
||||
}
|
||||
|
||||
@Operation(summary = "订单详情")
|
||||
@Operation(summary = "订单详情(含酒店/产品/日历)")
|
||||
@GetMapping("/{id}")
|
||||
@PreAuthorize("@pms.hasPermission('supermarket_smHotelOrder_view')")
|
||||
public R<SmHotelOrderEntity> detail(@PathVariable Long id) {
|
||||
return R.ok(orderService.getDetail(id));
|
||||
public R<SmHotelOrderDetailVo> detail(@PathVariable Long id) {
|
||||
SmHotelOrderDetailVo vo = orderService.getAdminDetail(id);
|
||||
if (vo == null || vo.getOrder() == null) {
|
||||
return R.failed("订单不存在");
|
||||
}
|
||||
return R.ok(vo);
|
||||
}
|
||||
|
||||
@SysLog("后台取消酒店订单")
|
||||
@@ -49,6 +54,15 @@ public class SmHotelOrderController {
|
||||
return orderService.cancelByAdmin(id, reason);
|
||||
}
|
||||
|
||||
@SysLog("后台退款酒店订单")
|
||||
@Operation(summary = "后台退款:已支付订单原路退款并关闭预订")
|
||||
@PostMapping("/refund/{id}")
|
||||
@PreAuthorize("@pms.hasPermission('supermarket_smHotelOrder_cancel')")
|
||||
public R<Boolean> refund(@PathVariable Long id, @RequestBody(required = false) Map<String, Object> body) {
|
||||
String reason = body == null ? null : String.valueOf(body.getOrDefault("reason", "后台退款"));
|
||||
return orderService.refundByAdmin(id, reason);
|
||||
}
|
||||
|
||||
@SysLog("后台试下单(调上游)")
|
||||
@Operation(summary = "后台试下单:校验可定后调上游 order/add")
|
||||
@PostMapping("/create")
|
||||
|
||||
+7
@@ -37,4 +37,11 @@ public class SmHotelOrderInnerController {
|
||||
public R<Boolean> paySuccess(@RequestParam("orderId") Long orderId) {
|
||||
return R.ok(orderService.onAppPaySuccess(orderId));
|
||||
}
|
||||
|
||||
@Inner
|
||||
@Operation(summary = "退款成功后置")
|
||||
@PostMapping("/refund-success")
|
||||
public R<Boolean> refundSuccess(@RequestParam("orderId") Long orderId) {
|
||||
return R.ok(orderService.onAppRefundSuccess(orderId));
|
||||
}
|
||||
}
|
||||
+20
@@ -1,6 +1,8 @@
|
||||
package com.bsd.pjcloud.supermarket.job;
|
||||
|
||||
import com.bsd.pjcloud.common.data.tenant.TenantBroker;
|
||||
import com.bsd.pjcloud.supermarket.config.ShangyouHotelProperties;
|
||||
import com.bsd.pjcloud.supermarket.service.SmHotelOrderService;
|
||||
import com.bsd.pjcloud.supermarket.service.SmHotelSyncService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -22,6 +24,8 @@ public class SmHotelShangyouSpringSchedule {
|
||||
|
||||
private final SmHotelSyncService syncService;
|
||||
|
||||
private final SmHotelOrderService hotelOrderService;
|
||||
|
||||
@Scheduled(cron = "${shangyou-hotel.hotel-list-daily-cron:0 0 2 * * ?}")
|
||||
public void scheduledListSync() {
|
||||
if (!properties.isSpringScheduleEnabled()) {
|
||||
@@ -72,4 +76,20 @@ public class SmHotelShangyouSpringSchedule {
|
||||
log.error("|酒店日更|Spring 本地滚加异常", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* App 待支付超时清理:默认每分钟扫一次,取消过期单并释放本地预占库存。
|
||||
* 不依赖 spring-schedule-enabled(支付超时与日更无关)。
|
||||
* 租户与订单补偿 Worker 一致,走 callback-tenant-id。
|
||||
*/
|
||||
@Scheduled(fixedDelayString = "${shangyou-hotel.app-pay-expire-scan-ms:60000}")
|
||||
public void scheduledCancelExpiredAppUnpaid() {
|
||||
try {
|
||||
Long tenantId = properties.getCallbackTenantId() == null ? 1L : properties.getCallbackTenantId();
|
||||
TenantBroker.runAs(tenantId, tid -> hotelOrderService.cancelExpiredAppUnpaidOrders(100));
|
||||
}
|
||||
catch (Exception e) {
|
||||
log.error("|酒店待支付超时|定时清理异常", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+38
-3
@@ -4,9 +4,11 @@ import cn.hutool.json.JSONObject;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.bsd.pjcloud.common.security.service.PjcloudUser;
|
||||
import com.bsd.pjcloud.supermarket.entity.SmHotelOrderEntity;
|
||||
import com.bsd.pjcloud.supermarket.vo.SmHotelOrderDetailVo;
|
||||
import com.bsd.pjcloud.supermarket.vo.SmHotelOrderPayInfoVo;
|
||||
import com.bsd.pjcloud.common.core.util.R;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
|
||||
public interface SmHotelOrderService {
|
||||
@@ -21,6 +23,9 @@ public interface SmHotelOrderService {
|
||||
|
||||
SmHotelOrderEntity getDetail(Long id);
|
||||
|
||||
/** PC 后台详情:订单 + 酒店基础信息 + 产品 + 入住日历 + 入住人 */
|
||||
SmHotelOrderDetailVo getAdminDetail(Long id);
|
||||
|
||||
/** 下游回传下单 → 可定校验 → 上游创建 → 必要时挂补偿 */
|
||||
R<JSONObject> createFromDownstream(String appKey, Map<String, Object> body);
|
||||
|
||||
@@ -31,18 +36,48 @@ public interface SmHotelOrderService {
|
||||
R<JSONObject> createByAdmin(Map<String, Object> body);
|
||||
|
||||
/**
|
||||
* App 待支付创单:只落业务订单,不扣本地库存、不调上游;支付成功后再履约。
|
||||
* App 待支付创单:落业务订单;本地酒店预占日历库存,上游酒店此时不调上游。
|
||||
* 支付成功后再确认本地单 / 调上游;超时未付自动取消并释放预占。
|
||||
*/
|
||||
R<JSONObject> createAppPendingOrder(Map<String, Object> body);
|
||||
|
||||
/** 供 thirdparty 拉起支付前校验 */
|
||||
/** 供 thirdparty 拉起支付前校验(含支付超时自动取消) */
|
||||
SmHotelOrderPayInfoVo getPayInfo(Long orderId);
|
||||
|
||||
/**
|
||||
* App 支付成功履约。本地酒店只确认库存;上游酒店再调上游下单。
|
||||
* App 支付成功履约。本地酒店确认已预占库存;上游酒店再调上游下单。
|
||||
*/
|
||||
Boolean onAppPaySuccess(Long orderId);
|
||||
|
||||
/**
|
||||
* 第三方退款成功后置(幂等):关闭预订并标记已退款。
|
||||
*/
|
||||
Boolean onAppRefundSuccess(Long orderId);
|
||||
|
||||
/**
|
||||
* PC 后台对已支付订单发起原路退款,并关闭预订。
|
||||
*/
|
||||
R<Boolean> refundByAdmin(Long id, String reason);
|
||||
|
||||
/**
|
||||
* 扫描并取消已超时的 App 待支付订单(释放本地预占库存)。
|
||||
*
|
||||
* @return 本次取消条数
|
||||
*/
|
||||
int cancelExpiredAppUnpaidOrders(int limit);
|
||||
|
||||
/**
|
||||
* 若仍为待支付且已超时则取消;返回是否已取消。
|
||||
* 用于列表/详情懒清理。
|
||||
*/
|
||||
boolean cancelIfAppPayExpired(SmHotelOrderEntity order);
|
||||
|
||||
/** App 待支付剩余秒数;非待支付返回 null */
|
||||
Long calcAppPayRemainSeconds(SmHotelOrderEntity order);
|
||||
|
||||
/** App 待支付截止时间;非待支付返回 null */
|
||||
LocalDateTime calcAppPayExpireTime(SmHotelOrderEntity order);
|
||||
|
||||
/** 下游回传取消 → 调上游取消 */
|
||||
R<JSONObject> cancelFromDownstream(String appKey, Map<String, Object> body);
|
||||
|
||||
|
||||
+584
-47
@@ -16,11 +16,15 @@ import com.bsd.pjcloud.supermarket.integration.shangyou.ShangyouOrderCallResult;
|
||||
import com.bsd.pjcloud.supermarket.mapper.*;
|
||||
import com.bsd.pjcloud.supermarket.service.SmHotelOrderService;
|
||||
import com.bsd.pjcloud.supermarket.service.SmHotelPriceConfigService;
|
||||
import com.bsd.pjcloud.supermarket.vo.SmHotelOrderDetailVo;
|
||||
import com.bsd.pjcloud.supermarket.vo.SmHotelOrderPayInfoVo;
|
||||
import com.bsd.pjcloud.common.core.util.R;
|
||||
import com.bsd.pjcloud.common.data.tenant.TenantBroker;
|
||||
import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
|
||||
import com.bsd.pjcloud.common.security.service.PjcloudUser;
|
||||
import com.bsd.pjcloud.thirdparty.api.feign.RemoterThirdAppPlusPayService;
|
||||
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum;
|
||||
import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdRemoterRefundDto;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -28,6 +32,7 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
@@ -63,6 +68,9 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
|
||||
/** App 本地支付状态:已支付 */
|
||||
private static final String PAY_PAID = "PAID";
|
||||
|
||||
/** App 本地支付状态:已退款 */
|
||||
private static final String PAY_REFUNDED = "REFUNDED";
|
||||
|
||||
private final SmHotelDownstreamAppMapper appMapper;
|
||||
|
||||
private final SmHotelMapper hotelMapper;
|
||||
@@ -77,6 +85,10 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
|
||||
|
||||
private final com.bsd.pjcloud.supermarket.config.ShangyouHotelProperties shangyouProperties;
|
||||
|
||||
private final RemoterThirdAppPlusPayService remoterThirdAppPlusPayService;
|
||||
|
||||
private final SmHotelRoomMapper roomMapper;
|
||||
|
||||
@Override
|
||||
public Page<SmHotelOrderEntity> pageOrders(Page<SmHotelOrderEntity> page, SmHotelOrderEntity query) {
|
||||
Page<SmHotelOrderEntity> result = baseMapper.selectPage(page, Wrappers.<SmHotelOrderEntity>lambdaQuery()
|
||||
@@ -112,6 +124,12 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
|
||||
.eq(StrUtil.isNotBlank(orderStatus), SmHotelOrderEntity::getOrderStatus, orderStatus)
|
||||
.likeRight(SmHotelOrderEntity::getChannelNo, appChannelPrefix)
|
||||
.orderByDesc(SmHotelOrderEntity::getCreateTime));
|
||||
// 列表懒清理:超时未付自动取消并释放本地预占
|
||||
if (result.getRecords() != null) {
|
||||
for (SmHotelOrderEntity o : result.getRecords()) {
|
||||
cancelIfAppPayExpired(o);
|
||||
}
|
||||
}
|
||||
fillOrderListDisplay(result.getRecords());
|
||||
return result;
|
||||
}
|
||||
@@ -186,7 +204,148 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
|
||||
|
||||
@Override
|
||||
public SmHotelOrderEntity getDetail(Long id) {
|
||||
return baseMapper.selectById(id);
|
||||
SmHotelOrderEntity order = baseMapper.selectById(id);
|
||||
if (order == null) {
|
||||
return null;
|
||||
}
|
||||
fillOrderListDisplay(List.of(order));
|
||||
fillProductName(order);
|
||||
return order;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SmHotelOrderDetailVo getAdminDetail(Long id) {
|
||||
SmHotelOrderEntity order = getDetail(id);
|
||||
if (order == null) {
|
||||
return null;
|
||||
}
|
||||
SmHotelOrderDetailVo vo = new SmHotelOrderDetailVo();
|
||||
vo.setOrder(order);
|
||||
vo.setGuests(parseJsonLenient(order.getGuestJson()));
|
||||
vo.setReqSnapshot(parseJsonLenient(order.getReqJson()));
|
||||
vo.setUpstreamResp(parseJsonLenient(order.getUpstreamRespJson()));
|
||||
|
||||
SmHotelEntity hotel = null;
|
||||
if (order.getHotelId() != null) {
|
||||
hotel = hotelMapper.selectById(order.getHotelId());
|
||||
}
|
||||
if (hotel == null && StrUtil.isNotBlank(order.getHotelCode())) {
|
||||
hotel = hotelMapper.selectOne(Wrappers.<SmHotelEntity>lambdaQuery()
|
||||
.eq(SmHotelEntity::getHotelCode, order.getHotelCode())
|
||||
.last("limit 1"));
|
||||
}
|
||||
if (hotel != null) {
|
||||
SmHotelOrderDetailVo.HotelBrief hb = new SmHotelOrderDetailVo.HotelBrief();
|
||||
hb.setId(hotel.getId());
|
||||
hb.setHotelCode(hotel.getHotelCode());
|
||||
hb.setHotelName(hotel.getHotelName());
|
||||
hb.setGroupCode(hotel.getGroupCode());
|
||||
hb.setDataSource(SmHotelDataSource.isLocal(hotel.getSheetName()) ? "local" : "shangyou");
|
||||
hb.setUpstreamStatus(hotel.getUpstreamStatus());
|
||||
hb.setCoverImage(hotel.getCoverImage());
|
||||
hb.setAddress(hotel.getAddress());
|
||||
hb.setDistrict(hotel.getDistrict());
|
||||
hb.setBizArea(hotel.getBizArea());
|
||||
hb.setStarLevel(hotel.getStarLevel());
|
||||
hb.setScore(hotel.getScore());
|
||||
hb.setPhone(hotel.getPhone());
|
||||
hb.setIntro(hotel.getIntro());
|
||||
hb.setFacilityTags(hotel.getFacilityTags());
|
||||
hb.setLongitude(hotel.getLongitude());
|
||||
hb.setLatitude(hotel.getLatitude());
|
||||
hb.setDeptId(hotel.getDeptId());
|
||||
vo.setHotel(hb);
|
||||
if (StrUtil.isBlank(order.getHotelName())) {
|
||||
order.setHotelName(hotel.getHotelName());
|
||||
}
|
||||
}
|
||||
|
||||
SmHotelProductEntity product = null;
|
||||
Long hotelId = hotel != null ? hotel.getId() : order.getHotelId();
|
||||
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"));
|
||||
}
|
||||
if (product != null) {
|
||||
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());
|
||||
vo.setProduct(pb);
|
||||
order.setProductName(product.getProductName());
|
||||
}
|
||||
|
||||
String roomTypeCode = StrUtil.blankToDefault(order.getRoomTypeCode(),
|
||||
product != null ? product.getRoomTypeCode() : null);
|
||||
if (hotelId != null && StrUtil.isNotBlank(roomTypeCode)) {
|
||||
SmHotelRoomEntity room = roomMapper.selectOne(Wrappers.<SmHotelRoomEntity>lambdaQuery()
|
||||
.eq(SmHotelRoomEntity::getHotelId, hotelId)
|
||||
.eq(SmHotelRoomEntity::getRoomTypeCode, roomTypeCode)
|
||||
.last("limit 1"));
|
||||
if (room != null) {
|
||||
vo.setRoomTypeName(room.getRoomTypeName());
|
||||
}
|
||||
}
|
||||
|
||||
if (hotelId != null && StrUtil.isNotBlank(order.getProductCode()) && order.getCheckInDate() != null
|
||||
&& order.getCheckOutDate() != null) {
|
||||
List<SmHotelDailyEntity> dailies = dailyMapper.selectList(Wrappers.<SmHotelDailyEntity>lambdaQuery()
|
||||
.eq(SmHotelDailyEntity::getHotelId, hotelId)
|
||||
.eq(SmHotelDailyEntity::getProductCode, order.getProductCode())
|
||||
.ge(SmHotelDailyEntity::getStayDate, order.getCheckInDate())
|
||||
.lt(SmHotelDailyEntity::getStayDate, order.getCheckOutDate())
|
||||
.orderByAsc(SmHotelDailyEntity::getStayDate));
|
||||
for (SmHotelDailyEntity d : dailies) {
|
||||
int stock = d.getStock() == null ? 0 : d.getStock();
|
||||
int sold = d.getSold() == null ? 0 : d.getSold();
|
||||
d.setRemainStock(Math.max(0, stock - sold));
|
||||
}
|
||||
vo.setDailyList(dailies);
|
||||
}
|
||||
else {
|
||||
vo.setDailyList(List.of());
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
|
||||
/** 详情补产品名称,便于后台抽屉展示。 */
|
||||
private void fillProductName(SmHotelOrderEntity order) {
|
||||
if (order == null || StrUtil.isBlank(order.getProductCode())) {
|
||||
return;
|
||||
}
|
||||
SmHotelProductEntity product = productMapper.selectOne(Wrappers.<SmHotelProductEntity>lambdaQuery()
|
||||
.eq(order.getHotelId() != null, SmHotelProductEntity::getHotelId, order.getHotelId())
|
||||
.eq(SmHotelProductEntity::getProductCode, order.getProductCode())
|
||||
.last("limit 1"));
|
||||
if (product != null) {
|
||||
order.setProductName(product.getProductName());
|
||||
}
|
||||
}
|
||||
|
||||
private Object parseJsonLenient(String raw) {
|
||||
if (StrUtil.isBlank(raw)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSONUtil.parse(raw);
|
||||
}
|
||||
catch (Exception e) {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -358,6 +517,9 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
|
||||
if (checkIn == null || checkOut == null || !checkIn.isBefore(checkOut)) {
|
||||
return R.failed("入住/离店日期无效");
|
||||
}
|
||||
if (java.time.temporal.ChronoUnit.DAYS.between(checkIn, checkOut) > 30) {
|
||||
return R.failed("连住最多30晚,请缩短离店日期");
|
||||
}
|
||||
String contactName = str(body.get("contactName"));
|
||||
String contactPhone = str(body.get("contactPhone"));
|
||||
if (StrUtil.hasBlank(contactName, contactPhone)) {
|
||||
@@ -467,7 +629,7 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
|
||||
|
||||
/**
|
||||
* App 待支付创单:校验与金额落库后返回,支付成功再履约。
|
||||
* 本地酒店仅预检库存不占用;上游酒店此时不调上游。
|
||||
* 本地酒店创单即预占日历 sold;上游酒店此时不调上游。
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@@ -521,11 +683,22 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
|
||||
if (checkIn == null || checkOut == null || !checkIn.isBefore(checkOut)) {
|
||||
return R.failed("入住/离店日期无效");
|
||||
}
|
||||
if (checkIn.isBefore(LocalDate.now())) {
|
||||
return R.failed("入住日期不能早于今天");
|
||||
}
|
||||
// App 连住上限 30 晚,避免超长区间拖垮日历校验与前端展示
|
||||
long stayNights = java.time.temporal.ChronoUnit.DAYS.between(checkIn, checkOut);
|
||||
if (stayNights > 30) {
|
||||
return R.failed("连住最多30晚,请缩短离店日期");
|
||||
}
|
||||
String contactName = str(body.get("contactName"));
|
||||
String contactPhone = str(body.get("contactPhone"));
|
||||
if (StrUtil.hasBlank(contactName, contactPhone)) {
|
||||
return R.failed("联系人姓名和手机号不能为空");
|
||||
}
|
||||
if (!contactPhone.matches("^1\\d{10}$")) {
|
||||
return R.failed("手机号格式不正确");
|
||||
}
|
||||
|
||||
String channelNo = str(body.get("channelNo"));
|
||||
if (StrUtil.isBlank(channelNo)) {
|
||||
@@ -535,18 +708,51 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
|
||||
.eq(SmHotelOrderEntity::getChannelNo, channelNo)
|
||||
.last("limit 1"));
|
||||
if (exist != null) {
|
||||
JSONObject existOut = buildOut(exist);
|
||||
existOut.set("hotelName", hotel.getHotelName());
|
||||
existOut.set("productName", product.getProductName());
|
||||
existOut.set("sellAmount", exist.getSellAmount());
|
||||
existOut.set("amountFen", toAmountFen(exist.getSellAmount()));
|
||||
return R.ok(existOut, "订单已存在(幂等)");
|
||||
cancelIfAppPayExpired(exist);
|
||||
exist = baseMapper.selectById(exist.getId());
|
||||
if (exist == null) {
|
||||
return R.failed("订单不存在");
|
||||
}
|
||||
return R.ok(buildAppPendingOut(exist, hotel, product), "订单已存在(幂等)");
|
||||
}
|
||||
|
||||
int count = body.get("count") == null ? 1 : Integer.parseInt(String.valueOf(body.get("count")));
|
||||
if (count < 1) {
|
||||
count = 1;
|
||||
}
|
||||
if (count > 10) {
|
||||
return R.failed("单次预订间数不能超过10间");
|
||||
}
|
||||
|
||||
// 同用户 + 酒店 + 房型 + 入离店 + 间数:复用未过期待支付单,避免支付失败连点刷出多单
|
||||
Long appUserId = null;
|
||||
Object appUserIdObj = body.get("appUserId");
|
||||
if (appUserIdObj != null && StrUtil.isNotBlank(String.valueOf(appUserIdObj))) {
|
||||
try {
|
||||
appUserId = Long.valueOf(String.valueOf(appUserIdObj));
|
||||
}
|
||||
catch (Exception ignored) {
|
||||
appUserId = null;
|
||||
}
|
||||
}
|
||||
if (appUserId == null) {
|
||||
appUserId = parseAppUserId(channelNo);
|
||||
}
|
||||
if (appUserId != null) {
|
||||
SmHotelOrderEntity pending = findReusableAppUnpaidOrder(appUserId, hotel.getId(), productCode, checkIn,
|
||||
checkOut, count);
|
||||
if (pending != null) {
|
||||
cancelIfAppPayExpired(pending);
|
||||
pending = baseMapper.selectById(pending.getId());
|
||||
if (pending != null && isAppUnpaidPending(pending)) {
|
||||
// 刷新联系人信息(仍同一待支付单)
|
||||
pending.setContactName(contactName);
|
||||
pending.setContactPhone(contactPhone);
|
||||
baseMapper.updateById(pending);
|
||||
return R.ok(buildAppPendingOut(pending, hotel, product), "已复用待支付订单");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SmHotelOrderEntity order = new SmHotelOrderEntity();
|
||||
order.setDownstreamAppId(null);
|
||||
@@ -587,7 +793,13 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
|
||||
if (precheck != null) {
|
||||
return R.failed(precheck);
|
||||
}
|
||||
// 待支付阶段按日历卖价锁定应付金额,支付成功履约时再占用库存
|
||||
// 待支付即预占日历 sold,超时/取消时回补,避免占坑不付
|
||||
String deductMsg = deductLocalInventory(product.getId(), checkIn, checkOut, count);
|
||||
if (deductMsg != null) {
|
||||
return R.failed(deductMsg);
|
||||
}
|
||||
order.setInventoryStatus(SmHotelInventoryStatus.APPLIED);
|
||||
order.setLastCompensateMsg("待支付预占本地库存");
|
||||
BigDecimal sellSum = sumLocalSellAmount(product.getId(), checkIn, checkOut, count);
|
||||
if (sellSum != null) {
|
||||
order.setSellAmount(sellSum);
|
||||
@@ -611,17 +823,54 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
|
||||
if (order.getSellAmount() == null || order.getSellAmount().compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return R.failed("应付金额无效,无法发起支付");
|
||||
}
|
||||
// 倒计时按 createTime 推算,插入前落本地时间避免 fill 延迟导致 remainSeconds 为空
|
||||
if (order.getCreateTime() == null) {
|
||||
order.setCreateTime(LocalDateTime.now());
|
||||
}
|
||||
|
||||
baseMapper.insert(order);
|
||||
JSONObject out = buildOut(order);
|
||||
out.set("hotelName", hotel.getHotelName());
|
||||
out.set("productName", product.getProductName());
|
||||
out.set("sellAmount", order.getSellAmount());
|
||||
out.set("amountFen", toAmountFen(order.getSellAmount()));
|
||||
JSONObject out = buildAppPendingOut(order, hotel, product);
|
||||
out.set("hotelDeptId", hotel.getDeptId());
|
||||
return R.ok(out, "待支付订单已创建");
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找可复用的 App 待支付单(未超时):同用户、同酒店、同产品、同入离店、同间数。
|
||||
*/
|
||||
private SmHotelOrderEntity findReusableAppUnpaidOrder(Long appUserId, Long hotelId, String productCode,
|
||||
LocalDate checkIn, LocalDate checkOut, int count) {
|
||||
if (appUserId == null || hotelId == null || StrUtil.isBlank(productCode) || checkIn == null || checkOut == null) {
|
||||
return null;
|
||||
}
|
||||
return baseMapper.selectOne(Wrappers.<SmHotelOrderEntity>lambdaQuery()
|
||||
.likeRight(SmHotelOrderEntity::getChannelNo, "APP" + appUserId)
|
||||
.eq(SmHotelOrderEntity::getHotelId, hotelId)
|
||||
.eq(SmHotelOrderEntity::getProductCode, productCode)
|
||||
.eq(SmHotelOrderEntity::getCheckInDate, checkIn)
|
||||
.eq(SmHotelOrderEntity::getCheckOutDate, checkOut)
|
||||
.eq(SmHotelOrderEntity::getCount, count)
|
||||
.eq(SmHotelOrderEntity::getPayStatus, PAY_UNPAID)
|
||||
.eq(SmHotelOrderEntity::getOrderStatus, "CREATED")
|
||||
.orderByDesc(SmHotelOrderEntity::getCreateTime)
|
||||
.last("limit 1"));
|
||||
}
|
||||
|
||||
private JSONObject buildAppPendingOut(SmHotelOrderEntity order, SmHotelEntity hotel, SmHotelProductEntity product) {
|
||||
JSONObject out = buildOut(order);
|
||||
if (hotel != null) {
|
||||
out.set("hotelName", hotel.getHotelName());
|
||||
out.set("hotelDeptId", hotel.getDeptId());
|
||||
}
|
||||
if (product != null) {
|
||||
out.set("productName", product.getProductName());
|
||||
}
|
||||
out.set("sellAmount", order.getSellAmount());
|
||||
out.set("amountFen", toAmountFen(order.getSellAmount()));
|
||||
out.set("payExpireTime", calcAppPayExpireTime(order));
|
||||
out.set("remainSeconds", calcAppPayRemainSeconds(order));
|
||||
return out;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SmHotelOrderPayInfoVo getPayInfo(Long orderId) {
|
||||
SmHotelOrderPayInfoVo vo = new SmHotelOrderPayInfoVo();
|
||||
@@ -634,6 +883,13 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
|
||||
vo.setPayable(Boolean.FALSE);
|
||||
return vo;
|
||||
}
|
||||
// 支付前再拦一层超时,避免过期单继续拉起渠道
|
||||
cancelIfAppPayExpired(order);
|
||||
order = baseMapper.selectById(orderId);
|
||||
if (order == null) {
|
||||
vo.setPayable(Boolean.FALSE);
|
||||
return vo;
|
||||
}
|
||||
vo.setOrderId(order.getId());
|
||||
vo.setUserId(parseAppUserId(order.getChannelNo()));
|
||||
vo.setAmountFen(toAmountFen(order.getSellAmount()));
|
||||
@@ -646,16 +902,94 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
|
||||
vo.setHotelDeptId(hotel.getDeptId());
|
||||
}
|
||||
}
|
||||
Long remain = calcAppPayRemainSeconds(order);
|
||||
boolean payable = PAY_UNPAID.equals(order.getPayStatus())
|
||||
&& "CREATED".equals(order.getOrderStatus())
|
||||
&& vo.getUserId() != null
|
||||
&& vo.getHotelDeptId() != null
|
||||
&& vo.getAmountFen() != null
|
||||
&& vo.getAmountFen() > 0;
|
||||
&& vo.getAmountFen() > 0
|
||||
&& remain != null
|
||||
&& remain > 0;
|
||||
vo.setPayable(payable);
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int cancelExpiredAppUnpaidOrders(int limit) {
|
||||
int batch = limit < 1 ? 50 : Math.min(limit, 200);
|
||||
int minutes = appPayTimeoutMinutes();
|
||||
LocalDateTime deadline = LocalDateTime.now().minusMinutes(minutes);
|
||||
List<SmHotelOrderEntity> list = baseMapper.selectList(Wrappers.<SmHotelOrderEntity>lambdaQuery()
|
||||
.eq(SmHotelOrderEntity::getPayStatus, PAY_UNPAID)
|
||||
.eq(SmHotelOrderEntity::getOrderStatus, "CREATED")
|
||||
.likeRight(SmHotelOrderEntity::getChannelNo, "APP")
|
||||
.lt(SmHotelOrderEntity::getCreateTime, deadline)
|
||||
.orderByAsc(SmHotelOrderEntity::getCreateTime)
|
||||
.last("limit " + batch));
|
||||
int n = 0;
|
||||
for (SmHotelOrderEntity order : list) {
|
||||
R<Boolean> r = cancelByAdmin(order.getId(), "支付超时自动取消");
|
||||
if (r != null && r.isOk()) {
|
||||
n++;
|
||||
}
|
||||
}
|
||||
if (n > 0) {
|
||||
log.info("|酒店待支付超时|自动取消|count={}|timeoutMinutes={}|", n, minutes);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean cancelIfAppPayExpired(SmHotelOrderEntity order) {
|
||||
if (order == null || order.getId() == null) {
|
||||
return false;
|
||||
}
|
||||
if (!isAppUnpaidPending(order)) {
|
||||
return false;
|
||||
}
|
||||
Long remain = calcAppPayRemainSeconds(order);
|
||||
if (remain == null || remain > 0) {
|
||||
return false;
|
||||
}
|
||||
R<Boolean> r = cancelByAdmin(order.getId(), "支付超时自动取消");
|
||||
if (r != null && r.isOk()) {
|
||||
order.setOrderStatus("CANCELLED");
|
||||
order.setFailMsg("支付超时自动取消");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Long calcAppPayRemainSeconds(SmHotelOrderEntity order) {
|
||||
if (!isAppUnpaidPending(order) || order.getCreateTime() == null) {
|
||||
return null;
|
||||
}
|
||||
LocalDateTime expire = order.getCreateTime().plusMinutes(appPayTimeoutMinutes());
|
||||
long sec = Duration.between(LocalDateTime.now(), expire).getSeconds();
|
||||
return Math.max(0L, sec);
|
||||
}
|
||||
|
||||
@Override
|
||||
public LocalDateTime calcAppPayExpireTime(SmHotelOrderEntity order) {
|
||||
if (!isAppUnpaidPending(order) || order.getCreateTime() == null) {
|
||||
return null;
|
||||
}
|
||||
return order.getCreateTime().plusMinutes(appPayTimeoutMinutes());
|
||||
}
|
||||
|
||||
private boolean isAppUnpaidPending(SmHotelOrderEntity order) {
|
||||
return order != null
|
||||
&& PAY_UNPAID.equals(order.getPayStatus())
|
||||
&& "CREATED".equals(order.getOrderStatus());
|
||||
}
|
||||
|
||||
private int appPayTimeoutMinutes() {
|
||||
int minutes = shangyouProperties == null ? 10 : shangyouProperties.getAppPayTimeoutMinutes();
|
||||
return minutes > 0 ? minutes : 10;
|
||||
}
|
||||
|
||||
/**
|
||||
* 支付成功履约:本地酒店扣日历库存确认;上游酒店再调上游下单。幂等可重入。
|
||||
*/
|
||||
@@ -785,28 +1119,139 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
|
||||
return "入住/离店日期无效";
|
||||
}
|
||||
int need = rooms < 1 ? 1 : rooms;
|
||||
List<LocalDate> missing = new ArrayList<>();
|
||||
List<LocalDate> closed = new ArrayList<>();
|
||||
List<String> shortStock = new ArrayList<>();
|
||||
for (LocalDate d = checkIn; d.isBefore(checkOut); d = d.plusDays(1)) {
|
||||
SmHotelDailyEntity row = dailyMapper.selectOne(Wrappers.<SmHotelDailyEntity>lambdaQuery()
|
||||
.eq(SmHotelDailyEntity::getProductId, productId)
|
||||
.eq(SmHotelDailyEntity::getStayDate, d)
|
||||
.last("limit 1"));
|
||||
if (row == null) {
|
||||
return "本地库存不足:缺少日期 " + d + " 的日历,请先在后台维护本地房价房量";
|
||||
missing.add(d);
|
||||
continue;
|
||||
}
|
||||
if (!Integer.valueOf(1).equals(row.getDailyStatus())) {
|
||||
return "本地库存不足:日期 " + d + " 已关房";
|
||||
closed.add(d);
|
||||
continue;
|
||||
}
|
||||
int stock = row.getStock() == null ? 0 : row.getStock();
|
||||
int sold = row.getSold() == null ? 0 : row.getSold();
|
||||
if (stock - sold < need) {
|
||||
return "本地库存不足:日期 " + d + " 可售 " + Math.max(0, stock - sold) + ",需要 " + need;
|
||||
shortStock.add(d + " 可售" + Math.max(0, stock - sold));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
if (missing.isEmpty() && closed.isEmpty() && shortStock.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
StringBuilder sb = new StringBuilder("所选入住区间不可订");
|
||||
if (!missing.isEmpty()) {
|
||||
sb.append(":缺少日历 ").append(formatDateList(missing));
|
||||
}
|
||||
if (!closed.isEmpty()) {
|
||||
sb.append(missing.isEmpty() ? ":" : ";").append("已关房 ").append(formatDateList(closed));
|
||||
}
|
||||
if (!shortStock.isEmpty()) {
|
||||
sb.append(missing.isEmpty() && closed.isEmpty() ? ":" : ";")
|
||||
.append("库存不足 ").append(String.join("、", shortStock));
|
||||
}
|
||||
String hint = buildAvailableDatesHint(productId, need);
|
||||
if (StrUtil.isNotBlank(hint)) {
|
||||
sb.append("。").append(hint);
|
||||
}
|
||||
else {
|
||||
sb.append("。请更换入住日期或联系商家维护房价房量");
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/** 汇总近期可住日期,便于用户改期。 */
|
||||
private String buildAvailableDatesHint(Long productId, int need) {
|
||||
if (productId == null) {
|
||||
return "";
|
||||
}
|
||||
int rooms = need < 1 ? 1 : need;
|
||||
LocalDate from = LocalDate.now();
|
||||
LocalDate to = from.plusDays(60);
|
||||
List<SmHotelDailyEntity> rows = dailyMapper.selectList(Wrappers.<SmHotelDailyEntity>lambdaQuery()
|
||||
.eq(SmHotelDailyEntity::getProductId, productId)
|
||||
.ge(SmHotelDailyEntity::getStayDate, from)
|
||||
.lt(SmHotelDailyEntity::getStayDate, to)
|
||||
.eq(SmHotelDailyEntity::getDailyStatus, 1)
|
||||
.orderByAsc(SmHotelDailyEntity::getStayDate));
|
||||
List<LocalDate> ok = new ArrayList<>();
|
||||
for (SmHotelDailyEntity row : rows) {
|
||||
if (row.getStayDate() == null) {
|
||||
continue;
|
||||
}
|
||||
int stock = row.getStock() == null ? 0 : row.getStock();
|
||||
int sold = row.getSold() == null ? 0 : row.getSold();
|
||||
if (stock - sold >= rooms) {
|
||||
ok.add(row.getStayDate());
|
||||
}
|
||||
}
|
||||
if (ok.isEmpty()) {
|
||||
return "该房型近60天暂无可住日期";
|
||||
}
|
||||
return "近期可住:" + formatDateRanges(ok);
|
||||
}
|
||||
|
||||
private static String formatDateList(List<LocalDate> dates) {
|
||||
if (dates == null || dates.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
int limit = Math.min(dates.size(), 8);
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < limit; i++) {
|
||||
if (i > 0) {
|
||||
sb.append("、");
|
||||
}
|
||||
sb.append(dates.get(i));
|
||||
}
|
||||
if (dates.size() > limit) {
|
||||
sb.append("等").append(dates.size()).append("天");
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/** 将连续日期压成区间文案,如 2026-03-01~03、2026-03-05 */
|
||||
private static String formatDateRanges(List<LocalDate> dates) {
|
||||
if (dates == null || dates.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
List<String> parts = new ArrayList<>();
|
||||
LocalDate start = dates.get(0);
|
||||
LocalDate prev = start;
|
||||
for (int i = 1; i < dates.size(); i++) {
|
||||
LocalDate cur = dates.get(i);
|
||||
if (prev.plusDays(1).equals(cur)) {
|
||||
prev = cur;
|
||||
continue;
|
||||
}
|
||||
parts.add(formatRangePart(start, prev));
|
||||
if (parts.size() >= 6) {
|
||||
parts.add("…");
|
||||
return String.join("、", parts);
|
||||
}
|
||||
start = cur;
|
||||
prev = cur;
|
||||
}
|
||||
parts.add(formatRangePart(start, prev));
|
||||
return String.join("、", parts);
|
||||
}
|
||||
|
||||
private static String formatRangePart(LocalDate start, LocalDate end) {
|
||||
if (start.equals(end)) {
|
||||
return start.toString();
|
||||
}
|
||||
if (start.getYear() == end.getYear() && start.getMonth() == end.getMonth()) {
|
||||
return start + "~" + end.getDayOfMonth();
|
||||
}
|
||||
return start + "~" + end;
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地酒店下单:按入住夜 CAS 增加 sold,成功则本地确认,不调上游。
|
||||
* 本地酒店确认:若待支付阶段已预占库存则不再二次扣减,只落确认状态。
|
||||
*/
|
||||
private R<JSONObject> finishLocalOrder(SmHotelOrderEntity order, SmHotelProductEntity product) {
|
||||
if (product == null || product.getId() == null) {
|
||||
@@ -817,19 +1262,22 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
|
||||
throw new IllegalArgumentException("入住/离店日期无效");
|
||||
}
|
||||
int rooms = order.getCount() == null || order.getCount() < 1 ? 1 : order.getCount();
|
||||
String deductMsg = deductLocalInventory(product.getId(), order.getCheckInDate(), order.getCheckOutDate(),
|
||||
rooms);
|
||||
if (deductMsg != null) {
|
||||
// 抛错回滚本事务(含订单 insert 与已 CAS 的 sold),避免半成功
|
||||
throw new IllegalArgumentException(deductMsg);
|
||||
// App 待支付创单已预占:支付成功只确认,避免 sold 双扣
|
||||
if (!SmHotelInventoryStatus.isApplied(order.getInventoryStatus())) {
|
||||
String deductMsg = deductLocalInventory(product.getId(), order.getCheckInDate(), order.getCheckOutDate(),
|
||||
rooms);
|
||||
if (deductMsg != null) {
|
||||
// 抛错回滚本事务(含订单 insert 与已 CAS 的 sold),避免半成功
|
||||
throw new IllegalArgumentException(deductMsg);
|
||||
}
|
||||
order.setInventoryStatus(SmHotelInventoryStatus.APPLIED);
|
||||
}
|
||||
order.setPlatformNo("LOCAL-" + order.getId());
|
||||
order.setOrderStatus("UPSTREAM_OK");
|
||||
order.setBookStatus(ShangyouBookStatus.R);
|
||||
order.setCompensateStatus(COMP_DONE);
|
||||
order.setInventoryStatus(SmHotelInventoryStatus.APPLIED);
|
||||
order.setNextCompensateTime(null);
|
||||
order.setLastCompensateMsg("本地日历库存已占用");
|
||||
order.setLastCompensateMsg("本地日历库存已确认");
|
||||
// 按日历卖价重算订单金额(有则覆盖)
|
||||
BigDecimal sellSum = sumLocalSellAmount(product.getId(), order.getCheckInDate(), order.getCheckOutDate(),
|
||||
rooms);
|
||||
@@ -846,22 +1294,23 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
|
||||
* @return null=成功;非空=失败原因(调用方应回滚事务)
|
||||
*/
|
||||
private String deductLocalInventory(Long productId, LocalDate checkIn, LocalDate checkOut, int rooms) {
|
||||
// 与预检同一套文案,避免占库存阶段提示仍只有「缺少日期」
|
||||
String pre = precheckLocalInventory(productId, checkIn, checkOut, rooms);
|
||||
if (pre != null) {
|
||||
return pre;
|
||||
}
|
||||
for (LocalDate d = checkIn; d.isBefore(checkOut); d = d.plusDays(1)) {
|
||||
SmHotelDailyEntity row = dailyMapper.selectOne(Wrappers.<SmHotelDailyEntity>lambdaQuery()
|
||||
.eq(SmHotelDailyEntity::getProductId, productId)
|
||||
.eq(SmHotelDailyEntity::getStayDate, d)
|
||||
.last("limit 1"));
|
||||
if (row == null) {
|
||||
return "本地库存不足:缺少日期 " + d + " 的日历";
|
||||
}
|
||||
if (!Integer.valueOf(1).equals(row.getDailyStatus())) {
|
||||
return "本地库存不足:日期 " + d + " 已关房";
|
||||
}
|
||||
int updated = dailyMapper.increaseSoldIfEnough(productId, d, rooms);
|
||||
if (updated == 0) {
|
||||
int stock = row.getStock() == null ? 0 : row.getStock();
|
||||
int sold = row.getSold() == null ? 0 : row.getSold();
|
||||
return "本地库存不足:日期 " + d + " 可售 " + Math.max(0, stock - sold) + ",需要 " + rooms;
|
||||
SmHotelDailyEntity row = dailyMapper.selectOne(Wrappers.<SmHotelDailyEntity>lambdaQuery()
|
||||
.eq(SmHotelDailyEntity::getProductId, productId)
|
||||
.eq(SmHotelDailyEntity::getStayDate, d)
|
||||
.last("limit 1"));
|
||||
int stock = row == null || row.getStock() == null ? 0 : row.getStock();
|
||||
int sold = row == null || row.getSold() == null ? 0 : row.getSold();
|
||||
String hint = buildAvailableDatesHint(productId, rooms);
|
||||
return "所选入住区间不可订:日期 " + d + " 可售 " + Math.max(0, stock - sold) + ",需要 " + rooms
|
||||
+ (StrUtil.isNotBlank(hint) ? "。" + hint : "");
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -1339,9 +1788,12 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
|
||||
baseMapper.updateById(order);
|
||||
return R.ok(true, "本地订单已取消并回补库存");
|
||||
}
|
||||
// App 待支付订单:尚未占用库存、未调上游,直接取消
|
||||
if (StrUtil.isBlank(order.getPlatformNo()) && PAY_UNPAID.equals(order.getPayStatus())
|
||||
&& "CREATED".equals(order.getOrderStatus())) {
|
||||
// 尚无上游/本地确认单号:未支付或支付后履约失败等,仅关本地单并回补预占
|
||||
if (StrUtil.isBlank(order.getPlatformNo())) {
|
||||
if (SmHotelInventoryStatus.isApplied(order.getInventoryStatus())) {
|
||||
restoreLocalInventory(order);
|
||||
}
|
||||
applyBookStatus(order, ShangyouBookStatus.X, order.getPayStatus());
|
||||
order.setOrderStatus("CANCELLED");
|
||||
order.setCompensateStatus(COMP_DONE);
|
||||
order.setNextCompensateTime(null);
|
||||
@@ -1349,10 +1801,7 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
|
||||
order.setFailMsg(StrUtil.sub(reason, 0, 200));
|
||||
}
|
||||
baseMapper.updateById(order);
|
||||
return R.ok(true, "未支付订单已取消");
|
||||
}
|
||||
if (StrUtil.isBlank(order.getPlatformNo())) {
|
||||
return R.failed("尚无上游订单号");
|
||||
return R.ok(true, PAY_UNPAID.equals(order.getPayStatus()) ? "未支付订单已取消" : "本地订单已关闭");
|
||||
}
|
||||
try {
|
||||
boolean ok = upstreamClient.cancelOrder(order.getPlatformNo(), order.getHotelCode());
|
||||
@@ -1374,6 +1823,94 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public R<Boolean> refundByAdmin(Long id, String reason) {
|
||||
SmHotelOrderEntity order = baseMapper.selectById(id);
|
||||
if (order == null) {
|
||||
return R.failed("订单不存在");
|
||||
}
|
||||
if (PAY_REFUNDED.equals(order.getPayStatus())) {
|
||||
return R.ok(true, "订单已退款");
|
||||
}
|
||||
if (!PAY_PAID.equals(order.getPayStatus())) {
|
||||
return R.failed("仅已支付订单可退款");
|
||||
}
|
||||
if (order.getSellAmount() == null || order.getSellAmount().compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return R.failed("订单金额异常,无法退款");
|
||||
}
|
||||
int refundFen = order.getSellAmount().multiply(new BigDecimal("100")).setScale(0, RoundingMode.HALF_UP)
|
||||
.intValue();
|
||||
if (refundFen <= 0) {
|
||||
return R.failed("退款金额无效");
|
||||
}
|
||||
ThirdRemoterRefundDto refundDto = new ThirdRemoterRefundDto();
|
||||
refundDto.setOrderId(order.getId());
|
||||
// 整单退款用业务订单 ID 作售后幂等号,避免重复发起渠道退款
|
||||
refundDto.setAfterSaleId(order.getId());
|
||||
refundDto.setBizType(ThirdPayOrderBizTypeEnum.HOTEL_ORDER_PAY);
|
||||
refundDto.setPrice(refundFen);
|
||||
R<String> refundRes;
|
||||
try {
|
||||
refundRes = remoterThirdAppPlusPayService.remoterRefund(refundDto);
|
||||
}
|
||||
catch (Exception e) {
|
||||
log.error("|酒店订单退款|调用第三方退款异常|orderId={}|", id, e);
|
||||
return R.failed("退款失败: " + e.getMessage());
|
||||
}
|
||||
if (refundRes == null || !refundRes.isOk()) {
|
||||
return R.failed(refundRes == null ? "退款失败" : StrUtil.blankToDefault(refundRes.getMsg(), "退款失败"));
|
||||
}
|
||||
// 渠道受理后关闭预订并落已退款(回调后置也会幂等执行)
|
||||
Boolean closed = onAppRefundSuccess(id, StrUtil.blankToDefault(reason, "后台退款"));
|
||||
if (!Boolean.TRUE.equals(closed)) {
|
||||
return R.failed("支付已退款,但关闭预订失败,请人工核对");
|
||||
}
|
||||
return R.ok(true, "退款已受理");
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Boolean onAppRefundSuccess(Long orderId) {
|
||||
return onAppRefundSuccess(orderId, "退款成功关闭预订");
|
||||
}
|
||||
|
||||
/**
|
||||
* 退款成功后置:关闭预订、回补库存、标记已退款(可重复调用)。
|
||||
*/
|
||||
private Boolean onAppRefundSuccess(Long orderId, String reason) {
|
||||
SmHotelOrderEntity order = baseMapper.selectById(orderId);
|
||||
if (order == null) {
|
||||
return false;
|
||||
}
|
||||
if (PAY_REFUNDED.equals(order.getPayStatus()) && "CANCELLED".equals(order.getOrderStatus())) {
|
||||
return true;
|
||||
}
|
||||
// 先尽量关闭预订;已取消则跳过
|
||||
if (!"CANCELLED".equals(order.getOrderStatus())
|
||||
&& !ShangyouBookStatus.X.equalsIgnoreCase(order.getBookStatus())) {
|
||||
R<Boolean> cancelR = cancelByAdmin(orderId, reason);
|
||||
if (cancelR == null || !cancelR.isOk()) {
|
||||
log.warn("|酒店订单退款|关闭预订失败|orderId={}|msg={}|", orderId,
|
||||
cancelR == null ? "null" : cancelR.getMsg());
|
||||
return false;
|
||||
}
|
||||
order = baseMapper.selectById(orderId);
|
||||
if (order == null) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
order.setPayStatus(PAY_REFUNDED);
|
||||
order.setOrderStatus("CANCELLED");
|
||||
order.setCompensateStatus(COMP_DONE);
|
||||
order.setNextCompensateTime(null);
|
||||
if (StrUtil.isNotBlank(reason)) {
|
||||
order.setFailMsg(StrUtil.sub(reason, 0, 200));
|
||||
}
|
||||
baseMapper.updateById(order);
|
||||
return true;
|
||||
}
|
||||
|
||||
private JSONObject buildOut(SmHotelOrderEntity order) {
|
||||
JSONObject out = new JSONObject();
|
||||
out.set("channelNo", order.getChannelNo());
|
||||
|
||||
+8
@@ -1752,6 +1752,14 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService {
|
||||
log.error("|APP支付-购买商品(有订单)|刷新退款聚合状态异常", e);
|
||||
return false;
|
||||
}
|
||||
} else if (ObjectUtil.equals(thirdPayOrder.getBizType(), ThirdPayOrderBizTypeEnum.HOTEL_ORDER_PAY.getType())) {
|
||||
try {
|
||||
R<Boolean> result = remoteSmHotelOrderService.refundSuccess(Long.valueOf(thirdPayOrder.getBizId()));
|
||||
return result != null && result.isOk() && Boolean.TRUE.equals(result.getData());
|
||||
} catch (Exception e) {
|
||||
log.error("|酒店订单支付|处理退款成功回调异常", e);
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user