Files
ERP/backend/app/routers/line_item_specs.py
T
MMD 0ea0322967 Revert 253c922: restore the PM/RBAC work to the integration
Reverting the production revert so the pm branch can merge cleanly on
top of the restored RBAC base. Conflicts against newer integration work
resolved by combining:
- App.tsx: v7's bot-draft wizard bypass + login returnUrl kept alongside
  the supervisor gating (no-project block screen, panel scoping)
- LoginPage: returnUrl navigation kept, supervisor routing added
- migration.sql: bot_drafts table kept, unified-projects ALTERs restored
2026-10-01 15:24:56 +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_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_role("admin", "procurement_manager")
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)}