Files
ERP/frontend/src/lib/api.ts
T

1800 lines
76 KiB
TypeScript

// ============================================================
// 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 { jalaliToGregorian, toAsciiDigits } from "./jalali";
import { useAuthStore } from "../store/auth";
import type {
Attachment,
ConfirmedReceipt,
InventoryRow,
InventoryTransaction,
Material,
PendingArrival,
Project,
Quote,
StandardConversion,
Store,
Supplier,
TransactionType,
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<string, unknown>): Record<string, string> {
const out: Record<string, string> = {};
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<string, string> {
const { token, user, isDev } = useAuthStore.getState();
const headers: Record<string, string> = {
"Content-Type": "application/json",
...((init?.headers as Record<string, string>) || {}),
};
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;
}
// 401 سراسری — هر پاسخ 401 یعنی نشست منقضی/نامعتبر است (JWT روی همه‌ی
// مسیرها الزامی شد). نشست را پاک می‌کنیم و به صفحه‌ی ورود می‌رویم تا به‌جای
// «برنامه‌ی خاموش» (استور خالی، بدون هیچ خطایی) کاربر به ورود هدایت شود.
// دخالت نمی‌کنیم در: خطای رمز خودِ لاگین، حالت توسعه (هدرهای X-User-*)،
// و وقتی از قبل روی /login هستیم (جلوگیری از حلقه‌ی ریدایرکت).
function handle401(path: string, res: Response): void {
if (res.status !== 401) return;
if (path.startsWith("/auth/login")) return;
if (useAuthStore.getState().isDev) return;
if (window.location.pathname === "/login") return;
useAuthStore.getState().logout();
window.location.replace("/login");
}
// پروژه‌ی فعالِ مدیر پروژه (چند-پروژه‌ای، 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<string, string> = {
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: "تاریخ رسید",
due_date: "تاریخ سررسید",
schedule_steps: "مراحل پرداخت",
milestones: "مراحل پرداخت",
amount: "مبلغ",
step_no: "شماره مرحله",
};
export const faField = (field: string): string => {
const parts = field.split(".").filter((p) => p && !/^\d+$/.test(p));
const last = parts.pop() || field;
return FIELD_FA[last] || FIELD_FA[field] || field;
};
// ترجمه‌ی خطای HTTP بر اساس کد وضعیت — پیام‌های معنادار بک‌اند حفظ می‌شوند
export const faHttpError = (status: number, fallback: string): string => {
if (status === 413) return "فایل انتخاب‌شده از حد مجاز (۵ مگابایت) بزرگ‌تر است";
if (status === 404 && (!fallback || fallback.startsWith("خطای سرور"))) return "مورد درخواستی یافت نشد";
if (status === 409 && (!fallback || fallback.startsWith("خطای سرور"))) return "تعارض داده — رکورد تکراری یا وضعیت نامعتبر";
if (status === 400 && (!fallback || fallback.startsWith("خطای سرور"))) return "داده‌ی ارسال‌شده نامعتبر است";
return fallback;
};
export class ApiError extends Error {
status: number;
code?: string;
field?: string;
constructor(message: string, status: number, code?: string, field?: string) {
super(message);
this.name = "ApiError";
this.status = status;
this.code = code;
this.field = field;
}
}
// ============================================================
// HTTP helpers — envelope بک‌اند: {"data": ..., "meta": ...}
// ============================================================
async function request<T>(path: string, init?: RequestInit): Promise<T> {
let res: Response;
try {
res = await fetch(BASE_URL + path, {
...init,
headers: authHeaders(init),
});
} catch {
throw new ApiError("اتصال به سرور برقرار نشد — آیا بک‌اند در دسترس است؟", 0);
}
handle401(path, res);
if (!res.ok) {
let msg = `خطای سرور (${res.status})`;
let errCode: string | undefined;
let errField: string | undefined;
try {
const body = await res.json();
if (body?.error) {
errCode = body.error.code;
errField = body.error.field;
const rawMsg = body.error.message || "";
const fName = errField ? faField(errField) : "";
if (fName && rawMsg) {
msg = `داده‌ی «${fName}» نامعتبر است: ${rawMsg}`;
} else if (rawMsg) {
msg = rawMsg;
}
} else if (Array.isArray(body?.detail)) {
// FastAPI 422 — آرایه‌ی خطاهای اعتبارسنجی با پیام انگلیسی
const first = body.detail[0];
const loc = first?.loc || [];
const filteredLoc = loc.filter((p: unknown) => p !== "body" && p !== "query" && p !== "path");
errField = filteredLoc.join(".");
const fieldName = filteredLoc.length ? String(filteredLoc[filteredLoc.length - 1]) : "";
const fa = first?.msg ? VALIDATION_FA(String(first.msg)) : "داده‌ی ارسال‌شده نامعتبر است";
const fName = fieldName ? faField(fieldName) : "";
msg = fName ? `داده‌ی «${fName}» نامعتبر است: ${fa}` : fa;
} else if (body?.detail) {
msg = String(body.detail);
}
} catch {
/* بدنۀ خطا JSON نیست */
}
const finalMsg = faHttpError(res.status, msg);
throw new ApiError(finalMsg, res.status, errCode, errField);
}
return (await res.json()) as T;
}
async function getData<T>(path: string): Promise<T> {
const res = await request<{ data: T }>(path);
return res.data;
}
async function postData<T>(path: string, body: unknown): Promise<T> {
const res = await request<{ data: T }>(path, { method: "POST", body: JSON.stringify(body) });
return res.data;
}
async function patchData<T>(path: string, body: unknown): Promise<T> {
const res = await request<{ data: T }>(path, { method: "PATCH", body: JSON.stringify(body) });
return res.data;
}
async function deleteData<T>(path: string): Promise<T> {
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<Attachment> {
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("اتصال به سرور برقرار نشد — آیا بک‌اند در دسترس است؟");
}
handle401(path, res);
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;
// v2.6: هر تأمین‌کننده دقیقاً یک دسته دارد (FK) — بک‌اند این را می‌فرستد
category_id?: string | null;
category?: { id: string; slug: string; name: 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;
project_id?: string | null;
project?: RawProject | 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; // پروژه‌های تخصیص‌یافته به این قلم
target_project?: 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 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<number, string>;
unitNameToId: Map<string, number>; // کلید: نام نرمال‌شده (بدون فاصله)
categoryIdToSlug: Map<string, string>;
categoryIdToName: Map<string, string>;
categoryNameToId: Map<string, string>; // کلید: نام نرمال‌شده
categorySlugToName: Map<string, string>; // slug → نام دسته (تاپیک)
brandNameToId: Map<string, string>; // کلید: نام نرمال‌شده
}
function norm(s: string): string {
return normalize(s).replace(/\s+/g, "");
}
function buildLookup(units: RawUnit[], categories: RawMaterialCategory[]): Lookup {
const unitIdToName = new Map<number, string>();
const unitNameToId = new Map<string, number>();
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<string, string>();
const categoryIdToName = new Map<string, string>();
const categoryNameToId = new Map<string, string>();
const categorySlugToName = new Map<string, string>();
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<string> {
if (!_categoryIdCache) {
try {
const cats = await getData<RawMaterialCategory[]>(`/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) || "" : ""),
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] {
const targetProj = raw.target_project || raw.decision?.project || null;
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 })),
target_project: targetProj ? { id: targetProj.id, name: targetProj.name } : null,
target_project_id: raw.decision?.project_id || targetProj?.id || null,
};
}
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 {
// هر تأمین‌کننده دقیقاً یک دسته دارد (FK) و بک‌اند آن را به شکل category
// برمی‌گرداند — منبع اصلی همین است؛ پارامتر categories فقط پشتیبان است.
const own = raw.category?.name ? [raw.category.name] : [];
return {
id: raw.id,
topic,
categories: own.length ? own : 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<string, Material>,
): Supplier[] {
const topics = new Map<string, Set<string>>();
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 (m.topic) topics.get(q.supplier_id)!.add(m.topic);
}
}
return rawSuppliers.map((s) => {
const t = topics.get(s.id);
const derivedTopic = t && t.size ? [...t][0] : "";
// اگر از quotes چیزی پیدا نشد، registry را چک کن
if (!derivedTopic) {
const reg = supplierTopicRegistry.get(s.id);
if (reg) return adaptSupplier(s, reg.topic, reg.categories);
}
// دستهٔ ثبت‌شده روی خودِ تأمین‌کننده (FK بک‌اند) منبع اصلی است و نامش با نام
// تاپیک‌ها یکی است؛ تاپیک از اقلام پیشنهاد استنتاج می‌شود.
const backendCat = s.category?.name || "";
return adaptSupplier(s, derivedTopic || backendCat, backendCat ? [backendCat] : []);
});
}
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 str = toAsciiDigits(v).trim();
if (!str) return null;
// ۱. فرمت جلالی با اسلش، خط تیره یا نقطه (سال‌های ۱۳۰۰ تا ۱۵۰۰)
const jm = str.match(/^(1[34]\d{2})[/\-.](\d{1,2})[/\-.](\d{1,2})/);
if (jm) {
const jy = parseInt(jm[1], 10);
const jmon = parseInt(jm[2], 10);
const jd = parseInt(jm[3], 10);
const [gy, gm, gd] = jalaliToGregorian(jy, jmon, jd);
return `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
}
// ۲. فرمت میلادی استاندارد YYYY-MM-DD
const gm = str.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (gm && parseInt(gm[1], 10) >= 1900) return str;
// ۳. رشته‌ی datetime کامل یا مقادیر دیگر
const d = new Date(str);
if (Number.isNaN(d.getTime())) return null;
if (d.getFullYear() < 1900 && d.getFullYear() >= 1300) {
const [gy, gm, gd] = jalaliToGregorian(d.getFullYear(), d.getMonth() + 1, d.getDate());
return `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
}
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
function toDateTime(v: string | null | undefined): string | null {
if (!v) return null;
const str = toAsciiDigits(v).trim();
if (!str) return null;
// ۱. تاریخ جلالی با زمان اختیاری
const jm = str.match(/^(1[34]\d{2})[/\-.](\d{1,2})[/\-.](\d{1,2})(?:[ T](\d{1,2}):(\d{1,2})(?::(\d{1,2}))?)?/);
if (jm) {
const jy = parseInt(jm[1], 10);
const jmon = parseInt(jm[2], 10);
const jd = parseInt(jm[3], 10);
const h = jm[4] != null ? parseInt(jm[4], 10) : 0;
const m = jm[5] != null ? parseInt(jm[5], 10) : 0;
const s = jm[6] != null ? parseInt(jm[6], 10) : 0;
const [gy, gm, gd] = jalaliToGregorian(jy, jmon, jd);
return new Date(gy, gm - 1, gd, h, m, s).toISOString();
}
// ۲. اگر میلادی استاندارد ISO است
const d = new Date(str);
if (!Number.isNaN(d.getTime()) && d.getFullYear() >= 1900) {
return /^\d{4}-\d{2}-\d{2}$/.test(str) ? str + "T00:00:00Z" : str;
}
const dateOnly = toDateOnly(str);
return dateOnly ? dateOnly + "T00:00:00Z" : null;
}
function todayIso(): string {
return new Date().toISOString().slice(0, 10);
}
export function looksLikeUuid(v: string | null | undefined): boolean {
if (!v) return false;
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(v).trim());
}
// ============================================================
// Registry موقت: material ساخته‌شده در همین نشست (id موقت → UUID واقعی)
// ویزارد id موقت ("m10") را در قلم ذخیره می‌کند؛ هنگام ساخت پیشنهاد
// باید به UUID واقعی بک‌اند ترجمه شود.
// ============================================================
const tempIdToRealId = new Map<string, string>();
function realMaterialId(id: string): string {
return tempIdToRealId.get(id) || id;
}
// ============================================================
// Supplier topic/category tracking — بک‌اند topic و category
// برای تأمین‌کننده ذخیره نمی‌کند. این Map اطلاعات topic و
// categories را برای تأمین‌کننده‌های جدید (بدون پیشنهاد) نگه
// می‌دارد تا در deriveSupplierTopics استفاده شود.
// ============================================================
const supplierTopicRegistry = new Map<string, { topic: string; categories: string[] }>();
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<Store> {
const [quotesRes, suppliersRes, materialsRes, projectsRes, unitsRes, categoriesRes, scuRes] =
await Promise.all([
getData<RawQuote[]>(`/quotes?expand=supplier,project,events,line_items,termsheets,decision,receipt&page_size=${PAGE_SIZE}`),
getData<RawSupplier[]>(`/suppliers?page_size=${PAGE_SIZE}`),
getData<RawMaterial[]>(`/materials?expand=brands,canonical_unit,attributes&page_size=${PAGE_SIZE}`),
getData<RawProject[]>(`/projects?page_size=${PAGE_SIZE}`),
getData<RawUnit[]>(`/units?page_size=${PAGE_SIZE}`),
getData<RawMaterialCategory[]>(`/material-categories?page_size=${PAGE_SIZE}`),
getData<RawStandardConversion[]>(`/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,
standardConversions: scuRes.map(adaptStandardConversion),
topics: buildTopics(categoriesRes),
};
},
quotes: {
list: async (): Promise<Quote[]> => {
const raw = await getData<RawQuote[]>(
`/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<Quote> => {
const raw = await getData<RawQuote>(`/quotes/${id}?expand=supplier,project,events,line_items,termsheets`);
return adaptQuote(raw);
},
create: async (q: Quote): Promise<Quote> => {
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++) {
let matId = realMaterialId(lineItems[i].material_id);
if (!looksLikeUuid(matId)) {
const rawMats = await getData<RawMaterial[]>(`/materials?page_size=100`);
const found = rawMats.find((rm) => rm.id === matId) || rawMats[0];
if (found) matId = found.id;
}
lineItems[i].material_id = matId;
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<RawQuote>("/quotes", payload);
return adaptQuote(raw);
},
update: async (id: string, patch: Partial<Quote>): Promise<Quote> => {
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");
// پروژه‌ی واحدِ انتخاب‌شده برای هر قلم (item_decisions.project_id)
const targetProjByLine = new Map<string, string>();
for (const li of patch.line_items || []) {
const tp = li.target_project;
if (tp && tp.name) {
const pid = looksLikeUuid(tp.id) ? tp.id : await projectIdByNameOrCreate(tp.name);
if (pid) targetProjByLine.set(li.id, pid);
}
}
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;
const chosenProjId = targetProjByLine.get(li.id);
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: [], // کاندیداهای قبلی در line_item_projects کاملاً حفظ می‌شوند
};
await patchData(`/quotes/${id}/accept`, payload);
return { ...current, ...patch, projects: current.projects } 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<string, unknown> = {};
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<void> => {
await deleteData(`/quotes/${id}`);
},
},
drafts: {
get: async (
id: string,
): Promise<{
id: string;
chat_id: number;
kind: string;
status: string;
payload: Record<string, any>;
missing?: { fields?: { ok: boolean; key: string; label: string; blocking: boolean }[] } | null;
source?: string;
created_at?: string;
updated_at?: string;
}> => {
return getData(`/drafts/${id}`);
},
update: async (
id: string,
payload: Record<string, any>,
): Promise<void> => {
await patchData(`/drafts/${id}`, { payload });
},
commit: async (
id: string,
quoteId?: string,
): Promise<void> => {
await postData(`/drafts/${id}/commit`, { quote_id: quoteId });
},
cancel: async (
id: string,
): Promise<void> => {
await postData(`/drafts/${id}/cancel`, {});
},
verifyToken: async (
token: string,
): Promise<{
id: string;
chat_id: number;
kind: string;
status: string;
payload: Record<string, any>;
missing?: { fields?: { ok: boolean; key: string; label: string; blocking: boolean }[] } | null;
source?: string;
created_at?: string;
updated_at?: string;
}> => {
return getData(`/drafts/verify?token=${encodeURIComponent(token)}`);
},
updateByToken: async (
token: string,
payload: Record<string, any>,
): Promise<void> => {
await patchData(`/drafts/by-token?token=${encodeURIComponent(token)}`, { payload });
},
commitByToken: async (
token: string,
quoteId?: string,
): Promise<void> => {
await postData(`/drafts/by-token/commit?token=${encodeURIComponent(token)}`, { quote_id: quoteId });
},
cancelByToken: async (
token: string,
): Promise<void> => {
await postData(`/drafts/by-token/cancel?token=${encodeURIComponent(token)}`, {});
},
},
suppliers: {
list: async (): Promise<Supplier[]> => {
const raw = await getData<RawSupplier[]>(`/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<Supplier> => {
// v2.6 backend: category_id الزامی است — از تاپیکِ انتخاب‌شده استنتاج می‌شود
const category_id = await categoryIdForTopic(s.topic || "");
const raw = await postData<RawSupplier>("/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<Supplier>): Promise<Supplier> => {
const raw = await patchData<RawSupplier>(`/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<void> => {
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<void> => {
await postData(`/suppliers/${supplierId}/materials`, { material_id: materialId });
},
remove: async (supplierId: string, materialId: string): Promise<void> => {
await deleteData(`/suppliers/${supplierId}/materials/${materialId}`);
},
},
materials: {
list: async (): Promise<Material[]> => {
const raw = await getData<RawMaterial[]>(`/materials?expand=brands,canonical_unit,attributes&page_size=${PAGE_SIZE}`);
const units = await getData<RawUnit[]>(`/units?page_size=${PAGE_SIZE}`);
const categories = await getData<RawMaterialCategory[]>(`/material-categories?page_size=${PAGE_SIZE}`);
return raw.map((m) => adaptMaterial(m, buildLookup(units, categories)));
},
create: async (m: Material): Promise<Material> => {
const lookup = await fetchLookup();
// واحد مرجع — بدون مقدار پیش‌فرض؛ متریال بدون واحدِ صریح ساخته نمی‌شود
// (بک‌اند 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 فقط نامِ خودِ متریال است؛ برند در brand_ids و مشخصه‌ها در
// attributes (EAV) ذخیره می‌شوند. اگر برند/مشخصه داخل name ریخته شود،
// در ویزارد به‌عنوان «نامِ متریال» کش می‌شود و مشخصه‌ها در نام ظاهر می‌شوند.
const name = (m.name || m.brand || (m.specs || []).join(" ") || "").trim() || "کالای جدید";
// مشخصه‌ها به‌صورت attribute (EAV) ذخیره می‌شوند تا بعد از رفرش دیده شوند
const attrs: { attribute_definition_id: string; value: string }[] = [];
const seen = new Set<string>();
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<RawMaterial>("/materials", {
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<void> => {
await patchData(`/materials/${id}`, { canonical_unit_id });
},
},
projects: {
list: async (): Promise<Project[]> => {
const raw = await getData<RawProject[]>(`/projects?page_size=${PAGE_SIZE}`);
return raw.map((p) => ({ id: p.id, name: p.name }));
},
create: async (p: Project): Promise<Project> => {
const raw = await postData<RawProject>("/projects", { name: p.name });
return { id: raw.id, name: raw.name };
},
},
units: {
getStandardConversions: async (): Promise<StandardConversion[]> => {
const raw = await getData<RawStandardConversion[]>(`/standard-conversions?page_size=${PAGE_SIZE}`);
return raw.map(adaptStandardConversion);
},
saveStandardConversion: async (c: StandardConversion): Promise<void> => {
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<void> => {
await deleteData(`/standard-conversions/${id}`);
},
},
// ---------- واحدهای هر متریال (material_units — ضریب تبدیل نسبت به واحد مرجع) ----------
materialUnits: {
list: async (materialId: string): Promise<MaterialUnitRow[]> => {
const data = await getData<RawMaterialUnit[]>(
`/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<MaterialUnitRow> => {
const raw = await postData<RawMaterialUnit>(`/materials/${materialId}/units`, body);
return adaptMaterialUnit(raw);
},
update: async (
materialId: string,
muId: string,
body: { conversion_factor?: number; is_default?: boolean },
): Promise<MaterialUnitRow> => {
const raw = await patchData<RawMaterialUnit>(`/materials/${materialId}/units/${muId}`, body);
return adaptMaterialUnit(raw);
},
remove: async (materialId: string, muId: string): Promise<void> => {
await deleteData(`/materials/${materialId}/units/${muId}`);
},
},
// ---------- اتصال برند↔متریال (برای قید برند در ضریب‌های تبدیل) ----------
brandMaterials: {
listByMaterial: async (materialId: string): Promise<BrandLinkRow[]> => {
const data = await getData<RawBrandLink[]>(
`/brand-materials?material_id=${materialId}&page_size=100&expand=brand`,
);
return data.map(adaptBrandLink);
},
link: async (materialId: string, brandId: string): Promise<void> => {
await postData("/brand-materials", { brand_id: brandId, material_id: materialId });
},
},
// ---------- پیوست‌ها (invoice / proforma) — بایت‌ها در MinIO ----------
files: {
upload: (quoteId: string, kind: FileKind, file: File): Promise<Attachment> =>
uploadFile(`/quotes/${quoteId}/${kind}`, file),
list: async (quoteId: string, kind: FileKind): Promise<Attachment[]> => {
const raw = await getData<RawAttachment[]>(`/quotes/${quoteId}/${kind}`);
return raw.map(adaptAttachment);
},
remove: async (quoteId: string, kind: FileKind, id: string): Promise<void> => {
await deleteData(`/quotes/${quoteId}/${kind}/${id}`);
},
// دانلود احراز هویت‌شده: <a href> توکن Authorization نمی‌فرستد، پس بایت‌ها
// را با توکن می‌گیریم و به شکل blob در مرورگر ذخیره می‌کنیم.
download: async (quoteId: string, kind: FileKind, id: string, fileName: string): Promise<void> => {
let res: Response;
const dlPath = `/quotes/${quoteId}/${kind}/${id}`;
try {
res = await fetch(BASE_URL + dlPath, {
headers: authHeaders(),
});
} catch {
throw new ApiError("اتصال به سرور برقرار نشد — آیا بک‌اند در دسترس است؟", 0);
}
handle401(dlPath, res);
if (!res.ok) throw new ApiError(faHttpError(res.status, "خطای سرور"), res.status);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
try {
const a = document.createElement("a");
a.href = url;
a.download = fileName || "attachment";
document.body.appendChild(a);
a.click();
a.remove();
} finally {
URL.revokeObjectURL(url);
}
},
// آدرس blob احراز هویت‌شده برای پیش‌نمایش (تصویر/PDF در مودال).
// تگ‌های <img>/<iframe>/<a href> توکن نمی‌فرستند، پس بایت‌ها را با توکن
// آدرس پیش‌نمایش احراز هویت‌شده برای <iframe>/<img>.
// این‌ها نمی‌توانند هدر Authorization بفرستند، پس بک‌اند یک توکن
// امضاشده‌ی کوتاه‌عمر در کوئری می‌دهد. عمداً blob ساخته نمی‌شود:
// دانلودمنیجرها (IDM) blob را شکار می‌کنند و فایل ۰ بایتی می‌سازند،
// در حالی که این یک URL واقعی است و درست بارگذاری می‌شود.
previewUrl: async (quoteId: string, kind: FileKind, id: string): Promise<string> => {
const res = await getData<{ url: string; token: string }>(
`/quotes/${quoteId}/preview-url/${kind}/${id}`,
);
return BASE_URL + res.url;
},
},
// ---------- برندها (از بک‌اند) ----------
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}`);
},
// مشخصه‌ها — رجیستری سراسری (attribute-definitions)، مستقل از تاپیک/دسته/برند.
// ویزارد از این لیست به‌عنوان منبع سراسری مشخصه‌ها استفاده می‌کند تا مشخصه‌های
// ثبت‌شده در پیشنهادهای قبلی همیشه در دسترس باشند.
specsList: async (): Promise<string[]> => {
const raw = await getData<{ label?: string; attribute_key?: string }[]>(
`/attribute-definitions?page_size=${PAGE_SIZE}`,
);
return raw
.map((d) => (d.label || "").trim() || (d.attribute_key || "").replace(/^spec_/, "").trim())
.filter(Boolean);
},
// ---------- واحدها (از بک‌اند) ----------
unitsList: async (): Promise<RawUnit[]> => {
return getData<RawUnit[]>(`/units?page_size=${PAGE_SIZE}`);
},
// بک‌اند endpoint بازنشانی ندارد — داده‌ها دوباره از سرور خوانده می‌شوند
reset: () => api.listAll(),
};
// ============================================================
// Helpers داخلی
// ============================================================
async function fetchLookup(): Promise<Lookup> {
const [units, categories] = await Promise.all([
getData<RawUnit[]>(`/units?page_size=${PAGE_SIZE}`),
getData<RawMaterialCategory[]>(`/material-categories?page_size=${PAGE_SIZE}`),
]);
return buildLookup(units, categories);
}
async function fetchProjects(): Promise<RawProject[]> {
return getData<RawProject[]>(`/projects?page_size=${PAGE_SIZE}`);
}
// پیمودن پروژه‌ها: پیدا کردن id پروژه؛ اگر نبود، در بک‌اند می‌سازد
async function projectIdByNameOrCreate(name: string | null | undefined): Promise<string | null> {
if (!name) return null;
const projects = await fetchProjects();
if (looksLikeUuid(name)) {
const byId = projects.find((x) => x.id.toLowerCase() === name.toLowerCase());
if (byId) return byId.id;
}
const pid = projectIdByName(projects, name);
if (pid) return pid;
const raw = await postData<RawProject>("/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<number> {
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<RawUnit>("/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<string | null> {
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<string, string>(); // norm(spec) → attribute_definition_id
// پیدا کردن/ساخت attribute definition برای یک مشخصه‌ی متنی
async function specDefinitionId(spec: string): Promise<string | null> {
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 regex = new RegExp(`^${prefix}(\\d+)$`);
const max = items
.map((x) => {
const m = String(x.id).match(regex);
return m ? parseInt(m[1], 10) : 0;
})
.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<string, string>;
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<RawCat[]> => getData<RawCat[]>("/material-categories?page_size=100"),
units: async (): Promise<RawUnitL[]> => getData<RawUnitL[]>("/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<import("../store/auth").AuthUser> => {
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<UserRow[]> => {
const data = await getData<UserRow[]>("/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<UserRow> => postData<UserRow>("/users", body),
updateRole: async (id: string, role: "pm" | "procurement_manager" | "admin" | null): Promise<UserRow> =>
patchData<UserRow>(`/users/${id}`, { role }),
assignProject: async (id: string, projectId: string | null): Promise<UserRow> =>
postData<UserRow>(`/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<void> => {
await patchData(`/projects/${projectId}`, { pm_user_id: userId });
},
},
warehouses: {
list: async (projectId?: string): Promise<Warehouse[]> => {
const pid = projectId ?? activeProjectId() ?? undefined;
const qs = pid ? `?project_id=${pid}&page_size=100` : "?page_size=100";
const data = await getData<Parameters<typeof rawWarehouse>[0][]>(`/inventory/warehouses${qs}`);
return data.map(rawWarehouse);
},
create: async (body: { project_id: string; name: string; description?: string | null }): Promise<Warehouse> => {
const d = await postData<Parameters<typeof rawWarehouse>[0]>(`/inventory/warehouses${projectQuery()}`, body);
return rawWarehouse(d);
},
// project_id عمداً در تایپ نیست — انبار برای همیشه به پروژه‌ی والدش تعلق دارد
update: async (id: string, body: { name?: string; description?: string | null }): Promise<Warehouse> => {
const d = await patchData<Parameters<typeof rawWarehouse>[0]>(`/inventory/warehouses/${id}${projectQuery()}`, body);
return rawWarehouse(d);
},
delete: async (id: string): Promise<void> => {
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<InventoryRow[]> => {
const pid = params.project_id ?? activeProjectId() ?? undefined;
const qs = new URLSearchParams({ page_size: "100", ...cleanParams({ ...params, project_id: pid }) }).toString();
const data = await getData<Parameters<typeof rawInventory>[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<InventoryTransaction[]> => {
const pid = params.project_id ?? activeProjectId() ?? undefined;
const qs = new URLSearchParams({ page_size: "100", ...cleanParams({ ...params, project_id: pid }) }).toString();
return getData<InventoryTransaction[]>(`/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<string, string> | null;
note?: string | null; transaction_date?: string | null;
}): Promise<InventoryTransaction[]> => {
return postData<InventoryTransaction[]>(`/inventory/transactions${projectQuery()}`, body);
},
types: async (): Promise<{ type: TransactionType; label: string }[]> => {
return getData<{ type: TransactionType; label: string }[]>("/inventory/meta/types");
},
},
pm: {
// پروژه فعال به‌صورت صریح ارسال می‌شود؛ بک‌اند تعلق آن به PM را بررسی می‌کند
onTheWay: async (projectId?: string | null): Promise<PendingArrival[]> =>
getData<PendingArrival[]>(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<ConfirmedReceipt[]> =>
getData<ConfirmedReceipt[]>(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,
topic: null,
canonical_unit_id: opts.canonical_unit_id,
status: "active",
brand_ids: opts.brand_ids,
attributes: [],
}),
},
};