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
|
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)
|
catalog_tipuri_motoare (id, tenant_id, denumire)
|
||||||
mecanici (id, tenant_id, user_id, nume, prenume, activ)
|
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
|
-- 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,
|
client_cod_fiscal, client_adresa, nr_inmatriculare,
|
||||||
marca_id, model_id, an_fabricatie, serie_sasiu,
|
marca_id, model_id, an_fabricatie, serie_sasiu,
|
||||||
tip_motor_id, created_at, updated_at)
|
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,
|
tip_deviz_id, status, km_intrare, observatii,
|
||||||
-- client snapshot (denormalized)
|
-- client snapshot (denormalized)
|
||||||
client_nume, client_telefon, nr_auto, marca_denumire, model_denumire,
|
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
|
um, cantitate, pret_unitar, -- material
|
||||||
total, mecanic_id, ordine, created_at, updated_at)
|
total, mecanic_id, ordine, created_at, updated_at)
|
||||||
|
|
||||||
invoices (id, tenant_id, order_id, nr_factura, serie_factura,
|
invoices (id, tenant_id, order_id, client_id, nr_factura, serie_factura,
|
||||||
data_factura, modalitate_plata,
|
data_factura, tip_document, modalitate_plata,
|
||||||
client_nume, client_cod_fiscal, nr_auto,
|
client_nume, client_cod_fiscal, nr_auto,
|
||||||
total_fara_tva, tva, total_general, created_at, updated_at)
|
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)
|
4. Responsive testing (phone, tablet, desktop)
|
||||||
5. Reports: sumar lunar, export CSV
|
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)
|
## 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=manopera) | `dev_oper` | SaaS unifica oper+materiale in order_lines |
|
||||||
| `order_lines` (tip=material) | `dev_estimari_produse` | Acelasi tabel, filtrat pe `tip` |
|
| `order_lines` (tip=material) | `dev_estimari_produse` | Acelasi tabel, filtrat pe `tip` |
|
||||||
| `vehicles` | `dev_masiniclienti` | Renamed, aceleasi coloane client+vehicul |
|
| `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_marci` | `dev_nom_marci` | +tenant_id |
|
||||||
| `catalog_modele` | `dev_nom_masini` | Identic |
|
| `catalog_modele` | `dev_nom_masini` | Identic |
|
||||||
| `catalog_ansamble` | `dev_nom_ansamble` | +tenant_id |
|
| `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 |
|
| `catalog_tipuri_motoare` | `dev_tipuri_motoare` | +tenant_id |
|
||||||
| `mecanici` | `dev_mecanici` | +tenant_id, +user_id |
|
| `mecanici` | `dev_mecanici` | +tenant_id, +user_id |
|
||||||
| `invoices` | `facturi` (local) | Identic structural |
|
| `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) |
|
| `tenants` | - | Doar SaaS (nu exista in Oracle) |
|
||||||
| `users` | - | Doar SaaS |
|
| `users` | - | Doar SaaS |
|
||||||
| `appointments` | - | Doar SaaS (feature nou) |
|
| `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