qmj 1 неделя назад
Родитель
Сommit
81a45b63ba
31 измененных файлов с 2037 добавлено и 18 удалено
  1. 16 0
      .claude/homunculus/observations.jsonl
  2. 1 1
      .specify/feature.json
  3. 1 1
      CLAUDE.md
  4. 213 0
      ruoyi-admin/src/main/java/com/ruoyi/app/mendian/FoodSpecController.java
  5. 154 10
      ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosFoodController.java
  6. 6 6
      ruoyi-admin/src/main/resources/application-druid.yml
  7. 35 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/FoodSpecRelation.java
  8. 70 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/FoodSpecs.java
  9. 53 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/FoodSpecsValue.java
  10. 3 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/PosFood.java
  11. 62 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/FoodSpecRelationMapper.java
  12. 62 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/FoodSpecsMapper.java
  13. 62 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/FoodSpecsValueMapper.java
  14. 32 0
      ruoyi-system/src/main/java/com/ruoyi/system/service/IFoodSpecRelationService.java
  15. 32 0
      ruoyi-system/src/main/java/com/ruoyi/system/service/IFoodSpecsService.java
  16. 32 0
      ruoyi-system/src/main/java/com/ruoyi/system/service/IFoodSpecsValueService.java
  17. 59 0
      ruoyi-system/src/main/java/com/ruoyi/system/service/impl/FoodSpecRelationServiceImpl.java
  18. 59 0
      ruoyi-system/src/main/java/com/ruoyi/system/service/impl/FoodSpecsServiceImpl.java
  19. 59 0
      ruoyi-system/src/main/java/com/ruoyi/system/service/impl/FoodSpecsValueServiceImpl.java
  20. 60 0
      ruoyi-system/src/main/resources/mapper/system/FoodSpecRelationMapper.xml
  21. 95 0
      ruoyi-system/src/main/resources/mapper/system/FoodSpecsMapper.xml
  22. 80 0
      ruoyi-system/src/main/resources/mapper/system/FoodSpecsValueMapper.xml
  23. 36 0
      specs/013-food-spec/checklists/requirements.md
  24. 95 0
      specs/013-food-spec/contracts/api.md
  25. 88 0
      specs/013-food-spec/data-model.md
  26. 85 0
      specs/013-food-spec/plan.md
  27. 48 0
      specs/013-food-spec/quickstart.md
  28. 52 0
      specs/013-food-spec/research.md
  29. 130 0
      specs/013-food-spec/spec.md
  30. 214 0
      specs/013-food-spec/tasks.md
  31. 43 0
      updatesql/sql.md

Разница между файлами не показана из-за своего большого размера
+ 16 - 0
.claude/homunculus/observations.jsonl


+ 1 - 1
.specify/feature.json

@@ -1,3 +1,3 @@
 {
-  "feature_directory": "specs/012-im-user-integration"
+  "feature_directory": "specs/013-food-spec"
 }

+ 1 - 1
CLAUDE.md

@@ -172,5 +172,5 @@ Strong success criteria let you loop independently. Weak criteria ("make it work
 
 <!-- SPECKIT START -->
 For additional context about technologies to be used, project structure,
-shell commands, and other important information, read the current plan: `specs/012-im-user-integration/plan.md`
+shell commands, and other important information, read the current plan: `specs/013-food-spec/plan.md`
 <!-- SPECKIT END -->

+ 213 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/mendian/FoodSpecController.java

@@ -0,0 +1,213 @@
+package com.ruoyi.app.mendian;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+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.system.domain.FoodSpecs;
+import com.ruoyi.system.domain.FoodSpecsValue;
+import com.ruoyi.system.service.IFoodSpecsService;
+import com.ruoyi.system.service.IFoodSpecsValueService;
+import com.ruoyi.system.utils.Auth;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * 商品规格管理 Controller(规格组 + 级联规格值)
+ * 门店级可复用规格模板
+ *
+ * @author ruoyi
+ * @date 2026-07-15
+ */
+@RestController
+@RequestMapping("/chanting/foodSpec")
+public class FoodSpecController extends BaseController
+{
+    @Autowired
+    private IFoodSpecsService foodSpecsService;
+
+    @Autowired
+    private IFoodSpecsValueService foodSpecsValueService;
+
+    /**
+     * 规格组分页列表(每条带 foodSpecsItems)
+     */
+    @Anonymous
+    @Auth
+    @GetMapping("/foodSpecPageList")
+    public AjaxResult foodSpecPageList(@RequestParam(defaultValue = "1") Integer pageNum,
+                                       @RequestParam(defaultValue = "10") Integer pageSize,
+                                       @RequestParam Long mdId,
+                                       @RequestParam(required = false) String language) {
+        Page<FoodSpecs> page = new Page<>(pageNum, pageSize);
+        LambdaQueryWrapper<FoodSpecs> qw = new LambdaQueryWrapper<>();
+        qw.eq(FoodSpecs::getMdId, mdId);
+        qw.eq(FoodSpecs::getIsDelete, false);
+        if (language != null && !"".equals(language)) {
+            qw.eq(FoodSpecs::getLanguage, language);
+        }
+        qw.orderByAsc(FoodSpecs::getSort).orderByDesc(FoodSpecs::getId);
+        IPage<FoodSpecs> result = foodSpecsService.page(page, qw);
+        fillItems(result.getRecords(), false);
+        return success(result);
+    }
+
+    /**
+     * 保存规格组(含级联规格值):id<=0 新增,否则更新
+     */
+    @Anonymous
+    @Auth
+    @PostMapping("/saveFoodSpec")
+    @Transactional(rollbackFor = Exception.class)
+    public AjaxResult saveFoodSpec(@RequestBody List<FoodSpecs> list) {
+        if (list == null || list.isEmpty()) {
+            return error();
+        }
+        for (FoodSpecs s : list) {
+            if (s.getIsDelete() == null) {
+                s.setIsDelete(false);
+            }
+            if (s.getIsOpen() == null) {
+                s.setIsOpen(true);
+            }
+            saveOrUpdateSpecs(s);
+            // 级联规格值
+            if (s.getFoodSpecsItems() != null) {
+                for (FoodSpecsValue v : s.getFoodSpecsItems()) {
+                    v.setParentId(s.getId());
+                    if (v.getIsOpen() == null) {
+                        v.setIsOpen(true);
+                    }
+                    saveOrUpdateValue(v);
+                }
+            }
+        }
+        return success();
+    }
+
+    private void saveOrUpdateSpecs(FoodSpecs s) {
+        if (s.getId() == null || s.getId() <= 0) {
+            s.setId(null);
+            foodSpecsService.save(s);
+        } else {
+            foodSpecsService.updateById(s);
+        }
+    }
+
+    private void saveOrUpdateValue(FoodSpecsValue v) {
+        if (v.getId() == null || v.getId() <= 0) {
+            v.setId(null);
+            foodSpecsValueService.save(v);
+        } else {
+            foodSpecsValueService.updateById(v);
+        }
+    }
+
+    /**
+     * 规格详情(带全部规格值)
+     */
+    @Anonymous
+    @Auth
+    @GetMapping("/getSpecs")
+    public AjaxResult getSpecs(@RequestParam Long id) {
+        FoodSpecs s = foodSpecsService.getById(id);
+        if (s != null) {
+            List<FoodSpecs> list = new ArrayList<>();
+            list.add(s);
+            fillItems(list, false);
+        }
+        return success(s);
+    }
+
+    /**
+     * 软删除规格组(is_delete=1)
+     */
+    @Anonymous
+    @Auth
+    @GetMapping("/deleteFoodSpec")
+    public AjaxResult deleteFoodSpec(@RequestParam Long id) {
+        FoodSpecs s = new FoodSpecs();
+        s.setId(id);
+        s.setIsDelete(true);
+        return toAjax(foodSpecsService.updateById(s));
+    }
+
+    /**
+     * 商品编辑页:本门店可用规格(is_open=1 & is_delete=0,仅含 is_open=1 的规格值)
+     */
+    @Anonymous
+    @Auth
+    @GetMapping("/getAvailableSpecsList")
+    public AjaxResult getAvailableSpecsList(@RequestParam Long mdId,
+                                            @RequestParam(required = false) String language) {
+        LambdaQueryWrapper<FoodSpecs> qw = new LambdaQueryWrapper<>();
+        qw.eq(FoodSpecs::getMdId, mdId);
+        qw.eq(FoodSpecs::getIsOpen, true);
+        qw.eq(FoodSpecs::getIsDelete, false);
+        if (language != null && !"".equals(language)) {
+            qw.eq(FoodSpecs::getLanguage, language);
+        }
+        qw.orderByAsc(FoodSpecs::getSort).orderByDesc(FoodSpecs::getId);
+        List<FoodSpecs> list = foodSpecsService.list(qw);
+        fillItems(list, true);
+        return success(list);
+    }
+
+    /**
+     * 启停规格组
+     */
+    @Anonymous
+    @Auth
+    @GetMapping("/changeOpen")
+    public AjaxResult changeOpen(@RequestParam Long id, @RequestParam Boolean isOpen) {
+        FoodSpecs s = new FoodSpecs();
+        s.setId(id);
+        s.setIsOpen(isOpen);
+        return toAjax(foodSpecsService.updateById(s));
+    }
+
+    /**
+     * 启停规格值
+     */
+    @Anonymous
+    @Auth
+    @GetMapping("/changeSpecValueOpen")
+    public AjaxResult changeSpecValueOpen(@RequestParam Long id, @RequestParam Boolean isOpen) {
+        FoodSpecsValue v = new FoodSpecsValue();
+        v.setId(id);
+        v.setIsOpen(isOpen);
+        return toAjax(foodSpecsValueService.updateById(v));
+    }
+
+    /**
+     * 批量填充规格值到规格组(避免 N+1)
+     *
+     * @param onlyOpen true=只取 is_open=1 的规格值(用于商品可用规格)
+     */
+    private void fillItems(List<FoodSpecs> list, boolean onlyOpen) {
+        if (list == null || list.isEmpty()) {
+            return;
+        }
+        List<Long> ids = list.stream().map(FoodSpecs::getId).collect(Collectors.toList());
+        LambdaQueryWrapper<FoodSpecsValue> qw = new LambdaQueryWrapper<FoodSpecsValue>()
+                .in(FoodSpecsValue::getParentId, ids)
+                .orderByAsc(FoodSpecsValue::getId);
+        if (onlyOpen) {
+            qw.eq(FoodSpecsValue::getIsOpen, true);
+        }
+        List<FoodSpecsValue> values = foodSpecsValueService.list(qw);
+        Map<Long, List<FoodSpecsValue>> map = values.stream()
+                .collect(Collectors.groupingBy(FoodSpecsValue::getParentId));
+        for (FoodSpecs s : list) {
+            s.setFoodSpecsItems(map.getOrDefault(s.getId(), new ArrayList<>()));
+        }
+    }
+}

+ 154 - 10
ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosFoodController.java

@@ -66,6 +66,13 @@ public class PosFoodController extends BaseController {
     @Autowired
     private PosStoreEnrichService posStoreEnrichService;
 
+    @Autowired  //规格组
+    private IFoodSpecsService foodSpecsService;
+    @Autowired  //规格值
+    private IFoodSpecsValueService foodSpecsValueService;
+    @Autowired  //商品-规格关联
+    private IFoodSpecRelationService foodSpecRelationService;
+
     //删除商品
     @Anonymous
     @Auth
@@ -87,14 +94,12 @@ public class PosFoodController extends BaseController {
     @Anonymous
     @PostMapping("/setposfood")
     public AjaxResult setposfood(@RequestBody PosFood posFood) {
-        JSONArray jsonArray = posFood.getSku();
-        posFood.setFoodSku(jsonArray.toString());
         Boolean org = posFoodService.saveOrUpdate(posFood);
-        if (org) {
-            return success();
-        } else {
+        if (!org) {
             return error();
         }
+        handleFoodSpec(posFood);
+        return success();
     }
 
     /**
@@ -120,6 +125,8 @@ public class PosFoodController extends BaseController {
         }
         queryWrapper.eq("language", lang);
         List<PosFood> list = posFoodService.list(queryWrapper);
+        List<Long> __foodIds = list.stream().map(PosFood::getId).collect(Collectors.toList());
+        Map<Long, List<FoodSpecs>> __specMap = loadFoodSpecsMap(__foodIds);
         JSONArray all = new JSONArray();
         for (int i = 0; i < list.size(); i++) {
             JSONObject org = new JSONObject();
@@ -131,7 +138,9 @@ public class PosFoodController extends BaseController {
             org.put("price", list.get(i).getPrice());
             org.put("introduce", list.get(i).getIntroduce());
             org.put("recommend", list.get(i).getRecommend());
-            org.put("foodSku", JSONArray.parseArray(list.get(i).getFoodSku()));
+            List<FoodSpecs> __fs = __specMap.getOrDefault(list.get(i).getId(), new ArrayList<>());
+            org.put("foodSpecs", __fs);
+            org.put("foodSku", __fs.isEmpty() ? JSONArray.parseArray(list.get(i).getFoodSku()) : buildFoodSkuArray(__fs));
             org.put("stackingUp", list.get(i).getStackingUp());
             org.put("toExamine", list.get(i).getToExamine());
             all.add(org);
@@ -166,6 +175,8 @@ public class PosFoodController extends BaseController {
             queryWrapper.apply(" BINARY name LIKE CONCAT('%', {0}, '%')", name);
         }
         List<PosFood> list = posFoodService.list(queryWrapper);
+        List<Long> __foodIds = list.stream().map(PosFood::getId).collect(Collectors.toList());
+        Map<Long, List<FoodSpecs>> __specMap = loadFoodSpecsMap(__foodIds);
         JSONArray all = new JSONArray();
         for (int i = 0; i < list.size(); i++) {
             JSONObject org = new JSONObject();
@@ -179,7 +190,9 @@ public class PosFoodController extends BaseController {
             org.put("price", list.get(i).getPrice());
             org.put("introduce", list.get(i).getIntroduce());
             org.put("recommend", list.get(i).getRecommend());
-            org.put("foodSku", JSONArray.parseArray(list.get(i).getFoodSku()));
+            List<FoodSpecs> __fs = __specMap.getOrDefault(list.get(i).getId(), new ArrayList<>());
+            org.put("foodSpecs", __fs);
+            org.put("foodSku", __fs.isEmpty() ? JSONArray.parseArray(list.get(i).getFoodSku()) : buildFoodSkuArray(__fs));
             org.put("stackingUp", list.get(i).getStackingUp());
             org.put("toExamine", list.get(i).getToExamine());
             all.add(org);
@@ -195,6 +208,10 @@ public class PosFoodController extends BaseController {
     public AjaxResult getfood(@RequestParam Integer id) {
         JSONObject org = new JSONObject();
         PosFood posFood = posFoodService.getById(id);
+        List<Long> __foodIds = new ArrayList<>();
+        __foodIds.add(posFood.getId());
+        Map<Long, List<FoodSpecs>> __specMap = loadFoodSpecsMap(__foodIds);
+        List<FoodSpecs> __fs = __specMap.getOrDefault(posFood.getId(), new ArrayList<>());
         org.put("id", posFood.getId());
         org.put("fenlei", posFenleiService.getById(posFood.getFlId()));
         PosStore store = posStoreService.getById(posFood.getMdid());
@@ -206,7 +223,8 @@ public class PosFoodController extends BaseController {
         org.put("introduce", posFood.getIntroduce());
         org.put("recommend", posFood.getRecommend());
         org.put("stackingUp", posFood.getStackingUp());
-        org.put("foodSku", JSONArray.parseArray(posFood.getFoodSku()));
+        org.put("foodSpecs", __fs);
+        org.put("foodSku", __fs.isEmpty() ? JSONArray.parseArray(posFood.getFoodSku()) : buildFoodSkuArray(__fs));
         return success(org);
     }
 
@@ -314,6 +332,13 @@ public class PosFoodController extends BaseController {
                     .apply(" CONCAT(name, ' ') LIKE CONCAT('%', {0}, '%') COLLATE utf8mb4_unicode_ci", keyword);
 
             List<PosFood> allFoods = posFoodService.list(foodQuery);
+            {
+                List<Long> __foodIds = allFoods.stream().map(PosFood::getId).collect(Collectors.toList());
+                Map<Long, List<FoodSpecs>> __specMap = loadFoodSpecsMap(__foodIds);
+                for (PosFood __f : allFoods) {
+                    __f.setFoodSpecs(__specMap.getOrDefault(__f.getId(), new ArrayList<>()));
+                }
+            }
 
             // 按店铺ID分组商品
             Map<Long, List<PosFood>> foodsByStore = allFoods.stream()
@@ -441,6 +466,11 @@ public class PosFoodController extends BaseController {
             queryWrapper.eq("fl_id", flId);
         }
         IPage<PosFood> result = posFoodService.page(foodPage, queryWrapper);
+        List<Long> __foodIds = result.getRecords().stream().map(PosFood::getId).collect(Collectors.toList());
+        Map<Long, List<FoodSpecs>> __specMap = loadFoodSpecsMap(__foodIds);
+        for (PosFood __f : result.getRecords()) {
+            __f.setFoodSpecs(__specMap.getOrDefault(__f.getId(), new ArrayList<>()));
+        }
         return success(result);
     }
 
@@ -527,7 +557,11 @@ public class PosFoodController extends BaseController {
     @Log(title = "food", businessType = BusinessType.INSERT)
     @PostMapping
     public AjaxResult add(@RequestBody PosFood posFood) {
-        return toAjax(posFoodService.insertPosFood(posFood));
+        int rows = posFoodService.insertPosFood(posFood);
+        if (rows > 0) {
+            handleFoodSpec(posFood);
+        }
+        return toAjax(rows);
     }
 
     /**
@@ -537,7 +571,11 @@ public class PosFoodController extends BaseController {
     @Log(title = "food", businessType = BusinessType.UPDATE)
     @PutMapping
     public AjaxResult edit(@RequestBody PosFood posFood) {
-        return toAjax(posFoodService.updatePosFood(posFood));
+        int rows = posFoodService.updatePosFood(posFood);
+        if (rows > 0) {
+            handleFoodSpec(posFood);
+        }
+        return toAjax(rows);
     }
 
     /**
@@ -611,6 +649,112 @@ public class PosFoodController extends BaseController {
     /**
      * 当门店是摊位时,用夜市的经纬度覆盖摊位的经纬度
      */
+    /**
+     * 保存商品规格:sku JSON 写 food_sku(落库) -> 删除旧关联 -> 按 foodSpecs 建立新关联
+     */
+    private void handleFoodSpec(PosFood posFood) {
+        if (posFood == null || posFood.getId() == null) {
+            return;
+        }
+        JSONArray sku = posFood.getSku();
+        if (sku != null) {
+            PosFood upd = new PosFood();
+            upd.setId(posFood.getId());
+            upd.setFoodSku(sku.toString());
+            posFoodService.updateById(upd);
+            posFood.setFoodSku(sku.toString());
+        }
+        foodSpecRelationService.remove(new LambdaQueryWrapper<FoodSpecRelation>()
+                .eq(FoodSpecRelation::getFoodId, posFood.getId()));
+        List<FoodSpecs> foodSpecs = posFood.getFoodSpecs();
+        if (foodSpecs != null && !foodSpecs.isEmpty()) {
+            List<FoodSpecRelation> rels = foodSpecs.stream()
+                    .filter(sp -> sp.getId() != null && sp.getId() > 0)
+                    .map(sp -> {
+                        FoodSpecRelation r = new FoodSpecRelation();
+                        r.setFoodId(posFood.getId());
+                        r.setSpecsId(sp.getId());
+                        return r;
+                    }).collect(Collectors.toList());
+            if (!rels.isEmpty()) {
+                foodSpecRelationService.saveBatch(rels);
+            }
+        }
+    }
+
+    /**
+     * 批量查询商品规格(避免 N+1):关联 -> 启用规格组 -> 启用规格值 -> 内存分组
+     * @return foodId -> 规格组列表(含启用规格值)
+     */
+    private Map<Long, List<FoodSpecs>> loadFoodSpecsMap(List<Long> foodIds) {
+        Map<Long, List<FoodSpecs>> result = new HashMap<>();
+        if (foodIds == null || foodIds.isEmpty()) {
+            return result;
+        }
+        List<FoodSpecRelation> rels = foodSpecRelationService.list(new LambdaQueryWrapper<FoodSpecRelation>()
+                .in(FoodSpecRelation::getFoodId, foodIds));
+        if (rels.isEmpty()) {
+            return result;
+        }
+        Map<Long, List<Long>> foodToSpecIds = rels.stream()
+                .collect(Collectors.groupingBy(FoodSpecRelation::getFoodId,
+                        Collectors.mapping(FoodSpecRelation::getSpecsId, Collectors.toList())));
+        List<Long> specsIds = rels.stream().map(FoodSpecRelation::getSpecsId).distinct().collect(Collectors.toList());
+        List<FoodSpecs> specsList = foodSpecsService.list(new LambdaQueryWrapper<FoodSpecs>()
+                .in(FoodSpecs::getId, specsIds)
+                .eq(FoodSpecs::getIsOpen, true)
+                .eq(FoodSpecs::getIsDelete, false)
+                .orderByAsc(FoodSpecs::getSort));
+        if (specsList.isEmpty()) {
+            return result;
+        }
+        List<Long> enabledSpecIds = specsList.stream().map(FoodSpecs::getId).collect(Collectors.toList());
+        List<FoodSpecsValue> values = foodSpecsValueService.list(new LambdaQueryWrapper<FoodSpecsValue>()
+                .in(FoodSpecsValue::getParentId, enabledSpecIds)
+                .eq(FoodSpecsValue::getIsOpen, true)
+                .orderByAsc(FoodSpecsValue::getId));
+        Map<Long, List<FoodSpecsValue>> valueMap = values.stream()
+                .collect(Collectors.groupingBy(FoodSpecsValue::getParentId));
+        Map<Long, FoodSpecs> specsMap = specsList.stream().collect(Collectors.toMap(FoodSpecs::getId, sp -> sp));
+        for (Map.Entry<Long, List<Long>> entry : foodToSpecIds.entrySet()) {
+            List<FoodSpecs> fsList = new ArrayList<>();
+            for (Long sid : entry.getValue()) {
+                FoodSpecs src = specsMap.get(sid);
+                if (src != null) {
+                    FoodSpecs copy = new FoodSpecs();
+                    copy.setId(src.getId());
+                    copy.setTitle(src.getTitle());
+                    copy.setType(src.getType());
+                    copy.setState(src.getState());
+                    copy.setLanguage(src.getLanguage());
+                    copy.setRemark(src.getRemark());
+                    copy.setMdId(src.getMdId());
+                    copy.setSort(src.getSort());
+                    copy.setIsOpen(src.getIsOpen());
+                    copy.setIsDelete(src.getIsDelete());
+                    copy.setFoodSpecsItems(valueMap.getOrDefault(src.getId(), new ArrayList<>()));
+                    fsList.add(copy);
+                }
+            }
+            result.put(entry.getKey(), fsList);
+        }
+        return result;
+    }
+
+    /**
+     * 由结构化规格构建兼容的 food_sku JSON 数组(明细字段名 foodSpecsItems)
+     */
+    private JSONArray buildFoodSkuArray(List<FoodSpecs> fsList) {
+        JSONArray skuArr = new JSONArray();
+        if (fsList == null || fsList.isEmpty()) {
+            return skuArr;
+        }
+        for (FoodSpecs s : fsList) {
+            skuArr.add(JSONObject.toJSON(s));
+        }
+        return skuArr;
+    }
+
     private void fillStallLocation(PosStore store) {
         if (store != null && store.getIsStall() != null && store.getIsStall() == 1
                 && store.getNightMarketId() != null) {

+ 6 - 6
ruoyi-admin/src/main/resources/application-druid.yml

@@ -10,13 +10,13 @@ spring:
 #                url: jdbc:mysql://localhost:3306/ry-vue?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
 #                username: root
 #                password: "!!112345"
-#                url: jdbc:mysql://localhost:3306/CTE2?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
-#                username: root
-#                password: "123456"
-                # 测试站点连接
-                url: jdbc:mysql://47.237.129.98:3306/foodie?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
+                url: jdbc:mysql://8.216.38.112:3306/foodie?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
                 username: foodie
-                password: "pdjpwMmr3tscf2Re"
+                password: "rRBNprjBdN2Ms2Rt"
+                # 测试站点连接
+#                url: jdbc:mysql://47.237.129.98:3306/foodie?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
+#                username: foodie
+#                password: "pdjpwMmr3tscf2Re"
             # 从库数据源
             slave:
                 # 从数据源开关/默认关闭

+ 35 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/FoodSpecRelation.java

@@ -0,0 +1,35 @@
+package com.ruoyi.system.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import javax.persistence.GeneratedValue;
+
+/**
+ * 商品-规格关联对象 food_spec_relation
+ * 商品与规格组的多对多关联
+ *
+ * @author ruoyi
+ * @date 2026-07-15
+ */
+@Data
+@TableName(value = "food_spec_relation")
+@EqualsAndHashCode(callSuper = false)
+public class FoodSpecRelation
+{
+    private static final long serialVersionUID = 1L;
+
+    @TableId(type = IdType.AUTO)
+    @GeneratedValue
+    /** id */
+    private Long id;
+
+    /** 商品id(pos_food.id) */
+    private Long foodId;
+
+    /** 规格组id(food_specs.id) */
+    private Long specsId;
+}

+ 70 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/FoodSpecs.java

@@ -0,0 +1,70 @@
+package com.ruoyi.system.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.ruoyi.common.annotation.Excel;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import javax.persistence.GeneratedValue;
+import java.util.List;
+
+/**
+ * 规格组对象 food_specs
+ * 门店级可复用规格维度(如 甜度、加料)
+ *
+ * @author ruoyi
+ * @date 2026-07-15
+ */
+@Data
+@TableName(value = "food_specs")
+@EqualsAndHashCode(callSuper = false)
+public class FoodSpecs
+{
+    private static final long serialVersionUID = 1L;
+
+    @TableId(type = IdType.AUTO)
+    @GeneratedValue
+    /** id */
+    private Long id;
+
+    /** 规格名(如 甜度) */
+    @Excel(name = "规格名")
+    private String title;
+
+    /** 选择类型:1=单选 2=多选 */
+    @Excel(name = "选择类型")
+    private String type;
+
+    /** 是否必选:1=必选 2=可选 */
+    @Excel(name = "是否必选")
+    private String state;
+
+    /** 语言 */
+    @Excel(name = "语言")
+    private String language;
+
+    /** 备注 */
+    private String remark;
+
+    /** 所属门店id */
+    @Excel(name = "门店id")
+    private Long mdId;
+
+    /** 排序 */
+    private Integer sort;
+
+    /** 是否启用 */
+    @TableField(value = "is_open")
+    private Boolean isOpen;
+
+    /** 软删除标记 */
+    @TableField(value = "is_delete")
+    private Boolean isDelete;
+
+    /** 规格值列表(不入库,保存时作入参、查询时作出参) */
+    @TableField(exist = false)
+    private List<FoodSpecsValue> foodSpecsItems;
+}

+ 53 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/FoodSpecsValue.java

@@ -0,0 +1,53 @@
+package com.ruoyi.system.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.ruoyi.common.annotation.Excel;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import javax.persistence.GeneratedValue;
+import java.math.BigDecimal;
+
+/**
+ * 规格值对象 food_specs_value
+ * 规格组下的可选项(如 无糖、珍珠),带加价
+ *
+ * @author ruoyi
+ * @date 2026-07-15
+ */
+@Data
+@TableName(value = "food_specs_value")
+@EqualsAndHashCode(callSuper = false)
+public class FoodSpecsValue
+{
+    private static final long serialVersionUID = 1L;
+
+    @TableId(type = IdType.AUTO)
+    @GeneratedValue
+    /** id */
+    private Long id;
+
+    /** 所属规格组id(food_specs.id) */
+    private Long parentId;
+
+    /** 规格值名(如 无糖) */
+    @Excel(name = "规格值名")
+    private String name;
+
+    /** 加价金额(与 pos_food.price 同口径) */
+    @Excel(name = "加价")
+    private BigDecimal price;
+
+    /** 备注 */
+    private String note;
+
+    /** 状态 */
+    private String state;
+
+    /** 是否启用 */
+    @TableField(value = "is_open")
+    private Boolean isOpen;
+}

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

@@ -84,6 +84,9 @@ public class PosFood
     //@JsonIgnore   //可读写,查询忽略
     private transient JSONArray sku;
 
+    /** 商品规格列表(不入库;保存时写 food_spec_relation,查询时回带结构) */
+    private transient java.util.List<FoodSpecs> foodSpecs;
+
     /** 创建时间 */
     @TableField(fill = FieldFill.INSERT)
     private Date createdAt;

+ 62 - 0
ruoyi-system/src/main/java/com/ruoyi/system/mapper/FoodSpecRelationMapper.java

@@ -0,0 +1,62 @@
+package com.ruoyi.system.mapper;
+
+import java.util.List;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ruoyi.system.domain.FoodSpecRelation;
+
+/**
+ * 商品-规格关联 Mapper 接口
+ *
+ * @author ruoyi
+ * @date 2026-07-15
+ */
+public interface FoodSpecRelationMapper extends BaseMapper<FoodSpecRelation>
+{
+    /**
+     * 查询商品-规格关联
+     *
+     * @param id 主键
+     * @return 商品-规格关联
+     */
+    public FoodSpecRelation selectFoodSpecRelationById(Long id);
+
+    /**
+     * 查询商品-规格关联列表
+     *
+     * @param foodSpecRelation 商品-规格关联
+     * @return 商品-规格关联集合
+     */
+    public List<FoodSpecRelation> selectFoodSpecRelationList(FoodSpecRelation foodSpecRelation);
+
+    /**
+     * 新增商品-规格关联
+     *
+     * @param foodSpecRelation 商品-规格关联
+     * @return 结果
+     */
+    public int insertFoodSpecRelation(FoodSpecRelation foodSpecRelation);
+
+    /**
+     * 修改商品-规格关联
+     *
+     * @param foodSpecRelation 商品-规格关联
+     * @return 结果
+     */
+    public int updateFoodSpecRelation(FoodSpecRelation foodSpecRelation);
+
+    /**
+     * 删除商品-规格关联
+     *
+     * @param id 主键
+     * @return 结果
+     */
+    public int deleteFoodSpecRelationById(Long id);
+
+    /**
+     * 批量删除商品-规格关联
+     *
+     * @param ids 需要删除的数据主键集合
+     * @return 结果
+     */
+    public int deleteFoodSpecRelationByIds(Long[] ids);
+}

+ 62 - 0
ruoyi-system/src/main/java/com/ruoyi/system/mapper/FoodSpecsMapper.java

@@ -0,0 +1,62 @@
+package com.ruoyi.system.mapper;
+
+import java.util.List;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ruoyi.system.domain.FoodSpecs;
+
+/**
+ * 规格组 Mapper 接口
+ *
+ * @author ruoyi
+ * @date 2026-07-15
+ */
+public interface FoodSpecsMapper extends BaseMapper<FoodSpecs>
+{
+    /**
+     * 查询规格组
+     *
+     * @param id 规格组主键
+     * @return 规格组
+     */
+    public FoodSpecs selectFoodSpecsById(Long id);
+
+    /**
+     * 查询规格组列表
+     *
+     * @param foodSpecs 规格组
+     * @return 规格组集合
+     */
+    public List<FoodSpecs> selectFoodSpecsList(FoodSpecs foodSpecs);
+
+    /**
+     * 新增规格组
+     *
+     * @param foodSpecs 规格组
+     * @return 结果
+     */
+    public int insertFoodSpecs(FoodSpecs foodSpecs);
+
+    /**
+     * 修改规格组
+     *
+     * @param foodSpecs 规格组
+     * @return 结果
+     */
+    public int updateFoodSpecs(FoodSpecs foodSpecs);
+
+    /**
+     * 删除规格组
+     *
+     * @param id 规格组主键
+     * @return 结果
+     */
+    public int deleteFoodSpecsById(Long id);
+
+    /**
+     * 批量删除规格组
+     *
+     * @param ids 需要删除的数据主键集合
+     * @return 结果
+     */
+    public int deleteFoodSpecsByIds(Long[] ids);
+}

+ 62 - 0
ruoyi-system/src/main/java/com/ruoyi/system/mapper/FoodSpecsValueMapper.java

@@ -0,0 +1,62 @@
+package com.ruoyi.system.mapper;
+
+import java.util.List;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ruoyi.system.domain.FoodSpecsValue;
+
+/**
+ * 规格值 Mapper 接口
+ *
+ * @author ruoyi
+ * @date 2026-07-15
+ */
+public interface FoodSpecsValueMapper extends BaseMapper<FoodSpecsValue>
+{
+    /**
+     * 查询规格值
+     *
+     * @param id 规格值主键
+     * @return 规格值
+     */
+    public FoodSpecsValue selectFoodSpecsValueById(Long id);
+
+    /**
+     * 查询规格值列表
+     *
+     * @param foodSpecsValue 规格值
+     * @return 规格值集合
+     */
+    public List<FoodSpecsValue> selectFoodSpecsValueList(FoodSpecsValue foodSpecsValue);
+
+    /**
+     * 新增规格值
+     *
+     * @param foodSpecsValue 规格值
+     * @return 结果
+     */
+    public int insertFoodSpecsValue(FoodSpecsValue foodSpecsValue);
+
+    /**
+     * 修改规格值
+     *
+     * @param foodSpecsValue 规格值
+     * @return 结果
+     */
+    public int updateFoodSpecsValue(FoodSpecsValue foodSpecsValue);
+
+    /**
+     * 删除规格值
+     *
+     * @param id 规格值主键
+     * @return 结果
+     */
+    public int deleteFoodSpecsValueById(Long id);
+
+    /**
+     * 批量删除规格值
+     *
+     * @param ids 需要删除的数据主键集合
+     * @return 结果
+     */
+    public int deleteFoodSpecsValueByIds(Long[] ids);
+}

+ 32 - 0
ruoyi-system/src/main/java/com/ruoyi/system/service/IFoodSpecRelationService.java

@@ -0,0 +1,32 @@
+package com.ruoyi.system.service;
+
+import java.util.List;
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.ruoyi.system.domain.FoodSpecRelation;
+
+/**
+ * 商品-规格关联 Service 接口
+ *
+ * @author ruoyi
+ * @date 2026-07-15
+ */
+public interface IFoodSpecRelationService extends IService<FoodSpecRelation>
+{
+    /** 查询商品-规格关联 */
+    public FoodSpecRelation selectFoodSpecRelationById(Long id);
+
+    /** 查询商品-规格关联列表 */
+    public List<FoodSpecRelation> selectFoodSpecRelationList(FoodSpecRelation foodSpecRelation);
+
+    /** 新增商品-规格关联 */
+    public int insertFoodSpecRelation(FoodSpecRelation foodSpecRelation);
+
+    /** 修改商品-规格关联 */
+    public int updateFoodSpecRelation(FoodSpecRelation foodSpecRelation);
+
+    /** 批量删除商品-规格关联 */
+    public int deleteFoodSpecRelationByIds(Long[] ids);
+
+    /** 删除商品-规格关联 */
+    public int deleteFoodSpecRelationById(Long id);
+}

+ 32 - 0
ruoyi-system/src/main/java/com/ruoyi/system/service/IFoodSpecsService.java

@@ -0,0 +1,32 @@
+package com.ruoyi.system.service;
+
+import java.util.List;
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.ruoyi.system.domain.FoodSpecs;
+
+/**
+ * 规格组 Service 接口
+ *
+ * @author ruoyi
+ * @date 2026-07-15
+ */
+public interface IFoodSpecsService extends IService<FoodSpecs>
+{
+    /** 查询规格组 */
+    public FoodSpecs selectFoodSpecsById(Long id);
+
+    /** 查询规格组列表 */
+    public List<FoodSpecs> selectFoodSpecsList(FoodSpecs foodSpecs);
+
+    /** 新增规格组 */
+    public int insertFoodSpecs(FoodSpecs foodSpecs);
+
+    /** 修改规格组 */
+    public int updateFoodSpecs(FoodSpecs foodSpecs);
+
+    /** 批量删除规格组 */
+    public int deleteFoodSpecsByIds(Long[] ids);
+
+    /** 删除规格组 */
+    public int deleteFoodSpecsById(Long id);
+}

+ 32 - 0
ruoyi-system/src/main/java/com/ruoyi/system/service/IFoodSpecsValueService.java

@@ -0,0 +1,32 @@
+package com.ruoyi.system.service;
+
+import java.util.List;
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.ruoyi.system.domain.FoodSpecsValue;
+
+/**
+ * 规格值 Service 接口
+ *
+ * @author ruoyi
+ * @date 2026-07-15
+ */
+public interface IFoodSpecsValueService extends IService<FoodSpecsValue>
+{
+    /** 查询规格值 */
+    public FoodSpecsValue selectFoodSpecsValueById(Long id);
+
+    /** 查询规格值列表 */
+    public List<FoodSpecsValue> selectFoodSpecsValueList(FoodSpecsValue foodSpecsValue);
+
+    /** 新增规格值 */
+    public int insertFoodSpecsValue(FoodSpecsValue foodSpecsValue);
+
+    /** 修改规格值 */
+    public int updateFoodSpecsValue(FoodSpecsValue foodSpecsValue);
+
+    /** 批量删除规格值 */
+    public int deleteFoodSpecsValueByIds(Long[] ids);
+
+    /** 删除规格值 */
+    public int deleteFoodSpecsValueById(Long id);
+}

+ 59 - 0
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/FoodSpecRelationServiceImpl.java

@@ -0,0 +1,59 @@
+package com.ruoyi.system.service.impl;
+
+import java.util.List;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ruoyi.system.mapper.FoodSpecRelationMapper;
+import com.ruoyi.system.domain.FoodSpecRelation;
+import com.ruoyi.system.service.IFoodSpecRelationService;
+
+/**
+ * 商品-规格关联 Service 业务层处理
+ *
+ * @author ruoyi
+ * @date 2026-07-15
+ */
+@Service
+public class FoodSpecRelationServiceImpl extends ServiceImpl<BaseMapper<FoodSpecRelation>, FoodSpecRelation> implements IFoodSpecRelationService
+{
+    @Autowired
+    private FoodSpecRelationMapper foodSpecRelationMapper;
+
+    @Override
+    public FoodSpecRelation selectFoodSpecRelationById(Long id)
+    {
+        return foodSpecRelationMapper.selectFoodSpecRelationById(id);
+    }
+
+    @Override
+    public List<FoodSpecRelation> selectFoodSpecRelationList(FoodSpecRelation foodSpecRelation)
+    {
+        return foodSpecRelationMapper.selectFoodSpecRelationList(foodSpecRelation);
+    }
+
+    @Override
+    public int insertFoodSpecRelation(FoodSpecRelation foodSpecRelation)
+    {
+        return foodSpecRelationMapper.insertFoodSpecRelation(foodSpecRelation);
+    }
+
+    @Override
+    public int updateFoodSpecRelation(FoodSpecRelation foodSpecRelation)
+    {
+        return foodSpecRelationMapper.updateFoodSpecRelation(foodSpecRelation);
+    }
+
+    @Override
+    public int deleteFoodSpecRelationByIds(Long[] ids)
+    {
+        return foodSpecRelationMapper.deleteFoodSpecRelationByIds(ids);
+    }
+
+    @Override
+    public int deleteFoodSpecRelationById(Long id)
+    {
+        return foodSpecRelationMapper.deleteFoodSpecRelationById(id);
+    }
+}

+ 59 - 0
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/FoodSpecsServiceImpl.java

@@ -0,0 +1,59 @@
+package com.ruoyi.system.service.impl;
+
+import java.util.List;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ruoyi.system.mapper.FoodSpecsMapper;
+import com.ruoyi.system.domain.FoodSpecs;
+import com.ruoyi.system.service.IFoodSpecsService;
+
+/**
+ * 规格组 Service 业务层处理
+ *
+ * @author ruoyi
+ * @date 2026-07-15
+ */
+@Service
+public class FoodSpecsServiceImpl extends ServiceImpl<BaseMapper<FoodSpecs>, FoodSpecs> implements IFoodSpecsService
+{
+    @Autowired
+    private FoodSpecsMapper foodSpecsMapper;
+
+    @Override
+    public FoodSpecs selectFoodSpecsById(Long id)
+    {
+        return foodSpecsMapper.selectFoodSpecsById(id);
+    }
+
+    @Override
+    public List<FoodSpecs> selectFoodSpecsList(FoodSpecs foodSpecs)
+    {
+        return foodSpecsMapper.selectFoodSpecsList(foodSpecs);
+    }
+
+    @Override
+    public int insertFoodSpecs(FoodSpecs foodSpecs)
+    {
+        return foodSpecsMapper.insertFoodSpecs(foodSpecs);
+    }
+
+    @Override
+    public int updateFoodSpecs(FoodSpecs foodSpecs)
+    {
+        return foodSpecsMapper.updateFoodSpecs(foodSpecs);
+    }
+
+    @Override
+    public int deleteFoodSpecsByIds(Long[] ids)
+    {
+        return foodSpecsMapper.deleteFoodSpecsByIds(ids);
+    }
+
+    @Override
+    public int deleteFoodSpecsById(Long id)
+    {
+        return foodSpecsMapper.deleteFoodSpecsById(id);
+    }
+}

+ 59 - 0
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/FoodSpecsValueServiceImpl.java

@@ -0,0 +1,59 @@
+package com.ruoyi.system.service.impl;
+
+import java.util.List;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ruoyi.system.mapper.FoodSpecsValueMapper;
+import com.ruoyi.system.domain.FoodSpecsValue;
+import com.ruoyi.system.service.IFoodSpecsValueService;
+
+/**
+ * 规格值 Service 业务层处理
+ *
+ * @author ruoyi
+ * @date 2026-07-15
+ */
+@Service
+public class FoodSpecsValueServiceImpl extends ServiceImpl<BaseMapper<FoodSpecsValue>, FoodSpecsValue> implements IFoodSpecsValueService
+{
+    @Autowired
+    private FoodSpecsValueMapper foodSpecsValueMapper;
+
+    @Override
+    public FoodSpecsValue selectFoodSpecsValueById(Long id)
+    {
+        return foodSpecsValueMapper.selectFoodSpecsValueById(id);
+    }
+
+    @Override
+    public List<FoodSpecsValue> selectFoodSpecsValueList(FoodSpecsValue foodSpecsValue)
+    {
+        return foodSpecsValueMapper.selectFoodSpecsValueList(foodSpecsValue);
+    }
+
+    @Override
+    public int insertFoodSpecsValue(FoodSpecsValue foodSpecsValue)
+    {
+        return foodSpecsValueMapper.insertFoodSpecsValue(foodSpecsValue);
+    }
+
+    @Override
+    public int updateFoodSpecsValue(FoodSpecsValue foodSpecsValue)
+    {
+        return foodSpecsValueMapper.updateFoodSpecsValue(foodSpecsValue);
+    }
+
+    @Override
+    public int deleteFoodSpecsValueByIds(Long[] ids)
+    {
+        return foodSpecsValueMapper.deleteFoodSpecsValueByIds(ids);
+    }
+
+    @Override
+    public int deleteFoodSpecsValueById(Long id)
+    {
+        return foodSpecsValueMapper.deleteFoodSpecsValueById(id);
+    }
+}

+ 60 - 0
ruoyi-system/src/main/resources/mapper/system/FoodSpecRelationMapper.xml

@@ -0,0 +1,60 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.ruoyi.system.mapper.FoodSpecRelationMapper">
+
+    <resultMap type="FoodSpecRelation" id="FoodSpecRelationResult">
+        <result property="id"    column="id"    />
+        <result property="foodId"    column="food_id"    />
+        <result property="specsId"    column="specs_id"    />
+    </resultMap>
+
+    <sql id="selectFoodSpecRelationVo">
+        select id, food_id, specs_id from food_spec_relation
+    </sql>
+
+    <select id="selectFoodSpecRelationList" parameterType="FoodSpecRelation" resultMap="FoodSpecRelationResult">
+        <include refid="selectFoodSpecRelationVo"/>
+        <where>
+            <if test="foodId != null"> and food_id = #{foodId}</if>
+            <if test="specsId != null"> and specs_id = #{specsId}</if>
+        </where>
+    </select>
+
+    <select id="selectFoodSpecRelationById" parameterType="Long" resultMap="FoodSpecRelationResult">
+        <include refid="selectFoodSpecRelationVo"/>
+        where id = #{id}
+    </select>
+
+    <insert id="insertFoodSpecRelation" parameterType="FoodSpecRelation" useGeneratedKeys="true" keyProperty="id">
+        insert into food_spec_relation
+        <trim prefix="(" suffix=")" suffixOverrides=",">
+            <if test="foodId != null">food_id,</if>
+            <if test="specsId != null">specs_id,</if>
+        </trim>
+        <trim prefix="values (" suffix=")" suffixOverrides=",">
+            <if test="foodId != null">#{foodId},</if>
+            <if test="specsId != null">#{specsId},</if>
+        </trim>
+    </insert>
+
+    <update id="updateFoodSpecRelation" parameterType="FoodSpecRelation">
+        update food_spec_relation
+        <trim prefix="SET" suffixOverrides=",">
+            <if test="foodId != null">food_id = #{foodId},</if>
+            <if test="specsId != null">specs_id = #{specsId},</if>
+        </trim>
+        where id = #{id}
+    </update>
+    <delete id="deleteFoodSpecRelationById" parameterType="Long">
+        delete from food_spec_relation where id = #{id}
+    </delete>
+
+    <delete id="deleteFoodSpecRelationByIds" parameterType="String">
+        delete from food_spec_relation where id in
+        <foreach item="id" collection="array" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+    </delete>
+</mapper>

+ 95 - 0
ruoyi-system/src/main/resources/mapper/system/FoodSpecsMapper.xml

@@ -0,0 +1,95 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.ruoyi.system.mapper.FoodSpecsMapper">
+
+    <resultMap type="FoodSpecs" id="FoodSpecsResult">
+        <result property="id"    column="id"    />
+        <result property="title"    column="title"    />
+        <result property="type"    column="type"    />
+        <result property="state"    column="state"    />
+        <result property="language"    column="language"    />
+        <result property="remark"    column="remark"    />
+        <result property="mdId"    column="md_id"    />
+        <result property="sort"    column="sort"    />
+        <result property="isOpen"    column="is_open"    />
+        <result property="isDelete"    column="is_delete"    />
+    </resultMap>
+
+    <sql id="selectFoodSpecsVo">
+        select id, title, type, state, language, remark, md_id, sort, is_open, is_delete from food_specs
+    </sql>
+
+    <select id="selectFoodSpecsList" parameterType="FoodSpecs" resultMap="FoodSpecsResult">
+        <include refid="selectFoodSpecsVo"/>
+        <where>
+            <if test="title != null and title != ''"> and title like concat('%', #{title}, '%')</if>
+            <if test="type != null and type != ''"> and type = #{type}</if>
+            <if test="state != null and state != ''"> and state = #{state}</if>
+            <if test="language != null and language != ''"> and language = #{language}</if>
+            <if test="mdId != null"> and md_id = #{mdId}</if>
+            <if test="isOpen != null"> and is_open = #{isOpen}</if>
+            <if test="isDelete != null"> and is_delete = #{isDelete}</if>
+        </where>
+        order by sort asc, id desc
+    </select>
+
+    <select id="selectFoodSpecsById" parameterType="Long" resultMap="FoodSpecsResult">
+        <include refid="selectFoodSpecsVo"/>
+        where id = #{id}
+    </select>
+
+    <insert id="insertFoodSpecs" parameterType="FoodSpecs" useGeneratedKeys="true" keyProperty="id">
+        insert into food_specs
+        <trim prefix="(" suffix=")" suffixOverrides=",">
+            <if test="title != null and title != ''">title,</if>
+            <if test="type != null and type != ''">type,</if>
+            <if test="state != null and state != ''">state,</if>
+            <if test="language != null">language,</if>
+            <if test="remark != null">remark,</if>
+            <if test="mdId != null">md_id,</if>
+            <if test="sort != null">sort,</if>
+            <if test="isOpen != null">is_open,</if>
+            <if test="isDelete != null">is_delete,</if>
+        </trim>
+        <trim prefix="values (" suffix=")" suffixOverrides=",">
+            <if test="title != null and title != ''">#{title},</if>
+            <if test="type != null and type != ''">#{type},</if>
+            <if test="state != null and state != ''">#{state},</if>
+            <if test="language != null">#{language},</if>
+            <if test="remark != null">#{remark},</if>
+            <if test="mdId != null">#{mdId},</if>
+            <if test="sort != null">#{sort},</if>
+            <if test="isOpen != null">#{isOpen},</if>
+            <if test="isDelete != null">#{isDelete},</if>
+        </trim>
+    </insert>
+
+    <update id="updateFoodSpecs" parameterType="FoodSpecs">
+        update food_specs
+        <trim prefix="SET" suffixOverrides=",">
+            <if test="title != null and title != ''">title = #{title},</if>
+            <if test="type != null and type != ''">type = #{type},</if>
+            <if test="state != null and state != ''">state = #{state},</if>
+            <if test="language != null">language = #{language},</if>
+            <if test="remark != null">remark = #{remark},</if>
+            <if test="mdId != null">md_id = #{mdId},</if>
+            <if test="sort != null">sort = #{sort},</if>
+            <if test="isOpen != null">is_open = #{isOpen},</if>
+            <if test="isDelete != null">is_delete = #{isDelete},</if>
+        </trim>
+        where id = #{id}
+    </update>
+
+    <delete id="deleteFoodSpecsById" parameterType="Long">
+        delete from food_specs where id = #{id}
+    </delete>
+
+    <delete id="deleteFoodSpecsByIds" parameterType="String">
+        delete from food_specs where id in
+        <foreach item="id" collection="array" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+    </delete>
+</mapper>

+ 80 - 0
ruoyi-system/src/main/resources/mapper/system/FoodSpecsValueMapper.xml

@@ -0,0 +1,80 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.ruoyi.system.mapper.FoodSpecsValueMapper">
+
+    <resultMap type="FoodSpecsValue" id="FoodSpecsValueResult">
+        <result property="id"    column="id"    />
+        <result property="parentId"    column="parent_id"    />
+        <result property="name"    column="name"    />
+        <result property="price"    column="price"    />
+        <result property="note"    column="note"    />
+        <result property="state"    column="state"    />
+        <result property="isOpen"    column="is_open"    />
+    </resultMap>
+
+    <sql id="selectFoodSpecsValueVo">
+        select id, parent_id, name, price, note, state, is_open from food_specs_value
+    </sql>
+
+    <select id="selectFoodSpecsValueList" parameterType="FoodSpecsValue" resultMap="FoodSpecsValueResult">
+        <include refid="selectFoodSpecsValueVo"/>
+        <where>
+            <if test="parentId != null"> and parent_id = #{parentId}</if>
+            <if test="name != null and name != ''"> and name like concat('%', #{name}, '%')</if>
+            <if test="state != null and state != ''"> and state = #{state}</if>
+            <if test="isOpen != null"> and is_open = #{isOpen}</if>
+        </where>
+        order by id asc
+    </select>
+
+    <select id="selectFoodSpecsValueById" parameterType="Long" resultMap="FoodSpecsValueResult">
+        <include refid="selectFoodSpecsValueVo"/>
+        where id = #{id}
+    </select>
+
+    <insert id="insertFoodSpecsValue" parameterType="FoodSpecsValue" useGeneratedKeys="true" keyProperty="id">
+        insert into food_specs_value
+        <trim prefix="(" suffix=")" suffixOverrides=",">
+            <if test="parentId != null">parent_id,</if>
+            <if test="name != null and name != ''">name,</if>
+            <if test="price != null">price,</if>
+            <if test="note != null">note,</if>
+            <if test="state != null and state != ''">state,</if>
+            <if test="isOpen != null">is_open,</if>
+        </trim>
+        <trim prefix="values (" suffix=")" suffixOverrides=",">
+            <if test="parentId != null">#{parentId},</if>
+            <if test="name != null and name != ''">#{name},</if>
+            <if test="price != null">#{price},</if>
+            <if test="note != null">#{note},</if>
+            <if test="state != null and state != ''">#{state},</if>
+            <if test="isOpen != null">#{isOpen},</if>
+        </trim>
+    </insert>
+
+    <update id="updateFoodSpecsValue" parameterType="FoodSpecsValue">
+        update food_specs_value
+        <trim prefix="SET" suffixOverrides=",">
+            <if test="parentId != null">parent_id = #{parentId},</if>
+            <if test="name != null and name != ''">name = #{name},</if>
+            <if test="price != null">price = #{price},</if>
+            <if test="note != null">note = #{note},</if>
+            <if test="state != null and state != ''">state = #{state},</if>
+            <if test="isOpen != null">is_open = #{isOpen},</if>
+        </trim>
+        where id = #{id}
+    </update>
+
+    <delete id="deleteFoodSpecsValueById" parameterType="Long">
+        delete from food_specs_value where id = #{id}
+    </delete>
+
+    <delete id="deleteFoodSpecsValueByIds" parameterType="String">
+        delete from food_specs_value where id in
+        <foreach item="id" collection="array" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+    </delete>
+</mapper>

+ 36 - 0
specs/013-food-spec/checklists/requirements.md

@@ -0,0 +1,36 @@
+# Specification Quality Checklist: 商品规格(SKU 规格)管理
+
+**Purpose**: Validate specification completeness and quality before proceeding to planning
+**Created**: 2026-07-15
+**Feature**: [spec.md](../spec.md)
+
+## Content Quality
+
+- [x] No implementation details (languages, frameworks, APIs) — 仅在 Assumptions 提及复用既有技术栈作为约束,正文聚焦业务 WHAT/WHY
+- [x] Focused on user value and business needs
+- [x] Written for non-technical stakeholders
+- [x] All mandatory sections completed
+
+## Requirement Completeness
+
+- [x] No [NEEDS CLARIFICATION] markers remain — 移植源项目功能,关键决策均采用合理默认并在 Assumptions 写明
+- [x] Requirements are testable and unambiguous
+- [x] Success criteria are measurable
+- [x] Success criteria are technology-agnostic (no implementation details)
+- [x] All acceptance scenarios are defined
+- [x] Edge cases are identified
+- [x] Scope is clearly bounded
+- [x] Dependencies and assumptions identified
+
+## Feature Readiness
+
+- [x] All functional requirements have clear acceptance criteria
+- [x] User scenarios cover primary flows (规格管理 / 商品使用规格 / 列表返回规格 / 价格口径)
+- [x] Feature meets measurable outcomes defined in Success Criteria
+- [x] No implementation details leak into specification
+
+## Notes
+
+- 本功能为从 cte_server 移植,源项目提供了所有关键决策的参考答案,故未使用 NEEDS CLARIFICATION,全部以合理默认 + Assumptions 表达。
+- 前端落地范围(商家端组件)留待 plan 阶段结合 foodie-store 现状确定。
+- 已校验通过,可进入 `/speckit-clarify`(如需进一步细化)或直接 `/speckit-plan`。

+ 95 - 0
specs/013-food-spec/contracts/api.md

@@ -0,0 +1,95 @@
+# API Contracts: 商品规格
+
+**Feature**: 013-food-spec | **Date**: 2026-07-15
+
+> 接口风格沿用 foodie 现有:`@Anonymous` + 自定义 `@Auth`(JWT),返回 `AjaxResult`/`TableDataInfo`,fastjson 拼装。价格单位与 `pos_food.price` 一致(decimal)。
+
+## 一、规格管理(新建 FoodSpecController,基路径 `/chanting/foodSpec`)
+
+| Method | Path | 入参 | 出参 | 说明 |
+|--------|------|------|------|------|
+| GET | /foodSpecPageList | token,pageNum,pageSize,mdId,language | IPage<FoodSpecs>(每条带 foodSpecsItems) | 规格组分页 |
+| POST | /saveFoodSpec | @RequestBody List<FoodSpecs>(含级联 foodSpecsItems) | AjaxResult | 新增/编辑规格组+级联值 |
+| GET | /getSpecs | id | FoodSpecs(带 foodSpecsItems) | 规格详情 |
+| GET | /deleteFoodSpec | id | AjaxResult | 软删除(is_delete=1) |
+| GET | /getAvailableSpecsList | mdId,language | List<FoodSpecs>(is_open=1 & is_delete=0,带 foodSpecsItems) | 商品编辑页选规格用 |
+| GET | /changeOpen | id,isOpen | AjaxResult | 启停规格组 |
+| GET | /changeSpecValueOpen | id,isOpen | AjaxResult | 启停规格值 |
+
+### 保存请求体示例(POST /saveFoodSpec)
+
+```json
+[
+  {
+    "id": 0,
+    "title": "甜度",
+    "type": "1",
+    "state": "1",
+    "language": "zh",
+    "mdId": 3,
+    "sort": 0,
+    "isOpen": true,
+    "foodSpecsItems": [
+      { "id": 0, "name": "无糖", "price": 0, "note": "", "isOpen": true },
+      { "id": 0, "name": "全糖", "price": 0, "isOpen": true }
+    ]
+  }
+]
+```
+
+## 二、商品接口规格部分(改 PosFoodController,基路径 `/chanting/food`)
+
+### 1. 保存商品 POST /setposfood(及后台 add/edit)
+
+请求体 `PosFood` 复用/增加:
+- `foodSpecs: List<FoodSpecs>` —— 所选规格组(写 `food_spec_relation`)
+- `sku: JSONArray` —— 规格精简 JSON(toString 写 `pos_food.food_sku`)
+
+后端处理(setposfood 与 add/edit 共用同一私有方法):
+1. 保存商品本身。
+2. 删除该 `food_id` 的旧关联,按 `foodSpecs[*].id` 批量插入新关联。
+3. `sku.toString()` 写入 `food_sku`。
+
+### 2. 商品详情 GET /getfood?id=
+
+响应在商品对象上增加:
+- `foodSpecs` —— 结构化规格(仅启用,明细字段名 `foodSpecsItems`)
+- `foodSku` —— 兼容 JSON(明细字段名统一为 `foodSpecsItems`)
+
+### 3. 商品列表(需注入规格的接口)
+
+- GET /getidlist(商家端分类列表)
+- GET /stallFoodList(C 端摊位扫码列表)
+- GET /foodSearch(搜索)
+
+均批量注入 `foodSpecs` + `foodSku`(批量查关联/规格组/规格值 + 内存分组,避免 N+1)。
+
+> 后台管理 GET /list、GET /{id} 为管理用途,本期不强制返回规格结构(可不含),以降低改动面。
+
+### 列表/详情响应规格结构示例
+
+```json
+{
+  "id": 100,
+  "name": "珍珠奶茶",
+  "price": 20.00,
+  "foodSpecs": [
+    {
+      "id": 7, "title": "甜度", "type": "1", "state": "1",
+      "mdId": 3, "sort": 0, "isOpen": true,
+      "foodSpecsItems": [
+        { "id": 15, "parentId": 7, "name": "无糖", "price": 0, "isOpen": true },
+        { "id": 16, "parentId": 7, "name": "全糖", "price": 0, "isOpen": true }
+      ]
+    }
+  ],
+  "foodSku": [
+    { "id": 7, "title": "甜度", "foodSpecsItems": [ "..." ] }
+  ]
+}
+```
+
+## 三、计价契约
+
+- 顾客选定规格值集合 S,实付单价 = `pos_food.price` + Σ(`S.price`)。
+- 该加价和 = 订单快照 `otherPrice` = 促销算价 `specPrice`(三者统一来源)。

+ 88 - 0
specs/013-food-spec/data-model.md

@@ -0,0 +1,88 @@
+# Data Model: 商品规格
+
+**Feature**: 013-food-spec | **Date**: 2026-07-15
+
+## 实体关系
+
+```text
+pos_store (门店)
+  │ 1
+  │
+  │ N
+food_specs (规格组) ──< food_specs_value (规格值)
+  │ N
+  │
+  │ N   (多对多,通过关联表)
+pos_food (商品) ──< food_spec_relation ──> food_specs
+```
+
+## 新增表
+
+### food_specs(规格组)
+
+门店级可复用规格维度(如"甜度""加料")。
+
+| 列 | 类型 | 说明 |
+|----|------|------|
+| id | bigint PK auto | 主键 |
+| title | varchar(64) | 规格名(如"甜度") |
+| type | char(1) | 1=单选 2=多选 |
+| state | char(1) | 1=必选 2=可选 |
+| language | varchar(8) | 语言(沿用现有 language 取值约定) |
+| remark | varchar(255) | 备注 |
+| md_id | bigint | 所属门店 id |
+| sort | int | 排序 |
+| is_open | tinyint(1) | 是否启用 |
+| is_delete | tinyint(1) | 软删除标记 |
+
+**transient 字段**(不入库):`foodSpecsItems: List<FoodSpecsValue>` —— 承载级联规格值,保存时作为入参、查询时作为出参。
+
+### food_specs_value(规格值)
+
+规格组下的可选项(如"无糖""珍珠")。
+
+| 列 | 类型 | 说明 |
+|----|------|------|
+| id | bigint PK auto | 主键 |
+| parent_id | bigint | 所属规格组 id(→ food_specs.id) |
+| name | varchar(64) | 规格值名 |
+| price | decimal(10,2) | 加价金额(与 pos_food.price 同口径) |
+| note | varchar(255) | 备注 |
+| state | char(1) | 状态 |
+| is_open | tinyint(1) | 是否启用 |
+
+### food_spec_relation(商品-规格关联)
+
+商品与规格组的多对多关联。
+
+| 列 | 类型 | 说明 |
+|----|------|------|
+| id | bigint PK auto | 主键 |
+| food_id | bigint | 商品 id(→ pos_food.id) |
+| specs_id | bigint | 规格组 id(→ food_specs.id) |
+
+## 既有表扩展
+
+### pos_food
+
+- 保留现有 `food_sku`(text/json)列:冗余存规格 JSON,与结构化关联双写。
+- **transient 字段**(不入库):
+  - `foodSpecs: List<FoodSpecs>` —— 保存商品时作为入参(写关联表),查询时作为出参(带规格结构)。
+  - `sku: JSONArray` —— 保存时作为入参(toString 写入 `food_sku` 列);foodie 已有此字段,复用。
+
+## 校验规则(来自需求)
+
+- 规格组 `type ∈ {1,2}`,`state ∈ {1,2}`。
+- 删除规格组为软删(`is_delete=1`);关联表记录保留(已生成订单不受影响)。
+- 商品可用规格 = `is_open=1` 且 `is_delete=0` 的规格组,及其下 `is_open=1` 的规格值。
+- 门店隔离:查询规格组强制按 `md_id` 过滤。
+
+## 索引建议
+
+- `food_specs`: `(md_id, language, is_delete, is_open)`
+- `food_specs_value`: `(parent_id, is_open)`
+- `food_spec_relation`: `(food_id)`, `(specs_id)`
+
+## 外键
+
+不建物理外键(与项目现有约定一致),靠应用层维护引用完整性。

+ 85 - 0
specs/013-food-spec/plan.md

@@ -0,0 +1,85 @@
+# Implementation Plan: 商品规格(SKU 规格)管理
+
+**Branch**: `test`(按用户要求不创建特性分支,直接在 test 分支开发) | **Date**: 2026-07-15 | **Spec**: [spec.md](spec.md)
+
+**Input**: Feature specification from `/specs/013-food-spec/spec.md`
+
+**Note**: This template is filled in by the `/speckit-plan` command.
+
+## Summary
+
+将 cte_server 的商品规格功能("规格加价"模型)移植到 foodie_server。引入规格组(`food_specs`)、规格值(`food_specs_value`)、商品-规格关联(`food_spec_relation`)三张表,复用 foodie 现有 MyBatis-Plus + fastjson + 若依技术栈。能力覆盖:商家端规格模板管理 CRUD、商品保存时挂载/更新规格关联、商品列表与详情接口返回规格结构、下单/促销链路按规格加价计价。保留现有 `pos_food.food_sku` JSON 字段与结构化关联双写,以兼容既有前端与订单读取逻辑。
+
+## Technical Context
+
+**Language/Version**: Java(Spring Boot + MyBatis-Plus),编译用 JDK 21(graalvm-jdk-21.0.7);前端 Vue.js + Element UI。
+
+**Primary Dependencies**: Spring Boot;MyBatis-Plus(BaseMapper/IService/LambdaQueryWrapper);fastjson(JSONArray/JSONObject);若依 BaseController/AjaxResult/TableDataInfo;自定义 `@Auth` 注解 + JwtUtil;前端 vue-i18n、Element UI、axios。
+
+**Storage**: MySQL。复用现有 `pos_food` 表;新增 `food_specs`、`food_specs_value`、`food_spec_relation` 三张表;保留 `pos_food.food_sku` 列。
+
+**Testing**: 项目无自动化测试框架,采用手动接口验证(Postman/curl)+ [quickstart.md](quickstart.md) 端到端场景。
+
+**Target Platform**: Java 后端服务(若依多模块)+ Vue 商家端(foodie-store)。
+
+**Project Type**: web-service(后端)+ web-frontend(商家端)。
+
+**Performance Goals**: 常规后台/商家端接口,无特殊性能指标;商品列表注入规格采用批量查询 + 内存分组,避免 N+1。
+
+**Constraints**:
+- 模块依赖 admin→system 不可反向(实体/mapper/service 在 ruoyi-system,controller 在 ruoyi-admin)。
+- 后端文件也是 CRLF 换行(编辑 Java/XML 注意匹配)。
+- 数据库变更写入 `updatesql/sql.md`,不直接执行。
+- 前端新增面向用户文字必须 `$t()` 国际化,四语言文件(zh/tw/en/vi)同步。
+- 新增字段须全栈同步(实体→xml→dto→service→controller→vue→i18n→sql)。
+- 规格名称是商家录入的动态多语言数据,按 `language` 字段承载,不做 i18n key。
+
+**Scale/Scope**: 后端 3 实体 + 3 mapper(+xml) + 3 service + 1 新建 `FoodSpecController` + 改 `PosFoodController`(setposfood 挂规格、列表/详情注入规格);前端 1 规格管理页 + 商品编辑页规格选择区 + 列表/详情规格展示;1 份 SQL 迁移。
+
+## Constitution Check
+
+`.specify/memory/constitution.md` 当前为未填写的占位模板,无实际项目原则/gates 约束 → **PASS**,无违规需论证。
+
+## Project Structure
+
+### Documentation (this feature)
+
+```text
+specs/013-food-spec/
+├── plan.md              # 本文件(/speckit-plan 产出)
+├── research.md          # Phase 0 决策记录(/speckit-plan)
+├── data-model.md        # Phase 1 数据模型(/speckit-plan)
+├── quickstart.md        # Phase 1 验证指南(/speckit-plan)
+├── contracts/           # Phase 1 接口契约(/speckit-plan)
+│   └── api.md
+└── tasks.md             # Phase 2 任务清单(/speckit-tasks 产出,本阶段不创建)
+```
+
+### Source Code (repository root)
+
+```text
+ruoyi-system/src/main/java/com/ruoyi/system/
+  domain/    FoodSpecs.java, FoodSpecsValue.java, FoodSpecRelation.java
+             扩展 PosFood.java(transient: foodSpecs, sku)
+  mapper/    FoodSpecsMapper.java, FoodSpecsValueMapper.java, FoodSpecRelationMapper.java
+  service/   IFoodSpecsService(+Impl), IFoodSpecsValueService(+Impl), IFoodSpecRelationService(+Impl)
+ruoyi-system/src/main/resources/mapper/system/
+  FoodSpecsMapper.xml, FoodSpecsValueMapper.xml, FoodSpecRelationMapper.xml
+
+ruoyi-admin/src/main/java/com/ruoyi/app/mendian/
+  FoodSpecController.java(新建:规格管理 CRUD)
+  PosFoodController.java(改:setposfood/add/edit 写规格关联+food_sku;getfood/getidlist/stallFoodList/foodSearch 注入规格)
+
+前端(E:\QtwCode\foodie\foodie-store\src\):
+  views/  规格管理页(新建)+ 商品编辑页规格选择区(改)
+  api/    specs.js(新建)+ food.js(改 setposfood 提交规格)
+  lang/   zh.js / tw.js / en.js / vi.js(新增规格相关 i18n)
+
+数据库: updatesql/sql.md(新增三表 DDL + pos_food.food_sku 列确认)
+```
+
+**Structure Decision**: 遵循现有若依多模块分层(domain/mapper/service 在 ruoyi-system,controller 在 ruoyi-admin,与 PosFood/PosFenlei 完全一致);前端落在商家端 foodie-store(与商品管理同端)。
+
+## Complexity Tracking
+
+无 Constitution 违规,本节不适用。

+ 48 - 0
specs/013-food-spec/quickstart.md

@@ -0,0 +1,48 @@
+# Quickstart 验证: 商品规格
+
+**Feature**: 013-food-spec | **Date**: 2026-07-15
+
+> 手动端到端验证(项目无自动化测试)。接口契约见 [contracts/api.md](../contracts/api.md),数据模型见 [data-model.md](../data-model.md)。
+
+## 前置
+
+1. 数据库已执行 `updatesql/sql.md` 中三张规格表的 DDL。
+2. 存在一个门店(mdId=3)与至少一个商品(`pos_food` 记录)。
+3. 商家账号 token 可用。
+
+## 场景 1:规格模板管理
+
+1. POST /chanting/foodSpec/saveFoodSpec,建"甜度"(单选/必选) + 规格值 无糖(0)/半糖(0)/全糖(0)。
+2. GET /chanting/foodSpec/foodSpecPageList?mdId=3 → 列表含该规格及 3 个值。
+3. GET /chanting/foodSpec/getSpecs?id={id} → 详情正确。
+4. GET /chanting/foodSpec/changeOpen?id={id}&isOpen=false → 再查 getAvailableSpecsList 不含该规格。
+
+**预期**:增删改查、启停、软删均生效;跨门店(mdId=4)查询不可见。
+
+## 场景 2:商品挂规格
+
+1. GET /chanting/foodSpec/getAvailableSpecsList?mdId=3 → 拿到可用规格。
+2. POST /chanting/food/setposfood,商品带 foodSpecs=[甜度, 加料]。
+3. GET /chanting/food/getfood?id={foodId} → 返回 foodSpecs 含甜度 + 加料。
+
+**预期**:`food_spec_relation` 写入 2 条;`food_sku` JSON 同步更新;重新勾选(减为仅甜度)后关联更新为 1 条。
+
+## 场景 3:列表返回规格
+
+1. GET /chanting/food/getidlist(含该商品)→ 每条商品带 foodSpecs。
+2. GET /chanting/food/stallFoodList、/foodSearch 同样带规格。
+
+**预期**:列表注入规格结构完整、仅含启用项;无规格商品返回空规格。
+
+## 场景 4:计价一致性
+
+1. 顾客选 全糖(加价 0) + 珍珠(加价 5) 下单。
+2. 检查订单快照 otherPrice = 5;订单单价 = 商品价 + 5。
+3. 若参与促销,specPrice = 5 正确叠加。
+
+**预期**:otherPrice / specPrice / 规格加价三者一致,金额 0 误差。
+
+## 场景 5:兼容性
+
+- 仅用旧 `food_sku` JSON(不经关联表)的历史商品:列表/详情仍能返回规格 JSON,不报错。
+- 后台 add/edit 编辑商品:规格能正确持久化(FR-011)。

+ 52 - 0
specs/013-food-spec/research.md

@@ -0,0 +1,52 @@
+# Research: 商品规格移植
+
+**Feature**: 013-food-spec | **Date**: 2026-07-15
+
+本研究基于对源项目 `cte_server` 规格功能与目标项目 `foodie_server` 商品体系的代码勘察,记录关键技术决策。无 NEEDS CLARIFICATION(移植任务,源代码即参考答案)。
+
+## 决策 1:数据模型 — 规格加价模型(非笛卡尔积 SKU)
+
+- **Decision**: 采用"规格组 + 规格值 + 商品-规格关联"三表模型;规格值带"加价",顾客选规格后加价累加到商品基础价。
+- **Rationale**: 源项目 cte_server 即此模型,需求是"移植";满足餐饮/夜市"甜度/加料/辣度"场景;实现简单,无需笛卡尔积组合管理。
+- **Alternatives**: 电商式 SKU(每规格组合独立价格/库存/编码)——拒绝:当前无独立库存/编码需求,且与现有 specPrice/otherPrice 加价口径冲突,复杂度不划算。
+
+## 决策 2:规格归属 — 门店级共享
+
+- **Decision**: 规格组按门店(`md_id`) + 语言(`language`)维度管理,本门店多商品复用。
+- **Rationale**: 与源项目一致;餐饮场景同一门店的"甜度"规格被多杯饮品复用,避免每商品重复录入。
+- **Alternatives**: 商品级独立规格——拒绝:录入成本高、无复用价值。
+
+## 决策 3:food_sku JSON 与结构化关联双写
+
+- **Decision**: 保留 `pos_food.food_sku` JSON 字段,与 `food_spec_relation` 结构化关联双写;查询时以结构化关联为权威,`food_sku` 作兼容冗余。
+- **Rationale**: foodie 现有前端与订单链路(`PromotionCalc.specPrice`、`PosOrder.food` 快照 `otherPrice`)读取 `food_sku` JSON;直接废弃会破坏存量链路。双写保证渐进兼容。
+- **Alternatives**: 仅结构化关联、废弃 JSON——拒绝:需同步改前端 + 订单读取,范围过大。
+
+## 决策 4:命名统一
+
+- **Decision**: 消除源项目命名不一致(其 `foodSku` JSON 内规格值明细叫 `objects`,`foodSpecs` 内叫 `foodSpecsItems`)。foodie 移植版统一:结构化规格 `FoodSpecs.foodSpecsItems` 用 `foodSpecsItems`;`food_sku` JSON 内明细若 foodie 前端已有约定则从之,否则统一为 `foodSpecsItems`。最终命名在 [contracts/api.md](contracts/api.md) 明确,前后端对齐。
+- **Rationale**: 源项目命名不一致是已知坑,移植时必须消除,否则前后端字段对不上。
+- **Alternatives**: 照搬两套命名——拒绝:维护负担大。
+
+## 决策 5:价格类型与口径统一
+
+- **Decision**: 规格值加价 `price` 与商品 `price` 同口径。foodie `PosFood.price` 为 BigDecimal(DB decimal),故规格加价列也用 decimal/BigDecimal,**不照搬源项目的 Long(分)**。下单实付单价 = 商品基础价 + 所选规格值加价之和;该和即写入订单快照 `otherPrice` 与促销 `specPrice`。
+- **Rationale**: 保持与 foodie 现有价格体系一致,避免单位换算 bug;统一 otherPrice/specPrice/规格加价三国口径。
+- **Alternatives**: 用整数分——拒绝:与商品价类型不一致,引入换算风险。
+
+## 决策 6:后台通道统一收口(FR-011)
+
+- **Decision**: 一并修复后台 `add`/`edit`(PosFoodController 后台段)不写规格的问题,使两条保存通道行为一致;抽取公共的"保存规格关联 + 写 food_sku"逻辑供 `setposfood` 与 `add`/`edit` 复用。
+- **Rationale**: spec FR-011 要求;否则后台编辑商品会丢规格,造成数据不一致。
+- **Alternatives**: 仅改商家端 setposfood——拒绝:留隐患。
+
+## 决策 7:查询性能 — 批量注入避免 N+1
+
+- **Decision**: 商品列表注入规格采用"一次查该批商品所有关联 → 一次查相关规格组 → 一次查相关规格值 → 内存分组",不逐商品查库(沿用源项目模式)。
+- **Rationale**: 列表接口商品数多,逐条查会 N+1。
+- **Alternatives**: 逐商品查——拒绝:性能差。
+
+## 依赖与集成
+
+- 技术栈均为 foodie 既有,无需引入新依赖。
+- 唯一需对齐的集成点:订单快照 `otherPrice`、促销 `specPrice` 的取值来源(决策 5 已统一为"所选规格值加价之和")。

+ 130 - 0
specs/013-food-spec/spec.md

@@ -0,0 +1,130 @@
+# Feature Specification: 商品规格(SKU 规格)管理
+
+**Feature Branch**: 在 `test` 分支直接开发(按用户要求不创建特性分支)
+
+**Created**: 2026-07-15
+
+**Status**: Draft
+
+**Input**: User description: "将 cte_server 的商品规格功能移植到 foodie_server,包括商品规格的管理、商品使用规格、商品列表返回规格信息"
+
+## User Scenarios & Testing *(mandatory)*
+
+### User Story 1 - 商家管理门店规格模板 (Priority: P1)
+
+商家在商家端维护本门店可复用的"规格"(如甜度、加料、辣度)。每个规格是一个**规格组**,组下有若干**规格值**(如"无糖/半糖/全糖"、"珍珠/椰果"),每个规格值可设定加价金额。规格组支持单选/多选、必选/可选、排序、启用/停用、软删除。
+
+**Why this priority**: 规格模板是商品使用规格的前提;没有它,商品无处挂规格,是整个功能的基础。
+
+**Independent Test**: 商家新增一个"甜度"规格(单选/必选),下设"无糖(加价0)/半糖(0)/全糖(0)"三个规格值,保存后在规格列表可见、可编辑、可停用。
+
+**Acceptance Scenarios**:
+
+1. **Given** 商家已登录其门店,**When** 商家新增规格组并填入名称、选择类型(单选/多选)、必选/可选、若干规格值及加价,**Then** 保存成功且列表可见。
+2. **Given** 规格组已存在,**When** 商家编辑修改名称或增删规格值,**Then** 保存后详情反映最新内容。
+3. **Given** 规格组/规格值已存在,**When** 商家停用某规格组或某规格值,**Then** 该规格组/值在"商品可用规格"中不再出现。
+4. **Given** 规格组已存在,**When** 商家删除规格组,**Then** 执行软删除,商品可用规格列表不再显示,但已生成订单与历史快照不受影响。
+
+---
+
+### User Story 2 - 商品使用规格 (Priority: P1)
+
+商家在新增/编辑商品时,可从本门店"可用规格"中勾选若干规格组挂到该商品上。保存商品时,商品与所选规格组建立关联;重新勾选时,旧的关联被清除、新的关联被建立。
+
+**Why this priority**: 这是规格功能的落地环节,没有它规格模板毫无意义,与 Story 1 同属 MVP。
+
+**Independent Test**: 商家编辑"珍珠奶茶",勾选"甜度""加料"两个规格组,保存后商品详情能返回这两个规格。
+
+**Acceptance Scenarios**:
+
+1. **Given** 门店已有可用规格,**When** 商家编辑商品并勾选规格组,**Then** 保存后商品与规格建立关联。
+2. **Given** 商品已挂规格,**When** 商家编辑时重新勾选(增减规格组),**Then** 保存后关联更新(旧关联清除、新关联建立)。
+3. **Given** 商品未挂任何规格,**When** 商家不勾选,**Then** 商品按无规格售卖(兼容现有行为)。
+
+---
+
+### User Story 3 - 商品列表与详情返回规格 (Priority: P1)
+
+商品列表(按分类、按门店、搜索分页)和商品详情接口返回该商品的规格结构(规格组 + 规格值 + 加价),前端据此渲染规格选择。顾客选定规格值后,所选规格值的加价累加到商品基础价。
+
+**Why this priority**: 规格必须能被下单链路消费,否则规格只是摆设,属于核心价值闭环。
+
+**Independent Test**: 顾客打开一个挂了规格的商品详情,能看到规格选项及对应加价。
+
+**Acceptance Scenarios**:
+
+1. **Given** 商品挂了规格,**When** 查询商品列表/详情,**Then** 返回完整规格结构(仅含启用的规格组与规格值)。
+2. **Given** 商品未挂规格,**When** 查询,**Then** 返回空规格(与现有无规格行为兼容)。
+3. **Given** 顾客选了若干规格值,**When** 计算价格,**Then** 实付单价 = 商品基础价 + 所选规格值加价之和。
+
+---
+
+### User Story 4 - 规格与订单/促销价格口径统一 (Priority: P2)
+
+顾客选规格下单时,订单商品快照保存所选规格及加价;促销算价将规格加价计入。与现有 `specPrice`/`otherPrice` 加价口径对齐,保证下单、促销、订单各环节金额一致。
+
+**Why this priority**: 保证规格价格在跨环节一致、避免算错钱;属正确性保障,依赖前三条已具备能力,故 P2。
+
+**Independent Test**: 顾客选加价规格下单,订单快照含规格与加价,且金额与所选规格加价完全一致。
+
+**Acceptance Scenarios**:
+
+1. **Given** 顾客选规格下单,**When** 生成订单,**Then** 订单商品快照记录所选规格及加价。
+2. **Given** 订单含规格加价,**When** 参与促销算价,**Then** 规格加价正确叠加到单价。
+
+---
+
+### Edge Cases
+
+- 商品已挂的规格组随后被门店停用/删除:列表与详情返回时自动剔除该规格组(顾客不可选)。
+- 规格值加价在商品售卖期间发生变更:已生成的订单快照不受影响(快照独立、不可变)。
+- 多语言:规格组/规格值名称需按门店 + 语言维度管理(项目支持 vi/zh/tw/en)。
+- 门店隔离:A 门店的规格对 B 门店完全不可见。
+- 商品保存同时携带结构化规格关联与旧版 `food_sku` JSON:以结构化规格关联为权威来源,`food_sku` JSON 作为冗余兼容字段同步更新。
+- 后台管理通道(平台端商品新增/编辑)当前不写规格信息:需统一收口,保证两条保存通道都能持久化规格。
+
+## Requirements *(mandatory)*
+
+### Functional Requirements
+
+- **FR-001**: 系统 MUST 支持门店级"规格组"的增、删(软删)、改、查;每个规格组包含名称、选择类型(单选/多选)、是否必选、排序、启用状态、所属门店、语言。
+- **FR-002**: 每个规格组下 MUST 能管理若干"规格值",每个规格值包含名称、加价金额、备注、启用状态。
+- **FR-003**: 规格组与规格值 MUST 支持启用/停用,停用后在"商品可用规格"中不可见。
+- **FR-004**: 商家编辑商品时 MUST 能从本门店可用规格中多选规格组挂到商品。
+- **FR-005**: 保存商品时系统 MUST 维护商品与规格组的关联(重新勾选时清除旧关联、建立新关联)。
+- **FR-006**: 商品列表(按分类、按门店、搜索分页)与商品详情接口 MUST 返回该商品的规格结构(仅启用的规格组与规格值)。
+- **FR-007**: 顾客选定规格后,实付单价 MUST 等于商品基础价加上所选规格值加价之和。
+- **FR-008**: 下单时订单商品快照 MUST 记录所选规格及加价,且促销算价将规格加价计入(与现有 `specPrice`/`otherPrice` 口径一致)。
+- **FR-009**: 规格数据 MUST 按门店隔离,A 门店的规格对 B 门店不可见。
+- **FR-010**: 规格组与规格值 MUST 支持多语言(vi/zh/tw/en)。
+- **FR-011**: 后台管理通道(平台端商品新增/编辑)保存商品时也 MUST 能正确持久化规格信息,与商家端通道行为一致。
+
+### Key Entities *(include if feature involves data)*
+
+- **规格组 (Spec Group)**: 一个可复用的规格维度,如"甜度""加料"。属于某门店、某种语言。含选择类型(单选/多选)、是否必选、排序、启用状态。下挂多个规格值。
+- **规格值 (Spec Value)**: 规格组下的一个可选项,如"无糖""珍珠"。含名称、加价金额、备注、启用状态。从属于一个规格组。
+- **商品-规格关联 (Food-Spec Relation)**: 商品与规格组的多对多关联,表达"该商品使用了哪些规格组"。
+- **商品 (Food)**: 已有实体,本次扩展其与规格的关联能力;保留既有 `food_sku` JSON 冗余字段以兼容。
+- **门店 (Store)**: 规格组的归属维度(规格组门店级共享、商品复用)。
+
+## Success Criteria *(mandatory)*
+
+### Measurable Outcomes
+
+- **SC-001**: 商家能在 1 分钟内为门店新建一个含 3 个规格值的规格组并保存成功。
+- **SC-002**: 商家编辑商品勾选/取消规格后,保存即时生效,商品详情立即反映正确规格(成功率 100%)。
+- **SC-003**: 顾客打开任意挂规格的商品详情,规格选项与加价 100% 正确展示。
+- **SC-004**: 顾客选规格下单,订单金额与所选规格加价完全一致(误差为 0)。
+- **SC-005**: 规格数据严格按门店隔离,跨门店查询规格返回为空(100%)。
+
+## Assumptions
+
+- 采用源项目 cte_server 的"规格加价"模型(规格组 + 规格值 + 关联表),而非电商式笛卡尔积 SKU(每组合独立价格/库存/编码)。规格值是对商品基础价的加价累加。
+- 规格组为门店级共享(一个门店定义规格,本门店多个商品复用),与源项目一致。
+- 规格组带 `language` 字段,按门店 + 语言维度管理(参照源项目 `FoodSpecs.language`)。
+- 保留 foodie 现有 `pos_food.food_sku` JSON 字段作为冗余兼容,与结构化规格关联双写,避免破坏现有前端/订单读取逻辑。
+- 规格管理入口放在商家端(与现有商品管理入口一致),不在 C 端顾客侧。
+- 价格单位沿用项目现有约定(商品价格 `price` 为数值,规格加价同口径)。
+- 复用项目既有技术栈与基础设施(Spring Boot + MyBatis-Plus + fastjson + 若依 `BaseController`/`AjaxResult` + 自定义 `@Auth`/`JwtUtil`)。
+- 规格组/规格值名称的多语言由数据本身的 `language` 字段承载(商家录入的多语言数据);前端 i18n 框架仅用于固定 UI 文案。
+- 本期聚焦后端能力 + 商家端规格管理/商品编辑交互;具体前端组件与落地范围在 plan 阶段依据现状进一步确定。

+ 214 - 0
specs/013-food-spec/tasks.md

@@ -0,0 +1,214 @@
+---
+
+description: "Task list for 商品规格(SKU 规格)管理 feature implementation"
+---
+
+# Tasks: 商品规格(SKU 规格)管理
+
+**Input**: Design documents from `/specs/013-food-spec/`
+
+**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/api.md, quickstart.md
+
+## 本期实现状态(2026-07-15)
+
+**已完成(后端,mvn compile 通过):** T001(DDL)、T002-T011(实体/Mapper/Service/XML)、T012-T013(FoodSpecController)、T018-T020(PosFoodController setposfood/add/edit 收口)、T023-T027(列表/详情注入规格)、T032(编译)。
+
+**本期暂缓(用户决定 2026-07-15):**
+- 前端 foodie-store:T014-T017(规格管理页/API/路由/i18n)、T021-T022(商品编辑规格区)、T028(列表展示)——后续单独安排
+- US4 价格口径:T029-T031(订单 otherPrice / 促销 specPrice 与规格加价统一)——留作后续
+
+**待开发者执行:** T033——先执行 updatesql/sql.md 建表,再按 quickstart.md 跑场景。
+
+**Tests**: 不包含自动化测试任务(项目无测试框架,spec 未要求 TDD);验证通过 quickstart.md 手动场景完成。
+
+**Organization**: 按用户故事分组,便于独立实现与验证。US1/US2/US3 为 P1,US4 为 P2。
+
+## Format: `[ID] [P?] [Story] Description`
+
+- **[P]**: 可并行(不同文件、无依赖)
+- **[Story]**: 所属用户故事(US1/US2/US3/US4)
+- 描述含确切文件路径
+
+## Path Conventions
+
+- 后端实体/mapper/service:`ruoyi-system/src/main/java/com/ruoyi/system/...`,XML 在 `ruoyi-system/src/main/resources/mapper/system/`
+- 后端 controller:`ruoyi-admin/src/main/java/com/ruoyi/app/mendian/...`(与 PosFoodController 同包)
+- 前端商家端:`E:\QtwCode\foodie\foodie-store\src\...`
+- SQL:`updatesql/sql.md`
+
+---
+
+## Phase 1: Setup (Shared Infrastructure)
+
+**Purpose**: 数据库结构准备
+
+- [x] T001 在 `updatesql/sql.md` 追加三张规格表 DDL(`food_specs`、`food_specs_value`、`food_spec_relation`,字段/类型见 data-model.md,规格加价 `price` 用 decimal 与 pos_food.price 同口径)并确认 `pos_food.food_sku` 列存在;标注日期 2026-07-15 与用途注释
+
+---
+
+## Phase 2: Foundational (Blocking Prerequisites)
+
+**Purpose**: 规格体系基础数据层(实体/Mapper/Service),所有用户故事共用
+
+**⚠️ CRITICAL**: 本阶段完成前不得开始任何用户故事
+
+- [x] T002 [P] 新建实体 `FoodSpecs`(`ruoyi-system/src/main/java/com/ruoyi/system/domain/FoodSpecs.java`):`@TableName("food_specs")`,字段 id/title/type/state/language/remark/mdId/sort/isOpen/isDelete,transient `foodSpecsItems: List<FoodSpecsValue>`
+- [x] T003 [P] 新建实体 `FoodSpecsValue`(`ruoyi-system/src/main/java/com/ruoyi/system/domain/FoodSpecsValue.java`):`@TableName("food_specs_value")`,字段 id/parentId/name/price(BigDecimal)/note/state/isOpen
+- [x] T004 [P] 新建实体 `FoodSpecRelation`(`ruoyi-system/src/main/java/com/ruoyi/system/domain/FoodSpecRelation.java`):`@TableName("food_spec_relation")`,字段 id/foodId/specsId
+- [x] T005 [P] 扩展 `PosFood`(`ruoyi-system/src/main/java/com/ruoyi/system/domain/PosFood.java`):新增 transient `foodSpecs: List<FoodSpecs>`(`@TableField(exist=false)`),确认既有 transient `sku: JSONArray` 可用
+- [x] T006 [P] 新建 `FoodSpecsMapper`(`ruoyi-system/.../mapper/FoodSpecsMapper.java`,`extends BaseMapper<FoodSpecs>`)+ XML(`ruoyi-system/src/main/resources/mapper/system/FoodSpecsMapper.xml`,含 resultMap 与标准 CRUD)
+- [x] T007 [P] 新建 `FoodSpecsValueMapper` + XML(同上目录结构,`extends BaseMapper<FoodSpecsValue>`)
+- [x] T008 [P] 新建 `FoodSpecRelationMapper` + XML(同上,`extends BaseMapper<FoodSpecRelation>`)
+- [x] T009 [P] 新建 `IFoodSpecsService`(`extends IService<FoodSpecs>`)+ `FoodSpecsServiceImpl`(`ruoyi-system/.../service/` 与 `service/impl/`)
+- [x] T010 [P] 新建 `IFoodSpecsValueService` + `FoodSpecsValueServiceImpl`(同上模式)
+- [x] T011 [P] 新建 `IFoodSpecRelationService` + `FoodSpecRelationServiceImpl`(同上模式)
+
+**Checkpoint**: 规格三实体/Mapper/Service 就绪,可开始用户故事
+
+---
+
+## Phase 3: User Story 1 - 商家管理门店规格模板 (Priority: P1) 🎯 MVP
+
+**Goal**: 商家在商家端对本门店规格组(含级联规格值)做增删改查、启停、软删
+
+**Independent Test**: 商家新建"甜度"(单选/必选)+3 个规格值,保存后在列表可见、可编辑、可停用、可软删;跨门店不可见
+
+### Implementation for User Story 1
+
+- [x] T012 [US1] 新建 `FoodSpecController`(`ruoyi-admin/src/main/java/com/ruoyi/app/mendian/FoodSpecController.java`,基路径 `/chanting/foodSpec`,`@Anonymous @Auth`):实现 `foodSpecPageList`/`getSpecs`/`deleteFoodSpec`(软删 isDelete=1)/`getAvailableSpecsList`(isOpen=1 & isDelete=0,按 mdId 隔离)/`changeOpen`/`changeSpecValueOpen`
+- [x] T013 [US1] 在 `FoodSpecController` 实现 `saveFoodSpec(@RequestBody List<FoodSpecs>)`:规格组 id≤0 新增否则更新,级联 saveOrUpdate 其下 `foodSpecsItems`(规格值),事务保证一致(依赖 T009/T010)
+- [ ] T014 [P] [US1] 前端 API 封装 `foodie-store/src/api/specs.js`(foodSpecPageList/saveFoodSpec/getSpecs/deleteFoodSpec/getAvailableSpecsList/changeOpen/changeSpecValueOpen)
+- [ ] T015 [US1] 前端规格管理页 `foodie-store/src/views/FoodSpec.vue`(或遵循 store 现有命名):分页列表(按门店切换)+ 新增/编辑弹窗(嵌套规格值子表增删改 name/price/note/isOpen)+ 启停开关 + 软删
+- [ ] T016 [US1] 前端路由/菜单接入规格管理页(按 foodie-store 现有路由约定)
+- [ ] T017 [P] [US1] 前端 i18n:在 `foodie-store/src/lang/` 的 zh.js/tw.js/en.js/vi.js 同一对象层级追加规格相关 key(有意义驼峰命名,四文件 key 一致)
+
+**Checkpoint**: US1 独立可用——规格模板完整管理闭环
+
+---
+
+## Phase 4: User Story 2 - 商品使用规格 (Priority: P1)
+
+**Goal**: 商家编辑商品时可从本门店可用规格多选规格组挂到商品;保存建立/更新关联
+
+**Independent Test**: 商家编辑"珍珠奶茶"勾选"甜度""加料",保存后详情返回这两个规格;重新勾选后关联正确更新
+
+### Implementation for User Story 2
+
+- [x] T018 [US2] 在 `PosFoodController`(`ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosFoodController.java`)抽取私有方法 `handleFoodSpec(PosFood)`:删除该 foodId 旧关联 → 按 `foodSpecs[*].id` 批量插 `food_spec_relation` → `sku.toString()` 写入 `food_sku`(依赖 T011)
+- [x] T019 [US2] `setposfood` 保存商品后调用 `handleFoodSpec`(依赖 T018)
+- [x] T020 [US2] 后台 `add`/`edit` 也调用 `handleFoodSpec`,统一收口(FR-011,修复当前后台通道不写规格)
+- [ ] T021 [P] [US2] 前端 `foodie-store/src/api/food.js` 的 setposfood 提交体增加 `foodSpecs` 与 `sku`
+- [ ] T022 [US2] 前端商品编辑页规格选择区:`el-select multiple` 选 `getAvailableSpecsList` 结果,`handleSpecChange` 同时构造 `form.foodSpecs`(完整规格对象,写关联) 与 `form.sku`(精简 JSON,写 food_sku),编辑回显由详情 foodSpecs 抽 id(依赖 T014/T021)
+
+**Checkpoint**: US1+US2 可用——规格可挂到商品并持久化
+
+---
+
+## Phase 5: User Story 3 - 商品列表与详情返回规格 (Priority: P1)
+
+**Goal**: 商品列表(分类/门店/搜索)与详情接口返回该商品规格结构(仅启用项),供前端渲染与下单
+
+**Independent Test**: 挂规格的商品在 getfood/getidlist/stallFoodList/foodSearch 返回完整规格与加价;无规格商品返回空规格
+
+### Implementation for User Story 3
+
+- [x] T023 [US3] 在 `PosFoodController` 抽取批量规格注入工具:按 foodId 集合一次查 `food_spec_relation` → 一次查相关 `food_specs`(isDelete=0,isOpen=1) → 一次查相关 `food_specs_value`(isOpen=1) → 内存分组,产出每商品的 `foodSpecs`+`foodSku`(命名统一为 `foodSpecsItems`,避免 N+1)
+- [x] T024 [US3] `getfood`(详情) 调用注入工具填 foodSpecs/foodSku(依赖 T023)
+- [x] T025 [US3] `getidlist` 批量注入规格(依赖 T023)
+- [x] T026 [US3] `stallFoodList` 批量注入规格(依赖 T023)
+- [x] T027 [US3] `foodSearch` 注入规格(依赖 T023)
+- [ ] T028 [US3] 前端商品列表/详情规格展示(遍历 foodSpecs 与 foodSpecsItems 显示名称/备注/加价),在 `foodie-store/src/views/` 相关商品组件实现
+
+**Checkpoint**: US1+US2+US3 构成核心闭环(管理→挂载→展示)——可交付 MVP
+
+---
+
+## Phase 6: User Story 4 - 规格与订单/促销价格口径统一 (Priority: P2)
+
+**Goal**: 选规格下单时订单快照记录规格及加价,促销算价计入加价,三者口径一致
+
+**Independent Test**: 顾客选加价规格下单,订单 otherPrice = 所选规格值加价之和,单价 = 商品价 + 加价;促销 specPrice 一致
+
+### Implementation for User Story 4
+
+- [ ] T029 [US4] 确认/补全下单链路:订单商品快照写入所选规格及加价,`otherPrice` = 所选规格值加价之和(`ruoyi-admin/.../app/order/PosOrderController.java` 及 `OrderDTO`/`OrderCreatItem`,先勘察现有 otherPrice 写入点)
+- [ ] T030 [US4] 确认促销算价 `specPrice` 口径 = 所选规格值加价之和(`ruoyi-system/.../service/impl/PromotionCalcServiceImpl.java`,先勘察现有 specPrice 叠加点)
+- [ ] T031 [US4] 验证 otherPrice / specPrice / 规格加价三者一致(按 quickstart 场景 4)
+
+**Checkpoint**: 全链路价格一致
+
+---
+
+## Phase 7: Polish & Cross-Cutting Concerns
+
+**Purpose**: 编译、验证、收尾
+
+- [x] T032 [P] 编译验证:用 JDK21(graalvm-jdk-21.0.7)执行 `mvn -pl ruoyi-system,ruoyi-admin -am compile` 通过
+- [ ] T033 按 `specs/013-food-spec/quickstart.md` 跑 5 个场景手动验证(规格管理/商品挂规格/列表返回/计价一致/兼容性)
+- [ ] T034 [P] 文档收尾:更新 spec 状态、在本会话 memory 记录 013-food-spec 要点
+
+---
+
+## Dependencies & Execution Order
+
+### Phase Dependencies
+
+- **Setup (Phase 1)**: 无依赖,立即开始(DDL 供开发者手动执行,不阻塞编码,但编码前最好先建表)
+- **Foundational (Phase 2)**: 依赖 Setup;**阻塞所有用户故事**
+- **US1 (Phase 3)**: 依赖 Foundational
+- **US2 (Phase 4)**: 依赖 Foundational;前端选规格依赖 US1 的 `getAvailableSpecsList`(T014)
+- **US3 (Phase 5)**: 依赖 Foundational;后端注入逻辑依赖 US2 已能写关联(否则无数据可返回,但代码可先行)
+- **US4 (Phase 6)**: 依赖 US2+US3(要先能挂规格、返回规格)
+- **Polish (Phase 7)**: 依赖所有目标 story 完成
+
+### Within Each User Story
+
+- 实体/Mapper/Service 先于 Controller
+- 后端先于前端
+- 核心实现先于集成展示
+
+### Parallel Opportunities
+
+- Foundational 的实体(T002-T005)、Mapper(T006-T008)、Service(T009-T011) 各组内可并行(不同文件)
+- US1 的前端 API(T014) 与 i18n(T017) 可与页面(T015) 部分并行
+- US3 的多个列表接口注入(T025-T027) 依赖同一工具(T023),T023 完成后可并行改造
+
+---
+
+## Parallel Example: Foundational
+
+```bash
+# 实体可并行创建(不同文件):
+Task: "FoodSpecs in domain/FoodSpecs.java"
+Task: "FoodSpecsValue in domain/FoodSpecsValue.java"
+Task: "FoodSpecRelation in domain/FoodSpecRelation.java"
+# Mapper 与 Service 依赖对应实体,实体就绪后各组并行
+```
+
+---
+
+## Implementation Strategy
+
+### MVP First(核心闭环 = US1+US2+US3)
+
+1. 完成 Phase 1 Setup(DDL)
+2. 完成 Phase 2 Foundational(**阻塞关键路径**)
+3. 完成 US1 → 独立验证规格管理
+4. 完成 US2 → 验证商品挂规格
+5. 完成 US3 → 验证列表/详情返回规格
+6. **STOP and VALIDATE**:US1+US2+US3 构成可交付 MVP
+
+### Incremental Delivery
+
+- Foundational → US1(规格管理可用)→ +US2(商品可挂规格)→ +US3(规格可展示/下单可见)= MVP
+- +US4(价格口径增强,P2)
+
+### Notes
+
+- [P] = 不同文件、无依赖,可并行
+- [Story] 标签用于用户故事阶段任务追踪
+- 价格一律与 `pos_food.price` 同口径(decimal/BigDecimal),不照搬源项目 Long(分)
+- 前端文件 CRLF,编辑用 Python 脚本替换;i18n 四语言 key 一致
+- 后台 add/edit 收口(FR-011)与 setposfood 共用 `handleFoodSpec`
+- 规格名称是商家录入的动态多语言数据(按 language 字段),不做成 i18n key
+- 提交策略:按用户故事或逻辑分组提交(用户未要求建分支,直接在 test 分支)

+ 43 - 0
updatesql/sql.md

@@ -345,3 +345,46 @@ ALTER TABLE info_user ADD COLUMN voip_token VARCHAR(255) DEFAULT NULL COMMENT 'i
 ALTER TABLE info_user ADD COLUMN cid_type VARCHAR(32) DEFAULT NULL COMMENT '设备类型(ios/android等,区分设备类型)';
 ```
 
+
+## 2026-07-15 商品规格三表(013-food-spec)
+
+```sql
+-- 规格组(门店级可复用规格维度,如 甜度/加料)
+CREATE TABLE food_specs (
+  id BIGINT AUTO_INCREMENT PRIMARY KEY,
+  title VARCHAR(64) NOT NULL COMMENT '规格名(如 甜度)',
+  type CHAR(1) DEFAULT '1' COMMENT '选择类型:1=单选 2=多选',
+  state CHAR(1) DEFAULT '1' COMMENT '是否必选:1=必选 2=可选',
+  language VARCHAR(8) COMMENT '语言',
+  remark VARCHAR(255) COMMENT '备注',
+  md_id BIGINT COMMENT '所属门店id',
+  sort INT DEFAULT 0 COMMENT '排序',
+  is_open TINYINT(1) DEFAULT 1 COMMENT '是否启用',
+  is_delete TINYINT(1) DEFAULT 0 COMMENT '软删除标记',
+  INDEX idx_md_lang (md_id, language, is_delete, is_open)
+) COMMENT='商品规格组(门店级)';
+
+-- 规格值(规格组下的可选项,带加价)
+CREATE TABLE food_specs_value (
+  id BIGINT AUTO_INCREMENT PRIMARY KEY,
+  parent_id BIGINT NOT NULL COMMENT '所属规格组id(food_specs.id)',
+  name VARCHAR(64) NOT NULL COMMENT '规格值名(如 无糖)',
+  price DECIMAL(10,2) DEFAULT 0 COMMENT '加价金额(与 pos_food.price 同口径)',
+  note VARCHAR(255) COMMENT '备注',
+  state CHAR(1) COMMENT '状态',
+  is_open TINYINT(1) DEFAULT 1 COMMENT '是否启用',
+  INDEX idx_parent (parent_id, is_open)
+) COMMENT='商品规格值';
+
+-- 商品-规格关联(多对多)
+CREATE TABLE food_spec_relation (
+  id BIGINT AUTO_INCREMENT PRIMARY KEY,
+  food_id BIGINT NOT NULL COMMENT '商品id(pos_food.id)',
+  specs_id BIGINT NOT NULL COMMENT '规格组id(food_specs.id)',
+  INDEX idx_food (food_id),
+  INDEX idx_specs (specs_id)
+) COMMENT='商品-规格关联';
+
+-- pos_food.food_sku 列已存在(text/json,存规格 JSON 冗余),无需新增;
+-- 若历史库缺失该列则执行:ALTER TABLE pos_food ADD COLUMN food_sku TEXT COMMENT '商品属性/规格JSON' AFTER language;
+```

Некоторые файлы не были показаны из-за большого количества измененных файлов