dscf-marketplace 0.8.9 → 0.9.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: ce8356967d3398e0db71eeea4bb7c4c4ab29b7755a6709728b4e221ed66d66b7
4
- data.tar.gz: 29a36ec8dee0f6b151e204678199122ca6f4876201c3afb0eaaabceb11cd3ca4
3
+ metadata.gz: 06e78b71c3cb2e9a47b73bdc71eb3c221e6ef2c00fae135fbe6f4b9f6b76c0ad
4
+ data.tar.gz: f416eef38f577530f81e698f6c0fbccfbca0e18b342866e50736a3a328bd65e4
5
5
  SHA512:
6
- metadata.gz: 0d1ee57848f223f11bbe6953b603e639cd4e71972056a36b73c95c8265e742df4872cfc095ef373b1bf11ef1dae3c41f6be9ed64baf4957b7b36d6b119f3b74b
7
- data.tar.gz: '07086f8b707602c32ebfa535c572642edd0f8d7d12ef39443f1aff18e9670eadc8a01cdaedf20c53dc0ccb115ca86e259edfb5b71e1564077d718885e0ee378c'
6
+ metadata.gz: 58a193bbca932e849a71adfe6faad3bb2b9b79a8cb312fcb4973aeec8e650b740ae6d10977548b8c94633616fbdeb5089570e332d205ef83e77538e5af6953b6
7
+ data.tar.gz: 727fd25dd31d44c4f52544ecd161096a502ca4168ca934e1bcd4324ac4b9dcff57c37c268607e4f081ede2e88227441aeef35cd021dd8a7333313a451349de23
@@ -104,47 +104,215 @@ module Dscf
104
104
  render_success("orders.success.index", data: orders, serializer_options: options)
105
105
  end
106
106
 
107
+ def validate
108
+ obj = find_record
109
+ authorize obj, :validate?
110
+
111
+ Dscf::Marketplace::OrderValidationService.validate(obj)
112
+ create_notification_for_order(obj, :validation_completed)
113
+ render_success(data: obj, serializer_options: { include: default_serializer_includes[:show] || [] })
114
+ rescue => e
115
+ render_error(errors: e.message, status: :unprocessable_entity)
116
+ end
117
+
118
+ def resolve_item
119
+ obj = find_record
120
+ authorize obj, :resolve_item?
121
+
122
+ item = obj.order_items.find(params[:order_item_id])
123
+ # Example: for price_changed, accept optional resolved_unit_price from params
124
+ if params[:resolved_unit_price].present?
125
+ item.resolved_unit_price = params[:resolved_unit_price]
126
+ item.validation_status = :validated
127
+ end
128
+ if params[:resolved_quantity].present?
129
+ item.resolved_quantity = params[:resolved_quantity]
130
+ item.validation_status = :validated
131
+ end
132
+ if params[:action_type] == "remove"
133
+ item.validation_status = :validated
134
+ item.quantity = 0 # will be cleaned or marked
135
+ end
136
+
137
+ item.save!
138
+ create_notification_for_order(obj, :item_resolved)
139
+ render_success(data: item)
140
+ rescue => e
141
+ render_error(errors: e.message, status: :unprocessable_entity)
142
+ end
143
+
144
+ def assign_source
145
+ obj = find_record
146
+ authorize obj, :split?
147
+
148
+ item = obj.order_items.find(params[:order_item_id])
149
+ Dscf::Marketplace::OrderSplittingService.assign_source(item, source_type: params[:source_type], source_id: params[:source_id])
150
+ render_success(data: item)
151
+ rescue => e
152
+ render_error(errors: e.message, status: :unprocessable_entity)
153
+ end
154
+
155
+ def split
156
+ obj = find_record
157
+ authorize obj, :split?
158
+
159
+ return render_error(errors: "All items must be validated before splitting") unless obj.all_items_validated?
160
+
161
+ Dscf::Marketplace::OrderSplittingService.perform_split(obj)
162
+ create_notification_for_order(obj, :split_ready)
163
+ render_success(data: obj, serializer_options: { include: default_serializer_includes[:show] || [] })
164
+ rescue => e
165
+ render_error(errors: e.message, status: :unprocessable_entity)
166
+ end
167
+
168
+ def supplier_confirm
169
+ obj = find_record
170
+ authorize obj, :supplier_confirm?
171
+
172
+ confirmed = params[:confirmed]
173
+ reason = params[:reason]
174
+ Dscf::Marketplace::OrderSplittingService.supplier_confirm(obj, confirmed: confirmed != false, reason: reason)
175
+
176
+ create_notification_for_order(obj, confirmed ? :supplier_confirmed : :supplier_rejected, reason: reason)
177
+ render_success(data: obj, serializer_options: { include: default_serializer_includes[:show] || [] })
178
+ rescue => e
179
+ render_error(errors: e.message, status: :unprocessable_entity)
180
+ end
181
+
182
+ def retailer_confirm
183
+ obj = find_record
184
+ authorize obj, :confirm? # reuse existing or add specific
185
+
186
+ return render_error(errors: "Order not ready for retailer confirmation") unless obj.retailer_can_confirm?
187
+
188
+ if params[:confirmed] == false
189
+ reason = params[:reason]
190
+ obj.update!(status: :cancelled)
191
+ obj.order_items.update_all(status: OrderItem.statuses[:cancelled])
192
+ create_notification_for_order(obj, :retailer_rejected, reason: reason)
193
+ else
194
+ obj.update!(status: :confirmed)
195
+ obj.order_items.update_all(status: OrderItem.statuses[:confirmed])
196
+ create_notification_for_order(obj, :retailer_confirmed)
197
+ end
198
+
199
+ render_success(data: obj, serializer_options: { include: default_serializer_includes[:show] || [] })
200
+ rescue => e
201
+ render_error(errors: e.message, status: :unprocessable_entity)
202
+ end
203
+
204
+
205
+ def create_notification_for_order(order, action, reason: nil)
206
+ recipient = order.ordered_by || order.user
207
+ return unless recipient
208
+
209
+ title = case action
210
+ when :validation_completed then "Order ##{order.id} validation complete"
211
+ when :item_resolved then "Item resolved in order ##{order.id}"
212
+ when :split_ready then "Order ##{order.id} ready for supplier confirmation"
213
+ when :supplier_confirmed then "Supplier confirmed order ##{order.id}"
214
+ when :supplier_rejected then "Supplier rejected item in order ##{order.id}"
215
+ when :retailer_confirmed then "Retailer confirmed order ##{order.id}"
216
+ when :retailer_rejected then "Retailer cancelled order ##{order.id}"
217
+ else "Order ##{order.id} update"
218
+ end
219
+
220
+ body = case action
221
+ when :supplier_rejected, :retailer_rejected then "Reason: #{reason}" if reason
222
+ else "Status: #{order.status}"
223
+ end
224
+
225
+ notification = Dscf::Core::Notification.create!(
226
+ notifiable: order,
227
+ recipient: recipient,
228
+ notification_type: :general,
229
+ title: title,
230
+ body: body.to_s
231
+ )
232
+ Dscf::Core::NotificationService.deliver(notification)
233
+ end
234
+
107
235
  private
108
236
 
109
237
  def create_direct_listing_order
110
- listing = Dscf::Marketplace::Listing.active.find_by(id: model_params[:listing_id])
111
- return render_error(errors: "Listing is not available", status: :unprocessable_entity) unless listing
238
+ listing = nil
239
+ aggregator_listing = nil
240
+
241
+ if model_params[:listing_type] == "AggregatorListing"
242
+ aggregator_listing = Dscf::Marketplace::AggregatorListing.active.find_by(id: model_params[:listing_id])
243
+ return render_error(errors: "Listing is not available", status: :unprocessable_entity) unless aggregator_listing
244
+ else
245
+ listing = Dscf::Marketplace::Listing.active.find_by(id: model_params[:listing_id])
246
+ if listing.blank? && model_params[:listing_type].blank?
247
+ aggregator_listing = Dscf::Marketplace::AggregatorListing.active.find_by(id: model_params[:listing_id])
248
+ end
249
+ return render_error(errors: "Listing is not available", status: :unprocessable_entity) if listing.blank? && aggregator_listing.blank?
250
+ end
112
251
 
113
252
  quantity = direct_listing_quantity
114
253
  return render_error(errors: "Quantity must be greater than 0", status: :unprocessable_entity) unless quantity.positive?
115
254
 
116
- if quantity > listing.quantity
255
+ limit_quantity = listing ? listing.quantity : aggregator_listing.quantity
256
+ if quantity > limit_quantity
117
257
  return render_error(errors: "Requested quantity exceeds available listing quantity", status: :unprocessable_entity)
118
258
  end
119
259
 
120
260
  order = nil
121
261
  ActiveRecord::Base.transaction do
122
- listing.lock!
123
-
124
- if quantity > listing.quantity
125
- listing.errors.add(:base, "Requested quantity exceeds available listing quantity")
126
- raise ActiveRecord::RecordInvalid.new(listing)
262
+ if listing
263
+ listing.lock!
264
+
265
+ if quantity > listing.quantity
266
+ listing.errors.add(:base, "Requested quantity exceeds available listing quantity")
267
+ raise ActiveRecord::RecordInvalid.new(listing)
268
+ end
269
+
270
+ order = @clazz.new(model_params.except(:order_items_attributes))
271
+ order.order_type = :direct_listing
272
+ order.status = :pending
273
+ order.listing = listing
274
+ order.ordered_to = listing.business
275
+
276
+ product = listing.supplier_product.product
277
+ order.order_items.build(
278
+ listing: listing,
279
+ product: product,
280
+ unit: product.unit,
281
+ quantity: quantity,
282
+ unit_price: listing.price,
283
+ status: :pending
284
+ )
285
+ order.save!
286
+
287
+ new_quantity = listing.quantity - quantity
288
+ listing.update!(quantity: new_quantity, status: (new_quantity.zero? ? :sold_out : listing.status))
289
+ else
290
+ aggregator_listing.lock!
291
+
292
+ if quantity > aggregator_listing.quantity
293
+ aggregator_listing.errors.add(:base, "Requested quantity exceeds available listing quantity")
294
+ raise ActiveRecord::RecordInvalid.new(aggregator_listing)
295
+ end
296
+
297
+ order = @clazz.new(model_params.except(:order_items_attributes, :listing_id))
298
+ order.order_type = :direct_listing
299
+ order.status = :pending
300
+ order.ordered_to = aggregator_listing.aggregator
301
+
302
+ product = aggregator_listing.supplier_product.product
303
+ order.order_items.build(
304
+ source: aggregator_listing,
305
+ product: product,
306
+ unit: product.unit,
307
+ quantity: quantity,
308
+ unit_price: aggregator_listing.price,
309
+ status: :pending
310
+ )
311
+ order.save!
312
+
313
+ new_quantity = aggregator_listing.quantity - quantity
314
+ aggregator_listing.update!(quantity: new_quantity, status: (new_quantity.zero? ? :sold_out : aggregator_listing.status))
127
315
  end
128
-
129
- order = @clazz.new(model_params.except(:order_items_attributes))
130
- order.order_type = :direct_listing
131
- order.status = :pending
132
- order.listing = listing
133
- order.ordered_to = listing.business
134
-
135
- product = listing.supplier_product.product
136
- order.order_items.build(
137
- listing: listing,
138
- product: product,
139
- unit: product.unit,
140
- quantity: quantity,
141
- unit_price: listing.price,
142
- status: :pending
143
- )
144
- order.save!
145
-
146
- new_quantity = listing.quantity - quantity
147
- listing.update!(quantity: new_quantity, status: (new_quantity.zero? ? :sold_out : listing.status))
148
316
  end
149
317
 
150
318
  order = @clazz.includes(eager_loaded_associations).find(order.id) if eager_loaded_associations.present?
@@ -170,7 +338,7 @@ module Dscf
170
338
  def model_params
171
339
  @model_params ||= params.require(:order).permit(
172
340
  :quotation_id, :listing_id, :user_id, :ordered_by_id, :ordered_to_id, :delivery_order_id, :dropoff_address_id,
173
- :order_type, :status, :fulfillment_type, :payment_method, :received_bank_name, :transaction_reference,
341
+ :order_type, :status, :fulfillment_type, :payment_method, :received_bank_name, :transaction_reference, :listing_type,
174
342
  order_items_attributes: [ :id, :quotation_item_id, :listing_id, :product_id, :unit_id, :quantity, :unit_price, :status, :_destroy ]
175
343
  )
176
344
  @model_params[:payment_method] = normalize_payment_method(@model_params[:payment_method]) if @model_params.key?(:payment_method)
@@ -183,7 +351,7 @@ module Dscf
183
351
 
184
352
  def default_serializer_includes
185
353
  {
186
- index: [ :user, :ordered_by, :ordered_to, :quotation, order_items: [ :product ] ],
354
+ index: [ :user, :ordered_by, :ordered_to, :quotation, :order_items ],
187
355
  show: [ :user, :ordered_by, :ordered_to, :quotation, :listing, :delivery_order, :order_items ],
188
356
  create: [ :user, :ordered_by, :ordered_to, :quotation, :listing, :order_items ],
189
357
  update: [ :user, :ordered_by, :ordered_to, :quotation, :listing, :order_items ]
@@ -1,7 +1,18 @@
1
1
  module Dscf::Marketplace
2
2
  class Order < ApplicationRecord
3
3
  enum :order_type, {rfq_based: 0, direct_listing: 1}
4
- enum :status, {pending: 0, confirmed: 1, processing: 2, completed: 3, cancelled: 4}
4
+ # Workflow statuses aligned with requirements doc for Sprint 2 order management
5
+ enum :status, {
6
+ pending: 0, # initial / validating
7
+ validating: 1,
8
+ splitting: 2,
9
+ waiting_supplier_confirmation: 3,
10
+ waiting_retailer_confirmation: 4,
11
+ confirmed: 5,
12
+ processing: 6, # legacy / fulfillment
13
+ completed: 7,
14
+ cancelled: 8
15
+ }
5
16
  enum :fulfillment_type, {self_pickup: 0, delivery: 1}
6
17
  enum :payment_method, {cash: 0, credit: 1, bank_transfer: 2}, default: :cash
7
18
 
@@ -46,7 +57,7 @@ module Dscf::Marketplace
46
57
 
47
58
  attributes = {
48
59
  order_type: :rfq_based,
49
- status: :processing,
60
+ status: :validating,
50
61
  fulfillment_type: dropoff_address.present? ? :delivery : :self_pickup,
51
62
  quotation: quotation,
52
63
  user: quotation.request_for_quotation.user, # Keep for backward compatibility
@@ -66,19 +77,21 @@ module Dscf::Marketplace
66
77
  unit: item.unit,
67
78
  quantity: item.quantity,
68
79
  unit_price: item.unit_price,
69
- status: :pending
80
+ status: :pending,
81
+ validation_status: :validated # initial from accepted quote; validation can re-run
70
82
  )
71
83
  end
72
84
 
73
85
  order
74
86
  end
75
87
 
88
+
76
89
  def self.create_from_listing(listing, user, quantity, dropoff_address = nil, payment_method = nil)
77
90
  return nil unless listing.visible? && quantity <= listing.quantity
78
91
 
79
92
  attributes = {
80
93
  order_type: :direct_listing,
81
- status: :pending,
94
+ status: :validating,
82
95
  fulfillment_type: dropoff_address.present? ? :delivery : :self_pickup,
83
96
  listing: listing,
84
97
  user: user, # Keep for backward compatibility
@@ -97,12 +110,14 @@ module Dscf::Marketplace
97
110
  unit: listing.supplier_product.product.unit,
98
111
  quantity: quantity,
99
112
  unit_price: listing.price,
100
- status: :pending
113
+ status: :pending,
114
+ validation_status: :validated
101
115
  )
102
116
 
103
117
  order
104
118
  end
105
119
 
120
+
106
121
  def total_amount
107
122
  # Return stored value if it exists and is greater than 0, otherwise calculate
108
123
  stored_value = super
@@ -117,7 +132,7 @@ module Dscf::Marketplace
117
132
  if rfq_based?
118
133
  quotation&.business
119
134
  elsif direct_listing?
120
- listing&.business
135
+ listing&.business || ordered_to
121
136
  end
122
137
  end
123
138
 
@@ -157,10 +172,44 @@ module Dscf::Marketplace
157
172
  self.total_amount = order_items.sum { |item| item.quantity * item.unit_price }
158
173
  end
159
174
 
175
+ # Workflow helpers for Sprint 2 order validation + splitting
176
+ def validating?
177
+ pending? || status.to_s == "processing"
178
+ end
179
+
180
+ def all_items_validated?
181
+ order_items.all? { |i| i.validation_status == "validated" }
182
+ end
183
+
184
+ def validation_summary
185
+ order_items.group_by(&:validation_status).transform_values(&:count)
186
+ end
187
+
188
+ def mark_splitting!
189
+ update!(status: :splitting)
190
+ end
191
+
192
+ def mark_waiting_supplier_confirmation!
193
+ update!(status: :waiting_supplier_confirmation)
194
+ end
195
+
196
+ def mark_waiting_retailer_confirmation!
197
+ update!(status: :waiting_retailer_confirmation)
198
+ end
199
+
200
+ def retailer_can_confirm?
201
+ waiting_retailer_confirmation? || confirmed? # allow re-confirm safety
202
+ end
203
+
204
+ def supplier_confirmation_complete?
205
+ # simplistic: all items have source and are confirmed or cancelled
206
+ order_items.all? { |i| i.source_id.present? && (i.status.to_s == "confirmed" || i.status.to_s == "cancelled") }
207
+ end
208
+
160
209
  private
161
210
 
162
211
  def quotation_or_listing_present
163
- unless quotation.present? || listing.present?
212
+ unless quotation.present? || listing.present? || order_items.any? { |item| item.source_type == "Dscf::Marketplace::AggregatorListing" }
164
213
  errors.add(:base, "Either quotation or listing must be present")
165
214
  end
166
215
  end
@@ -1,6 +1,12 @@
1
1
  module Dscf::Marketplace
2
2
  class OrderItem < ApplicationRecord
3
3
  enum :status, {pending: 0, confirmed: 1, processing: 2, fulfilled: 3, cancelled: 4}
4
+ enum :validation_status, {
5
+ validated: 0,
6
+ no_longer_listed: 1,
7
+ price_changed: 2,
8
+ low_quantity: 3
9
+ }, default: :validated
4
10
 
5
11
  delegate :name, :description, :thumbnail_url, :images_urls, to: :product, allow_nil: true
6
12
 
@@ -9,6 +15,7 @@ module Dscf::Marketplace
9
15
  belongs_to :listing, optional: true
10
16
  belongs_to :product
11
17
  belongs_to :unit
18
+ belongs_to :source, polymorphic: true, optional: true
12
19
 
13
20
  validates :quantity, presence: true, numericality: {greater_than: 0}
14
21
  validates :unit_price, presence: true, numericality: {greater_than_or_equal_to: 0}
@@ -19,11 +26,11 @@ module Dscf::Marketplace
19
26
 
20
27
  # Ransack configuration for secure filtering
21
28
  def self.ransackable_attributes(_auth_object = nil)
22
- %w[id order_id quotation_item_id listing_id product_id unit_id quantity unit_price status created_at updated_at]
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]
23
30
  end
24
31
 
25
32
  def self.ransackable_associations(_auth_object = nil)
26
- %w[order quotation_item listing product unit]
33
+ %w[order quotation_item listing product unit source]
27
34
  end
28
35
 
29
36
  def subtotal
@@ -49,8 +56,8 @@ module Dscf::Marketplace
49
56
  private
50
57
 
51
58
  def quotation_item_or_listing_present
52
- unless quotation_item.present? || listing.present?
53
- errors.add(:base, "Either quotation_item or listing must be 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")
54
61
  end
55
62
  end
56
63
  end
@@ -20,6 +20,30 @@ module Dscf
20
20
  def my_orders?
21
21
  user.has_permission?("orders.my_orders")
22
22
  end
23
+
24
+ def validate?
25
+ user.has_permission?("orders.validate")
26
+ end
27
+
28
+ def split?
29
+ user.has_permission?("orders.split")
30
+ end
31
+
32
+ def resolve_item?
33
+ user.has_permission?("orders.resolve_item")
34
+ end
35
+
36
+ def supplier_confirm?
37
+ user.has_permission?("orders.supplier_confirm")
38
+ end
39
+
40
+ def retailer_confirm?
41
+ user.has_permission?("orders.confirm") # or dedicated permission
42
+ end
43
+
44
+ def show?
45
+ user.has_permission?("orders.show") || user.has_permission?("orders.filter") || user.super_admin?
46
+ end
23
47
  end
24
48
  end
25
49
  end
@@ -3,7 +3,8 @@ module Dscf
3
3
  class OrderItemSerializer < ActiveModel::Serializer
4
4
  attributes :id, :order_id, :quotation_item_id, :listing_id,
5
5
  :product_id, :unit_id, :quantity, :unit_price,
6
- :status, :created_at, :updated_at,
6
+ :status, :validation_status, :validation_note, :resolved_unit_price, :resolved_quantity,
7
+ :source_type, :source_id, :source_name, :created_at, :updated_at,
7
8
  :subtotal, :product_name, :unit_name, :thumbnail_url, :images_urls
8
9
 
9
10
  belongs_to :order
@@ -11,6 +12,23 @@ module Dscf
11
12
  belongs_to :listing
12
13
  belongs_to :product
13
14
  belongs_to :unit
15
+
16
+ def source_name
17
+ return nil unless object.source
18
+ case object.source_type
19
+ when 'Dscf::Marketplace::SubSupplier'
20
+ object.source.try(:business_name) || object.source.try(:name)
21
+ when 'Dscf::Marketplace::Supplier'
22
+ object.source.try(:name)
23
+ when 'Dscf::Core::Business'
24
+ object.source.try(:name)
25
+ else
26
+ object.source.try(:name) || object.source.try(:business_name)
27
+ end
28
+ end
29
+
30
+ # Note: source association is polymorphic; use source_name + source_* attrs for most clients.
31
+ # belongs_to :source would be possible but we keep it attribute-based for simplicity.
14
32
  end
15
33
  end
16
34
  end
@@ -2,7 +2,7 @@ module Dscf
2
2
  module Marketplace
3
3
  class OrderSerializer < ActiveModel::Serializer
4
4
  attributes :id, :quotation_id, :listing_id, :user_id, :ordered_by_id, :ordered_to_id, :delivery_order_id, :dropoff_address_id,
5
- :order_type, :status, :fulfillment_type, :payment_method, :received_bank_name, :transaction_reference, :total_amount,
5
+ :order_type, :status, :workflow_status, :has_validation_issues, :fulfillment_type, :payment_method, :received_bank_name, :transaction_reference, :total_amount,
6
6
  :buyer_phone, :buyer_email, :seller_name, :seller_phone, :seller_email,
7
7
  :created_at, :updated_at
8
8
 
@@ -34,6 +34,15 @@ module Dscf
34
34
  def seller_email
35
35
  object.ordered_to&.contact_email
36
36
  end
37
+
38
+ # Sprint 2 workflow helpers for new order handling (validation + splitting)
39
+ def workflow_status
40
+ object.status
41
+ end
42
+
43
+ def has_validation_issues
44
+ object.order_items.any? { |i| i.validation_status.present? && i.validation_status.to_s != 'validated' }
45
+ end
37
46
  end
38
47
  end
39
48
  end
@@ -0,0 +1,49 @@
1
+ module Dscf
2
+ module Marketplace
3
+ class OrderSplittingService
4
+ # Handles source assignment and the split operation per requirements.
5
+ # Sources can be:
6
+ # - Aggregator self (via aggregator listing or direct)
7
+ # - Sub-supplier
8
+ # - Other supplier
9
+
10
+ def self.assign_source(order_item, source_type:, source_id:)
11
+ order_item.source_type = source_type
12
+ order_item.source_id = source_id
13
+ order_item.save!
14
+ order_item
15
+ end
16
+
17
+ def self.perform_split(order)
18
+ raise "Cannot split order that is not ready" unless order.all_items_validated?
19
+
20
+ # Mark items ready for source assignment / supplier confirmation
21
+ order.order_items.each do |item|
22
+ if item.status.to_s == "pending" || item.status.to_s == "confirmed"
23
+ item.status = :processing
24
+ end
25
+ item.save! if item.changed?
26
+ end
27
+
28
+ order.mark_waiting_supplier_confirmation!
29
+
30
+ order
31
+ end
32
+
33
+ def self.supplier_confirm(order, confirmed: true, reason: nil)
34
+ if confirmed
35
+ # In full impl, mark specific allocation as confirmed
36
+ # For now advance whole if appropriate
37
+ else
38
+ order.order_items.where(status: :processing).update_all(status: OrderItem.statuses[:cancelled])
39
+ end
40
+
41
+ # Check if supplier side is done
42
+ if order.supplier_confirmation_complete?
43
+ order.mark_waiting_retailer_confirmation!
44
+ end
45
+ order
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,68 @@
1
+ module Dscf
2
+ module Marketplace
3
+ class OrderValidationService
4
+ # Validates all items in an order against current listings / availability.
5
+ # Sets validation_status on each OrderItem per the requirements doc:
6
+ # validated, no_longer_listed, price_changed, low_quantity
7
+ # Also populates resolved_* fields where applicable.
8
+ def self.validate(order)
9
+ order.order_items.each do |item|
10
+ validate_item(item)
11
+ end
12
+ # Transition order to splitting ready if all validated
13
+ if order.order_items.all? { |i| i.validation_status == "validated" }
14
+ order.update!(status: :splitting) if order.validating?
15
+ end
16
+ order.reload
17
+ end
18
+
19
+
20
+ def self.validate_item(item)
21
+ current_listing = find_current_active_listing(item)
22
+ return mark_no_longer_listed(item) unless current_listing
23
+
24
+ current_price = current_listing.price
25
+ current_qty = current_listing.quantity
26
+
27
+ if current_price != item.unit_price
28
+ item.validation_status = :price_changed
29
+ item.validation_note = "Price changed (listed: #{current_price})"
30
+ item.resolved_unit_price = item.unit_price # keep original order price by default (user can choose listed later)
31
+ elsif item.quantity > current_qty
32
+ item.validation_status = :low_quantity
33
+ item.validation_note = "Low quantity available (listed: #{current_qty})"
34
+ item.resolved_quantity = [ item.quantity, current_qty ].min
35
+ else
36
+ item.validation_status = :validated
37
+ item.validation_note = nil
38
+ item.resolved_unit_price = nil
39
+ item.resolved_quantity = nil
40
+ end
41
+
42
+ item.save! if item.changed?
43
+ item
44
+ end
45
+
46
+ private_class_method def self.find_current_active_listing(item)
47
+ # Prefer the original listing if still active
48
+ if item.listing && Listing.active.exists?(id: item.listing.id)
49
+ return item.listing
50
+ end
51
+
52
+ # Fallback: find cheapest active listing for the product
53
+ Listing.active
54
+ .joins(:supplier_product)
55
+ .where(supplier_products: {product_id: item.product_id})
56
+ .order(price: :asc)
57
+ .first
58
+ end
59
+
60
+ private_class_method def self.mark_no_longer_listed(item)
61
+ item.validation_status = :no_longer_listed
62
+ item.validation_note = "Product no longer listed"
63
+ item.save! if item.changed?
64
+ item
65
+ end
66
+ end
67
+ end
68
+ end
data/config/routes.rb CHANGED
@@ -125,6 +125,12 @@ Dscf::Marketplace::Engine.routes.draw do
125
125
  post "cancel"
126
126
  post "complete"
127
127
  get "invoice"
128
+ post "validate"
129
+ post "resolve_item"
130
+ post "assign_source"
131
+ post "split"
132
+ post "supplier_confirm"
133
+ post "retailer_confirm"
128
134
  end
129
135
  collection do
130
136
  get "my_orders"
data/db/demo_seeds.rb CHANGED
@@ -22,6 +22,7 @@ Dscf::Marketplace::RfqItem.delete_all
22
22
  Dscf::Marketplace::RequestForQuotation.delete_all
23
23
  Dscf::Marketplace::AggregatorListing.delete_all
24
24
  Dscf::Marketplace::Listing.delete_all
25
+ Dscf::Marketplace::ProductInclusionRequest.delete_all
25
26
  Dscf::Marketplace::Agent.delete_all
26
27
  Dscf::Marketplace::SupplierProduct.delete_all
27
28
  Dscf::Marketplace::Supplier.delete_all
@@ -41,6 +42,13 @@ end
41
42
  sa_role = Dscf::Core::Role.find_or_create_by!(code: "SUPER_ADMIN") { |r| r.name = "Super Admin"; r.active = true }
42
43
  Dscf::Core::UserRole.find_or_create_by!(user: admin, role: sa_role)
43
44
 
45
+ # Ensure SUPER_ADMIN has basic orders permissions (show/filter etc) for detail views etc.
46
+ orders_perms = Dscf::Core::Permission.where(resource: "orders")
47
+ orders_perms.each do |perm|
48
+ Dscf::Core::RolePermission.find_or_create_by!(role: sa_role, permission: perm)
49
+ end
50
+ puts " Granted orders permissions to SUPER_ADMIN"
51
+
44
52
  puts "🌱 Seeding demo data..."
45
53
 
46
54
  # ── Businesses ──
@@ -113,8 +121,85 @@ Dscf::Marketplace::AggregatorListing.create!(aggregator: biz_agg, supplier_produ
113
121
  Dscf::Marketplace::AggregatorListing.create!(aggregator: biz_agg, supplier_product: sp6b, price: 420, quantity: 80, status: 0)
114
122
  puts " Aggregator Listings: 3"
115
123
 
124
+ # ── Demo Address ──
125
+ address = Dscf::Core::Address.first || Dscf::Core::Address.create!(addressable: admin, address_line_1: "Bole Road, Building 12", city: "Addis Ababa", country: "Ethiopia")
126
+
127
+ # ── Orders (Direct Purchases) ──
128
+ l1 = Dscf::Marketplace::Listing.first
129
+ if l1
130
+ order1 = Dscf::Marketplace::Order.create!(
131
+ order_type: :direct_listing,
132
+ status: :pending,
133
+ fulfillment_type: :self_pickup,
134
+ payment_method: :cash,
135
+ user: admin,
136
+ ordered_by: admin,
137
+ ordered_to: l1.business,
138
+ listing: l1,
139
+ total_amount: l1.price * 5
140
+ )
141
+ order1.order_items.create!(
142
+ listing: l1,
143
+ product: l1.supplier_product.product,
144
+ unit: l1.supplier_product.product.unit,
145
+ quantity: 5,
146
+ unit_price: l1.price,
147
+ status: :pending
148
+ )
149
+ end
150
+
151
+ l2 = Dscf::Marketplace::Listing.last
152
+ if l2 && l2 != l1
153
+ order2 = Dscf::Marketplace::Order.create!(
154
+ order_type: :direct_listing,
155
+ status: :processing,
156
+ fulfillment_type: :delivery,
157
+ payment_method: :bank_transfer,
158
+ user: admin,
159
+ ordered_by: admin,
160
+ ordered_to: l2.business,
161
+ listing: l2,
162
+ dropoff_address: address,
163
+ total_amount: l2.price * 10
164
+ )
165
+ order2.order_items.create!(
166
+ listing: l2,
167
+ product: l2.supplier_product.product,
168
+ unit: l2.supplier_product.product.unit,
169
+ quantity: 10,
170
+ unit_price: l2.price,
171
+ status: :pending
172
+ )
173
+ end
174
+ puts " Orders: 2"
175
+
176
+ # ── RFQs (Request For Quotations) ──
177
+ rfq1 = Dscf::Marketplace::RequestForQuotation.create!(
178
+ user: admin,
179
+ status: :sent,
180
+ notes: "Looking for 500kg of premium washed Yirgacheffe coffee in bulk for export."
181
+ )
182
+ rfq1.rfq_items.create!(
183
+ product: p1,
184
+ unit: p1.unit,
185
+ quantity: 500
186
+ )
187
+
188
+ rfq2 = Dscf::Marketplace::RequestForQuotation.create!(
189
+ user: admin,
190
+ status: :draft,
191
+ notes: "Checking prices for traditional Shema fabrics."
192
+ )
193
+ rfq2.rfq_items.create!(
194
+ product: p4,
195
+ unit: p4.unit,
196
+ quantity: 100
197
+ )
198
+ puts " RFQs: 2"
199
+
116
200
  puts ""
117
201
  puts "✅ DONE."
118
202
  puts " 6 visible: Yirgacheffe, Sidamo(650), Shema(promoted), Habesha(580), Berbere, Harrar"
119
203
  puts " 2 hidden: Sidamo(720), Habesha(650)"
120
204
  puts " 1 pending approval (sp6), 2 pending KYC, 1 inactive agent"
205
+ puts " 2 test orders, 2 test RFQs added for UI validation."
@@ -0,0 +1,13 @@
1
+ class AddValidationAndSourceFieldsToDscfMarketplaceOrderItems < ActiveRecord::Migration[8.0]
2
+ def change
3
+ add_column :dscf_marketplace_order_items, :validation_status, :integer, default: 0, null: false
4
+ add_column :dscf_marketplace_order_items, :validation_note, :text
5
+ add_column :dscf_marketplace_order_items, :resolved_unit_price, :decimal, precision: 15, scale: 6
6
+ add_column :dscf_marketplace_order_items, :resolved_quantity, :decimal, precision: 15, scale: 6
7
+ add_column :dscf_marketplace_order_items, :source_type, :string
8
+ add_column :dscf_marketplace_order_items, :source_id, :bigint
9
+
10
+ add_index :dscf_marketplace_order_items, :validation_status, name: "validation_status_on_dm_order_items_idx"
11
+ add_index :dscf_marketplace_order_items, [:source_type, :source_id], name: "source_on_dm_order_items_idx"
12
+ end
13
+ end
data/db/seeds.rb CHANGED
@@ -11,7 +11,7 @@ Dscf::Core::PermissionRegistry.register("dscf-marketplace") do
11
11
  resource :rfq_items, actions: %i[index show create update destroy]
12
12
  resource :quotations, actions: %i[index show create update destroy accept reject send_quotation my_quotes filter]
13
13
  resource :quotation_items, actions: %i[index show create update destroy]
14
- resource :orders, actions: %i[index show create update destroy confirm cancel complete my_orders filter]
14
+ 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]
15
15
  resource :order_items, actions: %i[index show create update destroy]
16
16
  resource :delivery_orders, actions: %i[
17
17
  index show create update destroy pickup start_delivery complete_delivery mark_failed accept summary
@@ -39,3 +39,15 @@ marketplace_permissions.find_each do |permission|
39
39
  end
40
40
 
41
41
  puts "✅ Seeded #{marketplace_permissions.count} marketplace permissions and assigned to USER role."
42
+
43
+ # Also grant key orders permissions (incl. show for detail views) to SUPER_ADMIN if exists
44
+ sa_role = Dscf::Core::Role.find_by(code: "SUPER_ADMIN")
45
+ if sa_role
46
+ orders_actions = %i[show filter validate resolve_item assign_source split supplier_confirm retailer_confirm]
47
+ orders_actions.each do |action|
48
+ if perm = Dscf::Core::Permission.find_by(resource: "orders", action: action)
49
+ Dscf::Core::RolePermission.find_or_create_by!(role: sa_role, permission: perm)
50
+ end
51
+ end
52
+ puts " Also granted key orders.* to SUPER_ADMIN"
53
+ end
@@ -33,7 +33,7 @@ module Dscf
33
33
  resource :rfq_items, actions: %i[index show create update destroy]
34
34
  resource :quotations, actions: %i[index show create update destroy accept reject send_quotation my_quotes filter]
35
35
  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]
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]
37
37
  resource :order_items, actions: %i[index show create update destroy]
38
38
  resource :delivery_orders, actions: %i[
39
39
  index show create update destroy pickup start_delivery complete_delivery mark_failed accept summary
@@ -1,5 +1,5 @@
1
1
  module Dscf
2
2
  module Marketplace
3
- VERSION = "0.8.9".freeze
3
+ VERSION = "0.9.1".freeze
4
4
  end
5
5
  end
@@ -4,6 +4,7 @@ FactoryBot.define do
4
4
  quantity { 5 }
5
5
  unit_price { 10.0 }
6
6
  status { :pending }
7
+ validation_status { :validated }
7
8
  quotation_item { nil } # Don't create by default
8
9
  listing { nil } # Don't create by default
9
10
 
@@ -45,5 +46,10 @@ FactoryBot.define do
45
46
  trait :cancelled do
46
47
  status { :cancelled }
47
48
  end
49
+
50
+ trait :price_changed do
51
+ validation_status { :price_changed }
52
+ validation_note { "Price changed" }
53
+ end
48
54
  end
49
55
  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.9
4
+ version: 0.9.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Asrat
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-06-19 00:00:00.000000000 Z
10
+ date: 2026-06-25 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: rails
@@ -525,6 +525,8 @@ files:
525
525
  - app/services/dscf/marketplace/gebeta_service.rb
526
526
  - app/services/dscf/marketplace/invoice_pdf_generator.rb
527
527
  - app/services/dscf/marketplace/my_resource_service.rb
528
+ - app/services/dscf/marketplace/order_splitting_service.rb
529
+ - app/services/dscf/marketplace/order_validation_service.rb
528
530
  - app/services/dscf/marketplace/rfq_response_service.rb
529
531
  - app/services/dscf/marketplace/role_service.rb
530
532
  - app/services/dscf/marketplace/route_optimization_service.rb
@@ -586,6 +588,7 @@ files:
586
588
  - db/migrate/20260616000010_add_code_to_dscf_marketplace_categories_and_gender_to_dscf_marketplace_agents.rb
587
589
  - db/migrate/20260619210001_add_user_id_to_dscf_marketplace_agents.rb
588
590
  - db/migrate/20260619210002_add_user_id_to_dscf_marketplace_retailers.rb
591
+ - db/migrate/20260622000001_add_validation_and_source_fields_to_dscf_marketplace_order_items.rb
589
592
  - db/seeds.rb
590
593
  - lib/dscf/marketplace.rb
591
594
  - lib/dscf/marketplace/engine.rb