Files
ERP/backend/app/routers/line_items.py
T

168 lines
6.9 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.auth import require_role
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"])
_procurement_writer = require_role("admin", "procurement_manager")
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),
_auth=Depends(_procurement_writer),
):
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),
_auth=Depends(_procurement_writer),
):
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),
_auth=Depends(_procurement_writer),
):
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}}