Supplier topic/categories were derived from quote line items' material
category and topic. The catalog v2.7 refactor decoupled materials from
categories, so that derivation now yields nothing and every supplier lost
its topic/category — the suppliers dashboard rendered without names,
because it groups topic → category → supplier.
The backend already returns the supplier's own category
(category_id FK + nested category{id,slug,name}); adaptSupplier simply
never read it. Now it is the primary source, with quote-derived values and
the local registry still taking precedence when present.
Verified against the live stack: all 7 suppliers return their category
from /suppliers, and the suppliers page renders names again.
1832 lines
78 KiB
TypeScript
1832 lines
78 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,
|
|
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<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 putData<T>(path: string, body: unknown): Promise<T> {
|
|
const res = await request<{ data: T }>(path, { method: "PUT", 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;
|
|
categories?: 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 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<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) || "" : ""),
|
|
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] {
|
|
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 {
|
|
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<string, Material>,
|
|
): Supplier[] {
|
|
const topics = new Map<string, Set<string>>();
|
|
const cats = 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 (!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);
|
|
const derivedTopic = t && t.size ? [...t][0] : "";
|
|
const derivedCats = c ? [...c].sort((a, b) => a.localeCompare(b, "fa")) : [];
|
|
// اگر از quotes چیزی پیدا نشد، registry را چک کن
|
|
if (!derivedTopic && !derivedCats.length) {
|
|
const reg = supplierTopicRegistry.get(s.id);
|
|
if (reg) {
|
|
return adaptSupplier(s, reg.topic, reg.categories);
|
|
}
|
|
}
|
|
// v2.7: متریالها از دسته جدا شدند، پس استنتاج از اقلام پیشنهاد معمولاً
|
|
// دستهای نمیدهد و صفحهی تأمینکنندهها بینام میشد. دستهٔ ثبتشده روی
|
|
// خودِ تأمینکننده (FK بکاند) منبع اصلی است؛ نامش با نام تاپیکها یکی است.
|
|
const backendCat = s.category?.name || "";
|
|
return adaptSupplier(
|
|
s,
|
|
derivedTopic || backendCat,
|
|
derivedCats.length ? derivedCats : backendCat ? [backendCat] : [],
|
|
);
|
|
});
|
|
}
|
|
|
|
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 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, cruRes, ucRes, 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=category,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<RawCategoryUnit[]>(`/category-reference-units?expand=category&page_size=${PAGE_SIZE}`),
|
|
getData<RawUnitConversion[]>(`/unit-conversions?expand=category&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,
|
|
categoryUnits: cruRes.map(adaptCategoryUnit),
|
|
unitConversions: ucRes.map((uc) => adaptUnitConversion(uc, lookup)),
|
|
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}`);
|
|
},
|
|
},
|
|
|
|
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=category,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.category || 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: {
|
|
getCategoryUnits: async (): Promise<CategoryUnit[]> => {
|
|
const raw = await getData<RawCategoryUnit[]>(`/category-reference-units?expand=category&page_size=${PAGE_SIZE}`);
|
|
return raw.map(adaptCategoryUnit);
|
|
},
|
|
saveCategoryUnit: async (cu: CategoryUnit): Promise<void> => {
|
|
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<UnitConversion[]> => {
|
|
const lookup = await fetchLookup();
|
|
const raw = await getData<RawUnitConversion[]>(`/unit-conversions?expand=category&page_size=${PAGE_SIZE}`);
|
|
return raw.map((uc) => adaptUnitConversion(uc, lookup));
|
|
},
|
|
saveConversion: async (c: UnitConversion): Promise<void> => {
|
|
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<void> => {
|
|
await deleteData(`/unit-conversions/${id}`);
|
|
},
|
|
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");
|
|
},
|
|
materialOptions: async (category_id: string, warehouse_id?: string): Promise<{ id: string; name: string; brands: { id: string; name: string }[] }[]> => {
|
|
const params: Record<string, string> = { 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<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: [],
|
|
}),
|
|
},
|
|
};
|