diff --git a/CLAUDE.md b/CLAUDE.md
index 89c307d15..f411e8614 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -61,6 +61,7 @@ npm install && npm run dev
3. Update Pydantic models when changing JSON data structure
4. Inventory filters don't support month (no time dimension)
5. Revenue goals: $800K/month single, $9.6M YTD all months
+6. Always document non-obvious logic changes with comments
## File Locations
- Views: `client/src/views/*.vue`
diff --git a/client/src/App.vue b/client/src/App.vue
index c2da05a5c..76851676e 100644
--- a/client/src/App.vue
+++ b/client/src/App.vue
@@ -22,6 +22,9 @@
{{ t('nav.demandForecast') }}
+
+ {{ t('restocking.title') }}
+
Reports
diff --git a/client/src/api.js b/client/src/api.js
index 11cb9db70..61d2d30fa 100644
--- a/client/src/api.js
+++ b/client/src/api.js
@@ -38,6 +38,21 @@ export const api = {
return response.data
},
+ async getRestockRecommendations(budget) {
+ const response = await axios.get(`${API_BASE_URL}/restock/recommendations?budget=${budget}`)
+ return response.data
+ },
+
+ async getSubmittedRestockOrders() {
+ const response = await axios.get(`${API_BASE_URL}/restock-orders`)
+ return response.data
+ },
+
+ async createRestockOrder(restockOrderData) {
+ const response = await axios.post(`${API_BASE_URL}/restock-orders`, restockOrderData)
+ return response.data
+ },
+
async getBacklog() {
const response = await axios.get(`${API_BASE_URL}/backlog`)
return response.data
diff --git a/client/src/locales/en.js b/client/src/locales/en.js
index 03a58fe6e..b43accc40 100644
--- a/client/src/locales/en.js
+++ b/client/src/locales/en.js
@@ -125,7 +125,50 @@ export default {
totalValue: 'Total Value',
status: 'Status',
expectedDelivery: 'Expected Delivery',
- actualDelivery: 'Actual Delivery'
+ actualDelivery: 'Actual Delivery',
+ leadTime: 'Lead Time'
+ },
+ submittedOrders: 'Submitted Orders',
+ submittedEmpty: 'No restocking orders submitted yet. Build one from the Restocking tab.',
+ leadTimeDays: '{days} days',
+ units: '{count} units'
+ },
+
+ // Restocking
+ restocking: {
+ title: 'Restocking',
+ description: 'Set a budget and order the highest-priority items from the demand forecast',
+ budget: 'Available Budget',
+ budgetHint: 'Drag to set how much you can spend this cycle',
+ allocated: 'Allocated',
+ remaining: 'Remaining',
+ itemsRecommended: 'Items Recommended',
+ totalUnits: 'Total Units',
+ longestLeadTime: 'Longest Lead Time',
+ leadTimeDays: '{days} days',
+ recommended: 'Recommended Restock',
+ recommendedEmpty: 'Budget too small to fund any forecast item.',
+ partial: 'Partial',
+ full: 'Full',
+ unfunded: 'Not Funded By This Budget',
+ placeOrder: 'Place Order',
+ placing: 'Submitting...',
+ orderPlaced: 'Restocking order {orderNumber} submitted. View it in the Orders tab.',
+ orderFailed: 'Failed to submit restocking order',
+ table: {
+ sku: 'SKU',
+ item: 'Item',
+ category: 'Category',
+ trend: 'Trend',
+ onHand: 'On Hand',
+ forecast: 'Forecast',
+ shortfall: 'Shortfall',
+ orderQty: 'Order Qty',
+ unitCost: 'Unit Cost',
+ lineTotal: 'Line Total',
+ leadTime: 'Lead Time',
+ coverage: 'Coverage',
+ shortfallCost: 'Cost to Cover'
}
},
@@ -206,7 +249,8 @@ export default {
backordered: 'Backordered',
inStock: 'In Stock',
lowStock: 'Low Stock',
- adequate: 'Adequate'
+ adequate: 'Adequate',
+ submitted: 'Submitted'
},
// Trends
diff --git a/client/src/locales/ja.js b/client/src/locales/ja.js
index db33223ac..3f84658f5 100644
--- a/client/src/locales/ja.js
+++ b/client/src/locales/ja.js
@@ -125,7 +125,50 @@ export default {
totalValue: '合計金額',
status: 'ステータス',
expectedDelivery: '予定配達日',
- actualDelivery: '実際の配達日'
+ actualDelivery: '実際の配達日',
+ leadTime: 'リードタイム'
+ },
+ submittedOrders: '発注済み注文',
+ submittedEmpty: '補充注文はまだありません。補充タブから作成してください。',
+ leadTimeDays: '{days}日',
+ units: '{count}個'
+ },
+
+ // Restocking
+ restocking: {
+ title: '補充',
+ description: '予算を設定し、需要予測から優先度の高い品目を発注します',
+ budget: '利用可能予算',
+ budgetHint: 'スライダーで今サイクルの予算を設定',
+ allocated: '割当額',
+ remaining: '残額',
+ itemsRecommended: '推奨品目数',
+ totalUnits: '合計数量',
+ longestLeadTime: '最長リードタイム',
+ leadTimeDays: '{days}日',
+ recommended: '推奨補充',
+ recommendedEmpty: '予算が少なすぎるため、対象品目がありません。',
+ partial: '一部',
+ full: '全量',
+ unfunded: 'この予算では未手配',
+ placeOrder: '発注する',
+ placing: '送信中...',
+ orderPlaced: '補充注文 {orderNumber} を送信しました。注文タブで確認できます。',
+ orderFailed: '補充注文の送信に失敗しました',
+ table: {
+ sku: 'SKU',
+ item: '品目',
+ category: 'カテゴリ',
+ trend: '傾向',
+ onHand: '在庫数',
+ forecast: '予測',
+ shortfall: '不足数',
+ orderQty: '発注数',
+ unitCost: '単価',
+ lineTotal: '小計',
+ leadTime: 'リードタイム',
+ coverage: '充足',
+ shortfallCost: '充足必要額'
}
},
@@ -206,7 +249,8 @@ export default {
backordered: 'バックオーダー',
inStock: '在庫あり',
lowStock: '在庫僅少',
- adequate: '適量'
+ adequate: '適量',
+ submitted: '発注済み'
},
// Trends
diff --git a/client/src/main.js b/client/src/main.js
index 477c2d966..cea203940 100644
--- a/client/src/main.js
+++ b/client/src/main.js
@@ -5,6 +5,7 @@ import Dashboard from './views/Dashboard.vue'
import Inventory from './views/Inventory.vue'
import Orders from './views/Orders.vue'
import Demand from './views/Demand.vue'
+import Restocking from './views/Restocking.vue'
import Spending from './views/Spending.vue'
import Reports from './views/Reports.vue'
@@ -15,6 +16,7 @@ const router = createRouter({
{ path: '/inventory', component: Inventory },
{ path: '/orders', component: Orders },
{ path: '/demand', component: Demand },
+ { path: '/restocking', component: Restocking },
{ path: '/spending', component: Spending },
{ path: '/reports', component: Reports }
]
diff --git a/client/src/views/Orders.vue b/client/src/views/Orders.vue
index 7413f6e66..52f792110 100644
--- a/client/src/views/Orders.vue
+++ b/client/src/views/Orders.vue
@@ -74,6 +74,56 @@
+
+
+
+
+
+
+
+ | {{ t('orders.table.orderNumber') }} |
+ {{ t('orders.table.items') }} |
+ {{ t('orders.table.status') }} |
+ {{ t('orders.table.orderDate') }} |
+ {{ t('orders.table.expectedDelivery') }} |
+ {{ t('orders.table.leadTime') }} |
+ {{ t('orders.table.totalValue') }} |
+
+
+
+
+ | {{ order.order_number }} |
+
+
+
+ {{ t('orders.itemsCount', { count: order.items.length }) }}
+
+
+
+ {{ translateProductName(item.name) }}
+ {{ t('orders.quantity') }}: {{ item.quantity }} @ {{ currencySymbol }}{{ item.unit_price }}
+ {{ t('orders.leadTimeDays', { days: item.lead_time_days }) }}
+
+
+
+ |
+
+
+ {{ t(`status.${order.status.toLowerCase()}`) }}
+
+ |
+ {{ formatDate(order.order_date) }} |
+ {{ formatDate(order.expected_delivery) }} |
+ {{ t('orders.leadTimeDays', { days: order.lead_time_days }) }} |
+ {{ currencySymbol }}{{ order.total_value.toLocaleString() }} |
+
+
+
+
{{ t('orders.submittedEmpty') }}
+
+
@@ -95,6 +145,7 @@ export default {
const loading = ref(true)
const error = ref(null)
const orders = ref([])
+ const submittedOrders = ref([])
// Use shared filters
const {
@@ -124,6 +175,16 @@ export default {
}
}
+ // Restocking orders have no warehouse/category dimension, so they are not
+ // affected by the global filters and are not reloaded on filter changes.
+ const loadSubmittedOrders = async () => {
+ try {
+ submittedOrders.value = await api.getSubmittedRestockOrders()
+ } catch (err) {
+ error.value = 'Failed to load submitted orders: ' + err.message
+ }
+ }
+
// Watch for filter changes and reload data
watch([selectedPeriod, selectedLocation, selectedCategory, selectedStatus], () => {
loadOrders()
@@ -138,7 +199,8 @@ export default {
'Delivered': 'success',
'Shipped': 'info',
'Processing': 'warning',
- 'Backordered': 'danger'
+ 'Backordered': 'danger',
+ 'Submitted': 'info'
}
return statusMap[status] || 'info'
}
@@ -153,13 +215,17 @@ export default {
})
}
- onMounted(loadOrders)
+ onMounted(() => {
+ loadOrders()
+ loadSubmittedOrders()
+ })
return {
t,
loading,
error,
orders,
+ submittedOrders,
getOrdersByStatus,
getOrderStatusClass,
formatDate,
@@ -276,4 +342,14 @@ export default {
font-size: 0.813rem;
color: #64748b;
}
+
+.submitted-orders-card {
+ margin-top: 1.5rem;
+}
+
+.empty-state {
+ padding: 2rem;
+ text-align: center;
+ color: #64748b;
+}
diff --git a/client/src/views/Restocking.vue b/client/src/views/Restocking.vue
new file mode 100644
index 000000000..8cb8e0be7
--- /dev/null
+++ b/client/src/views/Restocking.vue
@@ -0,0 +1,352 @@
+
+
+
+
+
+
+
+
+
{{ formatMoney(budget) }}
+
{{ t('restocking.budgetHint') }}
+
+
+
+
{{ t('common.loading') }}
+
{{ error }}
+
+
{{ submitMessage }}
+
+
+
+
{{ t('restocking.allocated') }}
+
{{ formatMoney(recommendations.total_cost) }}
+
+
+
{{ t('restocking.remaining') }}
+
{{ formatMoney(recommendations.remaining_budget) }}
+
+
+
{{ t('restocking.itemsRecommended') }}
+
{{ recommendations.items.length }}
+
+
+
{{ t('restocking.totalUnits') }}
+
{{ recommendations.total_units.toLocaleString() }}
+
+
+
{{ t('restocking.longestLeadTime') }}
+
{{ t('restocking.leadTimeDays', { days: recommendations.max_lead_time_days }) }}
+
+
+
+
+
+
+
+
+
+ | {{ t('restocking.table.sku') }} |
+ {{ t('restocking.table.item') }} |
+ {{ t('restocking.table.category') }} |
+ {{ t('restocking.table.trend') }} |
+ {{ t('restocking.table.onHand') }} |
+ {{ t('restocking.table.forecast') }} |
+ {{ t('restocking.table.shortfall') }} |
+ {{ t('restocking.table.orderQty') }} |
+ {{ t('restocking.table.unitCost') }} |
+ {{ t('restocking.table.lineTotal') }} |
+ {{ t('restocking.table.leadTime') }} |
+ {{ t('restocking.table.coverage') }} |
+
+
+
+
+ | {{ item.item_sku }} |
+ {{ item.item_name }} |
+ {{ item.category }} |
+
+
+ {{ t(`trends.${item.trend}`) }}
+ |
+ {{ item.quantity_on_hand }} |
+ {{ item.forecasted_demand }} |
+ {{ item.shortfall }} |
+ {{ item.recommended_quantity }} |
+ {{ formatMoney(item.unit_cost) }} |
+ {{ formatMoney(item.line_total) }} |
+ {{ t('restocking.leadTimeDays', { days: item.lead_time_days }) }} |
+
+
+ {{ item.fully_funded ? t('restocking.full') : t('restocking.partial') }}
+
+ |
+
+
+
+
{{ t('restocking.recommendedEmpty') }}
+
+
+
+
+
+
+
+
+
+ | {{ t('restocking.table.sku') }} |
+ {{ t('restocking.table.item') }} |
+ {{ t('restocking.table.shortfall') }} |
+ {{ t('restocking.table.unitCost') }} |
+ {{ t('restocking.table.shortfallCost') }} |
+
+
+
+
+ | {{ item.item_sku }} |
+ {{ item.item_name }} |
+ {{ item.shortfall }} |
+ {{ formatMoney(item.unit_cost) }} |
+ {{ formatMoney(item.shortfall_cost) }} |
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/server/data/demand_forecasts.json b/server/data/demand_forecasts.json
index e1b388385..1f45fff51 100644
--- a/server/data/demand_forecasts.json
+++ b/server/data/demand_forecasts.json
@@ -3,6 +3,8 @@
"id": "1",
"item_sku": "WDG-001",
"item_name": "Industrial Widget Type A",
+ "category": "Components",
+ "unit_cost": 42.5,
"current_demand": 300,
"forecasted_demand": 450,
"trend": "increasing",
@@ -12,6 +14,8 @@
"id": "2",
"item_sku": "BRG-102",
"item_name": "Steel Bearing Assembly",
+ "category": "Mechanical",
+ "unit_cost": 128.75,
"current_demand": 150,
"forecasted_demand": 152,
"trend": "stable",
@@ -21,6 +25,8 @@
"id": "3",
"item_sku": "GSK-203",
"item_name": "High-Temperature Gasket",
+ "category": "Seals",
+ "unit_cost": 18.25,
"current_demand": 500,
"forecasted_demand": 600,
"trend": "increasing",
@@ -30,6 +36,8 @@
"id": "4",
"item_sku": "MTR-304",
"item_name": "Electric Motor 5HP",
+ "category": "Motors",
+ "unit_cost": 875.0,
"current_demand": 50,
"forecasted_demand": 35,
"trend": "decreasing",
@@ -39,6 +47,8 @@
"id": "5",
"item_sku": "FLT-405",
"item_name": "Oil Filter Cartridge",
+ "category": "Filtration",
+ "unit_cost": 24.99,
"current_demand": 800,
"forecasted_demand": 950,
"trend": "increasing",
@@ -48,6 +58,8 @@
"id": "6",
"item_sku": "VLV-506",
"item_name": "Pressure Relief Valve",
+ "category": "Valves",
+ "unit_cost": 310.0,
"current_demand": 120,
"forecasted_demand": 121,
"trend": "stable",
@@ -57,6 +69,8 @@
"id": "7",
"item_sku": "PSU-501",
"item_name": "5V 10A Switching Power Supply",
+ "category": "Power Supplies",
+ "unit_cost": 18.99,
"current_demand": 250,
"forecasted_demand": 252,
"trend": "stable",
@@ -66,6 +80,8 @@
"id": "8",
"item_sku": "SNR-420",
"item_name": "Temperature Sensor Module",
+ "category": "Sensors",
+ "unit_cost": 64.5,
"current_demand": 180,
"forecasted_demand": 182,
"trend": "stable",
@@ -75,6 +91,8 @@
"id": "9",
"item_sku": "CTL-330",
"item_name": "Logic Controller Board",
+ "category": "Controllers",
+ "unit_cost": 189.0,
"current_demand": 95,
"forecasted_demand": 96,
"trend": "stable",
diff --git a/server/main.py b/server/main.py
index a0c2d8c5a..5f0dbe6c4 100644
--- a/server/main.py
+++ b/server/main.py
@@ -1,11 +1,29 @@
-from fastapi import FastAPI, HTTPException
+from fastapi import FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
from typing import List, Optional
+from datetime import datetime, timedelta
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 mock_data import inventory_items, orders, demand_forecasts, backlog_items, spending_summary, monthly_spending, category_spending, recent_transactions, purchase_orders, submitted_restock_orders
app = FastAPI(title="Factory Inventory Management System")
+# Supplier delivery lead time in days, keyed by item category.
+# Fixed per category (not random) so the same recommendation always yields the same delivery date.
+CATEGORY_LEAD_TIME_DAYS = {
+ 'Seals': 7,
+ 'Filtration': 9,
+ 'Sensors': 10,
+ 'Components': 10,
+ 'Power Supplies': 12,
+ 'Circuit Boards': 14,
+ 'Controllers': 18,
+ 'Mechanical': 21,
+ 'Actuators': 21,
+ 'Valves': 24,
+ 'Motors': 28,
+}
+DEFAULT_LEAD_TIME_DAYS = 14
+
# Quarter mapping for date filtering
QUARTER_MAP = {
'Q1-2025': ['2025-01', '2025-02', '2025-03'],
@@ -89,6 +107,8 @@ class DemandForecast(BaseModel):
forecasted_demand: int
trend: str
period: str
+ category: Optional[str] = None
+ unit_cost: Optional[float] = None
class BacklogItem(BaseModel):
id: str
@@ -112,6 +132,45 @@ class PurchaseOrder(BaseModel):
created_date: str
notes: Optional[str] = None
+class RestockRecommendationItem(BaseModel):
+ item_sku: str
+ item_name: str
+ category: str
+ trend: str
+ forecasted_demand: int
+ quantity_on_hand: int
+ shortfall: int
+ recommended_quantity: int
+ unit_cost: float
+ line_total: float
+ lead_time_days: int
+ fully_funded: bool
+
+class RestockRecommendation(BaseModel):
+ budget: float
+ items: List[RestockRecommendationItem]
+ total_cost: float
+ remaining_budget: float
+ total_units: int
+ max_lead_time_days: int
+ unfunded_items: List[dict]
+
+class CreateRestockOrderRequest(BaseModel):
+ budget: float
+ items: List[RestockRecommendationItem]
+
+class SubmittedRestockOrder(BaseModel):
+ id: str
+ order_number: str
+ status: str
+ budget: float
+ order_date: str
+ expected_delivery: str
+ lead_time_days: int
+ total_value: float
+ total_units: int
+ items: List[dict]
+
class CreatePurchaseOrderRequest(BaseModel):
backlog_item_id: str
supplier_name: str
@@ -179,6 +238,132 @@ def get_backlog():
result.append(item_dict)
return result
+def _lead_time_for(category: Optional[str]) -> int:
+ """Supplier lead time in days for a category, falling back to the default."""
+ return CATEGORY_LEAD_TIME_DAYS.get(category or '', DEFAULT_LEAD_TIME_DAYS)
+
+def _on_hand_for(sku: str) -> int:
+ """Total quantity on hand for a SKU across all warehouses (0 if the SKU is not stocked)."""
+ return sum(item['quantity_on_hand'] for item in inventory_items if item['sku'] == sku)
+
+@app.get("/api/restock/recommendations", response_model=RestockRecommendation)
+def get_restock_recommendations(budget: float = Query(0, ge=0)):
+ """Recommend restock quantities from the demand forecast that fit within a budget.
+
+ Gap-priority greedy allocation: rank items by shortfall (forecasted demand minus quantity on
+ hand), then buy the full shortfall for each item top-down until the budget runs out. The first
+ item that cannot be fully funded gets a partial quantity for whatever budget remains.
+ """
+ candidates = []
+ for forecast in demand_forecasts:
+ unit_cost = forecast.get('unit_cost') or 0
+ if unit_cost <= 0:
+ continue
+
+ on_hand = _on_hand_for(forecast['item_sku'])
+ shortfall = forecast['forecasted_demand'] - on_hand
+ if shortfall <= 0:
+ continue
+
+ candidates.append({
+ 'item_sku': forecast['item_sku'],
+ 'item_name': forecast['item_name'],
+ 'category': forecast.get('category') or 'Uncategorized',
+ 'trend': forecast['trend'],
+ 'forecasted_demand': forecast['forecasted_demand'],
+ 'quantity_on_hand': on_hand,
+ 'shortfall': shortfall,
+ 'unit_cost': unit_cost,
+ })
+
+ # Biggest shortfall first; increasing-trend items win ties since their gap is still widening
+ candidates.sort(key=lambda c: (-c['shortfall'], c['trend'] != 'increasing', c['item_sku']))
+
+ remaining = budget
+ recommended = []
+ unfunded = []
+
+ for candidate in candidates:
+ affordable_qty = int(remaining // candidate['unit_cost'])
+ quantity = min(candidate['shortfall'], affordable_qty)
+
+ if quantity <= 0:
+ unfunded.append({
+ 'item_sku': candidate['item_sku'],
+ 'item_name': candidate['item_name'],
+ 'shortfall': candidate['shortfall'],
+ 'unit_cost': candidate['unit_cost'],
+ 'shortfall_cost': round(candidate['shortfall'] * candidate['unit_cost'], 2),
+ })
+ continue
+
+ line_total = round(quantity * candidate['unit_cost'], 2)
+ remaining -= line_total
+ recommended.append({
+ **candidate,
+ 'recommended_quantity': quantity,
+ 'line_total': line_total,
+ 'lead_time_days': _lead_time_for(candidate['category']),
+ 'fully_funded': quantity == candidate['shortfall'],
+ })
+
+ total_cost = round(sum(item['line_total'] for item in recommended), 2)
+
+ return {
+ 'budget': budget,
+ 'items': recommended,
+ 'total_cost': total_cost,
+ 'remaining_budget': round(budget - total_cost, 2),
+ 'total_units': sum(item['recommended_quantity'] for item in recommended),
+ # Order-level lead time is the slowest line: the order is not complete until every item lands
+ 'max_lead_time_days': max((item['lead_time_days'] for item in recommended), default=0),
+ 'unfunded_items': unfunded,
+ }
+
+@app.get("/api/restock-orders", response_model=List[SubmittedRestockOrder])
+def get_submitted_restock_orders():
+ """Get all submitted restocking orders, newest first"""
+ return list(reversed(submitted_restock_orders))
+
+@app.post("/api/restock-orders", response_model=SubmittedRestockOrder, status_code=201)
+def create_restock_order(request: CreateRestockOrderRequest):
+ """Submit a restocking order built from budget-based recommendations"""
+ if not request.items:
+ raise HTTPException(status_code=400, detail="Cannot submit a restocking order with no items")
+
+ items = []
+ for item in request.items:
+ lead_time = _lead_time_for(item.category)
+ items.append({
+ 'sku': item.item_sku,
+ 'name': item.item_name,
+ 'category': item.category,
+ 'quantity': item.recommended_quantity,
+ 'unit_price': item.unit_cost,
+ 'line_total': round(item.recommended_quantity * item.unit_cost, 2),
+ 'lead_time_days': lead_time,
+ 'expected_delivery': (datetime.now() + timedelta(days=lead_time)).isoformat(timespec='seconds'),
+ })
+
+ order_lead_time = max(item['lead_time_days'] for item in items)
+ now = datetime.now()
+
+ order = {
+ 'id': str(len(submitted_restock_orders) + 1),
+ 'order_number': f"RST-{now.year}-{len(submitted_restock_orders) + 1:04d}",
+ 'status': 'Submitted',
+ 'budget': request.budget,
+ 'order_date': now.isoformat(timespec='seconds'),
+ 'expected_delivery': (now + timedelta(days=order_lead_time)).isoformat(timespec='seconds'),
+ 'lead_time_days': order_lead_time,
+ 'total_value': round(sum(item['line_total'] for item in items), 2),
+ 'total_units': sum(item['quantity'] for item in items),
+ 'items': items,
+ }
+
+ submitted_restock_orders.append(order)
+ return order
+
@app.get("/api/dashboard/summary")
def get_dashboard_summary(
warehouse: Optional[str] = None,
diff --git a/server/mock_data.py b/server/mock_data.py
index 2a9cd7dcb..62886b9f5 100644
--- a/server/mock_data.py
+++ b/server/mock_data.py
@@ -35,5 +35,10 @@ def load_json_file(filename):
# Load purchase orders
purchase_orders = load_json_file('purchase_orders.json')
+# Submitted restocking orders (created at runtime by the Restocking tab).
+# Intentionally not backed by a JSON file: this is in-memory only and resets on server restart,
+# matching the demo's no-database design.
+submitted_restock_orders = []
+
# 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..b2c2fa4a2
--- /dev/null
+++ b/tests/backend/test_restocking.py
@@ -0,0 +1,217 @@
+"""
+Tests for restocking API endpoints (budget recommendations and submitted restock orders).
+"""
+import pytest
+
+import mock_data
+
+
+@pytest.fixture(autouse=True)
+def clear_submitted_orders():
+ """Reset the in-memory submitted restock orders so each test starts from a clean slate."""
+ mock_data.submitted_restock_orders.clear()
+ yield
+ mock_data.submitted_restock_orders.clear()
+
+
+class TestRestockRecommendationEndpoint:
+ """Test suite for GET /api/restock/recommendations."""
+
+ def test_get_recommendations_structure(self, client):
+ """Test that a recommendation response has the expected shape."""
+ response = client.get("/api/restock/recommendations?budget=100000")
+ assert response.status_code == 200
+
+ data = response.json()
+ assert data["budget"] == 100000
+ assert isinstance(data["items"], list)
+ assert isinstance(data["unfunded_items"], list)
+ assert len(data["items"]) > 0
+
+ first_item = data["items"][0]
+ for field in [
+ "item_sku", "item_name", "category", "trend", "forecasted_demand",
+ "quantity_on_hand", "shortfall", "recommended_quantity", "unit_cost",
+ "line_total", "lead_time_days", "fully_funded"
+ ]:
+ assert field in first_item
+
+ def test_recommendations_respect_budget(self, client):
+ """Test that the allocated total never exceeds the requested budget."""
+ for budget in [0, 5000, 25000, 100000, 500000]:
+ response = client.get(f"/api/restock/recommendations?budget={budget}")
+ assert response.status_code == 200
+
+ data = response.json()
+ assert data["total_cost"] <= budget + 0.01
+ assert abs(data["remaining_budget"] - (budget - data["total_cost"])) < 0.01
+
+ def test_zero_budget_recommends_nothing(self, client):
+ """Test that a zero budget funds no items but still reports the shortfalls."""
+ response = client.get("/api/restock/recommendations?budget=0")
+ assert response.status_code == 200
+
+ data = response.json()
+ assert data["items"] == []
+ assert data["total_cost"] == 0
+ assert data["total_units"] == 0
+ assert data["max_lead_time_days"] == 0
+ assert len(data["unfunded_items"]) > 0
+
+ def test_negative_budget_rejected(self, client):
+ """Test that a negative budget fails validation."""
+ response = client.get("/api/restock/recommendations?budget=-100")
+ assert response.status_code == 422
+
+ def test_recommendations_sorted_by_shortfall(self, client):
+ """Test that items are ranked by shortfall, largest gap first."""
+ response = client.get("/api/restock/recommendations?budget=500000")
+ data = response.json()
+
+ shortfalls = [item["shortfall"] for item in data["items"]]
+ assert shortfalls == sorted(shortfalls, reverse=True)
+
+ def test_line_totals_and_aggregates(self, client):
+ """Test that line totals and response aggregates are internally consistent."""
+ response = client.get("/api/restock/recommendations?budget=100000")
+ data = response.json()
+
+ for item in data["items"]:
+ expected = item["recommended_quantity"] * item["unit_cost"]
+ assert abs(item["line_total"] - expected) < 0.01
+ assert 0 < item["recommended_quantity"] <= item["shortfall"]
+ assert item["fully_funded"] == (item["recommended_quantity"] == item["shortfall"])
+ assert item["lead_time_days"] > 0
+
+ assert abs(data["total_cost"] - sum(i["line_total"] for i in data["items"])) < 0.01
+ assert data["total_units"] == sum(i["recommended_quantity"] for i in data["items"])
+ assert data["max_lead_time_days"] == max(i["lead_time_days"] for i in data["items"])
+
+ def test_only_items_with_a_shortfall_are_recommended(self, client):
+ """Test that items already stocked above forecast demand are excluded."""
+ response = client.get("/api/restock/recommendations?budget=500000")
+ data = response.json()
+
+ recommended_and_unfunded = (
+ [i["item_sku"] for i in data["items"]] +
+ [i["item_sku"] for i in data["unfunded_items"]]
+ )
+
+ inventory = client.get("/api/inventory").json()
+ for forecast in client.get("/api/demand").json():
+ on_hand = sum(
+ item["quantity_on_hand"] for item in inventory
+ if item["sku"] == forecast["item_sku"]
+ )
+ if forecast["forecasted_demand"] - on_hand <= 0:
+ assert forecast["item_sku"] not in recommended_and_unfunded
+
+ def test_larger_budget_never_funds_less(self, client):
+ """Test that raising the budget never reduces the units ordered."""
+ small = client.get("/api/restock/recommendations?budget=25000").json()
+ large = client.get("/api/restock/recommendations?budget=250000").json()
+
+ assert large["total_units"] >= small["total_units"]
+ assert large["total_cost"] >= small["total_cost"]
+
+ def test_demand_forecast_exposes_cost_and_category(self, client):
+ """Test that the demand endpoint carries the fields the recommender relies on."""
+ response = client.get("/api/demand")
+ assert response.status_code == 200
+
+ for forecast in response.json():
+ assert isinstance(forecast["unit_cost"], (int, float))
+ assert forecast["unit_cost"] > 0
+ assert isinstance(forecast["category"], str)
+ assert forecast["category"]
+
+
+class TestSubmittedRestockOrderEndpoints:
+ """Test suite for GET/POST /api/restock-orders."""
+
+ @staticmethod
+ def _submit(client, budget=100000):
+ """Build a recommendation for a budget and submit it as an order."""
+ recommendation = client.get(f"/api/restock/recommendations?budget={budget}").json()
+ return client.post("/api/restock-orders", json={
+ "budget": recommendation["budget"],
+ "items": recommendation["items"]
+ })
+
+ def test_no_orders_initially(self, client):
+ """Test that no restock orders exist before any are submitted."""
+ response = client.get("/api/restock-orders")
+ assert response.status_code == 200
+ assert response.json() == []
+
+ def test_create_restock_order(self, client):
+ """Test submitting a restocking order built from recommendations."""
+ response = self._submit(client)
+ assert response.status_code == 201
+
+ order = response.json()
+ assert order["order_number"].startswith("RST-")
+ assert order["status"] == "Submitted"
+ assert order["budget"] == 100000
+ assert order["total_value"] <= 100000 + 0.01
+ assert order["lead_time_days"] > 0
+ assert "T" in order["order_date"]
+ assert "T" in order["expected_delivery"]
+ assert order["expected_delivery"] > order["order_date"]
+
+ def test_created_order_items_structure(self, client):
+ """Test that submitted order items use the same shape as regular order items."""
+ order = self._submit(client).json()
+ assert len(order["items"]) > 0
+
+ for item in order["items"]:
+ for field in ["sku", "name", "category", "quantity", "unit_price",
+ "line_total", "lead_time_days", "expected_delivery"]:
+ assert field in item
+ assert item["quantity"] > 0
+ assert abs(item["line_total"] - item["quantity"] * item["unit_price"]) < 0.01
+
+ assert order["total_units"] == sum(i["quantity"] for i in order["items"])
+ assert abs(order["total_value"] - sum(i["line_total"] for i in order["items"])) < 0.01
+
+ def test_order_lead_time_is_slowest_item(self, client):
+ """Test that the order-level lead time is the slowest line item's lead time."""
+ order = self._submit(client).json()
+ assert order["lead_time_days"] == max(i["lead_time_days"] for i in order["items"])
+
+ def test_submitted_orders_returned_newest_first(self, client):
+ """Test that the list endpoint returns the most recent order first."""
+ first = self._submit(client, budget=50000).json()
+ second = self._submit(client, budget=100000).json()
+
+ response = client.get("/api/restock-orders")
+ assert response.status_code == 200
+
+ orders = response.json()
+ assert [o["order_number"] for o in orders] == [second["order_number"], first["order_number"]]
+
+ def test_order_numbers_increment(self, client):
+ """Test that order numbers are sequential per submission."""
+ first = self._submit(client).json()
+ second = self._submit(client).json()
+
+ assert first["order_number"] != second["order_number"]
+ assert first["order_number"].endswith("0001")
+ assert second["order_number"].endswith("0002")
+
+ def test_create_order_with_no_items_rejected(self, client):
+ """Test that submitting an empty restocking order returns 400."""
+ response = client.post("/api/restock-orders", json={"budget": 1000, "items": []})
+ assert response.status_code == 400
+
+ data = response.json()
+ assert "detail" in data
+ assert "no items" in data["detail"].lower()
+
+ def test_create_order_with_malformed_item_rejected(self, client):
+ """Test that an item missing required fields fails validation."""
+ response = client.post("/api/restock-orders", json={
+ "budget": 1000,
+ "items": [{"item_sku": "WDG-001"}]
+ })
+ assert response.status_code == 422