- 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 <noreply@anthropic.com>
1696 lines
56 KiB
Markdown
1696 lines
56 KiB
Markdown
# 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 <token>"},
|
|
"response": {"id": "str", "email": "str", "tenant_id": "str", "plan": "str", "rol": "str"}
|
|
}
|
|
},
|
|
"sync": {
|
|
"GET /sync/full": {
|
|
"headers": {"Authorization": "Bearer <token>"},
|
|
"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
|
|
<template>
|
|
<div class="relative">
|
|
<input v-model="search" @input="onSearch"
|
|
placeholder="Nr. inmatriculare sau client..."
|
|
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm
|
|
focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
|
<ul v-if="results.length"
|
|
class="absolute z-20 w-full bg-white border border-gray-200 rounded-lg mt-1
|
|
shadow-lg max-h-48 overflow-y-auto">
|
|
<li v-for="v in results" :key="v.id" @click="select(v)"
|
|
class="px-3 py-2.5 text-sm hover:bg-gray-50 cursor-pointer border-b border-gray-50 last:border-0">
|
|
<span class="font-medium text-gray-900">{{ v.nr_inmatriculare }}</span>
|
|
<span class="text-gray-500 ml-2 text-xs">{{ v.client_nume }}</span>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</template>
|
|
<script setup>
|
|
import { ref } from 'vue'
|
|
import { execSQL } from '../../db/database.js'
|
|
const emit = defineEmits(['select'])
|
|
const search = ref(''), results = ref([])
|
|
async function onSearch() {
|
|
if (search.value.length < 2) { results.value = []; return }
|
|
results.value = await execSQL(
|
|
`SELECT * FROM vehicles WHERE nr_inmatriculare LIKE ? OR client_nume LIKE ? LIMIT 10`,
|
|
[`%${search.value}%`, `%${search.value}%`]
|
|
)
|
|
}
|
|
function select(v) {
|
|
search.value = `${v.nr_inmatriculare} - ${v.client_nume}`
|
|
results.value = []
|
|
emit('select', v)
|
|
}
|
|
</script>
|
|
```
|
|
|
|
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
|
|
<!DOCTYPE html>
|
|
<html lang="ro"><head>
|
|
<meta charset="UTF-8">
|
|
<style>
|
|
@page { size: A4; margin: 2cm; }
|
|
body { font-family: DejaVu Sans, sans-serif; font-size: 11pt; color: #111; }
|
|
.header { display: flex; justify-content: space-between; margin-bottom: 24px; }
|
|
h2 { margin: 0; font-size: 16pt; }
|
|
h3 { font-size: 11pt; margin: 16px 0 6px; color: #374151; }
|
|
table { width: 100%; border-collapse: collapse; }
|
|
th { background: #f3f4f6; padding: 6px 8px; text-align: left; font-size: 10pt; }
|
|
td { padding: 5px 8px; border-bottom: 1px solid #e5e7eb; font-size: 10pt; }
|
|
.totals { margin-top: 20px; text-align: right; }
|
|
.totals div { margin-bottom: 4px; }
|
|
.total-final { font-weight: bold; font-size: 13pt; border-top: 2px solid #111; padding-top: 6px; }
|
|
</style>
|
|
</head><body>
|
|
<div class="header">
|
|
<div>
|
|
<strong>{{ tenant.nume }}</strong><br>
|
|
{% if tenant.cui %}CUI: {{ tenant.cui }}<br>{% endif %}
|
|
{% if tenant.adresa %}{{ tenant.adresa }}<br>{% endif %}
|
|
{% if tenant.telefon %}Tel: {{ tenant.telefon }}{% endif %}
|
|
</div>
|
|
<div style="text-align:right">
|
|
<h2>DEVIZ Nr. {{ order.nr_comanda or order.id[:8].upper() }}</h2>
|
|
<div>Data: {{ order.data_comanda }}</div>
|
|
<div>Auto: <strong>{{ order.nr_auto }}</strong></div>
|
|
<div>{{ order.marca_denumire }} {{ order.model_denumire }}</div>
|
|
<div>Client: {{ order.client_nume }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
{% if manopera %}
|
|
<h3>Operatii manopera</h3>
|
|
<table>
|
|
<tr><th>Descriere</th><th>Ore</th><th>Pret/ora (RON)</th><th>Total (RON)</th></tr>
|
|
{% for l in manopera %}
|
|
<tr>
|
|
<td>{{ l.descriere }}</td><td>{{ l.ore }}</td>
|
|
<td>{{ "%.2f"|format(l.pret_ora or 0) }}</td>
|
|
<td>{{ "%.2f"|format(l.total or 0) }}</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</table>
|
|
{% endif %}
|
|
|
|
{% if materiale %}
|
|
<h3>Materiale</h3>
|
|
<table>
|
|
<tr><th>Descriere</th><th>UM</th><th>Cant.</th><th>Pret unit. (RON)</th><th>Total (RON)</th></tr>
|
|
{% for l in materiale %}
|
|
<tr>
|
|
<td>{{ l.descriere }}</td><td>{{ l.um }}</td><td>{{ l.cantitate }}</td>
|
|
<td>{{ "%.2f"|format(l.pret_unitar or 0) }}</td>
|
|
<td>{{ "%.2f"|format(l.total or 0) }}</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</table>
|
|
{% endif %}
|
|
|
|
<div class="totals">
|
|
<div>Manopera: {{ "%.2f"|format(order.total_manopera or 0) }} RON</div>
|
|
<div>Materiale: {{ "%.2f"|format(order.total_materiale or 0) }} RON</div>
|
|
<div class="total-final">TOTAL: {{ "%.2f"|format(order.total_general or 0) }} RON</div>
|
|
</div>
|
|
</body></html>
|
|
```
|
|
|
|
`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.
|