Factory Inventory Management — how it fits together
+
+ A full-stack demo app (Claude Code workshop baseline): a Vue 3 single-page client
+ talking over REST to a FastAPI backend that serves filtered views of in-memory mock
+ data. No database — JSON files are loaded into memory at startup.
+
A classic 3-tier split. The client owns all interaction and rendering; the server owns
+ filtering, aggregation and validation; the data tier is just process memory hydrated from disk.
no persistencewrites live only at runtime; restart reloads from disk
+
+
+
+
+
+
+
+
+
+
+
Tech stack
+
What it's built with
+
Lean and dependency-light by design — no state-management or component libraries on the front,
+ no ORM or database on the back.
+
+
+
+
+
Frontend
client/
+
+
+
Vue 3 Composition API3.4.21
+
Vite build + dev server5.2.0
+
Vue Router client-side routing4.3.0
+
axios HTTP client1.6.7
+
Custom SVG charts, no chart lib—
+
+
+
+
+
+
Backend
server/
+
+
+
Python requires ≥ 3.113.11+
+
FastAPI REST + OpenAPI0.110+
+
uvicorn ASGI server0.24+
+
Pydantic validation & serialisation2.5+
+
Swagger UI auto docs at /docs—
+
+
+
+
+
+
Data & tooling
repo
+
+
+
JSON files in-memory at startup7 files
+
uv Python env + deps—
+
npm frontend deps—
+
pytest backend API tests51 tests
+
MCP Playwright + GitHub—
+
+
+
+
+
+
+
+
+
+
+
+
Request lifecycle
+
Data flow — from a filter click to repaint
+
Filters are the spine of the app. Changing one in the global FilterBar
+ propagates through a singleton composable, triggers a watched reload, and round-trips to the API.
This is a demo baseline, not a production system. A few sharp edges are intentional workshop material.
+
+
+
+
No persistence. Data lives in process memory, hydrated from server/data/*.json at startup. Any runtime change is lost on restart.
+
Open CORS, no auth.allow_origins=["*"] with no authentication, authorization or rate limiting — development only.
+
Hardcoded API base. The client points at http://localhost:8001/api directly in api.js; no env configuration.
+
No request debouncing. Filter watchers fire loadData() on every change, so rapid edits trigger multiple requests.
+
Known baseline gaps. The client calls GET /api/tasks, which the backend doesn't implement (returns 404), and references an unregistered PurchaseOrderModal component — both surface as console errors and are candidate fixes.
+
+
+
+
+
+
+
+
+
diff --git a/server/data/demand_forecasts.json b/server/data/demand_forecasts.json
index e1b38838..39208916 100644
--- a/server/data/demand_forecasts.json
+++ b/server/data/demand_forecasts.json
@@ -6,7 +6,8 @@
"current_demand": 300,
"forecasted_demand": 450,
"trend": "increasing",
- "period": "Next 30 days"
+ "period": "Next 30 days",
+ "unit_cost": 255.0
},
{
"id": "2",
@@ -15,7 +16,8 @@
"current_demand": 150,
"forecasted_demand": 152,
"trend": "stable",
- "period": "Next 30 days"
+ "period": "Next 30 days",
+ "unit_cost": 128.0
},
{
"id": "3",
@@ -24,7 +26,8 @@
"current_demand": 500,
"forecasted_demand": 600,
"trend": "increasing",
- "period": "Next 30 days"
+ "period": "Next 30 days",
+ "unit_cost": 46.0
},
{
"id": "4",
@@ -33,7 +36,8 @@
"current_demand": 50,
"forecasted_demand": 35,
"trend": "decreasing",
- "period": "Next 30 days"
+ "period": "Next 30 days",
+ "unit_cost": 1250.0
},
{
"id": "5",
@@ -42,7 +46,8 @@
"current_demand": 800,
"forecasted_demand": 950,
"trend": "increasing",
- "period": "Next 30 days"
+ "period": "Next 30 days",
+ "unit_cost": 96.0
},
{
"id": "6",
@@ -51,7 +56,8 @@
"current_demand": 120,
"forecasted_demand": 121,
"trend": "stable",
- "period": "Next 30 days"
+ "period": "Next 30 days",
+ "unit_cost": 310.0
},
{
"id": "7",
@@ -60,7 +66,8 @@
"current_demand": 250,
"forecasted_demand": 252,
"trend": "stable",
- "period": "Next 30 days"
+ "period": "Next 30 days",
+ "unit_cost": 18.99
},
{
"id": "8",
@@ -69,7 +76,8 @@
"current_demand": 180,
"forecasted_demand": 182,
"trend": "stable",
- "period": "Next 30 days"
+ "period": "Next 30 days",
+ "unit_cost": 230.0
},
{
"id": "9",
@@ -78,6 +86,7 @@
"current_demand": 95,
"forecasted_demand": 96,
"trend": "stable",
- "period": "Next 30 days"
+ "period": "Next 30 days",
+ "unit_cost": 480.0
}
]
diff --git a/server/main.py b/server/main.py
index a0c2d8c5..aa8e5c61 100644
--- a/server/main.py
+++ b/server/main.py
@@ -1,7 +1,10 @@
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from typing import List, Optional
-from pydantic import BaseModel
+from pydantic import BaseModel, Field
+from datetime import datetime, timedelta
+import itertools
+import threading
from mock_data import inventory_items, orders, demand_forecasts, backlog_items, spending_summary, monthly_spending, category_spending, recent_transactions, purchase_orders
app = FastAPI(title="Factory Inventory Management System")
@@ -89,6 +92,7 @@ class DemandForecast(BaseModel):
forecasted_demand: int
trend: str
period: str
+ unit_cost: Optional[float] = None
class BacklogItem(BaseModel):
id: str
@@ -100,6 +104,7 @@ class BacklogItem(BaseModel):
days_delayed: int
priority: str
has_purchase_order: Optional[bool] = False
+ purchase_order_id: Optional[str] = None
class PurchaseOrder(BaseModel):
id: str
@@ -120,6 +125,140 @@ class CreatePurchaseOrderRequest(BaseModel):
expected_delivery_date: str
notes: Optional[str] = None
+# --- Task models ---
+# dueDate is intentionally camelCase: the UI reads task.dueDate directly.
+class Task(BaseModel):
+ id: str
+ title: str
+ priority: str
+ dueDate: str
+ status: str
+
+class CreateTaskRequest(BaseModel):
+ title: str
+ priority: str = "medium"
+ dueDate: str
+
+# --- Restocking models ---
+
+class RestockRecommendation(BaseModel):
+ item_sku: str
+ item_name: str
+ current_demand: int
+ forecasted_demand: int
+ demand_gap: int
+ trend: str
+ recommended_quantity: int
+ unit_cost: float
+ line_total: float
+ lead_time_days: int
+ priority_rank: int
+
+class RestockOrderItemRequest(BaseModel):
+ item_sku: str
+ item_name: str
+ quantity: int = Field(ge=1)
+ unit_cost: float = Field(ge=0)
+
+class CreateRestockOrderRequest(BaseModel):
+ budget: float = Field(ge=0)
+ items: List[RestockOrderItemRequest]
+
+class RestockOrderItem(BaseModel):
+ item_sku: str
+ item_name: str
+ quantity: int
+ unit_cost: float
+ line_total: float
+ lead_time_days: int
+ expected_delivery: str
+
+class RestockOrder(BaseModel):
+ id: str
+ order_number: str
+ status: str
+ created_date: str
+ budget: float
+ total_value: float
+ item_count: int
+ lead_time_days: int
+ expected_delivery: str
+ items: List[RestockOrderItem]
+
+# In-memory store for submitted restocking orders (resets on server restart,
+# consistent with the demo's no-database design).
+submitted_restock_orders: List[dict] = []
+# Monotonic order sequence + lock so concurrent submissions never collide on
+# id / order_number (the sync endpoint runs in Starlette's threadpool).
+_restock_order_seq = itertools.count(1)
+_restock_lock = threading.Lock()
+
+# In-memory store for tasks (resets on server restart), mirroring the
+# restocking-order store + monotonic sequence + lock pattern.
+tasks_store: List[dict] = []
+_task_seq = itertools.count(1)
+_task_lock = threading.Lock()
+
+# Monotonic PO sequence + lock so concurrent purchase-order creations never
+# collide on id (mirrors the restocking-order pattern).
+_po_seq = itertools.count(1)
+_po_lock = threading.Lock()
+
+def restock_unit_cost(sku: str):
+ """Authoritative unit cost for a SKU: the demand forecast price, falling
+ back to the inventory unit cost. Returns None for an unknown SKU."""
+ forecast = next((f for f in demand_forecasts if f["item_sku"] == sku), None)
+ if forecast is None:
+ return None
+ cost = forecast.get("unit_cost")
+ if cost is None:
+ inv = next((i for i in inventory_items if i["sku"] == sku), None)
+ cost = inv["unit_cost"] if inv else None
+ return cost
+
+def restock_lead_time_days(sku: str) -> int:
+ """Deterministic per-SKU delivery lead time in the 7-28 day range.
+
+ Uses a stable character-sum hash (not Python's randomized hash()) so the
+ same SKU always reports the same lead time across requests and restarts.
+ """
+ return 7 + (sum(ord(c) for c in sku) % 22)
+
+def build_restock_recommendations() -> List[dict]:
+ """Build budget-agnostic restock candidates from the demand forecast.
+
+ A candidate is any forecast item with a positive demand gap
+ (forecasted - current). Recommended quantity covers exactly that gap.
+ Sorted by demand urgency: largest gap first, then largest line total,
+ then SKU for a fully deterministic order.
+ """
+ candidates = []
+ for f in demand_forecasts:
+ gap = f["forecasted_demand"] - f["current_demand"]
+ if gap <= 0:
+ continue
+ unit_cost = restock_unit_cost(f["item_sku"])
+ if unit_cost is None:
+ unit_cost = 0.0
+ line_total = round(gap * unit_cost, 2)
+ candidates.append({
+ "item_sku": f["item_sku"],
+ "item_name": f["item_name"],
+ "current_demand": f["current_demand"],
+ "forecasted_demand": f["forecasted_demand"],
+ "demand_gap": gap,
+ "trend": f["trend"],
+ "recommended_quantity": gap,
+ "unit_cost": round(unit_cost, 2),
+ "line_total": line_total,
+ "lead_time_days": restock_lead_time_days(f["item_sku"]),
+ })
+
+ candidates.sort(key=lambda c: (-c["demand_gap"], -c["line_total"], c["item_sku"]))
+ for rank, c in enumerate(candidates, start=1):
+ c["priority_rank"] = rank
+ return candidates
+
# API endpoints
@app.get("/")
def root():
@@ -166,6 +305,158 @@ def get_demand_forecasts():
"""Get demand forecasts"""
return demand_forecasts
+@app.get("/api/restock/recommendations", response_model=List[RestockRecommendation])
+def get_restock_recommendations():
+ """Get restock recommendations derived from the demand forecast.
+
+ Returns every item with a positive demand gap, ordered by demand urgency.
+ Budget selection happens client-side so the slider responds instantly.
+ """
+ return build_restock_recommendations()
+
+@app.get("/api/restock/orders", response_model=List[RestockOrder])
+def get_restock_orders():
+ """Get submitted restocking orders, most recent first."""
+ return list(reversed(submitted_restock_orders))
+
+@app.post("/api/restock/orders", response_model=RestockOrder, status_code=201)
+def create_restock_order(request: CreateRestockOrderRequest):
+ """Submit a restocking order.
+
+ The server is the source of truth: it prices each line from the authoritative
+ SKU unit cost (ignoring any client-supplied price), recomputes line totals
+ and per-item lead times, and derives the order-level lead time from the
+ slowest line (the order is complete only once the last item arrives). It then
+ assigns a unique, monotonic RST order number.
+ """
+ if not request.items:
+ raise HTTPException(status_code=400, detail="A restocking order must contain at least one item.")
+
+ created_dt = datetime.now()
+ order_items = []
+ total_value = 0.0
+ max_lead = 0
+
+ for item in request.items:
+ unit_cost = restock_unit_cost(item.item_sku)
+ if unit_cost is None:
+ raise HTTPException(status_code=400, detail=f"Unknown or unpriced SKU: {item.item_sku}")
+ line_total = round(item.quantity * unit_cost, 2)
+ lead = restock_lead_time_days(item.item_sku)
+ total_value += line_total
+ max_lead = max(max_lead, lead)
+ order_items.append({
+ "item_sku": item.item_sku,
+ "item_name": item.item_name,
+ "quantity": item.quantity,
+ "unit_cost": round(unit_cost, 2),
+ "line_total": line_total,
+ "lead_time_days": lead,
+ "expected_delivery": (created_dt + timedelta(days=lead)).isoformat(timespec="seconds"),
+ })
+
+ total_value = round(total_value, 2)
+ if total_value > round(request.budget, 2) + 1e-9:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Order total {total_value} exceeds budget {request.budget}.",
+ )
+
+ # Distinct, never-reused identifiers even under concurrent submissions.
+ with _restock_lock:
+ seq = next(_restock_order_seq)
+ new_order = {
+ "id": str(seq),
+ "order_number": f"RST-{created_dt.year}-{seq:04d}",
+ "status": "Submitted",
+ "created_date": created_dt.isoformat(timespec="seconds"),
+ "budget": round(request.budget, 2),
+ "total_value": total_value,
+ "item_count": len(order_items),
+ "lead_time_days": max_lead,
+ "expected_delivery": (created_dt + timedelta(days=max_lead)).isoformat(timespec="seconds"),
+ "items": order_items,
+ }
+ submitted_restock_orders.append(new_order)
+ return new_order
+
+# --- Tasks endpoints ---
+@app.get("/api/tasks", response_model=List[Task])
+def get_tasks():
+ """Get all tasks, newest first."""
+ return list(reversed(tasks_store))
+
+@app.post("/api/tasks", response_model=Task, status_code=201)
+def create_task(request: CreateTaskRequest):
+ """Create a new task.
+
+ The id is the STRING f"task-{seq}" on purpose: the frontend mock tasks in
+ useAuth.js use INTEGER ids 1-4 and App.vue uses strict === to decide
+ mock-vs-API, so a string id can never collide with them and mutations
+ always route to the API instead of the mock list.
+ """
+ with _task_lock:
+ seq = next(_task_seq)
+ new_task = {
+ "id": f"task-{seq}",
+ "title": request.title,
+ "priority": request.priority,
+ "dueDate": request.dueDate,
+ "status": "pending",
+ }
+ tasks_store.append(new_task)
+ return new_task
+
+@app.patch("/api/tasks/{task_id}", response_model=Task)
+def toggle_task(task_id: str):
+ """Toggle a task's status between 'pending' and 'completed'."""
+ task = next((t for t in tasks_store if t["id"] == task_id), None)
+ if task is None:
+ raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
+ task["status"] = "completed" if task["status"] == "pending" else "pending"
+ return task
+
+@app.delete("/api/tasks/{task_id}", status_code=204)
+def delete_task(task_id: str):
+ """Delete a task by id."""
+ task = next((t for t in tasks_store if t["id"] == task_id), None)
+ if task is None:
+ raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
+ tasks_store.remove(task)
+
+# --- Purchase order endpoints ---
+@app.post("/api/purchase-orders", response_model=PurchaseOrder, status_code=201)
+def create_purchase_order(request: CreatePurchaseOrderRequest):
+ """Create a purchase order for a backlog item.
+
+ Returns the created PO including its id and backlog_item_id, both of which
+ the frontend's handlePOCreated handler relies on to update button state.
+ """
+ created_date = datetime.now().date().isoformat()
+ with _po_lock:
+ seq = next(_po_seq)
+ new_po = {
+ "id": f"PO-{seq:04d}",
+ "backlog_item_id": request.backlog_item_id,
+ "supplier_name": request.supplier_name,
+ "quantity": request.quantity,
+ "unit_cost": request.unit_cost,
+ "expected_delivery_date": request.expected_delivery_date,
+ "status": "Pending",
+ "created_date": created_date,
+ "notes": request.notes,
+ }
+ purchase_orders.append(new_po)
+ return new_po
+
+@app.get("/api/purchase-orders/{backlog_item_id}", response_model=PurchaseOrder)
+def get_purchase_order(backlog_item_id: str):
+ """Get the purchase order associated with a backlog item."""
+ po = next((p for p in purchase_orders if p["backlog_item_id"] == backlog_item_id), None)
+ if po is None:
+ raise HTTPException(status_code=404, detail=f"No purchase order for backlog item {backlog_item_id}")
+ return po
+
@app.get("/api/backlog", response_model=List[BacklogItem])
def get_backlog():
"""Get backlog items with purchase order status"""
@@ -173,9 +464,15 @@ def get_backlog():
result = []
for item in backlog_items:
item_dict = dict(item)
- # Check if this backlog item has a purchase order
- has_po = any(po["backlog_item_id"] == item["id"] for po in purchase_orders)
- item_dict["has_purchase_order"] = has_po
+ # Surface the matching PO's id (not just a boolean) so the Dashboard's
+ # Create/View PO button state persists across reloads, since the
+ # template keys off item.purchase_order_id.
+ po = next((p for p in purchase_orders if p["backlog_item_id"] == item["id"]), None)
+ if po is not None:
+ item_dict["purchase_order_id"] = po["id"]
+ item_dict["has_purchase_order"] = True
+ else:
+ item_dict["has_purchase_order"] = False
result.append(item_dict)
return result
@@ -228,12 +525,22 @@ def get_recent_transactions():
return recent_transactions
@app.get("/api/reports/quarterly")
-def get_quarterly_reports():
- """Get quarterly performance reports"""
- # Calculate quarterly statistics from orders
+def get_quarterly_reports(
+ warehouse: Optional[str] = None,
+ category: Optional[str] = None,
+ status: Optional[str] = None,
+ month: Optional[str] = None
+):
+ """Get quarterly performance reports (respects the global filter bar)"""
+ # Mirror /api/orders filtering so the report reflects the active filters
+ # instead of always aggregating every order.
+ filtered_orders = apply_filters(orders, warehouse, category, status)
+ filtered_orders = filter_by_month(filtered_orders, month)
+
+ # Calculate quarterly statistics from the filtered 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 +581,20 @@ 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 (respects the global filter bar)"""
+ # Mirror /api/orders filtering so the trend reflects the active filters.
+ 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_tasks_and_purchase_orders.py b/tests/backend/test_tasks_and_purchase_orders.py
new file mode 100644
index 00000000..36d527af
--- /dev/null
+++ b/tests/backend/test_tasks_and_purchase_orders.py
@@ -0,0 +1,234 @@
+"""
+Tests for tasks and purchase orders API endpoints.
+
+NOTE: The backend uses MODULE-LEVEL in-memory stores (tasks_store,
+purchase_orders) that persist across tests within the same process. These
+tests therefore avoid asserting absolute counts from an assumed-empty start.
+Instead they capture counts before/after a mutation and assert the delta, and
+they reference resources by the IDs they create so the tests remain
+order-independent.
+"""
+import pytest
+
+
+class TestTasksEndpoints:
+ """Test suite for /api/tasks endpoints."""
+
+ def _create_task(self, client, title="Reconcile inventory counts",
+ priority="high", due_date="2026-07-01"):
+ """Helper: create a task and return the parsed response."""
+ payload = {
+ "title": title,
+ "priority": priority,
+ "dueDate": due_date,
+ }
+ response = client.post("/api/tasks", json=payload)
+ return response, payload
+
+ def test_get_all_tasks_returns_list(self, client):
+ """Test that getting all tasks returns a JSON list."""
+ response = client.get("/api/tasks")
+ assert response.status_code == 200
+
+ data = response.json()
+ assert isinstance(data, list)
+
+ def test_create_task_returns_201_and_string_id(self, client):
+ """Test creating a task returns 201 with a non-numeric string id."""
+ response, payload = self._create_task(client)
+ assert response.status_code == 201
+
+ task = response.json()
+
+ # id must be a string and NOT purely numeric (e.g. "task-...")
+ assert "id" in task
+ assert isinstance(task["id"], str)
+ assert not task["id"].isdigit(), \
+ f"Task id should not be purely numeric, got {task['id']!r}"
+ assert task["id"].startswith("task-")
+
+ def test_create_task_default_status_pending(self, client):
+ """Test that a newly created task has status 'pending'."""
+ response, payload = self._create_task(client)
+ assert response.status_code == 201
+
+ task = response.json()
+ assert task["status"] == "pending"
+
+ def test_create_task_echoes_payload(self, client):
+ """Test that the created task echoes title, priority, and dueDate."""
+ response, payload = self._create_task(
+ client,
+ title="Audit supplier contracts",
+ priority="medium",
+ due_date="2026-08-15",
+ )
+ assert response.status_code == 201
+
+ task = response.json()
+ assert task["title"] == payload["title"]
+ assert task["priority"] == payload["priority"]
+ assert task["dueDate"] == payload["dueDate"]
+
+ def test_created_task_appears_in_list_newest_first(self, client):
+ """Test that a newly created task appears in GET and before older ones."""
+ # Create an older task first, then a newer one.
+ older_resp, _ = self._create_task(client, title="Older task")
+ assert older_resp.status_code == 201
+ older_id = older_resp.json()["id"]
+
+ newer_resp, _ = self._create_task(client, title="Newer task")
+ assert newer_resp.status_code == 201
+ newer_id = newer_resp.json()["id"]
+
+ response = client.get("/api/tasks")
+ assert response.status_code == 200
+ data = response.json()
+
+ ids = [t["id"] for t in data]
+ # Both tasks must be present.
+ assert older_id in ids
+ assert newer_id in ids
+
+ # Newest-first ordering: the newer task appears before the older one.
+ assert ids.index(newer_id) < ids.index(older_id)
+
+ def test_create_task_increases_count_by_one(self, client):
+ """Test the store grows by exactly one task (delta, not absolute)."""
+ before = len(client.get("/api/tasks").json())
+
+ response, _ = self._create_task(client)
+ assert response.status_code == 201
+
+ after = len(client.get("/api/tasks").json())
+ assert after == before + 1
+
+ def test_patch_task_toggles_status(self, client):
+ """Test PATCH toggles a task status pending -> completed and back."""
+ create_resp, _ = self._create_task(client)
+ assert create_resp.status_code == 201
+ task = create_resp.json()
+ task_id = task["id"]
+ assert task["status"] == "pending"
+
+ # Toggle to completed.
+ response = client.patch(f"/api/tasks/{task_id}")
+ assert response.status_code == 200
+ assert response.json()["status"] == "completed"
+
+ # Toggle back to pending.
+ response = client.patch(f"/api/tasks/{task_id}")
+ assert response.status_code == 200
+ assert response.json()["status"] == "pending"
+
+ def test_patch_nonexistent_task_returns_404(self, client):
+ """Test PATCH on an unknown task id returns 404."""
+ response = client.patch("/api/tasks/task-nonexistent-999")
+ assert response.status_code == 404
+
+ data = response.json()
+ assert "detail" in data
+ assert "not found" in data["detail"].lower()
+
+ def test_delete_task_returns_204_and_removes_it(self, client):
+ """Test DELETE returns 204 and the task no longer appears in GET."""
+ create_resp, _ = self._create_task(client)
+ assert create_resp.status_code == 201
+ task_id = create_resp.json()["id"]
+
+ response = client.delete(f"/api/tasks/{task_id}")
+ assert response.status_code == 204
+
+ # Task should no longer be present.
+ ids = [t["id"] for t in client.get("/api/tasks").json()]
+ assert task_id not in ids
+
+ def test_delete_nonexistent_task_returns_404(self, client):
+ """Test DELETE on an unknown task id returns 404."""
+ response = client.delete("/api/tasks/task-nonexistent-999")
+ assert response.status_code == 404
+
+ data = response.json()
+ assert "detail" in data
+ assert "not found" in data["detail"].lower()
+
+
+class TestPurchaseOrdersEndpoints:
+ """Test suite for /api/purchase-orders endpoints."""
+
+ def _create_purchase_order(self, client, backlog_item_id="backlog-test-1"):
+ """Helper: create a purchase order and return the response + payload."""
+ payload = {
+ "backlog_item_id": backlog_item_id,
+ "supplier_name": "Acme Components Ltd",
+ "quantity": 250,
+ "unit_cost": 12.75,
+ "expected_delivery_date": "2026-07-30",
+ "notes": "Expedited order for backlog clearance",
+ }
+ response = client.post("/api/purchase-orders", json=payload)
+ return response, payload
+
+ def test_create_purchase_order_returns_201(self, client):
+ """Test creating a purchase order returns 201 with a generated id."""
+ response, payload = self._create_purchase_order(
+ client, backlog_item_id="backlog-create-201"
+ )
+ assert response.status_code == 201
+
+ po = response.json()
+ assert "id" in po
+ assert po["id"] # non-empty generated id
+
+ def test_create_purchase_order_default_status_pending(self, client):
+ """Test a newly created purchase order has status 'Pending'."""
+ response, _ = self._create_purchase_order(
+ client, backlog_item_id="backlog-status-pending"
+ )
+ assert response.status_code == 201
+
+ po = response.json()
+ assert po["status"] == "Pending"
+
+ def test_create_purchase_order_echoes_payload(self, client):
+ """Test the created PO echoes backlog_item_id and payload fields."""
+ response, payload = self._create_purchase_order(
+ client, backlog_item_id="backlog-echo-fields"
+ )
+ assert response.status_code == 201
+
+ po = response.json()
+ assert po["backlog_item_id"] == payload["backlog_item_id"]
+ assert po["supplier_name"] == payload["supplier_name"]
+ assert po["quantity"] == payload["quantity"]
+ assert po["unit_cost"] == payload["unit_cost"]
+ assert po["expected_delivery_date"] == payload["expected_delivery_date"]
+ assert po["notes"] == payload["notes"]
+
+ def test_get_purchase_order_by_backlog_item_id(self, client):
+ """Test fetching a PO by its backlog_item_id returns the created PO."""
+ backlog_item_id = "backlog-get-by-id"
+ create_resp, payload = self._create_purchase_order(
+ client, backlog_item_id=backlog_item_id
+ )
+ assert create_resp.status_code == 201
+ created_id = create_resp.json()["id"]
+
+ response = client.get(f"/api/purchase-orders/{backlog_item_id}")
+ assert response.status_code == 200
+
+ po = response.json()
+ assert po["backlog_item_id"] == backlog_item_id
+ assert po["id"] == created_id
+ assert po["supplier_name"] == payload["supplier_name"]
+
+ def test_get_purchase_order_for_unknown_backlog_item_returns_404(self, client):
+ """Test fetching a PO for a backlog_item_id with no PO returns 404."""
+ response = client.get("/api/purchase-orders/backlog-nonexistent-999")
+ assert response.status_code == 404
+
+ data = response.json()
+ assert "detail" in data
+ # The 404 status is the contract; assert the message references the
+ # missing purchase order without pinning exact wording.
+ assert "purchase order" in data["detail"].lower()