Compare commits
22 Commits
d6e1a0c2fa
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d93a7b2903 | ||
|
|
16da72daff | ||
| 9db4e746e3 | |||
| 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
|
||||
|
||||
107
CLAUDE.md
Normal file
107
CLAUDE.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
ROA AUTO is a multi-tenant auto service management PWA. The architecture is **offline-first**: the Vue 3 frontend uses wa-sqlite (WebAssembly SQLite) running entirely in the browser as `:memory:`, with a sync engine that pulls from and pushes to the FastAPI backend. All business data lives locally in the browser's in-memory SQLite DB and is periodically synced.
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Backend (FastAPI)
|
||||
```bash
|
||||
cd backend
|
||||
source .venv/bin/activate
|
||||
uvicorn app.main:app --port 8000 --reload # start dev server
|
||||
pytest tests/ -v # run all tests
|
||||
pytest tests/test_orders.py -v # run single test file
|
||||
pytest tests/test_orders.py::test_name -v # run single test
|
||||
```
|
||||
|
||||
### Frontend (Vue 3 + Vite)
|
||||
```bash
|
||||
cd frontend
|
||||
npm run dev # start dev server (port 5173)
|
||||
npm run build # production build
|
||||
```
|
||||
|
||||
### Docker (preferred for full stack)
|
||||
```bash
|
||||
make dev # start all services (docker-compose.dev.yml)
|
||||
make test # run backend tests in container
|
||||
make migrate # run alembic migrations
|
||||
make seed # seed demo data
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Sync Model (critical to understand)
|
||||
- **Frontend writes locally first**, then queues changes in `_sync_queue` (SQLite table)
|
||||
- `SyncEngine` (`frontend/src/db/sync.js`) handles three operations:
|
||||
- `fullSync()` — downloads all tenant data from `/api/sync/full` on login
|
||||
- `incrementalSync()` — downloads changes since `last_sync_at`
|
||||
- `pushQueue()` — uploads queued local changes to `/api/sync/push`
|
||||
- The backend sync service (`backend/app/sync/service.py`) defines `SYNCABLE_TABLES` — only these tables are synced
|
||||
- IDs are `crypto.randomUUID()` generated client-side; backend uses `INSERT OR REPLACE`
|
||||
|
||||
### Frontend (`frontend/src/`)
|
||||
- **`db/database.js`** — singleton wa-sqlite instance. Uses `:memory:` only (no IndexedDB persistence). `execSQL(sql, params)` is the only query interface. Parameterized queries **must** use `sqlite3.statements()` async generator — `exec()` does not support `?` params. Do NOT use `prepare_v2` (doesn't exist in this library).
|
||||
- **`db/schema.js`** — full SQLite schema as a string constant, applied on init
|
||||
- **`db/sync.js`** — `SyncEngine` class, exported as singleton `syncEngine`
|
||||
- **`stores/`** — Pinia stores (`auth.js`, `orders.js`, `vehicles.js`) wrapping `execSQL` calls
|
||||
- **`views/`** — one file per route; data loaded via `execSQL` directly or through stores
|
||||
- **Reactivity** — `notifyTableChanged(table)` / `onTableChange(table, cb)` in `database.js` provide a pub/sub for cross-component updates. Call `notifyTableChanged` after any write.
|
||||
|
||||
### Backend (`backend/app/`)
|
||||
- **Multi-tenant**: every model has `tenant_id`. All queries filter by `tenant_id` from the JWT.
|
||||
- **Auth**: JWT in `Authorization: Bearer` header. `get_tenant_id` dep extracts tenant from token.
|
||||
- **`deps.py`** — `get_current_user` and `get_tenant_id` FastAPI dependencies used across all routers
|
||||
- **`sync/`** — The most complex module. `apply_push` does `INSERT OR REPLACE` for all ops.
|
||||
- **`pdf/`** — WeasyPrint + Jinja2 HTML templates to generate PDF deviz/factura
|
||||
- **`client_portal/`** — Public routes (no auth) for client-facing deviz view via `token_client`
|
||||
- **DB**: SQLite via SQLAlchemy async (`aiosqlite`). Alembic for migrations (files in `alembic/versions/`).
|
||||
|
||||
### Key Data Flow: Order Lifecycle
|
||||
`DRAFT` → `VALIDAT` → `FACTURAT`
|
||||
- Order lines (manopera/material) added only in DRAFT
|
||||
- PDF deviz available after VALIDAT
|
||||
- Invoice created locally in `handleFactureaza()` then queued for sync
|
||||
|
||||
## Critical wa-sqlite Notes
|
||||
- Import: `wa-sqlite.mjs` (sync WASM build), NOT `wa-sqlite-async.mjs`
|
||||
- All API methods (`open_v2`, `exec`, `step`, `finalize`) return Promises — always `await`
|
||||
- For parameterized queries: `for await (const stmt of sqlite3.statements(db, sql))` then `sqlite3.bind_collection(stmt, params)`
|
||||
- `vite.config.js` excludes `@journeyapps/wa-sqlite` from `optimizeDeps`
|
||||
- COOP/COEP headers (`Cross-Origin-Opener-Policy: same-origin` + `Cross-Origin-Embedder-Policy: require-corp`) are required for SharedArrayBuffer used by wa-sqlite — set in vite dev server and nginx
|
||||
|
||||
## WSL2 Note
|
||||
Running on WSL2 with code on Windows NTFS (`/mnt/e/`): Vite is configured with `server.watch.usePolling: true, interval: 1000` to work around inotify not firing on NTFS mounts.
|
||||
|
||||
## Testing
|
||||
- Backend tests use an in-memory SQLite DB (overrides `get_db` via `app.dependency_overrides`)
|
||||
- `asyncio_mode = auto` set in `pytest.ini` — no need to mark tests with `@pytest.mark.asyncio`
|
||||
- `auth_headers` fixture registers a user and returns `Authorization` header for authenticated tests
|
||||
- Demo credentials (after `make seed`): `demo@roaauto.ro` / `demo123`
|
||||
|
||||
## Execution Preferences
|
||||
- Executia task-urilor se face cu **team agents** (nu cu skill superpowers subagents)
|
||||
- **Playwright testing obligatoriu**: orice implementare majora trebuie testata E2E cu Playwright
|
||||
- Desktop: 1280x720, Mobile: 375x812
|
||||
- Verificari: responsive, elemente nu se suprapun, nu ies din ecran, butoane cu gap suficient
|
||||
- Raportul Playwright se salveaza in `docs/playwright-report-YYYY-MM-DD.md`
|
||||
|
||||
## Data Model: Clients
|
||||
- Tabel `clients` - nomenclator clienti cu date eFactura ANAF, separat de vehicule
|
||||
- Un client poate avea mai multe vehicule (1:N prin client_id pe vehicles)
|
||||
- `tip_persoana`: PF (persoana fizica) / PJ (persoana juridica)
|
||||
- Campuri eFactura: cod_fiscal, reg_com, adresa, judet, oras, cod_postal, cont_iban, banca
|
||||
|
||||
## Data Model: Invoices
|
||||
- `tip_document`: FACTURA (B2B, eFactura ANAF) sau BON_FISCAL (B2C, casa de marcat)
|
||||
- Factura: necesita date client complete (CUI, adresa)
|
||||
- Bon fiscal: format simplificat
|
||||
|
||||
## Order Lifecycle
|
||||
- `DRAFT` → `VALIDAT` → `FACTURAT` (cu devalidare VALIDAT → DRAFT)
|
||||
- Stergere: orice nefacturat; FACTURAT = sterge factura intai
|
||||
- Edit header doar in DRAFT
|
||||
51
Makefile
Normal file
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 ###
|
||||
@@ -0,0 +1,73 @@
|
||||
"""add_clients_table_and_client_id_columns
|
||||
|
||||
Revision ID: 6d8b5bd44531
|
||||
Revises: 7df0fb1c1e6f
|
||||
Create Date: 2026-03-14 10:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '6d8b5bd44531'
|
||||
down_revision: Union[str, None] = '7df0fb1c1e6f'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create clients table
|
||||
op.create_table(
|
||||
'clients',
|
||||
sa.Column('id', sa.String(length=36), primary_key=True),
|
||||
sa.Column('tenant_id', sa.String(length=36), nullable=False, index=True),
|
||||
sa.Column('created_at', sa.Text(), nullable=True),
|
||||
sa.Column('updated_at', sa.Text(), nullable=True),
|
||||
sa.Column('tip_persoana', sa.String(length=2), nullable=True),
|
||||
sa.Column('denumire', sa.String(length=200), nullable=True),
|
||||
sa.Column('nume', sa.String(length=100), nullable=True),
|
||||
sa.Column('prenume', sa.String(length=100), nullable=True),
|
||||
sa.Column('cod_fiscal', sa.String(length=20), nullable=True),
|
||||
sa.Column('reg_com', sa.String(length=30), nullable=True),
|
||||
sa.Column('telefon', sa.String(length=20), nullable=True),
|
||||
sa.Column('email', sa.String(length=200), nullable=True),
|
||||
sa.Column('adresa', sa.Text(), nullable=True),
|
||||
sa.Column('judet', sa.String(length=50), nullable=True),
|
||||
sa.Column('oras', sa.String(length=100), nullable=True),
|
||||
sa.Column('cod_postal', sa.String(length=10), nullable=True),
|
||||
sa.Column('tara', sa.String(length=2), nullable=True),
|
||||
sa.Column('cont_iban', sa.String(length=34), nullable=True),
|
||||
sa.Column('banca', sa.String(length=100), nullable=True),
|
||||
sa.Column('activ', sa.Integer(), server_default='1', nullable=False),
|
||||
sa.Column('oracle_id', sa.Integer(), nullable=True),
|
||||
)
|
||||
|
||||
# Add client_id to vehicles
|
||||
with op.batch_alter_table('vehicles') as batch_op:
|
||||
batch_op.add_column(sa.Column('client_id', sa.String(length=36), nullable=True))
|
||||
|
||||
# Add client_id to orders
|
||||
with op.batch_alter_table('orders') as batch_op:
|
||||
batch_op.add_column(sa.Column('client_id', sa.String(length=36), nullable=True))
|
||||
|
||||
# Add client_id and tip_document to invoices
|
||||
with op.batch_alter_table('invoices') as batch_op:
|
||||
batch_op.add_column(sa.Column('client_id', sa.String(length=36), nullable=True))
|
||||
batch_op.add_column(sa.Column('tip_document', sa.String(length=20), server_default='FACTURA', nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('invoices') as batch_op:
|
||||
batch_op.drop_column('tip_document')
|
||||
batch_op.drop_column('client_id')
|
||||
|
||||
with op.batch_alter_table('orders') as batch_op:
|
||||
batch_op.drop_column('client_id')
|
||||
|
||||
with op.batch_alter_table('vehicles') as batch_op:
|
||||
batch_op.drop_column('client_id')
|
||||
|
||||
op.drop_table('clients')
|
||||
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}
|
||||
0
backend/app/clients/__init__.py
Normal file
0
backend/app/clients/__init__.py
Normal file
163
backend/app/clients/router.py
Normal file
163
backend/app/clients/router.py
Normal file
@@ -0,0 +1,163 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.base import uuid7
|
||||
from app.db.models.client import Client
|
||||
from app.db.session import get_db
|
||||
from app.deps import get_tenant_id
|
||||
from app.clients import schemas
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_clients(
|
||||
search: str | None = None,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
query = select(Client).where(Client.tenant_id == tenant_id)
|
||||
if search:
|
||||
pattern = f"%{search}%"
|
||||
query = query.where(
|
||||
or_(
|
||||
Client.denumire.ilike(pattern),
|
||||
Client.nume.ilike(pattern),
|
||||
Client.prenume.ilike(pattern),
|
||||
Client.cod_fiscal.ilike(pattern),
|
||||
Client.telefon.ilike(pattern),
|
||||
Client.email.ilike(pattern),
|
||||
)
|
||||
)
|
||||
r = await db.execute(query)
|
||||
clients = r.scalars().all()
|
||||
return [
|
||||
{
|
||||
"id": c.id,
|
||||
"tip_persoana": c.tip_persoana,
|
||||
"denumire": c.denumire,
|
||||
"nume": c.nume,
|
||||
"prenume": c.prenume,
|
||||
"cod_fiscal": c.cod_fiscal,
|
||||
"reg_com": c.reg_com,
|
||||
"telefon": c.telefon,
|
||||
"email": c.email,
|
||||
"adresa": c.adresa,
|
||||
"judet": c.judet,
|
||||
"oras": c.oras,
|
||||
"cod_postal": c.cod_postal,
|
||||
"tara": c.tara,
|
||||
"cont_iban": c.cont_iban,
|
||||
"banca": c.banca,
|
||||
"activ": c.activ,
|
||||
}
|
||||
for c in clients
|
||||
]
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_client(
|
||||
data: schemas.ClientCreate,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
client = Client(
|
||||
id=data.id or uuid7(),
|
||||
tenant_id=tenant_id,
|
||||
tip_persoana=data.tip_persoana,
|
||||
denumire=data.denumire,
|
||||
nume=data.nume,
|
||||
prenume=data.prenume,
|
||||
cod_fiscal=data.cod_fiscal,
|
||||
reg_com=data.reg_com,
|
||||
telefon=data.telefon,
|
||||
email=data.email,
|
||||
adresa=data.adresa,
|
||||
judet=data.judet,
|
||||
oras=data.oras,
|
||||
cod_postal=data.cod_postal,
|
||||
tara=data.tara,
|
||||
cont_iban=data.cont_iban,
|
||||
banca=data.banca,
|
||||
activ=data.activ,
|
||||
)
|
||||
db.add(client)
|
||||
await db.commit()
|
||||
return {"id": client.id}
|
||||
|
||||
|
||||
@router.get("/{client_id}")
|
||||
async def get_client(
|
||||
client_id: str,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
r = await db.execute(
|
||||
select(Client).where(
|
||||
Client.id == client_id, Client.tenant_id == tenant_id
|
||||
)
|
||||
)
|
||||
c = r.scalar_one_or_none()
|
||||
if not c:
|
||||
raise HTTPException(status_code=404, detail="Client not found")
|
||||
return {
|
||||
"id": c.id,
|
||||
"tip_persoana": c.tip_persoana,
|
||||
"denumire": c.denumire,
|
||||
"nume": c.nume,
|
||||
"prenume": c.prenume,
|
||||
"cod_fiscal": c.cod_fiscal,
|
||||
"reg_com": c.reg_com,
|
||||
"telefon": c.telefon,
|
||||
"email": c.email,
|
||||
"adresa": c.adresa,
|
||||
"judet": c.judet,
|
||||
"oras": c.oras,
|
||||
"cod_postal": c.cod_postal,
|
||||
"tara": c.tara,
|
||||
"cont_iban": c.cont_iban,
|
||||
"banca": c.banca,
|
||||
"activ": c.activ,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/{client_id}")
|
||||
async def update_client(
|
||||
client_id: str,
|
||||
data: schemas.ClientUpdate,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
r = await db.execute(
|
||||
select(Client).where(
|
||||
Client.id == client_id, Client.tenant_id == tenant_id
|
||||
)
|
||||
)
|
||||
c = r.scalar_one_or_none()
|
||||
if not c:
|
||||
raise HTTPException(status_code=404, detail="Client not found")
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(c, key, value)
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/{client_id}")
|
||||
async def delete_client(
|
||||
client_id: str,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
r = await db.execute(
|
||||
select(Client).where(
|
||||
Client.id == client_id, Client.tenant_id == tenant_id
|
||||
)
|
||||
)
|
||||
c = r.scalar_one_or_none()
|
||||
if not c:
|
||||
raise HTTPException(status_code=404, detail="Client not found")
|
||||
await db.delete(c)
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
41
backend/app/clients/schemas.py
Normal file
41
backend/app/clients/schemas.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class ClientCreate(BaseModel):
|
||||
id: Optional[str] = None
|
||||
tip_persoana: str = "PF"
|
||||
denumire: Optional[str] = None
|
||||
nume: Optional[str] = None
|
||||
prenume: Optional[str] = None
|
||||
cod_fiscal: Optional[str] = None
|
||||
reg_com: Optional[str] = None
|
||||
telefon: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
adresa: Optional[str] = None
|
||||
judet: Optional[str] = None
|
||||
oras: Optional[str] = None
|
||||
cod_postal: Optional[str] = None
|
||||
tara: str = "RO"
|
||||
cont_iban: Optional[str] = None
|
||||
banca: Optional[str] = None
|
||||
activ: int = 1
|
||||
|
||||
|
||||
class ClientUpdate(BaseModel):
|
||||
tip_persoana: Optional[str] = None
|
||||
denumire: Optional[str] = None
|
||||
nume: Optional[str] = None
|
||||
prenume: Optional[str] = None
|
||||
cod_fiscal: Optional[str] = None
|
||||
reg_com: Optional[str] = None
|
||||
telefon: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
adresa: Optional[str] = None
|
||||
judet: Optional[str] = None
|
||||
oras: Optional[str] = None
|
||||
cod_postal: Optional[str] = None
|
||||
tara: Optional[str] = None
|
||||
cont_iban: Optional[str] = None
|
||||
banca: Optional[str] = None
|
||||
activ: Optional[int] = None
|
||||
15
backend/app/config.py
Normal file
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(),
|
||||
)
|
||||
39
backend/app/db/models/__init__.py
Normal file
39
backend/app/db/models/__init__.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from app.db.models.tenant import Tenant
|
||||
from app.db.models.user import User
|
||||
from app.db.models.client import Client
|
||||
from app.db.models.vehicle import Vehicle
|
||||
from app.db.models.order import Order
|
||||
from app.db.models.order_line import OrderLine
|
||||
from app.db.models.catalog import (
|
||||
CatalogMarca,
|
||||
CatalogModel,
|
||||
CatalogAnsamblu,
|
||||
CatalogNorma,
|
||||
CatalogPret,
|
||||
CatalogTipDeviz,
|
||||
CatalogTipMotor,
|
||||
)
|
||||
from app.db.models.invoice import Invoice
|
||||
from app.db.models.appointment import Appointment
|
||||
from app.db.models.mecanic import Mecanic
|
||||
from app.db.models.invite import InviteToken
|
||||
|
||||
__all__ = [
|
||||
"Tenant",
|
||||
"User",
|
||||
"Client",
|
||||
"Vehicle",
|
||||
"Order",
|
||||
"OrderLine",
|
||||
"CatalogMarca",
|
||||
"CatalogModel",
|
||||
"CatalogAnsamblu",
|
||||
"CatalogNorma",
|
||||
"CatalogPret",
|
||||
"CatalogTipDeviz",
|
||||
"CatalogTipMotor",
|
||||
"Invoice",
|
||||
"Appointment",
|
||||
"Mecanic",
|
||||
"InviteToken",
|
||||
]
|
||||
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))
|
||||
25
backend/app/db/models/client.py
Normal file
25
backend/app/db/models/client.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 Client(Base, UUIDMixin, TenantMixin, TimestampMixin):
|
||||
__tablename__ = "clients"
|
||||
tip_persoana: Mapped[str | None] = mapped_column(String(2), default="PF")
|
||||
denumire: Mapped[str | None] = mapped_column(String(200))
|
||||
nume: Mapped[str | None] = mapped_column(String(100))
|
||||
prenume: Mapped[str | None] = mapped_column(String(100))
|
||||
cod_fiscal: Mapped[str | None] = mapped_column(String(20))
|
||||
reg_com: Mapped[str | None] = mapped_column(String(30))
|
||||
telefon: Mapped[str | None] = mapped_column(String(20))
|
||||
email: Mapped[str | None] = mapped_column(String(200))
|
||||
adresa: Mapped[str | None] = mapped_column(Text)
|
||||
judet: Mapped[str | None] = mapped_column(String(50))
|
||||
oras: Mapped[str | None] = mapped_column(String(100))
|
||||
cod_postal: Mapped[str | None] = mapped_column(String(10))
|
||||
tara: Mapped[str | None] = mapped_column(String(2), default="RO")
|
||||
cont_iban: Mapped[str | None] = mapped_column(String(34))
|
||||
banca: Mapped[str | None] = mapped_column(String(100))
|
||||
activ: Mapped[int] = mapped_column(Integer, default=1, server_default="1")
|
||||
oracle_id: Mapped[int | None] = mapped_column(Integer)
|
||||
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
|
||||
24
backend/app/db/models/invoice.py
Normal file
24
backend/app/db/models/invoice.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from sqlalchemy import Float, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base, UUIDMixin, TenantMixin, TimestampMixin
|
||||
|
||||
|
||||
class Invoice(Base, UUIDMixin, TenantMixin, TimestampMixin):
|
||||
__tablename__ = "invoices"
|
||||
order_id: Mapped[str | None] = mapped_column(String(36), index=True)
|
||||
client_id: Mapped[str | None] = mapped_column(String(36))
|
||||
tip_document: Mapped[str | None] = mapped_column(String(20), default="FACTURA", server_default="FACTURA")
|
||||
nr_factura: Mapped[str | None] = mapped_column(String(50))
|
||||
serie_factura: Mapped[str | None] = mapped_column(String(20))
|
||||
data_factura: Mapped[str | None] = mapped_column(Text)
|
||||
modalitate_plata: Mapped[str | None] = mapped_column(String(50))
|
||||
client_nume: Mapped[str | None] = mapped_column(String(200))
|
||||
client_cod_fiscal: Mapped[str | None] = mapped_column(String(20))
|
||||
nr_auto: Mapped[str | None] = mapped_column(String(20))
|
||||
total_fara_tva: Mapped[float] = mapped_column(Float, default=0, server_default="0")
|
||||
tva: Mapped[float] = mapped_column(Float, default=0, server_default="0")
|
||||
total_general: Mapped[float] = mapped_column(Float, default=0, server_default="0")
|
||||
# Legacy field kept for REST API service compatibility
|
||||
total: Mapped[float] = mapped_column(Float, default=0, server_default="0")
|
||||
status: Mapped[str] = mapped_column(String(20), default="EMISA", server_default="EMISA")
|
||||
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")
|
||||
32
backend/app/db/models/order.py
Normal file
32
backend/app/db/models/order.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from sqlalchemy import Float, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base, UUIDMixin, TenantMixin, TimestampMixin
|
||||
|
||||
|
||||
class Order(Base, UUIDMixin, TenantMixin, TimestampMixin):
|
||||
__tablename__ = "orders"
|
||||
nr_comanda: Mapped[str | None] = mapped_column(String(50))
|
||||
vehicle_id: Mapped[str | None] = mapped_column(String(36))
|
||||
client_id: Mapped[str | None] = mapped_column(String(36))
|
||||
tip_deviz_id: Mapped[str | None] = mapped_column(String(36))
|
||||
status: Mapped[str] = mapped_column(String(20), default="DRAFT", server_default="DRAFT")
|
||||
data_comanda: Mapped[str | None] = mapped_column(Text)
|
||||
km_intrare: Mapped[int | None] = mapped_column(Integer)
|
||||
observatii: Mapped[str | None] = mapped_column(Text)
|
||||
mecanic_id: Mapped[str | None] = mapped_column(String(36))
|
||||
# Denormalized client/vehicle info for quick display
|
||||
client_nume: Mapped[str | None] = mapped_column(String(200))
|
||||
client_telefon: Mapped[str | None] = mapped_column(String(20))
|
||||
nr_auto: Mapped[str | None] = mapped_column(String(20))
|
||||
marca_denumire: Mapped[str | None] = mapped_column(String(100))
|
||||
model_denumire: Mapped[str | None] = mapped_column(String(100))
|
||||
# Totals — server_default ensures raw SQL INSERT without these fields still works
|
||||
total_manopera: Mapped[float] = mapped_column(Float, default=0, server_default="0")
|
||||
total_materiale: Mapped[float] = mapped_column(Float, default=0, server_default="0")
|
||||
total_general: Mapped[float] = mapped_column(Float, default=0, server_default="0")
|
||||
# Client portal
|
||||
token_client: Mapped[str | None] = mapped_column(String(36))
|
||||
status_client: Mapped[str | None] = mapped_column(String(20))
|
||||
# Audit
|
||||
created_by: Mapped[str | None] = mapped_column(String(36))
|
||||
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)
|
||||
26
backend/app/db/models/vehicle.py
Normal file
26
backend/app/db/models/vehicle.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from sqlalchemy import Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base, UUIDMixin, TenantMixin, TimestampMixin
|
||||
|
||||
|
||||
class Vehicle(Base, UUIDMixin, TenantMixin, TimestampMixin):
|
||||
__tablename__ = "vehicles"
|
||||
nr_inmatriculare: Mapped[str] = mapped_column(String(20))
|
||||
client_id: Mapped[str | None] = mapped_column(String(36))
|
||||
marca_id: Mapped[str | None] = mapped_column(String(36))
|
||||
model_id: Mapped[str | None] = mapped_column(String(36))
|
||||
an_fabricatie: Mapped[int | None] = mapped_column(Integer)
|
||||
# VIN / serie sasiu (vin kept for REST API compat, serie_sasiu for frontend sync)
|
||||
vin: Mapped[str | None] = mapped_column(String(17))
|
||||
serie_sasiu: Mapped[str | None] = mapped_column(String(50))
|
||||
tip_motor_id: Mapped[str | None] = mapped_column(String(36))
|
||||
capacitate_motor: Mapped[str | None] = mapped_column(String(20))
|
||||
putere_kw: Mapped[str | None] = mapped_column(String(20))
|
||||
client_nume: Mapped[str | None] = mapped_column(String(200))
|
||||
client_telefon: Mapped[str | None] = mapped_column(String(20))
|
||||
client_email: Mapped[str | None] = mapped_column(String(200))
|
||||
# client_cod_fiscal used by frontend; client_cui kept for REST API compat
|
||||
client_cod_fiscal: Mapped[str | None] = mapped_column(String(20))
|
||||
client_cui: Mapped[str | None] = mapped_column(String(20))
|
||||
client_adresa: Mapped[str | None] = mapped_column(Text)
|
||||
143
backend/app/db/seed.py
Normal file
143
backend/app/db/seed.py
Normal file
@@ -0,0 +1,143 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.base import uuid7
|
||||
from app.db.models.catalog import (
|
||||
CatalogAnsamblu,
|
||||
CatalogMarca,
|
||||
CatalogPret,
|
||||
CatalogTipDeviz,
|
||||
CatalogTipMotor,
|
||||
)
|
||||
|
||||
MARCI = [
|
||||
"Audi",
|
||||
"BMW",
|
||||
"Citroen",
|
||||
"Dacia",
|
||||
"Fiat",
|
||||
"Ford",
|
||||
"Honda",
|
||||
"Hyundai",
|
||||
"Kia",
|
||||
"Mazda",
|
||||
"Mercedes-Benz",
|
||||
"Mitsubishi",
|
||||
"Nissan",
|
||||
"Opel",
|
||||
"Peugeot",
|
||||
"Renault",
|
||||
"Seat",
|
||||
"Skoda",
|
||||
"Suzuki",
|
||||
"Toyota",
|
||||
"Volkswagen",
|
||||
"Volvo",
|
||||
"Alfa Romeo",
|
||||
"Jeep",
|
||||
]
|
||||
|
||||
ANSAMBLE = [
|
||||
"Motor",
|
||||
"Cutie de viteze",
|
||||
"Frane",
|
||||
"Directie",
|
||||
"Suspensie",
|
||||
"Climatizare",
|
||||
"Electrica",
|
||||
"Caroserie",
|
||||
"Esapament",
|
||||
"Transmisie",
|
||||
"Revizie",
|
||||
]
|
||||
|
||||
TIPURI_DEVIZ = ["Service", "ITP", "Regie", "Constatare"]
|
||||
|
||||
TIPURI_MOTOARE = ["Benzina", "Diesel", "Hibrid", "Electric", "GPL"]
|
||||
|
||||
PRETURI = [
|
||||
{"denumire": "Manopera standard", "pret": 150.0, "um": "ora"},
|
||||
{"denumire": "Revizie ulei + filtru", "pret": 250.0, "um": "buc"},
|
||||
{"denumire": "Diagnosticare", "pret": 100.0, "um": "buc"},
|
||||
]
|
||||
|
||||
|
||||
async def seed_demo(db: AsyncSession) -> None:
|
||||
from app.auth.service import register
|
||||
from sqlalchemy import select
|
||||
from app.db.models.user import User
|
||||
|
||||
r = await db.execute(select(User).where(User.email == "demo@roaauto.ro"))
|
||||
if r.scalar_one_or_none():
|
||||
print("Demo user already exists.")
|
||||
return
|
||||
user, tenant = await register(db, "demo@roaauto.ro", "demo123", "ROA AUTO Demo", "0722000000")
|
||||
print(f"Created demo tenant: {tenant.id}, user: {user.email}")
|
||||
counts = await seed_catalog(db, tenant.id)
|
||||
print(f"Seeded catalog: {counts}")
|
||||
|
||||
|
||||
async def seed_catalog(db: AsyncSession, tenant_id: str) -> dict:
|
||||
now = datetime.now(UTC).isoformat()
|
||||
counts = {}
|
||||
|
||||
# Marci
|
||||
for name in MARCI:
|
||||
db.add(
|
||||
CatalogMarca(id=uuid7(), tenant_id=tenant_id, denumire=name)
|
||||
)
|
||||
counts["marci"] = len(MARCI)
|
||||
|
||||
# Ansamble
|
||||
for name in ANSAMBLE:
|
||||
db.add(
|
||||
CatalogAnsamblu(id=uuid7(), tenant_id=tenant_id, denumire=name)
|
||||
)
|
||||
counts["ansamble"] = len(ANSAMBLE)
|
||||
|
||||
# Tipuri deviz
|
||||
for name in TIPURI_DEVIZ:
|
||||
db.add(
|
||||
CatalogTipDeviz(id=uuid7(), tenant_id=tenant_id, denumire=name)
|
||||
)
|
||||
counts["tipuri_deviz"] = len(TIPURI_DEVIZ)
|
||||
|
||||
# Tipuri motoare
|
||||
for name in TIPURI_MOTOARE:
|
||||
db.add(
|
||||
CatalogTipMotor(id=uuid7(), tenant_id=tenant_id, denumire=name)
|
||||
)
|
||||
counts["tipuri_motoare"] = len(TIPURI_MOTOARE)
|
||||
|
||||
# Preturi
|
||||
for p in PRETURI:
|
||||
db.add(
|
||||
CatalogPret(
|
||||
id=uuid7(),
|
||||
tenant_id=tenant_id,
|
||||
denumire=p["denumire"],
|
||||
pret=p["pret"],
|
||||
um=p["um"],
|
||||
)
|
||||
)
|
||||
counts["preturi"] = len(PRETURI)
|
||||
|
||||
await db.commit()
|
||||
return counts
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import asyncio
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
||||
from app.config import settings
|
||||
import app.db.models # noqa
|
||||
|
||||
async def main():
|
||||
engine = create_async_engine(settings.DATABASE_URL)
|
||||
Session = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with Session() as db:
|
||||
await seed_demo(db)
|
||||
await engine.dispose()
|
||||
|
||||
asyncio.run(main())
|
||||
11
backend/app/db/session.py
Normal file
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
149
backend/app/invoices/router.py
Normal file
149
backend/app/invoices/router.py
Normal file
@@ -0,0 +1,149 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models.invoice import Invoice
|
||||
from app.db.models.order import Order
|
||||
from app.db.models.order_line import OrderLine
|
||||
from app.db.models.tenant import Tenant
|
||||
from app.db.models.vehicle import Vehicle
|
||||
from app.db.session import get_db
|
||||
from app.deps import get_tenant_id
|
||||
from app.invoices import service
|
||||
from app.pdf.service import generate_factura
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_invoice(
|
||||
data: dict,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
order_id = data.get("order_id")
|
||||
if not order_id:
|
||||
raise HTTPException(status_code=422, detail="order_id required")
|
||||
try:
|
||||
invoice = await service.create_invoice(db, tenant_id, order_id)
|
||||
return {"id": invoice.id, "nr_factura": invoice.nr_factura}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=422, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{invoice_id}/pdf")
|
||||
async def get_invoice_pdf(
|
||||
invoice_id: str,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
r = await db.execute(
|
||||
select(Invoice).where(
|
||||
Invoice.id == invoice_id, Invoice.tenant_id == tenant_id
|
||||
)
|
||||
)
|
||||
invoice = r.scalar_one_or_none()
|
||||
if not invoice:
|
||||
raise HTTPException(status_code=404, detail="Invoice not found")
|
||||
|
||||
r = await db.execute(select(Order).where(Order.id == invoice.order_id))
|
||||
order = r.scalar_one()
|
||||
|
||||
r = await db.execute(select(Vehicle).where(Vehicle.id == order.vehicle_id))
|
||||
vehicle = r.scalar_one_or_none()
|
||||
|
||||
r = await db.execute(select(Tenant).where(Tenant.id == tenant_id))
|
||||
tenant = r.scalar_one()
|
||||
|
||||
r = await db.execute(
|
||||
select(OrderLine).where(OrderLine.order_id == order.id)
|
||||
)
|
||||
lines = r.scalars().all()
|
||||
|
||||
order_data = {
|
||||
"id": order.id,
|
||||
"nr_auto": vehicle.nr_inmatriculare if vehicle else "",
|
||||
"client_nume": vehicle.client_nume if vehicle else "",
|
||||
"client_cui": vehicle.client_cui if vehicle else "",
|
||||
"client_adresa": vehicle.client_adresa if vehicle else "",
|
||||
"total_manopera": order.total_manopera,
|
||||
"total_materiale": order.total_materiale,
|
||||
"total_general": order.total_general,
|
||||
}
|
||||
|
||||
invoice_data = {
|
||||
"nr_factura": invoice.nr_factura,
|
||||
"data_factura": invoice.data_factura,
|
||||
"total": invoice.total,
|
||||
}
|
||||
|
||||
tenant_data = {
|
||||
"nume": tenant.nume,
|
||||
"cui": tenant.cui,
|
||||
"reg_com": tenant.reg_com,
|
||||
"adresa": tenant.adresa,
|
||||
"iban": tenant.iban,
|
||||
"banca": tenant.banca,
|
||||
}
|
||||
|
||||
lines_data = [
|
||||
{
|
||||
"tip": l.tip,
|
||||
"descriere": l.descriere,
|
||||
"ore": l.ore,
|
||||
"pret_ora": l.pret_ora,
|
||||
"cantitate": l.cantitate,
|
||||
"pret_unitar": l.pret_unitar,
|
||||
"um": l.um,
|
||||
"total": l.total,
|
||||
}
|
||||
for l in lines
|
||||
]
|
||||
|
||||
pdf_bytes = generate_factura(invoice_data, order_data, lines_data, tenant_data)
|
||||
return Response(
|
||||
content=pdf_bytes,
|
||||
media_type="application/pdf",
|
||||
headers={
|
||||
"Content-Disposition": f'inline; filename="factura-{invoice.nr_factura}.pdf"'
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{invoice_id}")
|
||||
async def delete_invoice(
|
||||
invoice_id: str,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
r = await db.execute(
|
||||
select(Invoice).where(
|
||||
Invoice.id == invoice_id, Invoice.tenant_id == tenant_id
|
||||
)
|
||||
)
|
||||
invoice = r.scalar_one_or_none()
|
||||
if not invoice:
|
||||
raise HTTPException(status_code=404, detail="Invoice not found")
|
||||
|
||||
order_id = invoice.order_id
|
||||
|
||||
# Delete the invoice
|
||||
await db.delete(invoice)
|
||||
|
||||
# Revert the associated order status back to VALIDAT
|
||||
if order_id:
|
||||
r = await db.execute(
|
||||
select(Order).where(
|
||||
Order.id == order_id, Order.tenant_id == tenant_id
|
||||
)
|
||||
)
|
||||
order = r.scalar_one_or_none()
|
||||
if order:
|
||||
order.status = "VALIDAT"
|
||||
order.updated_at = datetime.now(UTC).isoformat()
|
||||
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
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
|
||||
49
backend/app/main.py
Normal file
49
backend/app/main.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.auth.router import router as auth_router
|
||||
from app.client_portal.router import router as portal_router
|
||||
from app.clients.router import router as clients_router
|
||||
from app.config import settings
|
||||
from app.db.base import Base
|
||||
from app.db.session import engine
|
||||
from app.invoices.router import router as invoices_router
|
||||
from app.orders.router import router as orders_router
|
||||
from app.sync.router import router as sync_router
|
||||
from app.users.router import router as users_router
|
||||
from app.vehicles.router import router as vehicles_router
|
||||
|
||||
# Import models so Base.metadata knows about them
|
||||
import app.db.models # noqa: F401
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.CORS_ORIGINS.split(","),
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
allow_credentials=True,
|
||||
)
|
||||
app.include_router(auth_router, prefix="/api/auth")
|
||||
app.include_router(sync_router, prefix="/api/sync")
|
||||
app.include_router(clients_router, prefix="/api/clients")
|
||||
app.include_router(orders_router, prefix="/api/orders")
|
||||
app.include_router(vehicles_router, prefix="/api/vehicles")
|
||||
app.include_router(invoices_router, prefix="/api/invoices")
|
||||
app.include_router(users_router, prefix="/api/users")
|
||||
app.include_router(portal_router, prefix="/api")
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
0
backend/app/orders/__init__.py
Normal file
0
backend/app/orders/__init__.py
Normal file
272
backend/app/orders/router.py
Normal file
272
backend/app/orders/router.py
Normal file
@@ -0,0 +1,272 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models.invoice import Invoice
|
||||
from app.db.models.order import Order
|
||||
from app.db.models.order_line import OrderLine
|
||||
from app.db.models.tenant import Tenant
|
||||
from app.db.models.vehicle import Vehicle
|
||||
from app.db.session import get_db
|
||||
from app.deps import get_tenant_id
|
||||
from app.orders import schemas, service
|
||||
from app.pdf.service import generate_deviz
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_orders(
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await service.list_orders(db, tenant_id)
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_order(
|
||||
data: schemas.CreateOrderRequest,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
order = await service.create_order(
|
||||
db,
|
||||
tenant_id,
|
||||
data.vehicle_id,
|
||||
data.tip_deviz_id,
|
||||
data.km_intrare,
|
||||
data.observatii,
|
||||
)
|
||||
return {"id": order.id}
|
||||
|
||||
|
||||
@router.get("/{order_id}")
|
||||
async def get_order(
|
||||
order_id: str,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await service.get_order(db, tenant_id, order_id)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/{order_id}/lines")
|
||||
async def add_line(
|
||||
order_id: str,
|
||||
data: schemas.AddLineRequest,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
line = await service.add_line(
|
||||
db,
|
||||
tenant_id,
|
||||
order_id,
|
||||
data.tip,
|
||||
data.descriere,
|
||||
data.ore,
|
||||
data.pret_ora,
|
||||
data.cantitate,
|
||||
data.pret_unitar,
|
||||
data.um,
|
||||
)
|
||||
return {"id": line.id}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=422, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{order_id}/validate")
|
||||
async def validate_order(
|
||||
order_id: str,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
order = await service.validate_order(db, tenant_id, order_id)
|
||||
return {"status": order.status}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=422, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{order_id}/pdf/deviz")
|
||||
async def get_deviz_pdf(
|
||||
order_id: str,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
r = await db.execute(
|
||||
select(Order).where(Order.id == order_id, Order.tenant_id == tenant_id)
|
||||
)
|
||||
order = r.scalar_one_or_none()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
|
||||
r = await db.execute(select(Vehicle).where(Vehicle.id == order.vehicle_id))
|
||||
vehicle = r.scalar_one_or_none()
|
||||
|
||||
r = await db.execute(select(Tenant).where(Tenant.id == tenant_id))
|
||||
tenant = r.scalar_one()
|
||||
|
||||
r = await db.execute(
|
||||
select(OrderLine).where(OrderLine.order_id == order.id)
|
||||
)
|
||||
lines = r.scalars().all()
|
||||
|
||||
order_data = {
|
||||
"id": order.id,
|
||||
"data_comanda": order.data_comanda,
|
||||
"nr_auto": vehicle.nr_inmatriculare if vehicle else "",
|
||||
"client_nume": vehicle.client_nume if vehicle else "",
|
||||
"marca_denumire": "",
|
||||
"model_denumire": "",
|
||||
"total_manopera": order.total_manopera,
|
||||
"total_materiale": order.total_materiale,
|
||||
"total_general": order.total_general,
|
||||
}
|
||||
|
||||
tenant_data = {
|
||||
"nume": tenant.nume,
|
||||
"cui": tenant.cui,
|
||||
"adresa": tenant.adresa,
|
||||
"telefon": tenant.telefon,
|
||||
}
|
||||
|
||||
lines_data = [
|
||||
{
|
||||
"tip": l.tip,
|
||||
"descriere": l.descriere,
|
||||
"ore": l.ore,
|
||||
"pret_ora": l.pret_ora,
|
||||
"cantitate": l.cantitate,
|
||||
"pret_unitar": l.pret_unitar,
|
||||
"um": l.um,
|
||||
"total": l.total,
|
||||
}
|
||||
for l in lines
|
||||
]
|
||||
|
||||
pdf_bytes = generate_deviz(order_data, lines_data, tenant_data)
|
||||
return Response(
|
||||
content=pdf_bytes,
|
||||
media_type="application/pdf",
|
||||
headers={
|
||||
"Content-Disposition": f'inline; filename="deviz-{order.id[:8]}.pdf"'
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{order_id}")
|
||||
async def update_order(
|
||||
order_id: str,
|
||||
data: schemas.UpdateOrderRequest,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
r = await db.execute(
|
||||
select(Order).where(Order.id == order_id, Order.tenant_id == tenant_id)
|
||||
)
|
||||
order = r.scalar_one_or_none()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
if order.status != "DRAFT":
|
||||
raise HTTPException(status_code=422, detail="Can only update DRAFT orders")
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(order, key, value)
|
||||
order.updated_at = datetime.now(UTC).isoformat()
|
||||
await db.commit()
|
||||
await db.refresh(order)
|
||||
return {
|
||||
"id": order.id,
|
||||
"vehicle_id": order.vehicle_id,
|
||||
"client_id": order.client_id,
|
||||
"tip_deviz_id": order.tip_deviz_id,
|
||||
"status": order.status,
|
||||
"km_intrare": order.km_intrare,
|
||||
"observatii": order.observatii,
|
||||
"client_nume": order.client_nume,
|
||||
"client_telefon": order.client_telefon,
|
||||
"nr_auto": order.nr_auto,
|
||||
"marca_denumire": order.marca_denumire,
|
||||
"model_denumire": order.model_denumire,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{order_id}/devalidate")
|
||||
async def devalidate_order(
|
||||
order_id: str,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
r = await db.execute(
|
||||
select(Order).where(Order.id == order_id, Order.tenant_id == tenant_id)
|
||||
)
|
||||
order = r.scalar_one_or_none()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
if order.status != "VALIDAT":
|
||||
raise HTTPException(status_code=422, detail="Can only devalidate VALIDAT orders")
|
||||
|
||||
# Check no invoice exists for this order
|
||||
r = await db.execute(
|
||||
select(Invoice).where(
|
||||
Invoice.order_id == order_id, Invoice.tenant_id == tenant_id
|
||||
)
|
||||
)
|
||||
invoice = r.scalar_one_or_none()
|
||||
if invoice:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Cannot devalidate order with existing invoice"
|
||||
)
|
||||
|
||||
order.status = "DRAFT"
|
||||
order.updated_at = datetime.now(UTC).isoformat()
|
||||
await db.commit()
|
||||
return {"ok": True, "status": "DRAFT"}
|
||||
|
||||
|
||||
@router.delete("/{order_id}")
|
||||
async def delete_order(
|
||||
order_id: str,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
r = await db.execute(
|
||||
select(Order).where(Order.id == order_id, Order.tenant_id == tenant_id)
|
||||
)
|
||||
order = r.scalar_one_or_none()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
|
||||
if order.status == "FACTURAT":
|
||||
# Check if invoice exists
|
||||
r = await db.execute(
|
||||
select(Invoice).where(
|
||||
Invoice.order_id == order_id, Invoice.tenant_id == tenant_id
|
||||
)
|
||||
)
|
||||
invoice = r.scalar_one_or_none()
|
||||
if invoice:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Cannot delete order with existing invoice"
|
||||
)
|
||||
|
||||
# Delete order lines first
|
||||
await db.execute(
|
||||
text("DELETE FROM order_lines WHERE order_id = :oid AND tenant_id = :tid"),
|
||||
{"oid": order_id, "tid": tenant_id},
|
||||
)
|
||||
# Delete the order
|
||||
await db.execute(
|
||||
text("DELETE FROM orders WHERE id = :oid AND tenant_id = :tid"),
|
||||
{"oid": order_id, "tid": tenant_id},
|
||||
)
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
31
backend/app/orders/schemas.py
Normal file
31
backend/app/orders/schemas.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class CreateOrderRequest(BaseModel):
|
||||
vehicle_id: str
|
||||
tip_deviz_id: str | None = None
|
||||
km_intrare: int | None = None
|
||||
observatii: str | None = None
|
||||
|
||||
|
||||
class UpdateOrderRequest(BaseModel):
|
||||
vehicle_id: str | None = None
|
||||
tip_deviz_id: str | None = None
|
||||
km_intrare: int | None = None
|
||||
observatii: str | None = None
|
||||
client_id: str | None = None
|
||||
client_nume: str | None = None
|
||||
client_telefon: str | None = None
|
||||
nr_auto: str | None = None
|
||||
marca_denumire: str | None = None
|
||||
model_denumire: str | None = None
|
||||
|
||||
|
||||
class AddLineRequest(BaseModel):
|
||||
tip: str # manopera | material
|
||||
descriere: str
|
||||
ore: float = 0
|
||||
pret_ora: float = 0
|
||||
cantitate: float = 0
|
||||
pret_unitar: float = 0
|
||||
um: str | None = None
|
||||
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 = []
|
||||
137
backend/app/sync/service.py
Normal file
137
backend/app/sync/service.py
Normal file
@@ -0,0 +1,137 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
SYNCABLE_TABLES = [
|
||||
"clients",
|
||||
"vehicles",
|
||||
"orders",
|
||||
"order_lines",
|
||||
"invoices",
|
||||
"appointments",
|
||||
"catalog_marci",
|
||||
"catalog_modele",
|
||||
"catalog_ansamble",
|
||||
"catalog_norme",
|
||||
"catalog_preturi",
|
||||
"catalog_tipuri_deviz",
|
||||
"catalog_tipuri_motoare",
|
||||
"mecanici",
|
||||
]
|
||||
|
||||
# Tables that don't have tenant_id directly
|
||||
NO_TENANT_TABLES = {"catalog_modele"}
|
||||
|
||||
|
||||
async def _get_table_columns(db: AsyncSession, table: str) -> set[str]:
|
||||
"""Return the set of column names for a given table using PRAGMA table_info."""
|
||||
rows = await db.execute(text(f"PRAGMA table_info({table})"))
|
||||
return {row[1] for row in rows}
|
||||
|
||||
|
||||
async def get_full(db: AsyncSession, tenant_id: str) -> dict:
|
||||
result = {}
|
||||
for table in SYNCABLE_TABLES:
|
||||
if table == "catalog_modele":
|
||||
rows = await db.execute(
|
||||
text(
|
||||
"SELECT cm.* FROM catalog_modele cm "
|
||||
"JOIN catalog_marci marc ON cm.marca_id = marc.id "
|
||||
"WHERE marc.tenant_id = :tid"
|
||||
),
|
||||
{"tid": tenant_id},
|
||||
)
|
||||
else:
|
||||
rows = await db.execute(
|
||||
text(f"SELECT * FROM {table} WHERE tenant_id = :tid"),
|
||||
{"tid": tenant_id},
|
||||
)
|
||||
result[table] = [dict(r._mapping) for r in rows]
|
||||
return result
|
||||
|
||||
|
||||
async def get_changes(db: AsyncSession, tenant_id: str, since: str) -> dict:
|
||||
result = {}
|
||||
for table in SYNCABLE_TABLES:
|
||||
if table == "catalog_modele":
|
||||
rows = await db.execute(
|
||||
text(
|
||||
"SELECT cm.* FROM catalog_modele cm "
|
||||
"JOIN catalog_marci marc ON cm.marca_id = marc.id "
|
||||
"WHERE marc.tenant_id = :tid AND cm.updated_at > :since"
|
||||
),
|
||||
{"tid": tenant_id, "since": since},
|
||||
)
|
||||
else:
|
||||
rows = await db.execute(
|
||||
text(
|
||||
f"SELECT * FROM {table} WHERE tenant_id = :tid AND updated_at > :since"
|
||||
),
|
||||
{"tid": tenant_id, "since": since},
|
||||
)
|
||||
rows_list = [dict(r._mapping) for r in rows]
|
||||
if rows_list:
|
||||
result[table] = rows_list
|
||||
return result
|
||||
|
||||
|
||||
async def apply_push(
|
||||
db: AsyncSession, tenant_id: str, operations: list
|
||||
) -> dict:
|
||||
applied = 0
|
||||
errors = []
|
||||
# Cache column sets per table to avoid repeated PRAGMA calls
|
||||
table_columns_cache: dict[str, set[str]] = {}
|
||||
|
||||
for op in operations:
|
||||
table = op["table"]
|
||||
if table not in SYNCABLE_TABLES:
|
||||
continue
|
||||
data = dict(op.get("data", {}))
|
||||
|
||||
# Enforce tenant isolation (except for no-tenant tables)
|
||||
if table not in NO_TENANT_TABLES:
|
||||
if data.get("tenant_id") and data["tenant_id"] != tenant_id:
|
||||
continue
|
||||
data["tenant_id"] = tenant_id
|
||||
|
||||
try:
|
||||
if op["operation"] in ("INSERT", "UPDATE"):
|
||||
# Fetch and cache the valid column names for this table
|
||||
if table not in table_columns_cache:
|
||||
table_columns_cache[table] = await _get_table_columns(db, table)
|
||||
valid_cols = table_columns_cache[table]
|
||||
|
||||
# Filter data to only include columns that exist in the DB table
|
||||
filtered = {k: v for k, v in data.items() if k in valid_cols}
|
||||
if not filtered:
|
||||
continue
|
||||
|
||||
cols = ", ".join(filtered.keys())
|
||||
ph = ", ".join(f":{k}" for k in filtered.keys())
|
||||
await db.execute(
|
||||
text(f"INSERT OR REPLACE INTO {table} ({cols}) VALUES ({ph})"),
|
||||
filtered,
|
||||
)
|
||||
applied += 1
|
||||
elif op["operation"] == "DELETE":
|
||||
if table in NO_TENANT_TABLES:
|
||||
await db.execute(
|
||||
text(f"DELETE FROM {table} WHERE id = :id"),
|
||||
{"id": op["id"]},
|
||||
)
|
||||
else:
|
||||
await db.execute(
|
||||
text(
|
||||
f"DELETE FROM {table} WHERE id = :id AND tenant_id = :tid"
|
||||
),
|
||||
{"id": op["id"], "tid": tenant_id},
|
||||
)
|
||||
applied += 1
|
||||
except Exception as exc: # noqa: BLE001
|
||||
errors.append({"table": table, "id": op.get("id"), "error": str(exc)})
|
||||
await db.rollback()
|
||||
|
||||
await db.commit()
|
||||
return {"applied": applied, "conflicts": errors}
|
||||
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)
|
||||
```
|
||||
60
docs/PLAN.md
60
docs/PLAN.md
@@ -245,13 +245,18 @@ catalog_tipuri_deviz (id, tenant_id, denumire)
|
||||
catalog_tipuri_motoare (id, tenant_id, denumire)
|
||||
mecanici (id, tenant_id, user_id, nume, prenume, activ)
|
||||
|
||||
-- Clients (nomenclator clienti cu date eFactura ANAF)
|
||||
clients (id, tenant_id, tip_persoana, denumire, cod_fiscal, reg_com,
|
||||
adresa, judet, oras, cod_postal, tara, telefon, email,
|
||||
cont_iban, banca, observatii, activ, created_at, updated_at)
|
||||
|
||||
-- Core Business
|
||||
vehicles (id, tenant_id, client_nume, client_telefon, client_email,
|
||||
vehicles (id, tenant_id, client_id, client_nume, client_telefon, client_email,
|
||||
client_cod_fiscal, client_adresa, nr_inmatriculare,
|
||||
marca_id, model_id, an_fabricatie, serie_sasiu,
|
||||
tip_motor_id, created_at, updated_at)
|
||||
|
||||
orders (id, tenant_id, nr_comanda, data_comanda, vehicle_id,
|
||||
orders (id, tenant_id, nr_comanda, data_comanda, vehicle_id, client_id,
|
||||
tip_deviz_id, status, km_intrare, observatii,
|
||||
-- client snapshot (denormalized)
|
||||
client_nume, client_telefon, nr_auto, marca_denumire, model_denumire,
|
||||
@@ -266,8 +271,8 @@ order_lines (id, order_id, tenant_id, tip, descriere,
|
||||
um, cantitate, pret_unitar, -- material
|
||||
total, mecanic_id, ordine, created_at, updated_at)
|
||||
|
||||
invoices (id, tenant_id, order_id, nr_factura, serie_factura,
|
||||
data_factura, modalitate_plata,
|
||||
invoices (id, tenant_id, order_id, client_id, nr_factura, serie_factura,
|
||||
data_factura, tip_document, modalitate_plata,
|
||||
client_nume, client_cod_fiscal, nr_auto,
|
||||
total_fara_tva, tva, total_general, created_at, updated_at)
|
||||
|
||||
@@ -379,6 +384,46 @@ _sync_state (table_name, last_sync_at)
|
||||
4. Responsive testing (phone, tablet, desktop)
|
||||
5. Reports: sumar lunar, export CSV
|
||||
|
||||
### Faza 8: Nomenclator Clienti (Clients)
|
||||
**Livrabil: CRUD clienti cu date eFactura ANAF, legatura 1:N cu vehicule**
|
||||
|
||||
1. Model `clients` + migrare Alembic (backend)
|
||||
2. `client_id` FK pe `vehicles`, `orders`, `invoices`
|
||||
3. CRUD endpoints: `GET/POST /api/clients`, `GET/PUT/DELETE /api/clients/{id}`
|
||||
4. wa-sqlite schema update (tabel `clients`, FK-uri)
|
||||
5. Frontend: pagina Clienti (list, create, edit, delete)
|
||||
6. Frontend: selector client in VehiclePicker si OrderCreate
|
||||
7. Sync: adauga `clients` in `SYNCABLE_TABLES`
|
||||
8. Playwright E2E tests (desktop + mobile)
|
||||
|
||||
### Faza 9: Edit/Delete/Devalidare Comenzi
|
||||
**Livrabil: Gestionare completa comenzi - edit, stergere, devalidare**
|
||||
|
||||
1. `PUT /api/orders/{id}` - edit header comanda (doar in DRAFT)
|
||||
2. `DELETE /api/orders/{id}` - stergere comanda (orice nefacturat)
|
||||
3. `POST /api/orders/{id}/devalidate` - VALIDAT → DRAFT
|
||||
4. `DELETE /api/invoices/{id}` - stergere factura (permite stergere comanda FACTURAT)
|
||||
5. Frontend: butoane edit/delete/devalidare pe OrderDetail
|
||||
6. Confirmare stergere cu modal
|
||||
7. Playwright E2E tests
|
||||
|
||||
### Faza 10: Integrare Nomenclator Clienti
|
||||
**Livrabil: Clienti integrati in flux comenzi si facturi**
|
||||
|
||||
1. Auto-populare date client pe comanda din nomenclator
|
||||
2. Selectie client existent sau creare client nou la vehicul
|
||||
3. Validare date client complete la facturare (CUI, adresa)
|
||||
4. PDF factura cu date client din nomenclator
|
||||
|
||||
### Faza 11: Bon Fiscal (tip_document)
|
||||
**Livrabil: Suport dual FACTURA + BON_FISCAL pe invoices**
|
||||
|
||||
1. `tip_document` pe invoices: FACTURA (B2B, eFactura) sau BON_FISCAL (B2C, casa de marcat)
|
||||
2. Factura: necesita date client complete (CUI, adresa)
|
||||
3. Bon fiscal: format simplificat, fara date client obligatorii
|
||||
4. UI: selectie tip document la facturare
|
||||
5. PDF template diferentiat pentru bon fiscal
|
||||
|
||||
---
|
||||
|
||||
## Referinta din Prototip (doar consultare)
|
||||
@@ -405,6 +450,9 @@ SaaS-ul trebuie sa fie compatibil cu ROAAUTO VFP9+Oracle. Clientii care cresc po
|
||||
| `order_lines` (tip=manopera) | `dev_oper` | SaaS unifica oper+materiale in order_lines |
|
||||
| `order_lines` (tip=material) | `dev_estimari_produse` | Acelasi tabel, filtrat pe `tip` |
|
||||
| `vehicles` | `dev_masiniclienti` | Renamed, aceleasi coloane client+vehicul |
|
||||
| `clients` | `nom_parteneri` + `adrese_parteneri` | Adrese simplificate flat |
|
||||
| `clients.tip_persoana` | `nom_parteneri.tip_persoana` | PF/PJ |
|
||||
| `clients.cod_fiscal` | `nom_parteneri.cod_fiscal` | CUI sau CNP |
|
||||
| `catalog_marci` | `dev_nom_marci` | +tenant_id |
|
||||
| `catalog_modele` | `dev_nom_masini` | Identic |
|
||||
| `catalog_ansamble` | `dev_nom_ansamble` | +tenant_id |
|
||||
@@ -414,6 +462,10 @@ SaaS-ul trebuie sa fie compatibil cu ROAAUTO VFP9+Oracle. Clientii care cresc po
|
||||
| `catalog_tipuri_motoare` | `dev_tipuri_motoare` | +tenant_id |
|
||||
| `mecanici` | `dev_mecanici` | +tenant_id, +user_id |
|
||||
| `invoices` | `facturi` (local) | Identic structural |
|
||||
| `invoices.tip_document` | `vanzari.tip_factura` | FACTURA/BON_FISCAL |
|
||||
| `invoices.client_id` | `vanzari.id_part` | FK la client |
|
||||
| `orders.client_id` | (denormalizat) | Referinta directa la client |
|
||||
| `vehicles.client_id` | (implicit in dev_masiniclienti) | 1:N client → vehicule |
|
||||
| `tenants` | - | Doar SaaS (nu exista in Oracle) |
|
||||
| `users` | - | Doar SaaS |
|
||||
| `appointments` | - | Doar SaaS (feature nou) |
|
||||
|
||||
98
docs/api-contract.json
Normal file
98
docs/api-contract.json
Normal file
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"version": "1.0",
|
||||
"note": "Contract shared intre backend-agent si frontend-agent. Nu modificati fara notificarea ambilor agenti.",
|
||||
"base_url": "/api",
|
||||
"auth": {
|
||||
"POST /auth/register": {
|
||||
"body": {"email": "str", "password": "str", "tenant_name": "str", "telefon": "str"},
|
||||
"response": {"access_token": "str", "token_type": "bearer", "tenant_id": "str", "plan": "str"}
|
||||
},
|
||||
"POST /auth/login": {
|
||||
"body": {"email": "str", "password": "str"},
|
||||
"response": {"access_token": "str", "token_type": "bearer", "tenant_id": "str", "plan": "str"}
|
||||
},
|
||||
"GET /auth/me": {
|
||||
"headers": {"Authorization": "Bearer <token>"},
|
||||
"response": {"id": "str", "email": "str", "tenant_id": "str", "plan": "str", "rol": "str"}
|
||||
}
|
||||
},
|
||||
"sync": {
|
||||
"GET /sync/full": {
|
||||
"headers": {"Authorization": "Bearer <token>"},
|
||||
"response": {
|
||||
"tables": {
|
||||
"clients": [], "vehicles": [], "orders": [], "order_lines": [],
|
||||
"invoices": [], "appointments": [],
|
||||
"catalog_marci": [], "catalog_modele": [],
|
||||
"catalog_ansamble": [], "catalog_norme": [],
|
||||
"catalog_preturi": [], "catalog_tipuri_deviz": [],
|
||||
"catalog_tipuri_motoare": [], "mecanici": []
|
||||
},
|
||||
"synced_at": "ISO8601"
|
||||
}
|
||||
},
|
||||
"GET /sync/changes": {
|
||||
"params": {"since": "ISO8601"},
|
||||
"response": {"tables": {}, "synced_at": "str"}
|
||||
},
|
||||
"POST /sync/push": {
|
||||
"body": {"operations": [{"table": "str", "id": "uuid", "operation": "INSERT|UPDATE|DELETE", "data": {}, "timestamp": "str"}]},
|
||||
"response": {"applied": 0, "conflicts": []}
|
||||
}
|
||||
},
|
||||
"orders": {
|
||||
"GET /orders": {"response": [{"id": "str", "status": "str", "nr_auto": "str", "total_general": 0}]},
|
||||
"POST /orders": {"body": {"vehicle_id": "str", "client_id": "str", "tip_deviz_id": "str", "km_intrare": 0, "observatii": "str"}, "response": {"id": "str"}},
|
||||
"GET /orders/{id}": {"response": {"id": "str", "status": "str", "lines": []}},
|
||||
"PUT /orders/{id}": {"body": {"vehicle_id": "str", "client_id": "str", "tip_deviz_id": "str", "km_intrare": 0, "observatii": "str"}, "note": "Edit header - doar in DRAFT"},
|
||||
"DELETE /orders/{id}": {"response": {"ok": true}, "note": "Stergere - orice nefacturat; FACTURAT = sterge factura intai"},
|
||||
"POST /orders/{id}/lines": {"body": {"tip": "manopera|material", "descriere": "str", "ore": 0, "pret_ora": 0, "cantitate": 0, "pret_unitar": 0, "um": "str"}},
|
||||
"POST /orders/{id}/validate": {"response": {"status": "VALIDAT"}},
|
||||
"POST /orders/{id}/devalidate": {"response": {"status": "DRAFT"}, "note": "VALIDAT → DRAFT"},
|
||||
"GET /orders/{id}/pdf/deviz": {"response": "application/pdf"}
|
||||
},
|
||||
"clients": {
|
||||
"GET /clients": {"response": [{"id": "str", "tip_persoana": "PF|PJ", "denumire": "str", "cod_fiscal": "str", "telefon": "str", "email": "str", "activ": true}]},
|
||||
"POST /clients": {"body": {"tip_persoana": "PF|PJ", "denumire": "str", "cod_fiscal": "str", "reg_com": "str", "adresa": "str", "judet": "str", "oras": "str", "cod_postal": "str", "tara": "str", "telefon": "str", "email": "str", "cont_iban": "str", "banca": "str", "observatii": "str"}, "response": {"id": "str"}},
|
||||
"GET /clients/{id}": {"response": {"id": "str", "tip_persoana": "str", "denumire": "str", "cod_fiscal": "str", "vehicles": []}},
|
||||
"PUT /clients/{id}": {"body": {"denumire": "str", "cod_fiscal": "str", "adresa": "str"}, "response": {"id": "str"}},
|
||||
"DELETE /clients/{id}": {"response": {"ok": true}}
|
||||
},
|
||||
"vehicles": {
|
||||
"GET /vehicles": {"response": [{"id": "str", "nr_auto": "str", "marca": "str", "model": "str", "an": 0}]},
|
||||
"POST /vehicles": {"body": {"nr_auto": "str", "marca_id": "str", "model_id": "str", "an_fabricatie": 0, "vin": "str", "proprietar_nume": "str", "proprietar_telefon": "str"}},
|
||||
"GET /vehicles/{id}": {"response": {"id": "str", "nr_auto": "str", "orders": []}},
|
||||
"PUT /vehicles/{id}": {"body": {}}
|
||||
},
|
||||
"client_portal": {
|
||||
"GET /p/{token}": {"response": {"order": {}, "tenant": {}, "lines": []}},
|
||||
"POST /p/{token}/accept": {"response": {"ok": true}},
|
||||
"POST /p/{token}/reject": {"response": {"ok": true}}
|
||||
},
|
||||
"invoices": {
|
||||
"POST /invoices": {"body": {"order_id": "str", "client_id": "str", "tip_document": "FACTURA|BON_FISCAL"}, "response": {"id": "str", "nr_factura": "str"}},
|
||||
"GET /invoices/{id}/pdf": {"response": "application/pdf"},
|
||||
"DELETE /invoices/{id}": {"response": {"ok": true}, "note": "Sterge factura, comanda revine la VALIDAT"}
|
||||
},
|
||||
"users": {
|
||||
"GET /users": {"response": [{"id": "str", "email": "str", "rol": "str"}]},
|
||||
"POST /users/invite": {"body": {"email": "str", "rol": "admin|mecanic"}},
|
||||
"DELETE /users/{id}": {"response": {"ok": true}},
|
||||
"POST /auth/accept-invite": {"body": {"token": "str", "password": "str"}}
|
||||
},
|
||||
"appointments": {
|
||||
"GET /appointments": {"response": [{"id": "str", "data": "str", "vehicle_id": "str", "descriere": "str"}]},
|
||||
"POST /appointments": {"body": {"vehicle_id": "str", "data": "ISO8601", "descriere": "str"}},
|
||||
"PUT /appointments/{id}": {"body": {}},
|
||||
"DELETE /appointments/{id}": {"response": {"ok": true}}
|
||||
},
|
||||
"catalog": {
|
||||
"GET /catalog/marci": {"response": [{"id": "str", "nume": "str"}]},
|
||||
"GET /catalog/modele": {"params": {"marca_id": "str"}, "response": [{"id": "str", "nume": "str"}]},
|
||||
"GET /catalog/norme": {"params": {"ansamblu_id": "str"}, "response": [{"id": "str", "descriere": "str", "ore": 0}]},
|
||||
"GET /catalog/preturi": {"response": [{"id": "str", "tip": "str", "valoare": 0}]}
|
||||
},
|
||||
"health": {
|
||||
"GET /health": {"response": {"status": "ok", "version": "str"}}
|
||||
}
|
||||
}
|
||||
68
docs/playwright-report-2026-03-14.md
Normal file
68
docs/playwright-report-2026-03-14.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# Playwright Test Report - 2026-03-14
|
||||
|
||||
## Summary
|
||||
- Total: 31
|
||||
- Pass: 27
|
||||
- Fail: 2
|
||||
- Skipped: 2
|
||||
|
||||
## Notes
|
||||
- The app uses in-memory SQLite (wa-sqlite `:memory:`). On full page reload, all local data is lost and must be re-synced.
|
||||
- Sync had errors due to schema mismatches (`table vehicles has no column named vi...`, `table catalog_marci has no column named...`), which prevented demo data from loading. All tests were performed with locally-created data.
|
||||
- The `tip_deviz` dropdown was empty because `tipuri_deviz` table had no data (sync failed).
|
||||
|
||||
## Desktop Tests (1280x720)
|
||||
|
||||
| Test | Description | Status | Notes |
|
||||
|------|-------------|--------|-------|
|
||||
| T1 | Login + Dashboard | PASS | Login with demo@roaauto.ro works. Dashboard shows stats cards (Total, Draft, Validate, Facturate) and orders table with filter tabs. |
|
||||
| T2 | Comanda noua button | PASS | Clicking "+ Comanda noua" navigates to /orders/new with step-by-step wizard. |
|
||||
| T3 | Full order creation | PASS | Created order with inline client+vehicle. Redirected to order detail with correct data (B 999 TST, Test SRL E2E, Dacia Logan, KM 55000). |
|
||||
| T4 | Inline client creation (PJ) | PASS | "+ Client nou" opens inline form. Switching to PJ shows denumire/CUI/telefon fields. Client created and auto-selected with "(RO12345678)" display. |
|
||||
| T5 | Inline vehicle creation | PASS | "+ Vehicul nou" opens form. "+" buttons for marca/model allow inline creation (Dacia/Logan). Vehicle created and auto-selected as "B 999 TST - (Dacia Logan)". |
|
||||
| T6 | Clients page | PASS | Added PF client (Popescu Ion). Search by name filters correctly. Click row opens edit form with pre-filled data. Updated email saved successfully. |
|
||||
| T7 | Edit order header | PASS | "Editeaza" opens edit form with pre-filled fields. Changed KM from 55000 to 60000 and observatii. Changes persisted after save. |
|
||||
| T8 | Add manopera from nomenclator | PASS | Manopera line "Schimb ulei motor" added with 2h * 100 RON/h = 200.00 RON. Line appears in table with MAN badge. Autocomplete from catalog_norme not testable (no synced data). |
|
||||
| T9 | Add material from catalog | PASS | Material line "Filtru ulei" added with 1 buc * 50 RON = 50.00 RON. MAT badge shown. Totals updated: Manopera 200, Materiale 50, Total general 250. |
|
||||
| T10 | Validate then devalidate | PASS | "Valideaza" changes status to VALIDAT, shows PDF Deviz/Devalideaza/Factureaza buttons, hides line delete buttons. "Devalideaza" with confirmation reverts to DRAFT. |
|
||||
| T11 | Delete DRAFT order | PASS | "Sterge" shows confirmation dialog. "Da, sterge" deletes order and redirects to /dashboard. |
|
||||
| T12 | Dashboard filters | PASS | Draft tab shows "Nicio comanda gasita" (0 draft). Facturate tab shows FACTURAT orders. Toate shows all. Validate tab works. |
|
||||
| T13 | Dashboard search | PASS | Searching "B 999" finds matching order. Searching "XXXXXX" shows "Nicio comanda gasita". |
|
||||
| T14 | /orders redirect | PASS | Navigating to /orders redirects to /dashboard. |
|
||||
| T15 | Catalog Norme | PASS | Added ansamblu "Motor" first. Then added norma NRM-001 "Schimb ulei si filtre" with ansamblu Motor and 1.5 ore normate. Appears in table. |
|
||||
| T16 | Facturare dialog | PASS | "Factureaza" on VALIDAT order shows "Tip document" dialog with Factura (default) and Bon fiscal radio options. |
|
||||
| T17 | Factura creation | PASS | Choosing Factura and clicking "Creeaza" changes status to FACTURAT. "Sterge factura" button appears. |
|
||||
| T18 | Bon fiscal creation | PASS | After deleting invoice, re-factureaza with "Bon fiscal" selected. Status becomes FACTURAT. Invoice number starts with "ROABF-" confirming Bon Fiscal type. |
|
||||
| T19 | InvoicesView | PASS | /invoices shows table with Nr. factura, Tip (BON FISCAL badge), Data, Client, Nr. auto, Total, PDF download button. |
|
||||
| T20 | Delete invoice | PASS | "Sterge factura" shows confirmation. "Da, sterge" reverts order to VALIDAT with Devalideaza/Factureaza buttons restored. |
|
||||
|
||||
## Mobile Tests (375x812)
|
||||
|
||||
| Test | Description | Status | Notes |
|
||||
|------|-------------|--------|-------|
|
||||
| T21 | Dashboard responsive | PASS | Stats cards stack in 2x2 grid. Filter buttons and search visible. "+ Comanda noua" button accessible. Bottom nav visible. |
|
||||
| T22 | Order form responsive | PASS | Form fields stack vertically. Client picker full-width. "+ Client nou" button accessible. "Inapoi" link visible. |
|
||||
| T23 | OrderDetail responsive | FAIL | Action buttons (Editeaza, Valideaza, Sterge, DRAFT badge) crowd/overlap with order number heading on narrow screens. The CMD number wraps to 2 lines while buttons stack alongside. Totals area partially obscured by bottom nav. |
|
||||
| T24 | Clients responsive | PASS | Heading and "+ Adauga client" button fit. Search field full-width. "Clienti" highlighted in bottom nav. Table replaced with empty state message on fresh load. |
|
||||
| T25 | Bottom nav | PASS | Bottom navigation shows on mobile with: Acasa, Clienti, Vehicule, Programari, Setari. Active link is highlighted. |
|
||||
|
||||
## Visual Checks
|
||||
|
||||
| Test | Description | Status | Notes |
|
||||
|------|-------------|--------|-------|
|
||||
| T26 | Overflow check | PASS | No horizontal overflow detected on any page. Content stays within viewport bounds on mobile. |
|
||||
| T27 | Button spacing | FAIL | On mobile OrderDetail, the action buttons (Editeaza/Valideaza/Sterge) and DRAFT badge are cramped with the heading. They flow into the same line space as the 2-line CMD number, creating a crowded layout. Recommend stacking buttons below heading on mobile. |
|
||||
| T28 | Text truncation | PASS | No problematic text truncation observed. Client names, plate numbers, and amounts display fully. |
|
||||
| T29 | Modal/dialog display | PASS | Factureaza "Tip document" modal displays centered on desktop with backdrop. Delete confirmation dialogs render properly on both desktop and mobile. |
|
||||
| T30 | Badge colors | PASS | MAN (blue), MAT (purple/pink), DRAFT (yellow outline), VALIDAT (green), FACTURAT (green), PF (gray), PJ (blue), BON FISCAL (dark) - all badges render with distinct colors. |
|
||||
| T31 | Dialog on mobile | PASS | Delete confirmation dialog renders inline on mobile, fully readable with accessible buttons. No overflow or clipping. |
|
||||
|
||||
## Sync Issues Observed
|
||||
- `SQLiteError: table vehicles has no column named vi...` - repeated on fullSync
|
||||
- `SQLiteError: table catalog_marci has no column named...` - on fullSync
|
||||
- These errors prevented demo seed data from syncing to the frontend. The schema in `frontend/src/db/schema.js` may be out of sync with the backend model changes.
|
||||
|
||||
## Recommendations
|
||||
1. **Fix mobile OrderDetail layout (T23/T27)**: Stack action buttons below the heading on screens < 640px. Consider using `flex-wrap` or a separate row for buttons.
|
||||
2. **Fix sync schema mismatch**: The `vehicles` and `catalog_marci` tables in `frontend/src/db/schema.js` are missing columns that the backend sync sends. This breaks fullSync for all users.
|
||||
3. **Bottom nav overlap**: On order detail mobile, the totals section at the bottom is partially hidden behind the bottom navigation bar. Add `padding-bottom` to account for the fixed bottom nav height.
|
||||
7
frontend/.dockerignore
Normal file
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";
|
||||
211
frontend/src/components/clients/ClientPicker.vue
Normal file
211
frontend/src/components/clients/ClientPicker.vue
Normal file
@@ -0,0 +1,211 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<label v-if="label" class="block text-sm font-medium text-gray-700 mb-1">{{ label }}</label>
|
||||
<input
|
||||
v-model="query"
|
||||
type="text"
|
||||
:placeholder="placeholder"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
@input="onSearch"
|
||||
@focus="showDropdown = true"
|
||||
/>
|
||||
<!-- Selected client display -->
|
||||
<div v-if="selected" class="mt-1 text-sm text-gray-600">
|
||||
{{ displayName(selected) }}
|
||||
<span v-if="selected.cod_fiscal" class="text-gray-400"> ({{ selected.cod_fiscal }})</span>
|
||||
<button @click="clear" class="ml-2 text-red-500 hover:text-red-700 text-xs">Sterge</button>
|
||||
</div>
|
||||
<!-- Dropdown results -->
|
||||
<ul
|
||||
v-if="showDropdown && results.length > 0"
|
||||
class="absolute z-10 mt-1 w-full bg-white border border-gray-200 rounded-md shadow-lg max-h-48 overflow-auto"
|
||||
>
|
||||
<li
|
||||
v-for="c in results"
|
||||
:key="c.id"
|
||||
class="px-3 py-2 hover:bg-blue-50 cursor-pointer text-sm"
|
||||
@mousedown="selectClient(c)"
|
||||
>
|
||||
<span class="font-medium">{{ displayName(c) }}</span>
|
||||
<span v-if="c.cod_fiscal" class="text-gray-400 ml-2">({{ c.cod_fiscal }})</span>
|
||||
<span v-if="c.telefon" class="text-gray-400 ml-2">{{ c.telefon }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<!-- No results -->
|
||||
<div
|
||||
v-if="showDropdown && query.length >= 2 && results.length === 0 && !loading"
|
||||
class="absolute z-10 mt-1 w-full bg-white border border-gray-200 rounded-md shadow-lg p-3 text-sm text-gray-500"
|
||||
>
|
||||
Niciun client gasit
|
||||
</div>
|
||||
<!-- Create new client inline -->
|
||||
<div v-if="!selected" class="mt-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="showNewForm = !showNewForm"
|
||||
class="text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
{{ showNewForm ? 'Ascunde formular' : '+ Client nou' }}
|
||||
</button>
|
||||
<div v-if="showNewForm" class="mt-3 space-y-3 bg-gray-50 rounded-lg p-4">
|
||||
<div class="flex gap-4 mb-2">
|
||||
<label class="flex items-center gap-1.5 text-xs">
|
||||
<input v-model="newClient.tip_persoana" type="radio" value="PF" class="text-blue-600" />
|
||||
PF
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5 text-xs">
|
||||
<input v-model="newClient.tip_persoana" type="radio" value="PJ" class="text-blue-600" />
|
||||
PJ
|
||||
</label>
|
||||
</div>
|
||||
<template v-if="newClient.tip_persoana === 'PF'">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Nume</label>
|
||||
<input v-model="newClient.nume" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Prenume</label>
|
||||
<input v-model="newClient.prenume" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">CNP</label>
|
||||
<input v-model="newClient.cod_fiscal" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Telefon</label>
|
||||
<input v-model="newClient.telefon" type="tel" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="newClient.tip_persoana === 'PJ'">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Denumire firma</label>
|
||||
<input v-model="newClient.denumire" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">CUI</label>
|
||||
<input v-model="newClient.cod_fiscal" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Telefon</label>
|
||||
<input v-model="newClient.telefon" type="tel" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Email</label>
|
||||
<input v-model="newClient.email" type="email" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Adresa</label>
|
||||
<input v-model="newClient.adresa" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="handleCreateClient"
|
||||
:disabled="creatingClient"
|
||||
class="px-4 py-2 bg-green-600 text-white text-sm rounded-md hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
{{ creatingClient ? 'Se salveaza...' : 'Salveaza client' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, watch } from 'vue'
|
||||
import { useClientsStore } from '../../stores/clients.js'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: null },
|
||||
label: { type: String, default: 'Client' },
|
||||
placeholder: { type: String, default: 'Cauta dupa denumire, CUI, telefon...' },
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue', 'select'])
|
||||
|
||||
const clientsStore = useClientsStore()
|
||||
|
||||
const query = ref('')
|
||||
const results = ref([])
|
||||
const selected = ref(null)
|
||||
const showDropdown = ref(false)
|
||||
const loading = ref(false)
|
||||
const showNewForm = ref(false)
|
||||
const creatingClient = ref(false)
|
||||
|
||||
const newClient = reactive({
|
||||
tip_persoana: 'PF',
|
||||
denumire: '', nume: '', prenume: '',
|
||||
cod_fiscal: '', telefon: '', email: '', adresa: '',
|
||||
})
|
||||
|
||||
let searchTimeout = null
|
||||
|
||||
function displayName(c) {
|
||||
if (c.tip_persoana === 'PJ' && c.denumire) return c.denumire
|
||||
const parts = [c.nume, c.prenume].filter(Boolean)
|
||||
return parts.length > 0 ? parts.join(' ') : (c.denumire || '-')
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
if (searchTimeout) clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(async () => {
|
||||
if (query.value.length < 2) {
|
||||
results.value = []
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
results.value = await clientsStore.search(query.value)
|
||||
loading.value = false
|
||||
}, 200)
|
||||
}
|
||||
|
||||
function selectClient(c) {
|
||||
selected.value = c
|
||||
query.value = displayName(c)
|
||||
showDropdown.value = false
|
||||
showNewForm.value = false
|
||||
emit('update:modelValue', c.id)
|
||||
emit('select', c)
|
||||
}
|
||||
|
||||
function clear() {
|
||||
selected.value = null
|
||||
query.value = ''
|
||||
emit('update:modelValue', null)
|
||||
emit('select', null)
|
||||
}
|
||||
|
||||
async function handleCreateClient() {
|
||||
creatingClient.value = true
|
||||
try {
|
||||
const row = await clientsStore.create({ ...newClient })
|
||||
selectClient(row)
|
||||
showNewForm.value = false
|
||||
Object.assign(newClient, {
|
||||
tip_persoana: 'PF', denumire: '', nume: '', prenume: '',
|
||||
cod_fiscal: '', telefon: '', email: '', adresa: '',
|
||||
})
|
||||
} finally {
|
||||
creatingClient.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Load initial client if modelValue is set
|
||||
watch(() => props.modelValue, async (id) => {
|
||||
if (id && !selected.value) {
|
||||
const c = await clientsStore.getById(id)
|
||||
if (c) {
|
||||
selected.value = c
|
||||
query.value = displayName(c)
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
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>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user