from datetime import datetime, date from decimal import Decimal from typing import Optional, TYPE_CHECKING from sqlalchemy import String, DateTime, Date, ForeignKey, Numeric, Text, Enum, Integer from sqlalchemy.orm import Mapped, mapped_column, relationship from database import Base import enum if TYPE_CHECKING: from .line_item import LineItem class InvoiceStatus(str, enum.Enum): PENDING = "PENDING" # Uploaded, awaiting OCR PROCESSED = "PROCESSED" # OCR complete, awaiting review REVIEWED = "REVIEWED" # User has reviewed/corrected CONFIRMED = "CONFIRMED" # Confirmed and included in GP class Invoice(Base): __tablename__ = "invoices" id: Mapped[int] = mapped_column(primary_key=True, index=True) kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), nullable=False) supplier_id: Mapped[int] = mapped_column(ForeignKey("suppliers.id"), nullable=True) # Extracted data invoice_number: Mapped[str] = mapped_column(String(100), nullable=True) invoice_date: Mapped[date] = mapped_column(Date, nullable=True) total: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=True) # Gross total (inc. VAT) net_total: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=True) # Net total (exc. VAT) vendor_name: Mapped[str] = mapped_column(String(255), nullable=True) # OCR-extracted vendor name supplier_match_type: Mapped[str] = mapped_column(String(20), nullable=True) # "exact", "fuzzy", or null # Document type and order tracking document_type: Mapped[str] = mapped_column(String(50), nullable=True, default="invoice") order_number: Mapped[str] = mapped_column(String(100), nullable=True) # Duplicate detection duplicate_status: Mapped[str] = mapped_column(String(50), nullable=True) duplicate_of_id: Mapped[Optional[int]] = mapped_column( Integer, ForeignKey("invoices.id", ondelete="SET NULL"), nullable=True ) related_document_id: Mapped[Optional[int]] = mapped_column( Integer, ForeignKey("invoices.id", ondelete="SET NULL"), nullable=True ) # OCR metadata image_path: Mapped[str] = mapped_column(String(500), nullable=False) ocr_raw_text: Mapped[str] = mapped_column(Text, nullable=True) ocr_raw_json: Mapped[str] = mapped_column(Text, nullable=True) # Full Azure response JSON for debugging/remapping ocr_confidence: Mapped[float] = mapped_column(Numeric(5, 4), nullable=True) # Status tracking status: Mapped[InvoiceStatus] = mapped_column( Enum(InvoiceStatus), default=InvoiceStatus.PENDING ) # Category for GP breakdown category: Mapped[str] = mapped_column(String(50), nullable=True, default="food") # Timestamps created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) # Dext integration notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True) dext_sent_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) dext_sent_by_user_id: Mapped[Optional[int]] = mapped_column( ForeignKey("users.id"), nullable=True ) # File storage tracking (for Nextcloud archival) file_storage_location: Mapped[str] = mapped_column(String(20), default="local") # "local" or "nextcloud" nextcloud_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) archived_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) original_local_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) # Source tracking - where did this invoice come from? source: Mapped[str] = mapped_column(String(50), default="upload") # upload, email, api source_reference: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) # email subject, API caller, etc. # Dispute linking - for credit notes linked to disputes linked_dispute_id: Mapped[Optional[int]] = mapped_column( Integer, ForeignKey("invoice_disputes.id", ondelete="SET NULL"), nullable=True ) # Relationships kitchen: Mapped["Kitchen"] = relationship("Kitchen", back_populates="invoices") supplier: Mapped["Supplier"] = relationship("Supplier", back_populates="invoices") line_items: Mapped[list["LineItem"]] = relationship( "LineItem", back_populates="invoice", cascade="all, delete-orphan", order_by="LineItem.line_number" ) # Dispute tracking relationships disputes: Mapped[list["InvoiceDispute"]] = relationship( "InvoiceDispute", back_populates="invoice", cascade="all, delete-orphan", foreign_keys="InvoiceDispute.invoice_id" ) credit_notes: Mapped[list["CreditNote"]] = relationship( "CreditNote", back_populates="invoice" ) cost_distributions: Mapped[list["CostDistribution"]] = relationship( "CostDistribution", back_populates="invoice" ) # Self-referential relationships for duplicate tracking duplicate_of: Mapped[Optional["Invoice"]] = relationship( "Invoice", remote_side=[id], foreign_keys=[duplicate_of_id], uselist=False ) related_document: Mapped[Optional["Invoice"]] = relationship( "Invoice", remote_side=[id], foreign_keys=[related_document_id], uselist=False ) # Dext integration relationship dext_sent_by_user: Mapped[Optional["User"]] = relationship( "User", foreign_keys=[dext_sent_by_user_id] ) # Linked dispute relationship (for credit notes) linked_dispute: Mapped[Optional["InvoiceDispute"]] = relationship( "InvoiceDispute", foreign_keys=[linked_dispute_id], uselist=False ) # Forward references from .user import Kitchen, User from .supplier import Supplier from .line_item import LineItem from .dispute import InvoiceDispute, CreditNote from .cost_distribution import CostDistribution