diff --git a/backend/app/config.py b/backend/app/config.py index 1c54ef8..1673996 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -49,6 +49,11 @@ class Settings(BaseSettings): CASDOOR_JWKS_URL: str = "" # default: {endpoint}/api/public_jwks CASDOOR_AUDIENCE: str = "inventory-app-client" # expected token aud (falls back to client_id) + # Casdoor console admin — used by POST /users/sync to mirror Casdoor + # identities into the app's users table (dev defaults: admin / 123). + CASDOOR_ADMIN_USER: str = "admin" + CASDOOR_ADMIN_PASSWORD: str = "123" + # Inventory dev fallback (only when no Authorization header is sent). INVENTORY_DEFAULT_ROLE: str = "pm" INVENTORY_DEFAULT_PROJECT_ID: str = "" diff --git a/backend/app/routers/inventory.py b/backend/app/routers/inventory.py index 875b8b9..8fe5602 100644 --- a/backend/app/routers/inventory.py +++ b/backend/app/routers/inventory.py @@ -10,6 +10,7 @@ Authorization (v2.6): """ import base64 import uuid +from contextlib import nullcontext from dataclasses import dataclass from decimal import Decimal @@ -465,7 +466,10 @@ async def create_transaction( db: AsyncSession = Depends(get_db), ): _require_pm(scope) - async with db.begin(): + # No explicit db.begin(): the session may already be inside an implicit + # transaction (inventory_scope read from it in the JWT path); get_db + # commits on success / rolls back on failure. + with nullcontext(): wh = await get_warehouse_or_404(db, payload.warehouse_id) if wh.project_id != scope.project_id: raise ForbiddenError("این انبار متعلق به پروژه شما نیست") diff --git a/backend/app/routers/pm.py b/backend/app/routers/pm.py index 9ded8c1..67b31f7 100644 --- a/backend/app/routers/pm.py +++ b/backend/app/routers/pm.py @@ -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], }} diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index 4d14b49..c9bef98 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -13,15 +13,23 @@ the same person in this system), unlike the plain require_role("admin"). """ import uuid +import httpx from fastapi import APIRouter, Depends from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession -from app.auth import UserContext, get_current_user +from app.auth import UserContext, get_current_user, require_role +from app.config import settings from app.db.engine import get_db from app.models.procurement import Project, User from app.schemas.procurement import UserCreate, UserProjectUpdate, UserRoleUpdate -from app.utils.errors import ConflictError, ForbiddenError, NotFoundError, ValidationError +from app.utils.errors import ( + ConflictError, + ForbiddenError, + NotFoundError, + ServiceUnavailableError, + ValidationError, +) from app.utils.serialization import to_iso router = APIRouter(prefix="/users", tags=["users"]) @@ -68,6 +76,32 @@ async def _unassign_projects(db: AsyncSession, user_id) -> None: ) +async def _casdoor_users() -> list[dict]: + """Fetch the users of the Casdoor organization (org inventory) via the + Casdoor management API, using the console admin session.""" + async with httpx.AsyncClient(timeout=15) as client: + login = await client.post( + f"{settings.CASDOOR_ENDPOINT}/api/login", + json={ + "type": "login", + "organization": "built-in", + "username": settings.CASDOOR_ADMIN_USER, + "password": settings.CASDOOR_ADMIN_PASSWORD, + "application": "app-built-in", + }, + ) + if login.status_code != 200 or (login.json() or {}).get("status") != "ok": + raise ServiceUnavailableError("ورود به کنسول Casdoor برای همگام‌سازی ممکن نشد") + resp = await client.get( + f"{settings.CASDOOR_ENDPOINT}/api/get-users?owner={settings.CASDOOR_ORGANIZATION}" + ) + resp.raise_for_status() + body = resp.json() + if body.get("status") != "ok": + raise ServiceUnavailableError("دریافت کاربران Casdoor ناموفق بود") + return body.get("data") or [] + + @router.get("/me") async def me(ctx: UserContext = Depends(get_current_user), db: AsyncSession = Depends(get_db)): project = await _user_project(db, ctx.user) if ctx.role == "pm" else None @@ -119,10 +153,12 @@ async def create_user( async def update_user_role( user_id, payload: UserRoleUpdate, - _: UserContext = Depends(_org_manager), + _: UserContext = Depends(require_role("admin")), db: AsyncSession = Depends(get_db), ): - """Set/clear a role. Revoking (or switching away from «pm») unassigns projects.""" + """Set/clear a role — ADMIN ONLY (RBAC): procurement managers must not + assign or elevate roles. Revoking (or switching away from «pm») also + unassigns the user from their project.""" user = await db.get(User, user_id) if user is None: raise NotFoundError("کاربر یافت نشد") @@ -134,6 +170,50 @@ async def update_user_role( return {"data": user_to_dict(user, project)} +@router.post("/sync") +async def sync_casdoor_users( + _: UserContext = Depends(_org_manager), + db: AsyncSession = Depends(get_db), +): + """Mirror the Casdoor organization users into the app's users table. + + Identity-only: roles are NOT touched here (role assignment stays in the + app — PATCH /users/{id}, admin-only). Returns the merged user list. + """ + cas_users = await _casdoor_users() + created = 0 + for cu in cas_users: + cid = str(cu.get("id") or "").strip() + if not cid: + continue + existing = await db.scalar(select(User).where(User.casdoor_id == cid)) + if existing is None: + db.add(User( + casdoor_id=cid, + first_name=str( + cu.get("firstName") + or cu.get("displayName") + or cu.get("name") + or "کاربر" + ), + last_name=str(cu.get("lastName") or ""), + phone=str(cu.get("phone") or "") or None, + )) + created += 1 + await db.flush() + + users = (await db.execute(select(User).order_by(User.created_at))).scalars().all() + pm_ids = {u.id for u in users if u.role == "pm"} + projects: dict = {} + if pm_ids: + projs = (await db.execute(select(Project).where(Project.pm_user_id.in_(pm_ids)))).scalars().all() + projects = {p.pm_user_id: p for p in projs} + return { + "data": [user_to_dict(u, projects.get(u.id)) for u in users], + "meta": {"total": len(users), "created": created}, + } + + @router.post("/{user_id}/project") async def assign_user_project( user_id, diff --git a/backend/app/services/expand_service.py b/backend/app/services/expand_service.py index 572ddb8..1fb3a4c 100644 --- a/backend/app/services/expand_service.py +++ b/backend/app/services/expand_service.py @@ -8,6 +8,7 @@ queries per related table, then assembly from in-memory maps. """ import uuid from datetime import date +from decimal import Decimal from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -305,7 +306,7 @@ class _LineItemData: self.specs_by_line: dict[uuid.UUID, list[LineItemSpec]] = {} self.attr_defs: dict[uuid.UUID, AttributeDefinition] = {} self.decisions: dict[uuid.UUID, ItemDecision] = {} - self.receipts_by_decision: dict[uuid.UUID, ItemReceipt] = {} + self.receipts_by_decision: dict[uuid.UUID, list[ItemReceipt]] = {} self.item_termsheets_by_line: dict[uuid.UUID, list[ItemTermsheet]] = {} self.item_termsheets_by_id: dict[uuid.UUID, ItemTermsheet] = {} self.projects_by_line: dict[uuid.UUID, list[Project]] = {} @@ -374,7 +375,10 @@ async def _load_line_item_data(db: AsyncSession, line_items: list[LineItem], exp dec_ids = [d.id for d in decisions] if dec_ids: receipts = (await db.execute(select(ItemReceipt).where(ItemReceipt.item_decision_id.in_(dec_ids)))).scalars().all() - data.receipts_by_decision = {r.item_decision_id: r for r in receipts} + by_dec: dict[uuid.UUID, list[ItemReceipt]] = {} + for r in receipts: + by_dec.setdefault(r.item_decision_id, []).append(r) + data.receipts_by_decision = by_dec if unit_ids: units = (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars().all() @@ -451,8 +455,19 @@ def _line_item_expanded(li: LineItem, expand: set[str], data: _LineItemData, inc if chosen_it is not None: d["accepted_termsheet_id"] = str(chosen_it.termsheet_id) if "receipt" in expand: - r = data.receipts_by_decision.get(decision.id) if decision is not None else None - d["receipt"] = receipt_brief(r) if r is not None else None + # 1:N partial deliveries — emit the aggregate (sum of received qty, + # latest receipt kept for the received_at contract). + rs = data.receipts_by_decision.get(decision.id) if decision is not None else None + if rs: + latest = rs[-1] + total = sum((r.received_qty for r in rs), Decimal("0")) + d["receipt"] = { + **receipt_brief(latest), + "received_qty": float(total), + "receipt_count": len(rs), + } + else: + d["receipt"] = None if include_termsheet_ids: d["termsheet_ids"] = [str(it.termsheet_id) for it in data.item_termsheets_by_line.get(li.id, [])] return d diff --git a/backend/tools/seed_casdoor.py b/backend/tools/seed_casdoor.py index 5fdbf1e..b0d8070 100644 --- a/backend/tools/seed_casdoor.py +++ b/backend/tools/seed_casdoor.py @@ -28,7 +28,7 @@ ORG = "inventory" APP = "inventory-app" CLIENT_ID = "inventory-app-client" CLIENT_SECRET = "inventory-app-secret-2026" -REDIRECT_URI = "http://localhost:3000/login" +REDIRECT_URI = "http://localhost/login" # Traefik edge (v1.3); unused by the in-page flow USERS = [ # (name, first_name, last_name, email, password)