diff --git a/backend/main.py b/backend/main.py index 26921f9..259b496 100644 --- a/backend/main.py +++ b/backend/main.py @@ -51,6 +51,7 @@ from shared.routes.system import create_system_router from backend.modules.reports.routers import create_reports_router from backend.modules.data_entry.routers import create_data_entry_router from backend.modules.telegram.routers import create_telegram_router +from backend.modules.service_auto.routers import create_service_auto_router # Configure logging (level from env: DEBUG, INFO, WARNING, ERROR) log_level = os.getenv('LOG_LEVEL', 'INFO').upper() @@ -609,6 +610,7 @@ app.add_middleware( app.include_router(create_reports_router(), prefix="/api/reports", tags=["reports"]) app.include_router(create_data_entry_router(), prefix="/api/data-entry", tags=["data-entry"]) app.include_router(create_telegram_router(), prefix="/api/telegram", tags=["telegram"]) +app.include_router(create_service_auto_router(), prefix="/api/service-auto", tags=["service-auto"]) # Shared routers auth_router = create_auth_router(prefix="", tags=["authentication"]) diff --git a/backend/modules/service_auto/__init__.py b/backend/modules/service_auto/__init__.py index e69de29..5c1d44a 100644 --- a/backend/modules/service_auto/__init__.py +++ b/backend/modules/service_auto/__init__.py @@ -0,0 +1,13 @@ +import logging +from pathlib import Path + +_LOG_DIR = Path(__file__).resolve().parents[2] / 'logs' +_LOG_DIR.mkdir(parents=True, exist_ok=True) + +logger = logging.getLogger('service_auto') +logger.propagate = False +if not logger.handlers: + fh = logging.FileHandler(_LOG_DIR / 'service_auto.log') + fh.setFormatter(logging.Formatter('%(asctime)s %(levelname)s %(name)s: %(message)s')) + logger.addHandler(fh) +logger.setLevel(logging.INFO) diff --git a/backend/modules/service_auto/routers/__init__.py b/backend/modules/service_auto/routers/__init__.py index e69de29..64a861f 100644 --- a/backend/modules/service_auto/routers/__init__.py +++ b/backend/modules/service_auto/routers/__init__.py @@ -0,0 +1,13 @@ +"""Service Auto module router factory.""" + +from fastapi import APIRouter + + +def create_service_auto_router() -> APIRouter: + router = APIRouter() + + from .comanda import router as comanda_router + + router.include_router(comanda_router, tags=["service-auto"]) + + return router diff --git a/backend/modules/service_auto/routers/comanda.py b/backend/modules/service_auto/routers/comanda.py new file mode 100644 index 0000000..ad35800 --- /dev/null +++ b/backend/modules/service_auto/routers/comanda.py @@ -0,0 +1,67 @@ +import time +from typing import List + +import oracledb +from fastapi import APIRouter, Depends, HTTPException + +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 ( + ComandaRequest, ComandaResponse, + FirmaItem, TipDevizItem, MasinaClientItem, +) +from ..services.comanda_service import ComandaService +from ..services.lookup_service import LookupService + +router = APIRouter() + + +@router.get("/ping") +async def ping(_: CurrentUser = Depends(get_current_user)): + """Health check: verifies Oracle connectivity for mariusm_test server.""" + t0 = time.perf_counter() + try: + async with oracle_pool.get_connection('mariusm_test') 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} + + +@router.get("/firme", response_model=List[FirmaItem]) +async def get_firme(current_user: CurrentUser = Depends(get_current_user)): + """Firmele accesibile utilizatorului curent (din JWT companies[]).""" + return await LookupService.get_firme(current_user.companies) + + +@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() + + +@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() + + +@router.post("/comenzi", response_model=ComandaResponse) +async def creeaza_comanda( + data: ComandaRequest, + current_user: CurrentUser = Depends(get_current_user), +): + try: + return await ComandaService.creeaza_comanda( + data=data, + username=current_user.username, + ) + except NotImplementedError as e: + raise HTTPException(status_code=501, detail=str(e)) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) diff --git a/backend/modules/service_auto/schemas/comanda.py b/backend/modules/service_auto/schemas/comanda.py new file mode 100644 index 0000000..8fe45cd --- /dev/null +++ b/backend/modules/service_auto/schemas/comanda.py @@ -0,0 +1,31 @@ +from pydantic import BaseModel + + +class ComandaRequest(BaseModel): + tip_id: int + id_masiniclient: int + solicitari: str + id_firma: int + + +class ComandaResponse(BaseModel): + id_ordl: int + nrord: str + mesaj: str + + +class FirmaItem(BaseModel): + id_firma: int + firma: str + schema_name: str + + +class TipDevizItem(BaseModel): + id_tip: int + denumire: str + inch_validare: int + + +class MasinaClientItem(BaseModel): + id_masiniclient: int + label: str # "PARTENER — MARCA MASINA, NRINMAT (ANFABRICATIE)" diff --git a/backend/modules/service_auto/services/comanda_service.py b/backend/modules/service_auto/services/comanda_service.py new file mode 100644 index 0000000..4f32ca2 --- /dev/null +++ b/backend/modules/service_auto/services/comanda_service.py @@ -0,0 +1,109 @@ +import re +from typing import NoReturn + +import oracledb +from fastapi import HTTPException +from shared.database.oracle_pool import oracle_pool +from ..schemas.comanda import ComandaRequest, ComandaResponse +from .. import logger + + +def _handle_oracle_error(e: Exception) -> NoReturn: + """ + Map Oracle error codes to FastAPI HTTPExceptions. Always raises. + + Code ranges: + 20001-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) + """ + err = e.args[0] + code = getattr(err, "code", 0) + raw_message = getattr(err, "message", str(e)) + + if 20001 <= code <= 20999: + # Strip "ORA-2xxxx: " prefix injected by Oracle; expose the business message only + clean = re.sub(r"^ORA-\d+:\s*", "", raw_message).strip() + raise HTTPException(status_code=422, detail=clean) + + if code in (12541, 12170, 12154, 12560): + raise HTTPException( + status_code=503, + detail="Serviciul bazei de date e temporar indisponibil", + ) + + if code == 1017: + logger.critical("Oracle credentials rejected (ORA-01017)", exc_info=True) + raise HTTPException( + status_code=500, + detail="Eroare de configurare. Contactați administratorul.", + ) + + if code == 942: + logger.critical("Oracle object not found or grant missing (ORA-00942)", exc_info=True) + raise HTTPException( + status_code=500, + detail="Eroare internă. Contactați administratorul.", + ) + + logger.error("Unexpected Oracle error ORA-%05d", code, exc_info=True) + raise HTTPException(status_code=500, detail="Eroare internă neașteptată") + + +class ComandaService: + @staticmethod + async def creeaza_comanda( + data: ComandaRequest, + username: str, + ) -> ComandaResponse: + logger.info( + "service_auto.create_comanda START", + extra={ + "user": username, + "tip": data.tip_id, + "client_id": data.id_masiniclient, + "id_firma": data.id_firma, + }, + ) + + async with oracle_pool.get_connection("mariusm_test") as connection: + try: + with connection.cursor() as cursor: + out_id_ordl = cursor.var(oracledb.NUMBER) + out_nrord = cursor.var(oracledb.STRING) + + cursor.callproc( + "MARIUSM_AUTO.SP_CREEAZA_COMANDA_PROTOTIP", + [ + data.tip_id, # p_tip IN NUMBER + data.id_masiniclient, # p_id_masiniclient IN NUMBER + data.solicitari, # p_solicitari IN VARCHAR2 + data.id_firma, # p_id_firma IN NUMBER + out_id_ordl, # p_id_ordl OUT NUMBER + out_nrord, # p_nrord OUT VARCHAR2 + ], + ) + + connection.commit() + + id_ordl = int(out_id_ordl.getvalue()) + nrord = out_nrord.getvalue() or "" + except oracledb.DatabaseError as e: + try: + connection.rollback() + except Exception: + pass # connection may be dead on network errors; ignore + _handle_oracle_error(e) + + logger.info( + "service_auto.create_comanda OK", + extra={"user": username, "id_ordl": id_ordl, "nrord": nrord}, + ) + + return ComandaResponse( + id_ordl=id_ordl, + nrord=nrord, + mesaj=f"Comanda {nrord} creata cu succes.", + ) diff --git a/backend/modules/service_auto/services/lookup_service.py b/backend/modules/service_auto/services/lookup_service.py new file mode 100644 index 0000000..fc7f324 --- /dev/null +++ b/backend/modules/service_auto/services/lookup_service.py @@ -0,0 +1,110 @@ +""" +Lookup data for service_auto forms — tip deviz, masini, firme. + +All three endpoints are read-only and infrequently changing. +""" +from typing import List + +import oracledb +from fastapi import HTTPException +from shared.database.oracle_pool import oracle_pool + +from ..schemas.comanda import FirmaItem, MasinaClientItem, TipDevizItem +from .. import logger + + +class LookupService: + + @staticmethod + async def get_firme(company_ids: List[str]) -> List[FirmaItem]: + """ + Returns firma names for the company IDs in the user's JWT. + Uses 'central' pool (CONTAFIN_ORACLE) to query V_NOM_FIRME. + """ + if not company_ids: + return [] + + placeholders = ", ".join(f":id{i}" for i in range(len(company_ids))) + query = f""" + SELECT id_firma, firma, schema + 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("central") 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 "") + for r in rows + ] + + @staticmethod + async def get_tip_deviz() -> List[TipDevizItem]: + """ + Returns all active tip deviz from MARIUSM_AUTO.DEV_TIP_DEVIZ. + ROA_WEB has SELECT grant on this view. + """ + query = """ + SELECT id_tip, denumire, inch_validare + FROM MARIUSM_AUTO.DEV_TIP_DEVIZ + ORDER BY id_tip + """ + try: + async with oracle_pool.get_connection("mariusm_test") 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") + + return [ + TipDevizItem(id_tip=r[0], denumire=r[1], inch_validare=r[2] or 0) + for r in rows + ] + + @staticmethod + async def get_masini() -> List[MasinaClientItem]: + """ + Returns active masini from MARIUSM_AUTO.AUTO_VMASINICLIENTI. + ROA_WEB has SELECT grant on this view. + Label format: "PARTENER — MARCA MASINA, NRINMAT (ANFABRICATIE)" + """ + query = """ + SELECT id_masiniclient, nrinmat, marca, masina, anfabricatie, partener + FROM MARIUSM_AUTO.AUTO_VMASINICLIENTI + WHERE inactiv = 0 + ORDER BY partener, nrinmat + """ + try: + async with oracle_pool.get_connection("mariusm_test") 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)) + + return result diff --git a/backend/modules/service_auto/tests/__init__.py b/backend/modules/service_auto/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/modules/service_auto/tests/test_comanda_persist.py b/backend/modules/service_auto/tests/test_comanda_persist.py new file mode 100644 index 0000000..a8bcf87 --- /dev/null +++ b/backend/modules/service_auto/tests/test_comanda_persist.py @@ -0,0 +1,139 @@ +""" +Integration test: SP_CREEAZA_COMANDA_PROTOTIP persist + reconnect verify — Săpt 9-10 + +Verifies that: +1. callproc SP_CREEAZA_COMANDA_PROTOTIP writes a row to DEV_ORDL +2. After commit + disconnect, a NEW connection sees the row (true durability) +3. Cleanup removes all prototype rows from DEV_ORDL and NOM_LUCRARI + +Run: + cd /workspace/roa2web + python -m pytest backend/modules/service_auto/tests/test_comanda_persist.py -v -m integration +""" +import os +import sys +import time + +import pytest +import oracledb + +# Add backend to path so secrets/ is accessible +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..', '..')) + +HOST = "10.0.20.121" +PORT = 1521 +SERVICE_NAME = "ROA" +USER = "CONTAFIN_ORACLE" +SECRETS_FILE = os.path.join( + os.path.dirname(__file__), '..', '..', '..', 'secrets', 'central.oracle_pass' +) + + +def _read_password() -> str: + with open(SECRETS_FILE) as f: + return f.read().strip() + + +def _connect() -> oracledb.Connection: + return oracledb.connect( + user=USER, + password=_read_password(), + host=HOST, + port=PORT, + service_name=SERVICE_NAME, + ) + + +@pytest.mark.integration +def test_comanda_persist_and_reconnect(): + """ + Full round-trip: callproc → commit → close → NEW connection → SELECT → assert exists. + Cleans up all inserted rows after verification. + """ + # --- Phase 1: Call SP and commit --- + conn1 = _connect() + id_ordl = None + id_lucrare = None + try: + with conn1.cursor() as cursor: + out_id_ordl = cursor.var(oracledb.NUMBER) + out_nrord = cursor.var(oracledb.STRING) + + t0 = time.perf_counter() + cursor.callproc( + "MARIUSM_AUTO.SP_CREEAZA_COMANDA_PROTOTIP", + [ + 3, # p_tip IN NUMBER + 200, # p_id_masiniclient IN NUMBER + 'Test pytest persist', # p_solicitari IN VARCHAR2 + 110, # p_id_firma IN NUMBER + out_id_ordl, # p_id_ordl OUT NUMBER + out_nrord, # p_nrord OUT VARCHAR2 + ], + ) + t_callproc = time.perf_counter() - t0 + + id_ordl = int(out_id_ordl.getvalue()) + nrord = out_nrord.getvalue() or "" + print(f"\n[PERSIST] callproc OK in {t_callproc*1000:.1f}ms → id_ordl={id_ordl} nrord={nrord}") + + assert id_ordl > 0, f"Expected positive id_ordl, got {id_ordl}" + assert nrord.startswith("P"), f"Expected nrord starting with 'P', got {nrord!r}" + + conn1.commit() + print(f"[PERSIST] commit OK on connection 1") + finally: + conn1.close() + print("[PERSIST] connection 1 closed") + + # --- Phase 2: Open NEW connection and verify row exists --- + assert id_ordl is not None, "id_ordl must be set before reconnect phase" + + conn2 = _connect() + try: + with conn2.cursor() as cursor: + cursor.execute( + "SELECT id_ordl, id_lucrare FROM MARIUSM_AUTO.DEV_ORDL WHERE id_ordl = :id", + {"id": id_ordl}, + ) + row = cursor.fetchone() + + print(f"[PERSIST] NEW connection SELECT → row={row}") + assert row is not None, f"Row id_ordl={id_ordl} not found after reconnect — durability FAILED" + assert int(row[0]) == id_ordl, f"id_ordl mismatch: got {row[0]}, expected {id_ordl}" + id_lucrare = int(row[1]) + print(f"[PERSIST] ASSERT PASSED: row exists in new connection ✅ (id_lucrare={id_lucrare})") + finally: + conn2.close() + print("[PERSIST] connection 2 closed") + + # --- Phase 3: Cleanup — delete child then parent --- + assert id_lucrare is not None, "id_lucrare must be set for cleanup" + + conn3 = _connect() + try: + with conn3.cursor() as cursor: + # Child first (FK constraint: DEV_ORDL → NOM_LUCRARI) + cursor.execute( + "DELETE FROM MARIUSM_AUTO.DEV_ORDL WHERE id_ordl = :id", + {"id": id_ordl}, + ) + deleted_ordl = cursor.rowcount + print(f"[PERSIST] DELETE DEV_ORDL id_ordl={id_ordl} → {deleted_ordl} row(s)") + + # Parent second + cursor.execute( + "DELETE FROM MARIUSM_AUTO.NOM_LUCRARI WHERE id_lucrare = :id", + {"id": id_lucrare}, + ) + deleted_nom = cursor.rowcount + print(f"[PERSIST] DELETE NOM_LUCRARI id_lucrare={id_lucrare} → {deleted_nom} row(s)") + + conn3.commit() + print("[PERSIST] cleanup commit OK ✅") + + assert deleted_ordl == 1, f"Expected 1 DEV_ORDL row deleted, got {deleted_ordl}" + assert deleted_nom == 1, f"Expected 1 NOM_LUCRARI row deleted, got {deleted_nom}" + finally: + conn3.close() + print("[PERSIST] connection 3 (cleanup) closed") diff --git a/backend/modules/service_auto/tests/test_diacritice_encoding.py b/backend/modules/service_auto/tests/test_diacritice_encoding.py new file mode 100644 index 0000000..d65e611 --- /dev/null +++ b/backend/modules/service_auto/tests/test_diacritice_encoding.py @@ -0,0 +1,109 @@ +""" +Integration test — Hypothesis #4: diacritice encoding end-to-end. + +Verifies that RAISE_APPLICATION_ERROR(-20001, 'mesaj cu ă î ș ț â') flows correctly +through the full chain: Oracle → oracledb → _handle_oracle_error → HTTPException.detail + +Two layers tested: + L1 (live Oracle) — PL/SQL anonymous block raises ORA-20001 with diacritice; + oracledb.DatabaseError.args[0].message must contain them intact. + L2 (_handle_oracle_error) — the HTTPException.detail must contain diacritice, + ORA prefix stripped. + +No HTTP server needed. Requires live Oracle connection (mariusm_test pool credentials). + +Run: + cd /workspace/roa2web + python -m pytest backend/modules/service_auto/tests/test_diacritice_encoding.py -v -m integration +""" +import os +import sys +import pytest +import oracledb +from fastapi import HTTPException + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..', '..')) + +from backend.modules.service_auto.services.comanda_service import _handle_oracle_error + +HOST = "10.0.20.121" +PORT = 1521 +SERVICE_NAME = "ROA" +SECRETS_FILE = os.path.join( + os.path.dirname(__file__), '..', '..', '..', 'secrets', 'mariusm_test.oracle_pass' +) +USER = "ROA_WEB" + +# Romanian diacritice in error message — full set +DIACRITICE_MSG = "Client invalid: ă î ș ț â Ă Î Ș Ț Â" + + +@pytest.fixture(scope="module") +def oracle_conn(): + """Live connection as ROA_WEB for the duration of this test module.""" + with open(SECRETS_FILE) as f: + pwd = f.read().strip() + conn = oracledb.connect(user=USER, password=pwd, host=HOST, port=PORT, service_name=SERVICE_NAME) + yield conn + conn.close() + + +@pytest.mark.integration +def test_l1_oracle_encodes_diacritice_in_raise_application_error(oracle_conn): + """ + L1: Oracle sends diacritice in RAISE_APPLICATION_ERROR message. + oracledb decodes them correctly through the NLS chain. + """ + raised = None + with oracle_conn.cursor() as cur: + try: + cur.execute( + f"BEGIN RAISE_APPLICATION_ERROR(-20001, :msg); END;", + {"msg": DIACRITICE_MSG}, + ) + except oracledb.DatabaseError as e: + raised = e + + assert raised is not None, "Expected ORA-20001 to be raised" + err = raised.args[0] + assert err.code == 20001, f"Expected code 20001, got {err.code}" + + # Every Romanian diacritic character must survive the Oracle → Python round-trip + for char in "ăîșțâĂÎȘȚÂ": + assert char in err.message, ( + f"Diacritic '{char}' lost in Oracle→oracledb encoding. " + f"Full message: {err.message!r}" + ) + + +@pytest.mark.integration +def test_l2_handle_oracle_error_preserves_diacritice_in_http_detail(oracle_conn): + """ + L2: _handle_oracle_error strips ORA prefix and passes diacritice into HTTPException.detail. + """ + raised_db = None + with oracle_conn.cursor() as cur: + try: + cur.execute( + "BEGIN RAISE_APPLICATION_ERROR(-20001, :msg); END;", + {"msg": DIACRITICE_MSG}, + ) + except oracledb.DatabaseError as e: + raised_db = e + + assert raised_db is not None + + # Pass through the same handler used in production + with pytest.raises(HTTPException) as exc_info: + _handle_oracle_error(raised_db) + + http_exc = exc_info.value + assert http_exc.status_code == 422 + assert not http_exc.detail.startswith("ORA-"), ( + f"ORA prefix not stripped from detail: {http_exc.detail!r}" + ) + for char in "ăîșțâĂÎȘȚÂ": + assert char in http_exc.detail, ( + f"Diacritic '{char}' lost in _handle_oracle_error. " + f"detail: {http_exc.detail!r}" + ) diff --git a/backend/modules/service_auto/tests/test_error_mapping.py b/backend/modules/service_auto/tests/test_error_mapping.py new file mode 100644 index 0000000..dfab3ce --- /dev/null +++ b/backend/modules/service_auto/tests/test_error_mapping.py @@ -0,0 +1,97 @@ +""" +Unit tests — Oracle error code → HTTP status mapping. + +Tests _handle_oracle_error() directly. No live Oracle connection required. + +Covered mappings: + ORA-20001 … ORA-20999 → HTTP 422 (business errors from RAISE_APPLICATION_ERROR) + ORA-12541/12170/12154/12560 → HTTP 503 (Oracle network / TNS unreachable) + ORA-01017 → HTTP 500 (bad credentials) + ORA-00942 → HTTP 500 (missing object / grant) + ORA- → HTTP 500 (unexpected error) +""" +import pytest +from unittest.mock import MagicMock +from fastapi import HTTPException + +from backend.modules.service_auto.services.comanda_service import _handle_oracle_error + + +def _make_oracle_error(code: int, msg: str = "test error"): + """ + Build a minimal mock that mimics oracledb.DatabaseError structure. + _handle_oracle_error only accesses e.args[0].code and e.args[0].message, + so a MagicMock is sufficient — no live oracledb import needed. + """ + err_obj = MagicMock() + err_obj.code = code + err_obj.message = f"ORA-{code:05d}: {msg}" + exc = MagicMock() + exc.args = (err_obj,) + return exc + + +# --------------------------------------------------------------------------- +# Parametrised: code → expected HTTP status + fragment in detail +# --------------------------------------------------------------------------- +@pytest.mark.parametrize("code, status, detail_fragment", [ + # Business errors — RAISE_APPLICATION_ERROR range + (20001, 422, "mesaj business 20001"), + (20500, 422, "mesaj business 20500"), + (20999, 422, "mesaj business 20999"), + # Oracle network / TNS unreachable + (12541, 503, "indisponibil"), + (12170, 503, "indisponibil"), + (12154, 503, "indisponibil"), + (12560, 503, "indisponibil"), + # Credential / config errors + (1017, 500, "configurare"), + # Missing object / grant + (942, 500, "internă"), + # Catch-all unexpected errors + (4031, 500, "neașteptată"), # ORA-04031: unable to allocate memory + (600, 500, "neașteptată"), # ORA-00600: internal error +]) +def test_error_mapping(code, status, detail_fragment): + msg = f"mesaj business {code}" if 20001 <= code <= 20999 else "test error" + exc = _make_oracle_error(code, msg) + + with pytest.raises(HTTPException) as exc_info: + _handle_oracle_error(exc) + + http_exc = exc_info.value + assert http_exc.status_code == status, ( + f"ORA-{code:05d} → expected HTTP {status}, got {http_exc.status_code}" + ) + assert detail_fragment in http_exc.detail, ( + f"ORA-{code:05d} detail: expected '{detail_fragment}' in '{http_exc.detail}'" + ) + + +# --------------------------------------------------------------------------- +# ORA-20xxx: verify ORA prefix is stripped from business message +# --------------------------------------------------------------------------- +def test_20001_strips_ora_prefix(): + """The 422 detail must not contain the 'ORA-20001:' prefix.""" + exc = _make_oracle_error(20001, "Mai exista o comanda cu numarul P01-42") + with pytest.raises(HTTPException) as exc_info: + _handle_oracle_error(exc) + detail = exc_info.value.detail + assert not detail.startswith("ORA-"), ( + f"ORA prefix not stripped: '{detail}'" + ) + assert "Mai exista" in detail + + +# --------------------------------------------------------------------------- +# Edge case: missing code attribute (e.g. thin-mode edge case) +# --------------------------------------------------------------------------- +def test_no_code_attribute_maps_to_500(): + """If the error object has no .code, fall through to generic 500.""" + err_obj = MagicMock(spec=[]) # spec=[] means NO attributes + exc = MagicMock() + exc.args = (err_obj,) + + with pytest.raises(HTTPException) as exc_info: + _handle_oracle_error(exc) + assert exc_info.value.status_code == 500 diff --git a/backend/modules/service_auto/tests/test_grants_integration.py b/backend/modules/service_auto/tests/test_grants_integration.py new file mode 100644 index 0000000..9cde648 --- /dev/null +++ b/backend/modules/service_auto/tests/test_grants_integration.py @@ -0,0 +1,161 @@ +""" +Integration tests — Hypothesis #3: ROA_WEB grant-scoped access. + +Proves: + - ROA_WEB cannot INSERT directly into MARIUSM_AUTO.NOM_LUCRARI → ORA-01031 or ORA-00942 + - ROA_WEB cannot SELECT directly from MARIUSM_AUTO.NOM_LUCRARI → ORA-00942 + - ROA_WEB CAN execute SP_CREEAZA_COMANDA_PROTOTIP via EXECUTE grant → no privilege error + +All tests skip gracefully when: + - backend/secrets/roa_web.oracle_pass is absent (user not yet created) + - ORA-01017 on connect (user doesn't exist / wrong password) + +Reference: docs/service-auto/grants-audit.md §3.1 +""" +import os +import pytest +import oracledb + +# --------------------------------------------------------------------------- +# Oracle connection constants (MARIUSM_AUTO on central server) +# --------------------------------------------------------------------------- +_HOST = "10.0.20.121" +_PORT = 1521 +_SERVICE_NAME = "ROA" +_ROA_WEB_USER = "ROA_WEB" +_SECRETS_DIR = os.path.normpath( + os.path.join(os.path.dirname(__file__), "..", "..", "..", "secrets") +) + + +def _read_roa_web_password() -> str | None: + """Return ROA_WEB password from secrets file, or None if not present.""" + path = os.path.join(_SECRETS_DIR, "roa_web.oracle_pass") + if not os.path.exists(path): + return None + with open(path) as f: + return f.read().strip() or None + + +# --------------------------------------------------------------------------- +# Shared fixture — one connection per test module +# --------------------------------------------------------------------------- +@pytest.fixture(scope="module") +def roa_web_connection(): + """ + Yields a sync oracledb.Connection as ROA_WEB. + Skips the whole module if the user does not yet exist on the server. + """ + password = _read_roa_web_password() + if not password: + pytest.skip( + "ROA_WEB password file not found in backend/secrets/ — " + "user not yet created (Phase B deferred, see grants-audit.md §3)" + ) + + try: + conn = oracledb.connect( + user=_ROA_WEB_USER, + password=password, + host=_HOST, + port=_PORT, + service_name=_SERVICE_NAME, + ) + except oracledb.DatabaseError as e: + err = e.args[0] + code = getattr(err, "code", 0) + if code == 1017: + pytest.skip( + f"ROA_WEB login failed (ORA-01017: invalid username/password) — " + "user not yet created on Oracle server" + ) + raise # unexpected error — let it surface + + yield conn + conn.close() + + +# --------------------------------------------------------------------------- +# Negative tests — direct DML/SELECT must be rejected +# --------------------------------------------------------------------------- +@pytest.mark.integration +def test_insert_direct_fails(roa_web_connection): + """ + ROA_WEB has no INSERT privilege on NOM_LUCRARI. + Expected: ORA-01031 (insufficient privileges) or ORA-00942 (no table grant). + """ + conn = roa_web_connection + with conn.cursor() as cur: + with pytest.raises(oracledb.DatabaseError) as exc_info: + cur.execute( + "INSERT INTO MARIUSM_AUTO.NOM_LUCRARI (nrord, id_mod) " + "VALUES ('TEST_GRANT_PROBE', 1200)" + ) + err = exc_info.value.args[0] + assert err.code in (1031, 942), ( + f"Expected ORA-01031 or ORA-00942, got ORA-{err.code}: {err.message}" + ) + + +@pytest.mark.integration +def test_select_direct_fails(roa_web_connection): + """ + ROA_WEB has no SELECT privilege on NOM_LUCRARI. + Expected: ORA-00942 (table or view does not exist). + """ + conn = roa_web_connection + with conn.cursor() as cur: + with pytest.raises(oracledb.DatabaseError) as exc_info: + cur.execute( + "SELECT COUNT(*) FROM MARIUSM_AUTO.NOM_LUCRARI WHERE ROWNUM < 2" + ) + cur.fetchone() + err = exc_info.value.args[0] + assert err.code == 942, ( + f"Expected ORA-00942, got ORA-{err.code}: {err.message}" + ) + + +# --------------------------------------------------------------------------- +# Positive test — SP execution must succeed (EXECUTE grant) +# --------------------------------------------------------------------------- +@pytest.mark.integration +def test_exec_sp_succeeds(roa_web_connection): + """ + ROA_WEB has EXECUTE on SP_CREEAZA_COMANDA_PROTOTIP. + The SP call must not raise ORA-01031 (insufficient privileges). + A FK violation (ORA-02291) is acceptable — it proves the SP was reached. + Transaction is always rolled back — no test data left in prod. + """ + conn = roa_web_connection + with conn.cursor() as cur: + out_id_ordl = cur.var(oracledb.NUMBER) + out_nrord = cur.var(oracledb.STRING) + + try: + cur.callproc( + "MARIUSM_AUTO.SP_CREEAZA_COMANDA_PROTOTIP", + [ + 1, # p_tip (FK DEV_TIP_DEVIZ) + 1, # p_id_masiniclient (placeholder) + "TEST GRANT PROBE", # p_solicitari + 1, # p_id_firma + out_id_ordl, + out_nrord, + ], + ) + except oracledb.DatabaseError as e: + conn.rollback() + err = e.args[0] + # ORA-02291: FK violation — SP ran, data was bad → EXECUTE grant works + if err.code == 2291: + return + # ORA-01031: execution was blocked → grant missing → test must fail + raise + + conn.rollback() # ALWAYS rollback — never persist test data + + id_ordl = out_id_ordl.getvalue() + nrord = out_nrord.getvalue() + assert id_ordl is not None, "SP must return a non-null p_id_ordl" + assert nrord, "SP must return a non-empty p_nrord" diff --git a/backend/modules/service_auto/tests/test_pool_concurrency.py b/backend/modules/service_auto/tests/test_pool_concurrency.py new file mode 100644 index 0000000..3cad131 --- /dev/null +++ b/backend/modules/service_auto/tests/test_pool_concurrency.py @@ -0,0 +1,154 @@ +""" +Integration test — Hypothesis #2: oracle pool concurrency — no state leak between connections. + +Verifies that: + 1. Two concurrent asyncio tasks can acquire connections from the same pool simultaneously + 2. Each connection operates independently (no shared cursor state, no cross-connection leakage) + 3. session_callback (if set) runs per-connection, not per-pool + +Uses the 'mariusm_test' pool (ROA_WEB user). Requires live Oracle connection. + +Run: + cd /workspace/roa2web + python -m pytest backend/modules/service_auto/tests/test_pool_concurrency.py -v -m integration +""" +import asyncio +import os +import sys +import time + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..', '..')) + +from shared.database.oracle_pool import OracleMultiPool + +SECRETS_FILE = os.path.join( + os.path.dirname(__file__), '..', '..', '..', 'secrets', 'mariusm_test.oracle_pass' +) +HOST = "10.0.20.121" +PORT = 1521 +SERVICE_NAME = "ROA" +USER = "ROA_WEB" + + +@pytest.fixture(scope="module") +def pool(): + """Dedicated OracleMultiPool instance (not the global one) for isolation.""" + with open(SECRETS_FILE) as f: + pwd = f.read().strip() + p = OracleMultiPool.__new__(OracleMultiPool) + p._pools = {} + p._pool_configs = {} + p._initialized = False + import asyncio as _asyncio + p._pool_lock = _asyncio.Lock() + p.register_server( + server_id="concurrency_test", + host=HOST, port=PORT, + user=USER, password=pwd, + service_name=SERVICE_NAME, + min_connections=2, + max_connections=5, + ) + yield p + # Cleanup + import asyncio as _asyncio + _asyncio.get_event_loop().run_until_complete(p.close_pool()) + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_two_concurrent_connections_return_correct_results(pool): + """ + Two asyncio tasks run simultaneously on the same pool. + Each uses a different bind value — results must not cross. + """ + results = {} + + async def query_task(task_id: int, expected_val: int): + async with pool.get_connection("concurrency_test") as conn: + with conn.cursor() as cur: + # Short sleep to maximise overlap window + await asyncio.sleep(0.01) + cur.execute("SELECT :v FROM DUAL", {"v": expected_val}) + row = cur.fetchone() + results[task_id] = row[0] + + await asyncio.gather( + query_task(1, 111), + query_task(2, 222), + ) + + assert results[1] == 111, f"Task 1 expected 111, got {results[1]}" + assert results[2] == 222, f"Task 2 expected 222, got {results[2]}" + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_session_callback_runs_per_connection(pool): + """ + Register a session_callback that writes to a list. + Verify it fires each time a new connection is acquired. + session_callback must NOT bleed state across connections. + """ + callback_log = [] + + def schema_callback(connection, requested_tag): + """Simulates ALTER SESSION SET CURRENT_SCHEMA; logs invocation.""" + callback_log.append(id(connection)) + + # Register a second server config with session_callback + with open(SECRETS_FILE) as f: + pwd = f.read().strip() + + pool.register_server( + server_id="cb_test", + host=HOST, port=PORT, + user=USER, password=pwd, + service_name=SERVICE_NAME, + min_connections=1, + max_connections=3, + session_callback=schema_callback, + ) + + # Acquire two connections sequentially (pool min=1 so first reuses, second may create) + conn_ids = [] + async with pool.get_connection("cb_test") as conn: + conn_ids.append(id(conn)) + async with pool.get_connection("cb_test") as conn: + conn_ids.append(id(conn)) + + # Callback must have fired at least once (at pool creation / first acquire) + assert len(callback_log) >= 1, ( + "session_callback never called — pool did not invoke it on connection creation" + ) + + await pool.close_pool("cb_test") + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_ten_concurrent_queries_no_errors(pool): + """ + Stress: 10 concurrent queries on pool with max_connections=5. + All must complete without errors (pool queues excess requests via POOL_GETMODE_WAIT). + """ + errors = [] + + async def single_query(i: int): + try: + async with pool.get_connection("concurrency_test") as conn: + with conn.cursor() as cur: + cur.execute("SELECT :i FROM DUAL", {"i": i}) + val = cur.fetchone()[0] + assert val == i, f"Expected {i}, got {val}" + except Exception as e: + errors.append(f"task {i}: {e}") + + t0 = time.perf_counter() + await asyncio.gather(*[single_query(i) for i in range(10)]) + elapsed = time.perf_counter() - t0 + + assert not errors, f"Errors in concurrent queries: {errors}" + print(f"\n[CONCURRENCY] 10 queries completed in {elapsed*1000:.0f}ms, no errors ✅") diff --git a/docs/service-auto/SP_CREEAZA_COMANDA_PROTOTIP.sql b/docs/service-auto/SP_CREEAZA_COMANDA_PROTOTIP.sql new file mode 100644 index 0000000..e974bc9 --- /dev/null +++ b/docs/service-auto/SP_CREEAZA_COMANDA_PROTOTIP.sql @@ -0,0 +1,68 @@ +-- ============================================================================= +-- SP_CREEAZA_COMANDA_PROTOTIP — Săpt 3 prototype +-- ============================================================================= +-- Creează o comandă minimă de service auto: insert NOM_LUCRARI (parent) + +-- DEV_ORDL (child) într-o singură tranzacție. +-- +-- Scope: PROTOTYPE ONLY. Nu înlocuiește pack_auto.dev_adauga_lucrare. Nu +-- agreghează norme, nu actualizează DEV_MASINICLIENTI (kmint/ore), nu setează +-- inspector/asigurator, nu generează PROC_TVAV prin pack_contafin. +-- +-- Nrord: generat din SEQ_NR_LUCRARE (nefolosită în producție) cu prefix 'P' + +-- id_firma — garantat unic, ușor de identificat pentru cleanup. +-- +-- Autor: oracle-agent, team service-auto-sapt3 (2026-04-11) +-- ============================================================================= + +CREATE OR REPLACE PROCEDURE MARIUSM_AUTO.SP_CREEAZA_COMANDA_PROTOTIP( + p_tip IN NUMBER, -- FK DEV_TIP_DEVIZ.ID_TIP + p_id_masiniclient IN NUMBER, -- FK DEV_MASINICLIENTI.ID_MASINICLIENT + p_solicitari IN VARCHAR2, -- text liber, stored as CLOB + p_id_firma IN NUMBER, -- multi-tenant marker (prototype only) + p_id_ordl OUT NUMBER, -- new DEV_ORDL.ID_ORDL (from trigger) + p_nrord OUT VARCHAR2 -- generated order number (echo) +) AS + v_id_lucrare NUMBER; + v_seq NUMBER; + v_exists NUMBER; + v_now DATE := SYSDATE; +BEGIN + -- 1. Generate nrord (atomic via sequence) + v_seq := MARIUSM_AUTO.SEQ_NR_LUCRARE.NEXTVAL; + p_nrord := 'P' || LPAD(p_id_firma, 2, '0') || '-' || v_seq; + + -- 2. Duplicate guard (paritate cu pack_auto — should never fire) + SELECT COUNT(*) INTO v_exists + FROM MARIUSM_AUTO.NOM_LUCRARI + WHERE sters = 0 AND nrord = p_nrord; + IF v_exists > 0 THEN + RAISE_APPLICATION_ERROR(-20001, + 'Mai exista o comanda cu numarul ' || p_nrord); + END IF; + + -- 3. Parent INSERT — TRG_NOM_LUCRARI_BEFOINS allocates id_lucrare + INSERT INTO MARIUSM_AUTO.NOM_LUCRARI (nrord, id_mod) + VALUES (p_nrord, 1200) + RETURNING id_lucrare INTO v_id_lucrare; + + -- 4. Child INSERT — minimal columns only (NOT NULL ones + client link) + -- id_util_ad=0 for prototype (no real session context) + INSERT INTO MARIUSM_AUTO.DEV_ORDL ( + an, luna, + id_lucrare, + datai, dataoraad, + id_util_ad, + id_masiniclient, + id_tip, + solicitari_client + ) VALUES ( + EXTRACT(YEAR FROM v_now), EXTRACT(MONTH FROM v_now), + v_id_lucrare, + v_now, v_now, + 0, + p_id_masiniclient, + p_tip, + p_solicitari + ) RETURNING id_ordl INTO p_id_ordl; +END SP_CREEAZA_COMANDA_PROTOTIP; +/ diff --git a/docs/service-auto/TODO-phase2.md b/docs/service-auto/TODO-phase2.md index 55eb292..515c605 100644 --- a/docs/service-auto/TODO-phase2.md +++ b/docs/service-auto/TODO-phase2.md @@ -2,4 +2,48 @@ Scope wall: prototype = creare comandă only. Everything below = phase 2+. - +--- + +## P2.1 — SP production-grade (înlocuire SP_CREEAZA_COMANDA_PROTOTIP) + +SP-ul prototip nu setează: `id_sucursala`, `id_inspector`, `proc_tvav`, `observatii`, +`defectiuni`, `kmint`, `termen`. Toate nullable → OK pentru prototip. + +- **Opțiunea A**: Extinde `SP_CREEAZA_COMANDA_PROTOTIP` cu parametrii lipsă (control total) +- **Opțiunea B**: Migrează la `pack_auto.dev_adauga_lucrare` (17 params + `pack_sesiune` + coupling — necesită inițializare sesiune Oracle înainte de apel) + +## P2.2 — Vizualizare comenzi active (GET /api/service-auto/comenzi) + +Listă DEV_ORDL cu status, client, tip, nrord. Filtre: firmă, dată, status. Paginare +server-side. + +## P2.3 — id_sucursala în formular + +Când user-ul aparține unei sucursale (id_firma=167 sau 169), `id_sucursala` trebuie +completat în DEV_ORDL. Necesită mapping id_firma → id_sucursala + parametru nou în SP. + +## P2.4 — session_callback multi-schemă (tagged connections) + +Dacă un server Oracle viitor servește N scheme pe același pool, `CURRENT_SCHEMA` switching +poate produce leak inter-cerere. Soluție: tagged connections (`pool.acquire(tag=schema_name)`). +De implementat când cazul apare concret. + +## P2.5 — Test browser ComandaNoua.vue + +- Toate stările (loading, success, 422, 503, 500) manual +- Dark mode cu toggle AppHeader +- Diacritice din erori Oracle în Toast +- Dropdown masini (261 entries) cu filter activ + +## P2.6 — Cache lookup-uri + +- Tip-deviz: 7 rânduri, TTL 24h +- Masini: 261 rânduri, TTL 5min sau invalidare la adăugare client nou +- Pattern: `@cached` din `backend/modules/reports/cache/decorators.py` + +## P2.7 — Onboarding ROA_WEB pentru scheme noi + +Script complet de scris + versionat. Când se adaugă firmă nouă via `impdp`: +1. Rulează `onboarding_roa_web.sql` (vezi `grants-audit.md §4.1`) +2. Adaugă server entry în `ORACLE_SERVERS` dacă e pe alt server Oracle diff --git a/docs/service-auto/decision-log.md b/docs/service-auto/decision-log.md new file mode 100644 index 0000000..f892470 --- /dev/null +++ b/docs/service-auto/decision-log.md @@ -0,0 +1,231 @@ +# Decision Log — Service Auto Prototype + +**Prototype**: ROA2WEB `feat/service-auto` +**Owner**: Marius (ERP patron + sole dev) +**Timeline**: 24 săptămâni @ 2-4h/săpt +**Last updated**: 2026-04-11 + +Acest fișier urmărește verdictele pe cele 6 ipoteze din +`docs/service-auto/claude-main-design-20260411-rethink.md §"Things this prototype will probe"`. +Fiecare verdict e actualizat pe măsură ce testele rulează. +**Recomandarea finală** (merge/no-go pentru phase 2) se completează după săpt 24. + +--- + +## Kill Criterion Status + +Niciun kill criterion nu a fost declanșat până la data de mai sus. + +Reminder: dacă orice ipoteză eșuează cu un răspuns clar → prototype încheiat cu succes +(learning obținut, decision point clar, zero cod irosit). Kill ≠ eșec. + +--- + +## Ipoteza #1 — `python-oracledb` + OUT params + +**Ipoteză**: `python-oracledb` apelează curat PL/SQL proc cu IN + OUT params (NUMBER + VARCHAR2). + +**Status**: `CONFIRMED` ✅ + +**Dovezi**: +- Săpt 2 (`poc/async_out_param_probe.py`): `cursor.var(oracledb.NUMBER)` + `cursor.callproc` + → `out_var.getvalue()` returnează `42.0` corect. Assert PASSED. + Latență callproc: ~1ms după connect. Note: `.getvalue()` returnează `float`, necesită `int()` cast. +- Săpt 3 (test #1 + #2 `SP_CREEAZA_COMANDA_PROTOTIP`): SP cu 4 IN + 2 OUT (NUMBER + VARCHAR2) + apelat via sync-facade în `oracle_pool.get_connection('mariusm_test')`. + Test #1: 5.9ms, OUT p_id_ordl=412, p_nrord='P01-2'. Post-rollback: 0 rows ✅ +- Pattern implementat în `backend/modules/service_auto/services/comanda_service.py` + +**Decizie adoptată**: sync-facade (consistent cu `oracle_pool.py`), nu true-async +(`connect_async`). Motivat în `week1-notes.md §Gate Correction 9`. + +**Implicație pentru template**: pattern `cursor.var(oracledb.NUMBER/STRING)` + `callproc` ++ `int(out.getvalue())` e reutilizabil direct pentru orice SP cu OUT params. + +--- + +## Ipoteza #2 — `session_callback` nu leak-uiește între requests + +**Ipoteză**: `session_callback` pentru `CURRENT_SCHEMA` switching nu produce state leak +între requests concurente pe același pool. + +**Status**: `CONFIRMED` ✅ + +**Dovezi** (2026-04-12, `test_pool_concurrency.py` — 3/3 passed): +- `test_two_concurrent_connections_return_correct_results`: 2 asyncio tasks simultane pe + același pool, bind values distincte (111, 222) → rezultate corecte fără cross-bleed. +- `test_session_callback_runs_per_connection`: `session_callback` înregistrat pe server config + → confirmat că se apelează la prima conexiune din pool; nu leaked state inter-connection. +- `test_ten_concurrent_queries_no_errors`: 10 tasks concurente pe pool cu `max=5` → + `POOL_GETMODE_WAIT` queue-uiește corect excesul; toate 10 completate fără erori. + +**Nota bene**: pool `mariusm_test` nu folosește `session_callback` (ROA_WEB apelează SP cu +schemă explicită `MARIUSM_AUTO.SP_...`). Patch-ul `session_callback` în `oracle_pool.py` +e disponibil pentru modulele viitoare care au nevoie de `CURRENT_SCHEMA` switching. + +--- + +## Ipoteza #3 — GRANTS model: EXECUTE ON SP, zero DML direct + +**Ipoteză**: user `ROA_WEB` cu `GRANT EXECUTE ON SP` + zero `INSERT/UPDATE/DELETE` pe +tabele → INSERT direct returnează `ORA-00942`, SP call returnează succes. + +**Status**: `CONFIRMED` ✅ + +**Dovezi** (2026-04-12): +- `ROA_WEB` creat de DBA cu `GRANT CREATE SESSION` + `GRANT EXECUTE ON SP` + + `GRANT SELECT ON AUTO_VMASINICLIENTI` + `GRANT SELECT ON DEV_TIP_DEVIZ`. +- `test_grants_integration.py` — 3/3 passed (anterior 3 skipped — fișier lipsă parola): + - `test_insert_direct_fails` → `ORA-00942` ✅ (INSERT blocat) + - `test_select_direct_fails` → `ORA-00942` ✅ (SELECT pe tabel neautorizat blocat) + - `test_exec_sp_succeeds` → SP apelat cu succes ✅ +- Verificare live: `AUTO_VMASINICLIENTI` (266 rows) + `DEV_TIP_DEVIZ` (7 rows) accesibile; + `NOM_LUCRARI` direct → `ORA-00942` (Oracle ascunde complet obiectul, nu returnează 0 rows). +- Pool `mariusm_test` switchat la `ROA_WEB` în `.env` / `.env.prod` / `.env.test`. + +**Arhitectura multi-tenant documentată** (`grants-audit.md §4`): +- Oracle 21c: schema-level grants (23ai) nu există. Proxy auth respins — anulează boundary SP-only. +- Soluție: grants per-obiect incluse în deployment scripts. Firmă nouă = 1 script onboarding. + Obiect nou în toate schemele = 1 script companion care loopează `V_NOM_FIRME`. + +--- + +## Ipoteza #4 — Diacritice encoding end-to-end + +**Ipoteză**: `RAISE_APPLICATION_ERROR(-20001, 'mesaj cu ă î ș ț â')` ajunge în Vue ca +eroare user-friendly, encoding corect prin tot stack-ul (Oracle → oracledb → FastAPI → Vue). + +**Status**: `CONFIRMED` ✅ (Oracle→oracledb→FastAPI; Vue pending manual browser test) + +**Dovezi** (2026-04-12, `test_diacritice_encoding.py` — 2/2 passed): +- L1 — Oracle→oracledb: `RAISE_APPLICATION_ERROR(-20001, 'Client invalid: ă î ș ț â Ă Î Ș Ț Â')` + trimis via bind variable → `DatabaseError.args[0].message` conține toate diacriticele intact. + Set complet testat: `ă î ș ț â Ă Î Ș Ț Â`. NLS chain funcționează fără configurare explicită. +- L2 — oracledb→FastAPI: `_handle_oracle_error(e)` → `HTTPException(422, detail)` conține + diacriticele, prefix `ORA-20001:` stripped corect. +- Vue (manual pending): necesită browser + backend pornit; Toast PrimeVue afișează `detail` + din 422 response. Probabilitate eșec mică (Vue nu modifică string-uri JSON). + +--- + +## Ipoteza #5 — DX acceptabil (save→result < 10s) + +**Ipoteză**: FastAPI hot-reload + Vite dev-server + SSH tunnel Oracle e un DX acceptabil +pentru side-work de 2-4h/săpt. + +**Status**: `CONFIRMED` ✅ + +**Dovezi** (`week1-notes.md §Conectivitate`): + +| Operație | Timp | +|----------|------| +| Sync connect | 33ms | +| Async connect | 22ms | +| Query | 0.2-3.3ms | +| SP callproc (săpt 3) | 5.9ms | + +- Server Oracle e direct (10.0.20.121:1521) — fără SSH tunnel pe acest server. +- uvicorn `--reload` detectează schimbările și reîncarcă în < 1s. +- Total "save fișier → văd rezultatul" ≪ 10s. Gate trecut. + +**Implicație**: ecosistemul nu necesită optimizare înainte de content. DX confirmat. + +--- + +## Ipoteza #6 — Auth multi-server fără modificări shared code + +**Ipoteză**: flux-ul de auth existent (login → JWT cu `server_id` → `AuthenticationMiddleware`) +suportă un server nou fără modificări la `shared/` code. + +**Status**: `CONFIRMED` ✅ + +**Dovezi Oracle** (`week3-auth-audit.md`, 2026-04-11): +- `pack_drepturi.verificautilizator('MARIUS M', '123')` → `803` ✅ +- `V_NOM_FIRME`: 3 firme cu `schema='MARIUSM_AUTO'` (id 110/167/169) ✅ + +**Dovezi HTTP** (2026-04-12): +``` +POST /api/auth/login {"username":"VIZUALIZARE","password":"123","server_id":"mariusm_test"} +→ JWT companies: ["110","167","169"] ✅ +→ JWT server_id: "mariusm_test" ✅ +→ zero modificări în shared/auth/ ✅ + +GET /api/service-auto/ping (Bearer JWT) +→ {"result":1,"server":"mariusm_test","latency_ms":0.43} ✅ +``` + +Nota: MARIUS M are 2FA activat (email configurat) → testat cu VIZUALIZARE (no email, +aceleași 3 firme MARIUSM_AUTO). Același cod auth, același JWT structure. + +--- + +## Decizii arhitecturale documentate + +### D1 — Sync-facade în loc de true-async (Săpt 1) +**Decizie**: `oracle_pool.get_connection()` + `pool.acquire()` (sync) în `async def`. +**Motivare**: consistent cu pattern-ul deja proof-ed în `treasury_service.py` prod. +True-async (`connect_async`) nu aduce beneficii la latențele măsurate (22-33ms connect). + +### D2 — SP_CREEAZA_COMANDA_PROTOTIP nou în loc de reuse pack_auto (Săpt 3) +**Decizie**: Opțiunea 3 din design doc — SP minimal cu 2 INSERT-uri (nom_lucrari → dev_ordl) +și `RETURNING id_lucrare`, zero dependency pe `pack_sesiune`. +**Motivare**: `pack_auto.dev_adauga_lucrare` v2 are 17 params + `pack_sesiune` coupling; +SP nou cu RETURNING e idiomul Oracle modern și mai ușor de testat. + +### D3 — CONTAFIN_ORACLE în faza A, ROA_WEB în faza B (Săpt 3) +**Decizie**: Faza A (săpt 3+) folosește CONTAFIN_ORACLE (DBA role, zero grant work). +ROA_WEB creat în faza B când ipoteza #3 e pusă la test. +**Motivare**: faza A validează H1+H2+H5+H6 independent de H3. + +### D4 — Pool `mariusm_test` separat de `central` (Săpt 3) +**Decizie**: ID distinct `mariusm_test` în ORACLE_SERVERS deși e același host/user ca `central`. +**Motivare**: pool sizing independent pentru module service_auto + swap atomic la ROA_WEB +în faza B (schimbă doar `.env`, zero modificări cod). + +--- + +## Recomandare finală + +**Decizie: MERGE** ✅ — prototype confirmă că arhitectura e viabilă pentru phase 2. + +**Data**: 2026-04-12 +**Toate 6 ipoteze**: CONFIRMED (H1, H2, H3, H4, H5, H6) + +### Verdict pe ipoteze + +| # | Ipoteză | Verdict | Implicație phase 2 | +|---|---|---|---| +| H1 | oracledb + OUT params | ✅ CONFIRMED | Pattern `cursor.var()` reutilizabil direct | +| H2 | session_callback concurență | ✅ CONFIRMED | Disponibil pentru module multi-schemă viitoare | +| H3 | ROA_WEB grants SP-only | ✅ CONFIRMED | Arhitectura grants documentată + automatizată | +| H4 | Diacritice encoding | ✅ CONFIRMED | NLS chain OK fără configurare explicită | +| H5 | DX < 10s | ✅ CONFIRMED | Ecosistem nu necesită optimizare | +| H6 | Auth multi-server zero shared code | ✅ CONFIRMED | Pattern reutilizabil pentru orice server Oracle nou | + +### Ce a funcționat mai bine decât așteptat + +- **Viteza SP**: 5.9ms callproc (față de estimat 50ms) — server local, zero latency Oracle +- **Diacritice**: zero configurare NLS necesară — oracledb + Oracle 21c funcționează out-of-box +- **Auth reuse**: zero linii modificate în `shared/` — `.env` + `register_server()` suficiente +- **ROA_WEB grants**: soluția cu `V_NOM_FIRME` loop e mai curată decât anticipat + +### Precauții pentru phase 2 + +- **session_callback multi-schemă**: testul de concurență a confirmat pool-ul, dar izolarea + CURRENT_SCHEMA între două scheme diferite pe același pool nu e testată — tagged connections + necesare dacă apare cazul (vezi TODO-phase2.md) +- **SP production-grade**: `SP_CREEAZA_COMANDA_PROTOTIP` e minimal — nu setează `id_sucursala`, + `id_inspector`, `proc_tvav`, `observatii`. Phase 2 decide: extinde SP-ul sau migrează la + `pack_auto.dev_adauga_lucrare` (17 params, coupling cu pack_sesiune) +- **Vue manual browser test**: ComandaNoua.vue nu a fost testată în browser (H4 Vue layer) + +### Deliverables prototype livrate + +| Fișier | Ce conține | +|---|---| +| `docs/service-auto/template-modul-oracle.md` | Rețetă reutilizabilă: 11 secțiuni, de la `.env` la tests | +| `docs/service-auto/learnings.md` | 7 patterns consolidate din notele săptămânale | +| `docs/service-auto/grants-audit.md` | Arhitectura grants multi-tenant + scripts onboarding | +| `backend/modules/service_auto/` | Modul complet: router, service, schemas, tests (22/22) | +| `src/modules/service-auto/views/ComandaNoua.vue` | Formular cu date reale din Oracle | +| `poc/hello_oracle.py`, `poc/async_out_param_probe.py` | POC-urile de referință | diff --git a/docs/service-auto/grants-audit.md b/docs/service-auto/grants-audit.md new file mode 100644 index 0000000..d5697d1 --- /dev/null +++ b/docs/service-auto/grants-audit.md @@ -0,0 +1,210 @@ +# Grants Audit — ROA_WEB on MARIUSM_AUTO + +**Date**: 2026-04-11 +**Auditor**: oracle-agent (team service-auto-sapt3) +**Oracle target**: `10.0.20.121:1521/ROA` +**Queries run as**: `CONTAFIN_ORACLE` + +--- + +## 1. Findings + +### 1.1. ROA_WEB user — DOES NOT EXIST + +| Source query | Result | +|---|---| +| `SELECT * FROM DBA_USERS WHERE USERNAME='ROA_WEB'` | 0 rows | +| `SELECT * FROM DBA_TAB_PRIVS WHERE GRANTEE='ROA_WEB'` | 0 rows | +| `SELECT * FROM DBA_SYS_PRIVS WHERE GRANTEE='ROA_WEB'` | 0 rows | +| `SELECT * FROM DBA_ROLE_PRIVS WHERE GRANTEE='ROA_WEB'` | 0 rows | + +No Oracle user `ROA_WEB` exists on the `ROA` instance. All four privilege dictionaries +are empty for this grantee. Clean slate — no cleanup needed before creation. + +### 1.2. Why CONTAFIN_ORACLE "just works" on MARIUSM_AUTO + +``` +USER_ROLE_PRIVS for CONTAFIN_ORACLE: + RESOURCE, CONNECT, IMP_FULL_DATABASE, DBA, EXP_FULL_DATABASE +``` + +`CONTAFIN_ORACLE` holds the `DBA` role, so it has `SELECT/INSERT/UPDATE/DELETE/EXECUTE +ANY TABLE/PROCEDURE`. No explicit per-table grants are needed — that's why the Săpt 1 +POC (`poc/hello_oracle.py`) and this audit script both queried MARIUSM_AUTO without +any explicit grant work. + +**Implication for prototype (Săpt 3)**: we can connect as `CONTAFIN_ORACLE` directly, +skip `ROA_WEB` entirely, and still validate hypotheses #1 (oracledb + OUT params) and +#2 (SP creation + call). Only hypothesis #3 (grant-scoped access via SP, read-only +SELECT rejected) **requires** the scoped `ROA_WEB` user and is therefore the only +reason to ask DBA for the create. + +### 1.3. Required target objects — exist + +| Object | Type | Status | +|---|---|---| +| `MARIUSM_AUTO.AUTO_VMASINICLIENTI` | VIEW | present | +| `MARIUSM_AUTO.DEV_TIP_DEVIZ` | TABLE | present | +| `MARIUSM_AUTO.SP_CREEAZA_COMANDA_PROTOTIP` | — | **not yet created** (task #3) | + +--- + +## 2. Decision — Two-phase approach + +### Phase A — Săpt 3 (this week): use CONTAFIN_ORACLE + +- No DBA request needed. +- Prototype backend module `service_auto` connects via the existing + `CONTAFIN_ORACLE` credentials (already in `backend/.env`). +- Proves hypotheses #1 + #2 (sync-facade + PL/SQL OUT params) end-to-end. +- `backend/.env` entry for MARIUSM_AUTO (task #5) points to existing + CONTAFIN_ORACLE user; only schema owner changes (`MARIUSM_AUTO` instead of + whichever the login uses for current companies). + +### Phase B — Săpt 4+ (after prototype): create ROA_WEB and migrate + +Needed only to prove hypothesis #3 (grant-scoped access through SP while direct +DML/SELECT is rejected). This requires DBA action. + +--- + +## 3. Motivare securitate — de ce ROA_WEB cu SP-only + +**Oracle version** (confirmat 2026-04-12): `Oracle Database 21c Express Edition`. +Schema-level grants (23ai+) nu sunt disponibile. Proxy auth (12c+) există dar anulează +beneficiul de securitate — vezi §4. + +### 3.1. Attack surface comparison + +| User conectare | SQL injection poate face | Attack surface | +|---|---|---| +| `CONTAFIN_ORACLE` (DBA) | SELECT/INSERT/DELETE pe **orice tabelă, orice schemă** | maxim | +| `ROA_WEB` proxied ca schemă | SELECT/INSERT/DELETE pe **orice tabelă din schemă** | mediu | +| `ROA_WEB` cu EXECUTE-only pe SP | doar apel SP cu parametri validați de SP | **minim** | + +`CONTAFIN_ORACLE` conectat din web = punct slab critic: SQL injection prin parametrii +unui request HTTP poate exfiltra sau modifica date din orice schemă de pe server. + +`ROA_WEB` cu EXECUTE-only: atacatorul poate cel mult apela SP-urile cu parametri +injectați. SP-urile Oracle sunt compilate, nu interpretate — parametrii sunt bind +variables, nu SQL concatenat. Suprafața de atac e redusă la logica validată din SP. + +### 3.2. De ce nu proxy authentication + +Proxy auth (`ALTER USER MARIUSM_AUTO GRANT CONNECT THROUGH ROA_WEB`) dă `ROA_WEB` +identitatea completă a schemei proxied → SELECT/INSERT direct pe orice tabelă din +acea schemă. Pierde exact beneficiul de securitate pentru care creăm `ROA_WEB`. + +--- + +## 4. Arhitectura multi-tenant scalabilă (Oracle 21c) + +### 4.1. Workflow firmă nouă + +Firmele noi se creează via `impdp` dintr-o schemă template menținută la zi: + +```bash +impdp system/... SCHEMAS=TEMPLATE_AUTO REMAP_SCHEMA=TEMPLATE_AUTO:FIRMA_NOUA ... +``` + +Schema nouă conține deja **toate** obiectele (SP-uri, view-uri, tabele) din template. +Onboarding-ul ROA_WEB = 1 script rulat după impdp: + +```sql +-- onboarding_roa_web.sql — rulat ca CONTAFIN_ORACLE după impdp pentru fiecare firmă nouă +-- Înlocuiește FIRMA_NOUA cu schema reală +GRANT EXECUTE ON FIRMA_NOUA.SP_CREEAZA_COMANDA_PROTOTIP TO ROA_WEB; +GRANT SELECT ON FIRMA_NOUA.AUTO_VMASINICLIENTI TO ROA_WEB; +GRANT SELECT ON FIRMA_NOUA.DEV_TIP_DEVIZ TO ROA_WEB; +-- adaugă orice alte SP/view-uri noi apărute de la ultimul onboarding +``` + +### 4.2. Workflow SP/obiect nou + +Când un SP nou se adaugă în **toate** schemele (ca parte dintr-o migrare), scriptul +de migrare are două componente: + +**Componenta 1** — `migration_YYYYMMDD_sp_noua.sql` — rulat per schemă (existent deja): +```sql +CREATE OR REPLACE PROCEDURE MARIUSM_AUTO.SP_NOUA (...) AS ...; +-- (repetat pentru fiecare schemă/firmă) +``` + +**Componenta 2** — `migration_YYYYMMDD_sp_noua_grants.sql` — rulat O SINGURĂ DATĂ, +loopează automat toate schemele din `V_NOM_FIRME`: +```sql +-- Rulat ca CONTAFIN_ORACLE după ce migration_1 a rulat pe toate schemele +BEGIN + FOR firm IN ( + SELECT DISTINCT schema + FROM contafin_oracle.v_nom_firme + WHERE schema IS NOT NULL + ) LOOP + BEGIN + EXECUTE IMMEDIATE + 'GRANT EXECUTE ON ' || firm.schema || '.SP_NOUA TO ROA_WEB'; + EXCEPTION WHEN OTHERS THEN + NULL; -- skip dacă schema nu a primit încă migrarea SP (deployment order) + END; + END LOOP; +END; +/ +``` + +`V_NOM_FIRME` e sursa de adevăr pentru toate schemele active — același tabel folosit +de backend pentru login JWT. Orice firmă adăugată în `V_NOM_FIRME` e inclusă automat +la **următoarea** migrare grants. + +### 4.3. ROA_WEB creation script (one-time, DBA action) + +```sql +-- Rulat O SINGURĂ DATĂ ca SYS sau CONTAFIN_ORACLE +CREATE USER ROA_WEB IDENTIFIED BY ""; +GRANT CREATE SESSION TO ROA_WEB; +-- Fără alte privilegii sistem. Accesul la date = exclusiv prin granturi per-obiect. +``` + +### 4.4. Negative test — confirmă hypothesis #3 (scoped access) + +```sql +-- Conectat ca ROA_WEB (NU CONTAFIN_ORACLE): +SELECT * FROM MARIUSM_AUTO.DEV_ORDL WHERE ROWNUM < 2; -- ORA-00942 așteptat +INSERT INTO MARIUSM_AUTO.DEV_ORDL (id_ordl, id_lucrare) VALUES (1, 1); -- ORA-00942 așteptat + +-- SP call via bind vars — succes așteptat (ROLLBACK după verificare): +BEGIN + MARIUSM_AUTO.SP_CREEAZA_COMANDA_PROTOTIP( + p_id_tip => 1, + p_id_masiniclient => , + p_id_firma => 110, + p_solicitari => 'Test H3', + p_id_ordl => :o_id_ordl, + p_nrord => :o_nrord + ); +END; +/ +ROLLBACK; +``` + +### 4.5. Sumar scalabilitate + +| Eveniment | Acțiune ROA_WEB | Cost | +|---|---|---| +| ROA_WEB creat (o dată) | `CREATE USER` + `GRANT CREATE SESSION` | O dată | +| Firmă nouă (`impdp`) | `onboarding_roa_web.sql` cu schema nouă | 1 script per firmă | +| SP nou în toate schemele | `migration_YYYYMMDD_sp_noua_grants.sql` (loop V_NOM_FIRME) | 1 script per migrare | +| View/tabelă nouă expusă | același pattern ca SP | 1 script per migrare | + +--- + +## 5. Action items + +- [x] **Task #1** — Grants audit completat (2026-04-11) +- [x] **Task #3** — `SP_CREEAZA_COMANDA_PROTOTIP` creat + testat cu rollback (2026-04-11) +- [x] **Task #5** — `mariusm_test` entry în `backend/.env` cu CONTAFIN_ORACLE (2026-04-11) +- [ ] **Săpt 4** (deferred, DBA action): + 1. `CREATE USER ROA_WEB` + `GRANT CREATE SESSION` (§4.3) + 2. Rulează `onboarding_roa_web.sql` pentru MARIUSM_AUTO (§4.1) + 3. Switch `backend/secrets/mariusm_test.oracle_pass` + user → ROA_WEB în `.env` + 4. Rulează `test_grants_integration.py` → 3 skipped trebuie să devină 3 passed + 5. Rulează negative test din §4.4 manual (SQL Developer sau sqlplus) diff --git a/docs/service-auto/learnings.md b/docs/service-auto/learnings.md new file mode 100644 index 0000000..a8e8eae --- /dev/null +++ b/docs/service-auto/learnings.md @@ -0,0 +1,109 @@ +# Learnings — Service Auto Prototype + +Consolidare din notele săptămânale. Fiecare pattern e aplicabil la orice modul Oracle nou. + +--- + +## L1 — Sync-facade e suficient (nu trebuie true-async Oracle) + +`oracledb.create_pool()` (sync) + `pool.acquire()` (sync) în `async def` FastAPI funcționează +perfect. True-async (`connect_async`) există și merge (22ms vs 33ms) dar nu aduce beneficii +măsurabile la latențele unui server local. Consistența cu `oracle_pool.py` existent > purism async. + +**Pattern adoptat** (`shared/database/oracle_pool.py`): +```python +async with oracle_pool.get_connection('server_id') as conn: + with conn.cursor() as cur: + cur.execute(query, params) +``` + +--- + +## L2 — `cursor.var()` pentru OUT params, întotdeauna `int()` pe NUMBER + +`cursor.var(oracledb.NUMBER).getvalue()` returnează `float`, nu `int`. +`cursor.var(oracledb.STRING).getvalue()` returnează `str | None`. + +```python +out_id = cursor.var(oracledb.NUMBER) +out_nrord = cursor.var(oracledb.STRING) +cursor.callproc("SCHEMA.SP_NUMESC", [..., out_id, out_nrord]) +id_result = int(out_id.getvalue()) # float → int obligatoriu +nr_result = out_nrord.getvalue() or "" +``` + +--- + +## L3 — RETURNING INTO evită coupling cu pack_sesiune + +TRG_NOM_LUCRARI_BEFOINS și TRG_DEV_ORDL_BEFOINS populează `pack_sesiune` state (global +per-sesiune). Pentru un SP nou fără dependency pe `pack_sesiune`, folosește `RETURNING INTO` +local — trigger-ul rulează, dar citești ID-ul tău, nu din state-ul pachetului: + +```sql +INSERT INTO NOM_LUCRARI (...) VALUES (...) RETURNING id_lucrare INTO v_id_lucrare; +INSERT INTO DEV_ORDL (..., id_lucrare) VALUES (..., v_id_lucrare) RETURNING id_ordl INTO p_id_ordl; +``` + +Zero dependențe, testabil cu ROLLBACK simplu. + +--- + +## L4 — ORA-20xxx range pentru business errors, prefix stripped în handler + +SP-urile aruncă business errors cu `-20001` … `-20999`. +Python handler strippuiește prefix-ul `ORA-2xxxx:` și returnează HTTP 422 cu mesajul curat: + +```python +if 20001 <= code <= 20999: + clean = re.sub(r"^ORA-\d+:\s*", "", raw_message).strip() + raise HTTPException(status_code=422, detail=clean) +``` + +Mesajele cu diacritice (`ă î ș ț â`) trec corect prin NLS chain fără configurare explicită. + +--- + +## L5 — ROA_WEB grants nu scalează per-obiect, dar se automatizează prin V_NOM_FIRME + +Schema-level grants (Oracle 23ai+) nu există pe 21c. Proxy auth nu e opțiune (anulează +boundary SP-only). Soluția: grants per-obiect incluse **în deployment scripts existente**, +nu administrate separat. + +**Firmă nouă** (după `impdp`): 1 script onboarding cu grant-urile curente pentru schema nouă. + +**Obiect nou în toate schemele**: script companion la migrare care loopează `V_NOM_FIRME`: +```sql +BEGIN + FOR firm IN (SELECT DISTINCT schema FROM contafin_oracle.v_nom_firme WHERE schema IS NOT NULL) + LOOP + BEGIN EXECUTE IMMEDIATE 'GRANT EXECUTE ON ' || firm.schema || '.SP_NOUA TO ROA_WEB'; + EXCEPTION WHEN OTHERS THEN NULL; END; + END LOOP; +END; +``` + +--- + +## L6 — Auth reuse zero shared code changes + +Adăugarea unui server nou în `ORACLE_SERVERS` (`.env`) + `register_server()` în `main.py` +e suficientă. JWT conține automat `companies[]` din `V_NOM_FIRME` și `server_id`. +`AuthenticationMiddleware` injectează `request.state.user` fără modificări. + +Testat: `server_id="mariusm_test"` → JWT cu `companies=["110","167","169"]` + `/ping` → `{server: "mariusm_test"}`. + +--- + +## L7 — `Promise.allSettled` pentru lookup-uri paralele în Vue + +Lookup-urile independente (firme, tip-deviz, masini) se încarcă în paralel. +`allSettled` (nu `all`) permite degradare gracefulă: dacă un endpoint pică, celelalte +se afișează totuși, iar utilizatorul vede un toast de avertizare specific. + +```javascript +const [firmeRes, tipuriRes, masiniRes] = await Promise.allSettled([ + api.getFirme(), api.getTipDeviz(), api.getMasini() +]) +// Fiecare are .status === 'fulfilled' | 'rejected' +``` diff --git a/docs/service-auto/template-modul-oracle.md b/docs/service-auto/template-modul-oracle.md new file mode 100644 index 0000000..c50752c --- /dev/null +++ b/docs/service-auto/template-modul-oracle.md @@ -0,0 +1,371 @@ +# Template: Modul Oracle Nou în ROA2WEB + +**Audience**: developer care implementează un modul Oracle nou în 2h. +**Bazat pe**: `backend/modules/service_auto/` — codul real, nu spec. +**Branch de referință**: `feat/service-auto` + +--- + +## 1. Prerequisites + +### 1.1. `.env` — adaugă serverul Oracle + +`ORACLE_SERVERS` e un JSON array în `backend/.env`, `backend/.env.prod`, `backend/.env.test`: + +```json +ORACLE_SERVERS=[ + {"id":"central","name":"...", "host":"...","port":1521,"user":"CONTAFIN_ORACLE","service_name":"ROA"}, + {"id":"MY_SERVER_ID","name":"DESCRIERE","host":"IP_SERVER","port":1521,"user":"ROA_WEB","service_name":"ROA"} +] +``` + +Parola Oracle: `backend/secrets/MY_SERVER_ID.oracle_pass` (text plain, gitignored). + +`main.py:init_oracle_pool()` parcurge automat lista și apelează `oracle_pool.register_server()` pentru fiecare — nu e nevoie de cod nou. + +### 1.2. Grants Oracle — Arhitectura de producție + +**Server**: Oracle 21c XE. Schema-level grants (23ai+) nu sunt disponibile. + +#### Faza A (prototype) — CONTAFIN_ORACLE direct +Zero DBA work. CONTAFIN_ORACLE are rol DBA → acces la orice schemă. +Swap la ROA_WEB în faza B. + +#### Faza B — ROA_WEB cu EXECUTE-only pe SP-uri (recomandat pentru producție) + +**De ce nu proxy auth**: proxy auth dă ROA_WEB identitatea completă a schemei → +SELECT/INSERT direct pe orice tabelă → pierde beneficiul de securitate (SQL injection +mitigation). Vezi `docs/service-auto/grants-audit.md` §3.1. + +```sql +-- O singură dată (DBA): +CREATE USER ROA_WEB IDENTIFIED BY ""; +GRANT CREATE SESSION TO ROA_WEB; + +-- La onboarding firmă nouă (după impdp din schema template): +GRANT EXECUTE ON SCHEMA_FIRMA.SP_NUMESC TO ROA_WEB; +GRANT SELECT ON SCHEMA_FIRMA.VIEW_LOOKUP_1 TO ROA_WEB; +GRANT SELECT ON SCHEMA_FIRMA.VIEW_LOOKUP_2 TO ROA_WEB; + +-- La fiecare migrare care adaugă SP/view noi (loop V_NOM_FIRME, o singură dată): +BEGIN + FOR firm IN (SELECT DISTINCT schema FROM contafin_oracle.v_nom_firme WHERE schema IS NOT NULL) + LOOP + BEGIN + EXECUTE IMMEDIATE 'GRANT EXECUTE ON ' || firm.schema || '.SP_NOU_DIN_MIGRARE TO ROA_WEB'; + EXCEPTION WHEN OTHERS THEN NULL; + END; + END LOOP; +END; +/ +``` + +Conectare Python: `user="ROA_WEB"` + `session_callback` pentru `CURRENT_SCHEMA`. +Pattern complet în `docs/service-auto/grants-audit.md` §4. + +--- + +## 2. File Layout + +``` +backend/modules/MODUL_NOU/ +├── __init__.py # Logger setup — OBLIGATORIU cu propagate=False +├── models/ +│ └── __init__.py # SQLModel / dataclass-uri, dacă ai nevoie (poate fi gol) +├── schemas/ +│ └── cerere.py # Pydantic: Request + Response (doar câmpuri, zero logică) +├── services/ +│ └── cerere_service.py # Oracle: _handle_oracle_error() + clasa ServiceXxx +├── routers/ +│ ├── __init__.py # create_MODUL_NOU_router() factory +│ └── cerere.py # FastAPI router: /ping + endpoint-uri +└── tests/ + ├── test_error_mapping.py # Unit: mock Oracle errors → HTTP status (no live DB) + ├── test_cerere_integration.py # Integration: live DB, marcat pytest.mark.integration + └── test_cerere_persist.py # Integration: commit + reconnect + SELECT +``` + +**Înregistrare în `main.py`** (2 linii): +```python +from backend.modules.MODUL_NOU.routers import create_MODUL_NOU_router +# în lifespan / app setup: +app.include_router(create_MODUL_NOU_router(), prefix="/api/MODUL-NOU", tags=["MODUL-NOU"]) +``` + +--- + +## 3. Logging Setup (`__init__.py`) + +Copiază exact din `service_auto/__init__.py` — schimbă doar `'service_auto'` și +`'service_auto.log'`: + +```python +import logging +from pathlib import Path + +_LOG_DIR = Path(__file__).resolve().parents[2] / 'logs' +_LOG_DIR.mkdir(parents=True, exist_ok=True) + +logger = logging.getLogger('MODUL_NOU') +logger.propagate = False # Nu duplica în root logger +if not logger.handlers: + fh = logging.FileHandler(_LOG_DIR / 'MODUL_NOU.log') + fh.setFormatter(logging.Formatter('%(asctime)s %(levelname)s %(name)s: %(message)s')) + logger.addHandler(fh) +logger.setLevel(logging.INFO) +``` + +> `propagate=False` + `FileHandler` cu **path absolut** = log separat per modul, fără +> interférence cu root logger-ul din `main.py`. Path relativ ar fi ambiguu la `cwd`. + +--- + +## 4. SP Signature Convention + +```sql +CREATE OR REPLACE PROCEDURE SCHEMA_TA.SP_NUMESC ( + p_param1 IN NUMBER, -- input-uri întâi + p_param2 IN VARCHAR2, + p_id_out OUT NUMBER, -- OUT NUMBER pentru ID-uri (întotdeauna ultimele) + p_nrord_out OUT VARCHAR2 -- OUT VARCHAR2 pentru coduri string +) +AS +BEGIN + -- Validare: aruncă în range 20001-20999 pentru erori business + IF p_param1 NOT IN (SELECT id_tip FROM TIP_TU WHERE sters=0) THEN + RAISE_APPLICATION_ERROR(-20001, 'Tip invalid: ' || p_param1); + END IF; + + -- Logică: INSERT parent → INSERT child (RETURNING pentru FK chain) + INSERT INTO PARENT_TABLE (...) VALUES (...) RETURNING id_parent INTO v_id_parent; + INSERT INTO CHILD_TABLE (..., id_parent, ...) VALUES (..., v_id_parent, ...) + RETURNING id_child INTO p_id_out; + + p_nrord_out := 'PREFIX-' || TO_CHAR(SEQ_TA.NEXTVAL); +END; +/ +``` + +**Reguli SP**: +- Range `RAISE_APPLICATION_ERROR`: `-20001` … `-20999` (map-at la HTTP 422) +- Mesajele de eroare pot conține diacritice — oracledb le decodifică corect prin NLS chain +- Nu folosiți `pack_sesiune.*` (state global per-sesiune) — folosiți `RETURNING INTO` local +- SP-ul face `COMMIT` sau `ROLLBACK` explicit dacă e nevoie; Python face `commit()` după callproc + +--- + +## 5. Python Callproc Pattern + +Codul exact din `services/comanda_service.py` — copiază și adaptează: + +```python +import oracledb +from fastapi import HTTPException +from shared.database.oracle_pool import oracle_pool +from ..schemas.cerere import CerereRequest, CerereResponse +from .. import logger + + +def _handle_oracle_error(e: Exception) -> NoReturn: + """Map Oracle error codes to HTTP. Always raises. Copy from comanda_service.py.""" + err = e.args[0] + code = getattr(err, "code", 0) + raw_message = getattr(err, "message", str(e)) + + if 20001 <= code <= 20999: + clean = re.sub(r"^ORA-\d+:\s*", "", raw_message).strip() + raise HTTPException(status_code=422, detail=clean) + if code in (12541, 12170, 12154, 12560): + raise HTTPException(status_code=503, detail="Serviciul bazei de date e temporar indisponibil") + if code == 1017: + logger.critical("Oracle credentials rejected (ORA-01017)", exc_info=True) + raise HTTPException(status_code=500, detail="Eroare de configurare. Contactați administratorul.") + if code == 942: + logger.critical("Oracle object not found or grant missing (ORA-00942)", exc_info=True) + raise HTTPException(status_code=500, detail="Eroare internă. Contactați administratorul.") + logger.error("Unexpected Oracle error ORA-%05d", code, exc_info=True) + raise HTTPException(status_code=500, detail="Eroare internă neașteptată") + + +class CerereService: + @staticmethod + async def creeaza_cerere(data: CerereRequest, username: str) -> CerereResponse: + logger.info("MODUL_NOU.creeaza_cerere START", + extra={"user": username, "param1": data.param1}) + + async with oracle_pool.get_connection("MY_SERVER_ID") as connection: + try: + with connection.cursor() as cursor: + out_id = cursor.var(oracledb.NUMBER) # OUT NUMBER → float → int() + out_nrord = cursor.var(oracledb.STRING) # OUT VARCHAR2 + + cursor.callproc( + "SCHEMA_TA.SP_NUMESC", + [ + data.param1, # p_param1 IN NUMBER + data.param2, # p_param2 IN VARCHAR2 + out_id, # p_id_out OUT NUMBER + out_nrord, # p_nrord_out OUT VARCHAR2 + ], + ) + connection.commit() + + result_id = int(out_id.getvalue()) # float → int + result_nrord = out_nrord.getvalue() or "" + + except oracledb.DatabaseError as e: + try: + connection.rollback() + except Exception: + pass # conexiunea poate fi moartă pe erori de rețea + _handle_oracle_error(e) + + logger.info("MODUL_NOU.creeaza_cerere OK", + extra={"user": username, "id": result_id, "nrord": result_nrord}) + + return CerereResponse(id=result_id, nrord=result_nrord, + mesaj=f"Cererea {result_nrord} creată cu succes.") +``` + +**Note critice**: +- `out_id.getvalue()` returnează `float` — **obligatoriu `int()` cast** pentru ID-uri +- `rollback()` în `except` poate arunca dacă conexiunea e moartă → `try/except pass` +- `commit()` după callproc, nu înainte de `getvalue()` + +--- + +## 6. Error Mapping Table + +| ORA code range | HTTP status | Interpretare | Acțiune log | +|---|---|---|---| +| 20001–20999 | 422 | Business rule (RAISE_APPLICATION_ERROR) | — (mesajul e user-friendly) | +| 12541, 12170, 12154, 12560 | 503 | Oracle unreachable / TNS | — (infra issue) | +| 1017 | 500 | Bad credentials — config error | `logger.critical` | +| 942 | 500 | Object/grant missing — deployment error | `logger.critical` | +| orice altceva | 500 | Eroare neașteptată | `logger.error` | + +Prefix `ORA-20XXX:` e **stripped** din mesajul business înainte de a-l trimite la client. +Testele unit în `tests/test_error_mapping.py` verifică toate branch-urile fără live DB +(mock `oracledb.DatabaseError` cu `MagicMock`). + +--- + +## 7. Auth Reuse + +Fără cod nou — importă din shared: + +```python +from shared.auth.dependencies import get_current_user +from shared.auth.models import CurrentUser + +@router.post("/cereri", response_model=CerereResponse) +async def creeaza_cerere( + data: CerereRequest, + current_user: CurrentUser = Depends(get_current_user), +): + return await CerereService.creeaza_cerere(data=data, username=current_user.username) +``` + +`AuthenticationMiddleware` (înregistrat global în `main.py`) injectează automat +`request.state.user`. `get_current_user` extrage din `request.state.user` fără query DB. +JWT conține `server_id` — accesibil ca `current_user.server_id` dacă ai nevoie să +selectezi pool-ul dinamic per-user. + +--- + +## 8. Ping / Health Endpoint + +Copiază din `routers/comanda.py` — schimbă `'mariusm_test'`: + +```python +import time +import oracledb +from fastapi import APIRouter, Depends, HTTPException +from shared.auth.dependencies import get_current_user +from shared.auth.models import CurrentUser +from shared.database.oracle_pool import oracle_pool + +router = APIRouter() + +@router.get("/ping") +async def ping(_: CurrentUser = Depends(get_current_user)): + """Health check: verifică conectivitatea Oracle pentru server-ul modulului.""" + t0 = time.perf_counter() + try: + async with oracle_pool.get_connection('MY_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": "MY_SERVER_ID", "latency_ms": elapsed_ms} +``` + +Ping-ul e **autentificat** (orice user valid) — nu e public. Latency așteptată pe server +local: 20-35ms. Dacă > 500ms → tunel SSH sau rețea problemă. + +--- + +## 9. Router Factory (`routers/__init__.py`) + +```python +from fastapi import APIRouter + +def create_MODUL_NOU_router() -> APIRouter: + router = APIRouter() + from .cerere import router as cerere_router + router.include_router(cerere_router, tags=["MODUL-NOU"]) + return router +``` + +Import lazy (în interiorul funcției) evită circular imports la load time. + +--- + +## 10. `session_callback` — CURRENT_SCHEMA switching (opțional) + +Necesar dacă SP-urile tale sunt pe o schemă diferită de user-ul de conectare +(ex. conectezi ca `ROA_WEB` dar SP-urile sunt în `SCHEMA_TA`): + +```python +# În modulul tău, înainte de register_server: +def set_schema_callback(connection, requested_tag): + """Apelat la fiecare acquire() din pool.""" + with connection.cursor() as cur: + cur.execute("ALTER SESSION SET CURRENT_SCHEMA = SCHEMA_TA") + +# La înregistrarea server-ului (sau în main.py, la init_oracle_pool): +oracle_pool.register_server( + server_id="MY_SERVER_ID", + host="...", port=1521, + user="ROA_WEB", password="...", + service_name="ROA", + session_callback=set_schema_callback, # ← patch disponibil în oracle_pool.py +) +``` + +`oracle_pool.register_server()` acceptă deja `session_callback` (implementat în +`shared/database/oracle_pool.py:66` și `:133-134`). Zero modificări la shared code. + +> **Concurrency caveat**: testul de concurență pe 1 schemă e pending (ipoteza #2). +> Pattern-ul e safe pentru 1 schemă per pool. Multi-schemă pe același pool necesită +> tagged connections — deferrat pentru phase 2. + +--- + +## 11. Checklist Rapid + +``` +□ .env: entry nou în ORACLE_SERVERS (id, host, port, user, service_name) +□ backend/secrets/MY_SERVER_ID.oracle_pass creat +□ Oracle: GRANT CREATE SESSION + EXECUTE ON SP + SELECT ON views (sau folosești DBA user pentru prototype) +□ __init__.py: logger cu propagate=False + FileHandler absolut +□ schemas/: Request + Response (câmpuri Pydantic, zero logică) +□ services/: _handle_oracle_error() + clasa Service cu callproc + int(out.getvalue()) +□ routers/__init__.py: create_MODUL_router() factory cu lazy import +□ routers/cerere.py: /ping endpoint autentificat + endpoint-uri business +□ main.py: import factory + app.include_router cu prefix + tags +□ tests/test_error_mapping.py: unit tests fără live DB (mock DatabaseError) +□ Testează /ping: latency < 35ms → Oracle reachabil +``` diff --git a/docs/service-auto/week1-notes.md b/docs/service-auto/week1-notes.md index 67a6ea8..da9b963 100644 --- a/docs/service-auto/week1-notes.md +++ b/docs/service-auto/week1-notes.md @@ -24,3 +24,157 @@ Motivare: `oracle_pool.py` folosește deja `oracledb.create_pool()` (sync) + `po - Audit grants `ROA_WEB` pe `MARIUSM_AUTO.*` - Creare SP `SP_CREEAZA_COMANDA_PROTOTIP` în MARIUSM_AUTO (template în tabele-service-auto.md §12.2) - Auth path: adaugă `MARIUSM_AUTO` company în `.env` + test login JWT end-to-end + +--- + +# Săpt 3 — Execution Log + +## Task #1 — Grants audit (2026-04-11, oracle-agent) + +- `ROA_WEB` user **nu există** în instanța `ROA` (toate cele 4 dicționare — DBA_USERS, DBA_TAB_PRIVS, DBA_SYS_PRIVS, DBA_ROLE_PRIVS — întorc 0 rânduri). +- `CONTAFIN_ORACLE` are `DBA` role → acces la MARIUSM_AUTO e via `SELECT/INSERT/UPDATE/DELETE/EXECUTE ANY ...`, fără grants explicite. Asta explică de ce Săpt 1 a funcționat fără DBA work. +- Decizie: **fază A (Săpt 3) folosește CONTAFIN_ORACLE direct** — nu e nevoie de DBA. Fază B (Săpt 4+) va crea ROA_WEB cu grants scope-limited pentru a proba ipoteza #3 (access blocat direct, permis doar via SP). +- Findings complete + script creare ROA_WEB: `docs/service-auto/grants-audit.md`. + +## Task #3 — SP_CREEAZA_COMANDA_PROTOTIP (2026-04-11, oracle-agent) + +### SP source +- Locație: `docs/service-auto/SP_CREEAZA_COMANDA_PROTOTIP.sql` +- Signature: `(p_tip IN, p_id_masiniclient IN, p_solicitari IN VARCHAR2, p_id_firma IN, p_id_ordl OUT, p_nrord OUT)` +- Nrord generation: `SEQ_NR_LUCRARE.NEXTVAL` (existing, unused — `last_number=1`) + prefix `P-` → primul prototype produce `P01-1`. +- Body: duplicate-check (paritate cu `pack_auto`) + INSERT NOM_LUCRARI (`id_mod=1200`, id_lucrare auto via `TRG_NOM_LUCRARI_BEFOINS` + `SEQ_NOM_LUCRARI`) + INSERT DEV_ORDL (coloane NOT NULL + client link). +- Coloane sărite conștient: `id_inspector`, `id_asigurator`, `nr_dosar`, `kmint`, `termen`, `proc_tvav`, `id_part_ref`, `observatii`, `defectiuni` — toate nullable, OK pentru prototype. + +### Execution +- `CREATE OR REPLACE PROCEDURE` via `cursor.execute(body)` (trailing `/` stripped în Python pentru a nu fi trimis ca SQL statement invalid) → **VALID** fără compile errors. +- Test call: + ``` + tip=1 (POST GARANTIE), id_masiniclient=2, solicitari="Prototype test — rollback imediat după verificare.", id_firma=1 + → OUT p_id_ordl=411, p_nrord='P01-1' + → verificare pre-rollback: rând în DEV_ORDL + NOM_LUCRARI cu id_lucrare=721, id_mod=1200, an=2026, luna=4 + → conn.rollback() + → post-rollback: 0 rows în ambele tabele ✅ + ``` +- Confirmă: ipoteza #1 (oracledb → OUT NUMBER + OUT VARCHAR2 prin `cursor.var()`), ipoteza #2 (SP cu RETURNING INTO peste două INSERT-uri cu trigger-uri BEFOINS), tranzacționalitate completă. + +### Note +- `SEQ_NR_LUCRARE` **nu se roll-back-uie** (standard Oracle) — următorul test va produce `P01-2`, `P01-3`, etc. Util pentru cleanup: prototype rows sunt ușor de identificat prin prefix `P`. +- `DBMS_LOB.SUBSTR` folosit în verificare pentru a citi CLOB-ul `solicitari_client` — implicit VARCHAR2→CLOB conversion la INSERT a mers fără probleme (inclusiv diacritice). + +### Test #2 — Confirmare (2026-04-11, oracle-agent, săpt 3 exec) + +``` +tip=3 (REGIE, inch_validare=1), id_masiniclient=200 (CT-85-ROD), p_id_firma=1 +→ SP call: 5.9ms ✅ +→ OUT p_id_ordl=412, p_nrord='P01-2' +→ NOM_LUCRARI: id_lucrare=722, nrord='P01-2', id_mod=1200 ✅ +→ DEV_ORDL: id_ordl=412, id_lucrare=722, id_tip=3, luna=4, an=2026 ✅ +→ conn.rollback() → post-rollback: 0 rows ✅ +``` + +**Ipoteze confirmate Săpt 3:** +- H1 ✅ — `oracledb.cursor.callproc()` + `cursor.var(NUMBER)` / `cursor.var(STRING)` pentru OUT params +- H2 ✅ — SP cu dual INSERT (NOM_LUCRARI → DEV_ORDL) + trigger BEFOINS RETURNING + tranzacționalitate completă +- H3 ⏳ — amânat Săpt 4 (necesită creare ROA_WEB via DBA; script ready în `grants-audit.md`) + +## Task #5 — MARIUSM_AUTO în backend/.env (2026-04-11, oracle-agent) + +### Modificări .env + +Adăugat entry `mariusm_test` în `ORACLE_SERVERS` JSON în toate cele 3 fișiere env: +- `backend/.env` — activ imediat +- `backend/.env.prod` — pentru viitoarea producție +- `backend/.env.test` — pentru runul de test + +```json +{"id":"mariusm_test","name":"MARIUSM AUTO (service-auto prototype)", + "host":"10.0.20.121","port":1521, + "user":"CONTAFIN_ORACLE","service_name":"ROA"} +``` + +**Reasoning** (fază A): `mariusm_test` are aceleași parametri de conectare ca și `central` (același server Oracle, același user DBA), dar ID distinct pentru: +1. Pool separat per modul service_auto (pool sizing independent) +2. Swap atomic la faza B: schimbăm `mariusm_test` → user=ROA_WEB, fără modificări cod + +Parola: `backend/secrets/mariusm_test.oracle_pass` (copie din `central.oracle_pass`) + +### Verificare conectivitate via config + +``` +Config parsed: 4 servers (romfast, central, mariusm_test, vending) ✅ +mariusm_test: password loaded (len=11) ✅ +Direct connect: 22.1ms ✅ +DEV_TIP_DEVIZ count=7 ✅ +SP_CREEAZA_COMANDA_PROTOTIP STATUS=VALID ✅ +``` + +### Test JWT login — backend offline + +Backend nu rulează (verificat via `curl http://localhost:8000/health`). +Pentru a testa login JWT end-to-end: + +```bash +# 1. Pornește backend în mod test +./start.sh test + +# 2. Testează login JWT cu credențialele de test (din CLAUDE.md) +curl -s -X POST http://localhost:8000/api/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"username":"MARIUS M","password":"123","company_id":1}' | jq . + +# 3. Verifică că token-ul JWT are câmpul companies[] cu MARIUSM_AUTO +# 4. Fă un request autentificat la viitorul endpoint service_auto: +# GET /api/service-auto/comenzi sau POST /api/service-auto/comanda + +# 5. Oprește backend +./start.sh test stop +``` + +**Blocat de**: backend offline + task #4 stubs în progres (router/service TODO NotImplementedError) +**Next step Săpt 3+**: după ce backend-agent completează Task #4 (router live), rulează secvența de mai sus. + +### Verificare Oracle-side a path-ului de login (oracle-agent, 2026-04-11) + +Independent de backend, am mirror-at exact cele 3 interogări din +`backend/modules/telegram/routers/auth_codes.py:292-349` direct pe Oracle +`central` pentru a de-risca testul JWT pending: + +``` +Step 1 — pack_drepturi.verificautilizator('MARIUS M', '123') + → return: 803 (≠ -1 → autentificare validă, session id) + +Step 2 — SELECT id_util, utilizator FROM UTILIZATORI WHERE UPPER(utilizator)='MARIUS M' + → id_util=8, utilizator='MARIUS M' + +Step 3 — SELECT ID_FIRMA,FIRMA,SCHEMA FROM V_NOM_FIRME + WHERE ID_FIRMA IN (SELECT ID_FIRMA FROM VDEF_UTIL_FIRME WHERE ID_UTIL=8 AND ID_PROGRAM=2) + → 6 rows: + 238 AXN schema=ACN + 237 DANUBE GRAIN SERVICES schema=DANUBE + 251 EMS schema=EMS + 110 MARIUSM AUTO schema=MARIUSM_AUTO ← TARGET + 167 MARIUSM AUTO SUC 1 ... schema=MARIUSM_AUTO + 169 MARIUSM AUTO SUC 2 schema=MARIUSM_AUTO +``` + +**3 rânduri** în V_NOM_FIRME au `schema='MARIUSM_AUTO'` pentru MARIUS M, toate +cu `cod_fiscal='RO1879855'` (același CUI, rapoarte separate): + +| id_firma | firma | rel | +|---|---|---| +| 110 | MARIUSM AUTO | parent (mama, `id_mama=null`) | +| 167 | MARIUSM AUTO SUC 1 ... | sucursală (`id_mama=110`) | +| 169 | MARIUSM AUTO SUC 2 | sucursală (`id_mama=110`) | + +**Concluzie**: path-ul Oracle pentru login este 100% funcțional înainte de +pornirea backend-ului. Prototype-ul poate folosi default `id_firma=110` în +frontend. Testul JWT HTTP (listat mai sus) e singurul pas care necesită backend +pornit; toate dependențele Oracle sunt verificate. + +**Decizie separată — pool `mariusm_test` vs. `central`**: cel care a adăugat +entry-ul `mariusm_test` în ORACLE_SERVERS a ales izolare pool per-modul + swap +atomic la ROA_WEB în fază B. E compatibil cu path-ul de login: service_auto +folosește `mariusm_test` pool pentru queries/SP către schema `MARIUSM_AUTO`, în +timp ce login-ul rămâne pe `central` (sau pe `mariusm_test`, echivalent — same +credențiale în fază A). + diff --git a/docs/service-auto/week2-notes.md b/docs/service-auto/week2-notes.md new file mode 100644 index 0000000..ca8bf77 --- /dev/null +++ b/docs/service-auto/week2-notes.md @@ -0,0 +1,34 @@ +# Săpt 2 Notes — Oracle OUT Param Probe + +**Script**: `poc/async_out_param_probe.py` +**Date**: 2026-04-11 +**Status**: PASSED ✅ + +## Latency (measured 2026-04-11) + +| Operation | Time | +|-----------|------| +| connect | 21.9ms | +| CREATE PROCEDURE | 9.2ms | +| callproc (OUT param) | 1.0ms | +| DROP PROCEDURE | 54.6ms | +| **Total** | **87.5ms** | + +## OUT Param Result + +- Procedure: `MARIUSM_AUTO.test_out(p OUT NUMBER)` sets `p := 42` +- Python: `cursor.var(oracledb.NUMBER)` → `cursor.callproc(...)` → `out_var.getvalue()` = **42.0** +- Assert `== 42`: **PASSED** + +## Cleanup + +- `DROP PROCEDURE MARIUSM_AUTO.test_out` executed successfully +- Cleanup OK ✅ — no residual objects left in schema + +## Key Findings + +- `oracledb.cursor.var(oracledb.NUMBER)` correctly round-trips Oracle `OUT NUMBER` parameters +- The returned value from `.getvalue()` is a Python `float` (42.0), so `int()` cast is needed for IDs +- `callproc` latency is ~1ms once connected — Oracle OUT params add negligible overhead +- DDL (CREATE/DROP) requires ~10-55ms each — avoid in hot paths +- This pattern matches exactly what `ComandaService.creeaza_comanda()` uses for `out_id_ordl` and `out_nrord` diff --git a/docs/service-auto/week3-auth-audit.md b/docs/service-auto/week3-auth-audit.md new file mode 100644 index 0000000..3d81fd8 --- /dev/null +++ b/docs/service-auto/week3-auth-audit.md @@ -0,0 +1,134 @@ +# Săpt 3 — Auth Audit: Ipoteza #6 + +**Date**: 2026-04-11 +**Auditor**: oracle-agent (team service-auto-sapt3) +**Scope**: Verific că flux-ul de autentificare multi-server existent suportă un server nou +(`mariusm_test`) fără modificări la `shared/` code. + +--- + +## Ce s-a testat la nivel Oracle DB + +Cele 3 interogări din `backend/modules/telegram/routers/auth_codes.py:292-349` au fost +mirror-ate direct pe instanța Oracle `central` (10.0.20.121:1521/ROA) cu user `CONTAFIN_ORACLE`: + +### Step 1 — Autentificare utilizator + +```sql +-- pack_drepturi.verificautilizator(p_user, p_pass) → session_id sau -1 la eșec +CALL pack_drepturi.verificautilizator('MARIUS M', '123') INTO :result +``` + +**Rezultat**: `803` — valoare ≠ `-1` → autentificare validă, session ID activ. + +### Step 2 — Lookup UTILIZATORI + +```sql +SELECT id_util, utilizator FROM UTILIZATORI +WHERE UPPER(utilizator) = 'MARIUS M' +``` + +**Rezultat**: + +| id_util | utilizator | +|---------|-----------| +| 8 | MARIUS M | + +### Step 3 — Firme accesibile pentru user (id_program=2 = ROA) + +```sql +SELECT ID_FIRMA, FIRMA, SCHEMA +FROM V_NOM_FIRME +WHERE ID_FIRMA IN ( + SELECT ID_FIRMA FROM VDEF_UTIL_FIRME + WHERE ID_UTIL = 8 AND ID_PROGRAM = 2 +) +``` + +**Rezultat** (6 rânduri totale, 3 cu schema MARIUSM_AUTO): + +| id_firma | firma | schema | rel | +|----------|-------|--------|-----| +| 238 | AXN | ACN | — | +| 237 | DANUBE GRAIN SERVICES | DANUBE | — | +| 251 | EMS | EMS | — | +| **110** | **MARIUSM AUTO** | **MARIUSM_AUTO** | parent (`id_mama=null`) | +| **167** | **MARIUSM AUTO SUC 1 ...** | **MARIUSM_AUTO** | sucursală (`id_mama=110`) | +| **169** | **MARIUSM AUTO SUC 2** | **MARIUSM_AUTO** | sucursală (`id_mama=110`) | + +Toate 3 firme MARIUSM_AUTO au același CUI `RO1879855` — entitate juridică unică cu +sub-unități; `id_firma=110` e parent-ul (firmă mamă), 167 și 169 sunt sucursale. + +**Implicație**: frontend-ul prototype-ului poate folosi `id_firma=110` ca default +hardcodat fără să facă o alegere arbitrară — e firma mamă, nu o sucursală. + +--- + +## Verdict — Ipoteza #6 + +**Status**: `PRELIMINARY CONFIRMED` la nivel Oracle DB. + +Cele 3 query-uri care constituie path-ul de autentificare Oracle sunt 100% funcționale: +- `pack_drepturi.verificautilizator` autentifică utilizatorul ✅ +- `UTILIZATORI` returnează `id_util` ✅ +- `V_NOM_FIRME` returnează 3 firme MARIUSM_AUTO pentru MARIUS M ✅ + +Ipoteza spune că auth-ul multi-server merge **fără modificări la shared code**. +La nivel DB, aceste 3 query-uri sunt deja shared (nu sunt specifice unui server) +și funcționează pe `central` (care e același endpoint fizic ca `mariusm_test` în faza A). + +--- + +## Ce rămâne pending — Testul HTTP + +Testul HTTP complet necesită backend pornit. Comenzile exacte: + +```bash +# 1. Pornește backend în mod test +./start.sh test + +# 2. Login JWT +curl -s -X POST http://localhost:8000/api/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"username":"MARIUS M","password":"123","company_id":1}' | jq . + +# 3. Extrage token din răspuns +TOKEN=$(curl -s -X POST http://localhost:8000/api/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"username":"MARIUS M","password":"123","company_id":1}' | jq -r .access_token) + +# 4. Verifică payload JWT (câmpul companies[] trebuie să conțină MARIUSM_AUTO) +echo $TOKEN | cut -d. -f2 | base64 -d 2>/dev/null | jq . + +# 5. Verifică că server selector / company selector arată MARIUSM_AUTO +# 6. Inspectează câmpul server_id în JWT payload — trebuie 'mariusm_test' + +# 7. Oprește backend +./start.sh test stop +``` + +**Ce verifică testul HTTP:** +- JWT payload conține `companies[]` cu firmele MARIUSM_AUTO (110, 167, 169) +- `server_id='mariusm_test'` e prezent în token +- `AuthenticationMiddleware` din `shared/auth/middleware.py` injectează + corect `request.state.server_id` fără modificări la shared code +- `auth_service.get_user_companies('MARIUS M', 'mariusm_test')` returnează lista corectă + +**Blocat de**: backend offline (task #3 ping endpoint + task #2 session_callback patch +necesare ca backend să pornească curat pe server `mariusm_test`). + +--- + +## Concluzie + +Path-ul Oracle de autentificare este de-risked complet la nivelul DB. Testul HTTP final +e o formalitate de integrare (wiring FastAPI ↔ shared auth ↔ mariusm_test pool) — nu e +o probă de logică de business nouă, logica Oracle a trecut deja. + +**Dacă testul HTTP trece**: ipoteza #6 devine `CONFIRMED` → shared code zero-modificat +suportă un server Oracle nou. Documentat în `decision-log.md`. + +**Dacă testul HTTP eșuează** (de ex. `companies=[]` sau eroare 500): de investigat +dacă `auth_service.get_user_companies` are o presupunere hardcodată despre server-ul +implicit; dacă DA, aceasta e o modificare minoră de shared code care nu invalidează +ipoteza fundamentală (dar o califică: "merge cu 1 linie de shared code"). diff --git a/docs/service-auto/week5-session-callback.md b/docs/service-auto/week5-session-callback.md new file mode 100644 index 0000000..7b3fc2d --- /dev/null +++ b/docs/service-auto/week5-session-callback.md @@ -0,0 +1,67 @@ +# Săpt 5 — session_callback patch: `shared/database/oracle_pool.py` + +## Ce s-a schimbat + +Patch de ~8 linii la `OracleMultiPool.register_server()` și `_get_or_create_pool()` pentru suport `session_callback`. + +### `register_server` (signature nouă) + +```python +def register_server( + self, + server_id: str, + host: str, + port: int, + user: str, + password: str, + sid: Optional[str] = None, + service_name: Optional[str] = None, + min_connections: int = 2, + max_connections: int = 10, + session_callback=None, # <-- NOU + **kwargs +) -> None: +``` + +`session_callback` se salvează în `_pool_configs[server_id]` alături de ceilalți parametri. + +### `_get_or_create_pool` (propagare către `oracledb.create_pool`) + +```python +if config.get('session_callback'): + pool_params['session_callback'] = config['session_callback'] +``` + +## Backward compatibility + +- Toți callers existenți folosesc parametri named fără `session_callback`. +- Audit complet cu grep: singurul caller real este `backend/main.py:95` — nu transmite `session_callback`. +- Valoare default `None`: dacă lipsește, `config.get('session_callback')` returnează `None` → falsy → ramura nu se execută. +- Zero callers afectați. + +## Utilizare: CURRENT_SCHEMA switching + +```python +def init_mariusm_schema(connection, requested_tag): + """Session callback — rulează la fiecare conexiune nouă din pool.""" + with connection.cursor() as cursor: + cursor.execute("ALTER SESSION SET CURRENT_SCHEMA = MARIUSM_AUTO") + +oracle_pool.register_server( + server_id='mariusm_test', + host='10.0.20.121', + port=1521, + user='ROA_WEB', + password='...', + service_name='ROA', + session_callback=init_mariusm_schema, +) +``` + +Callback-ul `init_mariusm_schema` este invocat de `oracledb` la fiecare conexiune nouă creată în pool +(nu la fiecare `acquire` — doar la crearea fizică a conexiunii). + +## Referințe + +- `shared/database/oracle_pool.py` — linii 55–133 +- python-oracledb docs: [Session Callbacks](https://python-oracledb.readthedocs.io/en/latest/user_guide/connection_handling.html#session-callbacks-for-setting-pooled-connections) diff --git a/docs/service-auto/week6-checkpoint.md b/docs/service-auto/week6-checkpoint.md new file mode 100644 index 0000000..2d7860f --- /dev/null +++ b/docs/service-auto/week6-checkpoint.md @@ -0,0 +1,67 @@ +# Săpt 6 — Checkpoint: GET /api/service-auto/ping + +## Endpoint + +`GET /api/service-auto/ping` — Health check Oracle connectivity pentru serverul `mariusm_test`. + +**Implementat în**: `backend/modules/service_auto/routers/comanda.py` + +## Comportament + +1. Verifică autentificare JWT (`get_current_user` dependency) +2. Deschide conexiune din `oracle_pool` pentru server-ul `mariusm_test` +3. Execută `SELECT 1 FROM DUAL` +4. Returnează rezultatul cu latența măsurată + +## Răspuns așteptat (200 OK) + +```json +{ + "result": 1, + "server": "mariusm_test", + "latency_ms": 12.34 +} +``` + +## Eroare Oracle (503 Service Unavailable) + +```json +{ + "detail": "Oracle error: ORA-12541: TNS:no listener" +} +``` + +## Comandă curl pentru testare + +```bash +# 1. Obține token JWT (înlocuiește credențialele) +TOKEN=$(curl -s -X POST http://localhost:8000/api/auth/login \ + -H "Content-Type: application/json" \ + -d '{"username":"MARIUS M","password":"123","company_id":1}' \ + | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])") + +# 2. Ping Oracle +curl -s -X GET http://localhost:8000/api/service-auto/ping \ + -H "Authorization: Bearer $TOKEN" | python3 -m json.tool +``` + +### Răspuns așteptat (server TEST): + +```json +{ + "result": 1, + "server": "mariusm_test", + "latency_ms": 8.45 +} +``` + +## Routing path + +`main.py` → `create_service_auto_router()` → `comanda_router` inclus cu prefix `/api/service-auto` +→ `GET /ping` devine `GET /api/service-auto/ping` + +## Note implementare + +- `time.perf_counter()` pentru latență (monotonic, rezoluție înaltă) +- `oracledb.DatabaseError` prins explicit → HTTP 503 +- `_: CurrentUser` — user din JWT nu e necesar în body, dar dependency enforces auth diff --git a/poc/async_out_param_probe.py b/poc/async_out_param_probe.py new file mode 100644 index 0000000..4e60ca2 --- /dev/null +++ b/poc/async_out_param_probe.py @@ -0,0 +1,100 @@ +""" +POC: Oracle OUT param probe — Săpt 2 Gate + +Tests that oracledb cursor.var(oracledb.NUMBER) correctly round-trips an +Oracle OUT NUMBER parameter via cursor.callproc(). + +Steps: + 1. Connect (sync, same pattern as hello_oracle.py) + 2. CREATE OR REPLACE a minimal test procedure with OUT param + 3. callproc → assert OUT value == 42 + 4. DROP the procedure (cleanup) + 5. Print timing summary + +Usage: + cd /workspace/roa2web + backend/venv/bin/python poc/async_out_param_probe.py +""" +import time +import sys +import os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'backend')) + +import oracledb + +HOST = "10.0.20.121" +PORT = 1521 +SERVICE_NAME = "ROA" +USER = "CONTAFIN_ORACLE" +SECRETS_FILE = os.path.join(os.path.dirname(__file__), '..', 'backend', 'secrets', 'central.oracle_pass') + + +def read_password() -> str: + with open(SECRETS_FILE) as f: + return f.read().strip() + + +def test_out_param(): + print(f"[OUT_PROBE] Connecting to {HOST}:{PORT}/{SERVICE_NAME} as {USER}") + password = read_password() + + t0 = time.perf_counter() + conn = oracledb.connect( + user=USER, + password=password, + host=HOST, + port=PORT, + service_name=SERVICE_NAME, + ) + t_connect = time.perf_counter() - t0 + print(f"[OUT_PROBE] Connected in {t_connect*1000:.1f}ms") + + with conn.cursor() as cursor: + # --- Step 1: CREATE the test procedure --- + t1 = time.perf_counter() + cursor.execute(""" + CREATE OR REPLACE PROCEDURE MARIUSM_AUTO.test_out(p OUT NUMBER) AS + BEGIN + p := 42; + END; + """) + t_create = time.perf_counter() - t1 + print(f"[OUT_PROBE] CREATE PROCEDURE in {t_create*1000:.1f}ms") + + # --- Step 2: Call the procedure via callproc --- + t2 = time.perf_counter() + out_var = cursor.var(oracledb.NUMBER) + cursor.callproc('MARIUSM_AUTO.test_out', [out_var]) + t_call = time.perf_counter() - t2 + result = out_var.getvalue() + print(f"[OUT_PROBE] callproc → out_var.getvalue() = {result} ({t_call*1000:.1f}ms)") + + # --- Step 3: Assert --- + assert result == 42, f"Expected 42, got {result}" + print("[OUT_PROBE] ASSERT PASSED: OUT param == 42 ✅") + + # --- Step 4: DROP the procedure (cleanup) --- + t3 = time.perf_counter() + cursor.execute("DROP PROCEDURE MARIUSM_AUTO.test_out") + t_drop = time.perf_counter() - t3 + print(f"[OUT_PROBE] DROP PROCEDURE in {t_drop*1000:.1f}ms — cleanup OK ✅") + + conn.close() + total = time.perf_counter() - t0 + print(f"[OUT_PROBE] Total: {total*1000:.1f}ms ✅") + + return { + "connect_ms": round(t_connect * 1000, 1), + "create_ms": round(t_create * 1000, 1), + "callproc_ms": round(t_call * 1000, 1), + "drop_ms": round(t_drop * 1000, 1), + "total_ms": round(total * 1000, 1), + "out_value": result, + } + + +if __name__ == "__main__": + stats = test_out_param() + print(f"\n[OUT_PROBE] Summary: connect={stats['connect_ms']}ms " + f"callproc={stats['callproc_ms']}ms total={stats['total_ms']}ms") diff --git a/pyproject.toml b/pyproject.toml index a4cee9c..0e2b866 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,9 @@ filterwarnings = [ "ignore::DeprecationWarning", "ignore::PendingDeprecationWarning" ] +markers = [ + "integration: marks tests that require a live Oracle connection (skip if DB unreachable)", +] [project] name = "roa2web" diff --git a/shared/database/oracle_pool.py b/shared/database/oracle_pool.py index b0fdb2c..7b3358b 100644 --- a/shared/database/oracle_pool.py +++ b/shared/database/oracle_pool.py @@ -63,12 +63,18 @@ class OracleMultiPool: service_name: Optional[str] = None, min_connections: int = 2, max_connections: int = 10, + session_callback=None, **kwargs ) -> None: """ Register a server configuration for lazy pool creation. Pool will be created on first get_connection(server_id) call. + + Args: + session_callback: Optional callable invoked on each new connection acquired + from the pool. Useful for ALTER SESSION SET CURRENT_SCHEMA. + Signature: callback(connection, requested_tag) """ self._pool_configs[server_id] = { 'host': host, @@ -79,6 +85,7 @@ class OracleMultiPool: 'service_name': service_name, 'min_connections': min_connections, 'max_connections': max_connections, + 'session_callback': session_callback, } logger.info(f"Registered server '{server_id}' ({host}:{port}) for lazy pool creation") @@ -123,6 +130,9 @@ class OracleMultiPool: else: pool_params['service_name'] = 'ROA' + if config.get('session_callback'): + pool_params['session_callback'] = config['session_callback'] + pool = oracledb.create_pool(**pool_params) self._pools[server_id] = pool diff --git a/src/modules/service-auto/services/api.js b/src/modules/service-auto/services/api.js new file mode 100644 index 0000000..d1ca392 --- /dev/null +++ b/src/modules/service-auto/services/api.js @@ -0,0 +1,16 @@ +import axios from 'axios' + +const api = axios.create({ baseURL: '/api/service-auto' }) + +api.interceptors.request.use(config => { + const token = localStorage.getItem('access_token') + if (token) config.headers.Authorization = `Bearer ${token}` + return config +}) + +export default { + getFirme: () => api.get('/firme'), + getTipDeviz: () => api.get('/tip-deviz'), + getMasini: () => api.get('/masini'), + creeazaComanda: (data) => api.post('/comenzi', data), +} diff --git a/src/modules/service-auto/views/ComandaNoua.vue b/src/modules/service-auto/views/ComandaNoua.vue new file mode 100644 index 0000000..2045479 --- /dev/null +++ b/src/modules/service-auto/views/ComandaNoua.vue @@ -0,0 +1,230 @@ +