feat(quotes): lock quotes with posted receipts; per-item target project + title dedup
Quotes that already have warehouse receipts can no longer be edited or rolled back: - backend: quote_has_receipts() guard — replace_quote_children and every backward status transition now return 409 with «این پیشنهاد بهدلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.» - reopening to «offered» additionally clears item_decisions.project_id (the purchased-project link belongs to acceptance, not to a reopened offer) - frontend EditModal: hasReceipts locks the whole form — disabled inputs and date pickers (JalaliDatePicker/NumberInput/MilestoneRow/MultiSelectCombobox gained disabled support), add/remove controls for items, termsheets and milestones hidden, status change blocked, amber warning banner; quote deletion now goes through a confirmation dialog (useDeleteQuote) - date-only semantics: JalaliDatePicker keeps/emits YYYY-MM-DD when withTime=false (typing, «امروز», day selection) and toDateOnly no longer mangles pure date strings Per-item target project (candidate projects stay intact): - expand_service exposes the decision target project (decision.project + line_item.target_project) - the accept payload derives item_decisions.project_id from that target project and sends item_projects: [] so line_item_projects keeps the original candidates untouched - AcceptModal/EditModal select the target project per line item Material titles: - quoteUtils.formatMaterialTitle() composes one normalized, deduplicated «name + brand + specs» title, replacing materialLabel/materialSpecs/specsLabel usage across dashboard cards, quote detail and the modals Verified: python compile + api/frontend image builds (tsc + vite) clean.
This commit is contained in:
@@ -40,20 +40,58 @@ export function specsLabel(specs: string[]): string {
|
||||
return specs.join(" · ");
|
||||
}
|
||||
|
||||
// نام نمایشی متریال — نام سراسری واقعی (materials.name)، نه دسته.
|
||||
// برند و مشخصه فقط وقتی به نام اضافه میشوند که داخلش نیامده باشند (بدون تکرار).
|
||||
export function materialLabel(m: Pick<Material, "name" | "brand" | "specs" | "category">): string {
|
||||
const name = (m.name || "").trim();
|
||||
const specs = (m.specs || []).filter(Boolean);
|
||||
if (!name && !m.brand && !specs.length) return m.category === "؟" ? "؟" : "کالا";
|
||||
if (!name) {
|
||||
return [m.brand, ...specs].filter(Boolean).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 : "کالا";
|
||||
}
|
||||
const parts = [name];
|
||||
if (m.brand && !name.includes(m.brand)) parts.push(m.brand);
|
||||
const missing = specs.filter((s) => !name.includes(s));
|
||||
if (missing.length) parts.push(`(${missing.join("، ")})`);
|
||||
return parts.join(" ");
|
||||
|
||||
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 یک قلم (هر دو فرمت قدیم/جدید)
|
||||
@@ -104,8 +142,21 @@ export function lineItemTotal(li: LineItem): number | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
// پروژههای یک قلم — فقط پروژههای تخصیصیافته به همین قلم (بدون fallback به سطح پیشنهاد)
|
||||
export function itemProjects(_q: Quote, li: LineItem): string[] {
|
||||
// پروژههای یک قلم:
|
||||
// - در وضعیت 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") {
|
||||
if (li.target_project?.name) {
|
||||
return [li.target_project.name];
|
||||
}
|
||||
// اگر target_project هنوز آبجکت نام نداشت، بررسی نام در صورت تکپروژه بودن
|
||||
if (li.projects && li.projects.length === 1) {
|
||||
const p = li.projects[0];
|
||||
return [typeof p === "string" ? p : p.name];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
return (li.projects || []).map((p) => (typeof p === "string" ? p : p.name));
|
||||
}
|
||||
|
||||
@@ -163,8 +214,24 @@ export function unitWarning(_store: Store, _q: Quote): { unit: string; cat: stri
|
||||
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 || "—";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user