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: 96c72fe29f50fca0d789f14bdf6fe195678ec9dc1f65d7284de72a6aa24dff20
4
- data.tar.gz: fa9fb4e1e7dab26a304efb5a528db3a0bcdab579d36c51a2505588b31fe40a9e
3
+ metadata.gz: 3cc1c9aaf986ce406e613ac0b3dc31e9a230a8a4e996ef61df353ef51003cdd6
4
+ data.tar.gz: ca71e2b9a1310eb873818fdeede519b86af08ca7db17764d0a123ab287c23376
5
5
  SHA512:
6
- metadata.gz: 1adf7c893ca340a6f77021fdc354c35cefbdb8aee7bacb0e412a7a42082b4ea33c37fc8c4a8b26c7048057929b249b241d733182fc9be669a53b44ef83df0e4d
7
- data.tar.gz: 07a3f375e446fbc537ec377c61de0800d18c8e42a94cc57369274d3b3dbacd96e66a071cb84f747ca14fb4c89fb37198f22faa2e61357faabb0889106139645d
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
- groups = resolved_items.group_by { |ri| ri[:ordered_to] }.values
455
- parent = nil
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(model_params.except(:order_items_attributes, :listing_id, :listing_type, :retailer_id, :ordered_to_id))
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(model_params.except(:order_items_attributes, :listing_id, :listing_type, :retailer_id))
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
@@ -124,6 +124,7 @@ Dscf::Marketplace::Engine.routes.draw do
124
124
  resources :order_items, only: [ :index ]
125
125
  member do
126
126
  post "confirm"
127
+ post "reorder"
127
128
  post "cancel"
128
129
  post "complete"
129
130
  get "invoice"
@@ -1,5 +1,5 @@
1
1
  module Dscf
2
2
  module Marketplace
3
- VERSION = "0.15.0".freeze
3
+ VERSION = "0.16.0".freeze
4
4
  end
5
5
  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.15.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-25 00:00:00.000000000 Z
10
+ date: 2026-07-29 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: rails