Class: Shipping::ItemMd5Extractor

Inherits:
BaseService show all
Defined in:
app/services/shipping/item_md5_extractor.rb

Overview

Takes an item, looks at shipping dimensions, create a packing entry

Race Condition Note:
This service can be triggered by multiple sources simultaneously:

  1. Item.after_save callback when box dimensions change
  2. Delivery state transitions (picking -> pending_ship_labels, any -> shipped)
  3. Pre-pack workflow calling set_packaged_items_md5_hash

To prevent ActiveRecord::RecordNotUnique errors when two processes try to
create the same Packing (same md5 + service_type), we use upsert which is
atomic at the database level.

Defined Under Namespace

Classes: Result

Instance Attribute Summary collapse

Attributes inherited from BaseService

#options

Instance Method Summary collapse

Methods inherited from BaseService

#log_debug, #log_error, #log_info, #log_warning, #logger, #tagged_logger

Constructor Details

#initialize(options = {}) ⇒ ItemMd5Extractor

Returns a new instance of ItemMd5Extractor.



26
27
28
29
# File 'app/services/shipping/item_md5_extractor.rb', line 26

def initialize(options = {})
  @ignore_timestamp = options.delete(:ignore_timestamp).to_b
  super
end

Instance Attribute Details

#ignore_timestampObject (readonly)

Returns the value of attribute ignore_timestamp.



24
25
26
# File 'app/services/shipping/item_md5_extractor.rb', line 24

def ignore_timestamp
  @ignore_timestamp
end

Instance Method Details

#process(item) ⇒ Object



31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
# File 'app/services/shipping/item_md5_extractor.rb', line 31

def process(item)
  result = Result.new

  logger.tagged("Item:#{item.id}") do
    # We only create packaging for goods
    # We only create packaging if shipping dimensions are present? (just in case validation didn't catch it)
    unless item.is_goods? && item.box1_defined?
      result.message = msg = "Item #{item.id} is not a good or has no shipping dimensions"
      logger.warn msg
      return result
    end

    md5_result = Shipping::Md5HashItem.process(item)
    logger.info "item md5: #{md5_result.md5}, item string: #{md5_result.items_string}"
    result.item_md5 = md5_result.md5

    # Create a packing for this single item
    service_type = Packing.service_types[item.shipping_class]
    raise "Unable to match item shipping class #{item.shipping_class}" unless service_type

    # Skip when a higher-authority fresh row already exists (Packing#authoritative_over?).
    # Consolidates the previous two ad-hoc checks (recency vs `item.updated_at` and
    # the hard-coded from_delivery/from_manual_entry block list) into the same
    # origin-rank + freshness gate that DeliveryMd5Extractor uses.
    existing_packing = Packing.find_by(md5: md5_result.md5, service_type:)
    if existing_packing && !ignore_timestamp && existing_packing.authoritative_over?(:from_item)
      result.message = msg = "Existing #{existing_packing.origin} packing #{existing_packing.id} outranks incoming from_item (last updated #{existing_packing.updated_at}); skip saving"
      logger.info msg
      return result
    end

    # Build packing attributes
    contents = if item.ships_in_single_box?
                 md5_result.full_item_id_hash
               else
                 item.get_multi_box_item_contents_item_id_hash_array
               end

    packdims = item.to_packdims
    packdim_contents = Packing.format_packdim_contents(md5: md5_result.md5, contents: contents, packdims: packdims)

    packing_timestamp = item.updated_at || item.created_at

    # Use upsert to atomically insert or update - prevents race condition when
    # multiple workers try to create the same packing simultaneously
    Packing.upsert(
      {
        md5: md5_result.md5,
        service_type:,
        origin: Packing.origins[:from_item],
        packdims: packdims,
        contents:,
        packdim_contents: packdim_contents.to_json,
        item_id: item.id,
        delivery_id: nil,
        shipment_id: nil,
        created_at: packing_timestamp,
        updated_at: Time.current
      },
      unique_by: :index_packings_on_md5_and_service_type
    )

    # Reload the packing to get the record (upsert doesn't return the full object)
    packing = Packing.find_by(md5: md5_result.md5, service_type:)
    unless packing
      msg = "Packing upsert did not persist (weight audit or DB); item #{item.id} md5=#{md5_result.md5}"
      result.message = msg
      logger.warn msg
      return result
    end

    # Link kit item IDs for searchability (use union to avoid duplicates)
    packing.item_ids |= md5_result.kit_item_ids

    result.packings << packing
    logger.info "Packing #{packing.id} upserted successfully for item #{item.id}"
  end

  result
end