diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 00000000..45ba25f4 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,125 @@ +# Factory Inventory Management — Architecture + +## System Overview + +A full-stack demo application for factory inventory tracking. No database — all data lives in JSON files loaded into memory at server startup. + +``` +┌─────────────────┐ HTTP/JSON ┌─────────────────┐ import ┌─────────────┐ +│ Vue 3 SPA │ ─────────────────> │ FastAPI │ ──────────────> │ JSON files │ +│ (port 3000) │ <───────────────── │ (port 8001) │ at startup │ server/data│ +└─────────────────┘ └─────────────────┘ └─────────────┘ + │ │ + │ │ + Vite dev server In-memory lists + Vue Router (7 views) Pydantic validation + Axios client List-comprehension filters +``` + +## Tech Stack + +| Layer | Technology | Purpose | +|------------|-------------------------------------|--------------------------------------| +| Frontend | Vue 3 (Composition API) | Reactive UI | +| Router | Vue Router 4 | Client-side navigation | +| HTTP | Axios | API calls | +| Build | Vite 5 | Dev server + bundler | +| Backend | FastAPI | REST API | +| Validation | Pydantic 2 | Response model enforcement | +| Server | Uvicorn | ASGI runtime | +| Data | JSON files (no DB) | Loaded once at import time | +| Testing | pytest + httpx + FastAPI TestClient | Backend only | + +## Frontend Structure + +``` +client/src/ +├── views/ 7 route-level pages +│ ├── Dashboard.vue KPI summary + charts +│ ├── Inventory.vue Stock levels by SKU +│ ├── Orders.vue Order list (250 records) +│ ├── Demand.vue Forecasts +│ ├── Backlog.vue Pending items +│ ├── Spending.vue Cost breakdown +│ └── Reports.vue Quarterly + monthly trends +├── components/ 8 modals + FilterBar + ProfileMenu + LanguageSwitcher +├── composables/ +│ ├── useFilters.js Shared filter state (warehouse, category, status, month) +│ ├── useAuth.js Auth state +│ └── useI18n.js en/ja locale switching +├── api.js Axios wrapper — builds query strings from filter state +└── App.vue Shell + global styles +``` + +## Backend Structure + +``` +server/ +├── main.py FastAPI app, 14 routes, Pydantic models, filter helpers +├── mock_data.py Loads all JSON → module-level lists at import time +├── generate_data.py Seed-data generator (dev utility) +└── data/ + ├── inventory.json 32 items + ├── orders.json 250 records + ├── demand_forecasts.json 9 records + ├── backlog_items.json 4 records + ├── spending.json summary + monthly + category + ├── transactions.json 56 records + └── purchase_orders.json empty +``` + +## API Surface + +| Endpoint | Filters | Returns | +|--------------------------------|-------------------------------------|-------------------| +| `GET /api/inventory` | warehouse, category | InventoryItem[] | +| `GET /api/inventory/{id}` | — | InventoryItem | +| `GET /api/orders` | warehouse, category, status, month | Order[] | +| `GET /api/orders/{id}` | — | Order | +| `GET /api/demand` | — | DemandForecast[] | +| `GET /api/backlog` | — | BacklogItem[] | +| `GET /api/dashboard/summary` | warehouse, category, status, month | aggregated dict | +| `GET /api/spending/summary` | — | dict | +| `GET /api/spending/monthly` | — | list | +| `GET /api/spending/categories` | — | list | +| `GET /api/spending/transactions`| — | list | +| `GET /api/reports/quarterly` | — | dict | +| `GET /api/reports/monthly-trends`| — | dict | + +## Data Flow — Filter Pipeline + +This is the core pattern. A single filter change propagates end-to-end: + +``` +1. User selects "Warehouse B" in FilterBar.vue + │ + ▼ +2. useFilters.js composable updates reactive ref + │ + ▼ +3. View component's watch() fires → calls api.getOrders(filters) + │ + ▼ +4. api.js builds query string: GET /api/orders?warehouse=Warehouse+B + │ + ▼ +5. main.py apply_filters() → [o for o in orders if o['warehouse'] == 'Warehouse B'] + │ + ▼ +6. Pydantic validates each item against Order model + │ + ▼ +7. JSON response → Axios → ref update → Vue re-renders +``` + +**Key insight:** filters are stateless on the server — every request re-filters the full in-memory list. No caching, no pagination. Fine for 250 records; would not scale. + +## Filtering Internals (`main.py`) + +- `apply_filters(items, warehouse, category, status)` — chained list comprehensions, case-insensitive on category/status +- `filter_by_month(items, month)` — supports quarter tokens (`Q1-2025` → expands to 3 month prefixes) via `QUARTER_MAP`, matched by substring against `order_date` +- `'all'` sentinel = no filter (checked both client and server side) + +## Data Loading + +`mock_data.py` runs at **import time** — every JSON file is read once when the server process starts. The data lives as module-level Python lists for the lifetime of the process. Editing a JSON file requires a server restart to take effect. diff --git a/CLAUDE.md b/CLAUDE.md index d2086efa..53640409 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,6 +42,8 @@ npm install && npm run dev ## Key Patterns +**Comments**: Always document non-obvious logic changes with comments + **Filter System**: 4 filters (Time Period, Warehouse, Category, Order Status) apply to all data via query params **Data Flow**: Vue filters → `client/src/api.js` → FastAPI → In-memory filtering → Pydantic validation → Computed properties **Reactivity**: Raw data in refs (`allOrders`, `inventoryItems`), derived data in computed properties diff --git a/client/src/App.vue b/client/src/App.vue index c2da05a5..b61aae15 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -16,6 +16,9 @@ {{ t('nav.orders') }} + + {{ t('nav.restocking') }} + {{ t('nav.finance') }} diff --git a/client/src/api.js b/client/src/api.js index 11cb9db7..4c8a5b65 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -102,5 +102,20 @@ 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 placeRestockingOrder(budget, items) { + const response = await axios.post(`${API_BASE_URL}/restocking/orders`, { budget, items }) + return response.data + }, + + async getSubmittedRestockingOrders() { + 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 03a58fe6..db904af1 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' }, diff --git a/client/src/locales/ja.js b/client/src/locales/ja.js index db33223a..60224453 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: '在庫管理システム' }, diff --git a/client/src/main.js b/client/src/main.js index 477c2d96..8884eea6 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 7413f6e6..c1b4c66f 100644 --- a/client/src/views/Orders.vue +++ b/client/src/views/Orders.vue @@ -27,6 +27,38 @@ +
+
+

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

+
+
+ + + + + + + + + + + + + + + + + + + + + +
Order #ItemsTotal ValueLead TimeExpected DeliveryStatus
{{ order.order_number }}{{ order.items.length }} item(s){{ currencySymbol }}{{ order.total_value.toLocaleString() }}{{ order.lead_time_days }} days{{ formatDate(order.expected_delivery) }} + {{ order.status }} +
+
+
+

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

@@ -95,6 +127,16 @@ export default { const loading = ref(true) const error = ref(null) const orders = ref([]) + const submittedOrders = ref([]) + + const loadSubmittedOrders = async () => { + try { + submittedOrders.value = await api.getSubmittedRestockingOrders() + } catch (err) { + // Restocking orders are supplementary; don't block the main view on failure + submittedOrders.value = [] + } + } // Use shared filters const { @@ -127,6 +169,7 @@ export default { // Watch for filter changes and reload data watch([selectedPeriod, selectedLocation, selectedCategory, selectedStatus], () => { loadOrders() + loadSubmittedOrders() }) const getOrdersByStatus = (status) => { @@ -153,13 +196,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 00000000..83f65b71 --- /dev/null +++ b/client/src/views/Restocking.vue @@ -0,0 +1,305 @@ + + + + + diff --git a/server/main.py b/server/main.py index a0c2d8c5..24c4ddac 100644 --- a/server/main.py +++ b/server/main.py @@ -2,6 +2,7 @@ from fastapi.middleware.cors import CORSMiddleware from typing import List, Optional from pydantic import BaseModel +from datetime import datetime, timedelta from mock_data import inventory_items, orders, demand_forecasts, backlog_items, spending_summary, monthly_spending, category_spending, recent_transactions, purchase_orders app = FastAPI(title="Factory Inventory Management System") @@ -120,6 +121,44 @@ class CreatePurchaseOrderRequest(BaseModel): expected_delivery_date: str notes: Optional[str] = None +class RestockingRecommendation(BaseModel): + sku: str + name: str + quantity: int + unit_cost: float + line_total: float + trend: str + priority: int + +class PlaceRestockingOrderRequest(BaseModel): + budget: float + items: List[dict] + +class SubmittedRestockingOrder(BaseModel): + id: str + order_number: str + items: List[dict] + total_value: float + budget: float + order_date: str + expected_delivery: str + lead_time_days: int + status: str + +# In-memory storage for restocking orders placed via the Restocking tab +submitted_restocking_orders: list = [] +_restocking_order_seq: int = 0 + +# Most demand-forecast SKUs don't exist in inventory.json; this table covers the gap +DEFAULT_UNIT_COSTS = { + "WDG-001": 24.50, "BRG-102": 89.00, "GSK-203": 12.25, + "MTR-304": 450.00, "FLT-405": 8.75, "VLV-506": 67.50, + "SNR-420": 34.00, "CTL-330": 125.00, +} + +# Supplier readiness: high-demand items ship fastest +LEAD_TIME_BY_TREND = {"increasing": 7, "stable": 14, "decreasing": 21} + # API endpoints @app.get("/") def root(): @@ -304,6 +343,77 @@ def get_monthly_trends(): result.sort(key=lambda x: x['month']) return result + +def _resolve_unit_cost(sku: str) -> float: + for item in inventory_items: + if item["sku"] == sku: + return item["unit_cost"] + return DEFAULT_UNIT_COSTS.get(sku, 50.0) + +@app.get("/api/restocking/recommendations", response_model=List[RestockingRecommendation]) +def get_restocking_recommendations(budget: float = 50000.0): + """ + Greedy budget allocation: sort demand items by priority (trend + demand growth), + then fill the budget top-down. Returns only items that fit. + """ + # Trend is the primary signal; demand growth breaks ties + trend_weight = {"increasing": 3, "stable": 2, "decreasing": 1} + candidates = [] + for d in demand_forecasts: + sku = d["item_sku"] + qty = d["forecasted_demand"] + cost = _resolve_unit_cost(sku) + growth = d["forecasted_demand"] - d["current_demand"] + candidates.append({ + "sku": sku, + "name": d["item_name"], + "quantity": qty, + "unit_cost": cost, + "line_total": round(qty * cost, 2), + "trend": d["trend"], + "priority": trend_weight.get(d["trend"], 1) * 1000 + growth, + }) + + candidates.sort(key=lambda c: c["priority"], reverse=True) + + selected = [] + spent = 0.0 + for c in candidates: + if spent + c["line_total"] <= budget: + selected.append(c) + spent += c["line_total"] + return selected + +@app.post("/api/restocking/orders", response_model=SubmittedRestockingOrder) +def place_restocking_order(req: PlaceRestockingOrderRequest): + global _restocking_order_seq + if not req.items: + raise HTTPException(status_code=400, detail="Cannot place an order with no items") + + total = round(sum(i["line_total"] for i in req.items), 2) + # Lead time follows the slowest item so the whole shipment arrives together + lead = max(LEAD_TIME_BY_TREND.get(i.get("trend", "stable"), 14) for i in req.items) + + _restocking_order_seq += 1 + now = datetime.now() + order = { + "id": f"rst-{_restocking_order_seq}", + "order_number": f"RST-{now.year}-{_restocking_order_seq:04d}", + "items": req.items, + "total_value": total, + "budget": req.budget, + "order_date": now.isoformat(timespec="seconds"), + "expected_delivery": (now + timedelta(days=lead)).isoformat(timespec="seconds"), + "lead_time_days": lead, + "status": "Submitted", + } + submitted_restocking_orders.append(order) + return order + +@app.get("/api/restocking/orders", response_model=List[SubmittedRestockingOrder]) +def list_restocking_orders(): + return submitted_restocking_orders + if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8001) diff --git a/tests/backend/test_restocking.py b/tests/backend/test_restocking.py new file mode 100644 index 00000000..595e89c1 --- /dev/null +++ b/tests/backend/test_restocking.py @@ -0,0 +1,169 @@ +""" +Tests for restocking API endpoints. +""" +import pytest +from main import submitted_restocking_orders + + +class TestRestockingRecommendations: + """Test suite for GET /api/restocking/recommendations.""" + + def test_get_recommendations_default_budget(self, client): + """Test getting recommendations with default budget.""" + response = client.get("/api/restocking/recommendations") + assert response.status_code == 200 + + data = response.json() + assert isinstance(data, list) + assert len(data) > 0 + + first = data[0] + assert "sku" in first + assert "name" in first + assert "quantity" in first + assert "unit_cost" in first + assert "line_total" in first + assert "trend" in first + assert "priority" in first + + def test_recommendations_respect_budget(self, client): + """Test that total never exceeds the budget.""" + budget = 20000 + response = client.get(f"/api/restocking/recommendations?budget={budget}") + assert response.status_code == 200 + + data = response.json() + total = sum(item["line_total"] for item in data) + assert total <= budget + + def test_recommendations_priority_ordering(self, client): + """Test that results are sorted by priority descending.""" + response = client.get("/api/restocking/recommendations?budget=200000") + data = response.json() + + priorities = [item["priority"] for item in data] + assert priorities == sorted(priorities, reverse=True) + + def test_recommendations_increasing_trend_first(self, client): + """Test that increasing-trend items win over stable/decreasing.""" + response = client.get("/api/restocking/recommendations?budget=200000") + data = response.json() + + # Priority encodes trend weight in the thousands place; increasing >= 3000 + increasing_priorities = [r["priority"] for r in data if r["trend"] == "increasing"] + other_priorities = [r["priority"] for r in data if r["trend"] != "increasing"] + if increasing_priorities and other_priorities: + assert min(increasing_priorities) > max(other_priorities) + + def test_recommendations_tiny_budget_empty(self, client): + """Test that an unrealistically small budget yields no recommendations.""" + response = client.get("/api/restocking/recommendations?budget=100") + assert response.status_code == 200 + assert response.json() == [] + + def test_recommendation_line_total_calculation(self, client): + """Test that line_total = quantity * unit_cost.""" + response = client.get("/api/restocking/recommendations?budget=100000") + data = response.json() + + for item in data: + expected = item["quantity"] * item["unit_cost"] + assert abs(item["line_total"] - expected) < 0.01 + + def test_recommendation_data_types(self, client): + """Test that numeric fields have proper types.""" + response = client.get("/api/restocking/recommendations?budget=100000") + data = response.json() + + for item in data: + assert isinstance(item["quantity"], int) + assert isinstance(item["unit_cost"], (int, float)) + assert isinstance(item["line_total"], (int, float)) + assert isinstance(item["priority"], int) + assert item["quantity"] > 0 + assert item["unit_cost"] > 0 + + +class TestPlaceRestockingOrder: + """Test suite for POST /api/restocking/orders.""" + + @pytest.fixture(autouse=True) + def _reset(self): + """In-memory storage persists across tests; wipe before each.""" + submitted_restocking_orders.clear() + yield + submitted_restocking_orders.clear() + + def _sample_items(self): + return [ + {"sku": "WDG-001", "name": "Widget", "quantity": 100, + "unit_cost": 24.5, "line_total": 2450.0, "trend": "increasing", "priority": 3150}, + {"sku": "BRG-102", "name": "Bearing", "quantity": 50, + "unit_cost": 89.0, "line_total": 4450.0, "trend": "stable", "priority": 2002}, + ] + + def test_place_order_success(self, client): + """Test placing a restocking order.""" + payload = {"budget": 10000, "items": self._sample_items()} + response = client.post("/api/restocking/orders", json=payload) + assert response.status_code == 200 + + order = response.json() + assert order["order_number"].startswith("RST-") + assert order["status"] == "Submitted" + assert order["total_value"] == 6900.0 + assert order["budget"] == 10000 + assert "T" in order["order_date"] + assert "T" in order["expected_delivery"] + + def test_place_order_lead_time_is_slowest_item(self, client): + """Lead time follows the slowest item (stable=14 > increasing=7).""" + payload = {"budget": 10000, "items": self._sample_items()} + response = client.post("/api/restocking/orders", json=payload) + order = response.json() + assert order["lead_time_days"] == 14 + + def test_place_order_all_increasing_fast_lead(self, client): + """All increasing-trend items gives 7-day lead.""" + items = [{"sku": "X", "name": "X", "quantity": 1, "unit_cost": 1.0, + "line_total": 1.0, "trend": "increasing", "priority": 3000}] + response = client.post("/api/restocking/orders", json={"budget": 100, "items": items}) + assert response.json()["lead_time_days"] == 7 + + def test_place_order_empty_items_rejected(self, client): + """Test that empty item list returns 400.""" + response = client.post("/api/restocking/orders", json={"budget": 1000, "items": []}) + assert response.status_code == 400 + assert "detail" in response.json() + + def test_place_order_appears_in_list(self, client): + """Test that placed order appears in GET /api/restocking/orders.""" + client.post("/api/restocking/orders", json={"budget": 5000, "items": self._sample_items()}) + + response = client.get("/api/restocking/orders") + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["total_value"] == 6900.0 + + def test_multiple_orders_sequential_numbers(self, client): + """Test that order numbers increment.""" + r1 = client.post("/api/restocking/orders", json={"budget": 5000, "items": self._sample_items()}) + r2 = client.post("/api/restocking/orders", json={"budget": 5000, "items": self._sample_items()}) + + # IDs are different even though the sequence counter is module-global + assert r1.json()["id"] != r2.json()["id"] + + response = client.get("/api/restocking/orders") + assert len(response.json()) == 2 + + +class TestListRestockingOrders: + """Test suite for GET /api/restocking/orders.""" + + def test_list_empty_initially(self, client): + """Test that list is empty when no orders placed.""" + submitted_restocking_orders.clear() + response = client.get("/api/restocking/orders") + assert response.status_code == 200 + assert response.json() == []