Initial commit
This commit is contained in:
312
Fusion Accounting/wizard/bank_statement_import_wizard.py
Normal file
312
Fusion Accounting/wizard/bank_statement_import_wizard.py
Normal 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,
|
||||
},
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user