""" 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)}" )