From 4120076cf95185750024f5e9c53e7e76a1dc52e6 Mon Sep 17 00:00:00 2001 From: Mukassir995 Date: Mon, 1 Jun 2026 14:01:58 +0530 Subject: [PATCH] Add restocking workflow with budget-based order placement - New /restocking route (Restocking.vue) lets users set a budget, review demand-forecast recommendations, and submit a restock order in one flow - POST /api/restock-orders backend endpoint creates a Submitted order in memory with auto-incremented order number and 14-day lead time - Orders view gains a Submitted stat card and a submitted-orders table - Full i18n coverage in en/ja locales; architecture doc added Co-Authored-By: Claude Sonnet 4.6 --- .claude/hooks/post-tool-use.sh | 0 CLAUDE.md | 27 ++- client/src/App.vue | 3 + client/src/api.js | 5 + client/src/locales/en.js | 27 +++ client/src/locales/ja.js | 27 +++ client/src/main.js | 2 + client/src/views/Orders.vue | 66 ++++- client/src/views/Restocking.vue | 347 +++++++++++++++++++++++++++ docs/architecture.html | 295 +++++++++++++++++++++++ scripts/start.sh | 0 scripts/stop.sh | 0 server/main.py | 65 +++++ tests/backend/test_restock_orders.py | 125 ++++++++++ 14 files changed, 985 insertions(+), 4 deletions(-) mode change 100755 => 100644 .claude/hooks/post-tool-use.sh create mode 100644 client/src/views/Restocking.vue create mode 100644 docs/architecture.html mode change 100755 => 100644 scripts/start.sh mode change 100755 => 100644 scripts/stop.sh create mode 100644 tests/backend/test_restock_orders.py diff --git a/.claude/hooks/post-tool-use.sh b/.claude/hooks/post-tool-use.sh old mode 100755 new mode 100644 diff --git a/CLAUDE.md b/CLAUDE.md index d2086efa..979d5f04 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,11 @@ # CLAUDE.md +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + Factory Inventory Management System Demo with GitHub integration - Full-stack application with Vue 3 frontend, Python FastAPI backend, and in-memory mock data (no database). +> Nested guidance: `client/CLAUDE.md` (Vue 3 patterns) and `server/CLAUDE.md` (FastAPI patterns) are auto-loaded when you work in those directories — consult them for subsystem detail. + ## Critical Tool Usage Rules ### Subagents @@ -38,20 +42,35 @@ uv run python main.py # Frontend cd client npm install && npm run dev + +# Tests (backend, 51 tests via pytest + FastAPI TestClient) +cd tests +uv run pytest -v +uv run pytest backend/test_inventory.py::TestInventoryEndpoints::test_get_all_inventory -v # single test ``` +On macOS/Linux, `./scripts/start.sh` and `./scripts/stop.sh` start/stop both servers. On Windows, run the manual commands above in separate terminals. + ## Key Patterns **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 +**Routing**: `client/src/main.js` defines vue-router routes — `/` Dashboard, `/inventory`, `/orders`, `/demand`, `/spending`, `/reports` +**Shared state (composables)**: `useFilters` (global filters), `useAuth` (auth/profile), `useI18n` (locale) +**i18n**: English/Japanese locales in `client/src/locales/{en,ja}.js`, toggled via `LanguageSwitcher.vue` +**Currency**: Format via `client/src/utils/currency.js` (don't hand-roll `toLocaleString` per component) ## API Endpoints +All routes are defined in `server/main.py`. Shared helpers: `apply_filters()` (warehouse/category/status) and `filter_by_month()` (month `2025-01` or quarter `Q1-2025` via `QUARTER_MAP`). - `GET /api/inventory` - Filters: warehouse, category +- `GET /api/inventory/{item_id}` - Single item (404 if missing) - `GET /api/orders` - Filters: warehouse, category, status, month +- `GET /api/orders/{order_id}` - Single order (404 if missing) - `GET /api/dashboard/summary` - All filters -- `GET /api/demand`, `/api/backlog` - No filters -- `GET /api/spending/*` - Summary, monthly, categories, transactions +- `GET /api/demand`, `/api/backlog` - No filters (backlog injects `has_purchase_order` flag) +- `GET /api/spending/*` - summary, monthly, categories, transactions +- `GET /api/reports/quarterly`, `/api/reports/monthly-trends` - Computed from orders ## Common Issues 1. Use unique keys in v-for (not `index`) - use `sku`, `month`, etc. @@ -59,6 +78,10 @@ npm install && npm run dev 3. Update Pydantic models when changing JSON data structure 4. Inventory filters don't support month (no time dimension) 5. Revenue goals: $800K/month single, $9.6M YTD all months +6. `client/src/api.js` calls `/api/tasks` (CRUD) and `/api/purchase-orders` (POST/GET) endpoints that are **not implemented** in `server/main.py` — add the backend routes before wiring up those features + +## Conventions +- Always document non-obvious logic changes with comments ## File Locations - Views: `client/src/views/*.vue` diff --git a/client/src/App.vue b/client/src/App.vue index c2da05a5..dd6849b1 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 11cb9db7..87304b78 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -33,6 +33,11 @@ export const api = { return response.data }, + async placeRestockOrder(payload) { + const response = await axios.post(`${API_BASE_URL}/restock-orders`, payload) + return response.data + }, + async getDemandForecasts() { const response = await axios.get(`${API_BASE_URL}/demand`) return response.data diff --git a/client/src/locales/en.js b/client/src/locales/en.js index 03a58fe6..67430a63 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' }, @@ -204,11 +205,37 @@ export default { shipped: 'Shipped', processing: 'Processing', backordered: 'Backordered', + submitted: 'Submitted', inStock: 'In Stock', lowStock: 'Low Stock', adequate: 'Adequate' }, + // Restocking + restocking: { + title: 'Restocking', + description: 'Set a budget and get restock recommendations from demand forecasts', + budgetLabel: 'Available Budget', + recommendations: 'Recommended Restock Items', + runningTotal: 'Order Total', + remaining: 'Remaining Budget', + placeOrder: 'Place Order', + placing: 'Placing Order...', + orderPlaced: 'Order {number} submitted successfully', + noRecommendations: 'No items fit within the selected budget', + submittedOrders: 'Submitted Orders', + leadTime: 'Lead Time', + leadTimeDays: '{days} days', + table: { + sku: 'SKU', + itemName: 'Item Name', + trend: 'Trend', + recommendedQty: 'Recommended Qty', + estUnitCost: 'Est. Unit Cost', + lineTotal: 'Line Total' + } + }, + // Trends trends: { increasing: 'increasing', diff --git a/client/src/locales/ja.js b/client/src/locales/ja.js index db33223a..0d2398d5 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: '在庫管理システム' }, @@ -204,11 +205,37 @@ export default { shipped: '出荷済み', processing: '処理中', backordered: 'バックオーダー', + submitted: '送信済み', inStock: '在庫あり', lowStock: '在庫僅少', adequate: '適量' }, + // Restocking + restocking: { + title: '補充', + description: '予算を設定し、需要予測から補充推奨を取得します', + budgetLabel: '利用可能な予算', + recommendations: '推奨補充品目', + runningTotal: '注文合計', + remaining: '残りの予算', + placeOrder: '注文する', + placing: '注文中...', + orderPlaced: '注文 {number} が正常に送信されました', + noRecommendations: '選択した予算内に収まる品目がありません', + submittedOrders: '送信済み注文', + leadTime: 'リードタイム', + leadTimeDays: '{days}日間', + table: { + sku: 'SKU', + itemName: '品目名', + trend: 'トレンド', + recommendedQty: '推奨数量', + estUnitCost: '推定単価', + lineTotal: '小計' + } + }, + // Trends trends: { increasing: '増加', diff --git a/client/src/main.js b/client/src/main.js index 477c2d96..3347ae01 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 7413f6e6..e394ea0b 100644 --- a/client/src/views/Orders.vue +++ b/client/src/views/Orders.vue @@ -25,6 +25,52 @@
{{ t('status.backordered') }}
{{ getOrdersByStatus('Backordered').length }}
+
+
{{ t('status.submitted') }}
+
{{ submittedOrders.length }}
+
+ + +
+
+

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

+
+
+ + + + + + + + + + + + + + + + + + + + + +
{{ t('orders.table.orderNumber') }}{{ t('orders.table.items') }}{{ t('orders.table.orderDate') }}{{ t('orders.table.expectedDelivery') }}{{ t('restocking.leadTime') }}{{ t('orders.table.totalValue') }}
{{ order.order_number }} +
+ + {{ t('orders.itemsCount', { count: order.items.length }) }} + +
+
+ {{ translateProductName(item.name) }} + {{ t('orders.quantity') }}: {{ item.quantity }} @ {{ currencySymbol }}{{ item.unit_price }} +
+
+
+
{{ formatDate(order.order_date) }}{{ formatDate(order.expected_delivery) }}{{ t('restocking.leadTimeDays', { days: leadTimeDays(order) }) }}{{ currencySymbol }}{{ order.total_value.toLocaleString() }}
+
@@ -95,6 +141,7 @@ export default { const loading = ref(true) const error = ref(null) const orders = ref([]) + const submittedOrders = ref([]) // Use shared filters const { @@ -109,7 +156,10 @@ export default { try { loading.value = true const filters = getCurrentFilters() - const fetchedOrders = await api.getOrders(filters) + const [fetchedOrders, submitted] = await Promise.all([ + api.getOrders(filters), + api.getOrders({ status: 'submitted' }) + ]) // Sort orders by order_date (earliest first) orders.value = fetchedOrders.sort((a, b) => { @@ -117,6 +167,9 @@ export default { const dateB = new Date(b.order_date) return dateA - dateB }) + + // Sort submitted orders newest first + submittedOrders.value = submitted.sort((a, b) => new Date(b.order_date) - new Date(a.order_date)) } catch (err) { error.value = 'Failed to load orders: ' + err.message } finally { @@ -138,11 +191,14 @@ export default { 'Delivered': 'success', 'Shipped': 'info', 'Processing': 'warning', - 'Backordered': 'danger' + 'Backordered': 'danger', + 'Submitted': 'info' } return statusMap[status] || 'info' } + const leadTimeDays = (o) => Math.round((new Date(o.expected_delivery) - new Date(o.order_date)) / 86400000) + const formatDate = (dateString) => { const { currentLocale } = useI18n() const locale = currentLocale.value === 'ja' ? 'ja-JP' : 'en-US' @@ -160,8 +216,10 @@ export default { loading, error, orders, + submittedOrders, getOrdersByStatus, getOrderStatusClass, + leadTimeDays, formatDate, currencySymbol, translateProductName, @@ -199,6 +257,10 @@ export default { width: 140px; } +.col-leadtime { + width: 110px; +} + .col-value { width: 120px; } diff --git a/client/src/views/Restocking.vue b/client/src/views/Restocking.vue new file mode 100644 index 00000000..a33d63d0 --- /dev/null +++ b/client/src/views/Restocking.vue @@ -0,0 +1,347 @@ + + + + + diff --git a/docs/architecture.html b/docs/architecture.html new file mode 100644 index 00000000..b5f575b9 --- /dev/null +++ b/docs/architecture.html @@ -0,0 +1,295 @@ + + + + + +Architecture · Factory Inventory Management System + + + +
+ +
+
System Architecture
+

Factory Inventory Management System

+

A full-stack demo application for inventory tracking, order management, demand forecasting, and spending analytics. A Vue 3 single-page app talks to a Python FastAPI service that serves filtered, validated data from in-memory JSON datasets — no database.

+
+ Vue 3 + Vite + Python FastAPI + In-memory JSON data + REST / JSON over HTTP + English / Japanese i18n +
+
+ + +
+

System Architecture — Three Tiers

+
+ +
+
+ Presentation — Browser + Vue 3 Composition API · Vite · vue-router · localhost:3000 +
+
+
ViewsDashboard, Inventory, Orders, Demand, Spending, Reports
+
ComponentsFilterBar, detail modals, ProfileMenu, LanguageSwitcher
+
ComposablesuseFilters, useAuth, useI18n (shared singleton state)
+
API clientsrc/api.js (axios)
+
+
+ +
+ + HTTP GET /api/* · query-param filters · JSON +
+ +
+
+ Application — API Service + Python FastAPI · Uvicorn · CORS (*) · localhost:8001 +
+
+
Route handlersserver/main.py — /api/inventory, /orders, /dashboard, /spending, /reports
+
Filter helpersapply_filters() · filter_by_month() + QUARTER_MAP
+
Pydantic modelsResponse validation & typing
+
+
+ +
+ + module import at startup · loaded once into memory +
+ +
+
+ Data — In-Memory Mock Store + server/mock_data.py · server/data/*.json +
+
+
JSON datasetsinventory, orders, demand, backlog, spending, transactions, purchase_orders
+
Loadermock_data.py reads files into Python lists/dicts
+
LifecycleRead-only at runtime; restart reloads from disk
+
+
+ +
+
+ + +
+

Technology Stack

+
+
+
Frontend
+
    +
  • Vue 3 — Composition API throughout
  • +
  • Vite 5 — dev server & build (port 3000)
  • +
  • vue-router 4 — 6 client routes
  • +
  • axios — HTTP client in api.js
  • +
  • Custom SVG charts & CSS Grid layouts
  • +
+
+
+
Backend
+
    +
  • FastAPI — REST endpoints
  • +
  • Uvicorn — ASGI server (port 8001)
  • +
  • Pydantic v2 — response models
  • +
  • CORS — open during development
  • +
  • Auto API docs at /docs
  • +
+
+
+
Data & Tooling
+
    +
  • JSON files — no database
  • +
  • uv — Python env & runner
  • +
  • pytest + TestClient — 51 backend tests
  • +
  • npm — frontend package manager
  • +
  • i18n locales: en.js, ja.js
  • +
+
+
+
+ + +
+

Request Data Flow

+
+
+

User filters

+

FilterBar updates shared refs in useFilters (Period, Warehouse, Category, Status).

+
+
+
+

API call

+

api.js builds query params and issues an axios GET to FastAPI.

+
+
+
+

Server filtering

+

apply_filters() and filter_by_month() narrow the in-memory lists.

+
+
+
+

Validation

+

Pydantic response_model validates and serializes the payload.

+
+
+
+

Reactive render

+

Raw data lands in refs; computed properties derive views & charts.

+
+
+
+ + +
+

API Endpoints

+ + + + + + + + + + + + + + + +
MethodPathFilters / Notes
GET/api/inventorywarehouse, category
GET/api/inventory/{item_id}single item — 404 if missing
GET/api/orderswarehouse, category, status, month/quarter
GET/api/orders/{order_id}single order — 404 if missing
GET/api/dashboard/summaryall four filters — aggregate metrics
GET/api/demandno filters
GET/api/backloginjects has_purchase_order flag
GET/api/spending/{summary|monthly|categories|transactions}no filters
GET/api/reports/{quarterly|monthly-trends}computed from orders
+
+ + +
+

Mock Data Layer

+
+
250
orders orders.json
+
32
inventory items inventory.json
+
56
transactions transactions.json
+
9
demand forecasts demand_forecasts.json
+
4
backlog items backlog_items.json
+
3
spending sections spending.json
+
+
+ + + +
+ + diff --git a/scripts/start.sh b/scripts/start.sh old mode 100755 new mode 100644 diff --git a/scripts/stop.sh b/scripts/stop.sh old mode 100755 new mode 100644 diff --git a/server/main.py b/server/main.py index a0c2d8c5..28db5674 100644 --- a/server/main.py +++ b/server/main.py @@ -2,6 +2,8 @@ from fastapi.middleware.cors import CORSMiddleware from typing import List, Optional from pydantic import BaseModel +from datetime import datetime, timedelta +from uuid import uuid4 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 +122,16 @@ class CreatePurchaseOrderRequest(BaseModel): expected_delivery_date: str notes: Optional[str] = None +class RestockOrderItem(BaseModel): + item_sku: str + item_name: str + quantity: int + unit_price: float + +class CreateRestockOrderRequest(BaseModel): + budget: float + items: List[RestockOrderItem] + # API endpoints @app.get("/") def root(): @@ -161,6 +173,59 @@ def get_order(order_id: str): raise HTTPException(status_code=404, detail="Order not found") return order +@app.post("/api/restock-orders", response_model=Order) +def create_restock_order(request: CreateRestockOrderRequest): + """Submit a restocking order; appends a 'Submitted' order to the in-memory orders list. + + Note: this is a demo store - submitted orders live in memory only and reset on + server restart. Lead time is a fixed 14 days (expected_delivery = order_date + 14d). + """ + if not request.items: + raise HTTPException(status_code=400, detail="No items to order") + + now = datetime.now() + expected = now + timedelta(days=14) + + # Generate the next order number from the existing ORD-2025-NNNN sequence + max_num = 0 + for o in orders: + number = o.get("order_number", "") + if number.startswith("ORD-2025-"): + try: + max_num = max(max_num, int(number.split("-")[-1])) + except ValueError: + pass + next_number = f"ORD-2025-{max_num + 1:04d}" + + # Map restock items (item_sku/item_name) to the existing order-item dict shape + items = [ + { + "sku": item.item_sku, + "name": item.item_name, + "quantity": item.quantity, + "unit_price": round(item.unit_price, 2), + } + for item in request.items + ] + # Compute total server-side rather than trusting the client + total_value = round(sum(i["quantity"] * i["unit_price"] for i in items), 2) + + new_order = { + "id": f"restock-{uuid4().hex[:8]}", + "order_number": next_number, + "customer": "Internal Restock", + "items": items, + "status": "Submitted", + "order_date": now.strftime("%Y-%m-%dT%H:%M:%S"), + "expected_delivery": expected.strftime("%Y-%m-%dT%H:%M:%S"), + "total_value": total_value, + "actual_delivery": None, + "warehouse": None, + "category": None, + } + orders.append(new_order) + return new_order + @app.get("/api/demand", response_model=List[DemandForecast]) def get_demand_forecasts(): """Get demand forecasts""" diff --git a/tests/backend/test_restock_orders.py b/tests/backend/test_restock_orders.py new file mode 100644 index 00000000..71ce413b --- /dev/null +++ b/tests/backend/test_restock_orders.py @@ -0,0 +1,125 @@ +""" +Tests for the restock-orders API endpoint (POST /api/restock-orders). +""" +from datetime import datetime + +import pytest + + +class TestRestockOrdersEndpoint: + """Test suite for submitting restocking orders.""" + + def _sample_payload(self): + """A valid restock order request payload.""" + return { + "budget": 50000, + "items": [ + { + "item_sku": "WDG-001", + "item_name": "Industrial Widget Type A", + "quantity": 150, + "unit_price": 42.5, + }, + { + "item_sku": "GSK-203", + "item_name": "High-Temperature Gasket", + "quantity": 100, + "unit_price": 12.0, + }, + ], + } + + def test_place_restock_order_success(self, client): + """Test submitting a valid restock order returns a Submitted order.""" + response = client.post("/api/restock-orders", json=self._sample_payload()) + assert response.status_code == 200 + + order = response.json() + # Core order shape (matches the Order model) + for field in ( + "id", + "order_number", + "customer", + "items", + "status", + "order_date", + "expected_delivery", + "total_value", + ): + assert field in order + + assert order["status"] == "Submitted" + assert order["customer"] == "Internal Restock" + assert order["order_number"].startswith("ORD-2025-") + + def test_restock_order_items_mapped_to_order_shape(self, client): + """Items must be stored in the order-item dict shape (sku/name/...).""" + response = client.post("/api/restock-orders", json=self._sample_payload()) + order = response.json() + + assert len(order["items"]) == 2 + for item in order["items"]: + # Stored as sku/name, NOT item_sku/item_name + assert "sku" in item + assert "name" in item + assert "quantity" in item + assert "unit_price" in item + assert "item_sku" not in item + assert "item_name" not in item + + def test_restock_order_total_value_calculation(self, client): + """total_value should be computed server-side from items.""" + payload = self._sample_payload() + response = client.post("/api/restock-orders", json=payload) + order = response.json() + + expected = sum(i["quantity"] * i["unit_price"] for i in payload["items"]) + assert abs(order["total_value"] - expected) < 0.01 + + def test_restock_order_lead_time_is_14_days(self, client): + """expected_delivery should be exactly 14 days after order_date.""" + response = client.post("/api/restock-orders", json=self._sample_payload()) + order = response.json() + + order_date = datetime.fromisoformat(order["order_date"]) + expected_delivery = datetime.fromisoformat(order["expected_delivery"]) + assert (expected_delivery - order_date).days == 14 + + def test_restock_order_appears_in_submitted_filter(self, client): + """A placed order is retrievable via GET /api/orders?status=submitted.""" + response = client.post("/api/restock-orders", json=self._sample_payload()) + placed = response.json() + + listing = client.get("/api/orders?status=submitted") + assert listing.status_code == 200 + data = listing.json() + numbers = [o["order_number"] for o in data] + assert placed["order_number"] in numbers + for o in data: + assert o["status"].lower() == "submitted" + + def test_restock_order_number_is_unique_and_increments(self, client): + """Two successive orders get distinct, sequential order numbers.""" + first = client.post("/api/restock-orders", json=self._sample_payload()).json() + second = client.post("/api/restock-orders", json=self._sample_payload()).json() + + assert first["order_number"] != second["order_number"] + first_num = int(first["order_number"].split("-")[-1]) + second_num = int(second["order_number"].split("-")[-1]) + assert second_num == first_num + 1 + + def test_restock_order_empty_items_rejected(self, client): + """Submitting with no items returns 400.""" + response = client.post( + "/api/restock-orders", json={"budget": 1000, "items": []} + ) + assert response.status_code == 400 + assert "detail" in response.json() + + def test_restock_order_missing_fields_validation(self, client): + """Malformed item (missing required fields) returns 422.""" + response = client.post( + "/api/restock-orders", + json={"budget": 1000, "items": [{"item_sku": "WDG-001"}]}, + ) + assert response.status_code == 422