Selaa lähdekoodia

完成闪送时段运价与整数计价

移除最低价、时长费、尖峰倍率和启停状态,按外卖公里规则计算整数新台币。

增加多时段运价管理、并发防重叠、订单计价快照,并同步测试、重建 SQL 与 spec-kit 文档。
qmj 3 päivää sitten
vanhempi
sitoutus
b7f4fe2b2d
30 muutettua tiedostoa jossa 812 lisäystä ja 275 poistoa
  1. 45 20
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryAdminController.java
  2. 1 1
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryAvailableOrderView.java
  3. 1 2
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderView.java
  4. 4 9
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryPriceBreakdown.java
  5. 18 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryPricingRequest.java
  6. 0 17
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryPricingUpdateRequest.java
  7. 11 10
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryQuoteView.java
  8. 8 7
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryServiceView.java
  9. 170 66
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java
  10. 30 39
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryPricingCalculator.java
  11. 2 0
      ruoyi-admin/src/main/resources/i18n/messages.properties
  12. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_en_US.properties
  13. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_vi.properties
  14. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
  15. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties
  16. 5 2
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryControllerContractTest.java
  17. 168 26
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java
  18. 2 1
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryI18nContractTest.java
  19. 64 26
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryPricingCalculatorTest.java
  20. 9 10
      ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryOrder.java
  21. 7 12
      ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryPricing.java
  22. 38 2
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/flash/FlashDeliveryPricingMapper.java
  23. 7 8
      ruoyi-system/src/main/resources/mapper/flash/FlashDeliveryOrderMapper.xml
  24. 10 0
      ruoyi-system/src/test/java/com/ruoyi/system/mapper/flash/FlashDeliveryMapperXmlTest.java
  25. 50 0
      ruoyi-system/src/test/java/com/ruoyi/system/mapper/flash/FlashDeliveryPricingMapperContractTest.java
  26. 5 1
      specs/024-flash-delivery/data-model.md
  27. 6 6
      specs/024-flash-delivery/plan.md
  28. 1 1
      specs/024-flash-delivery/spec.md
  29. 9 9
      specs/024-flash-delivery/tasks.md
  30. 133 0
      updatesql/sql.md

+ 45 - 20
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryAdminController.java

@@ -1,38 +1,64 @@
 package com.ruoyi.app.flashdelivery.controller;
 
-import com.ruoyi.app.flashdelivery.dto.FlashDeliveryPricingUpdateRequest;
+import com.ruoyi.app.flashdelivery.dto.FlashDeliveryPricingRequest;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryReasonRequest;
 import com.ruoyi.app.flashdelivery.service.FlashDeliveryApplicationService;
+import com.ruoyi.common.annotation.Log;
 import com.ruoyi.common.core.controller.BaseController;
 import com.ruoyi.common.core.domain.AjaxResult;
-import com.ruoyi.common.utils.SecurityUtils;
-import com.ruoyi.common.annotation.Log;
 import com.ruoyi.common.enums.BusinessType;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.common.utils.SecurityUtils;
 import org.springframework.security.access.prepost.PreAuthorize;
-import org.springframework.web.bind.annotation.*;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
 
-/** 平台端闪送管理接口,按计价和订单操作分别校验平台权限。 */
+/** 平台端闪送管理接口。 */
 @RestController
 @RequestMapping("/system/flashDelivery/admin")
 public class FlashDeliveryAdminController extends BaseController {
     private final FlashDeliveryApplicationService service;
-    public FlashDeliveryAdminController(FlashDeliveryApplicationService service) { this.service = service; }
 
-    /** 查询三种闪送服务的完整后台计价配置。 */
+    public FlashDeliveryAdminController(FlashDeliveryApplicationService service) {
+        this.service = service;
+    }
+
     @PreAuthorize("@ss.hasPermi('flash:pricing:list')")
     @GetMapping("/pricing")
-    public AjaxResult pricing() { return success(service.adminPricing()); }
+    public AjaxResult pricing() {
+        return success(service.adminPricing());
+    }
 
-    /** 更新计价配置,服务层使用版本条件避免并发覆盖。 */
     @PreAuthorize("@ss.hasPermi('flash:pricing:edit')")
-    @Log(title = "闪送计价配置", businessType = BusinessType.UPDATE)
-    @PutMapping("/pricing/{serviceType}")
-    public AjaxResult updatePricing(@PathVariable String serviceType,
-                                    @RequestBody FlashDeliveryPricingUpdateRequest request) {
-        return success(service.updatePricing(SecurityUtils.getUserId(), serviceType, request));
+    @Log(title = "闪送时段运价", businessType = BusinessType.INSERT)
+    @PostMapping("/pricing")
+    public AjaxResult createPricing(@RequestBody FlashDeliveryPricingRequest request) {
+        return success(service.createPricing(SecurityUtils.getUserId(), request));
+    }
+
+    @PreAuthorize("@ss.hasPermi('flash:pricing:edit')")
+    @Log(title = "闪送时段运价", businessType = BusinessType.UPDATE)
+    @PutMapping("/pricing/{id}")
+    public AjaxResult updatePricing(@PathVariable Long id,
+                                    @RequestBody FlashDeliveryPricingRequest request) {
+        return success(service.updatePricing(SecurityUtils.getUserId(), id, request));
+    }
+
+    @PreAuthorize("@ss.hasPermi('flash:pricing:edit')")
+    @Log(title = "闪送时段运价", businessType = BusinessType.DELETE)
+    @DeleteMapping("/pricing/{id}")
+    public AjaxResult deletePricing(@PathVariable Long id) {
+        service.deletePricing(id);
+        return success();
     }
 
-    /** 按状态、类型、订单号、用户或骑手筛选全部闪送订单。 */
     @PreAuthorize("@ss.hasPermi('flash:order:list')")
     @GetMapping("/orders")
     public AjaxResult orders(@RequestParam(defaultValue = "1") int pageNum,
@@ -45,12 +71,12 @@ public class FlashDeliveryAdminController extends BaseController {
         return success(service.adminOrders(pageNum, pageSize, status, serviceType, orderNo, userId, riderId));
     }
 
-    /** 查询完整订单、凭证和审计日志。 */
     @PreAuthorize("@ss.hasPermi('flash:order:query')")
     @GetMapping("/orders/{id}")
-    public AjaxResult detail(@PathVariable Long id) { return success(service.adminDetail(id)); }
+    public AjaxResult detail(@PathVariable Long id) {
+        return success(service.adminDetail(id));
+    }
 
-    /** 平台取消非终态异常订单,并记录原因和操作人。 */
     @PreAuthorize("@ss.hasPermi('flash:order:cancel')")
     @Log(title = "闪送订单取消", businessType = BusinessType.UPDATE)
     @PostMapping("/orders/{id}/cancel")
@@ -59,13 +85,12 @@ public class FlashDeliveryAdminController extends BaseController {
         return success();
     }
 
-    /** 平台将已送达订单人工收口为已完成。 */
     @PreAuthorize("@ss.hasPermi('flash:order:complete')")
     @Log(title = "闪送订单完成", businessType = BusinessType.UPDATE)
     @PostMapping("/orders/{id}/complete")
     public AjaxResult complete(@PathVariable Long id) {
         if (!service.complete(SecurityUtils.getUserId(), "ADMIN", id)) {
-            return error(com.ruoyi.common.utils.MessageUtils.message("flash.delivery.complete.not.allowed"));
+            return error(MessageUtils.message("flash.delivery.complete.not.allowed"));
         }
         return success();
     }

+ 1 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryAvailableOrderView.java

@@ -29,7 +29,7 @@ public class FlashDeliveryAvailableOrderView {
     private BigDecimal deliveryLongitudeApprox;
     private Integer distanceMeters;
     private Integer estimatedDurationSeconds;
-    private BigDecimal amount;
+    private Long amount;
     private String currency;
     private Date createTime;
 }

+ 1 - 2
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderView.java

@@ -2,7 +2,6 @@ package com.ruoyi.app.flashdelivery.dto;
 
 import lombok.Data;
 
-import java.math.BigDecimal;
 import java.util.Date;
 
 /** 用户和中单骑手共用的业务订单视图,不包含持久化及身份内部字段。 */
@@ -23,7 +22,7 @@ public class FlashDeliveryOrderView {
     private Integer distanceMeters;
     private String distanceSource;
     private Integer estimatedDurationSeconds;
-    private BigDecimal amount;
+    private Long amount;
     private String currency;
     private String userNote;
     private Date acceptedAt;

+ 4 - 9
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryPriceBreakdown.java

@@ -4,15 +4,10 @@ import lombok.Data;
 
 import java.math.BigDecimal;
 
-/** 报价计算的可展示明细,各金额统一保留两位小数。 */
+/** 按外卖运费规则计算的闪送费用明细。 */
 @Data
 public class FlashDeliveryPriceBreakdown {
-    private BigDecimal startPrice;
-    private BigDecimal minimumPrice;
-    private BigDecimal distanceFee;
-    private BigDecimal durationFee;
-    private BigDecimal subtotal;
-    private BigDecimal peakMultiplier;
-    private BigDecimal peakFee;
-    private BigDecimal amount;
+    private BigDecimal billableDistance;
+    private Long distanceFee;
+    private Long amount;
 }

+ 18 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryPricingRequest.java

@@ -0,0 +1,18 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/** 平台新增或修改闪送时段运价的请求。 */
+@Data
+public class FlashDeliveryPricingRequest {
+    private String serviceType;
+    private String startTime;
+    private String endTime;
+    private BigDecimal startingDistance;
+    private Long startingFare;
+    private BigDecimal distance;
+    private Long freight;
+    private Integer configVersion;
+}

+ 0 - 17
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryPricingUpdateRequest.java

@@ -1,17 +0,0 @@
-package com.ruoyi.app.flashdelivery.dto;
-
-import lombok.Data;
-
-import java.math.BigDecimal;
-
-/** 平台更新某一服务类型计价规则的请求。 */
-@Data
-public class FlashDeliveryPricingUpdateRequest {
-    private BigDecimal startPrice;
-    private BigDecimal minimumPrice;
-    private Integer startDistanceMeters;
-    private BigDecimal perKmPrice;
-    private BigDecimal perMinutePrice;
-    private BigDecimal peakMultiplier;
-    private Boolean enabled;
-}

+ 11 - 10
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryQuoteView.java

@@ -4,22 +4,23 @@ import lombok.Data;
 
 import java.math.BigDecimal;
 
-/** 服务端报价结果;distanceSource 用于区分地图路线和降级距离。 */
+/** 服务端按当前时段运价生成的闪送报价。 */
 @Data
 public class FlashDeliveryQuoteView {
     private String serviceType;
+    private Long pricingId;
+    private String startTime;
+    private String endTime;
     private Integer distanceMeters;
     private String distanceSource;
     private Integer estimatedDurationSeconds;
-    private BigDecimal startPrice;
-    private BigDecimal minimumPrice;
-    private BigDecimal distanceFee;
-    private BigDecimal durationFee;
-    private BigDecimal subtotal;
-    private BigDecimal peakMultiplier;
-    private BigDecimal peakFee;
-    private BigDecimal amount;
+    private BigDecimal startingDistance;
+    private Long startingFare;
+    private BigDecimal distance;
+    private Long freight;
+    private BigDecimal billableDistance;
+    private Long distanceFee;
+    private Long amount;
     private String currency;
-    /** 报价采用的计价版本,创建订单时仍会按最新配置重新计算。 */
     private Integer pricingVersion;
 }

+ 8 - 7
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryServiceView.java

@@ -4,16 +4,17 @@ import lombok.Data;
 
 import java.math.BigDecimal;
 
-/** 用户首页可见的服务和计价摘要。 */
+/** 用户首页当前可用的闪送服务及运价摘要。 */
 @Data
 public class FlashDeliveryServiceView {
     private String serviceType;
-    private BigDecimal startPrice;
-    private BigDecimal minimumPrice;
-    private Integer startDistanceMeters;
-    private BigDecimal perKmPrice;
-    private BigDecimal perMinutePrice;
-    private BigDecimal peakMultiplier;
+    private Long pricingId;
+    private String startTime;
+    private String endTime;
+    private BigDecimal startingDistance;
+    private Long startingFare;
+    private BigDecimal distance;
+    private Long freight;
     private Integer pricingVersion;
     private String currency;
 }

+ 170 - 66
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java

@@ -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());
     }
 

+ 30 - 39
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryPricingCalculator.java

@@ -1,58 +1,49 @@
 package com.ruoyi.app.flashdelivery.service;
 
-import com.ruoyi.system.domain.flash.FlashDeliveryPricing;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryPriceBreakdown;
+import com.ruoyi.system.domain.flash.FlashDeliveryPricing;
+import org.springframework.stereotype.Component;
 
 import java.math.BigDecimal;
 import java.math.RoundingMode;
-import org.springframework.stereotype.Component;
 
-/** 按计价配置计算闪送金额,所有金额统一保留两位小数。 */
+/** 按闪送时段运价计算整数新台币金额。 */
 @Component
 public class FlashDeliveryPricingCalculator {
-    public BigDecimal calculate(FlashDeliveryPricing pricing, int distanceMeters, int durationSeconds) {
-        return calculateBreakdown(pricing, distanceMeters, durationSeconds).getAmount();
+    public Long calculate(FlashDeliveryPricing pricing, int distanceMeters) {
+        return calculateBreakdown(pricing, distanceMeters).getAmount();
     }
 
-    /** 按实际超距公里和路线时长生成费用明细,并应用最低价及尖峰倍率。 */
-    public FlashDeliveryPriceBreakdown calculateBreakdown(FlashDeliveryPricing pricing,
-                                                           int distanceMeters,
-                                                           int durationSeconds) {
-        if (pricing == null || !Boolean.TRUE.equals(pricing.getEnabled()) || distanceMeters <= 0
-                || pricing.getStartPrice() == null || pricing.getStartPrice().signum() <= 0
-                || pricing.getMinimumPrice() == null || pricing.getMinimumPrice().signum() <= 0
-                || pricing.getStartDistanceMeters() == null || pricing.getStartDistanceMeters() <= 0
-                || pricing.getPerKmPrice() == null || pricing.getPerKmPrice().signum() <= 0
-                || pricing.getPerMinutePrice() == null || pricing.getPerMinutePrice().signum() < 0
-                || pricing.getPeakMultiplier() == null
-                || pricing.getPeakMultiplier().compareTo(BigDecimal.ONE) < 0
-                || durationSeconds < 0) {
+    public FlashDeliveryPriceBreakdown calculateBreakdown(FlashDeliveryPricing pricing, int distanceMeters) {
+        if (pricing == null || distanceMeters <= 0
+                || pricing.getStartingDistance() == null || pricing.getStartingDistance().signum() <= 0
+                || pricing.getStartingFare() == null || pricing.getStartingFare() <= 0
+                || pricing.getDistance() == null || pricing.getDistance().signum() <= 0
+                || pricing.getFreight() == null || pricing.getFreight() <= 0) {
             throw new IllegalArgumentException("invalid pricing or distance");
         }
-        BigDecimal extraKilometres = BigDecimal.valueOf(Math.max(0L,
-                        distanceMeters - (long) pricing.getStartDistanceMeters()))
-                .divide(BigDecimal.valueOf(1000), 6, RoundingMode.HALF_UP);
-        BigDecimal durationMinutes = BigDecimal.valueOf(durationSeconds)
-                .divide(BigDecimal.valueOf(60), 6, RoundingMode.HALF_UP);
-        BigDecimal distanceFee = money(pricing.getPerKmPrice().multiply(extraKilometres));
-        BigDecimal durationFee = money(pricing.getPerMinutePrice().multiply(durationMinutes));
-        BigDecimal subtotal = money(pricing.getStartPrice().add(distanceFee).add(durationFee));
-        BigDecimal peakFee = money(subtotal.multiply(pricing.getPeakMultiplier().subtract(BigDecimal.ONE)));
-        BigDecimal amount = money(subtotal.add(peakFee).max(pricing.getMinimumPrice()));
+
+        BigDecimal routeDistance = BigDecimal.valueOf(distanceMeters, 3);
+        BigDecimal extraDistance = routeDistance.subtract(pricing.getStartingDistance()).max(BigDecimal.ZERO);
+        BigDecimal chargeableDistance;
+        if (extraDistance.compareTo(new BigDecimal("0.50")) < 0) {
+            chargeableDistance = BigDecimal.ZERO;
+        } else if (extraDistance.compareTo(BigDecimal.ONE) < 0) {
+            chargeableDistance = BigDecimal.ONE;
+        } else {
+            chargeableDistance = extraDistance;
+        }
+
+        BigDecimal unitPrice = BigDecimal.valueOf(pricing.getFreight())
+                .divide(pricing.getDistance(), 8, RoundingMode.HALF_UP);
+        long distanceFee = chargeableDistance.multiply(unitPrice)
+                .setScale(0, RoundingMode.HALF_UP)
+                .longValueExact();
 
         FlashDeliveryPriceBreakdown result = new FlashDeliveryPriceBreakdown();
-        result.setStartPrice(money(pricing.getStartPrice()));
-        result.setMinimumPrice(money(pricing.getMinimumPrice()));
+        result.setBillableDistance(chargeableDistance.setScale(2, RoundingMode.HALF_UP));
         result.setDistanceFee(distanceFee);
-        result.setDurationFee(durationFee);
-        result.setSubtotal(subtotal);
-        result.setPeakMultiplier(pricing.getPeakMultiplier().setScale(2, RoundingMode.HALF_UP));
-        result.setPeakFee(peakFee);
-        result.setAmount(amount);
+        result.setAmount(Math.addExact(pricing.getStartingFare(), distanceFee));
         return result;
     }
-
-    private BigDecimal money(BigDecimal value) {
-        return value.setScale(2, RoundingMode.HALF_UP);
-    }
 }

+ 2 - 0
ruoyi-admin/src/main/resources/i18n/messages.properties

@@ -305,6 +305,8 @@ flash.delivery.cancel.not.allowed=The current order status cannot be cancelled t
 flash.delivery.complete.not.allowed=The order is not ready to complete
 flash.delivery.state.changed=The order status changed; please refresh and retry
 flash.delivery.pricing.invalid=Pricing values must be valid positive numbers
+flash.delivery.pricing.not.available=No pricing is available for the current time period
+flash.delivery.pricing.overlap=Pricing periods for the same service cannot overlap
 flash.delivery.package.type.invalid=Please select a valid package type
 flash.delivery.package.size.invalid=Please select a valid package size tier
 flash.delivery.distance.too.far=Delivery distance cannot exceed 40 kilometres

+ 2 - 0
ruoyi-admin/src/main/resources/i18n/messages_en_US.properties

@@ -308,6 +308,8 @@ flash.delivery.cancel.not.allowed=The current order status cannot be cancelled t
 flash.delivery.complete.not.allowed=The order is not ready to complete
 flash.delivery.state.changed=The order status changed; please refresh and retry
 flash.delivery.pricing.invalid=Pricing values must be valid positive numbers
+flash.delivery.pricing.not.available=No pricing is available for the current time period
+flash.delivery.pricing.overlap=Pricing periods for the same service cannot overlap
 flash.delivery.package.type.invalid=Please select a valid package type
 flash.delivery.package.size.invalid=Please select a valid package size tier
 flash.delivery.distance.too.far=Delivery distance cannot exceed 40 kilometres

+ 2 - 0
ruoyi-admin/src/main/resources/i18n/messages_vi.properties

@@ -308,6 +308,8 @@ flash.delivery.cancel.not.allowed=Trạng thái hiện tại không cho phép h
 flash.delivery.complete.not.allowed=Đơn hàng chưa thể hoàn tất
 flash.delivery.state.changed=Trạng thái đơn đã thay đổi, vui lòng tải lại và thử lại
 flash.delivery.pricing.invalid=Cấu hình giá phải là số dương hợp lệ
+flash.delivery.pricing.not.available=Không có mức giá khả dụng trong khung giờ hiện tại
+flash.delivery.pricing.overlap=Khung giờ giá của cùng một loại dịch vụ không được chồng lấn
 flash.delivery.package.type.invalid=Vui lòng chọn loại kiện hàng hợp lệ
 flash.delivery.package.size.invalid=Vui lòng chọn mức trọng lượng kiện hàng hợp lệ
 flash.delivery.distance.too.far=Khoảng cách giao hàng không được vượt quá 40 km

+ 2 - 0
ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties

@@ -309,6 +309,8 @@ flash.delivery.cancel.not.allowed=当前订单状态不允许这样取消
 flash.delivery.complete.not.allowed=当前订单尚不能完成
 flash.delivery.state.changed=订单状态已变化,请刷新后重试
 flash.delivery.pricing.invalid=计价配置必须为有效正数
+flash.delivery.pricing.not.available=当前时段暂无可用运价
+flash.delivery.pricing.overlap=同一服务类型的运价时段不能重叠
 flash.delivery.package.type.invalid=请选择有效的包裹类型
 flash.delivery.package.size.invalid=请选择有效的包裹重量档
 flash.delivery.distance.too.far=配送距离不能超过40公里

+ 2 - 0
ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties

@@ -309,6 +309,8 @@ flash.delivery.cancel.not.allowed=目前訂單狀態不允許這樣取消
 flash.delivery.complete.not.allowed=目前訂單尚不能完成
 flash.delivery.state.changed=訂單狀態已變更,請重新整理後再試
 flash.delivery.pricing.invalid=計價設定必須為有效正數
+flash.delivery.pricing.not.available=目前時段暫無可用運價
+flash.delivery.pricing.overlap=同一服務類型的運價時段不能重疊
 flash.delivery.package.type.invalid=請選擇有效的包裹類型
 flash.delivery.package.size.invalid=請選擇有效的包裹重量級距
 flash.delivery.distance.too.far=配送距離不能超過40公里

+ 5 - 2
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryControllerContractTest.java

@@ -45,8 +45,11 @@ class FlashDeliveryControllerContractTest {
 
     @Test
     void pricingAndOrderInterventionsAreOperationAudited() throws Exception {
-        assertNotNull(FlashDeliveryAdminController.class.getMethod("updatePricing", String.class,
-                com.ruoyi.app.flashdelivery.dto.FlashDeliveryPricingUpdateRequest.class).getAnnotation(Log.class));
+        assertNotNull(FlashDeliveryAdminController.class.getMethod("createPricing",
+                com.ruoyi.app.flashdelivery.dto.FlashDeliveryPricingRequest.class).getAnnotation(Log.class));
+        assertNotNull(FlashDeliveryAdminController.class.getMethod("updatePricing", Long.class,
+                com.ruoyi.app.flashdelivery.dto.FlashDeliveryPricingRequest.class).getAnnotation(Log.class));
+        assertNotNull(FlashDeliveryAdminController.class.getMethod("deletePricing", Long.class).getAnnotation(Log.class));
         assertNotNull(FlashDeliveryAdminController.class.getMethod("cancel", Long.class,
                 com.ruoyi.app.flashdelivery.dto.FlashDeliveryReasonRequest.class).getAnnotation(Log.class));
         assertNotNull(FlashDeliveryAdminController.class.getMethod("complete", Long.class).getAnnotation(Log.class));

+ 168 - 26
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java

@@ -5,7 +5,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryAddressRequest;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryCreateRequest;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryProofRequest;
-import com.ruoyi.app.flashdelivery.dto.FlashDeliveryPricingUpdateRequest;
+import com.ruoyi.app.flashdelivery.dto.FlashDeliveryPricingRequest;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryQuoteRequest;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryDeliverRequest;
 import com.ruoyi.app.flashdelivery.route.FlashDeliveryRouteService;
@@ -22,6 +22,7 @@ import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.AfterAll;
 import org.mockito.ArgumentCaptor;
+import org.mockito.InOrder;
 import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
 import org.springframework.beans.factory.support.DefaultListableBeanFactory;
 import org.springframework.context.support.StaticMessageSource;
@@ -45,6 +46,7 @@ import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.inOrder;
 
 class FlashDeliveryApplicationServiceTest {
     private static ConfigurableListableBeanFactory originalBeanFactory;
@@ -66,22 +68,25 @@ class FlashDeliveryApplicationServiceTest {
     }
 
     @Test
-    void quoteUsesServerRouteAndEnabledPricing() {
+    void quoteUsesServerRouteAndCurrentTimePricing() {
         Fixture fixture = new Fixture();
-        when(fixture.pricingMapper.selectOne(any(Wrapper.class))).thenReturn(pricing());
+        when(fixture.pricingMapper.selectCurrent("HELP_SEND")).thenReturn(List.of(pricing()));
         when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(3001, 600, "ROUTE"));
 
         var quote = fixture.service.quote(request());
 
         assertEquals(3001, quote.getDistanceMeters());
-        assertEquals(new BigDecimal("108.02"), quote.getAmount());
+        assertEquals(90L, quote.getAmount());
+        assertEquals(1L, quote.getPricingId());
+        assertEquals("00:00", quote.getStartTime());
+        assertEquals("24:00", quote.getEndTime());
         assertEquals("ROUTE", quote.getDistanceSource());
     }
 
     @Test
     void quoteRejectsRoutesLongerThanFortyKilometres() {
         Fixture fixture = new Fixture();
-        when(fixture.pricingMapper.selectOne(any(Wrapper.class))).thenReturn(pricing());
+        when(fixture.pricingMapper.selectCurrent("HELP_SEND")).thenReturn(List.of(pricing()));
         when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(40001, 1800, "ROUTE"));
 
         assertThrows(ServiceException.class, () -> fixture.service.quote(request()));
@@ -142,7 +147,7 @@ class FlashDeliveryApplicationServiceTest {
     @Test
     void scheduledCreateGeneratesUserOnlyPinAndStoresSenderProof() {
         Fixture fixture = new Fixture();
-        when(fixture.pricingMapper.selectOne(any(Wrapper.class))).thenReturn(pricing());
+        when(fixture.pricingMapper.selectCurrent("HELP_SEND")).thenReturn(List.of(pricing()));
         when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
         doAnswer(invocation -> {
             FlashDeliveryOrder order = invocation.getArgument(0);
@@ -165,6 +170,14 @@ class FlashDeliveryApplicationServiceTest {
         verify(fixture.orderMapper).insert(saved.capture());
         assertEquals("SCHEDULED", saved.getValue().getDeliveryMode());
         assertEquals("DOCUMENT", saved.getValue().getPackageType());
+        assertEquals(1L, saved.getValue().getPricingId());
+        assertEquals("00:00", saved.getValue().getPricingStartTime());
+        assertEquals("24:00", saved.getValue().getPricingEndTime());
+        assertEquals(new BigDecimal("3.00"), saved.getValue().getStartingDistance());
+        assertEquals(90L, saved.getValue().getStartingFare());
+        assertEquals(new BigDecimal("1.00"), saved.getValue().getDistance());
+        assertEquals(15L, saved.getValue().getFreight());
+        assertEquals(0L, saved.getValue().getDistanceFee());
     }
 
     @Test
@@ -241,31 +254,144 @@ class FlashDeliveryApplicationServiceTest {
         FlashDeliveryQuoteRequest request = request();
         request.getDelivery().setAddress(request.getPickup().getAddress());
         request.getDelivery().setAddressDetail(request.getPickup().getAddressDetail());
-        when(fixture.pricingMapper.selectOne(any(Wrapper.class))).thenReturn(pricing());
-        when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(1000, 100, "ROUTE"));
-
         assertThrows(ServiceException.class, () -> fixture.service.quote(request));
     }
 
     @Test
     void concurrentPricingUpdateIsRejected() {
         Fixture fixture = new Fixture();
-        when(fixture.pricingMapper.selectOne(any(Wrapper.class))).thenReturn(pricing());
+        when(fixture.pricingMapper.selectById(1L)).thenReturn(pricing());
+        when(fixture.pricingMapper.countOverlapping("HELP_SEND", "00:00", "12:00", 1L)).thenReturn(0);
         when(fixture.pricingMapper.update(isNull(), any(Wrapper.class))).thenReturn(0);
-        FlashDeliveryPricingUpdateRequest request = new FlashDeliveryPricingUpdateRequest();
-        request.setStartPrice(new BigDecimal("10.00"));
-        request.setMinimumPrice(new BigDecimal("10.00"));
-        request.setStartDistanceMeters(3000);
-        request.setPerKmPrice(new BigDecimal("3.00"));
-        request.setPerMinutePrice(BigDecimal.ZERO);
-        request.setPeakMultiplier(BigDecimal.ONE);
-        request.setEnabled(true);
+        FlashDeliveryPricingRequest request = pricingRequest();
+        request.setEndTime("12:00");
+        request.setConfigVersion(1);
 
         assertThrows(ServiceException.class,
-                () -> fixture.service.updatePricing(1L, "HELP_SEND", request));
+                () -> fixture.service.updatePricing(1L, 1L, request));
         verify(fixture.pricingMapper, never()).updateById(any(FlashDeliveryPricing.class));
     }
 
+    @Test
+    void overlappingPricingPeriodIsRejectedBeforeInsert() {
+        Fixture fixture = new Fixture();
+        FlashDeliveryPricingRequest request = pricingRequest();
+        when(fixture.pricingMapper.countOverlapping("HELP_SEND", "00:00", "24:00", null)).thenReturn(1);
+
+        assertThrows(ServiceException.class, () -> fixture.service.createPricing(1L, request));
+
+        request.setDistance(new BigDecimal("0.004"));
+        assertThrows(ServiceException.class, () -> fixture.service.createPricing(1L, request));
+
+        verify(fixture.pricingMapper, never()).insert(any(FlashDeliveryPricing.class));
+    }
+
+    @Test
+    void pricingDistanceHalfCentimetreNormalizesToDatabaseMinimum() {
+        Fixture fixture = new Fixture();
+        FlashDeliveryPricingRequest request = pricingRequest();
+        request.setDistance(new BigDecimal("0.005"));
+
+        fixture.service.createPricing(1L, request);
+
+        ArgumentCaptor<FlashDeliveryPricing> saved = ArgumentCaptor.forClass(FlashDeliveryPricing.class);
+        verify(fixture.pricingMapper).insert(saved.capture());
+        assertEquals(new BigDecimal("0.01"), saved.getValue().getDistance());
+    }
+
+    @Test
+    void pricingDistanceThatRoundsToZeroIsRejectedBeforePersistence() {
+        Fixture fixture = new Fixture();
+        FlashDeliveryPricingRequest request = pricingRequest();
+        request.setDistance(new BigDecimal("0.001"));
+
+        assertThrows(ServiceException.class, () -> fixture.service.createPricing(1L, request));
+
+        verify(fixture.pricingMapper, never()).insert(any(FlashDeliveryPricing.class));
+    }
+
+    @Test
+    void createPricingPersistsImmediatelyActiveTimePeriod() {
+        Fixture fixture = new Fixture();
+        FlashDeliveryPricingRequest request = pricingRequest();
+        when(fixture.pricingMapper.countOverlapping("HELP_SEND", "00:00", "24:00", null)).thenReturn(0);
+
+        fixture.service.createPricing(7L, request);
+
+        ArgumentCaptor<FlashDeliveryPricing> saved = ArgumentCaptor.forClass(FlashDeliveryPricing.class);
+        verify(fixture.pricingMapper).insert(saved.capture());
+        assertEquals(1, saved.getValue().getConfigVersion());
+        assertEquals(7L, saved.getValue().getUpdatedBy());
+        assertEquals(90L, saved.getValue().getStartingFare());
+
+        InOrder writeOrder = inOrder(fixture.pricingMapper);
+        writeOrder.verify(fixture.pricingMapper).lockServiceType("HELP_SEND");
+        writeOrder.verify(fixture.pricingMapper)
+                .countOverlapping("HELP_SEND", "00:00", "24:00", null);
+        writeOrder.verify(fixture.pricingMapper).insert(any(FlashDeliveryPricing.class));
+    }
+
+    @Test
+    void homeOmitsServiceWithoutCurrentPricingPeriod() {
+        Fixture fixture = new Fixture();
+
+        assertTrue(fixture.service.home().getServices().isEmpty());
+    }
+
+    @Test
+    void quoteWithoutCurrentPricingPeriodFailsBeforeRouteLookup() {
+        Fixture fixture = new Fixture();
+
+        assertThrows(ServiceException.class, () -> fixture.service.quote(request()));
+
+        verify(fixture.routeService, never()).calculate(any(), any());
+    }
+
+    @Test
+    void multipleCurrentPricingPeriodsFailExplicitlyBeforeRouteLookup() {
+        Fixture fixture = new Fixture();
+        when(fixture.pricingMapper.selectCurrent("HELP_SEND"))
+                .thenReturn(List.of(pricing(), pricing()));
+
+        assertThrows(ServiceException.class, () -> fixture.service.quote(request()));
+
+        verify(fixture.routeService, never()).calculate(any(), any());
+    }
+
+    @Test
+    void movingPricingPeriodLocksOldAndNewServiceTypesInStableOrder() {
+        Fixture fixture = new Fixture();
+        FlashDeliveryPricing existing = pricing();
+        existing.setServiceType("URGENT");
+        when(fixture.pricingMapper.selectById(1L)).thenReturn(existing);
+        when(fixture.pricingMapper.update(isNull(), any(Wrapper.class))).thenReturn(1);
+        FlashDeliveryPricingRequest request = pricingRequest();
+        request.setConfigVersion(1);
+
+        fixture.service.updatePricing(9L, 1L, request);
+
+        InOrder lockOrder = inOrder(fixture.pricingMapper);
+        lockOrder.verify(fixture.pricingMapper).lockServiceType("HELP_SEND");
+        lockOrder.verify(fixture.pricingMapper).lockServiceType("URGENT");
+        lockOrder.verify(fixture.pricingMapper)
+                .countOverlapping("HELP_SEND", "00:00", "24:00", 1L);
+    }
+
+    @Test
+    void deletePricingRemovesConfigurationImmediately() {
+        Fixture fixture = new Fixture();
+        when(fixture.pricingMapper.selectById(18L)).thenReturn(pricing());
+        when(fixture.pricingMapper.deleteById(18L)).thenReturn(1);
+
+        fixture.service.deletePricing(18L);
+
+        InOrder deleteOrder = inOrder(fixture.pricingMapper);
+        deleteOrder.verify(fixture.pricingMapper).selectById(18L);
+        deleteOrder.verify(fixture.pricingMapper).lockServiceType("HELP_SEND");
+        deleteOrder.verify(fixture.pricingMapper).selectById(18L);
+        deleteOrder.verify(fixture.pricingMapper).deleteById(18L);
+    }
+
     private FlashDeliveryQuoteRequest request() {
         FlashDeliveryQuoteRequest request = new FlashDeliveryQuoteRequest();
         request.setServiceType("HELP_SEND");
@@ -310,17 +436,28 @@ class FlashDeliveryApplicationServiceTest {
         FlashDeliveryPricing pricing = new FlashDeliveryPricing();
         pricing.setId(1L);
         pricing.setServiceType("HELP_SEND");
-        pricing.setStartPrice(new BigDecimal("70.00"));
-        pricing.setMinimumPrice(new BigDecimal("80.00"));
-        pricing.setStartDistanceMeters(1000);
-        pricing.setPerKmPrice(new BigDecimal("19.00"));
-        pricing.setPerMinutePrice(BigDecimal.ZERO);
-        pricing.setPeakMultiplier(BigDecimal.ONE);
-        pricing.setEnabled(true);
+        pricing.setStartTime("00:00");
+        pricing.setEndTime("24:00");
+        pricing.setStartingDistance(new BigDecimal("3.00"));
+        pricing.setStartingFare(90L);
+        pricing.setDistance(new BigDecimal("1.00"));
+        pricing.setFreight(15L);
         pricing.setConfigVersion(1);
         return pricing;
     }
 
+    private FlashDeliveryPricingRequest pricingRequest() {
+        FlashDeliveryPricingRequest request = new FlashDeliveryPricingRequest();
+        request.setServiceType("HELP_SEND");
+        request.setStartTime("00:00");
+        request.setEndTime("24:00");
+        request.setStartingDistance(new BigDecimal("3.00"));
+        request.setStartingFare(90L);
+        request.setDistance(new BigDecimal("1.00"));
+        request.setFreight(15L);
+        return request;
+    }
+
     private static class Fixture {
         final FlashDeliveryOrderMapper orderMapper = mock(FlashDeliveryOrderMapper.class);
         final FlashDeliveryPricingMapper pricingMapper = mock(FlashDeliveryPricingMapper.class);
@@ -331,5 +468,10 @@ class FlashDeliveryApplicationServiceTest {
         final FlashDeliveryApplicationService service = new FlashDeliveryApplicationService(
                 orderMapper, pricingMapper, imageMapper, logMapper, userMapper,
                 routeService, new FlashDeliveryPricingCalculator());
+
+        Fixture() {
+            when(pricingMapper.lockServiceType(any()))
+                    .thenAnswer(invocation -> invocation.getArgument(0));
+        }
     }
 }

+ 2 - 1
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryI18nContractTest.java

@@ -23,7 +23,8 @@ class FlashDeliveryI18nContractTest {
             "flash.delivery.transition.not.allowed",
             "flash.delivery.cancel.reason.required", "flash.delivery.cancel.not.allowed",
             "flash.delivery.complete.not.allowed", "flash.delivery.state.changed",
-            "flash.delivery.pricing.invalid", "flash.delivery.pricing.not.found",
+            "flash.delivery.pricing.invalid", "flash.delivery.pricing.not.available",
+            "flash.delivery.pricing.overlap", "flash.delivery.pricing.not.found",
             "flash.delivery.pricing.changed",
             "address.access.denied", "address.data.invalid");
 

+ 64 - 26
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryPricingCalculatorTest.java

@@ -13,45 +13,83 @@ class FlashDeliveryPricingCalculatorTest {
     private final FlashDeliveryPricingCalculator calculator = new FlashDeliveryPricingCalculator();
 
     @Test
-    void appliesMinimumPriceAtIncludedDistanceBoundary() {
-        assertEquals(new BigDecimal("80.00"), calculator.calculate(pricing(), 1000, 0));
+    void chargesOnlyStartingFareWithinStartingDistance() {
+        var breakdown = calculator.calculateBreakdown(pricing(), 3000);
+
+        assertEquals(new BigDecimal("0.00"), breakdown.getBillableDistance());
+        assertEquals(0L, breakdown.getDistanceFee());
+        assertEquals(90L, breakdown.getAmount());
+    }
+
+    @Test
+    void appliesFoodDeliveryHalfKilometreBoundary() {
+        assertBreakdown(3490, "0.00", 0L, 90L);
+        assertBreakdown(3494, "0.00", 0L, 90L);
+        assertBreakdown(3495, "0.00", 0L, 90L);
+        assertBreakdown(3499, "0.00", 0L, 90L);
+        assertBreakdown(3500, "1.00", 15L, 105L);
+        assertBreakdown(3990, "1.00", 15L, 105L);
+        assertBreakdown(4200, "1.20", 18L, 108L);
     }
 
     @Test
-    void returnsDistanceDurationAndPeakBreakdownUsingActualUnits() {
+    void supportsConfiguredBillingDistanceAndRoundsTaiwanDollarHalfUp() {
         FlashDeliveryPricing pricing = pricing();
-        pricing.setPerMinutePrice(new BigDecimal("1.50"));
-        pricing.setPeakMultiplier(new BigDecimal("1.10"));
+        pricing.setDistance(new BigDecimal("2.00"));
+        pricing.setFreight(25L);
 
-        var breakdown = calculator.calculateBreakdown(pricing, 3200, 600);
+        var breakdown = calculator.calculateBreakdown(pricing, 6000);
 
-        assertEquals(new BigDecimal("41.80"), breakdown.getDistanceFee());
-        assertEquals(new BigDecimal("15.00"), breakdown.getDurationFee());
-        assertEquals(new BigDecimal("126.80"), breakdown.getSubtotal());
-        assertEquals(new BigDecimal("12.68"), breakdown.getPeakFee());
-        assertEquals(new BigDecimal("139.48"), breakdown.getAmount());
+        assertEquals(new BigDecimal("3.00"), breakdown.getBillableDistance());
+        assertEquals(38L, breakdown.getDistanceFee());
+        assertEquals(128L, breakdown.getAmount());
+
+        pricing.setFreight(1L);
+        assertEquals(1L, calculator.calculateBreakdown(pricing, 4000).getDistanceFee());
+
+        pricing.setDistance(BigDecimal.ONE);
+        pricing.setFreight(500L);
+        assertEquals(601L, calculator.calculateBreakdown(pricing, 4201).getDistanceFee());
     }
 
     @Test
-    void rejectsNonPositiveDistanceOrInvalidPricing() {
-        assertThrows(IllegalArgumentException.class, () -> calculator.calculate(pricing(), 0, 0));
-        FlashDeliveryPricing pricing = pricing();
-        pricing.setPerKmPrice(BigDecimal.ZERO);
-        assertThrows(IllegalArgumentException.class, () -> calculator.calculate(pricing, 3001, 0));
-        FlashDeliveryPricing discountPeak = pricing();
-        discountPeak.setPeakMultiplier(new BigDecimal("0.90"));
-        assertThrows(IllegalArgumentException.class, () -> calculator.calculate(discountPeak, 3001, 0));
+    void rejectsInvalidDistanceOrPricing() {
+        assertThrows(IllegalArgumentException.class, () -> calculator.calculateBreakdown(pricing(), 0));
+
+        FlashDeliveryPricing invalidStartingDistance = pricing();
+        invalidStartingDistance.setStartingDistance(BigDecimal.ZERO);
+        assertThrows(IllegalArgumentException.class,
+                () -> calculator.calculateBreakdown(invalidStartingDistance, 3000));
+
+        FlashDeliveryPricing invalidStartingFare = pricing();
+        invalidStartingFare.setStartingFare(0L);
+        assertThrows(IllegalArgumentException.class,
+                () -> calculator.calculateBreakdown(invalidStartingFare, 3000));
+
+        FlashDeliveryPricing invalidDistance = pricing();
+        invalidDistance.setDistance(BigDecimal.ZERO);
+        assertThrows(IllegalArgumentException.class,
+                () -> calculator.calculateBreakdown(invalidDistance, 3000));
+
+        FlashDeliveryPricing invalidFreight = pricing();
+        invalidFreight.setFreight(0L);
+        assertThrows(IllegalArgumentException.class,
+                () -> calculator.calculateBreakdown(invalidFreight, 3000));
+    }
+
+    private void assertBreakdown(int distanceMeters, String billableDistance, long distanceFee, long amount) {
+        var breakdown = calculator.calculateBreakdown(pricing(), distanceMeters);
+        assertEquals(new BigDecimal(billableDistance), breakdown.getBillableDistance());
+        assertEquals(distanceFee, breakdown.getDistanceFee());
+        assertEquals(amount, breakdown.getAmount());
     }
 
     private FlashDeliveryPricing pricing() {
         FlashDeliveryPricing pricing = new FlashDeliveryPricing();
-        pricing.setStartPrice(new BigDecimal("70.00"));
-        pricing.setMinimumPrice(new BigDecimal("80.00"));
-        pricing.setStartDistanceMeters(1000);
-        pricing.setPerKmPrice(new BigDecimal("19.00"));
-        pricing.setPerMinutePrice(BigDecimal.ZERO);
-        pricing.setPeakMultiplier(BigDecimal.ONE);
-        pricing.setEnabled(true);
+        pricing.setStartingDistance(new BigDecimal("3.00"));
+        pricing.setStartingFare(90L);
+        pricing.setDistance(new BigDecimal("1.00"));
+        pricing.setFreight(15L);
         return pricing;
     }
 }

+ 9 - 10
ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryOrder.java

@@ -61,18 +61,17 @@ public class FlashDeliveryOrder {
     private Integer estimatedDurationSeconds;
 
     /** 下单时的金额和计价配置快照,后续后台调价不会修改这些字段。 */
-    private BigDecimal amount;
+    private Long amount;
     private String currency;
+    private Long pricingId;
     private Integer pricingVersion;
-    private BigDecimal startPrice;
-    private BigDecimal minimumPrice;
-    private Integer startDistanceMeters;
-    private BigDecimal perKmPrice;
-    private BigDecimal perMinutePrice;
-    private BigDecimal peakMultiplier;
-    private BigDecimal distanceFee;
-    private BigDecimal durationFee;
-    private BigDecimal peakFee;
+    private String pricingStartTime;
+    private String pricingEndTime;
+    private BigDecimal startingDistance;
+    private Long startingFare;
+    private BigDecimal distance;
+    private Long freight;
+    private Long distanceFee;
     private String userNote;
     /** 订单条件更新版本号,用于记录并发状态变化次数。 */
     private Integer version;

+ 7 - 12
ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryPricing.java

@@ -8,24 +8,19 @@ import lombok.Data;
 import java.math.BigDecimal;
 import java.util.Date;
 
-/** 某一闪送服务类型的后台计价配置。 */
+/** 某一种闪送服务在指定时间段内立即生效的运价配置。 */
 @Data
 @TableName("flash_delivery_pricing")
 public class FlashDeliveryPricing {
     @TableId(type = IdType.AUTO)
     private Long id;
     private String serviceType;
-    /** 起步价,覆盖 startDistanceMeters 以内的路线。 */
-    private BigDecimal startPrice;
-    /** 报价应用加成后仍不得低于该金额。 */
-    private BigDecimal minimumPrice;
-    private Integer startDistanceMeters;
-    /** 超出起步距离后按实际小数公里计算的单价。 */
-    private BigDecimal perKmPrice;
-    private BigDecimal perMinutePrice;
-    /** 当前运营时段使用的报价倍率,1 表示无加成。 */
-    private BigDecimal peakMultiplier;
-    private Boolean enabled;
+    private String startTime;
+    private String endTime;
+    private BigDecimal startingDistance;
+    private Long startingFare;
+    private BigDecimal distance;
+    private Long freight;
     /** 管理员并发更新使用的乐观版本号。 */
     private Integer configVersion;
     private Long updatedBy;

+ 38 - 2
ruoyi-system/src/main/java/com/ruoyi/system/mapper/flash/FlashDeliveryPricingMapper.java

@@ -2,6 +2,42 @@ package com.ruoyi.system.mapper.flash;
 
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.ruoyi.system.domain.flash.FlashDeliveryPricing;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
 
-/** 闪送计价配置持久化接口。 */
-public interface FlashDeliveryPricingMapper extends BaseMapper<FlashDeliveryPricing> { }
+import java.util.List;
+
+/** 闪送时段运价持久化接口。 */
+public interface FlashDeliveryPricingMapper extends BaseMapper<FlashDeliveryPricing> {
+    @Select("""
+            select service_type
+            from flash_delivery_pricing_lock
+            where service_type = #{serviceType}
+            for update
+            """)
+    String lockServiceType(@Param("serviceType") String serviceType);
+
+    @Select("""
+            select id, service_type, start_time, end_time, starting_distance, starting_fare,
+                   distance, freight, config_version, updated_by, create_time, update_time
+            from flash_delivery_pricing
+            where service_type = #{serviceType}
+              and time(start_time) <= current_time()
+              and time(end_time) > current_time()
+            order by start_time desc
+            """)
+    List<FlashDeliveryPricing> selectCurrent(@Param("serviceType") String serviceType);
+
+    @Select("""
+            select count(1)
+            from flash_delivery_pricing
+            where service_type = #{serviceType}
+              and start_time < #{endTime}
+              and end_time > #{startTime}
+              and (#{excludedId} is null or id <> #{excludedId})
+            """)
+    int countOverlapping(@Param("serviceType") String serviceType,
+                         @Param("startTime") String startTime,
+                         @Param("endTime") String endTime,
+                         @Param("excludedId") Long excludedId);
+}

+ 7 - 8
ruoyi-system/src/main/resources/mapper/flash/FlashDeliveryOrderMapper.xml

@@ -39,16 +39,15 @@
         <result property="estimatedDurationSeconds" column="estimated_duration_seconds"/>
         <result property="amount" column="amount"/>
         <result property="currency" column="currency"/>
+        <result property="pricingId" column="pricing_id"/>
         <result property="pricingVersion" column="pricing_version"/>
-        <result property="startPrice" column="start_price"/>
-        <result property="minimumPrice" column="minimum_price"/>
-        <result property="startDistanceMeters" column="start_distance_meters"/>
-        <result property="perKmPrice" column="per_km_price"/>
-        <result property="perMinutePrice" column="per_minute_price"/>
-        <result property="peakMultiplier" column="peak_multiplier"/>
+        <result property="pricingStartTime" column="pricing_start_time"/>
+        <result property="pricingEndTime" column="pricing_end_time"/>
+        <result property="startingDistance" column="starting_distance"/>
+        <result property="startingFare" column="starting_fare"/>
+        <result property="distance" column="distance"/>
+        <result property="freight" column="freight"/>
         <result property="distanceFee" column="distance_fee"/>
-        <result property="durationFee" column="duration_fee"/>
-        <result property="peakFee" column="peak_fee"/>
         <result property="userNote" column="user_note"/>
         <result property="version" column="version"/>
         <result property="acceptedAt" column="accepted_at"/>

+ 10 - 0
ruoyi-system/src/test/java/com/ruoyi/system/mapper/flash/FlashDeliveryMapperXmlTest.java

@@ -8,6 +8,8 @@ import org.junit.jupiter.api.Test;
 
 import java.io.InputStream;
 import java.nio.charset.StandardCharsets;
+import java.util.Set;
+import java.util.stream.Collectors;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
@@ -34,6 +36,14 @@ class FlashDeliveryMapperXmlTest {
                 .getResultMappings().stream().filter(item -> "amount".equals(item.getProperty()))
                 .findFirst().orElseThrow();
         assertEquals("amount", amount.getColumn());
+        Set<String> mappedProperties = configuration
+                .getResultMap("com.ruoyi.system.mapper.flash.FlashDeliveryOrderMapper.FlashDeliveryOrderResult")
+                .getResultMappings().stream()
+                .map(ResultMapping::getProperty)
+                .collect(Collectors.toSet());
+        assertTrue(mappedProperties.containsAll(Set.of(
+                "pricingId", "pricingVersion", "pricingStartTime", "pricingEndTime",
+                "startingDistance", "startingFare", "distance", "freight", "distanceFee")));
         assertTrue(xml.contains("status = 'WAITING_ACCEPTANCE'"));
         assertTrue(xml.contains("rider_id is null"));
         assertTrue(xml.contains("scheduled_pickup_start_at &lt;= #{now}"));

+ 50 - 0
ruoyi-system/src/test/java/com/ruoyi/system/mapper/flash/FlashDeliveryPricingMapperContractTest.java

@@ -0,0 +1,50 @@
+package com.ruoyi.system.mapper.flash;
+
+import org.apache.ibatis.annotations.Select;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Method;
+import java.util.Arrays;
+import java.util.Locale;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class FlashDeliveryPricingMapperContractTest {
+
+    @Test
+    void serviceTypeLockUsesDatabaseRowLock() throws Exception {
+        Method method = FlashDeliveryPricingMapper.class.getMethod("lockServiceType", String.class);
+        String sql = sql(method);
+
+        assertTrue(sql.contains("flash_delivery_pricing_lock"));
+        assertTrue(sql.contains("for update"));
+    }
+
+    @Test
+    void currentPeriodUsesInclusiveStartExclusiveEndAndDoesNotHideDuplicates() throws Exception {
+        Method method = FlashDeliveryPricingMapper.class.getMethod("selectCurrent", String.class);
+        String sql = sql(method);
+
+        assertTrue(sql.contains("time(start_time) <= current_time()"));
+        assertTrue(sql.contains("time(end_time) > current_time()"));
+        assertFalse(sql.contains("limit"));
+    }
+
+    @Test
+    void overlapQueryAllowsAdjacentPeriodsIncludingEndOfDay() throws Exception {
+        Method method = FlashDeliveryPricingMapper.class.getMethod(
+                "countOverlapping", String.class, String.class, String.class, Long.class);
+        String sql = sql(method);
+
+        assertTrue(sql.contains("start_time < #{endtime}"));
+        assertTrue(sql.contains("end_time > #{starttime}"));
+    }
+
+    private String sql(Method method) {
+        return Arrays.stream(method.getAnnotation(Select.class).value())
+                .reduce("", (left, right) -> left + " " + right)
+                .replaceAll("\\s+", " ")
+                .toLowerCase(Locale.ROOT);
+    }
+}

+ 5 - 1
specs/024-flash-delivery/data-model.md

@@ -8,7 +8,11 @@
 
 ## `flash_delivery_pricing`
 
-每个服务类型可保存多个运价时段。字段为 `service_type`、`start_time`、`end_time`、`starting_distance`、整数 `starting_fare`、`distance`、整数 `freight`、`config_version`、`updated_by` 和创建/更新时间。配置保存即生效,不保存 `enabled`;同一服务类型的时间段不得重叠,索引支持按服务类型和时间匹配当前时段。有效修改后版本加一,历史订单不回写。
+每个服务类型可保存多个运价时段。字段为 `service_type`、`start_time`、`end_time`、`starting_distance`、整数 `starting_fare`、`distance`、整数 `freight`、`config_version`、`updated_by` 和创建/更新时间。配置保存即生效,不保存 `enabled`;同一服务类型的时间段不得重叠,索引支持按服务类型和时间匹配当前时段。有效修改后版本加一,历史订单不回写。当前时段查询不使用 `LIMIT 1` 隐藏异常数据;若匹配到多条则明确返回配置重叠错误。
+
+## `flash_delivery_pricing_lock`
+
+以 `service_type` 为主键,固定保存 `HELP_SEND`、`HELP_PICKUP`、`URGENT` 三行。新增、修改和删除运价时,事务先通过 `SELECT ... FOR UPDATE` 锁定涉及的服务类型,再检查重叠并写入,避免并发请求同时通过“先查重、后写入”。修改跨服务类型时按服务类型字典序加锁,避免反向加锁造成死锁。
 
 ## `flash_delivery_order_image`
 

+ 6 - 6
specs/024-flash-delivery/plan.md

@@ -181,7 +181,7 @@ git diff --check
 
 **目标**:将闪送计价改为与现有外卖订单一致的“当前时段 + 起送距离/价格 + 计价距离/金额”规则,移除最低价、时长费、尖峰倍率和启停状态,并让平台在独立闪送入口维护三种服务的多个运价时段。
 
-**架构**:`flash_delivery_pricing` 继续作为闪送独立配置表,但从每服务单行改为每服务多时段。`FlashDeliveryPricingMapper` 在数据库当前时间下返回唯一匹配时段并提供重叠计数;`FlashDeliveryPricingCalculator` 只负责距离边界和整数 TWD 计算;应用服务负责 CRUD、版本、订单快照和业务错误。平台前端复用现有价格页与权限,改为时段新增、修改和删除。
+**架构**:`flash_delivery_pricing` 继续作为闪送独立配置表,但从每服务单行改为每服务多时段;`flash_delivery_pricing_lock` 为三种服务各保存一行事务锁。`FlashDeliveryPricingMapper` 在数据库当前时间下返回全部匹配时段、提供重叠计数并用 `SELECT ... FOR UPDATE` 串行化同服务写入;应用服务要求当前时段唯一,异常多行时显式失败。`FlashDeliveryPricingCalculator` 只负责距离边界和整数 TWD 计算;应用服务负责 CRUD、版本、订单快照和业务错误。平台前端复用现有价格页与权限,改为时段新增、修改和删除。
 
 **技术栈**:JDK 21、Spring Boot、MyBatis-Plus/MyBatis、MySQL、JUnit 5/Mockito;Vue 2.6、Element UI 2.15、Vue i18n 8、Node `node:test`。
 
@@ -191,7 +191,7 @@ git diff --check
 - 不直接执行数据库迁移;删除测试阶段旧计价数据和字段的 SQL 只追加到 `updatesql/sql.md`。
 - 路线预计时长继续保存和展示,但不得参与计价。
 - 起送价格、计价金额、里程费用和最终金额均为整数 TWD;计算结果使用 `RoundingMode.HALF_UP` 取整到 1 元,不做千位特殊取整。
-- 同一服务类型的时段使用开始包含、结束不包含语义且不得重叠;保存即生效,删除即失效,不提供 `enabled`。
+- 同一服务类型的时段使用开始包含、结束不包含语义且不得重叠;写操作先取得服务类型数据库行锁再查重,保存即生效,删除即失效,不提供 `enabled`。
 - Controller 使用明确 DTO 和显式注解,平台配置接口继续使用 `flash:pricing:list/edit`。
 - 前端新增可见文本同步简中、繁中、英文和越南文,文件保留 CRLF,不增加 npm 依赖。
 - 只修改闪送功能所需文件,不触碰工作区原有脏文件。
@@ -232,8 +232,8 @@ foodie-admin-vue/
 ### 后端接口与数据流
 
 1. `FlashDeliveryPricing` 使用 `startTime,endTime,startingDistance,startingFare,distance,freight`;距离为正十进制公里,金额为正 `Long`。
-2. `selectCurrent(serviceType)` 使用数据库 `CURTIME()` 和 `start_time <= CURTIME() AND end_time > CURTIME()` 匹配唯一时段;找不到时返回国际化业务错误
-3. `countOverlaps(serviceType,startTime,endTime,excludedId)` 使用 `start_time < endTime AND end_time > startTime` 检测重叠;新增和修改均在写入前校验
+2. `selectCurrent(serviceType)` 使用数据库 `CURTIME()` 和 `start_time <= CURTIME() AND end_time > CURTIME()` 返回全部匹配时段;找不到时返回无可用运价错误,超过一条时返回配置重叠错误,不用 `LIMIT 1` 掩盖异常
+3. `countOverlaps(serviceType,startTime,endTime,excludedId)` 使用 `start_time < endTime AND end_time > startTime` 检测重叠。新增、修改和删除在事务中先锁定 `flash_delivery_pricing_lock` 对应服务行;跨服务修改按字典序锁定旧、新服务,再校验并写入
 4. 计价器签名改为 `calculateBreakdown(FlashDeliveryPricing pricing, int distanceMeters)`。超出公里小于 `0.5` 时计费公里为 `0`,小于 `1` 时为 `1`,否则使用实际超出公里;`distanceFee` 四舍五入为 `Long`,`amount=startingFare+distanceFee`。
 5. `GET /admin/pricing` 返回全部时段;`POST /admin/pricing` 新增;`PUT /admin/pricing/{id}` 按版本更新;`DELETE /admin/pricing/{id}` 删除。新增、修改和删除都使用 `flash:pricing:edit`。
 6. `home` 对三种服务分别读取当前时段,只返回有配置的服务;`quote/create` 每次重新读取当前时段并计算。订单固化 `pricingId,pricingVersion,pricingStartTime,pricingEndTime,startingDistance,startingFare,distance,freight,distanceFee`。
@@ -251,10 +251,10 @@ foodie-admin-vue/
 
 1. 先改 `FlashDeliveryPricingCalculatorTest`,覆盖起送内、超出 `0.49/0.50/0.99/1.20` 公里、非 1 公里计价距离、`.5` 金额四舍五入及非法配置;确认旧实现编译或断言失败。
 2. 再改应用服务和 Controller 契约测试,覆盖当前时段读取、无时段、重叠新增/修改、版本冲突、删除、整数快照和新路由;确认旧接口失败。
-3. 实现实体、Mapper、DTO、计算器、应用服务、Controller 和四语言后端错误,使定向后端测试转绿。
+3. 实现实体、并发锁表、Mapper、DTO、计算器、应用服务、Controller 和四语言后端错误,使定向后端测试转绿。
 4. 先改 Node 测试,要求新增/修改/删除请求、六字段载荷、时段/整数校验、页面权限和四语言新 key,并确认旧前端失败。
 5. 实现前端请求、纯函数、价格页、订单详情与四语言,使 Node 测试转绿。
-6. 追加开发阶段清理 SQL,删除旧配置数据和两表旧字段,增加时段及新订单快照字段;不执行 SQL。
+6. 追加开发阶段重建 SQL,删除旧闪送测试表并创建运价锁、时段配置及新订单快照最终结构;不执行 SQL。
 
 ### 验证命令
 

+ 1 - 1
specs/024-flash-delivery/spec.md

@@ -4,7 +4,7 @@
 
 **创建日期**:2026-08-31
 
-**状态**:平台前端增量已实现;2026-09-01 外卖同规则时段运价调整已确认,待计划与实现
+**状态**:平台前端与外卖同规则时段运价调整已实现并通过约定验证
 
 **输入**:基于蓝湖“闪送”分组 7 个设计页面及 2026-08-24 用户端/骑手端补充原型,实现帮送、帮取、加急送的完整非支付业务闭环,包括共享地址簿、路线报价、包裹信息、立即/预约配送、可选 PIN 交付、创建订单、骑手主动抢单、取件、送达、用户签收、平台配置与介入。
 

+ 9 - 9
specs/024-flash-delivery/tasks.md

@@ -67,12 +67,12 @@
 
 ## Phase 10:外卖同规则时段运价
 
-- [ ] T039 [US1] 先重写 `FlashDeliveryPricingCalculatorTest`,覆盖起送内、0.49/0.50/0.99/1.20 公里边界、非 1 公里计价距离、整数 HALF_UP 和非法配置,并确认旧计价实现红灯
-- [ ] T040 [US1] 重写 `FlashDeliveryPricing`、`FlashDeliveryOrder`、费用 DTO、订单 XML 和计价器,删除最低价、分钟价、尖峰与启停字段,实现整数 TWD 的外卖同规则距离计价
-- [ ] T041 [US5] 先扩展 `FlashDeliveryApplicationServiceTest` 与 `FlashDeliveryControllerContractTest`,覆盖当前时段、无时段、重叠、新增、按 ID 修改、版本冲突、删除和新订单快照,并确认旧服务接口红灯
-- [ ] T042 [US5] 实现 `FlashDeliveryPricingMapper` 当前时段/重叠查询、时段 CRUD、home/quote/create 当前时段读取、完整快照及四语言业务错误,使后端定向测试转绿
-- [ ] T043 [US6] 先更新 `tests/flash-delivery.test.cjs`,覆盖价格 GET/POST/PUT/DELETE、六字段载荷、时段与整数校验、页面权限和四语言 key,并确认旧前端红灯
-- [ ] T044 [US6] 更新前端 API、`shared.js`、价格时段新增/编辑/删除页面、订单费用详情和四语言,删除启停、最低价、分钟价与尖峰展示,使定向测试转绿
-- [ ] T045 在 `updatesql/sql.md` 追加开发阶段迁移:清理旧闪送计价数据,删除两表旧计价字段,增加时段配置、新订单快照字段与索引;只记录不执行
-- [ ] T046 使用 JDK 21 运行后端定向测试和模块构建,运行前端定向测试、强制 ESLint、生产构建、CRLF 与两个仓库差异检查
-- [ ] T047 核对两个仓库暂存范围,分别使用中文提交并推送当前 `test` 分支,不包含后端仓库原有脏文件
+- [x] T039 [US1] 先重写 `FlashDeliveryPricingCalculatorTest`,覆盖起送内、0.49/0.50/0.99/1.20 公里边界、非 1 公里计价距离、整数 HALF_UP 和非法配置,并确认旧计价实现红灯
+- [x] T040 [US1] 重写 `FlashDeliveryPricing`、`FlashDeliveryOrder`、费用 DTO、订单 XML 和计价器,删除最低价、分钟价、尖峰与启停字段,实现整数 TWD 的外卖同规则距离计价
+- [x] T041 [US5] 先扩展 `FlashDeliveryApplicationServiceTest` 与 `FlashDeliveryControllerContractTest`,覆盖当前时段、无时段、重叠、新增、按 ID 修改、版本冲突、删除和新订单快照,并确认旧服务接口红灯
+- [x] T042 [US5] 实现 `FlashDeliveryPricingMapper` 当前时段/重叠查询、服务类型行锁、时段 CRUD、home/quote/create 当前时段唯一性检查、完整快照及四语言业务错误,使后端定向测试转绿
+- [x] T043 [US6] 先更新 `tests/flash-delivery.test.cjs`,覆盖价格 GET/POST/PUT/DELETE、六字段载荷、时段与整数校验、页面权限和四语言 key,并确认旧前端红灯
+- [x] T044 [US6] 更新前端 API、`shared.js`、价格时段新增/编辑/删除页面、订单费用详情和四语言,删除启停、最低价、分钟价与尖峰展示,使定向测试转绿
+- [x] T045 在 `updatesql/sql.md` 追加开发阶段重建脚本:删除旧闪送测试表并创建运价锁、时段配置、新订单快照与索引的最终结构;只记录不执行
+- [x] T046 使用 JDK 21 运行后端定向测试和模块构建,运行前端定向测试、强制 ESLint、生产构建、CRLF 与两个仓库差异检查
+- [x] T047 核对两个仓库暂存范围,分别使用中文提交并推送当前 `test` 分支,不包含后端仓库原有脏文件

+ 133 - 0
updatesql/sql.md

@@ -1208,3 +1208,136 @@ WHERE child.perms IN ('flash:pricing:list','flash:order:list')
   );
 ```
 
+## 2026-09-01 闪送时段运价与整数新台币计价
+
+> 用途:移除时长费、尖峰倍率、最低价和启停状态,改为按服务类型配置多个不重叠时段,并按外卖订单的公里运费规则计算整数新台币。当前处于开发测试阶段,不保留闪送历史订单和旧运价数据。只记录脚本,由开发者手动执行。
+
+```sql
+-- 旧闪送表可能处于初版或补充原型版。开发测试数据无需保留,直接重建为最终结构,
+-- 避免因 minimum_price 等旧增量尚未执行而再次出现 Unknown column。
+DROP TABLE IF EXISTS flash_delivery_order_image;
+DROP TABLE IF EXISTS flash_delivery_order_log;
+DROP TABLE IF EXISTS flash_delivery_order;
+DROP TABLE IF EXISTS flash_delivery_pricing;
+DROP TABLE IF EXISTS flash_delivery_pricing_lock;
+
+CREATE TABLE flash_delivery_pricing_lock (
+  service_type VARCHAR(20) NOT NULL,
+  PRIMARY KEY (service_type)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='闪送运价服务类型并发锁';
+
+INSERT INTO flash_delivery_pricing_lock (service_type)
+VALUES ('HELP_SEND'), ('HELP_PICKUP'), ('URGENT');
+
+CREATE TABLE flash_delivery_pricing (
+  id BIGINT NOT NULL AUTO_INCREMENT,
+  service_type VARCHAR(20) NOT NULL,
+  start_time CHAR(5) NOT NULL,
+  end_time CHAR(5) NOT NULL,
+  starting_distance DECIMAL(8,2) NOT NULL,
+  starting_fare BIGINT NOT NULL,
+  distance DECIMAL(8,2) NOT NULL,
+  freight BIGINT NOT NULL,
+  config_version INT NOT NULL DEFAULT 1,
+  updated_by BIGINT NULL,
+  create_time DATETIME NOT NULL,
+  update_time DATETIME NOT NULL,
+  PRIMARY KEY (id),
+  KEY idx_flash_pricing_period (service_type, start_time, end_time)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='闪送时段运价配置';
+
+CREATE TABLE flash_delivery_order (
+  id BIGINT NOT NULL AUTO_INCREMENT,
+  order_no VARCHAR(32) NOT NULL,
+  client_request_id VARCHAR(64) NOT NULL,
+  user_id BIGINT NOT NULL,
+  rider_id BIGINT NULL,
+  service_type VARCHAR(20) NOT NULL,
+  status VARCHAR(24) NOT NULL,
+  package_type VARCHAR(32) NOT NULL DEFAULT 'OTHER',
+  package_size VARCHAR(16) NOT NULL DEFAULT 'SMALL',
+  delivery_mode VARCHAR(16) NOT NULL DEFAULT 'NOW',
+  scheduled_pickup_start_at DATETIME NULL,
+  scheduled_pickup_end_at DATETIME NULL,
+  pin_required TINYINT(1) NOT NULL DEFAULT 0,
+  delivery_pin_code CHAR(4) NULL,
+  pickup_name VARCHAR(64) NOT NULL,
+  pickup_phone VARCHAR(32) NOT NULL,
+  pickup_address VARCHAR(255) NOT NULL,
+  pickup_address_detail VARCHAR(255) NULL,
+  pickup_city VARCHAR(64) NULL,
+  pickup_area VARCHAR(64) NULL,
+  pickup_handoff_method VARCHAR(40) NULL,
+  pickup_longitude DECIMAL(10,7) NOT NULL,
+  pickup_latitude DECIMAL(10,7) NOT NULL,
+  delivery_name VARCHAR(64) NOT NULL,
+  delivery_phone VARCHAR(32) NOT NULL,
+  delivery_address VARCHAR(255) NOT NULL,
+  delivery_address_detail VARCHAR(255) NULL,
+  delivery_city VARCHAR(64) NULL,
+  delivery_area VARCHAR(64) NULL,
+  delivery_handoff_method VARCHAR(40) NULL,
+  delivery_longitude DECIMAL(10,7) NOT NULL,
+  delivery_latitude DECIMAL(10,7) NOT NULL,
+  distance_meters INT NOT NULL,
+  distance_source VARCHAR(20) NOT NULL,
+  estimated_duration_seconds INT NULL,
+  amount BIGINT NOT NULL,
+  currency CHAR(3) NOT NULL DEFAULT 'TWD',
+  pricing_id BIGINT NOT NULL,
+  pricing_version INT NOT NULL,
+  pricing_start_time CHAR(5) NOT NULL,
+  pricing_end_time CHAR(5) NOT NULL,
+  starting_distance DECIMAL(8,2) NOT NULL,
+  starting_fare BIGINT NOT NULL,
+  distance DECIMAL(8,2) NOT NULL,
+  freight BIGINT NOT NULL,
+  distance_fee BIGINT NOT NULL,
+  user_note VARCHAR(500) NULL,
+  version INT NOT NULL DEFAULT 0,
+  accepted_at DATETIME NULL,
+  picked_up_at DATETIME NULL,
+  delivered_at DATETIME NULL,
+  completed_at DATETIME NULL,
+  cancelled_at DATETIME NULL,
+  cancel_reason VARCHAR(500) NULL,
+  cancel_operator_type VARCHAR(20) NULL,
+  cancel_operator_id BIGINT NULL,
+  create_time DATETIME NOT NULL,
+  update_time DATETIME NOT NULL,
+  PRIMARY KEY (id),
+  UNIQUE KEY uk_flash_order_no (order_no),
+  UNIQUE KEY uk_flash_order_request (user_id, client_request_id),
+  KEY idx_flash_available (status, delivery_mode, scheduled_pickup_start_at, service_type, create_time),
+  KEY idx_flash_user (user_id, create_time),
+  KEY idx_flash_rider (rider_id, status, create_time),
+  KEY idx_flash_auto_complete (status, delivered_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='闪送配送订单';
+
+CREATE TABLE flash_delivery_order_image (
+  id BIGINT NOT NULL AUTO_INCREMENT,
+  order_id BIGINT NOT NULL,
+  proof_type VARCHAR(20) NOT NULL,
+  image_url VARCHAR(1000) NOT NULL,
+  sort_order INT NOT NULL DEFAULT 0,
+  operator_type VARCHAR(20) NOT NULL DEFAULT 'RIDER',
+  operator_id BIGINT NOT NULL,
+  create_time DATETIME NOT NULL,
+  PRIMARY KEY (id),
+  KEY idx_flash_image_order (order_id, proof_type, sort_order)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='闪送取送凭证';
+
+CREATE TABLE flash_delivery_order_log (
+  id BIGINT NOT NULL AUTO_INCREMENT,
+  order_id BIGINT NOT NULL,
+  from_status VARCHAR(24) NULL,
+  to_status VARCHAR(24) NOT NULL,
+  operator_type VARCHAR(20) NOT NULL,
+  operator_id BIGINT NULL,
+  reason VARCHAR(500) NULL,
+  create_time DATETIME NOT NULL,
+  PRIMARY KEY (id),
+  KEY idx_flash_log_order (order_id, create_time)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='闪送状态审计日志';
+```
+