Files
roa2web-service-auto/docs/service-auto/template-modul-oracle.md
Claude Agent 32aca55c78 feat(service-auto): săpt 3-phase2 — toate ipotezele confirmate + modul funcțional
Backend:
- service_auto module complet: router, service, schemas, 5 teste suites (22/22 passed)
- 5 endpoints: GET /ping, /firme, /tip-deviz, /masini, POST /comenzi
- SP_CREEAZA_COMANDA_PROTOTIP creat în MARIUSM_AUTO (VALID, 5.9ms)
- oracle_pool.py: session_callback backward-compat patch
- ROA_WEB user: grants SP-only confirmate (H3), mariusm_test pool switchat
- pyproject.toml: integration pytest marker înregistrat

Frontend:
- ComandaNoua.vue: date reale din Oracle (firme/tip-deviz/masini), nu hardcodate
- src/modules/service-auto/services/api.js: axios service cu Bearer token
- src/router/index.js: rută /service-auto/comanda-noua

Docs:
- decision-log.md: verdict MERGE, toate 6 ipoteze CONFIRMED
- learnings.md: 7 patterns reutilizabile
- grants-audit.md: arhitectura multi-tenant + proxy auth analysis + V_NOM_FIRME loop
- template-modul-oracle.md: rețetă completă pentru module Oracle noi
- TODO-phase2.md: 7 items concrete

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 09:37:09 +00:00

14 KiB
Raw Blame History

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:

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.

-- O singură dată (DBA):
CREATE USER ROA_WEB IDENTIFIED BY "<parola_din_vault>";
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):

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

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

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

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ă floatobligatoriu 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
2000120999 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:

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

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)

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

# Î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