From 72f65116ad307effc4f90a1a39ab23234ace8c64 Mon Sep 17 00:00:00 2001 From: David Godoy Date: Mon, 27 Jul 2026 11:21:20 +0100 Subject: [PATCH] Restocking view, tasks API, i18n updates, architecture doc Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 2 + client/src/App.vue | 3 + client/src/api.js | 10 + client/src/locales/en.js | 38 +- client/src/locales/ja.js | 38 +- client/src/main.js | 4 +- client/src/views/Orders.vue | 107 ++- client/src/views/Restocking.vue | 445 +++++++++++ docs/architecture.html | 1055 ++++++++++++++++++++++++++ server/data/demand_forecasts.json | 80 +- server/main.py | 80 ++ tests/backend/test_misc_endpoints.py | 21 +- tests/backend/test_restocking.py | 248 ++++++ 13 files changed, 2077 insertions(+), 54 deletions(-) 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/CLAUDE.md b/CLAUDE.md index 89c307d15..5ae43bfb5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,3 +74,5 @@ npm install && npm run dev - Status: green/blue/yellow/red - Charts: Custom SVG, CSS Grid for layouts - No emojis in UI + +Always document non-obvious logic changes with comments \ No newline at end of file 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..666f1d515 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -102,5 +102,15 @@ export const api = { async getPurchaseOrderByBacklogItem(backlogItemId) { const response = await axios.get(`${API_BASE_URL}/purchase-orders/${backlogItemId}`) return response.data + }, + + async getRestockingOrders() { + const response = await axios.get(`${API_BASE_URL}/restocking-orders`) + return response.data + }, + + async createRestockingOrder(orderData) { + const response = await axios.post(`${API_BASE_URL}/restocking-orders`, orderData) + return response.data } } diff --git a/client/src/locales/en.js b/client/src/locales/en.js index 03a58fe6e..c4d7163ef 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' }, @@ -106,6 +107,10 @@ export default { title: 'Orders', description: 'View and manage customer orders', allOrders: 'All Orders', + submittedOrders: 'Submitted Orders', + noSubmittedOrders: 'No restocking orders submitted yet.', + leadTime: 'Lead Time', + leadTimeDays: '{count} days', totalOrders: 'Total Orders', totalRevenue: 'Total Revenue', avgOrderValue: 'Avg Order Value', @@ -188,6 +193,36 @@ export default { } }, + // Restocking + restocking: { + title: 'Restocking', + description: 'Set a budget and order the items your forecast says you are short of', + budget: 'Available Budget', + budgetHint: 'Drag to set how much you can spend on restocking', + recommended: 'Recommended Restock', + itemsRecommended: 'Items Recommended', + unitsRecommended: 'Units Recommended', + totalCost: 'Total Cost', + budgetRemaining: 'Budget Remaining', + placeOrder: 'Place Order', + placingOrder: 'Placing Order...', + orderPlaced: 'Placed {count} order(s) successfully. See the Orders tab.', + orderFailed: 'Failed to place order', + noRecommendations: 'No restocking needed for the current budget and filters.', + noShortfall: 'Every forecast item is fully covered by current stock.', + shortBy: 'Short {count} units', + ordersAcross: 'Will create {count} order(s), one per warehouse', + table: { + sku: 'SKU', + itemName: 'Item Name', + warehouse: 'Warehouse', + shortfall: 'Shortfall', + quantity: 'Order Qty', + unitCost: 'Unit Cost', + lineTotal: 'Line Total' + } + }, + // Filters filters: { timePeriod: 'Time Period', @@ -206,7 +241,8 @@ export default { backordered: 'Backordered', inStock: 'In Stock', lowStock: 'Low Stock', - adequate: 'Adequate' + adequate: 'Adequate', + submitted: 'Submitted' }, // Trends diff --git a/client/src/locales/ja.js b/client/src/locales/ja.js index db33223ac..c638fd737 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: '在庫管理システム' }, @@ -106,6 +107,10 @@ export default { title: '注文', description: '顧客注文の表示と管理', allOrders: 'すべての注文', + submittedOrders: '送信済み注文', + noSubmittedOrders: '送信済みの補充注文はまだありません。', + leadTime: 'リードタイム', + leadTimeDays: '{count}日', totalOrders: '総注文数', totalRevenue: '総収益', avgOrderValue: '平均注文額', @@ -188,6 +193,36 @@ export default { } }, + // Restocking + restocking: { + title: '補充', + description: '予算を設定し、予測で不足している品目を発注します', + budget: '利用可能予算', + budgetHint: 'ドラッグして補充に使える金額を設定します', + recommended: '推奨補充', + itemsRecommended: '推奨品目数', + unitsRecommended: '推奨数量', + totalCost: '合計金額', + budgetRemaining: '残り予算', + placeOrder: '発注する', + placingOrder: '発注中...', + orderPlaced: '{count}件の注文を送信しました。注文タブをご確認ください。', + orderFailed: '発注に失敗しました', + noRecommendations: '現在の予算とフィルターでは補充は不要です。', + noShortfall: 'すべての予測品目は現在の在庫で充足しています。', + shortBy: '{count}個不足', + ordersAcross: '倉庫ごとに{count}件の注文を作成します', + table: { + sku: 'SKU', + itemName: '品目名', + warehouse: '倉庫', + shortfall: '不足数', + quantity: '発注数量', + unitCost: '単価', + lineTotal: '小計' + } + }, + // Filters filters: { timePeriod: '期間', @@ -206,7 +241,8 @@ export default { backordered: 'バックオーダー', inStock: '在庫あり', lowStock: '在庫僅少', - adequate: '適量' + adequate: '適量', + submitted: '送信済み' }, // Trends diff --git a/client/src/main.js b/client/src/main.js index 477c2d966..8884eea63 100644 --- a/client/src/main.js +++ b/client/src/main.js @@ -7,6 +7,7 @@ import Orders from './views/Orders.vue' import Demand from './views/Demand.vue' import Spending from './views/Spending.vue' import Reports from './views/Reports.vue' +import Restocking from './views/Restocking.vue' const router = createRouter({ history: createWebHistory(), @@ -16,7 +17,8 @@ const router = createRouter({ { path: '/orders', component: Orders }, { path: '/demand', component: Demand }, { path: '/spending', component: Spending }, - { path: '/reports', component: Reports } + { path: '/reports', component: Reports }, + { path: '/restocking', component: Restocking } ] }) diff --git a/client/src/views/Orders.vue b/client/src/views/Orders.vue index 7413f6e66..ad8faa561 100644 --- a/client/src/views/Orders.vue +++ b/client/src/views/Orders.vue @@ -27,6 +27,59 @@ +
+
+

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

+
+
+ {{ t('orders.noSubmittedOrders') }} +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+

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

@@ -95,6 +148,7 @@ export default { const loading = ref(true) const error = ref(null) const orders = ref([]) + const submittedOrders = ref([]) // Use shared filters const { @@ -109,7 +163,15 @@ export default { try { loading.value = true const filters = getCurrentFilters() - const fetchedOrders = await api.getOrders(filters) + + // Restocking orders are a separate list and are NOT affected by the + // global filter bar - they always show in full, regardless of the + // selected period/warehouse/category/status. The call is wrapped in + // its own catch so a failure there doesn't blank the main orders table. + const [fetchedOrders, fetchedSubmitted] = await Promise.all([ + api.getOrders(filters), + api.getRestockingOrders().catch(() => []) + ]) // Sort orders by order_date (earliest first) orders.value = fetchedOrders.sort((a, b) => { @@ -117,6 +179,7 @@ export default { const dateB = new Date(b.order_date) return dateA - dateB }) + submittedOrders.value = fetchedSubmitted } catch (err) { error.value = 'Failed to load orders: ' + err.message } finally { @@ -160,6 +223,7 @@ export default { loading, error, orders, + submittedOrders, getOrdersByStatus, getOrderStatusClass, formatDate, @@ -276,4 +340,45 @@ export default { font-size: 0.813rem; color: #64748b; } + +/* Submitted orders empty state */ +.empty-state { + padding: 3rem; + text-align: center; + color: #64748b; +} + +/* Submitted orders table - fixed layout with its own column widths */ +.submitted-orders-table { + table-layout: fixed; + width: 100%; +} + +.scol-order-number { + width: 130px; +} + +.scol-warehouse { + width: 120px; +} + +.scol-items { + width: 160px; +} + +.scol-status { + width: 110px; +} + +.scol-date { + width: 140px; +} + +.scol-lead-time { + width: 110px; +} + +.scol-value { + width: 120px; +} diff --git a/client/src/views/Restocking.vue b/client/src/views/Restocking.vue new file mode 100644 index 000000000..61979abbc --- /dev/null +++ b/client/src/views/Restocking.vue @@ -0,0 +1,445 @@ + + + + + diff --git a/docs/architecture.html b/docs/architecture.html new file mode 100644 index 000000000..26c8f9b95 --- /dev/null +++ b/docs/architecture.html @@ -0,0 +1,1055 @@ + + + + + +Architecture — Factory Inventory Management System + + + + +
+
+

Architecture Reference

+

Factory Inventory Management System

+

+ A full-stack demo application: a Vue 3 single-page app backed by a read-only Python + FastAPI service that serves static JSON fixtures held in process memory. + Built as a Claude Code workshop project. +

+
+ Frontend Vue 3 · port 3000 + Backend FastAPI · port 8001 + Persistence None — in-memory fixtures + Auth None — mocked client-side + Repo beck-source/inventory-management +
+
+
+ +
+ + +
+

System Architecture

+

+ Three tiers, one direction. The browser owns all state and rendering; the API is a stateless + filter over lists loaded once at import; the data tier is seven JSON files read at boot and + never written back. +

+ +
+
+ Presentation + Vue 3 SPA + Vite dev server · localhost:3000 +
+
+
+
App.vue
+
Shell: sticky nav, global FilterBar, router-view, app-level modals. Owns all global CSS (unscoped).
+
+
+
6 routed views
+
Dashboard, Inventory, Orders, Demand, Spending, Reports. Statically imported, no lazy loading.
+
+
+
9 components
+
FilterBar, ProfileMenu, LanguageSwitcher and six detail modals (props down, events up).
+
+
+
3 composables
+
useFilters, useI18n, useAuth — module-scope refs as singleton stores. No Pinia or Vuex.
+
+
+
api.js
+
Single axios module. No instance, no interceptors, no timeout or retry.
+
+
+
vue-router 4
+
Web history mode. No guards, no named routes, no catch-all.
+
+
+
+ +
+ GET · JSON over HTTP · absolute URL http://localhost:8001/api · no Vite proxy +
+
+ +
+ +
+
+ Application + FastAPI service + Uvicorn · 0.0.0.0:8001 · docs at /docs +
+
+
+
main.py
+
Entire app in one file: 14 routes, 6 models, filter helpers. No routers, no services, no DI.
+
+
+
14 GET routes
+
Read-only surface. Zero POST, PUT, PATCH or DELETE routes exist.
+
+
+
Filter helpers
+
apply_filters() for warehouse, category and status; filter_by_month() plus QUARTER_MAP for time.
+
+
+
6 Pydantic models
+
Validate 7 of 14 responses. Two models are declared but never referenced by any route.
+
+
+
CORS middleware
+
Fully permissive: allow_origins=["*"] with allow_credentials=True.
+
+
+
No middleware stack
+
No auth, rate limiting, caching, logging or error handling beyond FastAPI defaults.
+
+
+
+ +
+ module-level globals · read-only · no ORM, no query layer +
+
+ +
+ +
+
+ Data + In-memory fixtures + 7 JSON files · server/data/ +
+
+
+
mock_data.py
+
Eagerly json.loads every file at import time into 9 module globals.
+
+
+
Process lifetime
+
Data lives as long as the process. Mutations would not persist — and nothing mutates.
+
+
+
No database
+
No schema, migrations, connection pool or transactions anywhere in the project.
+
+
+
+ +
+
Reading the diagram
+

+ Every arrow points one way. There is no write path, no cache, no background job and no + inter-service communication. A restart returns the system to exactly its initial state, + which is what makes it safe as a demo and unsuitable as a foundation for production work. +

+
+
+ + +
+

Tech Stack

+

+ Versions below are the resolved ones from client/package.json and + server/uv.lock, not the looser ranges declared in the manifests. +

+ +
+
+

Frontend

+

client/ · npm

+
+
Vue
^3.4.21
+
vue-router
^4.3.0
+
axios
^1.6.7
+
Vite
^5.2.0
+
@vitejs/plugin-vue
^5.0.4
+
+

+ Deliberately minimal: no TypeScript, linter, CSS framework, chart library or state + library. Charts are hand-rolled SVG and CSS Grid; the design system is literal hex + values in App.vue rather than CSS custom properties. +

+
+ +
+

Backend

+

server/ · uv

+
+
Python (declared)
>=3.11
+
FastAPI
0.140.0
+
Starlette
1.3.1
+
Uvicorn
0.51.0
+
Pydantic
2.13.4
+
anyio
4.14.2
+
+

+ Dependencies are declared twice — in pyproject.toml and a legacy + requirements.txt. All route handlers are sync def, so FastAPI + runs them in its threadpool. +

+
+ +
+

Tooling

+

tests/ · scripts/ · .claude/

+
+
pytest
9.1.1
+
pytest-asyncio
1.4.0
+
pytest-cov
7.1.0
+
httpx
0.28.1
+
Backend tests
40 in 3 files
+
Frontend tests
none
+
+

+ No CI, no Dockerfile and no deployment config exist. scripts/start.sh and + stop.sh manage both servers locally and are macOS/Linux only. +

+
+
+
+ + +
+

Data Flow

+

+ The global filter system is the application's central mechanism: four controls in one bar + drive every view on every page. Following one filter change end to end explains most of the + codebase. +

+ +
    +
  1. +
    A control changes in the filter bar
    +

    + FilterBar.vue renders four selects — Time Period, Location, Category, + Order Status — each v-model-bound straight to a shared ref. All four + default to 'all'. +

    +
  2. +
  3. +
    The refs are module-scope singletons
    +

    + In composables/useFilters.js the four ref()s are declared + outside the exported function. Every importer receives the same instances — + that single detail is the entire global store. State is not persisted and not synced to + the URL, so a page reload resets all filters. +

    +
  4. +
  5. +
    Each view watches and refetches
    +

    + Views watch all four refs and call loadData() on any change. + There is no debounce and no request cancellation, so rapid changes issue overlapping + requests. +

    +
  6. +
  7. +
    UI names are mapped to API names
    +

    + getCurrentFilters() renames selectedLocation to + warehouse, and adds month only when the period is not + 'all'. +

    +
  8. +
  9. +
    The query string is built and sent
    +

    + api.js assembles URLSearchParams, omitting any value equal to + 'all', then issues an axios GET against the hardcoded base URL. +

    +
  10. +
  11. +
    The server filters lists in memory
    +

    + apply_filters() matches warehouse exactly and category and status + case-insensitively. filter_by_month() matches against + order_date and accepts either a direct YYYY-MM prefix or a + quarter key, which QUARTER_MAP expands into its member months. +

    +
  12. +
  13. +
    Pydantic validates — on half the routes
    +

    + Seven routes declare a response_model and are validated and coerced. The + other seven return bare dicts and lists, so their shapes are enforced nowhere. +

    +
  14. +
  15. +
    Raw data lands in refs, derived data in computeds
    +

    + Responses are stored unmodified in view-local refs such as allOrders and + inventoryItems. Every total, percentage and chart series is a + computed() over those refs — the convention that keeps derived values + from drifting out of sync. +

    +
  16. +
+ +

Which filters each endpoint actually honours

+

+ The filter bar is global, but most endpoints ignore most of it. Only two routes accept the + full set. +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
EndpointWarehouseCategoryStatusMonthNote
/api/ordersyesyesyesyesFull filter support
/api/dashboard/summaryyesyesyesyesStatus and month apply to orders only
/api/inventoryyesyesInventory has no time dimension
/api/demandReturns the full list
/api/backlogReturns the full list
/api/spending/*Pre-aggregated fixtures
/api/reports/*Aggregates all 250 orders every call
+
+ +
+
Consequence worth knowing
+

+ Because the filter bar is rendered globally in App.vue but most endpoints + ignore it, changing a filter on the Demand, Spending or Reports pages appears to do + nothing. That is the current design, not a rendering bug. +

+
+
+ + +
+

Reference — API Surface

+

+ All 14 routes defined in server/main.py. Every one is a GET; the service has no + write surface. Routes marked untyped return bare dicts or + lists with no schema validation. +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodPathParametersResponsePurpose
GET/untypedService name and version
GET/api/inventorywarehouse, categoryList[InventoryItem]Stock levels per SKU
GET/api/inventory/{item_id}path paramInventoryItemSingle item; 404 if absent
GET/api/orderswarehouse, category, status, monthList[Order]Customer orders
GET/api/orders/{order_id}path paramOrderSingle order; 404 if absent
GET/api/demandList[DemandForecast]Forecast vs current demand
GET/api/backlogList[BacklogItem]Shortages; injects has_purchase_order
GET/api/dashboard/summarywarehouse, category, status, monthuntypedFive headline metrics
GET/api/spending/summaryuntypedSpending totals
GET/api/spending/monthlyuntyped12 months by cost type
GET/api/spending/categoriesuntyped4 categories with share
GET/api/spending/transactionsuntyped56 recent transactions
GET/api/reports/quarterlyuntypedPer-quarter revenue and fulfilment rate
GET/api/reports/monthly-trendsuntypedPer-month orders, revenue, deliveries
+
+ +

Client methods with no server route

+

+ client/src/api.js exports 17 methods. Six of them call endpoints that do not + exist in server/main.py and will return 404. +

+
+ + + + + + + + + + + + + + + + + + + +
Client methodCallServer routeEffect
getTasks()GET /api/tasksmissingCalled on mount in App.vue; falls back to mock tasks
createTask()POST /api/tasksmissingTask creation never persists
deleteTask()DELETE /api/tasks/{id}missingDeletion never persists
toggleTask()PATCH /api/tasks/{id}missingToggle never persists
createPurchaseOrder()POST /api/purchase-ordersmissingNo caller — dead code
getPurchaseOrderByBacklogItem()GET /api/purchase-orders/{id}missingNo caller — dead code
+
+

+ Two further methods, getInventoryItem() and getOrder(), map to real + routes but have no callers. Conversely, Reports.vue bypasses + api.js entirely and calls both /api/reports/* routes with axios + directly. +

+
+ + +
+

Reference — Data Model

+

+ Seven fixture files in server/data/, loaded once into nine module globals. + Counts below are the actual record counts in the checked-in files. +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileEntityRecordsKey fields
inventory.jsonStock item / SKU32sku, category, warehouse, quantity_on_hand, reorder_point, unit_cost
orders.jsonCustomer order250order_number, customer, items[], status, warehouse, category, order_date, total_value
demand_forecasts.jsonDemand forecast9item_sku, current_demand, forecasted_demand, trend, period
backlog_items.jsonShortage4order_id, item_sku, quantity_needed, quantity_available, days_delayed, priority
transactions.jsonTransaction56date, description, category, warehouse, amount, vendor, type
spending.jsonSpending aggregates3 sectionsspending_summary, monthly_spending (12), category_spending (4)
purchase_orders.jsonPurchase order0Empty array — see Known Gaps
+
+ +

Domain vocabulary

+
+ + + + + + + + + + +
DimensionValues
WarehousesSan Francisco · London · Tokyo
CategoriesCircuit Boards · Sensors · Actuators · Controllers · Power Supplies
Order statusesDelivered · Shipped · Processing · Backordered
Date span2025-01-02 to 2025-12-31 (all 250 orders fall in calendar 2025)
Spending categoriesRaw Materials · Components · Equipment · Consumables
Localesen, ja — hand-rolled i18n; JPY conversion hardcoded at 150
+
+ +
+
Note on categories
+

+ Inventory and orders share the same five categories, so cross-filtering works. Spending + uses an entirely separate four-value vocabulary that does not intersect — which is + why spending cannot be filtered by the global category control. +

+
+
+ + +
+

Reference — Repository Map

+

Where each concept lives.

+
client/                        Vue 3 SPA
+  index.html                  Vite entry, mounts #app
+  vite.config.js              dev server on :3000 (no proxy)
+  src/
+    main.js                   createApp + router, 6 routes
+    App.vue                   shell, global CSS, task state
+    api.js                    axios client, 17 methods
+    views/                    7 files: 6 routed + Backlog.vue (dead)
+    components/               FilterBar, ProfileMenu, 6 modals
+    composables/              useFilters, useI18n, useAuth
+    locales/                  en.js, ja.js translation maps
+    utils/                    currency.js formatting + conversion
+
+server/                        FastAPI service
+  main.py                     routes, models, filters — the whole app
+  mock_data.py                loads fixtures at import
+  generate_data.py            standalone generator (stale vs fixtures)
+  pyproject.toml / uv.lock    uv-managed dependencies
+  data/                       7 JSON fixtures
+
+tests/                         pytest + FastAPI TestClient
+  pytest.ini                  testpaths = backend
+  backend/                    conftest.py + 3 test files, 40 tests
+
+scripts/                       start.sh / stop.sh (macOS + Linux only)
+docs/                          screenshot + this page
+.claude/                       agents, commands, skills, hooks, MCP config
+ +

Running it

+
+ + + + + + + + + + +
TaskCommand
Start both servers./scripts/start.sh
Backend onlycd server && uv run python main.py
Frontend onlycd client && npm install && npm run dev
Backend testscd tests && uv run pytest backend/ -v
Production buildcd client && npm run build
Interactive API docshttp://localhost:8001/docs
+
+
+ + +
+

Known Gaps

+

+ Verified against the source, not inferred. These materially affect how the architecture above + should be read — several parts of the system are modelled but not wired up. Each entry + cites its location so it stays checkable as the code changes. +

+ +
+
+ Six client methods call routes that do not exist + broken +
+

+ api.js defines four task methods against /api/tasks and two + purchase-order methods against /api/purchase-orders. The server defines + neither. App.vue calls getTasks() in onMounted, so + the request 404s and the task UI silently runs on the mock array in useAuth.js + — task edits look like they work but vanish on reload. +

+ client/src/api.js:78-105 · server/main.py (14 routes, all GET) +
+ +
+
+ Dashboard renders a component that does not exist + broken +
+

+ Dashboard.vue renders <PurchaseOrderModal> with props and + handlers, but the component is never imported, never registered, and no such file exists + anywhere in the repo. Vue logs an unresolved-component warning and renders nothing. The + supporting state and handlers are all present and unreachable. +

+ client/src/views/Dashboard.vue:289 +
+ +
+
+ The purchase-order feature is modelled but absent end to end + broken +
+

+ PurchaseOrder and CreatePurchaseOrderRequest are defined in + main.py and referenced by no route. purchase_orders.json is an + empty array, so has_purchase_order on /api/backlog is always + false. Combined with the two gaps above, the feature exists as scaffolding at + every layer and functions at none. +

+ server/main.py:104-121 · server/data/purchase_orders.json +
+ +
+
+ Reports.vue diverges from every codebase convention + inconsistent +
+

+ Alone among the views it uses the Options API rather than Composition, calls axios directly + instead of going through api.js, has no i18n coverage, and ignores the global + filter bar entirely. It also carries leftover console.log statements. +

+ client/src/views/Reports.vue +
+ +
+
+ Backlog.vue is unreachable + dead code +
+

+ The view is not routed and not imported anywhere. Its functionality is duplicated inside the + Dashboard's Inventory Shortages card, which is the copy users actually see. +

+ client/src/views/Backlog.vue +
+ +
+
+ Reports hardcode 2025 quarter boundaries + inconsistent +
+

+ QUARTER_MAP and the quarterly report enumerate 2025 months literally rather + than deriving them from the data. All fixture orders fall in 2025 today, so this is + invisible — and would silently return empty results against any other year. +

+ server/main.py (QUARTER_MAP, /api/reports/quarterly) +
+ +
+
+ Test documentation describes a file that never existed + doc drift +
+

+ tests/README.md and tests/TEST_SUMMARY.md both describe + test_orders.py with 15 tests, claiming totals of 51 and 55. The file is absent + from disk and from the entire git history. The real total is 40 tests across 3 files, with + no coverage of /api/orders or either reports route. +

+ tests/README.md · tests/TEST_SUMMARY.md · tests/backend/ +
+ +
+
+ The documented hook is not wired up + doc drift +
+

+ .claude/hooks/post-tool-use.sh exists and is documented, but + settings.local.json contains no hooks block, so it never runs. The + hooks README also documents a user-prompt-submit.sh that is not on disk, and + states a log path that differs from the one the script writes to. Two competing MCP configs + exist: .mcp.json pins Playwright 0.0.37 while + .claude/mcp-config.json uses @latest. +

+ .claude/hooks/ · .claude/settings.local.json · .mcp.json +
+ +
+
+ Demo-grade configuration, as intended + by design +
+

+ CORS is allow_origins=["*"] with allow_credentials=True; the + backend URL is hardcoded client-side with no proxy or environment variable; there is no + auth, since useAuth.isAuthenticated is a literal true and + logout() only raises an alert. Appropriate for a local workshop demo, and all + three are blockers for anything else. The README says as much. +

+ server/main.py · client/src/api.js:3 · client/src/composables/useAuth.js +
+
+ + + +
+ + diff --git a/server/data/demand_forecasts.json b/server/data/demand_forecasts.json index e1b388385..0056946cf 100644 --- a/server/data/demand_forecasts.json +++ b/server/data/demand_forecasts.json @@ -1,83 +1,83 @@ [ { "id": "1", - "item_sku": "WDG-001", - "item_name": "Industrial Widget Type A", - "current_demand": 300, - "forecasted_demand": 450, + "item_sku": "PRS-203", + "item_name": "Pressure Sensor Module", + "current_demand": 900, + "forecasted_demand": 1150, "trend": "increasing", "period": "Next 30 days" }, { "id": "2", - "item_sku": "BRG-102", - "item_name": "Steel Bearing Assembly", - "current_demand": 150, - "forecasted_demand": 152, - "trend": "stable", + "item_sku": "TMP-201", + "item_name": "Temperature Sensor Module", + "current_demand": 300, + "forecasted_demand": 400, + "trend": "increasing", "period": "Next 30 days" }, { "id": "3", - "item_sku": "GSK-203", - "item_name": "High-Temperature Gasket", - "current_demand": 500, - "forecasted_demand": 600, - "trend": "increasing", + "item_sku": "PCB-002", + "item_name": "Dual Layer PCB Assembly", + "current_demand": 600, + "forecasted_demand": 560, + "trend": "decreasing", "period": "Next 30 days" }, { "id": "4", - "item_sku": "MTR-304", - "item_name": "Electric Motor 5HP", - "current_demand": 50, - "forecasted_demand": 35, - "trend": "decreasing", + "item_sku": "PSU-508", + "item_name": "Battery Backup Power Supply", + "current_demand": 180, + "forecasted_demand": 250, + "trend": "increasing", "period": "Next 30 days" }, { "id": "5", - "item_sku": "FLT-405", - "item_name": "Oil Filter Cartridge", - "current_demand": 800, - "forecasted_demand": 950, + "item_sku": "PSU-505", + "item_name": "48V DC Power Supply Unit", + "current_demand": 250, + "forecasted_demand": 320, "trend": "increasing", "period": "Next 30 days" }, { "id": "6", - "item_sku": "VLV-506", - "item_name": "Pressure Relief Valve", - "current_demand": 120, - "forecasted_demand": 121, - "trend": "stable", + "item_sku": "HMD-202", + "item_name": "Humidity Sensor Module", + "current_demand": 200, + "forecasted_demand": 260, + "trend": "increasing", "period": "Next 30 days" }, { "id": "7", - "item_sku": "PSU-501", - "item_name": "5V 10A Switching Power Supply", - "current_demand": 250, - "forecasted_demand": 252, + "item_sku": "PSU-507", + "item_name": "Adjustable Bench Power Supply", + "current_demand": 238, + "forecasted_demand": 240, "trend": "stable", "period": "Next 30 days" }, { "id": "8", - "item_sku": "SNR-420", - "item_name": "Temperature Sensor Module", - "current_demand": 180, - "forecasted_demand": 182, + "item_sku": "SRV-301", + "item_name": "Micro Servo Motor", + "current_demand": 158, + "forecasted_demand": 160, "trend": "stable", "period": "Next 30 days" }, { "id": "9", - "item_sku": "CTL-330", - "item_name": "Logic Controller Board", + "item_sku": "SRV-302", + "item_name": "Standard Servo Motor", "current_demand": 95, - "forecasted_demand": 96, - "trend": "stable", + "forecasted_demand": 90, + "trend": "decreasing", "period": "Next 30 days" } ] diff --git a/server/main.py b/server/main.py index a0c2d8c5a..a922139f1 100644 --- a/server/main.py +++ b/server/main.py @@ -1,6 +1,7 @@ from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from typing import List, Optional +from datetime import datetime, timedelta 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,16 @@ 'Q4-2025': ['2025-10', '2025-11', '2025-12'] } +# Delivery lead time by warehouse, in days. This is not present in any fixture +# file - these are the documented shipping times for each site, and they are the +# only source for a restocking order's expected_delivery date. +WAREHOUSE_LEAD_TIME_DAYS = { + 'San Francisco': 5, + 'London': 10, + 'Tokyo': 14 +} +DEFAULT_LEAD_TIME_DAYS = 7 + def filter_by_month(items: list, month: Optional[str]) -> list: """Filter items by month/quarter based on order_date field""" if not month or month == 'all': @@ -120,6 +131,34 @@ class CreatePurchaseOrderRequest(BaseModel): expected_delivery_date: str notes: Optional[str] = None +class RestockingOrderLine(BaseModel): + sku: str + name: str + quantity: int + # Named unit_price (not unit_cost) to match the existing Order.items[] shape + unit_price: float + +class RestockingOrder(BaseModel): + id: str + order_number: str + warehouse: str + items: List[RestockingOrderLine] + status: str + order_date: str + expected_delivery: str + lead_time_days: int + total_value: float + +class CreateRestockingOrderRequest(BaseModel): + warehouse: str + items: List[RestockingOrderLine] + +# Submitted restocking orders. Deliberately process-local and never written to +# disk: this is the only mutable state in the service, and it resets on restart +# just like the JSON-backed fixtures. Kept separate from `orders` on purpose so +# submissions don't skew dashboard, reports or spending totals. +restocking_orders: List[dict] = [] + # API endpoints @app.get("/") def root(): @@ -304,6 +343,47 @@ def get_monthly_trends(): result.sort(key=lambda x: x['month']) return result +@app.get("/api/restocking-orders", response_model=List[RestockingOrder]) +def get_restocking_orders(): + """Get all submitted restocking orders, newest first""" + return list(reversed(restocking_orders)) + +@app.post("/api/restocking-orders", response_model=RestockingOrder, status_code=201) +def create_restocking_order(request: CreateRestockingOrderRequest): + """Submit a restocking order for a single warehouse""" + if not request.items: + raise HTTPException( + status_code=400, + detail="A restocking order must contain at least one item" + ) + + # Lead time is derived from the warehouse - it is the only delivery signal + # available, since no fixture carries a per-SKU lead time. + lead_time_days = WAREHOUSE_LEAD_TIME_DAYS.get(request.warehouse, DEFAULT_LEAD_TIME_DAYS) + + order_date = datetime.now() + expected_delivery = order_date + timedelta(days=lead_time_days) + total_value = sum(line.quantity * line.unit_price for line in request.items) + + # Sequence from the current list length - ids only need to be unique within + # this process, since the store is cleared on restart. + sequence = len(restocking_orders) + 1 + + new_order = { + 'id': f"RST-{sequence}", + 'order_number': f"RST-2025-{sequence:04d}", + 'warehouse': request.warehouse, + 'items': [line.model_dump() for line in request.items], + 'status': 'Submitted', + 'order_date': order_date.strftime('%Y-%m-%dT%H:%M:%S'), + 'expected_delivery': expected_delivery.strftime('%Y-%m-%dT%H:%M:%S'), + 'lead_time_days': lead_time_days, + 'total_value': round(total_value, 2) + } + + restocking_orders.append(new_order) + return new_order + if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8001) diff --git a/tests/backend/test_misc_endpoints.py b/tests/backend/test_misc_endpoints.py index 5a48fda8c..5d57a818b 100644 --- a/tests/backend/test_misc_endpoints.py +++ b/tests/backend/test_misc_endpoints.py @@ -52,8 +52,9 @@ def test_stable_demand_items_have_small_changes(self, client): stable_items = [item for item in data if item["trend"].lower() == "stable"] - # Should have at least 5 stable items - assert len(stable_items) >= 5, f"Expected at least 5 stable items, found {len(stable_items)}" + # Count tracks the demand fixture, which was realigned to reference real + # inventory SKUs so restocking recommendations can price them. + assert len(stable_items) >= 2, f"Expected at least 2 stable items, found {len(stable_items)}" for item in stable_items: current = item["current_demand"] @@ -65,23 +66,23 @@ def test_stable_demand_items_have_small_changes(self, client): assert percent_change < 2.0, \ f"Item {item['item_name']} has {percent_change:.2f}% change, expected < 2%" - def test_demand_forecast_has_new_items(self, client): - """Test that new demand forecast items exist.""" + def test_demand_forecast_has_stable_items(self, client): + """Test that the expected stable-trend forecast items exist.""" response = client.get("/api/demand") data = response.json() - # Check for the new items we added skus = [item["item_sku"] for item in data] - # Should have Temperature Sensor Module and Logic Controller Board - assert "SNR-420" in skus, "Missing Temperature Sensor Module" - assert "CTL-330" in skus, "Missing Logic Controller Board" + # These replaced the earlier SNR-420 / CTL-330 entries, which referenced + # SKUs that existed in no inventory record and so could never be priced. + assert "PSU-507" in skus, "Missing Adjustable Bench Power Supply" + assert "SRV-301" in skus, "Missing Micro Servo Motor" # Verify they are marked as stable for item in data: - if item["item_sku"] in ["SNR-420", "CTL-330"]: + if item["item_sku"] in ["PSU-507", "SRV-301"]: assert item["trend"].lower() == "stable", \ - f"New item {item['item_name']} should have stable trend" + f"Item {item['item_name']} should have stable trend" class TestBacklogEndpoints: diff --git a/tests/backend/test_restocking.py b/tests/backend/test_restocking.py new file mode 100644 index 000000000..3f3a2c3f3 --- /dev/null +++ b/tests/backend/test_restocking.py @@ -0,0 +1,248 @@ +""" +Tests for restocking order API endpoints. +""" +import pytest + + +# Lead times are derived from the warehouse, not stored in any fixture. +# Mirrors WAREHOUSE_LEAD_TIME_DAYS in server/main.py. +EXPECTED_LEAD_TIMES = { + "San Francisco": 5, + "London": 10, + "Tokyo": 14 +} + + +def build_order(warehouse="Tokyo", items=None): + """Build a valid restocking order payload.""" + if items is None: + items = [ + { + "sku": "SRV-301", + "name": "Micro Servo Motor", + "quantity": 10, + "unit_price": 445.00 + } + ] + return {"warehouse": warehouse, "items": items} + + +class TestRestockingEndpoints: + """Test suite for restocking-order endpoints. + + Note: the server stores submitted orders in a module-level list that is + shared across the whole test session, so these tests assert on the returned + object and on membership rather than on absolute list length. + """ + + def test_get_all_restocking_orders(self, client): + """Test getting all restocking orders.""" + response = client.get("/api/restocking-orders") + assert response.status_code == 200 + + data = response.json() + assert isinstance(data, list) + + def test_create_restocking_order(self, client): + """Test submitting a restocking order.""" + response = client.post("/api/restocking-orders", json=build_order()) + assert response.status_code == 201 + + order = response.json() + assert "id" in order + assert "order_number" in order + assert order["warehouse"] == "Tokyo" + assert order["status"] == "Submitted" + assert isinstance(order["items"], list) + assert len(order["items"]) == 1 + + def test_restocking_order_number_format(self, client): + """Test that restocking orders get an RST-prefixed order number.""" + response = client.post("/api/restocking-orders", json=build_order()) + order = response.json() + + assert order["order_number"].startswith("RST-2025-") + # Sequence is zero-padded to 4 digits + assert len(order["order_number"].split("-")[-1]) == 4 + + def test_restocking_order_items_structure(self, client): + """Test that restocking order items have proper structure.""" + response = client.post("/api/restocking-orders", json=build_order()) + order = response.json() + + for item in order["items"]: + assert "sku" in item + assert "name" in item + assert "quantity" in item + assert "unit_price" in item + assert isinstance(item["quantity"], int) + assert isinstance(item["unit_price"], (int, float)) + + def test_restocking_order_total_value_calculation(self, client): + """Test that total value is the sum of quantity * unit_price.""" + items = [ + {"sku": "SRV-301", "name": "Micro Servo Motor", "quantity": 10, "unit_price": 445.00}, + {"sku": "PSU-508", "name": "Battery Backup Power Supply", "quantity": 4, "unit_price": 185.50} + ] + response = client.post("/api/restocking-orders", json=build_order(items=items)) + order = response.json() + + calculated_total = sum(i["quantity"] * i["unit_price"] for i in items) + assert abs(order["total_value"] - calculated_total) < 0.01 + + def test_restocking_order_lead_time_by_warehouse(self, client): + """Test that lead time is derived correctly for each warehouse.""" + for warehouse, expected_days in EXPECTED_LEAD_TIMES.items(): + response = client.post( + "/api/restocking-orders", + json=build_order(warehouse=warehouse) + ) + assert response.status_code == 201 + + order = response.json() + assert order["warehouse"] == warehouse + assert order["lead_time_days"] == expected_days + + def test_restocking_order_unknown_warehouse_uses_default(self, client): + """Test that an unrecognized warehouse falls back to the default lead time.""" + response = client.post( + "/api/restocking-orders", + json=build_order(warehouse="Berlin") + ) + assert response.status_code == 201 + + order = response.json() + assert order["lead_time_days"] == 7 + + def test_restocking_order_dates_format(self, client): + """Test that restocking order dates are in ISO format with a time component.""" + response = client.post("/api/restocking-orders", json=build_order()) + order = response.json() + + assert "T" in order["order_date"] + assert "T" in order["expected_delivery"] + assert "-" in order["order_date"] + + def test_restocking_order_expected_delivery_offset(self, client): + """Test that expected delivery is order date plus the lead time.""" + from datetime import datetime + + response = client.post("/api/restocking-orders", json=build_order(warehouse="London")) + order = response.json() + + ordered = datetime.fromisoformat(order["order_date"]) + delivery = datetime.fromisoformat(order["expected_delivery"]) + + assert (delivery - ordered).days == EXPECTED_LEAD_TIMES["London"] + assert order["lead_time_days"] == EXPECTED_LEAD_TIMES["London"] + + def test_create_restocking_order_empty_items(self, client): + """Test that submitting an order with no items is rejected.""" + response = client.post( + "/api/restocking-orders", + json={"warehouse": "Tokyo", "items": []} + ) + assert response.status_code == 400 + + data = response.json() + assert "detail" in data + assert "at least one item" in data["detail"].lower() + + def test_create_restocking_order_missing_warehouse(self, client): + """Test that a missing required field returns a validation error.""" + response = client.post( + "/api/restocking-orders", + json={"items": [ + {"sku": "SRV-301", "name": "Micro Servo Motor", "quantity": 10, "unit_price": 445.00} + ]} + ) + assert response.status_code == 422 + + def test_create_restocking_order_invalid_item(self, client): + """Test that a malformed item line returns a validation error.""" + response = client.post( + "/api/restocking-orders", + json={"warehouse": "Tokyo", "items": [{"sku": "SRV-301"}]} + ) + assert response.status_code == 422 + + def test_submitted_order_appears_in_list(self, client): + """Test that a submitted order is retrievable from the list endpoint.""" + created = client.post("/api/restocking-orders", json=build_order()).json() + + response = client.get("/api/restocking-orders") + assert response.status_code == 200 + + order_numbers = [o["order_number"] for o in response.json()] + assert created["order_number"] in order_numbers + + def test_restocking_orders_newest_first(self, client): + """Test that the list returns the most recently submitted order first.""" + client.post("/api/restocking-orders", json=build_order(warehouse="London")) + latest = client.post("/api/restocking-orders", json=build_order(warehouse="Tokyo")).json() + + data = client.get("/api/restocking-orders").json() + assert data[0]["order_number"] == latest["order_number"] + + def test_restocking_orders_excluded_from_customer_orders(self, client): + """Test that restocking orders do not leak into the customer orders list.""" + client.post("/api/restocking-orders", json=build_order()) + + orders = client.get("/api/orders").json() + order_numbers = [o["order_number"] for o in orders] + + assert not any(num.startswith("RST-") for num in order_numbers) + + +class TestDemandForecastAlignment: + """Test suite verifying demand forecasts join to real inventory items.""" + + def test_forecast_skus_exist_in_inventory(self, client): + """Test that every forecast SKU references a real inventory item. + + Restocking recommendations join demand forecasts to inventory to get + unit cost and stock level, so an unjoinable forecast is unusable. + """ + forecasts = client.get("/api/demand").json() + inventory = client.get("/api/inventory").json() + + inventory_skus = {item["sku"] for item in inventory} + + for forecast in forecasts: + assert forecast["item_sku"] in inventory_skus, ( + f"Forecast SKU {forecast['item_sku']} has no inventory item" + ) + + def test_forecasts_produce_restockable_shortfall(self, client): + """Test that at least one forecast exceeds current stock. + + If no forecast has a positive shortfall, the restocking view would + recommend nothing at any budget. + """ + forecasts = client.get("/api/demand").json() + inventory = {item["sku"]: item for item in client.get("/api/inventory").json()} + + shortfalls = [ + f["forecasted_demand"] - inventory[f["item_sku"]]["quantity_on_hand"] + for f in forecasts + if f["item_sku"] in inventory + ] + + assert any(gap > 0 for gap in shortfalls) + + def test_forecasts_span_multiple_warehouses(self, client): + """Test that forecast items cover more than one warehouse. + + Place Order groups the cart by warehouse, so multi-warehouse coverage + is what exercises that path. + """ + forecasts = client.get("/api/demand").json() + inventory = {item["sku"]: item for item in client.get("/api/inventory").json()} + + warehouses = { + inventory[f["item_sku"]]["warehouse"] + for f in forecasts + if f["item_sku"] in inventory + } + + assert len(warehouses) > 1