Class: Packing

Inherits:
ApplicationRecord show all
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

Belongs to collapse

Methods included from Models::Auditable

#creator, #updater

Has and belongs to many collapse

Has many collapse

Class Method Summary collapse

Instance Method Summary collapse

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

#publish_event

Instance Attribute Details

#md5Object (readonly)



55
# File 'app/models/packing.rb', line 55

validates :md5, :service_type, :origin, presence: true

#originObject (readonly)



55
# File 'app/models/packing.rb', line 55

validates :md5, :service_type, :origin, presence: true

#service_typeObject (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.

Parameters:

  • containers (Array<Shipping::Container>)
  • md5 (String)

    32-char hex

  • relevant_md5 (String)

    32-char hex

  • service_type (Symbol)

    :parcel_service or :freight_service



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.message}"
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_findActiveRecord::Relation<Packing>

A relation of Packings that are md5 nested find. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Packing>)

See Also:



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}" \
      "#{weight_audit_messages_for_upsert_attrs(attrs).join('; ')}"
    )
    return nil
  end

  super(attrs, on_duplicate: on_duplicate, returning: returning, unique_by: unique_by, record_timestamps: record_timestamps)
end

Instance Method Details

#contents_to_itemsObject



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

#deliveryDelivery

Returns:

See Also:



46
# File 'app/models/packing.rb', line 46

belongs_to :delivery, optional: true

#itemItem

Returns:

See Also:



47
# File 'app/models/packing.rb', line 47

belongs_to :item, optional: true

#itemsActiveRecord::Relation<Item>

Returns:

  • (ActiveRecord::Relation<Item>)

See Also:



48
# File 'app/models/packing.rb', line 48

has_and_belongs_to_many :items, inverse_of: :packings


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.

Returns:

  • (Boolean)


267
268
269
# File 'app/models/packing.rb', line 267

def measured_weight?
  from_delivery?
end

#packaging_discrepancy_itemsActiveRecord::Relation<Item>

Returns:

  • (ActiveRecord::Relation<Item>)

See Also:



49
# File 'app/models/packing.rb', line 49

has_many :packaging_discrepancy_items, class_name: 'Item', foreign_key: 'packaging_discrepancy_packing_id', dependent: :nullify

#shipmentShipment

Returns:

See Also:



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).

Returns:

  • (Boolean)


272
273
274
# File 'app/models/packing.rb', line 272

def weight_audit_failure?
  weight_audit_messages.any?
end

#weight_audit_messagesObject



276
277
278
279
280
281
# File 'app/models/packing.rb', line 276

def weight_audit_messages
  msgs = []
  msgs.concat(weight_audit_messages_for_packdims)
  msgs.concat(weight_audit_messages_for_packdim_contents)
  msgs
end