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
+62
View File
@@ -0,0 +1,62 @@
"""ORM models — import order matters only for registration in Base.metadata."""
from app.db.base import Base
from app.models.catalog import (
AttributeDefinition,
Brand,
BrandMaterial,
CategoryAttribute,
CategoryReferenceUnit,
Material,
MaterialAttributeValue,
MaterialCategory,
MaterialUnit,
StandardConversion,
Unit,
UnitConversion,
)
from app.models.files import QuoteAttachment
from app.models.procurement import (
ItemDecision,
ItemReceipt,
ItemTermsheet,
ItemTermsheetsWithTotal,
LineItem,
LineItemSpec,
Project,
Quote,
QuoteEvent,
QuoteProjects,
Supplier,
Termsheet,
TermsheetScheduleStep,
)
__all__ = [
"Base",
"Unit",
"Brand",
"MaterialCategory",
"AttributeDefinition",
"CategoryAttribute",
"Material",
"BrandMaterial",
"MaterialUnit",
"MaterialAttributeValue",
"CategoryReferenceUnit",
"UnitConversion",
"StandardConversion",
"Supplier",
"Project",
"Quote",
"QuoteEvent",
"QuoteProjects",
"LineItem",
"LineItemSpec",
"Termsheet",
"TermsheetScheduleStep",
"ItemTermsheet",
"ItemTermsheetsWithTotal",
"ItemDecision",
"ItemReceipt",
"QuoteAttachment",
]
+261
View File
@@ -0,0 +1,261 @@
"""SQLAlchemy models for the CATALOG domain — mirrors migration.sql exactly.
Column types, nullability, server defaults, FKs, unique constraints and
indexes all follow the DDL in `data base/migration.sql`. No table is
created through SQLAlchemy metadata; the schema already exists in Postgres.
"""
import uuid
from datetime import date, datetime
from decimal import Decimal
from sqlalchemy import (
Boolean,
Date,
DateTime,
Enum,
ForeignKey,
Identity,
Integer,
Numeric,
SmallInteger,
String,
Text,
UniqueConstraint,
Uuid,
func,
text,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
attribute_data_type_enum = Enum(
"number", "integer", "string", "enum", "boolean", "date",
name="attribute_data_type",
native_enum=True,
)
material_status_enum = Enum(
"active", "discontinued", "draft",
name="material_status",
native_enum=True,
)
def _uuid_pk() -> Mapped[uuid.UUID]:
"""UUID primary key, server-generated via gen_random_uuid()."""
return mapped_column(Uuid(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()"))
def _timestamptz() -> Mapped[datetime]:
return mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
class Unit(Base):
"""units — the only non-UUID PK in the system (INTEGER IDENTITY)."""
__tablename__ = "units"
id: Mapped[int] = mapped_column(Integer, Identity(), primary_key=True)
code: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
name_fa: Mapped[str | None] = mapped_column(String)
dimension: Mapped[str | None] = mapped_column(String) # length, mass, pressure, ...
si_factor: Mapped[Decimal] = mapped_column(Numeric(18, 6), nullable=False, server_default=text("1.0"))
is_base: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
class Brand(Base):
__tablename__ = "brands"
__table_args__ = (UniqueConstraint("name", "country", name="uq_brands_name_country"),)
id: Mapped[uuid.UUID] = _uuid_pk()
name: Mapped[str] = mapped_column(Text, nullable=False)
country: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = _timestamptz()
class MaterialCategory(Base):
__tablename__ = "material_categories"
# eager_defaults: fetch onupdate=func.now() via RETURNING during flush so
# updated_at is never expired post-flush (lazy refresh raises
# MissingGreenlet in async code).
__mapper_args__ = {"eager_defaults": True}
id: Mapped[uuid.UUID] = _uuid_pk()
slug: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
name: Mapped[str] = mapped_column(Text, nullable=False)
description: Mapped[str | None] = mapped_column(Text)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true"))
created_at: Mapped[datetime] = _timestamptz()
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
class AttributeDefinition(Base):
"""Global attribute dictionary (EAV definition side)."""
__tablename__ = "attribute_definitions"
id: Mapped[uuid.UUID] = _uuid_pk()
attribute_key: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
label: Mapped[str] = mapped_column(Text, nullable=False)
data_type: Mapped[str] = mapped_column(attribute_data_type_enum, nullable=False)
unit_id: Mapped[int | None] = mapped_column(ForeignKey("units.id", ondelete="RESTRICT"))
enum_values: Mapped[list | None] = mapped_column(JSONB) # allowed options for enum types
created_at: Mapped[datetime] = _timestamptz()
class CategoryAttribute(Base):
"""Category-attribute junction (scopes global attributes & sets category rules)."""
__tablename__ = "category_attributes"
__table_args__ = (UniqueConstraint("category_id", "attribute_definition_id", name="uq_category_attribute"),)
id: Mapped[uuid.UUID] = _uuid_pk()
category_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("material_categories.id", ondelete="CASCADE"), nullable=False
)
attribute_definition_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("attribute_definitions.id", ondelete="CASCADE"), nullable=False
)
is_required: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
is_filterable: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
conditional_rules: Mapped[dict | None] = mapped_column(JSONB)
sort_order: Mapped[int] = mapped_column(SmallInteger, nullable=False, server_default=text("0"))
attribute: Mapped["AttributeDefinition"] = relationship(lazy="raise")
class Material(Base):
__tablename__ = "materials"
# v2.5/2.6: name is GLOBALLY unique; category_id is nullable + derived.
__table_args__ = (UniqueConstraint("name", name="uq_material_name"),)
__mapper_args__ = {"eager_defaults": True}
id: Mapped[uuid.UUID] = _uuid_pk()
category_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("material_categories.id", ondelete="RESTRICT")
)
name: Mapped[str] = mapped_column(Text, nullable=False)
topic: Mapped[str | None] = mapped_column(Text) # تاپیک جلوی برنامه (Persian) — legacy
canonical_unit_id: Mapped[int] = mapped_column(
ForeignKey("units.id", ondelete="RESTRICT"), nullable=False
)
status: Mapped[str] = mapped_column(material_status_enum, nullable=False, server_default=text("'active'"))
created_at: Mapped[datetime] = _timestamptz()
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
category: Mapped["MaterialCategory | None"] = relationship(lazy="raise")
canonical_unit: Mapped["Unit"] = relationship(lazy="raise")
attribute_values: Mapped[list["MaterialAttributeValue"]] = relationship(
back_populates="material", lazy="raise", passive_deletes=True
)
class BrandMaterial(Base):
"""M:N Brand <-> Material junction."""
__tablename__ = "brand_materials"
__table_args__ = (UniqueConstraint("brand_id", "material_id", name="uq_brand_material"),)
id: Mapped[uuid.UUID] = _uuid_pk()
brand_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("brands.id", ondelete="CASCADE"), nullable=False)
material_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("materials.id", ondelete="CASCADE"), nullable=False)
catalog_code: Mapped[str | None] = mapped_column(Text)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true"))
created_at: Mapped[datetime] = _timestamptz()
brand: Mapped["Brand"] = relationship(lazy="raise")
material: Mapped["Material"] = relationship(lazy="raise")
class MaterialUnit(Base):
"""Packaging / unit overrides per material (and optionally per brand)."""
__tablename__ = "material_units"
__table_args__ = (
UniqueConstraint("material_id", "brand_material_id", "unit_id", name="uq_material_unit"),
)
id: Mapped[uuid.UUID] = _uuid_pk()
material_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("materials.id", ondelete="CASCADE"), nullable=False)
brand_material_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("brand_materials.id", ondelete="CASCADE")
) # NULL = generic material fallback
unit_id: Mapped[int] = mapped_column(ForeignKey("units.id", ondelete="RESTRICT"), nullable=False)
conversion_factor: Mapped[Decimal] = mapped_column(Numeric(18, 6), nullable=False)
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
class MaterialAttributeValue(Base):
"""Typed EAV values for a material."""
__tablename__ = "material_attribute_values"
__table_args__ = (UniqueConstraint("material_id", "attribute_definition_id", name="uq_material_attr_value"),)
id: Mapped[uuid.UUID] = _uuid_pk()
material_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("materials.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)
material: Mapped["Material"] = relationship(back_populates="attribute_values", lazy="raise")
definition: Mapped["AttributeDefinition"] = relationship(lazy="raise")
# ---------------------------------------------------------------------------
# Units-dashboard tables (NEW v2 contracts) — created IF NOT EXISTS at startup
# by app.db.init_db.ensure_dashboard_tables.
# ---------------------------------------------------------------------------
class CategoryReferenceUnit(Base):
__tablename__ = "category_reference_units"
__mapper_args__ = {"eager_defaults": True}
id: Mapped[uuid.UUID] = _uuid_pk()
category_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("material_categories.id", ondelete="CASCADE"), nullable=False, unique=True
)
ref_unit: Mapped[str] = mapped_column(Text, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
class UnitConversion(Base):
__tablename__ = "unit_conversions"
__mapper_args__ = {"eager_defaults": True}
id: Mapped[uuid.UUID] = _uuid_pk()
category_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("material_categories.id", ondelete="CASCADE"), nullable=False
)
brand: Mapped[str | None] = mapped_column(Text)
from_unit: Mapped[str] = mapped_column(Text, nullable=False)
to_unit: Mapped[str] = mapped_column(Text, nullable=False)
factor: Mapped[Decimal] = mapped_column(Numeric(18, 6), nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
class StandardConversion(Base):
__tablename__ = "standard_conversions"
__table_args__ = (UniqueConstraint("from_unit", "to_unit", name="uq_standard_conversion"),)
__mapper_args__ = {"eager_defaults": True}
id: Mapped[uuid.UUID] = _uuid_pk()
from_unit: Mapped[str] = mapped_column(Text, nullable=False)
to_unit: Mapped[str] = mapped_column(Text, nullable=False)
factor: Mapped[Decimal] = mapped_column(Numeric(18, 6), nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
+52
View File
@@ -0,0 +1,52 @@
"""ORM model for invoice / proforma attachments (table `quote_attachments`).
File bytes live in MinIO object storage; this table is the metadata record
(``object_key`` points at the object inside the PROCUREMENT_MINIO_BUCKET).
Multiple attachments per quote per kind are allowed (multi-file contract,
no unique constraint on (quote_id, kind)).
"""
import uuid
from datetime import datetime
from sqlalchemy import BigInteger, CheckConstraint, DateTime, ForeignKey, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
from app.models.catalog import _timestamptz, _uuid_pk
from app.utils.serialization import to_iso
class QuoteAttachment(Base):
__tablename__ = "quote_attachments"
__mapper_args__ = {"eager_defaults": True} # fetch onupdate=func.now() via RETURNING
__table_args__ = (
CheckConstraint("kind IN ('invoice', 'proforma')", name="ck_quote_attachments_kind"),
)
id: Mapped[uuid.UUID] = _uuid_pk()
quote_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("quotes.id", ondelete="CASCADE"), nullable=False)
kind: Mapped[str] = mapped_column(Text, nullable=False) # "invoice" | "proforma"
file_name: Mapped[str] = mapped_column(Text, nullable=False)
mime_type: Mapped[str] = mapped_column(Text, nullable=False)
size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False)
object_key: Mapped[str] = mapped_column(Text, nullable=False) # MinIO key inside the bucket
uploaded_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
created_at: Mapped[datetime] = _timestamptz()
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
quote: Mapped["Quote"] = relationship(lazy="raise")
def to_dict(self) -> dict:
return {
"id": str(self.id),
"quote_id": str(self.quote_id),
"kind": self.kind,
"file_name": self.file_name,
"mime_type": self.mime_type,
"size_bytes": self.size_bytes,
"uploaded_at": to_iso(self.uploaded_at),
}
+178
View File
@@ -0,0 +1,178 @@
"""SQLAlchemy models for the INVENTORY domain (warehouses, inventory, transactions).
Merged from the software team's shipped inventory-dashboard fork with the
v2.6 fixes:
- unique stock index uses COALESCE(brand_id, sentinel) so NULL brand is ONE
concrete value (a plain unique index would allow duplicate unbranded rows).
- schema is created by sql/migration.sql (fresh) / init_db.ensure_v26
(existing volumes) — NOT through SQLAlchemy metadata.
"""
import uuid
from datetime import datetime
from decimal import Decimal
from sqlalchemy import (
Boolean,
DateTime,
Enum,
ForeignKey,
Numeric,
Text,
UniqueConstraint,
Uuid,
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 _timestamptz, _uuid_pk
inventory_transaction_reason_enum = Enum(
"PURCHASE",
"WITHDRAWAL",
"RETURN",
"TRANSFER_IN",
"TRANSFER_OUT",
"DAMAGE",
"WASTE",
"LOSS",
"FOUND",
"STOCK_COUNT",
name="inventory_transaction_reason",
native_enum=True,
)
# The 10 supported transaction types. No OTHER / generic adjustment.
INVENTORY_TRANSACTION_TYPES = (
"PURCHASE",
"WITHDRAWAL",
"RETURN",
"TRANSFER_IN",
"TRANSFER_OUT",
"DAMAGE",
"WASTE",
"LOSS",
"FOUND",
"STOCK_COUNT",
)
# Types that INCREASE inventory quantity.
INCREASING_TYPES = frozenset({"PURCHASE", "RETURN", "FOUND", "TRANSFER_IN"})
# Types that DECREASE inventory quantity.
DECREASING_TYPES = frozenset({"WITHDRAWAL", "DAMAGE", "WASTE", "LOSS", "TRANSFER_OUT"})
class Warehouse(Base):
"""warehouses — storage location bound to exactly one project."""
__tablename__ = "warehouses"
id: Mapped[uuid.UUID] = _uuid_pk()
project_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str] = mapped_column(Text, nullable=False)
description: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = _timestamptz()
project: Mapped["Project"] = relationship(lazy="raise")
class InventoryItem(Base):
"""inventory — current stock snapshot, unique per (warehouse, material, brand, specs)."""
__tablename__ = "inventory"
# The DB unique index (uq_inventory_stock) uses COALESCE on brand_id —
# this Table-level constraint is only a model mirror for tooling; the
# real constraint lives in migration.sql / init_db.ensure_v26.
__mapper_args__ = {"eager_defaults": True} # fetch onupdate=func.now() via RETURNING
id: Mapped[uuid.UUID] = _uuid_pk()
warehouse_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("warehouses.id", ondelete="CASCADE"), nullable=False
)
material_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("materials.id", ondelete="RESTRICT"), nullable=False
)
brand_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("brands.id", ondelete="SET NULL")
)
quantity: Mapped[Decimal] = mapped_column(Numeric(18, 6), nullable=False, server_default=text("0"))
unit_id: Mapped[int] = mapped_column(
ForeignKey("units.id", ondelete="RESTRICT"), nullable=False
)
# Canonical technical specifications (sorted JSON). specs_hash is the
# SHA256 of the sorted canonical JSON (or 'default' when empty).
specs: Mapped[dict] = mapped_column(JSONB, nullable=False, server_default=text("'{}'::jsonb"))
specs_hash: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'default'"))
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
warehouse: Mapped["Warehouse"] = relationship(lazy="raise")
material: Mapped["Material"] = relationship(lazy="raise")
brand: Mapped["Brand | None"] = relationship(lazy="raise")
unit: Mapped["Unit"] = relationship(lazy="raise")
transactions: Mapped[list["InventoryTransaction"]] = relationship(
back_populates="inventory", lazy="raise", passive_deletes=True,
order_by="InventoryTransaction.transaction_date",
)
class InventoryTransaction(Base):
"""inventory_transactions — full chronological ledger of stock changes.
quantity is SIGNED: positive = increase (PURCHASE/RETURN/FOUND/TRANSFER_IN),
negative = decrease (WITHDRAWAL/DAMAGE/WASTE/LOSS/TRANSFER_OUT).
A transfer is recorded as two linked rows (TRANSFER_OUT in source,
TRANSFER_IN in destination) sharing the same transfer_pair_id.
receipt_allocation_id links an auto-posted PURCHASE back to its
item_receipt_allocation — UNIQUE, so the same delivery never posts twice.
"""
__tablename__ = "inventory_transactions"
__mapper_args__ = {"eager_defaults": True} # fetch server_default now() via RETURNING
id: Mapped[uuid.UUID] = _uuid_pk()
inventory_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("inventory.id", ondelete="CASCADE"), nullable=False
)
warehouse_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("warehouses.id", ondelete="RESTRICT"), nullable=False
)
receipt_allocation_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("item_receipt_allocations.id", ondelete="SET NULL"), unique=True
)
transaction_type: Mapped[str] = mapped_column(
inventory_transaction_reason_enum, nullable=False
)
quantity: Mapped[Decimal] = mapped_column(Numeric(18, 6), nullable=False)
# STOCK_COUNT only: quantity before the count (recorded) and the counted
# value (physical). NULL for every other type.
recorded_qty: Mapped[Decimal | None] = mapped_column(Numeric(18, 6))
physical_qty: Mapped[Decimal | None] = mapped_column(Numeric(18, 6))
# NULL for a standalone entry; links TRANSFER_OUT to its TRANSFER_IN row.
transfer_pair_id: Mapped[uuid.UUID | None] = mapped_column(Uuid(as_uuid=True))
performed_by: Mapped[str | None] = mapped_column(Text) # user display name (dev placeholder)
transaction_date: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
note: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = _timestamptz()
inventory: Mapped["InventoryItem"] = relationship(back_populates="transactions", lazy="raise")
warehouse: Mapped["Warehouse"] = relationship(lazy="raise")
# Re-export for convenience (mirrors the ERD enum name)
__all__ = [
"Warehouse",
"InventoryItem",
"InventoryTransaction",
"inventory_transaction_reason_enum",
"INVENTORY_TRANSACTION_TYPES",
"INCREASING_TYPES",
"DECREASING_TYPES",
]
+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")