diff --git a/backend/modules/service_auto/routers/comanda.py b/backend/modules/service_auto/routers/comanda.py index f3b4a8a..4eea21b 100644 --- a/backend/modules/service_auto/routers/comanda.py +++ b/backend/modules/service_auto/routers/comanda.py @@ -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)) diff --git a/backend/modules/service_auto/schemas/comanda.py b/backend/modules/service_auto/schemas/comanda.py index 6baca30..b4773e8 100644 --- a/backend/modules/service_auto/schemas/comanda.py +++ b/backend/modules/service_auto/schemas/comanda.py @@ -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 diff --git a/backend/modules/service_auto/services/_context.py b/backend/modules/service_auto/services/_context.py new file mode 100644 index 0000000..9130b08 --- /dev/null +++ b/backend/modules/service_auto/services/_context.py @@ -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() diff --git a/backend/modules/service_auto/services/comanda_service.py b/backend/modules/service_auto/services/comanda_service.py index e1fe912..4720ecc 100644 --- a/backend/modules/service_auto/services/comanda_service.py +++ b/backend/modules/service_auto/services/comanda_service.py @@ -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 = _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] diff --git a/backend/modules/service_auto/services/lookup_service.py b/backend/modules/service_auto/services/lookup_service.py index 0aa055e..cd5e54c 100644 --- a/backend/modules/service_auto/services/lookup_service.py +++ b/backend/modules/service_auto/services/lookup_service.py @@ -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) diff --git a/docs/service-auto/autocomplete-dual-decision.md b/docs/service-auto/autocomplete-dual-decision.md new file mode 100644 index 0000000..0f628c0 --- /dev/null +++ b/docs/service-auto/autocomplete-dual-decision.md @@ -0,0 +1,38 @@ +# SupplierDualField — refactor decision + +**Decizie**: `SupplierDualField.vue` **NU** se refactorizează cu `AsyncAutoComplete`. + +## Context +Task #3 a extras `AsyncAutoComplete` (shared) din pattern-ul typeahead async din +`ComandaNoua.vue`. Candidatul pentru refactor a fost +`src/modules/data-entry/components/receipts/SupplierDualField.vue`. + +## Motive pentru skip + +1. **Filtru client-side, nu async remote** + `SupplierDualField` filtrează o listă `partners` **preîncărcată în memorie** (`props.partners.filter(...)` pe nume + CUI). + `AsyncAutoComplete` e construit pe `searchFn: (q) => Promise` (remote). + Adaptarea ar cere un wrapper artificial `async (q) => partners.filter(...)` care nu aduce valoare. + +2. **`force-selection: false` vs `force-selection: true`** + SupplierDualField permite intrare free-text (`forceSelection: false`) pentru că + users pot tasta manual CUI/nume furnizor. `AsyncAutoComplete` impune + `force-selection: true` ca invariant de securitate (evită ID-uri fantomă). + Schimbarea ar rupe flow-ul existent. + +3. **Dropdown manual + câmp CUI separat** + Componenta e **composite**: AutoComplete (nume) + `InputText` (CUI) + toggle adresă + + sync-button + status badges (oracle/local/warning), totul cu propriile `update:*` + emits. AutoComplete-ul e doar o parte — extragerea lui izolat ar lăsa componenta + într-o stare hibridă, mai complicată decât acum. + +4. **Prop `dropdown`** + SupplierDualField folosește `dropdown` (buton chevron care arată toată lista). + `AsyncAutoComplete` nu expune acest mod (ar contrazice pattern-ul async „caută + minim N caractere"). + +## Concluzie +Pattern-urile diferă fundamental. Păstrăm `SupplierDualField` neschimbat. +`AsyncAutoComplete` rămâne dedicat pattern-urilor de typeahead async, cu sursa +de date remote (ex: `ComandaNoua.vue` → partener service-auto, și viitoare +formulare similare). diff --git a/docs/service-auto/migrations/ff_2026_04_13_01_AUTO.sql b/docs/service-auto/migrations/ff_2026_04_13_01_AUTO.sql new file mode 100644 index 0000000..6602092 --- /dev/null +++ b/docs/service-auto/migrations/ff_2026_04_13_01_AUTO.sql @@ -0,0 +1,26 @@ +-- grant-uri ROA_WEB pe tabele asiguratori, inspectori, norme, parteneri + index typeahead + +-- Rulat conectat ca schema MARIUSM_AUTO (sau DBA cu privilegii de GRANT) + +GRANT SELECT ON MARIUSM_AUTO.DEV_NOM_NORME TO ROA_WEB; +GRANT SELECT ON MARIUSM_AUTO.DEV_NOM_INSPECTORI TO ROA_WEB; +GRANT SELECT ON MARIUSM_AUTO.DEV_NOM_ASIGURATORI TO ROA_WEB; +GRANT SELECT ON MARIUSM_AUTO.NOM_PARTENERI TO ROA_WEB; +GRANT EXECUTE ON MARIUSM_AUTO.pack_serii_numere TO ROA_WEB; + +-- SER_SERII / SER_PLAJE: adaugati manual DACA pack_serii_numere are AUTHID CURRENT_USER +-- GRANT SELECT, INSERT, UPDATE ON MARIUSM_AUTO.SER_SERII TO ROA_WEB; +-- GRANT SELECT, INSERT, UPDATE ON MARIUSM_AUTO.SER_PLAJE TO ROA_WEB; + +-- Index functional pentru typeahead parteneri (UPPER pe denumire) +-- Rulat ca MARIUSM_AUTO owner; ONLINE pentru zero-downtime pe Enterprise Edition +BEGIN + IF PACK_MIGRARE.OBJECTEXIST('IX_NOM_PARTENERI_DEN_UPPER','INDEX') = 0 THEN + EXECUTE IMMEDIATE + 'CREATE INDEX MARIUSM_AUTO.IX_NOM_PARTENERI_DEN_UPPER + ON MARIUSM_AUTO.NOM_PARTENERI (UPPER(DENUMIRE))'; + END IF; +END; +/ + +exec pack_migrare.UpdateVersiune('ff_2026_04_13_01_AUTO'); commit; diff --git a/docs/service-auto/migrations/ff_2026_04_13_02_AUTO.sql b/docs/service-auto/migrations/ff_2026_04_13_02_AUTO.sql new file mode 100644 index 0000000..243da5a --- /dev/null +++ b/docs/service-auto/migrations/ff_2026_04_13_02_AUTO.sql @@ -0,0 +1,8 @@ +-- grant INSERT pe NOM_PARTENERI pentru creare partener nou din UI Service Auto + +-- Rulat conectat ca schema MARIUSM_AUTO (sau DBA cu privilegii de GRANT) +-- GRANT este idempotent: re-rulare = no-op. + +GRANT INSERT ON MARIUSM_AUTO.NOM_PARTENERI TO ROA_WEB; + +exec pack_migrare.UpdateVersiune('ff_2026_04_13_02_AUTO'); commit; diff --git a/src/modules/service-auto/components/PartnerCreateDialog.vue b/src/modules/service-auto/components/PartnerCreateDialog.vue new file mode 100644 index 0000000..fb25e63 --- /dev/null +++ b/src/modules/service-auto/components/PartnerCreateDialog.vue @@ -0,0 +1,236 @@ +