diff --git a/CLAUDE.md b/CLAUDE.md index d2086efa..af382bfd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,6 +46,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 +## Code Style +- Always document non-obvious logic changes with comments + ## 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 c2da05a5..dd6849b1 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 11cb9db7..fd17fec7 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -102,5 +102,10 @@ export const api = { async getPurchaseOrderByBacklogItem(backlogItemId) { const response = await axios.get(`${API_BASE_URL}/purchase-orders/${backlogItemId}`) return response.data + }, + + async createRestockOrder(payload) { + const response = await axios.post(`${API_BASE_URL}/orders`, payload) + return response.data } } diff --git a/client/src/locales/en.js b/client/src/locales/en.js index 03a58fe6..fae5869f 100644 --- a/client/src/locales/en.js +++ b/client/src/locales/en.js @@ -6,6 +6,7 @@ export default { orders: 'Orders', finance: 'Finance', demandForecast: 'Demand Forecast', + restocking: 'Restocking', companyName: 'Catalyst Components', subtitle: 'Inventory Management System' }, @@ -106,11 +107,13 @@ 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', + leadTimeDays: '{days} days', quantity: 'Qty', table: { orderNumber: 'Order Number', @@ -125,7 +128,33 @@ export default { totalValue: 'Total Value', status: 'Status', expectedDelivery: 'Expected Delivery', - actualDelivery: 'Actual Delivery' + actualDelivery: 'Actual Delivery', + leadTime: 'Lead Time' + } + }, + + // Restocking + restocking: { + title: 'Restocking', + description: 'Set a budget and order recommended restock quantities from the demand forecast', + budgetLabel: 'Available Budget', + recommendationsTitle: 'Recommended Restock', + noRecommendations: 'No items fit within this budget. Increase the budget to see recommendations.', + totalCost: 'Total Cost', + remaining: 'Remaining Budget', + placeOrder: 'Place Order', + placingOrder: 'Placing order...', + orderPlaced: 'Restocking order {orderNumber} submitted successfully.', + viewInOrders: 'View in Orders', + table: { + sku: 'SKU', + itemName: 'Item Name', + currentDemand: 'Current Demand', + forecastedDemand: 'Forecasted Demand', + recommendedQty: 'Recommended Qty', + unitCost: 'Unit Cost', + lineTotal: 'Line Total', + trend: 'Trend' } }, @@ -204,6 +233,7 @@ export default { shipped: 'Shipped', processing: 'Processing', backordered: 'Backordered', + submitted: 'Submitted', inStock: 'In Stock', lowStock: 'Low Stock', adequate: 'Adequate' diff --git a/client/src/locales/ja.js b/client/src/locales/ja.js index db33223a..d99d9abd 100644 --- a/client/src/locales/ja.js +++ b/client/src/locales/ja.js @@ -6,6 +6,7 @@ export default { orders: '注文', finance: '財務', demandForecast: '需要予測', + restocking: '在庫補充', companyName: '触媒コンポーネンツ', subtitle: '在庫管理システム' }, @@ -106,11 +107,13 @@ export default { title: '注文', description: '顧客注文の表示と管理', allOrders: 'すべての注文', + submittedOrders: '提出済み注文', totalOrders: '総注文数', totalRevenue: '総収益', avgOrderValue: '平均注文額', onTimeDelivery: '定時配達', itemsCount: '{count}件', + leadTimeDays: '{days}日', quantity: '数量', table: { orderNumber: '注文番号', @@ -125,7 +128,33 @@ export default { totalValue: '合計金額', status: 'ステータス', expectedDelivery: '予定配達日', - actualDelivery: '実際の配達日' + actualDelivery: '実際の配達日', + leadTime: 'リードタイム' + } + }, + + // Restocking + restocking: { + title: '在庫補充', + description: '予算を設定し、需要予測から推奨される補充数量を発注します', + budgetLabel: '利用可能な予算', + recommendationsTitle: '推奨補充', + noRecommendations: 'この予算に収まる品目がありません。予算を増やすと推奨が表示されます。', + totalCost: '合計コスト', + remaining: '残り予算', + placeOrder: '発注する', + placingOrder: '発注中...', + orderPlaced: '在庫補充注文 {orderNumber} が正常に提出されました。', + viewInOrders: '注文で表示', + table: { + sku: 'SKU', + itemName: '品目名', + currentDemand: '現在の需要', + forecastedDemand: '予測需要', + recommendedQty: '推奨数量', + unitCost: '単価', + lineTotal: '小計', + trend: 'トレンド' } }, @@ -204,6 +233,7 @@ export default { shipped: '出荷済み', processing: '処理中', backordered: 'バックオーダー', + submitted: '提出済み', inStock: '在庫あり', lowStock: '在庫僅少', adequate: '適量' diff --git a/client/src/main.js b/client/src/main.js index 477c2d96..cea20394 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 7413f6e6..524fb3b0 100644 --- a/client/src/views/Orders.vue +++ b/client/src/views/Orders.vue @@ -29,7 +29,7 @@
-

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

+

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

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

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

+
+
+ + + + + + + + + + + + + + + + + + + + + +
{{ t('orders.table.orderNumber') }}{{ t('orders.table.items') }}{{ t('orders.table.totalValue') }}{{ t('orders.table.orderDate') }}{{ t('orders.table.expectedDelivery') }}{{ t('orders.table.leadTime') }}
{{ order.order_number }} +
+ + {{ t('orders.itemsCount', { count: order.items.length }) }} + +
+
+ {{ translateProductName(item.name) }} + {{ t('orders.quantity') }}: {{ item.quantity }} @ {{ currencySymbol }}{{ item.unit_price }} +
+
+
+
{{ currencySymbol }}{{ order.total_value.toLocaleString() }}{{ formatDate(order.order_date) }}{{ formatDate(order.expected_delivery) }}{{ t('orders.leadTimeDays', { days: getLeadTimeDays(order) }) }}
+
+
@@ -129,6 +171,17 @@ export default { loadOrders() }) + // Orders shown in the main "All Orders" table — submitted restock orders + // are broken out into their own section below. + const activeOrders = computed(() => { + return orders.value.filter(order => order.status !== 'Submitted') + }) + + // Submitted restock orders, rendered in a dedicated card. + const submittedOrders = computed(() => { + return orders.value.filter(order => order.status === 'Submitted') + }) + const getOrdersByStatus = (status) => { return orders.value.filter(order => order.status === status) } @@ -138,11 +191,20 @@ export default { 'Delivered': 'success', 'Shipped': 'info', 'Processing': 'warning', - 'Backordered': 'danger' + 'Backordered': 'danger', + 'Submitted': 'submitted' } return statusMap[status] || 'info' } + // Lead time in whole days between order placement and expected delivery. + // 86400000 ms = 1 day; Math.round smooths any DST/partial-day drift. + const getLeadTimeDays = (order) => { + return Math.round( + (new Date(order.expected_delivery) - new Date(order.order_date)) / 86400000 + ) + } + const formatDate = (dateString) => { const { currentLocale } = useI18n() const locale = currentLocale.value === 'ja' ? 'ja-JP' : 'en-US' @@ -160,8 +222,11 @@ export default { loading, error, orders, + activeOrders, + submittedOrders, getOrdersByStatus, getOrderStatusClass, + getLeadTimeDays, formatDate, currencySymbol, translateProductName, @@ -203,6 +268,16 @@ export default { width: 120px; } +.col-lead-time { + width: 120px; +} + +/* Submitted restock orders badge */ +.badge.submitted { + background: #e0e7ff; + color: #3730a3; +} + /* Items details styling */ .items-details { position: relative; diff --git a/client/src/views/Restocking.vue b/client/src/views/Restocking.vue new file mode 100644 index 00000000..cdf6f024 --- /dev/null +++ b/client/src/views/Restocking.vue @@ -0,0 +1,364 @@ + + + + + diff --git a/docs/architecture.html b/docs/architecture.html new file mode 100644 index 00000000..c7f76126 --- /dev/null +++ b/docs/architecture.html @@ -0,0 +1,470 @@ + + + + + +Factory Inventory Management — System Architecture + + + + +
+
+

System Architecture

+

Factory Inventory Management System

+

A full-stack demo application for inventory tracking, order management, demand forecasting, + and spending analytics. Single-page Vue 3 frontend, FastAPI backend, and file-based mock + data — no database.

+
+ Vue 3 + Vite + Python · FastAPI + Pydantic + Axios + In-memory JSON data + REST API +
+
+
+ + + +
+ + +
+

Overview

+
A two-tier web app with a clear client / server split
+

+ The system is organized into a Vue 3 single-page application (port 3000) that + renders all UI and charts, and a FastAPI service (port 8001) that exposes a + read-focused REST API over sample data. There is no database — the backend loads JSON files + from disk into memory at startup and serves them through validated, filterable endpoints. + Communication is one direction at runtime: the browser calls the API over HTTP/JSON via Axios. +

+
+
+
UI
+

Presentation

+
client/ · Vue 3 SPA
+
  • 7 routed views
  • 9 reusable components
  • Custom SVG charts, i18n (EN/JA)
+
+
+
API
+

Application

+
server/ · FastAPI
+
  • REST endpoints + filtering
  • Pydantic validation
  • CORS for the dev frontend
+
+
+
DATA
+

Data

+
server/data/ · JSON
+
  • 7 JSON datasets
  • Loaded once at startup
  • In-memory, read-only
+
+
+
+ + +
+

Tech Stack

+
What each layer is built with
+
+
+
F
+

Frontend

+
client/
+
    +
  • Vue 3 (Composition API)
  • +
  • Vite 5 dev server & build
  • +
  • vue-router 4 (history mode)
  • +
  • Axios HTTP client
  • +
  • Composables: filters, auth, i18n
  • +
  • Hand-rolled SVG charts
  • +
+
+
+
B
+

Backend

+
server/
+
    +
  • Python ≥ 3.11
  • +
  • FastAPI web framework
  • +
  • Uvicorn ASGI server
  • +
  • Pydantic v2 models
  • +
  • CORS middleware
  • +
+
+
+
D
+

Data

+
server/data/
+
    +
  • inventory.json
  • +
  • orders.json
  • +
  • demand_forecasts.json
  • +
  • backlog_items.json
  • +
  • purchase_orders.json
  • +
  • spending.json · transactions.json
  • +
+
+
+
T
+

Tooling

+
repo-wide
+
    +
  • uv (Python env & deps)
  • +
  • npm (frontend deps)
  • +
  • pytest + FastAPI TestClient
  • +
  • .claude/ agents, commands, hooks
  • +
+
+
+
+ + +
+

Architecture

+
How the tiers connect
+

Each box is a real layer in the codebase. Data is loaded bottom-up at startup, + and requests flow top-down at runtime.

+ +
+ +
+
+ +

Browser — Vue 3 SPA

+ localhost:3000 +
+
+
App.vueLayout, nav, FilterBar, ProfileMenu
+
Views (7)Dashboard, Inventory, Orders, Demand, Spending, Reports, Backlog
+
Components (9)Detail modals, filters, language switcher
+
ComposablesuseFilters · useAuth · useI18n
+
+
+ +
+ + api.js (Axios) — HTTP GET/POST with filter query params +
+ +
+
+ +

FastAPI Service

+ localhost:8001 +
+
+
Route handlersmain.py — /api/* endpoints
+
Filter helpersapply_filters() · filter_by_month()
+
Pydantic modelsValidate & shape responses
+
CORS middlewareAllows the dev frontend origin
+
+
+ +
+ + mock_data.py — imports in-memory Python lists/dicts +
+ +
+
+ +

Data Layer — JSON files

+ server/data/ · read-only +
+
+
load_json_file()Reads each dataset once at import time
+
7 datasetsinventory, orders, demand, backlog, POs, spending, transactions
+
No persistenceChanges don't survive restart
+
+
+ +
+
+ + +
+

Data Flow

+
A filtered request, end to end
+

The four global filters — Time Period, Warehouse, Category, Order Status — live in a + shared useFilters composable and drive every data fetch. Here is the round trip when a + user changes a filter on the Dashboard.

+
+
+
+

User changes a filter

+

The FilterBar updates shared refs in useFilters.js + (selectedPeriod, selectedLocation, selectedCategory, + selectedStatus).

+
+
+
+

View reacts & requests data

+

The active view calls getCurrentFilters() and passes the result to a method on + api.js, e.g. api.getDashboardSummary(filters).

+
+
+
+

Axios builds the HTTP request

+

Non-"all" filters become query params and Axios issues + GET http://localhost:8001/api/dashboard/summary?warehouse=…&month=….

+
+
+
+

FastAPI filters in memory

+

The route handler runs apply_filters() (warehouse / category / status) and + filter_by_month() (month or quarter) over the in-memory lists.

+
+
+
+

Pydantic validates the response

+

Results are shaped/validated against models (InventoryItem, Order, + …) and serialized to JSON.

+
+
+
+

Vue renders

+

The view stores raw data in refs; computed properties derive metrics and + chart inputs, and the SVG charts & tables re-render reactively.

+
+
+
+ + +
+

API Surface

+
Endpoints exposed by the backend
+

All filtering is done via optional query params: warehouse, + category, status, month. Inventory has no time dimension, so + it ignores status/month.

+ + + + + + + + + + + + + + + +
MethodPathPurposeFilters
GET/api/inventoryInventory itemswarehouse, category
GET/api/inventory/{id}Single item (404 if missing)
GET/api/ordersOrderswarehouse, category, status, month
GET/api/orders/{id}Single order
GET/api/demandDemand forecasts
GET/api/backlogBacklog + has_purchase_order flag
GET/api/dashboard/summaryAggregate KPIsall four
GET/api/spending/*summary · monthly · categories · transactions
GET/api/reports/quarterlyQuarterly performance
GET/api/reports/monthly-trendsMonth-over-month trends
PLANNED/api/tasks, /api/purchase-ordersClient (api.js) already calls these; backend routes not yet implemented
+
+ Note: the frontend's api.js defines methods for Tasks and + Purchase-Order endpoints (and a CreatePurchaseOrderRequest model exists in + main.py), but the matching backend routes are not yet wired up — the client is + slightly ahead of the server here. +
+
+ + +
+

Project Structure

+
Where things live
+
+inventory-management/ +├── client/ # Vue 3 + Vite frontend +│ └── src/ +│ ├── App.vue # Root layout + global styles +│ ├── main.js # App + vue-router setup +│ ├── api.js # Axios API client (single source of HTTP calls) +│ ├── views/ # 7 routed pages +│ ├── components/ # 9 reusable UI components / modals +│ ├── composables/ # useFilters, useAuth, useI18n +│ ├── locales/ # en.js, ja.js +│ └── utils/ # currency formatting +├── server/ # FastAPI backend +│ ├── main.py # Endpoints, models, filtering +│ ├── mock_data.py # Loads JSON into memory +│ └── data/ # 7 JSON datasets +├── tests/ # pytest backend tests +├── scripts/ # start.sh / stop.sh (macOS/Linux) +└── .claude/ # agents, commands, hooks, skills, MCP +
+
+ +
+ + + + + diff --git a/server/data/demand_forecasts.json b/server/data/demand_forecasts.json index e1b38838..3aadf5d6 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": 12.50 }, { "id": "2", @@ -15,7 +16,8 @@ "current_demand": 150, "forecasted_demand": 152, "trend": "stable", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 34.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.25 }, { "id": "4", @@ -33,7 +36,8 @@ "current_demand": 50, "forecasted_demand": 35, "trend": "decreasing", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 245.00 }, { "id": "5", @@ -42,7 +46,8 @@ "current_demand": 800, "forecasted_demand": 950, "trend": "increasing", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 15.99 }, { "id": "6", @@ -51,7 +56,8 @@ "current_demand": 120, "forecasted_demand": 121, "trend": "stable", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 62.50 }, { "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": 89.50 }, { "id": "9", @@ -78,6 +86,7 @@ "current_demand": 95, "forecasted_demand": 96, "trend": "stable", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 47.00 } ] diff --git a/server/main.py b/server/main.py index a0c2d8c5..2334eaea 100644 --- a/server/main.py +++ b/server/main.py @@ -2,10 +2,14 @@ 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") +# Fixed delivery lead time (in days) applied to submitted restocking orders +LEAD_TIME_DAYS = 14 + # Quarter mapping for date filtering QUARTER_MAP = { 'Q1-2025': ['2025-01', '2025-02', '2025-03'], @@ -89,6 +93,7 @@ class DemandForecast(BaseModel): forecasted_demand: int trend: str period: str + unit_cost: float class BacklogItem(BaseModel): id: str @@ -120,6 +125,16 @@ class CreatePurchaseOrderRequest(BaseModel): expected_delivery_date: str notes: Optional[str] = None +class RestockOrderItem(BaseModel): + sku: str + name: str + quantity: int + unit_price: float + +class CreateRestockOrderRequest(BaseModel): + items: List[RestockOrderItem] + warehouse: Optional[str] = None + # API endpoints @app.get("/") def root(): @@ -161,6 +176,43 @@ 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_restock_order(request: CreateRestockOrderRequest): + """Submit a restocking order. + + Builds a new order with the "Submitted" status, appends it to the in-memory + orders list (so GET /api/orders immediately reflects it), and returns it. + Note: data is in-memory only and resets when the server restarts. + """ + now = datetime.now() + + # Derive the next numeric id from existing orders, then format a restock-specific + # order number (RST- prefix distinguishes these from customer orders). + next_id = max((int(o["id"]) for o in orders), default=0) + 1 + new_id = str(next_id) + order_number = f"RST-2025-{next_id:04d}" + + items = [item.model_dump() for item in request.items] + total_value = round(sum(i["quantity"] * i["unit_price"] for i in items), 2) + + new_order = { + "id": new_id, + "order_number": order_number, + "customer": "Internal Restock", + "items": items, + "status": "Submitted", + "order_date": now.isoformat(timespec="seconds"), + # Fixed lead time: expected delivery is LEAD_TIME_DAYS after the order date + "expected_delivery": (now + timedelta(days=LEAD_TIME_DAYS)).isoformat(timespec="seconds"), + "total_value": total_value, + "actual_delivery": None, + "warehouse": request.warehouse, + "category": None, + } + + orders.append(new_order) + return new_order + @app.get("/api/demand", response_model=List[DemandForecast]) def get_demand_forecasts(): """Get demand forecasts""" @@ -194,9 +246,13 @@ def get_dashboard_summary( filtered_orders = apply_filters(orders, warehouse, category, status) filtered_orders = filter_by_month(filtered_orders, month) + # Submitted restocking orders are internal procurement, not customer orders, + # so they are excluded from the customer-facing dashboard metrics below. + customer_orders = [order for order in filtered_orders if order["status"] != "Submitted"] + total_inventory_value = sum(item["quantity_on_hand"] * item["unit_cost"] for item in filtered_inventory) low_stock_items = len([item for item in filtered_inventory if item["quantity_on_hand"] <= item["reorder_point"]]) - pending_orders = len([order for order in filtered_orders if order["status"] in ["Processing", "Backordered"]]) + pending_orders = len([order for order in customer_orders if order["status"] in ["Processing", "Backordered"]]) total_backlog_items = len(backlog_items) return { @@ -204,7 +260,7 @@ def get_dashboard_summary( "low_stock_items": low_stock_items, "pending_orders": pending_orders, "total_backlog_items": total_backlog_items, - "total_orders_value": sum(order["total_value"] for order in filtered_orders) + "total_orders_value": sum(order["total_value"] for order in customer_orders) } @app.get("/api/spending/summary") diff --git a/tests/backend/test_orders.py b/tests/backend/test_orders.py new file mode 100644 index 00000000..67813e0e --- /dev/null +++ b/tests/backend/test_orders.py @@ -0,0 +1,92 @@ +""" +Tests for order endpoints, including the restocking POST /api/orders endpoint. +""" +from datetime import datetime + + +class TestDemandUnitCost: + """Demand forecasts must expose a unit_cost so restocking can budget against them.""" + + def test_demand_items_have_unit_cost(self, client): + """Every demand forecast item includes a non-negative numeric 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, f"{forecast['item_sku']} missing unit_cost" + assert isinstance(forecast["unit_cost"], (int, float)) + assert forecast["unit_cost"] >= 0 + + +class TestCreateRestockOrder: + """Test suite for submitting restocking orders via POST /api/orders.""" + + def _payload(self): + return { + "items": [ + {"sku": "WDG-001", "name": "Industrial Widget Type A", "quantity": 150, "unit_price": 12.50}, + {"sku": "GSK-203", "name": "High-Temperature Gasket", "quantity": 100, "unit_price": 8.25} + ] + } + + def test_create_restock_order_returns_201(self, client): + """Submitting a restock order returns 201 with a Submitted order.""" + response = client.post("/api/orders", json=self._payload()) + assert response.status_code == 201 + + order = response.json() + assert order["status"] == "Submitted" + assert order["customer"] == "Internal Restock" + assert order["order_number"].startswith("RST-") + assert len(order["items"]) == 2 + + def test_total_value_is_calculated(self, client): + """total_value equals the sum of quantity * unit_price across items.""" + response = client.post("/api/orders", json=self._payload()) + order = response.json() + + expected_total = round(150 * 12.50 + 100 * 8.25, 2) + assert order["total_value"] == expected_total + + def test_lead_time_is_14_days(self, client): + """expected_delivery is exactly 14 days after order_date.""" + response = client.post("/api/orders", json=self._payload()) + order = response.json() + + 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_get_orders(self, client): + """A submitted order is returned by GET /api/orders.""" + created = client.post("/api/orders", json=self._payload()).json() + + response = client.get("/api/orders") + assert response.status_code == 200 + + orders = response.json() + assert any(o["id"] == created["id"] for o in orders), \ + "Submitted restock order should appear in GET /api/orders" + + def test_submitted_order_filterable_by_status(self, client): + """The submitted order is returned when filtering orders by status=Submitted.""" + client.post("/api/orders", json=self._payload()) + + response = client.get("/api/orders", params={"status": "Submitted"}) + assert response.status_code == 200 + + submitted = response.json() + assert len(submitted) > 0 + assert all(o["status"] == "Submitted" for o in submitted) + + def test_submitted_order_excluded_from_dashboard_value(self, client): + """Internal restock orders must not inflate the dashboard's order value.""" + before = client.get("/api/dashboard/summary").json()["total_orders_value"] + + client.post("/api/orders", json=self._payload()) + + after = client.get("/api/dashboard/summary").json()["total_orders_value"] + assert after == before, "Submitted restock order should not change total_orders_value"