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>
This commit is contained in:
@@ -3,15 +3,16 @@ from datetime import date
|
||||
from typing import List, Optional
|
||||
|
||||
import oracledb
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
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 (
|
||||
ComandaListResponse, ComandaRequest, ComandaResponse,
|
||||
FirmaItem, TipDevizItem, MasinaClientItem,
|
||||
AsiguratorItem, ComandaListResponse, ComandaRequest, ComandaResponse,
|
||||
FirmaItem, InspectorItem, MasinaClientItem, MasinaDetails,
|
||||
OperatieItem, PartenerItem, PartnerCreateRequest, TipDevizItem,
|
||||
)
|
||||
from ..services.comanda_service import ComandaService
|
||||
from ..services.lookup_service import LookupService
|
||||
@@ -19,69 +20,192 @@ 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(_: CurrentUser = Depends(get_current_user)):
|
||||
"""Health check: verifies Oracle connectivity for mariusm_test server."""
|
||||
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('mariusm_test') as conn:
|
||||
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": "mariusm_test", "latency_ms": elapsed_ms}
|
||||
return {"result": row[0], "server": server_id or "(default)", "latency_ms": elapsed_ms}
|
||||
|
||||
|
||||
@router.get("/firme", response_model=List[FirmaItem])
|
||||
async def get_firme(current_user: CurrentUser = Depends(get_current_user)):
|
||||
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)
|
||||
return await LookupService.get_firme(current_user.companies, _server_id(request))
|
||||
|
||||
|
||||
@router.get("/tip-deviz", response_model=List[TipDevizItem])
|
||||
async def get_tip_deviz(_: CurrentUser = Depends(get_current_user)):
|
||||
"""Tipuri de deviz din DEV_TIP_DEVIZ."""
|
||||
return await LookupService.get_tip_deviz()
|
||||
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(_: CurrentUser = Depends(get_current_user)):
|
||||
"""Mașini active din AUTO_VMASINICLIENTI (toate firmele pe același server)."""
|
||||
return await LookupService.get_masini()
|
||||
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),
|
||||
_: CurrentUser = Depends(get_current_user),
|
||||
id_firma: Optional[int] = Query(default=None, ge=1),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
# NOTE: DEV_ORDL has no id_firma column — firmă filter not available at DB level.
|
||||
# All comenzi in MARIUSM_AUTO schema are visible (companies 110/167/169 share schema).
|
||||
# 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))
|
||||
|
||||
Reference in New Issue
Block a user