spree_cm_commissioner 2.12.0.pre.pre1 → 2.12.0
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/Gemfile.lock +1 -1
- data/app/assets/javascripts/spree_cm_commissioner/backend.js +1 -0
- data/app/assets/javascripts/spree_cm_commissioner/copy_to_clipboard.js +49 -0
- data/app/controllers/spree/admin/inventory_locks_controller.rb +134 -0
- data/app/controllers/spree/admin/private_sales_controller.rb +116 -0
- data/app/controllers/spree/api/v2/storefront/draft_orders_controller.rb +6 -1
- data/app/controllers/spree/api/v2/storefront/private_sale_products_controller.rb +34 -0
- data/app/controllers/spree/api/v2/storefront/queue_draft_orders_controller.rb +6 -1
- data/app/controllers/spree/api/v2/storefront/waiting_room_sessions_controller.rb +4 -1
- data/app/controllers/spree/api/v2/tenant/draft_orders_controller.rb +6 -1
- data/app/interactors/spree_cm_commissioner/waiting_room_session_creator.rb +57 -8
- data/app/jobs/spree_cm_commissioner/publish_stock_statuses_to_firestore_job.rb +19 -0
- data/app/jobs/spree_cm_commissioner/stock_status_consolidation_job.rb +27 -0
- data/app/models/spree_cm_commissioner/inventory_item.rb +25 -0
- data/app/models/spree_cm_commissioner/line_item_decorator.rb +4 -0
- data/app/models/spree_cm_commissioner/private_sale.rb +106 -0
- data/app/models/spree_cm_commissioner/taxon_decorator.rb +21 -0
- data/app/models/spree_cm_commissioner/waiting_room_session.rb +9 -0
- data/app/overrides/spree/admin/shared/sub_menu/_product/private_sales_tab.html.erb.deface +3 -0
- data/app/overrides/spree/admin/taxons/_form/live_stock_window_form_date.html.erb.deface +41 -0
- data/app/serializers/spree/v2/storefront/taxon_serializer_decorator.rb +7 -1
- data/app/services/spree_cm_commissioner/draft_orders/create.rb +37 -1
- data/app/services/spree_cm_commissioner/events/publish_stock_statuses_to_firestore.rb +214 -0
- data/app/services/spree_cm_commissioner/inventory_items/bulk_adjust_quantities.rb +1 -1
- data/app/services/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_on_hold.rb +1 -1
- data/app/services/spree_cm_commissioner/private_sales/authorize.rb +41 -0
- data/app/services/spree_cm_commissioner/waiting_room/bypass_eligibility.rb +66 -0
- data/app/views/spree/admin/inventory_locks/index.html.erb +70 -0
- data/app/views/spree/admin/private_sales/_form.html.erb +57 -0
- data/app/views/spree/admin/private_sales/_share_link.html.erb +24 -0
- data/app/views/spree/admin/private_sales/_stock.html.erb +83 -0
- data/app/views/spree/admin/private_sales/edit.html.erb +31 -0
- data/app/views/spree/admin/private_sales/index.html.erb +80 -0
- data/app/views/spree/admin/private_sales/new.html.erb +14 -0
- data/config/initializers/spree_permitted_attributes.rb +2 -0
- data/config/locales/en.yml +79 -0
- data/config/locales/km.yml +11 -0
- data/config/routes.rb +11 -0
- data/db/migrate/20260820100000_create_cm_private_sales.rb +27 -0
- data/db/migrate/20260824120000_add_user_id_to_cm_waiting_room_sessions.rb +6 -0
- data/db/migrate/20260825092416_add_live_stock_window_to_spree_taxons.rb +8 -0
- data/lib/spree_cm_commissioner/test_helper/factories/private_sale_factory.rb +25 -0
- data/lib/spree_cm_commissioner/version.rb +1 -1
- metadata +26 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 10936d8185cc09d4bd8eca8c3c2626cb9bb113d2e94695030be009f9b38edbcc
|
|
4
|
+
data.tar.gz: 8b1493341611af89e52c06017d1abc3ad6adc838a541dd3ec93dd76877985eaf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f945b868565fa7e4f699c07c35f20bb324b4c87d910f96738d4d4049e5b17d7c1455fd3227c4ab699bf85a449349ab4bc0cdd52b1b3d55bd519c48762da39ab6
|
|
7
|
+
data.tar.gz: d6fd25adafc7a2ad5e710043fbd8dc062008292df46d4819598a0a45333fd4dbbccae1428b6bc389ec6c1bcde2ffd9f7ddd1665a5974ff14989148622fabcadd
|
data/Gemfile.lock
CHANGED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Copy-to-clipboard for any element carrying data-copy-value.
|
|
2
|
+
//
|
|
3
|
+
// Delegated from document rather than bound per button: the private sales index renders one of these
|
|
4
|
+
// per row, and re-binding on every Turbo page load would stack duplicate listeners on the same nodes.
|
|
5
|
+
//
|
|
6
|
+
// The icon swap is the whole feedback mechanism — without it a click looks like nothing happened.
|
|
7
|
+
document.addEventListener("click", function (event) {
|
|
8
|
+
const trigger = event.target.closest("[data-copy-value]");
|
|
9
|
+
if (!trigger) return;
|
|
10
|
+
|
|
11
|
+
event.preventDefault();
|
|
12
|
+
copyText(trigger.dataset.copyValue).then(function () {
|
|
13
|
+
confirmCopied(trigger);
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
// navigator.clipboard needs a secure context, which rules it out on plain-http staging boxes.
|
|
18
|
+
// execCommand is deprecated but is the only thing that works there, so it stays as the fallback.
|
|
19
|
+
function copyText(value) {
|
|
20
|
+
if (navigator.clipboard && window.isSecureContext) {
|
|
21
|
+
return navigator.clipboard.writeText(value);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const scratch = document.createElement("textarea");
|
|
25
|
+
scratch.value = value;
|
|
26
|
+
scratch.setAttribute("readonly", "");
|
|
27
|
+
scratch.style.position = "fixed";
|
|
28
|
+
scratch.style.opacity = "0";
|
|
29
|
+
document.body.appendChild(scratch);
|
|
30
|
+
scratch.select();
|
|
31
|
+
document.execCommand("copy");
|
|
32
|
+
document.body.removeChild(scratch);
|
|
33
|
+
|
|
34
|
+
return Promise.resolve();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function confirmCopied(trigger) {
|
|
38
|
+
const done = trigger.querySelector("[data-copy-done]");
|
|
39
|
+
const idle = trigger.querySelector("[data-copy-idle]");
|
|
40
|
+
if (!done || !idle) return;
|
|
41
|
+
|
|
42
|
+
idle.style.display = "none";
|
|
43
|
+
done.style.display = "inline";
|
|
44
|
+
|
|
45
|
+
setTimeout(function () {
|
|
46
|
+
idle.style.display = "inline";
|
|
47
|
+
done.style.display = "none";
|
|
48
|
+
}, 1500);
|
|
49
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Admin
|
|
3
|
+
# Withholds units of one product from public sale.
|
|
4
|
+
#
|
|
5
|
+
# Reached only from a private sale — there is no tab or sidebar entry. `create` is the POST target
|
|
6
|
+
# for the per-variant inputs on Spree::Admin::PrivateSalesController#edit, and `index` is the
|
|
7
|
+
# date-range screen that page links to for permanent-stock products, where one variant has an
|
|
8
|
+
# inventory item per date and a single input cannot express the range.
|
|
9
|
+
#
|
|
10
|
+
# All the accounting lives in InventoryItems::MoveToLocked — this controller resolves the
|
|
11
|
+
# product, calls it, and turns its named failures into flash messages.
|
|
12
|
+
#
|
|
13
|
+
class InventoryLocksController < Spree::Admin::ResourceController
|
|
14
|
+
skip_before_action :load_resource
|
|
15
|
+
|
|
16
|
+
before_action :load_parent
|
|
17
|
+
|
|
18
|
+
# GET /admin/products/:product_id/inventory_locks
|
|
19
|
+
def index
|
|
20
|
+
# Only active items can be locked — a past date cannot be sold, so withholding from it
|
|
21
|
+
# protects nothing (MoveToLocked refuses it outright).
|
|
22
|
+
# Paginated like every other admin index here: a permanent-stock product has one row per
|
|
23
|
+
# variant per date, so an unpaginated page is thousands of rows.
|
|
24
|
+
@inventory_items = @product.inventory_items.active
|
|
25
|
+
.includes(variant: { option_values: :option_type })
|
|
26
|
+
.order(:inventory_date)
|
|
27
|
+
.page(params[:page]).per(50)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# POST /admin/products/:product_id/inventory_locks
|
|
31
|
+
#
|
|
32
|
+
# One action for both directions: a positive quantity locks, a negative one releases. Applied
|
|
33
|
+
# across a date range for permanent-stock products, where each date is its own inventory item —
|
|
34
|
+
# "lock 5" means 5 per date, not 5 across the range.
|
|
35
|
+
def create
|
|
36
|
+
items = target_inventory_items
|
|
37
|
+
return redirect_with_error(:invalid_date_range) if items.nil?
|
|
38
|
+
return redirect_with_error(:no_inventory_item) if items.empty?
|
|
39
|
+
|
|
40
|
+
moved, failed = items.map { |item| move(item) }.partition(&:success?)
|
|
41
|
+
return redirect_with_error(failed.first.error.value) if moved.empty?
|
|
42
|
+
|
|
43
|
+
# Each date is moved independently and nothing can undo one that already went through —
|
|
44
|
+
# Redis has no rollback. So a mixed outcome says how many landed: a bare error would read as
|
|
45
|
+
# "nothing changed" while some dates are already locked.
|
|
46
|
+
if failed.empty?
|
|
47
|
+
flash[:success] = t('.moved', count: moved.size)
|
|
48
|
+
else
|
|
49
|
+
reason = error_sentence(failed.first.error.value)
|
|
50
|
+
flash[:error] = t('.partially_moved', count: moved.size, total: items.size, reason: reason)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
redirect_back_to_caller
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def load_parent
|
|
59
|
+
# Handled here rather than left to ResourceController's rescue_from: its handler redirects to
|
|
60
|
+
# collection_url, which for this controller resolves to a route that does not exist
|
|
61
|
+
# (model_class is InventoryItem, which has no admin resource route) — turning one 500 into
|
|
62
|
+
# another. An unknown or renamed slug belongs back at the product list.
|
|
63
|
+
@product = Spree::Product.find_by(slug: params[:product_id])
|
|
64
|
+
return if @product
|
|
65
|
+
|
|
66
|
+
flash[:error] = t('spree.admin.inventory_locks.create.product_not_found')
|
|
67
|
+
redirect_to spree.admin_products_path
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def move(inventory_item)
|
|
71
|
+
SpreeCmCommissioner::InventoryItems::MoveToLocked.call(
|
|
72
|
+
inventory_item: inventory_item,
|
|
73
|
+
quantity: params[:quantity],
|
|
74
|
+
caller_source: "#{self.class.name}#create"
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# A date range covers one inventory item per date; without one, ONLY the undated row.
|
|
79
|
+
#
|
|
80
|
+
# Never falls back to "every active date". Both callers can omit the range — the private sale's
|
|
81
|
+
# inline button always does — and a permanent-stock variant has a row per date for a year, so
|
|
82
|
+
# withholding across all of them because an operator left "To date" empty is unrecoverable:
|
|
83
|
+
# MoveToLocked's Redis half cannot be rolled back.
|
|
84
|
+
#
|
|
85
|
+
# Returns nil for a malformed range so #create can tell it apart from "found nothing".
|
|
86
|
+
def target_inventory_items
|
|
87
|
+
scope = @product.inventory_items.active.where(variant_id: params[:variant_id])
|
|
88
|
+
return scope.where(inventory_date: nil).to_a if params[:from_date].blank? || params[:to_date].blank?
|
|
89
|
+
|
|
90
|
+
dates = parsed_date_range
|
|
91
|
+
return nil if dates.nil?
|
|
92
|
+
|
|
93
|
+
scope.where(inventory_date: dates).to_a
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# nil when either date is unparseable, or when the range runs backwards — both are operator
|
|
97
|
+
# error, and an empty result would report the misleading "no stock on sale" instead.
|
|
98
|
+
def parsed_date_range
|
|
99
|
+
from = params[:from_date].to_date
|
|
100
|
+
to = params[:to_date].to_date
|
|
101
|
+
return nil if from > to
|
|
102
|
+
|
|
103
|
+
from..to
|
|
104
|
+
# Date::Error subclasses ArgumentError, so this covers both.
|
|
105
|
+
rescue ArgumentError
|
|
106
|
+
nil
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def redirect_with_error(reason)
|
|
110
|
+
flash[:error] = error_sentence(reason)
|
|
111
|
+
redirect_back_to_caller
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# MoveToLocked's named failures carry their sentences; this controller's own reasons (a variant
|
|
115
|
+
# with no inventory item) are local. Falls back to the humanized symbol so a new failure name
|
|
116
|
+
# still reads as English rather than as a missing-translation string.
|
|
117
|
+
def error_sentence(reason)
|
|
118
|
+
service_message = t("spree_cm_commissioner.inventory_items.move_to_locked.#{reason}", default: reason.to_s.humanize)
|
|
119
|
+
|
|
120
|
+
t(".#{reason}", default: service_message)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Posted from the private sale's edit page for undated stock, and from this screen for
|
|
124
|
+
# permanent-stock date ranges, so return to whichever asked.
|
|
125
|
+
def redirect_back_to_caller
|
|
126
|
+
redirect_back fallback_location: admin_product_inventory_locks_path(@product)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def model_class
|
|
130
|
+
SpreeCmCommissioner::InventoryItem
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Admin
|
|
3
|
+
# Managing private sales: which products, sold to whom, until when — and, on edit, how much of
|
|
4
|
+
# each variant to withhold.
|
|
5
|
+
#
|
|
6
|
+
# New is deliberately thin: name, dates and three remote-search pickers, so opening it costs one
|
|
7
|
+
# query for roles and nothing else. Creating redirects straight to edit, which is where the
|
|
8
|
+
# allotment is actually set up now that the products are known.
|
|
9
|
+
#
|
|
10
|
+
class PrivateSalesController < Spree::Admin::ResourceController
|
|
11
|
+
# Also on update: ResourceController re-renders :edit when validation fails, and the stock
|
|
12
|
+
# partial reads these ivars — without this a rejected save is a 500 instead of a form.
|
|
13
|
+
before_action :load_stock_rows, only: %i[edit update]
|
|
14
|
+
before_action :load_assignable_roles, only: %i[new create edit update]
|
|
15
|
+
|
|
16
|
+
# GET /admin/private_sales
|
|
17
|
+
def index
|
|
18
|
+
# Off `collection`, which ResourceController already scoped with accessible_by(current_ability)
|
|
19
|
+
# — going through model_class directly would discard that.
|
|
20
|
+
@private_sales = collection.order(created_at: :desc).page(params[:page]).per(25)
|
|
21
|
+
|
|
22
|
+
load_audience_names
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# PATCH /admin/private_sales/:id/revoke
|
|
26
|
+
#
|
|
27
|
+
# The point of storing the rule rather than signing it into the URL: one sale dies without
|
|
28
|
+
# touching the lock, so every other sale drawing on the same stock keeps working.
|
|
29
|
+
def revoke
|
|
30
|
+
# @private_sale is already loaded and authorized by ResourceController#load_resource.
|
|
31
|
+
@private_sale.revoked!
|
|
32
|
+
|
|
33
|
+
flash[:success] = t('.revoked')
|
|
34
|
+
redirect_to admin_private_sales_path
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
# Names for every product, user and role named by the sales ON THIS PAGE, in three queries.
|
|
40
|
+
# The ids live in jsonb rather than join tables, so there is no association for `includes` to
|
|
41
|
+
# preload — asking each row for its own would be 3 queries per row.
|
|
42
|
+
def load_audience_names
|
|
43
|
+
sales = @private_sales.to_a
|
|
44
|
+
|
|
45
|
+
@product_names = Spree::Product.where(id: sales.flat_map(&:product_ids)).pluck(:id, :name).to_h
|
|
46
|
+
@user_emails = Spree::User.where(id: sales.flat_map(&:user_ids)).pluck(:id, :email).to_h
|
|
47
|
+
@role_names = Spree::Role.where(id: sales.flat_map(&:role_ids))
|
|
48
|
+
.pluck(:id, :presentation, :name)
|
|
49
|
+
.to_h { |id, presentation, name| [id, presentation.presence || name] }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Straight to edit rather than back to the list: a sale is not usable until stock is withheld
|
|
53
|
+
# for it, and edit is where that happens. Landing on the index would hide the next step.
|
|
54
|
+
# non_vendor: a private sale grants access across the store, and this repo's roles include
|
|
55
|
+
# per-vendor and per-event check-in roles whose `presentation` is only unique within a vendor —
|
|
56
|
+
# listing them all renders hundreds of indistinguishable duplicate options.
|
|
57
|
+
def load_assignable_roles
|
|
58
|
+
@assignable_roles = Spree::Role.non_vendor.order(:name)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def location_after_save
|
|
62
|
+
edit_admin_private_sale_path(@private_sale)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Everything the edit page's per-variant lock controls need, in three queries regardless of how
|
|
66
|
+
# many products the sale names.
|
|
67
|
+
#
|
|
68
|
+
# Only UNDATED inventory items are loaded. A permanent-stock product has one row per date —
|
|
69
|
+
# hundreds per variant — which no inline control can express and which would make this page as
|
|
70
|
+
# slow as the table is large. Those link out to the product's own date-range screen instead.
|
|
71
|
+
def load_stock_rows
|
|
72
|
+
product_ids = @private_sale.product_ids
|
|
73
|
+
return @variants_by_product = {} if product_ids.empty?
|
|
74
|
+
|
|
75
|
+
@products_by_id = Spree::Product.where(id: product_ids).index_by(&:id)
|
|
76
|
+
|
|
77
|
+
# Master is excluded when a product has real variants — it carries no stock of its own there
|
|
78
|
+
# and would render as a blank row. A product with no variants sells through master, so it is
|
|
79
|
+
# the fallback. Same rule as Spree::Admin::StockManagementsController.
|
|
80
|
+
# option_values: :option_type, not just :option_values — sku_and_options_text walks the second
|
|
81
|
+
# hop, so preloading one level still fires a query per option value.
|
|
82
|
+
variants = Spree::Variant.where(product_id: product_ids)
|
|
83
|
+
.includes(option_values: :option_type)
|
|
84
|
+
.order(:id)
|
|
85
|
+
by_product = variants.group_by(&:product_id)
|
|
86
|
+
|
|
87
|
+
@variants_by_product = by_product.transform_values do |list|
|
|
88
|
+
list.reject(&:is_master?).presence || list
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
@inventory_item_by_variant = SpreeCmCommissioner::InventoryItem
|
|
92
|
+
.where(variant_id: @variants_by_product.values.flatten.map(&:id), inventory_date: nil)
|
|
93
|
+
.index_by(&:variant_id)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# override — the audience is posted as id arrays by the select2 pickers in _form.
|
|
97
|
+
def permitted_resource_params
|
|
98
|
+
params.require(:private_sale)
|
|
99
|
+
.permit(:name, :expires_at, product_ids: [], role_ids: [], user_ids: [])
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# override — created_by is ours to set, never the form's.
|
|
103
|
+
def build_resource
|
|
104
|
+
model_class.new(created_by: try_spree_current_user)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def collection_url(options = {})
|
|
108
|
+
admin_private_sales_path(options)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def model_class
|
|
112
|
+
SpreeCmCommissioner::PrivateSale
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -10,13 +10,18 @@ module Spree
|
|
|
10
10
|
# Endpoint: POST /api/v2/storefront/draft_orders
|
|
11
11
|
# Params:
|
|
12
12
|
# - line_items: Array of { variant_id:, quantity:, options: {}, public_metadata: {}, private_metadata: {} } (required)
|
|
13
|
+
# - st: private sale token granting this order the product's locked stock (optional).
|
|
13
14
|
def create
|
|
14
15
|
result = SpreeCmCommissioner::DraftOrders::Create.call(
|
|
15
16
|
line_items: draft_order_line_items,
|
|
16
17
|
user: spree_current_user,
|
|
17
18
|
store: current_store,
|
|
18
19
|
currency: current_currency,
|
|
19
|
-
options: {
|
|
20
|
+
options: {
|
|
21
|
+
device_id: device_id,
|
|
22
|
+
last_ip_address: request.remote_ip,
|
|
23
|
+
private_sale_token: params[:st]
|
|
24
|
+
}
|
|
20
25
|
)
|
|
21
26
|
|
|
22
27
|
if result.success?
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Api
|
|
3
|
+
module V2
|
|
4
|
+
module Storefront
|
|
5
|
+
# GET /api/v2/storefront/private_sale_products?st=<private sale token>
|
|
6
|
+
class PrivateSaleProductsController < ::Spree::Api::V2::Storefront::ProductsController
|
|
7
|
+
before_action :require_spree_current_user
|
|
8
|
+
before_action :authorize_private_sale_token!
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
attr_reader :private_sale
|
|
13
|
+
|
|
14
|
+
def authorize_private_sale_token!
|
|
15
|
+
result = SpreeCmCommissioner::PrivateSales::Authorize.call(
|
|
16
|
+
token: params[:st],
|
|
17
|
+
user: spree_current_user
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
return @private_sale = result.value if result.success?
|
|
21
|
+
|
|
22
|
+
render_error_payload(SpreeCmCommissioner::PrivateSales::Authorize.error_message(result.error.value), 403)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# override — the token names the products, so the client sends no ids. Returns them as a
|
|
26
|
+
# collection: one link is an agent's whole allotment across an event, not a single product.
|
|
27
|
+
def scope(**args)
|
|
28
|
+
super.where(id: private_sale.product_ids)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -15,6 +15,7 @@ module Spree
|
|
|
15
15
|
# Endpoint: POST /api/v2/storefront/queue_draft_orders
|
|
16
16
|
# Params: same as POST /api/v2/storefront/draft_orders — line_items: Array of
|
|
17
17
|
# { variant_id:, quantity:, options: {}, public_metadata: {}, private_metadata: {} } (required)
|
|
18
|
+
# st: private sale token granting this order the product's locked stock (optional).
|
|
18
19
|
def create
|
|
19
20
|
result = SpreeCmCommissioner::QueueDraftOrders::Enqueue.call(
|
|
20
21
|
create_type: 'generic',
|
|
@@ -23,7 +24,11 @@ module Spree
|
|
|
23
24
|
user: spree_current_user,
|
|
24
25
|
store: current_store,
|
|
25
26
|
currency: current_currency,
|
|
26
|
-
options: {
|
|
27
|
+
options: {
|
|
28
|
+
device_id: device_id,
|
|
29
|
+
last_ip_address: request.remote_ip,
|
|
30
|
+
private_sale_token: params[:st]
|
|
31
|
+
}
|
|
27
32
|
},
|
|
28
33
|
locale: I18n.locale.to_s
|
|
29
34
|
)
|
|
@@ -15,7 +15,10 @@ module Spree
|
|
|
15
15
|
remote_ip: request.remote_ip,
|
|
16
16
|
waiting_guest_firebase_doc_id: params[:waiting_guest_firebase_doc_id],
|
|
17
17
|
page_path: params[:page_path],
|
|
18
|
-
device_attributes: permitted_device_attributes
|
|
18
|
+
device_attributes: permitted_device_attributes,
|
|
19
|
+
bypass_jwt: params[:bypass_jwt],
|
|
20
|
+
bypass_order_token: request.headers['X-Spree-Order-Token'],
|
|
21
|
+
user: spree_current_user
|
|
19
22
|
)
|
|
20
23
|
|
|
21
24
|
if context.success?
|
|
@@ -7,6 +7,7 @@ module Spree
|
|
|
7
7
|
# Endpoint: POST /api/v2/tenant/draft_orders
|
|
8
8
|
# Params:
|
|
9
9
|
# - line_items: Array of { variant_id:, quantity:, options: {}, public_metadata: {}, private_metadata: {} } (required)
|
|
10
|
+
# - st: private sale token granting this order the product's locked stock (optional).
|
|
10
11
|
def create
|
|
11
12
|
result = SpreeCmCommissioner::DraftOrders::Create.call(
|
|
12
13
|
line_items: draft_order_line_items,
|
|
@@ -14,7 +15,11 @@ module Spree
|
|
|
14
15
|
store: current_store,
|
|
15
16
|
currency: current_currency,
|
|
16
17
|
tenant: @tenant,
|
|
17
|
-
options: {
|
|
18
|
+
options: {
|
|
19
|
+
device_id: params[:device_id],
|
|
20
|
+
last_ip_address: request.remote_ip,
|
|
21
|
+
private_sale_token: params[:st]
|
|
22
|
+
}
|
|
18
23
|
)
|
|
19
24
|
|
|
20
25
|
if result.success?
|
|
@@ -4,7 +4,7 @@ require 'zlib'
|
|
|
4
4
|
module SpreeCmCommissioner
|
|
5
5
|
class WaitingRoomSessionCreator < BaseInteractor
|
|
6
6
|
delegate :remote_ip, :waiting_guest_firebase_doc_id, :page_path, :tenant_id,
|
|
7
|
-
:device_attributes, to: :context
|
|
7
|
+
:device_attributes, :bypass_jwt, :bypass_order_token, :user, to: :context
|
|
8
8
|
|
|
9
9
|
SESSION_CREATION_LOCK_KEY = 'waiting_room_session_creation'.freeze
|
|
10
10
|
|
|
@@ -18,6 +18,12 @@ module SpreeCmCommissioner
|
|
|
18
18
|
# How long a session's JWT/expired_at is valid before it needs renewal. 3min default.
|
|
19
19
|
WAITING_ROOM_SESSION_EXPIRE_DURATION_IN_SECOND = (ENV['WAITING_ROOM_SESSION_EXPIRE_DURATION_IN_SECOND'] || (60 * 3)).to_i
|
|
20
20
|
|
|
21
|
+
# A verified bypasser is admitted past the normal max, but only up to this bounded headroom
|
|
22
|
+
# (max * multiplier) — never without limit — so a burst of stuck-payment returns can't push the
|
|
23
|
+
# protected pages far past capacity. Default 1.1 (10% headroom); 1 gates bypassers at max. See
|
|
24
|
+
# #bypass_full?.
|
|
25
|
+
WAITING_ROOM_BYPASS_HEADROOM_MULTIPLIER = (ENV['WAITING_ROOM_BYPASS_HEADROOM_MULTIPLIER'] || 1.1).to_f
|
|
26
|
+
|
|
21
27
|
# Device attributes the client may set on the session. device_id and
|
|
22
28
|
# device_fingerprint are real columns; the rest are virtual columns backed by
|
|
23
29
|
# public/private metadata (see WaitingRoomSession). Anything else the client
|
|
@@ -76,11 +82,17 @@ module SpreeCmCommissioner
|
|
|
76
82
|
# a row that's lapsed but still under its own max_expired_at — goes through the normal
|
|
77
83
|
# advisory-lock + full? gated path.
|
|
78
84
|
def create_new_session
|
|
85
|
+
# Checked before the lock, not inside it — the lock is the capacity serialization point and
|
|
86
|
+
# shouldn't be held any longer than necessary. A normal joiner short-circuits here for free.
|
|
87
|
+
bypass = bypass_session?
|
|
88
|
+
|
|
79
89
|
# Advisory lock ensures the capacity check and session creation are atomic across all app instances.
|
|
80
90
|
advisory_lock(SESSION_CREATION_LOCK_KEY) do
|
|
81
|
-
|
|
91
|
+
# Normal joiner is gated at max (full?); a verified bypasser at the higher, bounded ceiling
|
|
92
|
+
# (bypass_full?), so a full room still admits bypassers only up to the overshoot.
|
|
93
|
+
return context.fail!(message: 'sessions_reach_it_maximum') if bypass ? bypass_full? : full?
|
|
82
94
|
|
|
83
|
-
assign_token_and_create_session_to_db
|
|
95
|
+
assign_token_and_create_session_to_db(bypass: bypass)
|
|
84
96
|
end
|
|
85
97
|
|
|
86
98
|
# commented because of following bug: https://github.com/channainfo/commissioner/issues/2185
|
|
@@ -88,14 +100,42 @@ module SpreeCmCommissioner
|
|
|
88
100
|
# call_other_waiting_guests
|
|
89
101
|
end
|
|
90
102
|
|
|
103
|
+
# Whether this admission may skip full?. The client relays a signed token plus the order token it
|
|
104
|
+
# was signed with (the X-Spree-Order-Token header) — WaitingRoom::BypassEligibility decides, from that
|
|
105
|
+
# single verified decode plus the caller's own auth (see BypassEligibility#owned_by_caller?),
|
|
106
|
+
# whether it's genuine. Both must be present before we even look, so a normal joiner pays nothing.
|
|
107
|
+
def bypass_session?
|
|
108
|
+
return false if bypass_jwt.blank? || bypass_order_token.blank?
|
|
109
|
+
|
|
110
|
+
bypass_result.success?
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Memoized so #apply_bypass_attributes can read the verified payment number/source back off the
|
|
114
|
+
# result (result.value) without re-running the verify. `user` is the same doorkeeper-resolved
|
|
115
|
+
# caller already stamped on the session (nil for an app token/anonymous request).
|
|
116
|
+
def bypass_result
|
|
117
|
+
@bypass_result ||= SpreeCmCommissioner::WaitingRoom::BypassEligibility.call(
|
|
118
|
+
jwt: bypass_jwt, order_token: bypass_order_token, user: user
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
|
|
91
122
|
def full?
|
|
92
|
-
|
|
123
|
+
SpreeCmCommissioner::WaitingRoomSession.active.count >= max_sessions_count
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Higher, bounded ceiling used only for a verified bypasser: max plus the headroom
|
|
127
|
+
# (max * WAITING_ROOM_BYPASS_HEADROOM_MULTIPLIER, floored). Once the room reaches it, even a
|
|
128
|
+
# bypasser is rejected. Never called on the normal-joiner path, so the hot path is unchanged.
|
|
129
|
+
def bypass_full?
|
|
130
|
+
SpreeCmCommissioner::WaitingRoomSession.active.count >= (max_sessions_count * WAITING_ROOM_BYPASS_HEADROOM_MULTIPLIER).floor
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def max_sessions_count
|
|
134
|
+
Rails.cache.fetch('waiting_room/max_sessions_count', expires_in: 1.hour) do
|
|
93
135
|
fetcher = SpreeCmCommissioner::WaitingRoomSystemMetadataFetcher.new(firestore: firestore)
|
|
94
136
|
fetcher.load_document_data
|
|
95
137
|
fetcher.max_sessions_count_with_min
|
|
96
138
|
end
|
|
97
|
-
|
|
98
|
-
SpreeCmCommissioner::WaitingRoomSession.active.count >= max
|
|
99
139
|
end
|
|
100
140
|
|
|
101
141
|
def advisory_lock(lock_key)
|
|
@@ -111,7 +151,7 @@ module SpreeCmCommissioner
|
|
|
111
151
|
context.jwt_token = JWT.encode(payload, ENV.fetch('WAITING_ROOM_SESSION_SIGNATURE'), 'HS256')
|
|
112
152
|
end
|
|
113
153
|
|
|
114
|
-
def assign_token_and_create_session_to_db
|
|
154
|
+
def assign_token_and_create_session_to_db(bypass: false)
|
|
115
155
|
session = SpreeCmCommissioner::WaitingRoomSession.where(guest_identifier: waiting_guest_firebase_doc_id).first_or_initialize
|
|
116
156
|
context.room_session = session
|
|
117
157
|
|
|
@@ -123,9 +163,17 @@ module SpreeCmCommissioner
|
|
|
123
163
|
generate_jwt_token(session.max_expired_at)
|
|
124
164
|
|
|
125
165
|
apply_session_attributes(session)
|
|
166
|
+
apply_bypass_attributes(session) if bypass
|
|
126
167
|
session.save!
|
|
127
168
|
end
|
|
128
169
|
|
|
170
|
+
# Server-set audit trail for a granted bypass — never client-supplied.
|
|
171
|
+
def apply_bypass_attributes(session)
|
|
172
|
+
session.bypassed = true
|
|
173
|
+
session.bypass_payment_number = bypass_result.value[:payment_number]
|
|
174
|
+
session.bypass_source = bypass_result.value[:source]
|
|
175
|
+
end
|
|
176
|
+
|
|
129
177
|
def apply_session_attributes(session)
|
|
130
178
|
session.assign_attributes(
|
|
131
179
|
{
|
|
@@ -133,7 +181,8 @@ module SpreeCmCommissioner
|
|
|
133
181
|
expired_at: expired_at,
|
|
134
182
|
remote_ip: remote_ip,
|
|
135
183
|
page_path: page_path,
|
|
136
|
-
tenant_id: tenant_id
|
|
184
|
+
tenant_id: tenant_id,
|
|
185
|
+
user: user
|
|
137
186
|
}.merge(sanitized_device_attributes)
|
|
138
187
|
)
|
|
139
188
|
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module SpreeCmCommissioner
|
|
2
|
+
# Fired from SpreeCmCommissioner::InventoryItem#publish_live_stock_status_to_firestore
|
|
3
|
+
# (after_commit), one enqueue per event whose stock actually changed, so publishing is near-instant
|
|
4
|
+
# instead of waiting for the next StockStatusConsolidationJob cron tick.
|
|
5
|
+
#
|
|
6
|
+
# ApplicationUniqueJob (args-keyed via activejob-uniqueness, on_conflict: :log) so a burst of
|
|
7
|
+
# concurrent stock changes for the same popular event collapses into one in-flight publish per
|
|
8
|
+
# event_id, instead of one job per changed InventoryItem row.
|
|
9
|
+
class PublishStockStatusesToFirestoreJob < ApplicationUniqueJob
|
|
10
|
+
queue_as :default
|
|
11
|
+
|
|
12
|
+
def perform(event_id:)
|
|
13
|
+
taxon = Spree::Taxon.find_by(id: event_id)
|
|
14
|
+
return unless taxon
|
|
15
|
+
|
|
16
|
+
SpreeCmCommissioner::Events::PublishStockStatusesToFirestore.call(taxon: taxon)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module SpreeCmCommissioner
|
|
2
|
+
# Safety-net reconciliation sweep, run on a low-frequency cron (cm-market-server/config/schedule.yml,
|
|
3
|
+
# `stock_status_consolidation` entry, dispatched through the existing `InvokeJob` cron pattern — same
|
|
4
|
+
# mechanism already used for `WaitingRoom::StampQueuePositionsJob`/`Integrations::PollingSchedulerJob`).
|
|
5
|
+
#
|
|
6
|
+
# SpreeCmCommissioner::PublishStockStatusesToFirestoreJob (enqueued directly off order-complete, see
|
|
7
|
+
# SpreeCmCommissioner::OrderStateMachine) is the primary, near-instant trigger now. This job exists
|
|
8
|
+
# only to catch anything that trigger misses — a failed job, a Firestore hiccup, or stock changing via
|
|
9
|
+
# a path other than order-complete (e.g. an admin stock adjustment) — by re-scanning every event
|
|
10
|
+
# currently inside its live_stock_starts_at/live_stock_ends_at window (see Spree::Taxon.live_stock_active)
|
|
11
|
+
# regardless of whether anything actually changed. Delegates the actual (batched, per-event) Firestore
|
|
12
|
+
# write to Events::PublishStockStatusesToFirestore, same as the real-time job.
|
|
13
|
+
#
|
|
14
|
+
# ApplicationUniqueJob (not ApplicationJob) so a cycle that runs long — many live events, a slow
|
|
15
|
+
# Firestore round trip — can't overlap with the next cron tick a minute later; `perform` takes no
|
|
16
|
+
# arguments, so this is a single global "is a consolidation cycle already in flight" lock, same pattern
|
|
17
|
+
# as WaitingRoom::StampQueuePositionsJob above.
|
|
18
|
+
class StockStatusConsolidationJob < ApplicationUniqueJob
|
|
19
|
+
queue_as :default
|
|
20
|
+
|
|
21
|
+
def perform
|
|
22
|
+
Spree::Taxon.event.live_stock_active.find_each do |taxon|
|
|
23
|
+
SpreeCmCommissioner::Events::PublishStockStatusesToFirestore.call(taxon: taxon)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -4,6 +4,7 @@ module SpreeCmCommissioner
|
|
|
4
4
|
include SpreeCmCommissioner::StoreMetadata
|
|
5
5
|
|
|
6
6
|
MAX_DISPLAY_STOCK = 20
|
|
7
|
+
LOW_STOCK_THRESHOLD = 20
|
|
7
8
|
|
|
8
9
|
# Association
|
|
9
10
|
belongs_to :variant, class_name: 'Spree::Variant', optional: false
|
|
@@ -34,6 +35,7 @@ module SpreeCmCommissioner
|
|
|
34
35
|
before_save -> { self.product_type = variant.product_type }, if: -> { product_type.nil? }
|
|
35
36
|
|
|
36
37
|
after_commit :schedule_product_cache_invalidation
|
|
38
|
+
after_commit :publish_live_stock_status_to_firestore
|
|
37
39
|
|
|
38
40
|
# Only for ecommerce: the app uses product.in_stock? (derived from quantity_available) for product cards.
|
|
39
41
|
# Other types (e.g. accommodation, bus) have many inventory items (including daily-generated ones),
|
|
@@ -54,6 +56,29 @@ module SpreeCmCommissioner
|
|
|
54
56
|
SpreeCmCommissioner::MaintenanceTasks::CacheInvalidation.pending.create_or_find_by(maintainable: variant.product)
|
|
55
57
|
end
|
|
56
58
|
|
|
59
|
+
# The single trigger point for real-time Firestore stock-status publishing — catches every
|
|
60
|
+
# stock-mutation path that eventually saves an InventoryItem row: order complete (unstock),
|
|
61
|
+
# order cancel (restock), hold acquire/release/convert (all synced async via
|
|
62
|
+
# InventoryItems::BulkAdjustQuantities(OnHold), which uses per-record `update!`, so this callback
|
|
63
|
+
# does fire for those, just after that job's own async DB sync lands), and admin stock edits via
|
|
64
|
+
# `adjust_quantity!`. A burst of concurrent changes to the same event collapses into one in-flight
|
|
65
|
+
# publish, since PublishStockStatusesToFirestoreJob is unique per event_id (ApplicationUniqueJob,
|
|
66
|
+
# until_executed).
|
|
67
|
+
#
|
|
68
|
+
# Ecommerce-only and quantity-changed-only, same guard shape as schedule_product_cache_invalidation
|
|
69
|
+
# above, but checking all four quantity columns (not just available/locked) since quantity_on_hold
|
|
70
|
+
# alone can flip the published bucket to/from pending_hold.
|
|
71
|
+
def publish_live_stock_status_to_firestore
|
|
72
|
+
return unless ecommerce?
|
|
73
|
+
return unless saved_change_to_quantity_available? || saved_change_to_quantity_on_hold? ||
|
|
74
|
+
saved_change_to_quantity_locked? || saved_change_to_quantity_locked_on_hold?
|
|
75
|
+
|
|
76
|
+
event_id = variant.product.event_id
|
|
77
|
+
return if event_id.nil?
|
|
78
|
+
|
|
79
|
+
SpreeCmCommissioner::PublishStockStatusesToFirestoreJob.perform_later(event_id: event_id)
|
|
80
|
+
end
|
|
81
|
+
|
|
57
82
|
# Both pools as they stood before this save. saved_change_to_* is nil for a column that did not
|
|
58
83
|
# change, so an unchanged pool falls back to its current value rather than to zero.
|
|
59
84
|
def total_quantity_before_last_save
|