diff --git a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/dto/SmHotelFxLevelRuleDTO.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/dto/SmHotelFxLevelRuleDTO.java new file mode 100644 index 000000000..7808443f3 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/dto/SmHotelFxLevelRuleDTO.java @@ -0,0 +1,32 @@ +package com.bsd.pjcloud.supermarket.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.math.BigDecimal; + +@Data +@Schema(description = "酒店分销层级规则") +public class SmHotelFxLevelRuleDTO { + + @Schema(description = "层级 1~5") + private Integer level; + + @Schema(description = "岗位名") + private String name; + + @Schema(description = "占奖金池比例,如 0.40") + private BigDecimal shareOfPool; + + @Schema(description = "是否可晋升") + private Boolean canPromote; + + @Schema(description = "升到本级所需累计 promote 分,如四级升三级则为三级门槛 50000") + private BigDecimal promoteMin; + + @Schema(description = "保级门槛(二级:窗口内区域内新酒店锁定基数,默认 40000)") + private BigDecimal demoteMin; + + @Schema(description = "保级窗口天数(二级默认 90)") + private Integer demoteWindowDays; +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelFxAgentEntity.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelFxAgentEntity.java new file mode 100644 index 000000000..203fb3c30 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelFxAgentEntity.java @@ -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.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +@TenantTable +@TableName("canteen_hotel_fx_agent") +@EqualsAndHashCode(callSuper = true) +@Schema(description = "酒店分销员") +public class SmHotelFxAgentEntity extends Model { + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + private Long tenantId; + + @Schema(description = "系统用户 ID") + private Long userId; + + private String username; + + private String realName; + + private String mobile; + + @Schema(description = "层级 1~5") + private Integer levelNo; + + @Schema(description = "上级分销员") + private Long parentId; + + private String inviteCode; + + private String cityAdcode; + + private String regionJson; + + @Schema(description = "ACTIVE/DISABLED/EXIT") + private String status; + + @Schema(description = "晋升累计分") + private BigDecimal promoteScore; + + @Schema(description = "二级保级窗口累计") + private BigDecimal demoteWindowScore; + + private LocalDateTime demoteWindowStart; + + 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; + + @TableField(exist = false) + @Schema(description = "上级姓名") + private String parentName; + + @TableField(exist = false) + @Schema(description = "绑定酒店数") + private Integer hotelCount; +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelFxCommissionEntity.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelFxCommissionEntity.java new file mode 100644 index 000000000..861427ec1 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelFxCommissionEntity.java @@ -0,0 +1,89 @@ +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.LocalDate; +import java.time.LocalDateTime; + +@Data +@TenantTable +@TableName("canteen_hotel_fx_commission") +@EqualsAndHashCode(callSuper = true) +@Schema(description = "酒店分销佣金明细") +public class SmHotelFxCommissionEntity extends Model { + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + private Long tenantId; + + private Long orderId; + + private Long hotelId; + + private Long agentId; + + private Long ownerAgentId; + + @Schema(description = "本条对应层级份额 1~5") + private Integer chainLevel; + + @Schema(description = "结算时分销员层级快照") + private Integer agentLevel; + + private BigDecimal costAmount; + + private BigDecimal poolAmount; + + private BigDecimal shareRate; + + private BigDecimal commissionAmount; + + private BigDecimal promoteBaseAmount; + + @Schema(description = "ESTIMATED/LOCKED/CANCELLED") + private String status; + + private LocalDate checkOutDate; + + private LocalDateTime lockTime; + + @Schema(description = "提现单 ID") + private Long withdrawId; + + @Schema(description = "配置快照 JSON") + private String configSnapshot; + + 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; + + @TableField(exist = false) + private String agentName; + + @TableField(exist = false) + private String hotelName; + + @TableField(exist = false) + private String channelNo; +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelFxConfigEntity.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelFxConfigEntity.java new file mode 100644 index 000000000..80524af6d --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelFxConfigEntity.java @@ -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_fx_config") +@EqualsAndHashCode(callSuper = true) +@Schema(description = "酒店分销佣金配置") +public class SmHotelFxConfigEntity extends Model { + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + private Long tenantId; + + @Schema(description = "1开 0关") + private String enabled; + + @Schema(description = "奖金池成本系数,默认 0.06") + private BigDecimal poolCostRate; + + @Schema(description = "奖金池销售分佣池比例,默认 0.30") + private BigDecimal poolSalesRate; + + @Schema(description = "晋升累计成本系数,默认 0.06") + private BigDecimal promoteBaseRate; + + @Schema(description = "层级规则 JSON") + private String levelRulesJson; + + 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; +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelFxExitEntity.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelFxExitEntity.java new file mode 100644 index 000000000..d1936ec5d --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelFxExitEntity.java @@ -0,0 +1,64 @@ +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_exit") +@EqualsAndHashCode(callSuper = true) +@Schema(description = "酒店分销退出") +public class SmHotelFxExitEntity extends Model { + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + private Long tenantId; + + private Long agentId; + + private Long userId; + + private String reason; + + @Schema(description = "PENDING/DONE/REJECTED/CANCELLED") + private String status; + + private Long successorAgentId; + + private Integer hotelCount; + + 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; + + @TableField(exist = false) + private String successorName; +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelFxHotelBindEntity.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelFxHotelBindEntity.java new file mode 100644 index 000000000..4d2fd9324 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelFxHotelBindEntity.java @@ -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.time.LocalDateTime; + +@Data +@TenantTable +@TableName("canteen_hotel_fx_hotel_bind") +@EqualsAndHashCode(callSuper = true) +@Schema(description = "酒店与分销员绑定") +public class SmHotelFxHotelBindEntity extends Model { + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + private Long tenantId; + + private Long hotelId; + + private Long agentId; + + @Schema(description = "BOUND/UNBOUND") + private String bindStatus; + + private String auditRemark; + + @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 hotelName; + + @TableField(exist = false) + private String hotelCode; + + @TableField(exist = false) + private String agentName; +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelFxL1ApplyEntity.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelFxL1ApplyEntity.java new file mode 100644 index 000000000..d84a0aa86 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelFxL1ApplyEntity.java @@ -0,0 +1,67 @@ +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_fx_l1_apply") +@EqualsAndHashCode(callSuper = true) +@Schema(description = "酒店分销一级申请") +public class SmHotelFxL1ApplyEntity extends Model { + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + private Long tenantId; + + private Long agentId; + + private Long userId; + + private String cityAdcode; + + private String cityName; + + private BigDecimal promoteScore; + + @Schema(description = "PENDING/APPROVED/REJECTED/CANCELLED") + private String status; + + private String applyRemark; + + 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; + + @TableField(exist = false) + private Integer agentLevel; +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelFxWithdrawEntity.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelFxWithdrawEntity.java new file mode 100644 index 000000000..52b976844 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelFxWithdrawEntity.java @@ -0,0 +1,70 @@ +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_fx_withdraw") +@EqualsAndHashCode(callSuper = true) +@Schema(description = "酒店分销提现") +public class SmHotelFxWithdrawEntity extends Model { + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + private Long tenantId; + + private Long agentId; + + private Long userId; + + private BigDecimal amount; + + private Integer commissionCount; + + private String accountName; + + private String accountNo; + + private String bankName; + + @Schema(description = "PENDING/APPROVED/PAID/REJECTED") + private String status; + + private String applyRemark; + + private String auditRemark; + + private String auditBy; + + private LocalDateTime auditTime; + + private LocalDateTime payTime; + + @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; +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelFxController.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelFxController.java new file mode 100644 index 000000000..35fd669b2 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelFxController.java @@ -0,0 +1,241 @@ +package com.bsd.pjcloud.supermarket.controller; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.log.annotation.SysLog; +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.SmHotelFxHotelBindEntity; +import com.bsd.pjcloud.supermarket.entity.SmHotelFxL1ApplyEntity; +import com.bsd.pjcloud.supermarket.entity.SmHotelFxWithdrawEntity; +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.SmHotelFxL1ApplyService; +import com.bsd.pjcloud.supermarket.service.SmHotelFxWithdrawService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +/** + * 酒店分销:配置 / 分销员 / 佣金 / 一级申请 / 提现 / 退出。 + */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/smHotelFx") +@Tag(name = "酒店分销") +public class SmHotelFxController { + + private final SmHotelFxConfigService configService; + + private final SmHotelFxAgentService agentService; + + private final SmHotelFxCommissionService commissionService; + + private final SmHotelFxL1ApplyService l1ApplyService; + + private final SmHotelFxWithdrawService withdrawService; + + private final SmHotelFxExitService exitService; + + @Operation(summary = "分销配置") + @GetMapping("/config") + @PreAuthorize("@pms.hasPermission('supermarket_smHotelFx_view')") + public R getConfig() { + return R.ok(configService.getOrCreateCurrent()); + } + + @SysLog("保存酒店分销配置") + @Operation(summary = "保存分销配置(仅影响新单)") + @PutMapping("/config") + @PreAuthorize("@pms.hasPermission('supermarket_smHotelFx_edit')") + public R saveConfig(@RequestBody SmHotelFxConfigEntity body) { + return configService.saveCurrent(body); + } + + @Operation(summary = "分销员分页") + @GetMapping("/agent/page") + @PreAuthorize("@pms.hasPermission('supermarket_smHotelFxAgent_view')") + public R> agentPage(Page page, SmHotelFxAgentEntity query) { + return R.ok(agentService.pageAgents(page, query)); + } + + @SysLog("保存酒店分销员") + @Operation(summary = "新增/编辑分销员") + @PostMapping("/agent") + @PreAuthorize("@pms.hasPermission('supermarket_smHotelFxAgent_edit')") + public R saveAgent(@RequestBody SmHotelFxAgentEntity body) { + return agentService.saveAgent(body); + } + + @SysLog("划调酒店分销区域") + @Operation(summary = "划调二级负责区域(adcode 数组 JSON)") + @PutMapping("/agent/region") + @PreAuthorize("@pms.hasPermission('supermarket_smHotelFxAgent_edit')") + public R saveRegion(@RequestParam Long agentId, + @RequestParam(required = false) String regionJson, + @RequestParam(required = false) String cityAdcode) { + return agentService.saveRegion(agentId, regionJson, cityAdcode); + } + + @SysLog("手动执行二级保级考核") + @Operation(summary = "手动扫描二级到期降级/滚动窗口") + @PostMapping("/agent/demoteDue") + @PreAuthorize("@pms.hasPermission('supermarket_smHotelFx_edit')") + public R demoteDue() { + return R.ok(agentService.processDueDemotions()); + } + + @SysLog("绑定酒店分销员") + @Operation(summary = "酒店绑定分销员") + @PostMapping("/bind") + @PreAuthorize("@pms.hasPermission('supermarket_smHotelFxAgent_edit')") + public R bind(@RequestParam Long hotelId, @RequestParam Long agentId, + @RequestParam(required = false) String remark) { + return agentService.bindHotel(hotelId, agentId, remark); + } + + @SysLog("解绑酒店分销员") + @Operation(summary = "解绑酒店") + @PostMapping("/unbind") + @PreAuthorize("@pms.hasPermission('supermarket_smHotelFxAgent_edit')") + public R unbind(@RequestParam Long hotelId) { + return agentService.unbindHotel(hotelId); + } + + @Operation(summary = "酒店绑定分页") + @GetMapping("/bind/page") + @PreAuthorize("@pms.hasPermission('supermarket_smHotelFxAgent_view')") + public R> bindPage(Page page, + @RequestParam(required = false) Long agentId, @RequestParam(required = false) Long hotelId) { + return R.ok(agentService.pageBinds(page, agentId, hotelId)); + } + + @Operation(summary = "佣金明细分页(管理端)") + @GetMapping("/commission/page") + @PreAuthorize("@pms.hasPermission('supermarket_smHotelFxCommission_view')") + public R> commissionPage(Page page, + SmHotelFxCommissionEntity query) { + return R.ok(commissionService.pageAdmin(page, query)); + } + + @SysLog("手动锁定到期分销佣金") + @Operation(summary = "手动执行退房T+1锁定") + @PostMapping("/commission/lockDue") + @PreAuthorize("@pms.hasPermission('supermarket_smHotelFx_edit')") + public R lockDue() { + return R.ok(commissionService.lockDueCommissions()); + } + + @Operation(summary = "一级申请分页") + @GetMapping("/l1/page") + @PreAuthorize("@pms.hasPermission('supermarket_smHotelFxL1_view')") + public R> l1Page(Page page, SmHotelFxL1ApplyEntity query) { + return R.ok(l1ApplyService.pageAdmin(page, query)); + } + + @SysLog("审核一级申请") + @Operation(summary = "审核一级申请") + @PostMapping("/l1/audit") + @PreAuthorize("@pms.hasPermission('supermarket_smHotelFxL1_audit')") + public R l1Audit(@RequestParam Long id, @RequestParam boolean approved, + @RequestParam(required = false) String auditRemark) { + return l1ApplyService.audit(id, approved, auditRemark); + } + + @Operation(summary = "提现分页(管理端)") + @GetMapping("/withdraw/page") + @PreAuthorize("@pms.hasPermission('supermarket_smHotelFxWithdraw_view')") + public R> withdrawPage(Page page, + SmHotelFxWithdrawEntity query) { + return R.ok(withdrawService.pageAdmin(page, query)); + } + + @SysLog("审核分销提现") + @Operation(summary = "审核提现") + @PostMapping("/withdraw/audit") + @PreAuthorize("@pms.hasPermission('supermarket_smHotelFxWithdraw_audit')") + public R withdrawAudit(@RequestParam Long id, @RequestParam boolean approved, + @RequestParam(required = false) String auditRemark) { + return withdrawService.audit(id, approved, auditRemark); + } + + @SysLog("分销提现打款") + @Operation(summary = "标记提现已打款") + @PostMapping("/withdraw/paid") + @PreAuthorize("@pms.hasPermission('supermarket_smHotelFxWithdraw_audit')") + public R withdrawPaid(@RequestParam Long id, @RequestParam(required = false) String remark) { + return withdrawService.markPaid(id, remark); + } + + @Operation(summary = "退出申请分页") + @GetMapping("/exit/page") + @PreAuthorize("@pms.hasPermission('supermarket_smHotelFxExit_view')") + public R> exitPage(Page page, SmHotelFxExitEntity query) { + return R.ok(exitService.pageAdmin(page, query)); + } + + @SysLog("审核分销退出") + @Operation(summary = "审核退出(通过须传 successorAgentId)") + @PostMapping("/exit/audit") + @PreAuthorize("@pms.hasPermission('supermarket_smHotelFxExit_audit')") + public R exitAudit(@RequestParam Long id, @RequestParam boolean approved, + @RequestParam(required = false) Long successorAgentId, + @RequestParam(required = false) String auditRemark) { + return exitService.audit(id, approved, successorAgentId, auditRemark); + } + + @Operation(summary = "我的佣金汇总(App)") + @GetMapping("/app/mySummary") + public R> mySummary() { + return commissionService.mySummary(); + } + + @Operation(summary = "我的佣金明细(App)") + @GetMapping("/app/myPage") + public R> myPage(Page page, + @RequestParam(required = false) String status) { + return R.ok(commissionService.pageMine(page, status)); + } + + @Operation(summary = "申请一级(App)") + @PostMapping("/app/l1/apply") + public R appL1Apply(@RequestParam String cityAdcode, + @RequestParam(required = false) String cityName, @RequestParam(required = false) String remark) { + return l1ApplyService.applyMine(cityAdcode, cityName, remark); + } + + @Operation(summary = "可提现汇总(App)") + @GetMapping("/app/withdraw/summary") + public R> appWithdrawSummary() { + return withdrawService.withdrawableMine(); + } + + @Operation(summary = "申请提现(App)") + @PostMapping("/app/withdraw/apply") + public R appWithdrawApply(@RequestParam String accountName, + @RequestParam String accountNo, @RequestParam(required = false) String bankName, + @RequestParam(required = false) String remark) { + return withdrawService.applyMine(accountName, accountNo, bankName, remark); + } + + @Operation(summary = "我的提现单(App)") + @GetMapping("/app/withdraw/page") + public R> appWithdrawPage(Page page, + @RequestParam(required = false) String status) { + return R.ok(withdrawService.pageMine(page, status)); + } + + @Operation(summary = "申请退出(App)") + @PostMapping("/app/exit/apply") + public R appExitApply(@RequestParam(required = false) String reason) { + return exitService.applyMine(reason); + } +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/job/SmHotelFxCommissionLockSchedule.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/job/SmHotelFxCommissionLockSchedule.java new file mode 100644 index 000000000..e809cb30c --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/job/SmHotelFxCommissionLockSchedule.java @@ -0,0 +1,43 @@ +package com.bsd.pjcloud.supermarket.job; + +import com.bsd.pjcloud.common.data.tenant.TenantBroker; +import com.bsd.pjcloud.supermarket.service.SmHotelFxCommissionService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * 退房 T+1 锁定预计佣金:默认每天 01:15。 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class SmHotelFxCommissionLockSchedule { + + private final SmHotelFxCommissionService commissionService; + + @Value("${sm-hotel.fx.lock-schedule-enabled:true}") + private boolean scheduleEnabled; + + @Value("${sm-hotel.fx.tenant-id:1}") + private Long tenantId; + + @Scheduled(cron = "${sm-hotel.fx.lock-cron:0 15 1 * * ?}") + public void lockDue() { + if (!scheduleEnabled) { + return; + } + Long tid = tenantId == null ? 1L : tenantId; + try { + int n = TenantBroker.applyAs(tid, t -> commissionService.lockDueCommissions()); + if (n > 0) { + log.info("|酒店分销|T+1锁定|tenant={}|count={}|", tid, n); + } + } + catch (Exception e) { + log.error("|酒店分销|T+1锁定异常|", e); + } + } +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/job/SmHotelFxDemoteSchedule.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/job/SmHotelFxDemoteSchedule.java new file mode 100644 index 000000000..4eb1ed072 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/job/SmHotelFxDemoteSchedule.java @@ -0,0 +1,43 @@ +package com.bsd.pjcloud.supermarket.job; + +import com.bsd.pjcloud.common.data.tenant.TenantBroker; +import com.bsd.pjcloud.supermarket.service.SmHotelFxAgentService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * 二级区域保级考核:窗口到期未达 demoteMin 则降三级;达标则滚动新窗口。默认每天 02:15。 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class SmHotelFxDemoteSchedule { + + private final SmHotelFxAgentService agentService; + + @Value("${sm-hotel.fx.demote-schedule-enabled:true}") + private boolean scheduleEnabled; + + @Value("${sm-hotel.fx.tenant-id:1}") + private Long tenantId; + + @Scheduled(cron = "${sm-hotel.fx.demote-cron:0 15 2 * * ?}") + public void processDemote() { + if (!scheduleEnabled) { + return; + } + Long tid = tenantId == null ? 1L : tenantId; + try { + int n = TenantBroker.applyAs(tid, t -> agentService.processDueDemotions()); + if (n > 0) { + log.info("|酒店分销|二级降级|tenant={}|count={}|", tid, n); + } + } + catch (Exception e) { + log.error("|酒店分销|二级降级任务异常|", e); + } + } +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelFxAgentMapper.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelFxAgentMapper.java new file mode 100644 index 000000000..1aeafbdd8 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelFxAgentMapper.java @@ -0,0 +1,9 @@ +package com.bsd.pjcloud.supermarket.mapper; + +import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; +import com.bsd.pjcloud.supermarket.entity.SmHotelFxAgentEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface SmHotelFxAgentMapper extends PjcloudBaseMapper { +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelFxCommissionMapper.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelFxCommissionMapper.java new file mode 100644 index 000000000..52caaf833 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelFxCommissionMapper.java @@ -0,0 +1,9 @@ +package com.bsd.pjcloud.supermarket.mapper; + +import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; +import com.bsd.pjcloud.supermarket.entity.SmHotelFxCommissionEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface SmHotelFxCommissionMapper extends PjcloudBaseMapper { +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelFxConfigMapper.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelFxConfigMapper.java new file mode 100644 index 000000000..94db657f8 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelFxConfigMapper.java @@ -0,0 +1,9 @@ +package com.bsd.pjcloud.supermarket.mapper; + +import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; +import com.bsd.pjcloud.supermarket.entity.SmHotelFxConfigEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface SmHotelFxConfigMapper extends PjcloudBaseMapper { +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelFxExitMapper.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelFxExitMapper.java new file mode 100644 index 000000000..5b390cdb0 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelFxExitMapper.java @@ -0,0 +1,9 @@ +package com.bsd.pjcloud.supermarket.mapper; + +import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; +import com.bsd.pjcloud.supermarket.entity.SmHotelFxExitEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface SmHotelFxExitMapper extends PjcloudBaseMapper { +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelFxHotelBindMapper.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelFxHotelBindMapper.java new file mode 100644 index 000000000..aba366373 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelFxHotelBindMapper.java @@ -0,0 +1,9 @@ +package com.bsd.pjcloud.supermarket.mapper; + +import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; +import com.bsd.pjcloud.supermarket.entity.SmHotelFxHotelBindEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface SmHotelFxHotelBindMapper extends PjcloudBaseMapper { +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelFxL1ApplyMapper.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelFxL1ApplyMapper.java new file mode 100644 index 000000000..18fab9149 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelFxL1ApplyMapper.java @@ -0,0 +1,9 @@ +package com.bsd.pjcloud.supermarket.mapper; + +import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; +import com.bsd.pjcloud.supermarket.entity.SmHotelFxL1ApplyEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface SmHotelFxL1ApplyMapper extends PjcloudBaseMapper { +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelFxWithdrawMapper.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelFxWithdrawMapper.java new file mode 100644 index 000000000..843359087 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelFxWithdrawMapper.java @@ -0,0 +1,9 @@ +package com.bsd.pjcloud.supermarket.mapper; + +import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; +import com.bsd.pjcloud.supermarket.entity.SmHotelFxWithdrawEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface SmHotelFxWithdrawMapper extends PjcloudBaseMapper { +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelFxAgentService.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelFxAgentService.java new file mode 100644 index 000000000..1b1748130 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelFxAgentService.java @@ -0,0 +1,40 @@ +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.SmHotelFxAgentEntity; +import com.bsd.pjcloud.supermarket.entity.SmHotelFxHotelBindEntity; + +import java.math.BigDecimal; + +public interface SmHotelFxAgentService extends IService { + + Page pageAgents(Page page, SmHotelFxAgentEntity query); + + R saveAgent(SmHotelFxAgentEntity body); + + /** 划调二级负责区域(district adcode 列表 JSON) */ + R saveRegion(Long agentId, String regionJson, String cityAdcode); + + R bindHotel(Long hotelId, Long agentId, String remark); + + R unbindHotel(Long hotelId); + + Page pageBinds(Page page, Long agentId, Long hotelId); + + SmHotelFxAgentEntity getByUserId(Long userId); + + SmHotelFxAgentEntity getBoundAgentByHotelId(Long hotelId); + + /** 锁定佣金后:累加终身晋升分,并尝试 4→3 / 3→2 自动晋升 */ + void onPromoteBaseLocked(Long ownerAgentId, BigDecimal promoteBaseAmount); + + /** + * 锁定佣金后:若开发员为二级,且酒店在其区域内、绑定时间≥窗口起算,则累加保级窗口分。 + */ + void onDemoteWindowEligible(Long ownerAgentId, Long hotelId, BigDecimal promoteBaseAmount); + + /** 扫描到期二级:窗口内未达 demoteMin 则降为三级 */ + int processDueDemotions(); +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelFxCommissionService.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelFxCommissionService.java new file mode 100644 index 000000000..60d917793 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelFxCommissionService.java @@ -0,0 +1,31 @@ +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.SmHotelFxCommissionEntity; +import com.bsd.pjcloud.supermarket.entity.SmHotelOrderEntity; + +import java.util.Map; + +/** + * 酒店分销佣金:下单记预计、退房 T+1 锁定、取消作废;金额按配置快照留存。 + */ +public interface SmHotelFxCommissionService extends IService { + + /** 订单创建/上游成功后生成预计佣金(幂等) */ + void createEstimatedForOrder(SmHotelOrderEntity order); + + /** 订单取消:预计佣金作废 */ + void cancelForOrder(Long orderId); + + /** 退房日次日锁定预计佣金 */ + int lockDueCommissions(); + + Page pageAdmin(Page page, SmHotelFxCommissionEntity query); + + /** App:当前登录用户作为分销员的佣金汇总 + 分页 */ + R> mySummary(); + + Page pageMine(Page page, String status); +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelFxConfigService.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelFxConfigService.java new file mode 100644 index 000000000..8d0647e63 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelFxConfigService.java @@ -0,0 +1,19 @@ +package com.bsd.pjcloud.supermarket.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.supermarket.dto.SmHotelFxLevelRuleDTO; +import com.bsd.pjcloud.supermarket.entity.SmHotelFxConfigEntity; + +import java.util.List; + +public interface SmHotelFxConfigService extends IService { + + SmHotelFxConfigEntity getOrCreateCurrent(); + + R saveCurrent(SmHotelFxConfigEntity body); + + List parseLevelRules(SmHotelFxConfigEntity cfg); + + boolean isEnabled(SmHotelFxConfigEntity cfg); +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelFxExitService.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelFxExitService.java new file mode 100644 index 000000000..0bcfa19e7 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelFxExitService.java @@ -0,0 +1,18 @@ +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.SmHotelFxExitEntity; + +public interface SmHotelFxExitService extends IService { + + Page pageAdmin(Page page, SmHotelFxExitEntity query); + + R applyMine(String reason); + + /** + * 审核退出:通过时必须指定 successorAgentId,重分配酒店绑定、下级挂靠,并作废预计佣金。 + */ + R audit(Long id, boolean approved, Long successorAgentId, String auditRemark); +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelFxL1ApplyService.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelFxL1ApplyService.java new file mode 100644 index 000000000..f03c87748 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelFxL1ApplyService.java @@ -0,0 +1,16 @@ +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.SmHotelFxL1ApplyEntity; + +public interface SmHotelFxL1ApplyService extends IService { + + Page pageAdmin(Page page, SmHotelFxL1ApplyEntity query); + + /** App:二级达标后申请城市合伙人 */ + R applyMine(String cityAdcode, String cityName, String remark); + + R audit(Long id, boolean approved, String auditRemark); +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelFxWithdrawService.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelFxWithdrawService.java new file mode 100644 index 000000000..89517ea7b --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelFxWithdrawService.java @@ -0,0 +1,25 @@ +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.SmHotelFxWithdrawEntity; + +import java.util.Map; + +public interface SmHotelFxWithdrawService extends IService { + + Page pageAdmin(Page page, SmHotelFxWithdrawEntity query); + + Page pageMine(Page page, String status); + + /** 可提现:已锁定且未挂提现单的佣金合计 */ + R> withdrawableMine(); + + R applyMine(String accountName, String accountNo, String bankName, String remark); + + R audit(Long id, boolean approved, String auditRemark); + + /** 已审通过后标记打款完成 */ + R markPaid(Long id, String remark); +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelFxAgentServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelFxAgentServiceImpl.java new file mode 100644 index 000000000..3eb67fd20 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelFxAgentServiceImpl.java @@ -0,0 +1,489 @@ +package com.bsd.pjcloud.supermarket.service.impl; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.IdUtil; +import cn.hutool.core.util.StrUtil; +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.supermarket.dto.SmHotelFxLevelRuleDTO; +import com.bsd.pjcloud.supermarket.entity.SmHotelEntity; +import com.bsd.pjcloud.supermarket.entity.SmHotelFxAgentEntity; +import com.bsd.pjcloud.supermarket.entity.SmHotelFxHotelBindEntity; +import com.bsd.pjcloud.supermarket.mapper.SmHotelFxAgentMapper; +import com.bsd.pjcloud.supermarket.mapper.SmHotelFxHotelBindMapper; +import com.bsd.pjcloud.supermarket.mapper.SmHotelMapper; +import com.bsd.pjcloud.supermarket.service.SmHotelFxAgentService; +import com.bsd.pjcloud.supermarket.service.SmHotelFxConfigService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SmHotelFxAgentServiceImpl extends ServiceImpl + implements SmHotelFxAgentService { + + private final SmHotelFxHotelBindMapper bindMapper; + + private final SmHotelMapper hotelMapper; + + private final SmHotelFxConfigService configService; + + @Override + public Page pageAgents(Page page, SmHotelFxAgentEntity query) { + Page result = page(page, Wrappers.lambdaQuery() + .eq(query.getLevelNo() != null, SmHotelFxAgentEntity::getLevelNo, query.getLevelNo()) + .eq(StrUtil.isNotBlank(query.getStatus()), SmHotelFxAgentEntity::getStatus, query.getStatus()) + .like(StrUtil.isNotBlank(query.getRealName()), SmHotelFxAgentEntity::getRealName, query.getRealName()) + .like(StrUtil.isNotBlank(query.getMobile()), SmHotelFxAgentEntity::getMobile, query.getMobile()) + .like(StrUtil.isNotBlank(query.getUsername()), SmHotelFxAgentEntity::getUsername, query.getUsername()) + .orderByAsc(SmHotelFxAgentEntity::getLevelNo) + .orderByDesc(SmHotelFxAgentEntity::getId)); + fillExtras(result.getRecords()); + return result; + } + + private void fillExtras(List list) { + if (list == null || list.isEmpty()) { + return; + } + List parentIds = list.stream().map(SmHotelFxAgentEntity::getParentId).filter(Objects::nonNull).distinct() + .toList(); + Map parentName = Map.of(); + if (!parentIds.isEmpty()) { + parentName = listByIds(parentIds).stream() + .collect(Collectors.toMap(SmHotelFxAgentEntity::getId, + a -> StrUtil.blankToDefault(a.getRealName(), a.getUsername()), (a, b) -> a)); + } + List ids = list.stream().map(SmHotelFxAgentEntity::getId).toList(); + List binds = bindMapper.selectList(Wrappers.lambdaQuery() + .in(SmHotelFxHotelBindEntity::getAgentId, ids) + .eq(SmHotelFxHotelBindEntity::getBindStatus, "BOUND")); + Map cnt = binds.stream() + .collect(Collectors.groupingBy(SmHotelFxHotelBindEntity::getAgentId, Collectors.counting())); + for (SmHotelFxAgentEntity a : list) { + a.setParentName(a.getParentId() == null ? null : parentName.get(a.getParentId())); + a.setHotelCount(cnt.getOrDefault(a.getId(), 0L).intValue()); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public R saveAgent(SmHotelFxAgentEntity body) { + if (body == null || body.getUserId() == null) { + return R.failed("请选择系统用户"); + } + int level = body.getLevelNo() == null ? 4 : body.getLevelNo(); + if (level < 1 || level > 5) { + return R.failed("层级须在 1~5"); + } + body.setLevelNo(level); + if (StrUtil.isBlank(body.getStatus())) { + body.setStatus("ACTIVE"); + } + if (body.getId() == null) { + Long exists = count(Wrappers.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)); + } + if (body.getPromoteScore() == null) { + body.setPromoteScore(BigDecimal.ZERO); + } + if (body.getDemoteWindowScore() == null) { + body.setDemoteWindowScore(BigDecimal.ZERO); + } + if (body.getParentId() != null && (body.getLevelNo() == null || body.getLevelNo() == 4)) { + SmHotelFxAgentEntity parent = getById(body.getParentId()); + if (parent != null && Integer.valueOf(4).equals(parent.getLevelNo())) { + body.setLevelNo(5); + } + } + save(body); + if (Integer.valueOf(2).equals(body.getLevelNo())) { + initLevel2DemoteWindow(body); + updateById(body); + } + } + else { + SmHotelFxAgentEntity old = getById(body.getId()); + if (old == null) { + return R.failed("分销员不存在"); + } + Integer prevLevel = old.getLevelNo(); + 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()); + // 人工调到二级:开启保级窗口;从二级调走:清窗口 + if (Integer.valueOf(2).equals(old.getLevelNo()) && !Integer.valueOf(2).equals(prevLevel)) { + initLevel2DemoteWindow(old); + } + else if (!Integer.valueOf(2).equals(old.getLevelNo()) && Integer.valueOf(2).equals(prevLevel)) { + clearDemoteWindow(old); + } + updateById(old); + body = old; + } + return R.ok(body); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public R saveRegion(Long agentId, String regionJson, String cityAdcode) { + if (agentId == null) { + return R.failed("分销员为空"); + } + SmHotelFxAgentEntity agent = getById(agentId); + if (agent == null) { + return R.failed("分销员不存在"); + } + if (StrUtil.isNotBlank(regionJson)) { + try { + List parsed = JSONUtil.parseArray(regionJson); + if (parsed == null) { + return R.failed("区域 JSON 无效,需为 adcode 数组"); + } + agent.setRegionJson(JSONUtil.toJsonStr(parsed)); + } + catch (Exception e) { + return R.failed("区域 JSON 无效,需为 adcode 数组,如 [\"330402\",\"330411\"]"); + } + } + else { + agent.setRegionJson(null); + } + if (cityAdcode != null) { + agent.setCityAdcode(StrUtil.blankToDefault(cityAdcode, null)); + } + updateById(agent); + return R.ok(agent); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public R bindHotel(Long hotelId, Long agentId, String remark) { + if (hotelId == null || agentId == null) { + return R.failed("酒店与分销员不能为空"); + } + SmHotelEntity hotel = hotelMapper.selectById(hotelId); + if (hotel == null) { + return R.failed("酒店不存在"); + } + SmHotelFxAgentEntity agent = getById(agentId); + if (agent == null || !"ACTIVE".equals(agent.getStatus())) { + return R.failed("分销员不可用"); + } + SmHotelFxHotelBindEntity exist = bindMapper.selectOne(Wrappers.lambdaQuery() + .eq(SmHotelFxHotelBindEntity::getHotelId, hotelId) + .last("limit 1")); + LocalDateTime now = LocalDateTime.now(); + if (exist == null) { + SmHotelFxHotelBindEntity row = new SmHotelFxHotelBindEntity(); + row.setHotelId(hotelId); + row.setAgentId(agentId); + row.setBindStatus("BOUND"); + row.setAuditRemark(remark); + row.setCreateTime(now); + bindMapper.insert(row); + } + 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); + } + return R.ok(Boolean.TRUE); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public R unbindHotel(Long hotelId) { + SmHotelFxHotelBindEntity exist = bindMapper.selectOne(Wrappers.lambdaQuery() + .eq(SmHotelFxHotelBindEntity::getHotelId, hotelId) + .last("limit 1")); + if (exist == null) { + return R.ok(Boolean.TRUE); + } + exist.setBindStatus("UNBOUND"); + bindMapper.updateById(exist); + return R.ok(Boolean.TRUE); + } + + @Override + public Page pageBinds(Page page, Long agentId, Long hotelId) { + Page result = bindMapper.selectPage(page, + Wrappers.lambdaQuery() + .eq(agentId != null, SmHotelFxHotelBindEntity::getAgentId, agentId) + .eq(hotelId != null, SmHotelFxHotelBindEntity::getHotelId, hotelId) + .eq(SmHotelFxHotelBindEntity::getBindStatus, "BOUND") + .orderByDesc(SmHotelFxHotelBindEntity::getId)); + for (SmHotelFxHotelBindEntity b : result.getRecords()) { + SmHotelEntity h = hotelMapper.selectById(b.getHotelId()); + if (h != null) { + b.setHotelName(h.getHotelName()); + b.setHotelCode(h.getHotelCode()); + } + SmHotelFxAgentEntity a = getById(b.getAgentId()); + if (a != null) { + b.setAgentName(StrUtil.blankToDefault(a.getRealName(), a.getUsername())); + } + } + return result; + } + + @Override + public SmHotelFxAgentEntity getByUserId(Long userId) { + if (userId == null) { + return null; + } + return getOne(Wrappers.lambdaQuery() + .eq(SmHotelFxAgentEntity::getUserId, userId) + .eq(SmHotelFxAgentEntity::getStatus, "ACTIVE") + .last("limit 1"), false); + } + + @Override + public SmHotelFxAgentEntity getBoundAgentByHotelId(Long hotelId) { + if (hotelId == null) { + return null; + } + SmHotelFxHotelBindEntity bind = bindMapper.selectOne(Wrappers.lambdaQuery() + .eq(SmHotelFxHotelBindEntity::getHotelId, hotelId) + .eq(SmHotelFxHotelBindEntity::getBindStatus, "BOUND") + .last("limit 1")); + if (bind == null) { + return null; + } + SmHotelFxAgentEntity agent = getById(bind.getAgentId()); + if (agent == null || !"ACTIVE".equals(agent.getStatus())) { + return null; + } + return agent; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void onPromoteBaseLocked(Long ownerAgentId, BigDecimal promoteBaseAmount) { + if (ownerAgentId == null || promoteBaseAmount == null + || promoteBaseAmount.compareTo(BigDecimal.ZERO) <= 0) { + return; + } + SmHotelFxAgentEntity agent = getById(ownerAgentId); + if (agent == null || !"ACTIVE".equals(agent.getStatus())) { + return; + } + BigDecimal score = agent.getPromoteScore() == null ? BigDecimal.ZERO : agent.getPromoteScore(); + agent.setPromoteScore(score.add(promoteBaseAmount)); + updateById(agent); + tryAutoPromote(agent); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void onDemoteWindowEligible(Long ownerAgentId, Long hotelId, BigDecimal promoteBaseAmount) { + if (ownerAgentId == null || hotelId == null || promoteBaseAmount == null + || promoteBaseAmount.compareTo(BigDecimal.ZERO) <= 0) { + return; + } + SmHotelFxAgentEntity agent = getById(ownerAgentId); + if (agent == null || !"ACTIVE".equals(agent.getStatus()) || !Integer.valueOf(2).equals(agent.getLevelNo())) { + return; + } + if (agent.getDemoteWindowStart() == null) { + initLevel2DemoteWindow(agent); + updateById(agent); + } + if (!isHotelInAgentRegion(agent, hotelId)) { + return; + } + if (!isNewHotelForDemoteWindow(agent, hotelId)) { + return; + } + BigDecimal win = agent.getDemoteWindowScore() == null ? BigDecimal.ZERO : agent.getDemoteWindowScore(); + agent.setDemoteWindowScore(win.add(promoteBaseAmount)); + updateById(agent); + log.info("|酒店分销|保级窗口累加|agentId={}|hotelId={}|add={}|total={}|", ownerAgentId, hotelId, + promoteBaseAmount, agent.getDemoteWindowScore()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public int processDueDemotions() { + SmHotelFxLevelRuleDTO rule2 = findLevelRule(2); + int windowDays = rule2 != null && rule2.getDemoteWindowDays() != null ? rule2.getDemoteWindowDays() : 90; + BigDecimal demoteMin = rule2 != null && rule2.getDemoteMin() != null ? rule2.getDemoteMin() + : new BigDecimal("40000"); + LocalDateTime deadline = LocalDateTime.now().minusDays(windowDays); + List due = list(Wrappers.lambdaQuery() + .eq(SmHotelFxAgentEntity::getLevelNo, 2) + .eq(SmHotelFxAgentEntity::getStatus, "ACTIVE") + .isNotNull(SmHotelFxAgentEntity::getDemoteWindowStart) + .le(SmHotelFxAgentEntity::getDemoteWindowStart, deadline) + .last("limit 500")); + if (CollUtil.isEmpty(due)) { + return 0; + } + int n = 0; + for (SmHotelFxAgentEntity agent : due) { + BigDecimal win = agent.getDemoteWindowScore() == null ? BigDecimal.ZERO : agent.getDemoteWindowScore(); + if (win.compareTo(demoteMin) >= 0) { + // 达标:滚动新窗口继续考核 + initLevel2DemoteWindow(agent); + updateById(agent); + log.info("|酒店分销|二级保级通过|agentId={}|score={}|min={}|", agent.getId(), win, demoteMin); + continue; + } + demoteLevel2To3(agent); + n++; + } + return n; + } + + /** 四级→三级、三级→二级自动升;二级→一级三期人工申请 */ + private void tryAutoPromote(SmHotelFxAgentEntity agent) { + if (agent == null || agent.getLevelNo() == null) { + return; + } + int lv = agent.getLevelNo(); + if (lv <= 2 || lv == 5) { + return; + } + List rules = configService.parseLevelRules(configService.getOrCreateCurrent()); + int target = lv - 1; + SmHotelFxLevelRuleDTO targetRule = rules.stream().filter(r -> Objects.equals(r.getLevel(), target)).findFirst() + .orElse(null); + if (targetRule == null || targetRule.getPromoteMin() == null) { + return; + } + if (Boolean.FALSE.equals(targetRule.getCanPromote()) && target == 1) { + return; + } + BigDecimal score = agent.getPromoteScore() == null ? BigDecimal.ZERO : agent.getPromoteScore(); + if (score.compareTo(targetRule.getPromoteMin()) < 0) { + return; + } + agent.setLevelNo(target); + if (target == 2) { + initLevel2DemoteWindow(agent); + } + updateById(agent); + log.info("|酒店分销|自动晋升|agentId={}|{}→{}|score={}|need={}|", agent.getId(), lv, target, score, + targetRule.getPromoteMin()); + // 连续达标可连升(如一次锁定后从 4 直接够 2 的门槛) + if (target > 2) { + tryAutoPromote(agent); + } + } + + private void demoteLevel2To3(SmHotelFxAgentEntity agent) { + BigDecimal win = agent.getDemoteWindowScore() == null ? BigDecimal.ZERO : agent.getDemoteWindowScore(); + agent.setLevelNo(3); + clearDemoteWindow(agent); + // 佣金明细/历史锁定业绩保留;晋升分回落到「三级门槛」,避免立刻因终身分再升回二级 + SmHotelFxLevelRuleDTO rule3 = findLevelRule(3); + BigDecimal floor = rule3 != null && rule3.getPromoteMin() != null ? rule3.getPromoteMin() + : new BigDecimal("50000"); + agent.setPromoteScore(floor); + String tip = "二级保级未达标降三级(窗口分=" + win.toPlainString() + ")"; + agent.setRemark(StrUtil.maxLength(StrUtil.blankToDefault(agent.getRemark(), "") + ";" + tip, 255)); + updateById(agent); + log.info("|酒店分销|二级降级|agentId={}|windowScore={}|promoteScore→{}|", agent.getId(), win, floor); + } + + private void initLevel2DemoteWindow(SmHotelFxAgentEntity agent) { + agent.setDemoteWindowStart(LocalDateTime.now()); + agent.setDemoteWindowScore(BigDecimal.ZERO); + } + + private void clearDemoteWindow(SmHotelFxAgentEntity agent) { + agent.setDemoteWindowStart(null); + agent.setDemoteWindowScore(BigDecimal.ZERO); + } + + /** 酒店区县 adcode 是否在分销员 regionJson 内;未配置区域则不计保级分 */ + private boolean isHotelInAgentRegion(SmHotelFxAgentEntity agent, Long hotelId) { + Set codes = parseRegionAdcodes(agent.getRegionJson()); + if (codes.isEmpty()) { + return false; + } + SmHotelEntity hotel = hotelMapper.selectById(hotelId); + if (hotel == null || hotel.getDistrictAdcode() == null) { + return false; + } + return codes.contains(String.valueOf(hotel.getDistrictAdcode())); + } + + /** 升二级之后新绑定/再审的酒店才计入保级窗口 */ + private boolean isNewHotelForDemoteWindow(SmHotelFxAgentEntity agent, Long hotelId) { + if (agent.getDemoteWindowStart() == null) { + return false; + } + SmHotelFxHotelBindEntity bind = bindMapper.selectOne(Wrappers.lambdaQuery() + .eq(SmHotelFxHotelBindEntity::getHotelId, hotelId) + .eq(SmHotelFxHotelBindEntity::getAgentId, agent.getId()) + .eq(SmHotelFxHotelBindEntity::getBindStatus, "BOUND") + .last("limit 1")); + if (bind == null || bind.getCreateTime() == null) { + return false; + } + return !bind.getCreateTime().isBefore(agent.getDemoteWindowStart()); + } + + private Set parseRegionAdcodes(String regionJson) { + Set set = new HashSet<>(); + if (StrUtil.isBlank(regionJson)) { + return set; + } + try { + List arr = new ArrayList<>(JSONUtil.parseArray(regionJson)); + for (Object o : arr) { + if (o == null) { + continue; + } + String s = String.valueOf(o).trim(); + if (StrUtil.isNotBlank(s)) { + set.add(s); + } + } + } + catch (Exception ignored) { + // ignore bad json + } + return set; + } + + private SmHotelFxLevelRuleDTO findLevelRule(int level) { + List rules = configService.parseLevelRules(configService.getOrCreateCurrent()); + return rules.stream().filter(r -> Objects.equals(r.getLevel(), level)).findFirst().orElse(null); + } +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelFxCommissionServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelFxCommissionServiceImpl.java new file mode 100644 index 000000000..c580f8a94 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelFxCommissionServiceImpl.java @@ -0,0 +1,360 @@ +package com.bsd.pjcloud.supermarket.service.impl; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +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.dto.SmHotelFxLevelRuleDTO; +import com.bsd.pjcloud.supermarket.entity.SmHotelEntity; +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.SmHotelOrderEntity; +import com.bsd.pjcloud.supermarket.mapper.SmHotelFxCommissionMapper; +import com.bsd.pjcloud.supermarket.mapper.SmHotelMapper; +import com.bsd.pjcloud.supermarket.mapper.SmHotelOrderMapper; +import com.bsd.pjcloud.supermarket.service.SmHotelFxAgentService; +import com.bsd.pjcloud.supermarket.service.SmHotelFxCommissionService; +import com.bsd.pjcloud.supermarket.service.SmHotelFxConfigService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SmHotelFxCommissionServiceImpl extends ServiceImpl + implements SmHotelFxCommissionService { + + private static final String ST_EST = "ESTIMATED"; + + private static final String ST_LOCK = "LOCKED"; + + private static final String ST_CANCEL = "CANCELLED"; + + private final SmHotelFxConfigService configService; + + private final SmHotelFxAgentService agentService; + + private final SmHotelMapper hotelMapper; + + private final SmHotelOrderMapper orderMapper; + + @Override + @Transactional(rollbackFor = Exception.class) + public void createEstimatedForOrder(SmHotelOrderEntity order) { + if (order == null || order.getId() == null || order.getHotelId() == null) { + return; + } + SmHotelFxConfigEntity cfg = configService.getOrCreateCurrent(); + if (!configService.isEnabled(cfg)) { + return; + } + long exists = count(Wrappers.lambdaQuery() + .eq(SmHotelFxCommissionEntity::getOrderId, order.getId()) + .ne(SmHotelFxCommissionEntity::getStatus, ST_CANCEL)); + if (exists > 0) { + return; + } + SmHotelFxAgentEntity owner = agentService.getBoundAgentByHotelId(order.getHotelId()); + if (owner == null) { + return; + } + BigDecimal cost = order.getCostAmount(); + if (cost == null || cost.compareTo(BigDecimal.ZERO) <= 0) { + log.info("|酒店分销|跳过无成本订单|orderId={}|", order.getId()); + return; + } + BigDecimal poolCostRate = nvl(cfg.getPoolCostRate(), "0.06"); + BigDecimal poolSalesRate = nvl(cfg.getPoolSalesRate(), "0.30"); + BigDecimal promoteBaseRate = nvl(cfg.getPromoteBaseRate(), "0.06"); + BigDecimal pool = cost.multiply(poolCostRate).multiply(poolSalesRate).setScale(4, RoundingMode.HALF_UP); + BigDecimal promoteBase = cost.multiply(promoteBaseRate).setScale(4, RoundingMode.HALF_UP); + + List rules = configService.parseLevelRules(cfg); + Map ruleByLevel = rules.stream() + .filter(r -> r.getLevel() != null) + .collect(Collectors.toMap(SmHotelFxLevelRuleDTO::getLevel, r -> r, (a, b) -> a)); + + // 开发员向上链路:每人只拿「自身当前层级」对应份额(缺层则该份额不发) + List chain = buildUplineChain(owner); + Map snapshot = buildSnapshot(cfg, rules, cost, pool, promoteBase, owner.getId()); + + List 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) { + continue; + } + BigDecimal amount = pool.multiply(rule.getShareOfPool()).setScale(4, RoundingMode.HALF_UP); + if (amount.compareTo(BigDecimal.ZERO) <= 0) { + continue; + } + SmHotelFxCommissionEntity row = new SmHotelFxCommissionEntity(); + row.setOrderId(order.getId()); + row.setHotelId(order.getHotelId()); + row.setAgentId(agent.getId()); + row.setOwnerAgentId(owner.getId()); + row.setChainLevel(lv); + row.setAgentLevel(lv); + row.setCostAmount(cost); + row.setPoolAmount(pool); + row.setShareRate(rule.getShareOfPool()); + row.setCommissionAmount(amount); + row.setPromoteBaseAmount(promoteBase); + row.setStatus(ST_EST); + row.setCheckOutDate(order.getCheckOutDate()); + row.setConfigSnapshot(JSONUtil.toJsonStr(snapshot)); + row.setRemark("预计佣金"); + rows.add(row); + } + if (rows.isEmpty()) { + return; + } + saveBatch(rows); + log.info("|酒店分销|已记预计佣金|orderId={}|hotelId={}|lines={}|pool={}|", order.getId(), order.getHotelId(), + rows.size(), pool); + } + + /** 从开发员沿 parent 向上,同层级只保留最先遇到的人 */ + private List buildUplineChain(SmHotelFxAgentEntity owner) { + List chain = new ArrayList<>(); + Map seenLevel = new HashMap<>(); + SmHotelFxAgentEntity cur = owner; + int guard = 0; + while (cur != null && guard++ < 16) { + Integer lv = cur.getLevelNo(); + if (lv != null && !Boolean.TRUE.equals(seenLevel.get(lv))) { + seenLevel.put(lv, true); + chain.add(cur); + } + if (cur.getParentId() == null) { + break; + } + cur = agentService.getById(cur.getParentId()); + if (cur == null || !"ACTIVE".equals(cur.getStatus())) { + break; + } + } + return chain; + } + + private Map buildSnapshot(SmHotelFxConfigEntity cfg, List rules, + BigDecimal cost, BigDecimal pool, BigDecimal promoteBase, Long ownerAgentId) { + Map m = new LinkedHashMap<>(); + m.put("poolCostRate", cfg.getPoolCostRate()); + m.put("poolSalesRate", cfg.getPoolSalesRate()); + m.put("promoteBaseRate", cfg.getPromoteBaseRate()); + m.put("levelRules", rules); + m.put("costAmount", cost); + m.put("poolAmount", pool); + m.put("promoteBaseAmount", promoteBase); + m.put("ownerAgentId", ownerAgentId); + m.put("formula", "pool = cost * poolCostRate * poolSalesRate; commission = pool * shareOfPool"); + return m; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void cancelForOrder(Long orderId) { + if (orderId == null) { + return; + } + List list = list(Wrappers.lambdaQuery() + .eq(SmHotelFxCommissionEntity::getOrderId, orderId) + .eq(SmHotelFxCommissionEntity::getStatus, ST_EST)); + if (CollUtil.isEmpty(list)) { + return; + } + LocalDateTime now = LocalDateTime.now(); + for (SmHotelFxCommissionEntity row : list) { + row.setStatus(ST_CANCEL); + row.setRemark("订单取消作废"); + row.setUpdateTime(now); + } + updateBatchById(list); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public int lockDueCommissions() { + // 退房日次日 00:00 起可锁:check_out_date < today + LocalDate today = LocalDate.now(); + List due = list(Wrappers.lambdaQuery() + .eq(SmHotelFxCommissionEntity::getStatus, ST_EST) + .isNotNull(SmHotelFxCommissionEntity::getCheckOutDate) + .lt(SmHotelFxCommissionEntity::getCheckOutDate, today) + .last("limit 2000")); + if (CollUtil.isEmpty(due)) { + return 0; + } + LocalDateTime now = LocalDateTime.now(); + // owner 晋升分按人合并;保级窗口按「每笔×酒店」逐条判断区域/新酒店 + Map promoteAdd = new HashMap<>(); + List ownerRows = new ArrayList<>(); + for (SmHotelFxCommissionEntity row : due) { + row.setStatus(ST_LOCK); + row.setLockTime(now); + row.setRemark("退房T+1锁定"); + if (Objects.equals(row.getAgentId(), row.getOwnerAgentId()) && row.getPromoteBaseAmount() != null) { + promoteAdd.merge(row.getOwnerAgentId(), row.getPromoteBaseAmount(), BigDecimal::add); + ownerRows.add(row); + } + } + updateBatchById(due); + for (SmHotelFxCommissionEntity row : ownerRows) { + agentService.onDemoteWindowEligible(row.getOwnerAgentId(), row.getHotelId(), row.getPromoteBaseAmount()); + } + for (Map.Entry e : promoteAdd.entrySet()) { + agentService.onPromoteBaseLocked(e.getKey(), e.getValue()); + } + return due.size(); + } + + @Override + public Page pageAdmin(Page page, + SmHotelFxCommissionEntity query) { + Page result = page(page, Wrappers.lambdaQuery() + .eq(query.getAgentId() != null, SmHotelFxCommissionEntity::getAgentId, query.getAgentId()) + .eq(query.getHotelId() != null, SmHotelFxCommissionEntity::getHotelId, query.getHotelId()) + .eq(query.getOrderId() != null, SmHotelFxCommissionEntity::getOrderId, query.getOrderId()) + .eq(StrUtil.isNotBlank(query.getStatus()), SmHotelFxCommissionEntity::getStatus, query.getStatus()) + .orderByDesc(SmHotelFxCommissionEntity::getId)); + fillDisplay(result.getRecords()); + return result; + } + + @Override + public R> mySummary() { + SmHotelFxAgentEntity me = currentAgent(); + if (me == null) { + return R.failed("当前账号不是分销员"); + } + List all = list(Wrappers.lambdaQuery() + .eq(SmHotelFxCommissionEntity::getAgentId, me.getId()) + .ne(SmHotelFxCommissionEntity::getStatus, ST_CANCEL)); + BigDecimal estimated = BigDecimal.ZERO; + BigDecimal locked = BigDecimal.ZERO; + BigDecimal withdrawn = BigDecimal.ZERO; + BigDecimal withdrawable = BigDecimal.ZERO; + for (SmHotelFxCommissionEntity c : all) { + 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())) { + locked = locked.add(amt); + if (c.getWithdrawId() == null) { + withdrawable = withdrawable.add(amt); + } + } + else if ("WITHDRAWN".equals(c.getStatus())) { + withdrawn = withdrawn.add(amt); + } + } + Map data = new LinkedHashMap<>(); + data.put("agentId", me.getId()); + data.put("levelNo", me.getLevelNo()); + data.put("realName", me.getRealName()); + data.put("promoteScore", me.getPromoteScore()); + data.put("cityAdcode", me.getCityAdcode()); + data.put("demoteWindowScore", me.getDemoteWindowScore()); + data.put("demoteWindowStart", me.getDemoteWindowStart()); + data.put("regionJson", me.getRegionJson()); + data.put("estimatedAmount", estimated); + data.put("lockedAmount", locked); + data.put("withdrawableAmount", withdrawable); + data.put("withdrawnAmount", withdrawn); + data.put("totalAmount", estimated.add(locked)); + List rules = configService.parseLevelRules(configService.getOrCreateCurrent()); + 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); + } + else { + data.put("canApplyL1", false); + } + return R.ok(data); + } + + @Override + public Page pageMine(Page page, String status) { + SmHotelFxAgentEntity me = currentAgent(); + if (me == null) { + return page; + } + Page result = page(page, Wrappers.lambdaQuery() + .eq(SmHotelFxCommissionEntity::getAgentId, me.getId()) + .eq(StrUtil.isNotBlank(status), SmHotelFxCommissionEntity::getStatus, status) + .ne(SmHotelFxCommissionEntity::getStatus, ST_CANCEL) + .orderByDesc(SmHotelFxCommissionEntity::getId)); + fillDisplay(result.getRecords()); + return result; + } + + private SmHotelFxAgentEntity currentAgent() { + PjcloudUser user = SecurityUtils.getUser(); + if (user == null || user.getId() == null) { + return null; + } + return agentService.getByUserId(user.getId()); + } + + private void fillDisplay(List list) { + if (CollUtil.isEmpty(list)) { + return; + } + for (SmHotelFxCommissionEntity c : list) { + if (c.getHotelId() != null) { + SmHotelEntity h = hotelMapper.selectById(c.getHotelId()); + if (h != null) { + c.setHotelName(h.getHotelName()); + } + } + if (c.getAgentId() != null) { + SmHotelFxAgentEntity a = agentService.getById(c.getAgentId()); + if (a != null) { + c.setAgentName(StrUtil.blankToDefault(a.getRealName(), a.getUsername())); + } + } + if (c.getOrderId() != null) { + SmHotelOrderEntity o = orderMapper.selectById(c.getOrderId()); + if (o != null) { + c.setChannelNo(o.getChannelNo()); + } + } + } + } + + private static BigDecimal nvl(BigDecimal v, String def) { + return v == null ? new BigDecimal(def) : v; + } +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelFxConfigServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelFxConfigServiceImpl.java new file mode 100644 index 000000000..fa3f08082 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelFxConfigServiceImpl.java @@ -0,0 +1,105 @@ +package com.bsd.pjcloud.supermarket.service.impl; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.json.JSONUtil; +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.dto.SmHotelFxLevelRuleDTO; +import com.bsd.pjcloud.supermarket.entity.SmHotelFxConfigEntity; +import com.bsd.pjcloud.supermarket.mapper.SmHotelFxConfigMapper; +import com.bsd.pjcloud.supermarket.service.SmHotelFxConfigService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +@Service +public class SmHotelFxConfigServiceImpl extends ServiceImpl + implements SmHotelFxConfigService { + + private static final String DEFAULT_RULES = "[" + + "{\"level\":1,\"name\":\"城市合伙人\",\"shareOfPool\":0.40,\"canPromote\":false,\"promoteMin\":500000,\"demoteMin\":null,\"demoteWindowDays\":null}," + + "{\"level\":2,\"name\":\"区域负责人\",\"shareOfPool\":0.30,\"canPromote\":true,\"promoteMin\":200000,\"demoteMin\":40000,\"demoteWindowDays\":90}," + + "{\"level\":3,\"name\":\"团队骨干\",\"shareOfPool\":0.20,\"canPromote\":true,\"promoteMin\":50000,\"demoteMin\":null,\"demoteWindowDays\":null}," + + "{\"level\":4,\"name\":\"开发业务员\",\"shareOfPool\":0.10,\"canPromote\":true,\"promoteMin\":null,\"demoteMin\":null,\"demoteWindowDays\":null}," + + "{\"level\":5,\"name\":\"被邀请人\",\"shareOfPool\":0.00,\"canPromote\":false,\"promoteMin\":null,\"demoteMin\":null,\"demoteWindowDays\":null}" + + "]"; + + @Override + public SmHotelFxConfigEntity getOrCreateCurrent() { + SmHotelFxConfigEntity one = getOne(Wrappers.lambdaQuery().last("limit 1"), false); + if (one != null) { + return one; + } + SmHotelFxConfigEntity created = new SmHotelFxConfigEntity(); + created.setEnabled("1"); + created.setPoolCostRate(new BigDecimal("0.06")); + created.setPoolSalesRate(new BigDecimal("0.30")); + created.setPromoteBaseRate(new BigDecimal("0.06")); + created.setLevelRulesJson(DEFAULT_RULES); + created.setRemark("系统自动初始化"); + save(created); + return created; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public R saveCurrent(SmHotelFxConfigEntity body) { + if (body == null) { + return R.failed("参数为空"); + } + SmHotelFxConfigEntity cur = getOrCreateCurrent(); + if (body.getEnabled() != null) { + cur.setEnabled(body.getEnabled()); + } + if (body.getPoolCostRate() != null) { + cur.setPoolCostRate(body.getPoolCostRate()); + } + if (body.getPoolSalesRate() != null) { + cur.setPoolSalesRate(body.getPoolSalesRate()); + } + if (body.getPromoteBaseRate() != null) { + cur.setPromoteBaseRate(body.getPromoteBaseRate()); + } + if (body.getLevelRulesJson() != null) { + List rules = parseLevelRules(body); + if (rules.isEmpty()) { + return R.failed("层级规则 JSON 无效"); + } + cur.setLevelRulesJson(JSONUtil.toJsonStr(rules)); + } + if (body.getRemark() != null) { + cur.setRemark(body.getRemark()); + } + updateById(cur); + return R.ok(cur); + } + + @Override + public List parseLevelRules(SmHotelFxConfigEntity cfg) { + String json = cfg == null ? null : cfg.getLevelRulesJson(); + if (StrUtil.isBlank(json)) { + json = DEFAULT_RULES; + } + try { + List list = JSONUtil.toList(json, SmHotelFxLevelRuleDTO.class); + if (list == null) { + return new ArrayList<>(); + } + list.sort(Comparator.comparing(r -> r.getLevel() == null ? 99 : r.getLevel())); + return list; + } + catch (Exception e) { + return new ArrayList<>(); + } + } + + @Override + public boolean isEnabled(SmHotelFxConfigEntity cfg) { + return cfg != null && "1".equals(cfg.getEnabled()); + } +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelFxExitServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelFxExitServiceImpl.java new file mode 100644 index 000000000..614dd134a --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelFxExitServiceImpl.java @@ -0,0 +1,172 @@ +package com.bsd.pjcloud.supermarket.service.impl; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.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.SmHotelFxAgentEntity; +import com.bsd.pjcloud.supermarket.entity.SmHotelFxCommissionEntity; +import com.bsd.pjcloud.supermarket.entity.SmHotelFxExitEntity; +import com.bsd.pjcloud.supermarket.entity.SmHotelFxHotelBindEntity; +import com.bsd.pjcloud.supermarket.mapper.SmHotelFxCommissionMapper; +import com.bsd.pjcloud.supermarket.mapper.SmHotelFxExitMapper; +import com.bsd.pjcloud.supermarket.mapper.SmHotelFxHotelBindMapper; +import com.bsd.pjcloud.supermarket.service.SmHotelFxAgentService; +import com.bsd.pjcloud.supermarket.service.SmHotelFxExitService; +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 SmHotelFxExitServiceImpl extends ServiceImpl + implements SmHotelFxExitService { + + private final SmHotelFxAgentService agentService; + + private final SmHotelFxHotelBindMapper bindMapper; + + private final SmHotelFxCommissionMapper commissionMapper; + + @Override + public Page pageAdmin(Page page, SmHotelFxExitEntity query) { + Page result = page(page, Wrappers.lambdaQuery() + .eq(query.getAgentId() != null, SmHotelFxExitEntity::getAgentId, query.getAgentId()) + .eq(StrUtil.isNotBlank(query.getStatus()), SmHotelFxExitEntity::getStatus, query.getStatus()) + .orderByDesc(SmHotelFxExitEntity::getId)); + fillNames(result.getRecords()); + return result; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public R applyMine(String reason) { + PjcloudUser user = SecurityUtils.getUser(); + if (user == null || user.getId() == null) { + return R.failed("未登录"); + } + SmHotelFxAgentEntity me = agentService.getByUserId(user.getId()); + if (me == null || !"ACTIVE".equals(me.getStatus())) { + return R.failed("当前不是在职分销员"); + } + long pending = count(Wrappers.lambdaQuery() + .eq(SmHotelFxExitEntity::getAgentId, me.getId()) + .eq(SmHotelFxExitEntity::getStatus, "PENDING")); + if (pending > 0) { + return R.failed("已有待审退出申请"); + } + SmHotelFxExitEntity row = new SmHotelFxExitEntity(); + row.setAgentId(me.getId()); + row.setUserId(me.getUserId()); + row.setReason(StrUtil.blankToDefault(reason, "主动申请退出")); + row.setStatus("PENDING"); + save(row); + return R.ok(row); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public R audit(Long id, boolean approved, Long successorAgentId, String auditRemark) { + SmHotelFxExitEntity 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(auditRemark); + if (!approved) { + row.setStatus("REJECTED"); + updateById(row); + return R.ok(Boolean.TRUE, "已驳回退出申请"); + } + if (successorAgentId == null) { + return R.failed("通过退出须指定酒店接收分销员"); + } + if (successorAgentId.equals(row.getAgentId())) { + return R.failed("接收人不能是本人"); + } + SmHotelFxAgentEntity successor = agentService.getById(successorAgentId); + if (successor == null || !"ACTIVE".equals(successor.getStatus())) { + return R.failed("接收分销员不可用"); + } + SmHotelFxAgentEntity agent = agentService.getById(row.getAgentId()); + if (agent == null) { + return R.failed("退出分销员不存在"); + } + + // 1) 酒店绑定重分配 + List binds = bindMapper.selectList(Wrappers.lambdaQuery() + .eq(SmHotelFxHotelBindEntity::getAgentId, agent.getId()) + .eq(SmHotelFxHotelBindEntity::getBindStatus, "BOUND")); + LocalDateTime now = LocalDateTime.now(); + for (SmHotelFxHotelBindEntity b : binds) { + b.setAgentId(successorAgentId); + b.setCreateTime(now); + b.setAuditRemark(StrUtil.maxLength("退出重分配自 agent=" + agent.getId(), 255)); + bindMapper.updateById(b); + } + + // 2) 下级挂靠到原上级(或接收人上级链之外:挂到退出者的 parent) + Long newParent = agent.getParentId(); + List children = agentService.list(Wrappers.lambdaQuery() + .eq(SmHotelFxAgentEntity::getParentId, agent.getId()) + .eq(SmHotelFxAgentEntity::getStatus, "ACTIVE")); + for (SmHotelFxAgentEntity child : children) { + child.setParentId(newParent); + agentService.updateById(child); + } + + // 3) 作废本人预计佣金(已锁定可继续提现) + List est = commissionMapper.selectList(Wrappers.lambdaQuery() + .eq(SmHotelFxCommissionEntity::getAgentId, agent.getId()) + .eq(SmHotelFxCommissionEntity::getStatus, "ESTIMATED")); + for (SmHotelFxCommissionEntity c : est) { + c.setStatus("CANCELLED"); + c.setRemark("分销员退出作废预计佣金"); + commissionMapper.updateById(c); + } + + agent.setStatus("EXIT"); + agent.setRemark(StrUtil.maxLength(StrUtil.blankToDefault(agent.getRemark(), "") + ";已退出", 255)); + agentService.updateById(agent); + + row.setSuccessorAgentId(successorAgentId); + row.setHotelCount(binds.size()); + row.setStatus("DONE"); + updateById(row); + log.info("|酒店分销|退出完成|agentId={}|successor={}|hotels={}|children={}|", agent.getId(), successorAgentId, + binds.size(), children.size()); + return R.ok(Boolean.TRUE, "已退出并重分配 " + binds.size() + " 家酒店"); + } + + private void fillNames(List list) { + if (CollUtil.isEmpty(list)) { + return; + } + for (SmHotelFxExitEntity row : list) { + SmHotelFxAgentEntity a = agentService.getById(row.getAgentId()); + if (a != null) { + row.setAgentName(StrUtil.blankToDefault(a.getRealName(), a.getUsername())); + } + if (row.getSuccessorAgentId() != null) { + SmHotelFxAgentEntity s = agentService.getById(row.getSuccessorAgentId()); + if (s != null) { + row.setSuccessorName(StrUtil.blankToDefault(s.getRealName(), s.getUsername())); + } + } + } + } +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelFxL1ApplyServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelFxL1ApplyServiceImpl.java new file mode 100644 index 000000000..a9c217b7c --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelFxL1ApplyServiceImpl.java @@ -0,0 +1,163 @@ +package com.bsd.pjcloud.supermarket.service.impl; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.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.dto.SmHotelFxLevelRuleDTO; +import com.bsd.pjcloud.supermarket.entity.SmHotelFxAgentEntity; +import com.bsd.pjcloud.supermarket.entity.SmHotelFxL1ApplyEntity; +import com.bsd.pjcloud.supermarket.mapper.SmHotelFxL1ApplyMapper; +import com.bsd.pjcloud.supermarket.service.SmHotelFxAgentService; +import com.bsd.pjcloud.supermarket.service.SmHotelFxConfigService; +import com.bsd.pjcloud.supermarket.service.SmHotelFxL1ApplyService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Objects; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SmHotelFxL1ApplyServiceImpl extends ServiceImpl + implements SmHotelFxL1ApplyService { + + private final SmHotelFxAgentService agentService; + + private final SmHotelFxConfigService configService; + + @Override + public Page pageAdmin(Page page, SmHotelFxL1ApplyEntity query) { + Page result = page(page, Wrappers.lambdaQuery() + .eq(query.getAgentId() != null, SmHotelFxL1ApplyEntity::getAgentId, query.getAgentId()) + .eq(StrUtil.isNotBlank(query.getStatus()), SmHotelFxL1ApplyEntity::getStatus, query.getStatus()) + .eq(StrUtil.isNotBlank(query.getCityAdcode()), SmHotelFxL1ApplyEntity::getCityAdcode, query.getCityAdcode()) + .orderByDesc(SmHotelFxL1ApplyEntity::getId)); + fillAgent(result.getRecords()); + return result; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public R applyMine(String cityAdcode, String cityName, String remark) { + SmHotelFxAgentEntity me = requireActiveAgent(); + if (me == null) { + return R.failed("当前账号不是在职分销员"); + } + if (!Integer.valueOf(2).equals(me.getLevelNo())) { + return R.failed("仅二级可申请城市合伙人"); + } + if (StrUtil.isBlank(cityAdcode)) { + return R.failed("请填写申请城市 adcode"); + } + BigDecimal need = level1PromoteMin(); + BigDecimal score = me.getPromoteScore() == null ? BigDecimal.ZERO : me.getPromoteScore(); + if (score.compareTo(need) < 0) { + return R.failed("晋升累计未达门槛(需≥" + need.toPlainString() + ")"); + } + long pending = count(Wrappers.lambdaQuery() + .eq(SmHotelFxL1ApplyEntity::getAgentId, me.getId()) + .eq(SmHotelFxL1ApplyEntity::getStatus, "PENDING")); + if (pending > 0) { + return R.failed("已有待审核申请,请勿重复提交"); + } + SmHotelFxL1ApplyEntity row = new SmHotelFxL1ApplyEntity(); + row.setAgentId(me.getId()); + row.setUserId(me.getUserId()); + row.setCityAdcode(cityAdcode.trim()); + row.setCityName(cityName); + row.setPromoteScore(score); + row.setStatus("PENDING"); + row.setApplyRemark(remark); + save(row); + return R.ok(row); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public R audit(Long id, boolean approved, String auditRemark) { + SmHotelFxL1ApplyEntity 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(auditRemark); + if (!approved) { + row.setStatus("REJECTED"); + updateById(row); + return R.ok(Boolean.TRUE, "已驳回"); + } + SmHotelFxAgentEntity agent = agentService.getById(row.getAgentId()); + if (agent == null || !"ACTIVE".equals(agent.getStatus())) { + return R.failed("申请人状态不可用"); + } + if (!Integer.valueOf(2).equals(agent.getLevelNo())) { + return R.failed("申请人当前已非二级,无法任命"); + } + // 一城一人:原一级降为二级并开保级窗口 + List cityL1 = agentService.list(Wrappers.lambdaQuery() + .eq(SmHotelFxAgentEntity::getLevelNo, 1) + .eq(SmHotelFxAgentEntity::getStatus, "ACTIVE") + .eq(SmHotelFxAgentEntity::getCityAdcode, row.getCityAdcode()) + .ne(SmHotelFxAgentEntity::getId, agent.getId())); + for (SmHotelFxAgentEntity old : cityL1) { + old.setLevelNo(2); + old.setDemoteWindowStart(LocalDateTime.now()); + old.setDemoteWindowScore(BigDecimal.ZERO); + old.setRemark(StrUtil.maxLength( + StrUtil.blankToDefault(old.getRemark(), "") + ";被新城市合伙人替换降二级", 255)); + agentService.updateById(old); + log.info("|酒店分销|一级替换降二级|oldAgentId={}|city={}|", old.getId(), row.getCityAdcode()); + } + agent.setLevelNo(1); + agent.setCityAdcode(row.getCityAdcode()); + agent.setDemoteWindowStart(null); + agent.setDemoteWindowScore(BigDecimal.ZERO); + agentService.updateById(agent); + row.setStatus("APPROVED"); + updateById(row); + log.info("|酒店分销|任命一级|agentId={}|city={}|", agent.getId(), row.getCityAdcode()); + return R.ok(Boolean.TRUE, "已任命为城市合伙人"); + } + + private BigDecimal level1PromoteMin() { + List rules = configService.parseLevelRules(configService.getOrCreateCurrent()); + SmHotelFxLevelRuleDTO r1 = rules.stream().filter(r -> Objects.equals(r.getLevel(), 1)).findFirst().orElse(null); + return r1 != null && r1.getPromoteMin() != null ? r1.getPromoteMin() : new BigDecimal("500000"); + } + + private SmHotelFxAgentEntity requireActiveAgent() { + PjcloudUser user = SecurityUtils.getUser(); + if (user == null || user.getId() == null) { + return null; + } + return agentService.getByUserId(user.getId()); + } + + private void fillAgent(List list) { + if (CollUtil.isEmpty(list)) { + return; + } + for (SmHotelFxL1ApplyEntity row : list) { + SmHotelFxAgentEntity a = agentService.getById(row.getAgentId()); + if (a != null) { + row.setAgentName(StrUtil.blankToDefault(a.getRealName(), a.getUsername())); + row.setAgentLevel(a.getLevelNo()); + } + } + } +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelFxWithdrawServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelFxWithdrawServiceImpl.java new file mode 100644 index 000000000..84cd193ff --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelFxWithdrawServiceImpl.java @@ -0,0 +1,216 @@ +package com.bsd.pjcloud.supermarket.service.impl; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.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.SmHotelFxAgentEntity; +import com.bsd.pjcloud.supermarket.entity.SmHotelFxCommissionEntity; +import com.bsd.pjcloud.supermarket.entity.SmHotelFxWithdrawEntity; +import com.bsd.pjcloud.supermarket.mapper.SmHotelFxCommissionMapper; +import com.bsd.pjcloud.supermarket.mapper.SmHotelFxWithdrawMapper; +import com.bsd.pjcloud.supermarket.service.SmHotelFxAgentService; +import com.bsd.pjcloud.supermarket.service.SmHotelFxWithdrawService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SmHotelFxWithdrawServiceImpl extends ServiceImpl + implements SmHotelFxWithdrawService { + + private static final String ST_LOCK = "LOCKED"; + + private static final String ST_WITHDRAWN = "WITHDRAWN"; + + private final SmHotelFxAgentService agentService; + + private final SmHotelFxCommissionMapper commissionMapper; + + @Override + public Page pageAdmin(Page page, SmHotelFxWithdrawEntity query) { + Page result = page(page, Wrappers.lambdaQuery() + .eq(query.getAgentId() != null, SmHotelFxWithdrawEntity::getAgentId, query.getAgentId()) + .eq(StrUtil.isNotBlank(query.getStatus()), SmHotelFxWithdrawEntity::getStatus, query.getStatus()) + .orderByDesc(SmHotelFxWithdrawEntity::getId)); + fillAgent(result.getRecords()); + return result; + } + + @Override + public Page pageMine(Page page, String status) { + SmHotelFxAgentEntity me = currentAgent(); + if (me == null) { + return page; + } + return page(page, Wrappers.lambdaQuery() + .eq(SmHotelFxWithdrawEntity::getAgentId, me.getId()) + .eq(StrUtil.isNotBlank(status), SmHotelFxWithdrawEntity::getStatus, status) + .orderByDesc(SmHotelFxWithdrawEntity::getId)); + } + + @Override + public R> withdrawableMine() { + SmHotelFxAgentEntity me = currentAgent(); + if (me == null) { + return R.failed("当前账号不是分销员"); + } + List list = listWithdrawable(me.getId()); + BigDecimal amount = BigDecimal.ZERO; + for (SmHotelFxCommissionEntity c : list) { + amount = amount.add(c.getCommissionAmount() == null ? BigDecimal.ZERO : c.getCommissionAmount()); + } + Map data = new LinkedHashMap<>(); + data.put("agentId", me.getId()); + data.put("withdrawableAmount", amount); + data.put("commissionCount", list.size()); + return R.ok(data); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public R applyMine(String accountName, String accountNo, String bankName, String remark) { + SmHotelFxAgentEntity me = currentAgent(); + if (me == null || !"ACTIVE".equals(me.getStatus())) { + return R.failed("当前账号不是在职分销员"); + } + if (StrUtil.isBlank(accountName) || StrUtil.isBlank(accountNo)) { + return R.failed("请填写收款户名与账号"); + } + List list = listWithdrawable(me.getId()); + if (CollUtil.isEmpty(list)) { + return R.failed("暂无可提现锁定佣金"); + } + BigDecimal amount = BigDecimal.ZERO; + for (SmHotelFxCommissionEntity c : list) { + amount = amount.add(c.getCommissionAmount() == null ? BigDecimal.ZERO : c.getCommissionAmount()); + } + if (amount.compareTo(BigDecimal.ZERO) <= 0) { + return R.failed("可提现金额为 0"); + } + SmHotelFxWithdrawEntity wd = new SmHotelFxWithdrawEntity(); + wd.setAgentId(me.getId()); + wd.setUserId(me.getUserId()); + wd.setAmount(amount); + wd.setCommissionCount(list.size()); + wd.setAccountName(accountName.trim()); + wd.setAccountNo(accountNo.trim()); + wd.setBankName(bankName); + wd.setStatus("PENDING"); + wd.setApplyRemark(remark); + save(wd); + for (SmHotelFxCommissionEntity c : list) { + c.setWithdrawId(wd.getId()); + commissionMapper.updateById(c); + } + log.info("|酒店分销|提现申请|agentId={}|amount={}|lines={}|wdId={}|", me.getId(), amount, list.size(), + wd.getId()); + return R.ok(wd); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public R audit(Long id, boolean approved, String auditRemark) { + SmHotelFxWithdrawEntity wd = getById(id); + if (wd == null) { + return R.failed("提现单不存在"); + } + if (!"PENDING".equals(wd.getStatus())) { + return R.failed("仅待审提现可审核"); + } + PjcloudUser user = SecurityUtils.getUser(); + wd.setAuditBy(user != null ? user.getUsername() : null); + wd.setAuditTime(LocalDateTime.now()); + wd.setAuditRemark(auditRemark); + if (!approved) { + wd.setStatus("REJECTED"); + updateById(wd); + releaseCommissions(wd.getId()); + return R.ok(Boolean.TRUE, "已驳回,佣金已释放可再提"); + } + wd.setStatus("APPROVED"); + updateById(wd); + return R.ok(Boolean.TRUE, "已通过,待打款"); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public R markPaid(Long id, String remark) { + SmHotelFxWithdrawEntity wd = getById(id); + if (wd == null) { + return R.failed("提现单不存在"); + } + if (!"APPROVED".equals(wd.getStatus()) && !"PENDING".equals(wd.getStatus())) { + return R.failed("仅待审/已审通过的提现可标记打款"); + } + PjcloudUser user = SecurityUtils.getUser(); + if ("PENDING".equals(wd.getStatus())) { + wd.setAuditBy(user != null ? user.getUsername() : null); + wd.setAuditTime(LocalDateTime.now()); + } + wd.setStatus("PAID"); + wd.setPayTime(LocalDateTime.now()); + if (StrUtil.isNotBlank(remark)) { + wd.setAuditRemark(StrUtil.blankToDefault(wd.getAuditRemark(), "") + ";" + remark); + } + updateById(wd); + List list = commissionMapper.selectList(Wrappers.lambdaQuery() + .eq(SmHotelFxCommissionEntity::getWithdrawId, id) + .eq(SmHotelFxCommissionEntity::getStatus, ST_LOCK)); + for (SmHotelFxCommissionEntity c : list) { + c.setStatus(ST_WITHDRAWN); + c.setRemark("已打款提现"); + commissionMapper.updateById(c); + } + return R.ok(Boolean.TRUE, "已标记打款"); + } + + private List listWithdrawable(Long agentId) { + return commissionMapper.selectList(Wrappers.lambdaQuery() + .eq(SmHotelFxCommissionEntity::getAgentId, agentId) + .eq(SmHotelFxCommissionEntity::getStatus, ST_LOCK) + .isNull(SmHotelFxCommissionEntity::getWithdrawId)); + } + + private void releaseCommissions(Long withdrawId) { + List list = commissionMapper.selectList(Wrappers.lambdaQuery() + .eq(SmHotelFxCommissionEntity::getWithdrawId, withdrawId)); + for (SmHotelFxCommissionEntity c : list) { + c.setWithdrawId(null); + commissionMapper.updateById(c); + } + } + + private SmHotelFxAgentEntity currentAgent() { + PjcloudUser user = SecurityUtils.getUser(); + if (user == null || user.getId() == null) { + return null; + } + return agentService.getByUserId(user.getId()); + } + + private void fillAgent(List list) { + if (CollUtil.isEmpty(list)) { + return; + } + for (SmHotelFxWithdrawEntity row : list) { + SmHotelFxAgentEntity a = agentService.getById(row.getAgentId()); + if (a != null) { + row.setAgentName(StrUtil.blankToDefault(a.getRealName(), a.getUsername())); + } + } + } +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/resources/application.yml b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/resources/application.yml index c9fa26cfd..4766662df 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/resources/application.yml +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/resources/application.yml @@ -96,9 +96,19 @@ spring: # price-roll-day-offset: 90 # # 滚价按本地酒店 id 游标每批数量(防 OOM) # price-roll-batch-size: 100 -# # 全量房价房量:每天最多次数 / 最小间隔小时(「拉取全部启用详情」受此限制) +# # 全量房价房量:每天最多次数 / 最小间隔小时(全量详情 + 渠道范围详情共用) # full-price-sync-max-per-day: 3 # full-price-sync-min-interval-hours: 6 +# # 上游渠道「全量详情」定时(默认关;Cron 改后需重启;开关可库表热更) +# detail-pull-daily-enabled: false +# detail-pull-daily-cron: "0 0 4 * * ?" +# # 全部下游渠道范围合集「全量详情」定时(只拉范围内上游酒店,跳过本地;默认关) +# scope-detail-pull-daily-enabled: false +# scope-detail-pull-daily-cron: "0 0 5 * * ?" +# # 近窗详情:刷渠道范围合集未来 N 天(默认 15;可改 7);不占全量 6h/每日次数;默认开、每 2 小时 +# near-detail-pull-enabled: true +# near-detail-pull-days: 15 +# near-detail-pull-cron: "0 0 */2 * * ?" # # 全量拉详情:每家间隔毫秒(并行时主要靠 price-api-qps,默认 0) # detail-pull-interval-ms: 0 # # 全量拉详情酒店并发(吃满上游约 20 QPS;建议 4~8;多实例请调低)