Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
416683b3d7 | ||
|
|
bc86d35e1e |
@@ -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
@@ -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>
|
||||||
|
|
||||||
{/* دکمه اصلی: افزودن پیشنهاد جدید */}
|
{/* دکمه اصلی: افزودن پیشنهاد جدید */}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
// واحدها و برندهای سراسری (فهرست دیتابیس)
|
// واحدها و برندهای سراسری (فهرست دیتابیس)
|
||||||
@@ -281,7 +283,7 @@ export function UnitsPage() {
|
|||||||
setSuppliersLoading(true);
|
setSuppliersLoading(true);
|
||||||
api.materials
|
api.materials
|
||||||
.listSuppliers(materialId)
|
.listSuppliers(materialId)
|
||||||
.then((list) => {
|
.then((list) => {
|
||||||
if (alive) {
|
if (alive) {
|
||||||
setMaterialSuppliers(list);
|
setMaterialSuppliers(list);
|
||||||
setSuppliersLoadedId(materialId);
|
setSuppliersLoadedId(materialId);
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user