
Spree 阶梯定价实战Price List 的按量分级、百分比阶梯与 CSV 批量导入导出【免费下载链接】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 当前仓库中新增的批量/阶梯定价volume pricing能力展开一个 Price List价格表可以按变体variant携带一条数量阶梯quantity breaksCatalog 的百分比调整也可以按数量分段同时 Price List 的价格支持以 SKU 为键的 CSV 导出与合并式导入。读完本文你将理解这套功能背后的数据模型min_quantity行、PriceAdjustmentTier分段、价格解析时的取档逻辑以及从 CSV 文件到BulkUpsert写入的完整链路可据此在自己的 Spree 项目中落地阶梯定价。该功能对应仓库中的变更记录 .changeset/price-list-csv-and-volume-pricing.md设计规划见 docs/plans/6.0-volume-pricing.md 与 docs/plans/6.0-price-list-automatic-pricing.md用户侧操作说明见 docs/user/how-to/volume-pricing.mdx。一、功能全景变更记录里的三件事变更记录的 frontmatter 声明本次改动涉及四个包的 minor 版本升级spree/dashboard、spree/dashboard-core、spree/dashboard-ui与spree/admin-sdk。正文概括了三块能力变体级数量阶梯一个 Price List 可以为每个变体携带一条从每个数量起对应的单价a unit price from each quantity up在价格表电子表格price spreadsheet中按 tier 行编辑Catalog 百分比分段Catalog目录/协议的百分比调整也可以按数量分段——达到某个数量后改用该段的百分比Catalog 价格列会显示某变体携带多少个阶梯档位鼠标悬停可见完整阶梯并注明固定档位fixed tiers会直接定价不受百分比影响CSV 导出/导入Price List 的价格可以按一行一档one rung per row、以 SKU 为键导出与导入入口在价格表自己的页面以及拥有它的 Catalog 页面。导入采用合并语义文件中存在的行会写入空价格表示删除该档文件中未提及的档位保持不变。Admin SDK 的 price、price list 和 import 类型携带了新字段import 的 create 调用可接受要合并进去的价格表。以下结合源码逐层展开。二、数据模型价格行如何承载阶梯2.1Spree::Price的min_quantity与档位上限阶梯定价的落点是价格行本身。Spree::Price 上有一个min_quantity列要求为正整数同一变体在同一价格表、同一币种下min_quantity: 1的行就是普通单价数量大于 1 的行则是从该数量起的阶梯价breaksscope 即where.not(min_quantity: 1)。# spree/core/app/models/spree/price.rb MAXIMUM_BREAKS_PER_VARIANT 10 # ... scope :breaks, - { where.not(min_quantity: 1) }MAXIMUM_BREAKS_PER_VARIANT 10是源码注释中说明的UI 健全性上限而非技术上限但它在模型层强制校验breaks_within_cap因为价格解析器要为每一条已计价订单行扫描该变体的所有价格行——限制行数就是在限制解析成本。2.2 价格表的占位行机制一个变体进入价格表时会先获得占位价格行amount 为 nil# spree/core/app/models/spree/price_list.rb (add_products) # Only the bottom rung counts as already here: a variant priced solely # from a case up still needs its quantity-1 placeholder, or the editor # has no row to show the ladder under. existing prices.where(variant_id: variant_ids, min_quantity: 1) .pluck(:variant_id, :currency).to_set从源码注释可以推断其用意只有 quantity-1 的底层行才算已存在——如果一个变体只有从一箱case起的阶梯价而没有 quantity-1 占位行编辑器就没有一行来展示整条阶梯。反向地remove_products会把该变体在表中的所有档位行硬删除prices.where(variant_id: variant_ids).delete_all因为从表中移除一个产品就是移除该表为它定的一切价。三、百分比阶梯PriceAdjustmentTier与取档逻辑3.1 分段模型与约束Spree::PriceAdjustmentTier 表示从 min_quantity 起价格表按这个百分比而非列上的price_adjustment_percentage调整基础价。关键约束min_quantity必须大于 1quantity-1 由价格表自己的列回答档位 1 会被数值校验拒绝避免同一问题两个答案percentage必须非零且介于 -100不含与 1000不含之间——-100 会让所有派生价为 00 在解析时被视为不定价而不是打 100% 折单表档位数上限MAXIMUM_TIERS_PER_LIST直接取Spree::Price::MAXIMUM_BREAKS_PER_VARIANT10让商家只面对一个数字档位的百分比始终作用于基础价而不是上一档的结果——从 50 件起 -20% 就是按门市价打八折而不是在已打折数字上再折。3.2 取档与派生价格的源码路径Spree::PriceList的核心方法spree/core/app/models/spree/price_list.rb# 该数量能触达的最深档位触达不到则为 nil def band_for(quantity) line_quantity quantity.to_i return if line_quantity 2 price_adjustment_tiers.select { |tier| tier.min_quantity line_quantity }.max_by(:min_quantity) end # 该数量下的调整百分比触达最深档否则回落到列表列 def adjustment_percentage_for(quantity nil) band band_for(quantity) band ? band.percentage : price_adjustment_percentage end派生价在读取时计算而不是落库derived_price_from这样基础价变动时百分比表不会漂移def derived_price_from(base, quantity nil) band band_for(quantity) percentage band ? band.percentage : price_adjustment_percentage factor percentage 1 (percentage / 100) return if factor.nil? || factor 1 Spree::Price.new( variant_id: base.variant_id, currency: base.currency, amount: Spree::Money::Rounding.to_currency(base.amount * factor, base.currency), min_quantity: band.min_quantity || 1, price_list_id: id ) end两个值得注意的边界均来自源码注释只带档位、没有列值的表在订单行数量未达到第一档之前不调整任何东西——这就是不足一箱不打折的协议语义百分比含档位只允许挂在 Catalog 拥有的价格表上percentage_requires_catalog校验会拒绝独立standalone表携带百分比或档位理由是一个无规则的独立表若带百分比会在产品清单变化时让整店进入促销。3.3 Dashboard 端的呈现解析结果由 Spree::CatalogPrice 承载其中两个属性正是为阶梯定价引入的# break_count该金额之上还压着多少个数量档位0 单一价格 attribute :break_count, :integer, default: 0 # tiers阶梯本身按数量排序的 CatalogPriceTier 行—— # 商家读协议时不必打开价格表就能看到每个阈值下买家实付多少 attr_accessor :variantsource取值为封闭集合explicit / automatic / base分别是Catalog 自己的表上人工录入的金额该表百分比作用在基础价上的结果变体的正常门市价该协议未给它定价。结合变更记录的描述Catalog 的价格列显示break_count该变体携带多少个档位悬停展示tiers阶梯并注明固定档位explicit 行会直接定价、不受百分比影响——tiered?break_count为正即用于区分这是阶梯的底层还是这是唯一价格。四、CSV 导出一行一档SKU 为键导出实现是 Spree::Exports::PriceListPrices。CSV 列结构由导入 schema 直接决定spree/core/app/models/spree/import_schemas/price_list_prices.rb保证导出的文件无需改列名即可回灌导入列名标签必填含义skuSKU是变体 SKU行键currencyCurrency否币种导入时空值按店铺默认币种处理min_quantityMinimum quantity否该档的起始数量空按 1pricePrice是该档单价空 删除该档compare_at_priceCompare at Price否划线价/原价行序与分批阶梯从上往下读——先产品、再变体、币种、数量find_in_batches强制主键序因此先取出有序 id再按BATCH_SIZE 1_000分批加载行。两个关键过滤def scope super.for_price_list(store.price_lists).where.not(amount: nil) end只导本店铺的价格表、且排除 amount 为 nil 的占位行——因为导入把空价格读作删除该档若把占位行写进文件原样重新导入就会把这些产品从表中移除导出必须指定一个本店铺的价格表Ransack 过滤参数price_list_id_eqrecord_selection all或不指定列表都会被price_list_selected校验拒绝因为所有价格表混在一个文件里无法区分。金额格式由 Spree::CSV::PriceListPricePresenter 控制用币种自身精度、固定小数点18.00绝不输出$18.00或18,00原因是文件由导入按店铺 locale 读回写逗号会被读成百倍价格导出行还会经过Spree::CSV::FormulaSanitizer处理防止 CSV 公式注入。五、CSV 导入合并语义的完整链路导入实现是 Spree::Imports::PriceListPrices其类注释把合并语义说得很清楚一行写入或更新其档位、空价格删除该档、文件未提及的档位保持原样。目标价格表是 preference 而非列因为只有这一种导入类型带父级preference :price_list_id, :string, default: nil validate :price_list_present, on: :createprice_list始终经店铺读取store.price_lists.find_by(...)指向别的店铺的价格表会返回 nil、导入被拒。这也对应变更记录中admin SDK 的 import create 调用接受要合并进去的价格表——创建导入时把 price list 传入即可指定合并目标。按 SKU 分组group_column返回sku同一变体的所有档位行在同一次处理中完成因此档位上限10 档是对文件为该变体携带的整条阶梯整体判定的。单行处理器Spree::Imports::RowProcessors::PriceListPrice 的关键细节SKU 匹配仅限本店铺的变体大小写不敏感SKU 为空、查不到、或匹配到多个多卖家可能共享 SKU都会报行级错误错误信息分别对应price_list_import_sku_required / unknown_sku / ambiguous_sku文案币种空值取店铺默认币种其余必须是店铺经营币种之一金额解析不用 locale 感知的解析器而是严格正则AMOUNT_FORMAT /\A\d(\.\d)?\z/——空保持空服务层读作删除非法文本直接报错避免在逗号小数 locale 下把16.50读成 1650写入路径走Spree::Prices::BulkUpsert与批量价格编辑器同一条路径所以文件里空价格 删档与编辑器行为一致档位上限也由同一段代码判定compare_at 保护若文件未映射compare_at_price列则沿用该档已存的划线价避免缺列即清空所有划线价成员关系补全只有当某行确实写了金额才会ensure_membership调price_list.add_products补齐占位行——被拒行和空价格行都不应把产品带进表中。失败时会把服务层错误翻译为可定位的文案超档位数、非法数量等便于在导入报告里逐行排查。六、Admin SDK 的类型跟进变更记录还提到 Admin SDK 同步了类型从源码看packages/admin-sdk/src/types/generated/ 下的Price、PriceListProduct、Import等生成类型携带了阶梯定价相关的新字段如价格行上的数量档位、列表的分段数据、导入上的 price list 关联前端 packages/dashboard 侧的价格电子表格与 Catalog 价格列即基于这些类型渲染 tier 行、显示break_count与悬停阶梯。使用 SDK 的程序化调用方据此可以读、写整条阶梯而不仅是最底层的单价。七、落地建议与边界条件结合以上源码在实际使用这套功能时有几个值得记住的边界单变体单表最多 10 个档位行、单表最多 10 个百分比档位超限在模型层与导入层都会被拒绝错误信息中带出上限值百分比与百分比档位只能配在 Catalog 拥有的价格表上独立表会被percentage_requires_catalog拒绝CSV 是该表的完整快照心智模型导出会把占位行过滤掉所以导出后原样导入是安全的未提及的档位不动但要删除某档就在文件里把price列留空金额列必须用点号小数18.50而非18,50这是回灌导入时 locale 无关性的硬要求阶梯价explicit 行与百分比automatic是两条独立通道目录价格列中的悬停阶梯会把两者合并展示但固定档位定价优先于百分比——这与变更记录中fixed tiers set the price regardless of the percentage的表述一致。这套数量阶梯 百分比分段 CSV 往返的组合让 Spree 的价格表既能表达零售阶梯价也能表达 B2B 场景下整箱更便宜、大客户更折的协议定价同时保留了用 Excel/CSV 批量维护价格的现实工作流。【免费下载链接】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),仅供参考