diff --git a/CLAUDE.md b/CLAUDE.md index 89c307d15..dba5e3597 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,3 +74,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/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..fdc3e413c 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -102,5 +102,25 @@ export const api = { async getPurchaseOrderByBacklogItem(backlogItemId) { const response = await axios.get(`${API_BASE_URL}/purchase-orders/${backlogItemId}`) return response.data + }, + + async getRestockRecommendations(budget, filters = {}) { + const params = new URLSearchParams() + params.append('budget', budget) + if (filters.warehouse && filters.warehouse !== 'all') params.append('warehouse', filters.warehouse) + if (filters.category && filters.category !== 'all') params.append('category', filters.category) + + const response = await axios.get(`${API_BASE_URL}/restocking/recommendations?${params.toString()}`) + return response.data + }, + + async createRestockOrder(payload) { + const response = await axios.post(`${API_BASE_URL}/restocking/orders`, payload) + return response.data + }, + + async getRestockOrders() { + const response = await axios.get(`${API_BASE_URL}/restocking/orders`) + return response.data } } diff --git a/client/src/locales/en.js b/client/src/locales/en.js index 03a58fe6e..fd765a808 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,9 @@ export default { onTimeDelivery: 'On-Time Delivery', itemsCount: '{count} items', quantity: 'Qty', + submittedOrders: 'Submitted Orders', + noSubmittedOrders: 'No restocking orders submitted yet', + daysCount: '{count} days', table: { orderNumber: 'Order Number', orderId: 'Order ID', @@ -125,7 +129,8 @@ export default { totalValue: 'Total Value', status: 'Status', expectedDelivery: 'Expected Delivery', - actualDelivery: 'Actual Delivery' + actualDelivery: 'Actual Delivery', + leadTime: 'Lead Time' } }, @@ -188,6 +193,29 @@ export default { } }, + // Restocking + restocking: { + title: 'Restocking', + description: 'Set a budget and restock the highest-priority items from the demand forecast', + budgetLabel: 'Available Budget', + recommendedCount: 'Recommended Items', + recommendedItems: 'Recommended Items', + totalCost: 'Total Cost', + remainingBudget: 'Remaining Budget', + noRecommendations: 'No items to recommend at this budget', + placeOrder: 'Place Order', + orderPlaced: 'Order {orderNumber} submitted — see it in Orders → Submitted Orders', + table: { + sku: 'SKU', + itemName: 'Item Name', + trend: 'Trend', + forecastedDemand: 'Forecasted Demand', + recommendedQty: 'Recommended Qty', + unitCost: 'Unit Cost', + lineTotal: 'Line Total' + } + }, + // Filters filters: { timePeriod: 'Time Period', diff --git a/client/src/locales/ja.js b/client/src/locales/ja.js index db33223ac..bdb1e5948 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,9 @@ export default { onTimeDelivery: '定時配達', itemsCount: '{count}件', quantity: '数量', + submittedOrders: '提出済み注文', + noSubmittedOrders: 'まだ再入荷注文はありません', + daysCount: '{count}日', table: { orderNumber: '注文番号', orderId: '注文ID', @@ -125,7 +129,8 @@ export default { totalValue: '合計金額', status: 'ステータス', expectedDelivery: '予定配達日', - actualDelivery: '実際の配達日' + actualDelivery: '実際の配達日', + leadTime: 'リードタイム' } }, @@ -188,6 +193,29 @@ export default { } }, + // Restocking + restocking: { + title: '再入荷', + description: '予算を設定し、需要予測から優先度の高い商品を再入荷', + budgetLabel: '利用可能な予算', + recommendedCount: '推奨品目', + recommendedItems: '推奨品目', + totalCost: '合計コスト', + remainingBudget: '残り予算', + noRecommendations: 'この予算で推奨できる品目はありません', + placeOrder: '注文する', + orderPlaced: '注文{orderNumber}を提出しました — 注文 → 提出済み注文でご確認ください', + table: { + sku: 'SKU', + itemName: '品目名', + trend: 'トレンド', + forecastedDemand: '予測需要', + recommendedQty: '推奨数量', + unitCost: '単価', + lineTotal: '小計' + } + }, + // Filters filters: { timePeriod: '期間', diff --git a/client/src/main.js b/client/src/main.js index 477c2d966..3347ae013 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(), @@ -15,6 +16,7 @@ const router = createRouter({ { path: '/inventory', component: Inventory }, { path: '/orders', component: Orders }, { path: '/demand', component: Demand }, + { path: '/restocking', component: Restocking }, { path: '/spending', component: Spending }, { path: '/reports', component: Reports } ] diff --git a/client/src/views/Orders.vue b/client/src/views/Orders.vue index 7413f6e66..7100ba930 100644 --- a/client/src/views/Orders.vue +++ b/client/src/views/Orders.vue @@ -74,6 +74,51 @@ + +
+
+

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

+
+
{{ t('orders.noSubmittedOrders') }}
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+
@@ -95,6 +140,7 @@ export default { const loading = ref(true) const error = ref(null) const orders = ref([]) + const submittedOrders = ref([]) // Use shared filters const { @@ -129,6 +175,14 @@ export default { loadOrders() }) + const loadSubmittedOrders = async () => { + try { + submittedOrders.value = await api.getRestockOrders() + } catch (err) { + console.error('Failed to load submitted orders:', err) + } + } + const getOrdersByStatus = (status) => { return orders.value.filter(order => order.status === status) } @@ -153,13 +207,17 @@ export default { }) } - onMounted(loadOrders) + onMounted(() => { + loadOrders() + loadSubmittedOrders() + }) return { t, loading, error, orders, + submittedOrders, getOrdersByStatus, getOrderStatusClass, formatDate, diff --git a/client/src/views/Restocking.vue b/client/src/views/Restocking.vue new file mode 100644 index 000000000..11a355b89 --- /dev/null +++ b/client/src/views/Restocking.vue @@ -0,0 +1,289 @@ + + + + + diff --git a/docs/architecture.html b/docs/architecture.html new file mode 100644 index 000000000..fbbcb02c8 --- /dev/null +++ b/docs/architecture.html @@ -0,0 +1,573 @@ +Factory Inventory Management System — Architecture + + + +
+ +

System architecture · reference sheet

+

Factory Inventory Management System

+

A Vue 3 single-page app and a FastAPI backend, talking over plain REST/JSON. Each side keeps its own copy of the data in memory — there is no database and no shared process.

+ +
+
Frontend
Vue 3.4 · Vite 5.2
:3000
+
Backend
FastAPI · Python ≥3.11
:8001
+
Data
7 JSON files
loaded to memory at boot
+
Status
Local dev only
wildcard CORS, no auth
+
+ +
+

Architecture

+

The Vite dev server and the FastAPI app are two independent processes on two ports. The browser is the only thing that talks to both — restart either side without touching the other.

+ +
+
+ + + + + + + + + + + Browser + + + + Vue 3 SPA + + Vite dev server · :3000 + views · components · composables + + + + FastAPI app + + Uvicorn · :8001 + CORS: allow_origins = "*" + + + In-memory Python lists + mock_data.py + + + server/data/*.json + 7 files · read-only at runtime + + + loads + + + axios · GET / POST + JSON over HTTP + + + filter in Python + + + loaded at startup + +
+
Nothing is shared between the two sides except HTTP requests: no session, no socket, no shared memory. The JSON files are only ever read — once, at process start.
+
+
+ +
+

Tech stack

+
+
+

Frontend · client/

+
+
UI framework
Vue 3.4, Composition API only
+
Routing
vue-router 4.3, client-side (createWebHistory)
+
HTTP client
Axios 1.6
+
Build tool
Vite 5.2 + @vitejs/plugin-vue
+
State
No store library — shared refs inside composables
+
i18n
Hand-rolled composable, 2 locales (en, ja)
+
+
+
+

Backend · server/

+
+
Web framework
FastAPI ≥0.110
+
ASGI server
Uvicorn ≥0.24
+
Validation
Pydantic ≥2.5
+
Runtime
Python ≥3.11, managed with uv (pyproject.toml)
+
Data
mock_data.py loads server/data/*.json into module-level lists at import
+
Tests
pytest + FastAPI TestClient (tests/backend/)
+
+
+
+
+ +
+

Request lifecycle

+

Every view shares four filters — Time Period, Warehouse, Category, Order Status — held as refs in useFilters.js and sent as query params on every request. This is what happens for one filter change, in order.

+ +
+
+ + + + + + + + + CLIENT · VUE + SERVER · FASTAPI + + + + + + + 1 + Filter changed in FilterBar.vue + + + + + 2 + useFilters composable updates refs + + + + + 3 + View watcher triggers loadData() + + + + + 4 + api.js calls axios.get(...) + + + + + 5 + FastAPI route receives request + + + + + 6 + apply_filters / filter_by_month run + + + + + 7 + Pydantic model validates response + + + + + 8 + axios resolves; ref updated + + + + + 9 + Computed props re-render view + + + + + + + + axios · GET request + + + + + + 200 OK · JSON body + + + +
+
The two lanes are two separate processes: everything left of the dashed line runs in the browser's JS engine, everything right of it runs in the Python interpreter. The only thing that crosses is the HTTP request and its JSON response.
+
+
+ +
+

Routes

+

Registered once in client/src/main.js, matched to the nav bar in App.vue.

+
+ + + + + + + + + + +
PathComponentShows
/Dashboard.vueKPIs, order health, top products, inventory shortages
/inventoryInventory.vueInventory list — warehouse & category filters
/ordersOrders.vueOrder list — warehouse, category, status & month filters
/demandDemand.vueDemand forecasts & trend
/spendingSpending.vueSpending summary, monthly & category breakdowns
/reportsReports.vueQuarterly & month-over-month reports
+
+
+ Orphaned view + views/Backlog.vue exists but is never registered as a route or linked from the nav. Backlog items instead surface inline, inside the Dashboard's “Inventory Shortages” table, opened through BacklogDetailModal.vue. +
+
+ +
+

API surface

+

All defined in server/main.py. Filters are optional query params; a value of all (or an omitted param) skips that filter.

+
+ + + + + + + + + + + + + + + + + +
ResourceEndpointFilters
InventoryGET /api/inventorywarehouse, category
GET /api/inventory/{id}
OrdersGET /api/orderswarehouse, category, status, month
GET /api/orders/{id}
DemandGET /api/demand
BacklogGET /api/backlog
DashboardGET /api/dashboard/summarywarehouse, category, status, month
SpendingGET /api/spending/summary
GET /api/spending/monthly
GET /api/spending/categories
GET /api/spending/transactions
ReportsGET /api/reports/quarterly
GET /api/reports/monthly-trends
+
+
+ Client calls with no matching route + client/src/api.js also defines getTasks, createTask, deleteTask, toggleTask, createPurchaseOrder and getPurchaseOrderByBacklogItem, targeting /api/tasks and /api/purchase-orders. Neither path is defined in server/main.py — those calls will 404 until a matching route is added. +
+
+ + + +
diff --git a/server/main.py b/server/main.py index a0c2d8c5a..c724568ca 100644 --- a/server/main.py +++ b/server/main.py @@ -1,11 +1,15 @@ +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, restock_orders app = FastAPI(title="Factory Inventory Management System") +# Fixed demo lead time — there's no supplier/lead-time data anywhere in the mock dataset +RESTOCK_LEAD_TIME_DAYS = 14 + # Quarter mapping for date filtering QUARTER_MAP = { 'Q1-2025': ['2025-01', '2025-02', '2025-03'], @@ -120,6 +124,39 @@ class CreatePurchaseOrderRequest(BaseModel): expected_delivery_date: str notes: Optional[str] = None +class RestockOrderItem(BaseModel): + item_sku: str + item_name: str + quantity: int + unit_cost: float + +class RestockOrderRequest(BaseModel): + items: List[RestockOrderItem] + budget: float + +class RestockOrder(BaseModel): + id: str + order_number: str + items: List[RestockOrderItem] + total_cost: float + budget: float + created_date: str + lead_time_days: int + expected_delivery_date: str + status: str + +class RestockRecommendation(BaseModel): + item_sku: str + item_name: str + category: str + warehouse: str + unit_cost: float + current_demand: int + forecasted_demand: int + trend: str + recommended_quantity: int + line_total: float + # API endpoints @app.get("/") def root(): @@ -179,6 +216,87 @@ def get_backlog(): result.append(item_dict) return result +@app.get("/api/restocking/recommendations", response_model=List[RestockRecommendation]) +def get_restock_recommendations( + budget: float, + warehouse: Optional[str] = None, + category: Optional[str] = None +): + """Recommend items to restock from demand forecasts, greedily filling the given budget""" + inventory_by_sku = {item["sku"]: item for item in apply_filters(inventory_items, warehouse, category)} + + candidates = [] + for forecast in demand_forecasts: + if forecast["trend"] == "decreasing": + continue + item = inventory_by_sku.get(forecast["item_sku"]) + if not item: + continue + shortfall = forecast["forecasted_demand"] - forecast["current_demand"] + if shortfall <= 0: + continue + candidates.append((forecast, item, shortfall)) + + # Increasing trend first, then by largest shortfall + candidates.sort(key=lambda c: (c[0]["trend"] != "increasing", -c[2])) + + recommendations = [] + remaining_budget = budget + for forecast, item, shortfall in candidates: + unit_cost = item["unit_cost"] + if remaining_budget < unit_cost: + continue + affordable_qty = int(remaining_budget // unit_cost) + quantity = min(shortfall, affordable_qty) + if quantity <= 0: + continue + line_total = round(quantity * unit_cost, 2) + remaining_budget -= line_total + + recommendations.append({ + "item_sku": forecast["item_sku"], + "item_name": forecast["item_name"], + "category": item["category"], + "warehouse": item["warehouse"], + "unit_cost": unit_cost, + "current_demand": forecast["current_demand"], + "forecasted_demand": forecast["forecasted_demand"], + "trend": forecast["trend"], + "recommended_quantity": quantity, + "line_total": line_total + }) + + return recommendations + +@app.post("/api/restocking/orders", response_model=RestockOrder) +def create_restock_order(request: RestockOrderRequest): + """Submit a restocking order built from the recommendations""" + if not request.items: + raise HTTPException(status_code=400, detail="Order must contain at least one item") + + total_cost = round(sum(item.quantity * item.unit_cost for item in request.items), 2) + created_date = datetime.now() + expected_delivery = created_date + timedelta(days=RESTOCK_LEAD_TIME_DAYS) + + order = { + "id": str(len(restock_orders) + 1), + "order_number": f"RSO-{created_date.year}-{len(restock_orders) + 1:04d}", + "items": [item.model_dump() for item in request.items], + "total_cost": total_cost, + "budget": request.budget, + "created_date": created_date.strftime("%Y-%m-%d"), + "lead_time_days": RESTOCK_LEAD_TIME_DAYS, + "expected_delivery_date": expected_delivery.strftime("%Y-%m-%d"), + "status": "Submitted" + } + restock_orders.append(order) + return order + +@app.get("/api/restocking/orders", response_model=List[RestockOrder]) +def get_restock_orders(): + """Get all submitted restocking orders""" + return restock_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..fd105fb8c 100644 --- a/server/mock_data.py +++ b/server/mock_data.py @@ -35,5 +35,8 @@ def load_json_file(filename): # Load purchase orders purchase_orders = load_json_file('purchase_orders.json') +# Restocking orders submitted via POST /api/restocking/orders — in-memory only, no backing JSON file +restock_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..8c7d79706 --- /dev/null +++ b/tests/backend/test_restocking.py @@ -0,0 +1,104 @@ +""" +Tests for restocking API endpoints. +""" +import pytest + + +class TestRestockingEndpoints: + """Test suite for restocking-related endpoints.""" + + def test_get_recommendations_within_budget(self, client): + """Test that recommendations never exceed the given budget.""" + response = client.get("/api/restocking/recommendations?budget=5000") + assert response.status_code == 200 + + data = response.json() + assert isinstance(data, list) + + total = sum(item["line_total"] for item in data) + assert total <= 5000 + + def test_get_recommendations_zero_budget(self, client): + """Test that a zero budget returns no recommendations.""" + response = client.get("/api/restocking/recommendations?budget=0") + assert response.status_code == 200 + assert response.json() == [] + + def test_recommendations_exclude_decreasing_trend(self, client): + """Test that items with decreasing demand are never recommended.""" + response = client.get("/api/restocking/recommendations?budget=1000000") + assert response.status_code == 200 + + data = response.json() + for item in data: + assert item["trend"] != "decreasing" + + def test_recommendations_respect_warehouse_filter(self, client): + """Test filtering recommendations by warehouse.""" + response = client.get("/api/restocking/recommendations?budget=1000000&warehouse=Tokyo") + assert response.status_code == 200 + + data = response.json() + for item in data: + assert item["warehouse"] == "Tokyo" + + def test_recommendations_respect_category_filter(self, client): + """Test filtering recommendations by category.""" + response = client.get("/api/restocking/recommendations?budget=1000000&category=Sensors") + assert response.status_code == 200 + + data = response.json() + for item in data: + assert item["category"].lower() == "sensors" + + def test_recommendation_fields(self, client): + """Test that recommendations have all required fields with correct types.""" + response = client.get("/api/restocking/recommendations?budget=1000000") + data = response.json() + assert len(data) > 0 + + required_fields = [ + "item_sku", "item_name", "category", "warehouse", "unit_cost", + "current_demand", "forecasted_demand", "trend", + "recommended_quantity", "line_total" + ] + for item in data: + for field in required_fields: + assert field in item, f"Missing field: {field}" + assert isinstance(item["recommended_quantity"], int) + assert item["recommended_quantity"] > 0 + + def test_place_order_creates_submitted_order(self, client): + """Test that placing a restock order adds it to the submitted orders list.""" + request_body = { + "items": [ + {"item_sku": "PCB-001", "item_name": "Single Layer PCB Assembly", "quantity": 10, "unit_cost": 24.99} + ], + "budget": 500 + } + response = client.post("/api/restocking/orders", json=request_body) + assert response.status_code == 200 + + order = response.json() + assert order["total_cost"] == pytest.approx(249.9) + assert order["lead_time_days"] == 14 + assert order["status"] == "Submitted" + assert "order_number" in order + assert "expected_delivery_date" in order + + # Confirm it shows up in the submitted orders list + list_response = client.get("/api/restocking/orders") + assert list_response.status_code == 200 + order_ids = [o["id"] for o in list_response.json()] + assert order["id"] in order_ids + + def test_place_order_rejects_empty_items(self, client): + """Test that an order with no items is rejected.""" + response = client.post("/api/restocking/orders", json={"items": [], "budget": 500}) + assert response.status_code == 400 + + def test_get_restock_orders_returns_list(self, client): + """Test that the submitted orders endpoint always returns a list.""" + response = client.get("/api/restocking/orders") + assert response.status_code == 200 + assert isinstance(response.json(), list)