feat(glt): 实现送水订单配送员提成结算功能

- 修改经销商订单结算任务,按确认收货状态结算订单(deliveryStatus=20)
- 在送水订单控制器中添加配送员提成结算注释说明
- 扩展送水订单服务接口,新增超时自动确认收货方法
- 实现送水订单配送员提成结算逻辑,支持拍照上传和用户确认收货两种触发方式
- 添加配送员提成幂等处理,避免重复入账
- 创建租户10584送水订单超时自动确认收货定时任务
- 实现超时订单自动确认收货并触发配送员提成结算功能
This commit is contained in:
2026-02-07 17:56:32 +08:00
parent c0c1232768
commit 45878b9005
5 changed files with 320 additions and 1 deletions

View File

@@ -163,6 +163,7 @@ public class GltTicketOrderController extends BaseController {
Integer tenantId = getTenantId(); Integer tenantId = getTenantId();
requireActiveRider(loginUser.getUserId(), tenantId); requireActiveRider(loginUser.getUserId(), tenantId);
String sendEndImg = body == null ? null : body.getSendEndImg(); String sendEndImg = body == null ? null : body.getSendEndImg();
// 配送员提成结算:在 service 内部按“拍照上传/用户确认收货”规则幂等处理。
gltTicketOrderService.delivered(id, loginUser.getUserId(), tenantId, sendEndImg); gltTicketOrderService.delivered(id, loginUser.getUserId(), tenantId, sendEndImg);
return success("确认送达"); return success("确认送达");
} }
@@ -175,6 +176,7 @@ public class GltTicketOrderController extends BaseController {
if (loginUser == null) { if (loginUser == null) {
return fail("请先登录"); return fail("请先登录");
} }
// 配送员提成结算:在 service 内部按规则幂等处理。
gltTicketOrderService.confirmReceive(id, loginUser.getUserId(), getTenantId()); gltTicketOrderService.confirmReceive(id, loginUser.getUserId(), getTenantId());
return success("确认收货成功"); return success("确认收货成功");
} }

View File

@@ -6,6 +6,7 @@ import com.gxwebsoft.glt.entity.GltTicketOrder;
import com.gxwebsoft.glt.param.GltTicketOrderParam; import com.gxwebsoft.glt.param.GltTicketOrderParam;
import java.util.List; import java.util.List;
import java.time.LocalDateTime;
/** /**
* 送水订单Service * 送水订单Service
@@ -78,4 +79,17 @@ public interface GltTicketOrderService extends IService<GltTicketOrder> {
*/ */
void confirmReceive(Integer id, Integer userId, Integer tenantId); void confirmReceive(Integer id, Integer userId, Integer tenantId);
/**
* 超时自动确认收货:
* - 扫描已送达待确认(30)且送达时间(sendEndTime)超过指定小时数的订单
* - 自动置为已完成(40),并写 receiveConfirmTime / receiveConfirmType=30
*
* @param tenantId 租户ID
* @param now 当前时间
* @param timeoutHours 超时小时数如24
* @param batchSize 每次处理条数上限
* @return 本次自动确认条数
*/
int autoConfirmTimeout(Integer tenantId, LocalDateTime now, int timeoutHours, int batchSize);
} }

View File

@@ -5,6 +5,8 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.gxwebsoft.common.core.exception.BusinessException; import com.gxwebsoft.common.core.exception.BusinessException;
import com.gxwebsoft.common.core.web.PageParam; import com.gxwebsoft.common.core.web.PageParam;
import com.gxwebsoft.common.core.web.PageResult; import com.gxwebsoft.common.core.web.PageResult;
import com.gxwebsoft.common.system.entity.User;
import com.gxwebsoft.common.system.mapper.UserMapper;
import com.gxwebsoft.glt.entity.GltTicketOrder; import com.gxwebsoft.glt.entity.GltTicketOrder;
import com.gxwebsoft.glt.entity.GltUserTicket; import com.gxwebsoft.glt.entity.GltUserTicket;
import com.gxwebsoft.glt.entity.GltUserTicketLog; import com.gxwebsoft.glt.entity.GltUserTicketLog;
@@ -14,11 +16,21 @@ import com.gxwebsoft.glt.param.GltTicketOrderParam;
import com.gxwebsoft.glt.service.GltTicketOrderService; import com.gxwebsoft.glt.service.GltTicketOrderService;
import com.gxwebsoft.glt.service.GltUserTicketLogService; import com.gxwebsoft.glt.service.GltUserTicketLogService;
import com.gxwebsoft.glt.service.GltUserTicketService; import com.gxwebsoft.glt.service.GltUserTicketService;
import com.gxwebsoft.shop.entity.ShopDealerCapital;
import com.gxwebsoft.shop.entity.ShopDealerUser;
import com.gxwebsoft.shop.service.ShopDealerCapitalService;
import com.gxwebsoft.shop.service.ShopDealerUserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
import javax.annotation.Resource; import javax.annotation.Resource;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
@@ -28,10 +40,14 @@ import java.util.List;
* @author 科技小王子 * @author 科技小王子
* @since 2026-02-05 18:50:20 * @since 2026-02-05 18:50:20
*/ */
@Slf4j
@Service @Service
public class GltTicketOrderServiceImpl extends ServiceImpl<GltTicketOrderMapper, GltTicketOrder> implements GltTicketOrderService { public class GltTicketOrderServiceImpl extends ServiceImpl<GltTicketOrderMapper, GltTicketOrder> implements GltTicketOrderService {
public static final int CHANGE_TYPE_WRITE_OFF = 20; public static final int CHANGE_TYPE_WRITE_OFF = 20;
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;
@Resource @Resource
private GltUserTicketMapper gltUserTicketMapper; private GltUserTicketMapper gltUserTicketMapper;
@@ -42,6 +58,18 @@ public class GltTicketOrderServiceImpl extends ServiceImpl<GltTicketOrderMapper,
@Resource @Resource
private GltUserTicketLogService gltUserTicketLogService; private GltUserTicketLogService gltUserTicketLogService;
@Resource
private TransactionTemplate transactionTemplate;
@Resource
private ShopDealerUserService shopDealerUserService;
@Resource
private ShopDealerCapitalService shopDealerCapitalService;
@Resource
private UserMapper userMapper;
@Override @Override
public PageResult<GltTicketOrder> pageRel(GltTicketOrderParam param) { public PageResult<GltTicketOrder> pageRel(GltTicketOrderParam param) {
PageParam<GltTicketOrder, GltTicketOrderParam> page = new PageParam<>(param); PageParam<GltTicketOrder, GltTicketOrderParam> page = new PageParam<>(param);
@@ -252,6 +280,7 @@ public class GltTicketOrderServiceImpl extends ServiceImpl<GltTicketOrderMapper,
} }
@Override @Override
@Transactional(rollbackFor = Exception.class)
public void delivered(Integer id, Integer riderId, Integer tenantId, String sendEndImg) { public void delivered(Integer id, Integer riderId, Integer tenantId, String sendEndImg) {
if (id == null) { if (id == null) {
throw new BusinessException("订单id不能为空"); throw new BusinessException("订单id不能为空");
@@ -279,6 +308,8 @@ public class GltTicketOrderServiceImpl extends ServiceImpl<GltTicketOrderMapper,
.eq(GltTicketOrder::getDeliveryStatus, DELIVERY_STATUS_DELIVERING) .eq(GltTicketOrder::getDeliveryStatus, DELIVERY_STATUS_DELIVERING)
.update(); .update();
if (ok) { if (ok) {
// 配送员拍照上传送达后可触发提成结算(幂等,重复调用不会重复入账)
settleRiderCommissionIfEligible(id, tenantId, true);
return; return;
} }
@@ -297,12 +328,14 @@ public class GltTicketOrderServiceImpl extends ServiceImpl<GltTicketOrderMapper,
&& (order.getDeliveryStatus() == DELIVERY_STATUS_WAIT_CONFIRM && (order.getDeliveryStatus() == DELIVERY_STATUS_WAIT_CONFIRM
|| order.getDeliveryStatus() == DELIVERY_STATUS_FINISHED)) { || order.getDeliveryStatus() == DELIVERY_STATUS_FINISHED)) {
// 幂等:重复送达视为成功 // 幂等:重复送达视为成功
settleRiderCommissionIfEligible(id, tenantId, true);
return; return;
} }
throw new BusinessException("订单状态不允许确认送达"); throw new BusinessException("订单状态不允许确认送达");
} }
@Override @Override
@Transactional(rollbackFor = Exception.class)
public void confirmReceive(Integer id, Integer userId, Integer tenantId) { public void confirmReceive(Integer id, Integer userId, Integer tenantId) {
if (id == null) { if (id == null) {
throw new BusinessException("订单id不能为空"); throw new BusinessException("订单id不能为空");
@@ -327,6 +360,8 @@ public class GltTicketOrderServiceImpl extends ServiceImpl<GltTicketOrderMapper,
.eq(GltTicketOrder::getDeliveryStatus, DELIVERY_STATUS_WAIT_CONFIRM) .eq(GltTicketOrder::getDeliveryStatus, DELIVERY_STATUS_WAIT_CONFIRM)
.update(); .update();
if (ok) { if (ok) {
// 用户确认收货完成后触发配送员提成结算(幂等,重复调用不会重复入账)
settleRiderCommissionIfEligible(id, tenantId, false);
return; return;
} }
@@ -343,9 +378,220 @@ public class GltTicketOrderServiceImpl extends ServiceImpl<GltTicketOrderMapper,
} }
if (order.getDeliveryStatus() != null && order.getDeliveryStatus() == DELIVERY_STATUS_FINISHED) { if (order.getDeliveryStatus() != null && order.getDeliveryStatus() == DELIVERY_STATUS_FINISHED) {
// 幂等:重复确认收货视为成功 // 幂等:重复确认收货视为成功
settleRiderCommissionIfEligible(id, tenantId, false);
return; return;
} }
throw new BusinessException("订单状态不允许确认收货"); throw new BusinessException("订单状态不允许确认收货");
} }
@Override
public int autoConfirmTimeout(Integer tenantId, LocalDateTime now, int timeoutHours, int batchSize) {
if (tenantId == null) {
return 0;
}
if (now == null) {
now = LocalDateTime.now();
}
int hours = Math.max(timeoutHours, 1);
int limit = Math.max(batchSize, 1);
LocalDateTime deadline = now.minusHours(hours);
List<GltTicketOrder> candidates = this.lambdaQuery()
.select(GltTicketOrder::getId)
.eq(GltTicketOrder::getTenantId, tenantId)
.eq(GltTicketOrder::getDeleted, 0)
.eq(GltTicketOrder::getDeliveryStatus, DELIVERY_STATUS_WAIT_CONFIRM)
.isNotNull(GltTicketOrder::getSendEndTime)
.le(GltTicketOrder::getSendEndTime, deadline)
.orderByAsc(GltTicketOrder::getSendEndTime)
.orderByAsc(GltTicketOrder::getId)
.last("limit " + limit)
.list();
if (candidates == null || candidates.isEmpty()) {
return 0;
}
int confirmed = 0;
for (GltTicketOrder item : candidates) {
Integer id = item != null ? item.getId() : null;
if (id == null) {
continue;
}
try {
final LocalDateTime nowFinal = now;
final LocalDateTime deadlineFinal = deadline;
Boolean ok = transactionTemplate.execute(status -> {
boolean updated = this.lambdaUpdate()
.set(GltTicketOrder::getDeliveryStatus, DELIVERY_STATUS_FINISHED)
.set(GltTicketOrder::getReceiveConfirmTime, nowFinal)
.set(GltTicketOrder::getReceiveConfirmType, RECEIVE_CONFIRM_TYPE_TIMEOUT)
.set(GltTicketOrder::getUpdateTime, nowFinal)
.eq(GltTicketOrder::getId, id)
.eq(GltTicketOrder::getTenantId, tenantId)
.eq(GltTicketOrder::getDeleted, 0)
.eq(GltTicketOrder::getDeliveryStatus, DELIVERY_STATUS_WAIT_CONFIRM)
.le(GltTicketOrder::getSendEndTime, deadlineFinal)
.update();
if (!updated) {
return false;
}
// 超时自动确认收货后,也按“完成”逻辑触发配送员提成结算(幂等)。
settleRiderCommissionIfEligible(id, tenantId, false);
return true;
});
if (Boolean.TRUE.equals(ok)) {
confirmed++;
}
} catch (Exception e) {
log.warn("送水订单超时自动确认收货失败 - tenantId={}, ticketOrderId={}", tenantId, id, e);
}
}
return confirmed;
}
private void settleRiderCommissionIfEligible(Integer ticketOrderId, Integer tenantId, boolean requirePhoto) {
if (ticketOrderId == null || tenantId == null) {
return;
}
// 目前仅租户10584启用该提成规则避免影响其他租户历史逻辑。
if (tenantId != TENANT_ID_10584) {
return;
}
transactionTemplate.executeWithoutResult(status -> {
// 锁定送水订单行:避免并发下重复结算(如:配送员送达&用户确认收货同时触发)
GltTicketOrder order = this.lambdaQuery()
.eq(GltTicketOrder::getId, ticketOrderId)
.eq(GltTicketOrder::getTenantId, tenantId)
.eq(GltTicketOrder::getDeleted, 0)
.last("limit 1 for update")
.one();
if (order == null) {
return;
}
Integer riderId = order.getRiderId();
if (riderId == null || riderId <= 0) {
return;
}
Integer deliveryStatus = order.getDeliveryStatus();
if (requirePhoto) {
// 配送员拍照上传触发:至少需要到“待客户确认”或“已完成”状态,且存在送达照片。
if (deliveryStatus == null || (deliveryStatus != DELIVERY_STATUS_WAIT_CONFIRM && deliveryStatus != DELIVERY_STATUS_FINISHED)) {
return;
}
if (!StringUtils.hasText(order.getSendEndImg())) {
return;
}
} else {
// 用户确认收货触发:必须为“已完成”状态。
if (deliveryStatus == null || deliveryStatus != DELIVERY_STATUS_FINISHED) {
return;
}
}
int qty = order.getTotalNum() == null ? 0 : order.getTotalNum();
if (qty <= 0) {
return;
}
BigDecimal money = RIDER_UNIT_COMMISSION
.multiply(BigDecimal.valueOf(qty))
.setScale(RIDER_COMMISSION_SCALE, RoundingMode.HALF_UP);
if (money.signum() <= 0) {
return;
}
String orderNo = "gltTicketOrder:" + order.getId();
String comments = "配送员提成(ticketOrderId=" + order.getId() + ",unit=" + RIDER_UNIT_COMMISSION + ",qty=" + qty + ")";
// 幂等:同一送水订单同一配送员只结算一次
boolean already = shopDealerCapitalService.count(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<ShopDealerCapital>()
.eq(ShopDealerCapital::getTenantId, tenantId)
.eq(ShopDealerCapital::getFlowType, 10)
.eq(ShopDealerCapital::getUserId, riderId)
.eq(ShopDealerCapital::getOrderNo, orderNo)
.likeRight(ShopDealerCapital::getComments, "配送员提成(ticketOrderId=" + order.getId() + ",")
) > 0;
if (already) {
return;
}
// 送水订单提成:先入冻结金额 freeze_money与分销订单佣金一致
LocalDateTime now = LocalDateTime.now();
boolean updated = shopDealerUserService.update(
new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<ShopDealerUser>()
.eq(ShopDealerUser::getTenantId, tenantId)
.eq(ShopDealerUser::getUserId, riderId)
.setSql("freeze_money = IFNULL(freeze_money,0) + " + money.toPlainString())
.setSql("total_money = IFNULL(total_money,0) + " + money.toPlainString())
.set(ShopDealerUser::getUpdateTime, now)
);
if (!updated) {
// 配送员可能未开通分销账户:创建后再尝试入账一次(与分销结算逻辑保持一致)
ShopDealerUser existed = shopDealerUserService.getOne(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<ShopDealerUser>()
.eq(ShopDealerUser::getTenantId, tenantId)
.eq(ShopDealerUser::getUserId, riderId)
.last("limit 1")
);
if (existed == null) {
ShopDealerUser newDealerUser = new ShopDealerUser();
newDealerUser.setTenantId(tenantId);
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);
try {
User sysUser = userMapper.selectByIdIgnoreTenant(riderId);
if (sysUser != null) {
newDealerUser.setRealName(sysUser.getRealName() != null ? sysUser.getRealName() : sysUser.getNickname());
newDealerUser.setMobile(sysUser.getPhone());
}
} catch (Exception ignore) {
// 基础信息补齐失败不影响入账
}
newDealerUser.setCreateTime(now);
newDealerUser.setUpdateTime(now);
shopDealerUserService.save(newDealerUser);
}
updated = shopDealerUserService.update(
new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<ShopDealerUser>()
.eq(ShopDealerUser::getTenantId, tenantId)
.eq(ShopDealerUser::getUserId, riderId)
.setSql("freeze_money = IFNULL(freeze_money,0) + " + money.toPlainString())
.setSql("total_money = IFNULL(total_money,0) + " + money.toPlainString())
.set(ShopDealerUser::getUpdateTime, now)
);
if (!updated) {
log.warn("配送员提成入账失败:未找到/创建分销账户 - tenantId={}, ticketOrderId={}, riderId={}", tenantId, order.getId(), riderId);
return;
}
}
ShopDealerCapital cap = new ShopDealerCapital();
cap.setUserId(riderId);
cap.setOrderNo(orderNo);
cap.setFlowType(10);
cap.setMoney(money);
cap.setComments(comments);
cap.setToUserId(order.getUserId());
cap.setMonth(LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM")));
cap.setTenantId(tenantId);
cap.setCreateTime(now);
cap.setUpdateTime(now);
shopDealerCapitalService.save(cap);
});
}
} }

View File

@@ -133,12 +133,13 @@ public class DealerOrderSettlement10584Task {
} }
private List<ShopOrder> findUnsettledPaidOrders() { private List<ShopOrder> findUnsettledPaidOrders() {
// 订单付款成功即触发分佣:本任务会将佣金先计入 ShopDealerUser.freezeMoney // 以确认收货为准:仅结算 deliveryStatus=20 的订单租户10584约定
return shopOrderService.list( return shopOrderService.list(
new LambdaQueryWrapper<ShopOrder>() new LambdaQueryWrapper<ShopOrder>()
.eq(ShopOrder::getTenantId, TENANT_ID) .eq(ShopOrder::getTenantId, TENANT_ID)
.eq(ShopOrder::getDeleted, 0) .eq(ShopOrder::getDeleted, 0)
.eq(ShopOrder::getPayStatus, true) .eq(ShopOrder::getPayStatus, true)
.eq(ShopOrder::getDeliveryStatus, 20)
.eq(ShopOrder::getIsSettled, 0) .eq(ShopOrder::getIsSettled, 0)
.orderByAsc(ShopOrder::getOrderId) .orderByAsc(ShopOrder::getOrderId)
.last("limit " + MAX_ORDERS_PER_RUN) .last("limit " + MAX_ORDERS_PER_RUN)

View File

@@ -0,0 +1,56 @@
package com.gxwebsoft.glt.task;
import com.gxwebsoft.common.core.annotation.IgnoreTenant;
import com.gxwebsoft.glt.service.GltTicketOrderService;
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.time.LocalDateTime;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* 租户10584送水订单超时自动确认收货任务
*
* <p>扫描已送达待确认(30)且送达时间超过24小时的订单自动置为已完成(40)。</p>
* <p>自动确认后会触发配送员提成结算(幂等)。</p>
*/
@Slf4j
@Component
@RequiredArgsConstructor
@ConditionalOnProperty(prefix = "glt.ticket-order.auto-confirm10584", name = "enabled", havingValue = "true", matchIfMissing = true)
public class GltTicketOrderAutoConfirm10584Task {
private static final int TENANT_ID = 10584;
private static final int TIMEOUT_HOURS = 24;
private final GltTicketOrderService gltTicketOrderService;
@Value("${glt.ticket-order.auto-confirm10584.batch-size:200}")
private int batchSize;
private final AtomicBoolean running = new AtomicBoolean(false);
@Scheduled(cron = "${glt.ticket-order.auto-confirm10584.cron:0 */1 * * * ?}")
@IgnoreTenant("定时任务无登录态,需忽略租户隔离;内部使用 tenantId=10584 精确过滤")
public void run() {
if (!running.compareAndSet(false, true)) {
log.warn("送水订单超时自动确认任务仍在执行中,本轮跳过 - tenantId={}", TENANT_ID);
return;
}
try {
LocalDateTime now = LocalDateTime.now();
int confirmed = gltTicketOrderService.autoConfirmTimeout(TENANT_ID, now, TIMEOUT_HOURS, Math.max(batchSize, 1));
if (confirmed > 0) {
log.info("送水订单超时自动确认完成 - tenantId={}, confirmed={}, now={}", TENANT_ID, confirmed, now);
}
} finally {
running.set(false);
}
}
}