This commit is contained in:
2026-02-02 13:06:07 +05:30
commit 1b173f992a
41 changed files with 9380 additions and 0 deletions

6
db/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
"""Database module for ACH file processing."""
from .oracle_connector import OracleConnector
from .repository import Repository
__all__ = ['OracleConnector', 'Repository']

60
db/models.py Normal file
View File

@@ -0,0 +1,60 @@
#!/usr/bin/env python3
"""
Data models for ACH file processing.
Represents database records and transactions.
"""
from dataclasses import dataclass, asdict
from datetime import date, datetime
from decimal import Decimal
from typing import Optional
@dataclass
class TransactionRecord:
"""Represents a transaction record for ach_api_log table."""
narration: str
status: str
bankcode: str
jrnl_id: str
tran_date: date
cbs_acct: str
tran_amt: Decimal
txnind: str
def to_dict(self):
"""Convert to dictionary for database insertion."""
return {
'narration': self.narration,
'status': self.status,
'bankcode': self.bankcode,
'jrnl_id': self.jrnl_id,
'tran_date': self.tran_date,
'cbs_acct': self.cbs_acct,
'tran_amt': self.tran_amt,
'TXNIND': self.txnind,
}
@dataclass
class ProcessedFile:
"""Represents a processed file record for ach_processed_files table."""
filename: str
bankcode: str
file_path: str
transaction_count: int
status: str = 'SUCCESS'
error_message: Optional[str] = None
processed_at: Optional[datetime] = None
def to_dict(self):
"""Convert to dictionary for database insertion."""
return {
'filename': self.filename,
'bankcode': self.bankcode,
'file_path': self.file_path,
'transaction_count': self.transaction_count,
'status': self.status,
'error_message': self.error_message,
'processed_at': self.processed_at or datetime.now(),
}

123
db/oracle_connector.py Normal file
View File

@@ -0,0 +1,123 @@
#!/usr/bin/env python3
"""
Oracle database connection pool manager using oracledb.
Manages connections with pooling and health checks.
oracledb is the modern, simpler replacement for cx_Oracle.
No Oracle Instant Client required - uses Thick or Thin mode.
"""
import oracledb
from logging_config import get_logger
from config import get_config
logger = get_logger(__name__)
class OracleConnector:
"""Manages Oracle database connections with pooling."""
def __init__(self):
"""Initialize connection pool."""
self.pool = None
self.config = get_config()
self._initialize_client_mode()
def _initialize_client_mode(self):
"""
Initialize oracledb client mode.
oracledb uses Thin mode by default (no Oracle Instant Client needed).
"""
try:
# oracledb defaults to Thin mode - no initialization needed
logger.info("Using oracledb Thin mode (no Oracle Instant Client required)")
except Exception as e:
logger.warning(f"Oracle client initialization note: {e}")
def initialize_pool(self):
"""Create connection pool."""
try:
# Build connection string for oracledb
# Format: user/password@host:port/service_name
connection_string = (
f"{self.config.db_user}/{self.config.db_password}@"
f"{self.config.db_host}:{self.config.db_port}/{self.config.db_service_name}"
)
# Create connection pool using oracledb API
# Note: oracledb uses 'min' and 'max' for pool sizing
self.pool = oracledb.create_pool(
dsn=connection_string,
min=self.config.db_pool_min,
max=self.config.db_pool_max,
increment=1,
)
logger.info(f"Oracle connection pool initialized: min={self.config.db_pool_min}, max={self.config.db_pool_max}")
return True
except oracledb.DatabaseError as e:
logger.error(f"Failed to initialize connection pool: {e}", exc_info=True)
return False
except Exception as e:
logger.error(f"Unexpected error initializing pool: {e}", exc_info=True)
return False
def get_connection(self):
"""Get connection from pool."""
if not self.pool:
self.initialize_pool()
try:
conn = self.pool.acquire()
logger.debug("Connection acquired from pool")
return conn
except oracledb.DatabaseError as e:
logger.error(f"Failed to acquire connection: {e}", exc_info=True)
raise
except Exception as e:
logger.error(f"Unexpected error acquiring connection: {e}", exc_info=True)
raise
def close_pool(self):
"""Close connection pool."""
if self.pool:
try:
self.pool.close()
logger.info("Connection pool closed")
except Exception as e:
logger.error(f"Error closing pool: {e}")
def test_connection(self):
"""Test database connectivity."""
try:
conn = self.get_connection()
cursor = conn.cursor()
cursor.execute("SELECT 1 FROM dual")
result = cursor.fetchone()
cursor.close()
conn.close()
logger.info("Database connection test successful")
return True
except Exception as e:
logger.error(f"Database connection test failed: {e}")
return False
def __enter__(self):
"""Context manager entry."""
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Context manager exit."""
self.close_pool()
# Global connector instance
_connector = None
def get_connector():
"""Get or create global connector instance."""
global _connector
if _connector is None:
_connector = OracleConnector()
return _connector

200
db/repository.py Normal file
View File

@@ -0,0 +1,200 @@
#!/usr/bin/env python3
"""
Data access layer for ACH file processing.
Handles CRUD operations and transaction management.
"""
from typing import List, Optional
from logging_config import get_logger
from .oracle_connector import get_connector
from .models import TransactionRecord, ProcessedFile
logger = get_logger(__name__)
class Repository:
"""Data access layer for ACH processing."""
def __init__(self):
"""Initialize repository with connector."""
self.connector = get_connector()
def bulk_insert_transactions(self, transactions: List[TransactionRecord]) -> int:
"""
Bulk insert transaction records into ach_api_log.
Args:
transactions: List of TransactionRecord objects
Returns:
Number of inserted records
"""
if not transactions:
logger.warning("No transactions to insert")
return 0
conn = self.connector.get_connection()
try:
cursor = conn.cursor()
# Prepare batch data
batch_data = [txn.to_dict() for txn in transactions]
# Execute batch insert
insert_sql = """
INSERT INTO ach_api_log (
narration, status, bankcode, jrnl_id,
tran_date, cbs_acct, tran_amt, TXNIND
) VALUES (
:narration, :status, :bankcode, :jrnl_id,
:tran_date, :cbs_acct, :tran_amt, :TXNIND
)
"""
cursor.executemany(insert_sql, batch_data)
conn.commit()
count = len(transactions)
logger.info(f"Successfully inserted {count} transactions into ach_api_log")
return count
except Exception as e:
conn.rollback()
logger.error(f"Error inserting transactions: {e}", exc_info=True)
raise
finally:
cursor.close()
conn.close()
def is_file_processed(self, filename: str) -> bool:
"""
Check if file has already been processed.
Args:
filename: Name of the file to check
Returns:
True if file is in processed list, False otherwise
"""
conn = self.connector.get_connection()
try:
cursor = conn.cursor()
cursor.execute(
"SELECT COUNT(*) FROM ach_processed_files WHERE filename = :filename",
{'filename': filename}
)
count = cursor.fetchone()[0]
return count > 0
except Exception as e:
logger.error(f"Error checking processed file: {e}")
return False
finally:
cursor.close()
conn.close()
def mark_file_processed(self, processed_file: ProcessedFile) -> bool:
"""
Insert record into ach_processed_files to mark file as processed.
Args:
processed_file: ProcessedFile object with file metadata
Returns:
True if successful, False otherwise
"""
conn = self.connector.get_connection()
try:
cursor = conn.cursor()
file_data = processed_file.to_dict()
insert_sql = """
INSERT INTO ach_processed_files (
filename, bankcode, file_path, transaction_count,
status, error_message, processed_at
) VALUES (
:filename, :bankcode, :file_path, :transaction_count,
:status, :error_message, :processed_at
)
"""
cursor.execute(insert_sql, file_data)
conn.commit()
logger.info(f"Marked file as processed: {processed_file.filename}")
return True
except Exception as e:
conn.rollback()
logger.error(f"Error marking file as processed: {e}", exc_info=True)
return False
finally:
cursor.close()
conn.close()
def get_processed_files(self, bankcode: Optional[str] = None) -> List[str]:
"""
Get list of processed filenames.
Args:
bankcode: Optional bankcode filter
Returns:
List of filenames that have been processed
"""
conn = self.connector.get_connection()
try:
cursor = conn.cursor()
if bankcode:
cursor.execute(
"SELECT filename FROM ach_processed_files WHERE bankcode = :bankcode ORDER BY processed_at DESC",
{'bankcode': bankcode}
)
else:
cursor.execute("SELECT filename FROM ach_processed_files ORDER BY processed_at DESC")
filenames = [row[0] for row in cursor.fetchall()]
return filenames
except Exception as e:
logger.error(f"Error retrieving processed files: {e}")
return []
finally:
cursor.close()
conn.close()
def verify_tables_exist(self):
"""
Verify that required database tables exist.
If tables are missing, terminate the program.
"""
conn = self.connector.get_connection()
try:
cursor = conn.cursor()
# Check if ach_api_log table exists
try:
cursor.execute("SELECT COUNT(*) FROM ach_api_log WHERE ROWNUM = 1")
logger.info("✓ ach_api_log table exists")
except Exception as e:
logger.error(f"✗ ach_api_log table not found: {e}")
raise SystemExit("FATAL: ach_api_log table must be created manually before running this application")
# Check if ach_processed_files table exists
try:
cursor.execute("SELECT COUNT(*) FROM ach_processed_files WHERE ROWNUM = 1")
logger.info("✓ ach_processed_files table exists")
except Exception as e:
logger.error(f"✗ ach_processed_files table not found: {e}")
raise SystemExit("FATAL: ach_processed_files table must be created manually before running this application")
logger.info("Database tables verified successfully")
except SystemExit:
raise
except Exception as e:
logger.error(f"Error verifying tables: {e}", exc_info=True)
raise SystemExit(f"FATAL: Error verifying database tables: {e}")
finally:
cursor.close()
conn.close()