Compare commits

..

11 Commits

Author SHA1 Message Date
Claude Agent
3a10cc69c0 docs(readme): README orientat pe scop — învățare + hedge VFP, nu produs
Pagina repo-ului arăta README-ul generic roa2web și nu explica ce e
diferit / ce se urmărea. README-ul nou pune scopul pe prima pagină:
prototip de probă „web (Python/Vue) conduce business logic Oracle prin
PL/SQL", driver-ul de risc-talent VFP, cele 6 ipoteze, scope wall,
relația cu roa2web, stare WIP.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 11:45:39 +00:00
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
34 changed files with 4464 additions and 595 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).

462
README.md
View File

@@ -1,422 +1,92 @@
# ROA2WEB - Modern ERP Application # roa2web-service-auto
**FastAPI Backend + Vue.js 3 Frontend + Telegram Bot** > **Prototip de învățare + hedge de risc de talent.** NU un produs, NU un MVP, NU un
> angajament de livrare către clienți.
Modern ultrathin monolith ERP application for managing reports, data entry, and financial data with Oracle database integration. Acest repo explorează o singură întrebare strategică:
--- **Poate stack-ul web (Python / FastAPI async / Vue 3 / PrimeVue) să conducă logica de
business Oracle end-to-end prin proceduri PL/SQL — păstrând PL/SQL ca strat durabil?**
## Project Overview Domeniul ales pentru probă: **gestiune comenzi service auto** (atelier reparații auto) —
cel mai sigur teren de testare, pe o schemă Oracle dedicată de test (`MARIUSM_AUTO`), fără
nicio atingere a producției.
ROA2WEB is a comprehensive financial platform built with modern technologies: ## De ce există (driver-ul strategic)
- **Backend**: FastAPI (Python) - Unified async API with modular architecture Visual FoxPro (VFP) e o limbă moartă. ERP-ul care alimentează 4 clienți de lungă durată e
- **Frontend**: Vue.js 3 + PrimeVue - Single-page application with lazy-loaded modules scris în VFP + Oracle, iar **nu se mai pot angaja programatori dispuși să învețe VFP**
- **Telegram Bot**: Alternative command-based interface (integrated module) risc de talent care se acumulează an de an.
- **Database**: Oracle Database + SQLite (hybrid approach)
- **Architecture**: Ultrathin monolith with clear module boundaries
--- Migrarea la Postgres nu e 1:1 (25 de ani de PL/SQL, sinonime cross-schema, arhitectură
tenant-per-schemă). Deci calea realistă de hedge este:
## Quick Start > **Rămâi pe Oracle, mută stratul de UI + business logic pe Python/Vue, păstrează PL/SQL
> ca strat durabil.**
### Prerequisites Service auto e locul cu cel mai mic risc unde se poate proba dacă această cale funcționează.
Hedge-ul rezolvă jumătatea de stack unde **există** forță de muncă (Python+Vue+integrare),
NU problema angajării de specialiști PL/SQL.
- Python 3.11+ ## Ce probează concret (învățarea reală)
- Node.js 16+
- Oracle Database access
- SSH access to Oracle server (for development)
### Development Setup Prototipul confirmă sau infirmă 6 ipoteze. Dacă oricare eșuează cu un răspuns **clar**,
prototipul s-a încheiat cu succes — learning obținut, decizie clară, zero cod irosit.
```bash | # | Ipoteză | Status / referință |
# Clone repository |---|---------|--------------------|
git clone <repository-url> | 1 | `python-oracledb` async apelează curat PL/SQL cu params IN+OUT | `poc/`, `week1-notes.md` |
cd roa2web | 2 | `session_callback` pentru `CURRENT_SCHEMA` nu leak-uiește între requests concurente | `week5-session-callback.md` |
| 3 | Grants „EXECUTE pe SP, zero acces direct la tabele" țin în practică | `grants-audit.md`, `week3-auth-audit.md` |
| 4 | `RAISE_APPLICATION_ERROR` cu diacritice ajunge în Vue ca eroare user-friendly (encoding corect) | mapare ORA→HTTP în `backend/modules/service_auto/` |
| 5 | DX (FastAPI hot-reload + Vite + SSH tunnel Oracle) e acceptabil pt side-work 2-4h/săpt | `week1-notes.md` |
| 6 | Auth-ul multi-server existent suportă un server nou **fără modificări la shared code** | `week3-auth-audit.md` |
# Start all services with one command **Livrabilul real NU e ecranul de comandă** — e un **template reutilizabil pentru module
./start.sh prod # Production Oracle** (`docs/service-auto/template-modul-oracle.md`) + un **decision log**
./start.sh test # Test environment (`docs/service-auto/decision-log.md`). Dacă dovezile sunt pozitive, același pattern se
``` aplică modulului următor.
This starts SSH tunnel (if needed), unified backend (port 8000), and frontend (port 3000). ## Ce e în scope vs NU
**For individual service setup or troubleshooting**: See "Development & Testing" section below. **În scope** (prototip): 1 ecran (`ComandaNoua.vue`) → 1 SP de scriere → insert într-un
tabel dedicat de comenzi pe `MARIUSM_AUTO` → return ID + număr → Toast.
### Access the Application **NU în scope** (zid de scop explicit): cei 4 clienți existenți (rămân pe VFP, zero
atingere), migrare scheme, eFactura/ANAF, insert în ACT (registrul jurnal — doar la
facturare), facturare, mobile/responsive, e2e Playwright, multi-tenant complet. Detalii în
`docs/service-auto/claude-main-design-20260411-rethink.md` (secțiunea „Scope wall").
- **Frontend**: http://localhost:3000 ## Relația cu roa2web
- **Backend API Docs**: http://localhost:8001/docs (Swagger UI)
- **Backend ReDoc**: http://localhost:8001/redoc
- **Health Check**: http://localhost:8001/health
--- Acest repo e un **fork complet** din [`romfast/roa2web`](https://gitea.romfast.ro/romfast/roa2web)
(strămoș comun: commit `b0f4800`). Conține tot codul comun roa2web (auth/JWT, pool Oracle
multi-tenant, shell-ul Vue SPA, design tokens, `AsyncAutoComplete`) PLUS modulul service-auto,
deci **rulează standalone**. Fix-urile se pot face `cherry-pick` în ambele sensuri.
## Architecture Detalii de proveniență: [`PROVENANCE.md`](PROVENANCE.md).
### Ultrathin Monolith Structure ## Cod specific service-auto
``` | Zonă | Cale |
. |------|------|
├── backend/ # Unified FastAPI backend (port 8001) | Backend | `backend/modules/service_auto/` (router / service / schemas / tests) |
│ ├── modules/ # Business logic modules | Frontend | `src/modules/service-auto/` + `src/shared/components/AsyncAutoComplete.vue` |
│ │ ├── reports/ # Reports module (Oracle read-only) | Oracle (pack-uri, DDL, migrări) | `docs/service-auto/` (`pack_auto.pck`, `pack_sesiune.pck`, `migrations/`) |
│ │ ├── data_entry/ # Data entry module (SQLite + workflow) | POC-uri | `poc/` |
│ │ └── telegram/ # Telegram bot module | Design, decizii, learnings | `docs/service-auto/` |
│ ├── config.py # Centralized configuration
│ └── main.py # FastAPI app entry point
├── src/ # Unified Vue.js 3 frontend
│ ├── modules/ # Feature modules
│ │ ├── reports/ # Reports frontend
│ │ └── data-entry/ # Data entry frontend
│ ├── shared/ # Shared frontend components
│ ├── assets/ # Global CSS, images
│ └── router/ # Vue Router
├── shared/ # Shared backend components
│ ├── database/ # Oracle connection pool
│ ├── auth/ # JWT authentication
│ └── frontend/ # Shared frontend assets
├── docs/ # Documentation
├── deployment/ # Deployment scripts
└── ssh-tunnel/ # SSH tunnel for Oracle DB
```
### Key Features ## ⚠️ Stare curentă
- **Shared Database Pool**: Singleton Oracle connection pool shared across all modules **WIP.** Ultimul commit de dezvoltare moștenit era „modificari in curs nu stiu care este
- **Two-Tier Cache System**: Hybrid L1 (Memory) + L2 (SQLite) for optimal performance faza" (2026-06-05) — proiectul **nu era la un checkpoint curat** când a fost desprins din
- **JWT Authentication**: Secure token-based auth with middleware roa2web (2026-06-13). Înainte de orice reluare: citește `docs/service-auto/decision-log.md`
- **Modular Architecture**: Independent modules with clear separation of concerns și notele săptămânale (`week*-notes.md`) ca să reconstruiești faza.
- **Oracle Integration**: Direct Oracle stored procedure calls with caching
- **Responsive Design**: Mobile-friendly Vue.js interface
- **Telegram Integration**: Bot-based interface cu procesare bonuri fiscale prin OCR (PDF/foto → preview → salvare Oracle)
--- Teste auto-raportate la desprindere: 62 passed / 3 skipped (dependente de Oracle live).
## Tech Stack ## Setup
**Backend**: FastAPI, python-oracledb, JWT (PyJWT), Pydantic, pytest, **Two-tier cache (Memory + SQLite)** Bază de cod identică cu roa2web — vezi instrucțiunile de pornire din scripturile
**Frontend**: Vue.js 3 (Composition API), PrimeVue, Pinia, Vite, Axios, Chart.js, Playwright `start.sh` / `start-backend.sh` / `start-frontend.sh` și `docs/`. Necesită acces la
**Telegram Bot**: python-telegram-bot, SQLite + aiosqlite, httpx, FastAPI (internal) serverul Oracle de test (`MARIUSM_AUTO`) prin user-ul tehnic `ROA_WEB`.
**Infrastructure**: Oracle Database, SSH Tunnel, Nginx, Docker (Linux), IIS + NSSM (Windows)
*See `CLAUDE.md` for detailed tech stack information, cache system, and architecture decisions.*
---
## Development & Testing
**Quick Start**: Use `./start.sh prod` to start all services (SSH tunnel + Backend + Frontend).
**For detailed development commands, testing procedures, and troubleshooting**: See `CLAUDE.md` and component-specific READMEs:
- Backend: `backend/ modules and CLAUDE.md`
- Frontend: `src/ and docs/MONOLITH_ARCHITECTURE.md` & `E2E testing guide in docs/`
- Telegram Bot: `backend/modules/telegram/README.md`
**Key Commands**:
```bash
# Start All Services
./start.sh prod # Start PROD (SSH tunnel + Backend + Frontend)
./start.sh test # Start TEST (direct Oracle connection)
./start.sh prod stop # Stop PROD services
./start.sh test stop # Stop TEST services
# Individual Service Control (for quick restarts)
./start-frontend.sh start|stop|restart|status # Frontend only (~7s restart!)
./start-backend.sh start|stop|restart|status # Backend only
# System Monitoring
./status.sh # Show all services status + health checks
# Infrastructure Only
./ssh-tunnel.sh start|stop|status # Oracle DB tunnel (for servers with SSH)
```
**💡 Pro Tips**:
- **Frontend changes?** Use `./start-frontend.sh restart` instead of restarting everything (87% faster!)
- **Check what's running:** `./status.sh` shows everything at a glance
- **Single unified script:** `start.sh` handles both environments with parameters
### 📖 Usage Flow
**Individual scripts (`start-frontend.sh`, `start-backend.sh`) are environment-neutral:**
- They DON'T change `.env` files
- They use whatever `.env` is already present
- Use them for **quick restarts** when working on a specific service
**Master scripts (`start.sh prod`, `start.sh test`) set the environment:**
- `start.sh prod` → uses existing `.env` files (DEV mode)
- `start.sh test` → copies `.env.test``.env` (TEST mode)
**Recommended workflow:**
```bash
# Morning: Start full stack with environment selection
./start.sh prod # DEV mode - sets up .env files
# During development: Quick service restarts
./start-frontend.sh restart # Frontend only (~7s)
./backend-reports.sh restart # Reports backend only (~30s)
# ⚠️ Individual scripts inherit the environment set by start.sh prod
# End of day: Stop everything
./start.sh prod stop
```
**Common scenarios:**
```bash
# Scenario 1: Working on frontend only
./start.sh prod # Start everything once
./start-frontend.sh restart # Restart frontend multiple times (fast!)
# Scenario 2: Debugging a single backend
./start.sh prod stop # Stop all
./ssh-tunnel.sh start # SSH tunnel (if needed)
./start-backend.sh start # Just the backend
./start-frontend.sh start # Just the frontend
# Scenario 3: Testing mode
./start.sh test # Starts everything in TEST mode
# All subsequent individual script calls use TEST .env files
# Scenario 4: Check what's running
./status.sh # See all services + health checks
```
**Note**: For automated testing and validation (`/validate` command), use `start.sh test` which starts all services connected to Oracle TEST server (LXC 10.0.20.121) with test credentials.
**API Documentation** (when backend running):
- Swagger UI: http://localhost:8001/docs
- ReDoc: http://localhost:8001/redoc
- Health Check: http://localhost:8001/health
---
## Production Deployment
ROA2WEB supports two deployment architectures:
### 🐧 Linux/Docker Deployment
```bash
./setup_production.sh # Initial setup
./scripts/deploy.sh # Deploy application
./scripts/health-check.sh # Health monitoring
```
### 🪟 Windows/IIS Deployment
**Modern Unified Workflow** (recommended):
```powershell
# On Development Machine (WSL/Linux)
cd deployment/windows/scripts
.\Publish-And-Deploy.ps1 # Build + Transfer to server (interactive menu)
# On Windows Server (PowerShell as Admin)
cd deployment/windows/scripts
.\ROA2WEB-Console.ps1 # Deploy + Manage services (interactive console)
```
**Alternative - Manual Installation**:
```powershell
# First-time installation
.\Install-ROA2WEB.ps1
.\Install-TelegramBot.ps1
# Automated deployment
.\Check-And-Deploy.ps1
```
**Complete Documentation**:
- **`DEPLOYMENT_GUIDE.md`** - Comprehensive guide for both platforms
- **`deployment/windows/README.md`** - Windows quick start
- **`deployment/windows/docs/WINDOWS_DEPLOYMENT.md`** - Complete Windows guide
---
## API Endpoints
All endpoints prefixed with `/api`:
### Authentication
- `POST /api/auth/login` - Login with Oracle credentials
### Companies
- `GET /api/companies` - Get user's accessible companies
### Dashboard
- `GET /api/dashboard/{company_id}` - Dashboard statistics
### Invoices
- `GET /api/invoices/{company_id}` - List invoices with filters
- `GET /api/invoices/{company_id}/summary` - Invoice summary
### Treasury
- `GET /api/treasury/{company_id}` - Payment data
### Telegram Bot
- `POST /api/telegram/auth/generate-code` - Generate linking code
- `POST /api/telegram/auth/verify-user` - Verify Oracle user
- `POST /api/telegram/auth/refresh-token` - Refresh JWT token
- `POST /api/telegram/export` - Export reports
---
## SSH Tunnel Configuration
ROA2WEB uses SSH tunnels to connect to Oracle servers. Configuration is in `backend/ssh-tunnels.json`.
### Setup (one-time)
**Linux:**
```bash
# Copy SSH key to secrets folder
cp ~/.ssh/your_key backend/secrets/vending.ssh_key
chmod 600 backend/secrets/vending.ssh_key
# Or use password (requires sshpass)
echo "your_password" > backend/secrets/vending.ssh_pass
sudo apt install sshpass
```
**Windows:**
```powershell
# Option 1: SSH Key (recommended)
ssh-keygen -t rsa -b 4096 -f C:\inetpub\wwwroot\roa2web\backend\secrets\vending.ssh_key -N ""
# Then add public key to remote server's ~/.ssh/authorized_keys
# Option 2: Password (requires PuTTY)
choco install putty -y
echo "your_password" > C:\inetpub\wwwroot\roa2web\backend\secrets\vending.ssh_pass
```
### Configuration File
`backend/ssh-tunnels.json`:
```json
[
{
"id": "vending",
"name": "Vending Master",
"local_port": 1522,
"ssh_host": "79.119.86.134",
"ssh_port": 22122,
"ssh_user": "romfast",
"ssh_hostkey": "SHA256:xxxxx",
"oracle_host": "127.0.0.1",
"oracle_port": 1521
}
]
```
**Important:**
- `local_port` must match the port in `ORACLE_SERVERS` (.env) for this server
- `ssh_hostkey` is **required on Windows** (plink batch mode). Get it with:
```powershell
plink.exe -ssh user@host -P port "exit"
# Accept the key, then copy SHA256 fingerprint from output
```
### Commands
| Platform | Start | Stop | Status |
|----------|-------|------|--------|
| Linux | `./ssh-tunnel.sh start` | `./ssh-tunnel.sh stop` | `./ssh-tunnel.sh status` |
| Windows | `.\scripts\ssh-tunnel.ps1 start` | `.\scripts\ssh-tunnel.ps1 stop` | `.\scripts\ssh-tunnel.ps1 status` |
### Auto-Start (Production)
- **Linux**: `start.sh` automatically starts tunnels before backend
- **Windows Service**: `start-backend-service.ps1` wrapper starts tunnels before uvicorn
- **Auto-Reconnect**: Backend monitors tunnels and restarts them if they drop (every 30s check)
---
## Environment Configuration
Copy `.env.example` to `.env` in each microservice and configure:
### Backend (`backend/.env`)
```bash
# Oracle Database (through SSH tunnel)
ORACLE_USER=your_username
ORACLE_PASSWORD=your_password
ORACLE_HOST=localhost
ORACLE_PORT=1526
ORACLE_SID=ROA
# JWT Authentication
JWT_SECRET_KEY=your_secret_key
JWT_ALGORITHM=HS256
JWT_EXPIRE_MINUTES=30
```
### Telegram Bot (`backend/modules/telegram/.env`)
```bash
# Telegram Bot Token
TELEGRAM_BOT_TOKEN=your_bot_token
# Backend API
BACKEND_API_URL=http://localhost:8001
```
---
## Documentation
### Quick Reference
- **`CLAUDE.md`** - Development guide for AI/Claude Code (architecture, cache system, common tasks, troubleshooting)
- **`docs/ARCHITECTURE-DECISIONS.md`** - Architecture Decision Records (ADRs)
- **`docs/MONOLITH_ARCHITECTURE.md`** - Ultrathin monolith architecture details
### Module Documentation
- `docs/data-entry/DATA-ENTRY-MODULE.md` - Data Entry module (SQLModel, workflow, OCR)
- `docs/telegram/README.md` - Telegram bot integration
- `docs/telegram/DEPLOYMENT.md` - Telegram single-worker requirement
### Frontend Styling & CSS
- `docs/ONBOARDING_CSS.md` - CSS system onboarding guide (start here!)
- `docs/CSS_PATTERNS.md` - Comprehensive CSS patterns library
- `docs/DESIGN_TOKENS.md` - Design tokens reference (colors, spacing, typography)
- `docs/MOBILE_PATTERNS.md` - Mobile UI patterns and components
### Deployment
- **`docs/DEPLOYMENT.md`** - Principal deployment guide (start here!)
- `deployment/linux/README.md` - Deploy from Linux/LXC
- `deployment/windows/README.md` - Deploy from Windows
### Testing
- `tests/ocr-validation/README.md` - OCR validation tests
---
## Contributing
1. Create feature branch from `main`
2. Make changes following project structure
3. Write tests for new features
4. Run all tests before committing
5. Create pull request with clear description
---
## License
[Your License Here]
---
## Support
For issues and questions:
- Check documentation in `` subdirectories
- Review CLAUDE.md for development guidelines
- See component-specific READMEs for detailed information
---
**Branch**: v2-roa2web-fastapi
**Working Directory**: `` - All development happens here

View File

@@ -1,16 +1,18 @@
import time import time
from typing import List from datetime import date
from typing import List, Optional
import oracledb 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.dependencies import get_current_user
from shared.auth.models import CurrentUser from shared.auth.models import CurrentUser
from shared.database.oracle_pool import oracle_pool from shared.database.oracle_pool import oracle_pool
from ..schemas.comanda import ( from ..schemas.comanda import (
ComandaRequest, ComandaResponse, AsiguratorItem, ComandaListResponse, ComandaRequest, ComandaResponse,
FirmaItem, TipDevizItem, MasinaClientItem, FirmaItem, InspectorItem, MasinaClientItem, MasinaDetails,
OperatieItem, PartenerItem, PartnerCreateRequest, TipDevizItem,
) )
from ..services.comanda_service import ComandaService from ..services.comanda_service import ComandaService
from ..services.lookup_service import LookupService from ..services.lookup_service import LookupService
@@ -18,48 +20,192 @@ from ..services.lookup_service import LookupService
router = APIRouter() 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") @router.get("/ping")
async def ping(_: CurrentUser = Depends(get_current_user)): async def ping(
"""Health check: verifies Oracle connectivity for mariusm_test server.""" request: Request,
_: CurrentUser = Depends(get_current_user),
):
"""Health check: verifies Oracle connectivity pe serverul curent."""
t0 = time.perf_counter() t0 = time.perf_counter()
server_id = _server_id(request)
try: 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: with conn.cursor() as cursor:
cursor.execute('SELECT 1 FROM DUAL') cursor.execute('SELECT 1 FROM DUAL')
row = cursor.fetchone() row = cursor.fetchone()
except oracledb.DatabaseError as e: except oracledb.DatabaseError as e:
raise HTTPException(status_code=503, detail=f"Oracle error: {e}") raise HTTPException(status_code=503, detail=f"Oracle error: {e}")
elapsed_ms = round((time.perf_counter() - t0) * 1000, 2) 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]) @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[]).""" """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]) @router.get("/tip-deviz", response_model=List[TipDevizItem])
async def get_tip_deviz(_: CurrentUser = Depends(get_current_user)): async def get_tip_deviz(
"""Tipuri de deviz din DEV_TIP_DEVIZ.""" request: Request,
return await LookupService.get_tip_deviz() 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]) @router.get("/masini", response_model=List[MasinaClientItem])
async def get_masini(_: CurrentUser = Depends(get_current_user)): async def get_masini(
"""Mașini active din AUTO_VMASINICLIENTI (toate firmele pe același server).""" request: Request,
return await LookupService.get_masini() 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) @router.post("/comenzi", response_model=ComandaResponse)
async def creeaza_comanda( async def creeaza_comanda(
data: ComandaRequest, data: ComandaRequest,
request: Request,
current_user: CurrentUser = Depends(get_current_user), 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: try:
return await ComandaService.creeaza_comanda( return await ComandaService.creeaza_comanda(
data=data, data=data,
username=current_user.username, username=current_user.username,
user_id=current_user.user_id,
server_id=_server_id(request),
) )
except NotImplementedError as e: except NotImplementedError as e:
raise HTTPException(status_code=501, detail=str(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): class ComandaRequest(BaseModel):
tip_id: int tip_id: int
id_masiniclient: int id_masiniclient: int
solicitari: str
id_firma: int 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): class ComandaResponse(BaseModel):
@@ -14,10 +27,55 @@ class ComandaResponse(BaseModel):
mesaj: str 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): class FirmaItem(BaseModel):
id_firma: int id_firma: int
firma: str firma: str
schema_name: str schema_name: str
id_mama: Optional[int] = None
class TipDevizItem(BaseModel): class TipDevizItem(BaseModel):
@@ -29,3 +87,22 @@ class TipDevizItem(BaseModel):
class MasinaClientItem(BaseModel): class MasinaClientItem(BaseModel):
id_masiniclient: int id_masiniclient: int
label: str # "PARTENER — MARCA MASINA, NRINMAT (ANFABRICATIE)" 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 import re
from typing import NoReturn from datetime import date, datetime
from typing import List, NoReturn, Optional, Tuple
import oracledb import oracledb
from fastapi import HTTPException from fastapi import HTTPException
from shared.database.oracle_pool import oracle_pool 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 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: def _handle_oracle_error(e: Exception) -> NoReturn:
""" """Map Oracle error codes to FastAPI HTTPExceptions. Always raises."""
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)
"""
err = e.args[0] err = e.args[0]
code = getattr(err, "code", 0) code = getattr(err, "code", 0)
raw_message = getattr(err, "message", str(e)) raw_message = getattr(err, "message", str(e))
if 20001 <= code <= 20999: if 20000 <= code <= 20999:
# Strip "ORA-2xxxx: " prefix injected by Oracle; expose the business message only # Strip "ORA-2xxxx: " prefix; strip "\nORA-06512: at ..." stack frames.
clean = re.sub(r"^ORA-\d+:\s*", "", raw_message).strip() clean = re.sub(r"^ORA-\d+:\s*", "", raw_message).strip()
clean = clean.split("\n")[0].strip()
raise HTTPException(status_code=422, detail=clean) 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): if code in (12541, 12170, 12154, 12560):
raise HTTPException( raise HTTPException(
status_code=503, status_code=503,
@@ -57,53 +135,219 @@ class ComandaService:
async def creeaza_comanda( async def creeaza_comanda(
data: ComandaRequest, data: ComandaRequest,
username: str, username: str,
user_id: Optional[int] = None,
server_id: Optional[str] = None,
) -> ComandaResponse: ) -> 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( logger.info(
"service_auto.create_comanda START", "service_auto.create_comanda START",
extra={ extra={
"user": username, "user": username,
"schema": schema,
"server_id": server_id,
"tip": data.tip_id, "tip": data.tip_id,
"client_id": data.id_masiniclient, "client_id": data.id_masiniclient,
"id_firma": data.id_firma, "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: async with oracle_pool.get_connection(server_id) as connection:
try:
with connection.cursor() as cursor: with connection.cursor() as cursor:
out_id_ordl = cursor.var(oracledb.NUMBER) # Step 1: allocate sequence number via pack_serii_numere
out_nrord = cursor.var(oracledb.STRING) try:
seq, id_numar = _aloca_numar_devize(
cursor.callproc( cursor, schema, user_id or 0, id_sucursala
"MARIUSM_AUTO.SP_CREEAZA_COMANDA_PROTOTIP",
[
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
],
) )
connection.commit()
id_ordl = int(out_id_ordl.getvalue())
nrord = out_nrord.getvalue() or ""
except oracledb.DatabaseError as e: except oracledb.DatabaseError as e:
try: try:
connection.rollback() connection.rollback()
except Exception: except Exception:
pass # connection may be dead on network errors; ignore pass
_handle_oracle_error(e) _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(
f"{schema}.PACK_AUTO.dev_adauga_lucrare",
[
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())
logger.info( logger.info(
"service_auto.create_comanda OK", "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( return ComandaResponse(
id_ordl=id_ordl, id_ordl=id_ordl,
nrord=nrord, nrord=pc_nr,
mesaj=f"Comanda {nrord} creata cu succes.", 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 import oracledb
from fastapi import HTTPException from fastapi import HTTPException
from shared.database.oracle_pool import oracle_pool 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 .. 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: class LookupService:
@staticmethod @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. 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: if not company_ids:
return [] return []
placeholders = ", ".join(f":id{i}" for i in range(len(company_ids))) placeholders = ", ".join(f":id{i}" for i in range(len(company_ids)))
query = f""" query = f"""
SELECT id_firma, firma, schema SELECT id_firma, firma, schema, id_mama
FROM CONTAFIN_ORACLE.V_NOM_FIRME FROM CONTAFIN_ORACLE.V_NOM_FIRME
WHERE id_firma IN ({placeholders}) WHERE id_firma IN ({placeholders})
ORDER BY id_firma ORDER BY id_firma
@@ -34,7 +69,7 @@ class LookupService:
params = {f"id{i}": int(cid) for i, cid in enumerate(company_ids)} params = {f"id{i}": int(cid) for i, cid in enumerate(company_ids)}
try: 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: with conn.cursor() as cur:
cur.execute(query, params) cur.execute(query, params)
rows = cur.fetchall() rows = cur.fetchall()
@@ -43,23 +78,31 @@ class LookupService:
raise HTTPException(status_code=503, detail="Eroare la încărcarea firmelor") raise HTTPException(status_code=503, detail="Eroare la încărcarea firmelor")
return [ 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 for r in rows
] ]
@staticmethod @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. Tip deviz din `{schema}.DEV_TIP_DEVIZ`. Cached 24 h per schema.
ROA_WEB has SELECT grant on this view.
""" """
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 SELECT id_tip, denumire, inch_validare
FROM MARIUSM_AUTO.DEV_TIP_DEVIZ FROM {schema}.DEV_TIP_DEVIZ
ORDER BY id_tip ORDER BY id_tip
""" """
try: 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: with conn.cursor() as cur:
cur.execute(query) cur.execute(query)
rows = cur.fetchall() rows = cur.fetchall()
@@ -67,26 +110,35 @@ class LookupService:
logger.error("get_tip_deviz Oracle error", exc_info=True) logger.error("get_tip_deviz Oracle error", exc_info=True)
raise HTTPException(status_code=503, detail="Eroare la încărcarea tipurilor de deviz") 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) TipDevizItem(id_tip=r[0], denumire=r[1], inch_validare=r[2] or 0)
for r in rows for r in rows
] ]
_cache_set(cache_key, result)
return result
@staticmethod @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. Mașini active din `{schema}.AUTO_VMASINICLIENTI`. Cached 5 min per schema.
ROA_WEB has SELECT grant on this view.
Label format: "PARTENER — MARCA MASINA, NRINMAT (ANFABRICATIE)"
""" """
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 SELECT id_masiniclient, nrinmat, marca, masina, anfabricatie, partener
FROM MARIUSM_AUTO.AUTO_VMASINICLIENTI FROM {schema}.AUTO_VMASINICLIENTI
WHERE inactiv = 0 WHERE inactiv = 0
ORDER BY partener, nrinmat ORDER BY partener, nrinmat
""" """
try: 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: with conn.cursor() as cur:
cur.execute(query) cur.execute(query)
rows = cur.fetchall() rows = cur.fetchall()
@@ -107,4 +159,302 @@ class LookupService:
label = f"{partener or '?'}{vehicul}, {nrinmat or '?'}{an_str}" label = f"{partener or '?'}{vehicul}, {nrinmat or '?'}{an_str}"
result.append(MasinaClientItem(id_masiniclient=int(id_mc), label=label)) result.append(MasinaClientItem(id_masiniclient=int(id_mc), label=label))
_cache_set(cache_key, result)
return 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.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(): def test_comanda_persist_and_reconnect():
""" """
Full round-trip: callproc → commit → close → NEW connection → SELECT → assert exists. 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.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): def test_select_direct_fails(roa_web_connection):
""" """
ROA_WEB has no SELECT privilege on NOM_LUCRARI. 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) # Positive test — SP execution must succeed (EXECUTE grant)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@pytest.mark.integration @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): def test_exec_sp_succeeds(roa_web_connection):
""" """
ROA_WEB has EXECUTE on SP_CREEAZA_COMANDA_PROTOTIP. 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) | | `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 | | `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ță | | `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 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.AUTO_VMASINICLIENTI TO ROA_WEB;
GRANT SELECT ON FIRMA_NOUA.DEV_TIP_DEVIZ 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 -- 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ă | | 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 | | 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 | | 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 // 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', title: 'Sistem',
items: [ items: [

View File

@@ -63,6 +63,24 @@
</ul> </ul>
</div> </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 --> <!-- ADMINISTRARE Section -->
<div class="menu-section"> <div class="menu-section">
<h3 class="menu-title">Administrare</h3> <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 } { 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 // ADMINISTRARE: Setări
const administrareItems = ref([ const administrareItems = ref([
{ to: '/settings', icon: 'pi pi-cog', label: 'Setări', exactMatch: false } { to: '/settings', icon: 'pi pi-cog', label: 'Setări', exactMatch: false }
@@ -179,6 +203,7 @@ export default {
principaleItems, principaleItems,
rapoarteItems, rapoarteItems,
analizeItems, analizeItems,
serviceAutoItems,
administrareItems, administrareItems,
isActive, isActive,
closeMenu, 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 => { api.interceptors.request.use(config => {
const token = localStorage.getItem('access_token') const token = localStorage.getItem('access_token')
if (token) config.headers.Authorization = `Bearer ${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 return config
}) })
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 { export default {
getFirme: () => api.get('/firme'), getFirme: () => api.get('/firme'),
getTipDeviz: () => api.get('/tip-deviz'), getTipDeviz: () => api.get('/tip-deviz'),
getMasini: () => api.get('/masini'), 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), 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,23 +1,33 @@
<template> <template>
<div class="page-container"> <div :class="isMobile ? 'mobile-page' : 'page-container'">
<Toast /> <Toast />
<div class="card" style="max-width: 560px; margin: var(--space-xl) auto;"> <!-- Mobile chrome (CLAUDE.md: toate paginile mobile MUST use MobileTopBar + MobileBottomNav) -->
<div class="card-header"> <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"
/>
<!-- 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;"> <h2 style="font-size: var(--text-xl); font-weight: var(--font-semibold); margin: 0;">
Comandă Nouă Comandă Nouă
</h2> </h2>
</div> </div>
<div class="card-body"> <div :class="isMobile ? '' : 'card-body'">
<form class="form-stack" @submit.prevent="submitComanda"> <form class="form-stack" @submit.prevent="submitComanda">
<!-- Firmă --> <!-- 1. Firmă -->
<div class="field"> <div class="field">
<label style="font-weight: var(--font-medium); font-size: var(--text-sm); color: var(--text-color-secondary);"> <label class="field-label">Firmă <span class="req">*</span></label>
Firmă * <Dropdown
</label>
<Select
v-model="form.id_firma" v-model="form.id_firma"
:options="firme" :options="firme"
option-label="firma" option-label="firma"
@@ -25,16 +35,15 @@
placeholder="Selectează firma" placeholder="Selectează firma"
:disabled="isSubmitting || loadingFirme" :disabled="isSubmitting || loadingFirme"
:loading="loadingFirme" :loading="loadingFirme"
fluid class="w-full"
aria-required="true"
/> />
</div> </div>
<!-- Tip comandă --> <!-- 2. Tip comandă -->
<div class="field"> <div class="field">
<label style="font-weight: var(--font-medium); font-size: var(--text-sm); color: var(--text-color-secondary);"> <label class="field-label">Tip comandă <span class="req">*</span></label>
Tip comandă * <Dropdown
</label>
<Select
v-model="form.tip_id" v-model="form.tip_id"
:options="tipuriComanda" :options="tipuriComanda"
option-label="denumire" option-label="denumire"
@@ -42,16 +51,15 @@
placeholder="Selectează tipul comenzii" placeholder="Selectează tipul comenzii"
:disabled="isSubmitting || loadingTipuri" :disabled="isSubmitting || loadingTipuri"
:loading="loadingTipuri" :loading="loadingTipuri"
fluid class="w-full"
aria-required="true"
/> />
</div> </div>
<!-- Client / Mașină --> <!-- 3. Client / Mașină -->
<div class="field"> <div class="field">
<label style="font-weight: var(--font-medium); font-size: var(--text-sm); color: var(--text-color-secondary);"> <label class="field-label">Client / Mașină <span class="req">*</span></label>
Client / Mașină * <Dropdown
</label>
<Select
ref="clientDropdownRef" ref="clientDropdownRef"
v-model="form.id_masiniclient" v-model="form.id_masiniclient"
:options="masini" :options="masini"
@@ -61,26 +69,201 @@
:disabled="isSubmitting || loadingMasini" :disabled="isSubmitting || loadingMasini"
:loading="loadingMasini" :loading="loadingMasini"
filter filter
fluid class="w-full"
aria-required="true"
/> />
</div> </div>
<!-- Operații solicitate --> <!-- 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>
<!-- 5. Asigurator + Inspector (cascadă) -->
<div class="field"> <div class="field">
<label style="font-weight: var(--font-medium); font-size: var(--text-sm); color: var(--text-color-secondary);"> <label class="field-label">Asigurător</label>
Operații solicitate * <Dropdown
</label> 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 <Textarea
v-model="form.solicitari" v-model="form.observatii"
rows="4" rows="3"
placeholder="Descrieți operațiile solicitate de client..." placeholder="Solicitări, observații client..."
:disabled="isSubmitting" :disabled="isSubmitting"
style="width: 100%; resize: vertical;" style="width: 100%; resize: vertical;"
/> />
</div> </div>
<!-- Submit --> <div class="field">
<div style="display: flex; justify-content: flex-end; padding-top: var(--space-sm);"> <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 <Button
type="submit" type="submit"
label="Creează Comanda" label="Creează Comanda"
@@ -94,46 +277,96 @@
</div> </div>
</div> </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> </template>
<script setup> <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 { useToast } from 'primevue/usetoast'
import Select from 'primevue/select' import Dropdown from 'primevue/dropdown'
import Textarea from 'primevue/textarea' import Textarea from 'primevue/textarea'
import Button from 'primevue/button' import Button from 'primevue/button'
import Toast from 'primevue/toast' 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 serviceAutoApi from '../services/api.js'
import { useCompanyStore } from '../stores/sharedStores.js'
const toast = useToast() const toast = useToast()
const router = useRouter()
const companyStore = useCompanyStore()
const clientDropdownRef = ref(null) const clientDropdownRef = ref(null)
const isSubmitting = ref(false) 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 firme = ref([])
const tipuriComanda = ref([]) const tipuriComanda = ref([])
const masini = ref([]) const masini = ref([])
const asiguratori = ref([])
const inspectori = ref([])
const operatii = ref([])
const masinaDetails = ref(null)
const loadingFirme = ref(false) const loadingFirme = ref(false)
const loadingTipuri = ref(false) const loadingTipuri = ref(false)
const loadingMasini = 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() { async function loadLookups() {
loadingFirme.value = true loadingFirme.value = true
loadingTipuri.value = true loadingTipuri.value = true
loadingMasini.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.getFirme(),
serviceAutoApi.getTipDeviz(), serviceAutoApi.getTipDeviz(),
serviceAutoApi.getMasini(), serviceAutoApi.getMasini(),
serviceAutoApi.getAsiguratori(),
serviceAutoApi.getOperatii(),
]) ])
if (firmeRes.status === 'fulfilled') { if (firmeRes.status === 'fulfilled') {
firme.value = firmeRes.value.data firme.value = firmeRes.value.data
// Default: first company
if (firme.value.length > 0 && form.value.id_firma === null) { 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 { } else {
toast.add({ severity: 'warn', summary: 'Firme', detail: 'Nu s-au putut încărca firmele', life: 4000 }) 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 }) 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 loadingFirme.value = false
loadingTipuri.value = false loadingTipuri.value = false
loadingMasini.value = false loadingMasini.value = false
loadingAsiguratori.value = false
loadingOperatii.value = false
} }
onMounted(loadLookups) onMounted(loadLookups)
@@ -164,48 +411,140 @@ const emptyForm = () => ({
id_firma: null, id_firma: null,
tip_id: null, tip_id: null,
id_masiniclient: 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 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(() => const isFormValid = computed(() =>
form.value.id_firma !== null && form.value.id_firma !== null &&
form.value.tip_id !== null && form.value.tip_id !== null &&
form.value.id_masiniclient !== null && form.value.id_masiniclient !== null
form.value.solicitari.trim().length > 0
) )
// ─── 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 ──────────────────────────────────────────────────────────────── // ─── Submit ────────────────────────────────────────────────────────────────
async function submitComanda() { async function submitComanda() {
if (!isFormValid.value) return if (!isFormValid.value || isSubmitting.value) return
isSubmitting.value = true isSubmitting.value = true
try { try {
const termen = form.value.termen
? new Date(form.value.termen).toISOString().split('T')[0]
: null
const { data } = await serviceAutoApi.creeazaComanda({ const { data } = await serviceAutoApi.creeazaComanda({
tip_id: form.value.tip_id, tip_id: form.value.tip_id,
id_masiniclient: form.value.id_masiniclient, id_masiniclient: form.value.id_masiniclient,
solicitari: form.value.solicitari.trim(),
id_firma: form.value.id_firma, 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({ toast.add({
severity: 'success', severity: 'success',
summary: 'Comandă creată', summary: 'Comandă creată',
detail: `Nr ${data.nrord}`, detail: `Nr ${data.nrord}`,
life: 3000, life: 4000,
}) })
// Reset — preserve firma + tip (user creează mai multe consecutive) // Redirect la browse cu highlight pe comanda nouă (D4)
const savedFirma = form.value.id_firma router.push({ path: '/service-auto/comenzi', query: { highlight: data.id_ordl } })
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()
} catch (err) { } catch (err) {
const status = err.response?.status const status = err.response?.status
@@ -214,7 +553,7 @@ async function submitComanda() {
} else if (status === 503) { } else if (status === 503) {
toast.add({ severity: 'error', summary: 'Eroare conexiune', detail: 'Serviciul nu este disponibil. Verificați conexiunea Oracle.', life: 5000 }) toast.add({ severity: 'error', summary: 'Eroare conexiune', detail: 'Serviciul nu este disponibil. Verificați conexiunea Oracle.', life: 5000 })
} else { } 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 { } finally {
isSubmitting.value = false isSubmitting.value = false
@@ -227,4 +566,75 @@ async function submitComanda() {
display: grid; display: grid;
gap: var(--space-md); 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> </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', path: '/service-auto/comenzi',
meta: { requiresAuth: true }, name: 'Comenzi',
children: [ component: () => import('@/modules/service-auto/views/ComenziBrowseView.vue'),
meta: { requiresAuth: true, title: 'Comenzi - Service Auto' }
},
{ {
path: 'comanda-noua', path: '/service-auto/comanda-noua',
name: 'ComandaNoua', name: 'ComandaNoua',
component: () => import('@/modules/service-auto/views/ComandaNoua.vue'), component: () => import('@/modules/service-auto/views/ComandaNoua.vue'),
meta: { requiresAuth: true, title: 'Comandă Nouă - Service Auto' } meta: { requiresAuth: true, title: 'Comandă Nouă - Service Auto' }
}
]
}, },
{ {
path: '/settings', 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>