Files
ERP/backend/app/routers/item_receipts.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

100 lines
4.0 KiB
Python

"""Item receipts — 1:1 with item_decisions. Quote-level arrival uses separate endpoints."""
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, ItemReceipt, LineItem
from app.schemas.procurement import ReceiptCreate, ReceiptUpdate
from app.services.expand_service import receipt_full
from app.services.quote_service import _fallback_confirmer_id
from app.utils.errors import ConflictError, NotFoundError
router = APIRouter(
prefix="/quotes/{quote_id}/line-items/{line_item_id}/receipt",
tags=["item-receipts"],
)
_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
async def _decision_or_404(db: AsyncSession, line_item_id) -> ItemDecision:
decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == line_item_id))
if decision is None:
raise NotFoundError("تصمیمی برای این خط آیتم ثبت نشده است")
return decision
@router.get("")
async def get_receipt(quote_id, line_item_id, db: AsyncSession = Depends(get_db)):
await _line_item_or_404(db, quote_id, line_item_id)
decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == line_item_id))
if decision is None:
raise NotFoundError("رسیدی برای این خط آیتم ثبت نشده است")
receipt = await db.scalar(select(ItemReceipt).where(ItemReceipt.item_decision_id == decision.id))
if receipt is None:
raise NotFoundError("رسیدی برای این خط آیتم ثبت نشده است")
return {"data": receipt_full(receipt)}
@router.post("", status_code=201)
async def create_receipt(
quote_id,
line_item_id,
payload: ReceiptCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
await _line_item_or_404(db, quote_id, line_item_id)
decision = await _decision_or_404(db, line_item_id)
existing = await db.scalar(select(ItemReceipt).where(ItemReceipt.item_decision_id == decision.id))
if existing is not None:
raise ConflictError("رسید این خط آیتم قبلاً ثبت شده است")
receipt = ItemReceipt(
item_decision_id=decision.id,
confirmed_by=await _fallback_confirmer_id(db),
received_at=payload.received_at,
received_qty=payload.received_qty,
on_time=payload.on_time,
quality_note=payload.quality_note,
)
db.add(receipt)
await db.flush()
return {"data": receipt_full(receipt)}
@router.patch("")
async def update_receipt(
quote_id,
line_item_id,
payload: ReceiptUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
await _line_item_or_404(db, quote_id, line_item_id)
decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == line_item_id))
if decision is None:
raise NotFoundError("تصمیمی برای این خط آیتم ثبت نشده است")
receipt = await db.scalar(select(ItemReceipt).where(ItemReceipt.item_decision_id == decision.id))
if receipt is None:
raise NotFoundError("رسیدی برای این خط آیتم ثبت نشده است")
if payload.received_at is not None:
receipt.received_at = payload.received_at
if payload.received_qty is not None:
receipt.received_qty = payload.received_qty
if payload.on_time is not None:
receipt.on_time = payload.on_time
if payload.quality_note is not None:
receipt.quality_note = payload.quality_note
await db.flush()
return {"data": receipt_full(receipt)}