234 lines
9.7 KiB
Python
234 lines
9.7 KiB
Python
|
|
"""
|
||
|
|
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,
|
||
|
|
}
|