Эх сурвалжийг харах

fix(selfdelivery): 夜市主无锚点行可配置自配送,保存时自动补建

测试库全库无 is_night_market=1 门店行(历史无创建路径),原反查不到即
拒绝使所有夜市主打不开配置页。改为:My 接口显式校验 user_type==3;
GET 锚点行缺失返回默认未启用配置;SAVE 经
resolveOrCreateNightMarketStoreByOwner 首次保存补建纯配置锚点行,
后建摊位经 night_market_id 照常解析。同步 029 规格与 5 个新测试。
qmj 1 долоо хоног өмнө
parent
commit
29ab307229

+ 20 - 12
ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosStoreController.java

@@ -563,27 +563,28 @@ public class PosStoreController extends BaseController {
     /**
      * (商家端 PC)读取当前登录夜市主自己的自配送配置(029)。
      * 夜市主账号只有 userId、不持有门店 ID,后端按归属反查夜市主门店行;
-     * 非夜市主(普通商家等)反查不到门店行,统一按"未找到夜市门店"拒绝。
+     * 2026-10-08 变更:夜市主尚未建锚点行时返回默认未启用配置(首次保存时补建),
+     * 非夜市主(user_type!=3)统一按"未找到夜市门店"拒绝。
      */
     @Anonymous
     @Auth
     @GetMapping("/getMyNightMarketSelfDelivery")
     public AjaxResult getMyNightMarketSelfDelivery(@RequestHeader String token) {
         Long userId = AuthContext.requireUserId();
+        requireNightMarketOwner(userId);
         PosStore configStore = selfDeliveryService.resolveNightMarketStoreByOwner(userId);
-        if (configStore == null) {
-            throw new ServiceException(MessageUtils.message("no.store.selfdelivery.market.missing"));
-        }
         Map<String, Object> data = new LinkedHashMap<>();
-        data.put("enabled", Integer.valueOf(1).equals(configStore.getSelfDeliveryEnabled()));
-        data.put("mode", configStore.getSelfDeliveryMode());
-        data.put("hours", selfDeliveryService.listHours(Long.valueOf(configStore.getId())));
+        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())));
         return success(data);
     }
 
     /**
      * (商家端 PC)保存当前登录夜市主自己的自配送配置(029)。
-     * 配置主体由后端反查决定,前端不传也不信任 mdId;校验与覆盖式保存复用 saveConfig。
+     * 配置主体由后端反查决定,前端不传也不信任 mdId;校验与覆盖式保存复用 saveConfig;
+     * 2026-10-08 变更:夜市主锚点行缺失时自动补建(纯配置行),后建摊位照常生效。
      */
     @Anonymous
     @Auth
@@ -594,16 +595,23 @@ public class PosStoreController extends BaseController {
         if (input == null) {
             throw new ServiceException(MessageUtils.message("no.store.selfdelivery.hours.invalid"));
         }
-        PosStore configStore = selfDeliveryService.resolveNightMarketStoreByOwner(userId);
-        if (configStore == null) {
-            throw new ServiceException(MessageUtils.message("no.store.selfdelivery.market.missing"));
-        }
+        InfoUser owner = requireNightMarketOwner(userId);
+        PosStore configStore = selfDeliveryService.resolveOrCreateNightMarketStoreByOwner(owner);
         // 主体由后端决定:覆盖前端传入的 mdId,防止越权写其他门店
         input.setMdId(configStore.getId());
         selfDeliveryService.saveConfig(input);
         return success();
     }
 
+    /** 029 My 接口门槛:仅夜市主(user_type=3)可用,其余一律按"未找到夜市门店"拒绝。 */
+    private InfoUser requireNightMarketOwner(Long userId) {
+        InfoUser owner = infoUserService.selectInfoUserByUserId(userId);
+        if (owner == null || !"3".equals(owner.getUserType())) {
+            throw new ServiceException(MessageUtils.message("no.store.selfdelivery.market.missing"));
+        }
+        return owner;
+    }
+
     //返回营业时间(按星期分组)
     private List<StoreOperatOutput> getStoreOperatOutput(List<OperatingHours> list, Long mdId){
         List<StoreOperatOutput> data= new ArrayList<>();

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

@@ -7,6 +7,7 @@ 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.InfoUser;
 import com.ruoyi.system.domain.PosStore;
 import com.ruoyi.system.domain.PosStoreSelfDeliveryHours;
 import com.ruoyi.system.mapper.PosStoreSelfDeliveryHoursMapper;
@@ -68,6 +69,29 @@ public class SelfDeliveryService {
                 .last("LIMIT 1"));
     }
 
+    /**
+     * 2026-10-08 变更:夜市主门店行缺失时按登录夜市主自动补建。
+     * 历史上没有任何路径产出过该行(测试库 is_night_market=1 全库为 0 条),
+     * 夜市主首次保存自配送配置时补一条纯配置锚点行(is_night_market=1、user_id=夜市主);
+     * 后建摊位经 night_market_id 解析到该行。C 端不受影响:夜市列表读 info_user
+     * (selectNightMarketListByDistance),摊位列表过滤 is_stall=1。
+     */
+    public PosStore resolveOrCreateNightMarketStoreByOwner(InfoUser owner) {
+        PosStore row = resolveNightMarketStoreByOwner(owner.getUserId());
+        if (row != null) {
+            return row;
+        }
+        PosStore anchor = new PosStore();
+        anchor.setUserId(owner.getUserId());
+        anchor.setPosName(owner.getUserName());
+        anchor.setIsNightMarket(1);
+        anchor.setIsStall(0);
+        anchor.setDelFlag("0");
+        anchor.setCretim(LocalDateTime.now());
+        posStoreService.save(anchor);
+        return anchor;
+    }
+
     /**
      * 判定基准时刻(029):预约单(delryTime 非空)按预约送达时间段、取其开始时刻,
      * 即时单按当前时间。delryTime 格式为"yyyy-MM-dd HH:mm-HH:mm"(预约时间段,见 DateUtil.isNearOrPastStartTime);

+ 34 - 3
ruoyi-admin/src/test/java/com/ruoyi/app/mendian/PosStoreControllerSelfDeliveryTest.java

@@ -20,6 +20,7 @@ import java.util.ArrayList;
 import java.util.Map;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
@@ -164,20 +165,50 @@ class PosStoreControllerSelfDeliveryTest {
 
     @Test
     void myNightMarketSelfDeliveryRejectsUserWithoutNightMarketStore() {
-        // 普通商家反查不到夜市主门店行,读写一律拒绝
+        // 非夜市主(user_type!=3,含账号不存在)在类型门槛即拒绝,不触发反查/补建
         login(101L, "1");
-        when(selfDelivery.resolveNightMarketStoreByOwner(101L)).thenReturn(null);
         assertThrows(ServiceException.class, () -> controller.getMyNightMarketSelfDelivery("t"));
         assertThrows(ServiceException.class,
                 () -> controller.saveMyNightMarketSelfDelivery("t", config(1)));
+        verify(selfDelivery, never()).resolveOrCreateNightMarketStoreByOwner(any());
         verify(selfDelivery, never()).saveConfig(any());
     }
 
+    @Test
+    void myNightMarketSelfDeliveryReturnsDefaultsWhenAnchorMissing() {
+        // 2026-10-08:夜市主未建锚点行也能打开配置页,返回默认未启用(首次保存时补建锚点行)
+        login(77L, "3");
+        when(selfDelivery.resolveNightMarketStoreByOwner(77L)).thenReturn(null);
+
+        var result = controller.getMyNightMarketSelfDelivery("t");
+        Map<String, Object> data = (Map<String, Object>) result.get("data");
+        assertEquals(Boolean.FALSE, data.get("enabled"));
+        assertNull(data.get("mode"));
+        assertTrue(((java.util.List<?>) data.get("hours")).isEmpty());
+    }
+
+    @Test
+    void saveMyNightMarketSelfDeliveryCreatesAnchorForNightMarketOwner() {
+        // 夜市主无锚点行时保存自动补建;后续新增摊位经 night_market_id 解析到该行
+        login(77L, "3");
+        when(selfDelivery.resolveOrCreateNightMarketStoreByOwner(any())).thenReturn(store(1, 0, 1, 77L));
+
+        controller.saveMyNightMarketSelfDelivery("t", config(999));
+
+        org.mockito.ArgumentCaptor<InfoUser> ownerCaptor = org.mockito.ArgumentCaptor.forClass(InfoUser.class);
+        verify(selfDelivery).resolveOrCreateNightMarketStoreByOwner(ownerCaptor.capture());
+        assertEquals(Long.valueOf(77L), ownerCaptor.getValue().getUserId());
+        org.mockito.ArgumentCaptor<SelfDeliveryConfigInput> captor =
+                org.mockito.ArgumentCaptor.forClass(SelfDeliveryConfigInput.class);
+        verify(selfDelivery).saveConfig(captor.capture());
+        assertEquals(Integer.valueOf(1), captor.getValue().getMdId());
+    }
+
     @Test
     void saveMyNightMarketSelfDeliveryOverridesClientMdId() {
         // 配置主体由后端反查决定,前端传入的 mdId 被覆盖,防止越权写其他门店
         login(77L, "3");
-        when(selfDelivery.resolveNightMarketStoreByOwner(77L)).thenReturn(store(1, 0, 1, 77L));
+        when(selfDelivery.resolveOrCreateNightMarketStoreByOwner(any())).thenReturn(store(1, 0, 1, 77L));
 
         controller.saveMyNightMarketSelfDelivery("t", config(999));
 

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

@@ -4,6 +4,7 @@ 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.InfoUser;
 import com.ruoyi.system.domain.PosStore;
 import com.ruoyi.system.domain.PosStoreSelfDeliveryHours;
 import com.ruoyi.system.mapper.PosStoreSelfDeliveryHoursMapper;
@@ -124,6 +125,37 @@ class SelfDeliveryServiceTest {
         assertFalse(service.isSelfDelivery(store(500, 1, 77L), LocalDateTime.now()));
     }
 
+    // ===== 夜市主锚点行补建(2026-10-08:库中无任何 is_night_market=1 行,保存时自动补建) =====
+
+    @Test
+    void resolveOrCreateBuildsAnchorRowWhenMissing() {
+        when(posStoreService.getOne(any())).thenReturn(null);
+        InfoUser owner = new InfoUser();
+        owner.setUserId(77L);
+        owner.setUserName("night market owner");
+        PosStore created = service.resolveOrCreateNightMarketStoreByOwner(owner);
+        assertNotNull(created);
+        assertEquals(Long.valueOf(77L), created.getUserId());
+        assertEquals(Integer.valueOf(1), created.getIsNightMarket());
+        assertEquals(Integer.valueOf(0), created.getIsStall());
+        assertEquals("0", created.getDelFlag());
+        assertEquals("night market owner", created.getPosName());
+        assertNotNull(created.getCretim());
+        verify(posStoreService).save(any(PosStore.class));
+    }
+
+    @Test
+    void resolveOrCreateReusesExistingAnchorWithoutInsert() {
+        PosStore existing = store(1, 0, null);
+        existing.setIsNightMarket(1);
+        existing.setUserId(77L);
+        when(posStoreService.getOne(any())).thenReturn(existing);
+        InfoUser owner = new InfoUser();
+        owner.setUserId(77L);
+        assertSame(existing, service.resolveOrCreateNightMarketStoreByOwner(owner));
+        verify(posStoreService, never()).save(any(PosStore.class));
+    }
+
     // ===== 保存校验 =====
 
     @Test

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

@@ -32,6 +32,12 @@
 
 **效果**:pos_store 两列更新;mode=2 时 hours 整体覆盖式重建(先删后插)。已下单订单的 self_delivery 快照不受影响。
 
+### GET/POST `/chanting/store/{get|save}MyNightMarketSelfDelivery`(夜市主 PC「我的自配送」)
+
+- Header `token` 必填;无业务参数(save 传 `SelfDeliveryConfigInput`,mdId 由后端覆盖)。
+- 门槛:仅夜市主(info_user.user_type=='3');其余一律拒绝 `no.store.selfdelivery.market.missing`。
+- 【2026-10-08 变更】锚点行(is_night_market=1 且 user_id=登录 userId)缺失时:GET 返回 `{enabled:false, mode:null, hours:[]}`;SAVE 自动补建锚点行(纯配置行,is_stall=0)后保存,后建摊位照常生效。
+
 ## 2. 商家配送操作(新增于 PosOrderShOprateController `/system/orderShOprate`)
 
 ### POST `/selfDeliveryStart`(开始配送,可选步骤)

+ 2 - 1
specs/029-merchant-self-delivery/spec.md

@@ -18,7 +18,8 @@
 
 ### 2026-09-10 追加:夜市主 PC 设置入口(T034 夜市子集恢复)
 
-- 后端新增「我的」读写接口:`GET/POST /chanting/store/{get|save}MyNightMarketSelfDelivery`——夜市主登录态只有 userId,接口内部按 user_id+is_night_market=1 反查门店行,前端不传也不信任 mdId(save 覆盖客户端 mdId 防越权);反查不到门店行(普通商家/未建夜市门店)统一拒绝 `no.store.selfdelivery.market.missing`(六语言)。原 mdId 版接口保留。
+- 后端新增「我的」读写接口:`GET/POST /chanting/store/{get|save}MyNightMarketSelfDelivery`——夜市主登录态只有 userId,接口内部按 user_id+is_night_market=1 反查门店行,前端不传也不信任 mdId(save 覆盖客户端 mdId 防越权);非夜市主(user_type!=3)统一拒绝 `no.store.selfdelivery.market.missing`(六语言)。原 mdId 版接口保留。
+- 【2026-10-08 变更】实测全库不存在任何 `is_night_market=1` 门店行(无创建路径产出过它),原"反查不到即拒绝"使所有夜市主打不开配置页。改为:GET 对夜市主在锚点行缺失时返回默认未启用配置(enabled=false、mode=null、hours=[],前端 `data.mode||1` 兼容);SAVE 由 `SelfDeliveryService.resolveOrCreateNightMarketStoreByOwner` 首次保存时自动补建锚点行(user_id=夜市主、is_night_market=1、is_stall=0、pos_name=账号昵称的纯配置行);后建摊位经 night_market_id 照常解析。C 端不受影响(夜市列表读 info_user、摊位列表过滤 is_stall=1)。
 - PC(foodie-store):「摊位管理」菜单下新增「自配送设置」(仅 userType==3 可见),新页面 `SelfDeliverySettings.vue`(开关+全天/自定义+星期多时段,复用整体覆盖保存);i18n 四语言新增 `Aside.selfDeliverySettings` 与 `selfDelivery` 命名空间。
 - 顺带发现既有缺口:foodie-store `en.js`/`vi.js` 缺整个 `Aside` 命名空间(本次为新增菜单 key 补了只含该 key 的对象,其余历史 key 未回填);后端 `messages_th_TH.properties` 缺 029 历史 no.* keys。
 

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

@@ -191,3 +191,7 @@ description: "Task list for 029-merchant-self-delivery"
 - [ ] T044 【外部配合】商家手机 App 的订单页面源码不在当前工作区:接入自配送标签及 `selfDeliveryStart`/`selfDeliveryComplete` 操作入口后,才能完成商家侧实际操作闭环。
 - [ ] T045 【范围外】用户 App(`msd-user`)按用户要求不修改;如后续需要,接入自配送标识、状态文案以及骑手入口隔离。
 - [ ] T046 【待用户确认后实施】复用 foodie-store 首页订单管理:夜市主仅查看 `type=0 && selfDelivery=1` 的旗下摊位订单;提供全部、备餐中、待配送、配送中、已完成、已取消/售后 Tab。摊位主保留接单/出餐;夜市主仅在待配送/配送中使用 `selfDeliveryStart`、`selfDeliveryComplete`。详见 delivery.md「夜市主 PC 订单配送调度」。
+- [x] T047 【2026-10-08 变更】夜市主锚点行缺失不再拒绝:测试库全库无 is_night_market=1 行(历史无创建路径),
+GET 对夜市主返回默认未启用配置;SAVE 经 `SelfDeliveryService.resolveOrCreateNightMarketStoreByOwner` 首次保存自动补建锚点行;
+门槛改为显式校验 user_type=='3'(非夜市主仍拒 market.missing)。
+测试:SelfDeliveryServiceTest 补建/复用 2 例 + PosStoreControllerSelfDeliveryTest 默认返回/补建保存/类型门槛 3 例。