酒店,app菜单修改
This commit is contained in:
1 parent
47a1fb2a27
commit
ecf2bad461
29 files changed
+2111
-57
No files matched your search
+85
@@ -0,0 +1,85 @@
|
||||
package com.bsd.pjcloud.app.api.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.FieldFill;
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.baomidou.mybatisplus.extension.activerecord.Model;
|
||||
import com.bsd.pjcloud.common.core.util.TenantTable;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* App 商家工作台菜单(「我的」页独立卡片)
|
||||
*/
|
||||
@Data
|
||||
@TenantTable
|
||||
@TableName("app_workbench_menu")
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@Schema(description = "App商家工作台菜单")
|
||||
public class AppWorkbenchMenuEntity extends Model<AppWorkbenchMenuEntity> {
|
||||
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
@Schema(description = "主键")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "菜单编码")
|
||||
private String menuCode;
|
||||
|
||||
@Schema(description = "菜单名称")
|
||||
private String menuName;
|
||||
|
||||
@Schema(description = "图标")
|
||||
private String icon;
|
||||
|
||||
@Schema(description = "原生跳转路径")
|
||||
private String linkPath;
|
||||
|
||||
@Schema(description = "是否H5:1是0否")
|
||||
private String h5Enable;
|
||||
|
||||
@Schema(description = "H5根地址")
|
||||
private String h5Url;
|
||||
|
||||
@Schema(description = "H5入口页")
|
||||
private String h5EntryPath;
|
||||
|
||||
@Schema(description = "可见角色编码,逗号分隔")
|
||||
private String roleCodes;
|
||||
|
||||
@Schema(description = "可见权限码,逗号分隔")
|
||||
private String permCodes;
|
||||
|
||||
@Schema(description = "排序")
|
||||
private Integer sortOrder;
|
||||
|
||||
@Schema(description = "状态:1启用0停用")
|
||||
private String status;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
@Schema(description = "创建人")
|
||||
private String createBy;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
@Schema(description = "创建时间")
|
||||
private LocalDateTime createTime;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||
@Schema(description = "更新人")
|
||||
private String updateBy;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||
@Schema(description = "更新时间")
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
@TableLogic
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
@Schema(description = "删除标记")
|
||||
private String delFlag;
|
||||
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
package com.bsd.pjcloud.app.controller;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.bsd.pjcloud.app.api.entity.AppWorkbenchMenuEntity;
|
||||
import com.bsd.pjcloud.app.service.AppWorkbenchMenuService;
|
||||
import com.bsd.pjcloud.common.core.util.R;
|
||||
import com.bsd.pjcloud.common.log.annotation.SysLog;
|
||||
import com.bsd.pjcloud.common.security.annotation.HasPermission;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springdoc.core.annotations.ParameterObject;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* App 商家工作台菜单
|
||||
*/
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/appWorkbenchMenu")
|
||||
@Tag(description = "appWorkbenchMenu", name = "商家工作台菜单")
|
||||
@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
|
||||
public class AppWorkbenchMenuController {
|
||||
|
||||
private final AppWorkbenchMenuService workbenchMenuService;
|
||||
|
||||
@Operation(summary = "App工作台菜单列表", description = "已启用;可见性由客户端按角色/权限过滤")
|
||||
@GetMapping("/app/list")
|
||||
public R listForApp() {
|
||||
return R.ok(workbenchMenuService.listEnabledForApp());
|
||||
}
|
||||
|
||||
@Operation(summary = "分页查询")
|
||||
@GetMapping("/page")
|
||||
@HasPermission("app_appWorkbenchMenu_view")
|
||||
public R getPage(@ParameterObject Page page, @ParameterObject AppWorkbenchMenuEntity query) {
|
||||
return R.ok(workbenchMenuService.page(page, Wrappers.<AppWorkbenchMenuEntity>lambdaQuery()
|
||||
.like(StrUtil.isNotBlank(query.getMenuName()), AppWorkbenchMenuEntity::getMenuName, query.getMenuName())
|
||||
.like(StrUtil.isNotBlank(query.getMenuCode()), AppWorkbenchMenuEntity::getMenuCode, query.getMenuCode())
|
||||
.eq(StrUtil.isNotBlank(query.getStatus()), AppWorkbenchMenuEntity::getStatus, query.getStatus())
|
||||
.orderByAsc(AppWorkbenchMenuEntity::getSortOrder)
|
||||
.orderByAsc(AppWorkbenchMenuEntity::getId)));
|
||||
}
|
||||
|
||||
@Operation(summary = "详情")
|
||||
@GetMapping("/{id}")
|
||||
@HasPermission("app_appWorkbenchMenu_view")
|
||||
public R getById(@PathVariable("id") Long id) {
|
||||
return R.ok(workbenchMenuService.getById(id));
|
||||
}
|
||||
|
||||
@Operation(summary = "新增")
|
||||
@SysLog("新增工作台菜单")
|
||||
@PostMapping
|
||||
@HasPermission("app_appWorkbenchMenu_add")
|
||||
public R save(@RequestBody AppWorkbenchMenuEntity entity) {
|
||||
return R.ok(workbenchMenuService.save(entity));
|
||||
}
|
||||
|
||||
@Operation(summary = "修改")
|
||||
@SysLog("修改工作台菜单")
|
||||
@PutMapping
|
||||
@HasPermission("app_appWorkbenchMenu_edit")
|
||||
public R updateById(@RequestBody AppWorkbenchMenuEntity entity) {
|
||||
return R.ok(workbenchMenuService.updateById(entity));
|
||||
}
|
||||
|
||||
@Operation(summary = "删除")
|
||||
@SysLog("删除工作台菜单")
|
||||
@DeleteMapping
|
||||
@HasPermission("app_appWorkbenchMenu_del")
|
||||
public R removeById(@RequestBody Long[] ids) {
|
||||
return R.ok(workbenchMenuService.removeBatchByIds(CollUtil.toList(ids)));
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.bsd.pjcloud.app.mapper;
|
||||
|
||||
import com.bsd.pjcloud.app.api.entity.AppWorkbenchMenuEntity;
|
||||
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface AppWorkbenchMenuMapper extends PjcloudBaseMapper<AppWorkbenchMenuEntity> {
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package com.bsd.pjcloud.app.service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.bsd.pjcloud.app.api.entity.AppWorkbenchMenuEntity;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface AppWorkbenchMenuService extends IService<AppWorkbenchMenuEntity> {
|
||||
|
||||
/** App「我的」工作台:已启用菜单,按排序 */
|
||||
List<AppWorkbenchMenuEntity> listEnabledForApp();
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package com.bsd.pjcloud.app.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.bsd.pjcloud.app.api.entity.AppWorkbenchMenuEntity;
|
||||
import com.bsd.pjcloud.app.mapper.AppWorkbenchMenuMapper;
|
||||
import com.bsd.pjcloud.app.service.AppWorkbenchMenuService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class AppWorkbenchMenuServiceImpl extends ServiceImpl<AppWorkbenchMenuMapper, AppWorkbenchMenuEntity>
|
||||
implements AppWorkbenchMenuService {
|
||||
|
||||
@Override
|
||||
public List<AppWorkbenchMenuEntity> listEnabledForApp() {
|
||||
return list(Wrappers.<AppWorkbenchMenuEntity>lambdaQuery()
|
||||
.eq(AppWorkbenchMenuEntity::getStatus, "1")
|
||||
.orderByAsc(AppWorkbenchMenuEntity::getSortOrder)
|
||||
.orderByAsc(AppWorkbenchMenuEntity::getId));
|
||||
}
|
||||
}
|
||||
+18
@@ -17,6 +17,9 @@ public final class SmHotelDownstreamProtocol {
|
||||
/** 慧订:docs/xiayou/shenzhen(静态 + hoteldatepush) */
|
||||
public static final String HUIDING = "huiding";
|
||||
|
||||
/** 圣诺/奈斯:docs/xiayou/shengnuo(getInfo + plan + price + close + orderChange) */
|
||||
public static final String SHENGNUO = "shengnuo";
|
||||
|
||||
/** @deprecated 历史误用名,normalize 后等价 {@link #HUANQI} */
|
||||
@Deprecated
|
||||
public static final String XIAYOU = "xiayou";
|
||||
@@ -25,6 +28,10 @@ public final class SmHotelDownstreamProtocol {
|
||||
@Deprecated
|
||||
public static final String SHENZHEN = "shenzhen";
|
||||
|
||||
/** @deprecated 文档别名,normalize 后等价 {@link #SHENGNUO} */
|
||||
@Deprecated
|
||||
public static final String NS = "ns";
|
||||
|
||||
private SmHotelDownstreamProtocol() {
|
||||
}
|
||||
|
||||
@@ -36,6 +43,9 @@ public final class SmHotelDownstreamProtocol {
|
||||
if (SHENZHEN.equals(p)) {
|
||||
return HUIDING;
|
||||
}
|
||||
if (NS.equals(p) || "nais".equals(p) || "nice".equals(p)) {
|
||||
return SHENGNUO;
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
@@ -44,6 +54,7 @@ public final class SmHotelDownstreamProtocol {
|
||||
Set<String> set = new LinkedHashSet<>();
|
||||
set.add(HUANQI);
|
||||
set.add(HUIDING);
|
||||
set.add(SHENGNUO);
|
||||
return set;
|
||||
}
|
||||
|
||||
@@ -59,6 +70,10 @@ public final class SmHotelDownstreamProtocol {
|
||||
return HUIDING.equals(normalize(protocol));
|
||||
}
|
||||
|
||||
public static boolean isShengnuo(String protocol) {
|
||||
return SHENGNUO.equals(normalize(protocol));
|
||||
}
|
||||
|
||||
public static String label(String protocol) {
|
||||
String p = normalize(protocol);
|
||||
if (HUANQI.equals(p)) {
|
||||
@@ -67,6 +82,9 @@ public final class SmHotelDownstreamProtocol {
|
||||
if (HUIDING.equals(p)) {
|
||||
return "慧订";
|
||||
}
|
||||
if (SHENGNUO.equals(p)) {
|
||||
return "圣诺/高德";
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package com.bsd.pjcloud.supermarket.constant;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 入住区间约定:
|
||||
* <ul>
|
||||
* <li>过夜:半开区间 {@code [checkIn, checkOut)},离店日不含;</li>
|
||||
* <li>钟点房/日租:允许 {@code checkIn == checkOut},占入住当天 1 个日历日库存与售价。</li>
|
||||
* </ul>
|
||||
*/
|
||||
public final class SmHotelStayDates {
|
||||
|
||||
private SmHotelStayDates() {
|
||||
}
|
||||
|
||||
/** 日期合法:非空且离店不早于入住(相等视为钟点房) */
|
||||
public static boolean isValid(LocalDate checkIn, LocalDate checkOut) {
|
||||
return checkIn != null && checkOut != null && !checkOut.isBefore(checkIn);
|
||||
}
|
||||
|
||||
/** 钟点房/日租:入住日 = 离店日 */
|
||||
public static boolean isDayUse(LocalDate checkIn, LocalDate checkOut) {
|
||||
return checkIn != null && checkIn.equals(checkOut);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计费与占库存的日历日列表。
|
||||
* 过夜:checkIn … checkOut 前一天;钟点房:仅 checkIn。
|
||||
*/
|
||||
public static List<LocalDate> occupyDates(LocalDate checkIn, LocalDate checkOut) {
|
||||
if (!isValid(checkIn, checkOut)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
if (checkIn.equals(checkOut)) {
|
||||
return List.of(checkIn);
|
||||
}
|
||||
List<LocalDate> list = new ArrayList<>();
|
||||
for (LocalDate d = checkIn; d.isBefore(checkOut); d = d.plusDays(1)) {
|
||||
list.add(d);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/** 间夜/日租单位数(钟点房算 1) */
|
||||
public static int occupyUnits(LocalDate checkIn, LocalDate checkOut) {
|
||||
return occupyDates(checkIn, checkOut).size();
|
||||
}
|
||||
}
|
||||
+13
-1
@@ -3,6 +3,8 @@ package com.bsd.pjcloud.supermarket.entity;
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import com.baomidou.mybatisplus.extension.activerecord.Model;
|
||||
import com.bsd.pjcloud.common.core.util.TenantTable;
|
||||
import com.fasterxml.jackson.annotation.JsonAlias;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
@@ -34,10 +36,15 @@ public class SmHotelEntity extends Model<SmHotelEntity> {
|
||||
@Schema(description = "酒店编码")
|
||||
private String hotelCode;
|
||||
|
||||
/** Jackson 默认会把 getSGroupId 编成 shotelId/sgroupId,显式固定字段名便于前后端一致 */
|
||||
@JsonProperty("sGroupId")
|
||||
@JsonAlias({ "sgroupId", "s_group_id" })
|
||||
@Schema(description = "外部集团ID")
|
||||
private String sGroupId;
|
||||
|
||||
@Schema(description = "外部酒店ID")
|
||||
@JsonProperty("sHotelId")
|
||||
@JsonAlias({ "shotelId", "s_hotel_id" })
|
||||
@Schema(description = "外部酒店ID(圣诺 getInfo 的艺龙同程 eid)")
|
||||
private String sHotelId;
|
||||
|
||||
@Schema(description = "酒店名称")
|
||||
@@ -131,4 +138,9 @@ public class SmHotelEntity extends Model<SmHotelEntity> {
|
||||
@TableField(exist = false)
|
||||
@Schema(description = "日历数量")
|
||||
private Integer dailyCount;
|
||||
|
||||
/** 列表展示:起售价(非表字段;优先今日可订日历,其次产品最低卖价) */
|
||||
@TableField(exist = false)
|
||||
@Schema(description = "最低卖价/起价")
|
||||
private BigDecimal minSellPrice;
|
||||
}
|
||||
+6
-3
@@ -786,8 +786,11 @@ public class SmHotelAppController {
|
||||
int nights = clampInt(body.get("nights"), 1, 30, 1);
|
||||
LocalDate checkIn = parseDay(str(body.get("checkIn")));
|
||||
LocalDate checkOut = parseDay(str(body.get("checkOut")));
|
||||
if (checkIn != null && checkOut != null && checkOut.isAfter(checkIn)) {
|
||||
nights = (int) Math.min(30, Math.max(1, ChronoUnit.DAYS.between(checkIn, checkOut)));
|
||||
if (checkIn != null && checkOut != null && !checkOut.isBefore(checkIn)) {
|
||||
// 过夜:离店不含当天;钟点房 checkIn==checkOut 算 1
|
||||
int units = checkIn.equals(checkOut) ? 1
|
||||
: (int) ChronoUnit.DAYS.between(checkIn, checkOut);
|
||||
nights = Math.min(30, Math.max(1, units));
|
||||
}
|
||||
SmHotelUserSearchPrefEntity row = userSearchPrefMapper.selectOne(Wrappers.<SmHotelUserSearchPrefEntity>lambdaQuery()
|
||||
.eq(SmHotelUserSearchPrefEntity::getUserId, user.getId())
|
||||
@@ -962,7 +965,7 @@ public class SmHotelAppController {
|
||||
if ("OFFLINE".equalsIgnoreCase(hotel.getUpstreamStatus())) {
|
||||
return R.failed("酒店已下架");
|
||||
}
|
||||
return midService.pageDaily(id, page, productCode);
|
||||
return midService.pageDaily(id, page, productCode, null, null, false);
|
||||
}
|
||||
|
||||
@Operation(summary = "提交预订")
|
||||
|
||||
+13
-1
@@ -4,6 +4,7 @@ import cn.hutool.core.util.StrUtil;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.bsd.pjcloud.supermarket.entity.SmHotelEntity;
|
||||
import com.bsd.pjcloud.supermarket.service.SmHotelOrderService;
|
||||
import com.bsd.pjcloud.supermarket.service.SmHotelService;
|
||||
import com.bsd.pjcloud.supermarket.util.HotelAreaAdcodeUtil;
|
||||
import com.bsd.pjcloud.supermarket.vo.SmHotelImportResultVO;
|
||||
@@ -19,6 +20,8 @@ import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/smHotel")
|
||||
@@ -28,6 +31,15 @@ public class SmHotelController {
|
||||
|
||||
private final SmHotelService hotelService;
|
||||
|
||||
private final SmHotelOrderService orderService;
|
||||
|
||||
@Operation(summary = "酒店管理数据概览(今日订单/间夜/销售额及较昨日)")
|
||||
@GetMapping("/dashboard/overview")
|
||||
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_view')")
|
||||
public R<Map<String, Object>> dashboardOverview() {
|
||||
return R.ok(orderService.managerDashboardOverview());
|
||||
}
|
||||
|
||||
@Operation(summary = "分页查询")
|
||||
@GetMapping("/page")
|
||||
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_view')")
|
||||
@@ -76,7 +88,7 @@ public class SmHotelController {
|
||||
@GetMapping("/{id}")
|
||||
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_view')")
|
||||
public R getById(@PathVariable Long id) {
|
||||
return R.ok(hotelService.getById(id));
|
||||
return R.ok(hotelService.getByIdInScope(id));
|
||||
}
|
||||
|
||||
@SysLog("新增酒店")
|
||||
|
||||
+4
-2
@@ -80,8 +80,10 @@ public class SmHotelMidController {
|
||||
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_view')")
|
||||
public R<Page<SmHotelDailyEntity>> dailyPage(@PathVariable Long id,
|
||||
@ParameterObject Page<SmHotelDailyEntity> page,
|
||||
@RequestParam(required = false) String productCode) {
|
||||
return midService.pageDaily(id, page, productCode);
|
||||
@RequestParam(required = false) String productCode,
|
||||
@RequestParam(required = false) @org.springframework.format.annotation.DateTimeFormat(iso = org.springframework.format.annotation.DateTimeFormat.ISO.DATE) java.time.LocalDate startDate,
|
||||
@RequestParam(required = false) @org.springframework.format.annotation.DateTimeFormat(iso = org.springframework.format.annotation.DateTimeFormat.ISO.DATE) java.time.LocalDate endDate) {
|
||||
return midService.pageDaily(id, page, productCode, startDate, endDate, true);
|
||||
}
|
||||
|
||||
@Operation(summary = "从上游拉取房型产品并加价落库")
|
||||
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
package com.bsd.pjcloud.supermarket.controller;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.json.JSONObject;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import com.bsd.pjcloud.common.security.annotation.Inner;
|
||||
import com.bsd.pjcloud.supermarket.integration.downstream.DownstreamInboundAccessLog;
|
||||
import com.bsd.pjcloud.supermarket.integration.downstream.DownstreamInboundAuth;
|
||||
import com.bsd.pjcloud.supermarket.service.SmHotelShengnuoOrderService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.function.BiFunction;
|
||||
|
||||
/**
|
||||
* 圣诺/奈斯订单入站(供应商回调地址,给对方配置):
|
||||
* <pre>
|
||||
* POST /third/party/shengnuo/order/createOrder
|
||||
* POST /third/party/shengnuo/order/orderCancel
|
||||
* POST /third/party/shengnuo/order/orderConfirmTimeOut
|
||||
* </pre>
|
||||
* 网关完整路径示例:https://域名/api/supermarket/third/party/shengnuo/order/createOrder
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/third/party/shengnuo/order")
|
||||
@Tag(name = "圣诺酒店订单对接")
|
||||
public class SmHotelShengnuoOrderController {
|
||||
|
||||
private final DownstreamInboundAuth inboundAuth;
|
||||
|
||||
private final SmHotelShengnuoOrderService shengnuoOrderService;
|
||||
|
||||
private final DownstreamInboundAccessLog inboundAccessLog;
|
||||
|
||||
@Inner(value = false)
|
||||
@Operation(summary = "圣诺推送下单")
|
||||
@PostMapping("/createOrder")
|
||||
public Map<String, Object> createOrder(HttpServletRequest request, @RequestBody String rawBody) {
|
||||
return handle(request, "inbound_create", rawBody, shengnuoOrderService::createOrder);
|
||||
}
|
||||
|
||||
@Inner(value = false)
|
||||
@Operation(summary = "圣诺推送取消")
|
||||
@PostMapping("/orderCancel")
|
||||
public Map<String, Object> orderCancel(HttpServletRequest request, @RequestBody String rawBody) {
|
||||
return handle(request, "inbound_cancel", rawBody, shengnuoOrderService::orderCancel);
|
||||
}
|
||||
|
||||
@Inner(value = false)
|
||||
@Operation(summary = "圣诺确认超时提醒")
|
||||
@PostMapping("/orderConfirmTimeOut")
|
||||
public Map<String, Object> orderConfirmTimeOut(HttpServletRequest request, @RequestBody String rawBody) {
|
||||
return handle(request, "inbound_timeout", rawBody, shengnuoOrderService::orderConfirmTimeOut);
|
||||
}
|
||||
|
||||
private Map<String, Object> handle(HttpServletRequest request, String action, String rawBody,
|
||||
BiFunction<String, JSONObject, Map<String, Object>> fn) {
|
||||
long t0 = System.currentTimeMillis();
|
||||
String partner = null;
|
||||
Map<String, Object> resp;
|
||||
try {
|
||||
partner = inboundAuth.verifyForShengnuo(rawBody);
|
||||
JSONObject body = JSONUtil.parseObj(rawBody);
|
||||
resp = fn.apply(partner, body);
|
||||
}
|
||||
catch (Exception e) {
|
||||
log.warn("|圣诺订单|{}|失败|{}", action, e.getMessage());
|
||||
resp = new LinkedHashMap<>(6);
|
||||
resp.put("code", 1);
|
||||
resp.put("success", false);
|
||||
resp.put("msg", StrUtil.blankToDefault(e.getMessage(), "处理失败"));
|
||||
resp.put("status", "error");
|
||||
resp.put("data", Map.of());
|
||||
}
|
||||
inboundAccessLog.record(partner, action, request.getRequestURI(), rawBody, resp,
|
||||
System.currentTimeMillis() - t0);
|
||||
return resp;
|
||||
}
|
||||
}
|
||||
+32
@@ -70,6 +70,38 @@ public class DownstreamHotelClient {
|
||||
return huiding.operationOrder(partnerCode, props, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* 圣诺订单状态回写(orderChange);非圣诺协议抛异常。
|
||||
*/
|
||||
public JSONObject shengnuoOrderChange(String partnerCode, Object body) {
|
||||
HotelDownstreamProperties props = resolve(partnerCode);
|
||||
DownstreamHotelAdapter ad = adapter(props);
|
||||
if (!(ad instanceof com.bsd.pjcloud.supermarket.integration.downstream.shengnuo.ShengnuoDownstreamHotelAdapter sn)) {
|
||||
throw new IllegalStateException("伙伴协议不是圣诺,无法 orderChange: " + partnerCode);
|
||||
}
|
||||
return sn.orderChange(partnerCode, props, body);
|
||||
}
|
||||
|
||||
/** 圣诺 getInfo(按 eid 拉对方酒店/房型) */
|
||||
public JSONObject shengnuoGetInfo(String partnerCode, String eid) {
|
||||
HotelDownstreamProperties props = resolve(partnerCode);
|
||||
DownstreamHotelAdapter ad = adapter(props);
|
||||
if (!(ad instanceof com.bsd.pjcloud.supermarket.integration.downstream.shengnuo.ShengnuoDownstreamHotelAdapter sn)) {
|
||||
throw new IllegalStateException("伙伴协议不是圣诺,无法 getInfo: " + partnerCode);
|
||||
}
|
||||
return sn.getInfo(partnerCode, props, eid);
|
||||
}
|
||||
|
||||
/** 圣诺关房 */
|
||||
public JSONObject shengnuoPushClose(String partnerCode, Long hotelId, String extHotelId, Object body) {
|
||||
HotelDownstreamProperties props = resolve(partnerCode);
|
||||
DownstreamHotelAdapter ad = adapter(props);
|
||||
if (!(ad instanceof com.bsd.pjcloud.supermarket.integration.downstream.shengnuo.ShengnuoDownstreamHotelAdapter sn)) {
|
||||
throw new IllegalStateException("伙伴协议不是圣诺,无法 pushHotelClose: " + partnerCode);
|
||||
}
|
||||
return sn.pushHotelClose(partnerCode, props, hotelId, extHotelId, body);
|
||||
}
|
||||
|
||||
private DownstreamHotelAdapter adapter(HotelDownstreamProperties props) {
|
||||
String protocol = props == null ? SmHotelDownstreamProtocol.HUANQI : props.getProtocol();
|
||||
return adapterRegistry.require(protocol);
|
||||
|
||||
+66
@@ -7,6 +7,8 @@ import com.bsd.pjcloud.supermarket.config.HotelDownstreamProperties;
|
||||
import com.bsd.pjcloud.supermarket.constant.SmHotelDownstreamProtocol;
|
||||
import com.bsd.pjcloud.supermarket.entity.SmHotelDownstreamAppEntity;
|
||||
import com.bsd.pjcloud.supermarket.integration.downstream.huiding.HuidingSignHelper;
|
||||
import com.bsd.pjcloud.supermarket.integration.downstream.shengnuo.ShengnuoDownstreamHotelAdapter;
|
||||
import com.bsd.pjcloud.supermarket.integration.downstream.shengnuo.ShengnuoSignHelper;
|
||||
import com.bsd.pjcloud.supermarket.mapper.SmHotelDownstreamAppMapper;
|
||||
import com.bsd.pjcloud.supermarket.service.SmHotelChannelConfigService;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
@@ -358,6 +360,70 @@ public class DownstreamInboundAuth {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 圣诺/奈斯 B→A 验签:body 内 appid + template + sign,md5('ns'+appid+secret+template)。
|
||||
* @return 匹配到的下游伙伴编码
|
||||
*/
|
||||
public String verifyForShengnuo(String rawBody) {
|
||||
if (StrUtil.isBlank(rawBody)) {
|
||||
throw new CheckedException("报文为空");
|
||||
}
|
||||
JsonNode root;
|
||||
try {
|
||||
root = objectMapper.readTree(rawBody);
|
||||
}
|
||||
catch (Exception e) {
|
||||
throw new CheckedException("报文不是合法 JSON");
|
||||
}
|
||||
String appId = textOrNull(root.get("appid"));
|
||||
if (StrUtil.isBlank(appId)) {
|
||||
appId = textOrNull(root.get("appId"));
|
||||
}
|
||||
String template = textOrNull(root.get("template"));
|
||||
String sign = textOrNull(root.get("sign"));
|
||||
if (StrUtil.hasBlank(appId, template, sign)) {
|
||||
throw new CheckedException("缺少 appid/template/sign");
|
||||
}
|
||||
long tpl;
|
||||
try {
|
||||
tpl = Long.parseLong(template.trim());
|
||||
}
|
||||
catch (Exception e) {
|
||||
throw new CheckedException("template 非法");
|
||||
}
|
||||
// 允许 ±10 分钟时钟差(对方可能略慢)
|
||||
long nowSec = System.currentTimeMillis() / 1000L;
|
||||
if (Math.abs(nowSec - tpl) > 600L) {
|
||||
throw new CheckedException("签名过期(template)");
|
||||
}
|
||||
String partner = null;
|
||||
String secret = null;
|
||||
for (String code : channelConfigService.listEnabledDownstreamPartners()) {
|
||||
HotelDownstreamProperties p = channelConfigService.resolveDownstreamRuntime(code);
|
||||
if (p == null || !SmHotelDownstreamProtocol.isShengnuo(p.getProtocol())) {
|
||||
continue;
|
||||
}
|
||||
String expectApp = ShengnuoDownstreamHotelAdapter.resolveAppId(p);
|
||||
if (appId.equals(expectApp)) {
|
||||
partner = code;
|
||||
secret = ShengnuoDownstreamHotelAdapter.resolveSecret(p);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (StrUtil.isBlank(partner) || StrUtil.isBlank(secret)) {
|
||||
throw new CheckedException("appid 未匹配到启用的圣诺下游配置");
|
||||
}
|
||||
if (!ShengnuoSignHelper.matches(appId, secret, template, sign)) {
|
||||
throw new CheckedException("签名错误");
|
||||
}
|
||||
Boolean ok = redisTemplate.opsForValue().setIfAbsent("sm:hotel:shengnuo:nonce:" + appId + ":" + template + ":"
|
||||
+ sign, "1", 5, TimeUnit.MINUTES);
|
||||
if (Boolean.FALSE.equals(ok)) {
|
||||
throw new CheckedException("重复请求");
|
||||
}
|
||||
return partner;
|
||||
}
|
||||
|
||||
private static String str(Object o) {
|
||||
return o == null ? null : String.valueOf(o);
|
||||
}
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.bsd.pjcloud.supermarket.integration.downstream.shengnuo;
|
||||
|
||||
/**
|
||||
* 圣诺/奈斯(docs/xiayou/shengnuo)出站路径。
|
||||
*/
|
||||
public final class ShengnuoDownstreamApi {
|
||||
|
||||
/** 按艺龙同程 eid 拉取对方酒店/房型 */
|
||||
public static final String GET_INFO = "/ns_hotel/getInfo";
|
||||
|
||||
/** 推送价格计划,返回 plan_id */
|
||||
public static final String PUSH_HOTEL_PLAN = "/ns_hotel/pushHotelPlan";
|
||||
|
||||
/** 推送日历价态(单次最多 15 天) */
|
||||
public static final String PUSH_HOTEL_PRICE = "/ns_hotel/pushHotelPrice";
|
||||
|
||||
/** 关房 */
|
||||
public static final String PUSH_HOTEL_CLOSE = "/ns_hotel/pushHotelClose";
|
||||
|
||||
/** 订单状态回写 */
|
||||
public static final String ORDER_CHANGE = "/ns_hotel/orderChange";
|
||||
|
||||
/** 查对方订单详情 */
|
||||
public static final String GET_ORDER_INFO = "/ns_hotel/getOrderInfo";
|
||||
|
||||
private ShengnuoDownstreamApi() {
|
||||
}
|
||||
}
|
||||
+312
@@ -0,0 +1,312 @@
|
||||
package com.bsd.pjcloud.supermarket.integration.downstream.shengnuo;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.http.HttpRequest;
|
||||
import cn.hutool.http.HttpResponse;
|
||||
import cn.hutool.json.JSONObject;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import com.bsd.pjcloud.common.core.exception.CheckedException;
|
||||
import com.bsd.pjcloud.supermarket.config.HotelDownstreamProperties;
|
||||
import com.bsd.pjcloud.supermarket.constant.SmHotelDownstreamProtocol;
|
||||
import com.bsd.pjcloud.supermarket.entity.SmHotelDownstreamPushLogEntity;
|
||||
import com.bsd.pjcloud.supermarket.integration.downstream.DownstreamHotelAdapter;
|
||||
import com.bsd.pjcloud.supermarket.integration.downstream.DownstreamPushLogContext;
|
||||
import com.bsd.pjcloud.supermarket.service.SmHotelDownstreamPushLogService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 圣诺/奈斯下游:getInfo + pushHotelPlan + pushHotelPrice + pushHotelClose + orderChange。
|
||||
* 签名 md5('ns'+appid+secretKey+template);成功 code=0。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class ShengnuoDownstreamHotelAdapter implements DownstreamHotelAdapter {
|
||||
|
||||
private static final String TAG = "下游推送[圣诺]";
|
||||
|
||||
private final SmHotelDownstreamPushLogService pushLogService;
|
||||
|
||||
@Override
|
||||
public String protocol() {
|
||||
return SmHotelDownstreamProtocol.SHENGNUO;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void assertReady(String partnerCode, HotelDownstreamProperties props) {
|
||||
if (props == null || !props.isEnabled()) {
|
||||
throw new CheckedException("下游伙伴未启用: " + partnerCode);
|
||||
}
|
||||
if (StrUtil.isBlank(props.getBaseUrl())) {
|
||||
throw new CheckedException("圣诺配置缺少 base-url: " + partnerCode);
|
||||
}
|
||||
if (StrUtil.isBlank(resolveAppId(props))) {
|
||||
throw new CheckedException("圣诺配置缺少 appid(app-key 或 order-app-id): " + partnerCode);
|
||||
}
|
||||
if (StrUtil.isBlank(resolveSecret(props))) {
|
||||
throw new CheckedException("圣诺配置缺少 secretKey(app-secret): " + partnerCode);
|
||||
}
|
||||
}
|
||||
|
||||
/** Mid 传入完整 body(已含 appid/template/sign);此处原样 POST */
|
||||
@Override
|
||||
public JSONObject hotelSync(String partnerCode, HotelDownstreamProperties props, Long hotelId, String extHotelId,
|
||||
Object body) {
|
||||
return post(partnerCode, props, hotelId, extHotelId, "push_plan", ShengnuoDownstreamApi.PUSH_HOTEL_PLAN, body);
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSONObject apartmentSync(String partnerCode, HotelDownstreamProperties props, Long hotelId, String extHotelId,
|
||||
Object body) {
|
||||
return okSkip("apartment_sync skipped for shengnuo");
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSONObject productSync(String partnerCode, HotelDownstreamProperties props, Long hotelId, String extHotelId,
|
||||
Object body) {
|
||||
return okSkip("product_sync skipped for shengnuo");
|
||||
}
|
||||
|
||||
/** Mid 传入 pushHotelPrice 完整 body */
|
||||
@Override
|
||||
public JSONObject dailySync(String partnerCode, HotelDownstreamProperties props, Long hotelId, String extHotelId,
|
||||
Object body) {
|
||||
return post(partnerCode, props, hotelId, extHotelId, "push_price", ShengnuoDownstreamApi.PUSH_HOTEL_PRICE,
|
||||
body);
|
||||
}
|
||||
|
||||
/** getInfo:按 eid 拉对方酒店/房型 */
|
||||
public JSONObject getInfo(String partnerCode, HotelDownstreamProperties props, String eid) {
|
||||
assertReady(partnerCode, props);
|
||||
String appId = resolveAppId(props);
|
||||
String secret = resolveSecret(props);
|
||||
String template = ShengnuoSignHelper.nowTemplate();
|
||||
String sign = ShengnuoSignHelper.sign(appId, secret, template);
|
||||
String url = trimSlash(props.getBaseUrl()) + ShengnuoDownstreamApi.GET_INFO + "?appid=" + enc(appId)
|
||||
+ "&template=" + enc(template) + "&sign=" + enc(sign) + "&eid=" + enc(eid);
|
||||
return get(partnerCode, props, null, eid, "get_info", ShengnuoDownstreamApi.GET_INFO, url);
|
||||
}
|
||||
|
||||
/** 关房 */
|
||||
public JSONObject pushHotelClose(String partnerCode, HotelDownstreamProperties props, Long hotelId,
|
||||
String extHotelId, Object body) {
|
||||
return post(partnerCode, props, hotelId, extHotelId, "push_close", ShengnuoDownstreamApi.PUSH_HOTEL_CLOSE,
|
||||
body);
|
||||
}
|
||||
|
||||
/** 订单状态回写 */
|
||||
public JSONObject orderChange(String partnerCode, HotelDownstreamProperties props, Object body) {
|
||||
return post(partnerCode, props, null, null, "order_change", ShengnuoDownstreamApi.ORDER_CHANGE, body);
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSONObject orderDetail(String partnerCode, HotelDownstreamProperties props, Object body) {
|
||||
assertReady(partnerCode, props);
|
||||
Map<String, Object> map = body instanceof Map<?, ?> m ? castMap(m) : JSONUtil.parseObj(JSONUtil.toJsonStr(body));
|
||||
String orderId = StrUtil.trim(String.valueOf(map.getOrDefault("orderId", "")));
|
||||
if (StrUtil.isBlank(orderId)) {
|
||||
throw new CheckedException("圣诺 getOrderInfo 缺少 orderId");
|
||||
}
|
||||
String appId = resolveAppId(props);
|
||||
String secret = resolveSecret(props);
|
||||
String template = ShengnuoSignHelper.nowTemplate();
|
||||
String sign = ShengnuoSignHelper.sign(appId, secret, template);
|
||||
String url = trimSlash(props.getBaseUrl()) + ShengnuoDownstreamApi.GET_ORDER_INFO + "?appid=" + enc(appId)
|
||||
+ "&template=" + enc(template) + "&sign=" + enc(sign) + "&orderId=" + enc(orderId);
|
||||
return get(partnerCode, props, null, orderId, "get_order_info", ShengnuoDownstreamApi.GET_ORDER_INFO, url);
|
||||
}
|
||||
|
||||
/** 给 Mid 拼公共签名字段 */
|
||||
public Map<String, Object> signedBase(HotelDownstreamProperties props) {
|
||||
String appId = resolveAppId(props);
|
||||
String secret = resolveSecret(props);
|
||||
String template = ShengnuoSignHelper.nowTemplate();
|
||||
Map<String, Object> base = new LinkedHashMap<>();
|
||||
base.put("appid", appId);
|
||||
base.put("template", Long.parseLong(template));
|
||||
base.put("sign", ShengnuoSignHelper.sign(appId, secret, template));
|
||||
return base;
|
||||
}
|
||||
|
||||
public static String resolveAppId(HotelDownstreamProperties props) {
|
||||
if (props == null) {
|
||||
return "";
|
||||
}
|
||||
return StrUtil.blankToDefault(StrUtil.trim(props.getOrderAppId()), StrUtil.trim(props.getAppKey()));
|
||||
}
|
||||
|
||||
public static String resolveSecret(HotelDownstreamProperties props) {
|
||||
if (props == null) {
|
||||
return "";
|
||||
}
|
||||
return StrUtil.blankToDefault(StrUtil.trim(props.getOrderAppSecret()), StrUtil.trim(props.getAppSecret()));
|
||||
}
|
||||
|
||||
private static JSONObject okSkip(String msg) {
|
||||
JSONObject o = new JSONObject();
|
||||
o.set("code", 0);
|
||||
o.set("success", true);
|
||||
o.set("msg", msg);
|
||||
return o;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Map<String, Object> castMap(Map<?, ?> m) {
|
||||
return (Map<String, Object>) m;
|
||||
}
|
||||
|
||||
private JSONObject post(String partnerCode, HotelDownstreamProperties props, Long hotelId, String extHotelId,
|
||||
String action, String path, Object bodyObj) {
|
||||
assertReady(partnerCode, props);
|
||||
String bodyJson = bodyObj instanceof String ? (String) bodyObj : JSONUtil.toJsonStr(bodyObj);
|
||||
String url = trimSlash(props.getBaseUrl()) + path;
|
||||
long t0 = System.currentTimeMillis();
|
||||
Integer httpStatus = null;
|
||||
String respBody = null;
|
||||
String errMsg = null;
|
||||
boolean ok = false;
|
||||
JSONObject json = null;
|
||||
try {
|
||||
int timeout = props.getTimeoutMs() > 0 ? props.getTimeoutMs() : 30000;
|
||||
HttpResponse resp = HttpRequest.post(url)
|
||||
.header("Content-Type", "application/json;charset=UTF-8")
|
||||
.body(bodyJson)
|
||||
.timeout(timeout)
|
||||
.execute();
|
||||
httpStatus = resp.getStatus();
|
||||
respBody = resp.body();
|
||||
json = parseBiz(path, httpStatus, respBody);
|
||||
ok = true;
|
||||
return json;
|
||||
}
|
||||
catch (CheckedException e) {
|
||||
errMsg = e.getMessage();
|
||||
throw e;
|
||||
}
|
||||
catch (Exception e) {
|
||||
errMsg = "圣诺调用失败: " + e.getMessage();
|
||||
throw new CheckedException(errMsg, e);
|
||||
}
|
||||
finally {
|
||||
saveLog(partnerCode, props, hotelId, extHotelId, action, url, bodyJson, httpStatus, respBody, errMsg, ok,
|
||||
System.currentTimeMillis() - t0);
|
||||
}
|
||||
}
|
||||
|
||||
private JSONObject get(String partnerCode, HotelDownstreamProperties props, Long hotelId, String extHotelId,
|
||||
String action, String path, String url) {
|
||||
long t0 = System.currentTimeMillis();
|
||||
Integer httpStatus = null;
|
||||
String respBody = null;
|
||||
String errMsg = null;
|
||||
boolean ok = false;
|
||||
try {
|
||||
int timeout = props.getTimeoutMs() > 0 ? props.getTimeoutMs() : 30000;
|
||||
HttpResponse resp = HttpRequest.get(url).timeout(timeout).execute();
|
||||
httpStatus = resp.getStatus();
|
||||
respBody = resp.body();
|
||||
JSONObject json = parseBiz(path, httpStatus, respBody);
|
||||
ok = true;
|
||||
return json;
|
||||
}
|
||||
catch (CheckedException e) {
|
||||
errMsg = e.getMessage();
|
||||
throw e;
|
||||
}
|
||||
catch (Exception e) {
|
||||
errMsg = "圣诺调用失败: " + e.getMessage();
|
||||
throw new CheckedException(errMsg, e);
|
||||
}
|
||||
finally {
|
||||
saveLog(partnerCode, props, hotelId, extHotelId, action, url, null, httpStatus, respBody, errMsg, ok,
|
||||
System.currentTimeMillis() - t0);
|
||||
}
|
||||
}
|
||||
|
||||
private JSONObject parseBiz(String path, Integer httpStatus, String respBody) {
|
||||
String trimmed = StrUtil.trim(respBody);
|
||||
if (StrUtil.isBlank(trimmed)) {
|
||||
throw new CheckedException("圣诺响应为空: " + path);
|
||||
}
|
||||
if (trimmed.startsWith("<")) {
|
||||
throw new CheckedException(StrUtil.format("圣诺返回 HTML [{}] http={} body={}", path, httpStatus,
|
||||
StrUtil.maxLength(trimmed, 180)));
|
||||
}
|
||||
JSONObject json;
|
||||
try {
|
||||
json = JSONUtil.parseObj(trimmed);
|
||||
}
|
||||
catch (Exception e) {
|
||||
throw new CheckedException(StrUtil.format("圣诺响应非 JSON [{}] http={} body={}", path, httpStatus,
|
||||
StrUtil.maxLength(trimmed, 180)));
|
||||
}
|
||||
int bizCode = json.getInt("code", -1);
|
||||
boolean successFlag = json.getBool("success", bizCode == 0);
|
||||
if (bizCode != 0 && !successFlag) {
|
||||
throw new CheckedException(StrUtil.format("圣诺失败[{}] code={} msg={}", path, bizCode,
|
||||
StrUtil.blankToDefault(json.getStr("msg"), json.getStr("message"))));
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
private void saveLog(String partnerCode, HotelDownstreamProperties props, Long hotelId, String extHotelId,
|
||||
String action, String url, String reqBody, Integer httpStatus, String respBody, String errMsg, boolean ok,
|
||||
long costMs) {
|
||||
try {
|
||||
String mode = StrUtil.blankToDefault(props == null ? null : props.getPushLogMode(), "fail_only")
|
||||
.toLowerCase();
|
||||
boolean should;
|
||||
if (!ok || "all".equals(mode)) {
|
||||
should = true;
|
||||
}
|
||||
else if ("sample".equals(mode)) {
|
||||
should = DownstreamPushLogContext.tryConsumeSuccessSample();
|
||||
}
|
||||
else {
|
||||
should = false;
|
||||
}
|
||||
if (!should) {
|
||||
return;
|
||||
}
|
||||
int maxChars = props == null || props.getPushLogBodyMaxChars() <= 0 ? 2048 : props.getPushLogBodyMaxChars();
|
||||
SmHotelDownstreamPushLogEntity row = new SmHotelDownstreamPushLogEntity();
|
||||
row.setPartnerCode(partnerCode);
|
||||
row.setSyncLogId(DownstreamPushLogContext.syncLogId());
|
||||
row.setHotelId(hotelId);
|
||||
row.setExtHotelId(extHotelId);
|
||||
row.setAction(action);
|
||||
row.setRequestUrl(url);
|
||||
row.setHttpStatus(httpStatus);
|
||||
row.setSuccess(ok ? "1" : "0");
|
||||
row.setErrorMsg(StrUtil.maxLength(errMsg, 900));
|
||||
row.setCostMs(costMs);
|
||||
row.setRequestBody(StrUtil.maxLength(reqBody, maxChars));
|
||||
row.setResponseBody(StrUtil.maxLength(respBody, maxChars));
|
||||
pushLogService.record(row);
|
||||
}
|
||||
catch (Exception e) {
|
||||
log.warn("|{}|写推送流水失败|{}|", TAG, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static String trimSlash(String base) {
|
||||
if (base == null) {
|
||||
return "";
|
||||
}
|
||||
return base.endsWith("/") ? base.substring(0, base.length() - 1) : base;
|
||||
}
|
||||
|
||||
private static String enc(String v) {
|
||||
try {
|
||||
return java.net.URLEncoder.encode(StrUtil.blankToDefault(v, ""), java.nio.charset.StandardCharsets.UTF_8);
|
||||
}
|
||||
catch (Exception e) {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
package com.bsd.pjcloud.supermarket.integration.downstream.shengnuo;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.json.JSONObject;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 圣诺侧酒店/房型/计划 ID 与本地映射(推送 getInfo/plan 后写入,订单入站反查)。
|
||||
* 不改表结构,落 Redis;TTL 默认 30 天。
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class ShengnuoIdMappingCache {
|
||||
|
||||
private static final String KEY = "sm:hotel:shengnuo:map:";
|
||||
|
||||
private static final String REV = "sm:hotel:shengnuo:rev:";
|
||||
|
||||
private static final long TTL_DAYS = 30L;
|
||||
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
|
||||
public void save(String partner, Long localHotelId, long nsHotelId, Map<String, Long> roomTypeToNsRoom,
|
||||
Map<String, Long> productToNsPlan) {
|
||||
if (StrUtil.isBlank(partner) || localHotelId == null || nsHotelId <= 0) {
|
||||
return;
|
||||
}
|
||||
Map<String, Long> rooms = roomTypeToNsRoom == null ? Map.of() : roomTypeToNsRoom;
|
||||
Map<String, Long> plans = productToNsPlan == null ? Map.of() : productToNsPlan;
|
||||
Map<String, String> revRooms = new HashMap<>();
|
||||
for (Map.Entry<String, Long> e : rooms.entrySet()) {
|
||||
if (e.getValue() != null) {
|
||||
revRooms.put(String.valueOf(e.getValue()), e.getKey());
|
||||
}
|
||||
}
|
||||
Map<String, String> revPlans = new HashMap<>();
|
||||
for (Map.Entry<String, Long> e : plans.entrySet()) {
|
||||
if (e.getValue() != null) {
|
||||
revPlans.put(String.valueOf(e.getValue()), e.getKey());
|
||||
}
|
||||
}
|
||||
JSONObject o = new JSONObject();
|
||||
o.set("localHotelId", localHotelId);
|
||||
o.set("nsHotelId", nsHotelId);
|
||||
o.set("rooms", rooms);
|
||||
o.set("plans", plans);
|
||||
o.set("revRooms", revRooms);
|
||||
o.set("revPlans", revPlans);
|
||||
redisTemplate.opsForValue().set(KEY + partner + ":" + localHotelId, o.toString(), TTL_DAYS, TimeUnit.DAYS);
|
||||
redisTemplate.opsForValue().set(REV + partner + ":" + nsHotelId, String.valueOf(localHotelId), TTL_DAYS,
|
||||
TimeUnit.DAYS);
|
||||
}
|
||||
|
||||
public JSONObject getByLocal(String partner, Long localHotelId) {
|
||||
if (StrUtil.isBlank(partner) || localHotelId == null) {
|
||||
return null;
|
||||
}
|
||||
String raw = redisTemplate.opsForValue().get(KEY + partner + ":" + localHotelId);
|
||||
if (StrUtil.isBlank(raw)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSONUtil.parseObj(raw);
|
||||
}
|
||||
catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public Long localHotelIdByNs(String partner, long nsHotelId) {
|
||||
if (StrUtil.isBlank(partner) || nsHotelId <= 0) {
|
||||
return null;
|
||||
}
|
||||
String raw = redisTemplate.opsForValue().get(REV + partner + ":" + nsHotelId);
|
||||
if (StrUtil.isBlank(raw)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Long.parseLong(raw.trim());
|
||||
}
|
||||
catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public Map<String, Long> rooms(JSONObject map) {
|
||||
return toLongMap(map == null ? null : map.get("rooms"));
|
||||
}
|
||||
|
||||
public Map<String, Long> plans(JSONObject map) {
|
||||
return toLongMap(map == null ? null : map.get("plans"));
|
||||
}
|
||||
|
||||
public String roomTypeByNs(JSONObject map, long nsRoomId) {
|
||||
if (map == null || nsRoomId <= 0) {
|
||||
return null;
|
||||
}
|
||||
Object rev = map.get("revRooms");
|
||||
if (rev instanceof Map<?, ?> m) {
|
||||
Object v = m.get(String.valueOf(nsRoomId));
|
||||
return v == null ? null : String.valueOf(v);
|
||||
}
|
||||
for (Map.Entry<String, Long> e : rooms(map).entrySet()) {
|
||||
if (e.getValue() != null && e.getValue() == nsRoomId) {
|
||||
return e.getKey();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public String productByNsPlan(JSONObject map, long nsPlanId) {
|
||||
if (map == null || nsPlanId <= 0) {
|
||||
return null;
|
||||
}
|
||||
Object rev = map.get("revPlans");
|
||||
if (rev instanceof Map<?, ?> m) {
|
||||
Object v = m.get(String.valueOf(nsPlanId));
|
||||
return v == null ? null : String.valueOf(v);
|
||||
}
|
||||
for (Map.Entry<String, Long> e : plans(map).entrySet()) {
|
||||
if (e.getValue() != null && e.getValue() == nsPlanId) {
|
||||
return e.getKey();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Map<String, Long> toLongMap(Object raw) {
|
||||
Map<String, Long> out = new HashMap<>();
|
||||
if (!(raw instanceof Map<?, ?> m)) {
|
||||
return out;
|
||||
}
|
||||
for (Map.Entry<?, ?> e : m.entrySet()) {
|
||||
if (e.getKey() == null || e.getValue() == null) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
out.put(String.valueOf(e.getKey()), Long.parseLong(String.valueOf(e.getValue())));
|
||||
}
|
||||
catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.bsd.pjcloud.supermarket.integration.downstream.shengnuo;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.crypto.digest.DigestUtil;
|
||||
|
||||
/**
|
||||
* 圣诺/奈斯签名:md5('ns' + appid + secretKey + template),template 为十位秒级时间戳。
|
||||
*/
|
||||
public final class ShengnuoSignHelper {
|
||||
|
||||
private ShengnuoSignHelper() {
|
||||
}
|
||||
|
||||
public static String nowTemplate() {
|
||||
return String.valueOf(System.currentTimeMillis() / 1000L);
|
||||
}
|
||||
|
||||
public static String sign(String appId, String secretKey, String template) {
|
||||
String raw = "ns" + StrUtil.blankToDefault(appId, "") + StrUtil.blankToDefault(secretKey, "")
|
||||
+ StrUtil.blankToDefault(template, "");
|
||||
return DigestUtil.md5Hex(raw);
|
||||
}
|
||||
|
||||
public static boolean matches(String appId, String secretKey, String template, String sign) {
|
||||
if (StrUtil.hasBlank(appId, secretKey, template, sign)) {
|
||||
return false;
|
||||
}
|
||||
return sign.equalsIgnoreCase(sign(appId, secretKey, template));
|
||||
}
|
||||
}
|
||||
+20
@@ -79,6 +79,26 @@ public interface SmHotelDailyMapper extends PjcloudBaseMapper<SmHotelDailyEntity
|
||||
List<Map<String, Object>> selectMinSellPriceByStayRange(@Param("fromDate") LocalDate fromDate,
|
||||
@Param("toExclusive") LocalDate toExclusive, @Param("hotelIds") Collection<Long> hotelIds);
|
||||
|
||||
/**
|
||||
* 管理端列表起价:入住半开区间内开房日历的最低卖价(不要求剩余库存,便于已维护价也能展示)。
|
||||
*/
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT hotel_id AS hotelId, MIN(sell_price) AS minSell
|
||||
FROM canteen_hotel_daily
|
||||
WHERE del_flag = '0'
|
||||
AND stay_date >= #{fromDate}
|
||||
AND stay_date < #{toExclusive}
|
||||
AND IFNULL(daily_status, 1) = 1
|
||||
AND sell_price IS NOT NULL
|
||||
AND hotel_id IN
|
||||
<foreach collection="hotelIds" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||
GROUP BY hotel_id
|
||||
</script>
|
||||
""")
|
||||
List<Map<String, Object>> selectAdminListMinSellPriceByStayRange(@Param("fromDate") LocalDate fromDate,
|
||||
@Param("toExclusive") LocalDate toExclusive, @Param("hotelIds") Collection<Long> hotelIds);
|
||||
|
||||
int insertBatch(@Param("list") List<SmHotelDailyEntity> list);
|
||||
|
||||
/**
|
||||
|
||||
+9
-2
@@ -63,8 +63,15 @@ public interface SmHotelMidService {
|
||||
/** 酒店详情:主数据 + 已拉取的房型/产品/日历预览 */
|
||||
R<SmHotelDetailVO> getHotelDetail(Long hotelId);
|
||||
|
||||
/** 日历分页 */
|
||||
R<Page<SmHotelDailyEntity>> pageDaily(Long hotelId, Page<SmHotelDailyEntity> page, String productCode);
|
||||
/**
|
||||
* 日历分页。
|
||||
* @param productCode 可选产品编码
|
||||
* @param startDate 可选起始入住日(含);空则默认今天起
|
||||
* @param endDate 可选结束入住日(含)
|
||||
* @param enforceDeptScope true=管理端按部门隔离;false=TOC 订房不校验部门
|
||||
*/
|
||||
R<Page<SmHotelDailyEntity>> pageDaily(Long hotelId, Page<SmHotelDailyEntity> page, String productCode,
|
||||
java.time.LocalDate startDate, java.time.LocalDate endDate, boolean enforceDeptScope);
|
||||
|
||||
/** 查询当前上游账号已授权酒店列表(getHotelList,支持关键字与分页) */
|
||||
R<Map<String, Object>> listAuthorizedHotels(String keyword, long current, long size, boolean refresh);
|
||||
|
||||
+5
@@ -116,4 +116,9 @@ public interface SmHotelOrderService {
|
||||
* 入参建议:extHotelId、extProductId、checkIn、checkOut、roomCnt、price(可选)。
|
||||
*/
|
||||
R<Map<String, Object>> checkBookableForDownstream(Map<String, Object> body);
|
||||
|
||||
/**
|
||||
* 酒店管理端数据概览:按当前用户酒店 DataScope 汇总今日/昨日新订单、间夜、销售额。
|
||||
*/
|
||||
Map<String, Object> managerDashboardOverview();
|
||||
}
|
||||
+9
@@ -12,6 +12,15 @@ public interface SmHotelService extends IService<SmHotelEntity> {
|
||||
|
||||
Page<SmHotelEntity> pageScope(Page<SmHotelEntity> page, LambdaQueryWrapper<SmHotelEntity> wrapper);
|
||||
|
||||
/**
|
||||
* 按数据权限取酒店;不在部门范围内时抛 IllegalArgumentException。
|
||||
* 用于详情/编辑/房型日历等写读,防止跨部门 IDOR。
|
||||
*/
|
||||
SmHotelEntity getByIdInScope(Long id);
|
||||
|
||||
/** 校验酒店在当前用户数据权限范围内(仅校验,不返回实体) */
|
||||
void assertHotelInScope(Long id);
|
||||
|
||||
SmHotelEntity saveHotel(SmHotelEntity entity);
|
||||
|
||||
Boolean updateHotel(SmHotelEntity entity);
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package com.bsd.pjcloud.supermarket.service;
|
||||
|
||||
import cn.hutool.json.JSONObject;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 圣诺/奈斯订单入站:createOrder / orderCancel / orderConfirmTimeOut;出站 orderChange。
|
||||
*/
|
||||
public interface SmHotelShengnuoOrderService {
|
||||
|
||||
Map<String, Object> createOrder(String partnerCode, JSONObject body);
|
||||
|
||||
Map<String, Object> orderCancel(String partnerCode, JSONObject body);
|
||||
|
||||
/** 即将确认超时提醒:只记日志,返回成功 */
|
||||
Map<String, Object> orderConfirmTimeOut(String partnerCode, JSONObject body);
|
||||
}
|
||||
+5
-7
@@ -8,6 +8,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
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.supermarket.constant.SmHotelStayDates;
|
||||
import com.bsd.pjcloud.supermarket.entity.SmHotelAvailSubEntity;
|
||||
import com.bsd.pjcloud.supermarket.entity.SmHotelDailyEntity;
|
||||
import com.bsd.pjcloud.supermarket.entity.SmHotelEntity;
|
||||
@@ -534,22 +535,19 @@ public class SmHotelAvailabilityNotifyServiceImpl implements SmHotelAvailability
|
||||
private List<LocalDate> missingBookableNights(Long hotelId, String productCode, LocalDate checkIn,
|
||||
LocalDate checkOut, int rooms) {
|
||||
List<LocalDate> missing = new ArrayList<>();
|
||||
if (hotelId == null || StrUtil.isBlank(productCode) || checkIn == null || checkOut == null
|
||||
|| !checkOut.isAfter(checkIn)) {
|
||||
if (hotelId == null || StrUtil.isBlank(productCode) || !SmHotelStayDates.isValid(checkIn, checkOut)) {
|
||||
if (checkIn != null) {
|
||||
missing.add(checkIn);
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
List<LocalDate> nights = new ArrayList<>();
|
||||
for (LocalDate d = checkIn; d.isBefore(checkOut); d = d.plusDays(1)) {
|
||||
nights.add(d);
|
||||
}
|
||||
List<LocalDate> nights = SmHotelStayDates.occupyDates(checkIn, checkOut);
|
||||
LocalDate queryTo = SmHotelStayDates.isDayUse(checkIn, checkOut) ? checkIn : checkOut.minusDays(1);
|
||||
List<SmHotelDailyEntity> rows = dailyMapper.selectList(Wrappers.<SmHotelDailyEntity>lambdaQuery()
|
||||
.eq(SmHotelDailyEntity::getHotelId, hotelId)
|
||||
.eq(SmHotelDailyEntity::getProductCode, productCode)
|
||||
.ge(SmHotelDailyEntity::getStayDate, checkIn)
|
||||
.lt(SmHotelDailyEntity::getStayDate, checkOut));
|
||||
.le(SmHotelDailyEntity::getStayDate, queryTo));
|
||||
Set<LocalDate> ok = new HashSet<>();
|
||||
if (rows != null) {
|
||||
for (SmHotelDailyEntity row : rows) {
|
||||
|
||||
+3
-3
@@ -7,6 +7,7 @@ import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.bsd.pjcloud.common.core.util.R;
|
||||
import com.bsd.pjcloud.supermarket.config.HotelDownstreamProperties;
|
||||
import com.bsd.pjcloud.supermarket.constant.SmHotelDownstreamProtocol;
|
||||
import com.bsd.pjcloud.supermarket.constant.SmHotelStayDates;
|
||||
import com.bsd.pjcloud.supermarket.entity.SmHotelEntity;
|
||||
import com.bsd.pjcloud.supermarket.entity.SmHotelOrderEntity;
|
||||
import com.bsd.pjcloud.supermarket.entity.SmHotelProductEntity;
|
||||
@@ -450,9 +451,8 @@ public class SmHotelHuidingOrderServiceImpl implements SmHotelHuidingOrderServic
|
||||
m.checkOut = parseDay(str(data.get("checkoutDate")));
|
||||
m.quantity = data.getInt("quantity") == null ? 1 : Math.max(1, data.getInt("quantity"));
|
||||
m.orderAmount = toDecimal(data.get("orderAmount"));
|
||||
if (StrUtil.isBlank(hotelId) || StrUtil.isBlank(productId) || m.checkIn == null || m.checkOut == null
|
||||
|| !m.checkIn.isBefore(m.checkOut)) {
|
||||
m.error = "hotelId/productId/入住离店日期无效";
|
||||
if (StrUtil.isBlank(hotelId) || StrUtil.isBlank(productId) || !SmHotelStayDates.isValid(m.checkIn, m.checkOut)) {
|
||||
m.error = "hotelId/productId/入住离店日期无效(离店不能早于入住;相等表示钟点房)";
|
||||
return m;
|
||||
}
|
||||
m.hotel = hotelMapper.selectOne(Wrappers.<SmHotelEntity>lambdaQuery()
|
||||
|
||||
+421
-13
@@ -15,6 +15,8 @@ import com.bsd.pjcloud.supermarket.constant.SmHotelDataSource;
|
||||
import com.bsd.pjcloud.supermarket.constant.SmHotelDownstreamProtocol;
|
||||
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.dto.SmHotelLocalDailyBatchDTO;
|
||||
import com.bsd.pjcloud.supermarket.entity.*;
|
||||
import com.bsd.pjcloud.supermarket.integration.downstream.DownstreamHotelClient;
|
||||
@@ -33,6 +35,7 @@ import com.bsd.pjcloud.supermarket.service.SmHotelPriceConfigService;
|
||||
import com.bsd.pjcloud.supermarket.vo.SmHotelDetailVO;
|
||||
import com.bsd.pjcloud.common.core.constant.CommonConstants;
|
||||
import com.bsd.pjcloud.common.core.util.R;
|
||||
import com.bsd.pjcloud.common.data.datascope.DataScope;
|
||||
import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -65,6 +68,9 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
|
||||
/** 本地日历单次批量最长天数(含首尾) */
|
||||
private static final int LOCAL_DAILY_BATCH_MAX_DAYS = 366;
|
||||
|
||||
/** 圣诺 pushHotelPrice 单次最多 15 天 */
|
||||
private static final int SHENGNUO_PRICE_CHUNK_DAYS = 15;
|
||||
|
||||
/**
|
||||
* 拆「位置区域」拼接文案为级联各层:省 / 市(州) / 区县。
|
||||
* 例:湖北省恩施土家族苗族自治州恩施市 → [湖北省, 恩施土家族苗族自治州, 恩施市]
|
||||
@@ -105,6 +111,10 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
|
||||
|
||||
private final StringRedisTemplate stringRedisTemplate;
|
||||
|
||||
private final ShengnuoDownstreamHotelAdapter shengnuoAdapter;
|
||||
|
||||
private final ShengnuoIdMappingCache shengnuoIdMappingCache;
|
||||
|
||||
public SmHotelMidServiceImpl(ShangyouHotelClient upstreamClient, ShangyouHotelProperties upstreamProperties,
|
||||
DownstreamHotelClient downstreamClient, HotelDownstreamProperties downstreamProperties,
|
||||
SmHotelPriceConfigService priceConfigService, SmHotelChannelConfigService channelConfigService,
|
||||
@@ -113,10 +123,13 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
|
||||
@Lazy SmHotelOpenResourceNotifyService openResourceNotifyService,
|
||||
@Lazy SmHotelAvailabilityNotifyService availabilityNotifyService, SmHotelMapper hotelMapper,
|
||||
SmHotelRoomMapper roomMapper, SmHotelProductMapper productMapper, SmHotelDailyMapper dailyMapper,
|
||||
StringRedisTemplate stringRedisTemplate) {
|
||||
StringRedisTemplate stringRedisTemplate, ShengnuoDownstreamHotelAdapter shengnuoAdapter,
|
||||
ShengnuoIdMappingCache shengnuoIdMappingCache) {
|
||||
this.upstreamClient = upstreamClient;
|
||||
this.upstreamProperties = upstreamProperties;
|
||||
this.downstreamClient = downstreamClient;
|
||||
this.shengnuoAdapter = shengnuoAdapter;
|
||||
this.shengnuoIdMappingCache = shengnuoIdMappingCache;
|
||||
this.downstreamProperties = downstreamProperties;
|
||||
this.priceConfigService = priceConfigService;
|
||||
this.channelConfigService = channelConfigService;
|
||||
@@ -484,6 +497,13 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
|
||||
closed.put("productCode", productCode);
|
||||
return R.ok(closed);
|
||||
}
|
||||
if (SmHotelDownstreamProtocol.isShengnuo(ds == null ? null : ds.getProtocol())) {
|
||||
Map<String, Object> closed = closeHotelDailyToShengnuo(hotel, partner);
|
||||
closed.put("pushKind", SmHotelDownstreamPushKind.CLOSE);
|
||||
closed.put("closed", true);
|
||||
closed.put("productCode", productCode);
|
||||
return R.ok(closed);
|
||||
}
|
||||
SmHotelProductEntity fake = new SmHotelProductEntity();
|
||||
fake.setProductCode(productCode.trim());
|
||||
fake.setRoomTypeCode(StrUtil.blankToDefault(roomTypeCode, productCode.trim()));
|
||||
@@ -570,6 +590,9 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
|
||||
if (SmHotelDownstreamProtocol.isHuiding(ds == null ? null : ds.getProtocol())) {
|
||||
return closeHotelDailyToHuiding(hotel, partnerCode);
|
||||
}
|
||||
if (SmHotelDownstreamProtocol.isShengnuo(ds == null ? null : ds.getProtocol())) {
|
||||
return closeHotelDailyToShengnuo(hotel, partnerCode);
|
||||
}
|
||||
Long hotelId = hotel.getId();
|
||||
downstreamClient.assertPartnerReady(partnerCode);
|
||||
List<SmHotelProductEntity> products = onlyProducts;
|
||||
@@ -593,6 +616,299 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 圣诺可售推:getInfo(eid) → pushHotelPlan → pushHotelPrice(15 天切片)。
|
||||
* eid 优先 sHotelId(艺龙同程 id),否则 hotelCode。
|
||||
*/
|
||||
private Map<String, Object> pushHotelToShengnuo(SmHotelEntity hotel, String partnerCode) {
|
||||
Long hotelId = hotel.getId();
|
||||
String sHotelId = StrUtil.trim(hotel.getSHotelId());
|
||||
String eid = StrUtil.blankToDefault(sHotelId, StrUtil.trim(hotel.getHotelCode()));
|
||||
if (StrUtil.isBlank(eid)) {
|
||||
throw new IllegalArgumentException("圣诺推送缺少 eid:请在酒店资料填写「外部酒店ID(sHotelId)=艺龙同程id」 hotelId=" + hotelId);
|
||||
}
|
||||
if (StrUtil.isBlank(sHotelId)) {
|
||||
log.warn("|下游推送[圣诺]|未填 sHotelId,回落 hotelCode 作 eid|hotelId={}|eid={}|", hotelId, eid);
|
||||
}
|
||||
downstreamClient.assertPartnerReady(partnerCode);
|
||||
HotelDownstreamProperties ds = channelConfigService.resolveDownstreamRuntime(partnerCode);
|
||||
|
||||
cn.hutool.json.JSONObject infoResp;
|
||||
try {
|
||||
infoResp = shengnuoAdapter.getInfo(partnerCode, ds, eid);
|
||||
}
|
||||
catch (Exception ex) {
|
||||
throw new IllegalArgumentException("圣诺 getInfo 失败(eid=" + eid
|
||||
+ (StrUtil.isBlank(sHotelId) ? ",当前用的是 hotelCode,请改填外部酒店ID/艺龙同程id" : ",请核对圣诺侧是否已建档")
|
||||
+ "):" + ex.getMessage(), ex);
|
||||
}
|
||||
cn.hutool.json.JSONObject infoData = infoResp == null ? null : infoResp.getJSONObject("data");
|
||||
if (infoData == null) {
|
||||
throw new IllegalArgumentException("圣诺 getInfo 无 data,eid=" + eid + "(请核对酒店外部酒店ID)");
|
||||
}
|
||||
long nsHotelId = infoData.getLong("id", 0L);
|
||||
if (nsHotelId <= 0) {
|
||||
throw new IllegalArgumentException("圣诺 getInfo 未返回酒店 id,eid=" + eid);
|
||||
}
|
||||
cn.hutool.json.JSONArray nsRooms = infoData.getJSONArray("rooms");
|
||||
Map<String, Long> roomTypeToNs = matchShengnuoRooms(hotelId, nsRooms);
|
||||
|
||||
List<SmHotelProductEntity> products = productMapper.selectList(Wrappers.<SmHotelProductEntity>lambdaQuery()
|
||||
.eq(SmHotelProductEntity::getHotelId, hotelId)
|
||||
.isNotNull(SmHotelProductEntity::getProductCode));
|
||||
Map<String, Long> productToPlan = new LinkedHashMap<>();
|
||||
int planOk = 0;
|
||||
List<String> planErrors = new ArrayList<>();
|
||||
for (SmHotelProductEntity p : products) {
|
||||
if (p == null || StrUtil.isBlank(p.getProductCode()) || StrUtil.isBlank(p.getRoomTypeCode())) {
|
||||
continue;
|
||||
}
|
||||
Long nsRoomId = roomTypeToNs.get(p.getRoomTypeCode());
|
||||
if (nsRoomId == null) {
|
||||
planErrors.add(p.getProductCode() + ":无匹配圣诺房型");
|
||||
continue;
|
||||
}
|
||||
int breakfast = p.getMealCount() == null ? 0 : Math.max(0, p.getMealCount());
|
||||
Map<String, Object> body = new LinkedHashMap<>(shengnuoAdapter.signedBase(ds));
|
||||
body.put("id", nsHotelId);
|
||||
body.put("roomId", nsRoomId);
|
||||
List<Map<String, Object>> planCode = new ArrayList<>(1);
|
||||
Map<String, Object> plan = new LinkedHashMap<>();
|
||||
plan.put("breakfast", breakfast);
|
||||
plan.put("is_cancel", 1);
|
||||
plan.put("sale", 1);
|
||||
plan.put("cancel_advance_day", 0);
|
||||
plan.put("cancel_specific_date", "18:00");
|
||||
plan.put("cancel_cut_type", 3);
|
||||
plan.put("cancel_amount", 0);
|
||||
planCode.add(plan);
|
||||
body.put("planCode", planCode);
|
||||
try {
|
||||
cn.hutool.json.JSONObject planResp = shengnuoAdapter.hotelSync(partnerCode, ds, hotelId,
|
||||
String.valueOf(nsHotelId), body);
|
||||
long planId = extractShengnuoPlanId(planResp, breakfast);
|
||||
if (planId <= 0) {
|
||||
planErrors.add(p.getProductCode() + ":未返回 plan_id");
|
||||
continue;
|
||||
}
|
||||
productToPlan.put(p.getProductCode(), planId);
|
||||
planOk++;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
planErrors.add(p.getProductCode() + ":" + ex.getMessage());
|
||||
log.warn("|下游计划推送[圣诺]|失败|hotelId={}|product={}|{}", hotelId, p.getProductCode(),
|
||||
ex.getMessage());
|
||||
}
|
||||
}
|
||||
shengnuoIdMappingCache.save(partnerCode, hotelId, nsHotelId, roomTypeToNs, productToPlan);
|
||||
|
||||
LocalDate[] window = resolveDownstreamDailyWindow(partnerCode);
|
||||
LocalDate dailyFrom = window[0];
|
||||
LocalDate dailyTo = window[1];
|
||||
List<SmHotelDailyEntity> dailies = dailyMapper.selectList(Wrappers.<SmHotelDailyEntity>lambdaQuery()
|
||||
.eq(SmHotelDailyEntity::getHotelId, hotelId)
|
||||
.ge(SmHotelDailyEntity::getStayDate, dailyFrom)
|
||||
.le(SmHotelDailyEntity::getStayDate, dailyTo)
|
||||
.orderByAsc(SmHotelDailyEntity::getStayDate));
|
||||
int priceChunks = pushShengnuoPriceChunks(partnerCode, ds, hotelId, nsHotelId, products, roomTypeToNs,
|
||||
productToPlan, dailies, dailyFrom, dailyTo);
|
||||
|
||||
Map<String, Object> result = new HashMap<>(12);
|
||||
result.put("protocol", SmHotelDownstreamProtocol.SHENGNUO);
|
||||
result.put("nsHotelId", nsHotelId);
|
||||
result.put("eid", eid);
|
||||
result.put("matchedRooms", roomTypeToNs.size());
|
||||
result.put("planOk", planOk);
|
||||
result.put("planErrors", planErrors);
|
||||
result.put("priceChunks", priceChunks);
|
||||
result.put("dailyRowCount", dailies.size());
|
||||
result.put("windowFrom", dailyFrom.toString());
|
||||
result.put("windowTo", dailyTo.toString());
|
||||
log.info("|下游可售推送[圣诺]|hotelId={}|partner={}|nsHotelId={}|plans={}|chunks={}|", hotelId, partnerCode,
|
||||
nsHotelId, planOk, priceChunks);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 圣诺关房:pushHotelClose(整店) */
|
||||
private Map<String, Object> closeHotelDailyToShengnuo(SmHotelEntity hotel, String partnerCode) {
|
||||
downstreamClient.assertPartnerReady(partnerCode);
|
||||
HotelDownstreamProperties ds = channelConfigService.resolveDownstreamRuntime(partnerCode);
|
||||
long nsHotelId = resolveShengnuoHotelId(partnerCode, ds, hotel);
|
||||
Map<String, Object> body = new LinkedHashMap<>(shengnuoAdapter.signedBase(ds));
|
||||
body.put("id", nsHotelId);
|
||||
shengnuoAdapter.pushHotelClose(partnerCode, ds, hotel.getId(), String.valueOf(nsHotelId), body);
|
||||
log.info("|下游关房推送[圣诺]|hotelId={}|partner={}|nsHotelId={}|", hotel.getId(), partnerCode, nsHotelId);
|
||||
Map<String, Object> result = new HashMap<>(6);
|
||||
result.put("protocol", SmHotelDownstreamProtocol.SHENGNUO);
|
||||
result.put("nsHotelId", nsHotelId);
|
||||
result.put("dailyCloseCount", 1);
|
||||
return result;
|
||||
}
|
||||
|
||||
private long resolveShengnuoHotelId(String partnerCode, HotelDownstreamProperties ds, SmHotelEntity hotel) {
|
||||
cn.hutool.json.JSONObject cached = shengnuoIdMappingCache.getByLocal(partnerCode, hotel.getId());
|
||||
if (cached != null) {
|
||||
long id = cached.getLong("nsHotelId", 0L);
|
||||
if (id > 0) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
String eid = StrUtil.blankToDefault(StrUtil.trim(hotel.getSHotelId()), StrUtil.trim(hotel.getHotelCode()));
|
||||
if (StrUtil.isBlank(eid)) {
|
||||
throw new IllegalArgumentException("圣诺关房缺少 eid/映射 hotelId=" + hotel.getId());
|
||||
}
|
||||
cn.hutool.json.JSONObject infoResp = shengnuoAdapter.getInfo(partnerCode, ds, eid);
|
||||
cn.hutool.json.JSONObject infoData = infoResp == null ? null : infoResp.getJSONObject("data");
|
||||
long nsHotelId = infoData == null ? 0L : infoData.getLong("id", 0L);
|
||||
if (nsHotelId <= 0) {
|
||||
throw new IllegalArgumentException("圣诺 getInfo 未返回酒店 id,eid=" + eid);
|
||||
}
|
||||
return nsHotelId;
|
||||
}
|
||||
|
||||
/** getInfo.rooms → 本地 roomTypeCode → 圣诺 room.id(优先 elong_room_type_code,其次名称) */
|
||||
private Map<String, Long> matchShengnuoRooms(Long hotelId, cn.hutool.json.JSONArray nsRooms) {
|
||||
Map<String, Long> out = new LinkedHashMap<>();
|
||||
if (nsRooms == null || nsRooms.isEmpty()) {
|
||||
return out;
|
||||
}
|
||||
List<SmHotelRoomEntity> rooms = roomMapper.selectList(Wrappers.<SmHotelRoomEntity>lambdaQuery()
|
||||
.eq(SmHotelRoomEntity::getHotelId, hotelId));
|
||||
Map<String, Long> byElong = new HashMap<>();
|
||||
Map<String, Long> byName = new HashMap<>();
|
||||
for (int i = 0; i < nsRooms.size(); i++) {
|
||||
cn.hutool.json.JSONObject r = nsRooms.getJSONObject(i);
|
||||
if (r == null) {
|
||||
continue;
|
||||
}
|
||||
long id = r.getLong("id", 0L);
|
||||
if (id <= 0) {
|
||||
continue;
|
||||
}
|
||||
String elong = StrUtil.trim(r.getStr("elong_room_type_code"));
|
||||
String name = StrUtil.trim(r.getStr("name"));
|
||||
if (StrUtil.isNotBlank(elong) && !"0000".equals(elong)) {
|
||||
byElong.putIfAbsent(elong, id);
|
||||
}
|
||||
if (StrUtil.isNotBlank(name)) {
|
||||
byName.putIfAbsent(name, id);
|
||||
}
|
||||
}
|
||||
for (SmHotelRoomEntity room : rooms) {
|
||||
if (room == null || StrUtil.isBlank(room.getRoomTypeCode())) {
|
||||
continue;
|
||||
}
|
||||
Long nsId = byElong.get(room.getRoomTypeCode());
|
||||
if (nsId == null && StrUtil.isNotBlank(room.getRoomTypeName())) {
|
||||
nsId = byName.get(StrUtil.trim(room.getRoomTypeName()));
|
||||
}
|
||||
if (nsId != null) {
|
||||
out.put(room.getRoomTypeCode(), nsId);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static long extractShengnuoPlanId(cn.hutool.json.JSONObject planResp, int breakfast) {
|
||||
if (planResp == null) {
|
||||
return 0L;
|
||||
}
|
||||
cn.hutool.json.JSONArray data = planResp.getJSONArray("data");
|
||||
if (data == null || data.isEmpty()) {
|
||||
return planResp.getLong("plan_id", 0L);
|
||||
}
|
||||
for (int i = 0; i < data.size(); i++) {
|
||||
cn.hutool.json.JSONObject row = data.getJSONObject(i);
|
||||
if (row == null) {
|
||||
continue;
|
||||
}
|
||||
String bf = StrUtil.trim(row.getStr("breakfast"));
|
||||
if (StrUtil.isBlank(bf) || String.valueOf(breakfast).equals(bf)) {
|
||||
return row.getLong("plan_id", 0L);
|
||||
}
|
||||
}
|
||||
cn.hutool.json.JSONObject first = data.getJSONObject(0);
|
||||
return first == null ? 0L : first.getLong("plan_id", 0L);
|
||||
}
|
||||
|
||||
private int pushShengnuoPriceChunks(String partnerCode, HotelDownstreamProperties ds, Long hotelId, long nsHotelId,
|
||||
List<SmHotelProductEntity> products, Map<String, Long> roomTypeToNs, Map<String, Long> productToPlan,
|
||||
List<SmHotelDailyEntity> dailies, LocalDate from, LocalDate to) {
|
||||
if (CollUtil.isEmpty(productToPlan) || from == null || to == null || from.isAfter(to)) {
|
||||
return 0;
|
||||
}
|
||||
Map<String, SmHotelProductEntity> productByCode = new HashMap<>();
|
||||
for (SmHotelProductEntity p : products) {
|
||||
if (p != null && StrUtil.isNotBlank(p.getProductCode())) {
|
||||
productByCode.put(p.getProductCode(), p);
|
||||
}
|
||||
}
|
||||
Map<String, Map<LocalDate, SmHotelDailyEntity>> dailyByProduct = new HashMap<>();
|
||||
for (SmHotelDailyEntity d : dailies) {
|
||||
if (d == null || StrUtil.isBlank(d.getProductCode()) || d.getStayDate() == null) {
|
||||
continue;
|
||||
}
|
||||
dailyByProduct.computeIfAbsent(d.getProductCode(), k -> new HashMap<>()).put(d.getStayDate(), d);
|
||||
}
|
||||
int chunks = 0;
|
||||
LocalDate cursor = from;
|
||||
while (!cursor.isAfter(to)) {
|
||||
LocalDate chunkEnd = cursor.plusDays(SHENGNUO_PRICE_CHUNK_DAYS - 1L);
|
||||
if (chunkEnd.isAfter(to)) {
|
||||
chunkEnd = to;
|
||||
}
|
||||
List<Map<String, Object>> priceInfo = new ArrayList<>();
|
||||
for (Map.Entry<String, Long> e : productToPlan.entrySet()) {
|
||||
SmHotelProductEntity p = productByCode.get(e.getKey());
|
||||
if (p == null) {
|
||||
continue;
|
||||
}
|
||||
Long nsRoomId = roomTypeToNs.get(p.getRoomTypeCode());
|
||||
if (nsRoomId == null) {
|
||||
continue;
|
||||
}
|
||||
List<Map<String, Object>> priceCode = new ArrayList<>();
|
||||
Map<LocalDate, SmHotelDailyEntity> dayMap = dailyByProduct.getOrDefault(e.getKey(), Map.of());
|
||||
for (LocalDate d = cursor; !d.isAfter(chunkEnd); d = d.plusDays(1)) {
|
||||
SmHotelDailyEntity row = dayMap.get(d);
|
||||
Map<String, Object> pc = new LinkedHashMap<>();
|
||||
pc.put("date", d.toString());
|
||||
if (row == null) {
|
||||
pc.put("cost", 1);
|
||||
pc.put("amount", 0);
|
||||
pc.put("status", 0);
|
||||
}
|
||||
else {
|
||||
BigDecimal price = row.getSellPrice() != null ? row.getSellPrice() : row.getCostPrice();
|
||||
if (price == null || price.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
price = BigDecimal.ONE;
|
||||
}
|
||||
int amount = row.getRemainStock() == null ? 0 : Math.max(0, row.getRemainStock());
|
||||
int open = Integer.valueOf(1).equals(row.getDailyStatus()) && amount > 0 ? 1 : 0;
|
||||
pc.put("cost", price.setScale(0, java.math.RoundingMode.HALF_UP).intValue());
|
||||
pc.put("amount", open == 1 ? Math.max(1, amount) : amount);
|
||||
pc.put("status", open);
|
||||
}
|
||||
priceCode.add(pc);
|
||||
}
|
||||
Map<String, Object> info = new LinkedHashMap<>();
|
||||
info.put("roomId", nsRoomId);
|
||||
info.put("planId", e.getValue());
|
||||
info.put("priceCode", priceCode);
|
||||
priceInfo.add(info);
|
||||
}
|
||||
if (CollUtil.isNotEmpty(priceInfo)) {
|
||||
Map<String, Object> body = new LinkedHashMap<>(shengnuoAdapter.signedBase(ds));
|
||||
body.put("id", nsHotelId);
|
||||
body.put("priceInfo", priceInfo);
|
||||
shengnuoAdapter.dailySync(partnerCode, ds, hotelId, String.valueOf(nsHotelId), body);
|
||||
chunks++;
|
||||
}
|
||||
cursor = chunkEnd.plusDays(1);
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/** 慧订关房:hoteldatepush 空 roomPriceInfoList,窗口内打满房 */
|
||||
private Map<String, Object> closeHotelDailyToHuiding(SmHotelEntity hotel, String partnerCode) {
|
||||
downstreamClient.assertPartnerReady(partnerCode);
|
||||
@@ -677,6 +993,16 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
|
||||
Map<String, Object> r = new LinkedHashMap<>();
|
||||
r.put("roomTypeId", huidingRoomTypeCode(supplierHotelId, room.getRoomTypeCode()));
|
||||
r.put("name", StrUtil.blankToDefault(room.getRoomTypeName(), room.getRoomTypeCode()));
|
||||
if (StrUtil.isNotBlank(room.getFloorInfo())) {
|
||||
r.put("floor", room.getFloorInfo());
|
||||
}
|
||||
if (room.getAreaSqm() != null) {
|
||||
r.put("area", room.getAreaSqm().stripTrailingZeros().toPlainString());
|
||||
}
|
||||
String huidingWindow = mapHuidingWindowType(room.getWindowType());
|
||||
if (StrUtil.isNotBlank(huidingWindow)) {
|
||||
r.put("windowType", huidingWindow);
|
||||
}
|
||||
if (room.getCapacity() != null) {
|
||||
r.put("maxCapacity", String.valueOf(room.getCapacity()));
|
||||
}
|
||||
@@ -846,6 +1172,29 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
|
||||
return 20;
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地窗型文案 → 慧订 windowType:1有窗 / 2无窗 / 3部分有窗;无法识别则不传。
|
||||
*/
|
||||
private static String mapHuidingWindowType(String windowType) {
|
||||
if (StrUtil.isBlank(windowType)) {
|
||||
return null;
|
||||
}
|
||||
String t = windowType.trim();
|
||||
if ("1".equals(t) || "2".equals(t) || "3".equals(t)) {
|
||||
return t;
|
||||
}
|
||||
if (t.contains("部分")) {
|
||||
return "3";
|
||||
}
|
||||
if (t.contains("无窗")) {
|
||||
return "2";
|
||||
}
|
||||
if (t.contains("有窗")) {
|
||||
return "1";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 窗型文案 → 环启 roomWindow:0无窗/1有窗/2部分有窗/99其他 */
|
||||
private static int mapRoomWindowCode(String windowType) {
|
||||
if (StrUtil.isBlank(windowType)) {
|
||||
@@ -991,12 +1340,15 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
|
||||
return matched.stream().filter(enabledSet::contains).distinct().toList();
|
||||
}
|
||||
|
||||
/** 按下游协议约定顺序推送:环启四段;慧订静态+价态 */
|
||||
/** 按下游协议约定顺序推送:环启四段;慧订静态+价态;圣诺 getInfo+plan+price */
|
||||
private Map<String, Object> pushHotelToPartner(SmHotelEntity hotel, String partnerCode) {
|
||||
HotelDownstreamProperties ds = channelConfigService.resolveDownstreamRuntime(partnerCode);
|
||||
if (SmHotelDownstreamProtocol.isHuiding(ds == null ? null : ds.getProtocol())) {
|
||||
return pushHotelToHuiding(hotel, partnerCode);
|
||||
}
|
||||
if (SmHotelDownstreamProtocol.isShengnuo(ds == null ? null : ds.getProtocol())) {
|
||||
return pushHotelToShengnuo(hotel, partnerCode);
|
||||
}
|
||||
Long hotelId = hotel.getId();
|
||||
String extHotelId = StrUtil.blankToDefault(hotel.getHotelCode(), String.valueOf(hotel.getId()));
|
||||
downstreamClient.assertPartnerReady(partnerCode);
|
||||
@@ -1204,7 +1556,10 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 连续同价/库存/房态日期合并为区间(环启 list 最多 50 段) */
|
||||
/**
|
||||
* 连续同价/剩余库存/有效房态日期合并为区间(环启 list 最多 50 段)。
|
||||
* 剩余 = max(0, stock - sold);剩余为 0 时按关房推,避免环启仍按配额超卖。
|
||||
*/
|
||||
private static List<Map<String, Object>> mergeDailyRanges(List<SmHotelDailyEntity> rows) {
|
||||
List<Map<String, Object>> ranges = new ArrayList<>();
|
||||
if (CollUtil.isEmpty(rows)) {
|
||||
@@ -1217,8 +1572,8 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
|
||||
boolean contiguous = prev.getStayDate() != null && cur.getStayDate() != null
|
||||
&& prev.getStayDate().plusDays(1).equals(cur.getStayDate());
|
||||
boolean same = Objects.equals(prev.getSellPrice(), cur.getSellPrice())
|
||||
&& Objects.equals(prev.getStock(), cur.getStock())
|
||||
&& Objects.equals(prev.getDailyStatus(), cur.getDailyStatus());
|
||||
&& huanqiRemainStock(prev) == huanqiRemainStock(cur)
|
||||
&& huanqiDailyStatus(prev) == huanqiDailyStatus(cur);
|
||||
if (contiguous && same) {
|
||||
prev = cur;
|
||||
continue;
|
||||
@@ -1231,13 +1586,33 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
|
||||
return ranges;
|
||||
}
|
||||
|
||||
/** 环启 dailyNumber:推可售剩余,不推裸配额 */
|
||||
private static int huanqiRemainStock(SmHotelDailyEntity row) {
|
||||
if (row == null) {
|
||||
return 0;
|
||||
}
|
||||
int stock = row.getStock() == null ? 0 : Math.max(0, row.getStock());
|
||||
int sold = row.getSold() == null ? 0 : Math.max(0, row.getSold());
|
||||
return Math.max(0, stock - sold);
|
||||
}
|
||||
|
||||
/** 环启 dailyStatus:本地开房且有剩余才推开房,否则关房 */
|
||||
private static int huanqiDailyStatus(SmHotelDailyEntity row) {
|
||||
int open = row == null || row.getDailyStatus() == null ? 1 : row.getDailyStatus();
|
||||
if (open != 1 || huanqiRemainStock(row) <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static Map<String, Object> dailyRangeItem(SmHotelDailyEntity start, SmHotelDailyEntity end) {
|
||||
Map<String, Object> item = new LinkedHashMap<>();
|
||||
item.put("startDate", start.getStayDate().toString());
|
||||
item.put("endDate", end.getStayDate().toString());
|
||||
item.put("dailyPrice", end.getSellPrice());
|
||||
item.put("dailyNumber", end.getStock());
|
||||
item.put("dailyStatus", end.getDailyStatus() == null ? 1 : end.getDailyStatus());
|
||||
int remain = huanqiRemainStock(end);
|
||||
item.put("dailyNumber", remain);
|
||||
item.put("dailyStatus", huanqiDailyStatus(end));
|
||||
return item;
|
||||
}
|
||||
|
||||
@@ -1311,9 +1686,12 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
|
||||
|
||||
@Override
|
||||
public R<SmHotelDetailVO> getHotelDetail(Long hotelId) {
|
||||
SmHotelEntity hotel = hotelMapper.selectById(hotelId);
|
||||
if (hotel == null) {
|
||||
return R.failed("酒店不存在");
|
||||
SmHotelEntity hotel;
|
||||
try {
|
||||
hotel = requireHotelInScope(hotelId);
|
||||
}
|
||||
catch (IllegalArgumentException e) {
|
||||
return R.failed(e.getMessage());
|
||||
}
|
||||
SmHotelDetailVO vo = new SmHotelDetailVO();
|
||||
vo.setHotel(hotel);
|
||||
@@ -1338,12 +1716,22 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
|
||||
|
||||
@Override
|
||||
public R<Page<SmHotelDailyEntity>> pageDaily(Long hotelId, Page<SmHotelDailyEntity> page,
|
||||
String productCode) {
|
||||
String productCode, LocalDate startDate, LocalDate endDate, boolean enforceDeptScope) {
|
||||
if (enforceDeptScope) {
|
||||
try {
|
||||
requireHotelInScope(hotelId);
|
||||
}
|
||||
catch (IllegalArgumentException e) {
|
||||
return R.failed(e.getMessage());
|
||||
}
|
||||
}
|
||||
LocalDate from = startDate != null ? startDate : LocalDate.now();
|
||||
Page<SmHotelDailyEntity> result = dailyMapper.selectPage(page,
|
||||
Wrappers.<SmHotelDailyEntity>lambdaQuery()
|
||||
.eq(SmHotelDailyEntity::getHotelId, hotelId)
|
||||
.eq(StrUtil.isNotBlank(productCode), SmHotelDailyEntity::getProductCode, productCode)
|
||||
.ge(SmHotelDailyEntity::getStayDate, LocalDate.now())
|
||||
.ge(SmHotelDailyEntity::getStayDate, from)
|
||||
.le(endDate != null, SmHotelDailyEntity::getStayDate, endDate)
|
||||
.orderByAsc(SmHotelDailyEntity::getStayDate)
|
||||
.orderByAsc(SmHotelDailyEntity::getProductCode));
|
||||
if (result.getRecords() != null) {
|
||||
@@ -1902,7 +2290,8 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
|
||||
}
|
||||
|
||||
private SmHotelEntity requireLocalHotel(Long hotelId) {
|
||||
SmHotelEntity hotel = requireHotel(hotelId, false);
|
||||
// 手工维护房型/产品/日历:本地来源 + 部门数据隔离
|
||||
SmHotelEntity hotel = requireHotelInScope(hotelId);
|
||||
if (!SmHotelDataSource.isLocal(hotel.getSheetName())) {
|
||||
throw new IllegalArgumentException("仅「本地」数据来源的酒店可手工维护房型/产品/日历");
|
||||
}
|
||||
@@ -1913,6 +2302,25 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
|
||||
return requireHotel(hotelId, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理端详情/日历/本地维护:必须在当前用户部门数据权限内。
|
||||
* TOC 订房、上游定时任务仍走 {@link #requireHotel}(不套 DataScope)。
|
||||
*/
|
||||
private SmHotelEntity requireHotelInScope(Long hotelId) {
|
||||
if (hotelId == null) {
|
||||
throw new IllegalArgumentException("酒店ID不能为空");
|
||||
}
|
||||
List<SmHotelEntity> list = hotelMapper.selectListByScope(
|
||||
Wrappers.<SmHotelEntity>lambdaQuery().eq(SmHotelEntity::getId, hotelId), DataScope.of());
|
||||
if (CollUtil.isEmpty(list)) {
|
||||
if (hotelMapper.selectById(hotelId) != null) {
|
||||
throw new IllegalArgumentException("无权访问该酒店(部门数据隔离)");
|
||||
}
|
||||
throw new IllegalArgumentException("酒店不存在");
|
||||
}
|
||||
return list.get(0);
|
||||
}
|
||||
|
||||
private SmHotelEntity requireHotel(Long hotelId, boolean requireListed) {
|
||||
if (hotelId == null) {
|
||||
throw new IllegalArgumentException("酒店ID不能为空");
|
||||
|
||||
+135
-23
@@ -11,6 +11,7 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.bsd.pjcloud.supermarket.constant.SmHotelDataSource;
|
||||
import com.bsd.pjcloud.supermarket.constant.SmHotelDownstreamProtocol;
|
||||
import com.bsd.pjcloud.supermarket.constant.SmHotelInventoryStatus;
|
||||
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;
|
||||
@@ -22,6 +23,7 @@ import com.bsd.pjcloud.supermarket.mapper.*;
|
||||
import com.bsd.pjcloud.supermarket.service.SmHotelAvailabilityNotifyService;
|
||||
import com.bsd.pjcloud.supermarket.service.SmHotelChannelConfigService;
|
||||
import com.bsd.pjcloud.supermarket.service.SmHotelChannelDeptService;
|
||||
import com.bsd.pjcloud.supermarket.service.SmHotelDownstreamAutoPushService;
|
||||
import com.bsd.pjcloud.supermarket.service.SmHotelOrderService;
|
||||
import com.bsd.pjcloud.supermarket.service.SmHotelOrderUpstreamLogService;
|
||||
import com.bsd.pjcloud.supermarket.service.SmHotelPriceConfigService;
|
||||
@@ -30,6 +32,7 @@ import com.bsd.pjcloud.supermarket.vo.SmHotelOrderPayInfoVo;
|
||||
import com.bsd.pjcloud.admin.api.entity.SysDept;
|
||||
import com.bsd.pjcloud.admin.api.feign.RemoteDeptService;
|
||||
import com.bsd.pjcloud.common.core.util.R;
|
||||
import com.bsd.pjcloud.common.data.datascope.DataScope;
|
||||
import com.bsd.pjcloud.common.data.tenant.TenantBroker;
|
||||
import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
|
||||
import com.bsd.pjcloud.common.security.service.PjcloudUser;
|
||||
@@ -39,6 +42,7 @@ import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdRemoterRefundDto;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -47,6 +51,7 @@ import java.math.RoundingMode;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
@@ -115,6 +120,9 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
|
||||
|
||||
private final SmHotelAvailabilityNotifyService availabilityNotifyService;
|
||||
|
||||
/** 本地占/放库存后防抖推下游(环启等),推剩余可售而非裸配额 */
|
||||
private final @Lazy SmHotelDownstreamAutoPushService downstreamAutoPushService;
|
||||
|
||||
@Override
|
||||
public Page<SmHotelOrderEntity> pageOrders(Page<SmHotelOrderEntity> page, SmHotelOrderEntity query) {
|
||||
Page<SmHotelOrderEntity> result = baseMapper.selectPage(page, Wrappers.<SmHotelOrderEntity>lambdaQuery()
|
||||
@@ -938,14 +946,16 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
|
||||
if (StrUtil.isBlank(extProductId)) {
|
||||
reasons.add("extProductId 不能为空");
|
||||
}
|
||||
if (checkIn == null || checkOut == null || !checkIn.isBefore(checkOut)) {
|
||||
reasons.add("checkIn/checkOut 无效");
|
||||
if (!SmHotelStayDates.isValid(checkIn, checkOut)) {
|
||||
reasons.add("checkIn/checkOut 无效(离店不能早于入住;相等表示钟点房占入住当天)");
|
||||
}
|
||||
if (!reasons.isEmpty()) {
|
||||
data.put("bookable", false);
|
||||
data.put("reasons", reasons);
|
||||
return R.ok(data);
|
||||
}
|
||||
data.put("dayUse", SmHotelStayDates.isDayUse(checkIn, checkOut));
|
||||
data.put("occupyUnits", SmHotelStayDates.occupyUnits(checkIn, checkOut));
|
||||
|
||||
SmHotelEntity hotel = hotelMapper.selectOne(Wrappers.<SmHotelEntity>lambdaQuery()
|
||||
.eq(SmHotelEntity::getHotelCode, extHotelId)
|
||||
@@ -982,7 +992,7 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
|
||||
reasons.add("房型不匹配:报文=" + extApartmentId + ",产品所属=" + product.getRoomTypeCode());
|
||||
}
|
||||
|
||||
// 日历库存 + 本地卖价合计
|
||||
// 日历库存 + 本地卖价合计(过夜 [in,out);钟点房占入住当天)
|
||||
BigDecimal localSell = BigDecimal.ZERO;
|
||||
List<Map<String, Object>> days = new ArrayList<>();
|
||||
boolean localDs = SmHotelDataSource.isLocal(hotel.getSheetName());
|
||||
@@ -990,7 +1000,7 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
|
||||
if (invMsg != null) {
|
||||
reasons.add(invMsg);
|
||||
}
|
||||
for (LocalDate d = checkIn; d.isBefore(checkOut); d = d.plusDays(1)) {
|
||||
for (LocalDate d : SmHotelStayDates.occupyDates(checkIn, checkOut)) {
|
||||
SmHotelDailyEntity row = dailyMapper.selectOne(Wrappers.<SmHotelDailyEntity>lambdaQuery()
|
||||
.eq(SmHotelDailyEntity::getProductId, product.getId())
|
||||
.eq(SmHotelDailyEntity::getStayDate, d)
|
||||
@@ -1085,10 +1095,10 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
|
||||
}
|
||||
LocalDate checkIn = parseDate(body.get("checkInDate"));
|
||||
LocalDate checkOut = parseDate(body.get("checkOutDate"));
|
||||
if (checkIn == null || checkOut == null || !checkIn.isBefore(checkOut)) {
|
||||
return R.failed("入住/离店日期无效");
|
||||
if (!SmHotelStayDates.isValid(checkIn, checkOut)) {
|
||||
return R.failed("入住/离店日期无效(离店不能早于入住;相等表示钟点房)");
|
||||
}
|
||||
if (java.time.temporal.ChronoUnit.DAYS.between(checkIn, checkOut) > 30) {
|
||||
if (SmHotelStayDates.occupyUnits(checkIn, checkOut) > 30) {
|
||||
return R.failed("连住最多30晚,请缩短离店日期");
|
||||
}
|
||||
String contactName = str(body.get("contactName"));
|
||||
@@ -1256,14 +1266,14 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
|
||||
}
|
||||
LocalDate checkIn = parseDate(body.get("checkInDate"));
|
||||
LocalDate checkOut = parseDate(body.get("checkOutDate"));
|
||||
if (checkIn == null || checkOut == null || !checkIn.isBefore(checkOut)) {
|
||||
return R.failed("入住/离店日期无效");
|
||||
if (!SmHotelStayDates.isValid(checkIn, checkOut)) {
|
||||
return R.failed("入住/离店日期无效(离店不能早于入住;相等表示钟点房)");
|
||||
}
|
||||
if (checkIn.isBefore(LocalDate.now())) {
|
||||
return R.failed("入住日期不能早于今天");
|
||||
}
|
||||
// App 连住上限 30 晚,避免超长区间拖垮日历校验与前端展示
|
||||
long stayNights = java.time.temporal.ChronoUnit.DAYS.between(checkIn, checkOut);
|
||||
// App 连住上限 30 晚(钟点房算 1),避免超长区间拖垮日历校验与前端展示
|
||||
int stayNights = SmHotelStayDates.occupyUnits(checkIn, checkOut);
|
||||
if (stayNights > 30) {
|
||||
return R.failed("连住最多30晚,请缩短离店日期");
|
||||
}
|
||||
@@ -1387,10 +1397,7 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
|
||||
order.setSellAmount(toDecimal(body.get("totalAmount")));
|
||||
}
|
||||
else if (product.getSellPrice() != null) {
|
||||
long nights = java.time.temporal.ChronoUnit.DAYS.between(checkIn, checkOut);
|
||||
if (nights < 1) {
|
||||
nights = 1;
|
||||
}
|
||||
int nights = Math.max(1, SmHotelStayDates.occupyUnits(checkIn, checkOut));
|
||||
order.setSellAmount(product.getSellPrice()
|
||||
.multiply(BigDecimal.valueOf(count))
|
||||
.multiply(BigDecimal.valueOf(nights)));
|
||||
@@ -1702,18 +1709,19 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
|
||||
|
||||
/**
|
||||
* 本地下单前只读校验日历库存(不占用 sold)。
|
||||
* 过夜占 [checkIn, checkOut);钟点房 checkIn==checkOut 占入住当天。
|
||||
*
|
||||
* @return null=可下;非空=失败原因
|
||||
*/
|
||||
private String precheckLocalInventory(Long productId, LocalDate checkIn, LocalDate checkOut, int rooms) {
|
||||
if (productId == null || checkIn == null || checkOut == null || !checkIn.isBefore(checkOut)) {
|
||||
if (productId == null || !SmHotelStayDates.isValid(checkIn, checkOut)) {
|
||||
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)) {
|
||||
for (LocalDate d : SmHotelStayDates.occupyDates(checkIn, checkOut)) {
|
||||
SmHotelDailyEntity row = dailyMapper.selectOne(Wrappers.<SmHotelDailyEntity>lambdaQuery()
|
||||
.eq(SmHotelDailyEntity::getProductId, productId)
|
||||
.eq(SmHotelDailyEntity::getStayDate, d)
|
||||
@@ -1880,7 +1888,7 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
|
||||
}
|
||||
|
||||
/**
|
||||
* 对 [checkIn, checkOut) 每一晚 CAS 增加 sold。
|
||||
* 对占用日历日 CAS 增加 sold(过夜 [checkIn, checkOut);钟点房占入住当天)。
|
||||
*
|
||||
* @return null=成功;非空=失败原因(调用方应回滚事务)
|
||||
*/
|
||||
@@ -1890,7 +1898,7 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
|
||||
if (pre != null) {
|
||||
return pre;
|
||||
}
|
||||
for (LocalDate d = checkIn; d.isBefore(checkOut); d = d.plusDays(1)) {
|
||||
for (LocalDate d : SmHotelStayDates.occupyDates(checkIn, checkOut)) {
|
||||
int updated = dailyMapper.increaseSoldIfEnough(productId, d, rooms);
|
||||
if (updated == 0) {
|
||||
SmHotelDailyEntity row = dailyMapper.selectOne(Wrappers.<SmHotelDailyEntity>lambdaQuery()
|
||||
@@ -1904,6 +1912,12 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
|
||||
+ (StrUtil.isNotBlank(hint) ? "。" + hint : "");
|
||||
}
|
||||
}
|
||||
// 占房成功:下游(环启)需同步剩余可售,避免仍按原配额售卖
|
||||
SmHotelProductEntity product = productMapper.selectById(productId);
|
||||
if (product != null && product.getHotelId() != null) {
|
||||
availabilityNotifyService.scheduleCheckAfterHotelChanged(product.getHotelId());
|
||||
downstreamAutoPushService.scheduleAfterHotelChanged(product.getHotelId());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1924,21 +1938,22 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
|
||||
throw new IllegalStateException("本地库存回补失败:产品不存在 " + order.getProductCode());
|
||||
}
|
||||
int rooms = order.getCount() == null || order.getCount() < 1 ? 1 : order.getCount();
|
||||
for (LocalDate d = order.getCheckInDate(); d.isBefore(order.getCheckOutDate()); d = d.plusDays(1)) {
|
||||
for (LocalDate d : SmHotelStayDates.occupyDates(order.getCheckInDate(), order.getCheckOutDate())) {
|
||||
int updated = dailyMapper.decreaseSoldIfEnough(product.getId(), d, rooms);
|
||||
if (updated == 0) {
|
||||
throw new IllegalStateException("本地库存回补失败:日期 " + d + " sold 不足");
|
||||
}
|
||||
}
|
||||
order.setInventoryStatus(SmHotelInventoryStatus.ROLLED_BACK);
|
||||
// 回补后可能重新可订,触发有房通知扫描
|
||||
// 回补后可能重新可订:有房通知 + 下游同步剩余
|
||||
availabilityNotifyService.scheduleCheckAfterHotelChanged(order.getHotelId());
|
||||
downstreamAutoPushService.scheduleAfterHotelChanged(order.getHotelId());
|
||||
}
|
||||
|
||||
private BigDecimal sumLocalSellAmount(Long productId, LocalDate checkIn, LocalDate checkOut, int rooms) {
|
||||
BigDecimal sum = BigDecimal.ZERO;
|
||||
boolean any = false;
|
||||
for (LocalDate d = checkIn; d.isBefore(checkOut); d = d.plusDays(1)) {
|
||||
for (LocalDate d : SmHotelStayDates.occupyDates(checkIn, checkOut)) {
|
||||
SmHotelDailyEntity row = dailyMapper.selectOne(Wrappers.<SmHotelDailyEntity>lambdaQuery()
|
||||
.eq(SmHotelDailyEntity::getProductId, productId)
|
||||
.eq(SmHotelDailyEntity::getStayDate, d)
|
||||
@@ -2727,7 +2742,7 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
|
||||
: BigDecimal.ZERO;
|
||||
List<com.alibaba.fastjson.JSONObject> dailyPriceList = new ArrayList<>();
|
||||
if (order.getCheckInDate() != null && order.getCheckOutDate() != null) {
|
||||
for (LocalDate d = order.getCheckInDate(); d.isBefore(order.getCheckOutDate()); d = d.plusDays(1)) {
|
||||
for (LocalDate d : SmHotelStayDates.occupyDates(order.getCheckInDate(), order.getCheckOutDate())) {
|
||||
com.alibaba.fastjson.JSONObject row = new com.alibaba.fastjson.JSONObject();
|
||||
row.put("bizDate", d.toString());
|
||||
row.put("afterTaxPrice", byDate.getOrDefault(d, fallback));
|
||||
@@ -2880,4 +2895,101 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
|
||||
public List<SmHotelOrderUpstreamLogEntity> listUpstreamLogs(Long orderId) {
|
||||
return upstreamLogService.listByOrderId(orderId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> managerDashboardOverview() {
|
||||
// 仅统计当前用户 DataScope 内酒店,避免跨部门泄露
|
||||
List<SmHotelEntity> hotels = hotelMapper.selectListByScope(
|
||||
Wrappers.<SmHotelEntity>lambdaQuery().select(SmHotelEntity::getId), DataScope.of());
|
||||
List<Long> hotelIds = hotels == null ? List.of()
|
||||
: hotels.stream().map(SmHotelEntity::getId).filter(Objects::nonNull).distinct().toList();
|
||||
|
||||
LocalDate today = LocalDate.now();
|
||||
LocalDateTime todayStart = today.atStartOfDay();
|
||||
LocalDateTime tomorrowStart = today.plusDays(1).atStartOfDay();
|
||||
LocalDateTime yesterdayStart = today.minusDays(1).atStartOfDay();
|
||||
|
||||
Map<String, Object> todayStat = emptyDashDay();
|
||||
Map<String, Object> yesterdayStat = emptyDashDay();
|
||||
if (CollUtil.isNotEmpty(hotelIds)) {
|
||||
todayStat = aggregateDashDay(hotelIds, todayStart, tomorrowStart);
|
||||
yesterdayStat = aggregateDashDay(hotelIds, yesterdayStart, todayStart);
|
||||
}
|
||||
|
||||
Map<String, Object> out = new LinkedHashMap<>(12);
|
||||
out.put("bizDate", today.toString());
|
||||
out.put("hotelCount", hotelIds.size());
|
||||
out.put("todayOrderCount", todayStat.get("orderCount"));
|
||||
out.put("todayRoomNights", todayStat.get("roomNights"));
|
||||
out.put("todaySellAmount", todayStat.get("sellAmount"));
|
||||
out.put("yesterdayOrderCount", yesterdayStat.get("orderCount"));
|
||||
out.put("yesterdayRoomNights", yesterdayStat.get("roomNights"));
|
||||
out.put("yesterdaySellAmount", yesterdayStat.get("sellAmount"));
|
||||
out.put("orderCountChangePct", calcChangePct(todayStat.get("orderCount"), yesterdayStat.get("orderCount")));
|
||||
out.put("roomNightsChangePct", calcChangePct(todayStat.get("roomNights"), yesterdayStat.get("roomNights")));
|
||||
out.put("sellAmountChangePct", calcChangePct(todayStat.get("sellAmount"), yesterdayStat.get("sellAmount")));
|
||||
return out;
|
||||
}
|
||||
|
||||
private Map<String, Object> emptyDashDay() {
|
||||
Map<String, Object> m = new LinkedHashMap<>(4);
|
||||
m.put("orderCount", 0L);
|
||||
m.put("roomNights", 0L);
|
||||
m.put("sellAmount", BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP));
|
||||
return m;
|
||||
}
|
||||
|
||||
/** 按创建时间汇总:有效订单数、间夜数(间数×入住晚数)、销售额 */
|
||||
private Map<String, Object> aggregateDashDay(List<Long> hotelIds, LocalDateTime from, LocalDateTime toExclusive) {
|
||||
List<SmHotelOrderEntity> rows = baseMapper.selectList(Wrappers.<SmHotelOrderEntity>lambdaQuery()
|
||||
.in(SmHotelOrderEntity::getHotelId, hotelIds)
|
||||
.ge(SmHotelOrderEntity::getCreateTime, from)
|
||||
.lt(SmHotelOrderEntity::getCreateTime, toExclusive)
|
||||
.ne(SmHotelOrderEntity::getOrderStatus, "CANCELLED")
|
||||
.select(SmHotelOrderEntity::getId, SmHotelOrderEntity::getCount, SmHotelOrderEntity::getSellAmount,
|
||||
SmHotelOrderEntity::getCheckInDate, SmHotelOrderEntity::getCheckOutDate));
|
||||
long orderCount = 0L;
|
||||
long roomNights = 0L;
|
||||
BigDecimal sell = BigDecimal.ZERO;
|
||||
if (CollUtil.isNotEmpty(rows)) {
|
||||
for (SmHotelOrderEntity o : rows) {
|
||||
orderCount++;
|
||||
int rooms = o.getCount() == null || o.getCount() < 1 ? 1 : o.getCount();
|
||||
long nights = 1L;
|
||||
if (o.getCheckInDate() != null && o.getCheckOutDate() != null
|
||||
&& o.getCheckOutDate().isAfter(o.getCheckInDate())) {
|
||||
nights = ChronoUnit.DAYS.between(o.getCheckInDate(), o.getCheckOutDate());
|
||||
if (nights < 1) {
|
||||
nights = 1L;
|
||||
}
|
||||
}
|
||||
roomNights += rooms * nights;
|
||||
if (o.getSellAmount() != null) {
|
||||
sell = sell.add(o.getSellAmount());
|
||||
}
|
||||
}
|
||||
}
|
||||
Map<String, Object> m = new LinkedHashMap<>(4);
|
||||
m.put("orderCount", orderCount);
|
||||
m.put("roomNights", roomNights);
|
||||
m.put("sellAmount", sell.setScale(2, RoundingMode.HALF_UP));
|
||||
return m;
|
||||
}
|
||||
|
||||
private static BigDecimal calcChangePct(Object todayVal, Object yesterdayVal) {
|
||||
BigDecimal today = toDecimal(todayVal);
|
||||
BigDecimal yesterday = toDecimal(yesterdayVal);
|
||||
if (yesterday == null || yesterday.compareTo(BigDecimal.ZERO) == 0) {
|
||||
if (today == null || today.compareTo(BigDecimal.ZERO) == 0) {
|
||||
return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
|
||||
}
|
||||
return BigDecimal.valueOf(100).setScale(2, RoundingMode.HALF_UP);
|
||||
}
|
||||
if (today == null) {
|
||||
today = BigDecimal.ZERO;
|
||||
}
|
||||
return today.subtract(yesterday)
|
||||
.multiply(BigDecimal.valueOf(100))
|
||||
.divide(yesterday, 2, RoundingMode.HALF_UP);
|
||||
}
|
||||
}
|
||||
+69
-2
@@ -38,7 +38,9 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.file.Files;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
@@ -86,7 +88,31 @@ public class SmHotelServiceImpl extends ServiceImpl<SmHotelMapper, SmHotelEntity
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 为本页酒店批量补齐房型/产品/日历数量,避免 N+1 */
|
||||
@Override
|
||||
public SmHotelEntity getByIdInScope(Long id) {
|
||||
if (id == null) {
|
||||
throw new IllegalArgumentException("酒店ID不能为空");
|
||||
}
|
||||
List<SmHotelEntity> list = baseMapper.selectListByScope(
|
||||
Wrappers.<SmHotelEntity>lambdaQuery().eq(SmHotelEntity::getId, id), DataScope.of());
|
||||
if (CollUtil.isEmpty(list)) {
|
||||
// 区分不存在与无权限:存在则明确拒绝跨部门访问
|
||||
if (baseMapper.selectById(id) != null) {
|
||||
throw new IllegalArgumentException("无权访问该酒店(部门数据隔离)");
|
||||
}
|
||||
throw new IllegalArgumentException("酒店不存在");
|
||||
}
|
||||
SmHotelEntity hotel = list.get(0);
|
||||
fillDetailCounts(List.of(hotel));
|
||||
return hotel;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void assertHotelInScope(Long id) {
|
||||
getByIdInScope(id);
|
||||
}
|
||||
|
||||
/** 为本页酒店批量补齐房型/产品/日历数量与起价,避免 N+1 */
|
||||
private void fillDetailCounts(List<SmHotelEntity> records) {
|
||||
if (CollUtil.isEmpty(records)) {
|
||||
return;
|
||||
@@ -98,11 +124,21 @@ public class SmHotelServiceImpl extends ServiceImpl<SmHotelMapper, SmHotelEntity
|
||||
Map<Long, Integer> roomMap = toCountMap(roomMapper.countGroupByHotelIds(ids));
|
||||
Map<Long, Integer> productMap = toCountMap(productMapper.countGroupByHotelIds(ids));
|
||||
Map<Long, Integer> dailyMap = toCountMap(dailyMapper.countGroupByHotelIds(ids));
|
||||
// 管理端列表起价:优先今日开房日历挂牌最低价;没有则回退产品最低卖价
|
||||
LocalDate today = LocalDate.now();
|
||||
Map<Long, BigDecimal> dailyMinMap = toMinSellMap(
|
||||
dailyMapper.selectAdminListMinSellPriceByStayRange(today, today.plusDays(1), ids));
|
||||
Map<Long, BigDecimal> productMinMap = toMinSellMap(productMapper.selectMinSellPriceByHotelIds(ids));
|
||||
for (SmHotelEntity h : records) {
|
||||
Long id = h.getId();
|
||||
h.setRoomCount(roomMap.getOrDefault(id, 0));
|
||||
h.setProductCount(productMap.getOrDefault(id, 0));
|
||||
h.setDailyCount(dailyMap.getOrDefault(id, 0));
|
||||
BigDecimal minSell = dailyMinMap.get(id);
|
||||
if (minSell == null) {
|
||||
minSell = productMinMap.get(id);
|
||||
}
|
||||
h.setMinSellPrice(minSell);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,6 +161,31 @@ public class SmHotelServiceImpl extends ServiceImpl<SmHotelMapper, SmHotelEntity
|
||||
return map;
|
||||
}
|
||||
|
||||
private static Map<Long, BigDecimal> toMinSellMap(List<Map<String, Object>> rows) {
|
||||
Map<Long, BigDecimal> map = new HashMap<>();
|
||||
if (CollUtil.isEmpty(rows)) {
|
||||
return map;
|
||||
}
|
||||
for (Map<String, Object> row : rows) {
|
||||
if (row == null) {
|
||||
continue;
|
||||
}
|
||||
Object hid = row.get("hotelId");
|
||||
if (hid == null) {
|
||||
hid = row.get("hotel_id");
|
||||
}
|
||||
Object minSell = row.get("minSell");
|
||||
if (minSell == null) {
|
||||
minSell = row.get("min_sell");
|
||||
}
|
||||
if (hid == null || minSell == null) {
|
||||
continue;
|
||||
}
|
||||
map.put(Long.valueOf(String.valueOf(hid)), new BigDecimal(String.valueOf(minSell)));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public SmHotelEntity saveHotel(SmHotelEntity entity) {
|
||||
@@ -162,7 +223,8 @@ public class SmHotelServiceImpl extends ServiceImpl<SmHotelMapper, SmHotelEntity
|
||||
if (entity.getId() == null) {
|
||||
throw new IllegalArgumentException("酒店ID不能为空");
|
||||
}
|
||||
SmHotelEntity old = baseMapper.selectById(entity.getId());
|
||||
// 先做部门范围校验,再读旧值,防止改他人部门酒店
|
||||
SmHotelEntity old = getByIdInScope(entity.getId());
|
||||
if (old == null) {
|
||||
throw new IllegalArgumentException("酒店不存在");
|
||||
}
|
||||
@@ -202,6 +264,8 @@ public class SmHotelServiceImpl extends ServiceImpl<SmHotelMapper, SmHotelEntity
|
||||
if (entity.getLocalRollHorizonDays() == null) {
|
||||
entity.setLocalRollHorizonDays(old.getLocalRollHorizonDays());
|
||||
}
|
||||
// 不允许通过编辑改挂部门,避免绕过数据隔离
|
||||
entity.setDeptId(old.getDeptId());
|
||||
normalizeLocalRoll(entity);
|
||||
boolean ok = baseMapper.updateById(entity) > 0;
|
||||
if (ok) {
|
||||
@@ -223,6 +287,9 @@ public class SmHotelServiceImpl extends ServiceImpl<SmHotelMapper, SmHotelEntity
|
||||
return Boolean.TRUE;
|
||||
}
|
||||
List<Long> idList = CollUtil.toList(ids);
|
||||
for (Long id : idList) {
|
||||
assertHotelInScope(id);
|
||||
}
|
||||
List<SmHotelEntity> before = baseMapper.selectBatchIds(idList);
|
||||
boolean ok = removeByIds(idList);
|
||||
if (ok && CollUtil.isNotEmpty(before)) {
|
||||
|
||||
+385
@@ -0,0 +1,385 @@
|
||||
package com.bsd.pjcloud.supermarket.service.impl;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.json.JSONObject;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.bsd.pjcloud.common.core.util.R;
|
||||
import com.bsd.pjcloud.supermarket.config.HotelDownstreamProperties;
|
||||
import com.bsd.pjcloud.supermarket.constant.SmHotelDownstreamProtocol;
|
||||
import com.bsd.pjcloud.supermarket.constant.SmHotelStayDates;
|
||||
import com.bsd.pjcloud.supermarket.entity.SmHotelEntity;
|
||||
import com.bsd.pjcloud.supermarket.entity.SmHotelOrderEntity;
|
||||
import com.bsd.pjcloud.supermarket.entity.SmHotelProductEntity;
|
||||
import com.bsd.pjcloud.supermarket.integration.downstream.DownstreamHotelClient;
|
||||
import com.bsd.pjcloud.supermarket.integration.downstream.shengnuo.ShengnuoDownstreamHotelAdapter;
|
||||
import com.bsd.pjcloud.supermarket.integration.downstream.shengnuo.ShengnuoIdMappingCache;
|
||||
import com.bsd.pjcloud.supermarket.mapper.SmHotelMapper;
|
||||
import com.bsd.pjcloud.supermarket.mapper.SmHotelOrderMapper;
|
||||
import com.bsd.pjcloud.supermarket.mapper.SmHotelProductMapper;
|
||||
import com.bsd.pjcloud.supermarket.service.SmHotelChannelConfigService;
|
||||
import com.bsd.pjcloud.supermarket.service.SmHotelOrderService;
|
||||
import com.bsd.pjcloud.supermarket.service.SmHotelShengnuoOrderService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 圣诺订单:入站落库并订上游;成功后 orderChange=已接单(2);取消成功后 orderChange=已取消(5)。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class SmHotelShengnuoOrderServiceImpl implements SmHotelShengnuoOrderService {
|
||||
|
||||
private static final String TAG = "圣诺订单";
|
||||
|
||||
/** 1 待确认 2 已接单 3 已入住 4 取消中 5 已取消 6 已离店 */
|
||||
private static final int ST_PENDING = 1;
|
||||
|
||||
private static final int ST_ACCEPTED = 2;
|
||||
|
||||
private static final int ST_CANCELLED = 5;
|
||||
|
||||
private static final DateTimeFormatter DAY = DateTimeFormatter.ISO_LOCAL_DATE;
|
||||
|
||||
private final SmHotelChannelConfigService channelConfigService;
|
||||
|
||||
private final SmHotelOrderService hotelOrderService;
|
||||
|
||||
private final SmHotelOrderMapper orderMapper;
|
||||
|
||||
private final SmHotelMapper hotelMapper;
|
||||
|
||||
private final SmHotelProductMapper productMapper;
|
||||
|
||||
private final DownstreamHotelClient downstreamClient;
|
||||
|
||||
private final ShengnuoIdMappingCache idMappingCache;
|
||||
|
||||
private final ShengnuoDownstreamHotelAdapter shengnuoAdapter;
|
||||
|
||||
public SmHotelShengnuoOrderServiceImpl(SmHotelChannelConfigService channelConfigService,
|
||||
SmHotelOrderService hotelOrderService, SmHotelOrderMapper orderMapper, SmHotelMapper hotelMapper,
|
||||
SmHotelProductMapper productMapper, DownstreamHotelClient downstreamClient,
|
||||
ShengnuoIdMappingCache idMappingCache, ShengnuoDownstreamHotelAdapter shengnuoAdapter) {
|
||||
this.channelConfigService = channelConfigService;
|
||||
this.hotelOrderService = hotelOrderService;
|
||||
this.orderMapper = orderMapper;
|
||||
this.hotelMapper = hotelMapper;
|
||||
this.productMapper = productMapper;
|
||||
this.downstreamClient = downstreamClient;
|
||||
this.idMappingCache = idMappingCache;
|
||||
this.shengnuoAdapter = shengnuoAdapter;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> createOrder(String partnerCode, JSONObject body) {
|
||||
String orderId = StrUtil.trim(str(body.get("orderId")));
|
||||
if (StrUtil.isBlank(orderId)) {
|
||||
return fail("orderId 不能为空");
|
||||
}
|
||||
String payStatus = StrUtil.trim(str(body.get("status")));
|
||||
// 文档:未支付可能推单;2026-09-17 后对方原则上只推已支付;仍兼容 status=1
|
||||
if ("1".equals(payStatus)) {
|
||||
log.info("|{}|未支付订单暂存|orderId={}|", TAG, orderId);
|
||||
}
|
||||
SmHotelOrderEntity exist = orderMapper.selectOne(Wrappers.<SmHotelOrderEntity>lambdaQuery()
|
||||
.eq(SmHotelOrderEntity::getChannelNo, orderId)
|
||||
.last("limit 1"));
|
||||
if (exist != null) {
|
||||
if (isBookConfirmed(exist)) {
|
||||
pushOrderChange(partnerCode, orderId, ST_ACCEPTED);
|
||||
}
|
||||
return ok("订单已存在(幂等)", exist.getId());
|
||||
}
|
||||
Mapped m = mapCreate(partnerCode, body);
|
||||
if (m.error != null) {
|
||||
return fail(m.error);
|
||||
}
|
||||
Map<String, Object> checkBody = new LinkedHashMap<>();
|
||||
checkBody.put("extHotelId", m.hotelCode);
|
||||
checkBody.put("extProductId", m.productCode);
|
||||
checkBody.put("extApartmentId", m.roomTypeCode);
|
||||
checkBody.put("checkIn", m.checkIn.toString());
|
||||
checkBody.put("checkOut", m.checkOut.toString());
|
||||
checkBody.put("roomCnt", m.quantity);
|
||||
checkBody.put("price", m.orderAmount);
|
||||
R<Map<String, Object>> check = hotelOrderService.checkBookableForDownstream(checkBody);
|
||||
boolean bookable = check != null && check.getCode() == 0 && check.getData() != null
|
||||
&& Boolean.TRUE.equals(check.getData().get("bookable"));
|
||||
if (!bookable) {
|
||||
Object reasons = check == null || check.getData() == null ? "预检失败" : check.getData().get("reasons");
|
||||
return fail("预检不可订:" + reasons);
|
||||
}
|
||||
|
||||
SmHotelOrderEntity order = new SmHotelOrderEntity();
|
||||
order.setPartnerCode(partnerCode);
|
||||
order.setChannelNo(orderId);
|
||||
order.setHotelId(m.hotel.getId());
|
||||
order.setHotelCode(m.hotelCode);
|
||||
order.setGroupCode(m.hotel.getGroupCode());
|
||||
order.setRoomTypeCode(m.roomTypeCode);
|
||||
order.setProductCode(m.productCode);
|
||||
order.setPriceCode(m.product == null ? null : m.product.getPriceCode());
|
||||
order.setCheckInDate(m.checkIn);
|
||||
order.setCheckOutDate(m.checkOut);
|
||||
order.setCount(m.quantity);
|
||||
order.setContactName(m.contactName);
|
||||
order.setContactPhone(m.contactPhone);
|
||||
order.setGuestJson(m.guestJson);
|
||||
order.setSellAmount(m.orderAmount);
|
||||
order.setMarkupPercent(BigDecimal.ZERO);
|
||||
order.setOrderStatus("PENDING");
|
||||
order.setCompensateStatus("NONE");
|
||||
order.setInventoryStatus("NONE");
|
||||
order.setCompensateRound(0);
|
||||
order.setReqJson(body.toString());
|
||||
order.setHuanqiPayload(body.toString());
|
||||
orderMapper.insert(order);
|
||||
|
||||
R<Boolean> book = hotelOrderService.bookUpstreamForExisting(order.getId());
|
||||
SmHotelOrderEntity latest = orderMapper.selectById(order.getId());
|
||||
if (latest == null) {
|
||||
return fail("下单后订单丢失");
|
||||
}
|
||||
if (book == null || book.getCode() != 0 || !isBookConfirmed(latest)) {
|
||||
String failMsg = latest.getFailMsg();
|
||||
if (StrUtil.isBlank(failMsg)) {
|
||||
failMsg = book == null ? "上游下单失败" : StrUtil.blankToDefault(book.getMsg(), "上游下单失败");
|
||||
}
|
||||
if (!"UPSTREAM_FAIL".equals(latest.getOrderStatus()) && !"NEED_MANUAL".equals(latest.getOrderStatus())
|
||||
&& !"CANCELLED".equals(latest.getOrderStatus())) {
|
||||
latest.setOrderStatus("UPSTREAM_FAIL");
|
||||
latest.setFailMsg(StrUtil.maxLength(failMsg, 500));
|
||||
orderMapper.updateById(latest);
|
||||
}
|
||||
// 无法接单:不主动回写拒绝态(文档无「新订拒绝」码);保持待确认由对方超时处理
|
||||
pushOrderChange(partnerCode, orderId, ST_PENDING);
|
||||
return fail(failMsg);
|
||||
}
|
||||
pushOrderChange(partnerCode, orderId, ST_ACCEPTED);
|
||||
return ok("下单成功", latest.getId());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> orderCancel(String partnerCode, JSONObject body) {
|
||||
String orderId = StrUtil.trim(str(body.get("orderId")));
|
||||
if (StrUtil.isBlank(orderId)) {
|
||||
return fail("orderId 不能为空");
|
||||
}
|
||||
SmHotelOrderEntity order = orderMapper.selectOne(Wrappers.<SmHotelOrderEntity>lambdaQuery()
|
||||
.eq(SmHotelOrderEntity::getChannelNo, orderId)
|
||||
.last("limit 1"));
|
||||
if (order == null) {
|
||||
return fail("订单不存在");
|
||||
}
|
||||
if (StrUtil.isNotBlank(partnerCode) && StrUtil.isNotBlank(order.getPartnerCode())
|
||||
&& !partnerCode.equals(order.getPartnerCode())) {
|
||||
return fail("订单不属于当前伙伴");
|
||||
}
|
||||
R<Boolean> r = hotelOrderService.cancelByAdmin(order.getId(), "圣诺取消");
|
||||
if (r == null || r.getCode() != 0) {
|
||||
// 拒绝取消:回写已接单(2)
|
||||
pushOrderChange(partnerCode, orderId, ST_ACCEPTED);
|
||||
return fail(r == null ? "取消失败" : r.getMsg());
|
||||
}
|
||||
pushOrderChange(partnerCode, orderId, ST_CANCELLED);
|
||||
return ok("取消成功", order.getId());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> orderConfirmTimeOut(String partnerCode, JSONObject body) {
|
||||
String orderId = StrUtil.trim(str(body.get("orderId")));
|
||||
log.warn("|{}|确认超时提醒|partner={}|orderId={}|remain={}|desc={}|", TAG, partnerCode, orderId,
|
||||
body.get("remain"), body.get("desc"));
|
||||
return ok("已收到超时提醒", null);
|
||||
}
|
||||
|
||||
private Mapped mapCreate(String partnerCode, JSONObject body) {
|
||||
Mapped m = new Mapped();
|
||||
long nsHotelId = toLong(body.get("hotel_id"));
|
||||
long nsRoomId = toLong(body.get("room_type_id"));
|
||||
long nsPlanId = toLong(body.get("price_plan_id"));
|
||||
Long localHotelId = idMappingCache.localHotelIdByNs(partnerCode, nsHotelId);
|
||||
SmHotelEntity hotel = null;
|
||||
if (localHotelId != null) {
|
||||
hotel = hotelMapper.selectById(localHotelId);
|
||||
}
|
||||
cn.hutool.json.JSONObject map = localHotelId == null ? null
|
||||
: idMappingCache.getByLocal(partnerCode, localHotelId);
|
||||
if (hotel == null) {
|
||||
m.error = "未找到本地酒店(请先推送资源建立映射)nsHotelId=" + nsHotelId;
|
||||
return m;
|
||||
}
|
||||
String roomType = idMappingCache.roomTypeByNs(map, nsRoomId);
|
||||
String productCode = idMappingCache.productByNsPlan(map, nsPlanId);
|
||||
if (StrUtil.isBlank(productCode) && StrUtil.isNotBlank(roomType)) {
|
||||
SmHotelProductEntity one = productMapper.selectOne(Wrappers.<SmHotelProductEntity>lambdaQuery()
|
||||
.eq(SmHotelProductEntity::getHotelId, hotel.getId())
|
||||
.eq(SmHotelProductEntity::getRoomTypeCode, roomType)
|
||||
.last("limit 1"));
|
||||
if (one != null) {
|
||||
productCode = one.getProductCode();
|
||||
}
|
||||
}
|
||||
if (StrUtil.isBlank(productCode)) {
|
||||
m.error = "未匹配本地产品 planId=" + nsPlanId + " roomId=" + nsRoomId;
|
||||
return m;
|
||||
}
|
||||
SmHotelProductEntity product = productMapper.selectOne(Wrappers.<SmHotelProductEntity>lambdaQuery()
|
||||
.eq(SmHotelProductEntity::getHotelId, hotel.getId())
|
||||
.eq(SmHotelProductEntity::getProductCode, productCode)
|
||||
.last("limit 1"));
|
||||
if (product == null) {
|
||||
m.error = "本地产品不存在:" + productCode;
|
||||
return m;
|
||||
}
|
||||
m.hotel = hotel;
|
||||
m.hotelCode = hotel.getHotelCode();
|
||||
m.roomTypeCode = StrUtil.blankToDefault(roomType, product.getRoomTypeCode());
|
||||
m.productCode = productCode;
|
||||
m.product = product;
|
||||
m.checkIn = parseDay(str(body.get("checkIn")));
|
||||
m.checkOut = parseDay(str(body.get("checkOut")));
|
||||
if (!SmHotelStayDates.isValid(m.checkIn, m.checkOut)) {
|
||||
m.error = "入住/离店日期非法(离店不能早于入住;相等表示钟点房)";
|
||||
return m;
|
||||
}
|
||||
m.quantity = Math.max(1, (int) toLong(body.get("room_number")));
|
||||
m.contactName = StrUtil.blankToDefault(StrUtil.trim(str(body.get("check_person_name"))), "旅客");
|
||||
m.contactPhone = StrUtil.trim(str(body.get("check_person_phone")));
|
||||
String priceStr = StrUtil.blankToDefault(str(body.get("total_prices")), str(body.get("pay_total_prices")));
|
||||
try {
|
||||
m.orderAmount = new BigDecimal(StrUtil.blankToDefault(priceStr, "0"));
|
||||
}
|
||||
catch (Exception e) {
|
||||
m.orderAmount = BigDecimal.ZERO;
|
||||
}
|
||||
Map<String, Object> guest = new LinkedHashMap<>(2);
|
||||
guest.put("name", m.contactName);
|
||||
guest.put("phone", m.contactPhone);
|
||||
m.guestJson = JSONUtil.toJsonStr(java.util.List.of(guest));
|
||||
return m;
|
||||
}
|
||||
|
||||
private void pushOrderChange(String partnerCode, String orderId, int status) {
|
||||
if (StrUtil.isBlank(partnerCode) || StrUtil.isBlank(orderId)) {
|
||||
return;
|
||||
}
|
||||
Runnable task = () -> {
|
||||
try {
|
||||
HotelDownstreamProperties props = channelConfigService.resolveDownstreamRuntime(partnerCode);
|
||||
if (props == null || !SmHotelDownstreamProtocol.isShengnuo(props.getProtocol())) {
|
||||
return;
|
||||
}
|
||||
Map<String, Object> body = new LinkedHashMap<>(shengnuoAdapter.signedBase(props));
|
||||
body.put("orderId", orderId);
|
||||
body.put("status", status);
|
||||
downstreamClient.shengnuoOrderChange(partnerCode, body);
|
||||
log.info("|{}|orderChange 成功|orderId={}|status={}|", TAG, orderId, status);
|
||||
}
|
||||
catch (Exception e) {
|
||||
log.warn("|{}|orderChange 失败|orderId={}|status={}|{}|", TAG, orderId, status, e.getMessage());
|
||||
}
|
||||
};
|
||||
if (TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||
@Override
|
||||
public void afterCommit() {
|
||||
task.run();
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
task.run();
|
||||
}
|
||||
|
||||
private static boolean isBookConfirmed(SmHotelOrderEntity order) {
|
||||
if (order == null) {
|
||||
return false;
|
||||
}
|
||||
if ("UPSTREAM_OK".equals(order.getOrderStatus()) || "CHECKED_IN".equals(order.getOrderStatus())) {
|
||||
return true;
|
||||
}
|
||||
return StrUtil.isNotBlank(order.getPlatformNo())
|
||||
&& !"UPSTREAM_FAIL".equals(order.getOrderStatus())
|
||||
&& !"NEED_MANUAL".equals(order.getOrderStatus())
|
||||
&& !"CANCELLED".equals(order.getOrderStatus());
|
||||
}
|
||||
|
||||
private static Map<String, Object> ok(String msg, Long localId) {
|
||||
Map<String, Object> m = new LinkedHashMap<>(6);
|
||||
m.put("code", 0);
|
||||
m.put("success", true);
|
||||
m.put("msg", msg);
|
||||
m.put("status", "success");
|
||||
if (localId != null) {
|
||||
m.put("data", Map.of("localOrderId", localId));
|
||||
}
|
||||
else {
|
||||
m.put("data", Map.of());
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
private static Map<String, Object> fail(String msg) {
|
||||
Map<String, Object> m = new LinkedHashMap<>(6);
|
||||
m.put("code", 1);
|
||||
m.put("success", false);
|
||||
m.put("msg", msg);
|
||||
m.put("status", "error");
|
||||
m.put("data", Map.of());
|
||||
return m;
|
||||
}
|
||||
|
||||
private static LocalDate parseDay(String s) {
|
||||
if (StrUtil.isBlank(s)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return LocalDate.parse(s.trim().replace('/', '-').substring(0, Math.min(10, s.trim().length())), DAY);
|
||||
}
|
||||
catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static long toLong(Object o) {
|
||||
if (o == null) {
|
||||
return 0L;
|
||||
}
|
||||
try {
|
||||
return Long.parseLong(String.valueOf(o).trim());
|
||||
}
|
||||
catch (Exception e) {
|
||||
return 0L;
|
||||
}
|
||||
}
|
||||
|
||||
private static String str(Object o) {
|
||||
return o == null ? null : String.valueOf(o);
|
||||
}
|
||||
|
||||
private static final class Mapped {
|
||||
String error;
|
||||
SmHotelEntity hotel;
|
||||
String hotelCode;
|
||||
String roomTypeCode;
|
||||
String productCode;
|
||||
SmHotelProductEntity product;
|
||||
LocalDate checkIn;
|
||||
LocalDate checkOut;
|
||||
int quantity;
|
||||
String contactName;
|
||||
String contactPhone;
|
||||
String guestJson;
|
||||
BigDecimal orderAmount;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user