diff --git a/client/package.json b/client/package.json index 58375dc8f..7ca85b4ff 100644 --- a/client/package.json +++ b/client/package.json @@ -15,5 +15,8 @@ "devDependencies": { "@vitejs/plugin-vue": "^5.0.4", "vite": "^5.2.0" + }, + "allowScripts": { + "esbuild@0.21.5": true } } diff --git a/client/src/App.vue b/client/src/App.vue index c2da05a5c..dd6849b1c 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 11cb9db70..5103b70b2 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -102,5 +102,15 @@ export const api = { async getPurchaseOrderByBacklogItem(backlogItemId) { const response = await axios.get(`${API_BASE_URL}/purchase-orders/${backlogItemId}`) return response.data + }, + + async getRestockOrders() { + const response = await axios.get(`${API_BASE_URL}/restock-orders`) + return response.data + }, + + async createRestockOrder(orderData) { + const response = await axios.post(`${API_BASE_URL}/restock-orders`, orderData) + return response.data } } diff --git a/client/src/locales/en.js b/client/src/locales/en.js index 03a58fe6e..eae6e5091 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' }, @@ -188,6 +189,45 @@ export default { } }, + // Restocking + restocking: { + title: 'Restocking', + description: 'Set a budget and order recommended items from the demand forecast', + budgetTitle: 'Available Budget', + budgetHelp: 'Drag to set how much you can spend on restocking', + recommendations: 'Recommended Restock Items', + itemsRecommended: 'Items Recommended', + totalCost: 'Total Cost', + budgetRemaining: 'Budget Remaining', + longestLeadTime: 'Longest Lead Time', + placeOrder: 'Place Order', + placingOrder: 'Placing Order...', + orderPlaced: 'Restock order {orderNumber} submitted successfully.', + viewInOrders: 'View in Orders', + orderFailed: 'Failed to place restock order: {message}', + noRecommendations: 'No items fit within this budget. Increase the budget to see recommendations.', + noShortfall: 'No forecasted shortfalls - nothing needs restocking.', + overBudget: 'Over budget', + submittedOrders: 'Submitted Orders', + noSubmittedOrders: 'No restock orders submitted yet.', + notFiltered: 'Not affected by filters', + leadTime: 'Lead Time', + eta: 'Estimated Arrival', + daysLead: '{days} days', + statusSubmitted: 'Submitted', + table: { + sku: 'SKU', + itemName: 'Item Name', + currentDemand: 'Current', + forecastedDemand: 'Forecast', + shortfall: 'Shortfall', + unitCost: 'Unit Cost', + quantity: 'Order Qty', + lineTotal: 'Line Total', + leadTime: 'Lead Time' + } + }, + // Filters filters: { timePeriod: 'Time Period', diff --git a/client/src/locales/ja.js b/client/src/locales/ja.js index db33223ac..13902e27f 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: '在庫管理システム' }, @@ -188,6 +189,45 @@ export default { } }, + // Restocking + restocking: { + title: '補充発注', + description: '予算を設定し、需要予測から推奨品目を発注します', + budgetTitle: '利用可能予算', + budgetHelp: 'ドラッグして補充に使える金額を設定してください', + recommendations: '推奨補充品目', + itemsRecommended: '推奨品目数', + totalCost: '合計金額', + budgetRemaining: '残り予算', + longestLeadTime: '最長リードタイム', + placeOrder: '発注する', + placingOrder: '発注中...', + orderPlaced: '補充発注 {orderNumber} を送信しました。', + viewInOrders: '注文一覧で確認', + orderFailed: '補充発注に失敗しました: {message}', + noRecommendations: 'この予算内に収まる品目はありません。予算を増やしてください。', + noShortfall: '予測不足はありません。補充は不要です。', + overBudget: '予算超過', + submittedOrders: '送信済み発注', + noSubmittedOrders: '送信済みの補充発注はありません。', + notFiltered: 'フィルタの影響を受けません', + leadTime: 'リードタイム', + eta: '到着予定', + daysLead: '{days}日', + statusSubmitted: '送信済み', + table: { + sku: 'SKU', + itemName: '品目名', + currentDemand: '現在', + forecastedDemand: '予測', + shortfall: '不足数', + unitCost: '単価', + quantity: '発注数', + lineTotal: '小計', + leadTime: 'リードタイム' + } + }, + // Filters filters: { timePeriod: '期間', diff --git a/client/src/main.js b/client/src/main.js index 477c2d966..cea203940 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 7413f6e66..5ef3d48c0 100644 --- a/client/src/views/Orders.vue +++ b/client/src/views/Orders.vue @@ -27,6 +27,60 @@ +
+
+

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

+ {{ t('restocking.notFiltered') }} +
+
{{ restockError }}
+
+ {{ t('restocking.noSubmittedOrders') }} +
+
+ + + + + + + + + + + + + + + + + + + + + + + +
{{ t('orders.table.orderNumber') }}{{ t('orders.table.items') }}{{ t('orders.table.status') }}{{ t('orders.table.orderDate') }}{{ t('restocking.leadTime') }}{{ t('restocking.eta') }}{{ t('orders.table.totalValue') }}
{{ order.order_number }} +
+ + {{ t('orders.itemsCount', { count: order.items.length }) }} + +
+
+ {{ item.item_name }} + + {{ t('orders.quantity') }}: {{ item.quantity }} @ {{ currencySymbol }}{{ item.unit_cost }} + · {{ t('restocking.daysLead', { days: item.lead_time_days }) }} + +
+
+
+
+ {{ t('restocking.statusSubmitted') }} + {{ formatDate(order.order_date) }}{{ t('restocking.daysLead', { days: order.max_lead_time_days }) }}{{ formatDate(order.expected_delivery) }}{{ currencySymbol }}{{ order.total_cost.toLocaleString() }}
+
+
+

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

@@ -96,6 +150,11 @@ export default { const error = ref(null) const orders = ref([]) + // Restock orders use isolated state - a failure here must not blank the + // whole page, and they are deliberately not affected by the shared filters + const restockOrders = ref([]) + const restockError = ref(null) + // Use shared filters const { selectedPeriod, @@ -124,7 +183,18 @@ export default { } } - // Watch for filter changes and reload data + const loadRestockOrders = async () => { + try { + restockError.value = null + restockOrders.value = await api.getRestockOrders() + } catch (err) { + restockError.value = 'Failed to load submitted orders: ' + err.message + } + } + + // Watch for filter changes and reload data. Restock orders are intentionally + // excluded - they have no warehouse, category, customer or matching status, + // so any active filter would empty the section and look like a bug. watch([selectedPeriod, selectedLocation, selectedCategory, selectedStatus], () => { loadOrders() }) @@ -153,13 +223,18 @@ export default { }) } - onMounted(loadOrders) + onMounted(() => { + loadOrders() + loadRestockOrders() + }) return { t, loading, error, orders, + restockOrders, + restockError, getOrdersByStatus, getOrderStatusClass, formatDate, @@ -172,6 +247,29 @@ export default { diff --git a/docs/architecture.html b/docs/architecture.html new file mode 100644 index 000000000..ec8b08104 --- /dev/null +++ b/docs/architecture.html @@ -0,0 +1,694 @@ + + + + + +Architecture — Factory Inventory Management System + + + +
+ +
+
System Documentation
+

Factory Inventory Management System

+

+ A single-page Vue 3 dashboard backed by a read-only FastAPI service. All business data is + loaded from JSON files into Python memory at import time — there is no database, no ORM, + and no persistence layer. Filtering happens as list comprehensions over in-memory dictionaries. +

+
+ Vue 3.4 · Composition API + FastAPI · Pydantic v2 + 14 read endpoints + 7 JSON data files + 40 backend tests + Demo / local only +
+
+ +
+

System Architecture

+ +
+ +
+
+ Presentation — Vue 3 SPA + client/ · Vite dev server :3000 +
+
+
+
App.vue
+
Shell: top nav, FilterBar, router-view, global modals
+
+
+
6 routed views
+
Dashboard, Inventory, Orders, Demand, Spending, Reports
+
+
+
9 components
+
FilterBar, detail modals, profile menu, language switcher
+
+
+
3 composables
+
useFilters, useI18n, useAuth — module-scope singletons
+
+
+
+ +
+
+
api.js · axios · URLSearchParams
+
+
+ +
+
+ API — FastAPI + server/main.py · uvicorn :8001 +
+
+
+
Route handlers
+
14 GET routes; optional query params default to None
+
+
+
apply_filters()
+
warehouse, category, status — skips the value all
+
+
+
filter_by_month()
+
Substring match on order_date; QUARTER_MAP expands Q1–Q4
+
+
+
Pydantic models
+
6 models used as response_model for validation on the way out
+
+
+
+ +
+
+
import-time load · module-level lists
+
+
+ +
+
+ Data — in-memory JSON + server/mock_data.py · server/data/*.json +
+
+
+
Read once at startup
+
mock_data.py opens each file and binds the result to a module global
+
+
+
Mutable but not durable
+
Nothing writes back; a restart discards any in-process change
+
+
+
No indexes
+
Every filter is a full linear scan — fine at this record count
+
+
+
+ +
+
+ +
+

Technology Stack

+
+ + + + + + + + + + + + + + + + +
LayerTechnologyVersionRole
FrontendVue^3.4.21Composition API throughout; no Options API components
Frontendvue-router^4.3.0createWebHistory, 6 flat routes, no guards or lazy loading
Frontendaxios^1.6.7HTTP client; base URL hardcoded to localhost:8001
BuildVite^5.2.0Dev server on port 3000; @vitejs/plugin-vue
BackendFastAPI≥0.110Routing, query-param parsing, OpenAPI docs at /docs
BackendPydantic≥2.5Response models — outbound validation only
Backenduvicorn≥0.24ASGI server, bound to 0.0.0.0:8001
RuntimePython≥3.11Managed by uv; venv at server/.venv
Testingpytest + TestClient≥8.040 tests across 3 files in tests/backend
ToolingMCP serversPlaywright (browser testing), GitHub (repo operations)
+
+

+ There is no state-management library, no TypeScript, no CSS framework, and no charting dependency. + Shared state is plain module-scope ref()s; charts are hand-written SVG inside the views. +

+
+ +
+

Data Flow — a filter change, end to end

+
    +
  1. +

    User changes a select in the filter bar

    +

    v-model writes straight into a shared ref — the component owns no local copy. + client/src/components/FilterBar.vue

    +
  2. +
  3. +

    The shared ref updates for every consumer at once

    +

    The four filter refs live at module scope, outside useFilters(), so all callers + receive the same instances. This singleton pattern is what keeps views in sync without a store. + client/src/composables/useFilters.js

    +
  4. +
  5. +

    The active view's watcher fires

    +

    Each view watches only the filters it honors — Dashboard watches all four, + Inventory watches just location and category, since inventory has no time dimension. + views/Dashboard.vue:676 · views/Inventory.vue:169

    +
  6. +
  7. +

    Filters are renamed for the wire

    +

    getCurrentFilters() maps UI names to API names: + selectedLocation becomes warehouse, selectedPeriod becomes month. + composables/useFilters.js

    +
  8. +
  9. +

    api.js builds the query string

    +

    Every method drops params equal to all before appending, so a default filter + is simply an absent param rather than a sentinel the server must interpret. + client/src/api.js

    +
  10. +
  11. +

    FastAPI filters the in-memory lists

    +

    Handlers chain apply_filters() then filter_by_month(). Both rebind a local + name to a new list comprehension, so the module-level source data is never mutated. + server/main.py:33 · server/main.py:17

    +
  12. +
  13. +

    Pydantic validates the response

    +

    The response_model shapes and type-checks each record on the way out. This is the + one place a JSON-file edit that breaks the schema will surface — as a 500, not a silent pass-through. + server/main.py

    +
  14. +
  15. +

    The view assigns to refs; computed properties do the rest

    +

    Raw responses land in refs (allOrders, inventoryItems); every derived + figure — totals, status counts, chart geometry — is a cached computed that re-evaluates only when its + dependencies change. + client/src/views/*.vue

    +
  16. +
+
+ +
+

API Surface

+
+ + + + + + + + + + + + + + + + + + + + +
EndpointFilters acceptedReturns
GET/Service name and version
GET/api/inventorywarehouse, categoryInventoryItem[]
GET/api/inventory/{item_id}InventoryItem, else 404
GET/api/orderswarehouse, category, status, monthOrder[]
GET/api/orders/{order_id}Order, else 404
GET/api/demandDemandForecast[]
GET/api/backlogBacklogItem[] with has_purchase_order
GET/api/dashboard/summarywarehouse, category, status, month5 aggregate figures
GET/api/spending/summarySpending totals object
GET/api/spending/monthlyMonthly spending series
GET/api/spending/categoriesSpending by category
GET/api/spending/transactionsRecent transactions
GET/api/reports/quarterlyPer-quarter revenue and fulfillment rate
GET/api/reports/monthly-trendsMonth-over-month order count and revenue
+
+

+ Every route is a GET. The service exposes no way to create, update, or delete anything — + which is consistent with data that is reloaded from disk on every restart. +

+
+ +
+

Data Layer

+
+ + + + + + + + + + + + + +
FileRecordsBound toNotes
orders.json250ordersThe only dataset with a usable time dimension
transactions.json56recent_transactionsFeeds the Spending view
inventory.json32inventory_itemsNo date field, so month filters cannot apply
demand_forecasts.json9demand_forecastsServed unfiltered
backlog_items.json4backlog_itemsServed unfiltered
spending.json3 keysspending_summary, monthly_spending, category_spendingOne file split across three globals
purchase_orders.json0purchase_ordersEmpty; see findings below
+
+ +
+
+

Filter semantics

+
    +
  • Warehouse: exact match — San Francisco, London, Tokyo
  • +
  • Category and status: case-insensitive match
  • +
  • Month: substring match against order_date
  • +
  • Quarters expand through QUARTER_MAP before matching
  • +
  • The literal all is treated as no filter at both ends
  • +
+
+
+

Localization

+
    +
  • English and Japanese dictionaries in src/locales
  • +
  • Locale persists to localStorage under app-locale
  • +
  • Currency is derived from locale, not chosen separately
  • +
  • JPY conversion uses a hardcoded rate of 150
  • +
  • Missing Japanese keys fall back to English
  • +
+
+
+
+ +
+

Findings

+

+ Three gaps surfaced while tracing the client against the server. Each was confirmed against the + running application rather than inferred from the source alone. +

+ +
+

Six client API methods target endpoints the server does not define

+

+ api.js exposes getTasks, createTask, deleteTask, + toggleTask, createPurchaseOrder, and getPurchaseOrderByBacklogItem, + which call /api/tasks and /api/purchase-orders. Neither path exists in + main.py, whose 14 routes are all GET. +

+

+ The four task methods are live: App.vue calls getTasks() on mount, so every page + load issues a request that 404s. The failure is caught and written to the console, and the UI falls back to + the hardcoded task list in useAuth.js — so the feature looks like it works while the write path + silently does nothing. The two purchase-order methods have no callers at all. +

+ curl localhost:8001/api/tasks → 404  ·  App.vue:91 +
+ +
+

Backlog.vue is unreachable

+

+ The view exists at client/src/views/Backlog.vue (152 lines) but is registered in no route in + main.js, linked from no router-link in App.vue, and imported nowhere. + Vite will not include it in a build, and no navigation path reaches it. +

+

+ Backlog data does still surface in the UI, but by a different route entirely — the Dashboard renders it + through BacklogDetailModal.vue. +

+ main.js declares 6 routes; none is /backlog +
+ +
+

has_purchase_order can only ever be false

+

+ /api/backlog computes the flag by checking each backlog item against + purchase_orders. That list is loaded from purchase_orders.json, which + contains an empty array — and since no endpoint writes to it, nothing can populate it at runtime. +

+

+ The flag returns false for all four backlog items on every request, so any UI branch + depending on it is effectively dead. +

+ GET /api/backlog → has_purchase_order: false × 4 +
+ +

+ Separately, and by design for a demo: CORS is set to allow_origins=["*"], there is no + authentication, and the API base URL is hardcoded to localhost:8001 with no environment + override. server/CLAUDE.md already documents all three as local-development-only choices. +

+
+ + + +
+ + diff --git a/server/data/demand_forecasts.json b/server/data/demand_forecasts.json index e1b388385..2bbf734f8 100644 --- a/server/data/demand_forecasts.json +++ b/server/data/demand_forecasts.json @@ -6,7 +6,9 @@ "current_demand": 300, "forecasted_demand": 450, "trend": "increasing", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 12.50, + "lead_time_days": 10 }, { "id": "2", @@ -15,7 +17,9 @@ "current_demand": 150, "forecasted_demand": 152, "trend": "stable", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 45.00, + "lead_time_days": 14 }, { "id": "3", @@ -24,7 +28,9 @@ "current_demand": 500, "forecasted_demand": 600, "trend": "increasing", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 8.75, + "lead_time_days": 7 }, { "id": "4", @@ -33,7 +39,9 @@ "current_demand": 50, "forecasted_demand": 35, "trend": "decreasing", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 420.00, + "lead_time_days": 21 }, { "id": "5", @@ -42,7 +50,9 @@ "current_demand": 800, "forecasted_demand": 950, "trend": "increasing", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 6.25, + "lead_time_days": 9 }, { "id": "6", @@ -51,7 +61,9 @@ "current_demand": 120, "forecasted_demand": 121, "trend": "stable", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 135.00, + "lead_time_days": 18 }, { "id": "7", @@ -60,7 +72,9 @@ "current_demand": 250, "forecasted_demand": 252, "trend": "stable", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 18.99, + "lead_time_days": 12 }, { "id": "8", @@ -69,7 +83,9 @@ "current_demand": 180, "forecasted_demand": 182, "trend": "stable", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 89.50, + "lead_time_days": 16 }, { "id": "9", @@ -78,6 +94,8 @@ "current_demand": 95, "forecasted_demand": 96, "trend": "stable", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 210.00, + "lead_time_days": 21 } ] diff --git a/server/main.py b/server/main.py index a0c2d8c5a..cf661c1ff 100644 --- a/server/main.py +++ b/server/main.py @@ -1,8 +1,9 @@ +from datetime import datetime, timedelta from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from typing import List, Optional -from pydantic import BaseModel -from mock_data import inventory_items, orders, demand_forecasts, backlog_items, spending_summary, monthly_spending, category_spending, recent_transactions, purchase_orders +from pydantic import BaseModel, Field +from mock_data import inventory_items, orders, demand_forecasts, backlog_items, spending_summary, monthly_spending, category_spending, recent_transactions, purchase_orders, restock_orders app = FastAPI(title="Factory Inventory Management System") @@ -89,6 +90,8 @@ class DemandForecast(BaseModel): forecasted_demand: int trend: str period: str + unit_cost: float + lead_time_days: int class BacklogItem(BaseModel): id: str @@ -120,6 +123,29 @@ class CreatePurchaseOrderRequest(BaseModel): expected_delivery_date: str notes: Optional[str] = None +class RestockOrderItem(BaseModel): + item_sku: str + item_name: str + quantity: int = Field(gt=0) + unit_cost: float = Field(ge=0) + lead_time_days: int = Field(ge=0) + line_total: float + +class RestockOrder(BaseModel): + id: str + order_number: str + items: List[RestockOrderItem] + status: str + order_date: str + expected_delivery: str + max_lead_time_days: int + budget: float + total_cost: float + +class CreateRestockOrderRequest(BaseModel): + budget: float = Field(ge=0) + items: List[RestockOrderItem] = Field(min_length=1) + # API endpoints @app.get("/") def root(): @@ -166,6 +192,56 @@ def get_demand_forecasts(): """Get demand forecasts""" return demand_forecasts +@app.get("/api/restock-orders", response_model=List[RestockOrder]) +def get_restock_orders(): + """Get all submitted restocking orders, newest first""" + return list(reversed(restock_orders)) + +@app.post("/api/restock-orders", response_model=RestockOrder, status_code=201) +def create_restock_order(request: CreateRestockOrderRequest): + """Submit a restocking order built from demand forecast recommendations""" + valid_skus = {forecast["item_sku"] for forecast in demand_forecasts} + + total_cost = 0.0 + for item in request.items: + if item.item_sku not in valid_skus: + raise HTTPException(status_code=400, detail=f"Unknown item SKU: {item.item_sku}") + + expected_line_total = round(item.quantity * item.unit_cost, 2) + if abs(item.line_total - expected_line_total) > 0.01: + raise HTTPException( + status_code=400, + detail=f"line_total mismatch for {item.item_sku}: expected {expected_line_total}" + ) + total_cost += expected_line_total + + total_cost = round(total_cost, 2) + if total_cost > request.budget + 0.01: + raise HTTPException( + status_code=400, + detail=f"Order total {total_cost} exceeds the budget of {request.budget}" + ) + + max_lead_time = max(item.lead_time_days for item in request.items) + now = datetime.now() + sequence = len(restock_orders) + 1 + + order = { + "id": f"RO-{sequence}", + "order_number": f"RSO-{now.year}-{sequence:04d}", + "items": [item.model_dump() for item in request.items], + "status": "Submitted", + "order_date": now.isoformat(timespec="seconds"), + "expected_delivery": (now + timedelta(days=max_lead_time)).isoformat(timespec="seconds"), + "max_lead_time_days": max_lead_time, + "budget": request.budget, + "total_cost": total_cost + } + + # Mutate in place - rebinding would drop the write (see mock_data.py) + restock_orders.append(order) + return order + @app.get("/api/backlog", response_model=List[BacklogItem]) def get_backlog(): """Get backlog items with purchase order status""" diff --git a/server/mock_data.py b/server/mock_data.py index 2a9cd7dcb..2749b3b09 100644 --- a/server/mock_data.py +++ b/server/mock_data.py @@ -35,5 +35,10 @@ def load_json_file(filename): # Load purchase orders purchase_orders = load_json_file('purchase_orders.json') +# Restock orders are created at runtime via POST /api/restock-orders. +# In-memory only (no JSON seed file) - resets on server restart. +# main.py holds a reference to this same list, so only ever mutate it in place. +restock_orders = [] + # All data is now loaded from JSON files in the data/ directory # This allows for easier maintenance and updates of the sample data diff --git a/tests/backend/conftest.py b/tests/backend/conftest.py index a6aa82271..02f4f2ac2 100644 --- a/tests/backend/conftest.py +++ b/tests/backend/conftest.py @@ -38,6 +38,32 @@ def sample_inventory_item(): } +@pytest.fixture +def sample_restock_order_request(): + """Sample restock order request payload for testing.""" + return { + "budget": 2500.0, + "items": [ + { + "item_sku": "WDG-001", + "item_name": "Industrial Widget Type A", + "quantity": 150, + "unit_cost": 12.50, + "lead_time_days": 10, + "line_total": 1875.00 + }, + { + "item_sku": "BRG-102", + "item_name": "Steel Bearing Assembly", + "quantity": 2, + "unit_cost": 45.00, + "lead_time_days": 14, + "line_total": 90.00 + } + ] + } + + @pytest.fixture def sample_order(): """Sample order for testing.""" diff --git a/tests/backend/test_restocking.py b/tests/backend/test_restocking.py new file mode 100644 index 000000000..0769bacc3 --- /dev/null +++ b/tests/backend/test_restocking.py @@ -0,0 +1,189 @@ +""" +Tests for restocking API endpoints. +""" +import re + +import pytest + +import mock_data + + +@pytest.fixture(autouse=True) +def reset_restock_orders(): + """Isolate restock order state between tests. + + mock_data.restock_orders is a module-level global and main.py holds a + reference to the same list object, so it must be mutated in place - + rebinding would leave main.py appending to the old list. + """ + saved = list(mock_data.restock_orders) + mock_data.restock_orders.clear() + yield + mock_data.restock_orders.clear() + mock_data.restock_orders.extend(saved) + + +class TestDemandForecastCostFields: + """Tests for the unit_cost and lead_time_days fields on demand forecasts.""" + + def test_demand_forecasts_have_unit_cost(self, client): + """Every demand forecast exposes a positive 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 + assert isinstance(forecast["unit_cost"], (int, float)) + assert forecast["unit_cost"] > 0, f"{forecast['item_sku']} has a non-positive unit_cost" + + def test_demand_forecasts_have_lead_time(self, client): + """Every demand forecast exposes a lead_time_days in a sensible range.""" + response = client.get("/api/demand") + assert response.status_code == 200 + data = response.json() + + for forecast in data: + assert "lead_time_days" in forecast + assert isinstance(forecast["lead_time_days"], int) + assert 1 <= forecast["lead_time_days"] <= 60, ( + f"{forecast['item_sku']} lead time {forecast['lead_time_days']} is out of range" + ) + + def test_psu_501_cost_matches_inventory(self, client): + """PSU-501 exists in both datasets - the unit_cost must not drift apart.""" + demand = client.get("/api/demand").json() + inventory = client.get("/api/inventory").json() + + demand_item = next(f for f in demand if f["item_sku"] == "PSU-501") + inventory_item = next(i for i in inventory if i["sku"] == "PSU-501") + + assert abs(demand_item["unit_cost"] - inventory_item["unit_cost"]) < 0.01 + + +class TestRestockOrdersEndpoints: + """Tests for the /api/restock-orders endpoints.""" + + def test_get_restock_orders_empty(self, client): + """A clean server has no submitted restock orders.""" + response = client.get("/api/restock-orders") + assert response.status_code == 200 + assert response.json() == [] + + def test_create_restock_order(self, client, sample_restock_order_request): + """Submitting a valid restock order returns 201 with derived fields.""" + response = client.post("/api/restock-orders", json=sample_restock_order_request) + assert response.status_code == 201 + + data = response.json() + assert data["id"] == "RO-1" + assert data["status"] == "Submitted" + assert len(data["items"]) == 2 + assert abs(data["total_cost"] - 1965.00) < 0.01 + assert data["budget"] == sample_restock_order_request["budget"] + assert data["max_lead_time_days"] == 14 + assert data["expected_delivery"] > data["order_date"] + + def test_create_restock_order_appears_in_list(self, client, sample_restock_order_request): + """A submitted order is returned by the list endpoint.""" + created = client.post("/api/restock-orders", json=sample_restock_order_request).json() + + response = client.get("/api/restock-orders") + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["order_number"] == created["order_number"] + + def test_restock_orders_newest_first(self, client, sample_restock_order_request): + """The list endpoint returns the most recently submitted order first.""" + first = client.post("/api/restock-orders", json=sample_restock_order_request).json() + second = client.post("/api/restock-orders", json=sample_restock_order_request).json() + + data = client.get("/api/restock-orders").json() + assert len(data) == 2 + assert data[0]["order_number"] == second["order_number"] + assert data[1]["order_number"] == first["order_number"] + + def test_order_number_format(self, client, sample_restock_order_request): + """Order numbers follow the RSO-YYYY-NNNN convention.""" + data = client.post("/api/restock-orders", json=sample_restock_order_request).json() + assert re.match(r"^RSO-\d{4}-\d{4}$", data["order_number"]), data["order_number"] + + def test_create_restock_order_rejects_unknown_sku(self, client): + """An item SKU not present in the demand forecast is rejected.""" + response = client.post("/api/restock-orders", json={ + "budget": 5000, + "items": [{ + "item_sku": "NOPE-999", + "item_name": "Nonexistent Part", + "quantity": 1, + "unit_cost": 10.0, + "lead_time_days": 5, + "line_total": 10.0 + }] + }) + assert response.status_code == 400 + assert "unknown item sku" in response.json()["detail"].lower() + + def test_create_restock_order_rejects_over_budget(self, client): + """An order totalling more than the stated budget is rejected.""" + response = client.post("/api/restock-orders", json={ + "budget": 100, + "items": [{ + "item_sku": "WDG-001", + "item_name": "Industrial Widget Type A", + "quantity": 150, + "unit_cost": 12.50, + "lead_time_days": 10, + "line_total": 1875.00 + }] + }) + assert response.status_code == 400 + assert "budget" in response.json()["detail"].lower() + + def test_create_restock_order_rejects_line_total_mismatch(self, client): + """A line_total inconsistent with quantity * unit_cost is rejected.""" + response = client.post("/api/restock-orders", json={ + "budget": 5000, + "items": [{ + "item_sku": "WDG-001", + "item_name": "Industrial Widget Type A", + "quantity": 150, + "unit_cost": 12.50, + "lead_time_days": 10, + "line_total": 99.00 + }] + }) + assert response.status_code == 400 + assert "line_total" in response.json()["detail"] + + def test_create_restock_order_rejects_empty_items(self, client): + """An order with no items fails Pydantic validation.""" + response = client.post("/api/restock-orders", json={"budget": 5000, "items": []}) + assert response.status_code == 422 + + def test_create_restock_order_rejects_zero_quantity(self, client): + """An item with a zero quantity fails Pydantic validation.""" + response = client.post("/api/restock-orders", json={ + "budget": 5000, + "items": [{ + "item_sku": "WDG-001", + "item_name": "Industrial Widget Type A", + "quantity": 0, + "unit_cost": 12.50, + "lead_time_days": 10, + "line_total": 0.0 + }] + }) + assert response.status_code == 422 + + def test_create_restock_order_does_not_touch_orders(self, client, sample_restock_order_request): + """Restock orders stay separate from customer orders.""" + before = client.get("/api/orders").json() + + client.post("/api/restock-orders", json=sample_restock_order_request) + + after = client.get("/api/orders").json() + assert len(after) == len(before) + assert not any(order["order_number"].startswith("RSO-") for order in after)