""" 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, }