From 8b9a11a0ec1270a83f56bfdd28c49d8a075c525f Mon Sep 17 00:00:00 2001 From: Harish Toppae Date: Thu, 27 Aug 2026 14:06:10 -0400 Subject: [PATCH 1/2] Add budget-driven Restocking tab Adds a Restocking view that recommends what to reorder from the demand forecast for a given budget, and surfaces submitted orders in the Orders tab. Frontend: - New Restocking.vue: budget slider ($0-$10,000, $250 steps) driving a greedy-by-shortfall allocation with partial fills on the last affordable line. Full-coverage cost is computed from the loaded forecasts rather than hardcoded, so it tracks fixture changes. - Money math is rounded to cents at each step. Accumulated float subtraction otherwise left the remaining budget at 95.99999999999957 instead of 96.00, which silently dropped a $96.00 line item and left the budget unspent at exactly the full-coverage figure. - Restocking uses formatCurrencyWithDecimals(..., 2) for unit costs and line totals so they reconcile against quantity. The shared currency.js default of 0 decimals is unchanged, since Dashboard, Spending, Reports and Orders all depend on it. - Orders.vue gains a "Submitted Orders" card above All Orders showing lead time and expected delivery; loads independently of the FilterBar. - en/ja locales at parity (266 keys, no gaps). Backend: - demand_forecasts.json gains unit_cost and lead_time_days on all 9 records. PSU-501 reuses its real inventory cost ($18.99); the other 8 SKUs have no inventory row, so costs are set per part type. - POST /api/restock-orders re-validates the budget, SKUs and quantities server-side, so the client cannot overspend even if its arithmetic drifts. GET /api/restock-orders lists newest first. - Orders are stored in a process-scoped list and are lost on restart; this app has no database by design. Tests: 19 new backend tests (59 total, all passing), written relative to a baseline count since the order list accumulates within a session. Co-Authored-By: Claude Opus 5 --- client/src/App.vue | 3 + client/src/api.js | 10 + client/src/locales/en.js | 50 +++ client/src/locales/ja.js | 50 +++ client/src/main.js | 4 +- client/src/views/Orders.vue | 87 +++++- client/src/views/Restocking.vue | 489 ++++++++++++++++++++++++++++++ server/data/demand_forecasts.json | 36 ++- server/main.py | 72 +++++ tests/backend/test_restocking.py | 278 +++++++++++++++++ 10 files changed, 1067 insertions(+), 12 deletions(-) create mode 100644 client/src/views/Restocking.vue create mode 100644 tests/backend/test_restocking.py 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() From 04ca729e5a222ebabb1ac6fbb67ff68b5e4c30c5 Mon Sep 17 00:00:00 2001 From: Harish Toppae Date: Thu, 27 Aug 2026 14:06:22 -0400 Subject: [PATCH 2/2] Add Claude Code GitHub Actions workflow Responds to @claude mentions on issues and PR review comments, pinned to anthropics/claude-code-action@v1. This is step 3 of the manual setup only. The workflow does nothing until someone with repo admin also: 1. installs the Claude GitHub App (github.com/apps/claude) 2. adds an ANTHROPIC_API_KEY repository secret /install-github-app could not be used here: it needs an interactive terminal for the OAuth grant and the gh CLI, which is not installed. On this public repo the action only runs for commenters with write access, and GitHub withholds secrets from fork-PR runs. No credential is committed here, only the secrets.ANTHROPIC_API_KEY reference. Co-Authored-By: Claude Opus 5 --- .github/workflows/claude.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .github/workflows/claude.yml 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 }}