Initial commit
This commit is contained in:
6
db/__init__.py
Normal file
6
db/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Database module for ACH file processing."""
|
||||
|
||||
from .oracle_connector import OracleConnector
|
||||
from .repository import Repository
|
||||
|
||||
__all__ = ['OracleConnector', 'Repository']
|
||||
77
db/models.py
Normal file
77
db/models.py
Normal file
@@ -0,0 +1,77 @@
|
||||
#!/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 NEFTOutwardRecord:
|
||||
"""Represents a parsed NEFT Inward transaction mapped to DB columns."""
|
||||
|
||||
txnind: str # VARCHAR2(2), default "DR"
|
||||
jrnl_id: str # VARCHAR2(4000), NOT NULL
|
||||
ref_no: str # VARCHAR2(400), NOT NULL
|
||||
txn_date: str # VARCHAR2(100), NOT NULL
|
||||
txn_amt: Optional[Decimal] # NUMBER(17,2)
|
||||
sender_ifsc: str # VARCHAR2(400)
|
||||
reciever_ifsc: str # VARCHAR2(400)
|
||||
sender_acct_no: str # VARCHAR2(400)
|
||||
sender_acct_name: str # VARCHAR2(400)
|
||||
recvr_acct_no: str # VARCHAR2(400)
|
||||
recvr_acct_name: str # VARCHAR2(400)
|
||||
reject_code: str # VARCHAR2(400)
|
||||
reject_reason: str # VARCHAR2(400)
|
||||
benef_address: str # VARCHAR2(400)
|
||||
msg_type: str # VARCHAR2(400)
|
||||
bank_code: str
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert to dictionary for DB insertion."""
|
||||
return {
|
||||
"TXNIND": self.txnind,
|
||||
"BANKCODE": self.bank_code,
|
||||
"JRNL_ID": self.jrnl_id,
|
||||
"REF_NO": self.ref_no,
|
||||
"TRAN_DATE": self.txn_date,
|
||||
"TXN_AMT": self.txn_amt,
|
||||
"SENDER_IFSC": self.sender_ifsc,
|
||||
"RECIEVER_IFSC": self.reciever_ifsc,
|
||||
"SENDER_ACCT_NO": self.sender_acct_no,
|
||||
"SENDER_NAME": self.sender_acct_name,
|
||||
"RECVR_ACCT_NO": self.recvr_acct_no,
|
||||
"RECIEVER_NAME": self.recvr_acct_name,
|
||||
"REJECT_CODE": self.reject_code,
|
||||
"REJECT_REASON": self.reject_reason,
|
||||
"BENEFICIARY_ADDRESS": self.benef_address,
|
||||
"MSG_TYPE": self.msg_type,
|
||||
}
|
||||
|
||||
|
||||
@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(),
|
||||
}
|
||||
111
db/oracle_connector.py
Normal file
111
db/oracle_connector.py
Normal file
@@ -0,0 +1,111 @@
|
||||
#!/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()
|
||||
|
||||
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.debug(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.debug("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
|
||||
307
db/repository.py
Normal file
307
db/repository.py
Normal file
@@ -0,0 +1,307 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Data access layer for NEFT inward 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 NEFTInwardRecord, ProcessedFile
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class Repository:
|
||||
"""Data access layer for NEFT inward processing."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize repository with connector."""
|
||||
self.connector = get_connector()
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# ADDED: Account validation using last 12 digits of RECVR_ACCT_NO
|
||||
# ---------------------------------------------------------
|
||||
def validate_account_exists(self, account_number: str) -> bool:
|
||||
"""
|
||||
Validate if account number exists in dep_account table.
|
||||
|
||||
Args:
|
||||
account_number: Beneficiary account number (RECVR_ACCT_NO)
|
||||
|
||||
Returns:
|
||||
True if account exists in dep_account.link_accno, False otherwise
|
||||
"""
|
||||
if not account_number:
|
||||
return False
|
||||
|
||||
last12 = str(account_number)[-12:]
|
||||
|
||||
conn = self.connector.get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"SELECT COUNT(*) FROM dep_account WHERE link_accno = :accno",
|
||||
{'accno': last12}
|
||||
)
|
||||
count = cursor.fetchone()[0]
|
||||
return count > 0
|
||||
except Exception as e:
|
||||
logger.warning(f"Error validating account {account_number}: {e}")
|
||||
return False
|
||||
finally:
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# UPDATED: bulk_insert_transactions WITH VALIDATION
|
||||
# ---------------------------------------------------------
|
||||
def bulk_insert_transactions(self, transactions: List[NEFTInwardRecord]) -> tuple:
|
||||
"""
|
||||
Bulk insert NEFT transactions into inward_neft_api_log.
|
||||
Records with invalid beneficiary account numbers are skipped.
|
||||
|
||||
Args:
|
||||
transactions: List of NEFTOutwardRecord objects
|
||||
|
||||
Returns:
|
||||
(inserted_count, skipped_count)
|
||||
"""
|
||||
if not transactions:
|
||||
logger.warning("No transactions to insert")
|
||||
return 0, 0
|
||||
|
||||
valid_transactions = []
|
||||
skipped_count = 0
|
||||
|
||||
|
||||
for txn in transactions:
|
||||
acct = txn.sender_acct_no
|
||||
|
||||
if self.validate_account_exists(acct):
|
||||
valid_transactions.append(txn)
|
||||
else:
|
||||
skipped_count += 1
|
||||
|
||||
if not valid_transactions:
|
||||
logger.debug(f"All {skipped_count} transactions skipped (invalid Remitter accounts)")
|
||||
return 0, skipped_count
|
||||
|
||||
conn = self.connector.get_connection()
|
||||
cursor = None
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
|
||||
batch_data = [txn.to_dict() for txn in valid_transactions]
|
||||
logger.info(batch_data)
|
||||
|
||||
insert_sql = """
|
||||
INSERT INTO outward_neft_api_log (
|
||||
TXNIND,
|
||||
JRNL_ID,
|
||||
BANKCODE,
|
||||
REF_NO,
|
||||
TRAN_DATE,
|
||||
TXN_AMT,
|
||||
SENDER_IFSC,
|
||||
RECIEVER_IFSC,
|
||||
SENDER_ACCT_NO,
|
||||
SENDER_NAME,
|
||||
RECVR_ACCT_NO,
|
||||
RECIEVER_NAME,
|
||||
REJECT_CODE,
|
||||
REJECT_REASON,
|
||||
BENEFICIARY_ADDRESS,
|
||||
MSG_TYPE
|
||||
) VALUES (
|
||||
:TXNIND,
|
||||
:JRNL_ID,
|
||||
:BANKCODE,
|
||||
:REF_NO,
|
||||
:TRAN_DATE,
|
||||
:TXN_AMT,
|
||||
:SENDER_IFSC,
|
||||
:RECIEVER_IFSC,
|
||||
:SENDER_ACCT_NO,
|
||||
:SENDER_NAME,
|
||||
:RECVR_ACCT_NO,
|
||||
:RECIEVER_NAME,
|
||||
:REJECT_CODE,
|
||||
:REJECT_REASON,
|
||||
:BENEFICIARY_ADDRESS,
|
||||
:MSG_TYPE
|
||||
)
|
||||
"""
|
||||
|
||||
cursor.executemany(insert_sql, batch_data)
|
||||
conn.commit()
|
||||
|
||||
inserted_count = len(valid_transactions)
|
||||
logger.info(f"Inserted {inserted_count} NEFT transactions into outward_neft_api_log")
|
||||
return inserted_count, skipped_count
|
||||
|
||||
except Exception as e:
|
||||
if conn:
|
||||
conn.rollback()
|
||||
logger.error(f"Error inserting NEFT transactions: {e}", exc_info=True)
|
||||
raise
|
||||
finally:
|
||||
if cursor:
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# NOTHING ELSE BELOW THIS LINE WAS TOUCHED
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def is_file_processed(self, filename: str, bankcode: str) -> bool:
|
||||
conn = self.connector.get_connection()
|
||||
cursor = None
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM neft_processed_files
|
||||
WHERE filename = :filename
|
||||
AND bankcode = :bankcode
|
||||
""",
|
||||
{'filename': filename, 'bankcode': bankcode}
|
||||
)
|
||||
count = cursor.fetchone()[0]
|
||||
return count > 0
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking processed file: {e}", exc_info=True)
|
||||
return False
|
||||
finally:
|
||||
if cursor:
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
def mark_file_processed(self, processed_file: ProcessedFile) -> bool:
|
||||
conn = self.connector.get_connection()
|
||||
cursor = None
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
|
||||
file_data = processed_file.to_dict()
|
||||
insert_sql = """
|
||||
INSERT INTO neft_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:
|
||||
if conn:
|
||||
conn.rollback()
|
||||
logger.error(f"Error marking file as processed: {e}", exc_info=True)
|
||||
return False
|
||||
finally:
|
||||
if cursor:
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
def get_processed_files(self, bankcode: Optional[str] = None) -> List[str]:
|
||||
conn = self.connector.get_connection()
|
||||
cursor = None
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
|
||||
if bankcode:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT filename
|
||||
FROM neft_processed_files
|
||||
WHERE bankcode = :bankcode
|
||||
ORDER BY processed_at DESC
|
||||
""",
|
||||
{'bankcode': bankcode}
|
||||
)
|
||||
else:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT filename
|
||||
FROM neft_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}", exc_info=True)
|
||||
return []
|
||||
finally:
|
||||
if cursor:
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
def call_neft_api_txn_post(self) -> bool:
|
||||
conn = self.connector.get_connection()
|
||||
cursor = None
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
logger.info("Calling neft_api_txn_post procedure to process all inserted transactions...")
|
||||
|
||||
try:
|
||||
cursor.callproc('neft_api_txn_post')
|
||||
except Exception:
|
||||
cursor.execute("BEGIN neft_api_txn_post; END;")
|
||||
|
||||
conn.commit()
|
||||
logger.info("neft_api_txn_post procedure executed successfully")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error calling neft_api_txn_post procedure: {e}", exc_info=True)
|
||||
return False
|
||||
finally:
|
||||
if cursor:
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
def verify_tables_exist(self):
|
||||
conn = self.connector.get_connection()
|
||||
cursor = None
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute("SELECT COUNT(*) FROM inward_neft_api_log WHERE ROWNUM = 1")
|
||||
logger.info("✓ inward_neft_api_log table exists")
|
||||
except Exception as e:
|
||||
logger.error(f"✗ inward_neft_api_log table not found: {e}")
|
||||
raise SystemExit(
|
||||
"FATAL: inward_neft_api_log table must be created manually before running this application"
|
||||
)
|
||||
|
||||
try:
|
||||
cursor.execute("SELECT COUNT(*) FROM neft_processed_files WHERE ROWNUM = 1")
|
||||
logger.info("✓ neft_processed_files table exists")
|
||||
except Exception as e:
|
||||
logger.error(f"✗ neft_processed_files table not found: {e}")
|
||||
raise SystemExit(
|
||||
"FATAL: neft_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:
|
||||
if cursor:
|
||||
cursor.close()
|
||||
conn.close()
|
||||
Reference in New Issue
Block a user