From affa180f136d6e6ae3b803a0f592b204e2e0a1db Mon Sep 17 00:00:00 2001 From: Ul Haq Date: Wed, 25 Mar 2026 14:12:34 +0530 Subject: [PATCH] Add restocking view, reports i18n, new inventory items, and backend API endpoints - Add Restocking page with budget-based recommendations from demand forecasts - Refactor Reports view from Options API to Composition API with i18n support - Add restocking and reports API endpoints (quarterly, monthly trends, recommendations) - Add submitted restocking orders section to Orders view - Add new inventory items (widgets, bearings, gaskets, motors, filters, valves, sensors, controllers) - Add en/ja translations for restocking and reports sections - Add backend test coverage for reports endpoints - Add architecture documentation - Update CLAUDE.md with comment documentation guideline Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 1 + client/src/App.vue | 5 +- client/src/api.js | 37 ++ client/src/locales/en.js | 60 ++++ client/src/locales/ja.js | 60 ++++ client/src/main.js | 4 +- client/src/views/Orders.vue | 69 +++- client/src/views/Reports.vue | 335 ++++++++---------- client/src/views/Restocking.vue | 583 ++++++++++++++++++++++++++++++++ docs/architecture.html | 378 +++++++++++++++++++++ server/data/inventory.json | 96 ++++++ server/main.py | 139 +++++++- tests/backend/test_reports.py | 243 +++++++++++++ 13 files changed, 1803 insertions(+), 207 deletions(-) create mode 100644 client/src/views/Restocking.vue create mode 100644 docs/architecture.html create mode 100644 tests/backend/test_reports.py diff --git a/CLAUDE.md b/CLAUDE.md index d2086efa..fb29c31c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,6 +54,7 @@ npm install && npm run dev - `GET /api/spending/*` - Summary, monthly, categories, transactions ## Common Issues +0. Always document non-obvious logic changes with comments 1. Use unique keys in v-for (not `index`) - use `sku`, `month`, etc. 2. Validate dates before `.getMonth()` calls 3. Update Pydantic models when changing JSON data structure diff --git a/client/src/App.vue b/client/src/App.vue index c2da05a5..19511597 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 11cb9db7..9e2bc707 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -102,5 +102,42 @@ export const api = { async getPurchaseOrderByBacklogItem(backlogItemId) { const response = await axios.get(`${API_BASE_URL}/purchase-orders/${backlogItemId}`) 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() { + const response = await axios.get(`${API_BASE_URL}/restocking/recommendations`) + return response.data + }, + + async submitRestockingOrder(data) { + const response = await axios.post(`${API_BASE_URL}/restocking/orders`, data) + return response.data + }, + + async getSubmittedRestockingOrders() { + const response = await axios.get(`${API_BASE_URL}/restocking/submitted-orders`) + return response.data } } diff --git a/client/src/locales/en.js b/client/src/locales/en.js index 03a58fe6..ad98a073 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' }, @@ -188,6 +190,64 @@ export default { } }, + // Restocking + restocking: { + title: 'Restocking', + description: 'Set a budget and get priority-ranked recommendations from demand forecasts', + budget: 'Budget', + recommendations: 'Recommendations', + table: { + select: 'Select', + sku: 'SKU', + itemName: 'Item Name', + trend: 'Trend', + forecastedDemand: 'Forecasted Demand', + currentStock: 'Current Stock', + restockQty: 'Restock Qty', + unitCost: 'Unit Cost', + totalCost: 'Total Cost' + }, + selectedItems: 'Selected Items', + totalCost: 'Total Cost', + remainingBudget: 'Remaining Budget', + placeOrder: 'Place Restocking Order', + orderSuccess: 'Restocking order placed successfully!', + orderNumber: 'Order Number', + noRecommendations: 'No restocking recommendations - all items are sufficiently stocked.', + submittedOrders: 'Submitted Restocking Orders', + noSubmittedOrders: 'No restocking orders have been submitted yet.', + items: 'Items', + status: 'Status', + orderDate: 'Order Date', + expectedDelivery: 'Expected Delivery', + leadTime: 'Lead Time', + totalValue: 'Total Value', + days: 'days' + }, + + // 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', + 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', + totalRevenueYTD: 'Total Revenue (YTD)', + avgMonthlyRevenue: 'Avg Monthly Revenue', + totalOrdersYTD: 'Total Orders (YTD)', + bestPerformingQuarter: 'Best Performing Quarter' + }, + // Filters filters: { timePeriod: 'Time Period', diff --git a/client/src/locales/ja.js b/client/src/locales/ja.js index db33223a..dee5049a 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: '在庫管理システム' }, @@ -188,6 +190,64 @@ export default { } }, + // Restocking + restocking: { + title: '補充', + description: '予算を設定し、需要予測に基づく優先順位付きの補充提案を取得', + budget: '予算', + recommendations: '推奨事項', + table: { + select: '選択', + sku: 'SKU', + itemName: '品目名', + trend: 'トレンド', + forecastedDemand: '予測需要', + currentStock: '現在庫', + restockQty: '補充数量', + unitCost: '単価', + totalCost: '合計コスト' + }, + selectedItems: '選択品目', + totalCost: '合計コスト', + remainingBudget: '残予算', + placeOrder: '補充注文を発注', + orderSuccess: '補充注文が正常に発注されました!', + orderNumber: '注文番号', + noRecommendations: '補充の推奨事項はありません - すべての品目は十分な在庫があります。', + submittedOrders: '発注済み補充注文', + noSubmittedOrders: 'まだ補充注文は発注されていません。', + items: '品目', + status: 'ステータス', + orderDate: '注文日', + expectedDelivery: '予定配達日', + leadTime: 'リードタイム', + totalValue: '合計金額', + days: '日' + }, + + // Reports + reports: { + title: 'パフォーマンスレポート', + description: '四半期業績指標と月次トレンドの表示', + quarterlyPerformance: '四半期業績', + monthlyRevenueTrend: '月次収益トレンド', + monthOverMonth: '月次比較分析', + quarter: '四半期', + totalOrders: '総注文数', + totalRevenue: '総収益', + avgOrderValue: '平均注文額', + fulfillmentRate: '履行率', + month: '月', + orders: '注文', + revenue: '収益', + change: '変化', + growthRate: '成長率', + totalRevenueYTD: '総収益(年初来)', + avgMonthlyRevenue: '平均月次収益', + totalOrdersYTD: '総注文数(年初来)', + bestPerformingQuarter: '最高業績四半期' + }, + // Filters filters: { timePeriod: '期間', 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..2ab90eee 100644 --- a/client/src/views/Orders.vue +++ b/client/src/views/Orders.vue @@ -74,6 +74,45 @@ + +
+
+

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

+
+
+ {{ t('restocking.noSubmittedOrders') }} +
+
+ + + + + + + + + + + + + + + + + + + + + + + +
{{ t('orders.table.orderNumber') }}{{ t('restocking.items') }}{{ t('restocking.status') }}{{ t('restocking.orderDate') }}{{ t('restocking.expectedDelivery') }}{{ t('restocking.leadTime') }}{{ t('restocking.totalValue') }}
{{ order.order_number }}{{ order.items.length }} {{ t('common.items') }} + + {{ t(`status.${order.status.toLowerCase()}`) }} + + {{ formatDate(order.order_date) }}{{ formatDate(order.expected_delivery) }}{{ calculateLeadTime(order.order_date, order.expected_delivery) }} {{ t('restocking.days') }}{{ currencySymbol }}{{ order.total_value.toLocaleString() }}
+
+
@@ -95,6 +134,7 @@ export default { const loading = ref(true) const error = ref(null) const orders = ref([]) + const submittedOrders = ref([]) // Use shared filters const { @@ -124,6 +164,21 @@ export default { } } + const loadSubmittedOrders = async () => { + try { + submittedOrders.value = await api.getSubmittedRestockingOrders() + } catch (err) { + console.error('Failed to load submitted restocking orders:', err) + } + } + + const calculateLeadTime = (orderDate, deliveryDate) => { + const start = new Date(orderDate) + const end = new Date(deliveryDate) + const diffTime = Math.abs(end - start) + return Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + } + // Watch for filter changes and reload data watch([selectedPeriod, selectedLocation, selectedCategory, selectedStatus], () => { loadOrders() @@ -153,16 +208,22 @@ export default { }) } - onMounted(loadOrders) + onMounted(() => { + loadOrders() + loadSubmittedOrders() + }) return { t, loading, error, orders, + submittedOrders, getOrdersByStatus, getOrderStatusClass, formatDate, + calculateLeadTime, + loadSubmittedOrders, currencySymbol, translateProductName, translateCustomerName @@ -276,4 +337,10 @@ export default { font-size: 0.813rem; color: #64748b; } + +.empty-state { + padding: 2rem; + text-align: center; + color: #64748b; +} diff --git a/client/src/views/Reports.vue b/client/src/views/Reports.vue index 35187eaf..4555fc94 100644 --- a/client/src/views/Reports.vue +++ b/client/src/views/Reports.vue @@ -1,8 +1,8 @@ diff --git a/client/src/views/Restocking.vue b/client/src/views/Restocking.vue new file mode 100644 index 00000000..b11534c3 --- /dev/null +++ b/client/src/views/Restocking.vue @@ -0,0 +1,583 @@ + + + + + diff --git a/docs/architecture.html b/docs/architecture.html new file mode 100644 index 00000000..ce048fef --- /dev/null +++ b/docs/architecture.html @@ -0,0 +1,378 @@ + + + + + + System Architecture - Factory Inventory Management + + + +
+

Factory Inventory Management System

+

System Architecture & Technical Overview

+ + +
+

System Architecture

+
+
+
+
Frontend — Vue 3 + Vite
+
Composition API · Vue Router · Axios · Port 3000
+
+
↓ ↑ HTTP (REST JSON)
+
+
Backend API — Python FastAPI
+
Pydantic Models · CORS · Uvicorn · Port 8001
+
+
↓ ↑ In-memory read
+
+
Data Layer — JSON Files
+
inventory.json · orders.json · spending.json · demand_forecasts.json · backlog_items.json
+
+
+
+
+ + +
+

Tech Stack

+
+
+

Frontend

+ Vue 3.4 + Vue Router 4.3 + Vite 5.2 + Axios 1.6 + Composition API +
+
+

Backend

+ FastAPI 0.110 + Uvicorn 0.24 + Pydantic 2.5 + Python 3.x +
+
+

Tooling & Testing

+ uv + pytest + httpx + pytest-cov + npm +
+
+
+ + +
+

Data Flow

+
+

How a filtered request travels through the system

+
+ FilterBar.vue + + useFilters() + + api.js + + FastAPI Endpoint + + apply_filters() + + Pydantic Model + + JSON Response +
+
+ JSON Response + + ref() state + + computed() + + Template render +
+
+
+ + +
+

Filter Support by Endpoint

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
EndpointWarehouseCategoryStatusMonth
GET /api/inventoryYesYesNoNo
GET /api/ordersYesYesYesYes
GET /api/dashboard/summaryYesYesYesYes
GET /api/demandNoNoNoNo
GET /api/backlogNoNoNoNo
GET /api/spending/*NoNoNoNo
+
+
+ + +
+

Frontend Architecture

+
+
+

Views (Pages)

+ Dashboard + Inventory + Orders + Demand + Spending + Reports + Backlog +
+
+

Components

+ FilterBar + InventoryDetailModal + BacklogDetailModal + ProductDetailModal + CostDetailModal + TasksModal + ProfileMenu + LanguageSwitcher +
+
+

Composables

+ useFilters + useAuth + useI18n +

Singleton pattern for shared state across components

+
+
+

Utilities

+ api.js + currency.js +

Centralized API client & currency formatting (USD/JPY)

+
+
+
+ + +
+

Backend Data Models

+
+
+

InventoryItem

+ id + sku + name + category + warehouse + quantity_on_hand + reorder_point + unit_cost +
+
+

Order

+ id + order_number + customer + items[] + status + warehouse + total_value +
+
+

DemandForecast

+ item_sku + item_name + current_demand + forecasted_demand + trend +
+
+

BacklogItem

+ order_id + item_sku + quantity_needed + quantity_available + days_delayed + priority +
+
+

PurchaseOrder

+ backlog_item_id + supplier_name + quantity + unit_cost + status +
+
+

SpendingSummary

+ total_costs + procurement + operational + labor + overhead +
+
+
+ + +
+

Project Structure

+
+
+ client/
+   src/
+     views/ — Dashboard, Inventory, Orders, Demand, Spending, Reports, Backlog
+     components/ — FilterBar, Modals, ProfileMenu, LanguageSwitcher
+     composables/ — useFilters, useAuth, useI18n
+     locales/ — en.js, ja.js (English + Japanese)
+     utils/ — currency.js
+     api.js — Centralized API client (Axios)
+     main.js — App entry + route definitions
+     App.vue — Root component + global styles
+
+ server/
+   main.py — FastAPI app, endpoints, Pydantic models, filtering
+   mock_data.py — Loads JSON files into memory at startup
+   data/ — inventory, orders, spending, demand, backlog, transactions JSON
+
+ tests/backend/ — pytest + FastAPI TestClient
+ docs/ — Documentation and screenshots
+ scripts/ — Start/stop helper scripts +
+
+
+ + +
+

Key Design Patterns

+
+
+

Singleton Filter State

+

useFilters() composable provides shared reactive state across all views. Filter changes trigger data reload via watchers.

+
+
+

Reactive Data Flow

+

Raw API data stored in ref(), derived/displayed data in computed(). Ensures UI stays in sync with minimal re-renders.

+
+
+

In-Memory Data

+

JSON files loaded once at server startup via mock_data.py. No database; all filtering happens on in-memory Python lists.

+
+
+

I18n with Fallback

+

English/Japanese support via useI18n() composable. Currency auto-switches (USD/JPY). Falls back to English for missing translations.

+
+
+
+ +
+

Generated for Factory Inventory Management System

+
+ + diff --git a/server/data/inventory.json b/server/data/inventory.json index 486fda81..fcbfcff3 100644 --- a/server/data/inventory.json +++ b/server/data/inventory.json @@ -382,5 +382,101 @@ "unit_cost": 185.50, "location": "Warehouse C-11", "last_updated": "2025-09-21T15:20:00" + }, + { + "id": "33", + "sku": "WDG-001", + "name": "Industrial Widget Type A", + "category": "Mechanical Parts", + "warehouse": "San Francisco", + "quantity_on_hand": 120, + "reorder_point": 200, + "unit_cost": 15.50, + "location": "Warehouse A-18", + "last_updated": "2025-09-30T10:00:00" + }, + { + "id": "34", + "sku": "BRG-102", + "name": "Steel Bearing Assembly", + "category": "Mechanical Parts", + "warehouse": "London", + "quantity_on_hand": 140, + "reorder_point": 100, + "unit_cost": 22.75, + "location": "Warehouse B-18", + "last_updated": "2025-09-28T14:00:00" + }, + { + "id": "35", + "sku": "GSK-203", + "name": "High-Temperature Gasket", + "category": "Mechanical Parts", + "warehouse": "Tokyo", + "quantity_on_hand": 200, + "reorder_point": 250, + "unit_cost": 8.25, + "location": "Warehouse C-12", + "last_updated": "2025-09-27T09:30:00" + }, + { + "id": "36", + "sku": "MTR-304", + "name": "Electric Motor 5HP", + "category": "Actuators", + "warehouse": "San Francisco", + "quantity_on_hand": 40, + "reorder_point": 20, + "unit_cost": 285.00, + "location": "Warehouse A-19", + "last_updated": "2025-09-26T11:15:00" + }, + { + "id": "37", + "sku": "FLT-405", + "name": "Oil Filter Cartridge", + "category": "Mechanical Parts", + "warehouse": "London", + "quantity_on_hand": 350, + "reorder_point": 400, + "unit_cost": 6.50, + "location": "Warehouse B-19", + "last_updated": "2025-09-25T16:00:00" + }, + { + "id": "38", + "sku": "VLV-506", + "name": "Pressure Relief Valve", + "category": "Mechanical Parts", + "warehouse": "Tokyo", + "quantity_on_hand": 110, + "reorder_point": 80, + "unit_cost": 45.00, + "location": "Warehouse C-13", + "last_updated": "2025-09-24T13:45:00" + }, + { + "id": "39", + "sku": "SNR-420", + "name": "Temperature Sensor Module", + "category": "Sensors", + "warehouse": "San Francisco", + "quantity_on_hand": 160, + "reorder_point": 100, + "unit_cost": 32.00, + "location": "Warehouse A-20", + "last_updated": "2025-09-23T10:30:00" + }, + { + "id": "40", + "sku": "CTL-330", + "name": "Logic Controller Board", + "category": "Controllers", + "warehouse": "London", + "quantity_on_hand": 85, + "reorder_point": 50, + "unit_cost": 55.00, + "location": "Warehouse B-20", + "last_updated": "2025-09-22T08:00:00" } ] \ No newline at end of file diff --git a/server/main.py b/server/main.py index a0c2d8c5..7faeb27d 100644 --- a/server/main.py +++ b/server/main.py @@ -2,6 +2,7 @@ from fastapi.middleware.cors import CORSMiddleware from typing import List, Optional from pydantic import BaseModel +from datetime import datetime, timedelta from mock_data import inventory_items, orders, demand_forecasts, backlog_items, spending_summary, monthly_spending, category_spending, recent_transactions, purchase_orders app = FastAPI(title="Factory Inventory Management System") @@ -120,6 +121,31 @@ class CreatePurchaseOrderRequest(BaseModel): expected_delivery_date: str notes: Optional[str] = None +class RestockingRecommendation(BaseModel): + sku: str + name: str + trend: str + forecasted_demand: int + current_stock: int + restock_qty: int + unit_cost: float + total_cost: float + priority_score: int + +class RestockingOrderItem(BaseModel): + sku: str + name: str + quantity: int + unit_cost: float + +class SubmitRestockingOrderRequest(BaseModel): + items: List[RestockingOrderItem] + budget: float + +# In-memory storage for restocking orders +restocking_orders = [] +restocking_order_counter = 0 + # API endpoints @app.get("/") def root(): @@ -228,12 +254,19 @@ def get_recent_transactions(): return recent_transactions @app.get("/api/reports/quarterly") -def get_quarterly_reports(): - """Get quarterly performance reports""" - # Calculate quarterly statistics from orders +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) + 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 +307,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 @@ -304,6 +345,90 @@ def get_monthly_trends(): result.sort(key=lambda x: x['month']) return result +@app.get("/api/restocking/recommendations", response_model=List[RestockingRecommendation]) +def get_restocking_recommendations(): + """Join demand forecasts with inventory to produce priority-ranked restocking recommendations.""" + # Build lookup of inventory by SKU + inventory_by_sku = {item["sku"]: item for item in inventory_items} + + recommendations = [] + for forecast in demand_forecasts: + sku = forecast["item_sku"] + inv = inventory_by_sku.get(sku) + if not inv: + continue + + current_stock = inv["quantity_on_hand"] + forecasted_demand = forecast["forecasted_demand"] + restock_qty = max(0, forecasted_demand - current_stock) + if restock_qty == 0: + continue + + unit_cost = inv["unit_cost"] + total_cost = round(restock_qty * unit_cost, 2) + + # Priority: increasing=0, stable=1000, decreasing=2000, then subtract demand gap + trend = forecast["trend"] + trend_base = {"increasing": 0, "stable": 1000, "decreasing": 2000}.get(trend, 1000) + priority_score = trend_base - (forecasted_demand - current_stock) + + recommendations.append({ + "sku": sku, + "name": forecast["item_name"], + "trend": trend, + "forecasted_demand": forecasted_demand, + "current_stock": current_stock, + "restock_qty": restock_qty, + "unit_cost": unit_cost, + "total_cost": total_cost, + "priority_score": priority_score, + }) + + recommendations.sort(key=lambda r: r["priority_score"]) + return recommendations + + +@app.post("/api/restocking/orders") +def submit_restocking_order(request: SubmitRestockingOrderRequest): + """Submit a restocking order from selected recommendations.""" + global restocking_order_counter + + total = sum(item.quantity * item.unit_cost for item in request.items) + if total > request.budget: + raise HTTPException(status_code=400, detail="Order total exceeds budget") + + restocking_order_counter += 1 + order_number = f"RST-2025-{restocking_order_counter:04d}" + now = datetime.now() + expected_delivery = now + timedelta(days=14) + + order = { + "id": f"rst-{restocking_order_counter}", + "order_number": order_number, + "customer": "Internal Restocking", + "items": [ + {"name": item.name, "quantity": item.quantity, "unit_price": item.unit_cost} + for item in request.items + ], + "status": "Processing", + "order_date": now.strftime("%Y-%m-%dT%H:%M:%S"), + "expected_delivery": expected_delivery.strftime("%Y-%m-%dT%H:%M:%S"), + "total_value": round(total, 2), + "warehouse": "San Francisco", + "category": "Restocking", + } + + restocking_orders.append(order) + orders.append(order) + return order + + +@app.get("/api/restocking/submitted-orders") +def get_submitted_restocking_orders(): + """Return all submitted restocking orders.""" + return restocking_orders + + if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8001) diff --git a/tests/backend/test_reports.py b/tests/backend/test_reports.py new file mode 100644 index 00000000..95fc226a --- /dev/null +++ b/tests/backend/test_reports.py @@ -0,0 +1,243 @@ +""" +Tests for reports API endpoints. +""" +import pytest + + +class TestQuarterlyReportsEndpoint: + """Test suite for GET /api/reports/quarterly.""" + + def test_get_quarterly_reports_unfiltered(self, client): + """Test getting all quarterly reports without filters.""" + response = client.get("/api/reports/quarterly") + assert response.status_code == 200 + + data = response.json() + assert isinstance(data, list) + assert len(data) > 0 + + def test_quarterly_reports_structure(self, client): + """Test that quarterly reports have correct structure.""" + response = client.get("/api/reports/quarterly") + data = response.json() + + for quarter in data: + assert "quarter" in quarter + assert "total_orders" in quarter + assert "total_revenue" in quarter + assert "avg_order_value" in quarter + assert "fulfillment_rate" in quarter + assert isinstance(quarter["total_orders"], int) + assert isinstance(quarter["total_revenue"], (int, float)) + assert isinstance(quarter["avg_order_value"], (int, float)) + assert isinstance(quarter["fulfillment_rate"], (int, float)) + assert quarter["total_orders"] > 0 + assert quarter["total_revenue"] > 0 + + def test_quarterly_reports_sorted_by_quarter(self, client): + """Test that quarterly reports are sorted by quarter.""" + response = client.get("/api/reports/quarterly") + data = response.json() + + quarters = [q["quarter"] for q in data] + assert quarters == sorted(quarters) + + def test_quarterly_reports_filter_by_warehouse(self, client): + """Test filtering quarterly reports by warehouse.""" + response = client.get("/api/reports/quarterly?warehouse=Tokyo") + assert response.status_code == 200 + + data = response.json() + assert isinstance(data, list) + + # Filtered results should have fewer or equal orders than unfiltered + unfiltered = client.get("/api/reports/quarterly").json() + total_filtered_orders = sum(q["total_orders"] for q in data) + total_unfiltered_orders = sum(q["total_orders"] for q in unfiltered) + assert total_filtered_orders <= total_unfiltered_orders + + def test_quarterly_reports_filter_by_category(self, client): + """Test filtering quarterly reports by category.""" + response = client.get("/api/reports/quarterly?category=Sensors") + assert response.status_code == 200 + + data = response.json() + assert isinstance(data, list) + + unfiltered = client.get("/api/reports/quarterly").json() + total_filtered_orders = sum(q["total_orders"] for q in data) + total_unfiltered_orders = sum(q["total_orders"] for q in unfiltered) + assert total_filtered_orders <= total_unfiltered_orders + + def test_quarterly_reports_filter_by_status(self, client): + """Test filtering quarterly reports by status.""" + response = client.get("/api/reports/quarterly?status=Delivered") + assert response.status_code == 200 + + data = response.json() + assert isinstance(data, list) + + def test_quarterly_reports_filter_by_month(self, client): + """Test filtering quarterly reports by month.""" + response = client.get("/api/reports/quarterly?month=2025-01") + assert response.status_code == 200 + + data = response.json() + assert isinstance(data, list) + # Only Q1-2025 should appear when filtering by January + for quarter in data: + assert quarter["quarter"] == "Q1-2025" + + def test_quarterly_reports_filter_all_passes_through(self, client): + """Test that filter value 'all' returns same as unfiltered.""" + unfiltered = client.get("/api/reports/quarterly").json() + filtered = client.get("/api/reports/quarterly?warehouse=all&category=all").json() + + assert len(unfiltered) == len(filtered) + + def test_quarterly_reports_avg_order_value_calculation(self, client): + """Test that avg_order_value equals total_revenue / total_orders.""" + response = client.get("/api/reports/quarterly") + data = response.json() + + for quarter in data: + expected_avg = round(quarter["total_revenue"] / quarter["total_orders"], 2) + assert abs(quarter["avg_order_value"] - expected_avg) < 0.01 + + def test_quarterly_reports_fulfillment_rate_range(self, client): + """Test that fulfillment rate is between 0 and 100.""" + response = client.get("/api/reports/quarterly") + data = response.json() + + for quarter in data: + assert 0 <= quarter["fulfillment_rate"] <= 100 + + +class TestMonthlyTrendsEndpoint: + """Test suite for GET /api/reports/monthly-trends.""" + + def test_get_monthly_trends_unfiltered(self, client): + """Test getting all monthly trends without filters.""" + response = client.get("/api/reports/monthly-trends") + assert response.status_code == 200 + + data = response.json() + assert isinstance(data, list) + assert len(data) > 0 + + def test_monthly_trends_structure(self, client): + """Test that monthly trends have correct structure.""" + response = client.get("/api/reports/monthly-trends") + data = response.json() + + for month in data: + assert "month" in month + assert "order_count" in month + assert "revenue" in month + assert "delivered_count" in month + assert isinstance(month["order_count"], int) + assert isinstance(month["revenue"], (int, float)) + assert isinstance(month["delivered_count"], int) + assert month["order_count"] > 0 + assert month["revenue"] > 0 + + def test_monthly_trends_month_format(self, client): + """Test that month field is in YYYY-MM format.""" + response = client.get("/api/reports/monthly-trends") + data = response.json() + + for month in data: + assert len(month["month"]) == 7 + assert month["month"][4] == "-" + assert month["month"][:4].isdigit() + assert month["month"][5:].isdigit() + + def test_monthly_trends_sorted_by_month(self, client): + """Test that monthly trends are sorted chronologically.""" + response = client.get("/api/reports/monthly-trends") + data = response.json() + + months = [m["month"] for m in data] + assert months == sorted(months) + + def test_monthly_trends_filter_by_warehouse(self, client): + """Test filtering monthly trends by warehouse.""" + response = client.get("/api/reports/monthly-trends?warehouse=Tokyo") + assert response.status_code == 200 + + data = response.json() + assert isinstance(data, list) + + unfiltered = client.get("/api/reports/monthly-trends").json() + total_filtered_orders = sum(m["order_count"] for m in data) + total_unfiltered_orders = sum(m["order_count"] for m in unfiltered) + assert total_filtered_orders <= total_unfiltered_orders + + def test_monthly_trends_filter_by_category(self, client): + """Test filtering monthly trends by category.""" + response = client.get("/api/reports/monthly-trends?category=Power Supplies") + assert response.status_code == 200 + + data = response.json() + assert isinstance(data, list) + + unfiltered = client.get("/api/reports/monthly-trends").json() + total_filtered = sum(m["order_count"] for m in data) + total_unfiltered = sum(m["order_count"] for m in unfiltered) + assert total_filtered <= total_unfiltered + + def test_monthly_trends_filter_by_status(self, client): + """Test filtering monthly trends by status.""" + response = client.get("/api/reports/monthly-trends?status=Delivered") + assert response.status_code == 200 + + data = response.json() + assert isinstance(data, list) + + # When filtered to delivered only, delivered_count should equal order_count + for month in data: + assert month["delivered_count"] == month["order_count"] + + def test_monthly_trends_filter_by_month(self, client): + """Test filtering monthly trends by specific month.""" + response = client.get("/api/reports/monthly-trends?month=2025-03") + assert response.status_code == 200 + + data = response.json() + assert isinstance(data, list) + # Should only contain March 2025 + for month in data: + assert month["month"] == "2025-03" + + def test_monthly_trends_filter_all_passes_through(self, client): + """Test that filter value 'all' returns same as unfiltered.""" + unfiltered = client.get("/api/reports/monthly-trends").json() + filtered = client.get("/api/reports/monthly-trends?warehouse=all&category=all").json() + + assert len(unfiltered) == len(filtered) + + def test_monthly_trends_delivered_count_not_exceeds_total(self, client): + """Test that delivered_count never exceeds order_count.""" + response = client.get("/api/reports/monthly-trends") + data = response.json() + + for month in data: + assert month["delivered_count"] <= month["order_count"] + + def test_monthly_trends_multiple_filters(self, client): + """Test filtering monthly trends with multiple filters.""" + response = client.get( + "/api/reports/monthly-trends?warehouse=San Francisco&category=Sensors" + ) + assert response.status_code == 200 + + data = response.json() + assert isinstance(data, list) + + # Combined filters should produce fewer results + warehouse_only = client.get( + "/api/reports/monthly-trends?warehouse=San Francisco" + ).json() + total_combined = sum(m["order_count"] for m in data) + total_warehouse = sum(m["order_count"] for m in warehouse_only) + assert total_combined <= total_warehouse