diff --git a/CLAUDE.md b/CLAUDE.md index 89c307d15..07e99c9bd 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 Conventions +- 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..71d9913f7 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -22,6 +22,9 @@ {{ t('nav.demandForecast') }} + + Restocking + Reports diff --git a/client/src/api.js b/client/src/api.js index 11cb9db70..df4f6dcfc 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -102,5 +102,23 @@ export const api = { async getPurchaseOrderByBacklogItem(backlogItemId) { const response = await axios.get(`${API_BASE_URL}/purchase-orders/${backlogItemId}`) return response.data + }, + + async getRestockCandidates(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) + const response = await axios.get(`${API_BASE_URL}/restocking/candidates?${params.toString()}`) + return response.data + }, + + async createRestockOrder(restockOrderData) { + const response = await axios.post(`${API_BASE_URL}/restock-orders`, restockOrderData) + return response.data + }, + + async getRestockOrders() { + const response = await axios.get(`${API_BASE_URL}/restock-orders`) + return response.data } } diff --git a/client/src/main.js b/client/src/main.js index 477c2d966..3347ae013 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(), @@ -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..d6c2e5759 100644 --- a/client/src/views/Orders.vue +++ b/client/src/views/Orders.vue @@ -27,6 +27,40 @@ +
+
+

Submitted Orders ({{ submittedOrders.length }})

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+
+

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

@@ -95,6 +129,9 @@ export default { const loading = ref(true) const error = ref(null) const orders = ref([]) + // Restocking orders submitted from the Restocking tab. Held separately from + // `orders` because they come from a different endpoint and are not customer orders. + const submittedOrders = ref([]) // Use shared filters const { @@ -153,13 +190,37 @@ export default { }) } - onMounted(loadOrders) + // Guarded formatter: submitted orders carry server-generated timestamps, so an + // unparseable value should render a dash rather than "Invalid Date". + const formatSafeDate = (dateString) => { + if (!dateString) return '—' + const parsed = new Date(dateString) + if (isNaN(parsed.getTime())) return '—' + return formatDate(dateString) + } + + const loadSubmittedOrders = async () => { + try { + submittedOrders.value = await api.getRestockOrders() + } catch (err) { + // A missing restocking feed must not blank out the customer orders table. + console.error('Failed to load submitted restocking orders:', err) + submittedOrders.value = [] + } + } + + onMounted(() => { + loadOrders() + loadSubmittedOrders() + }) return { t, loading, error, orders, + submittedOrders, + formatSafeDate, getOrdersByStatus, getOrderStatusClass, formatDate, diff --git a/client/src/views/Restocking.vue b/client/src/views/Restocking.vue new file mode 100644 index 000000000..77dc54c60 --- /dev/null +++ b/client/src/views/Restocking.vue @@ -0,0 +1,396 @@ + + + + + diff --git a/server/main.py b/server/main.py index a0c2d8c5a..07613130a 100644 --- a/server/main.py +++ b/server/main.py @@ -1,7 +1,8 @@ from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from typing import List, Optional -from pydantic import BaseModel +from pydantic import BaseModel, Field +from datetime import datetime, timedelta from mock_data import inventory_items, orders, demand_forecasts, backlog_items, spending_summary, monthly_spending, category_spending, recent_transactions, purchase_orders app = FastAPI(title="Factory Inventory Management System") @@ -14,6 +15,20 @@ 'Q4-2025': ['2025-10', '2025-11', '2025-12'] } +# Fixed delivery lead time for restocking orders, in days. Chosen to match the +# ~13-day order_date -> expected_delivery gap seen throughout orders.json. +RESTOCK_LEAD_TIME_DAYS = 14 + +# Ranking used to order restock candidates before the budget is applied, so the +# client's greedy fill spends on genuinely-short stock first. +RESTOCK_PRIORITY_RANK = {'high': 0, 'medium': 1, 'low': 2} + +# Submitted restocking orders. Deliberately kept separate from `orders` rather +# than appended to it: /api/dashboard/summary and /api/reports/* aggregate over +# `orders`, so appending would inflate live revenue and fulfilment figures with +# demo submissions. In-memory only - restarting the server clears these. +restock_orders: List[dict] = [] + 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 +135,44 @@ class CreatePurchaseOrderRequest(BaseModel): expected_delivery_date: str notes: Optional[str] = None +class RestockCandidate(BaseModel): + sku: str + name: str + category: str + warehouse: str + unit_cost: float + quantity_on_hand: int + reorder_point: int + shortfall: int + recommended_quantity: int + estimated_cost: float + priority: str + # Populated only when a demand forecast could be matched to this item. + demand_trend: Optional[str] = None + forecasted_demand: Optional[int] = None + +class RestockOrderItem(BaseModel): + sku: str + name: str + quantity: int = Field(gt=0) + unit_cost: float = Field(ge=0) + +class CreateRestockOrderRequest(BaseModel): + budget: float = Field(ge=0) + items: List[RestockOrderItem] + +class RestockOrder(BaseModel): + id: str + order_number: str + status: str + budget: float + total_cost: float + item_count: int + items: List[RestockOrderItem] + created_date: str + expected_delivery: str + lead_time_days: int + # API endpoints @app.get("/") def root(): @@ -304,6 +357,111 @@ def get_monthly_trends(): result.sort(key=lambda x: x['month']) return result +def find_demand_forecast(item: dict) -> Optional[dict]: + """Find the demand forecast matching an inventory item, if one exists. + + The seeded demand forecasts use a different SKU series than inventory - only + PSU-501 overlaps - so fall back to matching on product name, which recovers + SNR-420 -> TMP-201. Items with no forecast keep a null demand signal rather + than being excluded, so restocking still covers the whole catalogue. + """ + match = next((d for d in demand_forecasts if d["item_sku"] == item["sku"]), None) + if not match: + match = next((d for d in demand_forecasts if d["item_name"] == item["name"]), None) + return match + +@app.get("/api/restocking/candidates", response_model=List[RestockCandidate]) +def get_restock_candidates( + warehouse: Optional[str] = None, + category: Optional[str] = None +): + """Get items worth restocking, priced from inventory and ranked by urgency. + + Returns every candidate with its full cost so the client can apply a budget + reactively as the slider moves, instead of re-querying on every change. + """ + candidates = [] + + for item in apply_filters(inventory_items, warehouse, category): + shortfall = max(0, item["reorder_point"] - item["quantity_on_hand"]) + forecast = find_demand_forecast(item) + + # Restock back up to the reorder point, or to cover forecasted demand + # when a forecast exists - whichever is higher. + target = item["reorder_point"] + if forecast: + target = max(target, forecast["forecasted_demand"]) + + recommended_quantity = max(0, target - item["quantity_on_hand"]) + if recommended_quantity == 0: + continue + + if shortfall > 0: + priority = "high" + elif forecast and forecast["trend"].lower() == "increasing": + priority = "medium" + else: + priority = "low" + + candidates.append({ + "sku": item["sku"], + "name": item["name"], + "category": item["category"], + "warehouse": item["warehouse"], + "unit_cost": item["unit_cost"], + "quantity_on_hand": item["quantity_on_hand"], + "reorder_point": item["reorder_point"], + "shortfall": shortfall, + "recommended_quantity": recommended_quantity, + "estimated_cost": round(recommended_quantity * item["unit_cost"], 2), + "priority": priority, + "demand_trend": forecast["trend"] if forecast else None, + "forecasted_demand": forecast["forecasted_demand"] if forecast else None + }) + + # Urgent items first, then cheapest, so a small budget still clears the most + # critical shortfalls it can afford. + candidates.sort(key=lambda c: (RESTOCK_PRIORITY_RANK[c["priority"]], c["estimated_cost"])) + return candidates + +@app.post("/api/restock-orders", response_model=RestockOrder) +def create_restock_order(request: CreateRestockOrderRequest): + """Submit a restocking order for the selected items""" + if not request.items: + raise HTTPException(status_code=400, detail="A restocking order must contain at least one item") + + total_cost = round(sum(item.quantity * item.unit_cost for item in request.items), 2) + if total_cost > request.budget: + raise HTTPException( + status_code=400, + detail=f"Order total {total_cost} exceeds the available budget {request.budget}" + ) + + created = datetime.now() + expected_delivery = created + timedelta(days=RESTOCK_LEAD_TIME_DAYS) + sequence = len(restock_orders) + 1 + + order = { + "id": str(sequence), + "order_number": f"RST-{created.year}-{sequence:04d}", + "status": "Processing", + "budget": request.budget, + "total_cost": total_cost, + "item_count": len(request.items), + "items": [item.model_dump() for item in request.items], + "created_date": created.strftime("%Y-%m-%dT%H:%M:%S"), + "expected_delivery": expected_delivery.strftime("%Y-%m-%dT%H:%M:%S"), + "lead_time_days": RESTOCK_LEAD_TIME_DAYS + } + + restock_orders.append(order) + return order + +@app.get("/api/restock-orders", response_model=List[RestockOrder]) +def get_restock_orders(): + """Get submitted restocking orders, newest first""" + return list(reversed(restock_orders)) + if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8001)