酒店,app菜单修改

This commit is contained in:
zjf committed 2026-10-27 15:19:16 +08:00
1 parent 47a1fb2a27
commit ecf2bad461
29 files changed
+2111 -57

No files matched your search

@@ -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;
}
@@ -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)));
}
}
@@ -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> {
}
@@ -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();
}
@@ -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));
}
}
@@ -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;
}
@@ -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();
}
}
@@ -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;
}
@@ -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 = "提交预订")
@@ -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("新增酒店")
@@ -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 = "从上游拉取房型产品并加价落库")
@@ -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;
}
}
@@ -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);
@@ -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);
}
@@ -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() {
}
}
@@ -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;
}
}
}
@@ -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;
}
}
@@ -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));
}
}
@@ -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 &gt;= #{fromDate}
AND stay_date &lt; #{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);
/**
@@ -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);
@@ -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();
}
@@ -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);
@@ -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);
}
@@ -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) {
@@ -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()
@@ -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不能为空");
@@ -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);
}
}
@@ -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)) {
@@ -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;
}
}