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:
+19
-23
@@ -285,6 +285,8 @@ 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;
|
||||
}
|
||||
@@ -309,6 +311,7 @@ interface RawLineItem {
|
||||
termsheet_ids?: string[] | null;
|
||||
accepted_termsheet_id?: string | null; // id ترمشیت پذیرفتهشده (task: خط آبی)
|
||||
projects?: RawProject[] | null; // پروژههای تخصیصیافته به این قلم
|
||||
target_project?: RawProject | null;
|
||||
}
|
||||
interface RawTermsheet {
|
||||
id: string;
|
||||
@@ -517,6 +520,7 @@ function adaptMaterial(raw: RawMaterial, lookup: Lookup): Material {
|
||||
}
|
||||
|
||||
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 || "",
|
||||
@@ -537,6 +541,8 @@ function adaptLineItem(raw: RawLineItem): Quote["line_items"][number] {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -662,10 +668,10 @@ function adaptAttachment(raw: RawAttachment): Attachment {
|
||||
|
||||
function toDateOnly(v: string | null | undefined): string | null {
|
||||
if (!v) return null;
|
||||
const m = String(v).match(/^(\d{4}-\d{2}-\d{2})/);
|
||||
if (m) return m[1];
|
||||
const str = String(v).trim();
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(str)) return str;
|
||||
// datetime کامل → تاریخ به وقت محلی (نه UTC که یک روز عقب میافتد)
|
||||
const d = new Date(v);
|
||||
const d = new Date(str);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
@@ -855,23 +861,17 @@ export const api = {
|
||||
|
||||
if (becomingAccepted) {
|
||||
const acceptedTs = (patch.termsheets || []).filter((t) => t.status === "accepted");
|
||||
// تخصیص پروژه به هر قلم — نامها به id واقعی ترجمه میشوند
|
||||
const itemProjects: { line_item_id: string; project_ids: string[] }[] = [];
|
||||
const allProjectIds: string[] = [];
|
||||
const seenPids = new Set<string>();
|
||||
// پروژهی واحدِ انتخابشده برای هر قلم (item_decisions.project_id)
|
||||
const targetProjByLine = new Map<string, string>();
|
||||
for (const li of patch.line_items || []) {
|
||||
const ids: string[] = [];
|
||||
for (const p of li.projects || []) {
|
||||
const pid = looksLikeUuid(p.id) ? p.id : await projectIdByNameOrCreate(p.name);
|
||||
if (!pid) continue;
|
||||
ids.push(pid);
|
||||
if (!seenPids.has(pid)) {
|
||||
seenPids.add(pid);
|
||||
allProjectIds.push(pid);
|
||||
}
|
||||
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);
|
||||
}
|
||||
itemProjects.push({ line_item_id: li.id, project_ids: ids });
|
||||
}
|
||||
const allProjectIds = [...new Set([...targetProjByLine.values()])];
|
||||
|
||||
const payload = {
|
||||
accepted_termsheets: acceptedTs.map((t) => ({
|
||||
id: looksLikeUuid(t.id) ? t.id : undefined,
|
||||
@@ -887,11 +887,7 @@ export const api = {
|
||||
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;
|
||||
// پروژهی واحدِ انتخابشده برای این قلم — مستقیم داخل decision میرود تا
|
||||
// «کالاهای در راه» همان مدیر پروژه را (با item_decisions.project_id)
|
||||
// نشان دهد؛ قبلاً فقط item_projects جداگانه میرفت و decision بدون
|
||||
// پروژه میماند (قلمها در پنل هیچ مدیر پروژهای دیده نمیشدند)
|
||||
const chosenProjId = itemProjects.find((p) => p.line_item_id === li.id)?.project_ids[0];
|
||||
const chosenProjId = targetProjByLine.get(li.id);
|
||||
return {
|
||||
line_item_id: li.id,
|
||||
status: chosen ? "chosen" : "rejected",
|
||||
@@ -900,7 +896,7 @@ export const api = {
|
||||
...(li.unit_price != null ? { adjusted_unit_price: li.unit_price } : {}),
|
||||
};
|
||||
}),
|
||||
item_projects: itemProjects,
|
||||
item_projects: [], // کاندیداهای قبلی در line_item_projects کاملاً حفظ میشوند
|
||||
};
|
||||
await patchData(`/quotes/${id}/accept`, payload);
|
||||
// پروژههای سطح پیشنهاد با اتحاد پروژههای اقلام همگام میشوند
|
||||
|
||||
@@ -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 || "—";
|
||||
}
|
||||
|
||||
@@ -71,7 +71,9 @@ export interface LineItem {
|
||||
arrived_date: string | null; // تاریخ آخرین رسید
|
||||
received_qty?: number | null; // مجموع رسیدهای ثبتشده (رسید جزیی) — null = هنوز دادهای نیست
|
||||
received_warehouses?: { id: string; name: string; quantity: number }[] | null; // انبارهای مقصد رسیدها
|
||||
projects?: { id: string; name: string }[]; // پروژههای تخصیصیافته به این قلم
|
||||
projects?: { id: string; name: string }[]; // پروژههای کاندیدای تخصیصیافته به این قلم (line_item_projects)
|
||||
target_project?: { id: string; name: string } | null; // پروژهی نهایی انتخابشده در هنگام خرید (item_decisions.project_id)
|
||||
target_project_id?: string | null;
|
||||
}
|
||||
|
||||
// پیشنهاد خرید (اصلیترین entity)
|
||||
|
||||
Reference in New Issue
Block a user