181 lines
6.2 KiB
Python
181 lines
6.2 KiB
Python
"""
|
|
Fusion Accounting - Loan CSV Import Wizard
|
|
|
|
Allows bulk-importing loan records from a CSV file with the columns:
|
|
name, principal, rate, term, start_date.
|
|
|
|
After import, each loan is created in Draft state so the user can
|
|
review parameters and compute the amortization schedule manually.
|
|
"""
|
|
|
|
import base64
|
|
import csv
|
|
import io
|
|
from datetime import datetime
|
|
|
|
from odoo import api, fields, models, _
|
|
from odoo.exceptions import UserError, ValidationError
|
|
|
|
|
|
class FusionLoanImportWizard(models.TransientModel):
|
|
"""Transient wizard to import loans from a CSV file.
|
|
|
|
Expected CSV columns (header required):
|
|
- name : Loan reference / description
|
|
- principal : Principal amount (float)
|
|
- rate : Annual interest rate in percent (float)
|
|
- term : Loan term in months (integer)
|
|
- start_date : Start date in YYYY-MM-DD format
|
|
"""
|
|
_name = 'fusion.loan.import.wizard'
|
|
_description = 'Import Loans from CSV'
|
|
|
|
csv_file = fields.Binary(
|
|
string='CSV File',
|
|
required=True,
|
|
help="Upload a CSV file with columns: name, principal, rate, term, start_date.",
|
|
)
|
|
csv_filename = fields.Char(string='Filename')
|
|
journal_id = fields.Many2one(
|
|
'account.journal',
|
|
string='Default Journal',
|
|
required=True,
|
|
domain="[('type', 'in', ['bank', 'general'])]",
|
|
help="Journal to assign to all imported loans.",
|
|
)
|
|
loan_account_id = fields.Many2one(
|
|
'account.account',
|
|
string='Default Loan Account',
|
|
required=True,
|
|
help="Liability account for imported loans.",
|
|
)
|
|
interest_account_id = fields.Many2one(
|
|
'account.account',
|
|
string='Default Interest Account',
|
|
required=True,
|
|
help="Expense account for interest on imported loans.",
|
|
)
|
|
partner_id = fields.Many2one(
|
|
'res.partner',
|
|
string='Default Lender',
|
|
required=True,
|
|
help="Default lender assigned when the CSV doesn't specify one.",
|
|
)
|
|
payment_frequency = fields.Selection(
|
|
selection=[
|
|
('monthly', 'Monthly'),
|
|
('quarterly', 'Quarterly'),
|
|
('semi_annually', 'Semi-Annually'),
|
|
('annually', 'Annually'),
|
|
],
|
|
string='Default Payment Frequency',
|
|
default='monthly',
|
|
required=True,
|
|
)
|
|
amortization_method = fields.Selection(
|
|
selection=[
|
|
('french', 'French (Equal Payments)'),
|
|
('linear', 'Linear (Equal Principal)'),
|
|
],
|
|
string='Default Amortization Method',
|
|
default='french',
|
|
required=True,
|
|
)
|
|
auto_compute = fields.Boolean(
|
|
string='Auto-Compute Schedules',
|
|
default=False,
|
|
help="Automatically compute the amortization schedule after import.",
|
|
)
|
|
|
|
# Required CSV header columns
|
|
_REQUIRED_COLUMNS = {'name', 'principal', 'rate', 'term', 'start_date'}
|
|
|
|
def action_import(self):
|
|
"""Parse the uploaded CSV and create loan records."""
|
|
self.ensure_one()
|
|
|
|
if not self.csv_file:
|
|
raise UserError(_("Please upload a CSV file."))
|
|
|
|
# Decode file
|
|
try:
|
|
raw = base64.b64decode(self.csv_file)
|
|
text = raw.decode('utf-8-sig') # handles BOM gracefully
|
|
except Exception as exc:
|
|
raise UserError(
|
|
_("Unable to read the file. Ensure it is a valid UTF-8 CSV. (%s)", exc)
|
|
)
|
|
|
|
reader = csv.DictReader(io.StringIO(text))
|
|
|
|
# Validate headers
|
|
if not reader.fieldnames:
|
|
raise UserError(_("The CSV file appears to be empty."))
|
|
headers = {h.strip().lower() for h in reader.fieldnames}
|
|
missing = self._REQUIRED_COLUMNS - headers
|
|
if missing:
|
|
raise UserError(
|
|
_("Missing required CSV columns: %s", ', '.join(sorted(missing)))
|
|
)
|
|
|
|
# Build column name mapping (stripped & lowered -> original)
|
|
col_map = {h.strip().lower(): h for h in reader.fieldnames}
|
|
|
|
loan_vals_list = []
|
|
errors = []
|
|
for row_num, row in enumerate(reader, start=2):
|
|
try:
|
|
name = (row.get(col_map['name']) or '').strip()
|
|
principal = float(row.get(col_map['principal'], 0))
|
|
rate = float(row.get(col_map['rate'], 0))
|
|
term = int(row.get(col_map['term'], 0))
|
|
start_raw = (row.get(col_map['start_date']) or '').strip()
|
|
start_date = datetime.strptime(start_raw, '%Y-%m-%d').date()
|
|
except (ValueError, TypeError) as exc:
|
|
errors.append(_("Row %s: %s", row_num, exc))
|
|
continue
|
|
|
|
if principal <= 0:
|
|
errors.append(_("Row %s: principal must be positive.", row_num))
|
|
continue
|
|
if term <= 0:
|
|
errors.append(_("Row %s: term must be positive.", row_num))
|
|
continue
|
|
|
|
loan_vals_list.append({
|
|
'name': name or _('New'),
|
|
'partner_id': self.partner_id.id,
|
|
'journal_id': self.journal_id.id,
|
|
'loan_account_id': self.loan_account_id.id,
|
|
'interest_account_id': self.interest_account_id.id,
|
|
'principal_amount': principal,
|
|
'interest_rate': rate,
|
|
'loan_term': term,
|
|
'start_date': start_date,
|
|
'payment_frequency': self.payment_frequency,
|
|
'amortization_method': self.amortization_method,
|
|
})
|
|
|
|
if errors:
|
|
raise UserError(
|
|
_("Import errors:\n%s", '\n'.join(str(e) for e in errors))
|
|
)
|
|
if not loan_vals_list:
|
|
raise UserError(_("No valid loan rows found in the CSV file."))
|
|
|
|
loans = self.env['fusion.loan'].create(loan_vals_list)
|
|
|
|
if self.auto_compute:
|
|
for loan in loans:
|
|
loan.compute_amortization_schedule()
|
|
|
|
# Return the newly created loans
|
|
return {
|
|
'name': _('Imported Loans'),
|
|
'type': 'ir.actions.act_window',
|
|
'res_model': 'fusion.loan',
|
|
'view_mode': 'list,form',
|
|
'domain': [('id', 'in', loans.ids)],
|
|
'target': 'current',
|
|
}
|