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..ae458fb2 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` (detailed Vue 3 patterns) and `server/CLAUDE.md` (detailed FastAPI patterns) hold deeper, directory-specific best practices. Read them when doing substantial frontend or backend work.
+
## Critical Tool Usage Rules
### Subagents
@@ -38,13 +42,25 @@ uv run python main.py
# Frontend
cd client
npm install && npm run dev
+
+# Frontend production build
+cd client && npm run build # output: client/dist/
+
+# Backend tests (pytest + FastAPI TestClient; deps in server/pyproject.toml)
+cd server && uv run pytest ../tests/backend -v
+cd server && uv run pytest ../tests/backend/test_inventory.py::test_name -v # single test
```
+On macOS/Linux, `./scripts/start.sh` and `./scripts/stop.sh` run both servers. On Windows, use the manual commands above.
+
## 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
+**Composables** (`client/src/composables/`): `useFilters` (global filter state + `getCurrentFilters()`), `useI18n` (translations), `useAuth` (auth state)
+**i18n**: English/Japanese via `useI18n` + `client/src/locales/{en,ja}.js`; `LanguageSwitcher` component. Add UI strings to both locale files
+**Routing**: 6 views via vue-router (`client/src/main.js`): `/` Dashboard, `/inventory`, `/orders`, `/demand`, `/spending`, `/reports`
## API Endpoints
- `GET /api/inventory` - Filters: warehouse, category
@@ -52,6 +68,8 @@ npm install && npm run dev
- `GET /api/dashboard/summary` - All filters
- `GET /api/demand`, `/api/backlog` - No filters
- `GET /api/spending/*` - Summary, monthly, categories, transactions
+- `GET /api/reports/quarterly`, `/api/reports/monthly-trends` - Reports view data
+- `GET /api/inventory/{item_id}`, `/api/orders/{order_id}` - Single item (404 if missing)
## Common Issues
1. Use unique keys in v-for (not `index`) - use `sku`, `month`, etc.
@@ -61,11 +79,18 @@ npm install && npm run dev
5. Revenue goals: $800K/month single, $9.6M YTD all months
## File Locations
-- Views: `client/src/views/*.vue`
+- Views: `client/src/views/*.vue` (one per route)
+- Components: `client/src/components/*.vue` (FilterBar, detail modals, ProfileMenu, LanguageSwitcher)
+- Composables: `client/src/composables/*.js`
+- Locales: `client/src/locales/{en,ja}.js`
- API Client: `client/src/api.js`
-- Backend: `server/main.py`, `server/mock_data.py`
+- Backend: `server/main.py` (endpoints), `server/mock_data.py` (JSON loader)
- Data: `server/data/*.json`
-- Styles: `client/src/App.vue`
+- Tests: `tests/backend/*.py`
+- Styles: `client/src/App.vue` (global)
+
+## Conventions
+- Always document non-obvious logic changes with comments
## Design System
- Colors: Slate/gray (#0f172a, #64748b, #e2e8f0)
diff --git a/client/src/App.vue b/client/src/App.vue
index c2da05a5..c91abd9c 100644
--- a/client/src/App.vue
+++ b/client/src/App.vue
@@ -22,6 +22,7 @@
{{ t('nav.demandForecast') }}
+ {{ t('nav.restocking') }}
Reports
diff --git a/client/src/api.js b/client/src/api.js
index 11cb9db7..714e0cfd 100644
--- a/client/src/api.js
+++ b/client/src/api.js
@@ -102,5 +102,17 @@ export const api = {
async getPurchaseOrderByBacklogItem(backlogItemId) {
const response = await axios.get(`${API_BASE_URL}/purchase-orders/${backlogItemId}`)
return response.data
+ },
+
+ async getRestockingRecommendations(budget) {
+ const params = new URLSearchParams()
+ params.append('budget', budget)
+ const response = await axios.get(`${API_BASE_URL}/restocking/recommendations?${params.toString()}`)
+ return response.data
+ },
+
+ async createRestockingOrder(orderData) {
+ const response = await axios.post(`${API_BASE_URL}/restocking/order`, orderData)
+ return response.data
}
}
diff --git a/client/src/locales/en.js b/client/src/locales/en.js
index 03a58fe6..c6778ef0 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'
},
@@ -106,6 +107,9 @@ export default {
title: 'Orders',
description: 'View and manage customer orders',
allOrders: 'All Orders',
+ submittedOrders: 'Submitted Orders',
+ leadTime: 'Lead Time',
+ leadTimeDays: '{days} days',
totalOrders: 'Total Orders',
totalRevenue: 'Total Revenue',
avgOrderValue: 'Avg Order Value',
@@ -188,6 +192,30 @@ export default {
}
},
+ // Restocking
+ restocking: {
+ title: 'Restocking',
+ description: 'Set a budget to get recommended restock items from the demand forecast',
+ budget: 'Available Budget',
+ recommendedItems: 'Recommended Items',
+ placeOrder: 'Place Order',
+ placing: 'Placing Order...',
+ totalCost: 'Total Cost',
+ remainingBudget: 'Remaining Budget',
+ itemCount: '{count} items',
+ noRecommendations: 'Increase the budget to see recommended items',
+ skippedNotice: '{count} forecasted item(s) skipped (no inventory cost data)',
+ orderPlaced: 'Restock order {orderNumber} created',
+ table: {
+ sku: 'SKU',
+ itemName: 'Item Name',
+ trend: 'Trend',
+ gapQuantity: 'Restock Qty',
+ unitCost: 'Unit Cost',
+ lineCost: 'Line Cost'
+ }
+ },
+
// Filters
filters: {
timePeriod: 'Time Period',
@@ -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 db33223a..19bd6a08 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: '在庫管理システム'
},
@@ -106,6 +107,9 @@ export default {
title: '注文',
description: '顧客注文の表示と管理',
allOrders: 'すべての注文',
+ submittedOrders: '送信済み注文',
+ leadTime: 'リードタイム',
+ leadTimeDays: '{days}日',
totalOrders: '総注文数',
totalRevenue: '総収益',
avgOrderValue: '平均注文額',
@@ -188,6 +192,30 @@ export default {
}
},
+ // Restocking
+ restocking: {
+ title: '補充',
+ description: '予算を設定して、需要予測から推奨される補充品目を取得します',
+ budget: '利用可能な予算',
+ recommendedItems: '推奨品目',
+ placeOrder: '発注する',
+ placing: '発注中...',
+ totalCost: '合計コスト',
+ remainingBudget: '残り予算',
+ itemCount: '{count}件',
+ noRecommendations: '予算を増やすと推奨品目が表示されます',
+ skippedNotice: '{count}件の予測品目をスキップしました(在庫コストデータなし)',
+ orderPlaced: '補充注文 {orderNumber} を作成しました',
+ table: {
+ sku: 'SKU',
+ itemName: '品目名',
+ trend: 'トレンド',
+ gapQuantity: '補充数量',
+ unitCost: '単価',
+ lineCost: '小計'
+ }
+ },
+
// Filters
filters: {
timePeriod: '期間',
@@ -204,6 +232,7 @@ export default {
shipped: '出荷済み',
processing: '処理中',
backordered: 'バックオーダー',
+ submitted: '送信済み',
inStock: '在庫あり',
lowStock: '在庫僅少',
adequate: '適量'
@@ -373,6 +402,7 @@ export default {
'Superior Manufacturing': 'スーペリアマニュファクチャリング',
'Cascade Manufacturing': 'カスケードマニュファクチャリング',
'Acme Manufacturing Corp': 'アクメ製造',
+ 'Internal Restock': '社内補充',
'TechBuild Industries': 'テックビルド工業',
'Advanced Components Inc': 'アドバンストコンポーネンツ',
'Premier Industries': 'プレミア工業',
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..1c2f05bd 100644
--- a/client/src/views/Orders.vue
+++ b/client/src/views/Orders.vue
@@ -25,11 +25,15 @@
{{ t('status.backordered') }}
{{ getOrdersByStatus('Backordered').length }}
+
+
{{ t('status.submitted') }}
+
{{ getOrdersByStatus('Submitted').length }}
+
@@ -45,7 +49,7 @@
-
+
{{ order.order_number }}
{{ translateCustomerName(order.customer) }}
@@ -74,6 +78,56 @@
+
+
+
+
+
+
+
+ {{ t('orders.table.orderNumber') }}
+ {{ t('orders.table.customer') }}
+ {{ t('orders.table.items') }}
+ {{ t('orders.table.status') }}
+ {{ t('orders.table.orderDate') }}
+ {{ t('orders.table.expectedDelivery') }}
+ {{ t('orders.table.totalValue') }}
+ {{ t('orders.leadTime') }}
+
+
+
+
+ {{ order.order_number }}
+ {{ translateCustomerName(order.customer) }}
+
+
+
+ {{ t('orders.itemsCount', { count: order.items.length }) }}
+
+
+
+ {{ translateProductName(item.name) }}
+ {{ t('orders.quantity') }}: {{ item.quantity }} @ {{ currencySymbol }}{{ item.unit_price }}
+
+
+
+
+
+
+ {{ t(`status.${order.status.toLowerCase()}`) }}
+
+
+ {{ formatDate(order.order_date) }}
+ {{ formatDate(order.expected_delivery) }}
+ {{ currencySymbol }}{{ order.total_value.toLocaleString() }}
+ {{ getLeadTimeDays(order) }}
+
+
+
+
+
@@ -133,16 +187,28 @@ export default {
return orders.value.filter(order => order.status === status)
}
+ const submittedOrders = computed(() => orders.value.filter(o => o.status === 'Submitted'))
+ const otherOrders = computed(() => orders.value.filter(o => o.status !== 'Submitted'))
+
const getOrderStatusClass = (status) => {
const statusMap = {
'Delivered': 'success',
'Shipped': 'info',
'Processing': 'warning',
- 'Backordered': 'danger'
+ 'Backordered': 'danger',
+ 'Submitted': 'info'
}
return statusMap[status] || 'info'
}
+ const getLeadTimeDays = (order) => {
+ const orderDate = new Date(order.order_date)
+ const deliveryDate = new Date(order.expected_delivery)
+ if (isNaN(orderDate.getTime()) || isNaN(deliveryDate.getTime())) return '-'
+ const days = Math.round((deliveryDate - orderDate) / 86400000)
+ return t('orders.leadTimeDays', { days })
+ }
+
const formatDate = (dateString) => {
const { currentLocale } = useI18n()
const locale = currentLocale.value === 'ja' ? 'ja-JP' : 'en-US'
@@ -160,8 +226,11 @@ export default {
loading,
error,
orders,
+ submittedOrders,
+ otherOrders,
getOrdersByStatus,
getOrderStatusClass,
+ getLeadTimeDays,
formatDate,
currencySymbol,
translateProductName,
@@ -203,6 +272,10 @@ export default {
width: 120px;
}
+.col-lead-time {
+ width: 100px;
+}
+
/* Items details styling */
.items-details {
position: relative;
diff --git a/client/src/views/Restocking.vue b/client/src/views/Restocking.vue
new file mode 100644
index 00000000..473e8474
--- /dev/null
+++ b/client/src/views/Restocking.vue
@@ -0,0 +1,328 @@
+
+
+
+
+
{{ t('common.loading') }}
+
{{ error }}
+
+
+
+
+
+ {{ formatCurrency(budget, currentCurrency) }}
+
+
+
+
+ {{ formatCurrency(0, currentCurrency) }}
+ {{ formatCurrency(maxBudget, currentCurrency) }}
+
+
+
+
+
+
+
+
{{ t('restocking.itemCount', { count: recommendation.item_count }) }}
+
{{ recommendation.item_count }}
+
+
+
{{ t('restocking.totalCost') }}
+
{{ formatCurrency(recommendation.total_cost, currentCurrency) }}
+
+
+
{{ t('restocking.remainingBudget') }}
+
{{ formatCurrency(Math.max(0, budget - recommendation.total_cost), currentCurrency) }}
+
+
+
+
+ {{ t('restocking.skippedNotice', { count: recommendation.skipped_no_inventory.length }) }}
+
+
+
{{ error }}
+
+
+
+
+
+ {{ t('restocking.noRecommendations') }}
+
+
+
+
+
+ {{ t('restocking.table.sku') }}
+ {{ t('restocking.table.itemName') }}
+ {{ t('restocking.table.trend') }}
+ {{ t('restocking.table.gapQuantity') }}
+ {{ t('restocking.table.unitCost') }}
+ {{ t('restocking.table.lineCost') }}
+
+
+
+
+ {{ item.sku }}
+ {{ item.name }}
+
+
+ {{ t(`trends.${item.trend}`) }}
+
+
+ {{ item.quantity.toLocaleString() }}
+ {{ formatCurrency(item.unit_cost, currentCurrency) }}
+ {{ formatCurrency(item.line_cost, currentCurrency) }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/architecture.html b/docs/architecture.html
new file mode 100644
index 00000000..95279f7e
--- /dev/null
+++ b/docs/architecture.html
@@ -0,0 +1,323 @@
+
+
+
+
+
+Architecture · Factory Inventory Management System
+
+
+
+
+ Factory Inventory Management System
+ System architecture reference — a full-stack demo for inventory, orders, demand forecasting, and spending analytics. Vue 3 single-page app backed by a Python FastAPI service serving in-memory mock data.
+
+
+
+
+
+
+ Overview
+ Two independent processes during development: a Vite dev server hosting the Vue 3 client on port 3000, and a Uvicorn/FastAPI process on port 8001. The client talks to the API exclusively over HTTP/JSON via a single Axios client. The backend holds no database — it loads JSON fixtures from server/data/ into memory at startup and filters them in Python on each request.
+
+
+
+
+ Tech Stack
+
+
+
Frontend · :3000
+
Vue 3 SPA
+
+ Vue 3 (Composition API)
+ Vite 5 (dev server & build)
+ Vue Router 4 — 6 views
+ Axios HTTP client
+ Composables for shared state
+ i18n: English / Japanese
+ Custom SVG charts (no chart lib)
+
+
+
+
Backend · :8001
+
FastAPI Service
+
+ Python 3.11+ / FastAPI
+ Uvicorn ASGI server
+ Pydantic v2 response models
+ CORS open (allow_origins=["*"])
+ In-memory filtering helpers
+ Auto docs at /docs
+
+
+
+
Data & Tooling
+
Mock Data + uv
+
+ JSON fixtures in server/data/
+ Loaded via mock_data.py
+ No database / no persistence
+ uv for Python env & deps
+ npm for client deps
+ pytest + FastAPI TestClient
+
+
+
+
+
+
+
+ System Architecture
+
+
+
+
Browser — Vue 3 SPA
+
Vite dev server · http://localhost:3000
+
+ views/*.vue
+ components/*.vue
+ composables (useFilters, useI18n, useAuth)
+
+
+
+
+ ↓
+ single Axios client — client/src/api.js
+
+
+
+
API Client Layer
+
Builds query params from active filters, calls http://localhost:8001/api
+
+
+
+ ↓
+ HTTP / JSON · GET (+ POST for writes)
+
+
+
+
FastAPI Application — main.py
+
Uvicorn · http://localhost:8001 · CORS open
+
+ route handlers
+ apply_filters()
+ filter_by_month()
+ Pydantic models
+
+
+
+
+ ↓
+ in-memory Python lists imported at startup
+
+
+
+
Mock Data Loader — mock_data.py
+
Reads JSON fixtures once on import
+
+ inventory.json
+ orders.json
+ demand_forecasts.json
+ backlog_items.json
+ spending.json
+ transactions.json
+ purchase_orders.json
+
+
+
+
+
+
+
+
+ Request Data Flow
+ How a filtered view renders — e.g. selecting a warehouse on the Orders page:
+
+ User sets a filter One of 4 global filters — Time Period, Warehouse, Category, Order Status — updates shared refs in useFilters.js (singleton state).
+ View requests data The view calls getCurrentFilters() and passes the result to an api.js method. Time Period maps to a month param (e.g. 2025-03 or Q1-2025).
+ Axios issues the HTTP call Only non-"all" filters are appended as query params; the request hits GET /api/... on the FastAPI service.
+ FastAPI filters in memory apply_filters() narrows by warehouse / category / status; filter_by_month() resolves direct months and quarter ranges against order_date.
+ Pydantic validates the response Results are serialized through response_model types (InventoryItem, Order, …) and returned as JSON.
+ Vue renders via computed state Raw JSON lands in refs; computed properties derive metrics, chart series, and tables that update reactively.
+
+
+
+
+
+ API Endpoints
+
+ Method Path Filters / Notes
+
+ GET /api/inventorywarehouse, category
+ GET /api/inventory/{item_id}single item · 404 if missing
+ GET /api/orderswarehouse, category, status, month
+ GET /api/orders/{order_id}single order · 404 if missing
+ GET /api/dashboard/summaryall 4 filters · computed KPIs
+ GET /api/demandnone
+ GET /api/backlognone · injects has_purchase_order
+ GET /api/spending/{summary|monthly|categories|transactions}none
+ GET /api/reports/quarterlyaggregates orders by quarter
+ GET /api/reports/monthly-trendsmonth-over-month aggregation
+
+
+ Known gap: the client (api.js) also calls /api/tasks (GET/POST/DELETE/PATCH) and /api/purchase-orders (GET/POST). These routes are not defined in main.py, so those features will currently return 404 against the live backend.
+
+
+
+
+ Project Structure
+ inventory-management/
+├── client/ # Vue 3 + Vite frontend (:3000)
+│ └── src/
+│ ├── views/ # Dashboard, Inventory, Orders, Demand, Spending, Reports
+│ ├── components/ # FilterBar, detail modals, ProfileMenu, LanguageSwitcher
+│ ├── composables/ # useFilters, useI18n, useAuth
+│ ├── locales/ # en.js, ja.js
+│ ├── api.js # single Axios API client
+│ └── main.js # app entry + Vue Router
+├── server/ # FastAPI backend (:8001)
+│ ├── main.py # endpoints, Pydantic models, filter helpers
+│ ├── mock_data.py # loads JSON fixtures into memory
+│ └── data/ # *.json fixtures (source of truth)
+├── tests/backend/ # pytest + FastAPI TestClient
+└── docs/ # this page
+
+
+
+
+ Key Patterns
+
+
+
Global filter singleton
+
Filter refs live at module scope in useFilters.js, so every view shares one reactive source of truth. getCurrentFilters() shapes them for the API.
+
+
+
Refs in, computed out
+
Raw API responses are stored in refs; all derived data (totals, KPIs, chart series) lives in cached computed properties.
+
+
+
Stateless in-memory filtering
+
The backend never mutates the loaded lists — each request filters copies. Restarting the server resets all data to the JSON fixtures.
+
+
+
Pydantic as the contract
+
Response models define the API shape. Changing a JSON field requires updating the matching Pydantic model.
+
+
+
+
+
+
+
+ Factory Inventory Management System — architecture reference · generated for the codebase walkthrough · demo application (no auth, no database)
+
+
+
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/data/demand_forecasts.json b/server/data/demand_forecasts.json
index e1b38838..186842fc 100644
--- a/server/data/demand_forecasts.json
+++ b/server/data/demand_forecasts.json
@@ -1,56 +1,56 @@
[
{
"id": "1",
- "item_sku": "WDG-001",
- "item_name": "Industrial Widget Type A",
- "current_demand": 300,
- "forecasted_demand": 450,
+ "item_sku": "SRV-301",
+ "item_name": "Micro Servo Motor",
+ "current_demand": 45,
+ "forecasted_demand": 95,
"trend": "increasing",
"period": "Next 30 days"
},
{
"id": "2",
- "item_sku": "BRG-102",
- "item_name": "Steel Bearing Assembly",
- "current_demand": 150,
- "forecasted_demand": 152,
- "trend": "stable",
+ "item_sku": "TMP-201",
+ "item_name": "Temperature Sensor Module",
+ "current_demand": 120,
+ "forecasted_demand": 250,
+ "trend": "increasing",
"period": "Next 30 days"
},
{
"id": "3",
- "item_sku": "GSK-203",
- "item_name": "High-Temperature Gasket",
- "current_demand": 500,
- "forecasted_demand": 600,
+ "item_sku": "ACC-206",
+ "item_name": "3-Axis Accelerometer",
+ "current_demand": 80,
+ "forecasted_demand": 180,
"trend": "increasing",
"period": "Next 30 days"
},
{
"id": "4",
- "item_sku": "MTR-304",
- "item_name": "Electric Motor 5HP",
- "current_demand": 50,
- "forecasted_demand": 35,
- "trend": "decreasing",
+ "item_sku": "PCB-001",
+ "item_name": "Single Layer PCB Assembly",
+ "current_demand": 300,
+ "forecasted_demand": 520,
+ "trend": "increasing",
"period": "Next 30 days"
},
{
"id": "5",
- "item_sku": "FLT-405",
- "item_name": "Oil Filter Cartridge",
- "current_demand": 800,
- "forecasted_demand": 950,
+ "item_sku": "MCU-402",
+ "item_name": "32-bit ARM Microcontroller",
+ "current_demand": 400,
+ "forecasted_demand": 700,
"trend": "increasing",
"period": "Next 30 days"
},
{
"id": "6",
- "item_sku": "VLV-506",
- "item_name": "Pressure Relief Valve",
- "current_demand": 120,
- "forecasted_demand": 121,
- "trend": "stable",
+ "item_sku": "LED-406",
+ "item_name": "LED Driver IC",
+ "current_demand": 250,
+ "forecasted_demand": 400,
+ "trend": "increasing",
"period": "Next 30 days"
},
{
@@ -64,20 +64,20 @@
},
{
"id": "8",
- "item_sku": "SNR-420",
- "item_name": "Temperature Sensor Module",
- "current_demand": 180,
- "forecasted_demand": 182,
+ "item_sku": "PRS-203",
+ "item_name": "Pressure Sensor Module",
+ "current_demand": 850,
+ "forecasted_demand": 850,
"trend": "stable",
"period": "Next 30 days"
},
{
"id": "9",
- "item_sku": "CTL-330",
- "item_name": "Logic Controller Board",
- "current_demand": 95,
- "forecasted_demand": 96,
- "trend": "stable",
+ "item_sku": "STP-303",
+ "item_name": "Stepper Motor NEMA 17",
+ "current_demand": 80,
+ "forecasted_demand": 60,
+ "trend": "decreasing",
"period": "Next 30 days"
}
]
diff --git a/server/main.py b/server/main.py
index a0c2d8c5..05ddde70 100644
--- a/server/main.py
+++ b/server/main.py
@@ -2,8 +2,12 @@
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
+# Fixed delivery lead time applied to submitted restocking orders
+RESTOCK_LEAD_TIME_DAYS = 14
+
app = FastAPI(title="Factory Inventory Management System")
# Quarter mapping for date filtering
@@ -120,6 +124,25 @@ class CreatePurchaseOrderRequest(BaseModel):
expected_delivery_date: str
notes: Optional[str] = None
+class RestockLineItem(BaseModel):
+ sku: str
+ name: str
+ quantity: int # the positive demand gap (forecasted - current)
+ unit_cost: float # from the matching inventory item
+ line_cost: float # quantity * unit_cost
+ trend: str # carried through for display/prioritization
+
+class RestockRecommendation(BaseModel):
+ budget: float
+ items: List[RestockLineItem]
+ total_cost: float
+ item_count: int
+ max_budget: float # cost to cover ALL positive gaps (slider max hint)
+ skipped_no_inventory: List[str] # demand SKUs with no inventory match (no unit_cost)
+
+class RestockOrderRequest(BaseModel):
+ items: List[RestockLineItem] # the recommended set submitted by the client
+
# API endpoints
@app.get("/")
def root():
@@ -304,6 +327,126 @@ def get_monthly_trends():
result.sort(key=lambda x: x['month'])
return result
+def _build_restock_candidates():
+ """Join demand forecasts to inventory and build priced restock candidates.
+
+ Returns (candidates, skipped) where each candidate covers the positive demand
+ gap (forecasted - current) for an item that exists in inventory. Items with a
+ non-positive gap are excluded; forecast SKUs with no inventory match (and thus
+ no unit_cost) are reported in `skipped` so the UI can surface them.
+ """
+ inv_by_sku = {item["sku"]: item for item in inventory_items}
+ candidates = []
+ skipped = []
+
+ for forecast in demand_forecasts:
+ gap = forecast["forecasted_demand"] - forecast["current_demand"]
+ if gap <= 0:
+ # Stable-at-zero or decreasing demand needs no restock
+ continue
+
+ inv = inv_by_sku.get(forecast["item_sku"])
+ if inv is None:
+ # No inventory record means no unit_cost to price the gap against
+ skipped.append(forecast["item_sku"])
+ continue
+
+ unit_cost = inv["unit_cost"]
+ candidates.append({
+ "sku": forecast["item_sku"],
+ "name": inv["name"],
+ "quantity": gap,
+ "unit_cost": unit_cost,
+ "line_cost": round(gap * unit_cost, 2),
+ "trend": forecast["trend"],
+ })
+
+ return candidates, skipped
+
+@app.get("/api/restocking/recommendations", response_model=RestockRecommendation)
+def get_restock_recommendations(budget: float = 0):
+ """Recommend items to restock that fit within the given budget.
+
+ Items are prioritized by 'increasing' trend first, then larger demand gap
+ first. We greedily select each item whose full line cost fits the remaining
+ budget (all-or-nothing per item, never a partial gap), skipping items that
+ don't fit and continuing so cheaper later items can still be included.
+ """
+ candidates, skipped = _build_restock_candidates()
+ max_budget = round(sum(c["line_cost"] for c in candidates), 2)
+
+ # Deterministic priority: increasing trend first, then largest gap first
+ candidates.sort(key=lambda c: (0 if c["trend"] == "increasing" else 1, -c["quantity"]))
+
+ selected = []
+ remaining = budget
+ for c in candidates:
+ if c["line_cost"] <= remaining:
+ selected.append(c)
+ remaining -= c["line_cost"]
+
+ return {
+ "budget": budget,
+ "items": selected,
+ "total_cost": round(sum(c["line_cost"] for c in selected), 2),
+ "item_count": len(selected),
+ "max_budget": max_budget,
+ "skipped_no_inventory": skipped,
+ }
+
+@app.post("/api/restocking/order", response_model=Order, status_code=201)
+def create_restock_order(request: RestockOrderRequest):
+ """Submit a restocking order: build a valid Order from the recommended items
+ and append it to the in-memory orders list (persists for the server's lifetime).
+ """
+ if not request.items:
+ raise HTTPException(status_code=400, detail="No items to order")
+
+ # Next sequential order number in the existing ORD-2025-#### scheme
+ max_seq = 0
+ for o in orders:
+ num = o.get("order_number", "")
+ if num.startswith("ORD-2025-"):
+ try:
+ max_seq = max(max_seq, int(num.rsplit("-", 1)[-1]))
+ except ValueError:
+ continue
+ order_number = f"ORD-2025-{max_seq + 1:04d}"
+
+ # Next numeric id (ids are stringified integers)
+ next_id = 1
+ numeric_ids = [int(o["id"]) for o in orders if str(o.get("id", "")).isdigit()]
+ if numeric_ids:
+ next_id = max(numeric_ids) + 1
+
+ # Order items use unit_price; restock line items carry unit_cost
+ order_items = [
+ {"sku": item.sku, "name": item.name, "quantity": item.quantity, "unit_price": item.unit_cost}
+ for item in request.items
+ ]
+ total_value = round(sum(item.line_cost for item in request.items), 2)
+
+ order_date = datetime.now()
+ expected_delivery = order_date + timedelta(days=RESTOCK_LEAD_TIME_DAYS)
+ fmt = "%Y-%m-%dT%H:%M:%S"
+
+ new_order = {
+ "id": str(next_id),
+ "order_number": order_number,
+ "customer": "Internal Restock",
+ "items": order_items,
+ "status": "Submitted",
+ "order_date": order_date.strftime(fmt),
+ "expected_delivery": expected_delivery.strftime(fmt),
+ "total_value": total_value,
+ "actual_delivery": None,
+ "warehouse": None,
+ "category": None,
+ }
+
+ orders.append(new_order)
+ return new_order
+
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8001)
diff --git a/server/package-lock.json b/server/package-lock.json
new file mode 100644
index 00000000..b280e8ba
--- /dev/null
+++ b/server/package-lock.json
@@ -0,0 +1,6 @@
+{
+ "name": "server",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {}
+}
diff --git a/tests/backend/test_misc_endpoints.py b/tests/backend/test_misc_endpoints.py
index 5a48fda8..e5de0b7f 100644
--- a/tests/backend/test_misc_endpoints.py
+++ b/tests/backend/test_misc_endpoints.py
@@ -52,8 +52,8 @@ def test_stable_demand_items_have_small_changes(self, client):
stable_items = [item for item in data if item["trend"].lower() == "stable"]
- # Should have at least 5 stable items
- assert len(stable_items) >= 5, f"Expected at least 5 stable items, found {len(stable_items)}"
+ # Should have at least one stable item to validate the invariant against
+ assert len(stable_items) >= 1, f"Expected at least 1 stable item, found {len(stable_items)}"
for item in stable_items:
current = item["current_demand"]
@@ -65,23 +65,21 @@ def test_stable_demand_items_have_small_changes(self, client):
assert percent_change < 2.0, \
f"Item {item['item_name']} has {percent_change:.2f}% change, expected < 2%"
- def test_demand_forecast_has_new_items(self, client):
- """Test that new demand forecast items exist."""
- response = client.get("/api/demand")
- data = response.json()
-
- # Check for the new items we added
- skus = [item["item_sku"] for item in data]
-
- # Should have Temperature Sensor Module and Logic Controller Board
- assert "SNR-420" in skus, "Missing Temperature Sensor Module"
- assert "CTL-330" in skus, "Missing Logic Controller Board"
-
- # Verify they are marked as stable
- for item in data:
- if item["item_sku"] in ["SNR-420", "CTL-330"]:
- assert item["trend"].lower() == "stable", \
- f"New item {item['item_name']} should have stable trend"
+ def test_demand_forecast_skus_match_inventory(self, client):
+ """Every demand forecast SKU must reference a real inventory item.
+
+ The restocking feature prices demand gaps against inventory.unit_cost, so a
+ forecast SKU with no matching inventory record cannot be costed. This guards
+ against regressing to a demand dataset whose SKUs don't exist in inventory.
+ """
+ demand = client.get("/api/demand").json()
+ inventory = client.get("/api/inventory").json()
+ inventory_skus = {item["sku"] for item in inventory}
+
+ assert len(demand) > 0
+ for forecast in demand:
+ assert forecast["item_sku"] in inventory_skus, \
+ f"Demand SKU {forecast['item_sku']} has no matching inventory item"
class TestBacklogEndpoints:
diff --git a/tests/backend/test_restocking.py b/tests/backend/test_restocking.py
new file mode 100644
index 00000000..ed620c6e
--- /dev/null
+++ b/tests/backend/test_restocking.py
@@ -0,0 +1,183 @@
+"""
+Tests for restocking API endpoints.
+
+Covers GET /api/restocking/recommendations (budget-aware recommendations derived
+from demand forecast gaps priced against inventory unit_cost) and
+POST /api/restocking/order (submits the recommended set as a new in-memory order).
+"""
+import re
+from datetime import datetime
+
+
+def _expected_candidates(client):
+ """Build the expected priced restock candidates from the live demand +
+ inventory data, mirroring the backend join logic (positive gaps only,
+ inventory match required)."""
+ demand = client.get("/api/demand").json()
+ inventory = client.get("/api/inventory").json()
+ inv_by_sku = {i["sku"]: i for i in inventory}
+
+ candidates = []
+ skipped = []
+ for f in demand:
+ gap = f["forecasted_demand"] - f["current_demand"]
+ if gap <= 0:
+ continue
+ inv = inv_by_sku.get(f["item_sku"])
+ if inv is None:
+ skipped.append(f["item_sku"])
+ continue
+ candidates.append({
+ "sku": f["item_sku"],
+ "quantity": gap,
+ "unit_cost": inv["unit_cost"],
+ "line_cost": round(gap * inv["unit_cost"], 2),
+ "trend": f["trend"],
+ })
+ return candidates, skipped
+
+
+class TestRestockingRecommendations:
+ """Test suite for GET /api/restocking/recommendations."""
+
+ def test_recommendations_response_structure(self, client):
+ """Recommendations return the documented shape."""
+ response = client.get("/api/restocking/recommendations?budget=10000")
+ assert response.status_code == 200
+
+ data = response.json()
+ for key in ("budget", "items", "total_cost", "item_count",
+ "max_budget", "skipped_no_inventory"):
+ assert key in data
+ assert isinstance(data["items"], list)
+ assert isinstance(data["skipped_no_inventory"], list)
+ assert data["item_count"] == len(data["items"])
+
+ def test_line_item_structure_and_cost(self, client):
+ """Each recommended line item is well-formed and line_cost is correct."""
+ data = client.get("/api/restocking/recommendations?budget=1000000000").json()
+ assert len(data["items"]) > 0
+
+ for item in data["items"]:
+ for key in ("sku", "name", "quantity", "unit_cost", "line_cost", "trend"):
+ assert key in item
+ assert isinstance(item["quantity"], int)
+ assert item["quantity"] > 0
+ assert isinstance(item["unit_cost"], (int, float))
+ assert abs(item["line_cost"] - round(item["quantity"] * item["unit_cost"], 2)) < 0.01
+
+ def test_total_cost_respects_budget(self, client):
+ """Total cost never exceeds the requested budget."""
+ for budget in (0, 2000, 5000, 30000):
+ data = client.get(f"/api/restocking/recommendations?budget={budget}").json()
+ assert data["total_cost"] <= budget + 0.01
+ assert abs(data["total_cost"] - sum(i["line_cost"] for i in data["items"])) < 0.01
+
+ def test_total_cost_capped_at_max_budget(self, client):
+ """A very large budget selects everything and caps total at max_budget."""
+ data = client.get("/api/restocking/recommendations?budget=1000000000").json()
+ assert data["max_budget"] > 0
+ assert abs(data["total_cost"] - data["max_budget"]) < 0.01
+
+ def test_max_budget_matches_all_positive_gaps(self, client):
+ """max_budget equals the cost to cover every positive, in-stock gap."""
+ candidates, skipped = _expected_candidates(client)
+ expected_max = round(sum(c["line_cost"] for c in candidates), 2)
+
+ data = client.get("/api/restocking/recommendations?budget=0").json()
+ assert abs(data["max_budget"] - expected_max) < 0.01
+ # budget 0 yields no items but still reports max_budget
+ assert data["item_count"] == 0
+ assert data["max_budget"] > 0
+ # skipped list matches expectation (empty when all demand SKUs are in stock)
+ assert sorted(data["skipped_no_inventory"]) == sorted(skipped)
+
+ def test_only_positive_gap_items_recommended(self, client):
+ """Stable-at-zero and decreasing-demand SKUs never appear."""
+ candidates, _ = _expected_candidates(client)
+ eligible_skus = {c["sku"] for c in candidates}
+
+ data = client.get("/api/restocking/recommendations?budget=1000000000").json()
+ returned_skus = {i["sku"] for i in data["items"]}
+ assert returned_skus == eligible_skus
+
+ def test_increasing_trend_prioritized(self, client):
+ """In the selected list, no 'increasing' item appears after a non-increasing one."""
+ data = client.get("/api/restocking/recommendations?budget=1000000000").json()
+ trends = [i["trend"] for i in data["items"]]
+ seen_non_increasing = False
+ for trend in trends:
+ if trend != "increasing":
+ seen_non_increasing = True
+ elif seen_non_increasing:
+ assert False, "increasing item ranked after a non-increasing item"
+
+ def test_recommendations_deterministic(self, client):
+ """The same budget always yields the same set of SKUs in the same order."""
+ a = client.get("/api/restocking/recommendations?budget=8000").json()
+ b = client.get("/api/restocking/recommendations?budget=8000").json()
+ assert [i["sku"] for i in a["items"]] == [i["sku"] for i in b["items"]]
+
+
+class TestRestockingOrder:
+ """Test suite for POST /api/restocking/order."""
+
+ def _recommended_items(self, client, budget=8000):
+ return client.get(f"/api/restocking/recommendations?budget={budget}").json()["items"]
+
+ def test_submit_order_success(self, client):
+ """Submitting a recommended set creates a valid Submitted order."""
+ items = self._recommended_items(client)
+ assert len(items) > 0
+
+ response = client.post("/api/restocking/order", json={"items": items})
+ assert response.status_code == 201
+
+ order = response.json()
+ assert order["status"] == "Submitted"
+ assert order["customer"] == "Internal Restock"
+ assert re.match(r"^ORD-2025-\d{4}$", order["order_number"])
+
+ # total_value equals the sum of submitted line costs
+ expected_total = round(sum(i["line_cost"] for i in items), 2)
+ assert abs(order["total_value"] - expected_total) < 0.01
+
+ # order items use the Order item shape (unit_price, not unit_cost)
+ assert len(order["items"]) == len(items)
+ for oi in order["items"]:
+ for key in ("sku", "name", "quantity", "unit_price"):
+ assert key in oi
+
+ def test_submit_order_lead_time_is_14_days(self, client):
+ """expected_delivery is exactly 14 days after order_date."""
+ items = self._recommended_items(client)
+ order = client.post("/api/restocking/order", json={"items": items}).json()
+
+ order_date = datetime.fromisoformat(order["order_date"])
+ expected_delivery = datetime.fromisoformat(order["expected_delivery"])
+ assert (expected_delivery - order_date).days == 14
+
+ def test_submit_empty_order_rejected(self, client):
+ """An order with no items returns 400."""
+ response = client.post("/api/restocking/order", json={"items": []})
+ assert response.status_code == 400
+ assert "detail" in response.json()
+
+ def test_submitted_order_appears_in_orders(self, client):
+ """A submitted order is retrievable via GET /api/orders?status=Submitted."""
+ before = len(client.get("/api/orders?status=Submitted").json())
+
+ items = self._recommended_items(client)
+ created = client.post("/api/restocking/order", json={"items": items}).json()
+
+ after = client.get("/api/orders?status=Submitted").json()
+ assert len(after) == before + 1
+ assert any(o["order_number"] == created["order_number"] for o in after)
+
+ def test_submitted_orders_have_unique_numbers(self, client):
+ """Sequential submissions generate distinct order numbers and ids."""
+ items = self._recommended_items(client)
+ first = client.post("/api/restocking/order", json={"items": items}).json()
+ second = client.post("/api/restocking/order", json={"items": items}).json()
+ assert first["order_number"] != second["order_number"]
+ assert first["id"] != second["id"]