Files
ERP/backend/app/routers/termsheets.py
T
Milad 4a3c226518 feat(org): panel-based access system replacing roles (procurement/inventory)
- new tables user_panels (panel: procurement/inventory/pm + level full/limited)
  and user_project_members (M:N membership, separate from pm responsibility)
- UserContext carries panels + member_project_ids; /users/me and the users
  list include them
- admin endpoints: PUT /users/{id}/panels, PUT /users/{id}/panel-projects
- inventory scope: panel inventory 'full' -> manager scope (all projects, NO
  stock change); 'limited' -> supervisor scope (own memberships, stock change
  allowed); legacy roles kept as migration fallback; admin untouched
- procurement writes: _procurement_writer is now panel-aware (admin, legacy
  procurement_manager, or procurement 'full') across all 18 write routers;
  quote CREATE additionally allows 'limited' users restricted to their member
  projects (validated server-side)
- frontend: panel-aware HomeRoute (inventory -> warehouse panel, procurement
  limited -> minimal RequesterHome), wizard project options restricted for
  limited users, change-inventory button/route gated to inventory 'limited',
  organization panel gains the panels & access-levels management section
2026-09-29 13:37:59 +03:30

181 lines
6.6 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.auth import require_procurement_writer, require_role
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"])
_procurement_writer = require_procurement_writer()
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),
_auth=Depends(_procurement_writer),
):
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),
_auth=Depends(_procurement_writer),
):
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),
_auth=Depends(_procurement_writer),
):
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),
_auth=Depends(_procurement_writer),
):
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}}