Przeglądaj źródła

新增闪送骑手收入账单并完善账单查询

闪送订单完成后幂等记录全额收入,不入钱包、不走提现;同步测试、规格、接口文档及手动迁移 SQL。
qmj 2 tygodni temu
rodzic
commit
b93e54d47b
25 zmienionych plików z 586 dodań i 21 usunięć
  1. 19 2
      docs/flash-delivery-app-api.md
  2. 37 1
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java
  3. 43 11
      ruoyi-admin/src/main/java/com/ruoyi/app/pay/UserBillingController.java
  4. 4 1
      ruoyi-admin/src/main/java/com/ruoyi/app/pay/WalletController.java
  5. 6 0
      ruoyi-admin/src/main/java/com/ruoyi/app/pay/dto/QsBillingDto.java
  6. 2 0
      ruoyi-admin/src/main/resources/i18n/messages.properties
  7. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_en_US.properties
  8. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties
  9. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
  10. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties
  11. 7 2
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java
  12. 1 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryI18nContractTest.java
  13. 1 1
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryMultiPickupTest.java
  14. 179 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryRiderBillingTest.java
  15. 147 0
      ruoyi-admin/src/test/java/com/ruoyi/app/pay/UserBillingFlashDeliveryTest.java
  16. 3 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/UserBilling.java
  17. 8 1
      ruoyi-system/src/main/resources/mapper/Billing/UserBillingMapper.xml
  18. 53 0
      ruoyi-system/src/test/java/com/ruoyi/system/mapper/UserBillingOrderSourceTest.java
  19. 9 0
      specs/024-flash-delivery/contracts/api.md
  20. 7 0
      specs/024-flash-delivery/data-model.md
  21. 8 1
      specs/024-flash-delivery/plan.md
  22. 10 1
      specs/024-flash-delivery/spec.md
  23. 14 0
      specs/024-flash-delivery/tasks.md
  24. 10 0
      specs/024-flash-delivery/validation.md
  25. 10 0
      updatesql/sql.md

+ 19 - 2
docs/flash-delivery-app-api.md

@@ -2,7 +2,7 @@
 
 本文档供用户端 App 和骑手端 App 接入闪送功能使用,以当前后端实现为准。文档只描述接口契约和业务流程,不包含 App 前端实现代码。
 
-> 更新时间:2026-09-09。新增待接单订单修改发件/收件信息与追加小费:修改地址先重报价,用户确认后保存;保存时校验订单状态和版本。闪送仍不接入支付。
+> 更新时间:2026-09-30。本次增加闪送完成后的骑手收入账单;款项仍直付骑手,不入平台钱包。此前待接单地址重报价、确认保存与追加小费契约保持不变;线下支付及收款确认参照 031 文档。
 
 当前下单与订单编辑契约要点:
 
@@ -29,7 +29,7 @@
 - 寄件、取件和送达图片凭证。
 - 送达 24 小时后仍未由用户确认的订单自动完成。
 
-当前不包含支付、退款、骑手收入、结算、代购垫付、自动派单和骑手放弃订单。
+当前不包含支付网关、退款、平台结算、钱包入账、代购垫付、自动派单和骑手放弃订单。2026-09-30 增量增加完成后的骑手收入账单,款项直付骑手、不通过平台提现,接入说明见文末。
 
 设计原型中的支付方式选择、已付款状态、附近骑手上线数量、预计接单时间和骑手收益金额均为静态示意,接口不提供这些数据,App 不应展示或请求它们。发布成功页统一使用静态文案“发布后等待附近骑手接单”。
 
@@ -1171,3 +1171,20 @@ POST /system/address/{id}/top
 导航目标由 `currentTargetStopId` 定位,全部取完后为唯一收货点。客户端生成 Google Maps URL,使用 `api=1&travelmode=two-wheeler&dir_action=navigate`,地址参数正确编码,起点取设备当前位置。没有定位或无法启动外部地图时,保留目标地址并允许重试;不得因此自动取件。后端不返回导航 URL。
 
 上线前用户、骑手端必须完成数组、逐站凭证、目标调整及状态按钮适配再开启多点开关。当前工作区未提供对应 App 页面实现,本节为客户端交付契约,不能作为 App 已联调证据。
+
+## 2026-09-30:闪送完成后的骑手收入账单
+
+用户签收、平台完成或 24 小时自动完成成功后,服务端生成一条骑手账单,收入为订单 `amount`(含配送费、加急费、小费);不扣外卖抽成/税,不增加钱包余额,不走平台提现。不等骑手确认收款才生成账单,也不会自动将 `paymentStatus` 改为 1。“确认收款”接口保持仅记录实际收款声明,不额外生成账单。历史已完成订单不自动补账。
+
+使用现有 `/Billing/billing/getzddaylist`、`getzdmeeklist`、`getzdmatlist` 查询,日/周/月总收入和完成单数包含闪送及外卖。列表增加 `orderSource`:`FLASH` 为闪送、`FOOD` 为外卖(历史空来源的骑手订单账单按外卖兼容)。原列表字段保持兼容,闪送另有:
+
+| 字段 | 含义 |
+| --- | --- |
+| `ddId` | 闪送展示订单号,不是外卖订单 ID |
+| `flashOrderId` | 闪送主键,用于 `/system/flashDelivery/rider/orders/{id}` 详情 |
+| `amount` | 全额收入,已经包含小费 |
+| `freight` / `tipAmount` | 不含小费的配送费(含加急费) / 小费;二者相加为 amount |
+| `jvli` / `qsStarts` | 路线公里距离 / 当前骑手评分,未评分可为空 |
+| `payType` / `paymentStatus` | 4 现金、6 线下转账 / 0 未确认收款、1 已确认 |
+
+App 应根据来源打开对应订单详情,不能将账单收入展示为平台可提现金额,也不能将有账单标记为已收款。已有“未接单报价不得展示为收益”的限制不变;本节只放开已完成订单的收入账单。此次没有修改 App 页面,真实 App 展示需客户端接入验证。部署前执行 `updatesql/sql.md` 中本次 `user_billing.order_source` 增量 SQL。

+ 37 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java

@@ -17,8 +17,10 @@ import com.ruoyi.common.utils.DictUtils;
 import com.ruoyi.common.utils.MessageUtils;
 import com.ruoyi.common.utils.StringUtils;
 import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.UserBilling;
 import com.ruoyi.system.domain.flash.*;
 import com.ruoyi.system.mapper.InfoUserMapper;
+import com.ruoyi.system.mapper.UserBillingMapper;
 import com.ruoyi.system.mapper.flash.*;
 import com.ruoyi.system.service.PaymentMethodGateService;
 import org.springframework.beans.BeanUtils;
@@ -86,6 +88,7 @@ public class FlashDeliveryApplicationService {
     /** 031 支付闸门:闪送下单、报价展示和骑手待抢列表使用同一可用性规则。 */
     private final PaymentMethodGateService paymentMethodGateService;
     private final FlashDeliveryNotificationService notificationService;
+    private final UserBillingMapper userBillingMapper;
 
     public FlashDeliveryApplicationService(FlashDeliveryOrderMapper orderMapper,
                                            FlashDeliveryPricingMapper pricingMapper,
@@ -100,7 +103,8 @@ public class FlashDeliveryApplicationService {
                                            FlashDeliveryOrderStopMapper stopMapper,
                                            FlashDeliveryTargetAdjustmentMapper adjustmentMapper,
                                            PaymentMethodGateService paymentMethodGateService,
-                                           FlashDeliveryNotificationService notificationService) {
+                                           FlashDeliveryNotificationService notificationService,
+                                           UserBillingMapper userBillingMapper) {
         this.orderMapper = orderMapper;
         this.stopMapper = stopMapper;
         this.adjustmentMapper = adjustmentMapper;
@@ -115,6 +119,7 @@ public class FlashDeliveryApplicationService {
         this.riderDeliveryAcceptanceService = riderDeliveryAcceptanceService;
         this.paymentMethodGateService = paymentMethodGateService;
         this.notificationService = notificationService;
+        this.userBillingMapper = userBillingMapper;
     }
 
     /** 闪送首页:返回开放的业务场景、配送等级,以及当前时段各服务的公开运价摘要。 */
@@ -482,6 +487,7 @@ public class FlashDeliveryApplicationService {
             throw fail("flash.delivery.complete.not.allowed");
         }
         writeLog(orderId, DELIVERED, COMPLETED, "USER", userId, null, now);
+        recordRiderIncome(order, now);
     }
 
     /** 寄件人对已完成订单评价骑手:星级 1-5 仅一次,条件更新原子防并发重复提交。 */
@@ -772,9 +778,39 @@ public class FlashDeliveryApplicationService {
         Date now = new Date();
         if (orderMapper.transitionByStatus(orderId, DELIVERED, COMPLETED, "completed_at", now) != 1) return false;
         writeLog(orderId, DELIVERED, COMPLETED, operatorType, operatorId, null, now);
+        recordRiderIncome(order, now);
         return true;
     }
 
+    /**
+     * 仅在锁单后 DELIVERED -> COMPLETED 条件更新成功的事务中调用,三种完成入口共用,
+     * 重复请求或并发输家不会记第二笔;账单写入失败让订单完成和日志一起回滚。
+     * 闪送款项直接支付给骑手,本账单只记全额收入(含小费),不扣外卖抽成/税,
+     * 不调用钱包服务,也不代表骑手已经确认收款。
+     */
+    private void recordRiderIncome(FlashDeliveryOrder order, Date completedAt) {
+        if (order.getRiderId() == null || !hasText(order.getOrderNo())
+                || order.getAmount() == null || order.getAmount() < 0) {
+            throw fail("flash.delivery.billing.save.failed");
+        }
+        UserBilling bill = new UserBilling();
+        bill.setUserId(order.getRiderId());
+        bill.setUserType("2");
+        bill.setType("0");
+        bill.setState("0");
+        bill.setOrderSource("FLASH");
+        bill.setDdId(order.getOrderNo());
+        bill.setAmount(order.getAmount().doubleValue());
+        bill.setDivvy(0.0);
+        bill.setDivvyRate(0.0);
+        bill.setTax(BigDecimal.ZERO);
+        bill.setTaxRate("0");
+        bill.setPaymentMethod(order.getPayType());
+        bill.setIllustrate(MessageUtils.message("flash.delivery.billing.income"));
+        bill.setCretim(completedAt);
+        if (userBillingMapper.insert(bill) != 1) throw fail("flash.delivery.billing.save.failed");
+    }
+
     /** 骑手确认实际收款:仅本人已送达订单可确认,条件更新保证重复请求不重复写日志。 */
     @Transactional
     public void confirmPayment(Long riderId, Long orderId) {

+ 43 - 11
ruoyi-admin/src/main/java/com/ruoyi/app/pay/UserBillingController.java

@@ -26,6 +26,8 @@ import com.ruoyi.common.utils.poi.ExcelUtil;
 import com.ruoyi.system.domain.*;
 import com.ruoyi.system.domain.vo.BillDTO;
 import com.ruoyi.system.mapper.UserBillingMapper;
+import com.ruoyi.system.domain.flash.FlashDeliveryOrder;
+import com.ruoyi.system.mapper.flash.FlashDeliveryOrderMapper;
 import com.ruoyi.system.service.*;
 import com.ruoyi.system.utils.Auth;
 import com.ruoyi.system.utils.JwtUtil;
@@ -70,6 +72,8 @@ public class UserBillingController extends BaseController {
     private ICommissionRatesService commissionRatesService;
     @Autowired
     private IPosStoreService posStoreService;
+    @Autowired
+    private FlashDeliveryOrderMapper flashDeliveryOrderMapper;
 
     /**
      * 商家端账单列表
@@ -266,7 +270,7 @@ public class UserBillingController extends BaseController {
         org.put("cancelCount", cancleCount);
 
         if ("2".equals(user.getUserType())) {
-            setQsList(list, org);
+            setQsList(list, org, id);
         } else {
             ShBillingDto shBillingDto = userBillingMapper.getShDaysum(id, riqi, mdId);
             org.put("foodAmount", shBillingDto.getFoodAmount());
@@ -330,7 +334,7 @@ public class UserBillingController extends BaseController {
         long cancleCount = posOrderService.count(queryWrapper);
         org.put("cancelCount", cancleCount);
         if ("2".equals(user.getUserType())) {
-            setQsList(list, org);
+            setQsList(list, org, id);
         } else {
             ShBillingDto shBillingDto = userBillingMapper.getShMeeksum(id, riqi, mdId);
             org.put("foodAmount", shBillingDto.getFoodAmount());
@@ -380,14 +384,40 @@ public class UserBillingController extends BaseController {
     }
 
     //骑手返回骑手账单信息
-    private void setQsList(List<UserBilling> list, JSONObject org) {
+    private void setQsList(List<UserBilling> list, JSONObject org, Long riderId) {
         List<QsBillingDto> result = new ArrayList<>();
         if (!list.isEmpty()) {
-            List<String> ddIds = list.stream().map(x -> x.getDdId()).collect(Collectors.toList());
-            List<PosOrder> orders = posOrderService.list(new LambdaQueryWrapper<PosOrder>().in(PosOrder::getDdId, ddIds));
-            List<PosOrderRating> ratings = posOrderRatingService.list(new LambdaQueryWrapper<PosOrderRating>().in(PosOrderRating::getDdId, ddIds));
+            // 两类订单独立关联,不能拿闪送订单号查询 pos_order 或依赖编号前缀猜测来源。
+            List<String> ddIds = list.stream().filter(x -> !"FLASH".equals(x.getOrderSource()))
+                    .map(UserBilling::getDdId).collect(Collectors.toList());
+            List<String> flashNos = list.stream().filter(x -> "FLASH".equals(x.getOrderSource()))
+                    .map(UserBilling::getDdId).collect(Collectors.toList());
+            List<PosOrder> orders = ddIds.isEmpty() ? List.of() : posOrderService.list(
+                    new LambdaQueryWrapper<PosOrder>().in(PosOrder::getDdId, ddIds));
+            List<PosOrderRating> ratings = ddIds.isEmpty() ? List.of() : posOrderRatingService.list(
+                    new LambdaQueryWrapper<PosOrderRating>().in(PosOrderRating::getDdId, ddIds));
+            List<FlashDeliveryOrder> flashOrders = flashNos.isEmpty() ? List.of() : flashDeliveryOrderMapper.selectList(
+                    new LambdaQueryWrapper<FlashDeliveryOrder>().eq(FlashDeliveryOrder::getRiderId, riderId)
+                            .in(FlashDeliveryOrder::getOrderNo, flashNos));
             list.forEach(x -> {
                 QsBillingDto dto = Convert.convert(QsBillingDto.class, x);
+                if ("FLASH".equals(x.getOrderSource())) {
+                    flashOrders.stream().filter(o -> o.getOrderNo().equals(x.getDdId())).findFirst().ifPresent(o -> {
+                        dto.setFlashOrderId(o.getId());
+                        dto.setTipAmount(o.getTipAmount());
+                        // amount 已含小费;freight 只含基础/距离/加急费,保持运费与小费可分别展示。
+                        dto.setFreight(o.getAmount() == null ? null :
+                                o.getAmount().doubleValue() - (o.getTipAmount() == null ? 0 : o.getTipAmount()));
+                        dto.setJvli(o.getDistanceMeters() == null ? null : o.getDistanceMeters() / 1000.0);
+                        dto.setQsStarts(o.getRiderStars());
+                        dto.setPayType(o.getPayType());
+                        dto.setPaymentStatus(o.getPaymentStatus());
+                    });
+                    result.add(dto);
+                    return;
+                }
+                // 原有外卖账单没有来源字段,在骑手订单账单响应中统一兼容为 FOOD。
+                dto.setOrderSource("FOOD");
                 //货到付款的订单金额
                 orders.stream().filter(o -> o.getDdId().equals(x.getDdId())).findFirst().ifPresent(o -> {
                     if ("1".equals(o.getCollectPayment())) {
@@ -412,12 +442,13 @@ public class UserBillingController extends BaseController {
 
 
     private void setYhInfo(List<UserBilling> list) {
-        if (!list.isEmpty()) {
-            List<String> orderIds = list.stream().map(UserBilling::getDdId).collect(Collectors.toList());
+        List<UserBilling> foodBills = list.stream().filter(x -> !"FLASH".equals(x.getOrderSource())).collect(Collectors.toList());
+        if (!foodBills.isEmpty()) {
+            List<String> orderIds = foodBills.stream().map(UserBilling::getDdId).collect(Collectors.toList());
             LambdaQueryWrapper<PosOrder> query = new LambdaQueryWrapper<>();
             query.in(PosOrder::getDdId, orderIds);
             List<PosOrder> orders = posOrderService.list(query);
-            list.forEach(billing -> {
+            foodBills.forEach(billing -> {
                 orders.stream().filter(o -> o.getDdId().toString().equals(billing.getDdId())).findFirst().ifPresent(o -> {
                     billing.setActivity(o.getActivity());
                     billing.setMdActivity(o.getMdActivity());
@@ -479,7 +510,7 @@ public class UserBillingController extends BaseController {
         long cancleCount = posOrderService.count(queryWrapper);
         org.put("cancelCount", cancleCount);
         if ("2".equals(user.getUserType())) {
-            setQsList(list, org);
+            setQsList(list, org, id);
         } else {
             ShBillingDto shBillingDto = userBillingMapper.getshMonthsum(id, riqi, mdId);
             org.put("foodAmount", shBillingDto.getFoodAmount());
@@ -541,7 +572,8 @@ public class UserBillingController extends BaseController {
             ject.put("id", item.getId());
             ject.put("user", infoUserService.getById(item.getUserId()));
             ject.put("type", item.getType());
-            PosOrder order = posOrderService.getById(item.getDdId());
+            PosOrder order = "FLASH".equals(item.getOrderSource()) ? null : posOrderService.getById(item.getDdId());
+            ject.put("orderSource", item.getOrderSource());
             ject.put("ddId", order == null ? item.getDdId() : order.getDdId());
             ject.put("amount", item.getAmount());
             ject.put("divvy", item.getDivvy());

+ 4 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/pay/WalletController.java

@@ -85,7 +85,10 @@ public class WalletController extends BaseController {
         IPage<UserBilling> result = userBillingService.page(pageInput, queryWrapper);
 
         if (!result.getRecords().isEmpty()) {
-            List<String> orderDdIds = result.getRecords().stream().filter(x -> x.getType().equals("0")).map(x -> x.getDdId()).collect(Collectors.toList());
+            // 闪送收入也是 type=0,但只记账且有独立订单表,不能拿闪送编号关联外卖订单。
+            List<String> orderDdIds = result.getRecords().stream()
+                    .filter(x -> "0".equals(x.getType()) && !"FLASH".equals(x.getOrderSource()))
+                    .map(UserBilling::getDdId).collect(Collectors.toList());
             List<String> withDrawalDdIds = result.getRecords().stream().filter(x -> x.getType().equals("2")).map(UserBilling::getDdId).collect(Collectors.toList());
             List<String> rechargeDdIds = result.getRecords().stream().filter(x -> x.getType().equals("1")).map(UserBilling::getDdId).collect(Collectors.toList());
 

+ 6 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/pay/dto/QsBillingDto.java

@@ -7,6 +7,12 @@ import lombok.Data;
 
 @Data
 public class QsBillingDto extends UserBilling {
+    /** 仅闪送账单有值;ddId 为展示订单号,此主键用于打开闪送详情。 */
+    private Long flashOrderId;
+    /** 闪送小费,已包含在账单 amount 中,不要重复相加。 */
+    private Long tipAmount;
+    /** 闪送实际收款确认状态;账单生成与确认收款独立。 */
+    private Integer paymentStatus;
     /**
      * 骑手星级
      */

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

@@ -421,3 +421,5 @@ no.pay.switch.already.paid=Đơn hàng đã được thanh toán
 no.pay.switch.method.invalid=Phương thức thanh toán không hợp lệ
 no.pay.switch.offline.restricted=Tiền mặt / thanh toán khi nhận hàng chỉ dành cho đơn tự lấy hoặc dùng tại chỗ
 no.pay.switch.failed=Đổi phương thức thanh toán thất bại, vui lòng thử lại sau
+flash.delivery.billing.save.failed=Không thể ghi nhận thu nhập giao hàng nhanh, vui lòng thử lại
+flash.delivery.billing.income=Thu nhập giao hàng nhanh (thanh toán trực tiếp cho tài xế)

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

@@ -407,3 +407,5 @@ no.pay.switch.already.paid=Order already paid
 no.pay.switch.method.invalid=Invalid payment method
 no.pay.switch.offline.restricted=Cash / cash-on-delivery only for pickup or dine-in orders
 no.pay.switch.failed=Failed to change payment method, please try again later
+flash.delivery.billing.save.failed=Failed to record flash delivery income, please try again
+flash.delivery.billing.income=Flash delivery income (paid directly to rider)

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

@@ -408,3 +408,5 @@ no.pay.switch.already.paid=ชำระเงินคำสั่งซื้
 no.pay.switch.method.invalid=วิธีชำระเงินไม่ถูกต้อง
 no.pay.switch.offline.restricted=เงินสด / ชำระเงินปลายทางใช้ได้เฉพาะคำสั่งซื้อแบบรับสินค้าหรือทานที่ร้านเท่านั้น
 no.pay.switch.failed=เปลี่ยนวิธีชำระเงินไม่สำเร็จ กรุณาลองใหม่ภายหลัง
+flash.delivery.billing.save.failed=บันทึกรายได้จากการจัดส่งด่วนไม่สำเร็จ กรุณาลองอีกครั้ง
+flash.delivery.billing.income=รายได้จากการจัดส่งด่วน (ชำระให้ผู้ขับขี่โดยตรง)

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

@@ -409,3 +409,5 @@ no.pay.switch.already.paid=订单已支付,无法更换支付方式
 no.pay.switch.method.invalid=支付方式无效
 no.pay.switch.offline.restricted=仅自取/堂食订单可更换为现金或货到付款
 no.pay.switch.failed=更换支付方式失败,请稍后重试
+flash.delivery.billing.save.failed=闪送骑手账单生成失败,请重试
+flash.delivery.billing.income=闪送收入(款项直付骑手)

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

@@ -408,3 +408,5 @@ no.pay.switch.already.paid=訂單已付款,無法更換支付方式
 no.pay.switch.method.invalid=支付方式無效
 no.pay.switch.offline.restricted=僅自取/堂食訂單可更換為現金或貨到付款
 no.pay.switch.failed=更換支付方式失敗,請稍後重試
+flash.delivery.billing.save.failed=閃送騎手帳單產生失敗,請重試
+flash.delivery.billing.income=閃送收入(款項直付騎手)

+ 7 - 2
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java

@@ -641,6 +641,7 @@ class FlashDeliveryApplicationServiceTest {
         Fixture fixture = new Fixture();
         FlashDeliveryOrder order = order(10L, "DELIVERED");
         order.setReceiverUserId(88L);
+        order.setRiderId(8L);
         when(fixture.orderMapper.selectById(10L)).thenReturn(order);
         when(fixture.orderMapper.transitionByParticipant(eq(10L), eq(88L), eq("DELIVERED"),
                 eq("COMPLETED"), any(Date.class))).thenReturn(1);
@@ -1150,7 +1151,9 @@ class FlashDeliveryApplicationServiceTest {
         fixture.service.addTip(7L, 10L, tipRequest(20L, 2));
 
         // 合并后的 complete 要求订单已送达且全部取货站完成。
-        when(fixture.orderMapper.selectById(10L)).thenReturn(order(10L, "DELIVERED"));
+        FlashDeliveryOrder completedOrder = order(10L, "DELIVERED");
+        completedOrder.setRiderId(8L);
+        when(fixture.orderMapper.selectById(10L)).thenReturn(completedOrder);
         when(fixture.orderMapper.transitionByStatus(eq(10L), eq("DELIVERED"), eq("COMPLETED"),
                 eq("completed_at"), any(Date.class))).thenReturn(1);
         assertTrue(fixture.service.complete(1L, "ADMIN", 10L));
@@ -2118,12 +2121,14 @@ class FlashDeliveryApplicationServiceTest {
         final com.ruoyi.system.service.PaymentMethodGateService paymentMethodGateService =
                 mock(com.ruoyi.system.service.PaymentMethodGateService.class);
         final FlashDeliveryNotificationService notificationService = mock(FlashDeliveryNotificationService.class);
+        final com.ruoyi.system.mapper.UserBillingMapper billingMapper = mock(com.ruoyi.system.mapper.UserBillingMapper.class);
         final FlashDeliveryApplicationService service = new FlashDeliveryApplicationService(
                 orderMapper, pricingMapper, imageMapper, logMapper, userMapper, bankCardMapper,
                 routeService, new FlashDeliveryPricingCalculator(), lockService, acceptanceService, stopMapper, adjustmentMapper,
-                paymentMethodGateService, notificationService);
+                paymentMethodGateService, notificationService, billingMapper);
 
         Fixture() {
+            when(billingMapper.insert(any(com.ruoyi.system.domain.UserBilling.class))).thenReturn(1);
             when(paymentMethodGateService.listAvailable(any(), any(), any())).thenReturn(List.of(
                     new com.ruoyi.system.service.PaymentMethodGateService.PayMethodOption("COD", "1", true),
                     new com.ruoyi.system.service.PaymentMethodGateService.PayMethodOption("LINE_PAY", "3", true),

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

@@ -24,6 +24,7 @@ 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.billing.save.failed", "flash.delivery.billing.income",
             "flash.delivery.pricing.invalid", "flash.delivery.pricing.not.available",
             "flash.delivery.pricing.overlap", "flash.delivery.pricing.not.found",
             "flash.delivery.pricing.changed",

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

@@ -416,7 +416,7 @@ class FlashDeliveryMultiPickupTest {
         final FlashDeliveryApplicationService service = new FlashDeliveryApplicationService(orders, prices, images, logs, users, mock(com.ruoyi.system.mapper.InfoBankCardMapper.class),
                 routes, new FlashDeliveryPricingCalculator(), mock(RiderDeliveryLockService.class), mock(RiderDeliveryAcceptanceService.class),
                 stops, adjustments, mock(com.ruoyi.system.service.PaymentMethodGateService.class),
-                mock(FlashDeliveryNotificationService.class));
+                mock(FlashDeliveryNotificationService.class), mock(com.ruoyi.system.mapper.UserBillingMapper.class));
         final FlashDeliveryOrder order = new FlashDeliveryOrder();
         final List<FlashDeliveryOrderStop> rows = new ArrayList<>(List.of(stop(11L, 0, "PICKUP"), stop(12L, 1, "PICKUP"), stop(13L, 2, "DELIVERY")));
         final Map<String, FlashDeliveryTargetAdjustment> records = new HashMap<>();

+ 179 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryRiderBillingTest.java

@@ -0,0 +1,179 @@
+package com.ruoyi.app.flashdelivery.service;
+
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.ruoyi.app.flashdelivery.route.FlashDeliveryRouteService;
+import com.ruoyi.app.order.RiderDeliveryAcceptanceService;
+import com.ruoyi.app.order.RiderDeliveryLockService;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.spring.SpringUtils;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.UserBilling;
+import com.ruoyi.system.domain.flash.*;
+import com.ruoyi.system.mapper.InfoBankCardMapper;
+import com.ruoyi.system.mapper.InfoUserMapper;
+import com.ruoyi.system.mapper.UserBillingMapper;
+import com.ruoyi.system.mapper.flash.*;
+import com.ruoyi.system.service.PaymentMethodGateService;
+import org.junit.jupiter.api.*;
+import org.mockito.*;
+import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
+import org.springframework.beans.factory.support.DefaultListableBeanFactory;
+import org.springframework.context.support.StaticMessageSource;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/** Completion records the rider's direct income, never platform wallet credit. */
+class FlashDeliveryRiderBillingTest {
+    @Mock FlashDeliveryOrderMapper orders;
+    @Mock FlashDeliveryOrderStopMapper stops;
+    @Mock FlashDeliveryTargetAdjustmentMapper adjustments;
+    @Mock FlashDeliveryPricingMapper pricing;
+    @Mock FlashDeliveryOrderImageMapper images;
+    @Mock FlashDeliveryOrderLogMapper logs;
+    @Mock InfoUserMapper users;
+    @Mock InfoBankCardMapper cards;
+    @Mock FlashDeliveryRouteService routes;
+    @Mock FlashDeliveryPricingCalculator calculator;
+    @Mock RiderDeliveryLockService riderLock;
+    @Mock RiderDeliveryAcceptanceService acceptance;
+    @Mock PaymentMethodGateService paymentGate;
+    @Mock FlashDeliveryNotificationService notifications;
+    @Mock UserBillingMapper bills;
+    @InjectMocks FlashDeliveryApplicationService service;
+    private AutoCloseable mocks;
+    private FlashDeliveryOrder order;
+    private static ConfigurableListableBeanFactory original;
+
+    @BeforeAll static void messages() {
+        original = (ConfigurableListableBeanFactory) ReflectionTestUtils.getField(SpringUtils.class, "beanFactory");
+        var factory = new DefaultListableBeanFactory();
+        var messages = new StaticMessageSource(); messages.setUseCodeAsDefaultMessage(true);
+        factory.registerSingleton("messageSource", messages);
+        new SpringUtils().postProcessBeanFactory(factory);
+    }
+    @AfterAll static void restoreMessages() { new SpringUtils().postProcessBeanFactory(original); }
+    @BeforeEach void setUp() {
+        mocks = MockitoAnnotations.openMocks(this);
+        order = new FlashDeliveryOrder();
+        order.setId(10L); order.setOrderNo("98178900000000001");
+        order.setUserId(7L); order.setReceiverUserId(9L); order.setRiderId(8L);
+        order.setStatus("DELIVERED"); order.setPickupStopCount(1);
+        order.setAmount(160L); order.setTipAmount(20L); order.setPayType("4");
+        order.setPaymentStatus(0);
+        when(orders.selectById(10L)).thenReturn(order);
+        when(orders.selectByIdForUpdate(10L)).thenReturn(order);
+        var pickup = new FlashDeliveryOrderStop();
+        pickup.setId(11L); pickup.setOrderId(10L); pickup.setStopOrder(0);
+        pickup.setStopType("PICKUP"); pickup.setStatus("PICKED_UP");
+        var delivery = new FlashDeliveryOrderStop();
+        delivery.setId(12L); delivery.setOrderId(10L); delivery.setStopOrder(1);
+        delivery.setStopType("DELIVERY"); delivery.setStatus("PENDING");
+        when(stops.selectList(any(Wrapper.class))).thenReturn(List.of(pickup, delivery));
+        when(orders.transitionByParticipant(eq(10L), anyLong(), eq("DELIVERED"), eq("COMPLETED"), any()))
+                .thenReturn(1, 0);
+        when(orders.transitionByStatus(eq(10L), eq("DELIVERED"), eq("COMPLETED"), eq("completed_at"), any()))
+                .thenReturn(1, 0);
+        when(bills.insert(any(UserBilling.class))).thenReturn(1);
+    }
+    @AfterEach void closeMocks() throws Exception { mocks.close(); }
+
+    @Test void senderCompletionRecordsFullIncomeWithoutWaitingForPaymentConfirmation() {
+        service.confirmReceipt(7L, 10L);
+        assertIncomeBill();
+        assertEquals(0, order.getPaymentStatus());
+    }
+    @Test void receiverCompletionRecordsSameRiderIncome() {
+        service.confirmReceipt(9L, 10L);
+        assertIncomeBill();
+    }
+    @Test void platformCompletionRecordsOfflineTransferIncome() {
+        order.setPayType("6");
+        assertTrue(service.complete(1L, "ADMIN", 10L));
+        assertIncomeBill();
+    }
+    @Test void automaticCompletionRecordsSameIncome() {
+        assertTrue(service.complete(null, "SYSTEM", 10L));
+        assertIncomeBill();
+    }
+    @Test void repeatedCompletionDoesNotCreateSecondBill() {
+        assertTrue(service.complete(null, "SYSTEM", 10L));
+        assertFalse(service.complete(null, "SYSTEM", 10L));
+        verify(bills, times(1)).insert(any(UserBilling.class));
+    }
+    @Test void lostStatusRaceDoesNotGenerateBill() {
+        when(orders.transitionByParticipant(eq(10L), eq(7L), anyString(), anyString(), any())).thenReturn(0);
+        assertThrows(ServiceException.class, () -> service.confirmReceipt(7L, 10L));
+        verifyNoInteractions(bills);
+    }
+    @Test void nonDeliveredOrUncollectedOrderCannotGenerateBill() {
+        order.setStatus("CANCELLED");
+        assertFalse(service.complete(1L, "ADMIN", 10L));
+        order.setStatus("DELIVERED");
+        var pending = new FlashDeliveryOrderStop(); pending.setStopType("PICKUP"); pending.setStatus("PENDING");
+        var delivery = new FlashDeliveryOrderStop(); delivery.setStopType("DELIVERY");
+        when(stops.selectList(any())).thenReturn(List.of(pending, delivery));
+        assertFalse(service.complete(1L, "ADMIN", 10L));
+        verifyNoInteractions(bills);
+    }
+    @Test void confirmedPaymentAloneDoesNotCreateIncomeBill() {
+        var rider = new InfoUser(); rider.setUserType("2");
+        when(users.selectInfoUserByUserId(8L)).thenReturn(rider);
+        when(orders.update(isNull(), any())).thenReturn(1);
+        service.confirmPayment(8L, 10L);
+        verifyNoInteractions(bills);
+    }
+    @Test void failedBillInsertAbortsCompletionInsteadOfSilentlyLosingIncome() {
+        when(bills.insert(any(UserBilling.class))).thenReturn(0);
+        assertThrows(ServiceException.class, () -> service.complete(null, "SYSTEM", 10L));
+    }
+
+    @org.junit.jupiter.params.ParameterizedTest
+    @org.junit.jupiter.params.provider.ValueSource(strings = {"USER", "ADMIN", "SYSTEM"})
+    void billFailureRequestsTransactionRollbackForEveryCompletionEntry(String operator) {
+        // Exercise the real annotation-driven transaction boundary; this is not a MySQL integration test.
+        var manager = new RecordingTransactionManager();
+        var proxyFactory = new org.springframework.aop.framework.ProxyFactory(service);
+        proxyFactory.setProxyTargetClass(true);
+        proxyFactory.addAdvice(new org.springframework.transaction.interceptor.TransactionInterceptor(manager,
+                new org.springframework.transaction.annotation.AnnotationTransactionAttributeSource()));
+        var transactional = (FlashDeliveryApplicationService) proxyFactory.getProxy();
+        when(bills.insert(any(UserBilling.class))).thenReturn(0);
+        assertThrows(ServiceException.class, () -> {
+            if ("USER".equals(operator)) transactional.confirmReceipt(7L, 10L);
+            else transactional.complete("ADMIN".equals(operator) ? 1L : null, operator, 10L);
+        });
+        assertEquals(1, manager.rollbacks); assertEquals(0, manager.commits);
+        verify(orders).selectByIdForUpdate(10L);
+    }
+
+    private static class RecordingTransactionManager extends org.springframework.transaction.support.AbstractPlatformTransactionManager {
+        int commits;
+        int rollbacks;
+        protected Object doGetTransaction() { return new Object(); }
+        protected void doBegin(Object transaction, org.springframework.transaction.TransactionDefinition definition) { }
+        protected void doCommit(org.springframework.transaction.support.DefaultTransactionStatus status) { commits++; }
+        protected void doRollback(org.springframework.transaction.support.DefaultTransactionStatus status) { rollbacks++; }
+    }
+
+    private void assertIncomeBill() {
+        var saved = ArgumentCaptor.forClass(UserBilling.class);
+        verify(bills).insert(saved.capture());
+        UserBilling bill = saved.getValue();
+        assertEquals(8L, bill.getUserId());
+        assertEquals("98178900000000001", bill.getDdId());
+        assertEquals("FLASH", bill.getOrderSource());
+        assertEquals("0", bill.getType()); assertEquals("2", bill.getUserType());
+        assertEquals("0", bill.getState()); assertEquals(160.0, bill.getAmount());
+        assertEquals(0.0, bill.getDivvy()); assertEquals(0.0, bill.getDivvyRate());
+        assertEquals(BigDecimal.ZERO, bill.getTax());
+        assertNull(bill.getWalletBalance()); assertNull(bill.getMdId());
+        assertEquals(order.getPayType(), bill.getPaymentMethod());
+        assertNotNull(bill.getCretim());
+    }
+}

+ 147 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/pay/UserBillingFlashDeliveryTest.java

@@ -0,0 +1,147 @@
+package com.ruoyi.app.pay;
+
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.ruoyi.app.pay.dto.QsBillingDto;
+import com.ruoyi.common.utils.DictUtils;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.*;
+import com.ruoyi.system.domain.flash.FlashDeliveryOrder;
+import com.ruoyi.system.domain.vo.BillDTO;
+import com.ruoyi.system.mapper.UserBillingMapper;
+import com.ruoyi.system.mapper.flash.FlashDeliveryOrderMapper;
+import com.ruoyi.system.service.*;
+import com.ruoyi.system.utils.JwtUtil;
+import org.junit.jupiter.api.*;
+import org.mockito.*;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/** Exercises the public day/week/month API rather than a private enrichment helper. */
+class UserBillingFlashDeliveryTest {
+    @Mock UserBillingMapper bills;
+    @Mock FlashDeliveryOrderMapper flashOrders;
+    @Mock IPosOrderService foodOrders;
+    @Mock IPosOrderRatingService foodRatings;
+    @Mock IInfoUserService users;
+    @Mock IUserBillingService billingService;
+    @InjectMocks UserBillingController controller;
+    private AutoCloseable mocks;
+    private MockedStatic<DictUtils> dictionaries;
+    private MockedStatic<MessageUtils> messages;
+    private MockedConstruction<JwtUtil> tokens;
+    private UserBilling flashBill;
+    private FlashDeliveryOrder flashOrder;
+
+    @BeforeEach void setup() {
+        mocks = MockitoAnnotations.openMocks(this);
+        dictionaries = mockStatic(DictUtils.class);
+        dictionaries.when(() -> DictUtils.getDictCache("sys_qs_freight_bl")).thenReturn(null);
+        messages = mockStatic(MessageUtils.class);
+        tokens = mockConstruction(JwtUtil.class, (jwt, context) -> when(jwt.getusid("token")).thenReturn("8"));
+        var rider = new InfoUser(); rider.setUserId(8L); rider.setUserType("2");
+        when(users.getById(8L)).thenReturn(rider);
+        flashBill = new UserBilling(); flashBill.setUserId(8L); flashBill.setDdId("98178900000000001");
+        flashBill.setOrderSource("FLASH"); flashBill.setType("0"); flashBill.setUserType("2");
+        flashBill.setAmount(160.0);
+        flashBill.setCretim(new java.util.Date(1_000L));
+        flashOrder = new FlashDeliveryOrder(); flashOrder.setId(10L); flashOrder.setRiderId(8L);
+        flashOrder.setOrderNo("98178900000000001"); flashOrder.setDistanceMeters(3200);
+        flashOrder.setAmount(160L); flashOrder.setTipAmount(20L); flashOrder.setRiderStars(4.5); flashOrder.setPayType("6");
+        flashOrder.setPaymentStatus(0);
+        when(flashOrders.selectList(any(Wrapper.class))).thenReturn(List.of(flashOrder));
+        when(bills.getdaylist(eq(8L), anyString(), isNull(), eq(0L), eq(10L))).thenReturn(List.of(flashBill));
+        when(bills.getmeeklist(eq(8L), anyString(), isNull(), eq(0L), eq(10L))).thenReturn(List.of(flashBill));
+        when(bills.getmatlist(eq(8L), anyString(), isNull(), eq(0L), eq(10L))).thenReturn(List.of(flashBill));
+        var summary = new BillDTO(); summary.setSul(1); summary.setShouru(160); summary.setCommission(0); summary.setTax(0);
+        when(bills.getdaysum(eq(8L), anyString(), isNull())).thenReturn(summary);
+        when(bills.getmeeksum(eq(8L), anyString(), isNull())).thenReturn(summary);
+        when(bills.getmatsum(eq(8L), anyString(), isNull())).thenReturn(summary);
+    }
+    @AfterEach void cleanup() throws Exception {
+        tokens.close(); messages.close(); dictionaries.close(); mocks.close();
+    }
+
+    @Test void flashDayBillHasFlashDistanceFreightAndRating() {
+        var result = controller.getzddaylist("token", 1L, 10L, "2026-09-30", false, null);
+        assertFlash((com.alibaba.fastjson.JSONObject) result.get("data"));
+        // A flash order must not be treated as a food order by enrichment queries.
+        verify(foodOrders, never()).list(any(Wrapper.class));
+        verifyNoInteractions(foodRatings);
+    }
+    @Test void flashWeekBillUsesSameEnrichment() {
+        assertFlash((com.alibaba.fastjson.JSONObject) controller.getzdmeeklist("token", 1L, 10L, "2026-09-30", false, null).get("data"));
+    }
+    @Test void flashMonthBillUsesSameEnrichment() {
+        assertFlash((com.alibaba.fastjson.JSONObject) controller.getzdmatlist("token", 1L, 10L, "2026-09", false, null).get("data"));
+    }
+    @Test void mixedBillsPreserveFoodDetailsWithoutLeakingThemIntoFlash() {
+        var foodBill = new UserBilling(); foodBill.setDdId("991789000000000"); foodBill.setAmount(80.0);
+        when(bills.getdaylist(eq(8L), anyString(), isNull(), eq(0L), eq(10L))).thenReturn(List.of(foodBill, flashBill));
+        var food = new PosOrder(); food.setDdId("991789000000000"); food.setFreight(80.0); food.setJvli(2.5);
+        when(foodOrders.list(any(Wrapper.class))).thenReturn(List.of(food));
+        var data = (com.alibaba.fastjson.JSONObject) controller.getzddaylist("token", 1L, 10L, "2026-09-30", false, null).get("data");
+        List<QsBillingDto> list = (List<QsBillingDto>) data.get("list");
+        assertEquals("FOOD", list.get(0).getOrderSource());
+        assertEquals(80.0, list.get(0).getFreight()); assertEquals(2.5, list.get(0).getJvli());
+        assertEquals("FLASH", list.get(1).getOrderSource());
+        assertEquals(140.0, list.get(1).getFreight()); assertEquals(3.2, list.get(1).getJvli());
+    }
+    @Test void genericBillListKeepsPlatformWalletBalanceSeparateFromDirectIncome() {
+        var page = new com.baomidou.mybatisplus.extension.plugins.pagination.Page<UserBilling>(1, 10);
+        page.setRecords(List.of(flashBill));
+        when(billingService.page(any(com.baomidou.mybatisplus.extension.plugins.pagination.Page.class), any(Wrapper.class)))
+                .thenReturn(page);
+        when(billingService.getUserBalance(8L)).thenReturn(new java.math.BigDecimal("77.00"));
+        var data = (com.alibaba.fastjson.JSONObject) controller.getUsbalance("token", 1, 10, false, "").get("data");
+        assertEquals(new java.math.BigDecimal("77.00"), data.get("sum"));
+        var list = (com.alibaba.fastjson.JSONArray) data.get("list");
+        assertEquals("FLASH", list.getJSONObject(0).getString("orderSource"));
+        assertEquals(160.0, list.getJSONObject(0).getDouble("amount"));
+        verifyNoInteractions(foodOrders);
+    }
+    @Test void flashDetailLookupRestrictsBothRiderAndOrderNumber() {
+        com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo(
+                new org.apache.ibatis.builder.MapperBuilderAssistant(new com.baomidou.mybatisplus.core.MybatisConfiguration(), "test"),
+                FlashDeliveryOrder.class);
+        controller.getzddaylist("token", 1L, 10L, "2026-09-30", false, null);
+        var query = ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper.class);
+        verify(flashOrders).selectList(query.capture());
+        String sql = query.getValue().getSqlSegment();
+        assertTrue(sql.contains("rider_id ="), sql); assertTrue(sql.contains("order_no IN"), sql);
+        assertTrue(query.getValue().getParamNameValuePairs().containsValue(8L));
+        assertTrue(query.getValue().getParamNameValuePairs().containsValue("98178900000000001"));
+    }
+    @Test void missingFlashOrderDoesNotHideRecordedIncomeOrFallBackToFood() {
+        when(flashOrders.selectList(any(Wrapper.class))).thenReturn(List.of());
+        var data = (com.alibaba.fastjson.JSONObject) controller.getzddaylist("token", 1L, 10L, "2026-09-30", false, null).get("data");
+        var list = (List<QsBillingDto>) data.get("list");
+        assertEquals(160.0, list.get(0).getAmount()); assertEquals("FLASH", list.get(0).getOrderSource());
+        assertNull(list.get(0).getFlashOrderId()); assertNull(list.get(0).getFreight());
+        verify(foodOrders, never()).list(any(Wrapper.class));
+        verifyNoInteractions(foodRatings);
+    }
+    @Test void legacyWalletBillListDoesNotLookUpFlashNumbersInFoodOrders() {
+        var walletController = new WalletController();
+        org.springframework.test.util.ReflectionTestUtils.setField(walletController, "userBillingService", billingService);
+        org.springframework.test.util.ReflectionTestUtils.setField(walletController, "posOrderService", foodOrders);
+        var page = new com.baomidou.mybatisplus.extension.plugins.pagination.Page<UserBilling>(1, 10);
+        page.setRecords(List.of(flashBill));
+        when(billingService.page(any(com.baomidou.mybatisplus.core.metadata.IPage.class), any(Wrapper.class))).thenReturn(page);
+        walletController.getUserBills("token", 1, 10, "0");
+        verifyNoInteractions(foodOrders);
+    }
+    private void assertFlash(com.alibaba.fastjson.JSONObject data) {
+        List<QsBillingDto> list = (List<QsBillingDto>) data.get("list");
+        assertEquals(1, list.size());
+        var bill = list.get(0);
+        assertEquals("FLASH", bill.getOrderSource()); assertEquals(160.0, bill.getAmount());
+        assertEquals(10L, bill.getFlashOrderId()); assertEquals(20L, bill.getTipAmount());
+        assertEquals(0, bill.getPaymentStatus());
+        assertEquals(140.0, bill.getFreight()); assertEquals(3.2, bill.getJvli());
+        assertEquals(4.5, bill.getQsStarts()); assertEquals("6", bill.getPayType());
+    }
+}

+ 3 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/UserBilling.java

@@ -47,6 +47,9 @@ public class UserBilling
     @Excel(name = "订单id")
     private String ddId;
 
+    /** 订单来源:FLASH=闪送,FOOD=外卖;历史或非订单账单可为空,不能仅凭订单号猜测来源。 */
+    private String orderSource;
+
     /** 金额 */
     @Excel(name = "金额")
     private Double amount;

+ 8 - 1
ruoyi-system/src/main/resources/mapper/Billing/UserBillingMapper.xml

@@ -9,6 +9,7 @@
         <result property="userId"    column="user_id"    />
         <result property="type"    column="type"    />
         <result property="ddId"    column="dd_id"    />
+        <result property="orderSource" column="order_source" />
         <result property="amount"    column="amount"    />
         <result property="state"    column="state"    />
         <result property="illustrate"    column="illustrate"    />
@@ -28,7 +29,7 @@
     </resultMap>
 
     <sql id="selectUserBillingVo">
-        select b.id, b.user_id, b.type, b.dd_id, b.md_id, b.store_id, b.amount, b.divvy,b.state, b.illustrate, b.payment_method, b.account_number,b.balance_pay, b.user_type,b.cretim, b.divvy_rate, b.behalf_amount, b.wallet_balance, b.tax, b.tax_rate from user_billing b
+        select b.id, b.user_id, b.type, b.dd_id, b.order_source, b.md_id, b.store_id, b.amount, b.divvy,b.state, b.illustrate, b.payment_method, b.account_number,b.balance_pay, b.user_type,b.cretim, b.divvy_rate, b.behalf_amount, b.wallet_balance, b.tax, b.tax_rate from user_billing b
     </sql>
 
     <select id="selectUserBillingList" parameterType="UserBilling" resultMap="UserBillingResult">
@@ -40,6 +41,7 @@
             <if test="userId != null "> and b.user_id = #{userId}</if>
             <if test="type != null  and type != ''"> and b.type = #{type}</if>
             <if test="ddId != null  and ddId != ''"> and b.dd_id = #{ddId}</if>
+            <if test="orderSource != null and orderSource != ''"> and b.order_source = #{orderSource}</if>
             <if test="state != null  and state != ''"> and b.state = #{state}</if>
             <if test="mdId != null  and mdId != ''"> and b.md_id = #{mdId}</if>
             <if test="paymentMethod != null  and paymentMethod != ''"> and b.payment_method like concat('%', #{paymentMethod}, '%')</if>
@@ -80,6 +82,8 @@
             <if test="userId != null">user_id,</if>
             <if test="type != null">type,</if>
             <if test="ddId != null">dd_id,</if>
+            <if test="orderSource != null">order_source,</if>
+            <if test="cretim != null">cretim,</if>
             <if test="amount != null">amount,</if>
             <if test="state != null">state,</if>
             <if test="illustrate != null">illustrate,</if>
@@ -99,6 +103,8 @@
             <if test="userId != null">#{userId},</if>
             <if test="type != null">#{type},</if>
             <if test="ddId != null">#{ddId},</if>
+            <if test="orderSource != null">#{orderSource},</if>
+            <if test="cretim != null">#{cretim},</if>
             <if test="amount != null">#{amount},</if>
             <if test="state != null">#{state},</if>
             <if test="illustrate != null">#{illustrate},</if>
@@ -122,6 +128,7 @@
             <if test="userId != null">user_id = #{userId},</if>
             <if test="type != null">type = #{type},</if>
             <if test="ddId != null">dd_id = #{ddId},</if>
+            <if test="orderSource != null">order_source = #{orderSource},</if>
             <if test="amount != null">amount = #{amount},</if>
             <if test="state != null">state = #{state},</if>
             <if test="illustrate != null">illustrate = #{illustrate},</if>

+ 53 - 0
ruoyi-system/src/test/java/com/ruoyi/system/mapper/UserBillingOrderSourceTest.java

@@ -0,0 +1,53 @@
+package com.ruoyi.system.mapper;
+
+import com.ruoyi.system.domain.UserBilling;
+import org.apache.ibatis.builder.xml.XMLMapperBuilder;
+import org.apache.ibatis.session.Configuration;
+import org.junit.jupiter.api.Test;
+
+import java.io.InputStream;
+import java.util.Date;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/** Validate the SQL/binding contract consumed by MySQL, without claiming database integration. */
+class UserBillingOrderSourceTest {
+    private Configuration mappings() throws Exception {
+        Configuration config = new Configuration();
+        config.getTypeAliasRegistry().registerAlias("UserBilling", UserBilling.class);
+        String resource = "mapper/Billing/UserBillingMapper.xml";
+        try (InputStream input = getClass().getClassLoader().getResourceAsStream(resource)) {
+            assertNotNull(input);
+            new XMLMapperBuilder(input, config, resource, config.getSqlFragments()).parse();
+        }
+        return config;
+    }
+
+    @Test void insertBindsOrderSourceAndExplicitCompletionTimeToMatchingColumns() throws Exception {
+        var config = mappings();
+        var bill = new UserBilling(); bill.setUserId(8L); bill.setDdId("98178900000000001");
+        bill.setOrderSource("FLASH"); bill.setCretim(new Date(1_000L)); bill.setAmount(160.0);
+        var bound = config.getMappedStatement("com.ruoyi.system.mapper.UserBillingMapper.insertUserBilling").getBoundSql(bill);
+        String sql = bound.getSql().replaceAll("\\s+", " ").toLowerCase();
+        assertTrue(sql.contains("user_id, dd_id, order_source, cretim, amount"), sql);
+        assertEquals(java.util.List.of("userId", "ddId", "orderSource", "cretim", "amount"),
+                bound.getParameterMappings().stream().map(p -> p.getProperty()).toList());
+    }
+
+    @Test void sourceCanBeReadFilteredAndUpdatedWithoutChangingLegacyNullRecords() throws Exception {
+        var config = mappings();
+        String namespace = "com.ruoyi.system.mapper.UserBillingMapper.";
+        var sourceMapping = config.getResultMap(namespace + "UserBillingResult").getResultMappings().stream()
+                .filter(p -> p.getProperty().equals("orderSource")).findFirst().orElseThrow();
+        assertEquals("order_source", sourceMapping.getColumn());
+        var bill = new UserBilling(); bill.setId(1L); bill.setOrderSource("FLASH");
+        var select = config.getMappedStatement(namespace + "selectUserBillingList").getBoundSql(bill);
+        assertTrue(select.getSql().contains("b.order_source ="));
+        assertTrue(select.getParameterMappings().stream().anyMatch(p -> p.getProperty().equals("orderSource")));
+        var update = config.getMappedStatement(namespace + "updateUserBilling").getBoundSql(bill);
+        assertTrue(update.getSql().contains("order_source ="));
+        bill.setOrderSource(null);
+        var legacy = config.getMappedStatement(namespace + "selectUserBillingList").getBoundSql(bill);
+        assertFalse(legacy.getSql().contains("b.order_source ="));
+    }
+}

+ 9 - 0
specs/024-flash-delivery/contracts/api.md

@@ -214,3 +214,12 @@
 - `GET /system/flashDelivery/orders/{id}`:骑手接单后 `rider` 直接返回骑手完整 `info_user` 资料(含联系方式、车型、车牌和坐标),未接单时为空;不再按状态裁剪位置字段,也不再是精简摘要对象。
 - `GET /system/flashDelivery/rider/orders/{id}` 及接单成功响应:接单后新增 `user` 字段,直接返回寄件用户完整 `info_user` 资料;抢单前脱敏详情不返回 `user`,用户端详情不返回寄件用户资料。
 
+## 2026-09-30:骑手账单接入(不入钱包)
+
+- 成功进入 `COMPLETED` 时生成全额收入账单;无需先确认收款,钱包余额不变。重复完成不重复记账,记账失败完成状态一起回滚;不补历史已完成订单。
+- 复用 `GET /Billing/billing/getzddaylist`、`getzdmeeklist`、`getzdmatlist`,请求参数和分页/统计字段不变。骑手收入 `sum/shul` 包含两类订单,闪送不产生 commission/tax/behalfAmount。
+- `data.list` 的骑手账单新增/补充:`orderSource` (`FLASH` / `FOOD`,历史空来源返回 FOOD)、`flashOrderId`(仅闪送详情主键)、`tipAmount`(整数 TWD,已包含 amount)、`paymentStatus`(0 未确认、1 已确认)。`ddId` 为闪送订单号,`amount` 为全额收入;`freight` 为总额减小费,`jvli` 为公里距离,`qsStarts` 为当前评分,`payType` 为 `4`/`6`。
+- 通用 `/Billing/billing/getUsbalance` 的账单列表透出 `orderSource`(历史/非订单记录可能为 NULL),其 `sum` 仍是原钱包余额,不能把闪送账单收入相加当作可提现余额;原平台账单列表和详情的 UserBilling 同样透出来源。客户端不得用闪送账单的 `ddId` 或 `flashOrderId` 打开外卖详情。
+- 新增错误 `flash.delivery.billing.save.failed`;账单不等于已收款凭证,不提供可提现余额。
+- 旧 `/wallet/getUserBills` 同样透出实体的 `orderSource`,闪送账单不再参与外卖订单关联查询;该接口只返回账单记录,没有改动钱包余额或提现处理。
+

+ 7 - 0
specs/024-flash-delivery/data-model.md

@@ -45,3 +45,10 @@
 - `flash_delivery_order_image` 增加 `stop_id`;`PICKUP` 凭证属于实际取货站,`HANDOFF` 为异常关闭前的物品交接凭证。SENDER 仍为订单级图片,不强制分站。
 - 订单行锁统一串行化取件、目标调整及取消。部分取件不改变主状态但增加版本;所有站点完成才推进 `PICKED_UP`。站点、订单、图片和日志共享事务。
 - 迁移及字典初始化只记录于 `updatesql/sql.md`,由开发者手动执行;没有历史订单站点迁移、兼容回退或自动清理。
+
+## 2026-09-30:骑手收入账单
+
+- 复用 `user_billing`,新增可空 `order_source VARCHAR(16)`:新闪送账单为 `FLASH`,外卖为 `FOOD`;历史及非订单账单可为 NULL,历史骑手订单账单响应按 FOOD 兼容。
+- 闪送账单:`user_id=rider_id,user_type=2,type=0,state=0,dd_id=order_no,amount=订单总额,cretim=完成时间,payment_method=pay_type`。抽成、抽成比例、税均为 0;门店、摊位及 wallet_balance 不填写,不写钱包记录。
+- 主订单行锁及成功完成条件更新为幂等入口;用户签收、平台完成和自动完成在同一事务内写状态、日志和账单,任一步失败整体回滚。只生成未来成功完成的订单账单,不扫描补历史账单。
+- 收款状态仍保存在 `flash_delivery_order.payment_status`,账单收入不能推断实际收款;账单列表动态关联本骑手闪送订单,返回主键、小费、收款状态和当前评分。不得以闪送订单号去关联 `pos_order`。

+ 8 - 1
specs/024-flash-delivery/plan.md

@@ -4,7 +4,7 @@
 
 ## 摘要
 
-在既有独立闪送模块上补充包裹类别/四档重量范围、立即或预约配送、可选 PIN 交付、寄件图片、费用明细、页面状态分组、骑手公开摘要和角色安全响应。服务端继续负责路线报价、骑手原子抢单、取件/送达图片、用户签收、24 小时自动完成和平台介入;支付、退款、结算、骑手收入与自动派单仍不在本期范围。
+在既有独立闪送模块上补充包裹类别/四档重量范围、立即或预约配送、可选 PIN 交付、寄件图片、费用明细、页面状态分组、骑手公开摘要和角色安全响应。服务端继续负责路线报价、骑手原子抢单、取件/送达图片、用户签收、24 小时自动完成和平台介入;2026-09-30 补充完成后骑手收入账单(仅记账)。支付网关、退款、结算、钱包入账与自动派单仍不在本期范围。
 
 ## 技术上下文
 
@@ -761,3 +761,10 @@ The staged list must not contain `.claude/homunculus/observations.jsonl`、`.tmp
 - [x] Google Routes 失败时记录车型与脱敏失败原因后降级直线距离;HTTP 非成功响应记录状态码,不记录地址、坐标、Key 或完整响应。
 - [x] `GoogleRoutesDistanceProviderTest` 与 `FlashDeliveryRouteServiceTest` 共 5 项通过。
 - [ ] 从当前开发机访问 `routes.googleapis.com:443` 连接超时,未能验证共享 Key 的 Routes API 权限;需在测试服务器部署后查看新告警日志或从该服务器网络发起验证。
+
+## 2026-09-30:闪送骑手收入账单实施计划
+
+1. 在 `UserBilling` 和 XML 映射增加可空 `orderSource`,迁移 SQL 仅登记;非订单账单保持为空。新增闪送账单明确写 `FLASH`,历史骑手订单账单在响应中按 `FOOD` 兼容,不迁移历史账单。
+2. 两个事务完成入口在订单行锁和 `DELIVERED -> COMPLETED` 条件更新成功后共用 `recordRiderIncome`,直接调用账单 Mapper 写入全额收入及完成时间。写入失败抛国际化业务异常并回滚;不依赖确认收款,不调用任何钱包服务。
+3. `UserBillingController` 的日/周/月骑手账单按来源分别批量查询外卖和闪送订单;闪送查询同时约束当前骑手,保留分页顺序和原统计 SQL。`QsBillingDto` 补充闪送主键、小费与收款状态,通用账单响应也返回来源;旧 `WalletController.getUserBills` 仅过滤外卖关联查询的来源,余额/提现不变。
+4. TDD 覆盖四种完成操作人、重复/竞争失败、取消/未取完、账单写入失败、确认收款不生成账单,以及单独/混合来源的日周月详情;验证 XML 绑定新字段和完成时间。运行 JDK 21 定向测试、模块测试及构建,检查差异和换行;真实数据库事务/并发、SQL 迁移及 App 联调另列未验证。

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

@@ -281,7 +281,7 @@
 
 ## 非目标
 
-- 不接入支付网关、付款、退款、取消费、退回费、结算或骑手收入分账。
+- 不接入支付网关、付款、退款、取消费、退回费、结算或骑手收入分账;2026-09-30 增量仅生成骑手收入账单,不进入平台钱包或提现。
 - 不实现代购、垫付款、商品金额或禁寄品电子确认;禁运品和货值限制由前端展示规则说明,后端校验本期明确的物品数量、重量范围、规格长度和最大路线距离。
 - 不实现自动派单、抢单推送筛选、骑手在线状态计算或接单时间预测。
 - 不修改餐饮订单 `pos_order` 或打车订单 `taxi_order` 的业务语义。
@@ -385,3 +385,12 @@
 风格同构,便于电话报单与按时间排查。同毫秒并发由 JVM 内原子递增序列区分;
 时钟回拨、多实例等理论撞号由 order_no 唯一索引 uk_flash_order_no 兜底(updatesql/sql.md 2026-09-22 节),
 插入冲突时换号重试一次,userId+clientRequestId 幂等键行为不变。存量订单编号保持不变,不做迁移。
+
+## 2026-09-30:闪送骑手收入账单(仅记账)
+
+- FR-105:用户(寄件人或已绑定收件人)确认收货、平台手动完成、24 小时自动完成成功进入 `COMPLETED` 时,生成一条接单骑手的收入账单;`DELIVERED`、取消及异常关闭不生成。完成与账单写入必须同事务,写入失败整体回滚。
+- FR-106:账单金额等于服务端订单 `amount`,包含配送费、加急费及小费;不套用外卖抽成或扣税,不增加/读取钱包余额、不进入平台提现,不新增平台结算。款项由付款人直接付给骑手。
+- FR-107:复用 `user_billing`(`type=0,user_type=2,state=0`);`order_source=FLASH`,`dd_id` 为闪送 `orderNo`,`cretim` 为完成时间,`payment_method` 保存订单 `payType`。订单行锁及成功状态条件更新保证同一订单最多生成一次,重复或竞争失败的请求不得生成账单。
+- FR-108:记账不以 `payment_status=1` 为前提;生成账单不改变收款状态,“确认收款”也不再生成第二笔。账单表示完成订单收入,不是实际到账核验。
+- FR-109:现有骑手日/周/月账单列表及统计包含闪送收入;按 `orderSource` 区分 `FLASH`/`FOOD`,历史空来源的骑手订单账单按 `FOOD` 兼容。闪送详情只关联当前骑手的闪送订单,不关联外卖订单;返回 `flashOrderId`、订单号、金额、运费(总额减小费)、小费、公里距离、评分、支付方式及收款状态。账单列表保持原分页与排序。
+- FR-110:只针对上线后成功完成的订单,不自动补历史已完成订单,不修改外卖记账/钱包/提现规则。SQL 仅登记在 `updatesql/sql.md`,部署前由开发者执行;用户/骑手 App 页面不在本次范围内。

+ 14 - 0
specs/024-flash-delivery/tasks.md

@@ -220,3 +220,17 @@
 - [x] T112 【2026-09-17】预约单结束时间到达后自动取消:列表/接单限制结束边界,Quartz 每分钟以条件更新收口,平台详情概况显示预约取件时段,补 SQL、注释和定向测试;SQL 仅已登记,尚未执行。(原 test 分支 T103)
 - [x] T113 【2026-09-17】随合并引入的其余闪送增强:预约单到点开放推送(`is_display` 门控)、订单状态变更消息推送、订单完成后骑手评分与详情返回骑手平均分、用户端与骑手端按订单编号查询详情、详情返回对端完整用户资料;i18n 契约同步(沿用 `test-202609v2` 删除越南语包的策略,其余语言包补齐文案)。
 - 备注:test 分支原 T101(车型运价)、T102(共用 Google Map Key)已由 Phase 20 T105/T106 覆盖,不重复记录。
+
+## Phase 23:闪送骑手收入账单(2026-09-30)
+
+- [x] T114 补完成记账测试并验证旧流程不生成账单;两个事务完成入口只在成功状态更新后写全额骑手收入,不调用钱包,不等待确认收款。
+- [x] T115 增加账单订单来源、XML 映射及手动 SQL;补齐骑手日/周/月和通用账单响应,区分外卖/闪送、公里距离、运费、小费、评分与收款状态。
+- [x] T116 更新 spec、plan、数据模型、接口契约、App 接入文档、五套后端 i18n 和代码注释;不补历史账单、不修改外卖钱包规则。
+- [x] T117 运行 JDK 21 定向测试、模块回归及构建,核对变更范围和 CRLF;真实 MySQL 迁移/事务/并发与 App 联调独立登记未验证。
+
+### Phase 23 验证记录
+
+- 最终 `mvn -pl ruoyi-admin -am test`:本轮新鲜报告 141 个测试类、975 项测试,0 失败/错误/跳过;不计入 target 中 2026-09-24 遗留的已删除测试报告。
+- `mvn -pl ruoyi-admin -am -DskipTests package`:全部参与模块 BUILD SUCCESS。实际 JDK 为本机 `C:\Users\99029\.jdks\graalvm-jdk-21.0.12+7.1`,仅临时设置命令环境;规范中原 JDK 路径在本机不存在。
+- 独立只读审查未发现高/中严重度问题;已补旧 `/wallet/getUserBills` 的订单来源过滤及回归测试。保留 CRLF;使用 `git -c core.whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol diff --check` 检查(既有测试文件的 Git blob 本身含 CRLF)。
+- SQL 仅登记,不执行;未验证真实 MySQL 事务/多连接并发、App 展示、部署上线。提交与推送状态以 Git 记录为准。

+ 10 - 0
specs/024-flash-delivery/validation.md

@@ -41,3 +41,13 @@ Mock 测试覆盖业务分支、事务注解、订单锁及当前读调用顺序
 - 用户/骑手闪送 App 页面源码不在本次工作区,排序、目标选择、导航、进度与取消入口仍需客户端接入和联调;T099、T100 对应部分继续保留待办。
 - 未调用真实 Google Routes,未部署或切换 Foodie_HCM_Map,未验证真机导航。当前报价扩展既有 Google provider 的有序多经点与整线直线降级。
 - 未运行全仓完整回归,未部署上线。
+
+## 2026-09-30:闪送骑手账单增量验证
+
+以下仅针对本次账单增量,不覆盖上述旧章节的真实业务联调待办。
+
+- TDD:先确认完成记账 6 项行为失败(原逻辑未写账单),补实现后通过;先确认账单详情缺失与历史来源 4 项失败,补来源分流后通过。审查发现旧钱包账单入口仍跨表查询,先以测试复现,再补来源过滤。
+- 新增 `FlashDeliveryRiderBillingTest` 12 项、`UserBillingFlashDeliveryTest` 8 项、`UserBillingOrderSourceTest` 2 项,覆盖各完成操作人、完整金额、未确认收款、重复/竞争失败、未取完/取消、记账失败、Spring 注解事务回滚、本骑手查询过滤、日周月/通用列表、混合与缺失订单、来源/完成时间 XML 绑定。事务代理测试验证回滚请求,不等同真实数据库回滚验证。
+- 最终使用本机 JDK 21(`C:\Users\99029\.jdks\graalvm-jdk-21.0.12+7.1`)运行 `mvn -pl ruoyi-admin -am test`,本轮 141 个测试类、975 项测试,0 失败、0 错误、0 跳过;原 target 中过期的已删除测试报告不计入。
+- 执行 `mvn -pl ruoyi-admin -am -DskipTests package`,全部参与模块 `BUILD SUCCESS`。独立只读审查后修复唯一低严重度来源过滤问题,检查变更范围、CRLF 与 `git diff --check`(临时 `core.whitespace` 包含 `cr-at-eol`)。
+- 未执行 `user_billing.order_source` 迁移 SQL、真实 MySQL 多连接竞争/失败回滚、App 联调或部署。代码仅对未来成功完成的订单生成账单,不自动补历史账单,不进入钱包/提现。提交与推送状态以 Git 记录为准。

+ 10 - 0
updatesql/sql.md

@@ -1873,3 +1873,13 @@ ALTER TABLE info_bank_card
   ADD COLUMN store_id BIGINT NULL COMMENT '夜市摊位ID;普通商家和骑手卡为NULL' AFTER user_id,
   ADD KEY idx_bank_card_store (store_id);
 ```
+
+## 2026-09-30 024 闪送骑手收入账单(仅记账,不入钱包)
+
+部署本次代码前由开发者手动执行;不自动生成历史已完成闪送账单,不迁移钱包余额。
+历史外卖及非订单账单保持 NULL;骑手日/周/月订单账单接口将历史空来源按 FOOD 返回。
+
+```sql
+ALTER TABLE user_billing
+  ADD COLUMN order_source VARCHAR(16) DEFAULT NULL COMMENT '订单来源:FLASH闪送 FOOD外卖;历史及非订单账单可为空' AFTER dd_id;
+```