Merge branch 'master' of https://gitea.zianxin.com/zianxin/biz-api-cloud-zax
This commit is contained in:
commit
5a3236558c
58 files changed
+2972
-417
No files matched your search
+28
@@ -0,0 +1,28 @@
|
||||
package com.bsd.pjcloud.supermarket.api.feign;
|
||||
|
||||
import com.bsd.pjcloud.common.core.constant.ServiceNameConstants;
|
||||
import com.bsd.pjcloud.common.core.util.R;
|
||||
import com.bsd.pjcloud.common.feign.annotation.NoToken;
|
||||
import com.bsd.pjcloud.supermarket.vo.SmHotelFxInvitePreviewVO;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
/**
|
||||
* 酒店分销邀请:UPMS 注册页识别分销邀请码后回调挂靠分销员。
|
||||
*/
|
||||
@FeignClient(contextId = "remoteSmHotelFxInviteService", value = ServiceNameConstants.SUPERMARKET_SERVICE)
|
||||
public interface RemoteSmHotelFxInviteService {
|
||||
|
||||
@NoToken
|
||||
@GetMapping("/inner/smHotelFx/invite/preview")
|
||||
R<SmHotelFxInvitePreviewVO> preview(@RequestParam("inviteCode") String inviteCode);
|
||||
|
||||
@NoToken
|
||||
@PostMapping("/inner/smHotelFx/invite/bindAgent")
|
||||
R<Boolean> bindAgent(@RequestParam("inviteCode") String inviteCode, @RequestParam("userId") Long userId,
|
||||
@RequestParam(value = "username", required = false) String username,
|
||||
@RequestParam(value = "realName", required = false) String realName,
|
||||
@RequestParam(value = "mobile", required = false) String mobile);
|
||||
}
|
||||
+20
-4
@@ -17,9 +17,12 @@ public final class SmHotelDownstreamProtocol {
|
||||
/** 慧订:docs/xiayou/shenzhen(静态 + hoteldatepush) */
|
||||
public static final String HUIDING = "huiding";
|
||||
|
||||
/** 圣诺/奈斯:docs/xiayou/shengnuo(getInfo + plan + price + close + orderChange) */
|
||||
/** 圣诺:docs/xiayou/shengnuo(getInfo + plan + price + close + orderChange) */
|
||||
public static final String SHENGNUO = "shengnuo";
|
||||
|
||||
/** 奈斯:与圣诺同接口形态,独立伙伴配置与入站路径 /third/party/nice/order/* */
|
||||
public static final String NICE = "nice";
|
||||
|
||||
/** @deprecated 历史误用名,normalize 后等价 {@link #HUANQI} */
|
||||
@Deprecated
|
||||
public static final String XIAYOU = "xiayou";
|
||||
@@ -43,9 +46,12 @@ public final class SmHotelDownstreamProtocol {
|
||||
if (SHENZHEN.equals(p)) {
|
||||
return HUIDING;
|
||||
}
|
||||
if (NS.equals(p) || "nais".equals(p) || "nice".equals(p)) {
|
||||
if (NS.equals(p)) {
|
||||
return SHENGNUO;
|
||||
}
|
||||
if ("nais".equals(p)) {
|
||||
return NICE;
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
@@ -55,6 +61,7 @@ public final class SmHotelDownstreamProtocol {
|
||||
set.add(HUANQI);
|
||||
set.add(HUIDING);
|
||||
set.add(SHENGNUO);
|
||||
set.add(NICE);
|
||||
return set;
|
||||
}
|
||||
|
||||
@@ -70,8 +77,14 @@ public final class SmHotelDownstreamProtocol {
|
||||
return HUIDING.equals(normalize(protocol));
|
||||
}
|
||||
|
||||
/** 圣诺或奈斯(同套推送/验签逻辑) */
|
||||
public static boolean isShengnuo(String protocol) {
|
||||
return SHENGNUO.equals(normalize(protocol));
|
||||
String p = normalize(protocol);
|
||||
return SHENGNUO.equals(p) || NICE.equals(p);
|
||||
}
|
||||
|
||||
public static boolean isNice(String protocol) {
|
||||
return NICE.equals(normalize(protocol));
|
||||
}
|
||||
|
||||
public static String label(String protocol) {
|
||||
@@ -83,7 +96,10 @@ public final class SmHotelDownstreamProtocol {
|
||||
return "慧订";
|
||||
}
|
||||
if (SHENGNUO.equals(p)) {
|
||||
return "圣诺/高德";
|
||||
return "圣诺";
|
||||
}
|
||||
if (NICE.equals(p)) {
|
||||
return "奈斯";
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
+4
-1
@@ -53,7 +53,7 @@ public class SmHotelDownstreamChannelConfigDTO {
|
||||
@Schema(description = "hotel/apartment 每分钟上限,默认 600;≤0 关闭")
|
||||
private Integer rateLimitHotelApartmentPerMin;
|
||||
|
||||
@Schema(description = "product/daily 每分钟上限,默认 300;≤0 关闭")
|
||||
@Schema(description = "product/daily 每分钟上限,默认 150(环启 2026-10-26);≤0 关闭")
|
||||
private Integer rateLimitProductDailyPerMin;
|
||||
|
||||
@Schema(description = "慧订 hoteldatepush 出站 QPS,默认 5;≤0 关闭")
|
||||
@@ -112,4 +112,7 @@ public class SmHotelDownstreamChannelConfigDTO {
|
||||
|
||||
@Schema(description = "本地改价/库存/房态后是否自动推本伙伴;大批量补数可临时 false,默认 true(未配则跟进程级)")
|
||||
private Boolean resourceAutoPushEnabled;
|
||||
|
||||
@Schema(description = "全量/日更推送酒店并发;环启建议 ≤3,默认 2;1=串行")
|
||||
private Integer resourcePushConcurrency;
|
||||
}
|
||||
+10
@@ -51,6 +51,16 @@ public class SmHotelDailyEntity extends Model<SmHotelDailyEntity> {
|
||||
@Schema(description = "可售剩余")
|
||||
private Integer remainStock;
|
||||
|
||||
/** 按全局配置动态算的默认卖价(非表字段) */
|
||||
@TableField(exist = false)
|
||||
@Schema(description = "默认卖价=协议价按全局加价")
|
||||
private BigDecimal defaultSellPrice;
|
||||
|
||||
/** 各启用下游动态卖价 partnerCode → amount(非表字段) */
|
||||
@TableField(exist = false)
|
||||
@Schema(description = "下游渠道卖价映射")
|
||||
private java.util.Map<String, BigDecimal> channelSellPrices;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private String createBy;
|
||||
|
||||
|
||||
+9
@@ -56,6 +56,15 @@ public class SmHotelFxAgentEntity extends Model<SmHotelFxAgentEntity> {
|
||||
|
||||
private String remark;
|
||||
|
||||
@Schema(description = "收款方式 BANK/ALIPAY/WECHAT/OTHER")
|
||||
private String payMethod;
|
||||
|
||||
@Schema(description = "收款账号")
|
||||
private String payAccount;
|
||||
|
||||
@Schema(description = "开户名/收款户名")
|
||||
private String payAccountName;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private String createBy;
|
||||
|
||||
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
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 io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TenantTable
|
||||
@TableName("canteen_hotel_fx_hotel_apply")
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@Schema(description = "酒店分销-分销员提报酒店")
|
||||
public class SmHotelFxHotelApplyEntity extends Model<SmHotelFxHotelApplyEntity> {
|
||||
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
|
||||
private Long tenantId;
|
||||
|
||||
private Long agentId;
|
||||
|
||||
private Long userId;
|
||||
|
||||
private String hotelName;
|
||||
|
||||
private String hotelCode;
|
||||
|
||||
private String district;
|
||||
|
||||
private Long districtAdcode;
|
||||
|
||||
private String address;
|
||||
|
||||
private String contactName;
|
||||
|
||||
private String contactPhone;
|
||||
|
||||
private String remark;
|
||||
|
||||
@Schema(description = "提报扩展 JSON(licenseUrl 等)")
|
||||
private String hotelJson;
|
||||
|
||||
@Schema(description = "PENDING/APPROVED/REJECTED/CANCELLED")
|
||||
private String status;
|
||||
|
||||
private Long hotelId;
|
||||
|
||||
@Schema(description = "审核备注;通过时填酒店账号密码等,分销员可见")
|
||||
private String auditRemark;
|
||||
|
||||
private String auditBy;
|
||||
|
||||
private LocalDateTime auditTime;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private String createBy;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private LocalDateTime createTime;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||
private String updateBy;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
@TableLogic
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private String delFlag;
|
||||
|
||||
@TableField(exist = false)
|
||||
private String agentName;
|
||||
|
||||
/** 营业执照图片 URL(来自 hotel_json.licenseUrl,非表字段) */
|
||||
@TableField(exist = false)
|
||||
@Schema(description = "营业执照图片")
|
||||
private String licenseUrl;
|
||||
}
|
||||
+3
@@ -33,6 +33,9 @@ public class SmHotelMgrPushConfigEntity extends Model<SmHotelMgrPushConfigEntity
|
||||
@Schema(description = "新订单推送正文,可含 {hotelName}/{channelNo}")
|
||||
private String newOrderContent;
|
||||
|
||||
@Schema(description = "新订单同时推送 ROLE_ADMIN:1开 0关")
|
||||
private String newOrderAdminEnabled;
|
||||
|
||||
@Schema(description = "日历提醒:1开 0关")
|
||||
private String calendarRemindEnabled;
|
||||
|
||||
|
||||
+3
@@ -79,6 +79,9 @@ public class SmHotelMgrPushLogEntity extends Model<SmHotelMgrPushLogEntity> {
|
||||
|
||||
private String resultMsg;
|
||||
|
||||
@Schema(description = "下游请求体快照(失败/部分失败时写入 JSON)")
|
||||
private String requestJson;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private String createBy;
|
||||
|
||||
|
||||
+7
-1
@@ -58,13 +58,19 @@ public class SmHotelOrderEntity extends Model<SmHotelOrderEntity> {
|
||||
|
||||
private BigDecimal markupPercent;
|
||||
|
||||
@Schema(description = "下单时加价固定金额快照")
|
||||
private BigDecimal markupFixed;
|
||||
|
||||
@Schema(description = "下单计价所用下游 partner,空表示全局/App")
|
||||
private String pricePartnerCode;
|
||||
|
||||
private String contactName;
|
||||
|
||||
private String contactPhone;
|
||||
|
||||
private String guestJson;
|
||||
|
||||
@Schema(description = "CREATED/PENDING/UPSTREAM_OK/CHECKED_IN/CANCELLED/UPSTREAM_FAIL/PENDING_CONFIRM/NEED_MANUAL")
|
||||
@Schema(description = "CREATED/PENDING/UPSTREAM_OK/CHECKED_IN/CANCELLED/UPSTREAM_FAIL/BOOK_FAIL/PENDING_CONFIRM/NEED_MANUAL")
|
||||
private String orderStatus;
|
||||
|
||||
@Schema(description = "上游 bookStatus:P/R/E/X")
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
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 io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 酒店下游渠道售价加价:有启用配置则对协议价加价;无配置则推/验协议价原价。
|
||||
*/
|
||||
@Data
|
||||
@TenantTable
|
||||
@TableName("canteen_hotel_price_channel_config")
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@Schema(description = "酒店下游渠道售价加价")
|
||||
public class SmHotelPriceChannelConfigEntity extends Model<SmHotelPriceChannelConfigEntity> {
|
||||
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
|
||||
private Long tenantId;
|
||||
|
||||
@Schema(description = "下游伙伴编码")
|
||||
private String partnerCode;
|
||||
|
||||
@Schema(description = "加价百分比:正加价、负折扣")
|
||||
private BigDecimal markupPercent;
|
||||
|
||||
@Schema(description = "固定加价金额(元)")
|
||||
private BigDecimal markupFixed;
|
||||
|
||||
@Schema(description = "1启用 0停用")
|
||||
private String enabled;
|
||||
|
||||
private String remark;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private String createBy;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private LocalDateTime createTime;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||
private String updateBy;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
@TableLogic
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private String delFlag;
|
||||
}
|
||||
+3
@@ -29,6 +29,9 @@ public class SmHotelPriceConfigEntity extends Model<SmHotelPriceConfigEntity> {
|
||||
@Schema(description = "售价调整百分比:正数加价、负数折扣,例15=加价15%,-10=打九折")
|
||||
private BigDecimal markupPercent;
|
||||
|
||||
@Schema(description = "固定加价金额(元),可与百分比叠加:sell=cost*(1+p/100)+fixed")
|
||||
private BigDecimal markupFixed;
|
||||
|
||||
private String remark;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.bsd.pjcloud.supermarket.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 酒店分销邀请码预览(供 UPMS 注册页复用同一邀请码输入框)。
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "酒店分销邀请预览")
|
||||
public class SmHotelFxInvitePreviewVO {
|
||||
|
||||
private String inviteCode;
|
||||
|
||||
private Long parentAgentId;
|
||||
|
||||
private Long parentUserId;
|
||||
|
||||
/** 新用户落入邀请人所属部门 */
|
||||
private Long deptId;
|
||||
|
||||
private String parentName;
|
||||
|
||||
private Integer parentLevel;
|
||||
|
||||
/** 新分销员层级:1~3 邀 → 4;4 邀 → 5 */
|
||||
private Integer childLevel;
|
||||
|
||||
private String hint;
|
||||
}
|
||||
+5
-5
@@ -67,8 +67,8 @@ public class HotelDownstreamProperties {
|
||||
/** /hotel/sync、/apartment/sync 每分钟上限(环启文档 600) */
|
||||
private int rateLimitHotelApartmentPerMin = 600;
|
||||
|
||||
/** /product/sync、/daily/sync 每分钟上限(环启文档 300) */
|
||||
private int rateLimitProductDailyPerMin = 300;
|
||||
/** /product/sync、/daily/sync 每分钟上限(环启文档 2026-10-26 起 150) */
|
||||
private int rateLimitProductDailyPerMin = 150;
|
||||
|
||||
/**
|
||||
* 慧订 /splider/hoteldatepush 出站 QPS(对方已限流)。
|
||||
@@ -193,8 +193,8 @@ public class HotelDownstreamProperties {
|
||||
private String resourcePushCron = "0 30 2 * * ?";
|
||||
|
||||
/**
|
||||
* 下游全量推酒店并发(圣诺等 HTTP 出站)。1=串行;建议 4~8。
|
||||
* 对方限流时报错再调低。伙伴 YAML 可覆盖。
|
||||
* 下游全量推酒店并发。1=串行。
|
||||
* 环启文档建议并发≤3(产品/日历写重);默认 2。圣诺若对方宽松可在 YAML 调高。
|
||||
*/
|
||||
private int resourcePushConcurrency = 6;
|
||||
private int resourcePushConcurrency = 2;
|
||||
}
|
||||
+45
-5
@@ -47,16 +47,23 @@ public class SmHotelController {
|
||||
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_view')")
|
||||
public R<Page<SmHotelEntity>> page(@ParameterObject Page<SmHotelEntity> page,
|
||||
@ParameterObject SmHotelEntity query) {
|
||||
String nameKw = StrUtil.trim(query.getHotelName());
|
||||
boolean nameSearch = StrUtil.isNotBlank(nameKw);
|
||||
var wrapper = Wrappers.<SmHotelEntity>lambdaQuery()
|
||||
.like(StrUtil.isNotBlank(query.getHotelName()), SmHotelEntity::getHotelName, query.getHotelName())
|
||||
.like(StrUtil.isNotBlank(query.getHotelCode()), SmHotelEntity::getHotelCode, query.getHotelCode())
|
||||
.like(StrUtil.isNotBlank(query.getGroupCode()), SmHotelEntity::getGroupCode, query.getGroupCode())
|
||||
.eq(StrUtil.isNotBlank(query.getSourceId()), SmHotelEntity::getSourceId, query.getSourceId())
|
||||
.eq(StrUtil.isNotBlank(query.getSheetName()), SmHotelEntity::getSheetName, query.getSheetName())
|
||||
// 归属部门:渠道映射按部门推送时,便于按部门筛酒店核查
|
||||
.eq(query.getDeptId() != null, SmHotelEntity::getDeptId, query.getDeptId())
|
||||
// 默认按主键倒序:雪花 ID 近似创建时间,可走索引;避免无索引时 create_time 全表 filesort
|
||||
.orderByDesc(SmHotelEntity::getId);
|
||||
.eq(query.getDeptId() != null, SmHotelEntity::getDeptId, query.getDeptId());
|
||||
// 店名:先走前缀(可用 idx_hotel_name);含匹配去掉 ORDER BY + 不做 COUNT,避免十几万酒店超时
|
||||
if (nameSearch) {
|
||||
wrapper.and(w -> w.likeRight(SmHotelEntity::getHotelName, nameKw)
|
||||
.or()
|
||||
.eq(SmHotelEntity::getHotelCode, nameKw)
|
||||
.or()
|
||||
.eq(SmHotelEntity::getSHotelId, nameKw));
|
||||
}
|
||||
// 外部酒店ID/eid:推圣诺 getInfo 用 sHotelId,空时回落 hotelCode
|
||||
if (StrUtil.isNotBlank(query.getSHotelId())) {
|
||||
String eid = query.getSHotelId().trim();
|
||||
@@ -74,7 +81,40 @@ public class SmHotelController {
|
||||
.ne(SmHotelEntity::getUpstreamStatus, "OFFLINE"));
|
||||
}
|
||||
hotelService.applySaleableFilter(wrapper, query.getSaleable());
|
||||
return R.ok(hotelService.pageScope(page, wrapper));
|
||||
if (!nameSearch) {
|
||||
// 默认按主键倒序:雪花 ID 近似创建时间,可走索引
|
||||
wrapper.orderByDesc(SmHotelEntity::getId);
|
||||
return R.ok(hotelService.pageScope(page, wrapper));
|
||||
}
|
||||
// 名称搜索:先前缀(可排序);无结果再含匹配且不做 COUNT / 不 ORDER BY
|
||||
wrapper.orderByDesc(SmHotelEntity::getId);
|
||||
Page<SmHotelEntity> prefixPage = hotelService.pageScopeWithoutCount(page, wrapper);
|
||||
if (page.getCurrent() > 1 || (prefixPage.getRecords() != null && !prefixPage.getRecords().isEmpty())) {
|
||||
return R.ok(prefixPage);
|
||||
}
|
||||
var contains = Wrappers.<SmHotelEntity>lambdaQuery()
|
||||
.like(SmHotelEntity::getHotelName, nameKw)
|
||||
.like(StrUtil.isNotBlank(query.getHotelCode()), SmHotelEntity::getHotelCode, query.getHotelCode())
|
||||
.like(StrUtil.isNotBlank(query.getGroupCode()), SmHotelEntity::getGroupCode, query.getGroupCode())
|
||||
.eq(StrUtil.isNotBlank(query.getSourceId()), SmHotelEntity::getSourceId, query.getSourceId())
|
||||
.eq(StrUtil.isNotBlank(query.getSheetName()), SmHotelEntity::getSheetName, query.getSheetName())
|
||||
.eq(query.getDeptId() != null, SmHotelEntity::getDeptId, query.getDeptId());
|
||||
if (StrUtil.isNotBlank(query.getSHotelId())) {
|
||||
String eid = query.getSHotelId().trim();
|
||||
contains.and(w -> w.eq(SmHotelEntity::getSHotelId, eid).or().eq(SmHotelEntity::getHotelCode, eid));
|
||||
}
|
||||
HotelAreaAdcodeUtil.applyHotelListFilter(contains, query.getDistrictAdcode(), query.getDistrict());
|
||||
if ("OFFLINE".equalsIgnoreCase(query.getUpstreamStatus())) {
|
||||
contains.eq(SmHotelEntity::getUpstreamStatus, "OFFLINE");
|
||||
}
|
||||
else if ("ACTIVE".equalsIgnoreCase(query.getUpstreamStatus())) {
|
||||
contains.and(w -> w.isNull(SmHotelEntity::getUpstreamStatus)
|
||||
.or()
|
||||
.ne(SmHotelEntity::getUpstreamStatus, "OFFLINE"));
|
||||
}
|
||||
hotelService.applySaleableFilter(contains, query.getSaleable());
|
||||
// 含匹配:禁止 ORDER BY,让 MySQL 凑够 LIMIT 即可停,避免全表 filesort
|
||||
return R.ok(hotelService.pageScopeWithoutCount(page, contains));
|
||||
}
|
||||
|
||||
/** 上传文件会立刻 transferTo 临时目录,勿加 @SysLog */
|
||||
|
||||
+135
-1
@@ -1,5 +1,6 @@
|
||||
package com.bsd.pjcloud.supermarket.controller;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.bsd.pjcloud.common.core.util.R;
|
||||
import com.bsd.pjcloud.common.log.annotation.SysLog;
|
||||
@@ -7,6 +8,7 @@ import com.bsd.pjcloud.supermarket.entity.SmHotelFxAgentEntity;
|
||||
import com.bsd.pjcloud.supermarket.entity.SmHotelFxCommissionEntity;
|
||||
import com.bsd.pjcloud.supermarket.entity.SmHotelFxConfigEntity;
|
||||
import com.bsd.pjcloud.supermarket.entity.SmHotelFxExitEntity;
|
||||
import com.bsd.pjcloud.supermarket.entity.SmHotelFxHotelApplyEntity;
|
||||
import com.bsd.pjcloud.supermarket.entity.SmHotelFxHotelBindEntity;
|
||||
import com.bsd.pjcloud.supermarket.entity.SmHotelFxL1ApplyEntity;
|
||||
import com.bsd.pjcloud.supermarket.entity.SmHotelFxWithdrawEntity;
|
||||
@@ -14,6 +16,7 @@ import com.bsd.pjcloud.supermarket.service.SmHotelFxAgentService;
|
||||
import com.bsd.pjcloud.supermarket.service.SmHotelFxCommissionService;
|
||||
import com.bsd.pjcloud.supermarket.service.SmHotelFxConfigService;
|
||||
import com.bsd.pjcloud.supermarket.service.SmHotelFxExitService;
|
||||
import com.bsd.pjcloud.supermarket.service.SmHotelFxHotelApplyService;
|
||||
import com.bsd.pjcloud.supermarket.service.SmHotelFxL1ApplyService;
|
||||
import com.bsd.pjcloud.supermarket.service.SmHotelFxWithdrawService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
@@ -22,6 +25,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
@@ -45,6 +49,8 @@ public class SmHotelFxController {
|
||||
|
||||
private final SmHotelFxExitService exitService;
|
||||
|
||||
private final SmHotelFxHotelApplyService hotelApplyService;
|
||||
|
||||
@Operation(summary = "分销配置")
|
||||
@GetMapping("/config")
|
||||
@PreAuthorize("@pms.hasPermission('supermarket_smHotelFx_view')")
|
||||
@@ -118,6 +124,28 @@ public class SmHotelFxController {
|
||||
return R.ok(agentService.pageBinds(page, agentId, hotelId));
|
||||
}
|
||||
|
||||
@Operation(summary = "按酒店ID批量查已绑定分销员(审核选店)")
|
||||
@GetMapping("/bind/byHotels")
|
||||
@PreAuthorize("@pms.hasPermission('supermarket_smHotelFxAgent_view') || @pms.hasPermission('supermarket_smHotelFxHotelApply_view') || @pms.hasPermission('supermarket_smHotelFxHotelApply_audit')")
|
||||
public R<java.util.List<SmHotelFxHotelBindEntity>> bindsByHotels(@RequestParam String hotelIds) {
|
||||
java.util.List<Long> ids = new java.util.ArrayList<>();
|
||||
if (cn.hutool.core.util.StrUtil.isNotBlank(hotelIds)) {
|
||||
for (String p : hotelIds.split(",")) {
|
||||
String t = p.trim();
|
||||
if (cn.hutool.core.util.StrUtil.isBlank(t)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
ids.add(Long.parseLong(t));
|
||||
}
|
||||
catch (NumberFormatException ignored) {
|
||||
// skip bad token
|
||||
}
|
||||
}
|
||||
}
|
||||
return R.ok(agentService.listBoundByHotelIds(ids));
|
||||
}
|
||||
|
||||
@Operation(summary = "佣金明细分页(管理端)")
|
||||
@GetMapping("/commission/page")
|
||||
@PreAuthorize("@pms.hasPermission('supermarket_smHotelFxCommission_view')")
|
||||
@@ -195,7 +223,11 @@ public class SmHotelFxController {
|
||||
@Operation(summary = "我的佣金汇总(App)")
|
||||
@GetMapping("/app/mySummary")
|
||||
public R<Map<String, Object>> mySummary() {
|
||||
return commissionService.mySummary();
|
||||
R<Map<String, Object>> r = commissionService.mySummary();
|
||||
if (r != null && r.isOk() && r.getData() != null) {
|
||||
r.getData().put("pendingApplyCount", hotelApplyService.countPendingMine());
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
@Operation(summary = "我的佣金明细(App)")
|
||||
@@ -205,6 +237,22 @@ public class SmHotelFxController {
|
||||
return R.ok(commissionService.pageMine(page, status));
|
||||
}
|
||||
|
||||
@Operation(summary = "下线绩效汇总(App:直接下级佣金归本人,上级只读)")
|
||||
@GetMapping("/app/downlinePerf")
|
||||
public R<Map<String, Object>> downlinePerf() {
|
||||
return commissionService.downlinePerf();
|
||||
}
|
||||
|
||||
@SysLog("分销模拟下单")
|
||||
@Operation(summary = "模拟下单记佣(管理端联调:酒店须已绑定分销员)")
|
||||
@PostMapping("/mockOrder")
|
||||
@PreAuthorize("@pms.hasPermission('supermarket_smHotelFx_edit')")
|
||||
public R<Map<String, Object>> mockOrder(@RequestParam Long hotelId,
|
||||
@RequestParam(required = false) BigDecimal costAmount,
|
||||
@RequestParam(required = false, defaultValue = "false") Boolean lockNow) {
|
||||
return commissionService.mockOrderForBoundHotel(hotelId, costAmount, lockNow);
|
||||
}
|
||||
|
||||
@Operation(summary = "申请一级(App)")
|
||||
@PostMapping("/app/l1/apply")
|
||||
public R<SmHotelFxL1ApplyEntity> appL1Apply(@RequestParam String cityAdcode,
|
||||
@@ -238,4 +286,90 @@ public class SmHotelFxController {
|
||||
public R<SmHotelFxExitEntity> appExitApply(@RequestParam(required = false) String reason) {
|
||||
return exitService.applyMine(reason);
|
||||
}
|
||||
|
||||
@Operation(summary = "酒店提报分页(管理端)")
|
||||
@GetMapping("/hotelApply/page")
|
||||
@PreAuthorize("@pms.hasPermission('supermarket_smHotelFxHotelApply_view')")
|
||||
public R<Page<SmHotelFxHotelApplyEntity>> hotelApplyPage(Page<SmHotelFxHotelApplyEntity> page,
|
||||
SmHotelFxHotelApplyEntity query) {
|
||||
return R.ok(hotelApplyService.pageAdmin(page, query));
|
||||
}
|
||||
|
||||
@SysLog("审核分销酒店提报")
|
||||
@Operation(summary = "审核酒店提报(通过须选已完善酒店 + 备注账号密码)")
|
||||
@PostMapping("/hotelApply/audit")
|
||||
@PreAuthorize("@pms.hasPermission('supermarket_smHotelFxHotelApply_audit')")
|
||||
public R<Boolean> hotelApplyAudit(@RequestParam Long id, @RequestParam boolean approved,
|
||||
@RequestParam(required = false) String auditRemark,
|
||||
@RequestParam(required = false) Long hotelId) {
|
||||
return hotelApplyService.audit(id, approved, auditRemark, hotelId);
|
||||
}
|
||||
|
||||
@Operation(summary = "我的酒店提报(App)")
|
||||
@GetMapping("/app/hotelApply/page")
|
||||
public R<Page<SmHotelFxHotelApplyEntity>> appHotelApplyPage(Page<SmHotelFxHotelApplyEntity> page,
|
||||
@RequestParam(required = false) String status) {
|
||||
return R.ok(hotelApplyService.pageMine(page, status));
|
||||
}
|
||||
|
||||
@Operation(summary = "我的提报详情(App,编辑回填)")
|
||||
@GetMapping("/app/hotelApply/{id}")
|
||||
public R<SmHotelFxHotelApplyEntity> appHotelApplyDetail(@PathVariable Long id) {
|
||||
return hotelApplyService.getMine(id);
|
||||
}
|
||||
|
||||
@Operation(summary = "新增/重提酒店(App:名称+地址+营业执照;传 id 可编辑待审/驳回)")
|
||||
@PostMapping("/app/hotelApply")
|
||||
public R<SmHotelFxHotelApplyEntity> appHotelApply(@RequestBody Map<String, Object> body) {
|
||||
SmHotelFxHotelApplyEntity row = new SmHotelFxHotelApplyEntity();
|
||||
if (body != null) {
|
||||
Object idObj = body.get("id");
|
||||
if (idObj != null && StrUtil.isNotBlank(String.valueOf(idObj))) {
|
||||
try {
|
||||
row.setId(Long.valueOf(String.valueOf(idObj)));
|
||||
}
|
||||
catch (Exception ignored) {
|
||||
// ignore bad id
|
||||
}
|
||||
}
|
||||
Object json = body.get("hotelJson");
|
||||
if (json instanceof String) {
|
||||
row.setHotelJson((String) json);
|
||||
}
|
||||
else if (json != null) {
|
||||
row.setHotelJson(cn.hutool.json.JSONUtil.toJsonStr(json));
|
||||
}
|
||||
else {
|
||||
Map<String, Object> copy = new java.util.LinkedHashMap<>(body);
|
||||
copy.remove("id");
|
||||
row.setHotelJson(cn.hutool.json.JSONUtil.toJsonStr(copy));
|
||||
}
|
||||
Object name = body.get("hotelName");
|
||||
if (name != null) {
|
||||
row.setHotelName(String.valueOf(name));
|
||||
}
|
||||
Object address = body.get("address");
|
||||
if (address != null) {
|
||||
row.setAddress(String.valueOf(address));
|
||||
}
|
||||
Object license = body.get("licenseUrl");
|
||||
if (license == null) {
|
||||
license = body.get("businessLicense");
|
||||
}
|
||||
if (license != null) {
|
||||
row.setLicenseUrl(String.valueOf(license));
|
||||
}
|
||||
Object remark = body.get("remark");
|
||||
if (remark != null) {
|
||||
row.setRemark(String.valueOf(remark));
|
||||
}
|
||||
}
|
||||
return hotelApplyService.applyMine(row);
|
||||
}
|
||||
|
||||
@Operation(summary = "我的绑定酒店(App)")
|
||||
@GetMapping("/app/myHotels")
|
||||
public R<Page<SmHotelFxHotelBindEntity>> appMyHotels(Page<SmHotelFxHotelBindEntity> page) {
|
||||
return R.ok(agentService.pageMineHotels(page));
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package com.bsd.pjcloud.supermarket.controller;
|
||||
|
||||
import com.bsd.pjcloud.common.core.util.R;
|
||||
import com.bsd.pjcloud.common.security.annotation.Inner;
|
||||
import com.bsd.pjcloud.supermarket.service.SmHotelFxAgentService;
|
||||
import com.bsd.pjcloud.supermarket.vo.SmHotelFxInvitePreviewVO;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 酒店分销邀请内部接口:供 UPMS 邀请注册识别分销邀请码。
|
||||
*/
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/inner/smHotelFx/invite")
|
||||
@Tag(name = "酒店分销邀请-内部")
|
||||
public class SmHotelFxInviteInnerController {
|
||||
|
||||
private final SmHotelFxAgentService agentService;
|
||||
|
||||
@Inner
|
||||
@Operation(summary = "分销邀请码预览")
|
||||
@GetMapping("/preview")
|
||||
public R<SmHotelFxInvitePreviewVO> preview(@RequestParam String inviteCode) {
|
||||
return agentService.previewInvite(inviteCode);
|
||||
}
|
||||
|
||||
@Inner
|
||||
@Operation(summary = "注册后挂靠分销员")
|
||||
@PostMapping("/bindAgent")
|
||||
public R<Boolean> bindAgent(@RequestParam String inviteCode, @RequestParam Long userId,
|
||||
@RequestParam(required = false) String username, @RequestParam(required = false) String realName,
|
||||
@RequestParam(required = false) String mobile) {
|
||||
return agentService.createAgentByInvite(inviteCode, userId, username, realName, mobile);
|
||||
}
|
||||
}
|
||||
+33
-1
@@ -6,6 +6,7 @@ import com.bsd.pjcloud.supermarket.dto.SmHotelLocalDailyBatchDTO;
|
||||
import com.bsd.pjcloud.supermarket.dto.SmHotelSyncFailExcel;
|
||||
import com.bsd.pjcloud.supermarket.dto.SmHotelSyncFailLineVO;
|
||||
import com.bsd.pjcloud.supermarket.entity.SmHotelDailyEntity;
|
||||
import com.bsd.pjcloud.supermarket.entity.SmHotelPriceChannelConfigEntity;
|
||||
import com.bsd.pjcloud.supermarket.entity.SmHotelPriceConfigEntity;
|
||||
import com.bsd.pjcloud.supermarket.entity.SmHotelProductEntity;
|
||||
import com.bsd.pjcloud.supermarket.entity.SmHotelRoomEntity;
|
||||
@@ -29,6 +30,7 @@ import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -69,13 +71,43 @@ public class SmHotelMidController {
|
||||
}
|
||||
|
||||
@SysLog("全局同步酒店售价(仅第三方来源,按配置重算产品/日历,不动订单与本地酒店)")
|
||||
@Operation(summary = "按当前配置重算第三方酒店产品与日历卖价(排除数据来源=本地;不动订单)")
|
||||
@Operation(summary = "按当前全局配置重算第三方酒店默认卖价(排除本地;不动订单与下游渠道价)")
|
||||
@PostMapping("/priceConfig/resyncSell")
|
||||
@PreAuthorize("@pms.hasPermission('supermarket_smHotelPrice_edit')")
|
||||
public R<Map<String, Object>> resyncSellPrices() {
|
||||
return priceConfigService.resyncAllSellPrices();
|
||||
}
|
||||
|
||||
@Operation(summary = "下游渠道加价配置列表")
|
||||
@GetMapping("/priceConfig/channels")
|
||||
@PreAuthorize("@pms.hasPermission('supermarket_smHotelPrice_view')")
|
||||
public R<List<SmHotelPriceChannelConfigEntity>> listPriceChannels() {
|
||||
return priceConfigService.listChannelConfigs();
|
||||
}
|
||||
|
||||
@SysLog("保存酒店下游渠道加价")
|
||||
@Operation(summary = "保存下游渠道加价(有配置则对该伙伴按公式加价;删除后推原价)")
|
||||
@PutMapping("/priceConfig/channels")
|
||||
@PreAuthorize("@pms.hasPermission('supermarket_smHotelPrice_edit')")
|
||||
public R<Boolean> savePriceChannel(@RequestBody SmHotelPriceChannelConfigEntity entity) {
|
||||
return priceConfigService.saveChannelConfig(entity);
|
||||
}
|
||||
|
||||
@SysLog("删除酒店下游渠道加价")
|
||||
@Operation(summary = "删除下游渠道加价配置(之后该伙伴推/验协议价原价)")
|
||||
@DeleteMapping("/priceConfig/channels/{id}")
|
||||
@PreAuthorize("@pms.hasPermission('supermarket_smHotelPrice_edit')")
|
||||
public R<Boolean> deletePriceChannel(@PathVariable Long id) {
|
||||
return priceConfigService.deleteChannelConfig(id);
|
||||
}
|
||||
|
||||
@Operation(summary = "预览协议价在全局/各下游下的卖价")
|
||||
@GetMapping("/priceConfig/preview")
|
||||
@PreAuthorize("@pms.hasPermission('supermarket_smHotelPrice_view')")
|
||||
public R<Map<String, Object>> previewPrice(@RequestParam(required = false) BigDecimal basePrice) {
|
||||
return priceConfigService.previewSellPrices(basePrice);
|
||||
}
|
||||
|
||||
@Operation(summary = "酒店详情(含已拉取房型/产品/日历预览)")
|
||||
@GetMapping("/detail/{id}")
|
||||
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_view')")
|
||||
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
package com.bsd.pjcloud.supermarket.controller;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.json.JSONObject;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import com.bsd.pjcloud.common.security.annotation.Inner;
|
||||
import com.bsd.pjcloud.supermarket.integration.downstream.DownstreamInboundAccessLog;
|
||||
import com.bsd.pjcloud.supermarket.integration.downstream.DownstreamInboundAuth;
|
||||
import com.bsd.pjcloud.supermarket.service.SmHotelShengnuoOrderService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.function.BiFunction;
|
||||
|
||||
/**
|
||||
* 奈斯订单入站(与圣诺同协议、同验签;独立回调路径,伙伴 appid/secret 在「渠道对接配置」各自维护):
|
||||
* <pre>
|
||||
* POST /third/party/nice/order/createOrder
|
||||
* POST /third/party/nice/order/orderCancel
|
||||
* POST /third/party/nice/order/orderConfirmTimeOut
|
||||
* </pre>
|
||||
* 网关完整路径示例:https://域名/api/supermarket/third/party/nice/order/createOrder
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/third/party/nice/order")
|
||||
@Tag(name = "奈斯酒店订单对接")
|
||||
public class SmHotelNiceOrderController {
|
||||
|
||||
private final DownstreamInboundAuth inboundAuth;
|
||||
|
||||
private final SmHotelShengnuoOrderService shengnuoOrderService;
|
||||
|
||||
private final DownstreamInboundAccessLog inboundAccessLog;
|
||||
|
||||
@Inner(value = false)
|
||||
@Operation(summary = "奈斯推送下单")
|
||||
@PostMapping("/createOrder")
|
||||
public Map<String, Object> createOrder(HttpServletRequest request, @RequestBody String rawBody) {
|
||||
return handle(request, "inbound_create", rawBody, shengnuoOrderService::createOrder);
|
||||
}
|
||||
|
||||
@Inner(value = false)
|
||||
@Operation(summary = "奈斯推送取消")
|
||||
@PostMapping("/orderCancel")
|
||||
public Map<String, Object> orderCancel(HttpServletRequest request, @RequestBody String rawBody) {
|
||||
return handle(request, "inbound_cancel", rawBody, shengnuoOrderService::orderCancel);
|
||||
}
|
||||
|
||||
@Inner(value = false)
|
||||
@Operation(summary = "奈斯确认超时提醒")
|
||||
@PostMapping("/orderConfirmTimeOut")
|
||||
public Map<String, Object> orderConfirmTimeOut(HttpServletRequest request, @RequestBody String rawBody) {
|
||||
return handle(request, "inbound_timeout", rawBody, shengnuoOrderService::orderConfirmTimeOut);
|
||||
}
|
||||
|
||||
private Map<String, Object> handle(HttpServletRequest request, String action, String rawBody,
|
||||
BiFunction<String, JSONObject, Map<String, Object>> fn) {
|
||||
long t0 = System.currentTimeMillis();
|
||||
String partner = null;
|
||||
Map<String, Object> resp;
|
||||
try {
|
||||
partner = inboundAuth.verifyForShengnuo(rawBody);
|
||||
JSONObject body = JSONUtil.parseObj(rawBody);
|
||||
resp = fn.apply(partner, body);
|
||||
}
|
||||
catch (Exception e) {
|
||||
log.warn("|奈斯订单|{}|失败|{}", action, e.getMessage());
|
||||
resp = new LinkedHashMap<>(6);
|
||||
resp.put("code", 1);
|
||||
resp.put("success", false);
|
||||
resp.put("msg", StrUtil.blankToDefault(e.getMessage(), "处理失败"));
|
||||
resp.put("status", "error");
|
||||
resp.put("data", Map.of());
|
||||
}
|
||||
inboundAccessLog.record(partner, action, request.getRequestURI(), rawBody, resp,
|
||||
System.currentTimeMillis() - t0);
|
||||
return resp;
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -22,12 +22,13 @@ import java.util.Map;
|
||||
import java.util.function.BiFunction;
|
||||
|
||||
/**
|
||||
* 圣诺/奈斯订单入站(供应商回调地址,给对方配置):
|
||||
* 圣诺订单入站(供应商回调地址,给对方配置):
|
||||
* <pre>
|
||||
* POST /third/party/shengnuo/order/createOrder
|
||||
* POST /third/party/shengnuo/order/orderCancel
|
||||
* POST /third/party/shengnuo/order/orderConfirmTimeOut
|
||||
* </pre>
|
||||
* 奈斯同协议另见 {@link SmHotelNiceOrderController}(/third/party/nice/order/*)。
|
||||
* 网关完整路径示例:https://域名/api/supermarket/third/party/shengnuo/order/createOrder
|
||||
*/
|
||||
@Slf4j
|
||||
|
||||
+5
@@ -28,6 +28,11 @@ public class DownstreamHotelAdapterRegistry {
|
||||
byProtocol.put(key, adapter);
|
||||
}
|
||||
}
|
||||
// 奈斯与圣诺同接口:复用圣诺适配器,避免再声明继承 Bean 造成按类型注入冲突
|
||||
DownstreamHotelAdapter shengnuo = byProtocol.get(SmHotelDownstreamProtocol.SHENGNUO);
|
||||
if (shengnuo != null && !byProtocol.containsKey(SmHotelDownstreamProtocol.NICE)) {
|
||||
byProtocol.put(SmHotelDownstreamProtocol.NICE, shengnuo);
|
||||
}
|
||||
}
|
||||
|
||||
public DownstreamHotelAdapter require(String protocol) {
|
||||
|
||||
+1
-1
@@ -441,7 +441,7 @@ public class DownstreamInboundAuth {
|
||||
}
|
||||
}
|
||||
if (StrUtil.isBlank(partner) || StrUtil.isBlank(secret)) {
|
||||
throw new CheckedException("appid 未匹配到启用的圣诺下游配置");
|
||||
throw new CheckedException("appid 未匹配到启用的圣诺或奈斯下游配置");
|
||||
}
|
||||
if (!ShengnuoSignHelper.matches(appId, secret, template, sign)) {
|
||||
throw new CheckedException("签名错误");
|
||||
|
||||
+15
-5
@@ -187,14 +187,17 @@ public class HuanqiDownstreamHotelAdapter implements DownstreamHotelAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
/** 按 appKey + 接口组做分钟窗限流(多实例共享 Redis) */
|
||||
/**
|
||||
* 按 appKey + 接口组做分钟窗限流(多实例共享 Redis)。
|
||||
* 超限时必须 decrement 退回占用并等待,禁止「等超时仍继续」——否则会把对方打到「访问过于频繁」。
|
||||
*/
|
||||
private void acquireRatePermit(HotelDownstreamProperties props, String path) {
|
||||
int limit = resolveRateLimit(props, path);
|
||||
if (limit <= 0 || StrUtil.isBlank(props.getAppKey())) {
|
||||
return;
|
||||
}
|
||||
String group = rateGroup(path);
|
||||
for (int i = 0; i < 60; i++) {
|
||||
for (int i = 0; i < 120; i++) {
|
||||
long minute = System.currentTimeMillis() / 60_000L;
|
||||
String key = RATE_KEY + props.getAppKey() + ":" + group + ":" + minute;
|
||||
Long n = redisTemplate.opsForValue().increment(key);
|
||||
@@ -204,10 +207,17 @@ public class HuanqiDownstreamHotelAdapter implements DownstreamHotelAdapter {
|
||||
if (n == null || n <= limit) {
|
||||
return;
|
||||
}
|
||||
long wait = 60_000L - (System.currentTimeMillis() % 60_000L) + 20L;
|
||||
sleepQuiet(Math.min(wait, 5000L));
|
||||
// 超限:退回本次数,避免循环里反复 increment 把窗口计数打爆
|
||||
try {
|
||||
redisTemplate.opsForValue().decrement(key);
|
||||
}
|
||||
catch (Exception ignored) {
|
||||
// ignore
|
||||
}
|
||||
long wait = 60_000L - (System.currentTimeMillis() % 60_000L) + 80L;
|
||||
sleepQuiet(Math.min(Math.max(wait, 200L), 3000L));
|
||||
}
|
||||
log.warn("|{}|限流等待超时仍继续|appKey={}|path={}|limit={}|", TAG, props.getAppKey(), path, limit);
|
||||
throw new CheckedException("下游限流等待超时(" + group + "≤" + limit + "/min),请降低推送并发或错峰再试");
|
||||
}
|
||||
|
||||
private static int resolveRateLimit(HotelDownstreamProperties props, String path) {
|
||||
|
||||
+2
-1
@@ -20,7 +20,8 @@ import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 圣诺/奈斯下游:getInfo + pushHotelPlan + pushHotelPrice + pushHotelClose + orderChange。
|
||||
* 圣诺下游:getInfo + pushHotelPlan + pushHotelPrice + pushHotelClose + orderChange。
|
||||
* 奈斯协议复用本适配器(见 {@link com.bsd.pjcloud.supermarket.integration.downstream.DownstreamHotelAdapterRegistry})。
|
||||
* 签名 md5('ns'+appid+secretKey+template);成功 code=0。
|
||||
*/
|
||||
@Slf4j
|
||||
|
||||
+7
-3
@@ -30,6 +30,10 @@ public interface SmHotelDailyMapper extends PjcloudBaseMapper<SmHotelDailyEntity
|
||||
int physicalDeleteRange(@Param("hotelId") Long hotelId, @Param("fromDate") LocalDate fromDate,
|
||||
@Param("toExclusive") LocalDate toExclusive);
|
||||
|
||||
/** 清理过期日历(upsert 落库时顺带收口,避免关房行无限堆积) */
|
||||
@Delete("DELETE FROM canteen_hotel_daily WHERE hotel_id = #{hotelId} AND stay_date < #{beforeDate}")
|
||||
int physicalDeleteBefore(@Param("hotelId") Long hotelId, @Param("beforeDate") LocalDate beforeDate);
|
||||
|
||||
/**
|
||||
* 移动端列表:入住半开区间内有日历的酒店(DISTINCT,避免相关子查询扫大表)。
|
||||
*/
|
||||
@@ -102,18 +106,18 @@ public interface SmHotelDailyMapper extends PjcloudBaseMapper<SmHotelDailyEntity
|
||||
int insertBatch(@Param("list") List<SmHotelDailyEntity> list);
|
||||
|
||||
/**
|
||||
* 按底价重算日历卖价:sell = ROUND(cost * rate, 2)。仅第三方来源酒店;本地 sheet_name=local 不参与。
|
||||
* 按底价重算日历默认卖价:sell = ROUND(cost * rate + fixed, 2)。仅第三方来源;本地不参与。
|
||||
*/
|
||||
@Update("""
|
||||
UPDATE canteen_hotel_daily d
|
||||
INNER JOIN canteen_hotel h ON h.id = d.hotel_id AND h.del_flag = '0'
|
||||
SET d.sell_price = ROUND(d.cost_price * #{rate}, 2),
|
||||
SET d.sell_price = GREATEST(0.01, ROUND(d.cost_price * #{rate} + #{fixed}, 2)),
|
||||
d.update_time = NOW()
|
||||
WHERE d.del_flag = '0'
|
||||
AND d.cost_price IS NOT NULL
|
||||
AND (h.sheet_name IS NULL OR LOWER(TRIM(h.sheet_name)) <> 'local')
|
||||
""")
|
||||
int resyncSellPriceByCost(@Param("rate") BigDecimal rate);
|
||||
int resyncSellPriceByCost(@Param("rate") BigDecimal rate, @Param("fixed") BigDecimal fixed);
|
||||
|
||||
@Select("""
|
||||
<script>
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.bsd.pjcloud.supermarket.mapper;
|
||||
|
||||
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
|
||||
import com.bsd.pjcloud.supermarket.entity.SmHotelFxHotelApplyEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface SmHotelFxHotelApplyMapper extends PjcloudBaseMapper<SmHotelFxHotelApplyEntity> {
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.bsd.pjcloud.supermarket.mapper;
|
||||
|
||||
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
|
||||
import com.bsd.pjcloud.supermarket.entity.SmHotelPriceChannelConfigEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface SmHotelPriceChannelConfigMapper extends PjcloudBaseMapper<SmHotelPriceChannelConfigEntity> {
|
||||
}
|
||||
+3
-3
@@ -18,18 +18,18 @@ public interface SmHotelProductMapper extends PjcloudBaseMapper<SmHotelProductEn
|
||||
int insertBatch(@Param("list") List<SmHotelProductEntity> list);
|
||||
|
||||
/**
|
||||
* 按底价重算卖价:sell = ROUND(cost * rate, 2)。仅第三方来源酒店;本地 sheet_name=local 不参与。
|
||||
* 按底价重算默认卖价:sell = ROUND(cost * rate + fixed, 2)。仅第三方来源;本地不参与。
|
||||
*/
|
||||
@Update("""
|
||||
UPDATE canteen_hotel_product p
|
||||
INNER JOIN canteen_hotel h ON h.id = p.hotel_id AND h.del_flag = '0'
|
||||
SET p.sell_price = ROUND(p.cost_price * #{rate}, 2),
|
||||
SET p.sell_price = GREATEST(0.01, ROUND(p.cost_price * #{rate} + #{fixed}, 2)),
|
||||
p.update_time = NOW()
|
||||
WHERE p.del_flag = '0'
|
||||
AND p.cost_price IS NOT NULL
|
||||
AND (h.sheet_name IS NULL OR LOWER(TRIM(h.sheet_name)) <> 'local')
|
||||
""")
|
||||
int resyncSellPriceByCost(@Param("rate") BigDecimal rate);
|
||||
int resyncSellPriceByCost(@Param("rate") BigDecimal rate, @Param("fixed") BigDecimal fixed);
|
||||
|
||||
@Select("""
|
||||
<script>
|
||||
|
||||
+12
@@ -23,10 +23,22 @@ public interface SmHotelFxAgentService extends IService<SmHotelFxAgentEntity> {
|
||||
|
||||
Page<SmHotelFxHotelBindEntity> pageBinds(Page<SmHotelFxHotelBindEntity> page, Long agentId, Long hotelId);
|
||||
|
||||
/** 批量查酒店当前 BOUND 绑定(审核选店用) */
|
||||
java.util.List<SmHotelFxHotelBindEntity> listBoundByHotelIds(java.util.Collection<Long> hotelIds);
|
||||
|
||||
/** App:当前分销员已绑定酒店 */
|
||||
Page<SmHotelFxHotelBindEntity> pageMineHotels(Page<SmHotelFxHotelBindEntity> page);
|
||||
|
||||
SmHotelFxAgentEntity getByUserId(Long userId);
|
||||
|
||||
SmHotelFxAgentEntity getBoundAgentByHotelId(Long hotelId);
|
||||
|
||||
/** 分销邀请码预览;无效返回 failed */
|
||||
R<com.bsd.pjcloud.supermarket.vo.SmHotelFxInvitePreviewVO> previewInvite(String inviteCode);
|
||||
|
||||
/** 注册成功后按邀请码创建分销员并挂上级 */
|
||||
R<Boolean> createAgentByInvite(String inviteCode, Long userId, String username, String realName, String mobile);
|
||||
|
||||
/** 锁定佣金后:累加终身晋升分,并尝试 4→3 / 3→2 自动晋升 */
|
||||
void onPromoteBaseLocked(Long ownerAgentId, BigDecimal promoteBaseAmount);
|
||||
|
||||
|
||||
+10
@@ -6,6 +6,7 @@ import com.bsd.pjcloud.common.core.util.R;
|
||||
import com.bsd.pjcloud.supermarket.entity.SmHotelFxCommissionEntity;
|
||||
import com.bsd.pjcloud.supermarket.entity.SmHotelOrderEntity;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
@@ -27,5 +28,14 @@ public interface SmHotelFxCommissionService extends IService<SmHotelFxCommission
|
||||
/** App:当前登录用户作为分销员的佣金汇总 + 分页 */
|
||||
R<Map<String, Object>> mySummary();
|
||||
|
||||
/** App:直接下级绩效汇总(佣金归下级本人,上级仅查看) */
|
||||
R<Map<String, Object>> downlinePerf();
|
||||
|
||||
Page<SmHotelFxCommissionEntity> pageMine(Page<SmHotelFxCommissionEntity> page, String status);
|
||||
|
||||
/**
|
||||
* 管理端联调:对已绑定分销员的酒店模拟一笔订单并记预计佣金。
|
||||
* lockNow=true 时退房日设为昨天并立即锁定,便于测提现链路。
|
||||
*/
|
||||
R<Map<String, Object>> mockOrderForBoundHotel(Long hotelId, BigDecimal costAmount, Boolean lockNow);
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.bsd.pjcloud.supermarket.service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.bsd.pjcloud.common.core.util.R;
|
||||
import com.bsd.pjcloud.supermarket.entity.SmHotelFxHotelApplyEntity;
|
||||
|
||||
public interface SmHotelFxHotelApplyService extends IService<SmHotelFxHotelApplyEntity> {
|
||||
|
||||
Page<SmHotelFxHotelApplyEntity> pageAdmin(Page<SmHotelFxHotelApplyEntity> page, SmHotelFxHotelApplyEntity query);
|
||||
|
||||
Page<SmHotelFxHotelApplyEntity> pageMine(Page<SmHotelFxHotelApplyEntity> page, String status);
|
||||
|
||||
R<SmHotelFxHotelApplyEntity> applyMine(SmHotelFxHotelApplyEntity body);
|
||||
|
||||
/** App:我的提报详情(编辑回填) */
|
||||
R<SmHotelFxHotelApplyEntity> getMine(Long id);
|
||||
|
||||
/** 当前分销员待审提报数 */
|
||||
long countPendingMine();
|
||||
|
||||
/**
|
||||
* 审核。通过时必须传 hotelId(后台已完善的酒店)和 auditRemark(含账号密码,分销员可见)。
|
||||
*/
|
||||
R<Boolean> audit(Long id, boolean approved, String auditRemark, Long hotelId);
|
||||
}
|
||||
+46
-9
@@ -1,9 +1,11 @@
|
||||
package com.bsd.pjcloud.supermarket.service;
|
||||
|
||||
import com.bsd.pjcloud.supermarket.entity.SmHotelPriceConfigEntity;
|
||||
import com.bsd.pjcloud.common.core.util.R;
|
||||
import com.bsd.pjcloud.supermarket.entity.SmHotelPriceChannelConfigEntity;
|
||||
import com.bsd.pjcloud.supermarket.entity.SmHotelPriceConfigEntity;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public interface SmHotelPriceConfigService {
|
||||
@@ -12,18 +14,53 @@ public interface SmHotelPriceConfigService {
|
||||
|
||||
R<Boolean> saveOrUpdateConfig(SmHotelPriceConfigEntity entity);
|
||||
|
||||
/** 当前售价调整百分比(正加价/负折扣),未配置返回 0 */
|
||||
/** 当前全局加价百分比(正加价/负折扣),未配置返回 0 */
|
||||
BigDecimal currentMarkupPercent();
|
||||
|
||||
/** 底价按当前配置计算卖价:cost × (1 + percent/100) */
|
||||
BigDecimal applyMarkup(BigDecimal costPrice);
|
||||
|
||||
/** 用指定百分比计算卖价(不读库) */
|
||||
BigDecimal applyMarkup(BigDecimal costPrice, BigDecimal percent);
|
||||
/** 当前全局固定加价金额,未配置返回 0 */
|
||||
BigDecimal currentMarkupFixed();
|
||||
|
||||
/**
|
||||
* 按当前配置重算全部产品/日历卖价(仅 sell_price,不动订单)。
|
||||
* @return productUpdated / dailyUpdated / markupPercent
|
||||
* 底价按全局配置计算默认卖价(App/无 partner):cost × (1 + p/100) + fixed。
|
||||
*/
|
||||
BigDecimal applyMarkup(BigDecimal costPrice);
|
||||
|
||||
/** 兼容旧调用:仅百分比 */
|
||||
BigDecimal applyMarkup(BigDecimal costPrice, BigDecimal percent);
|
||||
|
||||
/** 统一公式:sell = ROUND(base × (1 + percent/100) + fixed, 2),下限 0.01 */
|
||||
BigDecimal applyMarkup(BigDecimal costPrice, BigDecimal percent, BigDecimal fixed);
|
||||
|
||||
/**
|
||||
* 按下游伙伴解析卖价(2A):
|
||||
* <ul>
|
||||
* <li>partner 有启用渠道配置 → 仅用该渠道 percent/fixed 作用在底价</li>
|
||||
* <li>partner 非空但无渠道配置 → 返回底价原价</li>
|
||||
* <li>partner 为空(App/内部)→ 用全局 percent/fixed</li>
|
||||
* </ul>
|
||||
*/
|
||||
BigDecimal resolveSellPrice(BigDecimal basePrice, String partnerCode);
|
||||
|
||||
/** 解析某伙伴实际使用的 percent/fixed;无渠道配置且有 partner 时 percent=0,fixed=0(原价) */
|
||||
MarkupRule resolveMarkupRule(String partnerCode);
|
||||
|
||||
R<List<SmHotelPriceChannelConfigEntity>> listChannelConfigs();
|
||||
|
||||
R<Boolean> saveChannelConfig(SmHotelPriceChannelConfigEntity entity);
|
||||
|
||||
R<Boolean> deleteChannelConfig(Long id);
|
||||
|
||||
/**
|
||||
* 对给定协议价预览:默认卖价 + 各已启用下游卖价。
|
||||
*/
|
||||
R<Map<String, Object>> previewSellPrices(BigDecimal basePrice);
|
||||
|
||||
/**
|
||||
* 按全局配置重算落库默认卖价(仅 sell_price,不动订单;不含下游渠道价)。
|
||||
*/
|
||||
R<Map<String, Object>> resyncAllSellPrices();
|
||||
|
||||
/** 加价规则快照 */
|
||||
record MarkupRule(BigDecimal percent, BigDecimal fixed, boolean fromChannel, String partnerCode) {
|
||||
}
|
||||
}
|
||||
+6
@@ -12,6 +12,12 @@ public interface SmHotelService extends IService<SmHotelEntity> {
|
||||
|
||||
Page<SmHotelEntity> pageScope(Page<SmHotelEntity> page, LambdaQueryWrapper<SmHotelEntity> wrapper);
|
||||
|
||||
/**
|
||||
* 同 {@link #pageScope},但不做 COUNT(*)(店名模糊搜索时 COUNT+LIKE 会扫十几万行超时)。
|
||||
* total 用 hasMore 估算,便于前端翻页。
|
||||
*/
|
||||
Page<SmHotelEntity> pageScopeWithoutCount(Page<SmHotelEntity> page, LambdaQueryWrapper<SmHotelEntity> wrapper);
|
||||
|
||||
/**
|
||||
* 管理端列表可售筛选:1/SALEABLE=近窗有开房可售库存;0/UNSALEABLE=近窗无可售。
|
||||
*/
|
||||
|
||||
+3
@@ -409,6 +409,9 @@ public class SmHotelChannelConfigServiceImpl extends ServiceImpl<SmHotelChannelC
|
||||
if (dto.getResourceAutoPushEnabled() != null) {
|
||||
target.setResourceAutoPushEnabled(dto.getResourceAutoPushEnabled());
|
||||
}
|
||||
if (dto.getResourcePushConcurrency() != null && dto.getResourcePushConcurrency() > 0) {
|
||||
target.setResourcePushConcurrency(dto.getResourcePushConcurrency());
|
||||
}
|
||||
}
|
||||
|
||||
private void overlayShangyou(ShangyouHotelProperties target, SmHotelShangyouChannelConfigDTO dto) {
|
||||
|
||||
+4
-2
@@ -59,7 +59,8 @@ public class SmHotelDownstreamInboundRetryServiceImpl implements SmHotelDownstre
|
||||
long t0 = System.currentTimeMillis();
|
||||
Map<String, Object> resp;
|
||||
try {
|
||||
if (SmHotelDownstreamProtocol.isShengnuo(protocol) || urlContains(url, "shengnuo")) {
|
||||
if (SmHotelDownstreamProtocol.isShengnuo(protocol) || urlContains(url, "shengnuo")
|
||||
|| urlContains(url, "/third/party/nice/")) {
|
||||
resp = replayShengnuo(partner, action, row.getRequestBody());
|
||||
if (StrUtil.isBlank(partner)) {
|
||||
partner = peekShengnuoPartner(row.getRequestBody());
|
||||
@@ -174,7 +175,8 @@ public class SmHotelDownstreamInboundRetryServiceImpl implements SmHotelDownstre
|
||||
return SmHotelDownstreamProtocol.normalize(p.getProtocol());
|
||||
}
|
||||
}
|
||||
if (urlContains(url, "shengnuo") || "inbound_timeout".equals(action)) {
|
||||
if (urlContains(url, "shengnuo") || urlContains(url, "/third/party/nice/")
|
||||
|| "inbound_timeout".equals(action)) {
|
||||
return SmHotelDownstreamProtocol.SHENGNUO;
|
||||
}
|
||||
if (urlContains(url, "huiding") || "inbound_notify".equals(action)) {
|
||||
|
||||
+245
-17
@@ -7,7 +7,11 @@ import cn.hutool.json.JSONUtil;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.bsd.pjcloud.admin.api.entity.SysUser;
|
||||
import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
|
||||
import com.bsd.pjcloud.common.core.util.R;
|
||||
import com.bsd.pjcloud.common.security.service.PjcloudUser;
|
||||
import com.bsd.pjcloud.common.security.util.SecurityUtils;
|
||||
import com.bsd.pjcloud.supermarket.dto.SmHotelFxLevelRuleDTO;
|
||||
import com.bsd.pjcloud.supermarket.entity.SmHotelEntity;
|
||||
import com.bsd.pjcloud.supermarket.entity.SmHotelFxAgentEntity;
|
||||
@@ -25,6 +29,8 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -44,6 +50,9 @@ public class SmHotelFxAgentServiceImpl extends ServiceImpl<SmHotelFxAgentMapper,
|
||||
|
||||
private final SmHotelFxConfigService configService;
|
||||
|
||||
/** 姓名/手机以系统用户为准,冗余字段保存与列表展示时同步刷新 */
|
||||
private final RemoteUserService remoteUserService;
|
||||
|
||||
@Override
|
||||
public Page<SmHotelFxAgentEntity> pageAgents(Page<SmHotelFxAgentEntity> page, SmHotelFxAgentEntity query) {
|
||||
Page<SmHotelFxAgentEntity> result = page(page, Wrappers.<SmHotelFxAgentEntity>lambdaQuery()
|
||||
@@ -62,6 +71,8 @@ public class SmHotelFxAgentServiceImpl extends ServiceImpl<SmHotelFxAgentMapper,
|
||||
if (list == null || list.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
// 列表展示跟系统用户最新资料对齐,并回写已漂移的冗余字段
|
||||
syncProfileFromUsers(list, true);
|
||||
List<Long> parentIds = list.stream().map(SmHotelFxAgentEntity::getParentId).filter(Objects::nonNull).distinct()
|
||||
.toList();
|
||||
Map<Long, String> parentName = Map.of();
|
||||
@@ -82,6 +93,74 @@ public class SmHotelFxAgentServiceImpl extends ServiceImpl<SmHotelFxAgentMapper,
|
||||
}
|
||||
}
|
||||
|
||||
/** 按 userId 拉系统用户,覆盖账号/姓名/手机;persist=true 时把变更写回分销员表 */
|
||||
private void syncProfileFromUsers(List<SmHotelFxAgentEntity> agents, boolean persist) {
|
||||
if (CollUtil.isEmpty(agents)) {
|
||||
return;
|
||||
}
|
||||
List<Long> userIds = agents.stream().map(SmHotelFxAgentEntity::getUserId).filter(Objects::nonNull).distinct()
|
||||
.toList();
|
||||
if (userIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Map<Long, SysUser> userMap = loadUserMap(userIds);
|
||||
if (userMap.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<SmHotelFxAgentEntity> dirty = new ArrayList<>();
|
||||
for (SmHotelFxAgentEntity a : agents) {
|
||||
SysUser u = a.getUserId() == null ? null : userMap.get(a.getUserId());
|
||||
if (u == null) {
|
||||
continue;
|
||||
}
|
||||
String username = StrUtil.blankToDefault(u.getUsername(), a.getUsername());
|
||||
String realName = StrUtil.blankToDefault(u.getName(),
|
||||
StrUtil.blankToDefault(u.getNickname(), a.getRealName()));
|
||||
String mobile = StrUtil.blankToDefault(u.getPhone(), a.getMobile());
|
||||
boolean changed = !Objects.equals(username, a.getUsername()) || !Objects.equals(realName, a.getRealName())
|
||||
|| !Objects.equals(mobile, a.getMobile());
|
||||
a.setUsername(username);
|
||||
a.setRealName(realName);
|
||||
a.setMobile(mobile);
|
||||
if (persist && changed && a.getId() != null) {
|
||||
dirty.add(a);
|
||||
}
|
||||
}
|
||||
if (persist && !dirty.isEmpty()) {
|
||||
updateBatchById(dirty);
|
||||
}
|
||||
}
|
||||
|
||||
private Map<Long, SysUser> loadUserMap(List<Long> userIds) {
|
||||
try {
|
||||
R<List<SysUser>> resp = remoteUserService.getInnerUserById(userIds);
|
||||
List<SysUser> users = resp != null && resp.isOk() ? resp.getData() : null;
|
||||
if (CollUtil.isEmpty(users)) {
|
||||
return Map.of();
|
||||
}
|
||||
return users.stream().filter(u -> u != null && u.getUserId() != null)
|
||||
.collect(Collectors.toMap(SysUser::getUserId, u -> u, (a, b) -> a));
|
||||
}
|
||||
catch (Exception e) {
|
||||
log.warn("|酒店分销|同步用户资料失败|err={}|", e.getMessage());
|
||||
return Map.of();
|
||||
}
|
||||
}
|
||||
|
||||
private void applyUserProfile(SmHotelFxAgentEntity agent) {
|
||||
if (agent == null || agent.getUserId() == null) {
|
||||
return;
|
||||
}
|
||||
Map<Long, SysUser> map = loadUserMap(List.of(agent.getUserId()));
|
||||
SysUser u = map.get(agent.getUserId());
|
||||
if (u == null) {
|
||||
return;
|
||||
}
|
||||
agent.setUsername(u.getUsername());
|
||||
agent.setRealName(StrUtil.blankToDefault(u.getName(), u.getNickname()));
|
||||
agent.setMobile(u.getPhone());
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public R<SmHotelFxAgentEntity> saveAgent(SmHotelFxAgentEntity body) {
|
||||
@@ -96,21 +175,28 @@ public class SmHotelFxAgentServiceImpl extends ServiceImpl<SmHotelFxAgentMapper,
|
||||
if (StrUtil.isBlank(body.getStatus())) {
|
||||
body.setStatus("ACTIVE");
|
||||
}
|
||||
// 姓名/账号/手机一律跟系统用户,不吃前端手填
|
||||
applyUserProfile(body);
|
||||
if (StrUtil.isBlank(body.getRealName())) {
|
||||
return R.failed("系统用户缺少姓名,请先在用户管理补全");
|
||||
}
|
||||
if (body.getId() == null) {
|
||||
Long exists = count(Wrappers.<SmHotelFxAgentEntity>lambdaQuery()
|
||||
.eq(SmHotelFxAgentEntity::getUserId, body.getUserId()));
|
||||
if (exists != null && exists > 0) {
|
||||
return R.failed("该用户已是分销员");
|
||||
}
|
||||
if (StrUtil.isBlank(body.getInviteCode())) {
|
||||
body.setInviteCode("FX" + IdUtil.getSnowflakeNextIdStr().substring(10));
|
||||
}
|
||||
// 邀请码固定 FX 前缀,注册页按前缀与部门邀请码分流
|
||||
body.setInviteCode(normalizeFxInviteCode(body.getInviteCode()));
|
||||
if (body.getPromoteScore() == null) {
|
||||
body.setPromoteScore(BigDecimal.ZERO);
|
||||
}
|
||||
if (body.getDemoteWindowScore() == null) {
|
||||
body.setDemoteWindowScore(BigDecimal.ZERO);
|
||||
}
|
||||
body.setPayMethod(StrUtil.trim(body.getPayMethod()));
|
||||
body.setPayAccount(StrUtil.trim(body.getPayAccount()));
|
||||
body.setPayAccountName(StrUtil.trim(body.getPayAccountName()));
|
||||
if (body.getParentId() != null && (body.getLevelNo() == null || body.getLevelNo() == 4)) {
|
||||
SmHotelFxAgentEntity parent = getById(body.getParentId());
|
||||
if (parent != null && Integer.valueOf(4).equals(parent.getLevelNo())) {
|
||||
@@ -128,16 +214,29 @@ public class SmHotelFxAgentServiceImpl extends ServiceImpl<SmHotelFxAgentMapper,
|
||||
if (old == null) {
|
||||
return R.failed("分销员不存在");
|
||||
}
|
||||
// 允许纠正 userId(前端曾 Number() 丢精度时需重绑);换绑时不可撞到其他分销员
|
||||
if (body.getUserId() != null && !Objects.equals(body.getUserId(), old.getUserId())) {
|
||||
Long clash = count(Wrappers.<SmHotelFxAgentEntity>lambdaQuery()
|
||||
.eq(SmHotelFxAgentEntity::getUserId, body.getUserId())
|
||||
.ne(SmHotelFxAgentEntity::getId, old.getId()));
|
||||
if (clash != null && clash > 0) {
|
||||
return R.failed("该用户已是分销员");
|
||||
}
|
||||
old.setUserId(body.getUserId());
|
||||
}
|
||||
Integer prevLevel = old.getLevelNo();
|
||||
old.setUsername(body.getUsername());
|
||||
old.setRealName(body.getRealName());
|
||||
old.setMobile(body.getMobile());
|
||||
old.setUsername(body.getUsername());
|
||||
old.setLevelNo(body.getLevelNo());
|
||||
old.setParentId(body.getParentId());
|
||||
old.setCityAdcode(body.getCityAdcode());
|
||||
old.setRegionJson(body.getRegionJson());
|
||||
old.setStatus(body.getStatus());
|
||||
old.setRemark(body.getRemark());
|
||||
old.setPayMethod(StrUtil.trim(body.getPayMethod()));
|
||||
old.setPayAccount(StrUtil.trim(body.getPayAccount()));
|
||||
old.setPayAccountName(StrUtil.trim(body.getPayAccountName()));
|
||||
// 人工调到二级:开启保级窗口;从二级调走:清窗口
|
||||
if (Integer.valueOf(2).equals(old.getLevelNo()) && !Integer.valueOf(2).equals(prevLevel)) {
|
||||
initLevel2DemoteWindow(old);
|
||||
@@ -209,19 +308,24 @@ public class SmHotelFxAgentServiceImpl extends ServiceImpl<SmHotelFxAgentMapper,
|
||||
row.setAuditRemark(remark);
|
||||
row.setCreateTime(now);
|
||||
bindMapper.insert(row);
|
||||
return R.ok(Boolean.TRUE);
|
||||
}
|
||||
else {
|
||||
boolean rebound = !"BOUND".equals(exist.getBindStatus())
|
||||
|| !Objects.equals(exist.getAgentId(), agentId);
|
||||
exist.setAgentId(agentId);
|
||||
exist.setBindStatus("BOUND");
|
||||
exist.setAuditRemark(remark);
|
||||
// 解绑后再绑 / 换绑:视为「新审核酒店」,保级窗口可计入
|
||||
if (rebound) {
|
||||
exist.setCreateTime(now);
|
||||
}
|
||||
bindMapper.updateById(exist);
|
||||
// 一店一员:已绑定其他分销员不可直接换绑(须先解绑或走退出重分配)
|
||||
if ("BOUND".equals(exist.getBindStatus()) && !Objects.equals(exist.getAgentId(), agentId)) {
|
||||
SmHotelFxAgentEntity occupied = getById(exist.getAgentId());
|
||||
String who = occupied == null ? String.valueOf(exist.getAgentId())
|
||||
: StrUtil.blankToDefault(occupied.getRealName(), occupied.getUsername());
|
||||
return R.failed("该酒店已绑定分销员「" + who + "」,请先解绑后再绑定");
|
||||
}
|
||||
boolean rebound = !"BOUND".equals(exist.getBindStatus());
|
||||
exist.setAgentId(agentId);
|
||||
exist.setBindStatus("BOUND");
|
||||
exist.setAuditRemark(remark);
|
||||
// 解绑后再绑:视为「新审核酒店」,保级窗口可计入
|
||||
if (rebound) {
|
||||
exist.setCreateTime(now);
|
||||
}
|
||||
bindMapper.updateById(exist);
|
||||
return R.ok(Boolean.TRUE);
|
||||
}
|
||||
|
||||
@@ -247,7 +351,44 @@ public class SmHotelFxAgentServiceImpl extends ServiceImpl<SmHotelFxAgentMapper,
|
||||
.eq(hotelId != null, SmHotelFxHotelBindEntity::getHotelId, hotelId)
|
||||
.eq(SmHotelFxHotelBindEntity::getBindStatus, "BOUND")
|
||||
.orderByDesc(SmHotelFxHotelBindEntity::getId));
|
||||
for (SmHotelFxHotelBindEntity b : result.getRecords()) {
|
||||
fillBindDisplay(result.getRecords());
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<SmHotelFxHotelBindEntity> listBoundByHotelIds(Collection<Long> hotelIds) {
|
||||
if (hotelIds == null || hotelIds.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<Long> ids = hotelIds.stream().filter(Objects::nonNull).distinct().collect(Collectors.toList());
|
||||
if (ids.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<SmHotelFxHotelBindEntity> list = bindMapper.selectList(Wrappers.<SmHotelFxHotelBindEntity>lambdaQuery()
|
||||
.in(SmHotelFxHotelBindEntity::getHotelId, ids)
|
||||
.eq(SmHotelFxHotelBindEntity::getBindStatus, "BOUND"));
|
||||
fillBindDisplay(list);
|
||||
return list;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Page<SmHotelFxHotelBindEntity> pageMineHotels(Page<SmHotelFxHotelBindEntity> page) {
|
||||
PjcloudUser user = SecurityUtils.getUser();
|
||||
if (user == null || user.getId() == null) {
|
||||
return page;
|
||||
}
|
||||
SmHotelFxAgentEntity me = getByUserId(user.getId());
|
||||
if (me == null) {
|
||||
return page;
|
||||
}
|
||||
return pageBinds(page, me.getId(), null);
|
||||
}
|
||||
|
||||
private void fillBindDisplay(List<SmHotelFxHotelBindEntity> list) {
|
||||
if (CollUtil.isEmpty(list)) {
|
||||
return;
|
||||
}
|
||||
for (SmHotelFxHotelBindEntity b : list) {
|
||||
SmHotelEntity h = hotelMapper.selectById(b.getHotelId());
|
||||
if (h != null) {
|
||||
b.setHotelName(h.getHotelName());
|
||||
@@ -258,7 +399,6 @@ public class SmHotelFxAgentServiceImpl extends ServiceImpl<SmHotelFxAgentMapper,
|
||||
b.setAgentName(StrUtil.blankToDefault(a.getRealName(), a.getUsername()));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -291,6 +431,94 @@ public class SmHotelFxAgentServiceImpl extends ServiceImpl<SmHotelFxAgentMapper,
|
||||
return agent;
|
||||
}
|
||||
|
||||
@Override
|
||||
public R<com.bsd.pjcloud.supermarket.vo.SmHotelFxInvitePreviewVO> previewInvite(String inviteCode) {
|
||||
SmHotelFxAgentEntity parent = findActiveByInviteCode(inviteCode);
|
||||
if (parent == null) {
|
||||
return R.failed("分销邀请码错误或已失效");
|
||||
}
|
||||
Integer lv = parent.getLevelNo();
|
||||
if (lv == null || lv < 1 || lv > 4) {
|
||||
return R.failed("该邀请人层级无法邀请下级(仅 1~4 级可邀请)");
|
||||
}
|
||||
int childLevel = lv <= 3 ? 4 : 5;
|
||||
Long deptId = null;
|
||||
try {
|
||||
Map<Long, SysUser> map = loadUserMap(List.of(parent.getUserId()));
|
||||
SysUser u = map.get(parent.getUserId());
|
||||
if (u != null) {
|
||||
deptId = u.getDeptId();
|
||||
}
|
||||
}
|
||||
catch (Exception e) {
|
||||
log.warn("|酒店分销|邀请预览取部门失败|agentId={}|{}", parent.getId(), e.getMessage());
|
||||
}
|
||||
com.bsd.pjcloud.supermarket.vo.SmHotelFxInvitePreviewVO vo = new com.bsd.pjcloud.supermarket.vo.SmHotelFxInvitePreviewVO();
|
||||
vo.setInviteCode(parent.getInviteCode());
|
||||
vo.setParentAgentId(parent.getId());
|
||||
vo.setParentUserId(parent.getUserId());
|
||||
vo.setDeptId(deptId);
|
||||
vo.setParentName(StrUtil.blankToDefault(parent.getRealName(), parent.getUsername()));
|
||||
vo.setParentLevel(lv);
|
||||
vo.setChildLevel(childLevel);
|
||||
vo.setHint(StrUtil.format("加入 {} 的分销团队,将成为{}级分销员", vo.getParentName(), childLevel));
|
||||
return R.ok(vo);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public R<Boolean> createAgentByInvite(String inviteCode, Long userId, String username, String realName,
|
||||
String mobile) {
|
||||
if (userId == null) {
|
||||
return R.failed("用户为空");
|
||||
}
|
||||
SmHotelFxAgentEntity exist = getOne(Wrappers.<SmHotelFxAgentEntity>lambdaQuery()
|
||||
.eq(SmHotelFxAgentEntity::getUserId, userId)
|
||||
.last("limit 1"), false);
|
||||
if (exist != null) {
|
||||
return R.failed("该用户已是分销员");
|
||||
}
|
||||
R<com.bsd.pjcloud.supermarket.vo.SmHotelFxInvitePreviewVO> preview = previewInvite(inviteCode);
|
||||
if (preview == null || preview.getCode() != 0 || preview.getData() == null) {
|
||||
return R.failed(preview == null ? "邀请码无效" : StrUtil.blankToDefault(preview.getMsg(), "邀请码无效"));
|
||||
}
|
||||
com.bsd.pjcloud.supermarket.vo.SmHotelFxInvitePreviewVO p = preview.getData();
|
||||
SmHotelFxAgentEntity body = new SmHotelFxAgentEntity();
|
||||
body.setUserId(userId);
|
||||
body.setUsername(username);
|
||||
body.setRealName(StrUtil.blankToDefault(realName, username));
|
||||
body.setMobile(mobile);
|
||||
body.setLevelNo(p.getChildLevel());
|
||||
body.setParentId(p.getParentAgentId());
|
||||
body.setStatus("ACTIVE");
|
||||
body.setPromoteScore(BigDecimal.ZERO);
|
||||
body.setDemoteWindowScore(BigDecimal.ZERO);
|
||||
body.setInviteCode(normalizeFxInviteCode(null));
|
||||
save(body);
|
||||
log.info("|酒店分销|邀请注册挂靠|userId={}|parent={}|level={}|", userId, p.getParentAgentId(),
|
||||
p.getChildLevel());
|
||||
return R.ok(Boolean.TRUE);
|
||||
}
|
||||
|
||||
/** 空则生成;非空则补齐 FX 前缀,保证与部门邀请码格式互斥 */
|
||||
private String normalizeFxInviteCode(String inviteCode) {
|
||||
if (StrUtil.isBlank(inviteCode)) {
|
||||
return "FX" + IdUtil.getSnowflakeNextIdStr().substring(10);
|
||||
}
|
||||
String code = inviteCode.trim();
|
||||
return StrUtil.startWithIgnoreCase(code, "FX") ? code : "FX" + code;
|
||||
}
|
||||
|
||||
private SmHotelFxAgentEntity findActiveByInviteCode(String inviteCode) {
|
||||
if (StrUtil.isBlank(inviteCode)) {
|
||||
return null;
|
||||
}
|
||||
return getOne(Wrappers.<SmHotelFxAgentEntity>lambdaQuery()
|
||||
.eq(SmHotelFxAgentEntity::getInviteCode, inviteCode.trim())
|
||||
.eq(SmHotelFxAgentEntity::getStatus, "ACTIVE")
|
||||
.last("limit 1"), false);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void onPromoteBaseLocked(Long ownerAgentId, BigDecimal promoteBaseAmount) {
|
||||
|
||||
+242
-11
@@ -95,18 +95,18 @@ public class SmHotelFxCommissionServiceImpl extends ServiceImpl<SmHotelFxCommiss
|
||||
.collect(Collectors.toMap(SmHotelFxLevelRuleDTO::getLevel, r -> r, (a, b) -> a));
|
||||
|
||||
// 开发员向上链路:每人只拿「自身当前层级」对应份额(缺层则该份额不发)
|
||||
// 特例:五级开发酒店 → 开发佣金归五级(按四级份额);其四级上级本单不拿佣金(仅下线绩效)
|
||||
List<SmHotelFxAgentEntity> chain = buildUplineChain(owner);
|
||||
Map<String, Object> snapshot = buildSnapshot(cfg, rules, cost, pool, promoteBase, owner.getId());
|
||||
|
||||
List<SmHotelFxCommissionEntity> rows = new ArrayList<>();
|
||||
for (SmHotelFxAgentEntity agent : chain) {
|
||||
int lv = agent.getLevelNo() == null ? 0 : agent.getLevelNo();
|
||||
SmHotelFxLevelRuleDTO rule = ruleByLevel.get(lv);
|
||||
if (rule == null || rule.getShareOfPool() == null
|
||||
|| rule.getShareOfPool().compareTo(BigDecimal.ZERO) <= 0) {
|
||||
BigDecimal share = resolveShareOfPool(owner, agent, ruleByLevel);
|
||||
if (share == null || share.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
continue;
|
||||
}
|
||||
BigDecimal amount = pool.multiply(rule.getShareOfPool()).setScale(4, RoundingMode.HALF_UP);
|
||||
BigDecimal amount = pool.multiply(share).setScale(4, RoundingMode.HALF_UP);
|
||||
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
continue;
|
||||
}
|
||||
@@ -119,13 +119,15 @@ public class SmHotelFxCommissionServiceImpl extends ServiceImpl<SmHotelFxCommiss
|
||||
row.setAgentLevel(lv);
|
||||
row.setCostAmount(cost);
|
||||
row.setPoolAmount(pool);
|
||||
row.setShareRate(rule.getShareOfPool());
|
||||
row.setShareRate(share);
|
||||
row.setCommissionAmount(amount);
|
||||
row.setPromoteBaseAmount(promoteBase);
|
||||
row.setStatus(ST_EST);
|
||||
row.setCheckOutDate(order.getCheckOutDate());
|
||||
row.setConfigSnapshot(JSONUtil.toJsonStr(snapshot));
|
||||
row.setRemark("预计佣金");
|
||||
row.setRemark(Objects.equals(agent.getId(), owner.getId()) && Integer.valueOf(5).equals(owner.getLevelNo())
|
||||
? "预计佣金(五级开发按四级份额)"
|
||||
: "预计佣金");
|
||||
rows.add(row);
|
||||
}
|
||||
if (rows.isEmpty()) {
|
||||
@@ -136,6 +138,37 @@ public class SmHotelFxCommissionServiceImpl extends ServiceImpl<SmHotelFxCommiss
|
||||
rows.size(), pool);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析某人在本单应得份额。
|
||||
* <ul>
|
||||
* <li>开发员为五级:本人按四级 shareOfPool 计开发佣金</li>
|
||||
* <li>开发员为五级:链上四级上级本单份额跳过(佣金归五级,四级只看下线绩效)</li>
|
||||
* <li>其余:按自身 level 的 shareOfPool</li>
|
||||
* </ul>
|
||||
*/
|
||||
private BigDecimal resolveShareOfPool(SmHotelFxAgentEntity owner, SmHotelFxAgentEntity agent,
|
||||
Map<Integer, SmHotelFxLevelRuleDTO> ruleByLevel) {
|
||||
if (owner == null || agent == null) {
|
||||
return null;
|
||||
}
|
||||
int lv = agent.getLevelNo() == null ? 0 : agent.getLevelNo();
|
||||
boolean ownerIsL5 = Integer.valueOf(5).equals(owner.getLevelNo());
|
||||
boolean isOwner = Objects.equals(agent.getId(), owner.getId());
|
||||
if (ownerIsL5 && !isOwner && lv == 4) {
|
||||
return null;
|
||||
}
|
||||
if (isOwner && lv == 5) {
|
||||
SmHotelFxLevelRuleDTO l4 = ruleByLevel.get(4);
|
||||
if (l4 != null && l4.getShareOfPool() != null
|
||||
&& l4.getShareOfPool().compareTo(BigDecimal.ZERO) > 0) {
|
||||
return l4.getShareOfPool();
|
||||
}
|
||||
return new BigDecimal("0.10");
|
||||
}
|
||||
SmHotelFxLevelRuleDTO rule = ruleByLevel.get(lv);
|
||||
return rule == null ? null : rule.getShareOfPool();
|
||||
}
|
||||
|
||||
/** 从开发员沿 parent 向上,同层级只保留最先遇到的人 */
|
||||
private List<SmHotelFxAgentEntity> buildUplineChain(SmHotelFxAgentEntity owner) {
|
||||
List<SmHotelFxAgentEntity> chain = new ArrayList<>();
|
||||
@@ -276,6 +309,12 @@ public class SmHotelFxCommissionServiceImpl extends ServiceImpl<SmHotelFxCommiss
|
||||
data.put("agentId", me.getId());
|
||||
data.put("levelNo", me.getLevelNo());
|
||||
data.put("realName", me.getRealName());
|
||||
data.put("username", me.getUsername());
|
||||
data.put("mobile", me.getMobile());
|
||||
data.put("inviteCode", me.getInviteCode());
|
||||
data.put("payMethod", me.getPayMethod());
|
||||
data.put("payAccount", me.getPayAccount());
|
||||
data.put("payAccountName", me.getPayAccountName());
|
||||
data.put("promoteScore", me.getPromoteScore());
|
||||
data.put("cityAdcode", me.getCityAdcode());
|
||||
data.put("demoteWindowScore", me.getDemoteWindowScore());
|
||||
@@ -286,16 +325,35 @@ public class SmHotelFxCommissionServiceImpl extends ServiceImpl<SmHotelFxCommiss
|
||||
data.put("withdrawableAmount", withdrawable);
|
||||
data.put("withdrawnAmount", withdrawn);
|
||||
data.put("totalAmount", estimated.add(locked));
|
||||
// 首页卡片:已绑酒店数
|
||||
data.put("hotelCount", agentService.pageBinds(new Page<>(1, 1), me.getId(), null).getTotal());
|
||||
List<SmHotelFxLevelRuleDTO> rules = configService.parseLevelRules(configService.getOrCreateCurrent());
|
||||
SmHotelFxLevelRuleDTO r1 = rules.stream().filter(r -> Objects.equals(r.getLevel(), 1)).findFirst().orElse(null);
|
||||
BigDecimal l1Need = r1 != null && r1.getPromoteMin() != null ? r1.getPromoteMin() : new BigDecimal("500000");
|
||||
data.put("l1PromoteMin", l1Need);
|
||||
// 下一档晋升门槛:挂在目标层级(4→3 看三级 5 万;3→2 看二级 20 万;2→1 看一级 50 万)
|
||||
Integer lv = me.getLevelNo();
|
||||
if (lv != null && lv >= 2 && lv <= 4) {
|
||||
int target = lv - 1;
|
||||
SmHotelFxLevelRuleDTO targetRule = rules.stream().filter(r -> Objects.equals(r.getLevel(), target))
|
||||
.findFirst().orElse(null);
|
||||
BigDecimal nextNeed = targetRule != null && targetRule.getPromoteMin() != null ? targetRule.getPromoteMin()
|
||||
: (target == 3 ? new BigDecimal("50000")
|
||||
: target == 2 ? new BigDecimal("200000") : l1Need);
|
||||
data.put("nextPromoteLevel", target);
|
||||
data.put("nextPromoteName", targetRule != null ? targetRule.getName() : null);
|
||||
data.put("nextPromoteMin", nextNeed);
|
||||
}
|
||||
else {
|
||||
data.put("nextPromoteLevel", null);
|
||||
data.put("nextPromoteName", null);
|
||||
data.put("nextPromoteMin", null);
|
||||
}
|
||||
if (Integer.valueOf(2).equals(me.getLevelNo())) {
|
||||
SmHotelFxLevelRuleDTO r2 = rules.stream().filter(r -> Objects.equals(r.getLevel(), 2)).findFirst()
|
||||
.orElse(null);
|
||||
data.put("demoteMin", r2 != null ? r2.getDemoteMin() : new BigDecimal("40000"));
|
||||
data.put("demoteWindowDays", r2 != null ? r2.getDemoteWindowDays() : 90);
|
||||
SmHotelFxLevelRuleDTO r1 = rules.stream().filter(r -> Objects.equals(r.getLevel(), 1)).findFirst()
|
||||
.orElse(null);
|
||||
BigDecimal l1Need = r1 != null && r1.getPromoteMin() != null ? r1.getPromoteMin() : new BigDecimal("500000");
|
||||
data.put("l1PromoteMin", l1Need);
|
||||
BigDecimal score = me.getPromoteScore() == null ? BigDecimal.ZERO : me.getPromoteScore();
|
||||
data.put("canApplyL1", score.compareTo(l1Need) >= 0);
|
||||
}
|
||||
@@ -305,6 +363,70 @@ public class SmHotelFxCommissionServiceImpl extends ServiceImpl<SmHotelFxCommiss
|
||||
return R.ok(data);
|
||||
}
|
||||
|
||||
@Override
|
||||
public R<Map<String, Object>> downlinePerf() {
|
||||
SmHotelFxAgentEntity me = currentAgent();
|
||||
if (me == null) {
|
||||
return R.failed("当前账号不是分销员");
|
||||
}
|
||||
List<SmHotelFxAgentEntity> children = agentService.list(Wrappers.<SmHotelFxAgentEntity>lambdaQuery()
|
||||
.eq(SmHotelFxAgentEntity::getParentId, me.getId())
|
||||
.eq(SmHotelFxAgentEntity::getStatus, "ACTIVE")
|
||||
.orderByAsc(SmHotelFxAgentEntity::getLevelNo)
|
||||
.orderByDesc(SmHotelFxAgentEntity::getId));
|
||||
BigDecimal totalEstimated = BigDecimal.ZERO;
|
||||
BigDecimal totalLocked = BigDecimal.ZERO;
|
||||
BigDecimal totalPromote = BigDecimal.ZERO;
|
||||
long totalHotels = 0L;
|
||||
List<Map<String, Object>> items = new ArrayList<>();
|
||||
for (SmHotelFxAgentEntity child : children) {
|
||||
List<SmHotelFxCommissionEntity> commissions = list(Wrappers.<SmHotelFxCommissionEntity>lambdaQuery()
|
||||
.eq(SmHotelFxCommissionEntity::getAgentId, child.getId())
|
||||
.ne(SmHotelFxCommissionEntity::getStatus, ST_CANCEL));
|
||||
BigDecimal estimated = BigDecimal.ZERO;
|
||||
BigDecimal locked = BigDecimal.ZERO;
|
||||
for (SmHotelFxCommissionEntity c : commissions) {
|
||||
BigDecimal amt = c.getCommissionAmount() == null ? BigDecimal.ZERO : c.getCommissionAmount();
|
||||
if (ST_EST.equals(c.getStatus())) {
|
||||
estimated = estimated.add(amt);
|
||||
}
|
||||
else if (ST_LOCK.equals(c.getStatus()) || "WITHDRAWN".equals(c.getStatus())) {
|
||||
locked = locked.add(amt);
|
||||
}
|
||||
}
|
||||
long hotelCount = agentService.pageBinds(new Page<>(1, 1), child.getId(), null).getTotal();
|
||||
BigDecimal promote = child.getPromoteScore() == null ? BigDecimal.ZERO : child.getPromoteScore();
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("agentId", child.getId());
|
||||
row.put("realName", child.getRealName());
|
||||
row.put("username", child.getUsername());
|
||||
row.put("mobile", child.getMobile());
|
||||
row.put("levelNo", child.getLevelNo());
|
||||
row.put("inviteCode", child.getInviteCode());
|
||||
row.put("hotelCount", hotelCount);
|
||||
row.put("promoteScore", promote);
|
||||
row.put("estimatedAmount", estimated);
|
||||
row.put("lockedAmount", locked);
|
||||
row.put("totalAmount", estimated.add(locked));
|
||||
items.add(row);
|
||||
totalEstimated = totalEstimated.add(estimated);
|
||||
totalLocked = totalLocked.add(locked);
|
||||
totalPromote = totalPromote.add(promote);
|
||||
totalHotels += hotelCount;
|
||||
}
|
||||
items.sort((a, b) -> ((BigDecimal) b.get("totalAmount")).compareTo((BigDecimal) a.get("totalAmount")));
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("childCount", items.size());
|
||||
data.put("hotelCount", totalHotels);
|
||||
data.put("promoteScore", totalPromote);
|
||||
data.put("estimatedAmount", totalEstimated);
|
||||
data.put("lockedAmount", totalLocked);
|
||||
data.put("totalAmount", totalEstimated.add(totalLocked));
|
||||
data.put("hint", "下线酒店订单佣金归下线本人;此处仅汇总查看绩效,不计入您的可提现");
|
||||
data.put("items", items);
|
||||
return R.ok(data);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Page<SmHotelFxCommissionEntity> pageMine(Page<SmHotelFxCommissionEntity> page, String status) {
|
||||
SmHotelFxAgentEntity me = currentAgent();
|
||||
@@ -325,7 +447,13 @@ public class SmHotelFxCommissionServiceImpl extends ServiceImpl<SmHotelFxCommiss
|
||||
if (user == null || user.getId() == null) {
|
||||
return null;
|
||||
}
|
||||
return agentService.getByUserId(user.getId());
|
||||
SmHotelFxAgentEntity agent = agentService.getByUserId(user.getId());
|
||||
if (agent == null) {
|
||||
// 常见原因:PC 保存时 Number(userId) 精度丢失,agent.user_id 与登录用户不一致
|
||||
log.warn("|酒店分销|currentAgent未命中|userId={}|username={}|tenantId={}|", user.getId(),
|
||||
user.getUsername(), user.getTenantId());
|
||||
}
|
||||
return agent;
|
||||
}
|
||||
|
||||
private void fillDisplay(List<SmHotelFxCommissionEntity> list) {
|
||||
@@ -354,6 +482,109 @@ public class SmHotelFxCommissionServiceImpl extends ServiceImpl<SmHotelFxCommiss
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public R<Map<String, Object>> mockOrderForBoundHotel(Long hotelId, BigDecimal costAmount, Boolean lockNow) {
|
||||
if (hotelId == null) {
|
||||
return R.failed("酒店ID为空");
|
||||
}
|
||||
SmHotelFxAgentEntity owner = agentService.getBoundAgentByHotelId(hotelId);
|
||||
if (owner == null || !"ACTIVE".equals(owner.getStatus())) {
|
||||
return R.failed("该酒店未绑定在职分销员,请先完成分销绑定");
|
||||
}
|
||||
SmHotelEntity hotel = hotelMapper.selectById(hotelId);
|
||||
if (hotel == null) {
|
||||
return R.failed("酒店不存在");
|
||||
}
|
||||
SmHotelFxConfigEntity cfg = configService.getOrCreateCurrent();
|
||||
if (!configService.isEnabled(cfg)) {
|
||||
return R.failed("分销未开启,请先在后台启用酒店分销配置");
|
||||
}
|
||||
BigDecimal cost = costAmount;
|
||||
if (cost == null || cost.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
cost = new BigDecimal("1000");
|
||||
}
|
||||
cost = cost.setScale(2, RoundingMode.HALF_UP);
|
||||
boolean doLock = Boolean.TRUE.equals(lockNow);
|
||||
LocalDate checkIn = LocalDate.now().minusDays(doLock ? 2 : 0);
|
||||
LocalDate checkOut = doLock ? LocalDate.now().minusDays(1) : LocalDate.now().plusDays(1);
|
||||
|
||||
SmHotelOrderEntity order = new SmHotelOrderEntity();
|
||||
order.setHotelId(hotelId);
|
||||
order.setHotelCode(hotel.getHotelCode());
|
||||
order.setGroupCode(hotel.getGroupCode());
|
||||
order.setPartnerCode("FX_MOCK");
|
||||
order.setChannelNo("FXMOCK" + owner.getId() + System.currentTimeMillis());
|
||||
order.setRoomTypeCode("FX-MOCK-ROOM");
|
||||
order.setProductCode("FX-MOCK-PROD");
|
||||
order.setCheckInDate(checkIn);
|
||||
order.setCheckOutDate(checkOut);
|
||||
order.setCount(1);
|
||||
order.setCostAmount(cost);
|
||||
order.setSellAmount(cost.multiply(new BigDecimal("1.2")).setScale(2, RoundingMode.HALF_UP));
|
||||
order.setContactName("分销模拟");
|
||||
order.setContactPhone(StrUtil.blankToDefault(owner.getMobile(), "10000000000"));
|
||||
order.setOrderStatus("UPSTREAM_OK");
|
||||
order.setBookStatus("R");
|
||||
order.setPayStatus("1");
|
||||
order.setInventoryStatus("NONE");
|
||||
order.setCompensateStatus("NONE");
|
||||
order.setFailMsg("分销联调模拟单");
|
||||
orderMapper.insert(order);
|
||||
createEstimatedForOrder(order);
|
||||
|
||||
List<SmHotelFxCommissionEntity> lines = list(Wrappers.<SmHotelFxCommissionEntity>lambdaQuery()
|
||||
.eq(SmHotelFxCommissionEntity::getOrderId, order.getId())
|
||||
.ne(SmHotelFxCommissionEntity::getStatus, ST_CANCEL));
|
||||
if (CollUtil.isEmpty(lines)) {
|
||||
return R.failed("订单已建但未生成佣金,请检查层级份额配置");
|
||||
}
|
||||
if (doLock) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
Map<Long, BigDecimal> promoteAdd = new HashMap<>();
|
||||
List<SmHotelFxCommissionEntity> ownerRows = new ArrayList<>();
|
||||
for (SmHotelFxCommissionEntity row : lines) {
|
||||
if (!ST_EST.equals(row.getStatus())) {
|
||||
continue;
|
||||
}
|
||||
row.setStatus(ST_LOCK);
|
||||
row.setLockTime(now);
|
||||
row.setRemark("模拟单立即锁定");
|
||||
if (Objects.equals(row.getAgentId(), row.getOwnerAgentId()) && row.getPromoteBaseAmount() != null) {
|
||||
promoteAdd.merge(row.getOwnerAgentId(), row.getPromoteBaseAmount(), BigDecimal::add);
|
||||
ownerRows.add(row);
|
||||
}
|
||||
}
|
||||
updateBatchById(lines);
|
||||
for (SmHotelFxCommissionEntity row : ownerRows) {
|
||||
agentService.onDemoteWindowEligible(row.getOwnerAgentId(), row.getHotelId(), row.getPromoteBaseAmount());
|
||||
}
|
||||
for (Map.Entry<Long, BigDecimal> e : promoteAdd.entrySet()) {
|
||||
agentService.onPromoteBaseLocked(e.getKey(), e.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
BigDecimal ownerAmt = lines.stream()
|
||||
.filter(c -> Objects.equals(c.getAgentId(), owner.getId()))
|
||||
.map(c -> c.getCommissionAmount() == null ? BigDecimal.ZERO : c.getCommissionAmount())
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("orderId", order.getId());
|
||||
data.put("channelNo", order.getChannelNo());
|
||||
data.put("hotelId", hotelId);
|
||||
data.put("hotelName", hotel.getHotelName());
|
||||
data.put("ownerAgentId", owner.getId());
|
||||
data.put("ownerAgentName", StrUtil.blankToDefault(owner.getRealName(), owner.getUsername()));
|
||||
data.put("costAmount", cost);
|
||||
data.put("checkOutDate", checkOut.toString());
|
||||
data.put("locked", doLock);
|
||||
data.put("commissionLines", lines.size());
|
||||
data.put("ownerCommission", ownerAmt);
|
||||
log.info("|酒店分销|管理端模拟下单|orderId={}|hotelId={}|agentId={}|cost={}|lock={}|lines={}|",
|
||||
order.getId(), hotelId, owner.getId(), cost, doLock, lines.size());
|
||||
return R.ok(data);
|
||||
}
|
||||
|
||||
private static BigDecimal nvl(BigDecimal v, String def) {
|
||||
return v == null ? new BigDecimal(def) : v;
|
||||
}
|
||||
|
||||
+302
@@ -0,0 +1,302 @@
|
||||
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.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.bsd.pjcloud.common.core.util.R;
|
||||
import com.bsd.pjcloud.common.security.service.PjcloudUser;
|
||||
import com.bsd.pjcloud.common.security.util.SecurityUtils;
|
||||
import com.bsd.pjcloud.supermarket.entity.SmHotelEntity;
|
||||
import com.bsd.pjcloud.supermarket.entity.SmHotelFxAgentEntity;
|
||||
import com.bsd.pjcloud.supermarket.entity.SmHotelFxHotelApplyEntity;
|
||||
import com.bsd.pjcloud.supermarket.mapper.SmHotelFxHotelApplyMapper;
|
||||
import com.bsd.pjcloud.supermarket.mapper.SmHotelMapper;
|
||||
import com.bsd.pjcloud.supermarket.service.SmHotelFxAgentService;
|
||||
import com.bsd.pjcloud.supermarket.service.SmHotelFxHotelApplyService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 分销员仅提报:酒店名称 + 地址 + 营业执照。
|
||||
* 后台完善酒店资料后,审核通过时选择已建酒店绑定,并在审核备注中填写酒店账号密码(分销员可见)。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SmHotelFxHotelApplyServiceImpl extends ServiceImpl<SmHotelFxHotelApplyMapper, SmHotelFxHotelApplyEntity>
|
||||
implements SmHotelFxHotelApplyService {
|
||||
|
||||
private final SmHotelFxAgentService agentService;
|
||||
|
||||
private final SmHotelMapper hotelMapper;
|
||||
|
||||
@Override
|
||||
public Page<SmHotelFxHotelApplyEntity> pageAdmin(Page<SmHotelFxHotelApplyEntity> page,
|
||||
SmHotelFxHotelApplyEntity query) {
|
||||
Page<SmHotelFxHotelApplyEntity> result = page(page, Wrappers.<SmHotelFxHotelApplyEntity>lambdaQuery()
|
||||
.eq(query.getAgentId() != null, SmHotelFxHotelApplyEntity::getAgentId, query.getAgentId())
|
||||
.eq(StrUtil.isNotBlank(query.getStatus()), SmHotelFxHotelApplyEntity::getStatus, query.getStatus())
|
||||
.like(StrUtil.isNotBlank(query.getHotelName()), SmHotelFxHotelApplyEntity::getHotelName,
|
||||
query.getHotelName())
|
||||
.orderByDesc(SmHotelFxHotelApplyEntity::getId));
|
||||
fillAgent(result.getRecords());
|
||||
fillLicense(result.getRecords());
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Page<SmHotelFxHotelApplyEntity> pageMine(Page<SmHotelFxHotelApplyEntity> page, String status) {
|
||||
SmHotelFxAgentEntity me = requireActiveAgent();
|
||||
if (me == null) {
|
||||
return page;
|
||||
}
|
||||
Page<SmHotelFxHotelApplyEntity> result = page(page, Wrappers.<SmHotelFxHotelApplyEntity>lambdaQuery()
|
||||
.eq(SmHotelFxHotelApplyEntity::getAgentId, me.getId())
|
||||
.eq(StrUtil.isNotBlank(status), SmHotelFxHotelApplyEntity::getStatus, status)
|
||||
.orderByDesc(SmHotelFxHotelApplyEntity::getId));
|
||||
fillLicense(result.getRecords());
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public R<SmHotelFxHotelApplyEntity> applyMine(SmHotelFxHotelApplyEntity body) {
|
||||
SmHotelFxAgentEntity me = requireActiveAgent();
|
||||
if (me == null) {
|
||||
return R.failed("当前账号不是在职分销员");
|
||||
}
|
||||
if (body == null) {
|
||||
return R.failed("请填写酒店信息");
|
||||
}
|
||||
JSONObject payload = parsePayload(body);
|
||||
String hotelName = firstNonBlank(payload.getStr("hotelName"), body.getHotelName());
|
||||
String address = firstNonBlank(payload.getStr("address"), body.getAddress());
|
||||
String licenseUrl = firstNonBlank(payload.getStr("licenseUrl"), payload.getStr("businessLicense"),
|
||||
body.getLicenseUrl());
|
||||
if (StrUtil.isBlank(hotelName)) {
|
||||
return R.failed("请填写酒店名称");
|
||||
}
|
||||
if (StrUtil.isBlank(address)) {
|
||||
return R.failed("请填写地址");
|
||||
}
|
||||
if (StrUtil.isBlank(licenseUrl)) {
|
||||
return R.failed("请上传营业执照");
|
||||
}
|
||||
hotelName = hotelName.trim();
|
||||
address = address.trim();
|
||||
licenseUrl = licenseUrl.trim();
|
||||
|
||||
// PENDING:同单修改;REJECTED:必须新建记录保留驳回痕迹;其它状态不可改
|
||||
SmHotelFxHotelApplyEntity pendingEdit = null;
|
||||
Long resubmitFromId = null;
|
||||
if (body.getId() != null) {
|
||||
SmHotelFxHotelApplyEntity exist = getById(body.getId());
|
||||
if (exist == null || !java.util.Objects.equals(exist.getAgentId(), me.getId())) {
|
||||
return R.failed("提报不存在或无权编辑");
|
||||
}
|
||||
if ("PENDING".equals(exist.getStatus())) {
|
||||
pendingEdit = exist;
|
||||
}
|
||||
else if ("REJECTED".equals(exist.getStatus())) {
|
||||
resubmitFromId = exist.getId();
|
||||
}
|
||||
else {
|
||||
return R.failed("仅待审或已驳回的提报可重新编辑提交");
|
||||
}
|
||||
}
|
||||
|
||||
long pending = count(Wrappers.<SmHotelFxHotelApplyEntity>lambdaQuery()
|
||||
.eq(SmHotelFxHotelApplyEntity::getAgentId, me.getId())
|
||||
.eq(SmHotelFxHotelApplyEntity::getStatus, "PENDING")
|
||||
.eq(SmHotelFxHotelApplyEntity::getHotelName, hotelName)
|
||||
.ne(pendingEdit != null, SmHotelFxHotelApplyEntity::getId,
|
||||
pendingEdit != null ? pendingEdit.getId() : null));
|
||||
if (pending > 0) {
|
||||
return R.failed("同名酒店已有待审核提报,请勿重复提交");
|
||||
}
|
||||
|
||||
JSONObject slim = new JSONObject();
|
||||
slim.set("hotelName", hotelName);
|
||||
slim.set("address", address);
|
||||
slim.set("licenseUrl", licenseUrl);
|
||||
if (resubmitFromId != null) {
|
||||
slim.set("resubmitFromId", resubmitFromId);
|
||||
}
|
||||
|
||||
SmHotelFxHotelApplyEntity row = pendingEdit != null ? pendingEdit : new SmHotelFxHotelApplyEntity();
|
||||
row.setAgentId(me.getId());
|
||||
row.setUserId(me.getUserId());
|
||||
row.setHotelName(hotelName);
|
||||
row.setAddress(address);
|
||||
row.setHotelJson(slim.toString());
|
||||
row.setLicenseUrl(licenseUrl);
|
||||
row.setStatus("PENDING");
|
||||
row.setRemark(StrUtil.trim(body.getRemark()));
|
||||
if (pendingEdit != null) {
|
||||
updateById(row);
|
||||
log.info("|酒店分销|提报修改|applyId={}|agentId={}|hotel={}|", row.getId(), me.getId(), hotelName);
|
||||
}
|
||||
else {
|
||||
save(row);
|
||||
log.info("|酒店分销|提报提交|applyId={}|agentId={}|hotel={}|fromRejected={}|",
|
||||
row.getId(), me.getId(), hotelName, resubmitFromId);
|
||||
}
|
||||
return R.ok(row);
|
||||
}
|
||||
|
||||
@Override
|
||||
public R<SmHotelFxHotelApplyEntity> getMine(Long id) {
|
||||
SmHotelFxAgentEntity me = requireActiveAgent();
|
||||
if (me == null) {
|
||||
return R.failed("当前账号不是在职分销员");
|
||||
}
|
||||
if (id == null) {
|
||||
return R.failed("提报ID为空");
|
||||
}
|
||||
SmHotelFxHotelApplyEntity row = getById(id);
|
||||
if (row == null || !java.util.Objects.equals(row.getAgentId(), me.getId())) {
|
||||
return R.failed("提报不存在");
|
||||
}
|
||||
fillLicense(java.util.Collections.singletonList(row));
|
||||
return R.ok(row);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public R<Boolean> audit(Long id, boolean approved, String auditRemark, Long hotelId) {
|
||||
SmHotelFxHotelApplyEntity row = getById(id);
|
||||
if (row == null) {
|
||||
return R.failed("提报不存在");
|
||||
}
|
||||
if (!"PENDING".equals(row.getStatus())) {
|
||||
return R.failed("提报已处理");
|
||||
}
|
||||
PjcloudUser user = SecurityUtils.getUser();
|
||||
row.setAuditBy(user != null ? user.getUsername() : null);
|
||||
row.setAuditTime(LocalDateTime.now());
|
||||
row.setAuditRemark(StrUtil.trim(auditRemark));
|
||||
if (!approved) {
|
||||
row.setStatus("REJECTED");
|
||||
updateById(row);
|
||||
return R.ok(Boolean.TRUE, "已驳回");
|
||||
}
|
||||
if (StrUtil.isBlank(row.getAuditRemark())) {
|
||||
return R.failed("请填写审核备注(酒店账号密码等,分销员可见)");
|
||||
}
|
||||
if (hotelId == null) {
|
||||
return R.failed("请选择要绑定的酒店(请先在酒店管理完善资料)");
|
||||
}
|
||||
SmHotelEntity hotel = hotelMapper.selectById(hotelId);
|
||||
if (hotel == null) {
|
||||
return R.failed("酒店不存在,请先在酒店管理新建并完善");
|
||||
}
|
||||
SmHotelFxAgentEntity agent = agentService.getById(row.getAgentId());
|
||||
if (agent == null || !"ACTIVE".equals(agent.getStatus())) {
|
||||
return R.failed("提报分销员不可用");
|
||||
}
|
||||
R<Boolean> bind = agentService.bindHotel(hotelId, agent.getId(), "分销提报审核通过");
|
||||
if (bind == null || bind.getCode() != 0) {
|
||||
return R.failed(bind == null ? "绑定失败" : StrUtil.blankToDefault(bind.getMsg(), "绑定失败"));
|
||||
}
|
||||
row.setHotelId(hotelId);
|
||||
row.setStatus("APPROVED");
|
||||
updateById(row);
|
||||
log.info("|酒店分销|提报通过|applyId={}|hotelId={}|agentId={}|", row.getId(), hotelId, agent.getId());
|
||||
return R.ok(Boolean.TRUE, "已通过并绑定分销员");
|
||||
}
|
||||
|
||||
@Override
|
||||
public long countPendingMine() {
|
||||
SmHotelFxAgentEntity me = requireActiveAgent();
|
||||
if (me == null) {
|
||||
return 0L;
|
||||
}
|
||||
return count(Wrappers.<SmHotelFxHotelApplyEntity>lambdaQuery()
|
||||
.eq(SmHotelFxHotelApplyEntity::getAgentId, me.getId())
|
||||
.eq(SmHotelFxHotelApplyEntity::getStatus, "PENDING"));
|
||||
}
|
||||
|
||||
private JSONObject parsePayload(SmHotelFxHotelApplyEntity body) {
|
||||
if (StrUtil.isNotBlank(body.getHotelJson()) && JSONUtil.isTypeJSON(body.getHotelJson())) {
|
||||
return JSONUtil.parseObj(body.getHotelJson());
|
||||
}
|
||||
JSONObject p = new JSONObject();
|
||||
if (StrUtil.isNotBlank(body.getHotelName())) {
|
||||
p.set("hotelName", body.getHotelName().trim());
|
||||
}
|
||||
if (StrUtil.isNotBlank(body.getAddress())) {
|
||||
p.set("address", body.getAddress().trim());
|
||||
}
|
||||
if (StrUtil.isNotBlank(body.getLicenseUrl())) {
|
||||
p.set("licenseUrl", body.getLicenseUrl().trim());
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
private String firstNonBlank(String... vals) {
|
||||
if (vals == null) {
|
||||
return null;
|
||||
}
|
||||
for (String v : vals) {
|
||||
if (StrUtil.isNotBlank(v)) {
|
||||
return v.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private SmHotelFxAgentEntity requireActiveAgent() {
|
||||
PjcloudUser user = SecurityUtils.getUser();
|
||||
if (user == null || user.getId() == null) {
|
||||
return null;
|
||||
}
|
||||
SmHotelFxAgentEntity me = agentService.getByUserId(user.getId());
|
||||
if (me == null || !"ACTIVE".equals(me.getStatus())) {
|
||||
return null;
|
||||
}
|
||||
return me;
|
||||
}
|
||||
|
||||
private void fillAgent(List<SmHotelFxHotelApplyEntity> list) {
|
||||
if (list == null || list.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (SmHotelFxHotelApplyEntity row : list) {
|
||||
if (row.getAgentId() == null) {
|
||||
continue;
|
||||
}
|
||||
SmHotelFxAgentEntity a = agentService.getById(row.getAgentId());
|
||||
if (a != null) {
|
||||
row.setAgentName(StrUtil.blankToDefault(a.getRealName(), a.getUsername()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 从 hotel_json 回填营业执照 URL,方便前后端展示 */
|
||||
private void fillLicense(List<SmHotelFxHotelApplyEntity> list) {
|
||||
if (list == null || list.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (SmHotelFxHotelApplyEntity row : list) {
|
||||
if (StrUtil.isNotBlank(row.getLicenseUrl())) {
|
||||
continue;
|
||||
}
|
||||
if (StrUtil.isBlank(row.getHotelJson()) || !JSONUtil.isTypeJSON(row.getHotelJson())) {
|
||||
continue;
|
||||
}
|
||||
JSONObject p = JSONUtil.parseObj(row.getHotelJson());
|
||||
String url = firstNonBlank(p.getStr("licenseUrl"), p.getStr("businessLicense"));
|
||||
if (StrUtil.isNotBlank(url)) {
|
||||
row.setLicenseUrl(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -199,7 +199,11 @@ public class SmHotelFxWithdrawServiceImpl extends ServiceImpl<SmHotelFxWithdrawM
|
||||
if (user == null || user.getId() == null) {
|
||||
return null;
|
||||
}
|
||||
return agentService.getByUserId(user.getId());
|
||||
SmHotelFxAgentEntity agent = agentService.getByUserId(user.getId());
|
||||
if (agent == null) {
|
||||
log.warn("|酒店分销|提现currentAgent未命中|userId={}|username={}|", user.getId(), user.getUsername());
|
||||
}
|
||||
return agent;
|
||||
}
|
||||
|
||||
private void fillAgent(List<SmHotelFxWithdrawEntity> list) {
|
||||
|
||||
+63
-6
@@ -167,12 +167,15 @@ public class SmHotelHuidingOrderServiceImpl implements SmHotelHuidingOrderServic
|
||||
.eq(SmHotelOrderEntity::getChannelNo, channelOrderNo)
|
||||
.last("limit 1"));
|
||||
if (exist != null) {
|
||||
outer.put("msg", "订单已存在(幂等)");
|
||||
outer.put("data", buildCreateData(exist));
|
||||
return outer;
|
||||
if (!"BOOK_FAIL".equals(exist.getOrderStatus())) {
|
||||
outer.put("msg", "订单已存在(幂等)");
|
||||
outer.put("data", buildCreateData(exist));
|
||||
return outer;
|
||||
}
|
||||
}
|
||||
MappedOrder mapped = mapCreateFields(partnerCode, data);
|
||||
if (mapped.error != null) {
|
||||
saveBookFailOrder(exist, partnerCode, channelOrderNo, mapped, data, mapped.error);
|
||||
return fail200(mapped.error);
|
||||
}
|
||||
Map<String, Object> checkBody = new LinkedHashMap<>();
|
||||
@@ -191,13 +194,16 @@ public class SmHotelHuidingOrderServiceImpl implements SmHotelHuidingOrderServic
|
||||
&& Boolean.TRUE.equals(check.getData().get("bookable"));
|
||||
if (!bookable) {
|
||||
Object reasons = check == null || check.getData() == null ? "预检失败" : check.getData().get("reasons");
|
||||
return fail200("预检不可订:" + reasons);
|
||||
String failMsg = "预检不可订:" + reasons;
|
||||
saveBookFailOrder(exist, partnerCode, channelOrderNo, mapped, data, failMsg);
|
||||
return fail200(failMsg);
|
||||
}
|
||||
|
||||
SmHotelOrderEntity order = new SmHotelOrderEntity();
|
||||
SmHotelOrderEntity order = exist != null ? exist : new SmHotelOrderEntity();
|
||||
order.setPartnerCode(partnerCode);
|
||||
order.setChannelNo(channelOrderNo);
|
||||
order.setHotelId(mapped.hotel.getId());
|
||||
order.setDeptId(mapped.hotel.getDeptId());
|
||||
order.setHotelCode(mapped.hotelCode);
|
||||
order.setGroupCode(mapped.hotel.getGroupCode());
|
||||
order.setRoomTypeCode(mapped.roomTypeCode);
|
||||
@@ -215,9 +221,15 @@ public class SmHotelHuidingOrderServiceImpl implements SmHotelHuidingOrderServic
|
||||
order.setCompensateStatus("NONE");
|
||||
order.setInventoryStatus("NONE");
|
||||
order.setCompensateRound(0);
|
||||
order.setFailMsg(null);
|
||||
order.setReqJson(data.toString());
|
||||
order.setHuanqiPayload(data.toString());
|
||||
orderMapper.insert(order);
|
||||
if (order.getId() == null) {
|
||||
orderMapper.insert(order);
|
||||
}
|
||||
else {
|
||||
orderMapper.updateById(order);
|
||||
}
|
||||
|
||||
// 本地酒店:占库存后 PENDING_CONFIRM,返回待确认,人工确认后再 operationOrder=1
|
||||
// 上游酒店:同步订上游,成功当场确认
|
||||
@@ -480,11 +492,56 @@ public class SmHotelHuidingOrderServiceImpl implements SmHotelHuidingOrderServic
|
||||
}
|
||||
return StrUtil.isNotBlank(order.getPlatformNo())
|
||||
&& !"UPSTREAM_FAIL".equals(order.getOrderStatus())
|
||||
&& !"BOOK_FAIL".equals(order.getOrderStatus())
|
||||
&& !"NEED_MANUAL".equals(order.getOrderStatus())
|
||||
&& !"CANCELLED".equals(order.getOrderStatus())
|
||||
&& !"PENDING_CANCEL".equals(order.getOrderStatus());
|
||||
}
|
||||
|
||||
/** 入站预检/映射失败落 BOOK_FAIL,对慧订仍返回失败 */
|
||||
private void saveBookFailOrder(SmHotelOrderEntity exist, String partnerCode, String channelOrderNo,
|
||||
MappedOrder mapped, JSONObject data, String failMsg) {
|
||||
try {
|
||||
SmHotelOrderEntity order = exist != null ? exist : new SmHotelOrderEntity();
|
||||
order.setPartnerCode(partnerCode);
|
||||
order.setChannelNo(channelOrderNo);
|
||||
order.setOrderStatus("BOOK_FAIL");
|
||||
order.setCompensateStatus("NONE");
|
||||
order.setInventoryStatus("NONE");
|
||||
order.setCompensateRound(0);
|
||||
order.setMarkupPercent(BigDecimal.ZERO);
|
||||
order.setFailMsg(StrUtil.maxLength(failMsg, 500));
|
||||
order.setReqJson(data == null ? null : data.toString());
|
||||
order.setHuanqiPayload(data == null ? null : data.toString());
|
||||
if (mapped != null && mapped.hotel != null) {
|
||||
order.setHotelId(mapped.hotel.getId());
|
||||
order.setDeptId(mapped.hotel.getDeptId());
|
||||
order.setHotelCode(mapped.hotelCode);
|
||||
order.setGroupCode(mapped.hotel.getGroupCode());
|
||||
order.setRoomTypeCode(mapped.roomTypeCode);
|
||||
order.setProductCode(mapped.productCode);
|
||||
order.setPriceCode(mapped.product == null ? null : mapped.product.getPriceCode());
|
||||
order.setCheckInDate(mapped.checkIn);
|
||||
order.setCheckOutDate(mapped.checkOut);
|
||||
order.setCount(mapped.quantity <= 0 ? 1 : mapped.quantity);
|
||||
order.setContactName(mapped.contactName);
|
||||
order.setContactPhone(mapped.contactPhone);
|
||||
order.setGuestJson(mapped.guestJson);
|
||||
order.setSellAmount(mapped.orderAmount);
|
||||
}
|
||||
if (order.getId() == null) {
|
||||
orderMapper.insert(order);
|
||||
}
|
||||
else {
|
||||
orderMapper.updateById(order);
|
||||
}
|
||||
log.info("|慧订订单|预订失败已落单|channelNo={}|localId={}|msg={}|", channelOrderNo, order.getId(), failMsg);
|
||||
}
|
||||
catch (Exception e) {
|
||||
log.warn("|慧订订单|预订失败落单异常|channelNo={}|{}|", channelOrderNo, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isPendingConfirm(SmHotelOrderEntity order) {
|
||||
return order != null && "PENDING_CONFIRM".equals(order.getOrderStatus());
|
||||
}
|
||||
|
||||
+231
-44
@@ -9,6 +9,7 @@ import com.bsd.pjcloud.admin.api.entity.SysDept;
|
||||
import com.bsd.pjcloud.admin.api.entity.SysUser;
|
||||
import com.bsd.pjcloud.admin.api.feign.RemoteDeptService;
|
||||
import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
|
||||
import com.bsd.pjcloud.common.core.constant.CommonRoleConstant;
|
||||
import com.bsd.pjcloud.common.core.util.R;
|
||||
import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
|
||||
import com.bsd.pjcloud.supermarket.entity.SmHotelEntity;
|
||||
@@ -20,6 +21,7 @@ import com.bsd.pjcloud.supermarket.mapper.SmHotelMgrPushConfigMapper;
|
||||
import com.bsd.pjcloud.supermarket.mapper.SmHotelMgrPushLogMapper;
|
||||
import com.bsd.pjcloud.supermarket.mapper.SmHotelOrderMapper;
|
||||
import com.bsd.pjcloud.supermarket.service.SmHotelMgrPushService;
|
||||
import com.bsd.pjcloud.thirdparty.api.feign.RemoteThirdPushDeliveryService;
|
||||
import com.bsd.pjcloud.thirdparty.api.feign.RemoterThirdAppMessageService;
|
||||
import com.bsd.pjcloud.thirdparty.dto.appmessage.HotelManagerPushEventDto;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -78,6 +80,9 @@ public class SmHotelMgrPushServiceImpl implements SmHotelMgrPushService {
|
||||
|
||||
private final RemoterThirdAppMessageService remoteAppMessageService;
|
||||
|
||||
/** 推送入箱后立刻踢一轮投递扫描,减少对 Quartz 的依赖 */
|
||||
private final RemoteThirdPushDeliveryService remotePushDeliveryService;
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public SmHotelMgrPushConfigEntity getOrCreateConfig() {
|
||||
@@ -101,6 +106,8 @@ public class SmHotelMgrPushServiceImpl implements SmHotelMgrPushService {
|
||||
current.setNewOrderEnabled(normalizeFlag(body.getNewOrderEnabled(), current.getNewOrderEnabled()));
|
||||
current.setNewOrderTitle(StrUtil.blankToDefault(StrUtil.trim(body.getNewOrderTitle()), DEFAULT_NEW_TITLE));
|
||||
current.setNewOrderContent(StrUtil.blankToDefault(StrUtil.trim(body.getNewOrderContent()), DEFAULT_NEW_CONTENT));
|
||||
current.setNewOrderAdminEnabled(
|
||||
normalizeFlag(body.getNewOrderAdminEnabled(), current.getNewOrderAdminEnabled()));
|
||||
current.setCalendarRemindEnabled(
|
||||
normalizeFlag(body.getCalendarRemindEnabled(), current.getCalendarRemindEnabled()));
|
||||
current.setCalendarRemindTitle(
|
||||
@@ -176,16 +183,28 @@ public class SmHotelMgrPushServiceImpl implements SmHotelMgrPushService {
|
||||
|
||||
private PushResult doPushNewOrder(Long orderId, boolean force) {
|
||||
if (orderId == null) {
|
||||
return PushResult.fail("订单ID为空");
|
||||
return PushResult.fail("订单为空");
|
||||
}
|
||||
// 同一 JVM 内串行化同订单自动推送,避免补偿/双路径并发各写一条 6/6
|
||||
synchronized (("HOTEL_MGR_PUSH_ORDER:" + orderId).intern()) {
|
||||
return doPushNewOrderLocked(orderId, force);
|
||||
}
|
||||
}
|
||||
|
||||
private PushResult doPushNewOrderLocked(Long orderId, boolean force) {
|
||||
try {
|
||||
SmHotelMgrPushConfigEntity cfg = getOrCreateConfig();
|
||||
if (!force && !ENABLED.equals(cfg.getNewOrderEnabled())) {
|
||||
return PushResult.skip("新订单推送已关闭");
|
||||
}
|
||||
// 自动推送:同一订单只真正推一次;补偿/补上游会反复触发 schedule,但手机端 eventId 幂等本就只会响一次
|
||||
if (!force && alreadyAutoPushedNewOrder(orderId)) {
|
||||
log.info("|酒店管理推送|新订单已推送过,跳过|orderId={}|", orderId);
|
||||
return PushResult.skip("该订单已自动推送过");
|
||||
}
|
||||
SmHotelOrderEntity order = orderMapper.selectById(orderId);
|
||||
if (order == null) {
|
||||
return PushResult.fail("订单不存在:" + orderId);
|
||||
return PushResult.fail("订单不存在");
|
||||
}
|
||||
if ("CREATED".equals(order.getOrderStatus()) && "UNPAID".equals(order.getPayStatus())) {
|
||||
return force ? PushResult.fail("订单待支付,暂不推送") : PushResult.skip("待支付跳过");
|
||||
@@ -204,26 +223,43 @@ public class SmHotelMgrPushServiceImpl implements SmHotelMgrPushService {
|
||||
String title = StrUtil.blankToDefault(cfg.getNewOrderTitle(), DEFAULT_NEW_TITLE);
|
||||
String contentTpl = StrUtil.blankToDefault(cfg.getNewOrderContent(), DEFAULT_NEW_CONTENT);
|
||||
String trigger = force ? SmHotelMgrPushLogEntity.TRIGGER_MANUAL : SmHotelMgrPushLogEntity.TRIGGER_AUTO;
|
||||
if (deptId == null) {
|
||||
String tip = "酒店未绑定部门(hotelId=" + order.getHotelId()
|
||||
+ ")。请在酒店信息里配置所属部门后再推。";
|
||||
log.info("|酒店管理推送|新订单跳过无部门|orderId={}|hotelId={}|", orderId, order.getHotelId());
|
||||
String channelNoVal = StrUtil.blankToDefault(order.getChannelNo(), String.valueOf(orderId));
|
||||
String orderLabel = StrUtil.isNotBlank(order.getChannelNo()) ? order.getChannelNo() : ("订单#" + orderId);
|
||||
boolean adminEnabled = ENABLED.equals(cfg.getNewOrderAdminEnabled());
|
||||
List<Long> leaders = deptId == null ? List.of() : resolveDeptLeaders(deptId);
|
||||
List<Long> admins = adminEnabled ? resolveUsersByRoleCode(CommonRoleConstant.ROLE_ADMIN) : List.of();
|
||||
LinkedHashSet<Long> recipients = new LinkedHashSet<>();
|
||||
recipients.addAll(leaders);
|
||||
recipients.addAll(admins);
|
||||
List<Long> logDepts = deptId == null ? List.of() : List.of(deptId);
|
||||
String deptName = deptId == null ? "" : resolveDeptNames(logDepts);
|
||||
if (recipients.isEmpty()) {
|
||||
String tip;
|
||||
if (deptId == null && !adminEnabled) {
|
||||
tip = "酒店未绑定部门,且未开启「推送管理员」。请配置酒店所属部门或开启管理员推送。";
|
||||
}
|
||||
else if (deptId == null) {
|
||||
tip = "酒店未绑定部门,且未找到 ROLE_ADMIN 用户。";
|
||||
}
|
||||
else if (CollUtil.isEmpty(leaders) && !adminEnabled) {
|
||||
tip = "部门「" + StrUtil.blankToDefault(deptName, String.valueOf(deptId))
|
||||
+ "」下没有岗位=部门负责人(TEAM_LEADER)的用户(仅本部门)。"
|
||||
+ "请确认用户所属部门与岗位,或开启「推送管理员」。";
|
||||
}
|
||||
else if (CollUtil.isEmpty(leaders)) {
|
||||
tip = "部门「" + StrUtil.blankToDefault(deptName, String.valueOf(deptId))
|
||||
+ "」无部门负责人,且未找到 ROLE_ADMIN 用户。";
|
||||
}
|
||||
else {
|
||||
tip = "未找到可推送对象。";
|
||||
}
|
||||
log.info("|酒店管理推送|新订单无接收人|orderId={}|deptId={}|adminEnabled={}|", orderId, deptId,
|
||||
adminEnabled);
|
||||
savePushLog(SmHotelMgrPushLogEntity.TYPE_NEW_ORDER, trigger, String.valueOf(orderId),
|
||||
order.getHotelId(), hotelName, List.of(), List.of(), 0, 0, title, null, tip);
|
||||
return force ? PushResult.fail(tip) : PushResult.skip(tip);
|
||||
}
|
||||
List<Long> leaders = resolveDeptLeaders(deptId);
|
||||
if (CollUtil.isEmpty(leaders)) {
|
||||
String tip = "部门 " + deptId + " 下没有岗位=部门负责人(TEAM_LEADER)的用户(仅本部门,不含下级)。"
|
||||
+ "请到用户管理确认:用户所属部门=该酒店部门,且岗位勾选「部门负责人」。";
|
||||
log.info("|酒店管理推送|新订单无负责人|orderId={}|deptId={}|hotelId={}|", orderId, deptId,
|
||||
order.getHotelId());
|
||||
savePushLog(SmHotelMgrPushLogEntity.TYPE_NEW_ORDER, trigger, String.valueOf(orderId),
|
||||
order.getHotelId(), hotelName, List.of(deptId), List.of(), 0, 0, title, null, tip);
|
||||
order.getHotelId(), hotelName, logDepts, List.of(), 0, 0, title, null, tip, null);
|
||||
return force ? PushResult.fail(tip) : PushResult.skip(tip);
|
||||
}
|
||||
Long tenantId = resolveTenantId(order.getTenantId(), hotel == null ? null : hotel.getTenantId());
|
||||
String channelNoVal = StrUtil.blankToDefault(order.getChannelNo(), String.valueOf(orderId));
|
||||
String content = contentTpl.replace("{hotelName}", hotelName).replace("{channelNo}", channelNoVal);
|
||||
if (!content.contains(hotelName) && !contentTpl.contains("{hotelName}")) {
|
||||
content = hotelName + ":" + content;
|
||||
@@ -234,6 +270,7 @@ public class SmHotelMgrPushServiceImpl implements SmHotelMgrPushService {
|
||||
payload.put("channelNo", channelNoVal);
|
||||
payload.put("deptId", deptId);
|
||||
payload.put("force", force);
|
||||
payload.put("adminPush", adminEnabled);
|
||||
Map<String, Object> linkParams = new HashMap<>(4);
|
||||
linkParams.put("orderId", String.valueOf(orderId));
|
||||
String eventPrefix = force
|
||||
@@ -241,7 +278,9 @@ public class SmHotelMgrPushServiceImpl implements SmHotelMgrPushService {
|
||||
: ("HOTEL_MGR_ORDER:" + orderId);
|
||||
int ok = 0;
|
||||
List<String> failHints = new ArrayList<>();
|
||||
for (Long userId : leaders) {
|
||||
List<Map<String, Object>> failRequests = new ArrayList<>();
|
||||
HotelManagerPushEventDto sampleRequest = null;
|
||||
for (Long userId : recipients) {
|
||||
if (userId == null) {
|
||||
continue;
|
||||
}
|
||||
@@ -259,31 +298,48 @@ public class SmHotelMgrPushServiceImpl implements SmHotelMgrPushService {
|
||||
dto.setDeepLinkPath("/pages/hotel-manage/orders");
|
||||
dto.setDeepLinkParamsJson(JSONUtil.toJsonStr(linkParams));
|
||||
dto.setOccurredTime(LocalDateTime.now());
|
||||
if (sampleRequest == null) {
|
||||
sampleRequest = dto;
|
||||
}
|
||||
String err = pushOne(dto);
|
||||
if (err == null) {
|
||||
ok++;
|
||||
}
|
||||
else if (failHints.size() < 3) {
|
||||
failHints.add("userId=" + userId + ":" + err);
|
||||
else {
|
||||
if (failHints.size() < 3) {
|
||||
failHints.add(displayUserName(userId) + ":" + err);
|
||||
}
|
||||
if (failRequests.size() < 5) {
|
||||
failRequests.add(buildFailRequestItem(displayUserName(userId), err, dto));
|
||||
}
|
||||
}
|
||||
}
|
||||
int fail = Math.max(0, leaders.size() - ok);
|
||||
log.info("|酒店管理推送|新订单完成|orderId={}|deptId={}|leaders={}|ok={}|force={}|leadersIds={}|",
|
||||
orderId, deptId, leaders.size(), ok, force, leaders);
|
||||
List<Long> recipientList = new ArrayList<>(recipients);
|
||||
int fail = Math.max(0, recipientList.size() - ok);
|
||||
String leaderNames = resolveUserNames(leaders);
|
||||
String adminNames = resolveUserNames(admins);
|
||||
String detail = buildNewOrderDetail(orderLabel, deptName, leaderNames, adminNames, adminEnabled);
|
||||
String requestJson = buildRequestSnapshot(sampleRequest, failRequests, ok, fail);
|
||||
log.info("|酒店管理推送|新订单完成|orderId={}|deptId={}|recipients={}|ok={}|force={}|admin={}|",
|
||||
orderId, deptId, recipientList.size(), ok, force, adminEnabled);
|
||||
if (ok == 0) {
|
||||
String tip = "找到负责人 " + leaders.size() + " 人(deptId=" + deptId + ", userIds=" + leaders
|
||||
+ "),但消息中心投递全部失败。"
|
||||
+ (failHints.isEmpty() ? "请确认 thirdparty 已重启并包含酒店管理推送接口。"
|
||||
: ("原因:" + String.join(";", failHints)));
|
||||
String tip = "找到接收人 " + recipientList.size() + " 人,但消息中心投递全部失败。" + detail
|
||||
+ (failHints.isEmpty() ? " 请确认 thirdparty 已重启并包含酒店管理推送接口。"
|
||||
: (" 原因:" + String.join(";", failHints)));
|
||||
savePushLog(SmHotelMgrPushLogEntity.TYPE_NEW_ORDER, trigger, String.valueOf(orderId),
|
||||
order.getHotelId(), hotelName, List.of(deptId), leaders, 0, fail, title, content, tip);
|
||||
order.getHotelId(), hotelName, logDepts, recipientList, 0, fail, title, content, tip,
|
||||
requestJson);
|
||||
return force ? PushResult.fail(tip) : PushResult.skip(tip);
|
||||
}
|
||||
String msg = "已推送成功 " + ok + "/" + leaders.size() + " 人(订单ID=" + orderId + ",deptId=" + deptId
|
||||
+ ",负责人=" + leaders + ")";
|
||||
kickPushDeliveryScan();
|
||||
String msg = "已推送成功 " + ok + "/" + recipientList.size() + " 人" + detail;
|
||||
if (fail > 0 && !failHints.isEmpty()) {
|
||||
msg = msg + ";部分失败:" + String.join(";", failHints);
|
||||
}
|
||||
savePushLog(SmHotelMgrPushLogEntity.TYPE_NEW_ORDER, trigger, String.valueOf(orderId),
|
||||
order.getHotelId(), hotelName, List.of(deptId), leaders, ok, fail, title, content, msg);
|
||||
return PushResult.success(ok, leaders.size(), msg);
|
||||
order.getHotelId(), hotelName, logDepts, recipientList, ok, fail, title, content, msg,
|
||||
requestJson);
|
||||
return PushResult.success(ok, recipientList.size(), msg);
|
||||
}
|
||||
catch (Exception e) {
|
||||
log.warn("|酒店管理推送|新订单异常|orderId={}|err={}|", orderId, e.getMessage(), e);
|
||||
@@ -324,11 +380,12 @@ public class SmHotelMgrPushServiceImpl implements SmHotelMgrPushService {
|
||||
}
|
||||
}
|
||||
if (leaders.isEmpty()) {
|
||||
List<Long> sampleDepts = deptIds.stream().limit(5).toList();
|
||||
String tip = "扫描到 " + deptIds.size() + " 个酒店部门,但都没有岗位=部门负责人(TEAM_LEADER)的用户。"
|
||||
+ "部门示例:" + deptIds.stream().limit(5).map(String::valueOf).collect(Collectors.joining(","));
|
||||
+ "部门示例:" + StrUtil.blankToDefault(resolveDeptNames(sampleDepts), "无");
|
||||
log.info("|酒店管理推送|日历提醒无负责人|hour={}|deptCount={}|", hour, deptIds.size());
|
||||
savePushLog(SmHotelMgrPushLogEntity.TYPE_CALENDAR, trigger, day + "-" + hour, null, null,
|
||||
new ArrayList<>(deptIds), List.of(), 0, 0, title, content, tip);
|
||||
new ArrayList<>(deptIds), List.of(), 0, 0, title, content, tip, null);
|
||||
return force ? PushResult.fail(tip) : PushResult.skip(tip);
|
||||
}
|
||||
Long tenantId = resolveTenantId(null, hotels.get(0).getTenantId());
|
||||
@@ -341,6 +398,8 @@ public class SmHotelMgrPushServiceImpl implements SmHotelMgrPushService {
|
||||
: ("HOTEL_MGR_CAL:" + day + ":" + hour);
|
||||
int ok = 0;
|
||||
List<String> failHints = new ArrayList<>();
|
||||
List<Map<String, Object>> failRequests = new ArrayList<>();
|
||||
HotelManagerPushEventDto sampleRequest = null;
|
||||
for (Long userId : leaders) {
|
||||
if (userId == null) {
|
||||
continue;
|
||||
@@ -357,34 +416,145 @@ public class SmHotelMgrPushServiceImpl implements SmHotelMgrPushService {
|
||||
dto.setPayloadJson(JSONUtil.toJsonStr(payload));
|
||||
dto.setDeepLinkPath("/pages/hotel-manage/list");
|
||||
dto.setOccurredTime(LocalDateTime.now());
|
||||
if (sampleRequest == null) {
|
||||
sampleRequest = dto;
|
||||
}
|
||||
String err = pushOne(dto);
|
||||
if (err == null) {
|
||||
ok++;
|
||||
}
|
||||
else if (failHints.size() < 3) {
|
||||
failHints.add("userId=" + userId + ":" + err);
|
||||
else {
|
||||
if (failHints.size() < 3) {
|
||||
failHints.add(displayUserName(userId) + ":" + err);
|
||||
}
|
||||
if (failRequests.size() < 5) {
|
||||
failRequests.add(buildFailRequestItem(displayUserName(userId), err, dto));
|
||||
}
|
||||
}
|
||||
}
|
||||
int fail = Math.max(0, leaders.size() - ok);
|
||||
log.info("|酒店管理推送|日历提醒完成|hour={}|deptCount={}|leaders={}|ok={}|ids={}|", hour, deptIds.size(),
|
||||
leaders.size(), ok, leaders);
|
||||
List<Long> leaderList = new ArrayList<>(leaders);
|
||||
String leaderNames = resolveUserNames(leaderList);
|
||||
String requestJson = buildRequestSnapshot(sampleRequest, failRequests, ok, fail);
|
||||
log.info("|酒店管理推送|日历提醒完成|hour={}|deptCount={}|leaders={}|ok={}|", hour, deptIds.size(),
|
||||
leaders.size(), ok);
|
||||
List<Long> logDepts = deptsWithLeader.isEmpty() ? new ArrayList<>(deptIds) : deptsWithLeader;
|
||||
if (ok == 0) {
|
||||
String tip = "找到负责人 " + leaders.size() + " 人,但投递全部失败。"
|
||||
String tip = "找到负责人 " + leaders.size() + " 人(" + leaderNames + "),但投递全部失败。"
|
||||
+ (failHints.isEmpty() ? "请确认 thirdparty 已重启。" : ("原因:" + String.join(";", failHints)));
|
||||
savePushLog(SmHotelMgrPushLogEntity.TYPE_CALENDAR, trigger, day + "-" + hour, null, null,
|
||||
logDepts, new ArrayList<>(leaders), 0, fail, title, content, tip);
|
||||
logDepts, leaderList, 0, fail, title, content, tip, requestJson);
|
||||
return force ? PushResult.fail(tip) : PushResult.skip(tip);
|
||||
}
|
||||
String msg = "日历提醒成功 " + ok + "/" + leaders.size() + " 人(负责人=" + leaders + ")";
|
||||
kickPushDeliveryScan();
|
||||
String msg = "日历提醒成功 " + ok + "/" + leaders.size() + " 人(负责人=" + leaderNames + ")";
|
||||
if (fail > 0 && !failHints.isEmpty()) {
|
||||
msg = msg + ";部分失败:" + String.join(";", failHints);
|
||||
}
|
||||
savePushLog(SmHotelMgrPushLogEntity.TYPE_CALENDAR, trigger, day + "-" + hour, null, null,
|
||||
logDepts, new ArrayList<>(leaders), ok, fail, title, content, msg);
|
||||
logDepts, leaderList, ok, fail, title, content, msg, requestJson);
|
||||
return PushResult.success(ok, leaders.size(), msg);
|
||||
}
|
||||
|
||||
private static String buildNewOrderDetail(String orderLabel, String deptName, String leaderNames,
|
||||
String adminNames, boolean adminEnabled) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("(订单=").append(StrUtil.blankToDefault(orderLabel, "-"));
|
||||
if (StrUtil.isNotBlank(deptName)) {
|
||||
sb.append(",部门=").append(deptName);
|
||||
}
|
||||
if (StrUtil.isNotBlank(leaderNames)) {
|
||||
sb.append(",负责人=").append(leaderNames);
|
||||
}
|
||||
if (adminEnabled) {
|
||||
sb.append(",管理员=").append(StrUtil.blankToDefault(adminNames, "无"));
|
||||
}
|
||||
sb.append(")");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/** 按角色编码取当前租户有效用户,失败返回空列表 */
|
||||
private List<Long> resolveUsersByRoleCode(String roleCode) {
|
||||
if (StrUtil.isBlank(roleCode)) {
|
||||
return List.of();
|
||||
}
|
||||
try {
|
||||
R<List<Long>> resp = remoteUserService.getInnerUserIdsByRoleCode(roleCode);
|
||||
if (resp == null || !resp.isOk() || CollUtil.isEmpty(resp.getData())) {
|
||||
log.info("|酒店管理推送|角色无用户|roleCode={}|code={}|msg={}|", roleCode,
|
||||
resp == null ? null : resp.getCode(), resp == null ? "null" : resp.getMsg());
|
||||
return List.of();
|
||||
}
|
||||
List<Long> ids = new ArrayList<>();
|
||||
for (Object o : resp.getData()) {
|
||||
Long id = toLong(o);
|
||||
if (id != null) {
|
||||
ids.add(id);
|
||||
}
|
||||
}
|
||||
return ids.stream().distinct().toList();
|
||||
}
|
||||
catch (Exception e) {
|
||||
log.warn("|酒店管理推送|查角色用户异常|roleCode={}|err={}|", roleCode, e.getMessage());
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
private String displayUserName(Long userId) {
|
||||
if (userId == null) {
|
||||
return "-";
|
||||
}
|
||||
String names = resolveUserNames(List.of(userId));
|
||||
return StrUtil.blankToDefault(names, String.valueOf(userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装下游排查快照:有失败时优先写失败请求;全部成功也保留 1 条样例请求便于对照。
|
||||
*/
|
||||
private static String buildRequestSnapshot(HotelManagerPushEventDto sample, List<Map<String, Object>> failItems,
|
||||
int ok, int fail) {
|
||||
if (sample == null && CollUtil.isEmpty(failItems)) {
|
||||
return null;
|
||||
}
|
||||
Map<String, Object> root = new HashMap<>(8);
|
||||
root.put("api", "thirdparty receiveHotelManagerPushEvent");
|
||||
root.put("successCount", ok);
|
||||
root.put("failCount", fail);
|
||||
if (CollUtil.isNotEmpty(failItems)) {
|
||||
root.put("fails", failItems);
|
||||
}
|
||||
else if (sample != null) {
|
||||
root.put("sampleRequest", sample);
|
||||
}
|
||||
return StrUtil.maxLength(JSONUtil.toJsonStr(root), 60000);
|
||||
}
|
||||
|
||||
private static Map<String, Object> buildFailRequestItem(String recipientName, String error,
|
||||
HotelManagerPushEventDto dto) {
|
||||
Map<String, Object> item = new HashMap<>(4);
|
||||
item.put("recipientName", recipientName);
|
||||
item.put("error", error);
|
||||
item.put("request", dto);
|
||||
return item;
|
||||
}
|
||||
|
||||
/** 是否已有该订单的自动新订单成功/部分成功记录(补偿重试不应再刷手机与推送日志) */
|
||||
private boolean alreadyAutoPushedNewOrder(Long orderId) {
|
||||
if (orderId == null) {
|
||||
return false;
|
||||
}
|
||||
Long cnt = pushLogMapper.selectCount(Wrappers.<SmHotelMgrPushLogEntity>lambdaQuery()
|
||||
.eq(SmHotelMgrPushLogEntity::getPushType, SmHotelMgrPushLogEntity.TYPE_NEW_ORDER)
|
||||
.eq(SmHotelMgrPushLogEntity::getTriggerType, SmHotelMgrPushLogEntity.TRIGGER_AUTO)
|
||||
.eq(SmHotelMgrPushLogEntity::getBizId, String.valueOf(orderId))
|
||||
.in(SmHotelMgrPushLogEntity::getStatus, SmHotelMgrPushLogEntity.STATUS_OK,
|
||||
SmHotelMgrPushLogEntity.STATUS_PARTIAL));
|
||||
return cnt != null && cnt > 0;
|
||||
}
|
||||
|
||||
private void savePushLog(String pushType, String triggerType, String bizId, Long hotelId, String hotelName,
|
||||
List<Long> deptIds, List<Long> userIds, int success, int fail, String title, String content,
|
||||
String resultMsg) {
|
||||
String resultMsg, String requestJson) {
|
||||
try {
|
||||
SmHotelMgrPushLogEntity row = new SmHotelMgrPushLogEntity();
|
||||
row.setPushType(pushType);
|
||||
@@ -401,6 +571,7 @@ public class SmHotelMgrPushServiceImpl implements SmHotelMgrPushService {
|
||||
row.setTitle(title);
|
||||
row.setContent(StrUtil.maxLength(content, 500));
|
||||
row.setResultMsg(StrUtil.maxLength(resultMsg, 900));
|
||||
row.setRequestJson(requestJson);
|
||||
if (success > 0 && fail <= 0) {
|
||||
row.setStatus(SmHotelMgrPushLogEntity.STATUS_OK);
|
||||
}
|
||||
@@ -500,6 +671,21 @@ public class SmHotelMgrPushServiceImpl implements SmHotelMgrPushService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 入箱后立刻扫一轮极光投递,避免只靠 Quartz(任务停了会一直不响) */
|
||||
private void kickPushDeliveryScan() {
|
||||
if (remotePushDeliveryService == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
R<Integer> resp = remotePushDeliveryService.processDueDeliveries();
|
||||
log.info("|酒店管理推送|踢投递扫描|claimed={}|ok={}|",
|
||||
resp == null ? null : resp.getData(), resp != null && resp.isOk());
|
||||
}
|
||||
catch (Exception e) {
|
||||
log.warn("|酒店管理推送|踢投递扫描失败|{}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 本部门负责人(不含下级):upms listDeptLeader,岗位编码 TEAM_LEADER。
|
||||
* Feign 反序列化可能把 id 解成 Integer,这里统一转 Long。
|
||||
@@ -555,6 +741,7 @@ public class SmHotelMgrPushServiceImpl implements SmHotelMgrPushService {
|
||||
row.setNewOrderEnabled(ENABLED);
|
||||
row.setNewOrderTitle(DEFAULT_NEW_TITLE);
|
||||
row.setNewOrderContent(DEFAULT_NEW_CONTENT);
|
||||
row.setNewOrderAdminEnabled(ENABLED);
|
||||
row.setCalendarRemindEnabled(ENABLED);
|
||||
row.setCalendarRemindTitle(DEFAULT_CAL_TITLE);
|
||||
row.setCalendarRemindContent(DEFAULT_CAL_CONTENT);
|
||||
|
||||
+209
-107
@@ -252,7 +252,7 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
|
||||
hotelPullTx.executeWithoutResult(status -> {
|
||||
hotelMapper.updateById(hotel);
|
||||
counts[0] = upsertRoomsFromComplex(hotel, complex);
|
||||
ensureRoomsBatch(hotel, roomTypesFromPrice);
|
||||
ensureRoomsBatch(hotel, roomTypesFromPrice);
|
||||
counts[1] = persistProducts(hotel, productMap);
|
||||
counts[2] = persistDailies(hotel, dailyDrafts, start, endExclusive);
|
||||
});
|
||||
@@ -285,6 +285,7 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
|
||||
result.put("priceDays", priceDays);
|
||||
result.put("hotelCode", hotelCode);
|
||||
result.put("markupPercent", priceConfigService.currentMarkupPercent());
|
||||
result.put("markupFixed", priceConfigService.currentMarkupFixed());
|
||||
result.put("upstreamTips", upstreamTips);
|
||||
log.info("|酒店拉取上游|结束|hotelCode={}|room={}|product={}|daily={}|tips={}|", hotelCode, roomCount,
|
||||
productCount, dailyCount, upstreamTips);
|
||||
@@ -574,12 +575,9 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
|
||||
.last("LIMIT 1"));
|
||||
if (db != null) {
|
||||
fake.setRoomTypeCode(StrUtil.blankToDefault(db.getRoomTypeCode(), fake.getRoomTypeCode()));
|
||||
if (db.getSellPrice() != null && db.getSellPrice().compareTo(BigDecimal.ZERO) > 0) {
|
||||
price = db.getSellPrice();
|
||||
}
|
||||
else if (db.getCostPrice() != null && db.getCostPrice().compareTo(BigDecimal.ZERO) > 0) {
|
||||
price = db.getCostPrice();
|
||||
}
|
||||
fake.setCostPrice(db.getCostPrice());
|
||||
fake.setSellPrice(db.getSellPrice());
|
||||
price = outboundSell(db.getCostPrice(), db.getSellPrice(), partner);
|
||||
}
|
||||
int n = pushCloseDailyRanges(partner, hotel, List.of(fake), price);
|
||||
Map<String, Object> data = new LinkedHashMap<>(6);
|
||||
@@ -1080,7 +1078,7 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
|
||||
if (row == null) {
|
||||
continue;
|
||||
}
|
||||
BigDecimal price = row.getSellPrice() != null ? row.getSellPrice() : row.getCostPrice();
|
||||
BigDecimal price = outboundSell(row.getCostPrice(), row.getSellPrice(), partnerCode);
|
||||
if (price == null || price.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
// 无有效价不推,不用 1 元占位
|
||||
continue;
|
||||
@@ -1176,7 +1174,7 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
|
||||
downstreamClient.hotelSync(partnerCode, hotelId, supplierHotelId, staticEnvelope);
|
||||
|
||||
List<Map<String, Object>> roomPriceInfoList = buildHuidingRoomPriceInfoList(hotel, supplierHotelId, products,
|
||||
dailies);
|
||||
dailies, partnerCode);
|
||||
Map<String, Object> dateEnvelope = buildHuidingDatePushEnvelope(ds, hotel, dailyFrom, dailyTo, roomPriceInfoList);
|
||||
downstreamClient.dailySync(partnerCode, hotelId, supplierHotelId, dateEnvelope);
|
||||
|
||||
@@ -1197,10 +1195,7 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
|
||||
Map<String, Object> hotelObj = new LinkedHashMap<>();
|
||||
hotelObj.put("hotelId", supplierHotelId);
|
||||
hotelObj.put("hotelName", StrUtil.blankToDefault(hotel.getHotelName(), supplierHotelId));
|
||||
String cityName = extractHuanqiCityNameFromDistrict(StrUtil.trim(hotel.getDistrict()));
|
||||
if (StrUtil.isBlank(cityName)) {
|
||||
cityName = StrUtil.trim(hotel.getDistrict());
|
||||
}
|
||||
String cityName = resolveHuanqiCityName(hotel, null);
|
||||
hotelObj.put("cityName", StrUtil.blankToDefault(cityName, "未知"));
|
||||
hotelObj.put("address",
|
||||
StrUtil.blankToDefault(buildHuanqiFullAddress(hotel.getDistrict(), hotel.getAddress()), "-"));
|
||||
@@ -1265,7 +1260,7 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
|
||||
* 按产品聚合日历;慧订 roomTypeCode 全局唯一;ratePlanCode 按早餐建议值。
|
||||
*/
|
||||
private List<Map<String, Object>> buildHuidingRoomPriceInfoList(SmHotelEntity hotel, String supplierHotelId,
|
||||
List<SmHotelProductEntity> products, List<SmHotelDailyEntity> dailies) {
|
||||
List<SmHotelProductEntity> products, List<SmHotelDailyEntity> dailies, String partnerCode) {
|
||||
Map<String, SmHotelProductEntity> productByCode = new LinkedHashMap<>();
|
||||
for (SmHotelProductEntity p : products) {
|
||||
if (p != null && StrUtil.isNotBlank(p.getProductCode())) {
|
||||
@@ -1304,10 +1299,7 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
|
||||
else {
|
||||
status = 0;
|
||||
}
|
||||
BigDecimal price = d.getSellPrice() != null ? d.getSellPrice() : d.getCostPrice();
|
||||
if (price == null || price.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
price = BigDecimal.ONE;
|
||||
}
|
||||
BigDecimal price = outboundSell(d.getCostPrice(), d.getSellPrice(), partnerCode);
|
||||
Map<String, Object> night = new LinkedHashMap<>();
|
||||
night.put("night", d.getStayDate().toString());
|
||||
night.put("price", price);
|
||||
@@ -1458,10 +1450,7 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
|
||||
for (SmHotelProductEntity p : products) {
|
||||
BigDecimal price = defaultPrice;
|
||||
if (price == null || price.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
price = p.getSellPrice() != null ? p.getSellPrice() : p.getCostPrice();
|
||||
}
|
||||
if (price == null || price.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
price = BigDecimal.ONE;
|
||||
price = outboundSell(p.getCostPrice(), p.getSellPrice(), partnerCode);
|
||||
}
|
||||
Map<String, Object> item = new LinkedHashMap<>();
|
||||
item.put("startDate", from.toString());
|
||||
@@ -1593,13 +1582,17 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
|
||||
hotelBody.put("cityName", hotel.getDistrict());
|
||||
}
|
||||
parseRemarkAddress(hotel.getRemark(), hotelBody);
|
||||
// 环启必填:cityName / address / receptionPhone;缺一则本店推送失败
|
||||
String cityName = StrUtil.trim(strObj(hotelBody.get("cityName")));
|
||||
// cityName:只从位置区域取「xx市」,没有再看其它字段;取不到则不调环启,只记跳过流水
|
||||
String cityName = resolveHuanqiCityName(hotel, StrUtil.trim(strObj(hotelBody.get("cityName"))));
|
||||
if (StrUtil.isBlank(cityName)) {
|
||||
cityName = StrUtil.trim(hotel.getDistrict());
|
||||
String reason = "位置区域未解析到市级(需含 xx市),跳过环启推送;district="
|
||||
+ StrUtil.blankToDefault(hotel.getDistrict(), "空");
|
||||
recordDownstreamPushSkip(partnerCode, hotel, reason);
|
||||
Map<String, Object> skip = new HashMap<>(6);
|
||||
skip.put("skipped", true);
|
||||
skip.put("skipReason", reason);
|
||||
return skip;
|
||||
}
|
||||
// 环启:只取位置区域里「市级」那一层,再去掉「市」(恩施市→恩施;青岛市城阳区→青岛)
|
||||
cityName = extractHuanqiCityNameFromDistrict(cityName);
|
||||
String address = StrUtil.trim(strObj(hotelBody.get("address")));
|
||||
if (StrUtil.isBlank(address)) {
|
||||
address = buildHuanqiFullAddress(hotel.getDistrict(), hotel.getAddress());
|
||||
@@ -1612,9 +1605,9 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
|
||||
if (StrUtil.isBlank(phone)) {
|
||||
phone = StrUtil.trim(hotel.getPhone());
|
||||
}
|
||||
if (StrUtil.isBlank(cityName) || StrUtil.isBlank(address) || StrUtil.isBlank(phone)) {
|
||||
throw new IllegalArgumentException("环启酒店必填缺失:cityName/address/receptionPhone(hotelId=" + hotelId
|
||||
+ ",请补全城市/地址/电话后再推)");
|
||||
if (StrUtil.isBlank(address) || StrUtil.isBlank(phone)) {
|
||||
throw new IllegalArgumentException("环启酒店必填缺失:address/receptionPhone(hotelId=" + hotelId
|
||||
+ ",请补全地址/电话后再推)");
|
||||
}
|
||||
hotelBody.put("cityName", cityName);
|
||||
hotelBody.put("address", address);
|
||||
@@ -1716,8 +1709,7 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
|
||||
body.put("apartmentType", p.getApartmentType() == null ? 1 : p.getApartmentType());
|
||||
body.put("productType", p.getProductType() == null ? 2 : p.getProductType());
|
||||
SmHotelCancelPolicy.putHuanqiProductCancel(body, resolveUpstreamCancelPolicy(hotel, p));
|
||||
BigDecimal price = p.getSellPrice() != null ? p.getSellPrice() : p.getCostPrice();
|
||||
body.put("homePrice", price == null || price.compareTo(BigDecimal.ZERO) <= 0 ? BigDecimal.ONE : price);
|
||||
body.put("homePrice", outboundSell(p.getCostPrice(), p.getSellPrice(), partnerCode));
|
||||
body.put("homeNumber", roomQtyMap.getOrDefault(roomCode, 1));
|
||||
try {
|
||||
downstreamClient.productSync(partnerCode, hotelId, extHotelId, body);
|
||||
@@ -1757,7 +1749,7 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
|
||||
}
|
||||
String roomCode = rows.get(0).getRoomTypeCode();
|
||||
String productCode = e.getKey();
|
||||
List<Map<String, Object>> ranges = mergeDailyRanges(rows);
|
||||
List<Map<String, Object>> ranges = mergeDailyRanges(rows, partnerCode);
|
||||
for (List<Map<String, Object>> batch : CollUtil.split(ranges, 50)) {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("extHotelId", extHotelId);
|
||||
@@ -1782,31 +1774,35 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
|
||||
|
||||
/**
|
||||
* 连续同价/剩余库存/有效房态日期合并为区间(环启 list 最多 50 段)。
|
||||
* 剩余 = max(0, stock - sold);剩余为 0 时按关房推,避免环启仍按配额超卖。
|
||||
* 同价按下游动态卖价比较。剩余 = max(0, stock - sold);无剩余按关房推。
|
||||
*/
|
||||
private static List<Map<String, Object>> mergeDailyRanges(List<SmHotelDailyEntity> rows) {
|
||||
private List<Map<String, Object>> mergeDailyRanges(List<SmHotelDailyEntity> rows, String partnerCode) {
|
||||
List<Map<String, Object>> ranges = new ArrayList<>();
|
||||
if (CollUtil.isEmpty(rows)) {
|
||||
return ranges;
|
||||
}
|
||||
SmHotelDailyEntity start = rows.get(0);
|
||||
SmHotelDailyEntity prev = start;
|
||||
BigDecimal prevPrice = outboundSell(prev.getCostPrice(), prev.getSellPrice(), partnerCode);
|
||||
for (int i = 1; i < rows.size(); i++) {
|
||||
SmHotelDailyEntity cur = rows.get(i);
|
||||
BigDecimal curPrice = outboundSell(cur.getCostPrice(), cur.getSellPrice(), partnerCode);
|
||||
boolean contiguous = prev.getStayDate() != null && cur.getStayDate() != null
|
||||
&& prev.getStayDate().plusDays(1).equals(cur.getStayDate());
|
||||
boolean same = Objects.equals(prev.getSellPrice(), cur.getSellPrice())
|
||||
boolean same = Objects.equals(prevPrice, curPrice)
|
||||
&& huanqiRemainStock(prev) == huanqiRemainStock(cur)
|
||||
&& huanqiDailyStatus(prev) == huanqiDailyStatus(cur);
|
||||
if (contiguous && same) {
|
||||
prev = cur;
|
||||
prevPrice = curPrice;
|
||||
continue;
|
||||
}
|
||||
ranges.add(dailyRangeItem(start, prev));
|
||||
ranges.add(dailyRangeItem(start, prev, prevPrice));
|
||||
start = cur;
|
||||
prev = cur;
|
||||
prevPrice = curPrice;
|
||||
}
|
||||
ranges.add(dailyRangeItem(start, prev));
|
||||
ranges.add(dailyRangeItem(start, prev, prevPrice));
|
||||
return ranges;
|
||||
}
|
||||
|
||||
@@ -1829,11 +1825,11 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static Map<String, Object> dailyRangeItem(SmHotelDailyEntity start, SmHotelDailyEntity end) {
|
||||
private static Map<String, Object> dailyRangeItem(SmHotelDailyEntity start, SmHotelDailyEntity end, BigDecimal price) {
|
||||
Map<String, Object> item = new LinkedHashMap<>();
|
||||
item.put("startDate", start.getStayDate().toString());
|
||||
item.put("endDate", end.getStayDate().toString());
|
||||
item.put("dailyPrice", end.getSellPrice());
|
||||
item.put("dailyPrice", price);
|
||||
int remain = huanqiRemainStock(end);
|
||||
item.put("dailyNumber", remain);
|
||||
item.put("dailyStatus", huanqiDailyStatus(end));
|
||||
@@ -1844,11 +1840,11 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
|
||||
return o == null ? null : String.valueOf(o);
|
||||
}
|
||||
|
||||
/** 有库存候选里取卖价(无则成本价)最低的一条 */
|
||||
/** 有库存候选里取协议价(无则卖价)最低的一条 */
|
||||
private static SmHotelProductEntity inProductLowestPrice(List<SmHotelProductEntity> inStock) {
|
||||
return inStock.stream().min((a, b) -> {
|
||||
BigDecimal pa = a.getSellPrice() != null ? a.getSellPrice() : a.getCostPrice();
|
||||
BigDecimal pb = b.getSellPrice() != null ? b.getSellPrice() : b.getCostPrice();
|
||||
BigDecimal pa = a.getCostPrice() != null ? a.getCostPrice() : a.getSellPrice();
|
||||
BigDecimal pb = b.getCostPrice() != null ? b.getCostPrice() : b.getSellPrice();
|
||||
if (pa == null && pb == null) {
|
||||
return 0;
|
||||
}
|
||||
@@ -1919,19 +1915,27 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
|
||||
}
|
||||
SmHotelDetailVO vo = new SmHotelDetailVO();
|
||||
vo.setHotel(hotel);
|
||||
// 详情列表不带 rawJson:上游原文很大,管理端 UI 不用,会显著拖慢详情接口
|
||||
vo.setRooms(roomMapper.selectList(Wrappers.<SmHotelRoomEntity>lambdaQuery()
|
||||
.select(SmHotelRoomEntity.class, i -> !"rawJson".equals(i.getProperty()))
|
||||
.eq(SmHotelRoomEntity::getHotelId, hotelId)
|
||||
.orderByAsc(SmHotelRoomEntity::getRoomTypeCode)));
|
||||
vo.setProducts(productMapper.selectList(Wrappers.<SmHotelProductEntity>lambdaQuery()
|
||||
.select(SmHotelProductEntity.class, i -> !"rawJson".equals(i.getProperty()))
|
||||
.eq(SmHotelProductEntity::getHotelId, hotelId)
|
||||
.orderByAsc(SmHotelProductEntity::getProductCode)));
|
||||
// 详情预览只统计近 90 天,避免「今天起无上界 COUNT」扫爆日历大表拖慢详情
|
||||
LocalDate dailyFrom = LocalDate.now();
|
||||
LocalDate dailyToEx = dailyFrom.plusDays(90);
|
||||
Long dailyCount = dailyMapper.selectCount(Wrappers.<SmHotelDailyEntity>lambdaQuery()
|
||||
.eq(SmHotelDailyEntity::getHotelId, hotelId)
|
||||
.ge(SmHotelDailyEntity::getStayDate, LocalDate.now()));
|
||||
.ge(SmHotelDailyEntity::getStayDate, dailyFrom)
|
||||
.lt(SmHotelDailyEntity::getStayDate, dailyToEx));
|
||||
vo.setDailyCount(dailyCount);
|
||||
vo.setDailyPreview(dailyMapper.selectList(Wrappers.<SmHotelDailyEntity>lambdaQuery()
|
||||
.eq(SmHotelDailyEntity::getHotelId, hotelId)
|
||||
.ge(SmHotelDailyEntity::getStayDate, LocalDate.now())
|
||||
.ge(SmHotelDailyEntity::getStayDate, dailyFrom)
|
||||
.lt(SmHotelDailyEntity::getStayDate, dailyToEx)
|
||||
.orderByAsc(SmHotelDailyEntity::getStayDate)
|
||||
.orderByAsc(SmHotelDailyEntity::getProductCode)
|
||||
.last("limit 30")));
|
||||
@@ -1959,8 +1963,10 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
|
||||
.orderByAsc(SmHotelDailyEntity::getStayDate)
|
||||
.orderByAsc(SmHotelDailyEntity::getProductCode));
|
||||
if (result.getRecords() != null) {
|
||||
List<String> partners = channelConfigService.listEnabledDownstreamPartners();
|
||||
for (SmHotelDailyEntity row : result.getRecords()) {
|
||||
fillDailyRemain(row);
|
||||
fillDailyDynamicPrices(row, partners);
|
||||
}
|
||||
}
|
||||
return R.ok(result);
|
||||
@@ -1976,6 +1982,43 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
|
||||
row.setRemainStock(Math.max(0, stock - sold));
|
||||
}
|
||||
|
||||
/** 日历行补默认卖价 + 各下游动态卖价(协议价为底) */
|
||||
private void fillDailyDynamicPrices(SmHotelDailyEntity row, List<String> partners) {
|
||||
if (row == null) {
|
||||
return;
|
||||
}
|
||||
BigDecimal base = protocolBase(row.getCostPrice(), row.getSellPrice());
|
||||
row.setDefaultSellPrice(priceConfigService.resolveSellPrice(base, null));
|
||||
Map<String, BigDecimal> channelMap = new LinkedHashMap<>();
|
||||
if (partners != null) {
|
||||
for (String code : partners) {
|
||||
if (StrUtil.isBlank(code)) {
|
||||
continue;
|
||||
}
|
||||
channelMap.put(code, priceConfigService.resolveSellPrice(base, code));
|
||||
}
|
||||
}
|
||||
row.setChannelSellPrices(channelMap);
|
||||
}
|
||||
|
||||
/** 协议价优先;无则回落已存卖价 */
|
||||
private static BigDecimal protocolBase(BigDecimal cost, BigDecimal sell) {
|
||||
if (cost != null && cost.compareTo(BigDecimal.ZERO) > 0) {
|
||||
return cost;
|
||||
}
|
||||
return sell;
|
||||
}
|
||||
|
||||
/** 下推出站卖价:按 partner 动态加价 */
|
||||
private BigDecimal outboundSell(BigDecimal cost, BigDecimal sell, String partnerCode) {
|
||||
BigDecimal base = protocolBase(cost, sell);
|
||||
BigDecimal resolved = priceConfigService.resolveSellPrice(base, partnerCode);
|
||||
if (resolved == null || resolved.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return BigDecimal.ONE;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
@Override
|
||||
public R<Map<String, Object>> listAuthorizedHotels(String keyword, long current, long size, boolean refresh) {
|
||||
if (current < 1) {
|
||||
@@ -2782,14 +2825,15 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
|
||||
product.setCanCancel(canCancel);
|
||||
product.setCancelRule(cancelRule);
|
||||
product.setCostPrice(cost);
|
||||
product.setSellPrice(priceConfigService.applyMarkup(cost));
|
||||
// 上游落库只存协议价;默认卖价与渠道卖价推送时动态 resolve,避免写死全局加价
|
||||
product.setSellPrice(cost);
|
||||
product.setStock(stock);
|
||||
product.setRawJson(rate.toJSONString());
|
||||
productMap.put(productCode, product);
|
||||
}
|
||||
else {
|
||||
product.setCostPrice(cost);
|
||||
product.setSellPrice(priceConfigService.applyMarkup(cost));
|
||||
product.setSellPrice(cost);
|
||||
if (stock > (product.getStock() == null ? 0 : product.getStock())) {
|
||||
product.setStock(stock);
|
||||
}
|
||||
@@ -2809,7 +2853,7 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
|
||||
draft.roomTypeCode = roomTypeId;
|
||||
draft.stayDate = LocalDate.parse(bizDate.substring(0, 10));
|
||||
draft.costPrice = cost;
|
||||
draft.sellPrice = priceConfigService.applyMarkup(cost);
|
||||
draft.sellPrice = cost;
|
||||
draft.stock = stock;
|
||||
draft.dailyStatus = stock > 0 ? 1 : 0;
|
||||
dailyDrafts.add(draft);
|
||||
@@ -2874,12 +2918,12 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 日历落库:先按半开区间物理删除再批量插入。
|
||||
* 日历落库:窗内 upsert(有则改、无则批量插);上游未带回的日期关房+库存 0,不做整窗物理删。
|
||||
* <p>
|
||||
* 唯一键 uk_product_date=(tenant_id, product_id, stay_date) 不含 del_flag,必须物理删。
|
||||
* 删除区间须覆盖本次将写入的日期(含回调报文里的历史日),不能写死 LocalDate.now()。
|
||||
* 唯一键 uk_product_date=(tenant_id, product_id, stay_date)。更新时保留 sold。
|
||||
* 过期(今天往前 30 天)顺带物理清理,避免关房行无限堆积。
|
||||
* <p>
|
||||
* 全量详情多酒店并行时,同租户唯一索引上易出现死锁,失败自动重试几次。
|
||||
* 全量/近窗多酒店并行时仍可能死锁,失败自动重试几次。
|
||||
*/
|
||||
private int persistDailies(SmHotelEntity hotel, List<DailyDraft> drafts, LocalDate fromInclusive,
|
||||
LocalDate toExclusive) {
|
||||
@@ -2907,15 +2951,14 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private int persistDailiesOnce(SmHotelEntity hotel, List<DailyDraft> drafts, LocalDate fromInclusive,
|
||||
LocalDate toExclusive) {
|
||||
LocalDate delFrom = fromInclusive;
|
||||
LocalDate delTo = toExclusive;
|
||||
if (delFrom == null || delTo == null || !delFrom.isBefore(delTo)) {
|
||||
// 兜底:无有效区间时按草稿最值;再无则只清今天起
|
||||
LocalDate winFrom = fromInclusive;
|
||||
LocalDate winToEx = toExclusive;
|
||||
if (winFrom == null || winToEx == null || !winFrom.isBefore(winToEx)) {
|
||||
if (CollUtil.isNotEmpty(drafts)) {
|
||||
LocalDate min = null;
|
||||
LocalDate max = null;
|
||||
@@ -2931,24 +2974,25 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
|
||||
}
|
||||
}
|
||||
if (min != null && max != null) {
|
||||
delFrom = min;
|
||||
delTo = max.plusDays(1);
|
||||
winFrom = min;
|
||||
winToEx = max.plusDays(1);
|
||||
}
|
||||
}
|
||||
if (delFrom == null || delTo == null || !delFrom.isBefore(delTo)) {
|
||||
delFrom = LocalDate.now();
|
||||
delTo = null;
|
||||
if (winFrom == null || winToEx == null || !winFrom.isBefore(winToEx)) {
|
||||
winFrom = LocalDate.now();
|
||||
winToEx = winFrom.plusDays(90);
|
||||
}
|
||||
}
|
||||
if (delTo == null) {
|
||||
dailyMapper.physicalDeleteFromDate(hotel.getId(), delFrom);
|
||||
|
||||
// 过期日历物理清(单酒店、今天往前 30 天),与窗内 upsert 无关
|
||||
LocalDate expireBefore = LocalDate.now().minusDays(30);
|
||||
try {
|
||||
dailyMapper.physicalDeleteBefore(hotel.getId(), expireBefore);
|
||||
}
|
||||
else {
|
||||
dailyMapper.physicalDeleteRange(hotel.getId(), delFrom, delTo);
|
||||
}
|
||||
if (CollUtil.isEmpty(drafts)) {
|
||||
return 0;
|
||||
catch (Exception e) {
|
||||
log.warn("|酒店日历落库|过期清理失败|hotelId={}|err={}|", hotel.getId(), e.getMessage());
|
||||
}
|
||||
|
||||
Map<String, Long> productIdMap = new HashMap<>();
|
||||
List<SmHotelProductEntity> products = productMapper
|
||||
.selectList(Wrappers.<SmHotelProductEntity>lambdaQuery()
|
||||
@@ -2957,18 +3001,45 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
|
||||
for (SmHotelProductEntity p : products) {
|
||||
productIdMap.put(p.getProductCode(), p.getId());
|
||||
}
|
||||
|
||||
Map<String, DailyDraft> unique = new LinkedHashMap<>();
|
||||
if (CollUtil.isNotEmpty(drafts)) {
|
||||
for (DailyDraft d : drafts) {
|
||||
if (d == null || d.stayDate == null || StrUtil.isBlank(d.productCode)) {
|
||||
continue;
|
||||
}
|
||||
if (d.stayDate.isBefore(winFrom) || !d.stayDate.isBefore(winToEx)) {
|
||||
continue;
|
||||
}
|
||||
unique.put(d.productCode + "|" + d.stayDate, d);
|
||||
}
|
||||
}
|
||||
|
||||
List<SmHotelDailyEntity> existRows = dailyMapper.selectList(Wrappers.<SmHotelDailyEntity>lambdaQuery()
|
||||
.eq(SmHotelDailyEntity::getHotelId, hotel.getId())
|
||||
.ge(SmHotelDailyEntity::getStayDate, winFrom)
|
||||
.lt(SmHotelDailyEntity::getStayDate, winToEx));
|
||||
Map<String, SmHotelDailyEntity> existMap = new HashMap<>(Math.max(16, existRows.size() * 2));
|
||||
for (SmHotelDailyEntity row : existRows) {
|
||||
if (row.getProductCode() == null || row.getStayDate() == null) {
|
||||
continue;
|
||||
}
|
||||
existMap.put(row.getProductCode() + "|" + row.getStayDate(), row);
|
||||
}
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
Long tenantId = resolveTenantId();
|
||||
List<SmHotelDailyEntity> toInsert = new ArrayList<>(unique.size());
|
||||
List<SmHotelDailyEntity> toInsert = new ArrayList<>();
|
||||
List<SmHotelDailyEntity> toUpdate = new ArrayList<>();
|
||||
|
||||
for (DailyDraft d : unique.values()) {
|
||||
Long productId = productIdMap.get(d.productCode);
|
||||
if (productId == null) {
|
||||
continue;
|
||||
}
|
||||
String key = d.productCode + "|" + d.stayDate;
|
||||
SmHotelDailyEntity exist = existMap.remove(key);
|
||||
if (exist == null) {
|
||||
SmHotelDailyEntity daily = new SmHotelDailyEntity();
|
||||
daily.setHotelId(hotel.getId());
|
||||
daily.setProductId(productId);
|
||||
@@ -2978,13 +3049,43 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
|
||||
daily.setCostPrice(d.costPrice);
|
||||
daily.setSellPrice(d.sellPrice);
|
||||
daily.setStock(d.stock);
|
||||
daily.setSold(0);
|
||||
daily.setSold(0);
|
||||
daily.setDailyStatus(d.dailyStatus);
|
||||
fillNewMeta(daily, tenantId, now);
|
||||
toInsert.add(daily);
|
||||
continue;
|
||||
}
|
||||
// 有则改:保留 sold,避免冲掉本地占房
|
||||
exist.setProductId(productId);
|
||||
exist.setRoomTypeCode(d.roomTypeCode);
|
||||
exist.setCostPrice(d.costPrice);
|
||||
exist.setSellPrice(d.sellPrice);
|
||||
exist.setStock(d.stock);
|
||||
exist.setDailyStatus(d.dailyStatus);
|
||||
exist.setUpdateTime(now);
|
||||
toUpdate.add(exist);
|
||||
}
|
||||
|
||||
// 窗内本地有、上游本次未带回 → 关房 + 库存 0(不删行、不动 sold)
|
||||
for (SmHotelDailyEntity left : existMap.values()) {
|
||||
boolean alreadyClosed = Integer.valueOf(0).equals(left.getDailyStatus())
|
||||
&& (left.getStock() == null || left.getStock() <= 0);
|
||||
if (alreadyClosed) {
|
||||
continue;
|
||||
}
|
||||
left.setStock(0);
|
||||
left.setDailyStatus(0);
|
||||
left.setUpdateTime(now);
|
||||
toUpdate.add(left);
|
||||
}
|
||||
|
||||
insertDailiesBatch(toInsert);
|
||||
return toInsert.size();
|
||||
if (CollUtil.isNotEmpty(toUpdate)) {
|
||||
Db.updateBatchById(toUpdate, PERSIST_BATCH);
|
||||
}
|
||||
log.debug("|酒店日历落库|upsert|hotelId={}|window={}~{}|in={}|up={}|closeCandidate={}|", hotel.getId(),
|
||||
winFrom, winToEx, toInsert.size(), toUpdate.size(), existMap.size());
|
||||
return toInsert.size() + toUpdate.size();
|
||||
}
|
||||
|
||||
/** 并发拉详情时,日历表 uk_product_date 易出现 InnoDB 死锁 */
|
||||
@@ -3172,42 +3273,56 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 从位置区域文案取「市级」层名称并去掉「市」。
|
||||
* <p>
|
||||
* 规则:先按省/市/州/区县拆层;优先取以「市」结尾的那一层(县级市如恩施市也算);
|
||||
* 若只有区县(如城阳区),则退回上一层地级市(青岛市)。
|
||||
* </p>
|
||||
* 环启 cityName:
|
||||
* <ol>
|
||||
* <li>优先从「位置区域」district 取带「市」的那一层(天津市津南区 → 天津市)</li>
|
||||
* <li>没有再从其它字段(如 remark 的 city=、调用方传入值)取</li>
|
||||
* <li>仍取不到带「市」的名称则返回空,不编造</li>
|
||||
* </ol>
|
||||
*/
|
||||
static String resolveHuanqiCityName(SmHotelEntity hotel, String preferredRaw) {
|
||||
// 1) 位置区域
|
||||
String fromDistrict = extractHuanqiCityNameFromDistrict(hotel == null ? null : hotel.getDistrict());
|
||||
if (StrUtil.isNotBlank(fromDistrict)) {
|
||||
return fromDistrict;
|
||||
}
|
||||
// 2) 其它字段(remark 解析结果等)
|
||||
String fromOther = extractHuanqiCityNameFromDistrict(preferredRaw);
|
||||
if (StrUtil.isNotBlank(fromOther)) {
|
||||
return fromOther;
|
||||
}
|
||||
// 3) 没有市就不用了
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 从文案中只取以「市」结尾的一层;没有则返回空。
|
||||
* <ul>
|
||||
* <li>湖北省恩施土家族苗族自治州恩施市 → 恩施</li>
|
||||
* <li>山东省青岛市城阳区 → 青岛</li>
|
||||
* <li>北京市朝阳区 → 北京</li>
|
||||
* <li>天津市津南区 → 天津市</li>
|
||||
* <li>山东省青岛市城阳区 → 青岛市</li>
|
||||
* <li>湖北省恩施土家族苗族自治州恩施市 → 恩施市</li>
|
||||
* <li>津南区 / 天津城区 → 空(没有「xx市」就不取)</li>
|
||||
* </ul>
|
||||
*/
|
||||
static String extractHuanqiCityNameFromDistrict(String district) {
|
||||
if (StrUtil.isBlank(district)) {
|
||||
return "";
|
||||
}
|
||||
String s = district.trim().replace(" ", "");
|
||||
String s = district.trim().replace(" ", "").replace("/", "");
|
||||
List<String> segments = splitAreaSegments(s);
|
||||
if (segments.isEmpty()) {
|
||||
return stripCitySuffix(s);
|
||||
return s.endsWith("市") && !s.endsWith("市辖区") ? s : "";
|
||||
}
|
||||
// 1) 优先:名称以「市」结尾的层(青岛市 / 恩施市 / 北京市)
|
||||
for (int i = segments.size() - 1; i >= 0; i--) {
|
||||
String seg = segments.get(i);
|
||||
if ("市辖区".equals(seg)) {
|
||||
continue;
|
||||
}
|
||||
if (seg.endsWith("市") && !seg.endsWith("市辖区")) {
|
||||
return stripCitySuffix(seg);
|
||||
return seg;
|
||||
}
|
||||
}
|
||||
// 2) 次选:地级「地区/盟」(极少);自治州不作为环启 cityName(对方要的是市短名)
|
||||
for (int i = segments.size() - 1; i >= 0; i--) {
|
||||
String seg = segments.get(i);
|
||||
if (seg.endsWith("地区") || seg.endsWith("盟")) {
|
||||
return seg.replaceAll("(地区|盟)$", "");
|
||||
}
|
||||
}
|
||||
// 3) 兜底:最后一层去掉区县后缀
|
||||
return stripCitySuffix(segments.get(segments.size() - 1));
|
||||
return "";
|
||||
}
|
||||
|
||||
/** 湖北省恩施土家族苗族自治州恩施市 → [湖北省, 恩施土家族苗族自治州, 恩施市] */
|
||||
@@ -3223,19 +3338,6 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
|
||||
return list;
|
||||
}
|
||||
|
||||
/** 去掉末尾「市」(仅去一层后缀,不碰自治州等) */
|
||||
static String stripCitySuffix(String name) {
|
||||
if (StrUtil.isBlank(name)) {
|
||||
return "";
|
||||
}
|
||||
String n = name.trim();
|
||||
if (n.endsWith("市") && !n.endsWith("市辖区") && n.length() > 1) {
|
||||
return n.substring(0, n.length() - 1);
|
||||
}
|
||||
n = n.replaceAll("(区|县|旗)$", "");
|
||||
return n.trim();
|
||||
}
|
||||
|
||||
private static class DailyDraft {
|
||||
private String productCode;
|
||||
private String roomTypeCode;
|
||||
|
||||
+10
-2
@@ -20,6 +20,7 @@ import com.bsd.pjcloud.supermarket.mapper.SmHotelRoomMapper;
|
||||
import com.bsd.pjcloud.supermarket.service.SmHotelChannelDeptService;
|
||||
import com.bsd.pjcloud.supermarket.service.SmHotelOpenApiService;
|
||||
import com.bsd.pjcloud.supermarket.service.SmHotelOrderService;
|
||||
import com.bsd.pjcloud.supermarket.service.SmHotelPriceConfigService;
|
||||
import com.bsd.pjcloud.supermarket.util.HotelAreaAdcodeUtil;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -73,6 +74,8 @@ public class SmHotelOpenApiServiceImpl implements SmHotelOpenApiService {
|
||||
|
||||
private final SmHotelOpenProperties openProperties;
|
||||
|
||||
private final SmHotelPriceConfigService priceConfigService;
|
||||
|
||||
@Override
|
||||
public R<Map<String, Object>> hotelList(SmHotelDownstreamAppEntity app, Map<String, Object> body) {
|
||||
R<List<Long>> scopeR = requireAllowedHotelIds(app);
|
||||
@@ -274,6 +277,7 @@ public class SmHotelOpenApiServiceImpl implements SmHotelOpenApiService {
|
||||
List<SmHotelProductEntity> products = productMapper.selectList(Wrappers.<SmHotelProductEntity>lambdaQuery()
|
||||
.eq(SmHotelProductEntity::getHotelId, hotel.getId())
|
||||
.orderByAsc(SmHotelProductEntity::getProductCode));
|
||||
String partner = resolvePartnerCode(app);
|
||||
List<Map<String, Object>> productList = new ArrayList<>();
|
||||
for (SmHotelProductEntity p : products) {
|
||||
Map<String, Object> row = new LinkedHashMap<>(8);
|
||||
@@ -283,7 +287,9 @@ public class SmHotelOpenApiServiceImpl implements SmHotelOpenApiService {
|
||||
row.put("priceCode", p.getPriceCode());
|
||||
row.put("mealType", p.getMealType());
|
||||
row.put("mealCount", p.getMealCount());
|
||||
row.put("sellPrice", p.getSellPrice());
|
||||
BigDecimal base = p.getCostPrice() != null ? p.getCostPrice() : p.getSellPrice();
|
||||
row.put("costPrice", p.getCostPrice());
|
||||
row.put("sellPrice", priceConfigService.resolveSellPrice(base, partner));
|
||||
row.put("stock", p.getStock() == null ? 0 : p.getStock());
|
||||
productList.add(row);
|
||||
}
|
||||
@@ -321,13 +327,15 @@ public class SmHotelOpenApiServiceImpl implements SmHotelOpenApiService {
|
||||
.orderByAsc(SmHotelDailyEntity::getProductCode)
|
||||
.orderByAsc(SmHotelDailyEntity::getStayDate)
|
||||
.last("limit 5000"));
|
||||
String partner = resolvePartnerCode(app);
|
||||
List<Map<String, Object>> list = new ArrayList<>();
|
||||
for (SmHotelDailyEntity d : dailies) {
|
||||
Map<String, Object> row = new LinkedHashMap<>(10);
|
||||
row.put("productCode", d.getProductCode());
|
||||
row.put("stayDate", d.getStayDate() == null ? null : d.getStayDate().toString());
|
||||
row.put("sellPrice", d.getSellPrice());
|
||||
BigDecimal base = d.getCostPrice() != null ? d.getCostPrice() : d.getSellPrice();
|
||||
row.put("costPrice", d.getCostPrice());
|
||||
row.put("sellPrice", priceConfigService.resolveSellPrice(base, partner));
|
||||
row.put("stock", d.getStock());
|
||||
row.put("sold", d.getSold());
|
||||
// remainStock 非表字段,开放接口按 stock-sold 计算,避免下游永远拿不到剩余
|
||||
|
||||
+89
-13
@@ -900,9 +900,9 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
|
||||
if (body.get("guestList") != null) {
|
||||
order.setGuestJson(JSONUtil.toJsonStr(body.get("guestList")));
|
||||
}
|
||||
// 全局加价仅针对第三方来源;本地酒店卖价按日历/产品原价,markup=0
|
||||
// 加价快照:本地=0;下游按 partner 渠道规则;无 partner 用全局
|
||||
boolean localHotel = SmHotelDataSource.isLocal(hotel.getSheetName());
|
||||
order.setMarkupPercent(localHotel ? BigDecimal.ZERO : priceConfigService.currentMarkupPercent());
|
||||
applyOrderMarkupSnapshot(order, localHotel, order.getPartnerCode());
|
||||
order.setSellAmount(toDecimal(body.get("totalAmount")));
|
||||
if (product != null && product.getCostPrice() != null && order.getCount() != null) {
|
||||
order.setCostAmount(product.getCostPrice().multiply(BigDecimal.valueOf(order.getCount())));
|
||||
@@ -1175,13 +1175,28 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
|
||||
int stock = row.getStock() == null ? 0 : row.getStock();
|
||||
int sold = row.getSold() == null ? 0 : row.getSold();
|
||||
int remain = Math.max(0, stock - sold);
|
||||
BigDecimal sell = row.getSellPrice() != null ? row.getSellPrice()
|
||||
: (product.getSellPrice() != null ? product.getSellPrice() : BigDecimal.ZERO);
|
||||
// 本地酒店:用落库卖价;第三方:按 partner 对协议价动态加价(无配置=原价)
|
||||
BigDecimal sell;
|
||||
if (localDs) {
|
||||
sell = row.getSellPrice() != null ? row.getSellPrice()
|
||||
: (product.getSellPrice() != null ? product.getSellPrice() : BigDecimal.ZERO);
|
||||
}
|
||||
else {
|
||||
BigDecimal base = row.getCostPrice() != null ? row.getCostPrice()
|
||||
: (row.getSellPrice() != null ? row.getSellPrice()
|
||||
: (product.getCostPrice() != null ? product.getCostPrice()
|
||||
: product.getSellPrice()));
|
||||
sell = priceConfigService.resolveSellPrice(base, partnerCode);
|
||||
if (sell == null) {
|
||||
sell = BigDecimal.ZERO;
|
||||
}
|
||||
}
|
||||
localSell = localSell.add(sell.multiply(BigDecimal.valueOf(roomCnt)));
|
||||
one.put("exists", true);
|
||||
one.put("dailyStatus", row.getDailyStatus());
|
||||
one.put("remain", remain);
|
||||
one.put("sellPrice", sell);
|
||||
one.put("costPrice", row.getCostPrice());
|
||||
}
|
||||
days.add(one);
|
||||
}
|
||||
@@ -1407,8 +1422,17 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
|
||||
order.setGuestJson(JSONUtil.toJsonStr(List.of(guest)));
|
||||
}
|
||||
boolean localHotel = SmHotelDataSource.isLocal(hotel.getSheetName());
|
||||
order.setMarkupPercent(localHotel ? BigDecimal.ZERO : priceConfigService.currentMarkupPercent());
|
||||
if (product.getSellPrice() != null) {
|
||||
applyOrderMarkupSnapshot(order, localHotel, order.getPartnerCode());
|
||||
if (!localHotel) {
|
||||
BigDecimal sum = sumResolvedSellAmount(product.getId(), checkIn, checkOut, count, order.getPartnerCode());
|
||||
if (sum != null) {
|
||||
order.setSellAmount(sum);
|
||||
}
|
||||
else if (body.get("totalAmount") != null) {
|
||||
order.setSellAmount(toDecimal(body.get("totalAmount")));
|
||||
}
|
||||
}
|
||||
else if (product.getSellPrice() != null) {
|
||||
order.setSellAmount(product.getSellPrice().multiply(BigDecimal.valueOf(count)));
|
||||
}
|
||||
else if (body.get("totalAmount") != null) {
|
||||
@@ -1624,10 +1648,17 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
|
||||
guest.put("mobile", contactPhone);
|
||||
order.setGuestJson(JSONUtil.toJsonStr(List.of(guest)));
|
||||
}
|
||||
order.setMarkupPercent(localHotel ? BigDecimal.ZERO : priceConfigService.currentMarkupPercent());
|
||||
applyOrderMarkupSnapshot(order, localHotel, null);
|
||||
if (product.getCostPrice() != null) {
|
||||
order.setCostAmount(product.getCostPrice().multiply(BigDecimal.valueOf(count)));
|
||||
}
|
||||
// App 自营:按全局加价动态卖价合计
|
||||
if (!localHotel && order.getSellAmount() == null) {
|
||||
BigDecimal sum = sumResolvedSellAmount(product.getId(), checkIn, checkOut, count, null);
|
||||
if (sum != null) {
|
||||
order.setSellAmount(sum);
|
||||
}
|
||||
}
|
||||
order.setOrderStatus("CREATED");
|
||||
order.setPayStatus(PAY_UNPAID);
|
||||
order.setCompensateStatus(COMP_NONE);
|
||||
@@ -1653,15 +1684,21 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
|
||||
}
|
||||
}
|
||||
else {
|
||||
// 上游:优先用前端合计(含间夜),否则按产品单价×间数×间夜
|
||||
// 上游:优先前端合计;否则按日历协议价+全局加价动态合计
|
||||
if (body.get("totalAmount") != null) {
|
||||
order.setSellAmount(toDecimal(body.get("totalAmount")));
|
||||
}
|
||||
else if (product.getSellPrice() != null) {
|
||||
int nights = Math.max(1, SmHotelStayDates.occupyUnits(checkIn, checkOut));
|
||||
order.setSellAmount(product.getSellPrice()
|
||||
.multiply(BigDecimal.valueOf(count))
|
||||
.multiply(BigDecimal.valueOf(nights)));
|
||||
else {
|
||||
BigDecimal sum = sumResolvedSellAmount(product.getId(), checkIn, checkOut, count, null);
|
||||
if (sum != null) {
|
||||
order.setSellAmount(sum);
|
||||
}
|
||||
else if (product.getSellPrice() != null) {
|
||||
int nights = Math.max(1, SmHotelStayDates.occupyUnits(checkIn, checkOut));
|
||||
order.setSellAmount(product.getSellPrice()
|
||||
.multiply(BigDecimal.valueOf(count))
|
||||
.multiply(BigDecimal.valueOf(nights)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (order.getSellAmount() == null || order.getSellAmount().compareTo(BigDecimal.ZERO) <= 0) {
|
||||
@@ -2624,6 +2661,45 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
|
||||
return any ? sum : null;
|
||||
}
|
||||
|
||||
/** 按 partner 对日历协议价动态加价后合计(App partner=null 用全局) */
|
||||
private BigDecimal sumResolvedSellAmount(Long productId, LocalDate checkIn, LocalDate checkOut, int rooms,
|
||||
String partnerCode) {
|
||||
BigDecimal sum = BigDecimal.ZERO;
|
||||
boolean any = false;
|
||||
for (LocalDate d : SmHotelStayDates.occupyDates(checkIn, checkOut)) {
|
||||
SmHotelDailyEntity row = dailyMapper.selectOne(Wrappers.<SmHotelDailyEntity>lambdaQuery()
|
||||
.eq(SmHotelDailyEntity::getProductId, productId)
|
||||
.eq(SmHotelDailyEntity::getStayDate, d)
|
||||
.last("limit 1"));
|
||||
if (row == null) {
|
||||
continue;
|
||||
}
|
||||
BigDecimal base = row.getCostPrice() != null ? row.getCostPrice() : row.getSellPrice();
|
||||
BigDecimal sell = priceConfigService.resolveSellPrice(base, partnerCode);
|
||||
if (sell != null) {
|
||||
sum = sum.add(sell.multiply(BigDecimal.valueOf(rooms)));
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
return any ? sum : null;
|
||||
}
|
||||
|
||||
private void applyOrderMarkupSnapshot(SmHotelOrderEntity order, boolean localHotel, String partnerCode) {
|
||||
if (order == null) {
|
||||
return;
|
||||
}
|
||||
if (localHotel) {
|
||||
order.setMarkupPercent(BigDecimal.ZERO);
|
||||
order.setMarkupFixed(BigDecimal.ZERO);
|
||||
order.setPricePartnerCode(null);
|
||||
return;
|
||||
}
|
||||
SmHotelPriceConfigService.MarkupRule rule = priceConfigService.resolveMarkupRule(partnerCode);
|
||||
order.setMarkupPercent(rule.percent());
|
||||
order.setMarkupFixed(rule.fixed());
|
||||
order.setPricePartnerCode(StrUtil.isBlank(partnerCode) ? null : StrUtil.trim(partnerCode));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return null=通过;非空=失败原因
|
||||
*/
|
||||
|
||||
+171
-23
@@ -1,13 +1,17 @@
|
||||
package com.bsd.pjcloud.supermarket.service.impl;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.bsd.pjcloud.common.core.util.R;
|
||||
import com.bsd.pjcloud.supermarket.entity.SmHotelPriceChannelConfigEntity;
|
||||
import com.bsd.pjcloud.supermarket.entity.SmHotelPriceConfigEntity;
|
||||
import com.bsd.pjcloud.supermarket.mapper.SmHotelDailyMapper;
|
||||
import com.bsd.pjcloud.supermarket.mapper.SmHotelPriceChannelConfigMapper;
|
||||
import com.bsd.pjcloud.supermarket.mapper.SmHotelPriceConfigMapper;
|
||||
import com.bsd.pjcloud.supermarket.mapper.SmHotelProductMapper;
|
||||
import com.bsd.pjcloud.supermarket.service.SmHotelChannelConfigService;
|
||||
import com.bsd.pjcloud.supermarket.service.SmHotelPriceConfigService;
|
||||
import com.bsd.pjcloud.common.core.util.R;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -15,7 +19,9 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Slf4j
|
||||
@@ -24,16 +30,22 @@ import java.util.Map;
|
||||
public class SmHotelPriceConfigServiceImpl extends ServiceImpl<SmHotelPriceConfigMapper, SmHotelPriceConfigEntity>
|
||||
implements SmHotelPriceConfigService {
|
||||
|
||||
/** 折扣下限:不能 ≤ -100%,否则卖价为 0 或负 */
|
||||
private static final BigDecimal MIN_PERCENT = new BigDecimal("-99.99");
|
||||
|
||||
/** 加价上限,防止误填过大 */
|
||||
private static final BigDecimal MAX_PERCENT = new BigDecimal("500");
|
||||
|
||||
private static final BigDecimal MIN_SELL = new BigDecimal("0.01");
|
||||
|
||||
private static final String ENABLED = "1";
|
||||
|
||||
private final SmHotelProductMapper productMapper;
|
||||
|
||||
private final SmHotelDailyMapper dailyMapper;
|
||||
|
||||
private final SmHotelPriceChannelConfigMapper channelConfigMapper;
|
||||
|
||||
private final SmHotelChannelConfigService channelConfigService;
|
||||
|
||||
@Override
|
||||
public R<SmHotelPriceConfigEntity> getCurrent() {
|
||||
SmHotelPriceConfigEntity one = baseMapper.selectOne(Wrappers.<SmHotelPriceConfigEntity>lambdaQuery()
|
||||
@@ -42,6 +54,10 @@ public class SmHotelPriceConfigServiceImpl extends ServiceImpl<SmHotelPriceConfi
|
||||
if (one == null) {
|
||||
one = new SmHotelPriceConfigEntity();
|
||||
one.setMarkupPercent(BigDecimal.ZERO);
|
||||
one.setMarkupFixed(BigDecimal.ZERO);
|
||||
}
|
||||
else if (one.getMarkupFixed() == null) {
|
||||
one.setMarkupFixed(BigDecimal.ZERO);
|
||||
}
|
||||
return R.ok(one);
|
||||
}
|
||||
@@ -52,19 +68,19 @@ public class SmHotelPriceConfigServiceImpl extends ServiceImpl<SmHotelPriceConfi
|
||||
if (entity.getMarkupPercent() == null) {
|
||||
return R.failed("调整百分比不能为空");
|
||||
}
|
||||
BigDecimal p = entity.getMarkupPercent();
|
||||
if (p.compareTo(MIN_PERCENT) < 0) {
|
||||
return R.failed("折扣不能超过 99.99%(调整值须大于 -100)");
|
||||
}
|
||||
if (p.compareTo(MAX_PERCENT) > 0) {
|
||||
return R.failed("加价百分比不能超过 500%");
|
||||
String err = validatePercent(entity.getMarkupPercent());
|
||||
if (err != null) {
|
||||
return R.failed(err);
|
||||
}
|
||||
BigDecimal fixed = entity.getMarkupFixed() == null ? BigDecimal.ZERO : entity.getMarkupFixed();
|
||||
SmHotelPriceConfigEntity exist = baseMapper.selectOne(Wrappers.<SmHotelPriceConfigEntity>lambdaQuery()
|
||||
.last("limit 1"));
|
||||
if (exist == null) {
|
||||
entity.setMarkupFixed(fixed);
|
||||
return R.ok(save(entity));
|
||||
}
|
||||
exist.setMarkupPercent(p);
|
||||
exist.setMarkupPercent(entity.getMarkupPercent());
|
||||
exist.setMarkupFixed(fixed);
|
||||
exist.setRemark(entity.getRemark());
|
||||
return R.ok(updateById(exist));
|
||||
}
|
||||
@@ -76,43 +92,175 @@ public class SmHotelPriceConfigServiceImpl extends ServiceImpl<SmHotelPriceConfi
|
||||
return one == null || one.getMarkupPercent() == null ? BigDecimal.ZERO : one.getMarkupPercent();
|
||||
}
|
||||
|
||||
@Override
|
||||
public BigDecimal currentMarkupFixed() {
|
||||
SmHotelPriceConfigEntity one = baseMapper.selectOne(Wrappers.<SmHotelPriceConfigEntity>lambdaQuery()
|
||||
.last("limit 1"));
|
||||
return one == null || one.getMarkupFixed() == null ? BigDecimal.ZERO : one.getMarkupFixed();
|
||||
}
|
||||
|
||||
@Override
|
||||
public BigDecimal applyMarkup(BigDecimal costPrice) {
|
||||
return applyMarkup(costPrice, currentMarkupPercent());
|
||||
return applyMarkup(costPrice, currentMarkupPercent(), currentMarkupFixed());
|
||||
}
|
||||
|
||||
@Override
|
||||
public BigDecimal applyMarkup(BigDecimal costPrice, BigDecimal percent) {
|
||||
return applyMarkup(costPrice, percent, BigDecimal.ZERO);
|
||||
}
|
||||
|
||||
@Override
|
||||
public BigDecimal applyMarkup(BigDecimal costPrice, BigDecimal percent, BigDecimal fixed) {
|
||||
if (costPrice == null) {
|
||||
return null;
|
||||
}
|
||||
BigDecimal p = percent == null ? BigDecimal.ZERO : percent;
|
||||
BigDecimal f = fixed == null ? BigDecimal.ZERO : fixed;
|
||||
BigDecimal rate = BigDecimal.ONE.add(p.divide(BigDecimal.valueOf(100), 6, RoundingMode.HALF_UP));
|
||||
if (rate.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
|
||||
BigDecimal sell = costPrice.multiply(rate).add(f).setScale(2, RoundingMode.HALF_UP);
|
||||
if (sell.compareTo(MIN_SELL) < 0) {
|
||||
return MIN_SELL;
|
||||
}
|
||||
return costPrice.multiply(rate).setScale(2, RoundingMode.HALF_UP);
|
||||
return sell;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BigDecimal resolveSellPrice(BigDecimal basePrice, String partnerCode) {
|
||||
if (basePrice == null) {
|
||||
return null;
|
||||
}
|
||||
MarkupRule rule = resolveMarkupRule(partnerCode);
|
||||
return applyMarkup(basePrice, rule.percent(), rule.fixed());
|
||||
}
|
||||
|
||||
@Override
|
||||
public MarkupRule resolveMarkupRule(String partnerCode) {
|
||||
String code = StrUtil.trim(partnerCode);
|
||||
if (StrUtil.isNotBlank(code)) {
|
||||
SmHotelPriceChannelConfigEntity ch = channelConfigMapper.selectOne(
|
||||
Wrappers.<SmHotelPriceChannelConfigEntity>lambdaQuery()
|
||||
.eq(SmHotelPriceChannelConfigEntity::getPartnerCode, code)
|
||||
.eq(SmHotelPriceChannelConfigEntity::getEnabled, ENABLED)
|
||||
.last("limit 1"));
|
||||
if (ch != null) {
|
||||
return new MarkupRule(
|
||||
ch.getMarkupPercent() == null ? BigDecimal.ZERO : ch.getMarkupPercent(),
|
||||
ch.getMarkupFixed() == null ? BigDecimal.ZERO : ch.getMarkupFixed(),
|
||||
true,
|
||||
code);
|
||||
}
|
||||
// 有 partner 但未配置渠道加价 → 原价(0/0)
|
||||
return new MarkupRule(BigDecimal.ZERO, BigDecimal.ZERO, false, code);
|
||||
}
|
||||
return new MarkupRule(currentMarkupPercent(), currentMarkupFixed(), false, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public R<List<SmHotelPriceChannelConfigEntity>> listChannelConfigs() {
|
||||
List<SmHotelPriceChannelConfigEntity> list = channelConfigMapper.selectList(
|
||||
Wrappers.<SmHotelPriceChannelConfigEntity>lambdaQuery()
|
||||
.orderByAsc(SmHotelPriceChannelConfigEntity::getPartnerCode));
|
||||
return R.ok(list == null ? List.of() : list);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public R<Boolean> saveChannelConfig(SmHotelPriceChannelConfigEntity entity) {
|
||||
if (entity == null || StrUtil.isBlank(entity.getPartnerCode())) {
|
||||
return R.failed("下游伙伴编码不能为空");
|
||||
}
|
||||
String partner = StrUtil.trim(entity.getPartnerCode());
|
||||
List<String> enabledPartners = channelConfigService.listEnabledDownstreamPartners();
|
||||
if (enabledPartners == null || !enabledPartners.contains(partner)) {
|
||||
return R.failed("伙伴「" + partner + "」不在已启用下游列表中,请先在渠道配置启用");
|
||||
}
|
||||
BigDecimal percent = entity.getMarkupPercent() == null ? BigDecimal.ZERO : entity.getMarkupPercent();
|
||||
String err = validatePercent(percent);
|
||||
if (err != null) {
|
||||
return R.failed(err);
|
||||
}
|
||||
BigDecimal fixed = entity.getMarkupFixed() == null ? BigDecimal.ZERO : entity.getMarkupFixed();
|
||||
String enabled = StrUtil.blankToDefault(entity.getEnabled(), ENABLED);
|
||||
SmHotelPriceChannelConfigEntity exist = channelConfigMapper.selectOne(
|
||||
Wrappers.<SmHotelPriceChannelConfigEntity>lambdaQuery()
|
||||
.eq(SmHotelPriceChannelConfigEntity::getPartnerCode, partner)
|
||||
.last("limit 1"));
|
||||
if (exist == null) {
|
||||
entity.setPartnerCode(partner);
|
||||
entity.setMarkupPercent(percent);
|
||||
entity.setMarkupFixed(fixed);
|
||||
entity.setEnabled(enabled);
|
||||
return R.ok(channelConfigMapper.insert(entity) == 1);
|
||||
}
|
||||
exist.setMarkupPercent(percent);
|
||||
exist.setMarkupFixed(fixed);
|
||||
exist.setEnabled(enabled);
|
||||
exist.setRemark(entity.getRemark());
|
||||
return R.ok(channelConfigMapper.updateById(exist) == 1);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public R<Boolean> deleteChannelConfig(Long id) {
|
||||
if (id == null) {
|
||||
return R.failed("id 为空");
|
||||
}
|
||||
return R.ok(channelConfigMapper.deleteById(id) > 0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public R<Map<String, Object>> previewSellPrices(BigDecimal basePrice) {
|
||||
BigDecimal base = basePrice == null ? new BigDecimal("100") : basePrice;
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("basePrice", base);
|
||||
out.put("defaultSellPrice", applyMarkup(base));
|
||||
out.put("globalPercent", currentMarkupPercent());
|
||||
out.put("globalFixed", currentMarkupFixed());
|
||||
List<Map<String, Object>> channels = new ArrayList<>();
|
||||
List<String> partners = channelConfigService.listEnabledDownstreamPartners();
|
||||
if (partners != null) {
|
||||
for (String code : partners) {
|
||||
MarkupRule rule = resolveMarkupRule(code);
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("partnerCode", code);
|
||||
row.put("configured", rule.fromChannel());
|
||||
row.put("markupPercent", rule.percent());
|
||||
row.put("markupFixed", rule.fixed());
|
||||
row.put("sellPrice", resolveSellPrice(base, code));
|
||||
channels.add(row);
|
||||
}
|
||||
}
|
||||
out.put("channels", channels);
|
||||
return R.ok(out);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public R<Map<String, Object>> resyncAllSellPrices() {
|
||||
BigDecimal percent = currentMarkupPercent();
|
||||
BigDecimal fixed = currentMarkupFixed();
|
||||
BigDecimal rate = BigDecimal.ONE.add(percent.divide(BigDecimal.valueOf(100), 6, RoundingMode.HALF_UP));
|
||||
if (rate.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return R.failed("当前调整比例会导致卖价≤0,请先修正配置");
|
||||
}
|
||||
int productUpdated = productMapper.resyncSellPriceByCost(rate);
|
||||
int dailyUpdated = dailyMapper.resyncSellPriceByCost(rate);
|
||||
// 仅同步非本地(第三方)酒店;本地日历/产品卖价由手工维护,不受全局加价影响
|
||||
log.info("|售价全局同步(排除本地)|percent={}|rate={}|productUpdated={}|dailyUpdated={}|", percent, rate,
|
||||
productUpdated, dailyUpdated);
|
||||
int productUpdated = productMapper.resyncSellPriceByCost(rate, fixed);
|
||||
int dailyUpdated = dailyMapper.resyncSellPriceByCost(rate, fixed);
|
||||
log.info("|售价全局同步(排除本地)|percent={}|fixed={}|rate={}|productUpdated={}|dailyUpdated={}|",
|
||||
percent, fixed, rate, productUpdated, dailyUpdated);
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("markupPercent", percent);
|
||||
out.put("markupFixed", fixed);
|
||||
out.put("rate", rate);
|
||||
out.put("productUpdated", productUpdated);
|
||||
out.put("dailyUpdated", dailyUpdated);
|
||||
out.put("scope", "non_local");
|
||||
out.put("scope", "non_local_default_sell");
|
||||
return R.ok(out);
|
||||
}
|
||||
|
||||
private static String validatePercent(BigDecimal p) {
|
||||
if (p.compareTo(MIN_PERCENT) < 0) {
|
||||
return "折扣不能超过 99.99%(调整值须大于 -100)";
|
||||
}
|
||||
if (p.compareTo(MAX_PERCENT) > 0) {
|
||||
return "加价百分比不能超过 500%";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+22
-3
@@ -76,8 +76,29 @@ public class SmHotelServiceImpl extends ServiceImpl<SmHotelMapper, SmHotelEntity
|
||||
@Override
|
||||
public Page<SmHotelEntity> pageScope(Page<SmHotelEntity> page,
|
||||
LambdaQueryWrapper<SmHotelEntity> wrapper) {
|
||||
applyListSelectAndScope(wrapper);
|
||||
return baseMapper.selectPage(page, wrapper);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Page<SmHotelEntity> pageScopeWithoutCount(Page<SmHotelEntity> page,
|
||||
LambdaQueryWrapper<SmHotelEntity> wrapper) {
|
||||
applyListSelectAndScope(wrapper);
|
||||
long size = Math.max(1L, Math.min(page.getSize(), 100L));
|
||||
long current = Math.max(1L, page.getCurrent());
|
||||
Page<SmHotelEntity> q = new Page<>(current, size + 1, false);
|
||||
Page<SmHotelEntity> raw = baseMapper.selectPage(q, wrapper);
|
||||
java.util.List<SmHotelEntity> rows = raw.getRecords() == null ? java.util.List.of() : raw.getRecords();
|
||||
boolean hasMore = rows.size() > size;
|
||||
java.util.List<SmHotelEntity> slice = hasMore ? rows.subList(0, (int) size) : rows;
|
||||
long total = (current - 1) * size + slice.size() + (hasMore ? 1 : 0);
|
||||
Page<SmHotelEntity> out = new Page<>(current, size, total);
|
||||
out.setRecords(new java.util.ArrayList<>(slice));
|
||||
return out;
|
||||
}
|
||||
|
||||
private void applyListSelectAndScope(LambdaQueryWrapper<SmHotelEntity> wrapper) {
|
||||
// 不再聚合房型/产品/日历数;列表只查展示列,丢掉 intro/轮播等大字段减轻 IO
|
||||
// coverImage 为短路径字符串,卡片封面需要;carouselImages/intro 仍不查
|
||||
wrapper.select(SmHotelEntity::getId, SmHotelEntity::getTenantId, SmHotelEntity::getDeptId,
|
||||
SmHotelEntity::getSourceId, SmHotelEntity::getGroupCode, SmHotelEntity::getHotelCode,
|
||||
SmHotelEntity::getSGroupId, SmHotelEntity::getSHotelId, SmHotelEntity::getHotelName,
|
||||
@@ -88,9 +109,7 @@ public class SmHotelServiceImpl extends ServiceImpl<SmHotelMapper, SmHotelEntity
|
||||
SmHotelEntity::getPhone, SmHotelEntity::getCoverImage,
|
||||
SmHotelEntity::getCreateTime, SmHotelEntity::getUpdateTime,
|
||||
SmHotelEntity::getDelFlag);
|
||||
// App/管家:按酒店商户绑定部门隔离;无酒店商户则无数据
|
||||
hotelMerchantScopeHelper.applyDeptScope(wrapper);
|
||||
return baseMapper.selectPage(page, wrapper);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+88
-4
@@ -112,11 +112,16 @@ public class SmHotelShengnuoOrderServiceImpl implements SmHotelShengnuoOrderServ
|
||||
if (exist != null) {
|
||||
if (isBookConfirmed(exist)) {
|
||||
pushOrderChange(partnerCode, orderId, ST_ACCEPTED);
|
||||
return ok("订单已存在(幂等)", exist.getId());
|
||||
}
|
||||
// 预订失败单允许同渠道单号重新预检;其它中间态仍幂等
|
||||
if (!"BOOK_FAIL".equals(exist.getOrderStatus())) {
|
||||
return ok("订单已存在(幂等)", exist.getId());
|
||||
}
|
||||
return ok("订单已存在(幂等)", exist.getId());
|
||||
}
|
||||
Mapped m = mapCreate(partnerCode, body);
|
||||
if (m.error != null) {
|
||||
saveBookFailOrder(exist, partnerCode, orderId, m, body, m.error);
|
||||
return fail(m.error);
|
||||
}
|
||||
Map<String, Object> checkBody = new LinkedHashMap<>();
|
||||
@@ -137,7 +142,9 @@ public class SmHotelShengnuoOrderServiceImpl implements SmHotelShengnuoOrderServ
|
||||
boolean priceSoft = checkData != null && Boolean.TRUE.equals(checkData.get("priceMismatchOnly"));
|
||||
if (!bookable && !priceSoft) {
|
||||
Object reasons = checkData == null ? "预检失败" : checkData.get("reasons");
|
||||
return fail("预检不可订:" + reasons);
|
||||
String failMsg = "预检不可订:" + reasons;
|
||||
saveBookFailOrder(exist, partnerCode, orderId, m, body, failMsg);
|
||||
return fail(failMsg);
|
||||
}
|
||||
String priceWarn = null;
|
||||
if (priceSoft) {
|
||||
@@ -147,10 +154,11 @@ public class SmHotelShengnuoOrderServiceImpl implements SmHotelShengnuoOrderServ
|
||||
log.warn("|{}|价差软通过|orderId={}|{}|", TAG, orderId, priceWarn);
|
||||
}
|
||||
|
||||
SmHotelOrderEntity order = new SmHotelOrderEntity();
|
||||
SmHotelOrderEntity order = exist != null ? exist : new SmHotelOrderEntity();
|
||||
order.setPartnerCode(partnerCode);
|
||||
order.setChannelNo(orderId);
|
||||
order.setHotelId(m.hotel.getId());
|
||||
order.setDeptId(m.hotel.getDeptId());
|
||||
order.setHotelCode(m.hotelCode);
|
||||
order.setGroupCode(m.hotel.getGroupCode());
|
||||
order.setRoomTypeCode(m.roomTypeCode);
|
||||
@@ -168,12 +176,18 @@ public class SmHotelShengnuoOrderServiceImpl implements SmHotelShengnuoOrderServ
|
||||
order.setCompensateStatus("NONE");
|
||||
order.setInventoryStatus("NONE");
|
||||
order.setCompensateRound(0);
|
||||
order.setFailMsg(null);
|
||||
order.setReqJson(body.toString());
|
||||
order.setHuanqiPayload(body.toString());
|
||||
if (priceWarn != null) {
|
||||
order.setFailMsg(StrUtil.maxLength(priceWarn, 500));
|
||||
}
|
||||
orderMapper.insert(order);
|
||||
if (order.getId() == null) {
|
||||
orderMapper.insert(order);
|
||||
}
|
||||
else {
|
||||
orderMapper.updateById(order);
|
||||
}
|
||||
|
||||
R<Boolean> book = hotelOrderService.bookUpstreamForExisting(order.getId());
|
||||
SmHotelOrderEntity latest = orderMapper.selectById(order.getId());
|
||||
@@ -300,6 +314,9 @@ public class SmHotelShengnuoOrderServiceImpl implements SmHotelShengnuoOrderServ
|
||||
m.error = "未找到本地酒店(请先推送资源建立映射,或核对外部酒店ID/sHotelId)nsHotelId=" + nsHotelId;
|
||||
return m;
|
||||
}
|
||||
// 后续匹配失败时仍带上酒店,便于预订失败单挂到对应门店
|
||||
m.hotel = hotel;
|
||||
m.hotelCode = hotel.getHotelCode();
|
||||
String roomType = idMappingCache.roomTypeByNs(map, nsRoomId);
|
||||
if (StrUtil.isBlank(roomType)) {
|
||||
roomType = matchLocalRoomType(hotel.getId(), nsRoomId, StrUtil.trim(str(body.get("room_type"))), map);
|
||||
@@ -600,11 +617,78 @@ public class SmHotelShengnuoOrderServiceImpl implements SmHotelShengnuoOrderServ
|
||||
}
|
||||
return StrUtil.isNotBlank(order.getPlatformNo())
|
||||
&& !"UPSTREAM_FAIL".equals(order.getOrderStatus())
|
||||
&& !"BOOK_FAIL".equals(order.getOrderStatus())
|
||||
&& !"NEED_MANUAL".equals(order.getOrderStatus())
|
||||
&& !"CANCELLED".equals(order.getOrderStatus())
|
||||
&& !"PENDING_CANCEL".equals(order.getOrderStatus());
|
||||
}
|
||||
|
||||
/**
|
||||
* 入站预检/映射失败也落酒店订单(BOOK_FAIL),便于运营在订单列表实时看到进单与原因;对下游仍返回失败。
|
||||
*/
|
||||
private Long saveBookFailOrder(SmHotelOrderEntity exist, String partnerCode, String orderId, Mapped m,
|
||||
JSONObject body, String failMsg) {
|
||||
try {
|
||||
SmHotelOrderEntity order = exist != null ? exist : new SmHotelOrderEntity();
|
||||
order.setPartnerCode(partnerCode);
|
||||
order.setChannelNo(orderId);
|
||||
order.setOrderStatus("BOOK_FAIL");
|
||||
order.setCompensateStatus("NONE");
|
||||
order.setInventoryStatus("NONE");
|
||||
order.setCompensateRound(0);
|
||||
order.setMarkupPercent(BigDecimal.ZERO);
|
||||
order.setFailMsg(StrUtil.maxLength(failMsg, 500));
|
||||
order.setReqJson(body == null ? null : body.toString());
|
||||
order.setHuanqiPayload(body == null ? null : body.toString());
|
||||
if (m != null && m.hotel != null) {
|
||||
order.setHotelId(m.hotel.getId());
|
||||
order.setDeptId(m.hotel.getDeptId());
|
||||
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 <= 0 ? 1 : m.quantity);
|
||||
order.setContactName(m.contactName);
|
||||
order.setContactPhone(m.contactPhone);
|
||||
order.setGuestJson(m.guestJson);
|
||||
order.setSellAmount(m.orderAmount);
|
||||
}
|
||||
else if (body != null) {
|
||||
long nsHotelId = toLong(body.get("hotel_id"));
|
||||
if (nsHotelId > 0) {
|
||||
order.setHotelCode(String.valueOf(nsHotelId));
|
||||
}
|
||||
order.setCheckInDate(parseDay(str(body.get("checkIn"))));
|
||||
order.setCheckOutDate(parseDay(str(body.get("checkOut"))));
|
||||
order.setCount(Math.max(1, (int) toLong(body.get("room_number"))));
|
||||
order.setContactName(StrUtil.blankToDefault(StrUtil.trim(str(body.get("check_person_name"))), "旅客"));
|
||||
order.setContactPhone(StrUtil.trim(str(body.get("check_person_phone"))));
|
||||
String priceStr = StrUtil.blankToDefault(str(body.get("total_prices")), str(body.get("pay_total_prices")));
|
||||
try {
|
||||
order.setSellAmount(new BigDecimal(StrUtil.blankToDefault(priceStr, "0")));
|
||||
}
|
||||
catch (Exception ignored) {
|
||||
order.setSellAmount(BigDecimal.ZERO);
|
||||
}
|
||||
}
|
||||
if (order.getId() == null) {
|
||||
orderMapper.insert(order);
|
||||
}
|
||||
else {
|
||||
orderMapper.updateById(order);
|
||||
}
|
||||
log.info("|{}|预订失败已落单|orderId={}|localId={}|msg={}|", TAG, orderId, order.getId(), failMsg);
|
||||
return order.getId();
|
||||
}
|
||||
catch (Exception e) {
|
||||
log.warn("|{}|预订失败落单异常|orderId={}|{}|", TAG, orderId, e.getMessage());
|
||||
return exist == null ? null : exist.getId();
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isPendingConfirm(SmHotelOrderEntity order) {
|
||||
return order != null && "PENDING_CONFIRM".equals(order.getOrderStatus());
|
||||
}
|
||||
|
||||
+94
-28
@@ -45,6 +45,7 @@ import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
@@ -53,6 +54,7 @@ import java.util.concurrent.Future;
|
||||
import java.util.concurrent.Semaphore;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 酒店列表日更 + Redis SDIFF + 滚价;手动/定时均写同步日志并支持进度查询。
|
||||
@@ -97,6 +99,12 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
|
||||
/** 暂停续跑检查点:idx:已处理条数 或 lid:详情拉取 lastId */
|
||||
private static final String RESUME_CKPT_KEY = "sm:hotel:sync:resume:";
|
||||
|
||||
/**
|
||||
* 下游全量推/失败重推的酒店 ID 快照(逗号分隔)。续跑必须用这份列表,
|
||||
* 不能再按「渠道全量授权酒店」重切,否则 RETRY 目标 142 却继续推成上千家。
|
||||
*/
|
||||
private static final String RESUME_HOTELS_KEY = "sm:hotel:sync:hotels:";
|
||||
|
||||
/** 同步日志超过该时间无 progress 心跳,视为死任务 */
|
||||
private static final int STALE_RUNNING_MINUTES = 30;
|
||||
|
||||
@@ -506,6 +514,7 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
|
||||
logEntity.setSkipCount(droppedNoEid);
|
||||
}
|
||||
syncLogMapper.insert(logEntity);
|
||||
saveResumeHotelIds(logEntity.getId(), hotelIds);
|
||||
|
||||
Long tenantId = TenantContextHolder.getTenantId();
|
||||
if (tenantId == null) {
|
||||
@@ -555,10 +564,10 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
|
||||
}
|
||||
}
|
||||
else {
|
||||
Long running = syncLogMapper.selectCount(Wrappers.<SmHotelSyncLogEntity>lambdaQuery()
|
||||
.eq(SmHotelSyncLogEntity::getSyncType, syncType)
|
||||
.eq(SmHotelSyncLogEntity::getStatus, STATUS_RUNNING));
|
||||
if (running != null && running > 0) {
|
||||
Long running = syncLogMapper.selectCount(Wrappers.<SmHotelSyncLogEntity>lambdaQuery()
|
||||
.eq(SmHotelSyncLogEntity::getSyncType, syncType)
|
||||
.eq(SmHotelSyncLogEntity::getStatus, STATUS_RUNNING));
|
||||
if (running != null && running > 0) {
|
||||
return R.failed("已有同类型任务进行中,请稍后再试(若卡住请先暂停/终止)");
|
||||
}
|
||||
}
|
||||
@@ -596,6 +605,7 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
|
||||
logEntity.setProgressPct(1);
|
||||
logEntity.setLocalCount((long) hotelIds.size());
|
||||
syncLogMapper.insert(logEntity);
|
||||
saveResumeHotelIds(logEntity.getId(), hotelIds);
|
||||
|
||||
Long tenantId = TenantContextHolder.getTenantId();
|
||||
if (tenantId == null) {
|
||||
@@ -760,8 +770,15 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
|
||||
final Long logId = e.getId();
|
||||
if (TYPE_DOWNSTREAM_PUSH.equals(type)) {
|
||||
String partner = SmHotelDataSource.normalize(e.getDataSource()).toLowerCase();
|
||||
List<Long> hotelIds = channelDeptService.listHotelIdsForDownstreamPartner(partner);
|
||||
hotelIds = filterShengnuoHotelsRequireEid(partner, hotelIds);
|
||||
// 优先用开跑时快照(RETRY 只有失败子集);没有快照再回退渠道全量
|
||||
List<Long> hotelIds = readResumeHotelIds(logId);
|
||||
boolean fromSnapshot = CollUtil.isNotEmpty(hotelIds);
|
||||
if (!fromSnapshot) {
|
||||
hotelIds = channelDeptService.listHotelIdsForDownstreamPartner(partner);
|
||||
hotelIds = filterShengnuoHotelsRequireEid(partner, hotelIds);
|
||||
log.warn("|下游全量推送|续跑无酒店快照,回退渠道全量|logId={}|partner={}|trigger={}|size={}|",
|
||||
logId, partner, e.getTriggerType(), hotelIds == null ? 0 : hotelIds.size());
|
||||
}
|
||||
final int baseOk = nullToZero(e.getSuccessCount());
|
||||
final int baseFail = nullToZero(e.getFailCount());
|
||||
final int baseSkip = nullToZero(e.getSkipCount());
|
||||
@@ -777,11 +794,19 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
|
||||
final List<Long> ids = List.copyOf(hotelIds == null ? List.of() : hotelIds);
|
||||
final int baseIdx = resumeIdx;
|
||||
final List<String> baseFails = parseFailLines(e.getFailDetail());
|
||||
final long totalHint = e.getLocalCount() == null ? (baseIdx + ids.size()) : e.getLocalCount();
|
||||
// 目标分母:有快照时用开跑时 localCount;无快照回退全量时按实际剩余校正,避免「目标142、已处理350」
|
||||
int planned = e.getLocalCount() == null ? 0 : e.getLocalCount().intValue();
|
||||
int totalHint = Math.max(planned, baseIdx + ids.size());
|
||||
if (!fromSnapshot && planned > 0 && totalHint > planned) {
|
||||
log.warn("|下游全量推送|续跑校正目标|logId={}|oldTarget={}|newTarget={}|remaining={}|", logId,
|
||||
planned, totalHint, ids.size());
|
||||
}
|
||||
final int totalFinal = totalHint;
|
||||
EXECUTOR.execute(() -> TenantBroker.runAs(tid,
|
||||
x -> runDownstreamScopePush(logId, partnerFinal, ids, baseIdx, baseOk, baseFail, baseSkip,
|
||||
baseFails, (int) totalHint)));
|
||||
return R.ok(e, "已继续下游全量推,从第 " + (baseIdx + 1) + " 家接着跑");
|
||||
baseFails, totalFinal)));
|
||||
return R.ok(e, "已继续下游全量推,从第 " + (baseIdx + 1) + " 家接着跑(目标 "
|
||||
+ totalFinal + ")");
|
||||
}
|
||||
|
||||
if (TYPE_DETAIL_SCOPE.equals(type) || TYPE_DETAIL_NEAR.equals(type)) {
|
||||
@@ -1135,7 +1160,7 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
|
||||
+ remain + "(每批 " + batchSize + ")");
|
||||
R<Map<String, Object>> imp = importDiffNewHotels(batchSize, dataSource);
|
||||
int n = 0;
|
||||
if (imp != null && imp.getData() != null && imp.getData().get("insertCount") != null) {
|
||||
if (imp != null && imp.getData() != null && imp.getData().get("insertCount") != null) {
|
||||
n = Integer.parseInt(String.valueOf(imp.getData().get("insertCount")));
|
||||
}
|
||||
if (n <= 0) {
|
||||
@@ -1412,7 +1437,9 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
|
||||
HotelDownstreamProperties dsProps = channelConfigService.resolveDownstreamRuntime(partnerCode);
|
||||
int sampleMax = dsProps == null ? 20 : Math.max(0, dsProps.getPushLogSampleSuccessMax());
|
||||
int concurrency = dsProps == null ? 6 : Math.max(1, dsProps.getResourcePushConcurrency());
|
||||
int total = totalAll > 0 ? totalAll : (baseProcessed + (hotelIds == null ? 0 : hotelIds.size()));
|
||||
int pending = hotelIds == null ? 0 : hotelIds.size();
|
||||
// 分母至少覆盖「已处理 + 本段待推」,避免续跑列表变大后出现成功数 > 目标
|
||||
int total = Math.max(totalAll > 0 ? totalAll : (baseProcessed + pending), baseProcessed + pending);
|
||||
AtomicInteger ok = new AtomicInteger(Math.max(0, baseOk));
|
||||
AtomicInteger fail = new AtomicInteger(Math.max(0, baseFail));
|
||||
AtomicInteger skip = new AtomicInteger(Math.max(0, baseSkip));
|
||||
@@ -1423,7 +1450,8 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
|
||||
try {
|
||||
progressSnapshot(logId, Math.min(95, 5 + (int) (processed.get() * 90L / Math.max(1, total))), "PUSH",
|
||||
(baseProcessed > 0 ? "继续向下游「" : "开始向下游「") + partnerCode + "」推送,共 " + total
|
||||
+ " 家,已处理 " + processed.get() + ",并发=" + concurrency + "…",
|
||||
+ " 家,已处理 " + processed.get() + ",并发=" + concurrency
|
||||
+ "(按酒店计:成功+失败+跳过=已处理,目标=计划酒店数)…",
|
||||
ok.get(), fail.get(), skip.get(), total, failLines);
|
||||
if (CollUtil.isEmpty(hotelIds)) {
|
||||
// fall through to complete
|
||||
@@ -1835,9 +1863,47 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
|
||||
private void clearResumeCkpt(Long logId) {
|
||||
if (logId != null) {
|
||||
redisTemplate.delete(RESUME_CKPT_KEY + logId);
|
||||
redisTemplate.delete(RESUME_HOTELS_KEY + logId);
|
||||
}
|
||||
}
|
||||
|
||||
/** 下游推送开跑时落酒店 ID 快照,供暂停后续跑(尤其 RETRY 失败子集) */
|
||||
private void saveResumeHotelIds(Long logId, List<Long> hotelIds) {
|
||||
if (logId == null || CollUtil.isEmpty(hotelIds)) {
|
||||
return;
|
||||
}
|
||||
String joined = hotelIds.stream().filter(Objects::nonNull).map(String::valueOf)
|
||||
.collect(Collectors.joining(","));
|
||||
if (StrUtil.isBlank(joined)) {
|
||||
return;
|
||||
}
|
||||
redisTemplate.opsForValue().set(RESUME_HOTELS_KEY + logId, joined, 14, TimeUnit.DAYS);
|
||||
}
|
||||
|
||||
private List<Long> readResumeHotelIds(Long logId) {
|
||||
if (logId == null) {
|
||||
return List.of();
|
||||
}
|
||||
String v = redisTemplate.opsForValue().get(RESUME_HOTELS_KEY + logId);
|
||||
if (StrUtil.isBlank(v)) {
|
||||
return List.of();
|
||||
}
|
||||
List<Long> ids = new ArrayList<>();
|
||||
for (String part : v.split(",")) {
|
||||
String p = StrUtil.trim(part);
|
||||
if (StrUtil.isBlank(p)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
ids.add(Long.valueOf(p));
|
||||
}
|
||||
catch (Exception ignored) {
|
||||
// skip bad token
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* 全量详情:酒店级并行 + 可缩房价窗,尽量吃满上游 price-api-qps。
|
||||
* 本地及其它渠道酒店一律不进此任务。
|
||||
@@ -1962,12 +2028,12 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
|
||||
processed.incrementAndGet();
|
||||
}
|
||||
int doneCnt = processed.get();
|
||||
if (intervalMs > 0) {
|
||||
try {
|
||||
Thread.sleep(intervalMs);
|
||||
}
|
||||
catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
if (intervalMs > 0) {
|
||||
try {
|
||||
Thread.sleep(intervalMs);
|
||||
}
|
||||
catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
if (doneCnt % 20 == 0 || doneCnt >= target) {
|
||||
@@ -2816,10 +2882,10 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
|
||||
continue;
|
||||
}
|
||||
codes.add(code);
|
||||
if (codes.size() >= batch) {
|
||||
updated += updateStatusByCodes(codes, "OFFLINE");
|
||||
codes.clear();
|
||||
}
|
||||
if (codes.size() >= batch) {
|
||||
updated += updateStatusByCodes(codes, "OFFLINE");
|
||||
codes.clear();
|
||||
}
|
||||
}
|
||||
if (!codes.isEmpty()) {
|
||||
updated += updateStatusByCodes(codes, "OFFLINE");
|
||||
@@ -2902,12 +2968,12 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
|
||||
int to = (int) Math.min(skip + size, ordered.size());
|
||||
for (int i = from; i < to; i++) {
|
||||
String hotelId = ordered.get(i);
|
||||
Map<String, Object> row = new LinkedHashMap<>(4);
|
||||
row.put("hotelId", hotelId);
|
||||
row.put("hotelName", upstreamClient.hotelNameOf(hotelId));
|
||||
row.put("type", "offline".equalsIgnoreCase(type) ? "offline" : "new");
|
||||
records.add(row);
|
||||
}
|
||||
Map<String, Object> row = new LinkedHashMap<>(4);
|
||||
row.put("hotelId", hotelId);
|
||||
row.put("hotelName", upstreamClient.hotelNameOf(hotelId));
|
||||
row.put("type", "offline".equalsIgnoreCase(type) ? "offline" : "new");
|
||||
records.add(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
|
||||
+3
-1
@@ -6,6 +6,7 @@ import com.bsd.pjcloud.common.swagger.annotation.EnableOpenApi;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
/**
|
||||
* @author archetype auto
|
||||
@@ -16,7 +17,8 @@ import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
|
||||
@EnablePjcloudFeignClients
|
||||
@EnableDiscoveryClient
|
||||
@EnablePjcloudResourceServer
|
||||
// 周期调度已统一迁移到 daemon-quartz(sys_job 注册 thirdPushTask / 支付任务租约表),集群下避免各实例重复触发
|
||||
// daemon-quartz thirdPushTask 仍为主调度;本地 @Scheduled 仅作系统通知 5 秒兜底(CAS 领取,可与 Quartz 并存)
|
||||
@EnableScheduling
|
||||
@SpringBootApplication
|
||||
public class ThirdPartyApp {
|
||||
public static void main(String[] args) {
|
||||
|
||||
+2
-2
@@ -109,8 +109,8 @@ public class AppMessageConstants {
|
||||
/** 单个投递任务允许的最大供应商调用次数。 */
|
||||
public static final int MAX_DELIVERY_ATTEMPTS = 8;
|
||||
|
||||
/** 处理中超过该分钟数视为任务失联并允许恢复。 */
|
||||
public static final int PROCESSING_TIMEOUT_MINUTES = 10;
|
||||
/** 处理中超过该分钟数视为任务失联并允许恢复(原 10 分钟过长,系统通知会像「卡死」)。 */
|
||||
public static final int PROCESSING_TIMEOUT_MINUTES = 2;
|
||||
|
||||
/** 一次投递扫描领取的最大任务数,控制数据库和供应商压力。 */
|
||||
public static final int DELIVERY_SCAN_LIMIT = 100;
|
||||
|
||||
+8
-2
@@ -1,5 +1,6 @@
|
||||
package com.bsd.pjcloud.thirdparty.controller.appmessage;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.bsd.pjcloud.common.api.encrypt.annotation.crypto.ApiCryptoAes;
|
||||
import com.bsd.pjcloud.common.core.util.R;
|
||||
@@ -98,14 +99,19 @@ public class ThirdAppMessageController {
|
||||
|
||||
/**
|
||||
* 内部幂等接收酒店管理推送(不进入商户策略/关系订阅矩阵;独立于有房通知)。
|
||||
* <p>收件箱写入成功但系统通知被跳过(无设备/用户关推送)时返回失败文案,避免上游误记为「手机已送达」。
|
||||
*
|
||||
* @param dto 超市酒店管理推送事件快照
|
||||
* @return true表示消息中心已完整接收
|
||||
* @return true 表示入箱且系统通知已进入投递;失败时 msg 说明跳过原因
|
||||
*/
|
||||
@Inner
|
||||
@PostMapping("/inner/hotel-manager-push-event")
|
||||
public R<Boolean> receiveHotelManagerPushEvent(@Valid @RequestBody HotelManagerPushEventDto dto) {
|
||||
return R.ok(TenantBroker.applyAs(dto.getTenantId(), tenantId -> hotelManagerNotifyService.receive(dto)));
|
||||
String skipTip = TenantBroker.applyAs(dto.getTenantId(), tenantId -> hotelManagerNotifyService.receive(dto));
|
||||
if (StrUtil.isNotBlank(skipTip)) {
|
||||
return R.failed(skipTip);
|
||||
}
|
||||
return R.ok(Boolean.TRUE);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package com.bsd.pjcloud.thirdparty.job;
|
||||
|
||||
import com.bsd.pjcloud.thirdparty.service.appmessage.ThirdPushDeliveryService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* App 消息系统通知本地兜底扫描。
|
||||
* <p>正式调度仍以 daemon-quartz thirdPushTask 为准;本地短周期扫描保证 Quartz 停摆/积压时
|
||||
* 系统通知不会拖到数分钟。领取靠 status CAS,多实例重复触发安全。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class ThirdAppMessagePushJob {
|
||||
|
||||
private final ThirdPushDeliveryService deliveryService;
|
||||
|
||||
/** 上轮结束后再隔 5 秒扫一轮 PENDING/RETRY */
|
||||
@Scheduled(fixedDelay = 5000L, initialDelay = 8000L)
|
||||
public void processDueDeliveries() {
|
||||
try {
|
||||
int claimed = deliveryService.processDueDeliveries();
|
||||
if (claimed > 0) {
|
||||
log.info("|App消息Push|本地兜底扫描完成|claimed={}|", claimed);
|
||||
}
|
||||
}
|
||||
catch (Exception e) {
|
||||
log.error("|App消息Push|本地兜底扫描异常|{}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -11,7 +11,7 @@ public interface ThirdHotelManagerNotifyService {
|
||||
* 幂等接收酒店管理推送事件。
|
||||
*
|
||||
* @param dto 超市模块提交的事件快照
|
||||
* @return true 表示已入箱或此前已幂等完成
|
||||
* @return null 表示入箱且系统通知已进入待投递/已幂等;非空为跳过系统通知的中文原因(收件箱通常已写入)
|
||||
*/
|
||||
boolean receive(HotelManagerPushEventDto dto);
|
||||
String receive(HotelManagerPushEventDto dto);
|
||||
}
|
||||
+10
-1
@@ -18,8 +18,9 @@ public interface ThirdPushDeliveryService {
|
||||
*
|
||||
* @param message 已持久化的收件箱消息
|
||||
* @param skipReason 稳定跳过原因,允许为空
|
||||
* @return 最终跳过原因;null 表示已进入待投递(或已有投递记录)
|
||||
*/
|
||||
void createDelivery(ThirdAppMessageEntity message, String skipReason);
|
||||
String createDelivery(ThirdAppMessageEntity message, String skipReason);
|
||||
|
||||
/**
|
||||
* 扫描并处理所有租户到期的投递任务,供轻量定时任务调用。
|
||||
@@ -28,6 +29,14 @@ public interface ThirdPushDeliveryService {
|
||||
*/
|
||||
int processDueDeliveries();
|
||||
|
||||
/**
|
||||
* 事务提交后立即尝试投递指定收件箱消息(PENDING),不坐等 Quartz 扫描。
|
||||
*
|
||||
* @param messageId 收件箱消息ID
|
||||
* @return 是否成功领取并处理
|
||||
*/
|
||||
boolean processPendingByMessageId(Long messageId);
|
||||
|
||||
/**
|
||||
* 分页查询指定商户的业务消息投递记录。
|
||||
*
|
||||
|
||||
+52
-5
@@ -10,15 +10,20 @@ import com.bsd.pjcloud.thirdparty.service.appmessage.ThirdAppMessageSettingServi
|
||||
import com.bsd.pjcloud.thirdparty.service.appmessage.ThirdHotelManagerNotifyService;
|
||||
import com.bsd.pjcloud.thirdparty.service.appmessage.ThirdPushDeliveryService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
/**
|
||||
* 酒店管理推送:仿有房通知旁路,独立 eventCode,避免进入商户策略矩阵。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ThirdHotelManagerNotifyServiceImpl implements ThirdHotelManagerNotifyService {
|
||||
@@ -35,7 +40,7 @@ public class ThirdHotelManagerNotifyServiceImpl implements ThirdHotelManagerNoti
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public boolean receive(HotelManagerPushEventDto dto) {
|
||||
public String receive(HotelManagerPushEventDto dto) {
|
||||
if (dto == null || StrUtil.isBlank(dto.getEventId()) || dto.getRecipientUserId() == null) {
|
||||
throw new IllegalArgumentException("酒店管理推送参数不完整");
|
||||
}
|
||||
@@ -44,7 +49,7 @@ public class ThirdHotelManagerNotifyServiceImpl implements ThirdHotelManagerNoti
|
||||
throw new IllegalArgumentException("不支持的酒店管理推送事件编码: " + eventCode);
|
||||
}
|
||||
if (ownerMessageExists(dto.getEventId(), dto.getRecipientUserId())) {
|
||||
return true;
|
||||
return null;
|
||||
}
|
||||
String title = trimToMax(dto.getTitle(), AppMessageConstants.MESSAGE_TITLE_MAX_LENGTH);
|
||||
String content = trimToMax(dto.getContent(), AppMessageConstants.MESSAGE_CONTENT_MAX_LENGTH);
|
||||
@@ -74,13 +79,45 @@ public class ThirdHotelManagerNotifyServiceImpl implements ThirdHotelManagerNoti
|
||||
}
|
||||
}
|
||||
catch (DuplicateKeyException conflict) {
|
||||
return true;
|
||||
return null;
|
||||
}
|
||||
String skipReason = settingService.isUserPushEnabled(dto.getRecipientUserId())
|
||||
? null
|
||||
: AppMessageConstants.SKIP_USER_DISABLED;
|
||||
deliveryService.createDelivery(message, skipReason);
|
||||
return true;
|
||||
String resolvedSkip = deliveryService.createDelivery(message, skipReason);
|
||||
if (resolvedSkip != null) {
|
||||
log.warn("|酒店管理推送|系统通知跳过|userId={}|eventId={}|reason={}|",
|
||||
dto.getRecipientUserId(), dto.getEventId(), resolvedSkip);
|
||||
return explainSkip(resolvedSkip);
|
||||
}
|
||||
scheduleImmediateDelivery(message.getId());
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 事务提交后异步立刻投递,避免 PENDING 未可见,也不阻塞 Feign 返回 */
|
||||
private void scheduleImmediateDelivery(Long messageId) {
|
||||
if (messageId == null) {
|
||||
return;
|
||||
}
|
||||
Runnable task = () -> CompletableFuture.runAsync(() -> {
|
||||
try {
|
||||
boolean ok = deliveryService.processPendingByMessageId(messageId);
|
||||
log.info("|酒店管理推送|立即投递|messageId={}|claimed={}|", messageId, ok);
|
||||
}
|
||||
catch (Exception e) {
|
||||
log.warn("|酒店管理推送|立即投递异常|messageId={}|{}", messageId, e.getMessage());
|
||||
}
|
||||
});
|
||||
if (TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||
@Override
|
||||
public void afterCommit() {
|
||||
task.run();
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
task.run();
|
||||
}
|
||||
|
||||
private boolean ownerMessageExists(String eventId, Long userId) {
|
||||
@@ -90,6 +127,16 @@ public class ThirdHotelManagerNotifyServiceImpl implements ThirdHotelManagerNoti
|
||||
return count != null && count > 0;
|
||||
}
|
||||
|
||||
private static String explainSkip(String code) {
|
||||
if (AppMessageConstants.SKIP_NO_DEVICE.equals(code)) {
|
||||
return "收件箱已写入,但该用户未绑定推送设备,无法发系统通知(请用手机打开 App 并登录以绑定极光设备)";
|
||||
}
|
||||
if (AppMessageConstants.SKIP_USER_DISABLED.equals(code)) {
|
||||
return "收件箱已写入,但该用户关闭了消息推送开关";
|
||||
}
|
||||
return "收件箱已写入,系统通知跳过:" + code;
|
||||
}
|
||||
|
||||
private static String trimToMax(String raw, int max) {
|
||||
String text = StrUtil.nullToEmpty(raw).trim();
|
||||
if (text.length() <= max) {
|
||||
|
||||
+94
-38
@@ -96,12 +96,15 @@ public class ThirdPushDeliveryServiceImpl implements ThirdPushDeliveryService {
|
||||
*
|
||||
* @param message 已持久化的收件箱消息
|
||||
* @param skipReason 业务策略跳过原因,允许为空
|
||||
* @return 最终跳过原因;null 表示待投递或已有投递记录
|
||||
*/
|
||||
@Override
|
||||
public void createDelivery(ThirdAppMessageEntity message, String skipReason) {
|
||||
if (deliveryMapper.selectCount(Wrappers.<ThirdPushDeliveryEntity>lambdaQuery()
|
||||
.eq(ThirdPushDeliveryEntity::getMessageId, message.getId())) > 0) {
|
||||
return;
|
||||
public String createDelivery(ThirdAppMessageEntity message, String skipReason) {
|
||||
ThirdPushDeliveryEntity existing = deliveryMapper.selectOne(Wrappers.<ThirdPushDeliveryEntity>lambdaQuery()
|
||||
.eq(ThirdPushDeliveryEntity::getMessageId, message.getId())
|
||||
.last("limit 1"));
|
||||
if (existing != null) {
|
||||
return existing.getSkipReason();
|
||||
}
|
||||
String resolvedSkipReason = skipReason;
|
||||
if (resolvedSkipReason == null && !deviceBindingService.hasActiveBinding(message.getRecipientUserId())) {
|
||||
@@ -118,6 +121,33 @@ public class ThirdPushDeliveryServiceImpl implements ThirdPushDeliveryService {
|
||||
delivery.setSkipReason(resolvedSkipReason);
|
||||
delivery.setAttemptCount(0);
|
||||
deliveryMapper.insert(delivery);
|
||||
return resolvedSkipReason;
|
||||
}
|
||||
|
||||
/**
|
||||
* 立即处理指定消息的 PENDING 投递(事务提交后调用)。
|
||||
*/
|
||||
@Override
|
||||
public boolean processPendingByMessageId(Long messageId) {
|
||||
if (messageId == null) {
|
||||
return false;
|
||||
}
|
||||
ThirdPushDeliveryEntity candidate = deliveryMapper.selectOne(Wrappers.<ThirdPushDeliveryEntity>lambdaQuery()
|
||||
.eq(ThirdPushDeliveryEntity::getMessageId, messageId)
|
||||
.eq(ThirdPushDeliveryEntity::getStatus, AppMessageConstants.DELIVERY_PENDING)
|
||||
.last("limit 1"));
|
||||
if (candidate == null || candidate.getTenantId() == null) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
Boolean claimed = TenantBroker.applyAs(candidate.getTenantId(),
|
||||
tenantId -> processOne(candidate.getId()));
|
||||
return Boolean.TRUE.equals(claimed);
|
||||
}
|
||||
catch (Exception exception) {
|
||||
log.error("立即投递失败,messageId:{},deliveryId:{}", messageId, candidate.getId(), exception);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,8 +176,9 @@ public class ThirdPushDeliveryServiceImpl implements ThirdPushDeliveryService {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
Boolean claimed = TenantBroker.applyAs(candidate.getTenantId(), tenantId ->
|
||||
transactionTemplate.execute(status -> processOne(candidate.getId())));
|
||||
// 极光调用不能包在长事务里:否则领取后进程抖动会卡在 PROCESSING 直到超时(原先 10 分钟)
|
||||
Boolean claimed = TenantBroker.applyAs(candidate.getTenantId(),
|
||||
tenantId -> processOne(candidate.getId()));
|
||||
if (Boolean.TRUE.equals(claimed)) {
|
||||
claimedCount++;
|
||||
}
|
||||
@@ -361,35 +392,20 @@ public class ThirdPushDeliveryServiceImpl implements ThirdPushDeliveryService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 领取并处理单条投递任务,条件更新保证多实例只有一个节点获得处理权。
|
||||
* 领取并处理单条投递任务:短事务领取 → 事务外调极光 → 短事务落终态。
|
||||
* 条件更新保证多实例只有一个节点获得处理权。
|
||||
*
|
||||
* @param deliveryId 投递任务ID
|
||||
* @return 是否成功领取任务
|
||||
*/
|
||||
private boolean processOne(Long deliveryId) {
|
||||
ThirdPushDeliveryEntity current = deliveryMapper.selectById(deliveryId);
|
||||
if (current == null || current.getAttemptCount() >= AppMessageConstants.MAX_DELIVERY_ATTEMPTS) {
|
||||
return false;
|
||||
}
|
||||
ThirdPushDeliveryEntity claim = new ThirdPushDeliveryEntity();
|
||||
claim.setStatus(AppMessageConstants.DELIVERY_PROCESSING);
|
||||
claim.setNextRetryTime(null);
|
||||
claim.setUpdateTime(LocalDateTime.now());
|
||||
var claimWrapper = Wrappers.<ThirdPushDeliveryEntity>lambdaUpdate()
|
||||
.eq(ThirdPushDeliveryEntity::getId, deliveryId)
|
||||
.eq(ThirdPushDeliveryEntity::getStatus, current.getStatus());
|
||||
if (AppMessageConstants.DELIVERY_PROCESSING.equals(current.getStatus())) {
|
||||
// 超时任务仍保持PROCESSING状态,必须附加旧更新时间条件,防止多个实例同时把同一任务领取成功。
|
||||
claimWrapper.le(ThirdPushDeliveryEntity::getUpdateTime,
|
||||
LocalDateTime.now().minusMinutes(AppMessageConstants.PROCESSING_TIMEOUT_MINUTES));
|
||||
}
|
||||
int claimed = deliveryMapper.update(claim, claimWrapper);
|
||||
if (claimed == 0) {
|
||||
ThirdPushDeliveryEntity current = transactionTemplate.execute(status -> tryClaim(deliveryId));
|
||||
if (current == null) {
|
||||
return false;
|
||||
}
|
||||
ThirdAppMessageEntity message = messageMapper.selectById(current.getMessageId());
|
||||
if (message == null) {
|
||||
markTerminalFailure(current, "MESSAGE_NOT_FOUND", 1);
|
||||
transactionTemplate.executeWithoutResult(status -> markTerminalFailure(current, "MESSAGE_NOT_FOUND", 1));
|
||||
return true;
|
||||
}
|
||||
PushProviderRequest request = new PushProviderRequest();
|
||||
@@ -400,7 +416,43 @@ public class ThirdPushDeliveryServiceImpl implements ThirdPushDeliveryService {
|
||||
"messageId", String.valueOf(message.getId()),
|
||||
"path", AppMessageConstants.MESSAGE_DETAIL_PATH));
|
||||
int remainingAttempts = AppMessageConstants.MAX_DELIVERY_ATTEMPTS - current.getAttemptCount();
|
||||
PushDispatchResult dispatchResult = providerDispatcher.dispatch(request, remainingAttempts);
|
||||
PushDispatchResult dispatchResult;
|
||||
try {
|
||||
dispatchResult = providerDispatcher.dispatch(request, remainingAttempts);
|
||||
}
|
||||
catch (Exception exception) {
|
||||
log.error("Push供应商调用异常,deliveryId:{}", deliveryId, exception);
|
||||
transactionTemplate.executeWithoutResult(status -> applyDispatchFailure(current, null,
|
||||
StrUtil.sub(exception.getMessage(), 0, 500), 1));
|
||||
return true;
|
||||
}
|
||||
transactionTemplate.executeWithoutResult(status -> applyDispatchResult(current, dispatchResult));
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return 领取成功时的投递快照;未领到返回 null */
|
||||
private ThirdPushDeliveryEntity tryClaim(Long deliveryId) {
|
||||
ThirdPushDeliveryEntity current = deliveryMapper.selectById(deliveryId);
|
||||
if (current == null || current.getAttemptCount() >= AppMessageConstants.MAX_DELIVERY_ATTEMPTS) {
|
||||
return null;
|
||||
}
|
||||
ThirdPushDeliveryEntity claim = new ThirdPushDeliveryEntity();
|
||||
claim.setStatus(AppMessageConstants.DELIVERY_PROCESSING);
|
||||
claim.setNextRetryTime(null);
|
||||
claim.setUpdateTime(LocalDateTime.now());
|
||||
var claimWrapper = Wrappers.<ThirdPushDeliveryEntity>lambdaUpdate()
|
||||
.eq(ThirdPushDeliveryEntity::getId, deliveryId)
|
||||
.eq(ThirdPushDeliveryEntity::getStatus, current.getStatus());
|
||||
if (AppMessageConstants.DELIVERY_PROCESSING.equals(current.getStatus())) {
|
||||
// 超时任务仍保持 PROCESSING,必须附加旧更新时间条件,防止多实例同时领取。
|
||||
claimWrapper.le(ThirdPushDeliveryEntity::getUpdateTime,
|
||||
LocalDateTime.now().minusMinutes(AppMessageConstants.PROCESSING_TIMEOUT_MINUTES));
|
||||
}
|
||||
int claimed = deliveryMapper.update(claim, claimWrapper);
|
||||
return claimed == 0 ? null : current;
|
||||
}
|
||||
|
||||
private void applyDispatchResult(ThirdPushDeliveryEntity current, PushDispatchResult dispatchResult) {
|
||||
int consumedAttempts = persistAttempts(current, dispatchResult);
|
||||
int newAttemptCount = current.getAttemptCount() + Math.max(consumedAttempts, 1);
|
||||
if (dispatchResult.isAccepted()) {
|
||||
@@ -414,28 +466,32 @@ public class ThirdPushDeliveryServiceImpl implements ThirdPushDeliveryService {
|
||||
deliveryMapper.update(success, Wrappers.<ThirdPushDeliveryEntity>lambdaUpdate()
|
||||
.eq(ThirdPushDeliveryEntity::getId, current.getId())
|
||||
.eq(ThirdPushDeliveryEntity::getStatus, AppMessageConstants.DELIVERY_PROCESSING));
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
PushDispatchAttempt lastAttempt = dispatchResult.getAttempts().isEmpty()
|
||||
? null : dispatchResult.getAttempts().get(dispatchResult.getAttempts().size() - 1);
|
||||
String error = lastAttempt == null ? "NO_PROVIDER_AVAILABLE"
|
||||
: StrUtil.blankToDefault(lastAttempt.getResult().getErrorMessage(), lastAttempt.getResult().getErrorCode());
|
||||
applyDispatchFailure(current, lastAttempt, error, newAttemptCount);
|
||||
}
|
||||
|
||||
private void applyDispatchFailure(ThirdPushDeliveryEntity current, PushDispatchAttempt lastAttempt,
|
||||
String error, int newAttemptCount) {
|
||||
if (lastAttempt != null && "INVALID_DEVICE".equals(lastAttempt.getResult().getErrorCode())) {
|
||||
deviceBindingService.disableInvalidBindings(current.getRecipientUserId(), lastAttempt.getProviderCode());
|
||||
}
|
||||
if (newAttemptCount >= AppMessageConstants.MAX_DELIVERY_ATTEMPTS) {
|
||||
markTerminalFailure(current, error, newAttemptCount);
|
||||
} else {
|
||||
ThirdPushDeliveryEntity retry = new ThirdPushDeliveryEntity();
|
||||
retry.setStatus(AppMessageConstants.DELIVERY_RETRY);
|
||||
retry.setAttemptCount(newAttemptCount);
|
||||
retry.setNextRetryTime(LocalDateTime.now().plusMinutes(retryMinutes(newAttemptCount)));
|
||||
retry.setLastError(StrUtil.sub(error, 0, 2000));
|
||||
deliveryMapper.update(retry, Wrappers.<ThirdPushDeliveryEntity>lambdaUpdate()
|
||||
.eq(ThirdPushDeliveryEntity::getId, current.getId())
|
||||
.eq(ThirdPushDeliveryEntity::getStatus, AppMessageConstants.DELIVERY_PROCESSING));
|
||||
return;
|
||||
}
|
||||
return true;
|
||||
ThirdPushDeliveryEntity retry = new ThirdPushDeliveryEntity();
|
||||
retry.setStatus(AppMessageConstants.DELIVERY_RETRY);
|
||||
retry.setAttemptCount(newAttemptCount);
|
||||
retry.setNextRetryTime(LocalDateTime.now().plusMinutes(retryMinutes(newAttemptCount)));
|
||||
retry.setLastError(StrUtil.sub(error, 0, 2000));
|
||||
deliveryMapper.update(retry, Wrappers.<ThirdPushDeliveryEntity>lambdaUpdate()
|
||||
.eq(ThirdPushDeliveryEntity::getId, current.getId())
|
||||
.eq(ThirdPushDeliveryEntity::getStatus, AppMessageConstants.DELIVERY_PROCESSING));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+12
@@ -39,4 +39,16 @@ public class SysDeptInvitePreviewVO {
|
||||
|
||||
@Schema(description = "过期时间")
|
||||
private java.time.LocalDateTime expireTime;
|
||||
|
||||
@Schema(description = "邀请类型:DEPT=部门邀请 / FX=酒店分销邀请")
|
||||
private String inviteKind;
|
||||
|
||||
@Schema(description = "分销邀请提示文案")
|
||||
private String hint;
|
||||
|
||||
@Schema(description = "分销邀请人姓名")
|
||||
private String parentName;
|
||||
|
||||
@Schema(description = "注册后分销层级(仅 FX)")
|
||||
private Integer childLevel;
|
||||
}
|
||||
@@ -245,6 +245,13 @@
|
||||
<version>5.7.0</version>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
<!-- 酒店分销邀请码:注册页复用同一邀请码入口 -->
|
||||
<dependency>
|
||||
<groupId>com.bsd</groupId>
|
||||
<artifactId>pjcloud-supermarket-api</artifactId>
|
||||
<version>5.7.0</version>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
|
||||
|
||||
+211
-64
@@ -2,7 +2,6 @@ package com.bsd.pjcloud.admin.service.impl;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import cn.hutool.core.util.ObjectUtil;
|
||||
import cn.hutool.core.util.RandomUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
@@ -42,6 +41,8 @@ import com.bsd.pjcloud.common.core.util.MsgUtils;
|
||||
import com.bsd.pjcloud.common.core.util.R;
|
||||
import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
|
||||
import com.bsd.pjcloud.flow.task.constant.TaskCheckStatusEnum;
|
||||
import com.bsd.pjcloud.supermarket.api.feign.RemoteSmHotelFxInviteService;
|
||||
import com.bsd.pjcloud.supermarket.vo.SmHotelFxInvitePreviewVO;
|
||||
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
|
||||
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -80,6 +81,7 @@ public class SysDeptInviteServiceImpl extends ServiceImpl<SysDeptInviteMapper, S
|
||||
private final SysRoleService sysRoleService;
|
||||
private final SysUserIdentityService sysUserIdentityService;
|
||||
private final RemoteMchService remoteMchService;
|
||||
private final RemoteSmHotelFxInviteService remoteSmHotelFxInviteService;
|
||||
private final ApplicationEventPublisher applicationEventPublisher;
|
||||
private final FlowUtil flowUtil;
|
||||
private final SysMessageService sysMessageService;
|
||||
@@ -261,28 +263,12 @@ public class SysDeptInviteServiceImpl extends ServiceImpl<SysDeptInviteMapper, S
|
||||
|
||||
@Override
|
||||
public SysDeptInvitePreviewVO preview(String token) {
|
||||
SysDeptInviteEntity invite = requireValidInvite(token);
|
||||
SysDept dept = sysDeptService.getById(invite.getDeptId());
|
||||
MchManageVo mch = resolveMerchantAlongUnitChain(invite.getDeptId());
|
||||
String attr = mch != null ? StrUtil.blankToDefault(mch.getMerchantAttr(), MerchantAttrEnum.NORMAL.getCode())
|
||||
: MerchantAttrEnum.NORMAL.getCode();
|
||||
MerchantAttrEnum attrEnum = MerchantAttrEnum.valueOfByCode(attr);
|
||||
String identityType = MerchantAttrEnum.resolveDefaultIdentityType(attr);
|
||||
UserIdentityTypeEnum identity = UserIdentityTypeEnum.valueOfByType(identityType);
|
||||
|
||||
SysDeptInvitePreviewVO vo = new SysDeptInvitePreviewVO();
|
||||
vo.setToken(invite.getToken());
|
||||
vo.setDeptId(invite.getDeptId());
|
||||
vo.setDeptName(dept != null ? dept.getName() : null);
|
||||
vo.setNeedAudit("1".equals(invite.getNeedAudit()));
|
||||
vo.setMerchantId(mch != null ? mch.getId() : null);
|
||||
vo.setMerchantAttr(attr);
|
||||
vo.setMerchantAttrLabel(attrEnum != null ? attrEnum.getLabel() : MerchantAttrEnum.NORMAL.getLabel());
|
||||
vo.setIdentityType(identityType);
|
||||
vo.setIdentityTypeLabel(identity != null ? identity.getDescription() : UserIdentityTypeEnum.DEFAULT.getDescription());
|
||||
vo.setValidFrom(invite.getValidFrom());
|
||||
vo.setExpireTime(invite.getExpireTime());
|
||||
return vo;
|
||||
String code = StrUtil.trim(token);
|
||||
// 分销邀请码统一 FX 前缀,与部门 6/8 位数字、UUID 邀请码格式互斥,按格式分流
|
||||
if (isFxInviteCode(code)) {
|
||||
return buildFxPreview(requireFxInvite(code));
|
||||
}
|
||||
return buildDeptPreview(requireValidDeptInvite(code));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -302,17 +288,168 @@ public class SysDeptInviteServiceImpl extends ServiceImpl<SysDeptInviteMapper, S
|
||||
return R.failed("验证码错误或已过期");
|
||||
}
|
||||
|
||||
SysDeptInviteEntity invite = requireValidInvite(dto.getToken());
|
||||
boolean phoneExists = sysUserMapper.exists(Wrappers.<SysUser>lambdaQuery().eq(SysUser::getPhone, phone));
|
||||
if (phoneExists) {
|
||||
return R.failed(MsgUtils.getMessage(ErrorCodes.SYS_USER_PHONE_EXISTING, phone));
|
||||
String code = dto.getToken().trim();
|
||||
if (isFxInviteCode(code)) {
|
||||
return registerByFxInvite(dto, requireFxInvite(code), phone);
|
||||
}
|
||||
return registerByDeptInvite(dto, requireValidDeptInvite(code), phone);
|
||||
}
|
||||
|
||||
/** 酒店分销邀请码:FX + 数字/字母,与部门邀请码格式区分 */
|
||||
private boolean isFxInviteCode(String token) {
|
||||
return StrUtil.isNotBlank(token) && StrUtil.startWithIgnoreCase(token.trim(), "FX");
|
||||
}
|
||||
|
||||
private SysDeptInvitePreviewVO buildDeptPreview(SysDeptInviteEntity invite) {
|
||||
SysDept dept = sysDeptService.getById(invite.getDeptId());
|
||||
MchManageVo mch = resolveMerchantAlongUnitChain(invite.getDeptId());
|
||||
String attr = mch != null ? StrUtil.blankToDefault(mch.getMerchantAttr(), MerchantAttrEnum.NORMAL.getCode())
|
||||
: MerchantAttrEnum.NORMAL.getCode();
|
||||
MerchantAttrEnum attrEnum = MerchantAttrEnum.valueOfByCode(attr);
|
||||
String identityType = MerchantAttrEnum.resolveDefaultIdentityType(attr);
|
||||
UserIdentityTypeEnum identity = UserIdentityTypeEnum.valueOfByType(identityType);
|
||||
|
||||
SysDeptInvitePreviewVO vo = new SysDeptInvitePreviewVO();
|
||||
vo.setInviteKind("DEPT");
|
||||
vo.setToken(invite.getToken());
|
||||
vo.setDeptId(invite.getDeptId());
|
||||
vo.setDeptName(dept != null ? dept.getName() : null);
|
||||
vo.setNeedAudit("1".equals(invite.getNeedAudit()));
|
||||
vo.setMerchantId(mch != null ? mch.getId() : null);
|
||||
vo.setMerchantAttr(attr);
|
||||
vo.setMerchantAttrLabel(attrEnum != null ? attrEnum.getLabel() : MerchantAttrEnum.NORMAL.getLabel());
|
||||
vo.setIdentityType(identityType);
|
||||
vo.setIdentityTypeLabel(identity != null ? identity.getDescription() : UserIdentityTypeEnum.DEFAULT.getDescription());
|
||||
vo.setValidFrom(invite.getValidFrom());
|
||||
vo.setExpireTime(invite.getExpireTime());
|
||||
return vo;
|
||||
}
|
||||
|
||||
private SysDeptInvitePreviewVO buildFxPreview(SmHotelFxInvitePreviewVO fx) {
|
||||
if (fx.getDeptId() == null) {
|
||||
throw new IllegalArgumentException("邀请人账号未归属部门,无法通过邀请注册,请联系管理员");
|
||||
}
|
||||
SysDept dept = sysDeptService.getById(fx.getDeptId());
|
||||
MchManageVo mch = resolveMerchantAlongUnitChain(fx.getDeptId());
|
||||
String attr = mch != null ? StrUtil.blankToDefault(mch.getMerchantAttr(), MerchantAttrEnum.NORMAL.getCode())
|
||||
: MerchantAttrEnum.NORMAL.getCode();
|
||||
MerchantAttrEnum attrEnum = MerchantAttrEnum.valueOfByCode(attr);
|
||||
String identityType = MerchantAttrEnum.resolveDefaultIdentityType(attr);
|
||||
UserIdentityTypeEnum identity = UserIdentityTypeEnum.valueOfByType(identityType);
|
||||
|
||||
SysDeptInvitePreviewVO vo = new SysDeptInvitePreviewVO();
|
||||
vo.setInviteKind("FX");
|
||||
vo.setToken(fx.getInviteCode());
|
||||
vo.setDeptId(fx.getDeptId());
|
||||
vo.setDeptName(dept != null ? dept.getName() : null);
|
||||
vo.setNeedAudit(false);
|
||||
vo.setMerchantId(mch != null ? mch.getId() : null);
|
||||
vo.setMerchantAttr(attr);
|
||||
vo.setMerchantAttrLabel(attrEnum != null ? attrEnum.getLabel() : MerchantAttrEnum.NORMAL.getLabel());
|
||||
vo.setIdentityType(identityType);
|
||||
vo.setIdentityTypeLabel(identity != null ? identity.getDescription() : UserIdentityTypeEnum.DEFAULT.getDescription());
|
||||
vo.setHint(fx.getHint());
|
||||
vo.setParentName(fx.getParentName());
|
||||
vo.setChildLevel(fx.getChildLevel());
|
||||
return vo;
|
||||
}
|
||||
|
||||
private SmHotelFxInvitePreviewVO requireFxInvite(String token) {
|
||||
if (StrUtil.isBlank(token)) {
|
||||
throw new IllegalArgumentException("邀请码无效");
|
||||
}
|
||||
R<SmHotelFxInvitePreviewVO> fxResp;
|
||||
try {
|
||||
fxResp = remoteSmHotelFxInviteService.preview(token.trim());
|
||||
}
|
||||
catch (Exception ex) {
|
||||
log.warn("|{}|分销邀请预览失败 token={} err={}", TAG, token, ex.getMessage());
|
||||
throw new IllegalArgumentException("邀请码错误或已失效");
|
||||
}
|
||||
if (fxResp == null || !fxResp.isOk() || fxResp.getData() == null) {
|
||||
throw new IllegalArgumentException(fxResp != null && StrUtil.isNotBlank(fxResp.getMsg())
|
||||
? fxResp.getMsg() : "邀请码错误或已失效");
|
||||
}
|
||||
return fxResp.getData();
|
||||
}
|
||||
|
||||
private R<Boolean> registerByDeptInvite(DeptInviteRegisterDTO dto, SysDeptInviteEntity invite, String phone) {
|
||||
PreparedInviteUser prepared = prepareInviteUser(dto, phone);
|
||||
if (prepared.errorMsg != null) {
|
||||
return R.failed(prepared.errorMsg);
|
||||
}
|
||||
|
||||
MchManageVo mch = resolveMerchantAlongUnitChain(invite.getDeptId());
|
||||
String attr = mch != null ? StrUtil.blankToDefault(mch.getMerchantAttr(), MerchantAttrEnum.NORMAL.getCode())
|
||||
: MerchantAttrEnum.NORMAL.getCode();
|
||||
String identityType = MerchantAttrEnum.resolveDefaultIdentityType(attr);
|
||||
boolean needAudit = "1".equals(invite.getNeedAudit());
|
||||
|
||||
SysUser sysUser = insertInviteUser(prepared, invite.getDeptId(), needAudit, phone);
|
||||
assignDefaultRolesAndIdentity(sysUser, identityType);
|
||||
|
||||
if (needAudit) {
|
||||
startInviteUserFlow(sysUser, invite.getDeptId());
|
||||
return R.ok(true, "注册成功,请等待审核通过后再登录");
|
||||
}
|
||||
|
||||
applicationEventPublisher.publishEvent(
|
||||
new UserWalletCreateEvent(sysUser.getUserId(), TenantContextHolder.getTenantId()));
|
||||
return R.ok(true, "注册成功");
|
||||
}
|
||||
|
||||
private R<Boolean> registerByFxInvite(DeptInviteRegisterDTO dto, SmHotelFxInvitePreviewVO fx, String phone) {
|
||||
if (fx.getDeptId() == null) {
|
||||
return R.failed("邀请人账号未归属部门,无法通过邀请注册,请联系管理员");
|
||||
}
|
||||
PreparedInviteUser prepared = prepareInviteUser(dto, phone);
|
||||
if (prepared.errorMsg != null) {
|
||||
return R.failed(prepared.errorMsg);
|
||||
}
|
||||
|
||||
MchManageVo mch = resolveMerchantAlongUnitChain(fx.getDeptId());
|
||||
String attr = mch != null ? StrUtil.blankToDefault(mch.getMerchantAttr(), MerchantAttrEnum.NORMAL.getCode())
|
||||
: MerchantAttrEnum.NORMAL.getCode();
|
||||
String identityType = MerchantAttrEnum.resolveDefaultIdentityType(attr);
|
||||
|
||||
SysUser sysUser = insertInviteUser(prepared, fx.getDeptId(), false, phone);
|
||||
// 分销邀请:默认用户角色 + 钱包角色 + 分销员角色(App 商家工作台「分销管理」可见)
|
||||
assignDefaultRolesAndIdentity(sysUser, identityType, "HOTEL_FX_AGENT");
|
||||
|
||||
R<Boolean> bind = remoteSmHotelFxInviteService.bindAgent(fx.getInviteCode(), sysUser.getUserId(),
|
||||
sysUser.getUsername(), sysUser.getName(), phone);
|
||||
if (bind == null || !bind.isOk()) {
|
||||
throw new IllegalArgumentException(
|
||||
bind != null && StrUtil.isNotBlank(bind.getMsg()) ? bind.getMsg() : "分销员挂靠失败,请稍后重试");
|
||||
}
|
||||
|
||||
applicationEventPublisher.publishEvent(
|
||||
new UserWalletCreateEvent(sysUser.getUserId(), TenantContextHolder.getTenantId()));
|
||||
String tip = fx.getChildLevel() != null
|
||||
? StrUtil.format("注册成功,已成为{}级分销员", fx.getChildLevel())
|
||||
: "注册成功";
|
||||
return R.ok(true, tip);
|
||||
}
|
||||
|
||||
private static final class PreparedInviteUser {
|
||||
private String username;
|
||||
private String password;
|
||||
private String displayName;
|
||||
private String idCard;
|
||||
private Integer gender;
|
||||
private String errorMsg;
|
||||
}
|
||||
|
||||
private PreparedInviteUser prepareInviteUser(DeptInviteRegisterDTO dto, String phone) {
|
||||
PreparedInviteUser prepared = new PreparedInviteUser();
|
||||
boolean phoneExists = sysUserMapper.exists(Wrappers.<SysUser>lambdaQuery().eq(SysUser::getPhone, phone));
|
||||
if (phoneExists) {
|
||||
prepared.errorMsg = MsgUtils.getMessage(ErrorCodes.SYS_USER_PHONE_EXISTING, phone);
|
||||
return prepared;
|
||||
}
|
||||
|
||||
// 账号默认用手机号前缀 u,兼容旧客户端显式传 username/password
|
||||
String username = StrUtil.blankToDefault(StrUtil.trim(dto.getUsername()), "u" + phone);
|
||||
String usernameErr = validateUsername(username);
|
||||
if (usernameErr != null) {
|
||||
// 纯数字账号不符合「字母开头」规则时,强制用 u+手机号
|
||||
if (validateUsername(username) != null) {
|
||||
username = "u" + phone;
|
||||
}
|
||||
boolean usernameExists = sysUserMapper.exists(Wrappers.<SysUser>lambdaQuery().eq(SysUser::getUsername, username));
|
||||
@@ -325,77 +462,87 @@ public class SysDeptInviteServiceImpl extends ServiceImpl<SysDeptInviteMapper, S
|
||||
if (StrUtil.isNotBlank(dto.getPassword())) {
|
||||
String passwordErr = validateInvitePassword(password);
|
||||
if (passwordErr != null) {
|
||||
return R.failed(passwordErr);
|
||||
prepared.errorMsg = passwordErr;
|
||||
return prepared;
|
||||
}
|
||||
}
|
||||
|
||||
String displayName = StrUtil.blankToDefault(StrUtil.trim(dto.getName()),
|
||||
"用户" + phone.substring(Math.max(0, phone.length() - 4)));
|
||||
String nameErr = validateInviteName(displayName);
|
||||
if (nameErr != null) {
|
||||
if (validateInviteName(displayName) != null) {
|
||||
displayName = "用户" + phone.substring(Math.max(0, phone.length() - 4));
|
||||
}
|
||||
|
||||
String idCard = StrUtil.trim(dto.getIdCard());
|
||||
if (StrUtil.isNotBlank(idCard) && !ID_CARD_PATTERN.matcher(idCard).matches()) {
|
||||
return R.failed("身份证号格式不正确");
|
||||
prepared.errorMsg = "身份证号格式不正确";
|
||||
return prepared;
|
||||
}
|
||||
|
||||
MchManageVo mch = resolveMerchantAlongUnitChain(invite.getDeptId());
|
||||
String attr = mch != null ? StrUtil.blankToDefault(mch.getMerchantAttr(), MerchantAttrEnum.NORMAL.getCode())
|
||||
: MerchantAttrEnum.NORMAL.getCode();
|
||||
String identityType = MerchantAttrEnum.resolveDefaultIdentityType(attr);
|
||||
boolean needAudit = "1".equals(invite.getNeedAudit());
|
||||
prepared.username = username;
|
||||
prepared.password = password;
|
||||
prepared.displayName = displayName;
|
||||
prepared.idCard = StrUtil.blankToDefault(idCard, null);
|
||||
prepared.gender = dto.getGender() == null ? 0 : dto.getGender();
|
||||
return prepared;
|
||||
}
|
||||
|
||||
private SysUser insertInviteUser(PreparedInviteUser prepared, Long deptId, boolean needAudit, String phone) {
|
||||
SysUser sysUser = new SysUser();
|
||||
sysUser.setUsername(username);
|
||||
sysUser.setUsername(prepared.username);
|
||||
sysUser.setPhone(phone);
|
||||
sysUser.setIdCard(StrUtil.blankToDefault(idCard, null));
|
||||
sysUser.setName(PersonNameUtils.normalize(displayName));
|
||||
sysUser.setIdCard(prepared.idCard);
|
||||
sysUser.setName(PersonNameUtils.normalize(prepared.displayName));
|
||||
sysUser.setNickname(sysUser.getName());
|
||||
sysUser.setGender(dto.getGender() == null ? 0 : dto.getGender());
|
||||
sysUser.setDeptId(invite.getDeptId());
|
||||
sysUser.setGender(prepared.gender);
|
||||
sysUser.setDeptId(deptId);
|
||||
sysUser.setLockFlag(UserStateEnum.NORMAL.getCode());
|
||||
sysUser.setCreateBy(username);
|
||||
sysUser.setUpdateBy(username);
|
||||
sysUser.setPassword(ENCODER.encode(password));
|
||||
sysUser.setCreateBy(prepared.username);
|
||||
sysUser.setUpdateBy(prepared.username);
|
||||
sysUser.setPassword(ENCODER.encode(prepared.password));
|
||||
sysUser.setPasswordModifyTime(LocalDateTime.now());
|
||||
sysUser.setProcessStatus(needAudit ? TaskCheckStatusEnum.PADDING.getCode() : TaskCheckStatusEnum.RESULT.getCode());
|
||||
sysUser.setProcessTime(LocalDateTime.now());
|
||||
sysUser.setStaffingType(UserStaffingTypeEnum.NONE_STAFFING.getType());
|
||||
sysUser.setPasswordExpireFlag(UserExpireEnum.NORMAL.getCode());
|
||||
sysUserMapper.insert(sysUser);
|
||||
return sysUser;
|
||||
}
|
||||
|
||||
// 与后台新增用户一致:固定 GENERAL_USER + WALLET_AUTO_CREATE(按角色编码,避免参数配置漂移)
|
||||
List<String> roleCodes = List.of(CommonRoleConstant.DEFAULT_USER, CommonRoleConstant.AUTO_CREATE_WALLET);
|
||||
private void assignDefaultRolesAndIdentity(SysUser sysUser, String identityType, String... extraRoleCodes) {
|
||||
List<String> roleCodes = new java.util.ArrayList<>(
|
||||
List.of(CommonRoleConstant.DEFAULT_USER, CommonRoleConstant.AUTO_CREATE_WALLET));
|
||||
if (extraRoleCodes != null) {
|
||||
for (String c : extraRoleCodes) {
|
||||
if (StrUtil.isNotBlank(c) && !roleCodes.contains(c)) {
|
||||
roleCodes.add(c.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
List<SysRole> defaultRoles = Optional.ofNullable(sysRoleService.findRolesByCodes(roleCodes))
|
||||
.orElseGet(Collections::emptyList);
|
||||
Map<String, Long> roleIdByCode = defaultRoles.stream()
|
||||
.collect(Collectors.toMap(SysRole::getRoleCode, SysRole::getRoleId, (a, b) -> a));
|
||||
if (!roleIdByCode.keySet().containsAll(roleCodes)) {
|
||||
if (!roleIdByCode.containsKey(CommonRoleConstant.DEFAULT_USER)
|
||||
|| !roleIdByCode.containsKey(CommonRoleConstant.AUTO_CREATE_WALLET)) {
|
||||
throw new IllegalStateException("默认用户角色配置不完整,请联系管理员配置 GENERAL_USER / WALLET_AUTO_CREATE");
|
||||
}
|
||||
for (String code : roleCodes) {
|
||||
Long roleId = roleIdByCode.get(code);
|
||||
if (roleId == null) {
|
||||
log.warn("|{}|邀请注册缺少角色 {},已跳过(请先执行分销工作台 SQL)|userId={}|", TAG, code,
|
||||
sysUser.getUserId());
|
||||
continue;
|
||||
}
|
||||
SysUserRole userRole = new SysUserRole();
|
||||
userRole.setUserId(sysUser.getUserId());
|
||||
userRole.setRoleId(roleIdByCode.get(code));
|
||||
userRole.setRoleId(roleId);
|
||||
sysUserRoleMapper.insert(userRole);
|
||||
}
|
||||
|
||||
UserDTO identityDto = new UserDTO();
|
||||
identityDto.setIdentityTypes(List.of(identityType));
|
||||
sysUserIdentityService.saveUserIdentity(sysUser.getUserId(), identityDto);
|
||||
|
||||
if (needAudit) {
|
||||
startInviteUserFlow(sysUser, invite.getDeptId());
|
||||
return R.ok(true, "注册成功,请等待审核通过后再登录");
|
||||
}
|
||||
|
||||
// 事务提交后再异步开钱包(带租户),避免 CompletableFuture 抢跑读不到新用户/丢租户头
|
||||
applicationEventPublisher.publishEvent(
|
||||
new UserWalletCreateEvent(sysUser.getUserId(), TenantContextHolder.getTenantId()));
|
||||
// 保留验证码供前端立即短信登录(登录时再消费)
|
||||
return R.ok(true, "注册成功");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -500,7 +647,7 @@ public class SysDeptInviteServiceImpl extends ServiceImpl<SysDeptInviteMapper, S
|
||||
return null;
|
||||
}
|
||||
|
||||
private SysDeptInviteEntity requireValidInvite(String token) {
|
||||
private SysDeptInviteEntity requireValidDeptInvite(String token) {
|
||||
if (StrUtil.isBlank(token)) {
|
||||
throw new IllegalArgumentException("邀请码无效");
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user