diff --git a/backend/app/routers/supplier_materials.py b/backend/app/routers/supplier_materials.py index 52e812a..b8069da 100644 --- a/backend/app/routers/supplier_materials.py +++ b/backend/app/routers/supplier_materials.py @@ -7,7 +7,7 @@ GET /materials/{id}/suppliers — suppliers linked to a material. from uuid import UUID from fastapi import APIRouter, Depends, Query -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.db.engine import get_db @@ -28,7 +28,11 @@ def link_to_dict( 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), @@ -111,6 +115,7 @@ async def create_supplier_material( async def delete_supplier_material( supplier_id: UUID, material_id: UUID, +<<<<<<< Updated upstream db: AsyncSession = Depends(get_db), ): async with db.begin(): @@ -161,12 +166,61 @@ async def list_material_suppliers( "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": "", + "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) @@ -186,4 +240,50 @@ async def list_material_suppliers( } 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 } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 33c6fa8..12cdb7a 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -458,10 +458,16 @@ function adaptBrandLink(raw: RawBrandLink): BrandLinkRow { return { id: raw.id, brand_id: raw.brand_id, brand_name: raw.brand?.name || "" }; } +<<<<<<< Updated upstream // تأمین‌کنندگان متصل به یک متریال — پاسخ GET /materials/{id}/suppliers // Raw shape mirrors the backend dict exactly: clean supplier objects produced // by an atomic JOIN (select(Supplier).join(SupplierMaterial)…) — the name // always comes from the suppliers table, never from the material row. +======= +// ── تأمین‌کننده‌های متریال — پاسخ GET /materials/{id}/suppliers ────────────── +// Raw shape mirrors the backend dict exactly. The backend uses an atomic +// column-level JOIN so `name` always comes from the suppliers table. +>>>>>>> Stashed changes interface RawMaterialSupplier { id: string; name: string; @@ -482,8 +488,11 @@ export interface MaterialSupplier { function adaptMaterialSupplier(raw: RawMaterialSupplier): MaterialSupplier { return { id: raw.id, +<<<<<<< Updated upstream // Bound straight from the supplier object in the API response — there is // no fallback, so a supplier row can never render the material's name. +======= +>>>>>>> Stashed changes name: raw.name, phone: raw.phone, contact_person: raw.contact_person, @@ -1159,6 +1168,7 @@ export const api = { updateCanonicalUnit: async (id: string, canonical_unit_id: number): Promise => { await patchData(`/materials/${id}`, { canonical_unit_id }); }, +<<<<<<< Updated upstream // فهرست تأمین‌کننده‌های متصل به یک متریال (GET /materials/{id}/suppliers) listSuppliers: async (materialId: string): Promise => { // The backend returns { data: [...clean supplier objects] } from an @@ -1166,6 +1176,12 @@ export const api = { // an empty array means the material genuinely has no suppliers. const raw = await getData( `/materials/${materialId}/suppliers`, +======= + // فهرست تأمین‌کننده‌های متصل به یک متریال — GET /materials/{id}/suppliers + listSuppliers: async (materialId: string): Promise => { + const raw = await getData( + `/materials/${materialId}/suppliers?page_size=100`, +>>>>>>> Stashed changes ); return raw.map(adaptMaterialSupplier); }, diff --git a/frontend/src/pages/Dashboard/DashboardPage.tsx b/frontend/src/pages/Dashboard/DashboardPage.tsx index 227b1cd..c289904 100644 --- a/frontend/src/pages/Dashboard/DashboardPage.tsx +++ b/frontend/src/pages/Dashboard/DashboardPage.tsx @@ -110,7 +110,7 @@ export function DashboardPage() { className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-200 text-xs font-semibold hover:bg-gray-50 dark:hover:bg-zinc-700 shadow-2xs transition-all no-underline" > - ضرایب تبدیل + اقلام و ضرایب {/* دکمه اصلی: افزودن پیشنهاد جدید */} diff --git a/frontend/src/pages/Units/UnitsPage.tsx b/frontend/src/pages/Units/UnitsPage.tsx index 03d32f5..9b231bc 100644 --- a/frontend/src/pages/Units/UnitsPage.tsx +++ b/frontend/src/pages/Units/UnitsPage.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from "react"; +import { Link } from "react-router-dom"; import { toast } from "sonner"; import { Building2, @@ -103,9 +104,16 @@ export function UnitsPage() { const [rowsLoading, setRowsLoading] = useState(false); const [brandLinks, setBrandLinks] = useState([]); +<<<<<<< Updated upstream // تأمین‌کننده‌های متریال انتخاب‌شده — type imported from api.ts const [materialSuppliers, setMaterialSuppliers] = useState([]); const [suppliersLoading, setSuppliersLoading] = useState(false); +======= + // تأمین‌کننده‌های متریال انتخاب‌شده — داده از GET /materials/{id}/suppliers + const [materialSuppliers, setMaterialSuppliers] = useState([]); + const [suppliersLoading, setSuppliersLoading] = useState(false); + // کلید پیشگیری از flicker: قبل از رسیدن پاسخ جدید، empty state نشان نمی‌دهیم +>>>>>>> Stashed changes const [suppliersLoadedId, setSuppliersLoadedId] = useState(null); // واحدها و برندهای سراسری (فهرست دیتابیس) @@ -281,7 +289,11 @@ export function UnitsPage() { setSuppliersLoading(true); api.materials .listSuppliers(materialId) +<<<<<<< Updated upstream .then((list) => { +======= + .then((list) => { +>>>>>>> Stashed changes if (alive) { setMaterialSuppliers(list); setSuppliersLoadedId(materialId); @@ -291,9 +303,13 @@ export function UnitsPage() { if (!alive) return; setMaterialSuppliers([]); setSuppliersLoadedId(materialId); +<<<<<<< Updated upstream // Surface the real error so backend issues are not silently hidden const msg = err instanceof Error ? err.message : "خواندن تأمین‌کننده‌های متریال ناموفق بود"; toast.error(msg); +======= + toast.error(err instanceof Error ? err.message : "خواندن تأمین‌کننده‌های متریال ناموفق بود"); +>>>>>>> Stashed changes }) .finally(() => { if (alive) setSuppliersLoading(false); }); return () => { alive = false; }; @@ -485,7 +501,7 @@ export function UnitsPage() { return (
) : ( <> +<<<<<<< Updated upstream {/* تامین‌کنندگان متریال — پاسخ GET /materials/{id}/suppliers */}
@@ -617,6 +634,72 @@ export function UnitsPage() { {s.contact_person && {s.contact_person}}
)} +======= + {/* ── تامین‌کنندگان متریال ── */} +
+ {/* سرصفحه — همیشه نمایش داده می‌شود */} +
+

تامین‌کنندگان متریال

+
+ + {/* محتوا — min-h برای جلوگیری از layout shift */} +
+ {suppliersLoading || materialId !== suppliersLoadedId ? ( + /* اسپینر — نه empty state — تا پاسخ API برسد */ +
+ +
+ ) : materialSuppliers.length === 0 ? ( +

+ تامین‌کننده‌ای برای این متریال ثبت نشده است +

+ ) : ( +
+ {materialSuppliers.map((s) => ( +
+ {/* نام تامین‌کننده */} +
+ نام تامین‌کننده + + {s.name} + +
+ {/* نام مسئول */} +
+ نام مسئول + + {s.contact_person || ثبت نشده} + +
+ {/* تلفن */} +
+ تلفن + {s.phone ? ( + + {s.phone} + + ) : ( + ثبت نشده + )} +
+ {/* محل */} +
+ محل + + {s.location || ثبت نشده} + +
+>>>>>>> Stashed changes
))}