"""SQLAlchemy models for the QUOTATION & PROCUREMENT domain — mirrors migration.sql exactly.""" import uuid from datetime import date, datetime from decimal import Decimal from sqlalchemy import ( Boolean, Column, Date, DateTime, Enum, ForeignKey, Numeric, SmallInteger, Table, Text, Uuid, UniqueConstraint, func, text, ) from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column, relationship from app.db.base import Base from app.models.catalog import Material, Unit, _timestamptz, _uuid_pk user_role_enum = Enum( "pm", "procurement_manager", "admin", name="user_role", native_enum=True, ) quote_status_enum = Enum( "offered", "accepted", "arrived", "rejected", "expired", name="quote_status", native_enum=True, ) termsheet_status_enum = Enum( "offered", "accepted", "rejected", name="termsheet_status", native_enum=True, ) discount_type_enum = Enum( "none", "percent", "fixed", name="discount_type", native_enum=True, ) item_decision_status_enum = Enum( "pending", "chosen", "rejected", name="item_decision_status", native_enum=True, ) quote_event_type_enum = Enum( "offered", "accepted", "rejected", "arrived", "note", "edited", "expired", name="quote_event_type", native_enum=True, ) class User(Base): """users — Casdoor-backed identity. role is OUR authorization source.""" __tablename__ = "users" id: Mapped[uuid.UUID] = _uuid_pk() casdoor_id: Mapped[str] = mapped_column(Text, nullable=False, unique=True) first_name: Mapped[str] = mapped_column(Text, nullable=False) last_name: Mapped[str] = mapped_column(Text, nullable=False) phone: Mapped[str | None] = mapped_column(Text) role: Mapped[str | None] = mapped_column(user_role_enum) created_at: Mapped[datetime] = _timestamptz() class Supplier(Base): __tablename__ = "suppliers" id: Mapped[uuid.UUID] = _uuid_pk() name: Mapped[str] = mapped_column(Text, nullable=False) # v2.6: 1 supplier → exactly 1 category (one of the 8 fixed topic # categories). The old runtime JSONB `categories` column is dropped. category_id: Mapped[uuid.UUID] = mapped_column( ForeignKey("material_categories.id", ondelete="RESTRICT"), nullable=False ) location: Mapped[str | None] = mapped_column(Text) phone: Mapped[str | None] = mapped_column(Text) contact_person: Mapped[str | None] = mapped_column(Text) tax_id: Mapped[str | None] = mapped_column(Text) created_at: Mapped[datetime] = _timestamptz() category: Mapped["MaterialCategory"] = relationship(lazy="raise") class SupplierMaterial(Base): """supplier_materials — catalog-layer M:N supplier ↔ material link (v2.5). Auto-created whenever a quote line item is saved; also managed via /suppliers/{id}/materials. Inserting a row derives materials.category_id (first-link-wins) via service + DB trigger. """ __tablename__ = "supplier_materials" __table_args__ = (UniqueConstraint("supplier_id", "material_id", name="uq_supplier_material"),) __mapper_args__ = {"eager_defaults": True} id: Mapped[uuid.UUID] = _uuid_pk() supplier_id: Mapped[uuid.UUID] = mapped_column( ForeignKey("suppliers.id", ondelete="CASCADE"), nullable=False ) material_id: Mapped[uuid.UUID] = mapped_column( ForeignKey("materials.id", ondelete="CASCADE"), nullable=False ) is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true")) last_quoted_price: Mapped[Decimal | None] = mapped_column(Numeric(18, 4)) last_quoted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) created_at: Mapped[datetime] = _timestamptz() supplier: Mapped["Supplier"] = relationship(lazy="raise") material: Mapped["Material"] = relationship(lazy="raise") class QuoteProjects(Base): """M:N quotes ↔ projects junction (a quote can be about several projects).""" __tablename__ = "quote_projects" quote_id: Mapped[uuid.UUID] = mapped_column( ForeignKey("quotes.id", ondelete="CASCADE"), primary_key=True ) project_id: Mapped[uuid.UUID] = mapped_column( ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True ) project: Mapped["Project"] = relationship(lazy="raise") class LineItemProjects(Base): """M:N line_items ↔ projects junction — which projects each material is allocated to.""" __tablename__ = "line_item_projects" line_item_id: Mapped[uuid.UUID] = mapped_column( ForeignKey("line_items.id", ondelete="CASCADE"), primary_key=True ) project_id: Mapped[uuid.UUID] = mapped_column( ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True ) project: Mapped["Project"] = relationship(lazy="raise") class Project(Base): __tablename__ = "projects" id: Mapped[uuid.UUID] = _uuid_pk() name: Mapped[str] = mapped_column(Text, nullable=False) description: Mapped[str | None] = mapped_column(Text) start_date: Mapped[date | None] = mapped_column(Date) # v2.6: the single PM responsible for this project (confirms arrivals). pm_user_id: Mapped[uuid.UUID | None] = mapped_column( ForeignKey("users.id", ondelete="SET NULL") ) created_at: Mapped[datetime] = _timestamptz() class Quote(Base): __tablename__ = "quotes" __mapper_args__ = {"eager_defaults": True} # fetch onupdate=func.now() via RETURNING id: Mapped[uuid.UUID] = _uuid_pk() supplier_id: Mapped[uuid.UUID] = mapped_column( ForeignKey("suppliers.id", ondelete="RESTRICT"), nullable=False ) project_id: Mapped[uuid.UUID | None] = mapped_column( ForeignKey("projects.id", ondelete="SET NULL") ) quote_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) expiry_date: Mapped[date | None] = mapped_column(Date) accepted_date: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) arrived_date: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) status: Mapped[str] = mapped_column(quote_status_enum, nullable=False, server_default=text("'offered'")) is_official: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true")) notes: Mapped[str | None] = mapped_column(Text) created_at: Mapped[datetime] = _timestamptz() updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now() ) supplier: Mapped["Supplier"] = relationship(foreign_keys=[supplier_id], lazy="raise") project: Mapped["Project | None"] = relationship(foreign_keys=[project_id], lazy="raise") line_items: Mapped[list["LineItem"]] = relationship( back_populates="quote", lazy="raise", passive_deletes=True, order_by="LineItem.created_at" ) termsheets: Mapped[list["Termsheet"]] = relationship( back_populates="quote", lazy="raise", passive_deletes=True, order_by="Termsheet.created_at" ) events: Mapped[list["QuoteEvent"]] = relationship( back_populates="quote", lazy="raise", passive_deletes=True, order_by="QuoteEvent.occurred_at" ) class QuoteEvent(Base): __tablename__ = "quote_events" id: Mapped[uuid.UUID] = _uuid_pk() quote_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("quotes.id", ondelete="CASCADE"), nullable=False) event_type: Mapped[str] = mapped_column(quote_event_type_enum, nullable=False) payload: Mapped[dict] = mapped_column(JSONB, nullable=False, server_default=text("'{}'::jsonb")) occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) actor: Mapped[str | None] = mapped_column(Text) quote: Mapped["Quote"] = relationship(back_populates="events", lazy="raise") class LineItem(Base): __tablename__ = "line_items" id: Mapped[uuid.UUID] = _uuid_pk() quote_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("quotes.id", ondelete="CASCADE"), nullable=False) material_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("materials.id", ondelete="RESTRICT"), nullable=False) brand_material_id: Mapped[uuid.UUID | None] = mapped_column( ForeignKey("brand_materials.id", ondelete="SET NULL") ) # optional; app-layer check ensures it belongs to material_id unit_id: Mapped[int] = mapped_column(ForeignKey("units.id", ondelete="RESTRICT"), nullable=False) quantity: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False) notes: Mapped[str | None] = mapped_column(Text) created_at: Mapped[datetime] = _timestamptz() quote: Mapped["Quote"] = relationship(back_populates="line_items", lazy="raise") material: Mapped["Material"] = relationship(lazy="raise") unit: Mapped["Unit"] = relationship(lazy="raise") specs: Mapped[list["LineItemSpec"]] = relationship( back_populates="line_item", lazy="raise", passive_deletes=True ) decision: Mapped["ItemDecision | None"] = relationship( back_populates="line_item", uselist=False, lazy="raise", passive_deletes=True ) item_termsheets: Mapped[list["ItemTermsheet"]] = relationship( back_populates="line_item", lazy="raise", passive_deletes=True ) class LineItemSpec(Base): """Historical snapshot — freezes material specs at quote generation time.""" __tablename__ = "line_item_specs" __table_args__ = (UniqueConstraint("line_item_id", "attribute_definition_id", name="uq_lineitem_spec"),) id: Mapped[uuid.UUID] = _uuid_pk() line_item_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("line_items.id", ondelete="CASCADE"), nullable=False) attribute_definition_id: Mapped[uuid.UUID] = mapped_column( ForeignKey("attribute_definitions.id", ondelete="RESTRICT"), nullable=False ) value_number: Mapped[Decimal | None] = mapped_column(Numeric(18, 6)) value_string: Mapped[str | None] = mapped_column(Text) value_boolean: Mapped[bool | None] = mapped_column(Boolean) value_date: Mapped[date | None] = mapped_column(Date) unit_id_at_time: Mapped[int | None] = mapped_column(ForeignKey("units.id", ondelete="SET NULL")) line_item: Mapped["LineItem"] = relationship(back_populates="specs", lazy="raise") class Termsheet(Base): __tablename__ = "termsheets" id: Mapped[uuid.UUID] = _uuid_pk() quote_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("quotes.id", ondelete="CASCADE"), nullable=False) label: Mapped[str] = mapped_column(Text, nullable=False) status: Mapped[str] = mapped_column(termsheet_status_enum, nullable=False, server_default=text("'offered'")) discount_type: Mapped[str] = mapped_column(discount_type_enum, nullable=False, server_default=text("'none'")) discount_value: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False, server_default=text("0")) created_at: Mapped[datetime] = _timestamptz() quote: Mapped["Quote"] = relationship(back_populates="termsheets", lazy="raise") schedule_steps: Mapped[list["TermsheetScheduleStep"]] = relationship( back_populates="termsheet", lazy="raise", passive_deletes=True, order_by="TermsheetScheduleStep.step_no" ) item_termsheets: Mapped[list["ItemTermsheet"]] = relationship( back_populates="termsheet", lazy="raise", passive_deletes=True ) class TermsheetScheduleStep(Base): __tablename__ = "termsheet_schedule_steps" __table_args__ = (UniqueConstraint("termsheet_id", "step_no", name="uq_termsheet_step"),) id: Mapped[uuid.UUID] = _uuid_pk() termsheet_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("termsheets.id", ondelete="CASCADE"), nullable=False) step_no: Mapped[int] = mapped_column(SmallInteger, nullable=False) due_date: Mapped[date] = mapped_column(Date, nullable=False) amount: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False) note: Mapped[str | None] = mapped_column(Text) termsheet: Mapped["Termsheet"] = relationship(back_populates="schedule_steps", lazy="raise") class ItemTermsheet(Base): """Line-item pricing inside a termsheet.""" __tablename__ = "item_termsheets" __table_args__ = (UniqueConstraint("line_item_id", "termsheet_id", name="uq_item_termsheet"),) id: Mapped[uuid.UUID] = _uuid_pk() line_item_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("line_items.id", ondelete="CASCADE"), nullable=False) termsheet_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("termsheets.id", ondelete="CASCADE"), nullable=False) unit_price: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False) min_order_qty: Mapped[Decimal | None] = mapped_column(Numeric(18, 4)) line_item: Mapped["LineItem"] = relationship(back_populates="item_termsheets", lazy="raise") termsheet: Mapped["Termsheet"] = relationship(back_populates="item_termsheets", lazy="raise") class ItemTermsheetsWithTotal(Base): """Read-only mapping of the VIEW item_termsheets_with_total. total_price is NEVER stored — it is computed by the view as unit_price × line_items.quantity. """ __table__ = Table( "item_termsheets_with_total", Base.metadata, Column("id", Uuid(as_uuid=True), primary_key=True), Column("line_item_id", Uuid(as_uuid=True)), Column("termsheet_id", Uuid(as_uuid=True)), Column("unit_price", Numeric(18, 4)), Column("min_order_qty", Numeric(18, 4)), Column("total_price", Numeric(18, 2)), ) class ItemDecision(Base): """1:1 with line_items (UNIQUE on line_item_id). project_id: the SINGLE purchased project, picked by the procurement manager at purchase (auto-filled when the offer had one project; validated against line_item_projects). Warehouses of this project are the only valid arrival destinations. """ __tablename__ = "item_decisions" id: Mapped[uuid.UUID] = _uuid_pk() line_item_id: Mapped[uuid.UUID] = mapped_column( ForeignKey("line_items.id", ondelete="CASCADE"), nullable=False, unique=True ) chosen_item_termsheet_id: Mapped[uuid.UUID | None] = mapped_column( ForeignKey("item_termsheets.id", ondelete="SET NULL") ) status: Mapped[str] = mapped_column(item_decision_status_enum, nullable=False, server_default=text("'pending'")) project_id: Mapped[uuid.UUID | None] = mapped_column( ForeignKey("projects.id", ondelete="SET NULL") ) adjusted_unit_price: Mapped[Decimal | None] = mapped_column(Numeric(18, 4)) adjusted_notes: Mapped[str | None] = mapped_column(Text) decided_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) line_item: Mapped["LineItem"] = relationship(back_populates="decision", lazy="raise") project: Mapped["Project | None"] = relationship(lazy="raise") receipt: Mapped["ItemReceipt | None"] = relationship( back_populates="decision", uselist=False, lazy="raise", passive_deletes=True ) class ItemReceipt(Base): """1 item : N receipts (PARTIAL DELIVERIES — no UNIQUE on item_decision_id). A receipt means "the PM confirmed this delivery". received_qty is in the line item's unit; inventory posting converts to the inventory row's unit. """ __tablename__ = "item_receipts" id: Mapped[uuid.UUID] = _uuid_pk() item_decision_id: Mapped[uuid.UUID] = mapped_column( ForeignKey("item_decisions.id", ondelete="CASCADE"), nullable=False ) confirmed_by: Mapped[uuid.UUID] = mapped_column( ForeignKey("users.id", ondelete="RESTRICT"), nullable=False ) received_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) received_qty: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False) on_time: Mapped[bool | None] = mapped_column(Boolean) quality_note: Mapped[str | None] = mapped_column(Text) decision: Mapped["ItemDecision"] = relationship(back_populates="receipt", lazy="raise") allocations: Mapped[list["ItemReceiptAllocation"]] = relationship( back_populates="receipt", lazy="raise", passive_deletes=True ) class ItemReceiptAllocation(Base): """item_receipt_allocations — the PM's split of one receipt across warehouses (typically 3). SUM(quantity) must equal the receipt's received_qty; every warehouse belongs to the decision's project. """ __tablename__ = "item_receipt_allocations" __table_args__ = (UniqueConstraint("receipt_id", "warehouse_id", name="uq_receipt_warehouse"),) id: Mapped[uuid.UUID] = _uuid_pk() receipt_id: Mapped[uuid.UUID] = mapped_column( ForeignKey("item_receipts.id", ondelete="CASCADE"), nullable=False ) warehouse_id: Mapped[uuid.UUID] = mapped_column( ForeignKey("warehouses.id", ondelete="RESTRICT"), nullable=False ) quantity: Mapped[Decimal] = mapped_column(Numeric(18, 6), nullable=False) receipt: Mapped["ItemReceipt"] = relationship(back_populates="allocations", lazy="raise") warehouse: Mapped["Warehouse"] = relationship(lazy="raise")