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:
Claude Agent
2026-04-13 20:09:42 +00:00
parent ee6d857e9d
commit 4397027f36
13 changed files with 2089 additions and 377 deletions

View File

@@ -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))

View File

@@ -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

View 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()

View File

@@ -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]

View File

@@ -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)