From 57c9dec99e395084f917248a4b9c6255cf0aa6a4 Mon Sep 17 00:00:00 2001 From: Farhan Bhagat Date: Wed, 29 Jul 2026 10:35:04 -0700 Subject: [PATCH 1/4] Add Restocking tab and system architecture doc Adds a budget-driven restocking workflow: a new Restocking tab lets users set an available budget, see items recommended from demand forecasts (prioritized by urgency and rising demand), and submit an order that appears in the Orders tab under a new "Submitted Orders" section with delivery lead time. Backfills unit_cost onto demand_forecasts.json since its SKUs don't overlap inventory.json's, so recommendations couldn't otherwise be costed. Also adds docs/architecture.html, a static overview of the system's tech stack, architecture, and data flow for onboarding. Co-Authored-By: Claude Sonnet 5 --- client/src/App.vue | 3 + client/src/api.js | 10 + client/src/locales/en.js | 31 ++- client/src/locales/ja.js | 31 ++- client/src/main.js | 4 +- client/src/views/Orders.vue | 87 ++++++- client/src/views/Restocking.vue | 382 ++++++++++++++++++++++++++++++ docs/architecture.html | 366 ++++++++++++++++++++++++++++ server/data/demand_forecasts.json | 27 ++- server/main.py | 110 +++++++++ tests/backend/test_restocking.py | 118 +++++++++ 11 files changed, 1150 insertions(+), 19 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/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..38c5a8645 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -94,6 +94,16 @@ export const api = { return response.data }, + async getRestockingRecommendations(budget) { + const response = await axios.get(`${API_BASE_URL}/restocking/recommendations?budget=${budget}`) + return response.data + }, + + async submitRestockingOrder(items) { + const response = await axios.post(`${API_BASE_URL}/restocking/orders`, { items }) + 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..d37dc84d9 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' }, @@ -125,7 +126,34 @@ export default { totalValue: 'Total Value', status: 'Status', expectedDelivery: 'Expected Delivery', - actualDelivery: 'Actual Delivery' + actualDelivery: 'Actual Delivery', + leadTime: 'Lead Time' + }, + submittedOrders: 'Submitted Orders', + leadTimeDays: '{days} days' + }, + + // Restocking + restocking: { + title: 'Restocking', + description: 'Set a budget and restock items recommended from demand forecasts', + budgetLabel: 'Available Budget', + recommended: 'Recommended Items', + totalCost: 'Total Cost', + remaining: 'Remaining Budget', + placeOrder: 'Place Order', + placingOrder: 'Placing Order...', + orderSuccess: 'Order submitted successfully', + orderError: 'Failed to submit order', + noItems: 'No items are recommended for this budget', + selectItems: 'Uncheck items to exclude them from the order', + table: { + sku: 'SKU', + itemName: 'Item Name', + quantity: 'Quantity', + unitCost: 'Unit Cost', + lineTotal: 'Line Total', + trend: 'Trend' } }, @@ -204,6 +232,7 @@ export default { shipped: 'Shipped', processing: 'Processing', backordered: 'Backordered', + submitted: 'Submitted', inStock: 'In Stock', lowStock: 'Low Stock', adequate: 'Adequate' diff --git a/client/src/locales/ja.js b/client/src/locales/ja.js index db33223ac..8ee80309a 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: '在庫管理システム' }, @@ -125,7 +126,34 @@ export default { totalValue: '合計金額', status: 'ステータス', expectedDelivery: '予定配達日', - actualDelivery: '実際の配達日' + actualDelivery: '実際の配達日', + leadTime: 'リードタイム' + }, + submittedOrders: '提出済み注文', + leadTimeDays: '{days}日' + }, + + // Restocking + restocking: { + title: '補充', + description: '予算を設定し、需要予測に基づいて推奨された品目を補充します', + budgetLabel: '利用可能な予算', + recommended: '推奨品目', + totalCost: '合計金額', + remaining: '残り予算', + placeOrder: '注文する', + placingOrder: '注文中...', + orderSuccess: '注文が正常に送信されました', + orderError: '注文の送信に失敗しました', + noItems: 'この予算で推奨される品目はありません', + selectItems: 'チェックを外すと注文から除外されます', + table: { + sku: 'SKU', + itemName: '品目名', + quantity: '数量', + unitCost: '単価', + lineTotal: '小計', + trend: 'トレンド' } }, @@ -204,6 +232,7 @@ export default { shipped: '出荷済み', processing: '処理中', backordered: 'バックオーダー', + submitted: '提出済み', inStock: '在庫あり', lowStock: '在庫僅少', adequate: '適量' 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..ac259b266 100644 --- a/client/src/views/Orders.vue +++ b/client/src/views/Orders.vue @@ -74,6 +74,51 @@ + +
+
+

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

+
+
+ + + + + + + + + + + + + + + + + + + + + + +
+
@@ -95,6 +140,7 @@ export default { const loading = ref(true) const error = ref(null) const orders = ref([]) + const submittedOrders = ref([]) // Use shared filters const { @@ -111,12 +157,15 @@ export default { const filters = getCurrentFilters() const fetchedOrders = await api.getOrders(filters) - // Sort orders by order_date (earliest first) - orders.value = fetchedOrders.sort((a, b) => { - const dateA = new Date(a.order_date) - const dateB = new Date(b.order_date) - return dateA - dateB - }) + // Exclude submitted (restocking) orders from the main list/stats, + // then sort by order_date (earliest first) + orders.value = fetchedOrders + .filter(o => o.status !== 'Submitted') + .sort((a, b) => { + const dateA = new Date(a.order_date) + const dateB = new Date(b.order_date) + return dateA - dateB + }) } catch (err) { error.value = 'Failed to load orders: ' + err.message } finally { @@ -124,9 +173,15 @@ export default { } } + const loadSubmittedOrders = async () => { + const filters = { ...getCurrentFilters(), status: 'Submitted' } + submittedOrders.value = await api.getOrders(filters) + } + // Watch for filter changes and reload data watch([selectedPeriod, selectedLocation, selectedCategory, selectedStatus], () => { loadOrders() + loadSubmittedOrders() }) const getOrdersByStatus = (status) => { @@ -153,13 +208,17 @@ export default { }) } - onMounted(loadOrders) + onMounted(() => { + loadOrders() + loadSubmittedOrders() + }) return { t, loading, error, orders, + submittedOrders, getOrdersByStatus, getOrderStatusClass, formatDate, @@ -276,4 +335,18 @@ export default { font-size: 0.813rem; color: #64748b; } + +.submitted-orders-table { + table-layout: fixed; + width: 100%; +} + +.col-lead-time { + width: 120px; +} + +.submitted-badge { + margin-left: 0.75rem; + vertical-align: middle; +} diff --git a/client/src/views/Restocking.vue b/client/src/views/Restocking.vue new file mode 100644 index 000000000..ab8c56720 --- /dev/null +++ b/client/src/views/Restocking.vue @@ -0,0 +1,382 @@ + + + + + diff --git a/docs/architecture.html b/docs/architecture.html new file mode 100644 index 000000000..aef74ca13 --- /dev/null +++ b/docs/architecture.html @@ -0,0 +1,366 @@ + + + + + +Architecture — Factory Inventory Management System + + + +
+ +
+

Factory Inventory Management System

+

Architecture overview — Vue 3 frontend, FastAPI backend, in-memory mock data (no database)

+
+ +
+

Tech Stack

+
+
+ Frontend +

Vue 3 + Vite

+
    +
  • Composition API
  • +
  • vue-router 4 (client-side routing)
  • +
  • axios (HTTP client)
  • +
+ http://localhost:3000 +
+
+ Backend +

Python FastAPI

+
    +
  • Pydantic models for validation
  • +
  • uvicorn ASGI server
  • +
  • CORS middleware (dev: allow all)
  • +
+ http://localhost:8001 · /docs +
+
+ Data +

In-Memory Mock Data

+
    +
  • JSON files in server/data/
  • +
  • Loaded once at startup
  • +
  • No persistence — resets on restart
  • +
+ no database +
+
+
+ +
+

System Architecture

+
+
+
+ Browser +
Vue 3 SPA
+
Views (Dashboard, Inventory, Orders, Demand, Spending, Reports)
+
Components (FilterBar, Modals, ProfileMenu)
+
Composables (useFilters, useAuth, useI18n)
+
+
+ +
+
+ API Client +
api.js (axios)
+
Builds query params from active filters
+
GET / POST / PATCH / DELETE over HTTP
+
+
+ +
+
+ Backend +
FastAPI (main.py)
+
Query-param filters (warehouse, category, status, month)
+
Pydantic response models
+
+
+ +
+
+ Data Layer +
mock_data.py
+
Loads JSON on import
+
server/data/*.json (inventory, orders, demand, backlog, spending…)
+
+
+
+
+ +
+

Filter Data Flow

+
+
+
User picks filterTime / Warehouse / Category / Status
+
+
FilterBar.vueemits selection
+
+
useFilters()shared reactive state
+
+
api.jsbuilds URLSearchParams
+
+
FastAPI routeapply_filters() / filter_by_month()
+
+
Pydantic validationresponse_model
+
+
Computed propertiesderived UI state
+
+
+
+ +
+

Key API Endpoints

+
+ + + + + + + + + + + + + +
EndpointFiltersPurpose
GET/api/inventorywarehouse, categoryInventory items across warehouses
GET/api/orderswarehouse, category, status, monthOrders with delivery/status tracking
GET/api/dashboard/summaryall filtersAggregate KPIs (inventory value, backlog, pending orders)
GET/api/demandnoneDemand forecasts with trend
GET/api/backlognoneBacklog items, flagged with purchase-order status
GET/api/spending/*noneSummary, monthly, category, and transaction spending views
GET/api/reports/quarterlynoneQuarterly revenue and fulfillment rollups
+
+
+ +
+

Project Structure

+
# client — Vue 3 + Vite frontend +client/src/ + views/ Dashboard, Inventory, Orders, Demand, Spending, Reports + components/ FilterBar, detail modals, ProfileMenu, LanguageSwitcher + composables/ useFilters, useAuth, useI18n + api.js centralized axios client + main.js app entry + vue-router config + +# server — FastAPI backend +server/ + main.py API routes, filtering logic, Pydantic models + mock_data.py loads JSON files into memory at startup + data/ inventory, orders, demand_forecasts, backlog_items, + purchase_orders, spending, transactions (*.json) + +# tests +tests/backend/ pytest + FastAPI TestClient
+
+ +
+

Notes

+
+
+

No database

+

All data lives in JSON files under server/data/, loaded into memory once on server start. Writes (tasks, purchase orders) only persist for the life of the process.

+
+
+

Shared filter system

+

Four filters — Time Period, Warehouse, Category, Order Status — flow through query params to nearly every endpoint, keeping views consistent.

+
+
+

Reactivity pattern

+

Raw API responses are stored in refs; anything derived (totals, chart series, filtered lists) is a computed property, recalculated only when its dependencies change.

+
+
+

Demo, not production

+

No authentication, no database, CORS wide open, no rate limiting — intentionally scoped for a workshop demo, not deployment.

+
+
+
+ +
Generated from repository inspection · docs/architecture.html
+ +
+ + diff --git a/server/data/demand_forecasts.json b/server/data/demand_forecasts.json index e1b388385..46d934eed 100644 --- a/server/data/demand_forecasts.json +++ b/server/data/demand_forecasts.json @@ -6,7 +6,8 @@ "current_demand": 300, "forecasted_demand": 450, "trend": "increasing", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 15.5 }, { "id": "2", @@ -15,7 +16,8 @@ "current_demand": 150, "forecasted_demand": 152, "trend": "stable", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 45.75 }, { "id": "3", @@ -24,7 +26,8 @@ "current_demand": 500, "forecasted_demand": 600, "trend": "increasing", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 8.5 }, { "id": "4", @@ -33,7 +36,8 @@ "current_demand": 50, "forecasted_demand": 35, "trend": "decreasing", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 285.0 }, { "id": "5", @@ -42,7 +46,8 @@ "current_demand": 800, "forecasted_demand": 950, "trend": "increasing", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 12.25 }, { "id": "6", @@ -51,7 +56,8 @@ "current_demand": 120, "forecasted_demand": 121, "trend": "stable", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 34.5 }, { "id": "7", @@ -60,7 +66,8 @@ "current_demand": 250, "forecasted_demand": 252, "trend": "stable", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 18.99 }, { "id": "8", @@ -69,7 +76,8 @@ "current_demand": 180, "forecasted_demand": 182, "trend": "stable", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 22.5 }, { "id": "9", @@ -78,6 +86,7 @@ "current_demand": 95, "forecasted_demand": 96, "trend": "stable", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 95.0 } ] diff --git a/server/main.py b/server/main.py index a0c2d8c5a..d7bed38d3 100644 --- a/server/main.py +++ b/server/main.py @@ -1,3 +1,5 @@ +import uuid +from datetime import datetime, timedelta, timezone from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from typing import List, Optional @@ -46,6 +48,53 @@ def apply_filters(items: list, warehouse: Optional[str] = None, category: Option return filtered +def compute_restocking_recommendations(budget: float) -> dict: + """Recommend items to restock within budget, prioritizing urgent + rising-demand items. + + Demand forecast SKUs are a distinct catalog from inventory SKUs in this dataset + (only one incidental overlap), so recommendations are computed directly from + demand_forecasts: a shortfall exists whenever forecasted_demand exceeds current_demand. + """ + candidates = [] + for forecast in demand_forecasts: + shortfall = forecast['forecasted_demand'] - forecast['current_demand'] + if shortfall <= 0: + continue + + line_total = round(shortfall * forecast['unit_cost'], 2) + + candidates.append({ + 'sku': forecast['item_sku'], + 'name': forecast['item_name'], + 'quantity': shortfall, + 'unit_cost': forecast['unit_cost'], + 'line_total': line_total, + 'current_demand': forecast['current_demand'], + 'forecasted_demand': forecast['forecasted_demand'], + 'trend': forecast['trend'], + 'shortfall': shortfall + }) + + increasing = sorted([c for c in candidates if c['trend'] == 'increasing'], key=lambda c: c['shortfall'], reverse=True) + other = sorted([c for c in candidates if c['trend'] != 'increasing'], key=lambda c: c['shortfall'], reverse=True) + priority_order = increasing + other + + budget_max = round(sum(c['line_total'] for c in priority_order), 2) + + recommended_items = [] + running_total = 0.0 + for candidate in priority_order: + if running_total + candidate['line_total'] <= budget: + recommended_items.append({k: v for k, v in candidate.items() if k != 'shortfall'}) + running_total += candidate['line_total'] + + return { + 'budget': budget, + 'budget_max': budget_max, + 'recommended_items': recommended_items, + 'total_cost': round(running_total, 2) + } + # CORS middleware app.add_middleware( CORSMiddleware, @@ -80,6 +129,7 @@ class Order(BaseModel): actual_delivery: Optional[str] = None warehouse: Optional[str] = None category: Optional[str] = None + lead_time_days: Optional[int] = None class DemandForecast(BaseModel): id: str @@ -120,6 +170,34 @@ class CreatePurchaseOrderRequest(BaseModel): expected_delivery_date: str notes: Optional[str] = None +class RestockingRecommendationItem(BaseModel): + sku: str + name: str + quantity: int + unit_cost: float + line_total: float + current_demand: int + forecasted_demand: int + trend: str + +class RestockingRecommendationsResponse(BaseModel): + budget: float + budget_max: float + recommended_items: List[RestockingRecommendationItem] + total_cost: float + +class RestockingOrderItem(BaseModel): + sku: str + name: str + quantity: int + unit_price: float + +class CreateRestockingOrderRequest(BaseModel): + items: List[RestockingOrderItem] + +class RestockingOrderResponse(BaseModel): + order: Order + # API endpoints @app.get("/") def root(): @@ -179,6 +257,38 @@ def get_backlog(): result.append(item_dict) return result +@app.get("/api/restocking/recommendations", response_model=RestockingRecommendationsResponse) +def get_restocking_recommendations(budget: float = 0): + """Recommend items to restock within the given budget""" + return compute_restocking_recommendations(budget) + +@app.post("/api/restocking/orders", response_model=RestockingOrderResponse) +def create_restocking_order(request: CreateRestockingOrderRequest): + """Submit a restocking order built from selected recommendations""" + if not request.items: + raise HTTPException(status_code=400, detail="No items selected") + + now = datetime.now(timezone.utc) + lead_time_days = 14 + total_value = round(sum(item.quantity * item.unit_price for item in request.items), 2) + + new_order = { + "id": str(uuid.uuid4()), + "order_number": f"RESTOCK-{now.strftime('%Y%m%d%H%M%S')}", + "customer": "Internal Restocking", + "items": [item.model_dump() for item in request.items], + "status": "Submitted", + "order_date": now.isoformat(), + "expected_delivery": (now + timedelta(days=lead_time_days)).isoformat(), + "total_value": total_value, + "actual_delivery": None, + "warehouse": None, + "category": None, + "lead_time_days": lead_time_days, + } + orders.append(new_order) + return {"order": new_order} + @app.get("/api/dashboard/summary") def get_dashboard_summary( warehouse: Optional[str] = None, diff --git a/tests/backend/test_restocking.py b/tests/backend/test_restocking.py new file mode 100644 index 000000000..3832ade87 --- /dev/null +++ b/tests/backend/test_restocking.py @@ -0,0 +1,118 @@ +""" +Tests for restocking API endpoints. +""" +import pytest + + +class TestRestockingEndpoints: + """Test suite for restocking-related endpoints.""" + + def test_get_recommendations_returns_budget_max(self, client): + """Test that recommendations endpoint returns a positive, stable budget_max.""" + response = client.get("/api/restocking/recommendations?budget=0") + assert response.status_code == 200 + + data = response.json() + assert "budget_max" in data + assert isinstance(data["budget_max"], (int, float)) + assert data["budget_max"] > 0 + + # Should be stable across repeated calls regardless of budget param + response_again = client.get("/api/restocking/recommendations?budget=999999") + assert response_again.json()["budget_max"] == data["budget_max"] + + def test_recommendations_structure(self, client): + """Test that recommended items have the expected fields.""" + response = client.get("/api/restocking/recommendations?budget=0") + data = response.json() + + assert "budget" in data + assert "recommended_items" in data + assert "total_cost" in data + assert isinstance(data["recommended_items"], list) + + def test_zero_budget_returns_no_items(self, client): + """Test that a budget of 0 recommends no items but still exposes budget_max.""" + response = client.get("/api/restocking/recommendations?budget=0") + assert response.status_code == 200 + + data = response.json() + assert data["recommended_items"] == [] + assert data["total_cost"] == 0 + + def test_increasing_budget_never_decreases_recommendations(self, client): + """Test that a larger budget never recommends fewer items or less total cost.""" + budget_max = client.get("/api/restocking/recommendations?budget=0").json()["budget_max"] + + low = client.get(f"/api/restocking/recommendations?budget={budget_max * 0.25}").json() + mid = client.get(f"/api/restocking/recommendations?budget={budget_max * 0.5}").json() + full = client.get(f"/api/restocking/recommendations?budget={budget_max}").json() + + assert len(low["recommended_items"]) <= len(mid["recommended_items"]) <= len(full["recommended_items"]) + assert low["total_cost"] <= mid["total_cost"] <= full["total_cost"] + + def test_full_budget_covers_every_candidate(self, client): + """Test that budget == budget_max includes every recommendable item.""" + budget_max = client.get("/api/restocking/recommendations?budget=0").json()["budget_max"] + + response = client.get(f"/api/restocking/recommendations?budget={budget_max}") + data = response.json() + + assert abs(data["total_cost"] - budget_max) < 0.01 + + def test_items_only_included_when_fully_affordable(self, client): + """Test that every recommended item's line_total fits within total_cost <= budget.""" + budget_max = client.get("/api/restocking/recommendations?budget=0").json()["budget_max"] + budget = budget_max * 0.4 + + response = client.get(f"/api/restocking/recommendations?budget={budget}") + data = response.json() + + assert data["total_cost"] <= budget + 0.01 + calculated_total = sum(item["line_total"] for item in data["recommended_items"]) + assert abs(calculated_total - data["total_cost"]) < 0.01 + + def test_submit_restocking_order(self, client): + """Test submitting a restocking order returns a Submitted order with lead time.""" + payload = { + "items": [ + {"sku": "WDG-001", "name": "Industrial Widget Type A", "quantity": 150, "unit_price": 15.5} + ] + } + response = client.post("/api/restocking/orders", json=payload) + assert response.status_code == 200 + + order = response.json()["order"] + assert order["status"] == "Submitted" + assert order["lead_time_days"] == 14 + assert order["total_value"] == 150 * 15.5 + + from datetime import datetime + order_date = datetime.fromisoformat(order["order_date"]) + expected_delivery = datetime.fromisoformat(order["expected_delivery"]) + assert (expected_delivery - order_date).days == 14 + + def test_submitted_order_appears_in_orders(self, client): + """Test that a submitted restocking order shows up via GET /api/orders.""" + payload = { + "items": [ + {"sku": "GSK-203", "name": "High-Temperature Gasket", "quantity": 100, "unit_price": 8.5} + ] + } + submit_response = client.post("/api/restocking/orders", json=payload) + new_order_id = submit_response.json()["order"]["id"] + + orders_response = client.get("/api/orders") + all_orders = orders_response.json() + + matching = [o for o in all_orders if o["id"] == new_order_id] + assert len(matching) == 1 + assert matching[0]["status"] == "Submitted" + + def test_submit_empty_items_returns_400(self, client): + """Test that submitting with no items is rejected.""" + response = client.post("/api/restocking/orders", json={"items": []}) + assert response.status_code == 400 + + data = response.json() + assert "detail" in data From 6d83e89558630c81a33242a040b22d9d4783c7f9 Mon Sep 17 00:00:00 2001 From: Farhan Bhagat Date: Wed, 29 Jul 2026 11:16:01 -0700 Subject: [PATCH 2/4] Fix restocking checkbox-reset bug and add order validation Unchecking a recommended item, then moving the budget slider at all, silently re-checked every item on the next recommendation refetch -- the checked-item selection was rebuilt from scratch on every budget change instead of only on first load. Now only the initial load defaults everything to checked; subsequent refetches preserve the user's existing exclusions and only default newly-appearing items to checked. Also adds Pydantic constraints (positive quantity, non- negative unit price, capped item-list length) to the restocking order submission endpoint, which previously trusted client-supplied values with no bounds. Found during PR review: https://github.com/beck-source/inventory-management/pull/234#issuecomment-5121756880 Co-Authored-By: Claude Sonnet 5 --- client/src/views/Restocking.vue | 23 ++++++++++++++++++++++- server/main.py | 8 ++++---- tests/backend/test_restocking.py | 31 +++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 5 deletions(-) diff --git a/client/src/views/Restocking.vue b/client/src/views/Restocking.vue index ab8c56720..3131fbac1 100644 --- a/client/src/views/Restocking.vue +++ b/client/src/views/Restocking.vue @@ -132,6 +132,11 @@ export default { const recommendedItems = ref([]) const totalCost = ref(0) const checkedSkus = ref(new Set()) + // Tracks whether applyRecommendations has ever run. Only the very first + // call should default every recommended item to checked; subsequent + // calls (from budget changes / post-order refresh) must preserve the + // user's existing unchecked selections instead of clobbering them. + let hasLoadedOnce = false let debounceTimer = null @@ -155,9 +160,25 @@ export default { } const applyRecommendations = (data) => { + const newSkus = data.recommended_items.map(item => item.sku) + + if (!hasLoadedOnce) { + // First load ever: check everything by default. + checkedSkus.value = new Set(newSkus) + hasLoadedOnce = true + } else { + // Subsequent recompute: keep the user's exclusions for SKUs that + // were already visible and unchecked. Brand-new SKUs (the user + // hasn't had a chance to react to them) default to checked. + const previousSkus = new Set(recommendedItems.value.map(item => item.sku)) + const nextChecked = new Set( + newSkus.filter(sku => !previousSkus.has(sku) || checkedSkus.value.has(sku)) + ) + checkedSkus.value = nextChecked + } + recommendedItems.value = data.recommended_items totalCost.value = data.total_cost - checkedSkus.value = new Set(data.recommended_items.map(item => item.sku)) } const fetchRecommendations = async (budgetValue) => { diff --git a/server/main.py b/server/main.py index d7bed38d3..b8422f6b1 100644 --- a/server/main.py +++ b/server/main.py @@ -3,7 +3,7 @@ from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from typing import List, Optional -from pydantic import BaseModel +from pydantic import BaseModel, Field 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") @@ -189,11 +189,11 @@ class RestockingRecommendationsResponse(BaseModel): class RestockingOrderItem(BaseModel): sku: str name: str - quantity: int - unit_price: float + quantity: int = Field(gt=0) + unit_price: float = Field(ge=0) class CreateRestockingOrderRequest(BaseModel): - items: List[RestockingOrderItem] + items: List[RestockingOrderItem] = Field(max_length=100) class RestockingOrderResponse(BaseModel): order: Order diff --git a/tests/backend/test_restocking.py b/tests/backend/test_restocking.py index 3832ade87..da983c689 100644 --- a/tests/backend/test_restocking.py +++ b/tests/backend/test_restocking.py @@ -116,3 +116,34 @@ def test_submit_empty_items_returns_400(self, client): data = response.json() assert "detail" in data + + def test_submit_negative_quantity_rejected(self, client): + """Test that a negative or zero quantity fails validation.""" + payload = { + "items": [ + {"sku": "WDG-001", "name": "Industrial Widget Type A", "quantity": -5, "unit_price": 15.5} + ] + } + response = client.post("/api/restocking/orders", json=payload) + assert response.status_code == 422 + + def test_submit_negative_unit_price_rejected(self, client): + """Test that a negative unit price fails validation.""" + payload = { + "items": [ + {"sku": "WDG-001", "name": "Industrial Widget Type A", "quantity": 10, "unit_price": -1.0} + ] + } + response = client.post("/api/restocking/orders", json=payload) + assert response.status_code == 422 + + def test_submit_too_many_items_rejected(self, client): + """Test that submitting more than the max allowed line items fails validation.""" + payload = { + "items": [ + {"sku": f"SKU-{i}", "name": "Item", "quantity": 1, "unit_price": 1.0} + for i in range(101) + ] + } + response = client.post("/api/restocking/orders", json=payload) + assert response.status_code == 422 From 1156154a017dbf7150e9f9cfe9d4289014d8f247 Mon Sep 17 00:00:00 2001 From: Farhan Bhagat Date: Wed, 29 Jul 2026 11:16:22 -0700 Subject: [PATCH 3/4] Ignore local Claude Code settings override .claude/settings.local.json holds personal env var overrides (e.g. experimental feature flags) that shouldn't be shared via git. Co-Authored-By: Claude Sonnet 5 --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index d38fbfe7e..d6d7dfbaa 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,9 @@ yarn-error.log* *~ .DS_Store +# Claude Code local settings (personal overrides, not shared with the team) +.claude/settings.local.json + # UV/Python package managers uv.lock *.lock From 97960086039a1651efedd4cca0af25b9e575e7fe Mon Sep 17 00:00:00 2001 From: Farhan Bhagat Date: Wed, 29 Jul 2026 11:16:40 -0700 Subject: [PATCH 4/4] Fix Reports page: no i18n, ignored filters, console spam, and more The Reports page was the one view in the app that hadn't kept pace with the rest of the codebase's conventions: - No i18n at all (hardcoded English strings, $ currency symbol regardless of locale) -- now fully translated via t(), including localized month labels. - Completely ignored the global filter bar -- neither the frontend nor the /api/reports/quarterly and /api/reports/monthly-trends backend endpoints accepted warehouse/category/status/month filters. Both endpoints now reuse the existing apply_filters/filter_by_month helpers, and the view wires into useFilters() like every other view. - ~14 unconditional console.log calls, several inside per-render helper functions -- removed entirely. - Still on Options API while every other view uses Composition API; bypassed the centralized api.js client in favor of raw axios calls to a hardcoded URL; used array index as v-for :key (a documented anti-pattern in this repo's own CLAUDE.md); recomputed max revenue by rescanning the full dataset on every bar render (O(n^2)); had a latent bug in hand-rolled number formatting that mis-placed a comma on negative values. All fixed to match Orders.vue/Demand.vue conventions. Also translates the "Reports" nav tab, the one nav link that was still hardcoded in English. Co-Authored-By: Claude Sonnet 5 --- client/src/App.vue | 2 +- client/src/api.js | 22 +++ client/src/locales/en.js | 26 +++ client/src/locales/ja.js | 26 +++ client/src/views/Reports.vue | 348 +++++++++++++++------------------- server/main.py | 28 ++- tests/backend/test_reports.py | 121 ++++++++++++ 7 files changed, 371 insertions(+), 202 deletions(-) create mode 100644 tests/backend/test_reports.py diff --git a/client/src/App.vue b/client/src/App.vue index dd6849b1c..19511597f 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -26,7 +26,7 @@ {{ t('nav.restocking') }} - Reports + {{ t('nav.reports') }} diff --git a/client/src/api.js b/client/src/api.js index 38c5a8645..e8f461990 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -94,6 +94,28 @@ export const api = { return response.data }, + async getQuarterlyReports(filters = {}) { + const params = new URLSearchParams() + if (filters.warehouse && filters.warehouse !== 'all') params.append('warehouse', filters.warehouse) + if (filters.category && filters.category !== 'all') params.append('category', filters.category) + if (filters.status && filters.status !== 'all') params.append('status', filters.status) + if (filters.month && filters.month !== 'all') params.append('month', filters.month) + + const response = await axios.get(`${API_BASE_URL}/reports/quarterly?${params.toString()}`) + return response.data + }, + + async getMonthlyTrends(filters = {}) { + const params = new URLSearchParams() + if (filters.warehouse && filters.warehouse !== 'all') params.append('warehouse', filters.warehouse) + if (filters.category && filters.category !== 'all') params.append('category', filters.category) + if (filters.status && filters.status !== 'all') params.append('status', filters.status) + if (filters.month && filters.month !== 'all') params.append('month', filters.month) + + const response = await axios.get(`${API_BASE_URL}/reports/monthly-trends?${params.toString()}`) + return response.data + }, + async getRestockingRecommendations(budget) { const response = await axios.get(`${API_BASE_URL}/restocking/recommendations?budget=${budget}`) return response.data diff --git a/client/src/locales/en.js b/client/src/locales/en.js index d37dc84d9..68bcba05e 100644 --- a/client/src/locales/en.js +++ b/client/src/locales/en.js @@ -7,6 +7,7 @@ export default { finance: 'Finance', demandForecast: 'Demand Forecast', restocking: 'Restocking', + reports: 'Reports', companyName: 'Catalyst Components', subtitle: 'Inventory Management System' }, @@ -157,6 +158,31 @@ export default { } }, + // Reports + reports: { + title: 'Performance Reports', + description: 'View quarterly performance metrics and monthly trends', + quarterlyPerformance: 'Quarterly Performance', + monthlyRevenueTrend: 'Monthly Revenue Trend', + monthOverMonth: 'Month-over-Month Analysis', + totalRevenueYTD: 'Total Revenue (YTD)', + avgMonthlyRevenue: 'Avg Monthly Revenue', + totalOrdersYTD: 'Total Orders (YTD)', + bestQuarter: 'Best Performing Quarter', + table: { + quarter: 'Quarter', + totalOrders: 'Total Orders', + totalRevenue: 'Total Revenue', + avgOrderValue: 'Avg Order Value', + fulfillmentRate: 'Fulfillment Rate', + month: 'Month', + orders: 'Orders', + revenue: 'Revenue', + change: 'Change', + growthRate: 'Growth Rate' + } + }, + // Finance/Spending finance: { title: 'Finance Dashboard', diff --git a/client/src/locales/ja.js b/client/src/locales/ja.js index 8ee80309a..07c22547e 100644 --- a/client/src/locales/ja.js +++ b/client/src/locales/ja.js @@ -7,6 +7,7 @@ export default { finance: '財務', demandForecast: '需要予測', restocking: '補充', + reports: 'レポート', companyName: '触媒コンポーネンツ', subtitle: '在庫管理システム' }, @@ -157,6 +158,31 @@ export default { } }, + // Reports + reports: { + title: 'パフォーマンスレポート', + description: '四半期業績指標と月次トレンドを表示', + quarterlyPerformance: '四半期業績', + monthlyRevenueTrend: '月次収益トレンド', + monthOverMonth: '月次比較分析', + totalRevenueYTD: '総収益(年初来)', + avgMonthlyRevenue: '平均月次収益', + totalOrdersYTD: '総注文数(年初来)', + bestQuarter: '最高業績四半期', + table: { + quarter: '四半期', + totalOrders: '総注文数', + totalRevenue: '総収益', + avgOrderValue: '平均注文額', + fulfillmentRate: '充足率', + month: '月', + orders: '注文数', + revenue: '収益', + change: '変化', + growthRate: '成長率' + } + }, + // Finance/Spending finance: { title: '財務ダッシュボード', diff --git a/client/src/views/Reports.vue b/client/src/views/Reports.vue index 35187eaf3..7db8f3f6c 100644 --- a/client/src/views/Reports.vue +++ b/client/src/views/Reports.vue @@ -1,35 +1,35 @@