mcpField
S
spree-returns

spree-returns

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

关于 spree-returns

spree-returns 是面向 Spree 6 的技能,介绍如何围绕订单处理退货、换货和索赔。它分别说明各自的生命周期、校验工作流、库存处理与退款结算规则,适合构建或定制 Spree 电商售后流程的团队。

主要功能

  1. 01区分退货、换货与索赔记录
  2. 02基于工作流的状态转换与钩子
  3. 03退货期限校验及覆盖规则
  4. 04收货、补货与预付退货标签
  5. 05退款、商店积分与换货结算

适用场景

  1. 01设计购买后的退货与索赔流程
  2. 02定制退货资格与审批规则
  3. 03检查退回商品后将其补回库存
  4. 04结算同一订单下多项售后记录的退款

安装

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

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

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

详细介绍

Spree 退货、换货与索赔

三种独立记录,各自拥有独立的生命周期,均关联到已下单的 Spree::Order:

记录前缀 / 编号发生的情况结果
Spree::Returnret_ / RET…客户寄回商品退款
Spree::Exchangeexch_ / EX…客户寄回商品换成其他商品(结算差价)
Spree::Claimclaim_ / CLM…商品送达时已损坏、未送达或发错商品退款、补发或两者兼有——通常不退回商品

请将它们区分开来:不要把“送达时已损坏”建模为退货并立即标记为已收货——应使用索赔记录,以便追踪损坏情况。你是从 5.x 版本迁移过来的吗(ReturnAuthorization → CustomerReturn → Reimbursement、ReturnItem)?这些都已移除;请参阅 spree-upgrade-5-to-6。

模型

Order
  ├── Return (ret_)        status, stock_location, reason (ReturnReason rar_), memo, received_at, refunded_at, created_by
  │     ├── ReturnLineItem (rli_)   fulfillment_item + line_item + variant; quantity (announced), received_quantity, resellable, pre_tax_amount
  │     ├── refunds (Spree::Refund, originator: return) / store_credits (originator: return)
  │     ├── ShippingLabel × n       prepaid return postage
  │     └── Delivery × n            inbound tracking
  ├── Exchange (exch_)     status, stock_location, reason (ReturnReason)
  │     └── ExchangeLineItem (eli_) fulfillment_item + line_item; original_variant → new_variant; price_difference
  └── Claim (claim_)       status, resolution, reason (ClaimReason clr_)
        └── ClaimLineItem (cli_)    line_item + variant; quantity, description, images, send_replacement,
                                     replacement_variant, refund_amount
  • 退货和换货关联的是履约商品(已发货的商品单位),而不是购物车行项目。索赔关联的是行项目(商品可能从未送达)。
  • 这三种记录都限定于店铺(SingleStoreResource),包含 metadata 和自定义字段,并记录操作人员(created_by、审批人、退款人)。
  • 原因是归店铺所有的分类词汇:Spree::ReturnReason(供退货和换货使用)和 Spree::ClaimReason。种子数据会为每家店铺创建一组默认原因(索赔原因包括“送达时已损坏”“从未送达”“发错商品”等);商家可以添加自己的原因。

状态

通过 has_status 使用字符串 status 列——没有状态机,状态转换由工作流处理:

记录流程允许取消的状态
退货requested → approved → received → refunded,或 canceledrequested、approved
换货requested → approved → received → fulfilled,或 canceledrequested、approved
索赔open → approved → resolved,或 denied / canceledopen、approved(仅允许从 open 状态拒绝)

工作流

每个转换都是通过 Spree.<key> 解析的 Spree::Workflow(可通过 Spree.dependencies 替换,参见 spree-dependencies)。每个转换都带有一个前置 validate 钩子。

记录工作流(DI 键)参数钩子
退货return_create_workflow → Spree::Returns::Createorder:, items: [{ fulfillment_item:, quantity: }], stock_location:, reason:, memo:, created_by:validate, after_create
return_approve_workflow → Returns::Approvereturn_record:, approver:validate, after_approve
return_receive_workflow → Returns::Receivereturn_record:, items: [{ return_line_item:, quantity:, resellable: }], received_by:validate, before_restock, after_receive
return_refund_workflow → Returns::Refundreturn_record:, amount:, refund_method: 'original_payment', refunder:validate, before_refund, after_refund
return_cancel_workflow → Returns::Cancelreturn_record:, reason:validate, after_cancel
return_purchase_label_workflow → Returns::PurchaseLabelreturn_record:validate, after_purchase_label
换货exchange_create_workflow → Exchanges::Createorder:, items: [{ fulfillment_item:, new_variant:, quantity: }], …validate, after_create
exchange_approve_workflow, exchange_receive_workflow, exchange_cancel_workflow与退货相同validate, after_*(接收时还有 before_restock)
exchange_fulfill_workflow → Exchanges::Fulfillexchange:, refund_method: 'store_credit', refunder:validate, before_settle, after_fulfill
索赔claim_create_workflow → Claims::Createorder:, items: [{ line_item:, quantity:, description:, send_replacement:, replacement_variant:, refund_amount: }], reason:, memo:, created_by:validate, after_create
claim_approve_workflow, claim_deny_workflow, claim_cancel_workflowvalidate, after_*
claim_resolve_workflow → Claims::Resolveclaim:, resolution:, refund_method: 'store_credit', amount:, replacement_line_item_ids:, resolver:validate, before_settle, after_resolve

钩子键遵循类名,例如:returns.create.validate、returns.receive.before_restock、exchanges.fulfill.before_settle、claims.resolve.after_resolve 等。

result = Spree.return_create_workflow.call(
  order: order,
  items: [{ fulfillment_item: order.fulfillment_items.find_by_prefix_id!('fi_…'), quantity: 1 }],
  reason: store.return_reasons.first, # or nil
  created_by: current_admin           # nil = customer self-service (window enforced)
)
result.success? ? result.value : result.error

创建限制(三种记录均适用):订单必须已完成且未取消,条目不能为空;退货和换货还会拒绝超过仍可退数量的请求——较早且未取消的退货 和 换货中的数量会相互抵扣(Spree::Returns::ReturnableQuantity),同一请求中重复指定的数量也会计入。索赔按行项目施加相同限制:已由 open/approved/resolved 索赔涵盖的数量不能再次索赔(被拒绝或已取消的索赔会释放这些数量)。

退货政策:期限、覆盖和自定义规则

Core 仅提供一条政策规则:Spree::Returns::EligibilityValidator,注册在 returns.create.validate 和 exchanges.create.validate 两个工作流上。

  • 期限为 market.preferred_return_window_days(默认值为 30;nil = 无限制);如果订单没有市场,则回退到 Spree::Market 默认值。期限从 order.completed_at(下单时间)开始计算,而非从送达时间开始计算。
  • 当 created_by 存在时,会跳过此检查——员工(Admin API 和 Seller API)发起退货时,会被视为覆盖政策,且可通过 created_by 追溯。Store API(客户)发起的退货始终会经过检查。
  • 市场上的 withdrawal_period_days(默认值为 14,符合欧盟最低要求)是单独的法定通知期限,不会限制退货创建。
  • 已弃用的 Spree::Config.return_eligibility_number_of_days 不会被任何代码读取。

可以替换或扩展此规则,使用你自己的处理器。Core 会在应用初始化器运行前注册其验证器,因此可以在 config/initializers/spree.rb 顶层替换:

# app/services/my_store/return_policy.rb
module MyStore
  class ReturnPolicy
    def call(workflow) # Spree::Returns::Create or Spree::Exchanges::Create
      return if workflow.created_by.present? # keep the staff override

      order = workflow.order
      window = order.market&.preferred_return_window_days || 30
      delivered_at = order.fulfillments.maximum(:delivered_at) || order.completed_at
      workflow.reject!("Returns are accepted for #{window} days after delivery") if delivered_at < window.days.ago

      final_sale = workflow.items.any? do |item|
        item[:fulfillment_item].variant.product.get_custom_field('policy.final_sale')&.value == 'true'
      end
      workflow.reject!('Final-sale items cannot be returned') if final_sale
    end
  end
end

# config/initializers/spree.rb
%w[returns.create.validate exchanges.create.validate].each do |key|
  Spree.hooks.unregister(key, 'Spree::Returns::EligibilityValidator')
  Spree.hooks.register(key, 'MyStore::ReturnPolicy')
end

处理器以类名字符串注册(可安全用于重新加载),无参数实例化,并通过工作流调用。workflow.reject!(message) 会添加一条基础错误并使工作流失败;API 会像呈现模型验证错误一样,将其呈现为 422 响应。同样的模式可用于拦截任何后续步骤,例如使用 returns.refund.validate 要求超过指定金额的退款获得经理批准,或使用 claims.resolve.validate 要求 claim_line_items.images 中包含照片。系统没有“符合资格但需要人工审核”状态;请通过自定义状态(在 to_prepare 中调用 Spree::Return.add_status)或元数据实现此功能。

收货和重新入库

Returns::Receive(以及 Exchanges::Receive)会根据仓库实际清点的数量处理收货。省略 items 时,会将所有已申报商品视为可转售并全部收货。每个明细都会记录 received_quantity,且只有可转售的商品才会重新入库——stock_location.restock(variant, qty, return) 会在退货的库存地点写入一条由该退货引起的 received 库存变动。不可转售的商品会被记录,但绝不会重新入库。使用 before_restock 将损坏商品转运至其他地点,或覆盖可转售性。

商品退回至何处:退货的 stock_location——可显式传入(仅限管理员)stock_location_id;否则使用发出商品的地点(前提是该地点启用了 returns_enabled),然后依次回退到卖家的退货地点、store.returns_location,最后回退到发货地点或商店默认地点。

退货入库包裹的配送信息即使报告已送达,也不会触发退货收货——到达不等于检验。

退款与结算

  • Returns::Refund 要求状态为 received。amount 默认为 refundable_total,且不能超过该金额——只需退还实际收到的单位(refund_total - refunded_total)。refund_method 可为 'original_payment'(退货的默认值)或 'store_credit'(Spree::RefundMethods::METHODS)。
  • 退回或申领的单位按客户折扣后的实付金额计算,而非标价:LineItem#discounted_amount_for(units)(按单位比例分摊该行的 discounted_amount,按币种精度四舍五入,且绝不为负数)为 ReturnLineItem#default_pre_tax_amount 和 ClaimLineItem#paid_amount 提供依据。行项目上的优惠券、促销或手动折扣不会退还,退回的赠品价值为零。
  • 无需退款的退货(refundable_total 为零,例如只退回了赠品)可通过调用不带金额(或金额为 0)的 Returns::Refund 关闭。该退货会变为 refunded,但不会创建退款记录、商店额度、税款抵扣或退款邮件;仍会发布 return.refunded。仍有应退款金额的退货传入零会失败并返回 refund_amount_required,负数金额会返回 refund_amount_negative,超过应退款金额则会返回 refund_exceeds_balance。
  • 商店额度会在事务中写入内部账本(Spree::StoreCredit,originator = 退货);网关退款则作为其后的 external_step 执行,并分摊到订单中仍可退款的付款上(Spree::Refund 记录,originator = 退货)。之后通过税务服务提供商抵扣税款(refund_tax)。
  • 每个订单只有一个可退款余额。 所有退款路径(退货、换货、申领——无论退款到网关还是商店额度)都会经过 Spree::Refunds::OrderPayments,该流程会锁定订单,并将金额限制为订单付款仍可退还的金额减去已计入该订单的商店额度(refunded_order)。超出时会失败并返回 refund_exceeds_paid,因此同一订单上的两条售后记录不能各自退还全额。
  • refunded_total 同时统计退款和商店额度。订单的 payment_status 也一样:退货、换货或申领退款发放的商店额度会带有 refunded_order_id,因此订单状态会像网关退款一样变为 partially_refunded / refunded。
  • 换货:Exchanges::Fulfill 会构建替换订单的配送单(通过 Spree::Stock::Coordinator)并分配库存。如果替换商品更便宜(price_difference 为负),则按实际收到的单位数(而非请求数量)退还差价——默认使用 'store_credit',也可使用 'original_payment'。如果替换商品更贵,余额会留给商家收取——核心系统绝不会擅自扣取已保存的银行卡。
  • 申领:Claims::Resolve 的 resolution 必须是 refund、replacement、refund_and_replacement 之一(Spree::Claim::RESOLUTIONS);且申领状态必须为 approved。退款默认使用 'store_credit',金额为申领总额(各行 refund_amount 之和),上限为各行 paid_amount 之和(通过 API 在申领行项目上公开;超出时会失败并返回 refund_exceeds_paid);替换商品会成为原订单的新配送单(客户无需再下第二笔订单)。在解决申领时提供的 replacement_line_item_ids 会覆盖创建申领时标记的行项目。
  • 退货和申领通过 Spree::Orders::UpdateStatuses 更新订单的 payment_status;不要直接写入该状态。

预付退货标签

不带请求正文调用 POST /api/v3/admin/orders/:order_id/returns/:id/labels,即可通过出库包裹使用的承运商账户购买邮资(Returns::PurchaseLabel → 配送服务提供商的 purchase_label(return)),从客户处寄回至退货的库存地点,并生成一条入库 Spree::Delivery。如果提供 file(已签名 Blob ID)和 tracking_number,则会记录在其他渠道购买的标签。客户可通过 GET /api/v3/store/orders/:order_id/returns/:id/label 下载标签。取消退货时,会退还未使用的预付标签费用。

API

商店 API(订单所有者——客户 JWT 或订单令牌),仅开放自助操作和查看:

POST /api/v3/store/orders/:order_id/returns   { "items": [{ "fulfillment_item_id": "fi_…", "quantity": 1 }], "reason_id": "rar_…", "memo": "Too small" }
GET  /api/v3/store/orders/:order_id/returns[/:id][/label]
POST /api/v3/store/orders/:order_id/claims    { "items": [{ "line_item_id": "li_…", "quantity": 1, "description": "Arrived cracked" }], "reason_id": "clr_…" }
GET  /api/v3/store/orders/:order_id/claims[/:id]

TypeScript:client.orders.returns.create(orderId, …)、client.orders.claims.create(orderId, …)。

管理 API — 受 read_orders / write_orders 权限控制(退货属于订单权限资源的一部分):

端点说明
…/admin/orders/:order_id/returns(index/show/create/update)create:items[{fulfillment_item_id, quantity}]、reason_id、stock_location_id、memo;update 只能编辑 memo、reason_id、stock_location_id、metadata
PATCH …/returns/:id/approve · /receive · /refund · /cancelreceive:items[{return_line_item_id, quantity, resellable}];refund:amount、refund_method;cancel:reason
…/returns/:id/labels(以及 download、refund)预付邮资
…/admin/orders/:order_id/exchanges + approve · receive · fulfill · cancel创建项目:fulfillment_item_id、new_variant_id、quantity;fulfill:refund_method
…/admin/orders/:order_id/claims + approve · resolve · deny · cancel创建项目:line_item_id、quantity、description、send_replacement、replacement_variant_id、refund_amount;resolve:resolution、refund_method、amount、replacement_line_item_ids
GET /api/v3/admin/returns、/exchanges、/claims跨订单列表;Ransack 示例:q[status_eq]=approved、q[created_at_gt]=…
/api/v3/admin/return_reasons、/claim_reasonsCRUD

管理 SDK:adminClient.orders.returns.{create,approve,receive,refund,cancel}、.returns.labels.*、adminClient.orders.exchanges.{approve,receive,fulfill,cancel}、adminClient.orders.claims.{approve,resolve,deny,cancel}、adminClient.returns.list(...)。

事件

return.requested、return.approved、return.received、return.refunded、return.canceled;exchange.requested/.approved/.received/.fulfilled/.canceled;claim.opened/.approved/.resolved/.denied/.canceled;以及每种对象的生命周期事件 *.created/.updated/.deleted。所有事件都会发送到 Webhook。可选的 spree_emails gem 会发送 Spree::ReturnMailer 邮件(例如,在 return.refunded 时发送退款邮件)。已移除:reimbursement.*、return_authorization.*、return_item.*、customer_return.*。

注意事项

  • 不要通过 update!(status: …) 转换状态——这会跳过补货、退款、事件和钩子。始终调用工作流。
  • 退货期限从下单时开始计算。如果你的政策从送达时开始计算(很常见,欧盟的撤回权也如此),请自行编写校验器(见上文)——fulfillment.delivered_at 会由承运商 Webhook 或 mark_delivered 填充。
  • 按设计,由员工或卖家创建的退货不受期限限制。如果你的政策也必须适用于员工,不要在处理程序中因 created_by 而提前返回。
  • Returns::Refund 仅在 received 状态下运行;每笔退货只能退款一次。分拆付款会在这一次退款中处理(款项会分摊到各笔支付),不要调用两次。
  • 不会自动向客户收取换货差额——请自行收款(例如在 exchanges.fulfill.after_fulfill 钩子中,或手动收款)。
  • 替换商品的配送、装箱单和控制面板中的 Exchanges 卡片,显示的是替换款式(new_variant),而不是原款式。
  • 换货或索赔寄出的商品单位会归入原始订单项,并标记为 FulfillmentItem#replacement = true(通过 Spree::Fulfillments::Replacements 构建)。订单编辑只统计已购单位,因此修改该订单项的数量会增加或减少付费商品,而不会影响替换商品。早期 6.0 beta 版本发出的替换商品单位未标记此标志,也不会回填。
  • 索赔不会补货——不会有商品退回。如果商品确实退回,请同时发起退货。
  • 始终通过订单/商店限定查询范围(order.fulfillment_items.find_by_prefix_id!)——绝不要使用 Spree::FulfillmentItem.find(params[:id])。

测试

工厂::return、:approved_return、:received_return、:return_line_item、:exchange、:approved_exchange、:received_exchange、:claim、:approved_claim、:return_reason、:claim_reason,以及 :completed_order_with_totals / :fulfillment。通过调用工作流并断言 result.failure? 和 result.error 消息来测试策略处理程序,而不只是单独调用处理程序。在非请求规格中设置 Spree::Current.store。

延伸阅读

  • node_modules/@spree/docs/dist/developer/core-concepts/returns-exchanges-claims.md
  • node_modules/@spree/docs/dist/developer/customization/workflows.md(钩子、reject!)
  • node_modules/@spree/docs/dist/developer/core-concepts/store-credits-gift-cards.md、payments.md
  • 相关技能:spree-workflows、spree-fulfillment(标签、配送、替换)、spree-inventory(补货库存变动)、spree-payments(退款)、spree-events-webhooks、spree-upgrade-5-to-6。

评论

支持 Markdown