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).
100 lines
4.2 KiB
Python
100 lines
4.2 KiB
Python
"""Supplier <-> Material catalog links (v2.5).
|
|
|
|
GET/POST /suppliers/{id}/materials — manage a supplier's catalog.
|
|
DELETE /suppliers/{id}/materials/{material_id} — unlink.
|
|
GET /materials/{id}/suppliers — price-comparison data source.
|
|
"""
|
|
from fastapi import APIRouter, Depends, Query
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.db.engine import get_db
|
|
from app.models.catalog import Material, MaterialCategory
|
|
from app.models.procurement import Supplier, SupplierMaterial
|
|
from app.schemas.procurement import SupplierMaterialCreate
|
|
from app.services.supplier_material_service import link_supplier_material, unlink_supplier_material
|
|
from app.utils.errors import NotFoundError
|
|
from app.utils.pagination import paginate
|
|
from app.utils.serialization import to_iso
|
|
|
|
router = APIRouter(tags=["supplier-materials"])
|
|
|
|
|
|
def link_to_dict(link: SupplierMaterial, material: Material | None = None,
|
|
category: MaterialCategory | None = None,
|
|
supplier: Supplier | None = None) -> dict:
|
|
return {
|
|
"id": str(link.id),
|
|
"supplier_id": str(link.supplier_id),
|
|
"material_id": str(link.material_id),
|
|
"is_active": link.is_active,
|
|
"last_quoted_price": float(link.last_quoted_price) if link.last_quoted_price is not None else None,
|
|
"last_quoted_at": to_iso(link.last_quoted_at) if link.last_quoted_at else None,
|
|
"created_at": to_iso(link.created_at),
|
|
"material": {
|
|
"id": str(material.id),
|
|
"name": material.name,
|
|
"category": {"id": str(category.id), "name": category.name} if category else None,
|
|
} if material else None,
|
|
"supplier": {"id": str(supplier.id), "name": supplier.name} if supplier else None,
|
|
}
|
|
|
|
|
|
@router.get("/suppliers/{supplier_id}/materials")
|
|
async def list_supplier_materials(
|
|
supplier_id,
|
|
page: int = Query(1, ge=1),
|
|
page_size: int = Query(20, ge=1, le=100),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
supplier = await db.get(Supplier, supplier_id)
|
|
if supplier is None:
|
|
raise NotFoundError("تأمینکننده یافت نشد")
|
|
stmt = (
|
|
select(SupplierMaterial)
|
|
.where(SupplierMaterial.supplier_id == supplier_id)
|
|
.order_by(SupplierMaterial.created_at)
|
|
)
|
|
items, meta = await paginate(stmt, page, page_size, db)
|
|
mat_ids = {l.material_id for l in items}
|
|
materials = {m.id: m for m in (await db.execute(select(Material).where(Material.id.in_(mat_ids)))).scalars()}
|
|
return {"data": [link_to_dict(l, materials.get(l.material_id), None) for l in items], "meta": meta}
|
|
|
|
|
|
@router.post("/suppliers/{supplier_id}/materials", status_code=201)
|
|
async def create_supplier_material(
|
|
supplier_id, payload: SupplierMaterialCreate, db: AsyncSession = Depends(get_db)
|
|
):
|
|
async with db.begin():
|
|
link = await link_supplier_material(db, supplier_id, payload.material_id)
|
|
material = await db.get(Material, payload.material_id)
|
|
return {"data": link_to_dict(link, material, None)}
|
|
|
|
|
|
@router.delete("/suppliers/{supplier_id}/materials/{material_id}")
|
|
async def delete_supplier_material(supplier_id, material_id, db: AsyncSession = Depends(get_db)):
|
|
async with db.begin():
|
|
await unlink_supplier_material(db, supplier_id, material_id)
|
|
return {"data": {"supplier_id": str(supplier_id), "material_id": str(material_id), "deleted": True}}
|
|
|
|
|
|
@router.get("/materials/{material_id}/suppliers")
|
|
async def list_material_suppliers(
|
|
material_id,
|
|
page: int = Query(1, ge=1),
|
|
page_size: int = Query(20, ge=1, le=100),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
material = await db.get(Material, material_id)
|
|
if material is None:
|
|
raise NotFoundError("متریال یافت نشد")
|
|
stmt = (
|
|
select(SupplierMaterial)
|
|
.where(SupplierMaterial.material_id == material_id)
|
|
.order_by(SupplierMaterial.last_quoted_at.desc().nulls_last())
|
|
)
|
|
items, meta = await paginate(stmt, page, page_size, db)
|
|
sup_ids = {l.supplier_id for l in items}
|
|
suppliers = {s.id: s for s in (await db.execute(select(Supplier).where(Supplier.id.in_(sup_ids)))).scalars()}
|
|
return {"data": [link_to_dict(l, supplier=suppliers.get(l.supplier_id)) for l in items], "meta": meta}
|