460 lines
16 KiB
Python
460 lines
16 KiB
Python
|
|
"""
|
||
|
|
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)}"
|
||
|
|
)
|