spree_cm_commissioner 2.8.6 → 2.8.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/.env.example +8 -0
  3. data/Gemfile.lock +1 -1
  4. data/app/controllers/spree/admin/import_new_orders_controller.rb +25 -0
  5. data/app/controllers/spree/api/v2/storefront/account/orders_controller_decorator.rb +5 -0
  6. data/app/controllers/spree/api/v2/storefront/order_histories_controller.rb +2 -2
  7. data/app/controllers/spree/api/v2/storefront/ticket_transfers_controller.rb +4 -3
  8. data/app/controllers/spree/api/v2/tenant/order_histories_controller.rb +2 -2
  9. data/app/finders/spree_cm_commissioner/orders/find.rb +33 -3
  10. data/app/finders/spree_cm_commissioner/orders/find_by_all_state.rb +11 -0
  11. data/app/interactors/spree_cm_commissioner/waiting_guests_caller.rb +16 -4
  12. data/app/jobs/concerns/spree_cm_commissioner/idempotent_job.rb +12 -0
  13. data/app/jobs/spree_cm_commissioner/cancel_import_order_job.rb +11 -0
  14. data/app/jobs/spree_cm_commissioner/idempotency_keys/prune_job.rb +20 -0
  15. data/app/jobs/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_job.rb +21 -3
  16. data/app/jobs/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_on_hold_job.rb +34 -3
  17. data/app/jobs/spree_cm_commissioner/telegram_alerts/order_integrity_check_job.rb +17 -0
  18. data/app/jobs/spree_cm_commissioner/waiting_guests_caller_job.rb +5 -0
  19. data/app/models/concerns/spree_cm_commissioner/order_state_machine.rb +10 -0
  20. data/app/models/concerns/spree_cm_commissioner/store_preference.rb +1 -0
  21. data/app/models/spree_cm_commissioner/idempotency_key.rb +21 -0
  22. data/app/models/spree_cm_commissioner/import.rb +2 -1
  23. data/app/models/spree_cm_commissioner/imported_order.rb +6 -0
  24. data/app/models/spree_cm_commissioner/imports/import_order.rb +21 -0
  25. data/app/models/spree_cm_commissioner/order_decorator.rb +14 -0
  26. data/app/models/spree_cm_commissioner/payment_decorator.rb +1 -0
  27. data/app/overrides/spree/admin/products/_form/allow_gift_transfer.html.erb.deface +1 -1
  28. data/app/overrides/spree/admin/products/_form/allow_transfer.html.erb.deface +1 -1
  29. data/app/overrides/spree/admin/stores/_form/store_preferences.html.erb.deface +9 -0
  30. data/app/services/spree_cm_commissioner/imports/orders/cancel.rb +63 -0
  31. data/app/services/spree_cm_commissioner/imports/orders/create.rb +1 -0
  32. data/app/services/spree_cm_commissioner/telegram_alerts/checks/order_complete_payment_not_paid.rb +21 -0
  33. data/app/services/spree_cm_commissioner/telegram_alerts/checks/payment_paid_order_not_complete.rb +21 -0
  34. data/app/services/spree_cm_commissioner/telegram_alerts/order_integrity_alert.rb +112 -0
  35. data/app/services/spree_cm_commissioner/telegram_alerts/order_integrity_checks_runner.rb +32 -0
  36. data/app/services/spree_cm_commissioner/waiting_room/stamp_queue_positions.rb +66 -24
  37. data/app/views/spree/admin/import_new_orders/_cancel_modal.html.erb +35 -0
  38. data/app/views/spree/admin/import_new_orders/index.html.erb +4 -0
  39. data/app/views/spree/admin/import_new_orders/show.html.erb +30 -0
  40. data/app/views/spree/admin/imports/index.html.erb +2 -0
  41. data/app/views/spree/admin/inventory_holds/index.html.erb +8 -3
  42. data/config/initializers/spree_permitted_attributes.rb +1 -0
  43. data/config/locales/en.yml +4 -4
  44. data/config/locales/km.yml +4 -4
  45. data/config/routes.rb +1 -0
  46. data/db/migrate/20260616000001_add_canceled_by_id_to_cm_imports.rb +5 -0
  47. data/db/migrate/20260617000002_create_cm_imported_orders.rb +12 -0
  48. data/db/migrate/20260619000001_create_cm_idempotency_keys.rb +11 -0
  49. data/lib/spree_cm_commissioner/version.rb +1 -1
  50. metadata +18 -2
@@ -99,6 +99,7 @@ module SpreeCmCommissioner
99
99
  return nil
100
100
  end
101
101
 
102
+ SpreeCmCommissioner::ImportedOrder.create!(order: order, import_order: import_order)
102
103
  order
103
104
  end
104
105
 
@@ -0,0 +1,21 @@
1
+ module SpreeCmCommissioner
2
+ module TelegramAlerts
3
+ module Checks
4
+ class OrderCompletePaymentNotPaid < OrderIntegrityAlert
5
+ private
6
+
7
+ def anomaly?(order)
8
+ order.complete? && order.payment_state != 'paid'
9
+ end
10
+
11
+ def emoji
12
+ '🚨'
13
+ end
14
+
15
+ def title
16
+ 'Order Complete With Unresolved Payment State'
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,21 @@
1
+ module SpreeCmCommissioner
2
+ module TelegramAlerts
3
+ module Checks
4
+ class PaymentPaidOrderNotComplete < OrderIntegrityAlert
5
+ private
6
+
7
+ def anomaly?(order)
8
+ order.payments.completed.exists? && !order.complete?
9
+ end
10
+
11
+ def emoji
12
+ '⚠️'
13
+ end
14
+
15
+ def title
16
+ 'Payment Paid But Order Not Complete'
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,112 @@
1
+ module SpreeCmCommissioner
2
+ module TelegramAlerts
3
+ # Abstract base class for order anomaly checks. Subclasses implement
4
+ # `anomaly?`, `emoji` and `title` to define what they detect and how
5
+ # the alert is presented; this class handles chat resolution, message
6
+ # building and delivery.
7
+ class OrderIntegrityAlert
8
+ prepend ::Spree::ServiceModule::Base
9
+
10
+ def call(order:)
11
+ return success(message: 'No anomaly detected') unless anomaly?(order)
12
+
13
+ chat_id = alert_chat_id
14
+ return failure(nil, 'Order anomaly alert chat ID not configured') if chat_id.blank?
15
+
16
+ TelegramNotificationSenderJob.perform_later(
17
+ chat_id: chat_id,
18
+ message: build_message(order),
19
+ parse_mode: 'HTML'
20
+ )
21
+
22
+ success(message: 'Order alert sent successfully')
23
+ rescue StandardError => e
24
+ failure(nil, "Failed to send order alert: #{e.message}")
25
+ end
26
+
27
+ private
28
+
29
+ def anomaly?(_order)
30
+ raise NotImplementedError, "#{self.class} must implement #anomaly?"
31
+ end
32
+
33
+ def emoji
34
+ raise NotImplementedError, "#{self.class} must implement #emoji"
35
+ end
36
+
37
+ def title
38
+ raise NotImplementedError, "#{self.class} must implement #title"
39
+ end
40
+
41
+ def alert_chat_id
42
+ Spree::Store.default&.preferred_telegram_order_anomaly_alert_chat_id.presence ||
43
+ ENV.fetch('EXCEPTION_NOTIFIER_TELEGRAM_CHANNEL_ID', nil)
44
+ end
45
+
46
+ def build_message(order)
47
+ line_items = order.line_items.includes(:product, :vendor)
48
+ has_payment = order.payments.any?
49
+
50
+ lines = header_lines +
51
+ order_lines(order, has_payment) +
52
+ [''] +
53
+ product_lines(line_items) +
54
+ [''] +
55
+ customer_lines(order) +
56
+ footer_lines(order)
57
+
58
+ lines.compact.join("\n")
59
+ end
60
+
61
+ def header_lines
62
+ [
63
+ "#{emoji} <b>ATTENTION — #{title}</b>",
64
+ "🕐 <code>#{Time.current.strftime('%Y-%m-%d %H:%M')}</code>",
65
+ ''
66
+ ]
67
+ end
68
+
69
+ def order_lines(order, has_payment)
70
+ lines = [
71
+ '📋 <b>Order</b>',
72
+ "<b>Number:</b> <code>#{order.number}</code>",
73
+ "<b>State:</b> #{order.state}"
74
+ ]
75
+ lines << "<b>Payment State:</b> #{order.payment_state}" if has_payment
76
+ lines
77
+ end
78
+
79
+ def product_lines(line_items)
80
+ items = line_items.map do |li|
81
+ vendor_name = li.vendor&.name
82
+ entry = "· #{ERB::Util.html_escape(li.product.name)} (x#{li.quantity})"
83
+ entry += " — #{ERB::Util.html_escape(vendor_name)}" if vendor_name.present?
84
+ entry
85
+ end
86
+
87
+ ['🎫 <b>Products</b>', *items]
88
+ end
89
+
90
+ def customer_lines(order)
91
+ [
92
+ '👤 <b>Customer</b>',
93
+ "<b>Name:</b> #{ERB::Util.html_escape(order.name.to_s)}",
94
+ ("<b>Email:</b> #{order.email}" if order.email.present?),
95
+ ("<b>Phone:</b> #{order.phone_number}" if order.respond_to?(:phone_number) && order.phone_number.present?)
96
+ ]
97
+ end
98
+
99
+ def footer_lines(order)
100
+ ['', "🔗 #{order_url(order)}"]
101
+ end
102
+
103
+ def order_url(order)
104
+ Spree::Core::Engine.routes.url_helpers.edit_admin_order_url(
105
+ order,
106
+ host: Rails.application.routes.default_url_options[:host],
107
+ port: Rails.application.routes.default_url_options[:port]
108
+ )
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,32 @@
1
+ module SpreeCmCommissioner
2
+ module TelegramAlerts
3
+ class OrderIntegrityChecksRunner
4
+ prepend ::Spree::ServiceModule::Base
5
+
6
+ CHECKS = [
7
+ 'SpreeCmCommissioner::TelegramAlerts::Checks::OrderCompletePaymentNotPaid',
8
+ 'SpreeCmCommissioner::TelegramAlerts::Checks::PaymentPaidOrderNotComplete'
9
+ ].freeze
10
+
11
+ def call(order:)
12
+ CHECKS.each do |check_class|
13
+ result = check_class.constantize.call(order: order)
14
+ log_failure(check_class, order, result.error.to_s) if result.failure?
15
+ rescue StandardError => e
16
+ log_failure(check_class, order, e.message)
17
+ end
18
+
19
+ success(message: 'Order integrity checks completed')
20
+ end
21
+
22
+ private
23
+
24
+ def log_failure(check_class, order, error)
25
+ CmAppLogger.error(
26
+ label: "#{check_class} failed",
27
+ data: { order_id: order.id, error: error }
28
+ )
29
+ end
30
+ end
31
+ end
32
+ end
@@ -15,6 +15,20 @@ module SpreeCmCommissioner
15
15
  # Firestore bounds a batch update by payload size (10 MiB); this batch size leaves 500/commit far under that.
16
16
  FIRESTORE_BATCH_SIZE = (ENV['WAITING_ROOM_FIRESTORE_BATCH_SIZE'] || 500).to_i
17
17
 
18
+ # Above this we leave estimated_wait_seconds nil so the app hides the ETA rather than
19
+ # showing a discouraging "~2 hours". Deep guests just see the coarse "lots ahead" state.
20
+ MAX_WAIT_TO_DISPLAY_SECONDS = (ENV['WAITING_ROOM_MAX_WAIT_TO_DISPLAY_SECONDS'] || 3600).to_i
21
+
22
+ # How often the caller runs and releases one batch. See schedule.yml for WaitingGuestsCallerJob.
23
+ CALLER_INTERVAL_SECONDS = (ENV['WAITING_ROOM_CALLER_INTERVAL_SECONDS'] || 60).to_i
24
+
25
+ # Caps the batch size assumed when estimating the ETA (not a release limit). The lobby's
26
+ # slots_per_call is a single run's available_slots, which spikes on cold starts / lulls
27
+ # (active_sessions low → available_slots ≈ max_sessions). An uncapped spike collapses hundreds
28
+ # of positions into batch 1 and under-promises the wait, so we bound it to keep ETAs
29
+ # conservative. Tune to roughly the real per-minute release capacity.
30
+ MAX_ETA_BATCH_SIZE = (ENV['WAITING_ROOM_MAX_ETA_BATCH_SIZE'] || 50).to_i
31
+
18
32
  def call
19
33
  stamp_positions
20
34
  success(stamped: @stamped.to_i)
@@ -26,11 +40,12 @@ module SpreeCmCommissioner
26
40
 
27
41
  def stamp_positions
28
42
  paths = stamp_paths
29
- avg_queue_to_enter = lobby_data[:avg_queue_to_enter_seconds].to_i
30
- total = paths.sum { |p| waiting_count(p) }
43
+ total = paths.sum { |path| waiting_count(path) }
31
44
  return if total.zero?
32
45
 
33
- stamped_at = Time.zone.now
46
+ @slots_per_call = lobby_data[:slots_per_call].to_i
47
+ @flat_wait = lobby_data[:avg_queue_to_enter_seconds].to_i
48
+ @stamped_at = Time.zone.now
34
49
  @stamped = 0
35
50
 
36
51
  paths.each do |path|
@@ -39,41 +54,68 @@ module SpreeCmCommissioner
39
54
 
40
55
  docs = waiting_query(path).order('queued_at').limit(remaining).get.to_a
41
56
 
42
- # Stamp the docs in chunks: each_slice splits them into groups of at most
43
- # FIRESTORE_BATCH_SIZE, and each group is written in one firestore.batch commit
44
- # (so e.g. 1000 docs = 2 commits, not 1000 round-trips).
57
+ # Commit in FIRESTORE_BATCH_SIZE chunks (one firestore.batch per slice), so e.g.
58
+ # 1000 docs = 2 commits, not 1000 round-trips.
45
59
  docs.each_slice(FIRESTORE_BATCH_SIZE) do |slice|
46
- firestore.batch do |b|
47
- slice.each { |doc| stamp_doc(b, doc, avg_queue_to_enter, stamped_at) }
48
- end
60
+ firestore.batch { |batch| slice.each { |doc| stamp_doc(batch, doc) } }
49
61
  end
50
62
  end
51
63
  end
52
64
 
53
- def stamp_doc(batch, doc, avg_queue_to_enter, stamped_at)
65
+ def stamp_doc(batch, doc)
54
66
  @stamped += 1
55
67
 
56
- # The app shows a progress bar via (queue_total - position + 1) / queue_total.
57
- # `position` only ever counts down toward 1, so we keep `queue_total` from ever
58
- # going down either (max of its old value and the current position) — it becomes
59
- # the guest's starting depth. That keeps the bar moving only forward, reaching
60
- # 100% at position 1, instead of jumping as the live waiting count shrinks.
61
- #
62
- # Example — a guest who started 8th, with the line draining from 8 to 4 people:
63
- # live total: position 6 / total 8 = 38%, then position 4 / total 4 = 25% (drops!) ❌
64
- # this fix: position 6 / total 8 = 38%, then position 4 / total 8 = 63% (climbs) ✅
65
- # ...continuing to position 1 / total 8 = 100% at the front.
66
68
  data = {
67
69
  position: @stamped,
68
- queue_total: [doc.data[:queue_total].to_i, @stamped].max,
69
- estimated_wait_seconds: avg_queue_to_enter,
70
- status_updated_at: stamped_at
70
+ queue_total: queue_total_for(doc),
71
+ estimated_wait_seconds: estimated_wait_for(@stamped),
72
+ status_updated_at: @stamped_at
71
73
  }
74
+ # Set on the first stamp only — records when the guest first reached the front N.
75
+ data[:position_set_at] = @stamped_at unless doc.data[:position_set_at]
72
76
 
73
- data[:position_set_at] = stamped_at unless doc.data[:position_set_at]
74
77
  batch.update(doc.ref, data)
75
78
  end
76
79
 
80
+ # The app's progress bar is (queue_total - position + 1) / queue_total. `position` only counts
81
+ # down toward 1, so we never let `queue_total` drop either — it freezes at the guest's starting
82
+ # depth (their deepest position). That keeps the bar moving forward to 100% at position 1
83
+ # instead of jumping as the live line shrinks. e.g. starting 8th, line draining 8 → 4:
84
+ # live total: 6/8 = 38%, then 4/4 = 25% (drops!) ❌
85
+ # frozen total: 6/8 = 38%, then 4/8 = 63% (climbs) ✅
86
+ def queue_total_for(doc)
87
+ [doc.data[:queue_total].to_i, @stamped].max
88
+ end
89
+
90
+ # ETA written every run (including nil, so a guest whose wait crosses the ceiling has any
91
+ # stale value cleared and the app stops rendering it). nil means "hide the ETA" — over 1 h
92
+ # we show the coarse "lots ahead" state instead of a discouraging number.
93
+ def estimated_wait_for(position)
94
+ seconds = batch_wait(position) || @flat_wait
95
+ return nil unless seconds.positive? && seconds <= MAX_WAIT_TO_DISPLAY_SECONDS
96
+
97
+ seconds
98
+ end
99
+
100
+ # Groups positions into batches that enter together, so everyone in a batch gets the same ETA.
101
+ # The batch size is slots_per_call, capped by MAX_ETA_BATCH_SIZE to keep a spiking release rate
102
+ # from under-promising. nil when slots_per_call is unknown (cold start) → falls back to the
103
+ # flat lobby average. e.g. batch size 50, 1 call/min:
104
+ # flat estimate: position 1 and position 50 both see ~20 min ❌
105
+ # batch estimate: positions 1–50 → batch 1 → ~10 min
106
+ # positions 51–100 → batch 2 → ~20 min ✅
107
+ def batch_wait(position)
108
+ return nil unless @slots_per_call.positive?
109
+
110
+ batch_size = [@slots_per_call, MAX_ETA_BATCH_SIZE].min
111
+ batch_number = ((position - 1) / batch_size) + 1
112
+ [batch_number * CALLER_INTERVAL_SECONDS, min_queue_floor].max
113
+ end
114
+
115
+ def min_queue_floor
116
+ SpreeCmCommissioner::WaitingGuestsCaller::MIN_QUEUE_TO_ENTER_SECONDS
117
+ end
118
+
77
119
  def stamp_paths
78
120
  [previous_records_path, records_path]
79
121
  end
@@ -0,0 +1,35 @@
1
+ <% modal_id = "cancelImportModal#{import.id}" %>
2
+
3
+ <div class="modal fade" id="<%= modal_id %>" tabindex="-1" role="dialog" aria-labelledby="<%= modal_id %>Label" aria-hidden="true">
4
+ <div class="modal-dialog" role="document">
5
+ <div class="modal-content">
6
+ <div class="modal-header">
7
+ <h5 class="modal-title" id="<%= modal_id %>Label">Cancel orders from "<%= import.name %>"?</h5>
8
+ <button type="button" class="close" data-dismiss="modal" aria-label="Close">
9
+ <span aria-hidden="true">&times;</span>
10
+ </button>
11
+ </div>
12
+ <%= form_with url: spree.cancel_admin_import_new_order_path(import.id), method: :post, local: true do %>
13
+ <div class="modal-body">
14
+ <div class="alert alert-warning mb-3">
15
+ This will cancel <strong>all orders</strong> created by this import, restock inventory, and release seat holds. <strong>This cannot be undone.</strong>
16
+ </div>
17
+
18
+ <div class="form-group">
19
+ <label>Type the import name <code class="text-danger"><%= import.name %></code> to confirm</label>
20
+ <%= text_field_tag :confirm_name, nil, class: 'form-control', autocomplete: 'off', required: true %>
21
+ </div>
22
+
23
+ <div class="form-group mb-0">
24
+ <label>Reason</label>
25
+ <%= text_area_tag :cancellation_reason, nil, class: 'form-control', rows: 4 %>
26
+ </div>
27
+ </div>
28
+ <div class="modal-footer">
29
+ <button type="button" class="btn btn-secondary" data-dismiss="modal">No, keep them</button>
30
+ <%= button_tag 'Yes, cancel orders', type: 'submit', class: 'btn btn-danger' %>
31
+ </div>
32
+ <% end %>
33
+ </div>
34
+ </div>
35
+ </div>
@@ -51,6 +51,10 @@
51
51
  <span class="badge badge-success"><%= import.status %></span>
52
52
  <% when 'failed' %>
53
53
  <span class="badge badge-danger"><%= import.status %></span>
54
+ <% when 'canceling' %>
55
+ <span class="badge badge-warning"><%= import.status %></span>
56
+ <% when 'canceled' %>
57
+ <span class="badge badge-dark"><%= import.status %></span>
54
58
  <% else %>
55
59
  <span class="badge badge-secondary"><%= import.status %></span>
56
60
  <% end %>
@@ -2,6 +2,16 @@
2
2
  <%= page_header_back_button spree.admin_import_new_orders_path %>
3
3
  <%= @import.name %>
4
4
  <% end %>
5
+
6
+ <% if @import.cancellable? %>
7
+ <% content_for :page_actions do %>
8
+ <button type="button" class="btn btn-danger" data-toggle="modal" data-target="#cancelImportModal<%= @import.id %>">
9
+ <%= svg_icon name: "cancel.svg", width: '14', height: '14' %>
10
+ Cancel orders
11
+ </button>
12
+ <% end %>
13
+ <% end %>
14
+
5
15
  <%= render partial: 'spree/admin/shared/import_order_tabs', locals: { current: :import_new_order } %>
6
16
 
7
17
  <div class="row w-75">
@@ -33,6 +43,10 @@
33
43
  <span class="badge badge-success"><%= @import.status %></span>
34
44
  <% when 'failed' %>
35
45
  <span class="badge badge-danger"><%= @import.status %></span>
46
+ <% when 'canceling' %>
47
+ <span class="badge badge-warning"><%= @import.status %></span>
48
+ <% when 'canceled' %>
49
+ <span class="badge badge-dark"><%= @import.status %></span>
36
50
  <% else %>
37
51
  <span class="badge badge-secondary"><%= @import.status %></span>
38
52
  <% end %>
@@ -48,8 +62,24 @@
48
62
  </div>
49
63
  <% end %>
50
64
  </dd>
65
+ <% if @import.canceled_at.present? %>
66
+ <dt class="col-sm-3">Canceled At:</dt>
67
+ <dd class="col-sm-9"><%= @import.canceled_at.strftime('%A, %d %B %Y %H:%M:%S') %></dd>
68
+ <dt class="col-sm-3">Canceled By:</dt>
69
+ <dd class="col-sm-9">
70
+ <%= [@import.canceled_by&.first_name, @import.canceled_by&.last_name].compact.join(' ').presence || @import.canceled_by&.email %>
71
+ </dd>
72
+ <% if @import.preferred_cancellation_reason.present? %>
73
+ <dt class="col-sm-3">Cancellation Reason:</dt>
74
+ <dd class="col-sm-9"><%= @import.preferred_cancellation_reason %></dd>
75
+ <% end %>
76
+ <% end %>
51
77
  </dl>
52
78
  </div>
53
79
  </div>
54
80
  </div>
55
81
  </div>
82
+
83
+ <% if @import.cancellable? %>
84
+ <%= render partial: 'cancel_modal', locals: { import: @import } %>
85
+ <% end %>
@@ -30,6 +30,8 @@
30
30
  when 'progress' then 'badge badge-warning'
31
31
  when 'done' then 'badge badge-success'
32
32
  when 'failed' then 'badge badge-danger'
33
+ when 'canceling' then 'badge badge-warning'
34
+ when 'canceled' then 'badge badge-dark'
33
35
  else 'badge badge-secondary'
34
36
  end %>
35
37
  <span class="<%= badge_class %>"><%= import.status %></span>
@@ -14,12 +14,17 @@
14
14
 
15
15
  <%= render 'search' if @holds.any? || params[:q].present? %>
16
16
 
17
+ <% current_status = params.dig(:q, :status_eq).presence %>
17
18
  <div class="flex-wrap mb-3 alert alert-info d-flex">
19
+ <%= link_to admin_inventory_holds_path(q: params.fetch(:q, {}).to_unsafe_h.except('status_eq')), class: 'mr-4' do %>
20
+ <span class="<%= 'font-weight-bold' if current_status.nil? %>">All:</span>
21
+ <span class="badge badge-dark"><%= @status_counts.values.sum %></span>
22
+ <% end %>
18
23
  <% SpreeCmCommissioner::InventoryHold.statuses.keys.each do |status| %>
19
- <span class="mr-4">
20
- <strong><%= status.humanize %>:</strong>
24
+ <%= link_to admin_inventory_holds_path(q: params.fetch(:q, {}).to_unsafe_h.merge('status_eq' => status)), class: 'mr-4' do %>
25
+ <span class="<%= 'font-weight-bold' if current_status == status %>"><%= status.humanize %>:</span>
21
26
  <span class="badge <%= status_badge_classes[status] || 'badge-light' %>"><%= @status_counts[status] || 0 %></span>
22
- </span>
27
+ <% end %>
23
28
  <% end %>
24
29
  </div>
25
30
 
@@ -55,6 +55,7 @@ module Spree
55
55
  :preferred_telegram_order_request_alert_chat_id,
56
56
  :preferred_telegram_new_vendor_alert_chat_id,
57
57
  :preferred_telegram_new_event_alert_chat_id,
58
+ :preferred_telegram_order_anomaly_alert_chat_id,
58
59
  :preferred_assetlinks,
59
60
  :preferred_apple_app_site_association,
60
61
  { default_notification_image_attributes: {} },
@@ -649,11 +649,11 @@ en:
649
649
  title: "💸 Order Complete!"
650
650
  message: "Your Order numer is %{order_number}"
651
651
  payment_incomplete_notification:
652
- title: "Incomplete Payment"
653
- message: "Unable to process payment %{order_number}"
652
+ title: "Complete Your Payment"
653
+ message: "We couldn't process your payment for order %{order_number}."
654
654
  release_inventory_item_notification:
655
- title: "Order released"
656
- message: "Order %{order_number} has been released."
655
+ title: "Ticket Reservation Expired"
656
+ message: "Your ticket reservation for order %{order_number} has expired and the reserved tickets have been released."
657
657
  order_accepted_notification:
658
658
  title: "Confirm!"
659
659
  message: "Your request has been confirmed; please check your upcoming trip."
@@ -474,11 +474,11 @@ km:
474
474
  title: "💸 ការបញ្ជាទិញទទួលបានជោគជ័យ!"
475
475
  message: "លេខបញ្ជាទិញរបស់អ្នកគឺ ​%{order_number}"
476
476
  payment_incomplete_notification:
477
- title: "ការទូទាត់មិនជោគជ័យ"
478
- message: "មិនអាចដំណើរការការទូទាត់បាន %{order_number}"
477
+ title: "សូមបញ្ចប់ការទូទាត់របស់អ្នក"
478
+ message: "យើងមិនអាចដំណើរការការទូទាត់សម្រាប់ការបញ្ជាទិញ %{order_number} របស់អ្នកបានទេ។"
479
479
  release_inventory_item_notification:
480
- title: "ការកក់ត្រូវបានដោះលែង"
481
- message: "ការបញ្ជាទិញ %{order_number} ត្រូវបានដោះលែងហើយ។"
480
+ title: "ការកក់សំបុត្រត្រូវបានផុតកំណត់"
481
+ message: "ការកក់សំបុត្រសម្រាប់ការបញ្ជាទិញ %{order_number} របស់អ្នកត្រូវបានផុតកំណត់ ហើយសំបុត្រដែលបានកក់ត្រូវបានដោះលែងវិញ។"
482
482
  order_accepted_notification:
483
483
  title: "បានយល់ព្រម!"
484
484
  message: "សំណើរបស់អ្នកត្រូវបានបញ្ជាក់; សូមពិនិត្យមើលការធ្វើដំណើរនាពេលខាងមុខរបស់អ្នក"
data/config/routes.rb CHANGED
@@ -285,6 +285,7 @@ Spree::Core::Engine.add_routes do
285
285
  resources :import_new_orders do
286
286
  member do
287
287
  get :download
288
+ post :cancel
288
289
  end
289
290
  end
290
291
 
@@ -0,0 +1,5 @@
1
+ class AddCanceledByIdToCmImports < ActiveRecord::Migration[7.0]
2
+ def change
3
+ add_column :cm_imports, :canceled_by_id, :bigint, if_not_exists: true
4
+ end
5
+ end
@@ -0,0 +1,12 @@
1
+ class CreateCmImportedOrders < ActiveRecord::Migration[7.0]
2
+ def change
3
+ create_table :cm_imported_orders, if_not_exists: true do |t|
4
+ t.bigint :order_id, null: false
5
+ t.bigint :import_order_id, null: false
6
+ t.timestamps
7
+ end
8
+
9
+ add_index :cm_imported_orders, :order_id, unique: true, if_not_exists: true
10
+ add_index :cm_imported_orders, :import_order_id, if_not_exists: true
11
+ end
12
+ end
@@ -0,0 +1,11 @@
1
+ class CreateCmIdempotencyKeys < ActiveRecord::Migration[7.0]
2
+ def change
3
+ create_table :cm_idempotency_keys, if_not_exists: true do |t|
4
+ t.string :key, null: false
5
+ t.timestamps
6
+ end
7
+
8
+ add_index :cm_idempotency_keys, :key, unique: true, if_not_exists: true
9
+ add_index :cm_idempotency_keys, :created_at, if_not_exists: true
10
+ end
11
+ end
@@ -1,5 +1,5 @@
1
1
  module SpreeCmCommissioner
2
- VERSION = '2.8.6'.freeze
2
+ VERSION = '2.8.7'.freeze
3
3
 
4
4
  module_function
5
5
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: spree_cm_commissioner
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.8.6
4
+ version: 2.8.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - You
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-18 00:00:00.000000000 Z
11
+ date: 2026-06-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: spree
@@ -1164,6 +1164,7 @@ files:
1164
1164
  - app/finders/spree_cm_commissioner/events/find_matches.rb
1165
1165
  - app/finders/spree_cm_commissioner/inventory_items/recently_changed_finder.rb
1166
1166
  - app/finders/spree_cm_commissioner/orders/find.rb
1167
+ - app/finders/spree_cm_commissioner/orders/find_by_all_state.rb
1167
1168
  - app/finders/spree_cm_commissioner/orders/find_by_state.rb
1168
1169
  - app/finders/spree_cm_commissioner/payment_methods/group_by_bank.rb
1169
1170
  - app/finders/spree_cm_commissioner/places/find_with_route.rb
@@ -1325,10 +1326,12 @@ files:
1325
1326
  - app/interactors/spree_cm_commissioner/webhook_subscriber_orders_sender.rb
1326
1327
  - app/jobs/application_job.rb
1327
1328
  - app/jobs/application_unique_job.rb
1329
+ - app/jobs/concerns/spree_cm_commissioner/idempotent_job.rb
1328
1330
  - app/jobs/spree_cm_commissioner/account_deletion_cron_job.rb
1329
1331
  - app/jobs/spree_cm_commissioner/application_job.rb
1330
1332
  - app/jobs/spree_cm_commissioner/application_job_decorator.rb
1331
1333
  - app/jobs/spree_cm_commissioner/application_unique_job.rb
1334
+ - app/jobs/spree_cm_commissioner/cancel_import_order_job.rb
1332
1335
  - app/jobs/spree_cm_commissioner/chatrace_order_creator_job.rb
1333
1336
  - app/jobs/spree_cm_commissioner/completion_steps/regenerate_for_line_items_job.rb
1334
1337
  - app/jobs/spree_cm_commissioner/customer_content_notification_creator_job.rb
@@ -1342,6 +1345,7 @@ files:
1342
1345
  - app/jobs/spree_cm_commissioner/export_job.rb
1343
1346
  - app/jobs/spree_cm_commissioner/firebase_email_fetcher_job.rb
1344
1347
  - app/jobs/spree_cm_commissioner/guests/preload_check_in_session_ids_job.rb
1348
+ - app/jobs/spree_cm_commissioner/idempotency_keys/prune_job.rb
1345
1349
  - app/jobs/spree_cm_commissioner/import_order_job.rb
1346
1350
  - app/jobs/spree_cm_commissioner/integrations/base_job.rb
1347
1351
  - app/jobs/spree_cm_commissioner/integrations/cleanup_sync_sessions_job.rb
@@ -1383,6 +1387,7 @@ files:
1383
1387
  - app/jobs/spree_cm_commissioner/state_job.rb
1384
1388
  - app/jobs/spree_cm_commissioner/subscription_order_executor_job.rb
1385
1389
  - app/jobs/spree_cm_commissioner/telegram_alerts/integration_sync_failure_job.rb
1390
+ - app/jobs/spree_cm_commissioner/telegram_alerts/order_integrity_check_job.rb
1386
1391
  - app/jobs/spree_cm_commissioner/telegram_debug_pin_code_sender_job.rb
1387
1392
  - app/jobs/spree_cm_commissioner/telegram_gateway/pin_code_sender_job.rb
1388
1393
  - app/jobs/spree_cm_commissioner/telegram_notification_sender_job.rb
@@ -1516,9 +1521,11 @@ files:
1516
1521
  - app/models/spree_cm_commissioner/hotel_google_wallet.rb
1517
1522
  - app/models/spree_cm_commissioner/icon_decorator.rb
1518
1523
  - app/models/spree_cm_commissioner/id_card.rb
1524
+ - app/models/spree_cm_commissioner/idempotency_key.rb
1519
1525
  - app/models/spree_cm_commissioner/image_decorator.rb
1520
1526
  - app/models/spree_cm_commissioner/image_methods_decorator.rb
1521
1527
  - app/models/spree_cm_commissioner/import.rb
1528
+ - app/models/spree_cm_commissioner/imported_order.rb
1522
1529
  - app/models/spree_cm_commissioner/imports/import_order.rb
1523
1530
  - app/models/spree_cm_commissioner/imports/import_payment_reference.rb
1524
1531
  - app/models/spree_cm_commissioner/integration.rb
@@ -2128,6 +2135,7 @@ files:
2128
2135
  - app/services/spree_cm_commissioner/homepage_data.rb
2129
2136
  - app/services/spree_cm_commissioner/homepage_data_loader.rb
2130
2137
  - app/services/spree_cm_commissioner/imports/orders/base.rb
2138
+ - app/services/spree_cm_commissioner/imports/orders/cancel.rb
2131
2139
  - app/services/spree_cm_commissioner/imports/orders/create.rb
2132
2140
  - app/services/spree_cm_commissioner/imports/orders/update.rb
2133
2141
  - app/services/spree_cm_commissioner/integrations/base/sync_manager.rb
@@ -2222,8 +2230,12 @@ files:
2222
2230
  - app/services/spree_cm_commissioner/signing/verify_signature.rb
2223
2231
  - app/services/spree_cm_commissioner/sprite_data_loader_service.rb
2224
2232
  - app/services/spree_cm_commissioner/sprite_import_service.rb
2233
+ - app/services/spree_cm_commissioner/telegram_alerts/checks/order_complete_payment_not_paid.rb
2234
+ - app/services/spree_cm_commissioner/telegram_alerts/checks/payment_paid_order_not_complete.rb
2225
2235
  - app/services/spree_cm_commissioner/telegram_alerts/event_creation.rb
2226
2236
  - app/services/spree_cm_commissioner/telegram_alerts/integration_sync_failure.rb
2237
+ - app/services/spree_cm_commissioner/telegram_alerts/order_integrity_alert.rb
2238
+ - app/services/spree_cm_commissioner/telegram_alerts/order_integrity_checks_runner.rb
2227
2239
  - app/services/spree_cm_commissioner/telegram_gateway/pin_code_sender.rb
2228
2240
  - app/services/spree_cm_commissioner/ticket_transfers/accept.rb
2229
2241
  - app/services/spree_cm_commissioner/ticket_transfers/cancel.rb
@@ -2360,6 +2372,7 @@ files:
2360
2372
  - app/views/spree/admin/import_existing_orders/index.html.erb
2361
2373
  - app/views/spree/admin/import_existing_orders/new.html.erb
2362
2374
  - app/views/spree/admin/import_existing_orders/show.html.erb
2375
+ - app/views/spree/admin/import_new_orders/_cancel_modal.html.erb
2363
2376
  - app/views/spree/admin/import_new_orders/_form.html.erb
2364
2377
  - app/views/spree/admin/import_new_orders/_import_new_order_instruction.html.erb
2365
2378
  - app/views/spree/admin/import_new_orders/index.html.erb
@@ -3254,6 +3267,9 @@ files:
3254
3267
  - db/migrate/20260610000001_drop_is_open_dated_from_cm_trips.rb
3255
3268
  - db/migrate/20260614000001_change_code_limit_in_cm_pin_codes.rb
3256
3269
  - db/migrate/20260615000002_add_index_to_code_on_cm_pin_codes.rb
3270
+ - db/migrate/20260616000001_add_canceled_by_id_to_cm_imports.rb
3271
+ - db/migrate/20260617000002_create_cm_imported_orders.rb
3272
+ - db/migrate/20260619000001_create_cm_idempotency_keys.rb
3257
3273
  - docker-compose.yml
3258
3274
  - docs/api/scoped-access-token-endpoints.md
3259
3275
  - docs/option_types/attr_types.md