Their three commits (730d0bd,5a9b543,387edfb) reached production by push and auto-deployed.387edfbexpects a users.username column that the live database does not have, so POST /api/v1/auth/login was returning HTTP 500. Their schema changes never applied either (projects still has only id, name, description, start_date, pm_user_id, created_at), so nothing depends on the reverted code. This commit sets the tree back to exactlya02b3ea— verified with git diff against it — while keeping history linear: the work itself is preserved on 'pm' at387edfb.
405 lines
17 KiB
Python
405 lines
17 KiB
Python
"""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")
|