feat(backend): add FastAPI backend — procurement, inventory domain, Casdoor auth, PM arrival endpoints
This commit is contained in:
@@ -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",
|
||||
]
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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