feat(boc): Complete Business Operations Center v1.0

- Go backend API with full CRUD for all modules (CRM, Sales, Finance, HR, Legal, Marketing, Support, Purchase, Inventory, Projects, Automation, Analytics)
- Rust analytics service with parallel report generation
- C runtime with POSIX shared memory IPC
- PostgreSQL schema with 30+ tables, full migrations
- Redis cache, sessions, pub/sub
- Kafka event streaming with Zookeeper
- WebSocket hub for real-time updates
- Automation engine with cron jobs, workflows, event triggers
- JWT authentication, multi-tenant from start
- Docker Compose with all services
- Nginx reverse proxy with rate limiting
- Integration tests passing
- Feature gap analysis against Fortnox/Odoo/Visma

Refs: BOC-001
This commit is contained in:
Bernt
2026-07-12 12:41:35 +00:00
parent 4789a7fb48
commit 58ca4e68db
26666 changed files with 575891 additions and 2074516 deletions
+429
View File
@@ -0,0 +1,429 @@
"""
Database module for ATM Anomaly Detection API.
Supports SQLite (default) and PostgreSQL.
"""
import os
import sqlite3
import json
from datetime import datetime
from typing import List, Dict, Optional, Any
from contextlib import contextmanager
from pathlib import Path
# Database configuration
DB_PATH = os.getenv("DATABASE_PATH", "data/atm_anomaly.db")
POSTGRES_URL = os.getenv("DATABASE_URL", "")
USE_POSTGRES = bool(POSTGRES_URL) and POSTGRES_URL.startswith("postgresql")
# Ensure data directory exists
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
def get_db_connection():
"""Get database connection."""
if USE_POSTGRES:
import psycopg2
return psycopg2.connect(POSTGRES_URL)
else:
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
@contextmanager
def get_db():
"""Context manager for database connections."""
conn = get_db_connection()
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def init_db():
"""Initialize database with schema."""
schema_path = Path(__file__).parent.parent.parent / "config" / "database.sql"
with get_db() as conn:
cursor = conn.cursor()
# Read and execute schema
if schema_path.exists():
with open(schema_path, 'r') as f:
schema = f.read()
# Split by semicolons and execute each statement
# Skip comments and empty statements
statements = []
current = []
for line in schema.split('\n'):
stripped = line.strip()
if not stripped or stripped.startswith('--'):
continue
current.append(line)
if stripped.endswith(';'):
statements.append('\n'.join(current))
current = []
for stmt in statements:
try:
cursor.execute(stmt)
except Exception as e:
# Ignore errors for existing tables/indexes
if "already exists" not in str(e).lower():
print(f"Schema warning: {e}")
conn.commit()
print(f"Database initialized: {'PostgreSQL' if USE_POSTGRES else 'SQLite'}")
def adapt_datetime(dt):
"""Adapt datetime for SQLite."""
return dt.isoformat()
def adapt_json(data):
"""Adapt JSON data for SQLite."""
return json.dumps(data)
# Register adapters for SQLite
sqlite3.register_adapter(datetime, adapt_datetime)
sqlite3.register_adapter(dict, adapt_json)
sqlite3.register_adapter(list, adapt_json)
class ATMRepository:
"""Repository for ATM-related database operations."""
@staticmethod
def get_atm_status(atm_id: str) -> Optional[Dict]:
"""Get ATM status and recent anomaly count."""
with get_db() as conn:
cursor = conn.cursor()
# Get ATM info
cursor.execute("""
SELECT * FROM atm_locations WHERE atm_id = ?
""", (atm_id,))
atm = cursor.fetchone()
if not atm:
return None
# Get recent anomaly count (last 24 hours)
cursor.execute("""
SELECT COUNT(*) as anomaly_count,
MAX(d.confidence_score) as max_confidence,
MAX(d.severity_score) as max_severity
FROM detected_anomalies d
JOIN atm_captures c ON d.capture_id = c.id
WHERE c.atm_id = ?
AND d.created_at >= datetime('now', '-1 day')
AND d.status != 'false_positive'
""", (atm_id,))
stats = cursor.fetchone()
# Get latest capture
cursor.execute("""
SELECT * FROM atm_captures
WHERE atm_id = ?
ORDER BY capture_timestamp DESC
LIMIT 1
""", (atm_id,))
latest_capture = cursor.fetchone()
return {
"atm_id": atm["atm_id"],
"bank_name": atm["bank_name"],
"branch_name": atm["branch_name"],
"address": atm["address"],
"latitude": atm["latitude"],
"longitude": atm["longitude"],
"city": atm["city"],
"country": atm["country"],
"status": atm["status"],
"camera_count": atm["camera_count"],
"installation_date": atm["installation_date"],
"anomaly_count_24h": stats["anomaly_count"] if stats else 0,
"max_confidence_24h": stats["max_confidence"] if stats else 0,
"max_severity_24h": stats["max_severity"] if stats else 0,
"latest_capture": dict(latest_capture) if latest_capture else None,
"updated_at": atm["updated_at"]
}
@staticmethod
def get_atm_anomalies(
atm_id: str,
status: Optional[str] = None,
limit: int = 50,
offset: int = 0
) -> List[Dict]:
"""Get anomalies for a specific ATM."""
with get_db() as conn:
cursor = conn.cursor()
query = """
SELECT d.*, c.atm_id, c.capture_timestamp, c.image_path,
c.camera_angle, t.type_name, t.severity_level as type_severity,
t.category, t.requires_immediate_action
FROM detected_anomalies d
JOIN atm_captures c ON d.capture_id = c.id
JOIN anomaly_types t ON d.anomaly_type_id = t.id
WHERE c.atm_id = ?
"""
params = [atm_id]
if status:
query += " AND d.status = ?"
params.append(status)
query += " ORDER BY d.created_at DESC LIMIT ? OFFSET ?"
params.extend([limit, offset])
cursor.execute(query, params)
rows = cursor.fetchall()
return [dict(row) for row in rows]
@staticmethod
def get_anomaly_by_id(anomaly_id: int) -> Optional[Dict]:
"""Get anomaly by ID."""
with get_db() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT d.*, c.atm_id, c.capture_timestamp, c.image_path,
c.camera_angle, t.type_name, t.severity_level as type_severity,
t.category, t.requires_immediate_action
FROM detected_anomalies d
JOIN atm_captures c ON d.capture_id = c.id
JOIN anomaly_types t ON d.anomaly_type_id = t.id
WHERE d.id = ?
""", (anomaly_id,))
row = cursor.fetchone()
return dict(row) if row else None
@staticmethod
def verify_anomaly(
anomaly_id: int,
verdict: str,
notes: Optional[str] = None,
assigned_to: Optional[str] = None
) -> bool:
"""Verify an anomaly with human review."""
with get_db() as conn:
cursor = conn.cursor()
status_map = {
"confirmed": "acknowledged",
"false_positive": "false_positive",
"uncertain": "open"
}
status = status_map.get(verdict, "open")
cursor.execute("""
UPDATE detected_anomalies
SET verified_by_human = TRUE,
human_verdict = ?,
human_notes = ?,
status = ?,
assigned_to = ?,
updated_at = ?
WHERE id = ?
""", (verdict, notes, status, assigned_to, datetime.now(), anomaly_id))
return cursor.rowcount > 0
@staticmethod
def save_capture(capture_data: Dict) -> int:
"""Save image capture to database."""
with get_db() as conn:
cursor = conn.cursor()
cursor.execute("""
INSERT INTO atm_captures (
atm_id, capture_timestamp, camera_angle, image_path,
image_hash, file_size_bytes, width_pixels, height_pixels,
lighting_condition, weather_condition, blur_score, quality_score,
metadata
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
capture_data.get("atm_id"),
capture_data.get("capture_timestamp", datetime.now()),
capture_data.get("camera_angle", "front"),
capture_data.get("image_path"),
capture_data.get("image_hash"),
capture_data.get("file_size_bytes"),
capture_data.get("width_pixels"),
capture_data.get("height_pixels"),
capture_data.get("lighting_condition", "indoor"),
capture_data.get("weather_condition", "clear"),
capture_data.get("blur_score"),
capture_data.get("quality_score"),
json.dumps(capture_data.get("metadata", {}))
))
return cursor.lastrowid
@staticmethod
def save_anomaly(anomaly_data: Dict) -> int:
"""Save detected anomaly to database."""
with get_db() as conn:
cursor = conn.cursor()
# Get anomaly type ID
cursor.execute(
"SELECT id FROM anomaly_types WHERE type_code = ?",
(anomaly_data.get("anomaly_type"),)
)
type_row = cursor.fetchone()
if not type_row:
# Default to physical_damage if not found
cursor.execute(
"SELECT id FROM anomaly_types WHERE type_code = 'physical_damage'"
)
type_row = cursor.fetchone()
type_id = type_row["id"] if type_row else 1
cursor.execute("""
INSERT INTO detected_anomalies (
capture_id, anomaly_type_id, confidence_score,
bounding_box, severity_score, model_version,
status, priority
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (
anomaly_data.get("capture_id"),
type_id,
anomaly_data.get("confidence_score", 0.0),
json.dumps(anomaly_data.get("bounding_box", {})),
anomaly_data.get("severity_score", 0.0),
anomaly_data.get("model_version", "unknown"),
anomaly_data.get("status", "open"),
anomaly_data.get("priority", 1)
))
return cursor.lastrowid
@staticmethod
def get_all_atms(status: Optional[str] = None) -> List[Dict]:
"""Get all ATM locations."""
with get_db() as conn:
cursor = conn.cursor()
query = "SELECT * FROM atm_locations"
params = []
if status:
query += " WHERE status = ?"
params.append(status)
query += " ORDER BY created_at DESC"
cursor.execute(query, params)
rows = cursor.fetchall()
return [dict(row) for row in rows]
@staticmethod
def get_recent_anomalies(limit: int = 20) -> List[Dict]:
"""Get recent anomalies across all ATMs."""
with get_db() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT d.*, c.atm_id, c.capture_timestamp, c.image_path,
c.camera_angle, t.type_name, t.severity_level as type_severity,
t.category, t.requires_immediate_action
FROM detected_anomalies d
JOIN atm_captures c ON d.capture_id = c.id
JOIN anomaly_types t ON d.anomaly_type_id = t.id
WHERE d.status != 'false_positive'
ORDER BY d.created_at DESC
LIMIT ?
""", (limit,))
rows = cursor.fetchall()
return [dict(row) for row in rows]
@staticmethod
def create_alert(alert_data: Dict) -> int:
"""Create alert record."""
with get_db() as conn:
cursor = conn.cursor()
cursor.execute("""
INSERT INTO alerts (anomaly_id, alert_type, recipient, status)
VALUES (?, ?, ?, ?)
""", (
alert_data.get("anomaly_id"),
alert_data.get("alert_type", "dashboard"),
alert_data.get("recipient"),
alert_data.get("status", "pending")
))
return cursor.lastrowid
@staticmethod
def get_stats() -> Dict:
"""Get dashboard statistics."""
with get_db() as conn:
cursor = conn.cursor()
# Total ATMs
cursor.execute("SELECT COUNT(*) as count FROM atm_locations")
total_atms = cursor.fetchone()["count"]
# Active ATMs
cursor.execute("SELECT COUNT(*) as count FROM atm_locations WHERE status = 'active'")
active_atms = cursor.fetchone()["count"]
# Total anomalies
cursor.execute("SELECT COUNT(*) as count FROM detected_anomalies")
total_anomalies = cursor.fetchone()["count"]
# Open anomalies
cursor.execute("""
SELECT COUNT(*) as count FROM detected_anomalies
WHERE status IN ('open', 'acknowledged')
""")
open_anomalies = cursor.fetchone()["count"]
# Anomalies in last 24h
cursor.execute("""
SELECT COUNT(*) as count FROM detected_anomalies
WHERE created_at >= datetime('now', '-1 day')
""")
anomalies_24h = cursor.fetchone()["count"]
# Critical anomalies (severity >= 4)
cursor.execute("""
SELECT COUNT(*) as count FROM detected_anomalies d
JOIN anomaly_types t ON d.anomaly_type_id = t.id
WHERE t.severity_level >= 4 AND d.status != 'false_positive'
""")
critical_anomalies = cursor.fetchone()["count"]
# Anomalies by type
cursor.execute("""
SELECT t.type_name, COUNT(*) as count
FROM detected_anomalies d
JOIN anomaly_types t ON d.anomaly_type_id = t.id
GROUP BY t.type_name
ORDER BY count DESC
""")
anomalies_by_type = [dict(row) for row in cursor.fetchall()]
return {
"total_atms": total_atms,
"active_atms": active_atms,
"total_anomalies": total_anomalies,
"open_anomalies": open_anomalies,
"anomalies_24h": anomalies_24h,
"critical_anomalies": critical_anomalies,
"anomalies_by_type": anomalies_by_type
}
+295
View File
@@ -0,0 +1,295 @@
"""
ATM Anomaly Detection API
FastAPI server with WebSocket support
"""
import os
import sys
import json
import asyncio
from pathlib import Path
from datetime import datetime
from typing import List, Optional
from fastapi import FastAPI, File, UploadFile, WebSocket, WebSocketDisconnect, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, HTMLResponse
import uvicorn
# Add parent to path
sys.path.append(str(Path(__file__).parent.parent))
sys.path.append(str(Path(__file__).parent.parent / "models"))
from anomaly_detector import ATMAnomalyDetector
app = FastAPI(
title="ATM Anomaly Detection API",
description="AI-powered anomaly detection for ATM networks",
version="1.0.0"
)
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Global state
detector: Optional[ATMAnomalyDetector] = None
active_connections: List[WebSocket] = []
@app.on_event("startup")
async def startup():
"""Load model on startup."""
global detector
model_path = os.getenv("MODEL_PATH", "models/checkpoints/best.pt")
if Path(model_path).exists():
detector = ATMAnomalyDetector(model_path=model_path)
else:
print(f"Warning: Model not found at {model_path}, using placeholder")
detector = ATMAnomalyDetector()
@app.get("/health")
async def health():
"""Health check endpoint."""
return {
"status": "healthy",
"model_loaded": detector is not None,
"timestamp": datetime.now().isoformat()
}
@app.post("/predict")
async def predict(
image: UploadFile = File(...),
atm_id: Optional[str] = None,
camera_angle: Optional[str] = None
):
"""
Run anomaly detection on single image.
Args:
image: Image file
atm_id: ATM identifier
camera_angle: Camera angle (front, side, etc.)
Returns:
Detection results
"""
if not detector:
raise HTTPException(status_code=503, detail="Model not loaded")
# Validate image
if not image.content_type.startswith("image/"):
raise HTTPException(status_code=400, detail="File must be an image")
# Save uploaded file
upload_dir = "data/uploads"
os.makedirs(upload_dir, exist_ok=True)
file_path = os.path.join(upload_dir, image.filename)
with open(file_path, "wb") as f:
content = await image.read()
f.write(content)
# Run detection
results = detector.predict(file_path)
# Add metadata
result = {
"success": True,
"atm_id": atm_id,
"camera_angle": camera_angle,
"filename": image.filename,
"timestamp": datetime.now().isoformat(),
"detections": results,
"summary": {
"total_anomalies": len(results),
"max_severity": max([d.get("severity", 0) for d in results], default=0),
"requires_action": any(d.get("requires_action", False) for d in results),
"anomaly_types": list(set(d.get("class_name", "unknown") for d in results))
}
}
# Broadcast alert if critical
if result["summary"]["requires_action"]:
await broadcast_alert({
"type": "alert",
"severity": result["summary"]["max_severity"],
"atm_id": atm_id,
"message": f"Critical anomaly detected on {atm_id or 'unknown ATM'}",
"timestamp": result["timestamp"]
})
return result
@app.post("/predict/batch")
async def predict_batch(images: List[UploadFile] = File(...)):
"""
Run anomaly detection on multiple images.
Args:
images: List of image files
Returns:
Batch detection results
"""
if not detector:
raise HTTPException(status_code=503, detail="Model not loaded")
results = []
for image in images:
if not image.content_type.startswith("image/"):
continue
upload_dir = "data/uploads"
os.makedirs(upload_dir, exist_ok=True)
file_path = os.path.join(upload_dir, image.filename)
with open(file_path, "wb") as f:
content = await image.read()
f.write(content)
detections = detector.predict(file_path)
results.append({
"filename": image.filename,
"detections": detections,
"summary": {
"total_anomalies": len(detections),
"max_severity": max([d.get("severity", 0) for d in detections], default=0)
}
})
return {
"success": True,
"total_images": len(results),
"results": results
}
@app.get("/atm/{atm_id}/status")
async def atm_status(atm_id: str):
"""
Get current status for ATM.
Args:
atm_id: ATM identifier
Returns:
ATM status
"""
# Placeholder - would query database
return {
"atm_id": atm_id,
"status": "active",
"last_check": datetime.now().isoformat(),
"open_anomalies": 0,
"max_severity": 0
}
@app.get("/atm/{atm_id}/anomalies")
async def atm_anomalies(
atm_id: str,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
limit: int = 100
):
"""
Get anomaly history for ATM.
Args:
atm_id: ATM identifier
start_date: Filter start date (ISO format)
end_date: Filter end date (ISO format)
limit: Maximum results
Returns:
List of anomalies
"""
# Placeholder - would query database
return {
"atm_id": atm_id,
"period": {"start": start_date, "end": end_date},
"total_anomalies": 0,
"anomalies": []
}
@app.post("/anomalies/{anomaly_id}/verify")
async def verify_anomaly(anomaly_id: int, verdict: str, notes: Optional[str] = None):
"""
Verify anomaly detection manually.
Args:
anomaly_id: Anomaly identifier
verdict: confirmed, false_positive, or uncertain
notes: Optional verification notes
Returns:
Verification result
"""
# Placeholder - would update database
return {
"success": True,
"anomaly_id": anomaly_id,
"verdict": verdict,
"verified_at": datetime.now().isoformat()
}
@app.websocket("/ws/alerts")
async def websocket_alerts(websocket: WebSocket):
"""
WebSocket endpoint for real-time alerts.
Clients connect here to receive live anomaly alerts.
"""
await websocket.accept()
active_connections.append(websocket)
try:
while True:
# Keep connection alive, wait for client messages
data = await websocket.receive_text()
message = json.loads(data)
# Handle subscription messages
if message.get("action") == "subscribe":
await websocket.send_json({
"type": "subscribed",
"message": "Subscribed to alerts"
})
except WebSocketDisconnect:
active_connections.remove(websocket)
except Exception as e:
print(f"WebSocket error: {e}")
if websocket in active_connections:
active_connections.remove(websocket)
async def broadcast_alert(alert: dict):
"""Broadcast alert to all connected WebSocket clients."""
disconnected = []
for conn in active_connections:
try:
await conn.send_json(alert)
except:
disconnected.append(conn)
# Clean up disconnected clients
for conn in disconnected:
if conn in active_connections:
active_connections.remove(conn)
@app.get("/", response_class=HTMLResponse)
async def dashboard():
"""Serve dashboard HTML."""
dashboard_path = Path(__file__).parent.parent / "dashboard" / "index.html"
if dashboard_path.exists():
return dashboard_path.read_text()
return "<h1>ATM Anomaly Detection API</h1><p>Dashboard not found</p>"
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)