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