dscf-marketplace 0.13.13 → 0.15.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/app/controllers/dscf/marketplace/aggregator_listings_controller.rb +42 -3
- data/app/controllers/dscf/marketplace/orders_controller.rb +119 -25
- data/app/models/dscf/marketplace/aggregator_listing.rb +82 -1
- data/app/models/dscf/marketplace/order.rb +76 -5
- data/app/serializers/dscf/marketplace/aggregator_listing_serializer.rb +10 -2
- data/app/serializers/dscf/marketplace/order_serializer.rb +6 -1
- data/app/services/dscf/marketplace/my_resource_service.rb +5 -2
- data/db/migrate/20260725000001_add_parent_order_to_dscf_marketplace_orders.rb +11 -0
- data/lib/dscf/marketplace/version.rb +1 -1
- data/spec/factories/dscf/marketplace/orders.rb +9 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 96c72fe29f50fca0d789f14bdf6fe195678ec9dc1f65d7284de72a6aa24dff20
|
|
4
|
+
data.tar.gz: fa9fb4e1e7dab26a304efb5a528db3a0bcdab579d36c51a2505588b31fe40a9e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1adf7c893ca340a6f77021fdc354c35cefbdb8aee7bacb0e412a7a42082b4ea33c37fc8c4a8b26c7048057929b249b241d733182fc9be669a53b44ef83df0e4d
|
|
7
|
+
data.tar.gz: 07a3f375e446fbc537ec377c61de0800d18c8e42a94cc57369274d3b3dbacd96e66a071cb84f747ca14fb4c89fb37198f22faa2e61357faabb0889106139645d
|
|
@@ -8,15 +8,19 @@ module Dscf
|
|
|
8
8
|
# Retailer-facing feed: only listings that are truly offerable — own
|
|
9
9
|
# status :active AND (for supplier-sourced) the supplier hasn't paused
|
|
10
10
|
# or run out of stock. A listing whose supplier paused drops out here.
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
# #offerable expresses that in SQL so the feed stays a relation and can
|
|
12
|
+
# be Ransack-filtered and paginated instead of loading every listing.
|
|
13
|
+
listings = filter_records(
|
|
14
|
+
@clazz.offerable.includes(feed_associations)
|
|
15
|
+
)
|
|
14
16
|
|
|
15
17
|
options = {
|
|
16
18
|
include: default_serializer_includes[:index] || [],
|
|
17
19
|
meta: {resource_type: "aggregator_feed"}
|
|
18
20
|
}
|
|
19
21
|
|
|
22
|
+
listings, options = apply_pagination(listings, options)
|
|
23
|
+
|
|
20
24
|
render_success(data: listings, serializer_options: options)
|
|
21
25
|
end
|
|
22
26
|
|
|
@@ -35,6 +39,41 @@ module Dscf
|
|
|
35
39
|
|
|
36
40
|
private
|
|
37
41
|
|
|
42
|
+
# The serializer reads product name/description/category through whichever
|
|
43
|
+
# source the listing was created from, so all three paths get eager loaded.
|
|
44
|
+
def feed_associations
|
|
45
|
+
[
|
|
46
|
+
{product: :category},
|
|
47
|
+
{supplier_product: {product: :category}},
|
|
48
|
+
{sub_supplier_product: {product: :category}}
|
|
49
|
+
]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Same pagination contract as Common#index (opt-in via ?page), for the
|
|
53
|
+
# custom collection actions that don't route through it.
|
|
54
|
+
def apply_pagination(records, options)
|
|
55
|
+
return [records, options] unless params[:page]
|
|
56
|
+
|
|
57
|
+
total_count = records.count
|
|
58
|
+
records = records.then(&paginate)
|
|
59
|
+
total_pages = (total_count.to_f / per_page).ceil
|
|
60
|
+
|
|
61
|
+
options[:pagination] = {
|
|
62
|
+
current_page: page_no,
|
|
63
|
+
per_page: per_page,
|
|
64
|
+
count: records.length,
|
|
65
|
+
total_count: total_count,
|
|
66
|
+
total_pages: total_pages,
|
|
67
|
+
links: pagination_links(total_pages)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
[records, options]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def allowed_order_columns
|
|
74
|
+
%w[id created_at updated_at price quantity]
|
|
75
|
+
end
|
|
76
|
+
|
|
38
77
|
def model_params
|
|
39
78
|
params.require(:aggregator_listing).permit(
|
|
40
79
|
:aggregator_id, :source_kind, :supplier_product_id, :product_id,
|
|
@@ -45,6 +45,7 @@ module Dscf
|
|
|
45
45
|
def confirm
|
|
46
46
|
@obj = find_record
|
|
47
47
|
authorize @obj, :confirm?
|
|
48
|
+
return if parent_order_guard(@obj)
|
|
48
49
|
return render_error("orders.errors.otp_confirmation_required", status: :unprocessable_entity) if @obj.agent_assisted? && !@obj.otp_verified?
|
|
49
50
|
|
|
50
51
|
if @obj.confirm!
|
|
@@ -97,6 +98,20 @@ module Dscf
|
|
|
97
98
|
def cancel
|
|
98
99
|
@obj = find_record
|
|
99
100
|
authorize @obj, :cancel?
|
|
101
|
+
|
|
102
|
+
# Cancelling the retailer-facing parent cancels every sub-order that
|
|
103
|
+
# hasn't already completed; the parent's own status follows via
|
|
104
|
+
# aggregation (child after_save -> sync_aggregates!).
|
|
105
|
+
if @obj.parent?
|
|
106
|
+
ActiveRecord::Base.transaction do
|
|
107
|
+
@obj.sub_orders.where.not(status: :completed).find_each do |sub_order|
|
|
108
|
+
sub_order.update!(status: :cancelled)
|
|
109
|
+
sub_order.order_items.update_all(status: OrderItem.statuses[:cancelled])
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
return render_success("orders.success.cancelled", data: @obj.reload)
|
|
113
|
+
end
|
|
114
|
+
|
|
100
115
|
if @obj.update(status: :cancelled)
|
|
101
116
|
@obj.order_items.update_all(status: OrderItem.statuses[:cancelled])
|
|
102
117
|
render_success("orders.success.cancelled", data: @obj)
|
|
@@ -108,6 +123,8 @@ module Dscf
|
|
|
108
123
|
def complete
|
|
109
124
|
@obj = find_record
|
|
110
125
|
authorize @obj, :complete?
|
|
126
|
+
return if parent_order_guard(@obj)
|
|
127
|
+
|
|
111
128
|
if @obj.can_be_completed? && @obj.update(status: :completed)
|
|
112
129
|
@obj.order_items.update_all(status: OrderItem.statuses[:fulfilled])
|
|
113
130
|
begin
|
|
@@ -125,6 +142,8 @@ module Dscf
|
|
|
125
142
|
def invoice
|
|
126
143
|
@obj = find_record
|
|
127
144
|
authorize @obj, :show?
|
|
145
|
+
return if parent_order_guard(@obj)
|
|
146
|
+
|
|
128
147
|
invoice = @obj.order_invoice
|
|
129
148
|
if invoice&.pdf_file&.attached?
|
|
130
149
|
redirect_to rails_storage_proxy_url(invoice.pdf_file, disposition: "attachment")
|
|
@@ -149,6 +168,7 @@ module Dscf
|
|
|
149
168
|
def validate
|
|
150
169
|
obj = find_record
|
|
151
170
|
authorize obj, :validate?
|
|
171
|
+
return if parent_order_guard(obj)
|
|
152
172
|
return render_error(errors: "Order awaits retailer OTP confirmation", status: :unprocessable_entity) if obj.agent_assisted? && !obj.otp_verified?
|
|
153
173
|
|
|
154
174
|
Dscf::Marketplace::OrderValidationService.validate(obj)
|
|
@@ -161,6 +181,7 @@ module Dscf
|
|
|
161
181
|
def resolve_item
|
|
162
182
|
obj = find_record
|
|
163
183
|
authorize obj, :resolve_item?
|
|
184
|
+
return if parent_order_guard(obj)
|
|
164
185
|
|
|
165
186
|
item = obj.order_items.find(params[:order_item_id])
|
|
166
187
|
|
|
@@ -199,6 +220,7 @@ module Dscf
|
|
|
199
220
|
def assign_source
|
|
200
221
|
obj = find_record
|
|
201
222
|
authorize obj, :split?
|
|
223
|
+
return if parent_order_guard(obj)
|
|
202
224
|
|
|
203
225
|
item = obj.order_items.find(params[:order_item_id])
|
|
204
226
|
Dscf::Marketplace::OrderSplittingService.assign_source(item, source_type: params[:source_type], source_id: params[:source_id])
|
|
@@ -210,6 +232,7 @@ module Dscf
|
|
|
210
232
|
def split
|
|
211
233
|
obj = find_record
|
|
212
234
|
authorize obj, :split?
|
|
235
|
+
return if parent_order_guard(obj)
|
|
213
236
|
|
|
214
237
|
return render_error(errors: "All items must be validated before splitting") unless obj.all_items_validated?
|
|
215
238
|
|
|
@@ -223,6 +246,7 @@ module Dscf
|
|
|
223
246
|
def supplier_confirm
|
|
224
247
|
obj = find_record
|
|
225
248
|
authorize obj, :supplier_confirm?
|
|
249
|
+
return if parent_order_guard(obj)
|
|
226
250
|
|
|
227
251
|
confirmed = params[:confirmed]
|
|
228
252
|
reason = params[:reason]
|
|
@@ -237,6 +261,7 @@ module Dscf
|
|
|
237
261
|
def confirm_item
|
|
238
262
|
obj = find_record
|
|
239
263
|
authorize obj, :supplier_confirm?
|
|
264
|
+
return if parent_order_guard(obj)
|
|
240
265
|
|
|
241
266
|
item = obj.order_items.find(params[:order_item_id])
|
|
242
267
|
confirmed = params[:confirmed] != false
|
|
@@ -252,6 +277,7 @@ module Dscf
|
|
|
252
277
|
def adjust_item
|
|
253
278
|
obj = find_record
|
|
254
279
|
authorize obj, :supplier_confirm?
|
|
280
|
+
return if parent_order_guard(obj)
|
|
255
281
|
|
|
256
282
|
item = obj.order_items.find(params[:order_item_id])
|
|
257
283
|
Dscf::Marketplace::OrderSplittingService.adjust_item(
|
|
@@ -269,6 +295,7 @@ module Dscf
|
|
|
269
295
|
def accept_adjustment
|
|
270
296
|
obj = find_record
|
|
271
297
|
authorize obj, :split?
|
|
298
|
+
return if parent_order_guard(obj)
|
|
272
299
|
|
|
273
300
|
item = obj.order_items.find(params[:order_item_id])
|
|
274
301
|
Dscf::Marketplace::OrderSplittingService.accept_item_adjustment(obj, item)
|
|
@@ -281,6 +308,7 @@ module Dscf
|
|
|
281
308
|
def reject_adjustment
|
|
282
309
|
obj = find_record
|
|
283
310
|
authorize obj, :split?
|
|
311
|
+
return if parent_order_guard(obj)
|
|
284
312
|
|
|
285
313
|
item = obj.order_items.find(params[:order_item_id])
|
|
286
314
|
Dscf::Marketplace::OrderSplittingService.reject_item_adjustment(obj, item, reason: params[:reason])
|
|
@@ -295,6 +323,7 @@ module Dscf
|
|
|
295
323
|
def reopen_item
|
|
296
324
|
obj = find_record
|
|
297
325
|
authorize obj, :split?
|
|
326
|
+
return if parent_order_guard(obj)
|
|
298
327
|
|
|
299
328
|
item = obj.order_items.find(params[:order_item_id])
|
|
300
329
|
Dscf::Marketplace::OrderSplittingService.reopen_item(obj, item)
|
|
@@ -307,24 +336,47 @@ module Dscf
|
|
|
307
336
|
obj = find_record
|
|
308
337
|
authorize obj, :confirm? # reuse existing or add specific
|
|
309
338
|
|
|
310
|
-
|
|
339
|
+
# The retailer confirms the parent once; the decision fans out to every
|
|
340
|
+
# sub-order that's ready. Sub-orders still waiting on their supplier are
|
|
341
|
+
# left untouched — they come back for confirmation when ready.
|
|
342
|
+
notification = retailer_rejecting? ? :retailer_rejected : :retailer_confirmed
|
|
311
343
|
|
|
312
|
-
if
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
obj.
|
|
320
|
-
|
|
344
|
+
if obj.parent?
|
|
345
|
+
ready = obj.sub_orders.to_a.select(&:retailer_can_confirm?)
|
|
346
|
+
return render_error(errors: "Order not ready for retailer confirmation") if ready.empty?
|
|
347
|
+
|
|
348
|
+
ActiveRecord::Base.transaction do
|
|
349
|
+
ready.each { |sub_order| apply_retailer_confirmation(sub_order) }
|
|
350
|
+
end
|
|
351
|
+
create_notification_for_order(obj.reload, notification, reason: params[:reason])
|
|
352
|
+
return render_success(data: obj, serializer_options: { include: default_serializer_includes[:show] || [] })
|
|
321
353
|
end
|
|
322
354
|
|
|
355
|
+
return render_error(errors: "Order not ready for retailer confirmation") unless obj.retailer_can_confirm?
|
|
356
|
+
|
|
357
|
+
apply_retailer_confirmation(obj)
|
|
358
|
+
create_notification_for_order(obj, notification, reason: params[:reason])
|
|
359
|
+
|
|
323
360
|
render_success(data: obj, serializer_options: { include: default_serializer_includes[:show] || [] })
|
|
324
361
|
rescue => e
|
|
325
362
|
render_error(errors: e.message, status: :unprocessable_entity)
|
|
326
363
|
end
|
|
327
364
|
|
|
365
|
+
def retailer_rejecting?
|
|
366
|
+
params[:confirmed] == false
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# The retailer's confirm-or-cancel decision applied to one order row.
|
|
370
|
+
def apply_retailer_confirmation(order)
|
|
371
|
+
if retailer_rejecting?
|
|
372
|
+
order.update!(status: :cancelled)
|
|
373
|
+
order.order_items.update_all(status: OrderItem.statuses[:cancelled])
|
|
374
|
+
else
|
|
375
|
+
order.update!(status: :confirmed)
|
|
376
|
+
order.order_items.update_all(status: OrderItem.statuses[:confirmed])
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
|
|
328
380
|
|
|
329
381
|
def create_notification_for_order(order, action, reason: nil, otp_code: nil)
|
|
330
382
|
recipient = order.ordered_by || order.user
|
|
@@ -368,7 +420,10 @@ module Dscf
|
|
|
368
420
|
# agent and the aggregator (the business the order was placed with)
|
|
369
421
|
# get notified, in addition to the retailer's own notification above.
|
|
370
422
|
def notify_agent_order_confirmed(order)
|
|
371
|
-
|
|
423
|
+
# A parent order has no ordered_to of its own — notify every
|
|
424
|
+
# sub-order's fulfilling business instead.
|
|
425
|
+
ordered_to_users = order.parent? ? order.sub_orders.map { |so| so.ordered_to&.user } : [ order.ordered_to&.user ]
|
|
426
|
+
[ order.agent&.user, *ordered_to_users ].compact.uniq.each do |recipient|
|
|
372
427
|
notification = Dscf::Core::Notification.create!(
|
|
373
428
|
notifiable: order,
|
|
374
429
|
recipient: recipient,
|
|
@@ -396,24 +451,36 @@ module Dscf
|
|
|
396
451
|
resolved_items = resolve_direct_listing_items
|
|
397
452
|
return if performed? # resolve_direct_listing_items already rendered an error
|
|
398
453
|
|
|
454
|
+
groups = resolved_items.group_by { |ri| ri[:ordered_to] }.values
|
|
455
|
+
parent = nil
|
|
399
456
|
orders = []
|
|
400
457
|
ActiveRecord::Base.transaction do
|
|
401
|
-
|
|
402
|
-
|
|
458
|
+
# A cart spanning several fulfilling businesses still checks out as
|
|
459
|
+
# ONE retailer-facing order: a parent with one sub-order per business.
|
|
460
|
+
# Single-business carts stay a plain standalone order (no parent).
|
|
461
|
+
parent = build_and_save_parent_order if groups.size > 1
|
|
462
|
+
groups.each do |items|
|
|
463
|
+
orders << build_and_save_direct_listing_order(items, parent: parent)
|
|
403
464
|
end
|
|
465
|
+
parent&.sync_aggregates!
|
|
404
466
|
end
|
|
405
467
|
|
|
406
|
-
|
|
468
|
+
if agent_order_retailer
|
|
469
|
+
# One OTP for the whole cart — on the parent when split, so the
|
|
470
|
+
# retailer confirms once; sub-orders inherit via Order#otp_verified?.
|
|
471
|
+
send_agent_order_otp(parent || orders.first, agent_order_retailer)
|
|
472
|
+
end
|
|
407
473
|
|
|
408
|
-
|
|
474
|
+
retailer_order = parent || orders.first
|
|
475
|
+
retailer_order = @clazz.includes(eager_loaded_associations).find(retailer_order.id) if eager_loaded_associations.present?
|
|
409
476
|
includes = default_serializer_includes[:create] || []
|
|
410
477
|
options = {include: includes} if includes.present?
|
|
411
478
|
|
|
412
|
-
if
|
|
413
|
-
render_success(data:
|
|
414
|
-
else
|
|
415
|
-
render_success(data: orders, serializer_options: options, status: :created,
|
|
479
|
+
if parent
|
|
480
|
+
render_success(data: retailer_order, serializer_options: options, status: :created,
|
|
416
481
|
meta: {split_by_business: true, order_count: orders.size})
|
|
482
|
+
else
|
|
483
|
+
render_success(data: retailer_order, serializer_options: options, status: :created)
|
|
417
484
|
end
|
|
418
485
|
rescue ActiveRecord::RecordInvalid => e
|
|
419
486
|
errors = e.record&.errors&.full_messages&.presence || [ e.message ]
|
|
@@ -509,7 +576,22 @@ module Dscf
|
|
|
509
576
|
# Order with one line per item, saves it, then decrements each source's
|
|
510
577
|
# quantity. Raises ActiveRecord::RecordInvalid on insufficient stock so
|
|
511
578
|
# the whole checkout (all groups) rolls back together.
|
|
512
|
-
|
|
579
|
+
# The retailer-facing umbrella for a multi-business cart. Carries the
|
|
580
|
+
# shared checkout fields (who ordered, payment, fulfillment, agent) but
|
|
581
|
+
# no ordered_to (it spans businesses), no listing and no items — those
|
|
582
|
+
# live on the per-business sub-orders. Status/total are synced from the
|
|
583
|
+
# sub-orders (Order#sync_aggregates!).
|
|
584
|
+
def build_and_save_parent_order
|
|
585
|
+
parent = @clazz.new(model_params.except(:order_items_attributes, :listing_id, :listing_type, :retailer_id, :ordered_to_id))
|
|
586
|
+
parent.order_type = :direct_listing
|
|
587
|
+
parent.status = :pending
|
|
588
|
+
parent.ordered_to = nil
|
|
589
|
+
parent.acts_as_parent = true
|
|
590
|
+
parent.save!
|
|
591
|
+
parent
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
def build_and_save_direct_listing_order(items, parent: nil)
|
|
513
595
|
items.each { |ri| (ri[:listing] || ri[:aggregator_listing]).lock! }
|
|
514
596
|
|
|
515
597
|
items.each do |ri|
|
|
@@ -524,6 +606,7 @@ module Dscf
|
|
|
524
606
|
order.order_type = :direct_listing
|
|
525
607
|
order.status = :pending
|
|
526
608
|
order.ordered_to = items.first[:ordered_to]
|
|
609
|
+
order.parent_order = parent
|
|
527
610
|
# Order#listing is a single FK; set it to the first supplier-listing item
|
|
528
611
|
# so quotation_or_listing_present is satisfied for listing-only groups
|
|
529
612
|
# (an aggregator-listing-sourced item satisfies it on its own — see
|
|
@@ -574,6 +657,17 @@ module Dscf
|
|
|
574
657
|
order
|
|
575
658
|
end
|
|
576
659
|
|
|
660
|
+
# Item-level workflow (validate/split/confirm/complete/invoice/…) happens
|
|
661
|
+
# on the per-business sub-orders — a parent has no items to act on. The
|
|
662
|
+
# retailer-facing actions that DO work on a parent (retailer_confirm,
|
|
663
|
+
# cancel) fan out explicitly instead of calling this guard.
|
|
664
|
+
def parent_order_guard(order)
|
|
665
|
+
return false unless order.parent?
|
|
666
|
+
|
|
667
|
+
render_error(errors: "Action not available on a parent order; act on its sub-orders", status: :unprocessable_entity)
|
|
668
|
+
true
|
|
669
|
+
end
|
|
670
|
+
|
|
577
671
|
def direct_listing_request?
|
|
578
672
|
%w[direct_listing 1].include?(model_params[:order_type].to_s)
|
|
579
673
|
end
|
|
@@ -590,15 +684,15 @@ module Dscf
|
|
|
590
684
|
end
|
|
591
685
|
|
|
592
686
|
def eager_loaded_associations
|
|
593
|
-
[ :quotation, :listing, :user, :ordered_by, :ordered_to, :delivery_order, :order_items ]
|
|
687
|
+
[ :quotation, :listing, :user, :ordered_by, :ordered_to, :delivery_order, :order_items, { sub_orders: :order_items } ]
|
|
594
688
|
end
|
|
595
689
|
|
|
596
690
|
def default_serializer_includes
|
|
597
691
|
{
|
|
598
|
-
index: [ :user, :ordered_by, :ordered_to, :quotation, :order_items ],
|
|
599
|
-
show: [ :user, :ordered_by, :ordered_to, :quotation, :listing, :delivery_order, :order_items ],
|
|
600
|
-
create: [ :user, :ordered_by, :ordered_to, :quotation, :listing, :order_items ],
|
|
601
|
-
update: [ :user, :ordered_by, :ordered_to, :quotation, :listing, :order_items ]
|
|
692
|
+
index: [ :user, :ordered_by, :ordered_to, :quotation, :order_items, "sub_orders.order_items" ],
|
|
693
|
+
show: [ :user, :ordered_by, :ordered_to, :quotation, :listing, :delivery_order, :order_items, "sub_orders.order_items" ],
|
|
694
|
+
create: [ :user, :ordered_by, :ordered_to, :quotation, :listing, :order_items, "sub_orders.order_items" ],
|
|
695
|
+
update: [ :user, :ordered_by, :ordered_to, :quotation, :listing, :order_items, "sub_orders.order_items" ]
|
|
602
696
|
}
|
|
603
697
|
end
|
|
604
698
|
|
|
@@ -36,8 +36,89 @@ module Dscf::Marketplace
|
|
|
36
36
|
scope :active, -> { where(status: :active) }
|
|
37
37
|
scope :by_aggregator, ->(aggregator_id) { where(aggregator_id: aggregator_id) }
|
|
38
38
|
|
|
39
|
+
# SQL equivalent of #available? — the retailer-facing "truly offerable" set.
|
|
40
|
+
# Kept as a scope (rather than `select(&:available?)`) so the feed stays a
|
|
41
|
+
# relation and can be filtered, counted and paginated in the database.
|
|
42
|
+
scope :offerable, lambda {
|
|
43
|
+
active
|
|
44
|
+
.where("#{table_name}.quantity > 0")
|
|
45
|
+
.where(
|
|
46
|
+
<<~SQL.squish,
|
|
47
|
+
#{table_name}.source_kind = :catalog
|
|
48
|
+
OR (
|
|
49
|
+
#{table_name}.source_kind = :supplier AND EXISTS (
|
|
50
|
+
SELECT 1 FROM #{SupplierProduct.table_name} sp
|
|
51
|
+
WHERE sp.id = #{table_name}.supplier_product_id
|
|
52
|
+
AND sp.status = :sp_active
|
|
53
|
+
AND sp.available_quantity > 0
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
OR (
|
|
57
|
+
#{table_name}.source_kind = :sub_supplier AND EXISTS (
|
|
58
|
+
SELECT 1 FROM #{SubSupplierProduct.table_name} ssp
|
|
59
|
+
WHERE ssp.id = #{table_name}.sub_supplier_product_id
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
SQL
|
|
63
|
+
catalog: source_kinds[:catalog],
|
|
64
|
+
supplier: source_kinds[:supplier],
|
|
65
|
+
sub_supplier: source_kinds[:sub_supplier],
|
|
66
|
+
sp_active: SupplierProduct.statuses[:active]
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
# Resolves the catalogue product id in SQL, mirroring #product: which column
|
|
71
|
+
# holds the reference depends on source_kind, so a plain association join
|
|
72
|
+
# can't reach it. Used by the ransackers below so retailers can filter the
|
|
73
|
+
# feed on product attributes regardless of how a listing was sourced.
|
|
74
|
+
def self.resolved_product_id_sql
|
|
75
|
+
<<~SQL.squish
|
|
76
|
+
CASE #{table_name}.source_kind
|
|
77
|
+
WHEN #{source_kinds[:supplier]} THEN (
|
|
78
|
+
SELECT sp.product_id FROM #{SupplierProduct.table_name} sp
|
|
79
|
+
WHERE sp.id = #{table_name}.supplier_product_id
|
|
80
|
+
)
|
|
81
|
+
WHEN #{source_kinds[:sub_supplier]} THEN (
|
|
82
|
+
SELECT ssp.product_id FROM #{SubSupplierProduct.table_name} ssp
|
|
83
|
+
WHERE ssp.id = #{table_name}.sub_supplier_product_id
|
|
84
|
+
)
|
|
85
|
+
ELSE #{table_name}.product_id
|
|
86
|
+
END
|
|
87
|
+
SQL
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Pulls one column off the resolved product row.
|
|
91
|
+
def self.resolved_product_column_sql(column)
|
|
92
|
+
<<~SQL.squish
|
|
93
|
+
(SELECT p.#{column} FROM #{Product.table_name} p
|
|
94
|
+
WHERE p.id = (#{resolved_product_id_sql}))
|
|
95
|
+
SQL
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
ransacker :product_name do
|
|
99
|
+
Arel.sql(resolved_product_column_sql(:name))
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
ransacker :product_description do
|
|
103
|
+
Arel.sql(resolved_product_column_sql(:description))
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
ransacker :product_category_id, type: :integer do
|
|
107
|
+
Arel.sql(resolved_product_column_sql(:category_id))
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Category *name*, so `q[product_category_cont]=Spice` works without the
|
|
111
|
+
# client having to look the category id up first.
|
|
112
|
+
ransacker :product_category do
|
|
113
|
+
Arel.sql(<<~SQL.squish)
|
|
114
|
+
(SELECT c.name FROM #{Category.table_name} c
|
|
115
|
+
WHERE c.id = #{resolved_product_column_sql(:category_id)})
|
|
116
|
+
SQL
|
|
117
|
+
end
|
|
118
|
+
|
|
39
119
|
def self.ransackable_attributes(_auth_object = nil)
|
|
40
|
-
%w[id aggregator_id supplier_product_id product_id sub_supplier_product_id source_kind price quantity status created_at updated_at]
|
|
120
|
+
%w[id aggregator_id supplier_product_id product_id sub_supplier_product_id source_kind price quantity status created_at updated_at] +
|
|
121
|
+
%w[product_name product_description product_category product_category_id]
|
|
41
122
|
end
|
|
42
123
|
|
|
43
124
|
def self.ransackable_associations(_auth_object = nil)
|
|
@@ -20,22 +20,39 @@ module Dscf::Marketplace
|
|
|
20
20
|
belongs_to :listing, optional: true
|
|
21
21
|
belongs_to :user, class_name: "Dscf::Core::User" # Keep for backward compatibility
|
|
22
22
|
belongs_to :ordered_by, class_name: "Dscf::Core::User"
|
|
23
|
-
|
|
23
|
+
# optional at the association level; presence is enforced below for every
|
|
24
|
+
# order except a parent (which spans businesses).
|
|
25
|
+
belongs_to :ordered_to, class_name: "Dscf::Core::Business", optional: true
|
|
24
26
|
belongs_to :delivery_order, optional: true
|
|
25
27
|
belongs_to :dropoff_address, class_name: "Dscf::Core::Address", optional: true
|
|
26
28
|
belongs_to :agent, class_name: "Dscf::Marketplace::Agent", optional: true
|
|
29
|
+
# A multi-business cart checks out as one retailer-facing parent order with
|
|
30
|
+
# one sub-order per fulfilling business. The parent carries no order_items
|
|
31
|
+
# of its own — items, stock, validation, splitting and invoicing all live on
|
|
32
|
+
# the sub-orders, which are exactly what standalone orders always were.
|
|
33
|
+
belongs_to :parent_order, class_name: "Dscf::Marketplace::Order", optional: true
|
|
34
|
+
has_many :sub_orders, class_name: "Dscf::Marketplace::Order",
|
|
35
|
+
foreign_key: :parent_order_id, inverse_of: :parent_order, dependent: :destroy
|
|
27
36
|
has_many :order_items, dependent: :destroy, autosave: true
|
|
28
37
|
has_one :order_invoice, class_name: "Dscf::Marketplace::OrderInvoice", dependent: :destroy
|
|
29
38
|
has_many :otp_verifications, as: :verifiable, class_name: "Dscf::Marketplace::OtpVerification", dependent: :destroy
|
|
30
39
|
accepts_nested_attributes_for :order_items, allow_destroy: true
|
|
31
40
|
|
|
41
|
+
# Marks an order being built as a parent before its sub-orders exist (they
|
|
42
|
+
# are created after the parent inside the checkout transaction, so
|
|
43
|
+
# `sub_orders.exists?` alone can't answer "am I a parent?" on first save).
|
|
44
|
+
attr_accessor :acts_as_parent
|
|
45
|
+
|
|
32
46
|
validates :order_type, presence: true
|
|
33
47
|
validates :status, presence: true
|
|
34
48
|
validates :fulfillment_type, presence: true
|
|
35
49
|
validates :payment_method, presence: true
|
|
36
50
|
validates :user, presence: true # Keep for backward compatibility
|
|
37
51
|
validates :ordered_by, presence: true
|
|
38
|
-
|
|
52
|
+
# A parent spans several fulfilling businesses, so it has no single
|
|
53
|
+
# counterparty; each sub-order carries its own ordered_to.
|
|
54
|
+
validates :ordered_to, presence: true, unless: :parent?
|
|
55
|
+
validates :parent_order_id, absence: true, if: :acts_as_parent
|
|
39
56
|
validates :dropoff_address, presence: true, if: :delivery?
|
|
40
57
|
validates :received_bank_name, :transaction_reference, absence: true, if: :cash?
|
|
41
58
|
validate :quotation_or_listing_present
|
|
@@ -45,14 +62,20 @@ module Dscf::Marketplace
|
|
|
45
62
|
before_validation :clear_payment_references_for_cash
|
|
46
63
|
before_validation :auto_assign_dropoff_address, if: :delivery?
|
|
47
64
|
before_save :calculate_total_amount
|
|
65
|
+
# Keep the parent's persisted status/total in step with its sub-orders so
|
|
66
|
+
# retailer-side filtering and ordering (ransack on my_orders) keep working.
|
|
67
|
+
# Reliable because sub-order status always changes via save/update! —
|
|
68
|
+
# update_all is only ever used on order_items.
|
|
69
|
+
after_save :sync_parent_aggregates,
|
|
70
|
+
if: -> { parent_order_id? && (saved_change_to_status? || saved_change_to_total_amount?) }
|
|
48
71
|
|
|
49
72
|
# Ransack configuration for secure filtering
|
|
50
73
|
def self.ransackable_attributes(_auth_object = nil)
|
|
51
|
-
%w[id quotation_id listing_id user_id ordered_by_id ordered_to_id delivery_order_id dropoff_address_id agent_id order_type status fulfillment_type payment_method received_bank_name transaction_reference total_amount created_at updated_at]
|
|
74
|
+
%w[id quotation_id listing_id user_id ordered_by_id ordered_to_id parent_order_id delivery_order_id dropoff_address_id agent_id order_type status fulfillment_type payment_method received_bank_name transaction_reference total_amount created_at updated_at]
|
|
52
75
|
end
|
|
53
76
|
|
|
54
77
|
def self.ransackable_associations(_auth_object = nil)
|
|
55
|
-
%w[quotation listing user ordered_by ordered_to delivery_order dropoff_address order_items]
|
|
78
|
+
%w[quotation listing user ordered_by ordered_to delivery_order dropoff_address order_items parent_order sub_orders]
|
|
56
79
|
end
|
|
57
80
|
|
|
58
81
|
def self.create_from_quotation(quotation, dropoff_address = nil, payment_method = nil)
|
|
@@ -180,9 +203,47 @@ module Dscf::Marketplace
|
|
|
180
203
|
end
|
|
181
204
|
|
|
182
205
|
def calculate_total_amount
|
|
206
|
+
# A parent has no items; its total is synced from sub-orders — don't
|
|
207
|
+
# zero it out on save.
|
|
208
|
+
return if parent?
|
|
209
|
+
|
|
183
210
|
self.total_amount = billable_order_items.sum { |item| item.quantity * item.unit_price }
|
|
184
211
|
end
|
|
185
212
|
|
|
213
|
+
# Retailer-facing umbrella over per-business sub-orders. The virtual flag
|
|
214
|
+
# covers the parent's own creation (before sub-orders exist); afterwards
|
|
215
|
+
# the association is authoritative.
|
|
216
|
+
def parent?
|
|
217
|
+
acts_as_parent.present? || (persisted? && sub_orders.exists?)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Recompute the parent's status/total from its sub-orders. update_columns
|
|
221
|
+
# on purpose: aggregation must not re-trigger validations or callbacks.
|
|
222
|
+
def sync_aggregates!
|
|
223
|
+
return unless parent?
|
|
224
|
+
|
|
225
|
+
update_columns(
|
|
226
|
+
status: self.class.statuses[compute_aggregate_status],
|
|
227
|
+
total_amount: sub_orders.sum(:total_amount),
|
|
228
|
+
updated_at: Time.current
|
|
229
|
+
)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# The parent's status mirrors the furthest-behind live sub-order: it only
|
|
233
|
+
# advances once every live (non-cancelled) sub-order has advanced.
|
|
234
|
+
def compute_aggregate_status
|
|
235
|
+
statuses = sub_orders.pluck(:status).map { |s| self.class.statuses.key(s) || s.to_s }
|
|
236
|
+
live = statuses.reject { |s| s == "cancelled" }
|
|
237
|
+
|
|
238
|
+
return :cancelled if live.empty?
|
|
239
|
+
return :completed if live.all? { |s| s == "completed" }
|
|
240
|
+
return :confirmed if live.all? { |s| %w[confirmed processing completed].include?(s) }
|
|
241
|
+
return :waiting_supplier_confirmation if live.any? { |s| %w[waiting_supplier_confirmation splitting].include?(s) }
|
|
242
|
+
return :waiting_retailer_confirmation if live.any? { |s| s == "waiting_retailer_confirmation" }
|
|
243
|
+
|
|
244
|
+
:pending
|
|
245
|
+
end
|
|
246
|
+
|
|
186
247
|
# Workflow helpers for Sprint 2 order validation + splitting.
|
|
187
248
|
# Note: `validating?` is the enum-generated predicate (true only when
|
|
188
249
|
# status == "validating"). Use this for "can still be run through
|
|
@@ -237,11 +298,17 @@ module Dscf::Marketplace
|
|
|
237
298
|
end
|
|
238
299
|
|
|
239
300
|
def otp_verified?
|
|
240
|
-
|
|
301
|
+
# A split (multi-business) agent order carries ONE OTP on the parent —
|
|
302
|
+
# the retailer confirms the whole cart once; sub-orders inherit it.
|
|
303
|
+
otp_verifications.verified.exists? || (parent_order.present? && parent_order.otp_verified?)
|
|
241
304
|
end
|
|
242
305
|
|
|
243
306
|
private
|
|
244
307
|
|
|
308
|
+
def sync_parent_aggregates
|
|
309
|
+
parent_order&.sync_aggregates!
|
|
310
|
+
end
|
|
311
|
+
|
|
245
312
|
def auto_assign_dropoff_address
|
|
246
313
|
return if dropoff_address_id.present? || dropoff_address.present?
|
|
247
314
|
|
|
@@ -275,6 +342,10 @@ module Dscf::Marketplace
|
|
|
275
342
|
end
|
|
276
343
|
|
|
277
344
|
def quotation_or_listing_present
|
|
345
|
+
# A parent order carries no lines of its own — its sub-orders each ground
|
|
346
|
+
# themselves in a listing/quotation/source.
|
|
347
|
+
return if parent?
|
|
348
|
+
|
|
278
349
|
# A feed-purchased order has no order.listing (Order#listing is a single
|
|
279
350
|
# FK; nothing to point it at when every item came from the aggregator's
|
|
280
351
|
# feed) but each item's own presence validation already requires it to
|
|
@@ -4,8 +4,8 @@ module Dscf
|
|
|
4
4
|
attributes :id, :aggregator_id, :source_kind, :supplier_product_id, :product_id,
|
|
5
5
|
:sub_supplier_product_id, :price, :quantity, :cost_price, :margin,
|
|
6
6
|
:status, :effective_status, :created_at, :updated_at, :total_value, :available?,
|
|
7
|
-
:product_name, :product_description, :
|
|
8
|
-
:source_label
|
|
7
|
+
:product_name, :product_description, :product_category, :product_category_id,
|
|
8
|
+
:thumbnail_url, :images_urls, :source_label
|
|
9
9
|
|
|
10
10
|
belongs_to :aggregator
|
|
11
11
|
|
|
@@ -17,6 +17,14 @@ module Dscf
|
|
|
17
17
|
object.product&.description
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
+
def product_category
|
|
21
|
+
object.product&.category&.name
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def product_category_id
|
|
25
|
+
object.product&.category_id
|
|
26
|
+
end
|
|
27
|
+
|
|
20
28
|
def thumbnail_url
|
|
21
29
|
object.product&.thumbnail_url
|
|
22
30
|
end
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
module Dscf
|
|
2
2
|
module Marketplace
|
|
3
3
|
class OrderSerializer < ActiveModel::Serializer
|
|
4
|
-
attributes :id, :quotation_id, :listing_id, :user_id, :ordered_by_id, :ordered_to_id, :delivery_order_id, :dropoff_address_id, :agent_id,
|
|
4
|
+
attributes :id, :quotation_id, :listing_id, :user_id, :ordered_by_id, :ordered_to_id, :parent_order_id, :delivery_order_id, :dropoff_address_id, :agent_id,
|
|
5
5
|
:order_type, :status, :workflow_status, :has_validation_issues, :fulfillment_type, :payment_method, :received_bank_name, :transaction_reference, :total_amount,
|
|
6
6
|
:buyer_name, :buyer_phone, :buyer_email, :seller_name, :seller_phone, :seller_email,
|
|
7
7
|
:created_at, :updated_at
|
|
@@ -15,6 +15,11 @@ module Dscf
|
|
|
15
15
|
belongs_to :dropoff_address
|
|
16
16
|
belongs_to :agent
|
|
17
17
|
has_many :order_items
|
|
18
|
+
# Per-business sub-orders of a retailer-facing parent order. Only ever
|
|
19
|
+
# rendered when explicitly asked for via serializer include
|
|
20
|
+
# ("sub_orders.order_items"), and sub-orders themselves have no
|
|
21
|
+
# sub-orders, so recursion is bounded.
|
|
22
|
+
has_many :sub_orders, serializer: OrderSerializer
|
|
18
23
|
|
|
19
24
|
# Retailer's registered display name (users have no name field of their
|
|
20
25
|
# own — only email/phone) so admin can see who placed the order, per
|
|
@@ -6,8 +6,11 @@ module Dscf
|
|
|
6
6
|
end
|
|
7
7
|
|
|
8
8
|
def my_orders(params = {})
|
|
9
|
-
orders
|
|
10
|
-
|
|
9
|
+
# Sub-orders are the per-business halves of a split cart — the retailer
|
|
10
|
+
# sees only the parent (with sub_orders nested), never the children as
|
|
11
|
+
# separate rows.
|
|
12
|
+
orders = Dscf::Marketplace::Order.where(ordered_by: @current_user, parent_order_id: nil)
|
|
13
|
+
.includes(:quotation, :listing, :user, :ordered_by, :ordered_to, :delivery_order, :order_items, sub_orders: :order_items)
|
|
11
14
|
|
|
12
15
|
apply_filters(orders, params)
|
|
13
16
|
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
class AddParentOrderToDscfMarketplaceOrders < ActiveRecord::Migration[8.0]
|
|
2
|
+
def change
|
|
3
|
+
# Retailer-facing umbrella order: a multi-business cart creates one parent
|
|
4
|
+
# (what the retailer sees) plus one sub-order per fulfilling business (what
|
|
5
|
+
# each supplier sees). Standalone orders keep parent_order_id nil.
|
|
6
|
+
add_reference :dscf_marketplace_orders, :parent_order,
|
|
7
|
+
null: true,
|
|
8
|
+
index: {name: "parent_order_id_on_dm_orders_idx"},
|
|
9
|
+
foreign_key: {to_table: :dscf_marketplace_orders}
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -56,5 +56,14 @@ FactoryBot.define do
|
|
|
56
56
|
trait :cancelled do
|
|
57
57
|
status { :cancelled }
|
|
58
58
|
end
|
|
59
|
+
|
|
60
|
+
# Retailer-facing umbrella over per-business sub-orders: no counterparty,
|
|
61
|
+
# no listing/quotation, no items of its own.
|
|
62
|
+
trait :parent do
|
|
63
|
+
acts_as_parent { true }
|
|
64
|
+
ordered_to { nil }
|
|
65
|
+
quotation { nil }
|
|
66
|
+
listing { nil }
|
|
67
|
+
end
|
|
59
68
|
end
|
|
60
69
|
end
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: dscf-marketplace
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.15.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Asrat
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-07-
|
|
10
|
+
date: 2026-07-25 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: rails
|
|
@@ -601,6 +601,7 @@ files:
|
|
|
601
601
|
- db/migrate/20260708000001_make_retailer_tin_number_optional.rb
|
|
602
602
|
- db/migrate/20260708000002_make_product_gross_weight_optional.rb
|
|
603
603
|
- db/migrate/20260709000001_remove_supplier_from_dscf_marketplace_sub_suppliers.rb
|
|
604
|
+
- db/migrate/20260725000001_add_parent_order_to_dscf_marketplace_orders.rb
|
|
604
605
|
- db/seeds.rb
|
|
605
606
|
- lib/dscf/marketplace.rb
|
|
606
607
|
- lib/dscf/marketplace/engine.rb
|