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