diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml new file mode 100644 index 000000000..b96d6f0ef --- /dev/null +++ b/.github/workflows/claude-code-review.yml @@ -0,0 +1,30 @@ +name: Claude Code Review + +on: + pull_request: + types: [opened, synchronize] + +jobs: + claude-review: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code Review + id: claude-review + uses: anthropics/claude-code-action@v1 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + prompt: | + Review this pull request. Focus on correctness, potential bugs, + security issues, and test coverage. Be concise and specific, and + point to file:line where possible. Leave inline comments via the + repository's review tools. diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 000000000..337f0be20 --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,39 @@ +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + claude: + # Only run when someone actually mentions @claude, to avoid spending + # Actions minutes and API tokens on every comment/issue event. + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + actions: read + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@v1 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} 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..3d4b82b11 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -33,6 +33,11 @@ export const api = { return response.data }, + async createOrder(orderData) { + const response = await axios.post(`${API_BASE_URL}/orders`, orderData) + return response.data + }, + async getDemandForecasts() { const response = await axios.get(`${API_BASE_URL}/demand`) return response.data diff --git a/client/src/locales/en.js b/client/src/locales/en.js index 03a58fe6e..d7fa7e541 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,12 +107,14 @@ export default { title: 'Orders', description: 'View and manage customer orders', allOrders: 'All Orders', + submittedOrders: 'Submitted Orders', totalOrders: 'Total Orders', totalRevenue: 'Total Revenue', avgOrderValue: 'Avg Order Value', onTimeDelivery: 'On-Time Delivery', itemsCount: '{count} items', quantity: 'Qty', + leadTimeDays: '{days} days', table: { orderNumber: 'Order Number', orderId: 'Order ID', @@ -125,7 +128,8 @@ export default { totalValue: 'Total Value', status: 'Status', expectedDelivery: 'Expected Delivery', - actualDelivery: 'Actual Delivery' + actualDelivery: 'Actual Delivery', + leadTime: 'Lead Time' } }, @@ -204,11 +208,37 @@ export default { shipped: 'Shipped', processing: 'Processing', backordered: 'Backordered', + submitted: 'Submitted', inStock: 'In Stock', lowStock: 'Low Stock', adequate: 'Adequate' }, + // Restocking + restocking: { + title: 'Restocking', + description: 'Set a budget and place restock orders based on the demand forecast', + budgetLabel: 'Available Budget', + budgetHelp: 'Drag to set how much you can spend. Recommendations update automatically.', + recommended: 'Recommended Restock', + itemsRecommended: 'Items Recommended', + totalCost: 'Total Cost', + budgetRemaining: 'Budget Remaining', + placeOrder: 'Place Order', + placing: 'Placing order...', + orderPlaced: 'Order {orderNumber} submitted. Expected delivery {date}.', + noRecommendations: 'Budget is too low to restock any item. Increase the budget to see recommendations.', + table: { + item: 'Item', + trend: 'Trend', + gap: 'Forecast Gap', + quantity: 'Restock Qty', + unitCost: 'Unit Cost', + lineCost: 'Line Cost', + leadTime: 'Lead Time' + } + }, + // Trends trends: { increasing: 'increasing', diff --git a/client/src/locales/ja.js b/client/src/locales/ja.js index db33223ac..be6813a59 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,12 +107,14 @@ export default { title: '注文', description: '顧客注文の表示と管理', allOrders: 'すべての注文', + submittedOrders: '送信済み注文', totalOrders: '総注文数', totalRevenue: '総収益', avgOrderValue: '平均注文額', onTimeDelivery: '定時配達', itemsCount: '{count}件', quantity: '数量', + leadTimeDays: '{days}日', table: { orderNumber: '注文番号', orderId: '注文ID', @@ -125,7 +128,8 @@ export default { totalValue: '合計金額', status: 'ステータス', expectedDelivery: '予定配達日', - actualDelivery: '実際の配達日' + actualDelivery: '実際の配達日', + leadTime: 'リードタイム' } }, @@ -204,11 +208,37 @@ export default { shipped: '出荷済み', processing: '処理中', backordered: 'バックオーダー', + submitted: '送信済み', inStock: '在庫あり', lowStock: '在庫僅少', adequate: '適量' }, + // Restocking + restocking: { + title: '補充', + description: '予算を設定し、需要予測に基づいて補充注文を行います', + budgetLabel: '利用可能な予算', + budgetHelp: 'ドラッグして使用可能額を設定します。推奨は自動的に更新されます。', + recommended: '推奨補充', + itemsRecommended: '推奨品目数', + totalCost: '合計費用', + budgetRemaining: '残り予算', + placeOrder: '注文する', + placing: '注文を送信中...', + orderPlaced: '注文 {orderNumber} を送信しました。予定配達日 {date}。', + noRecommendations: '予算が不足しているため、補充できる品目がありません。予算を増やしてください。', + table: { + item: '品目', + trend: '傾向', + gap: '予測ギャップ', + quantity: '補充数量', + unitCost: '単価', + lineCost: '小計', + leadTime: 'リードタイム' + } + }, + // Trends trends: { increasing: '増加', 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..4d995d98e 100644 --- a/client/src/views/Orders.vue +++ b/client/src/views/Orders.vue @@ -27,6 +27,54 @@ +
+
+

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

+
+
+ + + + + + + + + + + + + + + + + + + + + + + +
{{ t('orders.table.orderNumber') }}{{ t('orders.table.items') }}{{ t('orders.table.status') }}{{ t('orders.table.orderDate') }}{{ t('orders.table.expectedDelivery') }}{{ t('orders.table.leadTime') }}{{ t('orders.table.totalValue') }}
{{ order.order_number }} +
+ + {{ t('orders.itemsCount', { count: order.items.length }) }} + +
+
+ {{ translateProductName(item.name) }} + {{ t('orders.quantity') }}: {{ item.quantity }} @ {{ currencySymbol }}{{ item.unit_price }} +
+
+
+
+ + {{ t(`status.${order.status.toLowerCase()}`) }} + + {{ formatDate(order.order_date) }}{{ formatDate(order.expected_delivery) }}{{ t('orders.leadTimeDays', { days: getLeadTime(order) }) }}{{ currencySymbol }}{{ order.total_value.toLocaleString() }}
+
+
+

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

@@ -138,11 +186,20 @@ export default { 'Delivered': 'success', 'Shipped': 'info', 'Processing': 'warning', - 'Backordered': 'danger' + 'Backordered': 'danger', + 'Submitted': 'info' } return statusMap[status] || 'info' } + // Delivery lead time in whole days, derived from the gap between the order + // date and its expected delivery date. + const getLeadTime = (order) => { + const ordered = new Date(order.order_date) + const expected = new Date(order.expected_delivery) + return Math.round((expected - ordered) / 86400000) + } + const formatDate = (dateString) => { const { currentLocale } = useI18n() const locale = currentLocale.value === 'ja' ? 'ja-JP' : 'en-US' @@ -162,6 +219,7 @@ export default { orders, getOrdersByStatus, getOrderStatusClass, + getLeadTime, formatDate, currencySymbol, translateProductName, @@ -203,6 +261,15 @@ export default { width: 120px; } +.col-lead { + width: 110px; +} + +/* Submitted (restocking) orders get a subtle accent to set them apart */ +.submitted-card { + border-left: 4px solid #2563eb; +} + /* Items details styling */ .items-details { position: relative; diff --git a/client/src/views/Restocking.vue b/client/src/views/Restocking.vue new file mode 100644 index 000000000..4252e3239 --- /dev/null +++ b/client/src/views/Restocking.vue @@ -0,0 +1,367 @@ + + + + + diff --git a/docs/architecture.html b/docs/architecture.html new file mode 100644 index 000000000..de7546012 --- /dev/null +++ b/docs/architecture.html @@ -0,0 +1,399 @@ + + + + + +Architecture - Factory Inventory Management System + + + +
+
+

Factory Inventory Management System

+

System architecture, technology stack, and data flow reference

+
+ Full-stack demo + Vue 3 + Vite frontend + Python FastAPI backend + In-memory JSON data +
+
+
+ +
+ + +
+

System Architecture

+

A two-tier client-server application. A single-page Vue frontend runs in the browser and communicates with a stateless FastAPI service over HTTP. The API reads sample data loaded into memory from JSON files at startup. There is no database.

+ +
+ +
+
+ Client tier – Browser + Vue 3 SPA · Vite dev server · port 3000 +
+
+
Views (7)
Dashboard, Inventory, Orders, Demand, Spending, Reports
+
Components (9)
Filter bar, detail modals, profile, language switcher
+
Composables (3)
useFilters, useI18n, useAuth
+
API client
api.js (Axios), Vue Router
+
+
+ +
HTTP / JSON · Axios · CORS open
+ +
+
+ Application tier – API server + FastAPI + Uvicorn · port 8001 +
+
+
Route handlers
14 read endpoints under /api
+
Filter engine
apply_filters + filter_by_month (quarter map)
+
Pydantic models
Response validation and typing
+
CORS middleware
allow_origins = *
+
+
+ +
In-process load at startup · mock_data.py
+ +
+
+ Data tier – In-memory + JSON files in server/data/ · no database +
+
+
inventory.json
Stock across warehouses
+
orders.json
12 months of orders
+
spending / transactions
Finance datasets
+
demand / backlog / POs
Forecasts and fulfillment
+
+
+ +
+
+ + +
+

Technology Stack

+
+
+

Frontend

+
Single-page app, runs in the browser
+
    +
  • Vue^3.4.21
  • +
  • Vue Router^4.3.0
  • +
  • Axios^1.6.7
  • +
  • Vite^5.2.0
  • +
  • @vitejs/plugin-vue^5.0.4
  • +
+
+
+

Backend

+
Stateless REST API
+
    +
  • Python≥3.11
  • +
  • FastAPI≥0.110
  • +
  • Uvicorn≥0.24
  • +
  • Pydantic≥2.5
  • +
+
+
+

Data

+
Loaded into memory at startup
+
    +
  • FormatJSON files
  • +
  • Locationserver/data/
  • +
  • Loadermock_data.py
  • +
  • Databasenone
  • +
+
+
+

Tooling

+
Dev, test, and locale support
+
    +
  • Env manageruv / venv + pip
  • +
  • Testspytest + TestClient
  • +
  • i18nen / ja
  • +
  • CurrencyUSD / JPY
  • +
+
+
+
+ + +
+

Data Flow

+

A single request cycle, from a user changing a filter to updated numbers on screen. Filter state is shared app-wide through a singleton composable, so every view reads the same selection.

+
    +
  1. +
    User sets a filter
    +
    The filter bar updates shared state in useFilters (Time Period, Warehouse, Category, Order Status). State is a module-level singleton, so all views stay in sync.
    +
  2. +
  3. +
    View requests data
    +
    A view calls getCurrentFilters() and passes the result to a method on api.js, for example getDashboardSummary(filters).
    +
  4. +
  5. +
    HTTP request
    +
    Axios sends a GET to http://localhost:8001/api/... with active filters as query params. "all" values are omitted from the request.
    +
  6. +
  7. +
    Server filters in memory
    +
    FastAPI applies apply_filters (warehouse, category, status) and filter_by_month, which expands quarters such as Q3-2025 into their months.
    +
  8. +
  9. +
    Validate and respond
    +
    Results are shaped and validated against Pydantic models, then returned as JSON.
    +
  10. +
  11. +
    Reactive render
    +
    The view stores raw data in refs; computed properties derive metrics and charts, and Vue re-renders the affected parts of the page.
    +
  12. +
+
+ + +
+

API Endpoints

+

All endpoints are read-only GET requests. Filters accepted: warehouse, category, status, month.

+ + + + + + + + + + + + + + + + + + +
MethodPathPurposeFilters
GET/api/inventoryInventory itemswarehouse, category
GET/api/inventory/{id}Single inventory item
GET/api/ordersOrderswarehouse, category, status, month
GET/api/orders/{id}Single order
GET/api/demandDemand forecasts
GET/api/backlogBacklog items (with PO status)
GET/api/dashboard/summaryHeadline metricsall four
GET/api/spending/summarySpending totals
GET/api/spending/monthlyMonthly spend
GET/api/spending/categoriesSpend by category
GET/api/spending/transactionsRecent transactions
GET/api/reports/quarterlyQuarterly report
GET/api/reports/monthly-trendsMonthly trend series
GET/Service root / health
+
+ + +
+

Data Entities

+ + + + + + + + + + + +
DatasetFileRecordsNotes
Ordersorders.json250Spans 12 months, four statuses
Transactionstransactions.json56Finance ledger entries
Inventoryinventory.json32Stock by SKU and warehouse
Demand forecastsdemand_forecasts.json9Current vs forecast with trend
Backlog itemsbacklog_items.json4Delayed / short-stock items
Spendingspending.json3 setsSummary, monthly, category
Purchase orderspurchase_orders.json0Empty; drives backlog PO flag
+
+ + +
+

Architecture Notes

+
+
+
Stateless by design
+
The API holds no session state. Data is loaded from JSON once at startup and filtered per request. Restarting the server resets everything.
+
+
+
Direct cross-origin calls
+
The client calls the API host and port directly. There is no Vite proxy; the backend allows all origins via open CORS. Fine for a local demo, not for production.
+
+
+
Client methods without backend routes
+
api.js defines methods for tasks (GET/POST/DELETE/PATCH) and purchase orders (POST/GET), but the backend exposes no matching routes. Tasks are mocked in useAuth.js; those write calls would 404.
+
+
+
Demo, not production
+
No database, authentication, or authorization. useAuth returns a hard-coded user with isAuthenticated always true.
+
+
+
+ +
+ + + + + diff --git a/server/data/demand_forecasts.json b/server/data/demand_forecasts.json index e1b388385..c27b50494 100644 --- a/server/data/demand_forecasts.json +++ b/server/data/demand_forecasts.json @@ -6,7 +6,9 @@ "current_demand": 300, "forecasted_demand": 450, "trend": "increasing", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 42.50, + "lead_time_days": 14 }, { "id": "2", @@ -15,7 +17,9 @@ "current_demand": 150, "forecasted_demand": 152, "trend": "stable", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 65.00, + "lead_time_days": 21 }, { "id": "3", @@ -24,7 +28,9 @@ "current_demand": 500, "forecasted_demand": 600, "trend": "increasing", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 12.75, + "lead_time_days": 10 }, { "id": "4", @@ -33,7 +39,9 @@ "current_demand": 50, "forecasted_demand": 35, "trend": "decreasing", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 310.00, + "lead_time_days": 30 }, { "id": "5", @@ -42,7 +50,9 @@ "current_demand": 800, "forecasted_demand": 950, "trend": "increasing", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 8.90, + "lead_time_days": 7 }, { "id": "6", @@ -51,7 +61,9 @@ "current_demand": 120, "forecasted_demand": 121, "trend": "stable", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 120.00, + "lead_time_days": 18 }, { "id": "7", @@ -60,7 +72,9 @@ "current_demand": 250, "forecasted_demand": 252, "trend": "stable", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 18.99, + "lead_time_days": 12 }, { "id": "8", @@ -69,7 +83,9 @@ "current_demand": 180, "forecasted_demand": 182, "trend": "stable", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 33.25, + "lead_time_days": 9 }, { "id": "9", @@ -78,6 +94,8 @@ "current_demand": 95, "forecasted_demand": 96, "trend": "stable", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 78.40, + "lead_time_days": 15 } ] diff --git a/server/main.py b/server/main.py index a0c2d8c5a..c4eacab24 100644 --- a/server/main.py +++ b/server/main.py @@ -1,3 +1,4 @@ +from datetime import datetime, timedelta from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from typing import List, Optional @@ -89,6 +90,11 @@ class DemandForecast(BaseModel): forecasted_demand: int trend: str period: str + # Enriched fields used by the Restocking tab for budget math and delivery estimates. + # Required on the model because GET /api/demand uses response_model=List[DemandForecast], + # which drops any field not declared here. + unit_cost: float + lead_time_days: int class BacklogItem(BaseModel): id: str @@ -120,6 +126,17 @@ class CreatePurchaseOrderRequest(BaseModel): expected_delivery_date: str notes: Optional[str] = None +class CreateOrderItem(BaseModel): + sku: str + name: str + quantity: int + unit_price: float + +class CreateOrderRequest(BaseModel): + items: List[CreateOrderItem] + customer: Optional[str] = "Internal Restock" + warehouse: Optional[str] = None + # API endpoints @app.get("/") def root(): @@ -161,6 +178,43 @@ def get_order(order_id: str): raise HTTPException(status_code=404, detail="Order not found") return order +@app.post("/api/orders", response_model=Order) +def create_order(req: CreateOrderRequest): + """Create a restocking order and append it to the in-memory orders list. + + Note: in-memory only. The new order is visible to subsequent GET /api/orders + calls in this process, but is not written back to orders.json and resets on + server restart. This matches the demo's no-database design. + """ + now = datetime.now() + + # Order-level lead time is the longest item lead time (the delivery bottleneck), + # looked up from the demand forecast the item was recommended from. + lead_days = 0 + total_value = 0.0 + for it in req.items: + total_value += it.quantity * it.unit_price + forecast = next((d for d in demand_forecasts if d["item_sku"] == it.sku), None) + if forecast: + lead_days = max(lead_days, int(forecast.get("lead_time_days", 0))) + + seq = len(orders) + 1 + new_order = { + "id": str(seq), + "order_number": f"ORD-2025-{seq:04d}", + "customer": req.customer or "Internal Restock", + "items": [it.model_dump() for it in req.items], + "status": "Submitted", + "order_date": now.isoformat(timespec="seconds"), + "expected_delivery": (now + timedelta(days=lead_days)).isoformat(timespec="seconds"), + "total_value": round(total_value, 2), + "actual_delivery": None, + "warehouse": req.warehouse, + "category": None, + } + orders.append(new_order) + return new_order + @app.get("/api/demand", response_model=List[DemandForecast]) def get_demand_forecasts(): """Get demand forecasts"""