152 lines
5.6 KiB
Python
152 lines
5.6 KiB
Python
"""Item termsheets — pricing links between line items and termsheets.
|
||
|
||
total_price is COMPUTED (unit_price × quantity via the VIEW
|
||
item_termsheets_with_total) and never accepted from the client.
|
||
"""
|
||
from fastapi import APIRouter, Depends
|
||
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.procurement import (
|
||
ItemDecision,
|
||
ItemTermsheet,
|
||
ItemTermsheetsWithTotal,
|
||
LineItem,
|
||
Quote,
|
||
Termsheet,
|
||
)
|
||
from app.schemas.procurement import ItemTermsheetCreate, ItemTermsheetUpdate
|
||
from app.utils.errors import ConflictError, NotFoundError
|
||
|
||
router = APIRouter(
|
||
prefix="/quotes/{quote_id}/line-items/{line_item_id}/termsheets",
|
||
tags=["item-termsheets"],
|
||
)
|
||
_procurement_writer = require_role("admin", "procurement_manager")
|
||
|
||
|
||
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
|
||
|
||
|
||
def it_to_dict(row: ItemTermsheetsWithTotal | ItemTermsheet, total_price=None) -> dict:
|
||
return {
|
||
"id": str(row.id),
|
||
"line_item_id": str(row.line_item_id),
|
||
"termsheet_id": str(row.termsheet_id),
|
||
"unit_price": float(row.unit_price),
|
||
"min_order_qty": float(row.min_order_qty) if row.min_order_qty is not None else None,
|
||
"total_price": float(total_price) if total_price is not None else None,
|
||
}
|
||
|
||
|
||
@router.get("")
|
||
async def list_item_termsheets(quote_id, line_item_id, db: AsyncSession = Depends(get_db)):
|
||
await _line_item_or_404(db, quote_id, line_item_id)
|
||
rows = (
|
||
await db.execute(
|
||
select(ItemTermsheetsWithTotal).where(ItemTermsheetsWithTotal.line_item_id == line_item_id)
|
||
)
|
||
).scalars().all()
|
||
return {"data": [it_to_dict(r, r.total_price) for r in rows]}
|
||
|
||
|
||
@router.post("", status_code=201)
|
||
async def create_item_termsheet(
|
||
quote_id,
|
||
line_item_id,
|
||
payload: ItemTermsheetCreate,
|
||
db: AsyncSession = Depends(get_db),
|
||
_auth=Depends(_procurement_writer),
|
||
):
|
||
async with db.begin():
|
||
li = await _line_item_or_404(db, quote_id, line_item_id)
|
||
quote = await db.get(Quote, quote_id)
|
||
if quote is None or quote.status != "offered":
|
||
raise ConflictError("اتصال قیمت فقط در پیشنهادهای offered قابل افزودن است")
|
||
|
||
# termsheet must belong to the SAME quote (contract P0-35)
|
||
ts = await db.scalar(select(Termsheet).where(Termsheet.id == payload.termsheet_id, Termsheet.quote_id == quote_id))
|
||
if ts is None:
|
||
raise NotFoundError("ترمشیت یافت نشد یا متعلق به این پیشنهاد نیست", field="termsheet_id")
|
||
|
||
duplicate = await db.scalar(
|
||
select(ItemTermsheet.id).where(
|
||
ItemTermsheet.line_item_id == line_item_id, ItemTermsheet.termsheet_id == payload.termsheet_id
|
||
)
|
||
)
|
||
if duplicate is not None:
|
||
raise ConflictError("این خط آیتم قبلاً به این ترمشیت متصل شده است")
|
||
|
||
it = ItemTermsheet(
|
||
line_item_id=line_item_id,
|
||
termsheet_id=payload.termsheet_id,
|
||
unit_price=payload.unit_price,
|
||
min_order_qty=payload.min_order_qty,
|
||
)
|
||
db.add(it)
|
||
await db.flush()
|
||
total = it.unit_price * li.quantity
|
||
return {"data": it_to_dict(it, total)}
|
||
|
||
|
||
@router.patch("/{item_termsheet_id}")
|
||
async def update_item_termsheet(
|
||
quote_id,
|
||
line_item_id,
|
||
item_termsheet_id,
|
||
payload: ItemTermsheetUpdate,
|
||
db: AsyncSession = Depends(get_db),
|
||
_auth=Depends(_procurement_writer),
|
||
):
|
||
li = await _line_item_or_404(db, quote_id, line_item_id)
|
||
quote = await db.get(Quote, quote_id)
|
||
if quote is None or quote.status != "offered":
|
||
raise ConflictError("اتصال قیمت فقط در پیشنهادهای offered قابل ویرایش است")
|
||
it = await db.scalar(
|
||
select(ItemTermsheet).where(ItemTermsheet.id == item_termsheet_id, ItemTermsheet.line_item_id == line_item_id)
|
||
)
|
||
if it is None:
|
||
raise NotFoundError("اتصال قیمت یافت نشد")
|
||
if payload.unit_price is not None:
|
||
it.unit_price = payload.unit_price
|
||
if payload.min_order_qty is not None:
|
||
it.min_order_qty = payload.min_order_qty
|
||
await db.flush()
|
||
return {"data": it_to_dict(it, it.unit_price * li.quantity)}
|
||
|
||
|
||
@router.delete("/{item_termsheet_id}")
|
||
async def delete_item_termsheet(
|
||
quote_id,
|
||
line_item_id,
|
||
item_termsheet_id,
|
||
db: AsyncSession = Depends(get_db),
|
||
_auth=Depends(_procurement_writer),
|
||
):
|
||
it = await db.scalar(
|
||
select(ItemTermsheet).where(ItemTermsheet.id == item_termsheet_id, ItemTermsheet.line_item_id == line_item_id)
|
||
)
|
||
if it is None:
|
||
raise NotFoundError("اتصال قیمت یافت نشد")
|
||
quote = await db.get(Quote, quote_id)
|
||
if quote is None or quote.status != "offered":
|
||
raise ConflictError("اتصال قیمت فقط در پیشنهادهای offered قابل حذف است")
|
||
chosen = await db.scalar(
|
||
select(ItemDecision.id).where(
|
||
ItemDecision.chosen_item_termsheet_id == item_termsheet_id, ItemDecision.status == "chosen"
|
||
).limit(1)
|
||
)
|
||
if chosen:
|
||
raise ConflictError("اتصال قیمت انتخابشده قابل حذف نیست")
|
||
await db.delete(it)
|
||
await db.flush()
|
||
return {"data": {"deleted": True}}
|