diff --git a/pjcloud-app-server/pjcloud-app-server-api/src/main/java/com/bsd/pjcloud/app/api/entity/AppWorkbenchMenuEntity.java b/pjcloud-app-server/pjcloud-app-server-api/src/main/java/com/bsd/pjcloud/app/api/entity/AppWorkbenchMenuEntity.java new file mode 100644 index 00000000..11c9548e --- /dev/null +++ b/pjcloud-app-server/pjcloud-app-server-api/src/main/java/com/bsd/pjcloud/app/api/entity/AppWorkbenchMenuEntity.java @@ -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 { + + @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; + +} diff --git a/pjcloud-app-server/pjcloud-app-server-biz/src/main/java/com/bsd/pjcloud/app/controller/AppWorkbenchMenuController.java b/pjcloud-app-server/pjcloud-app-server-biz/src/main/java/com/bsd/pjcloud/app/controller/AppWorkbenchMenuController.java new file mode 100644 index 00000000..c817a764 --- /dev/null +++ b/pjcloud-app-server/pjcloud-app-server-biz/src/main/java/com/bsd/pjcloud/app/controller/AppWorkbenchMenuController.java @@ -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.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))); + } +} diff --git a/pjcloud-app-server/pjcloud-app-server-biz/src/main/java/com/bsd/pjcloud/app/mapper/AppWorkbenchMenuMapper.java b/pjcloud-app-server/pjcloud-app-server-biz/src/main/java/com/bsd/pjcloud/app/mapper/AppWorkbenchMenuMapper.java new file mode 100644 index 00000000..0735afb5 --- /dev/null +++ b/pjcloud-app-server/pjcloud-app-server-biz/src/main/java/com/bsd/pjcloud/app/mapper/AppWorkbenchMenuMapper.java @@ -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 { +} diff --git a/pjcloud-app-server/pjcloud-app-server-biz/src/main/java/com/bsd/pjcloud/app/service/AppWorkbenchMenuService.java b/pjcloud-app-server/pjcloud-app-server-biz/src/main/java/com/bsd/pjcloud/app/service/AppWorkbenchMenuService.java new file mode 100644 index 00000000..3f2fe133 --- /dev/null +++ b/pjcloud-app-server/pjcloud-app-server-biz/src/main/java/com/bsd/pjcloud/app/service/AppWorkbenchMenuService.java @@ -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 { + + /** App「我的」工作台:已启用菜单,按排序 */ + List listEnabledForApp(); +} diff --git a/pjcloud-app-server/pjcloud-app-server-biz/src/main/java/com/bsd/pjcloud/app/service/impl/AppWorkbenchMenuServiceImpl.java b/pjcloud-app-server/pjcloud-app-server-biz/src/main/java/com/bsd/pjcloud/app/service/impl/AppWorkbenchMenuServiceImpl.java new file mode 100644 index 00000000..6cfceeab --- /dev/null +++ b/pjcloud-app-server/pjcloud-app-server-biz/src/main/java/com/bsd/pjcloud/app/service/impl/AppWorkbenchMenuServiceImpl.java @@ -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 + implements AppWorkbenchMenuService { + + @Override + public List listEnabledForApp() { + return list(Wrappers.lambdaQuery() + .eq(AppWorkbenchMenuEntity::getStatus, "1") + .orderByAsc(AppWorkbenchMenuEntity::getSortOrder) + .orderByAsc(AppWorkbenchMenuEntity::getId)); + } +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/constant/SmHotelDownstreamProtocol.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/constant/SmHotelDownstreamProtocol.java index 68382456..0e5262e6 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/constant/SmHotelDownstreamProtocol.java +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/constant/SmHotelDownstreamProtocol.java @@ -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 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; } diff --git a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/constant/SmHotelStayDates.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/constant/SmHotelStayDates.java new file mode 100644 index 00000000..2b42501d --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/constant/SmHotelStayDates.java @@ -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; + +/** + * 入住区间约定: + *
    + *
  • 过夜:半开区间 {@code [checkIn, checkOut)},离店日不含;
  • + *
  • 钟点房/日租:允许 {@code checkIn == checkOut},占入住当天 1 个日历日库存与售价。
  • + *
+ */ +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 occupyDates(LocalDate checkIn, LocalDate checkOut) { + if (!isValid(checkIn, checkOut)) { + return Collections.emptyList(); + } + if (checkIn.equals(checkOut)) { + return List.of(checkIn); + } + List 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(); + } +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelEntity.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelEntity.java index dac0ee28..058a5df8 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelEntity.java +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelEntity.java @@ -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 { @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 { @TableField(exist = false) @Schema(description = "日历数量") private Integer dailyCount; + + /** 列表展示:起售价(非表字段;优先今日可订日历,其次产品最低卖价) */ + @TableField(exist = false) + @Schema(description = "最低卖价/起价") + private BigDecimal minSellPrice; } diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelAppController.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelAppController.java index 95ac97dc..3a0115eb 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelAppController.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelAppController.java @@ -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.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 = "提交预订") diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelController.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelController.java index d4ec11d5..18352fe1 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelController.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelController.java @@ -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> 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("新增酒店") diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelMidController.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelMidController.java index 2da6808e..f624faf9 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelMidController.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelMidController.java @@ -80,8 +80,10 @@ public class SmHotelMidController { @PreAuthorize("@pms.hasPermission('supermarket_smHotel_view')") public R> dailyPage(@PathVariable Long id, @ParameterObject Page 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 = "从上游拉取房型产品并加价落库") diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelShengnuoOrderController.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelShengnuoOrderController.java new file mode 100644 index 00000000..a6c57a2f --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelShengnuoOrderController.java @@ -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; + +/** + * 圣诺/奈斯订单入站(供应商回调地址,给对方配置): + *
+ * POST /third/party/shengnuo/order/createOrder
+ * POST /third/party/shengnuo/order/orderCancel
+ * POST /third/party/shengnuo/order/orderConfirmTimeOut
+ * 
+ * 网关完整路径示例: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 createOrder(HttpServletRequest request, @RequestBody String rawBody) { + return handle(request, "inbound_create", rawBody, shengnuoOrderService::createOrder); + } + + @Inner(value = false) + @Operation(summary = "圣诺推送取消") + @PostMapping("/orderCancel") + public Map orderCancel(HttpServletRequest request, @RequestBody String rawBody) { + return handle(request, "inbound_cancel", rawBody, shengnuoOrderService::orderCancel); + } + + @Inner(value = false) + @Operation(summary = "圣诺确认超时提醒") + @PostMapping("/orderConfirmTimeOut") + public Map orderConfirmTimeOut(HttpServletRequest request, @RequestBody String rawBody) { + return handle(request, "inbound_timeout", rawBody, shengnuoOrderService::orderConfirmTimeOut); + } + + private Map handle(HttpServletRequest request, String action, String rawBody, + BiFunction> fn) { + long t0 = System.currentTimeMillis(); + String partner = null; + Map 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; + } +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/DownstreamHotelClient.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/DownstreamHotelClient.java index 69058322..031a9f45 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/DownstreamHotelClient.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/DownstreamHotelClient.java @@ -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); diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/DownstreamInboundAuth.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/DownstreamInboundAuth.java index ed46132c..34144ab3 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/DownstreamInboundAuth.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/DownstreamInboundAuth.java @@ -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); } diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/shengnuo/ShengnuoDownstreamApi.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/shengnuo/ShengnuoDownstreamApi.java new file mode 100644 index 00000000..597974b0 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/shengnuo/ShengnuoDownstreamApi.java @@ -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() { + } +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/shengnuo/ShengnuoDownstreamHotelAdapter.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/shengnuo/ShengnuoDownstreamHotelAdapter.java new file mode 100644 index 00000000..1a0bc695 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/shengnuo/ShengnuoDownstreamHotelAdapter.java @@ -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 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 signedBase(HotelDownstreamProperties props) { + String appId = resolveAppId(props); + String secret = resolveSecret(props); + String template = ShengnuoSignHelper.nowTemplate(); + Map 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 castMap(Map m) { + return (Map) 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; + } + } +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/shengnuo/ShengnuoIdMappingCache.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/shengnuo/ShengnuoIdMappingCache.java new file mode 100644 index 00000000..ef22fb7f --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/shengnuo/ShengnuoIdMappingCache.java @@ -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 roomTypeToNsRoom, + Map productToNsPlan) { + if (StrUtil.isBlank(partner) || localHotelId == null || nsHotelId <= 0) { + return; + } + Map rooms = roomTypeToNsRoom == null ? Map.of() : roomTypeToNsRoom; + Map plans = productToNsPlan == null ? Map.of() : productToNsPlan; + Map revRooms = new HashMap<>(); + for (Map.Entry e : rooms.entrySet()) { + if (e.getValue() != null) { + revRooms.put(String.valueOf(e.getValue()), e.getKey()); + } + } + Map revPlans = new HashMap<>(); + for (Map.Entry 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 rooms(JSONObject map) { + return toLongMap(map == null ? null : map.get("rooms")); + } + + public Map 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 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 e : plans(map).entrySet()) { + if (e.getValue() != null && e.getValue() == nsPlanId) { + return e.getKey(); + } + } + return null; + } + + private Map toLongMap(Object raw) { + Map 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; + } +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/shengnuo/ShengnuoSignHelper.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/shengnuo/ShengnuoSignHelper.java new file mode 100644 index 00000000..83faf77e --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/shengnuo/ShengnuoSignHelper.java @@ -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)); + } +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelDailyMapper.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelDailyMapper.java index 752c8456..fd0ad8aa 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelDailyMapper.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelDailyMapper.java @@ -79,6 +79,26 @@ public interface SmHotelDailyMapper extends PjcloudBaseMapper> selectMinSellPriceByStayRange(@Param("fromDate") LocalDate fromDate, @Param("toExclusive") LocalDate toExclusive, @Param("hotelIds") Collection hotelIds); + /** + * 管理端列表起价:入住半开区间内开房日历的最低卖价(不要求剩余库存,便于已维护价也能展示)。 + */ + @Select(""" + + """) + List> selectAdminListMinSellPriceByStayRange(@Param("fromDate") LocalDate fromDate, + @Param("toExclusive") LocalDate toExclusive, @Param("hotelIds") Collection hotelIds); + int insertBatch(@Param("list") List list); /** diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelMidService.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelMidService.java index b0357dc3..c94e74fe 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelMidService.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelMidService.java @@ -63,8 +63,15 @@ public interface SmHotelMidService { /** 酒店详情:主数据 + 已拉取的房型/产品/日历预览 */ R getHotelDetail(Long hotelId); - /** 日历分页 */ - R> pageDaily(Long hotelId, Page page, String productCode); + /** + * 日历分页。 + * @param productCode 可选产品编码 + * @param startDate 可选起始入住日(含);空则默认今天起 + * @param endDate 可选结束入住日(含) + * @param enforceDeptScope true=管理端按部门隔离;false=TOC 订房不校验部门 + */ + R> pageDaily(Long hotelId, Page page, String productCode, + java.time.LocalDate startDate, java.time.LocalDate endDate, boolean enforceDeptScope); /** 查询当前上游账号已授权酒店列表(getHotelList,支持关键字与分页) */ R> listAuthorizedHotels(String keyword, long current, long size, boolean refresh); diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelOrderService.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelOrderService.java index fb66af27..bf1cbacc 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelOrderService.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelOrderService.java @@ -116,4 +116,9 @@ public interface SmHotelOrderService { * 入参建议:extHotelId、extProductId、checkIn、checkOut、roomCnt、price(可选)。 */ R> checkBookableForDownstream(Map body); + + /** + * 酒店管理端数据概览:按当前用户酒店 DataScope 汇总今日/昨日新订单、间夜、销售额。 + */ + Map managerDashboardOverview(); } diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelService.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelService.java index b513de3b..afe7716b 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelService.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelService.java @@ -12,6 +12,15 @@ public interface SmHotelService extends IService { Page pageScope(Page page, LambdaQueryWrapper wrapper); + /** + * 按数据权限取酒店;不在部门范围内时抛 IllegalArgumentException。 + * 用于详情/编辑/房型日历等写读,防止跨部门 IDOR。 + */ + SmHotelEntity getByIdInScope(Long id); + + /** 校验酒店在当前用户数据权限范围内(仅校验,不返回实体) */ + void assertHotelInScope(Long id); + SmHotelEntity saveHotel(SmHotelEntity entity); Boolean updateHotel(SmHotelEntity entity); diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelShengnuoOrderService.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelShengnuoOrderService.java new file mode 100644 index 00000000..6c61f8cf --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelShengnuoOrderService.java @@ -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 createOrder(String partnerCode, JSONObject body); + + Map orderCancel(String partnerCode, JSONObject body); + + /** 即将确认超时提醒:只记日志,返回成功 */ + Map orderConfirmTimeOut(String partnerCode, JSONObject body); +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelAvailabilityNotifyServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelAvailabilityNotifyServiceImpl.java index fc630407..036ac85d 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelAvailabilityNotifyServiceImpl.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelAvailabilityNotifyServiceImpl.java @@ -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 missingBookableNights(Long hotelId, String productCode, LocalDate checkIn, LocalDate checkOut, int rooms) { List 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 nights = new ArrayList<>(); - for (LocalDate d = checkIn; d.isBefore(checkOut); d = d.plusDays(1)) { - nights.add(d); - } + List nights = SmHotelStayDates.occupyDates(checkIn, checkOut); + LocalDate queryTo = SmHotelStayDates.isDayUse(checkIn, checkOut) ? checkIn : checkOut.minusDays(1); List rows = dailyMapper.selectList(Wrappers.lambdaQuery() .eq(SmHotelDailyEntity::getHotelId, hotelId) .eq(SmHotelDailyEntity::getProductCode, productCode) .ge(SmHotelDailyEntity::getStayDate, checkIn) - .lt(SmHotelDailyEntity::getStayDate, checkOut)); + .le(SmHotelDailyEntity::getStayDate, queryTo)); Set ok = new HashSet<>(); if (rows != null) { for (SmHotelDailyEntity row : rows) { diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelHuidingOrderServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelHuidingOrderServiceImpl.java index 53198fab..ed82cee7 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelHuidingOrderServiceImpl.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelHuidingOrderServiceImpl.java @@ -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.lambdaQuery() diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelMidServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelMidServiceImpl.java index c74290d7..e10998f3 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelMidServiceImpl.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelMidServiceImpl.java @@ -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 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 products = onlyProducts; @@ -593,6 +616,299 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { return result; } + /** + * 圣诺可售推:getInfo(eid) → pushHotelPlan → pushHotelPrice(15 天切片)。 + * eid 优先 sHotelId(艺龙同程 id),否则 hotelCode。 + */ + private Map 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 roomTypeToNs = matchShengnuoRooms(hotelId, nsRooms); + + List products = productMapper.selectList(Wrappers.lambdaQuery() + .eq(SmHotelProductEntity::getHotelId, hotelId) + .isNotNull(SmHotelProductEntity::getProductCode)); + Map productToPlan = new LinkedHashMap<>(); + int planOk = 0; + List 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 body = new LinkedHashMap<>(shengnuoAdapter.signedBase(ds)); + body.put("id", nsHotelId); + body.put("roomId", nsRoomId); + List> planCode = new ArrayList<>(1); + Map 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 dailies = dailyMapper.selectList(Wrappers.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 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 closeHotelDailyToShengnuo(SmHotelEntity hotel, String partnerCode) { + downstreamClient.assertPartnerReady(partnerCode); + HotelDownstreamProperties ds = channelConfigService.resolveDownstreamRuntime(partnerCode); + long nsHotelId = resolveShengnuoHotelId(partnerCode, ds, hotel); + Map 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 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 matchShengnuoRooms(Long hotelId, cn.hutool.json.JSONArray nsRooms) { + Map out = new LinkedHashMap<>(); + if (nsRooms == null || nsRooms.isEmpty()) { + return out; + } + List rooms = roomMapper.selectList(Wrappers.lambdaQuery() + .eq(SmHotelRoomEntity::getHotelId, hotelId)); + Map byElong = new HashMap<>(); + Map 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 products, Map roomTypeToNs, Map productToPlan, + List dailies, LocalDate from, LocalDate to) { + if (CollUtil.isEmpty(productToPlan) || from == null || to == null || from.isAfter(to)) { + return 0; + } + Map productByCode = new HashMap<>(); + for (SmHotelProductEntity p : products) { + if (p != null && StrUtil.isNotBlank(p.getProductCode())) { + productByCode.put(p.getProductCode(), p); + } + } + Map> 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> priceInfo = new ArrayList<>(); + for (Map.Entry e : productToPlan.entrySet()) { + SmHotelProductEntity p = productByCode.get(e.getKey()); + if (p == null) { + continue; + } + Long nsRoomId = roomTypeToNs.get(p.getRoomTypeCode()); + if (nsRoomId == null) { + continue; + } + List> priceCode = new ArrayList<>(); + Map dayMap = dailyByProduct.getOrDefault(e.getKey(), Map.of()); + for (LocalDate d = cursor; !d.isAfter(chunkEnd); d = d.plusDays(1)) { + SmHotelDailyEntity row = dayMap.get(d); + Map 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 info = new LinkedHashMap<>(); + info.put("roomId", nsRoomId); + info.put("planId", e.getValue()); + info.put("priceCode", priceCode); + priceInfo.add(info); + } + if (CollUtil.isNotEmpty(priceInfo)) { + Map 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 closeHotelDailyToHuiding(SmHotelEntity hotel, String partnerCode) { downstreamClient.assertPartnerReady(partnerCode); @@ -677,6 +993,16 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { Map 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 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> mergeDailyRanges(List rows) { List> 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 dailyRangeItem(SmHotelDailyEntity start, SmHotelDailyEntity end) { Map 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 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> pageDaily(Long hotelId, Page 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 result = dailyMapper.selectPage(page, Wrappers.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 list = hotelMapper.selectListByScope( + Wrappers.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不能为空"); diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelOrderServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelOrderServiceImpl.java index 3be73e52..65de228c 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelOrderServiceImpl.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelOrderServiceImpl.java @@ -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 pageOrders(Page page, SmHotelOrderEntity query) { Page result = baseMapper.selectPage(page, Wrappers.lambdaQuery() @@ -938,14 +946,16 @@ public class SmHotelOrderServiceImpl extends ServiceImpllambdaQuery() .eq(SmHotelEntity::getHotelCode, extHotelId) @@ -982,7 +992,7 @@ public class SmHotelOrderServiceImpl extends ServiceImpl 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 30) { return R.failed("连住最多30晚,请缩短离店日期"); } @@ -1387,10 +1397,7 @@ public class SmHotelOrderServiceImpl extends ServiceImpl missing = new ArrayList<>(); List closed = new ArrayList<>(); List 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.lambdaQuery() .eq(SmHotelDailyEntity::getProductId, productId) .eq(SmHotelDailyEntity::getStayDate, d) @@ -1880,7 +1888,7 @@ public class SmHotelOrderServiceImpl extends ServiceImpllambdaQuery() @@ -1904,6 +1912,12 @@ public class SmHotelOrderServiceImpl extends ServiceImpllambdaQuery() .eq(SmHotelDailyEntity::getProductId, productId) .eq(SmHotelDailyEntity::getStayDate, d) @@ -2727,7 +2742,7 @@ public class SmHotelOrderServiceImpl extends ServiceImpl 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 listUpstreamLogs(Long orderId) { return upstreamLogService.listByOrderId(orderId); } + + @Override + public Map managerDashboardOverview() { + // 仅统计当前用户 DataScope 内酒店,避免跨部门泄露 + List hotels = hotelMapper.selectListByScope( + Wrappers.lambdaQuery().select(SmHotelEntity::getId), DataScope.of()); + List 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 todayStat = emptyDashDay(); + Map yesterdayStat = emptyDashDay(); + if (CollUtil.isNotEmpty(hotelIds)) { + todayStat = aggregateDashDay(hotelIds, todayStart, tomorrowStart); + yesterdayStat = aggregateDashDay(hotelIds, yesterdayStart, todayStart); + } + + Map 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 emptyDashDay() { + Map 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 aggregateDashDay(List hotelIds, LocalDateTime from, LocalDateTime toExclusive) { + List rows = baseMapper.selectList(Wrappers.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 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); + } } diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelServiceImpl.java index 5eb29d45..00791871 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelServiceImpl.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelServiceImpl.java @@ -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 list = baseMapper.selectListByScope( + Wrappers.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 records) { if (CollUtil.isEmpty(records)) { return; @@ -98,11 +124,21 @@ public class SmHotelServiceImpl extends ServiceImpl roomMap = toCountMap(roomMapper.countGroupByHotelIds(ids)); Map productMap = toCountMap(productMapper.countGroupByHotelIds(ids)); Map dailyMap = toCountMap(dailyMapper.countGroupByHotelIds(ids)); + // 管理端列表起价:优先今日开房日历挂牌最低价;没有则回退产品最低卖价 + LocalDate today = LocalDate.now(); + Map dailyMinMap = toMinSellMap( + dailyMapper.selectAdminListMinSellPriceByStayRange(today, today.plusDays(1), ids)); + Map 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 toMinSellMap(List> rows) { + Map map = new HashMap<>(); + if (CollUtil.isEmpty(rows)) { + return map; + } + for (Map 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 0; if (ok) { @@ -223,6 +287,9 @@ public class SmHotelServiceImpl extends ServiceImpl idList = CollUtil.toList(ids); + for (Long id : idList) { + assertHotelInScope(id); + } List before = baseMapper.selectBatchIds(idList); boolean ok = removeByIds(idList); if (ok && CollUtil.isNotEmpty(before)) { diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelShengnuoOrderServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelShengnuoOrderServiceImpl.java new file mode 100644 index 00000000..084ded49 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelShengnuoOrderServiceImpl.java @@ -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 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.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 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> 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 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 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.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 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 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.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.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 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 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 ok(String msg, Long localId) { + Map 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 fail(String msg) { + Map 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; + } +}