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