From 3d0f1d6f2257e07075f41886c1f96b74c5b46472 Mon Sep 17 00:00:00 2001 From: Adrien Monte Date: Tue, 23 Jun 2026 11:17:28 +0100 Subject: [PATCH 1/2] feat: purchase orders + tasks APIs, restocking, and dashboard fixes Backend (server/main.py) - Tasks API: GET/POST/PATCH/DELETE /api/tasks backed by an in-memory store with string ids ("task-N") so they never collide with the frontend mock task ids; fixes the /api/tasks 404 on every page load. - Purchase Orders API: POST /api/purchase-orders and GET /api/purchase-orders/{backlog_item_id}. - Add purchase_order_id to BacklogItem and populate it in get_backlog() so the dashboard Create/View PO button state persists across reloads. - Restocking endpoints and models. Frontend - New PurchaseOrderModal.vue (create + view modes), imported and registered in Dashboard; resolves the "Failed to resolve component: PurchaseOrderModal" warning. - New Restocking view. - Fix doubled "+" on increasing-demand cards (Demand.vue). - Remove leftover debug console.* statements (Reports.vue). - Add inline SVG favicon (index.html); fixes /favicon.ico 404. - Tasks UI wiring, en/ja locales, dashboard/orders updates. Tests - tests/backend/test_tasks_and_purchase_orders.py (15 tests). Full backend suite: 55 passing. Docs - docs/architecture.html system-architecture page. Co-Authored-By: Claude Opus 4.8 (1M context) --- client/index.html | 1 + client/src/App.vue | 3 + client/src/api.js | 15 + client/src/components/PurchaseOrderModal.vue | 759 +++++++++++++++ client/src/locales/en.js | 53 ++ client/src/locales/ja.js | 53 ++ client/src/main.js | 2 + client/src/views/Dashboard.vue | 879 ++++++++++++------ client/src/views/Demand.vue | 2 +- client/src/views/Orders.vue | 126 ++- client/src/views/Reports.vue | 13 - client/src/views/Restocking.vue | 806 ++++++++++++++++ docs/architecture.html | 614 ++++++++++++ server/data/demand_forecasts.json | 27 +- server/main.py | 305 +++++- .../backend/test_tasks_and_purchase_orders.py | 234 +++++ 16 files changed, 3562 insertions(+), 330 deletions(-) create mode 100644 client/src/components/PurchaseOrderModal.vue create mode 100644 client/src/views/Restocking.vue create mode 100644 docs/architecture.html create mode 100644 tests/backend/test_tasks_and_purchase_orders.py diff --git a/client/index.html b/client/index.html index 1b6ad0a9..efdea57e 100644 --- a/client/index.html +++ b/client/index.html @@ -4,6 +4,7 @@ Factory Inventory Management System +
diff --git a/client/src/App.vue b/client/src/App.vue index c2da05a5..dd6849b1 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -22,6 +22,9 @@ {{ t('nav.demandForecast') }} + + {{ t('nav.restocking') }} + Reports diff --git a/client/src/api.js b/client/src/api.js index 11cb9db7..02ae1a5a 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -38,6 +38,21 @@ export const api = { return response.data }, + async getRestockRecommendations() { + const response = await axios.get(`${API_BASE_URL}/restock/recommendations`) + return response.data + }, + + async getRestockOrders() { + const response = await axios.get(`${API_BASE_URL}/restock/orders`) + return response.data + }, + + async createRestockOrder(payload) { + const response = await axios.post(`${API_BASE_URL}/restock/orders`, payload) + return response.data + }, + async getBacklog() { const response = await axios.get(`${API_BASE_URL}/backlog`) return response.data diff --git a/client/src/components/PurchaseOrderModal.vue b/client/src/components/PurchaseOrderModal.vue new file mode 100644 index 00000000..e1b67afb --- /dev/null +++ b/client/src/components/PurchaseOrderModal.vue @@ -0,0 +1,759 @@ + + + + + diff --git a/client/src/locales/en.js b/client/src/locales/en.js index 03a58fe6..1f21dea0 100644 --- a/client/src/locales/en.js +++ b/client/src/locales/en.js @@ -6,6 +6,7 @@ export default { orders: 'Orders', finance: 'Finance', demandForecast: 'Demand Forecast', + restocking: 'Restocking', companyName: 'Catalyst Components', subtitle: 'Inventory Management System' }, @@ -126,6 +127,19 @@ export default { status: 'Status', expectedDelivery: 'Expected Delivery', actualDelivery: 'Actual Delivery' + }, + submitted: { + title: 'Submitted Orders', + subtitle: 'Restocking orders awaiting delivery', + empty: 'No restocking orders submitted yet. Build one in the Restocking tab.', + orderNumber: 'Order Number', + submittedOn: 'Submitted', + items: 'Items', + orderTotal: 'Order Total', + leadTime: 'Lead Time', + expectedDelivery: 'Expected Delivery', + inDays: 'in {count} days', + status: 'Submitted' } }, @@ -188,6 +202,45 @@ export default { } }, + // Restocking + restocking: { + title: 'Restocking', + description: 'Set a budget and let the demand forecast recommend what to restock', + budgetLabel: 'Available budget', + budgetHint: 'Drag to set how much you can spend this cycle', + fullOrderCost: 'Full recommended order', + capacitySpent: 'Allocated', + capacityRemaining: 'Remaining', + overBudget: 'Over budget', + stats: { + inOrder: 'Items in order', + orderTotal: 'Order total', + budgetRemaining: 'Budget remaining', + excluded: "Won't fit" + }, + inOrder: 'In this order', + waterline: "won't fit", + table: { + item: 'Item', + trend: 'Trend', + gap: 'Forecast gap', + quantity: 'Restock qty', + unitCost: 'Unit cost', + lineTotal: 'Line total', + leadTime: 'Lead time', + share: 'Budget share' + }, + leadDays: '{count}d', + placeOrder: 'Place Order', + placing: 'Submitting…', + emptySelection: 'Increase the budget to add items to the order', + noRecommendations: 'No restock needed — every forecast item is fully stocked.', + successTitle: 'Order {orderNumber} submitted', + successBody: '{count} items · {total} · arrives in {lead} days', + viewInOrders: 'View in Orders', + placeAnother: 'Build another order' + }, + // Filters filters: { timePeriod: 'Time Period', diff --git a/client/src/locales/ja.js b/client/src/locales/ja.js index db33223a..7677034d 100644 --- a/client/src/locales/ja.js +++ b/client/src/locales/ja.js @@ -6,6 +6,7 @@ export default { orders: '注文', finance: '財務', demandForecast: '需要予測', + restocking: '補充', companyName: '触媒コンポーネンツ', subtitle: '在庫管理システム' }, @@ -126,6 +127,19 @@ export default { status: 'ステータス', expectedDelivery: '予定配達日', actualDelivery: '実際の配達日' + }, + submitted: { + title: '送信済み注文', + subtitle: '配達待ちの補充注文', + empty: 'まだ補充注文はありません。「補充」タブで作成してください。', + orderNumber: '注文番号', + submittedOn: '送信日', + items: '品目', + orderTotal: '注文合計', + leadTime: 'リードタイム', + expectedDelivery: '予定配達日', + inDays: '{count}日後', + status: '送信済み' } }, @@ -188,6 +202,45 @@ export default { } }, + // Restocking + restocking: { + title: '補充', + description: '予算を設定すると、需要予測が補充すべき品目を推奨します', + budgetLabel: '利用可能な予算', + budgetHint: 'このサイクルで使える金額をドラッグして設定', + fullOrderCost: '推奨注文の総額', + capacitySpent: '割当済み', + capacityRemaining: '残り', + overBudget: '予算超過', + stats: { + inOrder: '注文内の品目', + orderTotal: '注文合計', + budgetRemaining: '残り予算', + excluded: '予算外' + }, + inOrder: 'この注文に含む', + waterline: '予算外', + table: { + item: '品目', + trend: 'トレンド', + gap: '予測ギャップ', + quantity: '補充数量', + unitCost: '単価', + lineTotal: '小計', + leadTime: 'リードタイム', + share: '予算配分' + }, + leadDays: '{count}日', + placeOrder: '注文する', + placing: '送信中…', + emptySelection: '予算を増やすと品目が注文に追加されます', + noRecommendations: '補充は不要です — すべての予測品目は在庫十分です。', + successTitle: '注文 {orderNumber} を送信しました', + successBody: '{count}品目 · {total} · {lead}日後に到着', + viewInOrders: '注文で表示', + placeAnother: '別の注文を作成' + }, + // Filters filters: { timePeriod: '期間', diff --git a/client/src/main.js b/client/src/main.js index 477c2d96..cea20394 100644 --- a/client/src/main.js +++ b/client/src/main.js @@ -5,6 +5,7 @@ import Dashboard from './views/Dashboard.vue' import Inventory from './views/Inventory.vue' import Orders from './views/Orders.vue' import Demand from './views/Demand.vue' +import Restocking from './views/Restocking.vue' import Spending from './views/Spending.vue' import Reports from './views/Reports.vue' @@ -15,6 +16,7 @@ const router = createRouter({ { path: '/inventory', component: Inventory }, { path: '/orders', component: Orders }, { path: '/demand', component: Demand }, + { path: '/restocking', component: Restocking }, { path: '/spending', component: Spending }, { path: '/reports', component: Reports } ] diff --git a/client/src/views/Dashboard.vue b/client/src/views/Dashboard.vue index 437da9c2..f310e40b 100644 --- a/client/src/views/Dashboard.vue +++ b/client/src/views/Dashboard.vue @@ -1,22 +1,26 @@ diff --git a/client/src/views/Reports.vue b/client/src/views/Reports.vue index 35187eaf..364a3e98 100644 --- a/client/src/views/Reports.vue +++ b/client/src/views/Reports.vue @@ -142,38 +142,28 @@ export default { } }, mounted() { - console.log('Reports component mounted') this.loadData() }, methods: { async loadData() { - console.log('Loading reports data...') try { this.loading = true // Fetch quarterly data - console.log('Fetching quarterly data...') const quarterlyResponse = await axios.get('http://localhost:8001/api/reports/quarterly') this.quarterlyData = quarterlyResponse.data - console.log('Quarterly data:', this.quarterlyData) // Fetch monthly data - console.log('Fetching monthly data...') const monthlyResponse = await axios.get('http://localhost:8001/api/reports/monthly-trends') this.monthlyData = monthlyResponse.data - console.log('Monthly data:', this.monthlyData) // Calculate summary stats - console.log('Calculating summary stats...') this.calculateSummaryStats() - console.log('Summary stats calculated') } catch (err) { - console.log('Error loading reports:', err) this.error = 'Failed to load reports: ' + err.message } finally { this.loading = false - console.log('Loading complete') } }, @@ -212,7 +202,6 @@ export default { }, formatNumber(num) { - console.log('Formatting number:', num) // Format number with commas var str = num.toString() var parts = str.split('.') @@ -240,7 +229,6 @@ export default { }, formatMonth(monthStr) { - console.log('Formatting month:', monthStr) // Convert YYYY-MM to readable format var parts = monthStr.split('-') var year = parts[0] @@ -253,7 +241,6 @@ export default { }, getBarHeight(revenue) { - console.log('Calculating bar height for revenue:', revenue) // Calculate bar height (max height 200px) var maxRevenue = 0 for (var i = 0; i < this.monthlyData.length; i++) { diff --git a/client/src/views/Restocking.vue b/client/src/views/Restocking.vue new file mode 100644 index 00000000..3714b80b --- /dev/null +++ b/client/src/views/Restocking.vue @@ -0,0 +1,806 @@ + + + + + diff --git a/docs/architecture.html b/docs/architecture.html new file mode 100644 index 00000000..8e3f5034 --- /dev/null +++ b/docs/architecture.html @@ -0,0 +1,614 @@ + + + + + +System Architecture — Factory Inventory Management + + + + + + + + +
+
+
System Architecture
+

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. +

+
+ Vue 3 SPA :3000 + FastAPI :8001 + In-memory JSON 7 files + REST · query-param filters + no database · no auth +
+
+
+ + +
+
+
+
Three tiers
+

The system at a glance

+

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.

+
+ +
+ +
+
Client tier
+

Browser — Vue 3 SPA

+
localhost:3000 · Vite dev server
+
    +
  • vue-router6 routes: Dashboard, Inventory, Orders, Demand, Spending, Reports
  • +
  • composablesuseFilters (singleton state), useAuth, useI18n (en / ja)
  • +
  • api.js · axioscentralised client, base http://localhost:8001/api
  • +
  • views + componentscustom SVG charts, modals; no UI / chart library
  • +
+
+ + +
+
+ request +
+ HTTP GET · ?filters +
+
+ response +
+ JSON · validated +
+
+ + +
+
Server tier
+

FastAPI — REST API

+
localhost:8001 · uvicorn (ASGI)
+
    +
  • main.py14 GET endpoints under /api/*; CORS allow_origins=["*"]
  • +
  • apply_filters()in-memory filtering by warehouse / category / status / month
  • +
  • Pydantic modelsresponse_model validates & serialises every response
  • +
  • aggregationdashboard, quarterly & monthly reports computed on the fly
  • +
+
+ + +
+
+ read +
+ list comprehensions +
+
+ load · startup +
+ json.load() +
+
+ + +
+
Data tier
+

In-memory mock data

+
process memory · no DB
+
    +
  • mock_data.pyloads 7 JSON files into module-level dicts at import time
  • +
  • server/data/*.jsoninventory, orders, demand, backlog, spending, transactions, POs
  • +
  • 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.

+
+ +
+
01
FilterBar
User changes a dropdown; v-model updates a ref.
+
02
useFilters
Singleton refs (period, location, category, status) update everywhere.
+
03
watch()
View watcher fires loadData() on any filter change.
+
04
api.js · axios
Non-"all" values appended as URLSearchParams.
+
05
FastAPI route
Query params bound as Optional[str].
+
06
apply_filters()
In-memory list filtered by warehouse / category / status / month.
+
07
Pydantic
response_model validates & serialises to JSON.
+
08
Vue refs
Response assigned to refs; computed props re-derive.
+
09
Render
DOM repaints — tables, stat cards & SVG charts update.
+
+ +
+
+ Example requestGET http://localhost:8001/api/orders?warehouse=San+Francisco&category=Circuit+Boards&status=Delivered&month=2025-01 +
+
+Example response · List[Order][ + { + "id": "1", + "order_number": "ORD-2025-0001", + "customer": "MegaCorp Industries", + "status": "Delivered", + "warehouse": "San Francisco", + "total_value": 87799.5 + } +] +
+
+
+
+ + +
+
+
+
API surface
+

Endpoints

+

All read-only GET routes. Filters are optional query params; "all" is treated as "no filter".

+
+ +
+
+ + + + + + + + + + + + + + + + + + + + +
VerbPathFiltersDescription
GET/API info & version
GET/api/inventory
warehousecategory
Inventory items across warehouses
GET/api/inventory/{id}Single item (404 if missing)
GET/api/orders
warehousecategorystatusmonth
Orders with full filtering
GET/api/orders/{id}Single order (404 if missing)
GET/api/demandDemand forecasts & trends
GET/api/backlogBacklog + computed has_purchase_order
GET/api/dashboard/summary
warehousecategorystatusmonth
Aggregated KPIs
GET/api/spending/summaryCost summary + YoY
GET/api/spending/monthlyMonthly spend breakdown
GET/api/spending/categoriesSpend by procurement category
GET/api/spending/transactionsRecent transactions
GET/api/reports/quarterlyQuarterly performance
GET/api/reports/monthly-trendsMonth-over-month trends
+
+
+
+
+ + +
+
+
+
Domain models
+

Pydantic schemas

+

The contract between tiers. Each response is validated against one of these before it leaves the server. + dashed fields are optional.

+
+ +
+
+

InventoryItem

+
+ idskunamecategorywarehousequantity_on_handreorder_pointunit_costlocationlast_updated +
+
+
+

Order

+
+ idorder_numbercustomeritems[]statusorder_dateexpected_deliverytotal_valueactual_deliverywarehousecategory +
+
+
+

DemandForecast

+
+ iditem_skuitem_namecurrent_demandforecasted_demandtrendperiod +
+
+
+

BacklogItem

+
+ idorder_iditem_skuitem_namequantity_neededquantity_availabledays_delayedpriorityhas_purchase_order +
+
+
+

PurchaseOrder

+
+ idbacklog_item_idsupplier_namequantityunit_costexpected_delivery_datestatuscreated_datenotes +
+
+
+

CreatePurchaseOrderRequest

+
+ backlog_item_idsupplier_namequantityunit_costexpected_delivery_datenotes +
+
+
+
+
+ + +
+
+
+
Notes & constraints
+

Things to know

+

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.
  • +
+
+
+ + +
+
+
+ Factory Inventory Management System + · + architecture reference + · + generated 2026-06-23 + · + frontend :3000 + · + API :8001/docs +
+
+
+ + + 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..ca975549 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 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() From 868c465aa9af4c2842a76271f361051882061eb7 Mon Sep 17 00:00:00 2001 From: Adrien Monte Date: Tue, 23 Jun 2026 11:57:05 +0100 Subject: [PATCH 2/2] fix(reports): i18n, global filters, currency, and convention cleanup Rewrites the Reports page to match the rest of the app: - i18n: all strings via t() with new reports.* keys (en/ja) + nav.reports; App.vue nav label now uses t('nav.reports'); month labels localized. - Global filters: Reports reads useFilters, passes them to the API, and reloads on change. Backend get_quarterly_reports / get_monthly_trends now accept warehouse/category/status/month and mirror /api/orders filtering. - Currency: use formatCurrency(amount, currentCurrency) (USD/JPY + conversion) instead of a hand-rolled USD-only formatter that crashed on undefined. - Composition API ( diff --git a/server/main.py b/server/main.py index ca975549..aa8e5c61 100644 --- a/server/main.py +++ b/server/main.py @@ -525,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: @@ -571,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