// ============================================================ // API client — HTTP روی بک‌اند FastAPI (/api/v1) // // بک‌اند واقعی (PostgreSQL) منبع حقیقت است؛ این ماژول: // - همه‌ی داده‌ها را با fetch می‌خواند/می‌نویسد (localStorage حذف شد) // - شکل پاسخ بک‌اند (UUIDها، expand و ...) را به تایپ‌های جلوی // برنامه (types.ts) تبدیل می‌کند (adapter) // - خطاهای بک‌اند (envelope {error:{code,message,field}}) را به // Error با پیام فارسی تبدیل می‌کند // // BASE_URL: در Docker خالی است (پروکسی nginx → /api/v1)؛ در حالت // dev بدون پروکسی می‌توان VITE_API_BASE_URL را ست کرد. // ============================================================ import { normalize } from "./normalize"; import { buildTopics } from "./topics"; import { useAuthStore } from "../store/auth"; import type { Attachment, CategoryUnit, ConfirmedReceipt, InventoryRow, InventoryTransaction, Material, PendingArrival, Project, Quote, StandardConversion, Store, Supplier, TransactionType, UnitConversion, Warehouse, } from "./types"; const BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined)?.trim() || "/api/v1"; const PAGE_SIZE = 100; // حذف کلیدهای خالی/undefined از پارامترهای کوئری — URLSearchParams مقدار // undefined را به رشته‌ی "undefined" تبدیل می‌کند و بک‌اند ۴۰۰ برمی‌گرداند. function cleanParams(params: Record): Record { const out: Record = {}; for (const [k, v] of Object.entries(params)) { if (v !== undefined && v !== null && v !== "") out[k] = String(v); } return out; } // v2.6: توکن Casdoor (Bearer) یا هدرهای توسعه (X-User-*) — بک‌اند هر دو را می‌پذیرد. function authHeaders(init?: RequestInit): Record { const { token, user, isDev } = useAuthStore.getState(); const headers: Record = { "Content-Type": "application/json", ...((init?.headers as Record) || {}), }; if (token) { headers.Authorization = `Bearer ${token}`; } else if (isDev && user) { if (user.role) headers["X-User-Role"] = user.role; if (user.project_id) headers["X-Project-Id"] = user.project_id; if (user.display_name) { try { headers["X-User-Name-B64"] = btoa(unescape(encodeURIComponent(user.display_name))); } catch { /* نام غیر قابل encode — نادیده بگیر */ } } } return headers; } // پروژه‌ی فعالِ مدیر پروژه (چند-پروژه‌ای، 1:N): برای همه‌ی مسیرهای پنل انبار // صریحاً ارسال می‌شود تا بک‌اند داده‌های همان پروژه را برگرداند/اعتبارسنجی کند. // ادمین/مدیر تدارکات بدون پارامتر (همه‌ی پروژه‌ها) باقی می‌ماند. function activeProjectId(): string | null { const { user } = useAuthStore.getState(); return user?.role === "pm" ? user.project_id : null; } function projectQuery(projectId?: string | null): string { const pid = projectId ?? activeProjectId(); return pid ? `?project_id=${pid}` : ""; } // ترجمه‌ی پیام‌های رایج اعتبارسنجی FastAPI (422) به فارسی const VALIDATION_FA = (msg: string): string => { const lower = msg.toLowerCase(); if (lower.includes("field required")) return "فیلد اجباری خالی است"; if (lower.includes("extra fields")) return "فیلد ناشناخته ارسال شده است"; if (lower.includes("value is not a valid uuid") || lower.includes("input should be a valid uuid")) return "شناسه‌ی ارسال‌شده معتبر نیست (UUID)"; if (lower.includes("not a valid") && lower.includes("date")) return "تاریخ واردشده معتبر نیست"; if (lower.includes("input should be a valid datetime")) return "تاریخ و ساعت واردشده معتبر نیست"; if (lower.includes("input should be a valid date")) return "تاریخ واردشده معتبر نیست"; if (lower.includes("input should be a valid integer")) return "عدد صحیح وارد کنید"; if (lower.includes("input should be a valid number")) return "عدد معتبر وارد کنید"; if (lower.includes("input should be a valid boolean")) return "مقدار درست/نادرست (بولی) وارد کنید"; if (lower.includes("not a valid") && lower.includes("email")) return "ایمیل واردشده معتبر نیست"; if (lower.includes("greater than or equal to")) return "مقدار از حداقل مجاز کوچک‌تر است"; if (lower.includes("less than or equal to")) return "مقدار از حداکثر مجاز بزرگ‌تر است"; if (lower.includes("greater than 0")) return "مقدار باید بزرگ‌تر از صفر باشد"; if (lower.includes("string too short") || lower.includes("at least 1 character")) return "مقدار خیلی کوتاه است"; if (lower.includes("string too long")) return "مقدار خیلی طولانی است"; if (lower.includes("must be a valid")) return "مقدار واردشده معتبر نیست"; if (lower.includes("input should be")) return "نوع داده‌ی ارسال‌شده درست نیست"; if (lower.includes("value error")) return "مقدار واردشده نامعتبر است"; return msg; }; // ترجمه‌ی نام فیلدهای انگلیسی بک‌اند به فارسی (برای پیام‌های اعتبارسنجی) const FIELD_FA: Record = { supplier_id: "تأمین‌کننده", project_id: "پروژه", project_ids: "پروژه‌ها", line_items: "اقلام", termsheets: "شرایط پرداخت", item_termsheets: "قیمت اقلام", quantity: "تعداد", unit_price: "قیمت واحد", total_price: "قیمت کل", unit_id: "واحد", material_id: "متریال", category_id: "دسته‌بندی", brand: "برند", name: "نام", label: "عنوان", notes: "یادداشت", factor: "ضریب تبدیل", from_unit: "واحد مبدأ", to_unit: "واحد مقصد", ref_unit: "واحد مرجع", status: "وضعیت", categories: "متریال‌ها", file: "فایل", quote_date: "تاریخ پیشنهاد", expiry_date: "تاریخ انقضا", accepted_date: "تاریخ پذیرش", arrived_date: "تاریخ رسید", }; const faField = (field: string): string => { const last = field.split(".").filter(Boolean).pop() || field; return FIELD_FA[last] || FIELD_FA[field] || field; }; // ترجمه‌ی خطای HTTP بر اساس کد وضعیت const faHttpError = (status: number, fallback: string): string => { if (status === 413) return "فایل انتخاب‌شده از حد مجاز (۵ مگابایت) بزرگ‌تر است"; if (status === 404) return "مورد درخواستی یافت نشد"; if (status === 409) return "تعارض داده — رکورد تکراری یا وضعیت نامعتبر"; if (status === 400) return "داده‌ی ارسال‌شده نامعتبر است"; return fallback; }; // ============================================================ // HTTP helpers — envelope بک‌اند: {"data": ..., "meta": ...} // ============================================================ async function request(path: string, init?: RequestInit): Promise { let res: Response; try { res = await fetch(BASE_URL + path, { ...init, headers: authHeaders(init), }); } catch { throw new Error("اتصال به سرور برقرار نشد — آیا بک‌اند در دسترس است؟"); } if (!res.ok) { let msg = `خطای سرور (${res.status})`; try { const body = await res.json(); if (body?.error?.message) { msg = body.error.message; } else if (Array.isArray(body?.detail)) { // FastAPI 422 — آرایه‌ی خطاهای اعتبارسنجی با پیام انگلیسی const first = body.detail[0]; const field = first?.loc?.length ? String(first.loc[first.loc.length - 1]) : ""; const fa = first?.msg ? VALIDATION_FA(String(first.msg)) : "داده‌ی ارسال‌شده نامعتبر است"; msg = field ? `داده‌ی «${faField(field)}» نامعتبر است: ${fa}` : fa; } else if (body?.detail) { msg = String(body.detail); } } catch { /* بدنۀ خطا JSON نیست */ } throw new Error(faHttpError(res.status, msg)); } return (await res.json()) as T; } async function getData(path: string): Promise { const res = await request<{ data: T }>(path); return res.data; } async function postData(path: string, body: unknown): Promise { const res = await request<{ data: T }>(path, { method: "POST", body: JSON.stringify(body) }); return res.data; } async function patchData(path: string, body: unknown): Promise { const res = await request<{ data: T }>(path, { method: "PATCH", body: JSON.stringify(body) }); return res.data; } async function putData(path: string, body: unknown): Promise { const res = await request<{ data: T }>(path, { method: "PUT", body: JSON.stringify(body) }); return res.data; } async function deleteData(path: string): Promise { const res = await request<{ data: T }>(path, { method: "DELETE" }); return res.data; } // آپلود چندبخشی (multipart) — بدون هدر Content-Type دستی تا مرورگر // boundary درست بگذارد (JSON بودن هدر request، آپلود را خراب می‌کند). async function uploadFile(path: string, file: File): Promise { const form = new FormData(); form.append("file", file); let res: Response; try { // Content-Type را حذف می‌کنیم — مرورگر باید multipart boundary را خودش // بگذارد؛ وگرنه FastAPI فرم را نمی‌خواند و ۴۰۰/۴۲۲ برمی‌گردد. const headers = authHeaders(); delete headers["Content-Type"]; res = await fetch(BASE_URL + path, { method: "POST", body: form, headers }); } catch { throw new Error("اتصال به سرور برقرار نشد — آیا بک‌اند در دسترس است؟"); } if (!res.ok) { let msg = `خطای سرور (${res.status})`; try { const body = await res.json(); if (body?.error?.message) msg = body.error.message; } catch { /* بدنۀ خطا JSON نیست */ } throw new Error(faHttpError(res.status, msg)); } const body = (await res.json()) as { data: RawAttachment }; return adaptAttachment(body.data); } // ============================================================ // شکل خام پاسخ بک‌اند (فقط فیلدهایی که adapter استفاده می‌کند) // ============================================================ interface RawUnit { id: number; code: string; name_fa: string | null; } interface RawCategory { id: string; slug: string; name: string; } interface RawMaterial { id: string; name: string; topic?: string | null; category?: RawCategory | null; brands?: { id: string; name: string }[] | null; canonical_unit?: RawUnit | null; canonical_unit_id?: number | null; attributes?: RawSpec[] | null; // مشخصه‌ها (EAV) } interface RawSupplier { id: string; name: string; location?: string | null; phone?: string | null; contact_person?: string | null; tax_id?: string | null; categories?: string[] | null; // متریال‌های تأمین‌شده } interface RawProject { id: string; name: string; } interface RawSpec { attribute_key: string; label: string; value: unknown; unit: { id: number; code: string } | null; } interface RawDecision { id: string; status: string; chosen_item_termsheet_id?: string | null; adjusted_unit_price?: number | null; decided_at?: string | null; } interface RawReceipt { id: string; received_qty: number; received_at?: string | null; on_time?: boolean | null; allocations?: { warehouse_id: string; warehouse_name: string; quantity: number }[] | null; } interface RawLineItem { id: string; material?: RawMaterial | null; brand_material_id?: string | null; unit?: RawUnit | null; quantity: number | null; unit_price?: number | null; total_price?: number | null; specs?: RawSpec[] | null; decision?: RawDecision | null; receipt?: RawReceipt | null; termsheet_ids?: string[] | null; accepted_termsheet_id?: string | null; // id ترم‌شیت پذیرفته‌شده (task: خط آبی) projects?: RawProject[] | null; // پروژه‌های تخصیص‌یافته به این قلم } interface RawTermsheet { id: string; label: string; status: string; discount_type?: string; discount_value?: number; schedule_steps?: { id: string; step_no: number; due_date: string | null; amount: number; note: string | null }[] | null; } interface RawEvent { id: string; event_type: string; occurred_at: string; actor?: string | null; } interface RawQuote { id: string; supplier_id: string; project_id?: string | null; quote_date: string; expiry_date?: string | null; accepted_date?: string | null; arrived_date?: string | null; created_at?: string | null; status: string; is_official?: boolean; notes?: string | null; supplier?: RawSupplier | null; project?: RawProject | null; projects?: RawProject[] | null; // چند پروژه (quote_projects) events?: RawEvent[] | null; line_items?: RawLineItem[] | null; termsheets?: RawTermsheet[] | null; } interface RawCategoryUnit { id: string; category_id: string; ref_unit: string; category?: RawCategory | null; } interface RawUnitConversion { id: string; category_id: string; brand?: string | null; from_unit: string; to_unit: string; factor: number; category?: RawCategory | null; } interface RawStandardConversion { id: string; from_unit: string; to_unit: string; factor: number; } interface RawMaterialCategory { id: string; slug: string; name: string; } // یک ردیف material_units از بک‌اند (با expand=unit) interface RawMaterialUnit { id: string; material_id: string; brand_material_id: string | null; unit_id: number; conversion_factor: number; is_default: boolean; unit?: { id: number; code: string; name_fa: string | null } | null; } export interface MaterialUnitRow { id: string; material_id: string; brand_material_id: string | null; unit_id: number; conversion_factor: number; is_default: boolean; unit: { id: number; code: string; name_fa: string | null } | null; } function adaptMaterialUnit(raw: RawMaterialUnit): MaterialUnitRow { return { id: raw.id, material_id: raw.material_id, brand_material_id: raw.brand_material_id, unit_id: raw.unit_id, conversion_factor: raw.conversion_factor, is_default: raw.is_default, unit: raw.unit ?? null, }; } // اتصال برند ↔ متریال (brand_materials) برای قید برند در ضریب‌های تبدیل interface RawBrandLink { id: string; brand_id: string; material_id: string; brand?: { id: string; name: string } | null; } export interface BrandLinkRow { id: string; brand_id: string; brand_name: string; } function adaptBrandLink(raw: RawBrandLink): BrandLinkRow { return { id: raw.id, brand_id: raw.brand_id, brand_name: raw.brand?.name || "" }; } // فایل پیوست — پاسخ بک‌اند (metadata؛ بایت‌ها از endpoint دانلود می‌آیند) interface RawAttachment { id: string; quote_id: string; kind: string; file_name: string; mime_type: string; size_bytes: number; uploaded_at: string; } // ============================================================ // Lookup tables (ساخته‌شده در listAll از units / categories) // ============================================================ interface Lookup { units: RawUnit[]; categories: RawMaterialCategory[]; unitIdToName: Map; unitNameToId: Map; // کلید: نام نرمال‌شده (بدون فاصله) categoryIdToSlug: Map; categoryIdToName: Map; categoryNameToId: Map; // کلید: نام نرمال‌شده categorySlugToName: Map; // slug → نام دسته (تاپیک) brandNameToId: Map; // کلید: نام نرمال‌شده } function norm(s: string): string { return normalize(s).replace(/\s+/g, ""); } function buildLookup(units: RawUnit[], categories: RawMaterialCategory[]): Lookup { const unitIdToName = new Map(); const unitNameToId = new Map(); for (const u of units) { unitIdToName.set(u.id, u.name_fa || u.code); unitNameToId.set(norm(u.name_fa || u.code), u.id); unitNameToId.set(norm(u.code), u.id); } const categoryIdToSlug = new Map(); const categoryIdToName = new Map(); const categoryNameToId = new Map(); const categorySlugToName = new Map(); for (const c of categories) { categoryIdToSlug.set(c.id, c.slug); categoryIdToName.set(c.id, c.name); categoryNameToId.set(norm(c.name), c.id); categorySlugToName.set(c.slug, c.name); } return { units, categories, unitIdToName, unitNameToId, categoryIdToSlug, categoryIdToName, categoryNameToId, categorySlugToName, brandNameToId: new Map() }; } // ============================================================ // Adapters — بک‌اند → تایپ‌های جلوی برنامه // ============================================================ // v2.6 backend: بک‌اند برای ساخت تأمین‌کننده‌ی جدید category_id الزامی دارد. // دسته از روی نام تاپیکِ انتخاب‌شده پیدا می‌شود (تاپیک = دسته؛ نام‌ها از همان // منبع /material-categories می‌آیند، پس مقایسه روی نام نرمال‌شده دقیق است). let _categoryIdCache: { name: string; id: string }[] | null = null; async function categoryIdForTopic(topic: string): Promise { if (!_categoryIdCache) { try { const cats = await getData(`/material-categories?page_size=100`); _categoryIdCache = cats.map((c) => ({ name: c.name, id: c.id })); } catch { _categoryIdCache = []; } } const wanted = norm(topic); const match = _categoryIdCache.find((c) => norm(c.name) === wanted); return match ? match.id : ""; } function adaptMaterial(raw: RawMaterial, lookup: Lookup): Material { return { id: raw.id, name: raw.name, // تاپیک ذخیره‌شده در بک‌اند اولویت دارد؛ وگرنه از slug دسته استنتاج می‌شود topic: raw.topic || (raw.category?.slug ? lookup.categorySlugToName.get(raw.category.slug) || "" : ""), category: raw.category?.name || "", brand: raw.brands?.[0]?.name || "", // مشخصه‌ها از attribute های متریال خوانده می‌شوند (EAV) specs: (raw.attributes || []) .map((a) => String(a.value ?? a.label ?? "")) .filter(Boolean), base_unit: raw.canonical_unit?.name_fa || raw.canonical_unit?.code || (raw.canonical_unit_id != null ? lookup.unitIdToName.get(raw.canonical_unit_id) || "" : ""), }; } function adaptLineItem(raw: RawLineItem): Quote["line_items"][number] { return { id: raw.id, material_id: raw.material?.id || "", quantity: raw.quantity ?? null, unit: raw.unit?.name_fa || raw.unit?.code || "", unit_price: raw.decision?.adjusted_unit_price ?? raw.unit_price ?? null, total_price: raw.total_price ?? null, termsheet_ids: raw.termsheet_ids || [], // بک‌اند decision.chosen_item_termsheet_id یک id از item_termsheets است؛ // id ترم‌شیت واقعی از فیلد جدید accepted_termsheet_id می‌آید (task: خط آبی) accepted_ts_id: raw.accepted_termsheet_id ?? raw.decision?.chosen_item_termsheet_id ?? null, rejected: raw.decision?.status === "rejected", arrived_date: raw.receipt?.received_at ?? null, received_qty: raw.receipt?.received_qty ?? null, received_warehouses: (raw.receipt?.allocations || []).map((a) => ({ id: a.warehouse_id, name: a.warehouse_name, quantity: a.quantity, })), projects: (raw.projects || []).map((p) => ({ id: p.id, name: p.name })), }; } function adaptTermsheet(raw: RawTermsheet): Quote["termsheets"][number] { return { id: raw.id, label: raw.label, milestones: (raw.schedule_steps || []).map((s) => ({ when_iso: s.due_date || null, amount: s.amount ?? null, })), status: raw.status as Quote["termsheets"][number]["status"], }; } function adaptQuote(raw: RawQuote): Quote { return { id: raw.id, supplier_id: raw.supplier_id, project: raw.project?.name || "", projects: raw.projects && raw.projects.length ? raw.projects.map((p) => p.name) : raw.project ? [raw.project.name] : [], quote_date: raw.quote_date || "", created_at: raw.created_at || raw.quote_date || null, expiry_date: raw.expiry_date || null, accepted_date: raw.accepted_date || null, arrived_date: raw.arrived_date || null, status: raw.status as Quote["status"], is_official: raw.is_official ?? true, // پیوست‌ها از endpoint جداگانه (api.files.list) خوانده می‌شوند proformas: [], invoices: [], events: (raw.events || []).map((e) => ({ type: e.event_type, occurred_at: e.occurred_at })), notes: raw.notes || "", line_items: (raw.line_items || []).map((li) => adaptLineItem(li)), termsheets: (raw.termsheets || []).map((t) => adaptTermsheet(t)), }; } function adaptSupplier(raw: RawSupplier, topic: string, categories: string[]): Supplier { return { id: raw.id, topic, // دسته‌های ذخیره‌شده روی تأمین‌کننده (بک‌اند) اولویت دارند؛ وگرنه از quotes استنتاج می‌شود categories: Array.isArray(raw.categories) && raw.categories.length ? raw.categories : categories, name: raw.name, location: raw.location || "", phone: raw.phone || "", contact: raw.contact_person || "", }; } // تأمین‌کننده‌ها در بک‌اند تاپیک/دسته ندارند — از قلم‌های پیشنهادهایشان // (دسته‌ی جنس‌های پیشنهادشده) استنتاج می‌شود. // تأمین‌کننده‌های جدید که هنوز پیشنهادی ندارند از supplierTopicRegistry // topic و category خود را می‌گیرند. function deriveSupplierTopics( rawSuppliers: RawSupplier[], quotes: Quote[], materialsById: Map, ): Supplier[] { const topics = new Map>(); const cats = new Map>(); for (const q of quotes) { for (const li of q.line_items) { const m = materialsById.get(li.material_id); if (!m) continue; if (!topics.has(q.supplier_id)) topics.set(q.supplier_id, new Set()); if (!cats.has(q.supplier_id)) cats.set(q.supplier_id, new Set()); if (m.topic) topics.get(q.supplier_id)!.add(m.topic); if (m.category) cats.get(q.supplier_id)!.add(m.category); } } return rawSuppliers.map((s) => { const t = topics.get(s.id); const c = cats.get(s.id); // اگر از quotes topic/category پیدا نشد، registry را چک کن if (!t || t.size === 0) { const reg = supplierTopicRegistry.get(s.id); if (reg) { return adaptSupplier(s, reg.topic, reg.categories); } } return adaptSupplier(s, t ? [...t][0] : "", c ? [...c].sort((a, b) => a.localeCompare(b, "fa")) : []); }); } function adaptCategoryUnit(raw: RawCategoryUnit): CategoryUnit { return { id: raw.id, category: raw.category?.name || "", ref_unit: raw.ref_unit }; } function adaptUnitConversion(raw: RawUnitConversion, lookup: Lookup): UnitConversion { return { id: raw.id, category: raw.category?.name || lookup.categoryIdToName.get(raw.category_id) || "", brand: raw.brand || "", from_unit: raw.from_unit, to_unit: raw.to_unit, factor: raw.factor, }; } function adaptStandardConversion(raw: RawStandardConversion): StandardConversion { return { id: raw.id, from_unit: raw.from_unit, to_unit: raw.to_unit, factor: raw.factor }; } // بک‌اند → Attachment (فایل‌های ذخیره‌شده در MinIO) function adaptAttachment(raw: RawAttachment): Attachment { return { id: raw.id, name: raw.file_name, mime_type: raw.mime_type, size_bytes: raw.size_bytes, uploaded_at: raw.uploaded_at, }; } // ============================================================ // Date helpers — بک‌اند datetime/date را جدا می‌پذیرد // ============================================================ function toDateOnly(v: string | null | undefined): string | null { if (!v) return null; const m = String(v).match(/^(\d{4}-\d{2}-\d{2})/); if (m) return m[1]; // datetime کامل → تاریخ به وقت محلی (نه UTC که یک روز عقب می‌افتد) const d = new Date(v); if (Number.isNaN(d.getTime())) return null; return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; } function toDateTime(v: string | null | undefined): string | null { const d = toDateOnly(v); if (!d) return null; return /^\d{4}-\d{2}-\d{2}$/.test(v as string) ? d + "T00:00:00Z" : (v as string); } function todayIso(): string { return new Date().toISOString().slice(0, 10); } function looksLikeUuid(v: string): boolean { return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v); } // ============================================================ // Registry موقت: material ساخته‌شده در همین نشست (id موقت → UUID واقعی) // ویزارد id موقت ("m10") را در قلم ذخیره می‌کند؛ هنگام ساخت پیشنهاد // باید به UUID واقعی بک‌اند ترجمه شود. // ============================================================ const tempIdToRealId = new Map(); function realMaterialId(id: string): string { return tempIdToRealId.get(id) || id; } // ============================================================ // Supplier topic/category tracking — بک‌اند topic و category // برای تأمین‌کننده ذخیره نمی‌کند. این Map اطلاعات topic و // categories را برای تأمین‌کننده‌های جدید (بدون پیشنهاد) نگه // می‌دارد تا در deriveSupplierTopics استفاده شود. // ============================================================ const supplierTopicRegistry = new Map(); function registerSupplierTopic(id: string, topic: string, categories: string[]): void { if (topic || categories.length) { supplierTopicRegistry.set(id, { topic, categories }); } } // ============================================================ // API // ============================================================ export const api = { // ---------- کل store (یک بار در ریشه) ---------- async listAll(): Promise { const [quotesRes, suppliersRes, materialsRes, projectsRes, unitsRes, categoriesRes, cruRes, ucRes, scuRes] = await Promise.all([ getData(`/quotes?expand=supplier,project,events,line_items,termsheets,decision,receipt&page_size=${PAGE_SIZE}`), getData(`/suppliers?page_size=${PAGE_SIZE}`), getData(`/materials?expand=category,brands,canonical_unit,attributes&page_size=${PAGE_SIZE}`), getData(`/projects?page_size=${PAGE_SIZE}`), getData(`/units?page_size=${PAGE_SIZE}`), getData(`/material-categories?page_size=${PAGE_SIZE}`), getData(`/category-reference-units?expand=category&page_size=${PAGE_SIZE}`), getData(`/unit-conversions?expand=category&page_size=${PAGE_SIZE}`), getData(`/standard-conversions?page_size=${PAGE_SIZE}`), ]); const lookup = buildLookup(unitsRes, categoriesRes); const materials = materialsRes.map((m) => adaptMaterial(m, lookup)); const materialsById = new Map(materials.map((m) => [m.id, m])); const quotes = quotesRes.map((q) => adaptQuote(q)); const suppliers = deriveSupplierTopics(suppliersRes, quotes, materialsById); return { suppliers, materials, projects: projectsRes.map((p) => ({ id: p.id, name: p.name })), quotes, categoryUnits: cruRes.map(adaptCategoryUnit), unitConversions: ucRes.map((uc) => adaptUnitConversion(uc, lookup)), standardConversions: scuRes.map(adaptStandardConversion), topics: buildTopics(categoriesRes), }; }, quotes: { list: async (): Promise => { const raw = await getData( `/quotes?expand=supplier,project,events,line_items,termsheets,decision,receipt&page_size=${PAGE_SIZE}`, ); return raw.map((q) => adaptQuote(q)); }, get: async (id: string): Promise => { const raw = await getData(`/quotes/${id}?expand=supplier,project,events,line_items,termsheets`); return adaptQuote(raw); }, create: async (q: Quote): Promise => { const lookup = await fetchLookup(); const lineItems: { temp_id: string; material_id: string; unit_id: number; quantity: number; project_ids: string[] }[] = (q.line_items || []).map((li) => ({ temp_id: li.id, material_id: realMaterialId(li.material_id), unit_id: 0, // placeholder — بعداً resolve می‌شود quantity: li.quantity ?? 1, project_ids: [], })); // واحدِ تایپ‌شده‌ی کاربر در بک‌اند نیست → همان موقع ساخته می‌شود (بدون هیچ // مقدار پیش‌فرض/واحد جادویی؛ واحد خالی یعنی خطای صریح «واحد مشخص نشده») // پروژه‌های هر قلم هم به id واقعی ترجمه می‌شوند (در صورت نبودن ساخته می‌شوند) for (let i = 0; i < lineItems.length; i++) { lineItems[i].unit_id = await unitIdByNameOrCreate(lookup, q.line_items[i].unit); for (const p of q.line_items[i].projects || []) { const pid = looksLikeUuid(p.id) ? p.id : await projectIdByNameOrCreate(p.name); if (pid) lineItems[i].project_ids.push(pid); } } const termsheets = (q.termsheets || []).map((t) => ({ temp_id: t.id, label: t.label, discount_type: "none", discount_value: 0, schedule_steps: (t.milestones || []).map((m, i) => ({ step_no: i + 1, due_date: toDateOnly(m.when_iso) || todayIso(), amount: m.amount ?? 0, })), })); // فقط اقلامِ دارای قیمت به termsheet لینک می‌شوند — ذخیره‌ی ناقص مجاز است const itemTerms = (q.line_items || []).flatMap((li) => li.unit_price != null && li.unit_price > 0 ? (li.termsheet_ids || []).map((tsId) => ({ line_item_temp_id: li.id, termsheet_temp_id: tsId, unit_price: li.unit_price!, })) : [], ); // همه‌ی پروژه‌ها (نه فقط اولی) — ساخته می‌شوند اگر در بک‌اند نیستند const projectNames = q.projects?.length ? q.projects : q.project ? [q.project] : []; const projectIds: string[] = []; for (const pname of projectNames) { const pid = await projectIdByNameOrCreate(pname); if (pid) projectIds.push(pid); } const payload = { supplier_id: q.supplier_id, project_id: projectIds[0] || null, project_ids: projectIds, quote_date: toDateTime(q.quote_date), expiry_date: toDateOnly(q.expiry_date), is_official: q.is_official ?? true, notes: q.notes || null, line_items: lineItems, termsheets: termsheets, item_termsheets: itemTerms, }; const raw = await postData("/quotes", payload); return adaptQuote(raw); }, update: async (id: string, patch: Partial): Promise => { const current = await api.quotes.get(id); // --- تشخیص مقصد: رسید (arrive) / پذیرش (accept) / ویرایش ساده --- const curByLi = new Map(current.line_items.map((li) => [li.id, li])); const patchedLis = patch.line_items || []; const arrivalChanged = patchedLis.some( (li) => (li.arrived_date ?? null) !== (curByLi.get(li.id)?.arrived_date ?? null), ); const becomingArrived = patch.status === "arrived" && current.status !== "arrived"; const becomingAccepted = patch.status === "accepted" && current.status !== "accepted"; if (becomingArrived || arrivalChanged) { // هر قلم با arrived_date جدید → receipt؛ قلمی که حذف شده → DELETE receipt const arrive = patchedLis .filter((li) => li.arrived_date) .map((li) => ({ line_item_id: li.id, received_qty: li.quantity ?? 1, received_at: li.arrived_date, on_time: true, })); if (arrive.length) { await postData(`/quotes/${id}/arrive-items`, { items: arrive }); } for (const li of patchedLis) { if (!li.arrived_date && curByLi.get(li.id)?.arrived_date) { await deleteData(`/quotes/${id}/line-items/${li.id}/decision/receipt`).catch(() => undefined); } } return { ...current, ...patch } as Quote; } if (becomingAccepted) { const acceptedTs = (patch.termsheets || []).filter((t) => t.status === "accepted"); // تخصیص پروژه به هر قلم — نام‌ها به id واقعی ترجمه می‌شوند const itemProjects: { line_item_id: string; project_ids: string[] }[] = []; const allProjectIds: string[] = []; const seenPids = new Set(); for (const li of patch.line_items || []) { const ids: string[] = []; for (const p of li.projects || []) { const pid = looksLikeUuid(p.id) ? p.id : await projectIdByNameOrCreate(p.name); if (!pid) continue; ids.push(pid); if (!seenPids.has(pid)) { seenPids.add(pid); allProjectIds.push(pid); } } itemProjects.push({ line_item_id: li.id, project_ids: ids }); } const payload = { accepted_termsheets: acceptedTs.map((t) => ({ id: looksLikeUuid(t.id) ? t.id : undefined, label: t.label, discount_type: "none", discount_value: 0, schedule_steps: (t.milestones || []).map((m, i) => ({ step_no: i + 1, due_date: toDateOnly(m.when_iso) || todayIso(), amount: m.amount ?? 0, })), })), item_decisions: (patch.line_items || []).map((li) => { const idx = li.accepted_ts_id ? acceptedTs.findIndex((t) => t.id === li.accepted_ts_id) : -1; const chosen = !li.rejected && idx >= 0; // پروژه‌ی واحدِ انتخاب‌شده برای این قلم — مستقیم داخل decision می‌رود تا // «کالاهای در راه» همان مدیر پروژه را (با item_decisions.project_id) // نشان دهد؛ قبلاً فقط item_projects جداگانه می‌رفت و decision بدون // پروژه می‌ماند (قلم‌ها در پنل هیچ مدیر پروژه‌ای دیده نمی‌شدند) const chosenProjId = itemProjects.find((p) => p.line_item_id === li.id)?.project_ids[0]; return { line_item_id: li.id, status: chosen ? "chosen" : "rejected", ...(chosen ? { accepted_termsheet_index: idx } : {}), ...(chosen && chosenProjId ? { project_id: chosenProjId } : {}), ...(li.unit_price != null ? { adjusted_unit_price: li.unit_price } : {}), }; }), item_projects: itemProjects, }; await patchData(`/quotes/${id}/accept`, payload); // پروژه‌های سطح پیشنهاد با اتحاد پروژه‌های اقلام همگام می‌شوند if (allProjectIds.length) { await patchData(`/quotes/${id}`, { project_ids: allProjectIds }); } return { ...current, ...patch } as Quote; } // --- ویرایش ساده: اقلام/termsheetها (جایگزینی کامل) + فیلدهای metadata --- const childKey = (li: Quote["line_items"][number]) => JSON.stringify({ id: li.id, material_id: li.material_id, quantity: li.quantity, unit: li.unit, unit_price: li.unit_price, total_price: li.total_price, termsheet_ids: li.termsheet_ids || [], rejected: !!li.rejected, accepted_ts_id: li.accepted_ts_id ?? null, }); const childrenChanged = JSON.stringify(patchedLis.map(childKey)) !== JSON.stringify(current.line_items.map(childKey)) || JSON.stringify(patch.termsheets || []) !== JSON.stringify(current.termsheets || []); if (childrenChanged) { if (patch.status === "accepted" || patch.status === "arrived") { throw new Error("برای تغییر اقلام یا شرایط پرداخت، ابتدا وضعیت را به «پیشنهاد شده» برگردانید"); } // اگر هنوز offered نیست (بازگشت وضعیت)، اول وضعیت را برمی‌گردانیم if (current.status !== "offered") { await patchData(`/quotes/${id}`, { status: "offered" }); } await patchData(`/quotes/${id}/children`, await buildChildrenPayload(patchedLis, patch.termsheets || [])); } // --- فیلدهای metadata که بک‌اند می‌پذیرد --- const body: Record = {}; if (patch.supplier_id !== undefined) body.supplier_id = patch.supplier_id; const projNames = patch.projects?.length ? patch.projects : patch.project ? [patch.project] : []; if (patch.projects !== undefined || patch.project !== undefined) { const projectIds: string[] = []; for (const pname of projNames) { const pid = await projectIdByNameOrCreate(pname); if (pid) projectIds.push(pid); } body.project_ids = projectIds; } if (patch.quote_date !== undefined) body.quote_date = toDateTime(patch.quote_date); if (patch.expiry_date !== undefined) body.expiry_date = toDateOnly(patch.expiry_date); if (patch.is_official !== undefined) body.is_official = patch.is_official; if (patch.notes !== undefined) body.notes = patch.notes; if (patch.status !== undefined) body.status = patch.status; await patchData(`/quotes/${id}`, body); return { ...current, ...patch } as Quote; }, delete: async (id: string): Promise => { await deleteData(`/quotes/${id}`); }, }, suppliers: { list: async (): Promise => { const raw = await getData(`/suppliers?page_size=${PAGE_SIZE}`); const { quotes } = await api.listAll(); const materialsById = new Map((await api.materials.list()).map((m) => [m.id, m])); return deriveSupplierTopics(raw, quotes, materialsById); }, create: async (s: Supplier): Promise => { // v2.6 backend: category_id الزامی است — از تاپیکِ انتخاب‌شده استنتاج می‌شود const category_id = await categoryIdForTopic(s.topic || ""); const raw = await postData("/suppliers", { name: s.name, category_id, location: s.location || null, phone: s.phone || null, contact_person: s.contact || null, }); // ثبت topic/category برای این تأمین‌کننده تا در لیست‌ها دیده شود registerSupplierTopic(raw.id, s.topic || "", s.categories || []); return adaptSupplier(raw, s.topic || "", s.categories || []); }, update: async (id: string, patch: Partial): Promise => { const raw = await patchData(`/suppliers/${id}`, { ...(patch.name !== undefined ? { name: patch.name } : {}), ...(patch.location !== undefined ? { location: patch.location } : {}), ...(patch.phone !== undefined ? { phone: patch.phone } : {}), ...(patch.contact !== undefined ? { contact_person: patch.contact } : {}), }); if (patch.categories !== undefined) { registerSupplierTopic(id, patch.topic || "", patch.categories || []); } return adaptSupplier(raw, patch.topic || "", patch.categories || []); }, delete: async (id: string): Promise => { await deleteData(`/suppliers/${id}`); }, }, // v2.6 backend: اتصال واقعی تأمین‌کننده ↔ متریال (کاتالوگ) supplierMaterials: { list: async (supplierId: string): Promise<{ id: string; material_id: string; material_name: string }[]> => { const data = await getData< { id: string; material_id: string; material: { id: string; name: string } | null }[] >(`/suppliers/${supplierId}/materials?page_size=100`); return data.map((l) => ({ id: l.id, material_id: l.material_id, material_name: l.material?.name || "", })); }, create: async (supplierId: string, materialId: string): Promise => { await postData(`/suppliers/${supplierId}/materials`, { material_id: materialId }); }, remove: async (supplierId: string, materialId: string): Promise => { await deleteData(`/suppliers/${supplierId}/materials/${materialId}`); }, }, materials: { list: async (): Promise => { const raw = await getData(`/materials?expand=category,brands,canonical_unit,attributes&page_size=${PAGE_SIZE}`); const units = await getData(`/units?page_size=${PAGE_SIZE}`); const categories = await getData(`/material-categories?page_size=${PAGE_SIZE}`); return raw.map((m) => adaptMaterial(m, buildLookup(units, categories))); }, create: async (m: Material): Promise => { const lookup = await fetchLookup(); // v2.5: category_id اختیاری است — اگر نامِ تایپ‌شده یکی از ۸ دسته‌ی ثابت // نباشد، متریال به‌صورت سراسری (بدون دسته) ساخته می‌شود و دسته‌اش بعداً // از اولین تأمین‌کننده‌ی مرتبط استنتاج می‌شود (first-link-wins). // دسته‌ی rogue دیگر هرگز ساخته نمی‌شود — فقط تطبیق با ۸ دسته‌ی موجود؛ // در صورت نبود تطبیق، خطا نمی‌دهیم و category_id را null می‌فرستیم. const categoryId = m.category ? lookup.categoryNameToId.get(norm(m.category)) : undefined; // واحد مرجع — بدون مقدار پیش‌فرض؛ متریال بدون واحدِ صریح ساخته نمی‌شود // (بک‌اند canonical_unit_id را الزامی می‌داند) const unitName = (m.base_unit || "").trim(); if (!unitName) { throw new Error("واحد مرجع متریال مشخص نشده است — واحد را وارد کنید"); } const unitId = await unitIdByNameOrCreate(lookup, unitName); // برند — اگر تعریف نشده، در جدول brands ساخته و به متریال متصل می‌شود const brandId = await brandIdByNameOrCreate(lookup, m.brand); // تایپ جلوی برنامه name ندارد — از متریال/برند/مشخصه ساخته می‌شود const name = [m.brand, m.category, ...(m.specs || [])].filter(Boolean).join(" ") || "کالای جدید"; // مشخصه‌ها به‌صورت attribute (EAV) ذخیره می‌شوند تا بعد از رفرش دیده شوند const attrs: { attribute_definition_id: string; value: string }[] = []; const seen = new Set(); for (const spec of m.specs || []) { const key = norm(spec); if (!key || seen.has(key)) continue; seen.add(key); const defId = await specDefinitionId(spec); if (defId) attrs.push({ attribute_definition_id: defId, value: spec }); } const raw = await postData("/materials", { category_id: categoryId ?? null, name, topic: m.topic || null, canonical_unit_id: unitId, status: "active", brand_ids: brandId ? [brandId] : [], attributes: attrs, }); // ویزارد با id موقت به قلم ارجاع می‌دهد — ثبت نگاشت برای ساخت پیشنهاد tempIdToRealId.set(m.id, raw.id); return adaptMaterial(raw, lookup); }, // تغییر واحد مرجع متریال (صفحه‌ی ضرایب تبدیل) updateCanonicalUnit: async (id: string, canonical_unit_id: number): Promise => { await patchData(`/materials/${id}`, { canonical_unit_id }); }, }, projects: { list: async (): Promise => { const raw = await getData(`/projects?page_size=${PAGE_SIZE}`); return raw.map((p) => ({ id: p.id, name: p.name })); }, create: async (p: Project): Promise => { const raw = await postData("/projects", { name: p.name }); return { id: raw.id, name: raw.name }; }, }, units: { getCategoryUnits: async (): Promise => { const raw = await getData(`/category-reference-units?expand=category&page_size=${PAGE_SIZE}`); return raw.map(adaptCategoryUnit); }, saveCategoryUnit: async (cu: CategoryUnit): Promise => { const lookup = await fetchLookup(); const categoryId = lookup.categoryNameToId.get(norm(cu.category)); if (!categoryId) throw new Error(`دسته‌بندی «${cu.category}» یافت نشد`); await putData(`/category-reference-units/${categoryId}`, { ref_unit: cu.ref_unit }); }, getConversions: async (): Promise => { const lookup = await fetchLookup(); const raw = await getData(`/unit-conversions?expand=category&page_size=${PAGE_SIZE}`); return raw.map((uc) => adaptUnitConversion(uc, lookup)); }, saveConversion: async (c: UnitConversion): Promise => { const lookup = await fetchLookup(); const categoryId = lookup.categoryNameToId.get(norm(c.category)); if (!categoryId) throw new Error(`دسته‌بندی «${c.category}» یافت نشد`); const body = { category_id: categoryId, brand: c.brand || null, from_unit: c.from_unit, to_unit: c.to_unit, factor: c.factor, }; if (looksLikeUuid(c.id)) { await patchData(`/unit-conversions/${c.id}`, { brand: c.brand || null, from_unit: c.from_unit, to_unit: c.to_unit, factor: c.factor, }); } else { await postData("/unit-conversions", body); } }, deleteConversion: async (id: string): Promise => { await deleteData(`/unit-conversions/${id}`); }, getStandardConversions: async (): Promise => { const raw = await getData(`/standard-conversions?page_size=${PAGE_SIZE}`); return raw.map(adaptStandardConversion); }, saveStandardConversion: async (c: StandardConversion): Promise => { const body = { from_unit: c.from_unit, to_unit: c.to_unit, factor: c.factor }; if (looksLikeUuid(c.id)) { await patchData(`/standard-conversions/${c.id}`, body); } else { await postData("/standard-conversions", body); } }, deleteStandardConversion: async (id: string): Promise => { await deleteData(`/standard-conversions/${id}`); }, }, // ---------- واحدهای هر متریال (material_units — ضریب تبدیل نسبت به واحد مرجع) ---------- materialUnits: { list: async (materialId: string): Promise => { const data = await getData( `/materials/${materialId}/units?page_size=100&expand=unit,brand_material`, ); return data.map(adaptMaterialUnit); }, create: async ( materialId: string, body: { brand_material_id?: string | null; unit_id: number; conversion_factor: number; is_default?: boolean; }, ): Promise => { const raw = await postData(`/materials/${materialId}/units`, body); return adaptMaterialUnit(raw); }, update: async ( materialId: string, muId: string, body: { conversion_factor?: number; is_default?: boolean }, ): Promise => { const raw = await patchData(`/materials/${materialId}/units/${muId}`, body); return adaptMaterialUnit(raw); }, remove: async (materialId: string, muId: string): Promise => { await deleteData(`/materials/${materialId}/units/${muId}`); }, }, // ---------- اتصال برند↔متریال (برای قید برند در ضریب‌های تبدیل) ---------- brandMaterials: { listByMaterial: async (materialId: string): Promise => { const data = await getData( `/brand-materials?material_id=${materialId}&page_size=100&expand=brand`, ); return data.map(adaptBrandLink); }, link: async (materialId: string, brandId: string): Promise => { await postData("/brand-materials", { brand_id: brandId, material_id: materialId }); }, }, // ---------- پیوست‌ها (invoice / proforma) — بایت‌ها در MinIO ---------- files: { upload: (quoteId: string, kind: FileKind, file: File): Promise => uploadFile(`/quotes/${quoteId}/${kind}`, file), list: async (quoteId: string, kind: FileKind): Promise => { const raw = await getData(`/quotes/${quoteId}/${kind}`); return raw.map(adaptAttachment); }, remove: async (quoteId: string, kind: FileKind, id: string): Promise => { await deleteData(`/quotes/${quoteId}/${kind}/${id}`); }, downloadUrl: (quoteId: string, kind: FileKind, id: string): string => `${BASE_URL}/quotes/${quoteId}/${kind}/${id}`, }, // ---------- برندها (از بک‌اند) ---------- brands: { list: async (): Promise<{ id: string; name: string }[]> => { const raw = await getData<{ id: string; name: string }[]>(`/brands?page_size=${PAGE_SIZE}`); return raw.map((b) => ({ id: b.id, name: b.name })); }, create: async (name: string): Promise<{ id: string; name: string }> => { const raw = await postData<{ id: string; name: string }>("/brands", { name, country: null }); return { id: raw.id, name: raw.name }; }, }, brandsList: async (): Promise<{ id: string; name: string }[]> => { return getData<{ id: string; name: string }[]>(`/brands?page_size=${PAGE_SIZE}`); }, // ---------- واحدها (از بک‌اند) ---------- unitsList: async (): Promise => { return getData(`/units?page_size=${PAGE_SIZE}`); }, // بک‌اند endpoint بازنشانی ندارد — داده‌ها دوباره از سرور خوانده می‌شوند reset: () => api.listAll(), }; // ============================================================ // Helpers داخلی // ============================================================ async function fetchLookup(): Promise { const [units, categories] = await Promise.all([ getData(`/units?page_size=${PAGE_SIZE}`), getData(`/material-categories?page_size=${PAGE_SIZE}`), ]); return buildLookup(units, categories); } async function fetchProjects(): Promise { return getData(`/projects?page_size=${PAGE_SIZE}`); } // پیمودن پروژه‌ها: پیدا کردن id پروژه؛ اگر نبود، در بک‌اند می‌سازد async function projectIdByNameOrCreate(name: string | null | undefined): Promise { if (!name) return null; const projects = await fetchProjects(); const pid = projectIdByName(projects, name); if (pid) return pid; const raw = await postData("/projects", { name }); return raw.id; } // پیمودن اقلام/termsheetها برای PATCH /quotes/{id}/children (جایگزینی کامل) async function buildChildrenPayload( lineItems: Quote["line_items"][number][], termsheets: Quote["termsheets"][number][], ): Promise<{ line_items: unknown[]; termsheets: unknown[]; item_termsheets: unknown[] }> { const lookup = await fetchLookup(); const lis: { temp_id: string; material_id: string; unit_id: number; quantity: number; project_ids: string[] }[] = lineItems.map((li) => ({ temp_id: li.id, material_id: realMaterialId(li.material_id), unit_id: 0, // placeholder — بعداً resolve می‌شود quantity: li.quantity ?? 1, project_ids: [], })); for (let i = 0; i < lis.length; i++) { lis[i].unit_id = await unitIdByNameOrCreate(lookup, lineItems[i].unit); for (const p of lineItems[i].projects || []) { const pid = looksLikeUuid(p.id) ? p.id : await projectIdByNameOrCreate(p.name); if (pid) lis[i].project_ids.push(pid); } } const tss = termsheets.map((t) => ({ temp_id: t.id, label: t.label, discount_type: "none", discount_value: 0, schedule_steps: (t.milestones || []).map((m, i) => ({ step_no: i + 1, due_date: toDateOnly(m.when_iso) || todayIso(), amount: m.amount ?? 0, })), })); const itemTerms = lineItems.flatMap((li) => li.unit_price != null && li.unit_price > 0 ? (li.termsheet_ids || []).map((tsId) => ({ line_item_temp_id: li.id, termsheet_temp_id: tsId, unit_price: li.unit_price!, })) : [], ); return { line_items: lis, termsheets: tss, item_termsheets: itemTerms }; } // واحد را پیدا می‌کند؛ اگر وجود نداشت (با نامِ دقیقِ تایپ‌شده‌ی کاربر)، در // بک‌اند می‌سازد و id برمی‌گرداند. هیچ واحد پیش‌فرضی تزریق نمی‌شود — نام // خالی/بی‌معنی یعنی خطای صریح. async function unitIdByNameOrCreate(lookup: Lookup, name: string): Promise { const key = norm(name); if (!key) throw new Error("واحد قلم مشخص نشده است — واحد را وارد کنید"); const existing = lookup.unitNameToId.get(key); if (existing != null) return existing; // واحد جدید — ساخت در دیتابیس (code = نام نرمال‌شده‌ی همان واحد تایپ‌شده) const raw = await postData("/units", { code: key, name_fa: name.trim(), dimension: null, si_factor: 1, is_base: false, }); lookup.unitNameToId.set(key, raw.id); return raw.id; } // برند: پیدا کردن برند موجود (در حافظه یا بک‌اند) و اگر نبود، ساخت آن // تا material با brand_ids واقعی ساخته شود و بعد از رفرش برند دیده شود async function brandIdByNameOrCreate(lookup: Lookup, name: string): Promise { if (!name) return null; const key = norm(name); const cached = lookup.brandNameToId.get(key); if (cached) return cached; // جستجو در بک‌اند (ممکن است برند قبلاً ساخته شده باشد) const found = await getData<{ id: string; name: string }[]>(`/brands?q=${encodeURIComponent(name)}&page_size=100`); const hit = found.find((b) => norm(b.name) === key); if (hit) { lookup.brandNameToId.set(key, hit.id); return hit.id; } const raw = await postData<{ id: string; name: string }>("/brands", { name, country: null }); lookup.brandNameToId.set(key, raw.id); return raw.id; } function projectIdByName(projects: RawProject[], name: string | undefined | null): string | null { if (!name) return null; const p = projects.find((x) => norm(x.name) === norm(name)); return p ? p.id : null; } // --------------------------------------------------------------------------- // مشخصه‌ها (specs) — هر مشخصه یک attribute definition (EAV) است // --------------------------------------------------------------------------- const specDefCache = new Map(); // norm(spec) → attribute_definition_id // پیدا کردن/ساخت attribute definition برای یک مشخصه‌ی متنی async function specDefinitionId(spec: string): Promise { const key = norm(spec); if (!key) return null; const cached = specDefCache.get(key); if (cached) return cached; try { const raw = await postData<{ id: string }>("/attribute-definitions", { attribute_key: "spec_" + key.slice(0, 60), label: spec, data_type: "string", }); specDefCache.set(key, raw.id); return raw.id; } catch { // تکراری یا ناسازگار — با برچسب جستجو کن const list = await getData<{ id: string; label: string }[]>( `/attribute-definitions?q=${encodeURIComponent(spec)}&page_size=20`, ); const hit = list.find((d) => d.label === spec); if (hit) { specDefCache.set(key, hit.id); return hit.id; } return null; } } // utility برای ساخت id موقت جدید (ویزارد — قبل از ذخیره در بک‌اند) export function nextId(prefix: string, items: { id: string }[]): string { const max = items .map((x) => parseInt(String(x.id).replace(/\D/g, ""), 10)) .filter((n) => !Number.isNaN(n)) .reduce((a, b) => Math.max(a, b), 0); return prefix + (max + 1); } // id ترتیبی با خط تیره، مثلاً "li-3", "ts-4" export function nextDashedId(prefix: string, items: { id: string }[]): string { const max = items .map((x) => { const m = String(x.id).match(new RegExp("^" + prefix + "-(\\d+)$")); return m ? parseInt(m[1], 10) : 0; }) .reduce((a, b) => Math.max(a, b), 0); return prefix + "-" + (max + 1); } // helper برای خواندن فایل به صورت dataUrl — حذف شد: آپلودها مستقیم به // سرور (MinIO) می‌روند؛ بایت‌ها هرگز در حافظه‌ی مرورگر نمی‌مانند. // kind پیوست: فاکتور یا پیش‌فاکتور export type FileKind = "invoice" | "proforma"; // ============================================================ // v2.6 — inventory + PM arrival API (React port of the shipped dash) // ============================================================ const rawWarehouse = (w: { id: string; project_id: string; name: string; description: string | null; project?: { id: string; name: string } | null; items_count?: number; }): Warehouse => ({ id: w.id, project_id: w.project_id, name: w.name, description: w.description, project: w.project ?? null, items_count: w.items_count, }); const rawInventory = (r: { id: string; warehouse: { id: string; project_id: string; name: string; description: string | null }; material: { id: string; name: string; category: { id: string; name: string } | null }; brand: { id: string; name: string } | null; quantity: string; quantity_number: number; unit: { id: number; code: string; name_fa: string | null } | null; specs: Record; updated_at: string; }): InventoryRow => ({ id: r.id, warehouse: { id: r.warehouse.id, project_id: r.warehouse.project_id, name: r.warehouse.name, description: r.warehouse.description, }, material: r.material, brand: r.brand, quantity: r.quantity, quantity_number: r.quantity_number, unit: r.unit, specs: r.specs, updated_at: r.updated_at, }); // Lookup tables for the inventory dash (thin wrappers over existing endpoints). interface RawCat { id: string; slug: string; name: string; } interface RawUnitL { id: number; code: string; name_fa: string | null; } // Organization panel — one user row (backend GET /users shape). export interface UserRow { id: string; casdoor_id: string; first_name: string; last_name: string; phone: string | null; role: "pm" | "procurement_manager" | "admin" | null; role_label: string | null; project_id: string | null; project_name: string | null; // 1:N — every project this PM manages (oldest first). projects?: { id: string; name: string }[]; created_at: string; } export const apiV26 = { auth: { // ورود درون‌صفحه‌ای: بک‌اند با grant_type=password از Casdoor توکن می‌گیرد // و نقش/پروفایل را از جدول users خودمان برمی‌گرداند. login: async (body: { username: string; password: string; }): Promise<{ access_token: string; expires_in: number | null; user: import("../store/auth").AuthUser }> => { const res = await request<{ data: { access_token: string; token_type: string; expires_in: number | null; user: { id: string; casdoor_id: string; first_name: string; last_name: string; phone: string | null; role: "pm" | "procurement_manager" | "admin" | null; role_label: string | null; project_id: string | null; project_name: string | null; projects?: { id: string; name: string }[]; }; }; }>("/auth/login", { method: "POST", body: JSON.stringify(body) }); const u = res.data.user; return { access_token: res.data.access_token, expires_in: res.data.expires_in, user: { ...u, display_name: `${u.first_name} ${u.last_name}`.trim() || u.casdoor_id, }, }; }, }, lookup: { categories: async (): Promise => getData("/material-categories?page_size=100"), units: async (): Promise => getData("/units?page_size=100"), brands: async (): Promise<{ id: string; name: string }[]> => getData<{ id: string; name: string }[]>("/brands?page_size=100"), materials: async (): Promise<{ id: string; name: string; category: { id: string; name: string } | null; canonical_unit: { id: number; code: string; name_fa: string | null } | null }[]> => getData("/materials?expand=category,canonical_unit&page_size=100"), projects: async (): Promise<{ id: string; name: string; pm_user_id: string | null; pm_name?: string | null }[]> => getData("/projects?page_size=100"), }, users: { me: async (): Promise => { const data = await getData<{ id: string; casdoor_id: string; first_name: string; last_name: string; phone: string | null; role: "pm" | "procurement_manager" | "admin" | null; role_label: string | null; project_id: string | null; project_name: string | null; projects?: { id: string; name: string }[]; }>("/users/me"); return { id: data.id, casdoor_id: data.casdoor_id, first_name: data.first_name, last_name: data.last_name, phone: data.phone, role: data.role, role_label: data.role_label, project_id: data.project_id, project_name: data.project_name, projects: data.projects ?? [], display_name: `${data.first_name} ${data.last_name}`.trim() || data.casdoor_id, }; }, // پروژه‌های تخصیص‌یافته به کاربر جاری (PM) — مبنای سوییچر پروژه + اعتبارسنجی myProjects: async (): Promise<{ id: string; name: string }[]> => getData<{ id: string; name: string }[]>("/users/me/projects"), // --- Organization panel (admin / procurement manager) --- list: async (): Promise => { const data = await getData("/users"); return data; }, create: async (body: { first_name: string; last_name?: string; phone?: string | null; casdoor_id?: string | null; role?: "pm" | "procurement_manager" | "admin" | null; }): Promise => postData("/users", body), updateRole: async (id: string, role: "pm" | "procurement_manager" | "admin" | null): Promise => patchData(`/users/${id}`, { role }), assignProject: async (id: string, projectId: string | null): Promise => postData(`/users/${id}/project`, { project_id: projectId }), // همگام‌سازی هویت‌ها از Casdoor (ادمین/مدیر تدارکات) — نقش‌ها دست‌نخورده می‌مانند sync: async (): Promise<{ users: UserRow[]; created: number }> => { const res = await request<{ data: UserRow[]; meta: { total: number; created: number } }>("/users/sync", { method: "POST", }); return { users: res.data, created: res.meta?.created ?? 0 }; }, }, // 1:N تخصیص مدیر پروژه ↔ پروژه: هر پروژه حداکثر یک PM دارد؛ // pm_user_id=null یعنی جداسازی مدیر از پروژه. فقط ادمین/مدیر تدارکات. projects: { setPm: async (projectId: string, userId: string | null): Promise => { await patchData(`/projects/${projectId}`, { pm_user_id: userId }); }, }, warehouses: { list: async (projectId?: string): Promise => { const pid = projectId ?? activeProjectId() ?? undefined; const qs = pid ? `?project_id=${pid}&page_size=100` : "?page_size=100"; const data = await getData[0][]>(`/inventory/warehouses${qs}`); return data.map(rawWarehouse); }, create: async (body: { project_id: string; name: string; description?: string | null }): Promise => { const d = await postData[0]>(`/inventory/warehouses${projectQuery()}`, body); return rawWarehouse(d); }, update: async (id: string, body: { name?: string; description?: string | null; project_id?: string }): Promise => { const d = await patchData[0]>(`/inventory/warehouses/${id}${projectQuery()}`, body); return rawWarehouse(d); }, delete: async (id: string): Promise => { await deleteData(`/inventory/warehouses/${id}${projectQuery()}`); }, }, inventory: { list: async (params: { project_id?: string; category_id?: string; warehouse_id?: string; material_id?: string; sort?: string } = {}): Promise => { const pid = params.project_id ?? activeProjectId() ?? undefined; const qs = new URLSearchParams({ page_size: "100", ...cleanParams({ ...params, project_id: pid }) }).toString(); const data = await getData[0][]>(`/inventory?${qs}`); return data.map(rawInventory); }, transactions: async (params: { project_id?: string; warehouse_id?: string; transaction_type?: string; date_from?: string; date_to?: string; sort?: string } = {}): Promise => { const pid = params.project_id ?? activeProjectId() ?? undefined; const qs = new URLSearchParams({ page_size: "100", ...cleanParams({ ...params, project_id: pid }) }).toString(); return getData(`/inventory/transactions?${qs}`); }, createTransaction: async (body: { transaction_type: TransactionType; warehouse_id: string; dest_warehouse_id?: string | null; material_id: string; brand_id?: string | null; quantity?: number | null; physical_qty?: number | null; unit_id?: number | null; specs?: Record | null; note?: string | null; transaction_date?: string | null; }): Promise => { return postData(`/inventory/transactions${projectQuery()}`, body); }, types: async (): Promise<{ type: TransactionType; label: string }[]> => { return getData<{ type: TransactionType; label: string }[]>("/inventory/meta/types"); }, materialOptions: async (category_id: string, warehouse_id?: string): Promise<{ id: string; name: string; brands: { id: string; name: string }[] }[]> => { const params: Record = { category_id }; if (warehouse_id) params.warehouse_id = warehouse_id; const pid = activeProjectId(); if (pid) params.project_id = pid; return getData<{ id: string; name: string; brands: { id: string; name: string }[] }[]>( `/inventory/materials/options?${new URLSearchParams(params).toString()}`, ); }, }, pm: { // پروژه فعال به‌صورت صریح ارسال می‌شود؛ بک‌اند تعلق آن به PM را بررسی می‌کند onTheWay: async (projectId?: string | null): Promise => getData(projectId ? `/pm/on-the-way?project_id=${projectId}` : "/pm/on-the-way"), confirmReceipt: async (body: { item_decision_id: string; received_qty: number; allocations: { warehouse_id: string; quantity: number }[]; received_at?: string | null; on_time?: boolean | null; quality_note?: string | null; }, projectId?: string | null): Promise<{ receipt_id: string; state: string; posted_transactions: string[] }> => postData(projectId ? `/pm/receipts?project_id=${projectId}` : "/pm/receipts", body), receipts: async (projectId?: string | null): Promise => getData(projectId ? `/pm/receipts?project_id=${projectId}` : "/pm/receipts"), }, // ساخت موجودیت‌های کاتالوگ سراسری از داخل پنل انبار (موجودی اولیه): // متریال/برند/واحد جدید بدون وابستگی به دفتر تدارکات ساخته می‌شوند. catalog: { createUnit: async (name: string): Promise<{ id: number; code: string; name_fa: string | null }> => { const key = norm(name); if (!key) throw new Error("نام واحد معتبر نیست"); return postData("/units", { code: key, name_fa: name.trim(), dimension: null, si_factor: 1, is_base: false, }); }, createBrand: async (name: string): Promise<{ id: string; name: string }> => postData("/brands", { name, country: null }), createMaterial: async (opts: { name: string; canonical_unit_id: number; brand_ids: string[]; }): Promise<{ id: string; name: string }> => postData("/materials", { name: opts.name, category_id: null, // دسته از اولین تأمین‌کننده‌ی مرتبط استنتاج می‌شود (v2.5) topic: null, canonical_unit_id: opts.canonical_unit_id, status: "active", brand_ids: opts.brand_ids, attributes: [], }), }, };