feat(fusion_accounting_bank_rec): add empty sub-module skeleton
Scaffold the fusion_accounting_bank_rec sub-module with directory tree, manifest, empty package __init__ files, empty ACL CSV, icon, and Enterprise reference snapshots. No models, controllers, or business logic yet — installs cleanly on V19 westin-v19 dev DB. Made-with: Cursor
This commit is contained in:
4
fusion_accounting_bank_rec/__init__.py
Normal file
4
fusion_accounting_bank_rec/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from . import models
|
||||
from . import controllers
|
||||
from . import services
|
||||
from . import wizards
|
||||
37
fusion_accounting_bank_rec/__manifest__.py
Normal file
37
fusion_accounting_bank_rec/__manifest__.py
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
'name': 'Fusion Accounting — Bank Reconciliation',
|
||||
'version': '19.0.1.0.0',
|
||||
'category': 'Accounting/Accounting',
|
||||
'sequence': 28,
|
||||
'summary': 'Native V19 bank reconciliation widget with AI confidence scoring + behavioural learning.',
|
||||
'description': """
|
||||
Fusion Accounting — Bank Reconciliation
|
||||
========================================
|
||||
Replaces Odoo Enterprise's account_accountant bank-rec widget with a
|
||||
native V19 OWL implementation reading/writing Community's
|
||||
account.partial.reconcile tables.
|
||||
|
||||
Features:
|
||||
- Strict mirror of all Enterprise UI components (zero functional loss)
|
||||
- AI confidence badges with one-click Accept and ranked alternatives
|
||||
- Behavioural learning from historical reconciliations
|
||||
- Local LLM ready (Ollama, LM Studio) via OpenAI-compatible adapter
|
||||
- Coexists with account_accountant (Enterprise wins by default)
|
||||
|
||||
Built by Nexa Systems Inc.
|
||||
""",
|
||||
'icon': '/fusion_accounting_bank_rec/static/description/icon.png',
|
||||
'author': 'Nexa Systems Inc.',
|
||||
'website': 'https://nexasystems.ca',
|
||||
'maintainer': 'Nexa Systems Inc.',
|
||||
'depends': ['fusion_accounting_core'],
|
||||
'external_dependencies': {
|
||||
'python': ['hypothesis'],
|
||||
},
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'license': 'OPL-1',
|
||||
}
|
||||
0
fusion_accounting_bank_rec/controllers/__init__.py
Normal file
0
fusion_accounting_bank_rec/controllers/__init__.py
Normal file
@@ -0,0 +1,176 @@
|
||||
from datetime import date
|
||||
|
||||
from odoo import api, Command, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class AccountAutoReconcileWizard(models.TransientModel):
|
||||
""" This wizard is used to automatically reconcile account.move.line.
|
||||
It is accessible trough Accounting > Accounting tab > Actions > Auto-reconcile menuitem.
|
||||
"""
|
||||
_name = 'account.auto.reconcile.wizard'
|
||||
_description = 'Account automatic reconciliation wizard'
|
||||
_check_company_auto = True
|
||||
|
||||
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') # Amls from which we derive a preset for the wizard
|
||||
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="Reconcile journal items with opposite balance or clear accounts with a zero balance",
|
||||
)
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields):
|
||||
res = super().default_get(fields)
|
||||
domain = self.env.context.get('domain')
|
||||
if 'line_ids' in fields and 'line_ids' not in res and domain:
|
||||
amls = self.env['account.move.line'].search(domain)
|
||||
if amls:
|
||||
# pre-configure the wizard
|
||||
res.update(self._get_default_wizard_values(amls))
|
||||
res['line_ids'] = [Command.set(amls.ids)]
|
||||
return res
|
||||
|
||||
@api.model
|
||||
def _get_default_wizard_values(self, amls):
|
||||
""" Derive a preset configuration based on amls.
|
||||
For example if all amls have the same account_id we will set it in the wizard.
|
||||
:param amls: account move lines from which we will derive a preset
|
||||
:return: a dict with preset values
|
||||
"""
|
||||
return {
|
||||
'account_ids': [Command.set(amls[0].account_id.ids)] if all(aml.account_id == amls[0].account_id for aml in amls) else [],
|
||||
'partner_ids': [Command.set(amls[0].partner_id.ids)] if all(aml.partner_id == amls[0].partner_id for aml in amls) else [],
|
||||
'search_mode': 'zero_balance' if amls.company_currency_id.is_zero(sum(amls.mapped('balance'))) else 'one_to_one',
|
||||
'from_date': min(amls.mapped('date')),
|
||||
'to_date': max(amls.mapped('date')),
|
||||
}
|
||||
|
||||
def _get_wizard_values(self):
|
||||
""" Get the current configuration of the wizard as a dict of values.
|
||||
:return: a dict with the current configuration of the wizard.
|
||||
"""
|
||||
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,
|
||||
}
|
||||
|
||||
# ==== Business methods ====
|
||||
def _get_amls_domain(self):
|
||||
""" Get the domain of amls to be auto-reconciled. """
|
||||
self.ensure_one()
|
||||
if self.line_ids and self._get_wizard_values() == self._get_default_wizard_values(self.line_ids):
|
||||
domain = [('id', 'in', self.line_ids.ids)]
|
||||
else:
|
||||
domain = [
|
||||
('company_id', '=', self.company_id.id),
|
||||
('parent_state', '=', 'posted'),
|
||||
('display_type', 'not in', ('line_section', 'line_subsection', '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), # excludes exchange difference lines
|
||||
]
|
||||
if self.account_ids:
|
||||
domain.append(('account_id', 'in', self.account_ids.ids))
|
||||
if self.partner_ids:
|
||||
domain.append(('partner_id', 'in', self.partner_ids.ids))
|
||||
return domain
|
||||
|
||||
def _auto_reconcile_one_to_one(self):
|
||||
""" Auto-reconcile with one-to-one strategy:
|
||||
We will reconcile 2 amls together if their combined balance is zero.
|
||||
:return: a recordset of reconciled amls
|
||||
"""
|
||||
grouped_amls_data = self.env['account.move.line']._read_group(
|
||||
self._get_amls_domain(),
|
||||
['account_id', 'partner_id', 'currency_id', 'amount_residual_currency:abs_rounded'],
|
||||
['id:recordset'],
|
||||
)
|
||||
all_reconciled_amls = self.env['account.move.line']
|
||||
amls_grouped_by_2 = [] # we need to group amls with right format for _reconcile_plan
|
||||
for *__, grouped_aml_ids in grouped_amls_data:
|
||||
positive_amls = grouped_aml_ids.filtered(lambda aml: aml.amount_residual_currency >= 0).sorted('date')
|
||||
negative_amls = (grouped_aml_ids - positive_amls).sorted('date')
|
||||
min_len = min(len(positive_amls), len(negative_amls))
|
||||
positive_amls = positive_amls[:min_len]
|
||||
negative_amls = negative_amls[:min_len]
|
||||
all_reconciled_amls += positive_amls + negative_amls
|
||||
amls_grouped_by_2 += [pos_aml + neg_aml for (pos_aml, neg_aml) in zip(positive_amls, negative_amls)]
|
||||
self.env['account.move.line']._reconcile_plan(amls_grouped_by_2)
|
||||
return all_reconciled_amls
|
||||
|
||||
def _auto_reconcile_zero_balance(self):
|
||||
""" Auto-reconcile with zero balance strategy:
|
||||
We will reconcile all amls grouped by currency/account/partner that have a total balance of zero.
|
||||
:return: a recordset of reconciled amls
|
||||
"""
|
||||
grouped_amls_data = self.env['account.move.line']._read_group(
|
||||
self._get_amls_domain(),
|
||||
groupby=['account_id', 'partner_id', 'currency_id'],
|
||||
aggregates=['id:recordset'],
|
||||
having=[('amount_residual_currency:sum_rounded', '=', 0)],
|
||||
)
|
||||
all_reconciled_amls = self.env['account.move.line']
|
||||
amls_grouped_together = [] # we need to group amls with right format for _reconcile_plan
|
||||
for aml_data in grouped_amls_data:
|
||||
all_reconciled_amls += aml_data[-1]
|
||||
amls_grouped_together += [aml_data[-1]]
|
||||
self.env['account.move.line']._reconcile_plan(amls_grouped_together)
|
||||
return all_reconciled_amls
|
||||
|
||||
def auto_reconcile(self):
|
||||
""" Automatically reconcile amls given wizard's parameters.
|
||||
:return: an action that opens all reconciled items and related amls (exchange diff, etc)
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.search_mode == 'zero_balance':
|
||||
reconciled_amls = self._auto_reconcile_zero_balance()
|
||||
else:
|
||||
# search_mode == 'one_to_one'
|
||||
reconciled_amls = self._auto_reconcile_one_to_one()
|
||||
reconciled_amls_and_related = self.env['account.move.line'].search([
|
||||
('full_reconcile_id', 'in', reconciled_amls.full_reconcile_id.ids)
|
||||
])
|
||||
if reconciled_amls_and_related:
|
||||
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', reconciled_amls_and_related.ids)],
|
||||
}
|
||||
else:
|
||||
raise UserError(self.env._("Nothing to reconcile."))
|
||||
@@ -0,0 +1,325 @@
|
||||
from odoo import SUPERUSER_ID, api, fields, models
|
||||
from odoo.tools import SQL
|
||||
|
||||
|
||||
class AccountReconcileModel(models.Model):
|
||||
_inherit = 'account.reconcile.model'
|
||||
|
||||
# Technical field to know if the rule was created automatically or by a user.
|
||||
created_automatically = fields.Boolean(default=False, copy=False)
|
||||
|
||||
def _apply_lines_for_bank_widget(self, residual_amount_currency, residual_balance, partner, st_line):
|
||||
""" Apply the reconciliation model lines to the statement line passed as parameter.
|
||||
|
||||
:param residual_amount_currency: The open amount currency of the statement line in the bank reconciliation widget
|
||||
expressed in the statement line currency.
|
||||
:param residual_balance: The open balance of the statement line in the bank reconciliation widget
|
||||
expressed in the company currency.
|
||||
:param partner: The partner set on the wizard.
|
||||
:param st_line: The statement line processed by the bank reconciliation widget.
|
||||
:return: A list of python dictionaries (one per reconcile model line) representing
|
||||
the journal items to be created by the current reconcile model.
|
||||
"""
|
||||
self.ensure_one()
|
||||
currency = st_line.foreign_currency_id or st_line.journal_id.currency_id or st_line.company_currency_id
|
||||
vals_list = []
|
||||
for line in self.line_ids:
|
||||
vals = line._apply_in_bank_widget(
|
||||
residual_amount_currency=residual_amount_currency,
|
||||
residual_balance=residual_balance,
|
||||
partner=line.partner_id or partner,
|
||||
st_line=st_line,
|
||||
)
|
||||
amount_currency = vals['amount_currency']
|
||||
balance = vals['balance']
|
||||
|
||||
if currency.is_zero(amount_currency) and st_line.company_currency_id.is_zero(balance):
|
||||
continue
|
||||
|
||||
vals_list.append(vals)
|
||||
residual_amount_currency -= amount_currency
|
||||
residual_balance -= balance
|
||||
|
||||
return vals_list
|
||||
|
||||
@api.model
|
||||
def get_available_reconcile_model_per_statement_line(self, statement_line_ids):
|
||||
self.check_access('read')
|
||||
self.env['account.reconcile.model'].flush_model()
|
||||
self.env['account.bank.statement.line'].flush_model()
|
||||
self.env.cr.execute(SQL(
|
||||
"""
|
||||
WITH matching_journal_ids AS (
|
||||
SELECT account_reconcile_model_id,
|
||||
ARRAY_AGG(account_journal_id) AS ids
|
||||
FROM account_journal_account_reconcile_model_rel
|
||||
GROUP BY account_reconcile_model_id
|
||||
),
|
||||
matching_partner_ids AS (
|
||||
SELECT account_reconcile_model_id,
|
||||
ARRAY_AGG(res_partner_id) AS ids
|
||||
FROM account_reconcile_model_res_partner_rel
|
||||
GROUP BY account_reconcile_model_id
|
||||
)
|
||||
|
||||
SELECT st_line.id AS st_line_id,
|
||||
array_agg(reco_model.id ORDER BY reco_model.sequence ASC, reco_model.id ASC) AS reco_model_ids,
|
||||
array_agg(reco_model.name ORDER BY reco_model.sequence ASC, reco_model.id ASC) AS reco_model_names
|
||||
FROM account_bank_statement_line st_line
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT DISTINCT reco_model.id,
|
||||
reco_model.sequence,
|
||||
COALESCE(reco_model.name -> %(lang)s, reco_model.name -> 'en_US') as name
|
||||
FROM account_reconcile_model reco_model
|
||||
LEFT JOIN matching_journal_ids ON reco_model.id = matching_journal_ids.account_reconcile_model_id
|
||||
LEFT JOIN matching_partner_ids ON reco_model.id = matching_partner_ids.account_reconcile_model_id
|
||||
LEFT JOIN account_reconcile_model_line reco_model_line ON reco_model_line.model_id = reco_model.id
|
||||
WHERE (matching_journal_ids.ids IS NULL OR st_line.journal_id = ANY(matching_journal_ids.ids))
|
||||
AND (matching_partner_ids.ids IS NULL OR st_line.partner_id = ANY(matching_partner_ids.ids))
|
||||
AND (
|
||||
CASE COALESCE(reco_model.match_amount, '')
|
||||
WHEN 'lower' THEN st_line.amount <= reco_model.match_amount_max
|
||||
WHEN 'greater' THEN st_line.amount >= reco_model.match_amount_min
|
||||
WHEN 'between' THEN
|
||||
(st_line.amount BETWEEN reco_model.match_amount_min AND reco_model.match_amount_max) OR
|
||||
(st_line.amount BETWEEN reco_model.match_amount_max AND reco_model.match_amount_min)
|
||||
ELSE TRUE
|
||||
END
|
||||
)
|
||||
AND (
|
||||
reco_model.match_label IS NULL
|
||||
OR (
|
||||
reco_model.match_label = 'contains'
|
||||
AND (
|
||||
st_line.payment_ref IS NOT NULL AND st_line.payment_ref ILIKE '%%' || reco_model.match_label_param || '%%'
|
||||
OR st_line.transaction_details IS NOT NULL AND st_line.transaction_details::TEXT ILIKE '%%' || reco_model.match_label_param || '%%'
|
||||
)
|
||||
) OR (
|
||||
reco_model.match_label = 'not_contains'
|
||||
AND NOT (
|
||||
st_line.payment_ref IS NOT NULL AND st_line.payment_ref ILIKE '%%' || reco_model.match_label_param || '%%'
|
||||
OR st_line.transaction_details IS NOT NULL AND st_line.transaction_details::TEXT ILIKE '%%' || reco_model.match_label_param || '%%'
|
||||
)
|
||||
) OR (
|
||||
reco_model.match_label = 'match_regex'
|
||||
AND (
|
||||
st_line.payment_ref IS NOT NULL AND st_line.payment_ref ~* reco_model.match_label_param
|
||||
OR st_line.transaction_details IS NOT NULL AND st_line.transaction_details::TEXT ~* reco_model.match_label_param
|
||||
)
|
||||
)
|
||||
)
|
||||
AND reco_model.company_id = st_line.company_id
|
||||
AND reco_model.trigger = 'manual'
|
||||
AND reco_model_line.account_id IS NOT NULL
|
||||
AND reco_model.active IS TRUE
|
||||
) AS reco_model ON TRUE
|
||||
WHERE st_line.id IN %(statement_lines)s
|
||||
AND reco_model.id IS NOT NULL
|
||||
GROUP BY st_line.id
|
||||
""",
|
||||
lang=self.env.lang,
|
||||
statement_lines=tuple(statement_line_ids),
|
||||
))
|
||||
query_result = self.env.cr.fetchall()
|
||||
return {
|
||||
st_line_id: [
|
||||
{'id': model_id, 'display_name': model_name}
|
||||
for (model_id, model_name)
|
||||
in zip(model_ids, model_names)
|
||||
]
|
||||
for st_line_id, model_ids, model_names
|
||||
in query_result
|
||||
}
|
||||
|
||||
def _apply_reconcile_models(self, statement_lines):
|
||||
if not self or not statement_lines:
|
||||
return
|
||||
self.env['account.reconcile.model'].flush_model()
|
||||
statement_lines.flush_recordset(['journal_id', 'amount', 'amount_residual', 'transaction_details', 'payment_ref', 'partner_id', 'company_id'])
|
||||
self.env.cr.execute(SQL("""
|
||||
WITH matching_journal_ids AS (
|
||||
SELECT account_reconcile_model_id,
|
||||
ARRAY_AGG(account_journal_id) AS ids
|
||||
FROM account_journal_account_reconcile_model_rel
|
||||
GROUP BY account_reconcile_model_id
|
||||
),
|
||||
matching_partner_ids AS (
|
||||
SELECT account_reconcile_model_id,
|
||||
ARRAY_AGG(res_partner_id) AS ids
|
||||
FROM account_reconcile_model_res_partner_rel
|
||||
GROUP BY account_reconcile_model_id
|
||||
),
|
||||
model_fees AS (
|
||||
SELECT model_fees.id,
|
||||
model_fees.trigger,
|
||||
matching_journal_ids.ids AS journal_ids
|
||||
FROM account_reconcile_model model_fees
|
||||
JOIN ir_model_data imd ON model_fees.id = imd.res_id
|
||||
JOIN account_reconcile_model_line model_lines ON model_lines.model_id = model_fees.id
|
||||
LEFT JOIN matching_journal_ids ON model_fees.id = matching_journal_ids.account_reconcile_model_id
|
||||
WHERE imd.module = 'account'
|
||||
AND imd.name LIKE 'account_reco_model_fee_%%'
|
||||
AND model_fees.active IS TRUE
|
||||
AND model_lines.account_id IS NOT NULL
|
||||
)
|
||||
|
||||
SELECT st_line.id AS st_line_id,
|
||||
COALESCE(reco_model.id, model_fees.id) AS reco_model_id,
|
||||
COALESCE(reco_model.trigger, model_fees.trigger) AS trigger
|
||||
FROM account_bank_statement_line st_line
|
||||
JOIN account_move move ON st_line.move_id = move.id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT reco_model.id,
|
||||
reco_model.trigger
|
||||
FROM account_reconcile_model reco_model
|
||||
LEFT JOIN matching_journal_ids ON reco_model.id = matching_journal_ids.account_reconcile_model_id
|
||||
LEFT JOIN matching_partner_ids ON reco_model.id = matching_partner_ids.account_reconcile_model_id
|
||||
WHERE (matching_journal_ids.ids IS NULL OR st_line.journal_id = ANY(matching_journal_ids.ids))
|
||||
AND (matching_partner_ids.ids IS NULL OR st_line.partner_id = ANY(matching_partner_ids.ids))
|
||||
AND (
|
||||
CASE COALESCE(reco_model.match_amount, '')
|
||||
WHEN 'lower' THEN st_line.amount <= reco_model.match_amount_max
|
||||
WHEN 'greater' THEN st_line.amount >= reco_model.match_amount_min
|
||||
WHEN 'between' THEN
|
||||
(st_line.amount BETWEEN reco_model.match_amount_min AND reco_model.match_amount_max) OR
|
||||
(st_line.amount BETWEEN reco_model.match_amount_max AND reco_model.match_amount_min)
|
||||
ELSE TRUE
|
||||
END
|
||||
)
|
||||
AND (
|
||||
reco_model.match_label IS NULL
|
||||
OR (
|
||||
reco_model.match_label = 'contains'
|
||||
AND (
|
||||
st_line.payment_ref IS NOT NULL AND st_line.payment_ref ILIKE '%%' || reco_model.match_label_param || '%%'
|
||||
OR st_line.transaction_details IS NOT NULL AND st_line.transaction_details::TEXT ILIKE '%%' || reco_model.match_label_param || '%%'
|
||||
OR move.narration IS NOT NULL AND move.narration::TEXT ILIKE '%%' || reco_model.match_label_param || '%%'
|
||||
)
|
||||
) OR (
|
||||
reco_model.match_label = 'not_contains'
|
||||
AND NOT (
|
||||
st_line.payment_ref IS NOT NULL AND st_line.payment_ref ILIKE '%%' || reco_model.match_label_param || '%%'
|
||||
OR st_line.transaction_details IS NOT NULL AND st_line.transaction_details::TEXT ILIKE '%%' || reco_model.match_label_param || '%%'
|
||||
OR move.narration IS NOT NULL AND move.narration::TEXT ILIKE '%%' || reco_model.match_label_param || '%%'
|
||||
)
|
||||
) OR (
|
||||
reco_model.match_label = 'match_regex'
|
||||
AND (
|
||||
st_line.payment_ref IS NOT NULL AND st_line.payment_ref ~* reco_model.match_label_param
|
||||
OR st_line.transaction_details IS NOT NULL AND st_line.transaction_details::TEXT ~* reco_model.match_label_param
|
||||
OR move.narration IS NOT NULL AND move.narration::TEXT ~* reco_model.match_label_param
|
||||
)
|
||||
)
|
||||
)
|
||||
AND reco_model.id IN %s
|
||||
AND reco_model.can_be_proposed IS TRUE
|
||||
AND reco_model.company_id = st_line.company_id
|
||||
ORDER BY reco_model.sequence ASC, reco_model.id ASC
|
||||
LIMIT 1
|
||||
) AS reco_model ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT model_fees.id,
|
||||
model_fees.trigger
|
||||
FROM model_fees
|
||||
WHERE st_line.journal_id = ANY(model_fees.journal_ids)
|
||||
-- Show model fees if matched amount was 3 %% higher than incoming statement line amount
|
||||
AND SIGN(st_line.amount) > 0
|
||||
AND SIGN(st_line.amount_residual) > 0
|
||||
AND ABS(st_line.amount_residual) < 0.03 * st_line.amount / 1.03
|
||||
) AS model_fees ON TRUE
|
||||
WHERE st_line.id IN %s
|
||||
""", tuple(self.ids), tuple(statement_lines.ids)))
|
||||
|
||||
query_result = self.env.cr.fetchall()
|
||||
|
||||
processed_st_line_ids = set()
|
||||
# apply the found suitable reco models on the statement lines
|
||||
for st_line_id, reco_model_id, reco_model_trigger in query_result:
|
||||
if st_line_id in processed_st_line_ids or reco_model_id is None:
|
||||
continue
|
||||
|
||||
st_line = self.env['account.bank.statement.line'].browse(st_line_id).with_prefetch(statement_lines.ids)
|
||||
reco_model = self.env['account.reconcile.model'].browse(reco_model_id).with_prefetch(self.ids)
|
||||
|
||||
if reco_model_trigger == 'manual':
|
||||
st_line._action_manual_reco_model(reco_model_id)
|
||||
else:
|
||||
reco_model.with_user(SUPERUSER_ID)._trigger_reconciliation_model(st_line.with_user(SUPERUSER_ID))
|
||||
processed_st_line_ids.add(st_line_id)
|
||||
|
||||
def _trigger_reconciliation_model(self, statement_line):
|
||||
self.ensure_one()
|
||||
liquidity_line, suspense_line, other_lines = statement_line._seek_for_lines()
|
||||
|
||||
amls_to_create = list(
|
||||
self._apply_lines_for_bank_widget(
|
||||
residual_amount_currency=sum(suspense_line.mapped('amount_currency')),
|
||||
residual_balance=sum(suspense_line.mapped('balance')),
|
||||
partner=statement_line.partner_id,
|
||||
st_line=statement_line,
|
||||
)
|
||||
)
|
||||
# Get the original base lines and tax lines before the creation of new lines
|
||||
if any(aml.get('tax_ids') for aml in amls_to_create):
|
||||
original_base_lines, original_tax_lines = statement_line._prepare_for_tax_lines_recomputation()
|
||||
|
||||
statement_line._set_move_line_to_statement_line_move(liquidity_line + other_lines, amls_to_create)
|
||||
|
||||
# Now that the new lines have been added, we can recompute the taxes
|
||||
if any(aml.get('tax_ids') for aml in amls_to_create):
|
||||
_new_liquidity_line, new_suspense_line, _new_other_lines = statement_line._seek_for_lines()
|
||||
new_lines = statement_line.line_ids - (liquidity_line + other_lines + new_suspense_line)
|
||||
statement_line._create_tax_lines(original_base_lines, original_tax_lines, new_lines)
|
||||
|
||||
if self.next_activity_type_id:
|
||||
statement_line.move_id.activity_schedule(
|
||||
activity_type_id=self.next_activity_type_id.id,
|
||||
user_id=self.env.user.id,
|
||||
)
|
||||
|
||||
def trigger_reconciliation_model(self, statement_line_id):
|
||||
self.ensure_one()
|
||||
|
||||
statement_line = self.env['account.bank.statement.line'].browse(statement_line_id).exists()
|
||||
self._trigger_reconciliation_model(statement_line)
|
||||
|
||||
def write(self, vals):
|
||||
res = super().write(vals)
|
||||
unreconciled_statement_lines = self.env['account.bank.statement.line'].search([
|
||||
*self._check_company_domain(self.env.company),
|
||||
('is_reconciled', '=', False),
|
||||
])
|
||||
if unreconciled_statement_lines:
|
||||
unreconciled_statement_lines.line_ids.filtered(
|
||||
lambda line:
|
||||
line.account_id == line.move_id.journal_id.suspense_account_id and line.reconcile_model_id in self
|
||||
).reconcile_model_id = False
|
||||
self._apply_reconcile_models(unreconciled_statement_lines)
|
||||
|
||||
return res
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
reco_models = super().create(vals_list)
|
||||
unreconciled_statement_lines = self.env['account.bank.statement.line'].search([
|
||||
*self._check_company_domain(self.env.company),
|
||||
('is_reconciled', '=', False),
|
||||
])
|
||||
if unreconciled_statement_lines:
|
||||
reco_models._apply_reconcile_models(unreconciled_statement_lines)
|
||||
|
||||
return reco_models
|
||||
|
||||
def action_archive(self):
|
||||
res = super().action_archive()
|
||||
unreconciled_statement_lines = self.env['account.bank.statement.line'].search([
|
||||
*self._check_company_domain(self.env.company),
|
||||
('is_reconciled', '=', False),
|
||||
('line_ids.reconcile_model_id', 'in', self.ids),
|
||||
])
|
||||
if unreconciled_statement_lines:
|
||||
unreconciled_statement_lines.line_ids.filtered(
|
||||
lambda line:
|
||||
line.account_id == line.move_id.journal_id.suspense_account_id
|
||||
).reconcile_model_id = False
|
||||
return res
|
||||
@@ -0,0 +1,139 @@
|
||||
import { EventBus, reactive, useState } from "@odoo/owl";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
export class BankReconciliationService {
|
||||
constructor(env, services) {
|
||||
this.env = env;
|
||||
this.setup(env, services);
|
||||
}
|
||||
|
||||
setup(env, services) {
|
||||
this.bus = new EventBus();
|
||||
this.orm = services["orm"];
|
||||
|
||||
this.chatterState = reactive({
|
||||
visible:
|
||||
JSON.parse(
|
||||
browser.sessionStorage.getItem("isBankReconciliationWidgetChatterOpened")
|
||||
) ?? false,
|
||||
statementLine: null,
|
||||
});
|
||||
this.reconcileCountPerPartnerId = reactive({});
|
||||
this.reconcileModelPerStatementLineId = reactive({});
|
||||
}
|
||||
|
||||
toggleChatter() {
|
||||
this.chatterState.visible = !this.chatterState.visible;
|
||||
browser.sessionStorage.setItem(
|
||||
"isBankReconciliationWidgetChatterOpened",
|
||||
this.chatterState.visible
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Specific function to open the chatter.
|
||||
* For a particular case, where the customer clicks on
|
||||
* the chatter icon directly on the bank statement line,
|
||||
* we want to open the chatter but not close it.
|
||||
*/
|
||||
openChatter() {
|
||||
this.chatterState.visible = true;
|
||||
}
|
||||
|
||||
selectStatementLine(statementLine) {
|
||||
this.chatterState.statementLine = statementLine;
|
||||
}
|
||||
|
||||
reloadChatter() {
|
||||
this.bus.trigger("MAIL:RELOAD-THREAD", {
|
||||
model: "account.move",
|
||||
id: this.statementLineMoveId,
|
||||
});
|
||||
}
|
||||
|
||||
async computeReconcileLineCountPerPartnerId(records) {
|
||||
const groups = await this.orm.formattedReadGroup(
|
||||
"account.move.line",
|
||||
[
|
||||
["parent_state", "in", ["draft", "posted"]],
|
||||
[
|
||||
"partner_id",
|
||||
"in",
|
||||
records
|
||||
.filter((record) => !!record.data.partner_id.id)
|
||||
.map((record) => record.data.partner_id.id),
|
||||
],
|
||||
["company_id", "child_of", records.map((record) => record.data.company_id.id)],
|
||||
["search_account_id.reconcile", "=", true],
|
||||
["display_type", "not in", ["line_section", "line_note"]],
|
||||
["reconciled", "=", false],
|
||||
"|",
|
||||
["search_account_id.account_type", "not in", ["asset_receivable", "liability_payable"]],
|
||||
["payment_id", "=", false],
|
||||
["statement_line_id", "not in", records.map((record) => record.data.id)],
|
||||
],
|
||||
["partner_id"],
|
||||
["id:count"]
|
||||
);
|
||||
|
||||
this.reconcileCountPerPartnerId = {};
|
||||
groups.forEach((group) => {
|
||||
this.reconcileCountPerPartnerId[group.partner_id[0]] = group["id:count"];
|
||||
});
|
||||
}
|
||||
|
||||
async computeAvailableReconcileModels(records) {
|
||||
this.reconcileModelPerStatementLineId =
|
||||
Object.keys(records).length === 0
|
||||
? {}
|
||||
: await this.orm.call(
|
||||
"account.reconcile.model",
|
||||
"get_available_reconcile_model_per_statement_line",
|
||||
[records.map((record) => record.data.id)]
|
||||
);
|
||||
}
|
||||
|
||||
async updateAvailableReconcileModels(recordId) {
|
||||
const result = await this.orm.call(
|
||||
"account.reconcile.model",
|
||||
"get_available_reconcile_model_per_statement_line",
|
||||
[[recordId]]
|
||||
);
|
||||
this.reconcileModelPerStatementLineId[recordId] = result[recordId];
|
||||
}
|
||||
|
||||
async reloadRecords(records) {
|
||||
await Promise.all([...records.map((record) => record.load())]);
|
||||
}
|
||||
|
||||
get statementLineMove() {
|
||||
return this.chatterState.statementLine?.data.move_id;
|
||||
}
|
||||
|
||||
get statementLineMoveId() {
|
||||
return this.statementLineMove?.id;
|
||||
}
|
||||
|
||||
get statementLine() {
|
||||
return this.chatterState.statementLine;
|
||||
}
|
||||
|
||||
get statementLineId() {
|
||||
return this.statementLine?.data?.id;
|
||||
}
|
||||
}
|
||||
|
||||
const bankReconciliationService = {
|
||||
dependencies: ["orm"],
|
||||
start(env, services) {
|
||||
return new BankReconciliationService(env, services);
|
||||
},
|
||||
};
|
||||
|
||||
registry.category("services").add("bankReconciliation", bankReconciliationService);
|
||||
|
||||
export function useBankReconciliation() {
|
||||
return useState(useService("bankReconciliation"));
|
||||
}
|
||||
0
fusion_accounting_bank_rec/models/__init__.py
Normal file
0
fusion_accounting_bank_rec/models/__init__.py
Normal file
1
fusion_accounting_bank_rec/security/ir.model.access.csv
Normal file
1
fusion_accounting_bank_rec/security/ir.model.access.csv
Normal file
@@ -0,0 +1 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
|
0
fusion_accounting_bank_rec/services/__init__.py
Normal file
0
fusion_accounting_bank_rec/services/__init__.py
Normal file
BIN
fusion_accounting_bank_rec/static/description/icon.png
Normal file
BIN
fusion_accounting_bank_rec/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
0
fusion_accounting_bank_rec/tests/__init__.py
Normal file
0
fusion_accounting_bank_rec/tests/__init__.py
Normal file
0
fusion_accounting_bank_rec/wizards/__init__.py
Normal file
0
fusion_accounting_bank_rec/wizards/__init__.py
Normal file
Reference in New Issue
Block a user