尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Spree 6.0 实战:B2B 批发货运体系——箱型、托盘、整柜与“评审后报价“的运费设计

Spree 6.0 实战:B2B 批发货运体系——箱型、托盘、整柜与“评审后报价“的运费设计 Spree 6.0 实战B2B 批发货运体系——箱型、托盘、整柜与评审后报价的运费设计【免费下载链接】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 6.0 的设计文档 6.0-b2b-wholesale-shipping.md 及其在核心代码中的实现讲解 B2B 批发订单的货运链路如何把商品装箱carton→ 装托pallet→ 整柜container如何在结账时生成物流摘要而非即时运费以及如何用VolumeRule/CompanyRule与无价运费费率unpriced rate让批发分层和评审后报价quoted after review成为纯配置。读完本文你能完整理解这套从PackageType模型到 Freight 费率提供器的实现并知道每个决策背后的约束与取舍。一、批发订单为什么不能复用零售物流批发订单离开仓库时的形态与零售截然不同它走的是整箱carton、托盘pallet乃至集装箱container由整单总体积决定走哪一档而国际货运的价格通常不是结账时算出来的——是商家审阅订单后由货运代理forwarder报价的。因此平台必须回答四个零售物流不存在的问题商品如何装进箱子packaging vocabulary如何把购物车滚动roll up成物流数字箱数、立方米CBM、毛重如何把运输分类到商家配置好的档位tier如何让结账在没有运费价格的情况下完成。设计目标是不触碰零售物流让同一个目录同时服务零售与批发买家。方案由六个部分构成门店作用域的Spree::PackageType模型箱、信封、纸箱、托盘、集装箱吸收原来四个default_package_*门店偏好配置每个变体variant的装箱明细指向某个carton类PackageType的引用加上units_per_carton与整箱毛重在既有当时尚未使用的Stock::Package#volume链路上计算的货运摘要freight summaryDeliveryMethodRules::VolumeRule让运输分层成为运费方式上的配置而非代码无价运费费率一等公民的评审后报价费率携带物流摘要而非价格对应的管理端与 Store API 接口面。明确不在本方案范围内的是批发买家如何付款定金、账期、信用额度。这是独立的支付条款payment-terms主题文档在 2026-09-07 的评审中正式将其移出本方案——因为40% 定金 余额 Net 30这种组合安排是支付侧的问题运费方式上不应长出deposit_percentage。二、核心数据模型Spree::PackageType模型定义Spree::PackageType是整套体系的包装词汇表定义见 package_type.rbclass Spree::PackageType Spree.base_class KINDS %w[box envelope carton pallet container].freeze include Spree::SingleStoreResource include Spree::Metadata belongs_to :store, class_name: Spree::Store has_many :variants, class_name: Spree::Variant, foreign_key: :carton_package_type_id, dependent: :restrict_with_error validates :name, presence: true, uniqueness: { scope: spree_base_uniqueness_scope } validates :kind, inclusion: { in: KINDS } # length/width/height decimal(8,2), dimensions_unit, max_weight decimal(10,2), # weight_unit, default boolean def volume Spree::Measurement.cubic_meters(length, width, height, unit: dimensions_unit) end end关键决策与设计意图命名刻意不用Spree::PackageSpree::Stock::Package是估价器estimator内部那个内存中的打包单元若再有一个同名的持久化模型会造成永久性混淆。几何信息放在共享的 carton 行上而不是散列在变体上。商家会跨几百个产品复用少数几种标准箱型——改一次 carton 行所有用该纸箱打包的产品同步更新而因产品而异的打包事实能装几件、装满后多重留在变体上。直接删除四个default_package_*门店偏好不留废弃桥接。理由是它们就在同一个未发布的 6.0 周期内加入不存在需要保护的已发布 API若留一个写穿到数据库行的桥接一个发布周期内会出现同一门店箱子的两种写法毫无收益。这是永远要桥接惯例的一次记录在案的特例。升级任务保证删除安全spree:upgrade:package_types在删除偏好键的迁移之前运行先把偏好值落成每个门店一行默认PackageType跳过仍在种子零值上的门店。门店默认箱由既有单一入口读取Stock::Package 的#weight皮重 tare与#dimensions从默认行读取因此 EasyPost 包裹物流的形态不变。pallet/container行是商家词汇和参考数据也是未来面单/BOL 工作的天然锚点分层计算不依赖它们——tier 是规则上的体积区间商家配置档位甚至不需要定义任何 pallet 行。源码中的实现细节设计文档之外的实现细节package_type.rb 里还能看到三条保护性约束都体现了默认箱是报价命脉的定位每个 owner 只有一个默认箱demote_other_defaults在保存时用事务内条件 UPDATE 把同 owner 的旧默认降级配套部分唯一索引one default per owner。注意这里的 owner 维度后来扩展到了卖家见 6.0-seller-package-types.md 交叉引用市场级行seller_id为 nil卖家行归属卖家每个门店一个默认变成了每个 owner 一个默认。默认箱不允许被删除或取消默认ensure_not_default与default_cannot_be_given_up两个校验保证门店永远有皮重和尺寸可用——删掉它会让所有报价在无声中低估大件运输成本。carton 行不能被改成别的 kindkind_cannot_leave_the_products_packed_in_it校验阻止把仍有产品引用的 carton 改成 pallet否则这些产品的运费滚动数据会指向一个它们本不能再引用的行。此外PackageType#dimensions_unit在未显式设置时回落到门店单位制英制读作英寸、公制读作厘米与变体尺寸的回落规则一致#volume通过Spree::Measurement.cubic_meters做单位感知换算只有三条边都记录了才返回数值。管理端接口为settings作用域下的/api/v3/admin/package_typesCRUD仪表盘页面位于 Settings → Shipping。三、变体装箱明细Unit → Carton → Pallet 打包链Schema 与模型方法spree_variants表新增的列见 variant.rb 中的关联与校验# spree_variants gains: # carton_package_type_id bigint # a PackageType with kind carton # units_per_carton integer # carton_weight decimal(8,2) # gross weight of one PACKED carton # cartons_per_pallet integer # optional third packing level class Spree::Variant belongs_to :carton_package_type, class_name: Spree::PackageType, optional: true def carton_volume carton_package_type.volume end def cartons_for(quantity) return nil if units_per_carton.blank? || units_per_carton.zero? (quantity / units_per_carton.to_f).ceil end def units_per_pallet return nil if units_per_carton.blank? || cartons_per_pallet.blank? units_per_carton * cartons_per_pallet end end这四个字段共同构成打包链Unit → Carton → Pallet → CBM / Weight几何来自共享 carton 行units_per_carton、carton_weight、cartons_per_pallet是产品级打包事实货运摘要在cartons_per_pallet存在处报告托盘数同一份数据同时供给数量规则方案6.0-b2b-quantity-rules.md买家下单 5 个托盘 200 箱 4,800 件Spree 从一处数据同时知道三个数字。托盘几何仍放在pallet类PackageType行上变体只记录怎么堆。源码中的校验与约束variant.rb 中的实现印证了文档约束carton_package_type_must_belong_to_store校验拒绝跨门店引用引用一个非 carton kind 同样会被拒绝——carton?是唯一被分支依赖的 kindunits_per_carton、cartons_per_pallet必须为正整数carton_weight必须为正数均可留空存在order_multiple与units_per_carton互不整除时的incompatible_with_carton校验以及 carton 购买单位必须声明units_per_carton的校验——这些是数量规则方案与本方案在同一打包链上的耦合点。编辑路径变体编辑页与批量电子表格都新增 Carton 区箱型选择器 内联新建箱型 两个数值字段CSV 导入/导出按PackageType#name在门店内解析箱型。有一条来自 6.0-duties-and-custom-fees.md 的硬约束新变体属性必须在全部三条变体写入路径中都加入白名单嵌套 variants controller、products controller 上的内联variants: [...]列表、permitted-attribute 声明否则会在使用产品保存时静默丢失。单位换算基座Spree::Measurement这是本方案唯一真正的新原语一个小型无状态换算模块spree/core/lib/spree/measurement.rb负责in/ft/mm/cm→ cm 的尺寸换算与g/kg/lb/oz→ kg 的重量换算外加cubic_meters组合函数。文档明确决策体积计算从第一天起就是单位感知的——用英寸尺寸算出的 CBM 与厘米算法相差约 16 倍WeightRule被允许的裸数字捷径在体积上不可用所有体积/CBM 计算必须走这个单位感知 helper不允许直接相乘原始尺寸列。四、货运摘要Spree::FreightSummary值对象结构FreightSummary是值对象ActiveModel::ModelAttributes遵循规范不用 Struct实现在 freight_summary.rb每行明细在 freight_summary/line.rbSpree::FreightSummary.build(contents, store:) # total_units, total_cartons, total_pallets (nil unless every carton-bearing # line declares cartons_per_pallet), total_volume (CBM), total_weight (kg), # complete? (false when any line lacks carton data), lines[]逐行line的计算规则箱数 variant.cartons_for(quantity)向上取整体积 箱数 ×carton_volume重量 箱数 ×carton_weight。没有 carton 数据的行回落到单位体积/重量 × 数量并把摘要标记为不完整complete?为 false——数字仍然有用flag 告诉商家为什么只是部分值。源码中还能看到几个值得注意的滚动细节Line#combine同一变体在多票货中产生的行会被合并而非相加——箱数和托盘数在各自构建时都向上取整过每箱装 12、拆成 3 件和 9 件两票合计是 1 箱而不是 2 箱。除数units_per_carton/cartons_per_pallet直接随行走冻结快照多年后无需回查目录即可重组。FreightSummary.merge把多份履约fulfillment冻结的摘要合并成货代看到的单一负载。未完整行的体积在合并时按每箱体积 × 合并后箱数重算而不是把两个半满箱的体积相加——否则会报告合并后并不存在的空间占用。Stock::Package侧的接入点在 package.rb#freight_summary内存化计算、内容变更即失效过期的体积会选错运费档位#volume重新定义为freight_summary.total_volume原有contents.sum(:volume)无消费者改动安全。而Package#dimensions刻意不把单品尺寸求和的立场保持不变——货运聚合的是纸箱体积纸箱是可以堆叠的。冻结、绝不重算原则已成交订单上的货运摘要是冻结的提供器在估价时计算的摘要持久化在所选DeliveryRate#metadatajsonb已有列中购物车与订单面向的读取都来自那里——没有所选运费费率就没有摘要。这与 6.0-duties-and-custom-fees.md 中的关税快照教条一致绝不基于活目录对既有订单重算物流。FreightSummary.from_metadata在重建时只读取当前版本Line声明的属性键保证快照可以比写入它的代码活得更久。五、体积分层与公司门控VolumeRule与CompanyRuleVolumeRule分层即配置class Spree::DeliveryMethodRules::VolumeRule Spree::DeliveryMethodRule preference :minimum_volume, :decimal, default: nil, nullable: true # CBM preference :maximum_volume, :decimal, default: nil, nullable: true def eligible?(package) volume package.freight_summary.total_volume return false if preferred_minimum_volume.present? volume preferred_minimum_volume return false if preferred_maximum_volume.present? volume preferred_maximum_volume true end end实现见 volume_rule.rb与WeightRule/ItemTotalRule并排挂入 Estimator 的过滤器链遵循 6.0-delivery-method-rules.md 的资格判定只挂在 Estimator 过滤链上约束。实际实现比设计稿多了一条 fail-open 语义当摘要不完整目录部分未测量时最小值检查被跳过——未测量的目录只会低估负载低估的体积可以把订单抬进某个档位但绝不能把它排除出档位因低估而未达下限会把该方法从它本来该服务的订单上藏起来上限仍然生效因为在小数字上通过上限只是倾向多提供一个方法。于是批发 tier 表变成纯配置四个运费方式Carton shipping、Pallet、20ft container、40ft container各带一个VolumeRule区间 CompanyRule全部挂在 Freight 费率提供器上。CompanyRule按买家上下文分流company_rule.rb 只读package.owner.b2b?公司解析已经在购物车上company_orders_only: true默认——方法只对 B2B 购物车展示纸箱/整柜档位从零售购物车消失company_orders_only: false——方法只展示给非公司订单用于商家想把包裹式方法从批发买家那里彻底挡开的硬切分完全没有 CompanyRule 的方法对双方都开放。设计评审2026-09-05决定它必须是布尔而不是required | absent字符串规则只有两面字符串会让仪表盘渲染出自由文本框preference 类型自带 UI所以类型必须是诚实的那个。字符串允许的第三态未设置 不受约束没有丢失而是移位到没有该规则这一语义上。按公司细分属于 Enterprise 范畴OSS 只做存在性门控。没有新增 delivery profile 类型同一产品既走包裹寄给消费者、又走纸箱寄给批发买家而一个产品只有一个 profile——所以货运方式就放在常规 shipping profile 上由买家上下文ChannelRule与新的CompanyRule而非产品来门控。六、无价费率与 Freight 费率提供器无价费率是一等公民Estimate增加unpriced布尔属性estimate.rbspree_delivery_rates增加unpriced布尔列默认 falseEstimator 对 unpriced 估价跳过加价markup与税费对 0 取百分比无意义排序改为有价在前、unpriced 在后同级按 costestimator.rb——因为 unpriced 的 cost 是 0纯货运集合里它会自然排在最前Store Admin 的费率序列化器暴露unpriced前台与仪表盘渲染 Quoted after reviewi18n文案而非价格下单时订单总计按零运费计——评审后报价绝不能建模成零元有价费率那样会渲染成免运费是对商家的谎言delivery_rate.rb 中所有金额展示面对 unpriced 费率统一返回 quoted-after-review 文案free?对 unpriced 也恒为 false完成快照获胜费率行本就持久化metadata冻结的摘要免费搭车。DeliveryRateProvider::Freight实现见 freight.rb与Internal并排位于 coreclass Freight Base def self.uses_calculator? false end def self.requires_address? true end def estimate(package) Estimate.new( cost: 0, unpriced: true, name: delivery_method.name, metadata: { freight_summary package.freight_summary.as_json } ) end end即返回唯一一个unpriced: true、cost: 0的 Estimate名字取运费方式名metadata里携带货运摘要——商家将来发给货代的正是这份物流数字。该 provider 不计算任何价格uses_calculator?返回 false管理端隐藏计价表单但需要收货地址requires_address?为 true。2026-09-07 的范围裁定后这个类只保留无价费率这一真正的主题原先寄居其中的定金读取逻辑被移除。七、钱怎么走端到端流程买家在一个 unpriced 运费费率上完成结账。货款已付运费未付——因为还没有人报价商家审阅订单——仪表盘订单页展示 Logistics 卡片shipment type、件数、箱数、CBM、毛重数据读自费率 metadata商家拿到货代报价后通过既有/admin/orders/:id/fees端点把它加为一条Feekind 为freight总计通过既有的 typed-adjustments 路径重算订单欠差值商家通过既有支付面收款Orders::UpdateStatuses把订单滚到已支付。报价后的运费收费是Fee绝不修改运费费率、绝不加临时金额列。这是文档对当前工作的约束之一与关税/杂费方案共用同一 Fee 基座。关于定金与账期的边界再强调一次本方案留下的扩展点是Purchase#amount_due_at_checkout通过Spree::Purchases::AmountDueAtCheckout可替换经由Spree::Dependencies结账要求、两个完成守卫与发货这四处钱到齐了吗的决策点都读它而非total其余仍读total的地方新支付/网关会话的默认金额、capture 循环传的是显式数字而非决策。定金的正式方案尚不存在6.0-payment-method-rules.md 与 6.0-6.1-b2b-payment-terms.md 均已声明向其让位。八、API 接口面Admin APIpackage_typesCRUD既有 product/variant 写入路径上的 carton 属性运费方式规则的 types 发现机制自动收录两条新规则订单序列化器暴露outstanding_balance如未有与部分支付状态。Store APIcart 与 order 序列化器新增freight_summary对象可空——当没有任何行携带 carton 数据时为 null摘要从所选运费费率的 metadata 读取购物车与订单一致因此包裹类运输不携带摘要运费费率序列化器新增unpriced字段。已知的 OpenAPI 缺口freight_summary在 OpenAPI 规范中是非结构化对象$ref化的FreightSummaryschema 未做——规范由 rswag 集成测试生成v3 各序列化器六十余个非结构化属性均如此处理共享 schema 属于文档生成基建改造不在本功能范围。九、迁移路径与升级安全九阶段迁移路径phase 7 已于 2026-09-07 裁撤单位基座——Spree::MeasurementVariant#dimensions_unit的门店回落镜像既有weight_unit回落Schema——spree_package_types表spree_variants上carton_package_type_idcarton_weightcartons_per_palletunits_per_carton已由 6.0-b2b-quantity-rules.md 在 2026-08-31 添加本方案不得重复声明spree_delivery_rates上unpricedPackageType——模型、升级任务spree:upgrade:package_types从门店偏好创建默认行、Stock::Package改从默认行读取随后偏好被直接移除无桥接滚动聚合——FreightSummary、单位感知的Stock::Package#volume规则——VolumeRuleCompanyRule引擎注册、i18n、仪表盘 Conditions 卡片Freight 提供器 无价费率——provider、estimator 处理、序列化器Deposits——amount_due_at_checkout、Carts::Complete支付充分性变更等。2026-09-07 裁撤移至独立的 payment-terms 方案此阶段不再有任何内容随本方案发布Dashboard——package types 设置页、变体 carton 区表单 电子表格 CSV、订单 Logistics 卡片、quoted after review 渲染。注意编辑四个default_package_*偏好的 Settings → Store 卡片在 phase 3 随偏好一起被移除从 phase 3 到 phase 8 之间门店发货箱只能经 API 配置——这是无桥接删除偏好的代价设置页不应滞后太久上线Storefront——购物车/结账页的货运摘要、无价费率文案。除 package-type 回填外无数据迁移carton 字段初始为空从未填写的门店不受任何影响。十、已知缺口与遗留问题评审记录刻意不在本方案修复文档Known Gaps一节值得开发者留意其中三条EasyPost 包裹重量改为从门店weight_unit读取而非unit_system。旧逻辑把公制门店的重量当作克来乘新逻辑读取真正声明重量含义的偏好。这是正确读法且皮重同样换算与之自洽但从未设置weight_unit的unit_system: metric存量门店会从克跳到磅默认值——这是报价的大幅重标定需要发版说明而非代码改动这类门店的变体重量在其他地方本来就按默认单位读取早已混用单位。freight_summary在 OpenAPI 规范中是非结构化对象——见上文 API 节。内容重量仍跨单位求和。Package#weight现在会把包装皮重换算到门店重量单位但Stock::ContentItem#weight仍裸加variant.weight * quantity而Variant#weight_unit逐变体回落——克存的变体与磅存的变体会被不加换算地相加。此问题早于本方案影响WeightRule、重量分配器阈值与 EasyPost 包裹重量Spree::Measurement已就位可以修但改动触及每个重量消费者应单独立项。其他边界约束Constraints on Current Work汇总买家如何付款不是本方案主题运费方式不得长出deposit_percentage货运不得成为支付安排的第二个配置点永远不得直接相乘原始尺寸列算体积——所有 CBM 计算走单位感知 helper不得新增default_package_*门店偏好的读取方——包装尺寸一律经Stock::Package#weight/#dimensions当前与默认PackageType行phase 3 之后读取不得把评审后报价建模成零元有价费率——unpricedflag 才是机制units_per_carton已在spree_variants上数量规则方案所加其 carton 购买单位需要该除数本方案只在其外围补齐链条不得在spree_variants上加 carton 尺寸列——几何在被引用的PackageType上变体只存打包事实下单后的运费收费只能是Fee新变体属性必须进入全部三条写入路径关税方案教训tier/资格逻辑只经规则挂入 Estimator 过滤链。批次号lot numbers同样不属于本方案记录某件商品出自哪个批次/托盘是库存问题在 6.0-inventory-operations.md 中lot 级库存于 2026-08-30 加入该方案。十一、关联方案与延伸阅读本方案依赖并引用了同一 6.0 周期的一批设计文档构成交互引用网络6.0-delivery-profiles.md —— 交付 profile 基础货运方式所在的常规 shipping profile6.0-delivery-method-rules.md —— 规则模式 Estimator-only 资格判定约束WeightRule的裸数字例外记录于此6.0-delivery-rate-provider.md —— Freight provider 实现的 provider 契约6.0-b2b-companies-and-catalogs.md —— 购物车上的公司、#b2b?/resolved_companyCompanyRule 的读取源6.0-duties-and-custom-fees.md —— Fee 基座、快照教条、变体属性三处写入约束6.0-b2b-quantity-rules.md —— 建在同一打包链上的 MOQ / 订购倍数 / 购买单位6.0-service-workflows.md ——Carts::Complete管道6.0-inventory-operations.md —— lot 级库存本方案范围外6.0-shipping-labels-and-deliveries.md —— 货运托运的跟踪信息是履约单上的Spree::Delivery行手写 carrier 粘贴 tracking_urldelivered由员工经mark_delivered确认集装箱不会有 webhook货运单据提单、托盘标牌、装箱单绝不进Spree::ShippingLabel那张表放的是带成本与退款生命周期的承运人面单而是Spree::OrderDocument或 6.1-b2b-order-documents.md 的客户端打印。既有体积链路的实现入口variant.rb#volume、content_item.rb、package.rb门店默认包装偏好的旧位置在 store.rb。测试用例spree/core/spec/models/spree/freight_summary_spec.rb —— FreightSummary 的滚动、合并与快照重建行为spree/core/spec/models/spree/purchase/freight_spec.rb —— 订单侧读取冻结摘要的行为。总结Spree 6.0 的 B2B 批发货运体系用一个清晰的分层回答了批发怎么发PackageType承载共享几何变体承载产品级打包事实FreightSummary把购物车滚成货代看得懂的物流数字并冻结进费率 metadataVolumeRule/CompanyRule让 tier 表和买家分流成为纯配置unpriced费率让结账在无人报价时也能完成Fee 让评审后的真实运费以最小侵入进入订单。整套实现刻意不触碰零售物流、不建模买家付款方式留给 payment-terms 方案并通过单位感知换算 快照不重算 零元费率必标 unpriced三条铁律避免了批发场景下最常见的三类静默错误算错体积、篡改历史订单数字、把待报价渲染成免运费。【免费下载链接】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),仅供参考
返回列表