Compare commits
19 Commits
d6e1a0c2fa
...
3e449d0b0b
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e449d0b0b | |||
| 2ccc062bc1 | |||
| 9644833db9 | |||
| 165890b07d | |||
| 1e96db4d91 | |||
| 9aef3d6933 | |||
| 78d2a77b0d | |||
| 8c0346e41f | |||
| 5fa72e4323 | |||
| 1686efeead | |||
| 3bdafad22a | |||
| efc9545ae6 | |||
| 3a922a50e6 | |||
| ad41956ea1 | |||
| 907b7be0fd | |||
| c3482bba8d | |||
| a16d01a669 | |||
| 6f82c56995 | |||
| 1ab109b1d4 |
6
.env.example
Normal file
6
.env.example
Normal 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
47
.gitignore
vendored
@@ -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
84
CLAUDE.md
Normal 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
51
Makefile
Normal 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
11
backend/.dockerignore
Normal 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
6
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.db
|
||||
.pytest_cache/
|
||||
*.egg-info/
|
||||
20
backend/Dockerfile
Normal file
20
backend/Dockerfile
Normal 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
20
backend/Dockerfile.dev
Normal 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
36
backend/alembic.ini
Normal 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
50
backend/alembic/env.py
Normal 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()
|
||||
26
backend/alembic/script.py.mako
Normal file
26
backend/alembic/script.py.mako
Normal 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"}
|
||||
44
backend/alembic/versions/1a4da27efc65_add_invites_table.py
Normal file
44
backend/alembic/versions/1a4da27efc65_add_invites_table.py
Normal 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 ###
|
||||
126
backend/alembic/versions/7df0fb1c1e6f_sync_schema_alignment.py
Normal file
126
backend/alembic/versions/7df0fb1c1e6f_sync_schema_alignment.py
Normal 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 ###
|
||||
@@ -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 ###
|
||||
@@ -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 ###
|
||||
218
backend/alembic/versions/fbbfad4cd8f3_all_business_models.py
Normal file
218
backend/alembic/versions/fbbfad4cd8f3_all_business_models.py
Normal 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
0
backend/app/__init__.py
Normal file
0
backend/app/auth/__init__.py
Normal file
0
backend/app/auth/__init__.py
Normal file
80
backend/app/auth/router.py
Normal file
80
backend/app/auth/router.py
Normal 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))
|
||||
28
backend/app/auth/schemas.py
Normal file
28
backend/app/auth/schemas.py
Normal 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
|
||||
62
backend/app/auth/service.py
Normal file
62
backend/app/auth/service.py
Normal 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()
|
||||
0
backend/app/client_portal/__init__.py
Normal file
0
backend/app/client_portal/__init__.py
Normal file
94
backend/app/client_portal/router.py
Normal file
94
backend/app/client_portal/router.py
Normal 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
15
backend/app/config.py
Normal 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()
|
||||
0
backend/app/db/__init__.py
Normal file
0
backend/app/db/__init__.py
Normal file
32
backend/app/db/base.py
Normal file
32
backend/app/db/base.py
Normal 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(),
|
||||
)
|
||||
37
backend/app/db/models/__init__.py
Normal file
37
backend/app/db/models/__init__.py
Normal 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",
|
||||
]
|
||||
16
backend/app/db/models/appointment.py
Normal file
16
backend/app/db/models/appointment.py
Normal 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))
|
||||
46
backend/app/db/models/catalog.py
Normal file
46
backend/app/db/models/catalog.py
Normal 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))
|
||||
12
backend/app/db/models/invite.py
Normal file
12
backend/app/db/models/invite.py
Normal 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
|
||||
22
backend/app/db/models/invoice.py
Normal file
22
backend/app/db/models/invoice.py
Normal 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")
|
||||
12
backend/app/db/models/mecanic.py
Normal file
12
backend/app/db/models/mecanic.py
Normal 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")
|
||||
31
backend/app/db/models/order.py
Normal file
31
backend/app/db/models/order.py
Normal 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))
|
||||
20
backend/app/db/models/order_line.py
Normal file
20
backend/app/db/models/order_line.py
Normal 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)
|
||||
18
backend/app/db/models/tenant.py
Normal file
18
backend/app/db/models/tenant.py
Normal 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)
|
||||
13
backend/app/db/models/user.py
Normal file
13
backend/app/db/models/user.py
Normal 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)
|
||||
25
backend/app/db/models/vehicle.py
Normal file
25
backend/app/db/models/vehicle.py
Normal 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
150
backend/app/db/seed.py
Normal 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
11
backend/app/db/session.py
Normal 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
22
backend/app/deps.py
Normal 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"]
|
||||
0
backend/app/invoices/__init__.py
Normal file
0
backend/app/invoices/__init__.py
Normal file
111
backend/app/invoices/router.py
Normal file
111
backend/app/invoices/router.py
Normal 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"'
|
||||
},
|
||||
)
|
||||
49
backend/app/invoices/service.py
Normal file
49
backend/app/invoices/service.py
Normal 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
47
backend/app/main.py
Normal 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"}
|
||||
0
backend/app/orders/__init__.py
Normal file
0
backend/app/orders/__init__.py
Normal file
157
backend/app/orders/router.py
Normal file
157
backend/app/orders/router.py
Normal 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"'
|
||||
},
|
||||
)
|
||||
18
backend/app/orders/schemas.py
Normal file
18
backend/app/orders/schemas.py
Normal 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
|
||||
188
backend/app/orders/service.py
Normal file
188
backend/app/orders/service.py
Normal 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
|
||||
]
|
||||
0
backend/app/pdf/__init__.py
Normal file
0
backend/app/pdf/__init__.py
Normal file
30
backend/app/pdf/service.py
Normal file
30
backend/app/pdf/service.py
Normal 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()
|
||||
67
backend/app/pdf/templates/deviz.html
Normal file
67
backend/app/pdf/templates/deviz.html
Normal 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>
|
||||
65
backend/app/pdf/templates/factura.html
Normal file
65
backend/app/pdf/templates/factura.html
Normal 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>
|
||||
0
backend/app/sms/__init__.py
Normal file
0
backend/app/sms/__init__.py
Normal file
18
backend/app/sms/service.py
Normal file
18
backend/app/sms/service.py
Normal 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"},
|
||||
)
|
||||
0
backend/app/sync/__init__.py
Normal file
0
backend/app/sync/__init__.py
Normal file
41
backend/app/sync/router.py
Normal file
41
backend/app/sync/router.py
Normal 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
|
||||
18
backend/app/sync/schemas.py
Normal file
18
backend/app/sync/schemas.py
Normal 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
136
backend/app/sync/service.py
Normal 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}
|
||||
0
backend/app/users/__init__.py
Normal file
0
backend/app/users/__init__.py
Normal file
43
backend/app/users/router.py
Normal file
43
backend/app/users/router.py
Normal 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))
|
||||
11
backend/app/users/schemas.py
Normal file
11
backend/app/users/schemas.py
Normal 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
|
||||
98
backend/app/users/service.py
Normal file
98
backend/app/users/service.py
Normal 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
|
||||
0
backend/app/vehicles/__init__.py
Normal file
0
backend/app/vehicles/__init__.py
Normal file
110
backend/app/vehicles/router.py
Normal file
110
backend/app/vehicles/router.py
Normal 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}
|
||||
21
backend/app/vehicles/schemas.py
Normal file
21
backend/app/vehicles/schemas.py
Normal 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
0
backend/data/.gitkeep
Normal file
2
backend/pytest.ini
Normal file
2
backend/pytest.ini
Normal file
@@ -0,0 +1,2 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
14
backend/requirements.txt
Normal file
14
backend/requirements.txt
Normal 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
|
||||
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
62
backend/tests/conftest.py
Normal file
62
backend/tests/conftest.py
Normal 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
105
backend/tests/test_auth.py
Normal 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"}
|
||||
145
backend/tests/test_orders.py
Normal file
145
backend/tests/test_orders.py
Normal 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
142
backend/tests/test_pdf.py
Normal 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"
|
||||
101
backend/tests/test_portal.py
Normal file
101
backend/tests/test_portal.py
Normal 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
330
backend/tests/test_sync.py
Normal 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
111
backend/tests/test_users.py
Normal 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
26
docker-compose.dev.yml
Normal 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
33
docker-compose.yml
Normal 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
120
docs/DEPLOY.md
Normal 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
87
docs/api-contract.json
Normal 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
7
frontend/.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
.env
|
||||
.env.*
|
||||
12
frontend/Dockerfile
Normal file
12
frontend/Dockerfile
Normal 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
16
frontend/index.html
Normal 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
46
frontend/nginx.conf
Normal 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
6861
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
frontend/package.json
Normal file
23
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
5
frontend/public/favicon.svg
Normal file
5
frontend/public/favicon.svg
Normal 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 |
BIN
frontend/public/icon-192.png
Normal file
BIN
frontend/public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 547 B |
BIN
frontend/public/icon-512.png
Normal file
BIN
frontend/public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
18
frontend/src/App.vue
Normal file
18
frontend/src/App.vue
Normal 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>
|
||||
1
frontend/src/assets/css/main.css
Normal file
1
frontend/src/assets/css/main.css
Normal file
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
29
frontend/src/components/common/SyncIndicator.vue
Normal file
29
frontend/src/components/common/SyncIndicator.vue
Normal 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>
|
||||
54
frontend/src/components/common/UpgradeBanner.vue
Normal file
54
frontend/src/components/common/UpgradeBanner.vue
Normal 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>
|
||||
132
frontend/src/components/orders/OrderLineForm.vue
Normal file
132
frontend/src/components/orders/OrderLineForm.vue
Normal 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>
|
||||
44
frontend/src/components/orders/PdfDownloadButton.vue
Normal file
44
frontend/src/components/orders/PdfDownloadButton.vue
Normal 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>
|
||||
108
frontend/src/components/vehicles/VehiclePicker.vue
Normal file
108
frontend/src/components/vehicles/VehiclePicker.vue
Normal 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>
|
||||
21
frontend/src/composables/useOffline.js
Normal file
21
frontend/src/composables/useOffline.js
Normal 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 }
|
||||
}
|
||||
34
frontend/src/composables/usePdf.js
Normal file
34
frontend/src/composables/usePdf.js
Normal 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 }
|
||||
}
|
||||
22
frontend/src/composables/useSqlQuery.js
Normal file
22
frontend/src/composables/useSqlQuery.js
Normal 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 }
|
||||
}
|
||||
29
frontend/src/composables/useSync.js
Normal file
29
frontend/src/composables/useSync.js
Normal 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
Reference in New Issue
Block a user