diff --git a/.gitignore b/.gitignore index d38fbfe7e..d6d7dfbaa 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,9 @@ yarn-error.log* *~ .DS_Store +# Claude Code local settings (personal overrides, not shared with the team) +.claude/settings.local.json + # UV/Python package managers uv.lock *.lock diff --git a/client/src/App.vue b/client/src/App.vue index c2da05a5c..19511597f 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -22,8 +22,11 @@ {{ t('nav.demandForecast') }} + + {{ t('nav.restocking') }} + - Reports + {{ t('nav.reports') }} diff --git a/client/src/api.js b/client/src/api.js index 11cb9db70..e8f461990 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -94,6 +94,38 @@ export const api = { return response.data }, + async getQuarterlyReports(filters = {}) { + const params = new URLSearchParams() + if (filters.warehouse && filters.warehouse !== 'all') params.append('warehouse', filters.warehouse) + if (filters.category && filters.category !== 'all') params.append('category', filters.category) + if (filters.status && filters.status !== 'all') params.append('status', filters.status) + if (filters.month && filters.month !== 'all') params.append('month', filters.month) + + const response = await axios.get(`${API_BASE_URL}/reports/quarterly?${params.toString()}`) + return response.data + }, + + async getMonthlyTrends(filters = {}) { + const params = new URLSearchParams() + if (filters.warehouse && filters.warehouse !== 'all') params.append('warehouse', filters.warehouse) + if (filters.category && filters.category !== 'all') params.append('category', filters.category) + if (filters.status && filters.status !== 'all') params.append('status', filters.status) + if (filters.month && filters.month !== 'all') params.append('month', filters.month) + + const response = await axios.get(`${API_BASE_URL}/reports/monthly-trends?${params.toString()}`) + return response.data + }, + + async getRestockingRecommendations(budget) { + const response = await axios.get(`${API_BASE_URL}/restocking/recommendations?budget=${budget}`) + return response.data + }, + + async submitRestockingOrder(items) { + const response = await axios.post(`${API_BASE_URL}/restocking/orders`, { items }) + return response.data + }, + async createPurchaseOrder(purchaseOrderData) { const response = await axios.post(`${API_BASE_URL}/purchase-orders`, purchaseOrderData) return response.data diff --git a/client/src/locales/en.js b/client/src/locales/en.js index 03a58fe6e..68bcba05e 100644 --- a/client/src/locales/en.js +++ b/client/src/locales/en.js @@ -6,6 +6,8 @@ export default { orders: 'Orders', finance: 'Finance', demandForecast: 'Demand Forecast', + restocking: 'Restocking', + reports: 'Reports', companyName: 'Catalyst Components', subtitle: 'Inventory Management System' }, @@ -125,7 +127,59 @@ export default { totalValue: 'Total Value', status: 'Status', expectedDelivery: 'Expected Delivery', - actualDelivery: 'Actual Delivery' + actualDelivery: 'Actual Delivery', + leadTime: 'Lead Time' + }, + submittedOrders: 'Submitted Orders', + leadTimeDays: '{days} days' + }, + + // Restocking + restocking: { + title: 'Restocking', + description: 'Set a budget and restock items recommended from demand forecasts', + budgetLabel: 'Available Budget', + recommended: 'Recommended Items', + totalCost: 'Total Cost', + remaining: 'Remaining Budget', + placeOrder: 'Place Order', + placingOrder: 'Placing Order...', + orderSuccess: 'Order submitted successfully', + orderError: 'Failed to submit order', + noItems: 'No items are recommended for this budget', + selectItems: 'Uncheck items to exclude them from the order', + table: { + sku: 'SKU', + itemName: 'Item Name', + quantity: 'Quantity', + unitCost: 'Unit Cost', + lineTotal: 'Line Total', + trend: 'Trend' + } + }, + + // Reports + reports: { + title: 'Performance Reports', + description: 'View quarterly performance metrics and monthly trends', + quarterlyPerformance: 'Quarterly Performance', + monthlyRevenueTrend: 'Monthly Revenue Trend', + monthOverMonth: 'Month-over-Month Analysis', + totalRevenueYTD: 'Total Revenue (YTD)', + avgMonthlyRevenue: 'Avg Monthly Revenue', + totalOrdersYTD: 'Total Orders (YTD)', + bestQuarter: 'Best Performing Quarter', + table: { + quarter: 'Quarter', + totalOrders: 'Total Orders', + totalRevenue: 'Total Revenue', + avgOrderValue: 'Avg Order Value', + fulfillmentRate: 'Fulfillment Rate', + month: 'Month', + orders: 'Orders', + revenue: 'Revenue', + change: 'Change', + growthRate: 'Growth Rate' } }, @@ -204,6 +258,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 db33223ac..07c22547e 100644 --- a/client/src/locales/ja.js +++ b/client/src/locales/ja.js @@ -6,6 +6,8 @@ export default { orders: '注文', finance: '財務', demandForecast: '需要予測', + restocking: '補充', + reports: 'レポート', companyName: '触媒コンポーネンツ', subtitle: '在庫管理システム' }, @@ -125,7 +127,59 @@ export default { totalValue: '合計金額', status: 'ステータス', expectedDelivery: '予定配達日', - actualDelivery: '実際の配達日' + actualDelivery: '実際の配達日', + leadTime: 'リードタイム' + }, + submittedOrders: '提出済み注文', + leadTimeDays: '{days}日' + }, + + // Restocking + restocking: { + title: '補充', + description: '予算を設定し、需要予測に基づいて推奨された品目を補充します', + budgetLabel: '利用可能な予算', + recommended: '推奨品目', + totalCost: '合計金額', + remaining: '残り予算', + placeOrder: '注文する', + placingOrder: '注文中...', + orderSuccess: '注文が正常に送信されました', + orderError: '注文の送信に失敗しました', + noItems: 'この予算で推奨される品目はありません', + selectItems: 'チェックを外すと注文から除外されます', + table: { + sku: 'SKU', + itemName: '品目名', + quantity: '数量', + unitCost: '単価', + lineTotal: '小計', + trend: 'トレンド' + } + }, + + // Reports + reports: { + title: 'パフォーマンスレポート', + description: '四半期業績指標と月次トレンドを表示', + quarterlyPerformance: '四半期業績', + monthlyRevenueTrend: '月次収益トレンド', + monthOverMonth: '月次比較分析', + totalRevenueYTD: '総収益(年初来)', + avgMonthlyRevenue: '平均月次収益', + totalOrdersYTD: '総注文数(年初来)', + bestQuarter: '最高業績四半期', + table: { + quarter: '四半期', + totalOrders: '総注文数', + totalRevenue: '総収益', + avgOrderValue: '平均注文額', + fulfillmentRate: '充足率', + month: '月', + orders: '注文数', + revenue: '収益', + change: '変化', + growthRate: '成長率' } }, @@ -204,6 +258,7 @@ export default { shipped: '出荷済み', processing: '処理中', backordered: 'バックオーダー', + submitted: '提出済み', inStock: '在庫あり', lowStock: '在庫僅少', adequate: '適量' diff --git a/client/src/main.js b/client/src/main.js index 477c2d966..8884eea63 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 7413f6e66..ac259b266 100644 --- a/client/src/views/Orders.vue +++ b/client/src/views/Orders.vue @@ -74,6 +74,51 @@ + +
+
+

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

+
+
+ + + + + + + + + + + + + + + + + + + + + + +
+
@@ -95,6 +140,7 @@ export default { const loading = ref(true) const error = ref(null) const orders = ref([]) + const submittedOrders = ref([]) // Use shared filters const { @@ -111,12 +157,15 @@ export default { const filters = getCurrentFilters() const fetchedOrders = await api.getOrders(filters) - // Sort orders by order_date (earliest first) - orders.value = fetchedOrders.sort((a, b) => { - const dateA = new Date(a.order_date) - const dateB = new Date(b.order_date) - return dateA - dateB - }) + // Exclude submitted (restocking) orders from the main list/stats, + // then sort by order_date (earliest first) + orders.value = fetchedOrders + .filter(o => o.status !== 'Submitted') + .sort((a, b) => { + const dateA = new Date(a.order_date) + const dateB = new Date(b.order_date) + return dateA - dateB + }) } catch (err) { error.value = 'Failed to load orders: ' + err.message } finally { @@ -124,9 +173,15 @@ export default { } } + const loadSubmittedOrders = async () => { + const filters = { ...getCurrentFilters(), status: 'Submitted' } + submittedOrders.value = await api.getOrders(filters) + } + // Watch for filter changes and reload data watch([selectedPeriod, selectedLocation, selectedCategory, selectedStatus], () => { loadOrders() + loadSubmittedOrders() }) const getOrdersByStatus = (status) => { @@ -153,13 +208,17 @@ export default { }) } - onMounted(loadOrders) + onMounted(() => { + loadOrders() + loadSubmittedOrders() + }) return { t, loading, error, orders, + submittedOrders, getOrdersByStatus, getOrderStatusClass, formatDate, @@ -276,4 +335,18 @@ export default { font-size: 0.813rem; color: #64748b; } + +.submitted-orders-table { + table-layout: fixed; + width: 100%; +} + +.col-lead-time { + width: 120px; +} + +.submitted-badge { + margin-left: 0.75rem; + vertical-align: middle; +} diff --git a/client/src/views/Reports.vue b/client/src/views/Reports.vue index 35187eaf3..7db8f3f6c 100644 --- a/client/src/views/Reports.vue +++ b/client/src/views/Reports.vue @@ -1,35 +1,35 @@ + + diff --git a/docs/architecture.html b/docs/architecture.html new file mode 100644 index 000000000..aef74ca13 --- /dev/null +++ b/docs/architecture.html @@ -0,0 +1,366 @@ + + + + + +Architecture — Factory Inventory Management System + + + +
+ +
+

Factory Inventory Management System

+

Architecture overview — Vue 3 frontend, FastAPI backend, in-memory mock data (no database)

+
+ +
+

Tech Stack

+
+
+ Frontend +

Vue 3 + Vite

+
    +
  • Composition API
  • +
  • vue-router 4 (client-side routing)
  • +
  • axios (HTTP client)
  • +
+ http://localhost:3000 +
+
+ Backend +

Python FastAPI

+
    +
  • Pydantic models for validation
  • +
  • uvicorn ASGI server
  • +
  • CORS middleware (dev: allow all)
  • +
+ http://localhost:8001 · /docs +
+
+ Data +

In-Memory Mock Data

+
    +
  • JSON files in server/data/
  • +
  • Loaded once at startup
  • +
  • No persistence — resets on restart
  • +
+ no database +
+
+
+ +
+

System Architecture

+
+
+
+ Browser +
Vue 3 SPA
+
Views (Dashboard, Inventory, Orders, Demand, Spending, Reports)
+
Components (FilterBar, Modals, ProfileMenu)
+
Composables (useFilters, useAuth, useI18n)
+
+
+ +
+
+ API Client +
api.js (axios)
+
Builds query params from active filters
+
GET / POST / PATCH / DELETE over HTTP
+
+
+ +
+
+ Backend +
FastAPI (main.py)
+
Query-param filters (warehouse, category, status, month)
+
Pydantic response models
+
+
+ +
+
+ Data Layer +
mock_data.py
+
Loads JSON on import
+
server/data/*.json (inventory, orders, demand, backlog, spending…)
+
+
+
+
+ +
+

Filter Data Flow

+
+
+
User picks filterTime / Warehouse / Category / Status
+
+
FilterBar.vueemits selection
+
+
useFilters()shared reactive state
+
+
api.jsbuilds URLSearchParams
+
+
FastAPI routeapply_filters() / filter_by_month()
+
+
Pydantic validationresponse_model
+
+
Computed propertiesderived UI state
+
+
+
+ +
+

Key API Endpoints

+
+ + + + + + + + + + + + + +
EndpointFiltersPurpose
GET/api/inventorywarehouse, categoryInventory items across warehouses
GET/api/orderswarehouse, category, status, monthOrders with delivery/status tracking
GET/api/dashboard/summaryall filtersAggregate KPIs (inventory value, backlog, pending orders)
GET/api/demandnoneDemand forecasts with trend
GET/api/backlognoneBacklog items, flagged with purchase-order status
GET/api/spending/*noneSummary, monthly, category, and transaction spending views
GET/api/reports/quarterlynoneQuarterly revenue and fulfillment rollups
+
+
+ +
+

Project Structure

+
# client — Vue 3 + Vite frontend +client/src/ + views/ Dashboard, Inventory, Orders, Demand, Spending, Reports + components/ FilterBar, detail modals, ProfileMenu, LanguageSwitcher + composables/ useFilters, useAuth, useI18n + api.js centralized axios client + main.js app entry + vue-router config + +# server — FastAPI backend +server/ + main.py API routes, filtering logic, Pydantic models + mock_data.py loads JSON files into memory at startup + data/ inventory, orders, demand_forecasts, backlog_items, + purchase_orders, spending, transactions (*.json) + +# tests +tests/backend/ pytest + FastAPI TestClient
+
+ +
+

Notes

+
+
+

No database

+

All data lives in JSON files under server/data/, loaded into memory once on server start. Writes (tasks, purchase orders) only persist for the life of the process.

+
+
+

Shared filter system

+

Four filters — Time Period, Warehouse, Category, Order Status — flow through query params to nearly every endpoint, keeping views consistent.

+
+
+

Reactivity pattern

+

Raw API responses are stored in refs; anything derived (totals, chart series, filtered lists) is a computed property, recalculated only when its dependencies change.

+
+
+

Demo, not production

+

No authentication, no database, CORS wide open, no rate limiting — intentionally scoped for a workshop demo, not deployment.

+
+
+
+ + + +
+ + diff --git a/server/data/demand_forecasts.json b/server/data/demand_forecasts.json index e1b388385..46d934eed 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": 15.5 }, { "id": "2", @@ -15,7 +16,8 @@ "current_demand": 150, "forecasted_demand": 152, "trend": "stable", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 45.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.5 }, { "id": "4", @@ -33,7 +36,8 @@ "current_demand": 50, "forecasted_demand": 35, "trend": "decreasing", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 285.0 }, { "id": "5", @@ -42,7 +46,8 @@ "current_demand": 800, "forecasted_demand": 950, "trend": "increasing", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 12.25 }, { "id": "6", @@ -51,7 +56,8 @@ "current_demand": 120, "forecasted_demand": 121, "trend": "stable", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 34.5 }, { "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": 22.5 }, { "id": "9", @@ -78,6 +86,7 @@ "current_demand": 95, "forecasted_demand": 96, "trend": "stable", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 95.0 } ] diff --git a/server/main.py b/server/main.py index a0c2d8c5a..65eb743dd 100644 --- a/server/main.py +++ b/server/main.py @@ -1,7 +1,9 @@ +import uuid +from datetime import datetime, timedelta, timezone from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from typing import List, Optional -from pydantic import BaseModel +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 app = FastAPI(title="Factory Inventory Management System") @@ -46,6 +48,53 @@ def apply_filters(items: list, warehouse: Optional[str] = None, category: Option return filtered +def compute_restocking_recommendations(budget: float) -> dict: + """Recommend items to restock within budget, prioritizing urgent + rising-demand items. + + Demand forecast SKUs are a distinct catalog from inventory SKUs in this dataset + (only one incidental overlap), so recommendations are computed directly from + demand_forecasts: a shortfall exists whenever forecasted_demand exceeds current_demand. + """ + candidates = [] + for forecast in demand_forecasts: + shortfall = forecast['forecasted_demand'] - forecast['current_demand'] + if shortfall <= 0: + continue + + line_total = round(shortfall * forecast['unit_cost'], 2) + + candidates.append({ + 'sku': forecast['item_sku'], + 'name': forecast['item_name'], + 'quantity': shortfall, + 'unit_cost': forecast['unit_cost'], + 'line_total': line_total, + 'current_demand': forecast['current_demand'], + 'forecasted_demand': forecast['forecasted_demand'], + 'trend': forecast['trend'], + 'shortfall': shortfall + }) + + increasing = sorted([c for c in candidates if c['trend'] == 'increasing'], key=lambda c: c['shortfall'], reverse=True) + other = sorted([c for c in candidates if c['trend'] != 'increasing'], key=lambda c: c['shortfall'], reverse=True) + priority_order = increasing + other + + budget_max = round(sum(c['line_total'] for c in priority_order), 2) + + recommended_items = [] + running_total = 0.0 + for candidate in priority_order: + if running_total + candidate['line_total'] <= budget: + recommended_items.append({k: v for k, v in candidate.items() if k != 'shortfall'}) + running_total += candidate['line_total'] + + return { + 'budget': budget, + 'budget_max': budget_max, + 'recommended_items': recommended_items, + 'total_cost': round(running_total, 2) + } + # CORS middleware app.add_middleware( CORSMiddleware, @@ -80,6 +129,7 @@ class Order(BaseModel): actual_delivery: Optional[str] = None warehouse: Optional[str] = None category: Optional[str] = None + lead_time_days: Optional[int] = None class DemandForecast(BaseModel): id: str @@ -120,6 +170,34 @@ class CreatePurchaseOrderRequest(BaseModel): expected_delivery_date: str notes: Optional[str] = None +class RestockingRecommendationItem(BaseModel): + sku: str + name: str + quantity: int + unit_cost: float + line_total: float + current_demand: int + forecasted_demand: int + trend: str + +class RestockingRecommendationsResponse(BaseModel): + budget: float + budget_max: float + recommended_items: List[RestockingRecommendationItem] + total_cost: float + +class RestockingOrderItem(BaseModel): + sku: str + name: str + quantity: int = Field(gt=0) + unit_price: float = Field(ge=0) + +class CreateRestockingOrderRequest(BaseModel): + items: List[RestockingOrderItem] = Field(max_length=100) + +class RestockingOrderResponse(BaseModel): + order: Order + # API endpoints @app.get("/") def root(): @@ -179,6 +257,38 @@ def get_backlog(): result.append(item_dict) return result +@app.get("/api/restocking/recommendations", response_model=RestockingRecommendationsResponse) +def get_restocking_recommendations(budget: float = 0): + """Recommend items to restock within the given budget""" + return compute_restocking_recommendations(budget) + +@app.post("/api/restocking/orders", response_model=RestockingOrderResponse) +def create_restocking_order(request: CreateRestockingOrderRequest): + """Submit a restocking order built from selected recommendations""" + if not request.items: + raise HTTPException(status_code=400, detail="No items selected") + + now = datetime.now(timezone.utc) + lead_time_days = 14 + total_value = round(sum(item.quantity * item.unit_price for item in request.items), 2) + + new_order = { + "id": str(uuid.uuid4()), + "order_number": f"RESTOCK-{now.strftime('%Y%m%d%H%M%S')}", + "customer": "Internal Restocking", + "items": [item.model_dump() for item in request.items], + "status": "Submitted", + "order_date": now.isoformat(), + "expected_delivery": (now + timedelta(days=lead_time_days)).isoformat(), + "total_value": total_value, + "actual_delivery": None, + "warehouse": None, + "category": None, + "lead_time_days": lead_time_days, + } + orders.append(new_order) + return {"order": new_order} + @app.get("/api/dashboard/summary") def get_dashboard_summary( warehouse: Optional[str] = None, @@ -228,12 +338,20 @@ def get_recent_transactions(): return recent_transactions @app.get("/api/reports/quarterly") -def get_quarterly_reports(): - """Get quarterly performance reports""" +def get_quarterly_reports( + warehouse: Optional[str] = None, + category: Optional[str] = None, + status: Optional[str] = None, + month: Optional[str] = None +): + """Get quarterly performance reports with optional filtering""" + filtered_orders = apply_filters(orders, warehouse, category, status) + filtered_orders = filter_by_month(filtered_orders, month) + # Calculate quarterly statistics from orders quarters = {} - for order in orders: + for order in filtered_orders: order_date = order.get('order_date', '') # Determine quarter if '2025-01' in order_date or '2025-02' in order_date or '2025-03' in order_date: @@ -274,11 +392,19 @@ def get_quarterly_reports(): return result @app.get("/api/reports/monthly-trends") -def get_monthly_trends(): - """Get month-over-month trends""" +def get_monthly_trends( + warehouse: Optional[str] = None, + category: Optional[str] = None, + status: Optional[str] = None, + month: Optional[str] = None +): + """Get month-over-month trends with optional filtering""" + filtered_orders = apply_filters(orders, warehouse, category, status) + filtered_orders = filter_by_month(filtered_orders, month) + months = {} - for order in orders: + for order in filtered_orders: order_date = order.get('order_date', '') if not order_date: continue diff --git a/tests/backend/test_reports.py b/tests/backend/test_reports.py new file mode 100644 index 000000000..4b6638f5a --- /dev/null +++ b/tests/backend/test_reports.py @@ -0,0 +1,121 @@ +""" +Tests for reports API endpoints. +""" +import pytest + + +class TestQuarterlyReportsEndpoint: + """Test suite for quarterly reports endpoint.""" + + def test_get_quarterly_reports(self, client): + """Test getting quarterly reports.""" + response = client.get("/api/reports/quarterly") + assert response.status_code == 200 + + data = response.json() + assert isinstance(data, list) + assert len(data) > 0 + + first = data[0] + assert "quarter" in first + assert "total_orders" in first + assert "total_revenue" in first + assert "avg_order_value" in first + assert "fulfillment_rate" in first + + def test_quarterly_reports_by_warehouse(self, client): + """Test filtering quarterly reports by warehouse narrows the totals.""" + all_response = client.get("/api/reports/quarterly") + filtered_response = client.get("/api/reports/quarterly?warehouse=Tokyo") + assert filtered_response.status_code == 200 + + all_data = all_response.json() + filtered_data = filtered_response.json() + + all_total = sum(q["total_orders"] for q in all_data) + filtered_total = sum(q["total_orders"] for q in filtered_data) + assert filtered_total < all_total + assert filtered_total > 0 + + def test_quarterly_reports_by_month(self, client): + """Test filtering quarterly reports by a single month only reflects that month.""" + response = client.get("/api/reports/quarterly?month=2025-01") + assert response.status_code == 200 + + data = response.json() + # January 2025 orders should only ever roll up into Q1-2025 + for q in data: + assert q["quarter"] == "Q1-2025" + + def test_quarterly_reports_all_filter(self, client): + """Test that 'all' filter values return the same result as no filters.""" + response_all = client.get("/api/reports/quarterly?warehouse=all&status=all") + response_no_filter = client.get("/api/reports/quarterly") + + assert response_all.status_code == 200 + assert response_all.json() == response_no_filter.json() + + def test_quarterly_reports_fulfillment_rate_range(self, client): + """Test that fulfillment rate is a sane percentage.""" + response = client.get("/api/reports/quarterly") + data = response.json() + + for q in data: + assert 0 <= q["fulfillment_rate"] <= 100 + + +class TestMonthlyTrendsEndpoint: + """Test suite for monthly trends endpoint.""" + + def test_get_monthly_trends(self, client): + """Test getting monthly trends.""" + response = client.get("/api/reports/monthly-trends") + assert response.status_code == 200 + + data = response.json() + assert isinstance(data, list) + assert len(data) > 0 + + first = data[0] + assert "month" in first + assert "order_count" in first + assert "revenue" in first + assert "delivered_count" in first + + def test_monthly_trends_by_month_filter(self, client): + """Test filtering monthly trends down to a single month.""" + response = client.get("/api/reports/monthly-trends?month=2025-01") + assert response.status_code == 200 + + data = response.json() + assert len(data) == 1 + assert data[0]["month"] == "2025-01" + + def test_monthly_trends_by_warehouse(self, client): + """Test filtering monthly trends by warehouse narrows revenue.""" + all_response = client.get("/api/reports/monthly-trends") + filtered_response = client.get("/api/reports/monthly-trends?warehouse=London") + assert filtered_response.status_code == 200 + + all_revenue = sum(m["revenue"] for m in all_response.json()) + filtered_revenue = sum(m["revenue"] for m in filtered_response.json()) + assert filtered_revenue < all_revenue + assert filtered_revenue > 0 + + def test_monthly_trends_multiple_filters(self, client): + """Test combining warehouse and status filters.""" + response = client.get("/api/reports/monthly-trends?warehouse=San Francisco&status=Delivered") + assert response.status_code == 200 + + data = response.json() + for m in data: + # order_count should equal delivered_count since status is fully filtered to Delivered + assert m["order_count"] == m["delivered_count"] + + def test_monthly_trends_sorted_chronologically(self, client): + """Test that monthly trends are returned sorted by month ascending.""" + response = client.get("/api/reports/monthly-trends") + data = response.json() + + months = [m["month"] for m in data] + assert months == sorted(months) diff --git a/tests/backend/test_restocking.py b/tests/backend/test_restocking.py new file mode 100644 index 000000000..da983c689 --- /dev/null +++ b/tests/backend/test_restocking.py @@ -0,0 +1,149 @@ +""" +Tests for restocking API endpoints. +""" +import pytest + + +class TestRestockingEndpoints: + """Test suite for restocking-related endpoints.""" + + def test_get_recommendations_returns_budget_max(self, client): + """Test that recommendations endpoint returns a positive, stable budget_max.""" + response = client.get("/api/restocking/recommendations?budget=0") + assert response.status_code == 200 + + data = response.json() + assert "budget_max" in data + assert isinstance(data["budget_max"], (int, float)) + assert data["budget_max"] > 0 + + # Should be stable across repeated calls regardless of budget param + response_again = client.get("/api/restocking/recommendations?budget=999999") + assert response_again.json()["budget_max"] == data["budget_max"] + + def test_recommendations_structure(self, client): + """Test that recommended items have the expected fields.""" + response = client.get("/api/restocking/recommendations?budget=0") + data = response.json() + + assert "budget" in data + assert "recommended_items" in data + assert "total_cost" in data + assert isinstance(data["recommended_items"], list) + + def test_zero_budget_returns_no_items(self, client): + """Test that a budget of 0 recommends no items but still exposes budget_max.""" + response = client.get("/api/restocking/recommendations?budget=0") + assert response.status_code == 200 + + data = response.json() + assert data["recommended_items"] == [] + assert data["total_cost"] == 0 + + def test_increasing_budget_never_decreases_recommendations(self, client): + """Test that a larger budget never recommends fewer items or less total cost.""" + budget_max = client.get("/api/restocking/recommendations?budget=0").json()["budget_max"] + + low = client.get(f"/api/restocking/recommendations?budget={budget_max * 0.25}").json() + mid = client.get(f"/api/restocking/recommendations?budget={budget_max * 0.5}").json() + full = client.get(f"/api/restocking/recommendations?budget={budget_max}").json() + + assert len(low["recommended_items"]) <= len(mid["recommended_items"]) <= len(full["recommended_items"]) + assert low["total_cost"] <= mid["total_cost"] <= full["total_cost"] + + def test_full_budget_covers_every_candidate(self, client): + """Test that budget == budget_max includes every recommendable item.""" + budget_max = client.get("/api/restocking/recommendations?budget=0").json()["budget_max"] + + response = client.get(f"/api/restocking/recommendations?budget={budget_max}") + data = response.json() + + assert abs(data["total_cost"] - budget_max) < 0.01 + + def test_items_only_included_when_fully_affordable(self, client): + """Test that every recommended item's line_total fits within total_cost <= budget.""" + budget_max = client.get("/api/restocking/recommendations?budget=0").json()["budget_max"] + budget = budget_max * 0.4 + + response = client.get(f"/api/restocking/recommendations?budget={budget}") + data = response.json() + + assert data["total_cost"] <= budget + 0.01 + calculated_total = sum(item["line_total"] for item in data["recommended_items"]) + assert abs(calculated_total - data["total_cost"]) < 0.01 + + def test_submit_restocking_order(self, client): + """Test submitting a restocking order returns a Submitted order with lead time.""" + payload = { + "items": [ + {"sku": "WDG-001", "name": "Industrial Widget Type A", "quantity": 150, "unit_price": 15.5} + ] + } + response = client.post("/api/restocking/orders", json=payload) + assert response.status_code == 200 + + order = response.json()["order"] + assert order["status"] == "Submitted" + assert order["lead_time_days"] == 14 + assert order["total_value"] == 150 * 15.5 + + from datetime import datetime + 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_orders(self, client): + """Test that a submitted restocking order shows up via GET /api/orders.""" + payload = { + "items": [ + {"sku": "GSK-203", "name": "High-Temperature Gasket", "quantity": 100, "unit_price": 8.5} + ] + } + submit_response = client.post("/api/restocking/orders", json=payload) + new_order_id = submit_response.json()["order"]["id"] + + orders_response = client.get("/api/orders") + all_orders = orders_response.json() + + matching = [o for o in all_orders if o["id"] == new_order_id] + assert len(matching) == 1 + assert matching[0]["status"] == "Submitted" + + def test_submit_empty_items_returns_400(self, client): + """Test that submitting with no items is rejected.""" + response = client.post("/api/restocking/orders", json={"items": []}) + assert response.status_code == 400 + + data = response.json() + assert "detail" in data + + def test_submit_negative_quantity_rejected(self, client): + """Test that a negative or zero quantity fails validation.""" + payload = { + "items": [ + {"sku": "WDG-001", "name": "Industrial Widget Type A", "quantity": -5, "unit_price": 15.5} + ] + } + response = client.post("/api/restocking/orders", json=payload) + assert response.status_code == 422 + + def test_submit_negative_unit_price_rejected(self, client): + """Test that a negative unit price fails validation.""" + payload = { + "items": [ + {"sku": "WDG-001", "name": "Industrial Widget Type A", "quantity": 10, "unit_price": -1.0} + ] + } + response = client.post("/api/restocking/orders", json=payload) + assert response.status_code == 422 + + def test_submit_too_many_items_rejected(self, client): + """Test that submitting more than the max allowed line items fails validation.""" + payload = { + "items": [ + {"sku": f"SKU-{i}", "name": "Item", "quantity": 1, "unit_price": 1.0} + for i in range(101) + ] + } + response = client.post("/api/restocking/orders", json=payload) + assert response.status_code == 422