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,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]
|
||||
|
||||
Reference in New Issue
Block a user