spree_cm_commissioner 2.15.2 → 2.15.4
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 +3 -0
- data/Gemfile.lock +2 -2
- data/app/controllers/spree/admin/inventory_holds_controller.rb +32 -6
- data/app/errors/spree_cm_commissioner/integrations/rate_limited_error.rb +28 -0
- data/app/jobs/spree_cm_commissioner/integrations/book_me_bus_v1/sync_route_job.rb +70 -0
- data/app/models/spree_cm_commissioner/block.rb +16 -0
- data/app/models/spree_cm_commissioner/integration_mapping.rb +8 -0
- data/app/models/spree_cm_commissioner/integrations/book_me_bus_v1.rb +5 -0
- data/app/models/spree_cm_commissioner/reserved_block.rb +19 -3
- data/app/queries/spree_cm_commissioner/inventory_hold_insights_query.rb +151 -0
- data/app/queries/spree_cm_commissioner/inventory_hold_overview_query.rb +79 -0
- data/app/services/spree_cm_commissioner/integrations/base/sync_result.rb +7 -0
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/external_client/client.rb +24 -7
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/external_client/connection.rb +19 -13
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_item_availability.rb +12 -6
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_locations.rb +10 -3
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_routes.rb +11 -2
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_seat_layout.rb +8 -59
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_trips.rb +55 -35
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/resources/trip.rb +0 -4
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/route_freshness.rb +48 -0
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/seat_layout_freshness.rb +46 -0
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/sync_manager.rb +5 -6
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/sync_strategies/full_sync_strategy.rb +32 -15
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/sync_strategies/incremental_sync_strategy.rb +43 -40
- data/app/services/spree_cm_commissioner/integrations/call_limiter.rb +89 -0
- data/app/services/spree_cm_commissioner/integrations/polling_scheduler.rb +13 -7
- data/app/services/spree_cm_commissioner/integrations/sync_backoff.rb +61 -0
- data/app/services/spree_cm_commissioner/seats/apply_locks.rb +13 -7
- data/app/services/spree_cm_commissioner/seats/block_history.rb +168 -0
- data/app/services/spree_cm_commissioner/seats/lock_state.rb +3 -1
- data/app/services/spree_cm_commissioner/seats/swap_seats.rb +265 -0
- data/app/services/spree_cm_commissioner/seats/unlock_blocks.rb +11 -12
- data/app/services/spree_cm_commissioner/voting_credits/de_allocate.rb +11 -0
- data/app/views/spree/admin/inventory_holds/_insights.html.erb +99 -0
- data/app/views/spree/admin/inventory_holds/_insights_drawer.html.erb +83 -0
- data/app/views/spree/admin/inventory_holds/_order_status.html.erb +9 -0
- data/app/views/spree/admin/inventory_holds/_overview.html.erb +109 -0
- data/app/views/spree/admin/inventory_holds/_section.html.erb +5 -0
- data/app/views/spree/admin/inventory_holds/_stat.html.erb +4 -0
- data/app/views/spree/admin/inventory_holds/index.html.erb +12 -1
- data/config/routes.rb +4 -0
- data/lib/spree_cm_commissioner/version.rb +1 -1
- metadata +18 -2
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
require 'zlib'
|
|
2
|
+
|
|
3
|
+
module SpreeCmCommissioner
|
|
4
|
+
module Seats
|
|
5
|
+
# Swaps the seats assigned to two tickets, or moves one ticket onto an empty seat -- the admin
|
|
6
|
+
# picks two blocks, which code path runs is derived here from each block's live
|
|
7
|
+
# ReservedBlock status, never chosen by the caller.
|
|
8
|
+
#
|
|
9
|
+
# success(blocks: [Block, Block])
|
|
10
|
+
# | failure(nil, :same_block | :block_not_found | :not_a_seat | :block_not_assigned
|
|
11
|
+
# | :different_seat_layout | :different_variant
|
|
12
|
+
# | :seat_locked | :seat_on_hold | :nothing_to_swap
|
|
13
|
+
# | :guest_checked_in | :seat_no_longer_reserved
|
|
14
|
+
# | :target_seat_no_longer_available | :lock_conflict)
|
|
15
|
+
# | failure({ guest_name:, messages: [...] }, :guest_invalid)
|
|
16
|
+
#
|
|
17
|
+
# IMPORTANT: `cm_reserved_blocks` has a blanket unique index on (block_id, inventory_item_id) --
|
|
18
|
+
# a block has exactly one row per inventory item for its whole lifetime, reused across its
|
|
19
|
+
# statuses (the same reason ReservedBlocks::Hold uses find_or_initialize_by(inventory_item:,
|
|
20
|
+
# block:) rather than creating a fresh row each time). That means `block_id` is each row's
|
|
21
|
+
# stable identity, never something this service can repoint -- what actually moves between seats
|
|
22
|
+
# is the *guest*.
|
|
23
|
+
#
|
|
24
|
+
# Swap (both blocks carry a `reserved` row): both existing rows are locked (one `SELECT ... FOR
|
|
25
|
+
# UPDATE ... ORDER BY id`, ascending, so two concurrent swaps touching an overlapping pair always
|
|
26
|
+
# request row locks in the same order and cannot deadlock), re-verified still reserved and not
|
|
27
|
+
# checked in, then their `guest_id`s are exchanged. Neither row's `block_id` ever changes, so
|
|
28
|
+
# neither can collide with the unique index; each produces its own `update`-type PaperTrail
|
|
29
|
+
# version.
|
|
30
|
+
#
|
|
31
|
+
# Move (exactly one block carries a `reserved` row, the other has none or only a `canceled` one):
|
|
32
|
+
# the ticket's existing row is locked, and the target seat's row is found-or-initialized by
|
|
33
|
+
# (block: target_block, inventory_item_id: <the ticket's own inventory item>) -- reusing a
|
|
34
|
+
# `canceled` row there if one exists, same as Hold would. No row to lock at a target that may not
|
|
35
|
+
# exist yet, so a Postgres advisory lock closes the race window instead (same shape as
|
|
36
|
+
# InventoryHolds::Acquire's with_advisory_lock!, keyed by the sorted block-id pair rather than an
|
|
37
|
+
# order id); inside it, the target's row is re-read fresh and re-checked for availability
|
|
38
|
+
# immediately before writing, closing the TOCTOU gap between the admin's view and this write. The
|
|
39
|
+
# source row is left behind as `canceled`, not destroyed, so its guest_id and history survive as
|
|
40
|
+
# the record of where the ticket used to sit; the target row's own `block_id` never changes
|
|
41
|
+
# either -- only its status/guest/gender move onto it.
|
|
42
|
+
class SwapSeats
|
|
43
|
+
prepend ::Spree::ServiceModule::Base
|
|
44
|
+
extend SpreeCmCommissioner::ServiceModuleThrowable
|
|
45
|
+
|
|
46
|
+
def call(block_a_id:, block_b_id:, actor: nil)
|
|
47
|
+
return failure(nil, :same_block) if block_a_id == block_b_id
|
|
48
|
+
|
|
49
|
+
block_a = SpreeCmCommissioner::Block.find_by(id: block_a_id)
|
|
50
|
+
block_b = SpreeCmCommissioner::Block.find_by(id: block_b_id)
|
|
51
|
+
return failure(nil, :block_not_found) if block_a.nil? || block_b.nil?
|
|
52
|
+
|
|
53
|
+
reject_reason = blocks_ineligibility_reason(block_a, block_b)
|
|
54
|
+
return failure(nil, reject_reason) if reject_reason
|
|
55
|
+
|
|
56
|
+
outcome, log_data = dispatch(block_a, block_b, actor)
|
|
57
|
+
return failure(nil, outcome) unless outcome == :ok
|
|
58
|
+
|
|
59
|
+
CmAppLogger.log(label: "#{self.class.name}#call success", data: log_data)
|
|
60
|
+
success(blocks: [block_a.reload, block_b.reload])
|
|
61
|
+
rescue StandardError => e
|
|
62
|
+
error = { error_type: e.class.name.demodulize, block_a_id: block_a_id, block_b_id: block_b_id, message: e.message }
|
|
63
|
+
CmAppLogger.error(label: "#{self.class.name}#call failed", data: error)
|
|
64
|
+
return guest_invalid_failure(e.record) if guest_invalid?(e)
|
|
65
|
+
|
|
66
|
+
failure(nil, e.message)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
# move_guest! runs Guest's own validations, which can fail for reasons unrelated to seats (a
|
|
72
|
+
# guest saved before a validation was added, a seat whose variant no longer matches the ticket).
|
|
73
|
+
# The whole swap has already rolled back by here; this only names the guest and what to fix.
|
|
74
|
+
def guest_invalid?(error)
|
|
75
|
+
error.is_a?(ActiveRecord::RecordInvalid) && error.record.is_a?(SpreeCmCommissioner::Guest)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def guest_invalid_failure(guest)
|
|
79
|
+
failure({ guest_name: guest.full_name.presence, messages: guest.errors.full_messages }, :guest_invalid)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Everything that can be decided from the two blocks alone, before either's live reservation
|
|
83
|
+
# status is even read.
|
|
84
|
+
def blocks_ineligibility_reason(block_a, block_b)
|
|
85
|
+
return :not_a_seat unless block_a.seatable? && block_b.seatable?
|
|
86
|
+
return :block_not_assigned if block_a.variant_id.nil? || block_b.variant_id.nil?
|
|
87
|
+
return :different_seat_layout if block_a.seat_layout_id != block_b.seat_layout_id
|
|
88
|
+
return :different_variant if block_a.variant_id != block_b.variant_id
|
|
89
|
+
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Derives which of Swap/Move applies (or that neither does) from each block's live status, and
|
|
94
|
+
# runs it.
|
|
95
|
+
def dispatch(block_a, block_b, actor)
|
|
96
|
+
reserved_a = block_a.current_reserved_block
|
|
97
|
+
reserved_b = block_b.current_reserved_block
|
|
98
|
+
status_a = status_of(reserved_a)
|
|
99
|
+
status_b = status_of(reserved_b)
|
|
100
|
+
|
|
101
|
+
reject_reason = statuses_ineligibility_reason(status_a, status_b)
|
|
102
|
+
return [reject_reason, nil] if reject_reason
|
|
103
|
+
return swap!(reserved_a, reserved_b, actor) if status_a == :reserved && status_b == :reserved
|
|
104
|
+
return move!(reserved_a, block_b, actor) if status_a == :reserved
|
|
105
|
+
|
|
106
|
+
move!(reserved_b, block_a, actor)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def status_of(reserved_block)
|
|
110
|
+
reserved_block&.status&.to_sym || :available
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# A seat reads "available" to #current_reserved_block whether it was never touched or only has
|
|
114
|
+
# a `canceled` row -- both are valid swap/move targets. `locked`/`on_hold` never are, on either
|
|
115
|
+
# side, and two `available` sides mean neither block carries a ticket to act on.
|
|
116
|
+
def statuses_ineligibility_reason(status_a, status_b)
|
|
117
|
+
return :seat_locked if status_a == :locked || status_b == :locked
|
|
118
|
+
return :seat_on_hold if status_a == :on_hold || status_b == :on_hold
|
|
119
|
+
return :nothing_to_swap if status_a == :available && status_b == :available
|
|
120
|
+
|
|
121
|
+
nil
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def checked_in?(reserved_block)
|
|
125
|
+
guest = reserved_block&.guest
|
|
126
|
+
guest.present? && guest.check_ins.exists?
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Exchanges guest_id (and the other guest-carried fields) between the two rows -- block_id
|
|
130
|
+
# stays put on each, so this can never collide with the (block_id, inventory_item_id) unique
|
|
131
|
+
# index. Runs inside a single transaction with both rows locked by one ordered query, so two
|
|
132
|
+
# concurrent swaps touching an overlapping pair always request Postgres row locks in the same
|
|
133
|
+
# order and cannot deadlock each other.
|
|
134
|
+
def swap!(reserved_a, reserved_b, actor)
|
|
135
|
+
ordered_ids = [reserved_a.id, reserved_b.id].sort
|
|
136
|
+
outcome = nil
|
|
137
|
+
log_data = nil
|
|
138
|
+
|
|
139
|
+
SpreeCmCommissioner::ReservedBlock.transaction(requires_new: true) do
|
|
140
|
+
rows = SpreeCmCommissioner::ReservedBlock.lock.where(id: ordered_ids).order(:id).to_a
|
|
141
|
+
|
|
142
|
+
if rows.size != 2 || rows.any? { |row| !row.reserved? }
|
|
143
|
+
outcome = :seat_no_longer_reserved
|
|
144
|
+
raise ActiveRecord::Rollback
|
|
145
|
+
elsif rows.any? { |row| checked_in?(row) }
|
|
146
|
+
outcome = :guest_checked_in
|
|
147
|
+
raise ActiveRecord::Rollback
|
|
148
|
+
else
|
|
149
|
+
row_a = rows.find { |row| row.id == reserved_a.id }
|
|
150
|
+
row_b = rows.find { |row| row.id == reserved_b.id }
|
|
151
|
+
carry_a = guest_payload(row_a)
|
|
152
|
+
carry_b = guest_payload(row_b)
|
|
153
|
+
|
|
154
|
+
row_a.update!(**carry_b, updated_by: actor)
|
|
155
|
+
row_b.update!(**carry_a, updated_by: actor)
|
|
156
|
+
move_guest!(carry_a[:guest_id], row_b.block_id)
|
|
157
|
+
move_guest!(carry_b[:guest_id], row_a.block_id)
|
|
158
|
+
|
|
159
|
+
outcome = :ok
|
|
160
|
+
log_data = { mode: :swap, reserved_block_ids: ordered_ids, swapped_guest_ids: [carry_a[:guest_id], carry_b[:guest_id]],
|
|
161
|
+
actor_id: actor&.id
|
|
162
|
+
}
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
[outcome, log_data]
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def move!(reserved, target_block, actor)
|
|
170
|
+
return [:guest_checked_in, nil] if checked_in?(reserved)
|
|
171
|
+
|
|
172
|
+
with_advisory_lock(swap_lock_key(reserved.block_id, target_block.id)) { perform_move!(reserved, target_block, actor) }
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def perform_move!(reserved, target_block, actor)
|
|
176
|
+
outcome = nil
|
|
177
|
+
log_data = nil
|
|
178
|
+
|
|
179
|
+
SpreeCmCommissioner::ReservedBlock.transaction(requires_new: true) do
|
|
180
|
+
source_row = SpreeCmCommissioner::ReservedBlock.lock.find_by(id: reserved.id)
|
|
181
|
+
|
|
182
|
+
if source_row.nil? || !source_row.reserved?
|
|
183
|
+
outcome = :seat_no_longer_reserved
|
|
184
|
+
raise ActiveRecord::Rollback
|
|
185
|
+
elsif checked_in?(source_row)
|
|
186
|
+
outcome = :guest_checked_in
|
|
187
|
+
raise ActiveRecord::Rollback
|
|
188
|
+
else
|
|
189
|
+
# find_or_initialize_by, same as ReservedBlocks::Hold -- reuses a `canceled` row at this
|
|
190
|
+
# (block, inventory_item) if one exists, instead of ever creating a second row for the
|
|
191
|
+
# same pair. `.lock` is a no-op on a row that doesn't exist yet; it only matters when one
|
|
192
|
+
# does, which is exactly the case this re-check needs to be safe against a concurrent
|
|
193
|
+
# writer.
|
|
194
|
+
target_row = SpreeCmCommissioner::ReservedBlock.lock.find_or_initialize_by(block: target_block,
|
|
195
|
+
inventory_item_id: source_row.inventory_item_id
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
# Re-read fresh under the lock, not off any memoized association -- this is what closes
|
|
199
|
+
# the TOCTOU gap between the admin's view and this write.
|
|
200
|
+
if target_row.persisted? && target_row.unavailable?
|
|
201
|
+
outcome = :target_seat_no_longer_available
|
|
202
|
+
raise ActiveRecord::Rollback
|
|
203
|
+
else
|
|
204
|
+
target_row.assign_attributes(**guest_payload(source_row), updated_by: actor)
|
|
205
|
+
target_row.save!
|
|
206
|
+
|
|
207
|
+
source_row.update!(status: :canceled, expired_at: nil, updated_by: actor)
|
|
208
|
+
move_guest!(source_row.guest_id, target_block.id)
|
|
209
|
+
|
|
210
|
+
outcome = :ok
|
|
211
|
+
log_data = {
|
|
212
|
+
mode: :move, from_reserved_block_id: source_row.id, to_reserved_block_id: target_row.id,
|
|
213
|
+
from_block_id: source_row.block_id, to_block_id: target_block.id, actor_id: actor&.id
|
|
214
|
+
}
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
[outcome, log_data]
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# The ReservedBlock row is what the seat map reads, but tickets, the guest email and the Telegram
|
|
223
|
+
# order message read Guest#block_id / #seat_number, so both have to move together. Plain
|
|
224
|
+
# update!, same as Guests::UpdateSeat: Guest's own callbacks reassign
|
|
225
|
+
# seat_number and recompute the order's preload_block_ids from all its guests, so a swap between
|
|
226
|
+
# two guests of the same order still ends with the right set once both have moved.
|
|
227
|
+
def move_guest!(guest_id, block_id)
|
|
228
|
+
guest = SpreeCmCommissioner::Guest.find_by(id: guest_id)
|
|
229
|
+
guest.update!(block_id: block_id) if guest && guest.block_id != block_id
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Everything that belongs to the guest's booking, not to the row's (block, inventory_item)
|
|
233
|
+
# identity -- what a swap exchanges and a move carries onto the target row.
|
|
234
|
+
def guest_payload(reserved_block)
|
|
235
|
+
{
|
|
236
|
+
status: :reserved,
|
|
237
|
+
guest_id: reserved_block.guest_id,
|
|
238
|
+
gender: reserved_block.gender,
|
|
239
|
+
expired_at: nil,
|
|
240
|
+
payment_locked_at: reserved_block.payment_locked_at,
|
|
241
|
+
inventory_date: reserved_block.inventory_date
|
|
242
|
+
}
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def swap_lock_key(block_id_a, block_id_b)
|
|
246
|
+
"spree_cm_commissioner:seats_swap:#{[block_id_a, block_id_b].sort.join('-')}"
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Same pg_try_advisory_lock/pg_advisory_unlock shape as InventoryHolds::Acquire, keyed by the
|
|
250
|
+
# block pair instead of an order id -- a seat has no row of its own to lock until the move
|
|
251
|
+
# creates (or revives) one, so this is what stands in for a row lock here.
|
|
252
|
+
def with_advisory_lock(key)
|
|
253
|
+
lock_id = Zlib.crc32(key)
|
|
254
|
+
locked = [true, 't'].include?(ActiveRecord::Base.connection.select_value("SELECT pg_try_advisory_lock(#{lock_id})"))
|
|
255
|
+
return [:lock_conflict, nil] unless locked
|
|
256
|
+
|
|
257
|
+
begin
|
|
258
|
+
yield
|
|
259
|
+
ensure
|
|
260
|
+
ActiveRecord::Base.connection.execute("SELECT pg_advisory_unlock(#{lock_id})")
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
@@ -2,9 +2,12 @@ module SpreeCmCommissioner
|
|
|
2
2
|
module Seats
|
|
3
3
|
# Returns operator-locked seats to sale — the inverse of LockBlocks.
|
|
4
4
|
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
# a
|
|
5
|
+
# Cancels the locked row and gives the seat back to quantity_available. The row is kept rather than
|
|
6
|
+
# destroyed so the seat's PaperTrail history stays reachable from its block (Seats::BlockHistory) —
|
|
7
|
+
# a destroyed row's versions are keyed only by its old id. Hold/Reserve/LockBlocks all
|
|
8
|
+
# find_or_initialize_by (block, inventory_item), so the next booking or lock reuses this row. Rows
|
|
9
|
+
# that are no longer `locked` — a customer's booking, or a lock already released — are skipped, so a
|
|
10
|
+
# stale unlock request cannot touch a real reservation.
|
|
8
11
|
#
|
|
9
12
|
# success(applied: [ReservedBlock, ...], skipped: [{ date:, block_label:, reason: }, ...])
|
|
10
13
|
class UnlockBlocks
|
|
@@ -44,17 +47,13 @@ module SpreeCmCommissioner
|
|
|
44
47
|
inventory_item_id: item.id, block_id: block.id
|
|
45
48
|
)
|
|
46
49
|
|
|
47
|
-
# Absent, or belongs to a customer now, or already released — not ours to
|
|
50
|
+
# Absent, or belongs to a customer now, or already released — not ours to release.
|
|
48
51
|
return :not_locked if reserved_block.nil? || !reserved_block.locked?
|
|
49
52
|
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
|
|
54
|
-
# rubocop:disable Rails/SkipsModelValidations
|
|
55
|
-
reserved_block.update_columns(updated_by_id: actor.id) if actor.present?
|
|
56
|
-
# rubocop:enable Rails/SkipsModelValidations
|
|
57
|
-
reserved_block.destroy!
|
|
53
|
+
# Reason/note are cleared so a later reuse of this row doesn't carry a stale lock reason; the
|
|
54
|
+
# lock itself stays readable in the versions. updated_by is what history attributes the
|
|
55
|
+
# release to — the async lock job runs with no whodunnit.
|
|
56
|
+
reserved_block.update!(status: :canceled, lock_reason: nil, lock_note: nil, updated_by: actor)
|
|
58
57
|
|
|
59
58
|
# lock: false — we are already inside item.with_lock. warm_redis: false — the whole
|
|
60
59
|
# grid was warmed once in each_block_date.
|
|
@@ -10,6 +10,10 @@ module SpreeCmCommissioner
|
|
|
10
10
|
def call
|
|
11
11
|
return true if already_canceled?
|
|
12
12
|
|
|
13
|
+
# Every canceled order lands here, but only paid vote packages get a `purchase` transaction
|
|
14
|
+
# (free-claim packages record `free_claim` only), so anything else has nothing to refund.
|
|
15
|
+
return true unless paid_vote_line_items?
|
|
16
|
+
|
|
13
17
|
# DeAllocate#call
|
|
14
18
|
# → purchase_transaction_exists? → false
|
|
15
19
|
# → raise RuntimeError
|
|
@@ -41,6 +45,13 @@ module SpreeCmCommissioner
|
|
|
41
45
|
false
|
|
42
46
|
end
|
|
43
47
|
|
|
48
|
+
def paid_vote_line_items?
|
|
49
|
+
order.line_items
|
|
50
|
+
.joins(variant: :product)
|
|
51
|
+
.where(spree_products: { type: SpreeCmCommissioner::ShowEpisode.name, product_type: :vote_package })
|
|
52
|
+
.any? { |line_item| !line_item.product.claimable_vote_package }
|
|
53
|
+
end
|
|
54
|
+
|
|
44
55
|
def purchase_transaction_exists?
|
|
45
56
|
SpreeCmCommissioner::VotingCreditTransaction.exists?(
|
|
46
57
|
idempotency_key: "#{order.number}::purchase"
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
<%
|
|
2
|
+
i = @insights
|
|
3
|
+
hold, order, user = i[:hold], i[:order], i[:user]
|
|
4
|
+
q = SpreeCmCommissioner::InventoryHoldInsightsQuery
|
|
5
|
+
uh, ih, uo = i[:user_holds], i[:ip_holds], i[:user_orders]
|
|
6
|
+
verdicts = {
|
|
7
|
+
normal: ['success', 'Looks like a normal customer', 'No test, abandon or fraud signals found.'],
|
|
8
|
+
test: ['info', 'Probably a test order', 'Made by staff or a test account — exclude it from real sales numbers.'],
|
|
9
|
+
abandon: ['warning', 'Likely abandoned', 'This customer keeps holding seats without paying.'],
|
|
10
|
+
fraud: ['danger', 'Suspicious activity', 'Hold pattern looks automated or abusive — check the IP section below.']
|
|
11
|
+
}
|
|
12
|
+
tone, headline, explanation = verdicts[i[:verdict]]
|
|
13
|
+
%>
|
|
14
|
+
|
|
15
|
+
<div class="hi-verdict <%= tone %>">
|
|
16
|
+
<div class="hi-verdict-title"><%= headline %></div>
|
|
17
|
+
<div class="small"><%= explanation %></div>
|
|
18
|
+
<% reasons = i[:flags].values.flatten %>
|
|
19
|
+
<% if reasons.any? %>
|
|
20
|
+
<ul class="mb-0 mt-2 pl-3 small">
|
|
21
|
+
<% reasons.each do |reason| %><li><%= reason %></li><% end %>
|
|
22
|
+
</ul>
|
|
23
|
+
<% end %>
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
<%= render layout: 'section', locals: { title: 'This order', hint: 'Where the customer stopped in checkout.' } do %>
|
|
27
|
+
<dl class="hi-kv">
|
|
28
|
+
<dt>Order</dt><dd><%= order ? link_to(order.number, cart_admin_order_path(order)) : '—' %> · <%= order ? Spree::Money.new(order.total, currency: order.currency) : '—' %></dd>
|
|
29
|
+
<dt>Checkout step</dt><dd><%= order&.state&.humanize || '—' %></dd>
|
|
30
|
+
<dt>Payment</dt><dd><%= order&.payment_state&.humanize || 'Never reached payment' %></dd>
|
|
31
|
+
<dt>Hold</dt><dd><%= hold.status.humanize %><%= " — #{hold.release_reason.humanize.downcase}" if hold.release_reason %> · <%= hold.total_quantity_on_hold.to_i %> seats/items</dd>
|
|
32
|
+
</dl>
|
|
33
|
+
<% end %>
|
|
34
|
+
|
|
35
|
+
<%= render layout: 'section', locals: { title: 'Customer history', hint: 'Many paid orders = trusted customer. Zero paid orders = new, stuck, or not a real buyer.' } do %>
|
|
36
|
+
<div class="mb-2 small">
|
|
37
|
+
<%= user ? link_to(user.email, admin_user_path(user)) : (order&.email.presence || 'Guest checkout') %>
|
|
38
|
+
<% i[:roles].each do |role| %><span class="badge badge-dark ml-1"><%= role %></span><% end %>
|
|
39
|
+
</div>
|
|
40
|
+
<div class="hi-stats">
|
|
41
|
+
<%= render 'stat', value: uo[:completed], label: 'Paid orders', tone: ('success' if uo[:completed].positive?) %>
|
|
42
|
+
<%= render 'stat', value: uo[:incomplete], label: 'Unfinished orders', tone: ('warning' if uo[:completed].zero? && uo[:incomplete].positive?) %>
|
|
43
|
+
<%= render 'stat', value: uo[:last_completed_at] ? time_ago_in_words(uo[:last_completed_at]) + ' ago' : 'Never', label: 'Last paid' %>
|
|
44
|
+
</div>
|
|
45
|
+
<% end %>
|
|
46
|
+
|
|
47
|
+
<%= render layout: 'section', locals: { title: 'How fast is this user holding seats?', hint: 'Counted up to the moment this hold was made. A real person rarely makes more than 1–2 holds in 5 minutes.' } do %>
|
|
48
|
+
<div class="hi-stats">
|
|
49
|
+
<%= render 'stat', value: uh[:in_5m], label: 'Holds in 5 min' %>
|
|
50
|
+
<%= render 'stat', value: uh[:concurrent], label: 'Open right now', tone: ('danger' if uh[:concurrent] >= q::USER_CONCURRENT_HOLDS) %>
|
|
51
|
+
<%= render 'stat', value: uh[:abandoned], label: 'Expired unpaid', tone: ('warning' if uh[:abandoned] >= q::USER_ABANDONED_NO_CONVERT && uh[:converted].zero?) %>
|
|
52
|
+
<%= render 'stat', value: "#{uh[:converted]} / #{uh[:total]}", label: 'Paid / all holds' %>
|
|
53
|
+
</div>
|
|
54
|
+
<% end %>
|
|
55
|
+
|
|
56
|
+
<%= render layout: 'section', locals: { title: "Same internet address (IP #{hold.ip_address.presence || 'unknown'})", hint: 'Everyone connecting from this IP. Many accounts or many holds in a few minutes from one IP usually means a bot or one person with several accounts. A few users can also be shared office/café WiFi.' } do %>
|
|
57
|
+
<div class="hi-stats mb-3">
|
|
58
|
+
<%= render 'stat', value: ih[:in_3m], label: 'Holds in 3 min', tone: ('danger' if ih[:in_3m] >= q::IP_BURST_5M) %>
|
|
59
|
+
<%= render 'stat', value: ih[:in_5m], label: 'Holds in 5 min', tone: ('danger' if ih[:in_5m] >= q::IP_BURST_5M) %>
|
|
60
|
+
<%= render 'stat', value: ih[:distinct_users_24h], label: 'Accounts in 24h', tone: ('danger' if ih[:distinct_users_24h] >= q::IP_DISTINCT_USERS_24H) %>
|
|
61
|
+
<%= render 'stat', value: "#{ih[:converted]} / #{ih[:total]}", label: 'Paid / all holds' %>
|
|
62
|
+
</div>
|
|
63
|
+
<% if i[:ip_recent_holds].any? %>
|
|
64
|
+
<div class="small text-muted mb-1">Latest other holds from this IP</div>
|
|
65
|
+
<table class="table table-sm small mb-1">
|
|
66
|
+
<thead class="text-muted"><tr><th>Customer</th><th>Order</th><th>Order status</th><th>Hold</th><th></th></tr></thead>
|
|
67
|
+
<% i[:ip_recent_holds].each do |h| %>
|
|
68
|
+
<tr>
|
|
69
|
+
<td class="text-truncate" style="max-width:160px;"><%= h[:email] || 'Guest' %></td>
|
|
70
|
+
<td><%= h[:order_number] || '—' %></td>
|
|
71
|
+
<td class="no-wrap"><%= render 'order_status', state: h[:order_state], payment_state: h[:payment_state] %></td>
|
|
72
|
+
<td><span class="badge badge-light"><%= h[:status].to_s.humanize %></span></td>
|
|
73
|
+
<td class="text-muted no-wrap text-right"><%= time_ago_in_words(h[:created_at]) %> ago</td>
|
|
74
|
+
</tr>
|
|
75
|
+
<% end %>
|
|
76
|
+
</table>
|
|
77
|
+
<%= link_to 'See all holds from this IP →', admin_inventory_holds_path(q: { ip_address_eq: hold.ip_address }), class: 'small' %>
|
|
78
|
+
<% else %>
|
|
79
|
+
<div class="small text-muted">No other holds from this IP.</div>
|
|
80
|
+
<% end %>
|
|
81
|
+
<% end %>
|
|
82
|
+
|
|
83
|
+
<%= render layout: 'section', locals: { title: "Customer's recent orders", hint: 'Last 10 orders, newest first. A long list of unfinished carts with no paid order is a warning sign.' } do %>
|
|
84
|
+
<% if i[:recent_orders].any? %>
|
|
85
|
+
<table class="table table-sm small mb-0">
|
|
86
|
+
<thead class="text-muted"><tr><th>Order</th><th>Order status</th><th class="text-right">Total</th><th></th></tr></thead>
|
|
87
|
+
<% i[:recent_orders].each do |o| %>
|
|
88
|
+
<tr>
|
|
89
|
+
<td><%= o[:number] %></td>
|
|
90
|
+
<td class="no-wrap"><%= render 'order_status', state: o[:state], payment_state: o[:payment_state] %></td>
|
|
91
|
+
<td class="text-right"><%= Spree::Money.new(o[:total], currency: o[:currency]) %></td>
|
|
92
|
+
<td class="text-muted no-wrap text-right"><%= time_ago_in_words(o[:created_at]) %> ago</td>
|
|
93
|
+
</tr>
|
|
94
|
+
<% end %>
|
|
95
|
+
</table>
|
|
96
|
+
<% else %>
|
|
97
|
+
<div class="small text-muted">No orders.</div>
|
|
98
|
+
<% end %>
|
|
99
|
+
<% end %>
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
<%# Hidden until an Insights / Overall insights button is clicked; the body is fetched from its data-url. %>
|
|
2
|
+
<div id="hold_insights_backdrop" class="hold-insights-backdrop"></div>
|
|
3
|
+
<div id="hold_insights_drawer" class="hold-insights-drawer" role="dialog" aria-labelledby="hold_insights_title">
|
|
4
|
+
<div class="hold-insights-header">
|
|
5
|
+
<h5 class="mb-0" id="hold_insights_title">Hold insights</h5>
|
|
6
|
+
<button type="button" class="btn btn-sm btn-outline-secondary" data-dismiss="drawer" aria-label="Close">×</button>
|
|
7
|
+
</div>
|
|
8
|
+
<div id="hold_insights_body" class="hold-insights-body"></div>
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
<style>
|
|
12
|
+
.hold-insights-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,.4); opacity: 0; visibility: hidden; transition: opacity .2s ease; z-index: 1040; }
|
|
13
|
+
.hold-insights-backdrop.open { opacity: 1; visibility: visible; }
|
|
14
|
+
.hold-insights-drawer { position: fixed; top: 0; right: 0; height: 100%; width: 600px; max-width: 92vw; background: #fff; box-shadow: -2px 0 12px rgba(0,0,0,.2); transform: translateX(100%); transition: transform .25s ease; z-index: 1050; display: flex; flex-direction: column; }
|
|
15
|
+
.hold-insights-drawer.open { transform: translateX(0); }
|
|
16
|
+
.hold-insights-header { display: flex; align-items: center; justify-content: space-between; padding: 1rem 1.25rem; border-bottom: 1px solid #e9ecef; }
|
|
17
|
+
.hold-insights-body { padding: 1.25rem; overflow-y: auto; flex: 1; }
|
|
18
|
+
.hold-insights-body { background: #f8f9fa; }
|
|
19
|
+
.hi-section { background: #fff; border: 1px solid #e9ecef; border-radius: .5rem; padding: .875rem 1rem; margin-bottom: 1rem; }
|
|
20
|
+
.hi-section-title { font-weight: 600; }
|
|
21
|
+
.hi-section-hint { font-size: .8rem; color: #6c757d; margin-bottom: .75rem; }
|
|
22
|
+
.hi-stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(115px, 1fr)); gap: .5rem; }
|
|
23
|
+
.hi-stat { background: #f1f3f5; border-radius: .375rem; padding: .5rem .75rem; }
|
|
24
|
+
.hi-stat-value { font-size: 1.25rem; font-weight: 600; line-height: 1.2; }
|
|
25
|
+
.hi-stat-label { font-size: .75rem; color: #6c757d; }
|
|
26
|
+
.hi-stat.success { background: #e6f4ea; } .hi-stat.success .hi-stat-value { color: #1e7e34; }
|
|
27
|
+
.hi-stat.warning { background: #fff4e0; } .hi-stat.warning .hi-stat-value { color: #b86e00; }
|
|
28
|
+
.hi-stat.danger { background: #fdecea; } .hi-stat.danger .hi-stat-value { color: #c0392b; }
|
|
29
|
+
.hi-verdict { border-radius: .5rem; padding: .875rem 1rem; margin-bottom: 1rem; border-left: 4px solid; }
|
|
30
|
+
.hi-verdict-title { font-size: 1.05rem; font-weight: 600; }
|
|
31
|
+
.hi-verdict.success { background: #e6f4ea; border-color: #28a745; }
|
|
32
|
+
.hi-verdict.info { background: #e7f3fb; border-color: #17a2b8; }
|
|
33
|
+
.hi-verdict.warning { background: #fff4e0; border-color: #f0a500; }
|
|
34
|
+
.hi-verdict.danger { background: #fdecea; border-color: #dc3545; }
|
|
35
|
+
.hi-kv { display: grid; grid-template-columns: 110px 1fr; gap: .25rem .75rem; margin: 0; font-size: .875rem; }
|
|
36
|
+
.hi-kv dt { color: #6c757d; font-weight: normal; }
|
|
37
|
+
.hi-kv dd { margin: 0; }
|
|
38
|
+
.hi-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 4px; }
|
|
39
|
+
</style>
|
|
40
|
+
|
|
41
|
+
<script>
|
|
42
|
+
(function() {
|
|
43
|
+
var drawer = document.getElementById('hold_insights_drawer');
|
|
44
|
+
var backdrop = document.getElementById('hold_insights_backdrop');
|
|
45
|
+
var body = document.getElementById('hold_insights_body');
|
|
46
|
+
var title = document.getElementById('hold_insights_title');
|
|
47
|
+
var requestId = 0;
|
|
48
|
+
|
|
49
|
+
function close() {
|
|
50
|
+
drawer.classList.remove('open');
|
|
51
|
+
backdrop.classList.remove('open');
|
|
52
|
+
document.body.style.removeProperty('overflow');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
backdrop.addEventListener('click', close);
|
|
56
|
+
drawer.querySelector('[data-dismiss="drawer"]').addEventListener('click', close);
|
|
57
|
+
|
|
58
|
+
document.querySelectorAll('.js-hold-insights').forEach(function(button) {
|
|
59
|
+
button.addEventListener('click', function() {
|
|
60
|
+
var current = ++requestId;
|
|
61
|
+
title.textContent = button.dataset.title;
|
|
62
|
+
body.innerHTML = '<p class="text-muted">Loading…</p>';
|
|
63
|
+
drawer.classList.add('open');
|
|
64
|
+
backdrop.classList.add('open');
|
|
65
|
+
document.body.style.overflow = 'hidden';
|
|
66
|
+
|
|
67
|
+
fetch(button.dataset.url, { credentials: 'same-origin', headers: { 'Accept': 'text/html' } })
|
|
68
|
+
.then(function(response) {
|
|
69
|
+
if (!response.ok) throw new Error('Request failed (' + response.status + ')');
|
|
70
|
+
return response.text();
|
|
71
|
+
})
|
|
72
|
+
.then(function(html) { if (current === requestId) body.innerHTML = html; })
|
|
73
|
+
.catch(function(error) {
|
|
74
|
+
if (current !== requestId) return;
|
|
75
|
+
var message = document.createElement('p');
|
|
76
|
+
message.className = 'text-danger';
|
|
77
|
+
message.textContent = error.message;
|
|
78
|
+
body.replaceChildren(message);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
})();
|
|
83
|
+
</script>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<%# Checkout step + payment state, shared by the holds table and the insights drawer. %>
|
|
2
|
+
<% if state %>
|
|
3
|
+
<span class="badge <%= state == 'complete' ? 'badge-success' : 'badge-light' %>"><%= state.humanize %></span>
|
|
4
|
+
<% if payment_state %>
|
|
5
|
+
<span class="badge <%= { 'paid' => 'badge-success', 'failed' => 'badge-danger', 'void' => 'badge-secondary' }[payment_state] || 'badge-warning' %>"><%= payment_state.humanize %></span>
|
|
6
|
+
<% end %>
|
|
7
|
+
<% else %>
|
|
8
|
+
<span class="text-muted">—</span>
|
|
9
|
+
<% end %>
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
<%
|
|
2
|
+
o = @overview
|
|
3
|
+
f = o[:funnel]
|
|
4
|
+
pct = ->(n) { f[:total].zero? ? 0 : (100.0 * n / f[:total]).round(1) }
|
|
5
|
+
segments = [
|
|
6
|
+
[:converted, 'Paid', 'bg-success'],
|
|
7
|
+
[:open, 'Still in checkout', 'bg-primary'],
|
|
8
|
+
[:cart_abandoned, 'Left at cart', 'bg-warning'],
|
|
9
|
+
[:payment_abandoned, 'Left at payment', 'bg-danger'],
|
|
10
|
+
[:user_canceled, 'Canceled', 'bg-secondary'],
|
|
11
|
+
[:cart_changed, 'Changed cart', 'bg-info']
|
|
12
|
+
]
|
|
13
|
+
%>
|
|
14
|
+
|
|
15
|
+
<p class="small text-muted">All holds matching the table filters since <strong><%= order_time(@overview_from) %></strong>.</p>
|
|
16
|
+
|
|
17
|
+
<%= render layout: 'section', locals: { title: 'How did holds end?', hint: '"Left at payment" jumping suddenly usually means a payment gateway problem, not customers.' } do %>
|
|
18
|
+
<div class="hi-stats mb-3">
|
|
19
|
+
<%= render 'stat', value: f[:total], label: 'Holds' %>
|
|
20
|
+
<%= render 'stat', value: "#{f[:conversion_rate]}%", label: 'Paid (conversion)', tone: 'success' %>
|
|
21
|
+
<%= render 'stat', value: "#{pct.call(f[:cart_abandoned])}%", label: 'Left at cart', tone: 'warning' %>
|
|
22
|
+
<%= render 'stat', value: "#{pct.call(f[:payment_abandoned])}%", label: 'Left at payment', tone: 'danger' %>
|
|
23
|
+
</div>
|
|
24
|
+
<% if f[:total].positive? %>
|
|
25
|
+
<div class="progress mb-2" style="height: 10px;">
|
|
26
|
+
<% segments.each do |key, _, css| %>
|
|
27
|
+
<div class="progress-bar <%= css %>" style="width: <%= pct.call(f[key]) %>%"></div>
|
|
28
|
+
<% end %>
|
|
29
|
+
</div>
|
|
30
|
+
<div class="small d-flex flex-wrap">
|
|
31
|
+
<% segments.each do |key, label, css| %>
|
|
32
|
+
<span class="mr-3 no-wrap"><span class="hi-dot <%= css %>"></span><%= label %>: <strong><%= f[key] %></strong></span>
|
|
33
|
+
<% end %>
|
|
34
|
+
</div>
|
|
35
|
+
<% end %>
|
|
36
|
+
<% end %>
|
|
37
|
+
|
|
38
|
+
<%= render layout: 'section', locals: { title: 'Suspicious bursts', hint: 'Time is cut into 3- and 5-minute blocks. Each row is one IP making many holds inside one block — a human can do 1–2, a bot does 10+.' } do %>
|
|
39
|
+
<% o[:bursts].each do |bin, rows| %>
|
|
40
|
+
<div class="small font-weight-bold mb-1">Within <%= bin.sub('m', ' minutes') %></div>
|
|
41
|
+
<% if rows.any? %>
|
|
42
|
+
<table class="table table-sm small mb-3">
|
|
43
|
+
<thead class="text-muted"><tr><th>IP</th><th>When</th><th class="text-right">Holds</th><th class="text-right">Accounts</th></tr></thead>
|
|
44
|
+
<% rows.each do |row| %>
|
|
45
|
+
<tr class="<%= 'table-danger' if row[:holds] >= SpreeCmCommissioner::InventoryHoldInsightsQuery::IP_BURST_5M %>">
|
|
46
|
+
<td><%= link_to row[:ip_address], admin_inventory_holds_path(q: { ip_address_eq: row[:ip_address] }) %></td>
|
|
47
|
+
<td class="no-wrap"><%= order_time(row[:started_at]) %></td>
|
|
48
|
+
<td class="text-right"><%= row[:holds] %></td>
|
|
49
|
+
<td class="text-right"><%= row[:users] %></td>
|
|
50
|
+
</tr>
|
|
51
|
+
<% end %>
|
|
52
|
+
</table>
|
|
53
|
+
<% else %>
|
|
54
|
+
<div class="small text-muted mb-3">No IP made more than 1 hold in any <%= bin.sub('m', '-minute') %> block.</div>
|
|
55
|
+
<% end %>
|
|
56
|
+
<% end %>
|
|
57
|
+
<% end %>
|
|
58
|
+
|
|
59
|
+
<%= render layout: 'section', locals: { title: 'Busiest IPs', hint: 'IPs with the most holds. Red = more than 2 accounts on one IP (shared WiFi, or one person with many accounts). Many holds but 0 paid = likely blocking seats.' } do %>
|
|
60
|
+
<% if o[:top_ips].any? %>
|
|
61
|
+
<table class="table table-sm small mb-0">
|
|
62
|
+
<thead class="text-muted"><tr><th>IP</th><th class="text-right">Holds</th><th class="text-right">Accounts</th><th class="text-right">Paid</th></tr></thead>
|
|
63
|
+
<% o[:top_ips].each do |row| %>
|
|
64
|
+
<tr class="<%= 'table-danger' if row[:users] > 2 %>">
|
|
65
|
+
<td><%= link_to row[:ip_address], admin_inventory_holds_path(q: { ip_address_eq: row[:ip_address] }) %></td>
|
|
66
|
+
<td class="text-right"><%= row[:holds] %></td>
|
|
67
|
+
<td class="text-right"><%= row[:users] %></td>
|
|
68
|
+
<td class="text-right"><%= row[:converted] %></td>
|
|
69
|
+
</tr>
|
|
70
|
+
<% end %>
|
|
71
|
+
</table>
|
|
72
|
+
<% else %>
|
|
73
|
+
<div class="small text-muted">No holds with an IP.</div>
|
|
74
|
+
<% end %>
|
|
75
|
+
<% end %>
|
|
76
|
+
|
|
77
|
+
<%= render layout: 'section', locals: { title: 'Customers who hold but never pay', hint: 'Let holds expire and paid 0 times in this period. These block seats from real buyers.' } do %>
|
|
78
|
+
<% if o[:abandoners].any? %>
|
|
79
|
+
<table class="table table-sm small mb-0">
|
|
80
|
+
<thead class="text-muted"><tr><th>Customer</th><th class="text-right">Expired unpaid</th><th class="text-right">All holds</th></tr></thead>
|
|
81
|
+
<% o[:abandoners].each do |row| %>
|
|
82
|
+
<tr>
|
|
83
|
+
<td class="text-truncate" style="max-width:260px;"><%= link_to row[:email], admin_inventory_holds_path(q: { user_id_eq: row[:user_id] }) %></td>
|
|
84
|
+
<td class="text-right"><%= row[:abandoned] %></td>
|
|
85
|
+
<td class="text-right"><%= row[:holds] %></td>
|
|
86
|
+
</tr>
|
|
87
|
+
<% end %>
|
|
88
|
+
</table>
|
|
89
|
+
<% else %>
|
|
90
|
+
<div class="small text-muted">Nobody — every customer with expired holds also paid at least once.</div>
|
|
91
|
+
<% end %>
|
|
92
|
+
<% end %>
|
|
93
|
+
|
|
94
|
+
<%= render layout: 'section', locals: { title: 'Test & staff accounts', hint: 'Holds by staff (admin/vendor role) or test-looking emails. Not real customers — ignore them in sales numbers.' } do %>
|
|
95
|
+
<% if o[:test_accounts].any? %>
|
|
96
|
+
<table class="table table-sm small mb-0">
|
|
97
|
+
<thead class="text-muted"><tr><th>Account</th><th class="text-right">Holds</th><th></th></tr></thead>
|
|
98
|
+
<% o[:test_accounts].each do |row| %>
|
|
99
|
+
<tr>
|
|
100
|
+
<td class="text-truncate" style="max-width:260px;"><%= link_to row[:email], admin_inventory_holds_path(q: { user_id_eq: row[:user_id] }) %></td>
|
|
101
|
+
<td class="text-right"><%= row[:holds] %></td>
|
|
102
|
+
<td class="text-right"><span class="badge <%= row[:staff] ? 'badge-dark' : 'badge-info' %>"><%= row[:staff] ? 'Staff' : 'Test email' %></span></td>
|
|
103
|
+
</tr>
|
|
104
|
+
<% end %>
|
|
105
|
+
</table>
|
|
106
|
+
<% else %>
|
|
107
|
+
<div class="small text-muted">None in this period.</div>
|
|
108
|
+
<% end %>
|
|
109
|
+
<% end %>
|