diff --git a/CLAUDE.md b/CLAUDE.md index d2086efa..7fc95705 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,3 +72,6 @@ npm install && npm run dev - Status: green/blue/yellow/red - Charts: Custom SVG, CSS Grid for layouts - No emojis in UI + +## Code Style +- Always document non-obvious logic changes with comments diff --git a/architecture.html b/architecture.html new file mode 100644 index 00000000..393a101d --- /dev/null +++ b/architecture.html @@ -0,0 +1,543 @@ + + + + + +Factory Inventory Management — Architecture + + + +
+ +
+
System Documentation
+

Factory Inventory Management — Architecture

+

A full-stack demo of a factory inventory management system. Vue 3 single-page client communicates with a FastAPI service over a small REST surface; data is mocked in JSON files and loaded into memory at startup.

+
+
Frontend · localhost:3000
+
Backend · localhost:8001
+
In-memory mock data · no database
+
+
+ +
+

Tech Stack

+
Three thin layers: a Vue SPA, a FastAPI service, and JSON files on disk.
+
+
+
Frontend
+

Vue 3 SPA

+
client/ · port 3000
+
    +
  • Vue 3 Composition API
  • +
  • Vue Router 4.3
  • +
  • Axios 1.6
  • +
  • Vite 5.2
  • +
  • i18n en / ja
  • +
+
+
+
Backend
+

FastAPI Service

+
server/ · port 8001
+
    +
  • FastAPI ≥ 0.110
  • +
  • Pydantic ≥ 2.5
  • +
  • Uvicorn ≥ 0.24
  • +
  • Python ≥ 3.11
  • +
  • CORS open (dev)
  • +
+
+
+
Data
+

JSON Mock Store

+
server/data/ · in-memory
+
    +
  • inventory.json SKUs
  • +
  • orders.json 2025
  • +
  • demand_forecasts.json trends
  • +
  • spending.json costs
  • +
  • backlog, transactions
  • +
+
+
+
+ +
+

System Architecture

+
Browser issues HTTP requests against the FastAPI service, which filters in-memory data loaded from JSON at startup.
+
+ + + + + + + + + + + CLIENT · PORT 3000 + + + Browser + Vue 3 SPA · Vite + + + Views (router-view) + + + Composables · useFilters + + + api.js (Axios client) + + Routes + / /inventory /orders + /demand /spending + /reports + + + + + HTTP + GET /api/* + + + + JSON + + + + + SERVER · PORT 8001 + + + FastAPI + Uvicorn · ASGI + + + main.py · route handlers + + + apply_filters / filter_by_month + + + Pydantic models (validation) + + + mock_data.py (in-memory lists) + + + + + load + at startup + + + + + DATA + + + JSON Files + server/data/ + + inventory.json + orders.json + demand_forecasts.json + backlog_items.json + spending.json + transactions.json + purchase_orders.json + + +
+
+ +
+

Data Flow

+
End-to-end path for a typical filtered request — e.g. selecting "Q2-2025" on the Dashboard.
+
+
1
User selects a filterFilterBar.vue → useFilters composable
+
2
View reads current filtersgetCurrentFilters() returns { month, warehouse, category, status }
+
3
API client builds requestapi.getOrders(filters) → URLSearchParams
+
4
HTTP call to FastAPIGET /api/orders?month=Q2-2025
+
5
Route handler runsmain.py: get_orders() receives query params
+
6
Filters applied in memoryapply_filters() then filter_by_month()
+
7
Pydantic validates responseOrder[] schema enforced before serialization
+
8
JSON returned to clientAxios resolves → ref() updated → computed re-runs
+
+
+ +
+

API Endpoints

+
All routes return JSON. Filter params are optional; "all" or omitted means no filter.
+
+ + + + + + + + + + + + + + + + + + + +
MethodEndpointPurposeFilters
GET/api/inventoryList SKUs across warehouseswarehouse, category
GET/api/inventory/{id}Single inventory item
GET/api/ordersCustomer orderswarehouse, category, status, month
GET/api/orders/{id}Single order
GET/api/demandDemand forecasts
GET/api/backlogUnfulfilled items with PO status
GET/api/dashboard/summaryAggregated KPIs for landing pageall
GET/api/spending/summaryTotal procurement, ops, labor, overhead
GET/api/spending/monthlyMonth-by-month cost breakdown
GET/api/spending/categoriesCost distribution by category
GET/api/spending/transactionsRecent transactions
GET/api/reports/quarterlyQ1–Q4 2025 performance
GET/api/reports/monthly-trendsMonth-over-month trends
+
+
+ +
+

Views & Routes

+
Six top-level pages, each backed by a Composition-API view component.
+
+
+
/
+

Dashboard

+

KPIs, inventory overview, order metrics, revenue tracking.

+
+
+
/inventory
+

Inventory

+

Warehouse stock, SKU details, reorder points, locations.

+
+
+
/orders
+

Orders

+

Order status, customers, delivery tracking, date / status filters.

+
+
+
/demand
+

Demand

+

Forecasts, trend analysis, forecasted vs current demand per SKU.

+
+
+
/spending
+

Spending

+

Cost analytics, monthly and category breakdowns, transactions.

+
+
+
/reports
+

Reports

+

Quarterly performance summaries and monthly trend reports.

+
+
+
+ +
+

Key Design Choices

+
Patterns worth knowing before making changes.
+
+
+
State
+

No store library

+

Shared filter state lives in a singleton composable (useFilters). Each view manages its own loading / error / data refs. Derived data uses computed properties.

+
+
+
Filtering
+

Server-side, in-memory

+

FastAPI receives query params, filters Python lists with apply_filters / filter_by_month, and returns the trimmed result. Quarters (Q1–Q4) are mapped to month sets server-side.

+
+
+
Validation
+

Pydantic at the edge

+

Every endpoint declares a Pydantic response model. Changing a JSON field requires updating the model in main.py, otherwise responses fail validation.

+
+
+
+ + + +
+ + diff --git a/client/src/App.vue b/client/src/App.vue index c2da05a5..71d9913f 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -22,6 +22,9 @@ {{ t('nav.demandForecast') }} + + Restocking + Reports diff --git a/client/src/api.js b/client/src/api.js index 11cb9db7..7003a515 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -102,5 +102,22 @@ export const api = { async getPurchaseOrderByBacklogItem(backlogItemId) { const response = await axios.get(`${API_BASE_URL}/purchase-orders/${backlogItemId}`) return response.data + }, + + async getRestockingRecommendations(budget) { + const response = await axios.get(`${API_BASE_URL}/restocking/recommendations`, { + params: { budget } + }) + return response.data + }, + + async submitRestockingOrder(payload) { + const response = await axios.post(`${API_BASE_URL}/restocking/submit`, payload) + return response.data + }, + + async getSubmittedOrders() { + const response = await axios.get(`${API_BASE_URL}/restocking/submitted`) + return response.data } } diff --git a/client/src/main.js b/client/src/main.js index 477c2d96..2940c1ee 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,6 +17,7 @@ const router = createRouter({ { path: '/orders', component: Orders }, { path: '/demand', component: Demand }, { path: '/spending', component: Spending }, + { path: '/restocking', component: Restocking }, { path: '/reports', component: Reports } ] }) diff --git a/client/src/views/Orders.vue b/client/src/views/Orders.vue index 7413f6e6..87329209 100644 --- a/client/src/views/Orders.vue +++ b/client/src/views/Orders.vue @@ -27,6 +27,48 @@ +
+
+

Submitted Restocking Orders ({{ submittedOrders.length }})

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+
+

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

@@ -95,6 +137,7 @@ export default { const loading = ref(true) const error = ref(null) const orders = ref([]) + const submittedOrders = ref([]) // Use shared filters const { @@ -153,7 +196,18 @@ export default { }) } - onMounted(loadOrders) + const loadSubmittedOrders = async () => { + try { + submittedOrders.value = await api.getSubmittedOrders() + } catch (err) { + console.error('Failed to load submitted orders:', err) + } + } + + onMounted(() => { + loadOrders() + loadSubmittedOrders() + }) return { t, @@ -165,7 +219,8 @@ export default { formatDate, currencySymbol, translateProductName, - translateCustomerName + translateCustomerName, + submittedOrders } } } @@ -276,4 +331,18 @@ export default { font-size: 0.813rem; color: #64748b; } + +.submitted-orders-card { + border-left: 3px solid #2563eb; +} + +.submitted-table { + width: 100%; +} + +.submitted-table .order-num { + font-family: 'SF Mono', Monaco, Consolas, monospace; + font-size: 0.813rem; + color: #0f172a; +} diff --git a/client/src/views/Restocking.vue b/client/src/views/Restocking.vue new file mode 100644 index 00000000..ef1b068b --- /dev/null +++ b/client/src/views/Restocking.vue @@ -0,0 +1,390 @@ + + + + + diff --git a/server/main.py b/server/main.py index a0c2d8c5..658c7b16 100644 --- a/server/main.py +++ b/server/main.py @@ -1,8 +1,9 @@ +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, submitted_orders app = FastAPI(title="Factory Inventory Management System") @@ -14,6 +15,18 @@ 'Q4-2025': ['2025-10', '2025-11', '2025-12'] } +# Delivery lead time per inventory category, in days. Used when computing the +# expected delivery date for a submitted restocking order. Fallback below is +# applied for any category not listed here. +CATEGORY_LEAD_TIMES = { + "Circuit Boards": 14, + "Sensors": 7, + "Actuators": 10, + "Controllers": 12, + "Power Supplies": 9, +} +DEFAULT_LEAD_TIME_DAYS = 14 + def filter_by_month(items: list, month: Optional[str]) -> list: """Filter items by month/quarter based on order_date field""" if not month or month == 'all': @@ -120,6 +133,58 @@ class CreatePurchaseOrderRequest(BaseModel): expected_delivery_date: str notes: Optional[str] = None +class RestockingRecommendation(BaseModel): + sku: str + name: str + category: str + warehouse: str + unit_cost: float + current_quantity: int + reorder_point: int + quantity_to_order: int + line_cost: float + reason: str # 'below_reorder_point' | 'increasing_trend' + trend: Optional[str] = None + +class RestockingRecommendationsResponse(BaseModel): + budget: float + total_cost: float + items_count: int + recommendations: List[RestockingRecommendation] + +class SubmitOrderItemRequest(BaseModel): + sku: str + name: str + category: str + warehouse: str + unit_cost: float + quantity: int + +class SubmitOrderRequest(BaseModel): + items: List[SubmitOrderItemRequest] + budget: Optional[float] = None + +class SubmittedOrderItem(BaseModel): + sku: str + name: str + category: str + warehouse: str + unit_cost: float + quantity: int + line_cost: float + lead_time_days: int + +class SubmittedOrder(BaseModel): + id: str + order_number: str + submitted_date: str + total_cost: float + total_items: int + items: List[SubmittedOrderItem] + status: str + max_lead_time_days: int + expected_delivery: str + # API endpoints @app.get("/") def root(): @@ -304,6 +369,149 @@ def get_monthly_trends(): result.sort(key=lambda x: x['month']) return result +@app.get("/api/restocking/recommendations", response_model=RestockingRecommendationsResponse) +def get_restocking_recommendations(budget: float = 0.0): + """Recommend items to restock within a budget. + + Priority 1: inventory items below their reorder_point — sorted by largest deficit. + Priority 2: items with an 'increasing' demand trend not already in P1. + + Items are matched to demand forecasts by name (case-insensitive) because + demand_forecasts.json and inventory.json don't share SKU prefixes. + Greedy fill: line items are added in priority order while their line cost + fits in the remaining budget. + """ + if budget < 0: + raise HTTPException(status_code=400, detail="Budget must be non-negative") + + demand_by_name = {f["item_name"].lower(): f for f in demand_forecasts} + + candidates = [] + + # Priority 1: below reorder point. Order enough to reach 2x reorder_point so the + # buffer is meaningful for the demo (a pure deficit refill could be a single unit). + for item in inventory_items: + if item["quantity_on_hand"] < item["reorder_point"]: + qty = (2 * item["reorder_point"]) - item["quantity_on_hand"] + forecast = demand_by_name.get(item["name"].lower()) + candidates.append({ + "sku": item["sku"], + "name": item["name"], + "category": item["category"], + "warehouse": item["warehouse"], + "unit_cost": item["unit_cost"], + "current_quantity": item["quantity_on_hand"], + "reorder_point": item["reorder_point"], + "quantity_to_order": qty, + "line_cost": round(qty * item["unit_cost"], 2), + "reason": "below_reorder_point", + "trend": forecast["trend"] if forecast else None, + "_priority": 1, + "_deficit": item["reorder_point"] - item["quantity_on_hand"], + }) + + p1_skus = {c["sku"] for c in candidates} + + # Priority 2: trending up but not yet below reorder. Order ~30% of forecasted + # demand as a forward buffer. + for item in inventory_items: + if item["sku"] in p1_skus: + continue + forecast = demand_by_name.get(item["name"].lower()) + if forecast and forecast["trend"] == "increasing": + qty = max(int(forecast["forecasted_demand"] * 0.3), 1) + candidates.append({ + "sku": item["sku"], + "name": item["name"], + "category": item["category"], + "warehouse": item["warehouse"], + "unit_cost": item["unit_cost"], + "current_quantity": item["quantity_on_hand"], + "reorder_point": item["reorder_point"], + "quantity_to_order": qty, + "line_cost": round(qty * item["unit_cost"], 2), + "reason": "increasing_trend", + "trend": "increasing", + "_priority": 2, + "_deficit": 0, + }) + + candidates.sort(key=lambda c: (c["_priority"], -c["_deficit"], c["line_cost"])) + + selected = [] + total_cost = 0.0 + for c in candidates: + if total_cost + c["line_cost"] <= budget: + c.pop("_priority", None) + c.pop("_deficit", None) + selected.append(c) + total_cost += c["line_cost"] + + return { + "budget": budget, + "total_cost": round(total_cost, 2), + "items_count": len(selected), + "recommendations": selected, + } + +@app.post("/api/restocking/submit", response_model=SubmittedOrder) +def submit_restocking_order(request: SubmitOrderRequest): + """Submit a restocking order. The order is stored in-memory (resets on + restart) and surfaces in /api/restocking/submitted. + + Lead time is per-line based on category (CATEGORY_LEAD_TIMES). The order's + expected_delivery uses the longest line lead time so the whole order is + considered fulfilled once the slowest item arrives. + """ + if not request.items: + raise HTTPException(status_code=400, detail="At least one item is required") + + submitted_at = datetime.now() + order_number = f"RST-{submitted_at.strftime('%Y%m%d-%H%M%S')}" + + order_items = [] + total_cost = 0.0 + max_lead = 0 + for item in request.items: + if item.quantity <= 0: + raise HTTPException(status_code=400, detail=f"Quantity for {item.sku} must be positive") + lead = CATEGORY_LEAD_TIMES.get(item.category, DEFAULT_LEAD_TIME_DAYS) + line_cost = round(item.unit_cost * item.quantity, 2) + total_cost += line_cost + if lead > max_lead: + max_lead = lead + order_items.append({ + "sku": item.sku, + "name": item.name, + "category": item.category, + "warehouse": item.warehouse, + "unit_cost": item.unit_cost, + "quantity": item.quantity, + "line_cost": line_cost, + "lead_time_days": lead, + }) + + expected_delivery = (submitted_at + timedelta(days=max_lead)).date().isoformat() + + new_order = { + "id": f"sub-{len(submitted_orders) + 1}", + "order_number": order_number, + "submitted_date": submitted_at.isoformat(), + "total_cost": round(total_cost, 2), + "total_items": len(order_items), + "items": order_items, + "status": "Submitted", + "max_lead_time_days": max_lead, + "expected_delivery": expected_delivery, + } + submitted_orders.append(new_order) + return new_order + +@app.get("/api/restocking/submitted", response_model=List[SubmittedOrder]) +def get_submitted_orders(): + """List submitted restocking orders, most recent first.""" + return sorted(submitted_orders, key=lambda o: o["submitted_date"], reverse=True) + if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8001) diff --git a/server/mock_data.py b/server/mock_data.py index 2a9cd7dc..dd0baf94 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') +# In-memory list for restocking orders submitted via /api/restocking/submit. +# Intentionally not persisted to disk — resets on server restart, matching the +# rest of the mock-data model. +submitted_orders = [] + # All data is now loaded from JSON files in the data/ directory # This allows for easier maintenance and updates of the sample data