New alert system and panel de control page

This commit is contained in:
Urtzi Alfaro
2025-11-27 15:52:40 +01:00
parent 1a2f4602f3
commit e902419b6e
178 changed files with 20982 additions and 6944 deletions

View File

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

View File

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