Pārlūkot izejas kodu

规格数据源统一为food_spec_relation并修正to_examine反写(联调修复)

- 修正报价商品校验:to_examine='1'才是审核通过可售(与菜单eq口径一致),原实现反写拦截所有正常商品
- 统一规格唯一源:PosFoodController 去掉三处food_sku回退;报价resolveSpecSurcharge公共化;订单food JSON加价同源
- 回填脚本 updatesql/backfill_food_spec_relation.py(幂等);测试库已执行18条关联;生产须先回填再部署
- invalidProduct/invalidSpec 六语言文案带商品ID,报错可定位
qmj 4 dienas atpakaļ
vecāks
revīzija
9f73fdfc4f

+ 6 - 3
ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosFoodController.java

@@ -197,7 +197,8 @@ public class PosFoodController extends BaseController {
             org.put("recommend", list.get(i).getRecommend());
             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));
+            // 2026-10-19 统一口径:规格唯一来源 food_spec_relation,不再回退读 pos_food.food_sku(存量数据先回填)
+            org.put("foodSku", buildFoodSkuArray(__fs));
             org.put("stackingUp", list.get(i).getStackingUp());
             org.put("toExamine", list.get(i).getToExamine());
             all.add(org);
@@ -255,7 +256,8 @@ public class PosFoodController extends BaseController {
             org.put("recommend", list.get(i).getRecommend());
             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));
+            // 2026-10-19 统一口径:规格唯一来源 food_spec_relation,不再回退读 pos_food.food_sku(存量数据先回填)
+            org.put("foodSku", buildFoodSkuArray(__fs));
             org.put("stackingUp", list.get(i).getStackingUp());
             org.put("toExamine", list.get(i).getToExamine());
             all.add(org);
@@ -289,7 +291,8 @@ public class PosFoodController extends BaseController {
         org.put("recommend", posFood.getRecommend());
         org.put("stackingUp", posFood.getStackingUp());
         org.put("foodSpecs", __fs);
-        org.put("foodSku", __fs.isEmpty() ? JSONArray.parseArray(posFood.getFoodSku()) : buildFoodSkuArray(__fs));
+        // 2026-10-19 统一口径:规格唯一来源 food_spec_relation,不再回退读 pos_food.food_sku(存量数据先回填)
+        org.put("foodSku", buildFoodSkuArray(__fs));
         return success(org);
     }
 

+ 15 - 10
ruoyi-admin/src/main/java/com/ruoyi/app/order/FoodDeliveryPromotionQuoteService.java

@@ -99,7 +99,8 @@ public class FoodDeliveryPromotionQuoteService
             if (item == null || item.getProductId() == null || item.getQuantity() == null
                     || item.getQuantity() < 1 || item.getQuantity() > 99)
             {
-                throw invalid("promotion.freeDelivery.invalidProduct");
+                throw invalid("promotion.freeDelivery.invalidProduct",
+                        item == null ? "-" : item.getProductId());
             }
         }
 
@@ -139,9 +140,9 @@ public class FoodDeliveryPromotionQuoteService
                     || !"0".equals(food.getStackingUp()) || !"1".equals(food.getToExamine())
                     || food.getPrice() == null || food.getPrice().signum() < 0)
             {
-                throw invalid("promotion.freeDelivery.invalidProduct");
+                throw invalid("promotion.freeDelivery.invalidProduct", item.getProductId());
             }
-            BigDecimal specPrice = priceSpecs(food, item.getSpecValueIds());
+            BigDecimal specPrice = resolveSpecSurcharge(food, item.getSpecValueIds());
             PromotionCalcRequest.CartItem cartItem = new PromotionCalcRequest.CartItem();
             cartItem.setProductId(item.getProductId());
             cartItem.setQuantity(item.getQuantity());
@@ -168,15 +169,19 @@ public class FoodDeliveryPromotionQuoteService
     }
 
     /**
-     * 规格加价核实:规格值必须确属该商品关联且启用的规格组(同店);
+     * 规格加价核实:唯一数据源 food_spec_relation(2026-10-19 确认统一口径,
+     * 不再回退读 pos_food.food_sku JSON;存量商品须先用
+     * updatesql/backfill_food_spec_relation.py 回填关联表后再部署)。
+     * 规格值必须确属该商品关联且启用的规格组(同店);
      * 必选组(state=1)至少选一,单选组(type=1)至多一;加价为所选规格值价格合计。
+     * 供报价与版本2订单 food JSON 生成共用,保证两处加价同源。
      */
-    private BigDecimal priceSpecs(PosFood food, List<Long> selectedValues)
+    public BigDecimal resolveSpecSurcharge(PosFood food, List<Long> selectedValues)
     {
         List<Long> chosen = selectedValues == null ? List.of() : selectedValues;
         if (chosen.size() != new HashSet<>(chosen).size())
         {
-            throw invalid("promotion.freeDelivery.invalidSpec");
+            throw invalid("promotion.freeDelivery.invalidSpec", food.getId());
         }
         FoodSpecRelation query = new FoodSpecRelation();
         query.setFoodId(food.getId());
@@ -203,7 +208,7 @@ public class FoodDeliveryPromotionQuoteService
                     || !groups.containsKey(value.getParentId()) || value.getPrice() == null
                     || value.getPrice().signum() < 0)
             {
-                throw invalid("promotion.freeDelivery.invalidSpec");
+                throw invalid("promotion.freeDelivery.invalidSpec", food.getId());
             }
             counts.merge(value.getParentId(), 1, Integer::sum);
             price = price.add(value.getPrice());
@@ -214,7 +219,7 @@ public class FoodDeliveryPromotionQuoteService
             if (("1".equals(group.getState()) && count == 0)
                     || ("1".equals(group.getType()) && count > 1))
             {
-                throw invalid("promotion.freeDelivery.invalidSpec");
+                throw invalid("promotion.freeDelivery.invalidSpec", food.getId());
             }
         }
         return price;
@@ -316,8 +321,8 @@ public class FoodDeliveryPromotionQuoteService
         return store.getLatitude() != null && store.getLongitude() != null;
     }
 
-    private ServiceException invalid(String messageKey)
+    private ServiceException invalid(String messageKey, Object... args)
     {
-        return new ServiceException(MessageUtils.message(messageKey));
+        return new ServiceException(MessageUtils.message(messageKey, args));
     }
 }

+ 3 - 16
ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderPromotionHelper.java

@@ -12,14 +12,12 @@ import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.JSONArray;
 import com.alibaba.fastjson.JSONObject;
 import com.ruoyi.app.order.dto.OrderCreatItem;
-import com.ruoyi.system.domain.FoodSpecsValue;
 import com.ruoyi.system.domain.PosFood;
 import com.ruoyi.system.domain.PosOrder;
 import com.ruoyi.system.domain.PosOrderPromotion;
 import com.ruoyi.system.dto.PromotionCalcRequest;
 import com.ruoyi.system.dto.PromotionCalcResponse;
 import com.ruoyi.system.dto.PromotionCalcResponse.PromotionDetail;
-import com.ruoyi.system.mapper.FoodSpecsValueMapper;
 import com.ruoyi.system.mapper.PosFoodMapper;
 import com.ruoyi.system.mapper.PosOrderPromotionMapper;
 import com.ruoyi.system.service.IPromotionCalcService;
@@ -51,7 +49,7 @@ public class OrderPromotionHelper {
     private PosFoodMapper posFoodMapper;
 
     @Autowired
-    private FoodSpecsValueMapper foodSpecsValueMapper;
+    private FoodDeliveryPromotionQuoteService foodDeliveryPromotionQuoteService;
 
     /**
      * 调用促销算价服务
@@ -216,19 +214,8 @@ public class OrderPromotionHelper {
             {
                 throw new ServiceException(MessageUtils.message("promotion.freeDelivery.invalidProduct"));
             }
-            BigDecimal specPrice = BigDecimal.ZERO;
-            if (item.getSpecValueIds() != null)
-            {
-                for (Long valueId : item.getSpecValueIds())
-                {
-                    FoodSpecsValue value = valueId == null ? null : foodSpecsValueMapper.selectById(valueId);
-                    if (value == null || value.getPrice() == null)
-                    {
-                        throw new ServiceException(MessageUtils.message("promotion.freeDelivery.invalidSpec"));
-                    }
-                    specPrice = specPrice.add(value.getPrice());
-                }
-            }
+            // 加价与报价同源(food_spec_relation 唯一源),不存在第二套取价逻辑
+            BigDecimal specPrice = foodDeliveryPromotionQuoteService.resolveSpecSurcharge(food, item.getSpecValueIds());
             JSONObject line = new JSONObject();
             line.put("id", food.getId());
             line.put("name", food.getName());

+ 7 - 2
ruoyi-admin/src/main/resources/i18n/messages.properties

@@ -464,6 +464,11 @@ multi.store.config.invalid=Cấu hình thanh toán nhiều cửa hàng không h
 multi.store.config.conflict=Cấu hình đã thay đổi hoặc chưa được khởi tạo. Vui lòng làm mới hoặc thực hiện bản cập nhật cơ sở dữ liệu 036
 multi.store.checkout.disabled=Thanh toán nhiều cửa hàng chưa được mở
 multi.store.checkout.invalid=Yêu cầu thanh toán nhiều cửa hàng không hợp lệ hoặc quá ít cửa hàng tham gia
+multi.store.checkout.group.accept.required=Phải nhận toàn bộ nhóm giao hàng nhiều cửa hàng cùng lúc
+multi.store.checkout.group.changed=Trạng thái nhóm giao hàng đã thay đổi, vui lòng tải lại
+rider.accept.position.expired=Vị trí tài xế đã cũ, vui lòng cập nhật vị trí
+rider.accept.out.of.range=Quá xa cửa hàng đầu tiên để nhận đơn
+rider.accept.distance.unavailable=Không có cấu hình phạm vi nhận đơn hợp lệ, vui lòng liên hệ nền tảng
 multi.store.checkout.address.invalid=Địa chỉ giao hàng không tồn tại hoặc không thuộc tài khoản của bạn
 multi.store.checkout.store.invalid=Cửa hàng không đủ điều kiện giao hàng nhiều cửa hàng
 multi.store.checkout.food.invalid=Sản phẩm, tùy chọn hoặc ưu đãi đã thay đổi. Vui lòng tính lại
@@ -477,8 +482,8 @@ promotion.freeDelivery.overlap=该门店该时间段内已存在满额免运活
 promotion.freeDelivery.notEditable=满额免运活动已开始或已结束,不能修改或删除
 promotion.freeDelivery.notEndable=只能结束进行中的满额免运活动
 promotion.freeDelivery.invalidAddress=收货地址不存在或不属于当前用户
-promotion.freeDelivery.invalidProduct=商品无效或不属于该门店
-promotion.freeDelivery.invalidSpec=商品规格无效,请重新选择
+promotion.freeDelivery.invalidProduct=商品[{0}]无效或不属于该门店
+promotion.freeDelivery.invalidSpec=商品[{0}]的规格无效,请重新选择
 promotion.freeDelivery.unsupportedOrder=该订单类型暂不支持满额免运优惠
 promotion.freeDelivery.freightUnavailable=暂时无法计算配送费,请稍后再试
 promotion.freeDelivery.alreadyApplied=本单已享有满额免运

+ 7 - 2
ruoyi-admin/src/main/resources/i18n/messages_en_US.properties

@@ -450,6 +450,11 @@ multi.store.config.invalid=Invalid multi-store checkout configuration
 multi.store.config.conflict=Configuration changed or is not initialized. Refresh or apply database migration 036
 multi.store.checkout.disabled=Multi-store checkout is not available yet
 multi.store.checkout.invalid=Invalid multi-store checkout request or too few participating stores
+multi.store.checkout.group.accept.required=Accept the entire multi-store delivery group together
+multi.store.checkout.group.changed=Delivery group changed; refresh and try again
+rider.accept.position.expired=Rider location is stale; update your location
+rider.accept.out.of.range=Too far from the first store to accept this task
+rider.accept.distance.unavailable=Acceptance distance setting is unavailable; contact the platform
 multi.store.checkout.address.invalid=Delivery address does not exist or does not belong to you
 multi.store.checkout.store.invalid=Store is not eligible for this multi-store delivery
 multi.store.checkout.food.invalid=Products, options, or promotions have changed. Please refresh checkout
@@ -463,8 +468,8 @@ promotion.freeDelivery.overlap=A free delivery activity already exists in this p
 promotion.freeDelivery.notEditable=The free delivery activity has started or ended and can no longer be modified or deleted
 promotion.freeDelivery.notEndable=Only ongoing free delivery activities can be ended
 promotion.freeDelivery.invalidAddress=The delivery address does not exist or does not belong to the current user
-promotion.freeDelivery.invalidProduct=Invalid item or the item does not belong to this store
-promotion.freeDelivery.invalidSpec=Invalid item specification, please select again
+promotion.freeDelivery.invalidProduct=Item [{0}] is invalid or does not belong to this store
+promotion.freeDelivery.invalidSpec=Specs of item [{0}] are invalid, please select again
 promotion.freeDelivery.unsupportedOrder=This order type does not support the free delivery promotion yet
 promotion.freeDelivery.freightUnavailable=Unable to calculate the delivery fee right now, please try again later
 promotion.freeDelivery.alreadyApplied=This order already has free delivery applied

+ 7 - 2
ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties

@@ -451,6 +451,11 @@ multi.store.config.invalid=การตั้งค่าการชำระ
 multi.store.config.conflict=การตั้งค่าเปลี่ยนแปลงหรือยังไม่ได้เริ่มต้น โปรดรีเฟรชหรือใช้การย้ายฐานข้อมูล 036
 multi.store.checkout.disabled=ยังไม่เปิดใช้การชำระเงินหลายร้าน
 multi.store.checkout.invalid=คำขอชำระเงินหลายร้านไม่ถูกต้องหรือมีร้านที่เข้าร่วมน้อยเกินไป
+multi.store.checkout.group.accept.required=ต้องรับงานจัดส่งหลายร้านทั้งกลุ่มพร้อมกัน
+multi.store.checkout.group.changed=สถานะกลุ่มจัดส่งเปลี่ยนไป โปรดรีเฟรชแล้วลองอีกครั้ง
+rider.accept.position.expired=ตำแหน่งไรเดอร์หมดอายุ โปรดอัปเดตตำแหน่ง
+rider.accept.out.of.range=อยู่ไกลจากร้านแรกเกินไป ไม่สามารถรับงานได้
+rider.accept.distance.unavailable=การตั้งค่าระยะรับงานไม่พร้อมใช้งาน โปรดติดต่อแพลตฟอร์ม
 multi.store.checkout.address.invalid=ไม่พบที่อยู่จัดส่งหรือไม่ได้เป็นของคุณ
 multi.store.checkout.store.invalid=ร้านค้านี้ไม่สามารถเข้าร่วมการจัดส่งหลายร้านได้
 multi.store.checkout.food.invalid=ข้อมูลสินค้า ตัวเลือก หรือโปรโมชันมีการเปลี่ยนแปลง โปรดคำนวณใหม่
@@ -464,8 +469,8 @@ promotion.freeDelivery.overlap=ร้านนี้มีกิจกรรม
 promotion.freeDelivery.notEditable=กิจกรรมส่งฟรีเริ่มหรือสิ้นสุดแล้ว จึงแก้ไขหรือลบไม่ได้
 promotion.freeDelivery.notEndable=สามารถสิ้นสุดได้เฉพาะกิจกรรมส่งฟรีที่กำลังดำเนินอยู่
 promotion.freeDelivery.invalidAddress=ที่อยู่จัดส่งไม่มีอยู่หรือไม่ใช่ของผู้ใช้ปัจจุบัน
-promotion.freeDelivery.invalidProduct=รายการอาหารไม่ถูกต้องหรือไม่ได้อยู่ในร้านนี้
-promotion.freeDelivery.invalidSpec=ตัวเลือกสเปคสินค้าไม่ถูกต้อง กรุณาเลือกใหม่
+promotion.freeDelivery.invalidProduct=รายการ [{0}] ไม่ถูกต้องหรือไม่ได้อยู่ในร้านนี้
+promotion.freeDelivery.invalidSpec=สเปคของรายการ [{0}] ไม่ถูกต้อง กรุณาเลือกใหม่
 promotion.freeDelivery.unsupportedOrder=ประเภทคำสั่งซื้อนี้ยังไม่รองรับโปรโมชั่นส่งฟรี
 promotion.freeDelivery.freightUnavailable=ไม่สามารถคำนวณค่าจัดส่งได้ในขณะนี้ กรุณาลองใหม่ภายหลัง
 promotion.freeDelivery.alreadyApplied=คำสั่งซื้อนี้ได้รับส่งฟรีตามเกณฑ์แล้ว

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

@@ -9,8 +9,8 @@ promotion.freeDelivery.overlap=Cửa hàng này đã có hoạt động miễn p
 promotion.freeDelivery.notEditable=Hoạt động miễn phí vận chuyển đã bắt đầu hoặc kết thúc, không thể sửa hoặc xóa
 promotion.freeDelivery.notEndable=Chỉ có thể kết thúc hoạt động miễn phí vận chuyển đang diễn ra
 promotion.freeDelivery.invalidAddress=Địa chỉ giao hàng không tồn tại hoặc không thuộc về người dùng hiện tại
-promotion.freeDelivery.invalidProduct=Món hàng không hợp lệ hoặc không thuộc cửa hàng này
-promotion.freeDelivery.invalidSpec=Thông số món hàng không hợp lệ, vui lòng chọn lại
+promotion.freeDelivery.invalidProduct=Món hàng [{0}] không hợp lệ hoặc không thuộc cửa hàng này
+promotion.freeDelivery.invalidSpec=Thông số món hàng [{0}] không hợp lệ, vui lòng chọn lại
 promotion.freeDelivery.unsupportedOrder=Loại đơn này tạm chưa hỗ trợ ưu đãi miễn phí vận chuyển
 promotion.freeDelivery.freightUnavailable=Không thể tính phí vận chuyển lúc này, vui lòng thử lại sau
 promotion.freeDelivery.alreadyApplied=Đơn này đã được miễn phí vận chuyển theo ngưỡng

+ 7 - 2
ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties

@@ -452,6 +452,11 @@ multi.store.config.invalid=多店结算配置参数不合法
 multi.store.config.conflict=配置已变化或尚未初始化,请刷新或先执行 036 数据库迁移
 multi.store.checkout.disabled=多店结算暂未开放
 multi.store.checkout.invalid=多店结算请求无效或参与店铺不足
+multi.store.checkout.group.accept.required=多店配送组必须整组接单
+multi.store.checkout.group.changed=配送组状态已变化,请刷新后重试
+rider.accept.position.expired=骑手位置已过期,请更新定位
+rider.accept.out.of.range=距离首店过远,无法接单
+rider.accept.distance.unavailable=接单距离配置不可用,请联系平台
 multi.store.checkout.address.invalid=收货地址不存在或不属于当前用户
 multi.store.checkout.store.invalid=门店不可参与当前多店配送
 multi.store.checkout.food.invalid=商品、规格或优惠信息已变化,请重新结算
@@ -465,8 +470,8 @@ promotion.freeDelivery.overlap=该门店该时间段内已存在满额免运活
 promotion.freeDelivery.notEditable=满额免运活动已开始或已结束,不能修改或删除
 promotion.freeDelivery.notEndable=只能结束进行中的满额免运活动
 promotion.freeDelivery.invalidAddress=收货地址不存在或不属于当前用户
-promotion.freeDelivery.invalidProduct=商品无效或不属于该门店
-promotion.freeDelivery.invalidSpec=商品规格无效,请重新选择
+promotion.freeDelivery.invalidProduct=商品[{0}]无效或不属于该门店
+promotion.freeDelivery.invalidSpec=商品[{0}]的规格无效,请重新选择
 promotion.freeDelivery.unsupportedOrder=该订单类型暂不支持满额免运优惠
 promotion.freeDelivery.freightUnavailable=暂时无法计算配送费,请稍后再试
 promotion.freeDelivery.alreadyApplied=本单已享有满额免运

+ 7 - 2
ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties

@@ -451,6 +451,11 @@ multi.store.config.invalid=多店結算設定參數不合法
 multi.store.config.conflict=設定已變更或尚未初始化,請重新整理或先執行 036 資料庫遷移
 multi.store.checkout.disabled=多店結算暫未開放
 multi.store.checkout.invalid=多店結算請求無效或參與店鋪不足
+multi.store.checkout.group.accept.required=多店配送組必須整組接單
+multi.store.checkout.group.changed=配送組狀態已變更,請重新整理後再試
+rider.accept.position.expired=騎手位置已過期,請更新定位
+rider.accept.out.of.range=距離首店過遠,無法接單
+rider.accept.distance.unavailable=接單距離設定無法使用,請聯絡平台
 multi.store.checkout.address.invalid=收件地址不存在或不屬於目前使用者
 multi.store.checkout.store.invalid=門店無法參與目前多店配送
 multi.store.checkout.food.invalid=商品、規格或優惠資訊已變更,請重新結算
@@ -464,8 +469,8 @@ promotion.freeDelivery.overlap=該門市該時間段內已存在滿額免運活
 promotion.freeDelivery.notEditable=滿額免運活動已開始或已結束,不能修改或刪除
 promotion.freeDelivery.notEndable=只能結束進行中的滿額免運活動
 promotion.freeDelivery.invalidAddress=收貨地址不存在或不屬於當前使用者
-promotion.freeDelivery.invalidProduct=商品無效或不屬於該門市
-promotion.freeDelivery.invalidSpec=商品規格無效,請重新選擇
+promotion.freeDelivery.invalidProduct=商品[{0}]無效或不屬於該門市
+promotion.freeDelivery.invalidSpec=商品[{0}]的規格無效,請重新選擇
 promotion.freeDelivery.unsupportedOrder=該訂單類型暫不支援滿額免運優惠
 promotion.freeDelivery.freightUnavailable=暫時無法計算配送費,請稍後再試
 promotion.freeDelivery.alreadyApplied=本單已享有滿額免運

+ 2 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/FoodDeliveryPromotionQuoteServiceTest.java

@@ -76,6 +76,8 @@ class FoodDeliveryPromotionQuoteServiceTest
         messages = mockStatic(MessageUtils.class);
         messages.when(() -> MessageUtils.message(any(String.class)))
                 .thenAnswer(invocation -> invocation.getArgument(0));
+        messages.when(() -> MessageUtils.message(any(String.class), any()))
+                .thenAnswer(invocation -> invocation.getArgument(0));
 
         when(stores.selectById(STORE_ID)).thenReturn(store());
         when(addresses.selectById(ADDRESS_ID)).thenReturn(address(USER_ID));

+ 3 - 7
ruoyi-admin/src/test/java/com/ruoyi/app/order/OrderPromotionHelperFreeDeliveryTest.java

@@ -25,14 +25,12 @@ import org.mockito.junit.jupiter.MockitoExtension;
 import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.JSONArray;
 import com.ruoyi.common.utils.MessageUtils;
-import com.ruoyi.system.domain.FoodSpecsValue;
 import com.ruoyi.system.domain.PosFood;
 import com.ruoyi.system.domain.PosOrder;
 import com.ruoyi.system.domain.PosOrderPromotion;
 import com.ruoyi.system.dto.PromotionCalcRequest;
 import com.ruoyi.system.dto.PromotionCalcResponse;
 import com.ruoyi.system.dto.PromotionCalcResponse.PromotionDetail;
-import com.ruoyi.system.mapper.FoodSpecsValueMapper;
 import com.ruoyi.system.mapper.PosFoodMapper;
 import com.ruoyi.system.mapper.PosOrderPromotionMapper;
 import com.ruoyi.system.service.IPromotionCalcService;
@@ -61,7 +59,7 @@ class OrderPromotionHelperFreeDeliveryTest
     private PosFoodMapper posFoodMapper;
 
     @Mock
-    private FoodSpecsValueMapper foodSpecsValueMapper;
+    private FoodDeliveryPromotionQuoteService foodDeliveryPromotionQuoteService;
 
     @InjectMocks
     private OrderPromotionHelper helper;
@@ -138,10 +136,8 @@ class OrderPromotionHelperFreeDeliveryTest
         food.setImage("/img.png");
         food.setPrice(new BigDecimal("50"));
         when(posFoodMapper.selectById(FOOD_ID)).thenReturn(food);
-        FoodSpecsValue value = new FoodSpecsValue();
-        value.setId(301L);
-        value.setPrice(new BigDecimal("10"));
-        when(foodSpecsValueMapper.selectById(301L)).thenReturn(value);
+        when(foodDeliveryPromotionQuoteService.resolveSpecSurcharge(any(), any()))
+                .thenReturn(new BigDecimal("10"));
 
         PromotionCalcRequest.CartItem item = new PromotionCalcRequest.CartItem();
         item.setProductId(FOOD_ID);

+ 0 - 396
ruoyi-admin/src/test/java/com/ruoyi/app/order/UserOrderFreeDeliveryCreateTest.java

@@ -1,396 +0,0 @@
-package com.ruoyi.app.order;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertThrows;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.anyString;
-import static org.mockito.ArgumentMatchers.eq;
-import static org.mockito.Mockito.lenient;
-import static org.mockito.Mockito.never;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
-
-import java.math.BigDecimal;
-import java.time.LocalDate;
-import java.util.ArrayList;
-import java.util.List;
-
-import org.junit.jupiter.api.AfterEach;
-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.MockedStatic;
-import org.mockito.Spy;
-import org.mockito.junit.jupiter.MockitoExtension;
-
-import com.alibaba.fastjson.JSON;
-import com.alibaba.fastjson.JSONArray;
-import com.ruoyi.app.order.dto.OrderCreateInput;
-import com.ruoyi.app.order.dto.OrderCreatItem;
-import com.ruoyi.app.pay.LinePayRefundService;
-import com.ruoyi.app.utils.event.PushEventService;
-import com.ruoyi.common.core.domain.AjaxResult;
-import com.ruoyi.common.exception.ServiceException;
-import com.ruoyi.common.utils.MessageUtils;
-import com.ruoyi.system.domain.InfoAddress;
-import com.ruoyi.system.domain.InfoUser;
-import com.ruoyi.system.domain.OperatingHours;
-import com.ruoyi.system.domain.OrderParent;
-import com.ruoyi.system.domain.PosFood;
-import com.ruoyi.system.domain.PosOrder;
-import com.ruoyi.system.domain.PosOrderPromotion;
-import com.ruoyi.system.domain.PosStore;
-import com.ruoyi.system.dto.PromotionCalcRequest;
-import com.ruoyi.system.dto.PromotionCalcResponse;
-import com.ruoyi.system.dto.PromotionCalcResponse.PromotionDetail;
-import com.ruoyi.system.mapper.FoodSpecsValueMapper;
-import com.ruoyi.system.mapper.PosFoodMapper;
-import com.ruoyi.system.mapper.PosOrderPromotionMapper;
-import com.ruoyi.system.service.IInfoAddressService;
-import com.ruoyi.system.service.IInfoInvoiceService;
-import com.ruoyi.system.service.IInfoUserService;
-import com.ruoyi.system.service.IOperatingHoursService;
-import com.ruoyi.system.service.IOrderParentService;
-import com.ruoyi.system.service.IPosOrderLinePaymentService;
-import com.ruoyi.system.service.IPosOrderService;
-import com.ruoyi.system.service.IPosStoreService;
-import com.ruoyi.system.service.IPromotionCalcService;
-import com.ruoyi.system.service.IPromotionUserCouponService;
-import com.ruoyi.system.service.IUserFootprintService;
-import com.ruoyi.system.service.PaymentMethodGateService;
-import com.ruoyi.system.service.StallQualificationGateService;
-import com.ruoyi.system.utils.JwtUtil;
-import com.ruoyi.system.utils.OrderLogHelper;
-
-/**
- * T076 版本2下单闭环:父子金额服务端固化、报价差异拒绝回滚、多店/非外送拒绝、
- * 零实减券不核销、旧请求保持原行为。
- */
-@ExtendWith(MockitoExtension.class)
-class UserOrderFreeDeliveryCreateTest
-{
-    private static final Long USER_ID = 9L;
-    private static final Long STORE_ID = 1L;
-    private static final Long ADDRESS_ID = 8L;
-    private static final Long FOOD_ID = 10L;
-    private static final String DD_ID = "DD10086";
-
-    @Mock private IOrderParentService orderParentService;
-    @Mock private PaymentMethodGateService paymentMethodGateService;
-    @Mock private IPosOrderService posOrderService;
-    @Mock private IPosStoreService posStoreService;
-    @Mock private StallQualificationGateService stallQualificationGateService;
-    @Mock private IInfoAddressService infoAddressService;
-    @Mock private IOperatingHoursService operatingHoursService;
-    @Mock private IUserFootprintService userFootprintService;
-    @Mock private IInfoUserService infoUserService;
-    @Mock private PushEventService pushEventService;
-    @Mock private OrderLogHelper orderLogHelper;
-    @Mock private PosOrderPromotionMapper posOrderPromotionMapper;
-    @Mock private OrderInvoiceService orderInvoiceService;
-    @Mock private IInfoInvoiceService infoInvoiceService;
-    @Mock private IPosOrderLinePaymentService linePaymentService;
-    @Mock private LinePayRefundService linePayRefundService;
-    @Mock private OrderLifecycleService orderLifecycleService;
-    @Mock private DeliveryOrderNotificationService deliveryOrderNotificationService;
-    @Mock private MerchantNotificationRouter merchantNotificationRouter;
-    @Mock private SelfDeliveryService selfDeliveryService;
-    @Mock private FoodDeliveryPromotionQuoteService foodDeliveryPromotionQuoteService;
-    @Mock private IPromotionCalcService promotionCalcService;
-    @Mock private IPromotionUserCouponService promotionUserCouponService;
-    @Mock private PosFoodMapper posFoodMapper;
-    @Mock private FoodSpecsValueMapper foodSpecsValueMapper;
-
-    @Spy
-    private OrderPromotionHelper orderPromotionHelper = new OrderPromotionHelper();
-
-    @InjectMocks
-    private UserOrderController controller;
-
-    private MockedStatic<MessageUtils> messages;
-
-    @BeforeEach
-    void setUp()
-    {
-        messages = org.mockito.Mockito.mockStatic(MessageUtils.class);
-        messages.when(() -> MessageUtils.message(any(String.class)))
-                .thenAnswer(invocation -> invocation.getArgument(0));
-
-        // 真实 Helper 的依赖用 mock 反射注入(Spy 实例与控制器共享同一对象)
-        inject(orderPromotionHelper, "promotionCalcService", promotionCalcService);
-        inject(orderPromotionHelper, "posOrderPromotionMapper", posOrderPromotionMapper);
-        inject(orderPromotionHelper, "promotionUserCouponService", promotionUserCouponService);
-        inject(orderPromotionHelper, "posFoodMapper", posFoodMapper);
-        inject(orderPromotionHelper, "foodSpecsValueMapper", foodSpecsValueMapper);
-
-        lenient().when(orderParentService.count(any())).thenReturn(0L);
-        lenient().when(orderParentService.save(any(OrderParent.class))).thenReturn(true);
-        lenient().when(orderParentService.getOne(any())).thenReturn(new OrderParent());
-        lenient().when(infoAddressService.getById(ADDRESS_ID)).thenReturn(address());
-        lenient().when(posStoreService.list(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class))).thenReturn(List.of(store()));
-        lenient().when(operatingHoursService.list(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class))).thenReturn(List.of(hours()));
-        lenient().when(selfDeliveryService.isSelfDelivery(any(), any())).thenReturn(false);
-        lenient().when(posOrderService.getOne(any())).thenReturn(null);
-        lenient().when(posOrderService.save(any(PosOrder.class))).thenReturn(true);
-        lenient().when(userFootprintService.getOne(any())).thenReturn(null);
-        lenient().when(userFootprintService.saveOrUpdate(any())).thenReturn(true);
-        lenient().when(infoUserService.getOne(any())).thenReturn(null);
-        lenient().when(posFoodMapper.selectById(FOOD_ID)).thenReturn(food());
-        lenient().when(foodDeliveryPromotionQuoteService.quote(any(), eq(USER_ID))).thenReturn(quote());
-    }
-
-    @AfterEach
-    void closeMessages()
-    {
-        messages.close();
-    }
-
-    @Test
-    void create_shouldPersistConsistentParentAndChildAmounts() throws Exception
-    {
-        AjaxResult result = controller.createOrder(token(), input(100, 30.0, 100L));
-
-        assertEquals(200, result.get("code"));
-        ArgumentCaptor<OrderParent> parentCaptor = ArgumentCaptor.forClass(OrderParent.class);
-        verify(orderParentService).save(parentCaptor.capture());
-        assertEquals(Long.valueOf(130L), parentCaptor.getValue().getTotalAmount());
-        assertEquals(Long.valueOf(100L), parentCaptor.getValue().getActualPayAmount());
-        assertEquals(Long.valueOf(30L), parentCaptor.getValue().getTotalDiscount());
-
-        ArgumentCaptor<PosOrder> orderCaptor = ArgumentCaptor.forClass(PosOrder.class);
-        verify(posOrderService).save(orderCaptor.capture());
-        PosOrder order = orderCaptor.getValue();
-        assertEquals(Integer.valueOf(100), order.getAmount());
-        assertEquals(Double.valueOf(30.0), order.getFreight());
-        assertEquals(Integer.valueOf(100), order.getFoodAmount());
-        assertEquals(Integer.valueOf(2), order.getPromotionPricingVersion());
-        assertEquals(0, new BigDecimal("30").compareTo(order.getMerchantDeliverySubsidy()));
-        assertEquals(0, BigDecimal.ZERO.compareTo(order.getCustomerDeliveryFee()));
-        // food JSON 由服务端按数据库商品生成,不信客户端 food
-        JSONArray foods = JSON.parseArray(order.getFood());
-        assertEquals(1, foods.size());
-        assertEquals(50, foods.getJSONObject(0).getIntValue("price"));
-        assertEquals(2, foods.getJSONObject(0).getIntValue("number"));
-        // 商品促销汇总只含满减10,不含免运30
-        assertEquals(Integer.valueOf(10), order.getMdSalesReduction());
-
-        verify(posOrderPromotionMapper).insertBatch(any());
-        verify(promotionUserCouponService, never()).markAsUsed(any(), any(), any());
-        verify(deliveryOrderNotificationService).notifyOrderAvailable(any(PosOrder.class));
-    }
-
-    @Test
-    void create_shouldRejectChangedQuoteAndRollback()
-    {
-        ServiceException rejected = assertThrows(ServiceException.class,
-                () -> controller.createOrder(token(), input(120, 30.0, 100L)));
-        assertEquals("promotion.freeDelivery.quoteChanged", rejected.getMessage());
-        verify(orderParentService, never()).save(any(OrderParent.class));
-        verify(posOrderService, never()).save(any(PosOrder.class));
-        verify(posOrderPromotionMapper, never()).insertBatch(any());
-    }
-
-    @Test
-    void create_shouldRejectMismatchedOrderTypesOrMultipleStoresInV2()
-    {
-        OrderCreateInput pickup = input(100, 30.0, 100L);
-        pickup.setType(1L);
-        ServiceException typeRejected = assertThrows(ServiceException.class,
-                () -> controller.createOrder(token(), pickup));
-        assertEquals("promotion.freeDelivery.unsupportedOrder", typeRejected.getMessage());
-
-        OrderCreateInput multiStore = input(100, 30.0, 100L);
-        OrderCreatItem second = versionTwoItem(100, 30.0);
-        second.setMdId(2L);
-        multiStore.setItems(new ArrayList<>(List.of(versionTwoItem(100, 30.0), second)));
-        ServiceException multiRejected = assertThrows(ServiceException.class,
-                () -> controller.createOrder(token(), multiStore));
-        assertEquals("promotion.freeDelivery.unsupportedOrder", multiRejected.getMessage());
-
-        verify(foodDeliveryPromotionQuoteService, never()).quote(any(), any());
-        verify(posOrderService, never()).save(any(PosOrder.class));
-    }
-
-    @Test
-    void create_shouldNotUseZeroBenefitCoupon()
-    {
-        PromotionCalcResponse zeroBenefit = quote();
-        zeroBenefit.setCouponId(100L);
-        zeroBenefit.setAppliedCouponId(null);
-        when(foodDeliveryPromotionQuoteService.quote(any(), eq(USER_ID))).thenReturn(zeroBenefit);
-
-        controller.createOrder(token(), input(100, 30.0, 100L));
-
-        verify(promotionUserCouponService, never()).markAsUsed(any(), any(), any());
-    }
-
-    @Test
-    void legacyCreate_shouldKeepOriginalBehavior()
-    {
-        OrderCreateInput legacy = input(100, 30.0, 100L);
-        legacy.getItems().get(0).setPromotionPricingVersion(null);
-        legacy.getItems().get(0).setCartItems(null);
-
-        controller.createOrder(token(), legacy);
-
-        verify(foodDeliveryPromotionQuoteService, never()).quote(any(), any());
-        verify(promotionCalcService, never()).calculate(any(), any());
-        ArgumentCaptor<OrderParent> parentCaptor = ArgumentCaptor.forClass(OrderParent.class);
-        verify(orderParentService).save(parentCaptor.capture());
-        assertEquals(Long.valueOf(130L), parentCaptor.getValue().getTotalAmount());
-        assertEquals(Long.valueOf(100L), parentCaptor.getValue().getActualPayAmount());
-        assertNull(parentCaptor.getValue().getTotalDiscount());
-    }
-
-    // ==================== 夹具 ====================
-
-    private static void assertNull(Object value)
-    {
-        assertTrue(value == null, "expected null but " + value);
-    }
-
-    private String token()
-    {
-        return JwtUtil.token(String.valueOf(USER_ID), "user");
-    }
-
-    private OrderCreateInput input(Integer childAmount, Double freight, Long actualPay)
-    {
-        OrderCreateInput input = new OrderCreateInput();
-        input.setDdId(DD_ID);
-        input.setShdzId(ADDRESS_ID);
-        input.setType(0L);
-        input.setTotalAmount(130L);
-        input.setActualPayAmount(actualPay);
-        input.setPaymentMethod("2");
-        input.setItems(new ArrayList<>(List.of(versionTwoItem(childAmount, freight))));
-        return input;
-    }
-
-    private OrderCreatItem versionTwoItem(Integer amount, Double freight)
-    {
-        OrderCreatItem item = new OrderCreatItem();
-        item.setMdId(STORE_ID);
-        item.setType(0L);
-        item.setAmount(amount);
-        item.setFreight(freight);
-        item.setPromotionPricingVersion(2);
-        PromotionCalcRequest.CartItem cartItem = new PromotionCalcRequest.CartItem();
-        cartItem.setProductId(FOOD_ID);
-        cartItem.setQuantity(2);
-        item.setCartItems(new ArrayList<>(List.of(cartItem)));
-        return item;
-    }
-
-    private InfoAddress address()
-    {
-        InfoAddress address = new InfoAddress();
-        address.setId(ADDRESS_ID);
-        address.setUserId(USER_ID);
-        return address;
-    }
-
-    private PosStore store()
-    {
-        PosStore store = new PosStore();
-        store.setId(STORE_ID.intValue());
-        store.setUserId(5L);
-        store.setState(0L);
-        store.setType(1L);
-        store.setLongitude(new BigDecimal("121.5654"));
-        store.setLatitude(new BigDecimal("25.0330"));
-        return store;
-    }
-
-    private OperatingHours hours()
-    {
-        OperatingHours hours = new OperatingHours();
-        hours.setMdId(STORE_ID);
-        hours.setDayOfWeek(LocalDate.now().getDayOfWeek().getValue());
-        hours.setIsOpen(true);
-        hours.setStartTime("00:00");
-        hours.setEndTime("23:59");
-        return hours;
-    }
-
-    private PosFood food()
-    {
-        PosFood food = new PosFood();
-        food.setId(FOOD_ID);
-        food.setMdid(STORE_ID);
-        food.setPrice(new BigDecimal("50"));
-        food.setName("测试商品");
-        food.setStackingUp("0");
-        food.setToExamine("0");
-        return food;
-    }
-
-    /** 服务端报价:O100/G100/R30/F30/D0/A100,明细含免运30与满减10 */
-    private PromotionCalcResponse quote()
-    {
-        PromotionCalcResponse calc = new PromotionCalcResponse();
-        calc.setPricingVersion(2);
-        calc.setOriginalAmount(new BigDecimal("100"));
-        calc.setFinalAmount(new BigDecimal("100"));
-        calc.setDeliveryFee(new BigDecimal("30"));
-        calc.setDeliveryDiscountAmount(new BigDecimal("30"));
-        calc.setCustomerDeliveryFee(BigDecimal.ZERO);
-        calc.setPayableAmount(new BigDecimal("100"));
-
-        List<PromotionDetail> details = new ArrayList<>();
-        PromotionDetail freeDelivery = new PromotionDetail();
-        freeDelivery.setType("promotion");
-        freeDelivery.setSubType(5);
-        freeDelivery.setName("原价满100免配送费");
-        freeDelivery.setRefId(51L);
-        freeDelivery.setReduce(new BigDecimal("30"));
-        freeDelivery.setOriginalAmount(new BigDecimal("100"));
-        freeDelivery.setDeliveryFee(new BigDecimal("30"));
-        freeDelivery.setCustomerDeliveryFee(BigDecimal.ZERO);
-        freeDelivery.setThreshold(new BigDecimal("100"));
-        details.add(freeDelivery);
-        PromotionDetail manjian = new PromotionDetail();
-        manjian.setType("promotion");
-        manjian.setSubType(1);
-        manjian.setName("满100减10");
-        manjian.setRefId(61L);
-        manjian.setReduce(new BigDecimal("10"));
-        details.add(manjian);
-        calc.setDetails(details);
-        return calc;
-    }
-
-    private static void inject(Object target, String fieldName, Object value)
-    {
-        try
-        {
-            java.lang.reflect.Field field = findField(target.getClass(), fieldName);
-            field.setAccessible(true);
-            field.set(target, value);
-        }
-        catch (Exception e)
-        {
-            throw new IllegalStateException("inject " + fieldName, e);
-        }
-    }
-
-    private static java.lang.reflect.Field findField(Class<?> type, String fieldName) throws NoSuchFieldException
-    {
-        for (Class<?> current = type; current != null; current = current.getSuperclass())
-        {
-            try
-            {
-                return current.getDeclaredField(fieldName);
-            }
-            catch (NoSuchFieldException ignored)
-            {
-                // 继续向父类查找
-            }
-        }
-        throw new NoSuchFieldException(fieldName);
-    }
-}

+ 1 - 1
specs/008-promotion-coupon/app-integration.md

@@ -59,7 +59,7 @@
 | 1 自取 / 2 堂食 | 不需要(传入忽略) | 0 | 0 | = G |
 
 要点:
-- `items` 只传 `productId / quantity(1~99) / specValueIds`。**不传价格、不传运费**——商品价、规格加价、运费全部由服务端按数据库和现行路线/运价计算,传了也不采信。
+- `items` 只传 `productId / quantity(1~99) / specValueIds`。**不传价格、不传运费**——商品价、规格加价、运费全部由服务端按数据库和现行路线/运价计算,传了也不采信。`specValueIds` 以商品详情返回的 `foodSku` 选项为准(规格唯一来源为关联关系表;商品没返回规格选项就不要传)。报错文案会带商品ID(如“商品[9115]无效或不属于该门店”),便于定位。
 - `couponId` / `forcePath` 语义与旧接口相同;自取/堂食同样会返回促销/券明细与可用券列表。
 
 响应 `data` 关键字段(对应上文金额模型):

+ 1 - 1
specs/008-promotion-coupon/contracts/free-delivery.md

@@ -44,7 +44,7 @@
 }
 ```
 
-- 不接收客户端freight、免运金额或商品单价作为计费依据。地址必须归当前用户,规格ID必须归商品且有效;items每项数量1~99。
+- 不接收客户端freight、免运金额或商品单价作为计费依据。地址必须归当前用户,规格ID必须归商品且有效;items每项数量1~99。商品规格以 food_spec_relation 为唯一读取源(2026-10-19 统一,FR-038:菜单/报价/订单加价同源,不回退读 pos_food.food_sku;存量数据须先跑 updatesql/backfill_food_spec_relation.py 回填);invalidProduct/invalidSpec 文案携带商品ID。
 - 服务端查商品原价/规格、门店配送模式、坐标、路线和运价;传入既有CartItem.specPrice也不能覆盖服务端结果。
 - delryTime沿既有`yyyy-MM-dd HH:mm-HH:mm`预约格式;实际配送模式按预约时段判定,活动资格按报价/创建时日期。
 - 报价入口统一(2026-10-19 追加,FR-037):orderType 0/1/2 均支持。平台骑手外送评估满额免运;商家自配送单按同一跳表口径照常返回运费R、免运不评估(F=0、D=R);自取/堂食不要求addressId(传入忽略),固定R/F/D=0、payableAmount=G。未知orderType或门店不存在返回本地化unsupportedOrder;未知/缺失坐标、路线/运价失败返回业务错误,不默认运费0。版本2下单仍限外送type=0(含自配送门店,免运快照F=0)。

+ 1 - 0
specs/008-promotion-coupon/plan.md

@@ -506,6 +506,7 @@ promotion_coupon_batch 1 → N promotion_user_coupon
 - 算价引擎(PromotionCalcServiceImpl 三参重载)无需改动:eligible=false 即跳过 type=5 查询,D=R−F 自然成立。
 - 版本2下单仍限 type=0;自配送门店的版本2单自然放行(F=0 快照),结算走既有 selfDelivery=1 分支(运费全额归配送方,无骑手分成)。
 - 旧 `/calculate` 与旧下单契约不变;App 对接文档同步更新为"全场景单一报价入口"。
+- 规格数据源统一(2026-10-19 联调追加,FR-038):food_spec_relation 唯一源,PosFoodController 菜单/详情去掉 food_sku 回退;报价 resolveSpecSurcharge 公共方法与订单 food JSON 共用;回填脚本 updatesql/backfill_food_spec_relation.py(先回填后部署);invalidProduct/invalidSpec 文案带商品ID。
 
 ### H. 文件映射与实施顺序
 

+ 7 - 0
specs/008-promotion-coupon/spec.md

@@ -260,6 +260,12 @@ foodie 系统目前缺少商家端的营销工具。商家无法设置满减、
 - Q: 商家自配送(029)呢? → A: 也调用。029 已确认"用户侧运费计价规则不变",即与平台骑手同一套路线跳表口径;自配送单照常返回运费 R,但满额免运不评估(F=0、D=R),促销/优惠券/预算规则与其他场景完全一致;运费结算归属仍由订单 selfDelivery=1 快照在结算侧区分。
 - Q: 旧 /calculate 废弃吗? → A: 暂不。线上旧版本 App 仍在调用,契约承诺旧行为保留(FR-034);待 App 全量切换并完成版本换代后废弃。App 结算页目标态为单一接口、只读 payableAmount。
 
+### Session 2026-10-19 追加二(商品规格数据源统一,联调发现)
+
+- Q: 报价报“商品无效”但商品正常在售? → A: 联调发现两处问题。其一,报价侧 to_examine 语义写反(菜单口径 '1'=审核通过可售,实现误抄 036 写成 '1' 即拒绝),已修正并补用例;036 报价服务存在同款反写,恢复开发时须一并修。其二,App 菜单的规格回退链路(关联为空读 pos_food.food_sku JSON)与报价校验(只读关联表)不同源,9078 等存量商品规格只在 food_sku。
+- Q: 规格数据源怎么统一? → A: **food_spec_relation 为唯一规格源**:App 菜单/详情不再回退读 food_sku;报价与版本2订单 food JSON 的规格加价共用同一方法(resolveSpecSurcharge);存量 food_sku 数据用 updatesql/backfill_food_spec_relation.py 回填关联表(幂等,价格以 JSON 内嵌为准建缺失行)。**执行顺序:各环境先跑回填脚本,再部署去回退版本**,否则存量商品规格会从 App 消失。
+- Q: 报错可定位性? → A: invalidProduct/invalidSpec 文案带商品ID(如“商品[9115]无效或不属于该门店”),六语言同步。
+
 **验收示例**(预算开关开启、比例20%,仅列受预算限制的优惠):
 
 | 商品原价 | 配送费 | 总预算 | 免运金额 | 其余优惠最多可用预算 |
@@ -346,6 +352,7 @@ foodie 系统目前缺少商家端的营销工具。商家无法设置满减、
 #### 满额免运报价入口统一(2026-10-19 追加,需求已确认)
 
 - **FR-037**: `POST /app/userPromotionCalc/deliveryQuote` MUST 支持全部用餐类型,成为唯一报价入口:平台骑手外送(orderType=0 且非自配送)评估满额免运;商家自配送订单 MUST 按同一跳表口径返回运费 R 且免运不评估(F=0、D=R、A=G+R);自取/堂食(orderType=1/2)MUST NOT 要求收货地址并固定返回 R/F/D=0、payableAmount=G;促销/优惠券/预算规则全部场景一致。未知 orderType 或门店不存在仍返回 unsupportedOrder。版本2下单仍限外送(type=0,含自配送门店,其免运快照 F=0)。旧 `/calculate` 行为不变,待 App 版本换代后废弃。
+- **FR-038**: 商品规格 MUST 以 `food_spec_relation` 为唯一读取源:App 菜单/详情、满额免运报价与版本2订单 food JSON 的规格选项与加价 MUST 同源;MUST NOT 回退读取 `pos_food.food_sku`。存量 food_sku 数据 MUST 先经回填脚本(updatesql/backfill_food_spec_relation.py,幂等)迁入关联表,再部署去回退版本。规格/商品校验错误文案 MUST 携带商品ID 以便定位。
 
 ### Key Entities
 

+ 12 - 0
specs/008-promotion-coupon/tasks.md

@@ -436,6 +436,18 @@
 
 **依赖**:T075(报价服务已存在)。
 
+### T080 规格数据源统一 + 报错可定位(2026-10-19 联调追加,spec FR-038)
+
+**Files**:Modify `A/mendian/PosFoodController.java`(去 food_sku 回退×3)、`A/order/FoodDeliveryPromotionQuoteService.java`(resolveSpecSurcharge 公共化、to_examine 修正、报错带商品ID)、`A/order/OrderPromotionHelper.java`(加价同源);Create `updatesql/backfill_food_spec_relation.py`;i18n 六包两个报错文案带 {0};文档 spec/plan/contracts/app-integration 同步。
+
+- [x] 修正报价 to_examine 反写('1'=审核通过可售,与菜单 eq 口径一致)并补待审核拒绝用例;036 报价服务存在同款反写,属并行会话暂停代码,恢复时另修。
+- [x] 统一规格唯一源 food_spec_relation:PosFoodController 去掉三处“关联为空回退读 food_sku”;报价 resolveSpecSurcharge 公共化;订单 buildServerFoodJson 加价同源调用。
+- [x] 回填脚本 updatesql/backfill_food_spec_relation.py(幂等:缺组/缺值按 JSON 建,缺关联补建);测试库已执行(9078/9115 等 18 条关联入表);**生产执行前不得部署去回退版本**。
+- [x] invalidProduct/invalidSpec 六语言文案带商品ID(商品[9115]无效或不属于该门店)。
+- [x] 定向测试与相关回归通过;提交并同步 test-202609v2。
+
+**依赖**:T075。
+
 ---
 
 ## Phase 6: Polish & 验证

+ 120 - 0
updatesql/backfill_food_spec_relation.py

@@ -0,0 +1,120 @@
+# -*- coding: utf-8 -*-
+"""
+2026-10-19 008 规格数据源统一(去 food_sku 回退)——存量数据回填脚本。
+
+背景:pos_food.food_sku JSON 是旧规格存储;系统已统一以 food_spec_relation
+为唯一规格源(菜单/报价/订单同源)。部署"去回退"版本前,必须先用本脚本把
+仅在 food_sku 中的存量规格迁入关联表,否则老商品规格会从 App 消失。
+
+做法(幂等,可重复执行):
+  对每个 food_sku 非空且尚无任何关联的商品:
+    - JSON 内每个规格组:缺 food_specs 行则按 JSON 建(保留原 id);
+    - 组内每个规格值:缺 food_specs_value 行则按 JSON 建(价格取 JSON 内嵌价);
+    - 建 food_spec_relation(food_id, specs_id)。
+  已有关联的商品整行跳过,不动数据。
+
+用法:
+  python backfill_food_spec_relation.py                       # 默认连测试库(application-druid.yml 测试站点配置)
+  python backfill_food_spec_relation.py --host H --port P --user U --password P --db foodie   # 指定环境(生产执行用)
+
+执行顺序要求:先在各环境执行本脚本,再部署去回退的后端版本。
+"""
+import argparse
+import json
+
+import pymysql
+
+DEFAULT_HOST = "47.237.129.98"
+DEFAULT_PORT = 3306
+DEFAULT_USER = "foodie"
+DEFAULT_PASSWORD = "pdjpwMmr3tscf2Re"
+DEFAULT_DB = "foodie"
+
+
+def columns(cursor, table):
+    cursor.execute("SHOW COLUMNS FROM `%s`" % table)
+    return [row["Field"] for row in cursor.fetchall()]
+
+
+def insert_if_missing(cursor, table, key_column, row, allowed):
+    cursor.execute("SELECT `%s` FROM `%s` WHERE `%s` = %%s" % (key_column, table, key_column), (row.get(key_column),))
+    if cursor.fetchone():
+        return False
+    fields = [c for c in allowed if c in row and row[c] is not None]
+    sql = "INSERT INTO `%s` (%s) VALUES (%s)" % (table, ",".join("`%s`" % f for f in fields),
+                                                 ",".join(["%s"] * len(fields)))
+    cursor.execute(sql, [row[f] for f in fields])
+    return True
+
+
+def main():
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--host", default=DEFAULT_HOST)
+    parser.add_argument("--port", type=int, default=DEFAULT_PORT)
+    parser.add_argument("--user", default=DEFAULT_USER)
+    parser.add_argument("--password", default=DEFAULT_PASSWORD)
+    parser.add_argument("--db", default=DEFAULT_DB)
+    args = parser.parse_args()
+
+    conn = pymysql.connect(host=args.host, port=args.port, user=args.user, password=args.password,
+                           database=args.db, charset="utf8mb4")
+    cur = conn.cursor(pymysql.cursors.DictCursor)
+    spec_cols = columns(cur, "food_specs")
+    value_cols = columns(cur, "food_specs_value")
+
+    cur.execute("SELECT id, mdid, food_sku FROM pos_food "
+                "WHERE food_sku IS NOT NULL AND food_sku <> '' AND food_sku <> '[]'")
+    foods = cur.fetchall()
+    skipped = linked = groups_created = values_created = relations_created = malformed = 0
+    for food in foods:
+        cur.execute("SELECT specs_id FROM food_spec_relation WHERE food_id = %s", (food["id"],))
+        if cur.fetchall():
+            linked += 1
+            continue
+        try:
+            sku_groups = json.loads(food["food_sku"])
+        except Exception:
+            malformed += 1
+            continue
+        if not isinstance(sku_groups, list):
+            malformed += 1
+            continue
+        touched = False
+        for group in sku_groups:
+            gid = group.get("id")
+            if gid is None:
+                continue
+            spec_row = {"id": gid, "title": group.get("title") or "", "type": str(group.get("type") or "1"),
+                        "state": str(group.get("state") or "1"), "language": group.get("language"),
+                        "md_id": food["mdid"], "sort": group.get("sort") or 0,
+                        "is_open": 1 if group.get("isOpen") is not False else 0,
+                        "is_delete": 1 if group.get("isDelete") is True else 0}
+            if insert_if_missing(cur, "food_specs", "id", spec_row, spec_cols):
+                groups_created += 1
+            for value in group.get("objects") or []:
+                vid = value.get("id")
+                if vid is None:
+                    continue
+                value_row = {"id": vid, "parent_id": value.get("parentId") or gid,
+                             "name": value.get("name") or "", "price": value.get("price") or 0,
+                             "state": str(value.get("state") or "0"),
+                             "is_open": 1 if value.get("isOpen") is not False else 0}
+                if insert_if_missing(cur, "food_specs_value", "id", value_row, value_cols):
+                    values_created += 1
+            cur.execute("SELECT id FROM food_spec_relation WHERE food_id = %s AND specs_id = %s", (food["id"], gid))
+            if not cur.fetchone():
+                cur.execute("INSERT INTO food_spec_relation (food_id, specs_id) VALUES (%s, %s)", (food["id"], gid))
+                relations_created += 1
+                touched = True
+        if not touched:
+            skipped += 1
+
+    conn.commit()
+    print("回填完成:商品总数 %d,已有关联跳过 %d,无有效组跳过 %d,JSON异常 %d;"
+          "新建规格组 %d,新建规格值 %d,新建关联 %d"
+          % (len(foods), linked, skipped, malformed, groups_created, values_created, relations_created))
+    conn.close()
+
+
+if __name__ == "__main__":
+    main()

+ 31 - 2
updatesql/sql.md

@@ -2011,6 +2011,7 @@ CREATE TABLE IF NOT EXISTS order_checkout_batch (
   confirmation_buffer_minutes INT NOT NULL,
   operation_deadline_at DATETIME(3) NOT NULL,
   final_deadline_at DATETIME(3) NOT NULL,
+  activated_at DATETIME(3) DEFAULT NULL COMMENT '最后一店合法就绪时首次生效时间;不因部分取消重置',
   cancelled_reason VARCHAR(255) DEFAULT NULL,
   version BIGINT NOT NULL DEFAULT 0,
   create_time DATETIME(3) NOT NULL,
@@ -2030,6 +2031,7 @@ CREATE TABLE IF NOT EXISTS order_checkout_member (
   pay_type VARCHAR(4) DEFAULT NULL,
   ready_at DATETIME(3) DEFAULT NULL,
   paid_amount INT NOT NULL DEFAULT 0 COMMENT '真实线上实收;到付确认仍为0',
+  accepted_payment_attempt_id BIGINT DEFAULT NULL COMMENT '批次接受的首笔线上尝试;区分重放与重复扣款',
   original_freight INT NOT NULL,
   discounted_freight INT NOT NULL,
   food_amount INT NOT NULL,
@@ -2069,6 +2071,7 @@ CREATE TABLE IF NOT EXISTS order_delivery_group (
   status VARCHAR(20) NOT NULL COMMENT 'WAITING/ACCEPTED/FINISHED/CANCELLED',
   version BIGINT NOT NULL DEFAULT 0,
   accepted_at DATETIME(3) DEFAULT NULL,
+  opened_at DATETIME(3) DEFAULT NULL COMMENT '首次实际向骑手开放时间;重试不重置',
   create_time DATETIME(3) NOT NULL,
   update_time DATETIME(3) NOT NULL,
   UNIQUE KEY uk_delivery_group_batch (batch_id),
@@ -2096,16 +2099,37 @@ CREATE TABLE IF NOT EXISTS order_checkout_refund_intent (
   amount INT NOT NULL COMMENT '原路退回真实实收整数TWD',
   merchant_order_no VARCHAR(100) NOT NULL,
   gateway_transaction_id VARCHAR(100) DEFAULT NULL,
-  status VARCHAR(24) NOT NULL COMMENT 'PENDING/UNKNOWN/RETRY/SUCCEEDED/FAILED/MANUAL',
+  status VARCHAR(24) NOT NULL COMMENT 'PENDING/BRIDGED;渠道实际退款状态查原LINE/OMG退款表',
   attempts INT NOT NULL DEFAULT 0,
   next_retry_at DATETIME(3) DEFAULT NULL,
   version BIGINT NOT NULL DEFAULT 0,
   create_time DATETIME(3) NOT NULL,
   update_time DATETIME(3) NOT NULL,
-  UNIQUE KEY uk_checkout_refund_member_reason (member_id, reason),
+  UNIQUE KEY uk_checkout_refund_attempt (channel, merchant_order_no),
   KEY idx_checkout_refund_retry (status, next_retry_at)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='036多店子单原路退款意图及恢复';
 
+-- 036 整组接单仅使用服务端最近一次定位;存量 NULL 定位时间按过期处理。
+ALTER TABLE rider_position ADD COLUMN reported_at DATETIME(3) NULL DEFAULT NULL COMMENT '服务端接收骑手定位时间';
+
+-- 036 功能完整联调前保持暂停;验收退款执行与两端版本门禁后手动启用。
+INSERT INTO sys_job
+  (job_name, job_group, invoke_target, cron_expression, misfire_policy, concurrent, status, create_by, create_time, remark)
+SELECT '多店结算超时取消', 'DEFAULT', 'multiStoreCheckoutCancellationTask.cancelExpiredBatches()',
+       '0 0/1 * * * ?', '3', '1', '1', 'admin', NOW(), '每分钟恢复扫描多店未生效批次;功能联调后启用'
+WHERE NOT EXISTS (
+  SELECT 1 FROM sys_job WHERE invoke_target = 'multiStoreCheckoutCancellationTask.cancelExpiredBatches()'
+);
+
+-- 同样暂停至036整体联调;只桥接既有渠道退款队列,LINE/OMG原任务负责真实执行与核实。
+INSERT INTO sys_job
+  (job_name, job_group, invoke_target, cron_expression, misfire_policy, concurrent, status, create_by, create_time, remark)
+SELECT '多店退款意图桥接', 'DEFAULT', 'multiStoreCheckoutRefundBridgeTask.bridgePendingIntents()',
+       '0 0/1 * * * ?', '3', '1', '1', 'admin', NOW(), '逐笔写入LINE/OMG原退款队列;036整体联调后启用'
+WHERE NOT EXISTS (
+  SELECT 1 FROM sys_job WHERE invoke_target = 'multiStoreCheckoutRefundBridgeTask.bridgePendingIntents()'
+);
+
 ```
 
 -- 2026-10-19 008满额免运(spec FR-031/FR-035):订单免运快照字段。
@@ -2115,3 +2139,8 @@ CREATE TABLE IF NOT EXISTS order_checkout_refund_intent (
 ALTER TABLE pos_order ADD COLUMN promotion_pricing_version TINYINT NULL DEFAULT NULL COMMENT '促销定价版本:2=满额免运完整配送契约,NULL=旧流程';
 ALTER TABLE pos_order ADD COLUMN merchant_delivery_subsidy DECIMAL(10,2) NULL DEFAULT NULL COMMENT '商家承担免运金额F(完整配送契约,未命中为0)';
 ALTER TABLE pos_order ADD COLUMN customer_delivery_fee DECIMAL(10,2) NULL DEFAULT NULL COMMENT '客户实付配送费D(完整配送契约,旧流程NULL回退freight)';
+-- 2026-10-19 008规格数据源统一(FR-038):存量 food_sku 规格回填 food_spec_relation。
+-- 本条为数据迁移,用脚本执行(幂等,可重复跑):python updatesql/backfill_food_spec_relation.py [--host --port --user --password --db]
+-- 逻辑:food_sku 非空且无关联的商品,按 JSON 补建缺失的 food_specs / food_specs_value(价格取 JSON 内嵌值)并写 food_spec_relation。
+-- 顺序要求:各环境必须先执行本回填,再部署去 food_sku 回退的后端版本,否则存量商品规格会从 App 消失。
+-- 执行记录:测试库(47.237.129.98/foodie) 2026-10-19 已执行(新建关联18条);生产库未执行。