dscf-marketplace 0.15.0 → 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:
|
|
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
|
|
@@ -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
|
|
@@ -451,19 +506,8 @@ module Dscf
|
|
|
451
506
|
resolved_items = resolve_direct_listing_items
|
|
452
507
|
return if performed? # resolve_direct_listing_items already rendered an error
|
|
453
508
|
|
|
454
|
-
|
|
455
|
-
parent =
|
|
456
|
-
orders = []
|
|
457
|
-
ActiveRecord::Base.transaction do
|
|
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)
|
|
464
|
-
end
|
|
465
|
-
parent&.sync_aggregates!
|
|
466
|
-
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)
|
|
467
511
|
|
|
468
512
|
if agent_order_retailer
|
|
469
513
|
# One OTP for the whole cart — on the parent when split, so the
|
|
@@ -576,13 +620,122 @@ module Dscf
|
|
|
576
620
|
# Order with one line per item, saves it, then decrements each source's
|
|
577
621
|
# quantity. Raises ActiveRecord::RecordInvalid on insufficient stock so
|
|
578
622
|
# the whole checkout (all groups) rolls back together.
|
|
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
|
+
|
|
579
732
|
# The retailer-facing umbrella for a multi-business cart. Carries the
|
|
580
733
|
# shared checkout fields (who ordered, payment, fulfillment, agent) but
|
|
581
734
|
# no ordered_to (it spans businesses), no listing and no items — those
|
|
582
735
|
# live on the per-business sub-orders. Status/total are synced from the
|
|
583
736
|
# sub-orders (Order#sync_aggregates!).
|
|
584
|
-
def build_and_save_parent_order
|
|
585
|
-
parent = @clazz.new(
|
|
737
|
+
def build_and_save_parent_order(base_attrs)
|
|
738
|
+
parent = @clazz.new(base_attrs.except(:ordered_to_id))
|
|
586
739
|
parent.order_type = :direct_listing
|
|
587
740
|
parent.status = :pending
|
|
588
741
|
parent.ordered_to = nil
|
|
@@ -591,7 +744,7 @@ module Dscf
|
|
|
591
744
|
parent
|
|
592
745
|
end
|
|
593
746
|
|
|
594
|
-
def build_and_save_direct_listing_order(items, parent: nil)
|
|
747
|
+
def build_and_save_direct_listing_order(items, base_attrs, parent: nil)
|
|
595
748
|
items.each { |ri| (ri[:listing] || ri[:aggregator_listing]).lock! }
|
|
596
749
|
|
|
597
750
|
items.each do |ri|
|
|
@@ -602,7 +755,7 @@ module Dscf
|
|
|
602
755
|
raise ActiveRecord::RecordInvalid.new(source)
|
|
603
756
|
end
|
|
604
757
|
|
|
605
|
-
order = @clazz.new(
|
|
758
|
+
order = @clazz.new(base_attrs)
|
|
606
759
|
order.order_type = :direct_listing
|
|
607
760
|
order.status = :pending
|
|
608
761
|
order.ordered_to = items.first[:ordered_to]
|
|
@@ -87,6 +87,11 @@ module Dscf::Marketplace
|
|
|
87
87
|
SQL
|
|
88
88
|
end
|
|
89
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
|
+
|
|
90
95
|
# Pulls one column off the resolved product row.
|
|
91
96
|
def self.resolved_product_column_sql(column)
|
|
92
97
|
<<~SQL.squish
|
|
@@ -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
|
data/config/routes.rb
CHANGED
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
|