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,
|
||||
)
|
||||
Reference in New Issue
Block a user