diff --git a/.claude/skills/vue-component-audit/SKILL.md b/.claude/skills/vue-component-audit/SKILL.md new file mode 100644 index 000000000..52c11a458 --- /dev/null +++ b/.claude/skills/vue-component-audit/SKILL.md @@ -0,0 +1,245 @@ +--- +name: vue-component-audit +description: Analyzes Vue 3 component structure and suggests performance and code-reuse optimizations. Use this skill when auditing components in client/src, when asked to review Vue performance, reactivity, or duplication, or before extracting a shared composable or component. +--- + +# Vue Component Audit + +Analyze Vue 3 components in `client/src/` and report structural, performance, and reuse +problems with concrete fixes. + +**This skill reports; it does not rewrite.** Produce findings, let the user choose what to +apply. When they approve a fix that touches a `.vue` file, delegate the edit to the +**vue-expert** subagent per the mandate in CLAUDE.md. + +Distinct from the `/optimize` command, which sweeps the whole codebase for dead code and +edits in place. This skill is Vue-only, analysis-first, and focused on render cost and +duplication. + +## Scope + +| Path | What lives there | +|---|---| +| `client/src/views/*.vue` | Route-level views, one per tab | +| `client/src/components/*.vue` | Modals, filter bar, menus | +| `client/src/composables/*.js` | `useFilters`, `useI18n`, `useAuth` — singleton shared state | +| `client/src/api.js` | Every network call funnels through here | + +This project uses the **Options API `setup()`** form (`export default { setup() {...} }`), +**not** ` + + diff --git a/client/src/views/Orders.vue b/client/src/views/Orders.vue index 7413f6e66..27c5d4a3b 100644 --- a/client/src/views/Orders.vue +++ b/client/src/views/Orders.vue @@ -27,6 +27,61 @@ +
+
+

+ {{ t('orders.submitted.title') }} ({{ restockOrders.length }}) +

+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+
+

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

@@ -95,6 +150,7 @@ export default { const loading = ref(true) const error = ref(null) const orders = ref([]) + const restockOrders = ref([]) // Use shared filters const { @@ -124,6 +180,18 @@ export default { } } + // Submitted restocking orders are budget-driven and carry no warehouse or category, + // so the global filter bar does not apply to them - they are always loaded in full. + const loadRestockOrders = async () => { + try { + restockOrders.value = await api.getRestockOrders() + } catch (err) { + // A failure here must not blank out the customer orders table below it. + console.error('Failed to load submitted restocking orders:', err) + restockOrders.value = [] + } + } + // Watch for filter changes and reload data watch([selectedPeriod, selectedLocation, selectedCategory, selectedStatus], () => { loadOrders() @@ -153,13 +221,17 @@ export default { }) } - onMounted(loadOrders) + onMounted(() => { + loadOrders() + loadRestockOrders() + }) return { t, loading, error, orders, + restockOrders, getOrdersByStatus, getOrderStatusClass, formatDate, @@ -173,11 +245,23 @@ export default { diff --git a/client/src/views/Restocking.vue b/client/src/views/Restocking.vue new file mode 100644 index 000000000..96bd54fc3 --- /dev/null +++ b/client/src/views/Restocking.vue @@ -0,0 +1,489 @@ + + + + + diff --git a/docs/architecture.html b/docs/architecture.html new file mode 100644 index 000000000..31a32245b --- /dev/null +++ b/docs/architecture.html @@ -0,0 +1,647 @@ + + + + + +Architecture — Factory Inventory Management System + + + +
+ +
+

System Architecture

+

Factory Inventory Management System

+

+ A two-tier demo application: a Vue 3 single-page frontend talking to a Python FastAPI service + over plain HTTP. There is no database — the API loads seven JSON files into memory at import + time and filters them in Python on every request. One feature, Restocking, also writes: + submitted orders accumulate in a module-level list for the lifetime of the process. +

+
+
Frontend
:3000
+
Backend
:8001
+
Persistence
none
+
Endpoints
17
+
Views
7
+
Locales
en / ja
+
+
+ + +
+

Architecture

+ +
+ +
+
+ Browser — Vue 3 SPA + Vite 5 dev server · port 3000 +
+
+
App shell
App.vue
+
Router
vue-router 4
+
7 views
views/*.vue
+
9 components
components/*.vue
+
3 composables
shared refs
+
API client
api.js (axios)
+
+
+ +
+
+
HTTP · JSON · query-param filters · CORS *
+
+
+
+ +
+
+ API — FastAPI + uvicorn · port 8001 · docs at /docs +
+
+
Routes
main.py
+
CORS middleware
allow_origins ["*"]
+
Filter helpers
apply_filters()
+
Quarter map
filter_by_month()
+
Pydantic models
11 models
+
Aggregations
summary / reports
+
Restock planner
build_restock_plan()
+
Submitted orders
module-level list
+
+
+ +
+
+
module-level import · read once at startup
+
+
+
+ +
+
+ Data — in-memory JSON + mock_data.py · server/data/*.json +
+
+
orders
250 records
+
transactions
56 records
+
inventory
32 records
+
demand_forecasts
9 records · +cost/lead/supplier
+
backlog_items
4 records
+
spending
3 sub-objects
+
purchase_orders
0 records
+
submitted_restock_orders
runtime only · no file
+
+
+ +
+ +
+
Data is read-only and non-durable
+

+ mock_data.py calls json.load() at import, so every module shares the same + Python lists. Nothing writes back to disk; restarting the server resets all state. Filtering builds + new lists rather than mutating the originals, which keeps the shared data safe across requests. +

+
+
+ + +
+

Tech Stack

+
+ + + + + + + + + + + + + + + + + +
LayerTechnologyVersionRole
UI frameworkVue 3 (Composition API)^3.4.21Components, reactivity, computed derivations
Routingvue-router^4.3.0History-mode client routing, 6 flat routes
HTTP clientaxios^1.6.7All calls funnelled through api.js
Build / dev serverVite + @vitejs/plugin-vue^5.2.0HMR on port 3000, production bundling
API frameworkFastAPI≥0.110Routing, OpenAPI docs, dependency injection
ASGI serveruvicorn≥0.24Serves on 0.0.0.0:8001
ValidationPydantic≥2.5response_model enforcement on typed routes
RuntimePython≥3.11Managed by uv (pyproject.toml)
Testspytest + FastAPI TestClient≥8.0tests/backend/ — 68 tests: dashboard 13, inventory 10, misc 17, restocking 28
ChartsHand-written SVGNo charting library; computed props feed SVG
i18nCustom composableuseI18n + locales/en.js, ja.js, localStorage
+
+
+ + +
+

Data Flow

+

+ Every screen follows the same path. The four global filters are the only cross-cutting input, and they + live in a single shared module rather than a store. +

+ +
    +
  1. +
    User changes a filter
    +
    + FilterBar.vue binds to four refs in composables/useFilters.js — + period, location, category, status. The module holds them at top level, so every importer shares + one instance (singleton pattern, no Pinia/Vuex). +
    +
  2. +
  3. +
    View reloads on change
    +
    + The active view calls getCurrentFilters(), which maps UI names to API names — + selectedLocation becomes warehouse, and selectedPeriod is + emitted as month only when it is not all. +
    +
  4. +
  5. +
    api.js builds the query string
    +
    + Each method drops any filter equal to all via URLSearchParams, so a + default page load sends no filter params at all. +
    +
  6. +
  7. +
    FastAPI filters in memory
    +
    + apply_filters() chains warehouse / category / status list comprehensions + (category and status compare case-insensitively). filter_by_month() then handles either + a direct YYYY-MM substring match or a Q1-2025-style key expanded through + QUARTER_MAP, both against the order_date field. +
    +
  8. +
  9. +
    Pydantic validates the response
    +
    + Typed routes declare response_model, so a JSON shape that drifts from the model raises + a server-side error. Aggregate routes (/dashboard/summary, /reports/*, + /spending/*) return raw dicts with no model. +
    +
  10. +
  11. +
    Vue stores raw, derives the rest
    +
    + Responses land in refs such as allOrders and inventoryItems; every + number on screen — totals, low-stock counts, chart series — is a + computed() over those refs, so the DOM updates without a second fetch. +
    +
  12. +
+ +

Restocking — the one write path

+

+ Restocking is the only feature that does not follow the flow above. It ignores the four global + filters entirely and drives off a single local input instead: a budget slider. +

+ +
    +
  1. +
    Slider moves, request is debounced
    +
    + Restocking.vue watches one budget ref. A range input emits a value at + every step it crosses, so the watcher waits 250 ms for the drag to settle before + fetching — a full-track drag costs 2 requests, not 44. A planPending flag + disables Place Order while the on-screen plan belongs to an older budget. +
    +
  2. +
  3. +
    Server ranks by urgency, then funds greedily
    +
    + build_restock_plan() takes the demand gap + (forecasted_demand − current_demand), drops any non-positive gap, and + weights it by trend through TREND_WEIGHTS — increasing ×1.5, + stable ×1.0, decreasing ×0.5. Candidates sort by that score descending, SKU + ascending as a deterministic tie-break. +
    +
  4. +
  5. +
    Funding is all-or-nothing per line
    +
    + Walking the ranking, a line whose line_total fits the remaining budget is funded in + full; one that does not is pushed to skipped and the budget passes to the next item + down. A partially funded line would not close its demand gap, so partial fills are never offered. + The plan quotes max_lead_time_days — the order lands when its slowest line lands. +
    +
  6. +
  7. +
    POST appends to a process-local list
    +
    + create_restock_order() stamps RO-1001 upward by list length, sets + expected_delivery = today + max_lead_time_days, and appends to + submitted_restock_orders. No file is written. +
    +
  8. +
  9. +
    Orders view reads it back unfiltered
    +
    + Orders.vue calls getRestockOrders() once on mount, separately from its + filtered getOrders() call, and renders a Submitted Orders card above + All Orders. A failure there is logged and swallowed so it cannot blank out the customer + orders table beneath it. +
    +
  10. +
+
+ + +
+

API Surface

+
+ + + + + + + + + + + + + + + + + + + + + + + +
MethodPathFilters acceptedResponse model
GET/raw dict
GET/api/inventorywarehouse, categoryList[InventoryItem]
GET/api/inventory/{id}— (404 if absent)InventoryItem
GET/api/orderswarehouse, category, status, monthList[Order]
GET/api/orders/{id}— (404 if absent)Order
GET/api/demandnoneList[DemandForecast]
GET/api/backlognoneList[BacklogItem]
GET/api/restock/recommendationsbudget (≥0, 422 if negative)RestockPlan
POST/api/restock-ordersJSON body — 400 if items emptySubmittedRestockOrder 201
GET/api/restock-ordersnone — newest firstList[SubmittedRestockOrder]
GET/api/dashboard/summaryall fourraw dict — 5 metrics
GET/api/spending/summarynoneraw dict
GET/api/spending/monthlynoneraw list
GET/api/spending/categoriesnoneraw list
GET/api/spending/transactionsnoneraw list
GET/api/reports/quarterlynone — derived from all ordersraw list
GET/api/reports/monthly-trendsnone — derived from all ordersraw list
+
+ +

Client methods with no server route

+

+ api.js exposes six methods whose endpoints are not defined in main.py. + They return 404 at runtime — the app requests /api/tasks on load, so this is visible + in the server log on every page view. +

+
+ + + + + + + + + + + + +
Client methodAttemptsServer state
getTasks()GET /api/tasks404 no route, no model, no data file
createTask()POST /api/tasks404 tasks are hard-coded in useAuth.js instead
deleteTask()DELETE /api/tasks/{id}404
toggleTask()PATCH /api/tasks/{id}404
createPurchaseOrder()POST /api/purchase-orders404 models exist, route does not
getPurchaseOrderByBacklogItem()GET /api/purchase-orders/{id}404
+
+
+ + +
+

Frontend Structure

+
+
+

Views → routes

+
    +
  • Dashboard.vue/ · 1271 ln
  • +
  • Spending.vue/spending · 852 ln
  • +
  • Restocking.vue/restocking · 489 ln
  • +
  • Reports.vue/reports · 488 ln
  • +
  • Demand.vue/demand · 369 ln
  • +
  • Orders.vue/orders · 363 ln
  • +
  • Inventory.vue/inventory · 339 ln
  • +
  • Backlog.vueunrouted · 152 ln
  • +
+
+
+

Components

+
    +
  • FilterBarglobal filter row
  • +
  • TasksModal621 ln
  • +
  • InventoryDetailModal450 ln
  • +
  • CostDetailModal384 ln
  • +
  • BacklogDetailModal380 ln
  • +
  • ProductDetailModal335 ln
  • +
  • ProfileMenu281 ln
  • +
  • ProfileDetailsModal280 ln
  • +
  • LanguageSwitcher183 ln
  • +
+
+
+

Composables (shared state)

+
    +
  • useFilters4 filter refs
  • +
  • useI18nlocale + currency
  • +
  • useAuthmock user + tasks
  • +
+

+ Locale drives currency automatically: en → USD, ja → JPY. + The mock user's name, title and task list are all swapped per locale in + useAuth.js, and the choice persists in localStorage. +

+
+
+
+ + +
+

Notes & Constraints

+ +
+
Inventory has no time dimension
+

+ /api/inventory accepts only warehouse and category. Month and quarter filters apply to + orders, because filter_by_month() matches on order_date, a field inventory + records do not carry. +

+
+ +
+
Date filtering is substring matching
+

+ Both month and quarter filters use in against the raw date string rather than parsed + dates, and QUARTER_MAP hard-codes 2025 only. Data outside 2025 is silently excluded from + quarterly reports. +

+
+ +
+
Two aggregation paths can disagree
+

+ /api/reports/* derives its quarters from all 250 orders and ignores the global filters, + while /api/dashboard/summary honours all four. The same metric can differ between the + Reports and Overview screens by design. +

+
+ +
+
Not production-hardened
+

+ No authentication, no rate limiting, and CORS is allow_origins=["*"]. The backend binds + 0.0.0.0, so it is reachable from the local network. Suitable for local demo only. +

+
+ +
+
Submitted restock orders vanish on restart
+

+ submitted_restock_orders is a plain module-level list, not a file. Restarting the server + empties it and RO- numbering restarts at 1001, which means order numbers are + not unique across process lifetimes. The backend tests reset the list in an autouse fixture so cases + stay independent. +

+
+ +
+
Restocking bypasses the global filters
+

+ A restock plan is derived from budget alone, and a submitted order carries no warehouse or category, so + the Submitted Orders card in the Orders view is always shown in full while the table below it + respects the filter bar. Recommendations also draw purely on + demand_forecasts.json — on-hand stock in inventory.json is never + consulted, and only PSU-501 appears in both files. +

+
+ +
+
Backlog and purchase orders are half-wired
+

+ /api/backlog computes a has_purchase_order flag by scanning + purchase_orders.json — which currently holds zero records — so the flag is + always false. The PurchaseOrder and + CreatePurchaseOrderRequest models are defined but no route uses them. +

+
+
+ + + +
+ + diff --git a/server/data/demand_forecasts.json b/server/data/demand_forecasts.json index e1b388385..b6bd43e5f 100644 --- a/server/data/demand_forecasts.json +++ b/server/data/demand_forecasts.json @@ -6,7 +6,10 @@ "current_demand": 300, "forecasted_demand": 450, "trend": "increasing", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 42.5, + "lead_time_days": 14, + "supplier": "Acme Industrial Supply" }, { "id": "2", @@ -15,7 +18,10 @@ "current_demand": 150, "forecasted_demand": 152, "trend": "stable", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 87.25, + "lead_time_days": 21, + "supplier": "Northline Bearings" }, { "id": "3", @@ -24,7 +30,10 @@ "current_demand": 500, "forecasted_demand": 600, "trend": "increasing", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 12.75, + "lead_time_days": 7, + "supplier": "SealTech Materials" }, { "id": "4", @@ -33,7 +42,10 @@ "current_demand": 50, "forecasted_demand": 35, "trend": "decreasing", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 615.0, + "lead_time_days": 35, + "supplier": "Volta Motorworks" }, { "id": "5", @@ -42,7 +54,10 @@ "current_demand": 800, "forecasted_demand": 950, "trend": "increasing", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 9.4, + "lead_time_days": 10, + "supplier": "PureFlow Filtration" }, { "id": "6", @@ -51,7 +66,10 @@ "current_demand": 120, "forecasted_demand": 121, "trend": "stable", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 148.0, + "lead_time_days": 28, + "supplier": "Hydra Valve Co." }, { "id": "7", @@ -60,7 +78,10 @@ "current_demand": 250, "forecasted_demand": 252, "trend": "stable", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 18.99, + "lead_time_days": 12, + "supplier": "Meridian Power Systems" }, { "id": "8", @@ -69,7 +90,10 @@ "current_demand": 180, "forecasted_demand": 182, "trend": "stable", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 34.9, + "lead_time_days": 12, + "supplier": "ThermoSense Devices" }, { "id": "9", @@ -78,6 +102,9 @@ "current_demand": 95, "forecasted_demand": 96, "trend": "stable", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 212.0, + "lead_time_days": 24, + "supplier": "LogicCore Systems" } ] diff --git a/server/main.py b/server/main.py index a0c2d8c5a..4b76cb67a 100644 --- a/server/main.py +++ b/server/main.py @@ -1,5 +1,6 @@ -from fastapi import FastAPI, HTTPException +from fastapi import FastAPI, HTTPException, Query from fastapi.middleware.cors import CORSMiddleware +from datetime import date, timedelta 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 @@ -14,6 +15,19 @@ 'Q4-2025': ['2025-10', '2025-11', '2025-12'] } +# Restocking priority weights. A rising trend means the forecast gap understates the +# real risk, so it is inflated; a falling trend means the gap will likely shrink on its +# own, so it is discounted. Applied to the raw forecast gap to rank restock candidates. +TREND_WEIGHTS = { + 'increasing': 1.5, + 'stable': 1.0, + 'decreasing': 0.5 +} + +# Submitted restocking orders live here for the process lifetime only. Consistent with +# the rest of this demo: no database, so a server restart clears them. +submitted_restock_orders: list = [] + 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': @@ -89,6 +103,9 @@ class DemandForecast(BaseModel): forecasted_demand: int trend: str period: str + unit_cost: float + lead_time_days: int + supplier: str class BacklogItem(BaseModel): id: str @@ -120,6 +137,56 @@ class CreatePurchaseOrderRequest(BaseModel): expected_delivery_date: str notes: Optional[str] = None +class RestockRecommendation(BaseModel): + item_sku: str + item_name: str + supplier: str + trend: str + demand_gap: int + recommended_quantity: int + unit_cost: float + line_total: float + lead_time_days: int + priority_score: float + +class RestockPlan(BaseModel): + budget: float + recommendations: List[RestockRecommendation] + skipped: List[RestockRecommendation] + total_cost: float + remaining_budget: float + total_need: float + items_recommended: int + items_skipped: int + total_units: int + max_lead_time_days: int + +class RestockOrderLine(BaseModel): + item_sku: str + item_name: str + supplier: str + quantity: int + unit_cost: float + line_total: float + lead_time_days: int + +class CreateRestockOrderRequest(BaseModel): + budget: float + items: List[RestockOrderLine] + +class SubmittedRestockOrder(BaseModel): + id: str + order_number: str + status: str + submitted_date: str + expected_delivery: str + max_lead_time_days: int + budget: float + total_cost: float + item_count: int + total_units: int + items: List[RestockOrderLine] + # API endpoints @app.get("/") def root(): @@ -179,6 +246,103 @@ def get_backlog(): result.append(item_dict) return result +def build_restock_plan(budget: float) -> dict: + """Rank forecast items by restocking urgency and fund as many as the budget allows.""" + candidates = [] + + for forecast in demand_forecasts: + gap = forecast['forecasted_demand'] - forecast['current_demand'] + # A non-positive gap means demand is flat or falling, so there is nothing to + # restock for that item - it is excluded from the plan entirely. + if gap <= 0: + continue + + weight = TREND_WEIGHTS.get(forecast['trend'], 1.0) + candidates.append({ + 'item_sku': forecast['item_sku'], + 'item_name': forecast['item_name'], + 'supplier': forecast['supplier'], + 'trend': forecast['trend'], + 'demand_gap': gap, + 'recommended_quantity': gap, + 'unit_cost': forecast['unit_cost'], + 'line_total': round(gap * forecast['unit_cost'], 2), + 'lead_time_days': forecast['lead_time_days'], + 'priority_score': round(gap * weight, 1) + }) + + # Most urgent first. SKU breaks ties so that equal scores always yield the same + # ordering - without it, two items scoring alike could swap between requests and + # change which one the budget funds. + candidates.sort(key=lambda c: (-c['priority_score'], c['item_sku'])) + + recommendations = [] + skipped = [] + remaining = round(budget, 2) + + for candidate in candidates: + # All-or-nothing per item: a partially funded line does not close the demand + # gap, so an unaffordable item is passed over and the remaining budget is + # offered to the next item down the ranking. + if candidate['line_total'] <= remaining: + remaining = round(remaining - candidate['line_total'], 2) + recommendations.append(candidate) + else: + skipped.append(candidate) + + return { + 'budget': round(budget, 2), + 'recommendations': recommendations, + 'skipped': skipped, + 'total_cost': round(sum(r['line_total'] for r in recommendations), 2), + 'remaining_budget': remaining, + 'total_need': round(sum(c['line_total'] for c in candidates), 2), + 'items_recommended': len(recommendations), + 'items_skipped': len(skipped), + 'total_units': sum(r['recommended_quantity'] for r in recommendations), + # The whole order lands only when its slowest line lands, so the plan quotes + # the maximum lead time rather than an average. + 'max_lead_time_days': max((r['lead_time_days'] for r in recommendations), default=0) + } + +@app.get("/api/restock/recommendations", response_model=RestockPlan) +def get_restock_recommendations(budget: float = Query(0, ge=0)): + """Recommend demand-forecast items to restock within the given budget""" + return build_restock_plan(budget) + +@app.post("/api/restock-orders", response_model=SubmittedRestockOrder, status_code=201) +def create_restock_order(request: CreateRestockOrderRequest): + """Submit a restocking order and return the created order""" + if not request.items: + raise HTTPException(status_code=400, detail="A restocking order must contain at least one item") + + max_lead_time = max(item.lead_time_days for item in request.items) + submitted_on = date.today() + + order = { + 'id': str(len(submitted_restock_orders) + 1), + # Sequential within the process; numbering restarts with the server. + 'order_number': f"RO-{1001 + len(submitted_restock_orders)}", + 'status': 'Submitted', + 'submitted_date': submitted_on.isoformat(), + # Delivery date is driven by the slowest item in the order. + 'expected_delivery': (submitted_on + timedelta(days=max_lead_time)).isoformat(), + 'max_lead_time_days': max_lead_time, + 'budget': round(request.budget, 2), + 'total_cost': round(sum(item.line_total for item in request.items), 2), + 'item_count': len(request.items), + 'total_units': sum(item.quantity for item in request.items), + 'items': [item.model_dump() for item in request.items] + } + + submitted_restock_orders.append(order) + return order + +@app.get("/api/restock-orders", response_model=List[SubmittedRestockOrder]) +def get_restock_orders(): + """Get restocking orders submitted since the server started, newest first""" + return list(reversed(submitted_restock_orders)) + @app.get("/api/dashboard/summary") def get_dashboard_summary( warehouse: Optional[str] = None, @@ -228,12 +392,23 @@ def get_recent_transactions(): return recent_transactions @app.get("/api/reports/quarterly") -def get_quarterly_reports(): - """Get quarterly performance reports""" +def get_quarterly_reports( + warehouse: Optional[str] = None, + category: Optional[str] = None, + status: Optional[str] = None, + month: Optional[str] = None +): + """Get quarterly performance reports with optional filtering""" + # Reports previously derived from every order regardless of the global filter bar, so + # the same metric could disagree with the Overview screen. Filter on the same inputs + # as /api/dashboard/summary so both aggregation paths agree. + filtered_orders = apply_filters(orders, warehouse, category, status) + filtered_orders = filter_by_month(filtered_orders, month) + # Calculate quarterly statistics from orders quarters = {} - for order in orders: + for order in filtered_orders: order_date = order.get('order_date', '') # Determine quarter if '2025-01' in order_date or '2025-02' in order_date or '2025-03' in order_date: @@ -274,11 +449,19 @@ def get_quarterly_reports(): return result @app.get("/api/reports/monthly-trends") -def get_monthly_trends(): - """Get month-over-month trends""" +def get_monthly_trends( + warehouse: Optional[str] = None, + category: Optional[str] = None, + status: Optional[str] = None, + month: Optional[str] = None +): + """Get month-over-month trends with optional filtering""" + filtered_orders = apply_filters(orders, warehouse, category, status) + filtered_orders = filter_by_month(filtered_orders, month) + months = {} - for order in orders: + for order in filtered_orders: order_date = order.get('order_date', '') if not order_date: continue diff --git a/tests/backend/test_reports.py b/tests/backend/test_reports.py new file mode 100644 index 000000000..c64f5b232 --- /dev/null +++ b/tests/backend/test_reports.py @@ -0,0 +1,329 @@ +""" +Tests for reports API endpoints. +""" +import pytest + + +QUARTERLY_FIELDS = [ + "quarter", "total_orders", "total_revenue", + "delivered_orders", "avg_order_value", "fulfillment_rate" +] + +MONTHLY_FIELDS = ["month", "order_count", "revenue", "delivered_count"] + + +def order_count(client, query=""): + """Number of orders the /api/orders endpoint returns for the same filters.""" + response = client.get(f"/api/orders{query}") + assert response.status_code == 200 + return len(response.json()) + + +class TestQuarterlyReports: + """Test suite for /api/reports/quarterly.""" + + def test_get_all_quarterly(self, client): + """Test getting quarterly reports without filters.""" + response = client.get("/api/reports/quarterly") + assert response.status_code == 200 + + data = response.json() + assert isinstance(data, list) + assert len(data) > 0 + + for field in QUARTERLY_FIELDS: + assert field in data[0] + + def test_quarterly_field_types(self, client): + """Test that quarterly fields have proper numeric types.""" + data = client.get("/api/reports/quarterly").json() + + for quarter in data: + assert isinstance(quarter["quarter"], str) + assert isinstance(quarter["total_orders"], int) + assert isinstance(quarter["total_revenue"], (int, float)) + assert isinstance(quarter["delivered_orders"], int) + assert isinstance(quarter["avg_order_value"], (int, float)) + assert isinstance(quarter["fulfillment_rate"], (int, float)) + + assert quarter["total_orders"] >= 0 + assert quarter["total_revenue"] >= 0 + assert quarter["delivered_orders"] <= quarter["total_orders"] + + def test_quarterly_quarter_format(self, client): + """Test that quarter labels use the Qn-YYYY format the client parses.""" + data = client.get("/api/reports/quarterly").json() + + for quarter in data: + label = quarter["quarter"] + assert label.startswith("Q") + number, year = label.split("-") + assert number in ["Q1", "Q2", "Q3", "Q4"] + assert year.isdigit() + assert len(year) == 4 + + def test_quarterly_avg_order_value_calculation(self, client): + """Test that avg_order_value equals revenue divided by order count.""" + data = client.get("/api/reports/quarterly").json() + + for quarter in data: + if quarter["total_orders"] > 0: + expected = quarter["total_revenue"] / quarter["total_orders"] + assert abs(quarter["avg_order_value"] - expected) < 0.01 + + def test_quarterly_fulfillment_rate_calculation(self, client): + """Test that fulfillment_rate is the delivered share as a percentage.""" + data = client.get("/api/reports/quarterly").json() + + for quarter in data: + if quarter["total_orders"] > 0: + expected = (quarter["delivered_orders"] / quarter["total_orders"]) * 100 + assert abs(quarter["fulfillment_rate"] - expected) < 0.1 + assert 0 <= quarter["fulfillment_rate"] <= 100 + + def test_quarterly_sorted_by_quarter(self, client): + """Test that quarters come back in ascending order.""" + data = client.get("/api/reports/quarterly").json() + labels = [q["quarter"] for q in data] + assert labels == sorted(labels) + + def test_quarterly_filter_by_warehouse(self, client): + """Test filtering quarterly reports by warehouse.""" + response = client.get("/api/reports/quarterly?warehouse=Tokyo") + assert response.status_code == 200 + + data = response.json() + total = sum(q["total_orders"] for q in data) + assert total == order_count(client, "?warehouse=Tokyo") + assert total < order_count(client) + + def test_quarterly_filter_by_category(self, client): + """Test filtering quarterly reports by category.""" + response = client.get("/api/reports/quarterly?category=Power Supplies") + assert response.status_code == 200 + + data = response.json() + total = sum(q["total_orders"] for q in data) + assert total == order_count(client, "?category=Power Supplies") + + def test_quarterly_filter_by_status(self, client): + """Test that filtering to Delivered leaves every quarter fully fulfilled.""" + response = client.get("/api/reports/quarterly?status=Delivered") + assert response.status_code == 200 + + data = response.json() + assert len(data) > 0 + + for quarter in data: + assert quarter["delivered_orders"] == quarter["total_orders"] + assert quarter["fulfillment_rate"] == 100.0 + + def test_quarterly_filter_by_month(self, client): + """Test that a single-month filter narrows the result to that month's quarter.""" + response = client.get("/api/reports/quarterly?month=2025-01") + assert response.status_code == 200 + + data = response.json() + assert len(data) == 1 + assert data[0]["quarter"] == "Q1-2025" + assert data[0]["total_orders"] == order_count(client, "?month=2025-01") + + def test_quarterly_filter_by_quarter(self, client): + """Test that a quarter filter returns only that quarter.""" + response = client.get("/api/reports/quarterly?month=Q3-2025") + assert response.status_code == 200 + + data = response.json() + assert len(data) == 1 + assert data[0]["quarter"] == "Q3-2025" + + def test_quarterly_multiple_filters(self, client): + """Test combining several filters on quarterly reports.""" + query = "?warehouse=London&category=Power Supplies&status=Delivered" + response = client.get(f"/api/reports/quarterly{query}") + assert response.status_code == 200 + + data = response.json() + total = sum(q["total_orders"] for q in data) + assert total == order_count(client, query) + + def test_quarterly_filter_all_is_noop(self, client): + """Test that explicit 'all' values behave the same as no filters.""" + unfiltered = client.get("/api/reports/quarterly").json() + with_all = client.get( + "/api/reports/quarterly?warehouse=all&category=all&status=all&month=all" + ).json() + assert unfiltered == with_all + + def test_quarterly_unmatched_filter_returns_empty(self, client): + """Test that a filter matching no orders yields an empty report.""" + response = client.get("/api/reports/quarterly?warehouse=Atlantis") + assert response.status_code == 200 + assert response.json() == [] + + +class TestMonthlyTrends: + """Test suite for /api/reports/monthly-trends.""" + + def test_get_all_monthly(self, client): + """Test getting monthly trends without filters.""" + response = client.get("/api/reports/monthly-trends") + assert response.status_code == 200 + + data = response.json() + assert isinstance(data, list) + assert len(data) > 0 + + for field in MONTHLY_FIELDS: + assert field in data[0] + + def test_monthly_field_types(self, client): + """Test that monthly fields have proper numeric types.""" + data = client.get("/api/reports/monthly-trends").json() + + for month in data: + assert isinstance(month["month"], str) + assert isinstance(month["order_count"], int) + assert isinstance(month["revenue"], (int, float)) + assert isinstance(month["delivered_count"], int) + + assert month["order_count"] >= 0 + assert month["revenue"] >= 0 + assert month["delivered_count"] <= month["order_count"] + + def test_monthly_month_format(self, client): + """Test that month keys use YYYY-MM, which the client splits on.""" + data = client.get("/api/reports/monthly-trends").json() + + for month in data: + year, month_number = month["month"].split("-") + assert len(year) == 4 and year.isdigit() + assert len(month_number) == 2 and month_number.isdigit() + assert 1 <= int(month_number) <= 12 + + def test_monthly_sorted_by_month(self, client): + """Test that months come back in ascending order. + + The client's month-over-month comparison reads the previous array element, + so ordering is a correctness requirement, not a presentation detail. + """ + data = client.get("/api/reports/monthly-trends").json() + months = [m["month"] for m in data] + assert months == sorted(months) + + def test_monthly_filter_by_warehouse(self, client): + """Test filtering monthly trends by warehouse.""" + response = client.get("/api/reports/monthly-trends?warehouse=Tokyo") + assert response.status_code == 200 + + data = response.json() + total = sum(m["order_count"] for m in data) + assert total == order_count(client, "?warehouse=Tokyo") + assert total < order_count(client) + + def test_monthly_filter_by_category(self, client): + """Test filtering monthly trends by category.""" + response = client.get("/api/reports/monthly-trends?category=Sensors") + assert response.status_code == 200 + + data = response.json() + total = sum(m["order_count"] for m in data) + assert total == order_count(client, "?category=Sensors") + + def test_monthly_filter_by_status(self, client): + """Test that filtering to Delivered leaves every month fully delivered.""" + data = client.get("/api/reports/monthly-trends?status=Delivered").json() + assert len(data) > 0 + + for month in data: + assert month["delivered_count"] == month["order_count"] + + def test_monthly_filter_by_month(self, client): + """Test that a single-month filter returns exactly that month.""" + response = client.get("/api/reports/monthly-trends?month=2025-05") + assert response.status_code == 200 + + data = response.json() + assert len(data) == 1 + assert data[0]["month"] == "2025-05" + assert data[0]["order_count"] == order_count(client, "?month=2025-05") + + def test_monthly_filter_by_quarter(self, client): + """Test that a quarter filter returns at most that quarter's three months.""" + response = client.get("/api/reports/monthly-trends?month=Q2-2025") + assert response.status_code == 200 + + data = response.json() + assert len(data) <= 3 + for month in data: + assert month["month"] in ["2025-04", "2025-05", "2025-06"] + + def test_monthly_multiple_filters(self, client): + """Test combining several filters on monthly trends.""" + query = "?warehouse=San Francisco&status=Shipped" + response = client.get(f"/api/reports/monthly-trends{query}") + assert response.status_code == 200 + + data = response.json() + total = sum(m["order_count"] for m in data) + assert total == order_count(client, query) + + def test_monthly_filter_all_is_noop(self, client): + """Test that explicit 'all' values behave the same as no filters.""" + unfiltered = client.get("/api/reports/monthly-trends").json() + with_all = client.get( + "/api/reports/monthly-trends?warehouse=all&category=all&status=all&month=all" + ).json() + assert unfiltered == with_all + + def test_monthly_unmatched_filter_returns_empty(self, client): + """Test that a filter matching no orders yields an empty trend list.""" + response = client.get("/api/reports/monthly-trends?category=Nonexistent") + assert response.status_code == 200 + assert response.json() == [] + + +class TestReportsConsistency: + """Both report endpoints and /api/orders must agree on the same filters.""" + + def test_quarterly_and_monthly_agree_unfiltered(self, client): + """Test that quarterly and monthly totals reconcile with no filters.""" + quarterly = client.get("/api/reports/quarterly").json() + monthly = client.get("/api/reports/monthly-trends").json() + + assert sum(q["total_orders"] for q in quarterly) == \ + sum(m["order_count"] for m in monthly) + assert abs(sum(q["total_revenue"] for q in quarterly) - + sum(m["revenue"] for m in monthly)) < 0.01 + + def test_quarterly_and_monthly_agree_filtered(self, client): + """Test that the two reports stay reconciled once filtered.""" + query = "?warehouse=Tokyo&status=Delivered" + quarterly = client.get(f"/api/reports/quarterly{query}").json() + monthly = client.get(f"/api/reports/monthly-trends{query}").json() + + assert sum(q["total_orders"] for q in quarterly) == \ + sum(m["order_count"] for m in monthly) + + def test_reports_agree_with_dashboard_summary(self, client): + """Test that report revenue matches the dashboard for identical filters. + + Reports previously ignored the global filters while the dashboard honoured them, + so the same metric could differ between the two screens. This pins them together. + """ + query = "?warehouse=London&category=Power Supplies" + monthly = client.get(f"/api/reports/monthly-trends{query}").json() + summary = client.get(f"/api/dashboard/summary{query}").json() + + report_revenue = sum(m["revenue"] for m in monthly) + assert abs(report_revenue - summary["total_orders_value"]) < 0.01 + + def test_reports_revenue_matches_orders_endpoint(self, client): + """Test that report revenue equals the sum of the matching orders' values.""" + query = "?warehouse=Tokyo&category=Sensors" + monthly = client.get(f"/api/reports/monthly-trends{query}").json() + orders = client.get(f"/api/orders{query}").json() + + report_revenue = sum(m["revenue"] for m in monthly) + orders_revenue = sum(o["total_value"] for o in orders) + assert abs(report_revenue - orders_revenue) < 0.01 diff --git a/tests/backend/test_restocking.py b/tests/backend/test_restocking.py new file mode 100644 index 000000000..ad96cf36b --- /dev/null +++ b/tests/backend/test_restocking.py @@ -0,0 +1,368 @@ +""" +Tests for restocking API endpoints. +""" +import pytest + +import main + + +@pytest.fixture(autouse=True) +def clear_submitted_orders(): + """Reset submitted restocking orders so each test starts from a clean slate. + + The endpoint stores orders in a module-level list, so without this the order + numbering and counts would depend on which tests ran before. + """ + main.submitted_restock_orders.clear() + yield + main.submitted_restock_orders.clear() + + +@pytest.fixture +def plan(client): + """A restocking plan with a budget large enough to fund several items.""" + response = client.get("/api/restock/recommendations?budget=5000") + assert response.status_code == 200 + return response.json() + + +def to_order_lines(recommendations): + """Convert plan recommendations into the request shape POST expects.""" + return [ + { + "item_sku": rec["item_sku"], + "item_name": rec["item_name"], + "supplier": rec["supplier"], + "quantity": rec["recommended_quantity"], + "unit_cost": rec["unit_cost"], + "line_total": rec["line_total"], + "lead_time_days": rec["lead_time_days"] + } + for rec in recommendations + ] + + +class TestRestockRecommendations: + """Test suite for the restocking recommendation endpoint.""" + + def test_get_recommendations(self, client): + """Test getting a restocking plan for a budget.""" + response = client.get("/api/restock/recommendations?budget=5000") + assert response.status_code == 200 + + data = response.json() + assert isinstance(data, dict) + + for field in [ + "budget", "recommendations", "skipped", "total_cost", + "remaining_budget", "total_need", "items_recommended", + "items_skipped", "total_units", "max_lead_time_days" + ]: + assert field in data + + assert data["budget"] == 5000 + assert isinstance(data["recommendations"], list) + assert len(data["recommendations"]) > 0 + + def test_recommendation_structure(self, plan): + """Test that each recommendation exposes the full item contract.""" + for rec in plan["recommendations"]: + assert "item_sku" in rec + assert "item_name" in rec + assert "supplier" in rec + assert "trend" in rec + assert "demand_gap" in rec + assert "recommended_quantity" in rec + assert "unit_cost" in rec + assert "line_total" in rec + assert "lead_time_days" in rec + assert "priority_score" in rec + + def test_recommendation_types(self, plan): + """Test that numeric recommendation fields have proper types and ranges.""" + for rec in plan["recommendations"]: + assert isinstance(rec["demand_gap"], int) + assert isinstance(rec["recommended_quantity"], int) + assert isinstance(rec["lead_time_days"], int) + assert isinstance(rec["unit_cost"], (int, float)) + assert isinstance(rec["line_total"], (int, float)) + assert isinstance(rec["priority_score"], (int, float)) + + assert rec["demand_gap"] > 0 + assert rec["recommended_quantity"] > 0 + assert rec["unit_cost"] > 0 + assert rec["lead_time_days"] > 0 + + def test_line_total_calculation(self, plan): + """Test that each line total is quantity multiplied by unit cost.""" + for rec in plan["recommendations"]: + expected = rec["recommended_quantity"] * rec["unit_cost"] + assert abs(rec["line_total"] - expected) < 0.01 + + def test_total_cost_matches_line_totals(self, plan): + """Test that the plan total is the sum of its recommended lines.""" + expected = sum(rec["line_total"] for rec in plan["recommendations"]) + assert abs(plan["total_cost"] - expected) < 0.01 + + def test_total_cost_within_budget(self, client): + """Test that allocated cost never exceeds the budget, at any budget.""" + for budget in [0, 500, 1500, 5000, 9702, 20000]: + response = client.get(f"/api/restock/recommendations?budget={budget}") + assert response.status_code == 200 + + data = response.json() + assert data["total_cost"] <= budget + 0.01 + assert abs(data["remaining_budget"] - (budget - data["total_cost"])) < 0.01 + + def test_recommendations_ordered_by_priority(self, plan): + """Test that recommendations come back in descending priority order.""" + scores = [rec["priority_score"] for rec in plan["recommendations"]] + assert scores == sorted(scores, reverse=True) + + def test_priority_score_applies_trend_weight(self, plan): + """Test that priority score is the demand gap weighted by trend.""" + weights = {"increasing": 1.5, "stable": 1.0, "decreasing": 0.5} + + for rec in plan["recommendations"] + plan["skipped"]: + expected = rec["demand_gap"] * weights[rec["trend"]] + assert abs(rec["priority_score"] - expected) < 0.05 + + def test_decreasing_demand_excluded(self, client): + """Test that items with no positive demand gap are never recommended.""" + # MTR-304 forecasts 35 against current demand of 50, so it has no gap. + response = client.get("/api/restock/recommendations?budget=100000") + data = response.json() + + all_skus = [ + rec["item_sku"] for rec in data["recommendations"] + data["skipped"] + ] + assert "MTR-304" not in all_skus + + def test_zero_budget_recommends_nothing(self, client): + """Test that a zero budget funds no items.""" + response = client.get("/api/restock/recommendations?budget=0") + assert response.status_code == 200 + + data = response.json() + assert data["items_recommended"] == 0 + assert data["recommendations"] == [] + assert data["total_cost"] == 0 + assert data["max_lead_time_days"] == 0 + assert data["items_skipped"] > 0 + + def test_large_budget_funds_everything(self, client): + """Test that a budget above total need leaves nothing skipped.""" + response = client.get("/api/restock/recommendations?budget=100000") + assert response.status_code == 200 + + data = response.json() + assert data["items_skipped"] == 0 + assert data["skipped"] == [] + assert abs(data["total_cost"] - data["total_need"]) < 0.01 + + def test_negative_budget_rejected(self, client): + """Test that a negative budget is a validation error.""" + response = client.get("/api/restock/recommendations?budget=-100") + assert response.status_code == 422 + + def test_counts_match_lists(self, plan): + """Test that the reported counts match the returned lists.""" + assert plan["items_recommended"] == len(plan["recommendations"]) + assert plan["items_skipped"] == len(plan["skipped"]) + assert plan["total_units"] == sum( + rec["recommended_quantity"] for rec in plan["recommendations"] + ) + + def test_max_lead_time_is_slowest_line(self, plan): + """Test that max lead time is the longest lead time among funded items.""" + expected = max(rec["lead_time_days"] for rec in plan["recommendations"]) + assert plan["max_lead_time_days"] == expected + + def test_recommendations_are_deterministic(self, client): + """Test that the same budget always produces the same plan.""" + first = client.get("/api/restock/recommendations?budget=4200").json() + second = client.get("/api/restock/recommendations?budget=4200").json() + assert first == second + + +class TestRestockOrderSubmission: + """Test suite for submitting and retrieving restocking orders.""" + + def test_submit_order(self, client, plan): + """Test submitting a restocking order.""" + response = client.post("/api/restock-orders", json={ + "budget": plan["budget"], + "items": to_order_lines(plan["recommendations"]) + }) + assert response.status_code == 201 + + order = response.json() + assert order["order_number"] == "RO-1001" + assert order["status"] == "Submitted" + assert order["item_count"] == plan["items_recommended"] + assert order["total_units"] == plan["total_units"] + assert abs(order["total_cost"] - plan["total_cost"]) < 0.01 + assert order["max_lead_time_days"] == plan["max_lead_time_days"] + + def test_submitted_order_structure(self, client, plan): + """Test that a submitted order exposes the full contract.""" + response = client.post("/api/restock-orders", json={ + "budget": plan["budget"], + "items": to_order_lines(plan["recommendations"]) + }) + order = response.json() + + for field in [ + "id", "order_number", "status", "submitted_date", + "expected_delivery", "max_lead_time_days", "budget", + "total_cost", "item_count", "total_units", "items" + ]: + assert field in order + + assert isinstance(order["items"], list) + assert len(order["items"]) == order["item_count"] + + def test_expected_delivery_uses_max_lead_time(self, client, plan): + """Test that expected delivery is submitted date plus the longest lead time.""" + from datetime import date, timedelta + + response = client.post("/api/restock-orders", json={ + "budget": plan["budget"], + "items": to_order_lines(plan["recommendations"]) + }) + order = response.json() + + submitted = date.fromisoformat(order["submitted_date"]) + expected = submitted + timedelta(days=order["max_lead_time_days"]) + assert order["expected_delivery"] == expected.isoformat() + + def test_order_dates_are_iso_format(self, client, plan): + """Test that submitted and delivery dates are ISO dates.""" + from datetime import date + + response = client.post("/api/restock-orders", json={ + "budget": plan["budget"], + "items": to_order_lines(plan["recommendations"]) + }) + order = response.json() + + # Raises ValueError if the format is not YYYY-MM-DD. + date.fromisoformat(order["submitted_date"]) + date.fromisoformat(order["expected_delivery"]) + + def test_submit_empty_order_rejected(self, client): + """Test that an order with no items is rejected.""" + response = client.post("/api/restock-orders", json={ + "budget": 5000, + "items": [] + }) + assert response.status_code == 400 + + data = response.json() + assert "detail" in data + assert "at least one item" in data["detail"].lower() + + def test_submit_malformed_order_rejected(self, client): + """Test that an item missing required fields is a validation error.""" + response = client.post("/api/restock-orders", json={ + "budget": 5000, + "items": [{"item_sku": "WDG-001"}] + }) + assert response.status_code == 422 + + def test_get_orders_empty_initially(self, client): + """Test that no restocking orders exist before any are submitted.""" + response = client.get("/api/restock-orders") + assert response.status_code == 200 + assert response.json() == [] + + def test_get_orders_after_submission(self, client, plan): + """Test that a submitted order is retrievable.""" + created = client.post("/api/restock-orders", json={ + "budget": plan["budget"], + "items": to_order_lines(plan["recommendations"]) + }).json() + + response = client.get("/api/restock-orders") + assert response.status_code == 200 + + orders = response.json() + assert len(orders) == 1 + assert orders[0]["order_number"] == created["order_number"] + + def test_order_numbers_increment(self, client, plan): + """Test that each submitted order gets the next sequential number.""" + payload = { + "budget": plan["budget"], + "items": to_order_lines(plan["recommendations"]) + } + + numbers = [ + client.post("/api/restock-orders", json=payload).json()["order_number"] + for _ in range(3) + ] + assert numbers == ["RO-1001", "RO-1002", "RO-1003"] + + def test_orders_returned_newest_first(self, client, plan): + """Test that the order list is ordered newest to oldest.""" + payload = { + "budget": plan["budget"], + "items": to_order_lines(plan["recommendations"]) + } + for _ in range(3): + client.post("/api/restock-orders", json=payload) + + orders = client.get("/api/restock-orders").json() + assert [o["order_number"] for o in orders] == [ + "RO-1003", "RO-1002", "RO-1001" + ] + + def test_submitted_items_preserve_plan_lines(self, client, plan): + """Test that submitted line items round-trip unchanged.""" + lines = to_order_lines(plan["recommendations"]) + + order = client.post("/api/restock-orders", json={ + "budget": plan["budget"], + "items": lines + }).json() + + assert order["items"] == lines + + +class TestDemandForecastRestockFields: + """Test suite for the restocking fields added to demand forecasts.""" + + def test_forecasts_expose_restock_fields(self, client): + """Test that every forecast carries cost, lead time and supplier.""" + 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 + assert "lead_time_days" in forecast + assert "supplier" in forecast + + assert isinstance(forecast["unit_cost"], (int, float)) + assert isinstance(forecast["lead_time_days"], int) + assert isinstance(forecast["supplier"], str) + + assert forecast["unit_cost"] > 0 + assert forecast["lead_time_days"] > 0 + assert forecast["supplier"] != "" + + def test_recommendations_match_forecast_data(self, client): + """Test that plan lines carry the same cost and lead time as the forecast.""" + forecasts = {f["item_sku"]: f for f in client.get("/api/demand").json()} + data = client.get("/api/restock/recommendations?budget=100000").json() + + for rec in data["recommendations"]: + forecast = forecasts[rec["item_sku"]] + assert rec["unit_cost"] == forecast["unit_cost"] + assert rec["lead_time_days"] == forecast["lead_time_days"] + assert rec["supplier"] == forecast["supplier"] + assert rec["demand_gap"] == ( + forecast["forecasted_demand"] - forecast["current_demand"] + )