Compare commits
10 Commits
32aca55c78
...
22f66c4633
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22f66c4633 | ||
|
|
f115b5e35a | ||
|
|
fd64cf3f1e | ||
|
|
4397027f36 | ||
|
|
ee6d857e9d | ||
|
|
31d1f511c3 | ||
|
|
cc24aacfdf | ||
|
|
3cbf947d84 | ||
|
|
0a880baef9 | ||
|
|
574aca31e4 |
71
.claude/rules/oracle-migrations.md
Normal file
71
.claude/rules/oracle-migrations.md
Normal file
@@ -0,0 +1,71 @@
|
||||
---
|
||||
paths: "**/*.sql,docs/service-auto/migrations/**"
|
||||
---
|
||||
|
||||
# Oracle SQL Migration Script Rules
|
||||
|
||||
You are an Oracle SQL migration script writer. Transform raw DDL/DML into idempotent migration scripts following these rules:
|
||||
|
||||
## STRUCTURE
|
||||
- Header comment: `-- brief description` (e.g. `-- adaugare coloana nom_firme.caen_revizie`)
|
||||
- Body (idempotency rules below)
|
||||
- Footer: `exec pack_migrare.UpdateVersiune('<filename_without_.sql>'); commit;`
|
||||
|
||||
## IDEMPOTENCY RULES
|
||||
|
||||
1. **ALTER TABLE ADD COLUMN** → wrap in:
|
||||
```sql
|
||||
BEGIN
|
||||
IF PACK_MIGRARE.COLUMNEXIST('TABLE','COL')=0 THEN
|
||||
EXECUTE IMMEDIATE '...';
|
||||
END IF;
|
||||
END;
|
||||
/
|
||||
```
|
||||
|
||||
2. **CREATE OR REPLACE VIEW/PROCEDURE/FUNCTION** → keep as-is (already idempotent)
|
||||
|
||||
3. **INSERT** → replace with:
|
||||
```sql
|
||||
MERGE INTO table USING DUAL ON (key condition)
|
||||
WHEN NOT MATCHED THEN INSERT (cols) VALUES (vals);
|
||||
```
|
||||
|
||||
4. **UPDATE** → keep as-is
|
||||
|
||||
5. **CREATE TABLE** → wrap in:
|
||||
```sql
|
||||
BEGIN
|
||||
IF PACK_MIGRARE.OBJECTEXIST('TABLE','TABLE')=0 THEN
|
||||
EXECUTE IMMEDIATE '...';
|
||||
END IF;
|
||||
END;
|
||||
/
|
||||
```
|
||||
|
||||
6. **DROP** → wrap in:
|
||||
```sql
|
||||
BEGIN
|
||||
IF PACK_MIGRARE.OBJECTEXIST('OBJ')=1 THEN
|
||||
EXECUTE IMMEDIATE 'DROP...';
|
||||
END IF;
|
||||
END;
|
||||
/
|
||||
```
|
||||
|
||||
7. **COMMENT ON** → keep as plain DDL (not inside EXECUTE IMMEDIATE)
|
||||
|
||||
8. In MERGE/INSERT: omit NULL-valued columns and CLOB columns entirely
|
||||
|
||||
## FILENAME CONVENTION
|
||||
|
||||
```
|
||||
ff_YYYY_MM_DD_NN_<MODULE>.sql
|
||||
```
|
||||
- `YYYY_MM_DD` — data migrării
|
||||
- `NN` — secvență 2 cifre (01, 02...)
|
||||
- `<MODULE>` — modulul căruia îi aparține migrarea (ex: AUTO, FACTURARE, CONTAB)
|
||||
|
||||
## LANGUAGE
|
||||
Comments: write in Romanian.
|
||||
Output: only the SQL script, no explanation.
|
||||
46
PROVENANCE.md
Normal file
46
PROVENANCE.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Proveniență — roa2web-service-auto
|
||||
|
||||
> Acest repo a fost **desprins din `roa2web`** (gitea.romfast.ro/romfast/roa2web) ca proiect
|
||||
> separat, pe 2026-06-13, ca să nu se piardă și să poată evolua independent.
|
||||
|
||||
## Ce este
|
||||
|
||||
Modul de **gestiune comenzi service auto** (atelier reparații auto): deschidere comandă de
|
||||
lucru → lucrări, piese, inspector, sucursală, cu numerotare și scriere în Oracle prin pack-uri
|
||||
proprii (`PACK_AUTO`, `PACK_SERII_NUMERE`, `PACK_SESIUNE`).
|
||||
|
||||
Domeniu **distinct** de misiunea `roa2web` (raportare financiară + data entry bonuri de cheltuieli).
|
||||
|
||||
## Relația cu roa2web
|
||||
|
||||
- **Fork complet**: acest repo conține tot codul comun `roa2web` (auth/JWT, pool Oracle
|
||||
multi-tenant, shell-ul Vue SPA, design tokens, componenta reutilizabilă `AsyncAutoComplete`)
|
||||
PLUS modulul service-auto. Rulează standalone, nu depinde de roa2web la runtime.
|
||||
- **Strămoș comun**: forkat din `roa2web` la commitul `b0f4800`. Fix-urile relevante din
|
||||
roa2web pot fi aduse aici prin `cherry-pick`; invers, `AsyncAutoComplete.vue` și pattern-urile
|
||||
Oracle pot fi împrumutate înapoi în roa2web.
|
||||
|
||||
## Cod specific service-auto
|
||||
|
||||
| Zonă | Cale |
|
||||
|------|------|
|
||||
| Backend | `backend/modules/service_auto/` (router/service/schemas/tests) |
|
||||
| Frontend | `src/modules/service-auto/` + `src/shared/components/AsyncAutoComplete.vue` |
|
||||
| Oracle (pack-uri, DDL, migrări) | `docs/service-auto/` (`pack_auto.pck`, `pack_sesiune.pck`, `migrations/ff_2026_04_*_AUTO.sql`) |
|
||||
| POC-uri | `poc/` |
|
||||
| Documentație & decizii | `docs/service-auto/` (decision-log, learnings, grants-audit, template modul) |
|
||||
|
||||
## Stare la desprindere
|
||||
|
||||
⚠️ **WIP**. Ultimul commit moștenit din dezvoltare era `f115b5e` „modificari in curs nu stiu
|
||||
care este faza" (2026-06-05) — proiectul NU era la un checkpoint curat. Înainte de orice deploy:
|
||||
rebază mental pe contractul curent + adu modulul la o fază clară (vezi `docs/service-auto/decision-log.md`
|
||||
și notele săptămânale `week*-notes.md`).
|
||||
|
||||
Teste auto-raportate la desprindere: 62 passed / 3 skipped (dependente de Oracle live; vezi decision-log
|
||||
pentru cele 3 skip pe contract SP prototip depășit).
|
||||
|
||||
## Referință în roa2web
|
||||
|
||||
Decizia de desprindere e documentată în roa2web la `docs/prd/prd-0.6-audit-service-auto.md`
|
||||
(audit + decizie inițială PARK, urmată de extragere în acest repo).
|
||||
@@ -1,16 +1,18 @@
|
||||
import time
|
||||
from typing import List
|
||||
from datetime import date
|
||||
from typing import List, Optional
|
||||
|
||||
import oracledb
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
|
||||
from shared.auth.dependencies import get_current_user
|
||||
from shared.auth.models import CurrentUser
|
||||
from shared.database.oracle_pool import oracle_pool
|
||||
|
||||
from ..schemas.comanda import (
|
||||
ComandaRequest, ComandaResponse,
|
||||
FirmaItem, TipDevizItem, MasinaClientItem,
|
||||
AsiguratorItem, ComandaListResponse, ComandaRequest, ComandaResponse,
|
||||
FirmaItem, InspectorItem, MasinaClientItem, MasinaDetails,
|
||||
OperatieItem, PartenerItem, PartnerCreateRequest, TipDevizItem,
|
||||
)
|
||||
from ..services.comanda_service import ComandaService
|
||||
from ..services.lookup_service import LookupService
|
||||
@@ -18,48 +20,192 @@ from ..services.lookup_service import LookupService
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _server_id(request: Request) -> Optional[str]:
|
||||
"""Extrage server_id injectat de AuthenticationMiddleware din JWT."""
|
||||
return getattr(request.state, "server_id", None)
|
||||
|
||||
|
||||
def _company_id(
|
||||
current_user: CurrentUser,
|
||||
explicit: Optional[int],
|
||||
) -> int:
|
||||
"""
|
||||
Rezolvă id_firma: query/body param dacă e dat, altfel prima firmă din JWT.
|
||||
Validează că firma e printre cele autorizate în JWT.
|
||||
"""
|
||||
if explicit is not None:
|
||||
cid = explicit
|
||||
else:
|
||||
if not current_user.companies:
|
||||
raise HTTPException(status_code=400, detail="Niciun id_firma disponibil în JWT.")
|
||||
cid = int(current_user.companies[0])
|
||||
|
||||
allowed = {int(c) for c in current_user.companies}
|
||||
if cid not in allowed:
|
||||
raise HTTPException(status_code=403, detail="Firmă neautorizată pentru utilizator.")
|
||||
return cid
|
||||
|
||||
|
||||
@router.get("/ping")
|
||||
async def ping(_: CurrentUser = Depends(get_current_user)):
|
||||
"""Health check: verifies Oracle connectivity for mariusm_test server."""
|
||||
async def ping(
|
||||
request: Request,
|
||||
_: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
"""Health check: verifies Oracle connectivity pe serverul curent."""
|
||||
t0 = time.perf_counter()
|
||||
server_id = _server_id(request)
|
||||
try:
|
||||
async with oracle_pool.get_connection('mariusm_test') as conn:
|
||||
async with oracle_pool.get_connection(server_id) as conn:
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute('SELECT 1 FROM DUAL')
|
||||
row = cursor.fetchone()
|
||||
except oracledb.DatabaseError as e:
|
||||
raise HTTPException(status_code=503, detail=f"Oracle error: {e}")
|
||||
elapsed_ms = round((time.perf_counter() - t0) * 1000, 2)
|
||||
return {"result": row[0], "server": "mariusm_test", "latency_ms": elapsed_ms}
|
||||
return {"result": row[0], "server": server_id or "(default)", "latency_ms": elapsed_ms}
|
||||
|
||||
|
||||
@router.get("/firme", response_model=List[FirmaItem])
|
||||
async def get_firme(current_user: CurrentUser = Depends(get_current_user)):
|
||||
async def get_firme(
|
||||
request: Request,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
"""Firmele accesibile utilizatorului curent (din JWT companies[])."""
|
||||
return await LookupService.get_firme(current_user.companies)
|
||||
return await LookupService.get_firme(current_user.companies, _server_id(request))
|
||||
|
||||
|
||||
@router.get("/tip-deviz", response_model=List[TipDevizItem])
|
||||
async def get_tip_deviz(_: CurrentUser = Depends(get_current_user)):
|
||||
"""Tipuri de deviz din DEV_TIP_DEVIZ."""
|
||||
return await LookupService.get_tip_deviz()
|
||||
async def get_tip_deviz(
|
||||
request: Request,
|
||||
id_firma: Optional[int] = Query(default=None, ge=1),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
"""Tipuri de deviz din DEV_TIP_DEVIZ (scoped pe schema firmei)."""
|
||||
cid = _company_id(current_user, id_firma)
|
||||
return await LookupService.get_tip_deviz(cid, _server_id(request))
|
||||
|
||||
|
||||
@router.get("/masini", response_model=List[MasinaClientItem])
|
||||
async def get_masini(_: CurrentUser = Depends(get_current_user)):
|
||||
"""Mașini active din AUTO_VMASINICLIENTI (toate firmele pe același server)."""
|
||||
return await LookupService.get_masini()
|
||||
async def get_masini(
|
||||
request: Request,
|
||||
id_firma: Optional[int] = Query(default=None, ge=1),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
"""Mașini active din AUTO_VMASINICLIENTI (scoped pe schema firmei)."""
|
||||
cid = _company_id(current_user, id_firma)
|
||||
return await LookupService.get_masini(cid, _server_id(request))
|
||||
|
||||
|
||||
@router.get("/comenzi", response_model=ComandaListResponse)
|
||||
async def list_comenzi(
|
||||
request: Request,
|
||||
page: int = Query(default=1, ge=1),
|
||||
per_page: int = Query(default=20, ge=1, le=100),
|
||||
validat: Optional[int] = Query(default=None, ge=0, le=1),
|
||||
data_de_la: Optional[date] = Query(default=None),
|
||||
data_pana_la: Optional[date] = Query(default=None),
|
||||
id_firma: Optional[int] = Query(default=None, ge=1),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
# DEV_ORDL n-are id_firma; toate firmele pe aceeași schemă împart comenzile.
|
||||
cid = _company_id(current_user, id_firma)
|
||||
return await ComandaService.get_comenzi(
|
||||
company_id=cid,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
validat=validat,
|
||||
data_de_la=data_de_la,
|
||||
data_pana_la=data_pana_la,
|
||||
server_id=_server_id(request),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/asiguratori", response_model=List[AsiguratorItem])
|
||||
async def get_asiguratori(
|
||||
request: Request,
|
||||
id_firma: Optional[int] = Query(default=None, ge=1),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
"""Asigurători din DEV_NOM_ASIGURATORI (scoped pe schema firmei)."""
|
||||
cid = _company_id(current_user, id_firma)
|
||||
return await LookupService.get_asiguratori(cid, _server_id(request))
|
||||
|
||||
|
||||
@router.get("/inspectori", response_model=List[InspectorItem])
|
||||
async def get_inspectori(
|
||||
request: Request,
|
||||
id_asigurator: int = Query(..., ge=1),
|
||||
id_firma: Optional[int] = Query(default=None, ge=1),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
"""Inspectori filtrați pe asigurator (scoped pe schema firmei)."""
|
||||
cid = _company_id(current_user, id_firma)
|
||||
return await LookupService.get_inspectori(id_asigurator, cid, _server_id(request))
|
||||
|
||||
|
||||
@router.get("/operatii", response_model=List[OperatieItem])
|
||||
async def get_operatii(
|
||||
request: Request,
|
||||
id_firma: Optional[int] = Query(default=None, ge=1),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
"""Lista completă operații DEV_NOM_NORME (scoped pe schema firmei)."""
|
||||
cid = _company_id(current_user, id_firma)
|
||||
return await LookupService.get_operatii(cid, _server_id(request))
|
||||
|
||||
|
||||
@router.get("/parteneri", response_model=List[PartenerItem])
|
||||
async def search_parteneri(
|
||||
request: Request,
|
||||
q: str = Query(..., min_length=2, max_length=100),
|
||||
id_firma: Optional[int] = Query(default=None, ge=1),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
"""Typeahead pe NOM_PARTENERI (scoped pe schema firmei)."""
|
||||
cid = _company_id(current_user, id_firma)
|
||||
return await LookupService.search_parteneri(q, cid, _server_id(request))
|
||||
|
||||
|
||||
@router.post("/parteneri", response_model=PartenerItem, status_code=201)
|
||||
async def create_partener(
|
||||
data: PartnerCreateRequest,
|
||||
request: Request,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
"""Creează partener nou în NOM_PARTENERI (scoped pe schema firmei din JWT)."""
|
||||
cid = _company_id(current_user, data.id_firma)
|
||||
data.id_firma = cid
|
||||
return await LookupService.create_partener(data, _server_id(request))
|
||||
|
||||
|
||||
@router.get("/masini/{id_masiniclient}/detalii", response_model=Optional[MasinaDetails])
|
||||
async def get_masina_detalii(
|
||||
id_masiniclient: int,
|
||||
request: Request,
|
||||
id_firma: Optional[int] = Query(default=None, ge=1),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
"""Detalii complete mașină pentru card readonly după selecție."""
|
||||
cid = _company_id(current_user, id_firma)
|
||||
return await LookupService.get_masina_details(id_masiniclient, cid, _server_id(request))
|
||||
|
||||
|
||||
@router.post("/comenzi", response_model=ComandaResponse)
|
||||
async def creeaza_comanda(
|
||||
data: ComandaRequest,
|
||||
request: Request,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
# data.id_firma e obligatoriu în body — validat via _company_id
|
||||
cid = _company_id(current_user, data.id_firma)
|
||||
# asigură consistența (dacă body trimite id_firma diferit de fallback)
|
||||
data.id_firma = cid
|
||||
try:
|
||||
return await ComandaService.creeaza_comanda(
|
||||
data=data,
|
||||
username=current_user.username,
|
||||
user_id=current_user.user_id,
|
||||
server_id=_server_id(request),
|
||||
)
|
||||
except NotImplementedError as e:
|
||||
raise HTTPException(status_code=501, detail=str(e))
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
from pydantic import BaseModel
|
||||
from datetime import date
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ComandaRequest(BaseModel):
|
||||
tip_id: int
|
||||
id_masiniclient: int
|
||||
solicitari: str
|
||||
id_firma: int
|
||||
id_sucursala: Optional[int] = None
|
||||
id_asigurator: Optional[int] = None
|
||||
id_inspector: Optional[int] = None
|
||||
id_part_ref: Optional[int] = None
|
||||
sir_id_operatii: Optional[List[int]] = None
|
||||
observatii: str = ""
|
||||
defectiuni: Optional[str] = None
|
||||
km_int: int = 0
|
||||
ore_functionare: int = 0
|
||||
nr_dosar: str = ""
|
||||
termen: Optional[date] = None
|
||||
|
||||
|
||||
class ComandaResponse(BaseModel):
|
||||
@@ -14,10 +27,55 @@ class ComandaResponse(BaseModel):
|
||||
mesaj: str
|
||||
|
||||
|
||||
class AsiguratorItem(BaseModel):
|
||||
id_asigurator: int
|
||||
denumire: str
|
||||
|
||||
|
||||
class InspectorItem(BaseModel):
|
||||
id_inspector: int
|
||||
denumire: str
|
||||
id_asigurator: int
|
||||
|
||||
|
||||
class OperatieItem(BaseModel):
|
||||
id_norme: int
|
||||
codop: str
|
||||
denop: str
|
||||
timpn: Optional[float] = None
|
||||
|
||||
|
||||
class PartenerItem(BaseModel):
|
||||
id_part: int
|
||||
denumire: str
|
||||
|
||||
|
||||
class PartnerCreateRequest(BaseModel):
|
||||
"""Payload pentru POST /parteneri — creare partener nou în NOM_PARTENERI."""
|
||||
denumire: str = Field(min_length=2, max_length=100)
|
||||
cui: Optional[str] = Field(default=None, max_length=30)
|
||||
adresa: Optional[str] = Field(default=None, max_length=150)
|
||||
id_firma: int = Field(ge=1)
|
||||
|
||||
|
||||
class MasinaDetails(BaseModel):
|
||||
id_masiniclient: int
|
||||
label: str
|
||||
nr_inmatriculare: Optional[str] = None
|
||||
marca: Optional[str] = None
|
||||
model: Optional[str] = None
|
||||
serie_sasiu: Optional[str] = None
|
||||
cilindree: Optional[int] = None
|
||||
putere_cp: Optional[int] = None
|
||||
putere_kw: Optional[int] = None
|
||||
client_nume: Optional[str] = None
|
||||
|
||||
|
||||
class FirmaItem(BaseModel):
|
||||
id_firma: int
|
||||
firma: str
|
||||
schema_name: str
|
||||
id_mama: Optional[int] = None
|
||||
|
||||
|
||||
class TipDevizItem(BaseModel):
|
||||
@@ -29,3 +87,22 @@ class TipDevizItem(BaseModel):
|
||||
class MasinaClientItem(BaseModel):
|
||||
id_masiniclient: int
|
||||
label: str # "PARTENER — MARCA MASINA, NRINMAT (ANFABRICATIE)"
|
||||
|
||||
|
||||
class ComandaListItem(BaseModel):
|
||||
id_ordl: int
|
||||
nrord: str
|
||||
datai: Optional[str] # ISO date "YYYY-MM-DD"
|
||||
validat: int # 0=deschisă, 1=validată
|
||||
inchis_fortat: int # 1=arhivată fără validare
|
||||
id_tip: int
|
||||
tip_denumire: str
|
||||
vehicul: str # "PARTENER — MARCA MASINA, NRINMAT (AN)"
|
||||
id_masiniclient: Optional[int]
|
||||
|
||||
|
||||
class ComandaListResponse(BaseModel):
|
||||
comenzi: List[ComandaListItem]
|
||||
total: int
|
||||
page: int
|
||||
per_page: int
|
||||
|
||||
65
backend/modules/service_auto/services/_context.py
Normal file
65
backend/modules/service_auto/services/_context.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
Multi-tenant context resolver for service_auto.
|
||||
|
||||
Pattern-ul urmează `modules/reports`:
|
||||
- `server_id` vine din JWT (`request.state.server_id`), propagat la `oracle_pool.get_connection(server_id)`.
|
||||
- `schema` se rezolvă din `CONTAFIN_ORACLE.V_NOM_FIRME` bazat pe `id_firma`, pe serverul utilizatorului.
|
||||
- Rezultatul e cached in-process 24h per (company_id, server_id).
|
||||
|
||||
NU introduce hardcodări de schemă sau server_id în service_auto. Toate query-urile SQL trebuie să
|
||||
folosească `f"{schema}.{TABLE}"`, iar toate `get_connection()` trebuie să primească `server_id`.
|
||||
"""
|
||||
import time
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import oracledb
|
||||
from fastapi import HTTPException
|
||||
|
||||
from shared.database.oracle_pool import oracle_pool
|
||||
|
||||
from .. import logger
|
||||
|
||||
_SCHEMA_TTL = 86400 # 24h — schema / firma binding changes via DB migration, not runtime
|
||||
_schema_cache: dict = {}
|
||||
|
||||
|
||||
async def get_schema(company_id: int, server_id: Optional[str]) -> str:
|
||||
"""
|
||||
Rezolvă schema Oracle pentru o firmă pe serverul curent al utilizatorului.
|
||||
|
||||
Query pe `CONTAFIN_ORACLE.V_NOM_FIRME` (prezent pe fiecare server în arhitectura ROA2WEB).
|
||||
Cached per (company_id, server_id) 24h.
|
||||
|
||||
Raises 422 dacă firma nu există pe serverul respectiv (misconfiguration).
|
||||
"""
|
||||
key = (company_id, server_id or "")
|
||||
entry: Optional[Tuple[float, str]] = _schema_cache.get(key)
|
||||
if entry and (time.monotonic() - entry[0]) < _SCHEMA_TTL:
|
||||
return entry[1]
|
||||
|
||||
try:
|
||||
async with oracle_pool.get_connection(server_id) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT schema FROM CONTAFIN_ORACLE.V_NOM_FIRME WHERE id_firma = :id",
|
||||
{"id": company_id},
|
||||
)
|
||||
row = cur.fetchone()
|
||||
except oracledb.DatabaseError:
|
||||
logger.error("service_auto._context.get_schema Oracle error", exc_info=True)
|
||||
raise HTTPException(status_code=503, detail="Eroare la rezolvarea schemei firmei")
|
||||
|
||||
if not row or not row[0]:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Firma {company_id} nu are schemă configurată pe serverul curent.",
|
||||
)
|
||||
|
||||
schema = row[0]
|
||||
_schema_cache[key] = (time.monotonic(), schema)
|
||||
return schema
|
||||
|
||||
|
||||
def reset_schema_cache() -> None:
|
||||
"""Test helper — clear the schema cache."""
|
||||
_schema_cache.clear()
|
||||
@@ -1,33 +1,111 @@
|
||||
import re
|
||||
from typing import NoReturn
|
||||
from datetime import date, datetime
|
||||
from typing import List, NoReturn, Optional, Tuple
|
||||
|
||||
import oracledb
|
||||
from fastapi import HTTPException
|
||||
from shared.database.oracle_pool import oracle_pool
|
||||
from ..schemas.comanda import ComandaRequest, ComandaResponse
|
||||
from ..schemas.comanda import (
|
||||
ComandaListItem, ComandaListResponse, ComandaRequest, ComandaResponse,
|
||||
)
|
||||
from .lookup_service import LookupService
|
||||
from ._context import get_schema
|
||||
from .. import logger
|
||||
|
||||
_MAX_PER_PAGE = 100
|
||||
_MAX_OPERATII_CSV = 4000 # Oracle VARCHAR2 limit; ~600 IDs at 6 chars each
|
||||
|
||||
# Source: DEV_TIP_DEVIZ (verified 2026-04-13):
|
||||
# 1=POST GARANTIE, 2=GARANTIE, 3=REGIE, 4=PREGATIRE, 5=REGIE 2 (no VFP mapping → ""),
|
||||
# 6=PRODUCTIE, 7=CONSTATARE
|
||||
# VFP reference: oproceduri_devize.prg lines 108-120 (pntipcom switch)
|
||||
_PREFIX_MAP = {1: "", 2: "G", 3: "R", 4: "P", 6: "PR", 7: "C"}
|
||||
|
||||
|
||||
def _aloca_numar_devize(
|
||||
cursor, schema: str, user_id: int, id_sucursala: int,
|
||||
) -> Tuple[int, int]:
|
||||
"""
|
||||
Calls {schema}.PACK_SERII_NUMERE.aloca_numar(20, NULL, NULL, user_id, id_sucursala) — 7-param overload.
|
||||
Returns (seq, id_numar):
|
||||
seq — the allocated command number (used in pc_nr)
|
||||
id_numar — SERII_NUMERE.ID_NUMAR row, used by dezaloca_id_numar compensating call
|
||||
"""
|
||||
out_numar = cursor.var(oracledb.NUMBER)
|
||||
out_id_numar = cursor.var(oracledb.NUMBER)
|
||||
cursor.callproc(
|
||||
f"{schema}.PACK_SERII_NUMERE.aloca_numar",
|
||||
[20, None, None, user_id, id_sucursala, out_numar, out_id_numar],
|
||||
)
|
||||
seq = int(out_numar.getvalue() or 0)
|
||||
id_numar = int(out_id_numar.getvalue() or 0)
|
||||
logger.info(
|
||||
"service_auto.seq_allocated",
|
||||
extra={"seq": seq, "id_numar": id_numar, "user_id": user_id, "id_sucursala": id_sucursala},
|
||||
)
|
||||
if seq <= 0:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Nu aveți serie alocată pentru comenzi devize. Contactați administratorul.",
|
||||
)
|
||||
return seq, id_numar
|
||||
|
||||
|
||||
def _dezaloca_numar_devize(
|
||||
cursor, schema: str, seq: int, id_numar: int, reason: str,
|
||||
) -> None:
|
||||
"""Compensating transaction — releases allocated seq number on callproc failure."""
|
||||
try:
|
||||
cursor.callproc(f"{schema}.PACK_SERII_NUMERE.dezaloca_id_numar", [id_numar])
|
||||
logger.info(
|
||||
"service_auto.seq_released",
|
||||
extra={"seq": seq, "id_numar": id_numar, "reason": reason},
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"SEQ_LEAK",
|
||||
extra={"seq": seq, "id_numar": id_numar, "reason": reason},
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
def _build_pc_nr(tip_id: int, seq: int, nr_inmatriculare: str) -> str:
|
||||
"""Format: <prefix><seq>/<nr_inmatriculare>"""
|
||||
prefix = _PREFIX_MAP.get(tip_id)
|
||||
if prefix is None:
|
||||
logger.warning("service_auto.unknown_tip_id", extra={"tip_id": tip_id})
|
||||
prefix = ""
|
||||
return f"{prefix}{seq}/{nr_inmatriculare}"
|
||||
|
||||
|
||||
def _build_sir_id_operatii(operatii: Optional[List[int]]) -> Optional[str]:
|
||||
"""Serializes list of operation IDs to CSV string for pcSirIdOperatii param."""
|
||||
if not operatii:
|
||||
return None
|
||||
csv = ",".join(str(i) for i in operatii)
|
||||
if len(csv) > _MAX_OPERATII_CSV:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Prea multe operații selectate (max ~{_MAX_OPERATII_CSV // 6}).",
|
||||
)
|
||||
return csv
|
||||
|
||||
|
||||
def _handle_oracle_error(e: Exception) -> NoReturn:
|
||||
"""
|
||||
Map Oracle error codes to FastAPI HTTPExceptions. Always raises.
|
||||
|
||||
Code ranges:
|
||||
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)
|
||||
"""
|
||||
"""Map Oracle error codes to FastAPI HTTPExceptions. Always raises."""
|
||||
err = e.args[0]
|
||||
code = getattr(err, "code", 0)
|
||||
raw_message = getattr(err, "message", str(e))
|
||||
|
||||
if 20001 <= code <= 20999:
|
||||
# Strip "ORA-2xxxx: " prefix injected by Oracle; expose the business message only
|
||||
if 20000 <= code <= 20999:
|
||||
# Strip "ORA-2xxxx: " prefix; strip "\nORA-06512: at ..." stack frames.
|
||||
clean = re.sub(r"^ORA-\d+:\s*", "", raw_message).strip()
|
||||
clean = clean.split("\n")[0].strip()
|
||||
raise HTTPException(status_code=422, detail=clean)
|
||||
|
||||
if code == 1438:
|
||||
raise HTTPException(status_code=422, detail="Valoare invalidă pentru câmp (ID prea mare)")
|
||||
|
||||
if code in (12541, 12170, 12154, 12560):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
@@ -57,53 +135,219 @@ class ComandaService:
|
||||
async def creeaza_comanda(
|
||||
data: ComandaRequest,
|
||||
username: str,
|
||||
user_id: Optional[int] = None,
|
||||
server_id: Optional[str] = None,
|
||||
) -> ComandaResponse:
|
||||
now = datetime.now()
|
||||
|
||||
schema = await get_schema(data.id_firma, server_id)
|
||||
|
||||
# Fetch vehicle details early: validates vehicle exists + gets nrinmat for pc_nr
|
||||
masina = await LookupService.get_masina_details(
|
||||
data.id_masiniclient, data.id_firma, server_id
|
||||
)
|
||||
if masina is None:
|
||||
raise HTTPException(status_code=422, detail="Mașina selectată nu există.")
|
||||
nr_inmatriculare = masina.nr_inmatriculare or "?"
|
||||
|
||||
pc_sir_id_operatii = _build_sir_id_operatii(data.sir_id_operatii)
|
||||
id_sucursala = data.id_sucursala or data.id_firma
|
||||
|
||||
logger.info(
|
||||
"service_auto.create_comanda START",
|
||||
extra={
|
||||
"user": username,
|
||||
"schema": schema,
|
||||
"server_id": server_id,
|
||||
"tip": data.tip_id,
|
||||
"client_id": data.id_masiniclient,
|
||||
"id_firma": data.id_firma,
|
||||
"km": data.km_int,
|
||||
"ore": data.ore_functionare,
|
||||
"id_asigurator": data.id_asigurator,
|
||||
"id_inspector": data.id_inspector,
|
||||
"nr_operatii": len(data.sir_id_operatii) if data.sir_id_operatii else 0,
|
||||
},
|
||||
)
|
||||
|
||||
async with oracle_pool.get_connection("mariusm_test") as connection:
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
out_id_ordl = cursor.var(oracledb.NUMBER)
|
||||
out_nrord = cursor.var(oracledb.STRING)
|
||||
async with oracle_pool.get_connection(server_id) as connection:
|
||||
with connection.cursor() as cursor:
|
||||
# Step 1: allocate sequence number via pack_serii_numere
|
||||
try:
|
||||
seq, id_numar = _aloca_numar_devize(
|
||||
cursor, schema, user_id or 0, id_sucursala
|
||||
)
|
||||
except oracledb.DatabaseError as e:
|
||||
try:
|
||||
connection.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
_handle_oracle_error(e)
|
||||
|
||||
pc_nr = _build_pc_nr(data.tip_id, seq, nr_inmatriculare)
|
||||
|
||||
# Step 2: create comanda; compensating dezaloca on DB failure.
|
||||
# pnIdOrdl is IN OUT — setvalue(0, 0) sets the IN side to 0;
|
||||
# Oracle overwrites it with the new DEV_ORDL.ID_ORDL.
|
||||
out_id_ordl = cursor.var(oracledb.NUMBER)
|
||||
out_id_ordl.setvalue(0, 0)
|
||||
|
||||
try:
|
||||
cursor.callproc(
|
||||
"MARIUSM_AUTO.SP_CREEAZA_COMANDA_PROTOTIP",
|
||||
f"{schema}.PACK_AUTO.dev_adauga_lucrare",
|
||||
[
|
||||
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
|
||||
schema, # v_gcs IN VARCHAR2
|
||||
now.year, # tnan IN NUMBER
|
||||
now.month, # tnluna IN NUMBER
|
||||
user_id or 0, # tnIdUtil IN NUMBER
|
||||
pc_nr, # pcNr IN VARCHAR2 (NOM_LUCRARI.NRORD)
|
||||
data.id_inspector, # pnIdInsp IN NUMBER
|
||||
data.id_asigurator, # pnIdAsig IN NUMBER
|
||||
data.nr_dosar or "", # pcNrDosar IN VARCHAR2
|
||||
data.id_masiniclient, # pnIdMC IN NUMBER
|
||||
data.km_int, # pnKmInt IN NUMBER
|
||||
data.ore_functionare, # pnOreFct IN NUMBER
|
||||
data.termen, # pdTermen IN DATE
|
||||
data.tip_id, # pnTipCom IN NUMBER
|
||||
pc_sir_id_operatii, # pcSirIdOperatii IN VARCHAR2 (None or CSV)
|
||||
data.observatii or None, # pcObservatii IN VARCHAR2
|
||||
data.defectiuni or None, # pcDefectiuni IN VARCHAR2
|
||||
data.id_part_ref or 0, # pnIdPartRef IN NUMBER (decode(0)→NULL in SP)
|
||||
out_id_ordl, # pnIdOrdl IN OUT NUMBER
|
||||
],
|
||||
)
|
||||
|
||||
connection.commit()
|
||||
except oracledb.DatabaseError as e:
|
||||
try:
|
||||
connection.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
_dezaloca_numar_devize(cursor, schema, seq, id_numar, "dev_adauga_lucrare_failed")
|
||||
try:
|
||||
# aloca uses AUTONOMOUS_TRANSACTION; survives our rollback. Commit dezaloca.
|
||||
connection.commit()
|
||||
except Exception:
|
||||
pass
|
||||
_handle_oracle_error(e)
|
||||
|
||||
id_ordl = int(out_id_ordl.getvalue())
|
||||
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)
|
||||
id_ordl = int(out_id_ordl.getvalue())
|
||||
|
||||
logger.info(
|
||||
"service_auto.create_comanda OK",
|
||||
extra={"user": username, "id_ordl": id_ordl, "nrord": nrord},
|
||||
extra={"user": username, "id_ordl": id_ordl, "nrord": pc_nr},
|
||||
)
|
||||
|
||||
return ComandaResponse(
|
||||
id_ordl=id_ordl,
|
||||
nrord=nrord,
|
||||
mesaj=f"Comanda {nrord} creata cu succes.",
|
||||
nrord=pc_nr,
|
||||
mesaj=f"Comanda {pc_nr} creată cu succes.",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def get_comenzi(
|
||||
company_id: int,
|
||||
page: int,
|
||||
per_page: int,
|
||||
validat: Optional[int],
|
||||
data_de_la: Optional[date],
|
||||
data_pana_la: Optional[date],
|
||||
server_id: Optional[str] = None,
|
||||
) -> ComandaListResponse:
|
||||
per_page = min(per_page, _MAX_PER_PAGE)
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
schema = await get_schema(company_id, server_id)
|
||||
|
||||
where_parts = ["d.sters = 0"]
|
||||
filter_params: dict = {}
|
||||
|
||||
if validat is not None:
|
||||
where_parts.append("d.validat = :validat")
|
||||
filter_params["validat"] = validat
|
||||
|
||||
if data_de_la is not None:
|
||||
where_parts.append("d.datai >= :data_de_la")
|
||||
filter_params["data_de_la"] = data_de_la
|
||||
|
||||
if data_pana_la is not None:
|
||||
# +1 day range avoids TRUNC (keeps index use on datai)
|
||||
where_parts.append("d.datai < :data_pana_la + 1")
|
||||
filter_params["data_pana_la"] = data_pana_la
|
||||
|
||||
where_clause = " AND ".join(where_parts)
|
||||
|
||||
base_from = f"""
|
||||
FROM {schema}.DEV_ORDL d
|
||||
LEFT JOIN {schema}.NOM_LUCRARI l ON d.id_lucrare = l.id_lucrare
|
||||
LEFT JOIN {schema}.AUTO_VMASINICLIENTI mc
|
||||
ON d.id_masiniclient = mc.id_masiniclient
|
||||
LEFT JOIN {schema}.DEV_TIP_DEVIZ t ON d.id_tip = t.id_tip
|
||||
WHERE {where_clause}
|
||||
"""
|
||||
|
||||
count_query = f"SELECT COUNT(*) {base_from}"
|
||||
data_query = f"""
|
||||
SELECT d.id_ordl, l.nrord, d.datai, d.validat, d.inchis_fortat,
|
||||
d.id_tip, t.denumire,
|
||||
d.id_masiniclient, mc.nrinmat, mc.marca, mc.masina,
|
||||
mc.anfabricatie, mc.partener
|
||||
{base_from}
|
||||
ORDER BY d.datai DESC, d.id_ordl DESC
|
||||
OFFSET :offset ROWS FETCH NEXT :per_page ROWS ONLY
|
||||
"""
|
||||
|
||||
try:
|
||||
async with oracle_pool.get_connection(server_id) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(count_query, filter_params)
|
||||
total = cur.fetchone()[0]
|
||||
|
||||
cur.execute(
|
||||
data_query,
|
||||
{**filter_params, "offset": offset, "per_page": per_page},
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
except oracledb.DatabaseError as e:
|
||||
_handle_oracle_error(e)
|
||||
|
||||
comenzi: List[ComandaListItem] = []
|
||||
for r in rows:
|
||||
(id_ordl, nrord, datai, validat_val, inchis_fortat,
|
||||
id_tip, tip_denumire,
|
||||
id_mc, nrinmat, marca, masina, an, partener) = r
|
||||
|
||||
if id_mc:
|
||||
parts = []
|
||||
if marca:
|
||||
parts.append(marca)
|
||||
if masina:
|
||||
parts.append(masina)
|
||||
vehicul_str = " ".join(parts) if parts else "?"
|
||||
an_str = f" ({int(an)})" if an else ""
|
||||
vehicul = f"{partener or '?'} — {vehicul_str}, {nrinmat or '?'}{an_str}"
|
||||
else:
|
||||
vehicul = ""
|
||||
|
||||
comenzi.append(ComandaListItem(
|
||||
id_ordl=int(id_ordl),
|
||||
nrord=nrord or "",
|
||||
datai=datai.strftime("%Y-%m-%d") if datai else None,
|
||||
validat=int(validat_val),
|
||||
inchis_fortat=int(inchis_fortat or 0),
|
||||
id_tip=int(id_tip),
|
||||
tip_denumire=tip_denumire or "",
|
||||
vehicul=vehicul,
|
||||
id_masiniclient=int(id_mc) if id_mc else None,
|
||||
))
|
||||
|
||||
logger.debug(
|
||||
"service_auto.get_comenzi page=%d per_page=%d total=%d",
|
||||
page, per_page, total,
|
||||
)
|
||||
|
||||
return ComandaListResponse(
|
||||
comenzi=comenzi,
|
||||
total=total,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
)
|
||||
|
||||
@@ -1,32 +1,67 @@
|
||||
"""
|
||||
Lookup data for service_auto forms — tip deviz, masini, firme.
|
||||
Lookup data for service_auto forms — tip deviz, masini, firme, asiguratori, inspectori, operatii, parteneri.
|
||||
|
||||
All three endpoints are read-only and infrequently changing.
|
||||
Multi-tenant safe: `schema` e rezolvat din `id_firma` via `_context.get_schema()`; nu există
|
||||
schemă hardcodată. `server_id` propagat din JWT (`request.state.server_id`).
|
||||
"""
|
||||
from typing import List
|
||||
import time
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
import oracledb
|
||||
from fastapi import HTTPException
|
||||
from shared.database.oracle_pool import oracle_pool
|
||||
|
||||
from ..schemas.comanda import FirmaItem, MasinaClientItem, TipDevizItem
|
||||
from ..schemas.comanda import (
|
||||
AsiguratorItem, FirmaItem, InspectorItem, MasinaClientItem,
|
||||
MasinaDetails, OperatieItem, PartenerItem, PartnerCreateRequest,
|
||||
TipDevizItem,
|
||||
)
|
||||
from .. import logger
|
||||
from ._context import get_schema
|
||||
|
||||
# In-memory TTL cache: key → (monotonic_timestamp, value)
|
||||
_cache: dict = {}
|
||||
|
||||
_TTL_TIP_DEVIZ = 86400 # 24 h — tip deviz changes only via DB migration
|
||||
_TTL_MASINI = 300 # 5 min — vehicle inventory changes regularly
|
||||
_TTL_ASIGURATORI = 86400 # 24 h
|
||||
_TTL_INSPECTORI = 86400 # 24 h per asigurator
|
||||
_TTL_OPERATII = 86400 # 24 h — DEV_NOM_NORME changes only via DB
|
||||
|
||||
|
||||
def _cache_get(key: str, ttl: float):
|
||||
entry: Optional[Tuple] = _cache.get(key)
|
||||
if entry and (time.monotonic() - entry[0]) < ttl:
|
||||
return entry[1]
|
||||
return None
|
||||
|
||||
|
||||
def _cache_set(key: str, value) -> None:
|
||||
_cache[key] = (time.monotonic(), value)
|
||||
|
||||
|
||||
def reset_cache() -> None:
|
||||
"""Test helper."""
|
||||
_cache.clear()
|
||||
|
||||
|
||||
class LookupService:
|
||||
|
||||
@staticmethod
|
||||
async def get_firme(company_ids: List[str]) -> List[FirmaItem]:
|
||||
async def get_firme(
|
||||
company_ids: List[str],
|
||||
server_id: Optional[str] = None,
|
||||
) -> List[FirmaItem]:
|
||||
"""
|
||||
Returns firma names for the company IDs in the user's JWT.
|
||||
Uses 'central' pool (CONTAFIN_ORACLE) to query V_NOM_FIRME.
|
||||
Query pe `CONTAFIN_ORACLE.V_NOM_FIRME` pe serverul utilizatorului.
|
||||
"""
|
||||
if not company_ids:
|
||||
return []
|
||||
|
||||
placeholders = ", ".join(f":id{i}" for i in range(len(company_ids)))
|
||||
query = f"""
|
||||
SELECT id_firma, firma, schema
|
||||
SELECT id_firma, firma, schema, id_mama
|
||||
FROM CONTAFIN_ORACLE.V_NOM_FIRME
|
||||
WHERE id_firma IN ({placeholders})
|
||||
ORDER BY id_firma
|
||||
@@ -34,7 +69,7 @@ class LookupService:
|
||||
params = {f"id{i}": int(cid) for i, cid in enumerate(company_ids)}
|
||||
|
||||
try:
|
||||
async with oracle_pool.get_connection("central") as conn:
|
||||
async with oracle_pool.get_connection(server_id) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(query, params)
|
||||
rows = cur.fetchall()
|
||||
@@ -43,23 +78,31 @@ class LookupService:
|
||||
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 "")
|
||||
FirmaItem(id_firma=r[0], firma=r[1], schema_name=r[2] or "", id_mama=r[3])
|
||||
for r in rows
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
async def get_tip_deviz() -> List[TipDevizItem]:
|
||||
async def get_tip_deviz(
|
||||
company_id: int,
|
||||
server_id: Optional[str] = None,
|
||||
) -> List[TipDevizItem]:
|
||||
"""
|
||||
Returns all active tip deviz from MARIUSM_AUTO.DEV_TIP_DEVIZ.
|
||||
ROA_WEB has SELECT grant on this view.
|
||||
Tip deviz din `{schema}.DEV_TIP_DEVIZ`. Cached 24 h per schema.
|
||||
"""
|
||||
query = """
|
||||
schema = await get_schema(company_id, server_id)
|
||||
cache_key = f"tip_deviz:{schema}"
|
||||
cached = _cache_get(cache_key, _TTL_TIP_DEVIZ)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
query = f"""
|
||||
SELECT id_tip, denumire, inch_validare
|
||||
FROM MARIUSM_AUTO.DEV_TIP_DEVIZ
|
||||
FROM {schema}.DEV_TIP_DEVIZ
|
||||
ORDER BY id_tip
|
||||
"""
|
||||
try:
|
||||
async with oracle_pool.get_connection("mariusm_test") as conn:
|
||||
async with oracle_pool.get_connection(server_id) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(query)
|
||||
rows = cur.fetchall()
|
||||
@@ -67,26 +110,35 @@ class LookupService:
|
||||
logger.error("get_tip_deviz Oracle error", exc_info=True)
|
||||
raise HTTPException(status_code=503, detail="Eroare la încărcarea tipurilor de deviz")
|
||||
|
||||
return [
|
||||
result = [
|
||||
TipDevizItem(id_tip=r[0], denumire=r[1], inch_validare=r[2] or 0)
|
||||
for r in rows
|
||||
]
|
||||
_cache_set(cache_key, result)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
async def get_masini() -> List[MasinaClientItem]:
|
||||
async def get_masini(
|
||||
company_id: int,
|
||||
server_id: Optional[str] = None,
|
||||
) -> List[MasinaClientItem]:
|
||||
"""
|
||||
Returns active masini from MARIUSM_AUTO.AUTO_VMASINICLIENTI.
|
||||
ROA_WEB has SELECT grant on this view.
|
||||
Label format: "PARTENER — MARCA MASINA, NRINMAT (ANFABRICATIE)"
|
||||
Mașini active din `{schema}.AUTO_VMASINICLIENTI`. Cached 5 min per schema.
|
||||
"""
|
||||
query = """
|
||||
schema = await get_schema(company_id, server_id)
|
||||
cache_key = f"masini:{schema}"
|
||||
cached = _cache_get(cache_key, _TTL_MASINI)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
query = f"""
|
||||
SELECT id_masiniclient, nrinmat, marca, masina, anfabricatie, partener
|
||||
FROM MARIUSM_AUTO.AUTO_VMASINICLIENTI
|
||||
FROM {schema}.AUTO_VMASINICLIENTI
|
||||
WHERE inactiv = 0
|
||||
ORDER BY partener, nrinmat
|
||||
"""
|
||||
try:
|
||||
async with oracle_pool.get_connection("mariusm_test") as conn:
|
||||
async with oracle_pool.get_connection(server_id) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(query)
|
||||
rows = cur.fetchall()
|
||||
@@ -107,4 +159,302 @@ class LookupService:
|
||||
label = f"{partener or '?'} — {vehicul}, {nrinmat or '?'}{an_str}"
|
||||
result.append(MasinaClientItem(id_masiniclient=int(id_mc), label=label))
|
||||
|
||||
_cache_set(cache_key, result)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
async def get_masina_details(
|
||||
id_masiniclient: int,
|
||||
company_id: int,
|
||||
server_id: Optional[str] = None,
|
||||
) -> Optional[MasinaDetails]:
|
||||
"""
|
||||
Detalii complete vehicul din `{schema}.AUTO_VMASINICLIENTI`. Fără cache (per-record).
|
||||
"""
|
||||
schema = await get_schema(company_id, server_id)
|
||||
query = f"""
|
||||
SELECT id_masiniclient, nrinmat, marca, masina, anfabricatie, partener,
|
||||
series, cilindree, puterecp, puterekw
|
||||
FROM {schema}.AUTO_VMASINICLIENTI
|
||||
WHERE id_masiniclient = :id_mc
|
||||
"""
|
||||
try:
|
||||
async with oracle_pool.get_connection(server_id) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(query, {"id_mc": id_masiniclient})
|
||||
row = cur.fetchone()
|
||||
except oracledb.DatabaseError:
|
||||
logger.error("get_masina_details Oracle error", exc_info=True)
|
||||
raise HTTPException(status_code=503, detail="Eroare la încărcarea detaliilor mașinii")
|
||||
|
||||
if not row:
|
||||
return None
|
||||
|
||||
id_mc, nrinmat, marca, masina, an, partener, serie_sasiu, cilindree, putere_cp, putere_kw = row
|
||||
parts = [p for p in [marca, masina] if p]
|
||||
vehicul = " ".join(parts) if parts else "?"
|
||||
an_str = f" ({int(an)})" if an else ""
|
||||
label = f"{partener or '?'} — {vehicul}, {nrinmat or '?'}{an_str}"
|
||||
|
||||
return MasinaDetails(
|
||||
id_masiniclient=int(id_mc),
|
||||
label=label,
|
||||
nr_inmatriculare=nrinmat,
|
||||
marca=marca,
|
||||
model=masina,
|
||||
serie_sasiu=serie_sasiu,
|
||||
cilindree=int(cilindree) if cilindree else None,
|
||||
putere_cp=int(putere_cp) if putere_cp else None,
|
||||
putere_kw=int(putere_kw) if putere_kw else None,
|
||||
client_nume=partener,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def get_asiguratori(
|
||||
company_id: int,
|
||||
server_id: Optional[str] = None,
|
||||
) -> List[AsiguratorItem]:
|
||||
"""
|
||||
Asigurători activi din `{schema}.DEV_NOM_ASIGURATORI`. Cached 24h per schema.
|
||||
Numele din NOM_PARTENERI via FK ID_PART (DEV_NOM_ASIGURATORI nu are coloană denumire).
|
||||
"""
|
||||
schema = await get_schema(company_id, server_id)
|
||||
cache_key = f"asiguratori:{schema}"
|
||||
cached = _cache_get(cache_key, _TTL_ASIGURATORI)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
query = f"""
|
||||
SELECT a.id_asigurator, NVL(p.denumire, a.asigurator_vechi) AS denumire
|
||||
FROM {schema}.DEV_NOM_ASIGURATORI a
|
||||
LEFT JOIN {schema}.NOM_PARTENERI p ON a.id_part = p.id_part
|
||||
WHERE NVL(a.sters, 0) = 0
|
||||
ORDER BY NVL(p.denumire, a.asigurator_vechi)
|
||||
"""
|
||||
try:
|
||||
async with oracle_pool.get_connection(server_id) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(query)
|
||||
rows = cur.fetchall()
|
||||
except oracledb.DatabaseError:
|
||||
logger.error("get_asiguratori Oracle error", exc_info=True)
|
||||
raise HTTPException(status_code=503, detail="Eroare la încărcarea asigurătorilor")
|
||||
|
||||
result = [AsiguratorItem(id_asigurator=int(r[0]), denumire=r[1] or "") for r in rows]
|
||||
_cache_set(cache_key, result)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
async def get_inspectori(
|
||||
id_asigurator: int,
|
||||
company_id: int,
|
||||
server_id: Optional[str] = None,
|
||||
) -> List[InspectorItem]:
|
||||
"""
|
||||
Inspectori filtrați per asigurator din `{schema}.DEV_NOM_INSPECTORI`.
|
||||
Cached 24h per (schema, id_asigurator).
|
||||
"""
|
||||
schema = await get_schema(company_id, server_id)
|
||||
cache_key = f"inspectori:{schema}:{id_asigurator}"
|
||||
cached = _cache_get(cache_key, _TTL_INSPECTORI)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
query = f"""
|
||||
SELECT id_inspector, inspector AS denumire, id_asigurator
|
||||
FROM {schema}.DEV_NOM_INSPECTORI
|
||||
WHERE id_asigurator = :id_asig
|
||||
AND NVL(sters, 0) = 0
|
||||
ORDER BY inspector
|
||||
"""
|
||||
try:
|
||||
async with oracle_pool.get_connection(server_id) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(query, {"id_asig": id_asigurator})
|
||||
rows = cur.fetchall()
|
||||
except oracledb.DatabaseError:
|
||||
logger.error("get_inspectori Oracle error", exc_info=True)
|
||||
raise HTTPException(status_code=503, detail="Eroare la încărcarea inspectorilor")
|
||||
|
||||
result = [
|
||||
InspectorItem(id_inspector=int(r[0]), denumire=r[1] or "", id_asigurator=int(r[2]))
|
||||
for r in rows
|
||||
]
|
||||
_cache_set(cache_key, result)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
async def get_operatii(
|
||||
company_id: int,
|
||||
server_id: Optional[str] = None,
|
||||
) -> List[OperatieItem]:
|
||||
"""
|
||||
Operații din `{schema}.DEV_NOM_NORME`. Cached 24h per schema.
|
||||
Full list; filter client-side.
|
||||
"""
|
||||
schema = await get_schema(company_id, server_id)
|
||||
cache_key = f"operatii:{schema}"
|
||||
cached = _cache_get(cache_key, _TTL_OPERATII)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
query = f"""
|
||||
SELECT id_norme, codop, denop, timpn
|
||||
FROM {schema}.DEV_NOM_NORME
|
||||
WHERE NVL(sters, 0) = 0
|
||||
AND NVL(inactiv, 0) = 0
|
||||
ORDER BY denop
|
||||
"""
|
||||
try:
|
||||
async with oracle_pool.get_connection(server_id) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(query)
|
||||
rows = cur.fetchall()
|
||||
except oracledb.DatabaseError:
|
||||
logger.error("get_operatii Oracle error", exc_info=True)
|
||||
raise HTTPException(status_code=503, detail="Eroare la încărcarea operațiilor")
|
||||
|
||||
# Oracle treats '' as NULL, so NVL(col,'') can still yield None in Python.
|
||||
result = [
|
||||
OperatieItem(
|
||||
id_norme=int(r[0]),
|
||||
codop=r[1] or "",
|
||||
denop=r[2] or "",
|
||||
timpn=float(r[3]) if r[3] is not None else None,
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
_cache_set(cache_key, result)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
async def search_parteneri(
|
||||
q: str,
|
||||
company_id: int,
|
||||
server_id: Optional[str] = None,
|
||||
) -> List[PartenerItem]:
|
||||
"""
|
||||
Typeahead pe `{schema}.NOM_PARTENERI`. Min 2 chars, limit 50. No cache.
|
||||
Folosește IX_NOM_PARTENERI_DEN_UPPER; LIKE escape pentru %, _, \\.
|
||||
"""
|
||||
if len(q) < 2:
|
||||
return []
|
||||
|
||||
def _escape_like(s: str) -> str:
|
||||
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
|
||||
schema = await get_schema(company_id, server_id)
|
||||
query = f"""
|
||||
SELECT id_part, denumire
|
||||
FROM {schema}.NOM_PARTENERI
|
||||
WHERE UPPER(denumire) LIKE UPPER(:q) ESCAPE '\\'
|
||||
AND NVL(sters, 0) = 0
|
||||
ORDER BY denumire
|
||||
FETCH FIRST 50 ROWS ONLY
|
||||
"""
|
||||
try:
|
||||
async with oracle_pool.get_connection(server_id) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(query, {"q": _escape_like(q) + "%"})
|
||||
rows = cur.fetchall()
|
||||
except oracledb.DatabaseError:
|
||||
logger.error("search_parteneri Oracle error", exc_info=True)
|
||||
raise HTTPException(status_code=503, detail="Eroare la căutarea partenerilor")
|
||||
|
||||
return [PartenerItem(id_part=int(r[0]), denumire=r[1] or "") for r in rows]
|
||||
|
||||
@staticmethod
|
||||
async def create_partener(
|
||||
data: PartnerCreateRequest,
|
||||
server_id: Optional[str] = None,
|
||||
) -> PartenerItem:
|
||||
"""
|
||||
Creează partener nou în `{schema}.NOM_PARTENERI`.
|
||||
|
||||
- id_part alocat manual cu `NVL(MAX(id_part),0)+1` (nu există secvență/identity).
|
||||
- Pre-check unicitate CUI (NU există unique constraint pe COD_FISCAL) → 409.
|
||||
- PK collision (race) → ORA-00001 → retry o singură dată cu MAX+1 reactualizat.
|
||||
- ORA-00942/01031 (table missing / no privileges) → log.critical + 500 (lipsă GRANT).
|
||||
"""
|
||||
denumire = data.denumire.strip()
|
||||
cui = (data.cui or "").strip() or None
|
||||
adresa = (data.adresa or "").strip() or None
|
||||
|
||||
schema = await get_schema(data.id_firma, server_id)
|
||||
|
||||
try:
|
||||
async with oracle_pool.get_connection(server_id) as conn:
|
||||
with conn.cursor() as cur:
|
||||
# Pre-check duplicat CUI (doar dacă CUI a fost furnizat).
|
||||
if cui:
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT 1 FROM {schema}.NOM_PARTENERI
|
||||
WHERE cod_fiscal = :cui
|
||||
AND NVL(sters, 0) = 0
|
||||
AND ROWNUM = 1
|
||||
""",
|
||||
{"cui": cui},
|
||||
)
|
||||
if cur.fetchone():
|
||||
raise HTTPException(status_code=409, detail="CUI duplicat")
|
||||
|
||||
insert_sql = f"""
|
||||
INSERT INTO {schema}.NOM_PARTENERI
|
||||
(id_part, denumire, cod_fiscal, adresa,
|
||||
sters, inactiv, id_mod, tip_persoana)
|
||||
VALUES
|
||||
(:id_part, :denumire, :cui, :adresa,
|
||||
0, 0, 0, 1)
|
||||
"""
|
||||
params_base = {
|
||||
"denumire": denumire,
|
||||
"cui": cui,
|
||||
"adresa": adresa,
|
||||
}
|
||||
|
||||
new_id: Optional[int] = None
|
||||
last_err: Optional[oracledb.DatabaseError] = None
|
||||
for _attempt in range(2):
|
||||
cur.execute(
|
||||
f"SELECT NVL(MAX(id_part), 0) + 1 FROM {schema}.NOM_PARTENERI"
|
||||
)
|
||||
candidate = int(cur.fetchone()[0])
|
||||
try:
|
||||
cur.execute(insert_sql, {"id_part": candidate, **params_base})
|
||||
new_id = candidate
|
||||
break
|
||||
except oracledb.DatabaseError as e:
|
||||
err_code = e.args[0].code if e.args else None
|
||||
if err_code == 1: # ORA-00001 PK race
|
||||
last_err = e
|
||||
continue
|
||||
raise
|
||||
|
||||
if new_id is None:
|
||||
# Două încercări consecutive cu PK collision — escaladăm.
|
||||
if last_err is not None:
|
||||
raise last_err
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Nu s-a putut aloca id_part după 2 încercări",
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
except HTTPException:
|
||||
raise
|
||||
except oracledb.DatabaseError as e:
|
||||
err_code = e.args[0].code if e.args else None
|
||||
if err_code in (942, 1031):
|
||||
logger.critical(
|
||||
"create_partener: lipsă GRANT INSERT pe NOM_PARTENERI (schema=%s, ORA-%05d)",
|
||||
schema, err_code, exc_info=True,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Lipsă privilegii pe tabela NOM_PARTENERI; contactați administratorul.",
|
||||
)
|
||||
logger.error("create_partener Oracle error", exc_info=True)
|
||||
raise HTTPException(status_code=503, detail="Eroare la crearea partenerului")
|
||||
|
||||
return PartenerItem(id_part=new_id, denumire=denumire)
|
||||
|
||||
92
backend/modules/service_auto/tests/test_comanda_helpers.py
Normal file
92
backend/modules/service_auto/tests/test_comanda_helpers.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""
|
||||
Unit tests pentru helperii din comanda_service (fără DB, fără mocks).
|
||||
|
||||
Acoperire:
|
||||
- _build_pc_nr: toate prefixele VFP (tip_id=1..7) + fallback pe tip_id necunoscut
|
||||
- _build_sir_id_operatii: None, empty, CSV, limit 4000 chars
|
||||
"""
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from backend.modules.service_auto.services.comanda_service import (
|
||||
_build_pc_nr,
|
||||
_build_sir_id_operatii,
|
||||
_MAX_OPERATII_CSV,
|
||||
_PREFIX_MAP,
|
||||
)
|
||||
|
||||
|
||||
# ---- _build_pc_nr ----
|
||||
|
||||
@pytest.mark.parametrize("tip_id,expected_prefix", [
|
||||
(1, ""), # POST GARANTIE — fără prefix (VFP default)
|
||||
(2, "G"), # GARANTIE
|
||||
(3, "R"), # REGIE
|
||||
(4, "P"), # PREGATIRE
|
||||
(6, "PR"), # PRODUCTIE
|
||||
(7, "C"), # CONSTATARE
|
||||
])
|
||||
def test_pc_nr_known_tip_ids_use_vfp_prefix(tip_id, expected_prefix):
|
||||
"""Toate cele 6 tip_id-uri cu prefix VFP verificat (oproceduri_devize.prg)."""
|
||||
nrord = _build_pc_nr(tip_id, 123, "B-32-CTL")
|
||||
assert nrord == f"{expected_prefix}123/B-32-CTL"
|
||||
|
||||
|
||||
def test_pc_nr_tip_5_regie_2_no_vfp_mapping_uses_empty_prefix():
|
||||
"""tip_id=5 (REGIE 2) nu are mapare VFP → fallback prefix ''."""
|
||||
assert _build_pc_nr(5, 42, "B-10-ABC") == "42/B-10-ABC"
|
||||
|
||||
|
||||
def test_pc_nr_unknown_tip_id_uses_empty_prefix():
|
||||
"""tip_id necunoscut (ex: 99) → fallback prefix '' + warning logat."""
|
||||
assert _build_pc_nr(99, 1, "XYZ") == "1/XYZ"
|
||||
|
||||
|
||||
def test_pc_nr_format_matches_vfp_structure():
|
||||
"""Format final: <prefix><seq>/<nrinmat> — nu '<prefix>/<seq>/<nrinmat>'."""
|
||||
nrord = _build_pc_nr(2, 777, "CT-10-EEE")
|
||||
assert nrord == "G777/CT-10-EEE"
|
||||
assert "/" in nrord
|
||||
assert nrord.count("/") == 1 # o singură bară
|
||||
|
||||
|
||||
def test_prefix_map_covers_all_vfp_mappings():
|
||||
"""Regression guard: _PREFIX_MAP nu trebuie scăpat la refactor."""
|
||||
assert _PREFIX_MAP == {1: "", 2: "G", 3: "R", 4: "P", 6: "PR", 7: "C"}
|
||||
|
||||
|
||||
# ---- _build_sir_id_operatii ----
|
||||
|
||||
def test_sir_operatii_none_returns_none():
|
||||
"""None → None (nu trimite param la SP)."""
|
||||
assert _build_sir_id_operatii(None) is None
|
||||
|
||||
|
||||
def test_sir_operatii_empty_list_returns_none():
|
||||
"""Listă goală → None (echivalent cu 'fără operații')."""
|
||||
assert _build_sir_id_operatii([]) is None
|
||||
|
||||
|
||||
def test_sir_operatii_single_id():
|
||||
assert _build_sir_id_operatii([42]) == "42"
|
||||
|
||||
|
||||
def test_sir_operatii_multiple_ids_csv():
|
||||
assert _build_sir_id_operatii([1, 2, 3]) == "1,2,3"
|
||||
|
||||
|
||||
def test_sir_operatii_below_limit_passes():
|
||||
"""600 ID-uri cu 2 cifre + virgulă = ~1800 chars, sub limita 4000."""
|
||||
ids = list(range(10, 110)) # 100 IDs, 3 cifre → ~400 chars
|
||||
result = _build_sir_id_operatii(ids)
|
||||
assert result is not None
|
||||
assert len(result) < _MAX_OPERATII_CSV
|
||||
|
||||
|
||||
def test_sir_operatii_over_limit_raises_422():
|
||||
"""~1000 IDs cu 6 cifre → peste 4000 chars → HTTPException 422."""
|
||||
big_ids = list(range(100000, 101000)) # 1000 IDs × 7 chars (6 cifre + virgulă) = 7000 chars
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
_build_sir_id_operatii(big_ids)
|
||||
assert exc_info.value.status_code == 422
|
||||
assert "Prea multe" in exc_info.value.detail
|
||||
@@ -45,6 +45,16 @@ def _connect() -> oracledb.Connection:
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.skip(
|
||||
reason=(
|
||||
"Obsolete target SP: commit 9cd7f35 migrated comanda creation to "
|
||||
"PACK_AUTO (+PACK_SERII_NUMERE). SP_CREEAZA_COMANDA_PROTOTIP is no "
|
||||
"longer the production path; callproc signature drift causes "
|
||||
"PLS-00306. Persist/durability is now covered by live smoke tests "
|
||||
"via /api/service-auto/comenzi — see docs/service-auto/"
|
||||
"decision-log.md (2026-04-13)."
|
||||
)
|
||||
)
|
||||
def test_comanda_persist_and_reconnect():
|
||||
"""
|
||||
Full round-trip: callproc → commit → close → NEW connection → SELECT → assert exists.
|
||||
|
||||
@@ -98,6 +98,14 @@ def test_insert_direct_fails(roa_web_connection):
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.skip(
|
||||
reason=(
|
||||
"Obsolete premise: ff_2026_04_13_01_AUTO.sql granted SELECT on "
|
||||
"NOM_LUCRARI to ROA_WEB (needed by /api/service-auto/operatii). "
|
||||
"Assertion ORA-00942 no longer holds. Rework or remove — see "
|
||||
"docs/service-auto/decision-log.md (2026-04-13)."
|
||||
)
|
||||
)
|
||||
def test_select_direct_fails(roa_web_connection):
|
||||
"""
|
||||
ROA_WEB has no SELECT privilege on NOM_LUCRARI.
|
||||
@@ -120,6 +128,15 @@ def test_select_direct_fails(roa_web_connection):
|
||||
# Positive test — SP execution must succeed (EXECUTE grant)
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.skip(
|
||||
reason=(
|
||||
"Obsolete target SP: commit 9cd7f35 migrated comanda creation to "
|
||||
"PACK_AUTO (+PACK_SERII_NUMERE). SP_CREEAZA_COMANDA_PROTOTIP is no "
|
||||
"longer invoked by production code; signature drift causes "
|
||||
"PLS-00306. Rewrite against PACK_AUTO.DEV_ADAUGA_LUCRARE or remove "
|
||||
"— see docs/service-auto/decision-log.md (2026-04-13)."
|
||||
)
|
||||
)
|
||||
def test_exec_sp_succeeds(roa_web_connection):
|
||||
"""
|
||||
ROA_WEB has EXECUTE on SP_CREEAZA_COMANDA_PROTOTIP.
|
||||
|
||||
365
backend/modules/service_auto/tests/test_lookup_endpoints.py
Normal file
365
backend/modules/service_auto/tests/test_lookup_endpoints.py
Normal file
@@ -0,0 +1,365 @@
|
||||
"""
|
||||
Unit tests pentru `LookupService` (mock Oracle).
|
||||
|
||||
Acoperire:
|
||||
- Cache hit/miss per schema (tip_deviz, masini, asiguratori, inspectori, operatii)
|
||||
- LIKE escape în search_parteneri (%, _, \\ neutralizate)
|
||||
- min 2 chars validation pentru search_parteneri
|
||||
- get_masina_details: row absent → None
|
||||
- Reset cache între teste (autouse) — atât `_cache` din lookup_service
|
||||
cât și `_schema_cache` din _context.
|
||||
|
||||
Niciun test nu atinge Oracle real: `oracle_pool.get_connection` și `get_schema`
|
||||
sunt monkeypatched. Stilul urmează `test_comanda_helpers.py` (pytest-asyncio
|
||||
auto-mode din pyproject.toml — fără decoratori).
|
||||
"""
|
||||
from typing import List, Optional, Sequence
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.modules.service_auto.services import lookup_service
|
||||
from backend.modules.service_auto.services._context import reset_schema_cache
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Fakes pentru oracle_pool.get_connection
|
||||
# ============================================================
|
||||
|
||||
class _FakeCursor:
|
||||
"""Cursor sincron: __enter__/__exit__ + execute/fetchall/fetchone."""
|
||||
|
||||
def __init__(self, fetchall_rows=None, fetchone_row=None):
|
||||
self._fetchall_rows = fetchall_rows if fetchall_rows is not None else []
|
||||
self._fetchone_row = fetchone_row
|
||||
self.execute = MagicMock()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def fetchall(self):
|
||||
return self._fetchall_rows
|
||||
|
||||
def fetchone(self):
|
||||
return self._fetchone_row
|
||||
|
||||
|
||||
class _FakeConn:
|
||||
def __init__(self, cursor: _FakeCursor):
|
||||
self._cursor = cursor
|
||||
|
||||
def cursor(self):
|
||||
return self._cursor
|
||||
|
||||
|
||||
class _FakeConnCM:
|
||||
"""Async context manager imitând `@asynccontextmanager` din oracle_pool."""
|
||||
|
||||
def __init__(self, conn: _FakeConn):
|
||||
self._conn = conn
|
||||
|
||||
async def __aenter__(self):
|
||||
return self._conn
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
|
||||
class _PoolStub:
|
||||
"""
|
||||
Înlocuiește `oracle_pool.get_connection` — întoarce cursori în ordinea dată.
|
||||
Numără apelurile (per server_id, dacă vrem să verificăm propagarea).
|
||||
"""
|
||||
|
||||
def __init__(self, cursors: Sequence[_FakeCursor]):
|
||||
self._cursors = list(cursors)
|
||||
self.call_count = 0
|
||||
self.server_ids: List[Optional[str]] = []
|
||||
|
||||
def get_connection(self, server_id=None):
|
||||
self.call_count += 1
|
||||
self.server_ids.append(server_id)
|
||||
if not self._cursors:
|
||||
raise AssertionError("PoolStub epuizat: get_connection apelat de mai multe ori decât cursori furnizați")
|
||||
cursor = self._cursors.pop(0)
|
||||
return _FakeConnCM(_FakeConn(cursor))
|
||||
|
||||
|
||||
def _install_pool(monkeypatch, cursors: Sequence[_FakeCursor]) -> _PoolStub:
|
||||
"""Patchează `oracle_pool.get_connection` în lookup_service."""
|
||||
stub = _PoolStub(cursors)
|
||||
monkeypatch.setattr(lookup_service.oracle_pool, "get_connection", stub.get_connection)
|
||||
return stub
|
||||
|
||||
|
||||
def _install_schema(monkeypatch, schema: str = "MARIUSM_AUTO"):
|
||||
"""Patchează `get_schema` ca să nu lovim DB pentru rezolvarea schemei."""
|
||||
async def _fake_get_schema(company_id, server_id): # noqa: ARG001
|
||||
return schema
|
||||
monkeypatch.setattr(lookup_service, "get_schema", _fake_get_schema)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Reset cache între teste — OBLIGATORIU
|
||||
# ============================================================
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_caches():
|
||||
lookup_service.reset_cache()
|
||||
reset_schema_cache()
|
||||
yield
|
||||
lookup_service.reset_cache()
|
||||
reset_schema_cache()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# get_tip_deviz
|
||||
# ============================================================
|
||||
|
||||
async def test_tip_deviz_cache_miss_then_hit(monkeypatch):
|
||||
"""A doua chemare pentru aceeași schemă → fără query nou."""
|
||||
cursor = _FakeCursor(fetchall_rows=[(1, "POST GARANTIE", 1)])
|
||||
pool = _install_pool(monkeypatch, [cursor])
|
||||
_install_schema(monkeypatch, "MARIUSM_AUTO")
|
||||
|
||||
res1 = await lookup_service.LookupService.get_tip_deviz(167)
|
||||
res2 = await lookup_service.LookupService.get_tip_deviz(167)
|
||||
|
||||
assert pool.call_count == 1, "A doua chemare trebuia să vină din cache"
|
||||
assert res1 == res2
|
||||
assert res1[0].id_tip == 1
|
||||
assert res1[0].denumire == "POST GARANTIE"
|
||||
assert res1[0].inch_validare == 1
|
||||
|
||||
|
||||
async def test_tip_deviz_inch_validare_null_defaults_to_zero(monkeypatch):
|
||||
"""`inch_validare` NULL în DB → 0 (Pydantic int)."""
|
||||
cursor = _FakeCursor(fetchall_rows=[(2, "GARANTIE", None)])
|
||||
_install_pool(monkeypatch, [cursor])
|
||||
_install_schema(monkeypatch)
|
||||
|
||||
res = await lookup_service.LookupService.get_tip_deviz(167)
|
||||
assert res[0].inch_validare == 0
|
||||
|
||||
|
||||
async def test_tip_deviz_different_schema_triggers_new_query(monkeypatch):
|
||||
"""Schemă diferită (alt id_firma) → query nou (cache key e per schema)."""
|
||||
cur1 = _FakeCursor(fetchall_rows=[(1, "POST GARANTIE", 1)])
|
||||
cur2 = _FakeCursor(fetchall_rows=[(1, "POST GARANTIE", 1)])
|
||||
pool = _install_pool(monkeypatch, [cur1, cur2])
|
||||
|
||||
schemas = iter(["MARIUSM_AUTO", "ALTA_FIRMA_AUTO"])
|
||||
async def _switching_schema(_company_id, _server_id):
|
||||
return next(schemas)
|
||||
monkeypatch.setattr(lookup_service, "get_schema", _switching_schema)
|
||||
|
||||
await lookup_service.LookupService.get_tip_deviz(167)
|
||||
await lookup_service.LookupService.get_tip_deviz(110)
|
||||
|
||||
assert pool.call_count == 2, "Schemă diferită ⇒ cache miss ⇒ query nou"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# get_masini
|
||||
# ============================================================
|
||||
|
||||
async def test_masini_cache_hit_avoids_second_query(monkeypatch):
|
||||
cursor = _FakeCursor(fetchall_rows=[
|
||||
(101, "B-32-CTL", "DACIA", "LOGAN", 2018, "ION ION SRL"),
|
||||
])
|
||||
pool = _install_pool(monkeypatch, [cursor])
|
||||
_install_schema(monkeypatch)
|
||||
|
||||
res1 = await lookup_service.LookupService.get_masini(167)
|
||||
res2 = await lookup_service.LookupService.get_masini(167)
|
||||
|
||||
assert pool.call_count == 1
|
||||
assert res1[0].id_masiniclient == 101
|
||||
assert "ION ION SRL" in res1[0].label
|
||||
assert "B-32-CTL" in res1[0].label
|
||||
assert "(2018)" in res1[0].label
|
||||
assert res1 == res2
|
||||
|
||||
|
||||
async def test_masini_label_handles_missing_marca_and_year(monkeypatch):
|
||||
"""Vehicul fără marca/an: fallback labels '?' fără paranteze."""
|
||||
cursor = _FakeCursor(fetchall_rows=[
|
||||
(102, "CT-10-EEE", None, None, None, None),
|
||||
])
|
||||
_install_pool(monkeypatch, [cursor])
|
||||
_install_schema(monkeypatch)
|
||||
|
||||
res = await lookup_service.LookupService.get_masini(167)
|
||||
assert res[0].label == "? — ?, CT-10-EEE"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# get_asiguratori
|
||||
# ============================================================
|
||||
|
||||
async def test_asiguratori_cache_miss_then_hit(monkeypatch):
|
||||
cursor = _FakeCursor(fetchall_rows=[(7, "ALLIANZ ȚIRIAC")])
|
||||
pool = _install_pool(monkeypatch, [cursor])
|
||||
_install_schema(monkeypatch)
|
||||
|
||||
await lookup_service.LookupService.get_asiguratori(167)
|
||||
res2 = await lookup_service.LookupService.get_asiguratori(167)
|
||||
|
||||
assert pool.call_count == 1
|
||||
assert res2[0].id_asigurator == 7
|
||||
assert res2[0].denumire == "ALLIANZ ȚIRIAC"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# get_inspectori (cache key e per (schema, id_asigurator))
|
||||
# ============================================================
|
||||
|
||||
async def test_inspectori_cache_per_asigurator(monkeypatch):
|
||||
"""Cache cheie include id_asigurator → schimbare asigurator ⇒ query nou."""
|
||||
cur1 = _FakeCursor(fetchall_rows=[(11, "POPESCU ION", 7)])
|
||||
cur2 = _FakeCursor(fetchall_rows=[(22, "IONESCU MARIA", 8)])
|
||||
pool = _install_pool(monkeypatch, [cur1, cur2])
|
||||
_install_schema(monkeypatch)
|
||||
|
||||
await lookup_service.LookupService.get_inspectori(7, 167)
|
||||
await lookup_service.LookupService.get_inspectori(7, 167) # hit
|
||||
await lookup_service.LookupService.get_inspectori(8, 167) # miss (alt asigurator)
|
||||
|
||||
assert pool.call_count == 2
|
||||
|
||||
|
||||
# ============================================================
|
||||
# get_operatii
|
||||
# ============================================================
|
||||
|
||||
async def test_operatii_cache_and_timpn_null_handling(monkeypatch):
|
||||
"""Cache hit + timpn NULL rămâne None (nu 0.0)."""
|
||||
cursor = _FakeCursor(fetchall_rows=[
|
||||
(501, "OP-001", "Schimb ulei", 1.5),
|
||||
(502, "OP-002", "Aliniere", None),
|
||||
])
|
||||
pool = _install_pool(monkeypatch, [cursor])
|
||||
_install_schema(monkeypatch)
|
||||
|
||||
res1 = await lookup_service.LookupService.get_operatii(167)
|
||||
res2 = await lookup_service.LookupService.get_operatii(167)
|
||||
|
||||
assert pool.call_count == 1
|
||||
assert res1 == res2
|
||||
assert res1[0].timpn == 1.5
|
||||
assert res1[1].timpn is None
|
||||
|
||||
|
||||
# ============================================================
|
||||
# search_parteneri — LIKE escape + min 2 chars
|
||||
# ============================================================
|
||||
|
||||
async def test_search_parteneri_min_2_chars_returns_empty_without_query(monkeypatch):
|
||||
"""q='a' (1 char) → [] FĂRĂ să atingă DB."""
|
||||
pool = _install_pool(monkeypatch, []) # zero cursori — orice apel ⇒ AssertionError
|
||||
_install_schema(monkeypatch)
|
||||
|
||||
res = await lookup_service.LookupService.search_parteneri("a", 167)
|
||||
assert res == []
|
||||
assert pool.call_count == 0
|
||||
|
||||
|
||||
async def test_search_parteneri_escapes_like_wildcards(monkeypatch):
|
||||
"""%, _, \\ trebuie escape-uite înainte de a fi trimise în LIKE."""
|
||||
cursor = _FakeCursor(fetchall_rows=[])
|
||||
_install_pool(monkeypatch, [cursor])
|
||||
_install_schema(monkeypatch)
|
||||
|
||||
await lookup_service.LookupService.search_parteneri("foo%bar_baz", 167)
|
||||
|
||||
# cursor.execute(query, {"q": ...}) — verificăm al doilea pozițional
|
||||
args, _kwargs = cursor.execute.call_args
|
||||
assert args[1] == {"q": "foo\\%bar\\_baz%"}, (
|
||||
f"Expected escaped LIKE arg; got {args[1]!r}"
|
||||
)
|
||||
|
||||
|
||||
async def test_search_parteneri_escapes_backslash_first(monkeypatch):
|
||||
"""Ordinea escape-ului: \\ se face prima, ca să nu dublezi escape-urile %/_ ulterioare."""
|
||||
cursor = _FakeCursor(fetchall_rows=[])
|
||||
_install_pool(monkeypatch, [cursor])
|
||||
_install_schema(monkeypatch)
|
||||
|
||||
await lookup_service.LookupService.search_parteneri("a\\b", 167)
|
||||
|
||||
args, _ = cursor.execute.call_args
|
||||
# 'a\\b' (3 chars: a, \, b) → 'a\\\\b' (a, \, \, b) + '%'
|
||||
assert args[1] == {"q": "a\\\\b%"}
|
||||
|
||||
|
||||
async def test_search_parteneri_returns_results(monkeypatch):
|
||||
"""Happy path: query trimis cu suffix '%', rezultate mapate la PartenerItem."""
|
||||
cursor = _FakeCursor(fetchall_rows=[
|
||||
(4321, "POPESCU IMPEX SRL"),
|
||||
(4322, "POPESCU SERVICE"),
|
||||
])
|
||||
_install_pool(monkeypatch, [cursor])
|
||||
_install_schema(monkeypatch)
|
||||
|
||||
res = await lookup_service.LookupService.search_parteneri("pop", 167)
|
||||
|
||||
args, _ = cursor.execute.call_args
|
||||
assert args[1] == {"q": "pop%"}
|
||||
assert len(res) == 2
|
||||
assert res[0].id_part == 4321
|
||||
assert res[0].denumire == "POPESCU IMPEX SRL"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# get_masina_details — None pentru row lipsă
|
||||
# ============================================================
|
||||
|
||||
async def test_masina_details_returns_none_when_row_missing(monkeypatch):
|
||||
"""Row inexistent → None (nu raise)."""
|
||||
cursor = _FakeCursor(fetchone_row=None)
|
||||
_install_pool(monkeypatch, [cursor])
|
||||
_install_schema(monkeypatch)
|
||||
|
||||
res = await lookup_service.LookupService.get_masina_details(99999, 167)
|
||||
assert res is None
|
||||
|
||||
|
||||
async def test_masina_details_maps_row_to_pydantic(monkeypatch):
|
||||
"""Row complet → MasinaDetails cu toate câmpurile populate."""
|
||||
cursor = _FakeCursor(fetchone_row=(
|
||||
101, "B-32-CTL", "DACIA", "LOGAN", 2018, "ION ION SRL",
|
||||
"UU1LSDA8N12345678", 1461, 90, 66,
|
||||
))
|
||||
_install_pool(monkeypatch, [cursor])
|
||||
_install_schema(monkeypatch)
|
||||
|
||||
res = await lookup_service.LookupService.get_masina_details(101, 167)
|
||||
assert res is not None
|
||||
assert res.id_masiniclient == 101
|
||||
assert res.nr_inmatriculare == "B-32-CTL"
|
||||
assert res.marca == "DACIA"
|
||||
assert res.model == "LOGAN"
|
||||
assert res.serie_sasiu == "UU1LSDA8N12345678"
|
||||
assert res.cilindree == 1461
|
||||
assert res.putere_cp == 90
|
||||
assert res.putere_kw == 66
|
||||
assert res.client_nume == "ION ION SRL"
|
||||
assert "DACIA LOGAN" in res.label
|
||||
|
||||
|
||||
# ============================================================
|
||||
# server_id propagation
|
||||
# ============================================================
|
||||
|
||||
async def test_server_id_propagated_to_pool(monkeypatch):
|
||||
"""server_id din JWT trebuie să ajungă la oracle_pool.get_connection."""
|
||||
cursor = _FakeCursor(fetchall_rows=[])
|
||||
pool = _install_pool(monkeypatch, [cursor])
|
||||
_install_schema(monkeypatch)
|
||||
|
||||
await lookup_service.LookupService.get_tip_deviz(167, server_id="mariusm_test")
|
||||
|
||||
assert pool.server_ids == ["mariusm_test"]
|
||||
197
backend/modules/service_auto/tests/test_partener_create.py
Normal file
197
backend/modules/service_auto/tests/test_partener_create.py
Normal file
@@ -0,0 +1,197 @@
|
||||
"""
|
||||
Unit tests pentru creare partener nou:
|
||||
- Validare PartnerCreateRequest (denumire min_length=2, id_firma ge=1)
|
||||
- LookupService.create_partener — happy path + duplicat CUI (409) + lipsă GRANT (500)
|
||||
|
||||
Folosește mock pentru oracle_pool și _context.get_schema (fără DB).
|
||||
"""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import oracledb
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from pydantic import ValidationError
|
||||
|
||||
from backend.modules.service_auto.schemas.comanda import PartnerCreateRequest
|
||||
from backend.modules.service_auto.services.lookup_service import LookupService
|
||||
|
||||
|
||||
# ---- PartnerCreateRequest validation ----
|
||||
|
||||
def test_partner_request_denumire_too_short_raises():
|
||||
"""denumire cu 1 caracter → ValidationError (min_length=2)."""
|
||||
with pytest.raises(ValidationError) as exc:
|
||||
PartnerCreateRequest(denumire="X", id_firma=167)
|
||||
assert "denumire" in str(exc.value).lower()
|
||||
|
||||
|
||||
def test_partner_request_denumire_empty_raises():
|
||||
"""denumire goală → ValidationError."""
|
||||
with pytest.raises(ValidationError):
|
||||
PartnerCreateRequest(denumire="", id_firma=167)
|
||||
|
||||
|
||||
def test_partner_request_minimal_valid():
|
||||
"""Doar denumire + id_firma → CUI și adresa optionale = None."""
|
||||
req = PartnerCreateRequest(denumire="ACME SRL", id_firma=167)
|
||||
assert req.denumire == "ACME SRL"
|
||||
assert req.cui is None
|
||||
assert req.adresa is None
|
||||
assert req.id_firma == 167
|
||||
|
||||
|
||||
def test_partner_request_full():
|
||||
req = PartnerCreateRequest(
|
||||
denumire="ACME SRL",
|
||||
cui="RO12345678",
|
||||
adresa="Str. Exemplu nr. 1, București",
|
||||
id_firma=167,
|
||||
)
|
||||
assert req.cui == "RO12345678"
|
||||
assert req.adresa is not None and req.adresa.startswith("Str.")
|
||||
|
||||
|
||||
def test_partner_request_id_firma_zero_raises():
|
||||
"""id_firma=0 → ValidationError (ge=1)."""
|
||||
with pytest.raises(ValidationError):
|
||||
PartnerCreateRequest(denumire="ACME", id_firma=0)
|
||||
|
||||
|
||||
# ---- LookupService.create_partener (mocked) ----
|
||||
|
||||
def _make_pool_ctx(cursor_mock):
|
||||
"""
|
||||
Construiește un context manager async pentru oracle_pool.get_connection.
|
||||
Returnează: pool_mock cu .get_connection() → async ctx → conn cu .cursor()
|
||||
sync ctx care returnează cursor_mock.
|
||||
"""
|
||||
conn_mock = MagicMock()
|
||||
conn_mock.cursor.return_value.__enter__.return_value = cursor_mock
|
||||
conn_mock.cursor.return_value.__exit__.return_value = None
|
||||
conn_mock.commit = MagicMock()
|
||||
|
||||
async_ctx = MagicMock()
|
||||
async_ctx.__aenter__ = AsyncMock(return_value=conn_mock)
|
||||
async_ctx.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
pool_mock = MagicMock()
|
||||
pool_mock.get_connection = MagicMock(return_value=async_ctx)
|
||||
return pool_mock, conn_mock
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_partener_happy_path():
|
||||
"""
|
||||
Cazul nominal:
|
||||
- Pre-check CUI: nicio coliziune (fetchone() → None)
|
||||
- SELECT MAX(id_part)+1 → 4242
|
||||
- INSERT reușește; conn.commit() apelat; întoarce PartenerItem.
|
||||
"""
|
||||
cursor = MagicMock()
|
||||
# fetchone secvență: pre-check CUI (None), SELECT MAX (4242,)
|
||||
cursor.fetchone.side_effect = [None, (4242,)]
|
||||
cursor.execute = MagicMock()
|
||||
|
||||
pool_mock, conn_mock = _make_pool_ctx(cursor)
|
||||
|
||||
with patch(
|
||||
"backend.modules.service_auto.services.lookup_service.oracle_pool",
|
||||
pool_mock,
|
||||
), patch(
|
||||
"backend.modules.service_auto.services.lookup_service.get_schema",
|
||||
new=AsyncMock(return_value="MARIUSM_AUTO"),
|
||||
):
|
||||
req = PartnerCreateRequest(
|
||||
denumire="ACME SRL", cui="RO12345678", adresa="Str. X", id_firma=167,
|
||||
)
|
||||
result = await LookupService.create_partener(req, server_id="mariusm_test")
|
||||
|
||||
assert result.id_part == 4242
|
||||
assert result.denumire == "ACME SRL"
|
||||
conn_mock.commit.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_partener_duplicate_cui_raises_409():
|
||||
"""Pre-check CUI găsește rând existent → HTTPException 409, NU INSERT."""
|
||||
cursor = MagicMock()
|
||||
cursor.fetchone.return_value = (1,) # CUI deja există
|
||||
cursor.execute = MagicMock()
|
||||
|
||||
pool_mock, conn_mock = _make_pool_ctx(cursor)
|
||||
|
||||
with patch(
|
||||
"backend.modules.service_auto.services.lookup_service.oracle_pool",
|
||||
pool_mock,
|
||||
), patch(
|
||||
"backend.modules.service_auto.services.lookup_service.get_schema",
|
||||
new=AsyncMock(return_value="MARIUSM_AUTO"),
|
||||
):
|
||||
req = PartnerCreateRequest(
|
||||
denumire="ACME SRL", cui="RO12345678", id_firma=167,
|
||||
)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await LookupService.create_partener(req, server_id="mariusm_test")
|
||||
|
||||
assert exc.value.status_code == 409
|
||||
assert "CUI" in exc.value.detail
|
||||
conn_mock.commit.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_partener_no_cui_skips_precheck():
|
||||
"""Fără CUI → pre-check sărit, doar SELECT MAX + INSERT."""
|
||||
cursor = MagicMock()
|
||||
cursor.fetchone.side_effect = [(99,)] # doar SELECT MAX
|
||||
cursor.execute = MagicMock()
|
||||
|
||||
pool_mock, conn_mock = _make_pool_ctx(cursor)
|
||||
|
||||
with patch(
|
||||
"backend.modules.service_auto.services.lookup_service.oracle_pool",
|
||||
pool_mock,
|
||||
), patch(
|
||||
"backend.modules.service_auto.services.lookup_service.get_schema",
|
||||
new=AsyncMock(return_value="MARIUSM_AUTO"),
|
||||
):
|
||||
req = PartnerCreateRequest(denumire="Persoană fizică", id_firma=167)
|
||||
result = await LookupService.create_partener(req, server_id=None)
|
||||
|
||||
assert result.id_part == 99
|
||||
conn_mock.commit.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_partener_missing_grant_raises_500():
|
||||
"""ORA-01031 (lipsă INSERT privilege) → HTTPException 500 cu mesaj clar."""
|
||||
cursor = MagicMock()
|
||||
# CUI furnizat → fetchone secvență: pre-check (None=fără duplicat), SELECT MAX (1,)
|
||||
cursor.fetchone.side_effect = [None, (1,)]
|
||||
# INSERT primește ORA-01031
|
||||
err = oracledb.DatabaseError()
|
||||
err.args = (MagicMock(code=1031, message="ORA-01031: insufficient privileges"),)
|
||||
|
||||
def execute_side_effect(sql, *args, **kw):
|
||||
del args, kw
|
||||
if "INSERT" in sql.upper():
|
||||
raise err
|
||||
cursor.execute.side_effect = execute_side_effect
|
||||
|
||||
pool_mock, conn_mock = _make_pool_ctx(cursor)
|
||||
|
||||
with patch(
|
||||
"backend.modules.service_auto.services.lookup_service.oracle_pool",
|
||||
pool_mock,
|
||||
), patch(
|
||||
"backend.modules.service_auto.services.lookup_service.get_schema",
|
||||
new=AsyncMock(return_value="MARIUSM_AUTO"),
|
||||
):
|
||||
req = PartnerCreateRequest(
|
||||
denumire="ACME SRL", cui="RO99999999", id_firma=167,
|
||||
)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await LookupService.create_partener(req, server_id="mariusm_test")
|
||||
|
||||
assert exc.value.status_code == 500
|
||||
assert "privilegii" in exc.value.detail.lower()
|
||||
conn_mock.commit.assert_not_called()
|
||||
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
Unit tests pentru _company_id() și _server_id() din routers/comanda.py.
|
||||
|
||||
Acoperă izolarea multi-tenant:
|
||||
- fallback la JWT companies[0] când nu e specificat id_firma
|
||||
- 403 dacă id_firma nu e în JWT companies[]
|
||||
- 400 dacă JWT nu are nicio firmă
|
||||
- extragere server_id din request.state
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from backend.modules.service_auto.routers.comanda import _company_id, _server_id
|
||||
|
||||
|
||||
def _user(companies, username="MARIUS M", user_id=1):
|
||||
"""Construiește un CurrentUser minimal pentru teste (duck typing)."""
|
||||
return SimpleNamespace(
|
||||
username=username,
|
||||
user_id=user_id,
|
||||
companies=companies,
|
||||
permissions=["read", "write"],
|
||||
)
|
||||
|
||||
|
||||
# ---- _company_id ----
|
||||
|
||||
def test_company_id_explicit_in_allowed_list_passes():
|
||||
"""id_firma explicit + în JWT → OK."""
|
||||
user = _user(["110", "167", "169"])
|
||||
assert _company_id(user, 167) == 167
|
||||
|
||||
|
||||
def test_company_id_explicit_not_in_allowed_raises_403():
|
||||
"""id_firma explicit NU în JWT → 403."""
|
||||
user = _user(["110", "167"])
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_company_id(user, 999)
|
||||
assert exc.value.status_code == 403
|
||||
assert "neautorizat" in exc.value.detail.lower()
|
||||
|
||||
|
||||
def test_company_id_none_falls_back_to_first_company():
|
||||
"""Fără id_firma → prima firmă din JWT companies[]."""
|
||||
user = _user(["167", "110", "169"])
|
||||
assert _company_id(user, None) == 167
|
||||
|
||||
|
||||
def test_company_id_empty_companies_raises_400():
|
||||
"""JWT fără companies[] → 400 (nu putem alege firmă implicită)."""
|
||||
user = _user([])
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_company_id(user, None)
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
|
||||
def test_company_id_string_companies_converted_to_int():
|
||||
"""JWT stochează companies[] ca list[str]; comparația se face pe int."""
|
||||
user = _user(["110", "167", "169"])
|
||||
# comparație cu int funcționează
|
||||
assert _company_id(user, 110) == 110
|
||||
|
||||
|
||||
def test_company_id_accepts_string_id_from_first_company():
|
||||
"""Prima firmă e string în JWT → e convertită corect la int."""
|
||||
user = _user(["42"])
|
||||
assert _company_id(user, None) == 42
|
||||
|
||||
|
||||
# ---- _server_id ----
|
||||
|
||||
def test_server_id_from_request_state():
|
||||
"""Extragere server_id injectat de AuthenticationMiddleware."""
|
||||
request = SimpleNamespace(state=SimpleNamespace(server_id="mariusm_test"))
|
||||
assert _server_id(request) == "mariusm_test"
|
||||
|
||||
|
||||
def test_server_id_none_when_missing():
|
||||
"""request.state fără server_id → None (pool folosește primul server)."""
|
||||
request = SimpleNamespace(state=SimpleNamespace())
|
||||
assert _server_id(request) is None
|
||||
|
||||
|
||||
def test_server_id_none_when_explicit_none():
|
||||
"""server_id explicit None în state → None."""
|
||||
request = SimpleNamespace(state=SimpleNamespace(server_id=None))
|
||||
assert _server_id(request) is None
|
||||
267
docs/service-auto/PACK_SERII_NUMERE.pck
Normal file
267
docs/service-auto/PACK_SERII_NUMERE.pck
Normal file
@@ -0,0 +1,267 @@
|
||||
-- Reconstructed from ALL_SOURCE (GET_DDL unavailable for ROA_WEB)
|
||||
-- ============ PACKAGE SPEC ============
|
||||
CREATE OR REPLACE PACKAGE MARIUSM_AUTO.
|
||||
PACKAGE "PACK_SERII_NUMERE" is
|
||||
|
||||
-- Author : MARIUS.ATANASIU
|
||||
-- Created : 9/8/2006 14:00:56
|
||||
-- Purpose :
|
||||
|
||||
-- 11.11.2009
|
||||
-- marius.mutu
|
||||
-- aloca_numar - id_tip_entitate = 6 (tipuri imobilizari)
|
||||
|
||||
-- 14.07.2016
|
||||
-- marius.mutu
|
||||
-- am marit numarul de elemente din tabela_tipdoc si tabela_numere la 23
|
||||
-- dadea eroare la genereare de numere pentru PROFORMA tip=23
|
||||
|
||||
-- 17.08.2016
|
||||
-- marius.mutu
|
||||
-- adauga_serie, modifica_serie: adaugat parametri ISAUTOFACTURA, ISBENEFICIARI, ISTERTI, ISFURNIZORI
|
||||
|
||||
v_id_tipdoc PLAJE_NUMERE.ID_TIPDOC%TYPE;
|
||||
v_id_tipentitate PLAJE_NUMERE.ID_TIPENTITATE%TYPE;
|
||||
v_id_entitate PLAJE_NUMERE.ID_ENTITATE%TYPE;
|
||||
v_id_serie PLAJE_NUMERE.ID_SERIE%TYPE;
|
||||
v_id_sucursala PLAJE_NUMERE.ID_SUCURSALA%TYPE;
|
||||
|
||||
nNumereMultiple NUMBER(1) := 0; -- 1 = in cadrul unei operatii se genereaza mai multe numere din tipul de document respectiv
|
||||
|
||||
nTipNIR PLAJE_NUMERE.ID_TIPDOC%TYPE := 1;
|
||||
nTipBonConsum PLAJE_NUMERE.ID_TIPDOC%TYPE := 2;
|
||||
nTipBonFiscal PLAJE_NUMERE.ID_TIPDOC%TYPE := 3;
|
||||
nTipNIRCMP PLAJE_NUMERE.ID_TIPDOC%TYPE := 4;
|
||||
nTipFactura PLAJE_NUMERE.ID_TIPDOC%TYPE := 5;
|
||||
nTipAvizExpeditie PLAJE_NUMERE.ID_TIPDOC%TYPE := 6;
|
||||
nTipPVProductieVin PLAJE_NUMERE.ID_TIPDOC%TYPE := 7;
|
||||
nTipPVProductie PLAJE_NUMERE.ID_TIPDOC%TYPE := 8;
|
||||
nTipBonLivrare PLAJE_NUMERE.ID_TIPDOC%TYPE := 9;
|
||||
nTipNumarRegistratura PLAJE_NUMERE.ID_TIPDOC%TYPE := 10;
|
||||
nTipNumarInventar PLAJE_NUMERE.ID_TIPDOC%TYPE := 11;
|
||||
nTipMonetar PLAJE_NUMERE.ID_TIPDOC%TYPE := 12;
|
||||
nTipContract PLAJE_NUMERE.ID_TIPDOC%TYPE := 13;
|
||||
nTipCodMaterial PLAJE_NUMERE.ID_TIPDOC%TYPE := 14;
|
||||
nTipCodBare PLAJE_NUMERE.ID_TIPDOC%TYPE := 15;
|
||||
nTipChitanta PLAJE_NUMERE.ID_TIPDOC%TYPE := 16;
|
||||
nTipNotaPlata PLAJE_NUMERE.ID_TIPDOC%TYPE := 17;
|
||||
nTipNrInventarImob PLAJE_NUMERE.ID_TIPDOC%TYPE := 18;
|
||||
nTipNrDispPlata PLAJE_NUMERE.ID_TIPDOC%TYPE := 19;
|
||||
nTipNrOrdinPlata PLAJE_NUMERE.ID_TIPDOC%TYPE := 21;
|
||||
nTipBonImport PLAJE_NUMERE.ID_TIPDOC%TYPE := 22;
|
||||
nTipProforma PLAJE_NUMERE.ID_TIPDOC%TYPE := 23;
|
||||
nTipFactura394 PLAJE_NUMERE.ID_TIPDOC%TYPE := 24;
|
||||
nTipBonPersoane PLAJE_NUMERE.ID_TIPDOC%TYPE := 25;
|
||||
nTipPOSCard PLAJE_NUMERE.ID_TIPDOC%TYPE := 26;
|
||||
|
||||
nCountTipDoc NUMBER(5) := 26;
|
||||
|
||||
TYPE linie_numar IS RECORD(
|
||||
v_id_numar SERII_NUMERE.ID_NUMAR%TYPE,
|
||||
v_numar SERII_NUMERE.NUMAR%TYPE);
|
||||
|
||||
TYPE tab_numere IS TABLE OF linie_numar;
|
||||
TYPE tab_numere2d IS TABLE OF tab_numere;
|
||||
TYPE cursor_plaje IS REF CURSOR;
|
||||
TYPE tab_tipdoc_nrelem IS TABLE OF NUMBER(10);
|
||||
|
||||
tabela_tipdoc tab_tipdoc_nrelem := tab_tipdoc_nrelem(1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1);
|
||||
|
||||
linie_goala linie_numar := null;
|
||||
-- trebuie atatea linie_goala cate tipuri de documente sunt
|
||||
tabela_numere tab_numere2d := tab_numere2d(tab_numere(linie_goala,
|
||||
linie_goala,
|
||||
linie_goala,
|
||||
linie_goala,
|
||||
linie_goala,
|
||||
linie_goala,
|
||||
linie_goala,
|
||||
linie_goala,
|
||||
linie_goala,
|
||||
linie_goala,
|
||||
linie_goala,
|
||||
linie_goala,
|
||||
linie_goala,
|
||||
linie_goala,
|
||||
linie_goala,
|
||||
linie_goala,
|
||||
linie_goala,
|
||||
linie_goala,
|
||||
linie_goala,
|
||||
linie_goala,
|
||||
linie_goala,
|
||||
linie_goala,
|
||||
linie_goala,
|
||||
linie_goala,
|
||||
linie_goala,
|
||||
linie_goala));
|
||||
|
||||
PROCEDURE verificari_serie(V_ID_SERIE IN NUMBER,
|
||||
V_AN IN NUMBER,
|
||||
V_LUNA IN NUMBER,
|
||||
V_LUNGIME IN NUMBER);
|
||||
|
||||
PROCEDURE adauga_serie(V_SERIE IN VARCHAR2,
|
||||
V_AN IN NUMBER,
|
||||
V_LUNA IN NUMBER,
|
||||
V_LUNGIME IN NUMBER,
|
||||
V_INACTIV IN NUMBER,
|
||||
V_ID_UTIL IN NUMBER,
|
||||
V_ISAUTOFACTURA IN SERII.ISAUTOFACTURA%TYPE DEFAULT 0,
|
||||
V_ISBENEFICIARI IN SERII.ISBENEFICIARI%TYPE DEFAULT 0,
|
||||
V_ISTERTI IN SERII.ISTERTI%TYPE DEFAULT 0,
|
||||
V_ISFURNIZORI IN SERII.ISFURNIZORI%TYPE DEFAULT 0,
|
||||
V_PREFIX IN SERII.PREFIX%TYPE DEFAULT NULL,
|
||||
V_AN2CARACTERE IN SERII.AN2CARACTERE%TYPE DEFAULT 0);
|
||||
|
||||
PROCEDURE modifica_serie(V_ID_SERIE IN NUMBER,
|
||||
V_SERIE IN VARCHAR2,
|
||||
V_AN IN NUMBER,
|
||||
V_LUNA IN NUMBER,
|
||||
V_LUNGIME IN NUMBER,
|
||||
V_INACTIV IN NUMBER,
|
||||
V_ID_UTIL IN NUMBER,
|
||||
V_ISAUTOFACTURA IN SERII.ISAUTOFACTURA%TYPE DEFAULT 0,
|
||||
V_ISBENEFICIARI IN SERII.ISBENEFICIARI%TYPE DEFAULT 0,
|
||||
V_ISTERTI IN SERII.ISTERTI%TYPE DEFAULT 0,
|
||||
V_ISFURNIZORI IN SERII.ISFURNIZORI%TYPE DEFAULT 0,
|
||||
V_PREFIX IN SERII.PREFIX%TYPE DEFAULT NULL,
|
||||
V_AN2CARACTERE IN SERII.AN2CARACTERE%TYPE DEFAULT 0);
|
||||
|
||||
PROCEDURE sterge_serie(V_ID_SERIE IN NUMBER, V_ID_UTIL IN NUMBER);
|
||||
|
||||
PROCEDURE verificari_plaja(V_ID_PLAJA IN NUMBER,
|
||||
V_ID_TIPDOC IN NUMBER,
|
||||
V_ID_TIPENTITATE IN NUMBER,
|
||||
V_ID_ENTITATE IN NUMBER,
|
||||
V_ID_SERIE IN NUMBER,
|
||||
V_ID_SUCURSALA IN NUMBER,
|
||||
V_PL_INF IN NUMBER,
|
||||
V_PL_SUP IN NUMBER,
|
||||
V_DATAI IN DATE,
|
||||
V_DATAS IN DATE);
|
||||
|
||||
PROCEDURE adauga_plaja(V_ID_TIPDOC IN NUMBER,
|
||||
V_ID_TIPENTITATE IN NUMBER,
|
||||
V_ID_SERIE IN NUMBER,
|
||||
V_ID_ENTITATE IN NUMBER,
|
||||
V_ID_SUCURSALA IN NUMBER,
|
||||
V_PL_INF IN NUMBER,
|
||||
V_PL_SUP IN NUMBER,
|
||||
V_DATAI IN DATE,
|
||||
V_DATAS IN DATE,
|
||||
V_INACTIV IN NUMBER,
|
||||
V_ID_UTIL IN NUMBER);
|
||||
|
||||
PROCEDURE modifica_plaja(V_ID_PLAJA IN NUMBER,
|
||||
V_ID_TIPDOC IN NUMBER,
|
||||
V_ID_TIPENTITATE IN NUMBER,
|
||||
V_ID_SERIE IN NUMBER,
|
||||
V_ID_ENTITATE IN NUMBER,
|
||||
V_ID_SUCURSALA IN NUMBER,
|
||||
V_PL_INF IN NUMBER,
|
||||
V_PL_SUP IN NUMBER,
|
||||
V_DATAI IN DATE,
|
||||
V_DATAS IN DATE,
|
||||
V_INACTIV IN NUMBER,
|
||||
V_ID_UTIL IN NUMBER);
|
||||
|
||||
PROCEDURE sterge_plaja(V_ID_PLAJA IN NUMBER, V_ID_UTIL IN NUMBER);
|
||||
|
||||
PROCEDURE modifica_tipdoc(V_ID_TIPDOC IN NUMBER,
|
||||
V_ID_TIPENTITATE IN NUMBER,
|
||||
V_CU_SERIE IN NUMBER,
|
||||
V_CU_PLAJE IN NUMBER,
|
||||
V_AN IN NUMBER,
|
||||
V_LUNA IN NUMBER,
|
||||
V_AN2CARACTERE IN NUMBER,
|
||||
V_DEZACTIVEAZA_TOT IN NUMBER,
|
||||
V_ID_UTIL IN NUMBER);
|
||||
|
||||
PROCEDURE seteazaNumereMultiple(V_NUMERE_MULTIPLE IN NUMBER);
|
||||
|
||||
PROCEDURE adauga_element(V_ID_TIPDOC IN NUMBER);
|
||||
|
||||
PROCEDURE seteaza_numar(V_ID_PLAJA IN NUMBER,
|
||||
V_ULTIMUL_NUMAR IN NUMBER,
|
||||
V_URMATORUL_NUMAR IN NUMBER);
|
||||
|
||||
PROCEDURE aloca_numar(V_ID_TIPDOC IN NUMBER,
|
||||
V_ID_SERIE IN NUMBER,
|
||||
V_ID_GESTIUNE IN NUMBER,
|
||||
V_ID_UTIL IN NUMBER,
|
||||
V_ID_SUCURSALA IN NUMBER,
|
||||
V_NUMAR OUT NUMBER);
|
||||
|
||||
PROCEDURE aloca_numar(V_ID_TIPDOC IN NUMBER,
|
||||
V_ID_SERIE IN NUMBER,
|
||||
V_ID_GESTIUNE IN NUMBER,
|
||||
V_ID_UTIL IN NUMBER,
|
||||
V_ID_SUCURSALA IN NUMBER,
|
||||
V_NUMAR OUT NUMBER,
|
||||
V_ID_NUMAR OUT SERII_NUMERE.ID_NUMAR%TYPE);
|
||||
|
||||
PROCEDURE dezaloca_numere(V_ID_TIPDOC IN NUMBER, V_POZ_START IN NUMBER);
|
||||
|
||||
PROCEDURE dezaloca_numar(V_ID_TIPDOC IN NUMBER);
|
||||
|
||||
PROCEDURE dezaloca_id_numar(V_ID_NUMAR SERII_NUMERE.ID_NUMAR%TYPE);
|
||||
|
||||
FUNCTION genereaza_urmval(V_ID_PLAJA IN NUMBER,
|
||||
V_PL_INF IN NUMBER,
|
||||
V_PL_SUP IN NUMBER,
|
||||
V_MAXLEN IN NUMBER,
|
||||
V_AN IN NUMBER,
|
||||
V_LUNA IN NUMBER,
|
||||
V_LUNGIME IN NUMBER) RETURN VARCHAR2;
|
||||
|
||||
FUNCTION adauga_serie_numar(V_NUMAR IN NUMBER,
|
||||
V_ID_PLAJA IN NUMBER,
|
||||
V_ALOCAT IN NUMBER) RETURN NUMBER;
|
||||
|
||||
FUNCTION citeste_serie(V_ID_SERIE IN NUMBER) RETURN VARCHAR2;
|
||||
|
||||
PROCEDURE verifica_cursor_serii(V_ID_TIPDOC IN NUMBER,
|
||||
V_ID_UTIL IN NUMBER,
|
||||
V_ID_SUCURSALA IN NUMBER,
|
||||
V_REZULTAT OUT NUMBER,
|
||||
V_CURSOR OUT pack_serii_numere.cursor_plaje);
|
||||
|
||||
PROCEDURE verifica_tipdoc(V_ID_TIPDOC IN NUMBER, V_REZULTAT OUT NUMBER);
|
||||
|
||||
FUNCTION IdUtil2IdGrupUtil(V_ID_UTIL SYN_UTILIZATORI.ID_UTIL%TYPE,
|
||||
V_ID_SUCURSALA NUMBER,
|
||||
RAISE_APP_ERROR NUMBER)
|
||||
RETURN GRUPURI_UTILIZATORI.ID_GRUP%TYPE;
|
||||
|
||||
end PACK_SERII_NUMERE;
|
||||
/
|
||||
-- ============ PACKAGE BODY ============
|
||||
CREATE OR REPLACE PACKAGE BODY MARIUSM_AUTO.
|
||||
|
||||
/
|
||||
38
docs/service-auto/autocomplete-dual-decision.md
Normal file
38
docs/service-auto/autocomplete-dual-decision.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# SupplierDualField — refactor decision
|
||||
|
||||
**Decizie**: `SupplierDualField.vue` **NU** se refactorizează cu `AsyncAutoComplete`.
|
||||
|
||||
## Context
|
||||
Task #3 a extras `AsyncAutoComplete` (shared) din pattern-ul typeahead async din
|
||||
`ComandaNoua.vue`. Candidatul pentru refactor a fost
|
||||
`src/modules/data-entry/components/receipts/SupplierDualField.vue`.
|
||||
|
||||
## Motive pentru skip
|
||||
|
||||
1. **Filtru client-side, nu async remote**
|
||||
`SupplierDualField` filtrează o listă `partners` **preîncărcată în memorie** (`props.partners.filter(...)` pe nume + CUI).
|
||||
`AsyncAutoComplete` e construit pe `searchFn: (q) => Promise<Item[]>` (remote).
|
||||
Adaptarea ar cere un wrapper artificial `async (q) => partners.filter(...)` care nu aduce valoare.
|
||||
|
||||
2. **`force-selection: false` vs `force-selection: true`**
|
||||
SupplierDualField permite intrare free-text (`forceSelection: false`) pentru că
|
||||
users pot tasta manual CUI/nume furnizor. `AsyncAutoComplete` impune
|
||||
`force-selection: true` ca invariant de securitate (evită ID-uri fantomă).
|
||||
Schimbarea ar rupe flow-ul existent.
|
||||
|
||||
3. **Dropdown manual + câmp CUI separat**
|
||||
Componenta e **composite**: AutoComplete (nume) + `InputText` (CUI) + toggle adresă
|
||||
+ sync-button + status badges (oracle/local/warning), totul cu propriile `update:*`
|
||||
emits. AutoComplete-ul e doar o parte — extragerea lui izolat ar lăsa componenta
|
||||
într-o stare hibridă, mai complicată decât acum.
|
||||
|
||||
4. **Prop `dropdown`**
|
||||
SupplierDualField folosește `dropdown` (buton chevron care arată toată lista).
|
||||
`AsyncAutoComplete` nu expune acest mod (ar contrazice pattern-ul async „caută
|
||||
minim N caractere").
|
||||
|
||||
## Concluzie
|
||||
Pattern-urile diferă fundamental. Păstrăm `SupplierDualField` neschimbat.
|
||||
`AsyncAutoComplete` rămâne dedicat pattern-urilor de typeahead async, cu sursa
|
||||
de date remote (ex: `ComandaNoua.vue` → partener service-auto, și viitoare
|
||||
formulare similare).
|
||||
@@ -229,3 +229,34 @@ ROA_WEB creat în faza B când ipoteza #3 e pusă la test.
|
||||
| `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ță |
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-13 — Skip 3 integration tests obsolete (post bc481da refactor)
|
||||
|
||||
### Context
|
||||
După commit `bc481da` (multi-tenant refactor) + `9cd7f35` (phase 3 PACK_AUTO)
|
||||
+ migrația `ff_2026_04_13_01_AUTO.sql` (grants Tier 3), 3 integration tests
|
||||
vechi din hypothesis-probing phase 1 au rămas pe un contract depășit.
|
||||
|
||||
### Teste marcate cu `@pytest.mark.skip`
|
||||
|
||||
| Test | Cauză | Acțiune viitoare |
|
||||
|---|---|---|
|
||||
| `test_grants_integration::test_select_direct_fails` | `ff_2026_04_13_01_AUTO.sql` acordă SELECT pe NOM_LUCRARI lui ROA_WEB (necesar pentru `/operatii`). Asserția ORA-00942 e inversă realității actuale. | Șterge sau rescrie cu altă tabelă la care ROA_WEB n-are acces (dacă mai există un astfel de caz). |
|
||||
| `test_grants_integration::test_exec_sp_succeeds` | `SP_CREEAZA_COMANDA_PROTOTIP` nu mai e folosit în producție — `comanda_service.crea_comanda` invocă `PACK_SERII_NUMERE.NUMAR_AUTO_INI` + `PACK_AUTO.DEV_ADAUGA_LUCRARE`. Apelul cu 4 IN params cauzează PLS-00306. | Rescrie ca smoke live pe `PACK_AUTO.DEV_ADAUGA_LUCRARE` (17 params) SAU șterge — acoperit de `test_router_authorization` + live smoke tests. |
|
||||
| `test_comanda_persist::test_comanda_persist_and_reconnect` | Același SP obsolete + durabilitate acum validată prin live smoke `POST /api/service-auto/comenzi` (vezi HANDOFF.md, 2026-04-13). | Rescrie ca e2e peste endpoint-ul HTTP dacă e nevoie, altfel șterge. |
|
||||
|
||||
### Impact suite
|
||||
|
||||
| Înainte | După |
|
||||
|---|---|
|
||||
| 62 passed, 3 failed (PLS-00306 + ORA-00942 inversat) | 62 passed, 3 skipped (0 failed) |
|
||||
|
||||
### Ce NU s-a atins
|
||||
|
||||
- Nu s-a modificat cod production (`services/`, `routers/`, `schemas/`) — nu
|
||||
există regresie reală, doar teste care testau contractul vechi.
|
||||
- `test_insert_direct_fails` rămâne activ și trece — ROA_WEB încă NU are INSERT pe NOM_LUCRARI, asertiunea e corectă.
|
||||
- `test_pool_concurrency` (3/3 passed) și `test_diacritice_encoding` (2/2 passed) au ieșit din lista de "failing" — trec pe setup-ul curent; HANDOFF.md era puțin în urmă.
|
||||
|
||||
|
||||
277
docs/service-auto/deploy-schema-noua.md
Normal file
277
docs/service-auto/deploy-schema-noua.md
Normal file
@@ -0,0 +1,277 @@
|
||||
# Deploy — Onboarding schemă / server nou (Service Auto)
|
||||
|
||||
**Scop**: Procedură completă pentru aducerea unei schem Oracle noi (firmă nouă)
|
||||
sub umbrela modulului `service-auto`, inclusiv cazul în care schema trăiește pe
|
||||
un server Oracle nou care nu e încă listat în `ORACLE_SERVERS`.
|
||||
|
||||
**Audiență**: DBA + dev ops ROA2WEB.
|
||||
**Prerechizite**: `CONTAFIN_ORACLE` (sau DBA) pe instanța Oracle destinație;
|
||||
acces SSH la hostul backend pentru `.env` și restart.
|
||||
|
||||
---
|
||||
|
||||
## 0. Convenții
|
||||
|
||||
- `<SCHEMA>` — numele schemei noi (ex: `ACME_AUTO`, `MARIUSM_AUTO`).
|
||||
- `<SERVER_KEY>` — cheia logică pentru server în `ORACLE_SERVERS` (ex: `acme_prod`).
|
||||
- `<ID_FIRMA>` — `id_firma` returnat de `V_NOM_FIRME` pentru schema nouă.
|
||||
- Toate exemplele folosesc placeholder-uri; **nu hardcoda** `MARIUSM_AUTO` în scripturi noi.
|
||||
|
||||
---
|
||||
|
||||
## 1. `impdp` schema nouă
|
||||
|
||||
Schema nouă se creează dintr-o schemă template (sau dintr-un dump al unei firme
|
||||
existente) cu `impdp` + `REMAP_SCHEMA`.
|
||||
|
||||
```bash
|
||||
# Exemplu minimal — adaptează directory_name, dumpfile, logfile la instanța ta.
|
||||
impdp system/<pwd>@<tns> \
|
||||
SCHEMAS=TEMPLATE_AUTO \
|
||||
REMAP_SCHEMA=TEMPLATE_AUTO:<SCHEMA> \
|
||||
DIRECTORY=DATA_PUMP_DIR \
|
||||
DUMPFILE=template_auto.dmp \
|
||||
LOGFILE=impdp_<SCHEMA>.log \
|
||||
TRANSFORM=OID:N
|
||||
```
|
||||
|
||||
**Opțiuni utile**:
|
||||
|
||||
| Caz | Flag |
|
||||
|---|---|
|
||||
| Sequences există deja pe instanță (ex: secvențe globale) | `EXCLUDE=SEQUENCE` |
|
||||
| Doar DDL + date minimale (fără audit/log tables) | `EXCLUDE=TABLE:"IN('AUDIT_LOG','SESSION_LOG')"` |
|
||||
| Paralel pe server cu resurse | `PARALLEL=4` |
|
||||
| Reluare dacă a picat la jumătate | `TABLE_EXISTS_ACTION=SKIP` sau `REPLACE` |
|
||||
|
||||
**Verificare post-impdp**:
|
||||
|
||||
```sql
|
||||
SELECT COUNT(*) FROM DBA_OBJECTS WHERE OWNER = '<SCHEMA>';
|
||||
SELECT OBJECT_TYPE, COUNT(*) FROM DBA_OBJECTS WHERE OWNER = '<SCHEMA>'
|
||||
GROUP BY OBJECT_TYPE ORDER BY 2 DESC;
|
||||
```
|
||||
|
||||
Obiectele critice pentru `service-auto` care **trebuie** să existe:
|
||||
- `PACK_AUTO`, `PACK_MIGRARE`, `PACK_SERII_NUMERE` (PACKAGE)
|
||||
- `AUTO_VMASINICLIENTI` (VIEW)
|
||||
- `DEV_TIP_DEVIZ`, `DEV_ORDL`, `NOM_LUCRARI`, `NOM_PARTENERI`, `CALENDAR` (TABLE)
|
||||
- `DEV_NOM_NORME`, `DEV_NOM_INSPECTORI`, `DEV_NOM_ASIGURATORI` (TABLE)
|
||||
|
||||
---
|
||||
|
||||
## 2. Rulează `onboarding_roa_web.sql` (GRANT-uri)
|
||||
|
||||
**Fișier**: `docs/service-auto/onboarding_roa_web.sql`
|
||||
|
||||
1. Dacă e prima schemă pe instanță, rulează întâi §2 (CREATE USER ROA_WEB)
|
||||
— O SINGURĂ DATĂ pe instanță. Parola se ia din vault și se salvează în
|
||||
`backend/secrets/<SERVER_KEY>.oracle_pass` (gitignored).
|
||||
2. Deschide o copie locală a fișierului și înlocuiește `<SCHEMA>` cu schema reală
|
||||
(un singur search-and-replace — vezi header-ul fișierului).
|
||||
3. Rulează secțiunea §1 conectat ca `CONTAFIN_ORACLE` (sau DBA cu privilegii de GRANT).
|
||||
|
||||
**Verificare post-grants**:
|
||||
|
||||
```sql
|
||||
-- Ar trebui să returneze ≥ 11 rânduri (6 granturi inițiale + 5 din migrații 04_13).
|
||||
SELECT TABLE_NAME, PRIVILEGE
|
||||
FROM DBA_TAB_PRIVS
|
||||
WHERE GRANTEE = 'ROA_WEB' AND OWNER = '<SCHEMA>'
|
||||
ORDER BY TABLE_NAME, PRIVILEGE;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Update `.env` cu `ORACLE_SERVERS` (doar pentru server nou)
|
||||
|
||||
Dacă schema nouă e pe un **server Oracle nou** (alt host decât cele existente),
|
||||
trebuie adăugat un entry în `backend/.env`.
|
||||
|
||||
> **NU atinge `.env` direct pe serverele rulante în această procedură** — doar
|
||||
> documentează modificarea. Deployment-ul `.env` urmează procesul standard al
|
||||
> ops-ului (secret manager / Ansible / copy manual).
|
||||
|
||||
Pattern per `backend/ENV-SETUP.md`:
|
||||
|
||||
```bash
|
||||
# backend/.env (extras)
|
||||
ORACLE_SERVERS=existing_key,acme_prod
|
||||
|
||||
ORACLE_SERVER_ACME_PROD_DSN=oracle.acme.local:1521/ROA
|
||||
ORACLE_SERVER_ACME_PROD_USER=ROA_WEB
|
||||
ORACLE_SERVER_ACME_PROD_PASS_FILE=backend/secrets/acme_prod.oracle_pass
|
||||
```
|
||||
|
||||
Dacă schema nouă trăiește pe un server **deja listat** în `ORACLE_SERVERS`,
|
||||
pasul 3 se sare — nu trebuie modificat nimic în `.env`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Restart backend + verifică `V_NOM_FIRME`
|
||||
|
||||
Per `CLAUDE.md`, serverele se pornesc/opresc **doar** cu scripturile dedicate:
|
||||
|
||||
```bash
|
||||
./start-backend.sh restart # doar backend
|
||||
# sau
|
||||
./start.sh prod # backend + frontend + tunnel (dacă e cazul)
|
||||
./status.sh # verifică starea
|
||||
```
|
||||
|
||||
**Verificare că schema nouă a fost descoperită**:
|
||||
|
||||
```bash
|
||||
# Autentifică-te în UI cu un user care are permisiuni pe schema nouă (sau via curl):
|
||||
curl -H "Authorization: Bearer <JWT>" http://localhost:8000/api/auth/firme
|
||||
```
|
||||
|
||||
Trebuie să apară `<SCHEMA>` cu noul `id_firma`. Notează `<ID_FIRMA>` — e folosit
|
||||
în pasul următor și în smoke tests.
|
||||
|
||||
Dacă `V_NOM_FIRME` e cache-uit, forțează refresh conform `cache/decorators.py`
|
||||
(TTL schema lookups = 24h; restart backend = invalidare completă).
|
||||
|
||||
---
|
||||
|
||||
## 5. Rulează migrațiile service-auto în ordine cronologică
|
||||
|
||||
Directorul `docs/service-auto/migrations/` conține migrațiile per-schemă care
|
||||
completează schema template cu modificările recente. Rulează-le **în ordine
|
||||
alfabetică a numelui fișierului** (convenția `ff_YYYY_MM_DD_NN_AUTO.sql`
|
||||
garantează ordine cronologică).
|
||||
|
||||
Migrații curente (aprilie 2026):
|
||||
|
||||
| Ordine | Fișier | Rol |
|
||||
|---|---|---|
|
||||
| 1 | `ff_2026_04_12_01_AUTO.sql` | Adaugă `DEV_ORDL.id_sucursala` + rescrie `SP_CREEAZA_COMANDA_PROTOTIP` |
|
||||
| 2 | `ff_2026_04_13_01_AUTO.sql` | GRANT-uri + index functional `IX_NOM_PARTENERI_DEN_UPPER` |
|
||||
| 3 | `ff_2026_04_13_02_AUTO.sql` | `GRANT INSERT ON NOM_PARTENERI` |
|
||||
|
||||
**Rulare**: conectat **ca schema țintă** (`<SCHEMA>`), NU ca `CONTAFIN_ORACLE`,
|
||||
pentru că DDL-ul interior (ALTER TABLE, CREATE INDEX, CREATE OR REPLACE PROCEDURE)
|
||||
rulează pe obiectele proprii ale schemei.
|
||||
|
||||
```bash
|
||||
# Înlocuiește schema name în fiecare fișier (sau folosește sqlplus ca <SCHEMA>).
|
||||
sqlplus <SCHEMA>/<pwd>@<tns> @docs/service-auto/migrations/ff_2026_04_12_01_AUTO.sql
|
||||
sqlplus <SCHEMA>/<pwd>@<tns> @docs/service-auto/migrations/ff_2026_04_13_01_AUTO.sql
|
||||
sqlplus <SCHEMA>/<pwd>@<tns> @docs/service-auto/migrations/ff_2026_04_13_02_AUTO.sql
|
||||
```
|
||||
|
||||
Toate migrațiile AUTO sunt **idempotente** (per `.claude/rules/oracle-migrations.md`):
|
||||
- `ALTER TABLE ADD` wrap în `PACK_MIGRARE.COLUMNEXIST`
|
||||
- `CREATE INDEX` wrap în `PACK_MIGRARE.OBJECTEXIST`
|
||||
- `CREATE OR REPLACE PROCEDURE` — natural idempotent
|
||||
- `GRANT` — no-op la re-rulare
|
||||
|
||||
Re-rularea unei migrații = safe. Footer-ul `pack_migrare.UpdateVersiune(...)` e
|
||||
ce scrie în tabelul de versiuni.
|
||||
|
||||
**Verificare migrații**:
|
||||
|
||||
```sql
|
||||
-- Ar trebui să returneze cele 3 versiuni ff_2026_04_12_01_AUTO ... ff_2026_04_13_02_AUTO
|
||||
SELECT VERSIUNE, DATA_APLICARE FROM <SCHEMA>.VERSIUNI_APLICATE
|
||||
WHERE VERSIUNE LIKE 'ff_2026_04%_AUTO'
|
||||
ORDER BY VERSIUNE;
|
||||
```
|
||||
|
||||
(Numele tabelului de versiuni poate diferi — verifică în `PACK_MIGRARE` body.)
|
||||
|
||||
---
|
||||
|
||||
## 6. Smoke tests
|
||||
|
||||
Folosește un JWT valid pentru un user cu permisiuni pe `<ID_FIRMA>`.
|
||||
|
||||
```bash
|
||||
TOKEN="<JWT>"
|
||||
BASE="http://localhost:8000/api/service-auto"
|
||||
|
||||
# 6.1. Ping — răspuns rapid, fără query Oracle.
|
||||
curl -H "Authorization: Bearer $TOKEN" $BASE/ping
|
||||
|
||||
# 6.2. Tip deviz — citește din DEV_TIP_DEVIZ; verifică GRANT SELECT.
|
||||
curl -H "Authorization: Bearer $TOKEN" "$BASE/tip-deviz?id_firma=<ID_FIRMA>"
|
||||
|
||||
# 6.3. Mașini — citește din AUTO_VMASINICLIENTI.
|
||||
curl -H "Authorization: Bearer $TOKEN" "$BASE/masini?id_firma=<ID_FIRMA>"
|
||||
|
||||
# 6.4. Comenzi — JOIN DEV_ORDL + NOM_LUCRARI.
|
||||
curl -H "Authorization: Bearer $TOKEN" "$BASE/comenzi?id_firma=<ID_FIRMA>"
|
||||
```
|
||||
|
||||
**Expectat**: 200 OK cu JSON (array-uri sau obiecte paginate). 401/403 = JWT
|
||||
sau permisiuni greșite. 500 cu `ORA-00942` = GRANT lipsă (re-rulează §2).
|
||||
500 cu `ORA-04063` = migrațiile §5 nu au rulat complet (verifică `SP_CREEAZA_...`
|
||||
și `DEV_ORDL.id_sucursala`).
|
||||
|
||||
---
|
||||
|
||||
## 7. Rollback
|
||||
|
||||
Dacă onboarding-ul a eșuat ireversibil sau schema nouă trebuie eliminată:
|
||||
|
||||
### 7.1. Oprește accesul din backend
|
||||
|
||||
Elimină `<SERVER_KEY>` din `.env` `ORACLE_SERVERS` (dacă a fost adăugat la pasul 3)
|
||||
și `./start-backend.sh restart`. `V_NOM_FIRME` nu va mai fi interogat pe hostul
|
||||
respectiv.
|
||||
|
||||
### 7.2. Revocă granturi (optional — vor dispărea cu DROP USER)
|
||||
|
||||
```sql
|
||||
-- Conectat ca CONTAFIN_ORACLE / DBA
|
||||
REVOKE ALL ON <SCHEMA>.PACK_AUTO FROM ROA_WEB;
|
||||
REVOKE ALL ON <SCHEMA>.AUTO_VMASINICLIENTI FROM ROA_WEB;
|
||||
REVOKE ALL ON <SCHEMA>.DEV_TIP_DEVIZ FROM ROA_WEB;
|
||||
REVOKE ALL ON <SCHEMA>.CALENDAR FROM ROA_WEB;
|
||||
REVOKE ALL ON <SCHEMA>.DEV_ORDL FROM ROA_WEB;
|
||||
REVOKE ALL ON <SCHEMA>.NOM_LUCRARI FROM ROA_WEB;
|
||||
REVOKE ALL ON <SCHEMA>.DEV_NOM_NORME FROM ROA_WEB;
|
||||
REVOKE ALL ON <SCHEMA>.DEV_NOM_INSPECTORI FROM ROA_WEB;
|
||||
REVOKE ALL ON <SCHEMA>.DEV_NOM_ASIGURATORI FROM ROA_WEB;
|
||||
REVOKE ALL ON <SCHEMA>.NOM_PARTENERI FROM ROA_WEB;
|
||||
REVOKE ALL ON <SCHEMA>.PACK_SERII_NUMERE FROM ROA_WEB;
|
||||
```
|
||||
|
||||
### 7.3. DROP schema
|
||||
|
||||
```sql
|
||||
-- IRREVERSIBIL. Asigură-te că ai backup (expdp) înainte.
|
||||
DROP USER <SCHEMA> CASCADE;
|
||||
```
|
||||
|
||||
După `DROP USER CASCADE`, toate granturile acordate de `<SCHEMA>` dispar automat
|
||||
— §7.2 devine redundant, dar îl păstrăm pentru cazul în care vrei să dezactivezi
|
||||
temporar accesul fără a pierde date.
|
||||
|
||||
### 7.4. Rollback migrație specifică (selectiv)
|
||||
|
||||
Migrațiile AUTO nu au script de `DOWN`. Pentru rollback de SP, redeployează
|
||||
versiunea anterioară din `git log docs/service-auto/migrations/`. Pentru
|
||||
rollback de coloană (`DEV_ORDL.id_sucursala`):
|
||||
|
||||
```sql
|
||||
BEGIN
|
||||
IF PACK_MIGRARE.COLUMNEXIST('DEV_ORDL','ID_SUCURSALA')=1 THEN
|
||||
EXECUTE IMMEDIATE 'ALTER TABLE DEV_ORDL DROP COLUMN id_sucursala';
|
||||
END IF;
|
||||
END;
|
||||
/
|
||||
```
|
||||
|
||||
Atenție — drop coloană **pierde date**. Folosește doar dacă schema e nouă
|
||||
și nu are date reale.
|
||||
|
||||
---
|
||||
|
||||
## Anexă — Legături utile
|
||||
|
||||
- [Grants audit](grants-audit.md) — raționament securitate ROA_WEB, attack surface, scalabilitate.
|
||||
- [`onboarding_roa_web.sql`](onboarding_roa_web.sql) — scriptul de granturi per-schemă.
|
||||
- [`migrations/`](migrations/) — migrații AUTO idempotente, rulate per-schemă.
|
||||
- [`backend/ENV-SETUP.md`](../../backend/ENV-SETUP.md) — structura `.env` și `ORACLE_SERVERS`.
|
||||
- [`CLAUDE.md`](../../CLAUDE.md) — reguli `start.sh` / `start-backend.sh` / `status.sh`.
|
||||
@@ -116,6 +116,12 @@ Onboarding-ul ROA_WEB = 1 script rulat după impdp:
|
||||
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;
|
||||
GRANT SELECT ON FIRMA_NOUA.CALENDAR TO ROA_WEB;
|
||||
-- CALENDAR: period selector AppHeader (shared/routes/calendar.py)
|
||||
GRANT SELECT ON FIRMA_NOUA.DEV_ORDL TO ROA_WEB;
|
||||
-- DEV_ORDL: GET /api/service-auto/comenzi (list comenzi)
|
||||
GRANT SELECT ON FIRMA_NOUA.NOM_LUCRARI TO ROA_WEB;
|
||||
-- NOM_LUCRARI: JOIN cu DEV_ORDL pentru nrord (get_comenzi)
|
||||
-- adaugă orice alte SP/view-uri noi apărute de la ultimul onboarding
|
||||
```
|
||||
|
||||
@@ -194,6 +200,22 @@ ROLLBACK;
|
||||
| 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 |
|
||||
| Expunere `CALENDAR` pentru period selector | `GRANT SELECT {SCHEMA}.CALENDAR TO ROA_WEB` per schemă | 1 linie per schemă (parte din onboarding §4.1) |
|
||||
| Expunere `DEV_ORDL` + `NOM_LUCRARI` pentru GET /comenzi | `GRANT SELECT {SCHEMA}.DEV_ORDL/NOM_LUCRARI TO ROA_WEB` per schemă | 2 linii per schemă (parte din onboarding §4.1) |
|
||||
|
||||
---
|
||||
|
||||
## 4.6. Deploy procedure
|
||||
|
||||
Procedura completă de onboarding pentru o schemă/server nou (impdp → granturi →
|
||||
`.env` → migrații → smoke tests → rollback) este documentată separat în:
|
||||
|
||||
**[deploy-schema-noua.md](deploy-schema-noua.md)**
|
||||
|
||||
Acest audit (§4.1–§4.5) stabilește *de ce* onboarding-ul arată așa; fișierul
|
||||
`deploy-schema-noua.md` stabilește *cum* îl execuți pas-cu-pas. Scriptul
|
||||
`onboarding_roa_web.sql` e referința canonică pentru GRANT-urile per-schemă,
|
||||
cu header self-documenting și placeholder `<SCHEMA>`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
63
docs/service-auto/migrations/ff_2026_04_12_01_AUTO.sql
Normal file
63
docs/service-auto/migrations/ff_2026_04_12_01_AUTO.sql
Normal file
@@ -0,0 +1,63 @@
|
||||
-- adaugare coloana DEV_ORDL.id_sucursala + upgrade SP_CREEAZA_COMANDA_PROTOTIP
|
||||
|
||||
-- Rulat conectat ca schema tinta (ex: MARIUSM_AUTO), O SINGURA DATA per schema
|
||||
|
||||
BEGIN
|
||||
IF PACK_MIGRARE.COLUMNEXIST('DEV_ORDL','ID_SUCURSALA')=0 THEN
|
||||
EXECUTE IMMEDIATE 'ALTER TABLE DEV_ORDL ADD (id_sucursala NUMBER(10))';
|
||||
END IF;
|
||||
END;
|
||||
/
|
||||
|
||||
CREATE OR REPLACE PROCEDURE SP_CREEAZA_COMANDA_PROTOTIP(
|
||||
p_tip IN NUMBER,
|
||||
p_id_masiniclient IN NUMBER,
|
||||
p_solicitari IN VARCHAR2,
|
||||
p_id_firma IN NUMBER,
|
||||
p_id_sucursala IN NUMBER DEFAULT NULL,
|
||||
p_id_ordl OUT NUMBER,
|
||||
p_nrord OUT VARCHAR2
|
||||
) AS
|
||||
v_id_lucrare NUMBER;
|
||||
v_seq NUMBER;
|
||||
v_exists NUMBER;
|
||||
v_now DATE := SYSDATE;
|
||||
BEGIN
|
||||
v_seq := SEQ_NR_LUCRARE.NEXTVAL;
|
||||
p_nrord := 'P' || LPAD(p_id_firma, 2, '0') || '-' || v_seq;
|
||||
|
||||
SELECT COUNT(*) INTO v_exists
|
||||
FROM 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;
|
||||
|
||||
INSERT INTO NOM_LUCRARI (nrord, id_mod)
|
||||
VALUES (p_nrord, 1200)
|
||||
RETURNING id_lucrare INTO v_id_lucrare;
|
||||
|
||||
INSERT INTO DEV_ORDL (
|
||||
an, luna,
|
||||
id_lucrare,
|
||||
datai, dataoraad,
|
||||
id_util_ad,
|
||||
id_masiniclient,
|
||||
id_tip,
|
||||
solicitari_client,
|
||||
id_sucursala
|
||||
) 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,
|
||||
p_id_sucursala
|
||||
) RETURNING id_ordl INTO p_id_ordl;
|
||||
END SP_CREEAZA_COMANDA_PROTOTIP;
|
||||
/
|
||||
|
||||
exec pack_migrare.UpdateVersiune('ff_2026_04_12_01_AUTO'); commit;
|
||||
26
docs/service-auto/migrations/ff_2026_04_13_01_AUTO.sql
Normal file
26
docs/service-auto/migrations/ff_2026_04_13_01_AUTO.sql
Normal file
@@ -0,0 +1,26 @@
|
||||
-- grant-uri ROA_WEB pe tabele asiguratori, inspectori, norme, parteneri + index typeahead
|
||||
|
||||
-- Rulat conectat ca schema MARIUSM_AUTO (sau DBA cu privilegii de GRANT)
|
||||
|
||||
GRANT SELECT ON MARIUSM_AUTO.DEV_NOM_NORME TO ROA_WEB;
|
||||
GRANT SELECT ON MARIUSM_AUTO.DEV_NOM_INSPECTORI TO ROA_WEB;
|
||||
GRANT SELECT ON MARIUSM_AUTO.DEV_NOM_ASIGURATORI TO ROA_WEB;
|
||||
GRANT SELECT ON MARIUSM_AUTO.NOM_PARTENERI TO ROA_WEB;
|
||||
GRANT EXECUTE ON MARIUSM_AUTO.pack_serii_numere TO ROA_WEB;
|
||||
|
||||
-- SER_SERII / SER_PLAJE: adaugati manual DACA pack_serii_numere are AUTHID CURRENT_USER
|
||||
-- GRANT SELECT, INSERT, UPDATE ON MARIUSM_AUTO.SER_SERII TO ROA_WEB;
|
||||
-- GRANT SELECT, INSERT, UPDATE ON MARIUSM_AUTO.SER_PLAJE TO ROA_WEB;
|
||||
|
||||
-- Index functional pentru typeahead parteneri (UPPER pe denumire)
|
||||
-- Rulat ca MARIUSM_AUTO owner; ONLINE pentru zero-downtime pe Enterprise Edition
|
||||
BEGIN
|
||||
IF PACK_MIGRARE.OBJECTEXIST('IX_NOM_PARTENERI_DEN_UPPER','INDEX') = 0 THEN
|
||||
EXECUTE IMMEDIATE
|
||||
'CREATE INDEX MARIUSM_AUTO.IX_NOM_PARTENERI_DEN_UPPER
|
||||
ON MARIUSM_AUTO.NOM_PARTENERI (UPPER(DENUMIRE))';
|
||||
END IF;
|
||||
END;
|
||||
/
|
||||
|
||||
exec pack_migrare.UpdateVersiune('ff_2026_04_13_01_AUTO'); commit;
|
||||
8
docs/service-auto/migrations/ff_2026_04_13_02_AUTO.sql
Normal file
8
docs/service-auto/migrations/ff_2026_04_13_02_AUTO.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
-- grant INSERT pe NOM_PARTENERI pentru creare partener nou din UI Service Auto
|
||||
|
||||
-- Rulat conectat ca schema MARIUSM_AUTO (sau DBA cu privilegii de GRANT)
|
||||
-- GRANT este idempotent: re-rulare = no-op.
|
||||
|
||||
GRANT INSERT ON MARIUSM_AUTO.NOM_PARTENERI TO ROA_WEB;
|
||||
|
||||
exec pack_migrare.UpdateVersiune('ff_2026_04_13_02_AUTO'); commit;
|
||||
98
docs/service-auto/migrations/ff_2026_04_14_01_AUTO.sql
Normal file
98
docs/service-auto/migrations/ff_2026_04_14_01_AUTO.sql
Normal file
@@ -0,0 +1,98 @@
|
||||
-- configurare initiala serie + plaja numere pentru comenzi service auto (TIP_DOC=20)
|
||||
--
|
||||
-- Context: happy-path POST /api/service-auto/comenzi esueaza cu
|
||||
-- ORA-20000 "Nu exista plaje de serii comenzi auto pentru aceste configurari!"
|
||||
-- (PACK_SERII_NUMERE line 1071) pentru ca schema MARIUSM_AUTO nu are nicio
|
||||
-- intrare SERII + PLAJE_NUMERE pentru TIP_DOC=20.
|
||||
--
|
||||
-- Verificare live 2026-04-14: AUTHID=DEFINER, verifica_tipdoc(20)=0 (OK),
|
||||
-- verifica_cursor_serii(20, 1, 0)=ORA-20000. Vezi pack-serii-verification.md.
|
||||
--
|
||||
-- IMPORTANT: Aceasta migratie TREBUIE rulata ca MARIUSM_AUTO (sau DBA) — ROA_WEB
|
||||
-- are EXECUTE pe pachet dar nu are DML pe SERII/PLAJE_NUMERE (corect, DEFINER).
|
||||
--
|
||||
-- IMPORTANT: Inainte de executie, inlocuieste placeholder-urile <...> cu valori
|
||||
-- reale stabilite cu Marius M (administrator MARIUSM AUTO):
|
||||
-- <SERIE_NAME> — denumire serie (ex: 'DEV01')
|
||||
-- <V_LUNGIME> — lungime numerica (ex: 6 → 1..999999)
|
||||
-- <V_ID_UTIL> — ID util administrator (care creeaza seria); in runtime
|
||||
-- fiecare user propriu va fi mapat la grup via IdUtil2IdGrupUtil
|
||||
-- <V_PL_INF>,<V_PL_SUP> — plaja (ex: 1..999999)
|
||||
-- <V_ID_SUCURSALA> — 0 pentru toate sucursalele sau ID specific
|
||||
|
||||
DECLARE
|
||||
v_id_serie NUMBER;
|
||||
BEGIN
|
||||
-- pas 1: inregistreaza seria (idempotent: skip daca exista deja o serie cu acelasi nume)
|
||||
BEGIN
|
||||
SELECT ID_SERIE INTO v_id_serie
|
||||
FROM MARIUSM_AUTO.SERII
|
||||
WHERE SERIE = '<SERIE_NAME>'
|
||||
AND ROWNUM = 1;
|
||||
EXCEPTION WHEN NO_DATA_FOUND THEN
|
||||
MARIUSM_AUTO.PACK_SERII_NUMERE.adauga_serie(
|
||||
V_SERIE => '<SERIE_NAME>',
|
||||
V_AN => EXTRACT(YEAR FROM SYSDATE),
|
||||
V_LUNA => EXTRACT(MONTH FROM SYSDATE),
|
||||
V_LUNGIME => <V_LUNGIME>,
|
||||
V_INACTIV => 0,
|
||||
V_ID_UTIL => <V_ID_UTIL>,
|
||||
V_ISAUTOFACTURA => 0,
|
||||
V_ISBENEFICIARI => 0,
|
||||
V_ISTERTI => 0,
|
||||
V_ISFURNIZORI => 0,
|
||||
V_PREFIX => NULL,
|
||||
V_AN2CARACTERE => 0
|
||||
);
|
||||
SELECT ID_SERIE INTO v_id_serie
|
||||
FROM MARIUSM_AUTO.SERII
|
||||
WHERE SERIE = '<SERIE_NAME>'
|
||||
AND ROWNUM = 1;
|
||||
END;
|
||||
|
||||
-- pas 2: inregistreaza plaja pentru TIP_DOC=20 (idempotent: skip daca exista)
|
||||
DECLARE
|
||||
v_count NUMBER;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO v_count
|
||||
FROM MARIUSM_AUTO.PLAJE_NUMERE
|
||||
WHERE ID_TIPDOC = 20
|
||||
AND ID_SERIE = v_id_serie
|
||||
AND NVL(ID_SUCURSALA, 0) = NVL(<V_ID_SUCURSALA>, 0);
|
||||
IF v_count = 0 THEN
|
||||
MARIUSM_AUTO.PACK_SERII_NUMERE.adauga_plaja(
|
||||
V_ID_TIPDOC => 20,
|
||||
V_ID_TIPENTITATE => NULL,
|
||||
V_ID_SERIE => v_id_serie,
|
||||
V_ID_ENTITATE => NULL,
|
||||
V_ID_SUCURSALA => <V_ID_SUCURSALA>,
|
||||
V_PL_INF => <V_PL_INF>,
|
||||
V_PL_SUP => <V_PL_SUP>,
|
||||
V_DATAI => TO_DATE('01.01.' || EXTRACT(YEAR FROM SYSDATE), 'DD.MM.YYYY'),
|
||||
V_DATAS => TO_DATE('31.12.' || EXTRACT(YEAR FROM SYSDATE), 'DD.MM.YYYY'),
|
||||
V_INACTIV => 0,
|
||||
V_ID_UTIL => <V_ID_UTIL>
|
||||
);
|
||||
END IF;
|
||||
END;
|
||||
END;
|
||||
/
|
||||
|
||||
-- verificare post-migratie (nu afecteaza runtime; doar raporteaza status):
|
||||
DECLARE
|
||||
v_rez NUMBER;
|
||||
v_cur SYS_REFCURSOR;
|
||||
BEGIN
|
||||
MARIUSM_AUTO.PACK_SERII_NUMERE.verifica_cursor_serii(
|
||||
V_ID_TIPDOC => 20,
|
||||
V_ID_UTIL => <V_ID_UTIL>,
|
||||
V_ID_SUCURSALA => <V_ID_SUCURSALA>,
|
||||
V_REZULTAT => v_rez,
|
||||
V_CURSOR => v_cur
|
||||
);
|
||||
DBMS_OUTPUT.PUT_LINE('verifica_cursor_serii rezultat=' || v_rez);
|
||||
IF v_cur IS NOT NULL THEN CLOSE v_cur; END IF;
|
||||
END;
|
||||
/
|
||||
|
||||
exec pack_migrare.UpdateVersiune('ff_2026_04_14_01_AUTO'); commit;
|
||||
50
docs/service-auto/onboarding_roa_web.sql
Normal file
50
docs/service-auto/onboarding_roa_web.sql
Normal file
@@ -0,0 +1,50 @@
|
||||
-- =============================================================================
|
||||
-- File purpose : Script de onboarding ROA_WEB pentru o schemă nouă (per-firmă)
|
||||
-- When to run : Rulat ca CONTAFIN_ORACLE (sau DBA) DUPĂ `impdp` al schemei
|
||||
-- Usage : Substituie `<SCHEMA>` (3 apariții pe linie sau per bloc) cu
|
||||
-- numele schemei reale (ex: MARIUSM_AUTO, ACME_AUTO) înainte
|
||||
-- de rulare. Un singur search-and-replace acoperă tot fișierul.
|
||||
-- Version : 2026-04-12
|
||||
-- Prerequisite : ROA_WEB user creat O SINGURĂ DATĂ (vezi §2 de mai jos).
|
||||
-- Full procedure : docs/service-auto/deploy-schema-noua.md
|
||||
-- =============================================================================
|
||||
--
|
||||
-- ORDINE OPERAȚII pentru o schemă nouă:
|
||||
-- 1. impdp schema nouă (REMAP_SCHEMA, exclude sequences dacă există)
|
||||
-- 2. RULEAZĂ SECȚIUNEA §1 a acestui fișier (GRANT-uri per-schemă)
|
||||
-- 3. Adaugă `.env` → `ORACLE_SERVERS` dacă server nou + restart backend
|
||||
-- 4. Rulează migrațiile service-auto în ordine cronologică
|
||||
-- (docs/service-auto/migrations/ff_YYYY_MM_DD_NN_AUTO.sql)
|
||||
-- 5. Smoke test: /ping, /tip-deviz, /masini, /comenzi
|
||||
--
|
||||
-- =============================================================================
|
||||
-- §1. GRANT-uri per-schemă — RULAT PENTRU FIECARE FIRMĂ NOUĂ
|
||||
-- =============================================================================
|
||||
-- Înlocuiește `<SCHEMA>` cu schema reală (ex: MARIUSM_AUTO) înainte de rulare.
|
||||
|
||||
GRANT EXECUTE ON <SCHEMA>.PACK_AUTO TO ROA_WEB;
|
||||
GRANT SELECT ON <SCHEMA>.AUTO_VMASINICLIENTI TO ROA_WEB;
|
||||
GRANT SELECT ON <SCHEMA>.DEV_TIP_DEVIZ TO ROA_WEB;
|
||||
GRANT SELECT ON <SCHEMA>.CALENDAR TO ROA_WEB; -- period selector AppHeader
|
||||
GRANT SELECT ON <SCHEMA>.DEV_ORDL TO ROA_WEB; -- GET /api/service-auto/comenzi
|
||||
GRANT SELECT ON <SCHEMA>.NOM_LUCRARI TO ROA_WEB; -- JOIN cu DEV_ORDL pentru nrord
|
||||
|
||||
-- Granturi adăugate de migrațiile service-auto (ff_2026_04_13_01_AUTO.sql).
|
||||
-- Dacă rulezi onboarding-ul INAINTE de migrații, aceste linii pot fi skipped
|
||||
-- (migrația le va aplica). Dacă rulezi DUPĂ migrații, sunt idempotente (GRANT e no-op).
|
||||
GRANT SELECT ON <SCHEMA>.DEV_NOM_NORME TO ROA_WEB;
|
||||
GRANT SELECT ON <SCHEMA>.DEV_NOM_INSPECTORI TO ROA_WEB;
|
||||
GRANT SELECT ON <SCHEMA>.DEV_NOM_ASIGURATORI TO ROA_WEB;
|
||||
GRANT SELECT ON <SCHEMA>.NOM_PARTENERI TO ROA_WEB;
|
||||
GRANT INSERT ON <SCHEMA>.NOM_PARTENERI TO ROA_WEB;
|
||||
GRANT EXECUTE ON <SCHEMA>.PACK_SERII_NUMERE TO ROA_WEB;
|
||||
|
||||
-- =============================================================================
|
||||
-- §2. ROA_WEB user creation — O SINGURĂ DATĂ pe instanță Oracle
|
||||
-- =============================================================================
|
||||
-- NU rula pentru fiecare firmă nouă. Rulează doar la setup inițial al instanței.
|
||||
-- Parola reală se ia din vault și se salvează în `backend/secrets/<server>.oracle_pass`.
|
||||
|
||||
CREATE USER ROA_WEB IDENTIFIED BY "<REPLACE_WITH_STRONG_PASSWORD_FROM_VAULT>";
|
||||
GRANT CREATE SESSION TO ROA_WEB;
|
||||
-- Fără alte privilegii sistem. Accesul la date = exclusiv prin granturi per-obiect.
|
||||
149
docs/service-auto/pack-serii-verification.md
Normal file
149
docs/service-auto/pack-serii-verification.md
Normal file
@@ -0,0 +1,149 @@
|
||||
# PACK_SERII_NUMERE — Verificare live + unblock serii tipDoc=20
|
||||
|
||||
**Data verificare**: 2026-04-14
|
||||
**Executat de**: teammate `pack-serii-verifier` (task #4, branch `feat/service-auto`)
|
||||
**Mediu**: Oracle `10.0.20.121:1521/ROA`, schema `MARIUSM_AUTO`, user `ROA_WEB`
|
||||
|
||||
---
|
||||
|
||||
## 1. AUTHID — DEFINER ✅ **verificat live**
|
||||
|
||||
```sql
|
||||
SELECT OBJECT_NAME, PROCEDURE_NAME, AUTHID
|
||||
FROM ALL_PROCEDURES
|
||||
WHERE OWNER = 'MARIUSM_AUTO'
|
||||
AND OBJECT_NAME = 'PACK_SERII_NUMERE';
|
||||
```
|
||||
|
||||
Rezultat:
|
||||
|
||||
| OBJECT_NAME | PROCEDURE_NAME | AUTHID |
|
||||
|---|---|---|
|
||||
| `PACK_SERII_NUMERE` | *(null, package-level)* | **`DEFINER`** |
|
||||
|
||||
**Concluzie**: HANDOFF-ul era corect. Pachetul rulează cu privilegiile owner-ului
|
||||
(`MARIUSM_AUTO`), deci `GRANT EXECUTE` pentru `ROA_WEB` este suficient.
|
||||
Nu sunt necesare granturi suplimentare pe `SERII` / `PLAJE_NUMERE` / `SERII_NUMERE`.
|
||||
|
||||
Status obiect: `VALID`, created `2025-09-30`, last DDL `2026-04-13 19:11`.
|
||||
|
||||
---
|
||||
|
||||
## 2. DDL export
|
||||
|
||||
`DBMS_METADATA.GET_DDL('PACKAGE', …)` → **ORA-31603**: ROA_WEB nu are
|
||||
`SELECT_CATALOG_ROLE`. Fallback la `ALL_SOURCE` a reușit pentru **PACKAGE SPEC**
|
||||
(12 825 caractere) dar nu și pentru **PACKAGE BODY** (0 caractere — ROA_WEB
|
||||
nu are SELECT pe source-ul body-ului).
|
||||
|
||||
- Spec salvat: [`PACK_SERII_NUMERE.pck`](./PACK_SERII_NUMERE.pck)
|
||||
- Body-ul va trebui exportat manual de DBA (ex: de pe cont `MARIUSM_AUTO`)
|
||||
dacă e nevoie pentru audit complet.
|
||||
|
||||
### Tabele referite în spec (nume reale)
|
||||
|
||||
Contrar denumirilor din HANDOFF (`SER_SERII`/`SER_PLAJE`), tabelele efective sunt:
|
||||
|
||||
| Referit în spec ca | Rol |
|
||||
|---|---|
|
||||
| `SERII` | Master pentru serii (PREFIX, AN2CARACTERE, ISAUTOFACTURA, ISBENEFICIARI, ISTERTI, ISFURNIZORI) |
|
||||
| `PLAJE_NUMERE` | Plaje numere (ID_TIPDOC, ID_TIPENTITATE, ID_ENTITATE, ID_SERIE, ID_SUCURSALA, PL_INF, PL_SUP, DATAI, DATAS, INACTIV) |
|
||||
| `SERII_NUMERE` | Numere alocate (ID_NUMAR, NUMAR) — destinația lui `aloca_numar` |
|
||||
|
||||
---
|
||||
|
||||
## 3. TIP_DOC=20 — valid, dar **fără configurare**
|
||||
|
||||
Testarea indirectă, prin pachet (ROA_WEB nu are SELECT pe tabele, dar poate apela
|
||||
procedurile pachetului via EXECUTE + DEFINER):
|
||||
|
||||
```
|
||||
PACK_SERII_NUMERE.verifica_tipdoc(V_ID_TIPDOC=20, V_REZULTAT OUT) → 0 (OK)
|
||||
```
|
||||
|
||||
→ TIP_DOC=20 este **acceptat** de pachet (deși nu apare ca named constant
|
||||
`nTip…` în spec — există o „gaură" între `nTipNrDispPlata := 19` și
|
||||
`nTipNrOrdinPlata := 21`, dar array-urile interne `tabela_tipdoc` /
|
||||
`tabela_numere` au 26 elemente, deci indicele 20 e legal).
|
||||
|
||||
```
|
||||
PACK_SERII_NUMERE.verifica_cursor_serii(V_ID_TIPDOC=20, V_ID_UTIL=1, V_ID_SUCURSALA=0, …)
|
||||
→ ORA-20000: Nu exista plaje de serii comenzi auto pentru aceste configurari!
|
||||
at PACK_SERII_NUMERE line 1071
|
||||
```
|
||||
|
||||
**Aceasta este exact eroarea care blochează happy-path `POST /api/service-auto/comenzi`**
|
||||
(mapată la HTTP 422 „Nu s-au configurat valori…"). Cauza: nu există niciun rând în
|
||||
`SERII` + `PLAJE_NUMERE` pentru TIP_DOC=20 care să matchuiască user-ul 1.
|
||||
|
||||
---
|
||||
|
||||
## 4. Granturi efective ROA_WEB (live)
|
||||
|
||||
```sql
|
||||
SELECT TABLE_NAME, PRIVILEGE FROM USER_TAB_PRIVS
|
||||
WHERE GRANTEE='ROA_WEB' AND OWNER='MARIUSM_AUTO';
|
||||
```
|
||||
|
||||
| TABLE_NAME | PRIVILEGE |
|
||||
|---|---|
|
||||
| `AUTO_VMASINICLIENTI` | SELECT |
|
||||
| `CALENDAR` | SELECT |
|
||||
| `DEV_NOM_ASIGURATORI` | SELECT |
|
||||
| `DEV_NOM_INSPECTORI` | SELECT |
|
||||
| `DEV_NOM_NORME` | SELECT |
|
||||
| `DEV_ORDL` | SELECT |
|
||||
| `DEV_TIP_DEVIZ` | SELECT |
|
||||
| `NOM_LUCRARI` | SELECT |
|
||||
| `NOM_PARTENERI` | SELECT, INSERT |
|
||||
| `PACK_AUTO` | EXECUTE |
|
||||
| `PACK_SERII_NUMERE` | EXECUTE |
|
||||
| `SP_CREEAZA_COMANDA_PROTOTIP` | EXECUTE |
|
||||
|
||||
Granturile sunt **aliniate** cu `docs/service-auto/onboarding_roa_web.sql`.
|
||||
`SERII` / `PLAJE_NUMERE` / `SERII_NUMERE` NU sunt în listă — nici nu trebuie să fie,
|
||||
deoarece pachetul e DEFINER.
|
||||
|
||||
---
|
||||
|
||||
## 5. Unblock plan — 3 pași
|
||||
|
||||
### Pasul 1 — DBA (MARIUSM_AUTO sau DBA) rulează migrația
|
||||
|
||||
Fișierul [`migrations/ff_2026_04_14_01_AUTO.sql`](./migrations/ff_2026_04_14_01_AUTO.sql)
|
||||
conține un template cu placeholders pentru:
|
||||
|
||||
- nume serie (ex. `DEV01`)
|
||||
- lungime numerică (ex. `6` → numere 1..999999)
|
||||
- user/grup alocat (`<V_ID_UTIL>` — tipic admin MARIUSM_AUTO; pachetul îl
|
||||
transformă intern în grup via `IdUtil2IdGrupUtil`)
|
||||
- plaja `PL_INF`/`PL_SUP` (ex. 1..999999)
|
||||
- sucursală (`0` pentru toate sucursalele sau ID specific)
|
||||
|
||||
Migrația folosește **procedurile publice ale pachetului** (`adauga_serie` +
|
||||
`adauga_plaja`) pentru a respecta validările interne — NU face INSERT direct
|
||||
în `SERII` / `PLAJE_NUMERE`. DBA-ul trebuie doar să înlocuiască placeholders
|
||||
și să ruleze.
|
||||
|
||||
### Pasul 2 — Verificare de regresie (re-rulare probe)
|
||||
|
||||
După configurare, DBA sau teammate QA poate re-rula scriptul de probe și să
|
||||
confirme că:
|
||||
|
||||
```
|
||||
PACK_SERII_NUMERE.verifica_cursor_serii(20, 1, 0, …) → rezultat=0 (fără ORA-20000)
|
||||
```
|
||||
|
||||
### Pasul 3 — Smoke test happy-path
|
||||
|
||||
- `POST /api/service-auto/comenzi` cu payload valid (masina+tip deviz) pe
|
||||
tenant `mariusm_test`, user `MARIUS M`/pass `123`, firma `MARIUSM AUTO`
|
||||
- Așteptat: HTTP 200 + `pc_nr` generat (prefix + număr + `/` + nr. înmatriculare)
|
||||
|
||||
---
|
||||
|
||||
## 6. Decizie pe AUTHID-related grants
|
||||
|
||||
**NU este necesar** niciun grant suplimentar pe `SERII`/`PLAJE_NUMERE`/`SERII_NUMERE`.
|
||||
DEFINER rights sunt suficiente. Dacă pe viitor observăm ORA-01031 la apel pachet,
|
||||
atunci revedem (dar acum happy-path dă 422 nu 500, deci privilege ≠ cauza).
|
||||
@@ -24,6 +24,13 @@ export const menuSections = [
|
||||
// US-013: Removed bulk-upload link - functionality integrated into Lista Bonuri via drag & drop
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Service Auto',
|
||||
items: [
|
||||
{ to: '/service-auto/comenzi', icon: 'pi pi-wrench', label: 'Comenzi' },
|
||||
{ to: '/service-auto/comanda-noua', icon: 'pi pi-plus', label: 'Comandă Nouă' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Sistem',
|
||||
items: [
|
||||
|
||||
@@ -63,6 +63,24 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- SERVICE AUTO Section -->
|
||||
<div class="menu-section">
|
||||
<h3 class="menu-title">Service Auto</h3>
|
||||
<ul class="menu-list">
|
||||
<li class="menu-item" v-for="item in serviceAutoItems" :key="item.to">
|
||||
<router-link
|
||||
:to="item.to"
|
||||
class="menu-link"
|
||||
:class="{ active: isActive(item.to, item.exactMatch) }"
|
||||
@click="closeMenu"
|
||||
>
|
||||
<i :class="['menu-icon', item.icon]"></i>
|
||||
<span>{{ item.label }}</span>
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- ADMINISTRARE Section -->
|
||||
<div class="menu-section">
|
||||
<h3 class="menu-title">Administrare</h3>
|
||||
@@ -144,6 +162,12 @@ export default {
|
||||
{ to: '/reports/detailed-invoices', icon: 'pi pi-list', label: 'Facturi Detaliate', exactMatch: true }
|
||||
]);
|
||||
|
||||
// SERVICE AUTO: Comenzi, Comandă Nouă
|
||||
const serviceAutoItems = ref([
|
||||
{ to: '/service-auto/comenzi', icon: 'pi pi-list', label: 'Comenzi', exactMatch: true },
|
||||
{ to: '/service-auto/comanda-noua', icon: 'pi pi-plus', label: 'Comandă Nouă', exactMatch: true },
|
||||
]);
|
||||
|
||||
// ADMINISTRARE: Setări
|
||||
const administrareItems = ref([
|
||||
{ to: '/settings', icon: 'pi pi-cog', label: 'Setări', exactMatch: false }
|
||||
@@ -179,6 +203,7 @@ export default {
|
||||
principaleItems,
|
||||
rapoarteItems,
|
||||
analizeItems,
|
||||
serviceAutoItems,
|
||||
administrareItems,
|
||||
isActive,
|
||||
closeMenu,
|
||||
|
||||
236
src/modules/service-auto/components/PartnerCreateDialog.vue
Normal file
236
src/modules/service-auto/components/PartnerCreateDialog.vue
Normal file
@@ -0,0 +1,236 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:visible="visible"
|
||||
modal
|
||||
:header="'Adaugă partener nou'"
|
||||
:style="{ width: '420px', maxWidth: '95vw' }"
|
||||
:closable="!isSaving"
|
||||
:close-on-escape="!isSaving"
|
||||
@update:visible="onVisibleChange"
|
||||
@show="onShow"
|
||||
>
|
||||
<form class="partner-dialog-form" @submit.prevent="save">
|
||||
<div class="field">
|
||||
<label for="partener-denumire" class="field-label">
|
||||
Denumire <span class="req">*</span>
|
||||
</label>
|
||||
<InputText
|
||||
id="partener-denumire"
|
||||
ref="denumireInputRef"
|
||||
v-model="denumire"
|
||||
autocomplete="off"
|
||||
maxlength="100"
|
||||
:disabled="isSaving"
|
||||
:class="['w-full', { 'p-invalid': denumireError }]"
|
||||
aria-required="true"
|
||||
@input="denumireError = ''"
|
||||
/>
|
||||
<small v-if="denumireError" class="field-error">{{ denumireError }}</small>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="partener-cui" class="field-label">CUI / CIF</label>
|
||||
<InputText
|
||||
id="partener-cui"
|
||||
v-model="cui"
|
||||
autocomplete="off"
|
||||
maxlength="30"
|
||||
inputmode="text"
|
||||
placeholder="Ex: RO12345678 (opțional)"
|
||||
:disabled="isSaving"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="partener-adresa" class="field-label">Adresă</label>
|
||||
<Textarea
|
||||
id="partener-adresa"
|
||||
v-model="adresa"
|
||||
rows="2"
|
||||
maxlength="150"
|
||||
:disabled="isSaving"
|
||||
style="width: 100%; resize: vertical;"
|
||||
placeholder="Adresa partenerului (opțional)"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
label="Anulează"
|
||||
severity="secondary"
|
||||
text
|
||||
:disabled="isSaving"
|
||||
@click="cancel"
|
||||
/>
|
||||
<Button
|
||||
label="Salvează"
|
||||
icon="pi pi-check"
|
||||
:loading="isSaving"
|
||||
:disabled="!canSave"
|
||||
@click="save"
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Textarea from 'primevue/textarea'
|
||||
import Button from 'primevue/button'
|
||||
import serviceAutoApi from '../services/api.js'
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
idFirma: { type: Number, default: null },
|
||||
initialDenumire: { type: String, default: '' },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:visible', 'created'])
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const denumire = ref('')
|
||||
const cui = ref('')
|
||||
const adresa = ref('')
|
||||
const denumireError = ref('')
|
||||
const isSaving = ref(false)
|
||||
const denumireInputRef = ref(null)
|
||||
|
||||
const canSave = computed(() => denumire.value.trim().length >= 2 && !isSaving.value)
|
||||
|
||||
watch(() => props.visible, (open) => {
|
||||
if (open) {
|
||||
denumire.value = (props.initialDenumire || '').trim()
|
||||
cui.value = ''
|
||||
adresa.value = ''
|
||||
denumireError.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
function onShow() {
|
||||
// Focus pe input după render-ul dialogului (PrimeVue mounts overlay async).
|
||||
nextTick(() => {
|
||||
const el = denumireInputRef.value?.$el ?? denumireInputRef.value
|
||||
if (el && typeof el.focus === 'function') el.focus()
|
||||
})
|
||||
}
|
||||
|
||||
function onVisibleChange(val) {
|
||||
if (!val && isSaving.value) return // ignoră Esc/X în timpul salvării
|
||||
emit('update:visible', val)
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
if (isSaving.value) return
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const trimmed = denumire.value.trim()
|
||||
if (trimmed.length < 2) {
|
||||
denumireError.value = 'Denumirea trebuie să aibă cel puțin 2 caractere.'
|
||||
return
|
||||
}
|
||||
if (!props.idFirma) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Firmă lipsă',
|
||||
detail: 'Selectează o firmă înainte de a adăuga un partener.',
|
||||
life: 5000,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
isSaving.value = true
|
||||
try {
|
||||
const payload = {
|
||||
denumire: trimmed,
|
||||
cui: cui.value.trim() || null,
|
||||
adresa: adresa.value.trim() || null,
|
||||
id_firma: props.idFirma,
|
||||
}
|
||||
const { data } = await serviceAutoApi.createPartener(payload)
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Partener creat',
|
||||
detail: data.denumire,
|
||||
life: 3000,
|
||||
})
|
||||
|
||||
emit('created', data)
|
||||
emit('update:visible', false)
|
||||
} catch (err) {
|
||||
const status = err.response?.status
|
||||
if (status === 409) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'CUI duplicat',
|
||||
detail: err.response?.data?.detail || 'Există deja un partener cu acest CUI.',
|
||||
life: 5000,
|
||||
})
|
||||
} else if (status === 422) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Validare',
|
||||
detail: err.response?.data?.detail || 'Date invalide.',
|
||||
life: 5000,
|
||||
})
|
||||
} else if (status === 403) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Acces refuzat',
|
||||
detail: 'Nu aveți permisiune să creați parteneri pentru această firmă.',
|
||||
life: 5000,
|
||||
})
|
||||
} else {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Eroare server',
|
||||
detail: 'Partenerul nu a fost creat. Reîncercați.',
|
||||
life: 5000,
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.partner-dialog-form {
|
||||
display: grid;
|
||||
gap: var(--space-md);
|
||||
padding-top: var(--space-xs);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
.req {
|
||||
color: var(--red-500);
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.field-error {
|
||||
color: var(--red-600);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .field-error {
|
||||
color: var(--red-400);
|
||||
}
|
||||
</style>
|
||||
@@ -5,12 +5,50 @@ 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}`
|
||||
|
||||
// Auto-injectare id_firma din compania selectată (localStorage).
|
||||
// Cheia e `selected_company_<username>_<server_id>` — vezi `shared/stores/companies.js`.
|
||||
// Backend acceptă fallback JWT companies[0] dacă param-ul lipsește.
|
||||
const id_firma = getSelectedCompanyId()
|
||||
if (id_firma != null) {
|
||||
if (config.method === 'get') {
|
||||
config.params = { id_firma, ...(config.params || {}) }
|
||||
} else if (config.method === 'post' && config.data && typeof config.data === 'object' && !Array.isArray(config.data)) {
|
||||
if (config.data.id_firma == null) config.data.id_firma = id_firma
|
||||
}
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
export default {
|
||||
getFirme: () => api.get('/firme'),
|
||||
getTipDeviz: () => api.get('/tip-deviz'),
|
||||
getMasini: () => api.get('/masini'),
|
||||
creeazaComanda: (data) => api.post('/comenzi', data),
|
||||
function getSelectedCompanyId() {
|
||||
try {
|
||||
const user = JSON.parse(localStorage.getItem('user') || 'null')
|
||||
const serverId = localStorage.getItem('last_server_id')
|
||||
const username = user?.username
|
||||
if (!username) return null
|
||||
|
||||
const key = serverId
|
||||
? `selected_company_${username}_${serverId}`
|
||||
: `selected_company_${username}`
|
||||
const raw = localStorage.getItem(key)
|
||||
if (!raw) return null
|
||||
const company = JSON.parse(raw)
|
||||
return company?.id_firma ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
getFirme: () => api.get('/firme'),
|
||||
getTipDeviz: () => api.get('/tip-deviz'),
|
||||
getMasini: () => api.get('/masini'),
|
||||
getMasinaDetails:(id) => api.get(`/masini/${id}/detalii`),
|
||||
getAsiguratori: () => api.get('/asiguratori'),
|
||||
getInspectori: (id_asigurator) => api.get('/inspectori', { params: { id_asigurator } }),
|
||||
getOperatii: () => api.get('/operatii'),
|
||||
getParteneri: (q) => api.get('/parteneri', { params: { q } }),
|
||||
createPartener: (data) => api.post('/parteneri', data),
|
||||
getComenzi: (params) => api.get('/comenzi', { params }),
|
||||
creeazaComanda: (data) => api.post('/comenzi', data),
|
||||
}
|
||||
|
||||
15
src/modules/service-auto/stores/sharedStores.js
Normal file
15
src/modules/service-auto/stores/sharedStores.js
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Service Auto Module - Shared Store Instances
|
||||
*
|
||||
* Instantiates the shared stores (auth, companies) with the
|
||||
* Service Auto module's API service.
|
||||
*/
|
||||
|
||||
import { createAuthStore } from '@shared/stores/auth'
|
||||
import { createCompaniesStore } from '@shared/stores/companies'
|
||||
import api from '../services/api.js'
|
||||
|
||||
const resetAllStores = () => {}
|
||||
|
||||
export const useAuthStore = createAuthStore(api, { onLogout: resetAllStores })
|
||||
export const useCompanyStore = createCompaniesStore(api, useAuthStore)
|
||||
@@ -1,139 +1,372 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div :class="isMobile ? 'mobile-page' : 'page-container'">
|
||||
<Toast />
|
||||
|
||||
<div class="card" style="max-width: 560px; margin: var(--space-xl) auto;">
|
||||
<div class="card-header">
|
||||
<h2 style="font-size: var(--text-xl); font-weight: var(--font-semibold); margin: 0;">
|
||||
Comandă Nouă
|
||||
</h2>
|
||||
</div>
|
||||
<!-- Mobile chrome (CLAUDE.md: toate paginile mobile MUST use MobileTopBar + MobileBottomNav) -->
|
||||
<MobileTopBar
|
||||
v-if="isMobile"
|
||||
title="Comandă nouă"
|
||||
show-back
|
||||
:actions="[{ icon: 'pi pi-check', label: 'Salvează', disabled: !isFormValid || isSubmitting }]"
|
||||
@back-click="$router.back()"
|
||||
@action-click="submitComanda"
|
||||
/>
|
||||
|
||||
<div class="card-body">
|
||||
<form class="form-stack" @submit.prevent="submitComanda">
|
||||
<!-- Content area — card pe desktop, padding simplu pe mobile -->
|
||||
<div :class="isMobile ? 'mobile-content' : ''">
|
||||
<div :class="isMobile ? 'form-mobile' : 'card form-card'">
|
||||
<div v-if="!isMobile" class="card-header">
|
||||
<h2 style="font-size: var(--text-xl); font-weight: var(--font-semibold); margin: 0;">
|
||||
Comandă Nouă
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<!-- Firmă -->
|
||||
<div class="field">
|
||||
<label style="font-weight: var(--font-medium); font-size: var(--text-sm); color: var(--text-color-secondary);">
|
||||
Firmă *
|
||||
</label>
|
||||
<Select
|
||||
v-model="form.id_firma"
|
||||
:options="firme"
|
||||
option-label="firma"
|
||||
option-value="id_firma"
|
||||
placeholder="Selectează firma"
|
||||
:disabled="isSubmitting || loadingFirme"
|
||||
:loading="loadingFirme"
|
||||
fluid
|
||||
/>
|
||||
</div>
|
||||
<div :class="isMobile ? '' : 'card-body'">
|
||||
<form class="form-stack" @submit.prevent="submitComanda">
|
||||
|
||||
<!-- Tip comandă -->
|
||||
<div class="field">
|
||||
<label style="font-weight: var(--font-medium); font-size: var(--text-sm); color: var(--text-color-secondary);">
|
||||
Tip comandă *
|
||||
</label>
|
||||
<Select
|
||||
v-model="form.tip_id"
|
||||
:options="tipuriComanda"
|
||||
option-label="denumire"
|
||||
option-value="id_tip"
|
||||
placeholder="Selectează tipul comenzii"
|
||||
:disabled="isSubmitting || loadingTipuri"
|
||||
:loading="loadingTipuri"
|
||||
fluid
|
||||
/>
|
||||
</div>
|
||||
<!-- 1. Firmă -->
|
||||
<div class="field">
|
||||
<label class="field-label">Firmă <span class="req">*</span></label>
|
||||
<Dropdown
|
||||
v-model="form.id_firma"
|
||||
:options="firme"
|
||||
option-label="firma"
|
||||
option-value="id_firma"
|
||||
placeholder="Selectează firma"
|
||||
:disabled="isSubmitting || loadingFirme"
|
||||
:loading="loadingFirme"
|
||||
class="w-full"
|
||||
aria-required="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Client / Mașină -->
|
||||
<div class="field">
|
||||
<label style="font-weight: var(--font-medium); font-size: var(--text-sm); color: var(--text-color-secondary);">
|
||||
Client / Mașină *
|
||||
</label>
|
||||
<Select
|
||||
ref="clientDropdownRef"
|
||||
v-model="form.id_masiniclient"
|
||||
:options="masini"
|
||||
option-label="label"
|
||||
option-value="id_masiniclient"
|
||||
placeholder="Selectează client / mașină"
|
||||
:disabled="isSubmitting || loadingMasini"
|
||||
:loading="loadingMasini"
|
||||
filter
|
||||
fluid
|
||||
/>
|
||||
</div>
|
||||
<!-- 2. Tip comandă -->
|
||||
<div class="field">
|
||||
<label class="field-label">Tip comandă <span class="req">*</span></label>
|
||||
<Dropdown
|
||||
v-model="form.tip_id"
|
||||
:options="tipuriComanda"
|
||||
option-label="denumire"
|
||||
option-value="id_tip"
|
||||
placeholder="Selectează tipul comenzii"
|
||||
:disabled="isSubmitting || loadingTipuri"
|
||||
:loading="loadingTipuri"
|
||||
class="w-full"
|
||||
aria-required="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Operații solicitate -->
|
||||
<div class="field">
|
||||
<label style="font-weight: var(--font-medium); font-size: var(--text-sm); color: var(--text-color-secondary);">
|
||||
Operații solicitate *
|
||||
</label>
|
||||
<Textarea
|
||||
v-model="form.solicitari"
|
||||
rows="4"
|
||||
placeholder="Descrieți operațiile solicitate de client..."
|
||||
:disabled="isSubmitting"
|
||||
style="width: 100%; resize: vertical;"
|
||||
/>
|
||||
</div>
|
||||
<!-- 3. Client / Mașină -->
|
||||
<div class="field">
|
||||
<label class="field-label">Client / Mașină <span class="req">*</span></label>
|
||||
<Dropdown
|
||||
ref="clientDropdownRef"
|
||||
v-model="form.id_masiniclient"
|
||||
:options="masini"
|
||||
option-label="label"
|
||||
option-value="id_masiniclient"
|
||||
placeholder="Selectează client / mașină"
|
||||
:disabled="isSubmitting || loadingMasini"
|
||||
:loading="loadingMasini"
|
||||
filter
|
||||
class="w-full"
|
||||
aria-required="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<div style="display: flex; justify-content: flex-end; padding-top: var(--space-sm);">
|
||||
<Button
|
||||
type="submit"
|
||||
label="Creează Comanda"
|
||||
icon="pi pi-check"
|
||||
:disabled="!isFormValid || isSubmitting"
|
||||
:loading="isSubmitting"
|
||||
/>
|
||||
</div>
|
||||
<!-- 4. Card mașină readonly — apare după selecție (D3: afișează "—" nu ascunde) -->
|
||||
<div v-if="form.id_masiniclient" class="masina-card">
|
||||
<div style="font-size: var(--text-xs); font-weight: var(--font-semibold); color: var(--text-color-secondary); margin-bottom: var(--space-sm); text-transform: uppercase; letter-spacing: 0.05em;">
|
||||
Detalii vehicul
|
||||
</div>
|
||||
<div v-if="loadingMasinaDetails" class="masina-card-grid">
|
||||
<Skeleton v-for="i in 6" :key="i" height="36px" />
|
||||
</div>
|
||||
<div v-else class="masina-card-grid">
|
||||
<div>
|
||||
<div class="masina-field-label">Client</div>
|
||||
<div class="masina-field-value">{{ masinaDetails?.client_nume || '—' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="masina-field-label">Nr. înmatriculare</div>
|
||||
<div class="masina-field-value">{{ masinaDetails?.nr_inmatriculare || '—' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="masina-field-label">Marcă</div>
|
||||
<div class="masina-field-value">{{ masinaDetails?.marca || '—' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="masina-field-label">Model</div>
|
||||
<div class="masina-field-value">{{ masinaDetails?.model || '—' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="masina-field-label">Serie șasiu</div>
|
||||
<div class="masina-field-value">{{ masinaDetails?.serie_sasiu || '—' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="masina-field-label">Cilindree / Putere</div>
|
||||
<div class="masina-field-value">
|
||||
{{ masinaDetails?.cilindree ?? '—' }} cm³ /
|
||||
{{ masinaDetails?.putere_cp ?? '—' }} CP
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
<!-- 5. Asigurator + Inspector (cascadă) -->
|
||||
<div class="field">
|
||||
<label class="field-label">Asigurător</label>
|
||||
<Dropdown
|
||||
v-model="form.id_asigurator"
|
||||
:options="asiguratori"
|
||||
option-label="denumire"
|
||||
option-value="id_asigurator"
|
||||
placeholder="Selectează asigurătorul (opțional)"
|
||||
:disabled="isSubmitting || loadingAsiguratori"
|
||||
:loading="loadingAsiguratori"
|
||||
show-clear
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label">Inspector</label>
|
||||
<Dropdown
|
||||
v-model="form.id_inspector"
|
||||
:options="inspectori"
|
||||
option-label="denumire"
|
||||
option-value="id_inspector"
|
||||
:placeholder="form.id_asigurator ? 'Selectează inspectorul' : 'Selectați asigurătorul întâi'"
|
||||
:disabled="isSubmitting || !form.id_asigurator || loadingInspectori"
|
||||
:loading="loadingInspectori"
|
||||
show-clear
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 6. Referință partener (AsyncAutoComplete shared) -->
|
||||
<div class="field">
|
||||
<label class="field-label">Referință (partener)</label>
|
||||
<AsyncAutoComplete
|
||||
v-model="form._referinta_obj"
|
||||
:search-fn="searchParteneri"
|
||||
option-label="denumire"
|
||||
option-key="id_part"
|
||||
placeholder="Caută partener... (min. 2 caractere)"
|
||||
empty-action-label="+ Adaugă partener nou"
|
||||
aria-label="Referință partener"
|
||||
:disabled="isSubmitting"
|
||||
@empty-action="onAddNewPartner"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 7. Nr dosar, Km, Ore, Termen -->
|
||||
<div class="field">
|
||||
<label class="field-label">Nr. dosar asigurare</label>
|
||||
<InputText
|
||||
v-model="form.nr_dosar"
|
||||
placeholder="Completați dacă e comandă de asigurare..."
|
||||
:disabled="isSubmitting"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-md);">
|
||||
<div class="field">
|
||||
<label class="field-label">Kilometraj la recepție</label>
|
||||
<InputNumber
|
||||
v-model="form.km_int"
|
||||
:min="0"
|
||||
:max="9999999"
|
||||
:use-grouping="true"
|
||||
suffix=" km"
|
||||
:disabled="isSubmitting"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">Ore funcționare motor</label>
|
||||
<InputNumber
|
||||
v-model="form.ore_functionare"
|
||||
:min="0"
|
||||
:max="999999"
|
||||
:use-grouping="true"
|
||||
suffix=" ore"
|
||||
:disabled="isSubmitting"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label">Termen estimat finalizare</label>
|
||||
<Calendar
|
||||
v-model="form.termen"
|
||||
date-format="dd.mm.yy"
|
||||
placeholder="Selectează data..."
|
||||
:disabled="isSubmitting"
|
||||
:min-date="new Date()"
|
||||
show-icon
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 8. MultiSelect Operații cerute -->
|
||||
<div class="field">
|
||||
<label class="field-label">Operații cerute de client</label>
|
||||
<div v-if="loadingOperatii" style="display: flex; flex-direction: column; gap: var(--space-xs);">
|
||||
<Skeleton v-for="i in 3" :key="i" height="32px" />
|
||||
</div>
|
||||
<MultiSelect
|
||||
v-else
|
||||
v-model="form.sir_id_operatii"
|
||||
:options="operatii"
|
||||
option-label="denop"
|
||||
option-value="id_norme"
|
||||
display="chip"
|
||||
filter
|
||||
filter-placeholder="Caută operație..."
|
||||
empty-filter-message="Nicio operație nu corespunde filtrului"
|
||||
placeholder="Selectează operațiile (opțional)"
|
||||
:disabled="isSubmitting"
|
||||
class="w-full"
|
||||
>
|
||||
<template #emptyfilter>
|
||||
<div style="padding: var(--space-sm); color: var(--text-color-secondary); font-size: var(--text-sm);">
|
||||
Încearcă alt termen sau verifică ortografia.
|
||||
</div>
|
||||
</template>
|
||||
</MultiSelect>
|
||||
</div>
|
||||
|
||||
<!-- 9. Observații + Defecțiuni -->
|
||||
<div class="field">
|
||||
<label class="field-label">Observații client</label>
|
||||
<Textarea
|
||||
v-model="form.observatii"
|
||||
rows="3"
|
||||
placeholder="Solicitări, observații client..."
|
||||
:disabled="isSubmitting"
|
||||
style="width: 100%; resize: vertical;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label">Defecțiuni constatate la recepție</label>
|
||||
<Textarea
|
||||
v-model="form.defectiuni"
|
||||
rows="3"
|
||||
placeholder="Defecțiuni observate la preluarea vehiculului..."
|
||||
:disabled="isSubmitting"
|
||||
style="width: 100%; resize: vertical;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 10. Submit (desktop only — pe mobile e în MobileTopBar action) -->
|
||||
<div v-if="!isMobile" style="display: flex; justify-content: flex-end; padding-top: var(--space-sm);">
|
||||
<Button
|
||||
type="submit"
|
||||
label="Creează Comanda"
|
||||
icon="pi pi-check"
|
||||
:disabled="!isFormValid || isSubmitting"
|
||||
:loading="isSubmitting"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MobileBottomNav v-if="isMobile" :items="mobileNavItems" />
|
||||
|
||||
<PartnerCreateDialog
|
||||
v-model:visible="partnerDialogVisible"
|
||||
:id-firma="form.id_firma"
|
||||
:initial-denumire="partnerDialogQuery"
|
||||
@created="onPartnerCreated"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import Select from 'primevue/select'
|
||||
import Dropdown from 'primevue/dropdown'
|
||||
import Textarea from 'primevue/textarea'
|
||||
import Button from 'primevue/button'
|
||||
import Toast from 'primevue/toast'
|
||||
import InputNumber from 'primevue/inputnumber'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Calendar from 'primevue/calendar'
|
||||
import MultiSelect from 'primevue/multiselect'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import AsyncAutoComplete from '@shared/components/AsyncAutoComplete.vue'
|
||||
import MobileTopBar from '@shared/components/mobile/MobileTopBar.vue'
|
||||
import MobileBottomNav from '@shared/components/mobile/MobileBottomNav.vue'
|
||||
import PartnerCreateDialog from '../components/PartnerCreateDialog.vue'
|
||||
import serviceAutoApi from '../services/api.js'
|
||||
import { useCompanyStore } from '../stores/sharedStores.js'
|
||||
|
||||
const toast = useToast()
|
||||
const router = useRouter()
|
||||
const companyStore = useCompanyStore()
|
||||
const clientDropdownRef = ref(null)
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
// ─── Lookup data (from Oracle via API) ────────────────────────────────────────
|
||||
// ─── Mobile detection ──────────────────────────────────────────────────────
|
||||
|
||||
const isMobile = ref(window.innerWidth <= 900)
|
||||
|
||||
function onResize() { isMobile.value = window.innerWidth <= 900 }
|
||||
onMounted(() => window.addEventListener('resize', onResize))
|
||||
onUnmounted(() => window.removeEventListener('resize', onResize))
|
||||
|
||||
const mobileNavItems = [
|
||||
{ label: 'Comenzi', icon: 'pi pi-list', to: '/service-auto/comenzi' },
|
||||
{ label: 'Comandă nouă', icon: 'pi pi-plus', to: '/service-auto/comanda-noua', active: true },
|
||||
]
|
||||
|
||||
// ─── Lookup data ───────────────────────────────────────────────────────────
|
||||
|
||||
const firme = ref([])
|
||||
const tipuriComanda = ref([])
|
||||
const masini = ref([])
|
||||
const asiguratori = ref([])
|
||||
const inspectori = ref([])
|
||||
const operatii = ref([])
|
||||
const masinaDetails = ref(null)
|
||||
|
||||
const loadingFirme = ref(false)
|
||||
const loadingTipuri = ref(false)
|
||||
const loadingMasini = ref(false)
|
||||
const loadingAsiguratori = ref(false)
|
||||
const loadingInspectori = ref(false)
|
||||
const loadingOperatii = ref(false)
|
||||
const loadingMasinaDetails = ref(false)
|
||||
|
||||
async function loadLookups() {
|
||||
loadingFirme.value = true
|
||||
loadingTipuri.value = true
|
||||
loadingMasini.value = true
|
||||
loadingAsiguratori.value = true
|
||||
loadingOperatii.value = true
|
||||
|
||||
const [firmeRes, tipuriRes, masiniRes] = await Promise.allSettled([
|
||||
const [firmeRes, tipuriRes, masiniRes, asiguratoriRes, operatiiRes] = await Promise.allSettled([
|
||||
serviceAutoApi.getFirme(),
|
||||
serviceAutoApi.getTipDeviz(),
|
||||
serviceAutoApi.getMasini(),
|
||||
serviceAutoApi.getAsiguratori(),
|
||||
serviceAutoApi.getOperatii(),
|
||||
])
|
||||
|
||||
if (firmeRes.status === 'fulfilled') {
|
||||
firme.value = firmeRes.value.data
|
||||
// Default: first company
|
||||
if (firme.value.length > 0 && form.value.id_firma === null) {
|
||||
form.value.id_firma = firme.value[0].id_firma
|
||||
const selected = companyStore.selectedCompany
|
||||
const defaultFirma = firme.value.find(f => f.id_firma === selected?.id_firma) || firme.value[0]
|
||||
if (defaultFirma) form.value.id_firma = defaultFirma.id_firma
|
||||
}
|
||||
} else {
|
||||
toast.add({ severity: 'warn', summary: 'Firme', detail: 'Nu s-au putut încărca firmele', life: 4000 })
|
||||
@@ -151,9 +384,23 @@ async function loadLookups() {
|
||||
toast.add({ severity: 'warn', summary: 'Mașini', detail: 'Nu s-au putut încărca mașinile', life: 4000 })
|
||||
}
|
||||
|
||||
if (asiguratoriRes.status === 'fulfilled') {
|
||||
asiguratori.value = asiguratoriRes.value.data
|
||||
} else {
|
||||
toast.add({ severity: 'warn', summary: 'Asigurători', detail: 'Nu s-au putut încărca asigurătorii', life: 4000 })
|
||||
}
|
||||
|
||||
if (operatiiRes.status === 'fulfilled') {
|
||||
operatii.value = operatiiRes.value.data
|
||||
} else {
|
||||
toast.add({ severity: 'warn', summary: 'Operații', detail: 'Nu s-au putut încărca operațiile', life: 4000 })
|
||||
}
|
||||
|
||||
loadingFirme.value = false
|
||||
loadingTipuri.value = false
|
||||
loadingMasini.value = false
|
||||
loadingAsiguratori.value = false
|
||||
loadingOperatii.value = false
|
||||
}
|
||||
|
||||
onMounted(loadLookups)
|
||||
@@ -164,48 +411,140 @@ const emptyForm = () => ({
|
||||
id_firma: null,
|
||||
tip_id: null,
|
||||
id_masiniclient: null,
|
||||
solicitari: '',
|
||||
id_asigurator: null,
|
||||
id_inspector: null,
|
||||
id_part_ref: null,
|
||||
sir_id_operatii: [],
|
||||
observatii: '',
|
||||
defectiuni: '',
|
||||
km_int: 0,
|
||||
ore_functionare: 0,
|
||||
nr_dosar: '',
|
||||
termen: null,
|
||||
_referinta_obj: null, // AutoComplete display model — not sent to API
|
||||
})
|
||||
|
||||
const form = ref(emptyForm())
|
||||
|
||||
const selectedFirma = computed(() => firme.value.find(f => f.id_firma === form.value.id_firma) || null)
|
||||
const idSucursala = computed(() => selectedFirma.value?.id_mama != null ? form.value.id_firma : null)
|
||||
|
||||
const isFormValid = computed(() =>
|
||||
form.value.id_firma !== null &&
|
||||
form.value.tip_id !== null &&
|
||||
form.value.id_masiniclient !== null &&
|
||||
form.value.solicitari.trim().length > 0
|
||||
form.value.id_masiniclient !== null
|
||||
)
|
||||
|
||||
// ─── Mașină selection → card details ──────────────────────────────────────
|
||||
|
||||
watch(() => form.value.id_masiniclient, async (id) => {
|
||||
masinaDetails.value = null
|
||||
if (!id) return
|
||||
loadingMasinaDetails.value = true
|
||||
try {
|
||||
const { data } = await serviceAutoApi.getMasinaDetails(id)
|
||||
masinaDetails.value = data
|
||||
} catch {
|
||||
// Card rămâne null — comanda poate continua oricum
|
||||
} finally {
|
||||
loadingMasinaDetails.value = false
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Asigurator cascade → Inspector ────────────────────────────────────────
|
||||
|
||||
watch(() => form.value.id_asigurator, async (id) => {
|
||||
form.value.id_inspector = null
|
||||
inspectori.value = []
|
||||
if (!id) return
|
||||
loadingInspectori.value = true
|
||||
try {
|
||||
const { data } = await serviceAutoApi.getInspectori(id)
|
||||
inspectori.value = data
|
||||
} catch {
|
||||
toast.add({ severity: 'warn', summary: 'Inspectori', detail: 'Nu s-au putut încărca inspectorii', life: 4000 })
|
||||
} finally {
|
||||
loadingInspectori.value = false
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Partener AsyncAutoComplete ─────────────────────────────────────────────
|
||||
|
||||
async function searchParteneri(q) {
|
||||
try {
|
||||
const { data } = await serviceAutoApi.getParteneri(q)
|
||||
return data
|
||||
} catch {
|
||||
toast.add({ severity: 'warn', summary: 'Parteneri', detail: 'Eroare căutare parteneri', life: 3000 })
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// PartnerCreateDialog wiring: open with last typed query as initial denumire,
|
||||
// auto-select created partener via _referinta_obj (watcher syncs id_part_ref).
|
||||
const partnerDialogVisible = ref(false)
|
||||
const partnerDialogQuery = ref('')
|
||||
|
||||
function onAddNewPartner(query) {
|
||||
if (!form.value.id_firma) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Selectează firmă',
|
||||
detail: 'Alege o firmă înainte de a adăuga un partener.',
|
||||
life: 4000,
|
||||
})
|
||||
return
|
||||
}
|
||||
partnerDialogQuery.value = query || ''
|
||||
partnerDialogVisible.value = true
|
||||
}
|
||||
|
||||
function onPartnerCreated(partener) {
|
||||
form.value._referinta_obj = partener
|
||||
}
|
||||
|
||||
// Sync id_part_ref when autocomplete selection changes
|
||||
watch(() => form.value._referinta_obj, (val) => {
|
||||
form.value.id_part_ref = val?.id_part ?? null
|
||||
})
|
||||
|
||||
// ─── Submit ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function submitComanda() {
|
||||
if (!isFormValid.value) return
|
||||
if (!isFormValid.value || isSubmitting.value) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const termen = form.value.termen
|
||||
? new Date(form.value.termen).toISOString().split('T')[0]
|
||||
: null
|
||||
|
||||
const { data } = await serviceAutoApi.creeazaComanda({
|
||||
tip_id: form.value.tip_id,
|
||||
id_masiniclient: form.value.id_masiniclient,
|
||||
solicitari: form.value.solicitari.trim(),
|
||||
id_firma: form.value.id_firma,
|
||||
id_sucursala: idSucursala.value,
|
||||
id_asigurator: form.value.id_asigurator || null,
|
||||
id_inspector: form.value.id_inspector || null,
|
||||
id_part_ref: form.value.id_part_ref || null,
|
||||
sir_id_operatii: form.value.sir_id_operatii?.length ? form.value.sir_id_operatii : null,
|
||||
observatii: form.value.observatii.trim() || '',
|
||||
defectiuni: form.value.defectiuni.trim() || null,
|
||||
km_int: form.value.km_int ?? 0,
|
||||
ore_functionare: form.value.ore_functionare ?? 0,
|
||||
nr_dosar: form.value.nr_dosar.trim() || '',
|
||||
termen,
|
||||
})
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Comandă creată',
|
||||
detail: `Nr ${data.nrord}`,
|
||||
life: 3000,
|
||||
life: 4000,
|
||||
})
|
||||
|
||||
// Reset — preserve firma + tip (user creează mai multe consecutive)
|
||||
const savedFirma = form.value.id_firma
|
||||
const savedTip = form.value.tip_id
|
||||
form.value = emptyForm()
|
||||
form.value.id_firma = savedFirma
|
||||
form.value.tip_id = savedTip
|
||||
|
||||
await nextTick()
|
||||
clientDropdownRef.value?.$el?.querySelector('input, [role="combobox"]')?.focus()
|
||||
// Redirect la browse cu highlight pe comanda nouă (D4)
|
||||
router.push({ path: '/service-auto/comenzi', query: { highlight: data.id_ordl } })
|
||||
|
||||
} catch (err) {
|
||||
const status = err.response?.status
|
||||
@@ -214,7 +553,7 @@ async function submitComanda() {
|
||||
} else if (status === 503) {
|
||||
toast.add({ severity: 'error', summary: 'Eroare conexiune', detail: 'Serviciul nu este disponibil. Verificați conexiunea Oracle.', life: 5000 })
|
||||
} else {
|
||||
toast.add({ severity: 'error', summary: 'Eroare internă', detail: 'A apărut o eroare pe server. Reîncercați sau contactați suportul.', life: 5000 })
|
||||
toast.add({ severity: 'error', summary: 'Eroare server', detail: 'Comanda nu a fost salvată. Reîncercați sau contactați suportul.', life: 5000 })
|
||||
}
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
@@ -227,4 +566,75 @@ async function submitComanda() {
|
||||
display: grid;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.form-card {
|
||||
max-width: 560px;
|
||||
margin: var(--space-xl) auto;
|
||||
}
|
||||
|
||||
.form-mobile {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
/* MobileTopBar + MobileBottomNav height offsets */
|
||||
.mobile-content {
|
||||
padding-top: 56px;
|
||||
padding-bottom: 72px;
|
||||
}
|
||||
|
||||
/* Card mașină readonly */
|
||||
.masina-card {
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.masina-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-sm) var(--space-md);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.masina-card-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.masina-field-label {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-color-secondary);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.masina-field-value {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.field-label {
|
||||
display: block;
|
||||
font-weight: var(--font-medium);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
.req {
|
||||
color: var(--red-500);
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
/* MultiSelect chip tokens — dark mode safe */
|
||||
:deep(.p-multiselect-token) {
|
||||
background: var(--surface-hover);
|
||||
color: var(--text-color);
|
||||
border: 1px solid var(--surface-border);
|
||||
}
|
||||
|
||||
[data-theme="dark"] :deep(.p-multiselect-token) {
|
||||
background: var(--surface-100);
|
||||
}
|
||||
</style>
|
||||
|
||||
499
src/modules/service-auto/views/ComenziBrowseView.vue
Normal file
499
src/modules/service-auto/views/ComenziBrowseView.vue
Normal file
@@ -0,0 +1,499 @@
|
||||
<template>
|
||||
<div :class="isMobile ? 'mobile-page' : 'page-container'">
|
||||
<Toast />
|
||||
|
||||
<!-- Mobile chrome (CLAUDE.md: toate paginile mobile MUST use MobileTopBar + MobileBottomNav) -->
|
||||
<MobileTopBar
|
||||
v-if="isMobile"
|
||||
title="Comenzi Service"
|
||||
show-menu
|
||||
:actions="[
|
||||
{ icon: 'pi pi-filter', label: 'Filtre', active: hasActiveFilters },
|
||||
{ icon: 'pi pi-plus', label: 'Comandă nouă' },
|
||||
]"
|
||||
@action-click="onMobileAction"
|
||||
/>
|
||||
|
||||
<!-- Mobile filters — in BottomSheet, NEVER inline (CLAUDE.md) -->
|
||||
<BottomSheet v-if="isMobile" v-model="isFilterOpen">
|
||||
<div class="mobile-filter-content">
|
||||
<h3 style="font-size: var(--text-base); font-weight: var(--font-semibold); margin: 0 0 var(--space-md) 0;">Filtre</h3>
|
||||
<div class="field">
|
||||
<label class="filter-label">Status</label>
|
||||
<Dropdown
|
||||
v-model="filters.validat"
|
||||
:options="statusOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
placeholder="Toate"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="filter-label">De la</label>
|
||||
<Calendar
|
||||
v-model="filters.data_de_la"
|
||||
date-format="dd.mm.yy"
|
||||
placeholder="—"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="filter-label">Până la</label>
|
||||
<Calendar
|
||||
v-model="filters.data_pana_la"
|
||||
date-format="dd.mm.yy"
|
||||
placeholder="—"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div style="display: flex; gap: var(--space-sm); margin-top: var(--space-md);">
|
||||
<Button label="Resetează" severity="secondary" outlined style="flex: 1;" @click="clearFilters(); isFilterOpen = false" />
|
||||
<Button label="Aplică" style="flex: 1;" @click="resetAndLoad(); isFilterOpen = false" />
|
||||
</div>
|
||||
</div>
|
||||
</BottomSheet>
|
||||
|
||||
<!-- Main content -->
|
||||
<div :class="isMobile ? 'mobile-content' : ''">
|
||||
<div class="card">
|
||||
<!-- Desktop header with Comandă Nouă button -->
|
||||
<div v-if="!isMobile" class="card-header" style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--space-sm);">
|
||||
<h2 style="font-size: var(--text-xl); font-weight: var(--font-semibold); margin: 0;">
|
||||
Comenzi Service
|
||||
</h2>
|
||||
<router-link to="/service-auto/comanda-noua">
|
||||
<Button label="Comandă Nouă" icon="pi pi-plus" size="small" />
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Desktop filters row -->
|
||||
<div v-if="!isMobile" class="card-body" style="padding-bottom: 0;">
|
||||
<div class="filters-row">
|
||||
<div class="filter-group">
|
||||
<label class="filter-label">Status</label>
|
||||
<Dropdown
|
||||
v-model="filters.validat"
|
||||
:options="statusOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
placeholder="Toate"
|
||||
class="w-full"
|
||||
@change="resetAndLoad"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label class="filter-label">De la</label>
|
||||
<Calendar
|
||||
v-model="filters.data_de_la"
|
||||
date-format="dd.mm.yy"
|
||||
placeholder="—"
|
||||
class="w-full"
|
||||
@date-select="resetAndLoad"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label class="filter-label">Până la</label>
|
||||
<Calendar
|
||||
v-model="filters.data_pana_la"
|
||||
date-format="dd.mm.yy"
|
||||
placeholder="—"
|
||||
class="w-full"
|
||||
@date-select="resetAndLoad"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="filter-group filter-actions">
|
||||
<Button
|
||||
label="Resetează"
|
||||
icon="pi pi-filter-slash"
|
||||
severity="secondary"
|
||||
outlined
|
||||
size="small"
|
||||
@click="clearFilters"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table (desktop) / Card list (mobile) -->
|
||||
<div class="card-body">
|
||||
<!-- Desktop: PrimeVue DataTable -->
|
||||
<DataTable
|
||||
v-if="!isMobile"
|
||||
:value="comenzi"
|
||||
:lazy="true"
|
||||
:paginator="true"
|
||||
:rows="perPage"
|
||||
:total-records="total"
|
||||
:loading="loading"
|
||||
class="p-datatable-sm"
|
||||
striped-rows
|
||||
:row-class="(row) => row.id_ordl === highlightId ? 'row-highlight' : ''"
|
||||
@page="onPage"
|
||||
>
|
||||
<template #empty>
|
||||
<div style="text-align: center; padding: var(--space-xl); color: var(--text-color-secondary);">
|
||||
Nicio comandă găsită
|
||||
</div>
|
||||
</template>
|
||||
<Column field="nrord" header="Nr. Ord." style="min-width: 100px;" />
|
||||
<Column field="datai" header="Data" style="min-width: 100px;" />
|
||||
<Column header="Status" style="min-width: 110px;">
|
||||
<template #body="{ data }">
|
||||
<span :class="['status-badge', statusClass(data)]">
|
||||
{{ statusLabel(data) }}
|
||||
</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="Tip" style="min-width: 130px;">
|
||||
<template #body="{ data }">
|
||||
{{ data.tip_denumire }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="Client / Vehicul" style="min-width: 240px;">
|
||||
<template #body="{ data }">
|
||||
<span style="color: var(--text-color);">{{ data.vehicul || '—' }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
|
||||
<!-- Mobile: card list -->
|
||||
<div v-else>
|
||||
<div v-if="loading" style="text-align: center; padding: var(--space-xl); color: var(--text-color-secondary);">
|
||||
Se încarcă...
|
||||
</div>
|
||||
<div v-else-if="comenzi.length === 0" style="text-align: center; padding: var(--space-xl); color: var(--text-color-secondary);">
|
||||
Nicio comandă găsită
|
||||
</div>
|
||||
<div v-else>
|
||||
<div
|
||||
v-for="comanda in comenzi"
|
||||
:key="comanda.id_ordl"
|
||||
class="comanda-card-mobile"
|
||||
:class="{ 'row-highlight-mobile': comanda.id_ordl === highlightId }"
|
||||
>
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start; gap: var(--space-sm);">
|
||||
<div>
|
||||
<div class="comanda-nrord">{{ comanda.nrord || '—' }}</div>
|
||||
<div class="comanda-vehicul">{{ comanda.vehicul || '—' }}</div>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; align-items: flex-end; gap: 4px;">
|
||||
<span :class="['status-badge', statusClass(comanda)]">{{ statusLabel(comanda) }}</span>
|
||||
<span style="font-size: var(--text-xs); color: var(--text-color-secondary);">{{ comanda.datai || '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="comanda.tip_denumire" style="margin-top: var(--space-xs); font-size: var(--text-xs); color: var(--text-color-secondary);">
|
||||
{{ comanda.tip_denumire }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile pagination -->
|
||||
<div v-if="total > perPage" style="display: flex; justify-content: center; gap: var(--space-sm); padding: var(--space-md) 0;">
|
||||
<Button
|
||||
icon="pi pi-chevron-left"
|
||||
text
|
||||
rounded
|
||||
:disabled="page === 1"
|
||||
@click="page--; loadComenzi()"
|
||||
/>
|
||||
<span style="line-height: 2.5rem; font-size: var(--text-sm); color: var(--text-color-secondary);">
|
||||
{{ page }} / {{ Math.ceil(total / perPage) }}
|
||||
</span>
|
||||
<Button
|
||||
icon="pi pi-chevron-right"
|
||||
text
|
||||
rounded
|
||||
:disabled="page >= Math.ceil(total / perPage)"
|
||||
@click="page++; loadComenzi()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MobileBottomNav v-if="isMobile" :items="mobileNavItems" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import DataTable from 'primevue/datatable'
|
||||
import Column from 'primevue/column'
|
||||
import Dropdown from 'primevue/dropdown'
|
||||
import Calendar from 'primevue/calendar'
|
||||
import Button from 'primevue/button'
|
||||
import Toast from 'primevue/toast'
|
||||
import MobileTopBar from '@shared/components/mobile/MobileTopBar.vue'
|
||||
import MobileBottomNav from '@shared/components/mobile/MobileBottomNav.vue'
|
||||
import BottomSheet from '@shared/components/mobile/BottomSheet.vue'
|
||||
import serviceAutoApi from '../services/api.js'
|
||||
|
||||
const toast = useToast()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// ─── Mobile detection ─────────────────────────────────────────────────────────
|
||||
|
||||
const isMobile = ref(window.innerWidth <= 900)
|
||||
const isFilterOpen = ref(false)
|
||||
|
||||
function onResize() { isMobile.value = window.innerWidth <= 900 }
|
||||
onMounted(() => window.addEventListener('resize', onResize))
|
||||
onUnmounted(() => window.removeEventListener('resize', onResize))
|
||||
|
||||
const mobileNavItems = [
|
||||
{ label: 'Comenzi', icon: 'pi pi-list', to: '/service-auto/comenzi', active: true },
|
||||
{ label: 'Comandă nouă', icon: 'pi pi-plus', to: '/service-auto/comanda-noua' },
|
||||
]
|
||||
|
||||
// ─── State ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const comenzi = ref([])
|
||||
const total = ref(0)
|
||||
const highlightId = ref(null) // D4: id_ordl from ?highlight= query param
|
||||
const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const perPage = ref(20)
|
||||
|
||||
const filters = ref({
|
||||
validat: null,
|
||||
data_de_la: null,
|
||||
data_pana_la: null,
|
||||
})
|
||||
|
||||
const hasActiveFilters = computed(() =>
|
||||
filters.value.validat !== null ||
|
||||
filters.value.data_de_la !== null ||
|
||||
filters.value.data_pana_la !== null
|
||||
)
|
||||
|
||||
function onMobileAction(action) {
|
||||
if (action.label === 'Filtre') {
|
||||
isFilterOpen.value = true
|
||||
} else if (action.label === 'Comandă nouă') {
|
||||
router.push('/service-auto/comanda-noua')
|
||||
}
|
||||
}
|
||||
|
||||
const statusOptions = [
|
||||
{ label: 'Toate', value: null },
|
||||
{ label: 'Deschisă', value: 0 },
|
||||
{ label: 'Validată', value: 1 },
|
||||
]
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const fmtDate = (d) => d ? d.toISOString().slice(0, 10) : undefined
|
||||
|
||||
function statusLabel(row) {
|
||||
if (row.inchis_fortat) return 'Arhivată'
|
||||
return row.validat ? 'Validată' : 'Deschisă'
|
||||
}
|
||||
|
||||
function statusClass(row) {
|
||||
if (row.inchis_fortat) return 'status-archived'
|
||||
return row.validat ? 'status-validated' : 'status-open'
|
||||
}
|
||||
|
||||
// ─── Data loading ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function loadComenzi() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: page.value,
|
||||
per_page: perPage.value,
|
||||
}
|
||||
if (filters.value.validat !== null) params.validat = filters.value.validat
|
||||
if (filters.value.data_de_la) params.data_de_la = fmtDate(filters.value.data_de_la)
|
||||
if (filters.value.data_pana_la) params.data_pana_la = fmtDate(filters.value.data_pana_la)
|
||||
|
||||
const { data } = await serviceAutoApi.getComenzi(params)
|
||||
comenzi.value = data.comenzi
|
||||
total.value = data.total
|
||||
} catch (err) {
|
||||
const status = err.response?.status
|
||||
if (status === 503) {
|
||||
toast.add({ severity: 'error', summary: 'Eroare conexiune', detail: 'Serviciul bazei de date nu este disponibil', life: 5000 })
|
||||
} else {
|
||||
toast.add({ severity: 'error', summary: 'Eroare', detail: 'Nu s-au putut încărca comenzile', life: 4000 })
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetAndLoad() {
|
||||
page.value = 1
|
||||
loadComenzi()
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
filters.value = { validat: null, data_de_la: null, data_pana_la: null }
|
||||
page.value = 1
|
||||
loadComenzi()
|
||||
}
|
||||
|
||||
function onPage(event) {
|
||||
page.value = event.page + 1 // PrimeVue is 0-indexed
|
||||
perPage.value = event.rows
|
||||
loadComenzi()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// D4: read highlight param before loading so it's set when rows render
|
||||
const hl = route.query.highlight
|
||||
if (hl) highlightId.value = parseInt(hl)
|
||||
await loadComenzi()
|
||||
if (highlightId.value) {
|
||||
await nextTick()
|
||||
// Clear highlight after 2s flash animation
|
||||
setTimeout(() => { highlightId.value = null }, 2000)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* MobileTopBar + MobileBottomNav height offsets */
|
||||
.mobile-content {
|
||||
padding-top: 56px;
|
||||
padding-bottom: 72px;
|
||||
}
|
||||
|
||||
.mobile-filter-content {
|
||||
padding: var(--space-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.mobile-filter-content .field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
/* Mobile comanda cards */
|
||||
.comanda-card-mobile {
|
||||
padding: var(--space-md);
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
}
|
||||
|
||||
.comanda-card-mobile:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.comanda-nrord {
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.comanda-vehicul {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-color-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* reuses existing @keyframes row-flash / row-flash-dark defined below */
|
||||
.row-highlight-mobile {
|
||||
animation: row-flash 2s ease-out;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .row-highlight-mobile {
|
||||
animation: row-flash-dark 2s ease-out;
|
||||
}
|
||||
|
||||
.filters-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-md);
|
||||
align-items: flex-end;
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.filter-group.filter-actions {
|
||||
justify-content: flex-end;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
/* Status badges */
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 2px var(--space-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.status-open {
|
||||
background: var(--green-50);
|
||||
color: var(--green-600);
|
||||
}
|
||||
|
||||
.status-validated {
|
||||
background: var(--blue-50);
|
||||
color: var(--blue-600);
|
||||
}
|
||||
|
||||
.status-archived {
|
||||
background: var(--surface-hover);
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .status-open {
|
||||
background: var(--green-900);
|
||||
color: var(--green-200);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .status-validated {
|
||||
background: var(--blue-900);
|
||||
color: var(--blue-200);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .status-archived {
|
||||
background: var(--surface-100);
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
/* D4 — highlight row flash animation după creare comandă nouă */
|
||||
:deep(.row-highlight) {
|
||||
animation: row-flash 2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes row-flash {
|
||||
0% { background-color: var(--green-50); }
|
||||
60% { background-color: var(--green-50); }
|
||||
100% { background-color: transparent; }
|
||||
}
|
||||
|
||||
[data-theme="dark"] :deep(.row-highlight) {
|
||||
animation: row-flash-dark 2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes row-flash-dark {
|
||||
0% { background-color: var(--green-900); }
|
||||
60% { background-color: var(--green-900); }
|
||||
100% { background-color: transparent; }
|
||||
}
|
||||
</style>
|
||||
@@ -137,16 +137,16 @@ const routes = [
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/service-auto',
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: 'comanda-noua',
|
||||
name: 'ComandaNoua',
|
||||
component: () => import('@/modules/service-auto/views/ComandaNoua.vue'),
|
||||
meta: { requiresAuth: true, title: 'Comandă Nouă - Service Auto' }
|
||||
}
|
||||
]
|
||||
path: '/service-auto/comenzi',
|
||||
name: 'Comenzi',
|
||||
component: () => import('@/modules/service-auto/views/ComenziBrowseView.vue'),
|
||||
meta: { requiresAuth: true, title: 'Comenzi - Service Auto' }
|
||||
},
|
||||
{
|
||||
path: '/service-auto/comanda-noua',
|
||||
name: 'ComandaNoua',
|
||||
component: () => import('@/modules/service-auto/views/ComandaNoua.vue'),
|
||||
meta: { requiresAuth: true, title: 'Comandă Nouă - Service Auto' }
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
|
||||
171
src/shared/components/AsyncAutoComplete.vue
Normal file
171
src/shared/components/AsyncAutoComplete.vue
Normal file
@@ -0,0 +1,171 @@
|
||||
<template>
|
||||
<AutoComplete
|
||||
:model-value="modelValue"
|
||||
:suggestions="suggestions"
|
||||
:option-label="optionLabel"
|
||||
:data-key="optionKey"
|
||||
:loading="loading"
|
||||
:min-length="minChars"
|
||||
:delay="debounceMs"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:force-selection="true"
|
||||
:aria-label="ariaLabel"
|
||||
class="w-full async-autocomplete"
|
||||
@complete="onComplete"
|
||||
@update:model-value="onUpdate"
|
||||
@clear="onClear"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<template #option="slotProps">
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<span class="async-ac-option" v-html="highlight(getLabel(slotProps.option))"></span>
|
||||
</template>
|
||||
|
||||
<template #empty>
|
||||
<div class="async-ac-empty">
|
||||
<div class="async-ac-empty-text">Niciun rezultat găsit.</div>
|
||||
<Button
|
||||
v-if="emptyActionLabel"
|
||||
:label="emptyActionLabel"
|
||||
icon="pi pi-plus"
|
||||
size="small"
|
||||
text
|
||||
class="async-ac-empty-action"
|
||||
@click="$emit('emptyAction', lastQuery)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</AutoComplete>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import AutoComplete from 'primevue/autocomplete'
|
||||
import Button from 'primevue/button'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: [Object, null], default: null },
|
||||
searchFn: { type: Function, required: true },
|
||||
optionLabel: { type: String, default: 'denumire' },
|
||||
optionKey: { type: String, default: 'id' },
|
||||
placeholder: { type: String, default: 'Caută...' },
|
||||
minChars: { type: Number, default: 2 },
|
||||
debounceMs: { type: Number, default: 300 },
|
||||
disabled: { type: Boolean, default: false },
|
||||
emptyActionLabel: { type: String, default: '' },
|
||||
ariaLabel: { type: String, default: '' },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'search', 'emptyAction'])
|
||||
|
||||
const suggestions = ref([])
|
||||
const loading = ref(false)
|
||||
const lastQuery = ref('')
|
||||
|
||||
function getLabel(item) {
|
||||
if (item == null) return ''
|
||||
return String(item[props.optionLabel] ?? '')
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return s.replace(/[&<>"']/g, (c) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
}[c]))
|
||||
}
|
||||
|
||||
function escapeRegExp(s) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
function highlight(label) {
|
||||
const safe = escapeHtml(label)
|
||||
const q = lastQuery.value.trim()
|
||||
if (!q) return safe
|
||||
const re = new RegExp(`(${escapeRegExp(escapeHtml(q))})`, 'ig')
|
||||
return safe.replace(re, '<strong>$1</strong>')
|
||||
}
|
||||
|
||||
async function onComplete(event) {
|
||||
const q = (event?.query ?? '').trim()
|
||||
lastQuery.value = q
|
||||
emit('search', q)
|
||||
if (q.length < props.minChars) {
|
||||
suggestions.value = []
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await props.searchFn(q)
|
||||
suggestions.value = Array.isArray(result) ? result : []
|
||||
} catch {
|
||||
suggestions.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onUpdate(val) {
|
||||
// PrimeVue emits object (after select) or string (during typing with force-selection false).
|
||||
// With force-selection=true, committed value is always an object/null. Pass-through only
|
||||
// objects or null to the parent v-model.
|
||||
if (val && typeof val === 'object') {
|
||||
emit('update:modelValue', val)
|
||||
} else if (val == null || val === '') {
|
||||
emit('update:modelValue', null)
|
||||
}
|
||||
// During typing (string), do not emit — AutoComplete manages the input text internally.
|
||||
}
|
||||
|
||||
function onClear() {
|
||||
suggestions.value = []
|
||||
lastQuery.value = ''
|
||||
emit('update:modelValue', null)
|
||||
}
|
||||
|
||||
function onKeydown(event) {
|
||||
if (event.key === 'Escape') {
|
||||
onClear()
|
||||
return
|
||||
}
|
||||
if (event.key === 'Enter' && suggestions.value.length > 0 && !props.modelValue) {
|
||||
event.preventDefault()
|
||||
const first = suggestions.value[0]
|
||||
emit('update:modelValue', first)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.async-autocomplete {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.async-ac-option {
|
||||
display: block;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-color);
|
||||
padding: var(--space-xs) 0;
|
||||
}
|
||||
|
||||
.async-ac-option :deep(strong) {
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.async-ac-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
}
|
||||
|
||||
.async-ac-empty-text {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
.async-ac-empty-action {
|
||||
align-self: flex-start;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user