Files
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

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