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:
2026-03-13 18:45:31 +02:00
parent 9aef3d6933
commit 1e96db4d91
10 changed files with 321 additions and 58 deletions

View File

@@ -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}