Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .jules/bolt.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,7 @@
## 2025-05-15 - [Optimization with Aggregation Pipelines]
**Learning:** This codebase frequently uses N+1 query patterns in service methods (e.g., looping through results and calling find_one). These can be significantly optimized using MongoDB aggregation pipelines with $lookup. However, mongomock (used in the test suite) has limited support for advanced $lookup features like 'let' and sub-pipelines.
**Action:** Use simple $lookup (localField/foreignField) when possible to maintain test compatibility, and handle any additional filtering or data processing in Python if necessary, which still provides a massive performance win by reducing database roundtrips to 1.

## 2025-02-22 - [Optimization using $facet]
**Learning:** For reporting/dashboarding features that require multiple groupings (e.g., by category, location, and status), MongoDB's `$facet` stage is highly effective at reducing database roundtrips and network traffic. While `mongomock` may show higher execution time locally due to Python overhead, the real-world performance gain from reduced serialization and network transfer is substantial for large collections.
**Action:** Use `$facet` for multi-grouping statistics to keep logic in the database layer and return only the final results.
111 changes: 78 additions & 33 deletions app/services/consumable_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,51 +204,96 @@ def adjust_stock(barcode: str, quantity_change: int, reason: str) -> Tuple[bool,

@staticmethod
def get_statistics() -> Dict[str, Any]:
"""Holt Statistiken für Verbrauchsmaterialien"""
"""
Holt Statistiken für Verbrauchsmaterialien
OPTIMIERT: Verwendet MongoDB Aggregation Pipeline für bessere Performance (Bolt ⚡)
Reduziert Datenbank-Roundtrips und Speicherverbrauch durch serverseitige Berechnung.
"""
try:
all_consumables = ConsumableService.get_all_consumables()
# Aggregation-Pipeline zur Berechnung aller Statistiken in einem Aufruf
pipeline = [
{'$match': {'deleted': {'$ne': True}}},
{
'$facet': {
'categories': [
{'$group': {'_id': {'$ifNull': ['$category', 'Keine Kategorie']}, 'count': {'$sum': 1}}},
{'$sort': {'count': -1}}
],
'locations': [
{'$group': {'_id': {'$ifNull': ['$location', 'Kein Standort']}, 'count': {'$sum': 1}}},
{'$sort': {'count': -1}}
],
'stock_levels': [
{
'$group': {
'_id': None,
'total': {'$sum': 1},
'sufficient': {
'$sum': {
'$cond': [{'$gte': ['$quantity', '$min_quantity']}, 1, 0]
}
},
'warning': {
'$sum': {
'$cond': [
{'$and': [{'$lt': ['$quantity', '$min_quantity']}, {'$gt': ['$quantity', 0]}]},
1, 0
]
}
},
'critical': {
'$sum': {
'$cond': [{'$eq': ['$quantity', 0]}, 1, 0]
}
}
}
}
]
}
}
]

results = list(mongodb.aggregate('consumables', pipeline))
if not results:
return ConsumableService._get_fallback_statistics()

result = results[0]

# Formatieren der Ergebnisse für die UI-Kompatibilität
stats = {
'total_consumables': len(all_consumables),
'categories': {},
'locations': {},
'total_consumables': 0,
'categories': {item['_id']: item['count'] for item in result.get('categories', [])},
'locations': {item['_id']: item['count'] for item in result.get('locations', [])},
'stock_levels': {
'sufficient': 0,
'warning': 0,
'critical': 0
}
}

# Kategorie- und Standort-Statistiken
for consumable in all_consumables:
category = consumable.get('category', 'Keine Kategorie')
stats['categories'][category] = stats['categories'].get(category, 0) + 1

location = consumable.get('location', 'Kein Standort')
stats['locations'][location] = stats['locations'].get(location, 0) + 1
if result.get('stock_levels'):
sl = result['stock_levels'][0]
stats['total_consumables'] = sl.get('total', 0)
stats['stock_levels']['sufficient'] = sl.get('sufficient', 0)
stats['stock_levels']['warning'] = sl.get('warning', 0)
stats['stock_levels']['critical'] = sl.get('critical', 0)

# Bestandslevel
quantity = consumable.get('quantity', 0)
min_quantity = consumable.get('min_quantity', 0)

if quantity >= min_quantity:
stats['stock_levels']['sufficient'] += 1
elif quantity > 0:
stats['stock_levels']['warning'] += 1
else:
stats['stock_levels']['critical'] += 1

return stats

except Exception as e:
logger.error(f"Fehler beim Laden der Verbrauchsmaterial-Statistiken: [Interner Fehler]")
return {
'total_consumables': 0,
'categories': {},
'locations': {},
'stock_levels': {
'sufficient': 0,
'warning': 0,
'critical': 0
}
}
return ConsumableService._get_fallback_statistics()

@staticmethod
def _get_fallback_statistics() -> Dict[str, Any]:
"""Gibt leere Fallback-Statistiken zurück"""
return {
'total_consumables': 0,
'categories': {},
'locations': {},
'stock_levels': {
'sufficient': 0,
'warning': 0,
'critical': 0
}
}
69 changes: 69 additions & 0 deletions tests/unit/test_consumable_service_stats.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import pytest
from unittest.mock import patch
from app.services.consumable_service import ConsumableService
import mongomock

class TestConsumableServiceStats:
@pytest.fixture
def mock_db(self):
client = mongomock.MongoClient()
db = client.scandy
return db

def test_get_statistics_empty(self, mock_db):
"""Testet Statistiken bei leerer Datenbank"""
with patch('app.services.consumable_service.mongodb') as mock_mongodb:
# Simuliere das Verhalten von mongodb.aggregate
mock_mongodb.aggregate.side_effect = lambda coll, pipe: list(mock_db[coll].aggregate(pipe))

stats = ConsumableService.get_statistics()

assert stats['total_consumables'] == 0
assert stats['categories'] == {}
assert stats['locations'] == {}
assert stats['stock_levels'] == {
'sufficient': 0,
'warning': 0,
'critical': 0
}

def test_get_statistics_with_data(self, mock_db):
"""Testet Statistiken mit verschiedenen Testdaten"""
# Testdaten einfügen
mock_db.consumables.insert_many([
{'name': 'C1', 'category': 'Cat1', 'location': 'Loc1', 'quantity': 10, 'min_quantity': 5, 'deleted': False},
{'name': 'C2', 'category': 'Cat1', 'location': 'Loc2', 'quantity': 3, 'min_quantity': 5, 'deleted': False},
{'name': 'C3', 'category': 'Cat2', 'location': 'Loc1', 'quantity': 0, 'min_quantity': 5, 'deleted': False},
{'name': 'C4', 'category': 'Cat2', 'location': 'Loc2', 'quantity': 10, 'min_quantity': 5, 'deleted': True}, # Gelöscht, sollte ignoriert werden
])

with patch('app.services.consumable_service.mongodb') as mock_mongodb:
mock_mongodb.aggregate.side_effect = lambda coll, pipe: list(mock_db[coll].aggregate(pipe))

stats = ConsumableService.get_statistics()

assert stats['total_consumables'] == 3
assert stats['categories'] == {'Cat1': 2, 'Cat2': 1}
assert stats['locations'] == {'Loc1': 2, 'Loc2': 1}
assert stats['stock_levels'] == {
'sufficient': 1, # C1: 10 >= 5
'warning': 1, # C2: 3 < 5 und > 0
'critical': 1 # C3: 0
}

def test_get_statistics_missing_fields(self, mock_db):
"""Testet Statistiken bei fehlenden Feldern (Kategorie/Standort)"""
# Daten mit fehlenden Feldern
mock_db.consumables.insert_many([
{'name': 'C1', 'quantity': 10, 'min_quantity': 5, 'deleted': False},
])

with patch('app.services.consumable_service.mongodb') as mock_mongodb:
mock_mongodb.aggregate.side_effect = lambda coll, pipe: list(mock_db[coll].aggregate(pipe))

stats = ConsumableService.get_statistics()

assert stats['total_consumables'] == 1
assert stats['categories'] == {'Keine Kategorie': 1}
assert stats['locations'] == {'Kein Standort': 1}
assert stats['stock_levels']['sufficient'] == 1