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

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

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

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

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

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

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

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

461 lines
17 KiB
Python

"""
Lookup data for service_auto forms — tip deviz, masini, firme, asiguratori, inspectori, operatii, parteneri.
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
import oracledb
from fastapi import HTTPException
from shared.database.oracle_pool import oracle_pool
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_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):
entry: Optional[Tuple] = _cache.get(key)
if entry and (time.monotonic() - entry[0]) < ttl:
return entry[1]
return None
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],
server_id: Optional[str] = None,
) -> List[FirmaItem]:
"""
Returns firma names for the company IDs in the user's JWT.
Query pe `CONTAFIN_ORACLE.V_NOM_FIRME` pe serverul utilizatorului.
"""
if not company_ids:
return []
placeholders = ", ".join(f":id{i}" for i in range(len(company_ids)))
query = f"""
SELECT id_firma, firma, schema, id_mama
FROM CONTAFIN_ORACLE.V_NOM_FIRME
WHERE id_firma IN ({placeholders})
ORDER BY id_firma
"""
params = {f"id{i}": int(cid) for i, cid in enumerate(company_ids)}
try:
async with oracle_pool.get_connection(server_id) as conn:
with conn.cursor() as cur:
cur.execute(query, params)
rows = cur.fetchall()
except oracledb.DatabaseError:
logger.error("get_firme Oracle error", exc_info=True)
raise HTTPException(status_code=503, detail="Eroare la încărcarea firmelor")
return [
FirmaItem(id_firma=r[0], firma=r[1], schema_name=r[2] or "", id_mama=r[3])
for r in rows
]
@staticmethod
async def get_tip_deviz(
company_id: int,
server_id: Optional[str] = None,
) -> List[TipDevizItem]:
"""
Tip deviz din `{schema}.DEV_TIP_DEVIZ`. Cached 24 h per schema.
"""
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 = f"""
SELECT id_tip, denumire, inch_validare
FROM {schema}.DEV_TIP_DEVIZ
ORDER BY id_tip
"""
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_tip_deviz Oracle error", exc_info=True)
raise HTTPException(status_code=503, detail="Eroare la încărcarea tipurilor de deviz")
result = [
TipDevizItem(id_tip=r[0], denumire=r[1], inch_validare=r[2] or 0)
for r in rows
]
_cache_set(cache_key, result)
return result
@staticmethod
async def get_masini(
company_id: int,
server_id: Optional[str] = None,
) -> List[MasinaClientItem]:
"""
Mașini active din `{schema}.AUTO_VMASINICLIENTI`. Cached 5 min per schema.
"""
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 = f"""
SELECT id_masiniclient, nrinmat, marca, masina, anfabricatie, partener
FROM {schema}.AUTO_VMASINICLIENTI
WHERE inactiv = 0
ORDER BY partener, nrinmat
"""
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_masini Oracle error", exc_info=True)
raise HTTPException(status_code=503, detail="Eroare la încărcarea mașinilor")
result = []
for r in rows:
id_mc, nrinmat, marca, masina, an, partener = r
parts = []
if marca:
parts.append(marca)
if masina:
parts.append(masina)
vehicul = " ".join(parts) if parts else "?"
an_str = f" ({int(an)})" if an else ""
label = f"{partener or '?'}{vehicul}, {nrinmat or '?'}{an_str}"
result.append(MasinaClientItem(id_masiniclient=int(id_mc), label=label))
_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)