Files

405 lines
17 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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")