Initial commit

This commit is contained in:
gsinghpal
2026-02-22 01:22:18 -05:00
commit 5200d5baf0
2394 changed files with 386834 additions and 0 deletions

View File

@@ -0,0 +1,312 @@
# Fusion Accounting - Unified Bank Statement Import Wizard
# Accepts file uploads, auto-detects format (CSV, OFX, QIF, CAMT.053),
# routes to the correct parser, and creates bank statement lines.
import base64
import logging
from odoo import _, api, fields, models
from odoo.exceptions import UserError
from ..models.bank_statement_import_ofx import FusionOFXParser
from ..models.bank_statement_import_qif import FusionQIFParser
from ..models.bank_statement_import_camt import FusionCAMTParser
_log = logging.getLogger(__name__)
class FusionBankStatementImportWizard(models.TransientModel):
"""Transient wizard that provides a unified interface for importing
bank statements from multiple file formats.
The wizard:
1. Accepts a binary file upload from the user
2. Auto-detects the file format (OFX, QIF, CAMT.053, or CSV)
3. Routes the file to the appropriate parser
4. Creates ``account.bank.statement`` and ``account.bank.statement.line``
records from the parsed data
5. Reports the import results (or errors) to the user
"""
_name = 'fusion.bank.statement.import'
_description = 'Import Bank Statement'
# ---- Fields ----
journal_id = fields.Many2one(
'account.journal',
string='Bank Journal',
required=True,
domain="[('type', 'in', ('bank', 'credit', 'cash'))]",
default=lambda self: self._default_journal_id(),
help="The journal to import the bank statement into.",
)
data_file = fields.Binary(
string='Bank Statement File',
required=True,
help=(
"Upload a bank statement file. Supported formats: "
"OFX, QIF, CAMT.053 (XML), and CSV."
),
)
filename = fields.Char(
string='Filename',
help="Name of the uploaded file.",
)
detected_format = fields.Char(
string='Detected Format',
readonly=True,
help="The file format detected by the auto-detection engine.",
)
import_result = fields.Text(
string='Import Result',
readonly=True,
help="Summary of the import operation.",
)
# ---- Defaults ----
@api.model
def _default_journal_id(self):
"""Default to the journal from context, or the first bank journal."""
journal_id = self.env.context.get('default_journal_id')
if journal_id:
return journal_id
return self.env['account.journal'].search(
[('type', '=', 'bank')], limit=1,
).id or False
# ---- Onchange: auto-detect on file upload ----
@api.onchange('data_file', 'filename')
def _onchange_data_file(self):
"""Auto-detect the file format when a file is uploaded."""
if not self.data_file:
self.detected_format = ''
return
raw_data = base64.b64decode(self.data_file)
fmt = self._detect_format(raw_data, self.filename or '')
self.detected_format = fmt
# ---- Format detection ----
@staticmethod
def _detect_format(raw_data, filename=''):
"""Examine the file content (and optionally the filename) to
determine the file format.
Returns one of: ``'OFX'``, ``'QIF'``, ``'CAMT.053'``, ``'CSV'``,
or ``'Unknown'``.
"""
# Decode a preview of the file for text-based detection
try:
preview = raw_data[:8192].decode('utf-8-sig', errors='ignore')
except Exception:
preview = ''
preview_upper = preview.upper().strip()
fname_lower = (filename or '').lower().strip()
# --- CAMT.053 (XML with ISO 20022 namespace) ---
if 'camt.053' in preview.lower() or 'BkToCstmrStmt' in preview:
return 'CAMT.053'
# --- OFX (v1 SGML or v2 XML) ---
if (preview_upper.startswith('<?OFX') or
'<OFX>' in preview_upper or
'OFXHEADER:' in preview_upper):
return 'OFX'
if fname_lower.endswith('.ofx'):
return 'OFX'
# --- QIF ---
if preview_upper.startswith('!TYPE:') or preview_upper.startswith('!ACCOUNT:'):
return 'QIF'
if fname_lower.endswith('.qif'):
return 'QIF'
# --- CSV / spreadsheet ---
if fname_lower.endswith(('.csv', '.xls', '.xlsx')):
return 'CSV'
# Content-based CSV heuristic: looks for comma-separated or
# semicolon-separated tabular data
lines = preview.split('\n')
if len(lines) >= 2:
first_line = lines[0].strip()
if (',' in first_line or ';' in first_line or '\t' in first_line):
# Check second line has a similar number of separators
sep = ',' if ',' in first_line else (';' if ';' in first_line else '\t')
if abs(first_line.count(sep) - lines[1].strip().count(sep)) <= 1:
return 'CSV'
return 'Unknown'
# ---- Main import action ----
def action_import(self):
"""Main entry point: decode the file, detect format, parse, and
create bank statement records."""
self.ensure_one()
if not self.data_file:
raise UserError(_("Please upload a bank statement file."))
raw_data = base64.b64decode(self.data_file)
if not raw_data:
raise UserError(_("The uploaded file is empty."))
fmt = self._detect_format(raw_data, self.filename or '')
self.detected_format = fmt
if fmt == 'CSV':
return self._import_csv(raw_data)
elif fmt == 'OFX':
return self._import_parsed(raw_data, FusionOFXParser(), 'parse_ofx', 'OFX')
elif fmt == 'QIF':
return self._import_qif(raw_data)
elif fmt == 'CAMT.053':
return self._import_parsed(raw_data, FusionCAMTParser(), 'parse_camt', 'CAMT.053')
else:
raise UserError(
_("Could not determine the file format. "
"Supported formats are: OFX, QIF, CAMT.053, and CSV.")
)
# ---- Format-specific import handlers ----
def _import_parsed(self, raw_data, parser, method_name, label):
"""Generic handler for parsers that return a list of statement
dicts (OFX, CAMT.053)."""
try:
parse_fn = getattr(parser, method_name)
statements = parse_fn(raw_data)
except UserError:
raise
except Exception as exc:
_log.exception("%s parsing failed", label)
raise UserError(
_("Failed to parse the %(format)s file: %(error)s",
format=label, error=str(exc))
) from exc
if not statements:
raise UserError(
_("No statements found in the %(format)s file.", format=label)
)
return self._create_statements_from_parsed(statements, label)
def _import_qif(self, raw_data):
"""Handle QIF import — the parser returns a single dict."""
parser = FusionQIFParser()
try:
stmt = parser.parse_qif(raw_data)
except UserError:
raise
except Exception as exc:
_log.exception("QIF parsing failed")
raise UserError(
_("Failed to parse the QIF file: %s", str(exc))
) from exc
if not stmt:
raise UserError(_("No transactions found in the QIF file."))
return self._create_statements_from_parsed([stmt], 'QIF')
def _import_csv(self, raw_data):
"""Redirect CSV files to the existing base_import column-mapping
wizard, which provides interactive field mapping for CSV data."""
attachment = self.env['ir.attachment'].create({
'name': self.filename or 'bank_statement.csv',
'type': 'binary',
'raw': raw_data,
'mimetype': 'text/csv',
})
return self.journal_id._import_bank_statement(attachment)
# ---- Statement creation ----
def _create_statements_from_parsed(self, statements, format_label):
"""Create bank statement records from parsed statement dicts
and open the reconciliation widget for the imported lines.
This method delegates to the journal's existing import pipeline
for proper duplicate detection, partner matching, and statement
creation.
"""
journal = self.journal_id
if not journal:
raise UserError(_("Please select a bank journal."))
if not journal.default_account_id:
raise UserError(
_("You must set a Default Account for the journal: %s",
journal.name)
)
# Extract currency / account from first statement
currency_code = None
account_number = None
for stmt in statements:
if stmt.get('currency_code'):
currency_code = stmt['currency_code']
if stmt.get('account_number'):
account_number = stmt['account_number']
if currency_code and account_number:
break
# Validate through the journal pipeline
journal._check_parsed_data(statements, account_number)
# Find / validate journal match
target_journal = journal._find_additional_data(currency_code, account_number)
# Create an attachment reference for the import
attachment = self.env['ir.attachment'].create({
'name': self.filename or f'{format_label}_import',
'type': 'binary',
'raw': base64.b64decode(self.data_file),
'mimetype': 'application/octet-stream',
})
# Complete statement values
statements = target_journal._complete_bank_statement_vals(
statements, target_journal, account_number, attachment,
)
# Create statement records
stmt_ids, line_ids, notifications = target_journal._create_bank_statements(
statements,
)
if not stmt_ids:
raise UserError(
_("No new transactions were imported. "
"The file may contain only duplicates.")
)
# Build a summary message
stmt_records = self.env['account.bank.statement'].browse(stmt_ids)
total_lines = len(line_ids)
total_stmts = len(stmt_ids)
summary = _(
"Successfully imported %(lines)d transaction(s) into "
"%(stmts)d statement(s) from %(format)s file.",
lines=total_lines,
stmts=total_stmts,
format=format_label,
)
for notif in notifications:
summary += f"\n{notif.get('message', '')}"
# Open the reconciliation widget for the new lines
return stmt_records.line_ids._action_open_bank_reconciliation_widget(
extra_domain=[('statement_id', 'in', stmt_ids)],
default_context={
'search_default_not_matched': True,
'default_journal_id': target_journal.id,
'notifications': {
self.filename or format_label: summary,
},
},
)