diff --git a/CLAUDE.md b/CLAUDE.md
index d2086efa..af382bfd 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -46,6 +46,9 @@ npm install && npm run dev
**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
+## Code Style
+- Always document non-obvious logic changes with comments
+
## API Endpoints
- `GET /api/inventory` - Filters: warehouse, category
- `GET /api/orders` - Filters: warehouse, category, status, month
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..fd17fec7 100644
--- a/client/src/api.js
+++ b/client/src/api.js
@@ -102,5 +102,10 @@ export const api = {
async getPurchaseOrderByBacklogItem(backlogItemId) {
const response = await axios.get(`${API_BASE_URL}/purchase-orders/${backlogItemId}`)
return response.data
+ },
+
+ async createRestockOrder(payload) {
+ const response = await axios.post(`${API_BASE_URL}/orders`, payload)
+ return response.data
}
}
diff --git a/client/src/locales/en.js b/client/src/locales/en.js
index 03a58fe6..fae5869f 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,11 +107,13 @@ export default {
title: 'Orders',
description: 'View and manage customer orders',
allOrders: 'All Orders',
+ submittedOrders: 'Submitted Orders',
totalOrders: 'Total Orders',
totalRevenue: 'Total Revenue',
avgOrderValue: 'Avg Order Value',
onTimeDelivery: 'On-Time Delivery',
itemsCount: '{count} items',
+ leadTimeDays: '{days} days',
quantity: 'Qty',
table: {
orderNumber: 'Order Number',
@@ -125,7 +128,33 @@ export default {
totalValue: 'Total Value',
status: 'Status',
expectedDelivery: 'Expected Delivery',
- actualDelivery: 'Actual Delivery'
+ actualDelivery: 'Actual Delivery',
+ leadTime: 'Lead Time'
+ }
+ },
+
+ // Restocking
+ restocking: {
+ title: 'Restocking',
+ description: 'Set a budget and order recommended restock quantities from the demand forecast',
+ budgetLabel: 'Available Budget',
+ recommendationsTitle: 'Recommended Restock',
+ noRecommendations: 'No items fit within this budget. Increase the budget to see recommendations.',
+ totalCost: 'Total Cost',
+ remaining: 'Remaining Budget',
+ placeOrder: 'Place Order',
+ placingOrder: 'Placing order...',
+ orderPlaced: 'Restocking order {orderNumber} submitted successfully.',
+ viewInOrders: 'View in Orders',
+ table: {
+ sku: 'SKU',
+ itemName: 'Item Name',
+ currentDemand: 'Current Demand',
+ forecastedDemand: 'Forecasted Demand',
+ recommendedQty: 'Recommended Qty',
+ unitCost: 'Unit Cost',
+ lineTotal: 'Line Total',
+ trend: 'Trend'
}
},
@@ -204,6 +233,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..d99d9abd 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,11 +107,13 @@ export default {
title: '注文',
description: '顧客注文の表示と管理',
allOrders: 'すべての注文',
+ submittedOrders: '提出済み注文',
totalOrders: '総注文数',
totalRevenue: '総収益',
avgOrderValue: '平均注文額',
onTimeDelivery: '定時配達',
itemsCount: '{count}件',
+ leadTimeDays: '{days}日',
quantity: '数量',
table: {
orderNumber: '注文番号',
@@ -125,7 +128,33 @@ export default {
totalValue: '合計金額',
status: 'ステータス',
expectedDelivery: '予定配達日',
- actualDelivery: '実際の配達日'
+ actualDelivery: '実際の配達日',
+ leadTime: 'リードタイム'
+ }
+ },
+
+ // Restocking
+ restocking: {
+ title: '在庫補充',
+ description: '予算を設定し、需要予測から推奨される補充数量を発注します',
+ budgetLabel: '利用可能な予算',
+ recommendationsTitle: '推奨補充',
+ noRecommendations: 'この予算に収まる品目がありません。予算を増やすと推奨が表示されます。',
+ totalCost: '合計コスト',
+ remaining: '残り予算',
+ placeOrder: '発注する',
+ placingOrder: '発注中...',
+ orderPlaced: '在庫補充注文 {orderNumber} が正常に提出されました。',
+ viewInOrders: '注文で表示',
+ table: {
+ sku: 'SKU',
+ itemName: '品目名',
+ currentDemand: '現在の需要',
+ forecastedDemand: '予測需要',
+ recommendedQty: '推奨数量',
+ unitCost: '単価',
+ lineTotal: '小計',
+ trend: 'トレンド'
}
},
@@ -204,6 +233,7 @@ export default {
shipped: '出荷済み',
processing: '処理中',
backordered: 'バックオーダー',
+ submitted: '提出済み',
inStock: '在庫あり',
lowStock: '在庫僅少',
adequate: '適量'
diff --git a/client/src/main.js b/client/src/main.js
index 477c2d96..cea20394 100644
--- a/client/src/main.js
+++ b/client/src/main.js
@@ -5,6 +5,7 @@ import Dashboard from './views/Dashboard.vue'
import Inventory from './views/Inventory.vue'
import Orders from './views/Orders.vue'
import Demand from './views/Demand.vue'
+import Restocking from './views/Restocking.vue'
import Spending from './views/Spending.vue'
import Reports from './views/Reports.vue'
@@ -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..524fb3b0 100644
--- a/client/src/views/Orders.vue
+++ b/client/src/views/Orders.vue
@@ -29,7 +29,7 @@
@@ -45,7 +45,7 @@
-
+
{{ order.order_number }}
{{ translateCustomerName(order.customer) }}
@@ -74,6 +74,48 @@
+
+
+
+
+
+
+
+ {{ t('orders.table.orderNumber') }}
+ {{ t('orders.table.items') }}
+ {{ t('orders.table.totalValue') }}
+ {{ t('orders.table.orderDate') }}
+ {{ t('orders.table.expectedDelivery') }}
+ {{ t('orders.table.leadTime') }}
+
+
+
+
+ {{ order.order_number }}
+
+
+
+ {{ t('orders.itemsCount', { count: order.items.length }) }}
+
+
+
+ {{ translateProductName(item.name) }}
+ {{ t('orders.quantity') }}: {{ item.quantity }} @ {{ currencySymbol }}{{ item.unit_price }}
+
+
+
+
+ {{ currencySymbol }}{{ order.total_value.toLocaleString() }}
+ {{ formatDate(order.order_date) }}
+ {{ formatDate(order.expected_delivery) }}
+ {{ t('orders.leadTimeDays', { days: getLeadTimeDays(order) }) }}
+
+
+
+
+
@@ -129,6 +171,17 @@ export default {
loadOrders()
})
+ // Orders shown in the main "All Orders" table — submitted restock orders
+ // are broken out into their own section below.
+ const activeOrders = computed(() => {
+ return orders.value.filter(order => order.status !== 'Submitted')
+ })
+
+ // Submitted restock orders, rendered in a dedicated card.
+ const submittedOrders = computed(() => {
+ return orders.value.filter(order => order.status === 'Submitted')
+ })
+
const getOrdersByStatus = (status) => {
return orders.value.filter(order => order.status === status)
}
@@ -138,11 +191,20 @@ export default {
'Delivered': 'success',
'Shipped': 'info',
'Processing': 'warning',
- 'Backordered': 'danger'
+ 'Backordered': 'danger',
+ 'Submitted': 'submitted'
}
return statusMap[status] || 'info'
}
+ // Lead time in whole days between order placement and expected delivery.
+ // 86400000 ms = 1 day; Math.round smooths any DST/partial-day drift.
+ const getLeadTimeDays = (order) => {
+ return Math.round(
+ (new Date(order.expected_delivery) - new Date(order.order_date)) / 86400000
+ )
+ }
+
const formatDate = (dateString) => {
const { currentLocale } = useI18n()
const locale = currentLocale.value === 'ja' ? 'ja-JP' : 'en-US'
@@ -160,8 +222,11 @@ export default {
loading,
error,
orders,
+ activeOrders,
+ submittedOrders,
getOrdersByStatus,
getOrderStatusClass,
+ getLeadTimeDays,
formatDate,
currencySymbol,
translateProductName,
@@ -203,6 +268,16 @@ export default {
width: 120px;
}
+.col-lead-time {
+ width: 120px;
+}
+
+/* Submitted restock orders badge */
+.badge.submitted {
+ background: #e0e7ff;
+ color: #3730a3;
+}
+
/* 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..cdf6f024
--- /dev/null
+++ b/client/src/views/Restocking.vue
@@ -0,0 +1,364 @@
+
+
+
+
+
{{ t('common.loading') }}
+
{{ error }}
+
+
+
+
+
+
+
+
+
+
+ {{ t('restocking.noRecommendations') }}
+
+
+
+
+
+
+
+ {{ t('restocking.table.sku') }}
+ {{ t('restocking.table.itemName') }}
+ {{ t('restocking.table.recommendedQty') }}
+ {{ t('restocking.table.unitCost') }}
+ {{ t('restocking.table.lineTotal') }}
+ {{ t('restocking.table.trend') }}
+
+
+
+
+ {{ rec.sku }}
+ {{ translateProductName(rec.name) }}
+ {{ rec.quantity }}
+ {{ currencySymbol }}{{ rec.unit_cost.toLocaleString() }}
+ {{ currencySymbol }}{{ rec.lineTotal.toLocaleString() }}
+
+
+ {{ t(`trends.${rec.trend}`) }}
+
+
+
+
+
+
+
+
+
+ {{ t('restocking.totalCost') }}
+ {{ currencySymbol }}{{ totalCost.toLocaleString() }}
+
+
+ {{ t('restocking.remaining') }}
+ {{ currencySymbol }}{{ remaining.toLocaleString() }}
+
+
+
+
+
+
+
+ {{ submitting ? t('restocking.placingOrder') : t('restocking.placeOrder') }}
+
+
+
+ {{ t('restocking.orderPlaced', { orderNumber: placedOrderNumber }) }}
+
+ {{ t('restocking.viewInOrders') }}
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/architecture.html b/docs/architecture.html
new file mode 100644
index 00000000..c7f76126
--- /dev/null
+++ b/docs/architecture.html
@@ -0,0 +1,470 @@
+
+
+
+
+
+Factory Inventory Management — System Architecture
+
+
+
+
+
+
+
System Architecture
+
Factory Inventory Management System
+
A full-stack demo application for inventory tracking, order management, demand forecasting,
+ and spending analytics. Single-page Vue 3 frontend, FastAPI backend, and file-based mock
+ data — no database.
+
+ Vue 3 + Vite
+ Python · FastAPI
+ Pydantic
+ Axios
+ In-memory JSON data
+ REST API
+
+
+
+
+
+
+
+
+
+
+
+
+ Overview
+ A two-tier web app with a clear client / server split
+
+ The system is organized into a Vue 3 single-page application (port 3000) that
+ renders all UI and charts, and a FastAPI service (port 8001) that exposes a
+ read-focused REST API over sample data. There is no database — the backend loads JSON files
+ from disk into memory at startup and serves them through validated, filterable endpoints.
+ Communication is one direction at runtime: the browser calls the API over HTTP/JSON via Axios.
+
+
+
+
UI
+
Presentation
+
client/ · Vue 3 SPA
+
7 routed views 9 reusable components Custom SVG charts, i18n (EN/JA)
+
+
+
API
+
Application
+
server/ · FastAPI
+
REST endpoints + filtering Pydantic validation CORS for the dev frontend
+
+
+
DATA
+
Data
+
server/data/ · JSON
+
7 JSON datasets Loaded once at startup In-memory, read-only
+
+
+
+
+
+
+ Tech Stack
+ What each layer is built with
+
+
+
F
+
Frontend
+
client/
+
+ Vue 3 (Composition API)
+ Vite 5 dev server & build
+ vue-router 4 (history mode)
+ Axios HTTP client
+ Composables: filters, auth, i18n
+ Hand-rolled SVG charts
+
+
+
+
B
+
Backend
+
server/
+
+ Python ≥ 3.11
+ FastAPI web framework
+ Uvicorn ASGI server
+ Pydantic v2 models
+ CORS middleware
+
+
+
+
D
+
Data
+
server/data/
+
+ inventory.json
+ orders.json
+ demand_forecasts.json
+ backlog_items.json
+ purchase_orders.json
+ spending.json · transactions.json
+
+
+
+
T
+
Tooling
+
repo-wide
+
+ uv (Python env & deps)
+ npm (frontend deps)
+ pytest + FastAPI TestClient
+ .claude/ agents, commands, hooks
+
+
+
+
+
+
+
+ Architecture
+ How the tiers connect
+ Each box is a real layer in the codebase. Data is loaded bottom-up at startup,
+ and requests flow top-down at runtime.
+
+
+
+
+
+
+
Browser — Vue 3 SPA
+ localhost:3000
+
+
+
App.vue Layout, nav, FilterBar, ProfileMenu
+
Views (7) Dashboard, Inventory, Orders, Demand, Spending, Reports, Backlog
+
Components (9) Detail modals, filters, language switcher
+
Composables useFilters · useAuth · useI18n
+
+
+
+
+ ↓
+ api.js (Axios) — HTTP GET/POST with filter query params
+
+
+
+
+
+
FastAPI Service
+ localhost:8001
+
+
+
Route handlers main.py — /api/* endpoints
+
Filter helpers apply_filters() · filter_by_month()
+
Pydantic models Validate & shape responses
+
CORS middleware Allows the dev frontend origin
+
+
+
+
+ ↓
+ mock_data.py — imports in-memory Python lists/dicts
+
+
+
+
+
+
Data Layer — JSON files
+ server/data/ · read-only
+
+
+
load_json_file() Reads each dataset once at import time
+
7 datasets inventory, orders, demand, backlog, POs, spending, transactions
+
No persistence Changes don't survive restart
+
+
+
+
+
+
+
+
+ Data Flow
+ A filtered request, end to end
+ The four global filters — Time Period, Warehouse, Category, Order Status — live in a
+ shared useFilters composable and drive every data fetch. Here is the round trip when a
+ user changes a filter on the Dashboard.
+
+
+
+
User changes a filter
+
The FilterBar updates shared refs in useFilters.js
+ (selectedPeriod, selectedLocation, selectedCategory,
+ selectedStatus).
+
+
+
+
View reacts & requests data
+
The active view calls getCurrentFilters() and passes the result to a method on
+ api.js, e.g. api.getDashboardSummary(filters).
+
+
+
+
Axios builds the HTTP request
+
Non-"all" filters become query params and Axios issues
+ GET http://localhost:8001/api/dashboard/summary?warehouse=…&month=….
+
+
+
+
FastAPI filters in memory
+
The route handler runs apply_filters() (warehouse / category / status) and
+ filter_by_month() (month or quarter) over the in-memory lists.
+
+
+
+
Pydantic validates the response
+
Results are shaped/validated against models (InventoryItem, Order,
+ …) and serialized to JSON.
+
+
+
+
Vue renders
+
The view stores raw data in refs; computed properties derive metrics and
+ chart inputs, and the SVG charts & tables re-render reactively.
+
+
+
+
+
+
+ API Surface
+ Endpoints exposed by the backend
+ All filtering is done via optional query params: warehouse,
+ category, status, month. Inventory has no time dimension, so
+ it ignores status/month.
+
+ Method Path Purpose Filters
+
+ GET /api/inventoryInventory items warehouse, category
+ GET /api/inventory/{id}Single item (404 if missing) —
+ GET /api/ordersOrders warehouse, category, status, month
+ GET /api/orders/{id}Single order —
+ GET /api/demandDemand forecasts —
+ GET /api/backlogBacklog + has_purchase_order flag —
+ GET /api/dashboard/summaryAggregate KPIs all four
+ GET /api/spending/*summary · monthly · categories · transactions —
+ GET /api/reports/quarterlyQuarterly performance —
+ GET /api/reports/monthly-trendsMonth-over-month trends —
+ PLANNED /api/tasks, /api/purchase-ordersClient (api.js) already calls these; backend routes not yet implemented —
+
+
+
+ Note: the frontend's api.js defines methods for Tasks and
+ Purchase-Order endpoints (and a CreatePurchaseOrderRequest model exists in
+ main.py), but the matching backend routes are not yet wired up — the client is
+ slightly ahead of the server here.
+
+
+
+
+
+ Project Structure
+ Where things live
+
+inventory-management/
+├── client/ # Vue 3 + Vite frontend
+│ └── src/
+│ ├── App.vue # Root layout + global styles
+│ ├── main.js # App + vue-router setup
+│ ├── api.js # Axios API client (single source of HTTP calls)
+│ ├── views/ # 7 routed pages
+│ ├── components/ # 9 reusable UI components / modals
+│ ├── composables/ # useFilters, useAuth, useI18n
+│ ├── locales/ # en.js, ja.js
+│ └── utils/ # currency formatting
+├── server/ # FastAPI backend
+│ ├── main.py # Endpoints, models, filtering
+│ ├── mock_data.py # Loads JSON into memory
+│ └── data/ # 7 JSON datasets
+├── tests/ # pytest backend tests
+├── scripts/ # start.sh / stop.sh (macOS/Linux)
+└── .claude/ # agents, commands, hooks, skills, MCP
+
+
+
+
+
+
+ Generated architecture overview · Factory Inventory Management System ·
+ Frontend :3000 • Backend :8001 • API docs at /docs
+
+
+
+
diff --git a/server/data/demand_forecasts.json b/server/data/demand_forecasts.json
index e1b38838..3aadf5d6 100644
--- a/server/data/demand_forecasts.json
+++ b/server/data/demand_forecasts.json
@@ -6,7 +6,8 @@
"current_demand": 300,
"forecasted_demand": 450,
"trend": "increasing",
- "period": "Next 30 days"
+ "period": "Next 30 days",
+ "unit_cost": 12.50
},
{
"id": "2",
@@ -15,7 +16,8 @@
"current_demand": 150,
"forecasted_demand": 152,
"trend": "stable",
- "period": "Next 30 days"
+ "period": "Next 30 days",
+ "unit_cost": 34.75
},
{
"id": "3",
@@ -24,7 +26,8 @@
"current_demand": 500,
"forecasted_demand": 600,
"trend": "increasing",
- "period": "Next 30 days"
+ "period": "Next 30 days",
+ "unit_cost": 8.25
},
{
"id": "4",
@@ -33,7 +36,8 @@
"current_demand": 50,
"forecasted_demand": 35,
"trend": "decreasing",
- "period": "Next 30 days"
+ "period": "Next 30 days",
+ "unit_cost": 245.00
},
{
"id": "5",
@@ -42,7 +46,8 @@
"current_demand": 800,
"forecasted_demand": 950,
"trend": "increasing",
- "period": "Next 30 days"
+ "period": "Next 30 days",
+ "unit_cost": 15.99
},
{
"id": "6",
@@ -51,7 +56,8 @@
"current_demand": 120,
"forecasted_demand": 121,
"trend": "stable",
- "period": "Next 30 days"
+ "period": "Next 30 days",
+ "unit_cost": 62.50
},
{
"id": "7",
@@ -60,7 +66,8 @@
"current_demand": 250,
"forecasted_demand": 252,
"trend": "stable",
- "period": "Next 30 days"
+ "period": "Next 30 days",
+ "unit_cost": 18.99
},
{
"id": "8",
@@ -69,7 +76,8 @@
"current_demand": 180,
"forecasted_demand": 182,
"trend": "stable",
- "period": "Next 30 days"
+ "period": "Next 30 days",
+ "unit_cost": 89.50
},
{
"id": "9",
@@ -78,6 +86,7 @@
"current_demand": 95,
"forecasted_demand": 96,
"trend": "stable",
- "period": "Next 30 days"
+ "period": "Next 30 days",
+ "unit_cost": 47.00
}
]
diff --git a/server/main.py b/server/main.py
index a0c2d8c5..2334eaea 100644
--- a/server/main.py
+++ b/server/main.py
@@ -2,10 +2,14 @@
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")
+# Fixed delivery lead time (in days) applied to submitted restocking orders
+LEAD_TIME_DAYS = 14
+
# Quarter mapping for date filtering
QUARTER_MAP = {
'Q1-2025': ['2025-01', '2025-02', '2025-03'],
@@ -89,6 +93,7 @@ class DemandForecast(BaseModel):
forecasted_demand: int
trend: str
period: str
+ unit_cost: float
class BacklogItem(BaseModel):
id: str
@@ -120,6 +125,16 @@ class CreatePurchaseOrderRequest(BaseModel):
expected_delivery_date: str
notes: Optional[str] = None
+class RestockOrderItem(BaseModel):
+ sku: str
+ name: str
+ quantity: int
+ unit_price: float
+
+class CreateRestockOrderRequest(BaseModel):
+ items: List[RestockOrderItem]
+ warehouse: Optional[str] = None
+
# API endpoints
@app.get("/")
def root():
@@ -161,6 +176,43 @@ def get_order(order_id: str):
raise HTTPException(status_code=404, detail="Order not found")
return order
+@app.post("/api/orders", response_model=Order, status_code=201)
+def create_restock_order(request: CreateRestockOrderRequest):
+ """Submit a restocking order.
+
+ Builds a new order with the "Submitted" status, appends it to the in-memory
+ orders list (so GET /api/orders immediately reflects it), and returns it.
+ Note: data is in-memory only and resets when the server restarts.
+ """
+ now = datetime.now()
+
+ # Derive the next numeric id from existing orders, then format a restock-specific
+ # order number (RST- prefix distinguishes these from customer orders).
+ next_id = max((int(o["id"]) for o in orders), default=0) + 1
+ new_id = str(next_id)
+ order_number = f"RST-2025-{next_id:04d}"
+
+ items = [item.model_dump() for item in request.items]
+ total_value = round(sum(i["quantity"] * i["unit_price"] for i in items), 2)
+
+ new_order = {
+ "id": new_id,
+ "order_number": order_number,
+ "customer": "Internal Restock",
+ "items": items,
+ "status": "Submitted",
+ "order_date": now.isoformat(timespec="seconds"),
+ # Fixed lead time: expected delivery is LEAD_TIME_DAYS after the order date
+ "expected_delivery": (now + timedelta(days=LEAD_TIME_DAYS)).isoformat(timespec="seconds"),
+ "total_value": total_value,
+ "actual_delivery": None,
+ "warehouse": request.warehouse,
+ "category": None,
+ }
+
+ orders.append(new_order)
+ return new_order
+
@app.get("/api/demand", response_model=List[DemandForecast])
def get_demand_forecasts():
"""Get demand forecasts"""
@@ -194,9 +246,13 @@ def get_dashboard_summary(
filtered_orders = apply_filters(orders, warehouse, category, status)
filtered_orders = filter_by_month(filtered_orders, month)
+ # Submitted restocking orders are internal procurement, not customer orders,
+ # so they are excluded from the customer-facing dashboard metrics below.
+ customer_orders = [order for order in filtered_orders if order["status"] != "Submitted"]
+
total_inventory_value = sum(item["quantity_on_hand"] * item["unit_cost"] for item in filtered_inventory)
low_stock_items = len([item for item in filtered_inventory if item["quantity_on_hand"] <= item["reorder_point"]])
- pending_orders = len([order for order in filtered_orders if order["status"] in ["Processing", "Backordered"]])
+ pending_orders = len([order for order in customer_orders if order["status"] in ["Processing", "Backordered"]])
total_backlog_items = len(backlog_items)
return {
@@ -204,7 +260,7 @@ def get_dashboard_summary(
"low_stock_items": low_stock_items,
"pending_orders": pending_orders,
"total_backlog_items": total_backlog_items,
- "total_orders_value": sum(order["total_value"] for order in filtered_orders)
+ "total_orders_value": sum(order["total_value"] for order in customer_orders)
}
@app.get("/api/spending/summary")
diff --git a/tests/backend/test_orders.py b/tests/backend/test_orders.py
new file mode 100644
index 00000000..67813e0e
--- /dev/null
+++ b/tests/backend/test_orders.py
@@ -0,0 +1,92 @@
+"""
+Tests for order endpoints, including the restocking POST /api/orders endpoint.
+"""
+from datetime import datetime
+
+
+class TestDemandUnitCost:
+ """Demand forecasts must expose a unit_cost so restocking can budget against them."""
+
+ def test_demand_items_have_unit_cost(self, client):
+ """Every demand forecast item includes a non-negative numeric unit_cost."""
+ response = client.get("/api/demand")
+ assert response.status_code == 200
+
+ data = response.json()
+ assert len(data) > 0
+
+ for forecast in data:
+ assert "unit_cost" in forecast, f"{forecast['item_sku']} missing unit_cost"
+ assert isinstance(forecast["unit_cost"], (int, float))
+ assert forecast["unit_cost"] >= 0
+
+
+class TestCreateRestockOrder:
+ """Test suite for submitting restocking orders via POST /api/orders."""
+
+ def _payload(self):
+ return {
+ "items": [
+ {"sku": "WDG-001", "name": "Industrial Widget Type A", "quantity": 150, "unit_price": 12.50},
+ {"sku": "GSK-203", "name": "High-Temperature Gasket", "quantity": 100, "unit_price": 8.25}
+ ]
+ }
+
+ def test_create_restock_order_returns_201(self, client):
+ """Submitting a restock order returns 201 with a Submitted order."""
+ response = client.post("/api/orders", json=self._payload())
+ assert response.status_code == 201
+
+ order = response.json()
+ assert order["status"] == "Submitted"
+ assert order["customer"] == "Internal Restock"
+ assert order["order_number"].startswith("RST-")
+ assert len(order["items"]) == 2
+
+ def test_total_value_is_calculated(self, client):
+ """total_value equals the sum of quantity * unit_price across items."""
+ response = client.post("/api/orders", json=self._payload())
+ order = response.json()
+
+ expected_total = round(150 * 12.50 + 100 * 8.25, 2)
+ assert order["total_value"] == expected_total
+
+ def test_lead_time_is_14_days(self, client):
+ """expected_delivery is exactly 14 days after order_date."""
+ response = client.post("/api/orders", json=self._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_submitted_order_appears_in_get_orders(self, client):
+ """A submitted order is returned by GET /api/orders."""
+ created = client.post("/api/orders", json=self._payload()).json()
+
+ response = client.get("/api/orders")
+ assert response.status_code == 200
+
+ orders = response.json()
+ assert any(o["id"] == created["id"] for o in orders), \
+ "Submitted restock order should appear in GET /api/orders"
+
+ def test_submitted_order_filterable_by_status(self, client):
+ """The submitted order is returned when filtering orders by status=Submitted."""
+ client.post("/api/orders", json=self._payload())
+
+ response = client.get("/api/orders", params={"status": "Submitted"})
+ assert response.status_code == 200
+
+ submitted = response.json()
+ assert len(submitted) > 0
+ assert all(o["status"] == "Submitted" for o in submitted)
+
+ def test_submitted_order_excluded_from_dashboard_value(self, client):
+ """Internal restock orders must not inflate the dashboard's order value."""
+ before = client.get("/api/dashboard/summary").json()["total_orders_value"]
+
+ client.post("/api/orders", json=self._payload())
+
+ after = client.get("/api/dashboard/summary").json()["total_orders_value"]
+ assert after == before, "Submitted restock order should not change total_orders_value"