Browse Source

新增商家自配送配置、订单流转与运费结算

支持普通商家及夜市统一配置自配送时段,下单固化配送方式并隔离骑手操作。

新增商家开始配送、确认送达、运费全额结算及用户通知,同步测试、国际化、规格文档与数据库变更脚本。

验证:JDK 21 执行 mvn -B -pl ruoyi-admin -am test 成功,管理模块 458 项测试通过;暂存差异检查通过。数据库迁移、迁移后的接口冒烟及客户端接入未执行。
qmj 7 hours ago
parent
commit
6fb926dd5b
38 changed files with 2611 additions and 3 deletions
  1. 1 1
      .specify/feature.json
  2. 1 1
      CLAUDE.md
  3. 61 0
      ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosStoreController.java
  4. 35 0
      ruoyi-admin/src/main/java/com/ruoyi/app/mendian/dto/SelfDeliveryConfigInput.java
  5. 5 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderLifecycleService.java
  6. 31 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderService.java
  7. 14 1
      ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderQsOprateController.java
  8. 182 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderShOprateController.java
  9. 179 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/SelfDeliveryService.java
  10. 6 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/UserOrderController.java
  11. 15 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/SelfDeliveryOperateInput.java
  12. 60 0
      ruoyi-admin/src/main/java/com/ruoyi/app/utils/SelfDeliveryUtil.java
  13. 11 0
      ruoyi-admin/src/main/resources/i18n/messages.properties
  14. 11 0
      ruoyi-admin/src/main/resources/i18n/messages_en_US.properties
  15. 11 0
      ruoyi-admin/src/main/resources/i18n/messages_vi.properties
  16. 11 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
  17. 11 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties
  18. 155 0
      ruoyi-admin/src/test/java/com/ruoyi/app/mendian/PosStoreControllerSelfDeliveryTest.java
  19. 96 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/OrderServiceSelfDeliveryBillingTest.java
  20. 79 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/PosOrderQsOprateControllerTest.java
  21. 218 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/PosOrderShOprateControllerTest.java
  22. 46 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/SelfDeliveryOrderExposureTest.java
  23. 243 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/SelfDeliveryServiceTest.java
  24. 84 0
      ruoyi-admin/src/test/java/com/ruoyi/app/utils/SelfDeliveryUtilTest.java
  25. 5 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/PosOrder.java
  26. 8 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/PosStore.java
  27. 41 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/PosStoreSelfDeliveryHours.java
  28. 13 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosStoreSelfDeliveryHoursMapper.java
  29. 38 0
      specs/029-merchant-self-delivery/checklists/requirements.md
  30. 96 0
      specs/029-merchant-self-delivery/contracts/api.md
  31. 96 0
      specs/029-merchant-self-delivery/data-model.md
  32. 70 0
      specs/029-merchant-self-delivery/delivery.md
  33. 110 0
      specs/029-merchant-self-delivery/plan.md
  34. 82 0
      specs/029-merchant-self-delivery/quickstart.md
  35. 104 0
      specs/029-merchant-self-delivery/research.md
  36. 172 0
      specs/029-merchant-self-delivery/spec.md
  37. 184 0
      specs/029-merchant-self-delivery/tasks.md
  38. 26 0
      updatesql/sql.md

+ 1 - 1
.specify/feature.json

@@ -1,3 +1,3 @@
 {
-  "feature_directory": "specs/027-offline-transfer-payment"
+  "feature_directory": "specs/029-merchant-self-delivery"
 }

+ 1 - 1
CLAUDE.md

@@ -204,5 +204,5 @@ Strong success criteria let you loop independently. Weak criteria ("make it work
 
 <!-- SPECKIT START -->
 For additional context about technologies to be used, project structure,
-shell commands, and other important information, read the current plan: `specs/027-offline-transfer-payment/plan.md`
+shell commands, and other important information, read the current plan: `specs/029-merchant-self-delivery/plan.md`
 <!-- SPECKIT END -->

+ 61 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosStoreController.java

@@ -10,6 +10,8 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.ruoyi.app.order.dto.OrderCreatItem;
 import com.ruoyi.app.order.dto.OrderCreateInput;
 import com.ruoyi.app.order.dto.OrderPushBodyDto;
+import com.ruoyi.app.order.SelfDeliveryService;
+import com.ruoyi.app.mendian.dto.SelfDeliveryConfigInput;
 import com.ruoyi.app.service.UserService;
 import com.ruoyi.app.utils.DateUtil;
 import com.ruoyi.app.utils.ImageCompressUtils;
@@ -65,6 +67,8 @@ public class PosStoreController extends BaseController {
     private PosStoreMapper posStoreMapper;
     @Autowired
     private IPosCollectService posCollectService;
+    @Autowired  //029 商家自配送:配置解析/保存
+    private SelfDeliveryService selfDeliveryService;
     @Autowired
     private IPosFenleiService posFenleiService;
 
@@ -473,6 +477,63 @@ public class PosStoreController extends BaseController {
         merchantStoreAccessService.requireStoreAccess(loginUserId, storeId);
     }
 
+    /**
+     * (商家端)获取门店自配送配置(029):普通门店返回自身;摊位返回所辖夜市配置(前端只读展示)
+     */
+    @Anonymous
+    @Auth(session = true)
+    @GetMapping("/getMdSelfDeliveryHours")
+    public AjaxResult getMdSelfDeliveryHours(@RequestHeader String token, @RequestParam Integer mdId) {
+        if (mdId == null) {
+            throw new ServiceException(MessageUtils.message("no.mendian.not.exist"));
+        }
+        PosStore store = posStoreService.getById(mdId);
+        if (store == null) {
+            throw new ServiceException(MessageUtils.message("no.mendian.not.exist"));
+        }
+        // 摊位解析到夜市主门店行(spec FR-002:摊位配送方式跟随夜市);解析不到按未配置返回
+        PosStore configStore = selfDeliveryService.resolveConfigStore(store);
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("enabled", configStore != null && Integer.valueOf(1).equals(configStore.getSelfDeliveryEnabled()));
+        data.put("mode", configStore == null ? null : configStore.getSelfDeliveryMode());
+        data.put("hours", configStore == null ? new ArrayList<>()
+                : selfDeliveryService.listHours(Long.valueOf(configStore.getId())));
+        data.put("configMdId", configStore == null ? null : configStore.getId());
+        return success(data);
+    }
+
+    /**
+     * (商家端)保存门店自配送配置(029):摊位无配置权;夜市主门店行仅夜市主本人可保存;普通门店走门店权限
+     */
+    @Anonymous
+    @Auth(session = true)
+    @PostMapping("/saveMdSelfDeliveryHours")
+    public AjaxResult saveMdSelfDeliveryHours(@RequestHeader String token,
+                                              @RequestBody SelfDeliveryConfigInput input) {
+        Long userId = AuthContext.requireUserId();
+        if (input == null || input.getMdId() == null) {
+            throw new ServiceException(MessageUtils.message("no.store.selfdelivery.hours.invalid"));
+        }
+        PosStore store = posStoreService.getById(input.getMdId());
+        if (store == null) {
+            throw new ServiceException(MessageUtils.message("no.mendian.not.exist"));
+        }
+        // 摊位配送方式由夜市主统一设置(spec FR-002)
+        if (Integer.valueOf(1).equals(store.getIsStall())) {
+            throw new ServiceException(MessageUtils.message("no.store.stall.selfdelivery.readonly"));
+        }
+        if (Integer.valueOf(1).equals(store.getIsNightMarket())) {
+            // 夜市场景:配置主体是夜市主自己的门店行,仅夜市主本人可保存
+            if (!userId.equals(store.getUserId())) {
+                throw new ServiceException(MessageUtils.message("merchant.store.access.denied"));
+            }
+        } else {
+            requireStoreAccess(userId, infoUserService.selectInfoUserByUserId(userId), Long.valueOf(input.getMdId()));
+        }
+        selfDeliveryService.saveConfig(input);
+        return success();
+    }
+
     //返回营业时间(按星期分组)
     private List<StoreOperatOutput> getStoreOperatOutput(List<OperatingHours> list, Long mdId){
         List<StoreOperatOutput> data= new ArrayList<>();

+ 35 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/mendian/dto/SelfDeliveryConfigInput.java

@@ -0,0 +1,35 @@
+package com.ruoyi.app.mendian.dto;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 门店自配送配置保存入参(029 商家自配送)。
+ * 仅承载请求数据;格式校验在 SelfDeliveryService、权限校验在 Controller(项目 Controller 参数规范:DTO 无校验注解)。
+ */
+@Data
+public class SelfDeliveryConfigInput {
+
+    /** 配置主体门店id(普通门店=自身;夜市=夜市主门店行;摊位不允许保存) */
+    private Integer mdId;
+
+    /** 自配送开关 */
+    private Boolean enabled;
+
+    /** 时段模式:1=全天,2=自定义按星期 */
+    private Integer mode;
+
+    /** 自定义时段列表(mode=2 时必填非空) */
+    private List<SelfDeliveryHourInput> hours;
+
+    @Data
+    public static class SelfDeliveryHourInput {
+        /** 星期几:1=周一..7=周日 */
+        private Integer dayOfWeek;
+        /** 开始时刻 HH:mm(含) */
+        private String startTime;
+        /** 结束时刻 HH:mm(不含,必须大于开始时刻) */
+        private String endTime;
+    }
+}

+ 5 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderLifecycleService.java

@@ -493,6 +493,11 @@ public class OrderLifecycleService {
         if (order.getType() == 0L && order.getQsId() != null) {
             billingService.setQishouBilling(order);
         }
+        // 029 商家自配送:无骑手(qsId 为空)的外送单由配送方(sh_id)得全额运费
+        if (order.getType() == 0L && order.getQsId() == null
+                && Integer.valueOf(1).equals(order.getSelfDelivery())) {
+            billingService.setSelfDeliveryFreightBilling(order);
+        }
     }
 
     private void refundPoints(PosOrder order) {

+ 31 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderService.java

@@ -157,4 +157,35 @@ public class OrderService {
             }
         }
     }
+
+    /**
+     * 执行自配送运费入账(029):运费全额(平台不抽成,2026-09-08 确认)入账配送方。
+     * user_id=sh_id——普通商家单=商家本人,夜市摊位单=夜市主(摊位订单 shId 即夜市主,research F3),
+     * 夜市主与摊位主之间的运费分摊线下自理。type="5" 与商品分成(type=0)区分,dedup 键 (userId,type,ddId) 互不冲突;
+     * 运费为空或 0 不生成零元账单。
+     */
+    public void setSelfDeliveryFreightBilling(PosOrder posOrder) {
+        if (posOrder.getFreight() == null || posOrder.getFreight() <= 0) {
+            return;
+        }
+        long count = userBillingService.count(new LambdaQueryWrapper<UserBilling>()
+                .eq(UserBilling::getUserId, posOrder.getShId())
+                .eq(UserBilling::getType, "5")
+                .eq(UserBilling::getDdId, posOrder.getDdId()));
+        if (count <= 0) {
+            UserBilling billing = new UserBilling();
+            DecimalFormat df = new DecimalFormat("#.00");
+            billing.setUserId(posOrder.getShId());
+            billing.setDdId(String.valueOf(posOrder.getDdId()));
+            billing.setType("5");
+            billing.setAmount(Double.valueOf(df.format(posOrder.getFreight())));
+            billing.setDivvy(0.00);
+            billing.setDivvyRate(0.00);
+            billing.setState("0");
+            billing.setMdId(posOrder.getMdId());
+            billing.setUserType("1");
+            billing.setIllustrate(MessageUtils.message("no.billing.selfdelivery.freight"));
+            walletService.addBalance(billing.getUserId(), BigDecimal.valueOf(billing.getAmount()), billing);
+        }
+    }
 }

+ 14 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderQsOprateController.java

@@ -87,6 +87,10 @@ public class PosOrderQsOprateController extends BaseController {
         if (order == null) {
             throw new ServiceException(MessageUtils.message("no.order.not.found"));
         }
+        // 029 商家自配送订单不进骑手链路(防直调绕过)
+        if (Integer.valueOf(1).equals(order.getSelfDelivery())) {
+            throw new ServiceException(MessageUtils.message("no.order.selfdelivery.rider.denied"));
+        }
         if (order.getType() != null && order.getType() != 0L) {
             throw new ServiceException("仅外送订单支持骑手接单");
         }
@@ -129,6 +133,10 @@ public class PosOrderQsOprateController extends BaseController {
         if (order.getAfterSaleStatus() != null && order.getAfterSaleStatus() > 0) {
             throw new ServiceException("订单存在售后申请,无法操作");
         }
+        // 029 商家自配送订单不进骑手链路(防直调绕过)
+        if (Integer.valueOf(1).equals(order.getSelfDelivery())) {
+            throw new ServiceException(MessageUtils.message("no.order.selfdelivery.rider.denied"));
+        }
         PosOrder posOrder = new PosOrder();
         posOrder.setId(order.getId());
         posOrder.setDeliveryStatus(2L);
@@ -157,6 +165,10 @@ public class PosOrderQsOprateController extends BaseController {
         if (order == null) {
             throw new ServiceException(MessageUtils.message("no.order.not.found"));
         }
+        // 029 商家自配送订单不进骑手链路(防直调绕过)
+        if (Integer.valueOf(1).equals(order.getSelfDelivery())) {
+            throw new ServiceException(MessageUtils.message("no.order.selfdelivery.rider.denied"));
+        }
         PosOrder posOrder = new PosOrder();
         posOrder.setId(order.getId());
         posOrder.setQsImg(input.getQsImg());
@@ -352,7 +364,8 @@ public class PosOrderQsOprateController extends BaseController {
             case "newTask":
                 wrapper.eq(PosOrder::getType, 0L).eq(PosOrder::getDeliveryStatus, 0L)
                         .eq(PosOrder::getState, 0L).eq(PosOrder::getPayStatus, 1L)
-                        .eq(PosOrder::getAfterSaleStatus, 0L).isNull(PosOrder::getQsId);
+                        .eq(PosOrder::getAfterSaleStatus, 0L).isNull(PosOrder::getQsId)
+                        .eq(PosOrder::getSelfDelivery, 0);
                 if (longitude != null && latitude != null) {
                     // 距离上限(公里)从字典取,未配置则不过滤
                     Integer limitKm = getNewTaskDistanceLimit();

+ 182 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderShOprateController.java

@@ -6,10 +6,12 @@ package com.ruoyi.app.order;
 
 import com.alibaba.fastjson.JSON;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 import com.ruoyi.app.order.dto.OrderCreatItem;
 import com.ruoyi.app.order.dto.OrderCreateInput;
 import com.ruoyi.app.order.dto.OrderPushBodyDto;
+import com.ruoyi.app.order.dto.SelfDeliveryOperateInput;
 import com.ruoyi.app.pay.LinePayOrderGuard;
 import com.ruoyi.app.pay.LinePayRefundService;
 import com.ruoyi.app.utils.PayPush;
@@ -28,12 +30,15 @@ import com.ruoyi.system.utils.AuthContext;
 import com.ruoyi.system.utils.JwtUtil;
 import com.ruoyi.system.domain.dto.InvalidInvoiceDto;
 import com.ruoyi.system.utils.OrderLogHelper;
+import org.redisson.api.RLock;
+import org.redisson.api.RedissonClient;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.web.bind.annotation.*;
 
 import java.text.SimpleDateFormat;
 import java.util.*;
+import java.util.concurrent.TimeUnit;
 import java.util.stream.Collectors;
 
 /**
@@ -73,6 +78,12 @@ public class PosOrderShOprateController extends BaseController {
     private OrderLifecycleService orderLifecycleService;
     @Autowired
     private DeliveryOrderNotificationService deliveryOrderNotificationService;
+    @Autowired  //029 商家自配送:配置解析与下单判定
+    private SelfDeliveryService selfDeliveryService;
+    @Autowired  //029 商家自配送:配送操作并发锁
+    private RedissonClient redissonClient;
+    @Autowired  //029 自配送到付归因
+    private IUserBillingService userBillingService;
     @Autowired
     private MerchantStoreAccessService merchantStoreAccessService;
 
@@ -212,6 +223,10 @@ public class PosOrderShOprateController extends BaseController {
             PosStore store = storeOptional.get();
             posOrder.setLongitude(store.getLongitude());
             posOrder.setLatitude(store.getLatitude());
+            // 029 商家自配送:外送单按门店时段判定并固化快照(即时=下单时刻;预约按 delryTime 时间段开始时刻)
+            posOrder.setSelfDelivery(Long.valueOf(0L).equals(item.getType())
+                    && selfDeliveryService.isSelfDelivery(store,
+                    selfDeliveryService.resolveJudgeTime(item.getDelryTime())) ? 1 : 0);
             //设置订单分类
             storeList.stream().filter(s -> Long.valueOf(s.getId()).equals(item.getMdId())).findFirst().ifPresent(st -> {
                 posOrder.setOrderCategory(String.valueOf(st.getType()));
@@ -316,6 +331,10 @@ public class PosOrderShOprateController extends BaseController {
         InfoUser shUser = infoUserService.getOne(new LambdaQueryWrapper<InfoUser>().eq(InfoUser::getUserId, Long.valueOf(new JwtUtil().getusid(token))));
         String shName = shUser != null ? shUser.getNickName() : "";
         orderLogHelper.log(String.valueOf(order.getDdId()), 2, Long.valueOf(new JwtUtil().getusid(token)), shName, "商家" + shName + "已接单");
+        // 029 FR-009:自配送单无骑手接单环节,商家接单后向用户推送备餐通知(核验修复 2026-09-08)
+        if (Integer.valueOf(1).equals(order.getSelfDelivery())) {
+            pushSelfDeliveryStatus(order, "no.message.push.merchant.accepted.content", "1");
+        }
         return AjaxResult.success();
     }
 
@@ -389,6 +408,165 @@ public class PosOrderShOprateController extends BaseController {
         return AjaxResult.success();
     }
 
+    /**
+     * 商家自配送-开始配送(029,可选步骤):已出餐后标记开始配送,deliveryStatus 0→2,用户端显示商家配送中
+     */
+    @Anonymous
+    @Auth(session = true)
+    @PostMapping("/selfDeliveryStart")
+    public AjaxResult selfDeliveryStart(@RequestHeader String token, @RequestBody SelfDeliveryOperateInput input) {
+        return selfDeliveryOperate(input, false);
+    }
+
+    /**
+     * 商家自配送-确认送达(029):deliveryStatus→3 + state→3 + sdTime,允许跳过开始配送;
+     * 完成时执行商品分成+自配送运费入账+到付归因(须已支付)
+     */
+    @Anonymous
+    @Auth(session = true)
+    @Transactional(rollbackFor = Exception.class)
+    @PostMapping("/selfDeliveryComplete")
+    public AjaxResult selfDeliveryComplete(@RequestHeader String token, @RequestBody SelfDeliveryOperateInput input) {
+        return selfDeliveryOperate(input, true);
+    }
+
+    /**
+     * 自配送操作公共逻辑:权限复用商家订单访问校验(普通商家按门店权限、夜市主按 shId 归属)。
+     * CAS 条件更新防并发;complete 原子完成订单(delivery=3 ⇒ state=3,满足 018 状态机不变量)。
+     */
+    private AjaxResult selfDeliveryOperate(SelfDeliveryOperateInput input, boolean complete) {
+        if (input == null || input.getId() == null) {
+            throw new ServiceException(MessageUtils.message("no.order.not.found"));
+        }
+        RLock lock = redissonClient.getLock("order:sh:selfdelivery:" + input.getId());
+        try {
+            if (!lock.tryLock(10L, 10L, TimeUnit.SECONDS)) {
+                throw new ServiceException(MessageUtils.message("no.system.busy.try.again"));
+            }
+            try {
+                PosOrder order = posOrderService.getById(input.getId());
+                if (order == null) {
+                    throw new ServiceException(MessageUtils.message("no.order.not.found"));
+                }
+                InfoUser merchant = requireMerchantOrderAccess(order);
+                // 仅外送自配送单可操作;qsId 必须为空(历史异常数据防线)
+                if (!Long.valueOf(0L).equals(order.getType())
+                        || !Integer.valueOf(1).equals(order.getSelfDelivery())
+                        || order.getQsId() != null) {
+                    throw new ServiceException(MessageUtils.message("no.order.selfdelivery.required"));
+                }
+                if (order.getAfterSaleStatus() != null && order.getAfterSaleStatus() > 0) {
+                    throw new ServiceException(MessageUtils.message("no.order.selfdelivery.status.invalid"));
+                }
+                // 必须已出餐(state=2);开始配送要求未开始(deliveryStatus=0),确认送达允许 0/2
+                if (!Long.valueOf(2L).equals(order.getState())) {
+                    throw new ServiceException(MessageUtils.message("no.order.selfdelivery.status.invalid"));
+                }
+                // 完成前置:必须已支付(在线/到付创建即付;商家POS现金/转账外送单须先确认收款),
+                // 对齐骑手送达语义与管理端"完成单必已支付"不变量(029 核验修复 2026-09-08)
+                if (complete && !Long.valueOf(1L).equals(order.getPayStatus())) {
+                    throw new ServiceException(MessageUtils.message("no.order.selfdelivery.unpaid"));
+                }
+                long current = order.getDeliveryStatus() == null ? 0L : order.getDeliveryStatus();
+                if (complete ? (current != 0L && current != 2L) : current != 0L) {
+                    throw new ServiceException(MessageUtils.message("no.order.selfdelivery.status.invalid"));
+                }
+                PosOrder update = new PosOrder();
+                update.setId(order.getId());
+                LambdaUpdateWrapper<PosOrder> cas = new LambdaUpdateWrapper<PosOrder>()
+                        .eq(PosOrder::getId, order.getId())
+                        .eq(PosOrder::getState, 2L);
+                String logContent;
+                if (complete) {
+                    update.setDeliveryStatus(3L);
+                    update.setState(3L);
+                    update.setSdTime(new Date());
+                    update.setQsImg(input.getQsImg());
+                    cas.in(PosOrder::getDeliveryStatus, 0L, 2L);
+                    logContent = "商家已确认送达(自配送)";
+                } else {
+                    update.setDeliveryStatus(2L);
+                    cas.eq(PosOrder::getDeliveryStatus, 0L);
+                    logContent = "商家已开始配送(自配送)";
+                }
+                if (!posOrderService.update(update, cas)) {
+                    throw new ServiceException(MessageUtils.message("no.order.selfdelivery.status.invalid"));
+                }
+                if (complete) {
+                    // 完成副作用(029 US5):商品分成 + 自配送运费全额入账 sh_id + 到付归因配送方
+                    orderService.setSanghuBilling(order);
+                    orderService.setSelfDeliveryFreightBilling(order);
+                    if ("1".equals(order.getCollectPayment())) {
+                        updateUserBillForSelfDelivery(order);
+                    }
+                }
+                orderLogHelper.log(String.valueOf(order.getDdId()), 2, merchant.getUserId(),
+                        merchant.getNickName(), logContent);
+                pushSelfDeliveryStatus(order, selfDeliveryPushContent(complete), selfDeliveryPushState(complete));
+                return success();
+            } finally {
+                if (lock.isHeldByCurrentThread()) {
+                    lock.unlock();
+                }
+            }
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new ServiceException(MessageUtils.message("no.operation.interrupted.try.again"));
+        }
+    }
+
+    /**
+     * 自配送到付归因(029 US5):用户到付账单(type=3)收款方记为配送方 shId,
+     * 镜像骑手送达的 updateUserBill(QsOprate)但不依赖 qsId
+     */
+    private void updateUserBillForSelfDelivery(PosOrder order) {
+        QueryWrapper<UserBilling> queryWrapper = new QueryWrapper<>();
+        queryWrapper.eq("dd_id", order.getDdId());
+        queryWrapper.eq("user_id", order.getUserId());
+        queryWrapper.eq("type", "3");
+        UserBilling userBill = userBillingService.getOne(queryWrapper);
+        if (userBill != null) {
+            userBill.setState("0");
+            userBill.setIllustrate("Merchant Self Delivery Order");
+            userBill.setBalancePay("1");
+            userBill.setPaymentId(String.valueOf(order.getShId()));
+            userBillingService.saveOrUpdate(userBill);
+        }
+    }
+
+    /** 自配送推送文案 key(029):complete 区分送达文案(纯函数便于单测) */
+    static String selfDeliveryPushContent(boolean complete) {
+        return complete ? "no.message.push.merchant.delivery.complete" : "no.message.push.merchant.delivery.start";
+    }
+
+    /** 自配送推送透传的订单状态(029):complete=3 已送达,否则 2 配送中 */
+    static String selfDeliveryPushState(boolean complete) {
+        return complete ? "3" : "2";
+    }
+
+    /**
+     * 自配送状态推送(029):仅推用户,无骑手链路;文案 key 与透传状态由调用方给定
+     */
+    private void pushSelfDeliveryStatus(PosOrder order, String contentKey, String stateStr) {
+        InfoUser user = infoUserService.getById(order.getUserId());
+        if (user == null || StringUtils.isEmpty(user.getCid())) {
+            return;
+        }
+        String title = "no.message.push.message";
+        String content = contentKey;
+        String body = OrderPushBodyDto.getJson(String.valueOf(order.getDdId()), stateStr);
+        String ddId = String.valueOf(order.getDdId());
+        PayPush push = new PayPush();
+        Long uUserId = user.getUserId();
+        String uCid = user.getCid();
+        AsyncManager.me().execute(new TimerTask() {
+            @Override
+            public void run() {
+                PayPush.userPushHandleLocal(push, pushEventService, uUserId, uCid, title, content, body, "", ddId);
+            }
+        });
+    }
+
     /**
      * 商家确认现金收款:payType=4 的订单 payStatus 0→1(现金单创建时未支付,商家收到现金后在App触发)
      */
@@ -493,6 +671,10 @@ public class PosOrderShOprateController extends BaseController {
     }
 
     private void requireRiderAssigned(PosOrder order) {
+        // 029 商家自配送:无骑手环节,商家可直接接单/出餐
+        if (Integer.valueOf(1).equals(order.getSelfDelivery())) {
+            return;
+        }
         if (Long.valueOf(0L).equals(order.getType())
                 && (!Long.valueOf(1L).equals(order.getDeliveryStatus()) || order.getQsId() == null)) {
             throw new ServiceException(MessageUtils.message("no.order.rider.accept.required"));

+ 179 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/SelfDeliveryService.java

@@ -0,0 +1,179 @@
+package com.ruoyi.app.order;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
+import com.ruoyi.app.mendian.dto.SelfDeliveryConfigInput;
+import com.ruoyi.app.mendian.dto.SelfDeliveryConfigInput.SelfDeliveryHourInput;
+import com.ruoyi.app.utils.SelfDeliveryUtil;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.PosStore;
+import com.ruoyi.system.domain.PosStoreSelfDeliveryHours;
+import com.ruoyi.system.mapper.PosStoreSelfDeliveryHoursMapper;
+import com.ruoyi.system.service.IPosStoreService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * 商家自配送服务(029):配置主体解析、下单时段判定、配置保存。
+ * 配置主体规则:普通门店=自身行;夜市场景配置存于夜市主自己的门店行(isNightMarket=1),
+ * 摊位经 nightMarketId 解析读取——保证同一夜市统一模式(research D1/F3)。
+ */
+@Service
+public class SelfDeliveryService {
+
+    /** HH:mm 定长格式(配置校验用,保证字符串比较等价时间比较) */
+    private static final String TIME_PATTERN = "^([01]\\d|2[0-3]):[0-5]\\d$";
+
+    @Autowired
+    private PosStoreSelfDeliveryHoursMapper hoursMapper;
+    @Autowired
+    private IPosStoreService posStoreService;
+
+    /**
+     * 配置主体解析:摊位(isStall=1 且 nightMarketId 非空)返回夜市主门店行,其余返回自身。
+     * 夜市主门店行缺失返回 null——视为未配置,判定自然回落骑手池(research D1)。
+     */
+    public PosStore resolveConfigStore(PosStore store) {
+        if (store == null) {
+            return null;
+        }
+        boolean stall = Integer.valueOf(1).equals(store.getIsStall());
+        if (!stall || store.getNightMarketId() == null) {
+            return store;
+        }
+        // 夜市主门店行:归属夜市主(userId=nightMarketId)且 isNightMarket=1
+        return posStoreService.getOne(new LambdaQueryWrapper<PosStore>()
+                .eq(PosStore::getUserId, store.getNightMarketId())
+                .eq(PosStore::getIsNightMarket, 1)
+                .last("LIMIT 1"));
+    }
+
+    /**
+     * 判定基准时刻(029):预约单(delryTime 非空)按预约送达时间段、取其开始时刻,
+     * 即时单按当前时间。delryTime 格式为"yyyy-MM-dd HH:mm-HH:mm"(预约时间段,见 DateUtil.isNearOrPastStartTime);
+     * 解析失败按即时单处理(历史脏数据兜底,不阻断下单)。
+     */
+    public LocalDateTime resolveJudgeTime(String delryTime) {
+        if (delryTime == null || delryTime.isBlank()) {
+            return LocalDateTime.now();
+        }
+        try {
+            String[] parts = delryTime.trim().split(" ");
+            String[] range = parts[1].split("-");
+            return LocalDateTime.parse(parts[0].trim() + "T" + range[0].trim());
+        } catch (Exception e) {
+            return LocalDateTime.now();
+        }
+    }
+
+    /**
+     * 下单判定:订单门店是否命中自配送时段;结果由调用方固化到订单快照列 self_delivery。
+     *
+     * @param target 判定基准时刻:即时单=下单时刻,预约单=预约送达时间(spec 决策②)
+     */
+    public boolean isSelfDelivery(PosStore store, LocalDateTime target) {
+        PosStore configStore = resolveConfigStore(store);
+        if (configStore == null) {
+            return false;
+        }
+        List<PosStoreSelfDeliveryHours> hours = listHours(Long.valueOf(configStore.getId()));
+        return SelfDeliveryUtil.matches(configStore.getSelfDeliveryEnabled(),
+                configStore.getSelfDeliveryMode(), hours, target);
+    }
+
+    /** 读取配置主体门店的自定义时段列表(按星期与开始时刻排序) */
+    public List<PosStoreSelfDeliveryHours> listHours(Long mdId) {
+        return hoursMapper.selectList(new LambdaQueryWrapper<PosStoreSelfDeliveryHours>()
+                .eq(PosStoreSelfDeliveryHours::getMdId, mdId)
+                .orderByAsc(PosStoreSelfDeliveryHours::getDayOfWeek)
+                .orderByAsc(PosStoreSelfDeliveryHours::getStartTime));
+    }
+
+    /**
+     * 保存配置:整体覆盖式(先删后插,与营业时段保存一致)。
+     * 仅处理配置主体门店;摊位拦截与归属校验在 Controller 完成(contracts/api.md §1)。
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public void saveConfig(SelfDeliveryConfigInput input) {
+        validate(input);
+        int enabledFlag = Boolean.TRUE.equals(input.getEnabled()) ? 1 : 0;
+        Integer mode = enabledFlag == 1 ? input.getMode() : null;
+        // 关闭时需把 mode 显式置空:updateById 会忽略 null 字段,必须用 UpdateWrapper set;
+        // 用字符串列名(Lambda 列缓存在纯单测中不可用,仓库 QueryWrapper 亦有字符串列名先例)
+        posStoreService.update(new UpdateWrapper<PosStore>()
+                .eq("id", input.getMdId())
+                .set("self_delivery_enabled", enabledFlag)
+                .set("self_delivery_mode", mode));
+        hoursMapper.delete(new LambdaQueryWrapper<PosStoreSelfDeliveryHours>()
+                .eq(PosStoreSelfDeliveryHours::getMdId, Long.valueOf(input.getMdId())));
+        if (enabledFlag == 1 && input.getMode() == SelfDeliveryUtil.MODE_CUSTOM) {
+            for (SelfDeliveryHourInput h : input.getHours()) {
+                PosStoreSelfDeliveryHours row = new PosStoreSelfDeliveryHours();
+                row.setMdId(Long.valueOf(input.getMdId()));
+                row.setDayOfWeek(h.getDayOfWeek());
+                row.setStartTime(h.getStartTime());
+                row.setEndTime(h.getEndTime());
+                hoursMapper.insert(row);
+            }
+        }
+    }
+
+    private void validate(SelfDeliveryConfigInput input) {
+        if (input == null || input.getMdId() == null || input.getEnabled() == null) {
+            throw invalid();
+        }
+        if (Boolean.TRUE.equals(input.getEnabled())) {
+            Integer mode = input.getMode();
+            boolean modeOk = mode != null
+                    && (mode == SelfDeliveryUtil.MODE_ALL_DAY || mode == SelfDeliveryUtil.MODE_CUSTOM);
+            if (!modeOk) {
+                throw invalid();
+            }
+            if (mode == SelfDeliveryUtil.MODE_CUSTOM) {
+                List<SelfDeliveryHourInput> hours = input.getHours();
+                if (hours == null || hours.isEmpty() || hours.stream().anyMatch(h -> !hourValid(h))
+                        || hasOverlap(hours)) {
+                    throw invalid();
+                }
+            }
+        }
+    }
+
+    /**
+     * 同一星期内时段重叠校验(spec Edge Cases:重叠时段易误配)。
+     * 相邻衔接(前段结束=后段开始)不算重叠。
+     */
+    private static boolean hasOverlap(List<SelfDeliveryHourInput> hours) {
+        Map<Integer, List<SelfDeliveryHourInput>> byDay = hours.stream()
+                .collect(Collectors.groupingBy(SelfDeliveryHourInput::getDayOfWeek));
+        for (List<SelfDeliveryHourInput> day : byDay.values()) {
+            day.sort(Comparator.comparing(SelfDeliveryHourInput::getStartTime));
+            for (int i = 1; i < day.size(); i++) {
+                if (day.get(i).getStartTime().compareTo(day.get(i - 1).getEndTime()) < 0) {
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
+
+    private static boolean hourValid(SelfDeliveryHourInput h) {
+        return h != null && h.getDayOfWeek() != null && h.getDayOfWeek() >= 1 && h.getDayOfWeek() <= 7
+                && h.getStartTime() != null && h.getStartTime().matches(TIME_PATTERN)
+                && h.getEndTime() != null && h.getEndTime().matches(TIME_PATTERN)
+                // 结束必须晚于开始(定长 HH:mm 字符串比较),不支持跨天时段
+                && h.getEndTime().compareTo(h.getStartTime()) > 0;
+    }
+
+    private ServiceException invalid() {
+        return new ServiceException(MessageUtils.message("no.store.selfdelivery.hours.invalid"));
+    }
+}

+ 6 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/UserOrderController.java

@@ -89,6 +89,8 @@ public class UserOrderController extends BaseController {
     private DeliveryOrderNotificationService deliveryOrderNotificationService;
     @Autowired
     private MerchantNotificationRouter merchantNotificationRouter;
+    @Autowired  //029 商家自配送:配置解析与下单判定
+    private SelfDeliveryService selfDeliveryService;
 
     /**
      * 申请电子发票(订单完成后,客户主动申请:B2C 邮箱 / B2B 统编 / 载具)
@@ -293,6 +295,10 @@ public class UserOrderController extends BaseController {
            PosStore store = storeOptional.get();
             posOrder.setLongitude(store.getLongitude());
             posOrder.setLatitude(store.getLatitude());
+            // 029 商家自配送:外送单按门店时段判定并固化快照(即时=下单时刻;预约按 delryTime 时间段开始时刻)
+            posOrder.setSelfDelivery(Long.valueOf(0L).equals(item.getType())
+                    && selfDeliveryService.isSelfDelivery(store,
+                    selfDeliveryService.resolveJudgeTime(item.getDelryTime())) ? 1 : 0);
             //设置订单分类
             storeList.stream().filter(s -> Long.valueOf(s.getId()).equals(item.getMdId())).findFirst().ifPresent(st -> {
                 posOrder.setOrderCategory(String.valueOf(st.getType()));

+ 15 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/SelfDeliveryOperateInput.java

@@ -0,0 +1,15 @@
+package com.ruoyi.app.order.dto;
+
+import lombok.Data;
+
+/**
+ * 商家自配送操作入参(029:开始配送/确认送达)。
+ * 仅承载请求数据;校验在 Controller(项目 Controller 参数规范:DTO 无校验注解)。
+ */
+@Data
+public class SelfDeliveryOperateInput {
+    /** 订单 id(必填) */
+    private Long id;
+    /** 送达凭证图(可选,确认送达时使用) */
+    private String qsImg;
+}

+ 60 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/utils/SelfDeliveryUtil.java

@@ -0,0 +1,60 @@
+package com.ruoyi.app.utils;
+
+import com.ruoyi.system.domain.PosStoreSelfDeliveryHours;
+
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.List;
+
+/**
+ * 商家自配送时段判定工具(029):纯函数,供下单判定使用。
+ * 区间语义为含开始时刻、不含结束时刻 [start, end)(spec 2026-09-08 确认);
+ * 与 DateUtil.isLegalTime(含首含尾)语义不同,禁止混用。
+ */
+public class SelfDeliveryUtil {
+
+    /** 时段模式:全天 */
+    public static final int MODE_ALL_DAY = 1;
+    /** 时段模式:自定义按星期 */
+    public static final int MODE_CUSTOM = 2;
+
+    private static final DateTimeFormatter HHMM = DateTimeFormatter.ofPattern("HH:mm");
+
+    /**
+     * 判定目标时刻是否命中门店自配送时段。
+     *
+     * @param enabled 门店自配送开关(1=开启)
+     * @param mode    时段模式:1=全天,2=自定义按星期
+     * @param hours   自定义时段列表(仅 mode=2 使用)
+     * @param target  判定基准时刻:即时单=下单时刻,预约单=预约送达时间
+     */
+    public static boolean matches(Integer enabled, Integer mode, List<PosStoreSelfDeliveryHours> hours,
+                                  LocalDateTime target) {
+        if (target == null || enabled == null || enabled != 1) {
+            return false;
+        }
+        if (mode == null) {
+            return false;
+        }
+        if (mode == MODE_ALL_DAY) {
+            return true;
+        }
+        if (mode != MODE_CUSTOM || hours == null || hours.isEmpty()) {
+            return false;
+        }
+        int dayOfWeek = target.getDayOfWeek().getValue(); // ISO:1=周一..7=周日
+        String hhmm = target.format(HHMM);
+        for (PosStoreSelfDeliveryHours h : hours) {
+            if (h.getDayOfWeek() == null || h.getStartTime() == null || h.getEndTime() == null) {
+                continue;
+            }
+            // 配置校验保证 HH:mm 定长格式,字符串比较等价于时间比较
+            if (h.getDayOfWeek() == dayOfWeek
+                    && h.getStartTime().compareTo(hhmm) <= 0
+                    && hhmm.compareTo(h.getEndTime()) < 0) {
+                return true;
+            }
+        }
+        return false;
+    }
+}

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

@@ -335,3 +335,14 @@ flash.delivery.coordinates.invalid=Latitude and longitude must be provided in pa
 address.access.denied=Address does not exist or is not accessible
 address.data.invalid=Contact, address, or coordinates are invalid
 flash.delivery.rider.type.not.enabled=当前账号未开通闪送配送
+no.store.stall.selfdelivery.readonly=Phương thức giao hàng của quầy do chủ chợ đêm thiết lập thống nhất
+no.store.selfdelivery.hours.invalid=Cấu hình khung giờ tự giao không hợp lệ
+no.order.selfdelivery.required=Đơn hàng này không phải là đơn hàng do cửa hàng tự giao
+no.order.selfdelivery.rider.denied=Đơn hàng này do cửa hàng tự giao, không cần tài xế
+no.order.selfdelivery.status.invalid=Trạng thái đơn hàng không cho phép thao tác này
+no.message.push.merchant.delivery.start=Cửa hàng đã bắt đầu giao đơn hàng của bạn
+no.billing.selfdelivery.freight=Phí giao hàng tự giao của cửa hàng
+no.message.push.merchant.delivery.complete=Cửa hàng đã xác nhận giao đơn hàng của bạn
+no.order.selfdelivery.unpaid=Đơn hàng chưa thanh toán, vui lòng xác nhận đã thu tiền trước khi xác nhận giao hàng
+no.message.push.merchant.accepted.content=Cửa hàng đã nhận đơn và đang chuẩn bị món của bạn
+no.order.not.found=Không tìm thấy đơn hàng

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

@@ -338,3 +338,14 @@ flash.delivery.coordinates.invalid=Latitude and longitude must be provided in pa
 address.access.denied=Address does not exist or is not accessible
 address.data.invalid=Contact, address, or coordinates are invalid
 flash.delivery.rider.type.not.enabled=This account is not enabled for flash delivery orders
+no.store.stall.selfdelivery.readonly=Stall delivery mode is set by the night market owner
+no.store.selfdelivery.hours.invalid=Invalid self-delivery time slot configuration
+no.order.selfdelivery.required=This order is not a merchant self-delivery order
+no.order.selfdelivery.rider.denied=This order is delivered by the merchant; no rider is needed
+no.order.selfdelivery.status.invalid=Order status does not allow this operation
+no.message.push.merchant.delivery.start=The merchant has started delivering your order
+no.billing.selfdelivery.freight=Merchant self-delivery freight
+no.message.push.merchant.delivery.complete=The merchant has confirmed delivery of your order
+no.order.selfdelivery.unpaid=The order is unpaid; confirm payment receipt before completing delivery
+no.message.push.merchant.accepted.content=The merchant has accepted your order and is preparing it
+no.order.not.found=Order not found

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

@@ -338,3 +338,14 @@ flash.delivery.coordinates.invalid=Vĩ độ và kinh độ phải được cung
 address.access.denied=Địa chỉ không tồn tại hoặc không có quyền truy cập
 address.data.invalid=Thông tin liên hệ, địa chỉ hoặc tọa độ không hợp lệ
 flash.delivery.rider.type.not.enabled=Tài khoản này chưa mở giao hàng nhanh
+no.store.stall.selfdelivery.readonly=Phương thức giao hàng của quầy do chủ chợ đêm thiết lập thống nhất
+no.store.selfdelivery.hours.invalid=Cấu hình khung giờ tự giao không hợp lệ
+no.order.selfdelivery.required=Đơn hàng này không phải là đơn hàng do cửa hàng tự giao
+no.order.selfdelivery.rider.denied=Đơn hàng này do cửa hàng tự giao, không cần tài xế
+no.order.selfdelivery.status.invalid=Trạng thái đơn hàng không cho phép thao tác này
+no.message.push.merchant.delivery.start=Cửa hàng đã bắt đầu giao đơn hàng của bạn
+no.billing.selfdelivery.freight=Phí giao hàng tự giao của cửa hàng
+no.message.push.merchant.delivery.complete=Cửa hàng đã xác nhận giao đơn hàng của bạn
+no.order.selfdelivery.unpaid=Đơn hàng chưa thanh toán, vui lòng xác nhận đã thu tiền trước khi xác nhận giao hàng
+no.message.push.merchant.accepted.content=Cửa hàng đã nhận đơn và đang chuẩn bị món của bạn
+no.order.not.found=Không tìm thấy đơn hàng

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

@@ -339,3 +339,14 @@ flash.delivery.coordinates.invalid=经纬度必须成对提供且范围有效
 address.access.denied=地址不存在或无权访问
 address.data.invalid=联系人、地址或经纬度信息无效
 flash.delivery.rider.type.not.enabled=当前账号未开通闪送配送
+no.store.stall.selfdelivery.readonly=摊位配送方式由夜市主统一设置
+no.store.selfdelivery.hours.invalid=自配送时段配置无效
+no.order.selfdelivery.required=该订单不是商家自配送订单
+no.order.selfdelivery.rider.denied=该订单为商家自配送订单,无需骑手配送
+no.order.selfdelivery.status.invalid=订单状态不允许该操作
+no.message.push.merchant.delivery.start=商家已开始配送您的订单
+no.billing.selfdelivery.freight=商家自配送运费
+no.message.push.merchant.delivery.complete=商家已确认送达您的订单
+no.order.selfdelivery.unpaid=订单未支付,请先确认收款后再确认送达
+no.message.push.merchant.accepted.content=商家已接单,正在为您备餐
+no.order.not.found=订单不存在

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

@@ -339,3 +339,14 @@ flash.delivery.coordinates.invalid=經緯度必須成對提供且範圍有效
 address.access.denied=地址不存在或無權存取
 address.data.invalid=聯絡人、地址或經緯度資訊無效
 flash.delivery.rider.type.not.enabled=當前賬號未開通闁送配送
+no.store.stall.selfdelivery.readonly=攤位配送方式由夜市主統一設定
+no.store.selfdelivery.hours.invalid=自配送時段配置無效
+no.order.selfdelivery.required=該訂單不是商家自配送訂單
+no.order.selfdelivery.rider.denied=該訂單為商家自配送訂單,無需騎手配送
+no.order.selfdelivery.status.invalid=訂單狀態不允許該操作
+no.message.push.merchant.delivery.start=商家已開始配送您的訂單
+no.billing.selfdelivery.freight=商家自配送運費
+no.message.push.merchant.delivery.complete=商家已確認送達您的訂單
+no.order.selfdelivery.unpaid=訂單未支付,請先確認收款後再確認送達
+no.message.push.merchant.accepted.content=商家已接單,正在為您備餐
+no.order.not.found=訂單不存在

+ 155 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/mendian/PosStoreControllerSelfDeliveryTest.java

@@ -0,0 +1,155 @@
+package com.ruoyi.app.mendian;
+
+import com.ruoyi.app.mendian.dto.SelfDeliveryConfigInput;
+import com.ruoyi.app.order.SelfDeliveryService;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.PosStore;
+import com.ruoyi.system.service.IInfoUserService;
+import com.ruoyi.system.service.IPosStoreService;
+import com.ruoyi.system.service.MerchantStoreAccessService;
+import com.ruoyi.system.utils.AuthContext;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.ArrayList;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 029 配置接口权限(US2/T018/T019):摊位无配置权、夜市主门店行归属校验、摊位读取夜市配置。
+ */
+class PosStoreControllerSelfDeliveryTest {
+
+    private MockedStatic<MessageUtils> messages;
+    private MockedStatic<AuthContext> authContext;
+    private IPosStoreService stores;
+    private IInfoUserService users;
+    private SelfDeliveryService selfDelivery;
+    private MerchantStoreAccessService access;
+    private PosStoreController controller;
+
+    @BeforeEach
+    void setUp() {
+        messages = mockStatic(MessageUtils.class);
+        messages.when(() -> MessageUtils.message(anyString()))
+                .thenAnswer(invocation -> invocation.getArgument(0));
+        authContext = mockStatic(AuthContext.class);
+        stores = mock(IPosStoreService.class);
+        users = mock(IInfoUserService.class);
+        selfDelivery = mock(SelfDeliveryService.class);
+        access = mock(MerchantStoreAccessService.class);
+        controller = new PosStoreController();
+        ReflectionTestUtils.setField(controller, "posStoreService", stores);
+        ReflectionTestUtils.setField(controller, "infoUserService", users);
+        ReflectionTestUtils.setField(controller, "selfDeliveryService", selfDelivery);
+        ReflectionTestUtils.setField(controller, "merchantStoreAccessService", access);
+    }
+
+    @AfterEach
+    void tearDown() {
+        authContext.close();
+        messages.close();
+    }
+
+    private PosStore store(Integer id, Integer isStall, Integer isNightMarket, Long userId) {
+        PosStore s = new PosStore();
+        s.setId(id);
+        s.setIsStall(isStall);
+        s.setIsNightMarket(isNightMarket);
+        s.setUserId(userId);
+        return s;
+    }
+
+    private void login(Long userId, String userType) {
+        authContext.when(AuthContext::requireUserId).thenReturn(userId);
+        InfoUser user = new InfoUser();
+        user.setUserId(userId);
+        user.setUserType(userType);
+        when(users.selectInfoUserByUserId(userId)).thenReturn(user);
+    }
+
+    @Test
+    void stallStoreCannotSaveSelfDeliveryConfig() {
+        login(501L, "4");
+        when(stores.getById(500)).thenReturn(store(500, 1, 0, 101L));
+        assertThrows(ServiceException.class,
+                () -> controller.saveMdSelfDeliveryHours("t", config(500)));
+        verify(selfDelivery, never()).saveConfig(any());
+    }
+
+    @Test
+    void marketStoreSaveRestrictedToNightMarketOwner() {
+        // 夜市主门店行:仅夜市主本人(userId=77)可保存
+        when(stores.getById(1)).thenReturn(store(1, 0, 1, 77L));
+        login(999L, "3");
+        assertThrows(ServiceException.class,
+                () -> controller.saveMdSelfDeliveryHours("t", config(1)));
+        login(77L, "3");
+        controller.saveMdSelfDeliveryHours("t", config(1));
+        verify(selfDelivery).saveConfig(any());
+    }
+
+    @Test
+    void normalStoreSaveGoesThroughStoreAccess() {
+        when(stores.getById(9)).thenReturn(store(9, 0, 0, 101L));
+        login(101L, "1");
+        controller.saveMdSelfDeliveryHours("t", config(9));
+        verify(access).requireStoreAccess(101L, 9L);
+        verify(selfDelivery).saveConfig(any());
+    }
+
+    @Test
+    void getResolvesStallToNightMarketConfig() {
+        // 摊位 500 → 夜市主门店行 1 的配置(前端只读展示)
+        PosStore stall = store(500, 1, 0, 101L);
+        stall.setNightMarketId(77L);
+        PosStore market = store(1, 0, 1, 77L);
+        market.setSelfDeliveryEnabled(1);
+        market.setSelfDeliveryMode(2);
+        when(stores.getById(500)).thenReturn(stall);
+        when(selfDelivery.resolveConfigStore(stall)).thenReturn(market);
+        when(selfDelivery.listHours(1L)).thenReturn(new ArrayList<>());
+
+        com.ruoyi.common.core.domain.AjaxResult result = controller.getMdSelfDeliveryHours("t", 500);
+        Map<String, Object> data = (Map<String, Object>) result.get("data");
+        assertEquals(Boolean.TRUE, data.get("enabled"));
+        assertEquals(2, data.get("mode"));
+        assertEquals(1, data.get("configMdId"));
+    }
+
+    @Test
+    void getReturnsDisabledWhenConfigStoreMissing() {
+        PosStore stall = store(500, 1, 0, 101L);
+        stall.setNightMarketId(77L);
+        when(stores.getById(500)).thenReturn(stall);
+        when(selfDelivery.resolveConfigStore(stall)).thenReturn(null);
+
+        com.ruoyi.common.core.domain.AjaxResult result = controller.getMdSelfDeliveryHours("t", 500);
+        Map<String, Object> data = (Map<String, Object>) result.get("data");
+        assertEquals(Boolean.FALSE, data.get("enabled"));
+        assertTrue(((java.util.List<?>) data.get("hours")).isEmpty());
+    }
+
+    private SelfDeliveryConfigInput config(Integer mdId) {
+        SelfDeliveryConfigInput input = new SelfDeliveryConfigInput();
+        input.setMdId(mdId);
+        input.setEnabled(true);
+        input.setMode(1);
+        return input;
+    }
+}

+ 96 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/OrderServiceSelfDeliveryBillingTest.java

@@ -0,0 +1,96 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.domain.UserBilling;
+import com.ruoyi.system.service.IUserBillingService;
+import com.ruoyi.app.service.WalletService;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.MockedStatic;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.math.BigDecimal;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 029 US5/T029:自配送运费入账——全额(不抽成)入 sh_id、dedup、零运费跳过。
+ */
+class OrderServiceSelfDeliveryBillingTest {
+
+    private MockedStatic<MessageUtils> messages;
+    private IUserBillingService billingService;
+    private WalletService walletService;
+    private OrderService orderService;
+
+    @BeforeEach
+    void setUp() {
+        messages = mockStatic(MessageUtils.class);
+        messages.when(() -> MessageUtils.message(anyString()))
+                .thenAnswer(invocation -> invocation.getArgument(0));
+        billingService = mock(IUserBillingService.class);
+        walletService = mock(WalletService.class);
+        orderService = new OrderService();
+        ReflectionTestUtils.setField(orderService, "userBillingService", billingService);
+        ReflectionTestUtils.setField(orderService, "walletService", walletService);
+    }
+
+    @AfterEach
+    void tearDown() {
+        messages.close();
+    }
+
+    private PosOrder order(Double freight) {
+        PosOrder order = new PosOrder();
+        order.setId(10L);
+        order.setDdId("DD-SD");
+        order.setShId(101L);
+        order.setMdId(9L);
+        order.setUserId(7L);
+        order.setType(0L);
+        order.setSelfDelivery(1);
+        order.setFreight(freight);
+        return order;
+    }
+
+    @Test
+    void freightFullAmountCreditedToShIdWithoutCommission() {
+        when(billingService.count(any())).thenReturn(0L);
+        orderService.setSelfDeliveryFreightBilling(order(30.0));
+
+        ArgumentCaptor<UserBilling> captor = ArgumentCaptor.forClass(UserBilling.class);
+        verify(walletService).addBalance(eq(101L), eq(BigDecimal.valueOf(30.0)), captor.capture());
+        UserBilling billing = captor.getValue();
+        assertEquals("5", billing.getType());
+        assertEquals(30.0, billing.getAmount());
+        assertEquals(0.0, billing.getDivvy());
+        assertEquals("1", billing.getUserType());
+        assertEquals("DD-SD", billing.getDdId());
+    }
+
+    @Test
+    void duplicateBillingSkipped() {
+        when(billingService.count(any())).thenReturn(1L);
+        orderService.setSelfDeliveryFreightBilling(order(30.0));
+        verify(walletService, never()).addBalance(anyLong(), any(BigDecimal.class), any(UserBilling.class));
+    }
+
+    @Test
+    void zeroOrMissingFreightSkipped() {
+        orderService.setSelfDeliveryFreightBilling(order(null));
+        orderService.setSelfDeliveryFreightBilling(order(0.0));
+        verify(walletService, never()).addBalance(anyLong(), any(BigDecimal.class), any(UserBilling.class));
+    }
+}

+ 79 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/PosOrderQsOprateControllerTest.java

@@ -5,6 +5,9 @@ import com.baomidou.mybatisplus.core.conditions.Wrapper;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.ruoyi.app.service.UserService;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.system.domain.InfoUser;
 import com.ruoyi.system.domain.PosOrder;
 import com.ruoyi.system.service.IInfoUserService;
 import com.ruoyi.system.service.IPosOrderService;
@@ -23,11 +26,13 @@ import java.math.BigDecimal;
 import java.util.Collections;
 
 import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
@@ -55,6 +60,13 @@ class PosOrderQsOprateControllerTest {
         ReflectionTestUtils.setField(controller, "posOrderService", orders);
         ReflectionTestUtils.setField(controller, "posStoreService", mock(IPosStoreService.class));
         ReflectionTestUtils.setField(controller, "infoUserService", mock(IInfoUserService.class));
+        // 025 后 newTask 分支走 requireRider(userService 校验骑手身份),补齐 mock
+        UserService userService = mock(UserService.class);
+        ReflectionTestUtils.setField(controller, "userService", userService);
+        InfoUser rider = new InfoUser();
+        rider.setUserId(88L);
+        rider.setUserType("2");
+        when(userService.checkUserStatus(88L)).thenReturn(rider);
         Page<PosOrder> emptyPage = new Page<>(1, 10);
         emptyPage.setRecords(Collections.emptyList());
         when(orders.page(any(Page.class), any(Wrapper.class))).thenReturn(emptyPage);
@@ -68,7 +80,74 @@ class PosOrderQsOprateControllerTest {
         String sql = wrapper.getSqlSegment().toLowerCase();
         assertTrue(sql.contains("state"));
         assertTrue(sql.contains("pay_status"));
+        // 029 自配送单不进骑手抢单池
+        assertTrue(sql.contains("self_delivery"));
+        assertTrue(wrapper.getParamNameValuePairs().containsValue(0));
         assertFalse(wrapper.getParamNameValuePairs().containsValue(2L));
         assertTrue(wrapper.getParamNameValuePairs().containsValue(1L));
     }
+
+    @Test
+    void acceptOrderRejectsSelfDeliveryOrder() {
+        PosOrderQsOprateController controller = new PosOrderQsOprateController();
+        UserService userService = mock(UserService.class);
+        IInfoUserService users = mock(IInfoUserService.class);
+        IPosOrderService orders = mock(IPosOrderService.class);
+        ReflectionTestUtils.setField(controller, "userService", userService);
+        ReflectionTestUtils.setField(controller, "infoUserService", users);
+        ReflectionTestUtils.setField(controller, "posOrderService", orders);
+
+        InfoUser rider = new InfoUser();
+        rider.setUserId(88L);
+        rider.setUserType("2");
+        when(userService.checkUserStatus(88L)).thenReturn(rider);
+
+        PosOrder order = new PosOrder();
+        order.setId(10L);
+        order.setDdId("DD-SD");
+        order.setType(0L);
+        order.setState(0L);
+        order.setPayStatus(1L);
+        order.setSelfDelivery(1);
+        when(orders.getOne(any())).thenReturn(order);
+
+        // 029:骑手直调接单接口也必须被拒绝(防绕过,镜像 027 模式)
+        assertThrows(ServiceException.class,
+                () -> controller.acceptOrder(JwtUtil.token("88", "rider"), 10L));
+        verify(orders, never()).saveOrUpdate(any(PosOrder.class));
+    }
+
+    @Test
+    void pickupAndDeliverAlsoRejectSelfDeliveryOrder() {
+        PosOrderQsOprateController controller = new PosOrderQsOprateController();
+        UserService userService = mock(UserService.class);
+        IInfoUserService users = mock(IInfoUserService.class);
+        IPosOrderService orders = mock(IPosOrderService.class);
+        ReflectionTestUtils.setField(controller, "userService", userService);
+        ReflectionTestUtils.setField(controller, "infoUserService", users);
+        ReflectionTestUtils.setField(controller, "posOrderService", orders);
+
+        InfoUser rider = new InfoUser();
+        rider.setUserId(88L);
+        rider.setUserType("2");
+        when(userService.checkUserStatus(88L)).thenReturn(rider);
+        when(users.getById(any())).thenReturn(null);
+
+        PosOrder order = new PosOrder();
+        order.setId(10L);
+        order.setDdId("DD-SD");
+        order.setType(0L);
+        order.setState(2L);
+        order.setPayStatus(1L);
+        order.setSelfDelivery(1);
+        when(orders.getOne(any())).thenReturn(order);
+
+        com.ruoyi.app.order.dto.PickupOrderInput input = new com.ruoyi.app.order.dto.PickupOrderInput();
+        input.setId(10L);
+        assertThrows(ServiceException.class,
+                () -> controller.pickupOrder(JwtUtil.token("88", "rider"), input));
+        assertThrows(ServiceException.class,
+                () -> controller.deliverOrder(JwtUtil.token("88", "rider"), input));
+        verify(orders, never()).saveOrUpdate(any(PosOrder.class));
+    }
 }

+ 218 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/PosOrderShOprateControllerTest.java

@@ -2,17 +2,20 @@ package com.ruoyi.app.order;
 
 import com.ruoyi.app.order.dto.OrderCreatItem;
 import com.ruoyi.app.order.dto.OrderCreateInput;
+import com.ruoyi.app.order.dto.SelfDeliveryOperateInput;
 import com.ruoyi.app.pay.LinePayOrderGuard;
 import com.ruoyi.app.pay.LinePayRefundService;
 import com.ruoyi.common.exception.ServiceException;
 import com.ruoyi.common.utils.MessageUtils;
 import com.ruoyi.system.domain.InfoUser;
 import com.ruoyi.system.domain.OrderParent;
+import com.ruoyi.system.domain.UserBilling;
 import com.ruoyi.system.domain.PosOrder;
 import com.ruoyi.system.domain.PosStore;
 import com.ruoyi.system.service.IInfoUserService;
 import com.ruoyi.system.service.IOperatingHoursService;
 import com.ruoyi.system.service.IOrderParentService;
+import com.ruoyi.system.service.IUserBillingService;
 import com.ruoyi.system.service.IPosOrderService;
 import com.ruoyi.system.service.IPosStoreService;
 import com.ruoyi.system.service.MerchantAccessContext;
@@ -25,13 +28,18 @@ import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.mockito.MockedStatic;
 import org.mockito.ArgumentCaptor;
+import org.redisson.api.RLock;
+import org.redisson.api.RedissonClient;
 import org.springframework.test.util.ReflectionTestUtils;
 
+import java.util.concurrent.TimeUnit;
+
 import java.util.List;
 import java.util.Set;
 
 import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
 import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.anyLong;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.Mockito.mockStatic;
 import static org.mockito.ArgumentMatchers.any;
@@ -319,4 +327,214 @@ class PosOrderShOprateControllerTest {
         authContext.when(AuthContext::requireUserId).thenReturn(userId);
         ReflectionTestUtils.setField(controller, "merchantStoreAccessService", access);
     }
+
+    // ===== 029 商家自配送 =====
+
+    private void wireSelfDeliveryLock(PosOrderShOprateController controller) {
+        RedissonClient redisson = mock(RedissonClient.class);
+        RLock lock = mock(RLock.class);
+        when(redisson.getLock(anyString())).thenReturn(lock);
+        try {
+            org.mockito.Mockito.when(lock.tryLock(anyLong(), anyLong(), any(TimeUnit.class))).thenReturn(true);
+        } catch (InterruptedException ignored) {
+        }
+        ReflectionTestUtils.setField(controller, "redissonClient", redisson);
+    }
+
+    private PosOrder selfDeliveryOrder(Long state, Long deliveryStatus) {
+        PosOrder order = new PosOrder();
+        order.setId(10L);
+        order.setDdId("DD-SD");
+        order.setType(0L);
+        order.setState(state);
+        order.setDeliveryStatus(deliveryStatus);
+        order.setPayStatus(1L);
+        order.setAfterSaleStatus(0L);
+        order.setSelfDelivery(1);
+        order.setShId(101L);
+        order.setUserId(7L);
+        return order;
+    }
+
+    private static SelfDeliveryOperateInput operateInput(Long id) {
+        SelfDeliveryOperateInput input = new SelfDeliveryOperateInput();
+        input.setId(id);
+        return input;
+    }
+
+    @Test
+    void selfDeliveryCompleteOfCodOrderAttributesPaymentToMerchant() {
+        // T031:到付自配送单送达后,用户到付账单(type=3)归因到配送方 shId
+        PosOrderShOprateController controller = new PosOrderShOprateController();
+        IInfoUserService users = mock(IInfoUserService.class);
+        IPosOrderService orders = mock(IPosOrderService.class);
+        com.ruoyi.app.order.OrderService orderService = mock(com.ruoyi.app.order.OrderService.class);
+        IUserBillingService userBillingService = mock(IUserBillingService.class);
+        ReflectionTestUtils.setField(controller, "infoUserService", users);
+        ReflectionTestUtils.setField(controller, "posOrderService", orders);
+        ReflectionTestUtils.setField(controller, "orderService", orderService);
+        ReflectionTestUtils.setField(controller, "userBillingService", userBillingService);
+        ReflectionTestUtils.setField(controller, "linePayOrderGuard", mock(LinePayOrderGuard.class));
+        ReflectionTestUtils.setField(controller, "orderLogHelper", mock(OrderLogHelper.class));
+        wireMerchantSecurity(controller, 101L);
+        wireSelfDeliveryLock(controller);
+        when(users.getOne(any())).thenReturn(user(101L, "1", null));
+        when(users.getById(7L)).thenReturn(null);
+        when(orders.update(any(PosOrder.class), any())).thenReturn(true);
+
+        PosOrder codOrder = selfDeliveryOrder(2L, 0L);
+        codOrder.setCollectPayment("1");
+        when(orders.getById(10L)).thenReturn(codOrder);
+
+        UserBilling bill = new UserBilling();
+        when(userBillingService.getOne(any())).thenReturn(bill);
+
+        controller.selfDeliveryComplete(JwtUtil.token("101", "merchant"), operateInput(10L));
+
+        // 商品分成 + 自配送运费入账随送达触发
+        verify(orderService).setSanghuBilling(codOrder);
+        verify(orderService).setSelfDeliveryFreightBilling(codOrder);
+        // 到付账单归因:paymentId=配送方(101)、state=0、balancePay=1
+        ArgumentCaptor<UserBilling> captor = ArgumentCaptor.forClass(UserBilling.class);
+        verify(userBillingService).saveOrUpdate(captor.capture());
+        org.junit.jupiter.api.Assertions.assertEquals("101", captor.getValue().getPaymentId());
+        org.junit.jupiter.api.Assertions.assertEquals("0", captor.getValue().getState());
+        org.junit.jupiter.api.Assertions.assertEquals("1", captor.getValue().getBalancePay());
+    }
+
+    @Test
+    void selfDeliveryCompleteRejectsUnpaidOrder() {
+        // 核验修复:商家POS现金/转账外送单 payStatus=0,未确认收款不得确认送达(对齐骑手送达语义)
+        PosOrderShOprateController controller = new PosOrderShOprateController();
+        IInfoUserService users = mock(IInfoUserService.class);
+        IPosOrderService orders = mock(IPosOrderService.class);
+        com.ruoyi.app.order.OrderService orderService = mock(com.ruoyi.app.order.OrderService.class);
+        ReflectionTestUtils.setField(controller, "infoUserService", users);
+        ReflectionTestUtils.setField(controller, "posOrderService", orders);
+        ReflectionTestUtils.setField(controller, "orderService", orderService);
+        ReflectionTestUtils.setField(controller, "linePayOrderGuard", mock(LinePayOrderGuard.class));
+        ReflectionTestUtils.setField(controller, "orderLogHelper", mock(OrderLogHelper.class));
+        wireMerchantSecurity(controller, 101L);
+        wireSelfDeliveryLock(controller);
+        when(users.getOne(any())).thenReturn(user(101L, "1", null));
+
+        PosOrder unpaid = selfDeliveryOrder(2L, 0L);
+        unpaid.setPayStatus(0L);
+        when(orders.getById(10L)).thenReturn(unpaid);
+        assertThrows(ServiceException.class, () -> controller.selfDeliveryComplete(
+                JwtUtil.token("101", "merchant"), operateInput(10L)));
+        verify(orders, never()).update(any(PosOrder.class), any());
+        verify(orderService, never()).setSelfDeliveryFreightBilling(any());
+    }
+
+    @Test
+    void selfDeliveryOrderCanBeAcceptedAndDispatchedWithoutRider() {
+        PosOrderShOprateController controller = new PosOrderShOprateController();
+        IInfoUserService users = mock(IInfoUserService.class);
+        IPosOrderService orders = mock(IPosOrderService.class);
+        ReflectionTestUtils.setField(controller, "infoUserService", users);
+        ReflectionTestUtils.setField(controller, "posOrderService", orders);
+        ReflectionTestUtils.setField(controller, "linePayOrderGuard", mock(LinePayOrderGuard.class));
+        ReflectionTestUtils.setField(controller, "orderLogHelper", mock(OrderLogHelper.class));
+        ReflectionTestUtils.setField(controller, "orderInvoiceService", mock(OrderInvoiceService.class));
+        wireMerchantSecurity(controller, 101L);
+        when(users.getOne(any())).thenReturn(user(101L, "1", null));
+        when(users.list(org.mockito.ArgumentMatchers
+                .<com.baomidou.mybatisplus.core.conditions.Wrapper<InfoUser>>any())).thenReturn(List.of());
+
+        // 接单:现状会被 requireRiderAssigned 拦截,selfDelivery=1 放行
+        when(orders.getOne(any())).thenReturn(selfDeliveryOrder(0L, 0L));
+        controller.acceptOrder(JwtUtil.token("101", "merchant"), 10L);
+        ArgumentCaptor<PosOrder> acceptCaptor = ArgumentCaptor.forClass(PosOrder.class);
+        verify(orders).saveOrUpdate(acceptCaptor.capture());
+        org.junit.jupiter.api.Assertions.assertEquals(1L, acceptCaptor.getValue().getState());
+
+        // 出餐
+        when(orders.getOne(any())).thenReturn(selfDeliveryOrder(1L, 0L));
+        controller.dispatchOrder(JwtUtil.token("101", "merchant"), 10L);
+        ArgumentCaptor<PosOrder> dispatchCaptor = ArgumentCaptor.forClass(PosOrder.class);
+        verify(orders, org.mockito.Mockito.times(2)).saveOrUpdate(dispatchCaptor.capture());
+        org.junit.jupiter.api.Assertions.assertEquals(2L, dispatchCaptor.getValue().getState());
+    }
+
+    @Test
+    void selfDeliveryStartRejectsInvalidStates() {
+        PosOrderShOprateController controller = new PosOrderShOprateController();
+        IInfoUserService users = mock(IInfoUserService.class);
+        IPosOrderService orders = mock(IPosOrderService.class);
+        ReflectionTestUtils.setField(controller, "infoUserService", users);
+        ReflectionTestUtils.setField(controller, "posOrderService", orders);
+        ReflectionTestUtils.setField(controller, "linePayOrderGuard", mock(LinePayOrderGuard.class));
+        ReflectionTestUtils.setField(controller, "orderLogHelper", mock(OrderLogHelper.class));
+        ReflectionTestUtils.setField(controller, "orderInvoiceService", mock(OrderInvoiceService.class));
+        wireMerchantSecurity(controller, 101L);
+        wireSelfDeliveryLock(controller);
+        when(users.getOne(any())).thenReturn(user(101L, "1", null));
+
+        // 未出餐(state=1)不可开始配送
+        when(orders.getById(10L)).thenReturn(selfDeliveryOrder(1L, 0L));
+        assertThrows(ServiceException.class, () -> controller.selfDeliveryStart(
+                JwtUtil.token("101", "merchant"), operateInput(10L)));
+
+        // 已开始配送(deliveryStatus=2)重复开始被拒
+        when(orders.getById(10L)).thenReturn(selfDeliveryOrder(2L, 2L));
+        assertThrows(ServiceException.class, () -> controller.selfDeliveryStart(
+                JwtUtil.token("101", "merchant"), operateInput(10L)));
+        verify(orders, never()).update(any(PosOrder.class), any());
+    }
+
+    @Test
+    void selfDeliveryCompleteMovesOrderToDelivered() {
+        PosOrderShOprateController controller = new PosOrderShOprateController();
+        IInfoUserService users = mock(IInfoUserService.class);
+        IPosOrderService orders = mock(IPosOrderService.class);
+        ReflectionTestUtils.setField(controller, "infoUserService", users);
+        ReflectionTestUtils.setField(controller, "posOrderService", orders);
+        ReflectionTestUtils.setField(controller, "linePayOrderGuard", mock(LinePayOrderGuard.class));
+        ReflectionTestUtils.setField(controller, "orderLogHelper", mock(OrderLogHelper.class));
+        ReflectionTestUtils.setField(controller, "orderService", mock(com.ruoyi.app.order.OrderService.class));
+        wireMerchantSecurity(controller, 101L);
+        wireSelfDeliveryLock(controller);
+        when(users.getOne(any())).thenReturn(user(101L, "1", null));
+        // 送达推送:用户不存在则跳过(不触发 AsyncManager)
+        when(users.getById(7L)).thenReturn(null);
+        when(orders.update(any(PosOrder.class), any())).thenReturn(true);
+
+        // 允许跳过开始配送:出餐后(deliveryStatus=0)直接确认送达
+        when(orders.getById(10L)).thenReturn(selfDeliveryOrder(2L, 0L));
+        controller.selfDeliveryComplete(JwtUtil.token("101", "merchant"), operateInput(10L));
+        ArgumentCaptor<PosOrder> captor = ArgumentCaptor.forClass(PosOrder.class);
+        verify(orders).update(captor.capture(), any());
+        org.junit.jupiter.api.Assertions.assertEquals(3L, captor.getValue().getState());
+        org.junit.jupiter.api.Assertions.assertEquals(3L, captor.getValue().getDeliveryStatus());
+        org.junit.jupiter.api.Assertions.assertNotNull(captor.getValue().getSdTime());
+
+        // 从配送中(deliveryStatus=2)确认送达同样合法
+        when(orders.getById(10L)).thenReturn(selfDeliveryOrder(2L, 2L));
+        controller.selfDeliveryComplete(JwtUtil.token("101", "merchant"), operateInput(10L));
+        verify(orders, org.mockito.Mockito.times(2)).update(any(PosOrder.class), any());
+    }
+
+    @Test
+    void selfDeliveryOperateRejectsNonSelfDeliveryOrder() {
+        PosOrderShOprateController controller = new PosOrderShOprateController();
+        IInfoUserService users = mock(IInfoUserService.class);
+        IPosOrderService orders = mock(IPosOrderService.class);
+        ReflectionTestUtils.setField(controller, "infoUserService", users);
+        ReflectionTestUtils.setField(controller, "posOrderService", orders);
+        ReflectionTestUtils.setField(controller, "linePayOrderGuard", mock(LinePayOrderGuard.class));
+        ReflectionTestUtils.setField(controller, "orderLogHelper", mock(OrderLogHelper.class));
+        wireMerchantSecurity(controller, 101L);
+        wireSelfDeliveryLock(controller);
+        when(users.getOne(any())).thenReturn(user(101L, "1", null));
+
+        // 普通骑手单(selfDelivery=0)不能走自配送操作
+        PosOrder riderOrder = selfDeliveryOrder(2L, 0L);
+        riderOrder.setSelfDelivery(0);
+        riderOrder.setQsId(88L);
+        when(orders.getById(10L)).thenReturn(riderOrder);
+        assertThrows(ServiceException.class, () -> controller.selfDeliveryComplete(
+                JwtUtil.token("101", "merchant"), operateInput(10L)));
+        verify(orders, never()).update(any(PosOrder.class), any());
+    }
 }

+ 46 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/SelfDeliveryOrderExposureTest.java

@@ -0,0 +1,46 @@
+package com.ruoyi.app.order;
+
+import com.alibaba.fastjson.JSON;
+import com.ruoyi.system.domain.PosOrder;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 029 US3:订单字段透出与推送文案接线。
+ * 字段透出依赖 PosOrder 全字段序列化(research F16),推送在异步线程执行无法静态 mock 拦截,
+ * 故文案/状态映射抽为同步纯函数在此直接验证;"骑手零推送"由 qsId 结构性保证
+ * (chuCan 仅在 qsId 非空时推骑手,而骑手接单入口已拒绝自配送单,见 PosOrderQsOprateControllerTest)。
+ */
+class SelfDeliveryOrderExposureTest {
+
+    @Test
+    void orderJsonExposesSelfDeliveryField() {
+        // T023:订单接口普遍走 JSON.toJSONStringWithDateFormat(order) 全字段序列化
+        PosOrder order = new PosOrder();
+        order.setId(10L);
+        order.setDdId("DD-SD");
+        order.setSelfDelivery(1);
+        String json = JSON.toJSONStringWithDateFormat(order, "yyyy-MM-dd HH:mm:ss");
+        assertTrue(json.contains("\"selfDelivery\":1"), json);
+
+        // 下单时显式写 0/1(fastjson 默认不序列化 null,纯 new 出来的 null 字段不会出现)
+        PosOrder legacy = new PosOrder();
+        legacy.setId(11L);
+        legacy.setSelfDelivery(0);
+        String legacyJson = JSON.toJSONStringWithDateFormat(legacy, "yyyy-MM-dd HH:mm:ss");
+        assertTrue(legacyJson.contains("\"selfDelivery\":0"), legacyJson);
+    }
+
+    @Test
+    void pushContentAndStateDistinguishComplete() {
+        // T024:开始配送/确认送达文案与透传状态映射
+        assertEquals("no.message.push.merchant.delivery.start",
+                PosOrderShOprateController.selfDeliveryPushContent(false));
+        assertEquals("no.message.push.merchant.delivery.complete",
+                PosOrderShOprateController.selfDeliveryPushContent(true));
+        assertEquals("2", PosOrderShOprateController.selfDeliveryPushState(false));
+        assertEquals("3", PosOrderShOprateController.selfDeliveryPushState(true));
+    }
+}

+ 243 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/SelfDeliveryServiceTest.java

@@ -0,0 +1,243 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.app.mendian.dto.SelfDeliveryConfigInput;
+import com.ruoyi.app.mendian.dto.SelfDeliveryConfigInput.SelfDeliveryHourInput;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.PosStore;
+import com.ruoyi.system.domain.PosStoreSelfDeliveryHours;
+import com.ruoyi.system.mapper.PosStoreSelfDeliveryHoursMapper;
+import com.ruoyi.system.service.IPosStoreService;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.time.LocalDateTime;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 029 商家自配送服务:配置主体解析、判定、保存校验(tasks T007/T018/T025/T018 用例汇合处)。
+ */
+class SelfDeliveryServiceTest {
+
+    private MockedStatic<MessageUtils> messages;
+    private PosStoreSelfDeliveryHoursMapper hoursMapper;
+    private IPosStoreService posStoreService;
+    private SelfDeliveryService service;
+
+    @BeforeEach
+    void setUp() {
+        messages = mockStatic(MessageUtils.class);
+        messages.when(() -> MessageUtils.message(anyString()))
+                .thenAnswer(invocation -> invocation.getArgument(0));
+        hoursMapper = mock(PosStoreSelfDeliveryHoursMapper.class);
+        posStoreService = mock(IPosStoreService.class);
+        service = new SelfDeliveryService();
+        ReflectionTestUtils.setField(service, "hoursMapper", hoursMapper);
+        ReflectionTestUtils.setField(service, "posStoreService", posStoreService);
+    }
+
+    @AfterEach
+    void tearDown() {
+        messages.close();
+    }
+
+    private PosStore store(int id, Integer isStall, Long nightMarketId) {
+        PosStore s = new PosStore();
+        s.setId(id);
+        s.setIsStall(isStall);
+        s.setNightMarketId(nightMarketId);
+        return s;
+    }
+
+    // ===== 配置主体解析 =====
+
+    @Test
+    void normalStoreResolvesToItself() {
+        PosStore s = store(9, 0, null);
+        assertSame(s, service.resolveConfigStore(s));
+    }
+
+    @Test
+    void stallResolvesToNightMarketOwnerStore() {
+        PosStore market = store(1, 0, null);
+        when(posStoreService.getOne(any())).thenReturn(market);
+        PosStore resolved = service.resolveConfigStore(store(500, 1, 77L));
+        assertSame(market, resolved);
+    }
+
+    @Test
+    void stallWithoutNightMarketOrMissingMarketStoreResolvesNullOrSelf() {
+        // 无 nightMarketId 的摊位(数据异常)按自身处理:自身无配置即回落骑手池
+        PosStore s = store(500, 1, null);
+        assertSame(s, service.resolveConfigStore(s));
+        // 夜市主门店行缺失 → null(视为未配置)
+        when(posStoreService.getOne(any())).thenReturn(null);
+        assertNull(service.resolveConfigStore(store(500, 1, 77L)));
+    }
+
+    // ===== 下单判定 =====
+
+    @Test
+    void isSelfDeliveryCustomHit() {
+        PosStore s = store(9, 0, null);
+        s.setSelfDeliveryEnabled(1);
+        s.setSelfDeliveryMode(2);
+        PosStoreSelfDeliveryHours h = new PosStoreSelfDeliveryHours();
+        h.setDayOfWeek(5); // 2026-09-04 周五
+        h.setStartTime("18:00");
+        h.setEndTime("21:00");
+        when(hoursMapper.selectList(any())).thenReturn(Collections.singletonList(h));
+        assertTrue(service.isSelfDelivery(s, LocalDateTime.of(2026, 9, 4, 19, 0)));
+        assertFalse(service.isSelfDelivery(s, LocalDateTime.of(2026, 9, 4, 21, 0)));
+    }
+
+    @Test
+    void isSelfDeliveryDisabledOrMissingConfigFalse() {
+        PosStore disabled = store(9, 0, null);
+        disabled.setSelfDeliveryEnabled(0);
+        when(hoursMapper.selectList(any())).thenReturn(Collections.emptyList());
+        assertFalse(service.isSelfDelivery(disabled, LocalDateTime.of(2026, 9, 4, 19, 0)));
+        assertFalse(service.isSelfDelivery(null, LocalDateTime.now()));
+        // 摊位解析不到夜市主门店行 → 未配置
+        when(posStoreService.getOne(any())).thenReturn(null);
+        assertFalse(service.isSelfDelivery(store(500, 1, 77L), LocalDateTime.now()));
+    }
+
+    // ===== 保存校验 =====
+
+    @Test
+    void saveConfigRejectsInvalidInput() {
+        assertThrows(ServiceException.class, () -> service.saveConfig(null));
+        assertThrows(ServiceException.class, () -> service.saveConfig(config(9, null, null, null)));
+        assertThrows(ServiceException.class, () -> service.saveConfig(config(9, true, null, null)));
+        assertThrows(ServiceException.class, () -> service.saveConfig(config(9, true, 3, null)));
+        // 自定义模式时段为空
+        assertThrows(ServiceException.class, () -> service.saveConfig(config(9, true, 2, Collections.emptyList())));
+        // 结束不晚于开始
+        assertThrows(ServiceException.class,
+                () -> service.saveConfig(config(9, true, 2, Collections.singletonList(hour(5, "21:00", "18:00")))));
+        // 非 HH:mm 格式
+        assertThrows(ServiceException.class,
+                () -> service.saveConfig(config(9, true, 2, Collections.singletonList(hour(5, "9:00", "21:00")))));
+        // 星期越界
+        assertThrows(ServiceException.class,
+                () -> service.saveConfig(config(9, true, 2, Collections.singletonList(hour(8, "18:00", "21:00")))));
+        verify(posStoreService, never()).update(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class));
+    }
+
+    @Test
+    void saveConfigCustomPersistsOverwriteStyle() {
+        List<SelfDeliveryHourInput> hours = Arrays.asList(hour(5, "18:00", "21:00"), hour(6, "17:00", "22:00"));
+        service.saveConfig(config(9, true, 2, hours));
+        verify(posStoreService).update(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class));
+        verify(hoursMapper).delete(any());
+        verify(hoursMapper, times(2)).insert(any(PosStoreSelfDeliveryHours.class));
+    }
+
+    @Test
+    void saveConfigAllDaySkipsHoursAndDisableClearsHours() {
+        // 全天模式不写时段行
+        service.saveConfig(config(9, true, 1, null));
+        verify(hoursMapper, never()).insert(any(PosStoreSelfDeliveryHours.class));
+        // 关闭时清空时段且不插入
+        service.saveConfig(config(9, false, null, null));
+        verify(posStoreService, times(2)).update(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class));
+        verify(hoursMapper, times(2)).delete(any());
+        verify(hoursMapper, never()).insert(any(PosStoreSelfDeliveryHours.class));
+    }
+
+    @Test
+    void stallsOfSameNightMarketJudgeConsistently() {
+        // T021:同一夜市下两个摊位解析到同一夜市主门店行,同时段判定结果必须一致
+        PosStore market = store(1, 0, null);
+        market.setSelfDeliveryEnabled(1);
+        market.setSelfDeliveryMode(2);
+        when(posStoreService.getOne(any())).thenReturn(market);
+        PosStoreSelfDeliveryHours h = new PosStoreSelfDeliveryHours();
+        h.setDayOfWeek(5);
+        h.setStartTime("18:00");
+        h.setEndTime("21:00");
+        when(hoursMapper.selectList(any())).thenReturn(java.util.Collections.singletonList(h));
+        PosStore stallA = store(500, 1, 77L);
+        PosStore stallB = store(501, 1, 77L);
+        java.time.LocalDateTime target = java.time.LocalDateTime.of(2026, 9, 4, 19, 0);
+        assertTrue(service.isSelfDelivery(stallA, target));
+        assertTrue(service.isSelfDelivery(stallB, target));
+        assertFalse(service.isSelfDelivery(stallA, target.plusHours(3)));
+    }
+
+    @Test
+    void appointmentOrderJudgedByDeliveryTimeRange() {
+        // T025:预约单按预约时间段开始时刻判定(delryTime 格式 yyyy-MM-dd HH:mm-HH:mm)
+        PosStore s = store(9, 0, null);
+        s.setSelfDeliveryEnabled(1);
+        s.setSelfDeliveryMode(2);
+        PosStoreSelfDeliveryHours h = new PosStoreSelfDeliveryHours();
+        h.setDayOfWeek(5); // 2026-09-04 周五
+        h.setStartTime("18:00");
+        h.setEndTime("21:00");
+        when(hoursMapper.selectList(any())).thenReturn(Collections.singletonList(h));
+
+        // 15:00 下单预约 19:00-20:00(周五时段内)→ 自配送
+        assertTrue(service.isSelfDelivery(s, service.resolveJudgeTime("2026-09-04 19:00-20:00")));
+        // 预约次日 12:00-13:00(周六,按次日星期判定)→ 非自配送
+        assertFalse(service.isSelfDelivery(s, service.resolveJudgeTime("2026-09-05 12:00-13:00")));
+        // 预约周五 21:00-22:00(开始时刻=结束边界,不含)→ 非自配送
+        assertFalse(service.isSelfDelivery(s, service.resolveJudgeTime("2026-09-04 21:00-22:00")));
+
+        // 即时/脏数据回落:空与非法格式都返回当前时刻(非 null、不抛异常)
+        assertNotNull(service.resolveJudgeTime(null));
+        assertNotNull(service.resolveJudgeTime(""));
+        assertNotNull(service.resolveJudgeTime("garbage"));
+    }
+
+    @Test
+    void saveConfigRejectsOverlappingHoursButAllowsAdjacent() {
+        // 同日重叠:18:00-19:30 与 19:00-21:00 拒绝
+        assertThrows(ServiceException.class, () -> service.saveConfig(config(9, true, 2,
+                Arrays.asList(hour(5, "18:00", "19:30"), hour(5, "19:00", "21:00")))));
+        // 相邻衔接(前段结束=后段开始)允许
+        service.saveConfig(config(9, true, 2,
+                Arrays.asList(hour(5, "18:00", "19:00"), hour(5, "19:00", "21:00"))));
+        verify(hoursMapper, times(2)).insert(any(PosStoreSelfDeliveryHours.class));
+    }
+
+    private SelfDeliveryConfigInput config(Integer mdId, Boolean enabled, Integer mode,
+                                           List<SelfDeliveryHourInput> hours) {
+        SelfDeliveryConfigInput input = new SelfDeliveryConfigInput();
+        input.setMdId(mdId);
+        input.setEnabled(enabled);
+        input.setMode(mode);
+        input.setHours(hours);
+        return input;
+    }
+
+    private SelfDeliveryHourInput hour(int dayOfWeek, String start, String end) {
+        SelfDeliveryHourInput h = new SelfDeliveryHourInput();
+        h.setDayOfWeek(dayOfWeek);
+        h.setStartTime(start);
+        h.setEndTime(end);
+        return h;
+    }
+}

+ 84 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/utils/SelfDeliveryUtilTest.java

@@ -0,0 +1,84 @@
+package com.ruoyi.app.utils;
+
+import com.ruoyi.system.domain.PosStoreSelfDeliveryHours;
+import org.junit.jupiter.api.Test;
+
+import java.time.LocalDateTime;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static com.ruoyi.app.utils.SelfDeliveryUtil.MODE_ALL_DAY;
+import static com.ruoyi.app.utils.SelfDeliveryUtil.MODE_CUSTOM;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 029 商家自配送时段判定:边界/星期/多段/全天/跨天(quickstart 场景 1)。
+ * 2026-09-04 为周五(dayOfWeek=5)、2026-09-03 周四、2026-09-05 周六。
+ */
+class SelfDeliveryUtilTest {
+
+    private PosStoreSelfDeliveryHours hour(int dayOfWeek, String start, String end) {
+        PosStoreSelfDeliveryHours h = new PosStoreSelfDeliveryHours();
+        h.setDayOfWeek(dayOfWeek);
+        h.setStartTime(start);
+        h.setEndTime(end);
+        return h;
+    }
+
+    private LocalDateTime friday(int hour, int minute) {
+        return LocalDateTime.of(2026, 9, 4, hour, minute);
+    }
+
+    @Test
+    void boundaryIncludesStartExcludesEnd() {
+        List<PosStoreSelfDeliveryHours> hours = Collections.singletonList(hour(5, "18:00", "21:00"));
+        // 含开始:18:00:00 整命中
+        assertTrue(SelfDeliveryUtil.matches(1, MODE_CUSTOM, hours, friday(18, 0)), "开始时刻应命中");
+        // 不含结束:21:00:00 整不命中
+        assertFalse(SelfDeliveryUtil.matches(1, MODE_CUSTOM, hours, friday(21, 0)), "结束时刻不应命中");
+        assertTrue(SelfDeliveryUtil.matches(1, MODE_CUSTOM, hours, friday(20, 59)), "结束前一分钟应命中");
+    }
+
+    @Test
+    void customMatchesOnlyConfiguredWeekday() {
+        List<PosStoreSelfDeliveryHours> hours = Collections.singletonList(hour(5, "18:00", "21:00"));
+        assertFalse(SelfDeliveryUtil.matches(1, MODE_CUSTOM, hours, LocalDateTime.of(2026, 9, 3, 19, 0)),
+                "周四同时刻不应命中");
+        assertFalse(SelfDeliveryUtil.matches(1, MODE_CUSTOM, hours, LocalDateTime.of(2026, 9, 5, 19, 0)),
+                "周六同时刻不应命中");
+    }
+
+    @Test
+    void multipleSegmentsAnyMatchWins() {
+        List<PosStoreSelfDeliveryHours> hours = Arrays.asList(
+                hour(5, "10:00", "11:30"), hour(5, "18:00", "21:00"));
+        assertTrue(SelfDeliveryUtil.matches(1, MODE_CUSTOM, hours, friday(11, 29)), "第一段应命中");
+        assertTrue(SelfDeliveryUtil.matches(1, MODE_CUSTOM, hours, friday(19, 0)), "第二段应命中");
+        assertFalse(SelfDeliveryUtil.matches(1, MODE_CUSTOM, hours, friday(15, 0)), "两段之间不应命中");
+    }
+
+    @Test
+    void allDayModeMatchesAnyTimeWithoutHours() {
+        assertTrue(SelfDeliveryUtil.matches(1, MODE_ALL_DAY, Collections.emptyList(), friday(3, 0)),
+                "全天模式任意时刻命中");
+        assertTrue(SelfDeliveryUtil.matches(1, MODE_ALL_DAY, null, LocalDateTime.of(2026, 9, 3, 23, 59)));
+    }
+
+    @Test
+    void disabledOrInvalidModeNeverMatches() {
+        List<PosStoreSelfDeliveryHours> hours = Collections.singletonList(hour(5, "18:00", "21:00"));
+        assertFalse(SelfDeliveryUtil.matches(0, MODE_CUSTOM, hours, friday(19, 0)), "关闭不命中");
+        assertFalse(SelfDeliveryUtil.matches(null, MODE_CUSTOM, hours, friday(19, 0)), "开关空不命中");
+        assertFalse(SelfDeliveryUtil.matches(1, null, hours, friday(19, 0)), "模式空不命中");
+        assertFalse(SelfDeliveryUtil.matches(1, MODE_CUSTOM, Collections.emptyList(), friday(19, 0)),
+                "自定义模式无时段不命中");
+        assertFalse(SelfDeliveryUtil.matches(1, MODE_CUSTOM, null, friday(19, 0)), "时段列表空不命中");
+    }
+
+    @Test
+    void nullTargetNeverMatches() {
+        assertFalse(SelfDeliveryUtil.matches(1, MODE_ALL_DAY, Collections.emptyList(), null));
+    }
+}

+ 5 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/PosOrder.java

@@ -342,6 +342,11 @@ public class PosOrder {
      */
     private Long deliveryStatus;
 
+    /**
+     * 商家自配送:0=否,1=是(下单时按门店自配送时段判定的快照,不进骑手池,运费归shId)
+     */
+    private Integer selfDelivery;
+
     /**
      * 支付状态:0未支付,1已支付,2已退款
      */

+ 8 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/PosStore.java

@@ -153,6 +153,14 @@ public class PosStore
     @Excel(name = "是否免用发票", readConverterExp = "0=需开票,1=免用发票")
     private Integer invoiceExempt;
 
+    /** 自配送开关:0关闭/1开启(夜市主门店行开启=旗下摊位统一生效) */
+    @Excel(name = "自配送开关", readConverterExp = "0=关闭,1=开启")
+    private Integer selfDeliveryEnabled;
+
+    /** 自配送时段模式:1=全天,2=自定义按星期 */
+    @Excel(name = "自配送时段模式", readConverterExp = "1=全天,2=自定义按星期")
+    private Integer selfDeliveryMode;
+
     /** 用户名 */
     @Excel(name = "用户名")
     private transient String userName;

+ 41 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/PosStoreSelfDeliveryHours.java

@@ -0,0 +1,41 @@
+package com.ruoyi.system.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.ruoyi.common.annotation.Excel;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 门店自配送时段对象 pos_store_self_delivery_hours
+ * md_id 为配置主体门店:普通门店=自身;夜市场景=夜市主门店行(摊位经 nightMarketId 解析)
+ *
+ * @author ruoyi
+ * @date 2026-09-08
+ */
+@Data
+@TableName(value = "pos_store_self_delivery_hours")
+@EqualsAndHashCode(callSuper = false)
+public class PosStoreSelfDeliveryHours {
+
+    @TableId(type = IdType.AUTO)
+    /** id */
+    private Long id;
+
+    /** 配置主体门店id(夜市场景为夜市主门店行) */
+    @Excel(name = "配置主体门店id")
+    private Long mdId;
+
+    /** 星期几(1=周一,..., 6=周六,7=星期日) */
+    @Excel(name = "星期几")
+    private Integer dayOfWeek;
+
+    /** 开始时刻 HH:mm(含) */
+    @Excel(name = "开始时刻")
+    private String startTime;
+
+    /** 结束时刻 HH:mm(不含) */
+    @Excel(name = "结束时刻")
+    private String endTime;
+}

+ 13 - 0
ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosStoreSelfDeliveryHoursMapper.java

@@ -0,0 +1,13 @@
+package com.ruoyi.system.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ruoyi.system.domain.PosStoreSelfDeliveryHours;
+
+/**
+ * 门店自配送时段 Mapper:仅 BaseMapper CRUD(保存为整体覆盖式,无自定义 SQL/XML)
+ *
+ * @author ruoyi
+ * @date 2026-09-08
+ */
+public interface PosStoreSelfDeliveryHoursMapper extends BaseMapper<PosStoreSelfDeliveryHours> {
+}

+ 38 - 0
specs/029-merchant-self-delivery/checklists/requirements.md

@@ -0,0 +1,38 @@
+# Specification Quality Checklist: 商家自配送
+
+**Purpose**: Validate specification completeness and quality before proceeding to planning
+**Created**: 2026-09-08
+**Feature**: [spec.md](../spec.md)
+
+## Content Quality
+
+- [x] No implementation details (languages, frameworks, APIs)
+- [x] Focused on user value and business needs
+- [x] Written for non-technical stakeholders
+- [x] All mandatory sections completed
+
+## Requirement Completeness
+
+- [x] No [NEEDS CLARIFICATION] markers remain
+- [x] Requirements are testable and unambiguous
+- [x] Success criteria are measurable
+- [x] Success criteria are technology-agnostic (no implementation details)
+- [x] All acceptance scenarios are defined
+- [x] Edge cases are identified
+- [x] Scope is clearly bounded
+- [x] Dependencies and assumptions identified
+
+## Feature Readiness
+
+- [x] All functional requirements have clear acceptance criteria
+- [x] User scenarios cover primary flows
+- [x] Feature meets measurable outcomes defined in Success Criteria
+- [x] No implementation details leak into specification
+
+## Notes
+
+- 关键决策已由用户确认(2026-09-08):①夜市配置在夜市主、普通商家按门店级;②时段判定用下单时刻快照、预约单按预约送达时间;③运费归配送方、夜市主与摊位主线下分摊;④自配送订单不占用骑手字段语义;⑤客户端范围已确认
+- "合并订单/同一配送人多单"明确排除在范围外(另立需求)
+- 平台对自配送运费不抽成、全额归配送方(2026-09-08 clarify 确认,原假设项已解决)
+- 时段边界已确认:含开始、不含结束([开始, 结束)),2026-09-08 clarify 确认
+- 【待定】配送操作界面的端覆盖(商家端 PC / 商家手机 App)——用户暂缓决策,后续讨论清楚再定;spec 以"商家端"泛指,后端接口与判定/结算逻辑不受影响

+ 96 - 0
specs/029-merchant-self-delivery/contracts/api.md

@@ -0,0 +1,96 @@
+# API Contracts: 商家自配送 (029)
+
+**Date**: 2026-09-08 | 遵守项目 Controller 规范(@RequestHeader token / @RequestBody DTO / @RequestParam 显式标注 / 禁 Map 入参)
+
+## 1. 自配送配置读写(镜像营业时段接口对)
+
+### GET `/chanting/store/getMdSelfDeliveryHours`
+
+| 参数 | 位置 | 类型 | 说明 |
+|------|------|------|------|
+| token | Header | String | 登录 token |
+| mdId | Query | Integer | 配置主体门店 id(普通门店=自身;夜市=夜市主门店行) |
+
+**响应**:`{ enabled: boolean, mode: Integer(1=全天,2=自定义,null=未设置), hours: [{dayOfWeek, startTime, endTime}] }`
+(摊位主查询自己摊位 → 返回所辖夜市主门店的配置,便于前端展示"由夜市统一配送"提示;无配置返回 enabled=false 空结构)
+
+### POST `/chanting/store/saveMdSelfDeliveryHours`
+
+**入参 DTO** `SelfDeliveryConfigInput`:
+
+| 字段 | 类型 | 校验(Service 内,MessageUtils 报错) |
+|------|------|------|
+| mdId | Integer | 必填;权限校验见下 |
+| enabled | Boolean | 必填 |
+| mode | Integer | enabled=true 时必填,∈{1,2} |
+| hours | List<SelfDeliveryHourInput> | mode=2 时必填非空;每项 dayOfWeek∈[1,7]、startTime/endTime 匹配 HH:mm、end>start |
+
+**权限规则**:
+- 门店为摊位(`is_stall=1`)→ 拒绝(key: `no.store.stall.selfdelivery.readonly`,文案"摊位配送方式由夜市主统一设置")
+- 夜市主门店行(`is_night_market=1`)→ 仅 `store.userId == 登录用户` 可保存
+- 普通门店 → `merchantStoreAccessService.requireStoreAccess(loginUser, mdId)`(复用 022 多门店权限)
+
+**效果**:pos_store 两列更新;mode=2 时 hours 整体覆盖式重建(先删后插)。已下单订单的 self_delivery 快照不受影响。
+
+## 2. 商家配送操作(新增于 PosOrderShOprateController `/system/orderShOprate`)
+
+### POST `/selfDeliveryStart`(开始配送,可选步骤)
+
+入参 DTO `SelfDeliveryOperateInput`:`id: Long`(PosOrder id,必填)
+
+| 校验 | 失败响应 |
+|------|----------|
+| 订单存在且 `requireMerchantOrderAccess`(夜市主按 shId、商家按门店权限,复用现有方法) | `merchant.order.not.found` / 权限错误 |
+| `type==0 && self_delivery==1 && qsId==null` | `no.order.selfdelivery.required`(非自配送单) |
+| `state==2`(已出餐)且 `deliveryStatus==0` | `no.order.status.not.allow` |
+| afterSaleStatus==0 | `no.order.aftersale.block` |
+
+**效果**:deliveryStatus=2;订单日志"商家已开始配送";向用户推送配送中通知(新 key `no.message.push.merchant.delivery.start`,文案区分商家自配送)。Redisson 锁 `order:sh:selfdelivery:{id}` 防并发(镜像 QsOprate 模式)。
+
+### POST `/selfDeliveryComplete`(确认送达)
+
+入参 DTO 同上(`id` 必填,`qsImg` 可选送达凭证)
+
+校验同上,但状态要求 `state==2 && deliveryStatus∈{0,2}`(允许跳过开始配送);
+另须 `payStatus==1`(在线/到付创建即付;商家POS现金/转账外送单须先确认收款——对齐骑手送达语义与管理端"完成单必已支付"不变量,错误 key `no.order.selfdelivery.unpaid`,2026-09-08 核验修复)。
+
+**效果**(事务内):
+1. 原子置 `deliveryStatus=3 + state=3 + sdTime=now`
+2. `completeSideEffects`(复用/扩展 OrderLifecycleService L491-496 的分支):商品分成 + **自配送运费入账**(UserBilling type="5",user_id=sh_id,amount=freight 全额,divvy=0)
+3. 到付单(collectPayment=1):用户账单 type="3" 的 paymentId=sh_id
+4. 订单日志"商家已确认送达";向用户推送已送达通知(复用现有送达 key 或新 key,文案区分自配送)
+
+## 3. 下单判定(现有接口行为变化,无新端点)
+
+`UserOrderController.createOrder` 与 `PosOrderShOprateController.createOrder` 两个入口在创建每条 PosOrder 时:
+- 解析门店配置:普通门店读自身;摊位(is_stall=1 且 nightMarketId 非空)读夜市主门店行
+- 判定基准:`delryTime` 非空 → 预约时间;否则当前时间
+- `SelfDeliveryUtil.matches(...)`([start,end),ISO dayOfWeek)→ 写 `self_delivery=1/0`
+
+**响应变化**:订单对象自动携带 `selfDelivery` 字段(全字段序列化),客户端无需额外接口。
+
+## 4. 骑手侧隔离(现有接口行为变化)
+
+| 接口 | 变化 |
+|------|------|
+| `GET /system/orderQsOprate/orderList?tab=newTask` | 追加过滤 `self_delivery=0`(NULL 视为 0,兼容存量) |
+| `GET /system/orderQsOprate/acceptOrder` | 订单 `self_delivery=1` → 拒绝 `no.order.selfdelivery.rider.denied`(防直调,镜像 027 模式) |
+| `POST /system/orderQsOprate/pickupOrder` / `deliverOrder` | 同上 |
+
+## 5. i18n key 清单
+
+**后端**(`messages.properties` + en_US/vi/zh_CN/zh_TW 共 5 文件):
+`no.store.stall.selfdelivery.readonly`、`no.store.selfdelivery.hours.invalid`、`no.order.selfdelivery.required`、`no.order.selfdelivery.rider.denied`、`no.order.selfdelivery.status.invalid`、`no.order.selfdelivery.unpaid`、`no.message.push.merchant.accepted.content`(自配送单商家接单推用户,核验修复)、`no.message.push.merchant.delivery.start`、`no.billing.selfdelivery.freight`(账单说明)
+
+**商家端前端**(foodie-store `src/lang/`:zh.js/tw.js/en.js/vi.js,key 用有意义英文驼峰):
+`selfDelivery.*`(开关/模式/星期时段编辑/保存成功)、`orderList.selfDeliveryTag`(订单"自配送"标识)等,四文件 key 完全一致。
+
+## 6. 前端改动点(foodie-store,商家端 PC)
+
+| 位置 | 改动 |
+|------|------|
+| 门店信息页 | 自配送区块:开关 + 模式选择 + 星期×多时段编辑(复用营业时段编辑交互);摊位主隐藏该区块(读接口返回夜市配置时只读展示) |
+| 订单列表/详情 | `selfDelivery=1` 显示"自配送"标签;自配送单不显示"等待骑手接单"类状态 |
+| 配送操作按钮 | **独立任务(依赖 clarify Q2 暂缓决策)**:开始配送/确认送达按钮调用 §2 接口;接口先行交付 |
+
+注意:foodie-store 文件为 CRLF,编辑用 Python 脚本替换(项目规则)。

+ 96 - 0
specs/029-merchant-self-delivery/data-model.md

@@ -0,0 +1,96 @@
+# Data Model: 商家自配送 (029)
+
+**Date**: 2026-09-08 | 依据: [spec.md](spec.md) + [research.md](research.md)
+
+所有 DDL 写入 `updatesql/sql.md`(标注日期+用途),**不直接执行**(项目规则)。
+
+## 表变更
+
+### 1. pos_store(现有表,加 2 列)
+
+| 列 | 类型 | 说明 |
+|----|------|------|
+| `self_delivery_enabled` | TINYINT(1) NOT NULL DEFAULT 0 | 自配送开关(配置主体=该门店;夜市主门店行开启=整个夜市生效) |
+| `self_delivery_mode` | TINYINT(1) NULL | 时段模式:NULL/0=未设置,1=全天,2=自定义(按星期);仅 enabled=1 时有意义 |
+
+```sql
+-- 2026-09-08 029 商家自配送:门店配置
+ALTER TABLE pos_store ADD COLUMN self_delivery_enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT '自配送开关(夜市主门店行开启=旗下摊位统一生效)';
+ALTER TABLE pos_store ADD COLUMN self_delivery_mode TINYINT(1) DEFAULT NULL COMMENT '自配送时段模式:1=全天,2=自定义按星期';
+```
+
+### 2. pos_store_self_delivery_hours(新表,镜像 operating_hours)
+
+| 列 | 类型 | 说明 |
+|----|------|------|
+| `id` | BIGINT PK AUTO_INCREMENT | |
+| `md_id` | INT NOT NULL | **配置主体门店 id**(普通门店=自身;夜市=夜市主门店行 id,不是摊位 id) |
+| `day_of_week` | TINYINT NOT NULL | ISO 星期:1=周一 … 7=周日(对齐 OperatingHours 现有取值) |
+| `start_time` | VARCHAR(5) NOT NULL | HH:mm |
+| `end_time` | VARCHAR(5) NOT NULL | HH:mm,必须 > start_time(同段内不允许跨天) |
+
+约束:`UNIQUE (md_id, day_of_week, start_time)`。某天无记录 = 该天无自配送(对齐营业时段"未配即不营业"语义)。
+
+```sql
+-- 2026-09-08 029 商家自配送:时段表
+CREATE TABLE pos_store_self_delivery_hours (
+  id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
+  md_id INT NOT NULL COMMENT '配置主体门店id(夜市场景为夜市主门店行)',
+  day_of_week TINYINT NOT NULL COMMENT 'ISO星期:1=周一..7=周日',
+  start_time VARCHAR(5) NOT NULL COMMENT '开始时刻 HH:mm(含)',
+  end_time VARCHAR(5) NOT NULL COMMENT '结束时刻 HH:mm(不含)',
+  PRIMARY KEY (id),
+  UNIQUE KEY uk_sd_hours (md_id, day_of_week, start_time)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='门店自配送时段(按星期)';
+```
+
+### 3. pos_order(现有表,加 1 列)
+
+| 列 | 类型 | 说明 |
+|----|------|------|
+| `self_delivery` | TINYINT(1) NOT NULL DEFAULT 0 | 下单判定快照:1=商家自配送(不进骑手池、运费归 sh_id)。终身不变,存量单默认 0 |
+
+```sql
+-- 2026-09-08 029 商家自配送:订单判定快照
+ALTER TABLE pos_order ADD COLUMN self_delivery TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1=商家自配送(下单时快照,不进骑手池,运费归sh_id)';
+```
+
+### 4. user_billing(现有表,无 DDL,启用新 type 值)
+
+- 现有用途:type="0" 分成(商家/骑手,dedup 键 userId+type+ddId)、type="3" 到付用户账单。
+- **新增 type="5" = 自配送运费入账**(实现前 grep `setType("` 全库确认 "5" 未被 user_billing 语义占用)。
+- 字段赋值:`user_id=pos_order.sh_id`(普通商家=商家、夜市摊位单=夜市主,见 research F3)、`amount=freight 全额`、`divvy=0`、`divvy_rate=0`、`user_type="1"`、`md_id`、`dd_id`、`state="0"`、`illustrate=自配送运费(i18n)`。dedup 键沿用 (userId, type, ddId)——type 不同不与商品分成冲突。
+- 到付自配送单:送达时用户账单(type="3")`payment_id=sh_id`(镜像骑手送达的 qsId 归因,research F13)。
+
+## 实体/Mapper 变更(Java)
+
+| 层 | 文件 | 变更 |
+|----|------|------|
+| Entity | `ruoyi-system/.../domain/PosStore.java` | +selfDeliveryEnabled, +selfDeliveryMode |
+| Entity | `ruoyi-system/.../domain/PosOrder.java` | +selfDelivery |
+| Entity(新) | `ruoyi-system/.../domain/PosStoreSelfDeliveryHours.java` | 新实体(MyBatis-Plus 注解风格对齐 OperatingHours) |
+| Mapper(新) | `ruoyi-system/.../mapper/PosStoreSelfDeliveryHoursMapper.java` | MyBatis-Plus BaseMapper,无需 XML(简单 CRUD) |
+| Service(新) | `ruoyi-admin/.../order/SelfDeliveryService.java` | 配置解析(含摊位→夜市主门店解析)、时段判定入口、运费入账;仅依赖 system 实体+服务,符合模块边界 |
+| Util(新) | `ruoyi-admin/.../utils/SelfDeliveryUtil.java` | 纯函数:`matches(hours, enabled, mode, target)`,[start,end) 语义,可单测 |
+
+## 状态机(自配送外送单,type=0)
+
+```
+state:         0 待接单 ──商家接单──> 1 已接单 ──商家出餐──> 2 已出餐 ──确认送达──> 3 已完成
+deliveryStatus: 0 ────────────────────────────────(可选:开始配送──> 2 配送中)──────> 3 已送达
+```
+
+- 商家接单/出餐:`requireRiderAssigned` 对 `self_delivery=1` 放行(现状 F2 会拦)
+- "开始配送"(可选):state=2 且 deliveryStatus=0 → deliveryStatus=2;重复操作拒绝
+- "确认送达":state=2 且 deliveryStatus∈{0,2} → 原子置 deliveryStatus=3 + state=3 + sdTime=now;随后 completeSideEffects(商品分成 + 自配送运费入账 + 到付归因)
+- 满足管理端不变量(research F7):delivery=3 ⇒ state=3
+- 售后/取消:沿用现有规则,无骑手环节
+
+## 校验规则(配置保存)
+
+1. 自定义模式必须至少一段时段(否则拒绝)
+2. 每段 `end_time > start_time`(字符串比较即可,HH:mm 定长)
+3. day_of_week ∈ [1,7];start/end 匹配 `^([01]\d|2[0-3]):[0-5]\d$`
+4. 同一星期内时段不得重叠(相邻衔接允许:前段结束=后段开始不算重叠)
+5. 同 md_id + 星期 + start 唯一(数据库约束兜底)
+6. 保存为整体覆盖式(先删 md_id 全部再插入,与营业时段保存一致)

+ 70 - 0
specs/029-merchant-self-delivery/delivery.md

@@ -0,0 +1,70 @@
+# 029 商家自配送 — 交付说明
+
+**日期**: 2026-09-08 | **分支**: `test-202609v2`(按用户要求未单独开分支)
+
+## 交付状态
+
+| 部分 | 状态 |
+|------|------|
+| 后端(配置/判定/隔离/配送操作/结算/推送/i18n) | ✅ 完成,43 个新增测试全绿;全仓 455/455 全绿零回归 |
+| 数据库 DDL | ✅ 已登记 `updatesql/sql.md`(2026-09-08 029 段,共 3 条)——**需开发者手动执行后方可部署** |
+| 商家端 PC(foodie-store) | ⛔ 未做——见下「范围调整」 |
+| 客户端 App / 商家手机 App | 外部配合(清单见下) |
+
+## 上线前置清单(开发者手动执行)
+
+1. 在数据库执行 `updatesql/sql.md` 中「2026-09-08 029 商家自配送」的 3 条 DDL(pos_store 2 列、pos_store_self_delivery_hours 建表、pos_order 1 列)。**未执行前部署新包会导致 pos_order 相关查询报列不存在**。
+2. 执行后按 `quickstart.md` 场景 1-8 回归(服务启动 + 接口级冒烟)。
+3. (可选,T020 遗留)用有权限的账号跑下面的只读核验 SQL,确认夜市主门店行齐全;解析不到的夜市会自动回落骑手池(不报错,但那部分夜市自配送不生效):
+
+```sql
+SELECT DISTINCT p.night_market_id
+FROM pos_store p
+WHERE p.is_stall=1 AND p.del_flag='0' AND p.night_market_id IS NOT NULL
+  AND NOT EXISTS (SELECT 1 FROM pos_store m
+                  WHERE m.is_night_market=1 AND m.del_flag='0' AND m.user_id=p.night_market_id);
+```
+
+## 范围调整:商家端界面落点(重要)
+
+执行 T033 探索时发现:**foodie-store(PC)没有门店信息页、也没有订单列表页**(仅商品/分类/促销/对账等 17 个管理页);门店信息与订单管理界面在商家手机 App(uni-app,不在本工作区)。营业时段编辑也在 App 端。
+
+因此 spec 中 FR-001(门店信息配置界面)与 FR-010(订单列表标识)的**界面部分**归入商家 App 外部配合;后端接口(`/chanting/store/get|saveMdSelfDeliveryHours`)与订单字段已就绪,App 端接入即可。是否在 PC 端新建门店设置/订单页面,与 clarify Q2(配送操作界面端覆盖)同属一个待决策问题,建议一并讨论后追加。
+
+## 外部配合清单(客户端接入)
+
+**商家手机 App(uni-app)**:
+- 门店信息页:自配送区块——开关 + 模式(全天/自定义按星期)+ 星期×多时段编辑;调 `POST /chanting/store/saveMdSelfDeliveryHours`(DTO: mdId/enabled/mode/hours[{dayOfWeek,startTime,endTime}]),读 `GET /chanting/store/getMdSelfDeliveryHours`(返回 enabled/mode/hours/configMdId)。摊位主(isStall=1)保存会被拒(no.store.stall.selfdelivery.readonly),前端按 configMdId≠自身做只读展示"由夜市主统一配送"
+- 订单列表/详情:`selfDelivery==1` 显示"自配送"标签,不显示等待骑手接单状态
+- 配送操作(开始配送/确认送达,可与 Q2 决策合并):`POST /system/orderShOprate/selfDeliveryStart` / `selfDeliveryComplete`(DTO: {id, qsImg?});开始配送为可选步骤
+
+**客户 App(uni-app)**:
+- 订单对象已自动携带 `selfDelivery` 字段(0/1);`selfDelivery==1` 显示"商家自配送",配送状态映射:deliveryStatus 0=备餐中、2=商家配送中、3=已送达(无骑手接单环节)
+- 推送文案 key:`no.message.push.merchant.delivery.start` / `no.message.push.merchant.delivery.complete`(5 个 properties 已含四语言)
+
+## 实现要点(代码导航)
+
+- 判定:`SelfDeliveryUtil.matches`([开始,结束)、ISO 星期)+ `SelfDeliveryService.resolveJudgeTime`(即时=下单时刻;预约按 delryTime 时间段**开始时刻**,格式 `yyyy-MM-dd HH:mm-HH:mm`,解析失败回落即时)
+- 快照:两个下单入口(UserOrderController/PosOrderShOprateController 的 createOrderChild)写 `pos_order.self_delivery`,终身不变
+- 隔离:骑手 newTask 池过滤 + accept/pickup/deliver 直调拒绝(no.order.selfdelivery.rider.denied)
+- 放行:`requireRiderAssigned` 对 self_delivery=1 跳过骑手校验
+- 操作:`selfDeliveryStart`(state=2 且 deliveryStatus=0→2)/ `selfDeliveryComplete`(state=2 且 deliveryStatus∈{0,2}→原子置 3+state=3+sdTime),Redisson 锁 + CAS
+- 结算:`OrderService.setSelfDeliveryFreightBilling`(UserBilling type="5"、user_id=sh_id、全额、divvy=0);挂在商家送达与 `OrderLifecycleService.completeSideEffects`(管理端路径)两处;到付 type="3" 账单 paymentId=sh_id
+- 快照不回滚:判定只在创建时发生,配置后续变更不影响已创建订单(结构性保证)
+
+## 核验与修复记录(2026-09-08 子代理核验)
+
+核验结论:FR-001~FR-012 逐条落实(FR-007/010 界面部分归客户端仓库);发现并已修复:
+1. 【中】确认送达未校验支付状态——商家POS现金/转账外送单(payStatus=0)曾可被直接送达完成并生成分成账单;已加 payStatus==1 前置(no.order.selfdelivery.unpaid),对齐骑手送达语义
+2. 【中】FR-009"商家接单"节点推送缺失——自配送单已补:商家接单后推用户(no.message.push.merchant.accepted.content)
+3. 【低】时段重叠校验、pickup/deliver 直调拒绝测试、两处过期注释、既有缺口 key no.order.not.found(5 个 properties 补齐)
+
+已知隐患(本期不改,记录备查):平台管理端 `GET /system/order/list` 走 XML 显式列清单不含 self_delivery 列——管理端界面按 D10 不在本期范围,未来管理端需要展示自配送标识时须同步 PosOrderMapper.xml 的 selectPosOrderVo。
+
+修复后全量回归:458/458 全绿。
+
+## 遗留决策
+
+1. **clarify Q2**(配送操作界面端覆盖)——用户暂缓;后端接口先行,PC/App 界面待决策
+2. **PC 端是否新建门店设置/订单页**——随 Q2 一并讨论
+3. T040 运行时冒烟待 DDL 执行后进行(quickstart 场景 1-8)

+ 110 - 0
specs/029-merchant-self-delivery/plan.md

@@ -0,0 +1,110 @@
+# Implementation Plan: 商家自配送
+
+**Branch**: `test-202609v2`(按用户要求不单独开分支) | **Date**: 2026-09-08 | **Spec**: [spec.md](spec.md)
+
+**Input**: Feature specification from `/specs/029-merchant-self-delivery/spec.md`
+
+## Summary
+
+商家在门店信息开启自配送(全天/按星期自定义时段);下单时按快照判定(即时单按下单时刻、预约单按预约送达时间,区间 [开始,结束)),命中的外送单标记 `self_delivery=1`、不进骑手抢单池;商家接单/出餐后自行配送,可选"开始配送"、必点"确认送达"完成订单;运费全额(不抽成)入账 `user_id=sh_id`(普通商家=商家、夜市摊位单=夜市主),到付归因同步;夜市由夜市主在夜市主门店行统一配置、对旗下摊位生效;用户端与商家端显示"商家自配送/自配送"标识(四语言)。合并订单/同一配送人多单机制明确排除(另立需求)。
+
+技术方案:`pos_store` 2 列 + 新表 `pos_store_self_delivery_hours`(镜像营业时段模式)+ `pos_order` 1 列快照;新增 SelfDeliveryUtil(时段判定,不复用边界语义不符的 isLegalTime)与 SelfDeliveryService(配置解析/入账);配送操作端点加在 PosOrderShOprateController;结算分支挂 OrderLifecycleService.completeSideEffects;骑手池过滤 + 三操作直调拒绝。详见 [research.md](research.md)、[data-model.md](data-model.md)、[contracts/api.md](contracts/api.md)。
+
+## Technical Context
+
+**Language/Version**: Java 21(Spring Boot + MyBatis-Plus);前端 Vue2 + Element UI(foodie-store)
+
+**Primary Dependencies**: MyBatis-Plus(新表 BaseMapper)、Redisson(配送操作并发锁)、PayPush/MerchantNotificationRouter(推送)、merchantStoreAccessService(022 门店权限)
+
+**Storage**: MySQL(DDL 见 data-model.md,写入 `updatesql/sql.md` 由开发者手动执行)
+
+**Testing**: JUnit5(SelfDeliveryUtil 边界单测、SelfDeliveryService 判定/入账单测、Controller 契约测试——仓库已有 PosOrderQsOprateControllerTest/PosOrderShOprateControllerTest 先例);接口级冒烟按 quickstart.md
+
+**Target Platform**: Linux 服务器部署(Windows 开发机,JDK 用 `C:\Users\qmj\.jdks\graalvm-jdk-21.0.7` 临时设置 JAVA_HOME)
+
+**Project Type**: web-service(后端)+ 商家端管理前端(外部路径 `E:\QtwCode\foodie\foodie-store`,CRLF 文件用 Python 脚本编辑)
+
+**Performance Goals**: 无特殊要求(判定为 O(时段数) 纯内存计算;配置读取随下单批量查表)
+
+**Constraints**: 模块依赖 admin→system 不可反向;Controller 参数规范(token/DTO/@RequestParam);i18n 后端 5 个 properties + 前端 4 个语言文件;不触碰废弃代码清单
+
+**Scale/Scope**: 后端 ~8 个文件改动 + 4 个新文件;前端 1 个设置区块 + 订单标识 + i18n;1 张新表 + 3 列
+
+## Constitution Check
+
+*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
+
+`.specify/memory/constitution.md` 为未填写的占位模板,无项目级 gates。以 CLAUDE.md 项目规范替代检查:
+
+| 规范 | 状态 |
+|------|------|
+| Controller 参数规范(token/DTO/显式 @RequestParam/禁 Map) | ✅ contracts/api.md 全部 DTO 化 |
+| 模块边界 admin→system | ✅ 实体+Mapper 在 system,业务服务在 admin(data-model.md) |
+| DDL 入 updatesql/sql.md 不直接执行 | ✅ data-model.md 已给出待登记 SQL |
+| 前端 i18n 四语言、key 有意义、按对象层级插入 | ✅ contracts §5/§6;CRLF 用 Python 编辑 |
+| 废弃代码不触碰 | ✅ research.md「涉及的废弃代码」节 |
+| Java 注释禁 `*/`、主要注释规范 | ✅ 实现阶段遵守 |
+| 餐桌码先例:功能覆盖普通商家 | ✅ 配置/判定/操作均含普通商家路径 |
+
+## Project Structure
+
+### Documentation (this feature)
+
+```text
+specs/029-merchant-self-delivery/
+├── plan.md              # This file
+├── research.md          # Phase 0 output(现状事实 F1-F16 + 决策 D1-D10)
+├── data-model.md        # Phase 1 output(DDL + 状态机 + 校验)
+├── quickstart.md        # Phase 1 output(8 个验证场景)
+├── contracts/
+│   └── api.md           # Phase 1 output(接口契约 + i18n 清单)
+└── tasks.md             # Phase 2 output (/speckit-tasks - NOT created yet)
+```
+
+### Source Code (repository root)
+
+```text
+foodie_server/
+├── ruoyi-system/src/main/java/com/ruoyi/system/
+│   ├── domain/
+│   │   ├── PosStore.java                      # +selfDeliveryEnabled/selfDeliveryMode
+│   │   ├── PosOrder.java                      # +selfDelivery
+│   │   └── PosStoreSelfDeliveryHours.java     # 新实体
+│   └── mapper/
+│       └── PosStoreSelfDeliveryHoursMapper.java  # 新 BaseMapper(无 XML)
+├── ruoyi-admin/src/main/java/com/ruoyi/app/
+│   ├── utils/SelfDeliveryUtil.java            # 新:[start,end) 按星期判定
+│   ├── order/
+│   │   ├── SelfDeliveryService.java           # 新:配置解析(摊位→夜市主门店)/判定/运费入账
+│   │   ├── UserOrderController.java           # createOrderChild 判定快照
+│   │   ├── PosOrderShOprateController.java    # 商家端 createOrder 判定;requireRiderAssigned 放行;+selfDeliveryStart/Complete;chuCan 骑手推送跳过
+│   │   ├── PosOrderQsOprateController.java    # newTask 过滤;三操作直调拒绝
+│   │   ├── OrderLifecycleService.java         # completeSideEffects 增自配送运费分支
+│   │   └── OrderService.java                  # setSelfDeliveryFreightBilling(type="5")
+│   ├── mendian/PosStoreController.java        # +get/saveMdSelfDeliveryHours
+│   └── order/dto/
+│       ├── SelfDeliveryConfigInput.java       # 新(含 SelfDeliveryHourInput)
+│       └── SelfDeliveryOperateInput.java      # 新
+├── ruoyi-admin/src/main/resources/i18n/       # 5 个 properties 加 key
+├── ruoyi-admin/src/test/java/...              # SelfDeliveryUtilTest / SelfDeliveryServiceTest / 契约测试
+└── updatesql/sql.md                           # DDL 登记
+
+foodie-store/(商家端 PC,E:\QtwCode\foodie\foodie-store)
+├── src/views/...门店信息页                     # 自配送区块(复用营业时段编辑交互)
+├── src/views/...订单列表/详情                  # "自配送"标签
+└── src/lang/{zh,tw,en,vi}.js                  # selfDelivery.* keys
+```
+
+**Structure Decision**: 复用现有分层(system 实体 / admin 业务),新表走 MyBatis-Plus BaseMapper 不建 XML;前端仅 foodie-store,foodie-admin-vue 与客户端 uni-app 不在本功能改动范围(research D10、assumptions)。
+
+## Complexity Tracking
+
+> 无 Constitution Check 违规需要辩护。
+
+| Violation | Why Needed | Simpler Alternative Rejected Because |
+|-----------|------------|-------------------------------------|
+| — | — | — |
+
+## 遗留决策(不阻塞实施)
+
+- **clarify Q2(配送操作界面的端覆盖)**:后端接口与 PC 配置/标识任务先行;"PC 配送操作按钮"单列为可独立裁剪任务,等用户决策后启用(research D9)。

+ 82 - 0
specs/029-merchant-self-delivery/quickstart.md

@@ -0,0 +1,82 @@
+# Quickstart: 商家自配送 (029) 验证指南
+
+**Date**: 2026-09-08 | 前置:[data-model.md](data-model.md) 的 SQL 已由开发者手动执行、测试库可用、应用可启动(`JAVA_HOME=C:\Users\qmj\.jdks\graalvm-jdk-21.0.7`)。
+
+> 记住项目验证纪律:`mvn compile` 通过 ≠ 可部署。改完 mapper XML 需 `python -c "import xml.etree.ElementTree as ET; ET.parse(...)"` 校验;核心链路变更需启动冒烟。本功能无 mapper XML 变更(新表走 MyBatis-Plus BaseMapper),冒烟以接口级验证为准。
+
+## 场景 1:时段判定单测(不开服务即可跑)
+
+```bash
+# ruoyi-admin 下运行 SelfDeliveryUtil 测试
+mvn -pl ruoyi-admin test -Dtest=SelfDeliveryUtilTest
+```
+
+**预期全绿**,覆盖:
+- [开始,结束) 边界:18:00:00 整命中、21:00:00 整不命中
+- 按星期:周一 18:00-21:00 的配置,周二同时刻不命中
+- 多段命中:任一段命中即命中
+- 全天模式:任意时刻命中
+- 预约单跨天:预约次日 12:00 按次日的星期+时刻判定
+
+## 场景 2:配置读写与权限
+
+```bash
+# 普通商家保存(token 为商家登录 token)
+curl -X POST http://localhost:8080/chanting/store/saveMdSelfDeliveryHours \
+  -H "token: <商家token>" -H "Content-Type: application/json" \
+  -d '{"mdId":<门店id>,"enabled":true,"mode":2,"hours":[{"dayOfWeek":5,"startTime":"18:00","endTime":"21:00"}]}'
+# 读取核对
+curl "http://localhost:8080/chanting/store/getMdSelfDeliveryHours?mdId=<门店id>" -H "token: <商家token>"
+```
+
+**预期**:保存成功;读回一致。再用**摊位主 token** 对摊位 mdId 保存 → 拒绝("摊位配送方式由夜市主统一设置")。用夜市主 token 对夜市主门店行保存 → 成功,旗下摊位 getMdSelfDeliveryHours 返回夜市配置。
+
+## 场景 3:下单快照 + 骑手池隔离(spec SC-001)
+
+1. 配置门店当天时段覆盖"现在",下一笔**即时外送单**(用户端 createOrder)并支付
+2. 查库:`SELECT self_delivery FROM pos_order WHERE dd_id='...'` → 1
+3. 骑手 token 调 `GET /system/orderQsOprate/orderList?tab=newTask` → 列表**无此单**
+4. 骑手 token 直调 `GET /system/orderQsOprate/acceptOrder?id=<订单id>` → 拒绝(非自配送可接单的报错)
+5. 配置时段改为已过去的时间再下一单 → self_delivery=0 且出现在 newTask 池
+
+## 场景 4:预约单按预约时间判定
+
+时段设 18:00-21:00;下午 15:00 下单预约当天 19:00 送达(delryTime)→ self_delivery=1;预约次日 12:00 → self_delivery=0。
+
+## 场景 5:商家配送流转 + 结算(spec SC-002/SC-004)
+
+对一笔 self_delivery=1 的已支付单,用商家 token:
+
+```bash
+curl -X POST http://localhost:8080/system/orderShOprate/acceptOrder?id=<id> -H "token: <商家token>"
+curl -X POST http://localhost:8080/system/orderShOprate/dispatchOrder?id=<id> -H "token: <商家token>"   # 出餐
+curl -X POST http://localhost:8080/system/orderShOprate/selfDeliveryComplete -H "token: <商家token>" \
+  -H "Content-Type: application/json" -d '{"id":<id>}'
+```
+
+**预期**:
+- 接单/出餐不再被"等待骑手接单"拦截(现状会拦,research F2)
+- 送达后 `state=3, delivery_status=3, sd_time` 非空
+- 账单核对(SQL):
+  - `SELECT * FROM user_billing WHERE dd_id='<ddId>' AND type='5'` → 一条,user_id=sh_id、amount=freight 全额、divvy=0
+  - `type='0' AND user_id=sh_id`(商品分成)照常存在
+  - **不存在** user_id 为骑手的运费分成记录
+- 夜市摊位单重复场景:type='5' 的 user_id=夜市主 userId
+- 到付单(collectPayment=1)送达后:type='3' 用户账单 payment_id=sh_id
+- 用户收到出餐/送达推送(自配论文案),骑手无任何推送
+
+## 场景 6:开始配送为可选(clarify Q3)
+
+- 跳过 selfDeliveryStart 直接 selfDeliveryComplete → 成功,用户端状态由备餐直达已送达
+- 调用 selfDeliveryStart 后重复调用 → 拒绝(状态校验)
+- state=1(未出餐)时调 selfDeliveryComplete → 拒绝
+
+## 场景 7:商家端 PC(foodie-store)
+
+1. 门店信息页:开关/全天/自定义星期时段编辑保存,重新进入显示一致(四语言切换文案正确)
+2. 订单列表/详情:自配送单显示"自配送"标签
+3. 摊位主登录:门店信息无自配送编辑入口
+
+## 场景 8:快照不回滚(spec Edge Cases)
+
+订单已判 self_delivery=1 后,关闭门店自配送开关 → 该单仍按自配送流转(selfDeliveryComplete 可用);之后的新单 self_delivery=0 进骑手池。

+ 104 - 0
specs/029-merchant-self-delivery/research.md

@@ -0,0 +1,104 @@
+# Research: 商家自配送 (029)
+
+**Date**: 2026-09-08 | **Status**: Complete(全部 NEEDS CLARIFICATION 已解决,无遗留)
+
+代码现状核查于 2026-09-08,分支 `test-202609v2`(commit af8cc41 附近)。
+
+## 现状事实(设计依据)
+
+| # | 事实 | 出处 |
+|---|------|------|
+| F1 | 骑手为抢单模式:newTask 池过滤 `type=0 AND delivery_status=0 AND state=0 AND pay_status=1 AND after_sale_status=0 AND qs_id IS NULL` | PosOrderQsOprateController.orderList |
+| F2 | 商家接单/出餐对外送单强制 `deliveryStatus=1 AND qsId!=null`(骑手已接单才能出餐)——**自配送单必须放宽,否则商家无法流转订单** | PosOrderShOprateController.requireRiderAssigned (L495) |
+| F3 | 商家端订单可见性:userType=1/5 走 merchantStoreAccessService(022 多门店);userType=3(夜市主)按 `shId=loginUserId`;摊位主按 `mdId=user.storeId` → **摊位订单的 shId 即夜市主 userId** | PosOrderShOprateController.orderList (L615-636) |
+| F4 | 摊位订单分成对象已是夜市主(按 nightMarketId 取用户、抽其 commission),但钱包入摊位余额 addStallBalance | OrderService.setSanghuBilling (L54-114) |
+| F5 | 骑手运费分成:freight × 骑手 commission,余额入骑手钱包,含到付 behalfAmount;UserBilling type="0",dedup 键 (userId,type,ddId) | OrderService.setQishouBilling (L121-159) |
+| F6 | 送达完成副作用统一挂 `completeSideEffects`:setSanghuBilling 恒调 + `type=0 && qsId!=null` 才 setQishouBilling;管理端 018 状态机与骑手送达都走它 | OrderLifecycleService (L491-496) |
+| F7 | 管理端状态机不变量:非外送不能有配送状态;delivery=3 ⇒ state=3;完成单 payStatus=1 且无售后 | OrderLifecycleService.validateResultInvariants (L433-444) |
+| F8 | 营业时段按星期几存(OperatingHours: mdId/startTime/endTime/dayOfWeek/isOpen,HH:mm 字符串);判定工具 OperatingUtil.storeIsOperatingOfDay 用 `LocalDate.now().getDayOfWeek().getValue()`(ISO 1=周一..7=周日)+ DateUtil.isLegalTime | OperatingHours.java, OperatingUtil.java |
+| F9 | **isLegalTime 边界=含首含尾**(==start 或 ==end 返回 true)——与已确认的 [开始,结束) 语义冲突,不能直接复用 | DateUtil.isLegalTime (L134-146) |
+| F10 | 门店信息接口在 `/chanting/store`,营业时段已有读写接口对 getMdBusinessHours / saveMdBusinessHours——自配送时段镜像该模式 | PosStoreController (L407/L428) |
+| F11 | 多店结账=OrderParent(ddId) + 每门店一条 PosOrder(parentDdId 关联,子单号 ddId+001…);两处下单入口:UserOrderController.createOrder(用户端)与 PosOrderShOprateController.createOrder(商家端 POS) | UserOrderController.createOrderChild (L214-279) |
+| F12 | 骑手接单守卫禁止骑手持未完成单再接单(no.exist.undelivered.order)→ "同一骑手多单"机制现状不存在(本功能范围外,另立需求) | PosOrderQsOprateController (L188-197) |
+| F13 | 到付账单:用户 UserBilling type="3",骑手送达时 paymentId=qsId | PosOrderQsOprateController.updateUserBill (L301-314) |
+| F14 | 商家出餐推送 chuCan 给用户+骑手双推送;骑手送达推送按 deliveryStatus 1/2/3 取文案 key | PosOrderShOprateController.chuCan、QsOprate.setOrderQsState |
+| F15 | PosStore 已有 isStall/isNightMarket/nightMarketId;摊位建店时 nightMarketId=夜市主 userId | PosStore.java、StallController (L88) |
+| F16 | 订单接口普遍全字段序列化 PosOrder("新增字段无需手动添加")→ 新增订单字段自动透出给客户端 | QsOprate.orderList (L404) |
+
+## 设计决策
+
+### D1: 配置存储——pos_store 两列 + 时段子表(镜像营业时段)
+
+**Decision**: `pos_store` 加 `self_delivery_enabled`、`self_delivery_mode`(1=全天/2=自定义);新表 `pos_store_self_delivery_hours`(md_id, day_of_week, start_time, end_time),结构与 OperatingHours 对齐。配置主体:普通门店=自身行;夜市=**夜市主自己的门店行**(isNightMarket=1),摊位判定时经 `store.nightMarketId` 解析到夜市主门店行读配置。
+
+**Rationale**: 完全复用营业时段的 UI 交互、数据形态与团队心智;夜市级单一事实源,天然保证同一夜市统一模式(spec US2)。
+
+**Alternatives**: ①每摊位各存配置再同步——分裂配送风险+同步复杂,弃;②独立无门店关联的配置表——偏离现有模式,弃。
+
+**待实现验证**: 夜市主门店行存在性(isNightMarket=1 且 userId=夜市主)需在实现时用测试库数据确认;若个别夜市无门店行,判定时视为"未配置自配送"回落骑手池。
+
+### D2: 订单标记——pos_order.self_delivery 快照列
+
+**Decision**: `pos_order` 加 `self_delivery TINYINT(1) DEFAULT 0`,下单判定时写入,终身不变。
+
+**Rationale**: 独立标志字段(spec 决策④);F16 保证自动透出客户端;对存量数据默认 0 无影响。
+
+**Alternatives**: 复用 qsId=商家ID——污染骑手链路(骑手列表按 qsId 聚合、COD 归因、接单守卫均按 qsId+userType=2),弃。
+
+### D3: 时段判定——新工具方法,不复用 isLegalTime
+
+**Decision**: 新增 `SelfDeliveryUtil.matchesSelfDelivery(config, target)`:按 target 的 ISO dayOfWeek 过滤时段段,HH:mm 字符串比较,区间**含开始、不含结束 [start, end)**。判定基准时间:预约单(delryTime 非空)用预约时间,即时单用当前时间。判定时机:两个下单入口(F11)创建 PosOrder 时。
+
+**Rationale**: F9 边界语义冲突必须新写;预约单按预约时间是 spec 已确认决策②。
+
+**Alternatives**: 改 isLegalTime 全局边界——影响营业时段存量行为,风险大,弃。
+
+### D4: 状态机——跳过 deliveryStatus=1,送达原子完成
+
+**Decision**: 自配送外送单流转:state 0→1(商家接单)→2(出餐)→3(确认送达);deliveryStatus 0→(2 开始配送,**可选**)→3;"确认送达"端点原子置 deliveryStatus=3 + state=3 + sdTime。`requireRiderAssigned` 对 `self_delivery=1` 放行(F2)。
+
+**Rationale**: deliveryStatus=1 语义是"骑手已接单",自配送无此环节;delivery=3⇒state=3 恰好满足管理端不变量(F7);开始配送可选是 clarify Q3 结论。
+
+**Alternatives**: 自配送也走 deliveryStatus=1(语义撒谎,骑手列表查询会误捞),弃。
+
+### D5: 运费结算——UserBilling 新 type,user_id=sh_id 统一
+
+**Decision**: `completeSideEffects` 增分支:`type=0 && self_delivery=1 && qsId==null` → 生成自配送运费入账:UserBilling `type="5"`(实现时 grep 确认未占用)、`user_id=sh_id`、`amount=freight 全额`、`divvy=0/divvy_rate=0`、`user_type="1"`、钱包 `addBalance(sh_id)`。**不抽成**(clarify Q1)。到付单(collectPayment=1)送达时用户账单 type="3" 的 paymentId=sh_id(镜像 F13)。夜市摊位单 shId=夜市主(F3)→ 运费自然归夜市主,与摊主的分摊线下处理(spec 决策③)。
+
+**Rationale**: F3+F5 使 user_id=sh_id 对普通商家/夜市主统一成立,零分支;新 type 避开 setSanghuBilling 的 (userId,"0",ddId) dedup 冲突。
+
+**Alternatives**: 复用 type="0"——与商品分成 dedup 键冲突(互斥掉一条账单),必须弃;freight 并入商品分成金额——账目不可区分,弃。
+
+### D6: 骑手侧隔离——池过滤 + 操作直调拒绝
+
+**Decision**: newTask 池查询追加 `self_delivery=0`(含 NULL 兼容存量);QsOprate acceptOrder/pickupOrder/deliverOrder 对 `self_delivery=1` 的单抛错(镜像 027 线下转账单防直调模式)。
+
+**Rationale**: spec FR-004 接口级隔离;直调防御有先例(F1 转账单校验)。
+
+### D7: 推送——骑手链路全跳过,用户推送文案区分
+
+**Decision**: chuCan 出餐推送:self_delivery=1 时跳过骑手推送、保留用户推送;自配送单"开始配送/已送达"推送用户(新 i18n key,文案区分商家自配送);不触发任何骑手推送。
+
+**Rationale**: spec FR-009;复用 PayPush/MerchantNotificationRouter 现有通道。
+
+### D8: 配置接口——镜像营业时段接口对
+
+**Decision**: `/chanting/store/getMdSelfDeliveryHours`(GET)+ `/chanting/store/saveMdSelfDeliveryHours`(POST,DTO 承载)。权限:普通商家 merchantStoreAccessService.requireStoreAccess;夜市主要求门店行归属(store.userId=登录用户且 isNightMarket=1);**摊位门店(isStall=1)拒绝**(错误走 MessageUtils)。校验:自定义模式至少一段、每段 end>start、HH:mm 格式。
+
+**Rationale**: F10 镜像现有接口对;spec FR-002 摊位无配置权。
+
+### D9: 商家端 PC 范围——配置+标识必做,配送操作按钮独立任务
+
+**Decision**: foodie-store:门店设置自配送区块(复用营业时段星期编辑交互)+ 订单列表/详情"自配送"标识,必做。**PC 配送操作按钮(开始配送/确认送达)单列为独立任务,依赖 clarify Q2(端覆盖暂缓决策)**;后端操作接口先行交付,App/PC 界面后接入同一接口。
+
+**Rationale**: Q2 用户明确暂缓;接口与界面解耦后无论 Q2 结论如何都不返工。
+
+### D10: 平台管理端不在本功能范围
+
+**Decision**: 不改 foodie-admin-vue;订单接口全字段序列化已自动带 self_delivery(F16),管理端未来需要时直接用。
+
+**Rationale**: spec 范围声明(后端+商家端 PC);避免扩散。
+
+## 涉及的废弃代码
+
+无。本功能不触碰 CLAUDE.md 废弃清单内代码(ZaloPay/PayController/TestTask/OrderAppeal 等);结算挂在在用的 OrderLifecycleService/OrderService 链路。

+ 172 - 0
specs/029-merchant-self-delivery/spec.md

@@ -0,0 +1,172 @@
+# Feature Specification: 商家自配送
+
+**Feature Branch**: `test-202609v2`(按用户要求不单独开分支,spec 目录为 `specs/029-merchant-self-delivery/`)
+
+**Created**: 2026-09-08
+
+**Status**: Draft
+
+**Input**: User description: "商家自配送:商家在门店信息勾选自配送并设置时段(全天/自定义多时段),命中时段的外送订单不进骑手抢单池、由商家自行配送;夜市由夜市主统一配置并对旗下摊位生效、夜市主操作送达;运费归配送方结算;前端显示'商家自配送'。合并订单/同一骑手多单机制不在本次范围(另立需求)。"
+
+## Clarifications
+
+### Session 2026-09-08
+
+- Q: 自配送运费平台是否抽成? → A: 不抽成,运费全额归配送方(商家/夜市主)
+- Q: "开始配送"是否为强制步骤? → A: 可选步骤,允许出餐后直接"确认送达"(状态机允许跳过"配送中")
+- Q: 自定义时段的粒度? → A: 按星期几设置多时段(对齐现有营业时段的配置模式)
+- Q: 时段边界取值? → A: 含开始时刻、不含结束时刻([开始, 结束)):开始时刻整点下的单算自配送,结束时刻整点下的单不算
+
+## User Scenarios & Testing *(mandatory)*
+
+### User Story 1 - 普通商家开启自配送并完成配送 (Priority: P1)
+
+普通商家(自有配送人力的餐饮门店)在门店信息中勾选"自配送",选择全天自配送或自定义时间段(支持多个时间段)。开启后,命中时段的外送订单不再进入骑手抢单池,由商家自己配送:商家照常接单、出餐,出餐后标记"开始配送"、送达后"确认送达",订单完成。
+
+**Why this priority**: 这是功能的核心价值——让有自配能力的商家脱离平台骑手池,是最主要的业务场景(行业标准做法)。
+
+**Independent Test**: 商家开启全天自配送后下一笔外送订单,验证订单不进骑手池、骑手端不可见,商家端完成 开始配送→确认送达 后订单完结。
+
+**Acceptance Scenarios**:
+
+1. **Given** 商家门店已开启全天自配送,**When** 用户下外送订单并支付成功,**Then** 订单标记为"商家自配送",不出现在骑手抢单池,也不出现在骑手端任何列表
+2. **Given** 一笔已支付的自配送外送订单,**When** 商家接单并出餐,**Then** 商家可对订单标记"开始配送",用户端看到配送状态推进
+3. **Given** 商家已标记"开始配送",**When** 商家点击"确认送达",**Then** 订单进入已完成状态,用户收到送达通知
+4. **Given** 一笔已出餐的自配送订单且未标记"开始配送",**When** 商家直接点击"确认送达",**Then** 订单进入已完成状态,用户端状态由备餐中直接更新为已送达
+5. **Given** 自定义时段为 18:00-21:00,**When** 用户在 20:59 下即时外送单,**Then** 该单为自配送订单(即使 21:30 才实际送达)
+6. **Given** 自定义时段为 18:00-21:00,**When** 用户在 21:00:00 下即时外送单,**Then** 该单照常进入骑手抢单池,走现有骑手流程
+
+---
+
+### User Story 2 - 夜市主统一自配送旗下摊位订单 (Priority: P1)
+
+夜市主对整个夜市开启自配送并设置时段,配置对旗下全部摊位统一生效(摊位商家不单独配置)。夜市内摊位的外送订单由夜市主自己配送:夜市主在商家端可查看旗下所有摊位的自配送订单,出餐后由夜市主逐单执行配送操作直至送达。
+
+**Why this priority**: 这是需求提出的原始场景(夜市主自己配送多个摊位),配置粒度必须落在夜市级才能保证同一夜市订单配送方式统一、不出现分裂配送。
+
+**Independent Test**: 夜市主开启夜市自配送后,向夜市内某摊位下一笔外送订单,验证摊位商家端不出现自配送配置入口、订单不进骑手池、夜市主端可见并可完成配送操作。
+
+**Acceptance Scenarios**:
+
+1. **Given** 夜市主已开启夜市自配送(时段 17:00-23:00),**When** 用户向该夜市任一摊位下外送单(下单时间在时段内),**Then** 订单为自配送订单,不进骑手池,夜市主在商家端可看到该订单
+2. **Given** 夜市内多个摊位的自配送订单同时存在,**When** 夜市主执行配送操作,**Then** 可逐单完成"开始配送/确认送达",每笔订单独立流转至完成
+3. **Given** 摊位商家登录商家端,**When** 查看门店设置,**Then** 不提供自配送配置入口(配置权仅在夜市主)
+4. **Given** 用户一次结账同时购买同一夜市的多个摊位商品(生成多笔摊位子订单),**When** 下单时间在自配送时段内,**Then** 全部子订单统一为自配送,由夜市主配送
+
+---
+
+### User Story 3 - 用户端显示"商家自配送" (Priority: P1)
+
+用户在订单列表、订单详情和配送跟踪中看到"商家自配送"标识与对应的配送状态(商家备餐中/商家配送中/已送达),不再显示骑手接单、骑手位置等骑手侧信息。
+
+**Why this priority**: 用户预期管理是自配送上线的前提——没有标识,用户会因"无人接单"而恐慌或取消订单。
+
+**Independent Test**: 下一笔自配送订单,在订单列表、详情、跟踪页检查标识与状态文案(四种语言均正确)。
+
+**Acceptance Scenarios**:
+
+1. **Given** 一笔自配送订单已支付,**When** 用户查看订单详情/跟踪,**Then** 显示"商家自配送"标识及商家配送状态,无骑手接单相关提示
+2. **Given** 系统语言设置为越南语/繁中/英语,**When** 查看自配送订单,**Then** "商家自配送"及状态文案以对应语言显示
+
+---
+
+### User Story 4 - 预约单按预约送达时间判定 (Priority: P2)
+
+用户下单时可选择预约时间送达。预约订单以"预约送达时间"是否落在自配送时段内来判定是否自配送,而不是以下单时刻判定(例如 15:00 下的预约 19:00 送达的单,按 19:00 判定,为自配送)。
+
+**Why this priority**: 预约单是现有能力,自配送必须与之正确衔接,否则时段判定会错位;相对 P1 场景频次低,故 P2。
+
+**Independent Test**: 自配送时段 18:00-21:00 时,分别在时段外/时段内下单预约不同送达时间,验证判定跟随预约时间。
+
+**Acceptance Scenarios**:
+
+1. **Given** 自配送时段 18:00-21:00,**When** 用户 15:00 下单并预约 19:00 送达,**Then** 该单为自配送订单
+2. **Given** 自配送时段 18:00-21:00,**When** 用户 19:30 下单并预约次日 12:00 送达(时段外),**Then** 该单进入骑手抢单池,非自配送
+3. **Given** 预约送达时间落在自配送时段内,**When** 该预约单到临近时间才对商家/配送方展示(现有预约单机制),**Then** 配送方为商家,流转规则与即时自配送单一致
+
+---
+
+### User Story 5 - 自配送运费结算归配送方 (Priority: P2)
+
+自配送订单的运费结算给配送方:普通商家的自配送订单运费结算给商家;夜市摊位订单的运费结算给夜市主。用户侧运费计价不变(照常支付运费)。到付(货到付款)订单的代收金额归因同步调整为配送方。夜市主与摊位主之间如何分摊运费由双方线下自行处理,系统不做分账。
+
+**Why this priority**: 涉及资金归属,必须在上线前正确;但相对 P1 的主流程可独立验证,故 P2。
+
+**Independent Test**: 分别完成一笔普通商家自配送单和一笔夜市自配送单(含到付单),核对账单中运费入账记录的归属方与全额(无平台抽成),并确认不产生任何骑手分成记录。
+
+**Acceptance Scenarios**:
+
+1. **Given** 普通商家的一笔已送达自配送订单(运费 30 元),**When** 查看结算账单,**Then** 运费 30 元全额入账归属该商家,无平台抽成、无骑手分成记录
+2. **Given** 夜市摊位的一笔已送达自配送订单,**When** 查看结算账单,**Then** 运费全额入账归属夜市主,无平台抽成
+3. **Given** 一笔到付的自配送订单,**When** 配送方确认送达,**Then** 到付代收记录归因到配送方(商家或夜市主),不指向骑手
+
+---
+
+### User Story 6 - 商家端配置与订单标识 (Priority: P2)
+
+商家在商家端 PC 的门店信息中配置自配送(开关 + 全天/自定义时段),订单列表与详情对自配送订单显示"自配送"标识,配送操作按钮替换为商家的配送操作。
+
+**Why this priority**: 配置入口是 P1 场景的前提操作,作为独立界面能力单列验收。
+
+**Independent Test**: 在商家端门店信息页开启自配送并保存自定义时段,验证保存生效且订单列表出现标识。
+
+**Acceptance Scenarios**:
+
+1. **Given** 商家打开门店信息,**When** 勾选自配送并选择"自定义时段"、按星期几配置多个时间段后保存,**Then** 配置保存成功,重新打开显示一致
+2. **Given** 商家关闭自配送开关,**When** 之后下的外送订单,**Then** 照常进入骑手抢单池(关闭前已判定为自配送的订单不受影响)
+
+---
+
+### Edge Cases
+
+- **时段边界**:判定区间含开始时刻、不含结束时刻([开始, 结束)):时段 18:00-21:00 时,18:00:00 整下的单为自配送,21:00:00 整下的单进骑手池
+- **下单快照不回滚**:订单判定为自配送后,商家中途关闭自配送或修改时段,该订单仍按自配送完成;反之已进骑手池的订单不会因配置开启而变为自配送
+- **预约时间跨天**:预约次日送达的单按预约时间所在日的时段判定
+- **多摊位同夜市**:同一夜市的摊位统一跟随夜市配置,不存在"部分摊位自配送、部分进骑手池"的分裂状态
+- **售后/取消**:自配送订单的取消、售后沿用现有规则,仅无骑手环节(无骑手损失申诉等)
+- **骑手侧隔离**:任何接口路径(列表、详情、直调操作接口)都不能让骑手看到或操作自配送订单
+- **时段配置非法输入**:时间段为空、结束时间早于开始时间、时间段重叠时的校验与提示
+- **到付确认**:到付自配送订单的收款确认由配送方(商家/夜市主)操作
+
+## Requirements *(mandatory)*
+
+### Functional Requirements
+
+- **FR-001**: 门店信息 MUST 提供自配送开关与时段设置:全天模式或自定义模式;自定义模式 MUST 按星期几配置,每天可设置一个或多个时间段,每个时间段含开始与结束时刻(与营业时段的配置模式一致)
+- **FR-002**: 配置权限 MUST 按商家类型区分:普通商家配置自己门店的自配送;夜市摊位门店 MUST NOT 提供独立配置入口,其配送方式由所属夜市的夜市主统一配置并对该夜市全部摊位生效
+- **FR-003**: 系统 MUST 在外送订单创建并支付成功时判定配送方式并固化到订单:即时单按下单时刻判定,预约单按预约送达时间判定;时段判定含开始时刻、不含结束时刻([开始, 结束));命中自配送时段则订单永久标记为"商家自配送",判定结果 MUST NOT 受后续配置变更影响
+- **FR-004**: 自配送订单 MUST NOT 进入骑手抢单池,MUST NOT 出现在骑手端任何订单列表,骑手 MUST NOT 能通过任何操作接口接单或操作该订单
+- **FR-005**: 商家端 MUST 为自配送订单提供配送操作:出餐后可标记"开始配送"与"确认送达";"开始配送"为可选步骤,MUST 允许出餐后直接"确认送达"(状态机允许跳过"配送中");"确认送达"后订单进入完成状态并记录送达时间;操作后配送状态向用户可见
+- **FR-006**: 夜市主 MUST 能在商家端查看旗下全部摊位的自配送订单,并对其执行与商家自配送一致的配送操作(逐单操作)
+- **FR-007**: 用户端 MUST 在自配送订单的列表、详情与配送跟踪中显示"商家自配送"标识及商家配送状态,MUST NOT 显示骑手接单/骑手信息相关内容
+- **FR-008**: 自配送订单的运费 MUST 结算给配送方:普通商家门店订单结算给商家,夜市摊位订单结算给夜市主;平台 MUST NOT 对自配送运费抽成,运费全额归配送方;到付订单的代收确认与归因 MUST 指向配送方;系统 MUST NOT 为自配送订单生成骑手分成记录
+- **FR-009**: 自配送订单的关键节点(商家接单、出餐、开始配送、已送达)MUST 向用户推送通知;MUST NOT 触发任何面向骑手的接单/派单推送
+- **FR-010**: 商家端订单列表与详情 MUST 对自配送订单显示"自配送"标识
+- **FR-011**: 本功能所有新增用户可见文案 MUST 提供越南语、简体中文、繁体中文、英语四种语言
+- **FR-012**: 自配送时段配置 MUST 校验:时段非空、结束时刻晚于开始时刻;时段外的外送订单照常进入骑手抢单池,走现有骑手配送流程
+
+### Key Entities *(include if feature involves data)*
+
+- **门店自配送配置**: 开关、时段模式(全天/自定义)、按星期几的时间段列表;归属规则:普通门店归属该门店商家,摊位门店归属其所属夜市(由夜市主持有)
+- **订单配送方式**: 订单上的自配送判定标记(下单时固化),以及订单配送状态在商家自配送语境下的流转(备餐→开始配送→已送达)
+- **配送方**: 自配送订单的履约人与运费结算归属方;普通商家门店为商家本人,夜市摊位订单为夜市主
+
+## Success Criteria *(mandatory)*
+
+### Measurable Outcomes
+
+- **SC-001**: 命中自配送时段的已支付外送订单 100% 不出现在骑手抢单池与骑手端任何列表(含直调接口探测)
+- **SC-002**: 自配送订单从出餐到完结,配送方操作不超过两步(开始配送、确认送达),且每一步用户端状态即时更新
+- **SC-003**: 四种语言下,"商家自配送"标识与配送状态文案均正确显示,无硬编码单一语言文案
+- **SC-004**: 已完结自配送订单的运费 100% 全额入账归属正确配送方(商家/夜市主),无平台抽成、无骑手分成记录
+- **SC-005**: 时段判定(含时段起点/终点边界、预约单按预约时间、跨天预约)在验收用例集中判定正确率 100%
+
+## Assumptions
+
+- 自配送仅影响外送订单;自取、堂食、餐桌码扫码点单的既有流程不受影响
+- 用户侧运费计价规则不变,用户照常支付运费,仅结算归属变化
+- 平台对自配送运费不抽成,运费全额归配送方(2026-09-08 已确认)
+- 夜市主与摊位主之间的运费分摊、线下结算为线下行为,系统不做自动分账
+- "合并订单/同一配送人一趟多单"的批量合并操作机制不在本功能范围,由独立需求实现;本功能中夜市主对旗下订单逐单操作
+- 客户 App(uni-app)与骑手端的界面改造由对应客户端配合交付;本仓库范围包含后端与商家端 PC(含门店设置、订单标识、配送操作、多语言)
+- 推送渠道沿用现有推送体系(iOS 云函数 + 消息入库),不新增渠道

+ 184 - 0
specs/029-merchant-self-delivery/tasks.md

@@ -0,0 +1,184 @@
+---
+description: "Task list for 029-merchant-self-delivery"
+---
+
+# Tasks: 商家自配送 (029)
+
+**Input**: Design documents from `/specs/029-merchant-self-delivery/`
+
+**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/api.md, quickstart.md
+
+**Tests**: 已包含——spec SC-001/SC-004/SC-005 要求验收用例集 100%,quickstart 场景 1 即单测。测试任务先写并确认 RED,再实现转 GREEN。
+
+**Organization**: 按用户故事分阶段,每个故事可独立实现与验证。
+
+## Path Conventions
+
+- 后端仓库根:`E:\QtwCode\foodie\foodie_server`(下述相对路径基于此)
+- 商家端 PC:`E:\QtwCode\foodie\foodie-store`(CRLF,用 Python 脚本编辑)
+- 编译:临时 `JAVA_HOME=C:\Users\qmj\.jdks\graalvm-jdk-21.0.7`;本项目无 mapper XML 新增(新表走 MyBatis-Plus BaseMapper),但改 XML 时须 `python -c "import xml.etree.ElementTree as ET; ET.parse(...)"` 校验
+
+---
+
+## Phase 1: Setup (Shared Infrastructure)
+
+- [x] T001 将 3 条 DDL(pos_store 2 列、pos_store_self_delivery_hours 建表、pos_order 1 列,见 data-model.md)登记到 `updatesql/sql.md`,标注 `-- 2026-09-08 029 商家自配送`;不直接执行
+- [x] T002 [P] 新建实体 `ruoyi-system/src/main/java/com/ruoyi/system/domain/PosStoreSelfDeliveryHours.java`(MyBatis-Plus 注解风格对齐 OperatingHours)与 `ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosStoreSelfDeliveryHoursMapper.java`(BaseMapper,无 XML)
+- [x] T003 [P] 实体加字段:`ruoyi-system/src/main/java/com/ruoyi/system/domain/PosStore.java`(+selfDeliveryEnabled/selfDeliveryMode)、`ruoyi-system/src/main/java/com/ruoyi/system/domain/PosOrder.java`(+selfDelivery);grep `PosStoreMapper.xml`/`PosOrderMapper.xml` 若有涉及全列的 resultMap/select 则同步
+- [x] T004 [P] 后端 i18n:`ruoyi-admin/src/main/resources/i18n/` 下 5 个 properties(messages、zh_CN、zh_TW、en_US、vi)统一加 contracts/api.md §5 全部 key
+
+## Phase 2: Foundational (Blocking Prerequisites)
+
+**⚠️ US 全部依赖本阶段完成**
+
+- [x] T005 [P] 先写测试 `ruoyi-admin/src/test/java/com/ruoyi/app/utils/SelfDeliveryUtilTest.java`:[开始,结束) 边界(18:00:00 命中/21:00:00 不命中)、按星期、多段任一命中、全天、跨天(周二配置周一时刻不命中);确认 RED
+- [x] T006 实现 `ruoyi-admin/src/main/java/com/ruoyi/app/utils/SelfDeliveryUtil.java`:`matches(enabled, mode, hours, target LocalDateTime)`,HH:mm 定长比较、ISO dayOfWeek 过滤、区间含首不含尾;T005 转 GREEN
+- [x] T007 [P] 先写测试 `ruoyi-admin/src/test/java/com/ruoyi/app/order/SelfDeliveryServiceTest.java`:配置主体解析(普通门店=自身/摊位→夜市主门店行/夜市主门店行不存在→未配置回落)、保存校验(自定义至少一段、end>start、HH:mm 格式、dayOfWeek∈[1,7]);确认 RED
+- [x] T008 实现 `ruoyi-admin/src/main/java/com/ruoyi/app/order/SelfDeliveryService.java`:`resolveConfigStore(PosStore)`、`isSelfDelivery(store, target)`、`saveConfig(input)`(整体覆盖式:先删后插)、`getConfig(mdId)`;T007 转 GREEN
+
+---
+
+## Phase 3: User Story 1 - 普通商家开启自配送并完成配送 (Priority: P1) 🎯 MVP
+
+**Goal**: 普通商家配置自配送→即时单命中标记→不进骑手池→商家接单/出餐/(可选开始配送)/确认送达全链路。
+
+**Independent Test**: quickstart 场景 3+5+6(隔离/流转/可选步骤)。
+
+- [x] T009 [P] [US1] 先写契约测试:`ruoyi-admin/src/test/java/com/ruoyi/app/order/PosOrderShOprateControllerTest.java` 补用例——self_delivery=1 单接单/出餐放行(现状被 requireRiderAssigned 拦)、selfDeliveryStart/Complete 状态校验(state≠2 拒绝、重复开始拒绝);确认 RED
+- [x] T010 [P] [US1] 先写契约测试:`ruoyi-admin/src/test/java/com/ruoyi/app/order/PosOrderQsOprateControllerTest.java` 补用例——acceptOrder 对 self_delivery=1 拒绝(no.order.selfdelivery.rider.denied);确认 RED
+- [x] T011 [US1] 配置接口:`ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosStoreController.java` 加 `GET /getMdSelfDeliveryHours` 与 `POST /saveMdSelfDeliveryHours`(@RequestHeader token + DTO);新建 `ruoyi-admin/src/main/java/com/ruoyi/app/mendian/dto/SelfDeliveryConfigInput.java`(含内嵌 SelfDeliveryHourInput,无 Bean Validation 注解,校验在 Service);普通商家走 merchantStoreAccessService.requireStoreAccess
+- [x] T012 [US1] `ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderShOprateController.java` 的 `requireRiderAssigned` 对 `self_delivery=1` 放行(research F2)
+- [x] T013 [US1] 同文件新增 `POST /selfDeliveryStart`(state=2 且 deliveryStatus=0 → 2)与 `POST /selfDeliveryComplete`(state=2 且 deliveryStatus∈{0,2} → 原子置 deliveryStatus=3+state=3+sdTime);新建 DTO `ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/SelfDeliveryOperateInput.java`(id 必填、qsImg 可选);Redisson 锁 `order:sh:selfdelivery:{id}`;订单日志;用户推送(区分自配论文案)
+- [x] T014 [US1] 同文件 `chuCan` 出餐推送:self_delivery=1 跳过骑手推送、保留用户推送
+- [x] T015 [US1] 下单判定接入:`ruoyi-admin/src/main/java/com/ruoyi/app/order/UserOrderController.java` 的 createOrderChild 与 `PosOrderShOprateController.java` 的商家端 createOrder,创建 PosOrder 时调 SelfDeliveryService.isSelfDelivery(store, 基准时间) 写 self_delivery(本阶段基准=当前时间;预约基准在 T026 接入)
+- [x] T016 [US1] 骑手隔离:`ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderQsOprateController.java`——newTask 过滤追加 `self_delivery=0`(NULL 视为 0 兼容存量)、acceptOrder/pickupOrder/deliverOrder 对 self_delivery=1 抛 no.order.selfdelivery.rider.denied(防直调,镜像 027 模式)
+- [ ] T017 [US1] 冒烟:按 quickstart 场景 3(池隔离+快照)、场景 5(流转,暂只核状态不核账单——账单在 US5)、场景 6(可选开始配送)走通(并入 T040 统一回归——需 DDL 已执行+服务启动)
+
+**Checkpoint**: 普通商家即时单 MVP 端到端可用。
+
+---
+
+## Phase 4: User Story 2 - 夜市主统一自配送旗下摊位订单 (Priority: P1)
+
+**Goal**: 夜市主在夜市主门店行配置,对旗下全部摊位生效;夜市主可见并可操作摊位自配送订单;摊位无配置入口。
+
+**Independent Test**: quickstart 场景 2(配置权限+摊位只读)+ 夜市摊位单不进池。
+
+- [x] T018 [P] [US2] 先写测试:SelfDeliveryServiceTest 补——夜市主门店行归属校验(store.userId==登录用户且 isNightMarket=1 才可保存)、摊位(isStall=1)保存拒绝、摊位判定读夜市主门店行配置;确认 RED
+- [x] T019 [US2] `ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosStoreController.java` 配置接口权限完善:夜市主门店行归属校验、摊位拒绝(no.store.stall.selfdelivery.readonly);getMdSelfDeliveryHours 对摊位 mdId 返回所辖夜市配置(前端只读展示)
+- [x] T020 [US2] 判定摊位解析接线:`SelfDeliveryService.resolveConfigStore` 摊位(isStall=1 且 nightMarketId 非空)→ 按 nightMarketId 查夜市主门店行(isNightMarket=1 且 userId=nightMarketId);用测试库核验夜市主门店行存在性,缺失则视为未配置回落骑手池(research D1 待验证项)
+- [x] T021 [US2] 夜市主操作核验:`requireMerchantOrderAccess`(PosOrderShOprateController)对 shId=夜市主放行 selfDeliveryStart/Complete(补契约测试用例);同一父单下多摊位子单 self_delivery 一致性用例
+- [ ] T022 [US2] 冒烟:quickstart 场景 2 + 夜市摊位外送单(时段内)self_delivery=1 且不进 newTask、夜市主 token 可完成配送(并入 T040;库内核验 SQL 见下)
+
+**Checkpoint**: 夜市场景与普通商家场景并存,同一套操作端点。
+
+---
+
+## Phase 5: User Story 3 - 用户端显示"商家自配送" (Priority: P1)
+
+**Goal**: 订单数据透出自配送标记与配送状态;推送文案区分;客户端 App 展示由外部配合(本仓库交付字段与文案)。
+
+**Independent Test**: quickstart 相关断言——订单接口响应含 selfDelivery;推送文案 key 生效;骑手无推送。
+
+- [x] T023 [P] [US3] 测试核验:用户/商家/骑手各订单 list 与 detail 接口(UserOrderController 订单查询、PosOrderShOprateController.orderList/orderDetail、getParentOrderDetail)响应 JSON 含 selfDelivery 字段(全字段序列化,research F16)——补断言用例
+- [x] T024 [US3] 推送接线核验:selfDeliveryStart→`no.message.push.merchant.delivery.start`、selfDeliveryComplete→送达文案(复用现有送达 key,文案区分自配送);断言 self_delivery=1 全流程不触发骑手推送
+
+---
+
+## Phase 6: User Story 4 - 预约单按预约送达时间判定 (Priority: P2)
+
+**Goal**: delryTime 非空的订单按预约时间(而非下单时刻)判定自配送时段。
+
+**Independent Test**: quickstart 场景 4。
+
+- [x] T025 [P] [US4] 先写测试:SelfDeliveryServiceTest/UtilTest 补——预约时段内(15:00 下单预约 19:00,时段 18:00-21:00 → 命中)、预约时段外、预约次日(按次日的星期+时刻);确认 RED
+- [x] T026 [US4] 下单入口基准接入:T015 两处判定调用改传 `delryTime 非空 ? delryTime 解析值 : 当前时间`(`ruoyi-admin/.../order/UserOrderController.java`、`PosOrderShOprateController.java`);注意 delryTime 字符串格式的解析与失败回落(解析失败按即时单处理并记 warn 日志)
+- [ ] T027 [US4] 冒烟:quickstart 场景 4 两笔预约单判定正确(并入 T040)
+
+---
+
+## Phase 7: User Story 5 - 自配送运费结算归配送方 (Priority: P2)
+
+**Goal**: 确认送达后运费全额(不抽成)入账 user_id=sh_id;到付归因同步;无骑手分成。
+
+**Independent Test**: quickstart 场景 5 账单核对(本阶段补全账单断言)。
+
+- [x] T028 [US5] 前置核验:grep 全库 `setType("` 与 user_billing 现有 type 语义,确认 "5" 未被占用(research D5);若占用换值并同步 data-model.md/contracts
+- [x] T029 [P] [US5] 先写测试:OrderService 结算用例——type="5" 账单(user_id=sh_id、amount=freight 全额、divvy=0、dedup 键 userId+type+ddId)、夜市摊位单 shId=夜市主、到付单 type="3" 账单 payment_id=sh_id;确认 RED
+- [x] T030 [US5] 实现 `ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderService.java#setSelfDeliveryFreightBilling`(含 walletService.addBalance(sh_id)),并在 `ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderLifecycleService.java#completeSideEffects` 增分支:`type==0 && self_delivery==1 && qsId==null` 调用之(管理端 018 与商家送达共用此副作用)
+- [x] T031 [US5] 到付归因:selfDeliveryComplete 中 collectPayment=1 → 用户账单 type="3" 的 paymentId=sh_id(镜像 QsOprate.updateUserBill,注意不依赖 qsId)
+- [ ] T032 [US5] 冒烟:quickstart 场景 5 全量账单 SQL 核对(type=5 存在、骑手分成不存在、商品分成照常)(并入 T040;账单 SQL 断言见 quickstart 场景5)
+
+---
+
+## Phase 8: User Story 6 - 商家端配置与订单标识 (Priority: P2)
+
+**Goal**: foodie-store(`E:\QtwCode\foodie\foodie-store`)门店设置自配送区块 + 订单"自配送"标识,四语言。
+
+**Independent Test**: quickstart 场景 7。
+
+- [x] T033 [P] [US6] 定位前端结构:阅读 foodie-store 门店信息页(营业时段编辑所在组件)与订单列表/详情组件、`src/lang/` 四语言文件对应对象层级,输出改动点清单(无代码改动)
+- [ ] T034 [US6] 门店信息页加自配送区块:开关 + 模式(全天/自定义)+ 星期×多时段编辑(复用营业时段编辑交互),调 get/saveMdSelfDeliveryHours;摊位主只读展示"由夜市主统一配送"(foodie-store 对应门店设置组件,Python 脚本编辑保 CRLF)- [ ] T035 [P] [US6] i18n:foodie-store `src/lang/zh.js`、`tw.js`、`en.js`、`vi.js` 四文件同步加 selfDelivery.* keys(有意义英文驼峰、按对象层级插入、四文件 key 一致)- [ ] T036 [US6] 订单列表/详情:selfDelivery=1 显示"自配送"标签($t),不显示等待骑手类状态- [ ] T037 [US6] 【可裁剪:依赖 clarify Q2 端覆盖决策,未决策前跳过】PC 配送操作按钮:开始配送/确认送达调 `/system/orderShOprate/selfDeliveryStart|Complete`
+- [ ] T038 [US6] 冒烟:quickstart 场景 7(配置保存回显、标识、四语言、摊位主无编辑入口)
+---
+
+【转外部配合】T033 探索结论:foodie-store 无门店信息页与订单列表页(在商家 App),本仓库前端无可挂载点,界面归商家 App 接入(见 delivery.md 外部配合清单);i18n 前端 key 随 App 端实施
+
+## Phase 9: Polish & Cross-Cutting Concerns
+
+- [x] T039 构建与测试全绿:临时 JAVA_HOME 下 `mvn -pl ruoyi-system,ruoyi-admin -am compile` + `mvn -pl ruoyi-admin test`;若有 XML 改动跑 ET.parse 校验
+- [ ] T040 quickstart 场景 8(快照不回滚)+ 场景 1-8 整体回归一遍【待开发者执行 DDL 后进行:启动服务按 quickstart 场景1-8 回归】
+- [x] T041 子代理逐项核验:对照 spec.md FR-001~FR-012 与 SC-001~SC-005 逐条核对实现落实(含骑手直调接口探测),输出核对清单
+- [x] T042 交付说明:更新 specs/029 文档状态;列出客户端 App(uni-app)外部配合清单——订单字段 selfDelivery 含义、展示"商家自配送"与配送状态映射(0 备餐中/2 配送中/3 已送达)
+
+---
+
+## Dependencies & Execution Order
+
+### Phase Dependencies
+
+- **Phase 1/2(Setup+Foundational)**: 立即开始;T002/T003/T004 与 T005/T007 并行;T006 依赖 T005,T008 依赖 T007
+- **US1(Phase 3)**: 依赖 Phase 2 全部;T009/T010 可并行先写
+- **US2(Phase 4)**: 依赖 T011(配置接口存在)与 T013(操作端点存在),其余可并行
+- **US3(Phase 5)**: 依赖 T013/T015(字段已写入订单)
+- **US4(Phase 6)**: 依赖 T015(判定接入点存在)
+- **US5(Phase 7)**: 依赖 T013(送达端点存在)
+- **US6(Phase 8)**: 依赖 T011/T019(后端配置接口完整);T037 额外依赖 clarify Q2 决策
+- **Polish(Phase 9)**: 依赖全部期望交付的 Story 完成
+
+### User Story Dependencies
+
+- US1 为核心链路,US2/US3/US4/US5/US6 均在其端点/字段之上增量,但各自可独立验证
+- US4 与 US5、US6 无相互依赖,可并行
+
+### Parallel Opportunities
+
+- Phase 1:T002/T003/T004 三路并行(不同文件)
+- Phase 2:T005 与 T007 并行(测试文件不同)
+- Phase 3:T009 与 T010 并行(不同测试文件)
+- Phase 8:T035(语言文件)与 T034/T036(组件)不同文件可并行
+- US4/US5/US6 三个 Phase 可由不同人并行
+
+---
+
+## Implementation Strategy
+
+### MVP First (US1)
+
+1. Phase 1 + Phase 2 → 基础设施
+2. Phase 3 (US1) → 普通商家即时单端到端
+3. **STOP 验证**:quickstart 场景 1/3/5/6 通过即可演示
+
+### Incremental Delivery
+
+1. US1 → US2(夜市)→ US3(标识透出)→ US4(预约)→ US5(结算)→ US6(商家端界面)
+2. 每步独立验证不回退;T037 等 Q2 决策后再启用
+
+---
+
+## Notes
+
+- [P] = 不同文件且无未完成依赖
+- foodie-store 文件 CRLF,一律 Python 脚本编辑(项目规则)
+- T028 若 type="5" 被占用:换值后同步更新 data-model.md 与 contracts/api.md
+- 不触碰废弃代码清单(research.md「涉及的废弃代码」)

+ 26 - 0
updatesql/sql.md

@@ -1480,3 +1480,29 @@ WHERE provider IN ('line', 'line_user')
 GROUP BY provider_uid
 HAVING COUNT(*) > 1;
 ```
+
+# 2026-09-08:029 商家自配送
+
+门店自配送:门店勾选自配送并设置时段(全天/按星期),命中时段的外送单不进骑手池。
+夜市场景配置存于**夜市主自己的门店行**(is_night_market=1),摊位经 night_market_id 解析读取;
+订单在下单时判定并固化快照列 self_delivery(不随后续配置变化)。
+
+```sql
+-- 2026-09-08 029 商家自配送:门店配置两列
+ALTER TABLE pos_store ADD COLUMN self_delivery_enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT '自配送开关(夜市主门店行开启=旗下摊位统一生效)';
+ALTER TABLE pos_store ADD COLUMN self_delivery_mode TINYINT(1) DEFAULT NULL COMMENT '自配送时段模式:1=全天,2=自定义按星期';
+
+-- 2026-09-08 029 商家自配送:时段表(按星期,镜像 operating_hours 模式)
+CREATE TABLE pos_store_self_delivery_hours (
+  id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
+  md_id INT NOT NULL COMMENT '配置主体门店id(夜市场景为夜市主门店行)',
+  day_of_week TINYINT NOT NULL COMMENT 'ISO星期:1=周一..7=周日',
+  start_time VARCHAR(5) NOT NULL COMMENT '开始时刻 HH:mm(含)',
+  end_time VARCHAR(5) NOT NULL COMMENT '结束时刻 HH:mm(不含)',
+  PRIMARY KEY (id),
+  UNIQUE KEY uk_sd_hours (md_id, day_of_week, start_time)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='门店自配送时段(按星期)';
+
+-- 2026-09-08 029 商家自配送:订单判定快照列
+ALTER TABLE pos_order ADD COLUMN self_delivery TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1=商家自配送(下单时快照,不进骑手池,运费归sh_id)';
+```