Forráskód Böngészése

满额免运报价入口统一:deliveryQuote支持自取/堂食/商家自配送

- orderType白名单0/1/2:自取/堂食免地址、固定R/F/D=0、A=G(spec FR-037)
- 商家自配送不再拒绝:运费照常按同一跳表口径计算,免运不评估(F=0、D=R)
- 促销/优惠券/预算规则全场景一致;版本2下单仍限外送(含自配送门店)
- 同步contracts/spec/plan/tasks与App对接文档(全场景单一报价入口)
qmj 4 napja
szülő
commit
3e746ae7f9

+ 36 - 23
ruoyi-admin/src/main/java/com/ruoyi/app/order/FoodDeliveryPromotionQuoteService.java

@@ -39,10 +39,11 @@ import com.ruoyi.system.utils.GetArea;
 /**
  * 满额免运完整配送报价服务(008 T075,spec FR-033/FR-034)。
  *
- * 服务端核实门店、配送模式(仅平台骑手单店外送)、在售商品、规格归属与数量、
- * 收货地址归属,并按现行 Google 路线距离与运价跳表计算原配送费 R,构造可信
- * PromotionDeliveryContext 调用算价三参重载。客户端传入的运费、免运金额、
- * 商品单价一律不作为计费依据;路线/运价不可用时返回业务错误,不默认运费0。
+ * 服务端核实门店、在售商品、规格归属与数量,并按用餐类型构造可信
+ * PromotionDeliveryContext 调用算价三参重载(2026-10-19 追加 FR-037 报价入口统一):
+ * 平台骑手外送按 Google 路线与运价跳表计算 R 并评估满额免运;商家自配送 R 照算但
+ * 免运不评估(F=0);自取/堂食免地址、R/F/D=0、A=G。客户端传入的运费、免运金额、
+ * 商品单价一律不作为计费依据;外送路线/运价不可用时返回业务错误,不默认运费0。
  */
 @Service
 public class FoodDeliveryPromotionQuoteService
@@ -85,8 +86,10 @@ public class FoodDeliveryPromotionQuoteService
      */
     public PromotionCalcResponse quote(FoodDeliveryPromotionQuoteRequest request, Long userId)
     {
-        if (request == null || request.getStoreId() == null
-                || !Integer.valueOf(0).equals(request.getOrderType())
+        boolean delivery = request != null && Integer.valueOf(0).equals(request.getOrderType());
+        boolean pickupOrDineIn = request != null && (Integer.valueOf(1).equals(request.getOrderType())
+                || Integer.valueOf(2).equals(request.getOrderType()));
+        if (request == null || request.getStoreId() == null || (!delivery && !pickupOrDineIn)
                 || request.getItems() == null || request.getItems().isEmpty())
         {
             throw invalid("promotion.freeDelivery.unsupportedOrder");
@@ -105,22 +108,26 @@ public class FoodDeliveryPromotionQuoteService
         {
             throw invalid("promotion.freeDelivery.unsupportedOrder");
         }
-        if (!hasCoordinates(store))
+        boolean selfDeliveryMode = false;
+        InfoAddress address = null;
+        if (delivery)
         {
-            throw invalid("promotion.freeDelivery.freightUnavailable");
-        }
-        // 实际配送模式以门店自配送配置与(预约单)判定时段为准,不信客户端 selfDelivery
-        if (selfDeliveryService.isSelfDelivery(store,
-                selfDeliveryService.resolveJudgeTime(request.getDelryTime())))
-        {
-            throw invalid("promotion.freeDelivery.unsupportedOrder");
-        }
+            if (!hasCoordinates(store))
+            {
+                throw invalid("promotion.freeDelivery.freightUnavailable");
+            }
+            // 实际配送模式以门店自配送配置与(预约单)判定时段为准,不信客户端 selfDelivery;
+            // 自配送单不再拒绝:运费照常按同一跳表口径报价,仅满额免运不评估(spec FR-037)
+            selfDeliveryMode = selfDeliveryService.isSelfDelivery(store,
+                    selfDeliveryService.resolveJudgeTime(request.getDelryTime()));
 
-        InfoAddress address = addresses.selectById(request.getAddressId());
-        if (address == null || !userId.equals(address.getUserId()) || !hasCoordinates(address))
-        {
-            throw invalid("promotion.freeDelivery.invalidAddress");
+            address = addresses.selectById(request.getAddressId());
+            if (address == null || !userId.equals(address.getUserId()) || !hasCoordinates(address))
+            {
+                throw invalid("promotion.freeDelivery.invalidAddress");
+            }
         }
+        // 自取/堂食:无配送环节,addressId 不校验不使用,固定 R/F/D=0、A=G
 
         // 逐商品核实归属/在售/数量,规格加价由服务端按数据库计算
         List<PromotionCalcRequest.CartItem> cart = new ArrayList<>();
@@ -141,16 +148,22 @@ public class FoodDeliveryPromotionQuoteService
             cart.add(cartItem);
         }
 
-        PosFreight fare = currentFreight();
-        BigDecimal distanceMeters = routeDistanceMeters(store, address);
-        BigDecimal deliveryFee = originalFreight(fare, distanceMeters);
+        BigDecimal deliveryFee = BigDecimal.ZERO;
+        if (delivery)
+        {
+            PosFreight fare = currentFreight();
+            BigDecimal distanceMeters = routeDistanceMeters(store, address);
+            deliveryFee = originalFreight(fare, distanceMeters);
+        }
 
         PromotionCalcRequest calcRequest = new PromotionCalcRequest();
         calcRequest.setStoreId(request.getStoreId());
         calcRequest.setItems(cart);
         calcRequest.setCouponId(request.getCouponId());
         calcRequest.setForcePath(request.getForcePath());
-        return promotions.calculate(calcRequest, userId, new PromotionDeliveryContext(deliveryFee, true));
+        // 满额免运仅平台骑手外送评估;自配送与自取/堂食 eligible=false(F=0,D 语义见契约)
+        return promotions.calculate(calcRequest, userId,
+                new PromotionDeliveryContext(deliveryFee, delivery && !selfDeliveryMode));
     }
 
     /**

+ 2 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/user/UserPromotionCalcController.java

@@ -84,7 +84,8 @@ public class UserPromotionCalcController extends BaseController {
      *
      * 商品/规格价格、收货地址归属、配送模式与原配送费均由服务端核实;
      * 响应含 deliveryFee(R)/deliveryDiscountAmount(F)/customerDeliveryFee(D)/payableAmount(A)
-     * 与预算扩展字段;仅支持单店平台骑手外送(orderType=0)。
+     * 与预算扩展字段。全场景统一入口(FR-037):平台骑手外送评估满额免运;
+     * 商家自配送 R 照算、免运不评估;自取/堂食(orderType=1/2)免地址、R/F/D=0、A=G。
      *
      * @param token   用户登录token
      * @param request 完整配送报价请求DTO

+ 50 - 10
ruoyi-admin/src/test/java/com/ruoyi/app/order/FoodDeliveryPromotionQuoteServiceTest.java

@@ -137,20 +137,21 @@ class FoodDeliveryPromotionQuoteServiceTest
     }
 
     @Test
-    void quote_shouldRejectUnsupportedOrderTypes()
+    void quote_shouldRejectUnknownOrderTypeOrMissingStore()
     {
         FoodDeliveryPromotionQuoteService spy = spyWithRoute(BigDecimal.valueOf(3000));
 
-        FoodDeliveryPromotionQuoteRequest pickup = request();
-        pickup.setOrderType(1);
-        ServiceException selfPickup = assertThrows(ServiceException.class,
-                () -> spy.quote(pickup, USER_ID));
-        assertEquals("promotion.freeDelivery.unsupportedOrder", selfPickup.getMessage());
+        FoodDeliveryPromotionQuoteRequest unknown = request();
+        unknown.setOrderType(3);
+        ServiceException badType = assertThrows(ServiceException.class,
+                () -> spy.quote(unknown, USER_ID));
+        assertEquals("promotion.freeDelivery.unsupportedOrder", badType.getMessage());
 
-        when(selfDelivery.isSelfDelivery(any(), any())).thenReturn(true);
-        ServiceException selfDeliveryOrder = assertThrows(ServiceException.class,
-                () -> spy.quote(request(), USER_ID));
-        assertEquals("promotion.freeDelivery.unsupportedOrder", selfDeliveryOrder.getMessage());
+        FoodDeliveryPromotionQuoteRequest nullType = request();
+        nullType.setOrderType(null);
+        ServiceException missingType = assertThrows(ServiceException.class,
+                () -> spy.quote(nullType, USER_ID));
+        assertEquals("promotion.freeDelivery.unsupportedOrder", missingType.getMessage());
 
         when(stores.selectById(STORE_ID)).thenReturn(null);
         ServiceException missingStore = assertThrows(ServiceException.class,
@@ -158,6 +159,45 @@ class FoodDeliveryPromotionQuoteServiceTest
         assertEquals("promotion.freeDelivery.unsupportedOrder", missingStore.getMessage());
     }
 
+    @Test
+    void quote_shouldSupportPickupAndDineInWithoutAddress()
+    {
+        // T079 报价入口统一:自取/堂食免地址、无运费无免运,A=G
+        int call = 0;
+        for (Integer orderType : new Integer[] { 1, 2 })
+        {
+            call++;
+            FoodDeliveryPromotionQuoteService spy = spyWithRoute(BigDecimal.valueOf(3000));
+            FoodDeliveryPromotionQuoteRequest pickup = request();
+            pickup.setOrderType(orderType);
+            pickup.setAddressId(null);
+
+            spy.quote(pickup, USER_ID);
+
+            ArgumentCaptor<PromotionDeliveryContext> contextCaptor = ArgumentCaptor.forClass(PromotionDeliveryContext.class);
+            verify(promotions, org.mockito.Mockito.times(call)).calculate(any(), eq(USER_ID), contextCaptor.capture());
+            PromotionDeliveryContext context = contextCaptor.getAllValues().get(call - 1);
+            assertEquals(0, BigDecimal.ZERO.compareTo(context.getDeliveryFee()));
+            assertEquals(false, context.isEligible());
+        }
+        verify(addresses, org.mockito.Mockito.never()).selectById(any());
+    }
+
+    @Test
+    void quote_shouldQuoteSelfDeliveryWithoutFreeDelivery()
+    {
+        // T079:自配送不再拒绝——运费照算(同跳表),免运不评估(eligible=false)
+        when(selfDelivery.isSelfDelivery(any(), any())).thenReturn(true);
+        FoodDeliveryPromotionQuoteService spy = spyWithRoute(BigDecimal.valueOf(3500));
+
+        spy.quote(request(), USER_ID);
+
+        ArgumentCaptor<PromotionDeliveryContext> contextCaptor = ArgumentCaptor.forClass(PromotionDeliveryContext.class);
+        verify(promotions).calculate(any(), eq(USER_ID), contextCaptor.capture());
+        assertEquals(0, new BigDecimal("30").compareTo(contextCaptor.getValue().getDeliveryFee()));
+        assertEquals(false, contextCaptor.getValue().isEligible());
+    }
+
     @Test
     void quote_shouldFailOnMissingRouteOrFare()
     {

+ 24 - 0
ruoyi-system/src/test/java/com/ruoyi/system/service/impl/PromotionCalcServiceImplFreeDeliveryTest.java

@@ -329,6 +329,30 @@ class PromotionCalcServiceImplFreeDeliveryTest
         assertEquals(0, countDetail(response, "coupon", 0));
     }
 
+    @Test
+    void calculate_shouldSkipFreeDeliveryWhenContextNotEligible()
+    {
+        stubBudget(true, "20");
+
+        // 自取/堂食/自配送:上下文 eligible=false,即使门店有生效免运活动也不评估
+        PromotionCalcResponse response = service.calculate(request(), USER_ID,
+                new PromotionDeliveryContext(new BigDecimal("30"), false));
+
+        assertAmount("0", response.getDeliveryDiscountAmount());
+        assertAmount("30", response.getCustomerDeliveryFee());
+        assertNull(response.getFreeDeliveryActivityId());
+        assertAmount("130", response.getPayableAmount());
+        assertEquals(0, countDetail(response, "promotion", 5));
+        verify(activityMapper, never()).selectActiveFreeDeliveryByStoreId(eq(STORE_ID), any(Date.class));
+
+        // 自取/堂食 R=0:D=0,A=G
+        PromotionCalcResponse pickup = service.calculate(request(), USER_ID,
+                new PromotionDeliveryContext(BigDecimal.ZERO, false));
+        assertAmount("0", pickup.getDeliveryFee());
+        assertAmount("0", pickup.getCustomerDeliveryFee());
+        assertAmount("100", pickup.getPayableAmount());
+    }
+
     @Test
     void legacyCalculate_shouldKeepTwoArgBehaviorWithoutDelivery()
     {

+ 178 - 0
specs/008-promotion-coupon/app-integration.md

@@ -0,0 +1,178 @@
+# 满额免运 App 端对接文档(008,2026-10-19)
+
+> 后端已上线(分支 `codex/036-multi-store-checkout` 提交 `e907f511`,v2 线 `test-202609v2` 提交 `25d7bcdb`)。
+> 完整契约见 [contracts/free-delivery.md](./contracts/free-delivery.md);本文面向 App(用户端)开发,只讲怎么接。
+
+## 1. 功能与范围
+
+- 商家可配置「原价满 X 元免配送费」促销(type=5)。用户**无需领券**,商品原价合计达到门槛自动生效。
+- 命中后**客户运费实付为 0**,运费由商家承担;骑手配送费照旧按原运费结算,App 不用关心商家与骑手之间的交款。
+- **报价入口已统一(2026-10-19 追加 FR-037)**:`/deliveryQuote` 支持全部用餐类型——平台骑手外送评估满额免运;商家自配送运费照算但免运不生效;自取/堂食免地址、无运费。闪送、多店合并下单不适用。
+- 预约单(delryTime 非空)同样支持,配送模式与活动资格都按预约时段判断。
+
+## 2. 金额模型(展示口径以此为准)
+
+| 符号 | 字段 | 含义 |
+|---|---|---|
+| O | `originalAmount` | 商品原价合计(含规格加价,不含运费) |
+| G | `finalAmount` | 商品应付(已扣促销/新客/券)。**沿用旧接口语义,不含运费** |
+| R | `deliveryFee` | 原配送费(骑手结算基数) |
+| F | `deliveryDiscountAmount` | 满额免运减免额(命中=R,未命中=0) |
+| D | `customerDeliveryFee` | **客户实付配送费**(命中=0,未命中=R) |
+| A | `payableAmount` | **客户整单应付 = G + D**。下单、支付都用这个数 |
+
+结算页展示(命中示例:原价 100、运费 30):
+
+| 项目 | 金额 |
+|---|---:|
+| 商品应付 G | 100 |
+| 原配送费 R | 30 |
+| 满额免运优惠 −F | −30 |
+| **用户实付 A** | **100** |
+
+未命中(如原价 99 < 门槛 100):没有免运行,`customerDeliveryFee=30`,实付 = G + 30。
+
+## 3. 新接口:完整配送报价
+
+**`POST /app/userPromotionCalc/deliveryQuote`**(Header:`token`;与旧 `/calculate` 并存,旧接口行为不变)
+
+请求(结算页每次进入/改商品/换券/切路径都调用):
+
+```json
+{
+  "storeId": 1,
+  "orderType": 0,
+  "addressId": 8,
+  "delryTime": null,
+  "items": [{ "productId": 10, "quantity": 2, "specValueIds": [301] }],
+  "couponId": null,
+  "forcePath": null
+}
+```
+
+各场景返回差异(促销/优惠券/预算规则完全一致,App 无需分支判断,一律读 `payableAmount`):
+
+| orderType | addressId | 运费 R | 免运 F | 实付 A |
+|---|---|---|---|---|
+| 0 平台骑手外送 | 必填 | 服务端路线跳表算 | 命中门槛则 F=R | G + D |
+| 0 商家自配送(服务端判定) | 必填 | 同一套跳表算 | 恒为 0 | G + R |
+| 1 自取 / 2 堂食 | 不需要(传入忽略) | 0 | 0 | = G |
+
+要点:
+- `items` 只传 `productId / quantity(1~99) / specValueIds`。**不传价格、不传运费**——商品价、规格加价、运费全部由服务端按数据库和现行路线/运价计算,传了也不采信。
+- `couponId` / `forcePath` 语义与旧接口相同;自取/堂食同样会返回促销/券明细与可用券列表。
+
+响应 `data` 关键字段(对应上文金额模型):
+
+```json
+{
+  "pricingVersion": 2,
+  "originalAmount": 100,
+  "finalAmount": 100,
+  "deliveryFee": 30,
+  "deliveryDiscountAmount": 30,
+  "customerDeliveryFee": 0,
+  "payableAmount": 100,
+  "freeDeliveryActivityId": 51,
+  "freeDeliveryThreshold": 100,
+  "discountBudget": 20,
+  "remainingDiscountBudget": 0,
+  "appliedCouponId": null,
+  "details": [
+    { "type": "promotion", "subType": 5, "name": "原价满100免配送费", "refId": 51,
+      "reduce": 30, "originalAmount": 100, "deliveryFee": 30,
+      "customerDeliveryFee": 0, "threshold": 100 }
+  ],
+  "availableCoupons": []
+}
+```
+
+- `details` 里 `subType=5` 的明细就是免运行,直接渲染名称和 `reduce`。
+- `freeDeliveryActivityId` 非 null 即命中;为 null 未命中(不要伪造免运行)。
+- **券实际生效以 `appliedCouponId` 为准**(仅实减>0 时返回)。`couponId` 只是用户选择意图,实减为 0 时不会被核销。
+- 命中免运时 `availableCoupons` 中免配送费券 `usable=false`,`unusableReason` 已本地化("本单已享受满额免运"),直接展示即可。
+- 路径 A/B 已由服务端按**整单应付 A**(含运费、含预算封顶后)自动选优,App 沿用 `optimalPath` 展示即可;用户手动切换仍传 `forcePath`。
+
+## 4. 下单:版本 2 显式接入
+
+继续用 **`POST /system/userOrder/createOrder`**,在 `items[0]` 增加 `promotionPricingVersion: 2`:
+
+```json
+{
+  "ddId": "DD10086",
+  "shdzId": 8,
+  "type": 0,
+  "totalAmount": 130,
+  "actualPayAmount": 100,
+  "paymentMethod": "2",
+  "items": [{
+    "mdId": 1,
+    "type": 0,
+    "amount": 100,
+    "freight": 30,
+    "foodAmount": 100,
+    "promotionPricingVersion": 2,
+    "delryTime": null,
+    "cartItems": [{ "productId": 10, "quantity": 2, "specValueIds": [301] }],
+    "couponId": null,
+    "forcePath": null,
+    "food": []
+  }]
+}
+```
+
+**必须遵守:**
+- `items` 必须只有 1 个、父/子 `type` 都为 0(**版本2下单仍限外送**,含商家自配送门店,其免运快照 F=0);自取/堂食下单继续走旧流程;否则返回 `unsupportedOrder`。
+- `item.amount` = 报价的 `payableAmount`(A),`item.freight` = 报价的 `deliveryFee`(R)——**免运时 freight 也照传 30,不要改成 0**;父单 `actualPayAmount` = A。服务端会在同一事务内重新报价并逐项核对。
+- `cartItems` 必传(含 `specValueIds`);`food` 里的价格服务端会忽略并用数据库价格重写。
+
+**错误处理:**(业务错误走既有 AjaxResult 错误机制,`msg` 返回**按用户语言本地化后的文案**;下表 key 仅作对照,不要拿 key 字符串做 UI 判断)
+
+| key | 场景 | App 处理 |
+|---|---|---|
+| `promotion.freeDelivery.quoteChanged` | 报价过期/金额被改动(活动结束、改价、运价变化) | 提示"价格已变化",**自动重新调 deliveryQuote 刷新结算页**,让用户确认新金额后再下单 |
+| `promotion.freeDelivery.duplicateOrder` | ddId 重复提交 | 提示勿重复下单,刷新订单列表 |
+| `promotion.freeDelivery.unsupportedOrder` | 自取/堂食/多店传了 version=2 下单,或报价时 orderType 不是 0/1/2 | 报价不会遇到(0/1/2 都支持);下单时这些场景不要带 `promotionPricingVersion`,走旧流程 |
+| `promotion.freeDelivery.freightUnavailable` | 路线/运价暂不可用 | 提示稍后再试,结算页运费显示加载失败,不要默认 0 |
+| `promotion.freeDelivery.invalidAddress` / `invalidProduct` / `invalidSpec` | 地址不归属/商品下架/规格失效 | 提示并引导重新选择 |
+
+## 5. 订单查询侧的新字段
+
+订单详情/列表(返回 PosOrder 的接口,如 `getParentOrderDetail`)新增三个**派生交款字段**(仅外送单返回,其余为 null;金额由后端按订单快照和当前支付方式计算,App 只展示、不计算):
+
+| 场景(G100/R30/F30/D0/A100) | merchantPayToRider | riderPayToMerchant | customerCollectAmount |
+|---|---:|---:|---:|
+| 在线已付 | 30 | 0 | 0 |
+| 到付(payType=1) | 0 | 70 | 100 |
+| 到付且 A<R(如 A20/R30) | 10 | 0 | 20 |
+
+- 到付场景骑手端向客户收 `customerCollectAmount`;用户切换支付方式后重新查询即返回新方向。
+- 订单上 `freight` 仍是原运费 R=30、`amount`=A,**不是 bug**,不要在 UI 上把 freight 当客户运费展示;客户运费请用 `customerDeliveryFee`(旧订单该字段为 null 时回退显示 freight)。
+
+## 6. 明确不要做的事
+
+1. 不要用旧 `/calculate` 的 `finalAmount` 当整单应付——它只是商品应付 G,不含运费。外送结算页请整体切到 `deliveryQuote`。
+2. 不要客户端自己判断门槛/算免运金额——预算优先级(免运先全额预留、其余优惠用剩余预算、半价豁免)都在服务端,App 展示返回值即可。
+3. 不要在下单时把 `freight` 清零或传自算运费——服务端核对不一致会拒绝下单。
+4. 未接入 version=2 前不要在旧下单流程里传免运相关字段——旧契约原样保留,混传会被当普通请求处理或报错。
+5. 结算页不要按用餐类型分接口——目标态是**所有场景只调 `/deliveryQuote`**;旧 `/calculate` 仅为线上旧版本保留,新版本不要新增调用。
+
+## 7. App 验收清单
+
+- [ ] 结算页(外送)改调 `deliveryQuote`;展示 G / R / 免运行(−F) / 实付 A
+- [ ] 门槛边界:原价 100 命中、99 不命中;命中后再加券/促销 G 降低仍免运
+- [ ] 命中时免配送费券置灰并展示"本单已享受满额免运";实减 0 的券不核销
+- [ ] forcePath 切换 A/B 后金额正确(服务端已按 A 选优)
+- [ ] 下单传 `promotionPricingVersion=2`;篡改 amount/freight 触发 quoteChanged 并能自动重新报价
+- [ ] 到付/在线订单详情三方交款字段正确;切换支付方式后方向更新
+- [ ] 预约单(delryTime)报价与下单一致
+- [ ] 自取/堂食结算页调用 /deliveryQuote(A=G,无免运行);下单不带 version=2,行为与旧版一致
+- [ ] 商家自配送门店结算页:运费正常显示(服务端算),无免运行;version=2 下单成功且 F=0
+- [ ] 重复点击下单被 duplicateOrder 拦截
+- [ ] 四语言错误提示正常(错误文案由后端 i18n 返回)
+
+## 8. 依赖与未决
+
+- 后端依赖数据库执行 `updatesql/sql.md` 末尾 2026-10-19 的 3 条 `ALTER TABLE pos_order`(各环境各自执行后方可下单 version=2)。
+- 商家端(foodie-store)第五 Tab 已上线,测试商户可自行配置门槛活动。
+- 骑手端/商家 App 的展示不在本文范围(派生字段已在订单数据中,两端按需取用)。

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

@@ -47,7 +47,7 @@
 - 不接收客户端freight、免运金额或商品单价作为计费依据。地址必须归当前用户,规格ID必须归商品且有效;items每项数量1~99。
 - 服务端查商品原价/规格、门店配送模式、坐标、路线和运价;传入既有CartItem.specPrice也不能覆盖服务端结果。
 - delryTime沿既有`yyyy-MM-dd HH:mm-HH:mm`预约格式;实际配送模式按预约时段判定,活动资格按报价/创建时日期。
-- 只支持本轮平台骑手单店外送,其余返回本地化unsupportedOrder错误;未知/缺失坐标、路线/运价失败返回业务错误,不默认运费0。
+- 报价入口统一(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)。
 
 以下为`AjaxResult.data`内关键字段示例(原价100、配送费30、预算20,普通商品优惠实减0):
 

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

@@ -499,6 +499,14 @@ promotion_coupon_batch 1 → N promotion_user_coupon
 - 电子发票沿用既有“不含客户支付运费”口径,新流程invoiceTotal=A−D;原G100/R30免运例应开100,不能因freight仍为30而开70。两处计算同步。
 - LINE Pay、OMG、退款以及037无人接单取消只引用A与渠道实际交易;回调/退款用现有幂等和真实渠道能力。只增加回归断言,不重新设计支付接口,也不在规划阶段运行真实支付/退款。
 
+### G2. 报价入口统一(2026-10-19 追加,spec FR-037)
+
+- `/deliveryQuote` 放开 orderType=1/2:跳过地址/自配送/路线运费,固定 PromotionDeliveryContext(0, eligible=false),响应 R/F/D=0、A=G;传入的 addressId 忽略不校验。
+- 商家自配送门店不再返回 unsupportedOrder:地址/路线/跳表运费照算(029 已确认用户侧计价不变),仅免运不评估——构造 PromotionDeliveryContext(R, eligible=false),F=0、D=R。
+- 算价引擎(PromotionCalcServiceImpl 三参重载)无需改动:eligible=false 即跳过 type=5 查询,D=R−F 自然成立。
+- 版本2下单仍限 type=0;自配送门店的版本2单自然放行(F=0 快照),结算走既有 selfDelivery=1 分支(运费全额归配送方,无骑手分成)。
+- 旧 `/calculate` 与旧下单契约不变;App 对接文档同步更新为"全场景单一报价入口"。
+
 ### H. 文件映射与实施顺序
 
 详见tasks的每批Files/Interfaces/Test。核心新增文件为系统内部PromotionDeliveryContext、商家保存DTO、应用层完整报价请求与Service及对应定向测试;其余只在现有促销、订单、Mapper、发票、账单与商家PromotionActivity.vue挂接。不为该活动新建配置开关、券库存、领券记录、菜单、定时任务或批处理迁移。

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

@@ -254,6 +254,12 @@ foodie 系统目前缺少商家端的营销工具。商家无法设置满减、
 - Q: 骑手配送费是否归零? → A: 仅客户应付配送费归零;商家承担金额、原配送费和客户实付必须区分,骑手配送费仍按原规则结算,避免影响骑手收入或重复结算。
 - Q: 本轮实施范围与执行方式? → A: 后端和商家 PC 一起规划;App 端代码不动。继续更新现有 008 spec-kit 文档,不创建分支;Claude Code 负责后续代码实现。本轮由 Codex 完成规划,不编写生产代码。
 
+### Session 2026-10-19 追加(报价入口统一,已确认)
+
+- Q: 自取/堂食要不要也切到 /deliveryQuote? → A: 要。调整后 /deliveryQuote 成为全部用餐类型的唯一报价入口:自取/堂食不要求收货地址,固定返回 R/F/D=0、A=G(即 finalAmount)。
+- Q: 商家自配送(029)呢? → A: 也调用。029 已确认"用户侧运费计价规则不变",即与平台骑手同一套路线跳表口径;自配送单照常返回运费 R,但满额免运不评估(F=0、D=R),促销/优惠券/预算规则与其他场景完全一致;运费结算归属仍由订单 selfDelivery=1 快照在结算侧区分。
+- Q: 旧 /calculate 废弃吗? → A: 暂不。线上旧版本 App 仍在调用,契约承诺旧行为保留(FR-034);待 App 全量切换并完成版本换代后废弃。App 结算页目标态为单一接口、只读 payableAmount。
+
 **验收示例**(预算开关开启、比例20%,仅列受预算限制的优惠):
 
 | 商品原价 | 配送费 | 总预算 | 免运金额 | 其余优惠最多可用预算 |
@@ -337,6 +343,10 @@ foodie 系统目前缺少商家端的营销工具。商家无法设置满减、
 - **FR-035**: 订单创建后 MUST 固化免运金额和规则快照;活动结束及支付方式切换不得追改已有金额。到付客户待收=客户应付总额;取餐给商家金额=客户应付总额−原配送费,若为负则方向改为商家给骑手其绝对值。在线已付时商家给骑手原配送费;现金交付和账单/钱包不得重复支付同笔配送费。佣金、税仍依既有规则计算。
 - **FR-036**: 命中满额免运时免配送费券 MUST 标为不可用,不重复减免、不核销;因预算不足实减为0的普通券同样不核销。新流程的商品优惠汇总不包含免运,发票原有“不含运费”口径应扣除客户实际支付的配送费,退款上限按实际付款,不将商家承担的免运补贴额外退给用户。
 
+#### 满额免运报价入口统一(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 版本换代后废弃。
+
 ### Key Entities
 
 - **促销活动 (promotion_activity)**: 商家创建的促销规则,包含类型(满减/折扣/第二份半价/新客立减)、状态、时间范围

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

@@ -426,6 +426,16 @@
 
 **依赖顺序**:T074 → T075 → T076 → T077 → T078。实现前后按同一FR/契约核对,不重新询问已确认的原价门槛与免运优先规则。
 
+### T079 报价入口统一(2026-10-19 追加,spec FR-037)
+
+**Files**:Modify `A/order/FoodDeliveryPromotionQuoteService.java`、`A/user/UserPromotionCalcController.java`(仅注释);Test extend `ruoyi-admin/src/test/java/com/ruoyi/app/order/FoodDeliveryPromotionQuoteServiceTest.java`、`ruoyi-system/src/test/java/com/ruoyi/system/service/impl/PromotionCalcServiceImplFreeDeliveryTest.java`;文档同步 contracts/free-delivery.md 与 app-integration.md。
+
+- [x] 先改测试:`quote_shouldSupportPickupAndDineInWithoutAddress`(orderType=1/2 免地址、context(0,false)、不查地址)、`quote_shouldQuoteSelfDeliveryWithoutFreeDelivery`(自配送 R=30 照算、eligible=false、不再 unsupportedOrder)、`calculate_shouldSkipFreeDeliveryWhenContextNotEligible`(eligible=false 时 F=0/D=R/无 type=5 查询);`quote_shouldRejectUnknownOrderType`(orderType=3/null 仍拒绝)。
+- [x] 实现:orderType 白名单 0/1/2;自取/堂食短路配送上下文;自配送 eligible=false 运费照算;运行定向测试通过。
+- [ ] 同步 contracts/free-delivery.md、app-integration.md(全场景单一报价入口、v2 下单含自配送 F=0);提交并 cherry-pick 到 test-202609v2。
+
+**依赖**:T075(报价服务已存在)。
+
 ---
 
 ## Phase 6: Polish & 验证