free-delivery.md 9.1 KB

满额免运接口与金额契约(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。

创建示例:

{
  "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。

{
  "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):

{
  "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只需按本契约对接,不重新讨论门槛与预算规则。