dscf-marketplace 0.9.9 → 0.11.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 +4 -3
- data/app/controllers/dscf/marketplace/orders_controller.rb +52 -2
- data/app/controllers/dscf/marketplace/sub_supplier_products_controller.rb +29 -0
- data/app/models/dscf/marketplace/aggregator_listing.rb +47 -5
- data/app/models/dscf/marketplace/order_item.rb +15 -7
- data/app/serializers/dscf/marketplace/aggregator_listing_serializer.rb +4 -2
- data/app/serializers/dscf/marketplace/order_item_serializer.rb +4 -1
- data/app/serializers/dscf/marketplace/sub_supplier_product_serializer.rb +34 -0
- data/app/services/dscf/marketplace/order_splitting_service.rb +44 -1
- data/app/services/dscf/marketplace/order_validation_service.rb +10 -1
- data/config/routes.rb +4 -0
- data/db/migrate/20260702000001_unify_aggregator_listing_source.rb +58 -0
- data/db/migrate/20260703000001_add_supplier_adjustment_to_order_items.rb +10 -0
- data/lib/dscf/marketplace/engine.rb +4 -1
- data/lib/dscf/marketplace/version.rb +1 -1
- data/spec/factories/dscf/marketplace/aggregator_listings.rb +16 -1
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f0a4b240effc7edee8fd220eb4eca3ed1d9856b81bf1b52ae0d9825dc5457e11
|
|
4
|
+
data.tar.gz: 8d86a1061e2e9712836c589f4d1cda3b94b36b8d7c1a84a7e64f01705b69884f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0d79fd32300f658a1b29f8dd38d921461d4fa1c33c1a9501c03edd2c25e144260c45ba4fa7f1ce4e609a2a23e230787eec1c929ca6f5d8df271623648f1f489e
|
|
7
|
+
data.tar.gz: ccb0a00956391215272d0a403cd536468d7d56aab7f5b69cfc0854d0f3d869c7479823f17bb4396d99d92721460b10ffb165918312f25e9606c60235c53f6152
|
|
@@ -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, :
|
|
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
|
|
@@ -194,6 +194,47 @@ module Dscf
|
|
|
194
194
|
render_error(errors: e.message, status: :unprocessable_entity)
|
|
195
195
|
end
|
|
196
196
|
|
|
197
|
+
def adjust_item
|
|
198
|
+
obj = find_record
|
|
199
|
+
authorize obj, :supplier_confirm?
|
|
200
|
+
|
|
201
|
+
item = obj.order_items.find(params[:order_item_id])
|
|
202
|
+
Dscf::Marketplace::OrderSplittingService.adjust_item(
|
|
203
|
+
obj, item,
|
|
204
|
+
unit_price: params[:unit_price],
|
|
205
|
+
quantity: params[:quantity],
|
|
206
|
+
note: params[:note]
|
|
207
|
+
)
|
|
208
|
+
create_notification_for_order(obj, :supplier_adjusted, reason: params[:note])
|
|
209
|
+
render_success(data: obj.reload, serializer_options: { include: default_serializer_includes[:show] || [] })
|
|
210
|
+
rescue => e
|
|
211
|
+
render_error(errors: e.message, status: :unprocessable_entity)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def accept_adjustment
|
|
215
|
+
obj = find_record
|
|
216
|
+
authorize obj, :split?
|
|
217
|
+
|
|
218
|
+
item = obj.order_items.find(params[:order_item_id])
|
|
219
|
+
Dscf::Marketplace::OrderSplittingService.accept_item_adjustment(obj, item)
|
|
220
|
+
create_notification_for_order(obj, :adjustment_accepted)
|
|
221
|
+
render_success(data: obj.reload, serializer_options: { include: default_serializer_includes[:show] || [] })
|
|
222
|
+
rescue => e
|
|
223
|
+
render_error(errors: e.message, status: :unprocessable_entity)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def reject_adjustment
|
|
227
|
+
obj = find_record
|
|
228
|
+
authorize obj, :split?
|
|
229
|
+
|
|
230
|
+
item = obj.order_items.find(params[:order_item_id])
|
|
231
|
+
Dscf::Marketplace::OrderSplittingService.reject_item_adjustment(obj, item, reason: params[:reason])
|
|
232
|
+
create_notification_for_order(obj, :adjustment_rejected, reason: params[:reason])
|
|
233
|
+
render_success(data: obj.reload, serializer_options: { include: default_serializer_includes[:show] || [] })
|
|
234
|
+
rescue => e
|
|
235
|
+
render_error(errors: e.message, status: :unprocessable_entity)
|
|
236
|
+
end
|
|
237
|
+
|
|
197
238
|
def retailer_confirm
|
|
198
239
|
obj = find_record
|
|
199
240
|
authorize obj, :confirm? # reuse existing or add specific
|
|
@@ -227,13 +268,17 @@ module Dscf
|
|
|
227
268
|
when :split_ready then "Order ##{order.id} ready for supplier confirmation"
|
|
228
269
|
when :supplier_confirmed then "Supplier confirmed order ##{order.id}"
|
|
229
270
|
when :supplier_rejected then "Supplier rejected item in order ##{order.id}"
|
|
271
|
+
when :supplier_adjusted then "Supplier proposed an adjustment on order ##{order.id}"
|
|
272
|
+
when :adjustment_accepted then "Adjustment accepted on order ##{order.id}"
|
|
273
|
+
when :adjustment_rejected then "Adjustment rejected on order ##{order.id}"
|
|
230
274
|
when :retailer_confirmed then "Retailer confirmed order ##{order.id}"
|
|
231
275
|
when :retailer_rejected then "Retailer cancelled order ##{order.id}"
|
|
232
276
|
else "Order ##{order.id} update"
|
|
233
277
|
end
|
|
234
278
|
|
|
235
279
|
body = case action
|
|
236
|
-
when :supplier_rejected, :retailer_rejected
|
|
280
|
+
when :supplier_rejected, :retailer_rejected, :supplier_adjusted, :adjustment_rejected
|
|
281
|
+
"Reason: #{reason}" if reason
|
|
237
282
|
else "Status: #{order.status}"
|
|
238
283
|
end
|
|
239
284
|
|
|
@@ -314,7 +359,12 @@ module Dscf
|
|
|
314
359
|
order.status = :pending
|
|
315
360
|
order.ordered_to = aggregator_listing.aggregator
|
|
316
361
|
|
|
317
|
-
product = aggregator_listing.
|
|
362
|
+
product = aggregator_listing.product
|
|
363
|
+
# Order lines from the aggregator feed are auto-sourced to the originating
|
|
364
|
+
# AggregatorListing (regardless of source_kind) — the aggregator already
|
|
365
|
+
# knows the source, so there's no manual pick at split time. The listing's
|
|
366
|
+
# source_kind carries who ultimately fulfils it (self / sub-supplier /
|
|
367
|
+
# supplier), surfaced via source_name for display and confirmation routing.
|
|
318
368
|
order.order_items.build(
|
|
319
369
|
source: aggregator_listing,
|
|
320
370
|
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
|
-
|
|
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
|
-
|
|
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?
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
module Dscf::Marketplace
|
|
2
2
|
class OrderItem < ApplicationRecord
|
|
3
|
-
|
|
3
|
+
# :adjusted — the supplier proposed a revised price/quantity that is awaiting
|
|
4
|
+
# the aggregator's accept/reject follow-up. Distinct from confirmed/cancelled so
|
|
5
|
+
# supplier_confirmation_complete? holds the order until the aggregator responds.
|
|
6
|
+
enum :status, {pending: 0, confirmed: 1, processing: 2, fulfilled: 3, cancelled: 4, adjusted: 5}
|
|
4
7
|
enum :validation_status, {
|
|
5
8
|
validated: 0,
|
|
6
9
|
no_longer_listed: 1,
|
|
@@ -22,11 +25,11 @@ module Dscf::Marketplace
|
|
|
22
25
|
validates :status, presence: true
|
|
23
26
|
validates :product, presence: true
|
|
24
27
|
validates :unit, presence: true
|
|
25
|
-
validate :
|
|
28
|
+
validate :orderable_source_present
|
|
26
29
|
|
|
27
30
|
# Ransack configuration for secure filtering
|
|
28
31
|
def self.ransackable_attributes(_auth_object = nil)
|
|
29
|
-
%w[id order_id quotation_item_id listing_id product_id unit_id quantity unit_price status validation_status validation_note resolved_unit_price resolved_quantity source_type source_id created_at updated_at]
|
|
32
|
+
%w[id order_id quotation_item_id listing_id product_id unit_id quantity unit_price status validation_status validation_note resolved_unit_price resolved_quantity source_type source_id supplier_adjusted_unit_price supplier_adjusted_quantity supplier_adjustment_note created_at updated_at]
|
|
30
33
|
end
|
|
31
34
|
|
|
32
35
|
def self.ransackable_associations(_auth_object = nil)
|
|
@@ -55,10 +58,15 @@ module Dscf::Marketplace
|
|
|
55
58
|
|
|
56
59
|
private
|
|
57
60
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
61
|
+
# A line must trace back to something orderable: an RFQ quotation item, a
|
|
62
|
+
# supplier listing, or any assigned polymorphic source (aggregator listing,
|
|
63
|
+
# sub-supplier, other supplier/business). The last case is what lets the
|
|
64
|
+
# aggregator reassign a line to another source during splitting.
|
|
65
|
+
def orderable_source_present
|
|
66
|
+
return if quotation_item.present? || listing.present?
|
|
67
|
+
return if source_type.present? && source_id.present?
|
|
68
|
+
|
|
69
|
+
errors.add(:base, "Either a quotation item, a listing, or an assigned source must be present")
|
|
62
70
|
end
|
|
63
71
|
end
|
|
64
72
|
end
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
module Dscf
|
|
2
2
|
module Marketplace
|
|
3
3
|
class AggregatorListingSerializer < ActiveModel::Serializer
|
|
4
|
-
attributes :id, :aggregator_id, :
|
|
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
|
|
|
@@ -5,7 +5,8 @@ module Dscf
|
|
|
5
5
|
:product_id, :unit_id, :quantity, :unit_price,
|
|
6
6
|
:status, :validation_status, :validation_note, :resolved_unit_price, :resolved_quantity,
|
|
7
7
|
:source_type, :source_id, :source_name, :created_at, :updated_at,
|
|
8
|
-
:subtotal, :product_name, :unit_name, :thumbnail_url, :images_urls
|
|
8
|
+
:subtotal, :product_name, :unit_name, :thumbnail_url, :images_urls,
|
|
9
|
+
:supplier_adjusted_unit_price, :supplier_adjusted_quantity, :supplier_adjustment_note
|
|
9
10
|
|
|
10
11
|
belongs_to :order
|
|
11
12
|
belongs_to :quotation_item
|
|
@@ -16,6 +17,8 @@ module Dscf
|
|
|
16
17
|
def source_name
|
|
17
18
|
return nil unless object.source
|
|
18
19
|
case object.source_type
|
|
20
|
+
when 'Dscf::Marketplace::AggregatorListing'
|
|
21
|
+
object.source.try(:source_label)
|
|
19
22
|
when 'Dscf::Marketplace::SubSupplier'
|
|
20
23
|
object.source.try(:business_name) || object.source.try(:name)
|
|
21
24
|
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
|
|
@@ -32,7 +32,8 @@ module Dscf
|
|
|
32
32
|
|
|
33
33
|
def self.supplier_confirm(order, confirmed: true, reason: nil)
|
|
34
34
|
if confirmed
|
|
35
|
-
|
|
35
|
+
# In full impl, mark specific allocation as confirmed
|
|
36
|
+
# For now advance whole if appropriate
|
|
36
37
|
else
|
|
37
38
|
order.order_items.where(status: :processing).update_all(status: OrderItem.statuses[:cancelled])
|
|
38
39
|
end
|
|
@@ -57,6 +58,48 @@ module Dscf
|
|
|
57
58
|
order.mark_waiting_retailer_confirmation! if order.supplier_confirmation_complete?
|
|
58
59
|
order_item
|
|
59
60
|
end
|
|
61
|
+
|
|
62
|
+
# Supplier proposes revised terms instead of confirming/rejecting outright.
|
|
63
|
+
# The item goes to :adjusted holding the proposal; because :adjusted is neither
|
|
64
|
+
# confirmed nor cancelled, supplier_confirmation_complete? keeps the order in
|
|
65
|
+
# waiting_supplier_confirmation until the aggregator follows up (accept/reject).
|
|
66
|
+
def self.adjust_item(order, order_item, unit_price: nil, quantity: nil, note: nil)
|
|
67
|
+
order_item.supplier_adjusted_unit_price = unit_price if unit_price.present?
|
|
68
|
+
order_item.supplier_adjusted_quantity = quantity if quantity.present?
|
|
69
|
+
order_item.supplier_adjustment_note = note
|
|
70
|
+
order_item.status = :adjusted
|
|
71
|
+
order_item.save!
|
|
72
|
+
order_item
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Aggregator accepts the supplier's proposal: the adjusted price/quantity become
|
|
76
|
+
# the line's actual terms, the item is confirmed, and the order total recomputes
|
|
77
|
+
# (Order#calculate_total_amount runs on save). Advances once all items responded.
|
|
78
|
+
def self.accept_item_adjustment(order, order_item)
|
|
79
|
+
order_item.unit_price = order_item.supplier_adjusted_unit_price if order_item.supplier_adjusted_unit_price.present?
|
|
80
|
+
order_item.quantity = order_item.supplier_adjusted_quantity if order_item.supplier_adjusted_quantity.present?
|
|
81
|
+
order_item.supplier_adjusted_unit_price = nil
|
|
82
|
+
order_item.supplier_adjusted_quantity = nil
|
|
83
|
+
order_item.status = :confirmed
|
|
84
|
+
order_item.save!
|
|
85
|
+
|
|
86
|
+
order.reload
|
|
87
|
+
order.save! # recompute total_amount from the revised line
|
|
88
|
+
order.mark_waiting_retailer_confirmation! if order.supplier_confirmation_complete?
|
|
89
|
+
order_item
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Aggregator rejects the proposal — the line can't be fulfilled on agreeable
|
|
93
|
+
# terms, so it is cancelled (mirrors a not-confirm). Advances if all responded.
|
|
94
|
+
def self.reject_item_adjustment(order, order_item, reason: nil)
|
|
95
|
+
order_item.supplier_adjustment_note = reason if reason.present?
|
|
96
|
+
order_item.status = :cancelled
|
|
97
|
+
order_item.save!
|
|
98
|
+
|
|
99
|
+
order.reload
|
|
100
|
+
order.mark_waiting_retailer_confirmation! if order.supplier_confirmation_complete?
|
|
101
|
+
order_item
|
|
102
|
+
end
|
|
60
103
|
end
|
|
61
104
|
end
|
|
62
105
|
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
|
-
#
|
|
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
|
|
@@ -131,6 +132,9 @@ Dscf::Marketplace::Engine.routes.draw do
|
|
|
131
132
|
post "split"
|
|
132
133
|
post "supplier_confirm"
|
|
133
134
|
post "confirm_item"
|
|
135
|
+
post "adjust_item"
|
|
136
|
+
post "accept_adjustment"
|
|
137
|
+
post "reject_adjustment"
|
|
134
138
|
post "retailer_confirm"
|
|
135
139
|
end
|
|
136
140
|
collection 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
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
class AddSupplierAdjustmentToDscfMarketplaceOrderItems < ActiveRecord::Migration[8.0]
|
|
2
|
+
def change
|
|
3
|
+
# When a supplier can't fulfil an allocation at the aggregator's terms, they
|
|
4
|
+
# propose a revised price/quantity instead of outright rejecting. The proposal
|
|
5
|
+
# is held here (item status -> :adjusted) until the aggregator accepts or rejects.
|
|
6
|
+
add_column :dscf_marketplace_order_items, :supplier_adjusted_unit_price, :decimal, precision: 15, scale: 6
|
|
7
|
+
add_column :dscf_marketplace_order_items, :supplier_adjusted_quantity, :decimal, precision: 15, scale: 6
|
|
8
|
+
add_column :dscf_marketplace_order_items, :supplier_adjustment_note, :text
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -28,12 +28,15 @@ 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]
|
|
34
37
|
resource :quotations, actions: %i[index show create update destroy accept reject send_quotation my_quotes filter]
|
|
35
38
|
resource :quotation_items, actions: %i[index show create update destroy]
|
|
36
|
-
resource :orders, actions: %i[index show create update destroy confirm cancel complete my_orders filter validate resolve_item assign_source split supplier_confirm retailer_confirm]
|
|
39
|
+
resource :orders, actions: %i[index show create update destroy confirm cancel complete my_orders filter validate resolve_item assign_source split supplier_confirm confirm_item adjust_item accept_adjustment reject_adjustment retailer_confirm]
|
|
37
40
|
resource :order_items, actions: %i[index show create update destroy]
|
|
38
41
|
resource :delivery_orders, actions: %i[
|
|
39
42
|
index show create update destroy pickup start_delivery complete_delivery mark_failed accept summary
|
|
@@ -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.
|
|
4
|
+
version: 0.11.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Asrat
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-
|
|
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,8 @@ 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
|
|
596
|
+
- db/migrate/20260703000001_add_supplier_adjustment_to_order_items.rb
|
|
593
597
|
- db/seeds.rb
|
|
594
598
|
- lib/dscf/marketplace.rb
|
|
595
599
|
- lib/dscf/marketplace/engine.rb
|