New alert system and panel de control page
This commit is contained in:
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)}"
|
||||
)
|
||||
Reference in New Issue
Block a user