dscf-marketplace 0.13.13 → 0.16.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c4a2b0e347680bb877dbcce80829d80bddb751cd5248a674db3208ba169f4824
4
- data.tar.gz: 450244b5f6bb3bfe6933dc5bcbe6ec1ce9a0e4cf12d2b6ba38fbba64451c3009
3
+ metadata.gz: 3cc1c9aaf986ce406e613ac0b3dc31e9a230a8a4e996ef61df353ef51003cdd6
4
+ data.tar.gz: ca71e2b9a1310eb873818fdeede519b86af08ca7db17764d0a123ab287c23376
5
5
  SHA512:
6
- metadata.gz: 2c541d060329f90f173c4489da2991aa8e7ec95edd1eaf71cdea683ac6e676d2316990c24d05d8d6c85f59394b9d0a9e432164ff32449876703b61d95c0ad6c3
7
- data.tar.gz: e482e00be97fae32c2b57501b9c036520ddbf385e0bc12b3290e8e687907e7da5ca8673e6778f76d132ef85d4a8f900e51abb28ac02a790a3a6eb3ef1d221496
6
+ metadata.gz: 59c39334fa8624f99730ef5bf36c9bb18f17e3ecb2c653af7be70cea8068c45fe0dad28b3a7cd632398ef14eed830f1f57111d36bae5ce9d1c63bc6961685ad2
7
+ data.tar.gz: 688c0ab34c504eaf3e393ef5e82a4ec0c2047d8ed0114126776fd6af88a27156fadf87b532bbb91458b6a63d8d43d2ec568477f15f2c8eb6cae9fb4bf58e2437
@@ -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
- listings = @clazz.active
12
- .includes(:product, {supplier_product: :product}, {sub_supplier_product: :product})
13
- .select(&:available?)
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,
@@ -22,6 +22,61 @@ module Dscf
22
22
  render_error(error: e.message)
23
23
  end
24
24
 
25
+ # One-tap reorder: rebuild a past order's basket and place it again.
26
+ #
27
+ # An order line records the PRODUCT, not the aggregator listing it came
28
+ # from (a supplier-sourced feed line stores the fulfilling business as its
29
+ # source), so lines are re-resolved against what the same seller offers
30
+ # today rather than replayed. That also means the new order is priced at
31
+ # today's price — a stale price can't be honoured.
32
+ #
33
+ # Lines that can no longer be sourced are skipped and lines short on stock
34
+ # are clamped, both reported in meta, so one sold-out item doesn't block
35
+ # the whole reorder.
36
+ def reorder
37
+ source_order = find_record
38
+ authorize source_order, :reorder?
39
+
40
+ unless source_order.direct_listing?
41
+ return render_error(errors: "Only direct listing orders can be reordered", status: :unprocessable_entity)
42
+ end
43
+
44
+ resolved_items, skipped, adjusted = resolve_reorder_items(source_order)
45
+
46
+ if resolved_items.empty?
47
+ return render_error(
48
+ errors: "None of the items on order ##{source_order.id} are available to reorder",
49
+ status: :unprocessable_entity,
50
+ skipped_items: skipped
51
+ )
52
+ end
53
+
54
+ parent, orders = place_direct_listing_cart(resolved_items, base_attrs: reorder_base_attributes(source_order))
55
+
56
+ new_order = parent || orders.first
57
+ new_order = @clazz.includes(eager_loaded_associations).find(new_order.id) if eager_loaded_associations.present?
58
+ includes = default_serializer_includes[:create] || []
59
+ options = {include: includes} if includes.present?
60
+
61
+ meta = {
62
+ reordered_from: source_order.id,
63
+ reordered_items: resolved_items.size,
64
+ skipped_items: skipped,
65
+ adjusted_items: adjusted
66
+ }
67
+ if parent
68
+ meta[:split_by_business] = true
69
+ meta[:order_count] = orders.size
70
+ end
71
+
72
+ render_success(data: new_order, serializer_options: options, status: :created, meta: meta)
73
+ rescue ActiveRecord::RecordInvalid => e
74
+ errors = e.record&.errors&.full_messages&.presence || [ e.message ]
75
+ render_error(errors: errors.join(", "), status: :unprocessable_entity)
76
+ rescue => e
77
+ render_error(error: e.message)
78
+ end
79
+
25
80
  def filter
26
81
  authorize @clazz.new, :filter?
27
82
  orders = @clazz.all
@@ -45,6 +100,7 @@ module Dscf
45
100
  def confirm
46
101
  @obj = find_record
47
102
  authorize @obj, :confirm?
103
+ return if parent_order_guard(@obj)
48
104
  return render_error("orders.errors.otp_confirmation_required", status: :unprocessable_entity) if @obj.agent_assisted? && !@obj.otp_verified?
49
105
 
50
106
  if @obj.confirm!
@@ -97,6 +153,20 @@ module Dscf
97
153
  def cancel
98
154
  @obj = find_record
99
155
  authorize @obj, :cancel?
156
+
157
+ # Cancelling the retailer-facing parent cancels every sub-order that
158
+ # hasn't already completed; the parent's own status follows via
159
+ # aggregation (child after_save -> sync_aggregates!).
160
+ if @obj.parent?
161
+ ActiveRecord::Base.transaction do
162
+ @obj.sub_orders.where.not(status: :completed).find_each do |sub_order|
163
+ sub_order.update!(status: :cancelled)
164
+ sub_order.order_items.update_all(status: OrderItem.statuses[:cancelled])
165
+ end
166
+ end
167
+ return render_success("orders.success.cancelled", data: @obj.reload)
168
+ end
169
+
100
170
  if @obj.update(status: :cancelled)
101
171
  @obj.order_items.update_all(status: OrderItem.statuses[:cancelled])
102
172
  render_success("orders.success.cancelled", data: @obj)
@@ -108,6 +178,8 @@ module Dscf
108
178
  def complete
109
179
  @obj = find_record
110
180
  authorize @obj, :complete?
181
+ return if parent_order_guard(@obj)
182
+
111
183
  if @obj.can_be_completed? && @obj.update(status: :completed)
112
184
  @obj.order_items.update_all(status: OrderItem.statuses[:fulfilled])
113
185
  begin
@@ -125,6 +197,8 @@ module Dscf
125
197
  def invoice
126
198
  @obj = find_record
127
199
  authorize @obj, :show?
200
+ return if parent_order_guard(@obj)
201
+
128
202
  invoice = @obj.order_invoice
129
203
  if invoice&.pdf_file&.attached?
130
204
  redirect_to rails_storage_proxy_url(invoice.pdf_file, disposition: "attachment")
@@ -149,6 +223,7 @@ module Dscf
149
223
  def validate
150
224
  obj = find_record
151
225
  authorize obj, :validate?
226
+ return if parent_order_guard(obj)
152
227
  return render_error(errors: "Order awaits retailer OTP confirmation", status: :unprocessable_entity) if obj.agent_assisted? && !obj.otp_verified?
153
228
 
154
229
  Dscf::Marketplace::OrderValidationService.validate(obj)
@@ -161,6 +236,7 @@ module Dscf
161
236
  def resolve_item
162
237
  obj = find_record
163
238
  authorize obj, :resolve_item?
239
+ return if parent_order_guard(obj)
164
240
 
165
241
  item = obj.order_items.find(params[:order_item_id])
166
242
 
@@ -199,6 +275,7 @@ module Dscf
199
275
  def assign_source
200
276
  obj = find_record
201
277
  authorize obj, :split?
278
+ return if parent_order_guard(obj)
202
279
 
203
280
  item = obj.order_items.find(params[:order_item_id])
204
281
  Dscf::Marketplace::OrderSplittingService.assign_source(item, source_type: params[:source_type], source_id: params[:source_id])
@@ -210,6 +287,7 @@ module Dscf
210
287
  def split
211
288
  obj = find_record
212
289
  authorize obj, :split?
290
+ return if parent_order_guard(obj)
213
291
 
214
292
  return render_error(errors: "All items must be validated before splitting") unless obj.all_items_validated?
215
293
 
@@ -223,6 +301,7 @@ module Dscf
223
301
  def supplier_confirm
224
302
  obj = find_record
225
303
  authorize obj, :supplier_confirm?
304
+ return if parent_order_guard(obj)
226
305
 
227
306
  confirmed = params[:confirmed]
228
307
  reason = params[:reason]
@@ -237,6 +316,7 @@ module Dscf
237
316
  def confirm_item
238
317
  obj = find_record
239
318
  authorize obj, :supplier_confirm?
319
+ return if parent_order_guard(obj)
240
320
 
241
321
  item = obj.order_items.find(params[:order_item_id])
242
322
  confirmed = params[:confirmed] != false
@@ -252,6 +332,7 @@ module Dscf
252
332
  def adjust_item
253
333
  obj = find_record
254
334
  authorize obj, :supplier_confirm?
335
+ return if parent_order_guard(obj)
255
336
 
256
337
  item = obj.order_items.find(params[:order_item_id])
257
338
  Dscf::Marketplace::OrderSplittingService.adjust_item(
@@ -269,6 +350,7 @@ module Dscf
269
350
  def accept_adjustment
270
351
  obj = find_record
271
352
  authorize obj, :split?
353
+ return if parent_order_guard(obj)
272
354
 
273
355
  item = obj.order_items.find(params[:order_item_id])
274
356
  Dscf::Marketplace::OrderSplittingService.accept_item_adjustment(obj, item)
@@ -281,6 +363,7 @@ module Dscf
281
363
  def reject_adjustment
282
364
  obj = find_record
283
365
  authorize obj, :split?
366
+ return if parent_order_guard(obj)
284
367
 
285
368
  item = obj.order_items.find(params[:order_item_id])
286
369
  Dscf::Marketplace::OrderSplittingService.reject_item_adjustment(obj, item, reason: params[:reason])
@@ -295,6 +378,7 @@ module Dscf
295
378
  def reopen_item
296
379
  obj = find_record
297
380
  authorize obj, :split?
381
+ return if parent_order_guard(obj)
298
382
 
299
383
  item = obj.order_items.find(params[:order_item_id])
300
384
  Dscf::Marketplace::OrderSplittingService.reopen_item(obj, item)
@@ -307,24 +391,47 @@ module Dscf
307
391
  obj = find_record
308
392
  authorize obj, :confirm? # reuse existing or add specific
309
393
 
310
- return render_error(errors: "Order not ready for retailer confirmation") unless obj.retailer_can_confirm?
394
+ # The retailer confirms the parent once; the decision fans out to every
395
+ # sub-order that's ready. Sub-orders still waiting on their supplier are
396
+ # left untouched — they come back for confirmation when ready.
397
+ notification = retailer_rejecting? ? :retailer_rejected : :retailer_confirmed
311
398
 
312
- if params[:confirmed] == false
313
- reason = params[:reason]
314
- obj.update!(status: :cancelled)
315
- obj.order_items.update_all(status: OrderItem.statuses[:cancelled])
316
- create_notification_for_order(obj, :retailer_rejected, reason: reason)
317
- else
318
- obj.update!(status: :confirmed)
319
- obj.order_items.update_all(status: OrderItem.statuses[:confirmed])
320
- create_notification_for_order(obj, :retailer_confirmed)
399
+ if obj.parent?
400
+ ready = obj.sub_orders.to_a.select(&:retailer_can_confirm?)
401
+ return render_error(errors: "Order not ready for retailer confirmation") if ready.empty?
402
+
403
+ ActiveRecord::Base.transaction do
404
+ ready.each { |sub_order| apply_retailer_confirmation(sub_order) }
405
+ end
406
+ create_notification_for_order(obj.reload, notification, reason: params[:reason])
407
+ return render_success(data: obj, serializer_options: { include: default_serializer_includes[:show] || [] })
321
408
  end
322
409
 
410
+ return render_error(errors: "Order not ready for retailer confirmation") unless obj.retailer_can_confirm?
411
+
412
+ apply_retailer_confirmation(obj)
413
+ create_notification_for_order(obj, notification, reason: params[:reason])
414
+
323
415
  render_success(data: obj, serializer_options: { include: default_serializer_includes[:show] || [] })
324
416
  rescue => e
325
417
  render_error(errors: e.message, status: :unprocessable_entity)
326
418
  end
327
419
 
420
+ def retailer_rejecting?
421
+ params[:confirmed] == false
422
+ end
423
+
424
+ # The retailer's confirm-or-cancel decision applied to one order row.
425
+ def apply_retailer_confirmation(order)
426
+ if retailer_rejecting?
427
+ order.update!(status: :cancelled)
428
+ order.order_items.update_all(status: OrderItem.statuses[:cancelled])
429
+ else
430
+ order.update!(status: :confirmed)
431
+ order.order_items.update_all(status: OrderItem.statuses[:confirmed])
432
+ end
433
+ end
434
+
328
435
 
329
436
  def create_notification_for_order(order, action, reason: nil, otp_code: nil)
330
437
  recipient = order.ordered_by || order.user
@@ -368,7 +475,10 @@ module Dscf
368
475
  # agent and the aggregator (the business the order was placed with)
369
476
  # get notified, in addition to the retailer's own notification above.
370
477
  def notify_agent_order_confirmed(order)
371
- [ order.agent&.user, order.ordered_to&.user ].compact.each do |recipient|
478
+ # A parent order has no ordered_to of its own — notify every
479
+ # sub-order's fulfilling business instead.
480
+ ordered_to_users = order.parent? ? order.sub_orders.map { |so| so.ordered_to&.user } : [ order.ordered_to&.user ]
481
+ [ order.agent&.user, *ordered_to_users ].compact.uniq.each do |recipient|
372
482
  notification = Dscf::Core::Notification.create!(
373
483
  notifiable: order,
374
484
  recipient: recipient,
@@ -396,24 +506,25 @@ module Dscf
396
506
  resolved_items = resolve_direct_listing_items
397
507
  return if performed? # resolve_direct_listing_items already rendered an error
398
508
 
399
- orders = []
400
- ActiveRecord::Base.transaction do
401
- resolved_items.group_by { |ri| ri[:ordered_to] }.each_value do |items|
402
- orders << build_and_save_direct_listing_order(items)
403
- end
404
- end
509
+ base_attrs = model_params.except(:order_items_attributes, :listing_id, :listing_type, :retailer_id)
510
+ parent, orders = place_direct_listing_cart(resolved_items, base_attrs: base_attrs)
405
511
 
406
- orders.each { |order| send_agent_order_otp(order, agent_order_retailer) } if agent_order_retailer
512
+ if agent_order_retailer
513
+ # One OTP for the whole cart — on the parent when split, so the
514
+ # retailer confirms once; sub-orders inherit via Order#otp_verified?.
515
+ send_agent_order_otp(parent || orders.first, agent_order_retailer)
516
+ end
407
517
 
408
- orders = orders.map { |o| @clazz.includes(eager_loaded_associations).find(o.id) } if eager_loaded_associations.present?
518
+ retailer_order = parent || orders.first
519
+ retailer_order = @clazz.includes(eager_loaded_associations).find(retailer_order.id) if eager_loaded_associations.present?
409
520
  includes = default_serializer_includes[:create] || []
410
521
  options = {include: includes} if includes.present?
411
522
 
412
- if orders.size == 1
413
- render_success(data: orders.first, serializer_options: options, status: :created)
414
- else
415
- render_success(data: orders, serializer_options: options, status: :created,
523
+ if parent
524
+ render_success(data: retailer_order, serializer_options: options, status: :created,
416
525
  meta: {split_by_business: true, order_count: orders.size})
526
+ else
527
+ render_success(data: retailer_order, serializer_options: options, status: :created)
417
528
  end
418
529
  rescue ActiveRecord::RecordInvalid => e
419
530
  errors = e.record&.errors&.full_messages&.presence || [ e.message ]
@@ -509,7 +620,131 @@ module Dscf
509
620
  # Order with one line per item, saves it, then decrements each source's
510
621
  # quantity. Raises ActiveRecord::RecordInvalid on insufficient stock so
511
622
  # the whole checkout (all groups) rolls back together.
512
- def build_and_save_direct_listing_order(items)
623
+ # The shared order fields the reorder inherits. Deliberately NOT copied:
624
+ # status (the new order starts fresh), agent_id (a reorder is the
625
+ # retailer acting for themselves, so it skips the agent OTP gate), and
626
+ # any payment reference from the original transaction.
627
+ def reorder_base_attributes(source_order)
628
+ {
629
+ user_id: source_order.user_id,
630
+ ordered_by_id: source_order.ordered_by_id,
631
+ fulfillment_type: source_order.fulfillment_type,
632
+ payment_method: source_order.payment_method,
633
+ dropoff_address_id: source_order.dropoff_address_id
634
+ }
635
+ end
636
+
637
+ # Re-resolves a past order's lines against what is offerable now.
638
+ # Returns [resolved_items, skipped, adjusted] where the latter two are
639
+ # client-facing reports of what could not be honoured as-is.
640
+ def resolve_reorder_items(source_order)
641
+ resolved = []
642
+ skipped = []
643
+ adjusted = []
644
+
645
+ reorder_source_lines(source_order).each do |item, seller|
646
+ product_name = item.product&.name
647
+ wanted = item.quantity.to_i
648
+ next if wanted <= 0
649
+
650
+ listing, aggregator_listing = find_reorder_listing(item, seller)
651
+
652
+ if listing.nil? && aggregator_listing.nil?
653
+ skipped << {product_id: item.product_id, product_name: product_name, reason: "no_longer_available"}
654
+ next
655
+ end
656
+
657
+ available = (listing || aggregator_listing).quantity.to_i
658
+ if available <= 0
659
+ skipped << {product_id: item.product_id, product_name: product_name, reason: "out_of_stock"}
660
+ next
661
+ end
662
+
663
+ quantity = [ wanted, available ].min
664
+ if quantity < wanted
665
+ adjusted << {
666
+ product_id: item.product_id, product_name: product_name,
667
+ requested_quantity: wanted, quantity: quantity, reason: "insufficient_stock"
668
+ }
669
+ end
670
+
671
+ resolved << if listing
672
+ {listing: listing, quantity: quantity, ordered_to: listing.business}
673
+ else
674
+ {aggregator_listing: aggregator_listing, quantity: quantity, ordered_to: aggregator_listing.aggregator}
675
+ end
676
+ end
677
+
678
+ [ resolved, skipped, adjusted ]
679
+ end
680
+
681
+ # The lines worth reordering, each paired with the business it was bought
682
+ # from. A parent holds no lines of its own, so a reorder of a whole cart
683
+ # collects them from its sub-orders (and may split again). Cancelled lines
684
+ # were never fulfilled, so they don't come along.
685
+ def reorder_source_lines(source_order)
686
+ orders = source_order.parent? ? source_order.sub_orders.to_a : [ source_order ]
687
+ orders.flat_map do |order|
688
+ order.order_items.reject(&:cancelled?).map { |item| [ item, order.ordered_to ] }
689
+ end
690
+ end
691
+
692
+ # A supplier listing is re-found by id — silently moving the retailer to a
693
+ # different supplier would not be the same purchase. A feed line only
694
+ # knows its product, so it re-resolves to whatever the SAME aggregator
695
+ # currently offers for that product.
696
+ def find_reorder_listing(item, seller)
697
+ if item.listing_id.present?
698
+ listing = Dscf::Marketplace::Listing.active.find_by(id: item.listing_id)
699
+ return [ listing, nil ] if listing
700
+ end
701
+
702
+ aggregator_listing = Dscf::Marketplace::AggregatorListing
703
+ .offerable
704
+ .for_product(item.product_id)
705
+ .find_by(aggregator_id: seller&.id)
706
+
707
+ [ nil, aggregator_listing ]
708
+ end
709
+
710
+ # Groups a resolved cart by fulfilling business and writes it out. A cart
711
+ # spanning several businesses becomes ONE retailer-facing parent order
712
+ # with a sub-order each; a single-business cart stays a plain standalone
713
+ # order (no parent). Shared by checkout and reorder — they differ only in
714
+ # where the cart and the shared order fields come from.
715
+ # Returns [parent_or_nil, orders].
716
+ def place_direct_listing_cart(resolved_items, base_attrs:)
717
+ groups = resolved_items.group_by { |ri| ri[:ordered_to] }.values
718
+ parent = nil
719
+ orders = []
720
+
721
+ ActiveRecord::Base.transaction do
722
+ parent = build_and_save_parent_order(base_attrs) if groups.size > 1
723
+ groups.each do |items|
724
+ orders << build_and_save_direct_listing_order(items, base_attrs, parent: parent)
725
+ end
726
+ parent&.sync_aggregates!
727
+ end
728
+
729
+ [ parent, orders ]
730
+ end
731
+
732
+ # The retailer-facing umbrella for a multi-business cart. Carries the
733
+ # shared checkout fields (who ordered, payment, fulfillment, agent) but
734
+ # no ordered_to (it spans businesses), no listing and no items — those
735
+ # live on the per-business sub-orders. Status/total are synced from the
736
+ # sub-orders (Order#sync_aggregates!).
737
+ def build_and_save_parent_order(base_attrs)
738
+ parent = @clazz.new(base_attrs.except(:ordered_to_id))
739
+ parent.order_type = :direct_listing
740
+ parent.status = :pending
741
+ parent.ordered_to = nil
742
+ parent.acts_as_parent = true
743
+ parent.save!
744
+ parent
745
+ end
746
+
747
+ def build_and_save_direct_listing_order(items, base_attrs, parent: nil)
513
748
  items.each { |ri| (ri[:listing] || ri[:aggregator_listing]).lock! }
514
749
 
515
750
  items.each do |ri|
@@ -520,10 +755,11 @@ module Dscf
520
755
  raise ActiveRecord::RecordInvalid.new(source)
521
756
  end
522
757
 
523
- order = @clazz.new(model_params.except(:order_items_attributes, :listing_id, :listing_type, :retailer_id))
758
+ order = @clazz.new(base_attrs)
524
759
  order.order_type = :direct_listing
525
760
  order.status = :pending
526
761
  order.ordered_to = items.first[:ordered_to]
762
+ order.parent_order = parent
527
763
  # Order#listing is a single FK; set it to the first supplier-listing item
528
764
  # so quotation_or_listing_present is satisfied for listing-only groups
529
765
  # (an aggregator-listing-sourced item satisfies it on its own — see
@@ -574,6 +810,17 @@ module Dscf
574
810
  order
575
811
  end
576
812
 
813
+ # Item-level workflow (validate/split/confirm/complete/invoice/…) happens
814
+ # on the per-business sub-orders — a parent has no items to act on. The
815
+ # retailer-facing actions that DO work on a parent (retailer_confirm,
816
+ # cancel) fan out explicitly instead of calling this guard.
817
+ def parent_order_guard(order)
818
+ return false unless order.parent?
819
+
820
+ render_error(errors: "Action not available on a parent order; act on its sub-orders", status: :unprocessable_entity)
821
+ true
822
+ end
823
+
577
824
  def direct_listing_request?
578
825
  %w[direct_listing 1].include?(model_params[:order_type].to_s)
579
826
  end
@@ -590,15 +837,15 @@ module Dscf
590
837
  end
591
838
 
592
839
  def eager_loaded_associations
593
- [ :quotation, :listing, :user, :ordered_by, :ordered_to, :delivery_order, :order_items ]
840
+ [ :quotation, :listing, :user, :ordered_by, :ordered_to, :delivery_order, :order_items, { sub_orders: :order_items } ]
594
841
  end
595
842
 
596
843
  def default_serializer_includes
597
844
  {
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 ]
845
+ index: [ :user, :ordered_by, :ordered_to, :quotation, :order_items, "sub_orders.order_items" ],
846
+ show: [ :user, :ordered_by, :ordered_to, :quotation, :listing, :delivery_order, :order_items, "sub_orders.order_items" ],
847
+ create: [ :user, :ordered_by, :ordered_to, :quotation, :listing, :order_items, "sub_orders.order_items" ],
848
+ update: [ :user, :ordered_by, :ordered_to, :quotation, :listing, :order_items, "sub_orders.order_items" ]
602
849
  }
603
850
  end
604
851
 
@@ -36,8 +36,94 @@ 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
+ # Listings offering a given catalogue product, whichever way they source it.
91
+ # Used to re-find a listing for a product the retailer bought before — the
92
+ # order line records the product, not the listing it came from.
93
+ scope :for_product, ->(product_id) { where("(#{resolved_product_id_sql}) = ?", product_id) }
94
+
95
+ # Pulls one column off the resolved product row.
96
+ def self.resolved_product_column_sql(column)
97
+ <<~SQL.squish
98
+ (SELECT p.#{column} FROM #{Product.table_name} p
99
+ WHERE p.id = (#{resolved_product_id_sql}))
100
+ SQL
101
+ end
102
+
103
+ ransacker :product_name do
104
+ Arel.sql(resolved_product_column_sql(:name))
105
+ end
106
+
107
+ ransacker :product_description do
108
+ Arel.sql(resolved_product_column_sql(:description))
109
+ end
110
+
111
+ ransacker :product_category_id, type: :integer do
112
+ Arel.sql(resolved_product_column_sql(:category_id))
113
+ end
114
+
115
+ # Category *name*, so `q[product_category_cont]=Spice` works without the
116
+ # client having to look the category id up first.
117
+ ransacker :product_category do
118
+ Arel.sql(<<~SQL.squish)
119
+ (SELECT c.name FROM #{Category.table_name} c
120
+ WHERE c.id = #{resolved_product_column_sql(:category_id)})
121
+ SQL
122
+ end
123
+
39
124
  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]
125
+ %w[id aggregator_id supplier_product_id product_id sub_supplier_product_id source_kind price quantity status created_at updated_at] +
126
+ %w[product_name product_description product_category product_category_id]
41
127
  end
42
128
 
43
129
  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
- belongs_to :ordered_to, class_name: "Dscf::Core::Business"
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
- validates :ordered_to, presence: true
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
- otp_verifications.verified.exists?
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
@@ -17,6 +17,12 @@ module Dscf
17
17
  user.has_permission?("orders.cancel")
18
18
  end
19
19
 
20
+ # Reorder places a new order, so it is gated on create rather than on
21
+ # read access to the order being copied.
22
+ def reorder?
23
+ user.has_permission?("orders.create")
24
+ end
25
+
20
26
  def complete?
21
27
  user.has_permission?("orders.complete")
22
28
  end
@@ -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, :thumbnail_url, :images_urls,
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 = Dscf::Marketplace::Order.where(ordered_by: @current_user)
10
- .includes(:quotation, :listing, :user, :ordered_by, :ordered_to, :delivery_order, :order_items)
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
data/config/routes.rb CHANGED
@@ -124,6 +124,7 @@ Dscf::Marketplace::Engine.routes.draw do
124
124
  resources :order_items, only: [ :index ]
125
125
  member do
126
126
  post "confirm"
127
+ post "reorder"
127
128
  post "cancel"
128
129
  post "complete"
129
130
  get "invoice"
@@ -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
@@ -1,5 +1,5 @@
1
1
  module Dscf
2
2
  module Marketplace
3
- VERSION = "0.13.13".freeze
3
+ VERSION = "0.16.0".freeze
4
4
  end
5
5
  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.13.13
4
+ version: 0.16.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Asrat
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-07-09 00:00:00.000000000 Z
10
+ date: 2026-07-29 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