New alert system and panel de control page

This commit is contained in:
Urtzi Alfaro
2025-11-27 15:52:40 +01:00
parent 1a2f4602f3
commit e902419b6e
178 changed files with 20982 additions and 6944 deletions

View File

@@ -35,6 +35,13 @@ from .food_safety import (
FoodSafetyAlertType,
)
from .stock_receipt import (
StockReceipt,
StockReceiptLineItem,
StockLot,
ReceiptStatus,
)
# List all models for easier access
__all__ = [
# Inventory models
@@ -58,5 +65,10 @@ __all__ = [
"FoodSafetyStandard",
"ComplianceStatus",
"FoodSafetyAlertType",
# Stock receipt models
"StockReceipt",
"StockReceiptLineItem",
"StockLot",
"ReceiptStatus",
"AuditLog",
]

View File

@@ -0,0 +1,233 @@
"""
Stock Receipt Models for Inventory Service
Lot-level tracking for deliveries with expiration dates
"""
from sqlalchemy import Column, String, DateTime, Float, Integer, Text, Index, Boolean, Numeric, ForeignKey, Enum as SQLEnum, Date
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import relationship
import uuid
import enum
from datetime import datetime, timezone, date
from typing import Dict, Any, Optional, List
from decimal import Decimal
from shared.database.base import Base
class ReceiptStatus(enum.Enum):
"""Stock receipt status values"""
DRAFT = "draft"
CONFIRMED = "confirmed"
CANCELLED = "cancelled"
class StockReceipt(Base):
"""
Stock receipt tracking for purchase order deliveries
Captures lot-level details and expiration dates
"""
__tablename__ = "stock_receipts"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# Purchase order reference
po_id = Column(UUID(as_uuid=True), nullable=False, index=True)
po_number = Column(String(100), nullable=True) # Denormalized for quick reference
# Receipt details
received_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
received_by_user_id = Column(UUID(as_uuid=True), nullable=True)
# Status
status = Column(
SQLEnum(ReceiptStatus, name='receiptstatus', create_type=True),
nullable=False,
default=ReceiptStatus.DRAFT,
index=True
)
# Supplier information (denormalized)
supplier_id = Column(UUID(as_uuid=True), nullable=True, index=True)
supplier_name = Column(String(255), nullable=True)
# Overall notes
notes = Column(Text, nullable=True)
has_discrepancies = Column(Boolean, default=False, nullable=False)
# Timestamps
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
updated_at = Column(DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc))
confirmed_at = Column(DateTime(timezone=True), nullable=True)
# Relationships
line_items = relationship("StockReceiptLineItem", back_populates="receipt", cascade="all, delete-orphan")
__table_args__ = (
Index('idx_stock_receipts_tenant_status', 'tenant_id', 'status'),
Index('idx_stock_receipts_po', 'po_id'),
Index('idx_stock_receipts_received_at', 'tenant_id', 'received_at'),
Index('idx_stock_receipts_supplier', 'supplier_id', 'received_at'),
)
def to_dict(self) -> Dict[str, Any]:
"""Convert model to dictionary for API responses"""
return {
'id': str(self.id),
'tenant_id': str(self.tenant_id),
'po_id': str(self.po_id),
'po_number': self.po_number,
'received_at': self.received_at.isoformat() if self.received_at else None,
'received_by_user_id': str(self.received_by_user_id) if self.received_by_user_id else None,
'status': self.status.value if isinstance(self.status, enum.Enum) else self.status,
'supplier_id': str(self.supplier_id) if self.supplier_id else None,
'supplier_name': self.supplier_name,
'notes': self.notes,
'has_discrepancies': self.has_discrepancies,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
'confirmed_at': self.confirmed_at.isoformat() if self.confirmed_at else None,
'line_items': [item.to_dict() for item in self.line_items] if self.line_items else [],
}
class StockReceiptLineItem(Base):
"""
Individual line items in a stock receipt
One line item per product, with multiple lots possible
"""
__tablename__ = "stock_receipt_line_items"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
receipt_id = Column(UUID(as_uuid=True), ForeignKey('stock_receipts.id', ondelete='CASCADE'), nullable=False, index=True)
# Product information
ingredient_id = Column(UUID(as_uuid=True), nullable=False, index=True)
ingredient_name = Column(String(255), nullable=True) # Denormalized
# PO line reference
po_line_id = Column(UUID(as_uuid=True), nullable=True)
# Quantities
expected_quantity = Column(Numeric(10, 2), nullable=False)
actual_quantity = Column(Numeric(10, 2), nullable=False)
unit_of_measure = Column(String(20), nullable=False)
# Discrepancy tracking
has_discrepancy = Column(Boolean, default=False, nullable=False)
discrepancy_reason = Column(Text, nullable=True)
# Cost tracking
unit_cost = Column(Numeric(10, 2), nullable=True)
total_cost = Column(Numeric(10, 2), nullable=True)
# Timestamps
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc))
# Relationships
receipt = relationship("StockReceipt", back_populates="line_items")
lots = relationship("StockLot", back_populates="line_item", cascade="all, delete-orphan")
__table_args__ = (
Index('idx_line_items_receipt', 'receipt_id'),
Index('idx_line_items_ingredient', 'ingredient_id'),
Index('idx_line_items_discrepancy', 'tenant_id', 'has_discrepancy'),
)
def to_dict(self) -> Dict[str, Any]:
"""Convert model to dictionary for API responses"""
return {
'id': str(self.id),
'tenant_id': str(self.tenant_id),
'receipt_id': str(self.receipt_id),
'ingredient_id': str(self.ingredient_id),
'ingredient_name': self.ingredient_name,
'po_line_id': str(self.po_line_id) if self.po_line_id else None,
'expected_quantity': float(self.expected_quantity) if self.expected_quantity else None,
'actual_quantity': float(self.actual_quantity) if self.actual_quantity else None,
'unit_of_measure': self.unit_of_measure,
'has_discrepancy': self.has_discrepancy,
'discrepancy_reason': self.discrepancy_reason,
'unit_cost': float(self.unit_cost) if self.unit_cost else None,
'total_cost': float(self.total_cost) if self.total_cost else None,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
'lots': [lot.to_dict() for lot in self.lots] if self.lots else [],
}
class StockLot(Base):
"""
Individual lots within a line item
Critical for tracking expiration dates when deliveries are split
"""
__tablename__ = "stock_lots"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
line_item_id = Column(UUID(as_uuid=True), ForeignKey('stock_receipt_line_items.id', ondelete='CASCADE'), nullable=False, index=True)
# Links to stock table (created on confirmation)
stock_id = Column(UUID(as_uuid=True), nullable=True, index=True)
# Lot identification
lot_number = Column(String(100), nullable=True)
supplier_lot_number = Column(String(100), nullable=True)
# Quantity for this lot
quantity = Column(Numeric(10, 2), nullable=False)
unit_of_measure = Column(String(20), nullable=False)
# Critical: Expiration tracking
expiration_date = Column(Date, nullable=False, index=True)
best_before_date = Column(Date, nullable=True)
# Storage location
warehouse_location = Column(String(100), nullable=True)
storage_zone = Column(String(50), nullable=True)
# Quality notes
quality_notes = Column(Text, nullable=True)
# Timestamps
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc))
# Relationships
line_item = relationship("StockReceiptLineItem", back_populates="lots")
__table_args__ = (
Index('idx_lots_line_item', 'line_item_id'),
Index('idx_lots_stock', 'stock_id'),
Index('idx_lots_expiration', 'tenant_id', 'expiration_date'),
Index('idx_lots_lot_number', 'tenant_id', 'lot_number'),
)
def to_dict(self) -> Dict[str, Any]:
"""Convert model to dictionary for API responses"""
return {
'id': str(self.id),
'tenant_id': str(self.tenant_id),
'line_item_id': str(self.line_item_id),
'stock_id': str(self.stock_id) if self.stock_id else None,
'lot_number': self.lot_number,
'supplier_lot_number': self.supplier_lot_number,
'quantity': float(self.quantity) if self.quantity else None,
'unit_of_measure': self.unit_of_measure,
'expiration_date': self.expiration_date.isoformat() if self.expiration_date else None,
'best_before_date': self.best_before_date.isoformat() if self.best_before_date else None,
'warehouse_location': self.warehouse_location,
'storage_zone': self.storage_zone,
'quality_notes': self.quality_notes,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
}