mcpField
S
spree-order-totals

spree-order-totals

github.com/spree开发10104 次安装4 周前更新
代码仓库

关于 spree-order-totals

spree-order-totals 是面向 Spree 开发者的技能,说明购物车和订单如何通过类型化税费、折扣与费用行计算总额,并介绍重算、下单后金额变更、自定义调整器及店面金额展示。

主要功能

  1. 01类型化税费、折扣和费用行
  2. 02购物车与订单总额公式
  3. 03重算工作流及触发条件
  4. 04订单下单后的金额冻结
  5. 05用于费用和折扣的自定义调整器
  6. 06Store API 金额展示指南

适用场景

  1. 01构建店面总额展示
  2. 02通过调整器添加自定义费用
  3. 03修改已下单订单的折扣或费用
  4. 04排查税费及折扣总额差异

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/spree/agent-skills --skill spree-order-totals

安装后在 Claude Code 中输入/spree-order-totals确认已加载。

详细介绍

Spree 订单总额

购物车或订单的 total 由带类型的金额行汇总而成——不存在多态的 Spree::Adjustment。每种金额都有自己的表、购买记录中对应的反规范化汇总值,以及一套用于保持数据一致的工作流。

从 5.x 的调整项迁移?请参阅 spree-upgrade-5-to-6(spree:migrate_adjustments_to_typed_rows)。

计算公式

total = item_total + delivery_total + adjustment_total
adjustment_total = (all discount rows) + fee_total + additional_tax_total
列(购物车和订单)内容
item_totalΣ 商品行 amount(价格 × 数量),折扣和税费前
delivery_totalΣ 履约 cost(选定的配送费率)
discount_totalΣ 促销折扣行(负值)——不包含手动折扣(见注意事项)
fee_totalΣ 费用行(始终 ≥ 0),包含关税
additional_tax_totalΣ included: false 的税费行——在价格基础上另行加收
included_tax_totalΣ included: true 的税费行——已包含在价格中,仅供参考
adjustment_total折扣 + 费用 + 附加税费
total客户应付金额
payment_total已收款项减去退款
tax_total(方法)included_tax_total + additional_tax_total
amount_due(方法)max(total − payment_total − applied store credit, 0)

商品行和履约记录都带有各自的可调整对象列(discount_total、included_tax_total、additional_tax_total、adjustment_total、taxable_adjustment_total = 折扣、non_taxable_adjustment_total = 费用、pre_tax_amount),因此逐行计算和退货时无需重新查询这些行。

已下单的市场订单还包含 commission_amount_total / commission_tax_total / commission_total——由卖家承担,绝不计入 total(参见 spree-marketplace)。

带类型的行

这三种模型都包含 Spree::TypedAdjustmentLine:双重所有者 cart_id / order_id(必须且只能有一个——读取 row.owner)、label、amount(以及 display_amount)和 metadata。

模型(前缀)关联对象(必须且只能有一个……)类型正负写入方
Spree::TaxLine(tl_)商品行、履约记录或费用—(included 为 true/false、taxability_reason)≥ 0仅税费提供方(参见 spree-taxes)
Spree::Discount(disc_)商品行或履约记录promotion、manual≤ 0促销调整器;管理员(manual)
Spree::Fee(fee_)商品行、履约记录或都不关联(订单级)surcharge、handling、gift_wrap、cod、payment、duty(可扩展)≥ 0调整器、集成和管理员
  • 没有订单级折扣行。 订单范围内的促销或手动折扣会按最大余数法,根据每个商品行折后基数分摊——因此部分退货时能按正确比例退款,无需猜测。
  • 行会记录其来源快照:折扣会保留 code、value、value_type(促销外键在删除时置空);税费行会保留 rate、label、provider_id 和辖区信息;关税费用会在 metadata 中保留其 HS 编码、原产地和费率。切勿根据当前商品目录重新计算已下单金额。
  • 费用默认应税(税费行通过 fee_id 关联);duty 费用不计入应税范围。
  • 抵扣额绝不能作为负费用——应创建折扣。

Spree::Carts::RecalculateTotals — 唯一的衔接点

所有会改变金额的操作最终都会进入此工作流(Spree.cart_recalculate_totals_workflow;订单对应的工作流 Spree.order_recalculate_totals_workflow = Spree::Orders::RecalculateTotals)。cart.recalculate_totals! 是快捷方式。

reset caches → total_quantity → item_total / delivery_total / payment_total
  → [unless money-frozen] regenerate rows:
       Spree.adjusters.each { |a| a.adjust(cart) }          # promotions, your fees/discounts
       run_hooks :set_tax_line_context                      # carts.recalculate_totals.set_tax_line_context
       cart.tax_provider.estimate(cart, **cart.tax_estimate_inputs, context:)
  → fold rows into per-line and purchase columns → total → one update_columns

这是有意设计的两阶段流程:调整器先持久化折扣后的基数,然后再据此估算税额。

触发条件——写入操作,而非步骤。 添加/更新/移除商品(通过 Spree::Carts::Recalculate,该流程还会重建配送方案并重新激活促销)、更改地址或市场(recalculate_for_address_change! 还会重新定价商品)、选择配送费率、应用/移除优惠券/礼品卡/商店额度、支付、合并购物车,以及在完成锁内再次执行一次——绝不会信任较早请求算出的应收总额。读取购物车不会触发重新计算(Store API 的 GET 回填缺失配送方案时除外)。

每次 Store API 写入都会返回总额已刷新的完整购物车;店面不会调用“重新计算”端点。

促销调整器只保留胜出项

Spree::Adjusters::Promotion 会在每次重新计算时评估所有符合条件的促销,并且每个竞争组只持久化胜出项:行级操作按行项目竞争,履约操作按履约竞争,订单级操作在整个订单范围内竞争(胜出项的优惠额分摊到各行)。金额最负的候选项胜出;平局时由最新的操作胜出。落败候选项不会写入,因此当其成为最佳选项时会自动“恢复”。它还会删除本轮未写入的所有 kind: 'promotion' 行。详情:spree-promotions。

下单后的金额冻结

对于已下单的订单(completed?),或以 resum_only: true 调用时(卖家拆分后的子订单草稿),工作流会跳过行项目重新生成:不运行调整器、不进行促销竞争,也不估算税额。它只会重新汇总订单现有的行。影响如下:

  • 当前的促销和税率不会改写上周的订单。
  • 自定义调整器不会在已下单的订单上运行。
  • 下单后的金额变更必须通过显式服务写入行项目,然后重新汇总:
变更服务Admin API
手动折扣(固定金额/百分比,单行或分摊)Spree::Orders::Discounts::Create/Update/DestroyPOST/PATCH/DELETE /api/v3/admin/orders/:id/discounts
费用Spree::Orders::Fees::Create/Update/Destroy/api/v3/admin/orders/:id/fees
商品/属性Spree::Orders::Update(items: [...])、Orders::UpdateItem、Orders::AddItemPATCH /api/v3/admin/orders/:id、/api/v3/admin/orders/:id/items
税费行—(由服务提供方写入)GET /api/v3/admin/orders/:id/tax_lines(只读)
Spree::Orders::Discounts::Create.call(order: order, label: 'Price match', value: '15', value_type: 'flat')
Spree::Orders::Fees::Create.call(order: order, attributes: { label: 'Handling', kind: 'handling', amount: 2.5 })

上述每项服务都会重新汇总,然后调用 order.update_statuses!,因为没有事件会通知总额发生了变化。已付款的订单增加费用后会显示为 partially_paid;如果总额降低到已付款金额以下,则会显示为 overcharged(amount_due 最低为零,因此只有状态会表明需要退款)。修改已下单订单金额的自定义服务也必须进行同样的调用。Orders::Update 是全有或全无的:如果请求中的任何商品变更被拒绝,则不会保存任何行项目、数量或地址变更。

只有 manual 折扣可编辑——修改 promotion 行会失败(通过 API 返回 422)。由于已下单订单不会重新估算税额,手动折扣不会减少已收取的税额;税额更正请通过税务服务提供方处理,或通过退货处理。

自定义费用和折扣:调整器

调整器管理一组行,并在每次(未冻结的)重新计算时重新运行。约定:继承 Spree::Adjusters::Base,实现幂等的 update(写入应存在的行,删除不应存在的行)。order 是购物车(或草稿订单);order.fees / order.discounts 会自动关联到正确的所有者。

# server/app/models/my_app/adjusters/gift_wrap.rb
module MyApp
  module Adjusters
    class GiftWrap < Spree::Adjusters::Base
      PRICE = BigDecimal('4.99')

      def update
        wrapped = order.line_items.select { |li| li.metadata['gift_wrap'].present? }

        order.fees.where(kind: 'gift_wrap').where.not(line_item_id: wrapped.map(&:id)).delete_all
        wrapped.each do |li|
          order.fees.find_or_initialize_by(kind: 'gift_wrap', line_item_id: li.id)
               .update!(label: 'Gift wrapping', amount: PRICE * li.quantity)
        end
      end
    end
  end
end
# server/config/initializers/spree.rb — MUST be after_initialize:
# core assigns Spree.adjusters = [Spree::Adjusters::Promotion] in its own after_initialize,
# which would wipe anything appended earlier.
Rails.application.config.after_initialize do
  Spree.adjusters << MyApp::Adjusters::GiftWrap
  Spree::Fee::KINDS << 'insurance' unless Spree::Fee::KINDS.include?('insurance') # custom fee kind (validated, not frozen)
end

调整器创建的自定义折扣必须使用 kind: 'manual'(Spree::Discount::KINDS 固定为 promotion/manual),并关联到商品项或履约记录。不要写入 kind: 'promotion' 的行——促销调整器会删除它未创建的行。

调整器写入的费用会在同一轮中计税(税费提供程序在调整器之后运行)。

关税

关税是由到岸成本集成(通常是调整器)写入的 kind: 'duty' 的 Spree::Fee。规则:

  • 不在默认应税集合中(purchase.taxable_items 不包含关税)——如有进口增值税,由关税提供程序自身将其作为针对关税费用的税费行写入。
  • 在费用的 metadata 中快照输入值(HS 编码、原产地、税率);已下单订单的关税绝不可重新计算。
  • 变体带有 hs_code、country_of_origin(制造地,不是发货地)和 customs_description。

渲染总额(Store API / SDK)

金额是字符串("135.60"),并带有预先格式化的对应 display_* 字段("$135.60")。渲染 display_*;如必须进行计算,请使用十进制库或整数分(美分)——绝不要使用 JS 浮点数。

cart.display_item_total      // items
cart.display_discount_total  // negative
cart.display_delivery_total
cart.display_fee_total       // cart.fees[] for line-by-line (label, kind, display_amount)
cart.display_tax_total       // safe single tax line (included + additional)
cart.display_total
cart.display_amount_due      // after gift cards / store credit
  • 自行计算小计?只加上**additional_tax_total**——绝不要加 included_tax_total(它已包含在价格中)。这是欧洲商店最常见的错误。
  • 在小计和总额之间列出费用;它们已计入 total,不要重复相加。将 duty 费用单独标示。
  • Store API 的 cart.discounts / order.discounts 是按已应用促销汇总的数据(name、code、amount、display_amount、promotion_id),不是有类型的 Spree::Discount 行;有类型的行位于 Admin API(adminClient.orders.discounts)。
  • 礼品卡和商店余额是付款,不是折扣——它们会减少 amount_due,而不是 total。
  • 隐藏价格(受限 B2B 访客)时,金额属性会序列化为 null。

注意事项

  • discount_total 只统计 promotion 行。 手动折扣(包括自定义调整器折扣)计入 adjustment_total 和 total,但不计入 discount_total。如果汇总显示 discount_total + total,手动折扣看起来就像少了一笔金额——请将 item_total + delivery_total + fee_total + additional_tax_total − total 显示为“折扣”,或列出 Spree::Discount 行。
  • 绝不要直接写入总额列(update_column(:total, …))——下一次重新计算会覆盖它们。请写入行,然后调用 recalculate_totals! / 订单服务。
  • 绝不要手动创建 TaxLine 行。 提供程序会按商品项整体替换这些行;你创建的行会在下一次估算时消失。
  • 更改商品项会取消应用礼品卡和结账时使用的商店余额(Spree::Carts::Recalculate)——编辑后请重新应用。普通的总额重新计算(配送、折扣、税费变动)会保留礼品卡,并将其付款金额调整为新的总额。
  • 调整器抛出异常会中止整个重新计算(以及客户的加入购物车操作)。不要在调整器中调用外部服务,或在内部捕获异常。
  • 结账期间不要读取 row.order——请使用 row.owner。

延伸阅读

  • node_modules/@spree/docs/dist/developer/core-concepts/order-totals.md、discounts.md、fees.md
  • node_modules/@spree/docs/dist/developer/how-to/custom-promotion.md——自定义调整器
  • 相关技能:spree-taxes、spree-promotions、spree-checkout、spree-returns、spree-pricing
  • 源码:spree/core/app/workflows/spree/carts/recalculate_totals.rb、spree/core/app/models/spree/adjusters/、spree/core/app/models/spree/{tax_line,discount,fee}.rb

评论

支持 Markdown