feat(task): 添加配送奖励功能并优化套票发放流程

- 新增配送奖励计算和发放逻辑,按订单商品的配送费率计算奖励金额
- 添加配送奖励资金流水记录,分配FLOW_TYPE_DELIVERY_REWARD类型
- 优化套票发放流程,移除自动核销机制,改为用户主动履约核销
- 实现套票分批释放功能,支持按期数或月度平均释放策略
- 调整订单结算规则,绑定水票模板的订单支付成功即可分润
- 修复并发情况下的订单重复结算问题,增加分布式锁机制
- 更新日志输出中的分佣/分润术语统一,提升代码可读性
This commit is contained in:
2026-03-26 16:56:00 +08:00
parent 0823c42cbc
commit 7982b8f963
18 changed files with 1139 additions and 187 deletions

View File

@@ -52,6 +52,34 @@ public class GltTicketOrderController extends BaseController {
return success(gltTicketOrderService.pageRel(param));
}
@PreAuthorize("isAuthenticated()")
@Operation(summary = "配送员端:分页查询可接单的送水订单")
@GetMapping("/rider/available")
public ApiResult<PageResult<GltTicketOrder>> riderAvailablePage(GltTicketOrderParam param) {
User loginUser = getLoginUser();
if (loginUser == null) {
return fail("请先登录", null);
}
Integer tenantId = getTenantId();
param.setTenantId(tenantId);
// 仅允许配送员访问
requireActiveRider(loginUser.getUserId(), tenantId);
// 查询未分配的待配送订单riderId 为空或0
// 设置为0表示查询未分配的订单XML中会处理为 IS NULL OR = 0
param.setRiderId(0);
if (param.getDeliveryStatus() == null) {
param.setDeliveryStatus(GltTicketOrderService.DELIVERY_STATUS_WAITING);
}
// 配送员端默认按期望配送时间优先
if (StrUtil.isBlank(param.getSort())) {
param.setSort("sendTime asc, createTime desc");
}
// 使用现有的关联查询方法,通过参数控制
return success(gltTicketOrderService.pageRel(param));
}
@PreAuthorize("isAuthenticated()")
@Operation(summary = "配送员端:分页查询我的送水订单")
@GetMapping("/rider/page")
@@ -65,8 +93,12 @@ public class GltTicketOrderController extends BaseController {
// 仅允许配送员访问
requireActiveRider(loginUser.getUserId(), tenantId);
// 关键修复:配送员只能看到分配给自己的订单
param.setRiderId(loginUser.getUserId());
// 默认查询待配送和配送中的订单
if (param.getDeliveryStatus() == null) {
// 可以通过参数传递多个状态,这里简化为只查待配送
param.setDeliveryStatus(GltTicketOrderService.DELIVERY_STATUS_WAITING);
}
// 配送员端默认按期望配送时间优先
@@ -241,8 +273,33 @@ public class GltTicketOrderController extends BaseController {
@Operation(summary = "修改送水订单")
@PutMapping()
public ApiResult<?> update(@RequestBody GltTicketOrder gltTicketOrder) {
if (gltTicketOrderService.updateById(gltTicketOrder)) {
if (gltTicketOrder == null || gltTicketOrder.getId() == null) {
return fail("订单ID不能为空");
}
Integer tenantId = getTenantId();
// 根据 addressId 同步更新订单地址快照
if (gltTicketOrder.getAddressId() != null) {
ShopUserAddress userAddress = shopUserAddressService.getByIdRel(gltTicketOrder.getAddressId());
if (userAddress == null) {
return fail("收货地址不存在");
}
if (tenantId != null && userAddress.getTenantId() != null && !tenantId.equals(userAddress.getTenantId())) {
return fail("收货地址不存在或无权限");
}
GltTicketOrder oldOrder = gltTicketOrderService.getById(gltTicketOrder.getId());
if (oldOrder == null) {
return fail("订单不存在");
}
Integer targetUserId = gltTicketOrder.getUserId() != null ? gltTicketOrder.getUserId() : oldOrder.getUserId();
if (targetUserId != null && userAddress.getUserId() != null && !targetUserId.equals(userAddress.getUserId())) {
return fail("收货地址不存在或无权限");
}
gltTicketOrder.setAddressId(userAddress.getId());
gltTicketOrder.setAddress(buildAddressSnapshot(userAddress));
}
if (gltTicketOrderService.updateById(gltTicketOrder)) {
// 后台指派配送员(直接改 riderId同步商城订单为“已发货”(deliveryStatus=20)
if (gltTicketOrder != null
&& gltTicketOrder.getId() != null

View File

@@ -47,7 +47,6 @@ public class GltTicketTemplateController extends BaseController {
return success(gltTicketTemplateService.listRel(param));
}
@PreAuthorize("hasAuthority('glt:gltTicketTemplate:list')")
@Operation(summary = "根据id查询水票")
@GetMapping("/{id}")
public ApiResult<GltTicketTemplate> get(@PathVariable("id") Integer id) {

View File

@@ -35,6 +35,10 @@ public class GltTicketOrder implements Serializable {
@TableField(exist = false)
private String orderNo;
@Schema(description = "订单状态")
@TableField(exist = false)
private Integer orderStatus;
@Schema(description = "门店ID")
private Integer storeId;

View File

@@ -58,6 +58,9 @@ public class GltTicketTemplate implements Serializable {
@Schema(description = "首期释放时机0=支付成功当刻1=下个月同日")
private Integer firstReleaseMode;
@Schema(description = "步长")
private Integer step;
@Schema(description = "用户ID")
private Integer userId;

View File

@@ -88,6 +88,10 @@ public class GltUserTicket implements Serializable {
@TableField(exist = false)
private String phone;
@Schema(description = "订单状态")
@TableField(exist = false)
private Integer orderStatus;
@Schema(description = "排序(数字越小越靠前)")
private Integer sortNumber;

View File

@@ -11,7 +11,7 @@
d.name as receiverName, d.phone as receiverPhone,
d.province as receiverProvince, d.city as receiverCity, d.region as receiverRegion,
d.address as receiverAddress, d.full_address as receiverFullAddress, d.lat as receiverLat, d.lng as receiverLng,
COALESCE(o.order_no, f.order_no) as orderNo
COALESCE(o.order_no, f.order_no) as orderNo, o.order_status as orderStatus
FROM glt_ticket_order a
LEFT JOIN shop_store b ON a.store_id = b.id
LEFT JOIN shop_store_warehouse w ON a.warehouse_id = w.id
@@ -32,8 +32,13 @@
AND a.store_id = #{param.storeId}
</if>
<if test="param.riderId != null">
<if test="param.riderId == 0">
AND (a.rider_id IS NULL OR a.rider_id = 0)
</if>
<if test="param.riderId != 0">
AND a.rider_id = #{param.riderId}
</if>
</if>
<if test="param.deliveryStatus != null">
AND a.delivery_status = #{param.deliveryStatus}
</if>

View File

@@ -4,12 +4,12 @@
<!-- 关联查询sql -->
<sql id="selectSql">
SELECT a.*, u.nickname, u.avatar, u.phone, m.name AS templateName, o.pay_price AS payPrice
SELECT a.*, u.nickname, u.avatar, u.phone, m.name AS templateName, o.pay_price AS payPrice, o.order_status as orderStatus
FROM glt_user_ticket a
LEFT JOIN gxwebsoft_core.sys_user u ON a.user_id = u.user_id
LEFT JOIN glt_ticket_template m ON a.template_id = m.id
<!-- 使用 order_id + tenant_id 关联,避免 order_no 跨租户/重复导致 a.id 数据被 JOIN 放大 -->
LEFT JOIN shop_order o ON a.order_id = o.order_id AND a.tenant_id = o.tenant_id AND o.deleted = 0
LEFT JOIN shop_order o ON a.order_no = o.order_no AND a.tenant_id = o.tenant_id AND o.deleted = 0
<where>
<if test="param.id != null">
AND a.id = #{param.id}
@@ -26,6 +26,9 @@
<if test="param.orderNo != null">
AND a.order_no LIKE CONCAT('%', #{param.orderNo}, '%')
</if>
<if test="param.orderStatus != null">
AND o.order_status = #{param.orderStatus}
</if>
<if test="param.orderGoodsId != null">
AND a.order_goods_id = #{param.orderGoodsId}
</if>

View File

@@ -1,5 +1,6 @@
package com.gxwebsoft.glt.param;
import com.baomidou.mybatisplus.annotation.TableField;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.gxwebsoft.common.core.annotation.QueryField;
import com.gxwebsoft.common.core.annotation.QueryType;
@@ -38,6 +39,9 @@ public class GltTicketOrderParam extends BaseParam {
@QueryField(type = QueryType.EQ)
private Integer riderId;
@Schema(description = "订单编号")
private String orderNo;
@Schema(description = "配送状态10待配送、20配送中、30待客户确认、40已完成")
@QueryField(type = QueryType.EQ)
private Integer deliveryStatus;
@@ -83,4 +87,8 @@ public class GltTicketOrderParam extends BaseParam {
@QueryField(type = QueryType.EQ)
private Integer deleted;
@Schema(description = "订单状态")
@QueryField(type = QueryType.EQ)
private Integer orderStatus;
}

View File

@@ -40,6 +40,10 @@ public class GltUserTicketParam extends BaseParam {
@Schema(description = "订单编号")
private String orderNo;
@Schema(description = "订单状态")
@QueryField(type = QueryType.EQ)
private Integer orderStatus;
@Schema(description = "订单商品ID")
@QueryField(type = QueryType.EQ)
private Integer orderGoodsId;

View File

@@ -2,27 +2,22 @@ package com.gxwebsoft.glt.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.gxwebsoft.glt.entity.GltTicketOrder;
import com.gxwebsoft.glt.entity.GltTicketTemplate;
import com.gxwebsoft.glt.entity.GltUserTicket;
import com.gxwebsoft.glt.entity.GltUserTicketLog;
import com.gxwebsoft.glt.entity.GltUserTicketRelease;
import com.gxwebsoft.shop.entity.ShopOrder;
import com.gxwebsoft.shop.entity.ShopOrderGoods;
import com.gxwebsoft.shop.entity.ShopUserAddress;
import com.gxwebsoft.shop.service.ShopOrderGoodsService;
import com.gxwebsoft.shop.service.ShopOrderService;
import com.gxwebsoft.shop.service.ShopUserAddressService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
@@ -43,8 +38,6 @@ import java.util.Set;
public class GltTicketIssueService {
public static final int CHANGE_TYPE_ISSUE = 10;
/** 变更类型:起始送水自动核销(按模板 startSendQty 在发放时自动消耗) */
public static final int CHANGE_TYPE_START_SEND_WRITE_OFF = 12;
private enum IssueOutcome {
ISSUED,
@@ -60,8 +53,6 @@ public class GltTicketIssueService {
private final GltUserTicketService gltUserTicketService;
private final GltUserTicketReleaseService gltUserTicketReleaseService;
private final GltUserTicketLogService gltUserTicketLogService;
private final GltTicketOrderService gltTicketOrderService;
private final ShopUserAddressService shopUserAddressService;
private final TransactionTemplate transactionTemplate;
/**
@@ -163,11 +154,13 @@ public class GltTicketIssueService {
if (shouldCompleteOrder) {
LocalDateTime now = LocalDateTime.now();
// 任务执行完后将订单置为“已完成”order_status=1
// 任务执行完后将订单置为“已完成”,避免后续扫描重复处理(幂等虽可挡住,但会产生大量无意义查询)。
shopOrderService.update(
new LambdaUpdateWrapper<ShopOrder>()
.eq(ShopOrder::getOrderId, order.getOrderId())
.eq(ShopOrder::getTenantId, tenantId)
.eq(ShopOrder::getOrderStatus, 0)
.set(ShopOrder::getOrderStatus, 1)
.set(ShopOrder::getHasTakeGift, true)
.set(ShopOrder::getUpdateTime, now)
);
@@ -186,11 +179,29 @@ public class GltTicketIssueService {
return IssueOutcome.SKIPPED;
}
// 并发幂等兜底:
// - 该任务可能在多实例部署下并发执行;若不加锁,在没有唯一索引的情况下可能重复发放/重复核销。
// - 这里先对商城订单行加行锁,保证同一订单在同一时刻只会被一个事务处理。
// (注意:需数据库支持 SELECT ... FOR UPDATE且 shop_order.order_id 为主键/有索引)
if (order.getOrderId() != null) {
shopOrderService.getOne(
new LambdaQueryWrapper<ShopOrder>()
.eq(ShopOrder::getOrderId, order.getOrderId())
.eq(ShopOrder::getTenantId, tenantId)
.last("for update")
);
}
// 同一商品允许存在多条模板记录(历史数据/误操作)。为避免取到“错误模板”,这里做确定性排序取第一条。
// 排序规则与后台 getByGoodsId 保持一致sortNumber 越小越靠前,其次取最新创建时间。
GltTicketTemplate template = gltTicketTemplateService.getOne(
new LambdaQueryWrapper<GltTicketTemplate>()
.eq(GltTicketTemplate::getTenantId, tenantId)
.eq(GltTicketTemplate::getGoodsId, og.getGoodsId())
.eq(GltTicketTemplate::getDeleted, 0)
.orderByAsc(GltTicketTemplate::getSortNumber)
.orderByDesc(GltTicketTemplate::getCreateTime)
.orderByDesc(GltTicketTemplate::getId)
.last("limit 1")
);
@@ -238,7 +249,7 @@ public class GltTicketIssueService {
int giftMultiplier = template.getGiftMultiplier() != null ? template.getGiftMultiplier() : 0;
int giftQty = buyQty * Math.max(giftMultiplier, 0);
// 购买量buyQty应立即可用赠送量giftQty进入冻结并按计划释放。
// 总票数(购买量 + 赠送量)
int totalQty = buyQty + giftQty;
if (totalQty <= 0) {
@@ -249,6 +260,15 @@ public class GltTicketIssueService {
LocalDateTime now = LocalDateTime.now();
boolean useReleasePeriods = template.getReleasePeriods() != null && template.getReleasePeriods() > 0;
// 释放期数releasePeriods为高优先级
// - 配置了期数:按期数平均分摊 totalQty每期释放不再“先把购买桶数一次性释放”。
// - 未配置期数:保持原逻辑(购买量立即可用,赠送量冻结并按计划释放)。
int initAvailableQty = useReleasePeriods ? 0 : buyQty;
int initFrozenQty = useReleasePeriods ? totalQty : giftQty;
int initReleasedQty = useReleasePeriods ? 0 : buyQty;
GltUserTicket userTicket = new GltUserTicket();
userTicket.setTemplateId(template.getId());
userTicket.setGoodsId(og.getGoodsId());
@@ -256,11 +276,10 @@ public class GltTicketIssueService {
userTicket.setOrderNo(order.getOrderNo());
userTicket.setOrderGoodsId(og.getId());
userTicket.setTotalQty(totalQty);
userTicket.setAvailableQty(buyQty);
userTicket.setFrozenQty(giftQty);
userTicket.setAvailableQty(initAvailableQty);
userTicket.setFrozenQty(initFrozenQty);
userTicket.setUsedQty(0);
// 初始可用量来自“购买量”,视为已释放
userTicket.setReleasedQty(buyQty);
userTicket.setReleasedQty(initReleasedQty);
userTicket.setOrderGoodsQty(og.getTotalNum());
userTicket.setUserId(order.getUserId());
userTicket.setSortNumber(0);
@@ -273,12 +292,36 @@ public class GltTicketIssueService {
gltUserTicketService.save(userTicket);
// 生成释放计划(按月)
// 生成释放计划
// - 配置 releasePeriods按 totalQty 生成每期释放量periods 优先)
// - 未配置 releasePeriods按 giftQty 生成每期释放量
LocalDateTime baseTime = order.getPayTime() != null ? order.getPayTime() : order.getCreateTime();
if (baseTime == null) {
baseTime = now;
}
List<GltUserTicketRelease> releases = buildReleasePlan(template, userTicket, baseTime, giftQty, now);
int planQty = useReleasePeriods ? totalQty : giftQty;
List<GltUserTicketRelease> releases = buildReleasePlan(template, userTicket, baseTime, planQty, now);
// 若启用了 releasePeriods 且首期释放时机为“支付成功当刻”,则将首期释放量直接计入可用,
// 避免用户刚购买后短时间内无可用水票;后续期数仍由自动释放任务按 release_time 释放。
if (useReleasePeriods && !releases.isEmpty() && !Objects.equals(template.getFirstReleaseMode(), 1)) {
GltUserTicketRelease first = releases.get(0);
Integer firstQtyObj = first.getReleaseQty();
LocalDateTime firstTime = first.getReleaseTime();
int firstQty = firstQtyObj != null ? firstQtyObj : 0;
if (firstQty > 0 && (firstTime == null || !firstTime.isAfter(now))) {
first.setStatus(1);
first.setUpdateTime(now);
userTicket.setAvailableQty((userTicket.getAvailableQty() != null ? userTicket.getAvailableQty() : 0) + firstQty);
userTicket.setFrozenQty((userTicket.getFrozenQty() != null ? userTicket.getFrozenQty() : 0) - firstQty);
userTicket.setReleasedQty((userTicket.getReleasedQty() != null ? userTicket.getReleasedQty() : 0) + firstQty);
userTicket.setUpdateTime(now);
if (!gltUserTicketService.updateById(userTicket)) {
throw new IllegalStateException("首期释放:更新用户水票失败 userTicketId=" + userTicket.getId());
}
}
}
if (!releases.isEmpty()) {
gltUserTicketReleaseService.saveBatch(releases);
}
@@ -287,11 +330,11 @@ public class GltTicketIssueService {
GltUserTicketLog issueLog = new GltUserTicketLog();
issueLog.setUserTicketId(userTicket.getId());
issueLog.setChangeType(CHANGE_TYPE_ISSUE);
issueLog.setChangeAvailable(buyQty);
issueLog.setChangeFrozen(giftQty);
issueLog.setChangeAvailable(userTicket.getAvailableQty() != null ? userTicket.getAvailableQty() : 0);
issueLog.setChangeFrozen(userTicket.getFrozenQty() != null ? userTicket.getFrozenQty() : 0);
issueLog.setChangeUsed(0);
issueLog.setAvailableAfter(buyQty);
issueLog.setFrozenAfter(giftQty);
issueLog.setAvailableAfter(userTicket.getAvailableQty() != null ? userTicket.getAvailableQty() : 0);
issueLog.setFrozenAfter(userTicket.getFrozenQty() != null ? userTicket.getFrozenQty() : 0);
issueLog.setUsedAfter(0);
issueLog.setOrderId(order.getOrderId());
issueLog.setOrderNo(order.getOrderNo());
@@ -305,53 +348,17 @@ public class GltTicketIssueService {
issueLog.setUpdateTime(now);
gltUserTicketLogService.save(issueLog);
// 按模板配置:自动“使用掉第一次水票”(起始送水数量)
// 按整改需求:水票购买(囤券预付费)与水票核销(下单履约)应为两次独立用户动作;
// 因此模板 startSendQty 不再在“发放”阶段自动核销/自动生成送水订单。
Integer startSendQtyObj = template.getStartSendQty();
int startSendQty = startSendQtyObj != null ? startSendQtyObj : 0;
if (startSendQty > 0) {
int availableBefore = userTicket.getAvailableQty() != null ? userTicket.getAvailableQty() : 0;
int usedBefore = userTicket.getUsedQty() != null ? userTicket.getUsedQty() : 0;
int toUse = Math.min(startSendQty, availableBefore);
if (toUse > 0) {
userTicket.setAvailableQty(availableBefore - toUse);
userTicket.setUsedQty(usedBefore + toUse);
userTicket.setUpdateTime(now);
if (!gltUserTicketService.updateById(userTicket)) {
throw new IllegalStateException("起始送水自动核销:更新用户水票失败 userTicketId=" + userTicket.getId());
log.info("套票模板配置了 startSendQty但不再自动送水/自动核销 - tenantId={}, orderNo={}, templateId={}, userTicketId={}, startSendQty={}",
tenantId, order.getOrderNo(), template.getId(), userTicket.getId(), startSendQty);
}
// 起始送水:自动核销成功后,生成一条送水订单(用于配送端/后台跟踪)
GltTicketOrder ticketOrder = buildStartSendTicketOrder(tenantId, order, userTicket, toUse, now);
if (!gltTicketOrderService.save(ticketOrder)) {
throw new IllegalStateException("起始送水自动核销:创建送水订单失败 userTicketId=" + userTicket.getId());
}
GltUserTicketLog writeOffLog = new GltUserTicketLog();
writeOffLog.setUserTicketId(userTicket.getId());
writeOffLog.setChangeType(CHANGE_TYPE_START_SEND_WRITE_OFF);
writeOffLog.setChangeAvailable(-toUse);
writeOffLog.setChangeFrozen(0);
writeOffLog.setChangeUsed(toUse);
writeOffLog.setAvailableAfter(userTicket.getAvailableQty());
writeOffLog.setFrozenAfter(userTicket.getFrozenQty() != null ? userTicket.getFrozenQty() : 0);
writeOffLog.setUsedAfter(userTicket.getUsedQty());
// 关联送水订单(保持与“用户下单核销”的日志一致),并在备注里保留来源商城订单号便于追溯
writeOffLog.setOrderId(ticketOrder.getId());
writeOffLog.setOrderNo(ticketOrder.getId() == null ? null : String.valueOf(ticketOrder.getId()));
writeOffLog.setUserId(order.getUserId());
writeOffLog.setSortNumber(0);
writeOffLog.setComments("起始送水自动核销(来源商城订单:" + safe(order.getOrderNo()) + ")");
writeOffLog.setStatus(0);
writeOffLog.setDeleted(0);
writeOffLog.setTenantId(tenantId);
writeOffLog.setCreateTime(now);
writeOffLog.setUpdateTime(now);
gltUserTicketLogService.save(writeOffLog);
}
}
log.info("套票发放成功 - tenantId={}, orderNo={}, orderGoodsId={}, templateId={}, userTicketId={}, totalQty={}",
tenantId, order.getOrderNo(), og.getId(), template.getId(), userTicket.getId(), totalQty);
log.info("套票发放成功 - tenantId={}, orderNo={}, orderGoodsId={}, templateId={}, userTicketId={}, orderGoodsQty={}, buyQty={}, giftQty={}, startSendQty={}, totalQty={}",
tenantId, order.getOrderNo(), og.getId(), template.getId(), userTicket.getId(), og.getTotalNum(), buyQty, giftQty, startSendQty, totalQty);
return IssueOutcome.ISSUED;
}
@@ -380,12 +387,13 @@ public class GltTicketIssueService {
if (releasePeriods != null && releasePeriods > 0) {
int base = totalQty / releasePeriods;
int remainder = totalQty % releasePeriods;
for (int i = 1; i <= releasePeriods; i++) {
int qty = base + (i <= remainder ? 1 : 0);
// periodNo 从 0 开始第0期、第1期……更贴近任务执行计数
for (int i = 0; i < releasePeriods; i++) {
int qty = base + (i < remainder ? 1 : 0);
if (qty <= 0) {
continue;
}
list.add(buildRelease(userTicket, i, qty, firstReleaseTime.plusMonths(i - 1), now));
list.add(buildRelease(userTicket, i, qty, firstReleaseTime.plusMonths(i), now));
}
return list;
}
@@ -395,13 +403,14 @@ public class GltTicketIssueService {
: 10;
int periods = (totalQty + monthlyReleaseQty - 1) / monthlyReleaseQty;
int remaining = totalQty;
for (int i = 1; i <= periods; i++) {
// periodNo 从 0 开始第0期、第1期……
for (int i = 0; i < periods; i++) {
int qty = Math.min(monthlyReleaseQty, remaining);
if (qty <= 0) {
break;
}
remaining -= qty;
list.add(buildRelease(userTicket, i, qty, firstReleaseTime.plusMonths(i - 1), now));
list.add(buildRelease(userTicket, i, qty, firstReleaseTime.plusMonths(i), now));
}
return list;
@@ -436,77 +445,4 @@ public class GltTicketIssueService {
return LocalDateTime.of(adjusted, time);
}
private static final DateTimeFormatter DATETIME_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private GltTicketOrder buildStartSendTicketOrder(Integer tenantId,
ShopOrder shopOrder,
GltUserTicket userTicket,
int totalNum,
LocalDateTime now) {
GltTicketOrder o = new GltTicketOrder();
o.setUserTicketId(userTicket.getId());
o.setUserId(shopOrder.getUserId());
o.setStoreId(shopOrder.getStoreId());
o.setWarehouseId(shopOrder.getWarehouseId());
o.setRiderId(shopOrder.getRiderId());
o.setTotalNum(totalNum);
o.setPrice(BigDecimal.ZERO);
o.setBuyerRemarks(shopOrder.getBuyerRemarks());
// 地址快照:优先使用地址表快照;兜底使用商城订单上的 address 字段
Integer addressId = shopOrder.getAddressId();
o.setAddressId(addressId);
String addressSnapshot = null;
if (addressId != null) {
ShopUserAddress addr = shopUserAddressService.getOne(new LambdaQueryWrapper<ShopUserAddress>()
.eq(ShopUserAddress::getId, addressId)
.eq(ShopUserAddress::getUserId, shopOrder.getUserId())
.eq(ShopUserAddress::getTenantId, tenantId)
.last("limit 1"));
addressSnapshot = buildAddressSnapshot(addr);
}
if (addressSnapshot == null || addressSnapshot.isBlank()) {
addressSnapshot = shopOrder.getAddress();
}
o.setAddress(addressSnapshot);
String preferredSendTime = shopOrder.getSendStartTime();
if (preferredSendTime == null || preferredSendTime.isBlank()) {
preferredSendTime = now.format(DATETIME_FMT);
}
o.setSendTime(preferredSendTime);
o.setDeliveryStatus(GltTicketOrderService.DELIVERY_STATUS_WAITING);
o.setSortNumber(0);
o.setComments("起始送水自动下单(来源商城订单:" + safe(shopOrder.getOrderNo()) + ")");
o.setStatus(0);
o.setDeleted(0);
o.setTenantId(tenantId);
o.setCreateTime(now);
o.setUpdateTime(now);
return o;
}
private static String buildAddressSnapshot(ShopUserAddress addr) {
if (addr == null) {
return null;
}
if (addr.getFullAddress() != null && !addr.getFullAddress().isBlank()) {
return addr.getFullAddress();
}
// 兼容旧数据fullAddress 为空时,拼接省市区 + 详细地址
StringBuilder sb = new StringBuilder();
if (addr.getProvince() != null) sb.append(addr.getProvince());
if (addr.getCity() != null) sb.append(addr.getCity());
if (addr.getRegion() != null) sb.append(addr.getRegion());
if (addr.getAddress() != null) sb.append(addr.getAddress());
String s = sb.toString();
if (!s.isBlank()) {
return s;
}
return addr.getAddress();
}
private static String safe(String s) {
return s == null ? "" : s;
}
}

View File

@@ -0,0 +1,355 @@
package com.gxwebsoft.glt.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.gxwebsoft.glt.entity.GltTicketOrder;
import com.gxwebsoft.glt.entity.GltUserTicket;
import com.gxwebsoft.shop.entity.ShopStoreRider;
import com.gxwebsoft.shop.entity.ShopOrder;
import com.gxwebsoft.shop.entity.ShopOrderGoods;
import com.gxwebsoft.shop.entity.ShopUserAddress;
import com.gxwebsoft.shop.service.ShopOrderGoodsService;
import com.gxwebsoft.shop.service.ShopOrderService;
import com.gxwebsoft.shop.service.ShopStoreRiderService;
import com.gxwebsoft.shop.service.ShopUserAddressService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* 送水订单自动派单:
* - 扫描待配送且未指派配送员的 glt_ticket_order
* - 取收货地址坐标(优先 ShopOrder.addressLat/addressLng 订单快照;兜底 addressId -> shop_user_address.lat/lng
* - 在同门店、在线、启用、开启自动派单且有坐标的配送员中,按距离最近优先;
* - 派单写入 glt_ticket_order.rider_id即配送员 userId并同步商城订单 riderId/deliveryStatus复用 accept 逻辑)。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class GltTicketOrderAutoDispatchService {
private final GltTicketOrderService gltTicketOrderService;
private final GltUserTicketService gltUserTicketService;
private final ShopStoreRiderService shopStoreRiderService;
private final ShopUserAddressService shopUserAddressService;
private final ShopOrderService shopOrderService;
private final ShopOrderGoodsService shopOrderGoodsService;
/**
* 自动派单(按距离最近)。
*
* @param tenantId 租户ID
* @param batchSize 单次扫描最大处理条数
* @return 成功派单数量
*/
public int autoDispatchWaitingOrders(Integer tenantId, int batchSize) {
if (tenantId == null || tenantId <= 0) {
return 0;
}
if (batchSize <= 0) {
batchSize = 50;
}
List<GltTicketOrder> waiting = gltTicketOrderService.list(
new LambdaQueryWrapper<GltTicketOrder>()
.select(GltTicketOrder::getId, GltTicketOrder::getUserTicketId, GltTicketOrder::getStoreId, GltTicketOrder::getAddressId, GltTicketOrder::getCreateTime)
.eq(GltTicketOrder::getTenantId, tenantId)
.eq(GltTicketOrder::getDeleted, 0)
.and(w -> w.eq(GltTicketOrder::getStatus, 0).or().isNull(GltTicketOrder::getStatus))
.and(w -> w.eq(GltTicketOrder::getDeliveryStatus, GltTicketOrderService.DELIVERY_STATUS_WAITING)
.or().isNull(GltTicketOrder::getDeliveryStatus))
.and(w -> w.isNull(GltTicketOrder::getRiderId).or().eq(GltTicketOrder::getRiderId, 0))
.orderByAsc(GltTicketOrder::getCreateTime)
.orderByAsc(GltTicketOrder::getId)
.last("limit " + batchSize)
);
if (waiting == null || waiting.isEmpty()) {
return 0;
}
int dispatched = 0;
// 简化实现:按门店分组,避免对同一门店重复查配送员/在途单数。
Map<Integer, CandidatePool> poolByStoreId = new HashMap<>();
for (GltTicketOrder order : waiting) {
if (order == null || order.getId() == null) {
continue;
}
LngLat receiver = resolveReceiverLngLat(tenantId, order);
if (receiver == null) {
log.debug("自动派单跳过:无法获取收货坐标 - tenantId={}, ticketOrderId={}, addressId={}",
tenantId, order.getId(), order.getAddressId());
continue;
}
Integer storeId = order.getStoreId();
int storeKey = storeId == null ? -1 : storeId;
CandidatePool pool = poolByStoreId.computeIfAbsent(storeKey, k -> buildCandidatePool(tenantId, storeId));
if (pool == null || pool.candidates.isEmpty()) {
log.debug("自动派单跳过:无可用配送员 - tenantId={}, ticketOrderId={}, storeId={}",
tenantId, order.getId(), storeId);
continue;
}
ShopStoreRider best = pickNearest(pool, receiver);
if (best == null || best.getUserId() == null || best.getUserId() <= 0) {
log.debug("自动派单跳过:未选出配送员 - tenantId={}, ticketOrderId={}, storeId={}",
tenantId, order.getId(), storeId);
continue;
}
try {
// 复用“接单”的原子更新 + 同步商城订单逻辑;这里的 riderId 即配送员 userId。
gltTicketOrderService.accept(order.getId(), best.getUserId(), tenantId);
dispatched++;
// 派单成功后,更新 pool 中该骑手的在途数(便于同批次后续订单使用 maxOnhandOrders
pool.onhandCount.merge(best.getUserId(), 1, Integer::sum);
} catch (Exception e) {
// accept 本身包含幂等条件riderId 为空才会写入);此处吞掉异常,避免一单影响整批。
log.warn("自动派单失败 - tenantId={}, ticketOrderId={}, storeId={}, riderUserId={}",
tenantId, order.getId(), storeId, best.getUserId(), e);
}
}
return dispatched;
}
private CandidatePool buildCandidatePool(Integer tenantId, Integer storeId) {
List<ShopStoreRider> riders = shopStoreRiderService.list(
new LambdaQueryWrapper<ShopStoreRider>()
.eq(ShopStoreRider::getTenantId, tenantId)
.eq(ShopStoreRider::getIsDelete, 0)
.eq(ShopStoreRider::getStatus, 1)
.eq(ShopStoreRider::getWorkStatus, 1) // 仅在线
.eq(ShopStoreRider::getAutoDispatchEnabled, 1)
.eq(storeId != null, ShopStoreRider::getStoreId, storeId)
);
if (riders == null || riders.isEmpty()) {
return new CandidatePool(List.of(), Map.of());
}
// 过滤无 userId/无坐标,并提前解析坐标(避免每单重复 parse
List<ShopStoreRider> candidates = riders.stream()
.filter(Objects::nonNull)
.filter(r -> r.getUserId() != null && r.getUserId() > 0)
.filter(r -> parseLngLat(r.getLongitude(), r.getLatitude()) != null)
.toList();
if (candidates.isEmpty()) {
return new CandidatePool(List.of(), Map.of());
}
List<Integer> riderUserIds = candidates.stream().map(ShopStoreRider::getUserId).distinct().toList();
Map<Integer, Integer> onhand = loadOnhandCounts(tenantId, riderUserIds);
return new CandidatePool(candidates, onhand);
}
private Map<Integer, Integer> loadOnhandCounts(Integer tenantId, List<Integer> riderUserIds) {
if (riderUserIds == null || riderUserIds.isEmpty()) {
return new HashMap<>();
}
// 统计各配送员“未完成”的在途单数10/20/30
QueryWrapper<GltTicketOrder> qw = new QueryWrapper<>();
qw.select("rider_id AS riderId", "COUNT(1) AS cnt")
.eq("tenant_id", tenantId)
.eq("deleted", 0)
.in("rider_id", riderUserIds)
.in("delivery_status",
GltTicketOrderService.DELIVERY_STATUS_WAITING,
GltTicketOrderService.DELIVERY_STATUS_DELIVERING,
GltTicketOrderService.DELIVERY_STATUS_WAIT_CONFIRM
)
.groupBy("rider_id");
Map<Integer, Integer> map = new HashMap<>();
List<Map<String, Object>> rows = gltTicketOrderService.listMaps(qw);
if (rows == null || rows.isEmpty()) {
return map;
}
for (Map<String, Object> row : rows) {
if (row == null) {
continue;
}
Object riderIdObj = row.get("riderId");
Object cntObj = row.get("cnt");
if (!(riderIdObj instanceof Number) || !(cntObj instanceof Number)) {
continue;
}
map.put(((Number) riderIdObj).intValue(), ((Number) cntObj).intValue());
}
return map;
}
private ShopStoreRider pickNearest(CandidatePool pool, LngLat receiver) {
// 优先距离;距离相同用 dispatchPriority 兜底(越大越优先);再用 userId 稳定排序
return pool.candidates.stream()
.filter(r -> {
Integer userId = r.getUserId();
if (userId == null || userId <= 0) {
return false;
}
int max = r.getMaxOnhandOrders() == null ? 0 : r.getMaxOnhandOrders();
if (max <= 0) {
return true; // 0表示不限制
}
int onhand = pool.onhandCount.getOrDefault(userId, 0);
return onhand < max;
})
.min(Comparator
.comparingDouble((ShopStoreRider r) -> distanceMeters(receiver, parseLngLat(r.getLongitude(), r.getLatitude())))
.thenComparingInt((ShopStoreRider r) -> -(r.getDispatchPriority() == null ? 0 : r.getDispatchPriority()))
.thenComparing(r -> r.getUserId() == null ? Integer.MAX_VALUE : r.getUserId())
)
.orElse(null);
}
private static double distanceMeters(LngLat a, LngLat b) {
if (a == null || b == null) {
return Double.POSITIVE_INFINITY;
}
// Haversine
double lat1 = Math.toRadians(a.lat);
double lat2 = Math.toRadians(b.lat);
double dLat = lat2 - lat1;
double dLng = Math.toRadians(b.lng - a.lng);
double sinLat = Math.sin(dLat / 2);
double sinLng = Math.sin(dLng / 2);
double aa = sinLat * sinLat + Math.cos(lat1) * Math.cos(lat2) * sinLng * sinLng;
double c = 2 * Math.atan2(Math.sqrt(aa), Math.sqrt(1 - aa));
return 6371000.0 * c; // Earth radius (meters)
}
private LngLat resolveReceiverLngLat(Integer tenantId, GltTicketOrder order) {
if (order == null) {
return null;
}
// 1) 优先使用“商城订单快照坐标”ShopOrder.addressLat/addressLng避免地址表坐标被事后修改影响历史订单派单。
LngLat byOrderSnapshot = resolveReceiverLngLatFromShopOrderSnapshot(tenantId, order);
if (byOrderSnapshot != null) {
return byOrderSnapshot;
}
// 2) 兜底:用地址表坐标(适用于没有关联商城订单的水票下单等场景)
Integer addressId = order.getAddressId();
if (addressId == null || addressId <= 0) {
return null;
}
ShopUserAddress addr = shopUserAddressService.getOne(new LambdaQueryWrapper<ShopUserAddress>()
.select(ShopUserAddress::getLat, ShopUserAddress::getLng)
.eq(ShopUserAddress::getId, addressId)
.eq(ShopUserAddress::getTenantId, tenantId)
.last("limit 1"));
return addr == null ? null : parseLngLat(addr.getLng(), addr.getLat());
}
private LngLat resolveReceiverLngLatFromShopOrderSnapshot(Integer tenantId, GltTicketOrder ticketOrder) {
Integer userTicketId = ticketOrder.getUserTicketId();
if (userTicketId == null || userTicketId <= 0) {
return null;
}
GltUserTicket userTicket = gltUserTicketService.getOne(new LambdaQueryWrapper<GltUserTicket>()
.select(GltUserTicket::getOrderId, GltUserTicket::getOrderNo, GltUserTicket::getOrderGoodsId)
.eq(GltUserTicket::getTenantId, tenantId)
.eq(GltUserTicket::getDeleted, 0)
.eq(GltUserTicket::getId, userTicketId)
.last("limit 1"));
if (userTicket == null) {
return null;
}
Integer shopOrderId = userTicket.getOrderId();
String shopOrderNo = userTicket.getOrderNo();
// 兼容:历史数据只写了 orderGoodsId
if (shopOrderId == null && !StringUtils.hasText(shopOrderNo) && userTicket.getOrderGoodsId() != null) {
ShopOrderGoods og = shopOrderGoodsService.getOne(new LambdaQueryWrapper<ShopOrderGoods>()
.select(ShopOrderGoods::getOrderId)
.eq(ShopOrderGoods::getTenantId, tenantId)
.eq(ShopOrderGoods::getId, userTicket.getOrderGoodsId())
.last("limit 1"));
if (og != null) {
shopOrderId = og.getOrderId();
}
}
LambdaQueryWrapper<ShopOrder> qw = new LambdaQueryWrapper<ShopOrder>()
.select(ShopOrder::getAddressLat, ShopOrder::getAddressLng)
.eq(ShopOrder::getTenantId, tenantId)
.eq(ShopOrder::getDeleted, 0)
.last("limit 1");
if (shopOrderId != null && shopOrderId > 0) {
qw.eq(ShopOrder::getOrderId, shopOrderId);
} else if (StringUtils.hasText(shopOrderNo)) {
qw.eq(ShopOrder::getOrderNo, shopOrderNo);
} else {
return null;
}
ShopOrder shopOrder = shopOrderService.getOne(qw);
if (shopOrder == null) {
return null;
}
return parseLngLat(shopOrder.getAddressLng(), shopOrder.getAddressLat());
}
private static LngLat parseLngLat(String lngRaw, String latRaw) {
Double lng = parseDoubleOrNull(lngRaw);
Double lat = parseDoubleOrNull(latRaw);
if (lng == null || lat == null) {
return null;
}
// 若明显是 (lat,lng) 则交换
if (Math.abs(lng) <= 90 && Math.abs(lat) > 90 && Math.abs(lat) <= 180) {
double tmp = lng;
lng = lat;
lat = tmp;
}
if (Math.abs(lat) > 90 || Math.abs(lng) > 180) {
return null;
}
return new LngLat(lng, lat);
}
private static Double parseDoubleOrNull(String raw) {
if (raw == null) {
return null;
}
String s = raw.trim();
if (s.isEmpty()) {
return null;
}
try {
return Double.parseDouble(s);
} catch (Exception e) {
return null;
}
}
private record LngLat(double lng, double lat) {
}
private static final class CandidatePool {
final List<ShopStoreRider> candidates;
final Map<Integer, Integer> onhandCount;
CandidatePool(List<ShopStoreRider> candidates, Map<Integer, Integer> onhandCount) {
this.candidates = candidates;
this.onhandCount = new HashMap<>(onhandCount == null ? Map.of() : onhandCount);
}
}
}

View File

@@ -0,0 +1,167 @@
package com.gxwebsoft.glt.service;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.gxwebsoft.glt.entity.GltTicketOrder;
import com.gxwebsoft.glt.entity.GltUserTicket;
import com.gxwebsoft.glt.entity.GltUserTicketRelease;
import com.gxwebsoft.shop.entity.ShopOrderGoods;
import com.gxwebsoft.shop.service.ShopOrderGoodsService;
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.HashSet;
import java.util.List;
import java.util.Set;
/**
* 水票撤销(订单取消/退款成功后的清理):
* - 取消/隐藏用户水票glt_user_ticket.deleted=1
* - 删除未完成的释放计划glt_user_ticket_release.deleted=1, status!=1
* - 删除未完成的送水订单glt_ticket_order.deleted=1, delivery_status!=40
*
* <p>说明:该操作需保证幂等;若无关联水票则无任何副作用。</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class GltTicketRevokeService {
/** release.status已释放 */
private static final int RELEASE_STATUS_DONE = 1;
/** ticketOrder.deliveryStatus已完成 */
private static final int TICKET_ORDER_DELIVERY_STATUS_FINISHED = 40;
private final GltUserTicketService gltUserTicketService;
private final GltUserTicketReleaseService gltUserTicketReleaseService;
private final GltTicketOrderService gltTicketOrderService;
private final ShopOrderGoodsService shopOrderGoodsService;
@Transactional(rollbackFor = Exception.class)
public int revokeByShopOrder(Integer tenantId, Integer shopOrderId, String shopOrderNo, String reason) {
if (tenantId == null) {
return 0;
}
if (shopOrderId == null && StrUtil.isBlank(shopOrderNo)) {
return 0;
}
LambdaQueryWrapper<GltUserTicket> qw = new LambdaQueryWrapper<GltUserTicket>()
.eq(GltUserTicket::getTenantId, tenantId)
.eq(GltUserTicket::getDeleted, 0);
if (shopOrderId != null && StrUtil.isNotBlank(shopOrderNo)) {
qw.and(w -> w.eq(GltUserTicket::getOrderId, shopOrderId).or().eq(GltUserTicket::getOrderNo, shopOrderNo));
} else if (shopOrderId != null) {
qw.eq(GltUserTicket::getOrderId, shopOrderId);
} else {
qw.eq(GltUserTicket::getOrderNo, shopOrderNo);
}
List<GltUserTicket> tickets = gltUserTicketService.list(qw);
// 兼容历史数据:部分水票只记录了 orderGoodsId未记录 orderId/orderNo
if ((tickets == null || tickets.isEmpty()) && shopOrderId != null) {
try {
List<ShopOrderGoods> goodsList = shopOrderGoodsService.list(
new LambdaQueryWrapper<ShopOrderGoods>()
.select(ShopOrderGoods::getId)
.eq(ShopOrderGoods::getTenantId, tenantId)
.eq(ShopOrderGoods::getOrderId, shopOrderId)
);
List<Integer> orderGoodsIds = goodsList == null ? List.of() : goodsList.stream()
.map(ShopOrderGoods::getId)
.filter(id -> id != null && id > 0)
.distinct()
.toList();
if (!orderGoodsIds.isEmpty()) {
tickets = gltUserTicketService.list(
new LambdaQueryWrapper<GltUserTicket>()
.eq(GltUserTicket::getTenantId, tenantId)
.eq(GltUserTicket::getDeleted, 0)
.in(GltUserTicket::getOrderGoodsId, orderGoodsIds)
);
}
} catch (Exception e) {
log.warn("撤销水票通过orderGoodsId兜底反查失败 - tenantId={}, shopOrderId={}", tenantId, shopOrderId, e);
}
}
if (tickets == null || tickets.isEmpty()) {
return 0;
}
LocalDateTime now = LocalDateTime.now();
int revoked = 0;
// 不强制覆盖 comments避免影响后台人工备注reason 仅用于日志。
String reasonForLog = StrUtil.isBlank(reason) ? "订单取消/退款撤销水票" : reason.trim();
// 去重(避免 orderId/orderNo 与 orderGoodsId 两种路径重复命中)
Set<Integer> seen = new HashSet<>();
for (GltUserTicket t : tickets) {
if (t == null || t.getId() == null) {
continue;
}
if (!seen.add(t.getId())) {
continue;
}
Integer userTicketId = t.getId();
// 1) 删除未完成的送水订单(避免继续配送/接单/确认)
try {
LambdaUpdateWrapper<GltTicketOrder> uw = new LambdaUpdateWrapper<GltTicketOrder>()
.eq(GltTicketOrder::getTenantId, tenantId)
.eq(GltTicketOrder::getDeleted, 0)
.eq(GltTicketOrder::getUserTicketId, userTicketId)
// 兼容历史/脏数据deliveryStatus 为空时也按“未完成”处理
.and(w -> w.ne(GltTicketOrder::getDeliveryStatus, TICKET_ORDER_DELIVERY_STATUS_FINISHED)
.or().isNull(GltTicketOrder::getDeliveryStatus))
.set(GltTicketOrder::getDeleted, 1)
.set(GltTicketOrder::getUpdateTime, now);
gltTicketOrderService.update(null, uw);
} catch (Exception e) {
log.warn("撤销送水订单失败(继续尝试撤销水票/释放计划) - tenantId={}, shopOrderId={}, userTicketId={}",
tenantId, shopOrderId, userTicketId, e);
}
// 2) 删除未完成的释放计划(防止后续继续自动释放)
try {
LambdaUpdateWrapper<GltUserTicketRelease> uw = new LambdaUpdateWrapper<GltUserTicketRelease>()
.eq(GltUserTicketRelease::getTenantId, tenantId)
.eq(GltUserTicketRelease::getDeleted, 0)
.eq(GltUserTicketRelease::getUserTicketId, userTicketId.longValue())
// status 为空时也视为“未完成”
.and(w -> w.ne(GltUserTicketRelease::getStatus, RELEASE_STATUS_DONE)
.or().isNull(GltUserTicketRelease::getStatus))
.set(GltUserTicketRelease::getDeleted, 1)
.set(GltUserTicketRelease::getUpdateTime, now);
gltUserTicketReleaseService.update(null, uw);
} catch (Exception e) {
log.warn("撤销水票释放计划失败(继续尝试撤销水票) - tenantId={}, shopOrderId={}, userTicketId={}",
tenantId, shopOrderId, userTicketId, e);
}
// 3) 撤销水票本身(软删除;幂等)
boolean ok = gltUserTicketService.update(
null,
new LambdaUpdateWrapper<GltUserTicket>()
.eq(GltUserTicket::getTenantId, tenantId)
.eq(GltUserTicket::getDeleted, 0)
.eq(GltUserTicket::getId, userTicketId)
.set(GltUserTicket::getDeleted, 1)
.set(GltUserTicket::getUpdateTime, now)
);
if (ok) {
revoked++;
}
}
log.info("撤销水票完成 - tenantId={}, shopOrderId={}, shopOrderNo={}, tickets={}, reason={}",
tenantId, shopOrderId, shopOrderNo, revoked, reasonForLog);
return revoked;
}
}

View File

@@ -54,6 +54,7 @@ public class GltTicketOrderServiceImpl extends ServiceImpl<GltTicketOrderMapper,
private static final BigDecimal RIDER_UNIT_COMMISSION = new BigDecimal("0.10");
private static final int RIDER_COMMISSION_SCALE = 2;
private static final int TENANT_ID_10584 = 10584;
private static final DateTimeFormatter SEND_TIME_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
@Resource
private GltUserTicketMapper gltUserTicketMapper;
@@ -155,6 +156,8 @@ public class GltTicketOrderServiceImpl extends ServiceImpl<GltTicketOrderMapper,
gltTicketOrder.setStatus(0);
gltTicketOrder.setDeleted(0);
gltTicketOrder.setTenantId(tenantId);
// 关联商城订单号(用于后台/对账/追踪);优先取水票上的 orderNo缺失则按 orderId/orderGoodsId 兜底反查。
gltTicketOrder.setOrderNo(resolveShopOrderNo(userTicket, tenantId));
if (gltTicketOrder.getDeliveryStatus() == null) {
gltTicketOrder.setDeliveryStatus(DELIVERY_STATUS_WAITING);
}
@@ -164,6 +167,10 @@ public class GltTicketOrderServiceImpl extends ServiceImpl<GltTicketOrderMapper,
if (gltTicketOrder.getCreateTime() == null) {
gltTicketOrder.setCreateTime(now);
}
// “立刻送水”下单场景不再需要前端选择配送时间;若未传则默认当前时间,便于排序与派单。
if (!StringUtils.hasText(gltTicketOrder.getSendTime())) {
gltTicketOrder.setSendTime(now.format(SEND_TIME_FMT));
}
gltTicketOrder.setUpdateTime(now);
if (!this.save(gltTicketOrder)) {
throw new BusinessException("创建订单失败");
@@ -203,6 +210,43 @@ public class GltTicketOrderServiceImpl extends ServiceImpl<GltTicketOrderMapper,
return gltTicketOrder;
}
private String resolveShopOrderNo(GltUserTicket userTicket, Integer tenantId) {
if (userTicket == null || tenantId == null) {
return null;
}
if (StringUtils.hasText(userTicket.getOrderNo())) {
return userTicket.getOrderNo();
}
Integer orderId = userTicket.getOrderId();
// 兜底:历史数据可能只写了 orderGoodsId未写 orderId/orderNo
if (orderId == null && userTicket.getOrderGoodsId() != null) {
ShopOrderGoods og = shopOrderGoodsService.getOne(
new LambdaQueryWrapper<ShopOrderGoods>()
.select(ShopOrderGoods::getOrderId)
.eq(ShopOrderGoods::getTenantId, tenantId)
.eq(ShopOrderGoods::getId, userTicket.getOrderGoodsId())
.last("limit 1")
);
if (og != null) {
orderId = og.getOrderId();
}
}
if (orderId == null) {
return null;
}
ShopOrder order = shopOrderService.getOne(
new LambdaQueryWrapper<ShopOrder>()
.select(ShopOrder::getOrderNo)
.eq(ShopOrder::getTenantId, tenantId)
.eq(ShopOrder::getDeleted, 0)
.eq(ShopOrder::getOrderId, orderId)
.last("limit 1")
);
return order == null ? null : order.getOrderNo();
}
@Override
@Transactional(rollbackFor = Exception.class)
public void accept(Integer id, Integer riderId, Integer tenantId) {

View File

@@ -12,10 +12,14 @@ import com.gxwebsoft.glt.service.GltUserTicketService;
import com.gxwebsoft.shop.entity.ShopDealerCapital;
import com.gxwebsoft.shop.entity.ShopDealerOrder;
import com.gxwebsoft.shop.entity.ShopDealerUser;
import com.gxwebsoft.shop.entity.ShopGoods;
import com.gxwebsoft.shop.entity.ShopOrder;
import com.gxwebsoft.shop.entity.ShopOrderGoods;
import com.gxwebsoft.shop.service.ShopDealerCapitalService;
import com.gxwebsoft.shop.service.ShopDealerOrderService;
import com.gxwebsoft.shop.service.ShopDealerUserService;
import com.gxwebsoft.shop.service.ShopGoodsService;
import com.gxwebsoft.shop.service.ShopOrderGoodsService;
import com.gxwebsoft.shop.service.ShopOrderService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
@@ -25,6 +29,7 @@ import org.springframework.transaction.support.TransactionTemplate;
import javax.annotation.Resource;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
@@ -32,6 +37,7 @@ import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
@@ -59,12 +65,36 @@ public class DealerCommissionUnfreeze10584Task {
private static final int MAX_ELIGIBLE_TICKET_ORDERS_PER_RUN = 200;
private static final int MAX_CAPITALS_PER_RUN = 500;
private static final int FLOW_TYPE_DELIVERY_REWARD = 60; // 配送奖励(直接入可提现金额)
/**
* 兼容两种录入方式:
* - 0.05 表示 5%(比例)
* - 5 表示 5%(百分比)
*/
private static BigDecimal normalizeDeliveryRate(BigDecimal rawRate) {
if (rawRate == null || rawRate.signum() <= 0) {
return null;
}
// 如果录入 >= 1按“百分比”处理1 => 1%
if (rawRate.compareTo(BigDecimal.ONE) >= 0) {
return rawRate.movePointLeft(2);
}
return rawRate;
}
@Resource
private TransactionTemplate transactionTemplate;
@Resource
private ShopOrderService shopOrderService;
@Resource
private ShopOrderGoodsService shopOrderGoodsService;
@Resource
private ShopGoodsService shopGoodsService;
@Resource
private ShopDealerCapitalService shopDealerCapitalService;
@@ -85,7 +115,7 @@ public class DealerCommissionUnfreeze10584Task {
private final AtomicBoolean running = new AtomicBoolean(false);
@Scheduled(cron = "${dealer.commission.unfreeze10584.cron:0/30 * * * * ?}")
@Scheduled(cron = "${dealer.commission.unfreeze10584.cron:0/20 * * * * ?}")
@IgnoreTenant("定时任务无登录态,需忽略租户隔离;内部使用 tenantId=10584 精确过滤")
public void run() {
if (!running.compareAndSet(false, true)) {
@@ -110,12 +140,36 @@ public class DealerCommissionUnfreeze10584Task {
return;
}
// 配送奖励(与佣金解冻独立):按订单发放,幂等保证不会重复入账
int rewarded = 0;
for (String orderNo : eligibleOrderNos) {
try {
if (settleDeliveryRewardIfNeeded(orderNo)) {
rewarded++;
}
} catch (Exception e) {
log.error("发放配送奖励失败,将在下次任务重试 - tenantId={}, orderNo={}", TENANT_ID, orderNo, e);
}
}
List<ShopDealerCapital> capitals = findCapitalsByEligibleOrderNos(eligibleOrderNos);
if (capitals.isEmpty()) {
// 若本轮没有取到佣金明细,回退再按“最早确认收货”的订单扫一轮,尽量覆盖历史遗留未解冻。
eligibleOrderNos.clear();
eligibleOrderNos.addAll(findEligibleNonWaterOrderNos(waterFormIds, false));
eligibleOrderNos.addAll(findEligibleWaterOrderNosByFirstFinishedTicketOrder(waterFormIds));
// 兜底扫描出来的订单也补发配送奖励(幂等)
for (String orderNo : eligibleOrderNos) {
try {
if (settleDeliveryRewardIfNeeded(orderNo)) {
rewarded++;
}
} catch (Exception e) {
log.error("发放配送奖励失败,将在下次任务重试 - tenantId={}, orderNo={}", TENANT_ID, orderNo, e);
}
}
capitals = findCapitalsByEligibleOrderNos(eligibleOrderNos);
}
@@ -140,11 +194,186 @@ public class DealerCommissionUnfreeze10584Task {
log.info("分销佣金解冻完成 - tenantId={}, eligibleOrderNos={}, scannedCapitals={}, unfrozen={}",
TENANT_ID, eligibleOrderNos.size(), capitals.size(), unfrozen);
}
if (rewarded > 0) {
log.info("配送奖励发放完成 - tenantId={}, eligibleOrderNos={}, rewarded={}", TENANT_ID, eligibleOrderNos.size(), rewarded);
}
} finally {
running.set(false);
}
}
private boolean settleDeliveryRewardIfNeeded(String orderNo) {
if (orderNo == null || orderNo.isBlank()) {
return false;
}
ShopOrder order = shopOrderService.getOne(
new LambdaQueryWrapper<ShopOrder>()
.eq(ShopOrder::getTenantId, TENANT_ID)
.eq(ShopOrder::getDeleted, 0)
.eq(ShopOrder::getOrderNo, orderNo)
.last("limit 1")
);
if (order == null) {
return false;
}
Integer riderId = order.getRiderId();
if (riderId == null || riderId <= 0) {
return false;
}
// 快速幂等检查:已发放则跳过(事务内仍会二次校验避免并发重复)
boolean already = shopDealerCapitalService.count(
new LambdaQueryWrapper<ShopDealerCapital>()
.eq(ShopDealerCapital::getTenantId, TENANT_ID)
.eq(ShopDealerCapital::getFlowType, FLOW_TYPE_DELIVERY_REWARD)
.eq(ShopDealerCapital::getOrderNo, orderNo)
) > 0;
if (already) {
return false;
}
return Boolean.TRUE.equals(transactionTemplate.execute(status -> {
LocalDateTime now = LocalDateTime.now();
// 锁定配送员资金明细 marker确保并发幂等
ShopDealerCapital existedMarker = shopDealerCapitalService.getOne(
new LambdaQueryWrapper<ShopDealerCapital>()
.eq(ShopDealerCapital::getTenantId, TENANT_ID)
.eq(ShopDealerCapital::getFlowType, FLOW_TYPE_DELIVERY_REWARD)
.eq(ShopDealerCapital::getOrderNo, orderNo)
.last("limit 1 for update")
);
if (existedMarker != null) {
return false;
}
Integer orderId = order.getOrderId();
if (orderId == null) {
return false;
}
List<ShopOrderGoods> orderGoodsList = shopOrderGoodsService.list(
new LambdaQueryWrapper<ShopOrderGoods>()
.eq(ShopOrderGoods::getTenantId, TENANT_ID)
.eq(ShopOrderGoods::getOrderId, orderId)
);
if (orderGoodsList == null || orderGoodsList.isEmpty()) {
return false;
}
List<Integer> goodsIds = orderGoodsList.stream()
.map(ShopOrderGoods::getGoodsId)
.filter(Objects::nonNull)
.distinct()
.toList();
if (goodsIds.isEmpty()) {
return false;
}
Map<Integer, BigDecimal> goodsDeliveryMoneyMap = shopGoodsService.list(
new LambdaQueryWrapper<ShopGoods>()
.eq(ShopGoods::getTenantId, TENANT_ID)
.in(ShopGoods::getGoodsId, goodsIds)
).stream().collect(java.util.stream.Collectors.toMap(
ShopGoods::getGoodsId,
g -> g.getDeliveryMoney() != null ? g.getDeliveryMoney() : BigDecimal.ZERO,
(a, b) -> a
));
BigDecimal reward = BigDecimal.ZERO;
for (ShopOrderGoods og : orderGoodsList) {
Integer goodsId = og.getGoodsId();
if (goodsId == null) {
continue;
}
int qty = og.getTotalNum() == null ? 0 : og.getTotalNum();
if (qty <= 0) {
continue;
}
BigDecimal rawRate = goodsDeliveryMoneyMap.getOrDefault(goodsId, BigDecimal.ZERO);
BigDecimal rate = normalizeDeliveryRate(rawRate);
if (rate == null || rate.signum() <= 0) {
continue;
}
BigDecimal unitPrice = og.getPrice() != null ? og.getPrice() : BigDecimal.ZERO;
if (unitPrice.signum() <= 0) {
continue;
}
BigDecimal lineAmount = unitPrice.multiply(BigDecimal.valueOf(qty));
reward = reward.add(lineAmount.multiply(rate));
}
reward = reward.setScale(2, RoundingMode.HALF_UP);
if (reward.signum() <= 0) {
return false;
}
// 锁定/创建配送员分销账户
ShopDealerUser dealerUser = shopDealerUserService.getOne(
new LambdaQueryWrapper<ShopDealerUser>()
.eq(ShopDealerUser::getTenantId, TENANT_ID)
.eq(ShopDealerUser::getUserId, riderId)
.last("limit 1 for update")
);
if (dealerUser == null) {
ShopDealerUser newDealerUser = new ShopDealerUser();
newDealerUser.setTenantId(TENANT_ID);
newDealerUser.setUserId(riderId);
newDealerUser.setType(0);
newDealerUser.setIsDelete(0);
newDealerUser.setSortNumber(0);
newDealerUser.setFirstNum(0);
newDealerUser.setSecondNum(0);
newDealerUser.setThirdNum(0);
newDealerUser.setMoney(BigDecimal.ZERO);
newDealerUser.setFreezeMoney(BigDecimal.ZERO);
newDealerUser.setTotalMoney(BigDecimal.ZERO);
newDealerUser.setCreateTime(now);
newDealerUser.setUpdateTime(now);
shopDealerUserService.save(newDealerUser);
dealerUser = shopDealerUserService.getOne(
new LambdaQueryWrapper<ShopDealerUser>()
.eq(ShopDealerUser::getTenantId, TENANT_ID)
.eq(ShopDealerUser::getUserId, riderId)
.last("limit 1 for update")
);
if (dealerUser == null) {
log.warn("配送奖励入账失败:未找到/创建分销账户 - tenantId={}, orderNo={}, riderId={}", TENANT_ID, orderNo, riderId);
return false;
}
}
BigDecimal moneyVal = dealerUser.getMoney() != null ? dealerUser.getMoney() : BigDecimal.ZERO;
BigDecimal totalMoneyVal = dealerUser.getTotalMoney() != null ? dealerUser.getTotalMoney() : BigDecimal.ZERO;
dealerUser.setMoney(moneyVal.add(reward));
dealerUser.setTotalMoney(totalMoneyVal.add(reward));
dealerUser.setUpdateTime(now);
if (!shopDealerUserService.updateById(dealerUser)) {
log.warn("配送奖励入账失败:更新分销账户失败 - tenantId={}, orderNo={}, riderId={}, reward={}", TENANT_ID, orderNo, riderId, reward);
return false;
}
ShopDealerCapital cap = new ShopDealerCapital();
cap.setUserId(riderId);
cap.setOrderNo(orderNo);
cap.setFlowType(FLOW_TYPE_DELIVERY_REWARD);
cap.setMoney(reward);
cap.setComments("配送奖励");
cap.setToUserId(order.getUserId());
cap.setMonth(LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM")));
cap.setTenantId(TENANT_ID);
cap.setCreateTime(now);
cap.setUpdateTime(now);
shopDealerCapitalService.save(cap);
log.info("配送奖励发放成功 - tenantId={}, orderNo={}, riderId={}, reward={}", TENANT_ID, orderNo, riderId, reward);
return true;
}));
}
private Set<Integer> loadWaterFormIds() {
return gltTicketTemplateService.list(
new LambdaQueryWrapper<GltTicketTemplate>()

View File

@@ -3,6 +3,8 @@ package com.gxwebsoft.glt.task;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.gxwebsoft.common.core.annotation.IgnoreTenant;
import com.gxwebsoft.glt.entity.GltTicketTemplate;
import com.gxwebsoft.glt.service.GltTicketTemplateService;
import com.gxwebsoft.shop.entity.ShopDealerCapital;
import com.gxwebsoft.shop.entity.ShopDealerOrder;
import com.gxwebsoft.shop.entity.ShopDealerReferee;
@@ -87,6 +89,9 @@ public class DealerOrderSettlement10584Task {
@Resource
private UserMapper userMapper;
@Resource
private GltTicketTemplateService gltTicketTemplateService;
/**
* 每10秒执行一次。
*/
@@ -94,7 +99,8 @@ public class DealerOrderSettlement10584Task {
@IgnoreTenant("该定时任务仅处理租户10584但需要显式按tenantId过滤避免定时任务线程无租户上下文导致查询异常")
public void settleTenant10584Orders() {
try {
List<ShopOrder> orders = findUnsettledPaidOrders();
Set<Integer> waterFormIds = loadWaterFormIds();
List<ShopOrder> orders = findUnsettledPaidOrders(waterFormIds);
if (orders.isEmpty()) {
return;
}
@@ -105,7 +111,7 @@ public class DealerOrderSettlement10584Task {
DealerBasicSetting dealerBasicSetting = findDealerBasicSetting();
ShopDealerUser totalDealerUser = findTotalDealerUser();
if (totalDealerUser == null || totalDealerUser.getUserId() == null) {
log.warn("未找到总经销商账号,订单仍可结算但不会发放总经销商分润 - tenantId={}", TENANT_ID);
log.warn("未找到分红账号,订单仍可结算但不会发放分红 - tenantId={}", TENANT_ID);
}
log.debug("租户{}分销设置 - level={}", TENANT_ID, dealerBasicSetting.level);
@@ -118,7 +124,7 @@ public class DealerOrderSettlement10584Task {
try {
transactionTemplate.executeWithoutResult(status -> {
// 先“认领”订单:并发/多实例下避免重复结算update=0 表示被其他线程/实例处理)
if (!claimOrderToSettle(order.getOrderId())) {
if (!claimOrderToSettle(order.getOrderId(), waterFormIds)) {
return;
}
settleOneOrder(order, level1ParentCache, shopRoleCache, totalDealerUser, dealerBasicSetting.level);
@@ -132,28 +138,61 @@ public class DealerOrderSettlement10584Task {
}
}
private List<ShopOrder> findUnsettledPaidOrders() {
// 以确认收货为准:仅结算 deliveryStatus=20 的订单(租户10584约定)。
return shopOrderService.list(
new LambdaQueryWrapper<ShopOrder>()
private List<ShopOrder> findUnsettledPaidOrders(Set<Integer> waterFormIds) {
// 租户10584约定
// - 普通订单以发货为准deliveryStatus=20才结算
// - 绑定水票模板的订单:支付成功即可体现分润(无需等发货状态变更)。
LambdaQueryWrapper<ShopOrder> qw = new LambdaQueryWrapper<ShopOrder>()
.eq(ShopOrder::getTenantId, TENANT_ID)
.eq(ShopOrder::getDeleted, 0)
.eq(ShopOrder::getPayStatus, true)
.eq(ShopOrder::getDeliveryStatus, 20)
.eq(ShopOrder::getIsSettled, 0)
.orderByAsc(ShopOrder::getOrderId)
.last("limit " + MAX_ORDERS_PER_RUN)
);
// 退款/取消订单不结算,避免“退款后仍发放分红/分润/佣金”
.and(w -> w.notIn(ShopOrder::getOrderStatus, 2, 4, 5, 6, 7).or().isNull(ShopOrder::getOrderStatus));
if (waterFormIds != null && !waterFormIds.isEmpty()) {
qw.and(w -> w.eq(ShopOrder::getDeliveryStatus, 20).or().in(ShopOrder::getFormId, waterFormIds));
} else {
qw.eq(ShopOrder::getDeliveryStatus, 20);
}
private boolean claimOrderToSettle(Integer orderId) {
return shopOrderService.update(
new LambdaUpdateWrapper<ShopOrder>()
qw.orderByAsc(ShopOrder::getOrderId).last("limit " + MAX_ORDERS_PER_RUN);
return shopOrderService.list(qw);
}
private boolean claimOrderToSettle(Integer orderId, Set<Integer> waterFormIds) {
LambdaUpdateWrapper<ShopOrder> uw = new LambdaUpdateWrapper<ShopOrder>()
.eq(ShopOrder::getOrderId, orderId)
.eq(ShopOrder::getTenantId, TENANT_ID)
.eq(ShopOrder::getIsSettled, 0)
.set(ShopOrder::getIsSettled, 1)
);
// 二次防御:退款/取消订单不允许被“认领结算”
.and(w -> w.notIn(ShopOrder::getOrderStatus, 2, 4, 5, 6, 7).or().isNull(ShopOrder::getOrderStatus));
if (waterFormIds != null && !waterFormIds.isEmpty()) {
uw.and(w -> w.eq(ShopOrder::getDeliveryStatus, 20).or().in(ShopOrder::getFormId, waterFormIds));
} else {
uw.eq(ShopOrder::getDeliveryStatus, 20);
}
uw.set(ShopOrder::getIsSettled, 1);
return shopOrderService.update(uw);
}
private Set<Integer> loadWaterFormIds() {
try {
return gltTicketTemplateService.list(
new LambdaQueryWrapper<GltTicketTemplate>()
.eq(GltTicketTemplate::getTenantId, TENANT_ID)
.eq(GltTicketTemplate::getDeleted, 0)
.isNotNull(GltTicketTemplate::getGoodsId)
).stream()
.map(GltTicketTemplate::getGoodsId)
.filter(Objects::nonNull)
.collect(java.util.stream.Collectors.toSet());
} catch (Exception e) {
log.warn("读取水票模板goodsId失败将按普通订单规则结算 - tenantId={}", TENANT_ID, e);
return Collections.emptySet();
}
}
private void settleOneOrder(
@@ -201,10 +240,10 @@ public class DealerOrderSettlement10584Task {
// 1) 直推/间推shop_dealer_referee
DealerRefereeCommission dealerRefereeCommission = settleDealerRefereeCommission(order, baseAmount, goodsQty, commissionConfig, dealerLevel);
// 2) 门店分上级:从下单用户开始逐级向上找,命中 ShopDealerUser.type=1 的最近两级(直推门店/间推门店)。
// 2) 门店分上级:从下单用户开始逐级向上找,命中 ShopDealerUser.type=1 的最近两级(直推门店/间推门店)。
ShopRoleCommission shopRoleCommission = settleShopRoleRefereeCommission(order, baseAmount, goodsQty, commissionConfig, level1ParentCache, shopRoleCache);
// 3) 总经销商分润:固定比率,每个订单都分。
// 3) 分红:固定比率,每个订单都分。
TotalDealerCommission totalDealerCommission = settleTotalDealerCommission(order, baseAmount, goodsQty, totalDealerUser);
// 4) 写入分销订单记录(用于排查/统计;详细分佣以 ShopDealerCapital 为准)
@@ -265,7 +304,7 @@ public class DealerOrderSettlement10584Task {
directMoney,
order,
order.getUserId(),
buildCommissionComment("直推佣金", commissionConfig.commissionType, commissionConfig.dealerDirectValue, goodsQty)
buildCommissionComment("分佣", commissionConfig.commissionType, commissionConfig.dealerDirectValue, goodsQty)
);
}
if (normalizedLevel >= 2) {
@@ -320,7 +359,7 @@ public class DealerOrderSettlement10584Task {
Map<Integer, Boolean> shopRoleCache
) {
List<Integer> shopRoleReferees = findFirstTwoShopRoleReferees(order.getUserId(), level1ParentCache, shopRoleCache);
log.info("门店分命中结果(type=1门店角色取前两级) - orderNo={}, buyerUserId={}, shopRoleReferees={}",
log.info("门店分命中结果(type=1门店角色取前两级) - orderNo={}, buyerUserId={}, shopRoleReferees={}",
order.getOrderNo(), order.getUserId(), shopRoleReferees);
if (shopRoleReferees.isEmpty()) {
return ShopRoleCommission.empty();
@@ -330,14 +369,14 @@ public class DealerOrderSettlement10584Task {
// 仅找到一个门店:按(直推+间推)汇总发放
BigDecimal singleStoreValue = safeValue(commissionConfig.storeDirectValue).add(safeValue(commissionConfig.storeSimpleValue));
BigDecimal money = calcMoneyByCommissionType(baseAmount, singleStoreValue, goodsQty, DIVIDEND_SCALE, commissionConfig.commissionType);
log.info("发放(仅1门店) - orderNo={}, firstDividendUserId={}, commissionType={}, value={}, money={}",
log.info("发放(仅1门店) - orderNo={}, firstDividendUserId={}, commissionType={}, value={}, money={}",
order.getOrderNo(), shopRoleReferees.get(0), commissionConfig.commissionType, singleStoreValue, money);
creditDealerCommission(
shopRoleReferees.get(0),
money,
order,
order.getUserId(),
buildCommissionComment("门店直推佣金(仅1门店)", commissionConfig.commissionType, singleStoreValue, goodsQty)
buildCommissionComment("门店直推分润(仅1门店)", commissionConfig.commissionType, singleStoreValue, goodsQty)
);
return new ShopRoleCommission(shopRoleReferees.get(0), money, null, BigDecimal.ZERO);
}
@@ -347,7 +386,7 @@ public class DealerOrderSettlement10584Task {
calcMoneyByCommissionType(baseAmount, commissionConfig.storeDirectValue, goodsQty, DIVIDEND_SCALE, commissionConfig.commissionType);
BigDecimal storeSimpleMoney =
calcMoneyByCommissionType(baseAmount, commissionConfig.storeSimpleValue, goodsQty, DIVIDEND_SCALE, commissionConfig.commissionType);
log.info("发放(2人) - orderNo={}, firstDividendUserId={}, commissionType={}, firstValue={}, firstMoney={}, secondDividendUserId={}, secondValue={}, secondMoney={}",
log.info("发放(2人) - orderNo={}, firstDividendUserId={}, commissionType={}, firstValue={}, firstMoney={}, secondDividendUserId={}, secondValue={}, secondMoney={}",
order.getOrderNo(),
shopRoleReferees.get(0),
commissionConfig.commissionType,
@@ -361,14 +400,14 @@ public class DealerOrderSettlement10584Task {
storeDirectMoney,
order,
order.getUserId(),
buildCommissionComment("门店直推佣金", commissionConfig.commissionType, commissionConfig.storeDirectValue, goodsQty)
buildCommissionComment("门店直推分润", commissionConfig.commissionType, commissionConfig.storeDirectValue, goodsQty)
);
creditDealerCommission(
shopRoleReferees.get(1),
storeSimpleMoney,
order,
order.getUserId(),
buildCommissionComment("门店间推佣金", commissionConfig.commissionType, commissionConfig.storeSimpleValue, goodsQty)
buildCommissionComment("门店间推分润", commissionConfig.commissionType, commissionConfig.storeSimpleValue, goodsQty)
);
return new ShopRoleCommission(shopRoleReferees.get(0), storeDirectMoney, shopRoleReferees.get(1), storeSimpleMoney);
}
@@ -387,14 +426,14 @@ public class DealerOrderSettlement10584Task {
rate = TOTAL_DEALER_DIVIDEND_RATE;
}
BigDecimal money = calcMoneyByCommissionType(baseAmount, rate, goodsQty, DIVIDEND_SCALE, 20);
log.info("总经销商分润发放 - orderNo={}, totalDealerUserId={}, rate={}, money={}",
log.info("分红发放 - orderNo={}, totalDealerUserId={}, rate={}, money={}",
order.getOrderNo(), totalDealerUser.getUserId(), rate, money);
creditDealerCommission(
totalDealerUser.getUserId(),
money,
order,
order.getUserId(),
buildCommissionComment("总经销商分润", 20, rate, goodsQty)
buildCommissionComment("分红", 20, rate, goodsQty)
);
return new TotalDealerCommission(totalDealerUser.getUserId(), money);
}
@@ -451,7 +490,7 @@ public class DealerOrderSettlement10584Task {
}
/**
* 门店分规则:
* 门店分规则:
* - 门店角色为 ShopDealerUser.type=1
* - 从下单用户开始,沿 shop_dealer_referee(level=1) 链路逐级向上找;
* - 遇到第一个 type=1 用户命中为“直推门店用户”,继续向上找到第二个 type=1 用户命中为“间推门店用户”。
@@ -641,7 +680,7 @@ public class DealerOrderSettlement10584Task {
.last("limit 1")
);
if (existed != null) {
// 允许“补发”门店分时回填分字段,避免订单已结算但分字段一直为空,影响排查/对账。
// 允许“补发”门店分时回填分字段,避免订单已结算但分字段一直为空,影响排查/对账。
LambdaUpdateWrapper<ShopDealerOrder> uw = new LambdaUpdateWrapper<ShopDealerOrder>()
.eq(ShopDealerOrder::getTenantId, TENANT_ID)
.eq(ShopDealerOrder::getOrderNo, order.getOrderNo());
@@ -676,7 +715,7 @@ public class DealerOrderSettlement10584Task {
}
if (needUpdate) {
shopDealerOrderService.update(uw);
log.info("ShopDealerOrder已存在回填门店分字段 - orderNo={}, firstDividendUser={}, secondDividendUser={}",
log.info("ShopDealerOrder已存在回填门店分字段 - orderNo={}, firstDividendUser={}, secondDividendUser={}",
order.getOrderNo(), shopRoleCommission.storeDirectUserId, shopRoleCommission.storeSimpleUserId);
} else {
log.info("ShopDealerOrder已存在跳过写入 - orderNo={}", order.getOrderNo());
@@ -697,7 +736,7 @@ public class DealerOrderSettlement10584Task {
dealerOrder.setThirdUserId(dealerRefereeCommission.thirdDealerId);
dealerOrder.setThirdMoney(dealerRefereeCommission.thirdMoney);
// 门店(角色shop)两级分单独落字段(详细以 ShopDealerCapital 为准)
// 门店(角色shop)两级分单独落字段(详细以 ShopDealerCapital 为准)
dealerOrder.setFirstDividendUser(shopRoleCommission.storeDirectUserId);
dealerOrder.setFirstDividend(shopRoleCommission.storeDirectMoney);
dealerOrder.setSecondDividendUser(shopRoleCommission.storeSimpleUserId);

View File

@@ -18,7 +18,7 @@ import java.util.concurrent.atomic.AtomicBoolean;
* GLT 套票发放任务:
* - 每30秒扫描一次今日订单tenantId=10584, formId in 套票模板 goodsId, payStatus=1, orderStatus=0
* - 为订单生成用户套票账户 + 释放计划(幂等)
* - 若模板配置了 startSendQty则发放时自动核销对应数量用于“第一次送水”场景
* - 按整改需求:发放阶段不再自动核销/自动下单;“送水下单核销”由用户在履约时主动触发
*/
@Slf4j
@Component

View File

@@ -0,0 +1,54 @@
package com.gxwebsoft.glt.task;
import com.gxwebsoft.common.core.annotation.IgnoreTenant;
import com.gxwebsoft.glt.service.GltTicketOrderAutoDispatchService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* GLT 送水订单自动派单任务tenantId=10584
* - 扫描未指派配送员的待配送订单glt_ticket_order.delivery_status=10 且 rider_id 为空/0
* - 依据收货坐标(优先 ShopOrder.addressLat/addressLng 订单快照;兜底地址表 lat/lng与 配送员坐标(shop_store_rider.longitude/latitude) 计算距离,派给最近的配送员
* - 写入 rider_id配送员 userId并同步关联商城订单的 riderId/deliveryStatus复用 accept 逻辑)
*
* 默认不启用;需要在配置中显式打开:
* - glt.ticket.dispatch10584.enabled=true
*/
@Slf4j
@Component
@RequiredArgsConstructor
@ConditionalOnProperty(prefix = "glt.ticket.dispatch10584", name = "enabled", havingValue = "true", matchIfMissing = false)
public class GltTicketOrderAutoDispatch10584Task {
private static final int TENANT_ID = 10584;
private final GltTicketOrderAutoDispatchService gltTicketOrderAutoDispatchService;
private final AtomicBoolean running = new AtomicBoolean(false);
@Value("${glt.ticket.dispatch10584.batchSize:50}")
private int batchSize;
@Scheduled(cron = "${glt.ticket.dispatch10584.cron:0/20 * * * * ?}")
@IgnoreTenant("定时任务无登录态,需忽略租户隔离;内部使用 tenantId=10584 精确过滤")
public void run() {
if (!running.compareAndSet(false, true)) {
log.warn("送水订单自动派单任务仍在执行中,本轮跳过 - tenantId={}", TENANT_ID);
return;
}
try {
int n = gltTicketOrderAutoDispatchService.autoDispatchWaitingOrders(TENANT_ID, batchSize);
if (n > 0) {
log.info("送水订单自动派单完成 - tenantId={}, dispatched={}", TENANT_ID, n);
}
} finally {
running.set(false);
}
}
}

View File

@@ -0,0 +1,41 @@
package com.gxwebsoft.shop.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.List;
/**
* 历史退款订单:补偿撤销水票/释放计划/送水单 的修复请求。
*/
@Data
public class RefundedOrderGltRepairRequest implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "租户ID不传则使用当前请求租户")
private Integer tenantId;
@Schema(description = "指定订单ID列表优先使用为空则走时间窗口扫描")
private List<Integer> orderIds;
@Schema(description = "指定订单号列表(可选;为空则走时间窗口扫描)")
private List<String> orderNos;
@Schema(description = "退款时间起yyyy-MM-dd HH:mm:ss")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime refundTimeStart;
@Schema(description = "退款时间止yyyy-MM-dd HH:mm:ss")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime refundTimeEnd;
@Schema(description = "每次处理条数上限默认200")
private Integer batchSize;
@Schema(description = "是否仅预览默认true仅统计不落库")
private Boolean dryRun;
}