dscf-marketplace 0.1.4 → 0.1.6
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/models/dscf/marketplace/listing.rb +55 -0
- data/app/models/dscf/marketplace/quotation.rb +147 -0
- data/app/models/dscf/marketplace/quotation_item.rb +118 -0
- data/app/models/dscf/marketplace/request_for_quotation.rb +71 -0
- data/app/models/dscf/marketplace/rfq_item.rb +81 -0
- data/app/models/dscf/marketplace/supplier_product.rb +47 -0
- data/db/migrate/20250828072514_create_dscf_marketplace_supplier_products.rb +32 -0
- data/db/migrate/20250828081657_create_dscf_marketplace_listings.rb +31 -0
- data/db/migrate/20250828090000_create_dscf_marketplace_request_for_quotations.rb +18 -0
- data/db/migrate/20250828090100_create_dscf_marketplace_rfq_items.rb +21 -0
- data/db/migrate/20250828090200_create_dscf_marketplace_quotations.rb +24 -0
- data/db/migrate/20250828090300_create_dscf_marketplace_quotation_items.rb +25 -0
- data/db/migrate/20250828090400_change_rfq_status_to_integer.rb +35 -0
- data/db/migrate/20250828090500_change_quotation_status_to_integer.rb +35 -0
- data/lib/dscf/marketplace/version.rb +1 -1
- data/spec/factories/dscf/core/business_types.rb +5 -0
- data/spec/factories/dscf/core/businesses.rb +11 -0
- data/spec/factories/dscf/core/users.rb +8 -0
- data/spec/factories/dscf/marketplace/dscf/marketplace/quotation_items.rb +18 -0
- data/spec/factories/dscf/marketplace/dscf/marketplace/quotations.rb +25 -0
- data/spec/factories/dscf/marketplace/dscf/marketplace/request_for_quotations.rb +8 -0
- data/spec/factories/dscf/marketplace/dscf/marketplace/rfq_items.rb +9 -0
- data/spec/factories/dscf/marketplace/listings.rb +10 -0
- data/spec/factories/dscf/marketplace/supplier_products.rb +10 -0
- metadata +24 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 82b1976ad1b3d99364b621d51ce06c5ac51e8eb818e10527580419d61f5aeb3d
|
4
|
+
data.tar.gz: 6264cf89e35f2f24cc756f0eeaa48336af30b2e20473bab859453faf3964691e
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 9a4e05d5b18d0bc545d036b9f623eaa65345b3e641cc069a1bde5fc0a7179fa0a0aa7bc4cd2d3243406c32dd4d5e0ee6ed3fbb05133770f9da638161a7a7341d
|
7
|
+
data.tar.gz: 0eaa365be89d92f5320e5a7fca4ee74f300f19b4c5ad325740428bf456126cad807f9fbc700565262c289a912c2fe23661246308b9c9a192631a3a4388d9211d
|
@@ -0,0 +1,55 @@
|
|
1
|
+
module Dscf::Marketplace
|
2
|
+
class Listing < ApplicationRecord
|
3
|
+
# References
|
4
|
+
belongs_to :business, class_name: "Dscf::Core::Business"
|
5
|
+
belongs_to :supplier_product, class_name: "Dscf::Marketplace::SupplierProduct"
|
6
|
+
|
7
|
+
# Relationships
|
8
|
+
has_many :order_items, class_name: "Dscf::Marketplace::OrderItem"
|
9
|
+
|
10
|
+
# Status enum
|
11
|
+
enum :status, {active: 0, draft: 1, paused: 2, sold_out: 3}, default: :draft
|
12
|
+
|
13
|
+
# Validations
|
14
|
+
validates :price, numericality: {greater_than: 0}, presence: true
|
15
|
+
validates :quantity, numericality: {greater_than: 0}, presence: true
|
16
|
+
validates :business_id, presence: true
|
17
|
+
validates :supplier_product_id, presence: true
|
18
|
+
|
19
|
+
# Scopes
|
20
|
+
scope :active, -> { where(status: :active) }
|
21
|
+
scope :by_business, ->(business_id) { where(business_id: business_id) }
|
22
|
+
scope :owned_by, ->(business) { where(business: business) }
|
23
|
+
|
24
|
+
# Custom methods
|
25
|
+
def total_value
|
26
|
+
price * quantity
|
27
|
+
end
|
28
|
+
|
29
|
+
def margin
|
30
|
+
return nil unless supplier_product&.supplier_price
|
31
|
+
price - supplier_product.supplier_price
|
32
|
+
end
|
33
|
+
|
34
|
+
def margin_percentage
|
35
|
+
return nil unless supplier_product&.supplier_price && supplier_product.supplier_price > 0
|
36
|
+
((margin / supplier_product.supplier_price) * 100).round(2)
|
37
|
+
end
|
38
|
+
|
39
|
+
def available?
|
40
|
+
active? && quantity > 0
|
41
|
+
end
|
42
|
+
|
43
|
+
def supplier_business
|
44
|
+
supplier_product&.business
|
45
|
+
end
|
46
|
+
|
47
|
+
def product
|
48
|
+
supplier_product&.product
|
49
|
+
end
|
50
|
+
|
51
|
+
def can_be_managed_by?(business)
|
52
|
+
self.business == business
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,147 @@
|
|
1
|
+
module Dscf
|
2
|
+
module Marketplace
|
3
|
+
class Quotation < ApplicationRecord
|
4
|
+
belongs_to :request_for_quotation, class_name: "Dscf::Marketplace::RequestForQuotation"
|
5
|
+
belongs_to :business, class_name: "Dscf::Core::Business", optional: true if defined?(Dscf::Core)
|
6
|
+
|
7
|
+
has_many :quotation_items, class_name: "Dscf::Marketplace::QuotationItem", dependent: :destroy
|
8
|
+
|
9
|
+
validates :request_for_quotation_id, presence: true
|
10
|
+
validates :business_id, presence: true
|
11
|
+
validates :total_price, numericality: {greater_than_or_equal_to: 0}, allow_nil: true
|
12
|
+
validates :delivery_date, presence: true
|
13
|
+
validates :valid_until, presence: true
|
14
|
+
enum :status, {draft: 0, sent: 1, accepted: 2, rejected: 3, expired: 4}, default: :draft
|
15
|
+
validates :notes, length: {maximum: 2000}, allow_blank: true
|
16
|
+
|
17
|
+
validate :valid_until_after_delivery_date
|
18
|
+
validate :delivery_date_not_in_past
|
19
|
+
validate :valid_until_not_in_past
|
20
|
+
scope :by_business, ->(business_id) { where(business_id: business_id) }
|
21
|
+
scope :valid_quotations, -> { where("valid_until > ?", Time.current) }
|
22
|
+
scope :expired_quotations, -> { where("valid_until <= ?", Time.current) }
|
23
|
+
|
24
|
+
def calculate_total_price
|
25
|
+
self.total_price = quotation_items.sum(&:subtotal).to_f
|
26
|
+
end
|
27
|
+
|
28
|
+
def update_total_price!
|
29
|
+
calculate_total_price
|
30
|
+
save!
|
31
|
+
end
|
32
|
+
|
33
|
+
def sent?
|
34
|
+
status == "sent"
|
35
|
+
end
|
36
|
+
|
37
|
+
def accepted?
|
38
|
+
status == "accepted"
|
39
|
+
end
|
40
|
+
|
41
|
+
def rejected?
|
42
|
+
status == "rejected"
|
43
|
+
end
|
44
|
+
|
45
|
+
def expired?
|
46
|
+
status == "expired"
|
47
|
+
end
|
48
|
+
|
49
|
+
def draft?
|
50
|
+
status == "draft"
|
51
|
+
end
|
52
|
+
|
53
|
+
def sent?
|
54
|
+
status == "sent"
|
55
|
+
end
|
56
|
+
|
57
|
+
def accepted?
|
58
|
+
status == "accepted"
|
59
|
+
end
|
60
|
+
|
61
|
+
def rejected?
|
62
|
+
status == "rejected"
|
63
|
+
end
|
64
|
+
|
65
|
+
def within_validity_period?
|
66
|
+
valid_until > Time.current && !expired?
|
67
|
+
end
|
68
|
+
|
69
|
+
def expired!
|
70
|
+
update_columns(status: "expired") if valid_until <= Time.current
|
71
|
+
end
|
72
|
+
|
73
|
+
def accept!
|
74
|
+
return false unless sent?
|
75
|
+
return false if within_validity_period? == false
|
76
|
+
|
77
|
+
# Close other quotations for this RFQ first
|
78
|
+
request_for_quotation.quotations.where.not(id: id).update_all(status: "rejected")
|
79
|
+
|
80
|
+
update_columns(status: "accepted")
|
81
|
+
request_for_quotation.update!(status: "selected", selected_quotation: self)
|
82
|
+
true
|
83
|
+
end
|
84
|
+
|
85
|
+
def reject!
|
86
|
+
return false unless sent?
|
87
|
+
|
88
|
+
update!(status: "rejected")
|
89
|
+
end
|
90
|
+
|
91
|
+
def send_quotation!
|
92
|
+
return false unless draft?
|
93
|
+
|
94
|
+
update!(status: "sent")
|
95
|
+
request_for_quotation.update!(status: "responded") if request_for_quotation.sent? || request_for_quotation.draft?
|
96
|
+
true
|
97
|
+
end
|
98
|
+
|
99
|
+
def days_until_expiry
|
100
|
+
return nil unless valid_until
|
101
|
+
|
102
|
+
days = ((valid_until - Time.current) / 1.day).ceil
|
103
|
+
days.negative? ? nil : days
|
104
|
+
end
|
105
|
+
|
106
|
+
def delivery_in_days
|
107
|
+
return nil unless delivery_date
|
108
|
+
|
109
|
+
((delivery_date.to_time - Time.current) / 1.day).ceil
|
110
|
+
end
|
111
|
+
|
112
|
+
def complete?
|
113
|
+
quotation_items.exists? && quotation_items.all? { |item| item.unit_price.present? }
|
114
|
+
end
|
115
|
+
|
116
|
+
private
|
117
|
+
|
118
|
+
def calculate_total_price
|
119
|
+
self.total_price = quotation_items.sum(&:subtotal).to_f
|
120
|
+
end
|
121
|
+
|
122
|
+
def valid_until_after_delivery_date
|
123
|
+
return unless valid_until && delivery_date
|
124
|
+
|
125
|
+
if valid_until <= delivery_date
|
126
|
+
errors.add(:valid_until, "must be after delivery date")
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
def delivery_date_not_in_past
|
131
|
+
return unless delivery_date
|
132
|
+
|
133
|
+
if delivery_date < Date.current
|
134
|
+
errors.add(:delivery_date, "cannot be in the past")
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
def valid_until_not_in_past
|
139
|
+
return unless valid_until
|
140
|
+
|
141
|
+
if valid_until < Time.current
|
142
|
+
errors.add(:valid_until, "cannot be in the past")
|
143
|
+
end
|
144
|
+
end
|
145
|
+
end
|
146
|
+
end
|
147
|
+
end
|
@@ -0,0 +1,118 @@
|
|
1
|
+
module Dscf
|
2
|
+
module Marketplace
|
3
|
+
class QuotationItem < ApplicationRecord
|
4
|
+
belongs_to :quotation, class_name: "Dscf::Marketplace::Quotation"
|
5
|
+
belongs_to :rfq_item, class_name: "Dscf::Marketplace::RfqItem"
|
6
|
+
belongs_to :product, class_name: "Dscf::Marketplace::Product"
|
7
|
+
belongs_to :unit, class_name: "Dscf::Marketplace::Unit"
|
8
|
+
|
9
|
+
|
10
|
+
|
11
|
+
validates :quotation_id, presence: true
|
12
|
+
validates :rfq_item_id, presence: true
|
13
|
+
validates :product_id, presence: true
|
14
|
+
validates :quantity, presence: true, numericality: {greater_than: 0}
|
15
|
+
validates :unit_id, presence: true
|
16
|
+
validates :unit_price, numericality: {greater_than: 0}, allow_nil: true
|
17
|
+
validates :subtotal, numericality: {greater_than_or_equal_to: 0}, allow_nil: true
|
18
|
+
|
19
|
+
validate :product_matches_rfq_item
|
20
|
+
validate :unit_compatible_with_product
|
21
|
+
before_save :calculate_subtotal, if: :unit_price_changed?
|
22
|
+
|
23
|
+
# Only validate subtotal calculation if both unit_price and quantity are present
|
24
|
+
validate :subtotal_matches_calculation, if: :should_validate_subtotal?
|
25
|
+
|
26
|
+
scope :priced, -> { where.not(unit_price: nil) }
|
27
|
+
scope :unpriced, -> { where(unit_price: nil) }
|
28
|
+
scope :by_product, ->(product_id) { where(product_id: product_id) }
|
29
|
+
scope :by_unit, ->(unit_id) { where(unit_id: unit_id) }
|
30
|
+
|
31
|
+
def product_name
|
32
|
+
product&.name
|
33
|
+
end
|
34
|
+
|
35
|
+
def product_sku
|
36
|
+
product&.sku
|
37
|
+
end
|
38
|
+
|
39
|
+
def unit_name
|
40
|
+
unit&.name
|
41
|
+
end
|
42
|
+
|
43
|
+
def unit_code
|
44
|
+
unit&.code
|
45
|
+
end
|
46
|
+
|
47
|
+
def rfq_requested_quantity
|
48
|
+
rfq_item&.quantity
|
49
|
+
end
|
50
|
+
|
51
|
+
def quantity_difference
|
52
|
+
return nil unless quantity && rfq_requested_quantity
|
53
|
+
|
54
|
+
quantity - rfq_requested_quantity
|
55
|
+
end
|
56
|
+
|
57
|
+
def quantity_difference_percentage
|
58
|
+
return nil unless quantity && rfq_requested_quantity && rfq_requested_quantity > 0
|
59
|
+
|
60
|
+
((quantity_difference.to_f / rfq_requested_quantity) * 100).round(2)
|
61
|
+
end
|
62
|
+
|
63
|
+
def price_per_base_unit
|
64
|
+
return nil unless unit_price && quantity && quantity > 0
|
65
|
+
|
66
|
+
unit_price / quantity
|
67
|
+
end
|
68
|
+
|
69
|
+
def matches_rfq_request?
|
70
|
+
return false unless rfq_item
|
71
|
+
|
72
|
+
product_id == rfq_item.product_id &&
|
73
|
+
unit_id == rfq_item.unit_id
|
74
|
+
end
|
75
|
+
|
76
|
+
def can_create_order?
|
77
|
+
quotation.sent? || quotation.accepted?
|
78
|
+
end
|
79
|
+
|
80
|
+
private
|
81
|
+
|
82
|
+
def calculate_subtotal
|
83
|
+
return unless unit_price && quantity
|
84
|
+
|
85
|
+
self.subtotal = unit_price * quantity
|
86
|
+
end
|
87
|
+
|
88
|
+
def product_matches_rfq_item
|
89
|
+
return unless rfq_item_id && product_id
|
90
|
+
|
91
|
+
unless product_id == rfq_item.product_id
|
92
|
+
errors.add(:product_id, "must match the product in the RFQ item")
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
def unit_compatible_with_product
|
97
|
+
return unless product_id && unit_id
|
98
|
+
|
99
|
+
unless product.unit_id == unit_id || product.unit.convertible_to?(unit)
|
100
|
+
errors.add(:unit_id, "is not compatible with the selected product")
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
def subtotal_matches_calculation
|
105
|
+
return unless should_validate_subtotal?
|
106
|
+
|
107
|
+
expected_subtotal = unit_price * quantity
|
108
|
+
unless subtotal == expected_subtotal
|
109
|
+
errors.add(:subtotal, "must equal unit_price * quantity")
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
def should_validate_subtotal?
|
114
|
+
unit_price.present? && quantity.present? && subtotal.present?
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
module Dscf
|
2
|
+
module Marketplace
|
3
|
+
class RequestForQuotation < ApplicationRecord
|
4
|
+
belongs_to :user, class_name: "Dscf::Core::User", optional: true if defined?(Dscf::Core)
|
5
|
+
belongs_to :selected_quotation, class_name: "Dscf::Marketplace::Quotation", optional: true
|
6
|
+
|
7
|
+
has_many :rfq_items, class_name: "Dscf::Marketplace::RfqItem", dependent: :destroy
|
8
|
+
has_many :quotations, class_name: "Dscf::Marketplace::Quotation", dependent: :destroy
|
9
|
+
|
10
|
+
validates :user_id, presence: true
|
11
|
+
enum :status, {draft: 0, sent: 1, responded: 2, selected: 3, closed: 4}, default: :draft
|
12
|
+
validates :notes, length: {maximum: 2000}, allow_blank: true
|
13
|
+
|
14
|
+
validate :selected_quotation_belongs_to_this_rfq
|
15
|
+
scope :by_user, ->(user_id) { where(user_id: user_id) }
|
16
|
+
|
17
|
+
def draft?
|
18
|
+
status == "draft"
|
19
|
+
end
|
20
|
+
|
21
|
+
def sent?
|
22
|
+
status == "sent"
|
23
|
+
end
|
24
|
+
|
25
|
+
def responded?
|
26
|
+
status == "responded"
|
27
|
+
end
|
28
|
+
|
29
|
+
def selected?
|
30
|
+
status == "selected"
|
31
|
+
end
|
32
|
+
|
33
|
+
def closed?
|
34
|
+
status == "closed"
|
35
|
+
end
|
36
|
+
|
37
|
+
def can_select_quotation?
|
38
|
+
sent? || responded?
|
39
|
+
end
|
40
|
+
|
41
|
+
def select_quotation!(quotation)
|
42
|
+
return false unless can_select_quotation?
|
43
|
+
return false unless quotations.include?(quotation)
|
44
|
+
|
45
|
+
update!(selected_quotation: quotation, status: "selected")
|
46
|
+
end
|
47
|
+
|
48
|
+
def total_items
|
49
|
+
rfq_items.count
|
50
|
+
end
|
51
|
+
|
52
|
+
def has_responses?
|
53
|
+
quotations.where.not(status: "draft").exists?
|
54
|
+
end
|
55
|
+
|
56
|
+
def accepted_quotations
|
57
|
+
quotations.where(status: "accepted")
|
58
|
+
end
|
59
|
+
|
60
|
+
private
|
61
|
+
|
62
|
+
def selected_quotation_belongs_to_this_rfq
|
63
|
+
return unless selected_quotation_id
|
64
|
+
|
65
|
+
unless quotations.where(id: selected_quotation_id).exists?
|
66
|
+
errors.add(:selected_quotation_id, "must belong to this RFQ")
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
@@ -0,0 +1,81 @@
|
|
1
|
+
module Dscf
|
2
|
+
module Marketplace
|
3
|
+
class RfqItem < ApplicationRecord
|
4
|
+
belongs_to :request_for_quotation, class_name: "Dscf::Marketplace::RequestForQuotation"
|
5
|
+
belongs_to :product, class_name: "Dscf::Marketplace::Product"
|
6
|
+
belongs_to :unit, class_name: "Dscf::Marketplace::Unit"
|
7
|
+
|
8
|
+
has_many :quotation_items, class_name: "Dscf::Marketplace::QuotationItem", dependent: :destroy
|
9
|
+
|
10
|
+
validates :request_for_quotation_id, presence: true
|
11
|
+
validates :product_id, presence: true
|
12
|
+
validates :quantity, presence: true, numericality: {greater_than: 0}
|
13
|
+
validates :unit_id, presence: true
|
14
|
+
validates :notes, length: {maximum: 1000}, allow_blank: true
|
15
|
+
|
16
|
+
validate :unit_compatible_with_product
|
17
|
+
|
18
|
+
scope :by_product, ->(product_id) { where(product_id: product_id) }
|
19
|
+
scope :by_unit, ->(unit_id) { where(unit_id: unit_id) }
|
20
|
+
|
21
|
+
def product_name
|
22
|
+
product&.name
|
23
|
+
end
|
24
|
+
|
25
|
+
def product_sku
|
26
|
+
product&.sku
|
27
|
+
end
|
28
|
+
|
29
|
+
def unit_name
|
30
|
+
unit&.name
|
31
|
+
end
|
32
|
+
|
33
|
+
def unit_code
|
34
|
+
unit&.code
|
35
|
+
end
|
36
|
+
|
37
|
+
def quotation_count
|
38
|
+
quotation_items.count
|
39
|
+
end
|
40
|
+
|
41
|
+
def has_quotations?
|
42
|
+
quotation_items.exists?
|
43
|
+
end
|
44
|
+
|
45
|
+
def lowest_quoted_price
|
46
|
+
quotation_items.where.not(unit_price: nil).minimum(:unit_price)
|
47
|
+
end
|
48
|
+
|
49
|
+
def highest_quoted_price
|
50
|
+
quotation_items.where.not(unit_price: nil).maximum(:unit_price)
|
51
|
+
end
|
52
|
+
|
53
|
+
def quoted_price_range
|
54
|
+
return nil unless has_quotations?
|
55
|
+
|
56
|
+
low = lowest_quoted_price
|
57
|
+
high = highest_quoted_price
|
58
|
+
|
59
|
+
return low if low == high
|
60
|
+
"#{low} - #{high}"
|
61
|
+
end
|
62
|
+
|
63
|
+
def total_requested_quantity
|
64
|
+
return nil unless quantity && unit
|
65
|
+
|
66
|
+
# Convert to base unit for comparison if needed
|
67
|
+
quantity
|
68
|
+
end
|
69
|
+
|
70
|
+
private
|
71
|
+
|
72
|
+
def unit_compatible_with_product
|
73
|
+
return unless product_id && unit_id
|
74
|
+
|
75
|
+
unless product.unit_id == unit_id || product.unit.convertible_to?(unit)
|
76
|
+
errors.add(:unit_id, "is not compatible with the selected product")
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
module Dscf::Marketplace
|
2
|
+
class SupplierProduct < ApplicationRecord
|
3
|
+
# References
|
4
|
+
belongs_to :business, class_name: "Dscf::Core::Business"
|
5
|
+
belongs_to :product, class_name: "Dscf::Marketplace::Product"
|
6
|
+
|
7
|
+
# Relationships
|
8
|
+
has_many :listings, class_name: "Dscf::Marketplace::Listing"
|
9
|
+
has_many :order_items, class_name: "Dscf::Marketplace::OrderItem"
|
10
|
+
|
11
|
+
# Status enum
|
12
|
+
enum :status, {active: 0, inactive: 1, discontinued: 2}, default: :active
|
13
|
+
|
14
|
+
# Validations
|
15
|
+
validates :supplier_price, numericality: {greater_than: 0}, presence: true
|
16
|
+
validates :available_quantity, numericality: {greater_than_or_equal_to: 0}
|
17
|
+
validates :minimum_order_quantity, numericality: {greater_than: 0}, allow_nil: true
|
18
|
+
|
19
|
+
# Scopes
|
20
|
+
scope :active, -> { where(status: :active) }
|
21
|
+
scope :in_stock, -> { where("available_quantity > 0") }
|
22
|
+
scope :by_business, ->(business_id) { where(business_id: business_id) }
|
23
|
+
scope :by_product, ->(product_id) { where(product_id: product_id) }
|
24
|
+
|
25
|
+
# Custom methods
|
26
|
+
def in_stock?
|
27
|
+
available_quantity > 0
|
28
|
+
end
|
29
|
+
|
30
|
+
def can_fulfill?(quantity)
|
31
|
+
available_quantity >= quantity
|
32
|
+
end
|
33
|
+
|
34
|
+
def price_per_unit
|
35
|
+
return nil unless supplier_price && product&.base_quantity
|
36
|
+
supplier_price / product.base_quantity
|
37
|
+
end
|
38
|
+
|
39
|
+
def display_name
|
40
|
+
"#{business.name} - #{product.name}"
|
41
|
+
end
|
42
|
+
|
43
|
+
def available_for_order?(quantity = 1)
|
44
|
+
active? && can_fulfill?(quantity)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
class CreateDscfMarketplaceSupplierProducts < ActiveRecord::Migration[8.0]
|
2
|
+
def change
|
3
|
+
create_table :dscf_marketplace_supplier_products do |t|
|
4
|
+
t.references :business,
|
5
|
+
index: {name: "business_on_dm_sp_indx"},
|
6
|
+
null: false,
|
7
|
+
foreign_key: {to_table: :dscf_core_businesses}
|
8
|
+
t.references :product,
|
9
|
+
index: {name: "product_on_dm_sp_indx"},
|
10
|
+
null: false,
|
11
|
+
foreign_key: {to_table: :dscf_marketplace_products}
|
12
|
+
|
13
|
+
# Ethiopian Birr pricing
|
14
|
+
t.decimal :supplier_price, precision: 15, scale: 2, null: false
|
15
|
+
|
16
|
+
# Inventory management
|
17
|
+
t.decimal :available_quantity, precision: 15, scale: 6, null: false, default: 0
|
18
|
+
t.decimal :minimum_order_quantity, precision: 15, scale: 6
|
19
|
+
|
20
|
+
# Status
|
21
|
+
t.integer :status, null: false, default: 0
|
22
|
+
|
23
|
+
t.timestamps
|
24
|
+
end
|
25
|
+
|
26
|
+
# Unique constraint - one supplier product per business-product combination
|
27
|
+
add_index :dscf_marketplace_supplier_products,
|
28
|
+
[ :business_id, :product_id ],
|
29
|
+
unique: true,
|
30
|
+
name: "unique_business_product_indx"
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
class CreateDscfMarketplaceListings < ActiveRecord::Migration[8.0]
|
2
|
+
def change
|
3
|
+
create_table :dscf_marketplace_listings do |t|
|
4
|
+
t.references :business,
|
5
|
+
index: {name: "business_on_dm_listings_indx"},
|
6
|
+
null: false,
|
7
|
+
foreign_key: {to_table: :dscf_core_businesses}
|
8
|
+
t.references :supplier_product,
|
9
|
+
index: {name: "supplier_product_on_dm_listings_indx"},
|
10
|
+
null: false,
|
11
|
+
foreign_key: {to_table: :dscf_marketplace_supplier_products}
|
12
|
+
|
13
|
+
# Ethiopian Birr pricing
|
14
|
+
t.decimal :price, precision: 15, scale: 2, null: false
|
15
|
+
|
16
|
+
# Available quantity for sale
|
17
|
+
t.decimal :quantity, precision: 15, scale: 6, null: false
|
18
|
+
|
19
|
+
# Optional listing description
|
20
|
+
t.text :description
|
21
|
+
|
22
|
+
# Status
|
23
|
+
t.integer :status, null: false, default: 1 # draft
|
24
|
+
|
25
|
+
t.timestamps
|
26
|
+
end
|
27
|
+
|
28
|
+
add_index :dscf_marketplace_listings, :status, name: "status_on_dm_listings_indx"
|
29
|
+
add_index :dscf_marketplace_listings, [ :business_id, :supplier_product_id ], name: "business_supplier_product_on_dm_listings_indx"
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
class CreateDscfMarketplaceRequestForQuotations < ActiveRecord::Migration[8.0]
|
2
|
+
def change
|
3
|
+
create_table :dscf_marketplace_request_for_quotations do |t|
|
4
|
+
t.bigint :user_id, null: false
|
5
|
+
t.string :status, null: false
|
6
|
+
t.bigint :selected_quotation_id
|
7
|
+
t.text :notes
|
8
|
+
|
9
|
+
t.timestamps
|
10
|
+
|
11
|
+
t.index [ :user_id ], name: "user_on_dm_rfqs_indx"
|
12
|
+
t.index [ :status ], name: "status_on_dm_rfqs_indx"
|
13
|
+
t.index [ :selected_quotation_id ], name: "selected_quotation_on_dm_rfqs_indx"
|
14
|
+
end
|
15
|
+
|
16
|
+
add_foreign_key :dscf_marketplace_request_for_quotations, :dscf_core_users, column: :user_id, name: "fk_dm_rfqs_user"
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
class CreateDscfMarketplaceRfqItems < ActiveRecord::Migration[8.0]
|
2
|
+
def change
|
3
|
+
create_table :dscf_marketplace_rfq_items do |t|
|
4
|
+
t.bigint :request_for_quotation_id, null: false
|
5
|
+
t.bigint :product_id, null: false
|
6
|
+
t.decimal :quantity, precision: 15, scale: 6, null: false
|
7
|
+
t.bigint :unit_id, null: false
|
8
|
+
t.text :notes
|
9
|
+
|
10
|
+
t.timestamps
|
11
|
+
|
12
|
+
t.index [ :request_for_quotation_id ], name: "rfq_on_dm_rfq_items_indx"
|
13
|
+
t.index [ :product_id ], name: "product_on_dm_rfq_items_indx"
|
14
|
+
t.index [ :unit_id ], name: "unit_on_dm_rfq_items_indx"
|
15
|
+
end
|
16
|
+
|
17
|
+
add_foreign_key :dscf_marketplace_rfq_items, :dscf_marketplace_request_for_quotations, column: :request_for_quotation_id, name: "fk_dm_rfq_items_rfq"
|
18
|
+
add_foreign_key :dscf_marketplace_rfq_items, :dscf_marketplace_products, column: :product_id, name: "fk_dm_rfq_items_product"
|
19
|
+
add_foreign_key :dscf_marketplace_rfq_items, :dscf_marketplace_units, column: :unit_id, name: "fk_dm_rfq_items_unit"
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
class CreateDscfMarketplaceQuotations < ActiveRecord::Migration[8.0]
|
2
|
+
def change
|
3
|
+
create_table :dscf_marketplace_quotations do |t|
|
4
|
+
t.bigint :request_for_quotation_id, null: false
|
5
|
+
t.bigint :business_id, null: false
|
6
|
+
t.decimal :total_price, precision: 15, scale: 2
|
7
|
+
t.date :delivery_date, null: false
|
8
|
+
t.datetime :valid_until, null: false
|
9
|
+
t.string :status, null: false
|
10
|
+
t.text :notes
|
11
|
+
|
12
|
+
t.timestamps
|
13
|
+
|
14
|
+
t.index [ :request_for_quotation_id ], name: "rfq_on_dm_quotations_indx"
|
15
|
+
t.index [ :business_id ], name: "business_on_dm_quotations_indx"
|
16
|
+
t.index [ :status ], name: "status_on_dm_quotations_indx"
|
17
|
+
t.index [ :valid_until ], name: "valid_until_on_dm_quotations_indx"
|
18
|
+
end
|
19
|
+
|
20
|
+
add_foreign_key :dscf_marketplace_quotations, :dscf_marketplace_request_for_quotations, column: :request_for_quotation_id, name: "fk_dm_quotations_rfq"
|
21
|
+
add_foreign_key :dscf_marketplace_quotations, :dscf_core_businesses, column: :business_id, name: "fk_dm_quotations_business"
|
22
|
+
add_foreign_key :dscf_marketplace_request_for_quotations, :dscf_marketplace_quotations, column: :selected_quotation_id, name: "fk_dm_rfqs_selected_quotation"
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
class CreateDscfMarketplaceQuotationItems < ActiveRecord::Migration[8.0]
|
2
|
+
def change
|
3
|
+
create_table :dscf_marketplace_quotation_items do |t|
|
4
|
+
t.bigint :quotation_id, null: false
|
5
|
+
t.bigint :rfq_item_id, null: false
|
6
|
+
t.bigint :product_id, null: false
|
7
|
+
t.decimal :quantity, precision: 15, scale: 6, null: false
|
8
|
+
t.bigint :unit_id, null: false
|
9
|
+
t.decimal :unit_price, precision: 15, scale: 2
|
10
|
+
t.decimal :subtotal, precision: 15, scale: 2
|
11
|
+
|
12
|
+
t.timestamps
|
13
|
+
|
14
|
+
t.index [ :quotation_id ], name: "quotation_on_dm_quotation_items_indx"
|
15
|
+
t.index [ :rfq_item_id ], name: "rfq_item_on_dm_quotation_items_indx"
|
16
|
+
t.index [ :product_id ], name: "product_on_dm_quotation_items_indx"
|
17
|
+
t.index [ :unit_id ], name: "unit_on_dm_quotation_items_indx"
|
18
|
+
end
|
19
|
+
|
20
|
+
add_foreign_key :dscf_marketplace_quotation_items, :dscf_marketplace_quotations, column: :quotation_id, name: "fk_dm_quotation_items_quotation"
|
21
|
+
add_foreign_key :dscf_marketplace_quotation_items, :dscf_marketplace_rfq_items, column: :rfq_item_id, name: "fk_dm_quotation_items_rfq_item"
|
22
|
+
add_foreign_key :dscf_marketplace_quotation_items, :dscf_marketplace_products, column: :product_id, name: "fk_dm_quotation_items_product"
|
23
|
+
add_foreign_key :dscf_marketplace_quotation_items, :dscf_marketplace_units, column: :unit_id, name: "fk_dm_quotation_items_unit"
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
class ChangeRfqStatusToInteger < ActiveRecord::Migration[8.0]
|
2
|
+
def up
|
3
|
+
# Map string values to integers
|
4
|
+
execute <<-SQL
|
5
|
+
UPDATE dscf_marketplace_request_for_quotations
|
6
|
+
SET status = CASE
|
7
|
+
WHEN status = 'draft' THEN 0
|
8
|
+
WHEN status = 'sent' THEN 1
|
9
|
+
WHEN status = 'responded' THEN 2
|
10
|
+
WHEN status = 'selected' THEN 3
|
11
|
+
WHEN status = 'closed' THEN 4
|
12
|
+
ELSE 0
|
13
|
+
END
|
14
|
+
SQL
|
15
|
+
|
16
|
+
change_column :dscf_marketplace_request_for_quotations, :status, :integer, using: 'status::integer', default: 0, null: false
|
17
|
+
end
|
18
|
+
|
19
|
+
def down
|
20
|
+
change_column :dscf_marketplace_request_for_quotations, :status, :string
|
21
|
+
|
22
|
+
# Map integers back to strings
|
23
|
+
execute <<-SQL
|
24
|
+
UPDATE dscf_marketplace_request_for_quotations
|
25
|
+
SET status = CASE
|
26
|
+
WHEN status = 0 THEN 'draft'
|
27
|
+
WHEN status = 1 THEN 'sent'
|
28
|
+
WHEN status = 2 THEN 'responded'
|
29
|
+
WHEN status = 3 THEN 'selected'
|
30
|
+
WHEN status = 4 THEN 'closed'
|
31
|
+
ELSE 'draft'
|
32
|
+
END
|
33
|
+
SQL
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
class ChangeQuotationStatusToInteger < ActiveRecord::Migration[8.0]
|
2
|
+
def up
|
3
|
+
# Map string values to integers
|
4
|
+
execute <<-SQL
|
5
|
+
UPDATE dscf_marketplace_quotations
|
6
|
+
SET status = CASE
|
7
|
+
WHEN status = 'draft' THEN 0
|
8
|
+
WHEN status = 'sent' THEN 1
|
9
|
+
WHEN status = 'accepted' THEN 2
|
10
|
+
WHEN status = 'rejected' THEN 3
|
11
|
+
WHEN status = 'expired' THEN 4
|
12
|
+
ELSE 0
|
13
|
+
END
|
14
|
+
SQL
|
15
|
+
|
16
|
+
change_column :dscf_marketplace_quotations, :status, :integer, using: 'status::integer', default: 0, null: false
|
17
|
+
end
|
18
|
+
|
19
|
+
def down
|
20
|
+
change_column :dscf_marketplace_quotations, :status, :string
|
21
|
+
|
22
|
+
# Map integers back to strings
|
23
|
+
execute <<-SQL
|
24
|
+
UPDATE dscf_marketplace_quotations
|
25
|
+
SET status = CASE
|
26
|
+
WHEN status = 0 THEN 'draft'
|
27
|
+
WHEN status = 1 THEN 'sent'
|
28
|
+
WHEN status = 2 THEN 'accepted'
|
29
|
+
WHEN status = 3 THEN 'rejected'
|
30
|
+
WHEN status = 4 THEN 'expired'
|
31
|
+
ELSE 'draft'
|
32
|
+
END
|
33
|
+
SQL
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,11 @@
|
|
1
|
+
FactoryBot.define do
|
2
|
+
factory :dscf_core_business, class: 'Dscf::Core::Business' do
|
3
|
+
sequence(:name) { |n| "Test Business #{n}" }
|
4
|
+
description { "Test business description" }
|
5
|
+
contact_email { "contact@example.com" }
|
6
|
+
contact_phone { "+251911123456" }
|
7
|
+
tin_number { "1234567890" }
|
8
|
+
business_type { create(:dscf_core_business_type) }
|
9
|
+
user { create(:dscf_core_user) }
|
10
|
+
end
|
11
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
FactoryBot.define do
|
2
|
+
factory :dscf_marketplace_quotation_item, class: 'Dscf::Marketplace::QuotationItem' do
|
3
|
+
association :quotation, factory: :dscf_marketplace_quotation
|
4
|
+
association :rfq_item, factory: :dscf_marketplace_rfq_item
|
5
|
+
product { rfq_item.product }
|
6
|
+
unit { rfq_item.unit }
|
7
|
+
quantity { 10.0 }
|
8
|
+
unit_price { 15.0 }
|
9
|
+
subtotal { nil }
|
10
|
+
|
11
|
+
after(:build) do |item|
|
12
|
+
# Only calculate subtotal if it's not explicitly set
|
13
|
+
if item.subtotal.nil? && item.unit_price && item.quantity
|
14
|
+
item.subtotal = item.unit_price * item.quantity
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
FactoryBot.define do
|
2
|
+
factory :dscf_marketplace_quotation, class: 'Dscf::Marketplace::Quotation' do
|
3
|
+
association :request_for_quotation, factory: :dscf_marketplace_request_for_quotation
|
4
|
+
association :business, factory: :dscf_core_business
|
5
|
+
total_price { nil }
|
6
|
+
delivery_date { 5.days.from_now.to_date }
|
7
|
+
valid_until { 15.days.from_now }
|
8
|
+
status { "draft" }
|
9
|
+
notes { "Sample quotation notes" }
|
10
|
+
|
11
|
+
trait :expired do
|
12
|
+
after(:create) do |quotation|
|
13
|
+
quotation.update_columns(delivery_date: 10.days.ago.to_date, valid_until: 5.days.ago, status: 4)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
trait :sent do
|
18
|
+
status { "sent" }
|
19
|
+
end
|
20
|
+
|
21
|
+
trait :accepted do
|
22
|
+
status { "accepted" }
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,9 @@
|
|
1
|
+
FactoryBot.define do
|
2
|
+
factory :dscf_marketplace_rfq_item, class: 'Dscf::Marketplace::RfqItem' do
|
3
|
+
association :request_for_quotation, factory: :dscf_marketplace_request_for_quotation
|
4
|
+
association :product, factory: :dscf_marketplace_product
|
5
|
+
unit { product.unit }
|
6
|
+
quantity { 10.0 }
|
7
|
+
notes { "Sample RFQ item notes" }
|
8
|
+
end
|
9
|
+
end
|
@@ -0,0 +1,10 @@
|
|
1
|
+
FactoryBot.define do
|
2
|
+
factory :dscf_marketplace_listing, class: 'Dscf::Marketplace::Listing' do
|
3
|
+
business { create(:dscf_core_business) }
|
4
|
+
supplier_product { create(:dscf_marketplace_supplier_product) }
|
5
|
+
price { 500.00 }
|
6
|
+
quantity { 100.0 }
|
7
|
+
description { "High quality product listing" }
|
8
|
+
status { :draft }
|
9
|
+
end
|
10
|
+
end
|
@@ -0,0 +1,10 @@
|
|
1
|
+
FactoryBot.define do
|
2
|
+
factory :dscf_marketplace_supplier_product, class: 'Dscf::Marketplace::SupplierProduct' do
|
3
|
+
business { create(:dscf_core_business) }
|
4
|
+
product { create(:dscf_marketplace_product) }
|
5
|
+
supplier_price { 500.00 }
|
6
|
+
available_quantity { 100.0 }
|
7
|
+
minimum_order_quantity { 10.0 }
|
8
|
+
status { :active }
|
9
|
+
end
|
10
|
+
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: dscf-marketplace
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.1.
|
4
|
+
version: 0.1.6
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Asrat
|
@@ -422,7 +422,13 @@ files:
|
|
422
422
|
- app/mailers/dscf/marketplace/application_mailer.rb
|
423
423
|
- app/models/dscf/marketplace/application_record.rb
|
424
424
|
- app/models/dscf/marketplace/category.rb
|
425
|
+
- app/models/dscf/marketplace/listing.rb
|
425
426
|
- app/models/dscf/marketplace/product.rb
|
427
|
+
- app/models/dscf/marketplace/quotation.rb
|
428
|
+
- app/models/dscf/marketplace/quotation_item.rb
|
429
|
+
- app/models/dscf/marketplace/request_for_quotation.rb
|
430
|
+
- app/models/dscf/marketplace/rfq_item.rb
|
431
|
+
- app/models/dscf/marketplace/supplier_product.rb
|
426
432
|
- app/models/dscf/marketplace/unit.rb
|
427
433
|
- app/models/dscf/marketplace/unit_conversion.rb
|
428
434
|
- config/routes.rb
|
@@ -431,12 +437,29 @@ files:
|
|
431
437
|
- db/migrate/20250827182550_create_dscf_marketplace_units.rb
|
432
438
|
- db/migrate/20250827185656_create_dscf_marketplace_unit_conversions.rb
|
433
439
|
- db/migrate/20250828062945_create_dscf_marketplace_products.rb
|
440
|
+
- db/migrate/20250828072514_create_dscf_marketplace_supplier_products.rb
|
441
|
+
- db/migrate/20250828081657_create_dscf_marketplace_listings.rb
|
442
|
+
- db/migrate/20250828090000_create_dscf_marketplace_request_for_quotations.rb
|
443
|
+
- db/migrate/20250828090100_create_dscf_marketplace_rfq_items.rb
|
444
|
+
- db/migrate/20250828090200_create_dscf_marketplace_quotations.rb
|
445
|
+
- db/migrate/20250828090300_create_dscf_marketplace_quotation_items.rb
|
446
|
+
- db/migrate/20250828090400_change_rfq_status_to_integer.rb
|
447
|
+
- db/migrate/20250828090500_change_quotation_status_to_integer.rb
|
434
448
|
- lib/dscf/marketplace.rb
|
435
449
|
- lib/dscf/marketplace/engine.rb
|
436
450
|
- lib/dscf/marketplace/version.rb
|
437
451
|
- lib/tasks/dscf/marketplace_tasks.rake
|
452
|
+
- spec/factories/dscf/core/business_types.rb
|
453
|
+
- spec/factories/dscf/core/businesses.rb
|
454
|
+
- spec/factories/dscf/core/users.rb
|
438
455
|
- spec/factories/dscf/marketplace/categories.rb
|
456
|
+
- spec/factories/dscf/marketplace/dscf/marketplace/quotation_items.rb
|
457
|
+
- spec/factories/dscf/marketplace/dscf/marketplace/quotations.rb
|
458
|
+
- spec/factories/dscf/marketplace/dscf/marketplace/request_for_quotations.rb
|
459
|
+
- spec/factories/dscf/marketplace/dscf/marketplace/rfq_items.rb
|
460
|
+
- spec/factories/dscf/marketplace/listings.rb
|
439
461
|
- spec/factories/dscf/marketplace/products.rb
|
462
|
+
- spec/factories/dscf/marketplace/supplier_products.rb
|
440
463
|
- spec/factories/dscf/marketplace/unit_conversions.rb
|
441
464
|
- spec/factories/dscf/marketplace/units.rb
|
442
465
|
homepage: https://mksaddis.com/
|