From e8154ae6a6e5bfc555cd940747fac26d1c6eb544 Mon Sep 17 00:00:00 2001 From: "reza.g" Date: Tue, 8 Sep 2026 14:41:57 +0330 Subject: [PATCH] fix(quotes): derive decision.project_id from accept item_projects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-project quotes: the manager picks one project per line item in the accept modal (item_projects), but accept_quote only auto-filled decision.project_id from existing line_item_projects (≤1). For items without a single existing line-project the decision stayed NULL, so / pm/on-the-way (filtered by ItemDecision.project_id) never showed those items to any project manager — they accumulated only in the global admin list. Now derivation order is: explicit decision.project_id → payload.item_projects (single) → existing line_item_projects (single) → NULL (office-only, never misattributed). Verified E2E: two items of one quote accepted to two projects appear in exactly their own PM's on-the-way. --- backend/app/services/quote_service.py | 38 ++++++++++++++++++--------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/backend/app/services/quote_service.py b/backend/app/services/quote_service.py index f428df3..e7fa272 100644 --- a/backend/app/services/quote_service.py +++ b/backend/app/services/quote_service.py @@ -416,6 +416,9 @@ async def accept_quote(db: AsyncSession, quote_id: UUID, payload: AcceptQuoteIn, # --- process item decisions (UPSERT) --- chosen_ts_ids: set[UUID] = set() + # پروژه‌های ارسالی در payload.item_projects — منبع اصلی برای + # decision.project_id (مدیر تدارکات برای هر قلم یک پروژه انتخاب می‌کند) + proj_by_line: dict[UUID, list[UUID]] = {e.line_item_id: list(e.project_ids) for e in payload.item_projects} for decision_in in payload.item_decisions: decision = await db.scalar( select(ItemDecision).where(ItemDecision.line_item_id == decision_in.line_item_id).with_for_update() @@ -460,22 +463,31 @@ async def accept_quote(db: AsyncSession, quote_id: UUID, payload: AcceptQuoteIn, decision.adjusted_unit_price = decision_in.adjusted_unit_price decision.adjusted_notes = decision_in.adjusted_notes decision.decided_at = utcnow() - # v2.6: the SINGLE purchased project (proc manager picks it; - # auto-filled when the offer had exactly one project). + # v2.6: the SINGLE purchased project (proc manager picks it). + # Derivation order: + # 1) explicit decision_in.project_id + # 2) payload.item_projects for this line item (exactly one) + # 3) existing line_item_projects (exactly one) + # otherwise NULL — the item stays visible only to the office + # until a project is assigned (never wrongly visible to a PM). if decision_in.project_id is not None: decision.project_id = decision_in.project_id else: - line_projs = ( - (await db.execute( - select(LineItemProjects.project_id).where( - LineItemProjects.line_item_id == decision_in.line_item_id - ) - )) - .scalars() - .all() - ) - if len(line_projs) == 1: - decision.project_id = line_projs[0] + accept_projs = proj_by_line.get(decision_in.line_item_id) or [] + if len(accept_projs) == 1: + decision.project_id = accept_projs[0] + else: + line_projs = ( + (await db.execute( + select(LineItemProjects.project_id).where( + LineItemProjects.line_item_id == decision_in.line_item_id + ) + )) + .scalars() + .all() + ) + if len(line_projs) == 1: + decision.project_id = line_projs[0] if decision.project_id is not None: if await db.get(Project, decision.project_id) is None: raise NotFoundError("پروژه یافت نشد", field="item_decisions.project_id")