Wizard: - failed saves now route to the exact step+field: a new data-field attribute on every required section (supplier_id, line_items, termsheets, project, quote_date, expiry_date, notes, proforma) plus resolveErrorTarget() that maps ApiError.field / message keywords (supplier, line item/material/unit/quantity, termsheet/schedule/milestone, project, dates, notes, file) to the offending step — the «رفع خطا» toast action scrolls/focuses the right control instead of guessing - material records are no longer created on the fly while typing (rememberMaterial no longer fires addMaterial per blur); materials/specs/units are written once during the final save, avoiding half-created catalog rows Accept flow: - quote_service no longer rewrites LineItemProjects on accept — those candidate-project rows stay 100% untouched and only item_decisions.project_id is written, matching the per-item target-project model and the UI payload (item_projects: []) Dates/time: - JalaliDatePicker: hour/minute are now editable text fields (Persian digits, clamping, 3-digit retype handling) with a controlled useEffect that only rewrites the inputs when the numeric value actually changes — no more wiping in-progress typing; date-only detection tightened to a full YYYY-MM-DD match so datetime strings are not misread Also: /api validation logging (RequestValidationError) now includes the path and error list for faster diagnosis. Verified: api + frontend image builds (python, tsc, vite) clean on top of origin/production (incl. the team's mobile layout merges).
344 lines
13 KiB
TypeScript
344 lines
13 KiB
TypeScript
// ============================================================
|
|
// منطق بیزنس مشترک پیشنهادها — پورت از app.js
|
|
// ============================================================
|
|
|
|
import type { LineItem, Material, Quote, StandardConversion, Store, Supplier, UnitConversion } 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: "",
|
|
category: "؟",
|
|
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(" · ");
|
|
}
|
|
|
|
/**
|
|
* عنوان تمیز و بدون تکرار کالا:
|
|
* ترکیب هوشمندانهی نام اصلی (materials.name)، برند، و مشخصهها بدون تکرار کلمات مشترک.
|
|
*/
|
|
export function formatMaterialTitle(
|
|
m: Pick<Material, "name" | "brand" | "specs" | "category"> | null | undefined,
|
|
extraSpecs?: string[],
|
|
): string {
|
|
if (!m) return "کالا";
|
|
const rawName = (m.name || "").trim();
|
|
const rawBrand = (m.brand || "").trim();
|
|
const rawSpecs = [...(m.specs || []), ...(extraSpecs || [])]
|
|
.map((s) => (s || "").trim())
|
|
.filter(Boolean);
|
|
|
|
const normName = normalize(rawName);
|
|
const normBrand = normalize(rawBrand);
|
|
|
|
if (!rawName) {
|
|
if (rawBrand && rawSpecs.length) return `${rawBrand} · ${rawSpecs.join(" · ")}`;
|
|
if (rawBrand) return rawBrand;
|
|
if (rawSpecs.length) return rawSpecs.join(" · ");
|
|
return m.category && m.category !== "؟" ? m.category : "کالا";
|
|
}
|
|
|
|
let title = rawName;
|
|
if (normBrand && !normName.includes(normBrand)) {
|
|
title = `${rawBrand} ${title}`;
|
|
}
|
|
|
|
const normTitle = normalize(title);
|
|
const distinctSpecs: string[] = [];
|
|
const seenNormSpecs = new Set<string>();
|
|
|
|
for (const s of rawSpecs) {
|
|
const ns = normalize(s);
|
|
if (!ns || seenNormSpecs.has(ns)) continue;
|
|
seenNormSpecs.add(ns);
|
|
|
|
// اگر مشخصه قبلاً داخل عنوان یا نام کالا آمده باشد، تکرار نمیکنیم
|
|
if (normTitle.includes(ns)) continue;
|
|
distinctSpecs.push(s);
|
|
}
|
|
|
|
if (distinctSpecs.length > 0) {
|
|
return `${title} · ${distinctSpecs.join(" · ")}`;
|
|
}
|
|
return title;
|
|
}
|
|
|
|
export function materialLabel(m: Pick<Material, "name" | "brand" | "specs" | "category"> | null | undefined): string {
|
|
return formatMaterialTitle(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);
|
|
}
|
|
|
|
// ---------- واحد مرجع و ضریب تبدیل ----------
|
|
|
|
// واحد مرجع یک دسته
|
|
export function refUnitForCategory(store: Pick<Store, "categoryUnits">, category: string): string {
|
|
const cu = (store.categoryUnits || []).find((c) => normalize(c.category) === normalize(category));
|
|
return cu ? cu.ref_unit : "";
|
|
}
|
|
|
|
// ضریب تبدیل با اولویت: دستهای (برند مشخص > همه برندها) > تبدیل ثابت جهانی
|
|
export function findConversion(
|
|
store: Pick<Store, "unitConversions" | "standardConversions">,
|
|
category: string,
|
|
brand: string,
|
|
fromUnit: string,
|
|
toUnit: string,
|
|
): UnitConversion | StandardConversion | null {
|
|
const list = (store.unitConversions || []).filter(
|
|
(c) =>
|
|
normalize(c.category) === normalize(category) &&
|
|
normalize(c.from_unit) === normalize(fromUnit) &&
|
|
normalize(c.to_unit) === normalize(toUnit) &&
|
|
(!c.brand || normalize(c.brand) === normalize(brand)),
|
|
);
|
|
list.sort((a, b) => (b.brand ? 1 : 0) - (a.brand ? 1 : 0));
|
|
if (list[0]) return list[0];
|
|
return (
|
|
(store.standardConversions || []).find(
|
|
(c) => normalize(c.from_unit) === normalize(fromUnit) && normalize(c.to_unit) === normalize(toUnit),
|
|
) || null
|
|
);
|
|
}
|
|
|
|
// تبدیل یک قلم به واحد مرجع دسته؛ null وقتی ضریبی نیست یا واحد از قبل مرجع است
|
|
export function refConverted(
|
|
store: Pick<Store, "categoryUnits" | "unitConversions" | "standardConversions" | "materials">,
|
|
li: LineItem,
|
|
): { qty: number; unit: string; perRef: number | null } | null {
|
|
const m = materialById(store.materials, li.material_id);
|
|
const ref = refUnitForCategory(store, m.category);
|
|
if (!ref || !li.unit || normalize(ref) === normalize(li.unit)) return null;
|
|
const conv = findConversion(store, m.category, m.brand, li.unit, ref);
|
|
if (!conv) return null;
|
|
const qty = (li.quantity || 0) * conv.factor;
|
|
const total = li.total_price != null ? li.total_price : (li.unit_price || 0) * (li.quantity || 0);
|
|
const perRef = qty > 0 ? Math.round(total / qty) : null;
|
|
return { qty, unit: ref, perRef };
|
|
}
|
|
|
|
// واحدهای ناشناختهی استفادهشده در یک 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: { category: string; supplier: string; status: string; search: string },
|
|
): Quote[] {
|
|
const catVal = filters.category.trim();
|
|
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",
|
|
};
|
|
const stKey = STATUS_BY_LABEL[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 (catVal && !matInfo.some((m) => m.category === catVal)) return false;
|
|
|
|
if (q) {
|
|
const hay =
|
|
`${supObj.name} ${quote.project || ""} ` +
|
|
matInfo.map((m) => `${m.category} ${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" &&
|
|
!!q.expiry_date &&
|
|
new Date(q.expiry_date) < new Date(new Date().toDateString())
|
|
);
|
|
}
|
|
|
|
// آمادهسازی نمایش ترمشیت در حالت 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 };
|
|
});
|
|
}
|