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.
- checksums.yaml +4 -4
- data/.env.example +8 -0
- data/Gemfile.lock +1 -1
- data/app/controllers/spree/admin/import_new_orders_controller.rb +25 -0
- data/app/controllers/spree/api/v2/storefront/account/orders_controller_decorator.rb +5 -0
- data/app/controllers/spree/api/v2/storefront/order_histories_controller.rb +2 -2
- data/app/controllers/spree/api/v2/storefront/ticket_transfers_controller.rb +4 -3
- data/app/controllers/spree/api/v2/tenant/order_histories_controller.rb +2 -2
- data/app/finders/spree_cm_commissioner/orders/find.rb +33 -3
- data/app/finders/spree_cm_commissioner/orders/find_by_all_state.rb +11 -0
- data/app/interactors/spree_cm_commissioner/waiting_guests_caller.rb +16 -4
- data/app/jobs/concerns/spree_cm_commissioner/idempotent_job.rb +12 -0
- data/app/jobs/spree_cm_commissioner/cancel_import_order_job.rb +11 -0
- data/app/jobs/spree_cm_commissioner/idempotency_keys/prune_job.rb +20 -0
- data/app/jobs/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_job.rb +21 -3
- data/app/jobs/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_on_hold_job.rb +34 -3
- data/app/jobs/spree_cm_commissioner/telegram_alerts/order_integrity_check_job.rb +17 -0
- data/app/jobs/spree_cm_commissioner/waiting_guests_caller_job.rb +5 -0
- data/app/models/concerns/spree_cm_commissioner/order_state_machine.rb +10 -0
- data/app/models/concerns/spree_cm_commissioner/store_preference.rb +1 -0
- data/app/models/spree_cm_commissioner/idempotency_key.rb +21 -0
- data/app/models/spree_cm_commissioner/import.rb +2 -1
- data/app/models/spree_cm_commissioner/imported_order.rb +6 -0
- data/app/models/spree_cm_commissioner/imports/import_order.rb +21 -0
- data/app/models/spree_cm_commissioner/order_decorator.rb +14 -0
- data/app/models/spree_cm_commissioner/payment_decorator.rb +1 -0
- data/app/overrides/spree/admin/products/_form/allow_gift_transfer.html.erb.deface +1 -1
- data/app/overrides/spree/admin/products/_form/allow_transfer.html.erb.deface +1 -1
- data/app/overrides/spree/admin/stores/_form/store_preferences.html.erb.deface +9 -0
- data/app/services/spree_cm_commissioner/imports/orders/cancel.rb +63 -0
- data/app/services/spree_cm_commissioner/imports/orders/create.rb +1 -0
- data/app/services/spree_cm_commissioner/telegram_alerts/checks/order_complete_payment_not_paid.rb +21 -0
- data/app/services/spree_cm_commissioner/telegram_alerts/checks/payment_paid_order_not_complete.rb +21 -0
- data/app/services/spree_cm_commissioner/telegram_alerts/order_integrity_alert.rb +112 -0
- data/app/services/spree_cm_commissioner/telegram_alerts/order_integrity_checks_runner.rb +32 -0
- data/app/services/spree_cm_commissioner/waiting_room/stamp_queue_positions.rb +66 -24
- data/app/views/spree/admin/import_new_orders/_cancel_modal.html.erb +35 -0
- data/app/views/spree/admin/import_new_orders/index.html.erb +4 -0
- data/app/views/spree/admin/import_new_orders/show.html.erb +30 -0
- data/app/views/spree/admin/imports/index.html.erb +2 -0
- data/app/views/spree/admin/inventory_holds/index.html.erb +8 -3
- data/config/initializers/spree_permitted_attributes.rb +1 -0
- data/config/locales/en.yml +4 -4
- data/config/locales/km.yml +4 -4
- data/config/routes.rb +1 -0
- data/db/migrate/20260616000001_add_canceled_by_id_to_cm_imports.rb +5 -0
- data/db/migrate/20260617000002_create_cm_imported_orders.rb +12 -0
- data/db/migrate/20260619000001_create_cm_idempotency_keys.rb +11 -0
- data/lib/spree_cm_commissioner/version.rb +1 -1
- metadata +18 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4f8d11251736536036263c451dde5cb82e34a6d54b12fe116866a338346d8097
|
|
4
|
+
data.tar.gz: f1cec56cfe9db369b4bf70a40c8545e09fd2b89a9d0fd1e2d0547cf4818a04eb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b0d1ef1dca4921e6493b8455b31c31b9289444aa64b3d5378dc6d87c0676c081ee2c0148dfd3a46fb8572d30fb8fb86d6c53a24249768b3b9001fbb3f2f6a4fa
|
|
7
|
+
data.tar.gz: a3dfc479c74f38e3889e84ab72246fc01e31134a630563f4f3bf2b3713e3e13d2df1389a0bdf1aae4337e56d891cb6a6dc29e21570d28b84d9d75a5b1d1cd152
|
data/.env.example
CHANGED
|
@@ -22,6 +22,11 @@ WAITING_ROOM_MIN_SESSIONS_COUNT=5
|
|
|
22
22
|
WAITING_ROOM_DISABLED=no
|
|
23
23
|
WAITING_ROOM_POSITION_STAMP_LIMIT=1000
|
|
24
24
|
WAITING_ROOM_FIRESTORE_BATCH_SIZE=500
|
|
25
|
+
WAITING_ROOM_MIN_WAIT_TO_ENTER_SECONDS=600 # Waiting Room step floor (10 min)
|
|
26
|
+
WAITING_ROOM_MIN_QUEUE_TO_ENTER_SECONDS=300 # Queue step floor (5 min)
|
|
27
|
+
WAITING_ROOM_MAX_WAIT_TO_DISPLAY_SECONDS=3600 # above this the ETA is hidden (1 h)
|
|
28
|
+
WAITING_ROOM_CALLER_INTERVAL_SECONDS=60 # how often the caller runs; match the waiting_guests_caller cron
|
|
29
|
+
WAITING_ROOM_MAX_ETA_BATCH_SIZE=50 # cap on batch size assumed when estimating the ETA
|
|
25
30
|
|
|
26
31
|
# Vattanac Bank
|
|
27
32
|
VATTANAC_AES_SECRET_KEY= ""
|
|
@@ -68,3 +73,6 @@ HOLD_COOLDOWN_AFTER_EXPIRY_IN_MINUTES=2 # Cooldown period before a user can
|
|
|
68
73
|
|
|
69
74
|
# See: app/models/spree_cm_commissioner/payment_decorator.rb
|
|
70
75
|
PAYMENT_INCOMPLETE_NOTIFICATION_DELAY_IN_MINUTES=5 # Delay before notifying a customer that their order is still unpaid after payment is created
|
|
76
|
+
|
|
77
|
+
# See: app/jobs/spree_cm_commissioner/telegram_alerts/order_integrity_check_job.rb
|
|
78
|
+
ORDER_INTEGRITY_TELEGRAM_ALERT_ENABLED="no" # Set to yes to enable the order integrity Telegram alert job (off by default to avoid flooding Telegram rate limits during traffic spikes)
|
data/Gemfile.lock
CHANGED
|
@@ -23,6 +23,31 @@ module Spree
|
|
|
23
23
|
end
|
|
24
24
|
end
|
|
25
25
|
end
|
|
26
|
+
|
|
27
|
+
# POST: /admin/orders/import_new_orders/:id/cancel
|
|
28
|
+
def cancel
|
|
29
|
+
import = model_class.find(params[:id])
|
|
30
|
+
show_url = spree.admin_import_new_order_url(import.id)
|
|
31
|
+
|
|
32
|
+
unless import.cancellable?
|
|
33
|
+
flash[:error] = 'These orders cannot be canceled.' # rubocop:disable Rails/I18nLocaleTexts
|
|
34
|
+
redirect_to show_url and return
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
if params[:confirm_name].to_s.strip != import.name.to_s.strip
|
|
38
|
+
flash[:error] = 'Import name did not match. Cancellation aborted.' # rubocop:disable Rails/I18nLocaleTexts
|
|
39
|
+
redirect_to show_url and return
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
SpreeCmCommissioner::CancelImportOrderJob.perform_later(
|
|
43
|
+
import_order_id: import.id,
|
|
44
|
+
canceled_by_user_id: spree_current_user.id,
|
|
45
|
+
cancellation_reason: params[:cancellation_reason].to_s.strip.presence
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
flash[:success] = "Canceling orders from \"#{import.name}\". This runs in the background."
|
|
49
|
+
redirect_to show_url
|
|
50
|
+
end
|
|
26
51
|
end
|
|
27
52
|
end
|
|
28
53
|
end
|
|
@@ -44,6 +44,11 @@ module Spree
|
|
|
44
44
|
def collection_finder
|
|
45
45
|
SpreeCmCommissioner::Orders::FindByState
|
|
46
46
|
end
|
|
47
|
+
|
|
48
|
+
# this allows fetching a single order regardless of its state.
|
|
49
|
+
def resource_finder
|
|
50
|
+
SpreeCmCommissioner::Orders::FindByAllState
|
|
51
|
+
end
|
|
47
52
|
end
|
|
48
53
|
end
|
|
49
54
|
end
|
|
@@ -31,12 +31,12 @@ module Spree
|
|
|
31
31
|
|
|
32
32
|
def collection
|
|
33
33
|
if spree_current_user.present?
|
|
34
|
-
spree_current_user.orders.
|
|
34
|
+
spree_current_user.orders.not_archived
|
|
35
35
|
else
|
|
36
36
|
order_tokens = Array(params[:order_tokens])
|
|
37
37
|
return Spree::Order.none if order_tokens.empty?
|
|
38
38
|
|
|
39
|
-
Spree::Order.
|
|
39
|
+
Spree::Order.not_archived.without_user.where(token: order_tokens)
|
|
40
40
|
end
|
|
41
41
|
end
|
|
42
42
|
|
|
@@ -86,9 +86,10 @@ module Spree
|
|
|
86
86
|
end
|
|
87
87
|
|
|
88
88
|
def collection
|
|
89
|
-
SpreeCmCommissioner::TicketTransfer
|
|
90
|
-
|
|
91
|
-
|
|
89
|
+
SpreeCmCommissioner::TicketTransfer
|
|
90
|
+
.includes(:order, from_guest: [:event, { line_item: :product }])
|
|
91
|
+
.where(from_user_id: spree_current_user.id)
|
|
92
|
+
.order(id: :desc)
|
|
92
93
|
end
|
|
93
94
|
|
|
94
95
|
def resource_serializer
|
|
@@ -38,12 +38,12 @@ module Spree
|
|
|
38
38
|
|
|
39
39
|
def collection
|
|
40
40
|
if spree_current_user.present?
|
|
41
|
-
spree_current_user.orders.
|
|
41
|
+
spree_current_user.orders.not_archived
|
|
42
42
|
else
|
|
43
43
|
order_tokens = Array(params[:order_tokens])
|
|
44
44
|
return Spree::Order.none if order_tokens.empty?
|
|
45
45
|
|
|
46
|
-
Spree::Order.
|
|
46
|
+
Spree::Order.not_archived.without_user.where(token: order_tokens)
|
|
47
47
|
end
|
|
48
48
|
end
|
|
49
49
|
|
|
@@ -4,14 +4,30 @@
|
|
|
4
4
|
module SpreeCmCommissioner
|
|
5
5
|
module Orders
|
|
6
6
|
class Find
|
|
7
|
+
CURRENT_CART_STATES = %w[cart address].freeze
|
|
8
|
+
CART_MAX_AGE_IN_MINUTES = ENV.fetch('CART_MAX_AGE_IN_MINUTES', '720').to_i # Default: 12 hours
|
|
9
|
+
|
|
7
10
|
def execute(store:, user:, currency:, token: nil, state: nil)
|
|
11
|
+
state = Array(state).map(&:to_s)
|
|
12
|
+
|
|
8
13
|
params = { store_id: store.id, currency: currency }
|
|
9
14
|
params[:state] = state if state.present?
|
|
10
15
|
|
|
11
|
-
|
|
12
|
-
|
|
16
|
+
order = if token.present?
|
|
17
|
+
find_by_token(params, token)
|
|
18
|
+
elsif user.present?
|
|
19
|
+
find_by_user(params, user)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Only enforce hold and age expiration when fetching current cart states ('cart' or 'address').
|
|
23
|
+
# For other states (like 'payment,complete'), we want to return the order even if it's old or expired
|
|
24
|
+
# because some pages on client still need some specific state of order to display.
|
|
25
|
+
if state.intersect?(CURRENT_CART_STATES)
|
|
26
|
+
return nil if hold_expired?(order)
|
|
27
|
+
return nil if cart_too_old?(order)
|
|
28
|
+
end
|
|
13
29
|
|
|
14
|
-
|
|
30
|
+
order
|
|
15
31
|
end
|
|
16
32
|
|
|
17
33
|
def find_by_token(params, token)
|
|
@@ -24,6 +40,20 @@ module SpreeCmCommissioner
|
|
|
24
40
|
scope.order(created_at: :desc).find_by(params)
|
|
25
41
|
end
|
|
26
42
|
|
|
43
|
+
def hold_expired?(order)
|
|
44
|
+
return false if order.nil?
|
|
45
|
+
|
|
46
|
+
# Check if the order has a hold_expires_at column and if it has already passed
|
|
47
|
+
order.hold_expires_at.present? && order.hold_expires_at < Time.current
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def cart_too_old?(order)
|
|
51
|
+
return false if order.nil?
|
|
52
|
+
|
|
53
|
+
# Treat the cart as active based on its last update time, not its creation time.
|
|
54
|
+
order.updated_at < CART_MAX_AGE_IN_MINUTES.minutes.ago
|
|
55
|
+
end
|
|
56
|
+
|
|
27
57
|
private
|
|
28
58
|
|
|
29
59
|
def scope
|
|
@@ -3,8 +3,11 @@ require 'google/cloud/firestore'
|
|
|
3
3
|
# TODO: alert when available_slots is negative.
|
|
4
4
|
module SpreeCmCommissioner
|
|
5
5
|
class WaitingGuestsCaller < BaseInteractor
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
# 10 min floor — Waiting Room step (full journey, most uncertainty)
|
|
7
|
+
MIN_WAIT_TO_ENTER_SECONDS = (ENV['WAITING_ROOM_MIN_WAIT_TO_ENTER_SECONDS'] || 600).to_i
|
|
8
|
+
|
|
9
|
+
# 5 min floor — Queue step (position assigned, sub-segment of the journey)
|
|
10
|
+
MIN_QUEUE_TO_ENTER_SECONDS = (ENV['WAITING_ROOM_MIN_QUEUE_TO_ENTER_SECONDS'] || 300).to_i
|
|
8
11
|
|
|
9
12
|
# Firestore bounds a batch update by payload size (10 MiB); 500 ops/commit leaves us far under that.
|
|
10
13
|
FIRESTORE_BATCH_SIZE = (ENV['WAITING_ROOM_FIRESTORE_BATCH_SIZE'] || 500).to_i
|
|
@@ -20,7 +23,8 @@ module SpreeCmCommissioner
|
|
|
20
23
|
full: long_waiting_guests.size >= available_slots,
|
|
21
24
|
available_slots: available_slots - long_waiting_guests.size,
|
|
22
25
|
avg_wait_to_enter_seconds: compute_avg_wait_to_enter_seconds(long_waiting_guests),
|
|
23
|
-
avg_queue_to_enter_seconds: compute_avg_queue_to_enter_seconds(long_waiting_guests)
|
|
26
|
+
avg_queue_to_enter_seconds: compute_avg_queue_to_enter_seconds(long_waiting_guests),
|
|
27
|
+
slots_per_call: available_slots
|
|
24
28
|
)
|
|
25
29
|
end
|
|
26
30
|
|
|
@@ -96,10 +100,18 @@ module SpreeCmCommissioner
|
|
|
96
100
|
end
|
|
97
101
|
|
|
98
102
|
# merge: true so we preserve the published `waiting_guests_records_path` on the lobby doc.
|
|
99
|
-
def mark_as(
|
|
103
|
+
def mark_as(
|
|
104
|
+
full:,
|
|
105
|
+
available_slots:,
|
|
106
|
+
avg_wait_to_enter_seconds: nil,
|
|
107
|
+
avg_queue_to_enter_seconds: nil,
|
|
108
|
+
slots_per_call: nil
|
|
109
|
+
)
|
|
100
110
|
data = { full: full, available_slots: available_slots }
|
|
101
111
|
data[:avg_wait_to_enter_seconds] = avg_wait_to_enter_seconds if avg_wait_to_enter_seconds
|
|
102
112
|
data[:avg_queue_to_enter_seconds] = avg_queue_to_enter_seconds if avg_queue_to_enter_seconds
|
|
113
|
+
data[:slots_per_call] = slots_per_call if slots_per_call
|
|
114
|
+
|
|
103
115
|
lobby_document.set(data, merge: true)
|
|
104
116
|
end
|
|
105
117
|
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
module SpreeCmCommissioner
|
|
2
|
+
module IdempotentJob
|
|
3
|
+
def with_idempotency(key = nil, &block)
|
|
4
|
+
SpreeCmCommissioner::IdempotencyKey.run_once(key.presence || default_idempotency_key, &block)
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
# Namespaced so the key is self-describing in the DB (no `source` column needed).
|
|
8
|
+
def default_idempotency_key
|
|
9
|
+
"#{self.class.name}:#{job_id}"
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
module SpreeCmCommissioner
|
|
2
|
+
class CancelImportOrderJob < ApplicationUniqueJob
|
|
3
|
+
def perform(options = {})
|
|
4
|
+
SpreeCmCommissioner::Imports::Orders::Cancel.new(
|
|
5
|
+
import_order_id: options[:import_order_id],
|
|
6
|
+
canceled_by_user_id: options[:canceled_by_user_id],
|
|
7
|
+
cancellation_reason: options[:cancellation_reason]
|
|
8
|
+
).call
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Prunes old idempotency keys. Keys only matter within the retry window
|
|
2
|
+
# (minutes–hours), so anything older than the cutoff is safe to delete.
|
|
3
|
+
# The `created_at` index supports the delete.
|
|
4
|
+
module SpreeCmCommissioner
|
|
5
|
+
module IdempotencyKeys
|
|
6
|
+
class PruneJob < SpreeCmCommissioner::ApplicationJob
|
|
7
|
+
DEFAULT_CUTOFF_DAYS = 3
|
|
8
|
+
|
|
9
|
+
queue_as :default
|
|
10
|
+
|
|
11
|
+
def perform(cutoff_days = DEFAULT_CUTOFF_DAYS)
|
|
12
|
+
days = [cutoff_days.to_i, 1].max
|
|
13
|
+
|
|
14
|
+
SpreeCmCommissioner::IdempotencyKey
|
|
15
|
+
.where(created_at: ...days.days.ago)
|
|
16
|
+
.delete_all
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -1,12 +1,30 @@
|
|
|
1
1
|
module SpreeCmCommissioner
|
|
2
2
|
module InventoryItems
|
|
3
3
|
class BulkAdjustQuantitiesJob < ApplicationUniqueJob
|
|
4
|
+
include SpreeCmCommissioner::IdempotentJob
|
|
5
|
+
|
|
6
|
+
def perform(options = {})
|
|
7
|
+
with_idempotency(idempotency_key_for(options)) do
|
|
8
|
+
bulk_adjust_quantities!(options)
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
# Dedup on the line items being synced, so a re-pushed payload for the same
|
|
15
|
+
# line items no-ops. An explicit key wins; with no line items we fall back to
|
|
16
|
+
# the per-enqueue job_id (returning nil lets with_idempotency apply that default).
|
|
17
|
+
def idempotency_key_for(options)
|
|
18
|
+
return options[:idempotency_key] if options[:idempotency_key].present?
|
|
19
|
+
return nil if options[:line_item_ids].blank?
|
|
20
|
+
|
|
21
|
+
"line_item:#{options[:line_item_ids].compact.uniq.sort.join(':')}"
|
|
22
|
+
end
|
|
23
|
+
|
|
4
24
|
# :line_item_ids, :inventory_id_and_quantities, :caller_source
|
|
5
25
|
#
|
|
6
|
-
# :line_item_ids is included for unique job key generation to prevent duplicate jobs,
|
|
7
|
-
# though it's not used in the perform method implementation.
|
|
8
26
|
# :caller_source is a string like "ClassName#method" identifying who enqueued the job.
|
|
9
|
-
def
|
|
27
|
+
def bulk_adjust_quantities!(options = {})
|
|
10
28
|
SpreeCmCommissioner::InventoryItems::BulkAdjustQuantities.call!(
|
|
11
29
|
inventory_id_and_quantities: options[:inventory_id_and_quantities],
|
|
12
30
|
caller_source: options[:caller_source]
|
|
@@ -1,14 +1,45 @@
|
|
|
1
1
|
module SpreeCmCommissioner
|
|
2
2
|
module InventoryItems
|
|
3
3
|
class BulkAdjustQuantitiesOnHoldJob < ApplicationUniqueJob
|
|
4
|
+
include SpreeCmCommissioner::IdempotentJob
|
|
5
|
+
|
|
4
6
|
queue_as :default
|
|
5
7
|
|
|
8
|
+
def perform(options = {})
|
|
9
|
+
with_idempotency(idempotency_key_for(options)) do
|
|
10
|
+
bulk_adjust_quantities_on_hold(options)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
# Dedup on the order + the exact adjustment set, so a re-pushed identical
|
|
17
|
+
# on-hold payload no-ops while a genuinely different one re-applies. An explicit
|
|
18
|
+
# key wins; with no order_id we fall back to the per-enqueue job_id (returning
|
|
19
|
+
# nil lets with_idempotency apply that default).
|
|
20
|
+
def idempotency_key_for(options)
|
|
21
|
+
return options[:idempotency_key] if options[:idempotency_key].present?
|
|
22
|
+
return nil if options[:order_id].blank?
|
|
23
|
+
|
|
24
|
+
"order:#{options[:order_id]}:hold:#{adjustments_fingerprint(options[:inventory_id_and_quantities])}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Readable, order-independent fingerprint of the adjustment payload, e.g. "1=2,2=-1".
|
|
28
|
+
# Delimiter hierarchy: ":" separates key fields, "," separates adjustments, "=" pairs id/qty.
|
|
29
|
+
def adjustments_fingerprint(adjustments)
|
|
30
|
+
Array(adjustments)
|
|
31
|
+
.map { |adj| adj.with_indifferent_access.values_at(:inventory_id, :quantity) }
|
|
32
|
+
.sort_by { |inventory_id, quantity| [inventory_id.to_s, quantity.to_s] }
|
|
33
|
+
.map { |inventory_id, quantity| "#{inventory_id}=#{quantity}" }
|
|
34
|
+
.join(',')
|
|
35
|
+
end
|
|
36
|
+
|
|
6
37
|
# :order_id, :inventory_id_and_quantities, :caller_source
|
|
7
38
|
#
|
|
8
|
-
# :order_id is
|
|
9
|
-
# though it's not used in the
|
|
39
|
+
# :order_id is used for unique idempotency key generation to prevent duplicate
|
|
40
|
+
# processing, though it's not used in the adjustment itself.
|
|
10
41
|
# :caller_source is a string like "ClassName#method" identifying who enqueued the job.
|
|
11
|
-
def
|
|
42
|
+
def bulk_adjust_quantities_on_hold(options = {})
|
|
12
43
|
raise ArgumentError, 'order_id is required' if options[:order_id].blank?
|
|
13
44
|
raise ArgumentError, 'inventory_id_and_quantities is required' if options[:inventory_id_and_quantities].blank?
|
|
14
45
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module SpreeCmCommissioner
|
|
2
|
+
module TelegramAlerts
|
|
3
|
+
class OrderIntegrityCheckJob < ApplicationUniqueJob
|
|
4
|
+
queue_as :telegram_bot
|
|
5
|
+
# retry: 0 sends failed jobs straight to the dead queue, avoiding retry storms.
|
|
6
|
+
# Guarded since sidekiq_options requires the host app's Sidekiq integration.
|
|
7
|
+
sidekiq_options retry: 0 if respond_to?(:sidekiq_options)
|
|
8
|
+
|
|
9
|
+
def perform(order_id:)
|
|
10
|
+
order = Spree::Order.find_by(id: order_id)
|
|
11
|
+
return unless order
|
|
12
|
+
|
|
13
|
+
SpreeCmCommissioner::TelegramAlerts::OrderIntegrityChecksRunner.call(order: order)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -1,6 +1,11 @@
|
|
|
1
|
+
# Scheduled as a cron job (see cm-market-server/config/schedule.yml):
|
|
1
2
|
# waiting_guests_caller:
|
|
2
3
|
# cron: "*/1 * * * *" # Every minute
|
|
3
4
|
# class: "SpreeCmCommissioner::WaitingGuestsCallerJob"
|
|
5
|
+
#
|
|
6
|
+
# NOTE: the queue ETA assumes one batch is released per run, so this interval must match
|
|
7
|
+
# SpreeCmCommissioner::WaitingRoom::StampQueuePositions::CALLER_INTERVAL_SECONDS
|
|
8
|
+
# (env WAITING_ROOM_CALLER_INTERVAL_SECONDS). If you change the cron, update that too.
|
|
4
9
|
module SpreeCmCommissioner
|
|
5
10
|
class WaitingGuestsCallerJob < ApplicationUniqueJob
|
|
6
11
|
queue_as :waiting_room
|
|
@@ -28,6 +28,7 @@ module SpreeCmCommissioner
|
|
|
28
28
|
state_machine.after_transition to: :canceled, do: :restock_inventory!
|
|
29
29
|
|
|
30
30
|
state_machine.after_transition to: :complete, do: :increment_route_fulfilled_order_count
|
|
31
|
+
state_machine.after_transition to: :complete, do: :run_order_complete_alerts
|
|
31
32
|
|
|
32
33
|
scope :accepted, -> { where(request_state: 'accepted') }
|
|
33
34
|
|
|
@@ -322,6 +323,15 @@ module SpreeCmCommissioner
|
|
|
322
323
|
TelegramNotificationSenderJob.perform_later(chat_id: chat_id, message: factory.message, parse_mode: factory.parse_mode)
|
|
323
324
|
end
|
|
324
325
|
|
|
326
|
+
def run_order_complete_alerts
|
|
327
|
+
return unless ENV['ORDER_INTEGRITY_TELEGRAM_ALERT_ENABLED'] == 'yes'
|
|
328
|
+
|
|
329
|
+
# Allow async payment processors time to settle before checking for alerts
|
|
330
|
+
SpreeCmCommissioner::TelegramAlerts::OrderIntegrityCheckJob
|
|
331
|
+
.set(wait: 2.minutes)
|
|
332
|
+
.perform_later(order_id: id)
|
|
333
|
+
end
|
|
334
|
+
|
|
325
335
|
def send_order_complete_telegram_alert_to_store
|
|
326
336
|
title = '🎫 --- [NEW ORDER] ---'
|
|
327
337
|
chat_id = store.preferred_telegram_order_alert_chat_id
|
|
@@ -8,6 +8,7 @@ module SpreeCmCommissioner
|
|
|
8
8
|
preference :telegram_order_request_alert_chat_id, :string
|
|
9
9
|
preference :telegram_new_vendor_alert_chat_id, :string
|
|
10
10
|
preference :telegram_new_event_alert_chat_id, :string
|
|
11
|
+
preference :telegram_order_anomaly_alert_chat_id, :string
|
|
11
12
|
preference :assetlinks, :string, default: ''
|
|
12
13
|
preference :apple_app_site_association, :string, default: ''
|
|
13
14
|
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module SpreeCmCommissioner
|
|
2
|
+
class IdempotencyKey < Base
|
|
3
|
+
# Runs the block exactly once per key. A re-run finds the row and no-ops.
|
|
4
|
+
# Key row + block side effects commit atomically. `requires_new: true` opens a
|
|
5
|
+
# savepoint so a rescued insert race rolls back only this block, not an outer txn.
|
|
6
|
+
def self.run_once(key)
|
|
7
|
+
return yield if key.blank?
|
|
8
|
+
|
|
9
|
+
transaction(requires_new: true) do
|
|
10
|
+
create!(key: key)
|
|
11
|
+
yield
|
|
12
|
+
end
|
|
13
|
+
rescue ActiveRecord::RecordNotUnique
|
|
14
|
+
CmAppLogger.log(
|
|
15
|
+
label: 'SpreeCmCommissioner::IdempotencyKey.run_once',
|
|
16
|
+
data: { event: 'duplicate_skipped', key: key }
|
|
17
|
+
)
|
|
18
|
+
nil # already applied by a concurrent run
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -2,12 +2,13 @@ module SpreeCmCommissioner
|
|
|
2
2
|
class Import < Base
|
|
3
3
|
extend FriendlyId
|
|
4
4
|
|
|
5
|
-
enum :status, { :queue => 0, :progress => 1, :done => 2, :failed => 3 }
|
|
5
|
+
enum :status, { :queue => 0, :progress => 1, :done => 2, :failed => 3, :canceling => 4, :canceled => 5 }
|
|
6
6
|
enum :import_type, { :new_order => 0, :existing_order => 1, :aba_payment_reference => 2 }
|
|
7
7
|
has_one_attached :imported_file
|
|
8
8
|
friendly_id :name, use: :slugged
|
|
9
9
|
|
|
10
10
|
belongs_to :importable, polymorphic: true, optional: true
|
|
11
11
|
belongs_to :import_by, class_name: 'Spree::User', optional: true
|
|
12
|
+
belongs_to :canceled_by, class_name: 'Spree::User', optional: true
|
|
12
13
|
end
|
|
13
14
|
end
|
|
@@ -2,6 +2,27 @@ module SpreeCmCommissioner
|
|
|
2
2
|
module Imports
|
|
3
3
|
class ImportOrder < Import
|
|
4
4
|
preference :fail_rows, :string
|
|
5
|
+
# Stored as ISO8601 string — Spree's preferences have no :datetime cast.
|
|
6
|
+
preference :canceled_at, :string
|
|
7
|
+
preference :cancellation_reason, :string
|
|
8
|
+
|
|
9
|
+
has_many :imported_orders,
|
|
10
|
+
class_name: 'SpreeCmCommissioner::ImportedOrder',
|
|
11
|
+
dependent: :destroy,
|
|
12
|
+
inverse_of: :import_order
|
|
13
|
+
has_many :orders, through: :imported_orders, source: :order
|
|
14
|
+
|
|
15
|
+
# done → order rows were created; cancel will reverse them.
|
|
16
|
+
# failed → may have created some orders before failing; cancel cleans those up.
|
|
17
|
+
def cancellable?
|
|
18
|
+
done? || failed?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def canceled_at
|
|
22
|
+
return nil if preferred_canceled_at.blank?
|
|
23
|
+
|
|
24
|
+
Time.zone.parse(preferred_canceled_at)
|
|
25
|
+
end
|
|
5
26
|
end
|
|
6
27
|
end
|
|
7
28
|
end
|
|
@@ -31,6 +31,10 @@ module SpreeCmCommissioner
|
|
|
31
31
|
|
|
32
32
|
base.has_one :invoice, dependent: :destroy, class_name: 'SpreeCmCommissioner::Invoice'
|
|
33
33
|
base.has_one :customer, class_name: 'SpreeCmCommissioner::Customer', through: :subscription
|
|
34
|
+
base.has_one :imported_order,
|
|
35
|
+
class_name: 'SpreeCmCommissioner::ImportedOrder',
|
|
36
|
+
dependent: :destroy,
|
|
37
|
+
inverse_of: :order
|
|
34
38
|
|
|
35
39
|
base.belongs_to :tenant, class_name: 'SpreeCmCommissioner::Tenant', optional: true
|
|
36
40
|
base.belongs_to :subscription, class_name: 'SpreeCmCommissioner::Subscription', optional: true
|
|
@@ -80,6 +84,16 @@ module SpreeCmCommissioner
|
|
|
80
84
|
end
|
|
81
85
|
end
|
|
82
86
|
|
|
87
|
+
# override: imported orders are bulk-loaded from CSV and the contact email
|
|
88
|
+
# often belongs to a third party (organizer, importer) rather than the buyer,
|
|
89
|
+
# so the cancellation email would notify the wrong recipient. Normal orders
|
|
90
|
+
# still receive the cancel email via the spree_emails decorator.
|
|
91
|
+
def send_cancel_email
|
|
92
|
+
return if imported_order.present?
|
|
93
|
+
|
|
94
|
+
super
|
|
95
|
+
end
|
|
96
|
+
|
|
83
97
|
# override spree_core behavior to intentionally avoid calling `next!`.
|
|
84
98
|
#
|
|
85
99
|
# Goal: keep the order state at 'cart' when restarting checkout, especially
|
|
@@ -55,6 +55,15 @@
|
|
|
55
55
|
<% end %>
|
|
56
56
|
</div>
|
|
57
57
|
|
|
58
|
+
<!-- Telegram Order Anomaly Alert Chat ID -->
|
|
59
|
+
<div class="col-6">
|
|
60
|
+
<%= f.field_container :preferred_telegram_order_anomaly_alert_chat_id do %>
|
|
61
|
+
<%= f.label :preferred_telegram_order_anomaly_alert_chat_id, Spree.t(:telegram_order_anomaly_alert_chat_id) %>
|
|
62
|
+
<%= f.text_field :preferred_telegram_order_anomaly_alert_chat_id, class: 'form-control' %>
|
|
63
|
+
<%= f.error_message_on :preferred_telegram_order_anomaly_alert_chat_id %>
|
|
64
|
+
<% end %>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
58
67
|
<!-- Assetlinks -->
|
|
59
68
|
<div class="col-12">
|
|
60
69
|
<%= f.field_container :preferred_assetlinks do %>
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
module SpreeCmCommissioner
|
|
2
|
+
module Imports
|
|
3
|
+
module Orders
|
|
4
|
+
class Cancel < Base
|
|
5
|
+
BATCH_SIZE = ENV.fetch('CANCEL_IMPORT_ORDERS_BATCH_SIZE', '50').to_i
|
|
6
|
+
|
|
7
|
+
attr_reader :canceled_by_user_id, :cancellation_reason
|
|
8
|
+
|
|
9
|
+
def initialize(import_order_id:, canceled_by_user_id:, cancellation_reason: nil)
|
|
10
|
+
super(import_order_id: import_order_id)
|
|
11
|
+
@canceled_by_user_id = canceled_by_user_id
|
|
12
|
+
@cancellation_reason = cancellation_reason
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call
|
|
16
|
+
return if import_order.canceled?
|
|
17
|
+
|
|
18
|
+
mark_canceling!
|
|
19
|
+
cancel_orders
|
|
20
|
+
mark_canceled!
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def cancel_orders
|
|
24
|
+
import_order.orders.includes(:imported_order).where.not(state: 'canceled').find_in_batches(batch_size: BATCH_SIZE) do |batch|
|
|
25
|
+
batch.each do |order|
|
|
26
|
+
order.canceled_by(canceled_by_user, cancellation_reason: cancellation_reason)
|
|
27
|
+
rescue StandardError => e
|
|
28
|
+
CmAppLogger.error(
|
|
29
|
+
label: "#{self.class.name}#cancel_orders",
|
|
30
|
+
data: { message: e.message, order_id: order.id, order_number: order.number }
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def canceled_by_user
|
|
39
|
+
@canceled_by_user ||= Spree::User.find(canceled_by_user_id)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def mark_canceling!
|
|
43
|
+
# Skip on Sidekiq retry — keep the original canceler attribution.
|
|
44
|
+
return if import_order.canceling?
|
|
45
|
+
|
|
46
|
+
import_order.update!(
|
|
47
|
+
status: :canceling,
|
|
48
|
+
canceled_by_id: canceled_by_user_id,
|
|
49
|
+
preferred_canceled_at: Time.zone.now.iso8601,
|
|
50
|
+
preferred_cancellation_reason: cancellation_reason
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def mark_canceled!
|
|
55
|
+
import_order.update!(
|
|
56
|
+
status: :canceled,
|
|
57
|
+
finished_at: Time.zone.now
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|