feat(backend): add FastAPI backend — procurement, inventory domain, Casdoor auth, PM arrival endpoints

This commit is contained in:
ali_mohaghegh
2026-08-25 19:08:14 +03:30
parent c8a9b66395
commit 170aed66ea
67 changed files with 9774 additions and 0 deletions
+404
View File
@@ -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")