fix telegram

This commit is contained in:
Claude Agent
2026-02-23 15:12:33 +00:00
parent 6c78fec8a7
commit 8bc567a9c5
426 changed files with 112478 additions and 1 deletions

View File

@@ -0,0 +1,66 @@
"""
Cache module for ROA2WEB
Provides hybrid two-tier caching (Memory L1 + SQLite L2)
with performance tracking and event-based invalidation.
Usage:
# Initialize cache at app startup
from app.cache import init_cache
from app.cache.config import CacheConfig
config = CacheConfig.from_env()
await init_cache(config)
# Use @cached decorator in services
from app.cache.decorators import cached
@cached(cache_type='dashboard_summary', key_params=['company', 'username'])
async def get_complete_summary(company: str, username: str):
# ... Oracle query logic ...
# Get cache manager for manual operations
from app.cache import get_cache
cache = get_cache()
await cache.invalidate(company_id=123)
"""
from .config import CacheConfig
from .cache_manager import (
init_cache,
get_cache,
close_cache,
CacheManager
)
from .decorators import cached
from .event_monitor import (
init_event_monitor,
get_event_monitor,
toggle_event_monitor,
preload_all_schema_mappings
)
from .benchmarks import run_baseline_benchmarks
__all__ = [
# Configuration
'CacheConfig',
# Cache Manager
'init_cache',
'get_cache',
'close_cache',
'CacheManager',
# Decorators
'cached',
# Event Monitor
'init_event_monitor',
'get_event_monitor',
'toggle_event_monitor',
'preload_all_schema_mappings',
# Benchmarks
'run_baseline_benchmarks',
]

View File

@@ -0,0 +1,269 @@
"""
Baseline performance benchmarking
Runs at startup to establish baseline Oracle query times
Used for calculating "time saved" by cache
"""
import time
import logging
from typing import Dict
logger = logging.getLogger(__name__)
async def run_baseline_benchmarks() -> Dict[str, float]:
"""
Run baseline benchmarks for Oracle queries (without cache)
Measures typical query times to establish performance baselines
These are used to calculate time saved when cache hits occur
NOTE: This implementation provides a framework. Actual benchmark
implementations need access to Oracle services and sample data.
Returns:
Dictionary mapping cache_type to average query time (ms)
"""
from .cache_manager import get_cache
cache = get_cache()
if not cache:
logger.warning("Cache not initialized - skipping benchmarks")
return {}
logger.info("Starting baseline performance benchmarks...")
benchmarks = {}
try:
# Benchmark: Schema lookup
logger.info("Benchmarking: schema lookup")
schema_times = await _benchmark_schema_lookup()
if schema_times:
avg_schema = sum(schema_times) / len(schema_times)
benchmarks['schema'] = avg_schema
await cache.sqlite.set_benchmark('schema', avg_schema, len(schema_times))
logger.info(f" Schema lookup: {avg_schema:.2f}ms (avg of {len(schema_times)} samples)")
# Benchmark: Companies list
logger.info("Benchmarking: companies list")
companies_time = await _benchmark_companies_list()
if companies_time:
benchmarks['companies'] = companies_time
await cache.sqlite.set_benchmark('companies', companies_time, 1)
logger.info(f" Companies list: {companies_time:.2f}ms")
# Benchmark: Dashboard summary
logger.info("Benchmarking: dashboard summary")
dashboard_time = await _benchmark_dashboard_summary()
if dashboard_time:
benchmarks['dashboard_summary'] = dashboard_time
await cache.sqlite.set_benchmark('dashboard_summary', dashboard_time, 1)
logger.info(f" Dashboard summary: {dashboard_time:.2f}ms")
# Benchmark: Dashboard trends
logger.info("Benchmarking: dashboard trends")
trends_time = await _benchmark_dashboard_trends()
if trends_time:
benchmarks['dashboard_trends'] = trends_time
await cache.sqlite.set_benchmark('dashboard_trends', trends_time, 1)
logger.info(f" Dashboard trends: {trends_time:.2f}ms")
# Benchmark: Invoices
logger.info("Benchmarking: invoices")
invoices_time = await _benchmark_invoices()
if invoices_time:
benchmarks['invoices'] = invoices_time
await cache.sqlite.set_benchmark('invoices', invoices_time, 1)
logger.info(f" Invoices: {invoices_time:.2f}ms")
# Benchmark: Treasury
logger.info("Benchmarking: treasury")
treasury_time = await _benchmark_treasury()
if treasury_time:
benchmarks['treasury'] = treasury_time
await cache.sqlite.set_benchmark('treasury', treasury_time, 1)
logger.info(f" Treasury: {treasury_time:.2f}ms")
logger.info(f"Baseline benchmarks completed: {len(benchmarks)} types measured")
return benchmarks
except Exception as e:
logger.error(f"Benchmark error: {e}", exc_info=True)
return benchmarks
async def _benchmark_schema_lookup() -> list:
"""
Benchmark schema lookup queries
Returns:
List of query times (ms) for multiple samples
"""
try:
# Import here to avoid circular dependency
import sys
import os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../../..')))
from shared.database.oracle_pool import oracle_pool
# Get sample company IDs to test
sample_companies = await _get_sample_company_ids(limit=10)
if not sample_companies:
logger.warning("No sample companies found for schema benchmark")
return []
times = []
for company_id in sample_companies:
start = time.time()
async with oracle_pool.get_connection() as connection:
with connection.cursor() as cursor:
cursor.execute("""
SELECT schema
FROM CONTAFIN_ORACLE.v_nom_firme
WHERE id_firma = :id
""", {'id': company_id})
cursor.fetchone()
elapsed_ms = (time.time() - start) * 1000
times.append(elapsed_ms)
return times
except Exception as e:
logger.error(f"Schema benchmark error: {e}")
return []
async def _benchmark_companies_list() -> float:
"""
Benchmark companies list query
Returns:
Query time (ms)
"""
try:
import sys
import os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../../..')))
from shared.database.oracle_pool import oracle_pool
# Get sample username
sample_user = await _get_sample_username()
if not sample_user:
return 0
start = time.time()
async with oracle_pool.get_connection() as connection:
with connection.cursor() as cursor:
cursor.execute("""
SELECT nf.id_firma, nf.denumire, nf.cui, nf.schema
FROM CONTAFIN_ORACLE.v_nom_firme nf
JOIN CONTAFIN_ORACLE.vdef_util_firme uf ON nf.id_firma = uf.id_firma
WHERE uf.nume_utilizator = :username
ORDER BY nf.denumire
""", {'username': sample_user})
cursor.fetchall()
elapsed_ms = (time.time() - start) * 1000
return elapsed_ms
except Exception as e:
logger.error(f"Companies benchmark error: {e}")
return 0
async def _benchmark_dashboard_summary() -> float:
"""
Benchmark dashboard summary query
Returns:
Query time (ms)
"""
try:
# This requires access to DashboardService
# For now, return estimated value
logger.warning("Dashboard summary benchmark not implemented - using estimate")
return 250.0 # Estimated 250ms based on plan
except Exception as e:
logger.error(f"Dashboard benchmark error: {e}")
return 0
async def _benchmark_dashboard_trends() -> float:
"""Benchmark dashboard trends query"""
try:
logger.warning("Dashboard trends benchmark not implemented - using estimate")
return 400.0 # Estimated 400ms
except Exception as e:
logger.error(f"Trends benchmark error: {e}")
return 0
async def _benchmark_invoices() -> float:
"""Benchmark invoices query"""
try:
logger.warning("Invoices benchmark not implemented - using estimate")
return 180.0 # Estimated 180ms
except Exception as e:
logger.error(f"Invoices benchmark error: {e}")
return 0
async def _benchmark_treasury() -> float:
"""Benchmark treasury query"""
try:
logger.warning("Treasury benchmark not implemented - using estimate")
return 250.0 # Estimated 250ms
except Exception as e:
logger.error(f"Treasury benchmark error: {e}")
return 0
# Helper functions
async def _get_sample_company_ids(limit: int = 10) -> list:
"""Get sample company IDs for testing"""
try:
import sys
import os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../../..')))
from shared.database.oracle_pool import oracle_pool
async with oracle_pool.get_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(f"""
SELECT id_firma
FROM CONTAFIN_ORACLE.v_nom_firme
WHERE ROWNUM <= {limit}
""")
results = cursor.fetchall()
return [row[0] for row in results]
except Exception as e:
logger.error(f"Get sample companies error: {e}")
return []
async def _get_sample_username() -> str:
"""Get sample username for testing"""
try:
import sys
import os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../../..')))
from shared.database.oracle_pool import oracle_pool
async with oracle_pool.get_connection() as connection:
with connection.cursor() as cursor:
cursor.execute("""
SELECT nume_utilizator
FROM CONTAFIN_ORACLE.vdef_util_firme
WHERE ROWNUM <= 1
""")
result = cursor.fetchone()
return result[0] if result else "admin"
except Exception as e:
logger.error(f"Get sample username error: {e}")
return "admin"

View File

@@ -0,0 +1,339 @@
"""
Cache Manager - Orchestrator for hybrid L1 + L2 cache
"""
import logging
import asyncio
from typing import Any, Optional
from .config import CacheConfig
from .memory_cache import MemoryCache
from .sqlite_cache import SQLiteCache
logger = logging.getLogger(__name__)
class CacheManager:
"""
Hybrid cache manager (Memory L1 + SQLite L2)
Features:
- Two-tier caching: fast memory + persistent SQLite
- Automatic TTL management per cache type
- Performance tracking and benchmarking
- Per-user cache enable/disable
- Global cache toggle
"""
def __init__(self, config: CacheConfig):
"""
Initialize cache manager
Args:
config: Cache configuration
"""
self.config = config
self.memory = MemoryCache(max_size=config.memory_max_size)
self.sqlite = SQLiteCache(db_path=config.sqlite_path)
self._cleanup_task: Optional[asyncio.Task] = None
self._initialized = False
self._last_cache_source: Optional[str] = None # Track last cache source (L1/L2)
async def init(self):
"""Initialize cache system"""
if self._initialized:
logger.warning("Cache already initialized")
return
# Initialize SQLite database schema
await self.sqlite.init_db()
# Start cleanup task
if self.config.enabled:
self._cleanup_task = asyncio.create_task(self._cleanup_loop())
self._initialized = True
logger.info(f"Cache initialized: type={self.config.cache_type}, enabled={self.config.enabled}")
async def close(self):
"""Close cache and cleanup"""
if self._cleanup_task:
self._cleanup_task.cancel()
try:
await self._cleanup_task
except asyncio.CancelledError:
pass
# Close SQLite connection manager
if hasattr(self.sqlite, 'close'):
await self.sqlite.close()
logger.info("Cache closed")
async def get(self, key: str, cache_type: str) -> Optional[Any]:
"""
Get value from cache (L1 → L2)
Args:
key: Cache key
cache_type: Type of cache entry
Returns:
Cached value or None if not found
"""
if not self.config.enabled:
self._last_cache_source = None
return None
# Try L1 (Memory) first
value = await self.memory.get(key)
if value is not None:
self._last_cache_source = "L1"
logger.debug(f"Cache HIT L1 (memory): {key}")
return value
# Try L2 (SQLite)
value = await self.sqlite.get(key)
if value is not None:
self._last_cache_source = "L2"
logger.debug(f"Cache HIT L2 (sqlite): {key}")
# Populate L1 for next time
ttl = self.config.get_ttl_for_type(cache_type)
await self.memory.set(key, value, ttl)
return value
# Cache MISS
self._last_cache_source = None
logger.debug(f"Cache MISS: {key}")
return None
def get_last_cache_source(self) -> Optional[str]:
"""
Get source of last cache hit (L1/L2/None)
Returns:
"L1" if last hit was from memory cache
"L2" if last hit was from SQLite cache
None if last call was a cache miss or cache disabled
"""
return self._last_cache_source
async def set(self, key: str, value: Any, cache_type: str, company_id: Optional[int] = None,
ttl: Optional[int] = None):
"""
Set value in cache (both L1 and L2)
Args:
key: Cache key
value: Value to cache
cache_type: Type of cache entry
company_id: Company ID (for company-specific caches)
ttl: Time to live (uses default for cache_type if not provided)
"""
if not self.config.enabled:
return
if ttl is None:
ttl = self.config.get_ttl_for_type(cache_type)
# Store in both L1 and L2
await self.memory.set(key, value, ttl)
await self.sqlite.set(key, value, cache_type, company_id, ttl)
logger.debug(f"Cache SET (L1 + L2): {key} (TTL: {ttl}s)")
async def delete(self, key: str):
"""Delete entry from both L1 and L2"""
await self.memory.delete(key)
await self.sqlite.delete(key)
logger.debug(f"Cache deleted: {key}")
async def invalidate(self, company_id: Optional[int] = None, cache_type: Optional[str] = None):
"""
Invalidate cache entries
Args:
company_id: If provided, clear only this company's cache
cache_type: If provided, clear only this cache type
"""
if company_id is not None and cache_type is not None:
# Clear specific company + type
from .keys import generate_key_pattern
pattern = generate_key_pattern(cache_type, company_id)
await self.memory.clear_by_pattern(pattern)
# SQLite: clear by company + type (needs query)
# For now, just clear by company
await self.sqlite.clear_by_company(company_id)
logger.info(f"Cache invalidated: company={company_id}, type={cache_type}")
elif company_id is not None:
# Clear all for company
from .keys import generate_key_pattern
# Clear all types for this company (pattern match all)
# Memory: need to iterate and match company_id in key
# For simplicity, clear by pattern prefix
await self.memory.clear() # TODO: improve pattern matching
await self.sqlite.clear_by_company(company_id)
logger.info(f"Cache invalidated: company={company_id}")
elif cache_type is not None:
# Clear all for type
from .keys import generate_key_pattern
pattern = generate_key_pattern(cache_type)
await self.memory.clear_by_pattern(pattern)
await self.sqlite.clear_by_type(cache_type)
logger.info(f"Cache invalidated: type={cache_type}")
else:
# Clear everything
await self.memory.clear()
await self.sqlite.clear()
logger.info("Cache invalidated: ALL")
async def is_enabled_for_user(self, username: Optional[str]) -> bool:
"""
Check if cache is enabled for specific user
Args:
username: Username to check
Returns:
True if cache enabled for user, False otherwise
"""
if not self.config.enabled:
return False
if username is None:
return True
# Check per-user setting
return await self.sqlite.get_user_cache_enabled(username)
async def set_user_cache_enabled(self, username: str, enabled: bool):
"""Set user cache enabled/disabled"""
await self.sqlite.set_user_cache_enabled(username, enabled)
logger.info(f"User cache setting: {username} -> {enabled}")
# Benchmarking
async def get_benchmark(self, cache_type: str) -> Optional[float]:
"""Get average benchmark time for cache type"""
return await self.sqlite.get_benchmark(cache_type)
async def update_benchmark(self, cache_type: str, new_time_ms: float):
"""
Update benchmark with new measurement (exponential moving average)
Args:
cache_type: Type of cache
new_time_ms: New measured time in milliseconds
"""
current_avg = await self.sqlite.get_benchmark(cache_type)
if current_avg is None:
# First measurement
new_avg = new_time_ms
sample_count = 1
else:
# Exponential moving average (alpha = 0.1)
new_avg = 0.9 * current_avg + 0.1 * new_time_ms
# Get current sample count (TODO: retrieve from DB)
sample_count = 1 # Simplified for now
await self.sqlite.set_benchmark(cache_type, new_avg, sample_count)
logger.debug(f"Benchmark updated: {cache_type} -> {new_avg:.2f}ms")
# Performance Tracking
async def track_performance(self, cache_type: str, is_hit: bool, actual_time_ms: float,
time_saved_ms: Optional[float] = None,
estimated_oracle_time_ms: Optional[float] = None,
company_id: Optional[int] = None,
username: Optional[str] = None):
"""
Track performance metric
Args:
cache_type: Type of cache
is_hit: True if cache hit, False if cache miss
actual_time_ms: Actual response time
time_saved_ms: Time saved by cache (for hits)
estimated_oracle_time_ms: Estimated Oracle time (for hits)
company_id: Company ID
username: Username
"""
if not self.config.track_performance:
return
await self.sqlite.log_performance(
cache_type=cache_type,
company_id=company_id,
cache_hit=is_hit,
response_time_ms=actual_time_ms,
estimated_oracle_time_ms=estimated_oracle_time_ms,
time_saved_ms=time_saved_ms,
username=username
)
# Statistics
async def get_stats(self) -> dict:
"""Get comprehensive cache statistics"""
memory_stats = self.memory.get_stats()
sqlite_stats = await self.sqlite.get_stats()
return {
'enabled': self.config.enabled,
'cache_type': self.config.cache_type,
'memory': memory_stats,
'sqlite': sqlite_stats,
}
# Cleanup
async def _cleanup_loop(self):
"""Background task to cleanup expired entries"""
while True:
try:
await asyncio.sleep(self.config.cleanup_interval)
await self._cleanup_expired()
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Cleanup error: {e}", exc_info=True)
async def _cleanup_expired(self):
"""Remove expired entries from both caches"""
logger.info("Running cache cleanup...")
await self.memory.cleanup_expired()
await self.sqlite.cleanup_expired()
logger.info("Cache cleanup completed")
# Global cache manager instance
_cache_manager: Optional[CacheManager] = None
async def init_cache(config: CacheConfig):
"""Initialize global cache manager"""
global _cache_manager
if _cache_manager is not None:
logger.warning("Cache already initialized")
return
_cache_manager = CacheManager(config)
await _cache_manager.init()
logger.info("Global cache manager initialized")
def get_cache() -> Optional[CacheManager]:
"""Get global cache manager instance"""
return _cache_manager
async def close_cache():
"""Close global cache manager"""
global _cache_manager
if _cache_manager is not None:
await _cache_manager.close()
_cache_manager = None

View File

@@ -0,0 +1,89 @@
"""
Cache configuration from environment variables
"""
import os
from dataclasses import dataclass
from typing import Optional
@dataclass
class CacheConfig:
"""Cache configuration loaded from environment variables"""
# Core Settings
enabled: bool
cache_type: str # 'hybrid', 'memory', 'sqlite', 'disabled'
sqlite_path: str
memory_max_size: int
default_ttl: int
# TTL per Cache Type (seconds)
ttl_schema: int
ttl_companies: int
ttl_dashboard_summary: int
ttl_dashboard_trends: int
ttl_invoices: int
ttl_invoices_summary: int
ttl_treasury: int
ttl_trial_balance: int
ttl_calendar_periods: int
# Maintenance
cleanup_interval: int
# Event-Based Invalidation
auto_invalidate_enabled: bool
check_interval: int
# Performance Tracking
track_performance: bool
benchmark_on_startup: bool
@classmethod
def from_env(cls) -> 'CacheConfig':
"""Load configuration from environment variables"""
return cls(
# Core Settings
enabled=os.getenv('CACHE_ENABLED', 'True').lower() == 'true',
cache_type=os.getenv('CACHE_TYPE', 'hybrid'),
sqlite_path=os.getenv('CACHE_SQLITE_PATH', './data/cache/roa2web_cache.db'),
memory_max_size=int(os.getenv('CACHE_MEMORY_MAX_SIZE', '1000')),
default_ttl=int(os.getenv('CACHE_DEFAULT_TTL', '900')),
# TTL per Cache Type
ttl_schema=int(os.getenv('CACHE_TTL_SCHEMA', '86400')),
ttl_companies=int(os.getenv('CACHE_TTL_COMPANIES', '1800')),
ttl_dashboard_summary=int(os.getenv('CACHE_TTL_DASHBOARD_SUMMARY', '1800')),
ttl_dashboard_trends=int(os.getenv('CACHE_TTL_DASHBOARD_TRENDS', '1800')),
ttl_invoices=int(os.getenv('CACHE_TTL_INVOICES', '600')),
ttl_invoices_summary=int(os.getenv('CACHE_TTL_INVOICES_SUMMARY', '900')),
ttl_treasury=int(os.getenv('CACHE_TTL_TREASURY', '600')),
ttl_trial_balance=int(os.getenv('CACHE_TTL_TRIAL_BALANCE', '600')),
ttl_calendar_periods=int(os.getenv('CACHE_TTL_CALENDAR_PERIODS', '3600')),
# Maintenance
cleanup_interval=int(os.getenv('CACHE_CLEANUP_INTERVAL', '3600')),
# Event-Based Invalidation
auto_invalidate_enabled=os.getenv('CACHE_AUTO_INVALIDATE', 'False').lower() == 'true',
check_interval=int(os.getenv('CACHE_CHECK_INTERVAL', '300')),
# Performance Tracking
track_performance=os.getenv('CACHE_TRACK_PERFORMANCE', 'True').lower() == 'true',
benchmark_on_startup=os.getenv('CACHE_BENCHMARK_ON_STARTUP', 'True').lower() == 'true',
)
def get_ttl_for_type(self, cache_type: str) -> int:
"""Get TTL for specific cache type"""
ttl_map = {
'schema': self.ttl_schema,
'companies': self.ttl_companies,
'dashboard_summary': self.ttl_dashboard_summary,
'dashboard_trends': self.ttl_dashboard_trends,
'invoices': self.ttl_invoices,
'invoices_summary': self.ttl_invoices_summary,
'treasury': self.ttl_treasury,
'trial_balance': self.ttl_trial_balance,
'calendar_periods': self.ttl_calendar_periods,
}
return ttl_map.get(cache_type, self.default_ttl)

View File

@@ -0,0 +1,285 @@
"""
Cache decorators for service methods
"""
import time
import logging
import sqlite3
import asyncio
from functools import wraps
from typing import Callable, Optional, List
from .cache_manager import get_cache
from .keys import generate_cache_key
logger = logging.getLogger(__name__)
# Retry configuration for SQLite locked database errors
SQLITE_MAX_RETRIES = 3
SQLITE_RETRY_BASE_DELAY = 0.1 # 100ms base delay, exponential backoff
def cached(cache_type: str, ttl: Optional[int] = None, key_params: Optional[List[str]] = None):
"""
Decorator for caching service method results with performance tracking
Usage:
@cached(cache_type='dashboard_summary', key_params=['company', 'username'])
async def get_complete_summary(company: str, username: str):
# ... Oracle query logic ...
Features:
- Automatic cache key generation from function parameters
- Performance timing (cache hit vs miss)
- Benchmark tracking for time saved calculation
- Per-user cache enable/disable
- Global cache toggle
- Transparent - zero changes to function logic
Args:
cache_type: Type of cache (used for TTL lookup and stats)
ttl: Optional custom TTL (overrides config default)
key_params: List of parameter names to include in cache key
Returns:
Decorated async function
"""
def decorator(func: Callable):
@wraps(func)
async def wrapper(*args, **kwargs):
start_time = time.time()
cache = get_cache()
# Extract username for per-user settings
username = _extract_username(args, kwargs, key_params)
# Check if cache is enabled (global + per-user)
cache_enabled = await cache.is_enabled_for_user(username) if cache else False
if not cache or not cache_enabled:
# Cache disabled - execute directly
result = await func(*args, **kwargs)
elapsed_ms = (time.time() - start_time) * 1000
# Set metadata in request.state if available (for API responses)
if 'request' in kwargs and hasattr(kwargs['request'], 'state'):
kwargs['request'].state.cache_hit = False
kwargs['request'].state.response_time_ms = elapsed_ms
kwargs['request'].state.cache_source = None
if cache and cache.config.track_performance:
await cache.track_performance(
cache_type=cache_type,
is_hit=False,
actual_time_ms=elapsed_ms,
username=username
)
return result
# Generate cache key from function parameters
cache_key = generate_cache_key(cache_type, key_params, args, kwargs)
# Try to get from cache with retry logic for SQLite locks
cached_value = None
for attempt in range(SQLITE_MAX_RETRIES):
try:
cached_value = await cache.get(cache_key, cache_type)
break
except sqlite3.OperationalError as e:
if "database is locked" in str(e) and attempt < SQLITE_MAX_RETRIES - 1:
delay = SQLITE_RETRY_BASE_DELAY * (attempt + 1)
logger.warning(f"SQLite locked on cache.get, retry {attempt + 1}/{SQLITE_MAX_RETRIES} after {delay}s")
await asyncio.sleep(delay)
else:
logger.error(f"SQLite error after {attempt + 1} retries: {e}")
cached_value = None
break
if cached_value is not None:
# ✅ CACHE HIT
elapsed_ms = (time.time() - start_time) * 1000
# Set metadata in request.state if available (for API responses)
if 'request' in kwargs and hasattr(kwargs['request'], 'state'):
cache_source_value = cache.get_last_cache_source() # L1 or L2
kwargs['request'].state.cache_hit = True
kwargs['request'].state.response_time_ms = elapsed_ms
kwargs['request'].state.cache_source = cache_source_value
# Get benchmark for calculating time saved
benchmark = await cache.get_benchmark(cache_type)
time_saved_ms = (benchmark - elapsed_ms) if benchmark else None
# Track performance
if cache.config.track_performance:
await cache.track_performance(
cache_type=cache_type,
is_hit=True,
actual_time_ms=elapsed_ms,
time_saved_ms=time_saved_ms,
estimated_oracle_time_ms=benchmark,
company_id=_extract_company_id(args, kwargs, key_params),
username=username
)
return cached_value
# ❌ CACHE MISS - execute function (query Oracle)
result = await func(*args, **kwargs)
elapsed_ms = (time.time() - start_time) * 1000
# Set metadata in request.state if available (for API responses)
if 'request' in kwargs and hasattr(kwargs['request'], 'state'):
kwargs['request'].state.cache_hit = False
kwargs['request'].state.response_time_ms = elapsed_ms
kwargs['request'].state.cache_source = None
# Update benchmark with real Oracle time
await cache.update_benchmark(cache_type, elapsed_ms)
# Track performance
if cache.config.track_performance:
await cache.track_performance(
cache_type=cache_type,
is_hit=False,
actual_time_ms=elapsed_ms,
company_id=_extract_company_id(args, kwargs, key_params),
username=username
)
# Store in cache for next time (with retry logic for SQLite locks)
company_id = _extract_company_id(args, kwargs, key_params)
for attempt in range(SQLITE_MAX_RETRIES):
try:
await cache.set(cache_key, result, cache_type, company_id, ttl)
break
except sqlite3.OperationalError as e:
if "database is locked" in str(e) and attempt < SQLITE_MAX_RETRIES - 1:
delay = SQLITE_RETRY_BASE_DELAY * (attempt + 1)
logger.warning(f"SQLite locked on cache.set, retry {attempt + 1}/{SQLITE_MAX_RETRIES} after {delay}s")
await asyncio.sleep(delay)
else:
logger.error(f"SQLite error on cache.set after {attempt + 1} retries: {e}")
# Don't fail the request, just skip caching
break
return result
return wrapper
return decorator
def _extract_username(args, kwargs, key_params: Optional[List[str]]) -> Optional[str]:
"""
Extract username from function parameters (args or kwargs)
Checks:
1. key_params position in args (if username is in key_params)
2. Direct username in kwargs
3. current_user object in kwargs
4. user object in kwargs
5. request.state.user (from AuthenticationMiddleware)
Args:
args: Positional arguments
kwargs: Keyword arguments
key_params: List of parameter names (for finding position in args)
Returns:
Username string or None
"""
# Try to find username in args based on key_params position
if key_params and 'username' in key_params:
try:
username_idx = key_params.index('username')
if username_idx < len(args):
username = args[username_idx]
if username:
return str(username)
except (ValueError, IndexError):
pass
# Direct username parameter in kwargs
if 'username' in kwargs:
return kwargs['username']
# Current user object (from FastAPI Depends)
if 'current_user' in kwargs:
user = kwargs['current_user']
if hasattr(user, 'username'):
return user.username
elif isinstance(user, dict) and 'username' in user:
return user['username']
return str(user)
# User object
if 'user' in kwargs:
user = kwargs['user']
if hasattr(user, 'username'):
return user.username
elif isinstance(user, dict) and 'username' in user:
return user['username']
return str(user)
# Extract from request.state.user (set by AuthenticationMiddleware)
if 'request' in kwargs:
request = kwargs['request']
if hasattr(request, 'state') and hasattr(request.state, 'user'):
user = request.state.user
if hasattr(user, 'username'):
return user.username
elif isinstance(user, dict) and 'username' in user:
return user['username']
return None
def _extract_company_id(args, kwargs, key_params: Optional[List[str]]) -> Optional[int]:
"""
Extract company_id from function parameters for cache indexing
Tries multiple approaches:
1. Direct company_id in kwargs
2. company parameter (converted to int)
3. Positional args based on key_params position
Args:
args: Positional arguments
kwargs: Keyword arguments
key_params: List of parameter names
Returns:
Company ID as integer or None
"""
# Try kwargs first
if 'company_id' in kwargs:
try:
return int(kwargs['company_id'])
except (ValueError, TypeError):
pass
if 'company' in kwargs:
try:
return int(kwargs['company'])
except (ValueError, TypeError):
pass
# Try positional args based on key_params
if key_params:
if 'company_id' in key_params:
idx = key_params.index('company_id')
if idx < len(args):
try:
return int(args[idx])
except (ValueError, TypeError):
pass
elif 'company' in key_params:
idx = key_params.index('company')
if idx < len(args):
try:
return int(args[idx])
except (ValueError, TypeError):
pass
return None

View File

@@ -0,0 +1,333 @@
"""
Event-based cache invalidation monitor
Monitors {schema}.act tables for changes and invalidates cache automatically
"""
import asyncio
import logging
import sys
import os
from typing import Optional
# Path setup handled by main.py - this is redundant but kept for module isolation
# sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../../..')))
logger = logging.getLogger(__name__)
class EventMonitor:
"""
Monitors schema.act tables for changes to trigger cache invalidation
Runs as background task, checking max(id_act) at configured intervals
Uses permanent schema_mappings cache to avoid repeated schema lookups
"""
def __init__(self, cache_manager, config):
"""
Initialize event monitor
Args:
cache_manager: CacheManager instance
config: CacheConfig instance
"""
self.cache = cache_manager
self.config = config
self.running = False
self.task: Optional[asyncio.Task] = None
async def start(self):
"""Start monitoring task"""
if self.running:
logger.warning("Event monitor already running")
return
self.running = True
self.task = asyncio.create_task(self._monitor_loop())
logger.info(
f"Event monitor started (interval: {self.config.check_interval}s)"
)
async def stop(self):
"""Stop monitoring task"""
if not self.running:
return
self.running = False
if self.task:
self.task.cancel()
try:
await self.task
except asyncio.CancelledError:
pass
logger.info("Event monitor stopped")
async def _monitor_loop(self):
"""Main monitoring loop"""
while self.running:
try:
await self._check_all_companies()
await asyncio.sleep(self.config.check_interval)
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Event monitor error: {e}", exc_info=True)
# Wait 1 minute on error before retrying
await asyncio.sleep(60)
async def _check_all_companies(self):
"""
Check all companies with active cache for changes
Queries max(id_act) from {schema}.act for each cached company
and invalidates cache if changes detected
"""
try:
# Get list of companies with active cache entries
cached_companies = await self.cache.sqlite.get_cached_company_ids()
if not cached_companies:
logger.debug("No cached companies to monitor")
return
logger.info(f"Checking {len(cached_companies)} companies for changes...")
invalidated_count = 0
for company_id in cached_companies:
try:
# Check if company data changed
changed = await self._check_company_changes(company_id)
if changed:
# Invalidate cache for this company
await self.cache.invalidate(company_id=company_id)
invalidated_count += 1
logger.info(
f"Cache invalidated for company {company_id} due to act changes"
)
except Exception as e:
# Error for one company shouldn't stop checking others
logger.error(f"Error checking company {company_id}: {e}")
continue
if invalidated_count > 0:
logger.info(
f"Auto-invalidation complete: {invalidated_count} companies affected"
)
except Exception as e:
logger.error(f"Check all companies error: {e}", exc_info=True)
async def _check_company_changes(self, company_id: int) -> bool:
"""
Check if company data changed (monitor max(id_act) in schema.act)
Args:
company_id: Company ID to check
Returns:
True if cache should be invalidated, False otherwise
"""
try:
# 1. Get schema (from permanent cache)
schema = await self._get_schema_for_company(company_id)
if not schema:
logger.warning(f"Schema not found for company {company_id}")
return False
# 2. Get current max(id_act) from Oracle
current_max = await self._get_max_id_act(schema)
# 3. Get cached watermark
cached_watermark = await self.cache.sqlite.get_watermark(company_id)
# 4. Compare
if cached_watermark is None:
# First time checking - store watermark, no invalidation
await self.cache.sqlite.set_watermark(company_id, schema, current_max)
logger.debug(
f"Watermark initialized for company {company_id}: {current_max}"
)
return False
if current_max > cached_watermark:
# Changes detected!
logger.info(
f"Schema {schema} (company {company_id}): "
f"id_act changed {cached_watermark} -> {current_max}"
)
# Update watermark
await self.cache.sqlite.set_watermark(company_id, schema, current_max)
return True # Invalidate cache
# No changes
return False
except Exception as e:
logger.error(f"Check company {company_id} changes error: {e}")
return False # Don't invalidate on error
async def _get_schema_for_company(self, company_id: int) -> Optional[str]:
"""
Get schema for company (with permanent caching)
First checks permanent schema_mappings cache,
falls back to Oracle query if not cached
Args:
company_id: Company ID
Returns:
Schema name or None
"""
# Check permanent cache first
cached_schema = await self.cache.sqlite.get_schema_mapping(company_id)
if cached_schema:
logger.debug(f"Schema mapping HIT for company {company_id}: {cached_schema}")
return cached_schema
# Cache MISS - query Oracle
logger.info(f"Schema mapping MISS for company {company_id}, querying Oracle...")
try:
from shared.database.oracle_pool import oracle_pool
async with oracle_pool.get_connection() as connection:
with connection.cursor() as cursor:
cursor.execute("""
SELECT schema
FROM CONTAFIN_ORACLE.v_nom_firme
WHERE id_firma = :id
""", {'id': company_id})
result = cursor.fetchone()
if not result:
logger.warning(f"Company {company_id} not found in v_nom_firme")
return None
schema = result[0]
# Store PERMANENT in schema_mappings (never expires)
await self.cache.sqlite.set_schema_mapping(company_id, schema)
logger.info(f"Schema mapping stored for company {company_id}: {schema}")
return schema
except Exception as e:
logger.error(f"Get schema for company {company_id} error: {e}")
return None
async def _get_max_id_act(self, schema: str) -> int:
"""
Query max(id_act) from {schema}.act
Args:
schema: Schema name
Returns:
Max id_act value (0 if table empty)
"""
try:
from shared.database.oracle_pool import oracle_pool
async with oracle_pool.get_connection() as connection:
with connection.cursor() as cursor:
# IMPORTANT: Schema comes from v_nom_firme (trusted source)
# so it's safe from SQL injection
query = f"SELECT MAX(id_act) FROM {schema}.act"
cursor.execute(query)
result = cursor.fetchone()
max_id_act = result[0] if result and result[0] is not None else 0
return max_id_act
except Exception as e:
logger.error(f"Get max_id_act for schema {schema} error: {e}")
return 0
# Optional: Preload all schema mappings at startup
async def preload_all_schema_mappings():
"""
Preload all schema mappings at startup (optional)
Prevents cache misses on first requests by populating
schema_mappings table with all companies
"""
from .cache_manager import get_cache
cache = get_cache()
if not cache:
logger.warning("Cache not initialized - skipping schema preload")
return
logger.info("Preloading all schema mappings...")
try:
from shared.database.oracle_pool import oracle_pool
async with oracle_pool.get_connection() as connection:
with connection.cursor() as cursor:
cursor.execute("""
SELECT id_firma, schema
FROM CONTAFIN_ORACLE.v_nom_firme
""")
results = cursor.fetchall()
for id_firma, schema in results:
await cache.sqlite.set_schema_mapping(id_firma, schema)
logger.info(f"Preloaded {len(results)} schema mappings")
except Exception as e:
logger.error(f"Schema preload error: {e}")
# Global event monitor instance
_event_monitor: Optional[EventMonitor] = None
async def init_event_monitor(cache_manager, config):
"""
Initialize global event monitor
Args:
cache_manager: CacheManager instance
config: CacheConfig instance
"""
global _event_monitor
_event_monitor = EventMonitor(cache_manager, config)
# Start if auto-invalidate enabled
if config.auto_invalidate_enabled:
await _event_monitor.start()
def get_event_monitor() -> Optional[EventMonitor]:
"""Get global event monitor instance"""
return _event_monitor
async def toggle_event_monitor(enabled: bool):
"""
Toggle event monitor on/off
Args:
enabled: True to start monitoring, False to stop
"""
monitor = get_event_monitor()
if not monitor:
logger.warning("Event monitor not initialized")
return
if enabled and not monitor.running:
await monitor.start()
elif not enabled and monitor.running:
await monitor.stop()

View File

@@ -0,0 +1,150 @@
"""
Cache key generation utilities
"""
import hashlib
import json
from typing import Any, List, Optional
def generate_cache_key(cache_type: str, key_params: Optional[List[str]], args: tuple, kwargs: dict) -> str:
"""
Generate cache key from function parameters
Format: "{cache_type}:{param1_value}:{param2_value}:..."
Args:
cache_type: Type of cache (e.g., 'dashboard_summary', 'invoices')
key_params: List of parameter names to include in key
args: Positional arguments from function call
kwargs: Keyword arguments from function call
Returns:
Cache key string
Examples:
generate_cache_key('schema', ['company_id'], (123,), {})
-> "schema:123"
generate_cache_key('dashboard_summary', ['company', 'username'], (), {'company': '123', 'username': 'john'})
-> "dashboard_summary:123:john"
generate_cache_key('invoices', ['company', 'invoice_type', 'status'], (123, 'CLIENTI', 'neplatite'), {})
-> "invoices:123:CLIENTI:neplatite"
"""
key_parts = [cache_type]
if not key_params:
# No specific params - use all args/kwargs (fallback)
if args:
key_parts.extend([str(arg) for arg in args])
if kwargs:
# Sort kwargs for consistent key generation
sorted_kwargs = sorted(kwargs.items())
key_parts.extend([f"{k}={v}" for k, v in sorted_kwargs])
else:
# Extract specific params
for i, param_name in enumerate(key_params):
# Try to get from kwargs first
if param_name in kwargs:
value = kwargs[param_name]
# Then try positional args
elif i < len(args):
value = args[i]
else:
# Parameter not found - use placeholder
value = "none"
key_parts.append(str(value))
return ":".join(key_parts)
def generate_key_pattern(cache_type: str, company_id: Optional[int] = None) -> str:
"""
Generate cache key pattern for matching multiple keys
Used for invalidation by type or company
Args:
cache_type: Type of cache
company_id: Optional company ID to filter by
Returns:
Pattern string (prefix)
Examples:
generate_key_pattern('dashboard_summary')
-> "dashboard_summary:"
generate_key_pattern('dashboard_summary', 123)
-> "dashboard_summary:123"
"""
if company_id is not None:
return f"{cache_type}:{company_id}"
return f"{cache_type}:"
def hash_complex_params(params: dict) -> str:
"""
Generate hash for complex parameters (e.g., filters, queries)
Used when cache key would be too long with full param values
Args:
params: Dictionary of parameters to hash
Returns:
8-character hash string
Example:
filters = {'status': 'neplatite', 'date_from': '2024-01-01', 'date_to': '2024-12-31'}
hash_complex_params(filters)
-> "a3f8b2c1"
"""
# Sort keys for consistent hashing
sorted_params = json.dumps(params, sort_keys=True)
hash_obj = hashlib.sha256(sorted_params.encode())
# Return first 8 characters of hex digest
return hash_obj.hexdigest()[:8]
def extract_company_id_from_key(cache_key: str) -> Optional[int]:
"""
Extract company_id from cache key
Assumes format: "cache_type:company_id:..."
Args:
cache_key: Cache key string
Returns:
Company ID or None if not found
Example:
extract_company_id_from_key("dashboard_summary:123:john")
-> 123
"""
parts = cache_key.split(":")
if len(parts) >= 2:
try:
return int(parts[1])
except (ValueError, TypeError):
pass
return None
def extract_cache_type_from_key(cache_key: str) -> str:
"""
Extract cache_type from cache key
Args:
cache_key: Cache key string
Returns:
Cache type (first part before colon)
Example:
extract_cache_type_from_key("dashboard_summary:123:john")
-> "dashboard_summary"
"""
return cache_key.split(":")[0]

View File

@@ -0,0 +1,180 @@
"""
In-memory cache with TTL (L1 cache)
Fast, limited size, lost on restart
"""
import time
import logging
from typing import Any, Optional, Dict
from collections import OrderedDict
logger = logging.getLogger(__name__)
class MemoryCache:
"""
In-memory LRU cache with TTL support
Features:
- LRU eviction when max_size reached
- Per-entry TTL expiration
- Thread-safe operations
- Fast O(1) get/set operations
"""
def __init__(self, max_size: int = 1000):
"""
Initialize memory cache
Args:
max_size: Maximum number of entries to store
"""
self.max_size = max_size
self._cache: OrderedDict[str, Dict[str, Any]] = OrderedDict()
self._stats = {
'hits': 0,
'misses': 0,
'sets': 0,
'evictions': 0
}
async def get(self, key: str) -> Optional[Any]:
"""
Get value from cache
Args:
key: Cache key
Returns:
Cached value or None if not found/expired
"""
if key not in self._cache:
self._stats['misses'] += 1
return None
entry = self._cache[key]
# Check TTL expiration
if entry['expires_at'] < time.time():
# Expired - remove and return None
del self._cache[key]
self._stats['misses'] += 1
logger.debug(f"Memory cache expired: {key}")
return None
# Move to end (LRU - most recently used)
self._cache.move_to_end(key)
self._stats['hits'] += 1
logger.debug(f"Memory cache HIT: {key}")
return entry['value']
async def set(self, key: str, value: Any, ttl: int):
"""
Set value in cache
Args:
key: Cache key
value: Value to cache
ttl: Time to live in seconds
"""
expires_at = time.time() + ttl
# Check if we need to evict (LRU)
if key not in self._cache and len(self._cache) >= self.max_size:
# Evict oldest entry (first item in OrderedDict)
evicted_key = next(iter(self._cache))
del self._cache[evicted_key]
self._stats['evictions'] += 1
logger.debug(f"Memory cache evicted (LRU): {evicted_key}")
# Store entry
self._cache[key] = {
'value': value,
'expires_at': expires_at,
'created_at': time.time()
}
# Move to end (most recently used)
self._cache.move_to_end(key)
self._stats['sets'] += 1
logger.debug(f"Memory cache SET: {key} (TTL: {ttl}s)")
async def delete(self, key: str) -> bool:
"""
Delete entry from cache
Args:
key: Cache key
Returns:
True if deleted, False if not found
"""
if key in self._cache:
del self._cache[key]
logger.debug(f"Memory cache deleted: {key}")
return True
return False
async def clear(self):
"""Clear all entries from cache"""
count = len(self._cache)
self._cache.clear()
logger.info(f"Memory cache cleared: {count} entries removed")
async def clear_by_pattern(self, pattern: str):
"""
Clear entries matching pattern (simple prefix match)
Args:
pattern: Key prefix to match (e.g., "dashboard_summary:123")
"""
keys_to_delete = [key for key in self._cache.keys() if key.startswith(pattern)]
for key in keys_to_delete:
del self._cache[key]
logger.info(f"Memory cache cleared by pattern '{pattern}': {len(keys_to_delete)} entries")
async def cleanup_expired(self):
"""Remove all expired entries"""
now = time.time()
expired_keys = [
key for key, entry in self._cache.items()
if entry['expires_at'] < now
]
for key in expired_keys:
del self._cache[key]
if expired_keys:
logger.info(f"Memory cache cleanup: {len(expired_keys)} expired entries removed")
def get_stats(self) -> Dict[str, Any]:
"""
Get cache statistics
Returns:
Dictionary with stats (hits, misses, size, etc.)
"""
total_requests = self._stats['hits'] + self._stats['misses']
hit_rate = (self._stats['hits'] / total_requests * 100) if total_requests > 0 else 0
return {
'size': len(self._cache),
'max_size': self.max_size,
'hits': self._stats['hits'],
'misses': self._stats['misses'],
'sets': self._stats['sets'],
'evictions': self._stats['evictions'],
'hit_rate': hit_rate,
'total_requests': total_requests
}
def reset_stats(self):
"""Reset statistics counters"""
self._stats = {
'hits': 0,
'misses': 0,
'sets': 0,
'evictions': 0
}

View File

@@ -0,0 +1,594 @@
"""
SQLite persistent cache (L2 cache)
Persistent, survives restarts, unlimited size
Uses singleton connection pattern with asyncio.Lock for write serialization
to prevent "database is locked" errors under concurrent access.
"""
import time
import json
import logging
import asyncio
import aiosqlite
from typing import Any, Optional, List, Dict
from pathlib import Path
from decimal import Decimal
from datetime import datetime, date
# SQLite busy timeout in milliseconds (wait for lock instead of failing immediately)
SQLITE_BUSY_TIMEOUT_MS = 5000
logger = logging.getLogger(__name__)
class CustomJSONEncoder(json.JSONEncoder):
"""Custom JSON encoder that handles Pydantic models, Decimal, datetime, etc."""
def default(self, obj):
# Handle Pydantic models
if hasattr(obj, 'dict'):
return obj.dict()
if hasattr(obj, 'model_dump'): # Pydantic v2
return obj.model_dump()
# Handle Decimal
if isinstance(obj, Decimal):
return float(obj)
# Handle datetime/date
if isinstance(obj, (datetime, date)):
return obj.isoformat()
return super().default(obj)
class SQLiteConnectionManager:
"""
Singleton connection manager with write serialization.
Solves "database is locked" errors by:
1. Maintaining a single persistent connection (instead of N connections per request)
2. Serializing all write operations through an asyncio.Lock
3. Using WAL mode for better concurrent read performance
Architecture:
┌─────────────────────────────────────┐
│ SQLiteConnectionManager │
│ (SINGLETON) │
│ │
│ _connection: aiosqlite.Connection │
│ _write_lock: asyncio.Lock │
└─────────────────────────────────────┘
┌───────────────┼───────────────┐
▼ ▼ ▼
Task 1 Task 2 Task N
cache.get() cache.set() cache.get()
│ │ │
└───────────────┴───────────────┘
async with _write_lock:
(serialized writes)
"""
_instance: Optional['SQLiteConnectionManager'] = None
_instance_lock: asyncio.Lock = None # Will be created on first use
def __init__(self, db_path: str):
"""
Initialize connection manager (called only by get_instance).
Args:
db_path: Path to SQLite database file
"""
self.db_path = db_path
self._connection: Optional[aiosqlite.Connection] = None
self._write_lock: Optional[asyncio.Lock] = None
self._initialized = False
@classmethod
async def get_instance(cls, db_path: str) -> 'SQLiteConnectionManager':
"""
Get or create singleton instance.
Thread-safe singleton pattern using asyncio.Lock.
Args:
db_path: Path to SQLite database file
Returns:
SQLiteConnectionManager singleton instance
"""
# Create instance lock on first call (must be done in async context)
if cls._instance_lock is None:
cls._instance_lock = asyncio.Lock()
async with cls._instance_lock:
if cls._instance is None or cls._instance.db_path != db_path:
cls._instance = cls(db_path)
return cls._instance
async def initialize(self):
"""
Create connection with WAL mode and busy timeout.
Sets up:
- Busy timeout (5 seconds) - wait for locks instead of failing
- WAL journal mode - allows concurrent reads while writing
- Write lock for serializing write operations
"""
if self._initialized:
return
# Create write lock in async context
self._write_lock = asyncio.Lock()
# Create persistent connection
self._connection = await aiosqlite.connect(self.db_path)
await self._connection.execute(f"PRAGMA busy_timeout={SQLITE_BUSY_TIMEOUT_MS}")
await self._connection.execute("PRAGMA journal_mode=WAL")
await self._connection.commit()
self._initialized = True
logger.info(f"SQLite connection manager initialized: {self.db_path}")
async def get_connection(self) -> aiosqlite.Connection:
"""
Get the persistent connection, with health check.
If connection is unhealthy (closed or stale), reconnects automatically.
Returns:
Active aiosqlite connection
"""
if self._connection is None or not await self._is_healthy():
await self._reconnect()
return self._connection
async def _is_healthy(self) -> bool:
"""
Check if connection is valid.
Returns:
True if connection can execute queries, False otherwise
"""
try:
async with self._connection.execute("SELECT 1") as cursor:
await cursor.fetchone()
return True
except Exception:
return False
async def _reconnect(self):
"""Reconnect if connection was lost."""
logger.warning("SQLite connection unhealthy, reconnecting...")
# Close old connection if exists
if self._connection:
try:
await self._connection.close()
except Exception:
pass
# Create new connection
self._connection = await aiosqlite.connect(self.db_path)
await self._connection.execute(f"PRAGMA busy_timeout={SQLITE_BUSY_TIMEOUT_MS}")
await self._connection.execute("PRAGMA journal_mode=WAL")
await self._connection.commit()
logger.info("SQLite connection re-established")
@property
def write_lock(self) -> asyncio.Lock:
"""Get the write lock for serializing write operations."""
return self._write_lock
async def close(self):
"""Close the connection and reset singleton."""
if self._connection:
try:
await self._connection.close()
except Exception as e:
logger.warning(f"Error closing SQLite connection: {e}")
self._connection = None
self._initialized = False
# Reset singleton
SQLiteConnectionManager._instance = None
logger.info("SQLite connection manager closed")
class SQLiteCache:
"""
SQLite-based persistent cache
Features:
- Persistent storage (survives restarts)
- JSON serialization for complex objects
- Schema mappings (permanent cache for company->schema)
- Watermarks for event-based invalidation
- Performance tracking and benchmarks
- Singleton connection with write serialization (prevents "database is locked")
"""
def __init__(self, db_path: str):
"""
Initialize SQLite cache
Args:
db_path: Path to SQLite database file
"""
self.db_path = db_path
self._conn_manager: Optional[SQLiteConnectionManager] = None
self._ensure_db_dir()
def _ensure_db_dir(self):
"""Ensure database directory exists"""
db_dir = Path(self.db_path).parent
db_dir.mkdir(parents=True, exist_ok=True)
async def init_db(self):
"""Initialize database schema with WAL mode enabled"""
# Get or create singleton connection manager
self._conn_manager = await SQLiteConnectionManager.get_instance(self.db_path)
await self._conn_manager.initialize()
# Create tables using the persistent connection
async with self._conn_manager.write_lock:
conn = await self._conn_manager.get_connection()
# Table: cache_entries
await conn.execute("""
CREATE TABLE IF NOT EXISTS cache_entries (
cache_key TEXT PRIMARY KEY,
cache_type TEXT NOT NULL,
company_id INTEGER,
data_json TEXT NOT NULL,
created_at REAL NOT NULL,
expires_at REAL NOT NULL,
hit_count INTEGER DEFAULT 0,
last_accessed REAL
)
""")
await conn.execute("CREATE INDEX IF NOT EXISTS idx_cache_type ON cache_entries(cache_type)")
await conn.execute("CREATE INDEX IF NOT EXISTS idx_company_id ON cache_entries(company_id)")
await conn.execute("CREATE INDEX IF NOT EXISTS idx_expires_at ON cache_entries(expires_at)")
# Table: schema_mappings (PERMANENT)
await conn.execute("""
CREATE TABLE IF NOT EXISTS schema_mappings (
id_firma INTEGER PRIMARY KEY,
schema TEXT NOT NULL,
created_at REAL NOT NULL,
last_verified REAL
)
""")
# Table: query_benchmarks
await conn.execute("""
CREATE TABLE IF NOT EXISTS query_benchmarks (
cache_type TEXT PRIMARY KEY,
avg_time_ms REAL NOT NULL,
sample_count INTEGER DEFAULT 0,
last_updated REAL
)
""")
# Table: performance_log
await conn.execute("""
CREATE TABLE IF NOT EXISTS performance_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
cache_type TEXT NOT NULL,
company_id INTEGER,
cache_hit BOOLEAN NOT NULL,
response_time_ms REAL NOT NULL,
estimated_oracle_time_ms REAL,
time_saved_ms REAL,
username TEXT,
timestamp REAL NOT NULL
)
""")
await conn.execute("CREATE INDEX IF NOT EXISTS idx_perf_timestamp ON performance_log(timestamp)")
await conn.execute("CREATE INDEX IF NOT EXISTS idx_perf_cache_type ON performance_log(cache_type)")
# Table: user_cache_settings
await conn.execute("""
CREATE TABLE IF NOT EXISTS user_cache_settings (
username TEXT PRIMARY KEY,
cache_enabled BOOLEAN DEFAULT TRUE,
created_at REAL,
updated_at REAL
)
""")
# Table: cache_config
await conn.execute("""
CREATE TABLE IF NOT EXISTS cache_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at REAL
)
""")
# Table: cache_watermarks
await conn.execute("""
CREATE TABLE IF NOT EXISTS cache_watermarks (
company_id INTEGER PRIMARY KEY,
schema TEXT NOT NULL,
max_id_act INTEGER NOT NULL,
checked_at REAL NOT NULL
)
""")
await conn.commit()
logger.info("SQLite cache database initialized")
async def get(self, key: str) -> Optional[Any]:
"""
Get value from cache
Args:
key: Cache key
Returns:
Cached value or None if not found/expired
"""
# Use write lock because we may update hit_count or delete expired entries
async with self._conn_manager.write_lock:
conn = await self._conn_manager.get_connection()
async with conn.execute("""
SELECT data_json, expires_at
FROM cache_entries
WHERE cache_key = ?
""", (key,)) as cursor:
result = await cursor.fetchone()
if not result:
return None
data_json, expires_at = result
# Check TTL expiration
if expires_at < time.time():
# Expired - delete and return None
await conn.execute("DELETE FROM cache_entries WHERE cache_key = ?", (key,))
await conn.commit()
logger.debug(f"SQLite cache expired: {key}")
return None
# Update hit_count and last_accessed
await conn.execute("""
UPDATE cache_entries
SET hit_count = hit_count + 1, last_accessed = ?
WHERE cache_key = ?
""", (time.time(), key))
await conn.commit()
logger.debug(f"SQLite cache HIT: {key}")
return json.loads(data_json)
async def set(self, key: str, value: Any, cache_type: str, company_id: Optional[int], ttl: int):
"""
Set value in cache
Args:
key: Cache key
value: Value to cache
cache_type: Type of cache entry
company_id: Company ID (None for global caches)
ttl: Time to live in seconds
"""
# Use custom encoder to handle Pydantic models, Decimal, datetime, etc.
data_json = json.dumps(value, cls=CustomJSONEncoder)
now = time.time()
expires_at = now + ttl
async with self._conn_manager.write_lock:
conn = await self._conn_manager.get_connection()
await conn.execute("""
INSERT OR REPLACE INTO cache_entries
(cache_key, cache_type, company_id, data_json, created_at, expires_at, hit_count, last_accessed)
VALUES (?, ?, ?, ?, ?, ?, 0, ?)
""", (key, cache_type, company_id, data_json, now, expires_at, now))
await conn.commit()
logger.debug(f"SQLite cache SET: {key} (TTL: {ttl}s)")
async def delete(self, key: str) -> bool:
"""Delete entry from cache"""
async with self._conn_manager.write_lock:
conn = await self._conn_manager.get_connection()
cursor = await conn.execute("DELETE FROM cache_entries WHERE cache_key = ?", (key,))
await conn.commit()
deleted = cursor.rowcount > 0
if deleted:
logger.debug(f"SQLite cache deleted: {key}")
return deleted
async def clear(self):
"""Clear all cache entries"""
async with self._conn_manager.write_lock:
conn = await self._conn_manager.get_connection()
cursor = await conn.execute("DELETE FROM cache_entries")
await conn.commit()
count = cursor.rowcount
logger.info(f"SQLite cache cleared: {count} entries removed")
async def clear_by_company(self, company_id: int):
"""Clear all entries for specific company"""
async with self._conn_manager.write_lock:
conn = await self._conn_manager.get_connection()
cursor = await conn.execute("DELETE FROM cache_entries WHERE company_id = ?", (company_id,))
await conn.commit()
count = cursor.rowcount
logger.info(f"SQLite cache cleared for company {company_id}: {count} entries")
async def clear_by_type(self, cache_type: str):
"""Clear all entries of specific type"""
async with self._conn_manager.write_lock:
conn = await self._conn_manager.get_connection()
cursor = await conn.execute("DELETE FROM cache_entries WHERE cache_type = ?", (cache_type,))
await conn.commit()
count = cursor.rowcount
logger.info(f"SQLite cache cleared for type '{cache_type}': {count} entries")
async def cleanup_expired(self):
"""Remove all expired entries"""
async with self._conn_manager.write_lock:
conn = await self._conn_manager.get_connection()
cursor = await conn.execute("DELETE FROM cache_entries WHERE expires_at < ?", (time.time(),))
await conn.commit()
count = cursor.rowcount
if count > 0:
logger.info(f"SQLite cache cleanup: {count} expired entries removed")
# Schema Mappings (PERMANENT)
async def get_schema_mapping(self, company_id: int) -> Optional[str]:
"""Get permanent cached schema for company (READ-ONLY, no lock needed)"""
conn = await self._conn_manager.get_connection()
async with conn.execute("""
SELECT schema
FROM schema_mappings
WHERE id_firma = ?
""", (company_id,)) as cursor:
result = await cursor.fetchone()
return result[0] if result else None
async def set_schema_mapping(self, company_id: int, schema: str):
"""Set permanent schema mapping (never expires)"""
async with self._conn_manager.write_lock:
conn = await self._conn_manager.get_connection()
await conn.execute("""
INSERT OR REPLACE INTO schema_mappings
(id_firma, schema, created_at, last_verified)
VALUES (?, ?, ?, ?)
""", (company_id, schema, time.time(), time.time()))
await conn.commit()
# Benchmarks
async def get_benchmark(self, cache_type: str) -> Optional[float]:
"""Get average benchmark time for cache type (READ-ONLY, no lock needed)"""
conn = await self._conn_manager.get_connection()
async with conn.execute("""
SELECT avg_time_ms
FROM query_benchmarks
WHERE cache_type = ?
""", (cache_type,)) as cursor:
result = await cursor.fetchone()
return result[0] if result else None
async def set_benchmark(self, cache_type: str, avg_time_ms: float, sample_count: int):
"""Set/update benchmark"""
async with self._conn_manager.write_lock:
conn = await self._conn_manager.get_connection()
await conn.execute("""
INSERT OR REPLACE INTO query_benchmarks
(cache_type, avg_time_ms, sample_count, last_updated)
VALUES (?, ?, ?, ?)
""", (cache_type, avg_time_ms, sample_count, time.time()))
await conn.commit()
# Performance Tracking
async def log_performance(self, cache_type: str, company_id: Optional[int], cache_hit: bool,
response_time_ms: float, estimated_oracle_time_ms: Optional[float],
time_saved_ms: Optional[float], username: Optional[str]):
"""Log performance metric"""
async with self._conn_manager.write_lock:
conn = await self._conn_manager.get_connection()
await conn.execute("""
INSERT INTO performance_log
(cache_type, company_id, cache_hit, response_time_ms, estimated_oracle_time_ms,
time_saved_ms, username, timestamp)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (cache_type, company_id, cache_hit, response_time_ms, estimated_oracle_time_ms,
time_saved_ms, username, time.time()))
await conn.commit()
# User Settings
async def get_user_cache_enabled(self, username: str) -> bool:
"""Get user cache setting (default True) - READ-ONLY, no lock needed"""
conn = await self._conn_manager.get_connection()
async with conn.execute("""
SELECT cache_enabled
FROM user_cache_settings
WHERE username = ?
""", (username,)) as cursor:
result = await cursor.fetchone()
return bool(result[0]) if result else True # Default enabled, explicit bool conversion
async def set_user_cache_enabled(self, username: str, enabled: bool):
"""Set user cache setting"""
async with self._conn_manager.write_lock:
conn = await self._conn_manager.get_connection()
await conn.execute("""
INSERT OR REPLACE INTO user_cache_settings
(username, cache_enabled, created_at, updated_at)
VALUES (?, ?, ?, ?)
""", (username, enabled, time.time(), time.time()))
await conn.commit()
# Watermarks
async def get_watermark(self, company_id: int) -> Optional[int]:
"""Get cached watermark (max_id_act) for company - READ-ONLY, no lock needed"""
conn = await self._conn_manager.get_connection()
async with conn.execute("""
SELECT max_id_act
FROM cache_watermarks
WHERE company_id = ?
""", (company_id,)) as cursor:
result = await cursor.fetchone()
return result[0] if result else None
async def set_watermark(self, company_id: int, schema: str, max_id_act: int):
"""Set/update watermark for company"""
async with self._conn_manager.write_lock:
conn = await self._conn_manager.get_connection()
await conn.execute("""
INSERT OR REPLACE INTO cache_watermarks
(company_id, schema, max_id_act, checked_at)
VALUES (?, ?, ?, ?)
""", (company_id, schema, max_id_act, time.time()))
await conn.commit()
async def get_cached_company_ids(self) -> List[int]:
"""Get list of company_ids with active cache entries - READ-ONLY, no lock needed"""
conn = await self._conn_manager.get_connection()
async with conn.execute("""
SELECT DISTINCT company_id
FROM cache_entries
WHERE company_id IS NOT NULL
AND expires_at > ?
""", (time.time(),)) as cursor:
results = await cursor.fetchall()
return [row[0] for row in results]
# Statistics
async def get_stats(self) -> Dict[str, Any]:
"""Get cache statistics - READ-ONLY, no lock needed"""
conn = await self._conn_manager.get_connection()
# Total entries
async with conn.execute("SELECT COUNT(*) FROM cache_entries") as cursor:
total_entries = (await cursor.fetchone())[0]
# Active entries (not expired)
async with conn.execute("""
SELECT COUNT(*) FROM cache_entries WHERE expires_at > ?
""", (time.time(),)) as cursor:
active_entries = (await cursor.fetchone())[0]
return {
'total_entries': total_entries,
'active_entries': active_entries,
'expired_entries': total_entries - active_entries
}
async def close(self):
"""Close the connection manager"""
if self._conn_manager:
await self._conn_manager.close()
self._conn_manager = None

View File

@@ -0,0 +1,19 @@
"""
Calendar period models for accounting period selector
"""
from pydantic import BaseModel
from typing import List, Optional
class CalendarPeriod(BaseModel):
"""Model for an accounting period"""
an: int # Year
luna: int # Month (1-12)
display_name: str # Format: "Decembrie 2025"
class CalendarPeriodsResponse(BaseModel):
"""Response model for calendar periods list"""
periods: List[CalendarPeriod]
current_period: Optional[CalendarPeriod] = None # Most recent period
total_count: int

View File

@@ -0,0 +1,156 @@
from pydantic import BaseModel
from decimal import Decimal
from typing import List, Dict, Optional, Any
class BudgetDebtSubAccount(BaseModel):
"""Cont individual din cadrul unui grup de datorii buget"""
cont: str # ex: "4311"
label: str # ex: "4311 - CAS angajat"
precedent: Decimal # sold luna precedentă (pozitiv=datorie, negativ=creanță)
curent: Decimal # sold luna curentă (pozitiv=datorie, negativ=creanță)
datorat: Decimal = Decimal('0') # datorie din luna precedentă (= preccred - precdeb)
achitat: Decimal = Decimal('0') # plăți efectuate luna curentă (= ruldeb)
sold: Decimal = Decimal('0') # sold final real (= soldcred - solddeb)
class BudgetDebtGroup(BaseModel):
"""Grup de datorii la buget (TVA / BASS / CAM)"""
key: str # 'TVA', 'BASS', 'CAM'
label: str # 'TVA', 'BASS', 'CAM'
precedent: Decimal # total grup luna prec (semn ±)
curent: Decimal # total grup luna crt (semn ±)
sub_accounts: List[BudgetDebtSubAccount] = []
datorat: Decimal = Decimal('0') # total datorie grup luna precedentă
achitat: Decimal = Decimal('0') # total plăți grup luna curentă
sold: Decimal = Decimal('0') # sold final real al grupului
class TreasuryAccount(BaseModel):
"""Cont de trezorerie (bancă/casă)"""
cont: str # 5121, 5124, 5311, 5314
nume_cont: str # "Bancă LEI", "Casă VALUTA" etc
nume_banca: str # Numele băncii din vbalanta_parteneri.nume
sold: Decimal
valuta: str
class TrendData(BaseModel):
"""Model pentru datele de trend - MODEL VECHI"""
labels: List[str]
incasari: List[Decimal]
plati: List[Decimal]
trezorerie: List[Decimal]
incasari_total: Decimal
plati_total: Decimal
trezorerie_total: Decimal
incasari_change: Optional[float] = None
plati_change: Optional[float] = None
trezorerie_change: Optional[float] = None
class TrendsResponse(BaseModel):
"""Model pentru răspunsul endpoint-ului de trenduri - MODEL NOU"""
# Current period data
periods: List[str]
clienti_facturat: List[float]
clienti_incasat: List[float]
furnizori_facturat: List[float]
furnizori_achitat: List[float]
clienti_sold: List[float]
furnizori_sold: List[float]
trezorerie_sold: Optional[List[float]] = None
rata_incasare_clienti: List[float]
rata_achitare_furnizori: List[float]
# Previous period data (for year-over-year comparison in sparklines)
previous_periods: Optional[List[str]] = None
clienti_facturat_prev: Optional[List[float]] = None
clienti_incasat_prev: Optional[List[float]] = None
furnizori_facturat_prev: Optional[List[float]] = None
furnizori_achitat_prev: Optional[List[float]] = None
clienti_sold_prev: Optional[List[float]] = None
furnizori_sold_prev: Optional[List[float]] = None
trezorerie_sold_prev: Optional[List[float]] = None
# Metadata and analytics
metadata: Dict[str, Any]
growth_rates: Optional[Dict[str, float]] = None
# Cache metadata (optional, for Telegram Bot)
cache_hit: Optional[bool] = None
response_time_ms: Optional[float] = None
cache_source: Optional[str] = None
class DashboardSummary(BaseModel):
"""Model pentru toate datele dashboard-ului"""
# CLIENȚI - statistici existente
clienti_total_facturat: Decimal # precdeb + debit (conturi 4111, 461)
clienti_total_incasat: Decimal # preccred + credit (conturi 4111, 461)
clienti_avansuri: Decimal # sold 419 (pasiv): credit - debit
clienti_sold_total: Decimal # (facturat - incasat) - avansuri
clienti_sold_restant: Decimal # sold cu datascad < azi
# CLIENȚI - NOI câmpuri pentru sold în termen
clienti_sold_in_termen: Decimal # sold cu datascad >= azi
# CLIENȚI - NOI detalieri restanțe (sold cu datascad < azi)
clienti_restant_7: Decimal # restant 1-7 zile
clienti_restant_14: Decimal # restant 8-14 zile
clienti_restant_30: Decimal # restant 15-30 zile
clienti_restant_60: Decimal # restant 31-60 zile
clienti_restant_90: Decimal # restant 61-90 zile
clienti_restant_90plus: Decimal # restant 90+ zile
# CLIENȚI - NOI detalieri scadențe (sold cu datascad >= azi)
clienti_scadent_7: Decimal # scadent în 1-7 zile
clienti_scadent_14: Decimal # scadent în 8-14 zile
clienti_scadent_30: Decimal # scadent în 15-30 zile
clienti_scadent_60: Decimal # scadent în 31-60 zile
clienti_scadent_90: Decimal # scadent în 61-90 zile
clienti_scadent_90plus: Decimal # scadent în 90+ zile
# FURNIZORI - statistici existente
furnizori_total_facturat: Decimal # preccred + credit (conturi 401, 404, 462)
furnizori_total_achitat: Decimal # precdeb + debit (conturi 401, 404, 462)
furnizori_avansuri: Decimal # sold 409x (activ): debit - credit
furnizori_sold_total: Decimal # (facturat - achitat) - avansuri
furnizori_sold_restant: Decimal # sold cu datascad < azi
# FURNIZORI - NOI câmpuri pentru sold în termen
furnizori_sold_in_termen: Decimal # sold cu datascad >= azi
# FURNIZORI - NOI detalieri restanțe (sold cu datascad < azi)
furnizori_restant_7: Decimal # restant 1-7 zile
furnizori_restant_14: Decimal # restant 8-14 zile
furnizori_restant_30: Decimal # restant 15-30 zile
furnizori_restant_60: Decimal # restant 31-60 zile
furnizori_restant_90: Decimal # restant 61-90 zile
furnizori_restant_90plus: Decimal # restant 90+ zile
# FURNIZORI - NOI detalieri scadențe (sold cu datascad >= azi)
furnizori_scadent_7: Decimal # scadent în 1-7 zile
furnizori_scadent_14: Decimal # scadent în 8-14 zile
furnizori_scadent_30: Decimal # scadent în 15-30 zile
furnizori_scadent_60: Decimal # scadent în 31-60 zile
furnizori_scadent_90: Decimal # scadent în 61-90 zile
furnizori_scadent_90plus: Decimal # scadent în 90+ zile
# TREZORERIE - existente
treasury_accounts: List[TreasuryAccount]
treasury_totals_by_currency: Dict[str, Decimal]
# DATE SUPLIMENTARE pentru trend analysis
clienti_facturat_luna_anterioara: Optional[Decimal] = Decimal('0')
furnizori_facturat_luna_anterioara: Optional[Decimal] = Decimal('0')
clienti_facturat_an_curent: Optional[Decimal] = Decimal('0')
clienti_facturat_an_anterior: Optional[Decimal] = Decimal('0')
furnizori_facturat_an_curent: Optional[Decimal] = Decimal('0')
furnizori_facturat_an_anterior: Optional[Decimal] = Decimal('0')
# SOLDURI TVA
tva_plata_precedent: Decimal = Decimal('0')
tva_recuperat_precedent: Decimal = Decimal('0')
tva_plata_curent: Decimal = Decimal('0')
tva_recuperat_curent: Decimal = Decimal('0')
# DATORII LA BUGET - breakdown pe grupe (TVA / BASS / CAM) cu sub-conturi
budget_debt_breakdown: List[BudgetDebtGroup] = []
budget_debt_total_precedent: Decimal = Decimal('0') # suma tuturor grupurilor luna prec
budget_debt_total_sold: Decimal = Decimal('0') # sold final total (cât mai rămâne de plată)

View File

@@ -0,0 +1,79 @@
"""
Modele Pydantic pentru facturi - Compatibile cu aplicația Flask existentă
"""
from pydantic import BaseModel, Field, validator
from datetime import date
from typing import Optional, List, Literal
from decimal import Decimal
class InvoiceBase(BaseModel):
"""Model de bază pentru factură - mapează exact pe rezultatul query-ului Flask"""
nume: str = Field(description="Numele partenerului")
nract: int = Field(description="Numărul actului")
dataact: Optional[date] = Field(description="Data actului")
datascad: Optional[date] = Field(description="Data scadentă")
contract: Optional[str] = Field(description="Numărul contractului")
cod_fiscal: Optional[str] = Field(description="Codul fiscal")
reg_comert: Optional[str] = Field(description="Registrul comerțului")
cont: Optional[str] = Field(description="Contul contabil")
valuta: str = Field(default="RON", description="Valuta (RON, EUR, USD, etc.)")
class Invoice(InvoiceBase):
"""Model complet pentru factură cu calcule financiare"""
totctva: Decimal = Field(description="Total cu TVA", decimal_places=2)
achitat: Decimal = Field(description="Suma achitată", decimal_places=2)
soldfinal: Decimal = Field(description="Soldul final", decimal_places=2)
css_class: Literal["", "invoice-paid", "invoice-overdue"] = Field(
default="", description="Clasa CSS pentru stilizare"
)
@validator('css_class', always=True)
def determine_css_class(cls, v, values):
"""Determină automat clasa CSS bazată pe status factură"""
if 'soldfinal' in values and 'datascad' in values:
sold = values['soldfinal']
data_scad = values['datascad']
if sold < 1:
return 'invoice-paid'
elif data_scad and data_scad < date.today() and sold != 0:
return 'invoice-overdue'
return ''
class InvoiceFilter(BaseModel):
"""Filtru pentru căutarea facturilor"""
company: str = Field(description="Codul firmei (schema Oracle)")
partner_type: Literal["CLIENTI", "FURNIZORI"] = Field(description="Tipul partenerului")
luna: Optional[int] = Field(default=None, ge=1, le=12, description="Luna contabilă (1-12)")
an: Optional[int] = Field(default=None, ge=2000, le=2100, description="Anul contabil")
partner_name: Optional[str] = Field(description="Filtru după nume")
cont: Optional[str] = Field(description="Filtru după cont contabil")
only_unpaid: bool = Field(default=True, description="Doar neachitate")
min_amount: Optional[Decimal] = Field(description="Suma minimă")
max_amount: Optional[Decimal] = Field(description="Suma maximă")
page: int = Field(default=1, ge=1, description="Pagina")
page_size: int = Field(default=50, ge=1, le=10000000, description="Mărimea paginii")
class InvoiceListResponse(BaseModel):
"""Răspuns pentru lista de facturi"""
invoices: List[Invoice]
total_count: int
filtered_count: int
total_amount: Decimal
page: int
page_size: int
has_more: bool
accounting_period: Optional[dict] = Field(default=None, description="Perioada contabilă (an, luna)")
# Total sold din TOATE facturile filtrate (nu doar pagina curentă)
total_sold_all: Decimal = Field(default=Decimal('0.00'), description="Total sold din toate facturile filtrate")
class InvoiceSummary(BaseModel):
"""Rezumat pentru facturi - pentru dashboard"""
company: str
partner_type: str
total_invoices: int
total_amount: Decimal
paid_amount: Decimal
outstanding_amount: Decimal
overdue_amount: Decimal
overdue_count: int

View File

@@ -0,0 +1,52 @@
from pydantic import BaseModel
from decimal import Decimal
from datetime import datetime
from typing import Optional, List
class AccountingPeriod(BaseModel):
"""Model pentru perioada contabilă"""
an: Optional[int] = None
luna: Optional[int] = None
class BankCashRegister(BaseModel):
"""Model pentru Registrul de Casă și Bancă"""
nume: str
nract: Optional[int] = None
dataact: Optional[datetime] = None
nume_cont_bancar: str # din vbalanta_parteneri.nume
incasari: Decimal
plati: Decimal
sold: Decimal
valuta: Optional[str] = None
tip_registru: str # "BANCA LEI", "CASA VALUTA" etc
explicatia: str
class RegisterFilter(BaseModel):
"""Filtre pentru registrul de casă și bancă"""
company: str
register_type: Optional[str] = None # BANCA_LEI, BANCA_VALUTA, CASA_LEI, CASA_VALUTA sau None pentru toate
luna: Optional[int] = None # Luna contabilă (1-12) pentru PACK_SESIUNE
an: Optional[int] = None # Anul contabil pentru PACK_SESIUNE
date_from: Optional[datetime] = None
date_to: Optional[datetime] = None
partner_name: Optional[str] = None
bank_account: Optional[str] = None # Filter for specific bank/cash account (bancasa)
page: int = 1
page_size: int = 50
class RegisterListResponse(BaseModel):
"""Răspuns pentru lista din registru"""
registers: List[BankCashRegister]
total_count: int
filtered_count: int
total_incasari: Decimal
total_plati: Decimal
page: int
page_size: int
has_more: bool
accounting_period: Optional[AccountingPeriod] = None
# Totaluri din TOATE înregistrările filtrate (nu doar pagina curentă)
sold_precedent_all: Decimal = Decimal('0.00')
total_incasari_all: Decimal = Decimal('0.00')
total_plati_all: Decimal = Decimal('0.00')
sold_final_all: Decimal = Decimal('0.00')

View File

@@ -0,0 +1,102 @@
"""
Pydantic models for Trial Balance (Balanță de Verificare)
Maps to Oracle VBAL VIEW (exists in each company schema)
"""
from pydantic import BaseModel, Field
from typing import Optional, List
from decimal import Decimal
class TrialBalanceItem(BaseModel):
"""
Individual trial balance record from VBAL VIEW
Real structure from Oracle:
- CONT: account number
- DENUMIRE: account description
- PRECDEB/PRECCRED: previous balance debit/credit
- RULDEB/RULCRED: monthly movement debit/credit
- SOLDDEB/SOLDCRED: final balance debit/credit
"""
cont: str = Field(description="Număr cont contabil (CONT)")
denumire: Optional[str] = Field(default="", description="Denumire cont (DENUMIRE)")
sold_precedent_debit: Decimal = Field(description="Sold precedent debit (PRECDEB)", decimal_places=2)
sold_precedent_credit: Decimal = Field(description="Sold precedent credit (PRECCRED)", decimal_places=2)
rulaj_lunar_debit: Decimal = Field(description="Rulaj lunar debit (RULDEB)", decimal_places=2)
rulaj_lunar_credit: Decimal = Field(description="Rulaj lunar credit (RULCRED)", decimal_places=2)
sold_final_debit: Decimal = Field(description="Sold final debit (SOLDDEB)", decimal_places=2)
sold_final_credit: Decimal = Field(description="Sold final credit (SOLDCRED)", decimal_places=2)
class Config:
from_attributes = True
class TrialBalanceFilters(BaseModel):
"""
Filters applied to trial balance data
"""
luna: int = Field(description="Luna (1-12)")
an: int = Field(description="An")
cont_filter: Optional[str] = Field(default=None, description="Filtru număr cont (partial match)")
denumire_filter: Optional[str] = Field(default=None, description="Filtru denumire cont (partial match, case-insensitive)")
class TrialBalancePagination(BaseModel):
"""
Pagination metadata
"""
total_items: int = Field(description="Total number of items")
total_pages: int = Field(description="Total number of pages")
current_page: int = Field(description="Current page number")
page_size: int = Field(description="Items per page")
class TrialBalanceTotals(BaseModel):
"""
Totals for all 6 columns from all filtered records (not just current page)
"""
total_sold_precedent_debit: Decimal = Decimal('0.00')
total_sold_precedent_credit: Decimal = Decimal('0.00')
total_rulaj_lunar_debit: Decimal = Decimal('0.00')
total_rulaj_lunar_credit: Decimal = Decimal('0.00')
total_sold_final_debit: Decimal = Decimal('0.00')
total_sold_final_credit: Decimal = Decimal('0.00')
class TrialBalanceResponse(BaseModel):
"""
Complete response for trial balance endpoint
"""
success: bool = Field(default=True, description="Request success status")
data: dict = Field(description="Trial balance data with items, pagination, and filters")
class Config:
json_schema_extra = {
"example": {
"success": True,
"data": {
"items": [
{
"cont": "4111",
"dcont": "Furnizori interni",
"sold_precedent_debit": 0.00,
"sold_precedent_credit": 15000.00,
"rulaj_lunar_debit": 5000.00,
"rulaj_lunar_credit": 8000.00,
"sold_final_debit": 0.00,
"sold_final_credit": 18000.00
}
],
"pagination": {
"total_items": 150,
"total_pages": 3,
"current_page": 1,
"page_size": 50
},
"filters_applied": {
"luna": 11,
"an": 2025,
"cont_filter": None,
"denumire_filter": "furnizori"
}
}
}
}

View File

@@ -0,0 +1,36 @@
"""Reports module router factory."""
from fastapi import APIRouter
def create_reports_router() -> APIRouter:
"""
Create and configure Reports module router.
Includes all report-related endpoints:
- /invoices - Invoice management
- /dashboard - Dashboard and metrics
- /treasury - Treasury operations
- /trial-balance - Trial balance reports
- /cache - Cache management
Returns:
APIRouter: Configured router for reports module
"""
router = APIRouter()
# Import routers here to avoid circular imports
from .invoices import router as invoices_router
from .dashboard import router as dashboard_router
from .treasury import router as treasury_router
from .trial_balance import router as trial_balance_router
from .cache import router as cache_router
# Include all sub-routers (no prefix - already prefixed in main.py with /api/reports)
router.include_router(invoices_router, prefix="/invoices", tags=["reports-invoices"])
router.include_router(dashboard_router, prefix="/dashboard", tags=["reports-dashboard"])
router.include_router(treasury_router, prefix="/treasury", tags=["reports-treasury"])
router.include_router(trial_balance_router, prefix="/trial-balance", tags=["reports-trial-balance"])
router.include_router(cache_router, prefix="/cache", tags=["reports-cache"])
return router

View File

@@ -0,0 +1,398 @@
"""
API Router pentru managementul cache-ului
"""
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from typing import Optional, Dict, Any
# import sys # Removed - no longer needed
import os
import time
from datetime import datetime, timedelta
from shared.auth.dependencies import get_current_user
from shared.auth.models import CurrentUser
from ..cache import get_cache, get_event_monitor, toggle_event_monitor
router = APIRouter(tags=["cache"])
# Pydantic Models
class CacheStatsResponse(BaseModel):
"""Răspuns statistici cache"""
enabled: bool
global_enabled: bool
user_enabled: bool
cache_type: str
hit_rate: float
total_hits: int
total_misses: int
queries_saved: Dict[str, int]
response_times: Dict[str, Dict[str, Any]]
cache_size: Dict[str, int]
auto_invalidate: bool
last_cleanup: Optional[str] = None
class InvalidateCacheRequest(BaseModel):
"""Request pentru invalidare cache"""
company_id: Optional[int] = None
cache_type: Optional[str] = None
class ToggleUserCacheRequest(BaseModel):
"""Request pentru toggle cache per-user"""
enabled: bool
class ToggleGlobalCacheRequest(BaseModel):
"""Request pentru toggle cache global"""
enabled: bool
class ToggleAutoInvalidateRequest(BaseModel):
"""Request pentru toggle auto-invalidation"""
enabled: bool
# Helper Functions
async def _calculate_cache_stats() -> Dict[str, Any]:
"""Calculate comprehensive cache statistics"""
cache = get_cache()
if not cache:
raise HTTPException(status_code=503, detail="Cache not initialized")
# Get basic cache stats
stats = await cache.get_stats()
# Calculate hit rate
memory_stats = stats.get('memory', {})
total_hits = memory_stats.get('hits', 0)
total_misses = memory_stats.get('misses', 0)
total_requests = total_hits + total_misses
hit_rate = (total_hits / total_requests * 100) if total_requests > 0 else 0
# Calculate queries saved (from performance_log)
queries_saved = await _calculate_queries_saved(cache)
# Calculate response times per cache type
response_times = await _calculate_response_times(cache)
# Get cache sizes
cache_size = {
'memory': memory_stats.get('size', 0),
'sqlite': stats.get('sqlite', {}).get('active_entries', 0)
}
# Get event monitor status
monitor = get_event_monitor()
auto_invalidate = monitor.running if monitor else False
return {
'enabled': cache.config.enabled,
'global_enabled': cache.config.enabled,
'cache_type': cache.config.cache_type,
'hit_rate': round(hit_rate, 1),
'total_hits': total_hits,
'total_misses': total_misses,
'queries_saved': queries_saved,
'response_times': response_times,
'cache_size': cache_size,
'auto_invalidate': auto_invalidate,
'last_cleanup': None # TODO: track last cleanup time
}
async def _calculate_queries_saved(cache) -> Dict[str, int]:
"""Calculate queries saved by time period"""
import aiosqlite
try:
async with aiosqlite.connect(cache.sqlite.db_path) as db:
now = time.time()
today_start = now - 86400 # 24 hours
week_start = now - 604800 # 7 days
# Today
async with db.execute("""
SELECT COUNT(*) FROM performance_log
WHERE cache_hit = 1 AND timestamp >= ?
""", (today_start,)) as cursor:
today = (await cursor.fetchone())[0]
# This week
async with db.execute("""
SELECT COUNT(*) FROM performance_log
WHERE cache_hit = 1 AND timestamp >= ?
""", (week_start,)) as cursor:
week = (await cursor.fetchone())[0]
# All time
async with db.execute("""
SELECT COUNT(*) FROM performance_log
WHERE cache_hit = 1
""") as cursor:
total = (await cursor.fetchone())[0]
return {
'today': today,
'week': week,
'total': total
}
except Exception as e:
return {'today': 0, 'week': 0, 'total': 0}
async def _calculate_response_times(cache) -> Dict[str, Dict[str, Any]]:
"""Calculate average response times per cache type"""
import aiosqlite
try:
async with aiosqlite.connect(cache.sqlite.db_path) as db:
# Get average times per cache type
async with db.execute("""
SELECT
cache_type,
AVG(CASE WHEN cache_hit = 1 THEN response_time_ms ELSE NULL END) as avg_cached,
AVG(CASE WHEN cache_hit = 0 THEN response_time_ms ELSE NULL END) as avg_oracle
FROM performance_log
WHERE timestamp >= ?
GROUP BY cache_type
""", (time.time() - 86400,)) as cursor: # Last 24 hours
results = await cursor.fetchall()
response_times = {}
for row in results:
cache_type, avg_cached, avg_oracle = row
if avg_cached and avg_oracle:
improvement = int((avg_oracle - avg_cached) / avg_oracle * 100)
response_times[cache_type] = {
'cached': int(avg_cached),
'oracle': int(avg_oracle),
'improvement': improvement
}
return response_times
except Exception as e:
return {}
# API Endpoints
@router.get("/stats", response_model=CacheStatsResponse)
async def get_cache_stats(
current_user: CurrentUser = Depends(get_current_user)
):
"""
Obține statistici complete cache
Returns:
- Hit rate, queries saved, response times
- Cache sizes (memory + SQLite)
- Auto-invalidation status
- Per-user cache setting
"""
try:
cache = get_cache()
if not cache:
raise HTTPException(status_code=503, detail="Cache not initialized")
# Get base stats
stats = await _calculate_cache_stats()
# Add user-specific setting
user_enabled = await cache.is_enabled_for_user(current_user.username)
stats['user_enabled'] = user_enabled
return CacheStatsResponse(**stats)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error retrieving cache stats: {str(e)}")
@router.post("/invalidate")
async def invalidate_cache(
request: InvalidateCacheRequest,
current_user: CurrentUser = Depends(get_current_user)
):
"""
Invalidează cache
Args:
company_id: Opțional - invalidează doar pentru această companie
cache_type: Opțional - invalidează doar acest tip de cache
Returns:
Message de confirmare
"""
try:
cache = get_cache()
if not cache:
raise HTTPException(status_code=503, detail="Cache not initialized")
await cache.invalidate(
company_id=request.company_id,
cache_type=request.cache_type
)
if request.company_id and request.cache_type:
message = f"Cache invalidated for company {request.company_id}, type {request.cache_type}"
elif request.company_id:
message = f"Cache invalidated for company {request.company_id}"
elif request.cache_type:
message = f"Cache invalidated for type {request.cache_type}"
else:
message = "All cache invalidated"
return {
"success": True,
"message": message,
"invalidated_at": datetime.now().isoformat()
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error invalidating cache: {str(e)}")
@router.post("/toggle-user")
async def toggle_user_cache(
request: ToggleUserCacheRequest,
current_user: CurrentUser = Depends(get_current_user)
):
"""
Toggle cache per-user
Permite utilizatorului să activeze/dezactiveze cache-ul pentru el
Folosit pentru A/B testing și comparații de performanță
Args:
enabled: True pentru activare, False pentru dezactivare
Returns:
Noul status
"""
try:
cache = get_cache()
if not cache:
raise HTTPException(status_code=503, detail="Cache not initialized")
await cache.set_user_cache_enabled(current_user.username, request.enabled)
return {
"success": True,
"username": current_user.username,
"cache_enabled": request.enabled,
"message": f"Cache {'enabled' if request.enabled else 'disabled'} for user {current_user.username}"
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error toggling user cache: {str(e)}")
@router.post("/toggle-global")
async def toggle_global_cache(
request: ToggleGlobalCacheRequest,
current_user: CurrentUser = Depends(get_current_user)
):
"""
Toggle cache global (ADMIN only)
Activează/dezactivează cache-ul la nivel global pentru toți utilizatorii
Args:
enabled: True pentru activare, False pentru dezactivare
Returns:
Noul status global
"""
try:
# TODO: Add admin permission check
# For now, allow any authenticated user
cache = get_cache()
if not cache:
raise HTTPException(status_code=503, detail="Cache not initialized")
# Update config (NOTE: This is runtime only, .env needs manual update)
cache.config.enabled = request.enabled
return {
"success": True,
"global_enabled": request.enabled,
"message": f"Cache {'enabled' if request.enabled else 'disabled'} globally",
"note": "This change is runtime only. Update .env CACHE_ENABLED for persistence."
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error toggling global cache: {str(e)}")
@router.post("/toggle-auto-invalidate")
async def toggle_auto_invalidation(
request: ToggleAutoInvalidateRequest,
current_user: CurrentUser = Depends(get_current_user)
):
"""
Toggle auto-invalidation monitoring
Activează/dezactivează monitorizarea automată a {schema}.act
pentru invalidarea cache-ului când se detectează modificări
Args:
enabled: True pentru activare, False pentru dezactivare
Returns:
Noul status auto-invalidation
"""
try:
# TODO: Add admin permission check
# For now, allow any authenticated user
await toggle_event_monitor(request.enabled)
return {
"success": True,
"auto_invalidate_enabled": request.enabled,
"message": f"Auto-invalidation {'enabled' if request.enabled else 'disabled'}",
"note": "Monitors max(id_act) in {schema}.act tables for changes"
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error toggling auto-invalidation: {str(e)}")
@router.get("/health")
async def cache_health():
"""
Health check pentru sistemul de cache
Returns:
Status cache, mărime, și uptime
"""
try:
cache = get_cache()
if not cache:
return {
"status": "not_initialized",
"enabled": False
}
stats = await cache.get_stats()
monitor = get_event_monitor()
return {
"status": "healthy",
"enabled": cache.config.enabled,
"cache_type": cache.config.cache_type,
"memory_size": stats.get('memory', {}).get('size', 0),
"sqlite_size": stats.get('sqlite', {}).get('active_entries', 0),
"auto_invalidate_running": monitor.running if monitor else False
}
except Exception as e:
return {
"status": "error",
"error": str(e)
}

View File

@@ -0,0 +1,661 @@
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from typing import Optional
import os
from shared.auth.dependencies import get_current_user
from shared.auth.models import CurrentUser
import logging
logger = logging.getLogger(__name__)
from ..models.dashboard import DashboardSummary, TrendsResponse, TrendData
from ..models.financial_indicators import FinancialIndicatorsResponse
from ..services.dashboard_service import DashboardService
from ..services.financial_indicators_service import FinancialIndicatorsService
from ..cache.decorators import cached
router = APIRouter()
@router.get("/summary")
async def get_dashboard_summary(
request: Request,
company: str = Query(description="Codul firmei"),
luna: Optional[int] = Query(None, ge=1, le=12, description="Luna contabilă (1-12)"),
an: Optional[int] = Query(None, ge=2000, le=2100, description="Anul contabil"),
current_user: CurrentUser = Depends(get_current_user)
):
"""
Obține toate datele pentru dashboard într-un singur apel
- Necesită autentificare JWT
- Returnează statistici clienți/furnizori și trezorerie
- Include metadata cache pentru Telegram Bot (X-Include-Cache-Metadata header)
- Suportă filtrare pe luna/an contabil (dacă nu sunt specificate, folosește ultima perioadă)
"""
try:
# Verifică dacă utilizatorul are acces la firma specificată
if company not in current_user.companies:
raise HTTPException(status_code=403, detail=f"Nu aveți acces la firma {company}")
server_id = getattr(request.state, 'server_id', None)
result = await DashboardService.get_complete_summary(company, current_user.username, luna=luna, an=an, request=request, server_id=server_id)
# Convert Pydantic model to dict for JSON serialization
result_dict = result.dict() if hasattr(result, 'dict') else result
# Add cache metadata if requested (for Telegram Bot)
include_metadata = request.headers.get('X-Include-Cache-Metadata', '').lower() == 'true'
if include_metadata:
cache_hit = getattr(request.state, 'cache_hit', False)
response_time = getattr(request.state, 'response_time_ms', 0)
cache_source = getattr(request.state, 'cache_source', None)
result_dict['cache_hit'] = cache_hit
result_dict['response_time_ms'] = response_time
# Always include cache_source, even if None
result_dict['cache_source'] = cache_source
return result_dict
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Eroare la obținerea datelor dashboard: {str(e)}")
@router.get("/trends", response_model=TrendsResponse)
async def get_dashboard_trends(
request: Request,
company: str = Query(description="Codul firmei"),
period: str = Query(default="30d", description="Perioada pentru trends: 7d, 30d, ytd, 12m"),
luna: Optional[int] = Query(None, ge=1, le=12, description="Luna contabilă (1-12)"),
an: Optional[int] = Query(None, ge=2000, le=2100, description="Anul contabil"),
compare_previous: bool = Query(default=True, description="Compară cu perioada anterioară"),
current_user: CurrentUser = Depends(get_current_user)
):
"""
Obține trenduri pentru indicatorii principali (clienți/furnizori)
- period: "7d" (7 zile), "30d" (30 zile), "ytd" (year to date), "12m" (12 luni)
- luna/an: perioada contabilă de referință (dacă nu sunt specificate, folosește ultima perioadă)
- compare_previous: dacă să compare cu perioada anterioară
- Necesită autentificare JWT
- Returnează date pentru grafice de trenduri
"""
try:
# Verifică dacă utilizatorul are acces la firma specificată
if company not in current_user.companies:
raise HTTPException(status_code=403, detail=f"Nu aveți acces la firma {company}")
# Validează perioada
valid_periods = ["7d", "30d", "ytd", "12m"]
if period not in valid_periods:
raise HTTPException(
status_code=400,
detail=f"Perioadă nevalidă: {period}. Valori permise: {', '.join(valid_periods)}"
)
server_id = getattr(request.state, 'server_id', None)
# Obține datele de trenduri
result = await DashboardService.get_trends(int(company), period, luna=luna, an=an, request=request, server_id=server_id)
# Convert to dict if needed
result_dict = result.dict() if hasattr(result, 'dict') else result
# Add cache metadata if requested (for Telegram Bot)
include_metadata = request.headers.get('X-Include-Cache-Metadata', '').lower() == 'true'
if include_metadata:
cache_hit = getattr(request.state, 'cache_hit', False)
response_time = getattr(request.state, 'response_time_ms', 0)
cache_source = getattr(request.state, 'cache_source', None)
result_dict['cache_hit'] = cache_hit
result_dict['response_time_ms'] = response_time
# Always include cache_source, even if None
result_dict['cache_source'] = cache_source
# Return as TrendsResponse
return TrendsResponse(**result_dict)
except ValueError as e:
logger.error(f"Value error in trends endpoint: {str(e)}")
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Eroare la obținerea trendurilor: {str(e)}")
raise HTTPException(status_code=500, detail=f"Eroare la obținerea trendurilor: {str(e)}")
@router.get("/detailed-data")
async def get_detailed_data(
request: Request,
company: str = Query(description="Codul firmei"),
data_type: str = Query(description="Tipul de date: clients, suppliers, treasury"),
luna: Optional[int] = Query(None, ge=1, le=12, description="Luna contabilă (1-12)"),
an: Optional[int] = Query(None, ge=2000, le=2100, description="Anul contabil"),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=25, ge=1, le=100),
search: str = Query(default="", description="Termen de căutare"),
current_user: CurrentUser = Depends(get_current_user)
):
"""
Obține date detaliate pentru tabelele din dashboard
"""
logger.info(f"[ROUTER] detailed-data called: company={company}, data_type={data_type}")
try:
if company not in current_user.companies:
raise HTTPException(status_code=403, detail=f"Nu aveți acces la firma {company}")
server_id = getattr(request.state, 'server_id', None)
logger.info(f"[ROUTER] Calling DashboardService.get_detailed_data")
result = await DashboardService.get_detailed_data(
company=company,
data_type=data_type,
luna=luna,
an=an,
page=page,
page_size=page_size,
search=search,
server_id=server_id
)
logger.info(f"[ROUTER] Service returned: {len(result.get('data', []))} rows")
return result
except Exception as e:
logger.error(f"Eroare la obținerea datelor detaliate: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/performance")
async def get_performance(
request: Request,
company: int = Query(..., description="ID-ul firmei"),
period: str = Query("7d", regex="^(7d|1m|3m|6m|ytd|12m)$", description="Perioada pentru analiză"),
current_user: CurrentUser = Depends(get_current_user)
):
"""
Returnează date performanță pentru perioada selectată
- Necesită autentificare JWT
- Returnează grafice încasări vs plăți pentru perioada selectată
- Calculează indicatori: rata încasării, cash conversion, working capital
"""
try:
# Verifică dacă utilizatorul are acces la firma specificată
if str(company) not in current_user.companies:
raise HTTPException(status_code=403, detail=f"Nu aveți acces la firma {company}")
server_id = getattr(request.state, 'server_id', None)
result = await DashboardService.get_performance_data(company, period, server_id=server_id)
# Convert to Chart.js compatible format
return {
"labels": result.get("labels", []),
"datasets": [{
"data": result.get("data", []),
"label": result.get("label", "Performance"),
"borderColor": result.get("borderColor", "#3B82F6"),
"backgroundColor": result.get("backgroundColor", "rgba(59, 130, 246, 0.1)"),
"tension": 0.4
}]
}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Eroare la obținerea datelor de performanță: {str(e)}")
raise HTTPException(status_code=500, detail=f"Eroare la obținerea datelor de performanță: {str(e)}")
@router.get("/cashflow")
async def get_cashflow(
request: Request,
company: int = Query(..., description="ID-ul firmei"),
period: str = Query("7d", regex="^(7d|1m|3m|6m)$", description="Perioada pentru previziune"),
current_user: CurrentUser = Depends(get_current_user)
):
"""
Returnează previziune cash flow pentru perioada selectată
- Necesită autentificare JWT
- Analizează scadențele viitoare pentru calculul cash flow-ului
- Identifică zilele critice cu deficit de cash
"""
try:
# Verifică dacă utilizatorul are acces la firma specificată
if str(company) not in current_user.companies:
raise HTTPException(status_code=403, detail=f"Nu aveți acces la firma {company}")
server_id = getattr(request.state, 'server_id', None)
result = await DashboardService.get_cashflow_forecast(company, period, server_id=server_id)
# Convert to Chart.js compatible format
return {
"labels": result.get("labels", []),
"datasets": [{
"data": result.get("data", []),
"label": result.get("label", "Cash Flow"),
"borderColor": result.get("borderColor", "#10B981"),
"backgroundColor": result.get("backgroundColor", "rgba(16, 185, 129, 0.1)"),
"tension": 0.4
}]
}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Eroare la obținerea previziunii cash flow: {str(e)}")
raise HTTPException(status_code=500, detail=f"Eroare la obținerea previziunii cash flow: {str(e)}")
@router.get("/maturity")
async def get_maturity_analysis(
request: Request,
company: int = Query(..., description="ID-ul firmei"),
period: str = Query("7d", regex="^(7d|1m|3m|6m|12m|all)$", description="Orizont de planificare pentru analiza scadențelor"),
luna: Optional[int] = Query(None, ge=1, le=12, description="Luna contabilă (1-12)"),
an: Optional[int] = Query(None, ge=2000, le=2100, description="Anul contabil"),
current_user: CurrentUser = Depends(get_current_user)
):
"""
Returnează analiza scadențelor pentru orizontul de planificare selectat
- Necesită autentificare JWT
- Logică: Include TOATE restanțele + scadențele viitoare din perioada selectată
- luna/an: perioada contabilă de referință (dacă nu sunt specificate, folosește ultima perioadă)
- Perioade disponibile:
* 7d: Toate restanțele + scadențe următoarelor 7 zile
* 1m: Toate restanțele + scadențe următoarelor 30 zile
* 3m: Toate restanțele + scadențe următoarelor 90 zile
* 6m: Toate restanțele + scadențe următoarelor 180 zile
* 12m: Toate restanțele + scadențe următoarelor 365 zile
* all: Toate soldurile (fără filtru)
- Compară scadențele clienți vs furnizori
- Calculează balanța și oferă recomandări
- Returnează metadate cu statistici complete
- Include metadata cache pentru Telegram Bot (X-Include-Cache-Metadata header)
"""
try:
# Verifică dacă utilizatorul are acces la firma specificată
if str(company) not in current_user.companies:
raise HTTPException(status_code=403, detail=f"Nu aveți acces la firma {company}")
server_id = getattr(request.state, 'server_id', None)
result = await DashboardService.get_maturity_analysis(company, period, luna=luna, an=an, request=request, server_id=server_id)
# Convert to dict if needed
result_dict = result.dict() if hasattr(result, 'dict') else result
# Add cache metadata if requested (for Telegram Bot)
include_metadata = request.headers.get('X-Include-Cache-Metadata', '').lower() == 'true'
if include_metadata:
cache_hit = getattr(request.state, 'cache_hit', False)
response_time = getattr(request.state, 'response_time_ms', 0)
cache_source = getattr(request.state, 'cache_source', None)
result_dict['cache_hit'] = cache_hit
result_dict['response_time_ms'] = response_time
# Always include cache_source, even if None
result_dict['cache_source'] = cache_source
return result_dict
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Eroare la obținerea analizei scadențelor: {str(e)}")
raise HTTPException(status_code=500, detail=f"Eroare la obținerea analizei scadențelor: {str(e)}")
@router.get("/monthly-flows")
async def get_monthly_flows(
request: Request,
company: int = Query(..., description="ID-ul firmei"),
luna: Optional[int] = Query(None, ge=1, le=12, description="Luna contabilă (1-12)"),
an: Optional[int] = Query(None, ge=2000, le=2100, description="Anul contabil"),
current_user: CurrentUser = Depends(get_current_user)
):
"""
Returnează fluxurile lunare pentru firma selectată
- Necesită autentificare JWT
- Returnează date pentru analiza fluxurilor lunare
- luna/an: perioada contabilă de referință (dacă nu sunt specificate, folosește ultima perioadă)
- Include metadata cache pentru Telegram Bot (X-Include-Cache-Metadata header)
"""
try:
# Verifică dacă utilizatorul are acces la firma specificată
if str(company) not in current_user.companies:
raise HTTPException(status_code=403, detail=f"Nu aveți acces la firma {company}")
server_id = getattr(request.state, 'server_id', None)
# Apelăm serviciul cu request pentru cache metadata
result = await DashboardService.get_monthly_flows(company, luna=luna, an=an, request=request, server_id=server_id)
# Convert to dict if needed
result_dict = result.dict() if hasattr(result, 'dict') else result
# Add cache metadata if requested (for Telegram Bot / Dashboard)
include_metadata = request.headers.get('X-Include-Cache-Metadata', '').lower() == 'true'
if include_metadata:
cache_hit = getattr(request.state, 'cache_hit', False)
response_time = getattr(request.state, 'response_time_ms', 0)
cache_source = getattr(request.state, 'cache_source', None)
result_dict['cache_hit'] = cache_hit
result_dict['response_time_ms'] = response_time
result_dict['cache_source'] = cache_source
return result_dict
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Eroare la obținerea fluxurilor lunare: {str(e)}")
raise HTTPException(status_code=500, detail=f"Eroare la obținerea fluxurilor lunare: {str(e)}")
@router.get("/treasury-breakdown")
async def get_treasury_breakdown(
request: Request,
company: int = Query(..., description="ID-ul firmei"),
luna: Optional[int] = Query(None, ge=1, le=12, description="Luna contabilă (1-12)"),
an: Optional[int] = Query(None, ge=2000, le=2100, description="Anul contabil"),
current_user: CurrentUser = Depends(get_current_user)
):
"""
Returnează defalcarea trezoreriei pentru firma selectată
- Necesită autentificare JWT
- Returnează distribuția soldurilor pe conturi și tipuri
- luna/an: perioada contabilă de referință (dacă nu sunt specificate, folosește ultima perioadă)
- Include metadata cache pentru Telegram Bot (X-Include-Cache-Metadata header)
"""
try:
# Verifică dacă utilizatorul are acces la firma specificată
if str(company) not in current_user.companies:
raise HTTPException(status_code=403, detail=f"Nu aveți acces la firma {company}")
server_id = getattr(request.state, 'server_id', None)
result = await DashboardService.get_treasury_breakdown(company, luna=luna, an=an, request=request, server_id=server_id)
# Convert to dict if needed
result_dict = result.dict() if hasattr(result, 'dict') else result
# Add cache metadata if requested (for Telegram Bot)
include_metadata = request.headers.get('X-Include-Cache-Metadata', '').lower() == 'true'
if include_metadata:
cache_hit = getattr(request.state, 'cache_hit', False)
response_time = getattr(request.state, 'response_time_ms', 0)
cache_source = getattr(request.state, 'cache_source', None)
result_dict['cache_hit'] = cache_hit
result_dict['response_time_ms'] = response_time
# Always include cache_source, even if None
result_dict['cache_source'] = cache_source
return result_dict
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Eroare la obținerea defalcării trezoreriei: {str(e)}")
raise HTTPException(status_code=500, detail=f"Eroare la obținerea defalcării trezoreriei: {str(e)}")
@router.get("/net-balance-breakdown")
async def get_net_balance_breakdown(
request: Request,
company: int = Query(..., description="ID-ul firmei"),
luna: Optional[int] = Query(None, ge=1, le=12, description="Luna contabilă (1-12)"),
an: Optional[int] = Query(None, ge=2000, le=2100, description="Anul contabil"),
current_user: CurrentUser = Depends(get_current_user)
):
"""
Returnează defalcarea balanței nete pentru firma selectată
- Necesită autentificare JWT
- Returnează analiza detaliată a balanței nete
- luna/an: perioada contabilă de referință (dacă nu sunt specificate, folosește ultima perioadă)
- Include metadata cache pentru Telegram Bot (X-Include-Cache-Metadata header)
"""
try:
# Verifică dacă utilizatorul are acces la firma specificată
if str(company) not in current_user.companies:
raise HTTPException(status_code=403, detail=f"Nu aveți acces la firma {company}")
server_id = getattr(request.state, 'server_id', None)
result = await DashboardService.get_net_balance_breakdown(company, luna=luna, an=an, request=request, server_id=server_id)
# Convert to dict if needed
result_dict = result.dict() if hasattr(result, 'dict') else result
# Add cache metadata if requested (for Telegram Bot)
include_metadata = request.headers.get('X-Include-Cache-Metadata', '').lower() == 'true'
if include_metadata:
cache_hit = getattr(request.state, 'cache_hit', False)
response_time = getattr(request.state, 'response_time_ms', 0)
cache_source = getattr(request.state, 'cache_source', None)
result_dict['cache_hit'] = cache_hit
result_dict['response_time_ms'] = response_time
# Always include cache_source, even if None
result_dict['cache_source'] = cache_source
return result_dict
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Eroare la obținerea defalcării balanței nete: {str(e)}")
raise HTTPException(status_code=500, detail=f"Eroare la obținerea defalcării balanței nete: {str(e)}")
@router.get("/current-period")
async def get_current_period(
request: Request,
company: int = Query(..., description="ID-ul firmei"),
current_user: CurrentUser = Depends(get_current_user)
):
"""
Returnează perioada curentă (an și lună) din calendarul Oracle
- Necesită autentificare JWT
- Returnează anul, luna și perioada curentă în format YYYY-MM
- Folosit pentru afișarea lunii curente în dashboard
"""
try:
# Verifică dacă utilizatorul are acces la firma specificată
if str(company) not in current_user.companies:
raise HTTPException(status_code=403, detail=f"Nu aveți acces la firma {company}")
server_id = getattr(request.state, 'server_id', None)
result = await DashboardService.get_current_period(company, server_id=server_id)
return result
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Eroare la obținerea perioadei curente: {str(e)}")
raise HTTPException(status_code=500, detail=f"Eroare la obținerea perioadei curente: {str(e)}")
@router.get(
"/financial-indicators",
tags=["dashboard"]
)
async def get_financial_indicators(
request: Request,
company: int = Query(..., description="ID-ul firmei (required)"),
luna: Optional[int] = Query(None, ge=1, le=12, description="Luna contabilă (1-12)"),
an: Optional[int] = Query(None, ge=2000, le=2100, description="Anul contabil"),
include_sparklines: bool = Query(True, description="Include date istorice pentru sparklines (12 luni)"),
current_user: CurrentUser = Depends(get_current_user)
):
"""
Returnează toți indicatorii financiari calculați pentru firma selectată.
Acest endpoint agregă datele din:
- Lichiditate: Current Ratio, Quick Ratio, Cash Ratio
- Eficiență: DSO, DPO, Cash Conversion Cycle, rate încasare/plată
- Risc: creanțe/datorii restante, raport datorii/trezorerie
- Cash Flow: flux net lunar, YTD, YoY, acoperire
- Dinamică: creștere vânzări/achiziții YoY, marjă implicită
- Altman Z-Score: scor și componente X1-X4
Parametri:
- company (required): ID-ul firmei pentru care se calculează indicatorii
- luna (optional): Luna contabilă (1-12). Dacă nu este specificată,
se folosește ultima perioadă disponibilă.
- an (optional): Anul contabil (2000-2100). Dacă nu este specificat,
se folosește anul curent.
- include_sparklines (optional, default=true): Dacă să includă date istorice
pentru vizualizarea trendului pe ultimele 12 luni (sparkline_data și sparkline_labels
în fiecare indicator)
Cache:
- TTL: 30 minute pentru indicatori curenți (cache_type='financial_indicators')
- TTL: 1 oră pentru date istorice sparkline (cache_type='financial_indicators_historical')
- Se invalidează automat la schimbarea datelor din balanță
Necesită autentificare JWT și acces la firma specificată.
"""
try:
# Verifică dacă utilizatorul are acces la firma specificată
if str(company) not in current_user.companies:
raise HTTPException(
status_code=403,
detail=f"Nu aveți acces la firma {company}"
)
# Dacă luna/an nu sunt specificate, obținem perioada curentă
# Folosim variabile tipizate explicit pentru a evita erori de tip
resolved_luna: int
resolved_an: int
server_id = getattr(request.state, 'server_id', None)
if luna is None or an is None:
try:
current_period = await DashboardService.get_current_period(company, server_id=server_id)
resolved_luna = luna if luna is not None else current_period.get('luna', 12)
resolved_an = an if an is not None else current_period.get('an', 2024)
except Exception as e:
logger.warning(f"Could not get current period: {e}, using defaults")
from datetime import datetime
resolved_luna = luna if luna is not None else datetime.now().month
resolved_an = an if an is not None else datetime.now().year
else:
resolved_luna = luna
resolved_an = an
# Dacă include_sparklines este True, folosim metoda care include datele istorice
if include_sparklines:
response = await FinancialIndicatorsService.get_indicators_with_sparklines(
company, resolved_luna, resolved_an, months=12, request=request, server_id=server_id
)
# FIX: Cache poate returna dict în loc de obiect Pydantic
# Extragem valorile pentru logging în mod compatibil cu ambele tipuri
if isinstance(response, dict):
zscore_val = response.get('altman_zscore', {}).get('zscore', {}).get('value')
zscore_status = response.get('altman_zscore', {}).get('zscore', {}).get('status')
else:
zscore_val = response.altman_zscore.zscore.value
zscore_status = response.altman_zscore.zscore.status
logger.info(
f"Financial indicators with sparklines for company {company}, "
f"luna={resolved_luna}, an={resolved_an}: "
f"Z-Score={zscore_val} ({zscore_status}), "
f"cache_hit={getattr(request.state, 'cache_hit', False)}, "
f"response_time={getattr(request.state, 'response_time_ms', 0):.1f}ms"
)
# Add cache metadata if requested (for Telegram Bot / Dashboard)
include_metadata = request.headers.get('X-Include-Cache-Metadata', '').lower() == 'true'
if include_metadata:
result_dict = response.dict() if hasattr(response, 'dict') else response
result_dict['cache_hit'] = getattr(request.state, 'cache_hit', False)
result_dict['response_time_ms'] = getattr(request.state, 'response_time_ms', 0)
result_dict['cache_source'] = getattr(request.state, 'cache_source', None)
return result_dict
return response
# Dacă include_sparklines este False, calculăm doar indicatorii curenți
import asyncio
# Apelăm serviciul pentru fiecare categorie de indicatori
lichiditate_task = FinancialIndicatorsService.calculate_liquidity_indicators(
company, resolved_luna, resolved_an, server_id=server_id
)
eficienta_task = FinancialIndicatorsService.calculate_efficiency_indicators(
company, resolved_luna, resolved_an, server_id=server_id
)
risc_task = FinancialIndicatorsService.calculate_risk_indicators(
company, resolved_luna, resolved_an, server_id=server_id
)
cash_flow_task = FinancialIndicatorsService.calculate_cashflow_indicators(
company, resolved_luna, resolved_an, server_id=server_id
)
dinamica_task = FinancialIndicatorsService.calculate_dynamics_indicators(
company, resolved_luna, resolved_an, server_id=server_id
)
altman_task = FinancialIndicatorsService.calculate_altman_zscore(
company, resolved_luna, resolved_an, server_id=server_id
)
profitabilitate_task = FinancialIndicatorsService.calculate_profitability_indicators(
company, resolved_luna, resolved_an, server_id=server_id
)
solvabilitate_task = FinancialIndicatorsService.calculate_solvability_indicators(
company, resolved_luna, resolved_an, server_id=server_id
)
# Executăm toate calculele în paralel pentru performanță
(
lichiditate,
eficienta,
risc,
cash_flow,
dinamica,
altman_zscore,
profitabilitate,
solvabilitate
) = await asyncio.gather(
lichiditate_task,
eficienta_task,
risc_task,
cash_flow_task,
dinamica_task,
altman_task,
profitabilitate_task,
solvabilitate_task
)
# Construim răspunsul
response = FinancialIndicatorsResponse(
lichiditate=lichiditate,
eficienta=eficienta,
risc=risc,
cash_flow=cash_flow,
dinamica=dinamica,
altman_zscore=altman_zscore,
profitabilitate=profitabilitate,
solvabilitate=solvabilitate
)
# FIX: Cache poate returna dict în loc de obiect Pydantic
if isinstance(altman_zscore, dict):
zscore_val = altman_zscore.get('zscore', {}).get('value')
zscore_status = altman_zscore.get('zscore', {}).get('status')
else:
zscore_val = altman_zscore.zscore.value
zscore_status = altman_zscore.zscore.status
logger.info(
f"Financial indicators for company {company}, luna={resolved_luna}, an={resolved_an}: "
f"Z-Score={zscore_val} ({zscore_status})"
)
# Add cache metadata if requested (for Telegram Bot / Dashboard)
include_metadata = request.headers.get('X-Include-Cache-Metadata', '').lower() == 'true'
if include_metadata:
result_dict = response.dict() if hasattr(response, 'dict') else response
result_dict['cache_hit'] = getattr(request.state, 'cache_hit', False)
result_dict['response_time_ms'] = getattr(request.state, 'response_time_ms', 0)
result_dict['cache_source'] = getattr(request.state, 'cache_source', None)
return result_dict
return response
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Eroare la obținerea indicatorilor financiari: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Eroare la obținerea indicatorilor financiari: {str(e)}"
)

View File

@@ -0,0 +1,140 @@
"""
API Router pentru facturi
"""
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from typing import List, Optional
from datetime import date
# import sys # Removed - no longer needed
import os
from shared.auth.dependencies import get_current_user, require_company_access
from shared.auth.models import CurrentUser
from ..models.invoice import InvoiceFilter, InvoiceListResponse, InvoiceSummary
from ..services.invoice_service import InvoiceService
router = APIRouter()
@router.get("/", response_model=InvoiceListResponse)
async def get_invoices(
request: Request,
company: str = Query(description="Codul firmei"),
partner_type: str = Query("CLIENTI", description="CLIENTI sau FURNIZORI"),
luna: Optional[int] = Query(None, ge=1, le=12, description="Luna contabilă (1-12)"),
an: Optional[int] = Query(None, ge=2000, le=2100, description="Anul contabil"),
partner_name: Optional[str] = Query(None, description="Filtru nume partener"),
cont: Optional[str] = Query(None, description="Filtru după cont contabil"),
only_unpaid: bool = Query(True, description="Doar facturile neachitate"),
min_amount: Optional[float] = Query(None, description="Suma minimă"),
max_amount: Optional[float] = Query(None, description="Suma maximă"),
page: int = Query(1, ge=1, description="Pagina"),
page_size: int = Query(50, ge=1, le=10000000, description="Mărimea paginii"),
current_user: CurrentUser = Depends(get_current_user)
):
"""
Obține lista de facturi pentru o firmă
- Necesită autentificare JWT
- Utilizatorul trebuie să aibă acces la firma specificată
- Suportă filtrare după luna/an contabil și paginare
"""
try:
# Verifică dacă utilizatorul are acces la firma specificată
if company not in current_user.companies:
raise HTTPException(status_code=403, detail=f"Nu aveți acces la firma {company}")
server_id = getattr(request.state, 'server_id', None)
filter_params = InvoiceFilter(
company=company,
partner_type=partner_type,
luna=luna,
an=an,
partner_name=partner_name,
cont=cont,
only_unpaid=only_unpaid,
min_amount=min_amount,
max_amount=max_amount,
page=page,
page_size=page_size
)
result = await InvoiceService.get_invoices(filter_params, current_user.username, server_id=server_id)
return result
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Eroare la obținerea facturilor: {str(e)}")
@router.get("/summary", response_model=InvoiceSummary)
async def get_invoices_summary(
request: Request,
company: str = Query(description="Codul firmei"),
partner_type: str = Query("CLIENTI", description="CLIENTI sau FURNIZORI"),
current_user: CurrentUser = Depends(get_current_user)
):
"""Obține rezumatul facturilor pentru dashboard"""
try:
# Verifică dacă utilizatorul are acces la firma specificată
if company not in current_user.companies:
raise HTTPException(status_code=403, detail=f"Nu aveți acces la firma {company}")
server_id = getattr(request.state, 'server_id', None)
result = await InvoiceService.get_invoice_summary(company, partner_type, current_user.username, server_id=server_id)
return result
except Exception as e:
raise HTTPException(status_code=500, detail=f"Eroare la obținerea rezumatului facturilor: {str(e)}")
@router.get("/{invoice_number}")
async def get_invoice_details(
request: Request,
invoice_number: str,
company: str = Query(description="Codul firmei"),
current_user: CurrentUser = Depends(get_current_user)
):
"""Obține detaliile unei facturi specifice"""
try:
# Verifică dacă utilizatorul are acces la firma specificată
if company not in current_user.companies:
raise HTTPException(status_code=403, detail=f"Nu aveți acces la firma {company}")
server_id = getattr(request.state, 'server_id', None)
result = await InvoiceService.get_invoice_details(company, invoice_number, current_user.username, server_id=server_id)
return result
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Eroare la obținerea detaliilor facturii: {str(e)}")
@router.get("/export/{format}")
async def export_invoices(
request: Request,
format: str,
company: str = Query(description="Codul firmei"),
partner_type: str = Query("CLIENTI", description="CLIENTI sau FURNIZORI"),
date_from: Optional[str] = Query(None, description="Data început (YYYY-MM-DD)"),
date_to: Optional[str] = Query(None, description="Data sfârșit (YYYY-MM-DD)"),
partner_name: Optional[str] = Query(None, description="Filtru nume partener"),
only_unpaid: bool = Query(True, description="Doar facturile neachitate"),
current_user: CurrentUser = Depends(get_current_user)
):
"""
Export facturi în format specificat (excel, pdf, csv)
Această funcție va fi implementată în viitor
"""
# Verifică dacă utilizatorul are acces la firma specificată
if company not in current_user.companies:
raise HTTPException(status_code=403, detail=f"Nu aveți acces la firma {company}")
server_id = getattr(request.state, 'server_id', None) # For future use
# Verifică formatul
if format not in ["excel", "pdf", "csv"]:
raise HTTPException(status_code=400, detail="Format invalid. Formatele suportate sunt: excel, pdf, csv")
# Pentru moment, returnează o eroare că funcția nu este implementată
raise HTTPException(status_code=501, detail=f"Export în format {format} nu este încă implementat")

View File

@@ -0,0 +1,123 @@
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from typing import Optional, List
from datetime import date
# import sys # Removed - no longer needed
import os
from shared.auth.dependencies import get_current_user
from shared.auth.models import CurrentUser
from ..models.treasury import RegisterFilter, RegisterListResponse
from ..services.treasury_service import TreasuryService
router = APIRouter()
@router.get("/bank-cash-register", response_model=RegisterListResponse)
async def get_bank_cash_register(
request: Request,
company: str = Query(description="Codul firmei"),
register_type: Optional[str] = Query(None, description="Tipul registrului: BANCA_LEI, BANCA_VALUTA, CASA_LEI, CASA_VALUTA sau None pentru toate"),
luna: Optional[int] = Query(None, ge=1, le=12, description="Luna contabilă (1-12)"),
an: Optional[int] = Query(None, ge=2000, le=2100, description="Anul contabil"),
date_from: Optional[str] = Query(None, description="Data început (YYYY-MM-DD)"),
date_to: Optional[str] = Query(None, description="Data sfârșit (YYYY-MM-DD)"),
partner_name: Optional[str] = Query(None, description="Filtru nume partener"),
bank_account: Optional[str] = Query(None, description="Filtru cont bancă/casă (bancasa)"),
page: int = Query(1, ge=1, description="Pagina"),
page_size: int = Query(50, ge=1, le=10000000, description="Mărimea paginii"),
current_user: CurrentUser = Depends(get_current_user)
):
"""
Obține registrul de casă și bancă
- Necesită autentificare JWT
- Suportă filtrare pe tip registru: BANCA_LEI, BANCA_VALUTA, CASA_LEI, CASA_VALUTA
- Suportă filtrare și paginare
"""
try:
# Verifică dacă utilizatorul are acces la firma specificată
if company not in current_user.companies:
raise HTTPException(status_code=403, detail=f"Nu aveți acces la firma {company}")
server_id = getattr(request.state, 'server_id', None)
# Validează register_type dacă e specificat
valid_types = ['BANCA_LEI', 'BANCA_VALUTA', 'CASA_LEI', 'CASA_VALUTA']
if register_type and register_type not in valid_types:
raise HTTPException(
status_code=400,
detail=f"Tip registru invalid. Valori acceptate: {', '.join(valid_types)}"
)
# Convertește datele
date_from_obj = None
date_to_obj = None
if date_from:
try:
date_from_obj = date.fromisoformat(date_from)
except ValueError:
raise HTTPException(status_code=400, detail="Format dată început invalid")
if date_to:
try:
date_to_obj = date.fromisoformat(date_to)
except ValueError:
raise HTTPException(status_code=400, detail="Format dată sfârșit invalid")
filter_params = RegisterFilter(
company=company,
register_type=register_type,
luna=luna,
an=an,
date_from=date_from_obj,
date_to=date_to_obj,
partner_name=partner_name,
bank_account=bank_account,
page=page,
page_size=page_size
)
result = await TreasuryService.get_bank_cash_register(filter_params, current_user.username, server_id=server_id)
return result
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Eroare la obținerea registrului: {str(e)}")
@router.get("/bank-cash-accounts", response_model=List[str])
async def get_bank_cash_accounts(
request: Request,
company: str = Query(description="Codul firmei"),
register_type: str = Query(description="Tipul registrului: BANCA_LEI, BANCA_VALUTA, CASA_LEI, CASA_VALUTA"),
current_user: CurrentUser = Depends(get_current_user)
):
"""
Obține lista distinctă de conturi bancă/casă pentru dropdown
- Necesită autentificare JWT
- Returnează lista de valori bancasa pentru tipul de registru selectat
"""
try:
# Verifică dacă utilizatorul are acces la firma specificată
if company not in current_user.companies:
raise HTTPException(status_code=403, detail=f"Nu aveți acces la firma {company}")
server_id = getattr(request.state, 'server_id', None)
# Validează register_type
valid_types = ['BANCA_LEI', 'BANCA_VALUTA', 'CASA_LEI', 'CASA_VALUTA']
if register_type not in valid_types:
raise HTTPException(
status_code=400,
detail=f"Tip registru invalid. Valori acceptate: {', '.join(valid_types)}"
)
result = await TreasuryService.get_bank_cash_accounts(int(company), register_type, server_id=server_id)
return result
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Eroare la obținerea conturilor: {str(e)}")

View File

@@ -0,0 +1,94 @@
"""
API Router for Trial Balance (Balanță de Verificare)
Refactored to use service layer with caching
"""
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from typing import Optional
from datetime import date
# import sys # Removed - no longer needed
import os
from shared.auth.dependencies import get_current_user
from shared.auth.models import CurrentUser
from ..models.trial_balance import TrialBalanceResponse
from ..services.trial_balance_service import TrialBalanceService
import logging
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/", response_model=TrialBalanceResponse)
async def get_trial_balance(
request: Request,
company: str = Query(description="Codul firmei (ID)"),
luna: Optional[int] = Query(None, ge=1, le=12, description="Luna (1-12), default: luna curentă"),
an: Optional[int] = Query(None, ge=2000, le=2100, description="An, default: anul curent"),
cont_filter: Optional[str] = Query(None, description="Filtru număr cont (ex: '512', '4111')"),
denumire_filter: Optional[str] = Query(None, description="Filtru denumire cont (partial match, case-insensitive)"),
sort_by: str = Query("CONT", description="Coloană pentru sortare"),
sort_order: str = Query("asc", description="Ordinea sortării (asc | desc)"),
page: int = Query(1, ge=1, description="Pagina"),
page_size: int = Query(50, ge=1, le=1000000, description="Mărimea paginii"),
current_user: CurrentUser = Depends(get_current_user)
):
"""
Obține balanța de verificare sintetică pentru o firmă
- Necesită autentificare JWT
- Utilizatorul trebuie să aibă acces la firma specificată
- Suportă filtrare după cont și denumire
- Suportă paginare și sortare
- **CACHED 10 min** - folosește sistem cache two-tier (L1 Memory + L2 SQLite)
"""
try:
# Verifică dacă utilizatorul are acces la firma specificată
if company not in current_user.companies:
raise HTTPException(
status_code=403,
detail=f"Nu aveți acces la firma {company}"
)
server_id = getattr(request.state, 'server_id', None)
# Setează valorile implicite pentru lună și an (luna și anul curent)
current_date = date.today()
if luna is None:
luna = current_date.month
if an is None:
an = current_date.year
# Convert company to int
company_id = int(company)
# Call service (with caching) - all business logic moved to service
data = await TrialBalanceService.get_trial_balance(
company_id=company_id,
luna=luna,
an=an,
cont_filter=cont_filter,
denumire_filter=denumire_filter,
sort_by=sort_by,
sort_order=sort_order,
page=page,
page_size=page_size,
username=current_user.username,
server_id=server_id
)
return TrialBalanceResponse(
success=True,
data=data
)
except ValueError as e:
# Schema not found or validation error
logger.error(f"Validation error in trial balance: {str(e)}")
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
# Log unexpected errors
logger.error(f"Error fetching trial balance: {str(e)}", exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Eroare la obținerea balanței de verificare: {str(e)}"
)

View File

@@ -0,0 +1,78 @@
"""
Calendar service for fetching available accounting periods
"""
# import sys # Removed - no longer needed
import os
from typing import Optional
from shared.database.oracle_pool import oracle_pool
from ..models.calendar import CalendarPeriod, CalendarPeriodsResponse
from ..cache.decorators import cached
import logging
logger = logging.getLogger(__name__)
class CalendarService:
"""Service for calendar/accounting period operations"""
# Romanian month names for display
MONTH_NAMES_RO = [
"Ianuarie", "Februarie", "Martie", "Aprilie", "Mai", "Iunie",
"Iulie", "August", "Septembrie", "Octombrie", "Noiembrie", "Decembrie"
]
@staticmethod
@cached(cache_type='schema', key_params=['company_id', 'server_id'])
async def _get_schema(company_id: int, server_id: Optional[str] = None) -> str:
"""Get schema for company (CACHED 24h)"""
async with oracle_pool.get_connection(server_id) as connection:
with connection.cursor() as cursor:
cursor.execute("""
SELECT schema FROM CONTAFIN_ORACLE.v_nom_firme
WHERE id_firma = :company_id
""", {'company_id': company_id})
result = cursor.fetchone()
return result[0] if result else None
@staticmethod
@cached(cache_type='calendar_periods', key_params=['company_id', 'server_id'])
async def get_available_periods(company_id: int, server_id: Optional[str] = None) -> CalendarPeriodsResponse:
"""
Get all available accounting periods for a company (CACHED 1h)
Returns periods ordered by year DESC, month DESC with Romanian month names.
"""
schema = await CalendarService._get_schema(company_id, server_id)
if not schema:
logger.warning(f"Schema not found for company {company_id}")
return CalendarPeriodsResponse(periods=[], current_period=None, total_count=0)
async with oracle_pool.get_connection(server_id) as connection:
with connection.cursor() as cursor:
cursor.execute(f"""
SELECT anul, luna
FROM {schema}.calendar
ORDER BY anul DESC, luna DESC
""")
rows = cursor.fetchall()
periods = []
for row in rows:
an, luna = row[0], row[1]
month_name = CalendarService.MONTH_NAMES_RO[luna - 1]
periods.append(CalendarPeriod(
an=an,
luna=luna,
display_name=f"{month_name} {an}"
))
current_period = periods[0] if periods else None
logger.info(f"Loaded {len(periods)} accounting periods for company {company_id}")
return CalendarPeriodsResponse(
periods=periods,
current_period=current_period,
total_count=len(periods)
)

View File

@@ -0,0 +1,324 @@
"""
Service pentru logica facturi - Portează query-urile din aplicația Flask
"""
# import sys # Removed - no longer needed
import os
from shared.database.oracle_pool import oracle_pool
from typing import List, Tuple, Optional
from ..models.invoice import Invoice, InvoiceFilter, InvoiceListResponse, InvoiceSummary
from ..cache.decorators import cached
from decimal import Decimal
import logging
logger = logging.getLogger(__name__)
class InvoiceService:
"""Service pentru gestionarea facturilor"""
@staticmethod
@cached(cache_type='schema', key_params=['company_id', 'server_id'])
async def _get_schema(company_id: int, server_id: Optional[str] = None) -> str:
"""Obține schema pentru company_id (CACHED PERMANENT)"""
async with oracle_pool.get_connection(server_id) as connection:
with connection.cursor() as cursor:
schema_query = """
SELECT schema
FROM CONTAFIN_ORACLE.v_nom_firme
WHERE id_firma = :company_id
"""
cursor.execute(schema_query, {'company_id': company_id})
schema_result = cursor.fetchone()
if not schema_result:
raise ValueError(f"Schema not found for company {company_id}")
return schema_result[0]
@staticmethod
@cached(cache_type='invoices', key_params=['filter_params', 'username', 'server_id'])
async def get_invoices(filter_params: InvoiceFilter, username: str, server_id: Optional[str] = None) -> InvoiceListResponse:
"""
Obține lista de facturi - Query simplu pentru afișare în tabel (CACHED 10 min)
"""
company_id = int(filter_params.company)
schema = await InvoiceService._get_schema(company_id, server_id)
async with oracle_pool.get_connection(server_id) as connection:
with connection.cursor() as cursor:
# Determină conturile în funcție de partner_type
if filter_params.partner_type == "CLIENTI":
conturi = "'4111', '461'"
elif filter_params.partner_type == "FURNIZORI":
conturi = "'401', '404', '462'"
else:
conturi = "'4111'" # default
# Determine period to use: from params or MAX from calendar
if filter_params.luna and filter_params.an:
period_condition = "vp.an = :an AND vp.luna = :luna"
use_param_period = True
else:
period_condition = f"""vp.an = (SELECT anul FROM {schema}.calendar WHERE anul*12+luna = (SELECT MAX(anul*12+luna) FROM {schema}.calendar))
AND vp.luna = (SELECT luna FROM {schema}.calendar WHERE anul*12+luna = (SELECT MAX(anul*12+luna) FROM {schema}.calendar))"""
use_param_period = False
# Query cu calculele corecte pentru solduri
base_query = f"""
SELECT
vp.NUME,
vp.NRACT,
vp.DATAACT,
vp.DATASCAD,
vp.CONTRACT,
vp.COD_FISCAL,
vp.REG_COMERT,
CASE
WHEN vp.CONT IN ('4111','461') THEN vp.PRECDEB + vp.DEBIT -- Total facturat clienți
WHEN vp.CONT IN ('401','404','462') THEN vp.PRECCRED + vp.CREDIT -- Total facturat furnizori
END as total_facturat,
CASE
WHEN vp.CONT IN ('4111','461') THEN vp.PRECCRED + vp.CREDIT -- Încasat clienți
WHEN vp.CONT IN ('401','404','462') THEN vp.PRECDEB + vp.DEBIT -- Achitat furnizori
END as achitat,
CASE
WHEN vp.CONT IN ('4111','461') THEN
(vp.PRECDEB + vp.DEBIT) - (vp.PRECCRED + vp.CREDIT) -- Sold clienți
WHEN vp.CONT IN ('401','404','462') THEN
(vp.PRECCRED + vp.CREDIT) - (vp.PRECDEB + vp.DEBIT) -- Sold furnizori
END as sold,
vp.CONT,
NVL(vp.NUME_VAL, 'RON') as valuta,
CASE
WHEN vp.DATASCAD < SYSDATE THEN 'restant'
ELSE 'in_termen'
END as status
FROM {schema}.vireg_parteneri vp
WHERE {period_condition}
AND (
(:partner_type = 'CLIENTI' AND vp.cont IN ('4111', '461'))
OR
(:partner_type = 'FURNIZORI' AND vp.cont IN ('401', '404', '462'))
)
"""
params = {'partner_type': filter_params.partner_type}
# Add period params if using explicit period
if use_param_period:
params['an'] = filter_params.an
params['luna'] = filter_params.luna
if filter_params.partner_name:
base_query += " AND UPPER(vp.nume) LIKE UPPER(:partner_name)"
params['partner_name'] = f"%{filter_params.partner_name}%"
if filter_params.cont:
base_query += " AND vp.cont = :cont"
params['cont'] = filter_params.cont
if filter_params.min_amount:
base_query += " AND total_facturat >= :min_amount"
params['min_amount'] = filter_params.min_amount
if filter_params.max_amount:
base_query += " AND total_facturat <= :max_amount"
params['max_amount'] = filter_params.max_amount
if filter_params.only_unpaid:
# Nu putem folosi aliasul "sold" în WHERE în Oracle, trebuie să repetăm calculul
base_query += """ AND (
CASE
WHEN vp.CONT IN ('4111','461') THEN
(vp.PRECDEB + vp.DEBIT) - (vp.PRECCRED + vp.CREDIT)
WHEN vp.CONT IN ('401','404','462') THEN
(vp.PRECCRED + vp.CREDIT) - (vp.PRECDEB + vp.DEBIT)
END
) > 0"""
# Count total pentru paginare
count_query = f"SELECT COUNT(*) FROM ({base_query})"
cursor.execute(count_query, params)
total_count = cursor.fetchone()[0]
# Query pentru TOTAL SOLD din TOATE facturile filtrate (nu doar pagina curentă)
total_sold_query = f"""
SELECT NVL(SUM(sold), 0) as total_sold
FROM ({base_query})
"""
cursor.execute(total_sold_query, params)
total_sold_result = cursor.fetchone()
total_sold_all = Decimal(str(total_sold_result[0])) if total_sold_result else Decimal('0.00')
# Get accounting period - use params if provided, else from calendar
if use_param_period:
accounting_period = {
'an': filter_params.an,
'luna': filter_params.luna
}
else:
period_query = f"""
SELECT anul, luna
FROM {schema}.calendar
WHERE anul*12+luna = (SELECT MAX(anul*12+luna) FROM {schema}.calendar)
"""
cursor.execute(period_query)
period_result = cursor.fetchone()
accounting_period = {
'an': period_result[0] if period_result else None,
'luna': period_result[1] if period_result else None
}
# Adaugă ORDER BY și paginare - Ordonare cronologică (DATAACT, NRACT, NUME)
base_query += " ORDER BY vp.DATAACT ASC, vp.NRACT ASC, vp.NUME"
# Paginare Oracle
offset = (filter_params.page - 1) * filter_params.page_size
limit = offset + filter_params.page_size
paginated_query = f"""
SELECT * FROM (
SELECT ROWNUM as rn, t.* FROM ({base_query}) t WHERE ROWNUM <= :limit
) WHERE rn > :offset
"""
params['offset'] = offset
params['limit'] = limit
cursor.execute(paginated_query, params)
rows = cursor.fetchall()
# Procesează rezultatele cu structura nouă
invoices = []
total_amount = Decimal('0.00')
for row in rows:
# Skip ROWNUM, extrage valorile din query-ul nou
nume = row[1]
nract = row[2]
dataact = row[3]
datascad = row[4]
contract = row[5]
cod_fiscal = row[6]
reg_comert = row[7]
total_facturat = Decimal(str(row[8] or 0))
achitat = Decimal(str(row[9] or 0))
sold = Decimal(str(row[10] or 0))
cont = row[11]
valuta = row[12] or 'RON'
status = row[13]
invoice_data = {
'nume': nume or '',
'nract': nract or 0,
'dataact': dataact,
'datascad': datascad,
'contract': contract,
'cod_fiscal': cod_fiscal,
'reg_comert': reg_comert,
'cont': cont,
'totctva': total_facturat,
'achitat': achitat,
'soldfinal': sold,
'valuta': valuta
}
invoice = Invoice(**invoice_data)
invoices.append(invoice)
total_amount += total_facturat
return InvoiceListResponse(
invoices=invoices,
total_count=total_count,
filtered_count=len(invoices),
total_amount=total_amount,
page=filter_params.page,
page_size=filter_params.page_size,
has_more=len(invoices) == filter_params.page_size,
accounting_period=accounting_period,
# Total sold din TOATE facturile filtrate
total_sold_all=total_sold_all
)
@staticmethod
async def get_invoice_details(company: str, invoice_number: str, username: str, server_id: Optional[str] = None) -> Invoice:
"""
Obține detaliile unei facturi specifice
"""
async with oracle_pool.get_connection(server_id) as connection:
with connection.cursor() as cursor:
# Obține schema din v_nom_firme bazat pe id_firma
company_id = int(company)
schema_query = "SELECT schema FROM CONTAFIN_ORACLE.v_nom_firme WHERE id_firma = :company_id"
cursor.execute(schema_query, {'company_id': company_id})
schema_result = cursor.fetchone()
if not schema_result:
raise ValueError(f"Schema nu a fost găsită pentru id_firma {company_id}")
schema = schema_result[0]
# Query simplu pentru detalii factură
detail_query = f"""
SELECT
NUME,
NRACT,
DATAACT,
DATASCAD,
CONTRACT,
COD_FISCAL,
REG_COMERT,
PRECDEB,
PRECCRED,
DEBIT,
CREDIT,
CONT
FROM {schema}.vireg_parteneri
WHERE nract = :invoice_number
AND an = (select anul from {schema}.calendar where anul*12+luna = (select max(anul*12+luna) as anmax from {schema}.calendar))
AND luna = (select luna from {schema}.calendar where anul*12+luna = (select max(anul*12+luna) as anmax from {schema}.calendar))
"""
cursor.execute(detail_query, {'invoice_number': invoice_number})
row = cursor.fetchone()
if not row:
raise ValueError(f"Factura {invoice_number} nu a fost găsită")
# Extrage valorile
nume = row[0]
nract = row[1]
dataact = row[2]
datascad = row[3]
contract = row[4]
cod_fiscal = row[5]
reg_comert = row[6]
precdeb = Decimal(str(row[7] or 0))
preccred = Decimal(str(row[8] or 0))
debit = Decimal(str(row[9] or 0))
credit = Decimal(str(row[10] or 0))
cont = row[11]
# Calculează valorile în funcție de tipul contului
if cont in ('4111', '461'): # CLIENTI
totctva = precdeb + debit
achitat = preccred + credit
soldfinal = precdeb - preccred + debit - credit
else: # FURNIZORI
totctva = preccred + credit
achitat = precdeb + debit
soldfinal = preccred - precdeb + credit - debit
invoice_data = {
'nume': nume or '',
'nract': nract or 0,
'dataact': dataact,
'datascad': datascad,
'contract': contract,
'cod_fiscal': cod_fiscal,
'reg_comert': reg_comert,
'totctva': totctva,
'achitat': achitat,
'soldfinal': soldfinal
}
return Invoice(**invoice_data)

View File

@@ -0,0 +1,410 @@
# import sys # Removed - no longer needed
import os
from typing import Optional, List, Tuple, Any
import oracledb
from shared.database.oracle_pool import oracle_pool
from ..models.treasury import BankCashRegister, RegisterFilter, RegisterListResponse, AccountingPeriod
from ..cache.decorators import cached
from decimal import Decimal
import logging
logger = logging.getLogger(__name__)
class TreasuryService:
"""Service pentru trezorerie - registru casă și bancă"""
@staticmethod
@cached(cache_type='schema', key_params=['company_id', 'server_id'])
async def _get_schema(company_id: int, server_id: Optional[str] = None) -> str:
"""Obține schema pentru company_id (CACHED PERMANENT)"""
async with oracle_pool.get_connection(server_id) as connection:
with connection.cursor() as cursor:
schema_query = """
SELECT schema
FROM CONTAFIN_ORACLE.v_nom_firme
WHERE id_firma = :company_id
"""
cursor.execute(schema_query, {'company_id': company_id})
schema_result = cursor.fetchone()
if not schema_result:
raise ValueError(f"Schema not found for company {company_id}")
return schema_result[0]
@staticmethod
def _get_view_query(schema: str, register_type: Optional[str] = None) -> str:
"""
Construiește query-ul pentru view-ul vbancasa corespunzător.
Dacă register_type este None, returnează UNION ALL pentru toate tipurile.
NU se filtrează pe incasari/plati > 0 - se afișează TOATE înregistrările!
"""
view_configs = {
'BANCA_LEI': {
'view': f'{schema}.vbancasa_5121_cum',
'incasari_col': 'incasari',
'plati_col': 'plati',
'valuta': "'RON'",
'tip': "'BANCA LEI'"
},
'BANCA_VALUTA': {
'view': f'{schema}.vbancasa_5124_cum',
'incasari_col': 'incasval',
'plati_col': 'platival',
'valuta': "COALESCE(numeval, 'EUR')",
'tip': "'BANCA VALUTA'"
},
'CASA_LEI': {
'view': f'{schema}.vbancasa_5311_cum',
'incasari_col': 'incasari',
'plati_col': 'plati',
'valuta': "'RON'",
'tip': "'CASA LEI'"
},
'CASA_VALUTA': {
'view': f'{schema}.vbancasa_5314_cum',
'incasari_col': 'incasval',
'plati_col': 'platival',
'valuta': "COALESCE(numeval, 'EUR')",
'tip': "'CASA VALUTA'"
}
}
def build_select(config):
# NU se filtrează - se afișează TOATE înregistrările
# SOLD CUMULAT: Running balance per bancasa using window function
# NULL-date rows (opening balance) come first due to NULLS FIRST
return f"""
SELECT
nume, nract, dataact, bancasa,
{config['incasari_col']} as incasari,
{config['plati_col']} as plati,
SUM({config['incasari_col']} - {config['plati_col']}) OVER (
PARTITION BY bancasa
ORDER BY dataact ASC NULLS FIRST, nract ASC NULLS FIRST
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) as sold,
{config['valuta']} as valuta,
{config['tip']} as tip_registru,
explicatia
FROM {config['view']}
"""
if register_type and register_type in view_configs:
return build_select(view_configs[register_type])
else:
# UNION ALL pentru toate tipurile
queries = [build_select(cfg) for cfg in view_configs.values()]
return " UNION ALL ".join(queries)
@staticmethod
@cached(cache_type='treasury', key_params=['filter_params', 'username', 'server_id'])
async def get_bank_cash_register(filter_params: RegisterFilter, username: str, server_id: Optional[str] = None) -> RegisterListResponse:
"""
Obține registrul de casă și bancă din vbancasa views (CACHED 10 min)
IMPORTANT: PACK_SESIUNE.SETAN și SETLUNA trebuie executate în ACEEAȘI
tranzacție cu SELECT-ul din vbancasa* views!
Folosim un bloc PL/SQL anonim care:
1. Obține anul și luna curentă din calendar
2. Apelează PACK_SESIUNE.SETAN și SETLUNA
3. Execută SELECT-ul din vbancasa*
Toate în aceeași tranzacție!
"""
company_id = int(filter_params.company)
schema = await TreasuryService._get_schema(company_id, server_id)
async with oracle_pool.get_connection(server_id) as connection:
with connection.cursor() as cursor:
# Construiește query-ul pentru tipul de registru selectat
base_select = TreasuryService._get_view_query(schema, filter_params.register_type)
# Construiește WHERE conditions
where_conditions = []
# Date filter preserves NULL-date rows (opening balance)
# for correct cumulative sum calculation
if filter_params.date_from and filter_params.date_to:
where_conditions.append(f"(dataact IS NULL OR (dataact >= TO_DATE('{filter_params.date_from.strftime('%Y-%m-%d')}', 'YYYY-MM-DD') AND dataact <= TO_DATE('{filter_params.date_to.strftime('%Y-%m-%d')}', 'YYYY-MM-DD')))")
elif filter_params.date_from:
where_conditions.append(f"(dataact IS NULL OR dataact >= TO_DATE('{filter_params.date_from.strftime('%Y-%m-%d')}', 'YYYY-MM-DD'))")
elif filter_params.date_to:
where_conditions.append(f"(dataact IS NULL OR dataact <= TO_DATE('{filter_params.date_to.strftime('%Y-%m-%d')}', 'YYYY-MM-DD'))")
if filter_params.partner_name:
# Escape single quotes pentru SQL
partner_escaped = filter_params.partner_name.replace("'", "''")
where_conditions.append(f"UPPER(nume) LIKE UPPER('%{partner_escaped}%')")
if filter_params.bank_account:
# Escape single quotes pentru SQL
bank_escaped = filter_params.bank_account.replace("'", "''")
where_conditions.append(f"bancasa = '{bank_escaped}'")
where_clause = ""
if where_conditions:
where_clause = " WHERE " + " AND ".join(where_conditions)
# Paginare Oracle
offset = (filter_params.page - 1) * filter_params.page_size
limit_val = filter_params.page_size
# Determine period to use: from params or MAX from calendar
if filter_params.luna and filter_params.an:
use_param_period = True
period_select = f"""
v_an := :param_an;
v_luna := :param_luna;
"""
else:
use_param_period = False
period_select = f"""
SELECT anul, luna INTO v_an, v_luna
FROM {schema}.calendar
WHERE anul*12+luna = (SELECT MAX(anul*12+luna) FROM {schema}.calendar);
"""
# Bloc PL/SQL anonim care face totul într-o singură tranzacție:
# 1. Obține anul și luna din params sau calendar
# 2. Setează PACK_SESIUNE.SETAN și SETLUNA
# 3. Returnează datele prin REF CURSOR
# IMPORTANT: Folosim ROW_NUMBER() pentru paginare corectă cu ORDER BY NULLS FIRST
plsql_block = f"""
DECLARE
v_an NUMBER;
v_luna NUMBER;
v_cursor SYS_REFCURSOR;
BEGIN
-- Obține anul și luna din parametri sau calendar
{period_select}
-- Setează contextul de sesiune (OBLIGATORIU înainte de SELECT din vbancasa*)
{schema}.PACK_SESIUNE.SETAN(v_an);
{schema}.PACK_SESIUNE.SETLUNA(v_luna);
-- Return accounting period
:out_an := v_an;
:out_luna := v_luna;
-- Returnează datele prin cursor cu ROW_NUMBER pentru paginare corectă
-- Pentru rânduri cu dataact=NULL (solduri precedente), sortare după bancasa
-- Pentru rânduri cu date, sortare după data, număr, bancasa
OPEN :result_cursor FOR
SELECT * FROM (
SELECT t.*, ROW_NUMBER() OVER (
ORDER BY dataact ASC NULLS FIRST,
CASE WHEN dataact IS NULL THEN bancasa END ASC,
nract ASC NULLS FIRST,
bancasa ASC
) as rn
FROM ({base_select}) t{where_clause}
) WHERE rn > {offset} AND rn <= {offset + limit_val};
END;
"""
# Creează cursor pentru rezultate (oracledb.CURSOR pentru REF CURSOR)
result_cursor = cursor.var(oracledb.CURSOR)
out_an = cursor.var(int)
out_luna = cursor.var(int)
# Build params dict
exec_params = {'result_cursor': result_cursor, 'out_an': out_an, 'out_luna': out_luna}
if use_param_period:
exec_params['param_an'] = filter_params.an
exec_params['param_luna'] = filter_params.luna
# Execută blocul PL/SQL cu REF CURSOR
cursor.execute(plsql_block, exec_params)
# Get accounting period values
accounting_year = out_an.getvalue()
accounting_month = out_luna.getvalue()
# Obține rezultatele din cursor
ref_cursor = result_cursor.getvalue()
rows = ref_cursor.fetchall()
ref_cursor.close()
# Pentru count total, executăm alt bloc PL/SQL
count_plsql = f"""
DECLARE
v_an NUMBER;
v_luna NUMBER;
BEGIN
-- Obține anul și luna din parametri sau calendar
{period_select}
{schema}.PACK_SESIUNE.SETAN(v_an);
{schema}.PACK_SESIUNE.SETLUNA(v_luna);
SELECT COUNT(*) INTO :total_count FROM ({base_select}) sub{where_clause};
END;
"""
total_count_var = cursor.var(int)
count_params = {'total_count': total_count_var}
if use_param_period:
count_params['param_an'] = filter_params.an
count_params['param_luna'] = filter_params.luna
cursor.execute(count_plsql, count_params)
total_count = total_count_var.getvalue()
# Query pentru TOTALURI din TOATE înregistrările filtrate (nu doar pagina curentă)
# sold_precedent = suma sold pentru rânduri cu dataact IS NULL
# total_incasari = suma incasari pentru rânduri cu dataact IS NOT NULL
# total_plati = suma plati pentru rânduri cu dataact IS NOT NULL
# Notă: where_clause poate fi gol sau poate conține "WHERE ..."
# Dacă e gol, adăugăm WHERE; dacă nu, adăugăm AND
dataact_null_cond = " AND dataact IS NULL" if where_clause else " WHERE dataact IS NULL"
dataact_not_null_cond = " AND dataact IS NOT NULL" if where_clause else " WHERE dataact IS NOT NULL"
totals_plsql = f"""
DECLARE
v_an NUMBER;
v_luna NUMBER;
BEGIN
-- Obține anul și luna din parametri sau calendar
{period_select}
{schema}.PACK_SESIUNE.SETAN(v_an);
{schema}.PACK_SESIUNE.SETLUNA(v_luna);
-- Sold precedent: suma sold pentru rânduri fără dată (opening balance)
SELECT NVL(SUM(sold), 0) INTO :sold_precedent_all
FROM ({base_select}) sub{where_clause}{dataact_null_cond};
-- Total încasări: suma incasari pentru rânduri cu dată (transactions)
SELECT NVL(SUM(incasari), 0) INTO :total_incasari_all
FROM ({base_select}) sub{where_clause}{dataact_not_null_cond};
-- Total plăți: suma plati pentru rânduri cu dată (transactions)
SELECT NVL(SUM(plati), 0) INTO :total_plati_all
FROM ({base_select}) sub{where_clause}{dataact_not_null_cond};
END;
"""
sold_precedent_all_var = cursor.var(oracledb.NUMBER)
total_incasari_all_var = cursor.var(oracledb.NUMBER)
total_plati_all_var = cursor.var(oracledb.NUMBER)
totals_params = {
'sold_precedent_all': sold_precedent_all_var,
'total_incasari_all': total_incasari_all_var,
'total_plati_all': total_plati_all_var
}
if use_param_period:
totals_params['param_an'] = filter_params.an
totals_params['param_luna'] = filter_params.luna
cursor.execute(totals_plsql, totals_params)
sold_precedent_all = Decimal(str(sold_precedent_all_var.getvalue() or 0))
total_incasari_all = Decimal(str(total_incasari_all_var.getvalue() or 0))
total_plati_all = Decimal(str(total_plati_all_var.getvalue() or 0))
sold_final_all = sold_precedent_all + total_incasari_all - total_plati_all
# Procesare rezultate
registers = []
total_incasari = Decimal('0.00')
total_plati = Decimal('0.00')
for row in rows:
# Coloane: nume, nract, dataact, bancasa, incasari, plati, sold, valuta, tip_registru, explicatia, rn
# row[0-9] = date, row[10] = rn (ROW_NUMBER la final)
register_data = BankCashRegister(
nume=row[0] or '',
nract=row[1],
dataact=row[2],
nume_cont_bancar=row[3] or '',
incasari=Decimal(str(row[4] or 0)),
plati=Decimal(str(row[5] or 0)),
sold=Decimal(str(row[6] or 0)),
valuta=row[7],
tip_registru=row[8],
explicatia=row[9] or ''
)
registers.append(register_data)
total_incasari += register_data.incasari
total_plati += register_data.plati
logger.info(f"Treasury query for company {company_id}, type={filter_params.register_type}: {len(registers)} records, total={total_count}")
return RegisterListResponse(
registers=registers,
total_count=total_count,
filtered_count=len(registers),
total_incasari=total_incasari,
total_plati=total_plati,
page=filter_params.page,
page_size=filter_params.page_size,
has_more=len(registers) == filter_params.page_size,
accounting_period=AccountingPeriod(an=accounting_year, luna=accounting_month),
# Totaluri din TOATE înregistrările filtrate
sold_precedent_all=sold_precedent_all,
total_incasari_all=total_incasari_all,
total_plati_all=total_plati_all,
sold_final_all=sold_final_all
)
@staticmethod
@cached(cache_type='treasury', key_params=['company_id', 'register_type', 'server_id'])
async def get_bank_cash_accounts(company_id: int, register_type: str, server_id: Optional[str] = None) -> List[str]:
"""
Obține lista distinctă de conturi bancă/casă (bancasa) pentru dropdown.
Cached pentru performanță.
IMPORTANT: Trebuie să setăm contextul PACK_SESIUNE înainte de a accesa vbancasa views!
"""
schema = await TreasuryService._get_schema(company_id, server_id)
# Map register_type to view
view_map = {
'BANCA_LEI': f'{schema}.vbancasa_5121_cum',
'BANCA_VALUTA': f'{schema}.vbancasa_5124_cum',
'CASA_LEI': f'{schema}.vbancasa_5311_cum',
'CASA_VALUTA': f'{schema}.vbancasa_5314_cum'
}
if register_type not in view_map:
return []
view_name = view_map[register_type]
async with oracle_pool.get_connection(server_id) as connection:
with connection.cursor() as cursor:
# PL/SQL block to set session context and get accounts
plsql_block = f"""
DECLARE
v_an NUMBER;
v_luna NUMBER;
BEGIN
-- Get current year and month from calendar
SELECT anul, luna INTO v_an, v_luna
FROM {schema}.calendar
WHERE anul*12+luna = (SELECT MAX(anul*12+luna) FROM {schema}.calendar);
-- Set session context (REQUIRED before accessing vbancasa* views)
{schema}.PACK_SESIUNE.SETAN(v_an);
{schema}.PACK_SESIUNE.SETLUNA(v_luna);
-- Return accounts via cursor
OPEN :result_cursor FOR
SELECT DISTINCT bancasa
FROM {view_name}
WHERE bancasa IS NOT NULL
ORDER BY bancasa;
END;
"""
result_cursor = cursor.var(oracledb.CURSOR)
cursor.execute(plsql_block, {'result_cursor': result_cursor})
ref_cursor = result_cursor.getvalue()
rows = ref_cursor.fetchall()
ref_cursor.close()
accounts = [row[0] for row in rows if row[0]]
logger.info(f"Found {len(accounts)} bank/cash accounts for company {company_id}, type={register_type}")
return accounts

View File

@@ -0,0 +1,219 @@
"""
Service pentru Trial Balance (Balanță de Verificare) - Query VBAL VIEW
Refactored to use caching system for optimal performance
"""
# import sys # Removed - no longer needed
import os
from typing import Dict, Any, Optional
from shared.database.oracle_pool import oracle_pool
from ..models.trial_balance import (
TrialBalanceItem,
TrialBalanceFilters,
TrialBalancePagination,
TrialBalanceResponse
)
from ..cache.decorators import cached
from decimal import Decimal
import math
import logging
logger = logging.getLogger(__name__)
class TrialBalanceService:
"""Service pentru gestionarea balanței de verificare cu cache"""
@staticmethod
@cached(cache_type='schema', key_params=['company_id', 'server_id'])
async def _get_schema(company_id: int, server_id: Optional[str] = None) -> str:
"""
Obține schema pentru company_id (CACHED 24h)
This is cached permanently because company schemas rarely change.
"""
async with oracle_pool.get_connection(server_id) as connection:
with connection.cursor() as cursor:
schema_query = """
SELECT schema
FROM CONTAFIN_ORACLE.v_nom_firme
WHERE id_firma = :company_id
"""
cursor.execute(schema_query, {'company_id': company_id})
schema_result = cursor.fetchone()
if not schema_result:
raise ValueError(f"Schema not found for company {company_id}")
return schema_result[0]
@staticmethod
@cached(cache_type='trial_balance', key_params=['company_id', 'luna', 'an', 'cont_filter',
'denumire_filter', 'sort_by', 'sort_order',
'page', 'page_size', 'username', 'server_id'])
async def get_trial_balance(
company_id: int,
luna: int,
an: int,
cont_filter: str | None,
denumire_filter: str | None,
sort_by: str,
sort_order: str,
page: int,
page_size: int,
username: str,
server_id: Optional[str] = None
) -> Dict[str, Any]:
"""
Obține balanța de verificare sintetică (CACHED 10 min)
Cache key includes all filter parameters to ensure unique cache entries
for different query variations.
Args:
company_id: ID firmei
luna: Luna (1-12)
an: Anul
cont_filter: Filtru număr cont (optional)
denumire_filter: Filtru denumire cont (optional)
sort_by: Coloană pentru sortare
sort_order: Ordinea sortării (asc/desc)
page: Pagina
page_size: Mărimea paginii
username: Username pentru cache tracking
server_id: Optional Oracle server identifier for multi-server support
Returns:
Dictionary cu items, pagination, filters_applied
"""
# Get schema (cached separately)
schema = await TrialBalanceService._get_schema(company_id, server_id)
# Validate sort_order
if sort_order.lower() not in ['asc', 'desc']:
sort_order = 'asc'
# Validate sort_by (prevent SQL injection)
valid_sort_columns = ['CONT', 'DENUMIRE', 'PRECDEB', 'PRECCRED',
'RULDEB', 'RULCRED', 'SOLDDEB', 'SOLDCRED']
if sort_by.upper() not in valid_sort_columns:
sort_by = 'CONT'
async with oracle_pool.get_connection(server_id) as connection:
with connection.cursor() as cursor:
# Build base query for VBAL VIEW
base_query = f"""
SELECT
CONT,
NVL(DENUMIRE, '') as DENUMIRE,
NVL(PRECDEB, 0) as PRECDEB,
NVL(PRECCRED, 0) as PRECCRED,
NVL(RULDEB, 0) as RULDEB,
NVL(RULCRED, 0) as RULCRED,
NVL(SOLDDEB, 0) as SOLDDEB,
NVL(SOLDCRED, 0) as SOLDCRED
FROM {schema}.VBAL
WHERE AN = :an
AND LUNA = :luna
"""
params = {
'an': an,
'luna': luna
}
# Add dynamic filters
if cont_filter:
base_query += " AND CONT LIKE :cont_filter"
params['cont_filter'] = f"{cont_filter}%"
if denumire_filter:
base_query += " AND UPPER(DENUMIRE) LIKE UPPER(:denumire_filter)"
params['denumire_filter'] = f"%{denumire_filter}%"
# Count total for pagination
count_query = f"SELECT COUNT(*) FROM ({base_query})"
cursor.execute(count_query, params)
total_count = cursor.fetchone()[0]
# Query pentru TOTALURI din TOATE înregistrările filtrate (nu doar pagina curentă)
totals_query = f"""
SELECT
NVL(SUM(PRECDEB), 0) as total_prec_deb,
NVL(SUM(PRECCRED), 0) as total_prec_cred,
NVL(SUM(RULDEB), 0) as total_rul_deb,
NVL(SUM(RULCRED), 0) as total_rul_cred,
NVL(SUM(SOLDDEB), 0) as total_sold_deb,
NVL(SUM(SOLDCRED), 0) as total_sold_cred
FROM ({base_query})
"""
cursor.execute(totals_query, params)
totals_row = cursor.fetchone()
totals = {
"total_sold_precedent_debit": Decimal(str(totals_row[0])) if totals_row else Decimal('0.00'),
"total_sold_precedent_credit": Decimal(str(totals_row[1])) if totals_row else Decimal('0.00'),
"total_rulaj_lunar_debit": Decimal(str(totals_row[2])) if totals_row else Decimal('0.00'),
"total_rulaj_lunar_credit": Decimal(str(totals_row[3])) if totals_row else Decimal('0.00'),
"total_sold_final_debit": Decimal(str(totals_row[4])) if totals_row else Decimal('0.00'),
"total_sold_final_credit": Decimal(str(totals_row[5])) if totals_row else Decimal('0.00')
}
# Add sorting
base_query += f" ORDER BY {sort_by.upper()} {sort_order.upper()}"
# Pagination (Oracle ROWNUM with ORDER BY)
offset = (page - 1) * page_size
limit = offset + page_size
paginated_query = f"""
SELECT * FROM (
SELECT a.*, ROWNUM rnum FROM (
{base_query}
) a WHERE ROWNUM <= :limit
) WHERE rnum > :offset
"""
params['offset'] = offset
params['limit'] = limit
cursor.execute(paginated_query, params)
rows = cursor.fetchall()
# Process results
# Index: CONT(0), DENUMIRE(1), PRECDEB(2), PRECCRED(3),
# RULDEB(4), RULCRED(5), SOLDDEB(6), SOLDCRED(7), rnum(8)
items = []
for row in rows:
item = TrialBalanceItem(
cont=row[0] or '',
denumire=row[1] or '',
sold_precedent_debit=Decimal(str(row[2] or 0)),
sold_precedent_credit=Decimal(str(row[3] or 0)),
rulaj_lunar_debit=Decimal(str(row[4] or 0)),
rulaj_lunar_credit=Decimal(str(row[5] or 0)),
sold_final_debit=Decimal(str(row[6] or 0)),
sold_final_credit=Decimal(str(row[7] or 0))
)
items.append(item.dict())
# Calculate pagination
total_pages = math.ceil(total_count / page_size) if page_size > 0 else 0
# Build response
return {
"items": items,
"pagination": {
"total_items": total_count,
"total_pages": total_pages,
"current_page": page,
"page_size": page_size
},
"filters_applied": {
"luna": luna,
"an": an,
"cont_filter": cont_filter,
"denumire_filter": denumire_filter
},
# Totaluri din TOATE înregistrările filtrate (nu doar pagina curentă)
"totals": totals
}