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>
461 lines
17 KiB
Python
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)
|