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

Instance Method Summary collapse

Methods inherited from BaseService

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

Constructor Details

#initialize(options = {}) ⇒ ItemMd5Extractor

Returns a new instance of ItemMd5Extractor.



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

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.



21
22
23
# File 'app/services/shipping/item_md5_extractor.rb', line 21

def ignore_timestamp
  @ignore_timestamp
end

Instance Method Details

#process(item) ⇒ Object



28
29
30
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 28

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

    # Check for existing packing with newer timestamp (skip update if so)
    existing_packing = Packing.find_by(md5: md5_result.md5, service_type:)
    if existing_packing && !ignore_timestamp && existing_packing.created_at && existing_packing.created_at > item.updated_at
      result.message = msg = "Newer Packing data already available. Existing packing #{existing_packing.id}/#{existing_packing.created_at} > item #{item.id}/#{item.updated_at}, skip saving"
      logger.warn msg
      return result
    end

    if existing_packing&.origin.in?(%w[from_delivery from_manual_entry])
      result.message = msg = "Packing #{existing_packing.id} is #{existing_packing.origin}; from_item must not overwrite"
      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
    upsert_result = 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