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 +4 -4
- data/app/controllers/dscf/marketplace/aggregator_listings_controller.rb +42 -3
- data/app/controllers/dscf/marketplace/orders_controller.rb +277 -30
- data/app/models/dscf/marketplace/aggregator_listing.rb +87 -1
- data/app/models/dscf/marketplace/order.rb +76 -5
- data/app/policies/dscf/marketplace/order_policy.rb +6 -0
- 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/config/routes.rb +1 -0
- 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: 3cc1c9aaf986ce406e613ac0b3dc31e9a230a8a4e996ef61df353ef51003cdd6
|
|
4
|
+
data.tar.gz: ca71e2b9a1310eb873818fdeede519b86af08ca7db17764d0a123ab287c23376
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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,
|
|
@@ -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
|
-
|
|
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
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
obj.
|
|
320
|
-
|
|
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
|
-
|
|
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
|
-
|
|
400
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
413
|
-
render_success(data:
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
@@ -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, :
|
|
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
|
data/config/routes.rb
CHANGED
|
@@ -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.16.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-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
|