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