Files
ERP/backend/app/routers/item_termsheets.py
ZCode 253c922280 revert: restore production to the state before 730d0bd (PM/RBAC work moved to 'pm')
Their three commits (730d0bd, 5a9b543, 387edfb) reached production by push and
auto-deployed. 387edfb expects 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 exactly a02b3ea — verified with git diff against
it — while keeping history linear: the work itself is preserved on 'pm' at 387edfb.
2026-09-26 17:41:07 +00:00

137 lines
5.3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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}}