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,16 @@
from . import account_change_lock_date
from . import account_auto_reconcile_wizard
from . import account_reconcile_wizard
from . import account_report_send
from . import account_report_file_download_error_wizard
from . import fiscal_year
from . import multicurrency_revaluation
from . import report_export_wizard
from . import asset_modify
from . import setup_wizards
from . import account_bank_statement_import_csv
from . import bank_statement_import_wizard
from . import followup_send_wizard
from . import loan_import_wizard
from . import edi_import_wizard
from . import extraction_review_wizard

View File

@@ -0,0 +1,245 @@
# Fusion Accounting - Automatic Reconciliation Wizard
# Enables batch reconciliation of journal items using configurable
# matching strategies (perfect match or zero-balance clearing).
from datetime import date
from odoo import api, Command, fields, models, _
from odoo.exceptions import UserError
class AccountAutoReconcileWizard(models.TransientModel):
"""Wizard for automated matching and reconciliation of journal items.
Accessible via Accounting > Actions > Auto-reconcile."""
_name = 'account.auto.reconcile.wizard'
_description = 'Account automatic reconciliation wizard'
_check_company_auto = True
# --- Organizational Fields ---
company_id = fields.Many2one(
comodel_name='res.company',
required=True,
readonly=True,
default=lambda self: self.env.company,
)
line_ids = fields.Many2many(
comodel_name='account.move.line',
help="Pre-selected journal items used to seed wizard defaults.",
)
# --- Filter Parameters ---
from_date = fields.Date(string='From')
to_date = fields.Date(
string='To',
default=fields.Date.context_today,
required=True,
)
account_ids = fields.Many2many(
comodel_name='account.account',
string='Accounts',
check_company=True,
domain="[('reconcile', '=', True), "
"('account_type', '!=', 'off_balance')]",
)
partner_ids = fields.Many2many(
comodel_name='res.partner',
string='Partners',
check_company=True,
domain="[('company_id', 'in', (False, company_id)), "
"'|', ('parent_id', '=', False), ('is_company', '=', True)]",
)
search_mode = fields.Selection(
selection=[
('one_to_one', "Perfect Match"),
('zero_balance', "Clear Account"),
],
string='Reconcile',
required=True,
default='one_to_one',
help="Choose to match items pairwise by opposite balance, "
"or clear accounts where the total balance is zero.",
)
# -------------------------------------------------------------------------
# Defaults
# -------------------------------------------------------------------------
@api.model
def default_get(self, field_names):
"""Pre-configure the wizard from context domain if available."""
defaults = super().default_get(field_names)
ctx_domain = self.env.context.get('domain')
if 'line_ids' in field_names and 'line_ids' not in defaults and ctx_domain:
matching_lines = self.env['account.move.line'].search(ctx_domain)
if matching_lines:
defaults.update(self._derive_defaults_from_lines(matching_lines))
defaults['line_ids'] = [Command.set(matching_lines.ids)]
return defaults
@api.model
def _derive_defaults_from_lines(self, move_lines):
"""Infer wizard preset values from a set of journal items.
When all items share a common account or partner, pre-fill those
filters automatically."""
unique_accounts = move_lines.mapped('account_id')
unique_partners = move_lines.mapped('partner_id')
total_balance = sum(move_lines.mapped('balance'))
all_dates = move_lines.mapped('date')
preset = {
'account_ids': (
[Command.set(unique_accounts.ids)]
if len(unique_accounts) == 1 else []
),
'partner_ids': (
[Command.set(unique_partners.ids)]
if len(unique_partners) == 1 else []
),
'search_mode': (
'zero_balance'
if move_lines.company_currency_id.is_zero(total_balance)
else 'one_to_one'
),
'from_date': min(all_dates),
'to_date': max(all_dates),
}
return preset
def _snapshot_wizard_config(self):
"""Capture current wizard state as a comparable dict."""
self.ensure_one()
return {
'account_ids': (
[Command.set(self.account_ids.ids)] if self.account_ids else []
),
'partner_ids': (
[Command.set(self.partner_ids.ids)] if self.partner_ids else []
),
'search_mode': self.search_mode,
'from_date': self.from_date,
'to_date': self.to_date,
}
# Keep backward-compatible alias
_get_wizard_values = _snapshot_wizard_config
_get_default_wizard_values = _derive_defaults_from_lines
# -------------------------------------------------------------------------
# Domain Construction
# -------------------------------------------------------------------------
def _get_amls_domain(self):
"""Build a search domain for journal items eligible for
automatic reconciliation."""
self.ensure_one()
# If the config hasn't changed from the seed defaults, use IDs directly
if (
self.line_ids
and self._snapshot_wizard_config() == self._derive_defaults_from_lines(self.line_ids)
):
return [('id', 'in', self.line_ids.ids)]
base_domain = [
('company_id', '=', self.company_id.id),
('parent_state', '=', 'posted'),
('display_type', 'not in', ('line_section', 'line_note')),
('date', '>=', self.from_date or date.min),
('date', '<=', self.to_date),
('reconciled', '=', False),
('account_id.reconcile', '=', True),
('amount_residual_currency', '!=', 0.0),
('amount_residual', '!=', 0.0),
]
if self.account_ids:
base_domain.append(('account_id', 'in', self.account_ids.ids))
if self.partner_ids:
base_domain.append(('partner_id', 'in', self.partner_ids.ids))
return base_domain
# -------------------------------------------------------------------------
# Reconciliation Strategies
# -------------------------------------------------------------------------
def _auto_reconcile_one_to_one(self):
"""Pair items with matching opposite amounts and reconcile them.
Groups by account, partner, currency, and absolute residual amount,
then pairs positive with negative items chronologically."""
AML = self.env['account.move.line']
grouped_data = AML._read_group(
self._get_amls_domain(),
['account_id', 'partner_id', 'currency_id',
'amount_residual_currency:abs_rounded'],
['id:recordset'],
)
matched_lines = AML
paired_groups = []
for *_grouping, item_set in grouped_data:
pos_items = item_set.filtered(
lambda ln: ln.amount_residual_currency >= 0
).sorted('date')
neg_items = (item_set - pos_items).sorted('date')
pair_count = min(len(pos_items), len(neg_items))
trimmed_pos = pos_items[:pair_count]
trimmed_neg = neg_items[:pair_count]
matched_lines += trimmed_pos + trimmed_neg
for p_item, n_item in zip(trimmed_pos, trimmed_neg):
paired_groups.append(p_item + n_item)
AML._reconcile_plan(paired_groups)
return matched_lines
def _auto_reconcile_zero_balance(self):
"""Reconcile all items within groups (account/partner/currency)
where the total residual sums to zero."""
AML = self.env['account.move.line']
grouped_data = AML._read_group(
self._get_amls_domain(),
groupby=['account_id', 'partner_id', 'currency_id'],
aggregates=['id:recordset'],
having=[('amount_residual_currency:sum_rounded', '=', 0)],
)
matched_lines = AML
reconcile_groups = []
for group_row in grouped_data:
group_amls = group_row[-1]
matched_lines += group_amls
reconcile_groups.append(group_amls)
AML._reconcile_plan(reconcile_groups)
return matched_lines
# -------------------------------------------------------------------------
# Main Entry Point
# -------------------------------------------------------------------------
def auto_reconcile(self):
"""Execute the selected reconciliation strategy and display results."""
self.ensure_one()
if self.search_mode == 'zero_balance':
reconciled = self._auto_reconcile_zero_balance()
else:
reconciled = self._auto_reconcile_one_to_one()
# Gather all related lines (including exchange diff entries)
related_lines = self.env['account.move.line'].search([
('full_reconcile_id', 'in', reconciled.full_reconcile_id.ids),
])
if not related_lines:
raise UserError(_("No matching entries found for reconciliation."))
return {
'name': _("Automatically Reconciled Entries"),
'type': 'ir.actions.act_window',
'res_model': 'account.move.line',
'context': "{'search_default_group_by_matching': True}",
'view_mode': 'list',
'domain': [('id', 'in', related_lines.ids)],
}

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_account_auto_reconcile_wizard" model="ir.ui.view">
<field name="name">account.auto.reconcile.wizard.form</field>
<field name="model">account.auto.reconcile.wizard</field>
<field name="arch" type="xml">
<form string="Reconcile automatically">
<field name="company_id" invisible="1"/>
<field name="line_ids" invisible="1"/>
<group>
<field name="search_mode" widget="radio" options="{'horizontal': true}"/>
<field name="account_ids" widget="many2many_tags" options="{'no_create': True, 'no_edit':True}"/>
<field name="partner_ids" widget="many2many_tags" options="{'no_create': True, 'no_edit':True}" invisible="not account_ids"/>
<field name="from_date" invisible="search_mode == 'zero_balance'"/>
<field name="to_date"/>
</group>
<footer>
<button string="Reconcile" class="btn-primary" name="auto_reconcile" type="object" data-hotkey="v"/>
<button string="Discard" special="cancel" data-hotkey="z"/>
</footer>
</form>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,198 @@
# Fusion Accounting - CSV Bank Statement Import
# Extends the base import wizard to handle bank statement CSV files
# with debit/credit columns, running balances, and date ordering
import contextlib
import psycopg2
from odoo import _, api, fields, models, Command
from odoo.exceptions import UserError, ValidationError
from odoo.addons.base_import.models.base_import import FIELDS_RECURSION_LIMIT
class AccountBankStmtImportCSV(models.TransientModel):
"""Extends the standard CSV import to support bank-statement-specific
columns such as debit, credit, and cumulative balance, automatically
computing net amounts and statement boundaries."""
_inherit = 'base_import.import'
@api.model
def get_fields_tree(self, model, depth=FIELDS_RECURSION_LIMIT):
"""Append virtual monetary columns (balance, debit, credit)
when the import is running in bank statement context."""
available_fields = super().get_fields_tree(model, depth=depth)
if self.env.context.get('bank_stmt_import', False):
extra_columns = [
{
'id': 'balance',
'name': 'balance',
'string': 'Cumulative Balance',
'required': False,
'fields': [],
'type': 'monetary',
'model_name': model,
},
{
'id': 'debit',
'name': 'debit',
'string': 'Debit',
'required': False,
'fields': [],
'type': 'monetary',
'model_name': model,
},
{
'id': 'credit',
'name': 'credit',
'string': 'Credit',
'required': False,
'fields': [],
'type': 'monetary',
'model_name': model,
},
]
available_fields.extend(extra_columns)
return available_fields
def _safe_float(self, raw_value):
"""Safely convert a string or empty value to float, defaulting to 0.0."""
return float(raw_value) if raw_value else 0.0
def _parse_import_data(self, data, import_fields, options):
# EXTENDS base
data = super()._parse_import_data(data, import_fields, options)
target_journal_id = self.env.context.get('default_journal_id')
is_bank_import = options.get('bank_stmt_import')
if not target_journal_id or not is_bank_import:
return data
# Validate that either amount OR both debit and credit are mapped
amount_mapped = 'amount' in import_fields
credit_mapped = 'credit' in import_fields
debit_mapped = 'debit' in import_fields
if (debit_mapped ^ credit_mapped) or not (amount_mapped ^ debit_mapped):
raise ValidationError(
_("Make sure that an Amount or Debit and Credit is in the file.")
)
stmt_metadata = options['statement_vals'] = {}
output_rows = []
import_fields.append('sequence')
balance_col_idx = False
need_amount_conversion = False
# Ensure rows are sorted chronologically (ascending or descending accepted)
if 'date' in import_fields:
date_col = import_fields.index('date')
parsed_dates = [
fields.Date.from_string(row[date_col])
for row in data
if row[date_col]
]
ascending_order = sorted(parsed_dates)
if parsed_dates != ascending_order:
descending_order = ascending_order[::-1]
if parsed_dates == descending_order:
# Flip to ascending for consistent processing
data = data[::-1]
else:
raise UserError(_('Rows must be sorted by date.'))
# Handle debit/credit column conversion to a single amount
if 'debit' in import_fields and 'credit' in import_fields:
debit_col = import_fields.index('debit')
credit_col = import_fields.index('credit')
self._parse_float_from_data(data, debit_col, 'debit', options)
self._parse_float_from_data(data, credit_col, 'credit', options)
import_fields.append('amount')
need_amount_conversion = True
# Extract opening and closing balance from the balance column
if 'balance' in import_fields:
balance_col_idx = import_fields.index('balance')
self._parse_float_from_data(data, balance_col_idx, 'balance', options)
first_row_balance = self._safe_float(data[0][balance_col_idx])
amount_col = import_fields.index('amount')
if not need_amount_conversion:
first_row_amount = self._safe_float(data[0][amount_col])
else:
first_row_amount = (
abs(self._safe_float(data[0][credit_col]))
- abs(self._safe_float(data[0][debit_col]))
)
stmt_metadata['balance_start'] = first_row_balance - first_row_amount
stmt_metadata['balance_end_real'] = data[-1][balance_col_idx]
import_fields.remove('balance')
# Clean up temporary column mappings
if need_amount_conversion:
import_fields.remove('debit')
import_fields.remove('credit')
# Build final row data with sequence numbers, converting debit/credit
for seq_num, row in enumerate(data):
row.append(seq_num)
cols_to_drop = []
if need_amount_conversion:
net_amount = (
abs(self._safe_float(row[credit_col]))
- abs(self._safe_float(row[debit_col]))
)
row.append(net_amount)
cols_to_drop.extend([debit_col, credit_col])
if balance_col_idx:
cols_to_drop.append(balance_col_idx)
# Drop virtual columns in reverse order to preserve indices
for drop_idx in sorted(cols_to_drop, reverse=True):
del row[drop_idx]
# Only include rows that have a non-zero amount
if row[import_fields.index('amount')]:
output_rows.append(row)
return output_rows
def parse_preview(self, options, count=10):
"""Inject bank statement context flag when previewing CSV data."""
if options.get('bank_stmt_import', False):
self = self.with_context(bank_stmt_import=True)
return super().parse_preview(options, count=count)
def execute_import(self, fields, columns, options, dryrun=False):
"""Execute the import, wrapping bank statement rows into a
statement record with computed balance boundaries."""
if options.get('bank_stmt_import'):
with self.env.cr.savepoint(flush=False) as sp:
import_result = super().execute_import(fields, columns, options, dryrun=dryrun)
if 'statement_id' not in fields:
new_statement = self.env['account.bank.statement'].create({
'reference': self.file_name,
'line_ids': [Command.set(import_result.get('ids', []))],
**options.get('statement_vals', {}),
})
if not dryrun:
import_result['messages'].append({
'statement_id': new_statement.id,
'type': 'bank_statement',
})
with contextlib.suppress(psycopg2.InternalError):
sp.close(rollback=dryrun)
return import_result
else:
return super().execute_import(fields, columns, options, dryrun=dryrun)

View File

@@ -0,0 +1,517 @@
# Fusion Accounting - Lock Date Management Wizard
# Provides UI for managing fiscal, tax, sales, and purchase lock dates
# with support for temporary exceptions and draft entry warnings.
from datetime import date, timedelta
from odoo import api, fields, models, _
from odoo.exceptions import UserError
from odoo.osv import expression
from odoo.tools import date_utils
from odoo.addons.account.models.company import SOFT_LOCK_DATE_FIELDS, LOCK_DATE_FIELDS
class AccountChangeLockDate(models.TransientModel):
"""Wizard that enables administrators to update accounting lock dates
and manage exceptions for individual users or the entire organization."""
_name = 'account.change.lock.date'
_description = 'Change Lock Date'
# --- Core Company Reference ---
company_id = fields.Many2one(
comodel_name='res.company',
required=True,
readonly=True,
default=lambda self: self.env.company,
)
# --- Fiscal Year Lock ---
fiscalyear_lock_date = fields.Date(
string='Lock Everything',
default=lambda self: self.env.company.fiscalyear_lock_date,
help="Entries on or before this date are locked and will be "
"rescheduled according to the journal's sequence.",
)
fiscalyear_lock_date_for_me = fields.Date(
string='Lock Everything For Me',
compute='_compute_lock_date_exceptions',
)
fiscalyear_lock_date_for_everyone = fields.Date(
string='Lock Everything For Everyone',
compute='_compute_lock_date_exceptions',
)
min_fiscalyear_lock_date_exception_for_me_id = fields.Many2one(
comodel_name='account.lock_exception',
compute='_compute_lock_date_exceptions',
)
min_fiscalyear_lock_date_exception_for_everyone_id = fields.Many2one(
comodel_name='account.lock_exception',
compute='_compute_lock_date_exceptions',
)
# --- Tax Lock ---
tax_lock_date = fields.Date(
string="Lock Tax Return",
default=lambda self: self.env.company.tax_lock_date,
help="Tax entries on or before this date are locked. This date "
"updates automatically when a tax closing entry is posted.",
)
tax_lock_date_for_me = fields.Date(
string='Lock Tax Return For Me',
compute='_compute_lock_date_exceptions',
)
tax_lock_date_for_everyone = fields.Date(
string='Lock Tax Return For Everyone',
compute='_compute_lock_date_exceptions',
)
min_tax_lock_date_exception_for_me_id = fields.Many2one(
comodel_name='account.lock_exception',
compute='_compute_lock_date_exceptions',
)
min_tax_lock_date_exception_for_everyone_id = fields.Many2one(
comodel_name='account.lock_exception',
compute='_compute_lock_date_exceptions',
)
# --- Sales Lock ---
sale_lock_date = fields.Date(
string='Lock Sales',
default=lambda self: self.env.company.sale_lock_date,
help="Sales entries on or before this date are locked and will "
"be postponed per the journal's sequence.",
)
sale_lock_date_for_me = fields.Date(
string='Lock Sales For Me',
compute='_compute_lock_date_exceptions',
)
sale_lock_date_for_everyone = fields.Date(
string='Lock Sales For Everyone',
compute='_compute_lock_date_exceptions',
)
min_sale_lock_date_exception_for_me_id = fields.Many2one(
comodel_name='account.lock_exception',
compute='_compute_lock_date_exceptions',
)
min_sale_lock_date_exception_for_everyone_id = fields.Many2one(
comodel_name='account.lock_exception',
compute='_compute_lock_date_exceptions',
)
# --- Purchase Lock ---
purchase_lock_date = fields.Date(
string='Lock Purchases',
default=lambda self: self.env.company.purchase_lock_date,
help="Purchase entries on or before this date are locked and "
"will be postponed per the journal's sequence.",
)
purchase_lock_date_for_me = fields.Date(
string='Lock Purchases For Me',
compute='_compute_lock_date_exceptions',
)
purchase_lock_date_for_everyone = fields.Date(
string='Lock Purchases For Everyone',
compute='_compute_lock_date_exceptions',
)
min_purchase_lock_date_exception_for_me_id = fields.Many2one(
comodel_name='account.lock_exception',
compute='_compute_lock_date_exceptions',
)
min_purchase_lock_date_exception_for_everyone_id = fields.Many2one(
comodel_name='account.lock_exception',
compute='_compute_lock_date_exceptions',
)
# --- Hard (Irreversible) Lock ---
hard_lock_date = fields.Date(
string='Hard Lock',
default=lambda self: self.env.company.hard_lock_date,
help="Entries on or before this date are permanently locked. "
"This lock cannot be removed and allows no exceptions.",
)
current_hard_lock_date = fields.Date(
string='Current Hard Lock',
related='company_id.hard_lock_date',
readonly=True,
)
# --- Exception Configuration ---
exception_needed = fields.Boolean(
string='Exception needed',
compute='_compute_exception_needed',
)
exception_needed_fields = fields.Char(
compute='_compute_exception_needed_fields',
)
exception_applies_to = fields.Selection(
string='Exception applies',
selection=[
('me', "for me"),
('everyone', "for everyone"),
],
default='me',
required=True,
)
exception_duration = fields.Selection(
string='Exception Duration',
selection=[
('5min', "for 5 minutes"),
('15min', "for 15 minutes"),
('1h', "for 1 hour"),
('24h', "for 24 hours"),
('forever', "forever"),
],
default='5min',
required=True,
)
exception_reason = fields.Char(
string='Exception Reason',
)
# --- Warning for draft entries ---
show_draft_entries_warning = fields.Boolean(
string="Show Draft Entries Warning",
compute='_compute_show_draft_entries_warning',
)
# -------------------------------------------------------------------------
# Compute Methods
# -------------------------------------------------------------------------
@api.depends('company_id')
@api.depends_context('user', 'company')
def _compute_lock_date_exceptions(self):
"""Retrieve active lock date exceptions for each soft lock field
and determine the minimum exception per user scope."""
LockException = self.env['account.lock_exception']
for wiz in self:
active_domain = LockException._get_active_exceptions_domain(
wiz.company_id, SOFT_LOCK_DATE_FIELDS,
)
active_exceptions = LockException.search(active_domain)
current_uid = self.env.user.id
for lock_field in SOFT_LOCK_DATE_FIELDS:
# Partition exceptions by scope (current user vs global)
my_exceptions = active_exceptions.filtered(
lambda exc: exc.lock_date_field == lock_field and exc.user_id.id == current_uid
)
global_exceptions = active_exceptions.filtered(
lambda exc: exc.lock_date_field == lock_field and not exc.user_id.id
)
# Find the earliest exception in each scope
earliest_mine = (
min(my_exceptions, key=lambda e: e[lock_field] or date.min)
if my_exceptions else False
)
earliest_global = (
min(global_exceptions, key=lambda e: e[lock_field] or date.min)
if global_exceptions else False
)
wiz[f"min_{lock_field}_exception_for_me_id"] = earliest_mine
wiz[f"min_{lock_field}_exception_for_everyone_id"] = earliest_global
wiz[f"{lock_field}_for_me"] = earliest_mine.lock_date if earliest_mine else False
wiz[f"{lock_field}_for_everyone"] = earliest_global.lock_date if earliest_global else False
def _build_draft_moves_domain(self):
"""Build a domain to find draft moves that would fall within
any of the configured lock periods."""
self.ensure_one()
period_domains = []
if self.hard_lock_date:
period_domains.append([('date', '<=', self.hard_lock_date)])
if self.fiscalyear_lock_date:
period_domains.append([('date', '<=', self.fiscalyear_lock_date)])
if self.sale_lock_date:
period_domains.append([
('date', '<=', self.sale_lock_date),
('journal_id.type', '=', 'sale'),
])
if self.purchase_lock_date:
period_domains.append([
('date', '<=', self.purchase_lock_date),
('journal_id.type', '=', 'purchase'),
])
return [
('company_id', 'child_of', self.env.company.id),
('state', '=', 'draft'),
*expression.OR(period_domains),
]
# Keep backward-compatible alias
_get_draft_moves_in_locked_period_domain = _build_draft_moves_domain
@api.depends('fiscalyear_lock_date', 'tax_lock_date', 'sale_lock_date',
'purchase_lock_date', 'hard_lock_date')
def _compute_show_draft_entries_warning(self):
"""Flag whether any draft journal entries exist in the locked period."""
AccountMove = self.env['account.move']
for wiz in self:
has_drafts = bool(AccountMove.search(
wiz._build_draft_moves_domain(), limit=1,
))
wiz.show_draft_entries_warning = has_drafts
def _get_changes_needing_exception(self):
"""Identify soft lock fields that are being loosened
(i.e. the new date is earlier than the current company setting)."""
self.ensure_one()
company = self.env.company
relaxed = {}
for fld in SOFT_LOCK_DATE_FIELDS:
current_val = company[fld]
new_val = self[fld]
if current_val and (not new_val or new_val < current_val):
relaxed[fld] = new_val
return relaxed
@api.depends(*SOFT_LOCK_DATE_FIELDS)
def _compute_exception_needed(self):
for wiz in self:
wiz.exception_needed = bool(wiz._get_changes_needing_exception())
@api.depends(*SOFT_LOCK_DATE_FIELDS)
def _compute_exception_needed_fields(self):
for wiz in self:
relaxed_fields = wiz._get_changes_needing_exception()
wiz.exception_needed_fields = ','.join(relaxed_fields)
# -------------------------------------------------------------------------
# Lock Date Value Preparation
# -------------------------------------------------------------------------
def _prepare_lock_date_values(self, exception_vals_list=None):
"""Return a dict of lock-date fields that have changed and
are not covered by a newly created exception."""
self.ensure_one()
company = self.env.company
# Hard lock date can never decrease
if company.hard_lock_date and (
not self.hard_lock_date or self.hard_lock_date < company.hard_lock_date
):
raise UserError(_(
'The Hard Lock Date cannot be decreased or removed once set.'
))
changed_vals = {}
for fld in LOCK_DATE_FIELDS:
if self[fld] != company[fld]:
changed_vals[fld] = self[fld]
# No lock date may be set in the future
today = fields.Date.context_today(self)
for fld, val in changed_vals.items():
if val and val > today:
raise UserError(_(
'A Lock Date cannot be set to a future date.'
))
# Exclude fields that are being handled via exception creation
if exception_vals_list:
for exc_vals in exception_vals_list:
for fld in LOCK_DATE_FIELDS:
if fld in exc_vals:
changed_vals.pop(fld, None)
return changed_vals
def _prepare_exception_values(self):
"""Construct a list of dicts suitable for creating
account.lock_exception records for any loosened lock dates."""
self.ensure_one()
relaxed = self._get_changes_needing_exception()
if not relaxed:
return False
# Relaxing for everyone forever is equivalent to simply updating the date
if self.exception_applies_to == 'everyone' and self.exception_duration == 'forever':
return False
# Validate that scope and duration are set
validation_issues = []
if not self.exception_applies_to:
validation_issues.append(_('Please select who the exception applies to.'))
if not self.exception_duration:
validation_issues.append(_('Please select a duration for the exception.'))
if validation_issues:
raise UserError('\n'.join(validation_issues))
# Build shared exception values
shared_vals = {
'company_id': self.env.company.id,
}
# Determine target user
scope_to_user = {
'me': self.env.user.id,
'everyone': False,
}
shared_vals['user_id'] = scope_to_user[self.exception_applies_to]
# Determine expiration
duration_map = {
'5min': timedelta(minutes=5),
'15min': timedelta(minutes=15),
'1h': timedelta(hours=1),
'24h': timedelta(hours=24),
'forever': False,
}
delta = duration_map[self.exception_duration]
if delta:
shared_vals['end_datetime'] = self.env.cr.now() + delta
if self.exception_reason:
shared_vals['reason'] = self.exception_reason
return [
{**shared_vals, fld: val}
for fld, val in relaxed.items()
]
# -------------------------------------------------------------------------
# Period Date Helpers
# -------------------------------------------------------------------------
def _get_current_period_dates(self, lock_date_field):
"""Determine the start and end of the current period relative
to the selected lock date (using the prior lock date or fiscal year start)."""
self.ensure_one()
existing_lock = self.env.company[lock_date_field]
if existing_lock:
period_start = existing_lock + timedelta(days=1)
else:
period_start = date_utils.get_fiscal_year(self[lock_date_field])[0]
return period_start, self[lock_date_field]
def _create_default_report_external_values(self, lock_date_field):
"""Hook for generating default report external values when lock
dates change. Extended by account reporting modules."""
date_from, date_to = self._get_current_period_dates(lock_date_field)
self.env['account.report']._generate_default_external_values(
date_from, date_to, lock_date_field == 'tax_lock_date',
)
# -------------------------------------------------------------------------
# Core Lock Date Application
# -------------------------------------------------------------------------
def _change_lock_date(self, lock_date_values=None):
"""Apply the given lock date values to the company, generating
default report external values as needed."""
self.ensure_one()
if lock_date_values is None:
lock_date_values = self._prepare_lock_date_values()
# Handle tax lock date report externals
new_tax_lock = lock_date_values.get('tax_lock_date')
if new_tax_lock and new_tax_lock != self.env.company['tax_lock_date']:
self._create_default_report_external_values('tax_lock_date')
# Handle fiscal year / hard lock date report externals
new_fy_lock = lock_date_values.get('fiscalyear_lock_date')
new_hard_lock = lock_date_values.get('hard_lock_date')
if new_fy_lock or new_hard_lock:
candidate_lock, candidate_field = max(
[(new_fy_lock, 'fiscalyear_lock_date'),
(new_hard_lock, 'hard_lock_date')],
key=lambda pair: pair[0] or date.min,
)
existing_fy_max = max(
self.env.company.fiscalyear_lock_date or date.min,
self.env.company.hard_lock_date or date.min,
)
if candidate_lock != existing_fy_max:
self._create_default_report_external_values(candidate_field)
self.env.company.sudo().write(lock_date_values)
def change_lock_date(self):
"""Main action: validate permissions, create exceptions if needed,
and apply new lock dates to the company."""
self.ensure_one()
if not self.env.user.has_group('account.group_account_manager'):
raise UserError(_(
'Only Billing Administrators are allowed to change lock dates!'
))
exc_vals_list = self._prepare_exception_values()
updated_lock_vals = self._prepare_lock_date_values(
exception_vals_list=exc_vals_list,
)
if exc_vals_list:
self.env['account.lock_exception'].create(exc_vals_list)
self._change_lock_date(updated_lock_vals)
return {'type': 'ir.actions.act_window_close'}
# -------------------------------------------------------------------------
# UI Actions
# -------------------------------------------------------------------------
def action_show_draft_moves_in_locked_period(self):
"""Open a list view showing draft moves within the locked period."""
self.ensure_one()
return {
'view_mode': 'list',
'name': _('Draft Entries'),
'res_model': 'account.move',
'type': 'ir.actions.act_window',
'domain': self._build_draft_moves_domain(),
'search_view_id': [
self.env.ref('account.view_account_move_filter').id, 'search',
],
'views': [
[self.env.ref('account.view_move_tree_multi_edit').id, 'list'],
[self.env.ref('account.view_move_form').id, 'form'],
],
}
def action_reopen_wizard(self):
"""Return an action that reopens this wizard in a dialog."""
return {
'type': 'ir.actions.act_window',
'res_model': self._name,
'res_id': self.id,
'view_mode': 'form',
'target': 'new',
}
def _action_revoke_min_exception(self, exception_field):
"""Revoke a specific exception and refresh the wizard."""
self.ensure_one()
target_exception = self[exception_field]
if target_exception:
target_exception.action_revoke()
self._compute_lock_date_exceptions()
return self.action_reopen_wizard()
# --- Per-field revoke actions (current user) ---
def action_revoke_min_sale_lock_date_exception_for_me(self):
return self._action_revoke_min_exception('min_sale_lock_date_exception_for_me_id')
def action_revoke_min_purchase_lock_date_exception_for_me(self):
return self._action_revoke_min_exception('min_purchase_lock_date_exception_for_me_id')
def action_revoke_min_tax_lock_date_exception_for_me(self):
return self._action_revoke_min_exception('min_tax_lock_date_exception_for_me_id')
def action_revoke_min_fiscalyear_lock_date_exception_for_me(self):
return self._action_revoke_min_exception('min_fiscalyear_lock_date_exception_for_me_id')
# --- Per-field revoke actions (everyone) ---
def action_revoke_min_sale_lock_date_exception_for_everyone(self):
return self._action_revoke_min_exception('min_sale_lock_date_exception_for_everyone_id')
def action_revoke_min_purchase_lock_date_exception_for_everyone(self):
return self._action_revoke_min_exception('min_purchase_lock_date_exception_for_everyone_id')
def action_revoke_min_tax_lock_date_exception_for_everyone(self):
return self._action_revoke_min_exception('min_tax_lock_date_exception_for_everyone_id')
def action_revoke_min_fiscalyear_lock_date_exception_for_everyone(self):
return self._action_revoke_min_exception('min_fiscalyear_lock_date_exception_for_everyone_id')

View File

@@ -0,0 +1,231 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="view_account_change_lock_date" model="ir.ui.view">
<field name="name">account.change.lock.date.form</field>
<field name="model">account.change.lock.date</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<field name="show_draft_entries_warning" invisible="True"/>
<div class="alert alert-warning mb-0 d-flex" role="alert"
colspan="2"
invisible="not show_draft_entries_warning">
There are still draft entries in the period you want to lock.
You should either post or delete them.
<a type="object"
class="oe_link fw-bold"
name="action_show_draft_moves_in_locked_period"
icon="oi-arrow-right">
Review
</a>
</div>
<div class="text-muted mb-1" colspan="2">
<i>Lock transactions up to specific dates, inclusive</i>
</div>
<field name="company_id" invisible="True"/>
<field name="min_sale_lock_date_exception_for_me_id" invisible="True"/>
<field name="min_purchase_lock_date_exception_for_me_id" invisible="True"/>
<field name="min_tax_lock_date_exception_for_me_id" invisible="True"/>
<field name="min_fiscalyear_lock_date_exception_for_me_id" invisible="True"/>
<field name="min_sale_lock_date_exception_for_everyone_id" invisible="True"/>
<field name="min_purchase_lock_date_exception_for_everyone_id" invisible="True"/>
<field name="min_tax_lock_date_exception_for_everyone_id" invisible="True"/>
<field name="min_fiscalyear_lock_date_exception_for_everyone_id" invisible="True"/>
<label for="sale_lock_date"/>
<div class="d-flex">
<field name="sale_lock_date" placeholder="Not locked"
class="oe_inline"
decoration-info="exception_needed_fields and 'sale_lock_date' in exception_needed_fields"
options="{'warn_future': true}"/>
<span class="text-muted o_form_label"
invisible="not min_sale_lock_date_exception_for_me_id">
For me:
<field name="sale_lock_date_for_me" nolabel="1" class="oe_inline"
invisible="not sale_lock_date_for_me"/>
<a type="object"
class="oe_link fw-bold"
name="action_revoke_min_sale_lock_date_exception_for_me">
Revoke
</a>
</span>
<span class="text-muted o_form_label"
invisible="not min_sale_lock_date_exception_for_me_id
or not min_sale_lock_date_exception_for_everyone_id">
<span style="white-space: pre">; </span>
</span>
<span class="text-muted o_form_label"
invisible="not min_sale_lock_date_exception_for_everyone_id">
For everyone:
<field name="sale_lock_date_for_everyone" nolabel="1" class="oe_inline"
invisible="not sale_lock_date_for_everyone"/>
<a type="object"
class="oe_link fw-bold"
name="action_revoke_min_sale_lock_date_exception_for_everyone">
Revoke
</a>
</span>
</div>
<label for="purchase_lock_date"/>
<div class="d-flex">
<field name="purchase_lock_date" placeholder="Not locked"
class="oe_inline"
decoration-info="exception_needed_fields and 'purchase_lock_date' in exception_needed_fields"
options="{'warn_future': true}"/>
<span class="text-muted o_form_label"
invisible="not min_purchase_lock_date_exception_for_me_id">
For me:
<field name="purchase_lock_date_for_me" nolabel="1" class="oe_inline"
invisible="not purchase_lock_date_for_me"/>
<a type="object"
class="oe_link fw-bold"
name="action_revoke_min_purchase_lock_date_exception_for_me">
Revoke
</a>
</span>
<span class="text-muted o_form_label"
invisible="not min_purchase_lock_date_exception_for_me_id
or not min_purchase_lock_date_exception_for_everyone_id">
<span style="white-space: pre">; </span>
</span>
<span class="text-muted o_form_label"
invisible="not min_purchase_lock_date_exception_for_everyone_id">
For everyone:
<field name="purchase_lock_date_for_everyone" nolabel="1" class="oe_inline"
invisible="not purchase_lock_date_for_everyone"/>
<a type="object"
class="oe_link fw-bold"
name="action_revoke_min_purchase_lock_date_exception_for_everyone">
Revoke
</a>
</span>
</div>
<label for="tax_lock_date"/>
<div class="d-flex">
<field name="tax_lock_date" placeholder="Not locked"
class="oe_inline"
decoration-info="exception_needed_fields and 'tax_lock_date' in exception_needed_fields"
options="{'warn_future': true}"/>
<span class="text-muted"
invisible="min_tax_lock_date_exception_for_me_id or min_tax_lock_date_exception_for_everyone_id">
<i>after a tax closing</i>
</span>
<span class="text-muted o_form_label"
invisible="not min_tax_lock_date_exception_for_me_id">
For me:
<field name="tax_lock_date_for_me" nolabel="1" class="oe_inline"
invisible="not tax_lock_date_for_me"/>
<a type="object"
class="oe_link fw-bold"
name="action_revoke_min_tax_lock_date_exception_for_me">
Revoke
</a>
</span>
<span class="text-muted o_form_label"
invisible="not min_tax_lock_date_exception_for_me_id
or not min_tax_lock_date_exception_for_everyone_id">
<span style="white-space: pre">; </span>
</span>
<span class="text-muted o_form_label"
invisible="not min_tax_lock_date_exception_for_everyone_id">
For everyone:
<field name="tax_lock_date_for_everyone" nolabel="1" class="oe_inline"
invisible="not tax_lock_date_for_everyone"/>
<a type="object"
class="oe_link fw-bold"
name="action_revoke_min_tax_lock_date_exception_for_everyone">
Revoke
</a>
</span>
</div>
<label for="fiscalyear_lock_date"/>
<div class="d-flex">
<field name="fiscalyear_lock_date" placeholder="Not locked"
class="oe_inline"
decoration-info="exception_needed_fields and 'fiscalyear_lock_date' in exception_needed_fields"
options="{'warn_future': true}"/>
<span class="text-muted"
invisible="min_fiscalyear_lock_date_exception_for_me_id or min_fiscalyear_lock_date_exception_for_everyone_id">
<i>but allow exceptions</i>
</span>
<span class="text-muted o_form_label"
invisible="not min_fiscalyear_lock_date_exception_for_me_id">
For me:
<field name="fiscalyear_lock_date_for_me" nolabel="1" class="oe_inline"
invisible="not fiscalyear_lock_date_for_me"/>
<a type="object"
class="oe_link fw-bold"
name="action_revoke_min_fiscalyear_lock_date_exception_for_me">
Revoke
</a>
</span>
<span class="text-muted o_form_label"
invisible="not min_fiscalyear_lock_date_exception_for_me_id
or not min_fiscalyear_lock_date_exception_for_everyone_id">
<span style="white-space: pre">; </span>
</span>
<span class="text-muted o_form_label"
invisible="not min_fiscalyear_lock_date_exception_for_everyone_id">
For everyone:
<field name="fiscalyear_lock_date_for_everyone" nolabel="1" class="oe_inline"
invisible="not fiscalyear_lock_date_for_everyone"/>
<a type="object"
class="oe_link fw-bold"
name="action_revoke_min_fiscalyear_lock_date_exception_for_everyone">
Revoke
</a>
</span>
</div>
<label for="hard_lock_date"/>
<div class="d-flex">
<field name="hard_lock_date" placeholder="Not locked"
class="oe_inline"
decoration-info="exception_needed_fields and 'hard_lock_date' in exception_needed_fields"
options="{'warn_future': true}"/>
<span class="text-muted" invisible="hard_lock_date != current_hard_lock_date">
<i>to ensure inalterability</i>
</span>
<span class="text-danger o_form_label" invisible="hard_lock_date == current_hard_lock_date">
<i>This change is irreversible</i>
</span>
</div>
<div class="alert alert-info ps-3 mb-3 d-flex" role="alert"
colspan="2"
style="white-space: pre;"
invisible="not exception_needed_fields">
<span>Exception</span>
<field name="exception_applies_to" class="oe_inline mx-3"/>
<field name="exception_duration" class="oe_inline"/>
<div invisible="exception_applies_to == 'everyone' and exception_duration == 'forever'">
<field name="exception_reason" placeholder="Reason..." class="oe_inline mx-3"/>
</div>
</div>
</group>
</sheet>
<footer>
<button string="Save" name="change_lock_date" type="object" class="btn-primary" data-hotkey="q"/>
<button string="Discard" class="btn-secondary" special="cancel" data-hotkey="x"/>
</footer>
</form>
</field>
</record>
<record id="action_view_account_change_lock_date" model="ir.actions.act_window">
<field name="name">Lock Journal Entries</field>
<field name="res_model">account.change.lock.date</field>
<field name="view_mode">form</field>
<field name="view_id" ref="view_account_change_lock_date"/>
<field name="target">new</field>
</record>
<menuitem
id="menu_action_change_lock_date"
name="Lock Dates"
action="action_view_account_change_lock_date"
parent="account.menu_finance_entries"
sequence="70"
groups="account.group_account_manager"/>
</data>
</odoo>

View File

@@ -0,0 +1,850 @@
# Fusion Accounting - Manual Reconciliation Wizard
# Handles partial/full reconciliation of selected journal items with
# optional write-off, account transfers, and tax support.
from collections import defaultdict
from datetime import timedelta
from odoo import api, Command, fields, models, _
from odoo.exceptions import UserError
from odoo.tools import groupby, SQL
from odoo.tools.misc import formatLang
class AccountReconcileWizard(models.TransientModel):
"""Interactive wizard for reconciling selected journal items,
optionally generating write-off or account transfer entries."""
_name = 'account.reconcile.wizard'
_description = 'Account reconciliation wizard'
_check_company_auto = True
# -------------------------------------------------------------------------
# Default Values
# -------------------------------------------------------------------------
@api.model
def default_get(self, field_names):
"""Validate and load the selected journal items from the context."""
result = super().default_get(field_names)
if 'move_line_ids' not in field_names:
return result
active_model = self.env.context.get('active_model')
active_ids = self.env.context.get('active_ids')
if active_model != 'account.move.line' or not active_ids:
raise UserError(_('This wizard can only be used on journal items.'))
selected_lines = self.env['account.move.line'].browse(active_ids)
involved_accounts = selected_lines.account_id
if len(involved_accounts) > 2:
raise UserError(_(
'Reconciliation is limited to at most two accounts: %s',
', '.join(involved_accounts.mapped('display_name')),
))
# When two accounts are involved, shadow the secondary account
override_map = None
if len(involved_accounts) == 2:
primary_account = selected_lines[0].account_id
override_map = {
line: {'account_id': primary_account}
for line in selected_lines
if line.account_id != primary_account
}
selected_lines._check_amls_exigibility_for_reconciliation(
shadowed_aml_values=override_map,
)
result['move_line_ids'] = [Command.set(selected_lines.ids)]
return result
# -------------------------------------------------------------------------
# Field Declarations
# -------------------------------------------------------------------------
company_id = fields.Many2one(
comodel_name='res.company',
required=True,
readonly=True,
compute='_compute_company_id',
)
move_line_ids = fields.Many2many(
comodel_name='account.move.line',
string='Move lines to reconcile',
required=True,
)
reco_account_id = fields.Many2one(
comodel_name='account.account',
string='Reconcile Account',
compute='_compute_reco_wizard_data',
)
amount = fields.Monetary(
string='Amount in company currency',
currency_field='company_currency_id',
compute='_compute_reco_wizard_data',
)
company_currency_id = fields.Many2one(
comodel_name='res.currency',
string='Company currency',
related='company_id.currency_id',
)
amount_currency = fields.Monetary(
string='Amount',
currency_field='reco_currency_id',
compute='_compute_reco_wizard_data',
)
reco_currency_id = fields.Many2one(
comodel_name='res.currency',
string='Currency to use for reconciliation',
compute='_compute_reco_wizard_data',
)
edit_mode_amount = fields.Monetary(
currency_field='company_currency_id',
compute='_compute_edit_mode_amount',
)
edit_mode_amount_currency = fields.Monetary(
string='Edit mode amount',
currency_field='edit_mode_reco_currency_id',
compute='_compute_edit_mode_amount_currency',
store=True,
readonly=False,
)
edit_mode_reco_currency_id = fields.Many2one(
comodel_name='res.currency',
compute='_compute_edit_mode_reco_currency',
)
edit_mode = fields.Boolean(
compute='_compute_edit_mode',
)
single_currency_mode = fields.Boolean(
compute='_compute_single_currency_mode',
)
allow_partials = fields.Boolean(
string="Allow partials",
compute='_compute_allow_partials',
store=True,
readonly=False,
)
force_partials = fields.Boolean(
compute='_compute_reco_wizard_data',
)
display_allow_partials = fields.Boolean(
compute='_compute_display_allow_partials',
)
date = fields.Date(
string='Date',
compute='_compute_date',
store=True,
readonly=False,
)
journal_id = fields.Many2one(
comodel_name='account.journal',
string='Journal',
check_company=True,
domain="[('type', '=', 'general')]",
compute='_compute_journal_id',
store=True,
readonly=False,
required=True,
precompute=True,
)
account_id = fields.Many2one(
comodel_name='account.account',
string='Account',
check_company=True,
domain="[('account_type', '!=', 'off_balance')]",
)
is_rec_pay_account = fields.Boolean(
compute='_compute_is_rec_pay_account',
)
to_partner_id = fields.Many2one(
comodel_name='res.partner',
string='Partner',
check_company=True,
compute='_compute_to_partner_id',
store=True,
readonly=False,
)
label = fields.Char(string='Label', default='Write-Off')
tax_id = fields.Many2one(
comodel_name='account.tax',
string='Tax',
default=False,
check_company=True,
)
to_check = fields.Boolean(
string='To Check',
default=False,
help='Flag this entry for review if information is uncertain.',
)
is_write_off_required = fields.Boolean(
string='Is a write-off move required to reconcile',
compute='_compute_is_write_off_required',
)
is_transfer_required = fields.Boolean(
string='Is an account transfer required',
compute='_compute_reco_wizard_data',
)
transfer_warning_message = fields.Char(
string='Transfer warning message',
compute='_compute_reco_wizard_data',
)
transfer_from_account_id = fields.Many2one(
comodel_name='account.account',
string='Account Transfer From',
compute='_compute_reco_wizard_data',
)
lock_date_violated_warning_message = fields.Char(
string='Lock date violation warning',
compute='_compute_lock_date_violated_warning_message',
)
reco_model_id = fields.Many2one(
comodel_name='account.reconcile.model',
string='Reconciliation model',
store=False,
check_company=True,
)
reco_model_autocomplete_ids = fields.Many2many(
comodel_name='account.reconcile.model',
string='All reconciliation models',
compute='_compute_reco_model_autocomplete_ids',
)
# -------------------------------------------------------------------------
# Compute Methods
# -------------------------------------------------------------------------
@api.depends('move_line_ids.company_id')
def _compute_company_id(self):
for wiz in self:
wiz.company_id = wiz.move_line_ids[0].company_id
@api.depends('move_line_ids')
def _compute_single_currency_mode(self):
for wiz in self:
foreign_currencies = wiz.move_line_ids.currency_id - wiz.company_currency_id
wiz.single_currency_mode = len(foreign_currencies) <= 1
@api.depends('force_partials')
def _compute_allow_partials(self):
for wiz in self:
wiz.allow_partials = wiz.display_allow_partials and wiz.force_partials
@api.depends('move_line_ids')
def _compute_display_allow_partials(self):
"""Show the partial reconciliation checkbox only when both
debit and credit items are selected."""
for wiz in self:
found_debit = found_credit = False
for line in wiz.move_line_ids:
if line.balance > 0.0 or line.amount_currency > 0.0:
found_debit = True
elif line.balance < 0.0 or line.amount_currency < 0.0:
found_credit = True
if found_debit and found_credit:
break
wiz.display_allow_partials = found_debit and found_credit
@api.depends('move_line_ids', 'journal_id', 'tax_id')
def _compute_date(self):
for wiz in self:
latest_date = max(ln.date for ln in wiz.move_line_ids)
temp_entry = self.env['account.move'].new({
'journal_id': wiz.journal_id.id,
})
wiz.date = temp_entry._get_accounting_date(
latest_date, bool(wiz.tax_id),
)
@api.depends('company_id')
def _compute_journal_id(self):
for wiz in self:
wiz.journal_id = self.env['account.journal'].search([
*self.env['account.journal']._check_company_domain(wiz.company_id),
('type', '=', 'general'),
], limit=1)
@api.depends('account_id')
def _compute_is_rec_pay_account(self):
for wiz in self:
wiz.is_rec_pay_account = (
wiz.account_id.account_type
in ('asset_receivable', 'liability_payable')
)
@api.depends('is_rec_pay_account')
def _compute_to_partner_id(self):
for wiz in self:
if wiz.is_rec_pay_account:
linked_partners = wiz.move_line_ids.partner_id
wiz.to_partner_id = linked_partners if len(linked_partners) == 1 else None
else:
wiz.to_partner_id = None
@api.depends('amount', 'amount_currency')
def _compute_is_write_off_required(self):
"""A write-off is needed when the balance does not reach zero."""
for wiz in self:
company_zero = wiz.company_currency_id.is_zero(wiz.amount)
reco_zero = (
wiz.reco_currency_id.is_zero(wiz.amount_currency)
if wiz.reco_currency_id else True
)
wiz.is_write_off_required = not company_zero or not reco_zero
@api.depends('move_line_ids')
def _compute_reco_wizard_data(self):
"""Compute reconciliation currency, transfer requirements,
and write-off amounts from the selected journal items."""
def _determine_transfer_info(lines, accts):
"""When two accounts are involved, decide which one to transfer from."""
balance_per_acct = defaultdict(float)
for ln in lines:
balance_per_acct[ln.account_id] += ln.amount_residual
# Transfer from the account with the smaller absolute balance
if abs(balance_per_acct[accts[0]]) < abs(balance_per_acct[accts[1]]):
src_acct, dst_acct = accts[0], accts[1]
else:
src_acct, dst_acct = accts[1], accts[0]
transfer_lines = lines.filtered(lambda ln: ln.account_id == src_acct)
foreign_curs = lines.currency_id - lines.company_currency_id
if len(foreign_curs) == 1:
xfer_currency = foreign_curs
xfer_amount = sum(ln.amount_currency for ln in transfer_lines)
else:
xfer_currency = lines.company_currency_id
xfer_amount = sum(ln.balance for ln in transfer_lines)
if xfer_amount == 0.0 and xfer_currency != lines.company_currency_id:
xfer_currency = lines.company_currency_id
xfer_amount = sum(ln.balance for ln in transfer_lines)
formatted_amt = formatLang(
self.env, abs(xfer_amount), currency_obj=xfer_currency,
)
warning_msg = _(
'An entry will transfer %(amount)s from %(from_account)s to %(to_account)s.',
amount=formatted_amt,
from_account=(src_acct.display_name if xfer_amount < 0
else dst_acct.display_name),
to_account=(dst_acct.display_name if xfer_amount < 0
else src_acct.display_name),
)
return {
'transfer_from_account_id': src_acct,
'reco_account_id': dst_acct,
'transfer_warning_message': warning_msg,
}
def _resolve_reco_currency(lines, residual_map):
"""Determine the best currency for reconciliation."""
base_cur = lines.company_currency_id
foreign_curs = lines.currency_id - base_cur
if not foreign_curs:
return base_cur
if len(foreign_curs) == 1:
return foreign_curs
# Multiple foreign currencies - check which have residuals
lines_with_balance = self.env['account.move.line']
for ln, vals in residual_map.items():
if vals['amount_residual'] or vals['amount_residual_currency']:
lines_with_balance += ln
if lines_with_balance and len(lines_with_balance.currency_id - base_cur) > 1:
return False
return (lines_with_balance.currency_id - base_cur) or base_cur
for wiz in self:
amls = wiz.move_line_ids._origin
accts = amls.account_id
# Reset defaults
wiz.reco_currency_id = False
wiz.amount_currency = wiz.amount = 0.0
wiz.force_partials = True
wiz.transfer_from_account_id = wiz.transfer_warning_message = False
wiz.is_transfer_required = len(accts) == 2
if wiz.is_transfer_required:
wiz.update(_determine_transfer_info(amls, accts))
else:
wiz.reco_account_id = accts
# Shadow all items to the reconciliation account
shadow_vals = {
ln: {'account_id': wiz.reco_account_id}
for ln in amls
}
# Build reconciliation plan
plan_list, all_lines = amls._optimize_reconciliation_plan(
[amls], shadowed_aml_values=shadow_vals,
)
# Prefetch for performance
all_lines.move_id
all_lines.matched_debit_ids
all_lines.matched_credit_ids
residual_map = {
ln: {
'aml': ln,
'amount_residual': ln.amount_residual,
'amount_residual_currency': ln.amount_residual_currency,
}
for ln in all_lines
}
skip_exchange_diff = bool(
self.env['ir.config_parameter'].sudo().get_param(
'account.disable_partial_exchange_diff',
)
)
plan = plan_list[0]
amls.with_context(
no_exchange_difference=(
self.env.context.get('no_exchange_difference')
or skip_exchange_diff
),
)._prepare_reconciliation_plan(
plan, residual_map, shadowed_aml_values=shadow_vals,
)
resolved_cur = _resolve_reco_currency(amls, residual_map)
if not resolved_cur:
continue
residual_amounts = {
ln: ln._prepare_move_line_residual_amounts(
vals, resolved_cur, shadowed_aml_values=shadow_vals,
)
for ln, vals in residual_map.items()
}
# Verify all residuals are expressed in the resolved currency
if all(
resolved_cur in rv
for rv in residual_amounts.values() if rv
):
wiz.reco_currency_id = resolved_cur
elif all(
amls.company_currency_id in rv
for rv in residual_amounts.values() if rv
):
wiz.reco_currency_id = amls.company_currency_id
resolved_cur = wiz.reco_currency_id
else:
continue
# Compute write-off amounts using the most recent line's rate
newest_line = max(amls, key=lambda ln: ln.date)
if not newest_line.amount_currency:
fx_rate = lower_bound = upper_bound = 0.0
elif newest_line.currency_id == resolved_cur:
fx_rate = abs(newest_line.balance / newest_line.amount_currency)
tolerance = (
amls.company_currency_id.rounding / 2
/ abs(newest_line.amount_currency)
)
lower_bound = fx_rate - tolerance
upper_bound = fx_rate + tolerance
else:
fx_rate = self.env['res.currency']._get_conversion_rate(
resolved_cur, amls.company_currency_id,
amls.company_id, newest_line.date,
)
lower_bound = upper_bound = fx_rate
# Identify lines at the correct rate to avoid spurious exchange diffs
at_correct_rate = {
ln
for ln, rv in residual_amounts.items()
if (
ln.currency_id == resolved_cur
and abs(ln.balance) >= ln.company_currency_id.round(
abs(ln.amount_currency) * lower_bound
)
and abs(ln.balance) <= ln.company_currency_id.round(
abs(ln.amount_currency) * upper_bound
)
)
}
wiz.amount_currency = sum(
rv[wiz.reco_currency_id]['residual']
for rv in residual_amounts.values() if rv
)
raw_amount = sum(
(
rv[amls.company_currency_id]['residual']
if ln in at_correct_rate
else rv[wiz.reco_currency_id]['residual'] * fx_rate
)
for ln, rv in residual_amounts.items() if rv
)
wiz.amount = amls.company_currency_id.round(raw_amount)
wiz.force_partials = False
@api.depends('move_line_ids')
def _compute_edit_mode_amount_currency(self):
for wiz in self:
wiz.edit_mode_amount_currency = (
wiz.amount_currency if wiz.edit_mode else 0.0
)
@api.depends('edit_mode_amount_currency')
def _compute_edit_mode_amount(self):
for wiz in self:
if wiz.edit_mode:
single_ln = wiz.move_line_ids
conversion = (
abs(single_ln.amount_currency / single_ln.balance)
if single_ln.balance else 0.0
)
wiz.edit_mode_amount = (
single_ln.company_currency_id.round(
wiz.edit_mode_amount_currency / conversion
) if conversion else 0.0
)
else:
wiz.edit_mode_amount = 0.0
@api.depends('move_line_ids')
def _compute_edit_mode_reco_currency(self):
for wiz in self:
wiz.edit_mode_reco_currency_id = (
wiz.move_line_ids.currency_id if wiz.edit_mode else False
)
@api.depends('move_line_ids')
def _compute_edit_mode(self):
for wiz in self:
wiz.edit_mode = len(wiz.move_line_ids) == 1
@api.depends('move_line_ids.move_id', 'date')
def _compute_lock_date_violated_warning_message(self):
for wiz in self:
override_date = wiz._get_date_after_lock_date()
if override_date:
wiz.lock_date_violated_warning_message = _(
'The selected date conflicts with a lock date. '
'It will be adjusted to: %(replacement_date)s',
replacement_date=override_date,
)
else:
wiz.lock_date_violated_warning_message = None
@api.depends('company_id')
def _compute_reco_model_autocomplete_ids(self):
"""Find reconciliation models of type write-off with exactly one line."""
for wiz in self:
filter_domain = [
('rule_type', '=', 'writeoff_button'),
('company_id', '=', wiz.company_id.id),
('counterpart_type', 'not in', ('sale', 'purchase')),
]
q = self.env['account.reconcile.model']._where_calc(filter_domain)
matching_ids = [
row[0] for row in self.env.execute_query(SQL("""
SELECT arm.id
FROM %s
JOIN account_reconcile_model_line arml
ON arml.model_id = arm.id
WHERE %s
GROUP BY arm.id
HAVING COUNT(arm.id) = 1
""", q.from_clause, q.where_clause or SQL("TRUE")))
]
wiz.reco_model_autocomplete_ids = (
self.env['account.reconcile.model'].browse(matching_ids)
)
# -------------------------------------------------------------------------
# Onchange
# -------------------------------------------------------------------------
@api.onchange('reco_model_id')
def _onchange_reco_model_id(self):
"""Pre-fill write-off details from the selected reconciliation model."""
if self.reco_model_id:
model_line = self.reco_model_id.line_ids
self.to_check = self.reco_model_id.to_check
self.label = model_line.label
self.tax_id = model_line.tax_ids[0] if model_line[0].tax_ids else None
self.journal_id = model_line.journal_id
self.account_id = model_line.account_id
# -------------------------------------------------------------------------
# Constraints
# -------------------------------------------------------------------------
@api.constrains('edit_mode_amount_currency')
def _check_min_max_edit_mode_amount_currency(self):
for wiz in self:
if not wiz.edit_mode:
continue
if wiz.edit_mode_amount_currency == 0.0:
raise UserError(_(
'The write-off amount for a single line cannot be zero.'
))
is_debit = (
wiz.move_line_ids.balance > 0.0
or wiz.move_line_ids.amount_currency > 0.0
)
if is_debit and wiz.edit_mode_amount_currency < 0.0:
raise UserError(_(
'The write-off amount for a debit line must be positive.'
))
if not is_debit and wiz.edit_mode_amount_currency > 0.0:
raise UserError(_(
'The write-off amount for a credit line must be negative.'
))
# -------------------------------------------------------------------------
# Actions
# -------------------------------------------------------------------------
def _action_open_wizard(self):
self.ensure_one()
return {
'name': _('Write-Off Entry'),
'type': 'ir.actions.act_window',
'view_type': 'form',
'view_mode': 'form',
'res_model': 'account.reconcile.wizard',
'target': 'new',
}
# -------------------------------------------------------------------------
# Business Logic
# -------------------------------------------------------------------------
def _get_date_after_lock_date(self):
"""Return the first valid date if the current date violates a lock."""
self.ensure_one()
violated = self.company_id._get_violated_lock_dates(
self.date, bool(self.tax_id), self.journal_id,
)
if violated:
return violated[-1][0] + timedelta(days=1)
def _compute_write_off_taxes_data(self, partner):
"""Calculate tax breakdown for the write-off entry, including
base and per-repartition-line tax amounts."""
TaxEngine = self.env['account.tax']
wo_amount_currency = self.edit_mode_amount_currency or self.amount_currency
wo_amount = self.edit_mode_amount or self.amount
conversion_rate = abs(wo_amount_currency / wo_amount)
tax_usage = self.tax_id.type_tax_use if self.tax_id else None
is_refund_entry = (
(tax_usage == 'sale' and wo_amount_currency > 0.0)
or (tax_usage == 'purchase' and wo_amount_currency < 0.0)
)
base_input = TaxEngine._prepare_base_line_for_taxes_computation(
self,
partner_id=partner,
currency_id=self.reco_currency_id,
tax_ids=self.tax_id,
price_unit=wo_amount_currency,
quantity=1.0,
account_id=self.account_id,
is_refund=is_refund_entry,
rate=conversion_rate,
special_mode='total_included',
)
computation_lines = [base_input]
TaxEngine._add_tax_details_in_base_lines(computation_lines, self.company_id)
TaxEngine._round_base_lines_tax_details(computation_lines, self.company_id)
TaxEngine._add_accounting_data_in_base_lines_tax_details(
computation_lines, self.company_id, include_caba_tags=True,
)
tax_output = TaxEngine._prepare_tax_lines(computation_lines, self.company_id)
_input_line, base_update = tax_output['base_lines_to_update'][0]
tax_detail_list = []
for tax_line_vals in tax_output['tax_lines_to_add']:
tax_detail_list.append({
'tax_amount': tax_line_vals['balance'],
'tax_amount_currency': tax_line_vals['amount_currency'],
'tax_tag_ids': tax_line_vals['tax_tag_ids'],
'tax_account_id': tax_line_vals['account_id'],
})
base_amt_currency = base_update['amount_currency']
base_amt = wo_amount - sum(d['tax_amount'] for d in tax_detail_list)
return {
'base_amount': base_amt,
'base_amount_currency': base_amt_currency,
'base_tax_tag_ids': base_update['tax_tag_ids'],
'tax_lines_data': tax_detail_list,
}
def _create_write_off_lines(self, partner=None):
"""Build Command.create entries for the write-off journal entry."""
if not partner:
partner = self.env['res.partner']
target_partner = self.to_partner_id if self.is_rec_pay_account else partner
tax_info = (
self._compute_write_off_taxes_data(target_partner)
if self.tax_id else None
)
wo_amount_currency = self.edit_mode_amount_currency or self.amount_currency
wo_amount = self.edit_mode_amount or self.amount
line_commands = [
# Counterpart on reconciliation account
Command.create({
'name': self.label or _('Write-Off'),
'account_id': self.reco_account_id.id,
'partner_id': partner.id,
'currency_id': self.reco_currency_id.id,
'amount_currency': -wo_amount_currency,
'balance': -wo_amount,
}),
# Write-off on target account
Command.create({
'name': self.label,
'account_id': self.account_id.id,
'partner_id': target_partner.id,
'currency_id': self.reco_currency_id.id,
'tax_ids': self.tax_id.ids,
'tax_tag_ids': (
tax_info['base_tax_tag_ids'] if tax_info else None
),
'amount_currency': (
tax_info['base_amount_currency']
if tax_info else wo_amount_currency
),
'balance': (
tax_info['base_amount'] if tax_info else wo_amount
),
}),
]
# Append one line per tax repartition
if tax_info:
for detail in tax_info['tax_lines_data']:
line_commands.append(Command.create({
'name': self.tax_id.name,
'account_id': detail['tax_account_id'],
'partner_id': target_partner.id,
'currency_id': self.reco_currency_id.id,
'tax_tag_ids': detail['tax_tag_ids'],
'amount_currency': detail['tax_amount_currency'],
'balance': detail['tax_amount'],
}))
return line_commands
def create_write_off(self):
"""Create and post a write-off journal entry."""
self.ensure_one()
linked_partners = self.move_line_ids.partner_id
partner = linked_partners if len(linked_partners) == 1 else None
entry_vals = {
'journal_id': self.journal_id.id,
'company_id': self.company_id.id,
'date': self._get_date_after_lock_date() or self.date,
'checked': not self.to_check,
'line_ids': self._create_write_off_lines(partner=partner),
}
wo_move = self.env['account.move'].with_context(
skip_invoice_sync=True,
skip_invoice_line_sync=True,
).create(entry_vals)
wo_move.action_post()
return wo_move
def create_transfer(self):
"""Create and post an account transfer entry, grouped by partner
and currency to maintain correct partner ledger balances."""
self.ensure_one()
transfer_cmds = []
source_lines = self.move_line_ids.filtered(
lambda ln: ln.account_id == self.transfer_from_account_id,
)
for (partner, currency), grouped_lines in groupby(
source_lines, lambda ln: (ln.partner_id, ln.currency_id),
):
group_balance = sum(ln.amount_residual for ln in grouped_lines)
group_amt_cur = sum(ln.amount_residual_currency for ln in grouped_lines)
transfer_cmds.extend([
Command.create({
'name': _('Transfer from %s', self.transfer_from_account_id.display_name),
'account_id': self.reco_account_id.id,
'partner_id': partner.id,
'currency_id': currency.id,
'amount_currency': group_amt_cur,
'balance': group_balance,
}),
Command.create({
'name': _('Transfer to %s', self.reco_account_id.display_name),
'account_id': self.transfer_from_account_id.id,
'partner_id': partner.id,
'currency_id': currency.id,
'amount_currency': -group_amt_cur,
'balance': -group_balance,
}),
])
xfer_move = self.env['account.move'].create({
'journal_id': self.journal_id.id,
'company_id': self.company_id.id,
'date': self._get_date_after_lock_date() or self.date,
'line_ids': transfer_cmds,
})
xfer_move.action_post()
return xfer_move
def reconcile(self):
"""Execute reconciliation with optional transfer and/or write-off."""
self.ensure_one()
target_lines = self.move_line_ids._origin
needs_transfer = self.is_transfer_required
needs_writeoff = (
self.edit_mode
or (self.is_write_off_required and not self.allow_partials)
)
# Handle account transfer if two accounts are involved
if needs_transfer:
xfer_move = self.create_transfer()
from_lines = target_lines.filtered(
lambda ln: ln.account_id == self.transfer_from_account_id,
)
xfer_from_lines = xfer_move.line_ids.filtered(
lambda ln: ln.account_id == self.transfer_from_account_id,
)
xfer_to_lines = xfer_move.line_ids.filtered(
lambda ln: ln.account_id == self.reco_account_id,
)
(from_lines + xfer_from_lines).reconcile()
target_lines = target_lines - from_lines + xfer_to_lines
# Handle write-off if balance is non-zero
if needs_writeoff:
wo_move = self.create_write_off()
wo_counterpart = wo_move.line_ids[0]
target_lines += wo_counterpart
reconcile_plan = [[target_lines, wo_counterpart]]
else:
reconcile_plan = [target_lines]
self.env['account.move.line']._reconcile_plan(reconcile_plan)
if needs_transfer:
return target_lines + xfer_move.line_ids
return target_lines
def reconcile_open(self):
"""Reconcile and open the result in the reconciliation view."""
self.ensure_one()
return self.reconcile().open_reconcile_view()

View File

@@ -0,0 +1,107 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_account_reconcile_wizard" model="ir.ui.view">
<field name="name">account.reconcile.wizard.form</field>
<field name="model">account.reconcile.wizard</field>
<field name="arch" type="xml">
<form>
<field name="company_id" invisible="1"/>
<field name="move_line_ids" invisible="1"/>
<field name="company_currency_id" invisible="1"/>
<field name="single_currency_mode" invisible="1"/>
<field name="is_write_off_required" invisible="1"/>
<field name="is_transfer_required" invisible="1"/>
<field name="display_allow_partials" invisible="1"/>
<field name="force_partials" invisible="1"/>
<field name="is_rec_pay_account" invisible="1"/>
<field name="edit_mode" invisible="1"/>
<field name="reco_model_autocomplete_ids" invisible="1"/>
<!-- Warnings -->
<div class="alert alert-warning" role="alert" invisible="not is_transfer_required">
<field name="transfer_warning_message"/>
</div>
<div class="alert alert-warning" role="alert" invisible="not lock_date_violated_warning_message">
<field name="lock_date_violated_warning_message"/>
</div>
<div class="alert alert-warning" role="alert" invisible="not force_partials">
<p>Only partial reconciliation is possible. Proceed in multiple steps if you want to full reconcile.</p>
</div>
<!-- Reco models -->
<field name="reco_model_id" nolabel="1"
domain="[['id', 'in', reco_model_autocomplete_ids]]"
widget="selection_badge"
options="{'horizontal': true}"
invisible="not reco_model_autocomplete_ids and not reco_model_id"/>
<group>
<!-- Left side of the form -->
<group>
<field name="allow_partials" invisible="not display_allow_partials" readonly="force_partials"/>
<field name="account_id"
required="not allow_partials or edit_mode"
invisible="allow_partials and not edit_mode"/>
<field name="to_partner_id"
required="not allow_partials or edit_mode"
invisible="not is_rec_pay_account or (allow_partials and not edit_mode)"/>
<field name="tax_id"
context="{'append_type_to_tax_name': True}"
invisible="allow_partials and not edit_mode"/>
<field name="journal_id"
required="not allow_partials or edit_mode"
invisible="allow_partials and not edit_mode"/>
<field name="label"
required="not allow_partials or edit_mode"
invisible="allow_partials and not edit_mode"/>
</group>
<!-- Right side of the form-->
<group invisible="allow_partials and not edit_mode">
<div class="o_td_label">
<label for="amount_currency"
string="Amount"
class="o_form_label"
invisible="edit_mode"/>
<label for="edit_mode_amount_currency"
string="Amount"
class="o_form_label"
invisible="not edit_mode"/>
</div>
<div class="d-flex">
<div name="regular_mode_amounts" invisible="edit_mode">
<field name="amount_currency"
required="not allow_partials and not edit_mode"
options="{'no_symbol': True}"
class="oe_inline"/>
<span class="oe_inline o_form_label mx-3"
invisible="single_currency_mode"> in </span>
<field name="reco_currency_id"
invisible="single_currency_mode"
class="oe_inline"/>
</div>
<div name="edit_mode_amounts" invisible="not edit_mode">
<field name="edit_mode_amount_currency"
required="edit_mode"
options="{'no_symbol': True}"
class="oe_inline"/>
<span class="oe_inline o_form_label mx-3"
invisible="single_currency_mode"> in </span>
<field name="reco_currency_id"
invisible="single_currency_mode"
class="oe_inline"/>
</div>
</div>
<field name="date" required="not allow_partials or edit_mode"/>
<field name="to_check"/>
</group>
</group>
<footer>
<button string="Reconcile" class="btn-primary" name="reconcile" type="object" data-hotkey="q"/>
<button string="Reconcile &amp; open" class="btn-primary" name="reconcile_open" type="object" data-hotkey="o"/>
<button string="Discard" special="cancel" data-hotkey="x"/>
</footer>
</form>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,32 @@
# Fusion Accounting - File Download Error Wizard
# Displays generation errors and optionally offers a partial file download
from odoo import fields, models
class AccountReportFileDownloadErrorWizard(models.TransientModel):
"""Transient wizard shown when report file generation encounters
recoverable errors, giving the user visibility into what went
wrong and an option to download any partial output."""
_name = 'account.report.file.download.error.wizard'
_description = "Fusion Accounting Report Download Error Handler"
actionable_errors = fields.Json()
file_name = fields.Char()
file_content = fields.Binary()
def button_download(self):
"""Trigger browser download of the partially generated file,
if one was produced despite the errors."""
self.ensure_one()
if self.file_name:
download_url = (
f'/web/content/{self._name}/{self.id}'
f'/file_content/{self.file_name}?download=1'
)
return {
'type': 'ir.actions.act_url',
'url': download_url,
'close': True,
}

View File

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="view_account_report_file_download_error_wizard_form" model="ir.ui.view">
<field name="name">account.report.file.download.error.wizard.form</field>
<field name="model">account.report.file.download.error.wizard</field>
<field name="arch" type="xml">
<form string="File Download Errors">
<field name="file_content" invisible="1"/>
<div>
<span>One or more error(s) occurred during file generation:</span>
</div>
<div>
<field name="actionable_errors" class="o_field_html w-100" widget="actionable_errors"/>
<p invisible="file_content"><i>Errors marked with <i class="fa fa-warning"/> are critical and prevent the file generation.</i></p>
</div>
<footer>
<button string="Download Anyway"
class="btn btn-primary"
invisible="file_content"
disabled=""/>
<button string="Download Anyway"
name="button_download"
type="object"
class="btn btn-primary"
close="1"
data-hotkey="d"
invisible="not file_content"/>
<button string="Close"
class="btn btn-secondary"
special="cancel"
data-hotkey="z"/>
</footer>
</form>
</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,374 @@
# Fusion Accounting - Report Send Wizard
# Handles email dispatch and download of accounting reports to partners
from odoo import _, api, fields, models
from odoo.exceptions import UserError
from odoo.tools.misc import get_lang
class AccountReportSend(models.TransientModel):
"""Wizard providing a unified interface for sending accounting reports
via email and/or downloading them as PDF attachments."""
_name = 'account.report.send'
_description = "Fusion Accounting Report Dispatch Wizard"
partner_ids = fields.Many2many(
comodel_name='res.partner',
compute='_compute_partner_ids',
)
mode = fields.Selection(
selection=[
('single', "Single Recipient"),
('multi', "Multiple Recipients"),
],
compute='_compute_mode',
readonly=False,
store=True,
)
# -- Download options --
enable_download = fields.Boolean()
checkbox_download = fields.Boolean(string="Download")
# -- Email options --
enable_send_mail = fields.Boolean(default=True)
checkbox_send_mail = fields.Boolean(string="Email", default=True)
display_mail_composer = fields.Boolean(compute='_compute_mail_ui_state')
warnings = fields.Json(compute='_compute_warnings')
send_mail_readonly = fields.Boolean(compute='_compute_mail_ui_state')
mail_template_id = fields.Many2one(
comodel_name='mail.template',
string="Email template",
domain="[('model', '=', 'res.partner')]",
)
account_report_id = fields.Many2one(
comodel_name='account.report',
string="Report",
)
report_options = fields.Json()
mail_lang = fields.Char(
string="Lang",
compute='_compute_mail_lang',
)
mail_partner_ids = fields.Many2many(
comodel_name='res.partner',
string="Recipients",
compute='_compute_mail_partner_ids',
store=True,
readonly=False,
)
mail_subject = fields.Char(
string="Subject",
compute='_compute_mail_subject_body',
store=True,
readonly=False,
)
mail_body = fields.Html(
string="Contents",
sanitize_style=True,
compute='_compute_mail_subject_body',
store=True,
readonly=False,
)
mail_attachments_widget = fields.Json(
compute='_compute_mail_attachments_widget',
store=True,
readonly=False,
)
# -------------------------------------------------------------------------
# Default values
# -------------------------------------------------------------------------
@api.model
def default_get(self, fields_list):
# EXTENDS 'base'
defaults = super().default_get(fields_list)
ctx_options = self.env.context.get('default_report_options', {})
if 'account_report_id' in fields_list and 'account_report_id' not in defaults:
defaults['account_report_id'] = ctx_options.get('report_id', False)
defaults['report_options'] = ctx_options
return defaults
# -------------------------------------------------------------------------
# Helper methods
# -------------------------------------------------------------------------
@api.model
def _get_mail_field_value(self, partner, mail_template, mail_lang, field, **kwargs):
"""Render a single field from the mail template for a given partner."""
if not mail_template:
return
rendered = mail_template.with_context(lang=mail_lang)._render_field(
field, partner.ids, **kwargs
)
return rendered[partner._origin.id]
def _get_default_mail_attachments_widget(self, partner, mail_template):
"""Combine report placeholder attachments with template-defined attachments."""
placeholder_data = self._get_placeholder_mail_attachments_data(partner)
template_data = self._get_mail_template_attachments_data(mail_template)
return placeholder_data + template_data
def _get_wizard_values(self):
"""Serialize the current wizard state into a storable dictionary."""
self.ensure_one()
opts = self.report_options
if not opts.get('partner_ids', []):
opts['partner_ids'] = self.partner_ids.ids
return {
'mail_template_id': self.mail_template_id.id,
'checkbox_download': self.checkbox_download,
'checkbox_send_mail': self.checkbox_send_mail,
'report_options': opts,
}
def _get_placeholder_mail_attachments_data(self, partner):
"""Generate placeholder attachment metadata for the report PDF.
Returns a list with one dict per placeholder containing:
- id: unique placeholder identifier string
- name: display filename
- mimetype: MIME type of the file
- placeholder: True (prevents download/deletion in the widget)
"""
self.ensure_one()
pdf_name = (
f"{partner.name} - "
f"{self.account_report_id.get_default_report_filename(self.report_options, 'pdf')}"
)
return [{
'id': f'placeholder_{pdf_name}',
'name': pdf_name,
'mimetype': 'application/pdf',
'placeholder': True,
}]
@api.model
def _get_mail_template_attachments_data(self, mail_template):
"""Build attachment metadata from files linked to the mail template."""
return [
{
'id': att.id,
'name': att.name,
'mimetype': att.mimetype,
'placeholder': False,
'mail_template_id': mail_template.id,
}
for att in mail_template.attachment_ids
]
# -------------------------------------------------------------------------
# Computed fields
# -------------------------------------------------------------------------
@api.depends('partner_ids')
def _compute_mode(self):
for wiz in self:
wiz.mode = 'single' if len(wiz.partner_ids) == 1 else 'multi'
@api.depends('checkbox_send_mail')
def _compute_mail_ui_state(self):
"""Determine whether the full mail composer should be shown
and whether the send-mail checkbox should be read-only."""
for wiz in self:
wiz.display_mail_composer = wiz.mode == 'single'
recipients_missing_email = wiz.mail_partner_ids.filtered(lambda p: not p.email)
wiz.send_mail_readonly = recipients_missing_email == wiz.mail_partner_ids
@api.depends('mail_partner_ids', 'checkbox_send_mail', 'send_mail_readonly')
def _compute_warnings(self):
for wiz in self:
alert_map = {}
no_email_partners = wiz.mail_partner_ids.filtered(lambda p: not p.email)
if wiz.send_mail_readonly or (wiz.checkbox_send_mail and no_email_partners):
alert_map['account_missing_email'] = {
'message': _("Partner(s) should have an email address."),
'action_text': _("View Partner(s)"),
'action': no_email_partners._get_records_action(
name=_("Check Partner(s) Email(s)")
),
}
wiz.warnings = alert_map
@api.depends('partner_ids')
def _compute_mail_lang(self):
for wiz in self:
if wiz.mode == 'single':
wiz.mail_lang = wiz.partner_ids.lang
else:
wiz.mail_lang = get_lang(self.env).code
@api.depends('account_report_id', 'report_options')
def _compute_partner_ids(self):
for wiz in self:
wiz.partner_ids = wiz.account_report_id._get_report_send_recipients(
wiz.report_options
)
@api.depends('account_report_id', 'report_options')
def _compute_mail_partner_ids(self):
for wiz in self:
wiz.mail_partner_ids = wiz.partner_ids
@api.depends('mail_template_id', 'mail_lang', 'mode')
def _compute_mail_subject_body(self):
for wiz in self:
if wiz.mode == 'single' and wiz.mail_template_id:
wiz.mail_subject = self._get_mail_field_value(
wiz.mail_partner_ids, wiz.mail_template_id, wiz.mail_lang, 'subject'
)
wiz.mail_body = self._get_mail_field_value(
wiz.mail_partner_ids, wiz.mail_template_id, wiz.mail_lang,
'body_html', options={'post_process': True},
)
else:
wiz.mail_subject = wiz.mail_body = None
@api.depends('mail_template_id', 'mode')
def _compute_mail_attachments_widget(self):
for wiz in self:
if wiz.mode == 'single':
wiz.mail_attachments_widget = wiz._get_default_mail_attachments_widget(
wiz.mail_partner_ids, wiz.mail_template_id,
)
else:
wiz.mail_attachments_widget = []
# -------------------------------------------------------------------------
# Actions
# -------------------------------------------------------------------------
@api.model
def _action_download(self, attachments):
"""Return an action that triggers browser download of the given attachments,
or a zip archive when multiple files are present."""
return {
'type': 'ir.actions.act_url',
'url': f'/account_reports/download_attachments/{",".join(map(str, attachments.ids))}',
'close': True,
}
def _process_send_and_print(self, report, options, recipient_partner_ids=None, wizard=None):
"""Core processing logic: generates the report for each partner,
optionally emails it, and collects attachments for download.
:param report: account.report record to generate
:param options: dict of report generation options
:param recipient_partner_ids: explicit list of partner IDs to receive emails
:param wizard: the send wizard record (None when running via cron)
"""
stored_vals = report.send_and_print_values if not wizard else wizard._get_wizard_values()
should_email = stored_vals['checkbox_send_mail']
should_download = stored_vals['checkbox_download']
template = self.env['mail.template'].browse(stored_vals['mail_template_id'])
if wizard:
attachment_ids_from_widget = [
item['id']
for item in (wizard.mail_attachments_widget or [])
if not item['placeholder']
]
else:
attachment_ids_from_widget = template.attachment_ids.ids
options['unfold_all'] = True
target_partner_ids = options.get('partner_ids', [])
target_partners = self.env['res.partner'].browse(target_partner_ids)
if not recipient_partner_ids:
recipient_partner_ids = target_partners.filtered('email').ids
files_for_download = self.env['ir.attachment']
for partner_rec in target_partners:
options['partner_ids'] = partner_rec.ids
generated_attachment = partner_rec._get_partner_account_report_attachment(
report, options,
)
if should_email and recipient_partner_ids:
# Determine subject/body based on single vs multi mode
if wizard and wizard.mode == 'single':
email_subject = self.mail_subject
email_body = self.mail_body
else:
email_subject = self._get_mail_field_value(
partner_rec, template, partner_rec.lang, 'subject',
)
email_body = self._get_mail_field_value(
partner_rec, template, partner_rec.lang,
'body_html', options={'post_process': True},
)
partner_rec.message_post(
body=email_body,
subject=email_subject,
partner_ids=recipient_partner_ids,
attachment_ids=attachment_ids_from_widget + generated_attachment.ids,
)
if should_download:
files_for_download += generated_attachment
if files_for_download:
return self._action_download(files_for_download)
def action_send_and_print(self, force_synchronous=False):
"""Entry point: dispatch report generation, emailing, and/or downloading.
When sending to multiple recipients without download, processing is
deferred to a background cron job for better performance.
:param force_synchronous: bypass async processing when True
"""
self.ensure_one()
if self.mode == 'multi' and self.checkbox_send_mail and not self.mail_template_id:
raise UserError(
_('Please select a mail template to send multiple statements.')
)
# Download always requires synchronous processing
force_synchronous = force_synchronous or self.checkbox_download
defer_to_cron = self.mode == 'multi' and not force_synchronous
if defer_to_cron:
if self.account_report_id.send_and_print_values:
raise UserError(
_('There are currently reports waiting to be sent, please try again later.')
)
self.account_report_id.send_and_print_values = self._get_wizard_values()
self.env.ref('fusion_accounting.ir_cron_account_report_send')._trigger()
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'type': 'info',
'title': _('Sending statements'),
'message': _('Statements are being sent in the background.'),
'next': {'type': 'ir.actions.act_window_close'},
},
}
merged_options = {
**self.report_options,
'partner_ids': self.partner_ids.ids,
}
return self._process_send_and_print(
report=self.account_report_id,
options=merged_options,
recipient_partner_ids=self.mail_partner_ids.ids,
wizard=self,
)

View File

@@ -0,0 +1,92 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="account_report_send_form" model="ir.ui.view">
<field name="name">account.report.send.form</field>
<field name="model">account.report.send</field>
<field name="group_ids" eval="[Command.link(ref('base.group_user'))]"/>
<field name="arch" type="xml">
<form>
<!-- Invisible fields -->
<field name="mode" invisible="1"/>
<field name="enable_download" invisible="1"/>
<field name="enable_send_mail" invisible="1"/>
<field name="send_mail_readonly" invisible="1"/>
<field name="display_mail_composer" invisible="1"/>
<field name="mail_lang" invisible="1"/>
<field name="account_report_id" invisible="1"/>
<field name="report_options" invisible="1"/>
<div class="m-0" name="warnings" invisible="not warnings">
<field name="warnings" class="o_field_html" widget="actionable_errors"/>
</div>
<!-- Options -->
<div name="options" class="row">
<div name="standard_options" class="col-3">
<div name="option_send_mail"
invisible="not enable_send_mail">
<i class="fa fa-question-circle ml4"
role="img"
aria-label="Warning"
title="The email address is unknown on the partner"
invisible="not send_mail_readonly"/>
</div>
</div>
<div name="advanced_options" class="col-3"/>
</div>
<!-- Mail -->
<div invisible="not checkbox_send_mail">
<group invisible="not display_mail_composer">
<label for="mail_partner_ids" string="Recipients"/>
<div>
<field name="mail_partner_ids"
widget="many2many_tags_email"
placeholder="Add contacts to notify..."
options="{'no_quick_create': True}"
context="{'show_email': True, 'form_view_ref': 'base.view_partner_simple_form'}"/>
</div>
<field name="mail_subject"
placeholder="Subject..."
required="checkbox_send_mail and mode == 'single'"/>
</group>
<field name="mail_body"
class="oe-bordered-editor"
widget="html_mail"
invisible="not display_mail_composer"/>
<group>
<group invisible="not display_mail_composer">
<field name="mail_attachments_widget"
widget="mail_attachments"
string="Attach a file"
nolabel="1"
colspan="2"/>
</group>
<group>
<field name="mail_template_id"
required="mode == 'multi'"
options="{'no_create': True, 'no_edit': True}"
context="{'default_model': 'res.partner'}"/>
</group>
</group>
</div>
<footer>
<button string="Print &amp; Send"
invisible="not checkbox_send_mail and not checkbox_download"
data-hotkey="q"
name="action_send_and_print"
type="object"
class="print btn-primary o_mail_send">
</button>
<button string="Cancel"
data-hotkey="x"
special="cancel"
class="btn-secondary"/>
</footer>
</form>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ================================================================= -->
<!-- Account Transfer Wizard Form View -->
<!-- ================================================================= -->
<record id="fusion_account_transfer_form" model="ir.ui.view">
<field name="name">fusion.account.transfer.form</field>
<field name="model">fusion.account.transfer</field>
<field name="arch" type="xml">
<form string="Account Balance Transfer">
<group>
<group string="Transfer Details">
<field name="company_id" invisible="1"/>
<field name="currency_id" invisible="1"/>
<field name="source_account_id"
options="{'no_create': True}"
placeholder="Select source account..."/>
<field name="destination_account_id"
options="{'no_create': True}"
placeholder="Select destination account..."/>
<field name="amount"/>
</group>
<group string="Entry Details">
<field name="journal_id"
options="{'no_create': True}"/>
<field name="date"/>
<field name="partner_id"
options="{'no_create': True}"/>
<field name="memo"
placeholder="e.g. Reclassification of prepaid expenses"/>
</group>
</group>
<footer>
<button name="action_transfer"
type="object"
string="Create Transfer"
class="btn-primary"
data-hotkey="q"/>
<button string="Cancel"
class="btn-secondary"
special="cancel"
data-hotkey="x"/>
</footer>
</form>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,650 @@
# Fusion Accounting - Asset Modification Wizard
# Enables users to modify, dispose, sell, pause, or resume
# fixed assets with full depreciation board recalculation.
from dateutil.relativedelta import relativedelta
from odoo import api, fields, models, _, Command
from odoo.exceptions import UserError
from odoo.tools import float_is_zero
from odoo.tools.misc import format_date
class AssetModify(models.TransientModel):
"""Wizard for modifying running assets, including value changes,
depreciation parameter adjustments, disposal, and sales."""
_name = 'asset.modify'
_description = 'Modify Asset'
# --- Core Fields ---
name = fields.Text(string='Note')
asset_id = fields.Many2one(
string="Asset",
comodel_name='account.asset',
required=True,
help="The target asset to modify.",
ondelete="cascade",
)
currency_id = fields.Many2one(
related='asset_id.currency_id',
)
company_id = fields.Many2one(
comodel_name='res.company',
related='asset_id.company_id',
)
date = fields.Date(
default=lambda self: fields.Date.today(),
string='Date',
)
# --- Depreciation Parameters ---
method_number = fields.Integer(
string='Duration',
required=True,
)
method_period = fields.Selection(
selection=[('1', 'Months'), ('12', 'Years')],
string='Number of Months in a Period',
help="Interval between successive depreciation entries.",
)
value_residual = fields.Monetary(
string="Depreciable Amount",
help="Updated depreciable amount for the asset.",
compute="_compute_value_residual",
store=True,
readonly=False,
)
salvage_value = fields.Monetary(
string="Not Depreciable Amount",
help="Updated non-depreciable (salvage) amount.",
)
# --- Action Selection ---
modify_action = fields.Selection(
selection="_get_selection_modify_options",
string="Action",
)
# --- Gross Increase Accounts ---
account_asset_id = fields.Many2one(
comodel_name='account.account',
string="Gross Increase Account",
check_company=True,
domain="[]",
)
account_asset_counterpart_id = fields.Many2one(
comodel_name='account.account',
check_company=True,
domain="[]",
string="Asset Counterpart Account",
)
account_depreciation_id = fields.Many2one(
comodel_name='account.account',
check_company=True,
domain="[]",
string="Depreciation Account",
)
account_depreciation_expense_id = fields.Many2one(
comodel_name='account.account',
check_company=True,
domain="[]",
string="Expense Account",
)
# --- Sale / Disposal Fields ---
invoice_ids = fields.Many2many(
comodel_name='account.move',
string="Customer Invoice",
check_company=True,
domain="[('move_type', '=', 'out_invoice'), ('state', '=', 'posted')]",
help="Invoice(s) linked to this disposal or sale.",
)
invoice_line_ids = fields.Many2many(
comodel_name='account.move.line',
check_company=True,
domain="[('move_id', '=', invoice_id), ('display_type', '=', 'product')]",
help="Specific invoice line(s) related to this asset.",
)
select_invoice_line_id = fields.Boolean(
compute="_compute_select_invoice_line_id",
)
gain_value = fields.Boolean(
compute="_compute_gain_value",
)
# --- Gain/Loss Accounts ---
gain_account_id = fields.Many2one(
comodel_name='account.account',
check_company=True,
domain="[]",
compute="_compute_accounts",
inverse="_inverse_gain_account",
readonly=False,
compute_sudo=True,
help="Account for recording gains on asset disposal.",
)
loss_account_id = fields.Many2one(
comodel_name='account.account',
check_company=True,
domain="[]",
compute="_compute_accounts",
inverse="_inverse_loss_account",
readonly=False,
compute_sudo=True,
help="Account for recording losses on asset disposal.",
)
# --- Informational ---
informational_text = fields.Html(
compute='_compute_informational_text',
)
gain_or_loss = fields.Selection(
selection=[('gain', 'Gain'), ('loss', 'Loss'), ('no', 'No')],
compute='_compute_gain_or_loss',
)
# -------------------------------------------------------------------------
# Selection Helpers
# -------------------------------------------------------------------------
def _compute_modify_action(self):
"""Determine the default action based on context."""
if self.env.context.get('resume_after_pause'):
return 'resume'
return 'dispose'
@api.depends('asset_id')
def _get_selection_modify_options(self):
"""Return available modification actions."""
if self.env.context.get('resume_after_pause'):
return [('resume', _('Resume'))]
return [
('dispose', _("Dispose")),
('sell', _("Sell")),
('modify', _("Re-evaluate")),
('pause', _("Pause")),
]
# -------------------------------------------------------------------------
# Compute Methods
# -------------------------------------------------------------------------
@api.depends('company_id')
def _compute_accounts(self):
for rec in self:
rec.gain_account_id = rec.company_id.gain_account_id
rec.loss_account_id = rec.company_id.loss_account_id
@api.depends('date')
def _compute_value_residual(self):
for rec in self:
rec.value_residual = rec.asset_id._get_residual_value_at_date(rec.date)
def _inverse_gain_account(self):
for rec in self:
rec.company_id.sudo().gain_account_id = rec.gain_account_id
def _inverse_loss_account(self):
for rec in self:
rec.company_id.sudo().loss_account_id = rec.loss_account_id
@api.depends('asset_id', 'invoice_ids', 'invoice_line_ids', 'modify_action', 'date')
def _compute_gain_or_loss(self):
"""Determine whether disposing/selling results in a gain or loss."""
for rec in self:
invoice_total = abs(sum(
ln.balance for ln in rec.invoice_line_ids
))
book_val = rec.asset_id._get_own_book_value(rec.date)
cmp_result = rec.company_id.currency_id.compare_amounts(
book_val, invoice_total,
)
if rec.modify_action in ('sell', 'dispose') and cmp_result < 0:
rec.gain_or_loss = 'gain'
elif rec.modify_action in ('sell', 'dispose') and cmp_result > 0:
rec.gain_or_loss = 'loss'
else:
rec.gain_or_loss = 'no'
@api.depends('asset_id', 'value_residual', 'salvage_value')
def _compute_gain_value(self):
"""Check whether the modification increases the asset's book value."""
for rec in self:
rec.gain_value = rec.currency_id.compare_amounts(
rec._get_own_book_value(),
rec.asset_id._get_own_book_value(rec.date),
) > 0
@api.depends('loss_account_id', 'gain_account_id', 'gain_or_loss',
'modify_action', 'date', 'value_residual', 'salvage_value')
def _compute_informational_text(self):
"""Generate user-facing description of what will happen."""
for wiz in self:
formatted_date = format_date(self.env, wiz.date)
if wiz.modify_action == 'dispose':
acct_name, result_label = '', _('gain/loss')
if wiz.gain_or_loss == 'gain':
acct_name = wiz.gain_account_id.display_name or ''
result_label = _('gain')
elif wiz.gain_or_loss == 'loss':
acct_name = wiz.loss_account_id.display_name or ''
result_label = _('loss')
wiz.informational_text = _(
"Depreciation will be posted through %(date)s."
"<br/> A disposal entry will go to the %(account_type)s "
"account <b>%(account)s</b>.",
date=formatted_date,
account_type=result_label,
account=acct_name,
)
elif wiz.modify_action == 'sell':
acct_name = ''
if wiz.gain_or_loss == 'gain':
acct_name = wiz.gain_account_id.display_name or ''
elif wiz.gain_or_loss == 'loss':
acct_name = wiz.loss_account_id.display_name or ''
wiz.informational_text = _(
"Depreciation will be posted through %(date)s."
"<br/> A secondary entry will neutralize the original "
"revenue and post the sale outcome to account "
"<b>%(account)s</b>.",
date=formatted_date,
account=acct_name,
)
elif wiz.modify_action == 'pause':
wiz.informational_text = _(
"Depreciation will be posted through %s.",
formatted_date,
)
elif wiz.modify_action == 'modify':
increase_note = (
_("A child asset will be created for the value increase. <br/>")
if wiz.gain_value else ""
)
wiz.informational_text = _(
"Depreciation will be posted through %(date)s. <br/> "
"%(extra_text)s Future entries will be recalculated to "
"reflect the updated parameters.",
date=formatted_date,
extra_text=increase_note,
)
else:
# Resume or other
increase_note = (
_("A child asset will be created for the value increase. <br/>")
if wiz.gain_value else ""
)
wiz.informational_text = _(
"%s Future entries will be recalculated to reflect "
"the updated parameters.",
increase_note,
)
@api.depends('invoice_ids', 'modify_action')
def _compute_select_invoice_line_id(self):
for rec in self:
rec.select_invoice_line_id = (
rec.modify_action == 'sell'
and len(rec.invoice_ids.invoice_line_ids) > 1
)
# -------------------------------------------------------------------------
# Onchange
# -------------------------------------------------------------------------
@api.onchange('modify_action')
def _onchange_action(self):
if (
self.modify_action == 'sell'
and self.asset_id.children_ids.filtered(
lambda child: child.state in ('draft', 'open')
or child.value_residual > 0
)
):
raise UserError(_(
"Cannot automate the sale journal entry for an asset "
"with active gross increases. Please dispose of the "
"increase(s) first."
))
if self.modify_action not in ('modify', 'resume'):
self.write({
'value_residual': self.asset_id._get_residual_value_at_date(self.date),
'salvage_value': self.asset_id.salvage_value,
})
@api.onchange('invoice_ids')
def _onchange_invoice_ids(self):
"""Keep invoice_line_ids in sync when invoices change."""
valid_line_ids = self.invoice_ids.invoice_line_ids.filtered(
lambda ln: ln._origin.id in self.invoice_line_ids.ids,
)
self.invoice_line_ids = valid_line_ids
# Auto-select lines for single-line invoices
for inv in self.invoice_ids.filtered(lambda i: len(i.invoice_line_ids) == 1):
self.invoice_line_ids += inv.invoice_line_ids
# -------------------------------------------------------------------------
# CRUD Override
# -------------------------------------------------------------------------
@api.model_create_multi
def create(self, vals_list):
"""Populate defaults from the linked asset when not provided."""
AssetModel = self.env['account.asset']
for vals in vals_list:
if 'asset_id' not in vals:
continue
asset = AssetModel.browse(vals['asset_id'])
# Block if future posted depreciation exists
future_posted = asset.depreciation_move_ids.filtered(
lambda mv: (
mv.state == 'posted'
and not mv.reversal_move_ids
and mv.date > fields.Date.today()
)
)
if future_posted:
raise UserError(_(
'Please reverse any future-dated depreciation entries '
'before modifying this asset.'
))
# Fill in missing defaults from the asset
field_defaults = {
'method_number': asset.method_number,
'method_period': asset.method_period,
'salvage_value': asset.salvage_value,
'account_asset_id': asset.account_asset_id.id,
'account_depreciation_id': asset.account_depreciation_id.id,
'account_depreciation_expense_id': asset.account_depreciation_expense_id.id,
}
for fld, default_val in field_defaults.items():
if fld not in vals:
vals[fld] = default_val
return super().create(vals_list)
# -------------------------------------------------------------------------
# Main Business Actions
# -------------------------------------------------------------------------
def modify(self):
"""Re-evaluate the asset: update depreciation parameters,
handle value increases/decreases, and recompute the schedule."""
lock_threshold = self.asset_id.company_id._get_user_fiscal_lock_date(
self.asset_id.journal_id,
)
if self.date <= lock_threshold:
raise UserError(_(
"The selected date is on or before the fiscal lock date. "
"Re-evaluation is not permitted."
))
# Snapshot current values for change tracking
prior_vals = {
'method_number': self.asset_id.method_number,
'method_period': self.asset_id.method_period,
'value_residual': self.asset_id.value_residual,
'salvage_value': self.asset_id.salvage_value,
}
updated_vals = {
'method_number': self.method_number,
'method_period': self.method_period,
'salvage_value': self.salvage_value,
'account_asset_id': self.account_asset_id,
'account_depreciation_id': self.account_depreciation_id,
'account_depreciation_expense_id': self.account_depreciation_expense_id,
}
# Handle resume-after-pause scenario
is_resuming = self.env.context.get('resume_after_pause')
if is_resuming:
latest_depr = self.asset_id.depreciation_move_ids
last_depr_date = (
max(latest_depr, key=lambda m: m.date).date
if latest_depr else self.asset_id.acquisition_date
)
gap_days = self.asset_id._get_delta_days(last_depr_date, self.date) - 1
if self.currency_id.compare_amounts(gap_days, 0) < 0:
raise UserError(_(
"Resume date must be after the pause date."
))
updated_vals['asset_paused_days'] = (
self.asset_id.asset_paused_days + gap_days
)
updated_vals['state'] = 'open'
self.asset_id.message_post(
body=_("Asset resumed. %s", self.name),
)
# Compute value changes
current_book = self.asset_id._get_own_book_value(self.date)
target_book = self._get_own_book_value()
book_increase = target_book - current_book
new_residual, new_salvage = self._get_new_asset_values(current_book)
residual_diff = max(0, self.value_residual - new_residual)
salvage_diff = max(0, self.salvage_value - new_salvage)
# Create depreciation up to modification date (unless resuming)
if not is_resuming:
draft_before = self.env['account.move'].search_count([
('asset_id', '=', self.asset_id.id),
('state', '=', 'draft'),
('date', '<=', self.date),
], limit=1)
if draft_before:
raise UserError(_(
'Unposted depreciation entries exist before the '
'selected date. Please process them first.'
))
self.asset_id._create_move_before_date(self.date)
updated_vals['salvage_value'] = new_salvage
# Detect if child assets need recomputation
schedule_changed = (
updated_vals['method_number'] != self.asset_id.method_number
or updated_vals['method_period'] != self.asset_id.method_period
or (
updated_vals.get('asset_paused_days')
and not float_is_zero(
updated_vals['asset_paused_days']
- self.asset_id.asset_paused_days, 8,
)
)
)
self.asset_id.write(updated_vals)
# Create gross increase asset if value went up
total_increase = residual_diff + salvage_diff
if self.currency_id.compare_amounts(total_increase, 0) > 0:
increase_date = self.date + relativedelta(days=1)
increase_entry = self.env['account.move'].create({
'journal_id': self.asset_id.journal_id.id,
'date': increase_date,
'move_type': 'entry',
'asset_move_type': 'positive_revaluation',
'line_ids': [
Command.create({
'account_id': self.account_asset_id.id,
'debit': total_increase,
'credit': 0,
'name': _(
'Value increase for: %(asset)s',
asset=self.asset_id.name,
),
}),
Command.create({
'account_id': self.account_asset_counterpart_id.id,
'debit': 0,
'credit': total_increase,
'name': _(
'Value increase for: %(asset)s',
asset=self.asset_id.name,
),
}),
],
})
increase_entry._post()
child_asset = self.env['account.asset'].create({
'name': (
self.asset_id.name + ': ' + self.name
if self.name else ""
),
'currency_id': self.asset_id.currency_id.id,
'company_id': self.asset_id.company_id.id,
'method': self.asset_id.method,
'method_number': self.method_number,
'method_period': self.method_period,
'method_progress_factor': self.asset_id.method_progress_factor,
'acquisition_date': increase_date,
'value_residual': residual_diff,
'salvage_value': salvage_diff,
'prorata_date': increase_date,
'prorata_computation_type': (
'daily_computation'
if self.asset_id.prorata_computation_type == 'daily_computation'
else 'constant_periods'
),
'original_value': self._get_increase_original_value(
residual_diff, salvage_diff,
),
'account_asset_id': self.account_asset_id.id,
'account_depreciation_id': self.account_depreciation_id.id,
'account_depreciation_expense_id': self.account_depreciation_expense_id.id,
'journal_id': self.asset_id.journal_id.id,
'parent_id': self.asset_id.id,
'original_move_line_ids': [
(6, 0, increase_entry.line_ids.filtered(
lambda ln: ln.account_id == self.account_asset_id,
).ids),
],
})
child_asset.validate()
link_html = child_asset._get_html_link()
self.asset_id.message_post(
body=_('Gross increase created: %(link)s', link=link_html),
)
# Create negative revaluation entry if value went down
if self.currency_id.compare_amounts(book_increase, 0) < 0:
depr_vals = self.env['account.move']._prepare_move_for_asset_depreciation({
'amount': -book_increase,
'asset_id': self.asset_id,
'move_ref': _(
'Value decrease for: %(asset)s', asset=self.asset_id.name,
),
'depreciation_beginning_date': self.date,
'depreciation_end_date': self.date,
'date': self.date,
'asset_number_days': 0,
'asset_value_change': True,
'asset_move_type': 'negative_revaluation',
})
self.env['account.move'].create(depr_vals)._post()
# Recompute depreciation schedule
board_start = self.date if is_resuming else self.date + relativedelta(days=1)
if self.asset_id.depreciation_move_ids:
self.asset_id.compute_depreciation_board(board_start)
else:
self.asset_id.compute_depreciation_board()
# Propagate changes to child assets if schedule params changed
if schedule_changed:
child_assets = self.asset_id.children_ids
child_assets.write({
'method_number': updated_vals['method_number'],
'method_period': updated_vals['method_period'],
'asset_paused_days': self.asset_id.asset_paused_days,
})
for child in child_assets:
if not is_resuming:
child._create_move_before_date(self.date)
if child.depreciation_move_ids:
child.compute_depreciation_board(board_start)
else:
child.compute_depreciation_board()
child._check_depreciations()
child.depreciation_move_ids.filtered(
lambda mv: mv.state != 'posted',
)._post()
# Log tracked changes in the chatter
tracked_fields = self.env['account.asset'].fields_get(prior_vals.keys())
changes, tracking_vals = self.asset_id._mail_track(
tracked_fields, prior_vals,
)
if changes:
self.asset_id.message_post(
body=_('Depreciation board modified %s', self.name),
tracking_value_ids=tracking_vals,
)
self.asset_id._check_depreciations()
self.asset_id.depreciation_move_ids.filtered(
lambda mv: mv.state != 'posted',
)._post()
return {'type': 'ir.actions.act_window_close'}
def pause(self):
"""Pause depreciation for the asset."""
for rec in self:
rec.asset_id.pause(pause_date=rec.date, message=self.name)
def sell_dispose(self):
"""Dispose of or sell the asset, generating closing entries."""
self.ensure_one()
if (
self.gain_account_id == self.asset_id.account_depreciation_id
or self.loss_account_id == self.asset_id.account_depreciation_id
):
raise UserError(_(
"The gain/loss account cannot be the same as the "
"Depreciation Account."
))
disposal_lines = (
self.env['account.move.line']
if self.modify_action == 'dispose'
else self.invoice_line_ids
)
return self.asset_id.set_to_close(
invoice_line_ids=disposal_lines,
date=self.date,
message=self.name,
)
# -------------------------------------------------------------------------
# Utility Methods
# -------------------------------------------------------------------------
def _get_own_book_value(self):
"""Return the wizard's configured book value (residual + salvage)."""
return self.value_residual + self.salvage_value
def _get_increase_original_value(self, residual_increase, salvage_increase):
"""Compute the original value for a gross increase child asset."""
return residual_increase + salvage_increase
def _get_new_asset_values(self, current_asset_book):
"""Calculate capped residual and salvage values to ensure
they don't exceed the current book value."""
self.ensure_one()
capped_residual = min(
current_asset_book - min(self.salvage_value, self.asset_id.salvage_value),
self.value_residual,
)
capped_salvage = min(
current_asset_book - capped_residual,
self.salvage_value,
)
return capped_residual, capped_salvage

View File

@@ -0,0 +1,104 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record model="ir.ui.view" id="asset_modify_form">
<field name="name">wizard.asset.modify.form</field>
<field name="model">asset.modify</field>
<field name="arch" type="xml">
<form string="Modify Asset">
<field name="asset_id" invisible="1"/>
<field name="gain_value" invisible="1"/>
<field name="select_invoice_line_id" invisible="1"/>
<field name="gain_or_loss" invisible="1"/>
<field name="company_id" invisible="1"/>
<group col="2" invisible="modify_action == 'resume'">
<field name="modify_action" widget="radio" required="1" options="{'horizontal': true}"/>
</group>
<group invisible="not modify_action">
<group>
<field name="date"/>
<label for="method_number" invisible="modify_action not in ('modify', 'resume')"/>
<div class="o_row" invisible="modify_action not in ('modify', 'resume')">
<field name="method_number" required="1"/>
<field name="method_period" required="1" nolabel="1"/>
</div>
<field name="invoice_ids"
options="{'no_quick_create': True}"
context="{'default_move_type': 'out_invoice', 'input_full_display_name': True}"
invisible="modify_action != 'sell'"
required="modify_action == 'sell'"
widget="many2many_tags"/>
<field name="invoice_line_ids"
options="{'no_create': True, 'no_quick_create': True}"
invisible="modify_action != 'sell' and not select_invoice_line_id"
required="select_invoice_line_id"
domain="[('move_id', 'in', invoice_ids), ('display_type', '=', 'product')]"
widget="many2many_tags"/>
<field name="gain_account_id"
invisible="gain_or_loss != 'gain'"
required="gain_or_loss == 'gain'"/>
<field name="loss_account_id"
invisible="gain_or_loss != 'loss'"
required="gain_or_loss == 'loss'"/>
<field name="value_residual" invisible="modify_action not in ('modify', 'resume')"/>
<field name="salvage_value" invisible="modify_action not in ('modify', 'resume')"/>
<field name="account_asset_id" invisible="modify_action != 'modify'" required="gain_value"/>
<field name="account_asset_counterpart_id" invisible="modify_action != 'modify' or not gain_value" required="gain_value"/>
<field name="account_depreciation_id" invisible="modify_action != 'modify'" required="gain_value"/>
<field name="account_depreciation_expense_id" invisible="modify_action != 'modify'" required="gain_value"/>
<field name="name"
placeholder="Add an internal note"
required="modify_action in ('modify', 'resume')"/>
</group>
<group>
<field name="informational_text" nolabel="1"/>
</group>
</group>
<footer>
<button name="pause"
string="Pause"
type="object"
class="btn-primary"
data-hotkey="p"
invisible="modify_action != 'pause'"/>
<button name="modify"
string="Modify"
type="object"
class="btn-primary"
data-hotkey="q"
invisible="modify_action != 'modify'"/>
<button name="modify"
string="Resume"
type="object"
class="btn-primary"
data-hotkey="r"
invisible="modify_action != 'resume'"/>
<button name="sell_dispose"
string="Sell"
type="object"
class="btn-primary"
data-hotkey="s"
invisible="modify_action != 'sell'" />
<button name="sell_dispose"
string="Dispose"
type="object"
class="btn-primary"
data-hotkey="t"
invisible="modify_action != 'dispose'" />
<button string="Cancel"
class="btn-secondary"
special="cancel"
data-hotkey="x"/>
</footer>
</form>
</field>
</record>
</odoo>

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,
},
},
)

View File

@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============================================================ -->
<!-- Fusion Bank Statement Import Wizard - Form View -->
<!-- ============================================================ -->
<record id="fusion_bank_statement_import_wizard_form" model="ir.ui.view">
<field name="name">fusion.bank.statement.import.form</field>
<field name="model">fusion.bank.statement.import</field>
<field name="arch" type="xml">
<form string="Import Bank Statement">
<group>
<group>
<field name="journal_id"
options="{'no_create': True, 'no_open': True}"/>
<field name="data_file" filename="filename"
widget="binary"/>
<field name="filename" invisible="1"/>
</group>
<group>
<field name="detected_format"
readonly="1"
invisible="not detected_format"/>
</group>
</group>
<group invisible="not import_result">
<field name="import_result" widget="text" readonly="1"/>
</group>
<div class="text-muted mt-3 mb-3" style="font-size: 0.9em;">
<p><strong>Supported formats:</strong></p>
<ul>
<li><strong>OFX</strong> — Open Financial Exchange (v1 SGML and v2 XML)</li>
<li><strong>QIF</strong> — Quicken Interchange Format</li>
<li><strong>CAMT.053</strong> — ISO 20022 Bank-to-Customer Statement (XML)</li>
<li><strong>CSV</strong> — Comma-Separated Values (redirects to column mapper)</li>
</ul>
</div>
<footer>
<button name="action_import" string="Import"
type="object" class="btn-primary"/>
<button string="Cancel" class="btn-secondary"
special="cancel"/>
</footer>
</form>
</field>
</record>
<!-- ============================================================ -->
<!-- Wizard Action -->
<!-- ============================================================ -->
<record id="action_fusion_bank_statement_import" model="ir.actions.act_window">
<field name="name">Import Bank Statement</field>
<field name="res_model">fusion.bank.statement.import</field>
<field name="view_mode">form</field>
<field name="target">new</field>
<field name="view_id" ref="fusion_bank_statement_import_wizard_form"/>
</record>
</odoo>

View File

@@ -0,0 +1,74 @@
"""
Fusion Accounting - EDI Import Wizard
Provides a transient model that lets users upload a UBL 2.1 or CII XML
file and create a draft invoice from its contents.
Original implementation by Nexa Systems Inc.
"""
import base64
import logging
from odoo import api, fields, models, _
from odoo.exceptions import UserError
_log = logging.getLogger(__name__)
class FusionEDIImportWizard(models.TransientModel):
"""
File-upload wizard for importing electronic invoices in UBL 2.1 or
UN/CEFACT CII format.
"""
_name = "fusion.edi.import.wizard"
_description = "Import EDI Invoice"
xml_file = fields.Binary(
string="XML File",
required=True,
help="Select a UBL 2.1 or CII XML invoice file to import.",
)
xml_filename = fields.Char(
string="File Name",
)
move_type = fields.Selection(
selection=[
("out_invoice", "Customer Invoice"),
("out_refund", "Customer Credit Note"),
("in_invoice", "Vendor Bill"),
("in_refund", "Vendor Credit Note"),
],
string="Document Type",
default="out_invoice",
help=(
"Fallback document type if the XML does not specify one. "
"The parser may override this based on the XML content."
),
)
def action_import(self):
"""Parse the uploaded XML and create a draft invoice.
Returns:
dict: A window action opening the newly created invoice.
"""
self.ensure_one()
if not self.xml_file:
raise UserError(_("Please select an XML file to import."))
xml_bytes = base64.b64decode(self.xml_file)
AccountMove = self.env["account.move"].with_context(
default_move_type=self.move_type,
)
move = AccountMove.create_invoice_from_xml(xml_bytes)
return {
"type": "ir.actions.act_window",
"name": _("Imported Invoice"),
"res_model": "account.move",
"res_id": move.id,
"view_mode": "form",
"target": "current",
}

View File

@@ -0,0 +1,266 @@
"""
Fusion Accounting - Extraction Review Wizard
Transient model that presents the OCR-extracted invoice fields alongside
the original scan so the user can verify, correct, and then apply them
to the parent ``account.move`` record.
Corrections are tracked so they can later feed back into extraction
quality metrics or fine-tuning data sets.
Original implementation by Nexa Systems Inc.
"""
import json
import logging
from odoo import api, fields, models, _
from odoo.exceptions import UserError
_log = logging.getLogger(__name__)
class FusionExtractionReviewWizard(models.TransientModel):
"""
Review and optionally correct OCR-extracted invoice fields before
they are committed to the journal entry.
"""
_name = "fusion.extraction.review.wizard"
_description = "Review Extracted Invoice Fields"
# ------------------------------------------------------------------
# Relationship
# ------------------------------------------------------------------
move_id = fields.Many2one(
comodel_name="account.move",
string="Invoice / Bill",
required=True,
ondelete="cascade",
help="The journal entry whose extracted data is being reviewed.",
)
# ------------------------------------------------------------------
# Extracted header fields (editable)
# ------------------------------------------------------------------
vendor_name = fields.Char(
string="Vendor / Supplier Name",
help="Name of the vendor as read by the OCR engine.",
)
invoice_number = fields.Char(
string="Invoice Number",
help="The supplier's invoice reference.",
)
invoice_date = fields.Date(
string="Invoice Date",
)
due_date = fields.Date(
string="Due Date",
)
total_amount = fields.Float(
string="Total Amount",
digits=(16, 2),
)
tax_amount = fields.Float(
string="Tax Amount",
digits=(16, 2),
)
subtotal = fields.Float(
string="Subtotal",
digits=(16, 2),
)
currency_code = fields.Char(
string="Currency",
help="ISO 4217 currency code (e.g. USD, EUR, CAD).",
)
# ------------------------------------------------------------------
# Read-only context
# ------------------------------------------------------------------
raw_text = fields.Text(
string="Raw OCR Text",
readonly=True,
help="Full OCR output shown for reference while reviewing fields.",
)
confidence = fields.Float(
string="Confidence (%)",
digits=(5, 2),
readonly=True,
)
# ------------------------------------------------------------------
# Line items (JSON text field editable as raw JSON for power users)
# ------------------------------------------------------------------
line_items_json = fields.Text(
string="Line Items (JSON)",
help=(
"Extracted line items in JSON format. Each item should "
"have: description, quantity, unit_price, amount."
),
)
# ------------------------------------------------------------------
# Correction tracking
# ------------------------------------------------------------------
corrections_json = fields.Text(
string="Corrections Log (JSON)",
readonly=True,
help="Records which fields the user changed during review.",
)
# ------------------------------------------------------------------
# Computed: original attachment preview
# ------------------------------------------------------------------
attachment_preview = fields.Binary(
string="Original Scan",
compute="_compute_attachment_preview",
)
attachment_filename = fields.Char(
string="Attachment Filename",
compute="_compute_attachment_preview",
)
@api.depends("move_id")
def _compute_attachment_preview(self):
"""Fetch the first image / PDF attachment for inline preview."""
for wiz in self:
att = wiz.move_id._find_extractable_attachment() if wiz.move_id else None
if att:
wiz.attachment_preview = att.datas
wiz.attachment_filename = att.name
else:
wiz.attachment_preview = False
wiz.attachment_filename = False
# ------------------------------------------------------------------
# Actions
# ------------------------------------------------------------------
def action_apply(self):
"""Validate the (possibly corrected) fields and apply them to
the invoice.
Returns:
dict: A window action returning to the updated invoice form.
"""
self.ensure_one()
# Build the fields dict from the wizard form
fields_dict = {
"vendor_name": self.vendor_name or "",
"invoice_number": self.invoice_number or "",
"invoice_date": str(self.invoice_date) if self.invoice_date else "",
"due_date": str(self.due_date) if self.due_date else "",
"total_amount": self.total_amount,
"tax_amount": self.tax_amount,
"subtotal": self.subtotal,
"currency": self.currency_code or "",
}
# Parse line items
line_items = []
if self.line_items_json:
try:
line_items = json.loads(self.line_items_json)
if not isinstance(line_items, list):
raise ValueError("Expected a JSON array.")
except (json.JSONDecodeError, ValueError) as exc:
raise UserError(
_("The line items JSON is invalid: %s", str(exc))
) from exc
fields_dict["line_items"] = line_items
# Track corrections
self._track_corrections(fields_dict)
# Apply to the invoice
self.move_id._apply_extracted_fields(fields_dict)
# Update the stored JSON on the move for audit purposes
self.move_id.fusion_extracted_fields_json = json.dumps(
fields_dict, default=str, indent=2,
)
return {
"type": "ir.actions.act_window",
"name": _("Invoice"),
"res_model": "account.move",
"res_id": self.move_id.id,
"view_mode": "form",
"target": "current",
}
def action_discard(self):
"""Close the wizard without applying any changes.
Returns:
dict: An action that closes the wizard window.
"""
return {"type": "ir.actions.act_window_close"}
def action_re_extract(self):
"""Re-run the OCR extraction pipeline from scratch.
Useful when the user has attached a better-quality scan.
Returns:
dict: The action returned by
:meth:`~FusionInvoiceExtractor.action_extract_from_attachment`.
"""
self.ensure_one()
return self.move_id.action_extract_from_attachment()
# ------------------------------------------------------------------
# Correction tracking
# ------------------------------------------------------------------
def _track_corrections(self, final_fields):
"""Compare the wizard values against the original extraction
and record any user edits.
The result is stored in :attr:`corrections_json` on the wizard
and logged for future reference.
Args:
final_fields (dict): The fields the user is about to apply.
"""
self.ensure_one()
original = {}
if self.move_id.fusion_extracted_fields_json:
try:
original = json.loads(self.move_id.fusion_extracted_fields_json)
except (json.JSONDecodeError, TypeError):
original = {}
corrections = {}
compare_keys = [
"vendor_name", "invoice_number", "invoice_date",
"due_date", "total_amount", "tax_amount", "subtotal", "currency",
]
for key in compare_keys:
orig_val = str(original.get(key, "") or "")
new_val = str(final_fields.get(key, "") or "")
if orig_val != new_val:
corrections[key] = {
"original": orig_val,
"corrected": new_val,
}
if corrections:
self.corrections_json = json.dumps(corrections, default=str, indent=2)
_log.info(
"Fusion OCR: user corrected %d field(s) on move %s: %s",
len(corrections), self.move_id.id, list(corrections.keys()),
)
# Store corrections on the move as a note for audit trail
body_lines = [_("<b>OCR Extraction Manual Corrections</b><ul>")]
for field_name, change in corrections.items():
body_lines.append(
_("<li><b>%s</b>: %s%s</li>",
field_name, change["original"], change["corrected"])
)
body_lines.append("</ul>")
self.move_id.message_post(
body="".join(str(l) for l in body_lines),
message_type="comment",
subtype_xmlid="mail.mt_note",
)

View File

@@ -0,0 +1,121 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- =============================================================
Fusion Extraction Review Wizard Form View
============================================================= -->
<record id="fusion_extraction_review_wizard_form" model="ir.ui.view">
<field name="name">fusion.extraction.review.wizard.form</field>
<field name="model">fusion.extraction.review.wizard</field>
<field name="arch" type="xml">
<form string="Review Extracted Invoice Data">
<header>
<button name="action_apply"
string="Apply to Invoice"
type="object"
class="btn-primary"
icon="fa-check"/>
<button name="action_re_extract"
string="Re-Extract"
type="object"
class="btn-warning"
icon="fa-refresh"
confirm="This will overwrite the current extraction. Continue?"/>
<button name="action_discard"
string="Discard"
type="object"
class="btn-secondary"
special="cancel"/>
</header>
<sheet>
<!-- Confidence banner -->
<div class="alert alert-success text-center"
role="alert"
invisible="confidence &lt; 70">
<strong>High confidence extraction</strong>
<field name="confidence" widget="float" readonly="1" class="d-inline"/> %
of key fields were detected.
</div>
<div class="alert alert-warning text-center"
role="alert"
invisible="confidence &gt;= 70 or confidence &lt; 40">
<strong>Medium confidence extraction</strong>
<field name="confidence" widget="float" readonly="1" class="d-inline"/> %
of key fields were detected. Please review carefully.
</div>
<div class="alert alert-danger text-center"
role="alert"
invisible="confidence &gt;= 40">
<strong>Low confidence extraction</strong>
<field name="confidence" widget="float" readonly="1" class="d-inline"/> %
of key fields were detected. Manual entry may be required.
</div>
<group>
<group string="Invoice Header">
<field name="vendor_name"
placeholder="e.g. Acme Corp"/>
<field name="invoice_number"
placeholder="e.g. INV-2026-001"/>
<field name="invoice_date"/>
<field name="due_date"/>
<field name="currency_code"
placeholder="e.g. USD"/>
</group>
<group string="Amounts">
<field name="subtotal"/>
<field name="tax_amount"/>
<field name="total_amount"/>
</group>
</group>
<notebook>
<page string="Line Items" name="line_items">
<field name="line_items_json"
widget="ace"
options="{'mode': 'json'}"
placeholder='[{"description": "...", "quantity": 1, "unit_price": 0.0, "amount": 0.0}]'/>
<div class="text-muted small mt-2">
Edit the JSON array above to correct line items.
Each object should contain: description, quantity,
unit_price, amount.
</div>
</page>
<page string="Raw OCR Text" name="raw_text">
<field name="raw_text"
readonly="1"
nolabel="1"/>
</page>
<page string="Original Scan" name="scan_preview">
<group>
<field name="attachment_filename" invisible="1"/>
<field name="attachment_preview"
widget="image"
readonly="1"
options="{'size': [800, 1100]}"
invisible="not attachment_preview"/>
<div invisible="attachment_preview"
class="text-muted">
No image preview available. The attachment may
be a PDF (open it separately to view).
</div>
</group>
</page>
</notebook>
<field name="move_id" invisible="1"/>
<field name="corrections_json" invisible="1"/>
</sheet>
</form>
</field>
</record>
<!-- =============================================================
Wizard Action (used internally from Python)
============================================================= -->
<record id="fusion_extraction_review_wizard_action" model="ir.actions.act_window">
<field name="name">Review Extracted Data</field>
<field name="res_model">fusion.extraction.review.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>

View File

@@ -0,0 +1,32 @@
# Fusion Accounting - Fiscal Year Opening Wizard
# Extends the base fiscal year opening wizard with
# tax periodicity configuration fields.
from odoo import api, fields, models, _
class FinancialYearOpeningWizard(models.TransientModel):
"""Extension of the fiscal year opening balance wizard that
exposes tax periodicity settings for the current company."""
_inherit = 'account.financial.year.op'
_description = 'Opening Balance of Financial Year'
# --- Tax Periodicity Configuration ---
account_tax_periodicity = fields.Selection(
related='company_id.account_tax_periodicity',
string='Periodicity in month',
readonly=False,
required=True,
)
account_tax_periodicity_reminder_day = fields.Integer(
related='company_id.account_tax_periodicity_reminder_day',
string='Reminder',
readonly=False,
required=True,
)
account_tax_periodicity_journal_id = fields.Many2one(
related='company_id.account_tax_periodicity_journal_id',
string='Journal',
readonly=False,
)

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="setup_financial_year_opening_form" model="ir.ui.view">
<field name="name">account.financial.year.op.setup.wizard.form</field>
<field name="model">account.financial.year.op</field>
<field name="inherit_id" ref="account.setup_financial_year_opening_form"/>
<field name="arch" type="xml">
<xpath expr="//sheet/group" position="inside">
<field name="account_tax_periodicity" string="VAT Periodicity"/>
<label for="account_tax_periodicity_reminder_day" groups="base.group_no_one"/>
<div groups="base.group_no_one">
<field name="account_tax_periodicity_reminder_day" class="text-center" string="Reminder" no_label="1" style="width: 40px !important;"/> days after period
</div>
<field name="account_tax_periodicity_journal_id" string="Journal" groups="base.group_no_one"/>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,248 @@
# Part of Fusion Accounting. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.exceptions import UserError
class FusionFollowupSendWizard(models.TransientModel):
"""Wizard for previewing and manually sending payment follow-ups.
Allows the user to review the email content, modify the message
body, choose communication channels, and send the follow-up for
one or more partners at once.
"""
_name = 'fusion.followup.send.wizard'
_description = "Fusion Follow-up Send Wizard"
# ---- Context Fields ----
followup_line_id = fields.Many2one(
comodel_name='fusion.followup.line',
string="Follow-up Record",
readonly=True,
)
partner_id = fields.Many2one(
comodel_name='res.partner',
string="Partner",
related='followup_line_id.partner_id',
readonly=True,
)
followup_level_id = fields.Many2one(
comodel_name='fusion.followup.level',
string="Follow-up Level",
related='followup_line_id.followup_level_id',
readonly=True,
)
# ---- Editable Content ----
email_subject = fields.Char(
string="Subject",
compute='_compute_email_preview',
store=True,
readonly=False,
)
email_body = fields.Html(
string="Email Body",
compute='_compute_email_preview',
store=True,
readonly=False,
help="Edit the email body before sending. Changes apply only to this send.",
)
# ---- Channel Overrides ----
do_send_email = fields.Boolean(
string="Send Email",
compute='_compute_channel_defaults',
store=True,
readonly=False,
)
do_send_sms = fields.Boolean(
string="Send SMS",
compute='_compute_channel_defaults',
store=True,
readonly=False,
)
do_print_letter = fields.Boolean(
string="Print Letter",
compute='_compute_channel_defaults',
store=True,
readonly=False,
)
do_join_invoices = fields.Boolean(
string="Attach Invoices",
compute='_compute_channel_defaults',
store=True,
readonly=False,
)
# ---- Informational ----
overdue_amount = fields.Monetary(
string="Overdue Amount",
related='followup_line_id.overdue_amount',
readonly=True,
currency_field='currency_id',
)
overdue_count = fields.Integer(
string="Overdue Invoices",
related='followup_line_id.overdue_count',
readonly=True,
)
currency_id = fields.Many2one(
comodel_name='res.currency',
related='followup_line_id.currency_id',
readonly=True,
)
# --------------------------------------------------
# Default Values
# --------------------------------------------------
@api.model
def default_get(self, fields_list):
"""Populate the wizard from the active follow-up line."""
defaults = super().default_get(fields_list)
active_id = self.env.context.get('active_id')
if active_id and self.env.context.get('active_model') == 'fusion.followup.line':
defaults['followup_line_id'] = active_id
return defaults
# --------------------------------------------------
# Computed Fields
# --------------------------------------------------
@api.depends('followup_level_id', 'partner_id')
def _compute_email_preview(self):
"""Build a preview of the email subject and body.
Uses the level's email template if set, otherwise falls back to
the level description or a sensible default.
"""
for wizard in self:
level = wizard.followup_level_id
partner = wizard.partner_id
company = wizard.followup_line_id.company_id or self.env.company
if level and level.email_template_id:
template = level.email_template_id
# Render the template for the partner
rendered = template._render_field(
'body_html', [partner.id],
engine='inline_template',
compute_lang=True,
)
wizard.email_body = rendered.get(partner.id, '')
rendered_subject = template._render_field(
'subject', [partner.id],
engine='inline_template',
compute_lang=True,
)
wizard.email_subject = rendered_subject.get(partner.id, '')
else:
wizard.email_subject = _(
"%(company)s - Payment Reminder",
company=company.name,
)
if level and level.description:
wizard.email_body = level.description
else:
wizard.email_body = _(
"<p>Dear %(partner)s,</p>"
"<p>This is a reminder that your account has an outstanding "
"balance. Please arrange payment at your earliest convenience.</p>"
"<p>Best regards,<br/>%(company)s</p>",
partner=partner.name or _('Customer'),
company=company.name,
)
@api.depends('followup_level_id')
def _compute_channel_defaults(self):
"""Pre-fill channel toggles from the follow-up level configuration."""
for wizard in self:
level = wizard.followup_level_id
if level:
wizard.do_send_email = level.send_email
wizard.do_send_sms = level.send_sms
wizard.do_print_letter = level.send_letter
wizard.do_join_invoices = level.join_invoices
else:
wizard.do_send_email = True
wizard.do_send_sms = False
wizard.do_print_letter = False
wizard.do_join_invoices = False
# --------------------------------------------------
# Actions
# --------------------------------------------------
def action_send_followup(self):
"""Send the follow-up using the wizard-configured channels and content.
Sends the (possibly edited) email, optionally triggers SMS,
then advances the partner to the next follow-up level.
:returns: ``True`` to close the wizard.
:raises UserError: If no follow-up line is linked.
"""
self.ensure_one()
line = self.followup_line_id
if not line:
raise UserError(_("No follow-up record is linked to this wizard."))
partner = line.partner_id
# ---- Email ----
if self.do_send_email:
attachment_ids = []
if self.do_join_invoices:
attachment_ids = line._get_invoice_attachments(partner)
mail_values = {
'subject': self.email_subject,
'body_html': self.email_body,
'email_from': (
line.company_id.email
or self.env.user.email_formatted
),
'email_to': partner.email,
'author_id': self.env.user.partner_id.id,
'res_id': partner.id,
'model': 'res.partner',
'attachment_ids': [(6, 0, attachment_ids)],
}
mail = self.env['mail.mail'].sudo().create(mail_values)
mail.send()
# ---- SMS ----
if self.do_send_sms and line.followup_level_id.sms_template_id:
try:
line.followup_level_id.sms_template_id._send_sms(partner.id)
except Exception:
pass
# ---- Advance Level ----
level = line.followup_level_id
if level:
next_level = level._get_next_level()
line.write({
'date': fields.Date.context_today(self),
'followup_level_id': next_level.id if next_level else level.id,
})
else:
line.write({'date': fields.Date.context_today(self)})
return {'type': 'ir.actions.act_window_close'}
def action_preview_email(self):
"""Recompute the email preview after manual edits to the level.
:returns: Action to reload the wizard form.
"""
self.ensure_one()
self._compute_email_preview()
return {
'type': 'ir.actions.act_window',
'res_model': self._name,
'res_id': self.id,
'view_mode': 'form',
'target': 'new',
}

View File

@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Form View: Follow-up Send Wizard -->
<record id="fusion_followup_send_wizard_view_form" model="ir.ui.view">
<field name="name">fusion.followup.send.wizard.form</field>
<field name="model">fusion.followup.send.wizard</field>
<field name="arch" type="xml">
<form string="Send Payment Follow-up">
<group>
<group string="Partner Information">
<field name="partner_id"/>
<field name="followup_level_id"/>
<field name="overdue_amount" widget="monetary"/>
<field name="overdue_count"/>
<field name="currency_id" invisible="1"/>
<field name="followup_line_id" invisible="1"/>
</group>
<group string="Communication Channels">
<field name="do_send_email"/>
<field name="do_send_sms"/>
<field name="do_print_letter"/>
<field name="do_join_invoices" invisible="not do_send_email"/>
</group>
</group>
<separator string="Email Preview"/>
<group invisible="not do_send_email">
<field name="email_subject"/>
</group>
<field name="email_body" invisible="not do_send_email"/>
<footer>
<button name="action_send_followup" type="object"
string="Send" class="btn-primary"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<!-- Action: Open Wizard -->
<record id="action_fusion_followup_send_wizard" model="ir.actions.act_window">
<field name="name">Preview &amp; Send Follow-up</field>
<field name="res_model">fusion.followup.send.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
<field name="binding_model_id" ref="fusion_accounting.model_fusion_followup_line"/>
</record>
</odoo>

View File

@@ -0,0 +1,180 @@
"""
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',
}

View File

@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============================================================ -->
<!-- LOAN IMPORT WIZARD - Form View -->
<!-- ============================================================ -->
<record id="fusion_loan_import_wizard_view_form" model="ir.ui.view">
<field name="name">fusion.loan.import.wizard.form</field>
<field name="model">fusion.loan.import.wizard</field>
<field name="arch" type="xml">
<form string="Import Loans from CSV">
<group>
<group string="File">
<field name="csv_file" filename="csv_filename"/>
<field name="csv_filename" invisible="1"/>
</group>
<group string="Options">
<field name="auto_compute"/>
</group>
</group>
<separator string="Default Values"/>
<group>
<group>
<field name="partner_id"/>
<field name="journal_id"/>
<field name="loan_account_id"/>
<field name="interest_account_id"/>
</group>
<group>
<field name="payment_frequency"/>
<field name="amortization_method"/>
</group>
</group>
<separator/>
<div class="alert alert-info" role="alert">
<strong>Expected CSV format:</strong>
<code>name, principal, rate, term, start_date</code>
<br/>
Example row:
<code>Office Loan, 50000, 5.5, 60, 2026-01-15</code>
</div>
<footer>
<button name="action_import"
string="Import"
type="object"
class="btn-primary"/>
<button string="Cancel" special="cancel"/>
</footer>
</form>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="mail_activity_schedule_view_form" model="ir.ui.view">
<field name="name">mail.activity.schedule.inherit.account.tax</field>
<field name="model">mail.activity.schedule</field>
<field name="inherit_id" ref="mail.mail_activity_schedule_view_form"/>
<field name="arch" type="xml">
<xpath expr="//button[@name='action_schedule_activities_done']" position="attributes">
<attribute name="invisible">has_error or activity_category == 'tax_report' or chaining_type == 'trigger'</attribute>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,316 @@
# Fusion Accounting - Multicurrency Revaluation Wizard
# Creates journal entries to adjust for exchange rate differences
# on foreign-currency denominated balances, with automatic reversal.
import json
from dateutil.relativedelta import relativedelta
from odoo import api, fields, models, _, Command
from odoo.exceptions import UserError
from odoo.tools import format_date
class MulticurrencyRevaluationWizard(models.TransientModel):
"""Generates revaluation journal entries that capture unrealized
gains or losses from exchange rate fluctuations, then creates
an auto-reversal entry for the following period."""
_name = 'account.multicurrency.revaluation.wizard'
_description = 'Multicurrency Revaluation Wizard'
# --- Organization ---
company_id = fields.Many2one(
comodel_name='res.company',
default=lambda self: self.env.company,
)
# --- Accounting Configuration ---
journal_id = fields.Many2one(
comodel_name='account.journal',
compute='_compute_accounting_values',
inverse='_inverse_revaluation_journal',
compute_sudo=True,
domain=[('type', '=', 'general')],
required=True,
readonly=False,
)
date = fields.Date(
default=lambda self: self.env.context[
'multicurrency_revaluation_report_options'
]['date']['date_to'],
required=True,
)
reversal_date = fields.Date(required=True)
expense_provision_account_id = fields.Many2one(
comodel_name='account.account',
compute='_compute_accounting_values',
inverse='_inverse_expense_provision_account',
compute_sudo=True,
string="Expense Account",
required=True,
readonly=False,
)
income_provision_account_id = fields.Many2one(
comodel_name='account.account',
compute='_compute_accounting_values',
inverse='_inverse_income_provision_account',
compute_sudo=True,
string="Income Account",
required=True,
readonly=False,
)
# --- Preview & Warnings ---
preview_data = fields.Text(
compute='_compute_preview_data',
)
show_warning_move_id = fields.Many2one(
comodel_name='account.move',
compute='_compute_show_warning',
)
# -------------------------------------------------------------------------
# Defaults
# -------------------------------------------------------------------------
@api.model
def default_get(self, default_fields):
"""Initialize reversal date and validate that adjustments are needed."""
result = super().default_get(default_fields)
if 'reversal_date' in default_fields:
report_opts = self.env.context['multicurrency_revaluation_report_options']
period_end = fields.Date.to_date(report_opts['date']['date_to'])
result['reversal_date'] = period_end + relativedelta(days=1)
# Verify there is actually something to adjust
if (
not self.env.context.get('revaluation_no_loop')
and not self.with_context(
revaluation_no_loop=True,
)._get_move_vals()['line_ids']
):
raise UserError(_("No currency adjustment is needed."))
return result
# -------------------------------------------------------------------------
# Compute Methods
# -------------------------------------------------------------------------
@api.depends('expense_provision_account_id', 'income_provision_account_id',
'reversal_date')
def _compute_show_warning(self):
"""Warn if there's an unreversed entry on the provision accounts."""
AML = self.env['account.move.line']
for wiz in self:
provision_accts = (
wiz.expense_provision_account_id
+ wiz.income_provision_account_id
)
recent_entry = AML.search([
('account_id', 'in', provision_accts.ids),
('date', '<', wiz.reversal_date),
], order='date desc', limit=1).move_id
wiz.show_warning_move_id = (
False if recent_entry.reversed_entry_id else recent_entry
)
@api.depends('expense_provision_account_id', 'income_provision_account_id',
'date', 'journal_id')
def _compute_preview_data(self):
"""Build JSON data for the move preview widget."""
col_definitions = [
{'field': 'account_id', 'label': _("Account")},
{'field': 'name', 'label': _("Label")},
{'field': 'debit', 'label': _("Debit"), 'class': 'text-end text-nowrap'},
{'field': 'credit', 'label': _("Credit"), 'class': 'text-end text-nowrap'},
]
for wiz in self:
move_vals = self._get_move_vals()
preview_groups = [
self.env['account.move']._move_dict_to_preview_vals(
move_vals, wiz.company_id.currency_id,
),
]
wiz.preview_data = json.dumps({
'groups_vals': preview_groups,
'options': {'columns': col_definitions},
})
@api.depends('company_id')
def _compute_accounting_values(self):
"""Load revaluation settings from the company."""
for wiz in self:
wiz.journal_id = wiz.company_id.account_revaluation_journal_id
wiz.expense_provision_account_id = (
wiz.company_id.account_revaluation_expense_provision_account_id
)
wiz.income_provision_account_id = (
wiz.company_id.account_revaluation_income_provision_account_id
)
# -------------------------------------------------------------------------
# Inverse Methods (persist settings back to company)
# -------------------------------------------------------------------------
def _inverse_revaluation_journal(self):
for wiz in self:
wiz.company_id.sudo().account_revaluation_journal_id = wiz.journal_id
def _inverse_expense_provision_account(self):
for wiz in self:
wiz.company_id.sudo().account_revaluation_expense_provision_account_id = (
wiz.expense_provision_account_id
)
def _inverse_income_provision_account(self):
for wiz in self:
wiz.company_id.sudo().account_revaluation_income_provision_account_id = (
wiz.income_provision_account_id
)
# -------------------------------------------------------------------------
# Move Value Construction
# -------------------------------------------------------------------------
@api.model
def _get_move_vals(self):
"""Build the journal entry values from the multicurrency
revaluation report data, creating a pair of lines per
account/currency that needs adjustment."""
def _extract_model_id(parsed_segments, target_model):
"""Extract the record ID for a given model from parsed line segments."""
for _dummy, seg_model, seg_id in parsed_segments:
if seg_model == target_model:
return seg_id
return None
def _extract_adjustment(report_line):
"""Pull the adjustment amount from the report line columns."""
for col in report_line.get('columns', []):
if col.get('expression_label') == 'adjustment':
return col.get('no_format')
return None
report = self.env.ref('fusion_accounting.multicurrency_revaluation_report')
included_section = report.line_ids.filtered(
lambda ln: ln.code == 'multicurrency_included',
)
included_line_id = report._get_generic_line_id(
'account.report.line', included_section.id,
)
report_options = {
**self.env.context['multicurrency_revaluation_report_options'],
'unfold_all': False,
}
all_report_lines = report._get_lines(report_options)
entry_lines = []
for rpt_line in report._get_unfolded_lines(
all_report_lines, included_line_id,
):
parsed = report._parse_line_id(rpt_line.get('id'))
adj_balance = _extract_adjustment(rpt_line)
# Only process account-level lines with non-zero adjustments
if parsed[-1][-2] != 'account.account':
continue
if self.env.company.currency_id.is_zero(adj_balance):
continue
target_account = _extract_model_id(parsed, 'account.account')
target_currency = _extract_model_id(parsed, 'res.currency')
currency_name = self.env['res.currency'].browse(
target_currency,
).display_name
company_cur_name = self.env.company.currency_id.display_name
current_rate = report_options['currency_rates'][
str(target_currency)
]['rate']
# Account adjustment line
entry_lines.append(Command.create({
'name': _(
"Provision for %(for_cur)s "
"(1 %(comp_cur)s = %(rate)s %(for_cur)s)",
for_cur=currency_name,
comp_cur=company_cur_name,
rate=current_rate,
),
'debit': adj_balance if adj_balance > 0 else 0,
'credit': -adj_balance if adj_balance < 0 else 0,
'amount_currency': 0,
'currency_id': target_currency,
'account_id': target_account,
}))
# Provision counterpart line
if adj_balance < 0:
provision_label = _(
"Expense Provision for %s", currency_name,
)
provision_account = self.expense_provision_account_id.id
else:
provision_label = _(
"Income Provision for %s", currency_name,
)
provision_account = self.income_provision_account_id.id
entry_lines.append(Command.create({
'name': provision_label,
'debit': -adj_balance if adj_balance < 0 else 0,
'credit': adj_balance if adj_balance > 0 else 0,
'amount_currency': 0,
'currency_id': target_currency,
'account_id': provision_account,
}))
return {
'ref': _(
"Foreign currency adjustment as of %s",
format_date(self.env, self.date),
),
'journal_id': self.journal_id.id,
'date': self.date,
'line_ids': entry_lines,
}
# -------------------------------------------------------------------------
# Main Action
# -------------------------------------------------------------------------
def create_entries(self):
"""Create the revaluation entry and its automatic reversal."""
self.ensure_one()
move_data = self._get_move_vals()
if not move_data['line_ids']:
raise UserError(_("No provision adjustment is required."))
# Create and post the revaluation entry
reval_move = self.env['account.move'].create(move_data)
reval_move.action_post()
# Create and post the reversal
reversal = reval_move._reverse_moves(default_values_list=[{
'ref': _("Reversal of: %s", reval_move.ref),
}])
reversal.date = self.reversal_date
reversal.action_post()
# Open the revaluation entry in form view
form_view = self.env.ref('account.view_move_form', False)
clean_ctx = {
k: v for k, v in self.env.context.items()
if k != 'id'
}
return {
'type': 'ir.actions.act_window',
'res_model': 'account.move',
'res_id': reval_move.id,
'view_mode': 'form',
'view_id': form_view.id,
'views': [(form_view.id, 'form')],
'context': clean_ctx,
}

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="view_account_multicurrency_revaluation_wizard" model="ir.ui.view">
<field name="name">account.multicurrency.revaluation.wizard.view</field>
<field name="model">account.multicurrency.revaluation.wizard</field>
<field name="arch" type="xml">
<form string="Make Adjustment Entry">
<field name="company_id" invisible="1"/>
<div invisible="not show_warning_move_id" class="alert alert-warning" role="alert">Proceed with caution as there might be an existing adjustment for this period (<field name="show_warning_move_id"/>)</div>
<group>
<group>
<field name="journal_id"/>
<field name="expense_provision_account_id"/>
<field name="income_provision_account_id"/>
</group>
<group>
<field name="date"/>
<field name="reversal_date"/>
</group>
</group>
<field name="preview_data" widget="grouped_view_widget"/>
<footer>
<button string='Create Entry' name="create_entries" type="object" class="btn-primary" data-hotkey="q"/>
<button string="Cancel" class="btn-secondary" special="cancel" data-hotkey="x"/>
</footer>
</form>
</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_account_reconcile_model_widget_wizard" model="ir.ui.view">
<field name="name">account.reconcile.model.form</field>
<field name="model">account.reconcile.model</field>
<field name="arch" type="xml">
<form>
<div class="oe_title">
<label for="name"/>
<h1><field name="name" placeholder="e.g. Bank Fees"/></h1>
</div>
<group string="Counterpart Values">
<field name="line_ids" nolabel="1">
<list editable="bottom">
<field name="account_id"/>
<field name="amount_type"/>
<field name="amount_string"/>
<field name="tax_ids" widget="many2many_tags"/>
<field name="analytic_distribution" widget="analytic_distribution"
groups="analytic.group_analytic_accounting"
options="{'account_field': 'account_id', 'business_domain': 'general'}"/>
<!-- show_force_tax_included removed in V19 -->
<field name="company_id" column_invisible="True"/>
<field name="label"/>
</list>
</field>
</group>
<footer>
<button string="Validate" class="btn-primary" special="save" data-hotkey="q"/>
<button string="Cancel" special="cancel" data-hotkey="x"/>
</footer>
</form>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,203 @@
# Fusion Accounting - Report Export Wizard
# Provides multi-format export capabilities for accounting reports
import base64
import json
import types
from urllib.parse import urlparse, parse_qs
from odoo import _, api, fields, models
from odoo.exceptions import UserError
from odoo.models import check_method_name
class ReportExportWizard(models.TransientModel):
"""Transient wizard that enables batch export of accounting reports
into various file formats, stored as downloadable attachments."""
_name = 'account_reports.export.wizard'
_description = "Fusion Accounting Report Export Wizard"
export_format_ids = fields.Many2many(
string="Target Formats",
comodel_name='account_reports.export.wizard.format',
relation="dms_acc_rep_export_wizard_format_rel",
)
report_id = fields.Many2one(
string="Source Report",
comodel_name='account.report',
required=True,
)
doc_name = fields.Char(
string="Output Filename",
help="Base name applied to all generated output files.",
)
@api.model_create_multi
def create(self, vals_list):
"""Override creation to auto-populate available export formats
from the report's configured action buttons."""
new_wizards = super().create(vals_list)
for wiz in new_wizards:
wiz.doc_name = wiz.report_id.name
# Build format entries from the report's generation options
generation_opts = self.env.context.get('account_report_generation_options', {})
available_buttons = generation_opts.get('buttons', [])
for btn_config in available_buttons:
export_type = btn_config.get('file_export_type')
if export_type:
self.env['account_reports.export.wizard.format'].create({
'name': export_type,
'fun_to_call': btn_config['action'],
'fun_param': btn_config.get('action_param'),
'export_wizard_id': wiz.id,
})
return new_wizards
def export_report(self):
"""Execute the export process: generate files in each selected format
and return a window action displaying the resulting attachments."""
self.ensure_one()
saved_attachments = self.env['ir.attachment']
for attachment_data in self._build_attachment_values():
saved_attachments |= self.env['ir.attachment'].create(attachment_data)
return {
'type': 'ir.actions.act_window',
'name': _('Generated Documents'),
'view_mode': 'kanban,form',
'res_model': 'ir.attachment',
'domain': [('id', 'in', saved_attachments.ids)],
}
def _build_attachment_values(self):
"""Iterate over selected formats, invoke the corresponding report
generator, and collect attachment value dictionaries."""
self.ensure_one()
attachment_vals_list = []
current_options = self.env.context['account_report_generation_options']
for fmt in self.export_format_ids:
# Validate the callable name is a safe public method
method_name = fmt.fun_to_call
check_method_name(method_name)
# Resolve whether the custom handler or base report owns the method
target_report = self.report_id
if target_report.custom_handler_model_id:
handler_obj = self.env[target_report.custom_handler_model_name]
if hasattr(handler_obj, method_name):
callable_fn = getattr(handler_obj, method_name)
else:
callable_fn = getattr(target_report, method_name)
else:
callable_fn = getattr(target_report, method_name)
extra_args = [fmt.fun_param] if fmt.fun_param else []
action_result = callable_fn(current_options, *extra_args)
attachment_vals_list.append(fmt.apply_export(action_result))
return attachment_vals_list
class ReportExportFormatOption(models.TransientModel):
"""Represents a single selectable export format within the export wizard,
linking a display label to the callable that produces the file."""
_name = 'account_reports.export.wizard.format'
_description = "Fusion Accounting Report Export Format"
name = fields.Char(string="Format Label", required=True)
fun_to_call = fields.Char(string="Generator Method", required=True)
fun_param = fields.Char(string="Method Argument")
export_wizard_id = fields.Many2one(
string="Owning Wizard",
comodel_name='account_reports.export.wizard',
required=True,
ondelete='cascade',
)
def apply_export(self, action_payload):
"""Convert a report action response into attachment-ready values.
Handles two action types:
- ir_actions_account_report_download: direct file generation
- ir.actions.act_url: fetch file from a wizard model via URL params
"""
self.ensure_one()
if action_payload['type'] == 'ir_actions_account_report_download':
opts_dict = json.loads(action_payload['data']['options'])
# Resolve and invoke the file generator function
generator_name = action_payload['data']['file_generator']
check_method_name(generator_name)
source_report = self.export_wizard_id.report_id
if source_report.custom_handler_model_id:
handler_env = self.env[source_report.custom_handler_model_name]
if hasattr(handler_env, generator_name):
gen_callable = getattr(handler_env, generator_name)
else:
gen_callable = getattr(source_report, generator_name)
else:
gen_callable = getattr(source_report, generator_name)
output = gen_callable(opts_dict)
# Encode raw bytes content to base64; handle generator objects
raw_content = output['file_content']
if isinstance(raw_content, bytes):
encoded_data = base64.encodebytes(raw_content)
elif isinstance(raw_content, types.GeneratorType):
encoded_data = base64.encodebytes(b''.join(raw_content))
else:
encoded_data = raw_content
output_name = (
f"{self.export_wizard_id.doc_name or self.export_wizard_id.report_id.name}"
f".{output['file_type']}"
)
content_mime = self.export_wizard_id.report_id.get_export_mime_type(
output['file_type']
)
elif action_payload['type'] == 'ir.actions.act_url':
# Extract file data from a URL-based wizard action
url_parts = urlparse(action_payload['url'])
params = parse_qs(url_parts.query)
target_model = params['model'][0]
record_id = int(params['id'][0])
source_wizard = self.env[target_model].browse(record_id)
output_name = source_wizard[params['filename_field'][0]]
encoded_data = source_wizard[params['field'][0]]
extension = output_name.split('.')[-1]
content_mime = self.env['account.report'].get_export_mime_type(extension)
opts_dict = {}
else:
raise UserError(
_("The selected format cannot be exported as a document attachment.")
)
return self._prepare_attachment_dict(output_name, encoded_data, content_mime, opts_dict)
def _prepare_attachment_dict(self, filename, binary_data, mime_type, options_log):
"""Build the dictionary of values for creating an ir.attachment record."""
self.ensure_one()
return {
'name': filename,
'company_id': self.env.company.id,
'datas': binary_data,
'mimetype': mime_type,
'description': json.dumps(options_log),
}

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="view_report_export_wizard" model="ir.ui.view">
<field name="name">account_reports.export.wizard.form</field>
<field name="model">account_reports.export.wizard</field>
<field name="arch" type="xml">
<form string="Export">
<group>
<field name="export_format_ids" widget="many2many_tags" options="{'no_create': True}" domain="[('export_wizard_id', '=', id)]" required="1"/>
</group>
<group>
<group>
<field name="doc_name" required="1"/>
</group>
</group>
<footer>
<button string="Export" class="btn-primary" type="object" name="export_report" data-hotkey="q"/>
<button string="Cancel" class="btn-secondary" special="cancel" data-hotkey="x"/>
</footer>
</form>
</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,28 @@
# Fusion Accounting - Bank Setup Wizard Extension
# Automatically configures file import as the bank statement source
from odoo import models
class SetupBarBankConfigWizard(models.TransientModel):
"""Extends the manual bank configuration wizard to default newly
created bank journals to use file-based statement imports when
supported import formats are available."""
_inherit = 'account.setup.bank.manual.config'
def validate(self):
"""After standard bank validation, check whether the journal
should use file import as its statement source."""
result = super().validate()
is_fresh_journal = self.num_journals_without_account == 0
has_undefined_source = self.linked_journal_id.bank_statements_source == 'undefined'
available_formats = (
self.env['account.journal']._get_bank_statements_available_import_formats()
)
if (is_fresh_journal or has_undefined_source) and available_formats:
self.linked_journal_id.bank_statements_source = 'file_import'
return result