Files
ERP/backend/app/routers/termsheets.py
T
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

161 lines
6.3 KiB
Python

"""Termsheets nested under quotes. Unlimited per quote (constraint is per line item)."""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db
from app.models.procurement import (
ItemDecision,
ItemTermsheet,
Quote,
Termsheet,
TermsheetScheduleStep,
)
from app.schemas.procurement import (
TermsheetCreate,
TermsheetStatusUpdate,
TermsheetUpdate,
)
from app.services.expand_service import (
parse_expand,
serialize_termsheet,
serialize_termsheets,
)
from app.services.quote_service import _add_event, next_step_no
from app.utils.errors import ConflictError, NotFoundError, ValidationError
from app.utils.pagination import paginate, sort_query
router = APIRouter(prefix="/quotes/{quote_id}/termsheets", tags=["termsheets"])
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 _termsheet_or_404(db: AsyncSession, quote_id, termsheet_id) -> Termsheet:
ts = await db.scalar(
select(Termsheet).where(Termsheet.id == termsheet_id, Termsheet.quote_id == quote_id)
)
if ts is None:
raise NotFoundError("ترم‌شیت یافت نشد")
return ts
async def _insert_steps(db: AsyncSession, termsheet_id, steps) -> None:
"""Insert steps with auto-numbered step_no (MAX+1) when omitted."""
next_no = await next_step_no(db, termsheet_id)
for index, step in enumerate(steps):
step_no = step.step_no if step.step_no is not None else next_no + index
db.add(
TermsheetScheduleStep(
termsheet_id=termsheet_id,
step_no=step_no,
due_date=step.due_date,
amount=step.amount,
note=step.note,
)
)
@router.get("")
async def list_termsheets(
quote_id,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
status: str | None = None,
sort: str | None = None,
expand: str | None = None,
db: AsyncSession = Depends(get_db),
):
await _quote_or_404(db, quote_id)
stmt = select(Termsheet).where(Termsheet.quote_id == quote_id)
if status:
stmt = stmt.where(Termsheet.status == status)
stmt = sort_query(stmt, Termsheet, sort)
items, meta = await paginate(stmt, page, page_size, db)
return {"data": await serialize_termsheets(db, items, parse_expand(expand)), "meta": meta}
@router.post("", status_code=201)
async def create_termsheet(quote_id, payload: TermsheetCreate, 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 not payload.schedule_steps:
raise ValidationError("ترم‌شیت باید حداقل یک مرحله پرداخت داشته باشد", field="schedule_steps")
ts = Termsheet(
quote_id=quote.id,
label=payload.label,
status="offered",
discount_type=payload.discount_type,
discount_value=payload.discount_value,
)
db.add(ts)
await db.flush()
await _insert_steps(db, ts.id, payload.schedule_steps)
return {"data": await serialize_termsheet(db, ts, set())}
@router.get("/{termsheet_id}")
async def get_termsheet(
quote_id, termsheet_id, expand: str | None = None, db: AsyncSession = Depends(get_db)
):
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
return {"data": await serialize_termsheet(db, ts, parse_expand(expand))}
@router.patch("/{termsheet_id}")
async def update_termsheet(
quote_id, termsheet_id, payload: TermsheetUpdate, db: AsyncSession = Depends(get_db)
):
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
if ts.status != "offered":
raise ConflictError("ترم‌شیت‌های پذیرفته‌شده یا ردشده قابل ویرایش نیستند")
if payload.label is not None:
ts.label = payload.label
if payload.discount_type is not None:
ts.discount_type = payload.discount_type
if payload.discount_value is not None:
ts.discount_value = payload.discount_value
await db.flush()
return {"data": await serialize_termsheet(db, ts, set())}
@router.patch("/{termsheet_id}/status")
async def update_termsheet_status(
quote_id, termsheet_id, payload: TermsheetStatusUpdate, db: AsyncSession = Depends(get_db)
):
async with db.begin():
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
if ts.status != "offered":
raise ConflictError("فقط ترم‌شیت‌های offered تغییر وضعیت می‌دهند")
ts.status = payload.status # offered -> accepted | offered -> rejected (multiple OK)
_add_event(db, quote_id, "edited", {"action": "termsheet_status", "termsheet_id": str(ts.id), "status": ts.status})
return {"data": await serialize_termsheet(db, ts, set())}
@router.delete("/{termsheet_id}")
async def delete_termsheet(quote_id, termsheet_id, db: AsyncSession = Depends(get_db)):
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
quote = await _quote_or_404(db, quote_id)
if quote.status != "offered":
raise ConflictError("ترم‌شیت فقط در پیشنهادهای offered قابل حذف است")
count = await db.scalar(select(func.count()).select_from(Termsheet).where(Termsheet.quote_id == quote_id))
if count <= 1:
raise ConflictError("آخرین ترم‌شیت پیشنهاد قابل حذف نیست")
chosen = await db.scalar(
select(ItemDecision.id)
.join(ItemTermsheet, ItemDecision.chosen_item_termsheet_id == ItemTermsheet.id)
.where(ItemTermsheet.termsheet_id == termsheet_id, ItemDecision.status == "chosen")
.limit(1)
)
if chosen:
raise ConflictError("ترم‌شیتی که قلم انتخاب‌شده دارد قابل حذف نیست")
await db.delete(ts) # steps + item_termsheets cascade via DDL
await db.flush()
return {"data": {"deleted": True}}