Files
ERP/backend/app/routers/item_decisions.py
T
MMD f7b1f7f751 feat(security): JWT required on every API route, gated dev fallback, role checks
- main.py: global get_current_user dependency on all routers except
  /auth/login and /health; prod fail-fast startup guard rejects wildcard
  CORS, default credentials, DEV_LOGIN_ENABLED
- inventory/pm: X-User-Role header identity fallback now only active when
  PROCUREMENT_DEV_LOGIN_ENABLED=true (was unconditional in production)
- item_decisions writes + attachment deletes: require_role(
  procurement_manager, admin) — require_role was defined but never used
- config.py: ENVIRONMENT flag, comma-separated CORS_ORIGINS, no default
  Casdoor client secret/admin password, DEV_LOGIN_ENABLED=false
- frontend: authenticated file downloads (Bearer + blob) — <a href> links
  cannot send the token; FileList/QuoteDetail/AcceptModal/EditModal/
  PurchaseHistory converted; DEV_LOGIN_ENABLED build arg defaults false
- smoke tests authenticate via POST /auth/login (SMOKE_USER/SMOKE_PASSWORD)
- traefik: security headers, API rate limit, 12MB body cap (prod overlay)
- nginx client_max_body_size aligned to 12MB
2026-09-18 22:02:09 +03:30

147 lines
6.6 KiB
Python

"""Item decisions — 1:1 with line items.
State machine (P1 cross-endpoint rules):
pending -> chosen (requires chosen_item_termsheet_id of the SAME line item)
pending -> rejected
chosen/rejected -> pending/anything-else = 409
Auth: JWT required globally (main.py). The state-changing endpoints are a
purchase decision — restricted to procurement_manager / admin via
require_role; reads stay open to every authenticated role.
"""
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role
from app.db.engine import get_db
from app.models.procurement import ItemDecision, ItemTermsheet, LineItem
from app.schemas.procurement import DecisionPatch, DecisionPut, DecisionStatusUpdate
from app.services.expand_service import decision_full
from app.services.quote_service import utcnow
from app.utils.errors import ConflictError, NotFoundError, ValidationError
router = APIRouter(
prefix="/quotes/{quote_id}/line-items/{line_item_id}/decision",
tags=["item-decisions"],
)
# خرید/رد اقلام = تصمیم تدارکات؛ ادمین و مدیر تدارکات
_decision_writer = require_role("procurement_manager", "admin")
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 _get_or_create_pending(db: AsyncSession, line_item_id) -> ItemDecision:
decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == line_item_id))
if decision is None:
decision = ItemDecision(line_item_id=line_item_id, status="pending")
db.add(decision)
await db.flush()
return decision
async def _validate_chosen_link(db: AsyncSession, line_item_id, item_termsheet_id) -> None:
"""chosen_item_termsheet_id must reference an item_termsheets row of the SAME line item."""
if item_termsheet_id is None:
raise ValidationError("برای وضعیت chosen، chosen_item_termsheet_id الزامی است", field="chosen_item_termsheet_id")
it = await db.scalar(
select(ItemTermsheet).where(ItemTermsheet.id == item_termsheet_id, ItemTermsheet.line_item_id == line_item_id)
)
if it is None:
raise ValidationError("اتصال قیمت باید متعلق به همین خط آیتم باشد", field="chosen_item_termsheet_id")
@router.get("")
async def get_decision(quote_id, line_item_id, db: AsyncSession = Depends(get_db)):
"""Always returns a record — pending is auto-created (P0 contract)."""
await _line_item_or_404(db, quote_id, line_item_id)
decision = await _get_or_create_pending(db, line_item_id)
return {"data": decision_full(decision)}
@router.put("", dependencies=[Depends(_decision_writer)])
async def put_decision(
quote_id, line_item_id, payload: DecisionPut, db: AsyncSession = Depends(get_db)
):
"""UPSERT — pending -> chosen | pending -> rejected."""
created = False
async with db.begin():
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).with_for_update()
)
if decision is None:
decision = ItemDecision(line_item_id=line_item_id, status="pending")
db.add(decision)
await db.flush()
created = True
if decision.status != "pending":
raise ConflictError(
f"بازگشت از وضعیت {decision.status} به {payload.status} مجاز نیست"
)
if payload.status == "chosen":
await _validate_chosen_link(db, line_item_id, payload.chosen_item_termsheet_id)
decision.chosen_item_termsheet_id = payload.chosen_item_termsheet_id
decision.adjusted_unit_price = payload.adjusted_unit_price
decision.adjusted_notes = payload.adjusted_notes
else: # rejected
decision.chosen_item_termsheet_id = None
decision.adjusted_unit_price = None
decision.adjusted_notes = payload.adjusted_notes
decision.status = payload.status
decision.decided_at = utcnow()
return {"data": decision_full(decision), "status_code_hint": 201 if created else 200}
@router.patch("/status", dependencies=[Depends(_decision_writer)])
async def patch_decision_status(
quote_id, line_item_id, payload: DecisionStatusUpdate, db: AsyncSession = Depends(get_db)
):
"""Status-only transition; chosen requires chosen_item_termsheet_id already set."""
async with db.begin():
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).with_for_update()
)
if decision is None:
raise NotFoundError("تصمیمی برای این خط آیتم ثبت نشده است")
if decision.status != "pending":
raise ConflictError(
f"بازگشت از وضعیت {decision.status} به {payload.status} مجاز نیست"
)
if payload.status == "chosen" and decision.chosen_item_termsheet_id is None:
raise ValidationError(
"برای انتخاب، ابتدا باید chosen_item_termsheet_id تنظیم شود", field="chosen_item_termsheet_id"
)
decision.status = payload.status
decision.decided_at = utcnow()
return {"data": decision_full(decision)}
@router.patch("", dependencies=[Depends(_decision_writer)])
async def patch_decision(
quote_id, line_item_id, payload: DecisionPatch, db: AsyncSession = Depends(get_db)
):
"""Update chosen link / adjusted fields — status is NOT changeable here."""
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("تصمیمی برای این خط آیتم ثبت نشده است")
if payload.chosen_item_termsheet_id is not None:
await _validate_chosen_link(db, line_item_id, payload.chosen_item_termsheet_id)
decision.chosen_item_termsheet_id = payload.chosen_item_termsheet_id
if payload.adjusted_unit_price is not None:
decision.adjusted_unit_price = payload.adjusted_unit_price
if payload.adjusted_notes is not None:
decision.adjusted_notes = payload.adjusted_notes
await db.flush()
return {"data": decision_full(decision)}