| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133 |
- from __future__ import annotations
- from datetime import datetime
- from typing import TYPE_CHECKING
- from sqlalchemy import (
- Boolean,
- DateTime,
- Float,
- ForeignKey,
- Integer,
- String,
- UniqueConstraint,
- func,
- )
- from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
- from backend.app.core.database import Base
- if TYPE_CHECKING:
- from backend.app.models.spool import Spool
- def supplier_name_key(name: str) -> str:
- """Case-insensitive lookup key stored on ``Supplier.name_key``.
- Folded in Python, not in SQL: SQLite's ``lower()`` folds ASCII only, so a
- unique index on ``lower(name)`` lets “Ökofilament” and “ökofilament” both
- through — and the CSV import, which folds in Python, then collapses them
- onto one map entry and resolves to whichever row it built last. Every
- caller goes through this one function so the two folds cannot drift.
- """
- return name.strip().lower()
- class Supplier(Base):
- """Managed supplier master list (#2988).
- A supplier is *where filament is bought* — distinct from ``Spool.brand``,
- which is who made it. One supplier carries many brands, and the same
- product is available from several suppliers, hence the n:m assignment
- below instead of a free-text field that drifts in spelling.
- """
- __tablename__ = "suppliers"
- id: Mapped[int] = mapped_column(primary_key=True)
- name: Mapped[str] = mapped_column(String(200), index=True)
- # Case-insensitive uniqueness -- the whole feature keys on the name. The
- # CSV import resolves a column of names against this table and the master
- # list is what a rename re-points, so "Extrudr" and "extrudr" must not be
- # two rows. Stored key rather than a unique index on lower(name), for the
- # reason in supplier_name_key: only the Python fold is Unicode-correct on
- # every backend, and it is the same fold the import map uses.
- name_key: Mapped[str] = mapped_column(String(200), nullable=False, unique=True, index=True)
- # Shop / website URL, purely informational.
- website: Mapped[str | None] = mapped_column(String(500))
- # The business's own customer number AT this supplier.
- customer_number: Mapped[str | None] = mapped_column(String(100))
- note: Mapped[str | None] = mapped_column(String(500))
- created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
- updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
- spool_links: Mapped[list[SpoolSupplier]] = relationship(back_populates="supplier")
- @validates("name")
- def _sync_name_key(self, _key: str, value: str) -> str:
- """Derive name_key from every name write, so no insert path can skip it."""
- self.name_key = supplier_name_key(value)
- return value
- class SpoolSupplier(Base):
- """Spool-to-supplier assignment with per-assignment attributes (#2988).
- Modelled after ``CostCenterMember`` (surrogate id + UNIQUE pair) rather
- than a bare association table, because the assignment carries data: the
- supplier's own article number for the product (NOT the internal material
- number from #2870), the price at this supplier, and whether this concrete
- spool was actually bought there — the other rows are alternative sources.
- """
- __tablename__ = "spool_suppliers"
- __table_args__ = (UniqueConstraint("spool_id", "supplier_id", name="uq_spool_suppliers_spool_supplier"),)
- id: Mapped[int] = mapped_column(primary_key=True)
- spool_id: Mapped[int] = mapped_column(ForeignKey("spool.id", ondelete="CASCADE"), index=True)
- supplier_id: Mapped[int] = mapped_column(ForeignKey("suppliers.id"), index=True)
- # The supplier's article number for this product.
- supplier_article_number: Mapped[str | None] = mapped_column(String(100))
- # QUOTED price per kg at this supplier, for comparing sources. Named so it
- # can never be read as actual cost: ``spool.cost_per_kg`` is the cost basis
- # for every print and is never written from here.
- quoted_price_per_kg: Mapped[float | None] = mapped_column(Float)
- # True on the assignment this spool was actually purchased from.
- is_purchase_source: Mapped[bool] = mapped_column(Boolean, default=False)
- created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
- supplier: Mapped[Supplier] = relationship(back_populates="spool_links")
- spool: Mapped[Spool] = relationship(back_populates="supplier_links")
- @property
- def supplier_name(self) -> str:
- """Flattened for SpoolSupplierResponse. Every route that embeds links
- chains selectinload() onto ``supplier``, so this never lazy-loads."""
- return self.supplier.name if self.supplier else ""
- class SpoolmanSpoolSupplier(Base):
- """``SpoolSupplier`` for a Spoolman-managed spool.
- Mirrors ``SpoolmanKProfile``: Spoolman owns the spool, Bambuddy owns the
- supplier assignment, so the row is local and keyed by the remote spool id
- with no foreign key to enforce it. Suppliers are Bambuddy-side on purpose —
- Spoolman's ``vendor`` is the manufacturer, not the seller.
- """
- __tablename__ = "spoolman_spool_suppliers"
- __table_args__ = (UniqueConstraint("spoolman_spool_id", "supplier_id", name="uq_spoolman_spool_suppliers_pair"),)
- id: Mapped[int] = mapped_column(primary_key=True)
- spoolman_spool_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
- supplier_id: Mapped[int] = mapped_column(ForeignKey("suppliers.id"), index=True)
- supplier_article_number: Mapped[str | None] = mapped_column(String(100))
- quoted_price_per_kg: Mapped[float | None] = mapped_column(Float)
- is_purchase_source: Mapped[bool] = mapped_column(Boolean, default=False)
- created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
- supplier: Mapped[Supplier] = relationship()
- @property
- def supplier_name(self) -> str:
- return self.supplier.name if self.supplier else ""
|