feat(glt): 添加送水订单自动派单功能

- 在ShopStoreRider实体中增加经纬度字段用于定位
- 创建GltTicketOrderAutoDispatch10584Task定时任务处理自动派单
- 实现GltTicketOrderAutoDispatchService服务类进行距离计算和派单逻辑
- 支持按距离最近原则自动分配配送员给待配送订单
- 集成坐标解析和Haversine距离计算算法
- 实现多租户环境下的自动派单配置开关
- 添加配送员在途订单数限制和并发控制机制
This commit is contained in:
2026-02-25 13:45:16 +08:00
parent 409a078e2d
commit ca02e9e5a3
3 changed files with 415 additions and 0 deletions

View File

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

View File

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

View File

@@ -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;