refactor: split bulk-sync into backend/routers/sync.py
This commit is contained in:
@@ -6,7 +6,7 @@ from slowapi import Limiter
|
|||||||
from slowapi.util import get_remote_address
|
from slowapi.util import get_remote_address
|
||||||
from . import models
|
from . import models
|
||||||
from .database import engine
|
from .database import engine
|
||||||
from .routers import items, operations, users, auth, categories
|
from .routers import items, operations, users, auth, sync, categories
|
||||||
from .routers.admin import backups, config
|
from .routers.admin import backups, config
|
||||||
from .logger import log
|
from .logger import log
|
||||||
from .scheduler import scheduler, sync_scheduler_config
|
from .scheduler import scheduler, sync_scheduler_config
|
||||||
@@ -88,6 +88,7 @@ app.include_router(items.router)
|
|||||||
app.include_router(operations.router)
|
app.include_router(operations.router)
|
||||||
app.include_router(users.router)
|
app.include_router(users.router)
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
|
app.include_router(sync.router)
|
||||||
app.include_router(categories.router)
|
app.include_router(categories.router)
|
||||||
app.include_router(backups.router)
|
app.include_router(backups.router)
|
||||||
app.include_router(config.router)
|
app.include_router(config.router)
|
||||||
|
|||||||
@@ -190,72 +190,6 @@ def bulk_check_out(
|
|||||||
db.commit()
|
db.commit()
|
||||||
return results
|
return results
|
||||||
|
|
||||||
@router.post("/bulk-sync")
|
|
||||||
def bulk_sync(
|
|
||||||
payload: schemas.SyncPayload,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
current_user: auth.TokenData = Depends(auth.get_current_user)
|
|
||||||
):
|
|
||||||
"""[C-01] Bulk sync offline operations — only for authenticated users."""
|
|
||||||
results = {"success": [], "errors": []}
|
|
||||||
|
|
||||||
for op in payload.operations:
|
|
||||||
try:
|
|
||||||
# DEDUPLICATION CHECK: If this UUID already exists, skip it
|
|
||||||
if op.uuid:
|
|
||||||
existing = db.query(models.AuditLog).filter(models.AuditLog.uuid == op.uuid).first()
|
|
||||||
if existing:
|
|
||||||
results["success"].append({"barcode": op.barcode, "type": op.type, "note": "Already synced"})
|
|
||||||
continue
|
|
||||||
|
|
||||||
item = db.query(models.Item).filter(models.Item.barcode == op.barcode).first()
|
|
||||||
if not item:
|
|
||||||
results["errors"].append({"barcode": op.barcode, "error": "Item not found"})
|
|
||||||
continue
|
|
||||||
|
|
||||||
if op.type == "CHECK_IN":
|
|
||||||
item.quantity += op.quantity
|
|
||||||
change = op.quantity
|
|
||||||
elif op.type == "CHECK_OUT" or op.type == "TRASH":
|
|
||||||
if item.quantity < op.quantity:
|
|
||||||
results["errors"].append({"barcode": op.barcode, "error": f"Insufficient stock (Available: {item.quantity})"})
|
|
||||||
continue
|
|
||||||
item.quantity -= op.quantity
|
|
||||||
change = -op.quantity
|
|
||||||
else:
|
|
||||||
results["errors"].append({"barcode": op.barcode, "error": f"Invalid operation type: {op.type}"})
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Log audit with original offline timestamp and UUID
|
|
||||||
item_snapshot = {
|
|
||||||
"barcode": item.barcode,
|
|
||||||
"name": item.name,
|
|
||||||
"category": item.category,
|
|
||||||
"part_number": item.part_number
|
|
||||||
}
|
|
||||||
|
|
||||||
audit = models.AuditLog(
|
|
||||||
user_id=current_user.sub,
|
|
||||||
action=op.type,
|
|
||||||
target_item_id=item.id,
|
|
||||||
target_item_name=item.name,
|
|
||||||
target_item_pn=item.part_number,
|
|
||||||
target_item_barcode=item.barcode,
|
|
||||||
target_snapshot=json.dumps(item_snapshot),
|
|
||||||
quantity_change=change,
|
|
||||||
timestamp=op.timestamp,
|
|
||||||
uuid=op.uuid,
|
|
||||||
details="Offline Synchronization"
|
|
||||||
)
|
|
||||||
db.add(audit)
|
|
||||||
results["success"].append({"barcode": op.barcode, "type": op.type})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
results["errors"].append({"barcode": op.barcode, "error": str(e)})
|
|
||||||
|
|
||||||
db.commit()
|
|
||||||
return results
|
|
||||||
|
|
||||||
@router.get("/logs", response_model=List[schemas.AuditLogResponse])
|
@router.get("/logs", response_model=List[schemas.AuditLogResponse])
|
||||||
def get_logs(
|
def get_logs(
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
|
|||||||
77
backend/routers/sync.py
Normal file
77
backend/routers/sync.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from .. import models, schemas, auth
|
||||||
|
from ..database import get_db
|
||||||
|
import json
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/sync",
|
||||||
|
tags=["Sync"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/bulk-sync")
|
||||||
|
def bulk_sync(
|
||||||
|
payload: schemas.SyncPayload,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: auth.TokenData = Depends(auth.get_current_user)
|
||||||
|
):
|
||||||
|
"""[C-01] Bulk sync offline operations — only for authenticated users."""
|
||||||
|
results = {"success": [], "errors": []}
|
||||||
|
|
||||||
|
for op in payload.operations:
|
||||||
|
try:
|
||||||
|
# DEDUPLICATION CHECK: If this UUID already exists, skip it
|
||||||
|
if op.uuid:
|
||||||
|
existing = db.query(models.AuditLog).filter(models.AuditLog.uuid == op.uuid).first()
|
||||||
|
if existing:
|
||||||
|
results["success"].append({"barcode": op.barcode, "type": op.type, "note": "Already synced"})
|
||||||
|
continue
|
||||||
|
|
||||||
|
item = db.query(models.Item).filter(models.Item.barcode == op.barcode).first()
|
||||||
|
if not item:
|
||||||
|
results["errors"].append({"barcode": op.barcode, "error": "Item not found"})
|
||||||
|
continue
|
||||||
|
|
||||||
|
if op.type == "CHECK_IN":
|
||||||
|
item.quantity += op.quantity
|
||||||
|
change = op.quantity
|
||||||
|
elif op.type == "CHECK_OUT" or op.type == "TRASH":
|
||||||
|
if item.quantity < op.quantity:
|
||||||
|
results["errors"].append({"barcode": op.barcode, "error": f"Insufficient stock (Available: {item.quantity})"})
|
||||||
|
continue
|
||||||
|
item.quantity -= op.quantity
|
||||||
|
change = -op.quantity
|
||||||
|
else:
|
||||||
|
results["errors"].append({"barcode": op.barcode, "error": f"Invalid operation type: {op.type}"})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Log audit with original offline timestamp and UUID
|
||||||
|
item_snapshot = {
|
||||||
|
"barcode": item.barcode,
|
||||||
|
"name": item.name,
|
||||||
|
"category": item.category,
|
||||||
|
"part_number": item.part_number
|
||||||
|
}
|
||||||
|
|
||||||
|
audit = models.AuditLog(
|
||||||
|
user_id=current_user.sub,
|
||||||
|
action=op.type,
|
||||||
|
target_item_id=item.id,
|
||||||
|
target_item_name=item.name,
|
||||||
|
target_item_pn=item.part_number,
|
||||||
|
target_item_barcode=item.barcode,
|
||||||
|
target_snapshot=json.dumps(item_snapshot),
|
||||||
|
quantity_change=change,
|
||||||
|
timestamp=op.timestamp,
|
||||||
|
uuid=op.uuid,
|
||||||
|
details="Offline Synchronization"
|
||||||
|
)
|
||||||
|
db.add(audit)
|
||||||
|
results["success"].append({"barcode": op.barcode, "type": op.type})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
results["errors"].append({"barcode": op.barcode, "error": str(e)})
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return results
|
||||||
@@ -19,7 +19,7 @@ class TestOfflineSync:
|
|||||||
user = test_db.query(User).filter(User.username == "user").first()
|
user = test_db.query(User).filter(User.username == "user").first()
|
||||||
operation_uuid = str(uuid4())
|
operation_uuid = str(uuid4())
|
||||||
response = test_client.post(
|
response = test_client.post(
|
||||||
"/operations/bulk-sync",
|
"/sync/bulk-sync",
|
||||||
json={
|
json={
|
||||||
"user_id": user.id,
|
"user_id": user.id,
|
||||||
"operations": [
|
"operations": [
|
||||||
@@ -65,7 +65,7 @@ class TestOfflineSync:
|
|||||||
|
|
||||||
# First sync
|
# First sync
|
||||||
response1 = test_client.post(
|
response1 = test_client.post(
|
||||||
"/operations/bulk-sync", json=payload,
|
"/sync/bulk-sync", json=payload,
|
||||||
headers={"Authorization": f"Bearer {user_token}"}
|
headers={"Authorization": f"Bearer {user_token}"}
|
||||||
)
|
)
|
||||||
assert response1.status_code == status.HTTP_200_OK
|
assert response1.status_code == status.HTTP_200_OK
|
||||||
@@ -73,7 +73,7 @@ class TestOfflineSync:
|
|||||||
|
|
||||||
# Second sync (same UUID) — returns "Already synced" note
|
# Second sync (same UUID) — returns "Already synced" note
|
||||||
response2 = test_client.post(
|
response2 = test_client.post(
|
||||||
"/operations/bulk-sync", json=payload,
|
"/sync/bulk-sync", json=payload,
|
||||||
headers={"Authorization": f"Bearer {user_token}"}
|
headers={"Authorization": f"Bearer {user_token}"}
|
||||||
)
|
)
|
||||||
assert response2.status_code == status.HTTP_200_OK
|
assert response2.status_code == status.HTTP_200_OK
|
||||||
@@ -90,7 +90,7 @@ class TestOfflineSync:
|
|||||||
|
|
||||||
user = test_db.query(User).filter(User.username == "user").first()
|
user = test_db.query(User).filter(User.username == "user").first()
|
||||||
response = test_client.post(
|
response = test_client.post(
|
||||||
"/operations/bulk-sync",
|
"/sync/bulk-sync",
|
||||||
json={
|
json={
|
||||||
"user_id": user.id,
|
"user_id": user.id,
|
||||||
"operations": [
|
"operations": [
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ class TestBulkSync:
|
|||||||
|
|
||||||
user = test_db.query(User).filter(User.username == "user").first()
|
user = test_db.query(User).filter(User.username == "user").first()
|
||||||
response = test_client.post(
|
response = test_client.post(
|
||||||
"/operations/bulk-sync",
|
"/sync/bulk-sync",
|
||||||
json={
|
json={
|
||||||
"user_id": user.id,
|
"user_id": user.id,
|
||||||
"operations": [
|
"operations": [
|
||||||
@@ -141,7 +141,7 @@ class TestBulkSync:
|
|||||||
|
|
||||||
# Sync once
|
# Sync once
|
||||||
response1 = test_client.post(
|
response1 = test_client.post(
|
||||||
"/operations/bulk-sync", json=operation,
|
"/sync/bulk-sync", json=operation,
|
||||||
headers={"Authorization": f"Bearer {user_token}"}
|
headers={"Authorization": f"Bearer {user_token}"}
|
||||||
)
|
)
|
||||||
assert response1.status_code == status.HTTP_200_OK
|
assert response1.status_code == status.HTTP_200_OK
|
||||||
@@ -149,7 +149,7 @@ class TestBulkSync:
|
|||||||
|
|
||||||
# Sync again with same UUID — should be idempotent (returns "Already synced")
|
# Sync again with same UUID — should be idempotent (returns "Already synced")
|
||||||
response2 = test_client.post(
|
response2 = test_client.post(
|
||||||
"/operations/bulk-sync", json=operation,
|
"/sync/bulk-sync", json=operation,
|
||||||
headers={"Authorization": f"Bearer {user_token}"}
|
headers={"Authorization": f"Bearer {user_token}"}
|
||||||
)
|
)
|
||||||
assert response2.status_code == status.HTTP_200_OK
|
assert response2.status_code == status.HTTP_200_OK
|
||||||
|
|||||||
Reference in New Issue
Block a user