dscf-marketplace 0.9.9 → 0.10.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: '09ac0d7d913260fb2bbf266a66b72820f7f5514795df0408941d3ab5ee1520d5'
4
- data.tar.gz: 22f50cc572af09ce48a9cce8efae3feba6999c405034d8652700647793018ea7
3
+ metadata.gz: 007d71d7d41ca79db79fe7097cda7d169eb5ad0cc973523a8aa6d8ce52555bee
4
+ data.tar.gz: 7b57bd0640a1dc86b8d67e89a4fd93699d1386aab09202266c6bd93ea98de3a8
5
5
  SHA512:
6
- metadata.gz: 79ea2b6a8236df42e17451ba5e0c3b7446a9c937bc325010995583c6440a82da0dece86e61c975d9e42c46a1c287ad479a9edb269cb59477df683a41037d3a2b
7
- data.tar.gz: fdd0bd5e5bd5ee0bcc7732cd8326f9c07b5701faf5fa3815c9923b7cb65b852504b0e0b205be9a92e3dd7ea319b38477fb67cbf85739d65e61eb6232ff1296bc
6
+ metadata.gz: 1a52333ddfbb43afed4d019777f07aabbb2b5bd9fbbdfee5e386075f9e3bc3baa6778d8924ffd7db8c90cf781aa9a4f92418e69610be388ec2d9b3056507a501
7
+ data.tar.gz: f2fd36c7e8073ebbac241432c22639df6b84528f3893045eb686bd6cc4fda94cd145368cb95f548734cab064219323696b2921d1fb6c23a2b7a1346c19bcf5f8
@@ -5,7 +5,7 @@ module Dscf
5
5
 
6
6
  def feed
7
7
  authorize @clazz.new, :index?
8
- listings = @clazz.active.includes(supplier_product: :product)
8
+ listings = @clazz.active.includes(:product, {supplier_product: :product}, {sub_supplier_product: :product})
9
9
 
10
10
  options = {
11
11
  include: default_serializer_includes[:index] || [],
@@ -18,7 +18,7 @@ module Dscf
18
18
  def my_listings
19
19
  authorize @clazz.new, :my_listings?
20
20
  businesses = current_user.businesses.pluck(:id)
21
- listings = @clazz.where(aggregator_id: businesses).includes(supplier_product: :product)
21
+ listings = @clazz.where(aggregator_id: businesses).includes(:product, {supplier_product: :product}, {sub_supplier_product: :product})
22
22
 
23
23
  options = {
24
24
  include: default_serializer_includes[:index] || [],
@@ -32,7 +32,8 @@ module Dscf
32
32
 
33
33
  def model_params
34
34
  params.require(:aggregator_listing).permit(
35
- :aggregator_id, :supplier_product_id, :price, :quantity, :status
35
+ :aggregator_id, :source_kind, :supplier_product_id, :product_id,
36
+ :sub_supplier_product_id, :price, :quantity, :status
36
37
  )
37
38
  end
38
39
  end
@@ -314,7 +314,12 @@ module Dscf
314
314
  order.status = :pending
315
315
  order.ordered_to = aggregator_listing.aggregator
316
316
 
317
- product = aggregator_listing.supplier_product.product
317
+ product = aggregator_listing.product
318
+ # Order lines from the aggregator feed are auto-sourced to the originating
319
+ # AggregatorListing (regardless of source_kind) — the aggregator already
320
+ # knows the source, so there's no manual pick at split time. The listing's
321
+ # source_kind carries who ultimately fulfils it (self / sub-supplier /
322
+ # supplier), surfaced via source_name for display and confirmation routing.
318
323
  order.order_items.build(
319
324
  source: aggregator_listing,
320
325
  product: product,
@@ -0,0 +1,29 @@
1
+ module Dscf
2
+ module Marketplace
3
+ class SubSupplierProductsController < ApplicationController
4
+ include Dscf::Core::Common
5
+
6
+ private
7
+
8
+ def model_params
9
+ params.require(:sub_supplier_product).permit(
10
+ :sub_supplier_id, :product_id, :sub_supplier_price,
11
+ :aggregator_price, :quantity, :listed
12
+ )
13
+ end
14
+
15
+ def eager_loaded_associations
16
+ [ :sub_supplier, :product ]
17
+ end
18
+
19
+ def default_serializer_includes
20
+ {
21
+ index: [ :sub_supplier, :product ],
22
+ show: [ :sub_supplier, :product ],
23
+ create: [ :sub_supplier, :product ],
24
+ update: [ :sub_supplier, :product ]
25
+ }
26
+ end
27
+ end
28
+ end
29
+ end
@@ -1,32 +1,74 @@
1
1
  module Dscf::Marketplace
2
2
  class AggregatorListing < ApplicationRecord
3
3
  belongs_to :aggregator, class_name: "Dscf::Core::Business"
4
- belongs_to :supplier_product, class_name: "Dscf::Marketplace::SupplierProduct"
4
+ belongs_to :supplier_product, class_name: "Dscf::Marketplace::SupplierProduct", optional: true
5
+ belongs_to :product, class_name: "Dscf::Marketplace::Product", optional: true
6
+ belongs_to :sub_supplier_product, class_name: "Dscf::Marketplace::SubSupplierProduct", optional: true
5
7
 
6
8
  has_many :order_items, class_name: "Dscf::Marketplace::OrderItem", as: :source, dependent: :nullify
7
9
 
8
10
  delegate :name, :description, :thumbnail_url, :images_urls, to: :product, allow_nil: true
9
11
 
10
12
  enum :status, {active: 0, draft: 1, paused: 2, sold_out: 3}, default: :draft
13
+ # How the aggregator sourced this listing. Drives which reference is required,
14
+ # how the product/cost resolve, and who fulfils an order line placed against it.
15
+ enum :source_kind, {catalog: 0, supplier: 1, sub_supplier: 2}, default: :catalog
11
16
 
12
17
  validates :price, numericality: {greater_than: 0}, presence: true
13
18
  validates :quantity, numericality: {greater_than: 0}, presence: true
14
19
  validates :aggregator, presence: true
15
- validates :supplier_product_id, presence: true, uniqueness: {scope: :aggregator_id}
20
+
21
+ # Each kind requires (and is unique on) exactly one reference.
22
+ validates :supplier_product_id, presence: true, uniqueness: {scope: :aggregator_id}, if: :supplier?
23
+ validates :product_id, presence: true, uniqueness: {scope: :aggregator_id}, if: :catalog?
24
+ validates :sub_supplier_product_id, presence: true, uniqueness: {scope: :aggregator_id}, if: :sub_supplier?
16
25
 
17
26
  scope :active, -> { where(status: :active) }
18
27
  scope :by_aggregator, ->(aggregator_id) { where(aggregator_id: aggregator_id) }
19
28
 
20
29
  def self.ransackable_attributes(_auth_object = nil)
21
- %w[id aggregator_id supplier_product_id price quantity status created_at updated_at]
30
+ %w[id aggregator_id supplier_product_id product_id sub_supplier_product_id source_kind price quantity status created_at updated_at]
22
31
  end
23
32
 
24
33
  def self.ransackable_associations(_auth_object = nil)
25
- %w[aggregator supplier_product]
34
+ %w[aggregator supplier_product product sub_supplier_product]
26
35
  end
27
36
 
37
+ # Resolves the catalogue product regardless of how the listing was sourced.
28
38
  def product
29
- supplier_product&.product
39
+ case source_kind
40
+ when "supplier" then supplier_product&.product
41
+ when "sub_supplier" then sub_supplier_product&.product
42
+ else super
43
+ end
44
+ end
45
+
46
+ # The aggregator's cost basis for this listing (nil for catalog — the aggregator
47
+ # sets a selling price directly with no upstream cost to compare against).
48
+ def cost_price
49
+ case source_kind
50
+ when "supplier" then supplier_product&.supplier_price
51
+ when "sub_supplier" then sub_supplier_product&.sub_supplier_price
52
+ end
53
+ end
54
+
55
+ def margin
56
+ return nil unless cost_price
57
+ price - cost_price
58
+ end
59
+
60
+ # Human-readable name of who ultimately fulfils a line placed against this
61
+ # listing. An order line is auto-sourced to the AggregatorListing itself, so
62
+ # this drives the "Product source name" the requirements ask for:
63
+ # catalog -> the aggregator (self-fulfilled)
64
+ # supplier -> the supplier's business name
65
+ # sub_supplier -> the sub-supplier's business name
66
+ def source_label
67
+ case source_kind
68
+ when "supplier" then supplier_product&.business&.name
69
+ when "sub_supplier" then sub_supplier_product&.sub_supplier&.business_name
70
+ else aggregator&.name
71
+ end
30
72
  end
31
73
 
32
74
  def available?
@@ -22,7 +22,7 @@ module Dscf::Marketplace
22
22
  validates :status, presence: true
23
23
  validates :product, presence: true
24
24
  validates :unit, presence: true
25
- validate :quotation_item_or_listing_present
25
+ validate :orderable_source_present
26
26
 
27
27
  # Ransack configuration for secure filtering
28
28
  def self.ransackable_attributes(_auth_object = nil)
@@ -55,10 +55,15 @@ module Dscf::Marketplace
55
55
 
56
56
  private
57
57
 
58
- def quotation_item_or_listing_present
59
- unless quotation_item.present? || listing.present? || (source_type == "Dscf::Marketplace::AggregatorListing" && source_id.present?)
60
- errors.add(:base, "Either quotation_item, listing, or aggregator listing source must be present")
61
- end
58
+ # A line must trace back to something orderable: an RFQ quotation item, a
59
+ # supplier listing, or any assigned polymorphic source (aggregator listing,
60
+ # sub-supplier, other supplier/business). The last case is what lets the
61
+ # aggregator reassign a line to another source during splitting.
62
+ def orderable_source_present
63
+ return if quotation_item.present? || listing.present?
64
+ return if source_type.present? && source_id.present?
65
+
66
+ errors.add(:base, "Either a quotation item, a listing, or an assigned source must be present")
62
67
  end
63
68
  end
64
69
  end
@@ -1,9 +1,11 @@
1
1
  module Dscf
2
2
  module Marketplace
3
3
  class AggregatorListingSerializer < ActiveModel::Serializer
4
- attributes :id, :aggregator_id, :supplier_product_id, :price, :quantity,
4
+ attributes :id, :aggregator_id, :source_kind, :supplier_product_id, :product_id,
5
+ :sub_supplier_product_id, :price, :quantity, :cost_price, :margin,
5
6
  :status, :created_at, :updated_at, :total_value, :available?,
6
- :product_name, :product_description, :thumbnail_url, :images_urls
7
+ :product_name, :product_description, :thumbnail_url, :images_urls,
8
+ :source_label
7
9
 
8
10
  belongs_to :aggregator
9
11
 
@@ -16,6 +16,8 @@ module Dscf
16
16
  def source_name
17
17
  return nil unless object.source
18
18
  case object.source_type
19
+ when 'Dscf::Marketplace::AggregatorListing'
20
+ object.source.try(:source_label)
19
21
  when 'Dscf::Marketplace::SubSupplier'
20
22
  object.source.try(:business_name) || object.source.try(:name)
21
23
  when 'Dscf::Marketplace::Supplier'
@@ -0,0 +1,34 @@
1
+ module Dscf
2
+ module Marketplace
3
+ class SubSupplierProductSerializer < ActiveModel::Serializer
4
+ attributes :id, :sub_supplier_id, :product_id,
5
+ :sub_supplier_price, :aggregator_price, :quantity, :listed,
6
+ :created_at, :updated_at,
7
+ :product_name, :product_sku, :thumbnail_url, :images_urls,
8
+ :sub_supplier_name
9
+
10
+ belongs_to :sub_supplier
11
+ belongs_to :product
12
+
13
+ def product_name
14
+ object.product&.name
15
+ end
16
+
17
+ def product_sku
18
+ object.product&.sku
19
+ end
20
+
21
+ def thumbnail_url
22
+ object.product&.thumbnail_url
23
+ end
24
+
25
+ def images_urls
26
+ object.product&.images_urls
27
+ end
28
+
29
+ def sub_supplier_name
30
+ object.sub_supplier&.business_name
31
+ end
32
+ end
33
+ end
34
+ end
@@ -51,8 +51,17 @@ module Dscf
51
51
  item
52
52
  end
53
53
 
54
+ # Returns the current price/quantity reference for an item. This is duck-typed:
55
+ # any object responding to #price and #quantity works (Listing or
56
+ # AggregatorListing), since validate_item only reads those two fields.
54
57
  private_class_method def self.find_current_active_listing(item)
55
- # Prefer the original listing if still active
58
+ # Lines placed from the aggregator feed are sourced to their AggregatorListing —
59
+ # validate against that listing's live price/quantity, not a supplier listing.
60
+ if item.source.is_a?(AggregatorListing)
61
+ return AggregatorListing.active.find_by(id: item.source_id)
62
+ end
63
+
64
+ # Prefer the original supplier listing if still active
56
65
  if item.listing && Listing.active.exists?(id: item.listing.id)
57
66
  return item.listing
58
67
  end
data/config/routes.rb CHANGED
@@ -33,6 +33,7 @@ Dscf::Marketplace::Engine.routes.draw do
33
33
  end
34
34
 
35
35
  resources :sub_suppliers
36
+ resources :sub_supplier_products
36
37
 
37
38
  # Aggregator Feed
38
39
  resources :aggregator_listings do
@@ -0,0 +1,58 @@
1
+ class UnifyAggregatorListingSource < ActiveRecord::Migration[8.0]
2
+ def up
3
+ # New polymorphic-style source discriminator.
4
+ # 0 = catalog (aggregator lists a master-catalogue product directly)
5
+ # 1 = supplier (aggregator resells a supplier_product) <- the only kind that existed before
6
+ # 2 = sub_supplier (aggregator lists a sub-supplier product)
7
+ add_column :dscf_marketplace_aggregator_listings, :source_kind, :integer, default: 0, null: false
8
+
9
+ # Catalog / sub-supplier sourced listings don't have a supplier_product.
10
+ change_column_null :dscf_marketplace_aggregator_listings, :supplier_product_id, true
11
+
12
+ add_column :dscf_marketplace_aggregator_listings, :product_id, :bigint
13
+ add_column :dscf_marketplace_aggregator_listings, :sub_supplier_product_id, :bigint
14
+
15
+ add_index :dscf_marketplace_aggregator_listings, :product_id, name: "product_on_dm_al_indx"
16
+ add_index :dscf_marketplace_aggregator_listings, :sub_supplier_product_id, name: "ssp_on_dm_al_indx"
17
+
18
+ # Existing rows are all supplier-sourced; backfill kind + denormalised product_id.
19
+ execute(<<~SQL.squish)
20
+ UPDATE dscf_marketplace_aggregator_listings al
21
+ SET source_kind = 1,
22
+ product_id = sp.product_id
23
+ FROM dscf_marketplace_supplier_products sp
24
+ WHERE al.supplier_product_id = sp.id
25
+ AND al.supplier_product_id IS NOT NULL
26
+ SQL
27
+
28
+ # The old unique index assumed supplier_product_id is always present. Replace it
29
+ # with one partial unique index per kind so an aggregator can't double-list the
30
+ # same catalogue product / supplier_product / sub_supplier_product.
31
+ remove_index :dscf_marketplace_aggregator_listings, name: "unique_aggregator_sp_indx"
32
+
33
+ add_index :dscf_marketplace_aggregator_listings, [:aggregator_id, :supplier_product_id],
34
+ unique: true, where: "source_kind = 1", name: "uniq_agg_supplier_product_indx"
35
+ add_index :dscf_marketplace_aggregator_listings, [:aggregator_id, :product_id],
36
+ unique: true, where: "source_kind = 0", name: "uniq_agg_catalog_product_indx"
37
+ add_index :dscf_marketplace_aggregator_listings, [:aggregator_id, :sub_supplier_product_id],
38
+ unique: true, where: "source_kind = 2", name: "uniq_agg_sub_supplier_product_indx"
39
+ end
40
+
41
+ def down
42
+ remove_index :dscf_marketplace_aggregator_listings, name: "uniq_agg_supplier_product_indx"
43
+ remove_index :dscf_marketplace_aggregator_listings, name: "uniq_agg_catalog_product_indx"
44
+ remove_index :dscf_marketplace_aggregator_listings, name: "uniq_agg_sub_supplier_product_indx"
45
+
46
+ remove_index :dscf_marketplace_aggregator_listings, name: "product_on_dm_al_indx"
47
+ remove_index :dscf_marketplace_aggregator_listings, name: "ssp_on_dm_al_indx"
48
+
49
+ remove_column :dscf_marketplace_aggregator_listings, :product_id
50
+ remove_column :dscf_marketplace_aggregator_listings, :sub_supplier_product_id
51
+ remove_column :dscf_marketplace_aggregator_listings, :source_kind
52
+
53
+ # Restore the original unique index. Rows must be supplier-sourced for this to hold.
54
+ change_column_null :dscf_marketplace_aggregator_listings, :supplier_product_id, false
55
+ add_index :dscf_marketplace_aggregator_listings, [:aggregator_id, :supplier_product_id],
56
+ unique: true, name: "unique_aggregator_sp_indx"
57
+ end
58
+ end
@@ -28,6 +28,9 @@ module Dscf
28
28
  resource :units, actions: %i[index show create update destroy]
29
29
  resource :unit_conversions, actions: %i[index show create update destroy]
30
30
  resource :supplier_products, actions: %i[index show create update destroy my_products]
31
+ resource :sub_suppliers, actions: %i[index show create update destroy]
32
+ resource :sub_supplier_products, actions: %i[index show create update destroy]
33
+ resource :aggregator_listings, actions: %i[index show create update destroy feed my_listings]
31
34
  resource :listings, actions: %i[index show create update destroy activate pause sold_out my_listings listings_by_supplier]
32
35
  resource :request_for_quotations, actions: %i[index show create update destroy send_rfq close respond my_rfqs filter]
33
36
  resource :rfq_items, actions: %i[index show create update destroy]
@@ -1,5 +1,5 @@
1
1
  module Dscf
2
2
  module Marketplace
3
- VERSION = "0.9.9".freeze
3
+ VERSION = "0.10.0".freeze
4
4
  end
5
5
  end
@@ -1,9 +1,24 @@
1
1
  FactoryBot.define do
2
2
  factory :dscf_marketplace_aggregator_listing, class: "Dscf::Marketplace::AggregatorListing" do
3
3
  association :aggregator, factory: :dscf_core_business
4
- association :supplier_product, factory: :dscf_marketplace_supplier_product
5
4
  price { 250.00 }
6
5
  quantity { 50.0 }
7
6
  status { :active }
7
+
8
+ # Default: supplier-sourced (resells a supplier_product), matching pre-existing data.
9
+ source_kind { :supplier }
10
+ association :supplier_product, factory: :dscf_marketplace_supplier_product
11
+
12
+ trait :catalog do
13
+ source_kind { :catalog }
14
+ supplier_product { nil }
15
+ association :product, factory: :dscf_marketplace_product
16
+ end
17
+
18
+ trait :sub_supplier do
19
+ source_kind { :sub_supplier }
20
+ supplier_product { nil }
21
+ association :sub_supplier_product, factory: :dscf_marketplace_sub_supplier_product
22
+ end
8
23
  end
9
24
  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.9.9
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Asrat
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-06-30 00:00:00.000000000 Z
10
+ date: 2026-07-03 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: rails
@@ -438,6 +438,7 @@ files:
438
438
  - app/controllers/dscf/marketplace/request_for_quotations_controller.rb
439
439
  - app/controllers/dscf/marketplace/retailers_controller.rb
440
440
  - app/controllers/dscf/marketplace/rfq_items_controller.rb
441
+ - app/controllers/dscf/marketplace/sub_supplier_products_controller.rb
441
442
  - app/controllers/dscf/marketplace/sub_suppliers_controller.rb
442
443
  - app/controllers/dscf/marketplace/supplier_products_controller.rb
443
444
  - app/controllers/dscf/marketplace/suppliers_controller.rb
@@ -514,6 +515,7 @@ files:
514
515
  - app/serializers/dscf/marketplace/request_for_quotation_serializer.rb
515
516
  - app/serializers/dscf/marketplace/retailer_serializer.rb
516
517
  - app/serializers/dscf/marketplace/rfq_item_serializer.rb
518
+ - app/serializers/dscf/marketplace/sub_supplier_product_serializer.rb
517
519
  - app/serializers/dscf/marketplace/sub_supplier_serializer.rb
518
520
  - app/serializers/dscf/marketplace/supplier_product_serializer.rb
519
521
  - app/serializers/dscf/marketplace/supplier_serializer.rb
@@ -590,6 +592,7 @@ files:
590
592
  - db/migrate/20260619210001_add_user_id_to_dscf_marketplace_agents.rb
591
593
  - db/migrate/20260619210002_add_user_id_to_dscf_marketplace_retailers.rb
592
594
  - db/migrate/20260622000001_add_validation_and_source_fields_to_dscf_marketplace_order_items.rb
595
+ - db/migrate/20260702000001_unify_aggregator_listing_source.rb
593
596
  - db/seeds.rb
594
597
  - lib/dscf/marketplace.rb
595
598
  - lib/dscf/marketplace/engine.rb