Loading reports...
@@ -126,9 +126,14 @@
+
+
diff --git a/server/data/demand_forecasts.json b/server/data/demand_forecasts.json
index e1b388385..e64aa2186 100644
--- a/server/data/demand_forecasts.json
+++ b/server/data/demand_forecasts.json
@@ -6,7 +6,9 @@
"current_demand": 300,
"forecasted_demand": 450,
"trend": "increasing",
- "period": "Next 30 days"
+ "period": "Next 30 days",
+ "unit_cost": 12.5,
+ "lead_time_days": 7
},
{
"id": "2",
@@ -15,7 +17,9 @@
"current_demand": 150,
"forecasted_demand": 152,
"trend": "stable",
- "period": "Next 30 days"
+ "period": "Next 30 days",
+ "unit_cost": 34.75,
+ "lead_time_days": 10
},
{
"id": "3",
@@ -24,7 +28,9 @@
"current_demand": 500,
"forecasted_demand": 600,
"trend": "increasing",
- "period": "Next 30 days"
+ "period": "Next 30 days",
+ "unit_cost": 4.25,
+ "lead_time_days": 5
},
{
"id": "4",
@@ -33,7 +39,9 @@
"current_demand": 50,
"forecasted_demand": 35,
"trend": "decreasing",
- "period": "Next 30 days"
+ "period": "Next 30 days",
+ "unit_cost": 289.0,
+ "lead_time_days": 21
},
{
"id": "5",
@@ -42,7 +50,9 @@
"current_demand": 800,
"forecasted_demand": 950,
"trend": "increasing",
- "period": "Next 30 days"
+ "period": "Next 30 days",
+ "unit_cost": 6.9,
+ "lead_time_days": 4
},
{
"id": "6",
@@ -51,7 +61,9 @@
"current_demand": 120,
"forecasted_demand": 121,
"trend": "stable",
- "period": "Next 30 days"
+ "period": "Next 30 days",
+ "unit_cost": 47.2,
+ "lead_time_days": 12
},
{
"id": "7",
@@ -60,7 +72,9 @@
"current_demand": 250,
"forecasted_demand": 252,
"trend": "stable",
- "period": "Next 30 days"
+ "period": "Next 30 days",
+ "unit_cost": 18.4,
+ "lead_time_days": 14
},
{
"id": "8",
@@ -69,7 +83,9 @@
"current_demand": 180,
"forecasted_demand": 182,
"trend": "stable",
- "period": "Next 30 days"
+ "period": "Next 30 days",
+ "unit_cost": 9.15,
+ "lead_time_days": 6
},
{
"id": "9",
@@ -78,6 +94,8 @@
"current_demand": 95,
"forecasted_demand": 96,
"trend": "stable",
- "period": "Next 30 days"
+ "period": "Next 30 days",
+ "unit_cost": 62.0,
+ "lead_time_days": 18
}
]
diff --git a/server/data/restock_orders.json b/server/data/restock_orders.json
new file mode 100644
index 000000000..fe51488c7
--- /dev/null
+++ b/server/data/restock_orders.json
@@ -0,0 +1 @@
+[]
diff --git a/server/main.py b/server/main.py
index a0c2d8c5a..9c121eb68 100644
--- a/server/main.py
+++ b/server/main.py
@@ -2,7 +2,8 @@
from fastapi.middleware.cors import CORSMiddleware
from typing import List, Optional
from pydantic import BaseModel
-from mock_data import inventory_items, orders, demand_forecasts, backlog_items, spending_summary, monthly_spending, category_spending, recent_transactions, purchase_orders
+from datetime import datetime
+from mock_data import inventory_items, orders, demand_forecasts, backlog_items, spending_summary, monthly_spending, category_spending, recent_transactions, purchase_orders, restock_orders
app = FastAPI(title="Factory Inventory Management System")
@@ -120,6 +121,88 @@ class CreatePurchaseOrderRequest(BaseModel):
expected_delivery_date: str
notes: Optional[str] = None
+class RestockRecommendation(BaseModel):
+ item_sku: str
+ item_name: str
+ current_demand: int
+ forecasted_demand: int
+ gap: int
+ unit_cost: float
+ lead_time_days: int
+ allocated_budget: float
+ recommended_quantity: int
+ estimated_cost: float
+
+class RestockRecommendationsResponse(BaseModel):
+ budget: float
+ total_gap: int
+ total_allocated: float
+ remaining_budget: float
+ recommendations: List[RestockRecommendation]
+
+class RestockOrderItem(BaseModel):
+ item_sku: str
+ item_name: str
+ quantity: int
+ unit_cost: float
+ lead_time_days: int
+
+class RestockOrderRequest(BaseModel):
+ budget: float
+ items: List[RestockOrderItem]
+
+class RestockOrder(BaseModel):
+ id: str
+ order_number: str
+ items: List[RestockOrderItem]
+ total_cost: float
+ budget: float
+ status: str
+ order_date: str
+ max_lead_time_days: int
+
+def calculate_restock_recommendations(budget: float) -> RestockRecommendationsResponse:
+ """Proportionally allocate a budget across demand-forecast items based on their demand gap"""
+ gaps = []
+ for forecast in demand_forecasts:
+ gap = max(forecast["forecasted_demand"] - forecast["current_demand"], 0)
+ if gap > 0:
+ gaps.append((forecast, gap))
+
+ total_gap = sum(gap for _, gap in gaps)
+
+ recommendations = []
+ if total_gap > 0 and budget > 0:
+ for forecast, gap in gaps:
+ allocated_budget = budget * (gap / total_gap)
+ recommended_quantity = int(allocated_budget // forecast["unit_cost"])
+ if recommended_quantity <= 0:
+ continue
+ estimated_cost = recommended_quantity * forecast["unit_cost"]
+ recommendations.append(RestockRecommendation(
+ item_sku=forecast["item_sku"],
+ item_name=forecast["item_name"],
+ current_demand=forecast["current_demand"],
+ forecasted_demand=forecast["forecasted_demand"],
+ gap=gap,
+ unit_cost=forecast["unit_cost"],
+ lead_time_days=forecast["lead_time_days"],
+ allocated_budget=round(allocated_budget, 2),
+ recommended_quantity=recommended_quantity,
+ estimated_cost=round(estimated_cost, 2)
+ ))
+
+ recommendations.sort(key=lambda r: r.gap, reverse=True)
+ total_allocated = sum(r.estimated_cost for r in recommendations)
+
+ return RestockRecommendationsResponse(
+ budget=budget,
+ total_gap=total_gap,
+ total_allocated=round(total_allocated, 2),
+ remaining_budget=round(budget - total_allocated, 2),
+ recommendations=recommendations
+ )
+
# API endpoints
@app.get("/")
def root():
@@ -166,6 +249,37 @@ def get_demand_forecasts():
"""Get demand forecasts"""
return demand_forecasts
+@app.get("/api/restocking/recommendations", response_model=RestockRecommendationsResponse)
+def get_restocking_recommendations(budget: float):
+ """Get restock recommendations for a given budget, proportionally allocated by demand gap"""
+ if budget <= 0:
+ raise HTTPException(status_code=400, detail="budget must be greater than 0")
+ return calculate_restock_recommendations(budget)
+
+@app.post("/api/restocking/orders", response_model=RestockOrder)
+def create_restock_order(order_request: RestockOrderRequest):
+ """Submit a restock order built from recommended items"""
+ if not order_request.items:
+ raise HTTPException(status_code=400, detail="order must include at least one item")
+
+ order = RestockOrder(
+ id=str(len(restock_orders) + 1),
+ order_number=f"RSO-{len(restock_orders) + 1:04d}",
+ items=order_request.items,
+ total_cost=round(sum(item.quantity * item.unit_cost for item in order_request.items), 2),
+ budget=order_request.budget,
+ status="Submitted",
+ order_date=datetime.now().isoformat(),
+ max_lead_time_days=max(item.lead_time_days for item in order_request.items)
+ )
+ restock_orders.append(order.model_dump())
+ return order
+
+@app.get("/api/restocking/orders", response_model=List[RestockOrder])
+def get_restock_orders():
+ """Get all submitted restock orders"""
+ return restock_orders
+
@app.get("/api/backlog", response_model=List[BacklogItem])
def get_backlog():
"""Get backlog items with purchase order status"""
diff --git a/server/mock_data.py b/server/mock_data.py
index 2a9cd7dcb..43414996e 100644
--- a/server/mock_data.py
+++ b/server/mock_data.py
@@ -35,5 +35,8 @@ def load_json_file(filename):
# Load purchase orders
purchase_orders = load_json_file('purchase_orders.json')
+# Load restock orders (submitted via the Restocking tab)
+restock_orders = load_json_file('restock_orders.json')
+
# All data is now loaded from JSON files in the data/ directory
# This allows for easier maintenance and updates of the sample data
diff --git a/tests/backend/test_restocking.py b/tests/backend/test_restocking.py
new file mode 100644
index 000000000..3126f3c55
--- /dev/null
+++ b/tests/backend/test_restocking.py
@@ -0,0 +1,178 @@
+"""
+Tests for restocking API endpoints.
+"""
+import pytest
+
+
+class TestRestockingRecommendationsEndpoint:
+ """Test suite for GET /api/restocking/recommendations."""
+
+ def test_get_recommendations_basic(self, client):
+ """Test getting recommendations for a reasonable budget."""
+ response = client.get("/api/restocking/recommendations?budget=5000")
+ assert response.status_code == 200
+
+ data = response.json()
+ assert data["budget"] == 5000
+ assert isinstance(data["recommendations"], list)
+ assert data["total_gap"] > 0
+ assert len(data["recommendations"]) > 0
+
+ first = data["recommendations"][0]
+ assert "item_sku" in first
+ assert "item_name" in first
+ assert "gap" in first
+ assert "unit_cost" in first
+ assert "lead_time_days" in first
+ assert "recommended_quantity" in first
+ assert "estimated_cost" in first
+
+ def test_recommendations_exclude_zero_gap_items(self, client):
+ """Test that items with forecasted_demand <= current_demand are excluded."""
+ response = client.get("/api/restocking/recommendations?budget=5000")
+ data = response.json()
+
+ # MTR-304 has forecasted_demand (35) < current_demand (50), gap == 0
+ skus = [r["item_sku"] for r in data["recommendations"]]
+ assert "MTR-304" not in skus
+
+ def test_recommendations_proportional_to_gap(self, client):
+ """Test that items with larger demand gaps receive proportionally larger budget allocations."""
+ response = client.get("/api/restocking/recommendations?budget=5000")
+ data = response.json()
+
+ by_sku = {r["item_sku"]: r for r in data["recommendations"]}
+ # WDG-001 gap=150, PSU-501 gap=2 -- WDG-001 should get a much larger allocated_budget
+ assert by_sku["WDG-001"]["gap"] > by_sku["PSU-501"]["gap"]
+ assert by_sku["WDG-001"]["allocated_budget"] > by_sku["PSU-501"]["allocated_budget"]
+
+ def test_recommendations_quantity_within_allocated_budget(self, client):
+ """Test that estimated_cost never exceeds the item's allocated_budget."""
+ response = client.get("/api/restocking/recommendations?budget=5000")
+ data = response.json()
+
+ for rec in data["recommendations"]:
+ assert rec["estimated_cost"] <= rec["allocated_budget"] + 0.01
+ assert rec["recommended_quantity"] >= 1
+
+ def test_recommendations_total_allocated_within_budget(self, client):
+ """Test that total_allocated never exceeds the requested budget."""
+ response = client.get("/api/restocking/recommendations?budget=5000")
+ data = response.json()
+
+ assert data["total_allocated"] <= data["budget"] + 0.01
+ assert abs(data["remaining_budget"] - (data["budget"] - data["total_allocated"])) < 0.01
+
+ def test_recommendations_small_budget_may_exclude_expensive_items(self, client):
+ """Test that a very small budget yields fewer or no recommendations for expensive items."""
+ response = client.get("/api/restocking/recommendations?budget=1")
+ assert response.status_code == 200
+
+ data = response.json()
+ # A $1 budget split proportionally across many items can't afford a single unit of most items
+ for rec in data["recommendations"]:
+ assert rec["recommended_quantity"] >= 1
+
+ def test_recommendations_zero_budget_rejected(self, client):
+ """Test that a budget of 0 is rejected."""
+ response = client.get("/api/restocking/recommendations?budget=0")
+ assert response.status_code == 400
+ assert "detail" in response.json()
+
+ def test_recommendations_negative_budget_rejected(self, client):
+ """Test that a negative budget is rejected."""
+ response = client.get("/api/restocking/recommendations?budget=-100")
+ assert response.status_code == 400
+
+ def test_recommendations_missing_budget_rejected(self, client):
+ """Test that omitting the required budget param returns a validation error."""
+ response = client.get("/api/restocking/recommendations")
+ assert response.status_code == 422
+
+ def test_recommendations_sorted_by_gap_descending(self, client):
+ """Test that recommendations are sorted by gap, largest first."""
+ response = client.get("/api/restocking/recommendations?budget=5000")
+ data = response.json()
+
+ gaps = [r["gap"] for r in data["recommendations"]]
+ assert gaps == sorted(gaps, reverse=True)
+
+
+class TestRestockingOrdersEndpoint:
+ """Test suite for POST/GET /api/restocking/orders."""
+
+ def test_submit_order_success(self, client):
+ """Test submitting a restock order."""
+ payload = {
+ "budget": 1000,
+ "items": [
+ {
+ "item_sku": "WDG-001",
+ "item_name": "Industrial Widget Type A",
+ "quantity": 10,
+ "unit_cost": 12.5,
+ "lead_time_days": 7
+ },
+ {
+ "item_sku": "GSK-203",
+ "item_name": "High-Temperature Gasket",
+ "quantity": 20,
+ "unit_cost": 4.25,
+ "lead_time_days": 5
+ }
+ ]
+ }
+ response = client.post("/api/restocking/orders", json=payload)
+ assert response.status_code == 200
+
+ order = response.json()
+ assert order["status"] == "Submitted"
+ assert order["order_number"].startswith("RSO-")
+ assert order["budget"] == 1000
+ assert order["max_lead_time_days"] == 7
+ expected_total = 10 * 12.5 + 20 * 4.25
+ assert abs(order["total_cost"] - expected_total) < 0.01
+
+ def test_submit_order_empty_items_rejected(self, client):
+ """Test that an order with no items is rejected."""
+ response = client.post("/api/restocking/orders", json={"budget": 500, "items": []})
+ assert response.status_code == 400
+
+ def test_submitted_order_appears_in_get(self, client):
+ """Test that a submitted order is retrievable via GET /api/restocking/orders."""
+ payload = {
+ "budget": 300,
+ "items": [
+ {
+ "item_sku": "FLT-405",
+ "item_name": "Oil Filter Cartridge",
+ "quantity": 15,
+ "unit_cost": 6.9,
+ "lead_time_days": 4
+ }
+ ]
+ }
+ post_response = client.post("/api/restocking/orders", json=payload)
+ created_order = post_response.json()
+
+ get_response = client.get("/api/restocking/orders")
+ assert get_response.status_code == 200
+
+ orders = get_response.json()
+ assert isinstance(orders, list)
+ order_numbers = [o["order_number"] for o in orders]
+ assert created_order["order_number"] in order_numbers
+
+ def test_max_lead_time_days_is_max_of_items(self, client):
+ """Test that max_lead_time_days reflects the longest lead time among order items."""
+ payload = {
+ "budget": 500,
+ "items": [
+ {"item_sku": "GSK-203", "item_name": "High-Temperature Gasket", "quantity": 5, "unit_cost": 4.25, "lead_time_days": 5},
+ {"item_sku": "PSU-501", "item_name": "5V 10A Switching Power Supply", "quantity": 5, "unit_cost": 18.4, "lead_time_days": 14},
+ {"item_sku": "WDG-001", "item_name": "Industrial Widget Type A", "quantity": 5, "unit_cost": 12.5, "lead_time_days": 7}
+ ]
+ }
+ response = client.post("/api/restocking/orders", json=payload)
+ order = response.json()
+ assert order["max_lead_time_days"] == 14