# 满额免运接口与金额契约(2026-10-19,待实现) 对应spec.md FR-028~FR-036与plan.md本次变更;本文是Claude Code实施契约,接口/字段当前尚未上线。所有金额为与现有订单一致的新台币元,整数元口径,禁止擅自乘100。 ## 1. 范围与兼容 - 商家PC在“营销管理 → 促销活动”创建type=5;用户无需领券,不在CouponBatch新增免运券。 - 只做后端和商家PC;App代码不改。本轮新增完整报价接口供后续接入;旧App保持旧商品算价和旧下单契约,不承诺已支持免运展示。 - 新金额流程通过单店`promotionPricingVersion=2`显式接入;不接入036批次、商家自配送、闪送、自取或堂食。 - 原`POST /app/userPromotionCalc/calculate`的body和`finalAmount`含义保持,不能偷偷让finalAmount包含/扣除配送费。新配送上下文只能由服务端应用层构造。 ## 2. 商家活动管理 沿用`/system/shPromotionActivity`的POST/PUT和既有GET/结束/删除接口,不重新拆参数。登录token在Header;新增业务参数全部在body。 创建示例: ```json { "storeId": 1, "type": 5, "name": "原价满100免配送费", "startTime": "2026-10-19", "endTime": "2026-10-31", "rules": [{ "threshold": 100 }] } ``` 修改在同body增加id,仅允许有效状态未开始的活动;详情同样返回type=5和threshold。规则只能一条且threshold为正整数元,不填写reduceAmount、productId、discountRate。日期按本地整天含首尾日;人工结束优先,状态展示与算价必须一致;同店同类型日期不能重叠。普通商家与夜市场景均验证真实门店管理归属。 ## 3. 新增完整配送报价 `POST /app/userPromotionCalc/deliveryQuote`,Header `token`,明确`FoodDeliveryPromotionQuoteRequest` DTO。 ```json { "storeId": 1, "orderType": 0, "addressId": 8, "delryTime": null, "items": [{ "productId": 10, "quantity": 2, "specValueIds": [] }], "couponId": null, "forcePath": null } ``` - 不接收客户端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)。 以下为`AjaxResult.data`内关键字段示例(原价100、配送费30、预算20,普通商品优惠实减0): ```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 } ] } ``` | 字段 | Java类型 | 定义 | |---|---|---| | pricingVersion | Integer | 新完整配送报价固定2;旧接口可不返回 | | originalAmount / finalAmount | BigDecimal | O商品原价 / G优惠后商品金额,保留旧含义 | | deliveryFee | BigDecimal | R原配送费,也是骑手毛配送费基数 | | deliveryDiscountAmount | BigDecimal | F本活动免运减免;不再从G扣 | | customerDeliveryFee | BigDecimal | D客户实际支付配送费 | | payableAmount | BigDecimal | A=G+D,客户整单应付 | | freeDeliveryActivityId / freeDeliveryThreshold | Long / BigDecimal | 命中活动ID/原价门槛;未命中可为null | | discountBudget / remainingDiscountBudget | BigDecimal | 总预算B / 全部受限优惠分配后剩余预算;开关关闭用null表示无限,不能用0表示 | | couponId / appliedCouponId | Long / Long | 原字段保留用户选择意图;新增字段仅实际生效且实减>0时返回,核销以appliedCouponId为准 | PromotionDetail的originalAmount/deliveryFee/customerDeliveryFee/threshold为type=5快照扩展字段;其他旧明细可为null。活动未命中时不返回虚假type=5优惠记录;R=0时优惠额为0,不核销券。路径A/B保持原字段,新增完整报价默认选应用预算后的A最小路径;forcePath保留手选。 免运命中时免配送费券`usable=false`,理由为本地化“本单已享受满额免运”;不消费该券。预算耗尽的其他券预估实减0、不可用;选择这些券也不生成appliedCouponId。既有无免运场景的旧配送券偏差不借本次扩展全局改写。 ## 4. 新订单与快照 继续使用`UserOrderController`现有`POST /system/userOrder/createOrder`,不创建第二套订单生命周期。新单在`items[0]`增加`promotionPricingVersion: 2`,cartItems提供productId/quantity/specValueIds;父/子type都必须0且只有一家门店。沿用已有shdzId作为addressId。 - item.amount=A、item.freight=R,父actualPayAmount=A;服务端重算并核对,过期/篡改返回`promotion.freeDelivery.quoteChanged`,事务回滚。不要由前端把原freight改为0。 - 商品food JSON、商品原价、优惠汇总、父totalAmount/totalDiscount均由服务端确定;不接受客户端food中的price/otherPrice作为来源。 - 持久化:`promotion_pricing_version=2`、`merchant_delivery_subsidy=F`、`customer_delivery_fee=D`;freight继续R、amount继续A。type=5活动明细写已有pos_order_promotion并固化门槛与金额,不占商品减免汇总。 - 历史订单三字段NULL:不回填、不重算。读取D时仅对旧流程按既有freight回退;version=2却缺少关键金额视为不完整数据,应拒绝计费处理并报告业务错误,不能伪装成普通订单。 - 活动结束或支付方式切换后仍沿快照,不重选活动、重新扣预算或改付款总额;只重算由当前支付方式决定的交款方向。 ## 5. 三方资金与派生响应 仅新增可选响应,不要求App本轮渲染;金额由后端快照/支付方式派生,不新存现金确认状态。 | 场景(G100/R30/F30/D0/A100) | merchantPayToRider | riderPayToMerchant | customerCollectAmount | |---|---:|---:|---:| | 在线已付 | 30 | 0 | 0 | | 到付 | 0 | 70 | 100 | | 到付G20/R30/F30/D0/A20 | 10 | 0 | 20 | 返回的交款字段均为BigDecimal且非负,不能只给一个无方向的负数。字段作为`PosOrder`新增的`@TableField(exist=false)`派生属性/getter从快照计算,保留现有返回PosOrder的查询路径;legacy订单可不返回这些新字段,禁止为三个派生数值新增数据库列。到付依现有collectPayment/payType语义判断;支付切换后的查询即时反映新方向。骑手净收入仍按R减原佣金/税,不把毛运费说成到手金额。现金交付和现有钱包账单不是两次支付。 发票按既有不含客户配送费口径取A−D,免运示例为100;退款最多退渠道实际支付A,不另外退F。新流程商家商品分成基数为G,不重复从餐费减去免运。 ## 6. 验收数值 门槛100、预算比例20%,无半价例外,商品优惠/券按既有可用规则: | O | R | 商品受限优惠 | F | D | G | A | 说明 | |---:|---:|---:|---:|---:|---:|---:|---| | 100 | 30 | 0 | 30 | 0 | 100 | 100 | 免运可超预算 | | 100 | 20 | 0 | 20 | 0 | 100 | 100 | 预算刚好用完 | | 100 | 10 | 10 | 10 | 0 | 90 | 90 | 其他优惠最多剩10 | | 99 | 30 | 0 | 0 | 30 | 99 | 129 | 原价未达标 | | 100 | 30 | 30(预算关闭) | 30 | 0 | 70 | 70 | 优惠后不足门槛仍免运 | | 100 | 30 | 半价25(豁免) | 30 | 0 | 75 | 75 | 保留原半价例外 | ## 7. 错误与交接 新增本地化key至少包含`promotion.freeDelivery.invalidThreshold`、`invalidRules`、`forbiddenStore`、`overlap`、`invalidAddress`、`invalidProduct`、`invalidSpec`、`unsupportedOrder`、`freightUnavailable`、`alreadyApplied`、`quoteChanged`、`invalidSnapshot`(后三段同前缀)。沿用AjaxResult业务错误机制,不硬编码中文。 本轮验收覆盖商家PC及后端新接口;App用户端、商家App、骑手App的渲染与新报价接入未实施,不得在交付中标记已完成。将来App只需按本契约对接,不重新讨论门槛与预算规则。