4 changed files with 166 additions and 43 deletions
+101 -1
View File
@@ -7,7 +7,7 @@ GET /materials/{id}/suppliers — suppliers linked to a material.
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db from app.db.engine import get_db
@@ -28,7 +28,11 @@ def link_to_dict(
category: MaterialCategory | None = None, category: MaterialCategory | None = None,
supplier: Supplier | None = None, supplier: Supplier | None = None,
) -> dict: ) -> 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 for the API envelope."""
=======
"""Serialize a SupplierMaterial ORM row to a plain dict."""
>>>>>>> Stashed changes
return { return {
"id": str(link.id), "id": str(link.id),
"supplier_id": str(link.supplier_id), "supplier_id": str(link.supplier_id),
@@ -111,6 +115,7 @@ async def create_supplier_material(
async def delete_supplier_material( async def delete_supplier_material(
supplier_id: UUID, supplier_id: UUID,
material_id: UUID, material_id: UUID,
<<<<<<< Updated upstream
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
async with db.begin(): async with db.begin():
@@ -161,12 +166,61 @@ async def list_material_suppliers(
"location": "تهران" | 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) material = await db.get(Material, material_id)
if material is None: if material is None:
raise NotFoundError("متریال یافت نشد") raise NotFoundError("متریال یافت نشد")
<<<<<<< Updated upstream
stmt = ( stmt = (
select(Supplier) select(Supplier)
.join(SupplierMaterial, Supplier.id == SupplierMaterial.supplier_id) .join(SupplierMaterial, Supplier.id == SupplierMaterial.supplier_id)
@@ -186,4 +240,50 @@ async def list_material_suppliers(
} }
for supplier in 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
} }
+5 -11
View File
@@ -458,10 +458,9 @@ function adaptBrandLink(raw: RawBrandLink): BrandLinkRow {
return { id: raw.id, brand_id: raw.brand_id, brand_name: raw.brand?.name || "" }; return { id: raw.id, brand_id: raw.brand_id, brand_name: raw.brand?.name || "" };
} }
// تأمین‌کنندگان متصل به یک متریال — پاسخ GET /materials/{id}/suppliers // ── تأمین‌کننده‌های متریال — پاسخ GET /materials/{id}/suppliers ──────────────
// Raw shape mirrors the backend dict exactly: clean supplier objects produced // Raw shape mirrors the backend dict exactly. The backend uses an atomic
// by an atomic JOIN (select(Supplier).join(SupplierMaterial)…) — the name // column-level JOIN so `name` always comes from the suppliers table.
// always comes from the suppliers table, never from the material row.
interface RawMaterialSupplier { interface RawMaterialSupplier {
id: string; id: string;
name: string; name: string;
@@ -482,8 +481,6 @@ export interface MaterialSupplier {
function adaptMaterialSupplier(raw: RawMaterialSupplier): MaterialSupplier { function adaptMaterialSupplier(raw: RawMaterialSupplier): MaterialSupplier {
return { return {
id: raw.id, id: raw.id,
// 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.
name: raw.name, name: raw.name,
phone: raw.phone, phone: raw.phone,
contact_person: raw.contact_person, contact_person: raw.contact_person,
@@ -1159,13 +1156,10 @@ export const api = {
updateCanonicalUnit: async (id: string, canonical_unit_id: number): Promise<void> => { updateCanonicalUnit: async (id: string, canonical_unit_id: number): Promise<void> => {
await patchData(`/materials/${id}`, { canonical_unit_id }); await patchData(`/materials/${id}`, { canonical_unit_id });
}, },
// فهرست تأمین‌کننده‌های متصل به یک متریال (GET /materials/{id}/suppliers) // فهرست تأمین‌کننده‌های متصل به یک متریال — GET /materials/{id}/suppliers
listSuppliers: async (materialId: string): Promise<MaterialSupplier[]> => { listSuppliers: async (materialId: string): Promise<MaterialSupplier[]> => {
// The backend returns { data: [...clean supplier objects] } from an
// atomic JOIN; getData<T> unwraps the envelope. No fallback data —
// an empty array means the material genuinely has no suppliers.
const raw = await getData<RawMaterialSupplier[]>( const raw = await getData<RawMaterialSupplier[]>(
`/materials/${materialId}/suppliers`, `/materials/${materialId}/suppliers?page_size=100`,
); );
return raw.map(adaptMaterialSupplier); return raw.map(adaptMaterialSupplier);
}, },
@@ -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" 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"
> >
<Ruler className="h-4 w-4 text-gray-500 dark:text-zinc-400" /> <Ruler className="h-4 w-4 text-gray-500 dark:text-zinc-400" />
<span>ضرایب تبدیل</span> <span>اقلام و ضرایب</span>
</Link> </Link>
{/* دکمه اصلی: افزودن پیشنهاد جدید */} {/* دکمه اصلی: افزودن پیشنهاد جدید */}
+58 -29
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
Building2, Building2,
@@ -103,9 +104,10 @@ export function UnitsPage() {
const [rowsLoading, setRowsLoading] = useState(false); const [rowsLoading, setRowsLoading] = useState(false);
const [brandLinks, setBrandLinks] = useState<BrandLinkRow[]>([]); const [brandLinks, setBrandLinks] = useState<BrandLinkRow[]>([]);
// تأمین‌کننده‌های متریال انتخاب‌شده — type imported from api.ts // تأمین‌کننده‌های متریال انتخاب‌شده — داده از GET /materials/{id}/suppliers
const [materialSuppliers, setMaterialSuppliers] = useState<MaterialSupplier[]>([]); const [materialSuppliers, setMaterialSuppliers] = useState<MaterialSupplier[]>([]);
const [suppliersLoading, setSuppliersLoading] = useState(false); const [suppliersLoading, setSuppliersLoading] = useState(false);
// کلید پیشگیری از flicker: قبل از رسیدن پاسخ جدید، empty state نشان نمی‌دهیم
const [suppliersLoadedId, setSuppliersLoadedId] = useState<string | null>(null); const [suppliersLoadedId, setSuppliersLoadedId] = useState<string | null>(null);
// واحدها و برندهای سراسری (فهرست دیتابیس) // واحدها و برندهای سراسری (فهرست دیتابیس)
@@ -291,9 +293,7 @@ export function UnitsPage() {
if (!alive) return; if (!alive) return;
setMaterialSuppliers([]); setMaterialSuppliers([]);
setSuppliersLoadedId(materialId); setSuppliersLoadedId(materialId);
// Surface the real error so backend issues are not silently hidden toast.error(err instanceof Error ? err.message : "خواندن تأمین‌کننده‌های متریال ناموفق بود");
const msg = err instanceof Error ? err.message : "خواندن تأمین‌کننده‌های متریال ناموفق بود";
toast.error(msg);
}) })
.finally(() => { if (alive) setSuppliersLoading(false); }); .finally(() => { if (alive) setSuppliersLoading(false); });
return () => { alive = false; }; return () => { alive = false; };
@@ -485,7 +485,7 @@ export function UnitsPage() {
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<Header <Header
title="ضرایب تبدیل" title="اقلام و ضرایب"
sub="متریال را انتخاب کنید — واحد مرجع و ضریب تبدیل همان متریال" sub="متریال را انتخاب کنید — واحد مرجع و ضریب تبدیل همان متریال"
backTo="/" backTo="/"
backLabel="بازگشت به دفتر" backLabel="بازگشت به دفتر"
@@ -582,41 +582,70 @@ export function UnitsPage() {
</div> </div>
) : ( ) : (
<> <>
{/* تامین‌کنندگان متریال — پاسخ GET /materials/{id}/suppliers */} {/* ── تامین‌کنندگان متریال ── */}
<div className="mb-6 flex flex-col overflow-hidden rounded-xl border border-line bg-panel shadow-sm"> <div className="mb-5 overflow-hidden rounded-xl border border-line bg-panel shadow-sm">
<div className="shrink-0 border-b border-line bg-panel px-4 py-3"> {/* سرصفحه — همیشه نمایش داده می‌شود */}
<h4 className="flex items-center gap-2 text-[14px] font-bold text-accent"> <div className="border-b border-line px-4 py-3">
<Building2 className="h-4 w-4" /> تامین‌کنندگان متریال <h4 className="text-[14px] font-bold text-gray-900">تامین‌کنندگان متریال</h4>
</h4>
</div> </div>
<div className="flex w-full flex-1 flex-col p-4 transition-opacity duration-300 ease-in-out opacity-100 min-h-[80px] md:min-h-[100px]">
{/* محتوا — min-h برای جلوگیری از layout shift */}
<div className="min-h-[80px] p-4 transition-opacity duration-300 ease-in-out">
{suppliersLoading || materialId !== suppliersLoadedId ? ( {suppliersLoading || materialId !== suppliersLoadedId ? (
<div className="flex flex-1 flex-col items-center justify-center text-center text-sm text-muted"> /* اسپینر — نه empty state — تا پاسخ API برسد */
<div className="h-5 w-5 animate-spin rounded-full border-2 border-muted border-t-transparent"></div> <div className="flex h-[80px] items-center justify-center">
<span className="h-5 w-5 animate-spin rounded-full border-2 border-muted border-t-transparent" />
</div> </div>
) : materialSuppliers.length === 0 ? ( ) : materialSuppliers.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center text-center text-sm text-gray-400 md:text-base"> <p className="flex h-[80px] items-center justify-center text-sm text-muted">
تامین‌کننده‌ای برای این متریال ثبت نشده است تامین‌کننده‌ای برای این متریال ثبت نشده است
</div> </p>
) : ( ) : (
<div className="flex flex-col gap-2 max-h-[200px] overflow-y-auto custom-scrollbar pr-1"> <div className="flex flex-col gap-3 mt-4 max-h-[320px] overflow-y-auto">
{materialSuppliers.map((s) => ( {materialSuppliers.map((s) => (
<div <div
key={s.id} key={s.id}
className="rounded-lg border border-line bg-slate-bg px-3 py-2.5 text-sm shrink-0" className="flex flex-col md:flex-row md:items-center justify-between p-4 bg-white border border-gray-200 rounded-xl shadow-sm gap-4 w-full"
> >
{/* نام تأمین‌کننده — مستقیم از پاسخ API، بدون fallback */} {/* نام تامین‌کننده */}
<div className="flex items-center gap-1.5 text-[13px] font-semibold"> <div className="flex-1 flex flex-col gap-1 text-right">
<Building2 className="h-3.5 w-3.5 shrink-0 text-accent" /> <span className="text-xs font-medium text-gray-900">نام تامین‌کننده</span>
{s.name} <Link
to={`/suppliers/${s.id}`}
className="font-medium text-accent hover:underline decoration-dashed"
>
{s.name}
</Link>
</div>
{/* نام مسئول */}
<div className="flex-1 flex flex-col gap-1 text-right">
<span className="text-xs font-medium text-gray-900">نام مسئول</span>
<span className="text-gray-500">
{s.contact_person || <span className="text-gray-500">ثبت نشده</span>}
</span>
</div>
{/* تلفن */}
<div className="flex-1 flex flex-col gap-1 text-right">
<span className="text-xs font-medium text-gray-900">تلفن</span>
{s.phone ? (
<a
href={`tel:${s.phone}`}
dir="ltr"
className="font-medium text-accent hover:underline decoration-dashed"
>
{s.phone}
</a>
) : (
<span className="text-gray-500">ثبت نشده</span>
)}
</div>
{/* محل */}
<div className="flex-1 flex flex-col gap-1 text-right">
<span className="text-xs font-medium text-gray-900">محل</span>
<span className="text-gray-500">
{s.location || <span className="text-gray-500">ثبت نشده</span>}
</span>
</div> </div>
{(s.location || s.phone || s.contact_person) && (
<div className="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[11.5px] text-muted">
{s.location && <span>{s.location}</span>}
{s.phone && <span dir="ltr">{s.phone}</span>}
{s.contact_person && <span>{s.contact_person}</span>}
</div>
)}
</div> </div>
))} ))}
</div> </div>