Files
roa2web-service-auto/backend/modules/service_auto/routers/comanda.py
Claude Agent 4397027f36 feat(service-auto): multi-tenant + tier 3 lookups + D1 partener + AsyncAutoComplete
Refactor izolare multi-tenant:
- Schema Oracle rezolvată din id_firma via CONTAFIN_ORACLE.V_NOM_FIRME (cached 24h)
- server_id propagat din JWT (request.state.server_id) la oracle_pool.get_connection
- Elimină _SCHEMA='MARIUSM_AUTO' și literal 'mariusm_test' din toate query-urile
- Autorizare firmă la router (_company_id): 403 dacă id_firma nu e în JWT companies[]

Tier 3 — lookup endpoints cached 24h:
- GET /asiguratori (DEV_NOM_ASIGURATORI ← NOM_PARTENERI)
- GET /inspectori?id_asigurator=N (DEV_NOM_INSPECTORI per asig)
- GET /operatii (DEV_NOM_NORME)
- GET /parteneri?q=... (typeahead LIKE escape)
- GET /masini/{id}/detalii (VIN, cilindree, putere)
- POST /comenzi: PACK_SERII_NUMERE.aloca_numar + compensating dezaloca;
  pc_nr VFP-format prefix+seq/nrinmat; ORA-06512 stripped din detail

D1 PartnerCreateDialog (nou):
- POST /api/service-auto/parteneri → PartnerCreateRequest; 409 pe CUI
  duplicat (NOM_PARTENERI fără UNIQUE constraint — check manual);
  id_part = MAX+1 cu retry pe ORA-00001 (fără sequence în schema VFP legacy)
- Frontend PartnerCreateDialog.vue — PrimeVue, design tokens, dark-mode safe
- Integrat în ComandaNoua.vue via AutoComplete empty-action hook

Shared AsyncAutoComplete (nou):
- src/shared/components/AsyncAutoComplete.vue — typeahead async debounced
  cu emptyAction slot, force-selection, keyboard (Enter/Esc), design tokens
- ComandaNoua.vue refactorizat să folosească shared component
- SupplierDualField (data-entry) skipped — documentat în
  docs/service-auto/autocomplete-dual-decision.md (pattern diferit)

Mobile chrome (CLAUDE.md):
- ComandaNoua.vue + ComenziBrowseView.vue: MobileTopBar, BottomSheet
  filtre, MobileBottomNav, card list, isMobile resize listener

Migrații grant-uri idempotente:
- ff_2026_04_13_01_AUTO.sql — SELECT/EXECUTE pe tabele Tier 3 + index
  IX_NOM_PARTENERI_DEN_UPPER
- ff_2026_04_13_02_AUTO.sql — INSERT pe NOM_PARTENERI pentru D1

Live smoke pe MARIUSM_AUTO: /ping 1ms, /tip-deviz 7, /masini 261,
POST /parteneri id_part=70241, firma neautorizată → 403.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-05 09:37:10 +00:00

214 lines
7.7 KiB
Python

import time
from datetime import date
from typing import List, Optional
import oracledb
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from shared.auth.dependencies import get_current_user
from shared.auth.models import CurrentUser
from shared.database.oracle_pool import oracle_pool
from ..schemas.comanda import (
AsiguratorItem, ComandaListResponse, ComandaRequest, ComandaResponse,
FirmaItem, InspectorItem, MasinaClientItem, MasinaDetails,
OperatieItem, PartenerItem, PartnerCreateRequest, TipDevizItem,
)
from ..services.comanda_service import ComandaService
from ..services.lookup_service import LookupService
router = APIRouter()
def _server_id(request: Request) -> Optional[str]:
"""Extrage server_id injectat de AuthenticationMiddleware din JWT."""
return getattr(request.state, "server_id", None)
def _company_id(
current_user: CurrentUser,
explicit: Optional[int],
) -> int:
"""
Rezolvă id_firma: query/body param dacă e dat, altfel prima firmă din JWT.
Validează că firma e printre cele autorizate în JWT.
"""
if explicit is not None:
cid = explicit
else:
if not current_user.companies:
raise HTTPException(status_code=400, detail="Niciun id_firma disponibil în JWT.")
cid = int(current_user.companies[0])
allowed = {int(c) for c in current_user.companies}
if cid not in allowed:
raise HTTPException(status_code=403, detail="Firmă neautorizată pentru utilizator.")
return cid
@router.get("/ping")
async def ping(
request: Request,
_: CurrentUser = Depends(get_current_user),
):
"""Health check: verifies Oracle connectivity pe serverul curent."""
t0 = time.perf_counter()
server_id = _server_id(request)
try:
async with oracle_pool.get_connection(server_id) as conn:
with conn.cursor() as cursor:
cursor.execute('SELECT 1 FROM DUAL')
row = cursor.fetchone()
except oracledb.DatabaseError as e:
raise HTTPException(status_code=503, detail=f"Oracle error: {e}")
elapsed_ms = round((time.perf_counter() - t0) * 1000, 2)
return {"result": row[0], "server": server_id or "(default)", "latency_ms": elapsed_ms}
@router.get("/firme", response_model=List[FirmaItem])
async def get_firme(
request: Request,
current_user: CurrentUser = Depends(get_current_user),
):
"""Firmele accesibile utilizatorului curent (din JWT companies[])."""
return await LookupService.get_firme(current_user.companies, _server_id(request))
@router.get("/tip-deviz", response_model=List[TipDevizItem])
async def get_tip_deviz(
request: Request,
id_firma: Optional[int] = Query(default=None, ge=1),
current_user: CurrentUser = Depends(get_current_user),
):
"""Tipuri de deviz din DEV_TIP_DEVIZ (scoped pe schema firmei)."""
cid = _company_id(current_user, id_firma)
return await LookupService.get_tip_deviz(cid, _server_id(request))
@router.get("/masini", response_model=List[MasinaClientItem])
async def get_masini(
request: Request,
id_firma: Optional[int] = Query(default=None, ge=1),
current_user: CurrentUser = Depends(get_current_user),
):
"""Mașini active din AUTO_VMASINICLIENTI (scoped pe schema firmei)."""
cid = _company_id(current_user, id_firma)
return await LookupService.get_masini(cid, _server_id(request))
@router.get("/comenzi", response_model=ComandaListResponse)
async def list_comenzi(
request: Request,
page: int = Query(default=1, ge=1),
per_page: int = Query(default=20, ge=1, le=100),
validat: Optional[int] = Query(default=None, ge=0, le=1),
data_de_la: Optional[date] = Query(default=None),
data_pana_la: Optional[date] = Query(default=None),
id_firma: Optional[int] = Query(default=None, ge=1),
current_user: CurrentUser = Depends(get_current_user),
):
# DEV_ORDL n-are id_firma; toate firmele pe aceeași schemă împart comenzile.
cid = _company_id(current_user, id_firma)
return await ComandaService.get_comenzi(
company_id=cid,
page=page,
per_page=per_page,
validat=validat,
data_de_la=data_de_la,
data_pana_la=data_pana_la,
server_id=_server_id(request),
)
@router.get("/asiguratori", response_model=List[AsiguratorItem])
async def get_asiguratori(
request: Request,
id_firma: Optional[int] = Query(default=None, ge=1),
current_user: CurrentUser = Depends(get_current_user),
):
"""Asigurători din DEV_NOM_ASIGURATORI (scoped pe schema firmei)."""
cid = _company_id(current_user, id_firma)
return await LookupService.get_asiguratori(cid, _server_id(request))
@router.get("/inspectori", response_model=List[InspectorItem])
async def get_inspectori(
request: Request,
id_asigurator: int = Query(..., ge=1),
id_firma: Optional[int] = Query(default=None, ge=1),
current_user: CurrentUser = Depends(get_current_user),
):
"""Inspectori filtrați pe asigurator (scoped pe schema firmei)."""
cid = _company_id(current_user, id_firma)
return await LookupService.get_inspectori(id_asigurator, cid, _server_id(request))
@router.get("/operatii", response_model=List[OperatieItem])
async def get_operatii(
request: Request,
id_firma: Optional[int] = Query(default=None, ge=1),
current_user: CurrentUser = Depends(get_current_user),
):
"""Lista completă operații DEV_NOM_NORME (scoped pe schema firmei)."""
cid = _company_id(current_user, id_firma)
return await LookupService.get_operatii(cid, _server_id(request))
@router.get("/parteneri", response_model=List[PartenerItem])
async def search_parteneri(
request: Request,
q: str = Query(..., min_length=2, max_length=100),
id_firma: Optional[int] = Query(default=None, ge=1),
current_user: CurrentUser = Depends(get_current_user),
):
"""Typeahead pe NOM_PARTENERI (scoped pe schema firmei)."""
cid = _company_id(current_user, id_firma)
return await LookupService.search_parteneri(q, cid, _server_id(request))
@router.post("/parteneri", response_model=PartenerItem, status_code=201)
async def create_partener(
data: PartnerCreateRequest,
request: Request,
current_user: CurrentUser = Depends(get_current_user),
):
"""Creează partener nou în NOM_PARTENERI (scoped pe schema firmei din JWT)."""
cid = _company_id(current_user, data.id_firma)
data.id_firma = cid
return await LookupService.create_partener(data, _server_id(request))
@router.get("/masini/{id_masiniclient}/detalii", response_model=Optional[MasinaDetails])
async def get_masina_detalii(
id_masiniclient: int,
request: Request,
id_firma: Optional[int] = Query(default=None, ge=1),
current_user: CurrentUser = Depends(get_current_user),
):
"""Detalii complete mașină pentru card readonly după selecție."""
cid = _company_id(current_user, id_firma)
return await LookupService.get_masina_details(id_masiniclient, cid, _server_id(request))
@router.post("/comenzi", response_model=ComandaResponse)
async def creeaza_comanda(
data: ComandaRequest,
request: Request,
current_user: CurrentUser = Depends(get_current_user),
):
# data.id_firma e obligatoriu în body — validat via _company_id
cid = _company_id(current_user, data.id_firma)
# asigură consistența (dacă body trimite id_firma diferit de fallback)
data.id_firma = cid
try:
return await ComandaService.creeaza_comanda(
data=data,
username=current_user.username,
user_id=current_user.user_id,
server_id=_server_id(request),
)
except NotImplementedError as e:
raise HTTPException(status_code=501, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))