diff --git a/fusion_accounting_bank_rec/__init__.py b/fusion_accounting_bank_rec/__init__.py new file mode 100644 index 00000000..6311ca4b --- /dev/null +++ b/fusion_accounting_bank_rec/__init__.py @@ -0,0 +1,4 @@ +from . import models +from . import controllers +from . import services +from . import wizards diff --git a/fusion_accounting_bank_rec/__manifest__.py b/fusion_accounting_bank_rec/__manifest__.py new file mode 100644 index 00000000..82ac868c --- /dev/null +++ b/fusion_accounting_bank_rec/__manifest__.py @@ -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', +} diff --git a/fusion_accounting_bank_rec/controllers/__init__.py b/fusion_accounting_bank_rec/controllers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fusion_accounting_bank_rec/docs/odoo_diff/v19/account_accountant__account_auto_reconcile_wizard.py b/fusion_accounting_bank_rec/docs/odoo_diff/v19/account_accountant__account_auto_reconcile_wizard.py new file mode 100644 index 00000000..89259ea4 --- /dev/null +++ b/fusion_accounting_bank_rec/docs/odoo_diff/v19/account_accountant__account_auto_reconcile_wizard.py @@ -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.")) diff --git a/fusion_accounting_bank_rec/docs/odoo_diff/v19/account_accountant__account_reconcile_model.py b/fusion_accounting_bank_rec/docs/odoo_diff/v19/account_accountant__account_reconcile_model.py new file mode 100644 index 00000000..c1478769 --- /dev/null +++ b/fusion_accounting_bank_rec/docs/odoo_diff/v19/account_accountant__account_reconcile_model.py @@ -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 diff --git a/fusion_accounting_bank_rec/docs/odoo_diff/v19/account_accountant__bank_reconciliation_service.js b/fusion_accounting_bank_rec/docs/odoo_diff/v19/account_accountant__bank_reconciliation_service.js new file mode 100644 index 00000000..e4e4b08a --- /dev/null +++ b/fusion_accounting_bank_rec/docs/odoo_diff/v19/account_accountant__bank_reconciliation_service.js @@ -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")); +} diff --git a/fusion_accounting_bank_rec/models/__init__.py b/fusion_accounting_bank_rec/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fusion_accounting_bank_rec/security/ir.model.access.csv b/fusion_accounting_bank_rec/security/ir.model.access.csv new file mode 100644 index 00000000..97dd8b91 --- /dev/null +++ b/fusion_accounting_bank_rec/security/ir.model.access.csv @@ -0,0 +1 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink diff --git a/fusion_accounting_bank_rec/services/__init__.py b/fusion_accounting_bank_rec/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fusion_accounting_bank_rec/static/description/icon.png b/fusion_accounting_bank_rec/static/description/icon.png new file mode 100644 index 00000000..6773c627 Binary files /dev/null and b/fusion_accounting_bank_rec/static/description/icon.png differ diff --git a/fusion_accounting_bank_rec/tests/__init__.py b/fusion_accounting_bank_rec/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fusion_accounting_bank_rec/wizards/__init__.py b/fusion_accounting_bank_rec/wizards/__init__.py new file mode 100644 index 00000000..e69de29b