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

88 lines
3.6 KiB
Python

"""Line item specs — frozen EAV snapshot, full-replacement PUT, editable only while offered."""
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.catalog import AttributeDefinition, Unit
from app.models.procurement import LineItem, LineItemSpec, Quote
from app.schemas.procurement import SpecsPutIn
from app.services import material_service
from app.services.eav_service import flatten_eav_values
from app.utils.errors import ConflictError, NotFoundError
router = APIRouter(prefix="/quotes/{quote_id}/line-items/{line_item_id}/specs", tags=["line-item-specs"])
_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 _flatten(db: AsyncSession, li: LineItem) -> list[dict]:
rows = (
await db.execute(
select(LineItemSpec)
.where(LineItemSpec.line_item_id == li.id)
.order_by(LineItemSpec.attribute_definition_id)
)
).scalars().all()
def_ids = {row.attribute_definition_id for row in rows}
defs = {}
if def_ids:
defs = {
d.id: d
for d in (await db.execute(select(AttributeDefinition).where(AttributeDefinition.id.in_(def_ids)))).scalars()
}
unit_ids = {d.unit_id for d in defs.values() if d.unit_id is not None}
unit_ids |= {row.unit_id_at_time for row in rows if row.unit_id_at_time is not None}
units = {}
if unit_ids:
units = {u.id: u for u in (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars()}
return flatten_eav_values(rows, defs, units, include_definition_id=True)
@router.get("")
async def get_line_item_specs(quote_id, line_item_id, db: AsyncSession = Depends(get_db)):
li = await _line_item_or_404(db, quote_id, line_item_id)
return {"data": await _flatten(db, li)}
@router.put("")
async def put_line_item_specs(
quote_id,
line_item_id,
payload: SpecsPutIn,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
"""FULL replacement — specs not in the payload are DELETED (P1-19)."""
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 قابل ویرایش است")
seen: set = set()
for index, spec in enumerate(payload.specs):
if spec.attribute_definition_id in seen:
raise ConflictError("attribute_definition_id در درخواست تکراری است", field=f"specs[{index}]")
seen.add(spec.attribute_definition_id)
validated = await material_service.validate_attributes(db, payload.specs)
existing = (
(await db.execute(select(LineItemSpec).where(LineItemSpec.line_item_id == li.id))).scalars().all()
)
for row in existing:
await db.delete(row)
await db.flush()
for (ad, values), spec in zip(validated, payload.specs):
db.add(LineItemSpec(line_item_id=li.id, attribute_definition_id=ad.id, **values, unit_id_at_time=spec.unit_id_at_time))
return {"data": await _flatten(db, li)}