From 1e96db4d91f2abc831fc6f1c188307d1d44aa9c8 Mon Sep 17 00:00:00 2001 From: Marius Mutu Date: Fri, 13 Mar 2026 18:45:31 +0200 Subject: [PATCH] fix(backend): sync push error handling + validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - apply_push now uses PRAGMA table_info() to get valid column names per table and filters incoming data to only known columns, preventing "no such column" SQLite errors from frontend-only fields like oracle_id - Wrap each operation in try/except so one bad op doesn't abort the whole batch; errors are returned in the conflicts list instead of 500 - Add server_default to all NOT NULL float/int columns so raw SQL INSERT OR REPLACE without those fields still succeeds - Align DB models with frontend schema.js: - orders: add nr_comanda, client_nume, client_telefon, nr_auto, marca_denumire, model_denumire, created_by - order_lines: add norma_id, mecanic_id, ordine - vehicles: add serie_sasiu, client_cod_fiscal (keep vin, client_cui for REST API compat) - catalog_*: rename nume → denumire to match frontend schema - catalog_norme: align fields (cod, denumire, ore_normate) - invoices: add serie_factura, modalitate_plata, client_cod_fiscal, nr_auto, total_fara_tva, tva, total_general; keep total for compat - appointments: add client_nume, client_telefon, data_ora, durata_minute, status, order_id - mecanici: add user_id, prenume, activ - Fix seed.py to use denumire= instead of nome= after catalog rename - Add 3 new sync push tests covering order insert with frontend fields, unknown column filtering, and order_line with norma_id/mecanic_id/ordine Co-Authored-By: Claude Opus 4.6 --- backend/app/db/models/appointment.py | 13 +- backend/app/db/models/catalog.py | 22 +-- backend/app/db/models/invoice.py | 17 ++- backend/app/db/models/mecanic.py | 6 +- backend/app/db/models/order.py | 21 ++- backend/app/db/models/order_line.py | 15 +- backend/app/db/models/vehicle.py | 6 +- backend/app/db/seed.py | 8 +- backend/app/sync/service.py | 69 ++++++--- backend/tests/test_sync.py | 202 +++++++++++++++++++++++++++ 10 files changed, 321 insertions(+), 58 deletions(-) diff --git a/backend/app/db/models/appointment.py b/backend/app/db/models/appointment.py index 61d55e1..3c74890 100644 --- a/backend/app/db/models/appointment.py +++ b/backend/app/db/models/appointment.py @@ -1,4 +1,4 @@ -from sqlalchemy import String, Text +from sqlalchemy import Integer, String, Text from sqlalchemy.orm import Mapped, mapped_column from app.db.base import Base, UUIDMixin, TenantMixin, TimestampMixin @@ -6,6 +6,11 @@ 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) + vehicle_id: Mapped[str | None] = mapped_column(String(36)) + client_nume: Mapped[str | None] = mapped_column(String(200)) + client_telefon: Mapped[str | None] = mapped_column(String(20)) + data_ora: Mapped[str | None] = mapped_column(Text) + durata_minute: Mapped[int] = mapped_column(Integer, default=60, server_default="60") + observatii: Mapped[str | None] = mapped_column(Text) + status: Mapped[str] = mapped_column(String(20), default="PROGRAMAT", server_default="PROGRAMAT") + order_id: Mapped[str | None] = mapped_column(String(36)) diff --git a/backend/app/db/models/catalog.py b/backend/app/db/models/catalog.py index f810ca0..ad1ddd1 100644 --- a/backend/app/db/models/catalog.py +++ b/backend/app/db/models/catalog.py @@ -1,4 +1,4 @@ -from sqlalchemy import Float, String, Text +from sqlalchemy import Float, Integer, String, Text from sqlalchemy.orm import Mapped, mapped_column from app.db.base import Base, UUIDMixin, TenantMixin, TimestampMixin @@ -6,39 +6,41 @@ 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)) + denumire: Mapped[str] = mapped_column(String(100)) + activ: Mapped[int] = mapped_column(Integer, default=1, server_default="1") class CatalogModel(Base, UUIDMixin, TimestampMixin): __tablename__ = "catalog_modele" marca_id: Mapped[str] = mapped_column(String(36), index=True) - nume: Mapped[str] = mapped_column(String(100)) + denumire: Mapped[str] = mapped_column(String(100)) class CatalogAnsamblu(Base, UUIDMixin, TenantMixin, TimestampMixin): __tablename__ = "catalog_ansamble" - nume: Mapped[str] = mapped_column(String(100)) + denumire: 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) + cod: Mapped[str | None] = mapped_column(String(50)) + denumire: Mapped[str] = mapped_column(Text) + ore_normate: Mapped[float] = mapped_column(Float, default=0, server_default="0") + ansamblu_id: Mapped[str | None] = mapped_column(String(36), index=True) class CatalogPret(Base, UUIDMixin, TenantMixin, TimestampMixin): __tablename__ = "catalog_preturi" denumire: Mapped[str] = mapped_column(String(200)) - pret: Mapped[float] = mapped_column(Float, default=0) + pret: Mapped[float] = mapped_column(Float, default=0, server_default="0") um: Mapped[str] = mapped_column(String(10)) class CatalogTipDeviz(Base, UUIDMixin, TenantMixin, TimestampMixin): __tablename__ = "catalog_tipuri_deviz" - nume: Mapped[str] = mapped_column(String(100)) + denumire: Mapped[str] = mapped_column(String(100)) class CatalogTipMotor(Base, UUIDMixin, TenantMixin, TimestampMixin): __tablename__ = "catalog_tipuri_motoare" - nume: Mapped[str] = mapped_column(String(50)) + denumire: Mapped[str] = mapped_column(String(50)) diff --git a/backend/app/db/models/invoice.py b/backend/app/db/models/invoice.py index badf922..de7d472 100644 --- a/backend/app/db/models/invoice.py +++ b/backend/app/db/models/invoice.py @@ -6,8 +6,17 @@ 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)) + order_id: Mapped[str | None] = mapped_column(String(36), index=True) + nr_factura: Mapped[str | None] = mapped_column(String(50)) + serie_factura: Mapped[str | None] = mapped_column(String(20)) data_factura: Mapped[str | None] = mapped_column(Text) - total: Mapped[float] = mapped_column(Float, default=0) - status: Mapped[str] = mapped_column(String(20), default="EMISA") + modalitate_plata: Mapped[str | None] = mapped_column(String(50)) + client_nume: Mapped[str | None] = mapped_column(String(200)) + client_cod_fiscal: Mapped[str | None] = mapped_column(String(20)) + nr_auto: Mapped[str | None] = mapped_column(String(20)) + total_fara_tva: Mapped[float] = mapped_column(Float, default=0, server_default="0") + tva: Mapped[float] = mapped_column(Float, default=0, server_default="0") + total_general: Mapped[float] = mapped_column(Float, default=0, server_default="0") + # Legacy field kept for REST API service compatibility + total: Mapped[float] = mapped_column(Float, default=0, server_default="0") + status: Mapped[str] = mapped_column(String(20), default="EMISA", server_default="EMISA") diff --git a/backend/app/db/models/mecanic.py b/backend/app/db/models/mecanic.py index 36820fc..fea05d7 100644 --- a/backend/app/db/models/mecanic.py +++ b/backend/app/db/models/mecanic.py @@ -1,4 +1,4 @@ -from sqlalchemy import String +from sqlalchemy import Integer, String from sqlalchemy.orm import Mapped, mapped_column from app.db.base import Base, UUIDMixin, TenantMixin, TimestampMixin @@ -6,5 +6,7 @@ from app.db.base import Base, UUIDMixin, TenantMixin, TimestampMixin class Mecanic(Base, UUIDMixin, TenantMixin, TimestampMixin): __tablename__ = "mecanici" + user_id: Mapped[str | None] = mapped_column(String(36)) nume: Mapped[str] = mapped_column(String(200)) - telefon: Mapped[str | None] = mapped_column(String(20)) + prenume: Mapped[str | None] = mapped_column(String(200)) + activ: Mapped[int] = mapped_column(Integer, default=1, server_default="1") diff --git a/backend/app/db/models/order.py b/backend/app/db/models/order.py index c88f534..11cf8df 100644 --- a/backend/app/db/models/order.py +++ b/backend/app/db/models/order.py @@ -6,15 +6,26 @@ 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)) + nr_comanda: Mapped[str | None] = mapped_column(String(50)) + vehicle_id: Mapped[str | None] = mapped_column(String(36)) tip_deviz_id: Mapped[str | None] = mapped_column(String(36)) - status: Mapped[str] = mapped_column(String(20), default="DRAFT") + status: Mapped[str] = mapped_column(String(20), default="DRAFT", server_default="DRAFT") data_comanda: Mapped[str | None] = mapped_column(Text) km_intrare: Mapped[int | None] = mapped_column(Integer) observatii: Mapped[str | None] = mapped_column(Text) mecanic_id: Mapped[str | None] = mapped_column(String(36)) - 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) + # Denormalized client/vehicle info for quick display + client_nume: Mapped[str | None] = mapped_column(String(200)) + client_telefon: Mapped[str | None] = mapped_column(String(20)) + nr_auto: Mapped[str | None] = mapped_column(String(20)) + marca_denumire: Mapped[str | None] = mapped_column(String(100)) + model_denumire: Mapped[str | None] = mapped_column(String(100)) + # Totals — server_default ensures raw SQL INSERT without these fields still works + total_manopera: Mapped[float] = mapped_column(Float, default=0, server_default="0") + total_materiale: Mapped[float] = mapped_column(Float, default=0, server_default="0") + total_general: Mapped[float] = mapped_column(Float, default=0, server_default="0") + # Client portal token_client: Mapped[str | None] = mapped_column(String(36)) status_client: Mapped[str | None] = mapped_column(String(20)) + # Audit + created_by: Mapped[str | None] = mapped_column(String(36)) diff --git a/backend/app/db/models/order_line.py b/backend/app/db/models/order_line.py index 7ceec8f..40fe383 100644 --- a/backend/app/db/models/order_line.py +++ b/backend/app/db/models/order_line.py @@ -1,4 +1,4 @@ -from sqlalchemy import Float, String, Text +from sqlalchemy import Float, Integer, String, Text from sqlalchemy.orm import Mapped, mapped_column from app.db.base import Base, UUIDMixin, TenantMixin, TimestampMixin @@ -9,9 +9,12 @@ class OrderLine(Base, UUIDMixin, TenantMixin, TimestampMixin): 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) + norma_id: Mapped[str | None] = mapped_column(String(36)) + ore: Mapped[float] = mapped_column(Float, default=0, server_default="0") + pret_ora: Mapped[float] = mapped_column(Float, default=0, server_default="0") + cantitate: Mapped[float] = mapped_column(Float, default=0, server_default="0") + pret_unitar: Mapped[float] = mapped_column(Float, default=0, server_default="0") um: Mapped[str | None] = mapped_column(String(10)) - total: Mapped[float] = mapped_column(Float, default=0) + total: Mapped[float] = mapped_column(Float, default=0, server_default="0") + mecanic_id: Mapped[str | None] = mapped_column(String(36)) + ordine: Mapped[int | None] = mapped_column(Integer) diff --git a/backend/app/db/models/vehicle.py b/backend/app/db/models/vehicle.py index e35b789..b7a406c 100644 --- a/backend/app/db/models/vehicle.py +++ b/backend/app/db/models/vehicle.py @@ -7,15 +7,19 @@ 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) + # VIN / serie sasiu (vin kept for REST API compat, serie_sasiu for frontend sync) + vin: Mapped[str | None] = mapped_column(String(17)) + serie_sasiu: Mapped[str | None] = mapped_column(String(50)) tip_motor_id: Mapped[str | None] = mapped_column(String(36)) capacitate_motor: Mapped[str | None] = mapped_column(String(20)) putere_kw: Mapped[str | None] = mapped_column(String(20)) client_nume: Mapped[str | None] = mapped_column(String(200)) client_telefon: Mapped[str | None] = mapped_column(String(20)) client_email: Mapped[str | None] = mapped_column(String(200)) + # client_cod_fiscal used by frontend; client_cui kept for REST API compat + client_cod_fiscal: Mapped[str | None] = mapped_column(String(20)) client_cui: Mapped[str | None] = mapped_column(String(20)) client_adresa: Mapped[str | None] = mapped_column(Text) diff --git a/backend/app/db/seed.py b/backend/app/db/seed.py index 1063478..6b2c50f 100644 --- a/backend/app/db/seed.py +++ b/backend/app/db/seed.py @@ -77,28 +77,28 @@ async def seed_catalog(db: AsyncSession, tenant_id: str) -> dict: # Marci for name in MARCI: db.add( - CatalogMarca(id=uuid7(), tenant_id=tenant_id, nume=name) + CatalogMarca(id=uuid7(), tenant_id=tenant_id, denumire=name) ) counts["marci"] = len(MARCI) # Ansamble for name in ANSAMBLE: db.add( - CatalogAnsamblu(id=uuid7(), tenant_id=tenant_id, nume=name) + CatalogAnsamblu(id=uuid7(), tenant_id=tenant_id, denumire=name) ) counts["ansamble"] = len(ANSAMBLE) # Tipuri deviz for name in TIPURI_DEVIZ: db.add( - CatalogTipDeviz(id=uuid7(), tenant_id=tenant_id, nume=name) + CatalogTipDeviz(id=uuid7(), tenant_id=tenant_id, denumire=name) ) counts["tipuri_deviz"] = len(TIPURI_DEVIZ) # Tipuri motoare for name in TIPURI_MOTOARE: db.add( - CatalogTipMotor(id=uuid7(), tenant_id=tenant_id, nume=name) + CatalogTipMotor(id=uuid7(), tenant_id=tenant_id, denumire=name) ) counts["tipuri_motoare"] = len(TIPURI_MOTOARE) diff --git a/backend/app/sync/service.py b/backend/app/sync/service.py index 6a033b3..8ed493d 100644 --- a/backend/app/sync/service.py +++ b/backend/app/sync/service.py @@ -23,6 +23,12 @@ SYNCABLE_TABLES = [ NO_TENANT_TABLES = {"catalog_modele"} +async def _get_table_columns(db: AsyncSession, table: str) -> set[str]: + """Return the set of column names for a given table using PRAGMA table_info.""" + rows = await db.execute(text(f"PRAGMA table_info({table})")) + return {row[1] for row in rows} + + async def get_full(db: AsyncSession, tenant_id: str) -> dict: result = {} for table in SYNCABLE_TABLES: @@ -73,38 +79,57 @@ async def apply_push( db: AsyncSession, tenant_id: str, operations: list ) -> dict: applied = 0 + errors = [] + # Cache column sets per table to avoid repeated PRAGMA calls + table_columns_cache: dict[str, set[str]] = {} + for op in operations: table = op["table"] if table not in SYNCABLE_TABLES: continue - data = op.get("data", {}) + data = dict(op.get("data", {})) + # Enforce tenant isolation (except for no-tenant tables) if table not in NO_TENANT_TABLES: if data.get("tenant_id") and data["tenant_id"] != tenant_id: continue data["tenant_id"] = tenant_id - 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: + try: + if op["operation"] in ("INSERT", "UPDATE"): + # Fetch and cache the valid column names for this table + if table not in table_columns_cache: + table_columns_cache[table] = await _get_table_columns(db, table) + valid_cols = table_columns_cache[table] + + # Filter data to only include columns that exist in the DB table + filtered = {k: v for k, v in data.items() if k in valid_cols} + if not filtered: + continue + + cols = ", ".join(filtered.keys()) + ph = ", ".join(f":{k}" for k in filtered.keys()) await db.execute( - text(f"DELETE FROM {table} WHERE id = :id"), - {"id": op["id"]}, + text(f"INSERT OR REPLACE INTO {table} ({cols}) VALUES ({ph})"), + filtered, ) - else: - await db.execute( - text( - f"DELETE FROM {table} WHERE id = :id AND tenant_id = :tid" - ), - {"id": op["id"], "tid": tenant_id}, - ) - applied += 1 + applied += 1 + elif op["operation"] == "DELETE": + if table in NO_TENANT_TABLES: + await db.execute( + text(f"DELETE FROM {table} WHERE id = :id"), + {"id": op["id"]}, + ) + else: + await db.execute( + text( + f"DELETE FROM {table} WHERE id = :id AND tenant_id = :tid" + ), + {"id": op["id"], "tid": tenant_id}, + ) + applied += 1 + except Exception as exc: # noqa: BLE001 + errors.append({"table": table, "id": op.get("id"), "error": str(exc)}) + await db.commit() - return {"applied": applied, "conflicts": []} + return {"applied": applied, "conflicts": errors} diff --git a/backend/tests/test_sync.py b/backend/tests/test_sync.py index 5b0e1a0..7f8015b 100644 --- a/backend/tests/test_sync.py +++ b/backend/tests/test_sync.py @@ -7,6 +7,11 @@ from httpx import ASGITransport, AsyncClient from app.main import app +async def _get_tenant_id(client, auth_headers): + me = await client.get("/api/auth/me", headers=auth_headers) + return me.json()["tenant_id"] + + @pytest.mark.asyncio async def test_full_sync_returns_all_tables(client, auth_headers): r = await client.get("/api/sync/full", headers=auth_headers) @@ -126,3 +131,200 @@ async def test_sync_push_rejects_wrong_tenant(client, auth_headers): assert r.status_code == 200 # Wrong tenant_id is rejected (skipped) assert r.json()["applied"] == 0 + + +@pytest.mark.asyncio +async def test_sync_push_insert_order_with_frontend_fields(client, auth_headers): + """Frontend sends nr_comanda, client_nume, nr_auto, marca_denumire, model_denumire. + Backend must accept these without 500.""" + tenant_id = await _get_tenant_id(client, auth_headers) + + # First insert a vehicle + vid = str(uuid.uuid4()) + now = datetime.now(UTC).isoformat() + await client.post( + "/api/sync/push", + headers=auth_headers, + json={ + "operations": [ + { + "table": "vehicles", + "id": vid, + "operation": "INSERT", + "data": { + "id": vid, + "tenant_id": tenant_id, + "nr_inmatriculare": "B01TST", + "client_nume": "Ion Popescu", + "client_telefon": "0722000000", + "client_cod_fiscal": "RO12345", + "serie_sasiu": "WBA1234567890", + "created_at": now, + "updated_at": now, + }, + "timestamp": now, + } + ] + }, + ) + + # Now insert an order like the frontend does + oid = str(uuid.uuid4()) + r = await client.post( + "/api/sync/push", + headers=auth_headers, + json={ + "operations": [ + { + "table": "orders", + "id": oid, + "operation": "INSERT", + "data": { + "id": oid, + "tenant_id": tenant_id, + "nr_comanda": "CMD-ABC123", + "data_comanda": now, + "vehicle_id": vid, + "tip_deviz_id": None, + "status": "DRAFT", + "km_intrare": 50000, + "observatii": "Test order", + "client_nume": "Ion Popescu", + "client_telefon": "0722000000", + "nr_auto": "B01TST", + "marca_denumire": "Dacia", + "model_denumire": "Logan", + "created_at": now, + "updated_at": now, + }, + "timestamp": now, + } + ] + }, + ) + assert r.status_code == 200 + result = r.json() + assert result["applied"] == 1 + assert result["conflicts"] == [] + + # Verify the order appears in full sync + full = await client.get("/api/sync/full", headers=auth_headers) + orders = full.json()["tables"]["orders"] + assert any(o["id"] == oid for o in orders) + order = next(o for o in orders if o["id"] == oid) + assert order["nr_comanda"] == "CMD-ABC123" + assert order["client_nume"] == "Ion Popescu" + assert order["marca_denumire"] == "Dacia" + + +@pytest.mark.asyncio +async def test_sync_push_unknown_columns_ignored(client, auth_headers): + """If frontend sends extra fields not in the DB schema, they must be silently + ignored (not cause a 500 error).""" + tenant_id = await _get_tenant_id(client, auth_headers) + vid = str(uuid.uuid4()) + now = datetime.now(UTC).isoformat() + + r = await client.post( + "/api/sync/push", + headers=auth_headers, + json={ + "operations": [ + { + "table": "vehicles", + "id": vid, + "operation": "INSERT", + "data": { + "id": vid, + "tenant_id": tenant_id, + "nr_inmatriculare": "CT99XYZ", + "created_at": now, + "updated_at": now, + "oracle_id": 12345, # frontend-only field + "nonexistent_column": "boom", # completely unknown field + }, + "timestamp": now, + } + ] + }, + ) + assert r.status_code == 200 + result = r.json() + assert result["applied"] == 1 + assert result["conflicts"] == [] + + +@pytest.mark.asyncio +async def test_sync_push_insert_order_line_with_frontend_fields(client, auth_headers): + """Frontend sends norma_id, mecanic_id, ordine in order_line — must not cause 500.""" + tenant_id = await _get_tenant_id(client, auth_headers) + now = datetime.now(UTC).isoformat() + + # Create order first + vid = str(uuid.uuid4()) + oid = str(uuid.uuid4()) + lid = str(uuid.uuid4()) + + await client.post( + "/api/sync/push", + headers=auth_headers, + json={ + "operations": [ + { + "table": "vehicles", + "id": vid, + "operation": "INSERT", + "data": {"id": vid, "tenant_id": tenant_id, "nr_inmatriculare": "B02TST", "created_at": now, "updated_at": now}, + "timestamp": now, + }, + { + "table": "orders", + "id": oid, + "operation": "INSERT", + "data": { + "id": oid, "tenant_id": tenant_id, + "nr_comanda": "CMD-XYZ", "status": "DRAFT", + "vehicle_id": vid, "created_at": now, "updated_at": now, + }, + "timestamp": now, + }, + ] + }, + ) + + r = await client.post( + "/api/sync/push", + headers=auth_headers, + json={ + "operations": [ + { + "table": "order_lines", + "id": lid, + "operation": "INSERT", + "data": { + "id": lid, + "order_id": oid, + "tenant_id": tenant_id, + "tip": "manopera", + "descriere": "Schimb ulei", + "norma_id": None, + "ore": 1.5, + "pret_ora": 150.0, + "um": "ora", + "cantitate": 0, + "pret_unitar": 0, + "total": 225.0, + "mecanic_id": None, + "ordine": 1, + "created_at": now, + "updated_at": now, + }, + "timestamp": now, + } + ] + }, + ) + assert r.status_code == 200 + result = r.json() + assert result["applied"] == 1 + assert result["conflicts"] == []