fix(backend): arrival-confirmation 500, receipt state, RBAC, Casdoor user sync

- pm.py/inventory.py: remove db.begin() on already-begun sessions — the
  scope dependencies read from the session first, so opening a new
  transaction raised InvalidRequestError -> 500 on every receipt/transaction
  (JWT path); get_db already commits/rolls back atomically
- pm.py: receipt response returns the real derived state (was hardcoded
  'arrived'); dev-user creation is race-free (insert on conflict)
- expand_service: aggregate 1:N receipts per line item (received_qty sum +
  count) so the ledger can show partial-receipt totals
- users.py: role changes are admin-only (RBAC); new POST /users/sync mirrors
  Casdoor org users into the app users table (identity only, roles untouched)
- config/compose: CASDOOR_ADMIN_USER/PASSWORD for the sync endpoint
- seed_casdoor: redirect URI updated for the Traefik edge
This commit is contained in:
ali_mohaghegh
2026-09-01 17:28:06 +03:30
parent c59f5ad1a3
commit 83f4fb0747
6 changed files with 131 additions and 15 deletions
+17 -5
View File
@@ -25,11 +25,13 @@ inventory router.
"""
import base64
import uuid
from contextlib import nullcontext
from dataclasses import dataclass
from decimal import Decimal
from fastapi import APIRouter, Depends, Request
from sqlalchemy import func, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import UserContext, get_optional_user
@@ -113,9 +115,14 @@ async def arrivals_scope(request: Request, db: AsyncSession = Depends(get_db)) -
from app.models.procurement import User
dev = await db.scalar(select(User).where(User.casdoor_id == "dev"))
if dev is None:
dev = User(casdoor_id="dev", first_name="کاربر", last_name="آزمایشی", role="pm")
db.add(dev)
await db.flush()
# Insert-or-ignore: concurrent dev-mode requests must not race on the
# unique casdoor_id ("dev") and 500 with a unique violation.
await db.execute(
pg_insert(User)
.values(casdoor_id="dev", first_name="کاربر", last_name="آزمایشی", role="pm")
.on_conflict_do_nothing(index_elements=["casdoor_id"])
)
dev = await db.scalar(select(User).where(User.casdoor_id == "dev"))
return ArrivalScope(role=role, user_id=dev.id, project_id=project_id, display_name=actor or "کاربر آزمایشی")
@@ -222,7 +229,11 @@ async def pm_confirm_receipt(
ctx: ArrivalScope = Depends(arrivals_scope),
db: AsyncSession = Depends(get_db),
):
async with db.begin():
# No explicit db.begin(): the session is already inside an implicit
# transaction (arrivals_scope read from it above). get_db commits on
# success and rolls back on failure, so atomicity is preserved — opening
# another transaction here would raise "A transaction is already begun".
with nullcontext():
# --- decision must be chosen ---
decision = await db.get(ItemDecision, payload.item_decision_id)
if decision is None or decision.status != "chosen":
@@ -400,9 +411,10 @@ async def pm_confirm_receipt(
quote.status = "arrived"
quote.arrived_date = payload.received_at
item_state = arrival_state(li.quantity, already_received + payload.received_qty)
return {"data": {
"receipt_id": str(receipt.id),
"state": "arrived",
"state": item_state,
"posted_transactions": [str(tx.id) for tx in posted],
}}