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

372 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 "<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):
```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 |
|---|---|---|---|
| 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:
```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
```