feat(backend): add FastAPI backend — procurement, inventory domain, Casdoor auth, PM arrival endpoints
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
"""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.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"],
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
):
|
||||
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)
|
||||
):
|
||||
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)
|
||||
):
|
||||
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}}
|
||||
Reference in New Issue
Block a user