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:
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 ###
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
|
|||||||
11
backend/app/db/models/appointment.py
Normal file
11
backend/app/db/models/appointment.py
Normal 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)
|
||||||
44
backend/app/db/models/catalog.py
Normal file
44
backend/app/db/models/catalog.py
Normal 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))
|
||||||
13
backend/app/db/models/invoice.py
Normal file
13
backend/app/db/models/invoice.py
Normal 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")
|
||||||
10
backend/app/db/models/mecanic.py
Normal file
10
backend/app/db/models/mecanic.py
Normal 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))
|
||||||
19
backend/app/db/models/order.py
Normal file
19
backend/app/db/models/order.py
Normal 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))
|
||||||
17
backend/app/db/models/order_line.py
Normal file
17
backend/app/db/models/order_line.py
Normal 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)
|
||||||
21
backend/app/db/models/vehicle.py
Normal file
21
backend/app/db/models/vehicle.py
Normal 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
119
backend/app/db/seed.py
Normal 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
|
||||||
@@ -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")
|
||||||
|
|||||||
0
backend/app/orders/__init__.py
Normal file
0
backend/app/orders/__init__.py
Normal file
83
backend/app/orders/router.py
Normal file
83
backend/app/orders/router.py
Normal 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))
|
||||||
18
backend/app/orders/schemas.py
Normal file
18
backend/app/orders/schemas.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class CreateOrderRequest(BaseModel):
|
||||||
|
vehicle_id: str
|
||||||
|
tip_deviz_id: str | None = None
|
||||||
|
km_intrare: int | None = None
|
||||||
|
observatii: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AddLineRequest(BaseModel):
|
||||||
|
tip: str # manopera | material
|
||||||
|
descriere: str
|
||||||
|
ore: float = 0
|
||||||
|
pret_ora: float = 0
|
||||||
|
cantitate: float = 0
|
||||||
|
pret_unitar: float = 0
|
||||||
|
um: str | None = None
|
||||||
188
backend/app/orders/service.py
Normal file
188
backend/app/orders/service.py
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import select, text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.db.base import uuid7
|
||||||
|
from app.db.models.order import Order
|
||||||
|
from app.db.models.order_line import OrderLine
|
||||||
|
|
||||||
|
TRANSITIONS = {"DRAFT": ["VALIDAT"], "VALIDAT": ["FACTURAT"]}
|
||||||
|
|
||||||
|
|
||||||
|
async def create_order(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: str,
|
||||||
|
vehicle_id: str,
|
||||||
|
tip_deviz_id: str | None = None,
|
||||||
|
km_intrare: int | None = None,
|
||||||
|
observatii: str | None = None,
|
||||||
|
) -> Order:
|
||||||
|
now = datetime.now(UTC).isoformat()
|
||||||
|
order = Order(
|
||||||
|
id=uuid7(),
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
vehicle_id=vehicle_id,
|
||||||
|
tip_deviz_id=tip_deviz_id,
|
||||||
|
status="DRAFT",
|
||||||
|
data_comanda=now.split("T")[0],
|
||||||
|
km_intrare=km_intrare,
|
||||||
|
observatii=observatii,
|
||||||
|
total_manopera=0,
|
||||||
|
total_materiale=0,
|
||||||
|
total_general=0,
|
||||||
|
token_client=uuid7(),
|
||||||
|
)
|
||||||
|
db.add(order)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(order)
|
||||||
|
return order
|
||||||
|
|
||||||
|
|
||||||
|
async def add_line(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: str,
|
||||||
|
order_id: str,
|
||||||
|
tip: str,
|
||||||
|
descriere: str,
|
||||||
|
ore: float = 0,
|
||||||
|
pret_ora: float = 0,
|
||||||
|
cantitate: float = 0,
|
||||||
|
pret_unitar: float = 0,
|
||||||
|
um: str | None = None,
|
||||||
|
) -> OrderLine:
|
||||||
|
# Check order exists and belongs to tenant
|
||||||
|
r = await db.execute(
|
||||||
|
select(Order).where(Order.id == order_id, Order.tenant_id == tenant_id)
|
||||||
|
)
|
||||||
|
order = r.scalar_one_or_none()
|
||||||
|
if not order:
|
||||||
|
raise ValueError("Order not found")
|
||||||
|
if order.status != "DRAFT":
|
||||||
|
raise ValueError("Cannot add lines to non-DRAFT order")
|
||||||
|
|
||||||
|
if tip == "manopera":
|
||||||
|
total = ore * pret_ora
|
||||||
|
else:
|
||||||
|
total = cantitate * pret_unitar
|
||||||
|
|
||||||
|
line = OrderLine(
|
||||||
|
id=uuid7(),
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
order_id=order_id,
|
||||||
|
tip=tip,
|
||||||
|
descriere=descriere,
|
||||||
|
ore=ore,
|
||||||
|
pret_ora=pret_ora,
|
||||||
|
cantitate=cantitate,
|
||||||
|
pret_unitar=pret_unitar,
|
||||||
|
um=um,
|
||||||
|
total=total,
|
||||||
|
)
|
||||||
|
db.add(line)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
await recalc_totals(db, order_id)
|
||||||
|
return line
|
||||||
|
|
||||||
|
|
||||||
|
async def recalc_totals(db: AsyncSession, order_id: str):
|
||||||
|
lines = await db.execute(
|
||||||
|
text(
|
||||||
|
"SELECT tip, COALESCE(SUM(total), 0) as sub FROM order_lines "
|
||||||
|
"WHERE order_id = :oid GROUP BY tip"
|
||||||
|
),
|
||||||
|
{"oid": order_id},
|
||||||
|
)
|
||||||
|
totals = {r.tip: r.sub for r in lines}
|
||||||
|
manopera = totals.get("manopera", 0)
|
||||||
|
materiale = totals.get("material", 0)
|
||||||
|
await db.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE orders SET total_manopera=:m, total_materiale=:mat, "
|
||||||
|
"total_general=:g, updated_at=:u WHERE id=:id"
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"m": manopera,
|
||||||
|
"mat": materiale,
|
||||||
|
"g": manopera + materiale,
|
||||||
|
"u": datetime.now(UTC).isoformat(),
|
||||||
|
"id": order_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_order(
|
||||||
|
db: AsyncSession, tenant_id: str, order_id: str
|
||||||
|
) -> Order:
|
||||||
|
r = await db.execute(
|
||||||
|
select(Order).where(Order.id == order_id, Order.tenant_id == tenant_id)
|
||||||
|
)
|
||||||
|
order = r.scalar_one_or_none()
|
||||||
|
if not order:
|
||||||
|
raise ValueError("Order not found")
|
||||||
|
if "VALIDAT" not in TRANSITIONS.get(order.status, []):
|
||||||
|
raise ValueError(f"Cannot transition from {order.status} to VALIDAT")
|
||||||
|
order.status = "VALIDAT"
|
||||||
|
order.updated_at = datetime.now(UTC).isoformat()
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(order)
|
||||||
|
return order
|
||||||
|
|
||||||
|
|
||||||
|
async def get_order(
|
||||||
|
db: AsyncSession, tenant_id: str, order_id: str
|
||||||
|
) -> dict | None:
|
||||||
|
r = await db.execute(
|
||||||
|
select(Order).where(Order.id == order_id, Order.tenant_id == tenant_id)
|
||||||
|
)
|
||||||
|
order = r.scalar_one_or_none()
|
||||||
|
if not order:
|
||||||
|
return None
|
||||||
|
r = await db.execute(
|
||||||
|
select(OrderLine).where(OrderLine.order_id == order_id)
|
||||||
|
)
|
||||||
|
lines = r.scalars().all()
|
||||||
|
return {
|
||||||
|
"id": order.id,
|
||||||
|
"vehicle_id": order.vehicle_id,
|
||||||
|
"status": order.status,
|
||||||
|
"data_comanda": order.data_comanda,
|
||||||
|
"km_intrare": order.km_intrare,
|
||||||
|
"observatii": order.observatii,
|
||||||
|
"total_manopera": order.total_manopera,
|
||||||
|
"total_materiale": order.total_materiale,
|
||||||
|
"total_general": order.total_general,
|
||||||
|
"token_client": order.token_client,
|
||||||
|
"lines": [
|
||||||
|
{
|
||||||
|
"id": l.id,
|
||||||
|
"tip": l.tip,
|
||||||
|
"descriere": l.descriere,
|
||||||
|
"ore": l.ore,
|
||||||
|
"pret_ora": l.pret_ora,
|
||||||
|
"cantitate": l.cantitate,
|
||||||
|
"pret_unitar": l.pret_unitar,
|
||||||
|
"um": l.um,
|
||||||
|
"total": l.total,
|
||||||
|
}
|
||||||
|
for l in lines
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def list_orders(db: AsyncSession, tenant_id: str) -> list:
|
||||||
|
r = await db.execute(
|
||||||
|
select(Order).where(Order.tenant_id == tenant_id)
|
||||||
|
)
|
||||||
|
orders = r.scalars().all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": o.id,
|
||||||
|
"status": o.status,
|
||||||
|
"vehicle_id": o.vehicle_id,
|
||||||
|
"total_general": o.total_general,
|
||||||
|
}
|
||||||
|
for o in orders
|
||||||
|
]
|
||||||
0
backend/app/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 = []
|
||||||
110
backend/app/sync/service.py
Normal file
110
backend/app/sync/service.py
Normal 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": []}
|
||||||
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
|
||||||
@@ -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"]
|
||||||
|
|||||||
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
|
||||||
128
backend/tests/test_sync.py
Normal file
128
backend/tests/test_sync.py
Normal 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
|
||||||
Reference in New Issue
Block a user