From 65758b5e785b30fe63a8b4f03e46a56a24ea7332 Mon Sep 17 00:00:00 2001 From: "reza.g" Date: Sat, 19 Sep 2026 15:11:57 +0330 Subject: [PATCH] fix(projects): isolate quote acceptance status per project (decision-target based) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug: a multi-project quote accepted for «تست ۱» only also appeared under «تست ۲»'s پذیرفته شده column, because both the Projects page and /projects/{id}/stats classified quotes by their GLOBAL status over any project link. Rule now: a quote counts as accepted/arrived for a project ONLY if at least one non-rejected line item's decision targets that project (item_decisions.project_id). A quote merely linked as a candidate — including one accepted for a different project — is categorized exclusively under پیشنهاد شده for that project. Legacy quotes without any decision targets keep the old global-status classification. Backend: project_stats membership now spans all link kinds (legacy mirror, quote_projects, line_item_projects, decision targets) with the per-project decision-based bucketing above; bucket VALUES follow the same isolation (accepted/arrived value counts only items targeting the project; candidate items of a still-proposal quote stay in the offered bucket); the raw path string is normalized to UUID for the Python-side comparisons. /quotes?project_id=… now matches any relevant link kind instead of only the legacy mirror column. Frontend: ProjectsPage gains acceptedForProject/hasTargets helpers; the three columns (پیشنهاد شده / پذیرفته شده / رسیده) and their value chips use per-project items (target-project items first, then candidates), so totals no longer leak other projects' items. Verified end-to-end (2 projects × 1 quote × 2 items, accept item-1 for P1 only): P1 accepted=1 then arrived=1 after the PM receipt; P2 stays offered=1 / accepted=0 / arrived=0 at every step; /quotes?project_id=P2 still lists the quote. All fixture data removed; 7/7 checks pass. python compile + api/frontend builds clean. --- backend/app/routers/quotes.py | 29 +++++- backend/app/services/quote_service.py | 98 ++++++++++++++++++-- frontend/src/pages/Projects/ProjectsPage.tsx | 72 +++++++++----- 3 files changed, 165 insertions(+), 34 deletions(-) diff --git a/backend/app/routers/quotes.py b/backend/app/routers/quotes.py index 8aaef91..c59ba5d 100644 --- a/backend/app/routers/quotes.py +++ b/backend/app/routers/quotes.py @@ -10,7 +10,16 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.db.engine import get_db from app.models.catalog import Material -from app.models.procurement import ItemDecision, LineItem, Project, Quote, Supplier, Termsheet +from app.models.procurement import ( + ItemDecision, + LineItem, + LineItemProjects, + Project, + Quote, + QuoteProjects, + Supplier, + Termsheet, +) from app.schemas.procurement import ( AcceptQuoteIn, ArriveItemsIn, @@ -111,7 +120,23 @@ async def list_quotes( if supplier_id: stmt = stmt.where(Quote.supplier_id == supplier_id) if project_id: - stmt = stmt.where(Quote.project_id == project_id) + # any relevant link: legacy mirror, quote-level junction, line-item + # candidates, or an accepted (decision) target — NOT just the mirror + stmt = stmt.where( + Quote.id.in_( + select(Quote.id) + .outerjoin(QuoteProjects, QuoteProjects.quote_id == Quote.id) + .outerjoin(LineItem, LineItem.quote_id == Quote.id) + .outerjoin(LineItemProjects, LineItemProjects.line_item_id == LineItem.id) + .outerjoin(ItemDecision, ItemDecision.line_item_id == LineItem.id) + .where( + (Quote.project_id == project_id) + | (QuoteProjects.project_id == project_id) + | (LineItemProjects.project_id == project_id) + | (ItemDecision.project_id == project_id) + ) + ) + ) if category_id: stmt = stmt.where(_quote_category_exists(category_id)) if quote_date_from is not None: diff --git a/backend/app/services/quote_service.py b/backend/app/services/quote_service.py index 9f091c5..f56715e 100644 --- a/backend/app/services/quote_service.py +++ b/backend/app/services/quote_service.py @@ -915,22 +915,45 @@ def _quote_has_category(category_id: UUID): async def project_stats(db: AsyncSession, project_id: UUID) -> dict: - """Counts + total value per quote status for one project. + """Counts + total value per quote status for ONE project, isolated per project. - total_value: SUM(quantity × unit_price) over line items using the chosen - termsheet link first, else the first link. Rejected line items are - excluded from value sums (contract P1-14). + A quote lands in this project's «accepted»/«arrived» bucket ONLY if at + least one non-rejected line item's decision targets this project + (item_decisions.project_id). A quote merely linked to the project as a + candidate (quote-level link, line-item candidates, or accepted for a + DIFFERENT project) stays in «offered» — this project never accepted it. + + total_value: SUM(quantity × unit_price) over the bucket's line items, + using the chosen termsheet link first, else the first link. Rejected + line items are excluded from value sums (contract P1-14). """ project = await db.get(Project, project_id) if project is None: raise NotFoundError("پروژه یافت نشد") + # the route passes the raw path string — normalize for the Python-side + # UUID comparisons below (decision.project_id == project_id etc.) + project_id = UUID(str(project_id)) - quotes = (await db.execute(select(Quote).where(Quote.project_id == project_id))).scalars().all() - if not quotes: + # --- every quote RELEVANT to this project (any kind of link) --- + quote_id_rows = await db.execute( + select(Quote.id) + .outerjoin(QuoteProjects, QuoteProjects.quote_id == Quote.id) + .outerjoin(LineItem, LineItem.quote_id == Quote.id) + .outerjoin(LineItemProjects, LineItemProjects.line_item_id == LineItem.id) + .outerjoin(ItemDecision, ItemDecision.line_item_id == LineItem.id) + .where( + (Quote.project_id == project_id) + | (QuoteProjects.project_id == project_id) + | (LineItemProjects.project_id == project_id) + | (ItemDecision.project_id == project_id) + ) + ) + quote_ids = [row[0] for row in quote_id_rows.all()] + if not quote_ids: empty = {status: {"count": 0, "total_value": 0} for status in QUOTE_STATUSES} return {"project_id": str(project_id), "total_quotes": 0, **empty} - quote_ids = [q.id for q in quotes] + quotes = (await db.execute(select(Quote).where(Quote.id.in_(quote_ids)))).scalars().all() line_items = (await db.execute(select(LineItem).where(LineItem.quote_id.in_(quote_ids)))).scalars().all() li_ids = [li.id for li in line_items] @@ -938,6 +961,18 @@ async def project_stats(db: AsyncSession, project_id: UUID) -> dict: d.line_item_id: d for d in (await db.execute(select(ItemDecision).where(ItemDecision.line_item_id.in_(li_ids)))).scalars().all() } if li_ids else {} + # line items explicitly linked to this project as candidates + candidate_li_ids = set( + ( + await db.execute( + select(LineItemProjects.line_item_id).where( + LineItemProjects.line_item_id.in_(li_ids), + LineItemProjects.project_id == project_id, + ) + ) + ).scalars().all() + ) if li_ids else set() + item_termsheets = ( (await db.execute(select(ItemTermsheet).where(ItemTermsheet.line_item_id.in_(li_ids)))).scalars().all() ) if li_ids else [] @@ -946,12 +981,56 @@ async def project_stats(db: AsyncSession, project_id: UUID) -> dict: for it in item_termsheets: its_by_line.setdefault(it.line_item_id, []).append(it) + def _items_of(quote: Quote) -> list[LineItem]: + return [li for li in line_items if li.quote_id == quote.id] + + def _accepted_for_project(quote: Quote) -> bool: + """≥1 non-rejected line item whose decision targets THIS project.""" + return any( + (decision := decisions.get(li.id)) is not None + and decision.status == "chosen" + and decision.project_id == project_id + for li in _items_of(quote) + ) + + def _has_any_target(quote: Quote) -> bool: + """Whether any decision of this quote carries a target project at all + (legacy quotes accepted before the decision-project era have none — + they keep the old global-status classification).""" + return any( + (decisions.get(li.id) is not None and decisions[li.id].project_id is not None) + for li in _items_of(quote) + ) + per_status = {status: {"count": 0, "total_value": 0.0} for status in QUOTE_STATUSES} for q in quotes: - per_status[q.status]["count"] += 1 + if q.status in ("accepted", "arrived") and _has_any_target(q) and not _accepted_for_project(q): + per_status["offered"]["count"] += 1 # linked, but never accepted for THIS project + else: + per_status[q.status]["count"] += 1 for li in line_items: + quote = next(q for q in quotes if q.id == li.quote_id) decision = decisions.get(li.id) + + # --- which status bucket does this line item's value belong to? --- + accepted_here = ( + decision is not None + and decision.status == "chosen" + and decision.project_id == project_id + ) + if quote.status in ("accepted", "arrived") and _has_any_target(quote): + if accepted_here: + bucket = quote.status + elif li.id in candidate_li_ids: + # the quote is still just a proposal for this project — the + # item's candidate value stays in the offered bucket + bucket = "offered" + else: + continue # this item targets another project entirely + else: + bucket = quote.status + if decision is not None and decision.status == "rejected": continue # rejected items excluded from value sums if decision is not None and decision.chosen_item_termsheet_id is not None: @@ -961,8 +1040,7 @@ async def project_stats(db: AsyncSession, project_id: UUID) -> dict: price_it = links[0] if links else None if price_it is None: continue - quote = next(q for q in quotes if q.id == li.quote_id) - per_status[quote.status]["total_value"] += float(price_it.unit_price * li.quantity) + per_status[bucket]["total_value"] += float(price_it.unit_price * li.quantity) result = {"project_id": str(project_id), "total_quotes": len(quotes)} for status, values in per_status.items(): diff --git a/frontend/src/pages/Projects/ProjectsPage.tsx b/frontend/src/pages/Projects/ProjectsPage.tsx index c75175f..ff0fed4 100644 --- a/frontend/src/pages/Projects/ProjectsPage.tsx +++ b/frontend/src/pages/Projects/ProjectsPage.tsx @@ -16,7 +16,6 @@ import { import { normalize } from "../../lib/normalize"; import { useAppStore } from "../../store"; import { - activeItems, lineItemTotal, materialById, materialLabel, @@ -29,25 +28,45 @@ import { EmptyState } from "../../components/EmptyState"; import { Button } from "../../components/ui/button"; function projectQuotes(quotes: Quote[], name: string): Quote[] { - return quotes.filter((q) => (q.projects || []).includes(name) || q.project === name); + return quotes.filter( + (q) => + (q.projects || []).includes(name) || + q.project === name || + (q.line_items || []).some( + (li) => + li.target_project?.name === name || + (li.projects || []).some((p) => (typeof p === "string" ? p : p.name) === name), + ), + ); } -// اقلام مربوط به این پروژه در یک quote +// آیا این پیشنهاد برای «این» پروژه پذیرفته شده است؟ +// ملاک، پروژه‌ی هدفِ تصمیم خریدِ اقلام است (item_decisions.project_id) — نه +// وضعیت سراسری پیشنهاد؛ پیشنهادی که فقط برای پروژه‌ی دیگری پذیرفته شده، +// برای این پروژه همچنان «پیشنهاد شده» است. +function acceptedForProject(q: Quote, name: string): boolean { + return (q.line_items || []).some((li) => !li.rejected && li.target_project?.name === name); +} + +// آیا اصلاً برای این پیشنهاد پروژه‌ی هدفی ثبت شده؟ (پیشنهادهای قدیمیِ +// پیش از era تصمیم‌پروژه، طبق وضعیت سراسری دسته‌بندی می‌شوند) +function hasTargets(q: Quote): boolean { + return (q.line_items || []).some((li) => !!li.target_project); +} + +// اقلام مربوط به این پروژه در یک quote: +// اولویت با اقلامِ پذیرفته‌شده برای همین پروژه (تصمیم خرید)؛ سپس کاندیداهای +// همین پروژه؛ و اگر هیچ اتصال قلم‌به‌قلمی نبود، همه‌ی اقلام فعال. function itemsForProject(q: Quote, name: string): Quote["line_items"] { const items = (q.line_items || []).filter((li) => !li.rejected); + const targeted = items.filter((li) => li.target_project?.name === name); + if (targeted.length) return targeted; if (items.some((li) => li.projects && li.projects.length)) { return items.filter((li) => (li.projects || []).some((p) => p.name === name)); } return items; } -function qTotal(q: Quote): number { - return activeItems(q).reduce((sum, li) => { - const t = lineItemTotal(li); - return sum + (t || 0); - }, 0); -} - function daysSince(iso: string | null): number | null { if (!iso) return null; const d = new Date(iso); @@ -147,10 +166,11 @@ export function ProjectsPage() { // ---------- پنل جزئیات یک پروژه ---------- function renderProjectDetail(store: Store, name: string): React.ReactElement { const quotes = projectQuotes(store.quotes, name); - const sumBy = (st: Quote["status"]) => - quotes - .filter((q) => q.status === st) - .reduce((s, q) => s + qTotal(q), 0); + + // جمع مبلغ هر ستون فقط روی اقلامِ همین پروژه + const itemsTotal = (q: Quote) => + itemsForProject(q, name).reduce((s, li) => s + (lineItemTotal(li) || 0), 0); + const sumBy = (list: Quote[]) => list.reduce((s, q) => s + itemsTotal(q), 0); const matName = (mid: string) => { const m = materialById(store.materials, mid); @@ -184,11 +204,19 @@ function renderProjectDetail(store: Store, name: string): React.ReactElement { ); }; + // دسته‌بندی ایزوله per-project: + // - پذیرفته/رسیده فقط وقتی حداقل یک قلمِ غیرردشده برای «این» پروژه پذیرفته شده باشد؛ + // - پیشنهادی که صرفاً کاندیداست (یا فقط برای پروژه‌ی دیگری پذیرفته شده) → پیشنهاد شده. + // - پیشنهادهای قدیمیِ بدون پروژه‌ی هدف، طبق وضعیت سراسری می‌مانند. const offered = quotes - .filter((q) => q.status === "offered") + .filter( + (q) => + q.status === "offered" || + ((q.status === "accepted" || q.status === "arrived") && hasTargets(q) && !acceptedForProject(q, name)), + ) .sort((a, b) => new Date(a.expiry_date || 0).getTime() - new Date(b.expiry_date || 0).getTime()); - const accepted = quotes.filter((q) => q.status === "accepted"); - const arrived = quotes.filter((q) => q.status === "arrived"); + const accepted = quotes.filter((q) => q.status === "accepted" && acceptedForProject(q, name)); + const arrived = quotes.filter((q) => q.status === "arrived" && acceptedForProject(q, name)); const offeredRow = (q: Quote) => { const days = q.expiry_date ? Math.ceil((new Date(q.expiry_date).getTime() - Date.now()) / 86400000) : null; @@ -199,7 +227,7 @@ function renderProjectDetail(store: Store, name: string): React.ReactElement {
{itemsForProject(q, name).map(itemLine)}
- جمع: {faNum(qTotal(q))} تومان + جمع: {faNum(itemsTotal(q))} تومان
اعتبار تا {fmtDate(q.expiry_date)}
@@ -240,7 +268,7 @@ function renderProjectDetail(store: Store, name: string): React.ReactElement {
{itemsForProject(q, name).map(itemLine)}
- هزینه: {faNum(qTotal(q))} تومان + هزینه: {faNum(itemsTotal(q))} تومان
رسید: {fmtDate(q.arrived_date)}
@@ -255,13 +283,13 @@ function renderProjectDetail(store: Store, name: string): React.ReactElement {
کل پیشنهادها: {faNum(quotes.length)} - ارزش باز: {faNum(sumBy("offered"))} تومان + ارزش باز: {faNum(sumBy(offered))} تومان - ارزش پذیرفته: {faNum(sumBy("accepted"))} تومان + ارزش پذیرفته: {faNum(sumBy(accepted))} تومان - ارزش رسیده: {faNum(sumBy("arrived"))} تومان + ارزش رسیده: {faNum(sumBy(arrived))} تومان