Their three commits (730d0bd,5a9b543,387edfb) reached production by push and auto-deployed.387edfbexpects a users.username column that the live database does not have, so POST /api/v1/auth/login was returning HTTP 500. Their schema changes never applied either (projects still has only id, name, description, start_date, pm_user_id, created_at), so nothing depends on the reverted code. This commit sets the tree back to exactlya02b3ea— verified with git diff against it — while keeping history linear: the work itself is preserved on 'pm' at387edfb.
152 lines
6.6 KiB
Python
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}}
|
|
|