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