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>
372 lines
14 KiB
Markdown
372 lines
14 KiB
Markdown
# 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 |
|
||
|---|---|---|---|
|
||
| 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
|
||
```
|