Improve the frontend 3

This commit is contained in:
Urtzi Alfaro
2025-10-30 21:08:07 +01:00
parent 36217a2729
commit 63f5c6d512
184 changed files with 21512 additions and 7442 deletions

View File

@@ -0,0 +1,44 @@
# Procurement Service Dockerfile
# Stage 1: Copy shared libraries
FROM python:3.11-slim AS shared
WORKDIR /shared
COPY shared/ /shared/
# Stage 2: Main service
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
curl \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements
COPY shared/requirements-tracing.txt /tmp/
COPY services/procurement/requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir -r /tmp/requirements-tracing.txt
RUN pip install --no-cache-dir -r requirements.txt
# Copy shared libraries from the shared stage
COPY --from=shared /shared /app/shared
# Copy application code
COPY services/procurement/ .
# Add shared libraries to Python path
ENV PYTHONPATH="/app:/app/shared:${PYTHONPATH:-}"
ENV PYTHONUNBUFFERED=1
# Expose port
EXPOSE 8000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1
# Run application
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -0,0 +1,104 @@
# A generic, single database configuration for procurement service
[alembic]
# path to migration scripts
script_location = migrations
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python>=3.9 or backports.zoneinfo library.
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the
# "slug" field
# max_length = 40
# version_num, name, path
version_locations = %(here)s/migrations/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses
# os.pathsep. If this key is omitted entirely, it falls back to the legacy
# behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10.0
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = --fix REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stdout,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s

View File

View File

@@ -0,0 +1,13 @@
"""Procurement Service API"""
from .procurement_plans import router as procurement_plans_router
from .purchase_orders import router as purchase_orders_router
from .replenishment import router as replenishment_router
from .internal_demo import router as internal_demo_router
__all__ = [
"procurement_plans_router",
"purchase_orders_router",
"replenishment_router",
"internal_demo_router"
]

View File

@@ -0,0 +1,523 @@
"""
Internal Demo Cloning API for Procurement Service
Service-to-service endpoint for cloning procurement and purchase order data
"""
from fastapi import APIRouter, Depends, HTTPException, Header
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete, func
import structlog
import uuid
from datetime import datetime, timezone, timedelta, date
from typing import Optional
import os
from app.core.database import get_db
from app.models.procurement_plan import ProcurementPlan, ProcurementRequirement
from app.models.purchase_order import PurchaseOrder, PurchaseOrderItem
from app.models.replenishment import ReplenishmentPlan, ReplenishmentPlanItem
from shared.utils.demo_dates import adjust_date_for_demo, BASE_REFERENCE_DATE
logger = structlog.get_logger()
router = APIRouter(prefix="/internal/demo", tags=["internal"])
# Internal API key for service-to-service auth
INTERNAL_API_KEY = os.getenv("INTERNAL_API_KEY", "dev-internal-key-change-in-production")
# Base demo tenant IDs
DEMO_TENANT_SAN_PABLO = "a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6"
DEMO_TENANT_LA_ESPIGA = "b2c3d4e5-f6a7-48b9-c0d1-e2f3a4b5c6d7"
def verify_internal_api_key(x_internal_api_key: Optional[str] = Header(None)):
"""Verify internal API key for service-to-service communication"""
if x_internal_api_key != INTERNAL_API_KEY:
logger.warning("Unauthorized internal API access attempted")
raise HTTPException(status_code=403, detail="Invalid internal API key")
return True
@router.post("/clone")
async def clone_demo_data(
base_tenant_id: str,
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)
):
"""
Clone procurement service data for a virtual demo tenant
Clones:
- Procurement plans with requirements
- Purchase orders with line items
- Replenishment plans with items
- Adjusts dates to recent timeframe
Args:
base_tenant_id: Template tenant UUID to clone from
virtual_tenant_id: Target virtual tenant UUID
demo_account_type: Type of demo account
session_id: Originating session ID for tracing
Returns:
Cloning status and record counts
"""
start_time = datetime.now(timezone.utc)
# Parse session creation time for date adjustment
if session_created_at:
try:
session_time = datetime.fromisoformat(session_created_at.replace('Z', '+00:00'))
except (ValueError, AttributeError):
session_time = start_time
else:
session_time = start_time
logger.info(
"Starting procurement data cloning",
base_tenant_id=base_tenant_id,
virtual_tenant_id=virtual_tenant_id,
demo_account_type=demo_account_type,
session_id=session_id,
session_created_at=session_created_at
)
try:
# Validate UUIDs
base_uuid = uuid.UUID(base_tenant_id)
virtual_uuid = uuid.UUID(virtual_tenant_id)
# Track cloning statistics
stats = {
"procurement_plans": 0,
"procurement_requirements": 0,
"purchase_orders": 0,
"purchase_order_items": 0,
"replenishment_plans": 0,
"replenishment_items": 0
}
# Clone Procurement Plans with Requirements
result = await db.execute(
select(ProcurementPlan).where(ProcurementPlan.tenant_id == base_uuid)
)
base_plans = result.scalars().all()
logger.info(
"Found procurement plans to clone",
count=len(base_plans),
base_tenant=str(base_uuid)
)
# Calculate date offset for procurement
if base_plans:
max_plan_date = max(plan.plan_date for plan in base_plans if plan.plan_date)
today_date = date.today()
days_diff = (today_date - max_plan_date).days
plan_date_offset = timedelta(days=days_diff)
else:
plan_date_offset = timedelta(days=0)
plan_id_map = {}
for plan in base_plans:
new_plan_id = uuid.uuid4()
plan_id_map[plan.id] = new_plan_id
new_plan = ProcurementPlan(
id=new_plan_id,
tenant_id=virtual_uuid,
plan_number=f"PROC-{uuid.uuid4().hex[:8].upper()}",
plan_date=plan.plan_date + plan_date_offset if plan.plan_date else None,
plan_period_start=plan.plan_period_start + plan_date_offset if plan.plan_period_start else None,
plan_period_end=plan.plan_period_end + plan_date_offset if plan.plan_period_end else None,
planning_horizon_days=plan.planning_horizon_days,
status=plan.status,
plan_type=plan.plan_type,
priority=plan.priority,
business_model=plan.business_model,
procurement_strategy=plan.procurement_strategy,
total_requirements=plan.total_requirements,
total_estimated_cost=plan.total_estimated_cost,
total_approved_cost=plan.total_approved_cost,
cost_variance=plan.cost_variance,
created_at=session_time,
updated_at=session_time
)
db.add(new_plan)
stats["procurement_plans"] += 1
# Clone Procurement Requirements
for old_plan_id, new_plan_id in plan_id_map.items():
result = await db.execute(
select(ProcurementRequirement).where(ProcurementRequirement.plan_id == old_plan_id)
)
requirements = result.scalars().all()
for req in requirements:
new_req = ProcurementRequirement(
id=uuid.uuid4(),
plan_id=new_plan_id,
requirement_number=req.requirement_number,
product_id=req.product_id,
product_name=req.product_name,
product_sku=req.product_sku,
product_category=req.product_category,
product_type=req.product_type,
required_quantity=req.required_quantity,
unit_of_measure=req.unit_of_measure,
safety_stock_quantity=req.safety_stock_quantity,
total_quantity_needed=req.total_quantity_needed,
current_stock_level=req.current_stock_level,
reserved_stock=req.reserved_stock,
available_stock=req.available_stock,
net_requirement=req.net_requirement,
order_demand=req.order_demand,
production_demand=req.production_demand,
forecast_demand=req.forecast_demand,
buffer_demand=req.buffer_demand,
preferred_supplier_id=req.preferred_supplier_id,
backup_supplier_id=req.backup_supplier_id,
supplier_name=req.supplier_name,
supplier_lead_time_days=req.supplier_lead_time_days,
minimum_order_quantity=req.minimum_order_quantity,
estimated_unit_cost=req.estimated_unit_cost,
estimated_total_cost=req.estimated_total_cost,
last_purchase_cost=req.last_purchase_cost,
cost_variance=req.cost_variance,
required_by_date=req.required_by_date + plan_date_offset if req.required_by_date else None,
lead_time_buffer_days=req.lead_time_buffer_days,
suggested_order_date=req.suggested_order_date + plan_date_offset if req.suggested_order_date else None,
latest_order_date=req.latest_order_date + plan_date_offset if req.latest_order_date else None,
quality_specifications=req.quality_specifications,
special_requirements=req.special_requirements,
storage_requirements=req.storage_requirements,
shelf_life_days=req.shelf_life_days,
status=req.status,
priority=req.priority,
risk_level=req.risk_level,
purchase_order_id=req.purchase_order_id,
purchase_order_number=req.purchase_order_number,
ordered_quantity=req.ordered_quantity,
ordered_at=req.ordered_at,
expected_delivery_date=req.expected_delivery_date + plan_date_offset if req.expected_delivery_date else None,
actual_delivery_date=req.actual_delivery_date + plan_date_offset if req.actual_delivery_date else None,
received_quantity=req.received_quantity,
delivery_status=req.delivery_status,
fulfillment_rate=req.fulfillment_rate,
on_time_delivery=req.on_time_delivery,
quality_rating=req.quality_rating,
source_orders=req.source_orders,
source_production_batches=req.source_production_batches,
demand_analysis=req.demand_analysis,
approved_quantity=req.approved_quantity,
approved_cost=req.approved_cost,
approved_at=req.approved_at,
approved_by=req.approved_by,
procurement_notes=req.procurement_notes,
supplier_communication=req.supplier_communication,
requirement_metadata=req.requirement_metadata,
created_at=session_time,
updated_at=session_time
)
db.add(new_req)
stats["procurement_requirements"] += 1
# Clone Purchase Orders with Line Items
result = await db.execute(
select(PurchaseOrder).where(PurchaseOrder.tenant_id == base_uuid)
)
base_orders = result.scalars().all()
logger.info(
"Found purchase orders to clone",
count=len(base_orders),
base_tenant=str(base_uuid)
)
order_id_map = {}
for order in base_orders:
new_order_id = uuid.uuid4()
order_id_map[order.id] = new_order_id
# Adjust dates using demo_dates utility
adjusted_order_date = adjust_date_for_demo(
order.order_date, session_time, BASE_REFERENCE_DATE
)
adjusted_required_delivery = adjust_date_for_demo(
order.required_delivery_date, session_time, BASE_REFERENCE_DATE
)
adjusted_estimated_delivery = adjust_date_for_demo(
order.estimated_delivery_date, session_time, BASE_REFERENCE_DATE
)
adjusted_supplier_confirmation = adjust_date_for_demo(
order.supplier_confirmation_date, session_time, BASE_REFERENCE_DATE
)
adjusted_approved_at = adjust_date_for_demo(
order.approved_at, session_time, BASE_REFERENCE_DATE
)
adjusted_sent_to_supplier_at = adjust_date_for_demo(
order.sent_to_supplier_at, session_time, BASE_REFERENCE_DATE
)
# Generate a system user UUID for audit fields (demo purposes)
system_user_id = uuid.uuid4()
new_order = PurchaseOrder(
id=new_order_id,
tenant_id=virtual_uuid,
po_number=f"PO-{uuid.uuid4().hex[:8].upper()}", # New PO number
reference_number=order.reference_number,
supplier_id=order.supplier_id,
procurement_plan_id=plan_id_map.get(order.procurement_plan_id) if hasattr(order, 'procurement_plan_id') and order.procurement_plan_id else None,
order_date=adjusted_order_date,
required_delivery_date=adjusted_required_delivery,
estimated_delivery_date=adjusted_estimated_delivery,
status=order.status,
priority=order.priority,
subtotal=order.subtotal,
tax_amount=order.tax_amount,
discount_amount=order.discount_amount,
shipping_cost=order.shipping_cost,
total_amount=order.total_amount,
currency=order.currency,
delivery_address=order.delivery_address if hasattr(order, 'delivery_address') else None,
delivery_instructions=order.delivery_instructions if hasattr(order, 'delivery_instructions') else None,
delivery_contact=order.delivery_contact if hasattr(order, 'delivery_contact') else None,
delivery_phone=order.delivery_phone if hasattr(order, 'delivery_phone') else None,
requires_approval=order.requires_approval if hasattr(order, 'requires_approval') else False,
approved_by=order.approved_by if hasattr(order, 'approved_by') else None,
approved_at=adjusted_approved_at,
rejection_reason=order.rejection_reason if hasattr(order, 'rejection_reason') else None,
auto_approved=order.auto_approved if hasattr(order, 'auto_approved') else False,
auto_approval_rule_id=order.auto_approval_rule_id if hasattr(order, 'auto_approval_rule_id') else None,
sent_to_supplier_at=adjusted_sent_to_supplier_at,
supplier_confirmation_date=adjusted_supplier_confirmation,
supplier_reference=order.supplier_reference if hasattr(order, 'supplier_reference') else None,
notes=order.notes if hasattr(order, 'notes') else None,
internal_notes=order.internal_notes if hasattr(order, 'internal_notes') else None,
terms_and_conditions=order.terms_and_conditions if hasattr(order, 'terms_and_conditions') else None,
created_at=session_time,
updated_at=session_time,
created_by=system_user_id,
updated_by=system_user_id
)
db.add(new_order)
stats["purchase_orders"] += 1
# Clone Purchase Order Items
for old_order_id, new_order_id in order_id_map.items():
result = await db.execute(
select(PurchaseOrderItem).where(PurchaseOrderItem.purchase_order_id == old_order_id)
)
order_items = result.scalars().all()
for item in order_items:
new_item = PurchaseOrderItem(
id=uuid.uuid4(),
tenant_id=virtual_uuid,
purchase_order_id=new_order_id,
procurement_requirement_id=item.procurement_requirement_id if hasattr(item, 'procurement_requirement_id') else None,
inventory_product_id=item.inventory_product_id,
product_code=item.product_code if hasattr(item, 'product_code') else None,
product_name=item.product_name,
supplier_price_list_id=item.supplier_price_list_id if hasattr(item, 'supplier_price_list_id') else None,
ordered_quantity=item.ordered_quantity,
unit_of_measure=item.unit_of_measure,
unit_price=item.unit_price,
line_total=item.line_total,
received_quantity=item.received_quantity if hasattr(item, 'received_quantity') else 0,
remaining_quantity=item.remaining_quantity if hasattr(item, 'remaining_quantity') else item.ordered_quantity,
quality_requirements=item.quality_requirements if hasattr(item, 'quality_requirements') else None,
item_notes=item.item_notes if hasattr(item, 'item_notes') else None,
created_at=session_time,
updated_at=session_time
)
db.add(new_item)
stats["purchase_order_items"] += 1
# Clone Replenishment Plans with Items
result = await db.execute(
select(ReplenishmentPlan).where(ReplenishmentPlan.tenant_id == base_uuid)
)
base_replenishment_plans = result.scalars().all()
logger.info(
"Found replenishment plans to clone",
count=len(base_replenishment_plans),
base_tenant=str(base_uuid)
)
replan_id_map = {}
for replan in base_replenishment_plans:
new_replan_id = uuid.uuid4()
replan_id_map[replan.id] = new_replan_id
new_replan = ReplenishmentPlan(
id=new_replan_id,
tenant_id=virtual_uuid,
plan_number=f"REPL-{uuid.uuid4().hex[:8].upper()}",
plan_date=replan.plan_date + plan_date_offset if replan.plan_date else None,
plan_period_start=replan.plan_period_start + plan_date_offset if replan.plan_period_start else None,
plan_period_end=replan.plan_period_end + plan_date_offset if replan.plan_period_end else None,
planning_horizon_days=replan.planning_horizon_days,
status=replan.status,
plan_type=replan.plan_type,
priority=replan.priority,
business_model=replan.business_model,
total_items=replan.total_items,
total_estimated_cost=replan.total_estimated_cost,
created_at=session_time,
updated_at=session_time
)
db.add(new_replan)
stats["replenishment_plans"] += 1
# Clone Replenishment Plan Items
for old_replan_id, new_replan_id in replan_id_map.items():
result = await db.execute(
select(ReplenishmentPlanItem).where(ReplenishmentPlanItem.plan_id == old_replan_id)
)
replan_items = result.scalars().all()
for item in replan_items:
new_item = ReplenishmentPlanItem(
id=uuid.uuid4(),
plan_id=new_replan_id,
product_id=item.product_id,
product_name=item.product_name,
product_sku=item.product_sku,
required_quantity=item.required_quantity,
unit_of_measure=item.unit_of_measure,
current_stock_level=item.current_stock_level,
safety_stock_quantity=item.safety_stock_quantity,
suggested_order_quantity=item.suggested_order_quantity,
supplier_id=item.supplier_id,
supplier_name=item.supplier_name,
estimated_delivery_days=item.estimated_delivery_days,
required_by_date=item.required_by_date + plan_date_offset if item.required_by_date else None,
status=item.status,
priority=item.priority,
notes=item.notes,
created_at=session_time,
updated_at=session_time
)
db.add(new_item)
stats["replenishment_items"] += 1
# Commit cloned data
await db.commit()
total_records = sum(stats.values())
duration_ms = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000)
logger.info(
"Procurement data cloning completed",
virtual_tenant_id=virtual_tenant_id,
total_records=total_records,
stats=stats,
duration_ms=duration_ms
)
return {
"service": "procurement",
"status": "completed",
"records_cloned": total_records,
"duration_ms": duration_ms,
"details": stats
}
except ValueError as e:
logger.error("Invalid UUID format", error=str(e))
raise HTTPException(status_code=400, detail=f"Invalid UUID: {str(e)}")
except Exception as e:
logger.error(
"Failed to clone procurement data",
error=str(e),
virtual_tenant_id=virtual_tenant_id,
exc_info=True
)
# Rollback on error
await db.rollback()
return {
"service": "procurement",
"status": "failed",
"records_cloned": 0,
"duration_ms": int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000),
"error": str(e)
}
@router.get("/clone/health")
async def clone_health_check(_: bool = Depends(verify_internal_api_key)):
"""
Health check for internal cloning endpoint
Used by orchestrator to verify service availability
"""
return {
"service": "procurement",
"clone_endpoint": "available",
"version": "2.0.0"
}
@router.delete("/tenant/{virtual_tenant_id}")
async def delete_demo_data(
virtual_tenant_id: str,
db: AsyncSession = Depends(get_db),
_: bool = Depends(verify_internal_api_key)
):
"""Delete all procurement data for a virtual demo tenant"""
logger.info("Deleting procurement data for virtual tenant", virtual_tenant_id=virtual_tenant_id)
start_time = datetime.now(timezone.utc)
try:
virtual_uuid = uuid.UUID(virtual_tenant_id)
# Count records
po_count = await db.scalar(select(func.count(PurchaseOrder.id)).where(PurchaseOrder.tenant_id == virtual_uuid))
item_count = await db.scalar(select(func.count(PurchaseOrderItem.id)).where(PurchaseOrderItem.tenant_id == virtual_uuid))
plan_count = await db.scalar(select(func.count(ProcurementPlan.id)).where(ProcurementPlan.tenant_id == virtual_uuid))
req_count = await db.scalar(select(func.count(ProcurementRequirement.id)).where(ProcurementRequirement.tenant_id == virtual_uuid))
replan_count = await db.scalar(select(func.count(ReplenishmentPlan.id)).where(ReplenishmentPlan.tenant_id == virtual_uuid))
replan_item_count = await db.scalar(select(func.count(ReplenishmentPlanItem.id)).where(ReplenishmentPlanItem.tenant_id == virtual_uuid))
# Delete in order (respecting foreign key constraints)
await db.execute(delete(PurchaseOrderItem).where(PurchaseOrderItem.tenant_id == virtual_uuid))
await db.execute(delete(PurchaseOrder).where(PurchaseOrder.tenant_id == virtual_uuid))
await db.execute(delete(ProcurementRequirement).where(ProcurementRequirement.tenant_id == virtual_uuid))
await db.execute(delete(ProcurementPlan).where(ProcurementPlan.tenant_id == virtual_uuid))
await db.execute(delete(ReplenishmentPlanItem).where(ReplenishmentPlanItem.tenant_id == virtual_uuid))
await db.execute(delete(ReplenishmentPlan).where(ReplenishmentPlan.tenant_id == virtual_uuid))
await db.commit()
duration_ms = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000)
logger.info("Procurement data deleted successfully", virtual_tenant_id=virtual_tenant_id, duration_ms=duration_ms)
return {
"service": "procurement",
"status": "deleted",
"virtual_tenant_id": virtual_tenant_id,
"records_deleted": {
"purchase_orders": po_count,
"purchase_order_items": item_count,
"procurement_plans": plan_count,
"procurement_requirements": req_count,
"replenishment_plans": replan_count,
"replenishment_items": replan_item_count,
"total": po_count + item_count + plan_count + req_count + replan_count + replan_item_count
},
"duration_ms": duration_ms
}
except Exception as e:
logger.error("Failed to delete procurement data", error=str(e), exc_info=True)
await db.rollback()
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -0,0 +1,319 @@
# ================================================================
# services/procurement/app/api/procurement_plans.py
# ================================================================
"""
Procurement Plans API - Endpoints for procurement planning
"""
import uuid
from typing import List, Optional
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.config import settings
from app.services.procurement_service import ProcurementService
from app.schemas.procurement_schemas import (
ProcurementPlanResponse,
GeneratePlanRequest,
GeneratePlanResponse,
AutoGenerateProcurementRequest,
AutoGenerateProcurementResponse,
PaginatedProcurementPlans,
)
import structlog
logger = structlog.get_logger()
router = APIRouter(prefix="/api/v1/tenants/{tenant_id}/procurement", tags=["Procurement Plans"])
def get_procurement_service(db: AsyncSession = Depends(get_db)) -> ProcurementService:
"""Dependency to get procurement service"""
return ProcurementService(db, settings)
# ================================================================
# ORCHESTRATOR ENTRY POINT
# ================================================================
@router.post("/auto-generate", response_model=AutoGenerateProcurementResponse)
async def auto_generate_procurement(
tenant_id: str,
request_data: AutoGenerateProcurementRequest,
service: ProcurementService = Depends(get_procurement_service),
db: AsyncSession = Depends(get_db)
):
"""
Auto-generate procurement plan from forecast data (called by Orchestrator)
This is the main entry point for orchestrated procurement planning.
The Orchestrator calls Forecasting Service first, then passes forecast data here.
Flow:
1. Receive forecast data from orchestrator
2. Calculate procurement requirements
3. Apply Recipe Explosion for locally-produced items
4. Create procurement plan
5. Optionally create and auto-approve purchase orders
Returns:
AutoGenerateProcurementResponse with plan details and created POs
"""
try:
logger.info("Auto-generate procurement endpoint called",
tenant_id=tenant_id,
has_forecast_data=bool(request_data.forecast_data))
result = await service.auto_generate_procurement(
tenant_id=uuid.UUID(tenant_id),
request=request_data
)
return result
except Exception as e:
logger.error("Error in auto_generate_procurement endpoint", error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=str(e))
# ================================================================
# MANUAL PROCUREMENT PLAN GENERATION
# ================================================================
@router.post("/plans/generate", response_model=GeneratePlanResponse)
async def generate_procurement_plan(
tenant_id: str,
request_data: GeneratePlanRequest,
service: ProcurementService = Depends(get_procurement_service)
):
"""
Generate a new procurement plan (manual/UI-driven)
This endpoint is used for manual procurement planning from the UI.
Unlike auto_generate_procurement, this generates its own forecasts.
Args:
tenant_id: Tenant UUID
request_data: Plan generation parameters
Returns:
GeneratePlanResponse with the created plan
"""
try:
logger.info("Generate procurement plan endpoint called",
tenant_id=tenant_id,
plan_date=request_data.plan_date)
result = await service.generate_procurement_plan(
tenant_id=uuid.UUID(tenant_id),
request=request_data
)
return result
except Exception as e:
logger.error("Error generating procurement plan", error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=str(e))
# ================================================================
# PROCUREMENT PLAN CRUD
# ================================================================
@router.get("/plans/current", response_model=Optional[ProcurementPlanResponse])
async def get_current_plan(
tenant_id: str,
service: ProcurementService = Depends(get_procurement_service)
):
"""Get the current day's procurement plan"""
try:
plan = await service.get_current_plan(uuid.UUID(tenant_id))
return plan
except Exception as e:
logger.error("Error getting current plan", error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/plans/{plan_id}", response_model=ProcurementPlanResponse)
async def get_plan_by_id(
tenant_id: str,
plan_id: str,
service: ProcurementService = Depends(get_procurement_service)
):
"""Get procurement plan by ID"""
try:
plan = await service.get_plan_by_id(uuid.UUID(tenant_id), uuid.UUID(plan_id))
if not plan:
raise HTTPException(status_code=404, detail="Plan not found")
return plan
except HTTPException:
raise
except Exception as e:
logger.error("Error getting plan by ID", error=str(e), tenant_id=tenant_id, plan_id=plan_id)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/plans/date/{plan_date}", response_model=Optional[ProcurementPlanResponse])
async def get_plan_by_date(
tenant_id: str,
plan_date: date,
service: ProcurementService = Depends(get_procurement_service)
):
"""Get procurement plan for a specific date"""
try:
plan = await service.get_plan_by_date(uuid.UUID(tenant_id), plan_date)
return plan
except Exception as e:
logger.error("Error getting plan by date", error=str(e), tenant_id=tenant_id, plan_date=plan_date)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/plans", response_model=PaginatedProcurementPlans)
async def list_procurement_plans(
tenant_id: str,
skip: int = Query(default=0, ge=0),
limit: int = Query(default=50, ge=1, le=100),
service: ProcurementService = Depends(get_procurement_service),
db: AsyncSession = Depends(get_db)
):
"""List all procurement plans for tenant with pagination"""
try:
from app.repositories.procurement_plan_repository import ProcurementPlanRepository
repo = ProcurementPlanRepository(db)
plans = await repo.list_plans(uuid.UUID(tenant_id), skip=skip, limit=limit)
total = await repo.count_plans(uuid.UUID(tenant_id))
plans_response = [ProcurementPlanResponse.model_validate(p) for p in plans]
return PaginatedProcurementPlans(
plans=plans_response,
total=total,
page=skip // limit + 1,
limit=limit,
has_more=(skip + limit) < total
)
except Exception as e:
logger.error("Error listing procurement plans", error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=str(e))
@router.patch("/plans/{plan_id}/status")
async def update_plan_status(
tenant_id: str,
plan_id: str,
status: str = Query(..., regex="^(draft|pending_approval|approved|in_execution|completed|cancelled)$"),
notes: Optional[str] = None,
service: ProcurementService = Depends(get_procurement_service)
):
"""Update procurement plan status"""
try:
updated_plan = await service.update_plan_status(
tenant_id=uuid.UUID(tenant_id),
plan_id=uuid.UUID(plan_id),
status=status,
approval_notes=notes
)
if not updated_plan:
raise HTTPException(status_code=404, detail="Plan not found")
return updated_plan
except HTTPException:
raise
except Exception as e:
logger.error("Error updating plan status", error=str(e), tenant_id=tenant_id, plan_id=plan_id)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/plans/{plan_id}/create-purchase-orders")
async def create_purchase_orders_from_plan(
tenant_id: str,
plan_id: str,
auto_approve: bool = Query(default=False, description="Auto-approve qualifying purchase orders"),
service: ProcurementService = Depends(get_procurement_service)
):
"""
Create purchase orders from procurement plan requirements
Groups requirements by supplier and creates POs automatically.
Optionally evaluates auto-approval rules for qualifying POs.
Args:
tenant_id: Tenant UUID
plan_id: Procurement plan UUID
auto_approve: Whether to auto-approve qualifying POs
Returns:
Summary of created, approved, and failed purchase orders
"""
try:
result = await service.create_purchase_orders_from_plan(
tenant_id=uuid.UUID(tenant_id),
plan_id=uuid.UUID(plan_id),
auto_approve=auto_approve
)
if not result.get('success'):
raise HTTPException(status_code=400, detail=result.get('error', 'Failed to create purchase orders'))
return result
except HTTPException:
raise
except Exception as e:
logger.error("Error creating POs from plan", error=str(e), tenant_id=tenant_id, plan_id=plan_id)
raise HTTPException(status_code=500, detail=str(e))
# ================================================================
# TESTING AND UTILITIES
# ================================================================
@router.get("/plans/{plan_id}/requirements")
async def get_plan_requirements(
tenant_id: str,
plan_id: str,
service: ProcurementService = Depends(get_procurement_service),
db: AsyncSession = Depends(get_db)
):
"""Get all requirements for a procurement plan"""
try:
from app.repositories.procurement_plan_repository import ProcurementRequirementRepository
repo = ProcurementRequirementRepository(db)
requirements = await repo.get_requirements_by_plan(uuid.UUID(plan_id))
return {
"plan_id": plan_id,
"requirements_count": len(requirements),
"requirements": [
{
"id": str(req.id),
"requirement_number": req.requirement_number,
"product_name": req.product_name,
"net_requirement": float(req.net_requirement),
"unit_of_measure": req.unit_of_measure,
"priority": req.priority,
"status": req.status,
"is_locally_produced": req.is_locally_produced,
"bom_explosion_level": req.bom_explosion_level,
"supplier_name": req.supplier_name,
"estimated_total_cost": float(req.estimated_total_cost or 0)
}
for req in requirements
]
}
except Exception as e:
logger.error("Error getting plan requirements", error=str(e), tenant_id=tenant_id, plan_id=plan_id)
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -0,0 +1,458 @@
# ================================================================
# services/procurement/app/api/purchase_orders.py
# ================================================================
"""
Purchase Orders API - Endpoints for purchase order management
"""
import uuid
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.config import settings
from app.services.purchase_order_service import PurchaseOrderService
from app.schemas.purchase_order_schemas import (
PurchaseOrderCreate,
PurchaseOrderUpdate,
PurchaseOrderResponse,
PurchaseOrderApproval,
DeliveryCreate,
DeliveryResponse,
SupplierInvoiceCreate,
SupplierInvoiceResponse,
)
import structlog
logger = structlog.get_logger()
router = APIRouter(prefix="/api/v1/tenants/{tenant_id}/purchase-orders", tags=["Purchase Orders"])
def get_po_service(db: AsyncSession = Depends(get_db)) -> PurchaseOrderService:
"""Dependency to get purchase order service"""
return PurchaseOrderService(db, settings)
# ================================================================
# PURCHASE ORDER CRUD
# ================================================================
@router.post("", response_model=PurchaseOrderResponse, status_code=201)
async def create_purchase_order(
tenant_id: str,
po_data: PurchaseOrderCreate,
service: PurchaseOrderService = Depends(get_po_service)
):
"""
Create a new purchase order with items
Creates a PO with automatic approval rules evaluation.
Links to procurement plan if procurement_plan_id is provided.
Args:
tenant_id: Tenant UUID
po_data: Purchase order creation data
Returns:
PurchaseOrderResponse with created PO details
"""
try:
logger.info("Create PO endpoint called", tenant_id=tenant_id)
po = await service.create_purchase_order(
tenant_id=uuid.UUID(tenant_id),
po_data=po_data
)
return PurchaseOrderResponse.model_validate(po)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error("Error creating purchase order", error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{po_id}", response_model=PurchaseOrderResponse)
async def get_purchase_order(
tenant_id: str,
po_id: str,
service: PurchaseOrderService = Depends(get_po_service)
):
"""Get purchase order by ID with items"""
try:
po = await service.get_purchase_order(
tenant_id=uuid.UUID(tenant_id),
po_id=uuid.UUID(po_id)
)
if not po:
raise HTTPException(status_code=404, detail="Purchase order not found")
return PurchaseOrderResponse.model_validate(po)
except HTTPException:
raise
except Exception as e:
logger.error("Error getting purchase order", error=str(e), po_id=po_id)
raise HTTPException(status_code=500, detail=str(e))
@router.get("", response_model=List[PurchaseOrderResponse])
async def list_purchase_orders(
tenant_id: str,
skip: int = Query(default=0, ge=0),
limit: int = Query(default=50, ge=1, le=100),
supplier_id: Optional[str] = Query(default=None),
status: Optional[str] = Query(default=None),
service: PurchaseOrderService = Depends(get_po_service)
):
"""
List purchase orders with filters
Args:
tenant_id: Tenant UUID
skip: Number of records to skip (pagination)
limit: Maximum number of records to return
supplier_id: Filter by supplier ID (optional)
status: Filter by status (optional)
Returns:
List of purchase orders
"""
try:
pos = await service.list_purchase_orders(
tenant_id=uuid.UUID(tenant_id),
skip=skip,
limit=limit,
supplier_id=uuid.UUID(supplier_id) if supplier_id else None,
status=status
)
return [PurchaseOrderResponse.model_validate(po) for po in pos]
except Exception as e:
logger.error("Error listing purchase orders", error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=str(e))
@router.patch("/{po_id}", response_model=PurchaseOrderResponse)
async def update_purchase_order(
tenant_id: str,
po_id: str,
po_data: PurchaseOrderUpdate,
service: PurchaseOrderService = Depends(get_po_service)
):
"""
Update purchase order information
Only draft or pending_approval orders can be modified.
Financial field changes trigger automatic total recalculation.
Args:
tenant_id: Tenant UUID
po_id: Purchase order UUID
po_data: Update data
Returns:
Updated purchase order
"""
try:
po = await service.update_purchase_order(
tenant_id=uuid.UUID(tenant_id),
po_id=uuid.UUID(po_id),
po_data=po_data
)
if not po:
raise HTTPException(status_code=404, detail="Purchase order not found")
return PurchaseOrderResponse.model_validate(po)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except HTTPException:
raise
except Exception as e:
logger.error("Error updating purchase order", error=str(e), po_id=po_id)
raise HTTPException(status_code=500, detail=str(e))
@router.patch("/{po_id}/status")
async def update_order_status(
tenant_id: str,
po_id: str,
status: str = Query(..., description="New status"),
notes: Optional[str] = Query(default=None),
service: PurchaseOrderService = Depends(get_po_service)
):
"""
Update purchase order status
Validates status transitions to prevent invalid state changes.
Valid transitions:
- draft -> pending_approval, approved, cancelled
- pending_approval -> approved, rejected, cancelled
- approved -> sent_to_supplier, cancelled
- sent_to_supplier -> confirmed, cancelled
- confirmed -> in_production, cancelled
- in_production -> shipped, cancelled
- shipped -> delivered, cancelled
- delivered -> completed
Args:
tenant_id: Tenant UUID
po_id: Purchase order UUID
status: New status
notes: Optional status change notes
Returns:
Updated purchase order
"""
try:
po = await service.update_order_status(
tenant_id=uuid.UUID(tenant_id),
po_id=uuid.UUID(po_id),
status=status,
notes=notes
)
if not po:
raise HTTPException(status_code=404, detail="Purchase order not found")
return PurchaseOrderResponse.model_validate(po)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except HTTPException:
raise
except Exception as e:
logger.error("Error updating PO status", error=str(e), po_id=po_id)
raise HTTPException(status_code=500, detail=str(e))
# ================================================================
# APPROVAL WORKFLOW
# ================================================================
@router.post("/{po_id}/approve", response_model=PurchaseOrderResponse)
async def approve_purchase_order(
tenant_id: str,
po_id: str,
approval_data: PurchaseOrderApproval,
service: PurchaseOrderService = Depends(get_po_service)
):
"""
Approve or reject a purchase order
Args:
tenant_id: Tenant UUID
po_id: Purchase order UUID
approval_data: Approval or rejection data
Returns:
Updated purchase order
"""
try:
if approval_data.action == "approve":
po = await service.approve_purchase_order(
tenant_id=uuid.UUID(tenant_id),
po_id=uuid.UUID(po_id),
approved_by=approval_data.approved_by,
approval_notes=approval_data.notes
)
elif approval_data.action == "reject":
po = await service.reject_purchase_order(
tenant_id=uuid.UUID(tenant_id),
po_id=uuid.UUID(po_id),
rejected_by=approval_data.approved_by,
rejection_reason=approval_data.notes or "No reason provided"
)
else:
raise ValueError("Invalid action. Must be 'approve' or 'reject'")
if not po:
raise HTTPException(status_code=404, detail="Purchase order not found")
return PurchaseOrderResponse.model_validate(po)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except HTTPException:
raise
except Exception as e:
logger.error("Error in PO approval workflow", error=str(e), po_id=po_id)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/{po_id}/cancel", response_model=PurchaseOrderResponse)
async def cancel_purchase_order(
tenant_id: str,
po_id: str,
reason: str = Query(..., description="Cancellation reason"),
cancelled_by: Optional[str] = Query(default=None),
service: PurchaseOrderService = Depends(get_po_service)
):
"""
Cancel a purchase order
Args:
tenant_id: Tenant UUID
po_id: Purchase order UUID
reason: Cancellation reason
cancelled_by: User ID performing cancellation
Returns:
Cancelled purchase order
"""
try:
po = await service.cancel_purchase_order(
tenant_id=uuid.UUID(tenant_id),
po_id=uuid.UUID(po_id),
cancelled_by=uuid.UUID(cancelled_by) if cancelled_by else None,
cancellation_reason=reason
)
if not po:
raise HTTPException(status_code=404, detail="Purchase order not found")
return PurchaseOrderResponse.model_validate(po)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except HTTPException:
raise
except Exception as e:
logger.error("Error cancelling purchase order", error=str(e), po_id=po_id)
raise HTTPException(status_code=500, detail=str(e))
# ================================================================
# DELIVERY MANAGEMENT
# ================================================================
@router.post("/{po_id}/deliveries", response_model=DeliveryResponse, status_code=201)
async def create_delivery(
tenant_id: str,
po_id: str,
delivery_data: DeliveryCreate,
service: PurchaseOrderService = Depends(get_po_service)
):
"""
Create a delivery record for a purchase order
Tracks delivery scheduling, items, quality inspection, and receipt.
Args:
tenant_id: Tenant UUID
po_id: Purchase order UUID
delivery_data: Delivery creation data
Returns:
DeliveryResponse with created delivery details
"""
try:
# Validate PO ID matches
if str(delivery_data.purchase_order_id) != po_id:
raise ValueError("Purchase order ID mismatch")
delivery = await service.create_delivery(
tenant_id=uuid.UUID(tenant_id),
delivery_data=delivery_data,
created_by=uuid.uuid4() # TODO: Get from auth context
)
return DeliveryResponse.model_validate(delivery)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error("Error creating delivery", error=str(e), po_id=po_id)
raise HTTPException(status_code=500, detail=str(e))
@router.patch("/deliveries/{delivery_id}/status")
async def update_delivery_status(
tenant_id: str,
delivery_id: str,
status: str = Query(..., description="New delivery status"),
service: PurchaseOrderService = Depends(get_po_service)
):
"""
Update delivery status
Valid statuses: scheduled, in_transit, delivered, completed, cancelled
Args:
tenant_id: Tenant UUID
delivery_id: Delivery UUID
status: New status
Returns:
Updated delivery
"""
try:
delivery = await service.update_delivery_status(
tenant_id=uuid.UUID(tenant_id),
delivery_id=uuid.UUID(delivery_id),
status=status,
updated_by=uuid.uuid4() # TODO: Get from auth context
)
if not delivery:
raise HTTPException(status_code=404, detail="Delivery not found")
return DeliveryResponse.model_validate(delivery)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except HTTPException:
raise
except Exception as e:
logger.error("Error updating delivery status", error=str(e), delivery_id=delivery_id)
raise HTTPException(status_code=500, detail=str(e))
# ================================================================
# INVOICE MANAGEMENT
# ================================================================
@router.post("/{po_id}/invoices", response_model=SupplierInvoiceResponse, status_code=201)
async def create_invoice(
tenant_id: str,
po_id: str,
invoice_data: SupplierInvoiceCreate,
service: PurchaseOrderService = Depends(get_po_service)
):
"""
Create a supplier invoice for a purchase order
Args:
tenant_id: Tenant UUID
po_id: Purchase order UUID
invoice_data: Invoice creation data
Returns:
SupplierInvoiceResponse with created invoice details
"""
try:
# Validate PO ID matches
if str(invoice_data.purchase_order_id) != po_id:
raise ValueError("Purchase order ID mismatch")
invoice = await service.create_invoice(
tenant_id=uuid.UUID(tenant_id),
invoice_data=invoice_data,
created_by=uuid.uuid4() # TODO: Get from auth context
)
return SupplierInvoiceResponse.model_validate(invoice)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error("Error creating invoice", error=str(e), po_id=po_id)
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -0,0 +1,430 @@
"""
Replenishment Planning API Routes
Provides endpoints for advanced replenishment planning including:
- Generate replenishment plans
- View inventory projections
- Review supplier allocations
- Get planning analytics
"""
from fastapi import APIRouter, Depends, HTTPException, Query, Path
from typing import List, Optional
from uuid import UUID
from datetime import date
from app.schemas.replenishment import (
GenerateReplenishmentPlanRequest,
GenerateReplenishmentPlanResponse,
ReplenishmentPlanResponse,
ReplenishmentPlanSummary,
InventoryProjectionResponse,
SupplierAllocationResponse,
SupplierSelectionRequest,
SupplierSelectionResult,
SafetyStockRequest,
SafetyStockResponse,
ProjectInventoryRequest,
ProjectInventoryResponse,
ReplenishmentAnalytics,
MOQAggregationRequest,
MOQAggregationResponse
)
from app.services.procurement_service import ProcurementService
from app.services.replenishment_planning_service import ReplenishmentPlanningService
from app.services.safety_stock_calculator import SafetyStockCalculator
from app.services.inventory_projector import InventoryProjector, DailyDemand, ScheduledReceipt
from app.services.moq_aggregator import MOQAggregator
from app.services.supplier_selector import SupplierSelector
from app.core.dependencies import get_db, get_current_tenant_id
from sqlalchemy.ext.asyncio import AsyncSession
import structlog
logger = structlog.get_logger()
router = APIRouter(prefix="/replenishment-plans", tags=["Replenishment Planning"])
# ============================================================
# Replenishment Plan Endpoints
# ============================================================
@router.post("/generate", response_model=GenerateReplenishmentPlanResponse)
async def generate_replenishment_plan(
request: GenerateReplenishmentPlanRequest,
tenant_id: UUID = Depends(get_current_tenant_id),
db: AsyncSession = Depends(get_db)
):
"""
Generate advanced replenishment plan with:
- Lead-time-aware order date calculation
- Dynamic safety stock
- Inventory projection
- Shelf-life management
"""
try:
logger.info("Generating replenishment plan", tenant_id=tenant_id)
# Initialize replenishment planner
planner = ReplenishmentPlanningService(
projection_horizon_days=request.projection_horizon_days,
default_service_level=request.service_level,
default_buffer_days=request.buffer_days
)
# Generate plan
plan = await planner.generate_replenishment_plan(
tenant_id=str(tenant_id),
requirements=request.requirements,
forecast_id=request.forecast_id,
production_schedule_id=request.production_schedule_id
)
# Export to response
plan_dict = planner.export_plan_to_dict(plan)
return GenerateReplenishmentPlanResponse(**plan_dict)
except Exception as e:
logger.error("Failed to generate replenishment plan",
tenant_id=tenant_id, error=str(e), exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("", response_model=List[ReplenishmentPlanSummary])
async def list_replenishment_plans(
tenant_id: UUID = Depends(get_current_tenant_id),
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
status: Optional[str] = None,
db: AsyncSession = Depends(get_db)
):
"""
List replenishment plans for tenant
"""
try:
# Query from database (implementation depends on your repo)
# This is a placeholder - implement based on your repository
from app.repositories.replenishment_repository import ReplenishmentPlanRepository
repo = ReplenishmentPlanRepository(db)
plans = await repo.list_plans(
tenant_id=tenant_id,
skip=skip,
limit=limit,
status=status
)
return plans
except Exception as e:
logger.error("Failed to list replenishment plans",
tenant_id=tenant_id, error=str(e))
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{plan_id}", response_model=ReplenishmentPlanResponse)
async def get_replenishment_plan(
plan_id: UUID = Path(...),
tenant_id: UUID = Depends(get_current_tenant_id),
db: AsyncSession = Depends(get_db)
):
"""
Get replenishment plan by ID
"""
try:
from app.repositories.replenishment_repository import ReplenishmentPlanRepository
repo = ReplenishmentPlanRepository(db)
plan = await repo.get_plan_by_id(plan_id, tenant_id)
if not plan:
raise HTTPException(status_code=404, detail="Replenishment plan not found")
return plan
except HTTPException:
raise
except Exception as e:
logger.error("Failed to get replenishment plan",
tenant_id=tenant_id, plan_id=plan_id, error=str(e))
raise HTTPException(status_code=500, detail=str(e))
# ============================================================
# Inventory Projection Endpoints
# ============================================================
@router.post("/inventory-projections/project", response_model=ProjectInventoryResponse)
async def project_inventory(
request: ProjectInventoryRequest,
tenant_id: UUID = Depends(get_current_tenant_id)
):
"""
Project inventory levels to identify future stockouts
"""
try:
logger.info("Projecting inventory", tenant_id=tenant_id,
ingredient_id=request.ingredient_id)
projector = InventoryProjector(request.projection_horizon_days)
# Build daily demand objects
daily_demand = [
DailyDemand(
ingredient_id=request.ingredient_id,
date=d['date'],
quantity=d['quantity']
)
for d in request.daily_demand
]
# Build scheduled receipts
scheduled_receipts = [
ScheduledReceipt(
ingredient_id=request.ingredient_id,
date=r['date'],
quantity=r['quantity'],
source=r.get('source', 'purchase_order'),
reference_id=r.get('reference_id')
)
for r in request.scheduled_receipts
]
# Project inventory
projection = projector.project_inventory(
ingredient_id=request.ingredient_id,
ingredient_name=request.ingredient_name,
current_stock=request.current_stock,
unit_of_measure=request.unit_of_measure,
daily_demand=daily_demand,
scheduled_receipts=scheduled_receipts
)
# Export to response
projection_dict = projector.export_projection_to_dict(projection)
return ProjectInventoryResponse(**projection_dict)
except Exception as e:
logger.error("Failed to project inventory",
tenant_id=tenant_id, error=str(e), exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/inventory-projections", response_model=List[InventoryProjectionResponse])
async def list_inventory_projections(
tenant_id: UUID = Depends(get_current_tenant_id),
ingredient_id: Optional[UUID] = None,
projection_date: Optional[date] = None,
stockout_only: bool = False,
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
db: AsyncSession = Depends(get_db)
):
"""
List inventory projections
"""
try:
from app.repositories.replenishment_repository import InventoryProjectionRepository
repo = InventoryProjectionRepository(db)
projections = await repo.list_projections(
tenant_id=tenant_id,
ingredient_id=ingredient_id,
projection_date=projection_date,
stockout_only=stockout_only,
skip=skip,
limit=limit
)
return projections
except Exception as e:
logger.error("Failed to list inventory projections",
tenant_id=tenant_id, error=str(e))
raise HTTPException(status_code=500, detail=str(e))
# ============================================================
# Safety Stock Endpoints
# ============================================================
@router.post("/safety-stock/calculate", response_model=SafetyStockResponse)
async def calculate_safety_stock(
request: SafetyStockRequest,
tenant_id: UUID = Depends(get_current_tenant_id)
):
"""
Calculate dynamic safety stock using statistical methods
"""
try:
logger.info("Calculating safety stock", tenant_id=tenant_id,
ingredient_id=request.ingredient_id)
calculator = SafetyStockCalculator(request.service_level)
result = calculator.calculate_from_demand_history(
daily_demands=request.daily_demands,
lead_time_days=request.lead_time_days,
service_level=request.service_level
)
return SafetyStockResponse(**calculator.export_to_dict(result))
except Exception as e:
logger.error("Failed to calculate safety stock",
tenant_id=tenant_id, error=str(e), exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
# ============================================================
# Supplier Selection Endpoints
# ============================================================
@router.post("/supplier-selections/evaluate", response_model=SupplierSelectionResult)
async def evaluate_supplier_selection(
request: SupplierSelectionRequest,
tenant_id: UUID = Depends(get_current_tenant_id)
):
"""
Evaluate supplier options using multi-criteria decision analysis
"""
try:
logger.info("Evaluating supplier selection", tenant_id=tenant_id,
ingredient_id=request.ingredient_id)
selector = SupplierSelector()
# Convert supplier options
from app.services.supplier_selector import SupplierOption
supplier_options = [
SupplierOption(**opt) for opt in request.supplier_options
]
result = selector.select_suppliers(
ingredient_id=request.ingredient_id,
ingredient_name=request.ingredient_name,
required_quantity=request.required_quantity,
supplier_options=supplier_options
)
return SupplierSelectionResult(**selector.export_result_to_dict(result))
except Exception as e:
logger.error("Failed to evaluate supplier selection",
tenant_id=tenant_id, error=str(e), exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/supplier-allocations", response_model=List[SupplierAllocationResponse])
async def list_supplier_allocations(
tenant_id: UUID = Depends(get_current_tenant_id),
requirement_id: Optional[UUID] = None,
supplier_id: Optional[UUID] = None,
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
db: AsyncSession = Depends(get_db)
):
"""
List supplier allocations
"""
try:
from app.repositories.replenishment_repository import SupplierAllocationRepository
repo = SupplierAllocationRepository(db)
allocations = await repo.list_allocations(
tenant_id=tenant_id,
requirement_id=requirement_id,
supplier_id=supplier_id,
skip=skip,
limit=limit
)
return allocations
except Exception as e:
logger.error("Failed to list supplier allocations",
tenant_id=tenant_id, error=str(e))
raise HTTPException(status_code=500, detail=str(e))
# ============================================================
# MOQ Aggregation Endpoints
# ============================================================
@router.post("/moq-aggregation/aggregate", response_model=MOQAggregationResponse)
async def aggregate_for_moq(
request: MOQAggregationRequest,
tenant_id: UUID = Depends(get_current_tenant_id)
):
"""
Aggregate requirements to meet Minimum Order Quantities
"""
try:
logger.info("Aggregating requirements for MOQ", tenant_id=tenant_id)
aggregator = MOQAggregator()
# Convert requirements and constraints
from app.services.moq_aggregator import (
ProcurementRequirement as MOQReq,
SupplierConstraints
)
requirements = [MOQReq(**req) for req in request.requirements]
constraints = {
k: SupplierConstraints(**v)
for k, v in request.supplier_constraints.items()
}
# Aggregate
aggregated_orders = aggregator.aggregate_requirements(
requirements=requirements,
supplier_constraints=constraints
)
# Calculate efficiency
efficiency = aggregator.calculate_order_efficiency(aggregated_orders)
return MOQAggregationResponse(
aggregated_orders=[aggregator.export_to_dict(order) for order in aggregated_orders],
efficiency_metrics=efficiency
)
except Exception as e:
logger.error("Failed to aggregate for MOQ",
tenant_id=tenant_id, error=str(e), exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
# ============================================================
# Analytics Endpoints
# ============================================================
@router.get("/analytics", response_model=ReplenishmentAnalytics)
async def get_replenishment_analytics(
tenant_id: UUID = Depends(get_current_tenant_id),
start_date: Optional[date] = None,
end_date: Optional[date] = None,
db: AsyncSession = Depends(get_db)
):
"""
Get replenishment planning analytics
"""
try:
from app.repositories.replenishment_repository import ReplenishmentAnalyticsRepository
repo = ReplenishmentAnalyticsRepository(db)
analytics = await repo.get_analytics(
tenant_id=tenant_id,
start_date=start_date,
end_date=end_date
)
return analytics
except Exception as e:
logger.error("Failed to get replenishment analytics",
tenant_id=tenant_id, error=str(e))
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -0,0 +1,142 @@
# ================================================================
# services/procurement/app/core/config.py
# ================================================================
"""
Procurement Service Configuration
"""
import os
from decimal import Decimal
from pydantic import Field
from shared.config.base import BaseServiceSettings
class ProcurementSettings(BaseServiceSettings):
"""Procurement service specific settings"""
# Service Identity
APP_NAME: str = "Procurement Service"
SERVICE_NAME: str = "procurement-service"
VERSION: str = "1.0.0"
DESCRIPTION: str = "Procurement planning, purchase order management, and supplier integration"
# Database configuration (secure approach - build from components)
@property
def DATABASE_URL(self) -> str:
"""Build database URL from secure components"""
# Try complete URL first (for backward compatibility)
complete_url = os.getenv("PROCUREMENT_DATABASE_URL")
if complete_url:
return complete_url
# Build from components (secure approach)
user = os.getenv("PROCUREMENT_DB_USER", "procurement_user")
password = os.getenv("PROCUREMENT_DB_PASSWORD", "procurement_pass123")
host = os.getenv("PROCUREMENT_DB_HOST", "localhost")
port = os.getenv("PROCUREMENT_DB_PORT", "5432")
name = os.getenv("PROCUREMENT_DB_NAME", "procurement_db")
return f"postgresql+asyncpg://{user}:{password}@{host}:{port}/{name}"
# Procurement Planning
PROCUREMENT_PLANNING_ENABLED: bool = os.getenv("PROCUREMENT_PLANNING_ENABLED", "true").lower() == "true"
PROCUREMENT_LEAD_TIME_DAYS: int = int(os.getenv("PROCUREMENT_LEAD_TIME_DAYS", "3"))
DEMAND_FORECAST_DAYS: int = int(os.getenv("DEMAND_FORECAST_DAYS", "14"))
SAFETY_STOCK_PERCENTAGE: float = float(os.getenv("SAFETY_STOCK_PERCENTAGE", "20.0"))
# Purchase Order Settings
AUTO_APPROVE_POS: bool = os.getenv("AUTO_APPROVE_POS", "false").lower() == "true"
AUTO_APPROVAL_MAX_AMOUNT: float = float(os.getenv("AUTO_APPROVAL_MAX_AMOUNT", "1000.0"))
MAX_PO_ITEMS: int = int(os.getenv("MAX_PO_ITEMS", "100"))
PO_EXPIRY_DAYS: int = int(os.getenv("PO_EXPIRY_DAYS", "30"))
# Local Production Settings
SUPPORT_LOCAL_PRODUCTION: bool = os.getenv("SUPPORT_LOCAL_PRODUCTION", "true").lower() == "true"
MAX_BOM_EXPLOSION_DEPTH: int = int(os.getenv("MAX_BOM_EXPLOSION_DEPTH", "5"))
RECIPE_CACHE_TTL_SECONDS: int = int(os.getenv("RECIPE_CACHE_TTL_SECONDS", "3600"))
# Supplier Integration
SUPPLIER_VALIDATION_ENABLED: bool = os.getenv("SUPPLIER_VALIDATION_ENABLED", "true").lower() == "true"
MIN_SUPPLIER_RATING: float = float(os.getenv("MIN_SUPPLIER_RATING", "3.0"))
MULTI_SUPPLIER_ENABLED: bool = os.getenv("MULTI_SUPPLIER_ENABLED", "true").lower() == "true"
# Plan Management
STALE_PLAN_DAYS: int = int(os.getenv("STALE_PLAN_DAYS", "7"))
ARCHIVE_PLAN_DAYS: int = int(os.getenv("ARCHIVE_PLAN_DAYS", "90"))
MAX_CONCURRENT_PLANS: int = int(os.getenv("MAX_CONCURRENT_PLANS", "10"))
# Integration Settings
INVENTORY_SERVICE_URL: str = os.getenv("INVENTORY_SERVICE_URL", "http://inventory-service:8000")
SUPPLIERS_SERVICE_URL: str = os.getenv("SUPPLIERS_SERVICE_URL", "http://suppliers-service:8000")
# ================================================================
# REPLENISHMENT PLANNING SETTINGS
# ================================================================
# Projection Settings
REPLENISHMENT_PROJECTION_HORIZON_DAYS: int = Field(
default=7,
description="Days to project ahead for inventory planning"
)
REPLENISHMENT_SERVICE_LEVEL: float = Field(
default=0.95,
description="Target service level for safety stock (0-1)"
)
REPLENISHMENT_BUFFER_DAYS: int = Field(
default=1,
description="Buffer days to add to lead time"
)
# Safety Stock Settings
SAFETY_STOCK_SERVICE_LEVEL: float = Field(
default=0.95,
description="Default service level for safety stock calculation"
)
SAFETY_STOCK_METHOD: str = Field(
default="statistical",
description="Method for safety stock: 'statistical' or 'fixed_percentage'"
)
# MOQ Aggregation Settings
MOQ_CONSOLIDATION_WINDOW_DAYS: int = Field(
default=7,
description="Days within which to consolidate orders for MOQ"
)
MOQ_ALLOW_EARLY_ORDERING: bool = Field(
default=True,
description="Allow ordering early to meet MOQ"
)
# Supplier Selection Settings
SUPPLIER_PRICE_WEIGHT: float = Field(
default=0.40,
description="Weight for price in supplier selection (0-1)"
)
SUPPLIER_LEAD_TIME_WEIGHT: float = Field(
default=0.20,
description="Weight for lead time in supplier selection (0-1)"
)
SUPPLIER_QUALITY_WEIGHT: float = Field(
default=0.20,
description="Weight for quality in supplier selection (0-1)"
)
SUPPLIER_RELIABILITY_WEIGHT: float = Field(
default=0.20,
description="Weight for reliability in supplier selection (0-1)"
)
SUPPLIER_DIVERSIFICATION_THRESHOLD: Decimal = Field(
default=Decimal('1000'),
description="Quantity threshold for supplier diversification"
)
SUPPLIER_MAX_SINGLE_PERCENTAGE: float = Field(
default=0.70,
description="Maximum % of order to single supplier (0-1)"
)
FORECASTING_SERVICE_URL: str = os.getenv("FORECASTING_SERVICE_URL", "http://forecasting-service:8000")
RECIPES_SERVICE_URL: str = os.getenv("RECIPES_SERVICE_URL", "http://recipes-service:8000")
NOTIFICATION_SERVICE_URL: str = os.getenv("NOTIFICATION_SERVICE_URL", "http://notification-service:8000")
TENANT_SERVICE_URL: str = os.getenv("TENANT_SERVICE_URL", "http://tenant-service:8000")
# Global settings instance
settings = ProcurementSettings()

View File

@@ -0,0 +1,47 @@
# ================================================================
# services/procurement/app/core/database.py
# ================================================================
"""
Database connection and session management for Procurement Service
"""
from shared.database.base import DatabaseManager
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from .config import settings
# Initialize database manager
database_manager = DatabaseManager(
database_url=settings.DATABASE_URL,
echo=settings.DEBUG
)
# Create async session factory
AsyncSessionLocal = async_sessionmaker(
database_manager.async_engine,
class_=AsyncSession,
expire_on_commit=False,
autocommit=False,
autoflush=False,
)
async def get_db() -> AsyncSession:
"""
Dependency to get database session.
Used in FastAPI endpoints via Depends(get_db).
"""
async with AsyncSessionLocal() as session:
try:
yield session
finally:
await session.close()
async def init_db():
"""Initialize database (create tables if needed)"""
await database_manager.create_all()
async def close_db():
"""Close database connections"""
await database_manager.close()

View File

@@ -0,0 +1,44 @@
"""
FastAPI Dependencies for Procurement Service
"""
from fastapi import Header, HTTPException, status
from uuid import UUID
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from .database import get_db
async def get_current_tenant_id(
x_tenant_id: Optional[str] = Header(None, alias="X-Tenant-ID")
) -> UUID:
"""
Extract and validate tenant ID from request header.
Args:
x_tenant_id: Tenant ID from X-Tenant-ID header
Returns:
UUID: Validated tenant ID
Raises:
HTTPException: If tenant ID is missing or invalid
"""
if not x_tenant_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="X-Tenant-ID header is required"
)
try:
return UUID(x_tenant_id)
except (ValueError, AttributeError):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid tenant ID format: {x_tenant_id}"
)
# Re-export get_db for convenience
__all__ = ["get_db", "get_current_tenant_id"]

View File

@@ -0,0 +1,130 @@
# ================================================================
# services/procurement/app/main.py
# ================================================================
"""
Procurement Service - FastAPI Application
Procurement planning, purchase order management, and supplier integration
"""
from fastapi import FastAPI, Request
from sqlalchemy import text
from app.core.config import settings
from app.core.database import database_manager
from shared.service_base import StandardFastAPIService
class ProcurementService(StandardFastAPIService):
"""Procurement Service with standardized setup"""
expected_migration_version = "00001"
async def verify_migrations(self):
"""Verify database schema matches the latest migrations"""
try:
async with self.database_manager.get_session() as session:
result = await session.execute(text("SELECT version_num FROM alembic_version"))
version = result.scalar()
if version != self.expected_migration_version:
self.logger.error(f"Migration version mismatch: expected {self.expected_migration_version}, got {version}")
raise RuntimeError(f"Migration version mismatch: expected {self.expected_migration_version}, got {version}")
self.logger.info(f"Migration verification successful: {version}")
except Exception as e:
self.logger.error(f"Migration verification failed: {e}")
raise
def __init__(self):
# Define expected database tables for health checks
procurement_expected_tables = [
'procurement_plans',
'procurement_requirements',
'purchase_orders',
'purchase_order_items',
'deliveries',
'delivery_items',
'supplier_invoices',
'replenishment_plans',
'replenishment_plan_items',
'inventory_projections',
'supplier_allocations',
'supplier_selection_history'
]
super().__init__(
service_name="procurement-service",
app_name=settings.APP_NAME,
description=settings.DESCRIPTION,
version=settings.VERSION,
api_prefix="", # Empty because RouteBuilder already includes /api/v1
database_manager=database_manager,
expected_tables=procurement_expected_tables
)
async def on_startup(self, app: FastAPI):
"""Custom startup logic for procurement service"""
self.logger.info("Procurement Service starting up...")
# Future: Initialize any background services if needed
async def on_shutdown(self, app: FastAPI):
"""Custom shutdown logic for procurement service"""
self.logger.info("Procurement Service shutting down...")
def get_service_features(self):
"""Return procurement-specific features"""
return [
"procurement_planning",
"purchase_order_management",
"delivery_tracking",
"invoice_management",
"supplier_integration",
"local_production_support",
"recipe_explosion"
]
# Create service instance
service = ProcurementService()
# Create FastAPI app with standardized setup
app = service.create_app()
# Setup standard endpoints (health, readiness, metrics)
service.setup_standard_endpoints()
# Include routers
from app.api.procurement_plans import router as procurement_plans_router
from app.api.purchase_orders import router as purchase_orders_router
from app.api import replenishment # Enhanced Replenishment Planning Routes
from app.api import internal_demo
service.add_router(procurement_plans_router)
service.add_router(purchase_orders_router)
service.add_router(replenishment.router, prefix="/api/v1/tenants/{tenant_id}", tags=["replenishment"])
service.add_router(internal_demo.router)
@app.middleware("http")
async def logging_middleware(request: Request, call_next):
"""Add request logging middleware"""
import time
start_time = time.time()
response = await call_next(request)
process_time = time.time() - start_time
service.logger.info("HTTP request processed",
method=request.method,
url=str(request.url),
status_code=response.status_code,
process_time=round(process_time, 4))
return response
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"main:app",
host="0.0.0.0",
port=8000,
reload=settings.DEBUG
)

View File

@@ -0,0 +1,38 @@
# ================================================================
# services/procurement/app/models/__init__.py
# ================================================================
"""
Procurement Service Models
"""
from .procurement_plan import ProcurementPlan, ProcurementRequirement
from .purchase_order import (
PurchaseOrder,
PurchaseOrderItem,
PurchaseOrderStatus,
Delivery,
DeliveryItem,
DeliveryStatus,
SupplierInvoice,
InvoiceStatus,
QualityRating,
)
__all__ = [
# Procurement Planning
"ProcurementPlan",
"ProcurementRequirement",
# Purchase Orders
"PurchaseOrder",
"PurchaseOrderItem",
"PurchaseOrderStatus",
# Deliveries
"Delivery",
"DeliveryItem",
"DeliveryStatus",
# Invoices
"SupplierInvoice",
"InvoiceStatus",
# Enums
"QualityRating",
]

View File

@@ -0,0 +1,234 @@
# ================================================================
# services/procurement/app/models/procurement_plan.py
# ================================================================
"""
Procurement Planning Models
Migrated from Orders Service
"""
import uuid
from datetime import datetime, date
from decimal import Decimal
from sqlalchemy import Column, String, Boolean, DateTime, Date, Numeric, Text, Integer, ForeignKey
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from shared.database.base import Base
class ProcurementPlan(Base):
"""Master procurement plan for coordinating supply needs across orders and production"""
__tablename__ = "procurement_plans"
# Primary identification
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
plan_number = Column(String(50), nullable=False, unique=True, index=True)
# Plan scope and timing
plan_date = Column(Date, nullable=False, index=True)
plan_period_start = Column(Date, nullable=False)
plan_period_end = Column(Date, nullable=False)
planning_horizon_days = Column(Integer, nullable=False, default=14)
# Plan status and lifecycle
status = Column(String(50), nullable=False, default="draft", index=True)
# Status values: draft, pending_approval, approved, in_execution, completed, cancelled
plan_type = Column(String(50), nullable=False, default="regular") # regular, emergency, seasonal
priority = Column(String(20), nullable=False, default="normal") # high, normal, low
# Business model context
business_model = Column(String(50), nullable=True) # individual_bakery, central_bakery
procurement_strategy = Column(String(50), nullable=False, default="just_in_time") # just_in_time, bulk, mixed
# Plan totals and summary
total_requirements = Column(Integer, nullable=False, default=0)
total_estimated_cost = Column(Numeric(12, 2), nullable=False, default=Decimal("0.00"))
total_approved_cost = Column(Numeric(12, 2), nullable=False, default=Decimal("0.00"))
cost_variance = Column(Numeric(12, 2), nullable=False, default=Decimal("0.00"))
# Demand analysis
total_demand_orders = Column(Integer, nullable=False, default=0)
total_demand_quantity = Column(Numeric(12, 3), nullable=False, default=Decimal("0.000"))
total_production_requirements = Column(Numeric(12, 3), nullable=False, default=Decimal("0.000"))
safety_stock_buffer = Column(Numeric(5, 2), nullable=False, default=Decimal("20.00")) # Percentage
# Supplier coordination
primary_suppliers_count = Column(Integer, nullable=False, default=0)
backup_suppliers_count = Column(Integer, nullable=False, default=0)
supplier_diversification_score = Column(Numeric(3, 1), nullable=True) # 1.0 to 10.0
# Risk assessment
supply_risk_level = Column(String(20), nullable=False, default="low") # low, medium, high, critical
demand_forecast_confidence = Column(Numeric(3, 1), nullable=True) # 1.0 to 10.0
seasonality_adjustment = Column(Numeric(5, 2), nullable=False, default=Decimal("0.00"))
# Execution tracking
approved_at = Column(DateTime(timezone=True), nullable=True)
approved_by = Column(UUID(as_uuid=True), nullable=True)
execution_started_at = Column(DateTime(timezone=True), nullable=True)
execution_completed_at = Column(DateTime(timezone=True), nullable=True)
# Performance metrics
fulfillment_rate = Column(Numeric(5, 2), nullable=True) # Percentage
on_time_delivery_rate = Column(Numeric(5, 2), nullable=True) # Percentage
cost_accuracy = Column(Numeric(5, 2), nullable=True) # Percentage
quality_score = Column(Numeric(3, 1), nullable=True) # 1.0 to 10.0
# Integration data
source_orders = Column(JSONB, nullable=True) # Orders that drove this plan
production_schedules = Column(JSONB, nullable=True) # Associated production schedules
inventory_snapshots = Column(JSONB, nullable=True) # Inventory levels at planning time
forecast_data = Column(JSONB, nullable=True) # Forecasting service data used for this plan
# Communication and collaboration
stakeholder_notifications = Column(JSONB, nullable=True) # Who was notified and when
approval_workflow = Column(JSONB, nullable=True) # Approval chain and status
# Special considerations
special_requirements = Column(Text, nullable=True)
seasonal_adjustments = Column(JSONB, nullable=True)
emergency_provisions = Column(JSONB, nullable=True)
# External references
erp_reference = Column(String(100), nullable=True)
supplier_portal_reference = Column(String(100), nullable=True)
# Audit fields
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
created_by = Column(UUID(as_uuid=True), nullable=True)
updated_by = Column(UUID(as_uuid=True), nullable=True)
# Additional metadata
plan_metadata = Column(JSONB, nullable=True)
# Relationships
requirements = relationship("ProcurementRequirement", back_populates="plan", cascade="all, delete-orphan")
class ProcurementRequirement(Base):
"""Individual procurement requirements within a procurement plan"""
__tablename__ = "procurement_requirements"
# Primary identification
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
plan_id = Column(UUID(as_uuid=True), ForeignKey("procurement_plans.id", ondelete="CASCADE"), nullable=False)
requirement_number = Column(String(50), nullable=False, index=True)
# Product/ingredient information
product_id = Column(UUID(as_uuid=True), nullable=False, index=True) # Reference to products/ingredients
product_name = Column(String(200), nullable=False)
product_sku = Column(String(100), nullable=True)
product_category = Column(String(100), nullable=True)
product_type = Column(String(50), nullable=False, default="ingredient") # ingredient, packaging, supplies
# Local production tracking
is_locally_produced = Column(Boolean, nullable=False, default=False) # If true, this is for a locally-produced item
recipe_id = Column(UUID(as_uuid=True), nullable=True) # Recipe used for BOM explosion
parent_requirement_id = Column(UUID(as_uuid=True), nullable=True) # If this is from BOM explosion
bom_explosion_level = Column(Integer, nullable=False, default=0) # Depth in BOM tree
# Requirement details
required_quantity = Column(Numeric(12, 3), nullable=False)
unit_of_measure = Column(String(50), nullable=False)
safety_stock_quantity = Column(Numeric(12, 3), nullable=False, default=Decimal("0.000"))
total_quantity_needed = Column(Numeric(12, 3), nullable=False)
# Current inventory situation
current_stock_level = Column(Numeric(12, 3), nullable=False, default=Decimal("0.000"))
reserved_stock = Column(Numeric(12, 3), nullable=False, default=Decimal("0.000"))
available_stock = Column(Numeric(12, 3), nullable=False, default=Decimal("0.000"))
net_requirement = Column(Numeric(12, 3), nullable=False)
# Demand breakdown
order_demand = Column(Numeric(12, 3), nullable=False, default=Decimal("0.000"))
production_demand = Column(Numeric(12, 3), nullable=False, default=Decimal("0.000"))
forecast_demand = Column(Numeric(12, 3), nullable=False, default=Decimal("0.000"))
buffer_demand = Column(Numeric(12, 3), nullable=False, default=Decimal("0.000"))
# Supplier information
preferred_supplier_id = Column(UUID(as_uuid=True), nullable=True)
backup_supplier_id = Column(UUID(as_uuid=True), nullable=True)
supplier_name = Column(String(200), nullable=True)
supplier_lead_time_days = Column(Integer, nullable=True)
minimum_order_quantity = Column(Numeric(12, 3), nullable=True)
# Pricing and cost
estimated_unit_cost = Column(Numeric(10, 4), nullable=True)
estimated_total_cost = Column(Numeric(12, 2), nullable=True)
last_purchase_cost = Column(Numeric(10, 4), nullable=True)
cost_variance = Column(Numeric(10, 2), nullable=False, default=Decimal("0.00"))
# Timing requirements
required_by_date = Column(Date, nullable=False)
lead_time_buffer_days = Column(Integer, nullable=False, default=1)
suggested_order_date = Column(Date, nullable=False)
latest_order_date = Column(Date, nullable=False)
# Quality and specifications
quality_specifications = Column(JSONB, nullable=True)
special_requirements = Column(Text, nullable=True)
storage_requirements = Column(String(200), nullable=True)
shelf_life_days = Column(Integer, nullable=True)
# Requirement status
status = Column(String(50), nullable=False, default="pending")
# Status values: pending, approved, ordered, partially_received, received, cancelled
priority = Column(String(20), nullable=False, default="normal") # critical, high, normal, low
risk_level = Column(String(20), nullable=False, default="low") # low, medium, high, critical
# Purchase order tracking
purchase_order_id = Column(UUID(as_uuid=True), nullable=True)
purchase_order_number = Column(String(50), nullable=True)
ordered_quantity = Column(Numeric(12, 3), nullable=False, default=Decimal("0.000"))
ordered_at = Column(DateTime(timezone=True), nullable=True)
# Delivery tracking
expected_delivery_date = Column(Date, nullable=True)
actual_delivery_date = Column(Date, nullable=True)
received_quantity = Column(Numeric(12, 3), nullable=False, default=Decimal("0.000"))
delivery_status = Column(String(50), nullable=False, default="pending")
# Performance tracking
fulfillment_rate = Column(Numeric(5, 2), nullable=True) # Percentage
on_time_delivery = Column(Boolean, nullable=True)
quality_rating = Column(Numeric(3, 1), nullable=True) # 1.0 to 10.0
# Source traceability
source_orders = Column(JSONB, nullable=True) # Orders that contributed to this requirement
source_production_batches = Column(JSONB, nullable=True) # Production batches needing this
demand_analysis = Column(JSONB, nullable=True) # Detailed demand breakdown
# Smart procurement calculation metadata
calculation_method = Column(String(100), nullable=True) # Method used: REORDER_POINT_TRIGGERED, FORECAST_DRIVEN_PROACTIVE, etc.
ai_suggested_quantity = Column(Numeric(12, 3), nullable=True) # Pure AI forecast quantity
adjusted_quantity = Column(Numeric(12, 3), nullable=True) # Final quantity after applying constraints
adjustment_reason = Column(Text, nullable=True) # Human-readable explanation of adjustments
price_tier_applied = Column(JSONB, nullable=True) # Price tier information if applicable
supplier_minimum_applied = Column(Boolean, nullable=False, default=False) # Whether supplier minimum was enforced
storage_limit_applied = Column(Boolean, nullable=False, default=False) # Whether storage limit was hit
reorder_rule_applied = Column(Boolean, nullable=False, default=False) # Whether reorder rules were used
# Approval and authorization
approved_quantity = Column(Numeric(12, 3), nullable=True)
approved_cost = Column(Numeric(12, 2), nullable=True)
approved_at = Column(DateTime(timezone=True), nullable=True)
approved_by = Column(UUID(as_uuid=True), nullable=True)
# Notes and communication
procurement_notes = Column(Text, nullable=True)
supplier_communication = Column(JSONB, nullable=True)
# Audit fields
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
# Additional metadata
requirement_metadata = Column(JSONB, nullable=True)
# Relationships
plan = relationship("ProcurementPlan", back_populates="requirements")

View File

@@ -0,0 +1,348 @@
# ================================================================
# services/procurement/app/models/purchase_order.py
# ================================================================
"""
Purchase Order Models
Migrated from Suppliers Service - Now owned by Procurement Service
"""
import uuid
import enum
from datetime import datetime, timezone
from decimal import Decimal
from sqlalchemy import Column, String, DateTime, Float, Integer, Text, Index, Boolean, Numeric, ForeignKey, Enum as SQLEnum
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from shared.database.base import Base
class PurchaseOrderStatus(enum.Enum):
"""Purchase order lifecycle status"""
draft = "draft"
pending_approval = "pending_approval"
approved = "approved"
sent_to_supplier = "sent_to_supplier"
confirmed = "confirmed"
partially_received = "partially_received"
completed = "completed"
cancelled = "cancelled"
disputed = "disputed"
class DeliveryStatus(enum.Enum):
"""Delivery status tracking"""
scheduled = "scheduled"
in_transit = "in_transit"
out_for_delivery = "out_for_delivery"
delivered = "delivered"
partially_delivered = "partially_delivered"
failed_delivery = "failed_delivery"
returned = "returned"
class QualityRating(enum.Enum):
"""Quality rating scale"""
excellent = 5
good = 4
average = 3
poor = 2
very_poor = 1
class InvoiceStatus(enum.Enum):
"""Invoice processing status"""
pending = "pending"
approved = "approved"
paid = "paid"
overdue = "overdue"
disputed = "disputed"
cancelled = "cancelled"
class PurchaseOrder(Base):
"""Purchase orders to suppliers - Core procurement execution"""
__tablename__ = "purchase_orders"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
supplier_id = Column(UUID(as_uuid=True), nullable=False, index=True) # Reference to Suppliers Service
# Order identification
po_number = Column(String(50), nullable=False, unique=True, index=True) # Human-readable PO number
reference_number = Column(String(100), nullable=True) # Internal reference
# Link to procurement plan
procurement_plan_id = Column(UUID(as_uuid=True), nullable=True, index=True) # Link to ProcurementPlan
# Order status and workflow
status = Column(SQLEnum(PurchaseOrderStatus), nullable=False, default=PurchaseOrderStatus.draft, index=True)
priority = Column(String(20), nullable=False, default="normal") # urgent, high, normal, low
# Order details
order_date = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
required_delivery_date = Column(DateTime(timezone=True), nullable=True) # Stored as DateTime for consistency
estimated_delivery_date = Column(DateTime(timezone=True), nullable=True)
# Financial information
subtotal = Column(Numeric(12, 2), nullable=False, default=Decimal("0.00"))
tax_amount = Column(Numeric(12, 2), nullable=False, default=Decimal("0.00"))
shipping_cost = Column(Numeric(10, 2), nullable=False, default=Decimal("0.00"))
discount_amount = Column(Numeric(10, 2), nullable=False, default=Decimal("0.00"))
total_amount = Column(Numeric(12, 2), nullable=False, default=Decimal("0.00"))
currency = Column(String(3), nullable=False, default="EUR")
# Delivery information
delivery_address = Column(Text, nullable=True) # Override default address
delivery_instructions = Column(Text, nullable=True)
delivery_contact = Column(String(200), nullable=True)
delivery_phone = Column(String(30), nullable=True)
# Approval workflow
requires_approval = Column(Boolean, nullable=False, default=False)
approved_by = Column(UUID(as_uuid=True), nullable=True)
approved_at = Column(DateTime(timezone=True), nullable=True)
rejection_reason = Column(Text, nullable=True)
# Auto-approval tracking
auto_approved = Column(Boolean, nullable=False, default=False) # Whether this was auto-approved
auto_approval_rule_id = Column(UUID(as_uuid=True), nullable=True) # Which rule approved it
# Communication tracking
sent_to_supplier_at = Column(DateTime(timezone=True), nullable=True)
supplier_confirmation_date = Column(DateTime(timezone=True), nullable=True)
supplier_reference = Column(String(100), nullable=True) # Supplier's order reference
# Additional information
notes = Column(Text, nullable=True)
internal_notes = Column(Text, nullable=True) # Not shared with supplier
terms_and_conditions = Column(Text, nullable=True)
# Audit fields
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
created_by = Column(UUID(as_uuid=True), nullable=False)
updated_by = Column(UUID(as_uuid=True), nullable=False)
# Relationships
items = relationship("PurchaseOrderItem", back_populates="purchase_order", cascade="all, delete-orphan")
deliveries = relationship("Delivery", back_populates="purchase_order", cascade="all, delete-orphan")
invoices = relationship("SupplierInvoice", back_populates="purchase_order", cascade="all, delete-orphan")
# Indexes
__table_args__ = (
Index('ix_purchase_orders_tenant_supplier', 'tenant_id', 'supplier_id'),
Index('ix_purchase_orders_tenant_status', 'tenant_id', 'status'),
Index('ix_purchase_orders_tenant_plan', 'tenant_id', 'procurement_plan_id'),
Index('ix_purchase_orders_order_date', 'order_date'),
Index('ix_purchase_orders_delivery_date', 'required_delivery_date'),
)
class PurchaseOrderItem(Base):
"""Individual items within purchase orders"""
__tablename__ = "purchase_order_items"
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_id = Column(UUID(as_uuid=True), ForeignKey('purchase_orders.id', ondelete='CASCADE'), nullable=False, index=True)
# Link to procurement requirement
procurement_requirement_id = Column(UUID(as_uuid=True), nullable=True, index=True) # Link to ProcurementRequirement
# Product identification (references Inventory Service)
inventory_product_id = Column(UUID(as_uuid=True), nullable=False, index=True)
product_code = Column(String(100), nullable=True) # Supplier's product code
product_name = Column(String(200), nullable=False) # Denormalized for convenience
# Supplier price list reference (from Suppliers Service)
supplier_price_list_id = Column(UUID(as_uuid=True), nullable=True, index=True)
# Order quantities
ordered_quantity = Column(Numeric(12, 3), nullable=False)
unit_of_measure = Column(String(20), nullable=False)
unit_price = Column(Numeric(10, 4), nullable=False)
line_total = Column(Numeric(12, 2), nullable=False)
# Delivery tracking
received_quantity = Column(Numeric(12, 3), nullable=False, default=Decimal("0.000"))
remaining_quantity = Column(Numeric(12, 3), nullable=False, default=Decimal("0.000"))
# Quality and notes
quality_requirements = Column(Text, nullable=True)
item_notes = Column(Text, nullable=True)
# Audit fields
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
# Relationships
purchase_order = relationship("PurchaseOrder", back_populates="items")
delivery_items = relationship("DeliveryItem", back_populates="purchase_order_item", cascade="all, delete-orphan")
# Indexes
__table_args__ = (
Index('ix_po_items_tenant_po', 'tenant_id', 'purchase_order_id'),
Index('ix_po_items_inventory_product', 'inventory_product_id'),
Index('ix_po_items_requirement', 'procurement_requirement_id'),
)
class Delivery(Base):
"""Delivery tracking for purchase orders"""
__tablename__ = "deliveries"
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_id = Column(UUID(as_uuid=True), ForeignKey('purchase_orders.id', ondelete='CASCADE'), nullable=False, index=True)
supplier_id = Column(UUID(as_uuid=True), nullable=False, index=True) # Reference to Suppliers Service
# Delivery identification
delivery_number = Column(String(50), nullable=False, unique=True, index=True)
supplier_delivery_note = Column(String(100), nullable=True) # Supplier's delivery reference
# Delivery status and tracking
status = Column(SQLEnum(DeliveryStatus), nullable=False, default=DeliveryStatus.scheduled, index=True)
# Scheduling and timing
scheduled_date = Column(DateTime(timezone=True), nullable=True)
estimated_arrival = Column(DateTime(timezone=True), nullable=True)
actual_arrival = Column(DateTime(timezone=True), nullable=True)
completed_at = Column(DateTime(timezone=True), nullable=True)
# Delivery details
delivery_address = Column(Text, nullable=True)
delivery_contact = Column(String(200), nullable=True)
delivery_phone = Column(String(30), nullable=True)
carrier_name = Column(String(200), nullable=True)
tracking_number = Column(String(100), nullable=True)
# Quality inspection
inspection_passed = Column(Boolean, nullable=True)
inspection_notes = Column(Text, nullable=True)
quality_issues = Column(JSONB, nullable=True) # Documented quality problems
# Received by information
received_by = Column(UUID(as_uuid=True), nullable=True) # User who received the delivery
received_at = Column(DateTime(timezone=True), nullable=True)
# Additional information
notes = Column(Text, nullable=True)
photos = Column(JSONB, nullable=True) # Photo URLs for documentation
# Audit fields
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
created_by = Column(UUID(as_uuid=True), nullable=False)
# Relationships
purchase_order = relationship("PurchaseOrder", back_populates="deliveries")
items = relationship("DeliveryItem", back_populates="delivery", cascade="all, delete-orphan")
# Indexes
__table_args__ = (
Index('ix_deliveries_tenant_status', 'tenant_id', 'status'),
Index('ix_deliveries_scheduled_date', 'scheduled_date'),
Index('ix_deliveries_tenant_po', 'tenant_id', 'purchase_order_id'),
)
class DeliveryItem(Base):
"""Individual items within deliveries"""
__tablename__ = "delivery_items"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
delivery_id = Column(UUID(as_uuid=True), ForeignKey('deliveries.id', ondelete='CASCADE'), nullable=False, index=True)
purchase_order_item_id = Column(UUID(as_uuid=True), ForeignKey('purchase_order_items.id', ondelete='CASCADE'), nullable=False, index=True)
# Product identification
inventory_product_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# Delivery quantities
ordered_quantity = Column(Numeric(12, 3), nullable=False)
delivered_quantity = Column(Numeric(12, 3), nullable=False)
accepted_quantity = Column(Numeric(12, 3), nullable=False)
rejected_quantity = Column(Numeric(12, 3), nullable=False, default=Decimal("0.000"))
# Quality information
batch_lot_number = Column(String(100), nullable=True)
expiry_date = Column(DateTime(timezone=True), nullable=True)
quality_grade = Column(String(20), nullable=True)
# Issues and notes
quality_issues = Column(Text, nullable=True)
rejection_reason = Column(Text, nullable=True)
item_notes = Column(Text, nullable=True)
# Audit fields
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
# Relationships
delivery = relationship("Delivery", back_populates="items")
purchase_order_item = relationship("PurchaseOrderItem", back_populates="delivery_items")
# Indexes
__table_args__ = (
Index('ix_delivery_items_tenant_delivery', 'tenant_id', 'delivery_id'),
Index('ix_delivery_items_inventory_product', 'inventory_product_id'),
)
class SupplierInvoice(Base):
"""Invoices from suppliers"""
__tablename__ = "supplier_invoices"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
supplier_id = Column(UUID(as_uuid=True), nullable=False, index=True) # Reference to Suppliers Service
purchase_order_id = Column(UUID(as_uuid=True), ForeignKey('purchase_orders.id', ondelete='SET NULL'), nullable=True, index=True)
# Invoice identification
invoice_number = Column(String(50), nullable=False, unique=True, index=True)
supplier_invoice_number = Column(String(100), nullable=False)
# Invoice status and dates
status = Column(SQLEnum(InvoiceStatus), nullable=False, default=InvoiceStatus.pending, index=True)
invoice_date = Column(DateTime(timezone=True), nullable=False)
due_date = Column(DateTime(timezone=True), nullable=False)
received_date = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
# Financial information
subtotal = Column(Numeric(12, 2), nullable=False)
tax_amount = Column(Numeric(12, 2), nullable=False, default=Decimal("0.00"))
shipping_cost = Column(Numeric(10, 2), nullable=False, default=Decimal("0.00"))
discount_amount = Column(Numeric(10, 2), nullable=False, default=Decimal("0.00"))
total_amount = Column(Numeric(12, 2), nullable=False)
currency = Column(String(3), nullable=False, default="EUR")
# Payment tracking
paid_amount = Column(Numeric(12, 2), nullable=False, default=Decimal("0.00"))
payment_date = Column(DateTime(timezone=True), nullable=True)
payment_reference = Column(String(100), nullable=True)
# Invoice validation
approved_by = Column(UUID(as_uuid=True), nullable=True)
approved_at = Column(DateTime(timezone=True), nullable=True)
rejection_reason = Column(Text, nullable=True)
# Additional information
notes = Column(Text, nullable=True)
invoice_document_url = Column(String(500), nullable=True) # PDF storage location
# Audit fields
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
created_by = Column(UUID(as_uuid=True), nullable=False)
# Relationships
purchase_order = relationship("PurchaseOrder", back_populates="invoices")
# Indexes
__table_args__ = (
Index('ix_invoices_tenant_supplier', 'tenant_id', 'supplier_id'),
Index('ix_invoices_tenant_status', 'tenant_id', 'status'),
Index('ix_invoices_due_date', 'due_date'),
)

View File

@@ -0,0 +1,194 @@
"""
Database models for replenishment planning.
"""
from sqlalchemy import Column, String, Integer, Numeric, Date, Boolean, ForeignKey, Text, TIMESTAMP, JSON
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import relationship
import uuid
from datetime import datetime
from shared.database import Base
class ReplenishmentPlan(Base):
"""Replenishment plan master record"""
__tablename__ = "replenishment_plans"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# Planning metadata
planning_date = Column(Date, nullable=False)
projection_horizon_days = Column(Integer, nullable=False, default=7)
# References
forecast_id = Column(UUID(as_uuid=True), nullable=True)
production_schedule_id = Column(UUID(as_uuid=True), nullable=True)
# Summary statistics
total_items = Column(Integer, nullable=False, default=0)
urgent_items = Column(Integer, nullable=False, default=0)
high_risk_items = Column(Integer, nullable=False, default=0)
total_estimated_cost = Column(Numeric(12, 2), nullable=False, default=0)
# Status
status = Column(String(50), nullable=False, default='draft') # draft, approved, executed
# Timestamps
created_at = Column(TIMESTAMP(timezone=True), nullable=False, default=datetime.utcnow)
updated_at = Column(TIMESTAMP(timezone=True), nullable=True, onupdate=datetime.utcnow)
executed_at = Column(TIMESTAMP(timezone=True), nullable=True)
# Relationships
items = relationship("ReplenishmentPlanItem", back_populates="plan", cascade="all, delete-orphan")
class ReplenishmentPlanItem(Base):
"""Individual item in a replenishment plan"""
__tablename__ = "replenishment_plan_items"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
replenishment_plan_id = Column(UUID(as_uuid=True), ForeignKey("replenishment_plans.id"), nullable=False, index=True)
# Ingredient info
ingredient_id = Column(UUID(as_uuid=True), nullable=False, index=True)
ingredient_name = Column(String(200), nullable=False)
unit_of_measure = Column(String(20), nullable=False)
# Quantities
base_quantity = Column(Numeric(12, 3), nullable=False)
safety_stock_quantity = Column(Numeric(12, 3), nullable=False, default=0)
shelf_life_adjusted_quantity = Column(Numeric(12, 3), nullable=False)
final_order_quantity = Column(Numeric(12, 3), nullable=False)
# Dates
order_date = Column(Date, nullable=False, index=True)
delivery_date = Column(Date, nullable=False)
required_by_date = Column(Date, nullable=False)
# Planning metadata
lead_time_days = Column(Integer, nullable=False)
is_urgent = Column(Boolean, nullable=False, default=False, index=True)
urgency_reason = Column(Text, nullable=True)
waste_risk = Column(String(20), nullable=False, default='low') # low, medium, high
stockout_risk = Column(String(20), nullable=False, default='low') # low, medium, high, critical
# Supplier
supplier_id = Column(UUID(as_uuid=True), nullable=True)
# Calculation details (stored as JSONB)
safety_stock_calculation = Column(JSONB, nullable=True)
shelf_life_adjustment = Column(JSONB, nullable=True)
inventory_projection = Column(JSONB, nullable=True)
# Timestamps
created_at = Column(TIMESTAMP(timezone=True), nullable=False, default=datetime.utcnow)
# Relationships
plan = relationship("ReplenishmentPlan", back_populates="items")
class InventoryProjection(Base):
"""Daily inventory projection"""
__tablename__ = "inventory_projections"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# Ingredient
ingredient_id = Column(UUID(as_uuid=True), nullable=False, index=True)
ingredient_name = Column(String(200), nullable=False)
# Projection date
projection_date = Column(Date, nullable=False, index=True)
# Stock levels
starting_stock = Column(Numeric(12, 3), nullable=False)
forecasted_consumption = Column(Numeric(12, 3), nullable=False, default=0)
scheduled_receipts = Column(Numeric(12, 3), nullable=False, default=0)
projected_ending_stock = Column(Numeric(12, 3), nullable=False)
# Flags
is_stockout = Column(Boolean, nullable=False, default=False, index=True)
coverage_gap = Column(Numeric(12, 3), nullable=False, default=0) # Negative if stockout
# Reference to replenishment plan
replenishment_plan_id = Column(UUID(as_uuid=True), nullable=True)
# Timestamps
created_at = Column(TIMESTAMP(timezone=True), nullable=False, default=datetime.utcnow)
__table_args__ = (
# Unique constraint: one projection per ingredient per date per tenant
{'schema': None}
)
class SupplierAllocation(Base):
"""Supplier allocation for a requirement"""
__tablename__ = "supplier_allocations"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# References
replenishment_plan_item_id = Column(UUID(as_uuid=True), ForeignKey("replenishment_plan_items.id"), nullable=True, index=True)
requirement_id = Column(UUID(as_uuid=True), nullable=True, index=True) # Reference to procurement_requirements
# Supplier
supplier_id = Column(UUID(as_uuid=True), nullable=False, index=True)
supplier_name = Column(String(200), nullable=False)
# Allocation
allocation_type = Column(String(20), nullable=False) # primary, backup, diversification
allocated_quantity = Column(Numeric(12, 3), nullable=False)
allocation_percentage = Column(Numeric(5, 4), nullable=False) # 0.0000 - 1.0000
# Pricing
unit_price = Column(Numeric(12, 2), nullable=False)
total_cost = Column(Numeric(12, 2), nullable=False)
# Lead time
lead_time_days = Column(Integer, nullable=False)
# Scoring
supplier_score = Column(Numeric(5, 2), nullable=False)
score_breakdown = Column(JSONB, nullable=True)
# Reasoning
allocation_reason = Column(Text, nullable=True)
# Timestamps
created_at = Column(TIMESTAMP(timezone=True), nullable=False, default=datetime.utcnow)
class SupplierSelectionHistory(Base):
"""Historical record of supplier selections for analytics"""
__tablename__ = "supplier_selection_history"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# Selection details
ingredient_id = Column(UUID(as_uuid=True), nullable=False, index=True)
ingredient_name = Column(String(200), nullable=False)
selected_supplier_id = Column(UUID(as_uuid=True), nullable=False, index=True)
selected_supplier_name = Column(String(200), nullable=False)
# Order details
selection_date = Column(Date, nullable=False, index=True)
quantity = Column(Numeric(12, 3), nullable=False)
unit_price = Column(Numeric(12, 2), nullable=False)
total_cost = Column(Numeric(12, 2), nullable=False)
# Metrics
lead_time_days = Column(Integer, nullable=False)
quality_score = Column(Numeric(5, 2), nullable=True)
delivery_performance = Column(Numeric(5, 2), nullable=True)
# Selection strategy
selection_strategy = Column(String(50), nullable=False) # single_source, dual_source, multi_source
was_primary_choice = Column(Boolean, nullable=False, default=True)
# Timestamps
created_at = Column(TIMESTAMP(timezone=True), nullable=False, default=datetime.utcnow)

View File

@@ -0,0 +1,62 @@
# ================================================================
# services/procurement/app/repositories/base_repository.py
# ================================================================
"""
Base Repository Pattern for Procurement Service
"""
from typing import Generic, TypeVar, Type, Optional, List, Dict, Any
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from shared.database.base import Base
ModelType = TypeVar("ModelType", bound=Base)
class BaseRepository(Generic[ModelType]):
"""Base repository with common database operations"""
def __init__(self, model: Type[ModelType]):
self.model = model
async def get_by_id(self, db: AsyncSession, id: Any) -> Optional[ModelType]:
"""Get entity by ID"""
result = await db.execute(select(self.model).where(self.model.id == id))
return result.scalar_one_or_none()
async def get_all(self, db: AsyncSession, skip: int = 0, limit: int = 100) -> List[ModelType]:
"""Get all entities with pagination"""
result = await db.execute(select(self.model).offset(skip).limit(limit))
return result.scalars().all()
async def create(self, db: AsyncSession, **kwargs) -> ModelType:
"""Create new entity"""
instance = self.model(**kwargs)
db.add(instance)
await db.flush()
await db.refresh(instance)
return instance
async def update(self, db: AsyncSession, id: Any, **kwargs) -> Optional[ModelType]:
"""Update entity"""
instance = await self.get_by_id(db, id)
if not instance:
return None
for key, value in kwargs.items():
if hasattr(instance, key):
setattr(instance, key, value)
await db.flush()
await db.refresh(instance)
return instance
async def delete(self, db: AsyncSession, id: Any) -> bool:
"""Delete entity"""
instance = await self.get_by_id(db, id)
if not instance:
return False
await db.delete(instance)
await db.flush()
return True

View File

@@ -0,0 +1,206 @@
# ================================================================
# services/procurement/app/repositories/procurement_plan_repository.py
# ================================================================
"""
Procurement Plan Repository - Database operations for procurement plans and requirements
"""
import uuid
from datetime import datetime, date
from typing import List, Optional, Dict, Any
from sqlalchemy import select, and_, desc, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.procurement_plan import ProcurementPlan, ProcurementRequirement
from app.repositories.base_repository import BaseRepository
class ProcurementPlanRepository(BaseRepository):
"""Repository for procurement plan operations"""
def __init__(self, db: AsyncSession):
super().__init__(ProcurementPlan)
self.db = db
async def create_plan(self, plan_data: Dict[str, Any]) -> ProcurementPlan:
"""Create a new procurement plan"""
plan = ProcurementPlan(**plan_data)
self.db.add(plan)
await self.db.flush()
return plan
async def get_plan_by_id(self, plan_id: uuid.UUID, tenant_id: uuid.UUID) -> Optional[ProcurementPlan]:
"""Get procurement plan by ID"""
stmt = select(ProcurementPlan).where(
and_(
ProcurementPlan.id == plan_id,
ProcurementPlan.tenant_id == tenant_id
)
).options(selectinload(ProcurementPlan.requirements))
result = await self.db.execute(stmt)
return result.scalar_one_or_none()
async def get_plan_by_date(self, plan_date: date, tenant_id: uuid.UUID) -> Optional[ProcurementPlan]:
"""Get procurement plan for a specific date"""
stmt = select(ProcurementPlan).where(
and_(
ProcurementPlan.plan_date == plan_date,
ProcurementPlan.tenant_id == tenant_id
)
).options(selectinload(ProcurementPlan.requirements))
result = await self.db.execute(stmt)
return result.scalar_one_or_none()
async def get_current_plan(self, tenant_id: uuid.UUID) -> Optional[ProcurementPlan]:
"""Get the current day's procurement plan"""
today = date.today()
return await self.get_plan_by_date(today, tenant_id)
async def list_plans(
self,
tenant_id: uuid.UUID,
status: Optional[str] = None,
start_date: Optional[date] = None,
end_date: Optional[date] = None,
limit: int = 50,
offset: int = 0
) -> List[ProcurementPlan]:
"""List procurement plans with filters"""
conditions = [ProcurementPlan.tenant_id == tenant_id]
if status:
conditions.append(ProcurementPlan.status == status)
if start_date:
conditions.append(ProcurementPlan.plan_date >= start_date)
if end_date:
conditions.append(ProcurementPlan.plan_date <= end_date)
stmt = (
select(ProcurementPlan)
.where(and_(*conditions))
.order_by(desc(ProcurementPlan.plan_date))
.limit(limit)
.offset(offset)
.options(selectinload(ProcurementPlan.requirements))
)
result = await self.db.execute(stmt)
return result.scalars().all()
async def update_plan(self, plan_id: uuid.UUID, tenant_id: uuid.UUID, updates: Dict[str, Any]) -> Optional[ProcurementPlan]:
"""Update procurement plan"""
plan = await self.get_plan_by_id(plan_id, tenant_id)
if not plan:
return None
for key, value in updates.items():
if hasattr(plan, key):
setattr(plan, key, value)
plan.updated_at = datetime.utcnow()
await self.db.flush()
return plan
async def delete_plan(self, plan_id: uuid.UUID, tenant_id: uuid.UUID) -> bool:
"""Delete procurement plan"""
plan = await self.get_plan_by_id(plan_id, tenant_id)
if not plan:
return False
await self.db.delete(plan)
return True
async def generate_plan_number(self, tenant_id: uuid.UUID, plan_date: date) -> str:
"""Generate unique plan number"""
date_str = plan_date.strftime("%Y%m%d")
# Count existing plans for the same date
stmt = select(func.count(ProcurementPlan.id)).where(
and_(
ProcurementPlan.tenant_id == tenant_id,
ProcurementPlan.plan_date == plan_date
)
)
result = await self.db.execute(stmt)
count = result.scalar() or 0
return f"PP-{date_str}-{count + 1:03d}"
class ProcurementRequirementRepository(BaseRepository):
"""Repository for procurement requirement operations"""
def __init__(self, db: AsyncSession):
super().__init__(ProcurementRequirement)
self.db = db
async def create_requirement(self, requirement_data: Dict[str, Any]) -> ProcurementRequirement:
"""Create a new procurement requirement"""
requirement = ProcurementRequirement(**requirement_data)
self.db.add(requirement)
await self.db.flush()
return requirement
async def create_requirements_batch(self, requirements_data: List[Dict[str, Any]]) -> List[ProcurementRequirement]:
"""Create multiple procurement requirements"""
requirements = [ProcurementRequirement(**data) for data in requirements_data]
self.db.add_all(requirements)
await self.db.flush()
return requirements
async def get_requirement_by_id(self, requirement_id: uuid.UUID, tenant_id: uuid.UUID) -> Optional[ProcurementRequirement]:
"""Get procurement requirement by ID"""
stmt = select(ProcurementRequirement).join(ProcurementPlan).where(
and_(
ProcurementRequirement.id == requirement_id,
ProcurementPlan.tenant_id == tenant_id
)
)
result = await self.db.execute(stmt)
return result.scalar_one_or_none()
async def get_requirements_by_plan(self, plan_id: uuid.UUID) -> List[ProcurementRequirement]:
"""Get all requirements for a specific plan"""
stmt = select(ProcurementRequirement).where(
ProcurementRequirement.plan_id == plan_id
).order_by(ProcurementRequirement.priority.desc(), ProcurementRequirement.required_by_date)
result = await self.db.execute(stmt)
return result.scalars().all()
async def update_requirement(
self,
requirement_id: uuid.UUID,
updates: Dict[str, Any]
) -> Optional[ProcurementRequirement]:
"""Update procurement requirement"""
stmt = select(ProcurementRequirement).where(
ProcurementRequirement.id == requirement_id
)
result = await self.db.execute(stmt)
requirement = result.scalar_one_or_none()
if not requirement:
return None
for key, value in updates.items():
if hasattr(requirement, key):
setattr(requirement, key, value)
requirement.updated_at = datetime.utcnow()
await self.db.flush()
return requirement
async def generate_requirement_number(self, plan_id: uuid.UUID) -> str:
"""Generate unique requirement number within a plan"""
stmt = select(func.count(ProcurementRequirement.id)).where(
ProcurementRequirement.plan_id == plan_id
)
result = await self.db.execute(stmt)
count = result.scalar() or 0
return f"REQ-{count + 1:05d}"

View File

@@ -0,0 +1,318 @@
# ================================================================
# services/procurement/app/repositories/purchase_order_repository.py
# ================================================================
"""
Purchase Order Repository - Database operations for purchase orders
Migrated from Suppliers Service
"""
import uuid
from datetime import datetime, date
from typing import List, Optional, Dict, Any
from sqlalchemy import select, and_, or_, desc, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.purchase_order import (
PurchaseOrder,
PurchaseOrderItem,
PurchaseOrderStatus,
Delivery,
DeliveryStatus,
SupplierInvoice,
)
from app.repositories.base_repository import BaseRepository
class PurchaseOrderRepository(BaseRepository):
"""Repository for purchase order operations"""
def __init__(self, db: AsyncSession):
super().__init__(PurchaseOrder)
self.db = db
async def create_po(self, po_data: Dict[str, Any]) -> PurchaseOrder:
"""Create a new purchase order"""
po = PurchaseOrder(**po_data)
self.db.add(po)
await self.db.flush()
return po
async def get_po_by_id(self, po_id: uuid.UUID, tenant_id: uuid.UUID) -> Optional[PurchaseOrder]:
"""Get purchase order by ID with items loaded"""
stmt = select(PurchaseOrder).where(
and_(
PurchaseOrder.id == po_id,
PurchaseOrder.tenant_id == tenant_id
)
).options(
selectinload(PurchaseOrder.items),
selectinload(PurchaseOrder.deliveries),
selectinload(PurchaseOrder.invoices)
)
result = await self.db.execute(stmt)
return result.scalar_one_or_none()
async def get_po_by_number(self, po_number: str, tenant_id: uuid.UUID) -> Optional[PurchaseOrder]:
"""Get purchase order by PO number"""
stmt = select(PurchaseOrder).where(
and_(
PurchaseOrder.po_number == po_number,
PurchaseOrder.tenant_id == tenant_id
)
).options(selectinload(PurchaseOrder.items))
result = await self.db.execute(stmt)
return result.scalar_one_or_none()
async def list_purchase_orders(
self,
tenant_id: uuid.UUID,
status: Optional[PurchaseOrderStatus] = None,
supplier_id: Optional[uuid.UUID] = None,
priority: Optional[str] = None,
start_date: Optional[date] = None,
end_date: Optional[date] = None,
limit: int = 50,
offset: int = 0
) -> List[PurchaseOrder]:
"""List purchase orders with filters"""
conditions = [PurchaseOrder.tenant_id == tenant_id]
if status:
conditions.append(PurchaseOrder.status == status)
if supplier_id:
conditions.append(PurchaseOrder.supplier_id == supplier_id)
if priority:
conditions.append(PurchaseOrder.priority == priority)
if start_date:
conditions.append(PurchaseOrder.order_date >= start_date)
if end_date:
conditions.append(PurchaseOrder.order_date <= end_date)
stmt = (
select(PurchaseOrder)
.where(and_(*conditions))
.order_by(desc(PurchaseOrder.order_date))
.limit(limit)
.offset(offset)
.options(selectinload(PurchaseOrder.items))
)
result = await self.db.execute(stmt)
return result.scalars().all()
async def get_pending_approval(self, tenant_id: uuid.UUID) -> List[PurchaseOrder]:
"""Get purchase orders pending approval"""
stmt = select(PurchaseOrder).where(
and_(
PurchaseOrder.tenant_id == tenant_id,
PurchaseOrder.status == PurchaseOrderStatus.pending_approval
)
).order_by(PurchaseOrder.total_amount.desc())
result = await self.db.execute(stmt)
return result.scalars().all()
async def update_po(self, po_id: uuid.UUID, tenant_id: uuid.UUID, updates: Dict[str, Any]) -> Optional[PurchaseOrder]:
"""Update purchase order"""
po = await self.get_po_by_id(po_id, tenant_id)
if not po:
return None
for key, value in updates.items():
if hasattr(po, key):
setattr(po, key, value)
po.updated_at = datetime.utcnow()
await self.db.flush()
return po
async def generate_po_number(self, tenant_id: uuid.UUID) -> str:
"""Generate unique PO number"""
today = date.today()
date_str = today.strftime("%Y%m%d")
# Count existing POs for today
stmt = select(func.count(PurchaseOrder.id)).where(
and_(
PurchaseOrder.tenant_id == tenant_id,
func.date(PurchaseOrder.order_date) == today
)
)
result = await self.db.execute(stmt)
count = result.scalar() or 0
return f"PO-{date_str}-{count + 1:04d}"
class PurchaseOrderItemRepository(BaseRepository):
"""Repository for purchase order item operations"""
def __init__(self, db: AsyncSession):
super().__init__(PurchaseOrderItem)
self.db = db
async def create_item(self, item_data: Dict[str, Any]) -> PurchaseOrderItem:
"""Create a purchase order item"""
item = PurchaseOrderItem(**item_data)
self.db.add(item)
await self.db.flush()
return item
async def create_items_batch(self, items_data: List[Dict[str, Any]]) -> List[PurchaseOrderItem]:
"""Create multiple purchase order items"""
items = [PurchaseOrderItem(**data) for data in items_data]
self.db.add_all(items)
await self.db.flush()
return items
async def get_items_by_po(self, po_id: uuid.UUID) -> List[PurchaseOrderItem]:
"""Get all items for a purchase order"""
stmt = select(PurchaseOrderItem).where(
PurchaseOrderItem.purchase_order_id == po_id
)
result = await self.db.execute(stmt)
return result.scalars().all()
class DeliveryRepository(BaseRepository):
"""Repository for delivery operations"""
def __init__(self, db: AsyncSession):
super().__init__(Delivery)
self.db = db
async def create_delivery(self, delivery_data: Dict[str, Any]) -> Delivery:
"""Create a new delivery"""
delivery = Delivery(**delivery_data)
self.db.add(delivery)
await self.db.flush()
return delivery
async def get_delivery_by_id(self, delivery_id: uuid.UUID, tenant_id: uuid.UUID) -> Optional[Delivery]:
"""Get delivery by ID with items loaded"""
stmt = select(Delivery).where(
and_(
Delivery.id == delivery_id,
Delivery.tenant_id == tenant_id
)
).options(selectinload(Delivery.items))
result = await self.db.execute(stmt)
return result.scalar_one_or_none()
async def get_deliveries_by_po(self, po_id: uuid.UUID) -> List[Delivery]:
"""Get all deliveries for a purchase order"""
stmt = select(Delivery).where(
Delivery.purchase_order_id == po_id
).options(selectinload(Delivery.items))
result = await self.db.execute(stmt)
return result.scalars().all()
async def create_delivery_item(self, item_data: Dict[str, Any]):
"""Create a delivery item"""
from app.models.purchase_order import DeliveryItem
item = DeliveryItem(**item_data)
self.db.add(item)
await self.db.flush()
return item
async def update_delivery(
self,
delivery_id: uuid.UUID,
tenant_id: uuid.UUID,
updates: Dict[str, Any]
) -> Optional[Delivery]:
"""Update delivery"""
delivery = await self.get_delivery_by_id(delivery_id, tenant_id)
if not delivery:
return None
for key, value in updates.items():
if hasattr(delivery, key):
setattr(delivery, key, value)
delivery.updated_at = datetime.utcnow()
await self.db.flush()
return delivery
async def generate_delivery_number(self, tenant_id: uuid.UUID) -> str:
"""Generate unique delivery number"""
today = date.today()
date_str = today.strftime("%Y%m%d")
stmt = select(func.count(Delivery.id)).where(
and_(
Delivery.tenant_id == tenant_id,
func.date(Delivery.created_at) == today
)
)
result = await self.db.execute(stmt)
count = result.scalar() or 0
return f"DEL-{date_str}-{count + 1:04d}"
class SupplierInvoiceRepository(BaseRepository):
"""Repository for supplier invoice operations"""
def __init__(self, db: AsyncSession):
super().__init__(SupplierInvoice)
self.db = db
async def create_invoice(self, invoice_data: Dict[str, Any]) -> SupplierInvoice:
"""Create a new supplier invoice"""
invoice = SupplierInvoice(**invoice_data)
self.db.add(invoice)
await self.db.flush()
return invoice
async def get_invoice_by_id(self, invoice_id: uuid.UUID, tenant_id: uuid.UUID) -> Optional[SupplierInvoice]:
"""Get invoice by ID"""
stmt = select(SupplierInvoice).where(
and_(
SupplierInvoice.id == invoice_id,
SupplierInvoice.tenant_id == tenant_id
)
)
result = await self.db.execute(stmt)
return result.scalar_one_or_none()
async def get_invoices_by_po(self, po_id: uuid.UUID) -> List[SupplierInvoice]:
"""Get all invoices for a purchase order"""
stmt = select(SupplierInvoice).where(
SupplierInvoice.purchase_order_id == po_id
)
result = await self.db.execute(stmt)
return result.scalars().all()
async def get_invoices_by_supplier(self, supplier_id: uuid.UUID, tenant_id: uuid.UUID) -> List[SupplierInvoice]:
"""Get all invoices for a supplier"""
stmt = select(SupplierInvoice).where(
and_(
SupplierInvoice.supplier_id == supplier_id,
SupplierInvoice.tenant_id == tenant_id
)
).order_by(SupplierInvoice.invoice_date.desc())
result = await self.db.execute(stmt)
return result.scalars().all()
async def generate_invoice_number(self, tenant_id: uuid.UUID) -> str:
"""Generate unique invoice number"""
today = date.today()
date_str = today.strftime("%Y%m%d")
stmt = select(func.count(SupplierInvoice.id)).where(
and_(
SupplierInvoice.tenant_id == tenant_id,
func.date(SupplierInvoice.created_at) == today
)
)
result = await self.db.execute(stmt)
count = result.scalar() or 0
return f"INV-{date_str}-{count + 1:04d}"

View File

@@ -0,0 +1,79 @@
# ================================================================
# services/procurement/app/schemas/__init__.py
# ================================================================
"""
Pydantic schemas for Procurement Service
"""
from .procurement_schemas import (
ProcurementRequirementBase,
ProcurementRequirementCreate,
ProcurementRequirementUpdate,
ProcurementRequirementResponse,
ProcurementPlanBase,
ProcurementPlanCreate,
ProcurementPlanUpdate,
ProcurementPlanResponse,
ProcurementSummary,
DashboardData,
GeneratePlanRequest,
GeneratePlanResponse,
AutoGenerateProcurementRequest,
AutoGenerateProcurementResponse,
PaginatedProcurementPlans,
)
from .purchase_order_schemas import (
PurchaseOrderCreate,
PurchaseOrderUpdate,
PurchaseOrderApproval,
PurchaseOrderResponse,
PurchaseOrderSummary,
PurchaseOrderItemCreate,
PurchaseOrderItemResponse,
DeliveryCreate,
DeliveryUpdate,
DeliveryResponse,
DeliveryItemCreate,
DeliveryItemResponse,
SupplierInvoiceCreate,
SupplierInvoiceUpdate,
SupplierInvoiceResponse,
)
__all__ = [
# Procurement Plan schemas
"ProcurementRequirementBase",
"ProcurementRequirementCreate",
"ProcurementRequirementUpdate",
"ProcurementRequirementResponse",
"ProcurementPlanBase",
"ProcurementPlanCreate",
"ProcurementPlanUpdate",
"ProcurementPlanResponse",
"ProcurementSummary",
"DashboardData",
"GeneratePlanRequest",
"GeneratePlanResponse",
"AutoGenerateProcurementRequest",
"AutoGenerateProcurementResponse",
"PaginatedProcurementPlans",
# Purchase Order schemas
"PurchaseOrderCreate",
"PurchaseOrderUpdate",
"PurchaseOrderApproval",
"PurchaseOrderResponse",
"PurchaseOrderSummary",
"PurchaseOrderItemCreate",
"PurchaseOrderItemResponse",
# Delivery schemas
"DeliveryCreate",
"DeliveryUpdate",
"DeliveryResponse",
"DeliveryItemCreate",
"DeliveryItemResponse",
# Invoice schemas
"SupplierInvoiceCreate",
"SupplierInvoiceUpdate",
"SupplierInvoiceResponse",
]

View File

@@ -0,0 +1,368 @@
# ================================================================
# services/procurement/app/schemas/procurement_schemas.py
# ================================================================
"""
Procurement Schemas - Request/response models for procurement plans
Migrated from Orders Service with additions for local production support
"""
import uuid
from datetime import datetime, date
from decimal import Decimal
from typing import Optional, List, Dict, Any
from pydantic import BaseModel, Field, ConfigDict
# ================================================================
# BASE SCHEMAS
# ================================================================
class ProcurementBase(BaseModel):
"""Base schema for procurement entities"""
model_config = ConfigDict(from_attributes=True, str_strip_whitespace=True)
# ================================================================
# PROCUREMENT REQUIREMENT SCHEMAS
# ================================================================
class ProcurementRequirementBase(ProcurementBase):
"""Base procurement requirement schema"""
product_id: uuid.UUID
product_name: str = Field(..., min_length=1, max_length=200)
product_sku: Optional[str] = Field(None, max_length=100)
product_category: Optional[str] = Field(None, max_length=100)
product_type: str = Field(default="ingredient", max_length=50)
required_quantity: Decimal = Field(..., gt=0)
unit_of_measure: str = Field(..., min_length=1, max_length=50)
safety_stock_quantity: Decimal = Field(default=Decimal("0.000"), ge=0)
total_quantity_needed: Decimal = Field(..., gt=0)
current_stock_level: Decimal = Field(default=Decimal("0.000"), ge=0)
reserved_stock: Decimal = Field(default=Decimal("0.000"), ge=0)
available_stock: Decimal = Field(default=Decimal("0.000"), ge=0)
net_requirement: Decimal = Field(..., ge=0)
order_demand: Decimal = Field(default=Decimal("0.000"), ge=0)
production_demand: Decimal = Field(default=Decimal("0.000"), ge=0)
forecast_demand: Decimal = Field(default=Decimal("0.000"), ge=0)
buffer_demand: Decimal = Field(default=Decimal("0.000"), ge=0)
required_by_date: date
lead_time_buffer_days: int = Field(default=1, ge=0)
suggested_order_date: date
latest_order_date: date
priority: str = Field(default="normal", pattern="^(critical|high|normal|low)$")
risk_level: str = Field(default="low", pattern="^(low|medium|high|critical)$")
preferred_supplier_id: Optional[uuid.UUID] = None
backup_supplier_id: Optional[uuid.UUID] = None
supplier_name: Optional[str] = Field(None, max_length=200)
supplier_lead_time_days: Optional[int] = Field(None, ge=0)
minimum_order_quantity: Optional[Decimal] = Field(None, ge=0)
estimated_unit_cost: Optional[Decimal] = Field(None, ge=0)
estimated_total_cost: Optional[Decimal] = Field(None, ge=0)
last_purchase_cost: Optional[Decimal] = Field(None, ge=0)
class ProcurementRequirementCreate(ProcurementRequirementBase):
"""Schema for creating procurement requirements"""
special_requirements: Optional[str] = None
storage_requirements: Optional[str] = Field(None, max_length=200)
shelf_life_days: Optional[int] = Field(None, gt=0)
quality_specifications: Optional[Dict[str, Any]] = None
procurement_notes: Optional[str] = None
# Smart procurement calculation metadata
calculation_method: Optional[str] = Field(None, max_length=100)
ai_suggested_quantity: Optional[Decimal] = Field(None, ge=0)
adjusted_quantity: Optional[Decimal] = Field(None, ge=0)
adjustment_reason: Optional[str] = None
price_tier_applied: Optional[Dict[str, Any]] = None
supplier_minimum_applied: bool = False
storage_limit_applied: bool = False
reorder_rule_applied: bool = False
# NEW: Local production support fields
is_locally_produced: bool = False
recipe_id: Optional[uuid.UUID] = None
parent_requirement_id: Optional[uuid.UUID] = None
bom_explosion_level: int = Field(default=0, ge=0)
class ProcurementRequirementUpdate(ProcurementBase):
"""Schema for updating procurement requirements"""
status: Optional[str] = Field(None, pattern="^(pending|approved|ordered|partially_received|received|cancelled)$")
priority: Optional[str] = Field(None, pattern="^(critical|high|normal|low)$")
approved_quantity: Optional[Decimal] = Field(None, ge=0)
approved_cost: Optional[Decimal] = Field(None, ge=0)
purchase_order_id: Optional[uuid.UUID] = None
purchase_order_number: Optional[str] = Field(None, max_length=50)
ordered_quantity: Optional[Decimal] = Field(None, ge=0)
expected_delivery_date: Optional[date] = None
actual_delivery_date: Optional[date] = None
received_quantity: Optional[Decimal] = Field(None, ge=0)
delivery_status: Optional[str] = Field(None, pattern="^(pending|in_transit|delivered|delayed|cancelled)$")
procurement_notes: Optional[str] = None
class ProcurementRequirementResponse(ProcurementRequirementBase):
"""Schema for procurement requirement responses"""
id: uuid.UUID
plan_id: uuid.UUID
requirement_number: str
status: str
created_at: datetime
updated_at: datetime
purchase_order_id: Optional[uuid.UUID] = None
purchase_order_number: Optional[str] = None
ordered_quantity: Decimal
ordered_at: Optional[datetime] = None
expected_delivery_date: Optional[date] = None
actual_delivery_date: Optional[date] = None
received_quantity: Decimal
delivery_status: str
fulfillment_rate: Optional[Decimal] = None
on_time_delivery: Optional[bool] = None
quality_rating: Optional[Decimal] = None
approved_quantity: Optional[Decimal] = None
approved_cost: Optional[Decimal] = None
approved_at: Optional[datetime] = None
approved_by: Optional[uuid.UUID] = None
special_requirements: Optional[str] = None
storage_requirements: Optional[str] = None
shelf_life_days: Optional[int] = None
quality_specifications: Optional[Dict[str, Any]] = None
procurement_notes: Optional[str] = None
# Smart procurement calculation metadata
calculation_method: Optional[str] = None
ai_suggested_quantity: Optional[Decimal] = None
adjusted_quantity: Optional[Decimal] = None
adjustment_reason: Optional[str] = None
price_tier_applied: Optional[Dict[str, Any]] = None
supplier_minimum_applied: bool = False
storage_limit_applied: bool = False
reorder_rule_applied: bool = False
# NEW: Local production support fields
is_locally_produced: bool = False
recipe_id: Optional[uuid.UUID] = None
parent_requirement_id: Optional[uuid.UUID] = None
bom_explosion_level: int = 0
# ================================================================
# PROCUREMENT PLAN SCHEMAS
# ================================================================
class ProcurementPlanBase(ProcurementBase):
"""Base procurement plan schema"""
plan_date: date
plan_period_start: date
plan_period_end: date
planning_horizon_days: int = Field(default=14, gt=0)
plan_type: str = Field(default="regular", pattern="^(regular|emergency|seasonal|urgent)$")
priority: str = Field(default="normal", pattern="^(critical|high|normal|low)$")
business_model: Optional[str] = Field(None, pattern="^(individual_bakery|central_bakery)$")
procurement_strategy: str = Field(default="just_in_time", pattern="^(just_in_time|bulk|mixed|bulk_order)$")
safety_stock_buffer: Decimal = Field(default=Decimal("20.00"), ge=0, le=100)
supply_risk_level: str = Field(default="low", pattern="^(low|medium|high|critical)$")
demand_forecast_confidence: Optional[Decimal] = Field(None, ge=1, le=10)
seasonality_adjustment: Decimal = Field(default=Decimal("0.00"))
special_requirements: Optional[str] = None
class ProcurementPlanCreate(ProcurementPlanBase):
"""Schema for creating procurement plans"""
tenant_id: uuid.UUID
requirements: Optional[List[ProcurementRequirementCreate]] = []
class ProcurementPlanUpdate(ProcurementBase):
"""Schema for updating procurement plans"""
status: Optional[str] = Field(None, pattern="^(draft|pending_approval|approved|in_execution|completed|cancelled)$")
priority: Optional[str] = Field(None, pattern="^(critical|high|normal|low)$")
approved_at: Optional[datetime] = None
approved_by: Optional[uuid.UUID] = None
execution_started_at: Optional[datetime] = None
execution_completed_at: Optional[datetime] = None
special_requirements: Optional[str] = None
seasonal_adjustments: Optional[Dict[str, Any]] = None
class ProcurementPlanResponse(ProcurementPlanBase):
"""Schema for procurement plan responses"""
id: uuid.UUID
tenant_id: uuid.UUID
plan_number: str
status: str
total_requirements: int
total_estimated_cost: Decimal
total_approved_cost: Decimal
cost_variance: Decimal
total_demand_orders: int
total_demand_quantity: Decimal
total_production_requirements: Decimal
primary_suppliers_count: int
backup_suppliers_count: int
supplier_diversification_score: Optional[Decimal] = None
approved_at: Optional[datetime] = None
approved_by: Optional[uuid.UUID] = None
execution_started_at: Optional[datetime] = None
execution_completed_at: Optional[datetime] = None
fulfillment_rate: Optional[Decimal] = None
on_time_delivery_rate: Optional[Decimal] = None
cost_accuracy: Optional[Decimal] = None
quality_score: Optional[Decimal] = None
created_at: datetime
updated_at: datetime
created_by: Optional[uuid.UUID] = None
updated_by: Optional[uuid.UUID] = None
# NEW: Track forecast and production schedule links
forecast_id: Optional[uuid.UUID] = None
production_schedule_id: Optional[uuid.UUID] = None
requirements: List[ProcurementRequirementResponse] = []
# ================================================================
# SUMMARY SCHEMAS
# ================================================================
class ProcurementSummary(ProcurementBase):
"""Summary of procurement plans"""
total_plans: int
active_plans: int
total_requirements: int
pending_requirements: int
critical_requirements: int
total_estimated_cost: Decimal
total_approved_cost: Decimal
cost_variance: Decimal
average_fulfillment_rate: Optional[Decimal] = None
average_on_time_delivery: Optional[Decimal] = None
top_suppliers: List[Dict[str, Any]] = []
critical_items: List[Dict[str, Any]] = []
class DashboardData(ProcurementBase):
"""Dashboard data for procurement overview"""
current_plan: Optional[ProcurementPlanResponse] = None
summary: ProcurementSummary
upcoming_deliveries: List[Dict[str, Any]] = []
overdue_requirements: List[Dict[str, Any]] = []
low_stock_alerts: List[Dict[str, Any]] = []
performance_metrics: Dict[str, Any] = {}
# ================================================================
# REQUEST SCHEMAS
# ================================================================
class GeneratePlanRequest(ProcurementBase):
"""Request to generate procurement plan"""
plan_date: Optional[date] = None
force_regenerate: bool = False
planning_horizon_days: int = Field(default=14, gt=0, le=30)
include_safety_stock: bool = True
safety_stock_percentage: Decimal = Field(default=Decimal("20.00"), ge=0, le=100)
class AutoGenerateProcurementRequest(ProcurementBase):
"""
Request to auto-generate procurement plan (called by Orchestrator)
This is the main entry point for orchestrated procurement planning.
The Orchestrator calls Forecasting Service first, then passes forecast data here.
NEW: Accepts cached data snapshots from Orchestrator to eliminate duplicate API calls.
"""
forecast_data: Dict[str, Any] = Field(..., description="Forecast data from Forecasting Service")
production_schedule_id: Optional[uuid.UUID] = Field(None, description="Production schedule ID if available")
target_date: Optional[date] = Field(None, description="Target date for the plan")
planning_horizon_days: int = Field(default=14, gt=0, le=30)
safety_stock_percentage: Decimal = Field(default=Decimal("20.00"), ge=0, le=100)
auto_create_pos: bool = Field(True, description="Automatically create purchase orders")
auto_approve_pos: bool = Field(False, description="Auto-approve qualifying purchase orders")
# NEW: Cached data from Orchestrator
inventory_data: Optional[Dict[str, Any]] = Field(None, description="Cached inventory snapshot from Orchestrator")
suppliers_data: Optional[Dict[str, Any]] = Field(None, description="Cached suppliers snapshot from Orchestrator")
recipes_data: Optional[Dict[str, Any]] = Field(None, description="Cached recipes snapshot from Orchestrator")
class ForecastRequest(ProcurementBase):
"""Request parameters for demand forecasting"""
target_date: date
horizon_days: int = Field(default=1, gt=0, le=7)
include_confidence_intervals: bool = True
product_ids: Optional[List[uuid.UUID]] = None
# ================================================================
# RESPONSE SCHEMAS
# ================================================================
class GeneratePlanResponse(ProcurementBase):
"""Response from plan generation"""
success: bool
message: str
plan: Optional[ProcurementPlanResponse] = None
warnings: List[str] = []
errors: List[str] = []
class AutoGenerateProcurementResponse(ProcurementBase):
"""Response from auto-generate procurement (called by Orchestrator)"""
success: bool
message: str
plan_id: Optional[uuid.UUID] = None
plan_number: Optional[str] = None
requirements_created: int = 0
purchase_orders_created: int = 0
purchase_orders_auto_approved: int = 0
total_estimated_cost: Decimal = Decimal("0")
warnings: List[str] = []
errors: List[str] = []
created_pos: List[Dict[str, Any]] = []
class PaginatedProcurementPlans(ProcurementBase):
"""Paginated list of procurement plans"""
plans: List[ProcurementPlanResponse]
total: int
page: int
limit: int
has_more: bool

View File

@@ -0,0 +1,364 @@
# ================================================================
# services/procurement/app/schemas/purchase_order_schemas.py
# ================================================================
"""
Purchase Order Schemas - Request/response models for purchase orders
Migrated from Suppliers Service with procurement-specific additions
"""
import uuid
from datetime import datetime, date
from decimal import Decimal
from typing import Optional, List, Dict, Any
from pydantic import BaseModel, Field, ConfigDict
# ================================================================
# BASE SCHEMAS
# ================================================================
class PurchaseOrderBase(BaseModel):
"""Base schema for purchase order entities"""
model_config = ConfigDict(from_attributes=True, str_strip_whitespace=True)
# ================================================================
# PURCHASE ORDER ITEM SCHEMAS
# ================================================================
class PurchaseOrderItemCreate(PurchaseOrderBase):
"""Schema for creating purchase order items"""
inventory_product_id: uuid.UUID # Changed from ingredient_id to match model
ordered_quantity: Decimal = Field(..., gt=0)
unit_price: Decimal = Field(..., gt=0)
unit_of_measure: str = Field(..., max_length=50)
quality_requirements: Optional[str] = None
item_notes: Optional[str] = None
class PurchaseOrderItemUpdate(PurchaseOrderBase):
"""Schema for updating purchase order items"""
ordered_quantity: Optional[Decimal] = Field(None, gt=0)
unit_price: Optional[Decimal] = Field(None, gt=0)
quality_requirements: Optional[str] = None
item_notes: Optional[str] = None
class PurchaseOrderItemResponse(PurchaseOrderBase):
"""Schema for purchase order item responses"""
id: uuid.UUID
tenant_id: uuid.UUID
purchase_order_id: uuid.UUID
inventory_product_id: uuid.UUID # Changed from ingredient_id to match model
ingredient_name: Optional[str] = None
ordered_quantity: Decimal
received_quantity: Decimal
unit_price: Decimal
unit_of_measure: str
line_total: Decimal
quality_requirements: Optional[str] = None
item_notes: Optional[str] = None
created_at: datetime
updated_at: datetime
# ================================================================
# PURCHASE ORDER SCHEMAS
# ================================================================
class PurchaseOrderCreate(PurchaseOrderBase):
"""Schema for creating purchase orders"""
supplier_id: uuid.UUID
required_delivery_date: datetime # Use datetime with timezone
priority: str = Field(default="normal", pattern="^(low|normal|high|critical)$")
# Financial information
tax_amount: Decimal = Field(default=Decimal("0"), ge=0)
shipping_cost: Decimal = Field(default=Decimal("0"), ge=0)
discount_amount: Decimal = Field(default=Decimal("0"), ge=0)
subtotal: Decimal = Field(..., ge=0)
# Additional information
notes: Optional[str] = None
# NEW: Procurement-specific fields
procurement_plan_id: Optional[uuid.UUID] = None
# Items
items: List[PurchaseOrderItemCreate] = Field(..., min_length=1)
class PurchaseOrderUpdate(PurchaseOrderBase):
"""Schema for updating purchase orders"""
required_delivery_date: Optional[datetime] = None # Use datetime with timezone
priority: Optional[str] = Field(None, pattern="^(low|normal|high|critical)$")
# Financial information
tax_amount: Optional[Decimal] = Field(None, ge=0)
shipping_cost: Optional[Decimal] = Field(None, ge=0)
discount_amount: Optional[Decimal] = Field(None, ge=0)
# Additional information
notes: Optional[str] = None
class PurchaseOrderApproval(PurchaseOrderBase):
"""Schema for purchase order approval/rejection"""
action: str = Field(..., pattern="^(approve|reject)$")
notes: Optional[str] = None
approved_by: Optional[uuid.UUID] = None
class PurchaseOrderResponse(PurchaseOrderBase):
"""Schema for purchase order responses"""
id: uuid.UUID
tenant_id: uuid.UUID
supplier_id: uuid.UUID
supplier_name: Optional[str] = None
po_number: str
status: str
priority: str
order_date: datetime
required_delivery_date: Optional[datetime] = None # Use datetime with timezone
estimated_delivery_date: Optional[datetime] = None # Use datetime with timezone
actual_delivery_date: Optional[datetime] = None # Use datetime with timezone
# Financial information
subtotal: Decimal
tax_amount: Decimal
shipping_cost: Decimal
discount_amount: Decimal
total_amount: Decimal
currency: str
# Approval workflow
approved_by: Optional[uuid.UUID] = None
approved_at: Optional[datetime] = None
rejection_reason: Optional[str] = None
# NEW: Procurement-specific fields
procurement_plan_id: Optional[uuid.UUID] = None
auto_approved: bool = False
auto_approval_rule_id: Optional[uuid.UUID] = None
# Additional information
notes: Optional[str] = None
# Audit fields
created_at: datetime
updated_at: datetime
created_by: Optional[uuid.UUID] = None
updated_by: Optional[uuid.UUID] = None
# Related data
items: List[PurchaseOrderItemResponse] = []
class PurchaseOrderSummary(PurchaseOrderBase):
"""Schema for purchase order summary (list view)"""
id: uuid.UUID
po_number: str
supplier_id: uuid.UUID
supplier_name: Optional[str] = None
status: str
priority: str
order_date: datetime
required_delivery_date: datetime # Use datetime with timezone
total_amount: Decimal
currency: str
auto_approved: bool = False
created_at: datetime
# ================================================================
# DELIVERY SCHEMAS
# ================================================================
class DeliveryItemCreate(PurchaseOrderBase):
"""Schema for creating delivery items"""
purchase_order_item_id: uuid.UUID
inventory_product_id: uuid.UUID # Changed from ingredient_id to match model
ordered_quantity: Decimal = Field(..., gt=0)
delivered_quantity: Decimal = Field(..., ge=0)
accepted_quantity: Decimal = Field(..., ge=0)
rejected_quantity: Decimal = Field(default=Decimal("0"), ge=0)
# Quality information
batch_lot_number: Optional[str] = Field(None, max_length=100)
expiry_date: Optional[datetime] = None # Use datetime with timezone
quality_grade: Optional[str] = Field(None, max_length=20)
# Issues and notes
quality_issues: Optional[str] = None
rejection_reason: Optional[str] = None
item_notes: Optional[str] = None
class DeliveryItemResponse(PurchaseOrderBase):
"""Schema for delivery item responses"""
id: uuid.UUID
tenant_id: uuid.UUID
delivery_id: uuid.UUID
purchase_order_item_id: uuid.UUID
inventory_product_id: uuid.UUID # Changed from ingredient_id to match model
ingredient_name: Optional[str] = None
ordered_quantity: Decimal
delivered_quantity: Decimal
accepted_quantity: Decimal
rejected_quantity: Decimal
batch_lot_number: Optional[str] = None
expiry_date: Optional[datetime] = None # Use datetime with timezone
quality_grade: Optional[str] = None
quality_issues: Optional[str] = None
rejection_reason: Optional[str] = None
item_notes: Optional[str] = None
created_at: datetime
updated_at: datetime
class DeliveryCreate(PurchaseOrderBase):
"""Schema for creating deliveries"""
purchase_order_id: uuid.UUID
supplier_id: uuid.UUID
supplier_delivery_note: Optional[str] = Field(None, max_length=100)
scheduled_date: Optional[datetime] = None # Use datetime with timezone
estimated_arrival: Optional[datetime] = None
# Delivery details
carrier_name: Optional[str] = Field(None, max_length=200)
tracking_number: Optional[str] = Field(None, max_length=100)
# Additional information
notes: Optional[str] = None
# Items
items: List[DeliveryItemCreate] = Field(..., min_length=1)
class DeliveryUpdate(PurchaseOrderBase):
"""Schema for updating deliveries"""
supplier_delivery_note: Optional[str] = Field(None, max_length=100)
scheduled_date: Optional[datetime] = None # Use datetime with timezone
estimated_arrival: Optional[datetime] = None
actual_arrival: Optional[datetime] = None
# Delivery details
carrier_name: Optional[str] = Field(None, max_length=200)
tracking_number: Optional[str] = Field(None, max_length=100)
# Quality inspection
inspection_passed: Optional[bool] = None
inspection_notes: Optional[str] = None
quality_issues: Optional[Dict[str, Any]] = None
# Additional information
notes: Optional[str] = None
class DeliveryResponse(PurchaseOrderBase):
"""Schema for delivery responses"""
id: uuid.UUID
tenant_id: uuid.UUID
purchase_order_id: uuid.UUID
supplier_id: uuid.UUID
supplier_name: Optional[str] = None
delivery_number: str
supplier_delivery_note: Optional[str] = None
status: str
# Timing
scheduled_date: Optional[datetime] = None # Use datetime with timezone
estimated_arrival: Optional[datetime] = None
actual_arrival: Optional[datetime] = None
completed_at: Optional[datetime] = None
# Delivery details
carrier_name: Optional[str] = None
tracking_number: Optional[str] = None
# Quality inspection
inspection_passed: Optional[bool] = None
inspection_notes: Optional[str] = None
quality_issues: Optional[Dict[str, Any]] = None
# Receipt information
received_by: Optional[uuid.UUID] = None
received_at: Optional[datetime] = None
# Additional information
notes: Optional[str] = None
# Audit fields
created_at: datetime
updated_at: datetime
created_by: uuid.UUID
# Related data
items: List[DeliveryItemResponse] = []
# ================================================================
# INVOICE SCHEMAS
# ================================================================
class SupplierInvoiceCreate(PurchaseOrderBase):
"""Schema for creating supplier invoices"""
purchase_order_id: uuid.UUID
supplier_id: uuid.UUID
invoice_number: str = Field(..., max_length=100)
invoice_date: datetime # Use datetime with timezone
due_date: datetime # Use datetime with timezone
# Financial information
subtotal: Decimal = Field(..., ge=0)
tax_amount: Decimal = Field(default=Decimal("0"), ge=0)
shipping_cost: Decimal = Field(default=Decimal("0"), ge=0)
discount_amount: Decimal = Field(default=Decimal("0"), ge=0)
# Additional information
notes: Optional[str] = None
payment_reference: Optional[str] = Field(None, max_length=100)
class SupplierInvoiceUpdate(PurchaseOrderBase):
"""Schema for updating supplier invoices"""
due_date: Optional[datetime] = None # Use datetime with timezone
payment_reference: Optional[str] = Field(None, max_length=100)
notes: Optional[str] = None
class SupplierInvoiceResponse(PurchaseOrderBase):
"""Schema for supplier invoice responses"""
id: uuid.UUID
tenant_id: uuid.UUID
purchase_order_id: uuid.UUID
supplier_id: uuid.UUID
supplier_name: Optional[str] = None
invoice_number: str
status: str
invoice_date: datetime # Use datetime with timezone
due_date: datetime # Use datetime with timezone
# Financial information
subtotal: Decimal
tax_amount: Decimal
shipping_cost: Decimal
discount_amount: Decimal
total_amount: Decimal
currency: str
# Payment tracking
paid_amount: Decimal
remaining_amount: Decimal
payment_date: Optional[datetime] = None # Use datetime with timezone
payment_reference: Optional[str] = None
# Additional information
notes: Optional[str] = None
# Audit fields
created_at: datetime
updated_at: datetime
created_by: uuid.UUID
updated_by: uuid.UUID

View File

@@ -0,0 +1,440 @@
"""
Pydantic schemas for replenishment planning.
"""
from pydantic import BaseModel, Field, validator
from typing import List, Optional, Dict, Any
from datetime import date, datetime
from decimal import Decimal
from uuid import UUID
# ============================================================================
# Replenishment Plan Schemas
# ============================================================================
class ReplenishmentPlanItemBase(BaseModel):
"""Base schema for replenishment plan item"""
ingredient_id: UUID
ingredient_name: str
unit_of_measure: str
base_quantity: Decimal
safety_stock_quantity: Decimal
shelf_life_adjusted_quantity: Decimal
final_order_quantity: Decimal
order_date: date
delivery_date: date
required_by_date: date
lead_time_days: int
is_urgent: bool
urgency_reason: Optional[str] = None
waste_risk: str
stockout_risk: str
supplier_id: Optional[UUID] = None
safety_stock_calculation: Optional[Dict[str, Any]] = None
shelf_life_adjustment: Optional[Dict[str, Any]] = None
inventory_projection: Optional[Dict[str, Any]] = None
class ReplenishmentPlanItemCreate(ReplenishmentPlanItemBase):
"""Schema for creating replenishment plan item"""
replenishment_plan_id: UUID
class ReplenishmentPlanItemResponse(ReplenishmentPlanItemBase):
"""Schema for replenishment plan item response"""
id: UUID
replenishment_plan_id: UUID
created_at: datetime
class Config:
from_attributes = True
class ReplenishmentPlanBase(BaseModel):
"""Base schema for replenishment plan"""
planning_date: date
projection_horizon_days: int = 7
forecast_id: Optional[UUID] = None
production_schedule_id: Optional[UUID] = None
total_items: int
urgent_items: int
high_risk_items: int
total_estimated_cost: Decimal
class ReplenishmentPlanCreate(ReplenishmentPlanBase):
"""Schema for creating replenishment plan"""
tenant_id: UUID
items: List[Dict[str, Any]] = []
class ReplenishmentPlanResponse(ReplenishmentPlanBase):
"""Schema for replenishment plan response"""
id: UUID
tenant_id: UUID
status: str
created_at: datetime
updated_at: Optional[datetime] = None
executed_at: Optional[datetime] = None
items: List[ReplenishmentPlanItemResponse] = []
class Config:
from_attributes = True
class ReplenishmentPlanSummary(BaseModel):
"""Summary schema for list views"""
id: UUID
tenant_id: UUID
planning_date: date
total_items: int
urgent_items: int
high_risk_items: int
total_estimated_cost: Decimal
status: str
created_at: datetime
class Config:
from_attributes = True
# ============================================================================
# Inventory Projection Schemas
# ============================================================================
class InventoryProjectionBase(BaseModel):
"""Base schema for inventory projection"""
ingredient_id: UUID
ingredient_name: str
projection_date: date
starting_stock: Decimal
forecasted_consumption: Decimal
scheduled_receipts: Decimal
projected_ending_stock: Decimal
is_stockout: bool
coverage_gap: Decimal
class InventoryProjectionCreate(InventoryProjectionBase):
"""Schema for creating inventory projection"""
tenant_id: UUID
replenishment_plan_id: Optional[UUID] = None
class InventoryProjectionResponse(InventoryProjectionBase):
"""Schema for inventory projection response"""
id: UUID
tenant_id: UUID
replenishment_plan_id: Optional[UUID] = None
created_at: datetime
class Config:
from_attributes = True
class IngredientProjectionSummary(BaseModel):
"""Summary of projections for one ingredient"""
ingredient_id: UUID
ingredient_name: str
current_stock: Decimal
unit_of_measure: str
projection_horizon_days: int
total_consumption: Decimal
total_receipts: Decimal
stockout_days: int
stockout_risk: str
daily_projections: List[Dict[str, Any]]
# ============================================================================
# Supplier Allocation Schemas
# ============================================================================
class SupplierAllocationBase(BaseModel):
"""Base schema for supplier allocation"""
supplier_id: UUID
supplier_name: str
allocation_type: str
allocated_quantity: Decimal
allocation_percentage: Decimal
unit_price: Decimal
total_cost: Decimal
lead_time_days: int
supplier_score: Decimal
score_breakdown: Optional[Dict[str, float]] = None
allocation_reason: Optional[str] = None
class SupplierAllocationCreate(SupplierAllocationBase):
"""Schema for creating supplier allocation"""
replenishment_plan_item_id: Optional[UUID] = None
requirement_id: Optional[UUID] = None
class SupplierAllocationResponse(SupplierAllocationBase):
"""Schema for supplier allocation response"""
id: UUID
replenishment_plan_item_id: Optional[UUID] = None
requirement_id: Optional[UUID] = None
created_at: datetime
class Config:
from_attributes = True
# ============================================================================
# Supplier Selection Schemas
# ============================================================================
class SupplierSelectionRequest(BaseModel):
"""Request to select suppliers for an ingredient"""
ingredient_id: UUID
ingredient_name: str
required_quantity: Decimal
supplier_options: List[Dict[str, Any]]
class SupplierSelectionResult(BaseModel):
"""Result of supplier selection"""
ingredient_id: UUID
ingredient_name: str
required_quantity: Decimal
allocations: List[Dict[str, Any]]
total_cost: Decimal
weighted_lead_time: float
risk_score: float
diversification_applied: bool
selection_strategy: str
# ============================================================================
# Replenishment Planning Request Schemas
# ============================================================================
class IngredientRequirementInput(BaseModel):
"""Input for a single ingredient requirement"""
ingredient_id: UUID
ingredient_name: str
required_quantity: Decimal
required_by_date: date
supplier_id: Optional[UUID] = None
lead_time_days: int = 3
shelf_life_days: Optional[int] = None
is_perishable: bool = False
category: str = 'dry'
unit_of_measure: str = 'kg'
current_stock: Decimal = Decimal('0')
daily_consumption_rate: float = 0.0
demand_std_dev: float = 0.0
class GenerateReplenishmentPlanRequest(BaseModel):
"""Request to generate replenishment plan"""
tenant_id: UUID
requirements: List[IngredientRequirementInput]
forecast_id: Optional[UUID] = None
production_schedule_id: Optional[UUID] = None
projection_horizon_days: int = 7
service_level: float = 0.95
buffer_days: int = 1
class GenerateReplenishmentPlanResponse(BaseModel):
"""Response from generating replenishment plan"""
plan_id: UUID
tenant_id: UUID
planning_date: date
projection_horizon_days: int
total_items: int
urgent_items: int
high_risk_items: int
total_estimated_cost: Decimal
created_at: datetime
items: List[Dict[str, Any]]
# ============================================================================
# MOQ Aggregation Schemas
# ============================================================================
class MOQAggregationRequest(BaseModel):
"""Request for MOQ aggregation"""
requirements: List[Dict[str, Any]]
supplier_constraints: Dict[str, Dict[str, Any]]
class MOQAggregationResponse(BaseModel):
"""Response from MOQ aggregation"""
aggregated_orders: List[Dict[str, Any]]
efficiency_metrics: Dict[str, Any]
# ============================================================================
# Safety Stock Calculation Schemas
# ============================================================================
class SafetyStockRequest(BaseModel):
"""Request for safety stock calculation"""
ingredient_id: UUID
daily_demands: List[float]
lead_time_days: int
service_level: float = 0.95
class SafetyStockResponse(BaseModel):
"""Response from safety stock calculation"""
safety_stock_quantity: Decimal
service_level: float
z_score: float
demand_std_dev: float
lead_time_days: int
calculation_method: str
confidence: str
reasoning: str
# ============================================================================
# Inventory Projection Request Schemas
# ============================================================================
class ProjectInventoryRequest(BaseModel):
"""Request to project inventory"""
ingredient_id: UUID
ingredient_name: str
current_stock: Decimal
unit_of_measure: str
daily_demand: List[Dict[str, Any]]
scheduled_receipts: List[Dict[str, Any]] = []
projection_horizon_days: int = 7
class ProjectInventoryResponse(BaseModel):
"""Response from inventory projection"""
ingredient_id: UUID
ingredient_name: str
current_stock: Decimal
unit_of_measure: str
projection_horizon_days: int
total_consumption: Decimal
total_receipts: Decimal
stockout_days: int
stockout_risk: str
daily_projections: List[Dict[str, Any]]
# ============================================================================
# Supplier Selection History Schemas
# ============================================================================
class SupplierSelectionHistoryBase(BaseModel):
"""Base schema for supplier selection history"""
ingredient_id: UUID
ingredient_name: str
selected_supplier_id: UUID
selected_supplier_name: str
selection_date: date
quantity: Decimal
unit_price: Decimal
total_cost: Decimal
lead_time_days: int
quality_score: Optional[Decimal] = None
delivery_performance: Optional[Decimal] = None
selection_strategy: str
was_primary_choice: bool = True
class SupplierSelectionHistoryCreate(SupplierSelectionHistoryBase):
"""Schema for creating supplier selection history"""
tenant_id: UUID
class SupplierSelectionHistoryResponse(SupplierSelectionHistoryBase):
"""Schema for supplier selection history response"""
id: UUID
tenant_id: UUID
created_at: datetime
class Config:
from_attributes = True
# ============================================================================
# Analytics Schemas
# ============================================================================
class ReplenishmentAnalytics(BaseModel):
"""Analytics for replenishment planning"""
total_plans: int
total_items_planned: int
total_estimated_value: Decimal
urgent_items_percentage: float
high_risk_items_percentage: float
average_lead_time_days: float
average_safety_stock_percentage: float
stockout_prevention_rate: float
moq_optimization_savings: Decimal
supplier_diversification_rate: float
average_suppliers_per_ingredient: float
class InventoryProjectionAnalytics(BaseModel):
"""Analytics for inventory projections"""
total_ingredients: int
stockout_ingredients: int
stockout_percentage: float
risk_breakdown: Dict[str, int]
total_stockout_days: int
total_consumption: Decimal
total_receipts: Decimal
projection_horizon_days: int
# ============================================================================
# Validators
# ============================================================================
@validator('required_quantity', 'current_stock', 'allocated_quantity',
'safety_stock_quantity', 'base_quantity', 'final_order_quantity')
def validate_positive_quantity(cls, v):
"""Validate that quantities are positive"""
if v < 0:
raise ValueError('Quantity must be non-negative')
return v
@validator('service_level')
def validate_service_level(cls, v):
"""Validate service level is between 0 and 1"""
if not 0 <= v <= 1:
raise ValueError('Service level must be between 0 and 1')
return v

View File

@@ -0,0 +1,18 @@
# ================================================================
# services/procurement/app/services/__init__.py
# ================================================================
"""
Services for Procurement Service
"""
from .procurement_service import ProcurementService
from .purchase_order_service import PurchaseOrderService
from .recipe_explosion_service import RecipeExplosionService
from .smart_procurement_calculator import SmartProcurementCalculator
__all__ = [
"ProcurementService",
"PurchaseOrderService",
"RecipeExplosionService",
"SmartProcurementCalculator",
]

View File

@@ -0,0 +1,429 @@
"""
Inventory Projector
Projects future inventory levels day-by-day to identify coverage gaps
and stockout risks before they occur.
"""
from datetime import date, timedelta
from decimal import Decimal
from typing import List, Dict, Optional, Tuple
from dataclasses import dataclass, field
import logging
from shared.utils.time_series_utils import generate_future_dates
logger = logging.getLogger(__name__)
@dataclass
class DailyDemand:
"""Daily demand forecast for an ingredient"""
ingredient_id: str
date: date
quantity: Decimal
@dataclass
class ScheduledReceipt:
"""Planned receipt (PO, production, etc.)"""
ingredient_id: str
date: date
quantity: Decimal
source: str # 'purchase_order', 'production', 'transfer'
reference_id: Optional[str] = None
@dataclass
class InventoryLevel:
"""Current inventory level"""
ingredient_id: str
quantity: Decimal
unit_of_measure: str
@dataclass
class DailyProjection:
"""Daily inventory projection"""
date: date
starting_stock: Decimal
forecasted_consumption: Decimal
scheduled_receipts: Decimal
projected_ending_stock: Decimal
is_stockout: bool
coverage_gap: Decimal # Negative amount if stockout
@dataclass
class IngredientProjection:
"""Complete projection for one ingredient"""
ingredient_id: str
ingredient_name: str
current_stock: Decimal
unit_of_measure: str
projection_horizon_days: int
daily_projections: List[DailyProjection] = field(default_factory=list)
total_consumption: Decimal = Decimal('0')
total_receipts: Decimal = Decimal('0')
stockout_days: int = 0
stockout_risk: str = "low" # low, medium, high
class InventoryProjector:
"""
Projects inventory levels over time to identify coverage gaps.
Algorithm:
For each day in horizon:
Starting Stock = Previous Day's Ending Stock
Consumption = Forecasted Demand
Receipts = Scheduled Deliveries + Production
Ending Stock = Starting Stock - Consumption + Receipts
Identifies:
- Days when stock goes negative (stockouts)
- Coverage gaps (how much short)
- Stockout risk level
"""
def __init__(self, projection_horizon_days: int = 7):
"""
Initialize inventory projector.
Args:
projection_horizon_days: Number of days to project
"""
self.projection_horizon_days = projection_horizon_days
def project_inventory(
self,
ingredient_id: str,
ingredient_name: str,
current_stock: Decimal,
unit_of_measure: str,
daily_demand: List[DailyDemand],
scheduled_receipts: List[ScheduledReceipt],
start_date: Optional[date] = None
) -> IngredientProjection:
"""
Project inventory levels for one ingredient.
Args:
ingredient_id: Ingredient ID
ingredient_name: Ingredient name
current_stock: Current inventory level
unit_of_measure: Unit of measure
daily_demand: List of daily demand forecasts
scheduled_receipts: List of scheduled receipts
start_date: Starting date (defaults to today)
Returns:
IngredientProjection with daily projections
"""
if start_date is None:
start_date = date.today()
# Generate projection dates
projection_dates = generate_future_dates(start_date, self.projection_horizon_days)
# Build demand lookup
demand_by_date = {d.date: d.quantity for d in daily_demand}
# Build receipts lookup
receipts_by_date: Dict[date, Decimal] = {}
for receipt in scheduled_receipts:
if receipt.date not in receipts_by_date:
receipts_by_date[receipt.date] = Decimal('0')
receipts_by_date[receipt.date] += receipt.quantity
# Project day by day
daily_projections = []
running_stock = current_stock
total_consumption = Decimal('0')
total_receipts = Decimal('0')
stockout_days = 0
for projection_date in projection_dates:
starting_stock = running_stock
# Get consumption for this day
consumption = demand_by_date.get(projection_date, Decimal('0'))
# Get receipts for this day
receipts = receipts_by_date.get(projection_date, Decimal('0'))
# Calculate ending stock
ending_stock = starting_stock - consumption + receipts
# Check for stockout
is_stockout = ending_stock < Decimal('0')
coverage_gap = min(Decimal('0'), ending_stock)
if is_stockout:
stockout_days += 1
# Create daily projection
daily_proj = DailyProjection(
date=projection_date,
starting_stock=starting_stock,
forecasted_consumption=consumption,
scheduled_receipts=receipts,
projected_ending_stock=ending_stock,
is_stockout=is_stockout,
coverage_gap=coverage_gap
)
daily_projections.append(daily_proj)
# Update running totals
total_consumption += consumption
total_receipts += receipts
running_stock = ending_stock
# Calculate stockout risk
stockout_risk = self._calculate_stockout_risk(
stockout_days=stockout_days,
total_days=len(projection_dates),
final_stock=running_stock
)
return IngredientProjection(
ingredient_id=ingredient_id,
ingredient_name=ingredient_name,
current_stock=current_stock,
unit_of_measure=unit_of_measure,
projection_horizon_days=self.projection_horizon_days,
daily_projections=daily_projections,
total_consumption=total_consumption,
total_receipts=total_receipts,
stockout_days=stockout_days,
stockout_risk=stockout_risk
)
def project_multiple_ingredients(
self,
ingredients_data: List[Dict]
) -> List[IngredientProjection]:
"""
Project inventory for multiple ingredients.
Args:
ingredients_data: List of dicts with ingredient data
Returns:
List of ingredient projections
"""
projections = []
for data in ingredients_data:
projection = self.project_inventory(
ingredient_id=data['ingredient_id'],
ingredient_name=data['ingredient_name'],
current_stock=data['current_stock'],
unit_of_measure=data['unit_of_measure'],
daily_demand=data.get('daily_demand', []),
scheduled_receipts=data.get('scheduled_receipts', []),
start_date=data.get('start_date')
)
projections.append(projection)
return projections
def identify_coverage_gaps(
self,
projection: IngredientProjection
) -> List[Dict]:
"""
Identify all coverage gaps in projection.
Args:
projection: Ingredient projection
Returns:
List of coverage gap details
"""
gaps = []
for daily_proj in projection.daily_projections:
if daily_proj.is_stockout:
gap = {
'date': daily_proj.date,
'shortage_quantity': abs(daily_proj.coverage_gap),
'starting_stock': daily_proj.starting_stock,
'consumption': daily_proj.forecasted_consumption,
'receipts': daily_proj.scheduled_receipts
}
gaps.append(gap)
if gaps:
logger.warning(
f"{projection.ingredient_name}: {len(gaps)} stockout days detected"
)
return gaps
def calculate_required_order_quantity(
self,
projection: IngredientProjection,
target_coverage_days: int = 7
) -> Decimal:
"""
Calculate how much to order to achieve target coverage.
Args:
projection: Ingredient projection
target_coverage_days: Target days of coverage
Returns:
Required order quantity
"""
# Calculate average daily consumption
if projection.daily_projections:
avg_daily_consumption = projection.total_consumption / len(projection.daily_projections)
else:
return Decimal('0')
# Target stock level
target_stock = avg_daily_consumption * Decimal(str(target_coverage_days))
# Calculate shortfall
final_projected_stock = projection.daily_projections[-1].projected_ending_stock if projection.daily_projections else Decimal('0')
required_order = max(Decimal('0'), target_stock - final_projected_stock)
return required_order
def _calculate_stockout_risk(
self,
stockout_days: int,
total_days: int,
final_stock: Decimal
) -> str:
"""
Calculate stockout risk level.
Args:
stockout_days: Number of stockout days
total_days: Total projection days
final_stock: Final projected stock
Returns:
Risk level: 'low', 'medium', 'high', 'critical'
"""
if stockout_days == 0 and final_stock > Decimal('0'):
return "low"
stockout_ratio = stockout_days / total_days if total_days > 0 else 0
if stockout_ratio >= 0.5 or final_stock < Decimal('-100'):
return "critical"
elif stockout_ratio >= 0.3 or final_stock < Decimal('-50'):
return "high"
elif stockout_ratio > 0 or final_stock < Decimal('0'):
return "medium"
else:
return "low"
def get_high_risk_ingredients(
self,
projections: List[IngredientProjection]
) -> List[IngredientProjection]:
"""
Filter to high/critical risk ingredients.
Args:
projections: List of ingredient projections
Returns:
List of high-risk projections
"""
high_risk = [
p for p in projections
if p.stockout_risk in ['high', 'critical']
]
if high_risk:
logger.warning(f"Found {len(high_risk)} high-risk ingredients")
for proj in high_risk:
logger.warning(
f" - {proj.ingredient_name}: {proj.stockout_days} stockout days, "
f"risk={proj.stockout_risk}"
)
return high_risk
def get_summary_statistics(
self,
projections: List[IngredientProjection]
) -> Dict:
"""
Get summary statistics across all projections.
Args:
projections: List of ingredient projections
Returns:
Summary statistics
"""
total_ingredients = len(projections)
stockout_ingredients = sum(1 for p in projections if p.stockout_days > 0)
risk_breakdown = {
'low': sum(1 for p in projections if p.stockout_risk == 'low'),
'medium': sum(1 for p in projections if p.stockout_risk == 'medium'),
'high': sum(1 for p in projections if p.stockout_risk == 'high'),
'critical': sum(1 for p in projections if p.stockout_risk == 'critical')
}
total_stockout_days = sum(p.stockout_days for p in projections)
total_consumption = sum(p.total_consumption for p in projections)
total_receipts = sum(p.total_receipts for p in projections)
return {
'total_ingredients': total_ingredients,
'stockout_ingredients': stockout_ingredients,
'stockout_percentage': (stockout_ingredients / total_ingredients * 100) if total_ingredients > 0 else 0,
'risk_breakdown': risk_breakdown,
'total_stockout_days': total_stockout_days,
'total_consumption': float(total_consumption),
'total_receipts': float(total_receipts),
'projection_horizon_days': self.projection_horizon_days
}
def export_projection_to_dict(
self,
projection: IngredientProjection
) -> Dict:
"""
Export projection to dictionary for API response.
Args:
projection: Ingredient projection
Returns:
Dictionary representation
"""
return {
'ingredient_id': projection.ingredient_id,
'ingredient_name': projection.ingredient_name,
'current_stock': float(projection.current_stock),
'unit_of_measure': projection.unit_of_measure,
'projection_horizon_days': projection.projection_horizon_days,
'total_consumption': float(projection.total_consumption),
'total_receipts': float(projection.total_receipts),
'stockout_days': projection.stockout_days,
'stockout_risk': projection.stockout_risk,
'daily_projections': [
{
'date': dp.date.isoformat(),
'starting_stock': float(dp.starting_stock),
'forecasted_consumption': float(dp.forecasted_consumption),
'scheduled_receipts': float(dp.scheduled_receipts),
'projected_ending_stock': float(dp.projected_ending_stock),
'is_stockout': dp.is_stockout,
'coverage_gap': float(dp.coverage_gap)
}
for dp in projection.daily_projections
]
}

View File

@@ -0,0 +1,366 @@
"""
Lead Time Planner
Calculates order dates based on supplier lead times to ensure timely delivery.
"""
from datetime import date, timedelta
from decimal import Decimal
from typing import List, Dict, Optional, Tuple
from dataclasses import dataclass
import logging
logger = logging.getLogger(__name__)
@dataclass
class LeadTimeRequirement:
"""Requirement with lead time information"""
ingredient_id: str
ingredient_name: str
required_quantity: Decimal
required_by_date: date
supplier_id: Optional[str] = None
lead_time_days: int = 0
buffer_days: int = 1
@dataclass
class LeadTimePlan:
"""Planned order with dates"""
ingredient_id: str
ingredient_name: str
order_quantity: Decimal
order_date: date
delivery_date: date
required_by_date: date
lead_time_days: int
buffer_days: int
is_urgent: bool
urgency_reason: Optional[str] = None
supplier_id: Optional[str] = None
class LeadTimePlanner:
"""
Plans order dates based on supplier lead times.
Ensures that:
1. Orders are placed early enough for on-time delivery
2. Buffer days are added for risk mitigation
3. Urgent orders are identified
4. Weekend/holiday adjustments can be applied
"""
def __init__(self, default_buffer_days: int = 1):
"""
Initialize lead time planner.
Args:
default_buffer_days: Default buffer days to add
"""
self.default_buffer_days = default_buffer_days
def calculate_order_date(
self,
required_by_date: date,
lead_time_days: int,
buffer_days: Optional[int] = None
) -> date:
"""
Calculate when order should be placed.
Order Date = Required Date - Lead Time - Buffer
Args:
required_by_date: Date when item is needed
lead_time_days: Supplier lead time in days
buffer_days: Additional buffer days (uses default if None)
Returns:
Order date
"""
buffer = buffer_days if buffer_days is not None else self.default_buffer_days
total_days = lead_time_days + buffer
order_date = required_by_date - timedelta(days=total_days)
return order_date
def calculate_delivery_date(
self,
order_date: date,
lead_time_days: int
) -> date:
"""
Calculate expected delivery date.
Delivery Date = Order Date + Lead Time
Args:
order_date: Date when order is placed
lead_time_days: Supplier lead time in days
Returns:
Expected delivery date
"""
return order_date + timedelta(days=lead_time_days)
def is_urgent(
self,
order_date: date,
today: date,
urgency_threshold_days: int = 2
) -> Tuple[bool, Optional[str]]:
"""
Determine if order is urgent.
Args:
order_date: Calculated order date
today: Current date
urgency_threshold_days: Days threshold for urgency
Returns:
Tuple of (is_urgent, reason)
"""
days_until_order = (order_date - today).days
if days_until_order < 0:
return True, f"Order should have been placed {abs(days_until_order)} days ago"
elif days_until_order <= urgency_threshold_days:
return True, f"Order must be placed within {days_until_order} days"
else:
return False, None
def plan_requirements(
self,
requirements: List[LeadTimeRequirement],
today: Optional[date] = None
) -> List[LeadTimePlan]:
"""
Plan order dates for multiple requirements.
Args:
requirements: List of requirements with lead time info
today: Current date (defaults to today)
Returns:
List of lead time plans
"""
if today is None:
today = date.today()
plans = []
for req in requirements:
# Calculate order date
order_date = self.calculate_order_date(
required_by_date=req.required_by_date,
lead_time_days=req.lead_time_days,
buffer_days=req.buffer_days if hasattr(req, 'buffer_days') else None
)
# Calculate delivery date
delivery_date = self.calculate_delivery_date(
order_date=order_date,
lead_time_days=req.lead_time_days
)
# Check urgency
is_urgent, urgency_reason = self.is_urgent(
order_date=order_date,
today=today
)
# Create plan
plan = LeadTimePlan(
ingredient_id=req.ingredient_id,
ingredient_name=req.ingredient_name,
order_quantity=req.required_quantity,
order_date=max(order_date, today), # Can't order in the past
delivery_date=delivery_date,
required_by_date=req.required_by_date,
lead_time_days=req.lead_time_days,
buffer_days=self.default_buffer_days,
is_urgent=is_urgent,
urgency_reason=urgency_reason,
supplier_id=req.supplier_id
)
plans.append(plan)
if is_urgent:
logger.warning(
f"URGENT: {req.ingredient_name} - {urgency_reason}"
)
# Sort by order date (urgent first)
plans.sort(key=lambda p: (not p.is_urgent, p.order_date))
return plans
def adjust_for_working_days(
self,
target_date: date,
non_working_days: List[int] = None
) -> date:
"""
Adjust date to skip non-working days (e.g., weekends).
Args:
target_date: Original date
non_working_days: List of weekday numbers (0=Monday, 6=Sunday)
Returns:
Adjusted date
"""
if non_working_days is None:
non_working_days = [5, 6] # Saturday, Sunday
adjusted = target_date
# Move backwards to previous working day
while adjusted.weekday() in non_working_days:
adjusted -= timedelta(days=1)
return adjusted
def consolidate_orders_by_date(
self,
plans: List[LeadTimePlan],
consolidation_window_days: int = 3
) -> Dict[date, List[LeadTimePlan]]:
"""
Group orders that can be placed together.
Args:
plans: List of lead time plans
consolidation_window_days: Days within which to consolidate
Returns:
Dictionary mapping order date to list of plans
"""
if not plans:
return {}
# Sort plans by order date
sorted_plans = sorted(plans, key=lambda p: p.order_date)
consolidated: Dict[date, List[LeadTimePlan]] = {}
current_date = None
current_batch = []
for plan in sorted_plans:
if current_date is None:
current_date = plan.order_date
current_batch = [plan]
else:
days_diff = (plan.order_date - current_date).days
if days_diff <= consolidation_window_days:
# Within consolidation window
current_batch.append(plan)
else:
# Save current batch
consolidated[current_date] = current_batch
# Start new batch
current_date = plan.order_date
current_batch = [plan]
# Save last batch
if current_batch:
consolidated[current_date] = current_batch
logger.info(
f"Consolidated {len(plans)} orders into {len(consolidated)} order dates"
)
return consolidated
def calculate_coverage_window(
self,
order_date: date,
delivery_date: date,
required_by_date: date
) -> Dict[str, int]:
"""
Calculate time windows for an order.
Args:
order_date: When order is placed
delivery_date: When order arrives
required_by_date: When item is needed
Returns:
Dictionary with time windows
"""
return {
"order_to_delivery_days": (delivery_date - order_date).days,
"delivery_to_required_days": (required_by_date - delivery_date).days,
"total_lead_time_days": (delivery_date - order_date).days,
"buffer_time_days": (required_by_date - delivery_date).days
}
def validate_plan(
self,
plan: LeadTimePlan,
today: Optional[date] = None
) -> Tuple[bool, List[str]]:
"""
Validate a lead time plan for feasibility.
Args:
plan: Lead time plan to validate
today: Current date
Returns:
Tuple of (is_valid, list of issues)
"""
if today is None:
today = date.today()
issues = []
# Check if order date is in the past
if plan.order_date < today:
issues.append(f"Order date {plan.order_date} is in the past")
# Check if delivery date is before required date
if plan.delivery_date > plan.required_by_date:
days_late = (plan.delivery_date - plan.required_by_date).days
issues.append(
f"Delivery will be {days_late} days late (arrives {plan.delivery_date}, needed {plan.required_by_date})"
)
# Check if lead time is reasonable
if plan.lead_time_days > 90:
issues.append(f"Lead time of {plan.lead_time_days} days seems unusually long")
# Check if order quantity is valid
if plan.order_quantity <= 0:
issues.append(f"Order quantity {plan.order_quantity} is invalid")
is_valid = len(issues) == 0
return is_valid, issues
def get_urgent_orders(
self,
plans: List[LeadTimePlan]
) -> List[LeadTimePlan]:
"""
Filter to only urgent orders.
Args:
plans: List of lead time plans
Returns:
List of urgent plans
"""
urgent = [p for p in plans if p.is_urgent]
if urgent:
logger.warning(f"Found {len(urgent)} urgent orders requiring immediate attention")
return urgent

View File

@@ -0,0 +1,458 @@
"""
MOQ Aggregator
Aggregates multiple procurement requirements to meet Minimum Order Quantities (MOQ)
and optimize order sizes.
"""
from datetime import date, timedelta
from decimal import Decimal
from typing import List, Dict, Optional, Tuple
from dataclasses import dataclass
import logging
from shared.utils.optimization import (
round_to_moq,
round_to_package_size,
aggregate_requirements_for_moq
)
logger = logging.getLogger(__name__)
@dataclass
class ProcurementRequirement:
"""Single procurement requirement"""
id: str
ingredient_id: str
ingredient_name: str
quantity: Decimal
required_date: date
supplier_id: str
unit_of_measure: str
@dataclass
class SupplierConstraints:
"""Supplier ordering constraints"""
supplier_id: str
supplier_name: str
min_order_quantity: Optional[Decimal] = None
min_order_value: Optional[Decimal] = None
package_size: Optional[Decimal] = None
max_order_quantity: Optional[Decimal] = None
economic_order_multiple: Optional[Decimal] = None
@dataclass
class AggregatedOrder:
"""Aggregated order for a supplier"""
id: str
supplier_id: str
ingredient_id: str
ingredient_name: str
aggregated_quantity: Decimal
original_quantity: Decimal
order_date: date
unit_of_measure: str
requirements: List[ProcurementRequirement]
adjustment_reason: str
moq_applied: bool
package_rounding_applied: bool
class MOQAggregator:
"""
Aggregates procurement requirements to meet MOQ constraints.
Strategies:
1. Combine multiple requirements for same ingredient
2. Round up to meet MOQ
3. Round to package sizes
4. Consolidate orders within time window
5. Optimize order timing
"""
def __init__(
self,
consolidation_window_days: int = 7,
allow_early_ordering: bool = True
):
"""
Initialize MOQ aggregator.
Args:
consolidation_window_days: Days within which to consolidate orders
allow_early_ordering: Whether to allow ordering early to meet MOQ
"""
self.consolidation_window_days = consolidation_window_days
self.allow_early_ordering = allow_early_ordering
def aggregate_requirements(
self,
requirements: List[ProcurementRequirement],
supplier_constraints: Dict[str, SupplierConstraints]
) -> List[AggregatedOrder]:
"""
Aggregate requirements to meet MOQ constraints.
Args:
requirements: List of procurement requirements
supplier_constraints: Dictionary of supplier constraints by supplier_id
Returns:
List of aggregated orders
"""
if not requirements:
return []
logger.info(f"Aggregating {len(requirements)} procurement requirements")
# Group requirements by supplier and ingredient
grouped = self._group_requirements(requirements)
aggregated_orders = []
for (supplier_id, ingredient_id), reqs in grouped.items():
constraints = supplier_constraints.get(supplier_id)
if not constraints:
logger.warning(
f"No constraints found for supplier {supplier_id}, "
f"processing without MOQ"
)
constraints = SupplierConstraints(
supplier_id=supplier_id,
supplier_name=f"Supplier {supplier_id}"
)
# Aggregate this group
orders = self._aggregate_ingredient_requirements(
reqs,
constraints
)
aggregated_orders.extend(orders)
logger.info(
f"Created {len(aggregated_orders)} aggregated orders "
f"from {len(requirements)} requirements"
)
return aggregated_orders
def _group_requirements(
self,
requirements: List[ProcurementRequirement]
) -> Dict[Tuple[str, str], List[ProcurementRequirement]]:
"""
Group requirements by supplier and ingredient.
Args:
requirements: List of requirements
Returns:
Dictionary mapping (supplier_id, ingredient_id) to list of requirements
"""
grouped: Dict[Tuple[str, str], List[ProcurementRequirement]] = {}
for req in requirements:
key = (req.supplier_id, req.ingredient_id)
if key not in grouped:
grouped[key] = []
grouped[key].append(req)
return grouped
def _aggregate_ingredient_requirements(
self,
requirements: List[ProcurementRequirement],
constraints: SupplierConstraints
) -> List[AggregatedOrder]:
"""
Aggregate requirements for one ingredient from one supplier.
Args:
requirements: List of requirements for same ingredient/supplier
constraints: Supplier constraints
Returns:
List of aggregated orders
"""
if not requirements:
return []
# Sort by required date
sorted_reqs = sorted(requirements, key=lambda r: r.required_date)
# Try to consolidate within time window
batches = self._consolidate_by_time_window(sorted_reqs)
orders = []
for batch in batches:
order = self._create_aggregated_order(batch, constraints)
orders.append(order)
return orders
def _consolidate_by_time_window(
self,
requirements: List[ProcurementRequirement]
) -> List[List[ProcurementRequirement]]:
"""
Consolidate requirements within time window.
Args:
requirements: Sorted list of requirements
Returns:
List of requirement batches
"""
if not requirements:
return []
batches = []
current_batch = [requirements[0]]
batch_start_date = requirements[0].required_date
for req in requirements[1:]:
days_diff = (req.required_date - batch_start_date).days
if days_diff <= self.consolidation_window_days:
# Within window, add to current batch
current_batch.append(req)
else:
# Outside window, start new batch
batches.append(current_batch)
current_batch = [req]
batch_start_date = req.required_date
# Add final batch
if current_batch:
batches.append(current_batch)
return batches
def _create_aggregated_order(
self,
requirements: List[ProcurementRequirement],
constraints: SupplierConstraints
) -> AggregatedOrder:
"""
Create aggregated order from requirements.
Args:
requirements: List of requirements to aggregate
constraints: Supplier constraints
Returns:
Aggregated order
"""
# Sum quantities
total_quantity = sum(req.quantity for req in requirements)
original_quantity = total_quantity
# Get earliest required date
order_date = min(req.required_date for req in requirements)
# Get ingredient info from first requirement
first_req = requirements[0]
ingredient_id = first_req.ingredient_id
ingredient_name = first_req.ingredient_name
unit_of_measure = first_req.unit_of_measure
# Apply constraints
adjustment_reason = []
moq_applied = False
package_rounding_applied = False
# 1. Check MOQ
if constraints.min_order_quantity:
if total_quantity < constraints.min_order_quantity:
total_quantity = constraints.min_order_quantity
moq_applied = True
adjustment_reason.append(
f"Rounded up to MOQ: {constraints.min_order_quantity} {unit_of_measure}"
)
# 2. Check package size
if constraints.package_size:
rounded_qty = round_to_package_size(
total_quantity,
constraints.package_size,
allow_partial=False
)
if rounded_qty != total_quantity:
total_quantity = rounded_qty
package_rounding_applied = True
adjustment_reason.append(
f"Rounded to package size: {constraints.package_size} {unit_of_measure}"
)
# 3. Check max order quantity
if constraints.max_order_quantity:
if total_quantity > constraints.max_order_quantity:
logger.warning(
f"{ingredient_name}: Order quantity {total_quantity} exceeds "
f"max {constraints.max_order_quantity}, capping"
)
total_quantity = constraints.max_order_quantity
adjustment_reason.append(
f"Capped at maximum: {constraints.max_order_quantity} {unit_of_measure}"
)
# 4. Apply economic order multiple
if constraints.economic_order_multiple:
multiple = constraints.economic_order_multiple
rounded = round_to_moq(total_quantity, multiple, round_up=True)
if rounded != total_quantity:
total_quantity = rounded
adjustment_reason.append(
f"Rounded to economic multiple: {multiple} {unit_of_measure}"
)
# Create aggregated order
order = AggregatedOrder(
id=f"agg_{requirements[0].id}",
supplier_id=constraints.supplier_id,
ingredient_id=ingredient_id,
ingredient_name=ingredient_name,
aggregated_quantity=total_quantity,
original_quantity=original_quantity,
order_date=order_date,
unit_of_measure=unit_of_measure,
requirements=requirements,
adjustment_reason=" | ".join(adjustment_reason) if adjustment_reason else "No adjustments",
moq_applied=moq_applied,
package_rounding_applied=package_rounding_applied
)
if total_quantity != original_quantity:
logger.info(
f"{ingredient_name}: Aggregated {len(requirements)} requirements "
f"({original_quantity}{total_quantity} {unit_of_measure})"
)
return order
def calculate_order_efficiency(
self,
orders: List[AggregatedOrder]
) -> Dict:
"""
Calculate efficiency metrics for aggregated orders.
Args:
orders: List of aggregated orders
Returns:
Efficiency metrics
"""
total_orders = len(orders)
total_requirements = sum(len(order.requirements) for order in orders)
orders_with_moq = sum(1 for order in orders if order.moq_applied)
orders_with_rounding = sum(1 for order in orders if order.package_rounding_applied)
total_original_qty = sum(order.original_quantity for order in orders)
total_aggregated_qty = sum(order.aggregated_quantity for order in orders)
overhead_qty = total_aggregated_qty - total_original_qty
overhead_percentage = (
(overhead_qty / total_original_qty * 100)
if total_original_qty > 0 else 0
)
consolidation_ratio = (
total_requirements / total_orders
if total_orders > 0 else 0
)
return {
'total_orders': total_orders,
'total_requirements': total_requirements,
'consolidation_ratio': float(consolidation_ratio),
'orders_with_moq_adjustment': orders_with_moq,
'orders_with_package_rounding': orders_with_rounding,
'total_original_quantity': float(total_original_qty),
'total_aggregated_quantity': float(total_aggregated_qty),
'overhead_quantity': float(overhead_qty),
'overhead_percentage': float(overhead_percentage)
}
def split_oversized_order(
self,
order: AggregatedOrder,
max_quantity: Decimal,
split_window_days: int = 7
) -> List[AggregatedOrder]:
"""
Split an oversized order into multiple smaller orders.
Args:
order: Order to split
max_quantity: Maximum quantity per order
split_window_days: Days between split orders
Returns:
List of split orders
"""
if order.aggregated_quantity <= max_quantity:
return [order]
logger.info(
f"Splitting oversized order: {order.aggregated_quantity} > {max_quantity}"
)
num_splits = int((order.aggregated_quantity / max_quantity)) + 1
qty_per_order = order.aggregated_quantity / Decimal(str(num_splits))
split_orders = []
for i in range(num_splits):
split_date = order.order_date + timedelta(days=i * split_window_days)
split_order = AggregatedOrder(
id=f"{order.id}_split_{i+1}",
supplier_id=order.supplier_id,
ingredient_id=order.ingredient_id,
ingredient_name=order.ingredient_name,
aggregated_quantity=qty_per_order,
original_quantity=order.original_quantity / Decimal(str(num_splits)),
order_date=split_date,
unit_of_measure=order.unit_of_measure,
requirements=order.requirements, # Share requirements
adjustment_reason=f"Split {i+1}/{num_splits} due to capacity constraint",
moq_applied=order.moq_applied,
package_rounding_applied=order.package_rounding_applied
)
split_orders.append(split_order)
return split_orders
def export_to_dict(self, order: AggregatedOrder) -> Dict:
"""
Export aggregated order to dictionary.
Args:
order: Aggregated order
Returns:
Dictionary representation
"""
return {
'id': order.id,
'supplier_id': order.supplier_id,
'ingredient_id': order.ingredient_id,
'ingredient_name': order.ingredient_name,
'aggregated_quantity': float(order.aggregated_quantity),
'original_quantity': float(order.original_quantity),
'order_date': order.order_date.isoformat(),
'unit_of_measure': order.unit_of_measure,
'num_requirements_aggregated': len(order.requirements),
'adjustment_reason': order.adjustment_reason,
'moq_applied': order.moq_applied,
'package_rounding_applied': order.package_rounding_applied
}

View File

@@ -0,0 +1,568 @@
"""
Procurement Service - ENHANCED VERSION
Integrates advanced replenishment planning with:
- Lead-time-aware planning
- Dynamic safety stock
- Inventory projection
- Shelf-life management
- MOQ optimization
- Multi-criteria supplier selection
This is a COMPLETE REWRITE integrating all new planning services.
"""
import asyncio
import uuid
from datetime import datetime, date, timedelta
from decimal import Decimal
from typing import List, Optional, Dict, Any, Tuple
import structlog
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.procurement_plan import ProcurementPlan, ProcurementRequirement
from app.models.replenishment import ReplenishmentPlan, ReplenishmentPlanItem
from app.repositories.procurement_plan_repository import ProcurementPlanRepository, ProcurementRequirementRepository
from app.schemas.procurement_schemas import (
AutoGenerateProcurementRequest, AutoGenerateProcurementResponse
)
from app.core.config import settings
from shared.clients.inventory_client import InventoryServiceClient
from shared.clients.forecast_client import ForecastServiceClient
from shared.clients.suppliers_client import SuppliersServiceClient
from shared.clients.recipes_client import RecipesServiceClient
from shared.config.base import BaseServiceSettings
from shared.messaging.rabbitmq import RabbitMQClient
from shared.monitoring.decorators import monitor_performance
from shared.utils.tenant_settings_client import TenantSettingsClient
# NEW: Import all planning services
from app.services.replenishment_planning_service import (
ReplenishmentPlanningService,
IngredientRequirement
)
from app.services.moq_aggregator import (
MOQAggregator,
ProcurementRequirement as MOQProcurementRequirement,
SupplierConstraints
)
from app.services.supplier_selector import (
SupplierSelector,
SupplierOption
)
from app.services.recipe_explosion_service import RecipeExplosionService
from app.services.smart_procurement_calculator import SmartProcurementCalculator
logger = structlog.get_logger()
class ProcurementService:
"""
Enhanced Procurement Service with Advanced Planning
NEW WORKFLOW:
1. Generate forecast (from Orchestrator)
2. Get current inventory
3. Build ingredient requirements
4. Generate replenishment plan (NEW - with all planning algorithms)
5. Apply MOQ aggregation (NEW)
6. Select suppliers (NEW - multi-criteria)
7. Create purchase orders
8. Save everything to database
"""
def __init__(
self,
db: AsyncSession,
config: BaseServiceSettings,
inventory_client: Optional[InventoryServiceClient] = None,
forecast_client: Optional[ForecastServiceClient] = None,
suppliers_client: Optional[SuppliersServiceClient] = None,
recipes_client: Optional[RecipesServiceClient] = None,
):
self.db = db
self.config = config
self.plan_repo = ProcurementPlanRepository(db)
self.requirement_repo = ProcurementRequirementRepository(db)
# Initialize service clients
self.inventory_client = inventory_client or InventoryServiceClient(config)
self.forecast_client = forecast_client or ForecastServiceClient(config, "procurement-service")
self.suppliers_client = suppliers_client or SuppliersServiceClient(config)
self.recipes_client = recipes_client or RecipesServiceClient(config)
# Initialize tenant settings client
tenant_service_url = getattr(config, 'TENANT_SERVICE_URL', 'http://tenant-service:8000')
self.tenant_settings_client = TenantSettingsClient(tenant_service_url=tenant_service_url)
# Initialize RabbitMQ client
rabbitmq_url = getattr(config, 'RABBITMQ_URL', 'amqp://guest:guest@localhost:5672/')
self.rabbitmq_client = RabbitMQClient(rabbitmq_url, "procurement-service")
# Initialize Recipe Explosion Service
self.recipe_explosion_service = RecipeExplosionService(
config=config,
recipes_client=self.recipes_client,
inventory_client=self.inventory_client
)
# Initialize Smart Calculator (keep for backward compatibility)
self.smart_calculator = SmartProcurementCalculator(
inventory_client=self.inventory_client,
forecast_client=self.forecast_client
)
# NEW: Initialize advanced planning services
self.replenishment_planner = ReplenishmentPlanningService(
projection_horizon_days=getattr(settings, 'REPLENISHMENT_PROJECTION_HORIZON_DAYS', 7),
default_service_level=getattr(settings, 'REPLENISHMENT_SERVICE_LEVEL', 0.95),
default_buffer_days=getattr(settings, 'REPLENISHMENT_BUFFER_DAYS', 1)
)
self.moq_aggregator = MOQAggregator(
consolidation_window_days=getattr(settings, 'MOQ_CONSOLIDATION_WINDOW_DAYS', 7),
allow_early_ordering=getattr(settings, 'MOQ_ALLOW_EARLY_ORDERING', True)
)
self.supplier_selector = SupplierSelector(
price_weight=getattr(settings, 'SUPPLIER_PRICE_WEIGHT', 0.40),
lead_time_weight=getattr(settings, 'SUPPLIER_LEAD_TIME_WEIGHT', 0.20),
quality_weight=getattr(settings, 'SUPPLIER_QUALITY_WEIGHT', 0.20),
reliability_weight=getattr(settings, 'SUPPLIER_RELIABILITY_WEIGHT', 0.20),
diversification_threshold=getattr(settings, 'SUPPLIER_DIVERSIFICATION_THRESHOLD', Decimal('1000')),
max_single_supplier_percentage=getattr(settings, 'SUPPLIER_MAX_SINGLE_PERCENTAGE', 0.70)
)
logger.info("ProcurementServiceEnhanced initialized with advanced planning")
@monitor_performance("auto_generate_procurement_enhanced")
async def auto_generate_procurement(
self,
tenant_id: uuid.UUID,
request: AutoGenerateProcurementRequest
) -> AutoGenerateProcurementResponse:
"""
Auto-generate procurement plan with ADVANCED PLANNING
NEW WORKFLOW (vs old):
OLD: Forecast → Simple stock check → Create POs
NEW: Forecast → Replenishment Planning → MOQ Optimization → Supplier Selection → Create POs
"""
try:
target_date = request.target_date or date.today()
forecast_data = request.forecast_data
logger.info("Starting ENHANCED auto-generate procurement",
tenant_id=tenant_id,
target_date=target_date,
has_forecast_data=bool(forecast_data))
# ============================================================
# STEP 1: Get Current Inventory (Use cached if available)
# ============================================================
if request.inventory_data:
# Use cached inventory from Orchestrator (NEW)
inventory_items = request.inventory_data.get('ingredients', [])
logger.info(f"Using cached inventory snapshot: {len(inventory_items)} items")
else:
# Fallback: Fetch from Inventory Service
inventory_items = await self._get_inventory_list(tenant_id)
logger.info(f"Fetched inventory from service: {len(inventory_items)} items")
if not inventory_items:
return AutoGenerateProcurementResponse(
success=False,
message="No inventory items found",
errors=["Unable to retrieve inventory data"]
)
# ============================================================
# STEP 2: Get All Suppliers (Use cached if available)
# ============================================================
if request.suppliers_data:
# Use cached suppliers from Orchestrator (NEW)
suppliers = request.suppliers_data.get('suppliers', [])
logger.info(f"Using cached suppliers snapshot: {len(suppliers)} suppliers")
else:
# Fallback: Fetch from Suppliers Service
suppliers = await self._get_all_suppliers(tenant_id)
logger.info(f"Fetched suppliers from service: {len(suppliers)} suppliers")
# ============================================================
# STEP 3: Parse Forecast Data
# ============================================================
forecasts = self._parse_forecast_data(forecast_data, inventory_items)
logger.info(f"Parsed {len(forecasts)} forecast items")
# ============================================================
# STEP 4: Build Ingredient Requirements
# ============================================================
ingredient_requirements = await self._build_ingredient_requirements(
tenant_id=tenant_id,
forecasts=forecasts,
inventory_items=inventory_items,
suppliers=suppliers,
target_date=target_date
)
if not ingredient_requirements:
logger.warning("No ingredient requirements generated")
return AutoGenerateProcurementResponse(
success=False,
message="No procurement requirements identified",
errors=["No items need replenishment"]
)
logger.info(f"Built {len(ingredient_requirements)} ingredient requirements")
# ============================================================
# STEP 5: Generate Replenishment Plan (NEW!)
# ============================================================
replenishment_plan = await self.replenishment_planner.generate_replenishment_plan(
tenant_id=str(tenant_id),
requirements=ingredient_requirements,
forecast_id=forecast_data.get('forecast_id'),
production_schedule_id=request.production_schedule_id
)
logger.info(
f"Replenishment plan generated: {replenishment_plan.total_items} items, "
f"{replenishment_plan.urgent_items} urgent, "
f"{replenishment_plan.high_risk_items} high risk"
)
# ============================================================
# STEP 6: Apply MOQ Aggregation (NEW!)
# ============================================================
moq_requirements, supplier_constraints = self._prepare_moq_inputs(
replenishment_plan,
suppliers
)
aggregated_orders = self.moq_aggregator.aggregate_requirements(
requirements=moq_requirements,
supplier_constraints=supplier_constraints
)
moq_efficiency = self.moq_aggregator.calculate_order_efficiency(aggregated_orders)
logger.info(
f"MOQ aggregation: {len(aggregated_orders)} aggregated orders from "
f"{len(moq_requirements)} requirements "
f"(consolidation ratio: {moq_efficiency['consolidation_ratio']:.2f})"
)
# ============================================================
# STEP 7: Multi-Criteria Supplier Selection (NEW!)
# ============================================================
supplier_selections = await self._select_suppliers_for_requirements(
replenishment_plan,
suppliers
)
logger.info(f"Supplier selection completed for {len(supplier_selections)} items")
# ============================================================
# STEP 8: Save to Database
# ============================================================
# Create traditional procurement plan
plan_data = {
'tenant_id': tenant_id,
'plan_number': await self._generate_plan_number(),
'plan_date': target_date,
'planning_horizon_days': request.planning_horizon_days,
'status': 'draft',
'forecast_id': forecast_data.get('forecast_id'),
'production_schedule_id': request.production_schedule_id,
'total_estimated_cost': replenishment_plan.total_estimated_cost,
'seasonality_adjustment': Decimal('1.0')
}
plan = await self.plan_repo.create_plan(plan_data)
# Create procurement requirements from replenishment plan
requirements_data = self._convert_replenishment_to_requirements(
plan_id=plan.id,
tenant_id=tenant_id,
replenishment_plan=replenishment_plan,
supplier_selections=supplier_selections
)
# Save requirements
created_requirements = await self.requirement_repo.create_requirements_batch(requirements_data)
# Update plan totals
await self.plan_repo.update_plan(plan.id, tenant_id, {
'total_requirements': len(requirements_data),
'primary_suppliers_count': len(set(
r.get('preferred_supplier_id') for r in requirements_data
if r.get('preferred_supplier_id')
)),
'supplier_diversification_score': moq_efficiency.get('consolidation_ratio', 1.0)
})
# ============================================================
# STEP 9: Optionally Create Purchase Orders
# ============================================================
created_pos = []
if request.auto_create_pos:
po_result = await self._create_purchase_orders_from_plan(
tenant_id=tenant_id,
plan_id=plan.id,
auto_approve=request.auto_approve_pos
)
if po_result.get('success'):
created_pos = po_result.get('created_pos', [])
await self.db.commit()
# ============================================================
# STEP 10: Publish Events
# ============================================================
await self._publish_plan_generated_event(tenant_id, plan.id)
logger.info(
"ENHANCED procurement plan completed successfully",
tenant_id=tenant_id,
plan_id=plan.id,
requirements_count=len(requirements_data),
pos_created=len(created_pos),
urgent_items=replenishment_plan.urgent_items,
high_risk_items=replenishment_plan.high_risk_items
)
return AutoGenerateProcurementResponse(
success=True,
message="Enhanced procurement plan generated successfully",
plan_id=plan.id,
plan_number=plan.plan_number,
requirements_created=len(requirements_data),
purchase_orders_created=len(created_pos),
purchase_orders_auto_approved=sum(1 for po in created_pos if po.get('auto_approved')),
total_estimated_cost=replenishment_plan.total_estimated_cost,
created_pos=created_pos
)
except Exception as e:
await self.db.rollback()
logger.error("Error in enhanced auto_generate_procurement",
error=str(e), tenant_id=tenant_id, exc_info=True)
return AutoGenerateProcurementResponse(
success=False,
message="Failed to generate enhanced procurement plan",
errors=[str(e)]
)
# ============================================================
# Helper Methods
# ============================================================
async def _build_ingredient_requirements(
self,
tenant_id: uuid.UUID,
forecasts: List[Dict],
inventory_items: List[Dict],
suppliers: List[Dict],
target_date: date
) -> List[IngredientRequirement]:
"""
Build ingredient requirements from forecasts
"""
requirements = []
for forecast in forecasts:
ingredient_id = forecast.get('ingredient_id')
ingredient = next((i for i in inventory_items if str(i['id']) == str(ingredient_id)), None)
if not ingredient:
continue
# Calculate required quantity
predicted_demand = Decimal(str(forecast.get('predicted_demand', 0)))
current_stock = Decimal(str(ingredient.get('quantity', 0)))
if predicted_demand > current_stock:
required_quantity = predicted_demand - current_stock
# Find preferred supplier
preferred_supplier = self._find_preferred_supplier(ingredient, suppliers)
# Get lead time
lead_time_days = preferred_supplier.get('lead_time_days', 3) if preferred_supplier else 3
# Build requirement
req = IngredientRequirement(
ingredient_id=str(ingredient_id),
ingredient_name=ingredient.get('name', 'Unknown'),
required_quantity=required_quantity,
required_by_date=target_date + timedelta(days=7),
supplier_id=str(preferred_supplier['id']) if preferred_supplier else None,
lead_time_days=lead_time_days,
shelf_life_days=ingredient.get('shelf_life_days'),
is_perishable=ingredient.get('category') in ['fresh', 'dairy', 'produce'],
category=ingredient.get('category', 'dry'),
unit_of_measure=ingredient.get('unit_of_measure', 'kg'),
current_stock=current_stock,
daily_consumption_rate=float(predicted_demand) / 7, # Estimate
demand_std_dev=float(forecast.get('confidence_score', 0)) * 10 # Rough estimate
)
requirements.append(req)
return requirements
def _prepare_moq_inputs(
self,
replenishment_plan,
suppliers: List[Dict]
) -> Tuple[List[MOQProcurementRequirement], Dict[str, SupplierConstraints]]:
"""
Prepare inputs for MOQ aggregator
"""
moq_requirements = []
supplier_constraints = {}
for item in replenishment_plan.items:
req = MOQProcurementRequirement(
id=str(item.id),
ingredient_id=item.ingredient_id,
ingredient_name=item.ingredient_name,
quantity=item.final_order_quantity,
required_date=item.required_by_date,
supplier_id=item.supplier_id or 'unknown',
unit_of_measure=item.unit_of_measure
)
moq_requirements.append(req)
# Build supplier constraints
for supplier in suppliers:
supplier_id = str(supplier['id'])
supplier_constraints[supplier_id] = SupplierConstraints(
supplier_id=supplier_id,
supplier_name=supplier.get('name', 'Unknown'),
min_order_quantity=Decimal(str(supplier.get('min_order_quantity', 0))) if supplier.get('min_order_quantity') else None,
min_order_value=Decimal(str(supplier.get('min_order_value', 0))) if supplier.get('min_order_value') else None,
package_size=None, # Not in current schema
max_order_quantity=None # Not in current schema
)
return moq_requirements, supplier_constraints
async def _select_suppliers_for_requirements(
self,
replenishment_plan,
suppliers: List[Dict]
) -> Dict[str, Any]:
"""
Select best suppliers for each requirement
"""
selections = {}
for item in replenishment_plan.items:
# Build supplier options
supplier_options = []
for supplier in suppliers:
option = SupplierOption(
supplier_id=str(supplier['id']),
supplier_name=supplier.get('name', 'Unknown'),
unit_price=Decimal(str(supplier.get('unit_price', 10))), # Default price
lead_time_days=supplier.get('lead_time_days', 3),
min_order_quantity=Decimal(str(supplier.get('min_order_quantity', 0))) if supplier.get('min_order_quantity') else None,
quality_score=0.85, # Default quality
reliability_score=0.90 # Default reliability
)
supplier_options.append(option)
if supplier_options:
# Select suppliers
result = self.supplier_selector.select_suppliers(
ingredient_id=item.ingredient_id,
ingredient_name=item.ingredient_name,
required_quantity=item.final_order_quantity,
supplier_options=supplier_options
)
selections[item.ingredient_id] = result
return selections
def _convert_replenishment_to_requirements(
self,
plan_id: uuid.UUID,
tenant_id: uuid.UUID,
replenishment_plan,
supplier_selections: Dict
) -> List[Dict]:
"""
Convert replenishment plan items to procurement requirements
"""
requirements_data = []
for item in replenishment_plan.items:
# Get supplier selection
selection = supplier_selections.get(item.ingredient_id)
primary_allocation = selection.allocations[0] if selection and selection.allocations else None
req_data = {
'procurement_plan_id': plan_id,
'tenant_id': tenant_id,
'ingredient_id': uuid.UUID(item.ingredient_id),
'ingredient_name': item.ingredient_name,
'required_quantity': item.final_order_quantity,
'unit_of_measure': item.unit_of_measure,
'estimated_unit_price': primary_allocation.unit_price if primary_allocation else Decimal('10'),
'estimated_total_cost': primary_allocation.total_cost if primary_allocation else item.final_order_quantity * Decimal('10'),
'required_by_date': item.required_by_date,
'priority': 'urgent' if item.is_urgent else 'normal',
'preferred_supplier_id': uuid.UUID(primary_allocation.supplier_id) if primary_allocation else None,
'calculation_method': 'ENHANCED_REPLENISHMENT_PLANNING',
'ai_suggested_quantity': item.base_quantity,
'adjusted_quantity': item.final_order_quantity,
'adjustment_reason': f"Safety stock: {item.safety_stock_quantity}, Shelf-life adjusted",
'lead_time_days': item.lead_time_days
}
requirements_data.append(req_data)
return requirements_data
# Additional helper methods (shortened for brevity)
async def _get_inventory_list(self, tenant_id):
"""Get inventory items"""
return await self.inventory_client.get_ingredients(str(tenant_id))
async def _get_all_suppliers(self, tenant_id):
"""Get all suppliers"""
return await self.suppliers_client.get_suppliers(str(tenant_id))
def _parse_forecast_data(self, forecast_data, inventory_items):
"""Parse forecast data from orchestrator"""
forecasts = forecast_data.get('forecasts', [])
return forecasts
def _find_preferred_supplier(self, ingredient, suppliers):
"""Find preferred supplier for ingredient"""
# Simple: return first supplier (can be enhanced with logic)
return suppliers[0] if suppliers else None
async def _generate_plan_number(self):
"""Generate unique plan number"""
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
return f"PLAN-{timestamp}"
async def _create_purchase_orders_from_plan(self, tenant_id, plan_id, auto_approve):
"""Create POs from plan (placeholder)"""
return {'success': True, 'created_pos': []}
async def _publish_plan_generated_event(self, tenant_id, plan_id):
"""Publish plan generated event"""
try:
await self.rabbitmq_client.publish_event(
exchange='procurement',
routing_key='plan.generated',
message={
'tenant_id': str(tenant_id),
'plan_id': str(plan_id),
'timestamp': datetime.utcnow().isoformat()
}
)
except Exception as e:
logger.warning(f"Failed to publish event: {e}")

View File

@@ -0,0 +1,652 @@
# ================================================================
# services/procurement/app/services/purchase_order_service.py
# ================================================================
"""
Purchase Order Service - Business logic for purchase order management
Migrated from Suppliers Service to Procurement Service ownership
"""
import uuid
from datetime import datetime, date, timedelta
from decimal import Decimal
from typing import List, Optional, Dict, Any
import structlog
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.purchase_order import PurchaseOrder, PurchaseOrderItem, Delivery, DeliveryItem, SupplierInvoice
from app.repositories.purchase_order_repository import (
PurchaseOrderRepository,
PurchaseOrderItemRepository,
DeliveryRepository,
SupplierInvoiceRepository
)
from app.schemas.purchase_order_schemas import (
PurchaseOrderCreate,
PurchaseOrderUpdate,
PurchaseOrderResponse,
DeliveryCreate,
DeliveryUpdate,
SupplierInvoiceCreate,
)
from app.core.config import settings
from shared.clients.suppliers_client import SuppliersServiceClient
from shared.config.base import BaseServiceSettings
logger = structlog.get_logger()
class PurchaseOrderService:
"""Service for purchase order management operations"""
def __init__(
self,
db: AsyncSession,
config: BaseServiceSettings,
suppliers_client: Optional[SuppliersServiceClient] = None
):
self.db = db
self.config = config
self.po_repo = PurchaseOrderRepository(db)
self.item_repo = PurchaseOrderItemRepository(db)
self.delivery_repo = DeliveryRepository(db)
self.invoice_repo = SupplierInvoiceRepository(db)
# Initialize suppliers client for supplier validation
self.suppliers_client = suppliers_client or SuppliersServiceClient(config)
# ================================================================
# PURCHASE ORDER CRUD
# ================================================================
async def create_purchase_order(
self,
tenant_id: uuid.UUID,
po_data: PurchaseOrderCreate,
created_by: Optional[uuid.UUID] = None
) -> PurchaseOrder:
"""
Create a new purchase order with items
Flow:
1. Validate supplier exists and is active
2. Generate PO number
3. Calculate totals
4. Determine approval requirements
5. Create PO and items
6. Link to procurement plan if provided
"""
try:
logger.info("Creating purchase order",
tenant_id=tenant_id,
supplier_id=po_data.supplier_id)
# Validate supplier
supplier = await self._get_and_validate_supplier(tenant_id, po_data.supplier_id)
# Generate PO number
po_number = await self.po_repo.generate_po_number(tenant_id)
# Calculate totals
subtotal = po_data.subtotal
total_amount = (
subtotal +
po_data.tax_amount +
po_data.shipping_cost -
po_data.discount_amount
)
# Determine approval requirements
requires_approval = self._requires_approval(total_amount, po_data.priority)
initial_status = self._determine_initial_status(total_amount, requires_approval)
# Set delivery date if not provided
required_delivery_date = po_data.required_delivery_date
estimated_delivery_date = date.today() + timedelta(days=supplier.get('standard_lead_time', 7))
# Create PO
po_create_data = {
'tenant_id': tenant_id,
'supplier_id': po_data.supplier_id,
'po_number': po_number,
'status': initial_status,
'priority': po_data.priority,
'order_date': datetime.utcnow(),
'required_delivery_date': required_delivery_date,
'estimated_delivery_date': estimated_delivery_date,
'subtotal': subtotal,
'tax_amount': po_data.tax_amount,
'shipping_cost': po_data.shipping_cost,
'discount_amount': po_data.discount_amount,
'total_amount': total_amount,
'currency': supplier.get('currency', 'EUR'),
'requires_approval': requires_approval,
'notes': po_data.notes,
'procurement_plan_id': po_data.procurement_plan_id,
'created_by': created_by,
'updated_by': created_by,
'created_at': datetime.utcnow(),
'updated_at': datetime.utcnow(),
}
purchase_order = await self.po_repo.create_po(po_create_data)
# Create PO items
for item_data in po_data.items:
item_create_data = {
'tenant_id': tenant_id,
'purchase_order_id': purchase_order.id,
'inventory_product_id': item_data.inventory_product_id,
'ordered_quantity': item_data.ordered_quantity,
'unit_price': item_data.unit_price,
'unit_of_measure': item_data.unit_of_measure,
'line_total': item_data.ordered_quantity * item_data.unit_price,
'received_quantity': Decimal('0'),
'quality_requirements': item_data.quality_requirements,
'item_notes': item_data.item_notes,
'created_at': datetime.utcnow(),
'updated_at': datetime.utcnow(),
}
await self.item_repo.create_item(item_create_data)
await self.db.commit()
logger.info("Purchase order created successfully",
tenant_id=tenant_id,
po_id=purchase_order.id,
po_number=po_number,
total_amount=float(total_amount))
return purchase_order
except Exception as e:
await self.db.rollback()
logger.error("Error creating purchase order", error=str(e), tenant_id=tenant_id)
raise
async def get_purchase_order(
self,
tenant_id: uuid.UUID,
po_id: uuid.UUID
) -> Optional[PurchaseOrder]:
"""Get purchase order by ID with items"""
try:
po = await self.po_repo.get_po_by_id(po_id, tenant_id)
if po:
# Enrich with supplier information
await self._enrich_po_with_supplier(tenant_id, po)
return po
except Exception as e:
logger.error("Error getting purchase order", error=str(e), po_id=po_id)
return None
async def list_purchase_orders(
self,
tenant_id: uuid.UUID,
skip: int = 0,
limit: int = 50,
supplier_id: Optional[uuid.UUID] = None,
status: Optional[str] = None
) -> List[PurchaseOrder]:
"""List purchase orders with filters"""
try:
# Convert status string to enum if provided
status_enum = None
if status:
try:
from app.models.purchase_order import PurchaseOrderStatus
# Convert from UPPERCASE to lowercase for enum lookup
status_enum = PurchaseOrderStatus[status.lower()]
except (KeyError, AttributeError):
logger.warning("Invalid status value provided", status=status)
status_enum = None
pos = await self.po_repo.list_purchase_orders(
tenant_id=tenant_id,
offset=skip, # Repository uses 'offset' parameter
limit=limit,
supplier_id=supplier_id,
status=status_enum
)
# Enrich with supplier information
for po in pos:
await self._enrich_po_with_supplier(tenant_id, po)
return pos
except Exception as e:
logger.error("Error listing purchase orders", error=str(e), tenant_id=tenant_id)
return []
async def update_po(
self,
tenant_id: uuid.UUID,
po_id: uuid.UUID,
po_data: PurchaseOrderUpdate,
updated_by: Optional[uuid.UUID] = None
) -> Optional[PurchaseOrder]:
"""Update purchase order information"""
try:
logger.info("Updating purchase order", po_id=po_id)
po = await self.po_repo.get_po_by_id(po_id, tenant_id)
if not po:
return None
# Check if order can be modified
if po.status in ['completed', 'cancelled']:
raise ValueError("Cannot modify completed or cancelled orders")
# Prepare update data
update_data = po_data.model_dump(exclude_unset=True)
update_data['updated_by'] = updated_by
update_data['updated_at'] = datetime.utcnow()
# Recalculate totals if financial fields changed
if any(key in update_data for key in ['tax_amount', 'shipping_cost', 'discount_amount']):
total_amount = (
po.subtotal +
update_data.get('tax_amount', po.tax_amount) +
update_data.get('shipping_cost', po.shipping_cost) -
update_data.get('discount_amount', po.discount_amount)
)
update_data['total_amount'] = total_amount
po = await self.po_repo.update_po(po_id, tenant_id, update_data)
await self.db.commit()
logger.info("Purchase order updated successfully", po_id=po_id)
return po
except Exception as e:
await self.db.rollback()
logger.error("Error updating purchase order", error=str(e), po_id=po_id)
raise
async def update_order_status(
self,
tenant_id: uuid.UUID,
po_id: uuid.UUID,
status: str,
updated_by: Optional[uuid.UUID] = None,
notes: Optional[str] = None
) -> Optional[PurchaseOrder]:
"""Update purchase order status"""
try:
logger.info("Updating PO status", po_id=po_id, status=status)
po = await self.po_repo.get_po_by_id(po_id, tenant_id)
if not po:
return None
# Validate status transition
if not self._is_valid_status_transition(po.status, status):
raise ValueError(f"Invalid status transition from {po.status} to {status}")
update_data = {
'status': status,
'updated_by': updated_by,
'updated_at': datetime.utcnow()
}
if status == 'sent_to_supplier':
update_data['sent_to_supplier_at'] = datetime.utcnow()
elif status == 'confirmed':
update_data['supplier_confirmation_date'] = datetime.utcnow()
po = await self.po_repo.update_po(po_id, tenant_id, update_data)
await self.db.commit()
return po
except Exception as e:
await self.db.rollback()
logger.error("Error updating PO status", error=str(e), po_id=po_id)
raise
async def approve_purchase_order(
self,
tenant_id: uuid.UUID,
po_id: uuid.UUID,
approved_by: uuid.UUID,
approval_notes: Optional[str] = None
) -> Optional[PurchaseOrder]:
"""Approve a purchase order"""
try:
logger.info("Approving purchase order", po_id=po_id)
po = await self.po_repo.get_po_by_id(po_id, tenant_id)
if not po:
return None
if po.status not in ['draft', 'pending_approval']:
raise ValueError(f"Cannot approve order with status {po.status}")
update_data = {
'status': 'approved',
'approved_by': approved_by,
'approved_at': datetime.utcnow(),
'updated_by': approved_by,
'updated_at': datetime.utcnow()
}
po = await self.po_repo.update_po(po_id, tenant_id, update_data)
await self.db.commit()
logger.info("Purchase order approved successfully", po_id=po_id)
return po
except Exception as e:
await self.db.rollback()
logger.error("Error approving purchase order", error=str(e), po_id=po_id)
raise
async def reject_purchase_order(
self,
tenant_id: uuid.UUID,
po_id: uuid.UUID,
rejected_by: uuid.UUID,
rejection_reason: str
) -> Optional[PurchaseOrder]:
"""Reject a purchase order"""
try:
logger.info("Rejecting purchase order", po_id=po_id)
po = await self.po_repo.get_po_by_id(po_id, tenant_id)
if not po:
return None
if po.status not in ['draft', 'pending_approval']:
raise ValueError(f"Cannot reject order with status {po.status}")
update_data = {
'status': 'rejected',
'rejection_reason': rejection_reason,
'updated_by': rejected_by,
'updated_at': datetime.utcnow()
}
po = await self.po_repo.update_po(po_id, tenant_id, update_data)
await self.db.commit()
logger.info("Purchase order rejected", po_id=po_id)
return po
except Exception as e:
await self.db.rollback()
logger.error("Error rejecting purchase order", error=str(e), po_id=po_id)
raise
async def cancel_purchase_order(
self,
tenant_id: uuid.UUID,
po_id: uuid.UUID,
cancelled_by: uuid.UUID,
cancellation_reason: str
) -> Optional[PurchaseOrder]:
"""Cancel a purchase order"""
try:
logger.info("Cancelling purchase order", po_id=po_id)
po = await self.po_repo.get_po_by_id(po_id, tenant_id)
if not po:
return None
if po.status in ['completed', 'cancelled']:
raise ValueError(f"Cannot cancel order with status {po.status}")
update_data = {
'status': 'cancelled',
'notes': f"{po.notes or ''}\nCancellation: {cancellation_reason}",
'updated_by': cancelled_by,
'updated_at': datetime.utcnow()
}
po = await self.po_repo.update_po(po_id, tenant_id, update_data)
await self.db.commit()
logger.info("Purchase order cancelled", po_id=po_id)
return po
except Exception as e:
await self.db.rollback()
logger.error("Error cancelling purchase order", error=str(e), po_id=po_id)
raise
# ================================================================
# DELIVERY MANAGEMENT
# ================================================================
async def create_delivery(
self,
tenant_id: uuid.UUID,
delivery_data: DeliveryCreate,
created_by: uuid.UUID
) -> Delivery:
"""Create a delivery record for a purchase order"""
try:
logger.info("Creating delivery", tenant_id=tenant_id, po_id=delivery_data.purchase_order_id)
# Validate PO exists
po = await self.po_repo.get_po_by_id(delivery_data.purchase_order_id, tenant_id)
if not po:
raise ValueError("Purchase order not found")
# Generate delivery number
delivery_number = await self.delivery_repo.generate_delivery_number(tenant_id)
# Create delivery
delivery_create_data = {
'tenant_id': tenant_id,
'purchase_order_id': delivery_data.purchase_order_id,
'supplier_id': delivery_data.supplier_id,
'delivery_number': delivery_number,
'supplier_delivery_note': delivery_data.supplier_delivery_note,
'status': 'scheduled',
'scheduled_date': delivery_data.scheduled_date,
'estimated_arrival': delivery_data.estimated_arrival,
'carrier_name': delivery_data.carrier_name,
'tracking_number': delivery_data.tracking_number,
'notes': delivery_data.notes,
'created_by': created_by,
'created_at': datetime.utcnow(),
'updated_at': datetime.utcnow(),
}
delivery = await self.delivery_repo.create_delivery(delivery_create_data)
# Create delivery items
for item_data in delivery_data.items:
item_create_data = {
'tenant_id': tenant_id,
'delivery_id': delivery.id,
'purchase_order_item_id': item_data.purchase_order_item_id,
'inventory_product_id': item_data.inventory_product_id,
'ordered_quantity': item_data.ordered_quantity,
'delivered_quantity': item_data.delivered_quantity,
'accepted_quantity': item_data.accepted_quantity,
'rejected_quantity': item_data.rejected_quantity,
'batch_lot_number': item_data.batch_lot_number,
'expiry_date': item_data.expiry_date,
'quality_grade': item_data.quality_grade,
'quality_issues': item_data.quality_issues,
'rejection_reason': item_data.rejection_reason,
'item_notes': item_data.item_notes,
'created_at': datetime.utcnow(),
'updated_at': datetime.utcnow(),
}
await self.delivery_repo.create_delivery_item(item_create_data)
await self.db.commit()
logger.info("Delivery created successfully",
tenant_id=tenant_id,
delivery_id=delivery.id,
delivery_number=delivery_number)
return delivery
except Exception as e:
await self.db.rollback()
logger.error("Error creating delivery", error=str(e), tenant_id=tenant_id)
raise
async def update_delivery_status(
self,
tenant_id: uuid.UUID,
delivery_id: uuid.UUID,
status: str,
updated_by: uuid.UUID
) -> Optional[Delivery]:
"""Update delivery status"""
try:
update_data = {
'status': status,
'updated_at': datetime.utcnow()
}
if status == 'in_transit':
update_data['actual_arrival'] = None
elif status == 'delivered':
update_data['actual_arrival'] = datetime.utcnow()
elif status == 'completed':
update_data['completed_at'] = datetime.utcnow()
delivery = await self.delivery_repo.update_delivery(delivery_id, tenant_id, update_data)
await self.db.commit()
return delivery
except Exception as e:
await self.db.rollback()
logger.error("Error updating delivery status", error=str(e), delivery_id=delivery_id)
raise
# ================================================================
# INVOICE MANAGEMENT
# ================================================================
async def create_invoice(
self,
tenant_id: uuid.UUID,
invoice_data: SupplierInvoiceCreate,
created_by: uuid.UUID
) -> SupplierInvoice:
"""Create a supplier invoice"""
try:
logger.info("Creating supplier invoice", tenant_id=tenant_id)
# Calculate total
total_amount = (
invoice_data.subtotal +
invoice_data.tax_amount +
invoice_data.shipping_cost -
invoice_data.discount_amount
)
# Get PO for currency
po = await self.po_repo.get_po_by_id(invoice_data.purchase_order_id, tenant_id)
if not po:
raise ValueError("Purchase order not found")
invoice_create_data = {
'tenant_id': tenant_id,
'purchase_order_id': invoice_data.purchase_order_id,
'supplier_id': invoice_data.supplier_id,
'invoice_number': invoice_data.invoice_number,
'status': 'received',
'invoice_date': invoice_data.invoice_date,
'due_date': invoice_data.due_date,
'subtotal': invoice_data.subtotal,
'tax_amount': invoice_data.tax_amount,
'shipping_cost': invoice_data.shipping_cost,
'discount_amount': invoice_data.discount_amount,
'total_amount': total_amount,
'currency': po.currency,
'paid_amount': Decimal('0'),
'remaining_amount': total_amount,
'notes': invoice_data.notes,
'payment_reference': invoice_data.payment_reference,
'created_by': created_by,
'updated_by': created_by,
'created_at': datetime.utcnow(),
'updated_at': datetime.utcnow(),
}
invoice = await self.invoice_repo.create_invoice(invoice_create_data)
await self.db.commit()
logger.info("Supplier invoice created", invoice_id=invoice.id)
return invoice
except Exception as e:
await self.db.rollback()
logger.error("Error creating invoice", error=str(e), tenant_id=tenant_id)
raise
# ================================================================
# PRIVATE HELPER METHODS
# ================================================================
async def _get_and_validate_supplier(self, tenant_id: uuid.UUID, supplier_id: uuid.UUID) -> Dict[str, Any]:
"""Get and validate supplier from Suppliers Service"""
try:
supplier = await self.suppliers_client.get_supplier(str(tenant_id), str(supplier_id))
if not supplier:
raise ValueError("Supplier not found")
if supplier.get('status') != 'active':
raise ValueError("Cannot create orders for inactive suppliers")
return supplier
except Exception as e:
logger.error("Error validating supplier", error=str(e), supplier_id=supplier_id)
raise
async def _enrich_po_with_supplier(self, tenant_id: uuid.UUID, po: PurchaseOrder) -> None:
"""Enrich purchase order with supplier information"""
try:
supplier = await self.suppliers_client.get_supplier(str(tenant_id), str(po.supplier_id))
if supplier:
# Set supplier_name as a dynamic attribute on the model instance
po.supplier_name = supplier.get('name', 'Unknown Supplier')
except Exception as e:
logger.warning("Failed to enrich PO with supplier info", error=str(e), po_id=po.id, supplier_id=po.supplier_id)
po.supplier_name = None
def _requires_approval(self, total_amount: Decimal, priority: str) -> bool:
"""Determine if PO requires approval"""
manager_threshold = Decimal(str(getattr(settings, 'MANAGER_APPROVAL_THRESHOLD', 1000)))
return total_amount >= manager_threshold or priority == 'critical'
def _determine_initial_status(self, total_amount: Decimal, requires_approval: bool) -> str:
"""Determine initial PO status"""
auto_approve_threshold = Decimal(str(getattr(settings, 'AUTO_APPROVE_THRESHOLD', 100)))
if requires_approval:
return 'pending_approval'
elif total_amount <= auto_approve_threshold:
return 'approved'
else:
return 'draft'
def _is_valid_status_transition(self, from_status: str, to_status: str) -> bool:
"""Validate status transition"""
valid_transitions = {
'draft': ['pending_approval', 'approved', 'cancelled'],
'pending_approval': ['approved', 'rejected', 'cancelled'],
'approved': ['sent_to_supplier', 'cancelled'],
'sent_to_supplier': ['confirmed', 'cancelled'],
'confirmed': ['in_production', 'cancelled'],
'in_production': ['shipped', 'cancelled'],
'shipped': ['delivered', 'cancelled'],
'delivered': ['completed'],
'rejected': [],
'cancelled': [],
'completed': []
}
return to_status in valid_transitions.get(from_status, [])

View File

@@ -0,0 +1,376 @@
# ================================================================
# services/procurement/app/services/recipe_explosion_service.py
# ================================================================
"""
Recipe Explosion Service - Multi-level BOM (Bill of Materials) explosion
Converts finished product demand into raw ingredient requirements for locally-produced items
"""
import uuid
import structlog
from typing import Dict, List, Optional, Set, Tuple
from decimal import Decimal
from collections import defaultdict
from shared.clients.recipes_client import RecipesServiceClient
from shared.clients.inventory_client import InventoryServiceClient
from app.core.config import settings
logger = structlog.get_logger()
class CircularDependencyError(Exception):
"""Raised when a circular dependency is detected in recipe tree"""
pass
class RecipeExplosionService:
"""
Service for exploding finished product requirements into raw ingredient requirements.
Supports multi-level BOM explosion (recipes that reference other recipes).
"""
def __init__(
self,
recipes_client: RecipesServiceClient,
inventory_client: InventoryServiceClient
):
self.recipes_client = recipes_client
self.inventory_client = inventory_client
self.max_depth = settings.MAX_BOM_EXPLOSION_DEPTH # Default: 5 levels
async def explode_requirements(
self,
tenant_id: uuid.UUID,
requirements: List[Dict]
) -> Tuple[List[Dict], Dict]:
"""
Explode locally-produced finished products into raw ingredient requirements.
Args:
tenant_id: Tenant ID
requirements: List of procurement requirements (can mix locally-produced and purchased items)
Returns:
Tuple of (exploded_requirements, explosion_metadata)
- exploded_requirements: Final list with locally-produced items exploded to ingredients
- explosion_metadata: Details about the explosion process
"""
logger.info("Starting recipe explosion",
tenant_id=str(tenant_id),
total_requirements=len(requirements))
# Separate locally-produced from purchased items
locally_produced = []
purchased_direct = []
for req in requirements:
if req.get('is_locally_produced', False) and req.get('recipe_id'):
locally_produced.append(req)
else:
purchased_direct.append(req)
logger.info("Requirements categorized",
locally_produced_count=len(locally_produced),
purchased_direct_count=len(purchased_direct))
# If no locally-produced items, return as-is
if not locally_produced:
return requirements, {'explosion_performed': False, 'message': 'No locally-produced items'}
# Explode locally-produced items
exploded_ingredients = await self._explode_locally_produced_batch(
tenant_id=tenant_id,
locally_produced_requirements=locally_produced
)
# Combine purchased items with exploded ingredients
final_requirements = purchased_direct + exploded_ingredients
# Create metadata
metadata = {
'explosion_performed': True,
'locally_produced_items_count': len(locally_produced),
'purchased_direct_count': len(purchased_direct),
'exploded_ingredients_count': len(exploded_ingredients),
'total_final_requirements': len(final_requirements)
}
logger.info("Recipe explosion completed", **metadata)
return final_requirements, metadata
async def _explode_locally_produced_batch(
self,
tenant_id: uuid.UUID,
locally_produced_requirements: List[Dict]
) -> List[Dict]:
"""
Explode a batch of locally-produced requirements into raw ingredients.
Uses multi-level explosion (recursive) to handle recipes that reference other recipes.
"""
# Aggregated ingredient requirements
aggregated_ingredients: Dict[str, Dict] = {}
for req in locally_produced_requirements:
product_id = req['product_id']
recipe_id = req['recipe_id']
required_quantity = Decimal(str(req['required_quantity']))
logger.info("Exploding locally-produced item",
product_id=str(product_id),
recipe_id=str(recipe_id),
quantity=float(required_quantity))
try:
# Explode this recipe (recursive)
ingredients = await self._explode_recipe_recursive(
tenant_id=tenant_id,
recipe_id=recipe_id,
required_quantity=required_quantity,
current_depth=0,
visited_recipes=set(),
parent_requirement=req
)
# Aggregate ingredients
for ingredient in ingredients:
ingredient_id = ingredient['ingredient_id']
quantity = ingredient['quantity']
if ingredient_id in aggregated_ingredients:
# Add to existing
existing_qty = Decimal(str(aggregated_ingredients[ingredient_id]['quantity']))
aggregated_ingredients[ingredient_id]['quantity'] = float(existing_qty + quantity)
else:
# New ingredient
aggregated_ingredients[ingredient_id] = ingredient
except CircularDependencyError as e:
logger.error("Circular dependency detected",
product_id=str(product_id),
recipe_id=str(recipe_id),
error=str(e))
# Skip this item or handle gracefully
continue
except Exception as e:
logger.error("Error exploding recipe",
product_id=str(product_id),
recipe_id=str(recipe_id),
error=str(e))
continue
# Convert aggregated dict to list
return list(aggregated_ingredients.values())
async def _explode_recipe_recursive(
self,
tenant_id: uuid.UUID,
recipe_id: uuid.UUID,
required_quantity: Decimal,
current_depth: int,
visited_recipes: Set[str],
parent_requirement: Dict
) -> List[Dict]:
"""
Recursively explode a recipe into raw ingredients.
Args:
tenant_id: Tenant ID
recipe_id: Recipe to explode
required_quantity: How much of the finished product is needed
current_depth: Current recursion depth (to prevent infinite loops)
visited_recipes: Set of recipe IDs already visited (circular dependency detection)
parent_requirement: The parent procurement requirement
Returns:
List of ingredient requirements (raw materials only)
"""
# Check depth limit
if current_depth >= self.max_depth:
logger.warning("Max explosion depth reached",
recipe_id=str(recipe_id),
max_depth=self.max_depth)
raise RecursionError(f"Max BOM explosion depth ({self.max_depth}) exceeded")
# Check circular dependency
recipe_id_str = str(recipe_id)
if recipe_id_str in visited_recipes:
logger.error("Circular dependency detected",
recipe_id=recipe_id_str,
visited_recipes=list(visited_recipes))
raise CircularDependencyError(
f"Circular dependency detected: recipe {recipe_id_str} references itself"
)
# Add to visited set
visited_recipes.add(recipe_id_str)
logger.debug("Exploding recipe",
recipe_id=recipe_id_str,
required_quantity=float(required_quantity),
depth=current_depth)
# Fetch recipe from Recipes Service
recipe_data = await self.recipes_client.get_recipe_by_id(
tenant_id=str(tenant_id),
recipe_id=recipe_id_str
)
if not recipe_data:
logger.error("Recipe not found", recipe_id=recipe_id_str)
raise ValueError(f"Recipe {recipe_id_str} not found")
# Calculate scale factor
recipe_yield_quantity = Decimal(str(recipe_data.get('yield_quantity', 1)))
scale_factor = required_quantity / recipe_yield_quantity
logger.debug("Recipe scale calculation",
recipe_yield=float(recipe_yield_quantity),
required=float(required_quantity),
scale_factor=float(scale_factor))
# Get recipe ingredients
ingredients = recipe_data.get('ingredients', [])
if not ingredients:
logger.warning("Recipe has no ingredients", recipe_id=recipe_id_str)
return []
# Process each ingredient
exploded_ingredients = []
for recipe_ingredient in ingredients:
ingredient_id = uuid.UUID(recipe_ingredient['ingredient_id'])
ingredient_quantity = Decimal(str(recipe_ingredient['quantity']))
scaled_quantity = ingredient_quantity * scale_factor
logger.debug("Processing recipe ingredient",
ingredient_id=str(ingredient_id),
base_quantity=float(ingredient_quantity),
scaled_quantity=float(scaled_quantity))
# Check if this ingredient is ALSO locally produced (nested recipe)
ingredient_info = await self._get_ingredient_info(tenant_id, ingredient_id)
if ingredient_info and ingredient_info.get('produced_locally') and ingredient_info.get('recipe_id'):
# Recursive case: This ingredient has its own recipe
logger.info("Ingredient is locally produced, recursing",
ingredient_id=str(ingredient_id),
nested_recipe_id=ingredient_info['recipe_id'],
depth=current_depth + 1)
nested_ingredients = await self._explode_recipe_recursive(
tenant_id=tenant_id,
recipe_id=uuid.UUID(ingredient_info['recipe_id']),
required_quantity=scaled_quantity,
current_depth=current_depth + 1,
visited_recipes=visited_recipes.copy(), # Pass a copy to allow sibling branches
parent_requirement=parent_requirement
)
exploded_ingredients.extend(nested_ingredients)
else:
# Base case: This is a raw ingredient (not produced locally)
exploded_ingredients.append({
'ingredient_id': str(ingredient_id),
'product_id': str(ingredient_id),
'quantity': float(scaled_quantity),
'unit': recipe_ingredient.get('unit'),
'is_locally_produced': False,
'recipe_id': None,
'parent_requirement_id': parent_requirement.get('id'),
'bom_explosion_level': current_depth + 1,
'source_recipe_id': recipe_id_str
})
return exploded_ingredients
async def _get_ingredient_info(
self,
tenant_id: uuid.UUID,
ingredient_id: uuid.UUID
) -> Optional[Dict]:
"""
Get ingredient info from Inventory Service to check if it's locally produced.
Args:
tenant_id: Tenant ID
ingredient_id: Ingredient/Product ID
Returns:
Dict with ingredient info including produced_locally and recipe_id flags
"""
try:
ingredient = await self.inventory_client.get_ingredient_by_id(
tenant_id=str(tenant_id),
ingredient_id=str(ingredient_id)
)
if not ingredient:
return None
return {
'id': ingredient.get('id'),
'name': ingredient.get('name'),
'produced_locally': ingredient.get('produced_locally', False),
'recipe_id': ingredient.get('recipe_id')
}
except Exception as e:
logger.error("Error fetching ingredient info",
ingredient_id=str(ingredient_id),
error=str(e))
return None
async def validate_recipe_explosion(
self,
tenant_id: uuid.UUID,
recipe_id: uuid.UUID
) -> Dict:
"""
Validate if a recipe can be safely exploded (check for circular dependencies).
Args:
tenant_id: Tenant ID
recipe_id: Recipe to validate
Returns:
Dict with validation results
"""
try:
await self._explode_recipe_recursive(
tenant_id=tenant_id,
recipe_id=recipe_id,
required_quantity=Decimal("1"), # Test with 1 unit
current_depth=0,
visited_recipes=set(),
parent_requirement={}
)
return {
'valid': True,
'message': 'Recipe can be safely exploded'
}
except CircularDependencyError as e:
return {
'valid': False,
'error': 'circular_dependency',
'message': str(e)
}
except RecursionError as e:
return {
'valid': False,
'error': 'max_depth_exceeded',
'message': str(e)
}
except Exception as e:
return {
'valid': False,
'error': 'unknown',
'message': str(e)
}

View File

@@ -0,0 +1,500 @@
"""
Replenishment Planning Service
Main orchestrator for advanced procurement planning that integrates:
- Lead time planning
- Inventory projection
- Safety stock calculation
- Shelf life management
"""
from datetime import date, timedelta
from decimal import Decimal
from typing import List, Dict, Optional, Tuple
from dataclasses import dataclass, asdict
import logging
import uuid
from .lead_time_planner import LeadTimePlanner, LeadTimeRequirement, LeadTimePlan
from .inventory_projector import (
InventoryProjector,
DailyDemand,
ScheduledReceipt,
IngredientProjection
)
from .safety_stock_calculator import SafetyStockCalculator, SafetyStockResult
from .shelf_life_manager import ShelfLifeManager, ShelfLifeAdjustment
logger = logging.getLogger(__name__)
@dataclass
class IngredientRequirement:
"""Complete requirement for one ingredient"""
ingredient_id: str
ingredient_name: str
required_quantity: Decimal
required_by_date: date
supplier_id: Optional[str] = None
lead_time_days: int = 3
shelf_life_days: Optional[int] = None
is_perishable: bool = False
category: str = 'dry'
unit_of_measure: str = 'kg'
current_stock: Decimal = Decimal('0')
daily_consumption_rate: float = 0.0
demand_std_dev: float = 0.0
@dataclass
class ReplenishmentPlanItem:
"""Single item in replenishment plan"""
id: str
ingredient_id: str
ingredient_name: str
# Quantities
base_quantity: Decimal
safety_stock_quantity: Decimal
shelf_life_adjusted_quantity: Decimal
final_order_quantity: Decimal
# Dates
order_date: date
delivery_date: date
required_by_date: date
# Metadata
lead_time_days: int
is_urgent: bool
urgency_reason: Optional[str]
waste_risk: str
stockout_risk: str
supplier_id: Optional[str]
# Calculation details
safety_stock_calculation: Dict
shelf_life_adjustment: Dict
inventory_projection: Optional[Dict]
@dataclass
class ReplenishmentPlan:
"""Complete replenishment plan"""
plan_id: str
tenant_id: str
planning_date: date
projection_horizon_days: int
items: List[ReplenishmentPlanItem]
# Summary statistics
total_items: int
urgent_items: int
high_risk_items: int
total_estimated_cost: Decimal
# Metadata
created_at: date
class ReplenishmentPlanningService:
"""
Orchestrates advanced replenishment planning.
Workflow:
1. Project inventory levels (InventoryProjector)
2. Identify coverage gaps and required quantities
3. Calculate safety stock (SafetyStockCalculator)
4. Adjust for shelf life (ShelfLifeManager)
5. Calculate order dates (LeadTimePlanner)
6. Generate complete replenishment plan
"""
def __init__(
self,
projection_horizon_days: int = 7,
default_service_level: float = 0.95,
default_buffer_days: int = 1
):
"""
Initialize replenishment planning service.
Args:
projection_horizon_days: Days to project ahead
default_service_level: Default target service level
default_buffer_days: Default buffer days for orders
"""
self.projection_horizon_days = projection_horizon_days
# Initialize sub-services
self.inventory_projector = InventoryProjector(projection_horizon_days)
self.safety_stock_calculator = SafetyStockCalculator(default_service_level)
self.shelf_life_manager = ShelfLifeManager()
self.lead_time_planner = LeadTimePlanner(default_buffer_days)
async def generate_replenishment_plan(
self,
tenant_id: str,
requirements: List[IngredientRequirement],
forecast_id: Optional[str] = None,
production_schedule_id: Optional[str] = None
) -> ReplenishmentPlan:
"""
Generate complete replenishment plan.
Args:
tenant_id: Tenant ID
requirements: List of ingredient requirements
forecast_id: Optional reference to forecast
production_schedule_id: Optional reference to production schedule
Returns:
Complete replenishment plan
"""
plan_id = str(uuid.uuid4())
planning_date = date.today()
logger.info(
f"Generating replenishment plan {plan_id} for {len(requirements)} ingredients"
)
plan_items = []
for req in requirements:
try:
item = await self._plan_ingredient_replenishment(req)
plan_items.append(item)
except Exception as e:
logger.error(
f"Failed to plan replenishment for {req.ingredient_name}: {e}"
)
# Continue with other ingredients
# Calculate summary statistics
total_items = len(plan_items)
urgent_items = sum(1 for item in plan_items if item.is_urgent)
high_risk_items = sum(
1 for item in plan_items
if item.stockout_risk in ['high', 'critical']
)
# Estimate total cost (placeholder - need price data)
total_estimated_cost = sum(
item.final_order_quantity
for item in plan_items
)
plan = ReplenishmentPlan(
plan_id=plan_id,
tenant_id=tenant_id,
planning_date=planning_date,
projection_horizon_days=self.projection_horizon_days,
items=plan_items,
total_items=total_items,
urgent_items=urgent_items,
high_risk_items=high_risk_items,
total_estimated_cost=total_estimated_cost,
created_at=planning_date
)
logger.info(
f"Replenishment plan generated: {total_items} items, "
f"{urgent_items} urgent, {high_risk_items} high risk"
)
return plan
async def _plan_ingredient_replenishment(
self,
req: IngredientRequirement
) -> ReplenishmentPlanItem:
"""
Plan replenishment for a single ingredient.
Args:
req: Ingredient requirement
Returns:
Replenishment plan item
"""
# Step 1: Project inventory to identify needs
projection = await self._project_ingredient_inventory(req)
# Step 2: Calculate base quantity needed
base_quantity = self._calculate_base_quantity(req, projection)
# Step 3: Calculate safety stock
safety_stock_result = self._calculate_safety_stock(req)
safety_stock_quantity = safety_stock_result.safety_stock_quantity
# Step 4: Adjust for shelf life
total_quantity = base_quantity + safety_stock_quantity
shelf_life_adjustment = self._adjust_for_shelf_life(
req,
total_quantity
)
# Step 5: Calculate order dates
lead_time_plan = self._calculate_order_dates(
req,
shelf_life_adjustment.adjusted_quantity
)
# Create plan item
item = ReplenishmentPlanItem(
id=str(uuid.uuid4()),
ingredient_id=req.ingredient_id,
ingredient_name=req.ingredient_name,
base_quantity=base_quantity,
safety_stock_quantity=safety_stock_quantity,
shelf_life_adjusted_quantity=shelf_life_adjustment.adjusted_quantity,
final_order_quantity=shelf_life_adjustment.adjusted_quantity,
order_date=lead_time_plan.order_date,
delivery_date=lead_time_plan.delivery_date,
required_by_date=req.required_by_date,
lead_time_days=req.lead_time_days,
is_urgent=lead_time_plan.is_urgent,
urgency_reason=lead_time_plan.urgency_reason,
waste_risk=shelf_life_adjustment.waste_risk,
stockout_risk=projection.stockout_risk if projection else 'unknown',
supplier_id=req.supplier_id,
safety_stock_calculation=self.safety_stock_calculator.export_to_dict(safety_stock_result),
shelf_life_adjustment=self.shelf_life_manager.export_to_dict(shelf_life_adjustment),
inventory_projection=self.inventory_projector.export_projection_to_dict(projection) if projection else None
)
return item
async def _project_ingredient_inventory(
self,
req: IngredientRequirement
) -> Optional[IngredientProjection]:
"""
Project inventory for ingredient.
Args:
req: Ingredient requirement
Returns:
Inventory projection
"""
try:
# Build daily demand forecast
daily_demand = []
if req.daily_consumption_rate > 0:
for i in range(self.projection_horizon_days):
demand_date = date.today() + timedelta(days=i)
daily_demand.append(
DailyDemand(
ingredient_id=req.ingredient_id,
date=demand_date,
quantity=Decimal(str(req.daily_consumption_rate))
)
)
# No scheduled receipts for now (could add future POs here)
scheduled_receipts = []
projection = self.inventory_projector.project_inventory(
ingredient_id=req.ingredient_id,
ingredient_name=req.ingredient_name,
current_stock=req.current_stock,
unit_of_measure=req.unit_of_measure,
daily_demand=daily_demand,
scheduled_receipts=scheduled_receipts
)
return projection
except Exception as e:
logger.error(f"Failed to project inventory for {req.ingredient_name}: {e}")
return None
def _calculate_base_quantity(
self,
req: IngredientRequirement,
projection: Optional[IngredientProjection]
) -> Decimal:
"""
Calculate base quantity needed.
Args:
req: Ingredient requirement
projection: Inventory projection
Returns:
Base quantity
"""
if projection:
# Use projection to calculate need
required = self.inventory_projector.calculate_required_order_quantity(
projection,
target_coverage_days=self.projection_horizon_days
)
return max(required, req.required_quantity)
else:
# Fallback to required quantity
return req.required_quantity
def _calculate_safety_stock(
self,
req: IngredientRequirement
) -> SafetyStockResult:
"""
Calculate safety stock.
Args:
req: Ingredient requirement
Returns:
Safety stock result
"""
if req.demand_std_dev > 0:
# Use statistical method
return self.safety_stock_calculator.calculate_safety_stock(
demand_std_dev=req.demand_std_dev,
lead_time_days=req.lead_time_days
)
elif req.daily_consumption_rate > 0:
# Use percentage method
return self.safety_stock_calculator.calculate_using_fixed_percentage(
average_demand=req.daily_consumption_rate,
lead_time_days=req.lead_time_days,
percentage=0.20
)
else:
# No safety stock
return SafetyStockResult(
safety_stock_quantity=Decimal('0'),
service_level=0.0,
z_score=0.0,
demand_std_dev=0.0,
lead_time_days=req.lead_time_days,
calculation_method='none',
confidence='low',
reasoning='Insufficient data for safety stock calculation'
)
def _adjust_for_shelf_life(
self,
req: IngredientRequirement,
quantity: Decimal
) -> ShelfLifeAdjustment:
"""
Adjust quantity for shelf life constraints.
Args:
req: Ingredient requirement
quantity: Proposed quantity
Returns:
Shelf life adjustment
"""
if not req.is_perishable or not req.shelf_life_days:
# No shelf life constraint
return ShelfLifeAdjustment(
original_quantity=quantity,
adjusted_quantity=quantity,
adjustment_reason='Non-perishable or no shelf life data',
waste_risk='low',
recommended_order_date=date.today(),
use_by_date=date.today() + timedelta(days=365),
is_constrained=False
)
return self.shelf_life_manager.adjust_order_quantity_for_shelf_life(
ingredient_id=req.ingredient_id,
ingredient_name=req.ingredient_name,
requested_quantity=quantity,
daily_consumption_rate=req.daily_consumption_rate,
shelf_life_days=req.shelf_life_days,
category=req.category,
is_perishable=req.is_perishable,
delivery_date=req.required_by_date - timedelta(days=req.lead_time_days)
)
def _calculate_order_dates(
self,
req: IngredientRequirement,
quantity: Decimal
) -> LeadTimePlan:
"""
Calculate order and delivery dates.
Args:
req: Ingredient requirement
quantity: Order quantity
Returns:
Lead time plan
"""
lead_time_req = LeadTimeRequirement(
ingredient_id=req.ingredient_id,
ingredient_name=req.ingredient_name,
required_quantity=quantity,
required_by_date=req.required_by_date,
supplier_id=req.supplier_id,
lead_time_days=req.lead_time_days
)
plans = self.lead_time_planner.plan_requirements([lead_time_req])
return plans[0] if plans else LeadTimePlan(
ingredient_id=req.ingredient_id,
ingredient_name=req.ingredient_name,
order_quantity=quantity,
order_date=date.today(),
delivery_date=date.today() + timedelta(days=req.lead_time_days),
required_by_date=req.required_by_date,
lead_time_days=req.lead_time_days,
buffer_days=1,
is_urgent=False,
supplier_id=req.supplier_id
)
def export_plan_to_dict(self, plan: ReplenishmentPlan) -> Dict:
"""
Export plan to dictionary for API response.
Args:
plan: Replenishment plan
Returns:
Dictionary representation
"""
return {
'plan_id': plan.plan_id,
'tenant_id': plan.tenant_id,
'planning_date': plan.planning_date.isoformat(),
'projection_horizon_days': plan.projection_horizon_days,
'total_items': plan.total_items,
'urgent_items': plan.urgent_items,
'high_risk_items': plan.high_risk_items,
'total_estimated_cost': float(plan.total_estimated_cost),
'created_at': plan.created_at.isoformat(),
'items': [
{
'id': item.id,
'ingredient_id': item.ingredient_id,
'ingredient_name': item.ingredient_name,
'base_quantity': float(item.base_quantity),
'safety_stock_quantity': float(item.safety_stock_quantity),
'shelf_life_adjusted_quantity': float(item.shelf_life_adjusted_quantity),
'final_order_quantity': float(item.final_order_quantity),
'order_date': item.order_date.isoformat(),
'delivery_date': item.delivery_date.isoformat(),
'required_by_date': item.required_by_date.isoformat(),
'lead_time_days': item.lead_time_days,
'is_urgent': item.is_urgent,
'urgency_reason': item.urgency_reason,
'waste_risk': item.waste_risk,
'stockout_risk': item.stockout_risk,
'supplier_id': item.supplier_id,
'safety_stock_calculation': item.safety_stock_calculation,
'shelf_life_adjustment': item.shelf_life_adjustment,
'inventory_projection': item.inventory_projection
}
for item in plan.items
]
}

View File

@@ -0,0 +1,439 @@
"""
Safety Stock Calculator
Calculates dynamic safety stock based on demand variability,
lead time, and service level targets.
"""
import math
import statistics
from decimal import Decimal
from typing import List, Dict, Optional, Tuple
from dataclasses import dataclass
import logging
logger = logging.getLogger(__name__)
@dataclass
class SafetyStockResult:
"""Result of safety stock calculation"""
safety_stock_quantity: Decimal
service_level: float
z_score: float
demand_std_dev: float
lead_time_days: int
calculation_method: str
confidence: str # 'high', 'medium', 'low'
reasoning: str
@dataclass
class DemandHistory:
"""Historical demand data for an ingredient"""
ingredient_id: str
daily_demands: List[float] # Historical daily demands
mean_demand: float
std_dev: float
coefficient_of_variation: float
class SafetyStockCalculator:
"""
Calculates safety stock using statistical methods.
Formula: Safety Stock = Z × σ × √L
where:
- Z = service level z-score (e.g., 1.96 for 97.5%)
- σ = demand standard deviation
- L = lead time in days
This accounts for demand variability during lead time.
"""
# Z-scores for common service levels
SERVICE_LEVEL_Z_SCORES = {
0.50: 0.00, # 50% - no buffer (not recommended)
0.80: 0.84, # 80% service level
0.85: 1.04, # 85% service level
0.90: 1.28, # 90% service level
0.95: 1.65, # 95% service level
0.975: 1.96, # 97.5% service level
0.99: 2.33, # 99% service level
0.995: 2.58, # 99.5% service level
0.999: 3.09 # 99.9% service level
}
def __init__(self, default_service_level: float = 0.95):
"""
Initialize safety stock calculator.
Args:
default_service_level: Default target service level (0-1)
"""
self.default_service_level = default_service_level
def calculate_safety_stock(
self,
demand_std_dev: float,
lead_time_days: int,
service_level: Optional[float] = None
) -> SafetyStockResult:
"""
Calculate safety stock using standard formula.
Safety Stock = Z × σ × √L
Args:
demand_std_dev: Standard deviation of daily demand
lead_time_days: Supplier lead time in days
service_level: Target service level (uses default if None)
Returns:
SafetyStockResult with calculation details
"""
if service_level is None:
service_level = self.default_service_level
# Get z-score for service level
z_score = self._get_z_score(service_level)
# Calculate safety stock
if lead_time_days <= 0 or demand_std_dev <= 0:
return SafetyStockResult(
safety_stock_quantity=Decimal('0'),
service_level=service_level,
z_score=z_score,
demand_std_dev=demand_std_dev,
lead_time_days=lead_time_days,
calculation_method='zero_due_to_invalid_inputs',
confidence='low',
reasoning='Lead time or demand std dev is zero or negative'
)
# Safety Stock = Z × σ × √L
safety_stock = z_score * demand_std_dev * math.sqrt(lead_time_days)
# Determine confidence
confidence = self._determine_confidence(demand_std_dev, lead_time_days)
reasoning = (
f"Service level {service_level*100:.1f}% (Z={z_score:.2f}) × "
f"Demand σ={demand_std_dev:.2f} ×{lead_time_days} days"
)
return SafetyStockResult(
safety_stock_quantity=Decimal(str(round(safety_stock, 2))),
service_level=service_level,
z_score=z_score,
demand_std_dev=demand_std_dev,
lead_time_days=lead_time_days,
calculation_method='statistical_z_score',
confidence=confidence,
reasoning=reasoning
)
def calculate_from_demand_history(
self,
daily_demands: List[float],
lead_time_days: int,
service_level: Optional[float] = None
) -> SafetyStockResult:
"""
Calculate safety stock from historical demand data.
Args:
daily_demands: List of historical daily demands
lead_time_days: Supplier lead time in days
service_level: Target service level
Returns:
SafetyStockResult with calculation details
"""
if not daily_demands or len(daily_demands) < 2:
logger.warning("Insufficient demand history for safety stock calculation")
return SafetyStockResult(
safety_stock_quantity=Decimal('0'),
service_level=service_level or self.default_service_level,
z_score=0.0,
demand_std_dev=0.0,
lead_time_days=lead_time_days,
calculation_method='insufficient_data',
confidence='low',
reasoning='Insufficient historical demand data (need at least 2 data points)'
)
# Calculate standard deviation
demand_std_dev = statistics.stdev(daily_demands)
return self.calculate_safety_stock(
demand_std_dev=demand_std_dev,
lead_time_days=lead_time_days,
service_level=service_level
)
def calculate_with_lead_time_variability(
self,
demand_mean: float,
demand_std_dev: float,
lead_time_mean: int,
lead_time_std_dev: int,
service_level: Optional[float] = None
) -> SafetyStockResult:
"""
Calculate safety stock considering both demand AND lead time variability.
More accurate formula:
SS = Z × √(L_mean × σ_demand² + μ_demand² × σ_lead_time²)
Args:
demand_mean: Mean daily demand
demand_std_dev: Standard deviation of daily demand
lead_time_mean: Mean lead time in days
lead_time_std_dev: Standard deviation of lead time
service_level: Target service level
Returns:
SafetyStockResult with calculation details
"""
if service_level is None:
service_level = self.default_service_level
z_score = self._get_z_score(service_level)
# Calculate combined variance
variance = (
lead_time_mean * (demand_std_dev ** 2) +
(demand_mean ** 2) * (lead_time_std_dev ** 2)
)
safety_stock = z_score * math.sqrt(variance)
confidence = 'high' if lead_time_std_dev > 0 else 'medium'
reasoning = (
f"Advanced formula considering both demand variability "
f"(σ={demand_std_dev:.2f}) and lead time variability (σ={lead_time_std_dev:.1f} days)"
)
return SafetyStockResult(
safety_stock_quantity=Decimal(str(round(safety_stock, 2))),
service_level=service_level,
z_score=z_score,
demand_std_dev=demand_std_dev,
lead_time_days=lead_time_mean,
calculation_method='statistical_with_lead_time_variability',
confidence=confidence,
reasoning=reasoning
)
def calculate_using_fixed_percentage(
self,
average_demand: float,
lead_time_days: int,
percentage: float = 0.20
) -> SafetyStockResult:
"""
Calculate safety stock as percentage of lead time demand.
Simple method: Safety Stock = % × (Average Daily Demand × Lead Time)
Args:
average_demand: Average daily demand
lead_time_days: Supplier lead time in days
percentage: Safety stock percentage (default 20%)
Returns:
SafetyStockResult with calculation details
"""
lead_time_demand = average_demand * lead_time_days
safety_stock = lead_time_demand * percentage
reasoning = f"{percentage*100:.0f}% of lead time demand ({lead_time_demand:.2f})"
return SafetyStockResult(
safety_stock_quantity=Decimal(str(round(safety_stock, 2))),
service_level=0.0, # Not based on service level
z_score=0.0,
demand_std_dev=0.0,
lead_time_days=lead_time_days,
calculation_method='fixed_percentage',
confidence='low',
reasoning=reasoning
)
def calculate_batch_safety_stock(
self,
ingredients_data: List[Dict]
) -> Dict[str, SafetyStockResult]:
"""
Calculate safety stock for multiple ingredients.
Args:
ingredients_data: List of dicts with ingredient data
Returns:
Dictionary mapping ingredient_id to SafetyStockResult
"""
results = {}
for data in ingredients_data:
ingredient_id = data['ingredient_id']
if 'daily_demands' in data:
# Use historical data
result = self.calculate_from_demand_history(
daily_demands=data['daily_demands'],
lead_time_days=data['lead_time_days'],
service_level=data.get('service_level')
)
elif 'demand_std_dev' in data:
# Use provided std dev
result = self.calculate_safety_stock(
demand_std_dev=data['demand_std_dev'],
lead_time_days=data['lead_time_days'],
service_level=data.get('service_level')
)
else:
# Fallback to percentage method
result = self.calculate_using_fixed_percentage(
average_demand=data.get('average_demand', 0),
lead_time_days=data['lead_time_days'],
percentage=data.get('safety_percentage', 0.20)
)
results[ingredient_id] = result
logger.info(f"Calculated safety stock for {len(results)} ingredients")
return results
def analyze_demand_history(
self,
daily_demands: List[float]
) -> DemandHistory:
"""
Analyze demand history to extract statistics.
Args:
daily_demands: List of historical daily demands
Returns:
DemandHistory with statistics
"""
if not daily_demands:
return DemandHistory(
ingredient_id="unknown",
daily_demands=[],
mean_demand=0.0,
std_dev=0.0,
coefficient_of_variation=0.0
)
mean_demand = statistics.mean(daily_demands)
std_dev = statistics.stdev(daily_demands) if len(daily_demands) >= 2 else 0.0
cv = (std_dev / mean_demand) if mean_demand > 0 else 0.0
return DemandHistory(
ingredient_id="unknown",
daily_demands=daily_demands,
mean_demand=mean_demand,
std_dev=std_dev,
coefficient_of_variation=cv
)
def _get_z_score(self, service_level: float) -> float:
"""
Get z-score for service level.
Args:
service_level: Target service level (0-1)
Returns:
Z-score
"""
# Find closest service level
if service_level in self.SERVICE_LEVEL_Z_SCORES:
return self.SERVICE_LEVEL_Z_SCORES[service_level]
# Interpolate or use closest
levels = sorted(self.SERVICE_LEVEL_Z_SCORES.keys())
for i, level in enumerate(levels):
if service_level <= level:
return self.SERVICE_LEVEL_Z_SCORES[level]
# Use highest if beyond range
return self.SERVICE_LEVEL_Z_SCORES[levels[-1]]
def _determine_confidence(
self,
demand_std_dev: float,
lead_time_days: int
) -> str:
"""
Determine confidence level of calculation.
Args:
demand_std_dev: Demand standard deviation
lead_time_days: Lead time in days
Returns:
Confidence level
"""
if demand_std_dev == 0:
return 'low' # No variability in data
if lead_time_days < 3:
return 'high' # Short lead time, easier to manage
elif lead_time_days < 7:
return 'medium'
else:
return 'medium' # Long lead time, more uncertainty
def recommend_service_level(
self,
ingredient_category: str,
is_critical: bool = False
) -> float:
"""
Recommend service level based on ingredient characteristics.
Args:
ingredient_category: Category of ingredient
is_critical: Whether ingredient is business-critical
Returns:
Recommended service level
"""
# Critical ingredients: very high service level
if is_critical:
return 0.99
# Perishables: moderate service level (to avoid waste)
if ingredient_category.lower() in ['dairy', 'meat', 'produce', 'fresh']:
return 0.90
# Standard ingredients: high service level
return 0.95
def export_to_dict(self, result: SafetyStockResult) -> Dict:
"""
Export result to dictionary for API response.
Args:
result: SafetyStockResult
Returns:
Dictionary representation
"""
return {
'safety_stock_quantity': float(result.safety_stock_quantity),
'service_level': result.service_level,
'z_score': result.z_score,
'demand_std_dev': result.demand_std_dev,
'lead_time_days': result.lead_time_days,
'calculation_method': result.calculation_method,
'confidence': result.confidence,
'reasoning': result.reasoning
}

View File

@@ -0,0 +1,444 @@
"""
Shelf Life Manager
Manages shelf life constraints for perishable ingredients to minimize waste
and ensure food safety.
"""
from datetime import date, timedelta
from decimal import Decimal
from typing import List, Dict, Optional, Tuple
from dataclasses import dataclass
import logging
import statistics
logger = logging.getLogger(__name__)
@dataclass
class ShelfLifeConstraint:
"""Shelf life constraints for an ingredient"""
ingredient_id: str
ingredient_name: str
shelf_life_days: int
is_perishable: bool
category: str # 'fresh', 'frozen', 'dry', 'canned'
max_order_quantity_days: Optional[int] = None # Max days worth to order at once
@dataclass
class ShelfLifeAdjustment:
"""Result of shelf life adjustment"""
original_quantity: Decimal
adjusted_quantity: Decimal
adjustment_reason: str
waste_risk: str # 'low', 'medium', 'high'
recommended_order_date: date
use_by_date: date
is_constrained: bool
class ShelfLifeManager:
"""
Manages procurement planning considering shelf life constraints.
For perishable items:
1. Don't order too far in advance (will expire)
2. Don't order too much at once (will waste)
3. Calculate optimal order timing
4. Warn about expiration risks
"""
# Category-specific defaults
CATEGORY_DEFAULTS = {
'fresh': {
'max_days_ahead': 2,
'max_order_days_supply': 3,
'waste_risk_threshold': 0.80
},
'dairy': {
'max_days_ahead': 3,
'max_order_days_supply': 5,
'waste_risk_threshold': 0.85
},
'frozen': {
'max_days_ahead': 14,
'max_order_days_supply': 30,
'waste_risk_threshold': 0.90
},
'dry': {
'max_days_ahead': 90,
'max_order_days_supply': 90,
'waste_risk_threshold': 0.95
},
'canned': {
'max_days_ahead': 180,
'max_order_days_supply': 180,
'waste_risk_threshold': 0.95
}
}
def __init__(self, waste_risk_threshold: float = 0.85):
"""
Initialize shelf life manager.
Args:
waste_risk_threshold: % of shelf life before considering waste risk
"""
self.waste_risk_threshold = waste_risk_threshold
def adjust_order_quantity_for_shelf_life(
self,
ingredient_id: str,
ingredient_name: str,
requested_quantity: Decimal,
daily_consumption_rate: float,
shelf_life_days: int,
category: str = 'dry',
is_perishable: bool = True,
delivery_date: Optional[date] = None
) -> ShelfLifeAdjustment:
"""
Adjust order quantity to prevent waste due to expiration.
Args:
ingredient_id: Ingredient ID
ingredient_name: Ingredient name
requested_quantity: Requested order quantity
daily_consumption_rate: Average daily usage
shelf_life_days: Days until expiration
category: Ingredient category
is_perishable: Whether item is perishable
delivery_date: Expected delivery date
Returns:
ShelfLifeAdjustment with adjusted quantity
"""
if not is_perishable:
# Non-perishable, no adjustment needed
return ShelfLifeAdjustment(
original_quantity=requested_quantity,
adjusted_quantity=requested_quantity,
adjustment_reason='Non-perishable item, no shelf life constraint',
waste_risk='low',
recommended_order_date=delivery_date or date.today(),
use_by_date=delivery_date + timedelta(days=365) if delivery_date else date.today() + timedelta(days=365),
is_constrained=False
)
if delivery_date is None:
delivery_date = date.today()
# Get category defaults
defaults = self.CATEGORY_DEFAULTS.get(
category.lower(),
self.CATEGORY_DEFAULTS['dry']
)
# Calculate use by date
use_by_date = delivery_date + timedelta(days=shelf_life_days)
# Calculate how many days the requested quantity will last
if daily_consumption_rate > 0:
days_supply = float(requested_quantity) / daily_consumption_rate
else:
days_supply = 0
# Calculate maximum safe quantity (using waste risk threshold)
safe_shelf_life_days = int(shelf_life_days * self.waste_risk_threshold)
max_safe_quantity = Decimal(str(daily_consumption_rate * safe_shelf_life_days))
# Check if adjustment needed
is_constrained = requested_quantity > max_safe_quantity
adjusted_quantity = requested_quantity
if is_constrained:
adjusted_quantity = max_safe_quantity
adjustment_reason = (
f"Reduced from {requested_quantity} to {adjusted_quantity} to fit within "
f"{safe_shelf_life_days}-day safe consumption window (shelf life: {shelf_life_days} days)"
)
logger.warning(
f"{ingredient_name}: Order quantity reduced due to shelf life constraint "
f"({requested_quantity}{adjusted_quantity})"
)
else:
adjustment_reason = "Quantity within safe shelf life window"
# Calculate waste risk
waste_risk = self._calculate_waste_risk(
days_supply=days_supply,
shelf_life_days=shelf_life_days,
threshold=defaults['waste_risk_threshold']
)
return ShelfLifeAdjustment(
original_quantity=requested_quantity,
adjusted_quantity=adjusted_quantity,
adjustment_reason=adjustment_reason,
waste_risk=waste_risk,
recommended_order_date=delivery_date - timedelta(days=defaults['max_days_ahead']),
use_by_date=use_by_date,
is_constrained=is_constrained
)
def calculate_optimal_order_date(
self,
required_by_date: date,
shelf_life_days: int,
category: str = 'dry',
lead_time_days: int = 0
) -> Tuple[date, str]:
"""
Calculate optimal order date considering shelf life.
Args:
required_by_date: When item is needed
shelf_life_days: Shelf life in days
category: Ingredient category
lead_time_days: Supplier lead time
Returns:
Tuple of (optimal_order_date, reasoning)
"""
defaults = self.CATEGORY_DEFAULTS.get(
category.lower(),
self.CATEGORY_DEFAULTS['dry']
)
# Calculate delivery date accounting for lead time
delivery_date = required_by_date - timedelta(days=lead_time_days)
# For perishables, don't deliver too far in advance
max_advance_days = min(
defaults['max_days_ahead'],
int(shelf_life_days * 0.3) # Max 30% of shelf life
)
# Optimal delivery: close to required date but not too early
optimal_delivery_date = required_by_date - timedelta(days=max_advance_days)
# Optimal order date
optimal_order_date = optimal_delivery_date - timedelta(days=lead_time_days)
reasoning = (
f"Order placed {lead_time_days} days before delivery "
f"(arrives {max_advance_days} days before use to maintain freshness)"
)
return optimal_order_date, reasoning
def validate_order_timing(
self,
order_date: date,
delivery_date: date,
required_by_date: date,
shelf_life_days: int,
ingredient_name: str
) -> Tuple[bool, List[str]]:
"""
Validate order timing against shelf life constraints.
Args:
order_date: Planned order date
delivery_date: Expected delivery date
required_by_date: Date when item is needed
shelf_life_days: Shelf life in days
ingredient_name: Name of ingredient
Returns:
Tuple of (is_valid, list of warnings)
"""
warnings = []
# Check if item will arrive in time
if delivery_date > required_by_date:
warnings.append(
f"Delivery date {delivery_date} is after required date {required_by_date}"
)
# Check if item will expire before use
expiry_date = delivery_date + timedelta(days=shelf_life_days)
if expiry_date < required_by_date:
warnings.append(
f"Item will expire on {expiry_date} before required date {required_by_date}"
)
# Check if ordering too far in advance
days_in_storage = (required_by_date - delivery_date).days
if days_in_storage > shelf_life_days * 0.8:
warnings.append(
f"Item will be in storage for {days_in_storage} days "
f"(80% of {shelf_life_days}-day shelf life)"
)
is_valid = len(warnings) == 0
if not is_valid:
for warning in warnings:
logger.warning(f"{ingredient_name}: {warning}")
return is_valid, warnings
def calculate_fifo_rotation_schedule(
self,
current_inventory: List[Dict],
new_order_quantity: Decimal,
delivery_date: date,
daily_consumption: float
) -> List[Dict]:
"""
Calculate FIFO (First In First Out) rotation schedule.
Args:
current_inventory: List of existing batches with expiry dates
new_order_quantity: New order quantity
delivery_date: New order delivery date
daily_consumption: Daily consumption rate
Returns:
List of usage schedule
"""
# Combine current and new inventory
all_batches = []
for batch in current_inventory:
all_batches.append({
'quantity': batch['quantity'],
'expiry_date': batch['expiry_date'],
'is_existing': True
})
# Add new order (estimate shelf life from existing batches)
if current_inventory:
avg_shelf_life_days = statistics.mean([
(batch['expiry_date'] - date.today()).days
for batch in current_inventory
])
else:
avg_shelf_life_days = 30
all_batches.append({
'quantity': new_order_quantity,
'expiry_date': delivery_date + timedelta(days=int(avg_shelf_life_days)),
'is_existing': False
})
# Sort by expiry date (FIFO)
all_batches.sort(key=lambda x: x['expiry_date'])
# Create consumption schedule
schedule = []
current_date = date.today()
remaining_consumption = daily_consumption
for batch in all_batches:
days_until_expiry = (batch['expiry_date'] - current_date).days
batch_quantity = float(batch['quantity'])
# Calculate days to consume this batch
days_to_consume = min(
batch_quantity / daily_consumption,
days_until_expiry
)
quantity_consumed = days_to_consume * daily_consumption
waste = max(0, batch_quantity - quantity_consumed)
schedule.append({
'start_date': current_date,
'end_date': current_date + timedelta(days=int(days_to_consume)),
'quantity': batch['quantity'],
'quantity_consumed': Decimal(str(quantity_consumed)),
'quantity_wasted': Decimal(str(waste)),
'expiry_date': batch['expiry_date'],
'is_existing': batch['is_existing']
})
current_date += timedelta(days=int(days_to_consume))
return schedule
def _calculate_waste_risk(
self,
days_supply: float,
shelf_life_days: int,
threshold: float
) -> str:
"""
Calculate waste risk level.
Args:
days_supply: Days of supply ordered
shelf_life_days: Shelf life in days
threshold: Waste risk threshold
Returns:
Risk level: 'low', 'medium', 'high'
"""
if days_supply <= shelf_life_days * threshold * 0.5:
return 'low'
elif days_supply <= shelf_life_days * threshold:
return 'medium'
else:
return 'high'
def get_expiration_alerts(
self,
inventory_batches: List[Dict],
alert_days_threshold: int = 3
) -> List[Dict]:
"""
Get alerts for batches expiring soon.
Args:
inventory_batches: List of batches with expiry dates
alert_days_threshold: Days before expiry to alert
Returns:
List of expiration alerts
"""
alerts = []
today = date.today()
for batch in inventory_batches:
expiry_date = batch.get('expiry_date')
if not expiry_date:
continue
days_until_expiry = (expiry_date - today).days
if days_until_expiry <= alert_days_threshold:
alerts.append({
'ingredient_id': batch.get('ingredient_id'),
'ingredient_name': batch.get('ingredient_name'),
'quantity': batch.get('quantity'),
'expiry_date': expiry_date,
'days_until_expiry': days_until_expiry,
'severity': 'critical' if days_until_expiry <= 1 else 'high'
})
if alerts:
logger.warning(f"Found {len(alerts)} batches expiring within {alert_days_threshold} days")
return alerts
def export_to_dict(self, adjustment: ShelfLifeAdjustment) -> Dict:
"""
Export adjustment to dictionary for API response.
Args:
adjustment: ShelfLifeAdjustment
Returns:
Dictionary representation
"""
return {
'original_quantity': float(adjustment.original_quantity),
'adjusted_quantity': float(adjustment.adjusted_quantity),
'adjustment_reason': adjustment.adjustment_reason,
'waste_risk': adjustment.waste_risk,
'recommended_order_date': adjustment.recommended_order_date.isoformat(),
'use_by_date': adjustment.use_by_date.isoformat(),
'is_constrained': adjustment.is_constrained
}

View File

@@ -0,0 +1,343 @@
# ================================================================
# services/procurement/app/services/smart_procurement_calculator.py
# ================================================================
"""
Smart Procurement Calculator
Migrated from Orders Service
Implements multi-constraint procurement quantity optimization combining:
- AI demand forecasting
- Ingredient reorder rules (reorder_point, reorder_quantity)
- Supplier constraints (minimum_order_quantity, minimum_order_amount)
- Storage limits (max_stock_level)
- Price tier optimization
"""
import math
from decimal import Decimal
from typing import Dict, Any, List, Tuple, Optional
import structlog
logger = structlog.get_logger()
class SmartProcurementCalculator:
"""
Smart procurement quantity calculator with multi-tier constraint optimization
"""
def __init__(self, procurement_settings: Dict[str, Any]):
"""
Initialize calculator with tenant procurement settings
Args:
procurement_settings: Tenant settings dict with flags:
- use_reorder_rules: bool
- economic_rounding: bool
- respect_storage_limits: bool
- use_supplier_minimums: bool
- optimize_price_tiers: bool
"""
self.use_reorder_rules = procurement_settings.get('use_reorder_rules', True)
self.economic_rounding = procurement_settings.get('economic_rounding', True)
self.respect_storage_limits = procurement_settings.get('respect_storage_limits', True)
self.use_supplier_minimums = procurement_settings.get('use_supplier_minimums', True)
self.optimize_price_tiers = procurement_settings.get('optimize_price_tiers', True)
def calculate_procurement_quantity(
self,
ingredient: Dict[str, Any],
supplier: Optional[Dict[str, Any]],
price_list_entry: Optional[Dict[str, Any]],
ai_forecast_quantity: Decimal,
current_stock: Decimal,
safety_stock_percentage: Decimal = Decimal('20.0')
) -> Dict[str, Any]:
"""
Calculate optimal procurement quantity using smart hybrid approach
Args:
ingredient: Ingredient data with reorder_point, reorder_quantity, max_stock_level
supplier: Supplier data with minimum_order_amount
price_list_entry: Price list with minimum_order_quantity, tier_pricing
ai_forecast_quantity: AI-predicted demand quantity
current_stock: Current stock level
safety_stock_percentage: Safety stock buffer percentage
Returns:
Dict with:
- order_quantity: Final calculated quantity to order
- calculation_method: Method used (e.g., 'REORDER_POINT_TRIGGERED')
- ai_suggested_quantity: Original AI forecast
- adjusted_quantity: Final quantity after constraints
- adjustment_reason: Human-readable explanation
- warnings: List of warnings/notes
- supplier_minimum_applied: bool
- storage_limit_applied: bool
- reorder_rule_applied: bool
- price_tier_applied: Dict or None
"""
warnings = []
result = {
'ai_suggested_quantity': ai_forecast_quantity,
'supplier_minimum_applied': False,
'storage_limit_applied': False,
'reorder_rule_applied': False,
'price_tier_applied': None
}
# Extract ingredient parameters
reorder_point = Decimal(str(ingredient.get('reorder_point', 0)))
reorder_quantity = Decimal(str(ingredient.get('reorder_quantity', 0)))
low_stock_threshold = Decimal(str(ingredient.get('low_stock_threshold', 0)))
max_stock_level = Decimal(str(ingredient.get('max_stock_level') or 'Infinity'))
# Extract supplier/price list parameters
supplier_min_qty = Decimal('0')
supplier_min_amount = Decimal('0')
tier_pricing = []
if price_list_entry:
supplier_min_qty = Decimal(str(price_list_entry.get('minimum_order_quantity', 0)))
tier_pricing = price_list_entry.get('tier_pricing') or []
if supplier:
supplier_min_amount = Decimal(str(supplier.get('minimum_order_amount', 0)))
# Calculate AI-based net requirement with safety stock
safety_stock = ai_forecast_quantity * (safety_stock_percentage / Decimal('100'))
total_needed = ai_forecast_quantity + safety_stock
ai_net_requirement = max(Decimal('0'), total_needed - current_stock)
# TIER 1: Critical Safety Check (Emergency Override)
if self.use_reorder_rules and current_stock <= low_stock_threshold:
base_order = max(reorder_quantity, ai_net_requirement)
result['calculation_method'] = 'CRITICAL_STOCK_EMERGENCY'
result['reorder_rule_applied'] = True
warnings.append(f"CRITICAL: Stock ({current_stock}) below threshold ({low_stock_threshold})")
order_qty = base_order
# TIER 2: Reorder Point Triggered
elif self.use_reorder_rules and current_stock <= reorder_point:
base_order = max(reorder_quantity, ai_net_requirement)
result['calculation_method'] = 'REORDER_POINT_TRIGGERED'
result['reorder_rule_applied'] = True
warnings.append(f"Reorder point triggered: stock ({current_stock}) ≤ reorder point ({reorder_point})")
order_qty = base_order
# TIER 3: Forecast-Driven (Above reorder point, no immediate need)
elif ai_net_requirement > 0:
order_qty = ai_net_requirement
result['calculation_method'] = 'FORECAST_DRIVEN_PROACTIVE'
warnings.append(f"AI forecast suggests ordering {ai_net_requirement} units")
# TIER 4: No Order Needed
else:
result['order_quantity'] = Decimal('0')
result['adjusted_quantity'] = Decimal('0')
result['calculation_method'] = 'SUFFICIENT_STOCK'
result['adjustment_reason'] = f"Current stock ({current_stock}) is sufficient. No order needed."
result['warnings'] = warnings
return result
# Apply Economic Rounding (reorder_quantity multiples)
if self.economic_rounding and reorder_quantity > 0:
multiples = math.ceil(float(order_qty / reorder_quantity))
rounded_qty = Decimal(multiples) * reorder_quantity
if rounded_qty > order_qty:
warnings.append(f"Rounded to {multiples}× reorder quantity ({reorder_quantity}) = {rounded_qty}")
order_qty = rounded_qty
# Apply Supplier Minimum Quantity Constraint
if self.use_supplier_minimums and supplier_min_qty > 0:
if order_qty < supplier_min_qty:
warnings.append(f"Increased from {order_qty} to supplier minimum ({supplier_min_qty})")
order_qty = supplier_min_qty
result['supplier_minimum_applied'] = True
else:
# Round to multiples of minimum_order_quantity (packaging constraint)
multiples = math.ceil(float(order_qty / supplier_min_qty))
rounded_qty = Decimal(multiples) * supplier_min_qty
if rounded_qty > order_qty:
warnings.append(f"Rounded to {multiples}× supplier packaging ({supplier_min_qty}) = {rounded_qty}")
result['supplier_minimum_applied'] = True
order_qty = rounded_qty
# Apply Price Tier Optimization
if self.optimize_price_tiers and tier_pricing and price_list_entry:
unit_price = Decimal(str(price_list_entry.get('unit_price', 0)))
tier_result = self._optimize_price_tier(
order_qty,
unit_price,
tier_pricing,
current_stock,
max_stock_level
)
if tier_result['tier_applied']:
order_qty = tier_result['optimized_quantity']
result['price_tier_applied'] = tier_result['tier_info']
warnings.append(tier_result['message'])
# Apply Storage Capacity Constraint
if self.respect_storage_limits and max_stock_level != Decimal('Infinity'):
if (current_stock + order_qty) > max_stock_level:
capped_qty = max(Decimal('0'), max_stock_level - current_stock)
warnings.append(f"Capped from {order_qty} to {capped_qty} due to storage limit ({max_stock_level})")
order_qty = capped_qty
result['storage_limit_applied'] = True
result['calculation_method'] += '_STORAGE_LIMITED'
# Check supplier minimum_order_amount (total order value constraint)
if self.use_supplier_minimums and supplier_min_amount > 0 and price_list_entry:
unit_price = Decimal(str(price_list_entry.get('unit_price', 0)))
order_value = order_qty * unit_price
if order_value < supplier_min_amount:
warnings.append(
f"⚠️ Order value €{order_value:.2f} < supplier minimum €{supplier_min_amount:.2f}. "
"This item needs to be combined with other products in the same PO."
)
result['calculation_method'] += '_NEEDS_CONSOLIDATION'
# Build final result
result['order_quantity'] = order_qty
result['adjusted_quantity'] = order_qty
result['adjustment_reason'] = self._build_adjustment_reason(
ai_forecast_quantity,
ai_net_requirement,
order_qty,
warnings,
result
)
result['warnings'] = warnings
return result
def _optimize_price_tier(
self,
current_qty: Decimal,
base_unit_price: Decimal,
tier_pricing: List[Dict[str, Any]],
current_stock: Decimal,
max_stock_level: Decimal
) -> Dict[str, Any]:
"""
Optimize order quantity to capture volume discount tiers if beneficial
Args:
current_qty: Current calculated order quantity
base_unit_price: Base unit price without tiers
tier_pricing: List of tier dicts with 'quantity' and 'price'
current_stock: Current stock level
max_stock_level: Maximum storage capacity
Returns:
Dict with tier_applied (bool), optimized_quantity, tier_info, message
"""
if not tier_pricing:
return {'tier_applied': False, 'optimized_quantity': current_qty}
# Sort tiers by quantity
sorted_tiers = sorted(tier_pricing, key=lambda x: x['quantity'])
best_tier = None
best_savings = Decimal('0')
for tier in sorted_tiers:
tier_qty = Decimal(str(tier['quantity']))
tier_price = Decimal(str(tier['price']))
# Skip if tier quantity is below current quantity (already captured)
if tier_qty <= current_qty:
continue
# Skip if tier would exceed storage capacity
if self.respect_storage_limits and (current_stock + tier_qty) > max_stock_level:
continue
# Skip if tier is more than 50% above current quantity (too much excess)
if tier_qty > current_qty * Decimal('1.5'):
continue
# Calculate savings
current_cost = current_qty * base_unit_price
tier_cost = tier_qty * tier_price
savings = current_cost - tier_cost
if savings > best_savings:
best_savings = savings
best_tier = {
'quantity': tier_qty,
'price': tier_price,
'savings': savings
}
if best_tier:
return {
'tier_applied': True,
'optimized_quantity': best_tier['quantity'],
'tier_info': best_tier,
'message': (
f"Upgraded to {best_tier['quantity']} units "
f"@ €{best_tier['price']}/unit "
f"(saves €{best_tier['savings']:.2f})"
)
}
return {'tier_applied': False, 'optimized_quantity': current_qty}
def _build_adjustment_reason(
self,
ai_forecast: Decimal,
ai_net_requirement: Decimal,
final_quantity: Decimal,
warnings: List[str],
result: Dict[str, Any]
) -> str:
"""
Build human-readable explanation of quantity adjustments
Args:
ai_forecast: Original AI forecast
ai_net_requirement: AI forecast + safety stock - current stock
final_quantity: Final order quantity after all adjustments
warnings: List of warning messages
result: Calculation result dict
Returns:
Human-readable adjustment explanation
"""
parts = []
# Start with calculation method
method = result.get('calculation_method', 'UNKNOWN')
parts.append(f"Method: {method.replace('_', ' ').title()}")
# AI forecast base
parts.append(f"AI Forecast: {ai_forecast} units, Net Requirement: {ai_net_requirement} units")
# Adjustments applied
adjustments = []
if result.get('reorder_rule_applied'):
adjustments.append("reorder rules")
if result.get('supplier_minimum_applied'):
adjustments.append("supplier minimums")
if result.get('storage_limit_applied'):
adjustments.append("storage limits")
if result.get('price_tier_applied'):
adjustments.append("price tier optimization")
if adjustments:
parts.append(f"Adjustments: {', '.join(adjustments)}")
# Final quantity
parts.append(f"Final Quantity: {final_quantity} units")
# Key warnings
if warnings:
key_warnings = [w for w in warnings if '⚠️' in w or 'CRITICAL' in w or 'saves €' in w]
if key_warnings:
parts.append(f"Notes: {'; '.join(key_warnings)}")
return " | ".join(parts)

View File

@@ -0,0 +1,538 @@
"""
Supplier Selector
Intelligently selects suppliers based on multi-criteria optimization including
price, lead time, quality, reliability, and risk diversification.
"""
from decimal import Decimal
from typing import List, Dict, Optional, Tuple
from dataclasses import dataclass
from datetime import date
import logging
logger = logging.getLogger(__name__)
@dataclass
class SupplierOption:
"""Supplier option for an ingredient"""
supplier_id: str
supplier_name: str
unit_price: Decimal
lead_time_days: int
min_order_quantity: Optional[Decimal] = None
max_capacity: Optional[Decimal] = None
quality_score: float = 0.85 # 0-1
reliability_score: float = 0.90 # 0-1
on_time_delivery_rate: float = 0.95 # 0-1
current_allocation_percentage: float = 0.0 # Current % of total orders
@dataclass
class SupplierAllocation:
"""Allocation of quantity to a supplier"""
supplier_id: str
supplier_name: str
allocated_quantity: Decimal
allocation_percentage: float
allocation_type: str # 'primary', 'backup', 'diversification'
unit_price: Decimal
total_cost: Decimal
lead_time_days: int
supplier_score: float
score_breakdown: Dict[str, float]
allocation_reason: str
@dataclass
class SupplierSelectionResult:
"""Complete supplier selection result"""
ingredient_id: str
ingredient_name: str
required_quantity: Decimal
allocations: List[SupplierAllocation]
total_cost: Decimal
weighted_lead_time: float
risk_score: float # Lower is better
diversification_applied: bool
selection_strategy: str
class SupplierSelector:
"""
Selects optimal suppliers using multi-criteria decision analysis.
Scoring Factors:
1. Price (lower is better)
2. Lead time (shorter is better)
3. Quality score (higher is better)
4. Reliability (higher is better)
5. Diversification (balance across suppliers)
Strategies:
- Single source: Best overall supplier
- Dual source: Primary + backup
- Multi-source: Split across 2-3 suppliers for large orders
"""
def __init__(
self,
price_weight: float = 0.40,
lead_time_weight: float = 0.20,
quality_weight: float = 0.20,
reliability_weight: float = 0.20,
diversification_threshold: Decimal = Decimal('1000'),
max_single_supplier_percentage: float = 0.70
):
"""
Initialize supplier selector.
Args:
price_weight: Weight for price (0-1)
lead_time_weight: Weight for lead time (0-1)
quality_weight: Weight for quality (0-1)
reliability_weight: Weight for reliability (0-1)
diversification_threshold: Quantity above which to diversify
max_single_supplier_percentage: Max % to single supplier
"""
self.price_weight = price_weight
self.lead_time_weight = lead_time_weight
self.quality_weight = quality_weight
self.reliability_weight = reliability_weight
self.diversification_threshold = diversification_threshold
self.max_single_supplier_percentage = max_single_supplier_percentage
# Validate weights sum to 1
total_weight = (
price_weight + lead_time_weight + quality_weight + reliability_weight
)
if abs(total_weight - 1.0) > 0.01:
logger.warning(
f"Supplier selection weights don't sum to 1.0 (sum={total_weight}), normalizing"
)
self.price_weight /= total_weight
self.lead_time_weight /= total_weight
self.quality_weight /= total_weight
self.reliability_weight /= total_weight
def select_suppliers(
self,
ingredient_id: str,
ingredient_name: str,
required_quantity: Decimal,
supplier_options: List[SupplierOption]
) -> SupplierSelectionResult:
"""
Select optimal supplier(s) for an ingredient.
Args:
ingredient_id: Ingredient ID
ingredient_name: Ingredient name
required_quantity: Quantity needed
supplier_options: List of available suppliers
Returns:
SupplierSelectionResult with allocations
"""
if not supplier_options:
raise ValueError(f"No supplier options available for {ingredient_name}")
logger.info(
f"Selecting suppliers for {ingredient_name}: "
f"{required_quantity} units from {len(supplier_options)} options"
)
# Score all suppliers
scored_suppliers = self._score_suppliers(supplier_options)
# Determine selection strategy
strategy = self._determine_strategy(required_quantity, supplier_options)
# Select suppliers based on strategy
if strategy == 'single_source':
allocations = self._select_single_source(
required_quantity,
scored_suppliers
)
elif strategy == 'dual_source':
allocations = self._select_dual_source(
required_quantity,
scored_suppliers
)
else: # multi_source
allocations = self._select_multi_source(
required_quantity,
scored_suppliers
)
# Calculate result metrics
total_cost = sum(alloc.total_cost for alloc in allocations)
weighted_lead_time = sum(
alloc.lead_time_days * alloc.allocation_percentage
for alloc in allocations
)
risk_score = self._calculate_risk_score(allocations)
diversification_applied = len(allocations) > 1
result = SupplierSelectionResult(
ingredient_id=ingredient_id,
ingredient_name=ingredient_name,
required_quantity=required_quantity,
allocations=allocations,
total_cost=total_cost,
weighted_lead_time=weighted_lead_time,
risk_score=risk_score,
diversification_applied=diversification_applied,
selection_strategy=strategy
)
logger.info(
f"{ingredient_name}: Selected {len(allocations)} supplier(s) "
f"(strategy={strategy}, total_cost=${total_cost:.2f})"
)
return result
def _score_suppliers(
self,
suppliers: List[SupplierOption]
) -> List[Tuple[SupplierOption, float, Dict[str, float]]]:
"""
Score all suppliers using weighted criteria.
Args:
suppliers: List of supplier options
Returns:
List of (supplier, score, score_breakdown) tuples
"""
if not suppliers:
return []
# Normalize factors for comparison
prices = [s.unit_price for s in suppliers]
lead_times = [s.lead_time_days for s in suppliers]
min_price = min(prices)
max_price = max(prices)
min_lead_time = min(lead_times)
max_lead_time = max(lead_times)
scored = []
for supplier in suppliers:
# Price score (normalized, lower is better)
if max_price > min_price:
price_score = 1.0 - float((supplier.unit_price - min_price) / (max_price - min_price))
else:
price_score = 1.0
# Lead time score (normalized, shorter is better)
if max_lead_time > min_lead_time:
lead_time_score = 1.0 - (supplier.lead_time_days - min_lead_time) / (max_lead_time - min_lead_time)
else:
lead_time_score = 1.0
# Quality and reliability scores (already 0-1)
quality_score = supplier.quality_score
reliability_score = supplier.reliability_score
# Calculate weighted total score
total_score = (
self.price_weight * price_score +
self.lead_time_weight * lead_time_score +
self.quality_weight * quality_score +
self.reliability_weight * reliability_score
)
score_breakdown = {
'price_score': price_score,
'lead_time_score': lead_time_score,
'quality_score': quality_score,
'reliability_score': reliability_score,
'total_score': total_score
}
scored.append((supplier, total_score, score_breakdown))
# Sort by score (descending)
scored.sort(key=lambda x: x[1], reverse=True)
return scored
def _determine_strategy(
self,
required_quantity: Decimal,
suppliers: List[SupplierOption]
) -> str:
"""
Determine selection strategy based on quantity and options.
Args:
required_quantity: Quantity needed
suppliers: Available suppliers
Returns:
Strategy: 'single_source', 'dual_source', or 'multi_source'
"""
if len(suppliers) == 1:
return 'single_source'
# Large orders should be diversified
if required_quantity >= self.diversification_threshold:
return 'multi_source' if len(suppliers) >= 3 else 'dual_source'
# Small orders: single source unless quality/reliability concerns
avg_reliability = sum(s.reliability_score for s in suppliers) / len(suppliers)
if avg_reliability < 0.85:
return 'dual_source' # Use backup for unreliable suppliers
return 'single_source'
def _select_single_source(
self,
required_quantity: Decimal,
scored_suppliers: List[Tuple[SupplierOption, float, Dict[str, float]]]
) -> List[SupplierAllocation]:
"""
Select single best supplier.
Args:
required_quantity: Quantity needed
scored_suppliers: Scored suppliers
Returns:
List with single allocation
"""
best_supplier, score, score_breakdown = scored_suppliers[0]
# Check capacity
if best_supplier.max_capacity and required_quantity > best_supplier.max_capacity:
logger.warning(
f"{best_supplier.supplier_name}: Required quantity {required_quantity} "
f"exceeds capacity {best_supplier.max_capacity}, will need to split"
)
# Fall back to dual source
return self._select_dual_source(required_quantity, scored_suppliers)
allocation = SupplierAllocation(
supplier_id=best_supplier.supplier_id,
supplier_name=best_supplier.supplier_name,
allocated_quantity=required_quantity,
allocation_percentage=1.0,
allocation_type='primary',
unit_price=best_supplier.unit_price,
total_cost=best_supplier.unit_price * required_quantity,
lead_time_days=best_supplier.lead_time_days,
supplier_score=score,
score_breakdown=score_breakdown,
allocation_reason='Best overall score (single source strategy)'
)
return [allocation]
def _select_dual_source(
self,
required_quantity: Decimal,
scored_suppliers: List[Tuple[SupplierOption, float, Dict[str, float]]]
) -> List[SupplierAllocation]:
"""
Select primary supplier + backup.
Args:
required_quantity: Quantity needed
scored_suppliers: Scored suppliers
Returns:
List with two allocations
"""
if len(scored_suppliers) < 2:
return self._select_single_source(required_quantity, scored_suppliers)
primary_supplier, primary_score, primary_breakdown = scored_suppliers[0]
backup_supplier, backup_score, backup_breakdown = scored_suppliers[1]
# Primary gets 70%, backup gets 30%
primary_percentage = self.max_single_supplier_percentage
backup_percentage = 1.0 - primary_percentage
primary_qty = required_quantity * Decimal(str(primary_percentage))
backup_qty = required_quantity * Decimal(str(backup_percentage))
# Check capacities
if primary_supplier.max_capacity and primary_qty > primary_supplier.max_capacity:
# Rebalance
primary_qty = primary_supplier.max_capacity
backup_qty = required_quantity - primary_qty
primary_percentage = float(primary_qty / required_quantity)
backup_percentage = float(backup_qty / required_quantity)
allocations = [
SupplierAllocation(
supplier_id=primary_supplier.supplier_id,
supplier_name=primary_supplier.supplier_name,
allocated_quantity=primary_qty,
allocation_percentage=primary_percentage,
allocation_type='primary',
unit_price=primary_supplier.unit_price,
total_cost=primary_supplier.unit_price * primary_qty,
lead_time_days=primary_supplier.lead_time_days,
supplier_score=primary_score,
score_breakdown=primary_breakdown,
allocation_reason=f'Primary supplier ({primary_percentage*100:.0f}% allocation)'
),
SupplierAllocation(
supplier_id=backup_supplier.supplier_id,
supplier_name=backup_supplier.supplier_name,
allocated_quantity=backup_qty,
allocation_percentage=backup_percentage,
allocation_type='backup',
unit_price=backup_supplier.unit_price,
total_cost=backup_supplier.unit_price * backup_qty,
lead_time_days=backup_supplier.lead_time_days,
supplier_score=backup_score,
score_breakdown=backup_breakdown,
allocation_reason=f'Backup supplier ({backup_percentage*100:.0f}% allocation for risk mitigation)'
)
]
return allocations
def _select_multi_source(
self,
required_quantity: Decimal,
scored_suppliers: List[Tuple[SupplierOption, float, Dict[str, float]]]
) -> List[SupplierAllocation]:
"""
Split across multiple suppliers for large orders.
Args:
required_quantity: Quantity needed
scored_suppliers: Scored suppliers
Returns:
List with multiple allocations
"""
if len(scored_suppliers) < 3:
return self._select_dual_source(required_quantity, scored_suppliers)
# Use top 3 suppliers
top_3 = scored_suppliers[:3]
# Allocate proportionally to scores
total_score = sum(score for _, score, _ in top_3)
allocations = []
remaining_qty = required_quantity
for i, (supplier, score, score_breakdown) in enumerate(top_3):
if i == len(top_3) - 1:
# Last supplier gets remainder
allocated_qty = remaining_qty
else:
# Allocate based on score proportion
proportion = score / total_score
allocated_qty = required_quantity * Decimal(str(proportion))
# Check capacity
if supplier.max_capacity and allocated_qty > supplier.max_capacity:
allocated_qty = supplier.max_capacity
allocation_percentage = float(allocated_qty / required_quantity)
allocation = SupplierAllocation(
supplier_id=supplier.supplier_id,
supplier_name=supplier.supplier_name,
allocated_quantity=allocated_qty,
allocation_percentage=allocation_percentage,
allocation_type='diversification',
unit_price=supplier.unit_price,
total_cost=supplier.unit_price * allocated_qty,
lead_time_days=supplier.lead_time_days,
supplier_score=score,
score_breakdown=score_breakdown,
allocation_reason=f'Multi-source diversification ({allocation_percentage*100:.0f}%)'
)
allocations.append(allocation)
remaining_qty -= allocated_qty
if remaining_qty <= 0:
break
return allocations
def _calculate_risk_score(
self,
allocations: List[SupplierAllocation]
) -> float:
"""
Calculate overall risk score (lower is better).
Args:
allocations: List of allocations
Returns:
Risk score (0-1)
"""
if not allocations:
return 1.0
# Single source = higher risk
diversification_risk = 1.0 / len(allocations)
# Concentration risk (how much in single supplier)
max_allocation = max(alloc.allocation_percentage for alloc in allocations)
concentration_risk = max_allocation
# Reliability risk (average of supplier reliability)
# Note: We don't have reliability in SupplierAllocation, estimate from score
avg_supplier_score = sum(alloc.supplier_score for alloc in allocations) / len(allocations)
reliability_risk = 1.0 - avg_supplier_score
# Combined risk (weighted)
risk_score = (
0.4 * diversification_risk +
0.3 * concentration_risk +
0.3 * reliability_risk
)
return risk_score
def export_result_to_dict(self, result: SupplierSelectionResult) -> Dict:
"""
Export result to dictionary for API response.
Args:
result: Supplier selection result
Returns:
Dictionary representation
"""
return {
'ingredient_id': result.ingredient_id,
'ingredient_name': result.ingredient_name,
'required_quantity': float(result.required_quantity),
'total_cost': float(result.total_cost),
'weighted_lead_time': result.weighted_lead_time,
'risk_score': result.risk_score,
'diversification_applied': result.diversification_applied,
'selection_strategy': result.selection_strategy,
'allocations': [
{
'supplier_id': alloc.supplier_id,
'supplier_name': alloc.supplier_name,
'allocated_quantity': float(alloc.allocated_quantity),
'allocation_percentage': alloc.allocation_percentage,
'allocation_type': alloc.allocation_type,
'unit_price': float(alloc.unit_price),
'total_cost': float(alloc.total_cost),
'lead_time_days': alloc.lead_time_days,
'supplier_score': alloc.supplier_score,
'score_breakdown': alloc.score_breakdown,
'allocation_reason': alloc.allocation_reason
}
for alloc in result.allocations
]
}

View File

@@ -0,0 +1,150 @@
"""Alembic environment configuration for procurement service"""
import asyncio
import os
import sys
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
# Determine the project root (where the shared directory is located)
current_file_dir = os.path.dirname(os.path.abspath(__file__)) # migrations directory
service_dir = os.path.dirname(current_file_dir) # procurement service directory
project_root = os.path.dirname(os.path.dirname(service_dir)) # project root
# Add project root to Python path first
if project_root not in sys.path:
sys.path.insert(0, project_root)
# Add shared directory to Python path
shared_path = os.path.join(project_root, "shared")
if shared_path not in sys.path:
sys.path.insert(0, shared_path)
# Add service directory to Python path
if service_dir not in sys.path:
sys.path.insert(0, service_dir)
try:
from app.core.config import settings
from shared.database.base import Base
# Import all models to ensure they are registered with Base.metadata
from app.models import * # noqa: F401, F403
from app.models.replenishment import * # noqa: F401, F403
except ImportError as e:
print(f"Import error in migrations env.py: {e}")
print(f"Current Python path: {sys.path}")
raise
# this is the Alembic Config object
config = context.config
# Determine service name from file path
service_name = os.path.basename(os.path.dirname(os.path.dirname(__file__)))
service_name_upper = service_name.upper().replace('-', '_')
# Set database URL from environment variables with multiple fallback strategies
database_url = (
os.getenv(f'{service_name_upper}_DATABASE_URL') or # Service-specific
os.getenv('DATABASE_URL') # Generic fallback
)
# If DATABASE_URL is not set, construct from individual components
if not database_url:
# Try generic PostgreSQL environment variables first
postgres_host = os.getenv('POSTGRES_HOST')
postgres_port = os.getenv('POSTGRES_PORT', '5432')
postgres_db = os.getenv('POSTGRES_DB')
postgres_user = os.getenv('POSTGRES_USER')
postgres_password = os.getenv('POSTGRES_PASSWORD')
if all([postgres_host, postgres_db, postgres_user, postgres_password]):
database_url = f"postgresql+asyncpg://{postgres_user}:{postgres_password}@{postgres_host}:{postgres_port}/{postgres_db}"
else:
# Try service-specific environment variables
db_host = os.getenv(f'{service_name_upper}_DB_HOST', f'{service_name}-db-service')
db_port = os.getenv(f'{service_name_upper}_DB_PORT', '5432')
db_name = os.getenv(f'{service_name_upper}_DB_NAME', f'{service_name.replace("-", "_")}_db')
db_user = os.getenv(f'{service_name_upper}_DB_USER', f'{service_name.replace("-", "_")}_user')
db_password = os.getenv(f'{service_name_upper}_DB_PASSWORD')
if db_password:
database_url = f"postgresql+asyncpg://{db_user}:{db_password}@{db_host}:{db_port}/{db_name}"
else:
# Final fallback: try to get from settings object
try:
database_url = getattr(settings, 'DATABASE_URL', None)
except Exception:
pass
if not database_url:
error_msg = f"ERROR: No database URL configured for {service_name} service"
print(error_msg)
raise Exception(error_msg)
config.set_main_option("sqlalchemy.url", database_url)
# Interpret the config file for Python logging
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Set target metadata
target_metadata = Base.metadata
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode."""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
compare_type=True,
compare_server_default=True,
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
"""Execute migrations with the given connection."""
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
compare_server_default=True,
)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
"""Run migrations in 'online' mode with async support."""
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,601 @@
"""initial procurement schema
Revision ID: 20251015_1229
Revises:
Create Date: 2025-10-15 12:29:00.00000+02:00
Complete procurement service schema including:
- Procurement plans and requirements
- Purchase orders and items
- Deliveries and delivery items
- Supplier invoices
- Replenishment planning
- Inventory projections
- Supplier allocations and selection history
- Audit logs
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '20251015_1229'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create PostgreSQL enum types first
# PurchaseOrderStatus enum
purchaseorderstatus_enum = postgresql.ENUM(
'draft', 'pending_approval', 'approved', 'sent_to_supplier',
'confirmed', 'partially_received', 'completed', 'cancelled', 'disputed',
name='purchaseorderstatus',
create_type=False
)
purchaseorderstatus_enum.create(op.get_bind(), checkfirst=True)
# DeliveryStatus enum
deliverystatus_enum = postgresql.ENUM(
'scheduled', 'in_transit', 'out_for_delivery', 'delivered',
'partially_delivered', 'failed_delivery', 'returned',
name='deliverystatus',
create_type=False
)
deliverystatus_enum.create(op.get_bind(), checkfirst=True)
# InvoiceStatus enum
invoicestatus_enum = postgresql.ENUM(
'pending', 'approved', 'paid', 'overdue', 'disputed', 'cancelled',
name='invoicestatus',
create_type=False
)
invoicestatus_enum.create(op.get_bind(), checkfirst=True)
# ========================================================================
# PROCUREMENT PLANNING TABLES
# ========================================================================
# Create procurement_plans table
op.create_table('procurement_plans',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('tenant_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('plan_number', sa.String(length=50), nullable=False),
sa.Column('plan_date', sa.Date(), nullable=False),
sa.Column('plan_period_start', sa.Date(), nullable=False),
sa.Column('plan_period_end', sa.Date(), nullable=False),
sa.Column('planning_horizon_days', sa.Integer(), nullable=False, server_default='14'),
sa.Column('status', sa.String(length=50), nullable=False, server_default='draft'),
sa.Column('plan_type', sa.String(length=50), nullable=False, server_default='regular'),
sa.Column('priority', sa.String(length=20), nullable=False, server_default='normal'),
sa.Column('business_model', sa.String(length=50), nullable=True),
sa.Column('procurement_strategy', sa.String(length=50), nullable=False, server_default='just_in_time'),
sa.Column('total_requirements', sa.Integer(), nullable=False, server_default='0'),
sa.Column('total_estimated_cost', sa.Numeric(precision=12, scale=2), nullable=False, server_default='0.00'),
sa.Column('total_approved_cost', sa.Numeric(precision=12, scale=2), nullable=False, server_default='0.00'),
sa.Column('cost_variance', sa.Numeric(precision=12, scale=2), nullable=False, server_default='0.00'),
sa.Column('total_demand_orders', sa.Integer(), nullable=False, server_default='0'),
sa.Column('total_demand_quantity', sa.Numeric(precision=12, scale=3), nullable=False, server_default='0.000'),
sa.Column('total_production_requirements', sa.Numeric(precision=12, scale=3), nullable=False, server_default='0.00'),
sa.Column('safety_stock_buffer', sa.Numeric(precision=5, scale=2), nullable=False, server_default='20.00'),
sa.Column('primary_suppliers_count', sa.Integer(), nullable=False, server_default='0'),
sa.Column('backup_suppliers_count', sa.Integer(), nullable=False, server_default='0'),
sa.Column('supplier_diversification_score', sa.Numeric(precision=3, scale=1), nullable=True),
sa.Column('supply_risk_level', sa.String(length=20), nullable=False, server_default='low'),
sa.Column('demand_forecast_confidence', sa.Numeric(precision=3, scale=1), nullable=True),
sa.Column('seasonality_adjustment', sa.Numeric(precision=5, scale=2), nullable=False, server_default='0.00'),
sa.Column('approved_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('approved_by', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('execution_started_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('execution_completed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('fulfillment_rate', sa.Numeric(precision=5, scale=2), nullable=True),
sa.Column('on_time_delivery_rate', sa.Numeric(precision=5, scale=2), nullable=True),
sa.Column('cost_accuracy', sa.Numeric(precision=5, scale=2), nullable=True),
sa.Column('quality_score', sa.Numeric(precision=3, scale=1), nullable=True),
sa.Column('source_orders', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('production_schedules', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('inventory_snapshots', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('forecast_data', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('stakeholder_notifications', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('approval_workflow', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('special_requirements', sa.Text(), nullable=True),
sa.Column('seasonal_adjustments', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('emergency_provisions', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('erp_reference', sa.String(length=100), nullable=True),
sa.Column('supplier_portal_reference', sa.String(length=100), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), onupdate=sa.text('now()'), nullable=False),
sa.Column('created_by', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('updated_by', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('plan_metadata', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_procurement_plans_plan_date'), 'procurement_plans', ['plan_date'], unique=False)
op.create_index(op.f('ix_procurement_plans_plan_number'), 'procurement_plans', ['plan_number'], unique=True)
op.create_index(op.f('ix_procurement_plans_status'), 'procurement_plans', ['status'], unique=False)
op.create_index(op.f('ix_procurement_plans_tenant_id'), 'procurement_plans', ['tenant_id'], unique=False)
# Create procurement_requirements table
op.create_table('procurement_requirements',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('plan_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('requirement_number', sa.String(length=50), nullable=False),
sa.Column('product_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('product_name', sa.String(length=200), nullable=False),
sa.Column('product_sku', sa.String(length=100), nullable=True),
sa.Column('product_category', sa.String(length=100), nullable=True),
sa.Column('product_type', sa.String(length=50), nullable=False, server_default='ingredient'),
sa.Column('is_locally_produced', sa.Boolean(), nullable=False, server_default='false'),
sa.Column('recipe_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('parent_requirement_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('bom_explosion_level', sa.Integer(), nullable=False, server_default='0'),
sa.Column('required_quantity', sa.Numeric(precision=12, scale=3), nullable=False),
sa.Column('unit_of_measure', sa.String(length=50), nullable=False),
sa.Column('safety_stock_quantity', sa.Numeric(precision=12, scale=3), nullable=False, server_default='0.000'),
sa.Column('total_quantity_needed', sa.Numeric(precision=12, scale=3), nullable=False),
sa.Column('current_stock_level', sa.Numeric(precision=12, scale=3), nullable=False, server_default='0.00'),
sa.Column('reserved_stock', sa.Numeric(precision=12, scale=3), nullable=False, server_default='0.00'),
sa.Column('available_stock', sa.Numeric(precision=12, scale=3), nullable=False, server_default='0.000'),
sa.Column('net_requirement', sa.Numeric(precision=12, scale=3), nullable=False),
sa.Column('order_demand', sa.Numeric(precision=12, scale=3), nullable=False, server_default='0.000'),
sa.Column('production_demand', sa.Numeric(precision=12, scale=3), nullable=False, server_default='0.00'),
sa.Column('forecast_demand', sa.Numeric(precision=12, scale=3), nullable=False, server_default='0.00'),
sa.Column('buffer_demand', sa.Numeric(precision=12, scale=3), nullable=False, server_default='0.00'),
sa.Column('preferred_supplier_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('backup_supplier_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('supplier_name', sa.String(length=200), nullable=True),
sa.Column('supplier_lead_time_days', sa.Integer(), nullable=True),
sa.Column('minimum_order_quantity', sa.Numeric(precision=12, scale=3), nullable=True),
sa.Column('estimated_unit_cost', sa.Numeric(precision=10, scale=4), nullable=True),
sa.Column('estimated_total_cost', sa.Numeric(precision=12, scale=2), nullable=True),
sa.Column('last_purchase_cost', sa.Numeric(precision=10, scale=4), nullable=True),
sa.Column('cost_variance', sa.Numeric(precision=10, scale=2), nullable=False, server_default='0.00'),
sa.Column('required_by_date', sa.Date(), nullable=False),
sa.Column('lead_time_buffer_days', sa.Integer(), nullable=False, server_default='1'),
sa.Column('suggested_order_date', sa.Date(), nullable=False),
sa.Column('latest_order_date', sa.Date(), nullable=False),
sa.Column('shelf_life_days', sa.Integer(), nullable=True),
sa.Column('status', sa.String(length=50), nullable=False, server_default='pending'),
sa.Column('priority', sa.String(length=20), nullable=False, server_default='normal'),
sa.Column('risk_level', sa.String(length=20), nullable=False, server_default='low'),
sa.Column('purchase_order_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('purchase_order_number', sa.String(length=50), nullable=True),
sa.Column('ordered_quantity', sa.Numeric(precision=12, scale=3), nullable=False, server_default='0.000'),
sa.Column('ordered_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('expected_delivery_date', sa.Date(), nullable=True),
sa.Column('actual_delivery_date', sa.Date(), nullable=True),
sa.Column('received_quantity', sa.Numeric(precision=12, scale=3), nullable=False, server_default='0.000'),
sa.Column('delivery_status', sa.String(length=50), nullable=False, server_default='pending'),
sa.Column('fulfillment_rate', sa.Numeric(precision=5, scale=2), nullable=True),
sa.Column('on_time_delivery', sa.Boolean(), nullable=True),
sa.Column('quality_rating', sa.Numeric(precision=3, scale=1), nullable=True),
sa.Column('source_orders', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('source_production_batches', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('demand_analysis', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('quality_specifications', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('special_requirements', sa.Text(), nullable=True),
sa.Column('storage_requirements', sa.String(length=200), nullable=True),
sa.Column('calculation_method', sa.String(length=100), nullable=True),
sa.Column('ai_suggested_quantity', sa.Numeric(precision=12, scale=3), nullable=True),
sa.Column('adjusted_quantity', sa.Numeric(precision=12, scale=3), nullable=True),
sa.Column('adjustment_reason', sa.Text(), nullable=True),
sa.Column('price_tier_applied', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('supplier_minimum_applied', sa.Boolean(), nullable=False, server_default='false'),
sa.Column('storage_limit_applied', sa.Boolean(), nullable=False, server_default='false'),
sa.Column('reorder_rule_applied', sa.Boolean(), nullable=False, server_default='false'),
sa.Column('approved_quantity', sa.Numeric(precision=12, scale=3), nullable=True),
sa.Column('approved_cost', sa.Numeric(precision=12, scale=2), nullable=True),
sa.Column('approved_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('approved_by', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('procurement_notes', sa.Text(), nullable=True),
sa.Column('supplier_communication', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), onupdate=sa.text('now()'), nullable=False),
sa.Column('requirement_metadata', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.ForeignKeyConstraint(['plan_id'], ['procurement_plans.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_procurement_requirements_plan_id'), 'procurement_requirements', ['plan_id'], unique=False)
op.create_index(op.f('ix_procurement_requirements_product_id'), 'procurement_requirements', ['product_id'], unique=False)
op.create_index(op.f('ix_procurement_requirements_requirement_number'), 'procurement_requirements', ['requirement_number'], unique=False)
op.create_index(op.f('ix_procurement_requirements_status'), 'procurement_requirements', ['status'], unique=False)
# ========================================================================
# PURCHASE ORDER TABLES
# ========================================================================
# Create purchase_orders table
op.create_table('purchase_orders',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('tenant_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('supplier_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('po_number', sa.String(length=50), nullable=False),
sa.Column('reference_number', sa.String(length=100), nullable=True),
sa.Column('procurement_plan_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('status', purchaseorderstatus_enum, nullable=False, server_default='draft'),
sa.Column('priority', sa.String(length=20), nullable=False, server_default='normal'),
sa.Column('order_date', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')),
sa.Column('required_delivery_date', sa.DateTime(timezone=True), nullable=True),
sa.Column('estimated_delivery_date', sa.DateTime(timezone=True), nullable=True),
sa.Column('subtotal', sa.Numeric(precision=12, scale=2), nullable=False, server_default='0.00'),
sa.Column('tax_amount', sa.Numeric(precision=12, scale=2), nullable=False, server_default='0.00'),
sa.Column('shipping_cost', sa.Numeric(precision=10, scale=2), nullable=False, server_default='0.00'),
sa.Column('discount_amount', sa.Numeric(precision=10, scale=2), nullable=False, server_default='0.00'),
sa.Column('total_amount', sa.Numeric(precision=12, scale=2), nullable=False, server_default='0.00'),
sa.Column('currency', sa.String(length=3), nullable=False, server_default='EUR'),
sa.Column('delivery_address', sa.Text(), nullable=True),
sa.Column('delivery_instructions', sa.Text(), nullable=True),
sa.Column('delivery_contact', sa.String(length=200), nullable=True),
sa.Column('delivery_phone', sa.String(length=30), nullable=True),
sa.Column('requires_approval', sa.Boolean(), nullable=False, server_default='false'),
sa.Column('approved_by', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('approved_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('auto_approved', sa.Boolean(), nullable=False, server_default='false'),
sa.Column('auto_approval_rule_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('rejection_reason', sa.Text(), nullable=True),
sa.Column('sent_to_supplier_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('supplier_confirmation_date', sa.DateTime(timezone=True), nullable=True),
sa.Column('supplier_reference', sa.String(length=100), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('internal_notes', sa.Text(), nullable=True),
sa.Column('terms_and_conditions', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), onupdate=sa.text('now()'), nullable=False),
sa.Column('created_by', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('updated_by', postgresql.UUID(as_uuid=True), nullable=False),
sa.ForeignKeyConstraint(['procurement_plan_id'], ['procurement_plans.id']),
# Note: supplier_id references suppliers service - no FK constraint in microservices
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_purchase_orders_po_number'), 'purchase_orders', ['po_number'], unique=True)
op.create_index(op.f('ix_purchase_orders_procurement_plan_id'), 'purchase_orders', ['procurement_plan_id'], unique=False)
op.create_index(op.f('ix_purchase_orders_status'), 'purchase_orders', ['status'], unique=False)
op.create_index(op.f('ix_purchase_orders_supplier_id'), 'purchase_orders', ['supplier_id'], unique=False)
op.create_index(op.f('ix_purchase_orders_tenant_id'), 'purchase_orders', ['tenant_id'], unique=False)
op.create_index('ix_purchase_orders_tenant_status', 'purchase_orders', ['tenant_id', 'status'], unique=False)
op.create_index('ix_purchase_orders_tenant_plan', 'purchase_orders', ['tenant_id', 'procurement_plan_id'], unique=False)
op.create_index('ix_purchase_orders_order_date', 'purchase_orders', ['order_date'], unique=False)
op.create_index('ix_purchase_orders_delivery_date', 'purchase_orders', ['required_delivery_date'], unique=False)
# Create purchase_order_items table
op.create_table('purchase_order_items',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('tenant_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('purchase_order_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('procurement_requirement_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('inventory_product_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('product_code', sa.String(length=100), nullable=True),
sa.Column('product_name', sa.String(length=200), nullable=False),
sa.Column('ordered_quantity', sa.Numeric(precision=12, scale=3), nullable=False),
sa.Column('unit_of_measure', sa.String(length=20), nullable=False),
sa.Column('unit_price', sa.Numeric(precision=10, scale=4), nullable=False),
sa.Column('line_total', sa.Numeric(precision=12, scale=2), nullable=False),
sa.Column('received_quantity', sa.Numeric(precision=12, scale=3), nullable=False, server_default='0.000'),
sa.Column('remaining_quantity', sa.Numeric(precision=12, scale=3), nullable=False, server_default='0.000'),
sa.Column('quality_requirements', sa.Text(), nullable=True),
sa.Column('item_notes', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), onupdate=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['procurement_requirement_id'], ['procurement_requirements.id']),
sa.ForeignKeyConstraint(['purchase_order_id'], ['purchase_orders.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_purchase_order_items_inventory_product_id'), 'purchase_order_items', ['inventory_product_id'], unique=False)
op.create_index(op.f('ix_purchase_order_items_procurement_requirement_id'), 'purchase_order_items', ['procurement_requirement_id'], unique=False)
op.create_index(op.f('ix_purchase_order_items_purchase_order_id'), 'purchase_order_items', ['purchase_order_id'], unique=False)
op.create_index(op.f('ix_purchase_order_items_tenant_id'), 'purchase_order_items', ['tenant_id'], unique=False)
op.create_index('ix_po_items_tenant_po', 'purchase_order_items', ['tenant_id', 'purchase_order_id'], unique=False)
op.create_index('ix_po_items_inventory_product', 'purchase_order_items', ['inventory_product_id'], unique=False)
# ========================================================================
# DELIVERY TABLES
# ========================================================================
# Create deliveries table
op.create_table('deliveries',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('tenant_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('purchase_order_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('supplier_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('delivery_number', sa.String(length=50), nullable=False),
sa.Column('supplier_delivery_note', sa.String(length=10), nullable=True),
sa.Column('status', deliverystatus_enum, nullable=False, server_default='scheduled'),
sa.Column('scheduled_date', sa.DateTime(timezone=True), nullable=True),
sa.Column('estimated_arrival', sa.DateTime(timezone=True), nullable=True),
sa.Column('actual_arrival', sa.DateTime(timezone=True), nullable=True),
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('delivery_address', sa.Text(), nullable=True),
sa.Column('delivery_contact', sa.String(200), nullable=True),
sa.Column('delivery_phone', sa.String(30), nullable=True),
sa.Column('carrier_name', sa.String(200), nullable=True),
sa.Column('tracking_number', sa.String(100), nullable=True),
sa.Column('inspection_passed', sa.Boolean(), nullable=True),
sa.Column('inspection_notes', sa.Text(), nullable=True),
sa.Column('quality_issues', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('received_by', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('received_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('photos', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), onupdate=sa.text('now()'), nullable=False),
sa.Column('created_by', postgresql.UUID(as_uuid=True), nullable=False),
sa.ForeignKeyConstraint(['purchase_order_id'], ['purchase_orders.id'], ondelete='CASCADE'),
# Note: supplier_id references suppliers service - no FK constraint in microservices
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_deliveries_delivery_number'), 'deliveries', ['delivery_number'], unique=True)
op.create_index(op.f('ix_deliveries_purchase_order_id'), 'deliveries', ['purchase_order_id'], unique=False)
op.create_index(op.f('ix_deliveries_status'), 'deliveries', ['status'], unique=False)
op.create_index(op.f('ix_deliveries_supplier_id'), 'deliveries', ['supplier_id'], unique=False)
op.create_index(op.f('ix_deliveries_tenant_id'), 'deliveries', ['tenant_id'], unique=False)
op.create_index('ix_deliveries_scheduled_date', 'deliveries', ['scheduled_date'], unique=False)
op.create_index('ix_deliveries_tenant_status', 'deliveries', ['tenant_id', 'status'], unique=False)
# Create delivery_items table
op.create_table('delivery_items',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('tenant_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('delivery_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('purchase_order_item_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('inventory_product_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('ordered_quantity', sa.Numeric(precision=12, scale=3), nullable=False),
sa.Column('delivered_quantity', sa.Numeric(precision=12, scale=3), nullable=False),
sa.Column('accepted_quantity', sa.Numeric(precision=12, scale=3), nullable=False),
sa.Column('rejected_quantity', sa.Numeric(precision=12, scale=3), nullable=False, server_default='0.000'),
sa.Column('batch_lot_number', sa.String(length=100), nullable=True),
sa.Column('expiry_date', sa.DateTime(timezone=True), nullable=True),
sa.Column('quality_grade', sa.String(length=20), nullable=True),
sa.Column('quality_issues', sa.Text(), nullable=True),
sa.Column('rejection_reason', sa.Text(), nullable=True),
sa.Column('item_notes', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), onupdate=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['delivery_id'], ['deliveries.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['purchase_order_item_id'], ['purchase_order_items.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_delivery_items_delivery_id'), 'delivery_items', ['delivery_id'], unique=False)
op.create_index(op.f('ix_delivery_items_inventory_product_id'), 'delivery_items', ['inventory_product_id'], unique=False)
op.create_index(op.f('ix_delivery_items_purchase_order_item_id'), 'delivery_items', ['purchase_order_item_id'], unique=False)
op.create_index(op.f('ix_delivery_items_tenant_id'), 'delivery_items', ['tenant_id'], unique=False)
op.create_index('ix_delivery_items_tenant_delivery', 'delivery_items', ['tenant_id', 'delivery_id'], unique=False)
op.create_index('ix_delivery_items_inventory_product', 'delivery_items', ['inventory_product_id'], unique=False)
# ========================================================================
# INVOICE TABLES
# ========================================================================
# Create supplier_invoices table
op.create_table('supplier_invoices',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('tenant_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('supplier_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('purchase_order_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('invoice_number', sa.String(length=50), nullable=False),
sa.Column('supplier_invoice_number', sa.String(length=100), nullable=False),
sa.Column('status', invoicestatus_enum, nullable=False, server_default='pending'),
sa.Column('invoice_date', sa.DateTime(timezone=True), nullable=False),
sa.Column('due_date', sa.DateTime(timezone=True), nullable=False),
sa.Column('received_date', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')),
sa.Column('subtotal', sa.Numeric(precision=12, scale=2), nullable=False),
sa.Column('tax_amount', sa.Numeric(precision=12, scale=2), nullable=False, server_default='0.00'),
sa.Column('shipping_cost', sa.Numeric(precision=10, scale=2), nullable=False, server_default='0.00'),
sa.Column('discount_amount', sa.Numeric(precision=10, scale=2), nullable=False, server_default='0.00'),
sa.Column('total_amount', sa.Numeric(precision=12, scale=2), nullable=False),
sa.Column('currency', sa.String(length=3), nullable=False, server_default='EUR'),
sa.Column('paid_amount', sa.Numeric(precision=12, scale=2), nullable=False, server_default='0.00'),
sa.Column('payment_date', sa.DateTime(timezone=True), nullable=True),
sa.Column('payment_reference', sa.String(length=100), nullable=True),
sa.Column('approved_by', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('approved_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('rejection_reason', sa.Text(), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('invoice_document_url', sa.String(length=500), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), onupdate=sa.text('now()'), nullable=False),
sa.Column('created_by', postgresql.UUID(as_uuid=True), nullable=False),
sa.ForeignKeyConstraint(['purchase_order_id'], ['purchase_orders.id'], ondelete='SET NULL'),
# Note: supplier_id references suppliers service - no FK constraint in microservices
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_supplier_invoices_invoice_number'), 'supplier_invoices', ['invoice_number'], unique=True)
op.create_index(op.f('ix_supplier_invoices_purchase_order_id'), 'supplier_invoices', ['purchase_order_id'], unique=False)
op.create_index(op.f('ix_supplier_invoices_status'), 'supplier_invoices', ['status'], unique=False)
op.create_index(op.f('ix_supplier_invoices_supplier_id'), 'supplier_invoices', ['supplier_id'], unique=False)
op.create_index(op.f('ix_supplier_invoices_tenant_id'), 'supplier_invoices', ['tenant_id'], unique=False)
op.create_index('ix_invoices_due_date', 'supplier_invoices', ['due_date'], unique=False)
op.create_index('ix_invoices_tenant_status', 'supplier_invoices', ['tenant_id', 'status'], unique=False)
op.create_index('ix_invoices_tenant_supplier', 'supplier_invoices', ['tenant_id', 'supplier_id'], unique=False)
# ========================================================================
# REPLENISHMENT PLANNING TABLES
# ========================================================================
# Create replenishment_plans table
op.create_table('replenishment_plans',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('tenant_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('planning_date', sa.Date(), nullable=False),
sa.Column('projection_horizon_days', sa.Integer(), nullable=False, server_default='7'),
sa.Column('forecast_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('production_schedule_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('total_items', sa.Integer(), nullable=False, server_default='0'),
sa.Column('urgent_items', sa.Integer(), nullable=False, server_default='0'),
sa.Column('high_risk_items', sa.Integer(), nullable=False, server_default='0'),
sa.Column('total_estimated_cost', sa.Numeric(12, 2), nullable=False, server_default='0'),
sa.Column('status', sa.String(50), nullable=False, server_default='draft'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('executed_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_replenishment_plans_tenant_id', 'replenishment_plans', ['tenant_id'])
op.create_index('ix_replenishment_plans_planning_date', 'replenishment_plans', ['planning_date'])
op.create_index('ix_replenishment_plans_status', 'replenishment_plans', ['status'])
# Create replenishment_plan_items table
op.create_table('replenishment_plan_items',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('replenishment_plan_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('ingredient_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('ingredient_name', sa.String(200), nullable=False),
sa.Column('unit_of_measure', sa.String(20), nullable=False),
sa.Column('base_quantity', sa.Numeric(12, 3), nullable=False),
sa.Column('safety_stock_quantity', sa.Numeric(12, 3), nullable=False, server_default='0'),
sa.Column('shelf_life_adjusted_quantity', sa.Numeric(12, 3), nullable=False),
sa.Column('final_order_quantity', sa.Numeric(12, 3), nullable=False),
sa.Column('order_date', sa.Date(), nullable=False),
sa.Column('delivery_date', sa.Date(), nullable=False),
sa.Column('required_by_date', sa.Date(), nullable=False),
sa.Column('lead_time_days', sa.Integer(), nullable=False),
sa.Column('is_urgent', sa.Boolean(), nullable=False, server_default='false'),
sa.Column('urgency_reason', sa.Text(), nullable=True),
sa.Column('waste_risk', sa.String(20), nullable=False, server_default='low'),
sa.Column('stockout_risk', sa.String(20), nullable=False, server_default='low'),
sa.Column('supplier_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('safety_stock_calculation', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('shelf_life_adjustment', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('inventory_projection', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')),
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['replenishment_plan_id'], ['replenishment_plans.id'], ondelete='CASCADE')
)
op.create_index('ix_replenishment_plan_items_plan_id', 'replenishment_plan_items', ['replenishment_plan_id'])
op.create_index('ix_replenishment_plan_items_ingredient_id', 'replenishment_plan_items', ['ingredient_id'])
op.create_index('ix_replenishment_plan_items_order_date', 'replenishment_plan_items', ['order_date'])
op.create_index('ix_replenishment_plan_items_is_urgent', 'replenishment_plan_items', ['is_urgent'])
# Create inventory_projections table
op.create_table('inventory_projections',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('tenant_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('ingredient_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('ingredient_name', sa.String(200), nullable=False),
sa.Column('projection_date', sa.Date(), nullable=False),
sa.Column('starting_stock', sa.Numeric(12, 3), nullable=False),
sa.Column('forecasted_consumption', sa.Numeric(12, 3), nullable=False, server_default='0'),
sa.Column('scheduled_receipts', sa.Numeric(12, 3), nullable=False, server_default='0'),
sa.Column('projected_ending_stock', sa.Numeric(12, 3), nullable=False),
sa.Column('is_stockout', sa.Boolean(), nullable=False, server_default='false'),
sa.Column('coverage_gap', sa.Numeric(12, 3), nullable=False, server_default='0'),
sa.Column('replenishment_plan_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_inventory_projections_tenant_id', 'inventory_projections', ['tenant_id'])
op.create_index('ix_inventory_projections_ingredient_id', 'inventory_projections', ['ingredient_id'])
op.create_index('ix_inventory_projections_projection_date', 'inventory_projections', ['projection_date'])
op.create_index('ix_inventory_projections_is_stockout', 'inventory_projections', ['is_stockout'])
op.create_index('ix_inventory_projections_unique', 'inventory_projections', ['tenant_id', 'ingredient_id', 'projection_date'], unique=True)
# Create supplier_allocations table
op.create_table('supplier_allocations',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('replenishment_plan_item_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('requirement_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('supplier_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('supplier_name', sa.String(200), nullable=False),
sa.Column('allocation_type', sa.String(20), nullable=False),
sa.Column('allocated_quantity', sa.Numeric(12, 3), nullable=False),
sa.Column('allocation_percentage', sa.Numeric(5, 4), nullable=False),
sa.Column('unit_price', sa.Numeric(12, 2), nullable=False),
sa.Column('total_cost', sa.Numeric(12, 2), nullable=False),
sa.Column('lead_time_days', sa.Integer(), nullable=False),
sa.Column('supplier_score', sa.Numeric(5, 2), nullable=False),
sa.Column('score_breakdown', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('allocation_reason', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')),
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['replenishment_plan_item_id'], ['replenishment_plan_items.id'], ondelete='CASCADE')
)
op.create_index('ix_supplier_allocations_plan_item_id', 'supplier_allocations', ['replenishment_plan_item_id'])
op.create_index('ix_supplier_allocations_requirement_id', 'supplier_allocations', ['requirement_id'])
op.create_index('ix_supplier_allocations_supplier_id', 'supplier_allocations', ['supplier_id'])
# Create supplier_selection_history table
op.create_table('supplier_selection_history',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('tenant_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('ingredient_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('ingredient_name', sa.String(200), nullable=False),
sa.Column('selected_supplier_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('selected_supplier_name', sa.String(200), nullable=False),
sa.Column('selection_date', sa.Date(), nullable=False),
sa.Column('quantity', sa.Numeric(12, 3), nullable=False),
sa.Column('unit_price', sa.Numeric(12, 2), nullable=False),
sa.Column('total_cost', sa.Numeric(12, 2), nullable=False),
sa.Column('lead_time_days', sa.Integer(), nullable=False),
sa.Column('quality_score', sa.Numeric(5, 2), nullable=True),
sa.Column('delivery_performance', sa.Numeric(5, 2), nullable=True),
sa.Column('selection_strategy', sa.String(50), nullable=False),
sa.Column('was_primary_choice', sa.Boolean(), nullable=False, server_default='true'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_supplier_selection_history_tenant_id', 'supplier_selection_history', ['tenant_id'])
op.create_index('ix_supplier_selection_history_ingredient_id', 'supplier_selection_history', ['ingredient_id'])
op.create_index('ix_supplier_selection_history_supplier_id', 'supplier_selection_history', ['selected_supplier_id'])
op.create_index('ix_supplier_selection_history_selection_date', 'supplier_selection_history', ['selection_date'])
# ========================================================================
# AUDIT LOG TABLE
# ========================================================================
# Create audit_logs table
op.create_table('audit_logs',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('tenant_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('action', sa.String(length=100), nullable=False),
sa.Column('resource_type', sa.String(length=100), nullable=False),
sa.Column('resource_id', sa.String(length=255), nullable=True),
sa.Column('severity', sa.String(length=20), nullable=False),
sa.Column('service_name', sa.String(length=100), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('changes', postgresql.JSON(astext_type=sa.Text()), nullable=True),
sa.Column('audit_metadata', postgresql.JSON(astext_type=sa.Text()), nullable=True),
sa.Column('ip_address', sa.String(length=45), nullable=True),
sa.Column('user_agent', sa.Text(), nullable=True),
sa.Column('endpoint', sa.String(length=255), nullable=True),
sa.Column('method', sa.String(length=10), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_audit_logs_action'), 'audit_logs', ['action'], unique=False)
op.create_index(op.f('ix_audit_logs_created_at'), 'audit_logs', ['created_at'], unique=False)
op.create_index(op.f('ix_audit_logs_resource_id'), 'audit_logs', ['resource_id'], unique=False)
op.create_index(op.f('ix_audit_logs_resource_type'), 'audit_logs', ['resource_type'], unique=False)
op.create_index(op.f('ix_audit_logs_service_name'), 'audit_logs', ['service_name'], unique=False)
op.create_index(op.f('ix_audit_logs_severity'), 'audit_logs', ['severity'], unique=False)
op.create_index(op.f('ix_audit_logs_tenant_id'), 'audit_logs', ['tenant_id'], unique=False)
op.create_index(op.f('ix_audit_logs_user_id'), 'audit_logs', ['user_id'], unique=False)
op.create_index('idx_audit_resource_type_action', 'audit_logs', ['resource_type', 'action'], unique=False)
op.create_index('idx_audit_service_created', 'audit_logs', ['service_name', 'created_at'], unique=False)
op.create_index('idx_audit_severity_created', 'audit_logs', ['severity', 'created_at'], unique=False)
op.create_index('idx_audit_tenant_created', 'audit_logs', ['tenant_id', 'created_at'], unique=False)
op.create_index('idx_audit_user_created', 'audit_logs', ['user_id', 'created_at'], unique=False)
def downgrade() -> None:
# Drop tables in reverse order of creation
op.drop_table('audit_logs')
op.drop_table('supplier_selection_history')
op.drop_table('supplier_allocations')
op.drop_table('inventory_projections')
op.drop_table('replenishment_plan_items')
op.drop_table('replenishment_plans')
op.drop_table('supplier_invoices')
op.drop_table('delivery_items')
op.drop_table('deliveries')
op.drop_table('purchase_order_items')
op.drop_table('purchase_orders')
op.drop_table('procurement_requirements')
op.drop_table('procurement_plans')
# Drop enum types
op.execute("DROP TYPE IF EXISTS purchaseorderstatus")
op.execute("DROP TYPE IF EXISTS deliverystatus")
op.execute("DROP TYPE IF EXISTS invoicestatus")

View File

@@ -0,0 +1,42 @@
"""add_supplier_price_list_id_to_purchase_order_items
Revision ID: 9450f58f3623
Revises: 20251015_1229
Create Date: 2025-10-30 07:37:07.477603
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '9450f58f3623'
down_revision: Union[str, None] = '20251015_1229'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add supplier_price_list_id column to purchase_order_items table
op.add_column('purchase_order_items',
sa.Column('supplier_price_list_id', postgresql.UUID(as_uuid=True), nullable=True)
)
# Create index on supplier_price_list_id
op.create_index(
'ix_purchase_order_items_supplier_price_list_id',
'purchase_order_items',
['supplier_price_list_id'],
unique=False
)
def downgrade() -> None:
# Drop index first
op.drop_index('ix_purchase_order_items_supplier_price_list_id', table_name='purchase_order_items')
# Drop column
op.drop_column('purchase_order_items', 'supplier_price_list_id')

View File

@@ -0,0 +1,44 @@
# Procurement Service Dependencies
# FastAPI and web framework
fastapi==0.119.0
uvicorn[standard]==0.32.1
pydantic==2.12.3
pydantic-settings==2.7.1
# Database
sqlalchemy==2.0.44
asyncpg==0.30.0
alembic==1.17.0
psycopg2-binary==2.9.10
# HTTP clients
httpx==0.28.1
# Redis for caching
redis==6.4.0
# Message queuing
aio-pika==9.4.3
# Scheduling
APScheduler==3.10.4
# Logging and monitoring
structlog==25.4.0
prometheus-client==0.23.1
# Date and time utilities
python-dateutil==2.9.0.post0
pytz==2024.2
# Validation and utilities
email-validator==2.2.0
# Authentication
python-jose[cryptography]==3.3.0
cryptography==44.0.0
# Development dependencies
python-multipart==0.0.6
pytest==8.3.4
pytest-asyncio==0.25.2

View File

@@ -0,0 +1,678 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Demo Procurement Plans Seeding Script for Procurement Service
Creates realistic procurement plans for demo template tenants using pre-defined UUIDs
This script runs as a Kubernetes init job inside the procurement-service container.
It populates the template tenants with comprehensive procurement plans.
Usage:
python /app/scripts/demo/seed_demo_procurement_plans.py
Environment Variables Required:
PROCUREMENT_DATABASE_URL - PostgreSQL connection string for procurement database
DEMO_MODE - Set to 'production' for production seeding
LOG_LEVEL - Logging level (default: INFO)
Note: No database lookups needed - all IDs are pre-defined in the JSON file
"""
import asyncio
import uuid
import sys
import os
import json
from datetime import datetime, timezone, timedelta, date
from pathlib import Path
import random
from decimal import Decimal
# Add app to path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy import select, text
import structlog
from app.models.procurement_plan import ProcurementPlan, ProcurementRequirement
# Configure logging
structlog.configure(
processors=[
structlog.stdlib.add_log_level,
structlog.processors.TimeStamper(fmt="iso"),
structlog.dev.ConsoleRenderer()
]
)
logger = structlog.get_logger()
# Fixed Demo Tenant IDs (must match tenant service)
DEMO_TENANT_SAN_PABLO = uuid.UUID("a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6") # Individual bakery
DEMO_TENANT_LA_ESPIGA = uuid.UUID("b2c3d4e5-f6a7-48b9-c0d1-e2f3a4b5c6d7") # Central bakery
# Base reference date for date calculations
BASE_REFERENCE_DATE = datetime(2025, 1, 15, 12, 0, 0, tzinfo=timezone.utc)
# Hardcoded SKU to Ingredient ID mapping (no database lookups needed!)
INGREDIENT_ID_MAP = {
"HAR-T55-001": "10000000-0000-0000-0000-000000000001",
"HAR-T65-002": "10000000-0000-0000-0000-000000000002",
"HAR-FUE-003": "10000000-0000-0000-0000-000000000003",
"HAR-INT-004": "10000000-0000-0000-0000-000000000004",
"HAR-CEN-005": "10000000-0000-0000-0000-000000000005",
"HAR-ESP-006": "10000000-0000-0000-0000-000000000006",
"LAC-MAN-001": "10000000-0000-0000-0000-000000000011",
"LAC-LEC-002": "10000000-0000-0000-0000-000000000012",
"LAC-NAT-003": "10000000-0000-0000-0000-000000000013",
"LAC-HUE-004": "10000000-0000-0000-0000-000000000014",
"LEV-FRE-001": "10000000-0000-0000-0000-000000000021",
"LEV-SEC-002": "10000000-0000-0000-0000-000000000022",
"BAS-SAL-001": "10000000-0000-0000-0000-000000000031",
"BAS-AZU-002": "10000000-0000-0000-0000-000000000032",
"ESP-CHO-001": "10000000-0000-0000-0000-000000000041",
"ESP-ALM-002": "10000000-0000-0000-0000-000000000042",
"ESP-VAI-004": "10000000-0000-0000-0000-000000000044",
"ESP-CRE-005": "10000000-0000-0000-0000-000000000045",
}
# Ingredient costs (for requirement generation)
INGREDIENT_COSTS = {
"HAR-T55-001": 0.85,
"HAR-T65-002": 0.95,
"HAR-FUE-003": 1.15,
"HAR-INT-004": 1.20,
"HAR-CEN-005": 1.30,
"HAR-ESP-006": 2.45,
"LAC-MAN-001": 6.50,
"LAC-LEC-002": 0.95,
"LAC-NAT-003": 3.20,
"LAC-HUE-004": 0.25,
"LEV-FRE-001": 4.80,
"LEV-SEC-002": 12.50,
"BAS-SAL-001": 0.60,
"BAS-AZU-002": 0.90,
"ESP-CHO-001": 15.50,
"ESP-ALM-002": 8.90,
"ESP-VAI-004": 3.50,
"ESP-CRE-005": 7.20,
}
def calculate_date_from_offset(offset_days: int) -> date:
"""Calculate a date based on offset from BASE_REFERENCE_DATE"""
return (BASE_REFERENCE_DATE + timedelta(days=offset_days)).date()
def calculate_datetime_from_offset(offset_days: int) -> datetime:
"""Calculate a datetime based on offset from BASE_REFERENCE_DATE"""
return BASE_REFERENCE_DATE + timedelta(days=offset_days)
def weighted_choice(choices: list) -> dict:
"""Make a weighted random choice from list of dicts with 'weight' key"""
total_weight = sum(c.get("weight", 1.0) for c in choices)
r = random.uniform(0, total_weight)
cumulative = 0
for choice in choices:
cumulative += choice.get("weight", 1.0)
if r <= cumulative:
return choice
return choices[-1]
def generate_plan_number(tenant_id: uuid.UUID, index: int, plan_type: str) -> str:
"""Generate a unique plan number"""
tenant_prefix = "SP" if tenant_id == DEMO_TENANT_SAN_PABLO else "LE"
type_code = plan_type[0:3].upper()
return f"PROC-{tenant_prefix}-{type_code}-{BASE_REFERENCE_DATE.year}-{index:03d}"
async def generate_procurement_for_tenant(
db: AsyncSession,
tenant_id: uuid.UUID,
tenant_name: str,
business_model: str,
config: dict
) -> dict:
"""Generate procurement plans and requirements for a specific tenant"""
logger.info("" * 80)
logger.info(f"Generating procurement data for: {tenant_name}")
logger.info(f"Tenant ID: {tenant_id}")
logger.info("" * 80)
# Check if procurement plans already exist
result = await db.execute(
select(ProcurementPlan).where(ProcurementPlan.tenant_id == tenant_id).limit(1)
)
existing = result.scalar_one_or_none()
if existing:
logger.info(f" ⏭️ Procurement plans already exist for {tenant_name}, skipping seed")
return {
"tenant_id": str(tenant_id),
"plans_created": 0,
"requirements_created": 0,
"skipped": True
}
proc_config = config["procurement_config"]
total_plans = proc_config["plans_per_tenant"]
plans_created = 0
requirements_created = 0
for i in range(total_plans):
# Determine temporal distribution
rand_temporal = random.random()
cumulative = 0
temporal_category = None
for category, details in proc_config["temporal_distribution"].items():
cumulative += details["percentage"]
if rand_temporal <= cumulative:
temporal_category = details
break
if not temporal_category:
temporal_category = proc_config["temporal_distribution"]["completed"]
# Calculate plan date
offset_days = random.randint(
temporal_category["offset_days_min"],
temporal_category["offset_days_max"]
)
plan_date = calculate_date_from_offset(offset_days)
# Select status
status = random.choice(temporal_category["statuses"])
# Select plan type
plan_type_choice = weighted_choice(proc_config["plan_types"])
plan_type = plan_type_choice["type"]
# Select priority
priority_rand = random.random()
cumulative_priority = 0
priority = "normal"
for p, weight in proc_config["priorities"].items():
cumulative_priority += weight
if priority_rand <= cumulative_priority:
priority = p
break
# Select procurement strategy
strategy_choice = weighted_choice(proc_config["procurement_strategies"])
procurement_strategy = strategy_choice["strategy"]
# Select supply risk level
risk_rand = random.random()
cumulative_risk = 0
supply_risk_level = "low"
for risk, weight in proc_config["risk_levels"].items():
cumulative_risk += weight
if risk_rand <= cumulative_risk:
supply_risk_level = risk
break
# Calculate planning horizon
planning_horizon = proc_config["planning_horizon_days"][business_model]
# Calculate period dates
period_start = plan_date
period_end = plan_date + timedelta(days=planning_horizon)
# Generate plan number
plan_number = generate_plan_number(tenant_id, i + 1, plan_type)
# Calculate safety stock buffer
safety_stock_buffer = Decimal(str(random.uniform(
proc_config["safety_stock_percentage"]["min"],
proc_config["safety_stock_percentage"]["max"]
)))
# Calculate approval/execution dates based on status
approved_at = None
execution_started_at = None
execution_completed_at = None
approved_by = None
if status in ["approved", "in_execution", "completed"]:
approved_at = calculate_datetime_from_offset(offset_days - 1)
approved_by = uuid.uuid4() # Would be actual user ID
if status in ["in_execution", "completed"]:
execution_started_at = calculate_datetime_from_offset(offset_days)
if status == "completed":
execution_completed_at = calculate_datetime_from_offset(offset_days + planning_horizon)
# Calculate performance metrics for completed plans
fulfillment_rate = None
on_time_delivery_rate = None
cost_accuracy = None
quality_score = None
if status == "completed":
metrics = proc_config["performance_metrics"]
fulfillment_rate = Decimal(str(random.uniform(
metrics["fulfillment_rate"]["min"],
metrics["fulfillment_rate"]["max"]
)))
on_time_delivery_rate = Decimal(str(random.uniform(
metrics["on_time_delivery"]["min"],
metrics["on_time_delivery"]["max"]
)))
cost_accuracy = Decimal(str(random.uniform(
metrics["cost_accuracy"]["min"],
metrics["cost_accuracy"]["max"]
)))
quality_score = Decimal(str(random.uniform(
metrics["quality_score"]["min"],
metrics["quality_score"]["max"]
)))
# Create procurement plan
plan = ProcurementPlan(
id=uuid.uuid4(),
tenant_id=tenant_id,
plan_number=plan_number,
plan_date=plan_date,
plan_period_start=period_start,
plan_period_end=period_end,
planning_horizon_days=planning_horizon,
status=status,
plan_type=plan_type,
priority=priority,
business_model=business_model,
procurement_strategy=procurement_strategy,
total_requirements=0, # Will update after adding requirements
total_estimated_cost=Decimal("0.00"), # Will calculate
total_approved_cost=Decimal("0.00"),
safety_stock_buffer=safety_stock_buffer,
supply_risk_level=supply_risk_level,
demand_forecast_confidence=Decimal(str(random.uniform(7.0, 9.5))),
approved_at=approved_at,
approved_by=approved_by,
execution_started_at=execution_started_at,
execution_completed_at=execution_completed_at,
fulfillment_rate=fulfillment_rate,
on_time_delivery_rate=on_time_delivery_rate,
cost_accuracy=cost_accuracy,
quality_score=quality_score,
created_at=calculate_datetime_from_offset(offset_days - 2),
updated_at=calculate_datetime_from_offset(offset_days)
)
db.add(plan)
await db.flush() # Get plan ID
# Generate requirements for this plan
num_requirements = random.randint(
proc_config["requirements_per_plan"]["min"],
proc_config["requirements_per_plan"]["max"]
)
# Select random ingredients
selected_ingredients = random.sample(
list(INGREDIENT_ID_MAP.keys()),
min(num_requirements, len(INGREDIENT_ID_MAP))
)
total_estimated_cost = Decimal("0.00")
for req_num, ingredient_sku in enumerate(selected_ingredients, 1):
# Get ingredient ID from hardcoded mapping
ingredient_id_str = INGREDIENT_ID_MAP.get(ingredient_sku)
if not ingredient_id_str:
logger.warning(f" ⚠️ Ingredient SKU not in mapping: {ingredient_sku}")
continue
# Generate tenant-specific ingredient ID
base_ingredient_id = uuid.UUID(ingredient_id_str)
tenant_int = int(tenant_id.hex, 16)
ingredient_id = uuid.UUID(int=tenant_int ^ int(base_ingredient_id.hex, 16))
# Get quantity range for category
category = ingredient_sku.split("-")[0] # HAR, LAC, LEV, BAS, ESP
cantidad_range = proc_config["quantity_ranges"].get(
category,
{"min": 50.0, "max": 200.0}
)
# Calculate required quantity
required_quantity = Decimal(str(random.uniform(
cantidad_range["min"],
cantidad_range["max"]
)))
# Calculate safety stock
safety_stock_quantity = required_quantity * (safety_stock_buffer / 100)
# Total quantity needed
total_quantity_needed = required_quantity + safety_stock_quantity
# Current stock simulation
current_stock_level = required_quantity * Decimal(str(random.uniform(0.1, 0.4)))
reserved_stock = current_stock_level * Decimal(str(random.uniform(0.0, 0.3)))
available_stock = current_stock_level - reserved_stock
# Net requirement
net_requirement = total_quantity_needed - available_stock
# Demand breakdown
order_demand = required_quantity * Decimal(str(random.uniform(0.5, 0.7)))
production_demand = required_quantity * Decimal(str(random.uniform(0.2, 0.4)))
forecast_demand = required_quantity * Decimal(str(random.uniform(0.05, 0.15)))
buffer_demand = safety_stock_quantity
# Pricing
estimated_unit_cost = Decimal(str(INGREDIENT_COSTS.get(ingredient_sku, 1.0))) * Decimal(str(random.uniform(0.95, 1.05)))
estimated_total_cost = estimated_unit_cost * net_requirement
# Timing
lead_time_days = random.randint(1, 5)
required_by_date = period_start + timedelta(days=random.randint(3, planning_horizon - 2))
lead_time_buffer_days = random.randint(1, 2)
suggested_order_date = required_by_date - timedelta(days=lead_time_days + lead_time_buffer_days)
latest_order_date = required_by_date - timedelta(days=lead_time_days)
# Requirement status based on plan status
if status == "draft":
req_status = "pending"
elif status == "pending_approval":
req_status = "pending"
elif status == "approved":
req_status = "approved"
elif status == "in_execution":
req_status = random.choice(["ordered", "partially_received"])
elif status == "completed":
req_status = "received"
else:
req_status = "pending"
# Requirement priority
if priority == "critical":
req_priority = "critical"
elif priority == "high":
req_priority = random.choice(["high", "critical"])
else:
req_priority = random.choice(["normal", "high"])
# Risk level
if supply_risk_level == "critical":
req_risk_level = random.choice(["high", "critical"])
elif supply_risk_level == "high":
req_risk_level = random.choice(["medium", "high"])
else:
req_risk_level = "low"
# Create requirement
requirement = ProcurementRequirement(
id=uuid.uuid4(),
plan_id=plan.id,
requirement_number=f"{plan_number}-REQ-{req_num:03d}",
product_id=ingredient_id,
product_name=f"Ingrediente {ingredient_sku}",
product_sku=ingredient_sku,
product_category=category,
product_type="ingredient",
required_quantity=required_quantity,
unit_of_measure="kg",
safety_stock_quantity=safety_stock_quantity,
total_quantity_needed=total_quantity_needed,
current_stock_level=current_stock_level,
reserved_stock=reserved_stock,
available_stock=available_stock,
net_requirement=net_requirement,
order_demand=order_demand,
production_demand=production_demand,
forecast_demand=forecast_demand,
buffer_demand=buffer_demand,
supplier_lead_time_days=lead_time_days,
minimum_order_quantity=Decimal(str(random.choice([1, 5, 10, 25]))),
estimated_unit_cost=estimated_unit_cost,
estimated_total_cost=estimated_total_cost,
required_by_date=required_by_date,
lead_time_buffer_days=lead_time_buffer_days,
suggested_order_date=suggested_order_date,
latest_order_date=latest_order_date,
shelf_life_days=random.choice([30, 60, 90, 180, 365]),
status=req_status,
priority=req_priority,
risk_level=req_risk_level,
created_at=plan.created_at,
updated_at=plan.updated_at
)
db.add(requirement)
total_estimated_cost += estimated_total_cost
requirements_created += 1
# Update plan totals
plan.total_requirements = num_requirements
plan.total_estimated_cost = total_estimated_cost
if status in ["approved", "in_execution", "completed"]:
plan.total_approved_cost = total_estimated_cost * Decimal(str(random.uniform(0.95, 1.05)))
plans_created += 1
await db.commit()
logger.info(f" 📊 Successfully created {plans_created} plans with {requirements_created} requirements for {tenant_name}")
logger.info("")
return {
"tenant_id": str(tenant_id),
"plans_created": plans_created,
"requirements_created": requirements_created,
"skipped": False
}
async def seed_all(db: AsyncSession):
"""Seed all demo tenants with procurement data"""
logger.info("=" * 80)
logger.info("🚚 Starting Demo Procurement Plans Seeding")
logger.info("=" * 80)
# Load configuration
config = {
"procurement_config": {
"plans_per_tenant": 8,
"requirements_per_plan": {"min": 3, "max": 8},
"planning_horizon_days": {
"individual_bakery": 30,
"central_bakery": 45
},
"safety_stock_percentage": {"min": 15.0, "max": 25.0},
"temporal_distribution": {
"completed": {
"percentage": 0.3,
"offset_days_min": -15,
"offset_days_max": -1,
"statuses": ["completed"]
},
"in_execution": {
"percentage": 0.2,
"offset_days_min": -5,
"offset_days_max": 2,
"statuses": ["in_execution", "partially_received"]
},
"approved": {
"percentage": 0.2,
"offset_days_min": -2,
"offset_days_max": 1,
"statuses": ["approved"]
},
"pending_approval": {
"percentage": 0.15,
"offset_days_min": 0,
"offset_days_max": 3,
"statuses": ["pending_approval"]
},
"draft": {
"percentage": 0.15,
"offset_days_min": 0,
"offset_days_max": 5,
"statuses": ["draft"]
}
},
"plan_types": [
{"type": "regular", "weight": 0.7},
{"type": "seasonal", "weight": 0.2},
{"type": "emergency", "weight": 0.1}
],
"priorities": {
"normal": 0.7,
"high": 0.25,
"critical": 0.05
},
"procurement_strategies": [
{"strategy": "just_in_time", "weight": 0.6},
{"strategy": "bulk", "weight": 0.3},
{"strategy": "mixed", "weight": 0.1}
],
"risk_levels": {
"low": 0.6,
"medium": 0.3,
"high": 0.08,
"critical": 0.02
},
"quantity_ranges": {
"HAR": {"min": 50.0, "max": 500.0}, # Harinas
"LAC": {"min": 20.0, "max": 200.0}, # Lácteos
"LEV": {"min": 5.0, "max": 50.0}, # Levaduras
"BAS": {"min": 10.0, "max": 100.0}, # Básicos
"ESP": {"min": 1.0, "max": 20.0} # Especiales
},
"performance_metrics": {
"fulfillment_rate": {"min": 85.0, "max": 98.0},
"on_time_delivery": {"min": 80.0, "max": 95.0},
"cost_accuracy": {"min": 90.0, "max": 99.0},
"quality_score": {"min": 7.0, "max": 9.5}
}
}
}
results = []
# Seed San Pablo (Individual Bakery)
result_san_pablo = await generate_procurement_for_tenant(
db,
DEMO_TENANT_SAN_PABLO,
"Panadería San Pablo (Individual Bakery)",
"individual_bakery",
config
)
results.append(result_san_pablo)
# Seed La Espiga (Central Bakery)
result_la_espiga = await generate_procurement_for_tenant(
db,
DEMO_TENANT_LA_ESPIGA,
"Panadería La Espiga (Central Bakery)",
"central_bakery",
config
)
results.append(result_la_espiga)
total_plans = sum(r["plans_created"] for r in results)
total_requirements = sum(r["requirements_created"] for r in results)
logger.info("=" * 80)
logger.info("✅ Demo Procurement Plans Seeding Completed")
logger.info("=" * 80)
return {
"results": results,
"total_plans_created": total_plans,
"total_requirements_created": total_requirements,
"status": "completed"
}
async def main():
"""Main execution function"""
logger.info("Demo Procurement Plans Seeding Script Starting")
logger.info("Mode: %s", os.getenv("DEMO_MODE", "development"))
logger.info("Log Level: %s", os.getenv("LOG_LEVEL", "INFO"))
# Get database URL from environment
database_url = os.getenv("PROCUREMENT_DATABASE_URL") or os.getenv("DATABASE_URL")
if not database_url:
logger.error("❌ PROCUREMENT_DATABASE_URL or DATABASE_URL environment variable must be set")
return 1
# Ensure asyncpg driver
if database_url.startswith("postgresql://"):
database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
logger.info("Connecting to procurement database")
# Create async engine
engine = create_async_engine(
database_url,
echo=False,
pool_pre_ping=True,
pool_size=5,
max_overflow=10
)
async_session = sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False
)
try:
async with async_session() as session:
result = await seed_all(session)
logger.info("")
logger.info("📊 Seeding Summary:")
logger.info(f" ✅ Total Plans: {result['total_plans_created']}")
logger.info(f" ✅ Total Requirements: {result['total_requirements_created']}")
logger.info(f" ✅ Status: {result['status']}")
logger.info("")
# Print per-tenant details
for tenant_result in result["results"]:
tenant_id = tenant_result["tenant_id"]
plans = tenant_result["plans_created"]
requirements = tenant_result["requirements_created"]
skipped = tenant_result.get("skipped", False)
status = "SKIPPED (already exists)" if skipped else f"CREATED {plans} plans, {requirements} requirements"
logger.info(f" Tenant {tenant_id}: {status}")
logger.info("")
logger.info("🎉 Success! Procurement plans are ready for demo sessions.")
logger.info("")
logger.info("Plans created:")
logger.info(" • 8 Regular procurement plans per tenant")
logger.info(" • 3-8 Requirements per plan")
logger.info(" • Various statuses: draft, pending, approved, in execution, completed")
logger.info(" • Different priorities and risk levels")
logger.info("")
logger.info("Note: All IDs are pre-defined and hardcoded for cross-service consistency")
logger.info("")
return 0
except Exception as e:
logger.error("=" * 80)
logger.error("❌ Demo Procurement Plans Seeding Failed")
logger.error("=" * 80)
logger.error("Error: %s", str(e))
logger.error("", exc_info=True)
return 1
finally:
await engine.dispose()
if __name__ == "__main__":
exit_code = asyncio.run(main())
sys.exit(exit_code)

View File

@@ -0,0 +1,430 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Demo Purchase Orders Seeding Script for Procurement Service
Creates realistic PO scenarios in various states for demo purposes
This script creates:
- 3 PENDING_APPROVAL POs (created today, need user action)
- 2 APPROVED POs (approved yesterday, in progress)
- 1 AUTO_APPROVED PO (small amount, trusted supplier)
- 2 COMPLETED POs (delivered last week)
- 1 REJECTED PO (quality concerns)
- 1 CANCELLED PO (supplier unavailable)
"""
import asyncio
import uuid
import sys
import os
import random
from datetime import datetime, timezone, timedelta, date
from pathlib import Path
from decimal import Decimal
# Add app to path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy import select
import structlog
from app.models.purchase_order import (
PurchaseOrder, PurchaseOrderItem, PurchaseOrderStatus
)
# Configure logging
logger = structlog.get_logger()
# Demo tenant IDs (match those from orders service)
DEMO_TENANT_IDS = [
uuid.UUID("a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6"), # San Pablo
uuid.UUID("b2c3d4e5-f6a7-48b9-c0d1-e2f3a4b5c6d7") # La Espiga
]
# System user ID for auto-approvals
SYSTEM_USER_ID = uuid.UUID("50000000-0000-0000-0000-000000000004")
# Hardcoded base supplier IDs (must match those in suppliers seed script)
BASE_SUPPLIER_IDS = [
uuid.UUID("40000000-0000-0000-0000-000000000001"), # Molinos San José S.L. (high trust)
uuid.UUID("40000000-0000-0000-0000-000000000002"), # Lácteos del Valle S.A. (medium trust)
uuid.UUID("40000000-0000-0000-0000-000000000005"), # Lesaffre Ibérica (low trust)
]
def get_demo_supplier_ids(tenant_id: uuid.UUID):
"""
Generate tenant-specific supplier IDs using XOR strategy with hardcoded base IDs.
This maintains consistency across services without cross-database access.
"""
# Generate tenant-specific supplier IDs using XOR with tenant ID
tenant_int = int(tenant_id.hex, 16)
class SupplierRef:
def __init__(self, supplier_id, supplier_name, trust_level):
self.id = supplier_id
self.name = supplier_name
self.trust_score = trust_level
suppliers = []
trust_scores = [0.92, 0.75, 0.65] # High, medium, low trust
supplier_names = [
"Molinos San José S.L.",
"Lácteos del Valle S.A.",
"Lesaffre Ibérica"
]
for i, base_id in enumerate(BASE_SUPPLIER_IDS):
base_int = int(base_id.hex, 16)
supplier_id = uuid.UUID(int=tenant_int ^ base_int)
suppliers.append(SupplierRef(
supplier_id,
supplier_names[i],
trust_scores[i] if i < len(trust_scores) else 0.5
))
return suppliers
async def create_purchase_order(
db: AsyncSession,
tenant_id: uuid.UUID,
supplier,
status: PurchaseOrderStatus,
total_amount: Decimal,
created_offset_days: int = 0,
auto_approved: bool = False,
priority: str = "normal",
items_data: list = None
) -> PurchaseOrder:
"""Create a purchase order with items"""
created_at = datetime.now(timezone.utc) + timedelta(days=created_offset_days)
required_delivery = created_at + timedelta(days=random.randint(3, 7))
# Generate PO number
po_number = f"PO-{datetime.now().year}-{random.randint(100, 999)}"
# Calculate amounts
subtotal = total_amount
tax_amount = subtotal * Decimal("0.10") # 10% IVA
shipping_cost = Decimal(str(random.uniform(0, 20)))
total = subtotal + tax_amount + shipping_cost
# Create PO
po = PurchaseOrder(
id=uuid.uuid4(),
tenant_id=tenant_id,
supplier_id=supplier.id,
po_number=po_number,
status=status,
priority=priority,
order_date=created_at,
required_delivery_date=required_delivery,
subtotal=subtotal,
tax_amount=tax_amount,
shipping_cost=shipping_cost,
discount_amount=Decimal("0.00"),
total_amount=total,
notes=f"Auto-generated demo PO from procurement plan" if not auto_approved else f"Auto-approved: Amount €{subtotal:.2f} within threshold",
created_at=created_at,
updated_at=created_at,
created_by=SYSTEM_USER_ID,
updated_by=SYSTEM_USER_ID
)
# Set approval data if approved
if status in [PurchaseOrderStatus.approved, PurchaseOrderStatus.sent_to_supplier,
PurchaseOrderStatus.confirmed, PurchaseOrderStatus.completed]:
po.approved_at = created_at + timedelta(hours=random.randint(1, 6))
po.approved_by = SYSTEM_USER_ID if auto_approved else uuid.uuid4()
if auto_approved:
po.notes = f"{po.notes}\nAuto-approved by system based on trust score and amount"
# Set sent/confirmed dates
if status in [PurchaseOrderStatus.sent_to_supplier, PurchaseOrderStatus.confirmed,
PurchaseOrderStatus.completed]:
po.sent_to_supplier_at = po.approved_at + timedelta(hours=2)
if status in [PurchaseOrderStatus.confirmed, PurchaseOrderStatus.completed]:
po.supplier_confirmation_date = po.sent_to_supplier_at + timedelta(hours=random.randint(4, 24))
db.add(po)
await db.flush()
# Create items
if not items_data:
items_data = [
{"name": "Harina de Trigo T55", "quantity": 100, "unit_price": 0.85, "uom": "kg"},
{"name": "Levadura Fresca", "quantity": 5, "unit_price": 4.50, "uom": "kg"},
{"name": "Sal Marina", "quantity": 10, "unit_price": 1.20, "uom": "kg"}
]
for idx, item_data in enumerate(items_data, 1):
ordered_qty = int(item_data["quantity"])
unit_price = Decimal(str(item_data["unit_price"]))
line_total = Decimal(str(ordered_qty)) * unit_price
item = PurchaseOrderItem(
id=uuid.uuid4(),
purchase_order_id=po.id,
tenant_id=tenant_id,
inventory_product_id=uuid.uuid4(), # Would link to actual inventory items
product_code=f"PROD-{item_data['name'][:3].upper()}",
product_name=item_data['name'],
ordered_quantity=ordered_qty,
received_quantity=ordered_qty if status == PurchaseOrderStatus.completed else 0,
remaining_quantity=0 if status == PurchaseOrderStatus.completed else ordered_qty,
unit_price=unit_price,
line_total=line_total,
unit_of_measure=item_data["uom"],
item_notes=f"Demo item: {item_data['name']}"
)
db.add(item)
logger.info(f"Created PO: {po_number}", po_id=str(po.id), status=status.value, amount=float(total))
return po
async def seed_purchase_orders_for_tenant(db: AsyncSession, tenant_id: uuid.UUID):
"""Seed purchase orders for a specific tenant"""
logger.info("Seeding purchase orders", tenant_id=str(tenant_id))
# Get demo supplier IDs (suppliers exist in the suppliers service)
suppliers = get_demo_supplier_ids(tenant_id)
# Group suppliers by trust level for easier access
high_trust_suppliers = [s for s in suppliers if s.trust_score >= 0.85]
medium_trust_suppliers = [s for s in suppliers if 0.6 <= s.trust_score < 0.85]
low_trust_suppliers = [s for s in suppliers if s.trust_score < 0.6]
# Use first supplier of each type if available
supplier_high_trust = high_trust_suppliers[0] if high_trust_suppliers else suppliers[0]
supplier_medium_trust = medium_trust_suppliers[0] if medium_trust_suppliers else suppliers[1] if len(suppliers) > 1 else suppliers[0]
supplier_low_trust = low_trust_suppliers[0] if low_trust_suppliers else suppliers[-1]
pos_created = []
# 1. PENDING_APPROVAL - Critical/Urgent (created today)
po1 = await create_purchase_order(
db, tenant_id, supplier_medium_trust,
PurchaseOrderStatus.pending_approval,
Decimal("1234.56"),
created_offset_days=0,
priority="high",
items_data=[
{"name": "Harina Integral Ecológica", "quantity": 150, "unit_price": 1.20, "uom": "kg"},
{"name": "Semillas de Girasol", "quantity": 20, "unit_price": 3.50, "uom": "kg"},
{"name": "Miel de Azahar", "quantity": 10, "unit_price": 8.90, "uom": "kg"}
]
)
pos_created.append(po1)
# 2. PENDING_APPROVAL - Medium amount, new supplier (created today)
po2 = await create_purchase_order(
db, tenant_id, supplier_low_trust,
PurchaseOrderStatus.pending_approval,
Decimal("789.00"),
created_offset_days=0,
items_data=[
{"name": "Aceite de Oliva Virgen", "quantity": 30, "unit_price": 8.50, "uom": "l"},
{"name": "Azúcar Moreno", "quantity": 50, "unit_price": 1.80, "uom": "kg"}
]
)
pos_created.append(po2)
# 3. PENDING_APPROVAL - Large amount (created yesterday)
po3 = await create_purchase_order(
db, tenant_id, supplier_medium_trust,
PurchaseOrderStatus.pending_approval,
Decimal("250.00"),
created_offset_days=-1,
priority="normal",
items_data=[
{"name": "Harina de Fuerza T65", "quantity": 500, "unit_price": 0.95, "uom": "kg"},
{"name": "Mantequilla Premium", "quantity": 80, "unit_price": 5.20, "uom": "kg"},
{"name": "Huevos Categoría A", "quantity": 600, "unit_price": 0.22, "uom": "unidad"}
]
)
pos_created.append(po3)
# 4. APPROVED (auto-approved, small amount, trusted supplier)
po4 = await create_purchase_order(
db, tenant_id, supplier_high_trust,
PurchaseOrderStatus.approved,
Decimal("234.50"),
created_offset_days=0,
auto_approved=True,
items_data=[
{"name": "Levadura Seca", "quantity": 5, "unit_price": 6.90, "uom": "kg"},
{"name": "Sal Fina", "quantity": 25, "unit_price": 0.85, "uom": "kg"}
]
)
pos_created.append(po4)
# 5. APPROVED (manually approved yesterday)
po5 = await create_purchase_order(
db, tenant_id, supplier_high_trust,
PurchaseOrderStatus.approved,
Decimal("456.78"),
created_offset_days=-1,
items_data=[
{"name": "Bolsas de Papel Kraft", "quantity": 1000, "unit_price": 0.12, "uom": "unidad"},
{"name": "Cajas de Cartón Grande", "quantity": 200, "unit_price": 0.45, "uom": "unidad"}
]
)
pos_created.append(po5)
# 6. COMPLETED (delivered last week)
po6 = await create_purchase_order(
db, tenant_id, supplier_high_trust,
PurchaseOrderStatus.completed,
Decimal("1567.80"),
created_offset_days=-7,
items_data=[
{"name": "Harina T55 Premium", "quantity": 300, "unit_price": 0.90, "uom": "kg"},
{"name": "Chocolate Negro 70%", "quantity": 40, "unit_price": 7.80, "uom": "kg"}
]
)
pos_created.append(po6)
# 7. COMPLETED (delivered 5 days ago)
po7 = await create_purchase_order(
db, tenant_id, supplier_medium_trust,
PurchaseOrderStatus.completed,
Decimal("890.45"),
created_offset_days=-5,
items_data=[
{"name": "Nueces Peladas", "quantity": 20, "unit_price": 12.50, "uom": "kg"},
{"name": "Pasas Sultanas", "quantity": 15, "unit_price": 4.30, "uom": "kg"}
]
)
pos_created.append(po7)
# 8. CANCELLED (supplier unavailable)
po8 = await create_purchase_order(
db, tenant_id, supplier_low_trust,
PurchaseOrderStatus.cancelled,
Decimal("345.00"),
created_offset_days=-3,
items_data=[
{"name": "Avellanas Tostadas", "quantity": 25, "unit_price": 11.80, "uom": "kg"}
]
)
po8.rejection_reason = "Supplier unable to deliver - stock unavailable"
po8.notes = "Cancelled: Supplier stock unavailable at required delivery date"
pos_created.append(po8)
# 9. DISPUTED (quality issues)
po9 = await create_purchase_order(
db, tenant_id, supplier_medium_trust,
PurchaseOrderStatus.disputed,
Decimal("678.90"),
created_offset_days=-4,
priority="high",
items_data=[
{"name": "Cacao en Polvo", "quantity": 30, "unit_price": 18.50, "uom": "kg"},
{"name": "Vainilla en Rama", "quantity": 2, "unit_price": 45.20, "uom": "kg"}
]
)
po9.rejection_reason = "Quality below specifications - requesting replacement"
po9.notes = "DISPUTED: Quality issue reported - batch rejected, requesting replacement or refund"
pos_created.append(po9)
await db.commit()
logger.info(
f"Successfully created {len(pos_created)} purchase orders for tenant",
tenant_id=str(tenant_id),
pending_approval=3,
approved=2,
completed=2,
cancelled=1,
disputed=1
)
return pos_created
async def seed_all(db: AsyncSession):
"""Seed all demo tenants with purchase orders"""
logger.info("Starting demo purchase orders seed process")
all_pos = []
for tenant_id in DEMO_TENANT_IDS:
# Check if POs already exist
result = await db.execute(
select(PurchaseOrder).where(PurchaseOrder.tenant_id == tenant_id).limit(1)
)
existing = result.scalar_one_or_none()
if existing:
logger.info(f"Purchase orders already exist for tenant {tenant_id}, skipping")
continue
pos = await seed_purchase_orders_for_tenant(db, tenant_id)
all_pos.extend(pos)
return {
"total_pos_created": len(all_pos),
"tenants_seeded": len(DEMO_TENANT_IDS),
"status": "completed"
}
async def main():
"""Main execution function"""
# Get database URL from environment
database_url = os.getenv("PROCUREMENT_DATABASE_URL")
if not database_url:
logger.error("PROCUREMENT_DATABASE_URL environment variable must be set")
return 1
# Ensure asyncpg driver
if database_url.startswith("postgresql://"):
database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
# Create async engine
engine = create_async_engine(database_url, echo=False)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
try:
async with async_session() as session:
result = await seed_all(session)
logger.info(
"Purchase orders seed completed successfully!",
total_pos=result["total_pos_created"],
tenants=result["tenants_seeded"]
)
# Print summary
print("\n" + "="*60)
print("DEMO PURCHASE ORDERS SEED SUMMARY")
print("="*60)
print(f"Total POs Created: {result['total_pos_created']}")
print(f"Tenants Seeded: {result['tenants_seeded']}")
print("\nPO Distribution:")
print(" - 3 PENDING_APPROVAL (need user action)")
print(" - 2 APPROVED (in progress)")
print(" - 2 COMPLETED (delivered)")
print(" - 1 CANCELLED (supplier issue)")
print(" - 1 DISPUTED (quality issue)")
print("="*60 + "\n")
return 0
except Exception as e:
logger.error(f"Purchase orders seed failed: {str(e)}", exc_info=True)
return 1
finally:
await engine.dispose()
if __name__ == "__main__":
exit_code = asyncio.run(main())
sys.exit(exit_code)