Class: Packing
- Inherits:
-
ApplicationRecord
- Object
- ActiveRecord::Base
- ApplicationRecord
- Packing
- Includes:
- Models::Auditable
- Defined in:
- app/models/packing.rb
Overview
== Schema Information
Table name: packings
Database name: primary
id :integer not null, primary key
contents :jsonb
description :text
md5 :string(32) not null
origin :integer default("from_delivery"), not null
packdim_contents :jsonb
packdims :decimal(7, 2) is an Array
relevant_md5 :string
service_type :integer default("parcel_service"), not null
created_at :datetime not null
updated_at :datetime not null
delivery_id :integer
item_id :integer
shipment_id :integer
Indexes
idx_service_type (service_type)
index_packings_on_delivery_id (delivery_id)
index_packings_on_item_id (item_id)
index_packings_on_md5_and_service_type (md5,service_type) UNIQUE
index_packings_on_packdim_contents (packdim_contents) USING gin
index_packings_on_shipment_id (shipment_id)
Foreign Keys
fk_rails_... (delivery_id => deliveries.id) ON DELETE => nullify
fk_rails_... (item_id => items.id) ON DELETE => cascade
fk_rails_... (shipment_id => shipments.id) ON DELETE => nullify
Constant Summary collapse
- WEIGHT_AUDIT_MAX_SHORTFALL_FRACTION =
Maximum allowable shortfall vs catalog item weight: only flag (or delete in cleanup)
when recorded weight is more than this fraction below expected — avoids
discarding borderline packings (dunnage, rounding, catalog drift). E.g. 0.20 =>
keep when recorded >= 80% of expected. 0.20- WEIGHT_AUDIT_MIN_RATIO =
Minimum ratio: recorded weight must be at least (1 - WEIGHT_AUDIT_MAX_SHORTFALL_FRACTION)
of the sum of item shipping weights to pass. 1.0 - WEIGHT_AUDIT_MAX_SHORTFALL_FRACTION
- WEIGHT_AUDIT_MIN_EXPECTED_LBS =
Absolute floor (lbs) below which we do not flag under-weight (tiny parts / missing dims).
0.75
Constants included from Models::Auditable
Models::Auditable::ALWAYS_IGNORED
Instance Attribute Summary collapse
- #md5 ⇒ Object readonly
- #origin ⇒ Object readonly
- #service_type ⇒ Object readonly
Belongs to collapse
Methods included from Models::Auditable
Has and belongs to many collapse
Has many collapse
Class Method Summary collapse
- .format_packdim_contents(md5:, contents:, packdims:) ⇒ Object
-
.from_calculator_containers(containers, md5:, relevant_md5:, service_type:) ⇒ void
Persist PackingCalculator containers as a Packing record so future identical item sets are served from history rather than running the calculator again.
- .item_id_hash_to_items(item_id_hash) ⇒ Object
-
.md5_nested_find ⇒ ActiveRecord::Relation<Packing>
A relation of Packings that are md5 nested find.
-
.normalize_packdim_rows(packdims) ⇒ Object
Normalizes packdims to an array of per-container rows [L,W,H,Wt,(ct)].
- .packing_for_item_id(item_id) ⇒ Object
- .ransackable_scopes(_auth_object = nil) ⇒ Object
-
.sum_items_shipping_weight_lbs(item_id_qty_hash) ⇒ Object
Sums expected lbs from an { item_id => qty } hash (exploded kit components).
-
.upsert(attributes, on_duplicate: :update, returning: nil, unique_by: nil, record_timestamps: nil) ⇒ Object
Upsert skips ActiveRecord validations; enforce the same weight audit here.
Instance Method Summary collapse
- #contents_to_items ⇒ Object
- #link_items ⇒ Object
-
#measured_weight? ⇒ Boolean
from_delivery weights are measured at the shipping station — they are the source of truth.
-
#to_packages(md5 = nil) ⇒ Object
Represent this packing as boxes, if you pass an md5, only that package will be used.
-
#weight_audit_failure? ⇒ Boolean
True when recorded weights are implausible vs catalog item weights (used by data cleanup).
- #weight_audit_messages ⇒ Object
Methods included from Models::Auditable
#all_skipped_columns, #audit_reference_data, #should_not_save_version, #stamp_record
Methods inherited from ApplicationRecord
ransackable_associations, ransackable_attributes, ransortable_attributes, #to_relation
Methods included from Models::EventPublishable
Instance Attribute Details
#md5 ⇒ Object (readonly)
55 |
# File 'app/models/packing.rb', line 55 validates :md5, :service_type, :origin, presence: true |
#origin ⇒ Object (readonly)
55 |
# File 'app/models/packing.rb', line 55 validates :md5, :service_type, :origin, presence: true |
#service_type ⇒ Object (readonly)
55 |
# File 'app/models/packing.rb', line 55 validates :md5, :service_type, :origin, presence: true |
Class Method Details
.format_packdim_contents(md5:, contents:, packdims:) ⇒ Object
219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 |
# File 'app/models/packing.rb', line 219 def self.format_packdim_contents(md5:, contents:, packdims:) rows = normalize_packdim_rows(packdims) return [] if rows.empty? if contents.is_a?(Array) pdc = [] rows.each_with_index do |packdim, i| c = contents[i] pdc << { md5: Shipping::Md5HashItem.process(item_id_hash_to_items(c)).md5, p: packdim.flatten.map { |d| d.is_a?(String) ? d : d.to_f }, c: c } end pdc elsif rows.size > 1 # Multi-package delivery with a single combined line-item hash: we cannot map # items to boxes without per-shipment contents. Emit one row per physical # container with correct dims/weight only — do not attach the full item set # to one box (that produced bogus 9×5×5 / 2 lb "history" for an ~19 lb order). rows.map do |packdim| { md5: md5, p: packdim.flatten.map { |d| d.is_a?(String) ? d : d.to_f }, c: {} } end else [{ md5: md5, p: rows.first.flatten.map { |d| d.is_a?(String) ? d : d.to_f }, c: contents }] end end |
.from_calculator_containers(containers, md5:, relevant_md5:, service_type:) ⇒ void
This method returns an undefined value.
Persist PackingCalculator containers as a Packing record so future identical
item sets are served from history rather than running the calculator again.
Only overwrites existing from_calculator rows. All other origins
(from_delivery, from_manual_entry, from_item) are considered more
authoritative and are never overwritten by the calculator.
140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 |
# File 'app/models/packing.rb', line 140 def self.from_calculator_containers(containers, md5:, relevant_md5:, service_type:) return if containers.blank? || md5.blank? service_type_int = service_types[service_type.to_s] return unless service_type_int existing = find_by(md5: md5, service_type: service_type_int) return if existing && !existing.from_calculator? packdims = containers.map do |c| ct_int = Shipment.container_types[c.container_type.to_s] || 0 [c.length.to_f, c.width.to_f, c.height.to_f, c.weight.to_f, ct_int] end raw_contents = containers.map do |c| c.contents.each_with_object({}) { |pc, h| h[pc.item_id] = pc.quantity } end contents = raw_contents.size == 1 ? raw_contents.first : raw_contents pdc = format_packdim_contents(md5: md5, contents: contents, packdims: packdims) attrs = { md5: md5, relevant_md5: relevant_md5, service_type: service_type_int, origin: origins[:from_calculator], packdims: packdims, contents: contents, packdim_contents: pdc.to_json, created_at: Time.current, updated_at: Time.current } upsert(attrs, unique_by: :index_packings_on_md5_and_service_type) rescue StandardError => e Rails.logger.warn "Packing.from_calculator_containers: #{e.}" end |
.item_id_hash_to_items(item_id_hash) ⇒ Object
186 187 188 189 190 |
# File 'app/models/packing.rb', line 186 def self.item_id_hash_to_items(item_id_hash) item_id_hash.each_with_object({}) do |(k, v), h| h[Item.find(k)] = v end end |
.md5_nested_find ⇒ ActiveRecord::Relation<Packing>
A relation of Packings that are md5 nested find. Active Record Scope
75 |
# File 'app/models/packing.rb', line 75 scope :md5_nested_find, ->(md5) { where("packdim_contents @> '[{\"md5\": \"#{md5}\"}]'") } |
.normalize_packdim_rows(packdims) ⇒ Object
Normalizes packdims to an array of per-container rows [L,W,H,Wt,(ct)].
Callers may pass either [[L,W,H,Wt], ...] (delivery) or a single flat row
from Shipment#to_packdims (one box).
195 196 197 198 199 200 201 202 203 |
# File 'app/models/packing.rb', line 195 def self.normalize_packdim_rows(packdims) return [] if packdims.blank? if packdims.first.is_a?(Array) packdims else [packdims] end end |
.packing_for_item_id(item_id) ⇒ Object
81 82 83 |
# File 'app/models/packing.rb', line 81 def self.packing_for_item_id(item_id) Packing.order(id: :desc).find_by(item_id: item_id) end |
.ransackable_scopes(_auth_object = nil) ⇒ Object
77 78 79 |
# File 'app/models/packing.rb', line 77 def self.ransackable_scopes(_auth_object = nil) [:md5_nested_find] end |
.sum_items_shipping_weight_lbs(item_id_qty_hash) ⇒ Object
Sums expected lbs from an { item_id => qty } hash (exploded kit components).
206 207 208 209 210 211 212 213 214 215 216 217 |
# File 'app/models/packing.rb', line 206 def self.sum_items_shipping_weight_lbs(item_id_qty_hash) return 0.0 if item_id_qty_hash.blank? ids = item_id_qty_hash.keys.map(&:to_i) items_by_id = Item.where(id: ids).index_by(&:id) item_id_qty_hash.sum do |item_id, qty| item = items_by_id[item_id.to_i] next 0.0 unless item item.total_shipping_weight.to_f * qty.to_i end end |
.upsert(attributes, on_duplicate: :update, returning: nil, unique_by: nil, record_timestamps: nil) ⇒ Object
Upsert skips ActiveRecord validations; enforce the same weight audit here.
Pass skip_weight_audit: true only for tests or exceptional cases.
87 88 89 90 91 92 93 94 95 96 97 98 99 100 |
# File 'app/models/packing.rb', line 87 def self.upsert(attributes, on_duplicate: :update, returning: nil, unique_by: nil, record_timestamps: nil) attrs = attributes.symbolize_keys skip = attrs.delete(:skip_weight_audit) == true if !skip && weight_audit_violation_for_upsert_attrs?(attrs) Rails.logger.warn( "Packing upsert skipped (weight audit): md5=#{attrs[:md5].inspect} — " \ "#{(attrs).join('; ')}" ) return nil end super(attrs, on_duplicate: on_duplicate, returning: returning, unique_by: unique_by, record_timestamps: ) end |
Instance Method Details
#contents_to_items ⇒ Object
182 183 184 |
# File 'app/models/packing.rb', line 182 def contents_to_items self.class.item_id_hash_to_items((contents.is_a?(Array) ? contents.reduce(&:merge) : contents)) end |
#delivery ⇒ Delivery
46 |
# File 'app/models/packing.rb', line 46 belongs_to :delivery, optional: true |
#items ⇒ ActiveRecord::Relation<Item>
48 |
# File 'app/models/packing.rb', line 48 has_and_belongs_to_many :items, inverse_of: :packings |
#link_items ⇒ Object
178 179 180 |
# File 'app/models/packing.rb', line 178 def link_items self.item_ids = (contents.is_a?(Array) ? contents.map { |c| c.keys }.flatten.uniq : contents.keys.uniq) end |
#measured_weight? ⇒ Boolean
from_delivery weights are measured at the shipping station — they are the
source of truth. Other origins (calculator, item catalog, pre-pack) use
estimates or staff-entered values that may not reflect actual box weight.
267 268 269 |
# File 'app/models/packing.rb', line 267 def measured_weight? from_delivery? end |
#packaging_discrepancy_items ⇒ ActiveRecord::Relation<Item>
49 |
# File 'app/models/packing.rb', line 49 has_many :packaging_discrepancy_items, class_name: 'Item', foreign_key: 'packaging_discrepancy_packing_id', dependent: :nullify |
#shipment ⇒ Shipment
45 |
# File 'app/models/packing.rb', line 45 belongs_to :shipment, optional: true |
#to_packages(md5 = nil) ⇒ Object
Represent this packing as boxes, if you pass an md5, only that package will be used
244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 |
# File 'app/models/packing.rb', line 244 def to_packages(md5 = nil) packages = [] if packdim_contents.present? packdim_contents.each do |pc| # Skip this one if the md5 doesn't match, only in case when we do individual package matching next unless md5.nil? || pc['md5'] == md5 le, wi, he, we, ct = pc['p'] package = Shipping::Container.new(length: le, width: wi, height: he, weight: we, container_type: Shipment.container_types.invert[ct.to_i]) (pc['c'] || {}).each do |item_id, quantity| package.add_content(item_id, quantity) end packages << package end else packages = packdims.map { |(le, wi, he, we, ct)| Shipping::Container.new(length: le, width: wi, height: he, weight: we, container_type: Shipment.container_types.invert[ct.to_i]) } end packages end |
#weight_audit_failure? ⇒ Boolean
True when recorded weights are implausible vs catalog item weights (used by data cleanup).
272 273 274 |
# File 'app/models/packing.rb', line 272 def weight_audit_failure? .any? end |
#weight_audit_messages ⇒ Object
276 277 278 279 280 281 |
# File 'app/models/packing.rb', line 276 def msgs = [] msgs.concat() msgs.concat() msgs end |