Quellcode durchsuchen

修复商家分管账号订单与餐桌码权限并明确发票打印范围

qmj vor 1 Tag
Ursprung
Commit
a4caea8c66

+ 33 - 8
ruoyi-admin/src/main/java/com/ruoyi/app/mendian/ShindexController.java

@@ -9,9 +9,11 @@ import com.ruoyi.app.mendian.dto.ExportInput;
 import com.ruoyi.common.annotation.Anonymous;
 import com.ruoyi.common.core.controller.BaseController;
 import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.exception.ServiceException;
 import com.ruoyi.common.utils.MessageUtils;
 import com.ruoyi.common.utils.poi.ExcelUtil;
 import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.domain.InfoUser;
 import com.ruoyi.system.mapper.PosOrderMapper;
 import com.ruoyi.system.mapper.PosStoreMapper;
 import com.ruoyi.system.mapper.UserBillingMapper;
@@ -46,6 +48,8 @@ public class ShindexController extends BaseController {
     private IInfoUserService infoUserService;
     @Autowired
     private IInfoAddressService infoAddressService;
+    @Autowired
+    private MerchantStoreAccessService merchantStoreAccessService;
 
     @Anonymous
     @GetMapping("/getshindex")
@@ -80,12 +84,11 @@ public class ShindexController extends BaseController {
                                         @RequestParam(defaultValue = "") String startTime,
                                         @RequestParam(defaultValue = "") String endTime)
     {
-        JwtUtil jwtUtil = new JwtUtil();
-        String id = jwtUtil.getusid(token);
+        String id = requireOrderOwnerId(token, mdId);
         IPage<PosOrder> palist = new Page<>(page,size);
         QueryWrapper<PosOrder> queryWrapper= new QueryWrapper<>();
         queryWrapper.select().orderByDesc("cretim");
-        queryWrapper.eq("sh_id",id);
+        queryWrapper.eq(id != null, "sh_id",id);
         System.out.println("商户id:"+id);
         queryWrapper.eq("md_id",mdId);
         if(!"".equals(state)){
@@ -94,7 +97,7 @@ public class ShindexController extends BaseController {
                         posOrderQueryWrapper -> posOrderQueryWrapper.eq("collect_payment","1")
                 ).and(
                         posOrderQueryWrapper -> posOrderQueryWrapper.eq("type",1)
-                ).or().eq("state",1).and(
+                ).or().eq("state",1).and(id != null,
                         posOrderQueryWrapper -> posOrderQueryWrapper.eq("sh_id",id)
                 ).and(
                         posOrderQueryWrapper -> posOrderQueryWrapper.eq("md_id",mdId)
@@ -171,11 +174,10 @@ public class ShindexController extends BaseController {
     @Auth
     @PostMapping("/export")
     public void export(HttpServletResponse response,@RequestHeader String token, @RequestBody ExportInput input){
-        JwtUtil jwtUtil = new JwtUtil();
-        String id = jwtUtil.getusid(token);
+        String id = requireOrderOwnerId(token, input.getMdId());
         QueryWrapper<PosOrder> queryWrapper= new QueryWrapper<>();
         queryWrapper.select().orderByDesc("cretim");
-        queryWrapper.eq("sh_id",id);
+        queryWrapper.eq(id != null, "sh_id",id);
         System.out.println("商户id:"+id);
         queryWrapper.eq("md_id",input.getMdId());
         if(!"".equals(input.getState())){
@@ -184,7 +186,7 @@ public class ShindexController extends BaseController {
                         posOrderQueryWrapper -> posOrderQueryWrapper.eq("collect_payment","1")
                 ).and(
                         posOrderQueryWrapper -> posOrderQueryWrapper.eq("type",1)
-                ).or().eq("state",1).and(
+                ).or().eq("state",1).and(id != null,
                         posOrderQueryWrapper -> posOrderQueryWrapper.eq("sh_id",id)
                 ).and(
                         posOrderQueryWrapper -> posOrderQueryWrapper.eq("md_id",input.getMdId())
@@ -215,4 +217,27 @@ public class ShindexController extends BaseController {
         util.exportExcel(response, list, MessageUtils.message("no.export.excel.posorder"));
     }
 
+    private String requireOrderOwnerId(String token, String mdId) {
+        String loginId = new JwtUtil().getusid(token);
+        InfoUser user = infoUserService.selectInfoUserByUserId(Long.valueOf(loginId));
+        if (user == null) {
+            throw new ServiceException(MessageUtils.message("merchant.account.unavailable"));
+        }
+        if ("1".equals(user.getUserType()) || "4".equals(user.getUserType()) || "5".equals(user.getUserType())) {
+            Long storeId;
+            try {
+                storeId = Long.valueOf(mdId);
+            } catch (NumberFormatException exception) {
+                throw new ServiceException(MessageUtils.message("merchant.store.access.denied"));
+            }
+            MerchantAccessContext context = merchantStoreAccessService.resolve(user.getUserId());
+            if (!context.storeIds().contains(storeId)) {
+                throw new ServiceException(MessageUtils.message("merchant.store.access.denied"));
+            }
+            // 摊位账号按已校验的绑定门店查询,不能将摊位账号 ID 当作订单 sh_id。
+            return "4".equals(user.getUserType()) ? null : String.valueOf(context.ownerUserId());
+        }
+        return loginId;
+    }
+
 }

+ 19 - 6
ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayOfflineService.java

@@ -20,6 +20,7 @@ import com.ruoyi.system.service.IPosOrderLinePaymentService;
 import com.ruoyi.system.service.IPosOrderService;
 import com.ruoyi.system.service.IPosStoreService;
 import com.ruoyi.system.service.IPosStoreLinePayService;
+import com.ruoyi.system.service.MerchantStoreAccessService;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -60,6 +61,7 @@ public class LinePayOfflineService {
     private final LinePayFactService factService;
     private final LinePayGatewayAuditService auditService;
     private final PaymentCreateGuardService createGuard;
+    private final MerchantStoreAccessService merchantStoreAccessService;
 
     public LinePayOfflineService(IPosOrderService orderService,
                                  IInfoUserService userService,
@@ -69,7 +71,7 @@ public class LinePayOfflineService {
                                  LinePayClient linePayClient,
                                  LinePayFactService factService) {
         this(orderService, userService, storeService, credentialService, paymentService, linePayClient,
-                factService, null, null);
+                factService, null, null, null);
     }
 
     @Autowired
@@ -81,7 +83,8 @@ public class LinePayOfflineService {
                                  LinePayClient linePayClient,
                                  LinePayFactService factService,
                                  LinePayGatewayAuditService auditService,
-                                 PaymentCreateGuardService createGuard) {
+                                 PaymentCreateGuardService createGuard,
+                                 MerchantStoreAccessService merchantStoreAccessService) {
         this.orderService = orderService;
         this.userService = userService;
         this.storeService = storeService;
@@ -91,6 +94,7 @@ public class LinePayOfflineService {
         this.factService = factService;
         this.auditService = auditService;
         this.createGuard = createGuard;
+        this.merchantStoreAccessService = merchantStoreAccessService;
     }
 
     public LinePayOfflineResult pay(Long merchantId, String ddId, String oneTimeKey) {
@@ -456,10 +460,19 @@ public class LinePayOfflineService {
                     merchantId, ddId, order.getMdId());
             throw new ServiceException(MessageUtils.message("line.pay.offline.order.forbidden"));
         }
-        boolean owned = directMerchant
-                ? merchant.getUserId().equals(order.getShId())
-                && merchant.getUserId().equals(store.getUserId())
-                : merchant.getStoreId() != null && merchant.getStoreId().equals(order.getMdId());
+        boolean owned;
+        if ("5".equals(merchant.getUserType())) {
+            // 分管账号按实时授权集合访问门店,storeId 单值不代表其权限;订单仍归主账号。
+            var access = merchantStoreAccessService.resolve(merchantId);
+            owned = access.storeIds().contains(order.getMdId())
+                    && access.ownerUserId().equals(order.getShId())
+                    && access.ownerUserId().equals(store.getUserId());
+        } else {
+            owned = directMerchant
+                    ? merchant.getUserId().equals(order.getShId())
+                    && merchant.getUserId().equals(store.getUserId())
+                    : merchant.getStoreId() != null && merchant.getStoreId().equals(order.getMdId());
+        }
         if (!owned) {
             log.warn("LINE Pay offline order rejected: not owned, merchantId={}, userType={}, merchantStoreId={}, ddId={}, orderShId={}, orderMdId={}, storeUserId={}",
                     merchantId, merchant.getUserType(), merchant.getStoreId(), ddId,

+ 31 - 13
ruoyi-admin/src/main/java/com/ruoyi/app/tableqrcode/TableQrcodeController.java

@@ -7,6 +7,8 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.ruoyi.common.annotation.Anonymous;
 import com.ruoyi.common.core.controller.BaseController;
 import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
 import com.ruoyi.system.domain.InfoUser;
 import com.ruoyi.system.domain.PosOrder;
 import com.ruoyi.system.domain.PosStore;
@@ -15,6 +17,7 @@ import com.ruoyi.system.service.IInfoUserService;
 import com.ruoyi.system.service.IPosOrderService;
 import com.ruoyi.system.service.IPosStoreService;
 import com.ruoyi.system.service.ITableQrcodeService;
+import com.ruoyi.system.service.MerchantStoreAccessService;
 import com.ruoyi.system.utils.Auth;
 import com.ruoyi.system.utils.JwtUtil;
 import org.apache.catalina.Store;
@@ -48,6 +51,9 @@ public class TableQrcodeController extends BaseController
     @Autowired
     private IPosOrderService posOrderService;
 
+    @Autowired
+    private MerchantStoreAccessService merchantStoreAccessService;
+
 
     /**
      * 创建餐桌码
@@ -80,7 +86,7 @@ public class TableQrcodeController extends BaseController
         String userType = currentUser.getUserType();
 
         // 权限与参数处理
-        if ("4".equals(userType) || "1".equals(userType))
+        if ("4".equals(userType) || "1".equals(userType) || "5".equals(userType))
         {
             // 摊主只能创建摊位码
             if (tableQrcode.getType() != 0)
@@ -92,6 +98,9 @@ public class TableQrcodeController extends BaseController
             {
                 return error("当前用户未关联摊位");
             }
+            if ("1".equals(userType) || "5".equals(userType)) {
+                merchantStoreAccessService.requireStoreAccess(currentUser.getUserId(), tableQrcode.getStoreId());
+            }
             PosStore store=posStoreService.getOne(new LambdaQueryWrapper<PosStore>().eq(PosStore::getId,tableQrcode.getStoreId()));
             if("4".equals(userType)) {
                 tableQrcode.setStoreId(currentUser.getStoreId());
@@ -212,6 +221,9 @@ public class TableQrcodeController extends BaseController
         String userId = jwtUtil.getusid(token);
         InfoUser currentUser = infoUserService.selectInfoUserByUserId(Long.valueOf(userId));
 
+        if (currentUser == null) {
+            return error(MessageUtils.message("merchant.account.unavailable"));
+        }
         LambdaQueryWrapper<TableQrcode> query = new LambdaQueryWrapper<>();
         query.eq(type!=null,TableQrcode::getType,type);
         query.eq(status!=null,TableQrcode::getStatus,status);
@@ -231,14 +243,20 @@ public class TableQrcodeController extends BaseController
                 query.eq(TableQrcode::getStoreId,storeId);
             }
         }
-        else if("1".equals(currentUser.getUserType())){
-            List<PosStore> stores=posStoreService.list(new LambdaQueryWrapper<PosStore>().eq(PosStore::getUserId,userId));
-            if(stores.isEmpty()){
+        else if("1".equals(currentUser.getUserType()) || "5".equals(currentUser.getUserType())){
+            Set<Long> accessibleStoreIds = merchantStoreAccessService.getAccessibleStoreIds(currentUser.getUserId());
+            if (storeId != null && !accessibleStoreIds.contains(storeId)) {
+                throw new ServiceException(MessageUtils.message("merchant.store.access.denied"));
+            }
+            if(accessibleStoreIds.isEmpty()){
                 query.eq(TableQrcode::getId,0L);
             }else{
-                query.in(TableQrcode::getStoreId,stores.stream().map(PosStore::getId).collect(Collectors.toList()));
+                query.in(TableQrcode::getStoreId,accessibleStoreIds);
+                query.eq(storeId != null, TableQrcode::getStoreId, storeId);
             }
 
+        } else {
+            return error(MessageUtils.message("merchant.store.access.denied"));
         }
         List<TableQrcode> list = tableQrcodeService.list(query);
         Set<Long> storeIds = list.stream()
@@ -267,10 +285,10 @@ public class TableQrcodeController extends BaseController
     @GetMapping("/{id}")
     public AjaxResult getInfo(@RequestHeader String token, @PathVariable Long id)
     {
-//        if (!verifyOwnership(token, id))
-//        {
-//            return error("无权访问");
-//        }
+        if (!verifyOwnership(token, id))
+        {
+            return error(MessageUtils.message("merchant.store.access.denied"));
+        }
         TableQrcode tableQrcode = tableQrcodeService.selectTableQrcodeById(id);
         if (tableQrcode == null)
         {
@@ -549,17 +567,17 @@ public class TableQrcodeController extends BaseController
         InfoUser currentUser = infoUserService.selectInfoUserByUserId(Long.valueOf(userId));
 
         TableQrcode tableQrcode = tableQrcodeService.selectTableQrcodeById(tableId);
-        if (tableQrcode == null)
+        if (currentUser == null || tableQrcode == null)
         {
             return false;
         }
-        if("1".equals(currentUser.getUserType())){
+        if("1".equals(currentUser.getUserType()) || "5".equals(currentUser.getUserType())){
             if (tableQrcode.getStoreId() == null)
             {
                 return false;
             }
-            List<PosStore> stores=posStoreService.list(new LambdaQueryWrapper<PosStore>().eq(PosStore::getUserId,currentUser.getUserId()));
-            return stores.stream().anyMatch(x -> x.getId().equals(tableQrcode.getStoreId().intValue()));
+            merchantStoreAccessService.requireStoreAccess(currentUser.getUserId(), tableQrcode.getStoreId());
+            return true;
         }
 
         if ("4".equals(currentUser.getUserType()))

+ 276 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/mendian/MerchantLegacyEntryAccessTest.java

@@ -0,0 +1,276 @@
+package com.ruoyi.app.mendian;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.AbstractWrapper;
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.ruoyi.app.mendian.dto.ExportInput;
+import com.ruoyi.app.tableqrcode.TableQrcodeController;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.common.utils.poi.ExcelUtil;
+import com.ruoyi.system.domain.*;
+import com.ruoyi.system.mapper.*;
+import com.ruoyi.system.service.*;
+import com.ruoyi.system.utils.JwtUtil;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+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.mock.web.MockHttpServletResponse;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+class MerchantLegacyEntryAccessTest {
+    private final ShindexController index = new ShindexController();
+    private final TableQrcodeController tables = new TableQrcodeController();
+    private final IInfoUserService users = mock(IInfoUserService.class);
+    private final IPosOrderService orders = mock(IPosOrderService.class);
+    private final IPosStoreService stores = mock(IPosStoreService.class);
+    private final ITableQrcodeService codes = mock(ITableQrcodeService.class);
+    private final InfoUserMapper userMapper = mock(InfoUserMapper.class);
+    private final MerchantSubaccountStoreMapper grants = mock(MerchantSubaccountStoreMapper.class);
+    private final PosStoreMapper storeMapper = mock(PosStoreMapper.class);
+    private final MerchantStoreAccessService access = new MerchantStoreAccessService(
+            userMapper, grants, storeMapper, mock(PosOrderMapper.class), mock(PosFoodMapper.class));
+    private final InfoUser subaccount = user(936L, "5");
+    private final InfoUser owner = user(801L, "1");
+    private final PosStore store = new PosStore();
+    private final TableQrcode table = code(70L, 168L);
+    private MockedStatic<MessageUtils> messages;
+    private String token;
+
+    @BeforeEach
+    void setUp() {
+        messages = mockStatic(MessageUtils.class);
+        messages.when(() -> MessageUtils.message(anyString())).thenAnswer(call -> call.getArgument(0));
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), TableQrcode.class);
+        token = JwtUtil.setToken("936", "subaccount");
+        ReflectionTestUtils.setField(index, "infoUserService", users);
+        ReflectionTestUtils.setField(index, "posOrderService", orders);
+        ReflectionTestUtils.setField(tables, "infoUserService", users);
+        ReflectionTestUtils.setField(tables, "posStoreService", stores);
+        ReflectionTestUtils.setField(tables, "posOrderService", orders);
+        ReflectionTestUtils.setField(tables, "tableQrcodeService", codes);
+        ReflectionTestUtils.setField(index, "merchantStoreAccessService", access);
+        ReflectionTestUtils.setField(tables, "merchantStoreAccessService", access);
+        subaccount.setMerchantOwnerId(801L);
+        subaccount.setSubaccountStatus("0");
+        subaccount.setStoreId(null);
+        store.setId(168);
+        store.setUserId(801L);
+        store.setDelFlag("0");
+        when(users.selectInfoUserByUserId(936L)).thenReturn(subaccount);
+        when(users.selectInfoUserByUserId(801L)).thenReturn(owner);
+        when(userMapper.selectInfoUserByUserId(936L)).thenReturn(subaccount);
+        when(userMapper.selectInfoUserByUserId(801L)).thenReturn(owner);
+        when(grants.selectStoreIdsBySubaccountUserId(936L)).thenReturn(List.of(168L));
+        when(storeMapper.selectPosStoreById(168L)).thenReturn(store);
+        when(storeMapper.selectOwnedStoreIdsByUserId(801L)).thenReturn(List.of(168L));
+        when(stores.getOne(any(Wrapper.class))).thenReturn(store);
+        when(codes.selectTableQrcodeById(70L)).thenReturn(table);
+        when(codes.list(any(Wrapper.class))).thenReturn(List.of());
+        when(orders.page(any(), any(Wrapper.class))).thenReturn(new Page<PosOrder>(1, 10));
+        when(orders.list(any(Wrapper.class))).thenReturn(List.of());
+    }
+
+    @AfterEach
+    void tearDown() { messages.close(); }
+
+    @Test
+    void legacyListUsesOwnerAndAuthorizedStoreInBothStateBranches() {
+        index.getstoreorderlist(token, 1, 10, "168", "z01", "", "", "");
+        verify(orders).page(any(), argThat(query -> {
+            AbstractWrapper<?, ?, ?> wrapper = (AbstractWrapper<?, ?, ?>) query;
+            String sql = wrapper.getSqlSegment();
+            return sql.contains("sh_id") && sql.contains("md_id")
+                    && wrapper.getParamNameValuePairs().containsValue("801")
+                    && !wrapper.getParamNameValuePairs().containsValue("936");
+        }));
+    }
+
+    @Test
+    void legacyExportUsesOwnerAndAuthorizedStore() {
+        ExportInput input = new ExportInput();
+        input.setMdId("168");
+        input.setState("z01");
+        try (var excel = mockConstruction(ExcelUtil.class)) {
+            index.export(new MockHttpServletResponse(), token, input);
+        }
+        verify(orders).list(argThat((Wrapper<PosOrder> query) -> {
+            AbstractWrapper<?, ?, ?> wrapper = (AbstractWrapper<?, ?, ?>) query;
+            wrapper.getSqlSegment();
+            return wrapper.getParamNameValuePairs().containsValue("801")
+                    && !wrapper.getParamNameValuePairs().containsValue("936");
+        }));
+    }
+
+    @Test
+    void stallListUsesBoundStoreWithoutMistakingStallUserForOrderOwner() {
+        subaccount.setUserType("4");
+        subaccount.setStoreId(168L);
+        index.getstoreorderlist(token, 1, 10, "168", "z01", "", "", "");
+        verify(orders).page(any(), argThat(query -> query.getSqlSegment().contains("md_id")
+                && !query.getSqlSegment().contains("sh_id")));
+    }
+
+    @Test
+    void stallExportCannotTargetAnotherStore() {
+        subaccount.setUserType("4");
+        subaccount.setStoreId(168L);
+        ExportInput input = new ExportInput();
+        input.setMdId("169");
+        assertThrows(ServiceException.class, () -> index.export(new MockHttpServletResponse(), token, input));
+        verifyNoInteractions(orders);
+    }
+
+    @Test
+    void ordinaryOwnerRetainsAuthorizedTableManagement() {
+        String ownerToken = JwtUtil.setToken("801", "owner");
+        assertEquals(200, tables.list(ownerToken, 168L, null, null, null).get("code"));
+        assertEquals(200, tables.add(ownerToken, code(null, 168L)).get("code"));
+        assertEquals(200, tables.getInfo(ownerToken, 70L).get("code"));
+    }
+
+    @Test
+    void authorizedSubaccountCanGetImageButCannotManagePublicCode() {
+        when(codes.generateQrcodeImage(70L)).thenReturn("image");
+        assertEquals(200, tables.getQrcodeImage(token, 70L).get("code"));
+        verify(codes).generateQrcodeImage(70L);
+        table.setStoreId(null);
+        table.setType(1);
+        assertEquals(500, tables.getInfo(token, 70L).get("code"));
+        assertEquals(500, tables.delete(token, 70L).get("code"));
+        verify(codes, never()).deleteTableQrcodeById(anyLong());
+    }
+
+    @Test
+    void legacyListRejectsAnotherStoreBeforeQuery() {
+        assertThrows(ServiceException.class, () -> index.getstoreorderlist(token, 1, 10, "169", "", "", "", ""));
+        verifyNoInteractions(orders);
+    }
+
+    @Test
+    void revokedGrantBlocksExportEvenWithStaleSingleStoreId() {
+        grantsRevoked();
+        ExportInput input = new ExportInput();
+        input.setMdId("168");
+        assertThrows(ServiceException.class, () -> index.export(new MockHttpServletResponse(), token, input));
+        verifyNoInteractions(orders);
+    }
+
+    @Test
+    void tableListAlwaysHasAuthorizedStorePredicate() {
+        tables.list(token, null, null, null, null);
+        verify(codes).list(argThat((Wrapper<TableQrcode> query) -> {
+            AbstractWrapper<?, ?, ?> wrapper = (AbstractWrapper<?, ?, ?>) query;
+            return wrapper.getSqlSegment().contains("store_id IN")
+                    && wrapper.getParamNameValuePairs().containsValue(168L);
+        }));
+    }
+
+    @Test
+    void tableListWithoutGrantsCannotFallThroughToAllStores() {
+        grantsRevoked();
+        tables.list(token, null, null, null, null);
+        verify(codes).list(argThat((Wrapper<TableQrcode> query) -> {
+            AbstractWrapper<?, ?, ?> wrapper = (AbstractWrapper<?, ?, ?>) query;
+            return wrapper.getSqlSegment().contains("id =")
+                    && wrapper.getParamNameValuePairs().containsValue(0L);
+        }));
+    }
+
+    @Test
+    void explicitUnauthorizedTableStoreIsRejected() {
+        assertThrows(ServiceException.class, () -> tables.list(token, 169L, null, null, null));
+        verifyNoInteractions(codes);
+    }
+
+    @Test
+    void authorizedSubaccountCanCreateStoreTable() {
+        assertEquals(200, tables.add(token, code(null, 168L)).get("code"));
+        verify(codes).insertTableQrcode(any());
+    }
+
+    @Test
+    void ownerCannotCreateTableInAnotherOwnersStore() {
+        assertThrows(ServiceException.class, () -> tables.add(JwtUtil.setToken("801", "owner"), code(null, 169L)));
+        verify(codes, never()).insertTableQrcode(any());
+    }
+
+    @Test
+    void authorizedSubaccountCanReadDetailsAndOrders() {
+        assertEquals(200, tables.getInfo(token, 70L).get("code"));
+        assertEquals(200, tables.getTableOrders(token, 70L, 1, 10, null).get("code"));
+        verify(orders).page(any(), any(Wrapper.class));
+    }
+
+    @Test
+    void detailChecksPersistedStore() {
+        table.setStoreId(169L);
+        assertThrows(ServiceException.class, () -> tables.getInfo(token, 70L));
+    }
+
+    @Test
+    void authorizedSubaccountCanUpdateDeleteAndToggleTable() {
+        TableQrcode request = code(70L, 999L);
+        request.setStatus(1);
+        assertEquals(200, tables.update(token, request).get("code"));
+        assertEquals(200, tables.changeStatus(token, request).get("code"));
+        assertEquals(200, tables.delete(token, 70L).get("code"));
+        verify(codes, times(2)).updateTableQrcode(argThat(update -> update.getStoreId() == null));
+        verify(codes).deleteTableQrcodeById(70L);
+    }
+
+    @Test
+    void revokedGrantBlocksTableMutationUsingPersistedStore() {
+        grantsRevoked();
+        assertThrows(ServiceException.class, () -> tables.update(token, code(70L, 168L)));
+        verify(codes, never()).updateTableQrcode(any());
+    }
+
+    @Test
+    void disabledSubaccountCannotReadTableList() {
+        subaccount.setSubaccountStatus("1");
+        assertThrows(ServiceException.class, () -> tables.list(token, null, null, null, null));
+        verifyNoInteractions(codes);
+    }
+
+    @Test
+    void ordinaryCustomerCannotListAllTables() {
+        subaccount.setUserType("0");
+        assertEquals(500, tables.list(token, null, null, null, null).get("code"));
+        verifyNoInteractions(codes);
+    }
+
+    private void grantsRevoked() {
+        subaccount.setStoreId(168L);
+        when(grants.selectStoreIdsBySubaccountUserId(936L)).thenReturn(List.of());
+    }
+
+    private static InfoUser user(Long id, String type) {
+        InfoUser user = new InfoUser();
+        user.setUserId(id);
+        user.setUserType(type);
+        user.setStatus("0");
+        user.setDelFlag("0");
+        return user;
+    }
+
+    private static TableQrcode code(Long id, Long storeId) {
+        TableQrcode table = new TableQrcode();
+        table.setId(id);
+        table.setStoreId(storeId);
+        table.setTableNo("A01");
+        table.setType(0);
+        table.setQrCode("existing");
+        return table;
+    }
+}

+ 182 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/pay/LinePayOfflineMerchantAccessTest.java

@@ -0,0 +1,182 @@
+package com.ruoyi.app.pay;
+
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.ruoyi.app.utils.linepay.LinePayClient;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.domain.PosOrderLinePayment;
+import com.ruoyi.system.domain.PosStore;
+import com.ruoyi.system.mapper.InfoUserMapper;
+import com.ruoyi.system.mapper.MerchantSubaccountStoreMapper;
+import com.ruoyi.system.mapper.PosFoodMapper;
+import com.ruoyi.system.mapper.PosOrderMapper;
+import com.ruoyi.system.mapper.PosStoreMapper;
+import com.ruoyi.system.service.*;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+
+import java.util.Date;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+class LinePayOfflineMerchantAccessTest {
+    private final IPosOrderService orders = mock(IPosOrderService.class);
+    private final IInfoUserService users = mock(IInfoUserService.class);
+    private final IPosStoreService stores = mock(IPosStoreService.class);
+    private final IPosStoreLinePayService credentials = mock(IPosStoreLinePayService.class);
+    private final IPosOrderLinePaymentService payments = mock(IPosOrderLinePaymentService.class);
+    private final LinePayClient client = mock(LinePayClient.class);
+    private final LinePayFactService facts = mock(LinePayFactService.class);
+    private final InfoUserMapper userMapper = mock(InfoUserMapper.class);
+    private final MerchantSubaccountStoreMapper grants = mock(MerchantSubaccountStoreMapper.class);
+    private final PosStoreMapper storeMapper = mock(PosStoreMapper.class);
+    private final PosOrderMapper orderMapper = mock(PosOrderMapper.class);
+    private final MerchantStoreAccessService access = new MerchantStoreAccessService(
+            userMapper, grants, storeMapper, orderMapper, mock(PosFoodMapper.class));
+    private final InfoUser subaccount = user(936L, "5");
+    private final InfoUser owner = user(801L, "1");
+    private final PosStore store = new PosStore();
+    private final PosOrder order = new PosOrder();
+    private LinePayOfflineService service;
+    private MockedStatic<MessageUtils> messages;
+
+    @BeforeEach
+    void setUp() {
+        messages = mockStatic(MessageUtils.class);
+        messages.when(() -> MessageUtils.message(anyString())).thenAnswer(call -> call.getArgument(0));
+        service = new LinePayOfflineService(orders, users, stores, credentials, payments, client, facts,
+                null, null, access);
+        subaccount.setMerchantOwnerId(801L);
+        subaccount.setSubaccountStatus("0");
+        // 分管账号允许负责多门店,单值 storeId 为空;权限来自授权关系。
+        subaccount.setStoreId(null);
+        store.setId(168);
+        store.setUserId(801L);
+        store.setDelFlag("0");
+        order.setId(3254L);
+        order.setDdId("991793695657521");
+        order.setParentDdId(order.getDdId());
+        order.setShId(801L);
+        order.setMdId(168L);
+        order.setOrderSource("MERCHANT");
+        order.setPayType("3");
+        order.setPayStatus(0L);
+        order.setState(1L);
+        order.setAmount(280);
+        when(users.getOne(any(Wrapper.class))).thenReturn(subaccount);
+        when(orders.getOne(any(Wrapper.class))).thenReturn(order);
+        when(orders.count(any(Wrapper.class))).thenReturn(1L);
+        when(stores.getById(168L)).thenReturn(store);
+        when(userMapper.selectInfoUserByUserId(936L)).thenReturn(subaccount);
+        when(userMapper.selectInfoUserByUserId(801L)).thenReturn(owner);
+        when(grants.selectStoreIdsBySubaccountUserId(936L)).thenReturn(List.of(168L));
+        when(storeMapper.selectPosStoreById(168L)).thenReturn(store);
+        when(orderMapper.selectPosOrderById(3254L)).thenReturn(order);
+        PosOrderLinePayment attempt = new PosOrderLinePayment();
+        attempt.setId(70L);
+        attempt.setDdId(order.getDdId());
+        attempt.setStatus("WAITING_AUTH");
+        attempt.setPaymentMode("OFFLINE");
+        attempt.setCreateTime(new Date());
+        when(payments.getByDdId(order.getDdId())).thenReturn(List.of(attempt));
+        when(payments.getActiveByDdId(order.getDdId())).thenReturn(attempt);
+    }
+
+    @AfterEach
+    void tearDown() { messages.close(); }
+
+    @Test
+    void authorizedSubaccountWithNoSingleStoreCanQueryStatus() {
+        assertEquals("AUTH_REQUIRED", service.status(936L, order.getDdId()).getStatus());
+        verifyNoInteractions(client);
+    }
+
+    @Test
+    void authorizedSubaccountCanReusePaymentWithoutChargingAgain() {
+        var result = service.pay(936L, order.getDdId(), "123456789012345678");
+        assertEquals("AUTH_REQUIRED", result.getStatus());
+        assertTrue(result.getReusedAttempt());
+        verifyNoInteractions(client);
+    }
+
+    @Test
+    void authorizedOrderWithoutAttemptReportsNotStartedInsteadOfForbidden() {
+        when(payments.getByDdId(order.getDdId())).thenReturn(List.of());
+        assertEquals("line.pay.offline.attempt.not.found",
+                assertThrows(ServiceException.class, () -> service.status(936L, order.getDdId())).getMessage());
+        verifyNoInteractions(client);
+    }
+
+    @Test
+    void revocationTakesEffectOnNextQueryAndPayEvenWithStaleStoreId() {
+        assertEquals("AUTH_REQUIRED", service.status(936L, order.getDdId()).getStatus());
+        clearInvocations(payments);
+        subaccount.setStoreId(168L);
+        when(grants.selectStoreIdsBySubaccountUserId(936L)).thenReturn(List.of());
+        assertDeniedBeforePaymentAccess();
+    }
+
+    @Test
+    void disabledSubaccountCannotQueryOrPay() {
+        subaccount.setSubaccountStatus("1");
+        assertDeniedBeforePaymentAccess();
+    }
+
+    @Test
+    void disabledOwnerCannotDelegateQueryOrPay() {
+        owner.setStatus("1");
+        assertDeniedBeforePaymentAccess();
+    }
+
+    @Test
+    void transferredStoreCannotUseStaleGrant() {
+        store.setUserId(999L);
+        assertDeniedBeforePaymentAccess();
+    }
+
+    @Test
+    void grantForAnotherStoreDoesNotAllowThisOrder() {
+        when(grants.selectStoreIdsBySubaccountUserId(936L)).thenReturn(List.of(169L));
+        assertDeniedBeforePaymentAccess();
+    }
+
+    @Test
+    void authorizedSubaccountStillCannotUseCustomerOrder() {
+        order.setOrderSource("USER");
+        assertDeniedBeforePaymentAccess();
+    }
+
+    @Test
+    void deletedStoreCannotUseStaleGrant() {
+        store.setDelFlag("2");
+        assertDeniedBeforePaymentAccess();
+    }
+
+    @Test
+    void accessibleStoreDoesNotAuthorizeOrderOwnedByAnotherMerchant() {
+        order.setShId(999L);
+        assertDeniedBeforePaymentAccess();
+    }
+
+    private void assertDeniedBeforePaymentAccess() {
+        assertThrows(ServiceException.class, () -> service.status(936L, order.getDdId()));
+        assertThrows(ServiceException.class, () -> service.pay(936L, order.getDdId(), "123456789012345678"));
+        verifyNoInteractions(payments, credentials, client, facts);
+    }
+
+    private static InfoUser user(Long id, String type) {
+        InfoUser user = new InfoUser();
+        user.setUserId(id);
+        user.setUserType(type);
+        user.setStatus("0");
+        user.setDelFlag("0");
+        return user;
+    }
+}

+ 11 - 0
specs/010-order-invoice/plan.md

@@ -232,3 +232,14 @@ src/lang/{zh,tw,en,vi}.js                    # 四语言加 key
 2. Mapper 批量按订单ID读取当前发票身份、当前版式 READY 文件及打印申请次数;服务统一回填,不读取 PDF/PNG。
 3. 商家列表在分页权限筛选后回填,通用详情在订单权限校验后回填,原请求与打印流程不变。
 4. 验证批量回填、无票默认值、空页、两接口真实序列化响应及 Mapper 查询范围/身份/资格条件;更新 App 文档。无新增SQL,不执行数据库迁移。
+
+
+## 2026-11-03 载具打印范围收口
+
+用户确认:无 ezPay 真实打印码值就不打印,打印标识返回不可打印;捐赠不打印。按现有010规格增量维护,不启动载具转换开发。
+
+1. 静态核对 selectOrderAvailability:载具类型/号码、捐赠码为空且类别为 B2C/B2B,当前票已开立并有当前版式 READY 文件才返回 true。
+2. 静态核对预生成和打印快照:排除载具与捐赠;要求供应商 PrintFlag=Y 及三个真实码值,通过校验和渲染后才保存 READY。
+3. 保留当前生产实现,追加规格与任务记录。现有列表/详情字段和前端接入说明已符合决定,无需新增接口、SQL或前端改动。
+
+本轮仅文档调整及源码核对,不重复运行 Maven、数据库或实机联调。下午提交43f69b76的测试记录属于历史验证,不作为本轮重新执行的结果。

+ 15 - 0
specs/010-order-invoice/spec.md

@@ -274,3 +274,18 @@
 - `invoicePrintCount` 仅统计当前开立身份的本系统打印申请;无记录返回0,不代表打印机实际成功出纸次数。重开发票不继承旧票计数。
 - 列表只批量查询当前已授权页的状态,不加载文件大字段、不生成文件、不调用第三方;详情在权限校验后补充。真正申请打印时仍执行现有鉴权、实时查票及补印确认。
 - 用户确认本轮只返回现有能力;载具转纸本另做,不变更现有打印资格、不修改 App、不新增数据库字段。
+
+
+## 2026-11-03 载具及捐赠打印范围确认
+
+本节以本轮用户最新决定为准,替代此前“载具转纸本另做”的待推进安排:ezPay 未提供组装证明联所需的真实码值时,不打印;本轮不继续开发载具转纸本。已捐赠发票不打印,也不新增捐赠交易明细打印功能。
+
+- 当前载具发票按 PrintFlag=N 开立;可查询发票基础信息不等于可打印。不得自行补造 BarCode、QRcodeL、QRcodeR 或只修改本地字段放开打印。
+- 订单列表和详情对真实载具发票、捐赠发票均返回 canPrintInvoice=false。无票、未开立、失败、作废或当前版式文件未就绪时也返回 false。
+- 仅现有纸本资格校验通过、当前发票身份的预生成文件 READY 时可返回 true;真正打印仍执行权限、供应商实时查询、码值与金额校验及首次/补印规则。
+- invoicePrintCount 继续表示当前发票的本系统打印申请次数;不能根据该次数或仅凭发票号推断可打印。
+- 按实际开票数据判断载具:当前 MEMBER 选择自2026-08-05起实际走无载具纸本,不因名称含“会员载具”而一律禁打。
+
+验收场景:载具票即使有发票号也返回 false;捐赠票返回 false;纸本票缺码导致预生成失败、无 READY 文件时返回 false;符合现有资格且文件 READY 的纸本票按次数进入首次/补印。
+
+依据:[ezPay 标准手册1.2.2,第22、52–53页](https://inv.ezpay.com.tw/dw_files/info_api/EZP_INVI_1_2_2.pdf)。码值仅在 PrintFlag=Y 时返回;已核查的公开手册未列出已开立载具转纸本接口,不将此认定为供应商所有渠道均不支持。

+ 8 - 0
specs/010-order-invoice/tasks.md

@@ -357,3 +357,11 @@ description: "Task list for 订单 ezPay 电子发票开立"
 验证:JDK21执行 `mvn -q -pl ruoyi-admin -am -Dtest=*Invoice*Test,OrderImGroupControllerTest,OrderListPaymentFilterTest -Dsurefire.failIfNoSpecifiedTests=false package`,49项测试通过,失败/错误/跳过均0,模块构建通过。日志 `ruoyi-admin/target/invoice-availability.log`。git diff --check通过。
 
 按用户确认仅返回现有能力,载具转纸本另做。无新增SQL、未修改App、未部署;未执行全项目回归、真实MySQL/ezPay或App/打印机联调,T075外部验收继续保留。按用户要求在 test-202609v2 提交推送。
+
+
+## 2026-11-03 载具及捐赠打印范围收口
+
+- [x] T081 核对 ezPay 标准手册1.2.2和当前源码,记录用户决定:载具发票无打印码值不打印、canPrintInvoice=false;已捐赠发票不打印;本轮不推进载具转纸本。
+- [x] T082 静态核对列表/详情打印资格 SQL、预生成资格与快照码值校验,更新现有 spec.md / plan.md;当前实现已符合本次范围,无生产代码或SQL改动。
+
+本轮只执行源码/既有测试源码阅读及文档差异检查;未运行 Maven、编译、测试或外部联调,未连接数据库、调用真实开票/转换接口、部署或创建新提交。既有生产实现提交为43f69b76;T075外部验收继续保留。

+ 9 - 0
specs/019-line-pay/plan.md

@@ -239,3 +239,12 @@ foodie-admin-vue/
 ## 2026-10-10 骑手闪送 Online 计划状态
 
 本增量的主流程及取消边界已经用户确认:任意站点取件后用户和平台均不能取消;取件前 LINE Pay 已付款取消自动全额退款,未知和迟到付款须核实并补偿。详见 rider-flash-online-spec.md。实施计划见 [rider-flash-online-plan.md](rider-flash-online-plan.md),对应任务 T061 至 T065,当前已按确认计划原地实施,进入最终审查及验证。计划覆盖支付与取消/取件并发保护、历史订单兼容、平台取消资格展示及关键业务注释;不创建分支,不执行数据库迁移,不自动提交。
+
+
+## 2026-11-03 Offline 分管账号归属修复计划
+
+现场只读复现:账号936是主商家801的有效分管账号,已授权门店168;订单991793695657521(数据库ID3254)为该店 MERCHANT 订单。订单详情成功、Offline status 返回归属错误。旧逻辑将 userType=5 走 InfoUser.storeId 判定,但该字段为空;现场尚无 LINE Pay 流水。
+
+1. 使用真实 MerchantStoreAccessService 和模拟 Mapper 构建回归,覆盖查询、付款复用、无流水、撤权/停用/门店转移及用户单拒绝;先证明旧实现拒绝有效子账号。
+2. 在 LinePayOfflineService 共用归属门禁中注入现有授权服务,增加子账号分支,以实时授权集合、主账号及订单门店归属校验;保持其他用户类型原逻辑。
+3. 运行 JDK21 定向权限、Offline 和商家订单回归及模块构建,检查增量差异。只读线上复现与数据库取证不执行付款、数据库写入或部署;发布后需复测现场接口。

+ 9 - 0
specs/019-line-pay/spec.md

@@ -663,3 +663,12 @@ Header: token
 - 实现中:用户已确认原地执行计划,骑手凭证、接单后付款、可信到账/退款事实、持久恢复及取件/取消门禁已写入工作区;平台页面及四语同步。
 - 定向验证记录在 tasks.md;App 只提供 [接入文档](../../docs/flash-delivery-line-pay-app-integration.md),不修改 App。
 - SQL 仅登记,真实 LINE/MySQL 并发及部署未验证;本增量未提交或推送。
+
+
+## 2026-11-03 Offline 分管账号订单权限修复
+
+- 商家分管账号(userType=5)的 Offline 付款及状态查询,必须通过022现有门店授权服务解析实时权限;不能使用可为空的 InfoUser.storeId 判断归属。
+- 有效分管账号仅可访问已授权且仍属于有效主账号的门店订单;订单 shId、门店 userId 须与解析出的主账号一致。撤权、分管账号/主账号停用、门店删除或转移后拒绝付款及状态读取。
+- 保留普通商家、夜市商家和摊主原归属规则,以及 MERCHANT 来源、单门店、付款资格和重复支付保护。
+- 授权校验通过但不存在 OFFLINE 支付流水时,沿用 line.pay.offline.attempt.not.found;不得推断为已支付或为了查询创建支付流水。
+- 验收:有效分管账号 storeId 为空时可查已存在的 OFFLINE 状态、可复用处理中流水;无流水返回明确的未发起提示;权限失效时在访问支付流水或网关前拒绝。

+ 15 - 0
specs/019-line-pay/tasks.md

@@ -181,3 +181,18 @@
 - 平台 `node --test tests/rider-line-pay.test.cjs tests/flash-delivery.test.cjs tests/flash-multi-pickup.test.cjs`:16/16;`npm run build:stage` 成功。新增字段/四语、SQL与原因型注释同步,diff/换行最终复核。
 - 追加平台全部现有测试 `node --test tests/*.cjs`:25/25,0 失败/跳过。两仓库 CRLF/BOM 保留及 diff --check 通过,未混入原有 voice 接入文档。
 - 当前读的锁定 SQL、旧快照行为及超过一批的公平恢复已测试;未运行真实 MySQL 多连接竞争、SQL 迁移、真实骑手 Sandbox/生产付款退款、浏览器/App 联调,不把本地替身测试等同上线验收。没有修改 App、云函数或其他支付资金流。
+
+
+## 2026-11-03 Offline 分管账号归属修复
+
+- [x] T066 只读复现现场错误,核对有效分管账号、门店授权与订单来源,确认旧单门店字段造成误拒绝;更新增量规格和计划。
+- [x] T067 添加分管账号权限回归并修复 LinePayOfflineService 共用归属校验,覆盖付款与状态查询。
+- [x] T068 使用 JDK21 运行定向权限/Offline/商家订单测试和模块构建,核对差异并记录验证边界。
+
+本轮验证(2026-11-03):
+
+- 修复前9项回归中4项未通过,均对应有效子账号被旧归属校验拒绝(含无流水错误类型);修复后新增11项权限回归全部通过。
+- 仅命令环境使用本机 graalvm-jdk-21.0.12+7.1,运行 `mvn -q -pl ruoyi-admin -am -Dtest=LinePayOfflineMerchantAccessTest,LinePayOfflineServiceTest,MerchantStoreAccessServiceTest,PosOrderShOprateControllerTest,LinePayOrderGuardTest,LinePayOfflinePersistenceContractTest -Dsurefire.failIfNoSpecifiedTests=false package`:6个测试类、66项,失败/错误/跳过均0,模块构建退出码0。
+- 日志:`ruoyi-admin/target/linepay-subaccount-red.log`、`ruoyi-admin/target/linepay-subaccount-green.log`。差异检查通过,生产文件保留CRLF;未改动此前010发票和039小票文档的工作区修改。
+- 现场仅GET查询与只读事务取证;未执行付款、退款、数据库写入或迁移。未运行全项目回归、真实网关付款和发布后App联调;上述验证阶段未提交或部署;后续提交信息以 Git 记录为准,发布后仍需复测。
+- 现场订单查询时尚无LINE Pay流水,发布后权限校验通过仍应提示尚未发起扫码付款,不能把此状态当成PAID。

+ 8 - 0
specs/022-merchant-store-subaccounts/plan.md

@@ -185,6 +185,14 @@ ruoyi-admin/src/test/java/com/ruoyi/app/order/DeliveryOrderNotificationServiceTe
 
 ## 复杂度说明
 
+### 2026-11-03 同类遗漏修复
+
+1. 用 `MerchantLegacyEntryAccessTest` 复现旧订单入口使用子账号 ID、餐桌列表遗漏范围、餐桌操作拒绝合法子账号等问题。
+2. `ShindexController` 的列表及导出复用 `MerchantStoreAccessService`,授权后解析主账号,摊位账号按有效绑定门店过滤;`TableQrcodeController` 的类型 1/5 分支复用同一服务,并恢复管理详情权限校验。
+3. 回归授权/拒绝/撤权、普通商家与原摊位列表、LINE Pay 原修复和商家订单路径;使用 JDK 21 定向测试并构建模块。不调用真实支付、不变更数据库。
+
+核查结论:发票查询/作废、新版订单详情和商家订单操作已由调用入口执行集中授权,不能只凭内部兼容分支判断有缺陷。支付设置和银行卡的子账号共享配置有 031 后续规格依据。
+
 | 复杂度 | 必要性 | 未采用的简单方案 |
 |---|---|---|
 | `info_user` 类型 5 + 关联表 | 同时复用认证并隔离平台商家统计,支持账号与店铺多对多 | 类型 1 加角色容易污染所有商家查询;单独账号表复制认证体系 |

+ 8 - 0
specs/022-merchant-store-subaccounts/spec.md

@@ -199,3 +199,11 @@
 - 不改变骑手、普通用户或平台管理后台的账号体系。
 - 不允许平台管理员修改分管账号店铺授权或重置分管账号密码。
 - 不改变订单、支付、退款或配送本身的业务状态流转,只增加门店范围授权和通知接收者路由。
+
+## 2026-11-03:旧订单与餐桌码入口补齐门店授权
+
+- `/index/getstoreorderlist`、`/index/export` 对普通商家及分管账号先检查请求门店属于实时可访问集合,再按主账号 `sh_id` 与目标 `md_id` 查询;不得用分管账号 ID 代替订单商家 ID。摊位账号校验请求门店等于有效绑定门店后仅按 `md_id` 查询,不能附加 `sh_id=摊位账号 ID`。
+- 餐桌码管理的列表、新增、详情、修改、删除、启停、二维码图片及关联订单,支持普通商家和分管账号的门店权限。读取及修改已有餐桌码以数据库中的 `store_id` 为准;新增以目标门店为准。分管账号不能管理无门店归属的夜市公用码。
+- 无授权门店时列表返回空集合,不能省略过滤条件;显式访问未授权门店时拒绝。非商家角色不得读取管理列表。授权撤销、账号停用、主账号停用以及门店转移/删除均通过现有集中权限服务实时生效。
+- 餐桌码的夜市及摊位账号沿用现有权限规则;顾客扫码 `/table-qrcode/scan` 行为不变。
+- 支付方式及银行卡遵循后续 `031-pay-method-config` 的主账号共享设置规格,不据此恢复旧的子账号禁用规则。

+ 9 - 0
specs/022-merchant-store-subaccounts/tasks.md

@@ -378,3 +378,12 @@ T005..T007 --------------------------> T012
 - [x] `MerchantTokenSessionService` 删除身份认证职责,只保留退出当前会话、撤销账号全部 App/PC 会话和在线状态查询。
 - [x] 运行认证切面、商家登录、店铺和订单定向测试,并使用 JDK 21 构建 `ruoyi-admin` 及依赖模块。
 - [x] 检查 `git diff --check`、暂存文件清单和测试结果,形成独立中文提交并推送当前分支。
+
+## T013 旧订单与餐桌码入口授权补漏(2026-11-03)
+
+- [x] 复现旧订单列表/导出错误使用子账号 ID,以及餐桌码列表与操作的授权遗漏。
+- [x] 旧订单列表和导出按实时授权门店及主账号 ID 查询;摊位账号按有效绑定门店查询。
+- [x] 餐桌码各管理入口接入普通商家/分管账号门店权限,恢复详情校验并防止无授权时查全量。
+- [x] 定向回归、JDK 21 模块构建及差异/换行检查,记录实际结果;不部署、不执行数据库变更。
+
+验证记录:首轮 `MerchantLegacyEntryAccessTest` 15 项全部复现失败;补充摊位场景后 19 项中 2 项复现摊位遗漏。最终 JDK 21 执行 `mvn -q -pl ruoyi-admin -am -Dtest=MerchantLegacyEntryAccessTest,TableQrcodeControllerTest,LinePayOfflineMerchantAccessTest,LinePayOfflineServiceTest,MerchantStoreAccessServiceTest,PosOrderShOprateControllerTest,LinePayOrderGuardTest,LinePayOfflinePersistenceContractTest -Dsurefire.failIfNoSpecifiedTests=false package`,退出码 0,86 项测试全部通过。日志为 `ruoyi-admin/target/merchant-legacy-access-green.log`;`git diff --check` 通过,变更文件保持 CRLF。未执行线上部署、真实支付/数据库变更或全量回归,提交状态以 Git 记录为准,尚未部署。