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))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from datetime import date
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ComandaRequest(BaseModel):
|
||||
@@ -9,6 +9,10 @@ class ComandaRequest(BaseModel):
|
||||
id_masiniclient: int
|
||||
id_firma: int
|
||||
id_sucursala: Optional[int] = None
|
||||
id_asigurator: Optional[int] = None
|
||||
id_inspector: Optional[int] = None
|
||||
id_part_ref: Optional[int] = None
|
||||
sir_id_operatii: Optional[List[int]] = None
|
||||
observatii: str = ""
|
||||
defectiuni: Optional[str] = None
|
||||
km_int: int = 0
|
||||
@@ -23,6 +27,50 @@ class ComandaResponse(BaseModel):
|
||||
mesaj: str
|
||||
|
||||
|
||||
class AsiguratorItem(BaseModel):
|
||||
id_asigurator: int
|
||||
denumire: str
|
||||
|
||||
|
||||
class InspectorItem(BaseModel):
|
||||
id_inspector: int
|
||||
denumire: str
|
||||
id_asigurator: int
|
||||
|
||||
|
||||
class OperatieItem(BaseModel):
|
||||
id_norme: int
|
||||
codop: str
|
||||
denop: str
|
||||
timpn: Optional[float] = None
|
||||
|
||||
|
||||
class PartenerItem(BaseModel):
|
||||
id_part: int
|
||||
denumire: str
|
||||
|
||||
|
||||
class PartnerCreateRequest(BaseModel):
|
||||
"""Payload pentru POST /parteneri — creare partener nou în NOM_PARTENERI."""
|
||||
denumire: str = Field(min_length=2, max_length=100)
|
||||
cui: Optional[str] = Field(default=None, max_length=30)
|
||||
adresa: Optional[str] = Field(default=None, max_length=150)
|
||||
id_firma: int = Field(ge=1)
|
||||
|
||||
|
||||
class MasinaDetails(BaseModel):
|
||||
id_masiniclient: int
|
||||
label: str
|
||||
nr_inmatriculare: Optional[str] = None
|
||||
marca: Optional[str] = None
|
||||
model: Optional[str] = None
|
||||
serie_sasiu: Optional[str] = None
|
||||
cilindree: Optional[int] = None
|
||||
putere_cp: Optional[int] = None
|
||||
putere_kw: Optional[int] = None
|
||||
client_nume: Optional[str] = None
|
||||
|
||||
|
||||
class FirmaItem(BaseModel):
|
||||
id_firma: int
|
||||
firma: str
|
||||
|
||||
65
backend/modules/service_auto/services/_context.py
Normal file
65
backend/modules/service_auto/services/_context.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
Multi-tenant context resolver for service_auto.
|
||||
|
||||
Pattern-ul urmează `modules/reports`:
|
||||
- `server_id` vine din JWT (`request.state.server_id`), propagat la `oracle_pool.get_connection(server_id)`.
|
||||
- `schema` se rezolvă din `CONTAFIN_ORACLE.V_NOM_FIRME` bazat pe `id_firma`, pe serverul utilizatorului.
|
||||
- Rezultatul e cached in-process 24h per (company_id, server_id).
|
||||
|
||||
NU introduce hardcodări de schemă sau server_id în service_auto. Toate query-urile SQL trebuie să
|
||||
folosească `f"{schema}.{TABLE}"`, iar toate `get_connection()` trebuie să primească `server_id`.
|
||||
"""
|
||||
import time
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import oracledb
|
||||
from fastapi import HTTPException
|
||||
|
||||
from shared.database.oracle_pool import oracle_pool
|
||||
|
||||
from .. import logger
|
||||
|
||||
_SCHEMA_TTL = 86400 # 24h — schema / firma binding changes via DB migration, not runtime
|
||||
_schema_cache: dict = {}
|
||||
|
||||
|
||||
async def get_schema(company_id: int, server_id: Optional[str]) -> str:
|
||||
"""
|
||||
Rezolvă schema Oracle pentru o firmă pe serverul curent al utilizatorului.
|
||||
|
||||
Query pe `CONTAFIN_ORACLE.V_NOM_FIRME` (prezent pe fiecare server în arhitectura ROA2WEB).
|
||||
Cached per (company_id, server_id) 24h.
|
||||
|
||||
Raises 422 dacă firma nu există pe serverul respectiv (misconfiguration).
|
||||
"""
|
||||
key = (company_id, server_id or "")
|
||||
entry: Optional[Tuple[float, str]] = _schema_cache.get(key)
|
||||
if entry and (time.monotonic() - entry[0]) < _SCHEMA_TTL:
|
||||
return entry[1]
|
||||
|
||||
try:
|
||||
async with oracle_pool.get_connection(server_id) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT schema FROM CONTAFIN_ORACLE.V_NOM_FIRME WHERE id_firma = :id",
|
||||
{"id": company_id},
|
||||
)
|
||||
row = cur.fetchone()
|
||||
except oracledb.DatabaseError:
|
||||
logger.error("service_auto._context.get_schema Oracle error", exc_info=True)
|
||||
raise HTTPException(status_code=503, detail="Eroare la rezolvarea schemei firmei")
|
||||
|
||||
if not row or not row[0]:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Firma {company_id} nu are schemă configurată pe serverul curent.",
|
||||
)
|
||||
|
||||
schema = row[0]
|
||||
_schema_cache[key] = (time.monotonic(), schema)
|
||||
return schema
|
||||
|
||||
|
||||
def reset_schema_cache() -> None:
|
||||
"""Test helper — clear the schema cache."""
|
||||
_schema_cache.clear()
|
||||
@@ -1,6 +1,6 @@
|
||||
import re
|
||||
from datetime import date, datetime
|
||||
from typing import List, NoReturn, Optional
|
||||
from typing import List, NoReturn, Optional, Tuple
|
||||
|
||||
import oracledb
|
||||
from fastapi import HTTPException
|
||||
@@ -8,35 +8,102 @@ from shared.database.oracle_pool import oracle_pool
|
||||
from ..schemas.comanda import (
|
||||
ComandaListItem, ComandaListResponse, ComandaRequest, ComandaResponse,
|
||||
)
|
||||
from .lookup_service import LookupService
|
||||
from ._context import get_schema
|
||||
from .. import logger
|
||||
|
||||
_SCHEMA = "MARIUSM_AUTO"
|
||||
|
||||
_MAX_PER_PAGE = 100
|
||||
_MAX_OPERATII_CSV = 4000 # Oracle VARCHAR2 limit; ~600 IDs at 6 chars each
|
||||
|
||||
# Source: DEV_TIP_DEVIZ (verified 2026-04-13):
|
||||
# 1=POST GARANTIE, 2=GARANTIE, 3=REGIE, 4=PREGATIRE, 5=REGIE 2 (no VFP mapping → ""),
|
||||
# 6=PRODUCTIE, 7=CONSTATARE
|
||||
# VFP reference: oproceduri_devize.prg lines 108-120 (pntipcom switch)
|
||||
_PREFIX_MAP = {1: "", 2: "G", 3: "R", 4: "P", 6: "PR", 7: "C"}
|
||||
|
||||
|
||||
def _aloca_numar_devize(
|
||||
cursor, schema: str, user_id: int, id_sucursala: int,
|
||||
) -> Tuple[int, int]:
|
||||
"""
|
||||
Calls {schema}.PACK_SERII_NUMERE.aloca_numar(20, NULL, NULL, user_id, id_sucursala) — 7-param overload.
|
||||
Returns (seq, id_numar):
|
||||
seq — the allocated command number (used in pc_nr)
|
||||
id_numar — SERII_NUMERE.ID_NUMAR row, used by dezaloca_id_numar compensating call
|
||||
"""
|
||||
out_numar = cursor.var(oracledb.NUMBER)
|
||||
out_id_numar = cursor.var(oracledb.NUMBER)
|
||||
cursor.callproc(
|
||||
f"{schema}.PACK_SERII_NUMERE.aloca_numar",
|
||||
[20, None, None, user_id, id_sucursala, out_numar, out_id_numar],
|
||||
)
|
||||
seq = int(out_numar.getvalue() or 0)
|
||||
id_numar = int(out_id_numar.getvalue() or 0)
|
||||
logger.info(
|
||||
"service_auto.seq_allocated",
|
||||
extra={"seq": seq, "id_numar": id_numar, "user_id": user_id, "id_sucursala": id_sucursala},
|
||||
)
|
||||
if seq <= 0:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Nu aveți serie alocată pentru comenzi devize. Contactați administratorul.",
|
||||
)
|
||||
return seq, id_numar
|
||||
|
||||
|
||||
def _dezaloca_numar_devize(
|
||||
cursor, schema: str, seq: int, id_numar: int, reason: str,
|
||||
) -> None:
|
||||
"""Compensating transaction — releases allocated seq number on callproc failure."""
|
||||
try:
|
||||
cursor.callproc(f"{schema}.PACK_SERII_NUMERE.dezaloca_id_numar", [id_numar])
|
||||
logger.info(
|
||||
"service_auto.seq_released",
|
||||
extra={"seq": seq, "id_numar": id_numar, "reason": reason},
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"SEQ_LEAK",
|
||||
extra={"seq": seq, "id_numar": id_numar, "reason": reason},
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
def _build_pc_nr(tip_id: int, seq: int, nr_inmatriculare: str) -> str:
|
||||
"""Format: <prefix><seq>/<nr_inmatriculare>"""
|
||||
prefix = _PREFIX_MAP.get(tip_id)
|
||||
if prefix is None:
|
||||
logger.warning("service_auto.unknown_tip_id", extra={"tip_id": tip_id})
|
||||
prefix = ""
|
||||
return f"{prefix}{seq}/{nr_inmatriculare}"
|
||||
|
||||
|
||||
def _build_sir_id_operatii(operatii: Optional[List[int]]) -> Optional[str]:
|
||||
"""Serializes list of operation IDs to CSV string for pcSirIdOperatii param."""
|
||||
if not operatii:
|
||||
return None
|
||||
csv = ",".join(str(i) for i in operatii)
|
||||
if len(csv) > _MAX_OPERATII_CSV:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Prea multe operații selectate (max ~{_MAX_OPERATII_CSV // 6}).",
|
||||
)
|
||||
return csv
|
||||
|
||||
|
||||
def _handle_oracle_error(e: Exception) -> NoReturn:
|
||||
"""
|
||||
Map Oracle error codes to FastAPI HTTPExceptions. Always raises.
|
||||
|
||||
Code ranges:
|
||||
20000-20999 → 422 Unprocessable (business rule errors from RAISE_APPLICATION_ERROR)
|
||||
12541/12170/12154/12560 → 503 Service Unavailable (Oracle unreachable / network)
|
||||
1017 → 500 + CRITICAL log (bad credentials — config error)
|
||||
942 → 500 + CRITICAL log (missing object/grant — deployment error)
|
||||
* → 500 + ERROR log (unexpected)
|
||||
"""
|
||||
"""Map Oracle error codes to FastAPI HTTPExceptions. Always raises."""
|
||||
err = e.args[0]
|
||||
code = getattr(err, "code", 0)
|
||||
raw_message = getattr(err, "message", str(e))
|
||||
|
||||
if 20000 <= code <= 20999:
|
||||
# Strip "ORA-2xxxx: " prefix injected by Oracle; expose the business message only
|
||||
# Strip "ORA-2xxxx: " prefix; strip "\nORA-06512: at ..." stack frames.
|
||||
clean = re.sub(r"^ORA-\d+:\s*", "", raw_message).strip()
|
||||
clean = clean.split("\n")[0].strip()
|
||||
raise HTTPException(status_code=422, detail=clean)
|
||||
|
||||
if code == 1438:
|
||||
# ORA-01438: value larger than specified precision — bad input ID (e.g. id_masiniclient out of range)
|
||||
raise HTTPException(status_code=422, detail="Valoare invalidă pentru câmp (ID prea mare)")
|
||||
|
||||
if code in (12541, 12170, 12154, 12560):
|
||||
@@ -69,65 +136,101 @@ class ComandaService:
|
||||
data: ComandaRequest,
|
||||
username: str,
|
||||
user_id: Optional[int] = None,
|
||||
server_id: Optional[str] = None,
|
||||
) -> ComandaResponse:
|
||||
now = datetime.now()
|
||||
# pcNr serves as NOM_LUCRARI.NRORD — must be unique; timestamp gives collision-safe value.
|
||||
pc_nr = f"W{now.strftime('%Y%m%d%H%M%S')}{now.microsecond // 1000:03d}"
|
||||
|
||||
schema = await get_schema(data.id_firma, server_id)
|
||||
|
||||
# Fetch vehicle details early: validates vehicle exists + gets nrinmat for pc_nr
|
||||
masina = await LookupService.get_masina_details(
|
||||
data.id_masiniclient, data.id_firma, server_id
|
||||
)
|
||||
if masina is None:
|
||||
raise HTTPException(status_code=422, detail="Mașina selectată nu există.")
|
||||
nr_inmatriculare = masina.nr_inmatriculare or "?"
|
||||
|
||||
pc_sir_id_operatii = _build_sir_id_operatii(data.sir_id_operatii)
|
||||
id_sucursala = data.id_sucursala or data.id_firma
|
||||
|
||||
logger.info(
|
||||
"service_auto.create_comanda START",
|
||||
extra={
|
||||
"user": username,
|
||||
"schema": schema,
|
||||
"server_id": server_id,
|
||||
"tip": data.tip_id,
|
||||
"client_id": data.id_masiniclient,
|
||||
"id_firma": data.id_firma,
|
||||
"pc_nr": pc_nr,
|
||||
"km": data.km_int,
|
||||
"ore": data.ore_functionare,
|
||||
"id_asigurator": data.id_asigurator,
|
||||
"id_inspector": data.id_inspector,
|
||||
"nr_operatii": len(data.sir_id_operatii) if data.sir_id_operatii else 0,
|
||||
},
|
||||
)
|
||||
|
||||
async with oracle_pool.get_connection("mariusm_test") as connection:
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
# pnIdOrdl is IN OUT — setvalue(0, 0) sets the IN side to 0;
|
||||
# Oracle overwrites it with the new DEV_ORDL.ID_ORDL.
|
||||
out_id_ordl = cursor.var(oracledb.NUMBER)
|
||||
out_id_ordl.setvalue(0, 0)
|
||||
async with oracle_pool.get_connection(server_id) as connection:
|
||||
with connection.cursor() as cursor:
|
||||
# Step 1: allocate sequence number via pack_serii_numere
|
||||
try:
|
||||
seq, id_numar = _aloca_numar_devize(
|
||||
cursor, schema, user_id or 0, id_sucursala
|
||||
)
|
||||
except oracledb.DatabaseError as e:
|
||||
try:
|
||||
connection.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
_handle_oracle_error(e)
|
||||
|
||||
pc_nr = _build_pc_nr(data.tip_id, seq, nr_inmatriculare)
|
||||
|
||||
# Step 2: create comanda; compensating dezaloca on DB failure.
|
||||
# pnIdOrdl is IN OUT — setvalue(0, 0) sets the IN side to 0;
|
||||
# Oracle overwrites it with the new DEV_ORDL.ID_ORDL.
|
||||
out_id_ordl = cursor.var(oracledb.NUMBER)
|
||||
out_id_ordl.setvalue(0, 0)
|
||||
|
||||
try:
|
||||
cursor.callproc(
|
||||
f"{_SCHEMA}.PACK_AUTO.dev_adauga_lucrare",
|
||||
f"{schema}.PACK_AUTO.dev_adauga_lucrare",
|
||||
[
|
||||
_SCHEMA, # v_gcs IN VARCHAR2
|
||||
now.year, # tnan IN NUMBER
|
||||
now.month, # tnluna IN NUMBER
|
||||
user_id or 0, # tnIdUtil IN NUMBER (Oracle ID_UTIL)
|
||||
pc_nr, # pcNr IN VARCHAR2 (NOM_LUCRARI.NRORD)
|
||||
None, # pnIdInsp IN NUMBER
|
||||
None, # pnIdAsig IN NUMBER
|
||||
data.nr_dosar or "", # pcNrDosar IN VARCHAR2
|
||||
data.id_masiniclient, # pnIdMC IN NUMBER
|
||||
data.km_int, # pnKmInt IN NUMBER
|
||||
data.ore_functionare, # pnOreFct IN NUMBER (≥0; NOT NULL in DEV_MASINICLIENTI)
|
||||
data.termen, # pdTermen IN DATE
|
||||
data.tip_id, # pnTipCom IN NUMBER
|
||||
None, # pcSirIdOperatii IN VARCHAR2 — MUST be None, NOT ''
|
||||
data.observatii or None, # pcObservatii IN VARCHAR2 DEFAULT NULL
|
||||
data.defectiuni or None, # pcDefectiuni IN VARCHAR2 DEFAULT NULL
|
||||
0, # pnIdPartRef IN NUMBER (decode(0) → NULL inside SP)
|
||||
out_id_ordl, # pnIdOrdl IN OUT NUMBER
|
||||
schema, # v_gcs IN VARCHAR2
|
||||
now.year, # tnan IN NUMBER
|
||||
now.month, # tnluna IN NUMBER
|
||||
user_id or 0, # tnIdUtil IN NUMBER
|
||||
pc_nr, # pcNr IN VARCHAR2 (NOM_LUCRARI.NRORD)
|
||||
data.id_inspector, # pnIdInsp IN NUMBER
|
||||
data.id_asigurator, # pnIdAsig IN NUMBER
|
||||
data.nr_dosar or "", # pcNrDosar IN VARCHAR2
|
||||
data.id_masiniclient, # pnIdMC IN NUMBER
|
||||
data.km_int, # pnKmInt IN NUMBER
|
||||
data.ore_functionare, # pnOreFct IN NUMBER
|
||||
data.termen, # pdTermen IN DATE
|
||||
data.tip_id, # pnTipCom IN NUMBER
|
||||
pc_sir_id_operatii, # pcSirIdOperatii IN VARCHAR2 (None or CSV)
|
||||
data.observatii or None, # pcObservatii IN VARCHAR2
|
||||
data.defectiuni or None, # pcDefectiuni IN VARCHAR2
|
||||
data.id_part_ref or 0, # pnIdPartRef IN NUMBER (decode(0)→NULL in SP)
|
||||
out_id_ordl, # pnIdOrdl IN OUT NUMBER
|
||||
],
|
||||
)
|
||||
|
||||
connection.commit()
|
||||
except oracledb.DatabaseError as e:
|
||||
try:
|
||||
connection.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
_dezaloca_numar_devize(cursor, schema, seq, id_numar, "dev_adauga_lucrare_failed")
|
||||
try:
|
||||
# aloca uses AUTONOMOUS_TRANSACTION; survives our rollback. Commit dezaloca.
|
||||
connection.commit()
|
||||
except Exception:
|
||||
pass
|
||||
_handle_oracle_error(e)
|
||||
|
||||
id_ordl = int(out_id_ordl.getvalue())
|
||||
except oracledb.DatabaseError as e:
|
||||
try:
|
||||
connection.rollback()
|
||||
except Exception:
|
||||
pass # connection may be dead on network errors; ignore
|
||||
_handle_oracle_error(e)
|
||||
id_ordl = int(out_id_ordl.getvalue())
|
||||
|
||||
logger.info(
|
||||
"service_auto.create_comanda OK",
|
||||
@@ -137,20 +240,24 @@ class ComandaService:
|
||||
return ComandaResponse(
|
||||
id_ordl=id_ordl,
|
||||
nrord=pc_nr,
|
||||
mesaj=f"Comanda {pc_nr} creata cu succes.",
|
||||
mesaj=f"Comanda {pc_nr} creată cu succes.",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def get_comenzi(
|
||||
company_id: int,
|
||||
page: int,
|
||||
per_page: int,
|
||||
validat: Optional[int],
|
||||
data_de_la: Optional[date],
|
||||
data_pana_la: Optional[date],
|
||||
server_id: Optional[str] = None,
|
||||
) -> ComandaListResponse:
|
||||
per_page = min(per_page, _MAX_PER_PAGE)
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
schema = await get_schema(company_id, server_id)
|
||||
|
||||
where_parts = ["d.sters = 0"]
|
||||
filter_params: dict = {}
|
||||
|
||||
@@ -170,11 +277,11 @@ class ComandaService:
|
||||
where_clause = " AND ".join(where_parts)
|
||||
|
||||
base_from = f"""
|
||||
FROM MARIUSM_AUTO.DEV_ORDL d
|
||||
LEFT JOIN MARIUSM_AUTO.NOM_LUCRARI l ON d.id_lucrare = l.id_lucrare
|
||||
LEFT JOIN MARIUSM_AUTO.AUTO_VMASINICLIENTI mc
|
||||
FROM {schema}.DEV_ORDL d
|
||||
LEFT JOIN {schema}.NOM_LUCRARI l ON d.id_lucrare = l.id_lucrare
|
||||
LEFT JOIN {schema}.AUTO_VMASINICLIENTI mc
|
||||
ON d.id_masiniclient = mc.id_masiniclient
|
||||
LEFT JOIN MARIUSM_AUTO.DEV_TIP_DEVIZ t ON d.id_tip = t.id_tip
|
||||
LEFT JOIN {schema}.DEV_TIP_DEVIZ t ON d.id_tip = t.id_tip
|
||||
WHERE {where_clause}
|
||||
"""
|
||||
|
||||
@@ -190,7 +297,7 @@ class ComandaService:
|
||||
"""
|
||||
|
||||
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 cur:
|
||||
cur.execute(count_query, filter_params)
|
||||
total = cur.fetchone()[0]
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""
|
||||
Lookup data for service_auto forms — tip deviz, masini, firme.
|
||||
Lookup data for service_auto forms — tip deviz, masini, firme, asiguratori, inspectori, operatii, parteneri.
|
||||
|
||||
All three endpoints are read-only and infrequently changing.
|
||||
Multi-tenant safe: `schema` e rezolvat din `id_firma` via `_context.get_schema()`; nu există
|
||||
schemă hardcodată. `server_id` propagat din JWT (`request.state.server_id`).
|
||||
"""
|
||||
import time
|
||||
from typing import List, Optional, Tuple
|
||||
@@ -10,14 +11,22 @@ import oracledb
|
||||
from fastapi import HTTPException
|
||||
from shared.database.oracle_pool import oracle_pool
|
||||
|
||||
from ..schemas.comanda import FirmaItem, MasinaClientItem, TipDevizItem
|
||||
from ..schemas.comanda import (
|
||||
AsiguratorItem, FirmaItem, InspectorItem, MasinaClientItem,
|
||||
MasinaDetails, OperatieItem, PartenerItem, PartnerCreateRequest,
|
||||
TipDevizItem,
|
||||
)
|
||||
from .. import logger
|
||||
from ._context import get_schema
|
||||
|
||||
# In-memory TTL cache: key → (monotonic_timestamp, value)
|
||||
_cache: dict = {}
|
||||
|
||||
_TTL_TIP_DEVIZ = 86400 # 24 h — tip deviz changes only via DB migration
|
||||
_TTL_MASINI = 300 # 5 min — vehicle inventory changes regularly
|
||||
_TTL_TIP_DEVIZ = 86400 # 24 h — tip deviz changes only via DB migration
|
||||
_TTL_MASINI = 300 # 5 min — vehicle inventory changes regularly
|
||||
_TTL_ASIGURATORI = 86400 # 24 h
|
||||
_TTL_INSPECTORI = 86400 # 24 h per asigurator
|
||||
_TTL_OPERATII = 86400 # 24 h — DEV_NOM_NORME changes only via DB
|
||||
|
||||
|
||||
def _cache_get(key: str, ttl: float):
|
||||
@@ -31,13 +40,21 @@ def _cache_set(key: str, value) -> None:
|
||||
_cache[key] = (time.monotonic(), value)
|
||||
|
||||
|
||||
def reset_cache() -> None:
|
||||
"""Test helper."""
|
||||
_cache.clear()
|
||||
|
||||
|
||||
class LookupService:
|
||||
|
||||
@staticmethod
|
||||
async def get_firme(company_ids: List[str]) -> List[FirmaItem]:
|
||||
async def get_firme(
|
||||
company_ids: List[str],
|
||||
server_id: Optional[str] = None,
|
||||
) -> List[FirmaItem]:
|
||||
"""
|
||||
Returns firma names for the company IDs in the user's JWT.
|
||||
Uses 'central' pool (CONTAFIN_ORACLE) to query V_NOM_FIRME.
|
||||
Query pe `CONTAFIN_ORACLE.V_NOM_FIRME` pe serverul utilizatorului.
|
||||
"""
|
||||
if not company_ids:
|
||||
return []
|
||||
@@ -52,7 +69,7 @@ class LookupService:
|
||||
params = {f"id{i}": int(cid) for i, cid in enumerate(company_ids)}
|
||||
|
||||
try:
|
||||
async with oracle_pool.get_connection("central") as conn:
|
||||
async with oracle_pool.get_connection(server_id) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(query, params)
|
||||
rows = cur.fetchall()
|
||||
@@ -66,23 +83,26 @@ class LookupService:
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
async def get_tip_deviz() -> List[TipDevizItem]:
|
||||
async def get_tip_deviz(
|
||||
company_id: int,
|
||||
server_id: Optional[str] = None,
|
||||
) -> List[TipDevizItem]:
|
||||
"""
|
||||
Returns all active tip deviz from MARIUSM_AUTO.DEV_TIP_DEVIZ.
|
||||
Cached in-process for 24 h (changes only via DB migration).
|
||||
ROA_WEB has SELECT grant on this view.
|
||||
Tip deviz din `{schema}.DEV_TIP_DEVIZ`. Cached 24 h per schema.
|
||||
"""
|
||||
cached = _cache_get("tip_deviz", _TTL_TIP_DEVIZ)
|
||||
schema = await get_schema(company_id, server_id)
|
||||
cache_key = f"tip_deviz:{schema}"
|
||||
cached = _cache_get(cache_key, _TTL_TIP_DEVIZ)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
query = """
|
||||
query = f"""
|
||||
SELECT id_tip, denumire, inch_validare
|
||||
FROM MARIUSM_AUTO.DEV_TIP_DEVIZ
|
||||
FROM {schema}.DEV_TIP_DEVIZ
|
||||
ORDER BY id_tip
|
||||
"""
|
||||
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 cur:
|
||||
cur.execute(query)
|
||||
rows = cur.fetchall()
|
||||
@@ -94,29 +114,31 @@ class LookupService:
|
||||
TipDevizItem(id_tip=r[0], denumire=r[1], inch_validare=r[2] or 0)
|
||||
for r in rows
|
||||
]
|
||||
_cache_set("tip_deviz", result)
|
||||
_cache_set(cache_key, result)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
async def get_masini() -> List[MasinaClientItem]:
|
||||
async def get_masini(
|
||||
company_id: int,
|
||||
server_id: Optional[str] = None,
|
||||
) -> List[MasinaClientItem]:
|
||||
"""
|
||||
Returns active masini from MARIUSM_AUTO.AUTO_VMASINICLIENTI.
|
||||
Cached in-process for 5 min (vehicle inventory changes regularly).
|
||||
ROA_WEB has SELECT grant on this view.
|
||||
Label format: "PARTENER — MARCA MASINA, NRINMAT (ANFABRICATIE)"
|
||||
Mașini active din `{schema}.AUTO_VMASINICLIENTI`. Cached 5 min per schema.
|
||||
"""
|
||||
cached = _cache_get("masini", _TTL_MASINI)
|
||||
schema = await get_schema(company_id, server_id)
|
||||
cache_key = f"masini:{schema}"
|
||||
cached = _cache_get(cache_key, _TTL_MASINI)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
query = """
|
||||
query = f"""
|
||||
SELECT id_masiniclient, nrinmat, marca, masina, anfabricatie, partener
|
||||
FROM MARIUSM_AUTO.AUTO_VMASINICLIENTI
|
||||
FROM {schema}.AUTO_VMASINICLIENTI
|
||||
WHERE inactiv = 0
|
||||
ORDER BY partener, nrinmat
|
||||
"""
|
||||
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 cur:
|
||||
cur.execute(query)
|
||||
rows = cur.fetchall()
|
||||
@@ -137,5 +159,302 @@ class LookupService:
|
||||
label = f"{partener or '?'} — {vehicul}, {nrinmat or '?'}{an_str}"
|
||||
result.append(MasinaClientItem(id_masiniclient=int(id_mc), label=label))
|
||||
|
||||
_cache_set("masini", result)
|
||||
_cache_set(cache_key, result)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
async def get_masina_details(
|
||||
id_masiniclient: int,
|
||||
company_id: int,
|
||||
server_id: Optional[str] = None,
|
||||
) -> Optional[MasinaDetails]:
|
||||
"""
|
||||
Detalii complete vehicul din `{schema}.AUTO_VMASINICLIENTI`. Fără cache (per-record).
|
||||
"""
|
||||
schema = await get_schema(company_id, server_id)
|
||||
query = f"""
|
||||
SELECT id_masiniclient, nrinmat, marca, masina, anfabricatie, partener,
|
||||
series, cilindree, puterecp, puterekw
|
||||
FROM {schema}.AUTO_VMASINICLIENTI
|
||||
WHERE id_masiniclient = :id_mc
|
||||
"""
|
||||
try:
|
||||
async with oracle_pool.get_connection(server_id) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(query, {"id_mc": id_masiniclient})
|
||||
row = cur.fetchone()
|
||||
except oracledb.DatabaseError:
|
||||
logger.error("get_masina_details Oracle error", exc_info=True)
|
||||
raise HTTPException(status_code=503, detail="Eroare la încărcarea detaliilor mașinii")
|
||||
|
||||
if not row:
|
||||
return None
|
||||
|
||||
id_mc, nrinmat, marca, masina, an, partener, serie_sasiu, cilindree, putere_cp, putere_kw = row
|
||||
parts = [p for p in [marca, masina] if p]
|
||||
vehicul = " ".join(parts) if parts else "?"
|
||||
an_str = f" ({int(an)})" if an else ""
|
||||
label = f"{partener or '?'} — {vehicul}, {nrinmat or '?'}{an_str}"
|
||||
|
||||
return MasinaDetails(
|
||||
id_masiniclient=int(id_mc),
|
||||
label=label,
|
||||
nr_inmatriculare=nrinmat,
|
||||
marca=marca,
|
||||
model=masina,
|
||||
serie_sasiu=serie_sasiu,
|
||||
cilindree=int(cilindree) if cilindree else None,
|
||||
putere_cp=int(putere_cp) if putere_cp else None,
|
||||
putere_kw=int(putere_kw) if putere_kw else None,
|
||||
client_nume=partener,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def get_asiguratori(
|
||||
company_id: int,
|
||||
server_id: Optional[str] = None,
|
||||
) -> List[AsiguratorItem]:
|
||||
"""
|
||||
Asigurători activi din `{schema}.DEV_NOM_ASIGURATORI`. Cached 24h per schema.
|
||||
Numele din NOM_PARTENERI via FK ID_PART (DEV_NOM_ASIGURATORI nu are coloană denumire).
|
||||
"""
|
||||
schema = await get_schema(company_id, server_id)
|
||||
cache_key = f"asiguratori:{schema}"
|
||||
cached = _cache_get(cache_key, _TTL_ASIGURATORI)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
query = f"""
|
||||
SELECT a.id_asigurator, NVL(p.denumire, a.asigurator_vechi) AS denumire
|
||||
FROM {schema}.DEV_NOM_ASIGURATORI a
|
||||
LEFT JOIN {schema}.NOM_PARTENERI p ON a.id_part = p.id_part
|
||||
WHERE NVL(a.sters, 0) = 0
|
||||
ORDER BY NVL(p.denumire, a.asigurator_vechi)
|
||||
"""
|
||||
try:
|
||||
async with oracle_pool.get_connection(server_id) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(query)
|
||||
rows = cur.fetchall()
|
||||
except oracledb.DatabaseError:
|
||||
logger.error("get_asiguratori Oracle error", exc_info=True)
|
||||
raise HTTPException(status_code=503, detail="Eroare la încărcarea asigurătorilor")
|
||||
|
||||
result = [AsiguratorItem(id_asigurator=int(r[0]), denumire=r[1] or "") for r in rows]
|
||||
_cache_set(cache_key, result)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
async def get_inspectori(
|
||||
id_asigurator: int,
|
||||
company_id: int,
|
||||
server_id: Optional[str] = None,
|
||||
) -> List[InspectorItem]:
|
||||
"""
|
||||
Inspectori filtrați per asigurator din `{schema}.DEV_NOM_INSPECTORI`.
|
||||
Cached 24h per (schema, id_asigurator).
|
||||
"""
|
||||
schema = await get_schema(company_id, server_id)
|
||||
cache_key = f"inspectori:{schema}:{id_asigurator}"
|
||||
cached = _cache_get(cache_key, _TTL_INSPECTORI)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
query = f"""
|
||||
SELECT id_inspector, inspector AS denumire, id_asigurator
|
||||
FROM {schema}.DEV_NOM_INSPECTORI
|
||||
WHERE id_asigurator = :id_asig
|
||||
AND NVL(sters, 0) = 0
|
||||
ORDER BY inspector
|
||||
"""
|
||||
try:
|
||||
async with oracle_pool.get_connection(server_id) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(query, {"id_asig": id_asigurator})
|
||||
rows = cur.fetchall()
|
||||
except oracledb.DatabaseError:
|
||||
logger.error("get_inspectori Oracle error", exc_info=True)
|
||||
raise HTTPException(status_code=503, detail="Eroare la încărcarea inspectorilor")
|
||||
|
||||
result = [
|
||||
InspectorItem(id_inspector=int(r[0]), denumire=r[1] or "", id_asigurator=int(r[2]))
|
||||
for r in rows
|
||||
]
|
||||
_cache_set(cache_key, result)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
async def get_operatii(
|
||||
company_id: int,
|
||||
server_id: Optional[str] = None,
|
||||
) -> List[OperatieItem]:
|
||||
"""
|
||||
Operații din `{schema}.DEV_NOM_NORME`. Cached 24h per schema.
|
||||
Full list; filter client-side.
|
||||
"""
|
||||
schema = await get_schema(company_id, server_id)
|
||||
cache_key = f"operatii:{schema}"
|
||||
cached = _cache_get(cache_key, _TTL_OPERATII)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
query = f"""
|
||||
SELECT id_norme, codop, denop, timpn
|
||||
FROM {schema}.DEV_NOM_NORME
|
||||
WHERE NVL(sters, 0) = 0
|
||||
AND NVL(inactiv, 0) = 0
|
||||
ORDER BY denop
|
||||
"""
|
||||
try:
|
||||
async with oracle_pool.get_connection(server_id) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(query)
|
||||
rows = cur.fetchall()
|
||||
except oracledb.DatabaseError:
|
||||
logger.error("get_operatii Oracle error", exc_info=True)
|
||||
raise HTTPException(status_code=503, detail="Eroare la încărcarea operațiilor")
|
||||
|
||||
# Oracle treats '' as NULL, so NVL(col,'') can still yield None in Python.
|
||||
result = [
|
||||
OperatieItem(
|
||||
id_norme=int(r[0]),
|
||||
codop=r[1] or "",
|
||||
denop=r[2] or "",
|
||||
timpn=float(r[3]) if r[3] is not None else None,
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
_cache_set(cache_key, result)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
async def search_parteneri(
|
||||
q: str,
|
||||
company_id: int,
|
||||
server_id: Optional[str] = None,
|
||||
) -> List[PartenerItem]:
|
||||
"""
|
||||
Typeahead pe `{schema}.NOM_PARTENERI`. Min 2 chars, limit 50. No cache.
|
||||
Folosește IX_NOM_PARTENERI_DEN_UPPER; LIKE escape pentru %, _, \\.
|
||||
"""
|
||||
if len(q) < 2:
|
||||
return []
|
||||
|
||||
def _escape_like(s: str) -> str:
|
||||
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
|
||||
schema = await get_schema(company_id, server_id)
|
||||
query = f"""
|
||||
SELECT id_part, denumire
|
||||
FROM {schema}.NOM_PARTENERI
|
||||
WHERE UPPER(denumire) LIKE UPPER(:q) ESCAPE '\\'
|
||||
AND NVL(sters, 0) = 0
|
||||
ORDER BY denumire
|
||||
FETCH FIRST 50 ROWS ONLY
|
||||
"""
|
||||
try:
|
||||
async with oracle_pool.get_connection(server_id) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(query, {"q": _escape_like(q) + "%"})
|
||||
rows = cur.fetchall()
|
||||
except oracledb.DatabaseError:
|
||||
logger.error("search_parteneri Oracle error", exc_info=True)
|
||||
raise HTTPException(status_code=503, detail="Eroare la căutarea partenerilor")
|
||||
|
||||
return [PartenerItem(id_part=int(r[0]), denumire=r[1] or "") for r in rows]
|
||||
|
||||
@staticmethod
|
||||
async def create_partener(
|
||||
data: PartnerCreateRequest,
|
||||
server_id: Optional[str] = None,
|
||||
) -> PartenerItem:
|
||||
"""
|
||||
Creează partener nou în `{schema}.NOM_PARTENERI`.
|
||||
|
||||
- id_part alocat manual cu `NVL(MAX(id_part),0)+1` (nu există secvență/identity).
|
||||
- Pre-check unicitate CUI (NU există unique constraint pe COD_FISCAL) → 409.
|
||||
- PK collision (race) → ORA-00001 → retry o singură dată cu MAX+1 reactualizat.
|
||||
- ORA-00942/01031 (table missing / no privileges) → log.critical + 500 (lipsă GRANT).
|
||||
"""
|
||||
denumire = data.denumire.strip()
|
||||
cui = (data.cui or "").strip() or None
|
||||
adresa = (data.adresa or "").strip() or None
|
||||
|
||||
schema = await get_schema(data.id_firma, server_id)
|
||||
|
||||
try:
|
||||
async with oracle_pool.get_connection(server_id) as conn:
|
||||
with conn.cursor() as cur:
|
||||
# Pre-check duplicat CUI (doar dacă CUI a fost furnizat).
|
||||
if cui:
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT 1 FROM {schema}.NOM_PARTENERI
|
||||
WHERE cod_fiscal = :cui
|
||||
AND NVL(sters, 0) = 0
|
||||
AND ROWNUM = 1
|
||||
""",
|
||||
{"cui": cui},
|
||||
)
|
||||
if cur.fetchone():
|
||||
raise HTTPException(status_code=409, detail="CUI duplicat")
|
||||
|
||||
insert_sql = f"""
|
||||
INSERT INTO {schema}.NOM_PARTENERI
|
||||
(id_part, denumire, cod_fiscal, adresa,
|
||||
sters, inactiv, id_mod, tip_persoana)
|
||||
VALUES
|
||||
(:id_part, :denumire, :cui, :adresa,
|
||||
0, 0, 0, 1)
|
||||
"""
|
||||
params_base = {
|
||||
"denumire": denumire,
|
||||
"cui": cui,
|
||||
"adresa": adresa,
|
||||
}
|
||||
|
||||
new_id: Optional[int] = None
|
||||
last_err: Optional[oracledb.DatabaseError] = None
|
||||
for _attempt in range(2):
|
||||
cur.execute(
|
||||
f"SELECT NVL(MAX(id_part), 0) + 1 FROM {schema}.NOM_PARTENERI"
|
||||
)
|
||||
candidate = int(cur.fetchone()[0])
|
||||
try:
|
||||
cur.execute(insert_sql, {"id_part": candidate, **params_base})
|
||||
new_id = candidate
|
||||
break
|
||||
except oracledb.DatabaseError as e:
|
||||
err_code = e.args[0].code if e.args else None
|
||||
if err_code == 1: # ORA-00001 PK race
|
||||
last_err = e
|
||||
continue
|
||||
raise
|
||||
|
||||
if new_id is None:
|
||||
# Două încercări consecutive cu PK collision — escaladăm.
|
||||
if last_err is not None:
|
||||
raise last_err
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Nu s-a putut aloca id_part după 2 încercări",
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
except HTTPException:
|
||||
raise
|
||||
except oracledb.DatabaseError as e:
|
||||
err_code = e.args[0].code if e.args else None
|
||||
if err_code in (942, 1031):
|
||||
logger.critical(
|
||||
"create_partener: lipsă GRANT INSERT pe NOM_PARTENERI (schema=%s, ORA-%05d)",
|
||||
schema, err_code, exc_info=True,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Lipsă privilegii pe tabela NOM_PARTENERI; contactați administratorul.",
|
||||
)
|
||||
logger.error("create_partener Oracle error", exc_info=True)
|
||||
raise HTTPException(status_code=503, detail="Eroare la crearea partenerului")
|
||||
|
||||
return PartenerItem(id_part=new_id, denumire=denumire)
|
||||
|
||||
Reference in New Issue
Block a user