Jelajahi Sumber

实现连锁门店商品复制功能

新增菜单差异预览和事务复制服务。
补充门店权限、幂等、规格关联及覆盖边界测试。
提供商家端调用接口和四语言错误消息。
qmj 29 menit lalu
induk
melakukan
bac8de0b94
20 mengubah file dengan 1458 tambahan dan 2 penghapusan
  1. 20 0
      ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosFoodController.java
  2. 3 0
      ruoyi-admin/src/main/resources/i18n/messages.properties
  3. 3 0
      ruoyi-admin/src/main/resources/i18n/messages_en_US.properties
  4. 3 0
      ruoyi-admin/src/main/resources/i18n/messages_vi.properties
  5. 3 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
  6. 3 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties
  7. 109 0
      ruoyi-admin/src/test/java/com/ruoyi/app/mendian/StoreMenuCopyControllerTest.java
  8. 11 0
      ruoyi-system/src/main/java/com/ruoyi/system/dto/menu/StoreMenuCopyExecuteRequest.java
  9. 10 0
      ruoyi-system/src/main/java/com/ruoyi/system/dto/menu/StoreMenuCopyPreviewRequest.java
  10. 37 0
      ruoyi-system/src/main/java/com/ruoyi/system/dto/menu/StoreMenuCopyPreviewResponse.java
  11. 12 0
      ruoyi-system/src/main/java/com/ruoyi/system/dto/menu/StoreMenuCopyResult.java
  12. 8 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosFoodMapper.java
  13. 13 0
      ruoyi-system/src/main/java/com/ruoyi/system/service/StoreMenuCopyService.java
  14. 524 0
      ruoyi-system/src/main/java/com/ruoyi/system/service/impl/StoreMenuCopyServiceImpl.java
  15. 11 0
      ruoyi-system/src/main/resources/mapper/chanting/PosFoodMapper.xml
  16. 24 0
      ruoyi-system/src/test/java/com/ruoyi/system/mapper/PosFoodMapperSqlTest.java
  17. 496 0
      ruoyi-system/src/test/java/com/ruoyi/system/service/StoreMenuCopyServiceTest.java
  18. 129 0
      specs/026-store-menu-copy/plan.md
  19. 2 2
      specs/026-store-menu-copy/spec.md
  20. 37 0
      specs/026-store-menu-copy/tasks.md

+ 20 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosFoodController.java

@@ -19,6 +19,8 @@ import com.ruoyi.common.utils.MessageUtils;
 import com.ruoyi.common.utils.StringUtils;
 import com.ruoyi.common.utils.poi.ExcelUtil;
 import com.ruoyi.system.domain.*;
+import com.ruoyi.system.dto.menu.StoreMenuCopyExecuteRequest;
+import com.ruoyi.system.dto.menu.StoreMenuCopyPreviewRequest;
 import com.ruoyi.system.mapper.PosStoreMapper;
 import com.ruoyi.system.service.*;
 import com.ruoyi.system.utils.Auth;
@@ -77,6 +79,8 @@ public class PosFoodController extends BaseController {
     private OrderInvoiceService orderInvoiceService;
     @Autowired
     private MerchantStoreAccessService merchantStoreAccessService;
+    @Autowired
+    private StoreMenuCopyService storeMenuCopyService;
     //删除商品
     @Anonymous
     @Auth(session = true)
@@ -120,6 +124,22 @@ public class PosFoodController extends BaseController {
         return success();
     }
 
+    @Anonymous
+    @Auth(session = true)
+    @PostMapping("/menu-copy/preview")
+    public AjaxResult menuCopyPreview(@RequestHeader String token,
+                                      @RequestBody StoreMenuCopyPreviewRequest request) {
+        return success(storeMenuCopyService.preview(AuthContext.requireUserId(), request));
+    }
+
+    @Anonymous
+    @Auth(session = true)
+    @PostMapping("/menu-copy/execute")
+    public AjaxResult menuCopyExecute(@RequestHeader String token,
+                                      @RequestBody StoreMenuCopyExecuteRequest request) {
+        return success(storeMenuCopyService.execute(AuthContext.requireUserId(), request));
+    }
+
     /**
      * (h5页面使用)通过分类查询商品列表
      */

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

@@ -271,6 +271,9 @@ merchant.owner.unavailable=Merchant owner account is unavailable
 merchant.owner.required=Only the merchant owner account can perform this operation
 merchant.owner.not.found=Merchant owner account was not found
 merchant.store.access.denied=You do not have permission to access this store
+menu.copy.store.required=Source and target stores are required
+menu.copy.store.same=Source and target stores must be different
+menu.copy.strategy.invalid=Invalid conflict strategy
 merchant.order.not.found=Order was not found
 merchant.food.not.found=Product was not found
 merchant.session.invalid=Merchant session is invalid, please sign in again

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

@@ -274,6 +274,9 @@ merchant.owner.unavailable=Merchant owner account is unavailable
 merchant.owner.required=Only the merchant owner account can perform this operation
 merchant.owner.not.found=Merchant owner account was not found
 merchant.store.access.denied=You do not have permission to access this store
+menu.copy.store.required=Source and target stores are required
+menu.copy.store.same=Source and target stores must be different
+menu.copy.strategy.invalid=Invalid conflict strategy
 merchant.order.not.found=Order was not found
 merchant.food.not.found=Product was not found
 merchant.session.invalid=Merchant session is invalid, please sign in again

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

@@ -274,6 +274,9 @@ merchant.owner.unavailable=Tài khoản chủ cửa hàng không khả dụng
 merchant.owner.required=Chỉ tài khoản chủ cửa hàng mới được thực hiện thao tác này
 merchant.owner.not.found=Không tìm thấy tài khoản chủ cửa hàng
 merchant.store.access.denied=Bạn không có quyền truy cập cửa hàng này
+menu.copy.store.required=Cửa hàng nguồn và cửa hàng đích là bắt buộc
+menu.copy.store.same=Cửa hàng nguồn và cửa hàng đích phải khác nhau
+menu.copy.strategy.invalid=Cách xử lý xung đột không hợp lệ
 merchant.order.not.found=Không tìm thấy đơn hàng
 merchant.food.not.found=Không tìm thấy sản phẩm
 merchant.session.invalid=Phiên đăng nhập cửa hàng đã hết hiệu lực, vui lòng đăng nhập lại

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

@@ -275,6 +275,9 @@ merchant.owner.unavailable=所属商家主账号不可用
 merchant.owner.required=仅商家主账号可执行此操作
 merchant.owner.not.found=商家主账号不存在
 merchant.store.access.denied=无权访问该店铺
+menu.copy.store.required=源门店和目标门店不能为空
+menu.copy.store.same=源门店和目标门店不能相同
+menu.copy.strategy.invalid=差异商品处理方式无效
 merchant.order.not.found=订单不存在
 merchant.food.not.found=商品不存在
 merchant.session.invalid=商家登录已失效,请重新登录

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

@@ -275,6 +275,9 @@ merchant.owner.unavailable=所屬商家主帳號不可用
 merchant.owner.required=僅商家主帳號可執行此操作
 merchant.owner.not.found=商家主帳號不存在
 merchant.store.access.denied=無權存取該店鋪
+menu.copy.store.required=來源門店和目標門店不能為空
+menu.copy.store.same=來源門店和目標門店不能相同
+menu.copy.strategy.invalid=差異商品處理方式無效
 merchant.order.not.found=訂單不存在
 merchant.food.not.found=商品不存在
 merchant.session.invalid=商家登入已失效,請重新登入

+ 109 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/mendian/StoreMenuCopyControllerTest.java

@@ -0,0 +1,109 @@
+package com.ruoyi.app.mendian;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Method;
+
+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 org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestHeader;
+
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.dto.menu.StoreMenuCopyExecuteRequest;
+import com.ruoyi.system.dto.menu.StoreMenuCopyPreviewRequest;
+import com.ruoyi.system.dto.menu.StoreMenuCopyPreviewResponse;
+import com.ruoyi.system.dto.menu.StoreMenuCopyResult;
+import com.ruoyi.system.service.StoreMenuCopyService;
+import com.ruoyi.system.utils.AuthContext;
+
+class StoreMenuCopyControllerTest
+{
+    private PosFoodController controller;
+    private StoreMenuCopyService service;
+    private MockedStatic<MessageUtils> messages;
+    private MockedStatic<AuthContext> authContext;
+
+    @BeforeEach
+    void setUp()
+    {
+        messages = mockStatic(MessageUtils.class);
+        messages.when(() -> MessageUtils.message(org.mockito.ArgumentMatchers.anyString()))
+                .thenAnswer(invocation -> invocation.getArgument(0));
+        authContext = mockStatic(AuthContext.class);
+        authContext.when(AuthContext::requireUserId).thenReturn(9L);
+        controller = new PosFoodController();
+        service = mock(StoreMenuCopyService.class);
+        ReflectionTestUtils.setField(controller, "storeMenuCopyService", service);
+    }
+
+    @AfterEach
+    void tearDown()
+    {
+        authContext.close();
+        messages.close();
+    }
+
+    @Test
+    void endpointsRequireTokenHeaderAndExplicitRequestBodies() throws Exception
+    {
+        assertRequestContract("menuCopyPreview", StoreMenuCopyPreviewRequest.class);
+        assertRequestContract("menuCopyExecute", StoreMenuCopyExecuteRequest.class);
+    }
+
+    @Test
+    void previewUsesAuthenticatedUserAndReturnsServiceResult()
+    {
+        StoreMenuCopyPreviewRequest request = new StoreMenuCopyPreviewRequest();
+        StoreMenuCopyPreviewResponse expected = new StoreMenuCopyPreviewResponse();
+        when(service.preview(9L, request)).thenReturn(expected);
+
+        AjaxResult response = controller.menuCopyPreview("token", request);
+
+        assertEquals(expected, response.get(AjaxResult.DATA_TAG));
+        verify(service).preview(9L, request);
+    }
+
+    @Test
+    void executeUsesAuthenticatedUserAndReturnsServiceResult()
+    {
+        StoreMenuCopyExecuteRequest request = new StoreMenuCopyExecuteRequest();
+        StoreMenuCopyResult expected = new StoreMenuCopyResult();
+        when(service.execute(9L, request)).thenReturn(expected);
+
+        AjaxResult response = controller.menuCopyExecute("token", request);
+
+        assertEquals(expected, response.get(AjaxResult.DATA_TAG));
+        verify(service).execute(9L, request);
+    }
+
+    private void assertRequestContract(String methodName, Class<?> bodyType) throws Exception
+    {
+        Method method = PosFoodController.class.getMethod(methodName, String.class, bodyType);
+        Annotation[][] annotations = method.getParameterAnnotations();
+        assertNotNull(findAnnotation(annotations[0], RequestHeader.class));
+        assertNotNull(findAnnotation(annotations[1], RequestBody.class));
+    }
+
+    private <T extends Annotation> T findAnnotation(Annotation[] annotations, Class<T> type)
+    {
+        for (Annotation annotation : annotations)
+        {
+            if (type.isInstance(annotation))
+            {
+                return type.cast(annotation);
+            }
+        }
+        return null;
+    }
+}

+ 11 - 0
ruoyi-system/src/main/java/com/ruoyi/system/dto/menu/StoreMenuCopyExecuteRequest.java

@@ -0,0 +1,11 @@
+package com.ruoyi.system.dto.menu;
+
+import lombok.Data;
+
+@Data
+public class StoreMenuCopyExecuteRequest
+{
+    private Long sourceStoreId;
+    private Long targetStoreId;
+    private String conflictStrategy;
+}

+ 10 - 0
ruoyi-system/src/main/java/com/ruoyi/system/dto/menu/StoreMenuCopyPreviewRequest.java

@@ -0,0 +1,10 @@
+package com.ruoyi.system.dto.menu;
+
+import lombok.Data;
+
+@Data
+public class StoreMenuCopyPreviewRequest
+{
+    private Long sourceStoreId;
+    private Long targetStoreId;
+}

+ 37 - 0
ruoyi-system/src/main/java/com/ruoyi/system/dto/menu/StoreMenuCopyPreviewResponse.java

@@ -0,0 +1,37 @@
+package com.ruoyi.system.dto.menu;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import lombok.Data;
+
+@Data
+public class StoreMenuCopyPreviewResponse
+{
+    private Long sourceStoreId;
+    private Long targetStoreId;
+    private int addedCategoryCount;
+    private int mergedCategoryCount;
+    private int addedProductCount;
+    private int identicalProductCount;
+    private int differentProductCount;
+    private List<ProductDifference> differences = new ArrayList<>();
+
+    @Data
+    public static class ProductDifference
+    {
+        private Long sourceProductId;
+        private Long targetProductId;
+        private String name;
+        private String language;
+        private List<FieldDifference> fields = new ArrayList<>();
+    }
+
+    @Data
+    public static class FieldDifference
+    {
+        private String field;
+        private String sourceValue;
+        private String targetValue;
+    }
+}

+ 12 - 0
ruoyi-system/src/main/java/com/ruoyi/system/dto/menu/StoreMenuCopyResult.java

@@ -0,0 +1,12 @@
+package com.ruoyi.system.dto.menu;
+
+import lombok.Data;
+
+@Data
+public class StoreMenuCopyResult
+{
+    private int addedCategoryCount;
+    private int addedProductCount;
+    private int skippedProductCount;
+    private int overwrittenProductCount;
+}

+ 8 - 0
ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosFoodMapper.java

@@ -49,6 +49,14 @@ public interface PosFoodMapper  extends BaseMapper<PosFood>
      */
     public int updatePosFood(PosFood posFood);
 
+    /**
+     * 菜单复制覆盖商品内容,不修改目标商品上下架和审核状态。
+     *
+     * @param posFood 目标商品ID与源商品内容字段
+     * @return 结果
+     */
+    public int updateMenuCopyContent(PosFood posFood);
+
     /**
      * 删除food
      * 

+ 13 - 0
ruoyi-system/src/main/java/com/ruoyi/system/service/StoreMenuCopyService.java

@@ -0,0 +1,13 @@
+package com.ruoyi.system.service;
+
+import com.ruoyi.system.dto.menu.StoreMenuCopyExecuteRequest;
+import com.ruoyi.system.dto.menu.StoreMenuCopyPreviewRequest;
+import com.ruoyi.system.dto.menu.StoreMenuCopyPreviewResponse;
+import com.ruoyi.system.dto.menu.StoreMenuCopyResult;
+
+public interface StoreMenuCopyService
+{
+    StoreMenuCopyPreviewResponse preview(Long loginUserId, StoreMenuCopyPreviewRequest request);
+
+    StoreMenuCopyResult execute(Long loginUserId, StoreMenuCopyExecuteRequest request);
+}

+ 524 - 0
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/StoreMenuCopyServiceImpl.java

@@ -0,0 +1,524 @@
+package com.ruoyi.system.service.impl;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.FoodSpecRelation;
+import com.ruoyi.system.domain.FoodSpecs;
+import com.ruoyi.system.domain.FoodSpecsValue;
+import com.ruoyi.system.domain.PosFenlei;
+import com.ruoyi.system.domain.PosFood;
+import com.ruoyi.system.dto.menu.StoreMenuCopyExecuteRequest;
+import com.ruoyi.system.dto.menu.StoreMenuCopyPreviewRequest;
+import com.ruoyi.system.dto.menu.StoreMenuCopyPreviewResponse;
+import com.ruoyi.system.dto.menu.StoreMenuCopyPreviewResponse.FieldDifference;
+import com.ruoyi.system.dto.menu.StoreMenuCopyPreviewResponse.ProductDifference;
+import com.ruoyi.system.dto.menu.StoreMenuCopyResult;
+import com.ruoyi.system.mapper.FoodSpecRelationMapper;
+import com.ruoyi.system.mapper.FoodSpecsMapper;
+import com.ruoyi.system.mapper.FoodSpecsValueMapper;
+import com.ruoyi.system.mapper.PosFenleiMapper;
+import com.ruoyi.system.mapper.PosFoodMapper;
+import com.ruoyi.system.service.MerchantStoreAccessService;
+import com.ruoyi.system.service.StoreMenuCopyService;
+
+@Service
+public class StoreMenuCopyServiceImpl implements StoreMenuCopyService
+{
+    public static final String KEEP_TARGET = "KEEP_TARGET";
+    public static final String OVERWRITE_TARGET = "OVERWRITE_TARGET";
+
+    private final MerchantStoreAccessService accessService;
+    private final PosFenleiMapper categoryMapper;
+    private final PosFoodMapper foodMapper;
+    private final FoodSpecsMapper specsMapper;
+    private final FoodSpecsValueMapper specsValueMapper;
+    private final FoodSpecRelationMapper relationMapper;
+
+    public StoreMenuCopyServiceImpl(MerchantStoreAccessService accessService,
+                                    PosFenleiMapper categoryMapper,
+                                    PosFoodMapper foodMapper,
+                                    FoodSpecsMapper specsMapper,
+                                    FoodSpecsValueMapper specsValueMapper,
+                                    FoodSpecRelationMapper relationMapper)
+    {
+        this.accessService = accessService;
+        this.categoryMapper = categoryMapper;
+        this.foodMapper = foodMapper;
+        this.specsMapper = specsMapper;
+        this.specsValueMapper = specsValueMapper;
+        this.relationMapper = relationMapper;
+    }
+
+    @Override
+    public StoreMenuCopyPreviewResponse preview(Long loginUserId, StoreMenuCopyPreviewRequest request)
+    {
+        StorePair stores = validateStores(loginUserId,
+                request == null ? null : request.getSourceStoreId(),
+                request == null ? null : request.getTargetStoreId());
+        return buildPreview(loadSnapshot(stores), stores);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public StoreMenuCopyResult execute(Long loginUserId, StoreMenuCopyExecuteRequest request)
+    {
+        String strategy = normalizeStrategy(request == null ? null : request.getConflictStrategy());
+        StorePair stores = validateStores(loginUserId,
+                request == null ? null : request.getSourceStoreId(),
+                request == null ? null : request.getTargetStoreId());
+        MenuSnapshot snapshot = loadSnapshot(stores);
+        return copyMenu(snapshot, stores, strategy);
+    }
+
+    private StorePair validateStores(Long loginUserId, Long sourceStoreId, Long targetStoreId)
+    {
+        if (sourceStoreId == null || targetStoreId == null)
+        {
+            throw businessError("menu.copy.store.required");
+        }
+        if (sourceStoreId.equals(targetStoreId))
+        {
+            throw businessError("menu.copy.store.same");
+        }
+        Set<Long> accessibleStoreIds = accessService.getAccessibleStoreIds(loginUserId);
+        if (accessibleStoreIds == null
+                || !accessibleStoreIds.contains(sourceStoreId)
+                || !accessibleStoreIds.contains(targetStoreId))
+        {
+            throw businessError("merchant.store.access.denied");
+        }
+        return new StorePair(sourceStoreId, targetStoreId);
+    }
+
+    private String normalizeStrategy(String strategy)
+    {
+        if (strategy == null || strategy.isBlank())
+        {
+            return KEEP_TARGET;
+        }
+        String normalized = strategy.trim().toUpperCase(Locale.ROOT);
+        if (!KEEP_TARGET.equals(normalized) && !OVERWRITE_TARGET.equals(normalized))
+        {
+            throw businessError("menu.copy.strategy.invalid");
+        }
+        return normalized;
+    }
+
+    private MenuSnapshot loadSnapshot(StorePair stores)
+    {
+        List<Long> storeIds = List.of(stores.sourceStoreId(), stores.targetStoreId());
+        List<PosFenlei> categories = safeList(categoryMapper.selectList(
+                new LambdaQueryWrapper<PosFenlei>()
+                        .in(PosFenlei::getMendid, storeIds)
+                        .orderByAsc(PosFenlei::getId)));
+        List<PosFood> foods = safeList(foodMapper.selectList(
+                new LambdaQueryWrapper<PosFood>()
+                        .in(PosFood::getMdid, storeIds)
+                        .orderByAsc(PosFood::getId)));
+        List<FoodSpecs> specs = safeList(specsMapper.selectList(
+                new LambdaQueryWrapper<FoodSpecs>()
+                        .in(FoodSpecs::getMdId, storeIds)
+                        .orderByAsc(FoodSpecs::getId)));
+
+        List<Long> foodIds = foods.stream().map(PosFood::getId).filter(Objects::nonNull).toList();
+        List<FoodSpecRelation> relations = foodIds.isEmpty() ? Collections.emptyList()
+                : safeList(relationMapper.selectList(new LambdaQueryWrapper<FoodSpecRelation>()
+                        .in(FoodSpecRelation::getFoodId, foodIds)));
+        List<Long> specsIds = specs.stream().map(FoodSpecs::getId).filter(Objects::nonNull).toList();
+        List<FoodSpecsValue> values = specsIds.isEmpty() ? Collections.emptyList()
+                : safeList(specsValueMapper.selectList(new LambdaQueryWrapper<FoodSpecsValue>()
+                        .in(FoodSpecsValue::getParentId, specsIds)
+                        .orderByAsc(FoodSpecsValue::getId)));
+        return new MenuSnapshot(categories, foods, specs, values, relations);
+    }
+
+    private StoreMenuCopyPreviewResponse buildPreview(MenuSnapshot snapshot, StorePair stores)
+    {
+        List<PosFenlei> sourceCategories = snapshot.categories(stores.sourceStoreId());
+        Map<NameLanguageKey, PosFenlei> targetCategories = firstByKey(
+                snapshot.categories(stores.targetStoreId()), this::categoryKey);
+        Map<NameLanguageKey, PosFenlei> uniqueSourceCategories = firstByKey(sourceCategories, this::categoryKey);
+
+        StoreMenuCopyPreviewResponse response = new StoreMenuCopyPreviewResponse();
+        response.setSourceStoreId(stores.sourceStoreId());
+        response.setTargetStoreId(stores.targetStoreId());
+        for (NameLanguageKey key : uniqueSourceCategories.keySet())
+        {
+            if (targetCategories.containsKey(key))
+            {
+                response.setMergedCategoryCount(response.getMergedCategoryCount() + 1);
+            }
+            else
+            {
+                response.setAddedCategoryCount(response.getAddedCategoryCount() + 1);
+            }
+        }
+
+        Map<NameLanguageKey, PosFood> targetFoods = firstByKey(
+                snapshot.foods(stores.targetStoreId()), this::foodKey);
+        for (PosFood sourceFood : snapshot.foods(stores.sourceStoreId()))
+        {
+            PosFood targetFood = targetFoods.get(foodKey(sourceFood));
+            if (targetFood == null)
+            {
+                response.setAddedProductCount(response.getAddedProductCount() + 1);
+                continue;
+            }
+            List<FieldDifference> fields = compareFood(sourceFood, targetFood, snapshot);
+            if (fields.isEmpty())
+            {
+                response.setIdenticalProductCount(response.getIdenticalProductCount() + 1);
+            }
+            else
+            {
+                response.setDifferentProductCount(response.getDifferentProductCount() + 1);
+                ProductDifference difference = new ProductDifference();
+                difference.setSourceProductId(sourceFood.getId());
+                difference.setTargetProductId(targetFood.getId());
+                difference.setName(sourceFood.getName());
+                difference.setLanguage(sourceFood.getLanguage());
+                difference.setFields(fields);
+                response.getDifferences().add(difference);
+            }
+        }
+        return response;
+    }
+
+    private StoreMenuCopyResult copyMenu(MenuSnapshot snapshot, StorePair stores, String strategy)
+    {
+        StoreMenuCopyResult result = new StoreMenuCopyResult();
+        Map<NameLanguageKey, PosFenlei> targetCategories = firstByKey(
+                snapshot.categories(stores.targetStoreId()), this::categoryKey);
+        Map<Long, Long> sourceToTargetCategory = new LinkedHashMap<>();
+        for (PosFenlei sourceCategory : snapshot.categories(stores.sourceStoreId()))
+        {
+            NameLanguageKey key = categoryKey(sourceCategory);
+            PosFenlei targetCategory = targetCategories.get(key);
+            if (targetCategory == null)
+            {
+                targetCategory = copyCategory(sourceCategory, stores.targetStoreId());
+                categoryMapper.insert(targetCategory);
+                targetCategories.put(key, targetCategory);
+                result.setAddedCategoryCount(result.getAddedCategoryCount() + 1);
+            }
+            sourceToTargetCategory.putIfAbsent(sourceCategory.getId(), targetCategory.getId());
+        }
+
+        Map<NameLanguageKey, PosFood> targetFoods = firstByKey(
+                snapshot.foods(stores.targetStoreId()), this::foodKey);
+        Map<NameLanguageKey, FoodSpecs> targetSpecs = firstByKey(
+                snapshot.specs(stores.targetStoreId()), this::specKey);
+        for (PosFood sourceFood : snapshot.foods(stores.sourceStoreId()))
+        {
+            NameLanguageKey key = foodKey(sourceFood);
+            PosFood targetFood = targetFoods.get(key);
+            if (targetFood == null)
+            {
+                PosFood inserted = copyFood(sourceFood, stores.targetStoreId(),
+                        sourceToTargetCategory.get(sourceFood.getFlId()), true);
+                foodMapper.insert(inserted);
+                alignRelations(inserted.getId(), sourceFood, snapshot, stores.targetStoreId(), targetSpecs, false);
+                targetFoods.put(key, inserted);
+                result.setAddedProductCount(result.getAddedProductCount() + 1);
+                continue;
+            }
+
+            if (compareFood(sourceFood, targetFood, snapshot).isEmpty())
+            {
+                result.setSkippedProductCount(result.getSkippedProductCount() + 1);
+                continue;
+            }
+            if (KEEP_TARGET.equals(strategy))
+            {
+                result.setSkippedProductCount(result.getSkippedProductCount() + 1);
+                continue;
+            }
+
+            foodMapper.updateMenuCopyContent(copyFood(sourceFood, targetFood.getMdid(), null, false, targetFood.getId()));
+            alignRelations(targetFood.getId(), sourceFood, snapshot, stores.targetStoreId(), targetSpecs, true);
+            result.setOverwrittenProductCount(result.getOverwrittenProductCount() + 1);
+        }
+        return result;
+    }
+
+    private void alignRelations(Long targetFoodId, PosFood sourceFood, MenuSnapshot snapshot,
+                                Long targetStoreId, Map<NameLanguageKey, FoodSpecs> targetSpecs,
+                                boolean replaceExisting)
+    {
+        List<FoodSpecs> sourceSpecs = snapshot.specsForFood(sourceFood.getId());
+        List<Long> targetSpecsIds = new ArrayList<>();
+        for (FoodSpecs sourceSpec : sourceSpecs)
+        {
+            NameLanguageKey key = specKey(sourceSpec);
+            FoodSpecs targetSpec = targetSpecs.get(key);
+            if (targetSpec == null)
+            {
+                targetSpec = copySpecs(sourceSpec, targetStoreId);
+                specsMapper.insert(targetSpec);
+                for (FoodSpecsValue sourceValue : snapshot.valuesForSpec(sourceSpec.getId()))
+                {
+                    specsValueMapper.insert(copySpecValue(sourceValue, targetSpec.getId()));
+                }
+                targetSpecs.put(key, targetSpec);
+            }
+            targetSpecsIds.add(targetSpec.getId());
+        }
+        if (replaceExisting)
+        {
+            relationMapper.delete(new LambdaQueryWrapper<FoodSpecRelation>()
+                    .eq(FoodSpecRelation::getFoodId, targetFoodId));
+        }
+        for (Long targetSpecsId : new LinkedHashSet<>(targetSpecsIds))
+        {
+            FoodSpecRelation relation = new FoodSpecRelation();
+            relation.setFoodId(targetFoodId);
+            relation.setSpecsId(targetSpecsId);
+            relationMapper.insert(relation);
+        }
+    }
+
+    private List<FieldDifference> compareFood(PosFood source, PosFood target, MenuSnapshot snapshot)
+    {
+        List<FieldDifference> differences = new ArrayList<>();
+        addDifference(differences, "price", amountValue(source.getPrice()), amountValue(target.getPrice()));
+        addDifference(differences, "image", source.getImage(), target.getImage());
+        addDifference(differences, "introduce", source.getIntroduce(), target.getIntroduce());
+        addDifference(differences, "foodSku", source.getFoodSku(), target.getFoodSku());
+        addDifference(differences, "sort", stringValue(source.getSort()), stringValue(target.getSort()));
+        addDifference(differences, "recommend", stringValue(source.getRecommend()), stringValue(target.getRecommend()));
+        addDifference(differences, "specs", specsValue(source.getId(), snapshot), specsValue(target.getId(), snapshot));
+        return differences;
+    }
+
+    private void addDifference(List<FieldDifference> differences, String field,
+                               String sourceValue, String targetValue)
+    {
+        if (Objects.equals(sourceValue, targetValue))
+        {
+            return;
+        }
+        FieldDifference difference = new FieldDifference();
+        difference.setField(field);
+        difference.setSourceValue(sourceValue);
+        difference.setTargetValue(targetValue);
+        differences.add(difference);
+    }
+
+    private String specsValue(Long foodId, MenuSnapshot snapshot)
+    {
+        return snapshot.specsForFood(foodId).stream()
+                .map(this::specKey)
+                .map(NameLanguageKey::display)
+                .sorted()
+                .collect(Collectors.joining(","));
+    }
+
+    private PosFenlei copyCategory(PosFenlei source, Long targetStoreId)
+    {
+        PosFenlei copy = new PosFenlei();
+        copy.setMendid(targetStoreId);
+        copy.setName(source.getName());
+        copy.setImg(source.getImg());
+        copy.setSort(source.getSort());
+        copy.setLanguage(source.getLanguage());
+        return copy;
+    }
+
+    private PosFood copyFood(PosFood source, Long targetStoreId, Long targetCategoryId, boolean includeStatus)
+    {
+        return copyFood(source, targetStoreId, targetCategoryId, includeStatus, null);
+    }
+
+    private PosFood copyFood(PosFood source, Long targetStoreId, Long targetCategoryId,
+                             boolean includeStatus, Long targetFoodId)
+    {
+        PosFood copy = new PosFood();
+        copy.setId(targetFoodId);
+        if (targetCategoryId != null)
+        {
+            copy.setFlId(targetCategoryId);
+        }
+        copy.setMdid(targetStoreId);
+        copy.setName(source.getName());
+        copy.setLanguage(source.getLanguage());
+        copy.setPrice(source.getPrice());
+        copy.setImage(source.getImage());
+        copy.setIntroduce(source.getIntroduce());
+        copy.setFoodSku(source.getFoodSku());
+        copy.setSort(source.getSort());
+        copy.setRecommend(source.getRecommend());
+        if (includeStatus)
+        {
+            copy.setStackingUp(source.getStackingUp());
+            copy.setToExamine(source.getToExamine());
+        }
+        return copy;
+    }
+
+    private FoodSpecs copySpecs(FoodSpecs source, Long targetStoreId)
+    {
+        FoodSpecs copy = new FoodSpecs();
+        copy.setTitle(source.getTitle());
+        copy.setType(source.getType());
+        copy.setState(source.getState());
+        copy.setLanguage(source.getLanguage());
+        copy.setRemark(source.getRemark());
+        copy.setMdId(targetStoreId);
+        copy.setSort(source.getSort());
+        copy.setIsOpen(source.getIsOpen());
+        copy.setIsDelete(source.getIsDelete());
+        return copy;
+    }
+
+    private FoodSpecsValue copySpecValue(FoodSpecsValue source, Long targetSpecsId)
+    {
+        FoodSpecsValue copy = new FoodSpecsValue();
+        copy.setParentId(targetSpecsId);
+        copy.setName(source.getName());
+        copy.setPrice(source.getPrice());
+        copy.setNote(source.getNote());
+        copy.setState(source.getState());
+        copy.setIsOpen(source.getIsOpen());
+        return copy;
+    }
+
+    private NameLanguageKey categoryKey(PosFenlei category)
+    {
+        return new NameLanguageKey(category.getName(), category.getLanguage());
+    }
+
+    private NameLanguageKey foodKey(PosFood food)
+    {
+        return new NameLanguageKey(food.getName(), food.getLanguage());
+    }
+
+    private NameLanguageKey specKey(FoodSpecs specs)
+    {
+        return new NameLanguageKey(specs.getTitle(), specs.getLanguage());
+    }
+
+    private <T> Map<NameLanguageKey, T> firstByKey(Collection<T> values,
+                                                   Function<T, NameLanguageKey> keyFunction)
+    {
+        Map<NameLanguageKey, T> result = new LinkedHashMap<>();
+        for (T value : values)
+        {
+            result.putIfAbsent(keyFunction.apply(value), value);
+        }
+        return result;
+    }
+
+    private String amountValue(BigDecimal value)
+    {
+        return value == null ? null : value.stripTrailingZeros().toPlainString();
+    }
+
+    private String stringValue(Object value)
+    {
+        return value == null ? null : value.toString();
+    }
+
+    private <T> List<T> safeList(List<T> values)
+    {
+        return values == null ? Collections.emptyList() : values;
+    }
+
+    private ServiceException businessError(String messageKey)
+    {
+        return new ServiceException(MessageUtils.message(messageKey));
+    }
+
+    private record StorePair(Long sourceStoreId, Long targetStoreId)
+    {
+    }
+
+    private record NameLanguageKey(String name, String language)
+    {
+        private String display()
+        {
+            return string(name) + "|" + string(language);
+        }
+
+        private static String string(String value)
+        {
+            return value == null ? "" : value;
+        }
+    }
+
+    private static final class MenuSnapshot
+    {
+        private final List<PosFenlei> categories;
+        private final List<PosFood> foods;
+        private final List<FoodSpecs> specs;
+        private final Map<Long, List<FoodSpecsValue>> valuesBySpecId;
+        private final Map<Long, List<Long>> specsIdsByFoodId;
+        private final Map<Long, FoodSpecs> specsById;
+
+        private MenuSnapshot(List<PosFenlei> categories, List<PosFood> foods,
+                             List<FoodSpecs> specs, List<FoodSpecsValue> values,
+                             List<FoodSpecRelation> relations)
+        {
+            this.categories = categories;
+            this.foods = foods;
+            this.specs = specs;
+            this.valuesBySpecId = values.stream()
+                    .collect(Collectors.groupingBy(FoodSpecsValue::getParentId, LinkedHashMap::new,
+                            Collectors.toList()));
+            this.specsIdsByFoodId = relations.stream()
+                    .collect(Collectors.groupingBy(FoodSpecRelation::getFoodId, LinkedHashMap::new,
+                            Collectors.mapping(FoodSpecRelation::getSpecsId, Collectors.toList())));
+            this.specsById = specs.stream()
+                    .filter(spec -> spec.getId() != null)
+                    .collect(Collectors.toMap(FoodSpecs::getId, Function.identity(), (left, right) -> left,
+                            LinkedHashMap::new));
+        }
+
+        private List<PosFenlei> categories(Long storeId)
+        {
+            return categories.stream().filter(value -> storeId.equals(value.getMendid())).toList();
+        }
+
+        private List<PosFood> foods(Long storeId)
+        {
+            return foods.stream().filter(value -> storeId.equals(value.getMdid())).toList();
+        }
+
+        private List<FoodSpecs> specs(Long storeId)
+        {
+            return specs.stream().filter(value -> storeId.equals(value.getMdId())).toList();
+        }
+
+        private List<FoodSpecsValue> valuesForSpec(Long specsId)
+        {
+            return valuesBySpecId.getOrDefault(specsId, Collections.emptyList());
+        }
+
+        private List<FoodSpecs> specsForFood(Long foodId)
+        {
+            return specsIdsByFoodId.getOrDefault(foodId, Collections.emptyList()).stream()
+                    .map(specsById::get)
+                    .filter(Objects::nonNull)
+                    .sorted(Comparator.comparing(FoodSpecs::getId))
+                    .toList();
+        }
+    }
+}

+ 11 - 0
ruoyi-system/src/main/resources/mapper/chanting/PosFoodMapper.xml

@@ -91,6 +91,17 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         where id = #{id}
     </update>
 
+    <update id="updateMenuCopyContent" parameterType="PosFood">
+        update pos_food
+        set price = #{price},
+            image = #{image},
+            introduce = #{introduce},
+            food_sku = #{foodSku},
+            recommend = #{recommend},
+            sort = #{sort}
+        where id = #{id}
+    </update>
+
     <delete id="deletePosFoodById" parameterType="Long">
         delete from pos_food where id = #{id}
     </delete>

+ 24 - 0
ruoyi-system/src/test/java/com/ruoyi/system/mapper/PosFoodMapperSqlTest.java

@@ -1,15 +1,20 @@
 package com.ruoyi.system.mapper;
 
 import org.apache.ibatis.mapping.BoundSql;
+import org.apache.ibatis.builder.xml.XMLMapperBuilder;
 import org.apache.ibatis.session.Configuration;
 import org.junit.jupiter.api.Test;
 
+import java.io.IOException;
+import java.io.InputStream;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
 
 import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
 
 class PosFoodMapperSqlTest {
 
@@ -31,4 +36,23 @@ class PosFoodMapperSqlTest {
         assertTrue(sql.contains("F.STACKING_UP <> '1'"));
         assertTrue(sql.contains("F2.STACKING_UP <> '1'"));
     }
+
+    @Test
+    void menuCopyUpdateOnlyChangesContentFields() throws IOException {
+        Configuration configuration = new Configuration();
+        configuration.getTypeAliasRegistry().registerAlias("PosFood", com.ruoyi.system.domain.PosFood.class);
+        String resource = "mapper/chanting/PosFoodMapper.xml";
+        try (InputStream input = getClass().getClassLoader().getResourceAsStream(resource)) {
+            assertNotNull(input);
+            new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
+        }
+
+        BoundSql boundSql = configuration
+                .getMappedStatement("com.ruoyi.system.mapper.PosFoodMapper.updateMenuCopyContent")
+                .getBoundSql(new com.ruoyi.system.domain.PosFood());
+        String sql = boundSql.getSql().replaceAll("\\s+", " ").trim().toUpperCase(Locale.ROOT);
+
+        assertEquals("UPDATE POS_FOOD SET PRICE = ?, IMAGE = ?, INTRODUCE = ?, FOOD_SKU = ?, "
+                + "RECOMMEND = ?, SORT = ? WHERE ID = ?", sql);
+    }
 }

+ 496 - 0
ruoyi-system/src/test/java/com/ruoyi/system/service/StoreMenuCopyServiceTest.java

@@ -0,0 +1,496 @@
+package com.ruoyi.system.service;
+
+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;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicLong;
+
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
+import org.springframework.beans.factory.support.DefaultListableBeanFactory;
+import org.springframework.context.support.StaticMessageSource;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.spring.SpringUtils;
+import com.ruoyi.system.domain.FoodSpecRelation;
+import com.ruoyi.system.domain.FoodSpecs;
+import com.ruoyi.system.domain.FoodSpecsValue;
+import com.ruoyi.system.domain.PosFenlei;
+import com.ruoyi.system.domain.PosFood;
+import com.ruoyi.system.dto.menu.StoreMenuCopyExecuteRequest;
+import com.ruoyi.system.dto.menu.StoreMenuCopyPreviewRequest;
+import com.ruoyi.system.dto.menu.StoreMenuCopyPreviewResponse;
+import com.ruoyi.system.dto.menu.StoreMenuCopyResult;
+import com.ruoyi.system.mapper.FoodSpecRelationMapper;
+import com.ruoyi.system.mapper.FoodSpecsMapper;
+import com.ruoyi.system.mapper.FoodSpecsValueMapper;
+import com.ruoyi.system.mapper.PosFenleiMapper;
+import com.ruoyi.system.mapper.PosFoodMapper;
+import com.ruoyi.system.service.impl.StoreMenuCopyServiceImpl;
+
+@ExtendWith(MockitoExtension.class)
+class StoreMenuCopyServiceTest
+{
+    private static final Long USER_ID = 9L;
+    private static final Long SOURCE_STORE_ID = 1L;
+    private static final Long TARGET_STORE_ID = 2L;
+    private static ConfigurableListableBeanFactory originalBeanFactory;
+
+    @BeforeAll
+    static void initializeMessages()
+    {
+        originalBeanFactory = (ConfigurableListableBeanFactory)
+                ReflectionTestUtils.getField(SpringUtils.class, "beanFactory");
+        DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory();
+        StaticMessageSource messageSource = new StaticMessageSource();
+        messageSource.setUseCodeAsDefaultMessage(true);
+        messageSource.addMessage("menu.copy.store.required", Locale.getDefault(), "门店不能为空");
+        messageSource.addMessage("menu.copy.store.same", Locale.getDefault(), "源门店与目标门店不能相同");
+        messageSource.addMessage("menu.copy.strategy.invalid", Locale.getDefault(), "冲突处理方式无效");
+        messageSource.addMessage("merchant.store.access.denied", Locale.getDefault(), "无门店权限");
+        beanFactory.registerSingleton("messageSource", messageSource);
+        new SpringUtils().postProcessBeanFactory(beanFactory);
+    }
+
+    @AfterAll
+    static void restoreMessages()
+    {
+        new SpringUtils().postProcessBeanFactory(originalBeanFactory);
+    }
+
+    @Mock
+    private MerchantStoreAccessService accessService;
+
+    @Mock
+    private PosFenleiMapper categoryMapper;
+
+    @Mock
+    private PosFoodMapper foodMapper;
+
+    @Mock
+    private FoodSpecsMapper specsMapper;
+
+    @Mock
+    private FoodSpecsValueMapper specsValueMapper;
+
+    @Mock
+    private FoodSpecRelationMapper relationMapper;
+
+    @InjectMocks
+    private StoreMenuCopyServiceImpl service;
+
+    @BeforeEach
+    void setUp()
+    {
+        lenient().when(accessService.getAccessibleStoreIds(USER_ID))
+                .thenReturn(Set.of(SOURCE_STORE_ID, TARGET_STORE_ID));
+        lenient().when(categoryMapper.selectList(any())).thenReturn(CollectionsFixture.emptyCategories());
+        lenient().when(foodMapper.selectList(any())).thenReturn(CollectionsFixture.emptyFoods());
+        lenient().when(specsMapper.selectList(any())).thenReturn(CollectionsFixture.emptySpecs());
+        lenient().when(specsValueMapper.selectList(any())).thenReturn(CollectionsFixture.emptySpecValues());
+        lenient().when(relationMapper.selectList(any())).thenReturn(CollectionsFixture.emptyRelations());
+    }
+
+    @Test
+    void preview_shouldReportAddedMergedIdenticalAndDifferentItems()
+    {
+        PosFenlei sourceDrinks = category(11L, SOURCE_STORE_ID, "饮品", "2");
+        PosFenlei sourceMeals = category(12L, SOURCE_STORE_ID, "主食", "2");
+        PosFenlei targetDrinks = category(21L, TARGET_STORE_ID, "饮品", "2");
+        when(categoryMapper.selectList(any())).thenReturn(List.of(sourceDrinks, sourceMeals, targetDrinks));
+
+        PosFood sourceCoffee = food(101L, SOURCE_STORE_ID, 11L, "咖啡", "2", "100");
+        PosFood targetCoffee = food(201L, TARGET_STORE_ID, 21L, "咖啡", "2", "100");
+        PosFood sourceTea = food(102L, SOURCE_STORE_ID, 11L, "红茶", "2", "50");
+        PosFood targetTea = food(202L, TARGET_STORE_ID, 21L, "红茶", "2", "45");
+        PosFood sourceBurger = food(103L, SOURCE_STORE_ID, 12L, "汉堡", "2", "80");
+        when(foodMapper.selectList(any())).thenReturn(
+                List.of(sourceCoffee, sourceTea, sourceBurger, targetCoffee, targetTea));
+
+        StoreMenuCopyPreviewResponse response = service.preview(USER_ID, previewRequest());
+
+        assertEquals(1, response.getAddedCategoryCount());
+        assertEquals(1, response.getMergedCategoryCount());
+        assertEquals(1, response.getAddedProductCount());
+        assertEquals(1, response.getIdenticalProductCount());
+        assertEquals(1, response.getDifferentProductCount());
+        assertEquals("红茶", response.getDifferences().get(0).getName());
+        assertEquals(List.of("price"), response.getDifferences().get(0).getFields().stream()
+                .map(StoreMenuCopyPreviewResponse.FieldDifference::getField)
+                .toList());
+        assertEquals("45", response.getDifferences().get(0).getFields().get(0).getTargetValue());
+        assertEquals("50", response.getDifferences().get(0).getFields().get(0).getSourceValue());
+    }
+
+    @Test
+    void preview_shouldTreatSameNameInDifferentLanguageAsNewProduct()
+    {
+        when(categoryMapper.selectList(any())).thenReturn(List.of(
+                category(11L, SOURCE_STORE_ID, "饮品", "2"),
+                category(21L, TARGET_STORE_ID, "饮品", "2")));
+        when(foodMapper.selectList(any())).thenReturn(List.of(
+                food(101L, SOURCE_STORE_ID, 11L, "Cola", "1", "40"),
+                food(201L, TARGET_STORE_ID, 21L, "Cola", "2", "40")));
+
+        StoreMenuCopyPreviewResponse response = service.preview(USER_ID, previewRequest());
+
+        assertEquals(1, response.getAddedProductCount());
+        assertEquals(0, response.getIdenticalProductCount());
+        assertEquals(0, response.getDifferentProductCount());
+    }
+
+    @Test
+    void preview_shouldCountDuplicateSourceCategoriesOnlyOnce()
+    {
+        when(categoryMapper.selectList(any())).thenReturn(List.of(
+                category(11L, SOURCE_STORE_ID, "饮品", "2"),
+                category(12L, SOURCE_STORE_ID, "饮品", "2")));
+
+        StoreMenuCopyPreviewResponse response = service.preview(USER_ID, previewRequest());
+
+        assertEquals(1, response.getAddedCategoryCount());
+        assertEquals(0, response.getMergedCategoryCount());
+    }
+
+    @Test
+    void preview_shouldRejectMissingStoreBeforeReadingMenu()
+    {
+        StoreMenuCopyPreviewRequest request = new StoreMenuCopyPreviewRequest();
+        request.setTargetStoreId(TARGET_STORE_ID);
+
+        assertThrows(ServiceException.class, () -> service.preview(USER_ID, request));
+
+        verifyNoInteractions(accessService, categoryMapper, foodMapper, specsMapper, specsValueMapper, relationMapper);
+    }
+
+    @Test
+    void preview_shouldRejectSameStoreBeforeReadingMenu()
+    {
+        StoreMenuCopyPreviewRequest request = new StoreMenuCopyPreviewRequest();
+        request.setSourceStoreId(SOURCE_STORE_ID);
+        request.setTargetStoreId(SOURCE_STORE_ID);
+
+        assertThrows(ServiceException.class, () -> service.preview(USER_ID, request));
+
+        verifyNoInteractions(categoryMapper, foodMapper, specsMapper, specsValueMapper, relationMapper);
+    }
+
+    @Test
+    void preview_shouldRejectWhenEitherStoreIsOutsideCurrentAccessScope()
+    {
+        when(accessService.getAccessibleStoreIds(USER_ID)).thenReturn(Set.of(SOURCE_STORE_ID));
+
+        assertThrows(ServiceException.class, () -> service.preview(USER_ID, previewRequest()));
+
+        verifyNoInteractions(categoryMapper, foodMapper, specsMapper, specsValueMapper, relationMapper);
+    }
+
+    @Test
+    void execute_shouldCopyEmptyStoreMenuWithStatusesAndSpecs()
+    {
+        PosFenlei sourceCategory = category(11L, SOURCE_STORE_ID, "饮品", "2");
+        PosFood sourceFood = food(101L, SOURCE_STORE_ID, 11L, "咖啡", "2", "100");
+        sourceFood.setStackingUp("1");
+        sourceFood.setToExamine("0");
+        FoodSpecs sourceSpecs = specs(301L, SOURCE_STORE_ID, "大小", "2");
+        FoodSpecsValue sourceValue = specValue(401L, 301L, "大杯", "10");
+        FoodSpecRelation sourceRelation = relation(501L, 101L, 301L);
+        when(categoryMapper.selectList(any())).thenReturn(List.of(sourceCategory));
+        when(foodMapper.selectList(any())).thenReturn(List.of(sourceFood));
+        when(specsMapper.selectList(any())).thenReturn(List.of(sourceSpecs));
+        when(specsValueMapper.selectList(any())).thenReturn(List.of(sourceValue));
+        when(relationMapper.selectList(any())).thenReturn(List.of(sourceRelation));
+
+        AtomicLong ids = new AtomicLong(1000L);
+        doAnswer(invocation -> assignId(invocation.getArgument(0), ids.incrementAndGet()))
+                .when(categoryMapper).insert(any(PosFenlei.class));
+        doAnswer(invocation -> assignId(invocation.getArgument(0), ids.incrementAndGet()))
+                .when(specsMapper).insert(any(FoodSpecs.class));
+        doAnswer(invocation -> assignId(invocation.getArgument(0), ids.incrementAndGet()))
+                .when(specsValueMapper).insert(any(FoodSpecsValue.class));
+        doAnswer(invocation -> assignId(invocation.getArgument(0), ids.incrementAndGet()))
+                .when(foodMapper).insert(any(PosFood.class));
+        when(relationMapper.insert(any(FoodSpecRelation.class))).thenReturn(1);
+
+        StoreMenuCopyResult result = service.execute(USER_ID, executeRequest("KEEP_TARGET"));
+
+        assertEquals(1, result.getAddedCategoryCount());
+        assertEquals(1, result.getAddedProductCount());
+        assertEquals(0, result.getSkippedProductCount());
+        assertEquals(0, result.getOverwrittenProductCount());
+
+        ArgumentCaptor<PosFood> foodCaptor = ArgumentCaptor.forClass(PosFood.class);
+        verify(foodMapper).insert(foodCaptor.capture());
+        PosFood insertedFood = foodCaptor.getValue();
+        assertEquals(TARGET_STORE_ID, insertedFood.getMdid());
+        assertEquals("1", insertedFood.getStackingUp());
+        assertEquals("0", insertedFood.getToExamine());
+
+        ArgumentCaptor<FoodSpecs> specsCaptor = ArgumentCaptor.forClass(FoodSpecs.class);
+        verify(specsMapper).insert(specsCaptor.capture());
+        assertEquals(TARGET_STORE_ID, specsCaptor.getValue().getMdId());
+
+        ArgumentCaptor<FoodSpecsValue> valueCaptor = ArgumentCaptor.forClass(FoodSpecsValue.class);
+        verify(specsValueMapper).insert(valueCaptor.capture());
+        assertEquals(specsCaptor.getValue().getId(), valueCaptor.getValue().getParentId());
+
+        ArgumentCaptor<FoodSpecRelation> relationCaptor = ArgumentCaptor.forClass(FoodSpecRelation.class);
+        verify(relationMapper).insert(relationCaptor.capture());
+        assertEquals(insertedFood.getId(), relationCaptor.getValue().getFoodId());
+        assertEquals(specsCaptor.getValue().getId(), relationCaptor.getValue().getSpecsId());
+    }
+
+    @Test
+    void execute_shouldKeepDifferentTargetProductByDefault()
+    {
+        stubOneDifferentProductWithoutSpecs();
+
+        StoreMenuCopyResult result = service.execute(USER_ID, executeRequest(null));
+
+        assertEquals(1, result.getSkippedProductCount());
+        assertEquals(0, result.getOverwrittenProductCount());
+        verify(foodMapper, never()).updateMenuCopyContent(any(PosFood.class));
+    }
+
+    @Test
+    void execute_shouldOverwriteContentAndRelationsWithoutChangingTargetStatus()
+    {
+        PosFenlei sourceCategory = category(11L, SOURCE_STORE_ID, "饮品", "2");
+        PosFenlei targetCategory = category(21L, TARGET_STORE_ID, "饮品", "2");
+        PosFood sourceFood = food(101L, SOURCE_STORE_ID, 11L, "红茶", "2", "50");
+        sourceFood.setImage("source.png");
+        sourceFood.setStackingUp("0");
+        sourceFood.setToExamine("1");
+        PosFood targetFood = food(201L, TARGET_STORE_ID, 21L, "红茶", "2", "45");
+        targetFood.setImage("target.png");
+        targetFood.setStackingUp("1");
+        targetFood.setToExamine("0");
+        FoodSpecs sourceSpecs = specs(301L, SOURCE_STORE_ID, "大小", "2");
+        FoodSpecs targetSpecs = specs(302L, TARGET_STORE_ID, "大小", "2");
+        when(categoryMapper.selectList(any())).thenReturn(List.of(sourceCategory, targetCategory));
+        when(foodMapper.selectList(any())).thenReturn(List.of(sourceFood, targetFood));
+        when(specsMapper.selectList(any())).thenReturn(List.of(sourceSpecs, targetSpecs));
+        when(relationMapper.selectList(any())).thenReturn(List.of(relation(501L, 101L, 301L)));
+        when(foodMapper.updateMenuCopyContent(any(PosFood.class))).thenReturn(1);
+        when(relationMapper.delete(any())).thenReturn(1);
+        when(relationMapper.insert(any(FoodSpecRelation.class))).thenReturn(1);
+
+        StoreMenuCopyResult result = service.execute(USER_ID, executeRequest("OVERWRITE_TARGET"));
+
+        assertEquals(1, result.getOverwrittenProductCount());
+        ArgumentCaptor<PosFood> updateCaptor = ArgumentCaptor.forClass(PosFood.class);
+        verify(foodMapper).updateMenuCopyContent(updateCaptor.capture());
+        PosFood update = updateCaptor.getValue();
+        assertEquals(201L, update.getId());
+        assertEquals(new BigDecimal("50"), update.getPrice());
+        assertEquals("source.png", update.getImage());
+        assertNull(update.getStackingUp());
+        assertNull(update.getToExamine());
+        verify(specsMapper, never()).insert(any(FoodSpecs.class));
+        verify(specsValueMapper, never()).insert(any(FoodSpecsValue.class));
+        ArgumentCaptor<FoodSpecRelation> relationCaptor = ArgumentCaptor.forClass(FoodSpecRelation.class);
+        verify(relationMapper).insert(relationCaptor.capture());
+        assertEquals(201L, relationCaptor.getValue().getFoodId());
+        assertEquals(302L, relationCaptor.getValue().getSpecsId());
+    }
+
+    @Test
+    void execute_shouldBeIdempotentWhenTargetAlreadyMatches()
+    {
+        PosFenlei sourceCategory = category(11L, SOURCE_STORE_ID, "饮品", "2");
+        PosFenlei targetCategory = category(21L, TARGET_STORE_ID, "饮品", "2");
+        PosFood sourceFood = food(101L, SOURCE_STORE_ID, 11L, "咖啡", "2", "100");
+        PosFood targetFood = food(201L, TARGET_STORE_ID, 21L, "咖啡", "2", "100");
+        when(categoryMapper.selectList(any())).thenReturn(List.of(sourceCategory, targetCategory));
+        when(foodMapper.selectList(any())).thenReturn(List.of(sourceFood, targetFood));
+
+        StoreMenuCopyResult result = service.execute(USER_ID, executeRequest("OVERWRITE_TARGET"));
+
+        assertEquals(0, result.getAddedCategoryCount());
+        assertEquals(0, result.getAddedProductCount());
+        assertEquals(1, result.getSkippedProductCount());
+        assertEquals(0, result.getOverwrittenProductCount());
+        verify(categoryMapper, never()).insert(any(PosFenlei.class));
+        verify(foodMapper, never()).insert(any(PosFood.class));
+        verify(foodMapper, never()).updateMenuCopyContent(any(PosFood.class));
+    }
+
+    @Test
+    void execute_shouldRejectUnknownConflictStrategyBeforeReadingMenu()
+    {
+        assertThrows(ServiceException.class,
+                () -> service.execute(USER_ID, executeRequest("OVERWRITE_EVERYTHING")));
+
+        verifyNoInteractions(categoryMapper, foodMapper, specsMapper, specsValueMapper, relationMapper);
+    }
+
+    private void stubOneDifferentProductWithoutSpecs()
+    {
+        when(categoryMapper.selectList(any())).thenReturn(List.of(
+                category(11L, SOURCE_STORE_ID, "饮品", "2"),
+                category(21L, TARGET_STORE_ID, "饮品", "2")));
+        when(foodMapper.selectList(any())).thenReturn(List.of(
+                food(101L, SOURCE_STORE_ID, 11L, "红茶", "2", "50"),
+                food(201L, TARGET_STORE_ID, 21L, "红茶", "2", "45")));
+    }
+
+    private StoreMenuCopyPreviewRequest previewRequest()
+    {
+        StoreMenuCopyPreviewRequest request = new StoreMenuCopyPreviewRequest();
+        request.setSourceStoreId(SOURCE_STORE_ID);
+        request.setTargetStoreId(TARGET_STORE_ID);
+        return request;
+    }
+
+    private StoreMenuCopyExecuteRequest executeRequest(String strategy)
+    {
+        StoreMenuCopyExecuteRequest request = new StoreMenuCopyExecuteRequest();
+        request.setSourceStoreId(SOURCE_STORE_ID);
+        request.setTargetStoreId(TARGET_STORE_ID);
+        request.setConflictStrategy(strategy);
+        return request;
+    }
+
+    private PosFenlei category(Long id, Long storeId, String name, String language)
+    {
+        PosFenlei category = new PosFenlei();
+        category.setId(id);
+        category.setMendid(storeId);
+        category.setName(name);
+        category.setLanguage(language);
+        category.setImg("category.png");
+        category.setSort("1");
+        return category;
+    }
+
+    private PosFood food(Long id, Long storeId, Long categoryId, String name, String language, String price)
+    {
+        PosFood food = new PosFood();
+        food.setId(id);
+        food.setMdid(storeId);
+        food.setFlId(categoryId);
+        food.setName(name);
+        food.setLanguage(language);
+        food.setPrice(new BigDecimal(price));
+        food.setImage("food.png");
+        food.setIntroduce("介绍");
+        food.setFoodSku("[]");
+        food.setSort(1L);
+        food.setRecommend(0L);
+        food.setStackingUp("0");
+        food.setToExamine("1");
+        return food;
+    }
+
+    private FoodSpecs specs(Long id, Long storeId, String title, String language)
+    {
+        FoodSpecs specs = new FoodSpecs();
+        specs.setId(id);
+        specs.setMdId(storeId);
+        specs.setTitle(title);
+        specs.setLanguage(language);
+        specs.setType("1");
+        specs.setState("1");
+        specs.setSort(1);
+        specs.setIsOpen(true);
+        specs.setIsDelete(false);
+        return specs;
+    }
+
+    private FoodSpecsValue specValue(Long id, Long parentId, String name, String price)
+    {
+        FoodSpecsValue value = new FoodSpecsValue();
+        value.setId(id);
+        value.setParentId(parentId);
+        value.setName(name);
+        value.setPrice(new BigDecimal(price));
+        value.setState("1");
+        value.setIsOpen(true);
+        return value;
+    }
+
+    private FoodSpecRelation relation(Long id, Long foodId, Long specsId)
+    {
+        FoodSpecRelation relation = new FoodSpecRelation();
+        relation.setId(id);
+        relation.setFoodId(foodId);
+        relation.setSpecsId(specsId);
+        return relation;
+    }
+
+    private int assignId(Object entity, long id)
+    {
+        if (entity instanceof PosFenlei category)
+        {
+            category.setId(id);
+        }
+        else if (entity instanceof PosFood food)
+        {
+            food.setId(id);
+        }
+        else if (entity instanceof FoodSpecs specs)
+        {
+            specs.setId(id);
+        }
+        else if (entity instanceof FoodSpecsValue value)
+        {
+            value.setId(id);
+        }
+        return 1;
+    }
+
+    private static final class CollectionsFixture
+    {
+        private CollectionsFixture()
+        {
+        }
+
+        private static List<PosFenlei> emptyCategories()
+        {
+            return new ArrayList<>();
+        }
+
+        private static List<PosFood> emptyFoods()
+        {
+            return new ArrayList<>();
+        }
+
+        private static List<FoodSpecs> emptySpecs()
+        {
+            return new ArrayList<>();
+        }
+
+        private static List<FoodSpecsValue> emptySpecValues()
+        {
+            return new ArrayList<>();
+        }
+
+        private static List<FoodSpecRelation> emptyRelations()
+        {
+            return new ArrayList<>();
+        }
+    }
+}

+ 129 - 0
specs/026-store-menu-copy/plan.md

@@ -0,0 +1,129 @@
+# 连锁门店复制商品 Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 在商家 PC 商品管理页提供跨授权门店的菜单差异预览与整批复制,支持保留本店或覆盖差异商品,并保证幂等、事务性及规格关联正确。
+
+**Architecture:** `ruoyi-system` 新增独立的 `StoreMenuCopyService`,批量读取源店和目标店的分类、商品、规格模板、规格值与关联,使用“名称+语言”键实时生成预览;执行时在同一事务内重新计算并写入。`ruoyi-admin` 的 `PosFoodController` 只负责认证边界和 DTO 转发;`foodie-store` 新增独立弹窗组件完成源店选择、预览、冲突策略与结果反馈。平台 PC 不在本功能范围内。
+
+**Tech Stack:** Java 21、Spring Boot、MyBatis-Plus/MyBatis XML、JUnit 5、Mockito、Vue 2、Element UI、vue-i18n。
+
+## Global Constraints
+
+- Controller 必须使用 `@RequestHeader String token`、明确的 `@RequestBody` DTO,禁止 `Map` 入参。
+- 源门店与目标门店必须不同,且操作人必须同时拥有两店当前访问权限;权限判断复用 `MerchantStoreAccessService`。
+- 分类和商品均按“名称+语言”精确匹配;同名不同语言作为不同记录。
+- 新增商品继承源店上下架和审核状态;覆盖不得修改目标商品 ID、上下架状态和审核状态。
+- 规格模板按“规格名+语言”复用;缺失模板连同规格值复制;覆盖只调整商品关联,不修改目标店已有模板及规格值。
+- 执行必须在一个事务内完成;不增加数据库表、字段或同步状态,不执行 SQL 迁移。
+- 商家端所有新文案同步维护 `zh/tw/en/vi`,保持原 CRLF;平台 PC 不修改。
+- 测试使用 JDK 21;后端定向测试后运行 `ruoyi-system` 与 `ruoyi-admin` 相关构建,商家端运行生产构建。
+
+---
+
+### Task 1: 定义菜单复制 DTO 与服务接口
+
+**Files:**
+- Create: `ruoyi-system/src/main/java/com/ruoyi/system/dto/menu/StoreMenuCopyPreviewRequest.java`
+- Create: `ruoyi-system/src/main/java/com/ruoyi/system/dto/menu/StoreMenuCopyExecuteRequest.java`
+- Create: `ruoyi-system/src/main/java/com/ruoyi/system/dto/menu/StoreMenuCopyPreviewResponse.java`
+- Create: `ruoyi-system/src/main/java/com/ruoyi/system/dto/menu/StoreMenuCopyResult.java`
+- Create: `ruoyi-system/src/main/java/com/ruoyi/system/service/StoreMenuCopyService.java`
+
+**Interfaces:**
+
+```java
+StoreMenuCopyPreviewResponse preview(Long loginUserId, StoreMenuCopyPreviewRequest request);
+StoreMenuCopyResult execute(Long loginUserId, StoreMenuCopyExecuteRequest request);
+
+// conflictStrategy 仅允许 KEEP_TARGET / OVERWRITE_TARGET,空值按 KEEP_TARGET。
+```
+
+- [ ] DTO 仅承载源/目标门店、冲突策略、预览计数、字段差异和执行计数,不使用 Bean Validation 注解。
+- [ ] 响应字段固定为 `addedCategoryCount`、`mergedCategoryCount`、`addedProductCount`、`identicalProductCount`、`differentProductCount`、`differences`,执行结果固定为新增分类/商品、跳过、覆盖四项。
+- [ ] 服务接口不依赖 `com.ruoyi.app.*`,保持模块依赖 `ruoyi-admin -> ruoyi-system`。
+
+### Task 2: 测试先行实现实时差异预览
+
+**Files:**
+- Create: `ruoyi-system/src/test/java/com/ruoyi/system/service/StoreMenuCopyServiceTest.java`
+- Create: `ruoyi-system/src/main/java/com/ruoyi/system/service/impl/StoreMenuCopyServiceImpl.java`
+
+**Test breaks caught:** 错误匹配语言、漏报价格/图片/介绍/属性/排序/推荐/规格关联差异、重复分类计数错误、越权或同店预览被放行。
+
+- [ ] 写失败测试:源店 2 个分类、3 个商品,对目标店构造“新增、完全一致、内容不同”各一项,手工断言全部预览计数和字段差异。
+- [ ] 写失败测试:同名不同语言必须新增;源店重复同名分类只映射到第一项,不产生额外重复分类。
+- [ ] 写失败测试:源目标相同、任一门店越权、空门店 ID 均抛出国际化业务异常,且不调用写 Mapper。
+- [ ] 运行 `mvn -pl ruoyi-system -Dtest=StoreMenuCopyServiceTest test`,确认因服务实现缺失而 RED。
+- [ ] 实现批量快照读取、稳定键匹配和差异生成;预览全程只读。
+- [ ] 重跑定向测试确认 GREEN。
+
+### Task 3: 测试先行实现事务复制、覆盖与规格映射
+
+**Files:**
+- Modify: `ruoyi-system/src/test/java/com/ruoyi/system/service/StoreMenuCopyServiceTest.java`
+- Modify: `ruoyi-system/src/main/java/com/ruoyi/system/service/impl/StoreMenuCopyServiceImpl.java`
+
+**Test breaks caught:** 新商品状态丢失、覆盖篡改目标状态、已有规格模板被修改、遗漏规格关联、第二次执行重复写入、异常时缺少事务边界。
+
+- [ ] 写失败测试:空目标店复制后新增分类、商品、缺失规格模板/规格值及商品规格关联,新增商品继承上下架和审核状态。
+- [ ] 写失败测试:`KEEP_TARGET` 不更新差异商品;`OVERWRITE_TARGET` 更新内容和规格关联,但保持目标 ID、上下架、审核状态。
+- [ ] 写失败测试:目标店已有同名语言规格模板时复用且不更新模板/规格值;重复执行全部转为跳过。
+- [ ] 运行定向测试确认新增断言 RED。
+- [ ] 在 `@Transactional(rollbackFor = Exception.class)` 的 `execute` 中重新加载快照并完成最小写入;所有查询使用 MyBatis-Plus 参数绑定,不拼接 SQL。
+- [ ] 重跑定向测试确认 GREEN,并检查异常路径不会在权限验证前写入。
+
+### Task 4: 暴露认证接口并补齐后端国际化
+
+**Files:**
+- Modify: `ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosFoodController.java`
+- Create: `ruoyi-admin/src/test/java/com/ruoyi/app/mendian/StoreMenuCopyControllerTest.java`
+- Modify: `ruoyi-admin/src/main/resources/i18n/messages.properties`
+- Modify: `ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties`
+- Modify: `ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties`
+- Modify: `ruoyi-admin/src/main/resources/i18n/messages_en_US.properties`
+- Modify: `ruoyi-admin/src/main/resources/i18n/messages_vi.properties`
+
+**Interfaces:**
+
+```text
+POST /chanting/food/menu-copy/preview
+POST /chanting/food/menu-copy/execute
+Header: token
+Body: { sourceStoreId, targetStoreId [, conflictStrategy] }
+```
+
+- [ ] 写 Controller 失败测试,断言两个接口声明 `@RequestHeader String token` 和 `@RequestBody` DTO,并把 `AuthContext.requireUserId()` 传给服务。
+- [ ] 增加 `@Anonymous`、`@Auth(session = true)` 和两个 POST 方法,不从 token 文本自行解析用户 ID。
+- [ ] 五个后端资源文件补齐参数缺失、同店复制、非法冲突策略、无权限等消息 key。
+- [ ] 运行 `mvn -pl ruoyi-admin -am -Dtest=StoreMenuCopyControllerTest,StoreMenuCopyServiceTest -Dsurefire.failIfNoSpecifiedTests=false test`。
+
+### Task 5: 商家 PC 复制弹窗与四语言
+
+**Files:**
+- Modify: `E:/QtwCode/foodie/foodie-store/src/api/food.js`
+- Create: `E:/QtwCode/foodie/foodie-store/src/components/StoreMenuCopyDialog.vue`
+- Modify: `E:/QtwCode/foodie/foodie-store/src/views/AcidrollingActual.vue`
+- Modify: `E:/QtwCode/foodie/foodie-store/src/lang/zh.js`
+- Modify: `E:/QtwCode/foodie/foodie-store/src/lang/tw.js`
+- Modify: `E:/QtwCode/foodie/foodie-store/src/lang/en.js`
+- Modify: `E:/QtwCode/foodie/foodie-store/src/lang/vi.js`
+
+- [ ] API 模块增加 `previewStoreMenuCopy(data)` 与 `executeStoreMenuCopy(data)`,均走现有 token 请求链。
+- [ ] 商品管理页仅在 `mdlist.length >= 2` 时显示复制按钮,当前 `dqmd` 为目标门店,源门店下拉自动排除目标店。
+- [ ] 弹窗在选择源店后请求预览,显示新增分类、合并分类、新增商品、一致跳过、差异商品和字段级前后值。
+- [ ] 差异商品默认 `KEEP_TARGET`,用户显式选择后才能使用 `OVERWRITE_TARGET`;无变化或源店为空时禁用执行按钮。
+- [ ] 成功后显示新增分类/商品、跳过、覆盖计数,关闭弹窗并通知父页面刷新分类与商品列表。
+- [ ] 四语言新增相同的 `menuCopy` 嵌套 key,不硬编码用户可见文本;保持全部修改文件 CRLF。
+
+### Task 6: 回归、安全和交付检查
+
+**Files:**
+- Modify: `specs/026-store-menu-copy/spec.md`
+- Modify: `specs/026-store-menu-copy/tasks.md`
+
+- [ ] 使用 JDK 21 运行菜单复制定向测试、`ruoyi-system` 全量测试和 `ruoyi-admin` 相关测试/构建。
+- [ ] 在 `foodie-store` 运行 `npm run build`;确认平台 PC 无需修改。
+- [ ] 安全检查:无 Map Controller 入参、无字符串 SQL、两店权限先于读取/写入、无 token/敏感信息日志、覆盖不接受客户端商品字段。
+- [ ] 差异检查:三仓库仅包含本功能文件;无 DDL、无同步状态、无整文件换行变化。
+- [ ] 将规格状态更新为“已实现/待人工联调”,任务清单记录实际测试结果与明确未执行项。

+ 2 - 2
specs/026-store-menu-copy/spec.md

@@ -4,7 +4,7 @@
 
 **创建日期**:2026-09-03
 
-**状态**:设计定稿,**待排期实现**(2026-09-03 与需求方确认设计后暂缓开发;恢复时从 plan → tasks → implement 继续,禅道对外文档见同目录 zentao-design.md)
+**状态**:**已实现,待人工数据库联调**(2026-09-04 完成后端、商家 PC 与自动化测试;禅道对外文档见同目录 zentao-design.md)
 
 **输入**:连锁商家(如麦当劳型)各门店商品菜单基本一致,新开门店或补齐菜单时逐条重录商品费时费力。需要一个「复制商品」功能,把一家门店的分类+商品一次性复制到另一家门店;复制前可预览差异,同名但内容不同的商品由商家选择保留本店版本或用源店版本覆盖;不破坏目标门店已有商品;可重复执行(幂等)。
 
@@ -90,7 +90,7 @@
 - **FR-001**: 系统 MUST 提供复制预览能力:给定源门店与目标门店,返回新增分类数(含同名合并数)、新增商品数、同名一致跳过数、内容不同商品数及差异明细(商品名、语言、变更字段、前后值)。
 - **FR-002**: 系统 MUST 提供复制执行能力:按预览口径写入新增分类/商品,并按商家选择处理差异商品(保留或覆盖)。
 - **FR-003**: 分类匹配规则 MUST 为(分类名+语言)相同视为同一分类:目标店已有则复用(新增商品挂入),否则新建分类。
-- **FR-004**: 商品匹配规则 MUST 为(商品名+语言)相同视为同一商品;内容比对字段为:价格、图片、介绍、属性(foodSku)、规格集合(模板+规格值)、排序、推荐
+- **FR-004**: 商品匹配规则 MUST 为(商品名+语言)相同视为同一商品;内容比对字段为:价格、图片、介绍、属性(foodSku)、规格关联集合(规格名+语言)、排序、推荐。目标店已存在规格模板的规格值不纳入同步差异,边界见 FR-011
 - **FR-005**: 新增商品 MUST 继承源店的上架状态与审核状态,图片直接引用源店 URL;所属分类指向目标店合并/新建后的分类。
 - **FR-006**: 覆盖差异商品 MUST 只更新内容字段(同 FR-004 比对字段),MUST NOT 修改目标店商品的 id、上架状态、审核状态。
 - **FR-007**: 差异商品处理方式 MUST 默认为「保留本店版本」,覆盖为显式选择。

+ 37 - 0
specs/026-store-menu-copy/tasks.md

@@ -0,0 +1,37 @@
+# 连锁门店复制商品任务清单
+
+**输入:** [spec.md](spec.md)、[plan.md](plan.md)、[zentao-design.md](zentao-design.md)
+
+**执行约束:** 当前三个仓库均使用 `test-202609v2`;按用户要求直接在当前工作区实现,不创建 worktree;后端遵循测试先行;商家端保持 CRLF 和四语言一致;不修改平台 PC;不增加或执行 SQL。
+
+## Phase 1:契约与预览
+
+- [x] T001 创建预览/执行请求响应 DTO 与 `StoreMenuCopyService` 接口。
+- [x] T002 编写 `StoreMenuCopyServiceTest` 的权限、同店、分类/商品匹配和字段差异失败测试。
+- [x] T003 实现批量菜单快照与实时差异预览,使 T002 通过。
+
+## Phase 2:复制执行
+
+- [x] T004 编写空店复制、保留、覆盖、规格复用/新建、状态保持和幂等失败测试。
+- [x] T005 实现单事务复制与规格关联重建,使 T004 通过。
+
+## Phase 3:接口与商家 PC
+
+- [x] T006 新增两个认证 POST 接口、Controller 测试和后端国际化错误消息。
+- [x] T007 商家端增加复制 API、双店可见入口、差异预览/执行弹窗与结果刷新。
+- [x] T008 商家端简中、繁中、英文、越南文 `menuCopy` 文案全部补齐。
+
+## Phase 4:验证
+
+- [ ] T009 JDK 21 定向测试和后端模块回归通过。
+  - 026 定向测试 16 项通过;`ruoyi-system` 全量 83 项通过。
+  - `ruoyi-admin -am` 共执行 374 项,026 测试通过,但已有 `PosOrderQsOprateControllerTest` 因测试未注入 `userService` 出现 1 个无关错误,故本项暂不勾选。
+- [x] T010 `foodie-store` 生产构建通过,安全、换行和三仓库差异检查完成。
+- [ ] T011 人工数据库联调:真实双店菜单、事务回滚和 100 商品 10 秒性能验收。
+
+## 2026-09-04 验证记录
+
+- JDK 21:`StoreMenuCopyServiceTest` 11 项、`PosFoodMapperSqlTest` 2 项、`StoreMenuCopyControllerTest` 3 项通过。
+- 后端回归:`ruoyi-system` 83 项全部通过;`ruoyi-admin -am` 的 026 用例通过,完整回归存在上述 1 个存量测试错误。
+- 商家 PC:`npm run build` 成功,仅有项目原有的包体积与 Browserslist 更新提示。
+- 文件检查:商家端 7 个涉及文件均为 CRLF;平台 PC 无改动;本功能无 DDL、无同步状态表。