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:
2026-09-16 19:14:06 +03:30
parent 7ead60cd5c
commit 5084623be5
14 changed files with 444 additions and 158 deletions
+39 -4
View File
@@ -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),
},
}