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:
@@ -77,13 +77,14 @@ def project_brief(project: Project | None) -> dict | None:
|
||||
return {"id": str(project.id), "name": project.name}
|
||||
|
||||
|
||||
def decision_brief(d: ItemDecision) -> dict:
|
||||
def decision_brief(d: ItemDecision, project: Project | None = None) -> dict:
|
||||
"""Shape used inside expanded line items (P0-14)."""
|
||||
return {
|
||||
"id": str(d.id),
|
||||
"status": d.status,
|
||||
"chosen_item_termsheet_id": str(d.chosen_item_termsheet_id) if d.chosen_item_termsheet_id else None,
|
||||
"project_id": str(d.project_id) if d.project_id else None, # v2.6: the single purchased project
|
||||
"project": project_brief(project) if project is not None else None,
|
||||
"adjusted_unit_price": float(d.adjusted_unit_price) if d.adjusted_unit_price is not None else None,
|
||||
"decided_at": to_iso(d.decided_at),
|
||||
}
|
||||
@@ -313,6 +314,7 @@ class _LineItemData:
|
||||
self.item_termsheets_by_line: dict[uuid.UUID, list[ItemTermsheet]] = {}
|
||||
self.item_termsheets_by_id: dict[uuid.UUID, ItemTermsheet] = {}
|
||||
self.projects_by_line: dict[uuid.UUID, list[Project]] = {}
|
||||
self.projects_by_id: dict[uuid.UUID, Project] = {}
|
||||
|
||||
|
||||
async def _load_line_item_data(db: AsyncSession, line_items: list[LineItem], expand: set[str]) -> _LineItemData:
|
||||
@@ -375,6 +377,13 @@ async def _load_line_item_data(db: AsyncSession, line_items: list[LineItem], exp
|
||||
if "decision" in expand or "receipt" in expand:
|
||||
decisions = (await db.execute(select(ItemDecision).where(ItemDecision.line_item_id.in_(li_ids)))).scalars().all()
|
||||
data.decisions = {d.line_item_id: d for d in decisions}
|
||||
dec_proj_ids = {d.project_id for d in decisions if d.project_id is not None}
|
||||
missing_pids = dec_proj_ids - set(proj_map.keys())
|
||||
if missing_pids:
|
||||
extra_projs = (await db.execute(select(Project).where(Project.id.in_(missing_pids)))).scalars().all()
|
||||
for p in extra_projs:
|
||||
proj_map[p.id] = p
|
||||
data.projects_by_id = proj_map
|
||||
dec_ids = [d.id for d in decisions]
|
||||
if dec_ids:
|
||||
receipts = (await db.execute(select(ItemReceipt).where(ItemReceipt.item_decision_id.in_(dec_ids)))).scalars().all()
|
||||
@@ -469,8 +478,10 @@ def _line_item_expanded(li: LineItem, expand: set[str], data: _LineItemData, inc
|
||||
data.specs_by_line.get(li.id, []), data.attr_defs, data.units, include_data_type=False
|
||||
)
|
||||
decision = data.decisions.get(li.id)
|
||||
dec_proj = data.projects_by_id.get(decision.project_id) if decision and decision.project_id else None
|
||||
d["target_project"] = project_brief(dec_proj) if dec_proj is not None else None
|
||||
if "decision" in expand:
|
||||
d["decision"] = decision_brief(decision) if decision is not None else None
|
||||
d["decision"] = decision_brief(decision, dec_proj) if decision is not None else None
|
||||
# chosen_item_termsheet_id یک id از جدول item_termsheets است؛ برای مقایسه با
|
||||
# termsheet_ids در جلوی برنامه، id ترمشیت واقعی هم ارسال میشود.
|
||||
if decision is not None and decision.chosen_item_termsheet_id is not None:
|
||||
|
||||
@@ -279,13 +279,23 @@ async def update_quote(db: AsyncSession, quote_id: UUID, payload: QuoteUpdate, a
|
||||
return quote
|
||||
|
||||
|
||||
async def quote_has_receipts(db: AsyncSession, quote_id: UUID) -> bool:
|
||||
"""Returns True if any warehouse receipt has been posted for the quote's items."""
|
||||
count = await db.scalar(
|
||||
select(func.count(ItemReceipt.id))
|
||||
.join(ItemDecision, ItemReceipt.item_decision_id == ItemDecision.id)
|
||||
.join(LineItem, ItemDecision.line_item_id == LineItem.id)
|
||||
.where(LineItem.quote_id == quote_id, ItemReceipt.received_qty > 0)
|
||||
)
|
||||
return (count or 0) > 0
|
||||
|
||||
|
||||
async def replace_quote_children(
|
||||
db: AsyncSession, quote_id: UUID, payload: QuoteChildrenReplaceIn, actor: str | None = None
|
||||
) -> Quote:
|
||||
"""Full replacement of a quote's line items + termsheets (edit modal).
|
||||
|
||||
Only allowed while the quote is still offered — acceptance data
|
||||
(decisions/receipts) would otherwise be destroyed.
|
||||
Only allowed while the quote is still offered and has no warehouse receipts.
|
||||
"""
|
||||
async with db.begin():
|
||||
quote = await get_quote_or_404(db, quote_id)
|
||||
@@ -294,6 +304,11 @@ async def replace_quote_children(
|
||||
"برای تغییر اقلام یا شرایط پرداخت، ابتدا وضعیت را به «پیشنهاد شده» برگردانید",
|
||||
field="status",
|
||||
)
|
||||
if await quote_has_receipts(db, quote.id):
|
||||
raise ConflictError(
|
||||
"این پیشنهاد بهدلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.",
|
||||
field="status",
|
||||
)
|
||||
# children cascade via DDL: item_termsheets, item_decisions, item_receipts, schedule_steps
|
||||
await db.execute(
|
||||
delete(LineItem).where(LineItem.quote_id == quote.id).execution_options(synchronize_session=False)
|
||||
@@ -312,6 +327,12 @@ async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: s
|
||||
if new_status not in BACKWARD_TRANSITIONS.get(current, set()):
|
||||
raise ConflictError("انتقال وضعیت مجاز نیست — فقط انتقالهای عقبگرد پذیرفته میشود", field="status")
|
||||
|
||||
if await quote_has_receipts(db, quote.id):
|
||||
raise ConflictError(
|
||||
"این پیشنهاد بهدلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.",
|
||||
field="status",
|
||||
)
|
||||
|
||||
if new_status == "offered":
|
||||
# Reopen: clear lifecycle dates, demote accepted termsheets, reset decisions.
|
||||
quote.accepted_date = None
|
||||
@@ -332,10 +353,11 @@ async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: s
|
||||
chosen_item_termsheet_id=None,
|
||||
adjusted_unit_price=None,
|
||||
adjusted_notes=None,
|
||||
project_id=None,
|
||||
)
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
# item_receipts are deliberately NOT deleted (contract P0-17).
|
||||
# LineItemProjects (candidate projects) are preserved untouched!
|
||||
elif new_status == "accepted": # arrived -> accepted
|
||||
quote.arrived_date = None
|
||||
|
||||
@@ -692,7 +714,8 @@ async def expire_quote(db: AsyncSession, quote_id: UUID, actor: str | None = Non
|
||||
|
||||
|
||||
async def delete_quote(db: AsyncSession, quote_id: UUID) -> dict:
|
||||
async with db.begin():
|
||||
tx = db.begin_nested() if db.in_transaction() else db.begin()
|
||||
async with tx:
|
||||
quote = await get_quote_or_404(db, quote_id)
|
||||
if quote.status in ("accepted", "arrived"):
|
||||
raise ConflictError("پیشنهادهای پذیرفتهشده یا رسیده قابل حذف نیستند")
|
||||
@@ -712,6 +735,17 @@ async def delete_quote(db: AsyncSession, quote_id: UUID) -> dict:
|
||||
termsheets = (await db.execute(select(Termsheet).where(Termsheet.quote_id == quote.id))).scalars().all()
|
||||
events = (await db.execute(select(QuoteEvent).where(QuoteEvent.quote_id == quote.id))).scalars().all()
|
||||
|
||||
# clean up attachments in MinIO before cascade
|
||||
from app.models.files import QuoteAttachment
|
||||
from app.services import storage
|
||||
|
||||
attachments = (await db.execute(select(QuoteAttachment).where(QuoteAttachment.quote_id == quote.id))).scalars().all()
|
||||
for att in attachments:
|
||||
try:
|
||||
await storage.remove_object(att.object_key)
|
||||
except Exception:
|
||||
logger.warning("Failed to remove MinIO object %s for attachment %s", att.object_key, att.id)
|
||||
|
||||
await db.delete(quote)
|
||||
return {
|
||||
"id": str(quote.id),
|
||||
@@ -722,6 +756,7 @@ async def delete_quote(db: AsyncSession, quote_id: UUID) -> dict:
|
||||
"events": len(events),
|
||||
"decisions": len(decisions),
|
||||
"receipts": len(receipts),
|
||||
"attachments": len(attachments),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user