Spree 6.0 类型化库存变动(Typed Stock Movements)完整解析:从多态 originator 到可审计库存账本
【免费下载链接】spreeOpen Source eCommerce Platform for B2B, Marketplace, and Enterprise. REST API, TypeScript SDK, and production-ready Next.js storefront. Self-host it. Own your stack. No vendor lock-in. Zero platform fees.项目地址: https://gitcode.com/GitHub_Trending/sp/spree
导读
Spree::StockMovement是 Spree 电商平台记录"库存发生了什么"的核心模型。Spree 6.0 通过 6.0-typed-stock-movements.md 这一设计计划,将原先只有quantity + 多态 originator的模糊账目,重构为带kind语义(received/allocated/shipped/released/adjusted)和具体外键(order_id、fulfillment_id、return_id等)的类型化库存账本,同时引入allocated_count(已承诺数量)列,让count_on_hand回归"仓库货架上真实可数的库存"本义。读完本文,你将掌握:新模型与五个 kind 的完整语义、下单即分配(allocation)的核心行为变更、StockLocation五个库存动词(restock/unstock/allocate/release/adjust)的调用约定、数据迁移spree:migrate_stock_movements_to_typed_rows的工作原理,以及只读管理 APIGET /api/v3/admin/stock_movements的用法。
为什么需要类型化:旧模型的四个缺陷
计划文档开篇列出的问题,在当前 6.0 代码库中仍然可以从 stock_movement.rb 的belongs_to :originator, polymorphic: true看到旧痕迹(该行已标注@deprecated,将在 6.1 删除)。
1. 无语义的数字堆砌
旧的StockMovement本质上是{ quantity: -3, originator_type: 'Spree::Fulfillment', originator_id: 42 }。回答"有多少件已承诺给已下单的订单?"、"本月退回了多少件?"这类问题,必须解析 originator 类型并猜测正负号背后的业务含义——正数是入库,负数是出库,但"出库"到底是发货、取消释放还是调拨?账本里看不出来。
2. 多态 originator 的技术债
belongs_to :originator, polymorphic: true与 6.0-6.1-split-adjustments.md 中移除的多态 Adjustment 是同一个问题:无法干净地 eager load 或 join,无法在 API 上直接过滤(否则会把类名泄漏给客户端)。实际使用中 originator 可能是Fulfillment、StockTransfer、旧的ReturnAuthorization,或者干脆为空。
3. 没有"分配"(allocation)环节
旧模型中,订单一旦下单就立即扣减count_on_hand(通过Fulfillment#finalize!),导致:
- 货架数量失真:拣货员数出 40 件衬衫,Spree 里却显示 33 件;
- 发货时反而什么都不写——仓库最关心的事件在库存账本里没有任何痕迹;
- 超卖被编码成负的
count_on_hand,为此还衍生出restock_backordered这个绕过日志、用update_columns直接写库存的逃生舱。
4. 多写入者、零审计
count_on_hand被 movements、restock_backordered、直接调用StockItem#adjust_count_on_hand(变体库存表单、管理 API 的PATCH /stock_items)、以及停用库存追踪时的update_all共同写入,但只有第一种留下了记录。商家问"为什么这个数字是 47?",数据里找不到答案。
核心设计:五类 kind + 具体外键
单一表 + kind 列
计划明确:不做按类型分表(table per type)。所有 movement 形状相同——库存层级、数量、时间戳、原因——拆成五张表只会增加 join 而不增加含义。kind是带索引的字符串列,绝不是 STI 或多态类型列。
KINDS = %w[received allocated shipped released adjusted].freeze五种 kind 的语义:
| kind | 含义 | 对计数的影响 |
|---|---|---|
received | 货物到达(采购订单、调拨收货、退货/换货重新入库) | count_on_hand增加 |
allocated | 库存已承诺给已下单的订单 | allocated_count增加 |
shipped | 已承诺的库存物理离开 | count_on_hand减少、allocated_count减少 |
released | 承诺被撤回(履约取消、改仓、行项目移除) | allocated_count减少 |
adjusted | 手动修正(盘点、损耗、损坏) | count_on_hand增减 |
具体外键取代多态
在 stock_movement.rb 中可以看到完整实现——计划文档里的五个外键已全部落地,并额外增加了purchase_order_id和stock_receipt_id(来自 6.0-inventory-operations.md):
belongs_to :order, class_name: 'Spree::Order', optional: true belongs_to :fulfillment, class_name: 'Spree::Fulfillment', optional: true belongs_to :return, class_name: 'Spree::Return', optional: true belongs_to :exchange, class_name: 'Spree::Exchange', optional: true belongs_to :stock_transfer, class_name: 'Spree::StockTransfer', optional: true belongs_to :purchase_order, class_name: 'Spree::PurchaseOrder', optional: true belongs_to :stock_receipt, class_name: 'Spree::StockReceipt', optional: true, inverse_of: :stock_movements一个关键约定:外键描述的是"原因"(cause),而原因可以深一层。由履约(fulfillment)驱动的 movement 同时携带fulfillment_id和order_id,因为"这批货是给哪个订单的?"是商家最常问的问题,通过 fulfillment 再 join 一次去回答它太浪费。禁止的是在一行上放两个无关的原因。
数量符号约定
quantity对received和adjusted保留符号语义(正加负减);对三种订单驱动类型(allocated/shipped/released),方向已由 kind 表达,因此一律写正数、通过abs读取。实现里甚至有专门校验:这三个 kind 的quantity必须是正数(stock_movement.rb),避免存储"符号与变更方向矛盾"的行。
不可变性:readonly?
def readonly? persisted? && !being_created? endmovement 一旦持久化即只读。反转一个操作的正确方式是写入它的反操作,而不是编辑历史。实现中还处理了一个边界情况:父记录的 autosave 可能在自身保存进行中插入该行(通过inverse_of持有),此时行已在插入中途,不能误判为更新而拦截。
行为变更:下单即分配(Allocation),发货才动货架
这是整个计划的头条行为变更。
变更前 vs 变更后
| 事件 | 旧行为 | 新行为 |
|---|---|---|
| 订单下单 | count_on_hand立即减少(Fulfillment#finalize!→manifest_unstock) | 写allocatedmovement,allocated_count增加,count_on_hand不动 |
| 履约发货 | 不写任何 movement | 写shippedmovement,扣减count_on_hand并同时冲销对应 allocation |
| 履约取消 | restock(现库)或restock_backordered(无 movement) | 写releasedmovement,无物理货移动 |
| 履约恢复 | 再次 unstock | 写allocatedmovement,重新承诺 |
| 手动修正 | 直接改计数,无记录 | 写adjustedmovement,必填 reason |
从源码看,新流程已完整落地:下单时 orders/complete.rb 的finalize_fulfillments对每个履约调用fulfillment.finalize!后紧跟allocate_fulfillment_stock——按履约的 manifest 逐项调用stock_location.allocate,且只在item.variant.track_inventory?且数量为正时分配:
def finalize_fulfillments order.fulfillments.each do |fulfillment| fulfillment.finalize! allocate_fulfillment_stock(fulfillment) end end def allocate_fulfillment_stock(fulfillment) fulfillment.manifest.each do |item| next unless item.variant.track_inventory? next unless item.quantity.positive? fulfillment.stock_location.allocate(item.variant, item.quantity, fulfillment) end end注意该写操作位于order.with_lock的放置锁内部,且发生在finalize_fulfillments步骤——这正是旧模型中放置时 unstock 所在的位置。草稿订单(draft order)也直接进入这个工作流,这正是分配逻辑放在Orders::Complete而不是Carts::Complete的原因:在购物车工作流里分配会漏掉所有后台/B2B 草稿订单。
随后release_stock_reservations在锁外释放结算预留(reservation),因此单位会短暂地"既被预留又被分配"——此时可用性被低估而不是高估,这是安全方向的错误。
发货只转换分配,绝不发明单位
fulfillments/fulfill.rb 的ship_allocated_units只对"该履约实际持有的已分配数量"写shipped:
def ship_allocated_units allocated = @fulfillment.allocated_quantities return if allocated.empty? @fulfillment.manifest.each do |item| quantity = [allocated[item.variant.id].to_i, item.quantity].min next unless quantity.positive? @fulfillment.stock_location.unstock(item.variant, quantity, @fulfillment, force: force) end end没有分配的履约(例如升级前创建的所有履约)发货时不写 movement——这正是迁移窗口安全的原因:无需标志列、无需部署编排。
超卖的新表示法
超卖从"负的count_on_hand"变为"allocated_count>count_on_hand"。两者的有符号算术完全一致(count_on_hand - allocated_count与旧负数等价),因此backorder_limit和预售上限无需改动即可继续工作。restock_backordered与StockMovement#min_quantity这两个为负库存表示法服务的机制随之删除。
count_on_hand现在只能通过强制发货降到零以下,且含义收窄为"货物离开但 Spree 从未记录其到达"——一个接收缺口(receiving gap),当缺失的receivedmovement 落地时自愈。强制发货(force: true)是唯一允许负货架数的写入路径,force作为临时属性(transient attribute)从Fulfill(force:)→StockLocation#unstock(force:)→ movement 传递,永不持久化——这与Fulfillment#notify_customer的模式一致。
实现细节:Fulfillments::Fulfill在写任何东西之前先用ensure_shelf_can_cover_dispatch拒绝货架盖不住的发货(渲染为商家可行动的 422,而不是事务深处的校验异常),提示信息建议"修正计数或强制发货"。注意这里的顺序依赖:被取消的履约在离开时会重新分配,所以检查读取的是其 manifest 而不是当前空持有。
StockLevel:重命名与双计数器
为什么先改名(Phase 0)
计划特别说明:Spree::StockItem→Spree::StockLevel的重命名作为Phase 0先于新列落地——否则就得给一张即将改名的表加五个外键和一个计数器,然后全部再改一遍。"Level" 也是这个概念需要的词:一行同时承载 on-hand、allocated、reserved 三个数量,它是一个"层级/水位",而不是"条目"。
迁移实现见 20260814000001_rename_stock_items_to_stock_levels.rb:表与两个外键原地改名,四个手写命名的索引逐一搬运,其中部分唯一索引(partial unique index)因为rename_index在无原生重命名的适配器上会静默丢失 partial 条件,被显式重建。
两个计数器的不同写入路径
stock_level.rb 中两个计数器的写入方式刻意不同:
count_on_hand保留加锁的读-改-写(adjust_count_on_hand→set_count_on_hand),因为process_backorders需要差值、数值校验需要看到新值;allocated_count走原子计数器路径increment!/decrement!——编译为单条SET allocated_count = allocated_count + n,两个履约并发分配同一层级时谁都不必先读对方的值。
def adjust_allocated_count(value) value.negative? ? decrement!(:allocated_count, value.abs) : increment!(:allocated_count, value) touch endadjust_allocated_count末尾的touch是刻意的:计数器写入是裸 SQL UPDATE,不跑回调,若不 touch,占下最后一件货的下单操作会留下过期的变体缓存键和搜索索引,也不会发布stock_level.updated。
与它相对,release_allocated_count是加锁的——上限从同一计数器读取后写入,两个并发释放若都在写入前读取,会双双通过只够覆盖其一的上限:
def release_allocated_count(units) with_lock do withdrawn = [units.abs, allocated_count].min next if withdrawn.zero? adjust_allocated_count(-withdrawn) end endin_stock?移到available_count之上(available_count.positive?),available?无需修改——它本来就是in_stock? || backorderable?,所有调用者(包括StockReservations::Reserve#select_stock_item)自动继承新含义。5.5 中作为 shim 的allocated_count方法及其column_exists?检查随之消失——Rails attribute 优先于同名方法,真实列落地后 shim 自行作废。
Stock::Quantifier:唯一的可用性公式
stock/quantifier.rb 是权威可用性读取器,完整公式为:
purchasable = count_on_hand - allocated_count - active reservationstotal_on_hand将结果钳制在零([available_stock - reserved_quantity, 0].max);can_supply?是唯一允许越过零的调用者——那是backorder_limit决定超卖能走多远的地方:
supplyable = if oversellable [available_stock - reserved_quantity + limit, 0].max else total_on_hand end supplyable >= requiredavailable_stock对未加载的关联求和count_on_hand - allocated_count,reserved_quantity读取店铺偏好——整个形状在 5.5 的 Stock Reservations 中就已备好,本计划只需提供列。代码中已无allocated_count_column?分支(计划要求删除的正是它)。
StockLocation 五动词:扩展的唯一入口
计划明确约束:扩展代码调用动词,绝不直接StockMovement.create!。stock_location.rb 中的五个动词就是公共表面:
def restock(variant, quantity, cause = nil, unit_cost: nil) move(variant, quantity, kind: 'received', cause: cause, unit_cost: unit_cost) end def unstock(variant, quantity, cause = nil, force: false) move(variant, quantity, kind: 'shipped', cause: cause, force: force) end def allocate(variant, quantity, fulfillment) move(variant, quantity, kind: 'allocated', cause: fulfillment) end def release(variant, quantity, fulfillment) move(variant, quantity, kind: 'released', cause: fulfillment) end def adjust(variant, quantity, reason:) move(variant, quantity, kind: 'adjusted', reason: reason) end三个值得注意的实现细节:
unstock不再取反数量——旧实现调用move(variant, -quantity),因为符号是区分出发与到达的唯一手段;现在由kind表达,三个订单驱动类型一律写正数。- 原因解析集中在
cause_attributes一个方法——按类分发(when Spree::Fulfillment即is_a?),履约携带其订单、收货(StockReceipt)递归携带其 receivable 的原因。原因集合是封闭且 core 所有的,所以"一张方法展示全部映射"胜过在五个模型上各写一个stock_movement_attributes。为6.0-inventory-operations.md增加purchase_order_id只需加一个when子句。 restock_backordered已删除;persist: false路径保留(唯一调用者是StockTransfer#transfer,它必须在调拨记录保存前把两个 movement 挂到未保存的调拨上,因为stock_movements_not_empty校验要求行存在),但标注为"被保留而非被背书",随 6.1 的一级调拨条目(transfer items)落地而退役——不要在新增代码中使用。
force通过move的块参数传入 movement(stock_level.stock_movements.create!(attributes) { |movement| movement.force = force }),与模型的attr_accessor :force对接。
一个订单生命周期中的库存全景
计划给出了普通路径下订单一生中各事件对库存的影响,结合源码验证如下:
- 购物车变更(
Carts::AddItem、SetQuantity、RemoveLineItem、Carts::Update)——创建或刷新带 TTL 的 reservation;可退订(backorderable)与预售变体跳过。无 movement、无计数器变化。 - 配送步骤或地址变更(
Checkout::Advance→rebuild_fulfillments!)——按来源仓提议 fulfillment,每个单位对照available_count标注 on-hand 或 backordered。无库存写入。 - 购物车弃单——reservation 随 TTL 过期被清扫。无其他动作。
- 下单(
Orders::Complete#finalize_fulfillments,放置锁内)——每个 fulfillment 写allocated;allocated_count上升、count_on_hand不动。草稿订单直接进入此处。 - 同一请求、锁外一步(
release_stock_reservations)——删除 reservation;单位短暂"既预留又分配",可用性低估而非高估。 - 买标签(
Fulfillments::PurchaseLabel)——无库存影响。 - 发货(
Fulfillments::Fulfill)——为已分配数量写shipped;count_on_hand下降、allocated_count下降。 - 送达/跟踪更新(
MarkDelivered、UpdateTracking)——无库存影响。
第 4 步到第 7 步之间的一切都只改变承诺,不动货架。分支路径(取消、恢复、拆分、改仓、下单后编辑、退货、换货、调拨、手动修正)对应调用点表中的其余各行。
必须减掉分配量的读取者清单
分配存在后,任何裸读count_on_hand的地方都会变错。计划给出完整清单,仓库实现已逐一处理(每个都因故绕过 Quantifier——SQL scope、批量 pluck、加锁读):
| 读取者 | 变更 |
|---|---|
StockLevel#in_stock? | available_count.positive? |
Variant.in_stockscope、ProductScopes.in_stock_or_backorderable_condition | 比较count_on_hand - allocated_count |
Product#total_on_hand(未加载分支) | 求和差值,对齐Quantifier#available_stock |
Order#backordered_variants | count_on_hand - allocated_count <= 0 |
StockLocation#fill_status | 对照available_count拆分 on-hand 与 backordered |
StockReservations::Reserve | available_count - held_by_others - this_order_used |
OrderRouting::Rules::MinimizeSplits | pluck 两列并比较差值 |
StockTransfer#variants_available_in_source_location? | 要求available_count > 0 |
FulfillmentChanger | 在目标仓读取available_count |
Stock::Quantifier | 删除allocated_count_column?分支 |
事件:out_of_stock / back_in_stock 的触发时机变化
stock_movement/custom_events.rb 在保存前后对比Product#any_variant_in_stock_or_backorderable?,发布product.out_of_stock与product.back_in_stock。一旦 in-stock scope 改为扣除分配量,这两个事件就会在最后一个可购买单位被分配时(即下单时)触发,而不是几周后包裹发出时——这正是补货通知(back-in-stock list)想要的时机。
数据迁移:给历史定型
任务与运行时机
数据任务spree:migrate_stock_movements_to_typed_rows在 typed_stock_movements_migration.rake 中实现(Spree::TypedStockMovementsMigration),并已登记进 5.6 → 6.0 升级清单 manifest.yml。必须按序运行:在spree:migrate_shipping_to_delivery(改写'Spree::Shipment'originator 字符串)之后,在spree:upgrade:migrate_returns之后——因为旧ReturnAuthorization的 id 对新记录毫无意义,退货迁移器保留的是number而非 id,且一次授权可能变成Return或Exchange中的任意一个。
bin/rails spree:migrate_stock_movements_to_typed_rows # 可用 BATCH_SIZE 调节批次(默认 5000): BATCH_SIZE=1000 bin/rails spree:migrate_stock_movements_to_typed_rows历史定型规则
每个遗留行按其 originator 与符号获得 kind 和外键:
| 遗留行 | 变为 |
|---|---|
Fulfillment/Shipmentoriginator,负数量,履约已关闭 | shipped,fulfillment_id+ 履约的order_id |
Fulfillment/Shipmentoriginator,负数量,履约仍开启 | allocated,同样的外键——计数器随之移动 |
Fulfillmentoriginator,正数量 | released,同样的外键(取消或改仓的 restock) |
ReturnAuthorizationoriginator | received,return_id或exchange_id |
StockTransferoriginator,正数量 | received,stock_transfer_id |
StockTransferoriginator,负数量 | shipped,stock_transfer_id |
| 无 originator | adjusted,reason "Legacy manual adjustment" |
原因无法解析的行按符号定型、外键留空,而不是跳过——没有原因的运动仍是关于库存的真实陈述。
关键细节:开放履约按 allocated 定型
一个履约在升级时是否已发货,决定了它的历史如何被解读:已关闭履约的离库就是离库(shipped);未关闭履约的离库是它仍持有的承诺——旧模型下,下单时把货从货架上拿走就是分配——因此定型为allocated。若误标为shipped,allocated_quantities(用shipped减去allocated)会让对账净值为零,履约将报告"没有任何承诺"——取消时永远不会把货还给可用性,真实库存被卡死在零。
由于定型是跳过回调的update_all重标,开放履约的行要自己携带计数器:count_on_hand与allocated_count各按该行取反后的遗留数量做数据库端增量(Spree::StockLevel.update_counters),两个计数器同向移动使可用性不变;按行而非按履约处理,使部分补货的履约也能算对——遗留行守恒unstock − restock = 持有量,求和后恰好落在仍持有的数量上。
对账开放履约
对账步骤只处理完全没记录过 movement的开放履约:每个履约把数量加回count_on_hand,同量加到allocated_count,并写一条匹配的allocatedmovement 让跳跃有可见原因。这是刻意的直接列更新而非receivedmovement——因为没有任何货物到达。可用性在此同样构造性地不变。后退订单(backordered)无需特判:成对增量恰好抵消掉过去代表它们的负 on-hand——这正是本计划要做的表示法变更。
幂等性
幂等来自数据本身:定型跳过已有 kind 的行,对账跳过已有allocatedmovement 的履约。同一谓词也使部署窗口安全:任务运行前已发货的履约永远不会得到 allocation,而Fulfillments::Fulfill只为已分配单位写shipped,因此不会重复扣减。
管理 API:只读库存账本端点
计划新增的端点已在仓库中实现(stock_movements_controller.rb),只读、按recent倒序、随取随带stock_level(含stock_location与variant: :product)及全部原因关联:
GET /api/v3/admin/stock_movements?q[stock_level_variant_id_eq]=variant_xxx GET /api/v3/admin/stock_movements?q[kind_eq]=allocated&q[order_id_eq]=…它挂在既有的:stock权限资源上(Spree::StockMovement本就列于其中),RBAC 无需新目录条目。具体外键全部进入whitelisted_ransackable_attributes(stock_movement.rb,含stock_item_id别名,Ransack 会解析属性别名,删除旧过滤器会让仍发旧键的客户端拿到整个集合而非报错)。
响应示例(来自计划文档,序列化器字段已与实现一致):
{ "id": "sm_k5nR8xLq", "kind": "allocated", "quantity": 3, "reason": null, "stock_level_id": "sl_xyz", "order_id": "or_abc", "fulfillment_id": "ful_xyz", "return_id": null, "exchange_id": null, "stock_transfer_id": null, "created_at": "2026-08-13T14:30:00Z" }kind在 6.0 生命周期内可空(未跑数据任务的安装上,遗留行还没有 kind),kind的NOT NULL约束在 6.1 才加上——与 6.0 早期加店铺归属列的模式相同。originator_type/originator_id已从序列化器(stock_movement_serializer.rb)移除。
adjusted的 reason 有默认值兜底:PATCH /stock_levels仍接受裸count_on_hand、可选reason,缺省回退到翻译文本 "Manual adjustment"——审计要求不能把既有端点变成 422。代码中ADJUSTMENT_REASONS还提供了一组标准原因码(manual_adjustment、correction、count、received、return_restock、damaged、theft_or_loss、promotion_or_donation、inventory_feed),已知码解析为固定英文文本,未知文本原样保留——原因列被所有后台管理员阅读并跨全历史过滤,所以一个原因必须是一个字符串。
迁移路径总览:Phase 0–5
| 阶段 | 内容 | 状态 |
|---|---|---|
| Phase 0 | StockItem→StockLevel纯重命名(表、外键列、索引、常量别名、SDK 资源、双发stock_item.*事件) | ✅ 已发布 |
| Phase 1 | 加列:kind、reason、5 个外键、kind索引、allocated_count | ✅ 已发布 |
| Phase 2 | 重写模型与全部调用点/读取者,删除min_quantity、restock_backordered、shim | ✅ 已发布 |
| Phase 3 | 数据任务spree:migrate_stock_movements_to_typed_rows | ✅ 已发布 |
| Phase 4 | API 端点、序列化器、仪表盘消费 | ✅ 已发布 |
| Phase 5 | 6.1 清理:删多态列与全部桥接(originator_type/originator_id/action、常量别名、双发事件、关联别名、stock_item_id别名、序列化器注册键、工厂别名、服务/job shim) | 6.1 |
Phase 1 迁移见 20260814000002_add_typed_stock_movements.rb:
class AddTypedStockMovements < ActiveRecord::Migration[8.1] def change change_table :spree_stock_movements, bulk: true do |t| t.string :kind t.string :reason t.references :order t.references :fulfillment t.references :return t.references :exchange t.references :stock_transfer end add_index :spree_stock_movements, :kind add_column :spree_stock_levels, :allocated_count, :integer, null: false, default: 0 end end6.1 的删除(Phase 5)将移除多态列并给kind加NOT NULL。推迟到 6.1 的原因与 6.0 其他清理一致:这些列是数据任务的来源和回滚路径——升级到 6.0 后发现某行定型错误的安装,可以在整整一个发布周期内从 originator 重新推导。action列随之删除——多年没有应用代码读写它,kind才是它一直想成为的东西。
对当前开发者的约束
计划文档列出了开发约束,写扩展或调用库存相关代码时务必遵守:
- 不要直接创建
StockMovement记录——走StockLocation#restock/unstock(及allocate/release/adjust),确保行落地时带类型。 - 不要在 movement 之外写
count_on_hand——不再新增adjust_count_on_hand、set_count_on_hand、set_stock或update_all(count_on_hand:)的调用者;restock_backordered正在被删除。 - 不要写负的
count_on_hand——除强制发货外,层级保持在零或以上;超卖用"allocated_count超过count_on_hand"表达。 - 不要基于
originator_type构建业务逻辑——它在 6.1 消失,需要知道库存为何变动就等kind。 - 通过
Stock::Quantifier、StockLevel#available_count或#allocated_count读取可用性——绝不裸读count_on_hand;新增的裸读是本计划最主要的回归源。 - 不要假设库存在下单时离开——任何对账货架或报告"已移除单位"的逻辑都应锚定履约(fulfillment)而非下单。
StockMovement已由:stock权限资源覆盖——新库存端点无需目录条目,也不得挂在settings上。- 不要新增名为
stock_item的公共表面——Phase 0 已重命名类、表、端点、SDK 资源与 ID 前缀,带着旧词的新端点、事件名或 SDK 方法一出生就是弃用的。
总结
类型化库存变动(Typed Stock Movements)是 Spree 6.0 库存体系的基石改造:用带语义的kind与具体外键取代多态 originator,用allocated_count把"物理在架"与"已承诺"两个数量分开,让count_on_hand回归其字面含义。通过本文所述的五个 kind、五个库存动词、下单即分配的行为变更、幂等的数据迁移与只读审计 API,商家第一次拥有了完整可回答的库存账本——"为什么是 47?"这个问题,现在可以从数据里得到答案。相关实现与测试可在仓库中继续深入:stock_movement.rb、stock_level.rb、stock_location.rb、stock/quantifier.rb、typed_stock_movements_migration.rake 及 stock_movement_spec.rb 等测试用例。
【免费下载链接】spreeOpen Source eCommerce Platform for B2B, Marketplace, and Enterprise. REST API, TypeScript SDK, and production-ready Next.js storefront. Self-host it. Own your stack. No vendor lock-in. Zero platform fees.项目地址: https://gitcode.com/GitHub_Trending/sp/spree
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考