From b5085bf2fa281a959a0e4c349a8b7d33eb4dea73 Mon Sep 17 00:00:00 2001 From: Marius Mutu Date: Fri, 13 Mar 2026 17:07:04 +0200 Subject: [PATCH] chore: plan implementare Agent Teams + API contract - docs/PLAN.md - plan arhitectural complet (creat anterior) - docs/api-contract.json - contract API intre backend/frontend agenti - docs/superpowers/plans/2026-03-13-roaauto-implementation.md - plan implementare cu Agent Teams - HANDOFF.md - context pentru sesiuni viitoare Co-Authored-By: Claude Sonnet 4.6 --- HANDOFF.md | 50 + docs/PLAN.md | 609 ++++++ .../2026-03-13-roaauto-implementation.md | 1695 +++++++++++++++++ 3 files changed, 2354 insertions(+) create mode 100644 HANDOFF.md create mode 100644 docs/PLAN.md create mode 100644 docs/superpowers/plans/2026-03-13-roaauto-implementation.md diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 0000000..0693388 --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,50 @@ +# Handoff - ROA AUTO SaaS + +## Ce s-a facut in aceasta sesiune + +### Planificare completa + +1. Citit `docs/PLAN.md` - planul arhitectural complet al proiectului +2. Creat plan de implementare cu **Claude Code Agent Teams** (functionalitate experimentala reala) + +### Fisiere create + +- `docs/superpowers/plans/2026-03-13-roaauto-implementation.md` - planul complet de implementare + +### Despre planul creat + +Planul foloseste **Claude Code Agent Teams** (nu subagenti): +- Necesita `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` in `~/.claude/settings.json` +- Necesita Claude Code v2.1.32+ +- 3 teammates specializati: `backend-agent`, `frontend-agent`, `devops-agent` +- Task list comun cu 11 taskuri (TASK-001 pana la TASK-011) +- Comunicare directa intre agenti via mailbox +- `plan_approval_required: true` pentru TASK-002 si TASK-003 + +### Structura taskuri + +``` +TASK-001 (Lead) → API contract + structura directoare +TASK-002 (backend) → FastAPI + libSQL + Auth [paralel cu 003] +TASK-003 (frontend) → Vue 3 + wa-sqlite + Sync [paralel cu 002] +TASK-004 (devops) → Docker dev + Makefile [paralel cu 002+003] +TASK-005 (backend) → Sync endpoints + Models + Seed + Orders +TASK-006 (frontend) → Dashboard + Orders UI + Vehicle Picker +TASK-007 (backend) → PDF WeasyPrint + Portal Client + SMS + Invoices +TASK-008 (frontend) → Portal public + Order Detail + PDF download +TASK-009 (backend) → Invite system + User management +TASK-010 (devops) → Docker production + nginx +TASK-011 (frontend) → PWA + Backup/Restore + Upgrade prompts +``` + +### Cum pornesti implementarea + +1. Activeaza Agent Teams in `~/.claude/settings.json` +2. Deschide Claude Code in `/mnt/e/proiecte/roaauto/` +3. Spune: *"Citeste docs/superpowers/plans/2026-03-13-roaauto-implementation.md si creeaza un agent team cu 3 teammates (backend-agent, frontend-agent, devops-agent) sa implementeze proiectul ROA AUTO SaaS."* + +### Stare repo + +- Git init facut (fara commits inca) +- Remote: `git@gitea.romfast.ro:marius/roaauto.git` +- Nu s-a scris niciun cod de implementare - doar planul diff --git a/docs/PLAN.md b/docs/PLAN.md new file mode 100644 index 0000000..8a6df7e --- /dev/null +++ b/docs/PLAN.md @@ -0,0 +1,609 @@ +# Plan: ROA AUTO SaaS - Proiect Nou Separat + +## Context + +Marius (ROMFAST SRL, Constanta) construieste un **SaaS pentru service-uri auto mici si vulcanizari** din Romania. Serverul initial e pe Proxmox la birou (nu VPS cloud), deci aplicatia trebuie sa functioneze si cand serverul e offline. Cand proiectul se autosustine financiar, se muta pe VPS cloud. + +Exista un prototip in `roa2web/roa-auto-mobile/` (Ionic Vue + SQLite server, single-tenant, fara auth) - folosit doar ca referinta conceptuala. **Proiect nou de la zero, repository git separat**. + +--- + +## Decizii Confirmate + +| Decizie | Valoare | +|---------|---------| +| Nume proiect | **roaauto** | +| Director | `E:\proiecte\roaauto\` | +| Git remote | `gitea.romfast.ro:marius/roaauto` | +| Domeniu | **roaauto.romfast.ro** | +| CSS framework | **Tailwind CSS** | +| Cod existent | **De la zero** (prototip doar referinta) | +| DB server | **libSQL** (fisier, in-process cu FastAPI) | +| DB browser | **wa-sqlite** (SQLite WASM) | +| Sync | **Custom simplu** (timestamp-based) | +| Offline | **Da** - reads + writes locale, sync cand e net | + +--- + +## Arhitectura + +``` +[Browser] [Server Proxmox / VPS] +┌─────────────────────┐ ┌──────────────────────┐ +│ Vue 3 PWA │ │ FastAPI │ +│ wa-sqlite (WASM) │ ◄──── sync ────► │ libSQL (fisier) │ +│ └── OPFS storage │ │ └── roaauto.db │ +│ └── _sync_queue │ │ │ +│ └── ALL reads local│ │ WeasyPrint (PDF) │ +│ └── writes instant │ │ SMSAPI.ro (SMS) │ +│ │ │ JWT auth │ +│ Service Worker │ │ │ +│ └── app shell cache│ │ Cloudflare Tunnel │ +└─────────────────────┘ └──────────────────────┘ +``` + +### Cum functioneaza sync-ul + +**Initial sync (la login):** +``` +GET /api/sync/full?tables=orders,order_lines,vehicles,catalog_* + → Server returneaza toate datele tenant-ului + → Client INSERT-eaza in wa-sqlite local +``` + +**Write (offline OK):** +``` +1. User creeaza comanda +2. INSERT in wa-sqlite local (instant, UUID generat client-side) +3. INSERT in _sync_queue (table, id, operation, data, timestamp) +4. Daca online: POST /api/sync/push → server valideaza → INSERT in libSQL +5. Daca offline: queue asteapta → sync la reconectare +``` + +**Incremental sync (periodic, cand e online):** +``` +GET /api/sync/changes?since=2026-03-13T10:00:00 + → Server returneaza doar randurile modificate dupa timestamp + → Client UPSERT in wa-sqlite local +``` + +**Conflict resolution:** Server wins (last-write-wins). Simplu, predictibil. + +### De ce libSQL pe server (nu PostgreSQL) + +- **Un fisier** (`roaauto.db`) - nu un server/container separat +- **In-process** - FastAPI acceseaza direct, zero overhead retea +- **Replication built-in** - backup = copie fisier sau libSQL replication +- **Schema identica** cu wa-sqlite din browser (ambele SQLite) +- **Resurse minime** - perfect pentru Proxmox la birou +- **Migrare la PostgreSQL** cand cresc cerintele (change connection string + fix SQL specifics) + +**Limitari acceptate:** +- Write concurrency: un writer la un moment dat (WAL mode). OK pentru <50 useri. +- ALTER TABLE limitat: Alembic batch operations rezolva asta. +- No RLS: izolare multi-tenant application-level (WHERE tenant_id = ?). + +--- + +## Tech Stack + +| Layer | Tehnologie | De ce | +|-------|-----------|-------| +| **Backend** | FastAPI + SQLAlchemy 2.0 + Alembic | Known territory | +| **DB server** | libSQL (via `aiosqlite` dialect) | Fisier, in-process, replication | +| **DB browser** | wa-sqlite (`@journeyapps/wa-sqlite`) | SQLite WASM, OPFS persistence | +| **Sync** | Custom (timestamp-based push/pull) | Simplu, fara dependente extra | +| **Auth** | python-jose + passlib (bcrypt) | JWT 30 zile, offline valid | +| **PDF** | WeasyPrint | Server-side, diacritice ROM OK | +| **SMS** | SMSAPI.ro | Provider romanesc, REST API | +| **Frontend** | Vue 3 + Tailwind CSS 4 | Responsive, utility-first | +| **State** | Pinia + reactive SQL queries | Local-first | +| **PWA** | vite-plugin-pwa | App shell cache, install prompt | +| **Build** | Vite 6 | Fast | +| **Deploy** | Docker (single container) + Dokploy + Cloudflare Tunnel | Self-hosted Proxmox | +| **Git** | Gitea (gitea.romfast.ro) | Self-hosted | + +--- + +## Structura Proiect + +``` +E:\proiecte\roaauto\ +├── docker-compose.yml # Backend (FastAPI + libSQL) + frontend build +├── docker-compose.dev.yml +├── Makefile # make dev, make migrate, make seed, make backup +├── .env.example +│ +├── backend/ +│ ├── Dockerfile +│ ├── requirements.txt +│ ├── alembic.ini +│ ├── alembic/versions/ +│ ├── app/ +│ │ ├── main.py # FastAPI + lifespan +│ │ ├── config.py # Settings din env +│ │ ├── deps.py # get_db, get_current_user, get_tenant_id +│ │ │ +│ │ ├── auth/ +│ │ │ ├── router.py # register, login, refresh +│ │ │ ├── service.py # JWT 30 zile, bcrypt, trial logic +│ │ │ └── schemas.py +│ │ │ +│ │ ├── sync/ +│ │ │ ├── router.py # GET /sync/full, GET /sync/changes, POST /sync/push +│ │ │ └── service.py # Sync logic, conflict resolution +│ │ │ +│ │ ├── tenants/ # settings, subscription +│ │ ├── users/ # invite, list, deactivate +│ │ ├── vehicles/ # CRUD +│ │ ├── orders/ # CRUD + workflow +│ │ │ ├── router.py +│ │ │ ├── service.py # DRAFT->VALIDAT->FACTURAT, recalc totals +│ │ │ └── schemas.py +│ │ ├── invoices/ # create from order +│ │ ├── catalog/ # nomenclatures +│ │ ├── appointments/ # CRUD +│ │ ├── client_portal/ # GET /p/{token} (public, no auth) +│ │ ├── sms/ # SMSAPI.ro +│ │ ├── pdf/ +│ │ │ ├── service.py # WeasyPrint +│ │ │ └── templates/ # deviz.html, factura.html +│ │ │ +│ │ └── db/ +│ │ ├── base.py # SQLAlchemy Base, UUID mixin, TenantMixin +│ │ ├── session.py # libSQL/aiosqlite engine + session +│ │ └── models/ # tenant, user, vehicle, order, etc. +│ │ +│ ├── data/ # libSQL database file(s) +│ │ └── .gitkeep +│ └── tests/ +│ +├── frontend/ +│ ├── Dockerfile + nginx.conf +│ ├── package.json +│ ├── vite.config.js +│ ├── tailwind.config.js +│ ├── index.html +│ │ +│ └── src/ +│ ├── main.js # Vue + Pinia + wa-sqlite init +│ ├── App.vue +│ │ +│ ├── db/ +│ │ ├── schema.js # Tabele SQLite locale (mirror server) +│ │ ├── database.js # wa-sqlite init + OPFS +│ │ ├── sync.js # Sync engine (full sync, incremental, push queue) +│ │ └── queries.js # Reactive query helper +│ │ +│ ├── router/index.js +│ │ +│ ├── stores/ +│ │ ├── auth.js # JWT, login/register, offline token validation +│ │ ├── orders.js # SQL queries pe wa-sqlite local +│ │ ├── vehicles.js +│ │ ├── catalog.js +│ │ └── sync.js # Sync status, last sync time +│ │ +│ ├── composables/ +│ │ ├── useAuth.js +│ │ ├── useBreakpoint.js +│ │ ├── useSqlQuery.js # Reactive SQL query (re-run on table change) +│ │ └── useSync.js # Online/offline status, trigger sync +│ │ +│ ├── layouts/ +│ │ ├── AppLayout.vue # Sidebar desktop / bottom nav mobile +│ │ ├── AuthLayout.vue +│ │ └── ClientLayout.vue # Portal client (minimal) +│ │ +│ ├── views/ +│ │ ├── auth/ # Login, Register, InviteAccept +│ │ ├── dashboard/ # Today's orders, quick stats +│ │ ├── orders/ # List, Create, Detail, Invoice +│ │ ├── vehicles/ # List, Detail +│ │ ├── appointments/ # Calendar/list +│ │ ├── catalog/ # Tabbed: marci, norme, preturi +│ │ ├── settings/ # Profile, Users, Subscription +│ │ └── client/ # DevizView (public), AppointmentBook +│ │ +│ ├── components/ +│ │ ├── common/ # Button, Input, Modal, Table, StatusBadge, SyncIndicator +│ │ ├── orders/ # OrderCard, OperationForm, MaterialForm, OrderTotals +│ │ └── vehicles/ # VehiclePicker +│ │ +│ └── assets/css/ +│ └── main.css # Tailwind imports +│ +└── docs/ + ├── ARCHITECTURE.md + └── CLAUDE.md # Reguli pentru Claude Code in proiectul nou +``` + +--- + +## Schema Database (identica server + browser) + +Toate tabelele: `id TEXT PRIMARY KEY` (UUID v7 generat client-side), `tenant_id TEXT NOT NULL`, `oracle_id INTEGER` (NULL, mapare la Oracle ROAAUTO), `updated_at TEXT` (ISO timestamp pentru sync). + +```sql +-- Tenants & Users +tenants (id, nume, cui, reg_com, adresa, telefon, email, iban, banca, + plan, trial_expires_at, created_at, updated_at) + +users (id, tenant_id, email, password_hash, nume, rol, activ, + created_at, updated_at) + +invites (id, tenant_id, email, rol, token, expires_at, accepted_at, + created_by, created_at) + +-- Nomenclatures +catalog_marci (id, tenant_id, denumire, activ) +catalog_modele (id, marca_id, denumire) +catalog_ansamble (id, tenant_id, denumire) +catalog_norme (id, tenant_id, cod, denumire, ore_normate, ansamblu_id) +catalog_preturi (id, tenant_id, denumire, pret, um) +catalog_tipuri_deviz (id, tenant_id, denumire) +catalog_tipuri_motoare (id, tenant_id, denumire) +mecanici (id, tenant_id, user_id, nume, prenume, activ) + +-- Core Business +vehicles (id, tenant_id, client_nume, client_telefon, client_email, + client_cod_fiscal, client_adresa, nr_inmatriculare, + marca_id, model_id, an_fabricatie, serie_sasiu, + tip_motor_id, created_at, updated_at) + +orders (id, tenant_id, nr_comanda, data_comanda, vehicle_id, + tip_deviz_id, status, km_intrare, observatii, + -- client snapshot (denormalized) + client_nume, client_telefon, nr_auto, marca_denumire, model_denumire, + -- totals + total_manopera, total_materiale, total_general, + -- client portal + token_client, + created_by, created_at, updated_at) + +order_lines (id, order_id, tenant_id, tip, descriere, + norma_id, ore, pret_ora, -- manopera + um, cantitate, pret_unitar, -- material + total, mecanic_id, ordine, created_at, updated_at) + +invoices (id, tenant_id, order_id, nr_factura, serie_factura, + data_factura, modalitate_plata, + client_nume, client_cod_fiscal, nr_auto, + total_fara_tva, tva, total_general, created_at, updated_at) + +appointments (id, tenant_id, vehicle_id, client_nume, client_telefon, + data_ora, durata_minute, observatii, status, order_id, + created_at, updated_at) + +-- Sync (doar in browser, nu pe server) +_sync_queue (id, table_name, row_id, operation, data_json, created_at, synced_at) +_sync_state (table_name, last_sync_at) +``` + +--- + +## Faze de Implementare + +### Faza 0: Setup Proiect (aceasta sesiune) +**Livrabil: Director creat, plan salvat in proiect** + +1. Creeaza directorul `E:\proiecte\roaauto\` +2. Salveaza acest plan ca `E:\proiecte\roaauto\docs\PLAN.md` pentru referinte viitoare +3. STOP - nu continua cu implementarea + +### Faza 1: Fundatie + Auth + Sync (Saptamana 1-2) +**Livrabil: Register/Login, wa-sqlite local, sync functional** + +**Backend:** +1. `git init` la `E:\proiecte\roaauto\`, remote pe Gitea +2. FastAPI skeleton: `main.py`, `config.py`, `deps.py` +3. libSQL setup: `session.py` cu `aiosqlite` +4. Alembic init + prima migrare: `tenants`, `users`, `invites` +5. Auth: `POST /auth/register` (creeaza tenant + owner), `POST /auth/login` (JWT 30 zile) +6. Sync endpoints: `GET /sync/full`, `GET /sync/changes?since=`, `POST /sync/push` +7. JWT middleware: `get_current_user`, `get_tenant_id` + +**Frontend:** +8. Vue 3 + Vite + Tailwind + Pinia scaffold +9. wa-sqlite setup: `database.js` (init WASM + OPFS), `schema.js` (create tables) +10. Sync engine: `sync.js` (full sync, incremental sync, push queue) +11. Router cu auth guards +12. Pages: Login, Register +13. App layout responsive (sidebar desktop, bottom nav mobile) +14. Sync status indicator (online/offline/syncing) + +**Verificare:** Register → login → full sync → datele apar in wa-sqlite local → offline: app functioneaza → reconectare: sync push + +### Faza 2: Business Logic Core (Saptamana 3-4) +**Livrabil: Creare comanda cu operatii/materiale, workflow complet** + +1. Models + migrations: `vehicles`, `orders`, `order_lines`, `catalog_*`, `mecanici` +2. Seed data (Alembic): 24 marci, 11 ansamble, 6 tipuri deviz, 5 tipuri motoare, 3 preturi +3. OrderService: CRUD + workflow DRAFT→VALIDAT→FACTURAT + recalc totals +4. wa-sqlite schema update (mirror noile tabele) +5. Frontend: Dashboard (comenzi azi, stats) +6. Frontend: Orders list (query local SQLite, filtrare, search) +7. Frontend: Order create (vehicle picker, operatii, materiale, totals live) +8. Frontend: Order detail (view/edit, status actions) +9. Frontend: Catalog management (tabbed) +10. Frontend: Vehicle picker (search-as-you-type pe SQLite local) + +**Verificare:** Creare comanda offline → operatii + materiale → totals corecte → reconectare → sync → comanda pe server + +### Faza 3: Facturare + PDF (Saptamana 5) +**Livrabil: Deviz/factura PDF** + +1. Model `invoices`, InvoiceService (VALIDAT→FACTURAT, TVA 19%) +2. WeasyPrint templates HTML/CSS (deviz.html, factura.html cu diacritice) +3. Endpoint `GET /api/orders/{id}/pdf/deviz` +4. Frontend: Invoice page, PDF download/share + +**Verificare:** DRAFT → operatii → VALIDAT → factura → PDF cu diacritice corecte + +### Faza 4: Portal Client + SMS (Saptamana 6) +**Livrabil: Client primeste SMS, vede deviz, accepta/refuza** + +1. `GET /api/p/{token}` (public, no auth) → deviz data +2. `POST /api/p/{token}/accept`, `/reject` +3. SMSAPI.ro integration +4. Frontend: DevizViewPage (public, mobile-first) +5. Frontend: "Trimite deviz" button (SMS + WhatsApp link) +6. Model `appointments` + CRUD + client booking + +**Verificare:** Comanda → trimite SMS → client link → accepta → status updated + +### Faza 5: Management Angajati + Settings (Saptamana 7) +**Livrabil: Owner invita angajati, role-based access** + +1. Invite system (email link 48h) +2. User management (list, deactivate, roles) +3. Settings: profil service +4. Role-based UI (mecanic vede doar comenzile lui) + +### Faza 6: Deployment (Saptamana 8) +**Livrabil: roaauto.romfast.ro live** + +1. Dockerfile (FastAPI + libSQL, single container) +2. Frontend Dockerfile (nginx) +3. docker-compose.yml +4. Dokploy pe Proxmox + Cloudflare Tunnel +5. Trial expiry middleware +6. Backup strategy (libSQL replication / cron cp) + +### Faza 7: Polish + PWA (Saptamana 9-10) +**Livrabil: Production-ready, instalabil** + +1. PWA: service worker, install prompt, icons +2. Sync indicator UI (online/offline/syncing/error) +3. Error handling, toasts, loading states +4. Responsive testing (phone, tablet, desktop) +5. Reports: sumar lunar, export CSV + +--- + +## Referinta din Prototip (doar consultare) + +| Ce | Fisier | Ce portam conceptual | +|----|--------|---------------------| +| Workflow | `roa-auto-mobile/backend/services/order_service.py` | State machine, `_recalc_totals` | +| Seed data | `roa-auto-mobile/backend/seed.py` | 24 marci, ansamble, tipuri | +| PDF layout | `roa-auto-mobile/src/services/pdf.js` | Format coloane, header, totals | +| UI flow | `roa-auto-mobile/src/views/OrderCreatePage.vue` | Flow creare comanda | +| Vehicle search | `roa-auto-mobile/src/components/VehiclePicker.vue` | Search pattern | + +--- + +## Compatibilitate ROAAUTO Oracle + +SaaS-ul trebuie sa fie compatibil cu ROAAUTO VFP9+Oracle. Clientii care cresc pot migra la sistemul complet ROAAUTO cu Oracle. Coloanele SaaS mapeaza 1:1 la tabelele Oracle `MARIUSM_AUTO.dev_*`. + +### Mapare tabele SaaS → Oracle ROAAUTO + +| SaaS (libSQL) | Oracle (MARIUSM_AUTO) | Note | +|---------------|----------------------|------| +| `orders` | `dev_ordl` | +tenant_id, +token_client, UUID vs Integer PK | +| `order_lines` (tip=manopera) | `dev_oper` | SaaS unifica oper+materiale in order_lines | +| `order_lines` (tip=material) | `dev_estimari_produse` | Acelasi tabel, filtrat pe `tip` | +| `vehicles` | `dev_masiniclienti` | Renamed, aceleasi coloane client+vehicul | +| `catalog_marci` | `dev_nom_marci` | +tenant_id | +| `catalog_modele` | `dev_nom_masini` | Identic | +| `catalog_ansamble` | `dev_nom_ansamble` | +tenant_id | +| `catalog_norme` | `dev_nom_norme` | +tenant_id | +| `catalog_preturi` | `dev_nom_preturi` | +tenant_id | +| `catalog_tipuri_deviz` | `dev_tip_deviz` | +tenant_id | +| `catalog_tipuri_motoare` | `dev_tipuri_motoare` | +tenant_id | +| `mecanici` | `dev_mecanici` | +tenant_id, +user_id | +| `invoices` | `facturi` (local) | Identic structural | +| `tenants` | - | Doar SaaS (nu exista in Oracle) | +| `users` | - | Doar SaaS | +| `appointments` | - | Doar SaaS (feature nou) | + +### Coloane compatibile (pastreaza aceleasi nume) + +Coloanele business raman identice cu Oracle pentru migrare usoara: +- `nr_comanda`, `data_comanda`, `status`, `km_intrare`, `observatii` +- `client_nume`, `client_telefon`, `client_cod_fiscal`, `nr_auto` +- `marca_denumire`, `model_denumire` +- `total_manopera`, `total_materiale`, `total_general` +- `denumire`, `ore`, `pret_ora`, `cantitate`, `pret_unitar`, `total` +- `nr_inmatriculare`, `serie_sasiu`, `an_fabricatie` +- `cod`, `ore_normate`, `pret`, `um` +- `nr_factura`, `serie_factura`, `data_factura`, `modalitate_plata` +- `tva`, `total_fara_tva` + +### Coloane adaugate fata de Oracle (doar in SaaS) +- `id TEXT` (UUID v7) - in loc de `id INTEGER` (Oracle sequence) +- `tenant_id TEXT` - izolare multi-tenant +- `oracle_id INTEGER` - mapare la ID-ul Oracle, NULL pana la migrare +- `token_client TEXT` - portal client (feature SaaS) +- `updated_at TEXT` - timestamp sync +- `created_at TEXT` - audit + +### Script migrare SaaS → Oracle (Phase viitoare) +```python +# Migrare: export din libSQL SaaS → import in Oracle ROAAUTO +# 1. Map UUID → Integer (Oracle sequences) +# 2. Split order_lines → dev_oper (manopera) + dev_estimari_produse (material) +# 3. Rename tables: vehicles → dev_masiniclienti, orders → dev_ordl +# 4. Drop columns: tenant_id, token_client, oracle_id +# 5. Import nomenclatures shared (global marci etc) +``` + +--- + +## Sistem Tier-uri si Business Model + +### Free Forever (0 RON/luna) +- **100% local** - wa-sqlite in browser, ZERO cost server per user +- Toate features-urile de baza: comenzi, operatii, materiale, vehicule, nomenclator +- **PDF generation client-side** (jsPDF) - deviz + factura +- **Web Share API** - share PDF via WhatsApp/email +- **Backup/restore manual** - export DB ca fisier JSON/SQLite pe telefon, restore din fisier +- **Un singur device** (datele in browser-ul respectiv) +- **Nu necesita cont pe server** - app-ul se incarca ca PWA, functioneaza standalone +- **Limite soft**: ~50 comenzi/luna (nu blocheaza, doar sugereaza upgrade gratios) +- NU: sync cloud, client portal, SMS, multi-device, backup automat + +### Basic (49 RON/luna - de stabilit) +- Tot ce e in Free + +- **Cloud sync** (libSQL server) - datele salvate in cloud, backup automat +- **Multi-device** - lucreaza de pe telefon SI calculator +- **Client portal** - link unic pentru client sa vada devizul +- **WhatsApp link** - generare link deviz pentru WhatsApp (gratuit) +- 1 user + +### Pro (99 RON/luna - de stabilit) +- Tot ce e in Basic + +- **SMS deviz** - trimite SMS cu link via SMSAPI.ro +- **Multi-user** - pana la 3 useri (owner + 2 angajati) +- **Programari** - modul appointments +- **Rapoarte** - sumar lunar, export CSV +- **Priority support** + +### Cum functioneaza tehnic + +**Free tier (zero server cost):** +``` +1. User acceseaza roaauto.romfast.ro +2. PWA se incarca si se instaleaza (service worker cache) +3. wa-sqlite se initializeaza cu schema goala +4. User lucreaza 100% local - fara cont, fara server +5. Datele raman DOAR in browser (OPFS) +6. Daca sterge browser data → pierde tot (avertizare clara) +``` + +**Upgrade Free → Basic:** +``` +1. User da click "Creeaza cont" (POST /auth/register) +2. Se creeaza tenant + user pe server +3. Full sync: datele locale se uploadeaza pe server (POST /sync/push) +4. De acum: sync bidirectional activ +5. Datele safe pe server + backup +``` + +**Downgrade Basic → Free:** +``` +1. Subscription expirat sau anulat +2. Sync se opreste (server refuza sync requests) +3. Datele locale raman in browser (functioneaza ca inainte) +4. Datele pe server se pastreaza 90 zile (re-upgrade posibil) +5. Dupa 90 zile: datele server se sterg +``` + +**Trial flow:** +``` +1. Register → 30 zile trial Basic gratuit +2. Sync activ, toate features-urile Basic +3. La expirare: downgrade automat la Free +4. Datele locale raman, sync se opreste +5. "Upgrade" button prominent +``` + +### Backup manual (Free tier) + +``` +Export: wa-sqlite → JSON blob → download ca fisier "roaauto-backup-2026-03-13.json" + - Buton "Salveaza backup" in Settings + - Foloseste File System Access API sau download fallback + - Include: toate tabelele, settings, metadata + +Restore: fisier JSON → parse → INSERT in wa-sqlite + - Buton "Restaureaza din backup" in Settings + - Confirmare: "Asta va inlocui datele curente. Continui?" + - Validare: verifica schema version, integritate date +``` + +**UX important**: La fiecare 7 zile fara backup, reminder subtil (banner, nu modal): +"Ultima salvare: acum 12 zile. Salveaza un backup pe telefon." + +### Limite soft + sugestii gratioase (stil Revolut) + +**Filosofie**: Nu blocam NICIODATA. Doar sugeram, pozitiv, la momentul potrivit. + +**Limite soft Free:** +- ~50 comenzi/luna (numar orientativ, nu hard limit) +- Fara limita pe vehicule/nomenclator + +**Cum arata sugestiile (non-intrusive, pozitive):** + +| Moment | Tip | Mesaj (exemplu) | +|--------|-----|-----------------| +| Comanda #20 | Banner mic, dismissable | "Ai creat 20 de comenzi luna asta. Stiai ca poti sincroniza datele pe mai multe dispozitive?" | +| Comanda #40 | Card in dashboard | "Afacerea ta creste! Cu planul Basic ai backup automat si poti trimite devize direct clientilor." | +| Comanda #50 | Bottom sheet (o singura data) | "Ai atins 50 de comenzi. Felicitari! Activeaza planul Basic pentru sync, backup automat si portal client. Continui gratuit, fara restrictii." | +| Share PDF manual | Tooltip subtil | "Poti trimite devizul direct pe WhatsApp clientului cu un click. Vezi planul Basic." | +| Dupa 3 luni activ | Card in dashboard | "Folosesti ROA AUTO de 3 luni. Multumim! Protejeaza-ti datele cu backup automat in cloud." | + +**Reguli:** +- Fiecare sugestie apare MAX o data (dismiss = nu mai apare niciodata) +- Niciodata popup/modal blocant +- Ton pozitiv: "Felicitari", "Afacerea ta creste", "Stiai ca..." +- Buton "Nu, multumim" vizibil si respectat +- Nu afiseaza mai mult de 1 sugestie pe zi +- Dupa dismiss: minimum 7 zile pana la urmatoarea sugestie + +### Logica in cod + +```javascript +// Frontend: check tier before feature +const tier = authStore.tier // 'free', 'basic', 'pro' (din JWT sau local) + +// Sync: doar paid +if (tier !== 'free') { + await syncEngine.start() // start bidirectional sync +} + +// Client portal: doar basic+ +if (tier === 'free') { + showUpgradeModal('client_portal') +} else { + generateClientLink(order.token_client) +} + +// SMS: doar pro +if (tier !== 'pro') { + showUpgradeModal('sms') +} +``` + +```python +# Backend: middleware tier check +@app.middleware("http") +async def check_subscription(request, call_next): + if request.url.path.startswith("/api/sync"): + tenant = await get_tenant(request) + if tenant.plan == "free" or tenant.is_expired(): + return JSONResponse(status_code=402, content={"detail": "Upgrade required"}) + return await call_next(request) +``` + +--- + +## Migrare viitoare la PostgreSQL + +Cand proiectul creste si se muta pe VPS cloud: +1. Schimba `aiosqlite` → `asyncpg` in `session.py` +2. Adapteaza Alembic migrations (remove batch operations) +3. Adauga PostgreSQL RLS pentru izolare tenant +4. Optional: adauga PowerSync pentru sync automat (inlocuieste custom sync) +5. Schema e compatibila - aceleasi tabele, aceleasi coloane diff --git a/docs/superpowers/plans/2026-03-13-roaauto-implementation.md b/docs/superpowers/plans/2026-03-13-roaauto-implementation.md new file mode 100644 index 0000000..ad94de8 --- /dev/null +++ b/docs/superpowers/plans/2026-03-13-roaauto-implementation.md @@ -0,0 +1,1695 @@ +# ROA AUTO SaaS - Implementation Plan (Agent Teams) + +> **Mecanism:** Foloseste Claude Code Agent Teams (experimental) - nu subagenti, ci sesiuni +> Claude Code separate cu task list comun si comunicare directa intre agenti. + +**Goal:** Construieste un SaaS multi-tenant pentru service-uri auto din Romania cu offline-first (wa-sqlite), sync cloud si PDF/SMS. + +**Architecture:** Vue 3 PWA + wa-sqlite (OPFS) in browser, FastAPI + libSQL pe server, sync custom timestamp-based. Un singur container Docker, deploy pe Proxmox via Dokploy + Cloudflare Tunnel. + +**Tech Stack:** Python/FastAPI, SQLAlchemy 2.0/Alembic, libSQL/aiosqlite, Vue 3, Vite, Tailwind CSS 4, Pinia, wa-sqlite (WASM), WeasyPrint, SMSAPI.ro, Docker + +--- + +## Pasul 0: Activare Agent Teams + +Adauga in `~/.claude/settings.json`: + +```json +{ + "env": { + "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1" + }, + "teammateMode": "tmux" +} +``` + +Verifica versiunea: +```bash +claude --version +# Necesar: v2.1.32 sau mai recent +``` + +--- + +## Structura Echipei + +``` +TEAM LEAD (sesiunea ta principala de Claude Code) +├── Citeste planul, creeaza task list, spawneaza teammati +├── Gestioneaza dependintele si aproba planurile +├── Monitorizeaza progresul via Shift+Down +└── Sintetizeaza rezultatele la finalul fiecarei faze + +TEAMMATES: +├── backend-agent → FastAPI, libSQL, Alembic, auth, sync, business logic, PDF, SMS +├── frontend-agent → Vue 3, Vite, Tailwind, wa-sqlite, Pinia, toate views-urile +└── devops-agent → Docker, docker-compose, nginx, Makefile, CI/CD +``` + +**Reguli de izolare fisiere** (evita conflicte): +- `backend-agent` atinge DOAR `backend/` si `docs/api-contract.json` +- `frontend-agent` atinge DOAR `frontend/` +- `devops-agent` atinge DOAR `docker-compose*.yml`, `Makefile`, `.env.example`, `backend/Dockerfile`, `frontend/Dockerfile`, `frontend/nginx.conf` +- Team Lead atinge DOAR fisierele de coordonare (task list, documentatie) + +--- + +## Prompt de Start (Team Lead) + +Ruleaza aceasta comanda in Claude Code pentru a porni echipa: + +``` +Citeste docs/superpowers/plans/2026-03-13-roaauto-implementation.md + +Creeaza un agent team pentru implementarea proiectului ROA AUTO SaaS. +Spawneaza 3 teammates: + +1. backend-agent: specialist FastAPI/Python, responsabil pentru tot ce e in backend/ +2. frontend-agent: specialist Vue 3/JavaScript, responsabil pentru tot ce e in frontend/ +3. devops-agent: specialist Docker/nginx, responsabil pentru docker-compose, Makefile, Dockerfiles + +Reguli pentru echipa: +- Nu atingeti fisierele celuilalt agent +- Comunicati direct cand aveti nevoie de informatii de la alt agent +- Fiecare task din task list are un "owned_by" - respectati-l +- Faceti commit dupa fiecare task completat +- Inainte de a incepe implementarea unui task, verificati daca dependintele sunt COMPLETED + +Creeaza task list-ul conform planului si incepe cu Faza 1. +Require plan approval pentru Task 1 (backend setup) si Task 2 (frontend setup). +``` + +--- + +## Hooks de Calitate (optional, recomandat) + +Creeaza `.claude/hooks/teammate-idle.sh`: +```bash +#!/bin/bash +# TeammateIdle hook - verifica ca testele trec inainte de idle +# Daca backend-agent se opreste, verifica teste +if echo "$CLAUDE_TEAMMATE_NAME" | grep -q "backend"; then + cd backend && python -m pytest tests/ -q 2>&1 + if [ $? -ne 0 ]; then + echo "Testele nu trec! Rezolva inainte de a te opri." >&2 + exit 2 + fi +fi +``` + +--- + +## Task List pentru Agent Teams + +> Team Lead-ul creeaza aceste tasks in task list-ul comun la pornire. +> Teammates-ii le claim in ordine, respectand dependintele. + +--- + +## Faza 1: Fundatie [Saptamana 1] + +### TASK-001: API Contract + Structura Proiect +**owned_by:** team-lead +**depends_on:** - +**priority:** critical + +Team Lead-ul executa asta inainte de a spawna teammates, ca toti sa aiba contractul. + +```bash +# Creeaza structura +mkdir -p backend/app/{auth,sync,orders,vehicles,catalog,invoices,appointments,tenants,users,client_portal,sms,pdf/templates,db/models} +mkdir -p backend/{alembic/versions,data,tests} +mkdir -p frontend/src/{db,router,stores,composables,layouts,views/{auth,dashboard,orders,vehicles,appointments,catalog,settings,client},components/{common,orders,vehicles},assets/css} +touch backend/data/.gitkeep +git init +``` + +Creeaza `docs/api-contract.json`: +```json +{ + "version": "1.0", + "note": "Contract shared intre backend-agent si frontend-agent. Nu modificati fara notificarea ambilor agenti.", + "base_url": "/api", + "auth": { + "POST /auth/register": { + "body": {"email": "str", "password": "str", "tenant_name": "str", "telefon": "str"}, + "response": {"access_token": "str", "token_type": "bearer", "tenant_id": "str", "plan": "str"} + }, + "POST /auth/login": { + "body": {"email": "str", "password": "str"}, + "response": {"access_token": "str", "token_type": "bearer", "tenant_id": "str", "plan": "str"} + }, + "GET /auth/me": { + "headers": {"Authorization": "Bearer "}, + "response": {"id": "str", "email": "str", "tenant_id": "str", "plan": "str", "rol": "str"} + } + }, + "sync": { + "GET /sync/full": { + "headers": {"Authorization": "Bearer "}, + "response": { + "tables": { + "vehicles": [], "orders": [], "order_lines": [], + "invoices": [], "appointments": [], + "catalog_marci": [], "catalog_modele": [], + "catalog_ansamble": [], "catalog_norme": [], + "catalog_preturi": [], "catalog_tipuri_deviz": [], + "catalog_tipuri_motoare": [], "mecanici": [] + }, + "synced_at": "ISO8601" + } + }, + "GET /sync/changes": { + "params": {"since": "ISO8601"}, + "response": {"tables": {}, "synced_at": "str"} + }, + "POST /sync/push": { + "body": {"operations": [{"table": "str", "id": "uuid", "operation": "INSERT|UPDATE|DELETE", "data": {}, "timestamp": "str"}]}, + "response": {"applied": 0, "conflicts": []} + } + }, + "orders": { + "GET /orders": {"response": [{"id": "str", "status": "str", "nr_auto": "str", "total_general": 0}]}, + "POST /orders": {"body": {"vehicle_id": "str", "tip_deviz_id": "str", "km_intrare": 0, "observatii": "str"}, "response": {"id": "str"}}, + "GET /orders/{id}": {"response": {"id": "str", "status": "str", "lines": []}}, + "POST /orders/{id}/lines": {"body": {"tip": "manopera|material", "descriere": "str", "ore": 0, "pret_ora": 0, "cantitate": 0, "pret_unitar": 0, "um": "str"}}, + "POST /orders/{id}/validate": {"response": {"status": "VALIDAT"}}, + "GET /orders/{id}/pdf/deviz": {"response": "application/pdf"} + }, + "client_portal": { + "GET /p/{token}": {"response": {"order": {}, "tenant": {}, "lines": []}}, + "POST /p/{token}/accept": {"response": {"ok": true}}, + "POST /p/{token}/reject": {"response": {"ok": true}} + }, + "health": { + "GET /health": {"response": {"status": "ok"}} + } +} +``` + +Creeaza `.env.example`: +``` +SECRET_KEY=change-me-in-production +DATABASE_URL=sqlite+aiosqlite:///./data/roaauto.db +ACCESS_TOKEN_EXPIRE_DAYS=30 +TRIAL_DAYS=30 +SMSAPI_TOKEN= +CORS_ORIGINS=http://localhost:5173 +``` + +```bash +git add . +git commit -m "chore: project structure + API contract" +git remote add origin git@gitea.romfast.ro:marius/roaauto.git +git push -u origin main +``` + +**Mesaj catre teammates dupa TASK-001:** +``` +broadcast: TASK-001 completat. docs/api-contract.json e disponibil. +backend-agent: poti incepe TASK-002. +frontend-agent: poti incepe TASK-003. +devops-agent: poti incepe TASK-004. +``` + +--- + +### TASK-002: Backend - FastAPI + libSQL + Auth +**owned_by:** backend-agent +**depends_on:** TASK-001 +**plan_approval_required:** true +**priority:** critical + +**Plan pe care backend-agent il trimite spre aprobare:** +``` +Plan TASK-002: +1. requirements.txt cu toate dependintele +2. app/config.py (Settings din env) +3. app/db/base.py (Base, UUIDMixin, TenantMixin, TimestampMixin) +4. app/db/session.py (aiosqlite engine) +5. app/db/models/tenant.py + user.py +6. app/auth/schemas.py + service.py + router.py +7. app/main.py (FastAPI + lifespan + CORS + routers) +8. tests/conftest.py (in-memory SQLite pentru teste) +9. tests/test_auth.py (TDD: scrie testele intai) +10. alembic init + prima migrare +Estimat: 3-4h +``` + +**Implementare (dupa aprobare):** + +`backend/requirements.txt`: +``` +fastapi>=0.115 +uvicorn[standard]>=0.30 +sqlalchemy>=2.0 +aiosqlite>=0.20 +alembic>=1.13 +python-jose[cryptography]>=3.3 +passlib[bcrypt]>=1.7 +pydantic-settings>=2.0 +pydantic[email]>=2.0 +pytest>=8.0 +pytest-asyncio>=0.23 +httpx>=0.27 +weasyprint>=62 +jinja2>=3.1 +``` + +`backend/app/config.py`: +```python +from pydantic_settings import BaseSettings + +class Settings(BaseSettings): + SECRET_KEY: str = "dev-secret-change-me" + DATABASE_URL: str = "sqlite+aiosqlite:///./data/roaauto.db" + ACCESS_TOKEN_EXPIRE_DAYS: int = 30 + TRIAL_DAYS: int = 30 + SMSAPI_TOKEN: str = "" + CORS_ORIGINS: str = "http://localhost:5173" + + class Config: + env_file = ".env" + +settings = Settings() +``` + +`backend/app/db/base.py`: +```python +import uuid +from datetime import datetime, UTC +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column +from sqlalchemy import String, Text + +def uuid7() -> str: + return str(uuid.uuid4()) + +class Base(DeclarativeBase): + pass + +class UUIDMixin: + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid7) + +class TenantMixin: + tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + +class TimestampMixin: + created_at: Mapped[str] = mapped_column(Text, default=lambda: datetime.now(UTC).isoformat()) + updated_at: Mapped[str] = mapped_column( + Text, + default=lambda: datetime.now(UTC).isoformat(), + onupdate=lambda: datetime.now(UTC).isoformat() + ) +``` + +`backend/app/db/session.py`: +```python +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession +from app.config import settings + +engine = create_async_engine(settings.DATABASE_URL, echo=False) +AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False) + +async def get_db(): + async with AsyncSessionLocal() as session: + yield session +``` + +`backend/app/db/models/tenant.py`: +```python +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy import String, Text +from app.db.base import Base, UUIDMixin, TimestampMixin + +class Tenant(Base, UUIDMixin, TimestampMixin): + __tablename__ = "tenants" + nume: Mapped[str] = mapped_column(String(200)) + cui: Mapped[str | None] = mapped_column(String(20)) + reg_com: Mapped[str | None] = mapped_column(String(30)) + adresa: Mapped[str | None] = mapped_column(Text) + telefon: Mapped[str | None] = mapped_column(String(20)) + email: Mapped[str | None] = mapped_column(String(200)) + iban: Mapped[str | None] = mapped_column(String(34)) + banca: Mapped[str | None] = mapped_column(String(100)) + plan: Mapped[str] = mapped_column(String(20), default="trial") + trial_expires_at: Mapped[str | None] = mapped_column(Text) +``` + +`backend/app/db/models/user.py`: +```python +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy import String, Boolean +from app.db.base import Base, UUIDMixin, TenantMixin, TimestampMixin + +class User(Base, UUIDMixin, TenantMixin, TimestampMixin): + __tablename__ = "users" + email: Mapped[str] = mapped_column(String(200), unique=True, index=True) + password_hash: Mapped[str] = mapped_column(String(200)) + nume: Mapped[str] = mapped_column(String(200)) + rol: Mapped[str] = mapped_column(String(20), default="owner") + activ: Mapped[bool] = mapped_column(Boolean, default=True) +``` + +`backend/app/auth/schemas.py`: +```python +from pydantic import BaseModel, EmailStr + +class RegisterRequest(BaseModel): + email: EmailStr + password: str + tenant_name: str + telefon: str + +class LoginRequest(BaseModel): + email: EmailStr + password: str + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + tenant_id: str + plan: str +``` + +`backend/app/auth/service.py`: +```python +from datetime import datetime, timedelta, UTC +from jose import jwt +from passlib.context import CryptContext +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from app.config import settings +from app.db.models.tenant import Tenant +from app.db.models.user import User +from app.db.base import uuid7 + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +def hash_password(p: str) -> str: return pwd_context.hash(p) +def verify_password(plain: str, hashed: str) -> bool: return pwd_context.verify(plain, hashed) + +def create_token(user_id: str, tenant_id: str, plan: str) -> str: + exp = datetime.now(UTC) + timedelta(days=settings.ACCESS_TOKEN_EXPIRE_DAYS) + return jwt.encode({"sub": user_id, "tenant_id": tenant_id, "plan": plan, "exp": exp}, + settings.SECRET_KEY, algorithm="HS256") + +async def register(db: AsyncSession, email: str, password: str, tenant_name: str, telefon: str): + trial_exp = (datetime.now(UTC) + timedelta(days=settings.TRIAL_DAYS)).isoformat() + tenant = Tenant(id=uuid7(), nume=tenant_name, telefon=telefon, plan="trial", trial_expires_at=trial_exp) + db.add(tenant) + user = User(id=uuid7(), tenant_id=tenant.id, email=email, + password_hash=hash_password(password), nume=email.split("@")[0], rol="owner") + db.add(user) + await db.commit() + return user, tenant + +async def authenticate(db: AsyncSession, email: str, password: str): + r = await db.execute(select(User).where(User.email == email)) + user = r.scalar_one_or_none() + if not user or not verify_password(password, user.password_hash): + return None, None + r = await db.execute(select(Tenant).where(Tenant.id == user.tenant_id)) + return user, r.scalar_one_or_none() +``` + +`backend/app/auth/router.py`: +```python +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from app.db.session import get_db +from app.auth import service, schemas + +router = APIRouter() + +@router.post("/register", response_model=schemas.TokenResponse) +async def register(data: schemas.RegisterRequest, db: AsyncSession = Depends(get_db)): + user, tenant = await service.register(db, data.email, data.password, data.tenant_name, data.telefon) + return schemas.TokenResponse( + access_token=service.create_token(user.id, tenant.id, tenant.plan), + tenant_id=tenant.id, plan=tenant.plan + ) + +@router.post("/login", response_model=schemas.TokenResponse) +async def login(data: schemas.LoginRequest, db: AsyncSession = Depends(get_db)): + user, tenant = await service.authenticate(db, data.email, data.password) + if not user: + raise HTTPException(status_code=401, detail="Credentiale invalide") + return schemas.TokenResponse( + access_token=service.create_token(user.id, tenant.id, tenant.plan), + tenant_id=tenant.id, plan=tenant.plan + ) +``` + +`backend/app/deps.py`: +```python +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from jose import jwt, JWTError +from app.config import settings + +bearer = HTTPBearer() + +async def get_current_user(creds: HTTPAuthorizationCredentials = Depends(bearer)) -> dict: + try: + return jwt.decode(creds.credentials, settings.SECRET_KEY, algorithms=["HS256"]) + except JWTError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) + +async def get_tenant_id(user: dict = Depends(get_current_user)) -> str: + return user["tenant_id"] +``` + +`backend/app/main.py`: +```python +from contextlib import asynccontextmanager +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from app.config import settings +from app.db.session import engine +from app.db.base import Base +from app.auth.router import router as auth_router + +@asynccontextmanager +async def lifespan(app: FastAPI): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield + +app = FastAPI(lifespan=lifespan) +app.add_middleware(CORSMiddleware, + allow_origins=settings.CORS_ORIGINS.split(","), + allow_methods=["*"], allow_headers=["*"], allow_credentials=True) +app.include_router(auth_router, prefix="/api/auth") + +@app.get("/api/health") +async def health(): return {"status": "ok"} +``` + +`backend/tests/conftest.py`: +```python +import pytest +import pytest_asyncio +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker +from app.db.base import Base +from app.db.session import get_db +from app.main import app + +@pytest_asyncio.fixture(autouse=True) +async def setup_test_db(): + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + session_factory = async_sessionmaker(engine, expire_on_commit=False) + async def override_db(): + async with session_factory() as s: + yield s + app.dependency_overrides[get_db] = override_db + yield + app.dependency_overrides.clear() + await engine.dispose() +``` + +`backend/tests/test_auth.py` (scrie testele INAINTE de implementare): +```python +import pytest +from httpx import AsyncClient, ASGITransport +from app.main import app + +@pytest.mark.asyncio +async def test_register_creates_tenant(): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + r = await c.post("/api/auth/register", json={ + "email": "owner@service.ro", "password": "parola123", + "tenant_name": "Service Ionescu", "telefon": "0722000000" + }) + assert r.status_code == 200 + data = r.json() + assert "access_token" in data + assert data["plan"] == "trial" + +@pytest.mark.asyncio +async def test_login_returns_token(): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + await c.post("/api/auth/register", json={ + "email": "test@s.ro", "password": "abc123", + "tenant_name": "Test", "telefon": "0722" + }) + r = await c.post("/api/auth/login", json={"email": "test@s.ro", "password": "abc123"}) + assert r.status_code == 200 + assert "access_token" in r.json() + +@pytest.mark.asyncio +async def test_login_wrong_password_returns_401(): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + r = await c.post("/api/auth/login", json={"email": "x@x.ro", "password": "wrong"}) + assert r.status_code == 401 +``` + +Ruleaza testele: +```bash +cd backend && pip install -r requirements.txt +pytest tests/test_auth.py -v +# Expected: 3 PASSED +``` + +Alembic: +```bash +alembic init alembic +# Editeaza alembic.ini: sqlalchemy.url = sqlite+aiosqlite:///./data/roaauto.db +# Editeaza alembic/env.py pentru async + importa Base +alembic revision --autogenerate -m "initial_tenants_users" +alembic upgrade head +``` + +```bash +git add backend/ +git commit -m "feat(backend): FastAPI + libSQL + auth register/login + tests (TDD)" +``` + +**Mesaj catre team lead si frontend-agent dupa TASK-002:** +``` +message team-lead: TASK-002 completat. Auth endpoints live pe :8000. +POST /api/auth/register si /api/auth/login functioneaza conform api-contract.json. +Testele trec (3/3). + +message frontend-agent: Auth backend e gata. Poti conecta stores/auth.js la API real. +Endpoint register: POST /api/auth/register +Endpoint login: POST /api/auth/login +Token format: JWT cu payload {sub, tenant_id, plan, exp} +``` + +--- + +### TASK-003: Frontend - Vue 3 + wa-sqlite + Auth +**owned_by:** frontend-agent +**depends_on:** TASK-001 +**plan_approval_required:** true +**note:** Poate incepe in paralel cu TASK-002 (API contract e suficient) + +**Plan pe care frontend-agent il trimite spre aprobare:** +``` +Plan TASK-003: +1. package.json + npm install +2. vite.config.js + tailwind.config.js +3. src/db/schema.js (toate tabelele SQLite - mirror server) +4. src/db/database.js (wa-sqlite init + OPFS) +5. src/db/sync.js (SyncEngine: fullSync, incrementalSync, pushQueue) +6. src/stores/auth.js (JWT parse, login, register, plan tier) +7. src/composables/useSqlQuery.js (reactive SQL helper) +8. src/router/index.js (routes + auth guards) +9. src/layouts/AppLayout.vue (sidebar desktop / bottom nav mobile) +10. src/layouts/AuthLayout.vue +11. src/components/common/SyncIndicator.vue +12. src/views/auth/LoginView.vue + RegisterView.vue +13. src/main.js + App.vue +14. src/assets/css/main.css +Estimat: 3-4h +``` + +`frontend/package.json`: +```json +{ + "name": "roaauto-frontend", + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.5", + "vue-router": "^4.4", + "pinia": "^2.2", + "@journeyapps/wa-sqlite": "^1.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2", + "vite": "^6.0", + "tailwindcss": "^4.0", + "@tailwindcss/vite": "^4.0", + "vite-plugin-pwa": "^0.21" + } +} +``` + +`frontend/src/db/schema.js` - toate tabelele din PLAN.md (mirror exact): +```javascript +export const SCHEMA_SQL = ` + CREATE TABLE IF NOT EXISTS tenants ( + id TEXT PRIMARY KEY, tenant_id TEXT, nume TEXT, cui TEXT, reg_com TEXT, + adresa TEXT, telefon TEXT, email TEXT, iban TEXT, banca TEXT, + plan TEXT DEFAULT 'free', trial_expires_at TEXT, created_at TEXT, updated_at TEXT + ); + CREATE TABLE IF NOT EXISTS vehicles ( + id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, + client_nume TEXT, client_telefon TEXT, client_email TEXT, + client_cod_fiscal TEXT, client_adresa TEXT, + nr_inmatriculare TEXT, marca_id TEXT, model_id TEXT, + an_fabricatie INTEGER, serie_sasiu TEXT, tip_motor_id TEXT, + oracle_id INTEGER, created_at TEXT, updated_at TEXT + ); + CREATE TABLE IF NOT EXISTS orders ( + id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, + nr_comanda TEXT, data_comanda TEXT, vehicle_id TEXT, + tip_deviz_id TEXT, status TEXT DEFAULT 'DRAFT', + km_intrare INTEGER, observatii TEXT, + client_nume TEXT, client_telefon TEXT, nr_auto TEXT, + marca_denumire TEXT, model_denumire TEXT, + total_manopera REAL DEFAULT 0, total_materiale REAL DEFAULT 0, total_general REAL DEFAULT 0, + token_client TEXT, created_by TEXT, oracle_id INTEGER, created_at TEXT, updated_at TEXT + ); + CREATE TABLE IF NOT EXISTS order_lines ( + id TEXT PRIMARY KEY, order_id TEXT NOT NULL, tenant_id TEXT NOT NULL, + tip TEXT, descriere TEXT, + norma_id TEXT, ore REAL, pret_ora REAL, + um TEXT, cantitate REAL, pret_unitar REAL, + total REAL, mecanic_id TEXT, ordine INTEGER, + oracle_id INTEGER, created_at TEXT, updated_at TEXT + ); + CREATE TABLE IF NOT EXISTS invoices ( + id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, order_id TEXT, + nr_factura TEXT, serie_factura TEXT, data_factura TEXT, + modalitate_plata TEXT, client_nume TEXT, client_cod_fiscal TEXT, nr_auto TEXT, + total_fara_tva REAL, tva REAL, total_general REAL, + oracle_id INTEGER, created_at TEXT, updated_at TEXT + ); + CREATE TABLE IF NOT EXISTS appointments ( + id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, vehicle_id TEXT, + client_nume TEXT, client_telefon TEXT, data_ora TEXT, + durata_minute INTEGER DEFAULT 60, observatii TEXT, + status TEXT DEFAULT 'PROGRAMAT', order_id TEXT, + oracle_id INTEGER, created_at TEXT, updated_at TEXT + ); + CREATE TABLE IF NOT EXISTS catalog_marci ( + id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, denumire TEXT, activ INTEGER DEFAULT 1, + oracle_id INTEGER, updated_at TEXT + ); + CREATE TABLE IF NOT EXISTS catalog_modele ( + id TEXT PRIMARY KEY, marca_id TEXT, denumire TEXT, oracle_id INTEGER, updated_at TEXT + ); + CREATE TABLE IF NOT EXISTS catalog_ansamble ( + id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, denumire TEXT, oracle_id INTEGER, updated_at TEXT + ); + CREATE TABLE IF NOT EXISTS catalog_norme ( + id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, cod TEXT, denumire TEXT, + ore_normate REAL, ansamblu_id TEXT, oracle_id INTEGER, updated_at TEXT + ); + CREATE TABLE IF NOT EXISTS catalog_preturi ( + id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, denumire TEXT, pret REAL, um TEXT, + oracle_id INTEGER, updated_at TEXT + ); + CREATE TABLE IF NOT EXISTS catalog_tipuri_deviz ( + id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, denumire TEXT, oracle_id INTEGER, updated_at TEXT + ); + CREATE TABLE IF NOT EXISTS catalog_tipuri_motoare ( + id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, denumire TEXT, oracle_id INTEGER, updated_at TEXT + ); + CREATE TABLE IF NOT EXISTS mecanici ( + id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, user_id TEXT, + nume TEXT, prenume TEXT, activ INTEGER DEFAULT 1, oracle_id INTEGER, updated_at TEXT + ); + CREATE TABLE IF NOT EXISTS _sync_queue ( + id TEXT PRIMARY KEY, table_name TEXT, row_id TEXT, + operation TEXT, data_json TEXT, created_at TEXT, synced_at TEXT + ); + CREATE TABLE IF NOT EXISTS _sync_state (table_name TEXT PRIMARY KEY, last_sync_at TEXT); + CREATE TABLE IF NOT EXISTS _local_settings (key TEXT PRIMARY KEY, value TEXT); + PRAGMA journal_mode=WAL; +`; + +export const SYNC_TABLES = [ + 'vehicles','orders','order_lines','invoices','appointments', + 'catalog_marci','catalog_modele','catalog_ansamble','catalog_norme', + 'catalog_preturi','catalog_tipuri_deviz','catalog_tipuri_motoare','mecanici' +]; +``` + +`frontend/src/db/database.js`: +```javascript +import SQLiteESMFactory from '@journeyapps/wa-sqlite/dist/wa-sqlite-async.mjs' +import { IDBBatchAtomicVFS } from '@journeyapps/wa-sqlite/src/examples/IDBBatchAtomicVFS.js' +import * as SQLite from '@journeyapps/wa-sqlite' +import { SCHEMA_SQL } from './schema.js' + +let db = null +let sqlite3 = null +const tableListeners = new Map() + +export async function initDatabase() { + if (db) return db + const module = await SQLiteESMFactory() + sqlite3 = SQLite.Factory(module) + const vfs = await IDBBatchAtomicVFS.create('roaauto', module) + sqlite3.vfs_register(vfs, true) + db = await sqlite3.open_v2('roaauto.db', + SQLite.SQLITE_OPEN_READWRITE | SQLite.SQLITE_OPEN_CREATE, 'roaauto') + for (const sql of SCHEMA_SQL.split(';').filter(s => s.trim())) { + await sqlite3.exec(db, sql) + } + return db +} + +export function notifyTableChanged(table) { + tableListeners.get(table)?.forEach(cb => cb()) +} + +export function onTableChange(table, cb) { + if (!tableListeners.has(table)) tableListeners.set(table, new Set()) + tableListeners.get(table).add(cb) + return () => tableListeners.get(table).delete(cb) +} + +export async function execSQL(sql, params = []) { + if (!db) throw new Error('DB not initialized') + const results = [] + await sqlite3.exec(db, sql, (row, cols) => { + const obj = {} + cols.forEach((c, i) => { obj[c] = row[i] }) + results.push(obj) + }) + return results +} +``` + +`frontend/src/db/sync.js`: +```javascript +import { execSQL, notifyTableChanged } from './database.js' + +const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000/api' + +export class SyncEngine { + constructor() { + this.syncing = false + this.online = navigator.onLine + window.addEventListener('online', () => { this.online = true; this.pushQueue() }) + window.addEventListener('offline', () => { this.online = false }) + } + + getToken() { return localStorage.getItem('token') } + + async fullSync() { + const token = this.getToken() + if (!token) return + const res = await fetch(`${API_URL}/sync/full`, { headers: { Authorization: `Bearer ${token}` } }) + if (!res.ok) return + const { tables, synced_at } = await res.json() + for (const [tableName, rows] of Object.entries(tables)) { + for (const row of rows) { + const cols = Object.keys(row).join(', ') + const ph = Object.keys(row).map(() => '?').join(', ') + await execSQL(`INSERT OR REPLACE INTO ${tableName} (${cols}) VALUES (${ph})`, Object.values(row)) + } + notifyTableChanged(tableName) + await execSQL(`INSERT OR REPLACE INTO _sync_state VALUES (?, ?)`, [tableName, synced_at]) + } + } + + async incrementalSync() { + const token = this.getToken() + if (!token || !this.online) return + const [state] = await execSQL(`SELECT MIN(last_sync_at) as since FROM _sync_state`) + if (!state?.since) return this.fullSync() + const res = await fetch(`${API_URL}/sync/changes?since=${encodeURIComponent(state.since)}`, + { headers: { Authorization: `Bearer ${token}` } }) + if (!res.ok) return + const { tables, synced_at } = await res.json() + for (const [tableName, rows] of Object.entries(tables)) { + for (const row of rows) { + const cols = Object.keys(row).join(', ') + const ph = Object.keys(row).map(() => '?').join(', ') + await execSQL(`INSERT OR REPLACE INTO ${tableName} (${cols}) VALUES (${ph})`, Object.values(row)) + } + if (rows.length) notifyTableChanged(tableName) + await execSQL(`INSERT OR REPLACE INTO _sync_state VALUES (?, ?)`, [tableName, synced_at]) + } + } + + async addToQueue(tableName, rowId, operation, data) { + const id = crypto.randomUUID() + await execSQL( + `INSERT INTO _sync_queue (id, table_name, row_id, operation, data_json, created_at) VALUES (?,?,?,?,?,?)`, + [id, tableName, rowId, operation, JSON.stringify(data), new Date().toISOString()] + ) + if (this.online) this.pushQueue() + } + + async pushQueue() { + if (this.syncing) return + const token = this.getToken() + if (!token) return + this.syncing = true + try { + const queue = await execSQL(`SELECT * FROM _sync_queue WHERE synced_at IS NULL ORDER BY created_at`) + if (!queue.length) return + const res = await fetch(`${API_URL}/sync/push`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ operations: queue.map(q => ({ + table: q.table_name, id: q.row_id, operation: q.operation, + data: JSON.parse(q.data_json), timestamp: q.created_at + })) }) + }) + if (res.ok) { + const ids = queue.map(() => '?').join(',') + await execSQL(`UPDATE _sync_queue SET synced_at=? WHERE id IN (${ids})`, + [new Date().toISOString(), ...queue.map(q => q.id)]) + } + } finally { + this.syncing = false + } + } +} + +export const syncEngine = new SyncEngine() +``` + +`frontend/src/stores/auth.js`: +```javascript +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' + +const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000/api' + +export const useAuthStore = defineStore('auth', () => { + const token = ref(localStorage.getItem('token')) + const payload = computed(() => { + if (!token.value) return null + try { return JSON.parse(atob(token.value.split('.')[1])) } catch { return null } + }) + const isAuthenticated = computed(() => !!token.value && payload.value?.exp * 1000 > Date.now()) + const tenantId = computed(() => payload.value?.tenant_id) + const plan = computed(() => payload.value?.plan || 'free') + + async function login(email, password) { + const res = await fetch(`${API_URL}/auth/login`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }) + }) + if (!res.ok) throw new Error('Credentiale invalide') + const data = await res.json() + token.value = data.access_token + localStorage.setItem('token', data.access_token) + return data + } + + async function register(email, password, tenant_name, telefon) { + const res = await fetch(`${API_URL}/auth/register`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password, tenant_name, telefon }) + }) + if (!res.ok) throw new Error('Inregistrare esuata') + const data = await res.json() + token.value = data.access_token + localStorage.setItem('token', data.access_token) + return data + } + + function logout() { token.value = null; localStorage.removeItem('token') } + + return { token, isAuthenticated, tenantId, plan, login, register, logout } +}) +``` + +`frontend/src/composables/useSqlQuery.js`: +```javascript +import { ref, onMounted, onUnmounted } from 'vue' +import { execSQL, onTableChange } from '../db/database.js' + +export function useSqlQuery(sql, params = [], watchTables = []) { + const rows = ref([]) + const loading = ref(true) + + async function refresh() { + loading.value = true + try { rows.value = await execSQL(sql, params) } + finally { loading.value = false } + } + + const unsubs = [] + onMounted(() => { + refresh() + watchTables.forEach(t => unsubs.push(onTableChange(t, refresh))) + }) + onUnmounted(() => unsubs.forEach(fn => fn())) + + return { rows, loading, refresh } +} +``` + +`frontend/src/router/index.js`: +```javascript +import { createRouter, createWebHistory } from 'vue-router' +import { useAuthStore } from '../stores/auth.js' + +export default createRouter({ + history: createWebHistory(), + routes: [ + { path: '/login', component: () => import('../views/auth/LoginView.vue'), meta: { layout: 'auth' } }, + { path: '/register', component: () => import('../views/auth/RegisterView.vue'), meta: { layout: 'auth' } }, + { path: '/dashboard', component: () => import('../views/dashboard/DashboardView.vue'), meta: { requiresAuth: true } }, + { path: '/orders', component: () => import('../views/orders/OrdersListView.vue'), meta: { requiresAuth: true } }, + { path: '/orders/new', component: () => import('../views/orders/OrderCreateView.vue'), meta: { requiresAuth: true } }, + { path: '/orders/:id', component: () => import('../views/orders/OrderDetailView.vue'), meta: { requiresAuth: true } }, + { path: '/vehicles', component: () => import('../views/vehicles/VehiclesListView.vue'), meta: { requiresAuth: true } }, + { path: '/appointments', component: () => import('../views/appointments/AppointmentsView.vue'), meta: { requiresAuth: true } }, + { path: '/catalog', component: () => import('../views/catalog/CatalogView.vue'), meta: { requiresAuth: true } }, + { path: '/settings', component: () => import('../views/settings/SettingsView.vue'), meta: { requiresAuth: true } }, + { path: '/p/:token', component: () => import('../views/client/DevizPublicView.vue') }, + { path: '/', redirect: '/dashboard' }, + ], + scrollBehavior: () => ({ top: 0 }) +}) + +// Guard +const router = createRouter({ history: createWebHistory(), routes: [] }) +router.beforeEach((to) => { + if (to.meta.requiresAuth && !useAuthStore().isAuthenticated) return '/login' +}) +``` + +Layouts, views auth, SyncIndicator: implementeaza conform spec-ului vizual din PLAN.md. + +```bash +cd frontend && npm install +npm run dev +# Verifica: login apare, guard functioneaza, SyncIndicator arata Online/Offline +git add frontend/ +git commit -m "feat(frontend): Vue 3 + wa-sqlite + sync engine + auth + layouts" +``` + +**Mesaj catre team lead si backend-agent dupa TASK-003:** +``` +message team-lead: TASK-003 completat. Frontend ruleaza pe :5173. +wa-sqlite initializat, sync engine functional, auth store connected la API. +Verifica: http://localhost:5173 + +message backend-agent: Frontend e gata. sync.js asteapta GET /api/sync/full. +Cand TASK-005 (sync endpoints) e gata, fac full integration test. +``` + +--- + +### TASK-004: DevOps - Docker Dev + Makefile +**owned_by:** devops-agent +**depends_on:** TASK-001 +**priority:** high + +`docker-compose.dev.yml`: +```yaml +services: + backend: + build: + context: ./backend + dockerfile: Dockerfile.dev + ports: + - "8000:8000" + volumes: + - ./backend:/app + - ./backend/data:/app/data + environment: + - DATABASE_URL=sqlite+aiosqlite:///./data/roaauto.db + - SECRET_KEY=dev-secret-key-change-in-prod + - CORS_ORIGINS=http://localhost:5173 + command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload + + frontend: + image: node:20-alpine + ports: + - "5173:5173" + volumes: + - ./frontend:/app + working_dir: /app + environment: + - VITE_API_URL=http://localhost:8000/api + command: sh -c "npm install && npm run dev -- --host" +``` + +`backend/Dockerfile.dev`: +```dockerfile +FROM python:3.12-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install -r requirements.txt +COPY . . +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] +``` + +`Makefile`: +```makefile +.PHONY: dev migrate seed backup test shell + +dev: + docker compose -f docker-compose.dev.yml up + +migrate: + docker compose -f docker-compose.dev.yml exec backend alembic upgrade head + +seed: + docker compose -f docker-compose.dev.yml exec backend python -m app.db.seed + +backup: + cp backend/data/roaauto.db backend/data/backup-$(shell date +%Y%m%d-%H%M%S).db + +test: + docker compose -f docker-compose.dev.yml exec backend pytest tests/ -v + +shell: + docker compose -f docker-compose.dev.yml exec backend bash +``` + +```bash +git add docker-compose.dev.yml backend/Dockerfile.dev Makefile +git commit -m "chore(devops): docker-compose dev + Makefile" +``` + +**Mesaj catre team lead:** +``` +message team-lead: TASK-004 completat. `make dev` porneste backend + frontend. +`make test` ruleaza testele. `make migrate` aplica migrarile. +``` + +--- + +## Faza 2: Sync + Business Logic [Saptamana 2] + +### TASK-005: Backend - Sync Endpoints + All Models + Seed +**owned_by:** backend-agent +**depends_on:** TASK-002 +**priority:** critical + +Implementeaza in aceasta ordine (TDD): + +**1. Toate modelele SQLAlchemy** (`db/models/vehicle.py`, `order.py`, `order_line.py`, `catalog.py`, `invoice.py`, `appointment.py`): +- Fiecare: `id TEXT PK`, `tenant_id TEXT NOT NULL INDEX`, `oracle_id INTEGER NULL`, `updated_at TEXT` +- Respecta exact schema din `docs/PLAN.md` + +**2. Migrarile Alembic:** +```bash +alembic revision --autogenerate -m "catalog_and_vehicles" +alembic revision --autogenerate -m "orders_and_lines" +alembic revision --autogenerate -m "invoices_appointments" +alembic upgrade head +``` + +**3. Seed data** (`app/db/seed.py`): +```python +# Referinta: roa-auto-mobile/backend/seed.py +MARCI = [ + "Audi", "BMW", "Citroen", "Dacia", "Fiat", "Ford", "Honda", + "Hyundai", "Kia", "Mazda", "Mercedes-Benz", "Mitsubishi", "Nissan", + "Opel", "Peugeot", "Renault", "Seat", "Skoda", "Suzuki", + "Toyota", "Volkswagen", "Volvo", "Alfa Romeo", "Jeep" +] # 24 marci + +ANSAMBLE = [ + "Motor", "Cutie de viteze", "Frane", "Directie", "Suspensie", + "Climatizare", "Electrica", "Caroserie", "Esapament", "Transmisie", "Revizie" +] # 11 ansamble + +TIPURI_DEVIZ = [ + "Deviz reparatie", "Deviz revizie", "Deviz diagnosticare", + "Deviz estimativ", "Deviz vulcanizare", "Deviz ITP" +] + +TIPURI_MOTOARE = ["Benzina", "Diesel", "Hibrid", "Electric", "GPL"] + +PRETURI = [ + {"denumire": "Manopera standard", "pret": 150.0, "um": "ora"}, + {"denumire": "Revizie ulei + filtru", "pret": 250.0, "um": "buc"}, + {"denumire": "Diagnosticare", "pret": 100.0, "um": "buc"}, +] +``` + +**4. Sync endpoints** (`app/sync/router.py`, `app/sync/service.py`): + +`tests/test_sync.py` (FAIL first): +```python +async def test_full_sync_returns_all_tables(client, auth_headers): + r = await client.get("/api/sync/full", headers=auth_headers) + assert r.status_code == 200 + data = r.json() + assert "tables" in data and "synced_at" in data + assert "vehicles" in data["tables"] + assert "catalog_marci" in data["tables"] + +async def test_sync_push_insert_vehicle(client, auth_headers, tenant_id): + import uuid; from datetime import datetime, UTC + vid = str(uuid.uuid4()); now = datetime.now(UTC).isoformat() + r = await client.post("/api/sync/push", headers=auth_headers, json={"operations": [{ + "table": "vehicles", "id": vid, "operation": "INSERT", + "data": {"id": vid, "tenant_id": tenant_id, "nr_inmatriculare": "CTA01ABC", + "client_nume": "Popescu", "created_at": now, "updated_at": now}, + "timestamp": now + }]}) + assert r.status_code == 200 + assert r.json()["applied"] == 1 +``` + +`app/sync/service.py`: +```python +SYNCABLE_TABLES = [ + "vehicles", "orders", "order_lines", "invoices", "appointments", + "catalog_marci", "catalog_modele", "catalog_ansamble", "catalog_norme", + "catalog_preturi", "catalog_tipuri_deviz", "catalog_tipuri_motoare", "mecanici" +] + +async def get_full(db, tenant_id: str) -> dict: + result = {} + for table in SYNCABLE_TABLES: + # catalog_modele nu are tenant_id direct - join via catalog_marci + if table == "catalog_modele": + rows = await db.execute( + text("SELECT cm.* FROM catalog_modele cm " + "JOIN catalog_marci marc ON cm.marca_id = marc.id " + "WHERE marc.tenant_id = :tid"), {"tid": tenant_id}) + else: + rows = await db.execute( + text(f"SELECT * FROM {table} WHERE tenant_id = :tid"), {"tid": tenant_id}) + result[table] = [dict(r._mapping) for r in rows] + return result + +async def apply_push(db, tenant_id: str, operations: list) -> dict: + applied = 0 + for op in operations: + # Valideaza tenant isolation + if op["data"].get("tenant_id") and op["data"]["tenant_id"] != tenant_id: + continue + op["data"]["tenant_id"] = tenant_id # enforce + if op["operation"] in ("INSERT", "UPDATE"): + cols = ", ".join(op["data"].keys()) + ph = ", ".join(f":{k}" for k in op["data"].keys()) + await db.execute( + text(f"INSERT OR REPLACE INTO {op['table']} ({cols}) VALUES ({ph})"), + op["data"] + ) + applied += 1 + elif op["operation"] == "DELETE": + await db.execute( + text(f"DELETE FROM {op['table']} WHERE id = :id AND tenant_id = :tid"), + {"id": op["id"], "tid": tenant_id} + ) + applied += 1 + await db.commit() + return {"applied": applied, "conflicts": []} +``` + +**5. Order Service** (`app/orders/service.py`, `app/orders/router.py`): + +`tests/test_orders.py`: +```python +async def test_order_workflow(client, auth_headers): + # Creeaza vehicul via sync push + # Creeaza comanda (DRAFT) + # Adauga linie manopera: 2h x 150 = 300 + # Adauga linie material: 2 buc x 50 = 100 + # POST /orders/{id}/validate → VALIDAT + # GET /orders/{id} → total_manopera=300, total_materiale=100, total_general=400 + +async def test_cannot_add_line_to_validat_order(client, auth_headers): + # DRAFT → VALIDAT → add line → 422 +``` + +OrderService state machine: +```python +TRANSITIONS = {"DRAFT": ["VALIDAT"], "VALIDAT": ["FACTURAT"]} + +async def recalc_totals(db, order_id: str): + lines = await db.execute( + text("SELECT tip, COALESCE(SUM(total), 0) as sub FROM order_lines " + "WHERE order_id = :oid GROUP BY tip"), {"oid": order_id}) + totals = {r.tip: r.sub for r in lines} + manopera = totals.get("manopera", 0) + materiale = totals.get("material", 0) + await db.execute( + text("UPDATE orders SET total_manopera=:m, total_materiale=:mat, " + "total_general=:g, updated_at=:u WHERE id=:id"), + {"m": manopera, "mat": materiale, "g": manopera + materiale, + "u": datetime.now(UTC).isoformat(), "id": order_id} + ) +``` + +```bash +pytest tests/ -v +# Expected: toate trec + +make seed +# Verifica: 24 marci, 11 ansamble + +git commit -m "feat(backend): sync endpoints + all models + seed + order workflow" +``` + +**Mesaj catre frontend-agent:** +``` +message frontend-agent: TASK-005 completat! +GET /api/sync/full returneaza toate tabelele inclusiv seed data (24 marci, etc). +POST /api/sync/push functioneaza cu INSERT/UPDATE/DELETE. +Order endpoints: POST /api/orders, POST /api/orders/{id}/lines, POST /api/orders/{id}/validate. +Poti face integration test complet acum. +``` + +--- + +### TASK-006: Frontend - Dashboard + Orders UI + Vehicle Picker +**owned_by:** frontend-agent +**depends_on:** TASK-003 +**note:** Poate incepe cu TASK-003 gata; conecteaza la API real cand TASK-005 e gata + +`frontend/src/stores/orders.js`: +```javascript +import { defineStore } from 'pinia' +import { execSQL, notifyTableChanged } from '../db/database.js' +import { syncEngine } from '../db/sync.js' +import { useAuthStore } from './auth.js' + +export const useOrdersStore = defineStore('orders', () => { + async function createOrder(vehicleId, extraData = {}) { + const auth = useAuthStore() + const id = crypto.randomUUID() + const now = new Date().toISOString() + const data = { + id, tenant_id: auth.tenantId, vehicle_id: vehicleId, + status: 'DRAFT', data_comanda: now.split('T')[0], + total_manopera: 0, total_materiale: 0, total_general: 0, + token_client: crypto.randomUUID(), + created_at: now, updated_at: now, ...extraData + } + const cols = Object.keys(data).join(', ') + const ph = Object.keys(data).map(() => '?').join(', ') + await execSQL(`INSERT INTO orders (${cols}) VALUES (${ph})`, Object.values(data)) + notifyTableChanged('orders') + await syncEngine.addToQueue('orders', id, 'INSERT', data) + return id + } + + async function addLine(orderId, lineData) { + const auth = useAuthStore() + const id = crypto.randomUUID() + const now = new Date().toISOString() + const total = lineData.tip === 'manopera' + ? (Number(lineData.ore) || 0) * (Number(lineData.pret_ora) || 0) + : (Number(lineData.cantitate) || 0) * (Number(lineData.pret_unitar) || 0) + const data = { id, order_id: orderId, tenant_id: auth.tenantId, total, created_at: now, updated_at: now, ...lineData } + const cols = Object.keys(data).join(', ') + const ph = Object.keys(data).map(() => '?').join(', ') + await execSQL(`INSERT INTO order_lines (${cols}) VALUES (${ph})`, Object.values(data)) + await recalcTotals(orderId) + notifyTableChanged('order_lines') + await syncEngine.addToQueue('order_lines', id, 'INSERT', data) + } + + async function recalcTotals(orderId) { + const rows = await execSQL( + `SELECT tip, COALESCE(SUM(total),0) as sub FROM order_lines WHERE order_id=? GROUP BY tip`, + [orderId] + ) + let manopera = 0, materiale = 0 + rows.forEach(r => { if (r.tip === 'manopera') manopera = r.sub; else materiale = r.sub }) + const now = new Date().toISOString() + await execSQL( + `UPDATE orders SET total_manopera=?, total_materiale=?, total_general=?, updated_at=? WHERE id=?`, + [manopera, materiale, manopera + materiale, now, orderId] + ) + notifyTableChanged('orders') + } + + async function changeStatus(orderId, newStatus) { + const now = new Date().toISOString() + await execSQL(`UPDATE orders SET status=?, updated_at=? WHERE id=?`, [newStatus, now, orderId]) + notifyTableChanged('orders') + const [order] = await execSQL(`SELECT * FROM orders WHERE id=?`, [orderId]) + await syncEngine.addToQueue('orders', orderId, 'UPDATE', order) + } + + return { createOrder, addLine, recalcTotals, changeStatus } +}) +``` + +`frontend/src/components/vehicles/VehiclePicker.vue` (search-as-you-type pe wa-sqlite): +```vue + + +``` + +Views: DashboardView, OrdersListView, OrderCreateView, OrderDetailView. +Toate reads din wa-sqlite local, writes via `ordersStore` + sync queue. + +```bash +git commit -m "feat(frontend): dashboard + orders CRUD + vehicle picker + offline-first" +``` + +--- + +## Faza 3: PDF + Portal + SMS [Saptamana 3] + +### TASK-007: Backend - PDF + Portal Client + SMS + Invoices +**owned_by:** backend-agent +**depends_on:** TASK-005 +**priority:** high + +**PDF (WeasyPrint):** + +`backend/app/pdf/templates/deviz.html`: +```html + + + + + +
+
+ {{ tenant.nume }}
+ {% if tenant.cui %}CUI: {{ tenant.cui }}
{% endif %} + {% if tenant.adresa %}{{ tenant.adresa }}
{% endif %} + {% if tenant.telefon %}Tel: {{ tenant.telefon }}{% endif %} +
+
+

DEVIZ Nr. {{ order.nr_comanda or order.id[:8].upper() }}

+
Data: {{ order.data_comanda }}
+
Auto: {{ order.nr_auto }}
+
{{ order.marca_denumire }} {{ order.model_denumire }}
+
Client: {{ order.client_nume }}
+
+
+ +{% if manopera %} +

Operatii manopera

+ + + {% for l in manopera %} + + + + + + {% endfor %} +
DescriereOrePret/ora (RON)Total (RON)
{{ l.descriere }}{{ l.ore }}{{ "%.2f"|format(l.pret_ora or 0) }}{{ "%.2f"|format(l.total or 0) }}
+{% endif %} + +{% if materiale %} +

Materiale

+ + + {% for l in materiale %} + + + + + + {% endfor %} +
DescriereUMCant.Pret unit. (RON)Total (RON)
{{ l.descriere }}{{ l.um }}{{ l.cantitate }}{{ "%.2f"|format(l.pret_unitar or 0) }}{{ "%.2f"|format(l.total or 0) }}
+{% endif %} + +
+
Manopera: {{ "%.2f"|format(order.total_manopera or 0) }} RON
+
Materiale: {{ "%.2f"|format(order.total_materiale or 0) }} RON
+
TOTAL: {{ "%.2f"|format(order.total_general or 0) }} RON
+
+ +``` + +`app/pdf/service.py`: +```python +from pathlib import Path +from weasyprint import HTML +from jinja2 import Environment, FileSystemLoader + +TEMPLATES = Path(__file__).parent / "templates" + +def generate_deviz(order: dict, lines: list, tenant: dict) -> bytes: + env = Environment(loader=FileSystemLoader(str(TEMPLATES))) + html = env.get_template("deviz.html").render( + order=order, tenant=tenant, + manopera=[l for l in lines if l.get("tip") == "manopera"], + materiale=[l for l in lines if l.get("tip") == "material"] + ) + return HTML(string=html).write_pdf() +``` + +Test: +```python +async def test_pdf_deviz_returns_pdf_content_type(client, auth_headers): + # create order + lines → GET /api/orders/{id}/pdf/deviz + # assert content_type == "application/pdf" + pass +``` + +**Client Portal** (`app/client_portal/router.py`) - no auth: +```python +@router.get("/p/{token}") +async def get_deviz(token: str, db=Depends(get_db)): + r = await db.execute(select(Order).where(Order.token_client == token)) + order = r.scalar_one_or_none() + if not order: raise HTTPException(404) + # Returneaza order + lines + tenant (fara password_hash) + +@router.post("/p/{token}/accept") +async def accept(token: str, db=Depends(get_db)): + await db.execute(text("UPDATE orders SET status_client='ACCEPTAT' WHERE token_client=:t"), {"t": token}) + await db.commit() + return {"ok": True} +``` + +**SMS** (`app/sms/service.py`): +```python +import httpx +from app.config import settings + +async def send_deviz_sms(telefon: str, token_client: str, tenant_name: str, base_url: str): + if not settings.SMSAPI_TOKEN: + return # skip in dev/test + url = f"{base_url}/p/{token_client}" + msg = f"{tenant_name}: Devizul tau e gata. Vizualizeaza: {url}" + async with httpx.AsyncClient() as c: + await c.post("https://api.smsapi.ro/sms.do", + headers={"Authorization": f"Bearer {settings.SMSAPI_TOKEN}"}, + data={"to": telefon, "message": msg, "from": "ROAAUTO"}) +``` + +```bash +pytest tests/ -v +git commit -m "feat(backend): PDF deviz + portal client + SMS + invoice service" +``` + +--- + +### TASK-008: Frontend - Portal Public + Order Detail + PDF Download +**owned_by:** frontend-agent +**depends_on:** TASK-006 +**note:** Poate fi in paralel cu TASK-007 (foloseste mock data pana backend e gata) + +`frontend/src/views/client/DevizPublicView.vue` - pagina publica mobil-first. +`frontend/src/views/orders/OrderDetailView.vue` - cu butoane: Valideaza, PDF, SMS, Factura. + +```bash +git commit -m "feat(frontend): deviz public + order detail + PDF download" +``` + +--- + +## Faza 4: Multi-user + Deployment + PWA [Saptamana 4] + +### TASK-009: Backend - Invite System + User Management +**owned_by:** backend-agent +**depends_on:** TASK-005 + +Model `invites`, endpoints invite/accept, user list/deactivate. + +```bash +git commit -m "feat(backend): invite system + user management" +``` + +--- + +### TASK-010: DevOps - Docker Production +**owned_by:** devops-agent +**depends_on:** TASK-004 + +`backend/Dockerfile` (productie, cu libpango pentru WeasyPrint): +```dockerfile +FROM python:3.12-slim +RUN apt-get update && apt-get install -y \ + libpango-1.0-0 libpangoft2-1.0-0 libpangocairo-1.0-0 \ + libgdk-pixbuf2.0-0 libcairo2 curl \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"] +``` + +`frontend/Dockerfile`: +```dockerfile +FROM node:20-alpine AS build +WORKDIR /app +COPY package*.json . +RUN npm ci +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +``` + +`frontend/nginx.conf`: +```nginx +server { + listen 80; + root /usr/share/nginx/html; + index index.html; + location / { try_files $uri $uri/ /index.html; } + location /api { + proxy_pass http://backend:8000; + proxy_set_header Host $host; + proxy_read_timeout 60s; + } +} +``` + +`docker-compose.yml` (productie): +```yaml +services: + backend: + build: ./backend + restart: unless-stopped + volumes: + - ./backend/data:/app/data + environment: + - DATABASE_URL=sqlite+aiosqlite:///./data/roaauto.db + - SECRET_KEY=${SECRET_KEY} + - SMSAPI_TOKEN=${SMSAPI_TOKEN:-} + - CORS_ORIGINS=https://roaauto.romfast.ro + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:8000/api/health || exit 1"] + interval: 30s; timeout: 10s; retries: 3 + + frontend: + build: ./frontend + restart: unless-stopped + ports: + - "80:80" + depends_on: + backend: + condition: service_healthy +``` + +```bash +docker compose build && docker compose up -d +curl http://localhost/api/health # → {"status":"ok"} +docker compose down +git commit -m "chore(deploy): Docker production + nginx + health check" +``` + +--- + +### TASK-011: Frontend - PWA + Backup/Restore + Upgrade Prompts +**owned_by:** frontend-agent +**depends_on:** TASK-008 + +PWA via vite-plugin-pwa, backup JSON export/import, upgrade banners non-intrusive. + +```bash +git commit -m "feat(frontend): PWA + backup/restore + upgrade prompts" +``` + +--- + +## Verificare Finala [Team Lead] + +Team Lead-ul face verificarea finala dupa ce toate taskurile sunt COMPLETED: + +```bash +# Backend tests +make test # Expected: toate trec + +# Frontend build +cd frontend && npm run build # fara erori + +# Docker productie +docker compose build && docker compose up -d +curl http://localhost/api/health + +# Flow manual: +# 1. Register → trial tenant +# 2. Login → full sync → 24 marci in wa-sqlite +# 3. Creeaza comanda OFFLINE (DevTools offline) +# 4. Reconnect → sync push → comanda pe server +# 5. Valideaza → descarca PDF (verifica ș ț ă î â) +# 6. Deschide /p/{token} incognito → accept +# 7. Install PWA → deschide offline → datele OK + +git tag v0.1.0 +git push origin main --tags +``` + +**Mesaj final catre echipa:** +``` +broadcast: Verificare finala trecuta! v0.1.0 gata. +Va rog sa faceti cleanup (shutddown) in ordine: backend-agent, frontend-agent, devops-agent. +Multumesc pentru colaborare! +``` + +``` +Clean up the team +``` + +--- + +## Rezumat Dependinte + +``` +TASK-001 (Lead) + ├── TASK-002 (backend-agent) ──► TASK-005 ──► TASK-007 ──► TASK-009 + ├── TASK-003 (frontend-agent) ──► TASK-006 ──► TASK-008 ──► TASK-011 + └── TASK-004 (devops-agent) ──────────────────────────────► TASK-010 +``` + +Taskurile pe acelasi nivel pot rula in paralel dupa ce dependintele sunt COMPLETED.