diff --git a/src/main/java/com/gxwebsoft/glt/controller/GltTicketOrderController.java b/src/main/java/com/gxwebsoft/glt/controller/GltTicketOrderController.java index 4502142..7fa519e 100644 --- a/src/main/java/com/gxwebsoft/glt/controller/GltTicketOrderController.java +++ b/src/main/java/com/gxwebsoft/glt/controller/GltTicketOrderController.java @@ -52,6 +52,34 @@ public class GltTicketOrderController extends BaseController { return success(gltTicketOrderService.pageRel(param)); } + @PreAuthorize("isAuthenticated()") + @Operation(summary = "配送员端:分页查询可接单的送水订单") + @GetMapping("/rider/available") + public ApiResult> 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 (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)) { - Integer tenantId = getTenantId(); // 后台指派配送员(直接改 riderId)时,同步商城订单为“已发货”(deliveryStatus=20) if (gltTicketOrder != null && gltTicketOrder.getId() != null diff --git a/src/main/java/com/gxwebsoft/glt/controller/GltTicketTemplateController.java b/src/main/java/com/gxwebsoft/glt/controller/GltTicketTemplateController.java index ae7dce9..fb07151 100644 --- a/src/main/java/com/gxwebsoft/glt/controller/GltTicketTemplateController.java +++ b/src/main/java/com/gxwebsoft/glt/controller/GltTicketTemplateController.java @@ -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 get(@PathVariable("id") Integer id) { diff --git a/src/main/java/com/gxwebsoft/glt/entity/GltTicketOrder.java b/src/main/java/com/gxwebsoft/glt/entity/GltTicketOrder.java index 9038bb1..17bb743 100644 --- a/src/main/java/com/gxwebsoft/glt/entity/GltTicketOrder.java +++ b/src/main/java/com/gxwebsoft/glt/entity/GltTicketOrder.java @@ -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; diff --git a/src/main/java/com/gxwebsoft/glt/entity/GltTicketTemplate.java b/src/main/java/com/gxwebsoft/glt/entity/GltTicketTemplate.java index 4e4f941..95338a9 100644 --- a/src/main/java/com/gxwebsoft/glt/entity/GltTicketTemplate.java +++ b/src/main/java/com/gxwebsoft/glt/entity/GltTicketTemplate.java @@ -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; diff --git a/src/main/java/com/gxwebsoft/glt/entity/GltUserTicket.java b/src/main/java/com/gxwebsoft/glt/entity/GltUserTicket.java index 0448d2a..4a6e8a4 100644 --- a/src/main/java/com/gxwebsoft/glt/entity/GltUserTicket.java +++ b/src/main/java/com/gxwebsoft/glt/entity/GltUserTicket.java @@ -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; diff --git a/src/main/java/com/gxwebsoft/glt/mapper/xml/GltTicketOrderMapper.xml b/src/main/java/com/gxwebsoft/glt/mapper/xml/GltTicketOrderMapper.xml index 91312a4..f7efe52 100644 --- a/src/main/java/com/gxwebsoft/glt/mapper/xml/GltTicketOrderMapper.xml +++ b/src/main/java/com/gxwebsoft/glt/mapper/xml/GltTicketOrderMapper.xml @@ -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,7 +32,12 @@ AND a.store_id = #{param.storeId} - AND a.rider_id = #{param.riderId} + + AND (a.rider_id IS NULL OR a.rider_id = 0) + + + AND a.rider_id = #{param.riderId} + AND a.delivery_status = #{param.deliveryStatus} diff --git a/src/main/java/com/gxwebsoft/glt/mapper/xml/GltUserTicketMapper.xml b/src/main/java/com/gxwebsoft/glt/mapper/xml/GltUserTicketMapper.xml index c0f6ef0..7ba5daf 100644 --- a/src/main/java/com/gxwebsoft/glt/mapper/xml/GltUserTicketMapper.xml +++ b/src/main/java/com/gxwebsoft/glt/mapper/xml/GltUserTicketMapper.xml @@ -4,12 +4,12 @@ - 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 - 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 AND a.id = #{param.id} @@ -26,6 +26,9 @@ AND a.order_no LIKE CONCAT('%', #{param.orderNo}, '%') + + AND o.order_status = #{param.orderStatus} + AND a.order_goods_id = #{param.orderGoodsId} diff --git a/src/main/java/com/gxwebsoft/glt/param/GltTicketOrderParam.java b/src/main/java/com/gxwebsoft/glt/param/GltTicketOrderParam.java index 976a20b..963d775 100644 --- a/src/main/java/com/gxwebsoft/glt/param/GltTicketOrderParam.java +++ b/src/main/java/com/gxwebsoft/glt/param/GltTicketOrderParam.java @@ -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; + } diff --git a/src/main/java/com/gxwebsoft/glt/param/GltUserTicketParam.java b/src/main/java/com/gxwebsoft/glt/param/GltUserTicketParam.java index fa448a7..93873e7 100644 --- a/src/main/java/com/gxwebsoft/glt/param/GltUserTicketParam.java +++ b/src/main/java/com/gxwebsoft/glt/param/GltUserTicketParam.java @@ -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; diff --git a/src/main/java/com/gxwebsoft/glt/service/GltTicketIssueService.java b/src/main/java/com/gxwebsoft/glt/service/GltTicketIssueService.java index 2751277..5b4ccae 100644 --- a/src/main/java/com/gxwebsoft/glt/service/GltTicketIssueService.java +++ b/src/main/java/com/gxwebsoft/glt/service/GltTicketIssueService.java @@ -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() .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() + .eq(ShopOrder::getOrderId, order.getOrderId()) + .eq(ShopOrder::getTenantId, tenantId) + .last("for update") + ); + } + + // 同一商品允许存在多条模板记录(历史数据/误操作)。为避免取到“错误模板”,这里做确定性排序取第一条。 + // 排序规则与后台 getByGoodsId 保持一致:sortNumber 越小越靠前,其次取最新创建时间。 GltTicketTemplate template = gltTicketTemplateService.getOne( new LambdaQueryWrapper() .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 releases = buildReleasePlan(template, userTicket, baseTime, giftQty, now); + int planQty = useReleasePeriods ? totalQty : giftQty; + List 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()); - } - - // 起始送水:自动核销成功后,生成一条送水订单(用于配送端/后台跟踪) - 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("套票模板配置了 startSendQty,但不再自动送水/自动核销 - tenantId={}, orderNo={}, templateId={}, userTicketId={}, startSendQty={}", + tenantId, order.getOrderNo(), template.getId(), userTicket.getId(), startSendQty); } - 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() - .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; - } } diff --git a/src/main/java/com/gxwebsoft/glt/service/GltTicketOrderAutoDispatchService.java b/src/main/java/com/gxwebsoft/glt/service/GltTicketOrderAutoDispatchService.java new file mode 100644 index 0000000..b6d7842 --- /dev/null +++ b/src/main/java/com/gxwebsoft/glt/service/GltTicketOrderAutoDispatchService.java @@ -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 waiting = gltTicketOrderService.list( + new LambdaQueryWrapper() + .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 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 riders = shopStoreRiderService.list( + new LambdaQueryWrapper() + .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 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 riderUserIds = candidates.stream().map(ShopStoreRider::getUserId).distinct().toList(); + Map onhand = loadOnhandCounts(tenantId, riderUserIds); + return new CandidatePool(candidates, onhand); + } + + private Map loadOnhandCounts(Integer tenantId, List riderUserIds) { + if (riderUserIds == null || riderUserIds.isEmpty()) { + return new HashMap<>(); + } + + // 统计各配送员“未完成”的在途单数:10/20/30 + QueryWrapper 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 map = new HashMap<>(); + List> rows = gltTicketOrderService.listMaps(qw); + if (rows == null || rows.isEmpty()) { + return map; + } + for (Map 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() + .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() + .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() + .select(ShopOrderGoods::getOrderId) + .eq(ShopOrderGoods::getTenantId, tenantId) + .eq(ShopOrderGoods::getId, userTicket.getOrderGoodsId()) + .last("limit 1")); + if (og != null) { + shopOrderId = og.getOrderId(); + } + } + + LambdaQueryWrapper qw = new LambdaQueryWrapper() + .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 candidates; + final Map onhandCount; + + CandidatePool(List candidates, Map onhandCount) { + this.candidates = candidates; + this.onhandCount = new HashMap<>(onhandCount == null ? Map.of() : onhandCount); + } + } +} diff --git a/src/main/java/com/gxwebsoft/glt/service/GltTicketRevokeService.java b/src/main/java/com/gxwebsoft/glt/service/GltTicketRevokeService.java new file mode 100644 index 0000000..196be88 --- /dev/null +++ b/src/main/java/com/gxwebsoft/glt/service/GltTicketRevokeService.java @@ -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) + * + *

说明:该操作需保证幂等;若无关联水票则无任何副作用。

+ */ +@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 qw = new LambdaQueryWrapper() + .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 tickets = gltUserTicketService.list(qw); + + // 兼容历史数据:部分水票只记录了 orderGoodsId,未记录 orderId/orderNo + if ((tickets == null || tickets.isEmpty()) && shopOrderId != null) { + try { + List goodsList = shopOrderGoodsService.list( + new LambdaQueryWrapper() + .select(ShopOrderGoods::getId) + .eq(ShopOrderGoods::getTenantId, tenantId) + .eq(ShopOrderGoods::getOrderId, shopOrderId) + ); + List 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() + .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 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 uw = new LambdaUpdateWrapper() + .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 uw = new LambdaUpdateWrapper() + .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() + .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; + } +} diff --git a/src/main/java/com/gxwebsoft/glt/service/impl/GltTicketOrderServiceImpl.java b/src/main/java/com/gxwebsoft/glt/service/impl/GltTicketOrderServiceImpl.java index f2b1069..cd01711 100644 --- a/src/main/java/com/gxwebsoft/glt/service/impl/GltTicketOrderServiceImpl.java +++ b/src/main/java/com/gxwebsoft/glt/service/impl/GltTicketOrderServiceImpl.java @@ -54,6 +54,7 @@ public class GltTicketOrderServiceImpl extends ServiceImpl() + .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() + .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) { diff --git a/src/main/java/com/gxwebsoft/glt/task/DealerCommissionUnfreeze10584Task.java b/src/main/java/com/gxwebsoft/glt/task/DealerCommissionUnfreeze10584Task.java index 79f1438..1791b86 100644 --- a/src/main/java/com/gxwebsoft/glt/task/DealerCommissionUnfreeze10584Task.java +++ b/src/main/java/com/gxwebsoft/glt/task/DealerCommissionUnfreeze10584Task.java @@ -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 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() + .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() + .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() + .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 orderGoodsList = shopOrderGoodsService.list( + new LambdaQueryWrapper() + .eq(ShopOrderGoods::getTenantId, TENANT_ID) + .eq(ShopOrderGoods::getOrderId, orderId) + ); + if (orderGoodsList == null || orderGoodsList.isEmpty()) { + return false; + } + + List goodsIds = orderGoodsList.stream() + .map(ShopOrderGoods::getGoodsId) + .filter(Objects::nonNull) + .distinct() + .toList(); + if (goodsIds.isEmpty()) { + return false; + } + + Map goodsDeliveryMoneyMap = shopGoodsService.list( + new LambdaQueryWrapper() + .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() + .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() + .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 loadWaterFormIds() { return gltTicketTemplateService.list( new LambdaQueryWrapper() diff --git a/src/main/java/com/gxwebsoft/glt/task/DealerOrderSettlement10584Task.java b/src/main/java/com/gxwebsoft/glt/task/DealerOrderSettlement10584Task.java index d74f4c1..25b2b37 100644 --- a/src/main/java/com/gxwebsoft/glt/task/DealerOrderSettlement10584Task.java +++ b/src/main/java/com/gxwebsoft/glt/task/DealerOrderSettlement10584Task.java @@ -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 orders = findUnsettledPaidOrders(); + Set waterFormIds = loadWaterFormIds(); + List 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 findUnsettledPaidOrders() { - // 以确认收货为准:仅结算 deliveryStatus=20 的订单(租户10584约定)。 - return shopOrderService.list( - new LambdaQueryWrapper() - .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) - ); + private List findUnsettledPaidOrders(Set waterFormIds) { + // 租户10584约定: + // - 普通订单:以发货为准(deliveryStatus=20)才结算; + // - 绑定水票模板的订单:支付成功即可体现分润(无需等发货状态变更)。 + LambdaQueryWrapper qw = new LambdaQueryWrapper() + .eq(ShopOrder::getTenantId, TENANT_ID) + .eq(ShopOrder::getDeleted, 0) + .eq(ShopOrder::getPayStatus, true) + .eq(ShopOrder::getIsSettled, 0) + // 退款/取消订单不结算,避免“退款后仍发放分红/分润/佣金” + .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); + } + + qw.orderByAsc(ShopOrder::getOrderId).last("limit " + MAX_ORDERS_PER_RUN); + return shopOrderService.list(qw); } - private boolean claimOrderToSettle(Integer orderId) { - return shopOrderService.update( - new LambdaUpdateWrapper() - .eq(ShopOrder::getOrderId, orderId) - .eq(ShopOrder::getTenantId, TENANT_ID) - .eq(ShopOrder::getIsSettled, 0) - .set(ShopOrder::getIsSettled, 1) - ); + private boolean claimOrderToSettle(Integer orderId, Set waterFormIds) { + LambdaUpdateWrapper uw = new LambdaUpdateWrapper() + .eq(ShopOrder::getOrderId, orderId) + .eq(ShopOrder::getTenantId, TENANT_ID) + .eq(ShopOrder::getIsSettled, 0) + // 二次防御:退款/取消订单不允许被“认领结算” + .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 loadWaterFormIds() { + try { + return gltTicketTemplateService.list( + new LambdaQueryWrapper() + .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 shopRoleCache ) { List 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 uw = new LambdaUpdateWrapper() .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); diff --git a/src/main/java/com/gxwebsoft/glt/task/GltTicketIssue10584Task.java b/src/main/java/com/gxwebsoft/glt/task/GltTicketIssue10584Task.java index 8dd9f28..8d0d103 100644 --- a/src/main/java/com/gxwebsoft/glt/task/GltTicketIssue10584Task.java +++ b/src/main/java/com/gxwebsoft/glt/task/GltTicketIssue10584Task.java @@ -18,7 +18,7 @@ import java.util.concurrent.atomic.AtomicBoolean; * GLT 套票发放任务: * - 每30秒扫描一次今日订单(tenantId=10584, formId in 套票模板 goodsId, payStatus=1, orderStatus=0) * - 为订单生成用户套票账户 + 释放计划(幂等) - * - 若模板配置了 startSendQty,则发放时自动核销对应数量(用于“第一次送水”场景) + * - 按整改需求:发放阶段不再自动核销/自动下单;“送水下单核销”由用户在履约时主动触发 */ @Slf4j @Component diff --git a/src/main/java/com/gxwebsoft/glt/task/GltTicketOrderAutoDispatch10584Task.java b/src/main/java/com/gxwebsoft/glt/task/GltTicketOrderAutoDispatch10584Task.java new file mode 100644 index 0000000..3a1ef96 --- /dev/null +++ b/src/main/java/com/gxwebsoft/glt/task/GltTicketOrderAutoDispatch10584Task.java @@ -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); + } + } +} diff --git a/src/main/java/com/gxwebsoft/shop/dto/RefundedOrderGltRepairRequest.java b/src/main/java/com/gxwebsoft/shop/dto/RefundedOrderGltRepairRequest.java new file mode 100644 index 0000000..8526864 --- /dev/null +++ b/src/main/java/com/gxwebsoft/shop/dto/RefundedOrderGltRepairRequest.java @@ -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 orderIds; + + @Schema(description = "指定订单号列表(可选;为空则走时间窗口扫描)") + private List 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; +} +