feat(backend): sync endpoints + all models + seed + order workflow
- All business models: Vehicle, Order, OrderLine, Invoice, Appointment, CatalogMarca/Model/Ansamblu/Norma/Pret/TipDeviz/TipMotor, Mecanic - Sync endpoints: GET /sync/full, GET /sync/changes?since=, POST /sync/push with tenant isolation and last-write-wins conflict resolution - Order CRUD with state machine: DRAFT -> VALIDAT -> FACTURAT Auto-recalculates totals (manopera + materiale) - Vehicle CRUD: list, create, get, update - Seed data: 24 marci, 11 ansamble, 6 tipuri deviz, 5 tipuri motoare, 3 preturi - Alembic migration for all business models - 13 passing tests (auth + sync + orders) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
0
backend/app/sync/__init__.py
Normal file
0
backend/app/sync/__init__.py
Normal file
41
backend/app/sync/router.py
Normal file
41
backend/app/sync/router.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.deps import get_tenant_id
|
||||
from app.sync import schemas, service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/full")
|
||||
async def sync_full(
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
tables = await service.get_full(db, tenant_id)
|
||||
return {"tables": tables, "synced_at": datetime.now(UTC).isoformat()}
|
||||
|
||||
|
||||
@router.get("/changes")
|
||||
async def sync_changes(
|
||||
since: str = Query(...),
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
tables = await service.get_changes(db, tenant_id, since)
|
||||
return {"tables": tables, "synced_at": datetime.now(UTC).isoformat()}
|
||||
|
||||
|
||||
@router.post("/push", response_model=schemas.SyncPushResponse)
|
||||
async def sync_push(
|
||||
data: schemas.SyncPushRequest,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await service.apply_push(
|
||||
db, tenant_id, [op.model_dump() for op in data.operations]
|
||||
)
|
||||
return result
|
||||
18
backend/app/sync/schemas.py
Normal file
18
backend/app/sync/schemas.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class SyncOperation(BaseModel):
|
||||
table: str
|
||||
id: str
|
||||
operation: str # INSERT | UPDATE | DELETE
|
||||
data: dict = {}
|
||||
timestamp: str
|
||||
|
||||
|
||||
class SyncPushRequest(BaseModel):
|
||||
operations: list[SyncOperation]
|
||||
|
||||
|
||||
class SyncPushResponse(BaseModel):
|
||||
applied: int
|
||||
conflicts: list = []
|
||||
110
backend/app/sync/service.py
Normal file
110
backend/app/sync/service.py
Normal file
@@ -0,0 +1,110 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
SYNCABLE_TABLES = [
|
||||
"vehicles",
|
||||
"orders",
|
||||
"order_lines",
|
||||
"invoices",
|
||||
"appointments",
|
||||
"catalog_marci",
|
||||
"catalog_modele",
|
||||
"catalog_ansamble",
|
||||
"catalog_norme",
|
||||
"catalog_preturi",
|
||||
"catalog_tipuri_deviz",
|
||||
"catalog_tipuri_motoare",
|
||||
"mecanici",
|
||||
]
|
||||
|
||||
# Tables that don't have tenant_id directly
|
||||
NO_TENANT_TABLES = {"catalog_modele"}
|
||||
|
||||
|
||||
async def get_full(db: AsyncSession, tenant_id: str) -> dict:
|
||||
result = {}
|
||||
for table in SYNCABLE_TABLES:
|
||||
if table == "catalog_modele":
|
||||
rows = await db.execute(
|
||||
text(
|
||||
"SELECT cm.* FROM catalog_modele cm "
|
||||
"JOIN catalog_marci marc ON cm.marca_id = marc.id "
|
||||
"WHERE marc.tenant_id = :tid"
|
||||
),
|
||||
{"tid": tenant_id},
|
||||
)
|
||||
else:
|
||||
rows = await db.execute(
|
||||
text(f"SELECT * FROM {table} WHERE tenant_id = :tid"),
|
||||
{"tid": tenant_id},
|
||||
)
|
||||
result[table] = [dict(r._mapping) for r in rows]
|
||||
return result
|
||||
|
||||
|
||||
async def get_changes(db: AsyncSession, tenant_id: str, since: str) -> dict:
|
||||
result = {}
|
||||
for table in SYNCABLE_TABLES:
|
||||
if table == "catalog_modele":
|
||||
rows = await db.execute(
|
||||
text(
|
||||
"SELECT cm.* FROM catalog_modele cm "
|
||||
"JOIN catalog_marci marc ON cm.marca_id = marc.id "
|
||||
"WHERE marc.tenant_id = :tid AND cm.updated_at > :since"
|
||||
),
|
||||
{"tid": tenant_id, "since": since},
|
||||
)
|
||||
else:
|
||||
rows = await db.execute(
|
||||
text(
|
||||
f"SELECT * FROM {table} WHERE tenant_id = :tid AND updated_at > :since"
|
||||
),
|
||||
{"tid": tenant_id, "since": since},
|
||||
)
|
||||
rows_list = [dict(r._mapping) for r in rows]
|
||||
if rows_list:
|
||||
result[table] = rows_list
|
||||
return result
|
||||
|
||||
|
||||
async def apply_push(
|
||||
db: AsyncSession, tenant_id: str, operations: list
|
||||
) -> dict:
|
||||
applied = 0
|
||||
for op in operations:
|
||||
table = op["table"]
|
||||
if table not in SYNCABLE_TABLES:
|
||||
continue
|
||||
data = op.get("data", {})
|
||||
# Enforce tenant isolation (except for no-tenant tables)
|
||||
if table not in NO_TENANT_TABLES:
|
||||
if data.get("tenant_id") and data["tenant_id"] != tenant_id:
|
||||
continue
|
||||
data["tenant_id"] = tenant_id
|
||||
|
||||
if op["operation"] in ("INSERT", "UPDATE"):
|
||||
cols = ", ".join(data.keys())
|
||||
ph = ", ".join(f":{k}" for k in data.keys())
|
||||
await db.execute(
|
||||
text(f"INSERT OR REPLACE INTO {table} ({cols}) VALUES ({ph})"),
|
||||
data,
|
||||
)
|
||||
applied += 1
|
||||
elif op["operation"] == "DELETE":
|
||||
if table in NO_TENANT_TABLES:
|
||||
await db.execute(
|
||||
text(f"DELETE FROM {table} WHERE id = :id"),
|
||||
{"id": op["id"]},
|
||||
)
|
||||
else:
|
||||
await db.execute(
|
||||
text(
|
||||
f"DELETE FROM {table} WHERE id = :id AND tenant_id = :tid"
|
||||
),
|
||||
{"id": op["id"], "tid": tenant_id},
|
||||
)
|
||||
applied += 1
|
||||
await db.commit()
|
||||
return {"applied": applied, "conflicts": []}
|
||||
Reference in New Issue
Block a user