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.submittedOrders') }} ({{ submittedOrders.length }})

+
+
+ + + + + + + + + + + + + + + + + + + + + + + +
{{ 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 @@ + + + + + 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