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);
|
||||
// پروژههای سطح پیشنهاد با اتحاد پروژههای اقلام همگام میشوند
|
||||
|
||||
Reference in New Issue
Block a user