ruby_reactor 0.1.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 +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +98 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/README.md +570 -0
- data/Rakefile +12 -0
- data/documentation/DAG.md +457 -0
- data/documentation/README.md +123 -0
- data/documentation/async_reactors.md +369 -0
- data/documentation/composition.md +199 -0
- data/documentation/core_concepts.md +662 -0
- data/documentation/data_pipelines.md +224 -0
- data/documentation/examples/inventory_management.md +749 -0
- data/documentation/examples/order_processing.md +365 -0
- data/documentation/examples/payment_processing.md +654 -0
- data/documentation/getting_started.md +224 -0
- data/documentation/retry_configuration.md +357 -0
- data/lib/ruby_reactor/async_router.rb +91 -0
- data/lib/ruby_reactor/configuration.rb +41 -0
- data/lib/ruby_reactor/context.rb +169 -0
- data/lib/ruby_reactor/context_serializer.rb +164 -0
- data/lib/ruby_reactor/dependency_graph.rb +126 -0
- data/lib/ruby_reactor/dsl/compose_builder.rb +86 -0
- data/lib/ruby_reactor/dsl/map_builder.rb +112 -0
- data/lib/ruby_reactor/dsl/reactor.rb +151 -0
- data/lib/ruby_reactor/dsl/step_builder.rb +177 -0
- data/lib/ruby_reactor/dsl/template_helpers.rb +36 -0
- data/lib/ruby_reactor/dsl/validation_helpers.rb +35 -0
- data/lib/ruby_reactor/error/base.rb +16 -0
- data/lib/ruby_reactor/error/compensation_error.rb +8 -0
- data/lib/ruby_reactor/error/context_too_large_error.rb +11 -0
- data/lib/ruby_reactor/error/dependency_error.rb +8 -0
- data/lib/ruby_reactor/error/deserialization_error.rb +11 -0
- data/lib/ruby_reactor/error/input_validation_error.rb +29 -0
- data/lib/ruby_reactor/error/schema_version_error.rb +11 -0
- data/lib/ruby_reactor/error/step_failure_error.rb +18 -0
- data/lib/ruby_reactor/error/undo_error.rb +8 -0
- data/lib/ruby_reactor/error/validation_error.rb +8 -0
- data/lib/ruby_reactor/executor/compensation_manager.rb +79 -0
- data/lib/ruby_reactor/executor/graph_manager.rb +41 -0
- data/lib/ruby_reactor/executor/input_validator.rb +39 -0
- data/lib/ruby_reactor/executor/result_handler.rb +103 -0
- data/lib/ruby_reactor/executor/retry_manager.rb +156 -0
- data/lib/ruby_reactor/executor/step_executor.rb +319 -0
- data/lib/ruby_reactor/executor.rb +123 -0
- data/lib/ruby_reactor/map/collector.rb +65 -0
- data/lib/ruby_reactor/map/element_executor.rb +154 -0
- data/lib/ruby_reactor/map/execution.rb +60 -0
- data/lib/ruby_reactor/map/helpers.rb +67 -0
- data/lib/ruby_reactor/max_retries_exhausted_failure.rb +19 -0
- data/lib/ruby_reactor/reactor.rb +75 -0
- data/lib/ruby_reactor/retry_context.rb +92 -0
- data/lib/ruby_reactor/retry_queued_result.rb +26 -0
- data/lib/ruby_reactor/sidekiq_workers/map_collector_worker.rb +13 -0
- data/lib/ruby_reactor/sidekiq_workers/map_element_worker.rb +13 -0
- data/lib/ruby_reactor/sidekiq_workers/map_execution_worker.rb +15 -0
- data/lib/ruby_reactor/sidekiq_workers/worker.rb +55 -0
- data/lib/ruby_reactor/step/compose_step.rb +107 -0
- data/lib/ruby_reactor/step/map_step.rb +234 -0
- data/lib/ruby_reactor/step.rb +33 -0
- data/lib/ruby_reactor/storage/adapter.rb +51 -0
- data/lib/ruby_reactor/storage/configuration.rb +15 -0
- data/lib/ruby_reactor/storage/redis_adapter.rb +140 -0
- data/lib/ruby_reactor/template/base.rb +15 -0
- data/lib/ruby_reactor/template/element.rb +25 -0
- data/lib/ruby_reactor/template/input.rb +48 -0
- data/lib/ruby_reactor/template/result.rb +48 -0
- data/lib/ruby_reactor/template/value.rb +22 -0
- data/lib/ruby_reactor/validation/base.rb +26 -0
- data/lib/ruby_reactor/validation/input_validator.rb +62 -0
- data/lib/ruby_reactor/validation/schema_builder.rb +17 -0
- data/lib/ruby_reactor/version.rb +5 -0
- data/lib/ruby_reactor.rb +159 -0
- data/sig/ruby_reactor.rbs +4 -0
- metadata +178 -0
|
@@ -0,0 +1,749 @@
|
|
|
1
|
+
# Inventory Management Reactor Example
|
|
2
|
+
|
|
3
|
+
This example demonstrates inventory management with reservations, stock updates, and supplier integration.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
The InventoryManagementReactor handles complex inventory operations:
|
|
8
|
+
|
|
9
|
+
1. **Check Availability**: Verify stock levels
|
|
10
|
+
2. **Reserve Stock**: Temporarily hold inventory
|
|
11
|
+
3. **Update Quantities**: Modify stock levels
|
|
12
|
+
4. **Log Transactions**: Record inventory changes
|
|
13
|
+
5. **Trigger Replenishment**: Auto-order low stock items
|
|
14
|
+
6. **Send Notifications**: Alert relevant parties
|
|
15
|
+
|
|
16
|
+
### Inventory Management Workflow
|
|
17
|
+
|
|
18
|
+
```mermaid
|
|
19
|
+
graph TD
|
|
20
|
+
A[Inventory Request] --> B[validate_request]
|
|
21
|
+
B --> C{Valid<br/>Request?}
|
|
22
|
+
C -->|No| D[Fail: Validation Error]
|
|
23
|
+
C -->|Yes| E[check_availability]
|
|
24
|
+
E --> F{Stock<br/>Available?}
|
|
25
|
+
F -->|No| G[Fail: Insufficient Stock]
|
|
26
|
+
F -->|Yes| H[acquire_lock]
|
|
27
|
+
H --> I{Lock<br/>Acquired?}
|
|
28
|
+
I -->|No| J[Fail: Lock Contention]
|
|
29
|
+
I -->|Yes| K[perform_operation]
|
|
30
|
+
K --> L{Operation<br/>Success?}
|
|
31
|
+
L -->|No| M[Compensate: Release Lock]
|
|
32
|
+
L -->|Yes| N[release_lock]
|
|
33
|
+
N --> O[log_transaction]
|
|
34
|
+
O --> P[check_replenishment]
|
|
35
|
+
P --> Q{Replenishment<br/>Needed?}
|
|
36
|
+
Q -->|Yes| R[Trigger Replenishment Job]
|
|
37
|
+
Q -->|No| S[send_notifications]
|
|
38
|
+
S --> T[Success: Operation Complete]
|
|
39
|
+
|
|
40
|
+
R --> S
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Implementation
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
class InventoryManagementReactor < RubyReactor::Reactor
|
|
47
|
+
async true
|
|
48
|
+
|
|
49
|
+
retry_defaults max_attempts: 3, backoff: :exponential, base_delay: 1.second
|
|
50
|
+
|
|
51
|
+
step :validate_request do
|
|
52
|
+
validate_args do
|
|
53
|
+
required(:product_id).filled(:string)
|
|
54
|
+
required(:quantity).filled(:integer, gt?: 0)
|
|
55
|
+
required(:operation).filled(:string, included_in?: ['reserve', 'release', 'consume'])
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
run do |product_id:, quantity:, operation:, **|
|
|
59
|
+
product = Product.find_by(id: product_id)
|
|
60
|
+
raise "Product not found" unless product
|
|
61
|
+
|
|
62
|
+
Success({ product: product, operation: operation })
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
step :check_availability do
|
|
67
|
+
argument :request_data, result(:validate_request)
|
|
68
|
+
|
|
69
|
+
run do |args, _context|
|
|
70
|
+
product = args[:request_data][:product]
|
|
71
|
+
quantity = args[:request_data][:quantity]
|
|
72
|
+
operation = args[:request_data][:operation]
|
|
73
|
+
|
|
74
|
+
case operation
|
|
75
|
+
when 'consume'
|
|
76
|
+
raise "Insufficient stock" if product.inventory_count < quantity
|
|
77
|
+
when 'reserve'
|
|
78
|
+
available = product.inventory_count - product.reserved_count
|
|
79
|
+
raise "Insufficient available stock" if available < quantity
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
Success({ availability_checked: true })
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
step :acquire_lock do
|
|
87
|
+
argument :request_data, result(:validate_request)
|
|
88
|
+
argument :availability_data, result(:check_availability)
|
|
89
|
+
|
|
90
|
+
run do |args, _context|
|
|
91
|
+
product = args[:request_data][:product]
|
|
92
|
+
|
|
93
|
+
# Acquire distributed lock to prevent race conditions
|
|
94
|
+
lock_key = "inventory_lock:#{product.id}"
|
|
95
|
+
|
|
96
|
+
acquired = RedisLock.acquire(lock_key, ttl: 30.seconds)
|
|
97
|
+
raise "Failed to acquire inventory lock" unless acquired
|
|
98
|
+
|
|
99
|
+
Success({ lock_key: lock_key })
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
compensate do |args, _context|
|
|
103
|
+
lock_key = args[:availability_data][:lock_key] || args[:lock_key]
|
|
104
|
+
# Release lock on failure
|
|
105
|
+
RedisLock.release(lock_key) if lock_key
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
step :perform_operation do
|
|
110
|
+
argument :request_data, result(:validate_request)
|
|
111
|
+
argument :lock_data, result(:acquire_lock)
|
|
112
|
+
|
|
113
|
+
run do |args, _context|
|
|
114
|
+
product = args[:request_data][:product]
|
|
115
|
+
quantity = args[:request_data][:quantity]
|
|
116
|
+
operation = args[:request_data][:operation]
|
|
117
|
+
|
|
118
|
+
case operation
|
|
119
|
+
when 'reserve'
|
|
120
|
+
product.increment!(:reserved_count, quantity)
|
|
121
|
+
Success({ operation_completed: true, new_reserved: product.reserved_count })
|
|
122
|
+
when 'release'
|
|
123
|
+
product.decrement!(:reserved_count, quantity)
|
|
124
|
+
Success({ operation_completed: true, new_reserved: product.reserved_count })
|
|
125
|
+
when 'consume'
|
|
126
|
+
product.decrement!(:inventory_count, quantity)
|
|
127
|
+
Success({ operation_completed: true, new_inventory: product.inventory_count })
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
compensate do |args, _context|
|
|
132
|
+
product = args[:request_data][:product]
|
|
133
|
+
quantity = args[:request_data][:quantity]
|
|
134
|
+
operation = args[:request_data][:operation]
|
|
135
|
+
|
|
136
|
+
# Reverse the operation
|
|
137
|
+
case operation
|
|
138
|
+
when 'reserve'
|
|
139
|
+
product.decrement!(:reserved_count, quantity)
|
|
140
|
+
when 'release'
|
|
141
|
+
product.increment!(:reserved_count, quantity)
|
|
142
|
+
when 'consume'
|
|
143
|
+
product.increment!(:inventory_count, quantity)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
step :release_lock do
|
|
149
|
+
argument :lock_data, result(:acquire_lock)
|
|
150
|
+
argument :operation_data, result(:perform_operation)
|
|
151
|
+
|
|
152
|
+
run do |args, _context|
|
|
153
|
+
lock_key = args[:lock_data][:lock_key]
|
|
154
|
+
|
|
155
|
+
RedisLock.release(lock_key)
|
|
156
|
+
Success({ lock_released: true })
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
step :log_transaction do
|
|
161
|
+
argument :request_data, result(:validate_request)
|
|
162
|
+
argument :release_data, result(:release_lock)
|
|
163
|
+
|
|
164
|
+
run do |args, _context|
|
|
165
|
+
product = args[:request_data][:product]
|
|
166
|
+
quantity = args[:request_data][:quantity]
|
|
167
|
+
operation = args[:request_data][:operation]
|
|
168
|
+
|
|
169
|
+
InventoryTransaction.create!(
|
|
170
|
+
product: product,
|
|
171
|
+
operation: operation,
|
|
172
|
+
quantity: quantity,
|
|
173
|
+
performed_at: Time.current
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
Success({ transaction_logged: true })
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
step :check_replenishment do
|
|
181
|
+
argument :request_data, result(:validate_request)
|
|
182
|
+
argument :log_data, result(:log_transaction)
|
|
183
|
+
|
|
184
|
+
run do |args, _context|
|
|
185
|
+
product = args[:request_data][:product]
|
|
186
|
+
|
|
187
|
+
if product.inventory_count <= product.reorder_point
|
|
188
|
+
# Trigger replenishment process
|
|
189
|
+
ReplenishmentJob.perform_later(product.id)
|
|
190
|
+
Success({ replenishment_triggered: true })
|
|
191
|
+
else
|
|
192
|
+
Success({ replenishment_checked: true })
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
step :send_notifications do
|
|
198
|
+
argument :request_data, result(:validate_request)
|
|
199
|
+
argument :replenishment_data, result(:check_replenishment)
|
|
200
|
+
|
|
201
|
+
idempotent true
|
|
202
|
+
retries max_attempts: 3, backoff: :linear, base_delay: 5.seconds
|
|
203
|
+
|
|
204
|
+
run do |args, _context|
|
|
205
|
+
product = args[:request_data][:product]
|
|
206
|
+
operation = args[:request_data][:operation]
|
|
207
|
+
quantity = args[:request_data][:quantity]
|
|
208
|
+
replenishment_triggered = args[:replenishment_data][:replenishment_triggered]
|
|
209
|
+
|
|
210
|
+
notifications = []
|
|
211
|
+
|
|
212
|
+
# Notify warehouse staff for significant changes
|
|
213
|
+
if quantity > 10
|
|
214
|
+
notifications << NotificationService.notify_warehouse(
|
|
215
|
+
quantity: quantity
|
|
216
|
+
)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Notify managers for low stock
|
|
220
|
+
if replenishment_triggered
|
|
221
|
+
notifications << NotificationService.notify_managers(
|
|
222
|
+
product: product,
|
|
223
|
+
message: "Low stock alert: #{product.inventory_count} remaining"
|
|
224
|
+
)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
Success({ notifications_sent: notifications.all?(&:success?) })
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
## Advanced Inventory Scenarios
|
|
234
|
+
|
|
235
|
+
### Bulk Inventory Operations
|
|
236
|
+
|
|
237
|
+
```ruby
|
|
238
|
+
class BulkInventoryReactor < RubyReactor::Reactor
|
|
239
|
+
async true
|
|
240
|
+
|
|
241
|
+
step :validate_bulk_request do
|
|
242
|
+
validate_args do
|
|
243
|
+
required(:operations).filled(:array, max_size?: 100) do
|
|
244
|
+
each do
|
|
245
|
+
hash do
|
|
246
|
+
required(:product_id).filled(:string)
|
|
247
|
+
required(:quantity).filled(:integer, gt?: 0)
|
|
248
|
+
required(:operation).filled(:string, included_in?: ['reserve', 'release', 'consume'])
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
run do |operations:, **|
|
|
255
|
+
validated_ops = operations.map do |op|
|
|
256
|
+
product = Product.find(op[:product_id])
|
|
257
|
+
raise "Product #{op[:product_id]} not found" unless product
|
|
258
|
+
|
|
259
|
+
op.merge(product: product)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
Success({ validated_operations: validated_ops })
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
step :execute_operations do
|
|
267
|
+
argument :bulk_data, result(:validate_bulk_request)
|
|
268
|
+
|
|
269
|
+
run do |args, _context|
|
|
270
|
+
validated_operations = args[:bulk_data][:validated_operations]
|
|
271
|
+
|
|
272
|
+
results = []
|
|
273
|
+
|
|
274
|
+
validated_operations.each do |op|
|
|
275
|
+
begin
|
|
276
|
+
# Execute each operation in sequence to maintain consistency
|
|
277
|
+
result = InventoryManagementReactor.run(
|
|
278
|
+
product_id: op[:product_id],
|
|
279
|
+
quantity: op[:quantity],
|
|
280
|
+
operation: op[:operation]
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
if result.success?
|
|
284
|
+
results << { success: true, operation: op }
|
|
285
|
+
else
|
|
286
|
+
results << { success: false, operation: op, error: result.error.message }
|
|
287
|
+
end
|
|
288
|
+
rescue => e
|
|
289
|
+
results << { success: false, operation: op, error: e.message }
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
successful_count = results.count { |r| r[:success] }
|
|
294
|
+
|
|
295
|
+
if successful_count == 0
|
|
296
|
+
raise "All operations failed"
|
|
297
|
+
elsif successful_count < results.size
|
|
298
|
+
# Partial success - log warnings but don't fail
|
|
299
|
+
Rails.logger.warn("Bulk inventory: #{successful_count}/#{results.size} operations succeeded")
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
Success({ bulk_results: results, partial_success: successful_count < results.size })
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
### Inventory Transfer Between Locations
|
|
309
|
+
|
|
310
|
+
```ruby
|
|
311
|
+
class InventoryTransferReactor < RubyReactor::Reactor
|
|
312
|
+
async true
|
|
313
|
+
|
|
314
|
+
step :validate_transfer do
|
|
315
|
+
validate_args do
|
|
316
|
+
required(:from_location_id).filled(:string)
|
|
317
|
+
required(:to_location_id).filled(:string)
|
|
318
|
+
required(:product_id).filled(:string)
|
|
319
|
+
required(:quantity).filled(:integer, gt?: 0)
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
run do |from_location_id:, to_location_id:, product_id:, quantity:, **|
|
|
323
|
+
from_location = Location.find(from_location_id)
|
|
324
|
+
to_location = Location.find(to_location_id)
|
|
325
|
+
product = Product.find(product_id)
|
|
326
|
+
|
|
327
|
+
raise "Invalid locations" unless from_location && to_location
|
|
328
|
+
raise "Same location" if from_location_id == to_location_id
|
|
329
|
+
raise "Product not found" unless product
|
|
330
|
+
|
|
331
|
+
# Check source location has enough stock
|
|
332
|
+
source_inventory = LocationInventory.find_by(
|
|
333
|
+
location: from_location,
|
|
334
|
+
product: product
|
|
335
|
+
)
|
|
336
|
+
raise "Insufficient stock at source" unless source_inventory&.quantity&.>= quantity
|
|
337
|
+
|
|
338
|
+
Success({
|
|
339
|
+
from_location: from_location,
|
|
340
|
+
to_location: to_location,
|
|
341
|
+
product: product,
|
|
342
|
+
quantity: quantity
|
|
343
|
+
})
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
step :reserve_source do
|
|
348
|
+
argument :transfer_data, result(:validate_transfer)
|
|
349
|
+
|
|
350
|
+
run do |args, _context|
|
|
351
|
+
from_location = args[:transfer_data][:from_location]
|
|
352
|
+
product = args[:transfer_data][:product]
|
|
353
|
+
quantity = args[:transfer_data][:quantity]
|
|
354
|
+
|
|
355
|
+
# Reserve inventory at source location
|
|
356
|
+
reservation = InventoryService.reserve_at_location(
|
|
357
|
+
location: from_location,
|
|
358
|
+
product: product,
|
|
359
|
+
quantity: quantity
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
raise "Source reservation failed" unless reservation
|
|
363
|
+
|
|
364
|
+
Success({ source_reservation_id: reservation.id })
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
compensate do |args, _context|
|
|
368
|
+
source_reservation_id = args[:transfer_data][:source_reservation_id] || args[:source_reservation_id]
|
|
369
|
+
InventoryService.release_reservation(source_reservation_id)
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
step :prepare_destination do
|
|
374
|
+
argument :transfer_data, result(:validate_transfer)
|
|
375
|
+
argument :reservation_data, result(:reserve_source)
|
|
376
|
+
|
|
377
|
+
run do |args, _context|
|
|
378
|
+
to_location = args[:transfer_data][:to_location]
|
|
379
|
+
product = args[:transfer_data][:product]
|
|
380
|
+
quantity = args[:transfer_data][:quantity]
|
|
381
|
+
|
|
382
|
+
# Ensure destination location can accept the inventory
|
|
383
|
+
destination_inventory = LocationInventory.find_or_create_by(
|
|
384
|
+
location: to_location,
|
|
385
|
+
product: product
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
Success({ destination_prepared: true })
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
step :execute_transfer do
|
|
393
|
+
argument :transfer_data, result(:validate_transfer)
|
|
394
|
+
argument :reservation_data, result(:reserve_source)
|
|
395
|
+
argument :destination_data, result(:prepare_destination)
|
|
396
|
+
|
|
397
|
+
run do |args, _context|
|
|
398
|
+
from_location = args[:transfer_data][:from_location]
|
|
399
|
+
to_location = args[:transfer_data][:to_location]
|
|
400
|
+
product = args[:transfer_data][:product]
|
|
401
|
+
quantity = args[:transfer_data][:quantity]
|
|
402
|
+
source_reservation_id = args[:reservation_data][:source_reservation_id]
|
|
403
|
+
|
|
404
|
+
# Atomically transfer inventory
|
|
405
|
+
InventoryService.transfer_inventory(
|
|
406
|
+
from_location: from_location,
|
|
407
|
+
to_location: to_location,
|
|
408
|
+
product: product,
|
|
409
|
+
quantity: quantity,
|
|
410
|
+
reservation_id: source_reservation_id
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
Success({ transfer_completed: true })
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
compensate do |args, _context|
|
|
417
|
+
from_location = args[:transfer_data][:from_location]
|
|
418
|
+
to_location = args[:transfer_data][:to_location]
|
|
419
|
+
product = args[:transfer_data][:product]
|
|
420
|
+
quantity = args[:transfer_data][:quantity]
|
|
421
|
+
|
|
422
|
+
# Reverse the transfer - this is complex and might require manual intervention
|
|
423
|
+
Rails.logger.error("Transfer compensation needed for #{product.id} x #{quantity}")
|
|
424
|
+
# Trigger manual review process
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
step :log_transfer do
|
|
429
|
+
argument :transfer_data, result(:validate_transfer)
|
|
430
|
+
argument :transfer_result, result(:execute_transfer)
|
|
431
|
+
|
|
432
|
+
run do |args, _context|
|
|
433
|
+
from_location = args[:transfer_data][:from_location]
|
|
434
|
+
to_location = args[:transfer_data][:to_location]
|
|
435
|
+
product = args[:transfer_data][:product]
|
|
436
|
+
quantity = args[:transfer_data][:quantity]
|
|
437
|
+
|
|
438
|
+
InventoryTransfer.create!(
|
|
439
|
+
from_location: from_location,
|
|
440
|
+
to_location: to_location,
|
|
441
|
+
product: product,
|
|
442
|
+
quantity: quantity,
|
|
443
|
+
transferred_at: Time.current
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
Success({ transfer_logged: true })
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
end
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
## Supplier Integration
|
|
453
|
+
|
|
454
|
+
### Automatic Replenishment
|
|
455
|
+
|
|
456
|
+
```ruby
|
|
457
|
+
class ReplenishmentReactor < RubyReactor::Reactor
|
|
458
|
+
async true
|
|
459
|
+
|
|
460
|
+
retry_defaults max_attempts: 3, backoff: :exponential, base_delay: 5.minutes
|
|
461
|
+
|
|
462
|
+
step :check_supplier_availability do
|
|
463
|
+
validate_args do
|
|
464
|
+
required(:product_id).filled(:string)
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
run do |product_id:, **|
|
|
468
|
+
product = Product.find(product_id)
|
|
469
|
+
supplier = product.supplier
|
|
470
|
+
|
|
471
|
+
raise "No supplier configured" unless supplier
|
|
472
|
+
raise "Supplier inactive" unless supplier.active?
|
|
473
|
+
|
|
474
|
+
Success({ product: product, supplier: supplier })
|
|
475
|
+
end
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
step :calculate_order_quantity do
|
|
479
|
+
argument :supplier_data, result(:check_supplier_availability)
|
|
480
|
+
|
|
481
|
+
run do |args, _context|
|
|
482
|
+
product = args[:supplier_data][:product]
|
|
483
|
+
supplier = args[:supplier_data][:supplier]
|
|
484
|
+
|
|
485
|
+
# Calculate optimal order quantity
|
|
486
|
+
current_stock = product.inventory_count
|
|
487
|
+
reorder_point = product.reorder_point
|
|
488
|
+
max_stock = product.max_stock_level
|
|
489
|
+
|
|
490
|
+
# Order enough to reach max stock
|
|
491
|
+
order_quantity = [max_stock - current_stock, supplier.min_order_quantity].max
|
|
492
|
+
|
|
493
|
+
# Check supplier constraints
|
|
494
|
+
order_quantity = [order_quantity, supplier.max_order_quantity].min
|
|
495
|
+
|
|
496
|
+
raise "Invalid order quantity" if order_quantity <= 0
|
|
497
|
+
|
|
498
|
+
Success({ order_quantity: order_quantity })
|
|
499
|
+
end
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
step :check_supplier_inventory do
|
|
503
|
+
argument :supplier_data, result(:check_supplier_availability)
|
|
504
|
+
argument :quantity_data, result(:calculate_order_quantity)
|
|
505
|
+
|
|
506
|
+
run do |args, _context|
|
|
507
|
+
supplier = args[:supplier_data][:supplier]
|
|
508
|
+
product = args[:supplier_data][:product]
|
|
509
|
+
order_quantity = args[:quantity_data][:order_quantity]
|
|
510
|
+
|
|
511
|
+
# Query supplier API for availability
|
|
512
|
+
supplier_response = SupplierAPI.check_availability(
|
|
513
|
+
supplier_id: supplier.id,
|
|
514
|
+
product_sku: product.sku,
|
|
515
|
+
quantity: order_quantity
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
raise "Product not available from supplier" unless supplier_response.available?
|
|
519
|
+
|
|
520
|
+
actual_available = supplier_response.available_quantity
|
|
521
|
+
if actual_available < order_quantity
|
|
522
|
+
# Adjust order quantity
|
|
523
|
+
order_quantity = actual_available
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
Success({ adjusted_quantity: order_quantity, supplier_available: true })
|
|
527
|
+
end
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
step :place_supplier_order do
|
|
531
|
+
argument :supplier_data, result(:check_supplier_availability)
|
|
532
|
+
argument :inventory_data, result(:check_supplier_inventory)
|
|
533
|
+
|
|
534
|
+
run do |args, _context|
|
|
535
|
+
supplier = args[:supplier_data][:supplier]
|
|
536
|
+
product = args[:supplier_data][:product]
|
|
537
|
+
adjusted_quantity = args[:inventory_data][:adjusted_quantity]
|
|
538
|
+
|
|
539
|
+
order_response = SupplierAPI.place_order(
|
|
540
|
+
supplier_id: supplier.id,
|
|
541
|
+
items: [{
|
|
542
|
+
sku: product.sku,
|
|
543
|
+
quantity: adjusted_quantity,
|
|
544
|
+
unit_price: product.supplier_price
|
|
545
|
+
}]
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
raise "Supplier order failed" unless order_response.success?
|
|
549
|
+
|
|
550
|
+
Success({ supplier_order_id: order_response.order_id, order_placed: true })
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
compensate do |args, _context|
|
|
554
|
+
supplier_order_id = args[:inventory_data][:supplier_order_id] || args[:supplier_order_id]
|
|
555
|
+
# Cancel the supplier order
|
|
556
|
+
SupplierAPI.cancel_order(supplier_order_id) if supplier_order_id
|
|
557
|
+
end
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
step :record_purchase_order do
|
|
561
|
+
argument :supplier_data, result(:check_supplier_availability)
|
|
562
|
+
argument :inventory_data, result(:check_supplier_inventory)
|
|
563
|
+
argument :order_data, result(:place_supplier_order)
|
|
564
|
+
|
|
565
|
+
run do |args, _context|
|
|
566
|
+
product = args[:supplier_data][:product]
|
|
567
|
+
supplier = args[:supplier_data][:supplier]
|
|
568
|
+
adjusted_quantity = args[:inventory_data][:adjusted_quantity]
|
|
569
|
+
supplier_order_id = args[:order_data][:supplier_order_id]
|
|
570
|
+
|
|
571
|
+
purchase_order = PurchaseOrder.create!(
|
|
572
|
+
supplier: supplier,
|
|
573
|
+
supplier_order_id: supplier_order_id,
|
|
574
|
+
status: :ordered,
|
|
575
|
+
items_attributes: [{
|
|
576
|
+
product: product,
|
|
577
|
+
quantity: adjusted_quantity,
|
|
578
|
+
unit_price: product.supplier_price,
|
|
579
|
+
total_price: adjusted_quantity * product.supplier_price
|
|
580
|
+
}]
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
Success({ purchase_order_id: purchase_order.id })
|
|
584
|
+
end
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
step :schedule_delivery_tracking do
|
|
588
|
+
argument :purchase_data, result(:record_purchase_order)
|
|
589
|
+
|
|
590
|
+
run do |args, _context|
|
|
591
|
+
supplier_order_id = args[:order_data][:supplier_order_id]
|
|
592
|
+
|
|
593
|
+
# Schedule job to track delivery status
|
|
594
|
+
DeliveryTrackingJob.set(wait: 1.hour).perform_later(supplier_order_id)
|
|
595
|
+
|
|
596
|
+
Success({ tracking_scheduled: true })
|
|
597
|
+
end
|
|
598
|
+
end
|
|
599
|
+
end
|
|
600
|
+
```
|
|
601
|
+
|
|
602
|
+
## Testing Inventory Operations
|
|
603
|
+
|
|
604
|
+
```ruby
|
|
605
|
+
RSpec.describe InventoryManagementReactor do
|
|
606
|
+
let(:product) { create(:product, inventory_count: 100, reserved_count: 0) }
|
|
607
|
+
|
|
608
|
+
context "successful reservation" do
|
|
609
|
+
it "reserves inventory correctly" do
|
|
610
|
+
result = InventoryManagementReactor.run(
|
|
611
|
+
product_id: product.id,
|
|
612
|
+
quantity: 10,
|
|
613
|
+
operation: 'reserve'
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
expect(result).to be_success
|
|
617
|
+
product.reload
|
|
618
|
+
expect(product.reserved_count).to eq(10)
|
|
619
|
+
expect(product.available_count).to eq(90)
|
|
620
|
+
end
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
context "insufficient stock" do
|
|
624
|
+
it "fails when trying to consume more than available" do
|
|
625
|
+
result = InventoryManagementReactor.run(
|
|
626
|
+
product_id: product.id,
|
|
627
|
+
quantity: 150,
|
|
628
|
+
operation: 'consume'
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
expect(result).to be_failure
|
|
632
|
+
expect(result.error.message).to include("Insufficient stock")
|
|
633
|
+
end
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
context "concurrent operations" do
|
|
637
|
+
it "handles race conditions with locking" do
|
|
638
|
+
# Simulate concurrent operations
|
|
639
|
+
threads = 5.times.map do
|
|
640
|
+
Thread.new do
|
|
641
|
+
InventoryManagementReactor.run(
|
|
642
|
+
product_id: product.id,
|
|
643
|
+
quantity: 1,
|
|
644
|
+
operation: 'reserve'
|
|
645
|
+
)
|
|
646
|
+
end
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
threads.each(&:join)
|
|
650
|
+
|
|
651
|
+
product.reload
|
|
652
|
+
expect(product.reserved_count).to eq(5) # All operations succeeded
|
|
653
|
+
end
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
context "compensation" do
|
|
657
|
+
it "releases lock and reverses operation on failure" do
|
|
658
|
+
allow_any_instance_of(InventoryManagementReactor).to receive(:perform_operation)
|
|
659
|
+
.and_raise("Simulated failure")
|
|
660
|
+
|
|
661
|
+
initial_reserved = product.reserved_count
|
|
662
|
+
|
|
663
|
+
result = InventoryManagementReactor.run(
|
|
664
|
+
product_id: product.id,
|
|
665
|
+
quantity: 5,
|
|
666
|
+
operation: 'reserve'
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
expect(result).to be_failure
|
|
670
|
+
product.reload
|
|
671
|
+
expect(product.reserved_count).to eq(initial_reserved) # No change
|
|
672
|
+
end
|
|
673
|
+
end
|
|
674
|
+
end
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
## Performance Optimization
|
|
678
|
+
|
|
679
|
+
### Database Optimization
|
|
680
|
+
|
|
681
|
+
```ruby
|
|
682
|
+
# Use database constraints for inventory integrity
|
|
683
|
+
class Product < ApplicationRecord
|
|
684
|
+
# Ensure inventory never goes negative
|
|
685
|
+
validates :inventory_count, numericality: { greater_than_or_equal_to: 0 }
|
|
686
|
+
|
|
687
|
+
# Use optimistic locking
|
|
688
|
+
# validates :lock_version
|
|
689
|
+
end
|
|
690
|
+
```
|
|
691
|
+
|
|
692
|
+
### Caching Strategy
|
|
693
|
+
|
|
694
|
+
```ruby
|
|
695
|
+
class InventoryCache
|
|
696
|
+
def self.get_availability(product_id)
|
|
697
|
+
Rails.cache.fetch("inventory:#{product_id}", expires_in: 5.minutes) do
|
|
698
|
+
product = Product.find(product_id)
|
|
699
|
+
{
|
|
700
|
+
inventory_count: product.inventory_count,
|
|
701
|
+
reserved_count: product.reserved_count,
|
|
702
|
+
available: product.inventory_count - product.reserved_count
|
|
703
|
+
}
|
|
704
|
+
end
|
|
705
|
+
end
|
|
706
|
+
|
|
707
|
+
def self.invalidate(product_id)
|
|
708
|
+
Rails.cache.delete("inventory:#{product_id}")
|
|
709
|
+
end
|
|
710
|
+
end
|
|
711
|
+
```
|
|
712
|
+
|
|
713
|
+
### Batch Operations
|
|
714
|
+
|
|
715
|
+
```ruby
|
|
716
|
+
class BatchInventoryUpdateReactor < RubyReactor::Reactor
|
|
717
|
+
step :batch_update do
|
|
718
|
+
run do |updates:, **|
|
|
719
|
+
# Use single transaction for batch updates
|
|
720
|
+
Product.transaction do
|
|
721
|
+
updates.each do |update|
|
|
722
|
+
product = Product.find(update[:product_id])
|
|
723
|
+
product.increment!(:inventory_count, update[:quantity])
|
|
724
|
+
end
|
|
725
|
+
end
|
|
726
|
+
|
|
727
|
+
Success({ batch_completed: true })
|
|
728
|
+
end
|
|
729
|
+
end
|
|
730
|
+
end
|
|
731
|
+
```
|
|
732
|
+
|
|
733
|
+
## Monitoring and Alerting
|
|
734
|
+
|
|
735
|
+
```ruby
|
|
736
|
+
INVENTORY_METRICS = {
|
|
737
|
+
stockout_rate: "Percentage of products out of stock",
|
|
738
|
+
reservation_failure_rate: "Rate of failed reservations",
|
|
739
|
+
average_operation_time: "Average time for inventory operations",
|
|
740
|
+
lock_contention_rate: "Percentage of operations hitting lock contention",
|
|
741
|
+
replenishment_delay: "Average time to replenish low stock items"
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
INVENTORY_ALERTS = {
|
|
745
|
+
high_stockout_rate: "Stockout rate above 5%",
|
|
746
|
+
high_lock_contention: "Lock contention above 10%",
|
|
747
|
+
replenishment_failures: "Failed replenishment orders in last hour"
|
|
748
|
+
}
|
|
749
|
+
```
|