dscf-marketplace 0.8.0 → 0.8.1

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: 5f0f591983cea40d4d2f6a5c4467b7d545de79760fc7911ab344859f7511c77a
4
- data.tar.gz: 99b10ebe989e7152e2727da4c789dc857a33bf348192f706618f041c22bddc71
3
+ metadata.gz: f20dc6ec95c1bd2483126bf1f0b241ba3403999673a705b9f0e72e214ec29889
4
+ data.tar.gz: 6233b5ae039cd138f26ca51d70db6a9458e51b25d29a2ba21d37a142b103b933
5
5
  SHA512:
6
- metadata.gz: 81582f57d99751874955a157a46e73588352f38e1c93ae2ac08a11f2f5b7419df0cbd16076a4d0653aa0026d219a9e183b58ee7cb432077aed6365a6bc79dfc5
7
- data.tar.gz: fe8a10f50d36184274dceeb2d36d09406d5beda9665b064d0a45b6858a6e4088fd6ad5bcf053f56d8e3cba9f7fa37a9e860a23c5e3d71645f8a3dcf6facb281e
6
+ metadata.gz: b852b81aa1b9ea52cc219e40b1056691d4ed7d464e0fd684bf1395abf4219cd35bf07c2f7fa67ab6fc5397f438a95e420a98e826465e331002d1ebebe672ef8c
7
+ data.tar.gz: 0e32006533ed1f96fead2d9eb34b03cc74ed5eaf62a4ad5eb0eeb7e7c36b167d58559d0d27d5c023f5d67fef2c80a48783da0f13e08a7888c3fac93ce505cea4
@@ -33,6 +33,47 @@ module Dscf
33
33
  end
34
34
  end
35
35
 
36
+ def promote
37
+ @obj = find_record
38
+ authorize @obj, :promote?
39
+ if @obj.update(
40
+ orchestrator_promoted: true,
41
+ orchestrator_promoted_at: Time.current,
42
+ promoted_by: current_user
43
+ )
44
+ render_success("listings.success.promoted", data: @obj)
45
+ else
46
+ render_error("listings.errors.promote_failed")
47
+ end
48
+ end
49
+
50
+ def unpromote
51
+ @obj = find_record
52
+ authorize @obj, :unpromote?
53
+ if @obj.update(
54
+ orchestrator_promoted: false,
55
+ orchestrator_promoted_at: nil,
56
+ promoted_by: nil
57
+ )
58
+ render_success("listings.success.unpromoted", data: @obj)
59
+ else
60
+ render_error("listings.errors.unpromote_failed")
61
+ end
62
+ end
63
+
64
+ # Public feed: only visible listings (computed by 3 conditions)
65
+ def visible
66
+ authorize @clazz.new, :visible?
67
+ listings = @clazz.visible.includes(eager_loaded_associations)
68
+
69
+ options = {
70
+ include: default_serializer_includes[:index] || [],
71
+ meta: { resource_type: "visible_listings" }
72
+ }
73
+
74
+ render_success(data: listings, serializer_options: options)
75
+ end
76
+
36
77
  def my_listings
37
78
  authorize @clazz.new, :my_listings?
38
79
  service = MyResourceService.new(current_user)
@@ -40,7 +81,7 @@ module Dscf
40
81
 
41
82
  options = {
42
83
  include: default_serializer_includes[:index] || [],
43
- meta: {resource_type: "my_listings"}
84
+ meta: { resource_type: "my_listings" }
44
85
  }
45
86
 
46
87
  render_success("listings.success.index", data: listings, serializer_options: options)
@@ -50,7 +91,6 @@ module Dscf
50
91
  authorize @clazz.new, :listings_by_supplier?
51
92
  supplier_id = params[:supplier_id]
52
93
 
53
- # Validate supplier exists
54
94
  supplier = Dscf::Core::Business.find_by(id: supplier_id)
55
95
  return render_error("listings.errors.supplier_not_found") unless supplier
56
96
 
@@ -71,6 +111,10 @@ module Dscf
71
111
 
72
112
  private
73
113
 
114
+ def find_record
115
+ @clazz.find(params[:id])
116
+ end
117
+
74
118
  def model_params
75
119
  params.require(:listing).permit(
76
120
  :business_id, :supplier_product_id, :price, :quantity, :status
@@ -3,6 +3,7 @@ module Dscf::Marketplace
3
3
  # References
4
4
  belongs_to :business, class_name: "Dscf::Core::Business"
5
5
  belongs_to :supplier_product, class_name: "Dscf::Marketplace::SupplierProduct"
6
+ belongs_to :promoted_by, class_name: "Dscf::Core::User", optional: true
6
7
 
7
8
  # Relationships
8
9
  has_many :order_items, class_name: "Dscf::Marketplace::OrderItem"
@@ -20,14 +21,106 @@ module Dscf::Marketplace
20
21
  scope :active, -> { where(status: :active) }
21
22
  scope :by_business, ->(business_id) { where(business_id: business_id) }
22
23
  scope :owned_by, ->(business) { where(business: business) }
24
+ scope :promoted, -> { where(orchestrator_promoted: true) }
25
+ scope :not_promoted, -> { where(orchestrator_promoted: false) }
26
+
27
+ # Returns listings visible to end users based on 3 conditions:
28
+ # 1. Orchestrator promoted (manual override)
29
+ # 2. Sole provider — only one active listing for this product
30
+ # 3. Cheapest price — lowest price among active listings for the same product
31
+ scope :visible, -> {
32
+ active.where(<<~SQL.squish)
33
+ orchestrator_promoted = true
34
+ OR (
35
+ SELECT COUNT(*) FROM dscf_marketplace_listings l2
36
+ JOIN dscf_marketplace_supplier_products sp2 ON l2.supplier_product_id = sp2.id
37
+ WHERE sp2.product_id = (
38
+ SELECT sp.product_id FROM dscf_marketplace_supplier_products sp
39
+ WHERE sp.id = dscf_marketplace_listings.supplier_product_id
40
+ )
41
+ AND l2.status = #{Listing.statuses[:active]}
42
+ ) = 1
43
+ OR (
44
+ dscf_marketplace_listings.price = (
45
+ SELECT MIN(l3.price) FROM dscf_marketplace_listings l3
46
+ JOIN dscf_marketplace_supplier_products sp3 ON l3.supplier_product_id = sp3.id
47
+ WHERE sp3.product_id = (
48
+ SELECT sp.product_id FROM dscf_marketplace_supplier_products sp
49
+ WHERE sp.id = dscf_marketplace_listings.supplier_product_id
50
+ )
51
+ AND l3.status = #{Listing.statuses[:active]}
52
+ )
53
+ )
54
+ SQL
55
+ }
23
56
 
24
57
  # Ransack configuration for secure filtering
25
58
  def self.ransackable_attributes(_auth_object = nil)
26
- %w[id business_id supplier_product_id price quantity status created_at updated_at]
59
+ %w[id business_id supplier_product_id price quantity status
60
+ orchestrator_promoted orchestrator_promoted_at promoted_by_id
61
+ created_at updated_at]
27
62
  end
28
63
 
29
64
  def self.ransackable_associations(_auth_object = nil)
30
- %w[business supplier_product order_items]
65
+ %w[business supplier_product order_items promoted_by]
66
+ end
67
+
68
+ # Is this listing the only active listing for its product?
69
+ def sole_provider?
70
+ self.class.active
71
+ .joins(:supplier_product)
72
+ .where(supplier_product: { product_id: supplier_product.product_id })
73
+ .count == 1
74
+ end
75
+
76
+ # Does this listing have the lowest price for its product?
77
+ def cheapest?
78
+ self.class.active
79
+ .joins(:supplier_product)
80
+ .where(supplier_product: { product_id: supplier_product.product_id })
81
+ .minimum(:price) == price
82
+ end
83
+
84
+ # Should this listing be shown to end users?
85
+ def visible?
86
+ return false unless active? && quantity > 0
87
+ return true if orchestrator_promoted?
88
+
89
+ product_id = supplier_product.product_id
90
+ active_listings = self.class.active.joins(:supplier_product)
91
+ .where(supplier_product: { product_id: product_id })
92
+
93
+ active_listings.count == 1 || active_listings.minimum(:price) == price
94
+ end
95
+
96
+ # Returns :orchestrator_promoted, :sole_provider, :cheapest, or nil
97
+ def visibility_reason
98
+ return nil unless visible?
99
+ return :orchestrator_promoted if orchestrator_promoted?
100
+ return :sole_provider if sole_provider?
101
+ return :cheapest if cheapest?
102
+ end
103
+
104
+ # Market insight: how many active listings exist for this product
105
+ def total_listings_for_product
106
+ self.class.active.joins(:supplier_product)
107
+ .where(supplier_product: { product_id: supplier_product.product_id })
108
+ .count
109
+ end
110
+
111
+ # Market insight: lowest price across all businesses for this product
112
+ def cheapest_price_for_product
113
+ self.class.active.joins(:supplier_product)
114
+ .where(supplier_product: { product_id: supplier_product.product_id })
115
+ .minimum(:price)
116
+ end
117
+
118
+ # Market insight: this listing's price rank (1 = cheapest)
119
+ def price_rank
120
+ self.class.active.joins(:supplier_product)
121
+ .where(supplier_product: { product_id: supplier_product.product_id })
122
+ .where("price < ?", price)
123
+ .count + 1
31
124
  end
32
125
 
33
126
  # Custom methods
@@ -74,7 +74,7 @@ module Dscf::Marketplace
74
74
  end
75
75
 
76
76
  def self.create_from_listing(listing, user, quantity, dropoff_address = nil, payment_method = nil)
77
- return nil unless listing.status == "active" && quantity <= listing.quantity
77
+ return nil unless listing.visible? && quantity <= listing.quantity
78
78
 
79
79
  attributes = {
80
80
  order_type: :direct_listing,
@@ -13,6 +13,18 @@ module Dscf
13
13
  user.has_permission?("listings.sold_out")
14
14
  end
15
15
 
16
+ def promote?
17
+ user.has_permission?("listings.promote")
18
+ end
19
+
20
+ def unpromote?
21
+ user.has_permission?("listings.unpromote")
22
+ end
23
+
24
+ def visible?
25
+ true
26
+ end
27
+
16
28
  def my_listings?
17
29
  user.has_permission?("listings.my_listings")
18
30
  end
@@ -3,11 +3,43 @@ module Dscf
3
3
  class ListingSerializer < ActiveModel::Serializer
4
4
  attributes :id, :business_id, :supplier_product_id, :price, :quantity,
5
5
  :status, :created_at, :updated_at, :total_value, :margin,
6
- :margin_percentage, :available?
6
+ :margin_percentage, :available?,
7
+ :visible, :visibility_reason, :orchestrator_promoted,
8
+ :orchestrator_promoted_at, :promoted_by_id,
9
+ :sole_provider, :cheapest,
10
+ :total_listings_for_product, :cheapest_price_for_product, :price_rank
7
11
 
8
12
  belongs_to :business
9
13
  belongs_to :supplier_product
10
14
  has_many :order_items
15
+
16
+ def visible
17
+ object.visible?
18
+ end
19
+
20
+ def visibility_reason
21
+ object.visibility_reason
22
+ end
23
+
24
+ def sole_provider
25
+ object.sole_provider?
26
+ end
27
+
28
+ def cheapest
29
+ object.cheapest?
30
+ end
31
+
32
+ def total_listings_for_product
33
+ object.total_listings_for_product
34
+ end
35
+
36
+ def cheapest_price_for_product
37
+ object.cheapest_price_for_product
38
+ end
39
+
40
+ def price_rank
41
+ object.price_rank
42
+ end
11
43
  end
12
44
  end
13
45
  end
@@ -102,6 +102,9 @@ en:
102
102
  activated: "Listing activated successfully"
103
103
  paused: "Listing paused successfully"
104
104
  sold_out: "Listing marked as sold out successfully"
105
+ promoted: "Listing promoted by orchestrator successfully"
106
+ unpromoted: "Listing unpromoted successfully"
107
+ visible: "Visible listings retrieved successfully"
105
108
  errors:
106
109
  index: "Failed to retrieve listings"
107
110
  show: "Failed to retrieve listing details"
@@ -109,6 +112,9 @@ en:
109
112
  update: "Failed to update listing"
110
113
  destroy: "Failed to delete listing"
111
114
  supplier_not_found: "Supplier not found"
115
+ promote_failed: "Failed to promote listing"
116
+ unpromote_failed: "Failed to unpromote listing"
117
+ visible_failed: "Failed to retrieve visible listings"
112
118
 
113
119
  request_for_quotations:
114
120
  success:
data/config/routes.rb CHANGED
@@ -51,9 +51,12 @@ Dscf::Marketplace::Engine.routes.draw do
51
51
  post "activate"
52
52
  post "pause"
53
53
  post "sold_out"
54
+ post "promote"
55
+ post "unpromote"
54
56
  end
55
57
  collection do
56
58
  get "my_listings"
59
+ get "visible"
57
60
  get "by_supplier/:supplier_id", to: "listings#listings_by_supplier"
58
61
  end
59
62
  end
@@ -0,0 +1,22 @@
1
+ class AddOrchestratorFieldsToListings < ActiveRecord::Migration[8.0]
2
+ def change
3
+ add_column :dscf_marketplace_listings, :orchestrator_promoted, :boolean, default: false, null: false
4
+ add_column :dscf_marketplace_listings, :orchestrator_promoted_at, :datetime
5
+
6
+ add_reference :dscf_marketplace_listings, :promoted_by,
7
+ foreign_key: { to_table: :dscf_core_users },
8
+ index: { name: "promoted_by_on_dm_listings_indx" }
9
+
10
+ # Backfill: existing active listings remain visible after deploy
11
+ reversible do |dir|
12
+ dir.up do
13
+ execute <<~SQL.squish
14
+ UPDATE dscf_marketplace_listings
15
+ SET orchestrator_promoted = true,
16
+ orchestrator_promoted_at = NOW()
17
+ WHERE status = 0
18
+ SQL
19
+ end
20
+ end
21
+ end
22
+ end
@@ -1,5 +1,5 @@
1
1
  module Dscf
2
2
  module Marketplace
3
- VERSION = "0.8.0".freeze
3
+ VERSION = "0.8.1".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.8.0
4
+ version: 0.8.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Asrat
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-05-24 00:00:00.000000000 Z
10
+ date: 2026-05-27 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: rails
@@ -550,6 +550,7 @@ files:
550
550
  - db/migrate/20260514000004_replace_base_price_with_gross_weight_on_products.rb
551
551
  - db/migrate/20260514000005_create_dscf_marketplace_aggregator_listings.rb
552
552
  - db/migrate/20260514000006_add_supplier_id_to_addresses.rb
553
+ - db/migrate/20260527000001_add_orchestrator_fields_to_listings.rb
553
554
  - db/seeds.rb
554
555
  - lib/dscf/marketplace.rb
555
556
  - lib/dscf/marketplace/engine.rb