mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-24 07:10:21 -05:00
16edb71a33
- 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)
448 lines
18 KiB
Python
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}
|