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 }}
+
-

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

+

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

@@ -45,7 +49,7 @@ - +
{{ order.order_number }} {{ translateCustomerName(order.customer) }} @@ -74,6 +78,56 @@
+ +
+
+

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

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+
@@ -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 @@ + + + + + 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:

+
    +
  1. User sets a filterOne of 4 global filters — Time Period, Warehouse, Category, Order Status — updates shared refs in useFilters.js (singleton state).
  2. +
  3. View requests dataThe 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).
  4. +
  5. Axios issues the HTTP callOnly non-"all" filters are appended as query params; the request hits GET /api/... on the FastAPI service.
  6. +
  7. FastAPI filters in memoryapply_filters() narrows by warehouse / category / status; filter_by_month() resolves direct months and quarter ranges against order_date.
  8. +
  9. Pydantic validates the responseResults are serialized through response_model types (InventoryItem, Order, …) and returned as JSON.
  10. +
  11. Vue renders via computed stateRaw JSON lands in refs; computed properties derive metrics, chart series, and tables that update reactively.
  12. +
+
+ + +
+

API Endpoints

+ + + + + + + + + + + + + + +
MethodPathFilters / 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.

+
+
+
+ +
+ + + + 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"]