feat(backend): sync endpoints + all models + seed + order workflow

- All business models: Vehicle, Order, OrderLine, Invoice, Appointment,
  CatalogMarca/Model/Ansamblu/Norma/Pret/TipDeviz/TipMotor, Mecanic
- Sync endpoints: GET /sync/full, GET /sync/changes?since=, POST /sync/push
  with tenant isolation and last-write-wins conflict resolution
- Order CRUD with state machine: DRAFT -> VALIDAT -> FACTURAT
  Auto-recalculates totals (manopera + materiale)
- Vehicle CRUD: list, create, get, update
- Seed data: 24 marci, 11 ansamble, 6 tipuri deviz, 5 tipuri motoare, 3 preturi
- Alembic migration for all business models
- 13 passing tests (auth + sync + orders)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 17:31:02 +02:00
parent ad41956ea1
commit 3a922a50e6
25 changed files with 1410 additions and 1 deletions

View File

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

View File

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

View File

@@ -0,0 +1,11 @@
from sqlalchemy import 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] = mapped_column(String(36))
data: Mapped[str] = mapped_column(Text)
descriere: Mapped[str | None] = mapped_column(Text)

View File

@@ -0,0 +1,44 @@
from sqlalchemy import Float, 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"
nume: Mapped[str] = mapped_column(String(100))
class CatalogModel(Base, UUIDMixin, TimestampMixin):
__tablename__ = "catalog_modele"
marca_id: Mapped[str] = mapped_column(String(36), index=True)
nume: Mapped[str] = mapped_column(String(100))
class CatalogAnsamblu(Base, UUIDMixin, TenantMixin, TimestampMixin):
__tablename__ = "catalog_ansamble"
nume: Mapped[str] = mapped_column(String(100))
class CatalogNorma(Base, UUIDMixin, TenantMixin, TimestampMixin):
__tablename__ = "catalog_norme"
ansamblu_id: Mapped[str] = mapped_column(String(36), index=True)
descriere: Mapped[str] = mapped_column(Text)
ore: Mapped[float] = mapped_column(Float, default=0)
class CatalogPret(Base, UUIDMixin, TenantMixin, TimestampMixin):
__tablename__ = "catalog_preturi"
denumire: Mapped[str] = mapped_column(String(200))
pret: Mapped[float] = mapped_column(Float, default=0)
um: Mapped[str] = mapped_column(String(10))
class CatalogTipDeviz(Base, UUIDMixin, TenantMixin, TimestampMixin):
__tablename__ = "catalog_tipuri_deviz"
nume: Mapped[str] = mapped_column(String(100))
class CatalogTipMotor(Base, UUIDMixin, TenantMixin, TimestampMixin):
__tablename__ = "catalog_tipuri_motoare"
nume: Mapped[str] = mapped_column(String(50))

View File

@@ -0,0 +1,13 @@
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] = mapped_column(String(36), index=True)
nr_factura: Mapped[str] = mapped_column(String(50))
data_factura: Mapped[str | None] = mapped_column(Text)
total: Mapped[float] = mapped_column(Float, default=0)
status: Mapped[str] = mapped_column(String(20), default="EMISA")

View File

@@ -0,0 +1,10 @@
from sqlalchemy import 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"
nume: Mapped[str] = mapped_column(String(200))
telefon: Mapped[str | None] = mapped_column(String(20))

View File

@@ -0,0 +1,19 @@
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"
vehicle_id: Mapped[str] = mapped_column(String(36))
tip_deviz_id: Mapped[str | None] = mapped_column(String(36))
status: Mapped[str] = mapped_column(String(20), 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))
total_manopera: Mapped[float] = mapped_column(Float, default=0)
total_materiale: Mapped[float] = mapped_column(Float, default=0)
total_general: Mapped[float] = mapped_column(Float, default=0)
token_client: Mapped[str | None] = mapped_column(String(36))

View File

@@ -0,0 +1,17 @@
from sqlalchemy import Float, 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)
ore: Mapped[float] = mapped_column(Float, default=0)
pret_ora: Mapped[float] = mapped_column(Float, default=0)
cantitate: Mapped[float] = mapped_column(Float, default=0)
pret_unitar: Mapped[float] = mapped_column(Float, default=0)
um: Mapped[str | None] = mapped_column(String(10))
total: Mapped[float] = mapped_column(Float, default=0)

View File

@@ -0,0 +1,21 @@
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))
vin: Mapped[str | None] = mapped_column(String(17))
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)
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_cui: Mapped[str | None] = mapped_column(String(20))
client_adresa: Mapped[str | None] = mapped_column(Text)

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

@@ -0,0 +1,119 @@
from datetime import UTC, datetime
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.base import uuid7
from app.db.models.catalog import (
CatalogAnsamblu,
CatalogMarca,
CatalogPret,
CatalogTipDeviz,
CatalogTipMotor,
)
MARCI = [
"Audi",
"BMW",
"Citroen",
"Dacia",
"Fiat",
"Ford",
"Honda",
"Hyundai",
"Kia",
"Mazda",
"Mercedes-Benz",
"Mitsubishi",
"Nissan",
"Opel",
"Peugeot",
"Renault",
"Seat",
"Skoda",
"Suzuki",
"Toyota",
"Volkswagen",
"Volvo",
"Alfa Romeo",
"Jeep",
]
ANSAMBLE = [
"Motor",
"Cutie de viteze",
"Frane",
"Directie",
"Suspensie",
"Climatizare",
"Electrica",
"Caroserie",
"Esapament",
"Transmisie",
"Revizie",
]
TIPURI_DEVIZ = [
"Deviz reparatie",
"Deviz revizie",
"Deviz diagnosticare",
"Deviz estimativ",
"Deviz vulcanizare",
"Deviz ITP",
]
TIPURI_MOTOARE = ["Benzina", "Diesel", "Hibrid", "Electric", "GPL"]
PRETURI = [
{"denumire": "Manopera standard", "pret": 150.0, "um": "ora"},
{"denumire": "Revizie ulei + filtru", "pret": 250.0, "um": "buc"},
{"denumire": "Diagnosticare", "pret": 100.0, "um": "buc"},
]
async def seed_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, nume=name)
)
counts["marci"] = len(MARCI)
# Ansamble
for name in ANSAMBLE:
db.add(
CatalogAnsamblu(id=uuid7(), tenant_id=tenant_id, nume=name)
)
counts["ansamble"] = len(ANSAMBLE)
# Tipuri deviz
for name in TIPURI_DEVIZ:
db.add(
CatalogTipDeviz(id=uuid7(), tenant_id=tenant_id, nume=name)
)
counts["tipuri_deviz"] = len(TIPURI_DEVIZ)
# Tipuri motoare
for name in TIPURI_MOTOARE:
db.add(
CatalogTipMotor(id=uuid7(), tenant_id=tenant_id, nume=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

View File

@@ -7,6 +7,9 @@ from app.auth.router import router as auth_router
from app.config import settings from app.config import settings
from app.db.base import Base from app.db.base import Base
from app.db.session import engine from app.db.session import engine
from app.orders.router import router as orders_router
from app.sync.router import router as sync_router
from app.vehicles.router import router as vehicles_router
# Import models so Base.metadata knows about them # Import models so Base.metadata knows about them
import app.db.models # noqa: F401 import app.db.models # noqa: F401
@@ -28,6 +31,9 @@ app.add_middleware(
allow_credentials=True, allow_credentials=True,
) )
app.include_router(auth_router, prefix="/api/auth") app.include_router(auth_router, prefix="/api/auth")
app.include_router(sync_router, prefix="/api/sync")
app.include_router(orders_router, prefix="/api/orders")
app.include_router(vehicles_router, prefix="/api/vehicles")
@app.get("/api/health") @app.get("/api/health")

View File

View File

@@ -0,0 +1,83 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import get_db
from app.deps import get_tenant_id
from app.orders import schemas, service
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))

View File

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

View File

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

View File

View File

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

View File

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

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

@@ -0,0 +1,110 @@
from datetime import UTC, datetime
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
SYNCABLE_TABLES = [
"vehicles",
"orders",
"order_lines",
"invoices",
"appointments",
"catalog_marci",
"catalog_modele",
"catalog_ansamble",
"catalog_norme",
"catalog_preturi",
"catalog_tipuri_deviz",
"catalog_tipuri_motoare",
"mecanici",
]
# Tables that don't have tenant_id directly
NO_TENANT_TABLES = {"catalog_modele"}
async def get_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
for op in operations:
table = op["table"]
if table not in SYNCABLE_TABLES:
continue
data = 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
if op["operation"] in ("INSERT", "UPDATE"):
cols = ", ".join(data.keys())
ph = ", ".join(f":{k}" for k in data.keys())
await db.execute(
text(f"INSERT OR REPLACE INTO {table} ({cols}) VALUES ({ph})"),
data,
)
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
await db.commit()
return {"applied": applied, "conflicts": []}

View File

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import pytest import pytest
import pytest_asyncio import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.db.base import Base from app.db.base import Base
@@ -22,3 +23,40 @@ async def setup_test_db():
yield yield
app.dependency_overrides.clear() app.dependency_overrides.clear()
await engine.dispose() 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"]

View File

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

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

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