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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c4a2b0e347680bb877dbcce80829d80bddb751cd5248a674db3208ba169f4824
4
- data.tar.gz: 450244b5f6bb3bfe6933dc5bcbe6ec1ce9a0e4cf12d2b6ba38fbba64451c3009
3
+ metadata.gz: 96c72fe29f50fca0d789f14bdf6fe195678ec9dc1f65d7284de72a6aa24dff20
4
+ data.tar.gz: fa9fb4e1e7dab26a304efb5a528db3a0bcdab579d36c51a2505588b31fe40a9e
5
5
  SHA512:
6
- metadata.gz: 2c541d060329f90f173c4489da2991aa8e7ec95edd1eaf71cdea683ac6e676d2316990c24d05d8d6c85f59394b9d0a9e432164ff32449876703b61d95c0ad6c3
7
- data.tar.gz: e482e00be97fae32c2b57501b9c036520ddbf385e0bc12b3290e8e687907e7da5ca8673e6778f76d132ef85d4a8f900e51abb28ac02a790a3a6eb3ef1d221496
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
- 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,
@@ -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
- return render_error(errors: "Order not ready for retailer confirmation") unless obj.retailer_can_confirm?
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 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)
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
- [ order.agent&.user, order.ordered_to&.user ].compact.each do |recipient|
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
- resolved_items.group_by { |ri| ri[:ordered_to] }.each_value do |items|
402
- orders << build_and_save_direct_listing_order(items)
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
- orders.each { |order| send_agent_order_otp(order, agent_order_retailer) } if agent_order_retailer
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
- orders = orders.map { |o| @clazz.includes(eager_loaded_associations).find(o.id) } if eager_loaded_associations.present?
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 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,
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
- def build_and_save_direct_listing_order(items)
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
- 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
@@ -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
@@ -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.15.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.15.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-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