- main.py: global get_current_user dependency on all routers except /auth/login and /health; prod fail-fast startup guard rejects wildcard CORS, default credentials, DEV_LOGIN_ENABLED - inventory/pm: X-User-Role header identity fallback now only active when PROCUREMENT_DEV_LOGIN_ENABLED=true (was unconditional in production) - item_decisions writes + attachment deletes: require_role( procurement_manager, admin) — require_role was defined but never used - config.py: ENVIRONMENT flag, comma-separated CORS_ORIGINS, no default Casdoor client secret/admin password, DEV_LOGIN_ENABLED=false - frontend: authenticated file downloads (Bearer + blob) — <a href> links cannot send the token; FileList/QuoteDetail/AcceptModal/EditModal/ PurchaseHistory converted; DEV_LOGIN_ENABLED build arg defaults false - smoke tests authenticate via POST /auth/login (SMOKE_USER/SMOKE_PASSWORD) - traefik: security headers, API rate limit, 12MB body cap (prod overlay) - nginx client_max_body_size aligned to 12MB
1795 lines
76 KiB
TypeScript
1795 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,
|
|
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;
|
|
}
|
|
|
|
// پروژهی فعالِ مدیر پروژه (چند-پروژهای، 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);
|
|
}
|
|
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("اتصال به سرور برقرار نشد — آیا بکاند در دسترس است؟");
|
|
}
|
|
if (!res.ok) {
|
|
let msg = `خطای سرور (${res.status})`;
|
|
try {
|
|
const body = await res.json();
|
|
if (body?.error?.message) msg = body.error.message;
|
|
} catch {
|
|
/* بدنۀ خطا JSON نیست */
|
|
}
|
|
throw new Error(faHttpError(res.status, msg));
|
|
}
|
|
const body = (await res.json()) as { data: RawAttachment };
|
|
return adaptAttachment(body.data);
|
|
}
|
|
|
|
// ============================================================
|
|
// شکل خام پاسخ بکاند (فقط فیلدهایی که adapter استفاده میکند)
|
|
// ============================================================
|
|
|
|
interface RawUnit {
|
|
id: number;
|
|
code: string;
|
|
name_fa: string | null;
|
|
}
|
|
interface RawCategory {
|
|
id: string;
|
|
slug: string;
|
|
name: string;
|
|
}
|
|
interface RawMaterial {
|
|
id: string;
|
|
name: string;
|
|
topic?: string | null;
|
|
category?: RawCategory | null;
|
|
brands?: { id: string; name: string }[] | null;
|
|
canonical_unit?: RawUnit | null;
|
|
canonical_unit_id?: number | null;
|
|
attributes?: RawSpec[] | null; // مشخصهها (EAV)
|
|
}
|
|
interface RawSupplier {
|
|
id: string;
|
|
name: string;
|
|
location?: string | null;
|
|
phone?: string | null;
|
|
contact_person?: string | null;
|
|
tax_id?: string | null;
|
|
categories?: string[] | null; // متریالهای تأمینشده
|
|
}
|
|
interface RawProject {
|
|
id: string;
|
|
name: string;
|
|
}
|
|
interface RawSpec {
|
|
attribute_key: string;
|
|
label: string;
|
|
value: unknown;
|
|
unit: { id: number; code: string } | null;
|
|
}
|
|
interface RawDecision {
|
|
id: string;
|
|
status: string;
|
|
chosen_item_termsheet_id?: string | null;
|
|
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);
|
|
// اگر از quotes topic/category پیدا نشد، registry را چک کن
|
|
if (!t || t.size === 0) {
|
|
const reg = supplierTopicRegistry.get(s.id);
|
|
if (reg) {
|
|
return adaptSupplier(s, reg.topic, reg.categories);
|
|
}
|
|
}
|
|
return adaptSupplier(s, t ? [...t][0] : "", c ? [...c].sort((a, b) => a.localeCompare(b, "fa")) : []);
|
|
});
|
|
}
|
|
|
|
function adaptCategoryUnit(raw: RawCategoryUnit): CategoryUnit {
|
|
return { id: raw.id, category: raw.category?.name || "", ref_unit: raw.ref_unit };
|
|
}
|
|
function adaptUnitConversion(raw: RawUnitConversion, lookup: Lookup): UnitConversion {
|
|
return {
|
|
id: raw.id,
|
|
category: raw.category?.name || lookup.categoryIdToName.get(raw.category_id) || "",
|
|
brand: raw.brand || "",
|
|
from_unit: raw.from_unit,
|
|
to_unit: raw.to_unit,
|
|
factor: raw.factor,
|
|
};
|
|
}
|
|
function adaptStandardConversion(raw: RawStandardConversion): StandardConversion {
|
|
return { id: raw.id, from_unit: raw.from_unit, to_unit: raw.to_unit, factor: raw.factor };
|
|
}
|
|
|
|
// بکاند → Attachment (فایلهای ذخیرهشده در MinIO)
|
|
function adaptAttachment(raw: RawAttachment): Attachment {
|
|
return {
|
|
id: raw.id,
|
|
name: raw.file_name,
|
|
mime_type: raw.mime_type,
|
|
size_bytes: raw.size_bytes,
|
|
uploaded_at: raw.uploaded_at,
|
|
};
|
|
}
|
|
|
|
// ============================================================
|
|
// Date helpers — بکاند datetime/date را جدا میپذیرد
|
|
// ============================================================
|
|
|
|
function toDateOnly(v: string | null | undefined): string | null {
|
|
if (!v) return null;
|
|
const 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();
|
|
// v2.5: category_id اختیاری است — اگر نامِ تایپشده یکی از ۸ دستهی ثابت
|
|
// نباشد، متریال بهصورت سراسری (بدون دسته) ساخته میشود و دستهاش بعداً
|
|
// از اولین تأمینکنندهی مرتبط استنتاج میشود (first-link-wins).
|
|
// دستهی rogue دیگر هرگز ساخته نمیشود — فقط تطبیق با ۸ دستهی موجود؛
|
|
// در صورت نبود تطبیق، خطا نمیدهیم و category_id را null میفرستیم.
|
|
const categoryId = m.category ? lookup.categoryNameToId.get(norm(m.category)) : undefined;
|
|
// واحد مرجع — بدون مقدار پیشفرض؛ متریال بدون واحدِ صریح ساخته نمیشود
|
|
// (بکاند canonical_unit_id را الزامی میداند)
|
|
const unitName = (m.base_unit || "").trim();
|
|
if (!unitName) {
|
|
throw new Error("واحد مرجع متریال مشخص نشده است — واحد را وارد کنید");
|
|
}
|
|
const unitId = await unitIdByNameOrCreate(lookup, unitName);
|
|
// برند — اگر تعریف نشده، در جدول brands ساخته و به متریال متصل میشود
|
|
const brandId = await brandIdByNameOrCreate(lookup, m.brand);
|
|
// name فقط نامِ خودِ متریال است؛ برند در 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", {
|
|
category_id: categoryId ?? null,
|
|
name,
|
|
topic: m.topic || null,
|
|
canonical_unit_id: unitId,
|
|
status: "active",
|
|
brand_ids: brandId ? [brandId] : [],
|
|
attributes: attrs,
|
|
});
|
|
// ویزارد با id موقت به قلم ارجاع میدهد — ثبت نگاشت برای ساخت پیشنهاد
|
|
tempIdToRealId.set(m.id, raw.id);
|
|
return adaptMaterial(raw, lookup);
|
|
},
|
|
// تغییر واحد مرجع متریال (صفحهی ضرایب تبدیل)
|
|
updateCanonicalUnit: async (id: string, canonical_unit_id: number): Promise<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;
|
|
try {
|
|
res = await fetch(`${BASE_URL}/quotes/${quoteId}/${kind}/${id}`, {
|
|
headers: authHeaders(),
|
|
});
|
|
} catch {
|
|
throw new ApiError("اتصال به سرور برقرار نشد — آیا بکاند در دسترس است؟", 0);
|
|
}
|
|
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);
|
|
}
|
|
},
|
|
},
|
|
|
|
// ---------- برندها (از بکاند) ----------
|
|
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);
|
|
},
|
|
update: async (id: string, body: { name?: string; description?: string | null; project_id?: string }): 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,
|
|
category_id: null, // دسته از اولین تأمینکنندهی مرتبط استنتاج میشود (v2.5)
|
|
topic: null,
|
|
canonical_unit_id: opts.canonical_unit_id,
|
|
status: "active",
|
|
brand_ids: opts.brand_ids,
|
|
attributes: [],
|
|
}),
|
|
},
|
|
};
|