supplier.py 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133
  1. from __future__ import annotations
  2. from datetime import datetime
  3. from typing import TYPE_CHECKING
  4. from sqlalchemy import (
  5. Boolean,
  6. DateTime,
  7. Float,
  8. ForeignKey,
  9. Integer,
  10. String,
  11. UniqueConstraint,
  12. func,
  13. )
  14. from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
  15. from backend.app.core.database import Base
  16. if TYPE_CHECKING:
  17. from backend.app.models.spool import Spool
  18. def supplier_name_key(name: str) -> str:
  19. """Case-insensitive lookup key stored on ``Supplier.name_key``.
  20. Folded in Python, not in SQL: SQLite's ``lower()`` folds ASCII only, so a
  21. unique index on ``lower(name)`` lets “Ökofilament” and “ökofilament” both
  22. through — and the CSV import, which folds in Python, then collapses them
  23. onto one map entry and resolves to whichever row it built last. Every
  24. caller goes through this one function so the two folds cannot drift.
  25. """
  26. return name.strip().lower()
  27. class Supplier(Base):
  28. """Managed supplier master list (#2988).
  29. A supplier is *where filament is bought* — distinct from ``Spool.brand``,
  30. which is who made it. One supplier carries many brands, and the same
  31. product is available from several suppliers, hence the n:m assignment
  32. below instead of a free-text field that drifts in spelling.
  33. """
  34. __tablename__ = "suppliers"
  35. id: Mapped[int] = mapped_column(primary_key=True)
  36. name: Mapped[str] = mapped_column(String(200), index=True)
  37. # Case-insensitive uniqueness -- the whole feature keys on the name. The
  38. # CSV import resolves a column of names against this table and the master
  39. # list is what a rename re-points, so "Extrudr" and "extrudr" must not be
  40. # two rows. Stored key rather than a unique index on lower(name), for the
  41. # reason in supplier_name_key: only the Python fold is Unicode-correct on
  42. # every backend, and it is the same fold the import map uses.
  43. name_key: Mapped[str] = mapped_column(String(200), nullable=False, unique=True, index=True)
  44. # Shop / website URL, purely informational.
  45. website: Mapped[str | None] = mapped_column(String(500))
  46. # The business's own customer number AT this supplier.
  47. customer_number: Mapped[str | None] = mapped_column(String(100))
  48. note: Mapped[str | None] = mapped_column(String(500))
  49. created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
  50. updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
  51. spool_links: Mapped[list[SpoolSupplier]] = relationship(back_populates="supplier")
  52. @validates("name")
  53. def _sync_name_key(self, _key: str, value: str) -> str:
  54. """Derive name_key from every name write, so no insert path can skip it."""
  55. self.name_key = supplier_name_key(value)
  56. return value
  57. class SpoolSupplier(Base):
  58. """Spool-to-supplier assignment with per-assignment attributes (#2988).
  59. Modelled after ``CostCenterMember`` (surrogate id + UNIQUE pair) rather
  60. than a bare association table, because the assignment carries data: the
  61. supplier's own article number for the product (NOT the internal material
  62. number from #2870), the price at this supplier, and whether this concrete
  63. spool was actually bought there — the other rows are alternative sources.
  64. """
  65. __tablename__ = "spool_suppliers"
  66. __table_args__ = (UniqueConstraint("spool_id", "supplier_id", name="uq_spool_suppliers_spool_supplier"),)
  67. id: Mapped[int] = mapped_column(primary_key=True)
  68. spool_id: Mapped[int] = mapped_column(ForeignKey("spool.id", ondelete="CASCADE"), index=True)
  69. supplier_id: Mapped[int] = mapped_column(ForeignKey("suppliers.id"), index=True)
  70. # The supplier's article number for this product.
  71. supplier_article_number: Mapped[str | None] = mapped_column(String(100))
  72. # QUOTED price per kg at this supplier, for comparing sources. Named so it
  73. # can never be read as actual cost: ``spool.cost_per_kg`` is the cost basis
  74. # for every print and is never written from here.
  75. quoted_price_per_kg: Mapped[float | None] = mapped_column(Float)
  76. # True on the assignment this spool was actually purchased from.
  77. is_purchase_source: Mapped[bool] = mapped_column(Boolean, default=False)
  78. created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
  79. supplier: Mapped[Supplier] = relationship(back_populates="spool_links")
  80. spool: Mapped[Spool] = relationship(back_populates="supplier_links")
  81. @property
  82. def supplier_name(self) -> str:
  83. """Flattened for SpoolSupplierResponse. Every route that embeds links
  84. chains selectinload() onto ``supplier``, so this never lazy-loads."""
  85. return self.supplier.name if self.supplier else ""
  86. class SpoolmanSpoolSupplier(Base):
  87. """``SpoolSupplier`` for a Spoolman-managed spool.
  88. Mirrors ``SpoolmanKProfile``: Spoolman owns the spool, Bambuddy owns the
  89. supplier assignment, so the row is local and keyed by the remote spool id
  90. with no foreign key to enforce it. Suppliers are Bambuddy-side on purpose —
  91. Spoolman's ``vendor`` is the manufacturer, not the seller.
  92. """
  93. __tablename__ = "spoolman_spool_suppliers"
  94. __table_args__ = (UniqueConstraint("spoolman_spool_id", "supplier_id", name="uq_spoolman_spool_suppliers_pair"),)
  95. id: Mapped[int] = mapped_column(primary_key=True)
  96. spoolman_spool_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
  97. supplier_id: Mapped[int] = mapped_column(ForeignKey("suppliers.id"), index=True)
  98. supplier_article_number: Mapped[str | None] = mapped_column(String(100))
  99. quoted_price_per_kg: Mapped[float | None] = mapped_column(Float)
  100. is_purchase_source: Mapped[bool] = mapped_column(Boolean, default=False)
  101. created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
  102. supplier: Mapped[Supplier] = relationship()
  103. @property
  104. def supplier_name(self) -> str:
  105. return self.supplier.name if self.supplier else ""