New alert system and panel de control page
This commit is contained in:
@@ -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(
|
||||
|
||||
459
services/inventory/app/api/stock_receipts.py
Normal file
459
services/inventory/app/api/stock_receipts.py
Normal 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)}"
|
||||
)
|
||||
@@ -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."""
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
233
services/inventory/app/models/stock_receipt.py
Normal file
233
services/inventory/app/models/stock_receipt.py
Normal 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,
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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')
|
||||
@@ -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)
|
||||
@@ -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 ###
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user