Files
TimeTracker/app/services/inventory_report_service.py
T
Dries Peeters 16edb71a33 feat(client-portal): activity feed, report service, dashboard widgets and preferences
- Add ClientActivityFeedService and ClientReportService; update approval and notification services
- Add inventory report service updates
- Client portal routes: dashboard preferences (widget order/visibility), activity feed, reports
- Templates: dashboard, activity_feed, reports, base; add widgets (invoices, pending_actions, projects, stats, time_entries)
2026-03-16 15:15:28 +01:00

448 lines
18 KiB
Python

"""
Service for inventory reports and analytics.
"""
from collections import defaultdict
from datetime import datetime, timedelta
from decimal import Decimal
from typing import Any, Dict, List, Optional
from sqlalchemy import and_, func
from sqlalchemy.orm import joinedload
from app import db
from app.models import StockItem, StockLot, StockMovement, Warehouse, WarehouseStock
class InventoryReportService:
"""
Service for inventory reporting and analytics.
Provides methods for:
- Stock valuation calculations
- Inventory turnover analysis
- Movement history reports
- Low stock reports
"""
def get_stock_valuation(
self, warehouse_id: Optional[int] = None, category: Optional[str] = None, currency_code: Optional[str] = None
) -> Dict[str, Any]:
"""
Calculate total stock valuation.
Args:
warehouse_id: Filter by specific warehouse (None for all)
category: Filter by stock item category (None for all)
currency_code: Filter by currency (None for all)
Returns:
dict with valuation data including:
- total_value: Total inventory value
- by_warehouse: Value breakdown by warehouse
- by_category: Value breakdown by category
- item_details: Detailed item-level valuation
"""
# Prefer lot-based valuation when lots exist; fallback to default_cost * WarehouseStock otherwise.
# Lots unlock devalued returns/waste without creating new stock items.
lots_exist = False
try:
lots_exist = db.session.query(StockLot.id).limit(1).scalar() is not None
except Exception:
lots_exist = False
total_value = Decimal("0")
by_warehouse = {}
by_category = {}
item_details = []
if lots_exist:
lot_query = (
db.session.query(StockLot, StockItem, Warehouse)
.join(StockItem, StockLot.stock_item_id == StockItem.id)
.join(Warehouse, StockLot.warehouse_id == Warehouse.id)
.filter(StockItem.is_active == True, StockItem.is_trackable == True, StockLot.quantity_on_hand > 0)
)
if warehouse_id:
lot_query = lot_query.filter(StockLot.warehouse_id == warehouse_id)
if category:
lot_query = lot_query.filter(StockItem.category == category)
if currency_code:
lot_query = lot_query.filter(StockItem.currency_code == currency_code)
results = lot_query.all()
# Aggregate per item+warehouse to stay compatible with templates.
agg = {} # (item_id, warehouse_id) -> dict
for lot, item, warehouse in results:
qty = Decimal(str(lot.quantity_on_hand or 0))
cost = Decimal(str(lot.unit_cost or 0))
value = qty * cost
total_value += value
warehouse_key = f"{warehouse.name} ({warehouse.code})"
if warehouse_key not in by_warehouse:
by_warehouse[warehouse_key] = {
"warehouse_id": warehouse.id,
"warehouse_name": warehouse.name,
"warehouse_code": warehouse.code,
"value": Decimal("0"),
"currency": item.currency_code,
}
by_warehouse[warehouse_key]["value"] += value
cat = item.category or "Uncategorized"
if cat not in by_category:
by_category[cat] = {"category": cat, "value": Decimal("0"), "currency": item.currency_code}
by_category[cat]["value"] += value
key = (item.id, warehouse.id)
if key not in agg:
agg[key] = {
"item": item,
"warehouse": warehouse,
"total_qty": Decimal("0"),
"total_value": Decimal("0"),
}
agg[key]["total_qty"] += qty
agg[key]["total_value"] += value
for (item_id_k, warehouse_id_k), row in agg.items():
item = row["item"]
warehouse = row["warehouse"]
qty = row["total_qty"]
value = row["total_value"]
avg_cost = (value / qty) if qty > 0 else Decimal("0")
item_details.append(
{
"item_id": item.id,
"sku": item.sku,
"name": item.name,
"category": item.category,
"warehouse_id": warehouse.id,
"warehouse_name": warehouse.name,
"quantity": float(qty),
"cost": float(avg_cost),
"value": float(value),
"currency": item.currency_code,
}
)
else:
# Base query: join WarehouseStock with StockItem
query = (
db.session.query(WarehouseStock, StockItem, Warehouse)
.join(StockItem, WarehouseStock.stock_item_id == StockItem.id)
.join(Warehouse, WarehouseStock.warehouse_id == Warehouse.id)
.filter(
StockItem.is_active == True,
StockItem.is_trackable == True,
WarehouseStock.quantity_on_hand > 0,
)
)
# Apply filters
if warehouse_id:
query = query.filter(WarehouseStock.warehouse_id == warehouse_id)
if category:
query = query.filter(StockItem.category == category)
if currency_code:
query = query.filter(StockItem.currency_code == currency_code)
results = query.all()
for stock, item, warehouse in results:
cost = item.default_cost or Decimal("0")
quantity = Decimal(str(stock.quantity_on_hand or 0))
value = cost * quantity
total_value += value
warehouse_key = f"{warehouse.name} ({warehouse.code})"
if warehouse_key not in by_warehouse:
by_warehouse[warehouse_key] = {
"warehouse_id": warehouse.id,
"warehouse_name": warehouse.name,
"warehouse_code": warehouse.code,
"value": Decimal("0"),
"currency": item.currency_code,
}
by_warehouse[warehouse_key]["value"] += value
cat = item.category or "Uncategorized"
if cat not in by_category:
by_category[cat] = {"category": cat, "value": Decimal("0"), "currency": item.currency_code}
by_category[cat]["value"] += value
item_details.append(
{
"item_id": item.id,
"sku": item.sku,
"name": item.name,
"category": item.category,
"warehouse_id": warehouse.id,
"warehouse_name": warehouse.name,
"quantity": float(quantity),
"cost": float(cost),
"value": float(value),
"currency": item.currency_code,
}
)
return {
"total_value": float(total_value),
"by_warehouse": {k: {**v, "value": float(v["value"])} for k, v in by_warehouse.items()},
"by_category": {k: {**v, "value": float(v["value"])} for k, v in by_category.items()},
"item_details": item_details,
"currency": currency_code or "EUR",
"warehouse_id": warehouse_id,
"category": category,
}
def get_inventory_turnover(
self, start_date: Optional[datetime] = None, end_date: Optional[datetime] = None, item_id: Optional[int] = None
) -> Dict[str, Any]:
"""
Calculate inventory turnover analysis.
Args:
start_date: Start of analysis period
end_date: End of analysis period
item_id: Specific item to analyze (None for all)
Returns:
dict with turnover data
"""
if not start_date:
start_date = datetime.utcnow() - timedelta(days=365)
if not end_date:
end_date = datetime.utcnow()
# Get movements in the period
query = StockMovement.query.filter(
StockMovement.moved_at >= start_date,
StockMovement.moved_at <= end_date,
StockMovement.movement_type.in_(["sale", "usage", "consumption"]),
)
if item_id:
query = query.filter(StockMovement.stock_item_id == item_id)
movements = query.all()
# Aggregate by item
item_turnover = {}
for movement in movements:
item_id = movement.stock_item_id
if item_id not in item_turnover:
item = StockItem.query.get(item_id)
if not item:
continue
# Get average stock level during period
avg_stock = self._calculate_average_stock(item_id, start_date, end_date)
item_turnover[item_id] = {
"item_id": item_id,
"sku": item.sku,
"name": item.name,
"quantity_sold": Decimal("0"),
"avg_stock": avg_stock,
"turnover_rate": Decimal("0"),
"days_on_hand": Decimal("0"),
}
item_turnover[item_id]["quantity_sold"] += abs(movement.quantity)
# Calculate turnover rates
for item_id, data in item_turnover.items():
if data["avg_stock"] > 0:
days = (end_date - start_date).days
data["turnover_rate"] = data["quantity_sold"] / data["avg_stock"] if days > 0 else Decimal("0")
data["days_on_hand"] = days / data["turnover_rate"] if data["turnover_rate"] > 0 else Decimal("0")
else:
data["turnover_rate"] = Decimal("0")
data["days_on_hand"] = Decimal("0")
# Convert to float for JSON serialization
data["quantity_sold"] = float(data["quantity_sold"])
data["avg_stock"] = float(data["avg_stock"])
data["turnover_rate"] = float(data["turnover_rate"])
data["days_on_hand"] = float(data["days_on_hand"])
return {
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
"items": list(item_turnover.values()),
}
def _calculate_average_stock(self, item_id: int, start_date: datetime, end_date: datetime) -> Decimal:
"""Calculate average stock level for an item during a period."""
# Get stock levels at start and end
start_stock = self._get_stock_at_date(item_id, start_date)
end_stock = self._get_stock_at_date(item_id, end_date)
# Simple average (can be enhanced with more data points)
return (start_stock + end_stock) / 2
def _get_stock_at_date(self, item_id: int, date: datetime) -> Decimal:
"""Get stock level for an item at a specific date."""
# Get the most recent movement before or at the date
movement = (
StockMovement.query.filter(StockMovement.stock_item_id == item_id, StockMovement.moved_at <= date)
.order_by(StockMovement.moved_at.desc())
.first()
)
if movement:
# Get stock after this movement
# This is simplified - in reality, we'd need to track historical stock levels
stock = WarehouseStock.query.filter_by(stock_item_id=item_id).first()
return stock.quantity_on_hand if stock else Decimal("0")
# No movements, get current stock
stock = WarehouseStock.query.filter_by(stock_item_id=item_id).first()
return stock.quantity_on_hand if stock else Decimal("0")
def get_movement_history(
self,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
item_id: Optional[int] = None,
warehouse_id: Optional[int] = None,
movement_type: Optional[str] = None,
page: Optional[int] = None,
per_page: Optional[int] = None,
) -> Dict[str, Any]:
"""
Get detailed movement history.
Args:
start_date: Filter movements on or after this date.
end_date: Filter movements on or before this date.
item_id: Filter by stock item.
warehouse_id: Filter by warehouse.
movement_type: Filter by movement type.
page: If set with per_page, return paginated results.
per_page: Page size when paginating.
Returns:
dict with movements list, total_movements, and optionally pagination.
"""
query = StockMovement.query
if start_date:
query = query.filter(StockMovement.moved_at >= start_date)
if end_date:
query = query.filter(StockMovement.moved_at <= end_date)
if item_id:
query = query.filter(StockMovement.stock_item_id == item_id)
if warehouse_id:
query = query.filter(StockMovement.warehouse_id == warehouse_id)
if movement_type:
query = query.filter(StockMovement.movement_type == movement_type)
query = query.order_by(StockMovement.moved_at.desc())
if page is not None and per_page is not None:
per_page = min(per_page, 100)
paginated = query.paginate(page=page, per_page=per_page, error_out=False)
movements = paginated.items
total = paginated.total
pagination = {
"page": paginated.page,
"per_page": paginated.per_page,
"total": paginated.total,
"pages": paginated.pages,
"has_next": paginated.has_next,
"has_prev": paginated.has_prev,
"next_page": paginated.page + 1 if paginated.has_next else None,
"prev_page": paginated.page - 1 if paginated.has_prev else None,
}
else:
movements = query.all()
total = len(movements)
pagination = None
def _ref(m):
parts = [m.reference_type or "", str(m.reference_id) if m.reference_id is not None else ""]
s = "#".join(p for p in parts if p).strip("#") or None
return s
result = {
"movements": [
{
"id": m.id,
"date": m.moved_at.isoformat() if m.moved_at else None,
"item_id": m.stock_item_id,
"item_sku": m.stock_item.sku if m.stock_item else None,
"item_name": m.stock_item.name if m.stock_item else None,
"warehouse_id": m.warehouse_id,
"warehouse_name": m.warehouse.name if m.warehouse else None,
"quantity": float(m.quantity),
"type": m.movement_type,
"reference": _ref(m),
"notes": m.notes,
}
for m in movements
],
"total_movements": total,
}
if pagination is not None:
result["pagination"] = pagination
return result
def get_low_stock(self, warehouse_id: Optional[int] = None) -> Dict[str, Any]:
"""
Get items below reorder point per warehouse.
Args:
warehouse_id: If set, only return low-stock rows for this warehouse.
Returns:
dict with "items" list; each element has item/warehouse info and numeric fields as float.
"""
items = StockItem.query.filter_by(is_active=True, is_trackable=True).all()
items_with_reorder = [i for i in items if i.reorder_point]
item_ids = [i.id for i in items_with_reorder]
low_stock_items: List[Dict[str, Any]] = []
if not item_ids:
return {"items": low_stock_items}
query = WarehouseStock.query.options(joinedload(WarehouseStock.warehouse)).filter(
WarehouseStock.stock_item_id.in_(item_ids)
)
if warehouse_id is not None:
query = query.filter(WarehouseStock.warehouse_id == warehouse_id)
all_stock = query.all()
stock_by_item = defaultdict(list)
for s in all_stock:
stock_by_item[s.stock_item_id].append(s)
for item in items_with_reorder:
for stock in stock_by_item.get(item.id, []):
if stock.quantity_on_hand < item.reorder_point:
reorder_pt = item.reorder_point
reorder_qty = item.reorder_quantity or Decimal("0")
shortfall = reorder_pt - stock.quantity_on_hand
low_stock_items.append(
{
"item_id": item.id,
"item_sku": item.sku,
"item_name": item.name,
"warehouse_id": stock.warehouse_id,
"warehouse_code": stock.warehouse.code if stock.warehouse else None,
"warehouse_name": stock.warehouse.name if stock.warehouse else None,
"quantity_on_hand": float(stock.quantity_on_hand),
"reorder_point": float(reorder_pt),
"reorder_quantity": float(reorder_qty),
"shortfall": float(shortfall),
}
)
return {"items": low_stock_items}