From 3c1a76413ee34d7faf9d0fd959ea1887e3b8e59c Mon Sep 17 00:00:00 2001 From: Danny Maloney Date: Thu, 27 Aug 2026 10:43:26 -0700 Subject: [PATCH] Add Restocking tab with budget-driven order recommendations Adds a budget-aware restocking workflow: recommendations are ranked by urgency (largest demand-vs-stock gap), greedily filled to the chosen budget, and submitted orders show up in Orders under a new "Submitted Orders" section with computed lead time. Also adds an architecture overview page and a CLAUDE.md documentation note. Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 1 + client/src/App.vue | 3 + client/src/api.js | 18 ++ client/src/main.js | 4 +- client/src/views/Orders.vue | 75 +++++- client/src/views/Restocking.vue | 316 ++++++++++++++++++++++ docs/architecture.html | 447 +++++++++++++++++++++++++++++++ server/main.py | 157 ++++++++++- server/mock_data.py | 5 + tests/backend/test_restocking.py | 233 ++++++++++++++++ 10 files changed, 1256 insertions(+), 3 deletions(-) create mode 100644 client/src/views/Restocking.vue create mode 100644 docs/architecture.html create mode 100644 tests/backend/test_restocking.py 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..2c6081c38 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -25,6 +25,9 @@ Reports + + Restocking + + +
+
+

Submitted Orders ({{ restockingOrders.length }})

+
+
+ No restocking orders have been submitted yet. +
+
+ + + + + + + + + + + + + + + + + + + +
Order NumberItemsTotal ValueLead Time (days)Expected Delivery
{{ order.order_number }} +
+ + {{ order.items.length }} item{{ order.items.length === 1 ? '' : 's' }} + +
+
+ {{ item.name }} + Qty: {{ item.quantity }} @ {{ currencySymbol }}{{ item.unit_price }} +
+
+
+
{{ currencySymbol }}{{ order.total_value.toLocaleString() }}{{ order.lead_time_days }}{{ formatDate(order.expected_delivery) }}
+
+
@@ -95,6 +138,7 @@ export default { const loading = ref(true) const error = ref(null) const orders = ref([]) + const restockingOrders = ref([]) // Use shared filters const { @@ -124,6 +168,14 @@ export default { } } + const loadRestockingOrders = async () => { + try { + restockingOrders.value = await api.getRestockingOrders() + } catch (err) { + console.error('Failed to load restocking orders:', err) + } + } + // Watch for filter changes and reload data watch([selectedPeriod, selectedLocation, selectedCategory, selectedStatus], () => { loadOrders() @@ -153,13 +205,17 @@ export default { }) } - onMounted(loadOrders) + onMounted(() => { + loadOrders() + loadRestockingOrders() + }) return { t, loading, error, orders, + restockingOrders, getOrdersByStatus, getOrderStatusClass, formatDate, @@ -276,4 +332,21 @@ export default { font-size: 0.813rem; color: #64748b; } + +/* Submitted (restocking) orders table */ +.restocking-orders-table { + table-layout: fixed; + width: 100%; +} + +.col-lead-time { + width: 140px; +} + +.empty-state { + padding: 2rem; + text-align: center; + color: #64748b; + font-size: 0.938rem; +} diff --git a/client/src/views/Restocking.vue b/client/src/views/Restocking.vue new file mode 100644 index 000000000..8e578e445 --- /dev/null +++ b/client/src/views/Restocking.vue @@ -0,0 +1,316 @@ + + + + + diff --git a/docs/architecture.html b/docs/architecture.html new file mode 100644 index 000000000..0ad53ad4f --- /dev/null +++ b/docs/architecture.html @@ -0,0 +1,447 @@ + + + + + +Architecture — Factory Inventory Management System + + + +
+ +
+

Factory Inventory Management System

+

A full-stack demo application for inventory, orders, demand forecasting, and spending analytics across factory warehouses. Vue 3 frontend, FastAPI backend, in-memory JSON data — no database.

+
+ frontend :3000 + backend :8001 + /docs — OpenAPI +
+
+ +
+

Tech Stack

+
+
+
Frontend
+

Vue 3 + Vite

+
    +
  • vue^3.4.21
  • +
  • vue-router^4.3.0
  • +
  • axios^1.6.7
  • +
  • vite^5.2.0
  • +
+
+
+
Backend
+

Python + FastAPI

+
    +
  • fastapi>=0.110.0
  • +
  • uvicorn>=0.24.0
  • +
  • pydantic>=2.5.0
  • +
  • python>=3.11 (uv)
  • +
+
+
+
Data
+

In-memory JSON

+
    +
  • Storageserver/data/*.json
  • +
  • Loadedonce, at import
  • +
  • Persistencenone (resets on restart)
  • +
  • Databasenone
  • +
+
+
+
+ +
+

Data Flow

+
+
+
+
UI
+
Vue viewDashboard, Orders, …
+
FilterBaruseFilters() singleton
+
+
+
+
Client
+
api.jsaxios, URLSearchParams
+
+
+
+
Server
+
FastAPI routemain.py
+
apply_filters()warehouse / category / status / month
+
+
+
+
Data
+
mock_data.pyin-memory lists
+
*.json filesserver/data/
+
+
+
+
Response
+
Pydantic modelvalidate + serialize
+
computed()view renders
+
+
+
+
+ +
+

API Endpoints

+
+ + + + + + + + + + + + + + + + + + + +
MethodPathFiltersReturns
GET/api/inventorywarehouse, categoryList of inventory items
GET/api/inventory/{id}Single inventory item (404 if missing)
GET/api/orderswarehouse, category, status, monthList of orders
GET/api/orders/{id}Single order (404 if missing)
GET/api/demandnoneDemand forecasts
GET/api/backlognoneBacklog items (+ computed has_purchase_order)
GET/api/dashboard/summarywarehouse, category, status, monthAggregated dashboard stats
GET/api/spending/summarynoneSpending summary
GET/api/spending/monthlynoneMonthly spending
GET/api/spending/categoriesnoneSpending by category
GET/api/spending/transactionsnoneRecent transactions
GET/api/reports/quarterlynone (unfiltered)Per-quarter order/revenue stats
GET/api/reports/monthly-trendsnone (unfiltered)Per-month order/revenue stats
+
+
+ +
+

Frontend Routes

+
+ + + + + + + + + + + + + +
PathViewNotes
/Dashboard.vueOverview + key metrics
/inventoryInventory.vueInventory listing
/ordersOrders.vueOrder tracking
/demandDemand.vueDemand forecasting
/spendingSpending.vueSpending analytics
/reportsReports.vueQuarterly / monthly trend reports
Backlog.vueExists but not registered in the router — unreachable from the UI
+
+
+ +
+

Known Deviations

+
Dead route Backlog.vue has no router entry and no nav link — it's built but unreachable in the running app.
+
Missing API client/src/api.js calls /api/tasks and /api/purchase-orders, but neither exists in main.py — these requests fail at runtime; task state instead falls back to local mock data in the auth composable.
+
Unfiltered /api/reports/quarterly and /api/reports/monthly-trends ignore the global filter bar entirely — they always compute over the full unfiltered order set.
+
Dev-only CORS Backend allows all origins, methods, and headers (allow_origins=["*"]) — fine for this local demo, not production-safe.
+
+ + + +
+ + diff --git a/server/main.py b/server/main.py index a0c2d8c5a..1a3475999 100644 --- a/server/main.py +++ b/server/main.py @@ -1,8 +1,10 @@ +import uuid +from datetime import datetime, timedelta from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from typing import List, Optional from pydantic import BaseModel -from mock_data import inventory_items, orders, demand_forecasts, backlog_items, spending_summary, monthly_spending, category_spending, recent_transactions, purchase_orders +from mock_data import inventory_items, orders, demand_forecasts, backlog_items, spending_summary, monthly_spending, category_spending, recent_transactions, purchase_orders, restocking_orders app = FastAPI(title="Factory Inventory Management System") @@ -120,6 +122,115 @@ class CreatePurchaseOrderRequest(BaseModel): expected_delivery_date: str notes: Optional[str] = None +class RestockRecommendation(BaseModel): + sku: str + name: str + category: str + warehouse: str + quantity_on_hand: int + forecasted_demand: int + gap: int + unit_cost: float + recommended_quantity: int + line_cost: float + lead_time_days: int + +class RestockRecommendationResponse(BaseModel): + max_budget: float + budget_used: float + recommendations: List[RestockRecommendation] + +class RestockOrderItem(BaseModel): + sku: str + name: str + quantity: int + unit_price: float + +class SubmitRestockOrderRequest(BaseModel): + budget: float + items: List[RestockOrderItem] + +class RestockingOrder(BaseModel): + id: str + order_number: str + items: List[RestockOrderItem] + status: str = "Processing" + order_date: str + expected_delivery: str + total_value: float + lead_time_days: int + +# Delivery lead time by category, since neither inventory nor demand +# forecast data carries a lead-time field +LEAD_TIME_BY_CATEGORY = { + "Circuit Boards": 14, + "Sensors": 7, + "Power Supplies": 10, + "Actuators": 12, + "Controllers": 14, +} +DEFAULT_LEAD_TIME_DAYS = 10 + +def build_restock_recommendations(budget: Optional[float] = None) -> RestockRecommendationResponse: + """Join demand forecasts to inventory by SKU, rank by urgency (largest + stock-vs-demand gap first), and greedily fill the given budget. max_budget + is always the full cost of restocking every candidate's entire gap, + regardless of the budget passed in - the frontend uses it to bootstrap + the slider's upper bound on first load (called with no budget).""" + inventory_by_sku = {item["sku"]: item for item in inventory_items} + + candidates = [] + for forecast in demand_forecasts: + item = inventory_by_sku.get(forecast["item_sku"]) + if not item: + continue + gap = forecast["forecasted_demand"] - item["quantity_on_hand"] + if gap <= 0: + continue + candidates.append((forecast, item, gap)) + + # Urgency first: largest gap first, cheapest unit cost breaks ties + candidates.sort(key=lambda c: (-c[2], c[1]["unit_cost"])) + + max_budget = round(sum(c[1]["unit_cost"] * c[2] for c in candidates), 2) + remaining_budget = budget if budget is not None else max_budget + budget_used = 0.0 + recommendations = [] + + for forecast, item, gap in candidates: + unit_cost = item["unit_cost"] + if unit_cost <= 0: + continue + # round before flooring so float error (e.g. 149.95 / 29.99 == 4.999...) + # doesn't undercount a budget that divides evenly into the unit cost + affordable_qty = min(gap, int(round(remaining_budget / unit_cost, 6))) + if affordable_qty <= 0: + continue + + line_cost = round(affordable_qty * unit_cost, 2) + remaining_budget -= line_cost + budget_used += line_cost + + recommendations.append(RestockRecommendation( + sku=item["sku"], + name=item["name"], + category=item["category"], + warehouse=item["warehouse"], + quantity_on_hand=item["quantity_on_hand"], + forecasted_demand=forecast["forecasted_demand"], + gap=gap, + unit_cost=unit_cost, + recommended_quantity=affordable_qty, + line_cost=line_cost, + lead_time_days=LEAD_TIME_BY_CATEGORY.get(item["category"], DEFAULT_LEAD_TIME_DAYS), + )) + + return RestockRecommendationResponse( + max_budget=max_budget, + budget_used=round(budget_used, 2), + recommendations=recommendations, + ) + # API endpoints @app.get("/") def root(): @@ -179,6 +290,50 @@ def get_backlog(): result.append(item_dict) return result +@app.get("/api/restocking/recommendations", response_model=RestockRecommendationResponse) +def get_restocking_recommendations(budget: Optional[float] = None): + """Get urgency-ranked restock recommendations, optionally constrained to a budget""" + return build_restock_recommendations(budget) + +@app.post("/api/restocking/orders", response_model=RestockingOrder, status_code=201) +def submit_restocking_order(request: SubmitRestockOrderRequest): + """Submit a restocking order built from recommended items""" + inventory_by_sku = {item["sku"]: item for item in inventory_items} + + categories = [] + for order_item in request.items: + item = inventory_by_sku.get(order_item.sku) + if not item: + raise HTTPException(status_code=404, detail=f"Item {order_item.sku} not found") + categories.append(item["category"]) + + lead_time_days = max( + (LEAD_TIME_BY_CATEGORY.get(category, DEFAULT_LEAD_TIME_DAYS) for category in categories), + default=DEFAULT_LEAD_TIME_DAYS + ) + + order_date = datetime.now() + expected_delivery = order_date + timedelta(days=lead_time_days) + total_value = round(sum(item.quantity * item.unit_price for item in request.items), 2) + + new_order = RestockingOrder( + id=str(uuid.uuid4()), + order_number=f"RSO-{len(restocking_orders) + 1:04d}", + items=[item.model_dump() for item in request.items], + status="Processing", + order_date=order_date.isoformat(), + expected_delivery=expected_delivery.isoformat(), + total_value=total_value, + lead_time_days=lead_time_days, + ) + restocking_orders.append(new_order.model_dump()) + return new_order + +@app.get("/api/restocking/orders", response_model=List[RestockingOrder]) +def get_restocking_orders(): + """Get all submitted restocking orders""" + return restocking_orders + @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..8a2a3f3d3 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 start empty each run - not backed by a JSON +# file since they are created at runtime and intentionally don't persist +# across server restarts. +restocking_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..e01abff8c --- /dev/null +++ b/tests/backend/test_restocking.py @@ -0,0 +1,233 @@ +""" +Tests for the restocking API endpoints. +""" +import pytest + +import main + + +@pytest.fixture +def controlled_data(monkeypatch): + """Replace inventory_items/demand_forecasts with a small, known dataset + so recommendation/urgency-fill tests don't depend on the real sample + data (which currently produces zero real candidates - the one matching + SKU, PSU-501, is already well-stocked).""" + inventory = [ + { + "id": "t1", "sku": "TEST-URGENT", "name": "Urgent Widget", + "category": "Sensors", "warehouse": "Tokyo", + "quantity_on_hand": 10, "reorder_point": 50, "unit_cost": 10.0, + "location": "T-1", "last_updated": "2025-09-01T00:00:00" + }, + { + "id": "t2", "sku": "TEST-MILD", "name": "Mild Widget", + "category": "Actuators", "warehouse": "Tokyo", + "quantity_on_hand": 80, "reorder_point": 50, "unit_cost": 5.0, + "location": "T-2", "last_updated": "2025-09-01T00:00:00" + }, + { + "id": "t3", "sku": "TEST-STOCKED", "name": "Stocked Widget", + "category": "Controllers", "warehouse": "Tokyo", + "quantity_on_hand": 500, "reorder_point": 50, "unit_cost": 20.0, + "location": "T-3", "last_updated": "2025-09-01T00:00:00" + }, + ] + demand_forecasts = [ + # gap = 300 - 10 = 290 (most urgent) + { + "id": "d1", "item_sku": "TEST-URGENT", "item_name": "Urgent Widget", + "current_demand": 250, "forecasted_demand": 300, + "trend": "increasing", "period": "Next 30 days" + }, + # gap = 100 - 80 = 20 (mildly urgent) + { + "id": "d2", "item_sku": "TEST-MILD", "item_name": "Mild Widget", + "current_demand": 90, "forecasted_demand": 100, + "trend": "stable", "period": "Next 30 days" + }, + # gap = 100 - 500 = -400 (already well-stocked, not a candidate) + { + "id": "d3", "item_sku": "TEST-STOCKED", "item_name": "Stocked Widget", + "current_demand": 90, "forecasted_demand": 100, + "trend": "stable", "period": "Next 30 days" + }, + # no matching inventory item, must be dropped + { + "id": "d4", "item_sku": "TEST-UNKNOWN", "item_name": "Unknown Widget", + "current_demand": 10, "forecasted_demand": 999, + "trend": "increasing", "period": "Next 30 days" + }, + ] + + monkeypatch.setattr(main, "inventory_items", inventory) + monkeypatch.setattr(main, "demand_forecasts", demand_forecasts) + return inventory, demand_forecasts + + +class TestRestockRecommendationsEndpoint: + """Test suite for GET /api/restocking/recommendations.""" + + def test_bootstrap_with_no_budget_returns_full_gap_cost(self, client, controlled_data): + """With no budget param, max_budget equals the cost of restocking + every candidate's full gap, and budget_used matches max_budget since + nothing is held back.""" + response = client.get("/api/restocking/recommendations") + assert response.status_code == 200 + + data = response.json() + # TEST-URGENT: 290 * 10.0 = 2900, TEST-MILD: 20 * 5.0 = 100 + assert data["max_budget"] == pytest.approx(3000.0) + assert data["budget_used"] == pytest.approx(3000.0) + assert len(data["recommendations"]) == 2 + + def test_excludes_unmatched_and_well_stocked_items(self, client, controlled_data): + """SKUs with no inventory match or a non-positive gap are dropped.""" + response = client.get("/api/restocking/recommendations") + data = response.json() + + skus = {rec["sku"] for rec in data["recommendations"]} + assert skus == {"TEST-URGENT", "TEST-MILD"} + + def test_recommendations_ranked_by_urgency(self, client, controlled_data): + """Largest gap (most urgent) is recommended first.""" + response = client.get("/api/restocking/recommendations") + data = response.json() + + recs = data["recommendations"] + assert recs[0]["sku"] == "TEST-URGENT" + assert recs[0]["gap"] == 290 + assert recs[1]["sku"] == "TEST-MILD" + assert recs[1]["gap"] == 20 + + def test_budget_greedily_fills_most_urgent_first(self, client, controlled_data): + """A budget that can only afford part of the most urgent item's gap + should spend entirely on that item, never exceeding the budget.""" + response = client.get("/api/restocking/recommendations?budget=250") + assert response.status_code == 200 + + data = response.json() + assert data["budget_used"] <= 250 + assert len(data["recommendations"]) == 1 + + rec = data["recommendations"][0] + assert rec["sku"] == "TEST-URGENT" + # floor(250 / 10.0) = 25 units, capped by affordability not the full gap + assert rec["recommended_quantity"] == 25 + assert rec["line_cost"] == pytest.approx(250.0) + + def test_budget_covering_urgent_item_spills_to_next(self, client, controlled_data): + """Once the most urgent item's full gap is affordable, remaining + budget is spent on the next-most-urgent item.""" + # Full urgent gap costs 2900; leave room for 10 units of TEST-MILD (50) + response = client.get("/api/restocking/recommendations?budget=2950") + data = response.json() + + recs = {rec["sku"]: rec for rec in data["recommendations"]} + assert recs["TEST-URGENT"]["recommended_quantity"] == 290 + assert recs["TEST-MILD"]["recommended_quantity"] == 10 + assert data["budget_used"] <= 2950 + + def test_zero_budget_returns_no_recommendations(self, client, controlled_data): + """A budget of 0 can't afford anything.""" + response = client.get("/api/restocking/recommendations?budget=0") + assert response.status_code == 200 + + data = response.json() + assert data["recommendations"] == [] + assert data["budget_used"] == 0 + + def test_budget_fill_not_undercounted_by_float_precision(self, client, monkeypatch): + """A budget that divides evenly into a realistic (2-decimal) unit + cost should not be shorted a unit by float division error + (e.g. 149.95 // 29.99 == 4.0 in raw floor division, one short of the + exact 5 units 29.99 * 5 == 149.95 affords).""" + inventory = [{ + "id": "p1", "sku": "TEST-PENNY", "name": "Penny-Priced Widget", + "category": "Sensors", "warehouse": "Tokyo", + "quantity_on_hand": 0, "reorder_point": 50, "unit_cost": 29.99, + "location": "T-1", "last_updated": "2025-09-01T00:00:00" + }] + demand_forecasts = [{ + "id": "d1", "item_sku": "TEST-PENNY", "item_name": "Penny-Priced Widget", + "current_demand": 0, "forecasted_demand": 50, + "trend": "increasing", "period": "Next 30 days" + }] + monkeypatch.setattr(main, "inventory_items", inventory) + monkeypatch.setattr(main, "demand_forecasts", demand_forecasts) + + response = client.get("/api/restocking/recommendations?budget=149.95") + data = response.json() + + assert len(data["recommendations"]) == 1 + assert data["recommendations"][0]["recommended_quantity"] == 5 + assert data["recommendations"][0]["line_cost"] == pytest.approx(149.95) + + def test_recommendation_includes_lead_time(self, client, controlled_data): + """Each recommendation carries a lead time derived from its category.""" + response = client.get("/api/restocking/recommendations") + data = response.json() + + for rec in data["recommendations"]: + assert isinstance(rec["lead_time_days"], int) + assert rec["lead_time_days"] > 0 + + +class TestSubmitRestockingOrderEndpoint: + """Test suite for POST /api/restocking/orders.""" + + def test_submit_valid_order(self, client): + """Submitting a valid order returns 201 with computed fields.""" + response = client.post("/api/restocking/orders", json={ + "budget": 500, + "items": [ + {"sku": "PCB-001", "name": "Single Layer PCB Assembly", "quantity": 10, "unit_price": 24.99} + ] + }) + assert response.status_code == 201 + + data = response.json() + assert data["order_number"].startswith("RSO-") + assert data["status"] == "Processing" + assert data["total_value"] == pytest.approx(249.9) + assert data["lead_time_days"] > 0 + assert "T" in data["order_date"] + assert "T" in data["expected_delivery"] + + def test_submit_order_uses_category_lead_time(self, client): + """Circuit Boards items should use the Circuit Boards lead time (14 days).""" + response = client.post("/api/restocking/orders", json={ + "budget": 500, + "items": [ + {"sku": "PCB-001", "name": "Single Layer PCB Assembly", "quantity": 1, "unit_price": 24.99} + ] + }) + data = response.json() + assert data["lead_time_days"] == main.LEAD_TIME_BY_CATEGORY["Circuit Boards"] + + def test_submit_order_unknown_sku_returns_404(self, client): + """An item referencing a SKU not in inventory is rejected.""" + response = client.post("/api/restocking/orders", json={ + "budget": 100, + "items": [ + {"sku": "NOT-A-REAL-SKU", "name": "Nonexistent", "quantity": 1, "unit_price": 1.0} + ] + }) + assert response.status_code == 404 + assert "not found" in response.json()["detail"].lower() + + def test_submit_order_appears_in_get_list(self, client): + """A submitted order shows up in GET /api/restocking/orders.""" + submit_response = client.post("/api/restocking/orders", json={ + "budget": 500, + "items": [ + {"sku": "PCB-002", "name": "Dual Layer PCB Assembly", "quantity": 5, "unit_price": 29.99} + ] + }) + assert submit_response.status_code == 201 + submitted_id = submit_response.json()["id"] + + list_response = client.get("/api/restocking/orders") + assert list_response.status_code == 200 + + order_ids = [order["id"] for order in list_response.json()] + assert submitted_id in order_ids