- 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
181 lines
6.6 KiB
Python
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}}
|