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

@@ -1,4 +1,35 @@
from app.db.models.tenant import Tenant
from app.db.models.user import User
from app.db.models.vehicle import Vehicle
from app.db.models.order import Order
from app.db.models.order_line import OrderLine
from app.db.models.catalog import (
CatalogMarca,
CatalogModel,
CatalogAnsamblu,
CatalogNorma,
CatalogPret,
CatalogTipDeviz,
CatalogTipMotor,
)
from app.db.models.invoice import Invoice
from app.db.models.appointment import Appointment
from app.db.models.mecanic import Mecanic
__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