Spree 6.0 类型化库存变动(Typed Stock Movements)完整解析:从多态 originator 到可审计库存账本
2026/9/14 17:57:22 网站建设 项目流程

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_idfulfillment_idreturn_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 可能是FulfillmentStockTransfer、旧的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_idstock_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_idorder_id,因为"这批货是给哪个订单的?"是商家最常问的问题,通过 fulfillment 再 join 一次去回答它太浪费。禁止的是在一行上放两个无关的原因。

数量符号约定

quantityreceivedadjusted保留符号语义(正加负减);对三种订单驱动类型(allocated/shipped/released),方向已由 kind 表达,因此一律写正数、通过abs读取。实现里甚至有专门校验:这三个 kind 的quantity必须是正数(stock_movement.rb),避免存储"符号与变更方向矛盾"的行。

不可变性:readonly?

def readonly? persisted? && !being_created? end

movement 一旦持久化即只读。反转一个操作的正确方式是写入它的反操作,而不是编辑历史。实现中还处理了一个边界情况:父记录的 autosave 可能在自身保存进行中插入该行(通过inverse_of持有),此时行已在插入中途,不能误判为更新而拦截。

行为变更:下单即分配(Allocation),发货才动货架

这是整个计划的头条行为变更

变更前 vs 变更后

事件旧行为新行为
订单下单count_on_hand立即减少(Fulfillment#finalize!manifest_unstockallocatedmovement,allocated_count增加,count_on_hand不动
履约发货不写任何 movementshippedmovement,扣减count_on_hand并同时冲销对应 allocation
履约取消restock(现库)或restock_backordered(无 movement)releasedmovement,无物理货移动
履约恢复再次 unstockallocatedmovement,重新承诺
手动修正直接改计数,无记录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_backorderedStockMovement#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::StockItemSpree::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_handset_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 end

adjust_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 end

in_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 reservations

total_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 >= required

available_stock对未加载的关联求和count_on_hand - allocated_countreserved_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

三个值得注意的实现细节:

  1. unstock不再取反数量——旧实现调用move(variant, -quantity),因为符号是区分出发与到达的唯一手段;现在由kind表达,三个订单驱动类型一律写正数。
  2. 原因解析集中在cause_attributes一个方法——按类分发(when Spree::Fulfillmentis_a?),履约携带其订单、收货(StockReceipt)递归携带其 receivable 的原因。原因集合是封闭且 core 所有的,所以"一张方法展示全部映射"胜过在五个模型上各写一个stock_movement_attributes。为6.0-inventory-operations.md增加purchase_order_id只需加一个when子句。
  3. 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对接。

一个订单生命周期中的库存全景

计划给出了普通路径下订单一生中各事件对库存的影响,结合源码验证如下:

  1. 购物车变更Carts::AddItemSetQuantityRemoveLineItemCarts::Update)——创建或刷新带 TTL 的 reservation;可退订(backorderable)与预售变体跳过。无 movement、无计数器变化。
  2. 配送步骤或地址变更Checkout::Advancerebuild_fulfillments!)——按来源仓提议 fulfillment,每个单位对照available_count标注 on-hand 或 backordered。无库存写入。
  3. 购物车弃单——reservation 随 TTL 过期被清扫。无其他动作。
  4. 下单Orders::Complete#finalize_fulfillments,放置锁内)——每个 fulfillment 写allocatedallocated_count上升、count_on_hand不动。草稿订单直接进入此处。
  5. 同一请求、锁外一步release_stock_reservations)——删除 reservation;单位短暂"既预留又分配",可用性低估而非高估。
  6. 买标签Fulfillments::PurchaseLabel)——无库存影响。
  7. 发货Fulfillments::Fulfill)——为已分配数量写shippedcount_on_hand下降、allocated_count下降。
  8. 送达/跟踪更新MarkDeliveredUpdateTracking)——无库存影响。

第 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_variantscount_on_hand - allocated_count <= 0
StockLocation#fill_status对照available_count拆分 on-hand 与 backordered
StockReservations::Reserveavailable_count - held_by_others - this_order_used
OrderRouting::Rules::MinimizeSplitspluck 两列并比较差值
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_stockproduct.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,且一次授权可能变成ReturnExchange中的任意一个。

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,负数量,履约已关闭shippedfulfillment_id+ 履约的order_id
Fulfillment/Shipmentoriginator,负数量,履约仍开启allocated,同样的外键——计数器随之移动
Fulfillmentoriginator,正数量released,同样的外键(取消或改仓的 restock)
ReturnAuthorizationoriginatorreceivedreturn_idexchange_id
StockTransferoriginator,正数量receivedstock_transfer_id
StockTransferoriginator,负数量shippedstock_transfer_id
无 originatoradjusted,reason "Legacy manual adjustment"

原因无法解析的行按符号定型、外键留空,而不是跳过——没有原因的运动仍是关于库存的真实陈述。

关键细节:开放履约按 allocated 定型

一个履约在升级时是否已发货,决定了它的历史如何被解读:已关闭履约的离库就是离库(shipped);未关闭履约的离库是它仍持有的承诺——旧模型下,下单时把货从货架上拿走就是分配——因此定型为allocated。若误标为shippedallocated_quantities(用shipped减去allocated)会让对账净值为零,履约将报告"没有任何承诺"——取消时永远不会把货还给可用性,真实库存被卡死在零。

由于定型是跳过回调的update_all重标,开放履约的行要自己携带计数器:count_on_handallocated_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_locationvariant: :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),kindNOT 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_adjustmentcorrectioncountreceivedreturn_restockdamagedtheft_or_losspromotion_or_donationinventory_feed),已知码解析为固定英文文本,未知文本原样保留——原因列被所有后台管理员阅读并跨全历史过滤,所以一个原因必须是一个字符串。

迁移路径总览:Phase 0–5

阶段内容状态
Phase 0StockItemStockLevel纯重命名(表、外键列、索引、常量别名、SDK 资源、双发stock_item.*事件)✅ 已发布
Phase 1加列:kindreason、5 个外键、kind索引、allocated_count✅ 已发布
Phase 2重写模型与全部调用点/读取者,删除min_quantityrestock_backordered、shim✅ 已发布
Phase 3数据任务spree:migrate_stock_movements_to_typed_rows✅ 已发布
Phase 4API 端点、序列化器、仪表盘消费✅ 已发布
Phase 56.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 end

6.1 的删除(Phase 5)将移除多态列并给kindNOT NULL。推迟到 6.1 的原因与 6.0 其他清理一致:这些列是数据任务的来源回滚路径——升级到 6.0 后发现某行定型错误的安装,可以在整整一个发布周期内从 originator 重新推导。action列随之删除——多年没有应用代码读写它,kind才是它一直想成为的东西。

对当前开发者的约束

计划文档列出了开发约束,写扩展或调用库存相关代码时务必遵守:

  • 不要直接创建StockMovement记录——走StockLocation#restock/unstock(及allocate/release/adjust),确保行落地时带类型。
  • 不要在 movement 之外写count_on_hand——不再新增adjust_count_on_handset_count_on_handset_stockupdate_all(count_on_hand:)的调用者;restock_backordered正在被删除。
  • 不要写负的count_on_hand——除强制发货外,层级保持在零或以上;超卖用"allocated_count超过count_on_hand"表达。
  • 不要基于originator_type构建业务逻辑——它在 6.1 消失,需要知道库存为何变动就等kind
  • 通过Stock::QuantifierStockLevel#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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询