fix(backend): sync push error handling + validation
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
from sqlalchemy import String, Text
|
from sqlalchemy import Integer, String, Text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from app.db.base import Base, UUIDMixin, TenantMixin, TimestampMixin
|
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):
|
class Appointment(Base, UUIDMixin, TenantMixin, TimestampMixin):
|
||||||
__tablename__ = "appointments"
|
__tablename__ = "appointments"
|
||||||
vehicle_id: Mapped[str] = mapped_column(String(36))
|
vehicle_id: Mapped[str | None] = mapped_column(String(36))
|
||||||
data: Mapped[str] = mapped_column(Text)
|
client_nume: Mapped[str | None] = mapped_column(String(200))
|
||||||
descriere: Mapped[str | None] = mapped_column(Text)
|
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))
|
||||||
|
|||||||
@@ -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 sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from app.db.base import Base, UUIDMixin, TenantMixin, TimestampMixin
|
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):
|
class CatalogMarca(Base, UUIDMixin, TenantMixin, TimestampMixin):
|
||||||
__tablename__ = "catalog_marci"
|
__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):
|
class CatalogModel(Base, UUIDMixin, TimestampMixin):
|
||||||
__tablename__ = "catalog_modele"
|
__tablename__ = "catalog_modele"
|
||||||
marca_id: Mapped[str] = mapped_column(String(36), index=True)
|
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):
|
class CatalogAnsamblu(Base, UUIDMixin, TenantMixin, TimestampMixin):
|
||||||
__tablename__ = "catalog_ansamble"
|
__tablename__ = "catalog_ansamble"
|
||||||
nume: Mapped[str] = mapped_column(String(100))
|
denumire: Mapped[str] = mapped_column(String(100))
|
||||||
|
|
||||||
|
|
||||||
class CatalogNorma(Base, UUIDMixin, TenantMixin, TimestampMixin):
|
class CatalogNorma(Base, UUIDMixin, TenantMixin, TimestampMixin):
|
||||||
__tablename__ = "catalog_norme"
|
__tablename__ = "catalog_norme"
|
||||||
ansamblu_id: Mapped[str] = mapped_column(String(36), index=True)
|
cod: Mapped[str | None] = mapped_column(String(50))
|
||||||
descriere: Mapped[str] = mapped_column(Text)
|
denumire: Mapped[str] = mapped_column(Text)
|
||||||
ore: Mapped[float] = mapped_column(Float, default=0)
|
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):
|
class CatalogPret(Base, UUIDMixin, TenantMixin, TimestampMixin):
|
||||||
__tablename__ = "catalog_preturi"
|
__tablename__ = "catalog_preturi"
|
||||||
denumire: Mapped[str] = mapped_column(String(200))
|
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))
|
um: Mapped[str] = mapped_column(String(10))
|
||||||
|
|
||||||
|
|
||||||
class CatalogTipDeviz(Base, UUIDMixin, TenantMixin, TimestampMixin):
|
class CatalogTipDeviz(Base, UUIDMixin, TenantMixin, TimestampMixin):
|
||||||
__tablename__ = "catalog_tipuri_deviz"
|
__tablename__ = "catalog_tipuri_deviz"
|
||||||
nume: Mapped[str] = mapped_column(String(100))
|
denumire: Mapped[str] = mapped_column(String(100))
|
||||||
|
|
||||||
|
|
||||||
class CatalogTipMotor(Base, UUIDMixin, TenantMixin, TimestampMixin):
|
class CatalogTipMotor(Base, UUIDMixin, TenantMixin, TimestampMixin):
|
||||||
__tablename__ = "catalog_tipuri_motoare"
|
__tablename__ = "catalog_tipuri_motoare"
|
||||||
nume: Mapped[str] = mapped_column(String(50))
|
denumire: Mapped[str] = mapped_column(String(50))
|
||||||
|
|||||||
@@ -6,8 +6,17 @@ from app.db.base import Base, UUIDMixin, TenantMixin, TimestampMixin
|
|||||||
|
|
||||||
class Invoice(Base, UUIDMixin, TenantMixin, TimestampMixin):
|
class Invoice(Base, UUIDMixin, TenantMixin, TimestampMixin):
|
||||||
__tablename__ = "invoices"
|
__tablename__ = "invoices"
|
||||||
order_id: Mapped[str] = mapped_column(String(36), index=True)
|
order_id: Mapped[str | None] = mapped_column(String(36), index=True)
|
||||||
nr_factura: Mapped[str] = mapped_column(String(50))
|
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)
|
data_factura: Mapped[str | None] = mapped_column(Text)
|
||||||
total: Mapped[float] = mapped_column(Float, default=0)
|
modalitate_plata: Mapped[str | None] = mapped_column(String(50))
|
||||||
status: Mapped[str] = mapped_column(String(20), default="EMISA")
|
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")
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from sqlalchemy import String
|
from sqlalchemy import Integer, String
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from app.db.base import Base, UUIDMixin, TenantMixin, TimestampMixin
|
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):
|
class Mecanic(Base, UUIDMixin, TenantMixin, TimestampMixin):
|
||||||
__tablename__ = "mecanici"
|
__tablename__ = "mecanici"
|
||||||
|
user_id: Mapped[str | None] = mapped_column(String(36))
|
||||||
nume: Mapped[str] = mapped_column(String(200))
|
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")
|
||||||
|
|||||||
@@ -6,15 +6,26 @@ from app.db.base import Base, UUIDMixin, TenantMixin, TimestampMixin
|
|||||||
|
|
||||||
class Order(Base, UUIDMixin, TenantMixin, TimestampMixin):
|
class Order(Base, UUIDMixin, TenantMixin, TimestampMixin):
|
||||||
__tablename__ = "orders"
|
__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))
|
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)
|
data_comanda: Mapped[str | None] = mapped_column(Text)
|
||||||
km_intrare: Mapped[int | None] = mapped_column(Integer)
|
km_intrare: Mapped[int | None] = mapped_column(Integer)
|
||||||
observatii: Mapped[str | None] = mapped_column(Text)
|
observatii: Mapped[str | None] = mapped_column(Text)
|
||||||
mecanic_id: Mapped[str | None] = mapped_column(String(36))
|
mecanic_id: Mapped[str | None] = mapped_column(String(36))
|
||||||
total_manopera: Mapped[float] = mapped_column(Float, default=0)
|
# Denormalized client/vehicle info for quick display
|
||||||
total_materiale: Mapped[float] = mapped_column(Float, default=0)
|
client_nume: Mapped[str | None] = mapped_column(String(200))
|
||||||
total_general: Mapped[float] = mapped_column(Float, default=0)
|
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))
|
token_client: Mapped[str | None] = mapped_column(String(36))
|
||||||
status_client: Mapped[str | None] = mapped_column(String(20))
|
status_client: Mapped[str | None] = mapped_column(String(20))
|
||||||
|
# Audit
|
||||||
|
created_by: Mapped[str | None] = mapped_column(String(36))
|
||||||
|
|||||||
@@ -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 sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from app.db.base import Base, UUIDMixin, TenantMixin, TimestampMixin
|
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)
|
order_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||||
tip: Mapped[str] = mapped_column(String(20)) # manopera | material
|
tip: Mapped[str] = mapped_column(String(20)) # manopera | material
|
||||||
descriere: Mapped[str] = mapped_column(Text)
|
descriere: Mapped[str] = mapped_column(Text)
|
||||||
ore: Mapped[float] = mapped_column(Float, default=0)
|
norma_id: Mapped[str | None] = mapped_column(String(36))
|
||||||
pret_ora: Mapped[float] = mapped_column(Float, default=0)
|
ore: Mapped[float] = mapped_column(Float, default=0, server_default="0")
|
||||||
cantitate: Mapped[float] = mapped_column(Float, default=0)
|
pret_ora: Mapped[float] = mapped_column(Float, default=0, server_default="0")
|
||||||
pret_unitar: Mapped[float] = mapped_column(Float, 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))
|
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)
|
||||||
|
|||||||
@@ -7,15 +7,19 @@ from app.db.base import Base, UUIDMixin, TenantMixin, TimestampMixin
|
|||||||
class Vehicle(Base, UUIDMixin, TenantMixin, TimestampMixin):
|
class Vehicle(Base, UUIDMixin, TenantMixin, TimestampMixin):
|
||||||
__tablename__ = "vehicles"
|
__tablename__ = "vehicles"
|
||||||
nr_inmatriculare: Mapped[str] = mapped_column(String(20))
|
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))
|
marca_id: Mapped[str | None] = mapped_column(String(36))
|
||||||
model_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)
|
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))
|
tip_motor_id: Mapped[str | None] = mapped_column(String(36))
|
||||||
capacitate_motor: Mapped[str | None] = mapped_column(String(20))
|
capacitate_motor: Mapped[str | None] = mapped_column(String(20))
|
||||||
putere_kw: 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_nume: Mapped[str | None] = mapped_column(String(200))
|
||||||
client_telefon: Mapped[str | None] = mapped_column(String(20))
|
client_telefon: Mapped[str | None] = mapped_column(String(20))
|
||||||
client_email: Mapped[str | None] = mapped_column(String(200))
|
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_cui: Mapped[str | None] = mapped_column(String(20))
|
||||||
client_adresa: Mapped[str | None] = mapped_column(Text)
|
client_adresa: Mapped[str | None] = mapped_column(Text)
|
||||||
|
|||||||
@@ -77,28 +77,28 @@ async def seed_catalog(db: AsyncSession, tenant_id: str) -> dict:
|
|||||||
# Marci
|
# Marci
|
||||||
for name in MARCI:
|
for name in MARCI:
|
||||||
db.add(
|
db.add(
|
||||||
CatalogMarca(id=uuid7(), tenant_id=tenant_id, nume=name)
|
CatalogMarca(id=uuid7(), tenant_id=tenant_id, denumire=name)
|
||||||
)
|
)
|
||||||
counts["marci"] = len(MARCI)
|
counts["marci"] = len(MARCI)
|
||||||
|
|
||||||
# Ansamble
|
# Ansamble
|
||||||
for name in ANSAMBLE:
|
for name in ANSAMBLE:
|
||||||
db.add(
|
db.add(
|
||||||
CatalogAnsamblu(id=uuid7(), tenant_id=tenant_id, nume=name)
|
CatalogAnsamblu(id=uuid7(), tenant_id=tenant_id, denumire=name)
|
||||||
)
|
)
|
||||||
counts["ansamble"] = len(ANSAMBLE)
|
counts["ansamble"] = len(ANSAMBLE)
|
||||||
|
|
||||||
# Tipuri deviz
|
# Tipuri deviz
|
||||||
for name in TIPURI_DEVIZ:
|
for name in TIPURI_DEVIZ:
|
||||||
db.add(
|
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)
|
counts["tipuri_deviz"] = len(TIPURI_DEVIZ)
|
||||||
|
|
||||||
# Tipuri motoare
|
# Tipuri motoare
|
||||||
for name in TIPURI_MOTOARE:
|
for name in TIPURI_MOTOARE:
|
||||||
db.add(
|
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)
|
counts["tipuri_motoare"] = len(TIPURI_MOTOARE)
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ SYNCABLE_TABLES = [
|
|||||||
NO_TENANT_TABLES = {"catalog_modele"}
|
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:
|
async def get_full(db: AsyncSession, tenant_id: str) -> dict:
|
||||||
result = {}
|
result = {}
|
||||||
for table in SYNCABLE_TABLES:
|
for table in SYNCABLE_TABLES:
|
||||||
@@ -73,38 +79,57 @@ async def apply_push(
|
|||||||
db: AsyncSession, tenant_id: str, operations: list
|
db: AsyncSession, tenant_id: str, operations: list
|
||||||
) -> dict:
|
) -> dict:
|
||||||
applied = 0
|
applied = 0
|
||||||
|
errors = []
|
||||||
|
# Cache column sets per table to avoid repeated PRAGMA calls
|
||||||
|
table_columns_cache: dict[str, set[str]] = {}
|
||||||
|
|
||||||
for op in operations:
|
for op in operations:
|
||||||
table = op["table"]
|
table = op["table"]
|
||||||
if table not in SYNCABLE_TABLES:
|
if table not in SYNCABLE_TABLES:
|
||||||
continue
|
continue
|
||||||
data = op.get("data", {})
|
data = dict(op.get("data", {}))
|
||||||
|
|
||||||
# Enforce tenant isolation (except for no-tenant tables)
|
# Enforce tenant isolation (except for no-tenant tables)
|
||||||
if table not in NO_TENANT_TABLES:
|
if table not in NO_TENANT_TABLES:
|
||||||
if data.get("tenant_id") and data["tenant_id"] != tenant_id:
|
if data.get("tenant_id") and data["tenant_id"] != tenant_id:
|
||||||
continue
|
continue
|
||||||
data["tenant_id"] = tenant_id
|
data["tenant_id"] = tenant_id
|
||||||
|
|
||||||
if op["operation"] in ("INSERT", "UPDATE"):
|
try:
|
||||||
cols = ", ".join(data.keys())
|
if op["operation"] in ("INSERT", "UPDATE"):
|
||||||
ph = ", ".join(f":{k}" for k in data.keys())
|
# Fetch and cache the valid column names for this table
|
||||||
await db.execute(
|
if table not in table_columns_cache:
|
||||||
text(f"INSERT OR REPLACE INTO {table} ({cols}) VALUES ({ph})"),
|
table_columns_cache[table] = await _get_table_columns(db, table)
|
||||||
data,
|
valid_cols = table_columns_cache[table]
|
||||||
)
|
|
||||||
applied += 1
|
# Filter data to only include columns that exist in the DB table
|
||||||
elif op["operation"] == "DELETE":
|
filtered = {k: v for k, v in data.items() if k in valid_cols}
|
||||||
if table in NO_TENANT_TABLES:
|
if not filtered:
|
||||||
|
continue
|
||||||
|
|
||||||
|
cols = ", ".join(filtered.keys())
|
||||||
|
ph = ", ".join(f":{k}" for k in filtered.keys())
|
||||||
await db.execute(
|
await db.execute(
|
||||||
text(f"DELETE FROM {table} WHERE id = :id"),
|
text(f"INSERT OR REPLACE INTO {table} ({cols}) VALUES ({ph})"),
|
||||||
{"id": op["id"]},
|
filtered,
|
||||||
)
|
)
|
||||||
else:
|
applied += 1
|
||||||
await db.execute(
|
elif op["operation"] == "DELETE":
|
||||||
text(
|
if table in NO_TENANT_TABLES:
|
||||||
f"DELETE FROM {table} WHERE id = :id AND tenant_id = :tid"
|
await db.execute(
|
||||||
),
|
text(f"DELETE FROM {table} WHERE id = :id"),
|
||||||
{"id": op["id"], "tid": tenant_id},
|
{"id": op["id"]},
|
||||||
)
|
)
|
||||||
applied += 1
|
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()
|
await db.commit()
|
||||||
return {"applied": applied, "conflicts": []}
|
return {"applied": applied, "conflicts": errors}
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ from httpx import ASGITransport, AsyncClient
|
|||||||
from app.main import app
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_full_sync_returns_all_tables(client, auth_headers):
|
async def test_full_sync_returns_all_tables(client, auth_headers):
|
||||||
r = await client.get("/api/sync/full", headers=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
|
assert r.status_code == 200
|
||||||
# Wrong tenant_id is rejected (skipped)
|
# Wrong tenant_id is rejected (skipped)
|
||||||
assert r.json()["applied"] == 0
|
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"] == []
|
||||||
|
|||||||
Reference in New Issue
Block a user