supplier.py 4.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112
  1. """Pydantic schemas for the supplier master list (#2988)."""
  2. from datetime import datetime
  3. from pydantic import BaseModel, Field, field_validator
  4. # The CSV `suppliers` column joins names with "; " (#2988). A name carrying
  5. # the separator would split into unknown names on import and silently drop the
  6. # assignments, so it is refused at the edge rather than escaped — the column
  7. # stays readable and hand-editable.
  8. SUPPLIER_NAME_SEPARATOR = ";"
  9. def validate_supplier_name(value: str) -> str:
  10. trimmed = value.strip()
  11. if not trimmed:
  12. raise ValueError("name must not be empty")
  13. if SUPPLIER_NAME_SEPARATOR in trimmed:
  14. raise ValueError("name must not contain ';' — it separates suppliers in the CSV export")
  15. return trimmed
  16. class SupplierBase(BaseModel):
  17. name: str = Field(..., min_length=1, max_length=200)
  18. website: str | None = Field(default=None, max_length=500)
  19. # The business's own customer number AT this supplier.
  20. customer_number: str | None = Field(default=None, max_length=100)
  21. note: str | None = Field(default=None, max_length=500)
  22. class SupplierCreate(SupplierBase):
  23. # Only on the write schemas: SupplierResponse inherits SupplierBase, and a
  24. # row written before the rule existed must still be readable. mode="before"
  25. # so the name is trimmed before max_length sees it — a padded name at the
  26. # cap is not too long once stripped. Non-strings pass through to the type
  27. # check.
  28. @field_validator("name", mode="before")
  29. @classmethod
  30. def _normalize_name(cls, value: object) -> object:
  31. return validate_supplier_name(value) if isinstance(value, str) else value
  32. class SupplierUpdate(BaseModel):
  33. # Optional so a PATCH can leave the name alone. An explicit null is a 422
  34. # here instead of a NOT NULL violation surfacing as a 500.
  35. name: str | None = Field(default=None, min_length=1, max_length=200)
  36. website: str | None = Field(default=None, max_length=500)
  37. customer_number: str | None = Field(default=None, max_length=100)
  38. note: str | None = Field(default=None, max_length=500)
  39. @field_validator("name", mode="before")
  40. @classmethod
  41. def _normalize_name(cls, value: object) -> object:
  42. if value is None:
  43. raise ValueError("name must not be null")
  44. return validate_supplier_name(value) if isinstance(value, str) else value
  45. class SupplierResponse(SupplierBase):
  46. id: int
  47. # Number of spools referencing this supplier — shown in the settings list
  48. # and the reason a delete is refused (409) instead of orphaning links.
  49. spool_count: int = 0
  50. created_at: datetime
  51. updated_at: datetime
  52. class Config:
  53. from_attributes = True
  54. class SpoolSupplierLinkInput(BaseModel):
  55. """One spool-to-supplier assignment as written by the spool dialog."""
  56. supplier_id: int = Field(..., gt=0)
  57. # The supplier's own article number for the product — NOT the internal
  58. # material number (#2870).
  59. supplier_article_number: str | None = Field(default=None, max_length=100)
  60. # Quoted price per kg at this supplier, for comparing sources. Never a
  61. # cost basis — ``spool.cost_per_kg`` stays authoritative and is not
  62. # written from assignments.
  63. quoted_price_per_kg: float | None = Field(default=None, ge=0)
  64. # Marks where this concrete spool was actually bought; the other
  65. # assignments are alternative sources.
  66. is_purchase_source: bool = False
  67. class SpoolSupplierResponse(BaseModel):
  68. id: int
  69. supplier_id: int
  70. supplier_name: str
  71. supplier_article_number: str | None = None
  72. quoted_price_per_kg: float | None = None
  73. is_purchase_source: bool = False
  74. class Config:
  75. from_attributes = True
  76. class SupplierStats(BaseModel):
  77. """Per-supplier inventory aggregate (#2988), purchase-source spools only.
  78. ``spool_count`` and ``remaining_g`` cover active spools bought at this
  79. supplier; ``consumed_g`` and ``cost`` sum the recorded usage history of
  80. every purchase-source spool, archived included.
  81. """
  82. supplier_id: int
  83. supplier_name: str
  84. spool_count: int
  85. remaining_g: float
  86. consumed_g: float
  87. cost: float