Files
ERP/frontend/src/lib/quoteUtils.ts
T
ZCode b469238153 refactor(catalog): reduce the category taxonomy to a supplier label
Categories are kept only as the label each supplier carries (suppliers.category_id
+ the material_categories list it points at). Everything that treated category as
a shared key across materials, inventory, quotes and unit conversions is removed —
including two features that were never populated (0 rows on every volume).

Removed (backend):
- routers category_attributes, category_ref_units, unit_conversions + registration
- ?category_id= filters on /materials, /inventory and /quotes (the /suppliers one
  stays — it filters suppliers by their own label)
- GET /inventory/materials/options: required a category and had no caller
- the material-payload category derivation (expand_service.category_by_material,
  the inventory serializer lookup) and the material category on /pm/on-the-way;
  the wire keys stay, now always null, so the payload shape is unchanged
- startup DDL: DROP the empty category_attributes / category_reference_units /
  unit_conversions tables (idempotent) and their CREATE statements; seed.py
  truncate list updated

Removed (frontend):
- the category-keyed conversion path: refUnitForCategory / findConversion /
  refConverted, the categoryUnits + unitConversions store slices, api clients,
  hooks and the "≈ converted quantity" line on quote detail and project pages
  (it needed rows in those tables to render, and there were none)
- material.supplier_category and its read sites (the edit modal grew one
  yesterday to restore labels; the label lives on the supplier now)
- the inventory page's category chips, the row filter and the «دسته» column,
  plus the lookup's categories fetch and catName helper

Kept: supplier label + its list and picker, /suppliers?category_id=, the global
standard_conversions feature and the units page (material_units conversions).

Verified against a restore of the production dump: startup DDL drops exactly the
three tables, suppliers keep their label (3/3), material_categories still serves
its 8 rows, the removed endpoints 404, and materials/inventory/quotes/units/
standard-conversions all return 200 with quote line items intact.
2026-09-20 19:44:23 +00:00

364 lines
14 KiB
TypeScript

// ============================================================
// منطق بیزنس مشترک پیشنهادها — پورت از app.js
// ============================================================
import type { LineItem, Material, Quote, Store, Supplier } from "./types";
import { normalize } from "./normalize";
import { groupCommas } from "./numutil";
import { fmtDate } from "./formatters";
export function supplierById(suppliers: Supplier[], id: string): Supplier {
return suppliers.find((x) => x.id === id) || { id: "", name: "؟", location: "", topic: "", categories: [], phone: "", contact: "" };
}
export function materialById(materials: Material[], id: string): Material {
return (
materials.find((x) => x.id === id) || {
id: "",
name: "",
topic: "",
brand: "",
specs: [],
base_unit: "",
}
);
}
// spec chips یک material (داده‌های قدیمی ممکن است spec رشته‌ای داشته باشند)
export function materialSpecs(m: Material | null | undefined): string[] {
return (m && m.specs) || [];
}
// کلید متعارف یک مجموعه spec — ترتیب مهم نیست، متن نرمال‌شده
export function specsKey(specs: string[]): string {
return (specs || []).map((s) => normalize(s)).filter(Boolean).sort().join("|");
}
// specها با جداکننده‌ی « . »
export function specsLabel(specs: string[]): string {
return specs.join(" . ");
}
/**
* عنوان تمیز و بدون تکرار کالا:
* ترتیب اکید با جداکننده‌ی نقطه:
* [نام برند] . [نام متریال] . [مشخصه ۱] . [مشخصه ۲]
*/
export function formatMaterialTitle(
m: Pick<Material, "name" | "brand" | "specs"> | null | undefined,
extraSpecs?: string[],
): string {
if (!m) return "کالا";
let rawName = (m.name || "").trim();
const rawBrand = (m.brand || "").trim();
const rawSpecs = [...(m.specs || []), ...(extraSpecs || [])]
.map((s) => (s || "").trim())
.filter(Boolean);
const normBrand = normalize(rawBrand);
// اگر نام کالا با نام برند شروع شده باشد، برند را از ابتدای نام برمی‌داریم تا تکرار نشود
if (normBrand && rawName) {
const normName = normalize(rawName);
if (normName.startsWith(normBrand)) {
const rest = rawName.slice(rawBrand.length).trim();
rawName = rest.replace(/^[.\s·\-]+/, "").trim();
}
}
// قطعات نهایی با ترتیب مشخص: [Brand Name] . [Material Name] . [Specification 1] . [Specification 2]
const parts: string[] = [];
if (rawBrand) {
parts.push(rawBrand);
}
if (rawName) {
parts.push(rawName);
} else if (!rawBrand) {
parts.push("کالا");
}
// اضافه کردن مشخصه‌ها بدون تکرار
const normCurrent = normalize(parts.join(" "));
const seenNormSpecs = new Set<string>();
for (const s of rawSpecs) {
const ns = normalize(s);
if (!ns || seenNormSpecs.has(ns)) continue;
seenNormSpecs.add(ns);
// اگر مشخصه قبلاً داخل نام یا برند آمده باشد، تکرار نمی‌کنیم
if (normCurrent.includes(ns)) continue;
parts.push(s);
}
return parts.join(" . ");
}
export function materialLabel(m: Pick<Material, "name" | "brand" | "specs"> | null | undefined): string {
return formatMaterialTitle(m);
}
// نام پایه‌ی متریال از نامِ ذخیره‌شده.
// متریال‌های جدید فقط نامِ خودِ متریال را در name دارند (برند در brand و
// مشخصه‌ها در attributes ذخیره می‌شوند) و دست‌نخورده برمی‌گردند. رکوردهای قدیمی
// name را به شکل «برند + مشخصه» ذخیره کرده‌اند؛ آن قطعات جدا می‌شوند تا نامِ
// پایه باقی بماند.
export function materialBaseName(name: string, brand: string, specs: string[]): string {
const fallback = (name || "").trim();
if (!fallback) return "";
let base = fallback;
// بلندترین قطعه اول حذف می‌شود تا حذف‌های جزئی، رشته را ناقص نکنند
const parts = [brand, ...specs]
.map((p) => (p || "").trim())
.filter(Boolean)
.sort((a, b) => b.length - a.length);
for (const p of parts) {
if (!base.includes(p)) continue;
const next = base.split(p).join(" ").replace(/\s+/g, " ").trim();
// اگر حذفِ این قطعه کلِ نام را خالی کند، یعنی همان نامِ متریال بوده — نگهش دار
if (!next) continue;
base = next;
}
// شکلِ قدیمیِ خراب می‌تواند فقط یک مشخصه باقی بگذارد؛ آن دیگر نامِ متریال نیست
if (parts.some((p) => p === base)) return fallback;
return base || fallback;
}
/**
* یافتن متریالِ متناظر با یک ردیفِ اقلام.
*
* هویت متریال = نام + برند + مشخصه. پیش از v2.7 دسته هم در این تطبیق بود، ولی
* دسته دیگر روی متریال نیست؛ تکیه بر نام جلوی اشتباه‌گرفتن دو متریال با
* برند و مشخصه‌ی یکسان (مثل دو چمن مصنوعیِ هم‌برند) را می‌گیرد. نامِ خالی یعنی
* هویت نامعلوم — در آن حالت تطبیق نمی‌دهیم و متریالِ تازه ساخته می‌شود.
*/
export function matchMaterial(
materials: Material[],
name: string,
brand: string,
specs: string[],
): Material | undefined {
const wantName = normalize(name);
if (!wantName) return undefined;
const wantBrand = normalize(brand);
const wantSpecs = specsKey(specs);
const sameIdentity = (m: Material) => normalize(m.brand) === wantBrand && specsKey(m.specs) === wantSpecs;
const base = normalize(materialBaseName(name, brand, specs));
return (
materials.find((m) => normalize(m.name) === wantName && sameIdentity(m)) ||
materials.find(
(m) =>
base &&
normalize(materialBaseName(m.name, m.brand, m.specs)) === base &&
sameIdentity(m),
)
);
}
// آرایه‌ی termsheet_ids یک قلم (هر دو فرمت قدیم/جدید)
export function itemTermsheetIds(li: LineItem): string[] {
if (li.termsheet_ids) return li.termsheet_ids;
return [];
}
// اقلام داخل معامله (ردشده‌ها نگه داشته می‌شوند ولی هرگز شمرده نمی‌شوند)
export function activeItems(q: Quote): LineItem[] {
return (q.line_items || []).filter((li) => !li.rejected);
}
// جمع کل اقلام فعال (total_price اگر هست، وگرنه unit*qty)
export function quoteTotal(q: Quote): number {
return activeItems(q).reduce((sum, li) => {
const t =
li.total_price != null
? li.total_price
: li.unit_price != null && li.quantity != null
? li.unit_price * li.quantity
: 0;
return sum + (t || 0);
}, 0);
}
// ترم‌شیت مؤثر: پذیرفته‌شده، وگرنه اولین offered
export function effectiveTermsheet(q: Quote): Quote["termsheets"][number] | null {
if (!q.termsheets || !q.termsheets.length) return null;
return q.termsheets.find((t) => t.status === "accepted") || q.termsheets[0];
}
// id ترم‌شیت‌هایی که واقعاً توسط اقلام فعال ارجاع شده‌اند
export function usedTermsheetIds(q: Quote): string[] {
const ids = new Set<string>();
activeItems(q).forEach((li) => {
itemTermsheetIds(li).forEach((tid) => {
if (tid) ids.add(tid);
});
});
return [...ids];
}
// مجموع هر قلم
export function lineItemTotal(li: LineItem): number | null {
if (li.total_price != null) return li.total_price;
if (li.unit_price != null && li.quantity != null) return li.unit_price * li.quantity;
return null;
}
// پروژه‌های یک قلم:
// - در وضعیت offered: تمام پروژه‌های کاندیدا (line_item_projects)
// - در وضعیت accepted یا arrived: فقط تک پروژه‌ی انتخاب‌شده در خرید (item_decisions.project_id)
export function itemProjects(q: Quote, li: LineItem): string[] {
if (q.status === "accepted" || q.status === "arrived") {
return li.target_project?.name ? [li.target_project.name] : [];
}
// For 'offered', 'rejected', 'expired': return all candidate projects
return (li.projects || []).map((p) => (typeof p === "string" ? p : p.name)).filter(Boolean);
}
// واحدهای ناشناخته‌ی استفاده‌شده در یک quote → لینک به داشبورد واحدها
// واحدها دیگر hardcode نیستند — همه از بک‌اند می‌آیند؛ هشداری نمایش داده نمی‌شود
export function unitWarning(_store: Store, _q: Quote): { unit: string; cat: string }[] {
return [];
}
// ستون پروژه:
// در وضعیت‌های پذیرفته‌شده یا رسیده، فقط پروژه‌های هدف خرید اقلام نشان داده می‌شوند
export function projectCell(q: Quote): string {
if (q.status === "accepted" || q.status === "arrived") {
const targets = (q.line_items || [])
.filter((li) => !li.rejected)
.map((li) =>
li.target_project?.name ||
(li.projects && li.projects.length === 1
? typeof li.projects[0] === "string"
? li.projects[0]
: li.projects[0].name
: null),
)
.filter(Boolean) as string[];
const unique = [...new Set(targets)];
if (unique.length) return unique.join("، ");
}
if (q.projects && q.projects.length) return q.projects.join("، ");
return q.project || "—";
}
// خلاصه‌ی اقلام (ردیف جمع‌شده)
export function itemsSummary(store: Store, q: Quote): { text: string; count: number } {
const items = activeItems(q).map((li) => {
const m = materialById(store.materials, li.material_id);
return materialLabel(m);
});
return { text: items.join("، "), count: items.length };
}
// ستون معامله — نمایش فشرده برای ردیف جمع‌شده
export function termsheetSummary(store: Store, q: Quote): { title: string; sub: string; color: string } {
if (!q.termsheets || !q.termsheets.length) return { title: "—", sub: "", color: "" };
const used = usedTermsheetIds(q);
const accepted = q.termsheets.find((t) => t.status === "accepted");
if (accepted) {
const items = activeItems(q).filter((li) => itemTermsheetIds(li).includes(accepted.id));
const itemsDesc =
items.length === q.line_items.length
? "همه اقلام"
: items
.map((li) => {
const m = materialById(store.materials, li.material_id);
const specText = specsLabel(materialSpecs(m));
return specText || m.brand;
})
.join("، ");
return {
title: accepted.label,
sub: `${itemsDesc} · ${accepted.milestones.length} مرحله`,
color: "var(--green)",
};
}
if (used.length === 1) {
return { title: q.termsheets[0].label, sub: `${q.termsheets.length} گزینه`, color: "" };
}
return { title: `${used.length} termsheet جدا · ${q.termsheets.length} گزینه`, sub: "", color: "" };
}
// فیلترهای دفتر
export function filterQuotes(
store: Store,
filters: { supplier: string; status: string; search: string },
): Quote[] {
const supVal = filters.supplier.trim();
const stVal = filters.status.trim();
const q = filters.search.trim().toLowerCase();
const STATUS_BY_LABEL: Record<string, string> = {
"پیشنهاد شده": "offered",
"پذیرفته شده": "accepted",
"رسیده": "arrived",
"باز": "offered",
"پذیرفته": "accepted",
"offered": "offered",
"accepted": "accepted",
"arrived": "arrived",
};
const stKey = STATUS_BY_LABEL[stVal] || stVal;
return store.quotes.filter((quote) => {
const supObj = supplierById(store.suppliers, quote.supplier_id);
if (supVal && supObj.name !== supVal) return false;
if (stKey && quote.status !== stKey) return false;
const matInfo = activeItems(quote).map((li) => materialById(store.materials, li.material_id));
if (q) {
const hay =
`${supObj.name} ${quote.project || ""} ` +
matInfo.map((m) => `${m.brand} ${materialSpecs(m).join(" ")}`).join(" ");
if (!hay.toLowerCase().includes(q)) return false;
}
return true;
});
}
// وضعیت منقضی
export function isExpired(q: Quote): boolean {
return (
q.status === "offered" &&
!quoteHasArrivedOrAccepted(q) &&
!!q.expiry_date &&
new Date(q.expiry_date) < new Date(new Date().toDateString())
);
}
function quoteHasArrivedOrAccepted(q: Quote): boolean {
return q.status === "accepted" || q.status === "arrived";
}
// آماده‌سازی نمایش ترم‌شیت در حالت expanded
export function termSheetBlocks(
store: Store,
q: Quote,
): { ts: Quote["termsheets"][number]; isAccepted: boolean; isUsed: boolean; itemsDesc: string; milestones: string[] }[] {
const used = usedTermsheetIds(q);
const active = activeItems(q);
return q.termsheets.map((ts) => {
const isAccepted = ts.status === "accepted";
const isUsed = used.includes(ts.id);
const items = active.filter((li) => itemTermsheetIds(li).includes(ts.id));
const itemsDesc =
items.length === active.length
? "همه اقلام"
: items.length
? items
.map((li) => {
const m = materialById(store.materials, li.material_id);
return specsLabel(materialSpecs(m)) || m.brand;
})
.join("، ")
: "بدون قلم";
const milestones = ts.milestones.map((m) => {
const dateLabel = m.when_iso ? fmtDate(m.when_iso) : "?";
const amt = m.amount != null ? groupCommas(m.amount, true) : "?";
return `${dateLabel} · ${amt}`;
});
return { ts, isAccepted, isUsed, itemsDesc, milestones };
});
}