From ca02e9e5a35c2c86f5200931e5bf7fd181762a6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B5=B5=E5=BF=A0=E6=9E=97?= <170083662@qq.com> Date: Wed, 25 Feb 2026 13:45:16 +0800 Subject: [PATCH] =?UTF-8?q?feat(glt):=20=E6=B7=BB=E5=8A=A0=E9=80=81?= =?UTF-8?q?=E6=B0=B4=E8=AE=A2=E5=8D=95=E8=87=AA=E5=8A=A8=E6=B4=BE=E5=8D=95?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在ShopStoreRider实体中增加经纬度字段用于定位 - 创建GltTicketOrderAutoDispatch10584Task定时任务处理自动派单 - 实现GltTicketOrderAutoDispatchService服务类进行距离计算和派单逻辑 - 支持按距离最近原则自动分配配送员给待配送订单 - 集成坐标解析和Haversine距离计算算法 - 实现多租户环境下的自动派单配置开关 - 添加配送员在途订单数限制和并发控制机制 --- .../GltTicketOrderAutoDispatchService.java | 355 ++++++++++++++++++ .../GltTicketOrderAutoDispatch10584Task.java | 54 +++ .../gxwebsoft/shop/entity/ShopStoreRider.java | 6 + 3 files changed, 415 insertions(+) create mode 100644 src/main/java/com/gxwebsoft/glt/service/GltTicketOrderAutoDispatchService.java create mode 100644 src/main/java/com/gxwebsoft/glt/task/GltTicketOrderAutoDispatch10584Task.java 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/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/entity/ShopStoreRider.java b/src/main/java/com/gxwebsoft/shop/entity/ShopStoreRider.java index b0821a6..48e1df4 100644 --- a/src/main/java/com/gxwebsoft/shop/entity/ShopStoreRider.java +++ b/src/main/java/com/gxwebsoft/shop/entity/ShopStoreRider.java @@ -55,6 +55,12 @@ public class ShopStoreRider implements Serializable { @Schema(description = "接单状态:0休息/下线;1在线;2忙碌") private Integer workStatus; + @Schema(description = "经度") + private String longitude; + + @Schema(description = "纬度") + private String latitude; + @Schema(description = "是否开启自动派单:1是;0否") private Integer autoDispatchEnabled;