Compare commits

...

22 Commits

Author SHA1 Message Date
Claude Agent
d93a7b2903 fix(start): remove alembic stamp head fallback to prevent silent migration skip
stamp head was masking failed migrations by marking them as applied
without actually running them, causing missing columns at runtime.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 23:17:20 +00:00
Claude Agent
16da72daff start 2026-03-13 23:08:46 +00:00
9db4e746e3 feat: add clients nomenclator, order edit/delete/devalidate, invoice types, dashboard redesign
- New clients table with PF/PJ support, fiscal data (CUI, IBAN, eFactura fields)
- Full CRUD API for clients with search, sync integration
- Order lifecycle: edit header (DRAFT), devalidate (VALIDAT→DRAFT), delete order/invoice
- Invoice types: FACTURA (B2B) vs BON_FISCAL (B2C) with different nr formats
- OrderCreateView redesigned as multi-step flow (client→vehicle→details)
- Autocomplete from catalog_norme/catalog_preturi in OrderLineForm
- Dashboard now combines stats + full orders table with filter tabs and search
- ClientPicker and VehiclePicker with inline creation capability
- Frontend schema aligned with backend (missing columns causing sync errors)
- Mobile responsive fixes for OrderDetailView buttons

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 00:36:40 +02:00
3e449d0b0b claude 2026-03-13 20:38:57 +02:00
2ccc062bc1 chore: ignore playwright-mcp directory
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 20:38:11 +02:00
9644833db9 fix(backend): seed.py add __main__ + seed_demo for demo tenant creation 2026-03-13 19:15:15 +02:00
165890b07d fix(backend): alembic migration SQLite compat + sync push rollback on error 2026-03-13 19:05:35 +02:00
1e96db4d91 fix(backend): sync push error handling + validation
- apply_push now uses PRAGMA table_info() to get valid column names per
  table and filters incoming data to only known columns, preventing
  "no such column" SQLite errors from frontend-only fields like oracle_id
- Wrap each operation in try/except so one bad op doesn't abort the
  whole batch; errors are returned in the conflicts list instead of 500
- Add server_default to all NOT NULL float/int columns so raw SQL
  INSERT OR REPLACE without those fields still succeeds
- Align DB models with frontend schema.js:
  - orders: add nr_comanda, client_nume, client_telefon, nr_auto,
    marca_denumire, model_denumire, created_by
  - order_lines: add norma_id, mecanic_id, ordine
  - vehicles: add serie_sasiu, client_cod_fiscal (keep vin, client_cui
    for REST API compat)
  - catalog_*: rename nume → denumire to match frontend schema
  - catalog_norme: align fields (cod, denumire, ore_normate)
  - invoices: add serie_factura, modalitate_plata, client_cod_fiscal,
    nr_auto, total_fara_tva, tva, total_general; keep total for compat
  - appointments: add client_nume, client_telefon, data_ora, durata_minute,
    status, order_id
  - mecanici: add user_id, prenume, activ
- Fix seed.py to use denumire= instead of nome= after catalog rename
- Add 3 new sync push tests covering order insert with frontend fields,
  unknown column filtering, and order_line with norma_id/mecanic_id/ordine

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 18:45:31 +02:00
9aef3d6933 feat(frontend): wa-sqlite memory-only + Factureaza + Vite polling + Appointments + Catalog
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 18:37:08 +02:00
78d2a77b0d chore: configure gitignore 2026-03-13 17:40:28 +02:00
8c0346e41f feat(backend): invite system + user management
- InviteToken model with unique token for each invite
- POST /users/invite - create invite by email with role (admin/mecanic)
- POST /auth/accept-invite - accept invite, set password, return JWT
- GET /users - list all users in tenant
- DELETE /users/{id} - deactivate user (cannot deactivate owner)
- Alembic migration for invites table
- 25 passing tests (auth + sync + orders + pdf + portal + invoices + users)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 17:37:06 +02:00
5fa72e4323 fix(frontend): add COOP/COEP headers for SharedArrayBuffer (wa-sqlite WASM)
Dev server now sends Cross-Origin-Opener-Policy and
Cross-Origin-Embedder-Policy headers required for WASM
SharedArrayBuffer support used by wa-sqlite.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 17:36:17 +02:00
1686efeead feat(frontend): PWA + Backup/Restore + Upgrade Prompts + Settings
- PWA: vite-plugin-pwa with autoUpdate service worker, manifest, offline
  caching for assets + NetworkFirst for API calls
- PWA icons: 192x192 and 512x512 placeholder PNGs + favicon.svg
- index.html: theme-color, apple-touch-icon, description meta tags
- UpgradeBanner component: trial expiry warning with upgrade CTA
- SettingsView: complete settings page with:
  - Plan info display
  - Tenant profile form (firma, CUI, reg com, adresa, IBAN, banca)
  - Backup export (JSON with all tenant data from wa-sqlite)
  - Restore import (JSON file with validation and INSERT OR REPLACE)
  - User management with invite form (email + rol)
  - Logout button
- useOffline composable: shared reactive online/offline state
- DashboardView: added UpgradeBanner at top

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 17:35:06 +02:00
3bdafad22a feat(backend): PDF deviz + portal client + SMS + invoice service
- PDF generation with WeasyPrint: deviz and factura templates (A4, branding)
- GET /orders/{id}/pdf/deviz returns PDF with order lines and totals
- Client portal (public, no auth): GET /p/{token}, POST /p/{token}/accept|reject
- SMS service (SMSAPI.ro) - skips in dev when no token configured
- Invoice service: create from validated order, auto-number (F-YYYY-NNNN)
- GET /invoices/{id}/pdf returns factura PDF
- Order status_client field for client accept/reject tracking
- Alembic migration for status_client
- 19 passing tests (auth + sync + orders + pdf + portal + invoices)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 17:34:36 +02:00
efc9545ae6 feat(frontend): Portal public client + PDF download + Facturi view
- DevizPublicView: standalone public page /p/:token (no auth, no layout)
  - Loads order/tenant/lines from backend API
  - Accept/Reject buttons with feedback banners
  - Mobile-first design with service branding
- usePdf composable: fetch PDF blob from backend and trigger browser download
- PdfDownloadButton component: reusable button for deviz/invoice PDF download
- InvoicesView: table with invoice list from wa-sqlite, PDF download per row
- OrderDetailView: added PDF Deviz download button (visible when not DRAFT)
- Router: added /invoices route, portal /p/:token uses layout: 'none'
- App.vue: supports layout: 'none' for standalone pages
- AppLayout: added Facturi link in sidebar nav

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 17:31:41 +02:00
3a922a50e6 feat(backend): sync endpoints + all models + seed + order workflow
- All business models: Vehicle, Order, OrderLine, Invoice, Appointment,
  CatalogMarca/Model/Ansamblu/Norma/Pret/TipDeviz/TipMotor, Mecanic
- Sync endpoints: GET /sync/full, GET /sync/changes?since=, POST /sync/push
  with tenant isolation and last-write-wins conflict resolution
- Order CRUD with state machine: DRAFT -> VALIDAT -> FACTURAT
  Auto-recalculates totals (manopera + materiale)
- Vehicle CRUD: list, create, get, update
- Seed data: 24 marci, 11 ansamble, 6 tipuri deviz, 5 tipuri motoare, 3 preturi
- Alembic migration for all business models
- 13 passing tests (auth + sync + orders)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 17:31:02 +02:00
ad41956ea1 feat(frontend): Dashboard + Orders UI + Vehicle Picker + Vehicles list
- Pinia stores: orders (CRUD, line management, totals recalc, stats) and vehicles (CRUD, search, marca/model cascade)
- useSync composable: auto-sync on window focus + periodic 60s interval
- VehiclePicker component: debounced autocomplete search by nr. inmatriculare or client name
- OrderLineForm component: manopera/material toggle with live total preview
- DashboardView: stats cards (orders, vehicles, revenue), recent orders list
- OrdersListView: filterable table (all/draft/validat/facturat), clickable rows
- OrderCreateView: vehicle picker + inline new vehicle form, tip deviz select, km/observatii
- OrderDetailView: order info, lines table with add/remove, totals, validate action
- VehiclesListView: searchable table, inline create form with marca/model cascade
- AppLayout: mobile hamburger menu with slide-in sidebar overlay

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 17:29:02 +02:00
907b7be0fd feat(backend): FastAPI + libSQL + auth register/login/me + tests (TDD)
- FastAPI app with lifespan, CORS, health endpoint
- SQLAlchemy 2.0 async with aiosqlite, Base/UUIDMixin/TenantMixin/TimestampMixin
- Tenant and User models with multi-tenant isolation
- Auth: register (creates tenant+user), login, /me endpoint
- JWT HS256 tokens, bcrypt password hashing
- Alembic async setup with initial migration
- 6 passing tests (register, login, wrong password, me, no token, health)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 17:26:31 +02:00
c3482bba8d feat(frontend): Vue 3 + wa-sqlite + sync engine + auth + layouts
- package.json with Vue 3, Pinia, vue-router, wa-sqlite, Tailwind CSS 4, Vite
- wa-sqlite database layer with IDBBatchAtomicVFS (offline-first)
- Full schema mirroring backend tables (vehicles, orders, invoices, etc.)
- SyncEngine: fullSync, incrementalSync, pushQueue for offline queue
- Auth store with JWT parsing, login/register, plan tier detection
- Router with all routes and auth navigation guards
- AppLayout (sidebar desktop / bottom nav mobile) + AuthLayout
- Login/Register views connected to API contract
- SyncIndicator component (online/offline status)
- Reactive SQL query composable (useSqlQuery)
- Placeholder views for dashboard, orders, vehicles, appointments, catalog, settings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 17:22:50 +02:00
a16d01a669 chore(deploy): Docker production config, nginx gzip/cache, deploy docs
- docker-compose.yml: restart always, env_file, named volume, start_period
- frontend/nginx.conf: gzip compression, cache headers for assets, no-cache for SW
- Makefile: add prod-down and prod-logs targets
- docs/DEPLOY.md: Dokploy + Cloudflare Tunnel deploy instructions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 17:20:36 +02:00
6f82c56995 chore(devops): docker-compose dev + prod, Dockerfiles, nginx, Makefile
- backend/Dockerfile: Python 3.12 slim, non-root user, WeasyPrint system deps
- backend/Dockerfile.dev: dev variant with hot-reload support
- frontend/Dockerfile: Node 20 alpine build + nginx:alpine serve
- frontend/nginx.conf: SPA routing + /api proxy to backend:8000
- docker-compose.yml: production with healthcheck
- docker-compose.dev.yml: dev with volume mounts and hot-reload
- Makefile: dev, build, up, down, logs, migrate, test, shell, prod targets
- .dockerignore for backend and frontend

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 17:19:12 +02:00
1ab109b1d4 chore: project structure + API contract 2026-03-13 17:17:22 +02:00
134 changed files with 16487 additions and 4 deletions

6
.env.example Normal file
View File

@@ -0,0 +1,6 @@
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

47
.gitignore vendored
View File

@@ -1 +1,48 @@
HANDOFF.md
.claude/HANDOFF.md
# Playwright MCP
.playwright-mcp/
# Python
__pycache__/
*.py[cod]
*.pyo
.venv/
venv/
*.egg-info/
dist/
.pytest_cache/
.mypy_cache/
.ruff_cache/
# Environment
.env
*.env.local
# Database
backend/data/*.db
backend/data/*.db-shm
backend/data/*.db-wal
# Alembic
backend/alembic/versions/*.pyc
# Node
frontend/node_modules/
frontend/dist/
frontend/.vite/
# PWA
frontend/public/sw.js
frontend/public/workbox-*.js
frontend/public/sw.js.map
# OS
.DS_Store
Thumbs.db
# IDE
.vscode/
.idea/
*.swp

107
CLAUDE.md Normal file
View File

@@ -0,0 +1,107 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
ROA AUTO is a multi-tenant auto service management PWA. The architecture is **offline-first**: the Vue 3 frontend uses wa-sqlite (WebAssembly SQLite) running entirely in the browser as `:memory:`, with a sync engine that pulls from and pushes to the FastAPI backend. All business data lives locally in the browser's in-memory SQLite DB and is periodically synced.
## Development Commands
### Backend (FastAPI)
```bash
cd backend
source .venv/bin/activate
uvicorn app.main:app --port 8000 --reload # start dev server
pytest tests/ -v # run all tests
pytest tests/test_orders.py -v # run single test file
pytest tests/test_orders.py::test_name -v # run single test
```
### Frontend (Vue 3 + Vite)
```bash
cd frontend
npm run dev # start dev server (port 5173)
npm run build # production build
```
### Docker (preferred for full stack)
```bash
make dev # start all services (docker-compose.dev.yml)
make test # run backend tests in container
make migrate # run alembic migrations
make seed # seed demo data
```
## Architecture
### Sync Model (critical to understand)
- **Frontend writes locally first**, then queues changes in `_sync_queue` (SQLite table)
- `SyncEngine` (`frontend/src/db/sync.js`) handles three operations:
- `fullSync()` — downloads all tenant data from `/api/sync/full` on login
- `incrementalSync()` — downloads changes since `last_sync_at`
- `pushQueue()` — uploads queued local changes to `/api/sync/push`
- The backend sync service (`backend/app/sync/service.py`) defines `SYNCABLE_TABLES` — only these tables are synced
- IDs are `crypto.randomUUID()` generated client-side; backend uses `INSERT OR REPLACE`
### Frontend (`frontend/src/`)
- **`db/database.js`** — singleton wa-sqlite instance. Uses `:memory:` only (no IndexedDB persistence). `execSQL(sql, params)` is the only query interface. Parameterized queries **must** use `sqlite3.statements()` async generator — `exec()` does not support `?` params. Do NOT use `prepare_v2` (doesn't exist in this library).
- **`db/schema.js`** — full SQLite schema as a string constant, applied on init
- **`db/sync.js`** — `SyncEngine` class, exported as singleton `syncEngine`
- **`stores/`** — Pinia stores (`auth.js`, `orders.js`, `vehicles.js`) wrapping `execSQL` calls
- **`views/`** — one file per route; data loaded via `execSQL` directly or through stores
- **Reactivity** — `notifyTableChanged(table)` / `onTableChange(table, cb)` in `database.js` provide a pub/sub for cross-component updates. Call `notifyTableChanged` after any write.
### Backend (`backend/app/`)
- **Multi-tenant**: every model has `tenant_id`. All queries filter by `tenant_id` from the JWT.
- **Auth**: JWT in `Authorization: Bearer` header. `get_tenant_id` dep extracts tenant from token.
- **`deps.py`** — `get_current_user` and `get_tenant_id` FastAPI dependencies used across all routers
- **`sync/`** — The most complex module. `apply_push` does `INSERT OR REPLACE` for all ops.
- **`pdf/`** — WeasyPrint + Jinja2 HTML templates to generate PDF deviz/factura
- **`client_portal/`** — Public routes (no auth) for client-facing deviz view via `token_client`
- **DB**: SQLite via SQLAlchemy async (`aiosqlite`). Alembic for migrations (files in `alembic/versions/`).
### Key Data Flow: Order Lifecycle
`DRAFT``VALIDAT``FACTURAT`
- Order lines (manopera/material) added only in DRAFT
- PDF deviz available after VALIDAT
- Invoice created locally in `handleFactureaza()` then queued for sync
## Critical wa-sqlite Notes
- Import: `wa-sqlite.mjs` (sync WASM build), NOT `wa-sqlite-async.mjs`
- All API methods (`open_v2`, `exec`, `step`, `finalize`) return Promises — always `await`
- For parameterized queries: `for await (const stmt of sqlite3.statements(db, sql))` then `sqlite3.bind_collection(stmt, params)`
- `vite.config.js` excludes `@journeyapps/wa-sqlite` from `optimizeDeps`
- COOP/COEP headers (`Cross-Origin-Opener-Policy: same-origin` + `Cross-Origin-Embedder-Policy: require-corp`) are required for SharedArrayBuffer used by wa-sqlite — set in vite dev server and nginx
## WSL2 Note
Running on WSL2 with code on Windows NTFS (`/mnt/e/`): Vite is configured with `server.watch.usePolling: true, interval: 1000` to work around inotify not firing on NTFS mounts.
## Testing
- Backend tests use an in-memory SQLite DB (overrides `get_db` via `app.dependency_overrides`)
- `asyncio_mode = auto` set in `pytest.ini` — no need to mark tests with `@pytest.mark.asyncio`
- `auth_headers` fixture registers a user and returns `Authorization` header for authenticated tests
- Demo credentials (after `make seed`): `demo@roaauto.ro` / `demo123`
## Execution Preferences
- Executia task-urilor se face cu **team agents** (nu cu skill superpowers subagents)
- **Playwright testing obligatoriu**: orice implementare majora trebuie testata E2E cu Playwright
- Desktop: 1280x720, Mobile: 375x812
- Verificari: responsive, elemente nu se suprapun, nu ies din ecran, butoane cu gap suficient
- Raportul Playwright se salveaza in `docs/playwright-report-YYYY-MM-DD.md`
## Data Model: Clients
- Tabel `clients` - nomenclator clienti cu date eFactura ANAF, separat de vehicule
- Un client poate avea mai multe vehicule (1:N prin client_id pe vehicles)
- `tip_persoana`: PF (persoana fizica) / PJ (persoana juridica)
- Campuri eFactura: cod_fiscal, reg_com, adresa, judet, oras, cod_postal, cont_iban, banca
## Data Model: Invoices
- `tip_document`: FACTURA (B2B, eFactura ANAF) sau BON_FISCAL (B2C, casa de marcat)
- Factura: necesita date client complete (CUI, adresa)
- Bon fiscal: format simplificat
## Order Lifecycle
- `DRAFT``VALIDAT``FACTURAT` (cu devalidare VALIDAT → DRAFT)
- Stergere: orice nefacturat; FACTURAT = sterge factura intai
- Edit header doar in DRAFT

51
Makefile Normal file
View File

@@ -0,0 +1,51 @@
.PHONY: dev build up down logs migrate test shell-backend shell-frontend seed backup prod-build prod-up prod-down prod-logs
# Development
dev:
docker compose -f docker-compose.dev.yml up
build:
docker compose -f docker-compose.dev.yml build
up:
docker compose -f docker-compose.dev.yml up -d
down:
docker compose -f docker-compose.dev.yml down
logs:
docker compose -f docker-compose.dev.yml logs -f
# Database
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
# Testing
test:
docker compose -f docker-compose.dev.yml exec backend pytest tests/ -v
# Shell access
shell-backend:
docker compose -f docker-compose.dev.yml exec backend bash
shell-frontend:
docker compose -f docker-compose.dev.yml exec frontend sh
# Production
prod-build:
docker compose build
prod-up:
docker compose up -d
prod-down:
docker compose down
prod-logs:
docker compose logs -f

11
backend/.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
__pycache__
*.pyc
*.pyo
.pytest_cache
.env
data/*.db
data/backup-*
.git
.gitignore
tests/
*.md

6
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
.venv/
__pycache__/
*.pyc
*.db
.pytest_cache/
*.egg-info/

20
backend/Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
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/*
RUN useradd -m -s /bin/bash appuser
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN chown -R appuser:appuser /app
USER appuser
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]

20
backend/Dockerfile.dev Normal file
View File

@@ -0,0 +1,20 @@
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 \
&& rm -rf /var/lib/apt/lists/*
RUN useradd -m -s /bin/bash appuser
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN chown -R appuser:appuser /app
USER appuser
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

36
backend/alembic.ini Normal file
View File

@@ -0,0 +1,36 @@
[alembic]
script_location = alembic
sqlalchemy.url = sqlite+aiosqlite:///./data/roaauto.db
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

50
backend/alembic/env.py Normal file
View File

@@ -0,0 +1,50 @@
import asyncio
from logging.config import fileConfig
from alembic import context
from sqlalchemy.ext.asyncio import create_async_engine
from app.config import settings
from app.db.base import Base
import app.db.models # noqa: F401
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = settings.DATABASE_URL
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection):
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
connectable = create_async_engine(settings.DATABASE_URL)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,44 @@
"""add_invites_table
Revision ID: 1a4da27efc65
Revises: eec3c13599e7
Create Date: 2026-03-13 17:36:58.364672
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '1a4da27efc65'
down_revision: Union[str, None] = 'eec3c13599e7'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('invites',
sa.Column('email', sa.String(length=200), nullable=False),
sa.Column('rol', sa.String(length=20), nullable=False),
sa.Column('token', sa.String(length=36), nullable=False),
sa.Column('used', sa.Text(), nullable=True),
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('created_at', sa.Text(), nullable=False),
sa.Column('updated_at', sa.Text(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_invites_tenant_id'), 'invites', ['tenant_id'], unique=False)
op.create_index(op.f('ix_invites_token'), 'invites', ['token'], unique=True)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_invites_token'), table_name='invites')
op.drop_index(op.f('ix_invites_tenant_id'), table_name='invites')
op.drop_table('invites')
# ### end Alembic commands ###

View File

@@ -0,0 +1,73 @@
"""add_clients_table_and_client_id_columns
Revision ID: 6d8b5bd44531
Revises: 7df0fb1c1e6f
Create Date: 2026-03-14 10:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '6d8b5bd44531'
down_revision: Union[str, None] = '7df0fb1c1e6f'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create clients table
op.create_table(
'clients',
sa.Column('id', sa.String(length=36), primary_key=True),
sa.Column('tenant_id', sa.String(length=36), nullable=False, index=True),
sa.Column('created_at', sa.Text(), nullable=True),
sa.Column('updated_at', sa.Text(), nullable=True),
sa.Column('tip_persoana', sa.String(length=2), nullable=True),
sa.Column('denumire', sa.String(length=200), nullable=True),
sa.Column('nume', sa.String(length=100), nullable=True),
sa.Column('prenume', sa.String(length=100), nullable=True),
sa.Column('cod_fiscal', sa.String(length=20), nullable=True),
sa.Column('reg_com', sa.String(length=30), nullable=True),
sa.Column('telefon', sa.String(length=20), nullable=True),
sa.Column('email', sa.String(length=200), nullable=True),
sa.Column('adresa', sa.Text(), nullable=True),
sa.Column('judet', sa.String(length=50), nullable=True),
sa.Column('oras', sa.String(length=100), nullable=True),
sa.Column('cod_postal', sa.String(length=10), nullable=True),
sa.Column('tara', sa.String(length=2), nullable=True),
sa.Column('cont_iban', sa.String(length=34), nullable=True),
sa.Column('banca', sa.String(length=100), nullable=True),
sa.Column('activ', sa.Integer(), server_default='1', nullable=False),
sa.Column('oracle_id', sa.Integer(), nullable=True),
)
# Add client_id to vehicles
with op.batch_alter_table('vehicles') as batch_op:
batch_op.add_column(sa.Column('client_id', sa.String(length=36), nullable=True))
# Add client_id to orders
with op.batch_alter_table('orders') as batch_op:
batch_op.add_column(sa.Column('client_id', sa.String(length=36), nullable=True))
# Add client_id and tip_document to invoices
with op.batch_alter_table('invoices') as batch_op:
batch_op.add_column(sa.Column('client_id', sa.String(length=36), nullable=True))
batch_op.add_column(sa.Column('tip_document', sa.String(length=20), server_default='FACTURA', nullable=True))
def downgrade() -> None:
with op.batch_alter_table('invoices') as batch_op:
batch_op.drop_column('tip_document')
batch_op.drop_column('client_id')
with op.batch_alter_table('orders') as batch_op:
batch_op.drop_column('client_id')
with op.batch_alter_table('vehicles') as batch_op:
batch_op.drop_column('client_id')
op.drop_table('clients')

View File

@@ -0,0 +1,126 @@
"""sync_schema_alignment
Revision ID: 7df0fb1c1e6f
Revises: 1a4da27efc65
Create Date: 2026-03-13 18:46:36.554590
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '7df0fb1c1e6f'
down_revision: Union[str, None] = '1a4da27efc65'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('appointments', sa.Column('client_nume', sa.String(length=200), nullable=True))
op.add_column('appointments', sa.Column('client_telefon', sa.String(length=20), nullable=True))
op.add_column('appointments', sa.Column('data_ora', sa.Text(), nullable=True))
op.add_column('appointments', sa.Column('durata_minute', sa.Integer(), server_default='60', nullable=False))
op.add_column('appointments', sa.Column('observatii', sa.Text(), nullable=True))
op.add_column('appointments', sa.Column('status', sa.String(length=20), server_default='PROGRAMAT', nullable=False))
op.add_column('appointments', sa.Column('order_id', sa.String(length=36), nullable=True))
op.drop_column('appointments', 'descriere')
op.drop_column('appointments', 'data')
op.add_column('catalog_ansamble', sa.Column('denumire', sa.String(length=100), nullable=False))
op.drop_column('catalog_ansamble', 'nume')
op.add_column('catalog_marci', sa.Column('denumire', sa.String(length=100), nullable=False))
op.add_column('catalog_marci', sa.Column('activ', sa.Integer(), server_default='1', nullable=False))
op.drop_column('catalog_marci', 'nume')
op.add_column('catalog_modele', sa.Column('denumire', sa.String(length=100), nullable=False))
op.drop_column('catalog_modele', 'nume')
op.add_column('catalog_norme', sa.Column('cod', sa.String(length=50), nullable=True))
op.add_column('catalog_norme', sa.Column('denumire', sa.Text(), nullable=False))
op.add_column('catalog_norme', sa.Column('ore_normate', sa.Float(), server_default='0', nullable=False))
op.drop_column('catalog_norme', 'descriere')
op.drop_column('catalog_norme', 'ore')
op.add_column('catalog_tipuri_deviz', sa.Column('denumire', sa.String(length=100), nullable=False))
op.drop_column('catalog_tipuri_deviz', 'nume')
op.add_column('catalog_tipuri_motoare', sa.Column('denumire', sa.String(length=50), nullable=False))
op.drop_column('catalog_tipuri_motoare', 'nume')
op.add_column('invoices', sa.Column('serie_factura', sa.String(length=20), nullable=True))
op.add_column('invoices', sa.Column('modalitate_plata', sa.String(length=50), nullable=True))
op.add_column('invoices', sa.Column('client_nume', sa.String(length=200), nullable=True))
op.add_column('invoices', sa.Column('client_cod_fiscal', sa.String(length=20), nullable=True))
op.add_column('invoices', sa.Column('nr_auto', sa.String(length=20), nullable=True))
op.add_column('invoices', sa.Column('total_fara_tva', sa.Float(), server_default='0', nullable=False))
op.add_column('invoices', sa.Column('tva', sa.Float(), server_default='0', nullable=False))
op.add_column('invoices', sa.Column('total_general', sa.Float(), server_default='0', nullable=False))
op.add_column('mecanici', sa.Column('user_id', sa.String(length=36), nullable=True))
op.add_column('mecanici', sa.Column('prenume', sa.String(length=200), nullable=True))
op.add_column('mecanici', sa.Column('activ', sa.Integer(), server_default='1', nullable=False))
op.drop_column('mecanici', 'telefon')
op.add_column('order_lines', sa.Column('norma_id', sa.String(length=36), nullable=True))
op.add_column('order_lines', sa.Column('mecanic_id', sa.String(length=36), nullable=True))
op.add_column('order_lines', sa.Column('ordine', sa.Integer(), nullable=True))
op.add_column('orders', sa.Column('nr_comanda', sa.String(length=50), nullable=True))
op.add_column('orders', sa.Column('client_nume', sa.String(length=200), nullable=True))
op.add_column('orders', sa.Column('client_telefon', sa.String(length=20), nullable=True))
op.add_column('orders', sa.Column('nr_auto', sa.String(length=20), nullable=True))
op.add_column('orders', sa.Column('marca_denumire', sa.String(length=100), nullable=True))
op.add_column('orders', sa.Column('model_denumire', sa.String(length=100), nullable=True))
op.add_column('orders', sa.Column('created_by', sa.String(length=36), nullable=True))
op.add_column('vehicles', sa.Column('serie_sasiu', sa.String(length=50), nullable=True))
op.add_column('vehicles', sa.Column('client_cod_fiscal', sa.String(length=20), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('vehicles', 'client_cod_fiscal')
op.drop_column('vehicles', 'serie_sasiu')
op.drop_column('orders', 'created_by')
op.drop_column('orders', 'model_denumire')
op.drop_column('orders', 'marca_denumire')
op.drop_column('orders', 'nr_auto')
op.drop_column('orders', 'client_telefon')
op.drop_column('orders', 'client_nume')
op.drop_column('orders', 'nr_comanda')
op.drop_column('order_lines', 'ordine')
op.drop_column('order_lines', 'mecanic_id')
op.drop_column('order_lines', 'norma_id')
op.add_column('mecanici', sa.Column('telefon', sa.VARCHAR(length=20), nullable=True))
op.drop_column('mecanici', 'activ')
op.drop_column('mecanici', 'prenume')
op.drop_column('mecanici', 'user_id')
op.drop_column('invoices', 'total_general')
op.drop_column('invoices', 'tva')
op.drop_column('invoices', 'total_fara_tva')
op.drop_column('invoices', 'nr_auto')
op.drop_column('invoices', 'client_cod_fiscal')
op.drop_column('invoices', 'client_nume')
op.drop_column('invoices', 'modalitate_plata')
op.drop_column('invoices', 'serie_factura')
op.add_column('catalog_tipuri_motoare', sa.Column('nume', sa.VARCHAR(length=50), nullable=False))
op.drop_column('catalog_tipuri_motoare', 'denumire')
op.add_column('catalog_tipuri_deviz', sa.Column('nume', sa.VARCHAR(length=100), nullable=False))
op.drop_column('catalog_tipuri_deviz', 'denumire')
op.add_column('catalog_norme', sa.Column('ore', sa.FLOAT(), nullable=False))
op.add_column('catalog_norme', sa.Column('descriere', sa.TEXT(), nullable=False))
op.drop_column('catalog_norme', 'ore_normate')
op.drop_column('catalog_norme', 'denumire')
op.drop_column('catalog_norme', 'cod')
op.add_column('catalog_modele', sa.Column('nume', sa.VARCHAR(length=100), nullable=False))
op.drop_column('catalog_modele', 'denumire')
op.add_column('catalog_marci', sa.Column('nume', sa.VARCHAR(length=100), nullable=False))
op.drop_column('catalog_marci', 'activ')
op.drop_column('catalog_marci', 'denumire')
op.add_column('catalog_ansamble', sa.Column('nume', sa.VARCHAR(length=100), nullable=False))
op.drop_column('catalog_ansamble', 'denumire')
op.add_column('appointments', sa.Column('data', sa.TEXT(), nullable=False))
op.add_column('appointments', sa.Column('descriere', sa.TEXT(), nullable=True))
op.drop_column('appointments', 'order_id')
op.drop_column('appointments', 'status')
op.drop_column('appointments', 'observatii')
op.drop_column('appointments', 'durata_minute')
op.drop_column('appointments', 'data_ora')
op.drop_column('appointments', 'client_telefon')
op.drop_column('appointments', 'client_nume')
# ### end Alembic commands ###

View File

@@ -0,0 +1,62 @@
"""initial_tenants_users
Revision ID: 88221cd8e1c3
Revises:
Create Date: 2026-03-13 17:25:56.158996
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '88221cd8e1c3'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('tenants',
sa.Column('nume', sa.String(length=200), nullable=False),
sa.Column('cui', sa.String(length=20), nullable=True),
sa.Column('reg_com', sa.String(length=30), nullable=True),
sa.Column('adresa', sa.Text(), nullable=True),
sa.Column('telefon', sa.String(length=20), nullable=True),
sa.Column('email', sa.String(length=200), nullable=True),
sa.Column('iban', sa.String(length=34), nullable=True),
sa.Column('banca', sa.String(length=100), nullable=True),
sa.Column('plan', sa.String(length=20), nullable=False),
sa.Column('trial_expires_at', sa.Text(), nullable=True),
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('created_at', sa.Text(), nullable=False),
sa.Column('updated_at', sa.Text(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_table('users',
sa.Column('email', sa.String(length=200), nullable=False),
sa.Column('password_hash', sa.String(length=200), nullable=False),
sa.Column('nume', sa.String(length=200), nullable=False),
sa.Column('rol', sa.String(length=20), nullable=False),
sa.Column('activ', sa.Boolean(), nullable=False),
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('created_at', sa.Text(), nullable=False),
sa.Column('updated_at', sa.Text(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
op.create_index(op.f('ix_users_tenant_id'), 'users', ['tenant_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_users_tenant_id'), table_name='users')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_table('users')
op.drop_table('tenants')
# ### end Alembic commands ###

View File

@@ -0,0 +1,30 @@
"""add_order_status_client
Revision ID: eec3c13599e7
Revises: fbbfad4cd8f3
Create Date: 2026-03-13 17:34:26.366597
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'eec3c13599e7'
down_revision: Union[str, None] = 'fbbfad4cd8f3'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('orders', sa.Column('status_client', sa.String(length=20), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('orders', 'status_client')
# ### end Alembic commands ###

View File

@@ -0,0 +1,218 @@
"""all_business_models
Revision ID: fbbfad4cd8f3
Revises: 88221cd8e1c3
Create Date: 2026-03-13 17:30:47.251556
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'fbbfad4cd8f3'
down_revision: Union[str, None] = '88221cd8e1c3'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('appointments',
sa.Column('vehicle_id', sa.String(length=36), nullable=False),
sa.Column('data', sa.Text(), nullable=False),
sa.Column('descriere', sa.Text(), nullable=True),
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('created_at', sa.Text(), nullable=False),
sa.Column('updated_at', sa.Text(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_appointments_tenant_id'), 'appointments', ['tenant_id'], unique=False)
op.create_table('catalog_ansamble',
sa.Column('nume', sa.String(length=100), nullable=False),
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('created_at', sa.Text(), nullable=False),
sa.Column('updated_at', sa.Text(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_catalog_ansamble_tenant_id'), 'catalog_ansamble', ['tenant_id'], unique=False)
op.create_table('catalog_marci',
sa.Column('nume', sa.String(length=100), nullable=False),
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('created_at', sa.Text(), nullable=False),
sa.Column('updated_at', sa.Text(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_catalog_marci_tenant_id'), 'catalog_marci', ['tenant_id'], unique=False)
op.create_table('catalog_modele',
sa.Column('marca_id', sa.String(length=36), nullable=False),
sa.Column('nume', sa.String(length=100), nullable=False),
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('created_at', sa.Text(), nullable=False),
sa.Column('updated_at', sa.Text(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_catalog_modele_marca_id'), 'catalog_modele', ['marca_id'], unique=False)
op.create_table('catalog_norme',
sa.Column('ansamblu_id', sa.String(length=36), nullable=False),
sa.Column('descriere', sa.Text(), nullable=False),
sa.Column('ore', sa.Float(), nullable=False),
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('created_at', sa.Text(), nullable=False),
sa.Column('updated_at', sa.Text(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_catalog_norme_ansamblu_id'), 'catalog_norme', ['ansamblu_id'], unique=False)
op.create_index(op.f('ix_catalog_norme_tenant_id'), 'catalog_norme', ['tenant_id'], unique=False)
op.create_table('catalog_preturi',
sa.Column('denumire', sa.String(length=200), nullable=False),
sa.Column('pret', sa.Float(), nullable=False),
sa.Column('um', sa.String(length=10), nullable=False),
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('created_at', sa.Text(), nullable=False),
sa.Column('updated_at', sa.Text(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_catalog_preturi_tenant_id'), 'catalog_preturi', ['tenant_id'], unique=False)
op.create_table('catalog_tipuri_deviz',
sa.Column('nume', sa.String(length=100), nullable=False),
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('created_at', sa.Text(), nullable=False),
sa.Column('updated_at', sa.Text(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_catalog_tipuri_deviz_tenant_id'), 'catalog_tipuri_deviz', ['tenant_id'], unique=False)
op.create_table('catalog_tipuri_motoare',
sa.Column('nume', sa.String(length=50), nullable=False),
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('created_at', sa.Text(), nullable=False),
sa.Column('updated_at', sa.Text(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_catalog_tipuri_motoare_tenant_id'), 'catalog_tipuri_motoare', ['tenant_id'], unique=False)
op.create_table('invoices',
sa.Column('order_id', sa.String(length=36), nullable=False),
sa.Column('nr_factura', sa.String(length=50), nullable=False),
sa.Column('data_factura', sa.Text(), nullable=True),
sa.Column('total', sa.Float(), nullable=False),
sa.Column('status', sa.String(length=20), nullable=False),
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('created_at', sa.Text(), nullable=False),
sa.Column('updated_at', sa.Text(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_invoices_order_id'), 'invoices', ['order_id'], unique=False)
op.create_index(op.f('ix_invoices_tenant_id'), 'invoices', ['tenant_id'], unique=False)
op.create_table('mecanici',
sa.Column('nume', sa.String(length=200), nullable=False),
sa.Column('telefon', sa.String(length=20), nullable=True),
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('created_at', sa.Text(), nullable=False),
sa.Column('updated_at', sa.Text(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_mecanici_tenant_id'), 'mecanici', ['tenant_id'], unique=False)
op.create_table('order_lines',
sa.Column('order_id', sa.String(length=36), nullable=False),
sa.Column('tip', sa.String(length=20), nullable=False),
sa.Column('descriere', sa.Text(), nullable=False),
sa.Column('ore', sa.Float(), nullable=False),
sa.Column('pret_ora', sa.Float(), nullable=False),
sa.Column('cantitate', sa.Float(), nullable=False),
sa.Column('pret_unitar', sa.Float(), nullable=False),
sa.Column('um', sa.String(length=10), nullable=True),
sa.Column('total', sa.Float(), nullable=False),
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('created_at', sa.Text(), nullable=False),
sa.Column('updated_at', sa.Text(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_order_lines_order_id'), 'order_lines', ['order_id'], unique=False)
op.create_index(op.f('ix_order_lines_tenant_id'), 'order_lines', ['tenant_id'], unique=False)
op.create_table('orders',
sa.Column('vehicle_id', sa.String(length=36), nullable=False),
sa.Column('tip_deviz_id', sa.String(length=36), nullable=True),
sa.Column('status', sa.String(length=20), nullable=False),
sa.Column('data_comanda', sa.Text(), nullable=True),
sa.Column('km_intrare', sa.Integer(), nullable=True),
sa.Column('observatii', sa.Text(), nullable=True),
sa.Column('mecanic_id', sa.String(length=36), nullable=True),
sa.Column('total_manopera', sa.Float(), nullable=False),
sa.Column('total_materiale', sa.Float(), nullable=False),
sa.Column('total_general', sa.Float(), nullable=False),
sa.Column('token_client', sa.String(length=36), nullable=True),
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('created_at', sa.Text(), nullable=False),
sa.Column('updated_at', sa.Text(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_orders_tenant_id'), 'orders', ['tenant_id'], unique=False)
op.create_table('vehicles',
sa.Column('nr_inmatriculare', sa.String(length=20), nullable=False),
sa.Column('vin', sa.String(length=17), nullable=True),
sa.Column('marca_id', sa.String(length=36), nullable=True),
sa.Column('model_id', sa.String(length=36), nullable=True),
sa.Column('an_fabricatie', sa.Integer(), nullable=True),
sa.Column('tip_motor_id', sa.String(length=36), nullable=True),
sa.Column('capacitate_motor', sa.String(length=20), nullable=True),
sa.Column('putere_kw', sa.String(length=20), nullable=True),
sa.Column('client_nume', sa.String(length=200), nullable=True),
sa.Column('client_telefon', sa.String(length=20), nullable=True),
sa.Column('client_email', sa.String(length=200), nullable=True),
sa.Column('client_cui', sa.String(length=20), nullable=True),
sa.Column('client_adresa', sa.Text(), nullable=True),
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('created_at', sa.Text(), nullable=False),
sa.Column('updated_at', sa.Text(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_vehicles_tenant_id'), 'vehicles', ['tenant_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_vehicles_tenant_id'), table_name='vehicles')
op.drop_table('vehicles')
op.drop_index(op.f('ix_orders_tenant_id'), table_name='orders')
op.drop_table('orders')
op.drop_index(op.f('ix_order_lines_tenant_id'), table_name='order_lines')
op.drop_index(op.f('ix_order_lines_order_id'), table_name='order_lines')
op.drop_table('order_lines')
op.drop_index(op.f('ix_mecanici_tenant_id'), table_name='mecanici')
op.drop_table('mecanici')
op.drop_index(op.f('ix_invoices_tenant_id'), table_name='invoices')
op.drop_index(op.f('ix_invoices_order_id'), table_name='invoices')
op.drop_table('invoices')
op.drop_index(op.f('ix_catalog_tipuri_motoare_tenant_id'), table_name='catalog_tipuri_motoare')
op.drop_table('catalog_tipuri_motoare')
op.drop_index(op.f('ix_catalog_tipuri_deviz_tenant_id'), table_name='catalog_tipuri_deviz')
op.drop_table('catalog_tipuri_deviz')
op.drop_index(op.f('ix_catalog_preturi_tenant_id'), table_name='catalog_preturi')
op.drop_table('catalog_preturi')
op.drop_index(op.f('ix_catalog_norme_tenant_id'), table_name='catalog_norme')
op.drop_index(op.f('ix_catalog_norme_ansamblu_id'), table_name='catalog_norme')
op.drop_table('catalog_norme')
op.drop_index(op.f('ix_catalog_modele_marca_id'), table_name='catalog_modele')
op.drop_table('catalog_modele')
op.drop_index(op.f('ix_catalog_marci_tenant_id'), table_name='catalog_marci')
op.drop_table('catalog_marci')
op.drop_index(op.f('ix_catalog_ansamble_tenant_id'), table_name='catalog_ansamble')
op.drop_table('catalog_ansamble')
op.drop_index(op.f('ix_appointments_tenant_id'), table_name='appointments')
op.drop_table('appointments')
# ### end Alembic commands ###

0
backend/app/__init__.py Normal file
View File

View File

View File

@@ -0,0 +1,80 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import schemas, service
from app.db.session import get_db
from app.deps import get_current_user
from app.users.schemas import AcceptInviteRequest
from app.users.service import accept_invite
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,
)
@router.get("/me", response_model=schemas.UserResponse)
async def me(
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
from sqlalchemy import select
from app.db.models.user import User
from app.db.models.tenant import Tenant
r = await db.execute(select(User).where(User.id == current_user["sub"]))
user = r.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
r = await db.execute(select(Tenant).where(Tenant.id == user.tenant_id))
tenant = r.scalar_one()
return schemas.UserResponse(
id=user.id,
email=user.email,
tenant_id=user.tenant_id,
plan=tenant.plan,
rol=user.rol,
)
@router.post("/accept-invite", response_model=schemas.TokenResponse)
async def accept_invite_endpoint(
data: AcceptInviteRequest, db: AsyncSession = Depends(get_db)
):
try:
user = await accept_invite(db, data.token, data.password)
from app.db.models.tenant import Tenant
r = await db.execute(select(Tenant).where(Tenant.id == user.tenant_id))
tenant = r.scalar_one()
return schemas.TokenResponse(
access_token=service.create_token(user.id, tenant.id, tenant.plan),
tenant_id=tenant.id,
plan=tenant.plan,
)
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))

View File

@@ -0,0 +1,28 @@
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
class UserResponse(BaseModel):
id: str
email: str
tenant_id: str
plan: str
rol: str

View File

@@ -0,0 +1,62 @@
from datetime import UTC, datetime, timedelta
from jose import jwt
import bcrypt
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.db.base import uuid7
from app.db.models.tenant import Tenant
from app.db.models.user import User
def hash_password(p: str) -> str:
return bcrypt.hashpw(p.encode(), bcrypt.gensalt()).decode()
def verify_password(plain: str, hashed: str) -> bool:
return bcrypt.checkpw(plain.encode(), hashed.encode())
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()

View File

View File

@@ -0,0 +1,94 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models.order import Order
from app.db.models.order_line import OrderLine
from app.db.models.tenant import Tenant
from app.db.models.vehicle import Vehicle
from app.db.session import get_db
router = APIRouter()
@router.get("/p/{token}")
async def get_deviz_public(token: str, db: AsyncSession = 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(status_code=404, detail="Deviz not found")
r = await db.execute(select(Tenant).where(Tenant.id == order.tenant_id))
tenant = r.scalar_one()
r = await db.execute(select(Vehicle).where(Vehicle.id == order.vehicle_id))
vehicle = r.scalar_one_or_none()
r = await db.execute(
select(OrderLine).where(OrderLine.order_id == order.id)
)
lines = r.scalars().all()
return {
"order": {
"id": order.id,
"status": order.status,
"data_comanda": order.data_comanda,
"total_manopera": order.total_manopera,
"total_materiale": order.total_materiale,
"total_general": order.total_general,
"nr_auto": vehicle.nr_inmatriculare if vehicle else "",
"observatii": order.observatii,
},
"tenant": {
"nume": tenant.nume,
"telefon": tenant.telefon,
},
"lines": [
{
"tip": l.tip,
"descriere": l.descriere,
"ore": l.ore,
"pret_ora": l.pret_ora,
"cantitate": l.cantitate,
"pret_unitar": l.pret_unitar,
"um": l.um,
"total": l.total,
}
for l in lines
],
}
@router.post("/p/{token}/accept")
async def accept_deviz(token: str, db: AsyncSession = 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(status_code=404, detail="Deviz not found")
await db.execute(
text(
"UPDATE orders SET status_client='ACCEPTAT', updated_at=datetime('now') "
"WHERE token_client=:t"
),
{"t": token},
)
await db.commit()
return {"ok": True}
@router.post("/p/{token}/reject")
async def reject_deviz(token: str, db: AsyncSession = 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(status_code=404, detail="Deviz not found")
await db.execute(
text(
"UPDATE orders SET status_client='RESPINS', updated_at=datetime('now') "
"WHERE token_client=:t"
),
{"t": token},
)
await db.commit()
return {"ok": True}

View File

View File

@@ -0,0 +1,163 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select, or_
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.base import uuid7
from app.db.models.client import Client
from app.db.session import get_db
from app.deps import get_tenant_id
from app.clients import schemas
router = APIRouter()
@router.get("")
async def list_clients(
search: str | None = None,
tenant_id: str = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
query = select(Client).where(Client.tenant_id == tenant_id)
if search:
pattern = f"%{search}%"
query = query.where(
or_(
Client.denumire.ilike(pattern),
Client.nume.ilike(pattern),
Client.prenume.ilike(pattern),
Client.cod_fiscal.ilike(pattern),
Client.telefon.ilike(pattern),
Client.email.ilike(pattern),
)
)
r = await db.execute(query)
clients = r.scalars().all()
return [
{
"id": c.id,
"tip_persoana": c.tip_persoana,
"denumire": c.denumire,
"nume": c.nume,
"prenume": c.prenume,
"cod_fiscal": c.cod_fiscal,
"reg_com": c.reg_com,
"telefon": c.telefon,
"email": c.email,
"adresa": c.adresa,
"judet": c.judet,
"oras": c.oras,
"cod_postal": c.cod_postal,
"tara": c.tara,
"cont_iban": c.cont_iban,
"banca": c.banca,
"activ": c.activ,
}
for c in clients
]
@router.post("")
async def create_client(
data: schemas.ClientCreate,
tenant_id: str = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
client = Client(
id=data.id or uuid7(),
tenant_id=tenant_id,
tip_persoana=data.tip_persoana,
denumire=data.denumire,
nume=data.nume,
prenume=data.prenume,
cod_fiscal=data.cod_fiscal,
reg_com=data.reg_com,
telefon=data.telefon,
email=data.email,
adresa=data.adresa,
judet=data.judet,
oras=data.oras,
cod_postal=data.cod_postal,
tara=data.tara,
cont_iban=data.cont_iban,
banca=data.banca,
activ=data.activ,
)
db.add(client)
await db.commit()
return {"id": client.id}
@router.get("/{client_id}")
async def get_client(
client_id: str,
tenant_id: str = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
r = await db.execute(
select(Client).where(
Client.id == client_id, Client.tenant_id == tenant_id
)
)
c = r.scalar_one_or_none()
if not c:
raise HTTPException(status_code=404, detail="Client not found")
return {
"id": c.id,
"tip_persoana": c.tip_persoana,
"denumire": c.denumire,
"nume": c.nume,
"prenume": c.prenume,
"cod_fiscal": c.cod_fiscal,
"reg_com": c.reg_com,
"telefon": c.telefon,
"email": c.email,
"adresa": c.adresa,
"judet": c.judet,
"oras": c.oras,
"cod_postal": c.cod_postal,
"tara": c.tara,
"cont_iban": c.cont_iban,
"banca": c.banca,
"activ": c.activ,
}
@router.put("/{client_id}")
async def update_client(
client_id: str,
data: schemas.ClientUpdate,
tenant_id: str = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
r = await db.execute(
select(Client).where(
Client.id == client_id, Client.tenant_id == tenant_id
)
)
c = r.scalar_one_or_none()
if not c:
raise HTTPException(status_code=404, detail="Client not found")
update_data = data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(c, key, value)
await db.commit()
return {"ok": True}
@router.delete("/{client_id}")
async def delete_client(
client_id: str,
tenant_id: str = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
r = await db.execute(
select(Client).where(
Client.id == client_id, Client.tenant_id == tenant_id
)
)
c = r.scalar_one_or_none()
if not c:
raise HTTPException(status_code=404, detail="Client not found")
await db.delete(c)
await db.commit()
return {"ok": True}

View File

@@ -0,0 +1,41 @@
from pydantic import BaseModel
from typing import Optional
class ClientCreate(BaseModel):
id: Optional[str] = None
tip_persoana: str = "PF"
denumire: Optional[str] = None
nume: Optional[str] = None
prenume: Optional[str] = None
cod_fiscal: Optional[str] = None
reg_com: Optional[str] = None
telefon: Optional[str] = None
email: Optional[str] = None
adresa: Optional[str] = None
judet: Optional[str] = None
oras: Optional[str] = None
cod_postal: Optional[str] = None
tara: str = "RO"
cont_iban: Optional[str] = None
banca: Optional[str] = None
activ: int = 1
class ClientUpdate(BaseModel):
tip_persoana: Optional[str] = None
denumire: Optional[str] = None
nume: Optional[str] = None
prenume: Optional[str] = None
cod_fiscal: Optional[str] = None
reg_com: Optional[str] = None
telefon: Optional[str] = None
email: Optional[str] = None
adresa: Optional[str] = None
judet: Optional[str] = None
oras: Optional[str] = None
cod_postal: Optional[str] = None
tara: Optional[str] = None
cont_iban: Optional[str] = None
banca: Optional[str] = None
activ: Optional[int] = None

15
backend/app/config.py Normal file
View File

@@ -0,0 +1,15 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env")
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"
settings = Settings()

View File

32
backend/app/db/base.py Normal file
View File

@@ -0,0 +1,32 @@
import uuid
from datetime import datetime, UTC
from sqlalchemy import String, Text
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
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(),
)

View File

@@ -0,0 +1,39 @@
from app.db.models.tenant import Tenant
from app.db.models.user import User
from app.db.models.client import Client
from app.db.models.vehicle import Vehicle
from app.db.models.order import Order
from app.db.models.order_line import OrderLine
from app.db.models.catalog import (
CatalogMarca,
CatalogModel,
CatalogAnsamblu,
CatalogNorma,
CatalogPret,
CatalogTipDeviz,
CatalogTipMotor,
)
from app.db.models.invoice import Invoice
from app.db.models.appointment import Appointment
from app.db.models.mecanic import Mecanic
from app.db.models.invite import InviteToken
__all__ = [
"Tenant",
"User",
"Client",
"Vehicle",
"Order",
"OrderLine",
"CatalogMarca",
"CatalogModel",
"CatalogAnsamblu",
"CatalogNorma",
"CatalogPret",
"CatalogTipDeviz",
"CatalogTipMotor",
"Invoice",
"Appointment",
"Mecanic",
"InviteToken",
]

View File

@@ -0,0 +1,16 @@
from sqlalchemy import Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base, UUIDMixin, TenantMixin, TimestampMixin
class Appointment(Base, UUIDMixin, TenantMixin, TimestampMixin):
__tablename__ = "appointments"
vehicle_id: Mapped[str | None] = mapped_column(String(36))
client_nume: Mapped[str | None] = mapped_column(String(200))
client_telefon: Mapped[str | None] = mapped_column(String(20))
data_ora: Mapped[str | None] = mapped_column(Text)
durata_minute: Mapped[int] = mapped_column(Integer, default=60, server_default="60")
observatii: Mapped[str | None] = mapped_column(Text)
status: Mapped[str] = mapped_column(String(20), default="PROGRAMAT", server_default="PROGRAMAT")
order_id: Mapped[str | None] = mapped_column(String(36))

View File

@@ -0,0 +1,46 @@
from sqlalchemy import Float, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base, UUIDMixin, TenantMixin, TimestampMixin
class CatalogMarca(Base, UUIDMixin, TenantMixin, TimestampMixin):
__tablename__ = "catalog_marci"
denumire: Mapped[str] = mapped_column(String(100))
activ: Mapped[int] = mapped_column(Integer, default=1, server_default="1")
class CatalogModel(Base, UUIDMixin, TimestampMixin):
__tablename__ = "catalog_modele"
marca_id: Mapped[str] = mapped_column(String(36), index=True)
denumire: Mapped[str] = mapped_column(String(100))
class CatalogAnsamblu(Base, UUIDMixin, TenantMixin, TimestampMixin):
__tablename__ = "catalog_ansamble"
denumire: Mapped[str] = mapped_column(String(100))
class CatalogNorma(Base, UUIDMixin, TenantMixin, TimestampMixin):
__tablename__ = "catalog_norme"
cod: Mapped[str | None] = mapped_column(String(50))
denumire: Mapped[str] = mapped_column(Text)
ore_normate: Mapped[float] = mapped_column(Float, default=0, server_default="0")
ansamblu_id: Mapped[str | None] = mapped_column(String(36), index=True)
class CatalogPret(Base, UUIDMixin, TenantMixin, TimestampMixin):
__tablename__ = "catalog_preturi"
denumire: Mapped[str] = mapped_column(String(200))
pret: Mapped[float] = mapped_column(Float, default=0, server_default="0")
um: Mapped[str] = mapped_column(String(10))
class CatalogTipDeviz(Base, UUIDMixin, TenantMixin, TimestampMixin):
__tablename__ = "catalog_tipuri_deviz"
denumire: Mapped[str] = mapped_column(String(100))
class CatalogTipMotor(Base, UUIDMixin, TenantMixin, TimestampMixin):
__tablename__ = "catalog_tipuri_motoare"
denumire: Mapped[str] = mapped_column(String(50))

View File

@@ -0,0 +1,25 @@
from sqlalchemy import Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base, UUIDMixin, TenantMixin, TimestampMixin
class Client(Base, UUIDMixin, TenantMixin, TimestampMixin):
__tablename__ = "clients"
tip_persoana: Mapped[str | None] = mapped_column(String(2), default="PF")
denumire: Mapped[str | None] = mapped_column(String(200))
nume: Mapped[str | None] = mapped_column(String(100))
prenume: Mapped[str | None] = mapped_column(String(100))
cod_fiscal: Mapped[str | None] = mapped_column(String(20))
reg_com: Mapped[str | None] = mapped_column(String(30))
telefon: Mapped[str | None] = mapped_column(String(20))
email: Mapped[str | None] = mapped_column(String(200))
adresa: Mapped[str | None] = mapped_column(Text)
judet: Mapped[str | None] = mapped_column(String(50))
oras: Mapped[str | None] = mapped_column(String(100))
cod_postal: Mapped[str | None] = mapped_column(String(10))
tara: Mapped[str | None] = mapped_column(String(2), default="RO")
cont_iban: Mapped[str | None] = mapped_column(String(34))
banca: Mapped[str | None] = mapped_column(String(100))
activ: Mapped[int] = mapped_column(Integer, default=1, server_default="1")
oracle_id: Mapped[int | None] = mapped_column(Integer)

View File

@@ -0,0 +1,12 @@
from sqlalchemy import String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base, UUIDMixin, TenantMixin, TimestampMixin
class InviteToken(Base, UUIDMixin, TenantMixin, TimestampMixin):
__tablename__ = "invites"
email: Mapped[str] = mapped_column(String(200))
rol: Mapped[str] = mapped_column(String(20))
token: Mapped[str] = mapped_column(String(36), unique=True, index=True)
used: Mapped[str | None] = mapped_column(Text) # ISO8601 when used, null if pending

View File

@@ -0,0 +1,24 @@
from sqlalchemy import Float, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base, UUIDMixin, TenantMixin, TimestampMixin
class Invoice(Base, UUIDMixin, TenantMixin, TimestampMixin):
__tablename__ = "invoices"
order_id: Mapped[str | None] = mapped_column(String(36), index=True)
client_id: Mapped[str | None] = mapped_column(String(36))
tip_document: Mapped[str | None] = mapped_column(String(20), default="FACTURA", server_default="FACTURA")
nr_factura: Mapped[str | None] = mapped_column(String(50))
serie_factura: Mapped[str | None] = mapped_column(String(20))
data_factura: Mapped[str | None] = mapped_column(Text)
modalitate_plata: Mapped[str | None] = mapped_column(String(50))
client_nume: Mapped[str | None] = mapped_column(String(200))
client_cod_fiscal: Mapped[str | None] = mapped_column(String(20))
nr_auto: Mapped[str | None] = mapped_column(String(20))
total_fara_tva: Mapped[float] = mapped_column(Float, default=0, server_default="0")
tva: Mapped[float] = mapped_column(Float, default=0, server_default="0")
total_general: Mapped[float] = mapped_column(Float, default=0, server_default="0")
# Legacy field kept for REST API service compatibility
total: Mapped[float] = mapped_column(Float, default=0, server_default="0")
status: Mapped[str] = mapped_column(String(20), default="EMISA", server_default="EMISA")

View File

@@ -0,0 +1,12 @@
from sqlalchemy import Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base, UUIDMixin, TenantMixin, TimestampMixin
class Mecanic(Base, UUIDMixin, TenantMixin, TimestampMixin):
__tablename__ = "mecanici"
user_id: Mapped[str | None] = mapped_column(String(36))
nume: Mapped[str] = mapped_column(String(200))
prenume: Mapped[str | None] = mapped_column(String(200))
activ: Mapped[int] = mapped_column(Integer, default=1, server_default="1")

View File

@@ -0,0 +1,32 @@
from sqlalchemy import Float, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base, UUIDMixin, TenantMixin, TimestampMixin
class Order(Base, UUIDMixin, TenantMixin, TimestampMixin):
__tablename__ = "orders"
nr_comanda: Mapped[str | None] = mapped_column(String(50))
vehicle_id: Mapped[str | None] = mapped_column(String(36))
client_id: Mapped[str | None] = mapped_column(String(36))
tip_deviz_id: Mapped[str | None] = mapped_column(String(36))
status: Mapped[str] = mapped_column(String(20), default="DRAFT", server_default="DRAFT")
data_comanda: Mapped[str | None] = mapped_column(Text)
km_intrare: Mapped[int | None] = mapped_column(Integer)
observatii: Mapped[str | None] = mapped_column(Text)
mecanic_id: Mapped[str | None] = mapped_column(String(36))
# Denormalized client/vehicle info for quick display
client_nume: Mapped[str | None] = mapped_column(String(200))
client_telefon: Mapped[str | None] = mapped_column(String(20))
nr_auto: Mapped[str | None] = mapped_column(String(20))
marca_denumire: Mapped[str | None] = mapped_column(String(100))
model_denumire: Mapped[str | None] = mapped_column(String(100))
# Totals — server_default ensures raw SQL INSERT without these fields still works
total_manopera: Mapped[float] = mapped_column(Float, default=0, server_default="0")
total_materiale: Mapped[float] = mapped_column(Float, default=0, server_default="0")
total_general: Mapped[float] = mapped_column(Float, default=0, server_default="0")
# Client portal
token_client: Mapped[str | None] = mapped_column(String(36))
status_client: Mapped[str | None] = mapped_column(String(20))
# Audit
created_by: Mapped[str | None] = mapped_column(String(36))

View File

@@ -0,0 +1,20 @@
from sqlalchemy import Float, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base, UUIDMixin, TenantMixin, TimestampMixin
class OrderLine(Base, UUIDMixin, TenantMixin, TimestampMixin):
__tablename__ = "order_lines"
order_id: Mapped[str] = mapped_column(String(36), index=True)
tip: Mapped[str] = mapped_column(String(20)) # manopera | material
descriere: Mapped[str] = mapped_column(Text)
norma_id: Mapped[str | None] = mapped_column(String(36))
ore: Mapped[float] = mapped_column(Float, default=0, server_default="0")
pret_ora: Mapped[float] = mapped_column(Float, default=0, server_default="0")
cantitate: Mapped[float] = mapped_column(Float, default=0, server_default="0")
pret_unitar: Mapped[float] = mapped_column(Float, default=0, server_default="0")
um: Mapped[str | None] = mapped_column(String(10))
total: Mapped[float] = mapped_column(Float, default=0, server_default="0")
mecanic_id: Mapped[str | None] = mapped_column(String(36))
ordine: Mapped[int | None] = mapped_column(Integer)

View File

@@ -0,0 +1,18 @@
from sqlalchemy import String, Text
from sqlalchemy.orm import Mapped, mapped_column
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)

View File

@@ -0,0 +1,13 @@
from sqlalchemy import Boolean, String
from sqlalchemy.orm import Mapped, mapped_column
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)

View File

@@ -0,0 +1,26 @@
from sqlalchemy import Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base, UUIDMixin, TenantMixin, TimestampMixin
class Vehicle(Base, UUIDMixin, TenantMixin, TimestampMixin):
__tablename__ = "vehicles"
nr_inmatriculare: Mapped[str] = mapped_column(String(20))
client_id: Mapped[str | None] = mapped_column(String(36))
marca_id: Mapped[str | None] = mapped_column(String(36))
model_id: Mapped[str | None] = mapped_column(String(36))
an_fabricatie: Mapped[int | None] = mapped_column(Integer)
# VIN / serie sasiu (vin kept for REST API compat, serie_sasiu for frontend sync)
vin: Mapped[str | None] = mapped_column(String(17))
serie_sasiu: Mapped[str | None] = mapped_column(String(50))
tip_motor_id: Mapped[str | None] = mapped_column(String(36))
capacitate_motor: Mapped[str | None] = mapped_column(String(20))
putere_kw: Mapped[str | None] = mapped_column(String(20))
client_nume: Mapped[str | None] = mapped_column(String(200))
client_telefon: Mapped[str | None] = mapped_column(String(20))
client_email: Mapped[str | None] = mapped_column(String(200))
# client_cod_fiscal used by frontend; client_cui kept for REST API compat
client_cod_fiscal: Mapped[str | None] = mapped_column(String(20))
client_cui: Mapped[str | None] = mapped_column(String(20))
client_adresa: Mapped[str | None] = mapped_column(Text)

143
backend/app/db/seed.py Normal file
View File

@@ -0,0 +1,143 @@
from datetime import UTC, datetime
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.base import uuid7
from app.db.models.catalog import (
CatalogAnsamblu,
CatalogMarca,
CatalogPret,
CatalogTipDeviz,
CatalogTipMotor,
)
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",
]
ANSAMBLE = [
"Motor",
"Cutie de viteze",
"Frane",
"Directie",
"Suspensie",
"Climatizare",
"Electrica",
"Caroserie",
"Esapament",
"Transmisie",
"Revizie",
]
TIPURI_DEVIZ = ["Service", "ITP", "Regie", "Constatare"]
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"},
]
async def seed_demo(db: AsyncSession) -> None:
from app.auth.service import register
from sqlalchemy import select
from app.db.models.user import User
r = await db.execute(select(User).where(User.email == "demo@roaauto.ro"))
if r.scalar_one_or_none():
print("Demo user already exists.")
return
user, tenant = await register(db, "demo@roaauto.ro", "demo123", "ROA AUTO Demo", "0722000000")
print(f"Created demo tenant: {tenant.id}, user: {user.email}")
counts = await seed_catalog(db, tenant.id)
print(f"Seeded catalog: {counts}")
async def seed_catalog(db: AsyncSession, tenant_id: str) -> dict:
now = datetime.now(UTC).isoformat()
counts = {}
# Marci
for name in MARCI:
db.add(
CatalogMarca(id=uuid7(), tenant_id=tenant_id, denumire=name)
)
counts["marci"] = len(MARCI)
# Ansamble
for name in ANSAMBLE:
db.add(
CatalogAnsamblu(id=uuid7(), tenant_id=tenant_id, denumire=name)
)
counts["ansamble"] = len(ANSAMBLE)
# Tipuri deviz
for name in TIPURI_DEVIZ:
db.add(
CatalogTipDeviz(id=uuid7(), tenant_id=tenant_id, denumire=name)
)
counts["tipuri_deviz"] = len(TIPURI_DEVIZ)
# Tipuri motoare
for name in TIPURI_MOTOARE:
db.add(
CatalogTipMotor(id=uuid7(), tenant_id=tenant_id, denumire=name)
)
counts["tipuri_motoare"] = len(TIPURI_MOTOARE)
# Preturi
for p in PRETURI:
db.add(
CatalogPret(
id=uuid7(),
tenant_id=tenant_id,
denumire=p["denumire"],
pret=p["pret"],
um=p["um"],
)
)
counts["preturi"] = len(PRETURI)
await db.commit()
return counts
if __name__ == "__main__":
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from app.config import settings
import app.db.models # noqa
async def main():
engine = create_async_engine(settings.DATABASE_URL)
Session = async_sessionmaker(engine, expire_on_commit=False)
async with Session() as db:
await seed_demo(db)
await engine.dispose()
asyncio.run(main())

11
backend/app/db/session.py Normal file
View File

@@ -0,0 +1,11 @@
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
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

22
backend/app/deps.py Normal file
View File

@@ -0,0 +1,22 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
from app.config import settings
bearer = HTTPBearer(auto_error=False)
async def get_current_user(
creds: HTTPAuthorizationCredentials | None = Depends(bearer),
) -> dict:
if creds is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
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"]

View File

View File

@@ -0,0 +1,149 @@
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import Response
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models.invoice import Invoice
from app.db.models.order import Order
from app.db.models.order_line import OrderLine
from app.db.models.tenant import Tenant
from app.db.models.vehicle import Vehicle
from app.db.session import get_db
from app.deps import get_tenant_id
from app.invoices import service
from app.pdf.service import generate_factura
router = APIRouter()
@router.post("")
async def create_invoice(
data: dict,
tenant_id: str = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
order_id = data.get("order_id")
if not order_id:
raise HTTPException(status_code=422, detail="order_id required")
try:
invoice = await service.create_invoice(db, tenant_id, order_id)
return {"id": invoice.id, "nr_factura": invoice.nr_factura}
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))
@router.get("/{invoice_id}/pdf")
async def get_invoice_pdf(
invoice_id: str,
tenant_id: str = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
r = await db.execute(
select(Invoice).where(
Invoice.id == invoice_id, Invoice.tenant_id == tenant_id
)
)
invoice = r.scalar_one_or_none()
if not invoice:
raise HTTPException(status_code=404, detail="Invoice not found")
r = await db.execute(select(Order).where(Order.id == invoice.order_id))
order = r.scalar_one()
r = await db.execute(select(Vehicle).where(Vehicle.id == order.vehicle_id))
vehicle = r.scalar_one_or_none()
r = await db.execute(select(Tenant).where(Tenant.id == tenant_id))
tenant = r.scalar_one()
r = await db.execute(
select(OrderLine).where(OrderLine.order_id == order.id)
)
lines = r.scalars().all()
order_data = {
"id": order.id,
"nr_auto": vehicle.nr_inmatriculare if vehicle else "",
"client_nume": vehicle.client_nume if vehicle else "",
"client_cui": vehicle.client_cui if vehicle else "",
"client_adresa": vehicle.client_adresa if vehicle else "",
"total_manopera": order.total_manopera,
"total_materiale": order.total_materiale,
"total_general": order.total_general,
}
invoice_data = {
"nr_factura": invoice.nr_factura,
"data_factura": invoice.data_factura,
"total": invoice.total,
}
tenant_data = {
"nume": tenant.nume,
"cui": tenant.cui,
"reg_com": tenant.reg_com,
"adresa": tenant.adresa,
"iban": tenant.iban,
"banca": tenant.banca,
}
lines_data = [
{
"tip": l.tip,
"descriere": l.descriere,
"ore": l.ore,
"pret_ora": l.pret_ora,
"cantitate": l.cantitate,
"pret_unitar": l.pret_unitar,
"um": l.um,
"total": l.total,
}
for l in lines
]
pdf_bytes = generate_factura(invoice_data, order_data, lines_data, tenant_data)
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={
"Content-Disposition": f'inline; filename="factura-{invoice.nr_factura}.pdf"'
},
)
@router.delete("/{invoice_id}")
async def delete_invoice(
invoice_id: str,
tenant_id: str = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
r = await db.execute(
select(Invoice).where(
Invoice.id == invoice_id, Invoice.tenant_id == tenant_id
)
)
invoice = r.scalar_one_or_none()
if not invoice:
raise HTTPException(status_code=404, detail="Invoice not found")
order_id = invoice.order_id
# Delete the invoice
await db.delete(invoice)
# Revert the associated order status back to VALIDAT
if order_id:
r = await db.execute(
select(Order).where(
Order.id == order_id, Order.tenant_id == tenant_id
)
)
order = r.scalar_one_or_none()
if order:
order.status = "VALIDAT"
order.updated_at = datetime.now(UTC).isoformat()
await db.commit()
return {"ok": True}

View File

@@ -0,0 +1,49 @@
from datetime import UTC, datetime
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.base import uuid7
from app.db.models.invoice import Invoice
from app.db.models.order import Order
async def create_invoice(
db: AsyncSession, tenant_id: str, order_id: str
) -> Invoice:
r = await db.execute(
select(Order).where(Order.id == order_id, Order.tenant_id == tenant_id)
)
order = r.scalar_one_or_none()
if not order:
raise ValueError("Order not found")
if order.status != "VALIDAT":
raise ValueError("Order must be VALIDAT to create invoice")
# Generate next invoice number for this tenant
r = await db.execute(
text(
"SELECT COUNT(*) as cnt FROM invoices WHERE tenant_id = :tid"
),
{"tid": tenant_id},
)
count = r.scalar() + 1
now = datetime.now(UTC)
nr_factura = f"F-{now.year}-{count:04d}"
invoice = Invoice(
id=uuid7(),
tenant_id=tenant_id,
order_id=order_id,
nr_factura=nr_factura,
data_factura=now.isoformat().split("T")[0],
total=order.total_general,
)
db.add(invoice)
# Update order status to FACTURAT
order.status = "FACTURAT"
order.updated_at = now.isoformat()
await db.commit()
await db.refresh(invoice)
return invoice

49
backend/app/main.py Normal file
View File

@@ -0,0 +1,49 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.auth.router import router as auth_router
from app.client_portal.router import router as portal_router
from app.clients.router import router as clients_router
from app.config import settings
from app.db.base import Base
from app.db.session import engine
from app.invoices.router import router as invoices_router
from app.orders.router import router as orders_router
from app.sync.router import router as sync_router
from app.users.router import router as users_router
from app.vehicles.router import router as vehicles_router
# Import models so Base.metadata knows about them
import app.db.models # noqa: F401
@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.include_router(sync_router, prefix="/api/sync")
app.include_router(clients_router, prefix="/api/clients")
app.include_router(orders_router, prefix="/api/orders")
app.include_router(vehicles_router, prefix="/api/vehicles")
app.include_router(invoices_router, prefix="/api/invoices")
app.include_router(users_router, prefix="/api/users")
app.include_router(portal_router, prefix="/api")
@app.get("/api/health")
async def health():
return {"status": "ok"}

View File

View File

@@ -0,0 +1,272 @@
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import Response
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models.invoice import Invoice
from app.db.models.order import Order
from app.db.models.order_line import OrderLine
from app.db.models.tenant import Tenant
from app.db.models.vehicle import Vehicle
from app.db.session import get_db
from app.deps import get_tenant_id
from app.orders import schemas, service
from app.pdf.service import generate_deviz
router = APIRouter()
@router.get("")
async def list_orders(
tenant_id: str = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
return await service.list_orders(db, tenant_id)
@router.post("")
async def create_order(
data: schemas.CreateOrderRequest,
tenant_id: str = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
order = await service.create_order(
db,
tenant_id,
data.vehicle_id,
data.tip_deviz_id,
data.km_intrare,
data.observatii,
)
return {"id": order.id}
@router.get("/{order_id}")
async def get_order(
order_id: str,
tenant_id: str = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
result = await service.get_order(db, tenant_id, order_id)
if not result:
raise HTTPException(status_code=404, detail="Order not found")
return result
@router.post("/{order_id}/lines")
async def add_line(
order_id: str,
data: schemas.AddLineRequest,
tenant_id: str = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
try:
line = await service.add_line(
db,
tenant_id,
order_id,
data.tip,
data.descriere,
data.ore,
data.pret_ora,
data.cantitate,
data.pret_unitar,
data.um,
)
return {"id": line.id}
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))
@router.post("/{order_id}/validate")
async def validate_order(
order_id: str,
tenant_id: str = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
try:
order = await service.validate_order(db, tenant_id, order_id)
return {"status": order.status}
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))
@router.get("/{order_id}/pdf/deviz")
async def get_deviz_pdf(
order_id: str,
tenant_id: str = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
r = await db.execute(
select(Order).where(Order.id == order_id, Order.tenant_id == tenant_id)
)
order = r.scalar_one_or_none()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
r = await db.execute(select(Vehicle).where(Vehicle.id == order.vehicle_id))
vehicle = r.scalar_one_or_none()
r = await db.execute(select(Tenant).where(Tenant.id == tenant_id))
tenant = r.scalar_one()
r = await db.execute(
select(OrderLine).where(OrderLine.order_id == order.id)
)
lines = r.scalars().all()
order_data = {
"id": order.id,
"data_comanda": order.data_comanda,
"nr_auto": vehicle.nr_inmatriculare if vehicle else "",
"client_nume": vehicle.client_nume if vehicle else "",
"marca_denumire": "",
"model_denumire": "",
"total_manopera": order.total_manopera,
"total_materiale": order.total_materiale,
"total_general": order.total_general,
}
tenant_data = {
"nume": tenant.nume,
"cui": tenant.cui,
"adresa": tenant.adresa,
"telefon": tenant.telefon,
}
lines_data = [
{
"tip": l.tip,
"descriere": l.descriere,
"ore": l.ore,
"pret_ora": l.pret_ora,
"cantitate": l.cantitate,
"pret_unitar": l.pret_unitar,
"um": l.um,
"total": l.total,
}
for l in lines
]
pdf_bytes = generate_deviz(order_data, lines_data, tenant_data)
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={
"Content-Disposition": f'inline; filename="deviz-{order.id[:8]}.pdf"'
},
)
@router.put("/{order_id}")
async def update_order(
order_id: str,
data: schemas.UpdateOrderRequest,
tenant_id: str = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
r = await db.execute(
select(Order).where(Order.id == order_id, Order.tenant_id == tenant_id)
)
order = r.scalar_one_or_none()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
if order.status != "DRAFT":
raise HTTPException(status_code=422, detail="Can only update DRAFT orders")
update_data = data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(order, key, value)
order.updated_at = datetime.now(UTC).isoformat()
await db.commit()
await db.refresh(order)
return {
"id": order.id,
"vehicle_id": order.vehicle_id,
"client_id": order.client_id,
"tip_deviz_id": order.tip_deviz_id,
"status": order.status,
"km_intrare": order.km_intrare,
"observatii": order.observatii,
"client_nume": order.client_nume,
"client_telefon": order.client_telefon,
"nr_auto": order.nr_auto,
"marca_denumire": order.marca_denumire,
"model_denumire": order.model_denumire,
}
@router.post("/{order_id}/devalidate")
async def devalidate_order(
order_id: str,
tenant_id: str = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
r = await db.execute(
select(Order).where(Order.id == order_id, Order.tenant_id == tenant_id)
)
order = r.scalar_one_or_none()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
if order.status != "VALIDAT":
raise HTTPException(status_code=422, detail="Can only devalidate VALIDAT orders")
# Check no invoice exists for this order
r = await db.execute(
select(Invoice).where(
Invoice.order_id == order_id, Invoice.tenant_id == tenant_id
)
)
invoice = r.scalar_one_or_none()
if invoice:
raise HTTPException(
status_code=422,
detail="Cannot devalidate order with existing invoice"
)
order.status = "DRAFT"
order.updated_at = datetime.now(UTC).isoformat()
await db.commit()
return {"ok": True, "status": "DRAFT"}
@router.delete("/{order_id}")
async def delete_order(
order_id: str,
tenant_id: str = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
r = await db.execute(
select(Order).where(Order.id == order_id, Order.tenant_id == tenant_id)
)
order = r.scalar_one_or_none()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
if order.status == "FACTURAT":
# Check if invoice exists
r = await db.execute(
select(Invoice).where(
Invoice.order_id == order_id, Invoice.tenant_id == tenant_id
)
)
invoice = r.scalar_one_or_none()
if invoice:
raise HTTPException(
status_code=422,
detail="Cannot delete order with existing invoice"
)
# Delete order lines first
await db.execute(
text("DELETE FROM order_lines WHERE order_id = :oid AND tenant_id = :tid"),
{"oid": order_id, "tid": tenant_id},
)
# Delete the order
await db.execute(
text("DELETE FROM orders WHERE id = :oid AND tenant_id = :tid"),
{"oid": order_id, "tid": tenant_id},
)
await db.commit()
return {"ok": True}

View File

@@ -0,0 +1,31 @@
from pydantic import BaseModel
class CreateOrderRequest(BaseModel):
vehicle_id: str
tip_deviz_id: str | None = None
km_intrare: int | None = None
observatii: str | None = None
class UpdateOrderRequest(BaseModel):
vehicle_id: str | None = None
tip_deviz_id: str | None = None
km_intrare: int | None = None
observatii: str | None = None
client_id: str | None = None
client_nume: str | None = None
client_telefon: str | None = None
nr_auto: str | None = None
marca_denumire: str | None = None
model_denumire: str | None = None
class AddLineRequest(BaseModel):
tip: str # manopera | material
descriere: str
ore: float = 0
pret_ora: float = 0
cantitate: float = 0
pret_unitar: float = 0
um: str | None = None

View File

@@ -0,0 +1,188 @@
from datetime import UTC, datetime
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.base import uuid7
from app.db.models.order import Order
from app.db.models.order_line import OrderLine
TRANSITIONS = {"DRAFT": ["VALIDAT"], "VALIDAT": ["FACTURAT"]}
async def create_order(
db: AsyncSession,
tenant_id: str,
vehicle_id: str,
tip_deviz_id: str | None = None,
km_intrare: int | None = None,
observatii: str | None = None,
) -> Order:
now = datetime.now(UTC).isoformat()
order = Order(
id=uuid7(),
tenant_id=tenant_id,
vehicle_id=vehicle_id,
tip_deviz_id=tip_deviz_id,
status="DRAFT",
data_comanda=now.split("T")[0],
km_intrare=km_intrare,
observatii=observatii,
total_manopera=0,
total_materiale=0,
total_general=0,
token_client=uuid7(),
)
db.add(order)
await db.commit()
await db.refresh(order)
return order
async def add_line(
db: AsyncSession,
tenant_id: str,
order_id: str,
tip: str,
descriere: str,
ore: float = 0,
pret_ora: float = 0,
cantitate: float = 0,
pret_unitar: float = 0,
um: str | None = None,
) -> OrderLine:
# Check order exists and belongs to tenant
r = await db.execute(
select(Order).where(Order.id == order_id, Order.tenant_id == tenant_id)
)
order = r.scalar_one_or_none()
if not order:
raise ValueError("Order not found")
if order.status != "DRAFT":
raise ValueError("Cannot add lines to non-DRAFT order")
if tip == "manopera":
total = ore * pret_ora
else:
total = cantitate * pret_unitar
line = OrderLine(
id=uuid7(),
tenant_id=tenant_id,
order_id=order_id,
tip=tip,
descriere=descriere,
ore=ore,
pret_ora=pret_ora,
cantitate=cantitate,
pret_unitar=pret_unitar,
um=um,
total=total,
)
db.add(line)
await db.commit()
await recalc_totals(db, order_id)
return line
async def recalc_totals(db: AsyncSession, 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,
},
)
await db.commit()
async def validate_order(
db: AsyncSession, tenant_id: str, order_id: str
) -> Order:
r = await db.execute(
select(Order).where(Order.id == order_id, Order.tenant_id == tenant_id)
)
order = r.scalar_one_or_none()
if not order:
raise ValueError("Order not found")
if "VALIDAT" not in TRANSITIONS.get(order.status, []):
raise ValueError(f"Cannot transition from {order.status} to VALIDAT")
order.status = "VALIDAT"
order.updated_at = datetime.now(UTC).isoformat()
await db.commit()
await db.refresh(order)
return order
async def get_order(
db: AsyncSession, tenant_id: str, order_id: str
) -> dict | None:
r = await db.execute(
select(Order).where(Order.id == order_id, Order.tenant_id == tenant_id)
)
order = r.scalar_one_or_none()
if not order:
return None
r = await db.execute(
select(OrderLine).where(OrderLine.order_id == order_id)
)
lines = r.scalars().all()
return {
"id": order.id,
"vehicle_id": order.vehicle_id,
"status": order.status,
"data_comanda": order.data_comanda,
"km_intrare": order.km_intrare,
"observatii": order.observatii,
"total_manopera": order.total_manopera,
"total_materiale": order.total_materiale,
"total_general": order.total_general,
"token_client": order.token_client,
"lines": [
{
"id": l.id,
"tip": l.tip,
"descriere": l.descriere,
"ore": l.ore,
"pret_ora": l.pret_ora,
"cantitate": l.cantitate,
"pret_unitar": l.pret_unitar,
"um": l.um,
"total": l.total,
}
for l in lines
],
}
async def list_orders(db: AsyncSession, tenant_id: str) -> list:
r = await db.execute(
select(Order).where(Order.tenant_id == tenant_id)
)
orders = r.scalars().all()
return [
{
"id": o.id,
"status": o.status,
"vehicle_id": o.vehicle_id,
"total_general": o.total_general,
}
for o in orders
]

View File

View File

@@ -0,0 +1,30 @@
from pathlib import Path
from jinja2 import Environment, FileSystemLoader
from weasyprint import HTML
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()
def generate_factura(
invoice: dict, order: dict, lines: list, tenant: dict
) -> bytes:
env = Environment(loader=FileSystemLoader(str(TEMPLATES)))
html = env.get_template("factura.html").render(
invoice=invoice,
order=order,
tenant=tenant,
lines=lines,
)
return HTML(string=html).write_pdf()

View File

@@ -0,0 +1,67 @@
<!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 or '' }} {{ order.model_denumire or '' }}</div>
<div>Client: {{ order.client_nume or '' }}</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>

View File

@@ -0,0 +1,65 @@
<!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; }
.info-box { border: 1px solid #d1d5db; padding: 12px; margin-bottom: 16px; }
</style>
</head><body>
<div class="header">
<div>
<strong>FURNIZOR</strong><br>
<strong>{{ tenant.nume }}</strong><br>
{% if tenant.cui %}CUI: {{ tenant.cui }}<br>{% endif %}
{% if tenant.reg_com %}Reg. Com.: {{ tenant.reg_com }}<br>{% endif %}
{% if tenant.adresa %}{{ tenant.adresa }}<br>{% endif %}
{% if tenant.iban %}IBAN: {{ tenant.iban }}<br>{% endif %}
{% if tenant.banca %}Banca: {{ tenant.banca }}{% endif %}
</div>
<div style="text-align:right">
<h2>FACTURA Nr. {{ invoice.nr_factura }}</h2>
<div>Data: {{ invoice.data_factura }}</div>
</div>
</div>
<div class="info-box">
<strong>CLIENT</strong><br>
{{ order.client_nume or 'N/A' }}<br>
{% if order.client_cui %}CUI: {{ order.client_cui }}<br>{% endif %}
{% if order.client_adresa %}{{ order.client_adresa }}{% endif %}
</div>
<div style="margin-bottom: 8px;">
Auto: <strong>{{ order.nr_auto }}</strong> | Deviz: {{ order.id[:8]|upper }}
</div>
<table>
<tr><th>#</th><th>Descriere</th><th>UM</th><th>Cant.</th><th>Pret unit. (RON)</th><th>Total (RON)</th></tr>
{% for l in lines %}
<tr>
<td>{{ loop.index }}</td>
<td>{{ l.descriere }}</td>
<td>{{ l.um or ('ore' if l.tip == 'manopera' else 'buc') }}</td>
<td>{{ l.ore if l.tip == 'manopera' else l.cantitate }}</td>
<td>{{ "%.2f"|format(l.pret_ora if l.tip == 'manopera' else l.pret_unitar) }}</td>
<td>{{ "%.2f"|format(l.total or 0) }}</td>
</tr>
{% endfor %}
</table>
<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(invoice.total or 0) }} RON</div>
</div>
</body></html>

View File

View File

@@ -0,0 +1,18 @@
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"},
)

View File

View File

@@ -0,0 +1,41 @@
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import get_db
from app.deps import get_tenant_id
from app.sync import schemas, service
router = APIRouter()
@router.get("/full")
async def sync_full(
tenant_id: str = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
tables = await service.get_full(db, tenant_id)
return {"tables": tables, "synced_at": datetime.now(UTC).isoformat()}
@router.get("/changes")
async def sync_changes(
since: str = Query(...),
tenant_id: str = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
tables = await service.get_changes(db, tenant_id, since)
return {"tables": tables, "synced_at": datetime.now(UTC).isoformat()}
@router.post("/push", response_model=schemas.SyncPushResponse)
async def sync_push(
data: schemas.SyncPushRequest,
tenant_id: str = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
result = await service.apply_push(
db, tenant_id, [op.model_dump() for op in data.operations]
)
return result

View File

@@ -0,0 +1,18 @@
from pydantic import BaseModel
class SyncOperation(BaseModel):
table: str
id: str
operation: str # INSERT | UPDATE | DELETE
data: dict = {}
timestamp: str
class SyncPushRequest(BaseModel):
operations: list[SyncOperation]
class SyncPushResponse(BaseModel):
applied: int
conflicts: list = []

137
backend/app/sync/service.py Normal file
View File

@@ -0,0 +1,137 @@
from datetime import UTC, datetime
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
SYNCABLE_TABLES = [
"clients",
"vehicles",
"orders",
"order_lines",
"invoices",
"appointments",
"catalog_marci",
"catalog_modele",
"catalog_ansamble",
"catalog_norme",
"catalog_preturi",
"catalog_tipuri_deviz",
"catalog_tipuri_motoare",
"mecanici",
]
# Tables that don't have tenant_id directly
NO_TENANT_TABLES = {"catalog_modele"}
async def _get_table_columns(db: AsyncSession, table: str) -> set[str]:
"""Return the set of column names for a given table using PRAGMA table_info."""
rows = await db.execute(text(f"PRAGMA table_info({table})"))
return {row[1] for row in rows}
async def get_full(db: AsyncSession, tenant_id: str) -> dict:
result = {}
for table in SYNCABLE_TABLES:
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 get_changes(db: AsyncSession, tenant_id: str, since: str) -> dict:
result = {}
for table in SYNCABLE_TABLES:
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 AND cm.updated_at > :since"
),
{"tid": tenant_id, "since": since},
)
else:
rows = await db.execute(
text(
f"SELECT * FROM {table} WHERE tenant_id = :tid AND updated_at > :since"
),
{"tid": tenant_id, "since": since},
)
rows_list = [dict(r._mapping) for r in rows]
if rows_list:
result[table] = rows_list
return result
async def apply_push(
db: AsyncSession, tenant_id: str, operations: list
) -> dict:
applied = 0
errors = []
# Cache column sets per table to avoid repeated PRAGMA calls
table_columns_cache: dict[str, set[str]] = {}
for op in operations:
table = op["table"]
if table not in SYNCABLE_TABLES:
continue
data = dict(op.get("data", {}))
# Enforce tenant isolation (except for no-tenant tables)
if table not in NO_TENANT_TABLES:
if data.get("tenant_id") and data["tenant_id"] != tenant_id:
continue
data["tenant_id"] = tenant_id
try:
if op["operation"] in ("INSERT", "UPDATE"):
# Fetch and cache the valid column names for this table
if table not in table_columns_cache:
table_columns_cache[table] = await _get_table_columns(db, table)
valid_cols = table_columns_cache[table]
# Filter data to only include columns that exist in the DB table
filtered = {k: v for k, v in data.items() if k in valid_cols}
if not filtered:
continue
cols = ", ".join(filtered.keys())
ph = ", ".join(f":{k}" for k in filtered.keys())
await db.execute(
text(f"INSERT OR REPLACE INTO {table} ({cols}) VALUES ({ph})"),
filtered,
)
applied += 1
elif op["operation"] == "DELETE":
if table in NO_TENANT_TABLES:
await db.execute(
text(f"DELETE FROM {table} WHERE id = :id"),
{"id": op["id"]},
)
else:
await db.execute(
text(
f"DELETE FROM {table} WHERE id = :id AND tenant_id = :tid"
),
{"id": op["id"], "tid": tenant_id},
)
applied += 1
except Exception as exc: # noqa: BLE001
errors.append({"table": table, "id": op.get("id"), "error": str(exc)})
await db.rollback()
await db.commit()
return {"applied": applied, "conflicts": errors}

View File

View File

@@ -0,0 +1,43 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import get_db
from app.deps import get_current_user, get_tenant_id
from app.users import schemas, service
router = APIRouter()
@router.get("")
async def list_users(
tenant_id: str = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
return await service.list_users(db, tenant_id)
@router.post("/invite")
async def invite_user(
data: schemas.InviteRequest,
current_user: dict = Depends(get_current_user),
tenant_id: str = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
try:
invite = await service.invite_user(db, tenant_id, data.email, data.rol)
return {"token": invite.token, "email": invite.email}
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))
@router.delete("/{user_id}")
async def delete_user(
user_id: str,
tenant_id: str = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
try:
await service.deactivate_user(db, tenant_id, user_id)
return {"ok": True}
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))

View File

@@ -0,0 +1,11 @@
from pydantic import BaseModel, EmailStr
class InviteRequest(BaseModel):
email: EmailStr
rol: str = "mecanic"
class AcceptInviteRequest(BaseModel):
token: str
password: str

View File

@@ -0,0 +1,98 @@
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.service import hash_password
from app.db.base import uuid7
from app.db.models.invite import InviteToken
from app.db.models.user import User
async def invite_user(
db: AsyncSession, tenant_id: str, email: str, rol: str
) -> InviteToken:
# Check if user already exists in this tenant
r = await db.execute(
select(User).where(User.email == email, User.tenant_id == tenant_id)
)
if r.scalar_one_or_none():
raise ValueError("User already exists in this tenant")
token = uuid7()
invite = InviteToken(
id=uuid7(),
tenant_id=tenant_id,
email=email,
rol=rol,
token=token,
)
db.add(invite)
await db.commit()
await db.refresh(invite)
return invite
async def accept_invite(
db: AsyncSession, token: str, password: str
) -> User:
r = await db.execute(
select(InviteToken).where(
InviteToken.token == token, InviteToken.used == None
)
)
invite = r.scalar_one_or_none()
if not invite:
raise ValueError("Invalid or already used invite token")
# Check if email already registered
r = await db.execute(select(User).where(User.email == invite.email))
if r.scalar_one_or_none():
raise ValueError("Email already registered")
user = User(
id=uuid7(),
tenant_id=invite.tenant_id,
email=invite.email,
password_hash=hash_password(password),
nume=invite.email.split("@")[0],
rol=invite.rol,
)
db.add(user)
invite.used = datetime.now(UTC).isoformat()
await db.commit()
await db.refresh(user)
return user
async def list_users(db: AsyncSession, tenant_id: str) -> list:
r = await db.execute(
select(User).where(User.tenant_id == tenant_id)
)
users = r.scalars().all()
return [
{
"id": u.id,
"email": u.email,
"rol": u.rol,
"activ": u.activ,
}
for u in users
]
async def deactivate_user(
db: AsyncSession, tenant_id: str, user_id: str
) -> bool:
r = await db.execute(
select(User).where(User.id == user_id, User.tenant_id == tenant_id)
)
user = r.scalar_one_or_none()
if not user:
raise ValueError("User not found")
if user.rol == "owner":
raise ValueError("Cannot deactivate owner")
user.activ = False
await db.commit()
return True

View File

View File

@@ -0,0 +1,110 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.base import uuid7
from app.db.models.vehicle import Vehicle
from app.db.session import get_db
from app.deps import get_tenant_id
from app.vehicles import schemas
router = APIRouter()
@router.get("")
async def list_vehicles(
tenant_id: str = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
r = await db.execute(
select(Vehicle).where(Vehicle.tenant_id == tenant_id)
)
vehicles = r.scalars().all()
return [
{
"id": v.id,
"nr_auto": v.nr_inmatriculare,
"marca_id": v.marca_id,
"model_id": v.model_id,
"an": v.an_fabricatie,
"client_nume": v.client_nume,
}
for v in vehicles
]
@router.post("")
async def create_vehicle(
data: schemas.CreateVehicleRequest,
tenant_id: str = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
vehicle = Vehicle(
id=uuid7(),
tenant_id=tenant_id,
nr_inmatriculare=data.nr_auto,
marca_id=data.marca_id,
model_id=data.model_id,
an_fabricatie=data.an_fabricatie,
vin=data.vin,
client_nume=data.proprietar_nume,
client_telefon=data.proprietar_telefon,
)
db.add(vehicle)
await db.commit()
return {"id": vehicle.id}
@router.get("/{vehicle_id}")
async def get_vehicle(
vehicle_id: str,
tenant_id: str = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
r = await db.execute(
select(Vehicle).where(
Vehicle.id == vehicle_id, Vehicle.tenant_id == tenant_id
)
)
v = r.scalar_one_or_none()
if not v:
raise HTTPException(status_code=404, detail="Vehicle not found")
return {
"id": v.id,
"nr_auto": v.nr_inmatriculare,
"marca_id": v.marca_id,
"model_id": v.model_id,
"an": v.an_fabricatie,
"vin": v.vin,
"client_nume": v.client_nume,
"client_telefon": v.client_telefon,
"client_email": v.client_email,
}
@router.put("/{vehicle_id}")
async def update_vehicle(
vehicle_id: str,
data: schemas.UpdateVehicleRequest,
tenant_id: str = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
r = await db.execute(
select(Vehicle).where(
Vehicle.id == vehicle_id, Vehicle.tenant_id == tenant_id
)
)
v = r.scalar_one_or_none()
if not v:
raise HTTPException(status_code=404, detail="Vehicle not found")
update_data = data.model_dump(exclude_unset=True)
if "nr_auto" in update_data:
update_data["nr_inmatriculare"] = update_data.pop("nr_auto")
if "proprietar_nume" in update_data:
update_data["client_nume"] = update_data.pop("proprietar_nume")
if "proprietar_telefon" in update_data:
update_data["client_telefon"] = update_data.pop("proprietar_telefon")
for key, value in update_data.items():
setattr(v, key, value)
await db.commit()
return {"ok": True}

View File

@@ -0,0 +1,21 @@
from pydantic import BaseModel
class CreateVehicleRequest(BaseModel):
nr_auto: str
marca_id: str | None = None
model_id: str | None = None
an_fabricatie: int | None = None
vin: str | None = None
proprietar_nume: str | None = None
proprietar_telefon: str | None = None
class UpdateVehicleRequest(BaseModel):
nr_auto: str | None = None
marca_id: str | None = None
model_id: str | None = None
an_fabricatie: int | None = None
vin: str | None = None
proprietar_nume: str | None = None
proprietar_telefon: str | None = None

0
backend/data/.gitkeep Normal file
View File

2
backend/pytest.ini Normal file
View File

@@ -0,0 +1,2 @@
[pytest]
asyncio_mode = auto

14
backend/requirements.txt Normal file
View File

@@ -0,0 +1,14 @@
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

View File

62
backend/tests/conftest.py Normal file
View File

@@ -0,0 +1,62 @@
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
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()
@pytest_asyncio.fixture
async def client():
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as c:
yield c
@pytest_asyncio.fixture
async def auth_headers(client):
r = await client.post(
"/api/auth/register",
json={
"email": "test@service.ro",
"password": "testpass123",
"tenant_name": "Test Service",
"telefon": "0722000000",
},
)
token = r.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
@pytest_asyncio.fixture
async def tenant_id(client):
r = await client.post(
"/api/auth/register",
json={
"email": "tenant@service.ro",
"password": "testpass123",
"tenant_name": "Tenant Service",
"telefon": "0722000001",
},
)
return r.json()["tenant_id"]

105
backend/tests/test_auth.py Normal file
View File

@@ -0,0 +1,105 @@
import pytest
from httpx import ASGITransport, AsyncClient
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"
assert data["token_type"] == "bearer"
assert data["tenant_id"]
@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
@pytest.mark.asyncio
async def test_me_returns_user_info():
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as c:
reg = await c.post(
"/api/auth/register",
json={
"email": "me@test.ro",
"password": "pass123",
"tenant_name": "My Service",
"telefon": "0733",
},
)
token = reg.json()["access_token"]
r = await c.get(
"/api/auth/me",
headers={"Authorization": f"Bearer {token}"},
)
assert r.status_code == 200
data = r.json()
assert data["email"] == "me@test.ro"
assert data["rol"] == "owner"
assert data["plan"] == "trial"
@pytest.mark.asyncio
async def test_me_without_token_returns_403():
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as c:
r = await c.get("/api/auth/me")
assert r.status_code == 401
@pytest.mark.asyncio
async def test_health_endpoint():
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as c:
r = await c.get("/api/health")
assert r.status_code == 200
assert r.json() == {"status": "ok"}

View File

@@ -0,0 +1,145 @@
import uuid
from datetime import UTC, datetime
import pytest
from httpx import ASGITransport, AsyncClient
from app.main import app
async def _create_vehicle(client, auth_headers):
"""Helper to create a vehicle via sync push and return its id."""
me = await client.get("/api/auth/me", headers=auth_headers)
tenant_id = me.json()["tenant_id"]
vid = str(uuid.uuid4())
now = datetime.now(UTC).isoformat()
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": "CT01TST",
"client_nume": "Test Client",
"created_at": now,
"updated_at": now,
},
"timestamp": now,
}
]
},
)
return vid
@pytest.mark.asyncio
async def test_order_workflow(client, auth_headers):
vid = await _create_vehicle(client, auth_headers)
# Create order (DRAFT)
r = await client.post(
"/api/orders",
headers=auth_headers,
json={"vehicle_id": vid},
)
assert r.status_code == 200
order_id = r.json()["id"]
# Add manopera line: 2h x 150 = 300
r = await client.post(
f"/api/orders/{order_id}/lines",
headers=auth_headers,
json={
"tip": "manopera",
"descriere": "Reparatie motor",
"ore": 2,
"pret_ora": 150,
},
)
assert r.status_code == 200
# Add material line: 2 buc x 50 = 100
r = await client.post(
f"/api/orders/{order_id}/lines",
headers=auth_headers,
json={
"tip": "material",
"descriere": "Filtru ulei",
"cantitate": 2,
"pret_unitar": 50,
"um": "buc",
},
)
assert r.status_code == 200
# Validate order
r = await client.post(
f"/api/orders/{order_id}/validate",
headers=auth_headers,
)
assert r.status_code == 200
assert r.json()["status"] == "VALIDAT"
# Get order details
r = await client.get(
f"/api/orders/{order_id}",
headers=auth_headers,
)
assert r.status_code == 200
data = r.json()
assert data["total_manopera"] == 300
assert data["total_materiale"] == 100
assert data["total_general"] == 400
assert data["status"] == "VALIDAT"
assert len(data["lines"]) == 2
@pytest.mark.asyncio
async def test_cannot_add_line_to_validated_order(client, auth_headers):
vid = await _create_vehicle(client, auth_headers)
# Create and validate order
r = await client.post(
"/api/orders",
headers=auth_headers,
json={"vehicle_id": vid},
)
order_id = r.json()["id"]
await client.post(
f"/api/orders/{order_id}/validate",
headers=auth_headers,
)
# Try to add line to validated order
r = await client.post(
f"/api/orders/{order_id}/lines",
headers=auth_headers,
json={
"tip": "manopera",
"descriere": "Should fail",
"ore": 1,
"pret_ora": 100,
},
)
assert r.status_code == 422
@pytest.mark.asyncio
async def test_list_orders(client, auth_headers):
vid = await _create_vehicle(client, auth_headers)
await client.post(
"/api/orders",
headers=auth_headers,
json={"vehicle_id": vid},
)
r = await client.get("/api/orders", headers=auth_headers)
assert r.status_code == 200
assert len(r.json()) == 1

142
backend/tests/test_pdf.py Normal file
View File

@@ -0,0 +1,142 @@
import uuid
from datetime import UTC, datetime
import pytest
from httpx import ASGITransport, AsyncClient
from app.main import app
@pytest.mark.asyncio
async def test_pdf_deviz_returns_pdf(client, auth_headers):
me = await client.get("/api/auth/me", headers=auth_headers)
tenant_id = me.json()["tenant_id"]
vid = str(uuid.uuid4())
now = datetime.now(UTC).isoformat()
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": "CT99PDF",
"client_nume": "PDF Test",
"created_at": now,
"updated_at": now,
},
"timestamp": now,
}
]
},
)
r = await client.post(
"/api/orders",
headers=auth_headers,
json={"vehicle_id": vid},
)
order_id = r.json()["id"]
await client.post(
f"/api/orders/{order_id}/lines",
headers=auth_headers,
json={
"tip": "manopera",
"descriere": "Diagnosticare",
"ore": 0.5,
"pret_ora": 100,
},
)
r = await client.get(
f"/api/orders/{order_id}/pdf/deviz",
headers=auth_headers,
)
assert r.status_code == 200
assert r.headers["content-type"] == "application/pdf"
assert r.content[:4] == b"%PDF"
@pytest.mark.asyncio
async def test_invoice_workflow(client, auth_headers):
me = await client.get("/api/auth/me", headers=auth_headers)
tenant_id = me.json()["tenant_id"]
vid = str(uuid.uuid4())
now = datetime.now(UTC).isoformat()
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": "B01INV",
"client_nume": "Factura Test",
"created_at": now,
"updated_at": now,
},
"timestamp": now,
}
]
},
)
# Create order + line + validate
r = await client.post(
"/api/orders",
headers=auth_headers,
json={"vehicle_id": vid},
)
order_id = r.json()["id"]
await client.post(
f"/api/orders/{order_id}/lines",
headers=auth_headers,
json={
"tip": "material",
"descriere": "Filtru aer",
"cantitate": 1,
"pret_unitar": 80,
"um": "buc",
},
)
await client.post(
f"/api/orders/{order_id}/validate",
headers=auth_headers,
)
# Create invoice
r = await client.post(
"/api/invoices",
headers=auth_headers,
json={"order_id": order_id},
)
assert r.status_code == 200
data = r.json()
assert "id" in data
assert "nr_factura" in data
assert data["nr_factura"].startswith("F-")
# Get invoice PDF
invoice_id = data["id"]
r = await client.get(
f"/api/invoices/{invoice_id}/pdf",
headers=auth_headers,
)
assert r.status_code == 200
assert r.headers["content-type"] == "application/pdf"
assert r.content[:4] == b"%PDF"

View File

@@ -0,0 +1,101 @@
import uuid
from datetime import UTC, datetime
import pytest
from httpx import ASGITransport, AsyncClient
from app.main import app
async def _setup_order_with_lines(client, auth_headers):
"""Create vehicle + order + lines, return order details."""
me = await client.get("/api/auth/me", headers=auth_headers)
tenant_id = me.json()["tenant_id"]
vid = str(uuid.uuid4())
now = datetime.now(UTC).isoformat()
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": "B99PDF",
"client_nume": "Ion Popescu",
"created_at": now,
"updated_at": now,
},
"timestamp": now,
}
]
},
)
r = await client.post(
"/api/orders",
headers=auth_headers,
json={"vehicle_id": vid},
)
order_id = r.json()["id"]
await client.post(
f"/api/orders/{order_id}/lines",
headers=auth_headers,
json={
"tip": "manopera",
"descriere": "Schimb ulei",
"ore": 1,
"pret_ora": 150,
},
)
# Get order to find token_client
r = await client.get(f"/api/orders/{order_id}", headers=auth_headers)
return r.json()
@pytest.mark.asyncio
async def test_portal_get_deviz(client, auth_headers):
order = await _setup_order_with_lines(client, auth_headers)
token = order["token_client"]
# Portal access is public (no auth headers)
r = await client.get(f"/api/p/{token}")
assert r.status_code == 200
data = r.json()
assert "order" in data
assert "tenant" in data
assert "lines" in data
assert data["order"]["nr_auto"] == "B99PDF"
@pytest.mark.asyncio
async def test_portal_accept(client, auth_headers):
order = await _setup_order_with_lines(client, auth_headers)
token = order["token_client"]
r = await client.post(f"/api/p/{token}/accept")
assert r.status_code == 200
assert r.json()["ok"] is True
@pytest.mark.asyncio
async def test_portal_reject(client, auth_headers):
order = await _setup_order_with_lines(client, auth_headers)
token = order["token_client"]
r = await client.post(f"/api/p/{token}/reject")
assert r.status_code == 200
assert r.json()["ok"] is True
@pytest.mark.asyncio
async def test_portal_invalid_token(client):
r = await client.get("/api/p/invalid-token")
assert r.status_code == 404

330
backend/tests/test_sync.py Normal file
View File

@@ -0,0 +1,330 @@
import uuid
from datetime import UTC, datetime
import pytest
from httpx import ASGITransport, AsyncClient
from app.main import app
async def _get_tenant_id(client, auth_headers):
me = await client.get("/api/auth/me", headers=auth_headers)
return me.json()["tenant_id"]
@pytest.mark.asyncio
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"]
assert "orders" in data["tables"]
assert "mecanici" in data["tables"]
@pytest.mark.asyncio
async def test_sync_push_insert_vehicle(client, auth_headers):
# Get tenant_id from /me
me = await client.get("/api/auth/me", headers=auth_headers)
tenant_id = me.json()["tenant_id"]
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
# Verify via full sync
full = await client.get("/api/sync/full", headers=auth_headers)
vehicles = full.json()["tables"]["vehicles"]
assert len(vehicles) == 1
assert vehicles[0]["nr_inmatriculare"] == "CTA01ABC"
@pytest.mark.asyncio
async def test_sync_changes_since(client, auth_headers):
me = await client.get("/api/auth/me", headers=auth_headers)
tenant_id = me.json()["tenant_id"]
before = datetime.now(UTC).isoformat()
vid = str(uuid.uuid4())
now = datetime.now(UTC).isoformat()
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": "B99XYZ",
"created_at": now,
"updated_at": now,
},
"timestamp": now,
}
]
},
)
r = await client.get(
f"/api/sync/changes?since={before}", headers=auth_headers
)
assert r.status_code == 200
data = r.json()
assert "vehicles" in data["tables"]
@pytest.mark.asyncio
async def test_sync_push_rejects_wrong_tenant(client, auth_headers):
now = datetime.now(UTC).isoformat()
vid = str(uuid.uuid4())
r = await client.post(
"/api/sync/push",
headers=auth_headers,
json={
"operations": [
{
"table": "vehicles",
"id": vid,
"operation": "INSERT",
"data": {
"id": vid,
"tenant_id": "wrong-tenant-id",
"nr_inmatriculare": "HACK",
"created_at": now,
"updated_at": now,
},
"timestamp": now,
}
]
},
)
assert r.status_code == 200
# Wrong tenant_id is rejected (skipped)
assert r.json()["applied"] == 0
@pytest.mark.asyncio
async def test_sync_push_insert_order_with_frontend_fields(client, auth_headers):
"""Frontend sends nr_comanda, client_nume, nr_auto, marca_denumire, model_denumire.
Backend must accept these without 500."""
tenant_id = await _get_tenant_id(client, auth_headers)
# First insert a vehicle
vid = str(uuid.uuid4())
now = datetime.now(UTC).isoformat()
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": "B01TST",
"client_nume": "Ion Popescu",
"client_telefon": "0722000000",
"client_cod_fiscal": "RO12345",
"serie_sasiu": "WBA1234567890",
"created_at": now,
"updated_at": now,
},
"timestamp": now,
}
]
},
)
# Now insert an order like the frontend does
oid = str(uuid.uuid4())
r = await client.post(
"/api/sync/push",
headers=auth_headers,
json={
"operations": [
{
"table": "orders",
"id": oid,
"operation": "INSERT",
"data": {
"id": oid,
"tenant_id": tenant_id,
"nr_comanda": "CMD-ABC123",
"data_comanda": now,
"vehicle_id": vid,
"tip_deviz_id": None,
"status": "DRAFT",
"km_intrare": 50000,
"observatii": "Test order",
"client_nume": "Ion Popescu",
"client_telefon": "0722000000",
"nr_auto": "B01TST",
"marca_denumire": "Dacia",
"model_denumire": "Logan",
"created_at": now,
"updated_at": now,
},
"timestamp": now,
}
]
},
)
assert r.status_code == 200
result = r.json()
assert result["applied"] == 1
assert result["conflicts"] == []
# Verify the order appears in full sync
full = await client.get("/api/sync/full", headers=auth_headers)
orders = full.json()["tables"]["orders"]
assert any(o["id"] == oid for o in orders)
order = next(o for o in orders if o["id"] == oid)
assert order["nr_comanda"] == "CMD-ABC123"
assert order["client_nume"] == "Ion Popescu"
assert order["marca_denumire"] == "Dacia"
@pytest.mark.asyncio
async def test_sync_push_unknown_columns_ignored(client, auth_headers):
"""If frontend sends extra fields not in the DB schema, they must be silently
ignored (not cause a 500 error)."""
tenant_id = await _get_tenant_id(client, auth_headers)
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": "CT99XYZ",
"created_at": now,
"updated_at": now,
"oracle_id": 12345, # frontend-only field
"nonexistent_column": "boom", # completely unknown field
},
"timestamp": now,
}
]
},
)
assert r.status_code == 200
result = r.json()
assert result["applied"] == 1
assert result["conflicts"] == []
@pytest.mark.asyncio
async def test_sync_push_insert_order_line_with_frontend_fields(client, auth_headers):
"""Frontend sends norma_id, mecanic_id, ordine in order_line — must not cause 500."""
tenant_id = await _get_tenant_id(client, auth_headers)
now = datetime.now(UTC).isoformat()
# Create order first
vid = str(uuid.uuid4())
oid = str(uuid.uuid4())
lid = str(uuid.uuid4())
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": "B02TST", "created_at": now, "updated_at": now},
"timestamp": now,
},
{
"table": "orders",
"id": oid,
"operation": "INSERT",
"data": {
"id": oid, "tenant_id": tenant_id,
"nr_comanda": "CMD-XYZ", "status": "DRAFT",
"vehicle_id": vid, "created_at": now, "updated_at": now,
},
"timestamp": now,
},
]
},
)
r = await client.post(
"/api/sync/push",
headers=auth_headers,
json={
"operations": [
{
"table": "order_lines",
"id": lid,
"operation": "INSERT",
"data": {
"id": lid,
"order_id": oid,
"tenant_id": tenant_id,
"tip": "manopera",
"descriere": "Schimb ulei",
"norma_id": None,
"ore": 1.5,
"pret_ora": 150.0,
"um": "ora",
"cantitate": 0,
"pret_unitar": 0,
"total": 225.0,
"mecanic_id": None,
"ordine": 1,
"created_at": now,
"updated_at": now,
},
"timestamp": now,
}
]
},
)
assert r.status_code == 200
result = r.json()
assert result["applied"] == 1
assert result["conflicts"] == []

111
backend/tests/test_users.py Normal file
View File

@@ -0,0 +1,111 @@
import pytest
from httpx import ASGITransport, AsyncClient
from app.main import app
@pytest.mark.asyncio
async def test_invite_and_accept_flow(client, auth_headers):
# Invite a new user
r = await client.post(
"/api/users/invite",
headers=auth_headers,
json={"email": "mecanic@service.ro", "rol": "mecanic"},
)
assert r.status_code == 200
data = r.json()
assert "token" in data
assert data["email"] == "mecanic@service.ro"
invite_token = data["token"]
# Accept invite (creates user + returns JWT)
r = await client.post(
"/api/auth/accept-invite",
json={"token": invite_token, "password": "mecanic123"},
)
assert r.status_code == 200
data = r.json()
assert "access_token" in data
assert data["token_type"] == "bearer"
# New user can access /me
new_headers = {"Authorization": f"Bearer {data['access_token']}"}
r = await client.get("/api/auth/me", headers=new_headers)
assert r.status_code == 200
me = r.json()
assert me["email"] == "mecanic@service.ro"
assert me["rol"] == "mecanic"
@pytest.mark.asyncio
async def test_list_users(client, auth_headers):
r = await client.get("/api/users", headers=auth_headers)
assert r.status_code == 200
users = r.json()
assert len(users) >= 1
assert users[0]["rol"] == "owner"
@pytest.mark.asyncio
async def test_deactivate_user(client, auth_headers):
# Invite and accept a user first
r = await client.post(
"/api/users/invite",
headers=auth_headers,
json={"email": "delete@service.ro", "rol": "mecanic"},
)
invite_token = r.json()["token"]
await client.post(
"/api/auth/accept-invite",
json={"token": invite_token, "password": "pass123"},
)
# List users to get the new user's id
r = await client.get("/api/users", headers=auth_headers)
users = r.json()
mecanic = next(u for u in users if u["email"] == "delete@service.ro")
# Deactivate
r = await client.delete(
f"/api/users/{mecanic['id']}", headers=auth_headers
)
assert r.status_code == 200
assert r.json()["ok"] is True
@pytest.mark.asyncio
async def test_cannot_deactivate_owner(client, auth_headers):
r = await client.get("/api/users", headers=auth_headers)
users = r.json()
owner = next(u for u in users if u["rol"] == "owner")
r = await client.delete(
f"/api/users/{owner['id']}", headers=auth_headers
)
assert r.status_code == 422
@pytest.mark.asyncio
async def test_invalid_invite_token(client):
r = await client.post(
"/api/auth/accept-invite",
json={"token": "invalid-token", "password": "pass123"},
)
assert r.status_code == 422
@pytest.mark.asyncio
async def test_duplicate_invite(client, auth_headers):
# Invite same email twice - first should succeed, second may fail if user exists
await client.post(
"/api/users/invite",
headers=auth_headers,
json={"email": "dup@service.ro", "rol": "mecanic"},
)
# Second invite for same email is allowed (user not created yet)
r = await client.post(
"/api/users/invite",
headers=auth_headers,
json={"email": "dup@service.ro", "rol": "admin"},
)
assert r.status_code == 200

26
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,26 @@
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"

33
docker-compose.yml Normal file
View File

@@ -0,0 +1,33 @@
services:
backend:
build: ./backend
restart: always
env_file: .env
volumes:
- backend-data:/app/data
environment:
- DATABASE_URL=sqlite+aiosqlite:///./data/roaauto.db
- 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
start_period: 15s
frontend:
build: ./frontend
restart: always
ports:
- "80:80"
depends_on:
backend:
condition: service_healthy
volumes:
backend-data:
driver: local
driver_opts:
type: none
o: bind
device: ./backend/data

120
docs/DEPLOY.md Normal file
View File

@@ -0,0 +1,120 @@
# ROA AUTO - Deploy Guide
## Prerequisites
- Docker & Docker Compose installed
- Domain configured (e.g., roaauto.romfast.ro)
- Cloudflare Tunnel configured (or reverse proxy)
## Production Deploy on Dokploy
### 1. Clone and configure
```bash
git clone <repo-url> roaauto
cd roaauto
cp .env.example .env
```
Edit `.env` with production values:
```
SECRET_KEY=<generate-a-strong-secret>
DATABASE_URL=sqlite+aiosqlite:///./data/roaauto.db
SMSAPI_TOKEN=<your-smsapi-token>
CORS_ORIGINS=https://roaauto.romfast.ro
```
Generate a secret key:
```bash
python3 -c "import secrets; print(secrets.token_urlsafe(64))"
```
### 2. Build and start
```bash
make prod-build
make prod-up
```
Verify:
```bash
curl http://localhost/api/health
# {"status":"ok"}
```
### 3. Run initial migration
```bash
docker compose exec backend alembic upgrade head
```
### 4. Seed catalog data (first deploy only)
```bash
docker compose exec backend python -m app.db.seed
```
## Cloudflare Tunnel Setup
1. Install `cloudflared` on the Proxmox host
2. Create a tunnel: `cloudflared tunnel create roaauto`
3. Configure the tunnel to route `roaauto.romfast.ro` to `http://localhost:80`
4. Run as a service: `cloudflared service install`
The tunnel provides HTTPS termination - nginx listens on port 80 internally.
## Dokploy Configuration
If using Dokploy instead of manual Docker:
1. Create a new project in Dokploy
2. Set source to your Git repository
3. Set compose file to `docker-compose.yml`
4. Add environment variables from `.env.example`
5. Set the domain to `roaauto.romfast.ro`
6. Deploy
## Maintenance
### Logs
```bash
make prod-logs
```
### Database backup
```bash
make backup
```
### Update deployment
```bash
git pull
make prod-build
make prod-up
```
### Rollback
```bash
git checkout <previous-commit>
make prod-build
make prod-up
```
## Architecture
```
Internet → Cloudflare Tunnel → nginx (:80)
├── / → SPA (static files)
└── /api → backend (:8000)
backend: Python 3.12 + FastAPI + SQLite (file-based)
frontend: Vue 3 SPA served by nginx
data: ./backend/data/ (bind mount, persisted)
```

View File

@@ -245,13 +245,18 @@ catalog_tipuri_deviz (id, tenant_id, denumire)
catalog_tipuri_motoare (id, tenant_id, denumire)
mecanici (id, tenant_id, user_id, nume, prenume, activ)
-- Clients (nomenclator clienti cu date eFactura ANAF)
clients (id, tenant_id, tip_persoana, denumire, cod_fiscal, reg_com,
adresa, judet, oras, cod_postal, tara, telefon, email,
cont_iban, banca, observatii, activ, created_at, updated_at)
-- Core Business
vehicles (id, tenant_id, client_nume, client_telefon, client_email,
vehicles (id, tenant_id, client_id, client_nume, client_telefon, client_email,
client_cod_fiscal, client_adresa, nr_inmatriculare,
marca_id, model_id, an_fabricatie, serie_sasiu,
tip_motor_id, created_at, updated_at)
orders (id, tenant_id, nr_comanda, data_comanda, vehicle_id,
orders (id, tenant_id, nr_comanda, data_comanda, vehicle_id, client_id,
tip_deviz_id, status, km_intrare, observatii,
-- client snapshot (denormalized)
client_nume, client_telefon, nr_auto, marca_denumire, model_denumire,
@@ -266,8 +271,8 @@ order_lines (id, order_id, tenant_id, tip, descriere,
um, cantitate, pret_unitar, -- material
total, mecanic_id, ordine, created_at, updated_at)
invoices (id, tenant_id, order_id, nr_factura, serie_factura,
data_factura, modalitate_plata,
invoices (id, tenant_id, order_id, client_id, nr_factura, serie_factura,
data_factura, tip_document, modalitate_plata,
client_nume, client_cod_fiscal, nr_auto,
total_fara_tva, tva, total_general, created_at, updated_at)
@@ -379,6 +384,46 @@ _sync_state (table_name, last_sync_at)
4. Responsive testing (phone, tablet, desktop)
5. Reports: sumar lunar, export CSV
### Faza 8: Nomenclator Clienti (Clients)
**Livrabil: CRUD clienti cu date eFactura ANAF, legatura 1:N cu vehicule**
1. Model `clients` + migrare Alembic (backend)
2. `client_id` FK pe `vehicles`, `orders`, `invoices`
3. CRUD endpoints: `GET/POST /api/clients`, `GET/PUT/DELETE /api/clients/{id}`
4. wa-sqlite schema update (tabel `clients`, FK-uri)
5. Frontend: pagina Clienti (list, create, edit, delete)
6. Frontend: selector client in VehiclePicker si OrderCreate
7. Sync: adauga `clients` in `SYNCABLE_TABLES`
8. Playwright E2E tests (desktop + mobile)
### Faza 9: Edit/Delete/Devalidare Comenzi
**Livrabil: Gestionare completa comenzi - edit, stergere, devalidare**
1. `PUT /api/orders/{id}` - edit header comanda (doar in DRAFT)
2. `DELETE /api/orders/{id}` - stergere comanda (orice nefacturat)
3. `POST /api/orders/{id}/devalidate` - VALIDAT DRAFT
4. `DELETE /api/invoices/{id}` - stergere factura (permite stergere comanda FACTURAT)
5. Frontend: butoane edit/delete/devalidare pe OrderDetail
6. Confirmare stergere cu modal
7. Playwright E2E tests
### Faza 10: Integrare Nomenclator Clienti
**Livrabil: Clienti integrati in flux comenzi si facturi**
1. Auto-populare date client pe comanda din nomenclator
2. Selectie client existent sau creare client nou la vehicul
3. Validare date client complete la facturare (CUI, adresa)
4. PDF factura cu date client din nomenclator
### Faza 11: Bon Fiscal (tip_document)
**Livrabil: Suport dual FACTURA + BON_FISCAL pe invoices**
1. `tip_document` pe invoices: FACTURA (B2B, eFactura) sau BON_FISCAL (B2C, casa de marcat)
2. Factura: necesita date client complete (CUI, adresa)
3. Bon fiscal: format simplificat, fara date client obligatorii
4. UI: selectie tip document la facturare
5. PDF template diferentiat pentru bon fiscal
---
## Referinta din Prototip (doar consultare)
@@ -405,6 +450,9 @@ SaaS-ul trebuie sa fie compatibil cu ROAAUTO VFP9+Oracle. Clientii care cresc po
| `order_lines` (tip=manopera) | `dev_oper` | SaaS unifica oper+materiale in order_lines |
| `order_lines` (tip=material) | `dev_estimari_produse` | Acelasi tabel, filtrat pe `tip` |
| `vehicles` | `dev_masiniclienti` | Renamed, aceleasi coloane client+vehicul |
| `clients` | `nom_parteneri` + `adrese_parteneri` | Adrese simplificate flat |
| `clients.tip_persoana` | `nom_parteneri.tip_persoana` | PF/PJ |
| `clients.cod_fiscal` | `nom_parteneri.cod_fiscal` | CUI sau CNP |
| `catalog_marci` | `dev_nom_marci` | +tenant_id |
| `catalog_modele` | `dev_nom_masini` | Identic |
| `catalog_ansamble` | `dev_nom_ansamble` | +tenant_id |
@@ -414,6 +462,10 @@ SaaS-ul trebuie sa fie compatibil cu ROAAUTO VFP9+Oracle. Clientii care cresc po
| `catalog_tipuri_motoare` | `dev_tipuri_motoare` | +tenant_id |
| `mecanici` | `dev_mecanici` | +tenant_id, +user_id |
| `invoices` | `facturi` (local) | Identic structural |
| `invoices.tip_document` | `vanzari.tip_factura` | FACTURA/BON_FISCAL |
| `invoices.client_id` | `vanzari.id_part` | FK la client |
| `orders.client_id` | (denormalizat) | Referinta directa la client |
| `vehicles.client_id` | (implicit in dev_masiniclienti) | 1:N client vehicule |
| `tenants` | - | Doar SaaS (nu exista in Oracle) |
| `users` | - | Doar SaaS |
| `appointments` | - | Doar SaaS (feature nou) |

98
docs/api-contract.json Normal file
View File

@@ -0,0 +1,98 @@
{
"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": {
"clients": [], "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", "client_id": "str", "tip_deviz_id": "str", "km_intrare": 0, "observatii": "str"}, "response": {"id": "str"}},
"GET /orders/{id}": {"response": {"id": "str", "status": "str", "lines": []}},
"PUT /orders/{id}": {"body": {"vehicle_id": "str", "client_id": "str", "tip_deviz_id": "str", "km_intrare": 0, "observatii": "str"}, "note": "Edit header - doar in DRAFT"},
"DELETE /orders/{id}": {"response": {"ok": true}, "note": "Stergere - orice nefacturat; FACTURAT = sterge factura intai"},
"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"}},
"POST /orders/{id}/devalidate": {"response": {"status": "DRAFT"}, "note": "VALIDAT → DRAFT"},
"GET /orders/{id}/pdf/deviz": {"response": "application/pdf"}
},
"clients": {
"GET /clients": {"response": [{"id": "str", "tip_persoana": "PF|PJ", "denumire": "str", "cod_fiscal": "str", "telefon": "str", "email": "str", "activ": true}]},
"POST /clients": {"body": {"tip_persoana": "PF|PJ", "denumire": "str", "cod_fiscal": "str", "reg_com": "str", "adresa": "str", "judet": "str", "oras": "str", "cod_postal": "str", "tara": "str", "telefon": "str", "email": "str", "cont_iban": "str", "banca": "str", "observatii": "str"}, "response": {"id": "str"}},
"GET /clients/{id}": {"response": {"id": "str", "tip_persoana": "str", "denumire": "str", "cod_fiscal": "str", "vehicles": []}},
"PUT /clients/{id}": {"body": {"denumire": "str", "cod_fiscal": "str", "adresa": "str"}, "response": {"id": "str"}},
"DELETE /clients/{id}": {"response": {"ok": true}}
},
"vehicles": {
"GET /vehicles": {"response": [{"id": "str", "nr_auto": "str", "marca": "str", "model": "str", "an": 0}]},
"POST /vehicles": {"body": {"nr_auto": "str", "marca_id": "str", "model_id": "str", "an_fabricatie": 0, "vin": "str", "proprietar_nume": "str", "proprietar_telefon": "str"}},
"GET /vehicles/{id}": {"response": {"id": "str", "nr_auto": "str", "orders": []}},
"PUT /vehicles/{id}": {"body": {}}
},
"client_portal": {
"GET /p/{token}": {"response": {"order": {}, "tenant": {}, "lines": []}},
"POST /p/{token}/accept": {"response": {"ok": true}},
"POST /p/{token}/reject": {"response": {"ok": true}}
},
"invoices": {
"POST /invoices": {"body": {"order_id": "str", "client_id": "str", "tip_document": "FACTURA|BON_FISCAL"}, "response": {"id": "str", "nr_factura": "str"}},
"GET /invoices/{id}/pdf": {"response": "application/pdf"},
"DELETE /invoices/{id}": {"response": {"ok": true}, "note": "Sterge factura, comanda revine la VALIDAT"}
},
"users": {
"GET /users": {"response": [{"id": "str", "email": "str", "rol": "str"}]},
"POST /users/invite": {"body": {"email": "str", "rol": "admin|mecanic"}},
"DELETE /users/{id}": {"response": {"ok": true}},
"POST /auth/accept-invite": {"body": {"token": "str", "password": "str"}}
},
"appointments": {
"GET /appointments": {"response": [{"id": "str", "data": "str", "vehicle_id": "str", "descriere": "str"}]},
"POST /appointments": {"body": {"vehicle_id": "str", "data": "ISO8601", "descriere": "str"}},
"PUT /appointments/{id}": {"body": {}},
"DELETE /appointments/{id}": {"response": {"ok": true}}
},
"catalog": {
"GET /catalog/marci": {"response": [{"id": "str", "nume": "str"}]},
"GET /catalog/modele": {"params": {"marca_id": "str"}, "response": [{"id": "str", "nume": "str"}]},
"GET /catalog/norme": {"params": {"ansamblu_id": "str"}, "response": [{"id": "str", "descriere": "str", "ore": 0}]},
"GET /catalog/preturi": {"response": [{"id": "str", "tip": "str", "valoare": 0}]}
},
"health": {
"GET /health": {"response": {"status": "ok", "version": "str"}}
}
}

View File

@@ -0,0 +1,68 @@
# Playwright Test Report - 2026-03-14
## Summary
- Total: 31
- Pass: 27
- Fail: 2
- Skipped: 2
## Notes
- The app uses in-memory SQLite (wa-sqlite `:memory:`). On full page reload, all local data is lost and must be re-synced.
- Sync had errors due to schema mismatches (`table vehicles has no column named vi...`, `table catalog_marci has no column named...`), which prevented demo data from loading. All tests were performed with locally-created data.
- The `tip_deviz` dropdown was empty because `tipuri_deviz` table had no data (sync failed).
## Desktop Tests (1280x720)
| Test | Description | Status | Notes |
|------|-------------|--------|-------|
| T1 | Login + Dashboard | PASS | Login with demo@roaauto.ro works. Dashboard shows stats cards (Total, Draft, Validate, Facturate) and orders table with filter tabs. |
| T2 | Comanda noua button | PASS | Clicking "+ Comanda noua" navigates to /orders/new with step-by-step wizard. |
| T3 | Full order creation | PASS | Created order with inline client+vehicle. Redirected to order detail with correct data (B 999 TST, Test SRL E2E, Dacia Logan, KM 55000). |
| T4 | Inline client creation (PJ) | PASS | "+ Client nou" opens inline form. Switching to PJ shows denumire/CUI/telefon fields. Client created and auto-selected with "(RO12345678)" display. |
| T5 | Inline vehicle creation | PASS | "+ Vehicul nou" opens form. "+" buttons for marca/model allow inline creation (Dacia/Logan). Vehicle created and auto-selected as "B 999 TST - (Dacia Logan)". |
| T6 | Clients page | PASS | Added PF client (Popescu Ion). Search by name filters correctly. Click row opens edit form with pre-filled data. Updated email saved successfully. |
| T7 | Edit order header | PASS | "Editeaza" opens edit form with pre-filled fields. Changed KM from 55000 to 60000 and observatii. Changes persisted after save. |
| T8 | Add manopera from nomenclator | PASS | Manopera line "Schimb ulei motor" added with 2h * 100 RON/h = 200.00 RON. Line appears in table with MAN badge. Autocomplete from catalog_norme not testable (no synced data). |
| T9 | Add material from catalog | PASS | Material line "Filtru ulei" added with 1 buc * 50 RON = 50.00 RON. MAT badge shown. Totals updated: Manopera 200, Materiale 50, Total general 250. |
| T10 | Validate then devalidate | PASS | "Valideaza" changes status to VALIDAT, shows PDF Deviz/Devalideaza/Factureaza buttons, hides line delete buttons. "Devalideaza" with confirmation reverts to DRAFT. |
| T11 | Delete DRAFT order | PASS | "Sterge" shows confirmation dialog. "Da, sterge" deletes order and redirects to /dashboard. |
| T12 | Dashboard filters | PASS | Draft tab shows "Nicio comanda gasita" (0 draft). Facturate tab shows FACTURAT orders. Toate shows all. Validate tab works. |
| T13 | Dashboard search | PASS | Searching "B 999" finds matching order. Searching "XXXXXX" shows "Nicio comanda gasita". |
| T14 | /orders redirect | PASS | Navigating to /orders redirects to /dashboard. |
| T15 | Catalog Norme | PASS | Added ansamblu "Motor" first. Then added norma NRM-001 "Schimb ulei si filtre" with ansamblu Motor and 1.5 ore normate. Appears in table. |
| T16 | Facturare dialog | PASS | "Factureaza" on VALIDAT order shows "Tip document" dialog with Factura (default) and Bon fiscal radio options. |
| T17 | Factura creation | PASS | Choosing Factura and clicking "Creeaza" changes status to FACTURAT. "Sterge factura" button appears. |
| T18 | Bon fiscal creation | PASS | After deleting invoice, re-factureaza with "Bon fiscal" selected. Status becomes FACTURAT. Invoice number starts with "ROABF-" confirming Bon Fiscal type. |
| T19 | InvoicesView | PASS | /invoices shows table with Nr. factura, Tip (BON FISCAL badge), Data, Client, Nr. auto, Total, PDF download button. |
| T20 | Delete invoice | PASS | "Sterge factura" shows confirmation. "Da, sterge" reverts order to VALIDAT with Devalideaza/Factureaza buttons restored. |
## Mobile Tests (375x812)
| Test | Description | Status | Notes |
|------|-------------|--------|-------|
| T21 | Dashboard responsive | PASS | Stats cards stack in 2x2 grid. Filter buttons and search visible. "+ Comanda noua" button accessible. Bottom nav visible. |
| T22 | Order form responsive | PASS | Form fields stack vertically. Client picker full-width. "+ Client nou" button accessible. "Inapoi" link visible. |
| T23 | OrderDetail responsive | FAIL | Action buttons (Editeaza, Valideaza, Sterge, DRAFT badge) crowd/overlap with order number heading on narrow screens. The CMD number wraps to 2 lines while buttons stack alongside. Totals area partially obscured by bottom nav. |
| T24 | Clients responsive | PASS | Heading and "+ Adauga client" button fit. Search field full-width. "Clienti" highlighted in bottom nav. Table replaced with empty state message on fresh load. |
| T25 | Bottom nav | PASS | Bottom navigation shows on mobile with: Acasa, Clienti, Vehicule, Programari, Setari. Active link is highlighted. |
## Visual Checks
| Test | Description | Status | Notes |
|------|-------------|--------|-------|
| T26 | Overflow check | PASS | No horizontal overflow detected on any page. Content stays within viewport bounds on mobile. |
| T27 | Button spacing | FAIL | On mobile OrderDetail, the action buttons (Editeaza/Valideaza/Sterge) and DRAFT badge are cramped with the heading. They flow into the same line space as the 2-line CMD number, creating a crowded layout. Recommend stacking buttons below heading on mobile. |
| T28 | Text truncation | PASS | No problematic text truncation observed. Client names, plate numbers, and amounts display fully. |
| T29 | Modal/dialog display | PASS | Factureaza "Tip document" modal displays centered on desktop with backdrop. Delete confirmation dialogs render properly on both desktop and mobile. |
| T30 | Badge colors | PASS | MAN (blue), MAT (purple/pink), DRAFT (yellow outline), VALIDAT (green), FACTURAT (green), PF (gray), PJ (blue), BON FISCAL (dark) - all badges render with distinct colors. |
| T31 | Dialog on mobile | PASS | Delete confirmation dialog renders inline on mobile, fully readable with accessible buttons. No overflow or clipping. |
## Sync Issues Observed
- `SQLiteError: table vehicles has no column named vi...` - repeated on fullSync
- `SQLiteError: table catalog_marci has no column named...` - on fullSync
- These errors prevented demo seed data from syncing to the frontend. The schema in `frontend/src/db/schema.js` may be out of sync with the backend model changes.
## Recommendations
1. **Fix mobile OrderDetail layout (T23/T27)**: Stack action buttons below the heading on screens < 640px. Consider using `flex-wrap` or a separate row for buttons.
2. **Fix sync schema mismatch**: The `vehicles` and `catalog_marci` tables in `frontend/src/db/schema.js` are missing columns that the backend sync sends. This breaks fullSync for all users.
3. **Bottom nav overlap**: On order detail mobile, the totals section at the bottom is partially hidden behind the bottom navigation bar. Add `padding-bottom` to account for the fixed bottom nav height.

7
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
node_modules
dist
.git
.gitignore
*.md
.env
.env.*

12
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
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
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

16
frontend/index.html Normal file
View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="ro">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#111827" />
<meta name="description" content="ROA AUTO - Management Service Auto" />
<link rel="apple-touch-icon" href="/icon-192.png" />
<title>ROA AUTO</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

46
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,46 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_min_length 256;
gzip_types
text/plain
text/css
text/javascript
application/javascript
application/json
application/manifest+json
image/svg+xml;
# SPA routing
location / {
try_files $uri $uri/ /index.html;
}
# Static assets - long cache
location /assets {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Service worker - no cache
location /sw.js {
expires off;
add_header Cache-Control "no-store, no-cache, must-revalidate";
}
# API proxy
location /api {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
}
}

6861
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
frontend/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"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"
}
}

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" rx="20" fill="#111827"/>
<text x="50" y="38" font-family="Arial,sans-serif" font-size="22" font-weight="bold" fill="#fff" text-anchor="middle">ROA</text>
<text x="50" y="68" font-family="Arial,sans-serif" font-size="22" font-weight="bold" fill="#3b82f6" text-anchor="middle">AUTO</text>
</svg>

After

Width:  |  Height:  |  Size: 394 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 547 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

18
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,18 @@
<template>
<router-view v-if="route.meta.layout === 'none'" />
<component v-else :is="layout">
<router-view />
</component>
</template>
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import AppLayout from './layouts/AppLayout.vue'
import AuthLayout from './layouts/AuthLayout.vue'
const route = useRoute()
const layouts = { app: AppLayout, auth: AuthLayout }
const layout = computed(() => layouts[route.meta.layout] || AppLayout)
</script>

View File

@@ -0,0 +1 @@
@import "tailwindcss";

View File

@@ -0,0 +1,211 @@
<template>
<div class="relative">
<label v-if="label" class="block text-sm font-medium text-gray-700 mb-1">{{ label }}</label>
<input
v-model="query"
type="text"
:placeholder="placeholder"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
@input="onSearch"
@focus="showDropdown = true"
/>
<!-- Selected client display -->
<div v-if="selected" class="mt-1 text-sm text-gray-600">
{{ displayName(selected) }}
<span v-if="selected.cod_fiscal" class="text-gray-400"> ({{ selected.cod_fiscal }})</span>
<button @click="clear" class="ml-2 text-red-500 hover:text-red-700 text-xs">Sterge</button>
</div>
<!-- Dropdown results -->
<ul
v-if="showDropdown && results.length > 0"
class="absolute z-10 mt-1 w-full bg-white border border-gray-200 rounded-md shadow-lg max-h-48 overflow-auto"
>
<li
v-for="c in results"
:key="c.id"
class="px-3 py-2 hover:bg-blue-50 cursor-pointer text-sm"
@mousedown="selectClient(c)"
>
<span class="font-medium">{{ displayName(c) }}</span>
<span v-if="c.cod_fiscal" class="text-gray-400 ml-2">({{ c.cod_fiscal }})</span>
<span v-if="c.telefon" class="text-gray-400 ml-2">{{ c.telefon }}</span>
</li>
</ul>
<!-- No results -->
<div
v-if="showDropdown && query.length >= 2 && results.length === 0 && !loading"
class="absolute z-10 mt-1 w-full bg-white border border-gray-200 rounded-md shadow-lg p-3 text-sm text-gray-500"
>
Niciun client gasit
</div>
<!-- Create new client inline -->
<div v-if="!selected" class="mt-2">
<button
type="button"
@click="showNewForm = !showNewForm"
class="text-sm text-blue-600 hover:underline"
>
{{ showNewForm ? 'Ascunde formular' : '+ Client nou' }}
</button>
<div v-if="showNewForm" class="mt-3 space-y-3 bg-gray-50 rounded-lg p-4">
<div class="flex gap-4 mb-2">
<label class="flex items-center gap-1.5 text-xs">
<input v-model="newClient.tip_persoana" type="radio" value="PF" class="text-blue-600" />
PF
</label>
<label class="flex items-center gap-1.5 text-xs">
<input v-model="newClient.tip_persoana" type="radio" value="PJ" class="text-blue-600" />
PJ
</label>
</div>
<template v-if="newClient.tip_persoana === 'PF'">
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs text-gray-500 mb-1">Nume</label>
<input v-model="newClient.nume" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Prenume</label>
<input v-model="newClient.prenume" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs text-gray-500 mb-1">CNP</label>
<input v-model="newClient.cod_fiscal" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Telefon</label>
<input v-model="newClient.telefon" type="tel" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
</div>
</template>
<template v-if="newClient.tip_persoana === 'PJ'">
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs text-gray-500 mb-1">Denumire firma</label>
<input v-model="newClient.denumire" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">CUI</label>
<input v-model="newClient.cod_fiscal" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs text-gray-500 mb-1">Telefon</label>
<input v-model="newClient.telefon" type="tel" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Email</label>
<input v-model="newClient.email" type="email" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
</div>
</template>
<div>
<label class="block text-xs text-gray-500 mb-1">Adresa</label>
<input v-model="newClient.adresa" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
<button
type="button"
@click="handleCreateClient"
:disabled="creatingClient"
class="px-4 py-2 bg-green-600 text-white text-sm rounded-md hover:bg-green-700 disabled:opacity-50"
>
{{ creatingClient ? 'Se salveaza...' : 'Salveaza client' }}
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, watch } from 'vue'
import { useClientsStore } from '../../stores/clients.js'
const props = defineProps({
modelValue: { type: String, default: null },
label: { type: String, default: 'Client' },
placeholder: { type: String, default: 'Cauta dupa denumire, CUI, telefon...' },
})
const emit = defineEmits(['update:modelValue', 'select'])
const clientsStore = useClientsStore()
const query = ref('')
const results = ref([])
const selected = ref(null)
const showDropdown = ref(false)
const loading = ref(false)
const showNewForm = ref(false)
const creatingClient = ref(false)
const newClient = reactive({
tip_persoana: 'PF',
denumire: '', nume: '', prenume: '',
cod_fiscal: '', telefon: '', email: '', adresa: '',
})
let searchTimeout = null
function displayName(c) {
if (c.tip_persoana === 'PJ' && c.denumire) return c.denumire
const parts = [c.nume, c.prenume].filter(Boolean)
return parts.length > 0 ? parts.join(' ') : (c.denumire || '-')
}
function onSearch() {
if (searchTimeout) clearTimeout(searchTimeout)
searchTimeout = setTimeout(async () => {
if (query.value.length < 2) {
results.value = []
return
}
loading.value = true
results.value = await clientsStore.search(query.value)
loading.value = false
}, 200)
}
function selectClient(c) {
selected.value = c
query.value = displayName(c)
showDropdown.value = false
showNewForm.value = false
emit('update:modelValue', c.id)
emit('select', c)
}
function clear() {
selected.value = null
query.value = ''
emit('update:modelValue', null)
emit('select', null)
}
async function handleCreateClient() {
creatingClient.value = true
try {
const row = await clientsStore.create({ ...newClient })
selectClient(row)
showNewForm.value = false
Object.assign(newClient, {
tip_persoana: 'PF', denumire: '', nume: '', prenume: '',
cod_fiscal: '', telefon: '', email: '', adresa: '',
})
} finally {
creatingClient.value = false
}
}
// Load initial client if modelValue is set
watch(() => props.modelValue, async (id) => {
if (id && !selected.value) {
const c = await clientsStore.getById(id)
if (c) {
selected.value = c
query.value = displayName(c)
}
}
}, { immediate: true })
</script>

View File

@@ -0,0 +1,29 @@
<template>
<div class="flex items-center gap-1.5 text-xs">
<span
class="inline-block w-2 h-2 rounded-full"
:class="online ? 'bg-green-400' : 'bg-red-400'"
/>
<span :class="online ? 'text-green-300' : 'text-red-300'">
{{ online ? 'Online' : 'Offline' }}
</span>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
const online = ref(navigator.onLine)
function setOnline() { online.value = true }
function setOffline() { online.value = false }
onMounted(() => {
window.addEventListener('online', setOnline)
window.addEventListener('offline', setOffline)
})
onUnmounted(() => {
window.removeEventListener('online', setOnline)
window.removeEventListener('offline', setOffline)
})
</script>

Some files were not shown because too many files have changed in this diff Show More