Materials are now global and category-free. The category lives only on the supplier (suppliers.category_id); every category-facing query goes through the supplier edge instead of the material row.
Schema (migration.sql + init_db DDL):
- drop the materials.category_id column, its FK, and the v2.5 first-link-wins automation (fn_derive_material_category + trg_supplier_materials_derive_category) — the DDL runs idempotently at API startup
Backend:
- GET /materials?category_id=… now filters via EXISTS(supplier_materials ⋈ suppliers.category_id)
- quote category filters (quotes list) use the quote's supplier category
- /pm/on-the-way derives the display category from the supplier of the quote
- supplier_materials links no longer derive/return a material category
- expand_service: material expand=category resolves to null (kept for contract compatibility)
- material create/update no longer validates or stores category_id; seed.py aligned
Frontend:
- material creation no longer sends category_id
- formatMaterialTitle reworked to a strict order with « . » separator: [Brand] . [Material] . [Spec 1] . [Spec 2], brand deduped when the stored name already starts with it; specs separator switched from « · » to « . »
Validated: python compile, api + frontend image builds (tsc + vite) clean; branch cut from the tip of prod_v4 (= origin/production, 5a9ef40 — the team's merge made them identical).
166 lines
4.7 KiB
Python
166 lines
4.7 KiB
Python
"""Pydantic v2 request schemas — catalog domain."""
|
|
from decimal import Decimal
|
|
from typing import Literal
|
|
from uuid import UUID
|
|
|
|
from pydantic import Field
|
|
|
|
from app.schemas.common import APIModel
|
|
|
|
MaterialStatus = Literal["active", "discontinued", "draft"]
|
|
AttributeDataType = Literal["number", "integer", "string", "enum", "boolean", "date"]
|
|
|
|
|
|
class UnitCreate(APIModel):
|
|
code: str = Field(min_length=1, max_length=255)
|
|
name_fa: str | None = None
|
|
dimension: str | None = None
|
|
si_factor: Decimal = Field(default=Decimal("1.0"), gt=0)
|
|
is_base: bool = False
|
|
|
|
|
|
class UnitUpdate(APIModel):
|
|
code: str | None = Field(default=None, min_length=1, max_length=255)
|
|
name_fa: str | None = None
|
|
dimension: str | None = None
|
|
si_factor: Decimal | None = Field(default=None, gt=0)
|
|
is_base: bool | None = None
|
|
|
|
|
|
class BrandCreate(APIModel):
|
|
name: str = Field(min_length=1)
|
|
country: str | None = None
|
|
|
|
|
|
class BrandUpdate(APIModel):
|
|
name: str | None = Field(default=None, min_length=1)
|
|
country: str | None = None
|
|
|
|
|
|
class MaterialCategoryCreate(APIModel):
|
|
slug: str = Field(min_length=1)
|
|
name: str = Field(min_length=1)
|
|
description: str | None = None
|
|
is_active: bool = True
|
|
|
|
|
|
class MaterialCategoryUpdate(APIModel):
|
|
slug: str | None = Field(default=None, min_length=1)
|
|
name: str | None = Field(default=None, min_length=1)
|
|
description: str | None = None
|
|
is_active: bool | None = None
|
|
|
|
|
|
class AttributeDefinitionCreate(APIModel):
|
|
attribute_key: str = Field(min_length=1)
|
|
label: str = Field(min_length=1)
|
|
data_type: AttributeDataType
|
|
unit_id: int | None = None
|
|
enum_values: list | None = None
|
|
|
|
|
|
class AttributeDefinitionUpdate(APIModel):
|
|
label: str | None = Field(default=None, min_length=1)
|
|
data_type: AttributeDataType | None = None
|
|
unit_id: int | None = None
|
|
enum_values: list | None = None
|
|
|
|
|
|
class CategoryAttributeCreate(APIModel):
|
|
attribute_definition_id: UUID
|
|
is_required: bool = False
|
|
is_filterable: bool = False
|
|
conditional_rules: dict | None = None
|
|
sort_order: int = 0
|
|
|
|
|
|
class CategoryAttributeUpdate(APIModel):
|
|
is_required: bool | None = None
|
|
is_filterable: bool | None = None
|
|
conditional_rules: dict | None = None
|
|
sort_order: int | None = None
|
|
|
|
|
|
class MaterialAttributeIn(APIModel):
|
|
"""One EAV value. `value` is coerced against attribute_definitions.data_type."""
|
|
|
|
attribute_definition_id: UUID
|
|
value: int | float | str | bool | None = None
|
|
|
|
|
|
class MaterialCreate(APIModel):
|
|
name: str = Field(min_length=1)
|
|
topic: str | None = None # تاپیک جلوی برنامه (Persian) — legacy
|
|
canonical_unit_id: int
|
|
status: MaterialStatus = "active"
|
|
brand_ids: list[UUID] = Field(default_factory=list)
|
|
attributes: list[MaterialAttributeIn] = Field(default_factory=list)
|
|
|
|
|
|
class MaterialUpdate(APIModel):
|
|
name: str | None = Field(default=None, min_length=1)
|
|
topic: str | None = None
|
|
canonical_unit_id: int | None = None
|
|
status: MaterialStatus | None = None
|
|
|
|
|
|
class BrandMaterialCreate(APIModel):
|
|
brand_id: UUID
|
|
material_id: UUID
|
|
catalog_code: str | None = None
|
|
is_active: bool = True
|
|
|
|
|
|
class BrandMaterialUpdate(APIModel):
|
|
# brand_id / material_id are immutable (UNIQUE pair) — not accepted here.
|
|
catalog_code: str | None = None
|
|
is_active: bool | None = None
|
|
|
|
|
|
class MaterialUnitCreate(APIModel):
|
|
brand_material_id: UUID | None = None
|
|
unit_id: int
|
|
conversion_factor: Decimal = Field(gt=0)
|
|
is_default: bool = False
|
|
|
|
|
|
class MaterialUnitUpdate(APIModel):
|
|
unit_id: int | None = None
|
|
conversion_factor: Decimal | None = Field(default=None, gt=0)
|
|
is_default: bool | None = None
|
|
|
|
|
|
class MaterialAttributeValuesPut(APIModel):
|
|
attributes: list[MaterialAttributeIn] = Field(default_factory=list)
|
|
|
|
|
|
class CategoryReferenceUnitPut(APIModel):
|
|
ref_unit: str = Field(min_length=1)
|
|
|
|
|
|
class UnitConversionCreate(APIModel):
|
|
category_id: UUID
|
|
brand: str | None = None
|
|
from_unit: str = Field(min_length=1)
|
|
to_unit: str = Field(min_length=1)
|
|
factor: Decimal = Field(gt=0)
|
|
|
|
|
|
class UnitConversionUpdate(APIModel):
|
|
brand: str | None = None
|
|
from_unit: str | None = Field(default=None, min_length=1)
|
|
to_unit: str | None = Field(default=None, min_length=1)
|
|
factor: Decimal | None = Field(default=None, gt=0)
|
|
|
|
|
|
class StandardConversionCreate(APIModel):
|
|
from_unit: str = Field(min_length=1)
|
|
to_unit: str = Field(min_length=1)
|
|
factor: Decimal = Field(gt=0)
|
|
|
|
|
|
class StandardConversionUpdate(APIModel):
|
|
from_unit: str | None = Field(default=None, min_length=1)
|
|
to_unit: str | None = Field(default=None, min_length=1)
|
|
factor: Decimal | None = Field(default=None, gt=0)
|