diff --git a/CLAUDE.md b/CLAUDE.md index 89c307d15..8e2b0ce3e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,6 +54,17 @@ npm install && npm run dev - `GET /api/dashboard/summary` - All filters - `GET /api/demand`, `/api/backlog` - No filters - `GET /api/spending/*` - Summary, monthly, categories, transactions +- `GET /api/restocking/recommendations` - Required: budget. Filters: warehouse, category +- `GET|POST /api/restocking/orders` - No filters. The only write endpoint; persists to `server/data/restock_orders.json` + +## Code Conventions + +**Always document non-obvious logic with comments.** When logic encodes something the code itself can't show — a business rule, a workaround, an ordering dependency, a magic constant — add a short comment stating *why*, not what. Match the surrounding comment style and density; don't narrate self-evident lines. + +```javascript +// Inventory has no time dimension, so the month filter is intentionally ignored here +const params = buildParams({ warehouse, category }) +``` ## Common Issues 1. Use unique keys in v-for (not `index`) - use `sku`, `month`, etc. 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..da74269d4 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -102,5 +102,25 @@ export const api = { async getPurchaseOrderByBacklogItem(backlogItemId) { const response = await axios.get(`${API_BASE_URL}/purchase-orders/${backlogItemId}`) return response.data + }, + + async getRestockRecommendations(budget, filters = {}) { + const params = new URLSearchParams() + params.append('budget', budget) + if (filters.warehouse && filters.warehouse !== 'all') params.append('warehouse', filters.warehouse) + if (filters.category && filters.category !== 'all') params.append('category', filters.category) + + const response = await axios.get(`${API_BASE_URL}/restocking/recommendations?${params.toString()}`) + return response.data + }, + + async getRestockOrders() { + const response = await axios.get(`${API_BASE_URL}/restocking/orders`) + return response.data + }, + + async createRestockOrder(orderData) { + const response = await axios.post(`${API_BASE_URL}/restocking/orders`, orderData) + return response.data } } diff --git a/client/src/locales/en.js b/client/src/locales/en.js index 03a58fe6e..185a4e441 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', @@ -112,6 +113,8 @@ export default { onTimeDelivery: 'On-Time Delivery', itemsCount: '{count} items', quantity: 'Qty', + submittedOrders: 'Submitted Restock Orders', + noSubmittedOrders: 'No restock orders submitted yet', table: { orderNumber: 'Order Number', orderId: 'Order ID', @@ -125,7 +128,43 @@ export default { totalValue: 'Total Value', status: 'Status', expectedDelivery: 'Expected Delivery', - actualDelivery: 'Actual Delivery' + actualDelivery: 'Actual Delivery', + submittedDate: 'Submitted', + leadTime: 'Lead Time' + } + }, + + // Restocking + restocking: { + title: 'Restocking', + description: 'Set a budget and order the items that need restocking most', + availableBudget: 'Available Budget', + recommendedItems: 'Recommended Items', + budgetUsed: 'Budget Allocated', + budgetRemaining: 'Budget Remaining', + itemsRecommended: 'Items Recommended', + placeOrder: 'Place Order', + placingOrder: 'Placing order...', + orderPlaced: 'Restock order {orderNumber} submitted', + orderPlacedDetail: 'Expected delivery {date} ({days} day lead time)', + orderFailed: 'Failed to submit restock order', + viewInOrders: 'View in Orders', + allStocked: 'Every item is adequately stocked. Nothing needs restocking.', + budgetTooLow: 'Budget is below the cheapest item ({amount}). Increase the budget to see recommendations.', + days: '{count} days', + critical: 'Critical', + low: 'Low', + partial: 'Partial', + table: { + onHand: 'On Hand', + reorderPoint: 'Reorder Point', + target: 'Target', + shortfall: 'Shortfall', + orderQuantity: 'Order Qty', + unitCost: 'Unit Cost', + lineCost: 'Line Cost', + leadTime: 'Lead Time', + priority: 'Priority' } }, @@ -204,6 +243,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..4d4dda952 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: '触媒コンポーネンツ', @@ -112,6 +113,8 @@ export default { onTimeDelivery: '定時配達', itemsCount: '{count}件', quantity: '数量', + submittedOrders: '送信済み補充注文', + noSubmittedOrders: '送信済みの補充注文はありません', table: { orderNumber: '注文番号', orderId: '注文ID', @@ -125,7 +128,43 @@ export default { totalValue: '合計金額', status: 'ステータス', expectedDelivery: '予定配達日', - actualDelivery: '実際の配達日' + actualDelivery: '実際の配達日', + submittedDate: '送信日', + leadTime: 'リードタイム' + } + }, + + // Restocking + restocking: { + title: '在庫補充', + description: '予算を設定し、補充が最も必要な品目を発注します', + availableBudget: '利用可能予算', + recommendedItems: '推奨品目', + budgetUsed: '割当予算', + budgetRemaining: '残予算', + itemsRecommended: '推奨品目数', + placeOrder: '発注する', + placingOrder: '発注中...', + orderPlaced: '補充注文 {orderNumber} を送信しました', + orderPlacedDetail: '予定配達日 {date}(リードタイム {days} 日)', + orderFailed: '補充注文の送信に失敗しました', + viewInOrders: '注文タブで確認', + allStocked: 'すべての品目の在庫は十分です。補充は不要です。', + budgetTooLow: '予算が最安値の品目({amount})を下回っています。予算を増やしてください。', + days: '{count} 日', + critical: '緊急', + low: '低', + partial: '一部', + table: { + onHand: '在庫数', + reorderPoint: '発注点', + target: '目標数量', + shortfall: '不足数', + orderQuantity: '発注数量', + unitCost: '単価', + lineCost: '小計', + leadTime: 'リードタイム', + priority: '優先度' } }, @@ -204,6 +243,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..1d2893824 100644 --- a/client/src/views/Orders.vue +++ b/client/src/views/Orders.vue @@ -27,6 +27,69 @@ + +
+
+

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

+
+
{{ submittedError }}
+
+ {{ t('orders.noSubmittedOrders') }} +
+
+ + + + + + + + + + + + + + + + + + + + + + + +
{{ t('orders.table.orderNumber') }}{{ t('orders.table.items') }}{{ t('orders.table.status') }}{{ t('orders.table.submittedDate') }}{{ t('orders.table.leadTime') }}{{ t('orders.table.expectedDelivery') }}{{ t('orders.table.totalValue') }}
{{ order.order_number }} +
+ + {{ t('orders.itemsCount', { count: order.items.length }) }} + +
+
+ {{ item.name }} + + {{ t('orders.quantity') }}: {{ item.quantity }} @ {{ formatCurrencyWithDecimals(item.unit_cost, currentCurrency, 2) }} +
+
+
+
+ + {{ t(`status.${order.status.toLowerCase()}`) }} + + {{ formatDate(order.submitted_at) }}{{ t('restocking.days', { count: order.lead_time_days }) }}{{ formatDate(order.expected_delivery) }}{{ formatCurrency(order.total_value, currentCurrency) }}
+
+
+

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

@@ -83,6 +146,7 @@ import { ref, onMounted, watch, computed } from 'vue' import { api } from '../api' import { useFilters } from '../composables/useFilters' import { useI18n } from '../composables/useI18n' +import { formatCurrency, formatCurrencyWithDecimals } from '../utils/currency' export default { name: 'Orders', @@ -96,6 +160,13 @@ export default { const error = ref(null) const orders = ref([]) + // Submitted restock orders (from the Restocking tab) have their own loading + // state and their own error ref, kept separate from the page-level error + // above. If the backend hasn't picked up this endpoint yet, we want just + // this section to show an error, not blank out the whole Orders page. + const submittedOrders = ref([]) + const submittedError = ref(null) + // Use shared filters const { selectedPeriod, @@ -129,6 +200,21 @@ export default { loadOrders() }) + // Deliberately NOT added to the watch above: restock orders are + // filter-independent (see template comment), so refetching them on every + // filter change would be wasted work. + const loadSubmittedOrders = async () => { + try { + submittedOrders.value = await api.getRestockOrders() + } catch (err) { + // Only set submittedError here, never the page-level `error` ref - + // otherwise a backend that hasn't been restarted yet with this + // endpoint would blank out the entire Orders page instead of just + // this section. + submittedError.value = 'Failed to load submitted restock orders: ' + err.message + } + } + const getOrdersByStatus = (status) => { return orders.value.filter(order => order.status === status) } @@ -138,7 +224,8 @@ export default { 'Delivered': 'success', 'Shipped': 'info', 'Processing': 'warning', - 'Backordered': 'danger' + 'Backordered': 'danger', + 'Submitted': 'info' } return statusMap[status] || 'info' } @@ -153,17 +240,25 @@ export default { }) } - onMounted(loadOrders) + onMounted(() => { + loadOrders() + loadSubmittedOrders() + }) return { t, loading, error, orders, + submittedOrders, + submittedError, getOrdersByStatus, getOrderStatusClass, formatDate, currencySymbol, + currentCurrency, + formatCurrency, + formatCurrencyWithDecimals, translateProductName, translateCustomerName } @@ -203,6 +298,19 @@ export default { width: 120px; } +.col-lead-time { + width: 110px; +} + +/* Empty state for the submitted restock orders card, styled to match .no-tasks in TasksModal.vue */ +.no-submitted-orders { + text-align: center; + padding: 3rem; + color: #64748b; + font-size: 1.1rem; + font-style: italic; +} + /* 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 000000000..6240466fb --- /dev/null +++ b/client/src/views/Restocking.vue @@ -0,0 +1,415 @@ + + + + + diff --git a/docs/architecture.drawio b/docs/architecture.drawio new file mode 100644 index 000000000..98ed87960 --- /dev/null +++ b/docs/architecture.drawio @@ -0,0 +1,127 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/architecture.html b/docs/architecture.html new file mode 100644 index 000000000..cf34eb60e --- /dev/null +++ b/docs/architecture.html @@ -0,0 +1,589 @@ +Inventory System Architecture + + +
+ +
+
Architecture Reference
+

Factory Inventory Management System

+

+ A single-page Vue 3 dashboard over a stateless FastAPI service that filters in-memory + JSON fixtures. No database, no authentication, no build step on the server — the whole + system is two processes and seven data files. +

+
+ Vue 3.4 + Vite 5 · :3000 + FastAPI + Uvicorn · :8001 + Python ≥ 3.11 · uv + 14 GET endpoints + 351 fixture records + No database +
+
+ +
+

System Architecture

+

+ Four tiers, one direction. The browser loads the SPA from Vite; every data read leaves the + browser as an axios GET against port 8001; the API filters Python lists that were + loaded from disk once at import time and never written back. +

+ +
+ + + + + + + + + + Browser + http://localhost:3000 · history-mode routing · localStorage holds the selected locale + + + + + + + + Client — Vue 3 SPA, Composition API + + + Views (7) + Dashboard Inventory + Orders Spending + Demand Reports + + Backlog (unrouted) + 6 routes, vue-router + + + Components (9) + FilterBar + 4 × DetailModal + ProfileMenu / Details + TasksModal + LanguageSwitcher + + + Composables (3) + useFilters — 4 shared + refs, module-level + useI18n — en / ja + useAuth — mock user + + utils/currency.js + + + api.js + axios, single base URL + 17 methods + URLSearchParams; + drops 'all' values + no interceptors / retry + + + + + + + GET /api/* — JSON over HTTP, CORS allow_origins=["*"] + no cookies, no auth header, no cache layer + + + + Server — FastAPI on Uvicorn, single module + + + Routes — main.py + 14 GET, 0 write endpoints + inventory orders demand + backlog dashboard/summary + spending/* reports/* + HTTPException 404 on miss + + + Filter layer + apply_filters() + warehouse, category, status + filter_by_month() + YYYY-MM or Qn-2025 + list comprehensions, no index + + + Pydantic models (5) + InventoryItem Order + DemandForecast BacklogItem + PurchaseOrder + response_model validates + on the way out + + + + + + + + Data — read-only fixtures, process memory + + + mock_data.py + load_json_file() at import → module-level lists + + + + + + + server/data/*.json + 7 files · 351 records · edits require a restart + +
+ Solid arrows are requests; the dashed arrow is the JSON response. Nothing writes back to + disk — the data layer is effectively a constant for the lifetime of the process. +
+
+
+ +
+

Tech Stack

+
+
+
Frontend
+
Vue 3.4
+
Composition API throughout, vue-router 4 in history mode, no state library — shared state lives in module-scoped refs inside composables.
+
+
+
Build
+
Vite 5
+
Dev server pinned to port 3000 via vite.config.js. Only @vitejs/plugin-vue. No TypeScript, no linter, no test runner on the client.
+
+
+
HTTP
+
axios 1.6
+
Centralised in src/api.js against a hardcoded localhost:8001 base — no env var, so the origin is baked in at author time.
+
+
+
Backend
+
FastAPI
+
One 309-line main.py: models, filter helpers and all routes. Served by Uvicorn on 0.0.0.0:8001, auto-docs at /docs.
+
+
+
Validation
+
Pydantic 2
+
Applied as response_model on typed endpoints. The aggregate endpoints (dashboard, spending, reports) return bare dicts and are unvalidated.
+
+
+
Tooling
+
uv · pytest
+
Dependencies resolved by uv from pyproject.toml. Three backend test modules drive FastAPI's TestClient.
+
+
+
Persistence
+
JSON files
+
Loaded once into Python lists. Writes are not implemented anywhere, so nothing survives a restart by design.
+
+
+
Agent tooling
+
2 MCP servers
+
.mcp.json wires Playwright (browser testing) and GitHub (repo operations, token from env).
+
+
+
+ +
+

Data Flow

+

+ One filter interaction drives the entire application. Filter state is global by construction: + useFilters.js declares its four refs at module scope, so every importer shares one + instance and each view independently watches it and refetches. +

+ +
+ + + + + + + + + 01 + FilterBar + user picks period, + site, category, status + + + 02 + useFilters + module-scope refs + mutate — one copy + + + 03 + view watch + each view refetches + via api.js params + + + 04 + FastAPI filter + apply_filters + + filter_by_month + + + 05 + Pydantic + serialise the + filtered subset + + + + + + + + + 06 + JSON → raw refs → computed properties re-derive → DOM patches + totals, charts and status counts are never stored, only computed + +
+ Because filter state is shared but fetching is per-view, a single filter change can fan out + into several parallel requests — the Dashboard alone issues four. +
+
+ +

What each layer is responsible for

+
    +
  1. Filtering happens on the server. +

    The client sends only the four filter values; it never receives the full dataset and filters locally. Values equal to all are omitted from the query string entirely rather than sent and ignored.

  2. +
  3. Aggregation happens on the server for summaries, on the client for views. +

    /api/dashboard/summary and /api/reports/* compute totals in Python. Everything else returns rows, and the views derive totals in computed() properties.

  4. +
  5. Presentation is client-only. +

    Locale (en/ja) and currency never reach the API. utils/currency.js converts USD to JPY at a hardcoded rate of 150 for display, so yen figures are cosmetic rather than authoritative.

  6. +
  7. Charts are hand-built SVG. +

    No charting library is installed. Each view maps its data into coordinates inside computed properties and emits inline SVG.

  8. +
+
+ +
+

API Surface

+

Fourteen routes, all GET. The filter columns show which query parameters actually affect the result.

+
+ + + + + + + + + + + + + + + + + + + + + + +
EndpointAcceptsBacking dataNotes
GET /Version banner
/api/inventorywarehouse, categoryinventory.jsonNo time dimension, so month is deliberately unsupported
/api/inventory/{id}path idinventory.json404 when absent
/api/orderswarehouse, category, status, monthorders.jsonThe only fully-filterable collection
/api/orders/{id}path idorders.json404 when absent
/api/demanddemand_forecasts.jsonIgnores all filters
/api/backlogbacklog_items.jsonJoins against purchase orders to add has_purchase_order
/api/dashboard/summaryall fourinventory + ordersReturns 5 aggregates; backlog count is unfiltered
/api/spending/summaryspending.jsonPre-computed in the fixture
/api/spending/monthlyspending.jsonPre-computed in the fixture
/api/spending/categoriesspending.jsonPre-computed in the fixture
/api/spending/transactionstransactions.json56 rows, unpaginated
/api/reports/quarterlyorders.jsonDerives revenue and fulfilment rate per quarter
/api/reports/monthly-trendsorders.jsonGroups on the YYYY-MM prefix of order_date
+
+
+ +
+

Data Layer

+

+ Every figure in the UI traces back to these seven files. mock_data.py reads them + once at import; the API then filters those lists per request. +

+
+ + + + + + + + + + + + + +
FileRecordsShapeConsumed by
orders.json250List; spans all 12 months of 2025Orders, Dashboard, Spending, Reports
transactions.json56ListSpending
inventory.json32List, keyed by SKUInventory, Dashboard, Demand, Backlog
demand_forecasts.json9ListDemand
backlog_items.json4ListDashboard
spending.json3Object: summary, monthly, categoriesSpending
purchase_orders.json0Empty list/api/backlog join
+
+ +

Filter dimensions

+
+
+
Time period
+
12 + 4
+
Months 2025-012025-12, plus quarters Q1-2025Q4-2025 expanded by QUARTER_MAP. Matched by substring against order_date.
+
+
+
Warehouse
+
3 sites
+
San Francisco, London, Tokyo. Case-sensitive exact match.
+
+
+
Category
+
5 types
+
Sensors, Actuators, Controllers, Circuit Boards, Power Supplies. Case-insensitive match.
+
+
+
Order status
+
4 states
+
Processing, Shipped, Delivered, Backordered. Case-insensitive; only orders carries it.
+
+
+
+ +
+

Observations

+

+ Points where the code as written diverges from what the structure implies. Each was verified + against the running system rather than inferred. +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FindingDetailEffect
Gap Five client methods hit routes that do not existapi.js defines getTasks, createTask, deleteTask, toggleTask and createPurchaseOrder/getPurchaseOrderByBacklogItem. main.py declares no write routes at all; GET /api/tasks and GET /api/purchase-orders/… both return 404 on the running server.Task CRUD silently falls back to the mock list in useAuth (errors are caught and logged); purchase-order creation cannot succeed. The PurchaseOrder and CreatePurchaseOrderRequest models exist but are unreachable.
Gap views/Backlog.vue is orphaned152 lines, fully implemented, but absent from the router in main.js, from the nav in App.vue, and from every import.Unreachable at any URL. Backlog data surfaces only through the Dashboard table and its modal.
Note Reports.vue bypasses the API clientThe only view importing axios directly instead of api.js, and the only one importing neither useFilters nor useI18n.Reports ignore the global filter bar and are not translated — the base URL is also duplicated there.
Note purchase_orders.json is emptyThe /api/backlog join always evaluates has_purchase_order to false, and nothing can populate the file without a write endpoint.That branch of the backlog UI is permanently in its empty state.
Note Aggregate endpoints are untypeddashboard/summary, spending/* and reports/* return plain dicts with no response_model.Shape changes in those payloads fail at the Vue template rather than at the API boundary.
By design Demo-grade security postureCORS allow_origins=["*"] with credentials enabled, no authentication, no rate limiting, and the API origin hardcoded client-side.Documented as local-development-only in server/CLAUDE.md; would need to change before any shared deployment.
+
+
+ +
+

Running It

+
+
+
Backend
+
cd server && uv run python main.py
+
Serves the API on :8001 with interactive docs at /docs.
+
+
+
Frontend
+
cd client && npm run dev
+
Vite dev server on :3000 with HMR.
+
+
+
Both
+
scripts/start.sh
+
Installs missing dependencies, then backgrounds both processes. scripts/stop.sh tears them down.
+
+
+
Tests
+
cd tests && uv run pytest backend/ -v
+
Backend only — dashboard, inventory and misc endpoint suites via TestClient. There is no client-side test setup.
+
+
+
+ + + +
diff --git a/server/data/restock_orders.json b/server/data/restock_orders.json new file mode 100644 index 000000000..fe51488c7 --- /dev/null +++ b/server/data/restock_orders.json @@ -0,0 +1 @@ +[] diff --git a/server/main.py b/server/main.py index a0c2d8c5a..0000da3a0 100644 --- a/server/main.py +++ b/server/main.py @@ -1,8 +1,9 @@ -from fastapi import FastAPI, HTTPException +from fastapi import FastAPI, HTTPException, Query from fastapi.middleware.cors import CORSMiddleware from typing import List, Optional -from pydantic import BaseModel -from mock_data import inventory_items, orders, demand_forecasts, backlog_items, spending_summary, monthly_spending, category_spending, recent_transactions, purchase_orders +from datetime import datetime, timedelta +from pydantic import BaseModel, Field +from mock_data import inventory_items, orders, demand_forecasts, backlog_items, spending_summary, monthly_spending, category_spending, recent_transactions, purchase_orders, restock_orders, add_restock_order app = FastAPI(title="Factory Inventory Management System") @@ -14,6 +15,22 @@ 'Q4-2025': ['2025-10', '2025-11', '2025-12'] } +# Supplier lead time in days, by inventory category. An order's lead time is the +# max across its line items, since the whole shipment arrives together. +LEAD_TIME_DAYS = { + 'Circuit Boards': 14, + 'Sensors': 10, + 'Actuators': 21, + 'Controllers': 18, + 'Power Supplies': 12 +} +DEFAULT_LEAD_TIME_DAYS = 14 # fallback if inventory.json gains a new category + +# Restock target stock level. Mirrors the "adequate" threshold in Inventory.vue +# (quantity_on_hand <= reorder_point * 1.5) so the Restocking and Inventory tabs +# agree on what counts as adequately stocked. Keep the two in step. +TARGET_STOCK_MULTIPLIER = 1.5 + def filter_by_month(items: list, month: Optional[str]) -> list: """Filter items by month/quarter based on order_date field""" if not month or month == 'all': @@ -120,6 +137,144 @@ class CreatePurchaseOrderRequest(BaseModel): expected_delivery_date: str notes: Optional[str] = None +class RestockRecommendation(BaseModel): + sku: str + name: str + category: str + warehouse: str + unit_cost: float + quantity_on_hand: int + reorder_point: int + target_quantity: int + shortfall: int + recommended_quantity: int + line_cost: float + lead_time_days: int + priority: str # "critical" | "low" + fully_covered: bool # recommended_quantity == shortfall + demand_trend: Optional[str] = None # present only when a forecast exists for this SKU + +class RestockRecommendationsResponse(BaseModel): + budget: float + budget_used: float + budget_remaining: float + # The next three are budget-independent, so a single request gives the client + # everything it needs to size its slider and explain an empty result. + total_shortfall_cost: float + candidate_count: int + cheapest_unit_cost: Optional[float] = None + recommendations: List[RestockRecommendation] + +class RestockOrderLine(BaseModel): + sku: str + name: str + category: str + quantity: int + unit_cost: float # procurement cost, deliberately not `unit_price` like customer Orders + line_cost: float + +class RestockOrder(BaseModel): + id: str + order_number: str + status: str + budget: float + total_value: float + items: List[RestockOrderLine] + lead_time_days: int + submitted_at: str + expected_delivery: str + +class RestockOrderLineRequest(BaseModel): + sku: str + quantity: int = Field(gt=0) + +class CreateRestockOrderRequest(BaseModel): + budget: float = Field(ge=0) + items: List[RestockOrderLineRequest] = Field(min_length=1) + +def build_restock_candidates(warehouse: Optional[str] = None, + category: Optional[str] = None) -> list: + """Rank inventory items that need restocking, worst first. + + Ranking is critical items first (at or below their reorder point), then by + shortfall descending. A demand forecast is attached as `demand_trend` when one + exists for the SKU and acts only as a tiebreaker - today just PSU-501 overlaps + the forecast set, so it is informational rather than load-bearing. + """ + trends = {f['item_sku']: f['trend'] for f in demand_forecasts} + candidates = [] + + for item in apply_filters(inventory_items, warehouse, category): + # int() is defensive: every reorder_point is currently even so * 1.5 is a + # whole number, but a future odd value must not produce a fractional target. + target = int(item['reorder_point'] * TARGET_STOCK_MULTIPLIER) + shortfall = target - item['quantity_on_hand'] + if shortfall <= 0: + continue + + candidates.append({ + 'sku': item['sku'], + 'name': item['name'], + 'category': item['category'], + 'warehouse': item['warehouse'], + 'unit_cost': item['unit_cost'], + 'quantity_on_hand': item['quantity_on_hand'], + 'reorder_point': item['reorder_point'], + 'target_quantity': target, + 'shortfall': shortfall, + # <= matches the lowStock test in Inventory.vue, not < + 'priority': 'critical' if item['quantity_on_hand'] <= item['reorder_point'] else 'low', + 'lead_time_days': LEAD_TIME_DAYS.get(item['category'], DEFAULT_LEAD_TIME_DAYS), + 'demand_trend': trends.get(item['sku']) + }) + + trend_rank = {'increasing': 0, 'stable': 1, 'decreasing': 2} + candidates.sort(key=lambda c: ( + c['priority'] != 'critical', + -c['shortfall'], + trend_rank.get(c['demand_trend'], 1), + -c['shortfall'] * c['unit_cost'], + # Final tiebreak on sku keeps the order deterministic: HMD-202 and PSU-507 + # match on every preceding key, so without this their order is arbitrary. + c['sku'] + )) + return candidates + +def fill_budget(candidates: list, budget: float) -> list: + """Greedily allocate a budget across ranked candidates, partially filling the + last affordable line. + + Arithmetic is in integer cents because float division misbehaves at exactly the + boundaries that matter here - floor(8950.0 / 89.5) can yield 99 rather than 100. + + An unaffordable candidate is skipped rather than ending the loop, so leftover + budget can still go to a cheaper item further down the ranking. The feature + promises to spend the budget well, and stopping early would leave money unspent + while an item is still short. + """ + remaining_cents = int(round(budget * 100)) + picked = [] + + for candidate in candidates: + if remaining_cents <= 0: + break + + unit_cents = int(round(candidate['unit_cost'] * 100)) + quantity = min(candidate['shortfall'], remaining_cents // unit_cents) + if quantity <= 0: + continue + + line_cents = quantity * unit_cents + remaining_cents -= line_cents + picked.append({ + **candidate, + 'recommended_quantity': quantity, + 'line_cost': round(line_cents / 100, 2), + 'fully_covered': quantity == candidate['shortfall'] + }) + + return picked + # API endpoints @app.get("/") def root(): @@ -304,6 +459,93 @@ def get_monthly_trends(): result.sort(key=lambda x: x['month']) return result +@app.get("/api/restocking/recommendations", response_model=RestockRecommendationsResponse) +def get_restock_recommendations( + budget: float = Query(..., ge=0), + warehouse: Optional[str] = None, + category: Optional[str] = None +): + """Recommend inventory items to restock within a budget, worst shortfall first.""" + candidates = build_restock_candidates(warehouse, category) + recommendations = fill_budget(candidates, budget) + + budget_used = round(sum(r['line_cost'] for r in recommendations), 2) + + return { + 'budget': budget, + 'budget_used': budget_used, + 'budget_remaining': round(budget - budget_used, 2), + 'total_shortfall_cost': round( + sum(c['shortfall'] * c['unit_cost'] for c in candidates), 2 + ), + 'candidate_count': len(candidates), + 'cheapest_unit_cost': min((c['unit_cost'] for c in candidates), default=None), + 'recommendations': recommendations + } + +@app.get("/api/restocking/orders", response_model=List[RestockOrder]) +def get_restock_orders(): + """Get submitted restock orders, newest first. + + Deliberately unfiltered, unlike /api/orders. Every global filter would + spuriously empty this list: the period filter only offers 2025 months while + these are submitted now, the status filter has no "Submitted" option, and a + single restock order can span several warehouses and categories. + """ + return sorted(restock_orders, key=lambda o: o['submitted_at'], reverse=True) + +@app.post("/api/restocking/orders", response_model=RestockOrder, status_code=201) +def create_restock_order(request: CreateRestockOrderRequest): + """Submit a restock order, pricing every line from inventory and persisting it.""" + seen_skus = set() + lines = [] + + for line in request.items: + if line.sku in seen_skus: + raise HTTPException(status_code=400, detail=f"Duplicate sku in order: {line.sku}") + seen_skus.add(line.sku) + + item = next((i for i in inventory_items if i['sku'] == line.sku), None) + if not item: + raise HTTPException(status_code=404, detail=f"Item {line.sku} not found") + + # Price from inventory rather than from the request: a client must not be + # able to set its own unit cost and slip an order past the budget check. + lines.append({ + 'sku': item['sku'], + 'name': item['name'], + 'category': item['category'], + 'quantity': line.quantity, + 'unit_cost': item['unit_cost'], + 'line_cost': round(line.quantity * item['unit_cost'], 2) + }) + + total_value = round(sum(line['line_cost'] for line in lines), 2) + # Staying within budget is the whole point of the feature, so an over-budget + # order is rejected rather than recorded. The tolerance absorbs float noise. + if total_value > request.budget + 0.01: + raise HTTPException( + status_code=400, + detail=f"Order total {total_value} exceeds budget {request.budget}" + ) + + lead_time_days = max( + LEAD_TIME_DAYS.get(line['category'], DEFAULT_LEAD_TIME_DAYS) for line in lines + ) + # Second precision with no timezone, matching the existing date strings in the + # JSON data (e.g. "2025-09-30T10:30:00"). + submitted_at = datetime.now().replace(microsecond=0) + + return add_restock_order({ + 'status': 'Submitted', + 'budget': request.budget, + 'total_value': total_value, + 'items': lines, + 'lead_time_days': lead_time_days, + 'submitted_at': submitted_at.isoformat(), + 'expected_delivery': (submitted_at + timedelta(days=lead_time_days)).isoformat() + }) + if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8001) diff --git a/server/mock_data.py b/server/mock_data.py index 2a9cd7dcb..784f9ec7a 100644 --- a/server/mock_data.py +++ b/server/mock_data.py @@ -6,6 +6,7 @@ import json import os +import threading # Get the directory where this file is located BASE_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -17,6 +18,20 @@ def load_json_file(filename): with open(filepath, 'r') as f: return json.load(f) +def save_json_file(filename, data): + """Write data to a JSON file in the data directory. + + Writes to a temp file and then os.replace()s it onto the target, which is an + atomic swap - a crash mid-write can never leave a truncated, unparseable JSON + file behind. DATA_DIR is looked up on each call rather than captured as a + default argument, so tests can redirect writes away from the real data dir. + """ + filepath = os.path.join(DATA_DIR, filename) + tmp_path = filepath + '.tmp' + with open(tmp_path, 'w') as f: + json.dump(data, f, indent=2) + os.replace(tmp_path, filepath) + # Load all datasets from JSON files inventory_items = load_json_file('inventory.json') orders = load_json_file('orders.json') @@ -35,5 +50,34 @@ def load_json_file(filename): # Load purchase orders purchase_orders = load_json_file('purchase_orders.json') +# Load restock orders - the only dataset that is mutated at runtime +restock_orders = load_json_file('restock_orders.json') + +# FastAPI runs sync `def` endpoints in a threadpool, so two concurrent POSTs could +# interleave their read-modify-write of restock_orders and hand out duplicate ids. +# Serializing the whole append (id assignment included) rules that out. +_restock_lock = threading.Lock() + +def add_restock_order(order): + """Assign an id and order number to a restock order, persist it, then publish it. + + Writing to disk before appending to the in-memory list means a failed write + can never leave an order visible in the API but missing from the file. + """ + with _restock_lock: + next_id = max( + (int(o['id']) for o in restock_orders if str(o['id']).isdigit()), + default=0 + ) + 1 + stored = { + **order, + 'id': str(next_id), + # RST- prefix keeps these distinguishable from the ORD- customer orders + 'order_number': 'RST-{}-{:04d}'.format(order['submitted_at'][:4], next_id) + } + save_json_file('restock_orders.json', restock_orders + [stored]) + restock_orders.append(stored) + return stored + # All data is now loaded from JSON files in the data/ directory # This allows for easier maintenance and updates of the sample data diff --git a/tests/backend/conftest.py b/tests/backend/conftest.py index a6aa82271..bc63f2f88 100644 --- a/tests/backend/conftest.py +++ b/tests/backend/conftest.py @@ -21,6 +21,31 @@ def client(): yield test_client +@pytest.fixture +def restock_store(tmp_path, monkeypatch): + """Isolate restock order writes from the repo's data directory. + + POST /api/restocking/orders is the only endpoint that mutates state, and two + things leak without this fixture: + + 1. It writes server/data/restock_orders.json for real. That file is tracked in + git, so an unisolated test run leaves a modified data file in the working + tree. Redirecting DATA_DIR sends the write to tmp_path instead - which only + works because save_json_file() resolves DATA_DIR at call time rather than + capturing it as a default argument. + 2. main.py holds a reference to mock_data.restock_orders and the test client + shares one imported module, so appends survive into later tests. Restoring + with slice assignment mutates that same list object; rebinding the name + would leave main.py pointing at the old one. + """ + import mock_data + + original = list(mock_data.restock_orders) + monkeypatch.setattr(mock_data, 'DATA_DIR', str(tmp_path)) + yield tmp_path + mock_data.restock_orders[:] = original + + @pytest.fixture def sample_inventory_item(): """Sample inventory item for testing.""" diff --git a/tests/backend/test_restocking.py b/tests/backend/test_restocking.py new file mode 100644 index 000000000..30a6f366e --- /dev/null +++ b/tests/backend/test_restocking.py @@ -0,0 +1,442 @@ +""" +Tests for restocking API endpoints. +""" +import json +from datetime import datetime + +import pytest + + +# Lead times must stay in step with LEAD_TIME_DAYS in server/main.py +EXPECTED_LEAD_TIMES = { + "Circuit Boards": 14, + "Sensors": 10, + "Actuators": 21, + "Controllers": 18, + "Power Supplies": 12, +} + +# A budget large enough to cover every shortfall in the current fixtures ($58,575) +FULL_BUDGET = 100000 + + +class TestRestockRecommendationsEndpoint: + """Test suite for GET /api/restocking/recommendations.""" + + def test_get_recommendations_full_budget(self, client): + """Test that a large budget fully covers every candidate.""" + response = client.get(f"/api/restocking/recommendations?budget={FULL_BUDGET}") + assert response.status_code == 200 + + data = response.json() + assert data["candidate_count"] > 0 + assert len(data["recommendations"]) == data["candidate_count"] + + # Every line should close its whole shortfall when money is not the constraint + for item in data["recommendations"]: + assert item["fully_covered"] is True + assert item["recommended_quantity"] == item["shortfall"] + + assert abs(data["budget_used"] - data["total_shortfall_cost"]) < 0.01 + + def test_recommendations_response_structure(self, client): + """Test that the recommendations response has the documented structure.""" + response = client.get("/api/restocking/recommendations?budget=20000") + assert response.status_code == 200 + + data = response.json() + for field in [ + "budget", "budget_used", "budget_remaining", "total_shortfall_cost", + "candidate_count", "cheapest_unit_cost", "recommendations", + ]: + assert field in data + + assert isinstance(data["recommendations"], list) + assert len(data["recommendations"]) > 0 + + first_item = data["recommendations"][0] + for field in [ + "sku", "name", "category", "warehouse", "unit_cost", "quantity_on_hand", + "reorder_point", "target_quantity", "shortfall", "recommended_quantity", + "line_cost", "lead_time_days", "priority", "fully_covered", + ]: + assert field in first_item + + def test_recommendations_zero_budget(self, client): + """Test that a zero budget recommends nothing but still reports metadata.""" + response = client.get("/api/restocking/recommendations?budget=0") + assert response.status_code == 200 + + data = response.json() + assert data["recommendations"] == [] + assert data["budget_used"] == 0 + assert data["budget_remaining"] == 0 + + # Metadata is budget-independent, which is what lets the client size its + # slider and explain an empty result from a single request + assert data["candidate_count"] > 0 + assert data["total_shortfall_cost"] > 0 + assert data["cheapest_unit_cost"] > 0 + + def test_recommendations_below_cheapest_unit_cost(self, client): + """Test that a budget under the cheapest unit cost recommends nothing.""" + response = client.get("/api/restocking/recommendations?budget=20000") + cheapest = response.json()["cheapest_unit_cost"] + + response = client.get( + f"/api/restocking/recommendations?budget={cheapest - 1}" + ) + assert response.status_code == 200 + + data = response.json() + assert data["recommendations"] == [] + assert data["candidate_count"] > 0 + + def test_recommendations_partial_fill(self, client): + """Test that a budget covering only part of a shortfall fills it partially.""" + response = client.get("/api/restocking/recommendations?budget=20000") + cheapest = response.json()["cheapest_unit_cost"] + + # Exactly one unit of the cheapest candidate is affordable + response = client.get(f"/api/restocking/recommendations?budget={cheapest}") + assert response.status_code == 200 + + data = response.json() + assert len(data["recommendations"]) == 1 + + item = data["recommendations"][0] + assert item["recommended_quantity"] == 1 + assert item["fully_covered"] is False + assert abs(item["line_cost"] - cheapest) < 0.01 + + def test_recommendations_critical_items_ranked_first(self, client): + """Test that critical items are ranked ahead of non-critical ones.""" + response = client.get(f"/api/restocking/recommendations?budget={FULL_BUDGET}") + priorities = [item["priority"] for item in response.json()["recommendations"]] + + assert "critical" in priorities + assert "low" in priorities + assert priorities.index("low") > max( + i for i, p in enumerate(priorities) if p == "critical" + ) + + def test_recommendations_never_exceed_budget(self, client): + """Test that allocations never exceed the budget at any budget level.""" + for budget in [0, 100, 1000, 20000, 50000, FULL_BUDGET]: + response = client.get(f"/api/restocking/recommendations?budget={budget}") + assert response.status_code == 200 + + data = response.json() + assert data["budget_used"] <= budget + 0.01 + assert abs(data["budget_used"] + data["budget_remaining"] - budget) < 0.01 + + def test_recommendations_quantity_bounds(self, client): + """Test that recommended quantities and shortfalls are internally consistent.""" + response = client.get("/api/restocking/recommendations?budget=30000") + data = response.json() + + for item in data["recommendations"]: + assert isinstance(item["recommended_quantity"], int) + assert 1 <= item["recommended_quantity"] <= item["shortfall"] + assert item["target_quantity"] == int(item["reorder_point"] * 1.5) + assert item["shortfall"] == item["target_quantity"] - item["quantity_on_hand"] + assert item["shortfall"] > 0 + + def test_recommendations_line_cost_calculation(self, client): + """Test that line costs and the budget total are calculated correctly.""" + response = client.get("/api/restocking/recommendations?budget=30000") + data = response.json() + + for item in data["recommendations"]: + expected = item["recommended_quantity"] * item["unit_cost"] + assert abs(item["line_cost"] - expected) < 0.01 + + total = sum(item["line_cost"] for item in data["recommendations"]) + assert abs(data["budget_used"] - total) < 0.01 + + def test_recommendations_lead_time_per_line(self, client): + """Test that each line carries the lead time for its category.""" + response = client.get(f"/api/restocking/recommendations?budget={FULL_BUDGET}") + + for item in response.json()["recommendations"]: + assert item["lead_time_days"] == EXPECTED_LEAD_TIMES[item["category"]] + + def test_recommendations_priority_matches_reorder_point(self, client): + """Test that priority is critical exactly when stock is at or below reorder point.""" + response = client.get(f"/api/restocking/recommendations?budget={FULL_BUDGET}") + + for item in response.json()["recommendations"]: + is_critical = item["quantity_on_hand"] <= item["reorder_point"] + assert item["priority"] == ("critical" if is_critical else "low") + + def test_recommendations_deterministic_order(self, client): + """Test that identical requests return an identical ordering. + + Guards the sku tiebreak: two candidates currently match on shortfall and + unit cost, so without it their relative order would be arbitrary. + """ + first = client.get(f"/api/restocking/recommendations?budget={FULL_BUDGET}").json() + second = client.get(f"/api/restocking/recommendations?budget={FULL_BUDGET}").json() + + assert [i["sku"] for i in first["recommendations"]] == [ + i["sku"] for i in second["recommendations"] + ] + + def test_recommendations_by_warehouse(self, client): + """Test filtering recommendations by warehouse.""" + response = client.get( + f"/api/restocking/recommendations?budget={FULL_BUDGET}&warehouse=Tokyo" + ) + assert response.status_code == 200 + + for item in response.json()["recommendations"]: + assert item["warehouse"] == "Tokyo" + + def test_recommendations_by_category(self, client): + """Test filtering recommendations by category, case-insensitively.""" + response = client.get( + f"/api/restocking/recommendations?budget={FULL_BUDGET}&category=actuators" + ) + assert response.status_code == 200 + + data = response.json() + assert len(data["recommendations"]) > 0 + for item in data["recommendations"]: + assert item["category"].lower() == "actuators" + + def test_recommendations_missing_budget(self, client): + """Test that omitting the budget parameter is a validation error.""" + response = client.get("/api/restocking/recommendations") + assert response.status_code == 422 + + def test_recommendations_negative_budget(self, client): + """Test that a negative budget is a validation error.""" + response = client.get("/api/restocking/recommendations?budget=-100") + assert response.status_code == 422 + + def test_recommendations_match_inventory_endpoint(self, client): + """Test that candidates match an independent calculation over /api/inventory.""" + inventory = client.get("/api/inventory").json() + + expected_skus = { + item["sku"] for item in inventory + if int(item["reorder_point"] * 1.5) - item["quantity_on_hand"] > 0 + } + + response = client.get(f"/api/restocking/recommendations?budget={FULL_BUDGET}") + actual_skus = {item["sku"] for item in response.json()["recommendations"]} + + assert actual_skus == expected_skus + + +class TestRestockOrdersEndpoints: + """Test suite for GET and POST /api/restocking/orders.""" + + def _first_recommendation(self, client): + """Get the top-ranked recommendation at a budget that covers everything.""" + response = client.get(f"/api/restocking/recommendations?budget={FULL_BUDGET}") + return response.json()["recommendations"][0] + + def test_get_restock_orders_returns_list(self, client): + """Test getting submitted restock orders.""" + response = client.get("/api/restocking/orders") + assert response.status_code == 200 + assert isinstance(response.json(), list) + + def test_create_restock_order(self, client, restock_store): + """Test submitting a restock order.""" + item = self._first_recommendation(client) + + response = client.post("/api/restocking/orders", json={ + "budget": FULL_BUDGET, + "items": [{"sku": item["sku"], "quantity": item["shortfall"]}], + }) + assert response.status_code == 201 + + order = response.json() + assert order["order_number"].startswith("RST-") + assert order["status"] == "Submitted" + assert "id" in order + assert abs(order["total_value"] - item["shortfall"] * item["unit_cost"]) < 0.01 + + assert len(order["items"]) == 1 + line = order["items"][0] + assert line["sku"] == item["sku"] + assert line["name"] == item["name"] + assert line["category"] == item["category"] + assert abs(line["unit_cost"] - item["unit_cost"]) < 0.01 + + def test_create_restock_order_lead_time_and_delivery(self, client, restock_store): + """Test that expected delivery is the submission date plus the lead time.""" + item = self._first_recommendation(client) + + response = client.post("/api/restocking/orders", json={ + "budget": FULL_BUDGET, + "items": [{"sku": item["sku"], "quantity": 1}], + }) + assert response.status_code == 201 + + order = response.json() + assert order["lead_time_days"] == EXPECTED_LEAD_TIMES[item["category"]] + + submitted = datetime.fromisoformat(order["submitted_at"]) + expected = datetime.fromisoformat(order["expected_delivery"]) + assert (expected - submitted).days == order["lead_time_days"] + + def test_create_restock_order_lead_time_is_max_across_categories(self, client, restock_store): + """Test that an order's lead time is the longest across its line items.""" + response = client.get(f"/api/restocking/recommendations?budget={FULL_BUDGET}") + recommendations = response.json()["recommendations"] + + by_category = {} + for item in recommendations: + by_category.setdefault(item["category"], item) + assert len(by_category) > 1, "fixture should span multiple categories" + + picked = list(by_category.values()) + response = client.post("/api/restocking/orders", json={ + "budget": FULL_BUDGET, + "items": [{"sku": item["sku"], "quantity": 1} for item in picked], + }) + assert response.status_code == 201 + + assert response.json()["lead_time_days"] == max( + EXPECTED_LEAD_TIMES[item["category"]] for item in picked + ) + + def test_create_then_get_restock_orders(self, client, restock_store): + """Test that a submitted order appears in the orders list.""" + item = self._first_recommendation(client) + + created = client.post("/api/restocking/orders", json={ + "budget": FULL_BUDGET, + "items": [{"sku": item["sku"], "quantity": 1}], + }).json() + + response = client.get("/api/restocking/orders") + assert response.status_code == 200 + + order_numbers = [o["order_number"] for o in response.json()] + assert created["order_number"] in order_numbers + + def test_create_restock_order_unknown_sku(self, client, restock_store): + """Test that an unknown SKU is rejected.""" + response = client.post("/api/restocking/orders", json={ + "budget": 1000, + "items": [{"sku": "NOPE-999", "quantity": 1}], + }) + assert response.status_code == 404 + + data = response.json() + assert "detail" in data + assert "not found" in data["detail"].lower() + + def test_create_restock_order_duplicate_sku(self, client, restock_store): + """Test that the same SKU twice in one order is rejected.""" + item = self._first_recommendation(client) + + response = client.post("/api/restocking/orders", json={ + "budget": FULL_BUDGET, + "items": [ + {"sku": item["sku"], "quantity": 1}, + {"sku": item["sku"], "quantity": 2}, + ], + }) + assert response.status_code == 400 + assert "duplicate" in response.json()["detail"].lower() + + def test_create_restock_order_exceeds_budget(self, client, restock_store): + """Test that an order costing more than its budget is rejected.""" + item = self._first_recommendation(client) + + response = client.post("/api/restocking/orders", json={ + "budget": item["unit_cost"], + "items": [{"sku": item["sku"], "quantity": item["shortfall"] + 10}], + }) + assert response.status_code == 400 + assert "budget" in response.json()["detail"].lower() + + def test_create_restock_order_zero_quantity(self, client, restock_store): + """Test that a zero quantity is a validation error.""" + item = self._first_recommendation(client) + + response = client.post("/api/restocking/orders", json={ + "budget": 1000, + "items": [{"sku": item["sku"], "quantity": 0}], + }) + assert response.status_code == 422 + + def test_create_restock_order_empty_items(self, client, restock_store): + """Test that an order with no line items is a validation error.""" + response = client.post("/api/restocking/orders", json={ + "budget": 1000, + "items": [], + }) + assert response.status_code == 422 + + def test_create_restock_order_ignores_client_prices(self, client, restock_store): + """Test that line prices come from inventory, not from the request.""" + item = self._first_recommendation(client) + + response = client.post("/api/restocking/orders", json={ + "budget": FULL_BUDGET, + "items": [{ + "sku": item["sku"], + "quantity": 1, + "unit_cost": 0.01, # must be ignored + "line_cost": 0.01, # must be ignored + }], + }) + assert response.status_code == 201 + + line = response.json()["items"][0] + assert abs(line["unit_cost"] - item["unit_cost"]) < 0.01 + assert abs(line["line_cost"] - item["unit_cost"]) < 0.01 + + def test_create_restock_order_persists_to_disk(self, client, restock_store): + """Test that a submitted order is written to the data file.""" + item = self._first_recommendation(client) + + created = client.post("/api/restocking/orders", json={ + "budget": FULL_BUDGET, + "items": [{"sku": item["sku"], "quantity": 1}], + }).json() + + persisted_file = restock_store / "restock_orders.json" + assert persisted_file.exists() + + persisted = json.loads(persisted_file.read_text()) + assert isinstance(persisted, list) + assert persisted[-1]["order_number"] == created["order_number"] + + # The atomic write must not leave its temp file behind + assert not (restock_store / "restock_orders.json.tmp").exists() + + def test_restock_order_ids_increment(self, client, restock_store): + """Test that consecutive orders get distinct ids and order numbers.""" + item = self._first_recommendation(client) + payload = { + "budget": FULL_BUDGET, + "items": [{"sku": item["sku"], "quantity": 1}], + } + + first = client.post("/api/restocking/orders", json=payload).json() + second = client.post("/api/restocking/orders", json=payload).json() + + assert first["id"] != second["id"] + assert first["order_number"] != second["order_number"] + assert int(second["id"]) == int(first["id"]) + 1 + + def test_create_restock_order_does_not_affect_customer_orders(self, client, restock_store): + """Test that restock orders stay out of the customer orders collection.""" + before = len(client.get("/api/orders").json()) + + item = self._first_recommendation(client) + client.post("/api/restocking/orders", json={ + "budget": FULL_BUDGET, + "items": [{"sku": item["sku"], "quantity": 1}], + }) + + customer_orders = client.get("/api/orders").json() + assert len(customer_orders) == before + assert not any(o["status"] == "Submitted" for o in customer_orders)