From 3a922a50e6e5d4479bc3cadb72ee36a9fcbeb7b8 Mon Sep 17 00:00:00 2001 From: Marius Mutu Date: Fri, 13 Mar 2026 17:31:02 +0200 Subject: [PATCH] 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 --- .../fbbfad4cd8f3_all_business_models.py | 218 ++++++++++++++++++ backend/app/db/models/__init__.py | 33 ++- backend/app/db/models/appointment.py | 11 + backend/app/db/models/catalog.py | 44 ++++ backend/app/db/models/invoice.py | 13 ++ backend/app/db/models/mecanic.py | 10 + backend/app/db/models/order.py | 19 ++ backend/app/db/models/order_line.py | 17 ++ backend/app/db/models/vehicle.py | 21 ++ backend/app/db/seed.py | 119 ++++++++++ backend/app/main.py | 6 + backend/app/orders/__init__.py | 0 backend/app/orders/router.py | 83 +++++++ backend/app/orders/schemas.py | 18 ++ backend/app/orders/service.py | 188 +++++++++++++++ backend/app/sync/__init__.py | 0 backend/app/sync/router.py | 41 ++++ backend/app/sync/schemas.py | 18 ++ backend/app/sync/service.py | 110 +++++++++ backend/app/vehicles/__init__.py | 0 backend/app/vehicles/router.py | 110 +++++++++ backend/app/vehicles/schemas.py | 21 ++ backend/tests/conftest.py | 38 +++ backend/tests/test_orders.py | 145 ++++++++++++ backend/tests/test_sync.py | 128 ++++++++++ 25 files changed, 1410 insertions(+), 1 deletion(-) create mode 100644 backend/alembic/versions/fbbfad4cd8f3_all_business_models.py create mode 100644 backend/app/db/models/appointment.py create mode 100644 backend/app/db/models/catalog.py create mode 100644 backend/app/db/models/invoice.py create mode 100644 backend/app/db/models/mecanic.py create mode 100644 backend/app/db/models/order.py create mode 100644 backend/app/db/models/order_line.py create mode 100644 backend/app/db/models/vehicle.py create mode 100644 backend/app/db/seed.py create mode 100644 backend/app/orders/__init__.py create mode 100644 backend/app/orders/router.py create mode 100644 backend/app/orders/schemas.py create mode 100644 backend/app/orders/service.py create mode 100644 backend/app/sync/__init__.py create mode 100644 backend/app/sync/router.py create mode 100644 backend/app/sync/schemas.py create mode 100644 backend/app/sync/service.py create mode 100644 backend/app/vehicles/__init__.py create mode 100644 backend/app/vehicles/router.py create mode 100644 backend/app/vehicles/schemas.py create mode 100644 backend/tests/test_orders.py create mode 100644 backend/tests/test_sync.py diff --git a/backend/alembic/versions/fbbfad4cd8f3_all_business_models.py b/backend/alembic/versions/fbbfad4cd8f3_all_business_models.py new file mode 100644 index 0000000..23ed17a --- /dev/null +++ b/backend/alembic/versions/fbbfad4cd8f3_all_business_models.py @@ -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 ### diff --git a/backend/app/db/models/__init__.py b/backend/app/db/models/__init__.py index cf90ceb..9146f62 100644 --- a/backend/app/db/models/__init__.py +++ b/backend/app/db/models/__init__.py @@ -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", +] diff --git a/backend/app/db/models/appointment.py b/backend/app/db/models/appointment.py new file mode 100644 index 0000000..61d55e1 --- /dev/null +++ b/backend/app/db/models/appointment.py @@ -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) diff --git a/backend/app/db/models/catalog.py b/backend/app/db/models/catalog.py new file mode 100644 index 0000000..f810ca0 --- /dev/null +++ b/backend/app/db/models/catalog.py @@ -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)) diff --git a/backend/app/db/models/invoice.py b/backend/app/db/models/invoice.py new file mode 100644 index 0000000..badf922 --- /dev/null +++ b/backend/app/db/models/invoice.py @@ -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") diff --git a/backend/app/db/models/mecanic.py b/backend/app/db/models/mecanic.py new file mode 100644 index 0000000..36820fc --- /dev/null +++ b/backend/app/db/models/mecanic.py @@ -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)) diff --git a/backend/app/db/models/order.py b/backend/app/db/models/order.py new file mode 100644 index 0000000..79ac23b --- /dev/null +++ b/backend/app/db/models/order.py @@ -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)) diff --git a/backend/app/db/models/order_line.py b/backend/app/db/models/order_line.py new file mode 100644 index 0000000..7ceec8f --- /dev/null +++ b/backend/app/db/models/order_line.py @@ -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) diff --git a/backend/app/db/models/vehicle.py b/backend/app/db/models/vehicle.py new file mode 100644 index 0000000..e35b789 --- /dev/null +++ b/backend/app/db/models/vehicle.py @@ -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) diff --git a/backend/app/db/seed.py b/backend/app/db/seed.py new file mode 100644 index 0000000..1063478 --- /dev/null +++ b/backend/app/db/seed.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py index 1c9fb07..35b9f7a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,6 +7,9 @@ from app.auth.router import router as auth_router from app.config import settings from app.db.base import Base 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 app.db.models # noqa: F401 @@ -28,6 +31,9 @@ app.add_middleware( allow_credentials=True, ) 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") diff --git a/backend/app/orders/__init__.py b/backend/app/orders/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/orders/router.py b/backend/app/orders/router.py new file mode 100644 index 0000000..156d53d --- /dev/null +++ b/backend/app/orders/router.py @@ -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)) diff --git a/backend/app/orders/schemas.py b/backend/app/orders/schemas.py new file mode 100644 index 0000000..fa861d2 --- /dev/null +++ b/backend/app/orders/schemas.py @@ -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 diff --git a/backend/app/orders/service.py b/backend/app/orders/service.py new file mode 100644 index 0000000..ef57775 --- /dev/null +++ b/backend/app/orders/service.py @@ -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 + ] diff --git a/backend/app/sync/__init__.py b/backend/app/sync/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/sync/router.py b/backend/app/sync/router.py new file mode 100644 index 0000000..592bdcf --- /dev/null +++ b/backend/app/sync/router.py @@ -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 diff --git a/backend/app/sync/schemas.py b/backend/app/sync/schemas.py new file mode 100644 index 0000000..23a1be8 --- /dev/null +++ b/backend/app/sync/schemas.py @@ -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 = [] diff --git a/backend/app/sync/service.py b/backend/app/sync/service.py new file mode 100644 index 0000000..6a033b3 --- /dev/null +++ b/backend/app/sync/service.py @@ -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": []} diff --git a/backend/app/vehicles/__init__.py b/backend/app/vehicles/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/vehicles/router.py b/backend/app/vehicles/router.py new file mode 100644 index 0000000..1403468 --- /dev/null +++ b/backend/app/vehicles/router.py @@ -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} diff --git a/backend/app/vehicles/schemas.py b/backend/app/vehicles/schemas.py new file mode 100644 index 0000000..c0192c5 --- /dev/null +++ b/backend/app/vehicles/schemas.py @@ -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 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index c6166d9..c03be69 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,5 +1,6 @@ import pytest import pytest_asyncio +from httpx import ASGITransport, AsyncClient from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from app.db.base import Base @@ -22,3 +23,40 @@ async def setup_test_db(): yield app.dependency_overrides.clear() await engine.dispose() + + +@pytest_asyncio.fixture +async def client(): + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as c: + yield c + + +@pytest_asyncio.fixture +async def auth_headers(client): + r = await client.post( + "/api/auth/register", + json={ + "email": "test@service.ro", + "password": "testpass123", + "tenant_name": "Test Service", + "telefon": "0722000000", + }, + ) + token = r.json()["access_token"] + return {"Authorization": f"Bearer {token}"} + + +@pytest_asyncio.fixture +async def tenant_id(client): + r = await client.post( + "/api/auth/register", + json={ + "email": "tenant@service.ro", + "password": "testpass123", + "tenant_name": "Tenant Service", + "telefon": "0722000001", + }, + ) + return r.json()["tenant_id"] diff --git a/backend/tests/test_orders.py b/backend/tests/test_orders.py new file mode 100644 index 0000000..45886cc --- /dev/null +++ b/backend/tests/test_orders.py @@ -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 diff --git a/backend/tests/test_sync.py b/backend/tests/test_sync.py new file mode 100644 index 0000000..5b0e1a0 --- /dev/null +++ b/backend/tests/test_sync.py @@ -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