Compare commits

...

19 Commits

Author SHA1 Message Date
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
123 changed files with 14413 additions and 0 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

84
CLAUDE.md Normal file
View File

@@ -0,0 +1,84 @@
# 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`

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,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}

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,37 @@
from app.db.models.tenant import Tenant
from app.db.models.user import User
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",
"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,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,22 @@
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)
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,31 @@
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))
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,25 @@
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))
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)

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

@@ -0,0 +1,150 @@
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 = [
"Deviz reparatie",
"Deviz revizie",
"Deviz diagnosticare",
"Deviz estimativ",
"Deviz vulcanizare",
"Deviz ITP",
]
TIPURI_MOTOARE = ["Benzina", "Diesel", "Hibrid", "Electric", "GPL"]
PRETURI = [
{"denumire": "Manopera standard", "pret": 150.0, "um": "ora"},
{"denumire": "Revizie ulei + filtru", "pret": 250.0, "um": "buc"},
{"denumire": "Diagnosticare", "pret": 100.0, "um": "buc"},
]
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,111 @@
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"'
},
)

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

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

@@ -0,0 +1,47 @@
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.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(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,157 @@
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.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"'
},
)

View File

@@ -0,0 +1,18 @@
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 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 = []

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

@@ -0,0 +1,136 @@
from datetime import UTC, datetime
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
SYNCABLE_TABLES = [
"vehicles",
"orders",
"order_lines",
"invoices",
"appointments",
"catalog_marci",
"catalog_modele",
"catalog_ansamble",
"catalog_norme",
"catalog_preturi",
"catalog_tipuri_deviz",
"catalog_tipuri_motoare",
"mecanici",
]
# 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)
```

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

@@ -0,0 +1,87 @@
{
"version": "1.0",
"note": "Contract shared intre backend-agent si frontend-agent. Nu modificati fara notificarea ambilor agenti.",
"base_url": "/api",
"auth": {
"POST /auth/register": {
"body": {"email": "str", "password": "str", "tenant_name": "str", "telefon": "str"},
"response": {"access_token": "str", "token_type": "bearer", "tenant_id": "str", "plan": "str"}
},
"POST /auth/login": {
"body": {"email": "str", "password": "str"},
"response": {"access_token": "str", "token_type": "bearer", "tenant_id": "str", "plan": "str"}
},
"GET /auth/me": {
"headers": {"Authorization": "Bearer <token>"},
"response": {"id": "str", "email": "str", "tenant_id": "str", "plan": "str", "rol": "str"}
}
},
"sync": {
"GET /sync/full": {
"headers": {"Authorization": "Bearer <token>"},
"response": {
"tables": {
"vehicles": [], "orders": [], "order_lines": [],
"invoices": [], "appointments": [],
"catalog_marci": [], "catalog_modele": [],
"catalog_ansamble": [], "catalog_norme": [],
"catalog_preturi": [], "catalog_tipuri_deviz": [],
"catalog_tipuri_motoare": [], "mecanici": []
},
"synced_at": "ISO8601"
}
},
"GET /sync/changes": {
"params": {"since": "ISO8601"},
"response": {"tables": {}, "synced_at": "str"}
},
"POST /sync/push": {
"body": {"operations": [{"table": "str", "id": "uuid", "operation": "INSERT|UPDATE|DELETE", "data": {}, "timestamp": "str"}]},
"response": {"applied": 0, "conflicts": []}
}
},
"orders": {
"GET /orders": {"response": [{"id": "str", "status": "str", "nr_auto": "str", "total_general": 0}]},
"POST /orders": {"body": {"vehicle_id": "str", "tip_deviz_id": "str", "km_intrare": 0, "observatii": "str"}, "response": {"id": "str"}},
"GET /orders/{id}": {"response": {"id": "str", "status": "str", "lines": []}},
"POST /orders/{id}/lines": {"body": {"tip": "manopera|material", "descriere": "str", "ore": 0, "pret_ora": 0, "cantitate": 0, "pret_unitar": 0, "um": "str"}},
"POST /orders/{id}/validate": {"response": {"status": "VALIDAT"}},
"GET /orders/{id}/pdf/deviz": {"response": "application/pdf"}
},
"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"}, "response": {"id": "str", "nr_factura": "str"}},
"GET /invoices/{id}/pdf": {"response": "application/pdf"}
},
"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"}}
}
}

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,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>

View File

@@ -0,0 +1,54 @@
<template>
<div
v-if="showBanner"
class="bg-amber-50 border-l-4 border-amber-400 p-4 mb-4"
>
<div class="flex items-start">
<div class="flex-1">
<h3 class="text-sm font-medium text-amber-800">
{{ isExpired ? 'Perioada de trial a expirat' : 'Trial activ' }}
</h3>
<p class="text-sm text-amber-700 mt-1">
<template v-if="isExpired">
Contul tau a depasit perioada de trial. Upgradeaza la un plan platit pentru a continua sa folosesti toate functiile.
</template>
<template v-else>
Mai ai {{ daysRemaining }} zile de trial. Upgradeaza pentru acces complet.
</template>
</p>
</div>
<a
href="mailto:contact@roaauto.ro?subject=Upgrade%20plan"
class="ml-4 px-3 py-1.5 bg-amber-600 text-white text-sm rounded-md hover:bg-amber-700 whitespace-nowrap"
>
Upgradeaza
</a>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useAuthStore } from '../../stores/auth.js'
const auth = useAuthStore()
const showBanner = computed(() => auth.plan === 'trial')
const trialExpiresAt = computed(() => {
// JWT payload may have trial info, or we check local db
// For now use a simple heuristic from plan
return null
})
const isExpired = computed(() => {
if (!trialExpiresAt.value) return false
return new Date(trialExpiresAt.value) < new Date()
})
const daysRemaining = computed(() => {
if (!trialExpiresAt.value) return 30
const diff = new Date(trialExpiresAt.value) - new Date()
return Math.max(0, Math.ceil(diff / (1000 * 60 * 60 * 24)))
})
</script>

View File

@@ -0,0 +1,132 @@
<template>
<div class="bg-gray-50 rounded-lg p-4 border border-gray-200">
<h4 class="text-sm font-medium text-gray-700 mb-3">Adauga linie</h4>
<form @submit.prevent="handleSubmit" class="space-y-3">
<!-- Tip -->
<div class="flex gap-3">
<label class="flex items-center gap-1.5 text-sm">
<input v-model="form.tip" type="radio" value="manopera" class="text-blue-600" />
Manopera
</label>
<label class="flex items-center gap-1.5 text-sm">
<input v-model="form.tip" type="radio" value="material" class="text-blue-600" />
Material
</label>
</div>
<!-- Descriere -->
<div>
<input
v-model="form.descriere"
type="text"
required
placeholder="Descriere operatiune / material"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<!-- Manopera fields -->
<div v-if="form.tip === 'manopera'" class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs text-gray-500 mb-1">Ore</label>
<input
v-model.number="form.ore"
type="number"
step="0.1"
min="0"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Pret/ora (RON)</label>
<input
v-model.number="form.pret_ora"
type="number"
step="0.01"
min="0"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<!-- Material fields -->
<div v-if="form.tip === 'material'" class="grid grid-cols-3 gap-3">
<div>
<label class="block text-xs text-gray-500 mb-1">Cantitate</label>
<input
v-model.number="form.cantitate"
type="number"
step="0.01"
min="0"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Pret unitar (RON)</label>
<input
v-model.number="form.pret_unitar"
type="number"
step="0.01"
min="0"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">UM</label>
<input
v-model="form.um"
type="text"
placeholder="buc"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<!-- Total preview + submit -->
<div class="flex items-center justify-between pt-2">
<span class="text-sm text-gray-600">
Total: <strong>{{ computedTotal.toFixed(2) }} RON</strong>
</span>
<button
type="submit"
class="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700"
>
Adauga
</button>
</div>
</form>
</div>
</template>
<script setup>
import { reactive, computed } from 'vue'
const emit = defineEmits(['add'])
const form = reactive({
tip: 'manopera',
descriere: '',
ore: 0,
pret_ora: 0,
cantitate: 0,
pret_unitar: 0,
um: 'buc',
})
const computedTotal = computed(() => {
if (form.tip === 'manopera') return (form.ore || 0) * (form.pret_ora || 0)
return (form.cantitate || 0) * (form.pret_unitar || 0)
})
function handleSubmit() {
if (!form.descriere) return
emit('add', { ...form })
// Reset
form.descriere = ''
form.ore = 0
form.pret_ora = 0
form.cantitate = 0
form.pret_unitar = 0
form.um = 'buc'
}
</script>

View File

@@ -0,0 +1,44 @@
<template>
<button
@click="handleDownload"
:disabled="downloading"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md border border-gray-300 text-gray-700 hover:bg-gray-50 disabled:opacity-50"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
{{ downloading ? 'Se descarca...' : label }}
</button>
</template>
<script setup>
import { ref } from 'vue'
import { usePdf } from '../../composables/usePdf.js'
const props = defineProps({
type: { type: String, required: true, validator: v => ['deviz', 'invoice'].includes(v) },
orderId: { type: String, default: null },
invoiceId: { type: String, default: null },
nrComanda: { type: String, default: '' },
nrFactura: { type: String, default: '' },
label: { type: String, default: 'PDF' },
})
const { downloadDevizPdf, downloadInvoicePdf } = usePdf()
const downloading = ref(false)
async function handleDownload() {
downloading.value = true
try {
if (props.type === 'deviz') {
await downloadDevizPdf(props.orderId, props.nrComanda)
} else {
await downloadInvoicePdf(props.invoiceId, props.nrFactura)
}
} catch (e) {
alert(e.message)
} finally {
downloading.value = false
}
}
</script>

View File

@@ -0,0 +1,108 @@
<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 vehicle display -->
<div v-if="selected" class="mt-1 text-sm text-gray-600">
{{ selected.nr_inmatriculare }} - {{ selected.client_nume }}
<span v-if="selected.marca_denumire"> ({{ selected.marca_denounire }} {{ selected.model_denumire }})</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="v in results"
:key="v.id"
class="px-3 py-2 hover:bg-blue-50 cursor-pointer text-sm"
@mousedown="selectVehicle(v)"
>
<span class="font-medium">{{ v.nr_inmatriculare }}</span>
<span class="text-gray-500 ml-2">{{ v.client_nume }}</span>
<span v-if="v.marca_denumire" class="text-gray-400 ml-1">
({{ v.marca_denumire }} {{ v.model_denumire }})
</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 vehicul gasit
</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import { useVehiclesStore } from '../../stores/vehicles.js'
const props = defineProps({
modelValue: { type: String, default: null },
label: { type: String, default: 'Vehicul' },
placeholder: { type: String, default: 'Cauta dupa nr. inmatriculare sau client...' },
})
const emit = defineEmits(['update:modelValue', 'select'])
const vehiclesStore = useVehiclesStore()
const query = ref('')
const results = ref([])
const selected = ref(null)
const showDropdown = ref(false)
const loading = ref(false)
let searchTimeout = null
function onSearch() {
if (searchTimeout) clearTimeout(searchTimeout)
searchTimeout = setTimeout(async () => {
if (query.value.length < 2) {
results.value = []
return
}
loading.value = true
results.value = await vehiclesStore.search(query.value)
loading.value = false
}, 200)
}
function selectVehicle(v) {
selected.value = v
query.value = v.nr_inmatriculare
showDropdown.value = false
emit('update:modelValue', v.id)
emit('select', v)
}
function clear() {
selected.value = null
query.value = ''
emit('update:modelValue', null)
emit('select', null)
}
// Load initial vehicle if modelValue is set
watch(() => props.modelValue, async (id) => {
if (id && !selected.value) {
const v = await vehiclesStore.getById(id)
if (v) {
selected.value = v
query.value = v.nr_inmatriculare
}
}
}, { immediate: true })
// Close dropdown on outside click
function onClickOutside() { showDropdown.value = false }
</script>

View File

@@ -0,0 +1,21 @@
import { ref, onMounted, onUnmounted } from 'vue'
const online = ref(navigator.onLine)
function setOnline() { online.value = true }
function setOffline() { online.value = false }
let listenersAttached = false
export function useOffline() {
onMounted(() => {
if (!listenersAttached) {
window.addEventListener('online', setOnline)
window.addEventListener('offline', setOffline)
listenersAttached = true
}
online.value = navigator.onLine
})
return { online }
}

View File

@@ -0,0 +1,34 @@
const API_URL = import.meta.env.VITE_API_URL || '/api'
export function usePdf() {
function getToken() {
return localStorage.getItem('token')
}
async function downloadPdf(url, filename) {
const token = getToken()
const res = await fetch(`${API_URL}${url}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
if (!res.ok) throw new Error('Eroare la descarcarea PDF-ului')
const blob = await res.blob()
const objectUrl = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = objectUrl
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(objectUrl)
}
async function downloadDevizPdf(orderId, nrComanda) {
await downloadPdf(`/orders/${orderId}/pdf/deviz`, `deviz-${nrComanda || orderId}.pdf`)
}
async function downloadInvoicePdf(invoiceId, nrFactura) {
await downloadPdf(`/invoices/${invoiceId}/pdf`, `factura-${nrFactura || invoiceId}.pdf`)
}
return { downloadDevizPdf, downloadInvoicePdf }
}

View File

@@ -0,0 +1,22 @@
import { ref, onMounted, onUnmounted } from 'vue'
import { execSQL, onTableChange } from '../db/database.js'
export function useSqlQuery(sql, params = [], watchTables = []) {
const rows = ref([])
const loading = ref(true)
async function refresh() {
loading.value = true
try { rows.value = await execSQL(sql, params) }
finally { loading.value = false }
}
const unsubs = []
onMounted(() => {
refresh()
watchTables.forEach(t => unsubs.push(onTableChange(t, refresh)))
})
onUnmounted(() => unsubs.forEach(fn => fn()))
return { rows, loading, refresh }
}

View File

@@ -0,0 +1,29 @@
import { onMounted, onUnmounted } from 'vue'
import { syncEngine } from '../db/sync.js'
import { useAuthStore } from '../stores/auth.js'
export function useSync() {
const auth = useAuthStore()
let interval = null
function onFocus() {
if (auth.isAuthenticated && syncEngine.online) {
syncEngine.incrementalSync()
}
}
onMounted(() => {
window.addEventListener('focus', onFocus)
// Periodic sync every 60s
interval = setInterval(() => {
if (auth.isAuthenticated && syncEngine.online) {
syncEngine.incrementalSync()
}
}, 60000)
})
onUnmounted(() => {
window.removeEventListener('focus', onFocus)
if (interval) clearInterval(interval)
})
}

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