Consolidated incremental migrations into single unified initial schema files for both procurement and production services. This simplifies database setup and eliminates migration chain complexity. Changes: - Procurement: Merged 3 migrations into 001_unified_initial_schema.py - Initial schema (20251015_1229) - Add supplier_price_list_id (20251030_0737) - Add JTBD reasoning fields (20251107) - Production: Merged 3 migrations into 001_unified_initial_schema.py - Initial schema (20251015_1231) - Add waste tracking fields (20251023_0900) - Add JTBD reasoning fields (20251107) All new fields (reasoning, consequence, reasoning_data, waste_defect_type, is_ai_assisted, supplier_price_list_id) are now included in the initial schemas from the start. Updated model files to use deferred() for reasoning fields to prevent breaking queries when running against existing databases.
362 lines
16 KiB
Python
362 lines
16 KiB
Python
# ================================================================
|
|
# 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, deferred
|
|
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)
|
|
|
|
# JTBD Dashboard: Reasoning and consequences for user transparency
|
|
# Deferred loading to prevent breaking queries when columns don't exist yet
|
|
reasoning = deferred(Column(Text, nullable=True)) # Why this PO was created (e.g., "Low flour stock (2 days left)")
|
|
consequence = deferred(Column(Text, nullable=True)) # What happens if not approved (e.g., "Stock out risk in 48 hours")
|
|
reasoning_data = deferred(Column(JSONB, nullable=True)) # Structured reasoning data
|
|
# reasoning_data structure: {
|
|
# "trigger": "low_stock" | "forecast_demand" | "manual",
|
|
# "ingredients_affected": [{"id": "uuid", "name": "Flour", "current_stock": 10, "days_remaining": 2}],
|
|
# "orders_impacted": [{"id": "uuid", "product": "Baguette", "quantity": 100}],
|
|
# "urgency_score": 0-100,
|
|
# "estimated_stock_out_date": "2025-11-10T00:00:00Z"
|
|
# }
|
|
|
|
# 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'),
|
|
)
|