|
|
@@ -36,12 +36,13 @@ import static com.ruoyi.system.domain.flash.FlashDeliveryStatus.*;
|
|
|
*/
|
|
|
@Service
|
|
|
public class FlashDeliveryApplicationService {
|
|
|
- private static final Set<String> SERVICE_TYPES = Set.of("HELP_SEND", "HELP_PICKUP", "URGENT");
|
|
|
+ private static final List<String> SERVICE_TYPES = List.of("HELP_SEND", "HELP_PICKUP", "URGENT");
|
|
|
private static final Set<String> PACKAGE_TYPES = Set.of("DOCUMENT", "GIFT", "CLOTHING", "BEAUTY",
|
|
|
"DAILY_NECESSITIES", "FOOD_INGREDIENTS", "ELECTRONICS", "SMALL_APPLIANCE", "OTHER");
|
|
|
private static final Set<String> PACKAGE_SIZES = Set.of("SMALL", "MEDIUM", "LARGE");
|
|
|
private static final Set<String> DELIVERY_MODES = Set.of("NOW", "SCHEDULED");
|
|
|
private static final int MAX_DISTANCE_METERS = 40_000;
|
|
|
+ private static final BigDecimal MAX_PRICING_DISTANCE = new BigDecimal("999999.99");
|
|
|
private static final long SCHEDULE_SLOT_MILLIS = 30L * 60L * 1000L;
|
|
|
private static final long MAX_SCHEDULE_DELAY_MILLIS = 3L * 24L * 60L * 60L * 1000L;
|
|
|
private static final SecureRandom PIN_RANDOM = new SecureRandom();
|
|
|
@@ -71,8 +72,11 @@ public class FlashDeliveryApplicationService {
|
|
|
|
|
|
public FlashDeliveryHomeView home() {
|
|
|
FlashDeliveryHomeView view = new FlashDeliveryHomeView();
|
|
|
- view.setServices(pricingMapper.selectList(new QueryWrapper<FlashDeliveryPricing>()
|
|
|
- .eq("enabled", true).orderByAsc("id")).stream().map(this::serviceView).toList());
|
|
|
+ view.setServices(SERVICE_TYPES.stream()
|
|
|
+ .map(this::findCurrentPricingOrNull)
|
|
|
+ .filter(Objects::nonNull)
|
|
|
+ .map(this::serviceView)
|
|
|
+ .toList());
|
|
|
return view;
|
|
|
}
|
|
|
|
|
|
@@ -81,9 +85,16 @@ public class FlashDeliveryApplicationService {
|
|
|
QuoteContext context = calculateQuote(request);
|
|
|
FlashDeliveryQuoteView view = new FlashDeliveryQuoteView();
|
|
|
view.setServiceType(context.pricing().getServiceType());
|
|
|
+ view.setPricingId(context.pricing().getId());
|
|
|
+ view.setStartTime(context.pricing().getStartTime());
|
|
|
+ view.setEndTime(context.pricing().getEndTime());
|
|
|
view.setDistanceMeters(context.route().distanceMeters());
|
|
|
view.setDistanceSource(context.route().source());
|
|
|
view.setEstimatedDurationSeconds(context.route().durationSeconds());
|
|
|
+ view.setStartingDistance(context.pricing().getStartingDistance());
|
|
|
+ view.setStartingFare(context.pricing().getStartingFare());
|
|
|
+ view.setDistance(context.pricing().getDistance());
|
|
|
+ view.setFreight(context.pricing().getFreight());
|
|
|
applyBreakdown(view, context.breakdown());
|
|
|
view.setCurrency("TWD");
|
|
|
view.setPricingVersion(context.pricing().getConfigVersion());
|
|
|
@@ -130,17 +141,16 @@ public class FlashDeliveryApplicationService {
|
|
|
order.setEstimatedDurationSeconds(quote.route().durationSeconds());
|
|
|
order.setAmount(quote.breakdown().getAmount());
|
|
|
order.setCurrency("TWD");
|
|
|
+ order.setPricingId(quote.pricing().getId());
|
|
|
order.setPricingVersion(quote.pricing().getConfigVersion());
|
|
|
+ order.setPricingStartTime(quote.pricing().getStartTime());
|
|
|
+ order.setPricingEndTime(quote.pricing().getEndTime());
|
|
|
+ order.setStartingDistance(quote.pricing().getStartingDistance());
|
|
|
+ order.setStartingFare(quote.pricing().getStartingFare());
|
|
|
+ order.setDistance(quote.pricing().getDistance());
|
|
|
+ order.setFreight(quote.pricing().getFreight());
|
|
|
// 保存计价快照,后台调价只影响新订单,不追溯修改历史订单金额。
|
|
|
- order.setStartPrice(quote.pricing().getStartPrice());
|
|
|
- order.setMinimumPrice(quote.pricing().getMinimumPrice());
|
|
|
- order.setStartDistanceMeters(quote.pricing().getStartDistanceMeters());
|
|
|
- order.setPerKmPrice(quote.pricing().getPerKmPrice());
|
|
|
- order.setPerMinutePrice(quote.pricing().getPerMinutePrice());
|
|
|
- order.setPeakMultiplier(quote.pricing().getPeakMultiplier());
|
|
|
order.setDistanceFee(quote.breakdown().getDistanceFee());
|
|
|
- order.setDurationFee(quote.breakdown().getDurationFee());
|
|
|
- order.setPeakFee(quote.breakdown().getPeakFee());
|
|
|
order.setUserNote(trimToNull(request.getUserNote()));
|
|
|
order.setVersion(0);
|
|
|
order.setCreateTime(now);
|
|
|
@@ -270,57 +280,81 @@ public class FlashDeliveryApplicationService {
|
|
|
}
|
|
|
|
|
|
public List<FlashDeliveryPricing> adminPricing() {
|
|
|
- return pricingMapper.selectList(new QueryWrapper<FlashDeliveryPricing>().orderByAsc("id"));
|
|
|
+ return pricingMapper.selectList(new QueryWrapper<FlashDeliveryPricing>()
|
|
|
+ .orderByAsc("service_type", "start_time", "id"));
|
|
|
}
|
|
|
|
|
|
@Transactional
|
|
|
- public FlashDeliveryPricing updatePricing(Long adminId, String serviceType,
|
|
|
- FlashDeliveryPricingUpdateRequest request) {
|
|
|
- validateServiceType(serviceType);
|
|
|
- if (request == null || request.getStartPrice() == null || request.getStartPrice().signum() <= 0
|
|
|
- || request.getMinimumPrice() == null || request.getMinimumPrice().signum() <= 0
|
|
|
- || request.getStartDistanceMeters() == null || request.getStartDistanceMeters() <= 0
|
|
|
- || request.getPerKmPrice() == null || request.getPerKmPrice().signum() <= 0
|
|
|
- || request.getPerMinutePrice() == null || request.getPerMinutePrice().signum() < 0
|
|
|
- || request.getPeakMultiplier() == null
|
|
|
- || request.getPeakMultiplier().compareTo(BigDecimal.ONE) < 0
|
|
|
- || request.getEnabled() == null) throw fail("flash.delivery.pricing.invalid");
|
|
|
- FlashDeliveryPricing pricing = findPricing(serviceType, false);
|
|
|
+ public FlashDeliveryPricing createPricing(Long adminId, FlashDeliveryPricingRequest request) {
|
|
|
+ validatePricingRequest(request, false);
|
|
|
+ lockPricingServices(request.getServiceType());
|
|
|
+ ensureNoPricingOverlap(request, null);
|
|
|
+ Date now = new Date();
|
|
|
+ FlashDeliveryPricing pricing = pricingFrom(request);
|
|
|
+ pricing.setConfigVersion(1);
|
|
|
+ pricing.setUpdatedBy(adminId);
|
|
|
+ pricing.setCreateTime(now);
|
|
|
+ pricing.setUpdateTime(now);
|
|
|
+ pricingMapper.insert(pricing);
|
|
|
+ return pricing;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Transactional
|
|
|
+ public FlashDeliveryPricing updatePricing(Long adminId, Long pricingId,
|
|
|
+ FlashDeliveryPricingRequest request) {
|
|
|
+ validatePricingRequest(request, true);
|
|
|
+ FlashDeliveryPricing preliminary = pricingId == null ? null : pricingMapper.selectById(pricingId);
|
|
|
+ if (preliminary == null) throw fail("flash.delivery.pricing.not.found");
|
|
|
+ lockPricingServices(preliminary.getServiceType(), request.getServiceType());
|
|
|
+ FlashDeliveryPricing pricing = pricingMapper.selectById(pricingId);
|
|
|
if (pricing == null) throw fail("flash.delivery.pricing.not.found");
|
|
|
- int expectedVersion = pricing.getConfigVersion() == null ? 0 : pricing.getConfigVersion();
|
|
|
+ if (!Objects.equals(preliminary.getServiceType(), pricing.getServiceType())) {
|
|
|
+ throw fail("flash.delivery.pricing.changed");
|
|
|
+ }
|
|
|
+ ensureNoPricingOverlap(request, pricingId);
|
|
|
Date now = new Date();
|
|
|
- BigDecimal startPrice = request.getStartPrice().setScale(2, RoundingMode.HALF_UP);
|
|
|
- BigDecimal minimumPrice = request.getMinimumPrice().setScale(2, RoundingMode.HALF_UP);
|
|
|
- BigDecimal perKmPrice = request.getPerKmPrice().setScale(2, RoundingMode.HALF_UP);
|
|
|
- BigDecimal perMinutePrice = request.getPerMinutePrice().setScale(2, RoundingMode.HALF_UP);
|
|
|
- BigDecimal peakMultiplier = request.getPeakMultiplier().setScale(2, RoundingMode.HALF_UP);
|
|
|
UpdateWrapper<FlashDeliveryPricing> update = new UpdateWrapper<FlashDeliveryPricing>()
|
|
|
- .eq("id", pricing.getId()).eq("config_version", expectedVersion)
|
|
|
- .set("start_price", startPrice)
|
|
|
- .set("minimum_price", minimumPrice)
|
|
|
- .set("start_distance_meters", request.getStartDistanceMeters())
|
|
|
- .set("per_km_price", perKmPrice)
|
|
|
- .set("per_minute_price", perMinutePrice)
|
|
|
- .set("peak_multiplier", peakMultiplier)
|
|
|
- .set("enabled", request.getEnabled())
|
|
|
+ .eq("id", pricingId).eq("config_version", request.getConfigVersion())
|
|
|
+ .set("service_type", request.getServiceType())
|
|
|
+ .set("start_time", request.getStartTime())
|
|
|
+ .set("end_time", request.getEndTime())
|
|
|
+ .set("starting_distance", scaleDistance(request.getStartingDistance()))
|
|
|
+ .set("starting_fare", request.getStartingFare())
|
|
|
+ .set("distance", scaleDistance(request.getDistance()))
|
|
|
+ .set("freight", request.getFreight())
|
|
|
.set("updated_by", adminId)
|
|
|
.set("update_time", now)
|
|
|
.setSql("config_version = config_version + 1");
|
|
|
// 版本条件防止两个管理员同时保存时后提交者静默覆盖先提交者。
|
|
|
if (pricingMapper.update(null, update) != 1) throw fail("flash.delivery.pricing.changed");
|
|
|
- pricing.setStartPrice(startPrice);
|
|
|
- pricing.setMinimumPrice(minimumPrice);
|
|
|
- pricing.setStartDistanceMeters(request.getStartDistanceMeters());
|
|
|
- pricing.setPerKmPrice(perKmPrice);
|
|
|
- pricing.setPerMinutePrice(perMinutePrice);
|
|
|
- pricing.setPeakMultiplier(peakMultiplier);
|
|
|
- pricing.setEnabled(request.getEnabled());
|
|
|
- pricing.setConfigVersion(expectedVersion + 1);
|
|
|
+ pricing.setServiceType(request.getServiceType());
|
|
|
+ pricing.setStartTime(request.getStartTime());
|
|
|
+ pricing.setEndTime(request.getEndTime());
|
|
|
+ pricing.setStartingDistance(scaleDistance(request.getStartingDistance()));
|
|
|
+ pricing.setStartingFare(request.getStartingFare());
|
|
|
+ pricing.setDistance(scaleDistance(request.getDistance()));
|
|
|
+ pricing.setFreight(request.getFreight());
|
|
|
+ pricing.setConfigVersion(request.getConfigVersion() + 1);
|
|
|
pricing.setUpdatedBy(adminId);
|
|
|
pricing.setUpdateTime(now);
|
|
|
return pricing;
|
|
|
}
|
|
|
|
|
|
+ @Transactional
|
|
|
+ public void deletePricing(Long pricingId) {
|
|
|
+ FlashDeliveryPricing preliminary = pricingId == null ? null : pricingMapper.selectById(pricingId);
|
|
|
+ if (preliminary == null) {
|
|
|
+ throw fail("flash.delivery.pricing.not.found");
|
|
|
+ }
|
|
|
+ lockPricingServices(preliminary.getServiceType());
|
|
|
+ FlashDeliveryPricing pricing = pricingMapper.selectById(pricingId);
|
|
|
+ if (pricing == null) throw fail("flash.delivery.pricing.not.found");
|
|
|
+ if (!Objects.equals(preliminary.getServiceType(), pricing.getServiceType())) {
|
|
|
+ throw fail("flash.delivery.pricing.changed");
|
|
|
+ }
|
|
|
+ if (pricingMapper.deleteById(pricingId) != 1) throw fail("flash.delivery.pricing.not.found");
|
|
|
+ }
|
|
|
+
|
|
|
public IPage<FlashDeliveryOrder> adminOrders(int pageNum, int pageSize, String status,
|
|
|
String serviceType, String orderNo,
|
|
|
Long userId, Long riderId) {
|
|
|
@@ -385,26 +419,100 @@ public class FlashDeliveryApplicationService {
|
|
|
&& request.getPickup().getLongitude().compareTo(request.getDelivery().getLongitude()) == 0) {
|
|
|
throw fail("flash.delivery.address.same");
|
|
|
}
|
|
|
- FlashDeliveryPricing pricing = findPricing(request.getServiceType(), true);
|
|
|
+ FlashDeliveryPricing pricing = findCurrentPricing(request.getServiceType());
|
|
|
// 路线服务优先使用 Google Routes;不可用时在服务内部降级为球面直线距离。
|
|
|
RouteDistance route = routeService.calculate(
|
|
|
new GeoPoint(request.getPickup().getLatitude(), request.getPickup().getLongitude()),
|
|
|
new GeoPoint(request.getDelivery().getLatitude(), request.getDelivery().getLongitude()));
|
|
|
if (route.distanceMeters() > MAX_DISTANCE_METERS) throw fail("flash.delivery.distance.too.far");
|
|
|
FlashDeliveryPriceBreakdown breakdown = pricingCalculator.calculateBreakdown(pricing,
|
|
|
- route.distanceMeters(), route.durationSeconds() == null ? 0 : route.durationSeconds());
|
|
|
+ route.distanceMeters());
|
|
|
return new QuoteContext(pricing, route, breakdown);
|
|
|
}
|
|
|
|
|
|
- private FlashDeliveryPricing findPricing(String serviceType, boolean enabledOnly) {
|
|
|
- QueryWrapper<FlashDeliveryPricing> query = new QueryWrapper<FlashDeliveryPricing>()
|
|
|
- .eq("service_type", serviceType);
|
|
|
- if (enabledOnly) query.eq("enabled", true);
|
|
|
- FlashDeliveryPricing pricing = pricingMapper.selectOne(query);
|
|
|
- if (enabledOnly && pricing == null) throw fail("flash.delivery.service.unavailable");
|
|
|
+ private FlashDeliveryPricing findCurrentPricing(String serviceType) {
|
|
|
+ FlashDeliveryPricing pricing = findCurrentPricingOrNull(serviceType);
|
|
|
+ if (pricing == null) throw fail("flash.delivery.pricing.not.available");
|
|
|
+ return pricing;
|
|
|
+ }
|
|
|
+
|
|
|
+ private FlashDeliveryPricing findCurrentPricingOrNull(String serviceType) {
|
|
|
+ List<FlashDeliveryPricing> pricing = pricingMapper.selectCurrent(serviceType);
|
|
|
+ if (pricing == null || pricing.isEmpty()) return null;
|
|
|
+ if (pricing.size() > 1) throw fail("flash.delivery.pricing.overlap");
|
|
|
+ return pricing.get(0);
|
|
|
+ }
|
|
|
+
|
|
|
+ private void validatePricingRequest(FlashDeliveryPricingRequest request, boolean requireVersion) {
|
|
|
+ if (request == null) throw fail("flash.delivery.pricing.invalid");
|
|
|
+ validateServiceType(request.getServiceType());
|
|
|
+ BigDecimal startingDistance = normalizeDistance(request.getStartingDistance());
|
|
|
+ BigDecimal distance = normalizeDistance(request.getDistance());
|
|
|
+ if (!validStartTime(request.getStartTime()) || !validEndTime(request.getEndTime())
|
|
|
+ || toMinute(request.getStartTime()) >= toMinute(request.getEndTime())
|
|
|
+ || startingDistance == null || startingDistance.signum() <= 0
|
|
|
+ || startingDistance.compareTo(MAX_PRICING_DISTANCE) > 0
|
|
|
+ || request.getStartingFare() == null || request.getStartingFare() <= 0
|
|
|
+ || distance == null || distance.signum() <= 0
|
|
|
+ || distance.compareTo(MAX_PRICING_DISTANCE) > 0
|
|
|
+ || request.getFreight() == null || request.getFreight() <= 0
|
|
|
+ || requireVersion && (request.getConfigVersion() == null || request.getConfigVersion() <= 0)) {
|
|
|
+ throw fail("flash.delivery.pricing.invalid");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private void ensureNoPricingOverlap(FlashDeliveryPricingRequest request, Long excludedId) {
|
|
|
+ if (pricingMapper.countOverlapping(request.getServiceType(), request.getStartTime(),
|
|
|
+ request.getEndTime(), excludedId) > 0) {
|
|
|
+ throw fail("flash.delivery.pricing.overlap");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private void lockPricingServices(String... serviceTypes) {
|
|
|
+ Arrays.stream(serviceTypes)
|
|
|
+ .filter(Objects::nonNull)
|
|
|
+ .distinct()
|
|
|
+ .sorted()
|
|
|
+ .forEach(serviceType -> {
|
|
|
+ if (!Objects.equals(serviceType, pricingMapper.lockServiceType(serviceType))) {
|
|
|
+ throw fail("flash.delivery.pricing.invalid");
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ private FlashDeliveryPricing pricingFrom(FlashDeliveryPricingRequest request) {
|
|
|
+ FlashDeliveryPricing pricing = new FlashDeliveryPricing();
|
|
|
+ pricing.setServiceType(request.getServiceType());
|
|
|
+ pricing.setStartTime(request.getStartTime());
|
|
|
+ pricing.setEndTime(request.getEndTime());
|
|
|
+ pricing.setStartingDistance(scaleDistance(request.getStartingDistance()));
|
|
|
+ pricing.setStartingFare(request.getStartingFare());
|
|
|
+ pricing.setDistance(scaleDistance(request.getDistance()));
|
|
|
+ pricing.setFreight(request.getFreight());
|
|
|
return pricing;
|
|
|
}
|
|
|
|
|
|
+ private boolean validStartTime(String value) {
|
|
|
+ return value != null && value.matches("(?:[01]\\d|2[0-3]):[0-5]\\d");
|
|
|
+ }
|
|
|
+
|
|
|
+ private boolean validEndTime(String value) {
|
|
|
+ return "24:00".equals(value) || validStartTime(value);
|
|
|
+ }
|
|
|
+
|
|
|
+ private int toMinute(String value) {
|
|
|
+ if ("24:00".equals(value)) return 24 * 60;
|
|
|
+ return Integer.parseInt(value.substring(0, 2)) * 60 + Integer.parseInt(value.substring(3));
|
|
|
+ }
|
|
|
+
|
|
|
+ private BigDecimal scaleDistance(BigDecimal value) {
|
|
|
+ return normalizeDistance(value);
|
|
|
+ }
|
|
|
+
|
|
|
+ private BigDecimal normalizeDistance(BigDecimal value) {
|
|
|
+ return value == null ? null : value.setScale(2, RoundingMode.HALF_UP);
|
|
|
+ }
|
|
|
+
|
|
|
private void transitionWithProof(Long riderId, Long orderId, FlashDeliveryProofRequest request,
|
|
|
String expected, String next, String proofType, String timeColumn) {
|
|
|
List<String> urls = validateProofUrls(request == null ? null : request.getImageUrls(), true);
|
|
|
@@ -510,12 +618,13 @@ public class FlashDeliveryApplicationService {
|
|
|
private FlashDeliveryServiceView serviceView(FlashDeliveryPricing pricing) {
|
|
|
FlashDeliveryServiceView view = new FlashDeliveryServiceView();
|
|
|
view.setServiceType(pricing.getServiceType());
|
|
|
- view.setStartPrice(pricing.getStartPrice());
|
|
|
- view.setMinimumPrice(pricing.getMinimumPrice());
|
|
|
- view.setStartDistanceMeters(pricing.getStartDistanceMeters());
|
|
|
- view.setPerKmPrice(pricing.getPerKmPrice());
|
|
|
- view.setPerMinutePrice(pricing.getPerMinutePrice());
|
|
|
- view.setPeakMultiplier(pricing.getPeakMultiplier());
|
|
|
+ view.setPricingId(pricing.getId());
|
|
|
+ view.setStartTime(pricing.getStartTime());
|
|
|
+ view.setEndTime(pricing.getEndTime());
|
|
|
+ view.setStartingDistance(pricing.getStartingDistance());
|
|
|
+ view.setStartingFare(pricing.getStartingFare());
|
|
|
+ view.setDistance(pricing.getDistance());
|
|
|
+ view.setFreight(pricing.getFreight());
|
|
|
view.setPricingVersion(pricing.getConfigVersion());
|
|
|
view.setCurrency("TWD");
|
|
|
return view;
|
|
|
@@ -602,13 +711,8 @@ public class FlashDeliveryApplicationService {
|
|
|
}
|
|
|
|
|
|
private void applyBreakdown(FlashDeliveryQuoteView view, FlashDeliveryPriceBreakdown breakdown) {
|
|
|
- view.setStartPrice(breakdown.getStartPrice());
|
|
|
- view.setMinimumPrice(breakdown.getMinimumPrice());
|
|
|
+ view.setBillableDistance(breakdown.getBillableDistance());
|
|
|
view.setDistanceFee(breakdown.getDistanceFee());
|
|
|
- view.setDurationFee(breakdown.getDurationFee());
|
|
|
- view.setSubtotal(breakdown.getSubtotal());
|
|
|
- view.setPeakMultiplier(breakdown.getPeakMultiplier());
|
|
|
- view.setPeakFee(breakdown.getPeakFee());
|
|
|
view.setAmount(breakdown.getAmount());
|
|
|
}
|
|
|
|