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

@@ -46,6 +46,7 @@ async def clone_demo_data(
virtual_tenant_id: str,
demo_account_type: str,
session_id: Optional[str] = None,
session_created_at: Optional[str] = None,
db: AsyncSession = Depends(get_db),
_: bool = Depends(verify_internal_api_key)
):
@@ -62,12 +63,27 @@ async def clone_demo_data(
virtual_tenant_id: Target virtual tenant UUID
demo_account_type: Type of demo account
session_id: Originating session ID for tracing
session_created_at: ISO timestamp when demo session was created (for date adjustment)
Returns:
Cloning status and record counts
"""
start_time = datetime.now(timezone.utc)
session_created_at = datetime.now(timezone.utc)
# Parse session_created_at or fallback to now
if session_created_at:
try:
session_time = datetime.fromisoformat(session_created_at.replace('Z', '+00:00'))
except (ValueError, AttributeError) as e:
logger.warning(
"Invalid session_created_at format, using current time",
session_created_at=session_created_at,
error=str(e)
)
session_time = datetime.now(timezone.utc)
else:
logger.warning("session_created_at not provided, using current time")
session_time = datetime.now(timezone.utc)
logger.info(
"Starting inventory data cloning with date adjustment",
@@ -75,7 +91,7 @@ async def clone_demo_data(
virtual_tenant_id=virtual_tenant_id,
demo_account_type=demo_account_type,
session_id=session_id,
session_created_at=session_created_at.isoformat()
session_time=session_time.isoformat()
)
try:
@@ -228,24 +244,24 @@ async def clone_demo_data(
# Adjust dates relative to session creation
adjusted_expiration = adjust_date_for_demo(
stock.expiration_date,
session_created_at,
session_time,
BASE_REFERENCE_DATE
)
adjusted_received = adjust_date_for_demo(
stock.received_date,
session_created_at,
session_time,
BASE_REFERENCE_DATE
)
adjusted_best_before = adjust_date_for_demo(
stock.best_before_date,
session_created_at,
session_time,
BASE_REFERENCE_DATE
)
adjusted_created = adjust_date_for_demo(
stock.created_at,
session_created_at,
session_time,
BASE_REFERENCE_DATE
) or session_created_at
) or session_time
# Create new stock batch with new ID
new_stock_id = uuid.uuid4()
@@ -281,7 +297,7 @@ async def clone_demo_data(
is_expired=stock.is_expired,
quality_status=stock.quality_status,
created_at=adjusted_created,
updated_at=session_created_at
updated_at=session_time
)
db.add(new_stock)
stats["stock_batches"] += 1
@@ -319,15 +335,15 @@ async def clone_demo_data(
# Adjust movement date relative to session creation
adjusted_movement_date = adjust_date_for_demo(
movement.movement_date,
session_created_at,
session_time,
BASE_REFERENCE_DATE
) or session_created_at
) or session_time
adjusted_created_at = adjust_date_for_demo(
movement.created_at,
session_created_at,
session_time,
BASE_REFERENCE_DATE
) or session_created_at
) or session_time
# Create new stock movement
new_movement = StockMovement(

View File

@@ -0,0 +1,459 @@
"""
Stock Receipt API Endpoints
Handles delivery receipt confirmation with lot-level tracking.
Critical for food safety compliance - captures expiration dates per lot.
"""
from fastapi import APIRouter, HTTPException, Depends, status
from pydantic import BaseModel, Field, validator
from typing import List, Optional
from uuid import UUID
from datetime import datetime, date
from decimal import Decimal
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.stock_receipt import StockReceipt, StockReceiptLineItem, StockLot, ReceiptStatus
from app.models.inventory import Stock, StockMovement, StockMovementType
from shared.database.dependencies import get_db
from shared.security import get_current_user
logger = structlog.get_logger()
router = APIRouter(prefix="/stock-receipts", tags=["stock-receipts"])
# ============================================================
# Request/Response Models
# ============================================================
class LotInput(BaseModel):
"""Individual lot details within a line item"""
lot_number: Optional[str] = None
supplier_lot_number: Optional[str] = None
quantity: Decimal = Field(..., gt=0)
unit_of_measure: str
expiration_date: date = Field(..., description="Required for food safety")
best_before_date: Optional[date] = None
warehouse_location: Optional[str] = None
storage_zone: Optional[str] = None
quality_notes: Optional[str] = None
class LineItemInput(BaseModel):
"""Line item input for stock receipt"""
ingredient_id: UUID
ingredient_name: Optional[str] = None
po_line_id: Optional[UUID] = None
expected_quantity: Decimal
actual_quantity: Decimal
unit_of_measure: str
discrepancy_reason: Optional[str] = None
unit_cost: Optional[Decimal] = None
lots: List[LotInput] = Field(..., min_items=1, description="At least one lot required")
@validator('lots')
def validate_lot_totals(cls, lots, values):
"""Ensure lot quantities sum to actual quantity"""
if 'actual_quantity' not in values:
return lots
total_lot_qty = sum(lot.quantity for lot in lots)
actual_qty = values['actual_quantity']
if abs(total_lot_qty - actual_qty) > Decimal('0.01'): # Allow small floating point errors
raise ValueError(
f"Lot quantities ({total_lot_qty}) must sum to actual quantity ({actual_qty})"
)
return lots
class CreateStockReceiptRequest(BaseModel):
"""Create draft stock receipt"""
tenant_id: UUID
po_id: UUID
po_number: Optional[str] = None
received_by_user_id: UUID
supplier_id: Optional[UUID] = None
supplier_name: Optional[str] = None
notes: Optional[str] = None
line_items: List[LineItemInput] = Field(..., min_items=1)
class UpdateStockReceiptRequest(BaseModel):
"""Update draft stock receipt"""
notes: Optional[str] = None
line_items: Optional[List[LineItemInput]] = None
class ConfirmStockReceiptRequest(BaseModel):
"""Confirm stock receipt and update inventory"""
confirmed_by_user_id: UUID
# ============================================================
# API Endpoints
# ============================================================
@router.post("/", status_code=status.HTTP_201_CREATED)
async def create_stock_receipt(
request: CreateStockReceiptRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Create a draft stock receipt from a delivery.
Workflow:
1. User clicks "Mark as Received" on delivery alert
2. This endpoint creates draft receipt
3. Frontend opens StockReceiptModal with draft data
4. User fills in lot details
5. User saves draft (PUT endpoint) or confirms (POST /confirm)
"""
try:
# Create receipt
receipt = StockReceipt(
tenant_id=request.tenant_id,
po_id=request.po_id,
po_number=request.po_number,
received_at=datetime.utcnow(),
received_by_user_id=request.received_by_user_id,
status=ReceiptStatus.DRAFT,
supplier_id=request.supplier_id,
supplier_name=request.supplier_name,
notes=request.notes,
has_discrepancies=False
)
db.add(receipt)
await db.flush() # Get receipt ID
# Create line items and lots
for line_input in request.line_items:
has_discrepancy = abs(line_input.expected_quantity - line_input.actual_quantity) > Decimal('0.01')
if has_discrepancy:
receipt.has_discrepancies = True
line_item = StockReceiptLineItem(
tenant_id=request.tenant_id,
receipt_id=receipt.id,
ingredient_id=line_input.ingredient_id,
ingredient_name=line_input.ingredient_name,
po_line_id=line_input.po_line_id,
expected_quantity=line_input.expected_quantity,
actual_quantity=line_input.actual_quantity,
unit_of_measure=line_input.unit_of_measure,
has_discrepancy=has_discrepancy,
discrepancy_reason=line_input.discrepancy_reason,
unit_cost=line_input.unit_cost,
total_cost=line_input.unit_cost * line_input.actual_quantity if line_input.unit_cost else None
)
db.add(line_item)
await db.flush() # Get line item ID
# Create lots
for lot_input in line_input.lots:
lot = StockLot(
tenant_id=request.tenant_id,
line_item_id=line_item.id,
lot_number=lot_input.lot_number,
supplier_lot_number=lot_input.supplier_lot_number,
quantity=lot_input.quantity,
unit_of_measure=lot_input.unit_of_measure,
expiration_date=lot_input.expiration_date,
best_before_date=lot_input.best_before_date,
warehouse_location=lot_input.warehouse_location,
storage_zone=lot_input.storage_zone,
quality_notes=lot_input.quality_notes
)
db.add(lot)
await db.commit()
await db.refresh(receipt)
logger.info(
"Stock receipt created",
receipt_id=str(receipt.id),
po_id=str(request.po_id),
line_items=len(request.line_items),
tenant_id=str(request.tenant_id)
)
return receipt.to_dict()
except Exception as e:
await db.rollback()
logger.error(
"Failed to create stock receipt",
error=str(e),
po_id=str(request.po_id)
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create stock receipt: {str(e)}"
)
@router.get("/{receipt_id}")
async def get_stock_receipt(
receipt_id: UUID,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Retrieve stock receipt with all line items and lots.
Used to resume editing a draft receipt.
"""
try:
stmt = select(StockReceipt).where(
StockReceipt.id == receipt_id,
StockReceipt.tenant_id == current_user['tenant_id']
)
result = await db.execute(stmt)
receipt = result.scalar_one_or_none()
if not receipt:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Stock receipt not found"
)
return receipt.to_dict()
except HTTPException:
raise
except Exception as e:
logger.error(
"Failed to retrieve stock receipt",
receipt_id=str(receipt_id),
error=str(e)
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to retrieve stock receipt: {str(e)}"
)
@router.put("/{receipt_id}")
async def update_stock_receipt(
receipt_id: UUID,
request: UpdateStockReceiptRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Update draft stock receipt.
Allows user to save progress while filling in lot details.
"""
try:
stmt = select(StockReceipt).where(
StockReceipt.id == receipt_id,
StockReceipt.tenant_id == current_user['tenant_id'],
StockReceipt.status == ReceiptStatus.DRAFT
)
result = await db.execute(stmt)
receipt = result.scalar_one_or_none()
if not receipt:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Draft stock receipt not found"
)
# Update notes if provided
if request.notes is not None:
receipt.notes = request.notes
# Update line items if provided
if request.line_items:
# Delete existing line items (cascade deletes lots)
for line_item in receipt.line_items:
await db.delete(line_item)
# Create new line items
for line_input in request.line_items:
has_discrepancy = abs(line_input.expected_quantity - line_input.actual_quantity) > Decimal('0.01')
line_item = StockReceiptLineItem(
tenant_id=current_user['tenant_id'],
receipt_id=receipt.id,
ingredient_id=line_input.ingredient_id,
ingredient_name=line_input.ingredient_name,
po_line_id=line_input.po_line_id,
expected_quantity=line_input.expected_quantity,
actual_quantity=line_input.actual_quantity,
unit_of_measure=line_input.unit_of_measure,
has_discrepancy=has_discrepancy,
discrepancy_reason=line_input.discrepancy_reason,
unit_cost=line_input.unit_cost,
total_cost=line_input.unit_cost * line_input.actual_quantity if line_input.unit_cost else None
)
db.add(line_item)
await db.flush()
# Create lots
for lot_input in line_input.lots:
lot = StockLot(
tenant_id=current_user['tenant_id'],
line_item_id=line_item.id,
lot_number=lot_input.lot_number,
supplier_lot_number=lot_input.supplier_lot_number,
quantity=lot_input.quantity,
unit_of_measure=lot_input.unit_of_measure,
expiration_date=lot_input.expiration_date,
best_before_date=lot_input.best_before_date,
warehouse_location=lot_input.warehouse_location,
storage_zone=lot_input.storage_zone,
quality_notes=lot_input.quality_notes
)
db.add(lot)
await db.commit()
await db.refresh(receipt)
logger.info(
"Stock receipt updated",
receipt_id=str(receipt_id),
tenant_id=str(current_user['tenant_id'])
)
return receipt.to_dict()
except HTTPException:
raise
except Exception as e:
await db.rollback()
logger.error(
"Failed to update stock receipt",
receipt_id=str(receipt_id),
error=str(e)
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to update stock receipt: {str(e)}"
)
@router.post("/{receipt_id}/confirm")
async def confirm_stock_receipt(
receipt_id: UUID,
request: ConfirmStockReceiptRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Confirm stock receipt and update inventory.
This finalizes the receipt:
1. Creates Stock records for each lot
2. Creates StockMovement records (PURCHASE type)
3. Marks receipt as CONFIRMED
4. Updates PO status to RECEIVED (via procurement service)
"""
try:
stmt = select(StockReceipt).where(
StockReceipt.id == receipt_id,
StockReceipt.tenant_id == current_user['tenant_id'],
StockReceipt.status == ReceiptStatus.DRAFT
)
result = await db.execute(stmt)
receipt = result.scalar_one_or_none()
if not receipt:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Draft stock receipt not found"
)
# Process each line item and its lots
for line_item in receipt.line_items:
for lot in line_item.lots:
# Create Stock record
stock = Stock(
tenant_id=current_user['tenant_id'],
ingredient_id=line_item.ingredient_id,
supplier_id=receipt.supplier_id,
batch_number=f"RCV-{receipt_id}-{lot.id}",
lot_number=lot.lot_number,
supplier_batch_ref=lot.supplier_lot_number,
production_stage='raw_ingredient',
current_quantity=float(lot.quantity),
reserved_quantity=0.0,
available_quantity=float(lot.quantity),
received_date=receipt.received_at,
expiration_date=datetime.combine(lot.expiration_date, datetime.min.time()),
best_before_date=datetime.combine(lot.best_before_date, datetime.min.time()) if lot.best_before_date else None,
unit_cost=line_item.unit_cost,
total_cost=line_item.unit_cost * lot.quantity if line_item.unit_cost else None,
storage_location=lot.warehouse_location,
warehouse_zone=lot.storage_zone,
is_available=True,
is_expired=False,
quality_status="good"
)
db.add(stock)
await db.flush()
# Link lot to stock
lot.stock_id = stock.id
# Create StockMovement record
movement = StockMovement(
tenant_id=current_user['tenant_id'],
ingredient_id=line_item.ingredient_id,
stock_id=stock.id,
movement_type=StockMovementType.PURCHASE,
quantity=float(lot.quantity),
unit_cost=line_item.unit_cost,
total_cost=line_item.unit_cost * lot.quantity if line_item.unit_cost else None,
quantity_before=0.0,
quantity_after=float(lot.quantity),
reference_number=receipt.po_number,
supplier_id=receipt.supplier_id,
notes=f"Stock receipt {receipt_id}",
movement_date=receipt.received_at
)
db.add(movement)
# Mark receipt as confirmed
receipt.status = ReceiptStatus.CONFIRMED
receipt.confirmed_at = datetime.utcnow()
await db.commit()
logger.info(
"Stock receipt confirmed",
receipt_id=str(receipt_id),
po_id=str(receipt.po_id),
tenant_id=str(current_user['tenant_id'])
)
return {
"status": "success",
"receipt_id": str(receipt_id),
"message": "Stock receipt confirmed and inventory updated"
}
except HTTPException:
raise
except Exception as e:
await db.rollback()
logger.error(
"Failed to confirm stock receipt",
receipt_id=str(receipt_id),
error=str(e)
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to confirm stock receipt: {str(e)}"
)

View File

@@ -36,7 +36,7 @@ from app.api import (
class InventoryService(StandardFastAPIService):
"""Inventory Service with standardized setup"""
expected_migration_version = "make_stock_fields_nullable"
expected_migration_version = "20251123_unified_initial_schema"
async def verify_migrations(self):
"""Verify database schema matches the latest migrations."""

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

View File

@@ -16,7 +16,6 @@ from apscheduler.triggers.cron import CronTrigger
from sqlalchemy import text
from shared.alerts.base_service import BaseAlertService, AlertServiceMixin
from shared.alerts.templates import format_item_message
from app.repositories.stock_repository import StockRepository
from app.repositories.stock_movement_repository import StockMovementRepository
from app.repositories.inventory_alert_repository import InventoryAlertRepository
@@ -122,78 +121,73 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin):
self._errors_count += 1
async def _process_stock_issue(self, tenant_id: UUID, issue: Dict[str, Any]):
"""Process individual stock issue"""
"""Process individual stock issue - sends raw data, enrichment generates contextual message"""
try:
if issue['status'] == 'critical':
# Critical stock shortage - immediate alert
template_data = self.format_spanish_message(
'critical_stock_shortage',
ingredient_name=issue["name"],
current_stock=issue["current_stock"],
required_stock=issue["tomorrow_needed"] or issue["minimum_stock"],
shortage_amount=issue["shortage_amount"]
)
# Critical stock shortage - send raw data for enrichment
await self.publish_item(tenant_id, {
'type': 'critical_stock_shortage',
'severity': 'urgent',
'title': template_data['title'],
'message': template_data['message'],
'actions': template_data['actions'],
'title': 'Raw Alert - Will be enriched', # Placeholder, will be replaced
'message': 'Raw Alert - Will be enriched', # Placeholder, will be replaced
'actions': [], # Will be generated during enrichment
'metadata': {
'ingredient_id': str(issue['id']),
'ingredient_name': issue["name"],
'current_stock': float(issue['current_stock']),
'minimum_stock': float(issue['minimum_stock']),
'required_stock': float(issue["tomorrow_needed"] or issue["minimum_stock"]),
'shortage_amount': float(issue['shortage_amount']),
'tomorrow_needed': float(issue['tomorrow_needed'] or 0),
'lead_time_days': issue['lead_time_days']
'lead_time_days': issue.get('lead_time_days'),
'supplier_name': issue.get('supplier_name'),
'supplier_phone': issue.get('supplier_phone'),
'hours_until_stockout': issue.get('hours_until_stockout')
}
}, item_type='alert')
elif issue['status'] == 'low':
# Low stock - high priority alert
template_data = self.format_spanish_message(
'critical_stock_shortage',
ingredient_name=issue["name"],
current_stock=issue["current_stock"],
required_stock=issue["minimum_stock"]
)
# Low stock - send raw data for enrichment
severity = self.get_business_hours_severity('high')
await self.publish_item(tenant_id, {
'type': 'low_stock_warning',
'severity': severity,
'title': f'⚠️ Stock Bajo: {issue["name"]}',
'message': f'Stock actual {issue["current_stock"]}kg, mínimo {issue["minimum_stock"]}kg. Considerar pedido pronto.',
'actions': ['Revisar consumo', 'Programar pedido', 'Contactar proveedor'],
'title': 'Raw Alert - Will be enriched',
'message': 'Raw Alert - Will be enriched',
'actions': [],
'metadata': {
'ingredient_id': str(issue['id']),
'ingredient_name': issue["name"],
'current_stock': float(issue['current_stock']),
'minimum_stock': float(issue['minimum_stock'])
'minimum_stock': float(issue['minimum_stock']),
'supplier_name': issue.get('supplier_name'),
'supplier_phone': issue.get('supplier_phone')
}
}, item_type='alert')
elif issue['status'] == 'overstock':
# Overstock - medium priority alert
# Overstock - send raw data for enrichment
severity = self.get_business_hours_severity('medium')
await self.publish_item(tenant_id, {
'type': 'overstock_warning',
'severity': severity,
'title': f'📦 Exceso de Stock: {issue["name"]}',
'message': f'Stock actual {issue["current_stock"]}kg excede máximo {issue["maximum_stock"]}kg. Revisar para evitar caducidad.',
'actions': ['Revisar caducidades', 'Aumentar producción', 'Ofertas especiales', 'Ajustar pedidos'],
'title': 'Raw Alert - Will be enriched',
'message': 'Raw Alert - Will be enriched',
'actions': [],
'metadata': {
'ingredient_id': str(issue['id']),
'ingredient_name': issue["name"],
'current_stock': float(issue['current_stock']),
'maximum_stock': float(issue['maximum_stock'])
'maximum_stock': float(issue.get('maximum_stock', 0)),
'waste_risk_kg': float(issue.get('waste_risk_kg', 0))
}
}, item_type='alert')
except Exception as e:
logger.error("Error processing stock issue",
ingredient_id=str(issue.get('id')),
logger.error("Error processing stock issue",
ingredient_id=str(issue.get('id')),
error=str(e))
async def check_expiring_products(self):

View File

@@ -0,0 +1,246 @@
"""
Inventory Notification Service
Emits informational notifications for inventory state changes:
- stock_received: When deliveries arrive
- stock_movement: Transfers, adjustments
- stock_updated: General stock updates
These are NOTIFICATIONS (not alerts) - informational state changes that don't require user action.
"""
import logging
from datetime import datetime, timezone
from typing import Optional, Dict, Any
from sqlalchemy.orm import Session
from shared.schemas.event_classification import RawEvent, EventClass, EventDomain
from shared.alerts.base_service import BaseAlertService
logger = logging.getLogger(__name__)
class InventoryNotificationService(BaseAlertService):
"""
Service for emitting inventory notifications (informational state changes).
"""
def __init__(self, rabbitmq_url: str = None):
super().__init__(service_name="inventory", rabbitmq_url=rabbitmq_url)
async def emit_stock_received_notification(
self,
db: Session,
tenant_id: str,
stock_receipt_id: str,
ingredient_id: str,
ingredient_name: str,
quantity_received: float,
unit: str,
supplier_name: Optional[str] = None,
delivery_id: Optional[str] = None,
) -> None:
"""
Emit notification when stock is received.
Args:
db: Database session
tenant_id: Tenant ID
stock_receipt_id: Stock receipt ID
ingredient_id: Ingredient ID
ingredient_name: Ingredient name
quantity_received: Quantity received
unit: Unit of measurement
supplier_name: Supplier name (optional)
delivery_id: Delivery ID (optional)
"""
try:
# Create notification event
event = RawEvent(
tenant_id=tenant_id,
event_class=EventClass.NOTIFICATION,
event_domain=EventDomain.INVENTORY,
event_type="stock_received",
title=f"Stock Received: {ingredient_name}",
message=f"Received {quantity_received} {unit} of {ingredient_name}"
+ (f" from {supplier_name}" if supplier_name else ""),
service="inventory",
event_metadata={
"stock_receipt_id": stock_receipt_id,
"ingredient_id": ingredient_id,
"ingredient_name": ingredient_name,
"quantity_received": quantity_received,
"unit": unit,
"supplier_name": supplier_name,
"delivery_id": delivery_id,
"received_at": datetime.now(timezone.utc).isoformat(),
},
timestamp=datetime.now(timezone.utc),
)
# Publish to RabbitMQ for processing
await self.publish_item(tenant_id, event.dict(), item_type="notification")
logger.info(
f"Stock received notification emitted: {ingredient_name} ({quantity_received} {unit})",
extra={"tenant_id": tenant_id, "ingredient_id": ingredient_id}
)
except Exception as e:
logger.error(
f"Failed to emit stock received notification: {e}",
extra={"tenant_id": tenant_id, "ingredient_id": ingredient_id},
exc_info=True,
)
async def emit_stock_movement_notification(
self,
db: Session,
tenant_id: str,
movement_id: str,
ingredient_id: str,
ingredient_name: str,
quantity: float,
unit: str,
movement_type: str, # 'transfer', 'adjustment', 'waste', 'return'
from_location: Optional[str] = None,
to_location: Optional[str] = None,
reason: Optional[str] = None,
) -> None:
"""
Emit notification for stock movements (transfers, adjustments, waste).
Args:
db: Database session
tenant_id: Tenant ID
movement_id: Movement ID
ingredient_id: Ingredient ID
ingredient_name: Ingredient name
quantity: Quantity moved
unit: Unit of measurement
movement_type: Type of movement
from_location: Source location (optional)
to_location: Destination location (optional)
reason: Reason for movement (optional)
"""
try:
# Build message based on movement type
if movement_type == "transfer":
message = f"Transferred {quantity} {unit} of {ingredient_name}"
if from_location and to_location:
message += f" from {from_location} to {to_location}"
elif movement_type == "adjustment":
message = f"Adjusted {ingredient_name} by {quantity} {unit}"
if reason:
message += f" - {reason}"
elif movement_type == "waste":
message = f"Waste recorded: {quantity} {unit} of {ingredient_name}"
if reason:
message += f" - {reason}"
elif movement_type == "return":
message = f"Returned {quantity} {unit} of {ingredient_name}"
else:
message = f"Stock movement: {quantity} {unit} of {ingredient_name}"
# Create notification event
event = RawEvent(
tenant_id=tenant_id,
event_class=EventClass.NOTIFICATION,
event_domain=EventDomain.INVENTORY,
event_type="stock_movement",
title=f"Stock {movement_type.title()}: {ingredient_name}",
message=message,
service="inventory",
event_metadata={
"movement_id": movement_id,
"ingredient_id": ingredient_id,
"ingredient_name": ingredient_name,
"quantity": quantity,
"unit": unit,
"movement_type": movement_type,
"from_location": from_location,
"to_location": to_location,
"reason": reason,
"moved_at": datetime.now(timezone.utc).isoformat(),
},
timestamp=datetime.now(timezone.utc),
)
# Publish to RabbitMQ
await self.publish_item(tenant_id, event.dict(), item_type="notification")
logger.info(
f"Stock movement notification emitted: {movement_type} - {ingredient_name}",
extra={"tenant_id": tenant_id, "movement_id": movement_id}
)
except Exception as e:
logger.error(
f"Failed to emit stock movement notification: {e}",
extra={"tenant_id": tenant_id, "movement_id": movement_id},
exc_info=True,
)
async def emit_stock_updated_notification(
self,
db: Session,
tenant_id: str,
ingredient_id: str,
ingredient_name: str,
old_quantity: float,
new_quantity: float,
unit: str,
update_reason: str,
) -> None:
"""
Emit notification when stock is updated.
Args:
db: Database session
tenant_id: Tenant ID
ingredient_id: Ingredient ID
ingredient_name: Ingredient name
old_quantity: Previous quantity
new_quantity: New quantity
unit: Unit of measurement
update_reason: Reason for update
"""
try:
quantity_change = new_quantity - old_quantity
change_direction = "increased" if quantity_change > 0 else "decreased"
event = RawEvent(
tenant_id=tenant_id,
event_class=EventClass.NOTIFICATION,
event_domain=EventDomain.INVENTORY,
event_type="stock_updated",
title=f"Stock Updated: {ingredient_name}",
message=f"Stock {change_direction} by {abs(quantity_change)} {unit} - {update_reason}",
service="inventory",
event_metadata={
"ingredient_id": ingredient_id,
"ingredient_name": ingredient_name,
"old_quantity": old_quantity,
"new_quantity": new_quantity,
"quantity_change": quantity_change,
"unit": unit,
"update_reason": update_reason,
"updated_at": datetime.now(timezone.utc).isoformat(),
},
timestamp=datetime.now(timezone.utc),
)
await self.publish_item(tenant_id, event.dict(), item_type="notification")
logger.info(
f"Stock updated notification emitted: {ingredient_name}",
extra={"tenant_id": tenant_id, "ingredient_id": ingredient_id}
)
except Exception as e:
logger.error(
f"Failed to emit stock updated notification: {e}",
extra={"tenant_id": tenant_id, "ingredient_id": ingredient_id},
exc_info=True,
)

View File

@@ -1,77 +0,0 @@
"""add_local_production_support
Revision ID: add_local_production_support
Revises: e7fcea67bf4e
Create Date: 2025-10-29 14:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = 'add_local_production_support'
down_revision = 'e7fcea67bf4e'
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Add local production support columns to ingredients table"""
# Add produced_locally column
op.add_column('ingredients', sa.Column(
'produced_locally',
sa.Boolean(),
nullable=False,
server_default='false',
comment='If true, ingredient is produced in-house and requires BOM explosion'
))
# Add recipe_id column for BOM explosion
op.add_column('ingredients', sa.Column(
'recipe_id',
postgresql.UUID(as_uuid=True),
nullable=True,
comment='Links to recipe for BOM explosion when ingredient is produced locally'
))
# Create index for efficient querying of locally-produced ingredients
op.create_index(
'ix_ingredients_produced_locally',
'ingredients',
['produced_locally'],
unique=False
)
# Create index for recipe_id lookups
op.create_index(
'ix_ingredients_recipe_id',
'ingredients',
['recipe_id'],
unique=False
)
# Add check constraint: if produced_locally is true, recipe_id should be set
# Note: This is a soft constraint - we allow NULL recipe_id even if produced_locally=true
# to support gradual data migration and edge cases
# op.create_check_constraint(
# 'ck_ingredients_local_production',
# 'ingredients',
# 'produced_locally = false OR recipe_id IS NOT NULL'
# )
def downgrade() -> None:
"""Remove local production support columns from ingredients table"""
# Drop check constraint
# op.drop_constraint('ck_ingredients_local_production', 'ingredients', type_='check')
# Drop indexes
op.drop_index('ix_ingredients_recipe_id', table_name='ingredients')
op.drop_index('ix_ingredients_produced_locally', table_name='ingredients')
# Drop columns
op.drop_column('ingredients', 'recipe_id')
op.drop_column('ingredients', 'produced_locally')

View File

@@ -1,84 +0,0 @@
"""make_stock_management_fields_nullable
Revision ID: make_stock_fields_nullable
Revises: add_local_production_support
Create Date: 2025-11-08 12:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'make_stock_fields_nullable'
down_revision = 'add_local_production_support'
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Make stock management fields nullable to simplify onboarding
These fields (low_stock_threshold, reorder_point, reorder_quantity) are now optional
during onboarding and can be configured later based on actual usage patterns.
"""
# Make low_stock_threshold nullable
op.alter_column('ingredients', 'low_stock_threshold',
existing_type=sa.Float(),
nullable=True,
existing_nullable=False)
# Make reorder_point nullable
op.alter_column('ingredients', 'reorder_point',
existing_type=sa.Float(),
nullable=True,
existing_nullable=False)
# Make reorder_quantity nullable
op.alter_column('ingredients', 'reorder_quantity',
existing_type=sa.Float(),
nullable=True,
existing_nullable=False)
def downgrade() -> None:
"""Revert stock management fields to NOT NULL
WARNING: This will fail if any records have NULL values in these fields.
You must set default values before running this downgrade.
"""
# Set default values for any NULL records before making fields NOT NULL
op.execute("""
UPDATE ingredients
SET low_stock_threshold = 10.0
WHERE low_stock_threshold IS NULL
""")
op.execute("""
UPDATE ingredients
SET reorder_point = 20.0
WHERE reorder_point IS NULL
""")
op.execute("""
UPDATE ingredients
SET reorder_quantity = 50.0
WHERE reorder_quantity IS NULL
""")
# Make fields NOT NULL again
op.alter_column('ingredients', 'low_stock_threshold',
existing_type=sa.Float(),
nullable=False,
existing_nullable=True)
op.alter_column('ingredients', 'reorder_point',
existing_type=sa.Float(),
nullable=False,
existing_nullable=True)
op.alter_column('ingredients', 'reorder_quantity',
existing_type=sa.Float(),
nullable=False,
existing_nullable=True)

View File

@@ -1,9 +1,14 @@
"""initial_schema_20251015_1229
"""Unified initial schema for inventory service
Revision ID: e7fcea67bf4e
Revises:
Create Date: 2025-10-15 12:29:40.991849+02:00
Revision ID: 20251123_unified_initial_schema
Revises:
Create Date: 2025-11-23
This unified migration combines all previous inventory migrations:
- Initial schema (e7fcea67bf4e)
- Local production support (add_local_production_support)
- Stock management fields nullable (20251108_1200_make_stock_fields_nullable)
- Stock receipts (20251123_add_stock_receipts)
"""
from typing import Sequence, Union
@@ -12,14 +17,14 @@ import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'e7fcea67bf4e'
revision: str = '20251123_unified_initial_schema'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
# Create audit_logs table
op.create_table('audit_logs',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('tenant_id', sa.UUID(), nullable=False),
@@ -52,6 +57,8 @@ def upgrade() -> None:
op.create_index(op.f('ix_audit_logs_severity'), 'audit_logs', ['severity'], unique=False)
op.create_index(op.f('ix_audit_logs_tenant_id'), 'audit_logs', ['tenant_id'], unique=False)
op.create_index(op.f('ix_audit_logs_user_id'), 'audit_logs', ['user_id'], unique=False)
# Create ingredients table with all fields including local production support and nullable stock fields
op.create_table('ingredients',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('tenant_id', sa.UUID(), nullable=False),
@@ -69,9 +76,10 @@ def upgrade() -> None:
sa.Column('average_cost', sa.Numeric(precision=10, scale=2), nullable=True),
sa.Column('last_purchase_price', sa.Numeric(precision=10, scale=2), nullable=True),
sa.Column('standard_cost', sa.Numeric(precision=10, scale=2), nullable=True),
sa.Column('low_stock_threshold', sa.Float(), nullable=False),
sa.Column('reorder_point', sa.Float(), nullable=False),
sa.Column('reorder_quantity', sa.Float(), nullable=False),
# Stock management fields - made nullable for simplified onboarding
sa.Column('low_stock_threshold', sa.Float(), nullable=True),
sa.Column('reorder_point', sa.Float(), nullable=True),
sa.Column('reorder_quantity', sa.Float(), nullable=True),
sa.Column('max_stock_level', sa.Float(), nullable=True),
sa.Column('shelf_life_days', sa.Integer(), nullable=True),
sa.Column('display_life_hours', sa.Integer(), nullable=True),
@@ -85,6 +93,10 @@ def upgrade() -> None:
sa.Column('is_perishable', sa.Boolean(), nullable=True),
sa.Column('allergen_info', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('nutritional_info', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
# Local production support fields
sa.Column('produced_locally', sa.Boolean(), nullable=False, server_default='false'),
sa.Column('recipe_id', postgresql.UUID(as_uuid=True), nullable=True),
# Audit fields
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_by', sa.UUID(), nullable=True),
@@ -104,6 +116,10 @@ def upgrade() -> None:
op.create_index(op.f('ix_ingredients_product_type'), 'ingredients', ['product_type'], unique=False)
op.create_index(op.f('ix_ingredients_sku'), 'ingredients', ['sku'], unique=False)
op.create_index(op.f('ix_ingredients_tenant_id'), 'ingredients', ['tenant_id'], unique=False)
op.create_index('ix_ingredients_produced_locally', 'ingredients', ['produced_locally'], unique=False)
op.create_index('ix_ingredients_recipe_id', 'ingredients', ['recipe_id'], unique=False)
# Create temperature_logs table
op.create_table('temperature_logs',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('tenant_id', sa.UUID(), nullable=False),
@@ -128,6 +144,8 @@ def upgrade() -> None:
op.create_index(op.f('ix_temperature_logs_recorded_at'), 'temperature_logs', ['recorded_at'], unique=False)
op.create_index(op.f('ix_temperature_logs_storage_location'), 'temperature_logs', ['storage_location'], unique=False)
op.create_index(op.f('ix_temperature_logs_tenant_id'), 'temperature_logs', ['tenant_id'], unique=False)
# Create food_safety_compliance table
op.create_table('food_safety_compliance',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('tenant_id', sa.UUID(), nullable=False),
@@ -163,6 +181,8 @@ def upgrade() -> None:
op.create_index(op.f('ix_food_safety_compliance_next_audit_date'), 'food_safety_compliance', ['next_audit_date'], unique=False)
op.create_index(op.f('ix_food_safety_compliance_standard'), 'food_safety_compliance', ['standard'], unique=False)
op.create_index(op.f('ix_food_safety_compliance_tenant_id'), 'food_safety_compliance', ['tenant_id'], unique=False)
# Create product_transformations table
op.create_table('product_transformations',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('tenant_id', sa.UUID(), nullable=False),
@@ -196,6 +216,8 @@ def upgrade() -> None:
op.create_index('idx_transformations_tenant_date', 'product_transformations', ['tenant_id', 'transformation_date'], unique=False)
op.create_index(op.f('ix_product_transformations_tenant_id'), 'product_transformations', ['tenant_id'], unique=False)
op.create_index(op.f('ix_product_transformations_transformation_reference'), 'product_transformations', ['transformation_reference'], unique=False)
# Create stock table
op.create_table('stock',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('tenant_id', sa.UUID(), nullable=False),
@@ -252,6 +274,81 @@ def upgrade() -> None:
op.create_index(op.f('ix_stock_supplier_id'), 'stock', ['supplier_id'], unique=False)
op.create_index(op.f('ix_stock_tenant_id'), 'stock', ['tenant_id'], unique=False)
op.create_index(op.f('ix_stock_transformation_reference'), 'stock', ['transformation_reference'], unique=False)
# Create stock_receipts table (lot-level tracking)
# Note: The Enum will automatically create the type on first use
op.create_table(
'stock_receipts',
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column('tenant_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('po_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('po_number', sa.String(100), nullable=True),
sa.Column('received_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('received_by_user_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('status', sa.Enum('draft', 'confirmed', 'cancelled', name='receiptstatus', create_type=True), nullable=False, server_default='draft'),
sa.Column('supplier_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('supplier_name', sa.String(255), nullable=True),
sa.Column('notes', sa.Text, nullable=True),
sa.Column('has_discrepancies', sa.Boolean, nullable=False, server_default='false'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('confirmed_at', sa.DateTime(timezone=True), nullable=True),
)
op.create_index('idx_stock_receipts_tenant_status', 'stock_receipts', ['tenant_id', 'status'])
op.create_index('idx_stock_receipts_po', 'stock_receipts', ['po_id'])
op.create_index('idx_stock_receipts_received_at', 'stock_receipts', ['tenant_id', 'received_at'])
op.create_index('idx_stock_receipts_supplier', 'stock_receipts', ['supplier_id', 'received_at'])
# Create stock_receipt_line_items table
op.create_table(
'stock_receipt_line_items',
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column('tenant_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('receipt_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('ingredient_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('ingredient_name', sa.String(255), nullable=True),
sa.Column('po_line_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('expected_quantity', sa.Numeric(10, 2), nullable=False),
sa.Column('actual_quantity', sa.Numeric(10, 2), nullable=False),
sa.Column('unit_of_measure', sa.String(20), nullable=False),
sa.Column('has_discrepancy', sa.Boolean, nullable=False, server_default='false'),
sa.Column('discrepancy_reason', sa.Text, nullable=True),
sa.Column('unit_cost', sa.Numeric(10, 2), nullable=True),
sa.Column('total_cost', sa.Numeric(10, 2), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['receipt_id'], ['stock_receipts.id'], ondelete='CASCADE'),
)
op.create_index('idx_line_items_receipt', 'stock_receipt_line_items', ['receipt_id'])
op.create_index('idx_line_items_ingredient', 'stock_receipt_line_items', ['ingredient_id'])
op.create_index('idx_line_items_discrepancy', 'stock_receipt_line_items', ['tenant_id', 'has_discrepancy'])
# Create stock_lots table
op.create_table(
'stock_lots',
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column('tenant_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('line_item_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('stock_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('lot_number', sa.String(100), nullable=True),
sa.Column('supplier_lot_number', sa.String(100), nullable=True),
sa.Column('quantity', sa.Numeric(10, 2), nullable=False),
sa.Column('unit_of_measure', sa.String(20), nullable=False),
sa.Column('expiration_date', sa.Date, nullable=False),
sa.Column('best_before_date', sa.Date, nullable=True),
sa.Column('warehouse_location', sa.String(100), nullable=True),
sa.Column('storage_zone', sa.String(50), nullable=True),
sa.Column('quality_notes', sa.Text, nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['line_item_id'], ['stock_receipt_line_items.id'], ondelete='CASCADE'),
)
op.create_index('idx_lots_line_item', 'stock_lots', ['line_item_id'])
op.create_index('idx_lots_stock', 'stock_lots', ['stock_id'])
op.create_index('idx_lots_expiration', 'stock_lots', ['tenant_id', 'expiration_date'])
op.create_index('idx_lots_lot_number', 'stock_lots', ['tenant_id', 'lot_number'])
# Create food_safety_alerts table
op.create_table('food_safety_alerts',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('tenant_id', sa.UUID(), nullable=False),
@@ -329,6 +426,8 @@ def upgrade() -> None:
op.create_index(op.f('ix_food_safety_alerts_status'), 'food_safety_alerts', ['status'], unique=False)
op.create_index(op.f('ix_food_safety_alerts_stock_id'), 'food_safety_alerts', ['stock_id'], unique=False)
op.create_index(op.f('ix_food_safety_alerts_tenant_id'), 'food_safety_alerts', ['tenant_id'], unique=False)
# Create stock_alerts table
op.create_table('stock_alerts',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('tenant_id', sa.UUID(), nullable=False),
@@ -363,6 +462,8 @@ def upgrade() -> None:
op.create_index(op.f('ix_stock_alerts_ingredient_id'), 'stock_alerts', ['ingredient_id'], unique=False)
op.create_index(op.f('ix_stock_alerts_stock_id'), 'stock_alerts', ['stock_id'], unique=False)
op.create_index(op.f('ix_stock_alerts_tenant_id'), 'stock_alerts', ['tenant_id'], unique=False)
# Create stock_movements table
op.create_table('stock_movements',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('tenant_id', sa.UUID(), nullable=False),
@@ -397,11 +498,10 @@ def upgrade() -> None:
op.create_index(op.f('ix_stock_movements_stock_id'), 'stock_movements', ['stock_id'], unique=False)
op.create_index(op.f('ix_stock_movements_supplier_id'), 'stock_movements', ['supplier_id'], unique=False)
op.create_index(op.f('ix_stock_movements_tenant_id'), 'stock_movements', ['tenant_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
# Drop all tables in reverse order
op.drop_index(op.f('ix_stock_movements_tenant_id'), table_name='stock_movements')
op.drop_index(op.f('ix_stock_movements_supplier_id'), table_name='stock_movements')
op.drop_index(op.f('ix_stock_movements_stock_id'), table_name='stock_movements')
@@ -415,6 +515,7 @@ def downgrade() -> None:
op.drop_index('idx_movements_supplier', table_name='stock_movements')
op.drop_index('idx_movements_reference', table_name='stock_movements')
op.drop_table('stock_movements')
op.drop_index(op.f('ix_stock_alerts_tenant_id'), table_name='stock_alerts')
op.drop_index(op.f('ix_stock_alerts_stock_id'), table_name='stock_alerts')
op.drop_index(op.f('ix_stock_alerts_ingredient_id'), table_name='stock_alerts')
@@ -424,6 +525,7 @@ def downgrade() -> None:
op.drop_index('idx_alerts_tenant_active', table_name='stock_alerts')
op.drop_index('idx_alerts_ingredient', table_name='stock_alerts')
op.drop_table('stock_alerts')
op.drop_index(op.f('ix_food_safety_alerts_tenant_id'), table_name='food_safety_alerts')
op.drop_index(op.f('ix_food_safety_alerts_stock_id'), table_name='food_safety_alerts')
op.drop_index(op.f('ix_food_safety_alerts_status'), table_name='food_safety_alerts')
@@ -434,6 +536,27 @@ def downgrade() -> None:
op.drop_index(op.f('ix_food_safety_alerts_alert_type'), table_name='food_safety_alerts')
op.drop_index(op.f('ix_food_safety_alerts_alert_code'), table_name='food_safety_alerts')
op.drop_table('food_safety_alerts')
# Drop stock receipts tables
op.drop_index('idx_lots_lot_number', table_name='stock_lots')
op.drop_index('idx_lots_expiration', table_name='stock_lots')
op.drop_index('idx_lots_stock', table_name='stock_lots')
op.drop_index('idx_lots_line_item', table_name='stock_lots')
op.drop_table('stock_lots')
op.drop_index('idx_line_items_discrepancy', table_name='stock_receipt_line_items')
op.drop_index('idx_line_items_ingredient', table_name='stock_receipt_line_items')
op.drop_index('idx_line_items_receipt', table_name='stock_receipt_line_items')
op.drop_table('stock_receipt_line_items')
op.drop_index('idx_stock_receipts_supplier', table_name='stock_receipts')
op.drop_index('idx_stock_receipts_received_at', table_name='stock_receipts')
op.drop_index('idx_stock_receipts_po', table_name='stock_receipts')
op.drop_index('idx_stock_receipts_tenant_status', table_name='stock_receipts')
op.drop_table('stock_receipts')
op.execute('DROP TYPE receiptstatus')
# Continue with other tables
op.drop_index(op.f('ix_stock_transformation_reference'), table_name='stock')
op.drop_index(op.f('ix_stock_tenant_id'), table_name='stock')
op.drop_index(op.f('ix_stock_supplier_id'), table_name='stock')
@@ -452,6 +575,7 @@ def downgrade() -> None:
op.drop_index('idx_stock_expiration', table_name='stock')
op.drop_index('idx_stock_batch', table_name='stock')
op.drop_table('stock')
op.drop_index(op.f('ix_product_transformations_transformation_reference'), table_name='product_transformations')
op.drop_index(op.f('ix_product_transformations_tenant_id'), table_name='product_transformations')
op.drop_index('idx_transformations_tenant_date', table_name='product_transformations')
@@ -460,16 +584,21 @@ def downgrade() -> None:
op.drop_index('idx_transformations_source', table_name='product_transformations')
op.drop_index('idx_transformations_reference', table_name='product_transformations')
op.drop_table('product_transformations')
op.drop_index(op.f('ix_food_safety_compliance_tenant_id'), table_name='food_safety_compliance')
op.drop_index(op.f('ix_food_safety_compliance_standard'), table_name='food_safety_compliance')
op.drop_index(op.f('ix_food_safety_compliance_next_audit_date'), table_name='food_safety_compliance')
op.drop_index(op.f('ix_food_safety_compliance_ingredient_id'), table_name='food_safety_compliance')
op.drop_index(op.f('ix_food_safety_compliance_expiration_date'), table_name='food_safety_compliance')
op.drop_table('food_safety_compliance')
op.drop_index(op.f('ix_temperature_logs_tenant_id'), table_name='temperature_logs')
op.drop_index(op.f('ix_temperature_logs_storage_location'), table_name='temperature_logs')
op.drop_index(op.f('ix_temperature_logs_recorded_at'), table_name='temperature_logs')
op.drop_table('temperature_logs')
op.drop_index('ix_ingredients_recipe_id', table_name='ingredients')
op.drop_index('ix_ingredients_produced_locally', table_name='ingredients')
op.drop_index(op.f('ix_ingredients_tenant_id'), table_name='ingredients')
op.drop_index(op.f('ix_ingredients_sku'), table_name='ingredients')
op.drop_index(op.f('ix_ingredients_product_type'), table_name='ingredients')
@@ -485,6 +614,7 @@ def downgrade() -> None:
op.drop_index('idx_ingredients_ingredient_category', table_name='ingredients')
op.drop_index('idx_ingredients_barcode', table_name='ingredients')
op.drop_table('ingredients')
op.drop_index(op.f('ix_audit_logs_user_id'), table_name='audit_logs')
op.drop_index(op.f('ix_audit_logs_tenant_id'), table_name='audit_logs')
op.drop_index(op.f('ix_audit_logs_severity'), table_name='audit_logs')
@@ -499,4 +629,3 @@ def downgrade() -> None:
op.drop_index('idx_audit_service_created', table_name='audit_logs')
op.drop_index('idx_audit_resource_type_action', table_name='audit_logs')
op.drop_table('audit_logs')
# ### end Alembic commands ###

View File

@@ -36,6 +36,10 @@ import structlog
from app.models.inventory import Ingredient, Stock, StockMovement, StockMovementType
# Add shared path for demo utilities
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
from shared.utils.demo_dates import BASE_REFERENCE_DATE
# Configure logging
structlog.configure(
processors=[
@@ -51,9 +55,6 @@ logger = structlog.get_logger()
DEMO_TENANT_SAN_PABLO = uuid.UUID("a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6")
DEMO_TENANT_LA_ESPIGA = uuid.UUID("b2c3d4e5-f6a7-48b9-c0d1-e2f3a4b5c6d7")
# Base reference date for demo data (all relative dates calculated from this)
BASE_REFERENCE_DATE = datetime(2025, 1, 15, 12, 0, 0, tzinfo=timezone.utc)
# Daily consumption rates (kg/day) - aligned with procurement seed script
# Used to create realistic stock levels that trigger appropriate PO scenarios
DAILY_CONSUMPTION_RATES = {
@@ -196,7 +197,14 @@ async def create_stock_batches_for_ingredient(
# CRITICAL DEMO SCENARIO: Create consumption-aware stock levels
# This creates realistic scenarios that trigger intelligent PO reasoning
critical_low_stock_skus = ["HAR-T55-001", "LEV-SEC-001", "MAN-SAL-001"]
# DASHBOARD SHOWCASE: Critical low stock scenarios for realistic alert demonstration
# These will trigger automatic alert generation by the inventory service
critical_low_stock_skus = [
"HAR-T55-001", # Harina Tipo 55 - URGENT: Will run out in <18h, triggers delivery overdue scenario
"LEV-SEC-001", # Levadura (Yeast) - TODAY: Recommend ordering today
"MAN-SAL-001", # Mantequilla (Butter) - For croissant production batch at risk
"CHO-NEG-001" # Chocolate Negro - For chocolate cake batch at risk in 5 hours
]
is_critical_low = ingredient.sku in critical_low_stock_skus
# Calculate target total stock using consumption-aware logic