Improve the frontend 3
This commit is contained in:
44
services/procurement/Dockerfile
Normal file
44
services/procurement/Dockerfile
Normal 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"]
|
||||
104
services/procurement/alembic.ini
Normal file
104
services/procurement/alembic.ini
Normal 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
|
||||
0
services/procurement/app/__init__.py
Normal file
0
services/procurement/app/__init__.py
Normal file
13
services/procurement/app/api/__init__.py
Normal file
13
services/procurement/app/api/__init__.py
Normal 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"
|
||||
]
|
||||
523
services/procurement/app/api/internal_demo.py
Normal file
523
services/procurement/app/api/internal_demo.py
Normal 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))
|
||||
319
services/procurement/app/api/procurement_plans.py
Normal file
319
services/procurement/app/api/procurement_plans.py
Normal 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))
|
||||
458
services/procurement/app/api/purchase_orders.py
Normal file
458
services/procurement/app/api/purchase_orders.py
Normal 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))
|
||||
430
services/procurement/app/api/replenishment.py
Normal file
430
services/procurement/app/api/replenishment.py
Normal 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))
|
||||
0
services/procurement/app/core/__init__.py
Normal file
0
services/procurement/app/core/__init__.py
Normal file
142
services/procurement/app/core/config.py
Normal file
142
services/procurement/app/core/config.py
Normal 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()
|
||||
47
services/procurement/app/core/database.py
Normal file
47
services/procurement/app/core/database.py
Normal 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()
|
||||
44
services/procurement/app/core/dependencies.py
Normal file
44
services/procurement/app/core/dependencies.py
Normal 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"]
|
||||
130
services/procurement/app/main.py
Normal file
130
services/procurement/app/main.py
Normal 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
|
||||
)
|
||||
38
services/procurement/app/models/__init__.py
Normal file
38
services/procurement/app/models/__init__.py
Normal 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",
|
||||
]
|
||||
234
services/procurement/app/models/procurement_plan.py
Normal file
234
services/procurement/app/models/procurement_plan.py
Normal 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")
|
||||
348
services/procurement/app/models/purchase_order.py
Normal file
348
services/procurement/app/models/purchase_order.py
Normal 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'),
|
||||
)
|
||||
194
services/procurement/app/models/replenishment.py
Normal file
194
services/procurement/app/models/replenishment.py
Normal 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)
|
||||
0
services/procurement/app/repositories/__init__.py
Normal file
0
services/procurement/app/repositories/__init__.py
Normal file
62
services/procurement/app/repositories/base_repository.py
Normal file
62
services/procurement/app/repositories/base_repository.py
Normal 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
|
||||
@@ -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}"
|
||||
@@ -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}"
|
||||
79
services/procurement/app/schemas/__init__.py
Normal file
79
services/procurement/app/schemas/__init__.py
Normal 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",
|
||||
]
|
||||
368
services/procurement/app/schemas/procurement_schemas.py
Normal file
368
services/procurement/app/schemas/procurement_schemas.py
Normal 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
|
||||
364
services/procurement/app/schemas/purchase_order_schemas.py
Normal file
364
services/procurement/app/schemas/purchase_order_schemas.py
Normal 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
|
||||
440
services/procurement/app/schemas/replenishment.py
Normal file
440
services/procurement/app/schemas/replenishment.py
Normal 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
|
||||
18
services/procurement/app/services/__init__.py
Normal file
18
services/procurement/app/services/__init__.py
Normal 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",
|
||||
]
|
||||
429
services/procurement/app/services/inventory_projector.py
Normal file
429
services/procurement/app/services/inventory_projector.py
Normal 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
|
||||
]
|
||||
}
|
||||
366
services/procurement/app/services/lead_time_planner.py
Normal file
366
services/procurement/app/services/lead_time_planner.py
Normal 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
|
||||
458
services/procurement/app/services/moq_aggregator.py
Normal file
458
services/procurement/app/services/moq_aggregator.py
Normal 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
|
||||
}
|
||||
568
services/procurement/app/services/procurement_service.py
Normal file
568
services/procurement/app/services/procurement_service.py
Normal 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}")
|
||||
652
services/procurement/app/services/purchase_order_service.py
Normal file
652
services/procurement/app/services/purchase_order_service.py
Normal 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, [])
|
||||
376
services/procurement/app/services/recipe_explosion_service.py
Normal file
376
services/procurement/app/services/recipe_explosion_service.py
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
]
|
||||
}
|
||||
439
services/procurement/app/services/safety_stock_calculator.py
Normal file
439
services/procurement/app/services/safety_stock_calculator.py
Normal 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
|
||||
}
|
||||
444
services/procurement/app/services/shelf_life_manager.py
Normal file
444
services/procurement/app/services/shelf_life_manager.py
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
538
services/procurement/app/services/supplier_selector.py
Normal file
538
services/procurement/app/services/supplier_selector.py
Normal 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
|
||||
]
|
||||
}
|
||||
150
services/procurement/migrations/env.py
Normal file
150
services/procurement/migrations/env.py
Normal 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()
|
||||
26
services/procurement/migrations/script.py.mako
Normal file
26
services/procurement/migrations/script.py.mako
Normal 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"}
|
||||
@@ -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")
|
||||
@@ -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')
|
||||
44
services/procurement/requirements.txt
Normal file
44
services/procurement/requirements.txt
Normal 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
|
||||
678
services/procurement/scripts/demo/seed_demo_procurement_plans.py
Normal file
678
services/procurement/scripts/demo/seed_demo_procurement_plans.py
Normal 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)
|
||||
430
services/procurement/scripts/demo/seed_demo_purchase_orders.py
Normal file
430
services/procurement/scripts/demo/seed_demo_purchase_orders.py
Normal 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)
|
||||
Reference in New Issue
Block a user