From 30d59c672ed7daa5b01566905dbd8f2a0e58d42d Mon Sep 17 00:00:00 2001 From: Pedro Varela Date: Wed, 29 Jul 2026 11:10:32 +0100 Subject: [PATCH 1/3] Add budget-driven Restocking tab with submitted order tracking Adds a Restocking view that turns the demand forecast into an actionable purchase order constrained by a user-set budget. Backend: - GET /api/restock/recommendations?budget=N returns a plan built by ranking forecast items on demand gap weighted by trend (increasing x1.5, stable x1.0, decreasing x0.5), then funding lines greedily. Funding is all-or-nothing per line because a partial fill would not close the gap. - POST /api/restock-orders accepts a plan and returns the created order; GET /api/restock-orders lists them newest first. Orders live in a module-level list, consistent with this demo's no-database design, so they reset on restart. - demand_forecasts.json gains unit_cost, lead_time_days and supplier, without which budget-based recommendation is not possible. Frontend: - New /restocking view with a debounced budget slider. A range input emits a value per step crossed, so the fetch waits for the drag to settle and a planPending flag blocks submitting a plan the slider has moved past. - Orders view gains a Submitted Orders section showing lead time and expected delivery, loaded unfiltered since restock orders carry no warehouse or category. - Full en/ja translations for all new strings. Also adds docs/architecture.html documenting the system, and a Claude GitHub Actions workflow guarded to trusted author associations so @claude cannot be triggered by arbitrary users on this public repo. Tests: 28 new cases in tests/backend/test_restocking.py; 68 total passing. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/claude.yml | 80 ++++ CLAUDE.md | 3 + client/src/App.vue | 3 + client/src/api.js | 16 + client/src/locales/en.js | 58 +++ client/src/locales/ja.js | 58 +++ client/src/main.js | 2 + client/src/views/Orders.vue | 88 +++- client/src/views/Restocking.vue | 489 ++++++++++++++++++++++ docs/architecture.html | 647 ++++++++++++++++++++++++++++++ server/data/demand_forecasts.json | 45 ++- server/main.py | 166 +++++++- tests/backend/test_restocking.py | 368 +++++++++++++++++ 13 files changed, 2011 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/claude.yml create mode 100644 client/src/views/Restocking.vue create mode 100644 docs/architecture.html create mode 100644 tests/backend/test_restocking.py diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 000000000..0fbcbf9b7 --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,80 @@ +name: Claude + +# Responds to @claude mentions in issues, issue comments, and PR review comments. +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + pull_request_review: + types: [submitted] + issues: + types: [opened, assigned] + +# Never run more than one Claude job per issue/PR at a time. Two concurrent runs on the +# same thread would post interleaved comments and race each other's pushes. +concurrency: + group: claude-${{ github.event.issue.number || github.event.pull_request.number }} + cancel-in-progress: false + +jobs: + claude: + # This repository is PUBLIC, so an unguarded @claude mention would let any passer-by + # spend API credits. Only run for users GitHub already trusts with the repo. + if: | + (github.event.comment.author_association == 'OWNER' || + github.event.comment.author_association == 'MEMBER' || + github.event.comment.author_association == 'COLLABORATOR' || + github.event.issue.author_association == 'OWNER' || + github.event.issue.author_association == 'MEMBER' || + github.event.issue.author_association == 'COLLABORATOR') && + (contains(github.event.comment.body, '@claude') || + contains(github.event.review.body, '@claude') || + contains(github.event.issue.body, '@claude') || + github.event.action == 'assigned') + runs-on: ubuntu-latest + timeout-minutes: 30 + + permissions: + contents: write # commit fixes to a branch + pull-requests: write # open PRs and comment + issues: write # comment on issues + id-token: write # OIDC token the action uses to authenticate + + steps: + - uses: actions/checkout@v4 + with: + # Full history so Claude can inspect prior commits, not just the tip. + fetch-depth: 0 + + # The backend runs on the system Python here rather than uv: uv is not installed on + # the runner, and requirements.txt is already flat and pinned enough to install directly. + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: pip + + # No npm cache: caching keys off a lockfile, and client/package-lock.json is gitignored + # on purpose (see the warning in CLAUDE.md), so there is nothing stable to key against. + - uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install backend dependencies + run: pip install -r server/requirements.txt pytest httpx + + # No lockfile is committed (client/package-lock.json is gitignored to keep local + # registry config out of this public repo), so `npm install` is correct here - `npm ci` + # would fail outright without one. + - name: Install frontend dependencies + working-directory: client + run: npm install + + - name: Run Claude + uses: anthropics/claude-code-action@v1 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + # Allow the test suite and a production build so Claude can verify its own changes + # before pushing. Everything else still requires explicit approval. + claude_args: | + --allowedTools "Bash(pytest tests/backend/*),Bash(npm run build),Bash(git diff:*),Bash(git log:*)" diff --git a/CLAUDE.md b/CLAUDE.md index 89c307d15..425c6a342 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,6 +69,9 @@ npm install && npm run dev - Data: `server/data/*.json` - Styles: `client/src/App.vue` +## Code Style +- Always document non-obvious logic changes with comments + ## Design System - Colors: Slate/gray (#0f172a, #64748b, #e2e8f0) - Status: green/blue/yellow/red diff --git a/client/src/App.vue b/client/src/App.vue index c2da05a5c..dd6849b1c 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 11cb9db70..51ede77a9 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -43,6 +43,22 @@ export const api = { return response.data }, + async getRestockRecommendations(budget) { + const params = new URLSearchParams({ budget: String(budget) }) + const response = await axios.get(`${API_BASE_URL}/restock/recommendations?${params.toString()}`) + return response.data + }, + + async submitRestockOrder(order) { + const response = await axios.post(`${API_BASE_URL}/restock-orders`, order) + return response.data + }, + + async getRestockOrders() { + const response = await axios.get(`${API_BASE_URL}/restock-orders`) + return response.data + }, + async getDashboardSummary(filters = {}) { const params = new URLSearchParams() if (filters.warehouse && filters.warehouse !== 'all') params.append('warehouse', filters.warehouse) diff --git a/client/src/locales/en.js b/client/src/locales/en.js index 03a58fe6e..5c9766b7c 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,62 @@ export default { status: 'Status', expectedDelivery: 'Expected Delivery', actualDelivery: 'Actual Delivery' + }, + submitted: { + title: 'Submitted Orders', + note: 'Restocking orders - not affected by the filters above', + submittedDate: 'Submitted', + leadTime: 'Lead Time', + leadTimeValue: '{days} days' + } + }, + + // Restocking + restocking: { + title: 'Restocking', + description: 'Set a budget and order the most urgent items from the demand forecast', + placeOrder: 'Place Order', + placingOrder: 'Placing order...', + daysSuffix: 'd', + budget: { + title: 'Available Budget', + totalNeed: 'Full demand coverage would cost {amount}' + }, + stats: { + allocated: 'Budget Allocated', + remaining: 'Budget Remaining', + itemsRecommended: 'Items Recommended', + leadTime: 'Longest Lead Time' + }, + recommendations: { + title: 'Recommended Restock', + empty: 'No items fit within this budget. Increase the budget to see recommendations.' + }, + skipped: { + title: 'Not Funded', + note: 'These items have rising or unmet demand but do not fit in the remaining budget.', + shortBy: 'Short By' + }, + submitted: { + heading: 'Order {orderNumber} submitted', + detail: '{count} items, {units} units, arriving in {days} days', + viewInOrders: 'View in Orders' + }, + table: { + sku: 'SKU', + itemName: 'Item Name', + supplier: 'Supplier', + trend: 'Trend', + demandGap: 'Demand Gap', + quantity: 'Qty', + unitCost: 'Unit Cost', + lineTotal: 'Line Total', + leadTime: 'Lead Time', + total: 'Total' + }, + errors: { + load: 'Failed to load restocking recommendations:', + submit: 'Failed to submit restocking order:' } }, @@ -204,6 +261,7 @@ export default { shipped: 'Shipped', processing: 'Processing', backordered: 'Backordered', + submitted: 'Submitted', inStock: 'In Stock', lowStock: 'Low Stock', adequate: 'Adequate' diff --git a/client/src/locales/ja.js b/client/src/locales/ja.js index db33223ac..aab0e8fc3 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,62 @@ export default { status: 'ステータス', expectedDelivery: '予定配達日', actualDelivery: '実際の配達日' + }, + submitted: { + title: '発注済みオーダー', + note: '補充発注 - 上記のフィルターは適用されません', + submittedDate: '発注日', + leadTime: 'リードタイム', + leadTimeValue: '{days}日' + } + }, + + // Restocking + restocking: { + title: '補充発注', + description: '予算を設定し、需要予測から最も緊急度の高い品目を発注します', + placeOrder: '発注する', + placingOrder: '発注中...', + daysSuffix: '日', + budget: { + title: '利用可能予算', + totalNeed: '需要を全て満たすには{amount}が必要です' + }, + stats: { + allocated: '割当済み予算', + remaining: '残り予算', + itemsRecommended: '推奨品目数', + leadTime: '最長リードタイム' + }, + recommendations: { + title: '推奨補充品目', + empty: 'この予算内に収まる品目はありません。予算を増やしてください。' + }, + skipped: { + title: '予算外', + note: 'これらの品目は需要が増加または未充足ですが、残り予算に収まりません。', + shortBy: '不足額' + }, + submitted: { + heading: 'オーダー{orderNumber}を発注しました', + detail: '{count}品目、{units}個、{days}日後に到着予定', + viewInOrders: '注文タブで表示' + }, + table: { + sku: 'SKU', + itemName: '品目名', + supplier: 'サプライヤー', + trend: '傾向', + demandGap: '需要ギャップ', + quantity: '数量', + unitCost: '単価', + lineTotal: '小計', + leadTime: 'リードタイム', + total: '合計' + }, + errors: { + load: '補充推奨の読み込みに失敗しました:', + submit: '補充発注の送信に失敗しました:' } }, @@ -204,6 +261,7 @@ export default { shipped: '出荷済み', processing: '処理中', backordered: 'バックオーダー', + submitted: '発注済み', inStock: '在庫あり', lowStock: '在庫僅少', adequate: '適量' diff --git a/client/src/main.js b/client/src/main.js index 477c2d966..cea203940 100644 --- a/client/src/main.js +++ b/client/src/main.js @@ -5,6 +5,7 @@ import Dashboard from './views/Dashboard.vue' import Inventory from './views/Inventory.vue' import Orders from './views/Orders.vue' import Demand from './views/Demand.vue' +import Restocking from './views/Restocking.vue' import Spending from './views/Spending.vue' import Reports from './views/Reports.vue' @@ -15,6 +16,7 @@ const router = createRouter({ { path: '/inventory', component: Inventory }, { path: '/orders', component: Orders }, { path: '/demand', component: Demand }, + { path: '/restocking', component: Restocking }, { path: '/spending', component: Spending }, { path: '/reports', component: Reports } ] diff --git a/client/src/views/Orders.vue b/client/src/views/Orders.vue index 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/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. +

+
+
+ +
+ Generated from source at commit 27b0724 + uncommitted Restocking work · branch new_features · docs/architecture.html +
+ +
+ + 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..19fa9f32a 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, 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"] + ) From ea09cd2425bcade436ff6bee5600c435b50fddf2 Mon Sep 17 00:00:00 2001 From: Pedro Varela Date: Wed, 29 Jul 2026 11:51:56 +0100 Subject: [PATCH 2/3] Fix Reports page defects and translate Reports and Backlog Reports.vue was the only view still on the Options API, so it had none of the shared infrastructure the other views rely on. Porting it to the Composition API resolved most of the following. Reports: - Translate every string; the page rendered English in all locales, including its hardcoded nav label in App.vue. - Honour the global filter bar. Both report endpoints previously accepted no filter params, so they aggregated all 250 orders while /api/dashboard/summary honoured the filters - the same metric could differ between the two screens. Both endpoints now take warehouse, category, status and month. - Remove 13 console.log calls. Three sat in per-cell helpers (formatNumber, formatMonth, getBarHeight), so a single render logged hundreds of lines. - Route requests through api.js instead of importing axios and hardcoding the backend URL. - Use the shared currency util. Amounts were prefixed with a literal "$" in every locale, so JPY conversion never applied. - Drop the hand-rolled formatNumber, which reimplemented toLocaleString with a digit loop that emitted "-,500.00" for negatives and truncated decimals instead of rounding. - Key v-for on quarter/month rather than array index. - Compute maxRevenue once instead of rescanning every month per bar rendered. - Derive summary stats in a computed; they were stored in refs and refreshed imperatively, so they could drift from the data they summarise. - Clear the error ref on reload, or a successful retry still showed the old failure. Fetch the two reports concurrently. - Validate formatMonth input, which previously rendered "undefined 2025", and localise month and quarter labels. - Delete ~90 lines of CSS duplicating global App.vue styles. Backlog: - Translate every string, and use translated priority labels rather than the raw "high"/"medium"/"low" values. - Distinguish "1 day" from "3 days"; the custom t() has no plural support, so singular gets its own key. - Add the missing /backlog route. The view existed but was unreachable, and Vite was not compiling it. No nav entry yet - reachable by URL only. Tests: 30 new cases in tests/backend/test_reports.py, including cross-checks that the two reports, /api/orders and /api/dashboard/summary now agree on identical filters. 98 total passing. Co-Authored-By: Claude Opus 5 (1M context) --- client/src/App.vue | 2 +- client/src/api.js | 24 ++ client/src/locales/en.js | 76 ++++++ client/src/locales/ja.js | 76 ++++++ client/src/main.js | 6 +- client/src/views/Backlog.vue | 86 ++++-- client/src/views/Reports.vue | 485 +++++++++++++--------------------- server/main.py | 31 ++- tests/backend/test_reports.py | 329 +++++++++++++++++++++++ 9 files changed, 787 insertions(+), 328 deletions(-) create mode 100644 tests/backend/test_reports.py diff --git a/client/src/App.vue b/client/src/App.vue index dd6849b1c..19511597f 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -26,7 +26,7 @@ {{ t('nav.restocking') }} - Reports + {{ t('nav.reports') }} diff --git a/client/src/api.js b/client/src/api.js index 51ede77a9..3ba53349a 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -70,6 +70,30 @@ export const api = { return response.data }, + // Both report endpoints accept the same four global filters as the dashboard, so the + // Reports screen cannot disagree with Overview on the same metric. + async getQuarterlyReports(filters = {}) { + const params = new URLSearchParams() + if (filters.warehouse && filters.warehouse !== 'all') params.append('warehouse', filters.warehouse) + if (filters.category && filters.category !== 'all') params.append('category', filters.category) + if (filters.status && filters.status !== 'all') params.append('status', filters.status) + if (filters.month && filters.month !== 'all') params.append('month', filters.month) + + const response = await axios.get(`${API_BASE_URL}/reports/quarterly?${params.toString()}`) + return response.data + }, + + async getMonthlyTrends(filters = {}) { + const params = new URLSearchParams() + if (filters.warehouse && filters.warehouse !== 'all') params.append('warehouse', filters.warehouse) + if (filters.category && filters.category !== 'all') params.append('category', filters.category) + if (filters.status && filters.status !== 'all') params.append('status', filters.status) + if (filters.month && filters.month !== 'all') params.append('month', filters.month) + + const response = await axios.get(`${API_BASE_URL}/reports/monthly-trends?${params.toString()}`) + return response.data + }, + async getSpendingSummary() { const response = await axios.get(`${API_BASE_URL}/spending/summary`) return response.data diff --git a/client/src/locales/en.js b/client/src/locales/en.js index 5c9766b7c..a88a30842 100644 --- a/client/src/locales/en.js +++ b/client/src/locales/en.js @@ -7,6 +7,8 @@ export default { finance: 'Finance', demandForecast: 'Demand Forecast', restocking: 'Restocking', + reports: 'Reports', + backlog: 'Backlog', companyName: 'Catalyst Components', subtitle: 'Inventory Management System' }, @@ -245,6 +247,80 @@ export default { } }, + // Reports + reports: { + title: 'Performance Reports', + description: 'View quarterly performance metrics and monthly trends', + // Backend returns quarters as "Q1-2025"; these compose a locale-correct label. + quarterLabel: '{quarter} {year}', + quarters: { + q1: 'Q1', + q2: 'Q2', + q3: 'Q3', + q4: 'Q4' + }, + monthLabel: '{month} {year}', + notAvailable: 'N/A', + quarterly: { + title: 'Quarterly Performance', + quarter: 'Quarter', + totalOrders: 'Total Orders', + totalRevenue: 'Total Revenue', + avgOrderValue: 'Avg Order Value', + fulfillmentRate: 'Fulfillment Rate' + }, + monthlyChart: { + title: 'Monthly Revenue Trend', + ariaLabel: 'Bar chart of monthly revenue' + }, + monthOverMonth: { + title: 'Month-over-Month Analysis', + month: 'Month', + orders: 'Orders', + revenue: 'Revenue', + change: 'Change', + growthRate: 'Growth Rate' + }, + stats: { + totalRevenue: 'Total Revenue', + avgMonthlyRevenue: 'Avg Monthly Revenue', + totalOrders: 'Total Orders', + bestQuarter: 'Best Performing Quarter' + }, + errors: { + load: 'Failed to load reports:' + } + }, + + // Backlog + backlog: { + title: 'Backlog Management', + description: 'Track and resolve inventory shortages', + itemsTitle: 'Backlog Items', + empty: 'No backlog items - all orders can be fulfilled!', + unitsShort: '{count} units short', + // The custom t() has no plural support, so singular gets its own key. + dayValue: '{days} day', + daysValue: '{days} days', + highPriority: 'High Priority', + mediumPriority: 'Medium Priority', + lowPriority: 'Low Priority', + totalItems: 'Total Backlog Items', + table: { + orderId: 'Order ID', + sku: 'SKU', + itemName: 'Item Name', + quantityNeeded: 'Quantity Needed', + quantityAvailable: 'Quantity Available', + shortage: 'Shortage', + daysDelayed: 'Days Delayed', + priority: 'Priority' + }, + errors: { + load: 'Failed to load backlog:' + } + }, + // Filters filters: { timePeriod: 'Time Period', diff --git a/client/src/locales/ja.js b/client/src/locales/ja.js index aab0e8fc3..006d005b5 100644 --- a/client/src/locales/ja.js +++ b/client/src/locales/ja.js @@ -7,6 +7,8 @@ export default { finance: '財務', demandForecast: '需要予測', restocking: '補充発注', + reports: 'レポート', + backlog: 'バックログ', companyName: '触媒コンポーネンツ', subtitle: '在庫管理システム' }, @@ -245,6 +247,80 @@ export default { } }, + // Reports + reports: { + title: 'パフォーマンスレポート', + description: '四半期業績指標と月次トレンドを表示', + // Japanese puts the year first, so the placeholder order differs from English. + quarterLabel: '{year}年{quarter}', + quarters: { + q1: '第1四半期', + q2: '第2四半期', + q3: '第3四半期', + q4: '第4四半期' + }, + monthLabel: '{year}年{month}', + notAvailable: '該当なし', + quarterly: { + title: '四半期業績', + quarter: '四半期', + totalOrders: '総注文数', + totalRevenue: '総売上', + avgOrderValue: '平均注文額', + fulfillmentRate: '履行率' + }, + monthlyChart: { + title: '月次売上推移', + ariaLabel: '月次売上の棒グラフ' + }, + monthOverMonth: { + title: '前月比分析', + month: '月', + orders: '注文数', + revenue: '売上', + change: '増減', + growthRate: '成長率' + }, + stats: { + totalRevenue: '総売上', + avgMonthlyRevenue: '月平均売上', + totalOrders: '総注文数', + bestQuarter: '最高業績四半期' + }, + errors: { + load: 'レポートの読み込みに失敗しました:' + } + }, + + // Backlog + backlog: { + title: 'バックログ管理', + description: '在庫不足の追跡と解決', + itemsTitle: 'バックログ項目', + empty: 'バックログ項目はありません - すべての注文を履行できます', + unitsShort: '{count}個不足', + // Japanese does not inflect for number; both keys exist to match the en shape. + dayValue: '{days}日', + daysValue: '{days}日', + highPriority: '高優先度', + mediumPriority: '中優先度', + lowPriority: '低優先度', + totalItems: 'バックログ項目合計', + table: { + orderId: '注文ID', + sku: 'SKU', + itemName: '品目名', + quantityNeeded: '必要数量', + quantityAvailable: '利用可能数量', + shortage: '不足数', + daysDelayed: '遅延日数', + priority: '優先度' + }, + errors: { + load: 'バックログの読み込みに失敗しました:' + } + }, + // Filters filters: { timePeriod: '期間', diff --git a/client/src/main.js b/client/src/main.js index cea203940..42b7af15e 100644 --- a/client/src/main.js +++ b/client/src/main.js @@ -8,6 +8,7 @@ import Demand from './views/Demand.vue' import Restocking from './views/Restocking.vue' import Spending from './views/Spending.vue' import Reports from './views/Reports.vue' +import Backlog from './views/Backlog.vue' const router = createRouter({ history: createWebHistory(), @@ -18,7 +19,10 @@ const router = createRouter({ { path: '/demand', component: Demand }, { path: '/restocking', component: Restocking }, { path: '/spending', component: Spending }, - { path: '/reports', component: Reports } + { path: '/reports', component: Reports }, + // Backlog was previously built but never routed, so /backlog resolved to nothing. + // No nav entry yet - the page is reachable by URL only. + { path: '/backlog', component: Backlog } ] }) diff --git a/client/src/views/Backlog.vue b/client/src/views/Backlog.vue index bb3bac21d..54a225c55 100644 --- a/client/src/views/Backlog.vue +++ b/client/src/views/Backlog.vue @@ -1,75 +1,74 @@