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>
214 lines
7.7 KiB
Python
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))
|