Files
whqreza 5a9ef408ff feat(catalog): auto-purge orphan materials when quotes/items release them
A material that ends up referenced by nothing (no line_items anywhere, no warehouse stock) was previously left behind forever, accumulating dead catalog rows as offers were edited or deleted.

- new quote_service.cleanup_orphan_materials(db, material_ids): for each candidate it checks usage across line_items (all quotes) and warehouse inventory; only when both are zero does it remove the auxiliary rows (supplier_materials, brand_materials, material_units, material_attribute_values) and finally the materials row; returns the purged ids
- replace_quote_children (edit modal): snapshots the quote's material ids before the children are replaced, diffs against the incoming items, and purges whatever was detached
- delete_quote: collects the quote's material ids + attachment keys first, deletes the quote, purges orphans inside the transaction, and only then (post-commit) removes the MinIO objects — now both the recorded attachment keys and a recursive remove_prefix(quote_id/) sweep, so stray objects under the quote folder cannot survive; the response's cascaded block reports purged_orphan_materials
- line_items: PATCH that switches a line item's material, and DELETE of a line item, both clean up the previously referenced material
- storage.remove_prefix(prefix): best-effort recursive object deletion under a prefix (sync MinIO client wrapped in asyncio.to_thread, same pattern as remove_object)

Safety: usage checks run against the whole system before any delete, so shared materials — including those held only by warehouse stock — are never touched. Verified: python compile + api image build clean.
2026-09-17 18:04:57 +03:30

152 lines
6.6 KiB
Python

"""Line items nested under quotes. Editable only while the quote is offered."""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db
from app.models.catalog import Material, Unit
from app.models.procurement import ItemDecision, LineItem, Quote
from app.schemas.procurement import LineItemCreate, LineItemUpdate
from app.services.expand_service import parse_expand, serialize_line_items
from app.services.material_service import ensure_brand_material_belong
from app.services.supplier_material_service import link_supplier_material
from app.utils.errors import ConflictError, NotFoundError
from app.utils.pagination import paginate, sort_query
router = APIRouter(prefix="/quotes/{quote_id}/line-items", tags=["line-items"])
async def _quote_or_404(db: AsyncSession, quote_id) -> Quote:
quote = await db.get(Quote, quote_id)
if quote is None:
raise NotFoundError("پیشنهاد (استعلام) یافت نشد")
return quote
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
li = await db.scalar(
select(LineItem).where(LineItem.id == line_item_id, LineItem.quote_id == quote_id)
)
if li is None:
raise NotFoundError("خط آیتم یافت نشد")
return li
@router.get("")
async def list_line_items(
quote_id,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
sort: str | None = None,
expand: str | None = None,
db: AsyncSession = Depends(get_db),
):
await _quote_or_404(db, quote_id)
stmt = select(LineItem).where(LineItem.quote_id == quote_id)
stmt = sort_query(stmt, LineItem, sort, default="created_at")
items, meta = await paginate(stmt, page, page_size, db)
data = await serialize_line_items(db, items, parse_expand(expand))
return {"data": data, "meta": meta}
@router.post("", status_code=201)
async def create_line_item(quote_id, payload: LineItemCreate, db: AsyncSession = Depends(get_db)):
async with db.begin():
quote = await _quote_or_404(db, quote_id)
if quote.status != "offered":
raise ConflictError("خط آیتم فقط در پیشنهادهای offered قابل افزودن است")
if await db.get(Material, payload.material_id) is None:
raise NotFoundError("متریال یافت نشد", field="material_id")
if await db.get(Unit, payload.unit_id) is None:
raise NotFoundError("واحد یافت نشد", field="unit_id")
await ensure_brand_material_belong(db, payload.brand_material_id, payload.material_id)
li = LineItem(
quote_id=quote.id,
material_id=payload.material_id,
brand_material_id=payload.brand_material_id,
unit_id=payload.unit_id,
quantity=payload.quantity,
notes=payload.notes,
)
db.add(li)
await db.flush()
# v2.5: auto-link the material to the quote's supplier (catalog edge).
await link_supplier_material(db, quote.supplier_id, payload.material_id)
# auto-create the 1:1 pending decision (contract P0-22)
db.add(ItemDecision(line_item_id=li.id, status="pending"))
return {"data": (await serialize_line_items(db, [li], set()))[0]}
@router.get("/{line_item_id}")
async def get_line_item(
quote_id, line_item_id, expand: str | None = None, db: AsyncSession = Depends(get_db)
):
li = await _line_item_or_404(db, quote_id, line_item_id)
return {"data": (await serialize_line_items(db, [li], parse_expand(expand)))[0]}
@router.patch("/{line_item_id}")
async def update_line_item(
quote_id, line_item_id, payload: LineItemUpdate, db: AsyncSession = Depends(get_db)
):
li = await _line_item_or_404(db, quote_id, line_item_id)
quote = await _quote_or_404(db, quote_id)
if quote.status != "offered":
raise ConflictError("خط آیتم فقط در پیشنهادهای offered قابل ویرایش است")
new_material_id = payload.material_id if payload.material_id is not None else li.material_id
if payload.material_id is not None:
if await db.get(Material, payload.material_id) is None:
raise NotFoundError("متریال یافت نشد", field="material_id")
if payload.unit_id is not None:
if await db.get(Unit, payload.unit_id) is None:
raise NotFoundError("واحد یافت نشد", field="unit_id")
if payload.brand_material_id is not None or payload.material_id is not None:
# If only the material changed, re-validate the EXISTING brand link against it.
effective_bm = payload.brand_material_id if payload.brand_material_id is not None else li.brand_material_id
await ensure_brand_material_belong(db, effective_bm, new_material_id)
old_material_id = li.material_id
if payload.material_id is not None:
li.material_id = payload.material_id
# v2.5: keep the supplier<->material catalog edge in sync.
await link_supplier_material(db, quote.supplier_id, payload.material_id)
if payload.brand_material_id is not None:
li.brand_material_id = payload.brand_material_id
if payload.unit_id is not None:
li.unit_id = payload.unit_id
if payload.quantity is not None:
li.quantity = payload.quantity
if payload.notes is not None:
li.notes = payload.notes
await db.flush()
if payload.material_id is not None and payload.material_id != old_material_id:
from app.services.quote_service import cleanup_orphan_materials
await cleanup_orphan_materials(db, [old_material_id])
return {"data": (await serialize_line_items(db, [li], set()))[0]}
@router.delete("/{line_item_id}")
async def delete_line_item(quote_id, line_item_id, db: AsyncSession = Depends(get_db)):
li = await _line_item_or_404(db, quote_id, line_item_id)
quote = await _quote_or_404(db, quote_id)
if quote.status != "offered":
raise ConflictError("خط آیتم فقط در پیشنهادهای offered قابل حذف است")
decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == li.id))
if decision is not None and decision.status == "chosen":
raise ConflictError("خط آیتم انتخاب‌شده قابل حذف نیست")
old_material_id = li.material_id
await db.delete(li) # specs / links / decision / receipt cascade via DDL
await db.flush()
if old_material_id:
from app.services.quote_service import cleanup_orphan_materials
await cleanup_orphan_materials(db, [old_material_id])
return {"data": {"id": str(line_item_id), "deleted": True}}