Compare commits

..

10 Commits

Author SHA1 Message Date
Claude Agent
22f66c4633 docs: PROVENANCE — desprindere din roa2web ca repo separat
Fork complet din roa2web @ b0f4800 + modulul service-auto. Domeniu
distinct (gestiune comenzi atelier auto). Stare WIP la desprindere
(f115b5e). Vezi roa2web docs/prd/prd-0.6-audit-service-auto.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 11:40:35 +00:00
Claude Agent
f115b5e35a modificari in curs nu stiu care este faza 2026-06-05 15:00:42 +00:00
Claude Agent
fd64cf3f1e test(service-auto): unit tests multi-tenant + lookup + partener + pc_nr
Acoperire 49 tests offline (fără Oracle real):

test_comanda_helpers (16): _build_pc_nr toate prefixele VFP + fallback,
_build_sir_id_operatii csv + limit 4000 chars, _PREFIX_MAP regression.

test_router_authorization (9): _company_id fallback JWT companies[0],
403 firmă neautorizată, 400 companies[] gol, string→int coercion;
_server_id extragere din request.state.

test_lookup_endpoints (15): cache hit/miss per schema pentru tip_deviz,
masini, asiguratori, inspectori (per-asig), operatii; LIKE escape %/_/\;
min 2 chars short-circuit; server_id propagat la get_connection.

test_partener_create (9): 5 Pydantic validation (denumire min 2,
id_firma ge 1, cui opțional), 4 service mocked (happy path, 409
duplicat CUI, fără CUI, lipsă GRANT → 500 log.critical).

Pattern mock Oracle: fake context managers (async get_connection +
sync cursor), monkeypatch pe lookup_service.get_schema (not _context,
din cauza binding copy la import).

Rulare: pytest backend/modules/service_auto/tests/ -q → 62 passed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-05 09:37:10 +00:00
Claude Agent
4397027f36 feat(service-auto): multi-tenant + tier 3 lookups + D1 partener + AsyncAutoComplete
Refactor izolare multi-tenant:
- Schema Oracle rezolvată din id_firma via CONTAFIN_ORACLE.V_NOM_FIRME (cached 24h)
- server_id propagat din JWT (request.state.server_id) la oracle_pool.get_connection
- Elimină _SCHEMA='MARIUSM_AUTO' și literal 'mariusm_test' din toate query-urile
- Autorizare firmă la router (_company_id): 403 dacă id_firma nu e în JWT companies[]

Tier 3 — lookup endpoints cached 24h:
- GET /asiguratori (DEV_NOM_ASIGURATORI ← NOM_PARTENERI)
- GET /inspectori?id_asigurator=N (DEV_NOM_INSPECTORI per asig)
- GET /operatii (DEV_NOM_NORME)
- GET /parteneri?q=... (typeahead LIKE escape)
- GET /masini/{id}/detalii (VIN, cilindree, putere)
- POST /comenzi: PACK_SERII_NUMERE.aloca_numar + compensating dezaloca;
  pc_nr VFP-format prefix+seq/nrinmat; ORA-06512 stripped din detail

D1 PartnerCreateDialog (nou):
- POST /api/service-auto/parteneri → PartnerCreateRequest; 409 pe CUI
  duplicat (NOM_PARTENERI fără UNIQUE constraint — check manual);
  id_part = MAX+1 cu retry pe ORA-00001 (fără sequence în schema VFP legacy)
- Frontend PartnerCreateDialog.vue — PrimeVue, design tokens, dark-mode safe
- Integrat în ComandaNoua.vue via AutoComplete empty-action hook

Shared AsyncAutoComplete (nou):
- src/shared/components/AsyncAutoComplete.vue — typeahead async debounced
  cu emptyAction slot, force-selection, keyboard (Enter/Esc), design tokens
- ComandaNoua.vue refactorizat să folosească shared component
- SupplierDualField (data-entry) skipped — documentat în
  docs/service-auto/autocomplete-dual-decision.md (pattern diferit)

Mobile chrome (CLAUDE.md):
- ComandaNoua.vue + ComenziBrowseView.vue: MobileTopBar, BottomSheet
  filtre, MobileBottomNav, card list, isMobile resize listener

Migrații grant-uri idempotente:
- ff_2026_04_13_01_AUTO.sql — SELECT/EXECUTE pe tabele Tier 3 + index
  IX_NOM_PARTENERI_DEN_UPPER
- ff_2026_04_13_02_AUTO.sql — INSERT pe NOM_PARTENERI pentru D1

Live smoke pe MARIUSM_AUTO: /ping 1ms, /tip-deviz 7, /masini 261,
POST /parteneri id_part=70241, firma neautorizată → 403.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-05 09:37:10 +00:00
Claude Agent
ee6d857e9d feat(service-auto): phase 3 — PACK_AUTO callproc + câmpuri extinse formular
Migrare completă de la SP_CREEAZA_COMANDA_PROTOTIP la PACK_AUTO.dev_adauga_lucrare (18 params).
Formular ComandaNoua extins cu toate câmpurile din SP: observații, defecțiuni, km, ore motor, termen, nr. dosar.

- schema: solicitari → observatii (opțional); adăugat defectiuni, km_int, ore_functionare, nr_dosar, termen
- service: callproc cablat pe câmpurile noi; pc_nr cu milisecunde (evită colizii sub-secundă)
- error mapper: range 20001→20000 (ORA-20000 era neacoperit → 500 în loc de 422)
- onboarding_roa_web.sql: grant pe PACK_AUTO (înlocuiește SP prototip)
- ComandaNoua.vue: InputNumber km/ore, Calendar termen, Textarea defecțiuni, InputText nr_dosar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 09:37:10 +00:00
Claude Agent
31d1f511c3 fix(service-auto): mapare ORA-01438 → 422 pentru ID invalid
ORA-01438 (value larger than column precision) apare când
id_masiniclient depășește precizia coloanei Oracle. Este eroare
de input, nu server error — mapăm la 422 Unprocessable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 09:37:10 +00:00
Claude Agent
cc24aacfdf fix(service-auto): adaugă secțiunea Service Auto în hamburger menu
Linkurile Comenzi + Comandă Nouă lipseau din menu.js — adăugate în
secțiunea dedicată 'Service Auto' (înainte de Sistem).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 09:37:10 +00:00
Claude Agent
3cbf947d84 fix(service-auto): flatten routes — parent fără component bloca randarea
Vue Router 4 cu rută parent fără `component` nu știe în ce <router-view>
să randeze copiii. Soluție: ComenziBrowse și ComandaNoua devin rute
top-level (același pattern ca /dashboard).

Descoperit în QA P2.5: URL-ul era corect (/service-auto/comenzi) dar
conținutul era blank — PWA service worker servea cache vechi, ascunzând
bug-ul de router.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 09:37:10 +00:00
Claude Agent
0a880baef9 feat(service-auto): phase 2 — comenzi browse, id_sucursala, cache, migrare SQL
Backend:
- GET /api/service-auto/comenzi cu paginare server-side, filtre dată/status
- ComandaRequest.id_sucursala (Optional) + FirmaItem.id_mama
- get_firme() expune id_mama din V_NOM_FIRME
- callproc SP_CREEAZA_COMANDA_PROTOTIP cu 7 argumente (+ p_id_sucursala)
- Cache TTL in-process: tip_deviz 24h, masini 5min

Frontend:
- ComenziBrowseView.vue — DataTable lazy + filtre + status badges
- ComandaNoua.vue — company store integration, idSucursala computed
- service-auto/stores/sharedStores.js (createCompaniesStore factory)
- HamburgerMenu: secțiune Service Auto (Comenzi + Comandă Nouă)
- router: /service-auto/comenzi

SQL:
- migrations/ff_2026_04_12_01_AUTO.sql — idempotent (COLUMNEXIST guard + CREATE OR REPLACE SP)
- onboarding_roa_web.sql — versioned, parametrizat cu :SCHEMA_NAME
- .claude/rules/oracle-migrations.md — convenție ff_YYYY_MM_DD_NN_MODULE.sql

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-05 09:37:09 +00:00
Claude Agent
574aca31e4 fix(service-auto): PrimeVue Dropdown + document CALENDAR grant for ROA_WEB
ComandaNoua.vue folosea `primevue/select` (PrimeVue v4+), dar proiectul rulează
pe v3.48.0 unde componenta se numește `Dropdown`. Cauză a erorii 500 la load-ul
rutei /service-auto/comanda-noua: "Failed to resolve import primevue/select".

grants-audit.md: adăugat `GRANT SELECT ON {SCHEMA}.CALENDAR TO ROA_WEB` în
template-ul de onboarding §4.1 și în tabelul sumar §4.5. CALENDAR e accesat de
`shared/routes/calendar.py` pentru period selector-ul din AppHeader — necesar
pe orice server unde userul poate face login, nu doar service-auto.

DBA action separată: `GRANT SELECT ON MARIUSM_AUTO.CALENDAR TO ROA_WEB`.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-05 09:37:09 +00:00
33 changed files with 4398 additions and 199 deletions

View 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
View 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).

View File

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

View File

@@ -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

View 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()

View File

@@ -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,
)

View File

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

View 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

View File

@@ -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.

View File

@@ -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.

View 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"]

View 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()

View File

@@ -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

View 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.
/

View 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).

View File

@@ -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ă.

View 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`.

View File

@@ -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>`.
---

View 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;

View 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;

View 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;

View 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;

View 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.

View 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).

View File

@@ -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: [

View File

@@ -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,

View 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>

View File

@@ -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),
}

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

View File

@@ -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>

View 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>

View File

@@ -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',

View 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) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[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>