Spree 6.0 单据编号定制详解:在 Dashboard 中配置订单号格式、前后缀与序列起点

📅 发布时间:2026/9/14 15:22:33
Spree 6.0 单据编号定制详解:在 Dashboard 中配置订单号格式、前后缀与序列起点
Spree 6.0 单据编号定制详解在 Dashboard 中配置订单号格式、前后缀与序列起点【免费下载链接】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 的 changeset 文档 .changeset/document-number-customization.md 及其配套实现方案 docs/plans/6.0-document-numbers.md讲解 Spree 6.0 中订单编号可定制这一特性的完整落地方式如何在 Dashboard 的 Settings → Store → Order numbers 中配置编号格式顺序/随机、前缀、后缀与序列起点以及这些配置背后Spree::HasNumber关注点、运行时生成器注册表、spree_number_sequences计数表和 Store 偏好校验的源码实现。读完本文你既能像商家一样配置订单号也能像开发者一样替换或扩展任意单据类型的编号策略。功能概述编号从启动时冻结变为设置里可调Spree 的订单、退货、采购单等单据都有人类可读的编号如R1001、RET1005。在 6.0 之前编号的前缀和长度被冻结在每个模型的启动时旧式写法include Spree::Core::NumberGenerator.new(prefix: R)想改格式必须写 decorator 重新 include 模块且所有编号都是随机的。6.0 的改动用 changeset 文档原话概括为订单编号现在可以从 Dashboard 配置Settings → Store → Order numbers卡片控制编号形态——编号格式sequential 顺序 或 random 随机、订单号前缀和后缀、序列起始值并提供下一个编号长什么样的实时预览顺序编号是 6.0 的新默认订单从1001开始递增R1001、R1002不再携带 9 位随机数字。不希望向回头客暴露订单量的商家可以把格式切回 random修改只作用于未来订单已发出的编号永久不变SDK 类型同步扩展StoreUpdateParams和Store类型新增preferred_document_number_format、preferred_order_number_prefix、preferred_order_number_suffix和preferred_order_number_sequence_start四个字段。对应到仓库中的 TypeScript 实现这四个字段可以直接在 Store 类型定义 中看到preferred_document_number_format: string; preferred_order_number_prefix: string; preferred_order_number_suffix: string; preferred_order_number_sequence_start: number;并作为可选更新参数出现在 StoreUpdateParams 中preferred_document_number_format?: string preferred_order_number_prefix?: string preferred_order_number_suffix?: string preferred_order_number_sequence_start?: numberDashboard 侧的配置界面位于 store 设置页路由表单字段校验定义在 store 表单 schema 中。源码机制一has_spree_number 宏与 HasNumber 关注点6.0 用一个声明式宏取代了旧的模块工厂。每个需要编号的模型只需一行见 Spree::Orderclass Spree::Order Spree.base_class has_spree_number prefix: R endSpree::HasNumber被 include 进Spree::Base因此宏对所有模型可用但在某个模型真正调用has_spree_number之前什么都不发生。完整实现见 has_number.rb。宏做什么has_spree_number 只做三件事def has_spree_number(prefix:, key: nil) class_attribute :number_prefix, default: prefix, instance_writer: false class_attribute :number_key, default: (key || name.demodulize.underscore).to_sym, instance_writer: false before_validation :generate_number, on: :create end把prefix存为类属性number_prefix把 registry key 存为类属性number_key默认取模型名去模块、下划线化Spree::StockTransfer→:stock_transfer也可显式指定——例如 OrderGroup 写的是has_spree_number prefix: R, key: :order与 Order 共用同一个订单编号序列注册before_validation钩子仅在创建时触发generate_number。.has_spree_number?用于查询某模型是否已启用编号因为 concern 挂在所有模型上需要问而不是假设。生成主流程generate_numbergenerate_number 的循环逻辑是如果number已有值调用方显式提供直接返回不生成通过self.class.number_generator(store: number_store)解析出当前记录对应的生成器最多尝试MAX_ATTEMPTS 10次向生成器要一个候选值若number_taken?预检发现未被占用则赋值并返回10 次都失败则抛出GenerationError——生成器持续撞号是生成器本身的 bug而非运气差必须大声失败。其中number_storeL92-L96决定了读哪家店的编号设置模型直接属于某 store 就用该 store否则回落到Spree::Current.store。库存转移单这类不直接挂 store 的模型可以通过重写#number_store提供自己的解析源码注释提到库存转移单经由目的地 location 触达 store。三层唯一性防线编号的唯一性靠三层机制各层兜住前一层漏掉的竞态生成器内的预检number_taken?L103-L105unscoped.exists?(number: candidate)便宜但有建议性质——两个并发写者可能在检查与插入之间抢占同一编号唯一性校验模型上对number的数据库全局唯一索引配套校验数据库唯一索引 savepoint 重试这是唯一能挡住同一瞬间两个写者都提交的层。create_or_update的覆写L120-L133在捕获ActiveRecord::RecordNotUnique后重新生成一次并retry。两个实现细节值得注意插入必须跑在savepoint里transaction(requires_new: true)。PostgreSQL 中约束违例会毒化外层事务——没有 savepoint 时重新生成的查询本身会带着PG::InFailedSqlTransaction一起死掉而不是恢复SQLite 掩盖了这个问题所以本地测试套件不会暴露覆写必须保留 Rails 自身的(**, block)签名过宽的(*, **)会把它静默移出调用链让重试变成普通测试注意不到的死代码。重试测试见 has_number_race_spec.rb。源码机制二生成器策略类与运行时注册表生成器只负责一件事给这条记录一个候选编号字符串唯一性检查完全由HasNumber负责。基类见 base.rbmodule Spree module NumberGenerators class Base # param record [ActiveRecord::Base] 被编号的记录 # return [String] 候选编号 def generate(record) raise NotImplementedError, #{self.class.name} must implement #generate end # ... end end end由于记录被传进来自定义生成器可以读 store、market、日期——任何东西——不需要 decorator也不存在启动时冻结。Sequential6.0 默认策略Sequential 产生发票式编号R1001、R1002前缀 计数器 无零填充。核心流程def generate(record) store record.number_store raise_missing_store(record) if store.nil? resource_type record.number_key.to_s value Spree::NumberSequence.next_value( store: store, resource_type: resource_type, start_at: start_at_for(record) ) candidate compose(record, value) # #{prefix}#{value}#{suffix} return candidate unless taken?(record, candidate) # 若撞上了兄弟店铺已占用的连续号段 # 批量扫描出第一个空值并把计数器一次性跳过整段 free_value next_free_value(record, value) Spree::NumberSequence.advance_to(store: store, resource_type: resource_type, value: free_value) compose(record, free_value) end两个关键设计起点值只对订单生效start_at_for中record.number_key :order时才读 store 的order_number_sequence_start偏好其他单据类型一律从Spree::NumberSequence::DEFAULT_START1001计数——这与 changeset起始值属于订单编号设置卡片的决策一致批量跳段而非逐个重试next_free_value以SCAN_BATCH_SIZE 100为一批查询IN (...)找出第一个空值后advance_to一次性把计数器跳过整段。因为计数器推进发生在最终可能失败的回滚事务内——逐次重试时撞上兄弟店铺 10 个以上连续编号会耗尽保存重试预算且计数器推进会随失败保存一起回滚店铺将永久卡死。源码注释也明确了一条产品边界Numbering is mostly-gapless, never guaranteed gapless — a collision or a rolled-back transaction consumes a value.Never present this as legal invoice numbering.撞号或回滚事务会消耗一个值因此绝不能将此宣传为法定发票编号。前缀与后缀的读取在基类的prefix_for/suffix_forbase.rb L27-L50只有number_key :order的记录才会读 store 的order_number_prefix/order_number_suffix偏好其他单据一律用模型级代码默认前缀。Random5.x 行为作为可选项保留Random 即 6.0 之前的行为前缀 固定 9 位随机数字零填充class Random Base DEFAULT_LENGTH 9 def generate(record) digits SecureRandom.random_number(10**length).to_s.rjust(length, 0) #{prefix_for(record)}#{digits}#{suffix_for(record)} end end商家选择它的动机源码注释原话宁可给回头客念一串难以回读的号码也不想让对方从两次购买之间的编号差看出订单量。Registry注册表决定谁给谁编号生成器在运行时、按记录通过注册表解析实现见 registry.rbclass Registry FORMATS { sequential Spree::NumberGenerators::Sequential, random Spree::NumberGenerators::Random }.freeze DEFAULT_FORMAT sequential.freeze # ... def for(key, store: nil) registered self[key] return registered.new if registered format_class(store).new end private def format_class(store) format (store || Spree::Current.store).preferred_document_number_format FORMATS.fetch(format.to_s, FORMATS.fetch(DEFAULT_FORMAT)).constantize end end解析优先级显式注册Spree.number_generators[:order] MyApp::BranchOrderNumbers取到即用store 偏好读该记录的 store 的preferred_document_number_format缺失时回落Spree::Current.store在sequential/random两个内置策略中选择默认值偏好缺失时用DEFAULT_FORMAT sequential。两个实现要点值以字符串存储、读取时constantize因此宿主应用可以在 initializer 里注册而不必预加载类开发环境的 Zeitwerk 重载也能解析到最新定义for接收store:参数并优先读传入 store 的偏好而非Spree::Current这样后台任务给其他 store 的单据编号时仍遵守那家店的格式选择。这个注册机制正是 Dashboard 那个格式开关无需任何注册就能生效的原因——for的第二分支直接消费 store 偏好。源码机制三spree_number_sequences 计数表顺序编号的下一个值存在专门的计数器表里而不是从number列推导。模型见 number_sequence.rbclass NumberSequence Spree.base_class DEFAULT_START 1001 belongs_to :store, class_name: Spree::Store validates :resource_type, presence: true, uniqueness: { scope: :store_id, case_sensitive: false } # 每行 (store, resource_type) 的一个独立计数器源码注释解释了为什么不能从number列推导下一个值升级上来的店铺列里充满 5.x 随机值会污染任何MAX解析文本列排序R999会排在R1000后面并发写者各自算MAX 1必然撞车商家自定的起始值也不是任何既有行的事实。每行按(store, resource_type)独立计数保证了同一类型内编号连续R1001, R1002, …同时退货停在RET1001——共享一个时间序计数器会在订单序列中留下空档商家会把空档读成丢单。核心 API 都是带行锁的def next_value(store:, resource_type:, start_at: DEFAULT_START) sequence find_or_create_sequence(store, resource_type, start_at) sequence.with_lock do sequence.increment!(:value) sequence.value end end种子值新计数器初始化为start_at - 1使第一次自增恰好发出start_at本身即默认 1001并发安全行用with_lock锁定后自增PostgreSQL、MySQL、SQLite 上表现一致——这是跨数据库的便携式原生 sequencePostgres 有原生 sequenceMySQL/SQLite 没有并发插入兜底两个写者可能同时走到插入输家会撞上唯一索引RecordNotUnique或唯一性校验RecordInvalid两者含义相同——行已存在直接读它即可find_or_create_sequence L120-L128started?暴露计数器是否已发出过编号。Dashboard 用它来禁用起始值输入框并给出解释——一个静默失效的设置比一个说明自己为什么不能用的设置更糟糕。Store 偏好字段与校验规则四个新偏好挂在Spree::Store上遵循设置属于 Store绝不放Spree::Config的 store 级配置原则校验规则在 store.rbvalidates :preferred_document_number_format, inclusion: { in: Spree::NumberGenerators::Registry::FORMATS.keys } validates :preferred_order_number_sequence_start, numericality: { only_integer: true, greater_than: 0 } validates :preferred_order_number_prefix, :preferred_order_number_suffix, length: { maximum: 10 }, format: { with: /\A[A-Z0-9#-]*\z/, message: :invalid_document_number_affix } # ... validate :order_number_sequence_start_unchanged_after_first_number, on: :update整理为参数表偏好字段取值 / 默认校验preferred_document_number_formatsequential默认或random必须属于Registry::FORMATS的 keypreferred_order_number_prefix默认R长度 ≤ 10字符集A-Z、0-9、#、-允许空串preferred_order_number_suffix默认空串同上前缀与后缀同时为空是允许的纯整数编号preferred_order_number_sequence_start默认 1001正整数首个编号发出后服务端锁定再次修改会触发校验错误最后一条是 changeset 决策起始值仅在首个编号发出前可编辑的服务端落实order_number_sequence_start_unchanged_after_first_number 在 update 时若NumberSequence.started?已为真则追加:locked_after_first_number错误与 Dashboard 上被禁用的输入框一一对应。哪些模型参与编号按当前仓库中has_spree_number的实际挂载点见spree/core/app/models/spree/下各模型文件模型前缀说明Spree::Order/OrderGroupR商家可定制格式、前缀、后缀、起点Spree::ReturnRET独立计数跟随 store 格式开关Spree::ExchangeEX同上Spree::ClaimCLM同上Spree::StockTransferT跟随格式开关独立计数器Spree::StockReceiptSR库存单据Spree::PurchaseOrderPO6.0 库存操作引入出生即带编号Spree::Import/ExportIM/EF会通过邮件把编号发给商家如Your export EF1001 was successfully processed!Spree::DataRequestDSR数据请求单据注意格式开关的作用范围store 的sequential/random选择同时作用于 order、return、exchange、claim 等所有独立编号模型各单据类型的前缀仍由代码默认值固定RET、EX、CLM…只有订单开放了商家级前后缀配置。Fulfillment 与 Payment 在 6.0 改为存列优先、否则从父订单推导的派生编号R1001-F1、R1001-P1不再走计数器——这一机制的完整决策背景见方案文档 docs/plans/6.0-document-numbers.md 的 Derived numbers 一节。扩展指南注册自定义生成器结合Base类注释中的示例完整的扩展方式如下这是查看与运行宿主应用时的写法非对 Spree 仓库本身的修改# 1. 继承基类实现 generate(record) class MyApp::BranchOrderNumbers Spree::NumberGenerators::Base def generate(record) # record 可直接访问 number_store、number_prefix 等 #{record.number_store.code}-#{record.created_at.year}-#{SecureRandom.hex(3)} end end # 2. 注册到指定资源 key如 :order值为字符串类名 Spree.number_generators[:order] MyApp::BranchOrderNumbers # 需要时移除回落到 store 设置驱动的默认策略 Spree.number_generators.delete(:order)注册点应放在应用config/initializers/spree.rb中。方案文档特别强调了初始化时机registry 的播种发生在 engine initializer 且before: :load_config_initializers——与 subscriber 和 order-routing 注册表相同若在after_initialize播种所有文档化的注册方式会在启动时崩溃。方案文档还列出两条开发约束值得遵循绝不解析、正则匹配或假设number的格式——格式现在是商家可配置的查找一律精确字符串匹配用validate: false保存编号记录的代码如示例数据导入、退货迁移器必须显式调用generate_number因为编号钩子是before_validation这类保存会跳过它。旧版Spree::Core::NumberGeneratorspree/core/lib/spree/core/number_generator.rb保留为一个带Spree::Deprecation警告的废弃外壳把选项转译为新 concern计划 6.1 移除。小结与适用前提商家视角在 Dashboard 的Settings → Store → Order numbers卡片即可调整订单号格式默认顺序编号从R1001起、前缀、后缀与起点实时预览下一个编号切换 random 可避免向老客暴露订单量所有改动只影响未来订单已发出的编号永不变。开发者视角编号体系由三部分组成——Spree::HasNumber关注点钩子 重试 savepoint 兜底、Spree::NumberGenerators::{Base, Sequential, Random}策略类、(store, resource_type)粒度的spree_number_sequences计数行换策略通过Spree.number_generators[key]注册表完成无需 decorator。适用前提以上内容基于当前仓库Spree 6.0 周期的实际代码。顺序编号基本连续但不保证无空档撞号与回滚事务会消耗值源码明确反对将其宣传为法定发票编号起始值在首个编号发出后被服务端锁定升级自 5.x 的店铺会直接开始与历史随机编号并存的顺序编号历史数据不迁移、不改号与旧号的冲突由重试循环吸收。【免费下载链接】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),仅供参考