290 lines
9.7 KiB
Python
290 lines
9.7 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 — suppliers linked to a material.
|
|
"""
|
|
from uuid import UUID
|
|
|
|
from fastapi import APIRouter, Depends, Query
|
|
from sqlalchemy import func, 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:
|
|
<<<<<<< Updated upstream
|
|
"""Serialize a SupplierMaterial ORM row to a plain dict for the API envelope."""
|
|
=======
|
|
"""Serialize a SupplierMaterial ORM row to a plain dict."""
|
|
>>>>>>> Stashed changes
|
|
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
|
|
),
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /suppliers/{supplier_id}/materials
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@router.get("/suppliers/{supplier_id}/materials")
|
|
async def list_supplier_materials(
|
|
supplier_id: UUID,
|
|
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 = [lnk.material_id for lnk in items]
|
|
materials: dict = {}
|
|
if mat_ids:
|
|
result = await db.execute(select(Material).where(Material.id.in_(mat_ids)))
|
|
materials = {m.id: m for m in result.scalars()}
|
|
return {
|
|
"data": [link_to_dict(lnk, materials.get(lnk.material_id)) for lnk in items],
|
|
"meta": meta,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST /suppliers/{supplier_id}/materials
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@router.post("/suppliers/{supplier_id}/materials", status_code=201)
|
|
async def create_supplier_material(
|
|
supplier_id: UUID,
|
|
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)}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# DELETE /suppliers/{supplier_id}/materials/{material_id}
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@router.delete("/suppliers/{supplier_id}/materials/{material_id}")
|
|
async def delete_supplier_material(
|
|
supplier_id: UUID,
|
|
material_id: UUID,
|
|
<<<<<<< Updated upstream
|
|
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,
|
|
}
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /materials/{material_id}/suppliers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@router.get(
|
|
"/materials/{material_id}/suppliers",
|
|
summary="List all suppliers linked to a material",
|
|
)
|
|
async def list_material_suppliers(
|
|
material_id: UUID,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Return the suppliers linked to a material, straight from PostgreSQL.
|
|
|
|
The path parameter is typed as UUID so FastAPI validates and coerces it
|
|
before the handler runs; a malformed id is rejected with 422, an unknown
|
|
material with 404.
|
|
|
|
Atomic JOIN — a supplier is returned only through its supplier_materials
|
|
link, so the response can never contain a supplier that is not actually
|
|
linked to this material:
|
|
|
|
select(Supplier)
|
|
.join(SupplierMaterial, Supplier.id == SupplierMaterial.supplier_id)
|
|
.where(SupplierMaterial.material_id == material_id)
|
|
|
|
Response envelope (frontend MaterialSupplier binds these fields directly):
|
|
{
|
|
"data": [
|
|
{
|
|
"id": "<supplier_uuid>",
|
|
"name": "فولاد ناب آریا",
|
|
"phone": "021-..." | null,
|
|
"contact_person": "علی محمدی" | null,
|
|
"location": "تهران" | null
|
|
}
|
|
]
|
|
=======
|
|
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,
|
|
}
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /materials/{material_id}/suppliers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@router.get(
|
|
"/materials/{material_id}/suppliers",
|
|
summary="List all suppliers linked to a material",
|
|
)
|
|
async def list_material_suppliers(
|
|
material_id: UUID,
|
|
page: int = Query(1, ge=1),
|
|
page_size: int = Query(20, ge=1, le=100),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Return a paginated list of suppliers for a given material.
|
|
|
|
Uses explicit column-level SELECT with named labels so SQLAlchemy returns
|
|
plain Row tuples — no ORM identity-map involvement — eliminating the
|
|
string-vs-UUID comparison bug and the stale-entity collision that occurred
|
|
when selecting two mapped entities in the same session.
|
|
|
|
Response shape (matched by the frontend RawMaterialSupplier interface):
|
|
{
|
|
"data": [
|
|
{
|
|
"id": "<supplier_uuid>",
|
|
"name": "فولاد ناب آریا",
|
|
"phone": "021-..." | null,
|
|
"contact_person": "علی محمدی" | null,
|
|
"location": "تهران" | null
|
|
}
|
|
],
|
|
"meta": {"total": 1, "page": 1, "page_size": 20}
|
|
>>>>>>> Stashed changes
|
|
}
|
|
"""
|
|
material = await db.get(Material, material_id)
|
|
if material is None:
|
|
raise NotFoundError("متریال یافت نشد")
|
|
|
|
<<<<<<< Updated upstream
|
|
stmt = (
|
|
select(Supplier)
|
|
.join(SupplierMaterial, Supplier.id == SupplierMaterial.supplier_id)
|
|
.where(SupplierMaterial.material_id == material_id)
|
|
.order_by(Supplier.name)
|
|
)
|
|
suppliers = (await db.execute(stmt)).scalars().all()
|
|
|
|
return {
|
|
"data": [
|
|
{
|
|
"id": str(supplier.id),
|
|
"name": supplier.name,
|
|
"phone": supplier.phone,
|
|
"contact_person": supplier.contact_person,
|
|
"location": supplier.location,
|
|
}
|
|
for supplier in suppliers
|
|
]
|
|
=======
|
|
total: int = (
|
|
await db.scalar(
|
|
select(func.count(SupplierMaterial.id)).where(
|
|
SupplierMaterial.material_id == material_id
|
|
)
|
|
)
|
|
) or 0
|
|
|
|
if total == 0:
|
|
return {"data": [], "meta": {"total": 0, "page": page, "page_size": page_size}}
|
|
|
|
# Explicit column-level SELECT — returns plain Row objects keyed by label,
|
|
# never ORM entities, so no identity-map collision is possible.
|
|
stmt = (
|
|
select(
|
|
Supplier.id.label("sup_id"),
|
|
Supplier.name.label("sup_name"),
|
|
Supplier.phone.label("sup_phone"),
|
|
Supplier.contact_person.label("sup_contact_person"),
|
|
Supplier.location.label("sup_location"),
|
|
)
|
|
.join(SupplierMaterial, Supplier.id == SupplierMaterial.supplier_id)
|
|
.where(SupplierMaterial.material_id == material_id)
|
|
.order_by(SupplierMaterial.last_quoted_at.desc().nulls_last())
|
|
.offset((page - 1) * page_size)
|
|
.limit(page_size)
|
|
)
|
|
|
|
rows = (await db.execute(stmt)).all()
|
|
|
|
data = [
|
|
{
|
|
"id": str(row.sup_id),
|
|
"name": row.sup_name,
|
|
"phone": row.sup_phone,
|
|
"contact_person": row.sup_contact_person,
|
|
"location": row.sup_location,
|
|
}
|
|
for row in rows
|
|
]
|
|
|
|
return {
|
|
"data": data,
|
|
"meta": {"total": total, "page": page, "page_size": page_size},
|
|
>>>>>>> Stashed changes
|
|
}
|