1use super::types::*;
4use converge_pack::PackSolver;
5use converge_pack::gate::GateResult as Result;
6use converge_pack::gate::{ProblemSpec, ReplayEnvelope, SolverReport, StopReason};
7
8pub struct EoqSolver;
17
18impl EoqSolver {
19 pub fn solve_replenishment(
21 &self,
22 input: &InventoryReplenishmentInput,
23 spec: &ProblemSpec,
24 ) -> Result<(InventoryReplenishmentOutput, SolverReport)> {
25 let seed = spec.seed();
26 let constraints = &input.constraints;
27
28 let mut candidates: Vec<ReplenishmentCandidate> = input
30 .products
31 .iter()
32 .map(|p| self.calculate_candidate(p, constraints.target_service_level))
33 .collect();
34
35 candidates.sort_by(|a, b| {
37 a.days_until_stockout
38 .partial_cmp(&b.days_until_stockout)
39 .unwrap_or(std::cmp::Ordering::Equal)
40 });
41
42 let tie_break = &spec.determinism.tie_break;
44 let mut sorted_candidates = Vec::new();
45 let mut current_urgency = f64::NEG_INFINITY;
46 let mut urgency_group: Vec<ReplenishmentCandidate> = vec![];
47
48 for candidate in candidates {
49 if (candidate.days_until_stockout - current_urgency).abs() < 0.01 {
50 urgency_group.push(candidate);
51 } else {
52 if !urgency_group.is_empty() {
53 urgency_group.sort_by(|a, b| a.product.id.cmp(&b.product.id));
54 if let Some(selected) = tie_break
55 .select_by(&urgency_group, seed, |a, b| a.product.id.cmp(&b.product.id))
56 {
57 sorted_candidates.push(selected.clone());
58 } else {
59 sorted_candidates.extend(urgency_group.drain(..));
60 }
61 }
62 current_urgency = candidate.days_until_stockout;
63 urgency_group = vec![candidate];
64 }
65 }
66 if !urgency_group.is_empty() {
68 urgency_group.sort_by(|a, b| a.product.id.cmp(&b.product.id));
69 if let Some(selected) =
70 tie_break.select_by(&urgency_group, seed, |a, b| a.product.id.cmp(&b.product.id))
71 {
72 sorted_candidates.push(selected.clone());
73 } else {
74 sorted_candidates.extend(urgency_group.drain(..));
75 }
76 }
77
78 let mut remaining_budget = constraints.budget;
80 let mut orders = Vec::new();
81 let mut not_ordered = Vec::new();
82 let mut total_cost = 0.0;
83 let mut total_units: i64 = 0;
84
85 for candidate in &sorted_candidates {
86 if let Some(max) = constraints.max_orders {
88 if orders.len() >= max {
89 not_ordered.push(NotOrderedProduct {
90 product_id: candidate.product.id.clone(),
91 product_name: candidate.product.name.clone(),
92 reason: "Maximum order limit reached".to_string(),
93 current_inventory: candidate.product.current_inventory,
94 days_remaining: candidate.days_until_stockout,
95 });
96 continue;
97 }
98 }
99
100 let mut order_qty = candidate.recommended_quantity;
102
103 if let Some(min_qty) = constraints.min_order_quantity {
105 if order_qty < min_qty && order_qty > 0 {
106 order_qty = min_qty;
107 }
108 }
109
110 if !candidate.needs_order {
112 not_ordered.push(NotOrderedProduct {
113 product_id: candidate.product.id.clone(),
114 product_name: candidate.product.name.clone(),
115 reason: format!(
116 "Sufficient inventory ({} days remaining)",
117 candidate.days_until_stockout as i64
118 ),
119 current_inventory: candidate.product.current_inventory,
120 days_remaining: candidate.days_until_stockout,
121 });
122 continue;
123 }
124
125 let order_cost = candidate.product.total_order_cost(order_qty);
127
128 if order_cost > remaining_budget {
130 let max_affordable_units = ((remaining_budget - candidate.product.ordering_cost)
132 / candidate.product.unit_cost)
133 .floor() as i64;
134
135 if max_affordable_units > 0 {
136 let affordable_cost = candidate.product.total_order_cost(max_affordable_units);
137 if affordable_cost <= remaining_budget {
138 order_qty = max_affordable_units;
139 } else {
140 not_ordered.push(NotOrderedProduct {
141 product_id: candidate.product.id.clone(),
142 product_name: candidate.product.name.clone(),
143 reason: format!(
144 "Insufficient budget (need ${:.2}, have ${:.2})",
145 order_cost, remaining_budget
146 ),
147 current_inventory: candidate.product.current_inventory,
148 days_remaining: candidate.days_until_stockout,
149 });
150 continue;
151 }
152 } else {
153 not_ordered.push(NotOrderedProduct {
154 product_id: candidate.product.id.clone(),
155 product_name: candidate.product.name.clone(),
156 reason: format!(
157 "Insufficient budget (need ${:.2}, have ${:.2})",
158 order_cost, remaining_budget
159 ),
160 current_inventory: candidate.product.current_inventory,
161 days_remaining: candidate.days_until_stockout,
162 });
163 continue;
164 }
165 }
166
167 let final_cost = candidate.product.total_order_cost(order_qty);
168 remaining_budget -= final_cost;
169 total_cost += final_cost;
170 total_units += order_qty;
171
172 let order_day = self.calculate_order_day(&candidate);
174 let arrival_day = order_day + candidate.product.lead_time_days;
175
176 orders.push(ReplenishmentOrder {
177 product_id: candidate.product.id.clone(),
178 product_name: candidate.product.name.clone(),
179 quantity: order_qty,
180 order_day,
181 arrival_day,
182 order_cost: final_cost,
183 unit_cost: candidate.product.unit_cost,
184 eoq: candidate.eoq,
185 safety_stock: candidate.safety_stock,
186 reorder_point: candidate.reorder_point,
187 order_reason: self.generate_order_reason(&candidate),
188 });
189 }
190
191 let projections = self.generate_projections(&orders, &input.products, constraints);
193
194 let projected_service_level = self.calculate_projected_service_level(
196 &orders,
197 &input.products,
198 constraints.target_service_level,
199 );
200
201 let budget_utilization = if constraints.budget > 0.0 {
202 total_cost / constraints.budget
203 } else {
204 0.0
205 };
206
207 let output = InventoryReplenishmentOutput {
208 orders,
209 not_ordered,
210 projections,
211 stats: ReplenishmentStats {
212 total_order_cost: total_cost,
213 total_units_ordered: total_units,
214 products_ordered: sorted_candidates
215 .iter()
216 .filter(|c| c.needs_order)
217 .count()
218 .min(input.products.len()),
219 products_skipped: input.products.len()
220 - sorted_candidates
221 .iter()
222 .filter(|c| c.needs_order)
223 .count()
224 .min(input.products.len()),
225 budget_utilization,
226 projected_service_level,
227 reason: if total_units > 0 {
228 format!(
229 "EOQ-based replenishment plan for {} products",
230 input.products.len()
231 )
232 } else {
233 "No replenishment needed".to_string()
234 },
235 },
236 };
237
238 let mut final_output = output;
240 final_output.stats.products_ordered = final_output.orders.len();
241 final_output.stats.products_skipped = final_output.not_ordered.len();
242
243 let replay = ReplayEnvelope::minimal(seed);
244 let report = if !final_output.orders.is_empty() {
245 SolverReport::optimal("eoq-v1", -total_cost, replay)
247 } else if input.products.iter().all(|p| !p.needs_reorder()) {
248 SolverReport::feasible("eoq-v1", 0.0, StopReason::Feasible, replay)
250 } else {
251 SolverReport::infeasible("eoq-v1", vec![], StopReason::NoFeasible, replay)
252 };
253
254 Ok((final_output, report))
255 }
256
257 fn calculate_candidate(&self, product: &Product, service_level: f64) -> ReplenishmentCandidate {
258 let eoq = product.calculate_eoq();
259 let safety_stock = product.calculate_safety_stock(service_level);
260 let reorder_point = product.calculate_reorder_point(service_level);
261 let days_until_stockout = product.days_of_inventory();
262
263 let needs_order = (product.current_inventory as f64) < reorder_point;
265
266 let target_level = reorder_point + eoq;
268 let quantity_needed = (target_level - product.current_inventory as f64).max(0.0);
269 let recommended_quantity = if needs_order {
270 eoq.max(quantity_needed).ceil() as i64
271 } else {
272 0
273 };
274
275 ReplenishmentCandidate {
276 product: product.clone(),
277 eoq,
278 safety_stock,
279 reorder_point,
280 days_until_stockout,
281 needs_order,
282 recommended_quantity,
283 }
284 }
285
286 fn calculate_order_day(&self, candidate: &ReplenishmentCandidate) -> i64 {
287 if candidate.product.current_inventory as f64 <= candidate.reorder_point {
289 return 0;
290 }
291
292 let inventory_above_rop =
294 candidate.product.current_inventory as f64 - candidate.reorder_point;
295 let days_until_rop = if candidate.product.demand_forecast.average_daily > 0.0 {
296 (inventory_above_rop / candidate.product.demand_forecast.average_daily).floor() as i64
297 } else {
298 0
299 };
300
301 days_until_rop.max(0)
302 }
303
304 fn generate_order_reason(&self, candidate: &ReplenishmentCandidate) -> String {
305 if candidate.days_until_stockout < candidate.product.lead_time_days as f64 {
306 format!(
307 "Urgent: stockout risk in {:.1} days, lead time is {} days",
308 candidate.days_until_stockout, candidate.product.lead_time_days
309 )
310 } else if candidate.product.current_inventory as f64 <= candidate.reorder_point {
311 format!(
312 "Below reorder point ({:.0} units), current inventory: {}",
313 candidate.reorder_point, candidate.product.current_inventory
314 )
315 } else {
316 format!(
317 "Proactive replenishment, {:.1} days of inventory remaining",
318 candidate.days_until_stockout
319 )
320 }
321 }
322
323 fn generate_projections(
324 &self,
325 orders: &[ReplenishmentOrder],
326 products: &[Product],
327 constraints: &ReplenishmentConstraints,
328 ) -> Vec<InventoryProjection> {
329 let mut projections = Vec::new();
330
331 for product in products {
332 let order = orders.iter().find(|o| o.product_id == product.id);
333 let current_inventory = product.current_inventory as f64;
334
335 let key_days = vec![0, 7, 14, 21, constraints.planning_horizon_days];
337
338 for &day in &key_days {
339 if day > constraints.planning_horizon_days {
340 break;
341 }
342
343 let demand = product.demand_forecast.average_daily * day as f64;
345 let mut projected = (current_inventory - demand).max(0.0);
346
347 let order_arriving = if let Some(o) = order {
349 if o.arrival_day <= day {
350 projected += o.quantity as f64;
351 o.arrival_day == day
352 } else {
353 false
354 }
355 } else {
356 false
357 };
358
359 let safety_stock = product.calculate_safety_stock(constraints.target_service_level);
361 let stockout_prob = if projected <= 0.0 {
362 1.0
363 } else if projected < safety_stock {
364 (safety_stock - projected) / safety_stock
365 } else {
366 0.0
367 };
368
369 projections.push(InventoryProjection {
370 product_id: product.id.clone(),
371 day,
372 projected_inventory: projected.max(0.0) as i64,
373 stockout_probability: stockout_prob.min(1.0),
374 order_arriving,
375 });
376 }
377 }
378
379 projections
380 }
381
382 fn calculate_projected_service_level(
383 &self,
384 orders: &[ReplenishmentOrder],
385 products: &[Product],
386 target_service_level: f64,
387 ) -> f64 {
388 if products.is_empty() {
389 return 0.0;
390 }
391
392 let mut total_fill_rate = 0.0;
393
394 for product in products {
395 let order = orders.iter().find(|o| o.product_id == product.id);
396 let total_demand = product.total_forecast_demand();
397
398 if total_demand <= 0.0 {
399 total_fill_rate += 1.0;
400 continue;
401 }
402
403 let available =
405 product.current_inventory as f64 + order.map_or(0, |o| o.quantity) as f64;
406 let fill_rate = (available / total_demand).min(1.0);
407 total_fill_rate += fill_rate;
408 }
409
410 let avg_fill_rate = total_fill_rate / products.len() as f64;
411
412 if avg_fill_rate >= target_service_level {
414 avg_fill_rate
415 } else {
416 avg_fill_rate
417 }
418 }
419}
420
421#[derive(Debug, Clone)]
423struct ReplenishmentCandidate {
424 product: Product,
425 eoq: f64,
426 safety_stock: f64,
427 reorder_point: f64,
428 days_until_stockout: f64,
429 needs_order: bool,
430 recommended_quantity: i64,
431}
432
433impl PackSolver for EoqSolver {
434 fn id(&self) -> &'static str {
435 "eoq-v1"
436 }
437
438 fn solve(&self, spec: &ProblemSpec) -> Result<(serde_json::Value, SolverReport)> {
439 let input: InventoryReplenishmentInput = spec.inputs_as()?;
440 let (output, report) = self.solve_replenishment(&input, spec)?;
441 let json = serde_json::to_value(&output)
442 .map_err(|e| converge_pack::GateError::invalid_input(e.to_string()))?;
443 Ok((json, report))
444 }
445
446 fn is_exact(&self) -> bool {
447 false }
449}
450
451#[cfg(test)]
452mod tests {
453 use super::*;
454 use converge_pack::gate::ObjectiveSpec;
455
456 fn create_test_product(id: &str, inventory: i64, demand: f64) -> Product {
457 Product {
458 id: id.to_string(),
459 name: format!("Product {}", id),
460 current_inventory: inventory,
461 demand_forecast: DemandForecast {
462 average_daily: demand,
463 std_deviation: demand * 0.2,
464 forecast_days: 30,
465 },
466 lead_time_days: 7,
467 unit_cost: 10.0,
468 ordering_cost: 50.0,
469 holding_cost_per_day: 0.02,
470 stockout_cost: 25.0,
471 }
472 }
473
474 fn create_test_input() -> InventoryReplenishmentInput {
475 InventoryReplenishmentInput {
476 products: vec![
477 create_test_product("p1", 50, 10.0), create_test_product("p2", 200, 5.0), create_test_product("p3", 20, 15.0), ],
481 constraints: ReplenishmentConstraints {
482 budget: 10000.0,
483 target_service_level: 0.95,
484 planning_horizon_days: 30,
485 max_orders: None,
486 min_order_quantity: None,
487 },
488 }
489 }
490
491 fn create_spec(input: &InventoryReplenishmentInput, seed: u64) -> ProblemSpec {
492 ProblemSpec::builder("test", "tenant")
493 .objective(ObjectiveSpec::minimize("cost"))
494 .inputs(input)
495 .unwrap()
496 .seed(seed)
497 .build()
498 .unwrap()
499 }
500
501 #[test]
502 fn test_basic_replenishment() {
503 let solver = EoqSolver;
504 let input = create_test_input();
505 let spec = create_spec(&input, 42);
506
507 let (output, report) = solver.solve_replenishment(&input, &spec).unwrap();
508
509 assert!(!output.orders.is_empty());
510 assert!(report.feasible);
511 }
512
513 #[test]
514 fn test_prioritizes_urgent() {
515 let solver = EoqSolver;
516 let input = create_test_input();
517 let spec = create_spec(&input, 42);
518
519 let (output, _) = solver.solve_replenishment(&input, &spec).unwrap();
520
521 if !output.orders.is_empty() {
524 let first_order = &output.orders[0];
525 assert_eq!(first_order.product_id, "p3");
526 }
527 }
528
529 #[test]
530 fn test_respects_budget() {
531 let solver = EoqSolver;
532 let mut input = create_test_input();
533 input.constraints.budget = 500.0; let spec = create_spec(&input, 42);
536 let (output, _) = solver.solve_replenishment(&input, &spec).unwrap();
537
538 assert!(output.stats.total_order_cost <= 500.0);
539 }
540
541 #[test]
542 fn test_no_order_when_sufficient() {
543 let solver = EoqSolver;
544 let input = InventoryReplenishmentInput {
545 products: vec![create_test_product("p1", 1000, 5.0)], constraints: ReplenishmentConstraints::default(),
547 };
548
549 let spec = create_spec(&input, 42);
550 let (output, report) = solver.solve_replenishment(&input, &spec).unwrap();
551
552 assert!(output.orders.is_empty() || output.not_ordered.len() > 0);
555 assert!(report.feasible);
556 }
557
558 #[test]
559 fn test_max_orders_limit() {
560 let solver = EoqSolver;
561 let mut input = create_test_input();
562 input.constraints.max_orders = Some(1);
563
564 let spec = create_spec(&input, 42);
565 let (output, _) = solver.solve_replenishment(&input, &spec).unwrap();
566
567 assert!(output.orders.len() <= 1);
568 }
569
570 #[test]
571 fn test_determinism() {
572 let solver = EoqSolver;
573 let input = create_test_input();
574
575 let spec1 = create_spec(&input, 12345);
576 let spec2 = create_spec(&input, 12345);
577
578 let (output1, _) = solver.solve_replenishment(&input, &spec1).unwrap();
579 let (output2, _) = solver.solve_replenishment(&input, &spec2).unwrap();
580
581 assert_eq!(output1.orders.len(), output2.orders.len());
582 for (a, b) in output1.orders.iter().zip(output2.orders.iter()) {
583 assert_eq!(a.product_id, b.product_id);
584 assert_eq!(a.quantity, b.quantity);
585 }
586 }
587
588 #[test]
589 fn test_projections_generated() {
590 let solver = EoqSolver;
591 let input = create_test_input();
592 let spec = create_spec(&input, 42);
593
594 let (output, _) = solver.solve_replenishment(&input, &spec).unwrap();
595
596 assert!(!output.projections.is_empty());
597 let p1_projections: Vec<_> = output
599 .projections
600 .iter()
601 .filter(|p| p.product_id == "p1")
602 .collect();
603 assert!(!p1_projections.is_empty());
604 }
605
606 #[test]
607 fn test_service_level_calculation() {
608 let solver = EoqSolver;
609 let input = create_test_input();
610 let spec = create_spec(&input, 42);
611
612 let (output, _) = solver.solve_replenishment(&input, &spec).unwrap();
613
614 assert!(output.stats.projected_service_level >= 0.0);
615 assert!(output.stats.projected_service_level <= 1.0);
616 }
617}