diff --git a/CLAUDE.md b/CLAUDE.md index 89c307d15..462f0f798 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,6 +48,9 @@ npm install && npm run dev **Data Flow**: Vue filters → `client/src/api.js` → FastAPI → In-memory filtering → Pydantic validation → Computed properties **Reactivity**: Raw data in refs (`allOrders`, `inventoryItems`), derived data in computed properties +## Coding Conventions +- **Always document non-obvious logic changes with comments** — explain the *why* for edge cases, workarounds, non-trivial calculations, and ordering dependencies. Match surrounding comment style; don't over-comment obvious code. + ## API Endpoints - `GET /api/inventory` - Filters: warehouse, category - `GET /api/orders` - Filters: warehouse, category, status, month diff --git a/client/src/App.vue b/client/src/App.vue index c2da05a5c..b61aae150 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -16,6 +16,9 @@ {{ t('nav.orders') }} + + {{ t('nav.restocking') }} + {{ t('nav.finance') }} diff --git a/client/src/api.js b/client/src/api.js index 11cb9db70..3d4b82b11 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -33,6 +33,11 @@ export const api = { return response.data }, + async createOrder(orderData) { + const response = await axios.post(`${API_BASE_URL}/orders`, orderData) + return response.data + }, + async getDemandForecasts() { const response = await axios.get(`${API_BASE_URL}/demand`) return response.data diff --git a/client/src/locales/en.js b/client/src/locales/en.js index 03a58fe6e..a22e7c0ec 100644 --- a/client/src/locales/en.js +++ b/client/src/locales/en.js @@ -4,6 +4,7 @@ export default { overview: 'Overview', inventory: 'Inventory', orders: 'Orders', + restocking: 'Restocking', finance: 'Finance', demandForecast: 'Demand Forecast', companyName: 'Catalyst Components', @@ -106,12 +107,14 @@ export default { title: 'Orders', description: 'View and manage customer orders', allOrders: 'All Orders', + submittedOrders: 'Submitted Orders', totalOrders: 'Total Orders', totalRevenue: 'Total Revenue', avgOrderValue: 'Avg Order Value', onTimeDelivery: 'On-Time Delivery', itemsCount: '{count} items', quantity: 'Qty', + leadTimeDays: '{days} days', table: { orderNumber: 'Order Number', orderId: 'Order ID', @@ -125,7 +128,39 @@ export default { totalValue: 'Total Value', status: 'Status', expectedDelivery: 'Expected Delivery', - actualDelivery: 'Actual Delivery' + actualDelivery: 'Actual Delivery', + leadTime: 'Lead Time' + } + }, + + // Restocking + restocking: { + title: 'Restocking', + description: 'Turn demand forecasts into a budget-constrained restock order', + budget: 'Budget', + recommendedTotal: 'Recommended Total', + remaining: 'Remaining', + itemsSelected: 'Items Selected', + setBudget: 'Set Budget', + availableBudget: 'Available Budget', + recommendedRestock: 'Recommended Restock', + placeOrder: 'Place Order', + placing: 'Placing order...', + empty: 'No items fit within the current budget. Increase the budget to see recommendations.', + overBudget: 'Selected total exceeds the available budget. Reduce quantities or deselect items.', + withinBudget: 'Within budget', + success: 'Order {orderNumber} submitted successfully.', + leadTimeDays: '{days} days', + table: { + sku: 'SKU', + item: 'Item', + trend: 'Trend', + unitCost: 'Unit Cost', + projectedGap: 'Projected Gap', + include: 'Include', + quantity: 'Quantity', + lineTotal: 'Line Total', + leadTime: 'Lead Time' } }, @@ -204,6 +239,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..f42b485b8 100644 --- a/client/src/locales/ja.js +++ b/client/src/locales/ja.js @@ -4,6 +4,7 @@ export default { overview: '概要', inventory: '在庫', orders: '注文', + restocking: '補充', finance: '財務', demandForecast: '需要予測', companyName: '触媒コンポーネンツ', @@ -106,12 +107,14 @@ export default { title: '注文', description: '顧客注文の表示と管理', allOrders: 'すべての注文', + submittedOrders: '送信済み注文', totalOrders: '総注文数', totalRevenue: '総収益', avgOrderValue: '平均注文額', onTimeDelivery: '定時配達', itemsCount: '{count}件', quantity: '数量', + leadTimeDays: '{days}日', table: { orderNumber: '注文番号', orderId: '注文ID', @@ -125,7 +128,39 @@ export default { totalValue: '合計金額', status: 'ステータス', expectedDelivery: '予定配達日', - actualDelivery: '実際の配達日' + actualDelivery: '実際の配達日', + leadTime: 'リードタイム' + } + }, + + // Restocking + restocking: { + title: '補充', + description: '需要予測を予算制約付きの補充注文に変換します', + budget: '予算', + recommendedTotal: '推奨合計', + remaining: '残額', + itemsSelected: '選択品目数', + setBudget: '予算を設定', + availableBudget: '利用可能予算', + recommendedRestock: '推奨補充', + placeOrder: '注文する', + placing: '注文送信中...', + empty: '現在の予算内に収まる品目がありません。予算を増やすと推奨が表示されます。', + overBudget: '選択合計が利用可能予算を超えています。数量を減らすか品目の選択を解除してください。', + withinBudget: '予算内', + success: '注文 {orderNumber} が正常に送信されました。', + leadTimeDays: '{days}日', + table: { + sku: 'SKU', + item: '品目', + trend: 'トレンド', + unitCost: '単価', + projectedGap: '予測ギャップ', + include: '含める', + quantity: '数量', + lineTotal: '小計', + leadTime: 'リードタイム' } }, @@ -204,6 +239,7 @@ export default { shipped: '出荷済み', processing: '処理中', backordered: 'バックオーダー', + submitted: '送信済み', inStock: '在庫あり', lowStock: '在庫僅少', adequate: '適量' diff --git a/client/src/main.js b/client/src/main.js index 477c2d966..35668d241 100644 --- a/client/src/main.js +++ b/client/src/main.js @@ -4,6 +4,7 @@ import App from './App.vue' import Dashboard from './views/Dashboard.vue' import Inventory from './views/Inventory.vue' import Orders from './views/Orders.vue' +import Restocking from './views/Restocking.vue' import Demand from './views/Demand.vue' import Spending from './views/Spending.vue' import Reports from './views/Reports.vue' @@ -14,6 +15,7 @@ const router = createRouter({ { path: '/', component: Dashboard }, { path: '/inventory', component: Inventory }, { path: '/orders', component: Orders }, + { path: '/restocking', component: Restocking }, { path: '/demand', component: Demand }, { path: '/spending', component: Spending }, { path: '/reports', component: Reports } diff --git a/client/src/views/Orders.vue b/client/src/views/Orders.vue index 7413f6e66..9db142b4a 100644 --- a/client/src/views/Orders.vue +++ b/client/src/views/Orders.vue @@ -27,9 +27,61 @@ + +
+
+

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

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
{{ t('orders.table.orderNumber') }}{{ t('orders.table.customer') }}{{ t('orders.table.items') }}{{ t('orders.table.status') }}{{ t('orders.table.orderDate') }}{{ t('orders.table.expectedDelivery') }}{{ t('orders.table.leadTime') }}{{ t('orders.table.totalValue') }}
{{ order.order_number }}{{ translateCustomerName(order.customer) }} +
+ + {{ t('orders.itemsCount', { count: order.items.length }) }} + +
+
+ {{ translateProductName(item.name) }} + {{ t('orders.quantity') }}: {{ item.quantity }} @ {{ currencySymbol }}{{ item.unit_price }} +
+
+
+
+ + {{ t(`status.${order.status.toLowerCase()}`) }} + + {{ formatDate(order.order_date) }}{{ formatDate(order.expected_delivery) }}{{ t('orders.leadTimeDays', { days: getLeadTimeDays(order) }) }}{{ currencySymbol }}{{ order.total_value.toLocaleString() }}
+
+
+ +
-

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

+

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

@@ -45,7 +97,7 @@ - +
{{ order.order_number }} {{ translateCustomerName(order.customer) }} @@ -138,11 +190,29 @@ export default { 'Delivered': 'success', 'Shipped': 'info', 'Processing': 'warning', - 'Backordered': 'danger' + 'Backordered': 'danger', + // Submitted status used for restocking orders placed via the Restocking tab + 'Submitted': 'info' } return statusMap[status] || 'info' } + // Orders placed via the Restocking tab arrive with status 'Submitted' + const submittedOrders = computed(() => orders.value.filter(o => o.status === 'Submitted')) + // All other orders (avoids double-listing in the main table) + const mainOrders = computed(() => orders.value.filter(o => o.status !== 'Submitted')) + + /** + * Derives lead time in days from order_date → expected_delivery. + * Returns 0 for invalid/missing dates rather than NaN. + */ + const getLeadTimeDays = (order) => { + const start = new Date(order.order_date) + const end = new Date(order.expected_delivery) + const diff = Math.round((end - start) / 86400000) + return Number.isFinite(diff) ? diff : 0 + } + const formatDate = (dateString) => { const { currentLocale } = useI18n() const locale = currentLocale.value === 'ja' ? 'ja-JP' : 'en-US' @@ -160,8 +230,11 @@ export default { loading, error, orders, + submittedOrders, + mainOrders, getOrdersByStatus, getOrderStatusClass, + getLeadTimeDays, formatDate, currencySymbol, translateProductName, diff --git a/client/src/views/Restocking.vue b/client/src/views/Restocking.vue new file mode 100644 index 000000000..2bc0caf36 --- /dev/null +++ b/client/src/views/Restocking.vue @@ -0,0 +1,466 @@ + + + + + diff --git a/docs/architecture.html b/docs/architecture.html new file mode 100644 index 000000000..f500bcc04 --- /dev/null +++ b/docs/architecture.html @@ -0,0 +1,441 @@ + + + + + + Factory Inventory Management — System Architecture + + + + +
+
+

System Architecture

+

Factory Inventory Management System

+

A full-stack demo for tracking inventory, orders, demand, backlog, and spending across warehouses. Vue 3 single-page frontend, FastAPI backend, and in-memory data loaded from JSON files — no database.

+
+ Frontend · localhost:3000 + Backend API · localhost:8001 + Data · 7 in-memory JSON datasets +
+
+
+ + +
+
+
+

Architecture Overview

+

A classic three-tier layout. The browser SPA calls a REST API over HTTP; the API filters in-memory Python collections and validates responses before returning them.

+
+ +
+ +
+
Tier 1 Presentation · Vue 3 SPA (Vite dev server, port 3000)
+
+

Views

Dashboard, Inventory, Orders, Demand, Spending, Reports
+

Components

Filter bar, detail modals, profile menu, tasks, i18n switcher
+

Composables

useFilters, useAuth, useI18n (shared reactive state)
+

Router

vue-router, 6 client-side routes
+

API Client

api.js — axios wrapper, query-param builder
+
+
+ +
+
+ HTTP / JSON · axios GET with query params + + +
+
+ + +
+
Tier 2 Application · FastAPI + Uvicorn (port 8001)
+
+

Routes

Inventory, orders, demand, backlog, dashboard, spending, reports
+

Filtering

apply_filters + filter_by_month (warehouse / category / status / month)
+

Business Logic

Dashboard summary, quarterly & monthly-trend aggregation
+

Validation

Pydantic models as response_model schemas
+

CORS

Middleware, allow-all origins (dev only)
+
+
+ +
+
+ In-process function calls · module import + + +
+
+ + +
+
Tier 3 Data · In-memory collections (mock_data.py)
+
+

Loader

mock_data.py reads JSON at startup into Python lists/dicts
+

Datasets

inventory, orders, demand_forecasts, backlog_items
+

Spending

spending summary, monthly, categories, transactions
+

Purchasing

purchase_orders (linked to backlog items)
+

Persistence

None — changes are lost on restart (reloaded from files)
+
+
+
+
+
+ + +
+
+
+

Technology Stack

+

Versions as declared in client/package.json and server/pyproject.toml.

+
+ +
+
+

Frontend

client/
+
    +
  • Vue 3^3.4.21 · Composition API
  • +
  • Vue Router^4.3.0
  • +
  • Axios^1.6.7
  • +
  • Vite^5.2.0 · dev/build
  • +
  • @vitejs/plugin-vue^5.0.4
  • +
+
+ +
+

Backend

server/
+
    +
  • Python≥ 3.11
  • +
  • FastAPI≥ 0.110.0
  • +
  • Uvicorn≥ 0.24.0 · ASGI server
  • +
  • Pydantic≥ 2.5.0 · validation
  • +
+
+ +
+

Data

server/data/
+
    +
  • FormatStatic JSON files
  • +
  • StoreIn-memory (no DB)
  • +
  • Datasets7 files
  • +
  • Loaded bymock_data.py
  • +
+
+ +
+

Tooling

workspace
+
    +
  • npmclient packages
  • +
  • uvserver env & run
  • +
  • pytest + httpxbackend tests
  • +
  • i18nen / ja locales
  • +
+
+
+
+
+ + +
+
+
+

Data Flow

+

How a single filtered request travels from a user click to rendered output.

+
+ +
+
+
1
+
+

User adjusts a filterFrontend

+

Time Period, Warehouse, Category, or Order Status changes in the FilterBar. State lives in the useFilters composable as reactive refs shared across views.

+
+
+ +
+
2
+
+

View calls the API clientFrontend

+

A view (e.g. Dashboard.vue) passes the current filters to api.js, which builds a query string and issues an axios GET. Values equal to 'all' are omitted.

+
+
+ +
+
3
+
+

Request hits a FastAPI routeBackend

+

Endpoints such as /api/orders or /api/dashboard/summary receive the filters as optional query params.

+
+
+ +
+
4
+
+

In-memory filtering & aggregationData

+

apply_filters() and filter_by_month() narrow the Python lists (case-insensitive match, quarter mapping). Report endpoints aggregate totals, revenue, and fulfillment rates on the fly.

+
+
+ +
+
5
+
+

Pydantic validates the responseBackend

+

Typed response_model schemas (e.g. InventoryItem, Order) serialize and validate the payload before it is returned as JSON.

+
+
+ +
+
6
+
+

Vue stores & derivesFrontend

+

Raw results land in refs; computed properties derive KPIs, chart series, and tables. Custom SVG charts and CSS-grid layouts render the result to the user.

+
+
+
+
+
+ + +
+
+
+

API Endpoints

+

All read-only GET routes under /api. Filters are optional query parameters.

+
+ + + + + + + + + + + + + + + + + + + + +
MethodPathFiltersReturns
GET/api/inventorywarehouse, categoryInventory items
GET/api/inventory/{id}noneSingle item (404 if missing)
GET/api/orderswarehouse, category, status, monthOrders
GET/api/orders/{id}noneSingle order (404 if missing)
GET/api/demandnoneDemand forecasts
GET/api/backlognoneBacklog + purchase-order flag
GET/api/dashboard/summarywarehouse, category, status, monthKPI summary object
GET/api/spending/summarynoneSpending totals
GET/api/spending/monthlynoneMonthly spending
GET/api/spending/categoriesnoneSpending by category
GET/api/spending/transactionsnoneRecent transactions
GET/api/reports/quarterlynoneQuarterly performance
GET/api/reports/monthly-trendsnoneMonth-over-month trends
+
+
+ + + + + diff --git a/server/data/demand_forecasts.json b/server/data/demand_forecasts.json index e1b388385..d812321e2 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": 14 }, { "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": 10 }, { "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": 21 }, { "id": "4", @@ -33,7 +39,9 @@ "current_demand": 50, "forecasted_demand": 35, "trend": "decreasing", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 320.00, + "lead_time_days": 30 }, { "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.20, + "lead_time_days": 7 }, { "id": "6", @@ -51,7 +61,9 @@ "current_demand": 120, "forecasted_demand": 121, "trend": "stable", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 55.00, + "lead_time_days": 12 }, { "id": "7", @@ -60,7 +72,9 @@ "current_demand": 250, "forecasted_demand": 252, "trend": "stable", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 78.00, + "lead_time_days": 18 }, { "id": "8", @@ -69,7 +83,9 @@ "current_demand": 180, "forecasted_demand": 182, "trend": "stable", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 34.50, + "lead_time_days": 9 }, { "id": "9", @@ -78,6 +94,8 @@ "current_demand": 95, "forecasted_demand": 96, "trend": "stable", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 120.00, + "lead_time_days": 25 } ] diff --git a/server/main.py b/server/main.py index a0c2d8c5a..cb857cee8 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") @@ -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,19 @@ class CreatePurchaseOrderRequest(BaseModel): expected_delivery_date: str notes: Optional[str] = None +class CreateOrderItem(BaseModel): + sku: str + name: str + quantity: int + unit_price: float + +class CreateOrderRequest(BaseModel): + customer: str + items: List[CreateOrderItem] + lead_time_days: int + warehouse: Optional[str] = None + category: Optional[str] = None + # API endpoints @app.get("/") def root(): @@ -161,6 +177,61 @@ def get_order(order_id: str): raise HTTPException(status_code=404, detail="Order not found") return order +@app.post("/api/orders", response_model=Order, status_code=201) +def create_order(request: CreateOrderRequest): + """Create a new order (e.g. a restock order from the demand forecast). + + This is the app's only write endpoint. The order is appended to the + in-memory orders list with a "Submitted" status so it surfaces in the + existing GET /api/orders response. + """ + # Only persist line items that actually order something; a request with no + # positive quantities has nothing to buy and is rejected. + valid_items = [item for item in request.items if item.quantity > 0] + if not valid_items: + raise HTTPException(status_code=400, detail="Order must contain at least one item with quantity > 0") + + now = datetime.now() + # Clamp negative lead times to 0 so expected delivery never precedes the order date. + expected = now + timedelta(days=max(request.lead_time_days, 0)) + + # Generate a new numeric id from the current max (default 0 for an empty list). + new_id = str(max((int(o["id"]) for o in orders), default=0) + 1) + + # Order numbers look like ORD-YYYY-NNNN; derive the next sequence number from + # the largest existing numeric suffix so new numbers don't collide. + max_seq = 0 + for o in orders: + suffix = o.get("order_number", "").rsplit("-", 1)[-1] + if suffix.isdigit(): + max_seq = max(max_seq, int(suffix)) + order_number = f"ORD-{now.year}-{max_seq + 1:04d}" + + total_value = round(sum(item.quantity * item.unit_price for item in valid_items), 2) + + new_order = { + "id": new_id, + "order_number": order_number, + "customer": request.customer, + "items": [item.model_dump() for item in valid_items], + "status": "Submitted", + "order_date": now.isoformat(timespec="seconds"), + "expected_delivery": expected.isoformat(timespec="seconds"), + "total_value": total_value, + "actual_delivery": None, + "warehouse": request.warehouse, + "category": request.category + } + + # Deliberately mutate the module-level in-memory list so the new order is + # visible to later GET /api/orders calls. The server CLAUDE.md flags "don't + # mutate global data", but this is intentional and non-persistent by design: + # there is no database, and a server restart reloads orders from JSON, which + # drops any submitted orders. + orders.append(new_order) + + return new_order + @app.get("/api/demand", response_model=List[DemandForecast]) def get_demand_forecasts(): """Get demand forecasts""" diff --git a/tests/backend/test_misc_endpoints.py b/tests/backend/test_misc_endpoints.py index 5a48fda8c..20557bd6e 100644 --- a/tests/backend/test_misc_endpoints.py +++ b/tests/backend/test_misc_endpoints.py @@ -45,6 +45,20 @@ def test_demand_forecast_values(self, client): assert forecast["current_demand"] >= 0 assert forecast["forecasted_demand"] >= 0 + def test_demand_forecast_cost_and_lead_time(self, client): + """Test that every forecast exposes numeric unit_cost and int lead_time_days.""" + response = client.get("/api/demand") + data = response.json() + + for forecast in data: + assert "unit_cost" in forecast + assert "lead_time_days" in forecast + assert isinstance(forecast["unit_cost"], (int, float)) + assert forecast["unit_cost"] >= 0 + # lead_time_days is an int (bool is a subclass of int, so exclude it) + assert isinstance(forecast["lead_time_days"], int) and not isinstance(forecast["lead_time_days"], bool) + assert forecast["lead_time_days"] >= 0 + def test_stable_demand_items_have_small_changes(self, client): """Test that items with 'stable' trend have less than 2% change.""" response = client.get("/api/demand") diff --git a/tests/backend/test_orders.py b/tests/backend/test_orders.py new file mode 100644 index 000000000..7de68aca0 --- /dev/null +++ b/tests/backend/test_orders.py @@ -0,0 +1,142 @@ +""" +Tests for the orders API endpoints, focused on the POST /api/orders write endpoint. + +Note on isolation: the FastAPI app keeps orders in a single module-level list that +persists for the whole test session, so created orders accumulate across tests. +Every assertion here is therefore order-independent: we capture a GET baseline, +assert on deltas, and locate created orders by their unique order_number / id +rather than by absolute counts or list positions. +""" +import re +from datetime import datetime + +import pytest + + +ORDER_NUMBER_PATTERN = re.compile(r"^ORD-\d{4}-\d{4}$") + + +def _valid_payload(): + """A well-formed create-order request with two positive-quantity line items.""" + return { + "customer": "Internal Restock", + "lead_time_days": 14, + "items": [ + {"sku": "WDG-001", "name": "Industrial Widget Type A", "quantity": 10, "unit_price": 12.50}, + {"sku": "FLT-405", "name": "Oil Filter Cartridge", "quantity": 25, "unit_price": 6.20}, + ], + } + + +class TestCreateOrder: + """Test suite for the POST /api/orders endpoint.""" + + def test_create_order_returns_201_and_submitted_status(self, client): + """A valid POST returns 201 with a Submitted order and a well-formed number.""" + response = client.post("/api/orders", json=_valid_payload()) + assert response.status_code == 201 + + order = response.json() + assert order["status"] == "Submitted" + assert ORDER_NUMBER_PATTERN.match(order["order_number"]), \ + f"order_number {order['order_number']!r} does not match ORD-YYYY-NNNN" + assert order["actual_delivery"] is None + assert order["customer"] == "Internal Restock" + + def test_create_order_total_value_calculation(self, client): + """total_value equals the rounded sum of quantity * unit_price over items.""" + payload = _valid_payload() + response = client.post("/api/orders", json=payload) + assert response.status_code == 201 + + order = response.json() + expected_total = round( + sum(item["quantity"] * item["unit_price"] for item in payload["items"]), 2 + ) + assert order["total_value"] == expected_total + + def test_create_order_expected_delivery_after_order_date(self, client): + """expected_delivery is after order_date by exactly lead_time_days days.""" + payload = _valid_payload() + payload["lead_time_days"] = 21 + response = client.post("/api/orders", json=payload) + assert response.status_code == 201 + + order = response.json() + order_date = datetime.fromisoformat(order["order_date"]) + expected_delivery = datetime.fromisoformat(order["expected_delivery"]) + + assert expected_delivery > order_date + assert (expected_delivery - order_date).days == payload["lead_time_days"] + + def test_create_order_appears_in_get_orders(self, client): + """A created order appears in a subsequent GET /api/orders (count +1).""" + baseline = client.get("/api/orders?status=all&month=all").json() + baseline_count = len(baseline) + + response = client.post("/api/orders", json=_valid_payload()) + assert response.status_code == 201 + created = response.json() + + after = client.get("/api/orders?status=all&month=all").json() + assert len(after) == baseline_count + 1 + + # Locate by unique id rather than position. + match = next((o for o in after if o["id"] == created["id"]), None) + assert match is not None, "created order not found in GET /api/orders" + assert match["order_number"] == created["order_number"] + assert match["status"] == "Submitted" + + def test_create_order_new_id_and_order_number_are_unique(self, client): + """Two consecutive creates produce distinct ids and order numbers.""" + first = client.post("/api/orders", json=_valid_payload()).json() + second = client.post("/api/orders", json=_valid_payload()).json() + + assert first["id"] != second["id"] + assert first["order_number"] != second["order_number"] + + def test_create_order_empty_items_returns_400(self, client): + """An order with no items is rejected with 400.""" + payload = _valid_payload() + payload["items"] = [] + response = client.post("/api/orders", json=payload) + assert response.status_code == 400 + assert "detail" in response.json() + + def test_create_order_all_zero_quantity_returns_400(self, client): + """An order whose items all have quantity 0 is rejected with 400.""" + payload = _valid_payload() + for item in payload["items"]: + item["quantity"] = 0 + response = client.post("/api/orders", json=payload) + assert response.status_code == 400 + assert "detail" in response.json() + + def test_create_order_mixed_quantities_persists_only_positive(self, client): + """Only positive-quantity line items are persisted; total reflects them.""" + payload = { + "customer": "Internal Restock", + "lead_time_days": 7, + "items": [ + {"sku": "WDG-001", "name": "Industrial Widget Type A", "quantity": 5, "unit_price": 12.50}, + {"sku": "GSK-203", "name": "High-Temperature Gasket", "quantity": 0, "unit_price": 8.75}, + {"sku": "FLT-405", "name": "Oil Filter Cartridge", "quantity": 3, "unit_price": 6.20}, + ], + } + response = client.post("/api/orders", json=payload) + assert response.status_code == 201 + + order = response.json() + persisted_skus = {item["sku"] for item in order["items"]} + assert persisted_skus == {"WDG-001", "FLT-405"} + assert all(item["quantity"] > 0 for item in order["items"]) + + expected_total = round(5 * 12.50 + 3 * 6.20, 2) + assert order["total_value"] == expected_total + + def test_create_order_missing_required_field_returns_422(self, client): + """A request missing a required field fails Pydantic validation with 422.""" + payload = _valid_payload() + del payload["customer"] + response = client.post("/api/orders", json=payload) + assert response.status_code == 422