feat(backend): add FastAPI backend — procurement, inventory domain, Casdoor auth, PM arrival endpoints
This commit is contained in:
@@ -0,0 +1,404 @@
|
||||
"""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")
|
||||
Reference in New Issue
Block a user