feat(backend): add FastAPI backend — procurement, inventory domain, Casdoor auth, PM arrival endpoints
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
"""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.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"])
|
||||
|
||||
|
||||
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)):
|
||||
"""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)}
|
||||
Reference in New Issue
Block a user