feat(backend): add FastAPI backend — procurement, inventory domain, Casdoor auth, PM arrival endpoints

This commit is contained in:
ali_mohaghegh
2026-08-25 19:08:14 +03:30
parent c8a9b66395
commit 170aed66ea
67 changed files with 9774 additions and 0 deletions
+136
View File
@@ -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}}