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
processors/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
"""Processors module for ACH file processing."""
from .data_mapper import DataMapper
from .file_processor import FileProcessor
__all__ = ['DataMapper', 'FileProcessor']

147
processors/data_mapper.py Normal file
View File

@@ -0,0 +1,147 @@
#!/usr/bin/env python3
"""
Data mapper for field transformations.
Maps ACH parser output to database format.
"""
from datetime import datetime
from decimal import Decimal
from typing import Dict, Any
from logging_config import get_logger
from db.models import TransactionRecord
logger = get_logger(__name__)
class DataMapper:
"""Maps parsed ACH transactions to database records."""
@staticmethod
def convert_date(date_str: str) -> datetime.date:
"""
Convert ACH date string to Python date.
ACH format: DD/MM/YY (e.g., '19/01/26')
Args:
date_str: Date string in DD/MM/YY format
Returns:
datetime.date object
"""
try:
if not date_str or len(date_str) < 8:
raise ValueError(f"Invalid date format: {date_str}")
# Parse DD/MM/YY
parsed_date = datetime.strptime(date_str, '%d/%m/%y')
return parsed_date.date()
except Exception as e:
logger.error(f"Error converting date '{date_str}': {e}")
# Return today's date as fallback
return datetime.now().date()
@staticmethod
def calculate_txnind(amount_str: str) -> str:
"""
Calculate transaction indicator from amount.
Args:
amount_str: Amount as string
Returns:
'CR' for credit (>= 0), 'DR' for debit (< 0)
"""
try:
amount = Decimal(amount_str.strip())
return 'DR' if amount < 0 else 'CR'
except Exception as e:
logger.error(f"Error calculating TXNIND for amount '{amount_str}': {e}")
return 'CR' # Default to credit
@staticmethod
def convert_amount(amount_str: str) -> Decimal:
"""
Convert amount string to Decimal.
Args:
amount_str: Amount as string
Returns:
Decimal representation of amount
"""
try:
if not amount_str:
return Decimal('0')
amount = Decimal(amount_str.strip())
return abs(amount) # Store absolute value, use TXNIND for sign
except Exception as e:
logger.error(f"Error converting amount '{amount_str}': {e}")
return Decimal('0')
@classmethod
def map_transaction(
cls,
parsed_transaction: Dict[str, Any],
bankcode: str
) -> TransactionRecord:
"""
Map parsed transaction to database record.
Args:
parsed_transaction: Transaction from ACHParser
bankcode: Bank code for this transaction
Returns:
TransactionRecord ready for database insertion
"""
try:
amount_str = parsed_transaction.get('amount', '0')
amount = cls.convert_amount(amount_str)
txnind = cls.calculate_txnind(amount_str)
tran_date = cls.convert_date(parsed_transaction.get('date', ''))
record = TransactionRecord(
narration=parsed_transaction.get('remarks', '')[:500], # Limit to 500 chars
status=parsed_transaction.get('sys', ''),
bankcode=bankcode,
jrnl_id=parsed_transaction.get('jrnl_no', ''),
tran_date=tran_date,
cbs_acct=parsed_transaction.get('cust_acct', ''),
tran_amt=amount,
txnind=txnind,
)
return record
except Exception as e:
logger.error(f"Error mapping transaction: {e}", exc_info=True)
raise
@classmethod
def map_transactions(
cls,
parsed_transactions: list,
bankcode: str
) -> list:
"""
Map multiple transactions.
Args:
parsed_transactions: List of transactions from ACHParser
bankcode: Bank code for these transactions
Returns:
List of TransactionRecord objects
"""
records = []
for txn in parsed_transactions:
try:
record = cls.map_transaction(txn, bankcode)
records.append(record)
except Exception as e:
logger.warning(f"Skipping transaction due to error: {e}")
continue
logger.info(f"Mapped {len(records)} transactions for bank {bankcode}")
return records

View File

@@ -0,0 +1,179 @@
#!/usr/bin/env python3
"""
Main file processor for end-to-end ACH file processing.
Orchestrates download, parsing, mapping, and database insertion.
"""
import os
import tempfile
from pathlib import Path
from logging_config import get_logger
from ach_parser import ACHParser
from db.repository import Repository
from db.models import ProcessedFile
from sftp.sftp_client import SFTPClient
from .data_mapper import DataMapper
logger = get_logger(__name__)
class FileProcessor:
"""Processes ACH files end-to-end."""
def __init__(self, repository: Repository = None, sftp_client: SFTPClient = None):
"""
Initialize file processor.
Args:
repository: Repository instance (optional)
sftp_client: SFTPClient instance (optional)
"""
self.repository = repository or Repository()
self.sftp_client = sftp_client or SFTPClient()
self.temp_dir = tempfile.gettempdir()
def process_file(
self,
filename: str,
bankcode: str,
remote_path: str
) -> bool:
"""
Process a single ACH file end-to-end.
Workflow:
1. Download file from SFTP
2. Parse using ACHParser
3. Map to database format
4. Insert to database
5. Mark as processed
6. Cleanup local file
Args:
filename: Name of file to process
bankcode: Bank code for this file
remote_path: Full remote path on SFTP
Returns:
True if successful, False otherwise
"""
local_path = os.path.join(self.temp_dir, filename)
try:
logger.info(f"Starting processing: {filename} (bank: {bankcode})")
# Step 1: Check if already processed
if self.repository.is_file_processed(filename):
logger.info(f"File already processed: {filename}")
return True
# Step 2: Download file
if not self.sftp_client.download_file(remote_path, local_path):
raise Exception(f"Failed to download file: {remote_path}")
# Step 3: Parse file
parser = ACHParser(local_path)
transactions, metadata, summary = parser.parse()
logger.info(f"Parsed {len(transactions)} transactions from {filename}")
if not transactions:
logger.warning(f"No transactions found in {filename}")
# Still mark as processed but with 0 transactions
processed_file = ProcessedFile(
filename=filename,
bankcode=bankcode,
file_path=remote_path,
transaction_count=0,
status='SUCCESS'
)
self.repository.mark_file_processed(processed_file)
return True
# Step 4: Map transactions
mapped_records = DataMapper.map_transactions(transactions, bankcode)
logger.info(f"Mapped {len(mapped_records)} transactions")
# Step 5: Insert to database
inserted_count = self.repository.bulk_insert_transactions(mapped_records)
# Step 6: Mark file as processed
processed_file = ProcessedFile(
filename=filename,
bankcode=bankcode,
file_path=remote_path,
transaction_count=inserted_count,
status='SUCCESS'
)
self.repository.mark_file_processed(processed_file)
logger.info(f"Successfully processed {filename}: {inserted_count} transactions inserted")
return True
except Exception as e:
logger.error(f"Error processing {filename}: {e}", exc_info=True)
# Mark file as failed
try:
processed_file = ProcessedFile(
filename=filename,
bankcode=bankcode,
file_path=remote_path,
transaction_count=0,
status='FAILED',
error_message=str(e)[:2000]
)
self.repository.mark_file_processed(processed_file)
except Exception as mark_error:
logger.error(f"Failed to mark file as failed: {mark_error}")
return False
finally:
# Cleanup local file
try:
if os.path.exists(local_path):
os.remove(local_path)
logger.debug(f"Cleaned up local file: {local_path}")
except Exception as e:
logger.warning(f"Error cleaning up local file {local_path}: {e}")
def process_files(self, files_to_process: list) -> dict:
"""
Process multiple files.
Args:
files_to_process: List of (filename, bankcode, remote_path) tuples
Returns:
Dictionary with processing statistics
"""
stats = {
'total': len(files_to_process),
'successful': 0,
'failed': 0,
'files': []
}
for filename, bankcode, remote_path in files_to_process:
success = self.process_file(filename, bankcode, remote_path)
stats['successful'] += 1 if success else 0
stats['failed'] += 0 if success else 1
stats['files'].append({
'filename': filename,
'bankcode': bankcode,
'success': success
})
logger.info(f"Processing complete: {stats['successful']}/{stats['total']} successful")
return stats
def __enter__(self):
"""Context manager entry."""
if self.sftp_client and not self.sftp_client.sftp:
self.sftp_client.connect()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Context manager exit."""
if self.sftp_client:
self.sftp_client.disconnect()