diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 000000000..3d3795ff8 --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,23 @@ +name: Claude Code +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] +jobs: + claude: + if: contains(github.event.comment.body, '@claude') + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + issues: write + id-token: write + actions: read + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 1 + - uses: anthropics/claude-code-action@v1 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} diff --git a/client/src/App.vue b/client/src/App.vue index c2da05a5c..dd6849b1c 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -22,6 +22,9 @@ {{ t('nav.demandForecast') }} + + {{ t('nav.restocking') }} + Reports diff --git a/client/src/api.js b/client/src/api.js index 11cb9db70..5f98b7c10 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -94,6 +94,16 @@ export const api = { return response.data }, + async getRestockOrders() { + 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 createPurchaseOrder(purchaseOrderData) { const response = await axios.post(`${API_BASE_URL}/purchase-orders`, purchaseOrderData) return response.data diff --git a/client/src/locales/en.js b/client/src/locales/en.js index 03a58fe6e..4a411306e 100644 --- a/client/src/locales/en.js +++ b/client/src/locales/en.js @@ -6,6 +6,7 @@ export default { orders: 'Orders', finance: 'Finance', demandForecast: 'Demand Forecast', + restocking: 'Restocking', companyName: 'Catalyst Components', subtitle: 'Inventory Management System' }, @@ -112,6 +113,18 @@ export default { onTimeDelivery: 'On-Time Delivery', itemsCount: '{count} items', quantity: 'Qty', + submittedOrders: 'Submitted Orders', + submittedEmpty: 'No restocking orders submitted yet. Create one from the Restocking tab.', + submittedTable: { + orderNumber: 'Order Number', + items: 'Items', + submitted: 'Submitted', + leadTime: 'Lead Time', + expectedDelivery: 'Expected Delivery', + totalValue: 'Total Value', + status: 'Status' + }, + leadTimeDays: '{count} days', table: { orderNumber: 'Order Number', orderId: 'Order ID', @@ -189,6 +202,43 @@ export default { }, // Filters + restocking: { + title: 'Restocking', + description: 'Set a budget and restock the items with the largest forecast shortfall', + budget: 'Available Budget', + budgetHint: 'Drag to set how much you can spend this cycle', + fullCoverage: 'Full coverage', + recommended: 'Recommended Restock', + recommendedCount: '{count} of {total} items recommended', + allocated: 'Allocated', + remaining: 'Remaining', + itemsSelected: 'Items', + longestLeadTime: 'Longest Lead Time', + placeOrder: 'Place Order', + submitting: 'Submitting...', + orderPlaced: 'Order {orderNumber} submitted. Expected delivery {date}.', + viewInOrders: 'View in Orders', + nothingAffordable: 'This budget is too small to cover a full unit of any shortfall item. Increase the budget to see recommendations.', + noShortfall: 'No item is forecast to exceed its current demand, so there is nothing to restock.', + partial: 'Partial', + partialHint: 'Budget covers {quantity} of {needed} units', + fullHint: 'Covers the full shortfall', + excluded: 'Not recommended', + excludedNoShortfall: 'Forecast is at or below current demand', + excludedNoBudget: 'Budget exhausted before this item', + days: '{count} days', + table: { + sku: 'SKU', + itemName: 'Item Name', + shortfall: 'Shortfall', + unitCost: 'Unit Cost', + quantity: 'Restock Qty', + lineTotal: 'Line Total', + leadTime: 'Lead Time', + coverage: 'Coverage' + } + }, + filters: { timePeriod: 'Time Period', location: 'Location', diff --git a/client/src/locales/ja.js b/client/src/locales/ja.js index db33223ac..3f80d2fb8 100644 --- a/client/src/locales/ja.js +++ b/client/src/locales/ja.js @@ -6,6 +6,7 @@ export default { orders: '注文', finance: '財務', demandForecast: '需要予測', + restocking: '補充発注', companyName: '触媒コンポーネンツ', subtitle: '在庫管理システム' }, @@ -112,6 +113,18 @@ export default { onTimeDelivery: '定時配達', itemsCount: '{count}件', quantity: '数量', + submittedOrders: '送信済み発注', + submittedEmpty: '送信済みの補充発注はまだありません。補充発注タブから作成してください。', + submittedTable: { + orderNumber: '発注番号', + items: '品目', + submitted: '送信日', + leadTime: 'リードタイム', + expectedDelivery: '納品予定日', + totalValue: '合計金額', + status: 'ステータス' + }, + leadTimeDays: '{count}日', table: { orderNumber: '注文番号', orderId: '注文ID', @@ -189,6 +202,43 @@ export default { }, // Filters + restocking: { + title: '補充発注', + description: '予算を設定し、予測不足量が最も大きい品目を補充します', + budget: '利用可能予算', + budgetHint: 'このサイクルで使用できる金額をドラッグして設定します', + fullCoverage: '全量充当', + recommended: '推奨補充リスト', + recommendedCount: '{total}件中{count}件を推奨', + allocated: '割当額', + remaining: '残額', + itemsSelected: '品目数', + longestLeadTime: '最長リードタイム', + placeOrder: '発注する', + submitting: '送信中...', + orderPlaced: '発注 {orderNumber} を送信しました。納品予定日は {date} です。', + viewInOrders: '注文タブで確認', + nothingAffordable: 'この予算では不足品目を1単位も充当できません。予算を増やしてください。', + noShortfall: '現在の需要を上回る予測の品目がないため、補充対象はありません。', + partial: '一部', + partialHint: '予算で{needed}単位中{quantity}単位を充当', + fullHint: '不足量を全量充当', + excluded: '推奨対象外', + excludedNoShortfall: '予測が現在の需要以下です', + excludedNoBudget: 'この品目の前に予算を使い切りました', + days: '{count}日', + table: { + sku: 'SKU', + itemName: '品目名', + shortfall: '不足量', + unitCost: '単価', + quantity: '補充数量', + lineTotal: '小計', + leadTime: 'リードタイム', + coverage: '充当状況' + } + }, + filters: { timePeriod: '期間', location: '場所', diff --git a/client/src/main.js b/client/src/main.js index 477c2d966..8884eea63 100644 --- a/client/src/main.js +++ b/client/src/main.js @@ -7,6 +7,7 @@ import Orders from './views/Orders.vue' import Demand from './views/Demand.vue' import Spending from './views/Spending.vue' import Reports from './views/Reports.vue' +import Restocking from './views/Restocking.vue' const router = createRouter({ history: createWebHistory(), @@ -16,7 +17,8 @@ const router = createRouter({ { path: '/orders', component: Orders }, { path: '/demand', component: Demand }, { path: '/spending', component: Spending }, - { path: '/reports', component: Reports } + { path: '/reports', component: Reports }, + { path: '/restocking', component: Restocking } ] }) diff --git a/client/src/views/Orders.vue b/client/src/views/Orders.vue index 7413f6e66..656e9ac1f 100644 --- a/client/src/views/Orders.vue +++ b/client/src/views/Orders.vue @@ -27,6 +27,58 @@ +
+
+

{{ t('orders.submittedOrders') }} ({{ restockOrders.length }})

+
+
{{ restockOrdersError }}
+
+ {{ t('orders.submittedEmpty') }} +
+
+ + + + + + + + + + + + + + + + + + + + + + + +
{{ t('orders.submittedTable.orderNumber') }}{{ t('orders.submittedTable.items') }}{{ t('orders.submittedTable.submitted') }}{{ t('orders.submittedTable.leadTime') }}{{ t('orders.submittedTable.expectedDelivery') }}{{ t('orders.submittedTable.totalValue') }}{{ t('orders.submittedTable.status') }}
{{ restockOrder.order_number }} +
+ + {{ t('orders.itemsCount', { count: restockOrder.items.length }) }} + +
+
+ {{ translateProductName(item.item_name) }} + {{ t('orders.quantity') }}: {{ item.quantity }} @ {{ currencySymbol }}{{ item.unit_cost }} +
+
+
+
{{ formatDate(restockOrder.submitted_date) }}{{ t('orders.leadTimeDays', { count: restockOrder.lead_time_days }) }}{{ formatDate(restockOrder.expected_delivery) }}{{ currencySymbol }}{{ restockOrder.total_value.toLocaleString() }} + + {{ restockOrder.status }} + +
+
+
+

{{ t('orders.allOrders') }} ({{ orders.length }})

@@ -96,6 +148,9 @@ export default { const error = ref(null) const orders = ref([]) + const restockOrders = ref([]) + const restockOrdersError = ref(null) + // Use shared filters const { selectedPeriod, @@ -125,10 +180,21 @@ export default { } // Watch for filter changes and reload data + // Restock orders carry no warehouse/category/status/date dimension, + // so they must NOT be reloaded when the global filters change. watch([selectedPeriod, selectedLocation, selectedCategory, selectedStatus], () => { loadOrders() }) + const loadRestockOrders = async () => { + try { + restockOrdersError.value = null + restockOrders.value = await api.getRestockOrders() + } catch (err) { + restockOrdersError.value = 'Failed to load submitted orders: ' + err.message + } + } + const getOrdersByStatus = (status) => { return orders.value.filter(order => order.status === status) } @@ -138,7 +204,8 @@ export default { 'Delivered': 'success', 'Shipped': 'info', 'Processing': 'warning', - 'Backordered': 'danger' + 'Backordered': 'danger', + 'Submitted': 'info' } return statusMap[status] || 'info' } @@ -153,13 +220,18 @@ export default { }) } - onMounted(loadOrders) + onMounted(() => { + loadOrders() + loadRestockOrders() + }) return { t, loading, error, orders, + restockOrders, + restockOrdersError, getOrdersByStatus, getOrderStatusClass, formatDate, @@ -276,4 +348,15 @@ export default { font-size: 0.813rem; color: #64748b; } + +.empty-state { + padding: 1.5rem; + text-align: center; + color: #64748b; + font-size: 0.938rem; +} + +.restock-orders-table { + width: 100%; +} diff --git a/client/src/views/Restocking.vue b/client/src/views/Restocking.vue new file mode 100644 index 000000000..8a5ecabe6 --- /dev/null +++ b/client/src/views/Restocking.vue @@ -0,0 +1,489 @@ + + + + + diff --git a/server/data/demand_forecasts.json b/server/data/demand_forecasts.json index e1b388385..a0887f135 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": 24.5, + "lead_time_days": 14 }, { "id": "2", @@ -15,7 +17,9 @@ "current_demand": 150, "forecasted_demand": 152, "trend": "stable", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 42.0, + "lead_time_days": 21 }, { "id": "3", @@ -24,7 +28,9 @@ "current_demand": 500, "forecasted_demand": 600, "trend": "increasing", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 8.75, + "lead_time_days": 10 }, { "id": "4", @@ -33,7 +39,9 @@ "current_demand": 50, "forecasted_demand": 35, "trend": "decreasing", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 725.0, + "lead_time_days": 35 }, { "id": "5", @@ -42,7 +50,9 @@ "current_demand": 800, "forecasted_demand": 950, "trend": "increasing", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 12.25, + "lead_time_days": 7 }, { "id": "6", @@ -51,7 +61,9 @@ "current_demand": 120, "forecasted_demand": 121, "trend": "stable", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 96.0, + "lead_time_days": 28 }, { "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.99, + "lead_time_days": 12 }, { "id": "8", @@ -69,7 +83,9 @@ "current_demand": 180, "forecasted_demand": 182, "trend": "stable", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 34.5, + "lead_time_days": 18 }, { "id": "9", @@ -78,6 +94,8 @@ "current_demand": 95, "forecasted_demand": 96, "trend": "stable", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 158.0, + "lead_time_days": 24 } ] diff --git a/server/main.py b/server/main.py index a0c2d8c5a..148f453e2 100644 --- a/server/main.py +++ b/server/main.py @@ -2,6 +2,7 @@ from fastapi.middleware.cors import CORSMiddleware from typing import List, Optional from pydantic import BaseModel +from datetime import datetime, timedelta from mock_data import inventory_items, orders, demand_forecasts, backlog_items, spending_summary, monthly_spending, category_spending, recent_transactions, purchase_orders app = FastAPI(title="Factory Inventory Management System") @@ -89,6 +90,8 @@ class DemandForecast(BaseModel): forecasted_demand: int trend: str period: str + unit_cost: float + lead_time_days: int class BacklogItem(BaseModel): id: str @@ -112,6 +115,28 @@ class PurchaseOrder(BaseModel): created_date: str notes: Optional[str] = None +class RestockOrderItem(BaseModel): + item_sku: str + item_name: str + quantity: int + unit_cost: float + lead_time_days: int + +class RestockOrder(BaseModel): + id: str + order_number: str + items: List[RestockOrderItem] + total_value: float + budget: float + status: str + submitted_date: str + expected_delivery: str + lead_time_days: int + +class CreateRestockOrderRequest(BaseModel): + items: List[RestockOrderItem] + budget: float + class CreatePurchaseOrderRequest(BaseModel): backlog_item_id: str supplier_name: str @@ -120,6 +145,10 @@ class CreatePurchaseOrderRequest(BaseModel): expected_delivery_date: str notes: Optional[str] = None +# Submitted restocking orders. Process-scoped, like every other dataset here: +# these are held in memory and are gone when the backend restarts. +restock_orders: List[dict] = [] + # API endpoints @app.get("/") def root(): @@ -304,6 +333,49 @@ def get_monthly_trends(): result.sort(key=lambda x: x['month']) return result +@app.get("/api/restock-orders", response_model=List[RestockOrder]) +def get_restock_orders(): + """Get all submitted restocking orders, newest first""" + return list(reversed(restock_orders)) + +@app.post("/api/restock-orders", response_model=RestockOrder, status_code=201) +def create_restock_order(request: CreateRestockOrderRequest): + """Submit a restocking order built from the demand forecast recommendations""" + if not request.items: + raise HTTPException(status_code=400, detail="A restocking order needs at least one item") + + known_skus = {f["item_sku"] for f in demand_forecasts} + for item in request.items: + if item.item_sku not in known_skus: + raise HTTPException(status_code=400, detail=f"Unknown forecast SKU: {item.item_sku}") + if item.quantity < 1: + raise HTTPException(status_code=400, detail=f"Quantity for {item.item_sku} must be at least 1") + + total_value = round(sum(item.quantity * item.unit_cost for item in request.items), 2) + if total_value > request.budget: + raise HTTPException( + status_code=400, + detail=f"Order total {total_value} exceeds the budget of {request.budget}" + ) + + # The order arrives when its slowest line item arrives. + lead_time_days = max(item.lead_time_days for item in request.items) + submitted = datetime.now() + + order = { + "id": f"restock-{len(restock_orders) + 1}", + "order_number": f"RO-{len(restock_orders) + 1:04d}", + "items": [item.model_dump() for item in request.items], + "total_value": total_value, + "budget": request.budget, + "status": "Submitted", + "submitted_date": submitted.isoformat(timespec="seconds"), + "expected_delivery": (submitted + timedelta(days=lead_time_days)).isoformat(timespec="seconds"), + "lead_time_days": lead_time_days + } + restock_orders.append(order) + return order + if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8001) diff --git a/tests/backend/test_restocking.py b/tests/backend/test_restocking.py new file mode 100644 index 000000000..b8739e52e --- /dev/null +++ b/tests/backend/test_restocking.py @@ -0,0 +1,278 @@ +""" +Tests for restocking API endpoints. + +Covers the two new fields on the demand forecast (unit_cost, lead_time_days) +and the /api/restock-orders submit-and-list flow. + +Note: submitted restock orders live in a module-level list in main.py, so they +accumulate across tests within a session. Every assertion here is written +relative to a baseline count rather than against an absolute list length. +""" +from datetime import datetime + +import pytest + + +@pytest.fixture +def restock_payload(): + """A valid two-line restocking order that fits inside its budget.""" + return { + "budget": 6000, + "items": [ + { + "item_sku": "WDG-001", + "item_name": "Industrial Widget Type A", + "quantity": 150, + "unit_cost": 24.50, + "lead_time_days": 14 + }, + { + "item_sku": "FLT-405", + "item_name": "Oil Filter Cartridge", + "quantity": 150, + "unit_cost": 12.25, + "lead_time_days": 7 + } + ] + } + + +class TestDemandForecastRestockFields: + """Test suite for the restocking fields added to the demand forecast.""" + + def test_demand_forecasts_include_cost_and_lead_time(self, client): + """Test that every demand forecast exposes unit_cost and lead_time_days.""" + response = client.get("/api/demand") + assert response.status_code == 200 + + data = response.json() + assert isinstance(data, list) + assert len(data) > 0 + + for forecast in data: + assert "unit_cost" in forecast + assert "lead_time_days" in forecast + + def test_demand_forecast_field_types(self, client): + """Test that the new forecast fields have correct types and ranges.""" + response = client.get("/api/demand") + data = response.json() + + for forecast in data: + assert isinstance(forecast["unit_cost"], (int, float)) + assert isinstance(forecast["lead_time_days"], int) + assert forecast["unit_cost"] > 0 + assert forecast["lead_time_days"] > 0 + + def test_forecast_unit_cost_matches_inventory_where_sku_exists(self, client): + """Test that a forecast SKU also held in inventory agrees on unit cost.""" + forecasts = client.get("/api/demand").json() + inventory = client.get("/api/inventory").json() + + inventory_costs = {item["sku"]: item["unit_cost"] for item in inventory} + overlapping = [f for f in forecasts if f["item_sku"] in inventory_costs] + + # Only PSU-501 currently exists in both datasets, but assert on whatever + # overlaps so this test keeps working as the fixtures grow. + assert len(overlapping) > 0 + + for forecast in overlapping: + expected = inventory_costs[forecast["item_sku"]] + assert abs(forecast["unit_cost"] - expected) < 0.01 + + def test_shortfall_items_exist(self, client): + """Test that at least one forecast has a positive demand shortfall.""" + data = client.get("/api/demand").json() + + shortfalls = [ + f["forecasted_demand"] - f["current_demand"] + for f in data + ] + assert any(s > 0 for s in shortfalls) + + +class TestRestockOrdersEndpoints: + """Test suite for the restock-orders endpoints.""" + + def test_get_restock_orders_returns_list(self, client): + """Test getting all submitted restocking orders.""" + response = client.get("/api/restock-orders") + assert response.status_code == 200 + assert isinstance(response.json(), list) + + def test_create_restock_order_returns_201(self, client, restock_payload): + """Test that submitting a restocking order returns 201 with the order.""" + response = client.post("/api/restock-orders", json=restock_payload) + assert response.status_code == 201 + + order = response.json() + for field in [ + "id", "order_number", "items", "total_value", "budget", + "status", "submitted_date", "expected_delivery", "lead_time_days" + ]: + assert field in order + + assert order["status"] == "Submitted" + assert order["order_number"].startswith("RO-") + assert len(order["items"]) == 2 + + def test_create_restock_order_total_value_calculation(self, client, restock_payload): + """Test that total_value is the sum of quantity times unit cost.""" + response = client.post("/api/restock-orders", json=restock_payload) + order = response.json() + + calculated_total = sum( + item["quantity"] * item["unit_cost"] + for item in restock_payload["items"] + ) + assert abs(order["total_value"] - calculated_total) < 0.01 + assert order["total_value"] <= order["budget"] + + def test_create_restock_order_lead_time_is_slowest_item(self, client, restock_payload): + """Test that order lead time is the maximum across its line items.""" + response = client.post("/api/restock-orders", json=restock_payload) + order = response.json() + + expected_lead = max(item["lead_time_days"] for item in restock_payload["items"]) + assert order["lead_time_days"] == expected_lead + + def test_create_restock_order_expected_delivery_matches_lead_time(self, client, restock_payload): + """Test that expected_delivery is submitted_date plus the lead time.""" + response = client.post("/api/restock-orders", json=restock_payload) + order = response.json() + + submitted = datetime.fromisoformat(order["submitted_date"]) + delivery = datetime.fromisoformat(order["expected_delivery"]) + + assert (delivery - submitted).days == order["lead_time_days"] + assert delivery > submitted + + def test_created_order_appears_in_list(self, client, restock_payload): + """Test that a submitted order shows up in the restock-orders list.""" + before = len(client.get("/api/restock-orders").json()) + + created = client.post("/api/restock-orders", json=restock_payload).json() + + after = client.get("/api/restock-orders").json() + assert len(after) == before + 1 + assert any(o["order_number"] == created["order_number"] for o in after) + + def test_restock_orders_returned_newest_first(self, client, restock_payload): + """Test that the list returns the most recently submitted order first.""" + client.post("/api/restock-orders", json=restock_payload) + second = client.post("/api/restock-orders", json=restock_payload).json() + + listed = client.get("/api/restock-orders").json() + assert listed[0]["order_number"] == second["order_number"] + + def test_order_numbers_are_unique(self, client, restock_payload): + """Test that each submitted order gets a distinct order number.""" + client.post("/api/restock-orders", json=restock_payload) + client.post("/api/restock-orders", json=restock_payload) + + listed = client.get("/api/restock-orders").json() + numbers = [o["order_number"] for o in listed] + assert len(numbers) == len(set(numbers)) + + def test_create_restock_order_accepts_exact_budget_match(self, client): + """Test that an order costing exactly the budget is accepted.""" + payload = { + "budget": 2450.0, + "items": [{ + "item_sku": "WDG-001", + "item_name": "Industrial Widget Type A", + "quantity": 100, + "unit_cost": 24.50, + "lead_time_days": 14 + }] + } + response = client.post("/api/restock-orders", json=payload) + assert response.status_code == 201 + assert abs(response.json()["total_value"] - 2450.0) < 0.01 + + +class TestRestockOrderValidation: + """Test suite for restock-order rejection paths.""" + + def test_rejects_empty_items(self, client): + """Test that an order with no line items is rejected.""" + response = client.post("/api/restock-orders", json={"budget": 5000, "items": []}) + assert response.status_code == 400 + + detail = response.json()["detail"] + assert "at least one item" in detail.lower() + + def test_rejects_unknown_sku(self, client): + """Test that a SKU absent from the demand forecast is rejected.""" + payload = { + "budget": 5000, + "items": [{ + "item_sku": "NOPE-999", + "item_name": "Not A Real Part", + "quantity": 1, + "unit_cost": 10.0, + "lead_time_days": 5 + }] + } + response = client.post("/api/restock-orders", json=payload) + assert response.status_code == 400 + assert "NOPE-999" in response.json()["detail"] + + def test_rejects_zero_quantity(self, client): + """Test that a line item with no quantity is rejected.""" + payload = { + "budget": 5000, + "items": [{ + "item_sku": "WDG-001", + "item_name": "Industrial Widget Type A", + "quantity": 0, + "unit_cost": 24.50, + "lead_time_days": 14 + }] + } + response = client.post("/api/restock-orders", json=payload) + assert response.status_code == 400 + assert "at least 1" in response.json()["detail"].lower() + + def test_rejects_total_over_budget(self, client): + """Test that an order exceeding its budget is rejected.""" + payload = { + "budget": 100.0, + "items": [{ + "item_sku": "WDG-001", + "item_name": "Industrial Widget Type A", + "quantity": 150, + "unit_cost": 24.50, + "lead_time_days": 14 + }] + } + response = client.post("/api/restock-orders", json=payload) + assert response.status_code == 400 + + detail = response.json()["detail"].lower() + assert "exceeds" in detail + assert "budget" in detail + + def test_over_budget_order_is_not_stored(self, client): + """Test that a rejected order does not land in the list.""" + before = len(client.get("/api/restock-orders").json()) + + client.post("/api/restock-orders", json={ + "budget": 1.0, + "items": [{ + "item_sku": "WDG-001", + "item_name": "Industrial Widget Type A", + "quantity": 150, + "unit_cost": 24.50, + "lead_time_days": 14 + }] + }) + + after = len(client.get("/api/restock-orders").json()) + assert after == before + + def test_rejects_malformed_payload(self, client): + """Test that a payload missing required fields fails validation.""" + response = client.post("/api/restock-orders", json={"items": []}) + assert response.status_code == 422 + assert "detail" in response.json()