From 267f6653d08fc53f5059977cad77e6ed746ac636 Mon Sep 17 00:00:00 2001 From: Odoo Date: Mon, 14 Dec 2020 02:57:39 +0100 Subject: [PATCH 1/3] [ADD] account_reconciliation_widget: Base module code extracted from Odoo 13.0 --- .../models/account_bank_statement.py | 513 ++ .../models/account_journal.py | 22 + .../models/account_move.py | 127 + .../models/reconciliation_widget.py | 1180 ++++ .../models/res_company.py | 14 + .../reconciliation/reconciliation_action.js | 547 ++ .../js/reconciliation/reconciliation_model.js | 2356 ++++++++ .../reconciliation/reconciliation_renderer.js | 1194 ++++ .../src/scss/account_reconciliation.scss | 382 ++ .../static/src/xml/account_reconciliation.xml | 636 +++ .../tests/account_reconciliation_tests.js | 4922 +++++++++++++++++ 11 files changed, 11893 insertions(+) create mode 100644 account_reconciliation_widget/models/account_bank_statement.py create mode 100644 account_reconciliation_widget/models/account_journal.py create mode 100644 account_reconciliation_widget/models/account_move.py create mode 100644 account_reconciliation_widget/models/reconciliation_widget.py create mode 100644 account_reconciliation_widget/models/res_company.py create mode 100644 account_reconciliation_widget/static/src/js/reconciliation/reconciliation_action.js create mode 100644 account_reconciliation_widget/static/src/js/reconciliation/reconciliation_model.js create mode 100644 account_reconciliation_widget/static/src/js/reconciliation/reconciliation_renderer.js create mode 100644 account_reconciliation_widget/static/src/scss/account_reconciliation.scss create mode 100644 account_reconciliation_widget/static/src/xml/account_reconciliation.xml create mode 100644 account_reconciliation_widget/static/tests/account_reconciliation_tests.js diff --git a/account_reconciliation_widget/models/account_bank_statement.py b/account_reconciliation_widget/models/account_bank_statement.py new file mode 100644 index 0000000000..cee53c903e --- /dev/null +++ b/account_reconciliation_widget/models/account_bank_statement.py @@ -0,0 +1,513 @@ +from odoo import _, fields, models +from odoo.exceptions import UserError + + +class AccountBankStatement(models.Model): + + _inherit = "account.bank.statement" + + accounting_date = fields.Date( + string="Accounting Date", + help="If set, the accounting entries created during the bank statement " + "reconciliation process will be created at this date.\n" + "This is useful if the accounting period in which the entries should " + "normally be booked is already closed.", + states={"open": [("readonly", False)]}, + readonly=True, + ) + + def action_bank_reconcile_bank_statements(self): + self.ensure_one() + bank_stmt_lines = self.mapped("line_ids") + return { + "type": "ir.actions.client", + "tag": "bank_statement_reconciliation_view", + "context": { + "statement_line_ids": bank_stmt_lines.ids, + "company_ids": self.mapped("company_id").ids, + }, + } + + +class AccountBankStatementLine(models.Model): + + _inherit = "account.bank.statement.line" + + move_name = fields.Char( + string="Journal Entry Name", + readonly=True, + default=False, + copy=False, + help="Technical field holding the number given to the journal entry, " + "automatically set when the statement line is reconciled then " + "stored to set the same number again if the line is cancelled, " + "set to draft and re-processed again.", + ) + + def process_reconciliation( + self, counterpart_aml_dicts=None, payment_aml_rec=None, new_aml_dicts=None + ): + """Match statement lines with existing payments (eg. checks) and/or + payables/receivables (eg. invoices and credit notes) and/or new move + lines (eg. write-offs). + If any new journal item needs to be created (via new_aml_dicts or + counterpart_aml_dicts), a new journal entry will be created and will + contain those items, as well as a journal item for the bank statement + line. + Finally, mark the statement line as reconciled by putting the matched + moves ids in the column journal_entry_ids. + + :param self: browse collection of records that are supposed to have no + accounting entries already linked. + :param (list of dicts) counterpart_aml_dicts: move lines to create to + reconcile with existing payables/receivables. + The expected keys are : + - 'name' + - 'debit' + - 'credit' + - 'move_line' + # The move line to reconcile (partially if specified + # debit/credit is lower than move line's credit/debit) + + :param (list of recordsets) payment_aml_rec: recordset move lines + representing existing payments (which are already fully reconciled) + + :param (list of dicts) new_aml_dicts: move lines to create. The expected + keys are : + - 'name' + - 'debit' + - 'credit' + - 'account_id' + - (optional) 'tax_ids' + - (optional) Other account.move.line fields like analytic_account_id + or analytics_id + - (optional) 'reconcile_model_id' + + :returns: The journal entries with which the transaction was matched. + If there was at least an entry in counterpart_aml_dicts or + new_aml_dicts, this list contains the move created by the + reconciliation, containing entries for the statement.line (1), the + counterpart move lines (0..*) and the new move lines (0..*). + """ + payable_account_type = self.env.ref("account.data_account_type_payable") + receivable_account_type = self.env.ref("account.data_account_type_receivable") + suspense_moves_mode = self._context.get("suspense_moves_mode") + counterpart_aml_dicts = counterpart_aml_dicts or [] + payment_aml_rec = payment_aml_rec or self.env["account.move.line"] + new_aml_dicts = new_aml_dicts or [] + + aml_obj = self.env["account.move.line"] + + company_currency = self.journal_id.company_id.currency_id + statement_currency = self.journal_id.currency_id or company_currency + + counterpart_moves = self.env["account.move"] + + # Check and prepare received data + if any(rec.statement_id for rec in payment_aml_rec): + raise UserError(_("A selected move line was already reconciled.")) + for aml_dict in counterpart_aml_dicts: + if aml_dict["move_line"].reconciled and not suspense_moves_mode: + raise UserError(_("A selected move line was already reconciled.")) + if isinstance(aml_dict["move_line"], int): + aml_dict["move_line"] = aml_obj.browse(aml_dict["move_line"]) + + account_types = self.env["account.account.type"] + for aml_dict in counterpart_aml_dicts + new_aml_dicts: + if aml_dict.get("tax_ids") and isinstance(aml_dict["tax_ids"][0], int): + # Transform the value in the format required for One2many and + # Many2many fields + aml_dict["tax_ids"] = [(4, id, None) for id in aml_dict["tax_ids"]] + + user_type_id = ( + self.env["account.account"] + .browse(aml_dict.get("account_id")) + .user_type_id + ) + if ( + user_type_id in [payable_account_type, receivable_account_type] + and user_type_id not in account_types + ): + account_types |= user_type_id + if suspense_moves_mode: + if any(not line.journal_entry_ids for line in self): + raise UserError( + _( + "Some selected statement line were not already " + "reconciled with an account move." + ) + ) + else: + if any(line.journal_entry_ids for line in self): + raise UserError( + _( + "A selected statement line was already reconciled with " + "an account move." + ) + ) + + # Fully reconciled moves are just linked to the bank statement + total = self.amount + currency = self.currency_id or statement_currency + for aml_rec in payment_aml_rec: + balance = ( + aml_rec.amount_currency if aml_rec.currency_id else aml_rec.balance + ) + aml_currency = aml_rec.currency_id or aml_rec.company_currency_id + total -= aml_currency._convert( + balance, currency, aml_rec.company_id, aml_rec.date + ) + aml_rec.with_context(check_move_validity=False).write( + {"statement_line_id": self.id} + ) + counterpart_moves = counterpart_moves | aml_rec.move_id + if ( + aml_rec.journal_id.post_at == "bank_rec" + and aml_rec.payment_id + and aml_rec.move_id.state == "draft" + ): + # In case the journal is set to only post payments when + # performing bank reconciliation, we modify its date and post + # it. + aml_rec.move_id.date = self.date + aml_rec.payment_id.payment_date = self.date + aml_rec.move_id.post() + # We check the paid status of the invoices reconciled with this + # payment + for invoice in aml_rec.payment_id.reconciled_invoice_ids: + self._check_invoice_state(invoice) + + # Create move line(s). Either matching an existing journal entry + # (eg. invoice), in which case we reconcile the existing and the new + # move lines together, or being a write-off. + if counterpart_aml_dicts or new_aml_dicts: + + # Create the move + self.sequence = self.statement_id.line_ids.ids.index(self.id) + 1 + move_vals = self._prepare_reconciliation_move(self.statement_id.name) + if suspense_moves_mode: + self.button_cancel_reconciliation() + move = ( + self.env["account.move"] + .with_context(default_journal_id=move_vals["journal_id"]) + .create(move_vals) + ) + counterpart_moves = counterpart_moves | move + + # Create The payment + payment = self.env["account.payment"] + partner_id = ( + self.partner_id + or (aml_dict.get("move_line") and aml_dict["move_line"].partner_id) + or self.env["res.partner"] + ) + if abs(total) > 0.00001: + payment_vals = self._prepare_payment_vals(total) + if not payment_vals["partner_id"]: + payment_vals["partner_id"] = partner_id.id + if payment_vals["partner_id"] and len(account_types) == 1: + payment_vals["partner_type"] = ( + "customer" + if account_types == receivable_account_type + else "supplier" + ) + payment = payment.create(payment_vals) + + # Complete dicts to create both counterpart move lines and write-offs + to_create = counterpart_aml_dicts + new_aml_dicts + date = self.date or fields.Date.today() + for aml_dict in to_create: + aml_dict["move_id"] = move.id + aml_dict["partner_id"] = self.partner_id.id + aml_dict["statement_line_id"] = self.id + self._prepare_move_line_for_currency(aml_dict, date) + + # Create write-offs + for aml_dict in new_aml_dicts: + aml_dict["payment_id"] = payment and payment.id or False + aml_obj.with_context(check_move_validity=False).create(aml_dict) + + # Create counterpart move lines and reconcile them + for aml_dict in counterpart_aml_dicts: + if ( + aml_dict["move_line"].payment_id + and not aml_dict["move_line"].statement_line_id + ): + aml_dict["move_line"].write({"statement_line_id": self.id}) + if aml_dict["move_line"].partner_id.id: + aml_dict["partner_id"] = aml_dict["move_line"].partner_id.id + aml_dict["account_id"] = aml_dict["move_line"].account_id.id + aml_dict["payment_id"] = payment and payment.id or False + + counterpart_move_line = aml_dict.pop("move_line") + new_aml = aml_obj.with_context(check_move_validity=False).create( + aml_dict + ) + + (new_aml | counterpart_move_line).reconcile() + + self._check_invoice_state(counterpart_move_line.move_id) + + # Balance the move + st_line_amount = -sum([x.balance for x in move.line_ids]) + aml_dict = self._prepare_reconciliation_move_line(move, st_line_amount) + aml_dict["payment_id"] = payment and payment.id or False + aml_obj.with_context(check_move_validity=False).create(aml_dict) + + # Needs to be called manually as lines were created 1 by 1 + move.update_lines_tax_exigibility() + move.post() + # record the move name on the statement line to be able to retrieve + # it in case of unreconciliation + self.write({"move_name": move.name}) + payment and payment.write({"payment_reference": move.name}) + elif self.move_name: + raise UserError( + _( + "Operation not allowed. Since your statement line already " + "received a number (%s), you cannot reconcile it entirely " + "with existing journal entries otherwise it would make a " + "gap in the numbering. You should book an entry and make a " + "regular revert of it in case you want to cancel it." + ) + % (self.move_name) + ) + + # create the res.partner.bank if needed + if self.account_number and self.partner_id and not self.bank_account_id: + # Search bank account without partner to handle the case the + # res.partner.bank already exists but is set on a different partner. + self.bank_account_id = self._find_or_create_bank_account() + + counterpart_moves._check_balanced() + return counterpart_moves + + def _prepare_reconciliation_move(self, move_ref): + """Prepare the dict of values to create the move from a statement line. + This method may be overridden to adapt domain logic through model + inheritance (make sure to call super() to establish a clean extension + chain). + + :param char move_ref: will be used as the reference of the generated + account move + :return: dict of value to create() the account.move + """ + ref = move_ref or "" + if self.ref: + ref = move_ref + " - " + self.ref if move_ref else self.ref + data = { + "type": "entry", + "journal_id": self.statement_id.journal_id.id, + "currency_id": self.statement_id.currency_id.id, + "date": self.statement_id.accounting_date or self.date, + "partner_id": self.partner_id.id, + "ref": ref, + } + if self.move_name: + data.update(name=self.move_name) + return data + + def _prepare_reconciliation_move_line(self, move, amount): + """Prepare the dict of values to balance the move. + + :param recordset move: the account.move to link the move line + :param dict move: a dict of vals of a account.move which will be created + later + :param float amount: the amount of transaction that wasn't already + reconciled + """ + company_currency = self.journal_id.company_id.currency_id + statement_currency = self.journal_id.currency_id or company_currency + st_line_currency = self.currency_id or statement_currency + amount_currency = False + st_line_currency_rate = ( + self.currency_id and (self.amount_currency / self.amount) or False + ) + if isinstance(move, dict): + amount_sum = sum(x[2].get("amount_currency", 0) for x in move["line_ids"]) + else: + amount_sum = sum(x.amount_currency for x in move.line_ids) + # We have several use case here to compare the currency and amount + # currency of counterpart line to balance the move: + if ( + st_line_currency != company_currency + and st_line_currency == statement_currency + ): + # company in currency A, statement in currency B and transaction in + # currency B + # counterpart line must have currency B and correct amount is + # inverse of already existing lines + amount_currency = -amount_sum + elif ( + st_line_currency != company_currency + and statement_currency == company_currency + ): + # company in currency A, statement in currency A and transaction in + # currency B + # counterpart line must have currency B and correct amount is + # inverse of already existing lines + amount_currency = -amount_sum + elif ( + st_line_currency != company_currency + and st_line_currency != statement_currency + ): + # company in currency A, statement in currency B and transaction in + # currency C + # counterpart line must have currency B and use rate between B and + # C to compute correct amount + amount_currency = -amount_sum / st_line_currency_rate + elif ( + st_line_currency == company_currency + and statement_currency != company_currency + ): + # company in currency A, statement in currency B and transaction in + # currency A + # counterpart line must have currency B and amount is computed using + # the rate between A and B + amount_currency = amount / st_line_currency_rate + + # last case is company in currency A, statement in currency A and + # transaction in currency A + # and in this case counterpart line does not need any second currency + # nor amount_currency + + # Check if default_debit or default_credit account are properly configured + account_id = ( + amount >= 0 + and self.statement_id.journal_id.default_credit_account_id.id + or self.statement_id.journal_id.default_debit_account_id.id + ) + + if not account_id: + raise UserError( + _( + "No default debit and credit account defined on journal %s " + "(ids: %s)." + % ( + self.statement_id.journal_id.name, + self.statement_id.journal_id.ids, + ) + ) + ) + + aml_dict = { + "name": self.name, + "partner_id": self.partner_id and self.partner_id.id or False, + "account_id": account_id, + "credit": amount < 0 and -amount or 0.0, + "debit": amount > 0 and amount or 0.0, + "statement_line_id": self.id, + "currency_id": statement_currency != company_currency + and statement_currency.id + or (st_line_currency != company_currency and st_line_currency.id or False), + "amount_currency": amount_currency, + } + if isinstance(move, self.env["account.move"].__class__): + aml_dict["move_id"] = move.id + return aml_dict + + def _get_communication(self, payment_method_id): + return self.name or "" + + def _prepare_payment_vals(self, total): + """Prepare the dict of values to create the payment from a statement + line. This method may be overridden for update dict + through model inheritance (make sure to call super() to establish a + clean extension chain). + + :param float total: will be used as the amount of the generated payment + :return: dict of value to create() the account.payment + """ + self.ensure_one() + partner_type = False + if self.partner_id: + if total < 0: + partner_type = "supplier" + else: + partner_type = "customer" + if not partner_type and self.env.context.get("default_partner_type"): + partner_type = self.env.context["default_partner_type"] + currency = self.journal_id.currency_id or self.company_id.currency_id + payment_methods = ( + (total > 0) + and self.journal_id.inbound_payment_method_ids + or self.journal_id.outbound_payment_method_ids + ) + return { + "payment_method_id": payment_methods and payment_methods[0].id or False, + "payment_type": total > 0 and "inbound" or "outbound", + "partner_id": self.partner_id.id, + "partner_type": partner_type, + "journal_id": self.statement_id.journal_id.id, + "payment_date": self.date, + "state": "reconciled", + "currency_id": currency.id, + "amount": abs(total), + "communication": self._get_communication( + payment_methods[0] if payment_methods else False + ), + "name": self.statement_id.name or _("Bank Statement %s") % self.date, + } + + def _prepare_move_line_for_currency(self, aml_dict, date): + self.ensure_one() + company_currency = self.journal_id.company_id.currency_id + statement_currency = self.journal_id.currency_id or company_currency + st_line_currency = self.currency_id or statement_currency + st_line_currency_rate = ( + self.currency_id and (self.amount_currency / self.amount) or False + ) + company = self.company_id + + if st_line_currency.id != company_currency.id: + aml_dict["amount_currency"] = aml_dict["debit"] - aml_dict["credit"] + aml_dict["currency_id"] = st_line_currency.id + if ( + self.currency_id + and statement_currency.id == company_currency.id + and st_line_currency_rate + ): + # Statement is in company currency but the transaction is in + # foreign currency + aml_dict["debit"] = company_currency.round( + aml_dict["debit"] / st_line_currency_rate + ) + aml_dict["credit"] = company_currency.round( + aml_dict["credit"] / st_line_currency_rate + ) + elif self.currency_id and st_line_currency_rate: + # Statement is in foreign currency and the transaction is in + # another one + aml_dict["debit"] = statement_currency._convert( + aml_dict["debit"] / st_line_currency_rate, + company_currency, + company, + date, + ) + aml_dict["credit"] = statement_currency._convert( + aml_dict["credit"] / st_line_currency_rate, + company_currency, + company, + date, + ) + else: + # Statement is in foreign currency and no extra currency is + # given for the transaction + aml_dict["debit"] = st_line_currency._convert( + aml_dict["debit"], company_currency, company, date + ) + aml_dict["credit"] = st_line_currency._convert( + aml_dict["credit"], company_currency, company, date + ) + elif statement_currency.id != company_currency.id: + # Statement is in foreign currency but the transaction is in company + # currency + prorata_factor = ( + aml_dict["debit"] - aml_dict["credit"] + ) / self.amount_currency + aml_dict["amount_currency"] = prorata_factor * self.amount + aml_dict["currency_id"] = statement_currency.id + + def _check_invoice_state(self, invoice): + if invoice.is_invoice(include_receipts=True): + invoice._compute_amount() diff --git a/account_reconciliation_widget/models/account_journal.py b/account_reconciliation_widget/models/account_journal.py new file mode 100644 index 0000000000..261c8bbccd --- /dev/null +++ b/account_reconciliation_widget/models/account_journal.py @@ -0,0 +1,22 @@ +from odoo import models + + +class AccountJournal(models.Model): + + _inherit = "account.journal" + + def action_open_reconcile(self): + # Open reconciliation view for bank statements belonging to this journal + bank_stmt = ( + self.env["account.bank.statement"] + .search([("journal_id", "in", self.ids)]) + .mapped("line_ids") + ) + return { + "type": "ir.actions.client", + "tag": "bank_statement_reconciliation_view", + "context": { + "statement_line_ids": bank_stmt.ids, + "company_ids": self.mapped("company_id").ids, + }, + } diff --git a/account_reconciliation_widget/models/account_move.py b/account_reconciliation_widget/models/account_move.py new file mode 100644 index 0000000000..e1a689285e --- /dev/null +++ b/account_reconciliation_widget/models/account_move.py @@ -0,0 +1,127 @@ +from odoo import _, fields, models +from odoo.exceptions import UserError + + +class AccountMoveLine(models.Model): + + _inherit = "account.move.line" + + def _create_writeoff(self, writeoff_vals): + """Create a writeoff move per journal for the account.move.lines in + self. If debit/credit is not specified in vals, the writeoff amount + will be computed as the sum of amount_residual of the given recordset. + + :param writeoff_vals: list of dicts containing values suitable for + account_move_line.create(). The data in vals will be processed to + create bot writeoff account.move.line and their enclosing + account.move. + """ + + def compute_writeoff_counterpart_vals(values): + line_values = values.copy() + line_values["debit"], line_values["credit"] = ( + line_values["credit"], + line_values["debit"], + ) + if "amount_currency" in values: + line_values["amount_currency"] = -line_values["amount_currency"] + return line_values + + # Group writeoff_vals by journals + writeoff_dict = {} + for val in writeoff_vals: + journal_id = val.get("journal_id", False) + if not writeoff_dict.get(journal_id, False): + writeoff_dict[journal_id] = [val] + else: + writeoff_dict[journal_id].append(val) + + partner_id = ( + self.env["res.partner"]._find_accounting_partner(self[0].partner_id).id + ) + company_currency = self[0].account_id.company_id.currency_id + writeoff_currency = self[0].account_id.currency_id or company_currency + line_to_reconcile = self.env["account.move.line"] + # Iterate and create one writeoff by journal + writeoff_moves = self.env["account.move"] + for journal_id, lines in writeoff_dict.items(): + total = 0 + total_currency = 0 + writeoff_lines = [] + date = fields.Date.today() + for vals in lines: + # Check and complete vals + if "account_id" not in vals or "journal_id" not in vals: + raise UserError( + _( + "It is mandatory to specify an account and a " + "journal to create a write-off." + ) + ) + if ("debit" in vals) ^ ("credit" in vals): + raise UserError(_("Either pass both debit and credit or none.")) + if "date" not in vals: + vals["date"] = self._context.get("date_p") or fields.Date.today() + vals["date"] = fields.Date.to_date(vals["date"]) + if vals["date"] and vals["date"] < date: + date = vals["date"] + if "name" not in vals: + vals["name"] = self._context.get("comment") or _("Write-Off") + if "analytic_account_id" not in vals: + vals["analytic_account_id"] = self.env.context.get( + "analytic_id", False + ) + # compute the writeoff amount if not given + if "credit" not in vals and "debit" not in vals: + amount = sum([r.amount_residual for r in self]) + vals["credit"] = amount > 0 and amount or 0.0 + vals["debit"] = amount < 0 and abs(amount) or 0.0 + vals["partner_id"] = partner_id + total += vals["debit"] - vals["credit"] + if ( + "amount_currency" not in vals + and writeoff_currency != company_currency + ): + vals["currency_id"] = writeoff_currency.id + sign = 1 if vals["debit"] > 0 else -1 + vals["amount_currency"] = sign * abs( + sum([r.amount_residual_currency for r in self]) + ) + total_currency += vals["amount_currency"] + + writeoff_lines.append(compute_writeoff_counterpart_vals(vals)) + + # Create balance line + writeoff_lines.append( + { + "name": _("Write-Off"), + "debit": total > 0 and total or 0.0, + "credit": total < 0 and -total or 0.0, + "amount_currency": total_currency, + "currency_id": total_currency and writeoff_currency.id or False, + "journal_id": journal_id, + "account_id": self[0].account_id.id, + "partner_id": partner_id, + } + ) + + # Create the move + writeoff_move = self.env["account.move"].create( + { + "journal_id": journal_id, + "date": date, + "state": "draft", + "line_ids": [(0, 0, line) for line in writeoff_lines], + } + ) + writeoff_moves += writeoff_move + line_to_reconcile += writeoff_move.line_ids.filtered( + lambda r: r.account_id == self[0].account_id + ).sorted(key="id")[-1:] + + # post all the writeoff moves at once + if writeoff_moves: + writeoff_moves.post() + + # Return the writeoff move.line which is to be reconciled + return line_to_reconcile diff --git a/account_reconciliation_widget/models/reconciliation_widget.py b/account_reconciliation_widget/models/reconciliation_widget.py new file mode 100644 index 0000000000..ebe2c71010 --- /dev/null +++ b/account_reconciliation_widget/models/reconciliation_widget.py @@ -0,0 +1,1180 @@ +import copy + +from odoo import _, api, models +from odoo.exceptions import UserError +from odoo.osv import expression +from odoo.tools.misc import format_date, formatLang, parse_date + + +class AccountReconciliation(models.AbstractModel): + _name = "account.reconciliation.widget" + _description = "Account Reconciliation widget" + + #################################################### + # Public + #################################################### + + @api.model + def process_bank_statement_line(self, st_line_ids, data): + """Handles data sent from the bank statement reconciliation widget + (and can otherwise serve as an old-API bridge) + + :param st_line_ids + :param list of dicts data: must contains the keys + 'counterpart_aml_dicts', 'payment_aml_ids' and 'new_aml_dicts', + whose value is the same as described in process_reconciliation + except that ids are used instead of recordsets. + :returns dict: used as a hook to add additional keys. + """ + st_lines = self.env["account.bank.statement.line"].browse(st_line_ids) + AccountMoveLine = self.env["account.move.line"] + ctx = dict(self._context, force_price_include=False) + + processed_moves = self.env["account.move"] + for st_line, datum in zip(st_lines, copy.deepcopy(data)): + payment_aml_rec = AccountMoveLine.browse(datum.get("payment_aml_ids", [])) + + for aml_dict in datum.get("counterpart_aml_dicts", []): + aml_dict["move_line"] = AccountMoveLine.browse( + aml_dict["counterpart_aml_id"] + ) + del aml_dict["counterpart_aml_id"] + + if datum.get("partner_id") is not None: + st_line.write({"partner_id": datum["partner_id"]}) + + ctx["default_to_check"] = datum.get("to_check") + moves = st_line.with_context(ctx).process_reconciliation( + datum.get("counterpart_aml_dicts", []), + payment_aml_rec, + datum.get("new_aml_dicts", []), + ) + processed_moves = processed_moves | moves + return { + "moves": processed_moves.ids, + "statement_line_ids": processed_moves.mapped( + "line_ids.statement_line_id" + ).ids, + } + + @api.model + def get_move_lines_for_bank_statement_line( + self, + st_line_id, + partner_id=None, + excluded_ids=None, + search_str=False, + offset=0, + limit=None, + mode=None, + ): + """Returns move lines for the bank statement reconciliation widget, + formatted as a list of dicts + + :param st_line_id: ids of the statement lines + :param partner_id: optional partner id to select only the moves + line corresponding to the partner + :param excluded_ids: optional move lines ids excluded from the + result + :param search_str: optional search (can be the amout, display_name, + partner name, move line name) + :param offset: useless but kept in stable to preserve api + :param limit: number of the result to search + :param mode: 'rp' for receivable/payable or 'other' + """ + st_line = self.env["account.bank.statement.line"].browse(st_line_id) + + # Blue lines = payment on bank account not assigned to a statement yet + aml_accounts = [ + st_line.journal_id.default_credit_account_id.id, + st_line.journal_id.default_debit_account_id.id, + ] + + if partner_id is None: + partner_id = st_line.partner_id.id + + domain = self._domain_move_lines_for_reconciliation( + st_line, + aml_accounts, + partner_id, + excluded_ids=excluded_ids, + search_str=search_str, + mode=mode, + ) + recs_count = self.env["account.move.line"].search_count(domain) + + from_clause, where_clause, where_clause_params = ( + self.env["account.move.line"]._where_calc(domain).get_sql() + ) + query_str = """ + SELECT "account_move_line".id FROM {from_clause} + {where_str} + ORDER BY ("account_move_line".debit - + "account_move_line".credit) = {amount} DESC, + "account_move_line".date_maturity ASC, + "account_move_line".id ASC + {limit_str} + """.format( + from_clause=from_clause, + where_str=where_clause and (" WHERE %s" % where_clause) or "", + amount=st_line.amount, + limit_str=limit and " LIMIT %s" or "", + ) + params = where_clause_params + (limit and [limit] or []) + self.env["account.move"].flush() + self.env["account.move.line"].flush() + self.env["account.bank.statement"].flush() + self._cr.execute(query_str, params) + res = self._cr.fetchall() + + aml_recs = self.env["account.move.line"].browse([i[0] for i in res]) + target_currency = ( + st_line.currency_id + or st_line.journal_id.currency_id + or st_line.journal_id.company_id.currency_id + ) + return self._prepare_move_lines( + aml_recs, + target_currency=target_currency, + target_date=st_line.date, + recs_count=recs_count, + ) + + @api.model + def _get_bank_statement_line_partners(self, st_lines): + params = [] + + # Add the res.partner.ban's IR rules. In case partners are not shared + # between companies, identical bank accounts may exist in a company we + # don't have access to. + ir_rules_query = self.env["res.partner.bank"]._where_calc([]) + self.env["res.partner.bank"]._apply_ir_rules(ir_rules_query, "read") + from_clause, where_clause, where_clause_params = ir_rules_query.get_sql() + if where_clause: + where_bank = ("AND %s" % where_clause).replace("res_partner_bank", "bank") + params += where_clause_params + else: + where_bank = "" + + # Add the res.partner's IR rules. In case partners are not shared + # between companies, identical partners may exist in a company we don't + # have access to. + ir_rules_query = self.env["res.partner"]._where_calc([]) + self.env["res.partner"]._apply_ir_rules(ir_rules_query, "read") + from_clause, where_clause, where_clause_params = ir_rules_query.get_sql() + if where_clause: + where_partner = ("AND %s" % where_clause).replace("res_partner", "p3") + params += where_clause_params + else: + where_partner = "" + + query = """ + SELECT + st_line.id AS id, + COALESCE(p1.id,p2.id,p3.id) AS partner_id + FROM account_bank_statement_line st_line + """ + query += "INNER JOIN account_move m ON m.id = st_line.move_id \n" + query += ( + "LEFT JOIN res_partner_bank bank ON bank.id = m.partner_bank_id OR " + "bank.sanitized_acc_number " + "ILIKE regexp_replace(st_line.account_number, '\\W+', '', 'g') %s\n" + % (where_bank) + ) + query += "LEFT JOIN res_partner p1 ON st_line.partner_id=p1.id \n" + query += "LEFT JOIN res_partner p2 ON bank.partner_id=p2.id \n" + # By definition the commercial partner_id doesn't have a parent_id set + query += ( + "LEFT JOIN res_partner p3 ON p3.name ILIKE st_line.partner_name %s " + "AND p3.parent_id is NULL \n" % (where_partner) + ) + query += "WHERE st_line.id IN %s" + + params += [tuple(st_lines.ids)] + + self._cr.execute(query, params) + + result = {} + for res in self._cr.dictfetchall(): + result[res["id"]] = res["partner_id"] + return result + + @api.model + def get_bank_statement_line_data(self, st_line_ids, excluded_ids=None): + """Returns the data required to display a reconciliation widget, for + each statement line in self + + :param st_line_id: ids of the statement lines + :param excluded_ids: optional move lines ids excluded from the + result + """ + results = { + "lines": [], + "value_min": 0, + "value_max": 0, + "reconciled_aml_ids": [], + } + + if not st_line_ids: + return results + + excluded_ids = excluded_ids or [] + + # Make a search to preserve the table's order. + bank_statement_lines = self.env["account.bank.statement.line"].search( + [("id", "in", st_line_ids)] + ) + results["value_max"] = len(bank_statement_lines) + reconcile_model = self.env["account.reconcile.model"].search( + [("rule_type", "!=", "writeoff_button")] + ) + + # Search for missing partners when opening the reconciliation widget. + if bank_statement_lines: + partner_map = self._get_bank_statement_line_partners(bank_statement_lines) + matching_amls = reconcile_model._apply_rules( + bank_statement_lines, excluded_ids=excluded_ids, partner_map=partner_map + ) + + # Iterate on st_lines to keep the same order in the results list. + bank_statements_left = self.env["account.bank.statement"] + for line in bank_statement_lines: + if matching_amls[line.id].get("status") == "reconciled": + reconciled_move_lines = matching_amls[line.id].get("reconciled_lines") + results["value_min"] += 1 + results["reconciled_aml_ids"] += ( + reconciled_move_lines and reconciled_move_lines.ids or [] + ) + else: + aml_ids = matching_amls[line.id]["aml_ids"] + bank_statements_left += line.statement_id + target_currency = ( + line.currency_id + or line.journal_id.currency_id + or line.journal_id.company_id.currency_id + ) + + amls = aml_ids and self.env["account.move.line"].browse(aml_ids) + line_vals = { + "st_line": self._get_statement_line(line), + "reconciliation_proposition": aml_ids + and self._prepare_move_lines( + amls, target_currency=target_currency, target_date=line.date + ) + or [], + "model_id": matching_amls[line.id].get("model") + and matching_amls[line.id]["model"].id, + "write_off": matching_amls[line.id].get("status") == "write_off", + } + if not line.partner_id and partner_map.get(line.id): + partner = self.env["res.partner"].browse(partner_map[line.id]) + line_vals.update( + { + "partner_id": partner.id, + "partner_name": partner.name, + } + ) + results["lines"].append(line_vals) + + return results + + @api.model + def get_bank_statement_data(self, bank_statement_line_ids, srch_domain=None): + """Get statement lines of the specified statements or all unreconciled + statement lines and try to automatically reconcile them / find them + a partner. + Return ids of statement lines left to reconcile and other data for + the reconciliation widget. + + :param bank_statement_line_ids: ids of the bank statement lines + """ + if not bank_statement_line_ids: + return {} + suspense_moves_mode = self._context.get("suspense_moves_mode") + bank_statements = ( + self.env["account.bank.statement.line"] + .browse(bank_statement_line_ids) + .mapped("statement_id") + ) + + query = """ + SELECT line.id + FROM account_bank_statement_line line + LEFT JOIN res_partner p on p.id = line.partner_id + WHERE line.account_id IS NULL + AND line.amount != 0.0 + AND line.id IN %(ids)s + {cond} + GROUP BY line.id + """.format( + cond=not suspense_moves_mode + and """AND NOT EXISTS (SELECT 1 from account_move_line aml + WHERE aml.statement_line_id = line.id)""" + or "", + ) + self.env.cr.execute(query, {"ids": tuple(bank_statement_line_ids)}) + + domain = [["id", "in", [line.get("id") for line in self.env.cr.dictfetchall()]]] + if srch_domain is not None: + domain += srch_domain + bank_statement_lines = self.env["account.bank.statement.line"].search(domain) + + results = self.get_bank_statement_line_data(bank_statement_lines.ids) + bank_statement_lines_left = self.env["account.bank.statement.line"].browse( + [line["st_line"]["id"] for line in results["lines"]] + ) + bank_statements_left = bank_statement_lines_left.mapped("statement_id") + + results.update( + { + "statement_name": len(bank_statements_left) == 1 + and bank_statements_left.name + or False, + "journal_id": bank_statements + and bank_statements[0].journal_id.id + or False, + "notifications": [], + } + ) + + if len(results["lines"]) < len(bank_statement_lines): + results["notifications"].append( + { + "type": "info", + "template": "reconciliation.notification.reconciled", + "reconciled_aml_ids": results["reconciled_aml_ids"], + "nb_reconciled_lines": results["value_min"], + "details": { + "name": _("Journal Items"), + "model": "account.move.line", + "ids": results["reconciled_aml_ids"], + }, + } + ) + + return results + + @api.model + def get_move_lines_for_manual_reconciliation( + self, + account_id, + partner_id=False, + excluded_ids=None, + search_str=False, + offset=0, + limit=None, + target_currency_id=False, + ): + """Returns unreconciled move lines for an account or a partner+account, + formatted for the manual reconciliation widget""" + + Account_move_line = self.env["account.move.line"] + Account = self.env["account.account"] + Currency = self.env["res.currency"] + + domain = self._domain_move_lines_for_manual_reconciliation( + account_id, partner_id, excluded_ids, search_str + ) + recs_count = Account_move_line.search_count(domain) + lines = Account_move_line.search( + domain, limit=limit, order="date_maturity desc, id desc" + ) + if target_currency_id: + target_currency = Currency.browse(target_currency_id) + else: + account = Account.browse(account_id) + target_currency = account.currency_id or account.company_id.currency_id + return self._prepare_move_lines( + lines, target_currency=target_currency, recs_count=recs_count + ) + + @api.model + def get_all_data_for_manual_reconciliation(self, partner_ids, account_ids): + """Returns the data required for the invoices & payments matching of + partners/accounts. + If an argument is None, fetch all related reconciliations. Use [] to + fetch nothing. + """ + MoveLine = self.env["account.move.line"] + aml_ids = ( + self._context.get("active_ids") + and self._context.get("active_model") == "account.move.line" + and tuple(self._context.get("active_ids")) + ) + if aml_ids: + aml = MoveLine.browse(aml_ids) + aml._check_reconcile_validity() + account = aml[0].account_id + currency = account.currency_id or account.company_id.currency_id + return { + "accounts": [ + { + "reconciliation_proposition": self._prepare_move_lines( + aml, target_currency=currency + ), + "company_id": account.company_id.id, + "currency_id": currency.id, + "mode": "accounts", + "account_id": account.id, + "account_name": account.name, + "account_code": account.code, + } + ], + "customers": [], + "suppliers": [], + } + # If we have specified partner_ids, don't return the list of + # reconciliation for specific accounts as it will show entries that are + # not reconciled with other partner. Asking for a specific partner on a + # specific account is never done. + accounts_data = [] + if not partner_ids or not any(partner_ids): + accounts_data = self.get_data_for_manual_reconciliation( + "account", account_ids + ) + return { + "customers": self.get_data_for_manual_reconciliation( + "partner", partner_ids, "receivable" + ), + "suppliers": self.get_data_for_manual_reconciliation( + "partner", partner_ids, "payable" + ), + "accounts": accounts_data, + } + + @api.model + def get_data_for_manual_reconciliation( + self, res_type, res_ids=None, account_type=None + ): + """Returns the data required for the invoices & payments matching of + partners/accounts (list of dicts). + If no res_ids is passed, returns data for all partners/accounts that can + be reconciled. + + :param res_type: either 'partner' or 'account' + :param res_ids: ids of the partners/accounts to reconcile, use None to + fetch data indiscriminately of the id, use [] to prevent from + fetching any data at all. + :param account_type: if a partner is both customer and vendor, you can + use 'payable' to reconcile the vendor-related journal entries and + 'receivable' for the customer-related entries. + """ + + Account = self.env["account.account"] + Partner = self.env["res.partner"] + + if res_ids is not None and len(res_ids) == 0: + # Note : this short-circuiting is better for performances, but also + # required since postgresql doesn't implement empty list (so 'AND id + # in ()' is useless) + return [] + res_ids = res_ids and tuple(res_ids) + + assert res_type in ("partner", "account") + assert account_type in ("payable", "receivable", None) + is_partner = res_type == "partner" + res_alias = is_partner and "p" or "a" + aml_ids = ( + self._context.get("active_ids") + and self._context.get("active_model") == "account.move.line" + and tuple(self._context.get("active_ids")) + ) + all_entries = self._context.get("all_entries", False) + all_entries_query = """ + AND EXISTS ( + SELECT NULL + FROM account_move_line l + JOIN account_move move ON l.move_id = move.id + JOIN account_journal journal ON l.journal_id = journal.id + WHERE l.account_id = a.id + {inner_where} + AND l.amount_residual != 0 + AND (move.state = 'posted' OR (move.state = 'draft' + AND journal.post_at = 'bank_rec')) + ) + """.format( + inner_where=is_partner and "AND l.partner_id = p.id" or " " + ) + only_dual_entries_query = """ + AND EXISTS ( + SELECT NULL + FROM account_move_line l + JOIN account_move move ON l.move_id = move.id + JOIN account_journal journal ON l.journal_id = journal.id + WHERE l.account_id = a.id + {inner_where} + AND l.amount_residual > 0 + AND (move.state = 'posted' + OR (move.state = 'draft' AND journal.post_at = 'bank_rec')) + ) + AND EXISTS ( + SELECT NULL + FROM account_move_line l + JOIN account_move move ON l.move_id = move.id + JOIN account_journal journal ON l.journal_id = journal.id + WHERE l.account_id = a.id + {inner_where} + AND l.amount_residual < 0 + AND (move.state = 'posted' + OR (move.state = 'draft' AND journal.post_at = 'bank_rec')) + ) + """.format( + inner_where=is_partner and "AND l.partner_id = p.id" or " " + ) + query = """ + SELECT {select} account_id, account_name, account_code, max_date + FROM ( + SELECT {inner_select} + a.id AS account_id, + a.name AS account_name, + a.code AS account_code, + MAX(l.write_date) AS max_date + FROM + account_move_line l + RIGHT JOIN account_account a ON (a.id = l.account_id) + RIGHT JOIN account_account_type at + ON (at.id = a.user_type_id) + {inner_from} + WHERE + a.reconcile IS TRUE + AND l.full_reconcile_id is NULL + {where1} + {where2} + {where3} + AND l.company_id = {company_id} + {where4} + {where5} + GROUP BY {group_by1} a.id, a.name, a.code {group_by2} + {order_by} + ) as s + {outer_where} + """.format( + select=is_partner + and "partner_id, partner_name, to_char(last_time_entries_checked, " + "'YYYY-MM-DD') AS last_time_entries_checked," + or " ", + inner_select=is_partner + and "p.id AS partner_id, p.name AS partner_name, " + "p.last_time_entries_checked AS last_time_entries_checked," + or " ", + inner_from=is_partner + and "RIGHT JOIN res_partner p ON (l.partner_id = p.id)" + or " ", + where1=is_partner + and " " + or "AND ((at.type <> 'payable' AND at.type <> 'receivable') " + "OR l.partner_id IS NULL)", + where2=account_type and "AND at.type = %(account_type)s" or "", + where3=res_ids and "AND " + res_alias + ".id in %(res_ids)s" or "", + company_id=self.env.company.id, + where4=aml_ids and "AND l.id IN %(aml_ids)s" or " ", + where5=all_entries and all_entries_query or only_dual_entries_query, + group_by1=is_partner and "l.partner_id, p.id," or " ", + group_by2=is_partner and ", p.last_time_entries_checked" or " ", + order_by=is_partner + and "ORDER BY p.last_time_entries_checked" + or "ORDER BY a.code", + outer_where=is_partner + and "WHERE (last_time_entries_checked IS NULL " + "OR max_date > last_time_entries_checked)" + or " ", + ) + self.env["account.move.line"].flush() + self.env["account.account"].flush() + self.env.cr.execute(query, locals()) + + # Apply ir_rules by filtering out + rows = self.env.cr.dictfetchall() + ids = [x["account_id"] for x in rows] + allowed_ids = set(Account.browse(ids).ids) + rows = [row for row in rows if row["account_id"] in allowed_ids] + if is_partner: + ids = [x["partner_id"] for x in rows] + allowed_ids = set(Partner.browse(ids).ids) + rows = [row for row in rows if row["partner_id"] in allowed_ids] + + # Keep mode for future use in JS + if res_type == "account": + mode = "accounts" + else: + mode = "customers" if account_type == "receivable" else "suppliers" + + # Fetch other data + for row in rows: + account = Account.browse(row["account_id"]) + currency = account.currency_id or account.company_id.currency_id + row["currency_id"] = currency.id + partner_id = is_partner and row["partner_id"] or None + rec_prop = ( + aml_ids + and self.env["account.move.line"].browse(aml_ids) + or self._get_move_line_reconciliation_proposition( + account.id, partner_id + ) + ) + row["reconciliation_proposition"] = self._prepare_move_lines( + rec_prop, target_currency=currency + ) + row["mode"] = mode + row["company_id"] = account.company_id.id + + # Return the partners with a reconciliation proposition first, since + # they are most likely to be reconciled. + return [r for r in rows if r["reconciliation_proposition"]] + [ + r for r in rows if not r["reconciliation_proposition"] + ] + + @api.model + def process_move_lines(self, data): + """Used to validate a batch of reconciliations in a single call + :param data: list of dicts containing: + - 'type': either 'partner' or 'account' + - 'id': id of the affected res.partner or account.account + - 'mv_line_ids': ids of existing account.move.line to reconcile + - 'new_mv_line_dicts': list of dicts containing values suitable for + account_move_line.create() + """ + + Partner = self.env["res.partner"] + + for datum in data: + if ( + len(datum["mv_line_ids"]) >= 1 + or len(datum["mv_line_ids"]) + len(datum["new_mv_line_dicts"]) >= 2 + ): + self._process_move_lines( + datum["mv_line_ids"], datum["new_mv_line_dicts"] + ) + + if datum["type"] == "partner": + partners = Partner.browse(datum["id"]) + partners.mark_as_reconciled() + + #################################################### + # Private + #################################################### + + def _str_domain_for_mv_line(self, search_str): + return [ + "|", + ("account_id.code", "ilike", search_str), + "|", + ("move_id.name", "ilike", search_str), + "|", + ("move_id.ref", "ilike", search_str), + "|", + ("date_maturity", "like", parse_date(self.env, search_str)), + "&", + ("name", "!=", "/"), + ("name", "ilike", search_str), + ] + + @api.model + def _domain_move_lines(self, search_str): + """Returns the domain from the search_str search + :param search_str: search string + """ + if not search_str: + return [] + str_domain = self._str_domain_for_mv_line(search_str) + if search_str[0] in ["-", "+"]: + try: + amounts_str = search_str.split("|") + for amount_str in amounts_str: + amount = ( + amount_str[0] == "-" + and float(amount_str) + or float(amount_str[1:]) + ) + amount_domain = [ + "|", + ("amount_residual", "=", amount), + "|", + ("amount_residual_currency", "=", amount), + "|", + ( + amount_str[0] == "-" and "credit" or "debit", + "=", + float(amount_str[1:]), + ), + ("amount_currency", "=", amount), + ] + str_domain = expression.OR([str_domain, amount_domain]) + except Exception: + pass + else: + try: + amount = float(search_str) + amount_domain = [ + "|", + ("amount_residual", "=", amount), + "|", + ("amount_residual_currency", "=", amount), + "|", + ("amount_residual", "=", -amount), + "|", + ("amount_residual_currency", "=", -amount), + "&", + ("account_id.internal_type", "=", "liquidity"), + "|", + "|", + "|", + ("debit", "=", amount), + ("credit", "=", amount), + ("amount_currency", "=", amount), + ("amount_currency", "=", -amount), + ] + str_domain = expression.OR([str_domain, amount_domain]) + except Exception: + pass + return str_domain + + @api.model + def _domain_move_lines_for_reconciliation( + self, + st_line, + aml_accounts, + partner_id, + excluded_ids=None, + search_str=False, + mode="rp", + ): + """Return the domain for account.move.line records which can be used for + bank statement reconciliation. + + :param aml_accounts: + :param partner_id: + :param excluded_ids: + :param search_str: + :param mode: 'rp' for receivable/payable or 'other' + """ + AccountMoveLine = self.env["account.move.line"] + + # Always exclude the journal items that have been marked as + # 'to be checked' in a former bank statement reconciliation + to_check_excluded = AccountMoveLine.search( + AccountMoveLine._get_suspense_moves_domain() + ).ids + if excluded_ids is None: + excluded_ids = [] + excluded_ids.extend(to_check_excluded) + + domain_reconciliation = [ + "&", + "&", + "&", + ("statement_line_id", "=", False), + ("account_id", "in", aml_accounts), + ("payment_id", "<>", False), + ("balance", "!=", 0.0), + ] + + # default domain matching + domain_matching = [ + "&", + "&", + ("reconciled", "=", False), + ("account_id.reconcile", "=", True), + ("balance", "!=", 0.0), + ] + + domain = expression.OR([domain_reconciliation, domain_matching]) + if partner_id: + domain = expression.AND([domain, [("partner_id", "=", partner_id)]]) + if mode == "rp": + domain = expression.AND( + [ + domain, + [ + ( + "account_id.internal_type", + "in", + ["receivable", "payable", "liquidity"], + ) + ], + ] + ) + else: + domain = expression.AND( + [ + domain, + [ + ( + "account_id.internal_type", + "not in", + ["receivable", "payable", "liquidity"], + ) + ], + ] + ) + + # Domain factorized for all reconciliation use cases + if search_str: + str_domain = self._domain_move_lines(search_str=search_str) + str_domain = expression.OR( + [str_domain, [("partner_id.name", "ilike", search_str)]] + ) + domain = expression.AND([domain, str_domain]) + + if excluded_ids: + domain = expression.AND([[("id", "not in", excluded_ids)], domain]) + # filter on account.move.line having the same company as the statement + # line + domain = expression.AND([domain, [("company_id", "=", st_line.company_id.id)]]) + + # take only moves in valid state. Draft is accepted only when "Post At" + # is set to "Bank Reconciliation" in the associated journal + domain_post_at = [ + "|", + "&", + ("move_id.state", "=", "draft"), + ("journal_id.post_at", "=", "bank_rec"), + ("move_id.state", "not in", ["draft", "cancel"]), + ] + domain = expression.AND([domain, domain_post_at]) + + if st_line.company_id.account_bank_reconciliation_start: + domain = expression.AND( + [ + domain, + [ + ( + "date", + ">=", + st_line.company_id.account_bank_reconciliation_start, + ) + ], + ] + ) + return domain + + @api.model + def _domain_move_lines_for_manual_reconciliation( + self, account_id, partner_id=False, excluded_ids=None, search_str=False + ): + """ Create domain criteria that are relevant to manual reconciliation. """ + domain = [ + "&", + "&", + ("reconciled", "=", False), + ("account_id", "=", account_id), + "|", + ("move_id.state", "=", "posted"), + "&", + ("move_id.state", "=", "draft"), + ("move_id.journal_id.post_at", "=", "bank_rec"), + ] + domain = expression.AND([domain, [("balance", "!=", 0.0)]]) + if partner_id: + domain = expression.AND([domain, [("partner_id", "=", partner_id)]]) + if excluded_ids: + domain = expression.AND([[("id", "not in", excluded_ids)], domain]) + if search_str: + str_domain = self._domain_move_lines(search_str=search_str) + domain = expression.AND([domain, str_domain]) + # filter on account.move.line having the same company as the given account + account = self.env["account.account"].browse(account_id) + domain = expression.AND([domain, [("company_id", "=", account.company_id.id)]]) + return domain + + @api.model + def _prepare_move_lines( + self, move_lines, target_currency=False, target_date=False, recs_count=0 + ): + """Returns move lines formatted for the manual/bank reconciliation + widget + + :param move_line_ids: + :param target_currency: currency (browse) you want the move line + debit/credit converted into + :param target_date: date to use for the monetary conversion + """ + ret = [] + + for line in move_lines: + company_currency = line.company_id.currency_id + line_currency = ( + (line.currency_id and line.amount_currency) + and line.currency_id + or company_currency + ) + ret_line = { + "id": line.id, + "name": line.name + and line.name != "/" + and line.move_id.name != line.name + and line.move_id.name + ": " + line.name + or line.move_id.name, + "ref": line.move_id.ref or "", + # For reconciliation between statement transactions and already + # registered payments (eg. checks) + # NB : we don't use the 'reconciled' field because the line + # we're selecting is not the one that gets reconciled + "account_id": [line.account_id.id, line.account_id.display_name], + "already_paid": line.account_id.internal_type == "liquidity", + "account_code": line.account_id.code, + "account_name": line.account_id.name, + "account_type": line.account_id.internal_type, + "date_maturity": format_date(self.env, line.date_maturity), + "date": format_date(self.env, line.date), + "journal_id": [line.journal_id.id, line.journal_id.display_name], + "partner_id": line.partner_id.id, + "partner_name": line.partner_id.name, + "currency_id": line_currency.id, + } + + debit = line.debit + credit = line.credit + amount = line.amount_residual + amount_currency = line.amount_residual_currency + + # For already reconciled lines, don't use amount_residual(_currency) + if line.account_id.internal_type == "liquidity": + amount = debit - credit + amount_currency = line.amount_currency + + target_currency = target_currency or company_currency + + # Use case: + # Let's assume that company currency is in USD and that we have the + # 3 following move lines + # Debit Credit Amount currency Currency + # 1) 25 0 0 NULL + # 2) 17 0 25 EUR + # 3) 33 0 25 YEN + # + # If we ask to see the information in the reconciliation widget in + # company currency, we want to see The following information + # 1) 25 USD (no currency information) + # 2) 17 USD [25 EUR] (show 25 euro in currency information, + # in the little bill) + # 3) 33 USD [25 YEN] (show 25 yen in currency information) + # + # If we ask to see the information in another currency than the + # company let's say EUR + # 1) 35 EUR [25 USD] + # 2) 25 EUR (no currency information) + # 3) 50 EUR [25 YEN] + # In that case, we have to convert the debit-credit to the currency + # we want and we show next to it the value of the amount_currency or + # the debit-credit if no amount currency + if target_currency == company_currency: + if line_currency == target_currency: + amount = amount + amount_currency = "" + total_amount = debit - credit + total_amount_currency = "" + else: + amount = amount + amount_currency = amount_currency + total_amount = debit - credit + total_amount_currency = line.amount_currency + + if target_currency != company_currency: + if line_currency == target_currency: + amount = amount_currency + amount_currency = "" + total_amount = line.amount_currency + total_amount_currency = "" + else: + amount_currency = line.currency_id and amount_currency or amount + company = line.account_id.company_id + date = target_date or line.date + amount = company_currency._convert( + amount, target_currency, company, date + ) + total_amount = company_currency._convert( + (line.debit - line.credit), target_currency, company, date + ) + total_amount_currency = ( + line.currency_id + and line.amount_currency + or (line.debit - line.credit) + ) + + ret_line["recs_count"] = recs_count + ret_line["debit"] = amount > 0 and amount or 0 + ret_line["credit"] = amount < 0 and -amount or 0 + ret_line["amount_currency"] = amount_currency + ret_line["amount_str"] = formatLang( + self.env, abs(amount), currency_obj=target_currency + ) + ret_line["total_amount_str"] = formatLang( + self.env, abs(total_amount), currency_obj=target_currency + ) + ret_line["amount_currency_str"] = ( + amount_currency + and formatLang( + self.env, abs(amount_currency), currency_obj=line_currency + ) + or "" + ) + ret_line["total_amount_currency_str"] = ( + total_amount_currency + and formatLang( + self.env, abs(total_amount_currency), currency_obj=line_currency + ) + or "" + ) + ret.append(ret_line) + return ret + + @api.model + def _get_statement_line(self, st_line): + """Returns the data required by the bank statement reconciliation + widget to display a statement line""" + + statement_currency = ( + st_line.journal_id.currency_id or st_line.journal_id.company_id.currency_id + ) + if st_line.amount_currency and st_line.currency_id: + amount = st_line.amount_currency + amount_currency = st_line.amount + amount_currency_str = formatLang( + self.env, abs(amount_currency), currency_obj=statement_currency + ) + else: + amount = st_line.amount + amount_currency = amount + amount_currency_str = "" + amount_str = formatLang( + self.env, + abs(amount), + currency_obj=st_line.currency_id or statement_currency, + ) + + data = { + "id": st_line.id, + "ref": st_line.ref, + "note": st_line.note or "", + "name": st_line.name, + "date": format_date(self.env, st_line.date), + "amount": amount, + "amount_str": amount_str, # Amount in the statement line currency + "currency_id": st_line.currency_id.id or statement_currency.id, + "partner_id": st_line.partner_id.id, + "journal_id": st_line.journal_id.id, + "statement_id": st_line.statement_id.id, + "account_id": [ + st_line.journal_id.default_debit_account_id.id, + st_line.journal_id.default_debit_account_id.display_name, + ], + "account_code": st_line.journal_id.default_debit_account_id.code, + "account_name": st_line.journal_id.default_debit_account_id.name, + "partner_name": st_line.partner_id.name, + "communication_partner_name": st_line.partner_name, + # Amount in the statement currency + "amount_currency_str": amount_currency_str, + # Amount in the statement currency + "amount_currency": amount_currency, + "has_no_partner": not st_line.partner_id.id, + "company_id": st_line.company_id.id, + } + if st_line.partner_id: + data["open_balance_account_id"] = ( + amount > 0 + and st_line.partner_id.property_account_receivable_id.id + or st_line.partner_id.property_account_payable_id.id + ) + + return data + + @api.model + def _get_move_line_reconciliation_proposition(self, account_id, partner_id=None): + """ Returns two lines whose amount are opposite """ + + Account_move_line = self.env["account.move.line"] + + ir_rules_query = Account_move_line._where_calc([]) + Account_move_line._apply_ir_rules(ir_rules_query, "read") + from_clause, where_clause, where_clause_params = ir_rules_query.get_sql() + where_str = where_clause and (" WHERE %s" % where_clause) or "" + + # Get pairs + query = """ + SELECT a.id, b.id + FROM account_move_line a, account_move_line b, + account_move move_a, account_move move_b, + account_journal journal_a, account_journal journal_b + WHERE a.id != b.id + AND move_a.id = a.move_id + AND (move_a.state = 'posted' + OR (move_a.state = 'draft' AND journal_a.post_at = 'bank_rec')) + AND move_a.journal_id = journal_a.id + AND move_b.id = b.move_id + AND move_b.journal_id = journal_b.id + AND (move_b.state = 'posted' + OR (move_b.state = 'draft' AND journal_b.post_at = 'bank_rec')) + AND a.amount_residual = -b.amount_residual + AND a.balance != 0.0 + AND b.balance != 0.0 + AND NOT a.reconciled + AND a.account_id = %s + AND (%s IS NULL AND b.account_id = %s) + AND (%s IS NULL AND NOT b.reconciled OR b.id = %s) + AND (%s is NULL OR (a.partner_id = %s AND b.partner_id = %s)) + AND a.id IN (SELECT "account_move_line".id FROM {0}) + AND b.id IN (SELECT "account_move_line".id FROM {0}) + ORDER BY a.date desc + LIMIT 1 + """.format( + from_clause + where_str + ) + move_line_id = self.env.context.get("move_line_id") or None + params = ( + [ + account_id, + move_line_id, + account_id, + move_line_id, + move_line_id, + partner_id, + partner_id, + partner_id, + ] + + where_clause_params + + where_clause_params + ) + self.env.cr.execute(query, params) + + pairs = self.env.cr.fetchall() + + if pairs: + return Account_move_line.browse(pairs[0]) + return Account_move_line + + @api.model + def _process_move_lines(self, move_line_ids, new_mv_line_dicts): + """Create new move lines from new_mv_line_dicts (if not empty) then call + reconcile_partial on self and new move lines + + :param new_mv_line_dicts: list of dicts containing values suitable for + account_move_line.create() + """ + if len(move_line_ids) < 1 or len(move_line_ids) + len(new_mv_line_dicts) < 2: + raise UserError(_("A reconciliation must involve at least 2 move lines.")) + + account_move_line = self.env["account.move.line"].browse(move_line_ids) + writeoff_lines = self.env["account.move.line"] + + # Create writeoff move lines + if len(new_mv_line_dicts) > 0: + company_currency = account_move_line[0].account_id.company_id.currency_id + same_currency = False + currencies = list( + {aml.currency_id or company_currency for aml in account_move_line} + ) + if len(currencies) == 1 and currencies[0] != company_currency: + same_currency = True + # We don't have to convert debit/credit to currency as all values in + # the reconciliation widget are displayed in company currency + # If all the lines are in the same currency, create writeoff entry + # with same currency also + for mv_line_dict in new_mv_line_dicts: + if not same_currency: + mv_line_dict["amount_currency"] = False + writeoff_lines += account_move_line._create_writeoff([mv_line_dict]) + + (account_move_line + writeoff_lines).reconcile() + else: + account_move_line.reconcile() diff --git a/account_reconciliation_widget/models/res_company.py b/account_reconciliation_widget/models/res_company.py new file mode 100644 index 0000000000..dacab70da5 --- /dev/null +++ b/account_reconciliation_widget/models/res_company.py @@ -0,0 +1,14 @@ +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = "res.company" + + account_bank_reconciliation_start = fields.Date( + string="Bank Reconciliation Threshold", + help="The bank reconciliation widget won't ask to reconcile payments " + "older than this date.\n" + "This is useful if you install accounting after having used invoicing " + "for some time and don't want to reconcile all the past payments with " + "bank statements.", + ) diff --git a/account_reconciliation_widget/static/src/js/reconciliation/reconciliation_action.js b/account_reconciliation_widget/static/src/js/reconciliation/reconciliation_action.js new file mode 100644 index 0000000000..3acf3ba443 --- /dev/null +++ b/account_reconciliation_widget/static/src/js/reconciliation/reconciliation_action.js @@ -0,0 +1,547 @@ +odoo.define("account.ReconciliationClientAction", function (require) { + "use strict"; + + var AbstractAction = require("web.AbstractAction"); + var ReconciliationModel = require("account.ReconciliationModel"); + var ReconciliationRenderer = require("account.ReconciliationRenderer"); + var core = require("web.core"); + var QWeb = core.qweb; + + /** + * Widget used as action for 'account.bank.statement' reconciliation + */ + var StatementAction = AbstractAction.extend({ + hasControlPanel: true, + withSearchBar: true, + loadControlPanel: true, + title: core._t("Bank Reconciliation"), + contentTemplate: "reconciliation", + custom_events: { + change_mode: "_onAction", + change_filter: "_onAction", + change_offset: "_onAction", + change_partner: "_onAction", + add_proposition: "_onAction", + remove_proposition: "_onAction", + update_proposition: "_onAction", + create_proposition: "_onAction", + getPartialAmount: "_onActionPartialAmount", + quick_create_proposition: "_onAction", + partial_reconcile: "_onAction", + validate: "_onValidate", + close_statement: "_onCloseStatement", + load_more: "_onLoadMore", + reload: "reload", + search: "_onSearch", + navigation_move: "_onNavigationMove", + }, + config: _.extend({}, AbstractAction.prototype.config, { + // Used to instantiate the model + Model: ReconciliationModel.StatementModel, + // Used to instantiate the action interface + ActionRenderer: ReconciliationRenderer.StatementRenderer, + // Used to instantiate each widget line + LineRenderer: ReconciliationRenderer.LineRenderer, + // Used context params + params: ["statement_line_ids"], + // Number of statements/partners/accounts to display + defaultDisplayQty: 10, + // Number of moves lines displayed in 'match' mode + limitMoveLines: 15, + }), + + _onNavigationMove: function (ev) { + var non_reconciled_keys = _.keys( + _.pick(this.model.lines, function (value, key, object) { + return !value.reconciled; + }) + ); + var currentIndex = _.indexOf(non_reconciled_keys, ev.data.handle); + var widget = false; + switch (ev.data.direction) { + case "up": + ev.stopPropagation(); + widget = this._getWidget(non_reconciled_keys[currentIndex - 1]); + break; + case "down": + ev.stopPropagation(); + widget = this._getWidget(non_reconciled_keys[currentIndex + 1]); + break; + case "validate": + ev.stopPropagation(); + widget = this._getWidget(non_reconciled_keys[currentIndex]); + widget.$("caption .o_buttons button:visible").click(); + break; + } + if (widget) widget.$el.focus(); + }, + + /** + * @override + * @param {Object} params + * @param {Object} params.context + * + */ + init: function (parent, params) { + this._super.apply(this, arguments); + this.action_manager = parent; + this.params = params; + this.controlPanelParams.modelName = "account.bank.statement.line"; + this.model = new this.config.Model(this, { + modelName: "account.reconciliation.widget", + defaultDisplayQty: + (params.params && params.params.defaultDisplayQty) || + this.config.defaultDisplayQty, + limitMoveLines: + (params.params && params.params.limitMoveLines) || + this.config.limitMoveLines, + }); + this.widgets = []; + // Adding values from the context is necessary to put this information in the url via the action manager so that + // you can retrieve it if the person shares his url or presses f5 + _.each(params.params, function (value, name) { + params.context[name] = + name.indexOf("_ids") !== -1 + ? _.map(String(value).split(","), parseFloat) + : value; + }); + params.params = {}; + _.each(this.config.params, function (name) { + if (params.context[name]) { + params.params[name] = params.context[name]; + } + }); + }, + + /** + * Instantiate the action renderer + * + * @override + */ + willStart: function () { + var self = this; + var def = this.model.load(this.params.context).then(this._super.bind(this)); + return def.then(function () { + if (!self.model.context || !self.model.context.active_id) { + self.model.context = { + active_id: self.params.context.active_id, + active_model: self.params.context.active_model, + }; + } + var journal_id = self.params.context.journal_id; + if ( + self.model.context.active_id && + self.model.context.active_model === "account.journal" + ) { + journal_id = journal_id || self.model.context.active_id; + } + if (journal_id) { + var promise = self._rpc({ + model: "account.journal", + method: "read", + args: [journal_id, ["display_name"]], + }); + } else { + var promise = Promise.resolve(); + } + return promise.then(function (result) { + var title = + result && result[0] + ? result[0].display_name + : self.params.display_name || ""; + self._setTitle(title); + self.renderer = new self.config.ActionRenderer(self, self.model, { + bank_statement_line_id: self.model.bank_statement_line_id, + valuenow: self.model.valuenow, + valuemax: self.model.valuemax, + defaultDisplayQty: self.model.defaultDisplayQty, + title: title, + }); + }); + }); + }, + + reload: function () { + // On reload destroy all rendered line widget, reload data and then rerender widget + var self = this; + + self.$(".o_reconciliation_lines").addClass("d-none"); // Prevent the browser from recomputing css after each destroy for HUGE perf improvement on a lot of lines + _.each(this.widgets, function (widget) { + widget.destroy(); + }); + this.widgets = []; + self.$(".o_reconciliation_lines").removeClass("d-none"); + return this.model.reload().then(function () { + return self._renderLinesOrRainbow(); + }); + }, + + _renderLinesOrRainbow: function () { + var self = this; + return self._renderLines().then(function () { + var initialState = self.renderer._initialState; + var valuenow = self.model.statement + ? self.model.statement.value_min + : initialState.valuenow; + var valuemax = self.model.statement + ? self.model.statement.value_max + : initialState.valuemax; + // No more lines to reconcile, trigger the rainbowman. + if (valuenow === valuemax) { + initialState.valuenow = valuenow; + initialState.context = self.model.getContext(); + self.renderer.showRainbowMan(initialState); + self.remove_cp(); + } else { + // Create a notification if some lines have been reconciled automatically. + if (initialState.valuenow > 0) + self.renderer._renderNotifications( + self.model.statement.notifications + ); + self._openFirstLine(); + self.renderer.$('[data-toggle="tooltip"]').tooltip(); + self.do_show(); + } + }); + }, + + /** + * Append the renderer and instantiate the line renderers + * + * @override + */ + start: function () { + var self = this; + var args = arguments; + var sup = this._super; + + return this.renderer.prependTo(self.$(".o_form_sheet")).then(function () { + return self._renderLinesOrRainbow().then(function () { + self.do_show(); + return sup.apply(self, args); + }); + }); + }, + + /** + * Update the control panel and breadcrumbs + * + * @override + */ + do_show: function () { + this._super.apply(this, arguments); + if (this.action_manager) { + this.$pager = $( + QWeb.render("reconciliation.control.pager", {widget: this.renderer}) + ); + this.updateControlPanel({ + clear: true, + cp_content: { + $pager: this.$pager, + }, + }); + this.renderer.$progress = this.$pager; + $(this.renderer.$progress) + .parent() + .css("width", "100%") + .css("padding-left", "0"); + } + }, + + remove_cp: function () { + this.updateControlPanel({ + clear: true, + }); + }, + + // -------------------------------------------------------------------------- + // Private + // -------------------------------------------------------------------------- + + /** + * @private + * @param {String} handle + * @returns {Widget} widget line + */ + _getWidget: function (handle) { + return _.find(this.widgets, function (widget) { + return widget.handle === handle; + }); + }, + + /** + * + */ + _loadMore: function (qty) { + var self = this; + return this.model.loadMore(qty).then(function () { + return self._renderLines(); + }); + }, + /** + * Sitch to 'match' the first available line + * + * @private + */ + _openFirstLine: function (previous_handle) { + var self = this; + previous_handle = previous_handle || "rline0"; + var handle = _.compact( + _.map(this.model.lines, function (line, handle) { + return line.reconciled || + parseInt(handle.substr(5)) < parseInt(previous_handle.substr(5)) + ? null + : handle; + }) + )[0]; + if (handle) { + var line = this.model.getLine(handle); + this.model + .changeMode(handle, "default") + .then(function () { + self._getWidget(handle).update(line); + }) + .guardedCatch(function () { + self._getWidget(handle).update(line); + }) + .then(function () { + self._getWidget(handle).$el.focus(); + }); + } + return handle; + }, + + _forceUpdate: function () { + var self = this; + _.each(this.model.lines, function (handle) { + var widget = self._getWidget(handle.handle); + if (widget && handle.need_update) { + widget.update(handle); + widget.need_update = false; + } + }); + }, + /** + * Render line widget and append to view + * + * @private + */ + _renderLines: function () { + var self = this; + var linesToDisplay = this.model.getStatementLines(); + var linePromises = []; + _.each(linesToDisplay, function (line, handle) { + var widget = new self.config.LineRenderer(self, self.model, line); + widget.handle = handle; + self.widgets.push(widget); + linePromises.push(widget.appendTo(self.$(".o_reconciliation_lines"))); + }); + if (this.model.hasMoreLines() === false) { + this.renderer.hideLoadMoreButton(true); + } else { + this.renderer.hideLoadMoreButton(false); + } + return Promise.all(linePromises); + }, + + // -------------------------------------------------------------------------- + // Handlers + // -------------------------------------------------------------------------- + + /** + * dispatch on the camelcased event name to model method then update the + * line renderer with the new state. If the mode was switched from 'inactive' + * to 'create' or 'match_rp' or 'match_other', the other lines switch to + * 'inactive' mode + * + * @private + * @param {OdooEvent} event + */ + _onAction: function (event) { + var self = this; + var handle = event.target.handle; + var current_line = this.model.getLine(handle); + this.model[_.str.camelize(event.name)](handle, event.data.data).then( + function () { + var widget = self._getWidget(handle); + if (widget) { + widget.update(current_line); + } + if (current_line.mode !== "inactive") { + _.each(self.model.lines, function (line, _handle) { + if (line.mode !== "inactive" && _handle !== handle) { + self.model.changeMode(_handle, "inactive"); + var widget = self._getWidget(_handle); + if (widget) { + widget.update(line); + } + } + }); + } + } + ); + }, + + /** + * @private + * @param {OdooEvent} ev + */ + _onSearch: function (ev) { + var self = this; + ev.stopPropagation(); + this.model.domain = ev.data.domain; + this.model.display_context = "search"; + self.reload().then(function () { + self.renderer._updateProgressBar({ + valuenow: self.model.valuenow, + valuemax: self.model.valuemax, + }); + }); + }, + + _onActionPartialAmount: function (event) { + var self = this; + var handle = event.target.handle; + var line = this.model.getLine(handle); + var amount = this.model.getPartialReconcileAmount(handle, event.data); + self._getWidget(handle).updatePartialAmount(event.data.data, amount); + }, + + /** + * Call 'closeStatement' model method + * + * @private + * @param {OdooEvent} event + */ + _onCloseStatement: function (event) { + var self = this; + return this.model.closeStatement().then(function (result) { + self.do_action({ + name: "Bank Statements", + res_model: "account.bank.statement.line", + res_id: result, + views: [[false, "form"]], + type: "ir.actions.act_window", + view_mode: "form", + }); + $(".o_reward").remove(); + }); + }, + /** + * Load more statement and render them + * + * @param {OdooEvent} event + */ + _onLoadMore: function (event) { + return this._loadMore(this.model.defaultDisplayQty); + }, + /** + * Call 'validate' model method then destroy the + * validated lines and update the action renderer with the new status bar + * values and notifications then open the first available line + * + * @private + * @param {OdooEvent} event + */ + _onValidate: function (event) { + var self = this; + var handle = event.target.handle; + this.model.validate(handle).then(function (result) { + self.renderer.update({ + valuenow: self.model.valuenow, + valuemax: self.model.valuemax, + title: self.title, + time: Date.now() - self.time, + notifications: result.notifications, + context: self.model.getContext(), + }); + self._forceUpdate(); + _.each(result.handles, function (handle) { + var widget = self._getWidget(handle); + if (widget) { + widget.destroy(); + var index = _.findIndex(self.widgets, function (widget) { + return widget.handle === handle; + }); + self.widgets.splice(index, 1); + } + }); + // Get number of widget and if less than constant and if there are more to laod, load until constant + if ( + self.widgets.length < self.model.defaultDisplayQty && + self.model.valuemax - self.model.valuenow >= + self.model.defaultDisplayQty + ) { + var toLoad = self.model.defaultDisplayQty - self.widgets.length; + self._loadMore(toLoad); + } + self._openFirstLine(handle); + }); + }, + }); + + /** + * Widget used as action for 'account.move.line' and 'res.partner' for the + * manual reconciliation and mark data as reconciliate + */ + var ManualAction = StatementAction.extend({ + title: core._t("Journal Items to Reconcile"), + withSearchBar: false, + config: _.extend({}, StatementAction.prototype.config, { + Model: ReconciliationModel.ManualModel, + ActionRenderer: ReconciliationRenderer.ManualRenderer, + LineRenderer: ReconciliationRenderer.ManualLineRenderer, + params: ["company_ids", "mode", "partner_ids", "account_ids"], + defaultDisplayQty: 30, + limitMoveLines: 15, + }), + + // -------------------------------------------------------------------------- + // Handlers + // -------------------------------------------------------------------------- + + /** + * call 'validate' model method then destroy the + * reconcilied lines, update the not reconcilied and update the action + * renderer with the new status bar values and notifications then open the + * first available line + * + * @private + * @param {OdooEvent} event + */ + _onValidate: function (event) { + var self = this; + var handle = event.target.handle; + var method = "validate"; + this.model[method](handle).then(function (result) { + _.each(result.reconciled, function (handle) { + self._getWidget(handle).destroy(); + }); + _.each(result.updated, function (handle) { + self._getWidget(handle).update(self.model.getLine(handle)); + }); + self.renderer.update({ + valuenow: _.compact(_.invoke(self.widgets, "isDestroyed")).length, + valuemax: self.widgets.length, + title: self.title, + time: Date.now() - self.time, + }); + if ( + !_.any(result.updated, function (handle) { + return self.model.getLine(handle).mode !== "inactive"; + }) + ) { + self._openFirstLine(handle); + } + }); + }, + }); + + core.action_registry.add("bank_statement_reconciliation_view", StatementAction); + core.action_registry.add("manual_reconciliation_view", ManualAction); + + return { + StatementAction: StatementAction, + ManualAction: ManualAction, + }; +}); diff --git a/account_reconciliation_widget/static/src/js/reconciliation/reconciliation_model.js b/account_reconciliation_widget/static/src/js/reconciliation/reconciliation_model.js new file mode 100644 index 0000000000..26ca3f0978 --- /dev/null +++ b/account_reconciliation_widget/static/src/js/reconciliation/reconciliation_model.js @@ -0,0 +1,2356 @@ +odoo.define("account.ReconciliationModel", function (require) { + "use strict"; + + var BasicModel = require("web.BasicModel"); + var field_utils = require("web.field_utils"); + var utils = require("web.utils"); + var session = require("web.session"); + var WarningDialog = require("web.CrashManager").WarningDialog; + var core = require("web.core"); + var _t = core._t; + + /** + * Model use to fetch, format and update 'account.reconciliation.widget', + * datas allowing reconciliation + * + * The statement internal structure:: + * + * { + * valuenow: integer + * valuenow: valuemax + * [bank_statement_line_id]: { + * id: integer + * display_name: string + * } + * reconcileModels: [object] + * accounts: {id: code} + * } + * + * The internal structure of each line is:: + * + * { + * balance: { + * type: number - show/hide action button + * amount: number - real amount + * amount_str: string - formated amount + * account_code: string + * }, + * st_line: { + * partner_id: integer + * partner_name: string + * } + * mode: string ('inactive', 'match_rp', 'match_other', 'create') + * reconciliation_proposition: { + * id: number|string + * partial_amount: number + * invalid: boolean - through the invalid line (without account, label...) + * account_code: string + * date: string + * date_maturity: string + * label: string + * amount: number - real amount + * amount_str: string - formated amount + * [already_paid]: boolean + * [partner_id]: integer + * [partner_name]: string + * [account_code]: string + * [journal_id]: { + * id: integer + * display_name: string + * } + * [ref]: string + * [is_partially_reconciled]: boolean + * [to_check]: boolean + * [amount_currency_str]: string|false (amount in record currency) + * } + * mv_lines_match_rp: object - idem than reconciliation_proposition + * mv_lines_match_other: object - idem than reconciliation_proposition + * limitMoveLines: integer + * filter: string + * [createForm]: { + * account_id: { + * id: integer + * display_name: string + * } + * tax_ids: { + * id: integer + * display_name: string + * } + * analytic_account_id: { + * id: integer + * display_name: string + * } + * analytic_tag_ids: { + * } + * label: string + * amount: number, + * [journal_id]: { + * id: integer + * display_name: string + * } + * } + * } + */ + var StatementModel = BasicModel.extend({ + avoidCreate: false, + quickCreateFields: [ + "account_id", + "amount", + "analytic_account_id", + "label", + "tax_ids", + "force_tax_included", + "analytic_tag_ids", + "to_check", + ], + + // Overridden in ManualModel + modes: ["create", "match_rp", "match_other"], + + /** + * @override + * + * @param {Widget} parent + * @param {Object} options + */ + init: function (parent, options) { + this._super.apply(this, arguments); + this.reconcileModels = []; + this.lines = {}; + this.valuenow = 0; + this.valuemax = 0; + this.alreadyDisplayed = []; + this.domain = []; + this.defaultDisplayQty = (options && options.defaultDisplayQty) || 10; + this.limitMoveLines = (options && options.limitMoveLines) || 15; + this.display_context = "init"; + }, + + // -------------------------------------------------------------------------- + // Public + // -------------------------------------------------------------------------- + + /** + * add a reconciliation proposition from the matched lines + * We also display a warning if the user tries to add 2 line with different + * account type + * + * @param {String} handle + * @param {Number} mv_line_id + * @returns {Promise} + */ + addProposition: function (handle, mv_line_id) { + var self = this; + var line = this.getLine(handle); + var prop = _.clone(_.find(line["mv_lines_" + line.mode], {id: mv_line_id})); + this._addProposition(line, prop); + line["mv_lines_" + line.mode] = _.filter( + line["mv_lines_" + line.mode], + (l) => l.id != mv_line_id + ); + + // Remove all non valid lines + line.reconciliation_proposition = _.filter( + line.reconciliation_proposition, + function (prop) { + return prop && !prop.invalid; + } + ); + + // Onchange the partner if not already set on the statement line. + if ( + !line.st_line.partner_id && + line.reconciliation_proposition && + line.reconciliation_proposition.length == 1 && + prop.partner_id && + line.type === undefined + ) { + return this.changePartner( + handle, + {id: prop.partner_id, display_name: prop.partner_name}, + true + ); + } + + return Promise.all([ + this._computeLine(line), + this._performMoveLine( + handle, + "match_rp", + line.mode == "match_rp" ? 1 : 0 + ), + this._performMoveLine( + handle, + "match_other", + line.mode == "match_other" ? 1 : 0 + ), + ]); + }, + /** + * Change the filter for the target line and fetch the new matched lines + * + * @param {String} handle + * @param {String} filter + * @returns {Promise} + */ + changeFilter: function (handle, filter) { + var line = this.getLine(handle); + line["filter_" + line.mode] = filter; + line["mv_lines_" + line.mode] = []; + return this._performMoveLine(handle, line.mode); + }, + /** + * Change the mode line ('inactive', 'match_rp', 'match_other', 'create'), + * and fetch the new matched lines or prepare to create a new line + * + * ``match_rp`` + * display the matched lines from receivable/payable accounts, the user + * can select the lines to apply there as proposition + * ``match_other`` + * display the other matched lines, the user can select the lines to apply + * there as proposition + * ``create`` + * display fields and quick create button to create a new proposition + * for the reconciliation + * + * @param {String} handle + * @param {'inactive' | 'match_rp' | 'create'} mode + * @returns {Promise} + */ + changeMode: function (handle, mode) { + var self = this; + var line = this.getLine(handle); + if (mode === "default") { + var match_requests = self.modes + .filter((x) => x.startsWith("match")) + .map((x) => this._performMoveLine(handle, x)); + return Promise.all(match_requests).then(function () { + return self.changeMode(handle, self._getDefaultMode(handle)); + }); + } + if (mode === "next") { + var available_modes = self._getAvailableModes(handle); + mode = + available_modes[ + (available_modes.indexOf(line.mode) + 1) % + available_modes.length + ]; + } + line.mode = mode; + if (["match_rp", "match_other"].includes(line.mode)) { + if ( + !( + line["mv_lines_" + line.mode] && + line["mv_lines_" + line.mode].length + ) + ) { + return this._performMoveLine(handle, line.mode); + } + return this._formatMoveLine(handle, line.mode, []); + } + if (line.mode === "create") { + return this.createProposition(handle); + } + return Promise.resolve(); + }, + /** + * Fetch the more matched lines + * + * @param {String} handle + * @returns {Promise} + */ + changeOffset: function (handle) { + var line = this.getLine(handle); + return this._performMoveLine(handle, line.mode); + }, + /** + * Change the partner on the line and fetch the new matched lines + * + * @param {String} handle + * @param {Boolean} preserveMode + * @param {Object} partner + * @param {String} partner.display_name + * @param {Number} partner.id + * @returns {Promise} + */ + changePartner: function (handle, partner, preserveMode) { + var self = this; + var line = this.getLine(handle); + line.st_line.partner_id = partner && partner.id; + line.st_line.partner_name = (partner && partner.display_name) || ""; + line.mv_lines_match_rp = []; + line.mv_lines_match_other = []; + return Promise.resolve(partner && this._changePartner(handle, partner.id)) + .then(function () { + if (line.st_line.partner_id) { + _.each(line.reconciliation_proposition, function (prop) { + if (prop.partner_id != line.st_line.partner_id) { + line.reconciliation_proposition = []; + return false; + } + }); + } + return self._computeLine(line); + }) + .then(function () { + return self.changeMode( + handle, + preserveMode ? line.mode : "default", + true + ); + }); + }, + /** + * Close the statement + * @returns {Promise} resolves to the res_id of the closed statements + */ + closeStatement: function () { + var self = this; + return this._rpc({ + model: "account.bank.statement.line", + method: "button_confirm_bank", + args: [self.bank_statement_line_id.id], + }).then(function () { + return self.bank_statement_line_id.id; + }); + }, + /** + * + * Then open the first available line + * + * @param {String} handle + * @returns {Promise} + */ + createProposition: function (handle) { + var line = this.getLine(handle); + var prop = _.filter(line.reconciliation_proposition, "__focus"); + prop = this._formatQuickCreate(line); + line.reconciliation_proposition.push(prop); + line.createForm = _.pick(prop, this.quickCreateFields); + return this._computeLine(line); + }, + /** + * Return context information and journal_id + * @returns {Object} context + */ + getContext: function () { + return this.context; + }, + /** + * Return the lines that needs to be displayed by the widget + * + * @returns {Object} lines that are loaded and not yet displayed + */ + getStatementLines: function () { + var self = this; + var linesToDisplay = _.pick(this.lines, function (value, key, object) { + if ( + value.visible === true && + self.alreadyDisplayed.indexOf(key) === -1 + ) { + self.alreadyDisplayed.push(key); + return object; + } + }); + return linesToDisplay; + }, + /** + * Return a boolean telling if load button needs to be displayed or not + * overridden in ManualModel + * + * @returns {Boolean} true if load more button needs to be displayed + */ + hasMoreLines: function () { + var notDisplayed = _.filter(this.lines, function (line) { + return !line.visible; + }); + if (notDisplayed.length > 0) { + return true; + } + return false; + }, + /** + * Get the line data for this handle + * + * @param {Object} handle + * @returns {Object} + */ + getLine: function (handle) { + return this.lines[handle]; + }, + /** + * Load data from + * + * - 'account.bank.statement' fetch the line id and bank_statement_id info + * - 'account.reconcile.model' fetch all reconcile model (for quick add) + * - 'account.account' fetch all account code + * - 'account.reconciliation.widget' fetch each line data + * + * overridden in ManualModel + * @param {Object} context + * @param {Number[]} context.statement_line_ids + * @returns {Promise} + */ + load: function (context) { + var self = this; + this.context = context; + this.statement_line_ids = context.statement_line_ids; + if (this.statement_line_ids === undefined) { + // This could be undefined if the user pressed F5, take everything as fallback instead of rainbowman + return self + ._rpc({ + model: "account.bank.statement.line", + method: "search_read", + fields: ["id"], + domain: [["journal_id", "=?", context.active_id]], + }) + .then(function (result) { + self.statement_line_ids = result.map((r) => r.id); + return self.reload(); + }); + } + return self.reload(); + }, + /** + * Load more bank statement line + * + * @param {integer} qty quantity to load + * @returns {Promise} + */ + loadMore: function (qty) { + if (qty === undefined) { + qty = this.defaultDisplayQty; + } + var ids = _.pluck(this.lines, "id"); + ids = ids.splice(this.pagerIndex, qty); + this.pagerIndex += qty; + return this.loadData(ids, this._getExcludedIds()); + }, + /** + * RPC method to load informations on lines + * overridden in ManualModel + * + * @param {Array} ids ids of bank statement line passed to rpc call + * @param {Array} excluded_ids list of move_line ids that needs to be excluded from search + * @returns {Promise} + */ + loadData: function (ids) { + var self = this; + var excluded_ids = this._getExcludedIds(); + return self + ._rpc({ + model: "account.reconciliation.widget", + method: "get_bank_statement_line_data", + args: [ids, excluded_ids], + context: self.context, + }) + .then(function (res) { + return self._formatLine(res.lines); + }); + }, + /** + * Reload all data + */ + reload: function () { + var self = this; + self.alreadyDisplayed = []; + self.lines = {}; + self.pagerIndex = 0; + var def_statement = this._rpc({ + model: "account.reconciliation.widget", + method: "get_bank_statement_data", + kwargs: { + bank_statement_line_ids: self.statement_line_ids, + srch_domain: self.domain, + }, + context: self.context, + }).then(function (statement) { + self.statement = statement; + self.bank_statement_line_id = + self.statement_line_ids.length === 1 + ? { + id: self.statement_line_ids[0], + display_name: statement.statement_name, + } + : false; + self.valuenow = self.valuenow || statement.value_min; + self.valuemax = self.valuemax || statement.value_max; + self.context.journal_id = statement.journal_id; + _.each(statement.lines, function (res) { + var handle = _.uniqueId("rline"); + self.lines[handle] = { + id: res.st_line.id, + partner_id: res.st_line.partner_id, + handle: handle, + reconciled: false, + mode: "inactive", + mv_lines_match_rp: [], + mv_lines_match_other: [], + filter_match_rp: "", + filter_match_other: "", + reconciliation_proposition: [], + reconcileModels: [], + }; + }); + }); + var domainReconcile = []; + if (self.context && self.context.company_ids) { + domainReconcile.push(["company_id", "in", self.context.company_ids]); + } + if ( + self.context && + self.context.active_model === "account.journal" && + self.context.active_ids + ) { + domainReconcile.push("|"); + domainReconcile.push(["match_journal_ids", "=", false]); + domainReconcile.push([ + "match_journal_ids", + "in", + self.context.active_ids, + ]); + } + var def_reconcileModel = this._loadReconciliationModel({ + domainReconcile: domainReconcile, + }); + var def_account = this._rpc({ + model: "account.account", + method: "search_read", + fields: ["code"], + }).then(function (accounts) { + self.accounts = _.object( + _.pluck(accounts, "id"), + _.pluck(accounts, "code") + ); + }); + var def_taxes = self._loadTaxes(); + return Promise.all([ + def_statement, + def_reconcileModel, + def_account, + def_taxes, + ]).then(function () { + _.each(self.lines, function (line) { + line.reconcileModels = self.reconcileModels; + }); + var ids = _.pluck(self.lines, "id"); + ids = ids.splice(0, self.defaultDisplayQty); + self.pagerIndex = ids.length; + return self._formatLine(self.statement.lines); + }); + }, + _readAnalyticTags: function (params) { + var self = this; + this.analyticTags = {}; + if (!params || !params.res_ids || !params.res_ids.length) { + return $.when(); + } + var fields = ((params && params.fields) || []).concat([ + "id", + "display_name", + ]); + return this._rpc({ + model: "account.analytic.tag", + method: "read", + args: [params.res_ids, fields], + }).then(function (tags) { + for (var i = 0; i < tags.length; i++) { + var tag = tags[i]; + self.analyticTags[tag.id] = tag; + } + }); + }, + _loadReconciliationModel: function (params) { + var self = this; + return this._rpc({ + model: "account.reconcile.model", + method: "search_read", + domain: params.domainReconcile || [], + }).then(function (reconcileModels) { + var analyticTagIds = []; + for (var i = 0; i < reconcileModels.length; i++) { + var modelTags = reconcileModels[i].analytic_tag_ids || []; + for (var j = 0; j < modelTags.length; j++) { + if (analyticTagIds.indexOf(modelTags[j]) === -1) { + analyticTagIds.push(modelTags[j]); + } + } + } + return self + ._readAnalyticTags({res_ids: analyticTagIds}) + .then(function () { + for (var i = 0; i < reconcileModels.length; i++) { + var recModel = reconcileModels[i]; + var analyticTagData = []; + var modelTags = reconcileModels[i].analytic_tag_ids || []; + for (var j = 0; j < modelTags.length; j++) { + var tagId = modelTags[j]; + analyticTagData.push([ + tagId, + self.analyticTags[tagId].display_name, + ]); + } + recModel.analytic_tag_ids = analyticTagData; + } + self.reconcileModels = reconcileModels; + }); + }); + }, + _loadTaxes: function () { + var self = this; + self.taxes = {}; + return this._rpc({ + model: "account.tax", + method: "search_read", + fields: ["price_include", "name"], + }).then(function (taxes) { + _.each(taxes, function (tax) { + self.taxes[tax.id] = { + price_include: tax.price_include, + display_name: tax.name, + }; + }); + return taxes; + }); + }, + /** + * Add lines into the propositions from the reconcile model + * Can add 2 lines, and each with its taxes. The second line become editable + * in the create mode. + * + * @see 'updateProposition' method for more informations about the + * 'amount_type' + * + * @param {String} handle + * @param {integer} reconcileModelId + * @returns {Promise} + */ + quickCreateProposition: function (handle, reconcileModelId) { + var self = this; + var line = this.getLine(handle); + var reconcileModel = _.find(this.reconcileModels, function (r) { + return r.id === reconcileModelId; + }); + var fields = [ + "account_id", + "amount", + "amount_type", + "analytic_account_id", + "journal_id", + "label", + "force_tax_included", + "tax_ids", + "analytic_tag_ids", + "to_check", + "amount_from_label_regex", + "decimal_separator", + ]; + this._blurProposition(handle); + var focus = this._formatQuickCreate(line, _.pick(reconcileModel, fields)); + focus.reconcileModelId = reconcileModelId; + line.reconciliation_proposition.push(focus); + var defs = []; + if (reconcileModel.has_second_line) { + defs.push( + self._computeLine(line).then(function () { + var second = {}; + _.each(fields, function (key) { + second[key] = + "second_" + key in reconcileModel + ? reconcileModel["second_" + key] + : reconcileModel[key]; + }); + var second_focus = self._formatQuickCreate(line, second); + second_focus.reconcileModelId = reconcileModelId; + line.reconciliation_proposition.push(second_focus); + self._computeReconcileModels(handle, reconcileModelId); + }) + ); + } + return Promise.all(defs).then(function () { + line.createForm = _.pick(focus, self.quickCreateFields); + return self._computeLine(line); + }); + }, + /** + * Remove a proposition and switch to an active mode ('create' or 'match_rp' or 'match_other') + * overridden in ManualModel + * + * @param {String} handle + * @param {Number} id (move line id) + * @returns {Promise} + */ + removeProposition: function (handle, id) { + var self = this; + var line = this.getLine(handle); + var defs = []; + var prop = _.find(line.reconciliation_proposition, {id: id}); + if (prop) { + line.reconciliation_proposition = _.filter( + line.reconciliation_proposition, + function (p) { + return ( + p.id !== prop.id && + p.id !== prop.link && + p.link !== prop.id && + (!p.link || p.link !== prop.link) + ); + } + ); + if (prop.reconcileModelId === undefined) { + if ( + ["receivable", "payable", "liquidity"].includes( + prop.account_type + ) + ) { + line.mv_lines_match_rp.unshift(prop); + } else { + line.mv_lines_match_other.unshift(prop); + } + } + + // No proposition left and then, reset the st_line partner. + if ( + line.reconciliation_proposition.length == 0 && + line.st_line.has_no_partner + ) + defs.push(self.changePartner(line.handle)); + } + line.mode = + (id || line.mode !== "create") && isNaN(id) ? "create" : "match_rp"; + defs.push(this._computeLine(line)); + return Promise.all(defs).then(function () { + return self.changeMode(handle, line.mode, true); + }); + }, + getPartialReconcileAmount: function (handle, data) { + var line = this.getLine(handle); + var formatOptions = { + currency_id: line.st_line.currency_id, + noSymbol: true, + }; + var prop = _.find(line.reconciliation_proposition, {id: data.data}); + if (prop) { + var amount = prop.partial_amount || prop.amount; + // Check if we can get a partial amount that would directly set balance to zero + var partial = Math.abs(line.balance.amount + amount); + if (Math.abs(line.balance.amount) >= Math.abs(amount)) { + amount = Math.abs(amount); + } else if (partial <= Math.abs(prop.amount) && partial >= 0) { + amount = partial; + } else { + amount = Math.abs(amount); + } + return field_utils.format.monetary(amount, {}, formatOptions); + } + }, + /** + * Force the partial reconciliation to display the reconciliate button. + * + * @param {String} handle + * @returns {Promise} + */ + partialReconcile: function (handle, data) { + var line = this.getLine(handle); + var prop = _.find(line.reconciliation_proposition, {id: data.mvLineId}); + if (prop) { + var amount = data.amount; + try { + amount = field_utils.parse.float(data.amount); + } catch (err) { + amount = NaN; + } + // Amount can't be greater than line.amount and can not be negative and must be a number + // the amount we receive will be a string, so take sign of previous line amount in consideration in order to put + // the amount in the correct left or right column + if (amount >= Math.abs(prop.amount) || amount <= 0 || isNaN(amount)) { + delete prop.partial_amount_str; + delete prop.partial_amount; + if (isNaN(amount) || amount < 0) { + this.do_warn( + _.str.sprintf( + _t("The amount %s is not a valid partial amount"), + data.amount + ) + ); + } + return this._computeLine(line); + } + var format_options = {currency_id: line.st_line.currency_id}; + prop.partial_amount = (prop.amount > 0 ? 1 : -1) * amount; + prop.partial_amount_str = field_utils.format.monetary( + Math.abs(prop.partial_amount), + {}, + format_options + ); + } + return this._computeLine(line); + }, + /** + * Change the value of the editable proposition line or create a new one. + * + * If the editable line comes from a reconcile model with 2 lines + * and their 'amount_type' is "percent" + * and their total equals 100% (this doesn't take into account the taxes + * who can be included or not) + * Then the total is recomputed to have 100%. + * + * @param {String} handle + * @param {*} values + * @returns {Promise} + */ + updateProposition: function (handle, values) { + var self = this; + var line = this.getLine(handle); + var prop = _.last(_.filter(line.reconciliation_proposition, "__focus")); + if ("to_check" in values && values.to_check === false) { + // Check if we have another line with to_check and if yes don't change value of this proposition + prop.to_check = line.reconciliation_proposition.some(function ( + rec_prop, + index + ) { + return rec_prop.id !== prop.id && rec_prop.to_check; + }); + } + if (!prop) { + prop = this._formatQuickCreate(line); + line.reconciliation_proposition.push(prop); + } + _.each(values, function (value, fieldName) { + if (fieldName === "analytic_tag_ids") { + switch (value.operation) { + case "ADD_M2M": + // Handle analytic_tag selection via drop down (single dict) and + // full widget (array of dict) + var vids = _.isArray(value.ids) ? value.ids : [value.ids]; + _.each(vids, function (val) { + if (!_.findWhere(prop.analytic_tag_ids, {id: val.id})) { + prop.analytic_tag_ids.push(val); + } + }); + break; + case "FORGET": + var id = self.localData[value.ids[0]].ref; + prop.analytic_tag_ids = _.filter( + prop.analytic_tag_ids, + function (val) { + return val.id !== id; + } + ); + break; + } + } else if (fieldName === "tax_ids") { + switch (value.operation) { + case "ADD_M2M": + prop.__tax_to_recompute = true; + var vids = _.isArray(value.ids) ? value.ids : [value.ids]; + _.each(vids, function (val) { + if (!_.findWhere(prop.tax_ids, {id: val.id})) { + value.ids.price_include = self.taxes[val.id] + ? self.taxes[val.id].price_include + : false; + prop.tax_ids.push(val); + } + }); + break; + case "FORGET": + prop.__tax_to_recompute = true; + var id = self.localData[value.ids[0]].ref; + prop.tax_ids = _.filter(prop.tax_ids, function (val) { + return val.id !== id; + }); + break; + } + } else { + prop[fieldName] = values[fieldName]; + } + }); + if ("account_id" in values) { + prop.account_code = prop.account_id + ? this.accounts[prop.account_id.id] + : ""; + } + if ("amount" in values) { + prop.base_amount = values.amount; + if (prop.reconcileModelId) { + this._computeReconcileModels(handle, prop.reconcileModelId); + } + } + if ( + "force_tax_included" in values || + "amount" in values || + "account_id" in values + ) { + prop.__tax_to_recompute = true; + } + line.createForm = _.pick(prop, this.quickCreateFields); + // If you check/uncheck the force_tax_included box, reset the createForm amount. + if (prop.base_amount) line.createForm.amount = prop.base_amount; + if (prop.tax_ids.length !== 1) { + // When we have 0 or more than 1 taxes, reset the base_amount and force_tax_included, otherwise weird behavior can happen + prop.amount = prop.base_amount; + line.createForm.force_tax_included = false; + } + return this._computeLine(line); + }, + /** + * Format the value and send it to 'account.reconciliation.widget' model + * Update the number of validated lines + * overridden in ManualModel + * + * @param {(String|String[])} handle + * @returns {Promise} resolved with an object who contains + * 'handles' key + */ + validate: function (handle) { + var self = this; + this.display_context = "validate"; + var handles = []; + if (handle) { + handles = [handle]; + } else { + _.each(this.lines, function (line, handle) { + if ( + !line.reconciled && + line.balance && + !line.balance.amount && + line.reconciliation_proposition.length + ) { + handles.push(handle); + } + }); + } + var ids = []; + var values = []; + var handlesPromises = []; + _.each(handles, function (handle) { + var line = self.getLine(handle); + var props = _.filter(line.reconciliation_proposition, function (prop) { + return !prop.invalid; + }); + var computeLinePromise; + if (props.length === 0) { + // Usability: if user has not chosen any lines and click validate, it has the same behavior + // as creating a write-off of the same amount. + props.push( + self._formatQuickCreate(line, { + account_id: [ + line.st_line.open_balance_account_id, + self.accounts[line.st_line.open_balance_account_id], + ], + }) + ); + // Update balance of line otherwise it won't be to zero and another line will be added + line.reconciliation_proposition.push(props[0]); + computeLinePromise = self._computeLine(line); + } + ids.push(line.id); + handlesPromises.push( + Promise.resolve(computeLinePromise).then(function () { + var values_dict = { + partner_id: line.st_line.partner_id, + counterpart_aml_dicts: _.map( + _.filter(props, function (prop) { + return !isNaN(prop.id) && !prop.already_paid; + }), + self._formatToProcessReconciliation.bind(self, line) + ), + payment_aml_ids: _.pluck( + _.filter(props, function (prop) { + return !isNaN(prop.id) && prop.already_paid; + }), + "id" + ), + new_aml_dicts: _.map( + _.filter(props, function (prop) { + return isNaN(prop.id) && prop.display; + }), + self._formatToProcessReconciliation.bind(self, line) + ), + to_check: line.to_check, + }; + + // If the lines are not fully balanced, create an unreconciled amount. + // line.st_line.currency_id is never false here because its equivalent to + // statement_line.currency_id or statement_line.journal_id.currency_id or statement_line.journal_id.company_id.currency_id (Python-side). + // see: get_statement_line_for_reconciliation_widget method in account/models/account_bank_statement.py for more details + var currency = session.get_currency(line.st_line.currency_id); + var balance = line.balance.amount; + if (!utils.float_is_zero(balance, currency.digits[1])) { + var unreconciled_amount_dict = { + account_id: line.st_line.open_balance_account_id, + credit: balance > 0 ? balance : 0, + debit: balance < 0 ? -balance : 0, + name: line.st_line.name + " : " + _t("Open balance"), + }; + values_dict.new_aml_dicts.push(unreconciled_amount_dict); + } + values.push(values_dict); + line.reconciled = true; + }) + ); + + _.each(self.lines, function (other_line) { + if (other_line != line) { + var filtered_prop = other_line.reconciliation_proposition.filter( + (p) => + !line.reconciliation_proposition + .map((l) => l.id) + .includes(p.id) + ); + if ( + filtered_prop.length != + other_line.reconciliation_proposition.length + ) { + other_line.need_update = true; + other_line.reconciliation_proposition = filtered_prop; + } + self._computeLine(line); + } + }); + }); + + return Promise.all(handlesPromises).then(function () { + return self + ._rpc({ + model: "account.reconciliation.widget", + method: "process_bank_statement_line", + args: [ids, values], + context: self.context, + }) + .then(self._validatePostProcess.bind(self)) + .then(function () { + self.valuenow += handles.length; + return {handles: handles}; + }); + }); + }, + + // -------------------------------------------------------------------------- + // Private + // -------------------------------------------------------------------------- + + /** + * add a line proposition after checking receivable and payable accounts constraint + * + * @private + * @param {Object} line + * @param {Object} prop + */ + _addProposition: function (line, prop) { + line.reconciliation_proposition.push(prop); + }, + /** + * Stop the editable proposition line and remove it if it's invalid then + * compute the line + * + * See :func:`_computeLine` + * + * @private + * @param {String} handle + * @returns {Promise} + */ + _blurProposition: function (handle) { + var line = this.getLine(handle); + line.reconciliation_proposition = _.filter( + line.reconciliation_proposition, + function (l) { + l.__focus = false; + return !l.invalid; + } + ); + }, + /** + * When changing partner, read property_account_receivable and payable + * of that partner because the counterpart account might cahnge depending + * on the partner + * + * @private + * @param {String} handle + * @param {integer} partner_id + * @returns {Promise} + */ + _changePartner: function (handle, partner_id) { + var self = this; + return this._rpc({ + model: "res.partner", + method: "read", + args: [ + partner_id, + ["property_account_receivable_id", "property_account_payable_id"], + ], + }).then(function (result) { + if (result.length > 0) { + var line = self.getLine(handle); + self.lines[handle].st_line.open_balance_account_id = + line.balance.amount < 0 + ? result[0].property_account_payable_id[0] + : result[0].property_account_receivable_id[0]; + } + }); + }, + /** + * Calculates the balance; format each proposition amount_str and mark as + * invalid the line with empty account_id, amount or label + * Check the taxes server side for each updated propositions with tax_ids + * extended by ManualModel + * + * @private + * @param {Object} line + * @returns {Promise} + */ + _computeLine: function (line) { + // Balance_type + var self = this; + + // Compute taxes + var tax_defs = []; + var reconciliation_proposition = []; + var formatOptions = { + currency_id: line.st_line.currency_id, + }; + line.to_check = false; + _.each(line.reconciliation_proposition, function (prop) { + if (prop.to_check) { + // If one of the proposition is to_check, set the global to_check flag to true + line.to_check = true; + } + if (prop.tax_repartition_line_id) { + if ( + !_.find(line.reconciliation_proposition, {id: prop.link}) + .__tax_to_recompute + ) { + reconciliation_proposition.push(prop); + } + return; + } + if (!prop.already_paid && parseInt(prop.id)) { + prop.is_move_line = true; + } + reconciliation_proposition.push(prop); + + if ( + prop.tax_ids && + prop.tax_ids.length && + prop.__tax_to_recompute && + prop.base_amount + ) { + reconciliation_proposition = _.filter( + reconciliation_proposition, + function (p) { + return !p.tax_repartition_line_id || p.link !== prop.id; + } + ); + var args = [ + prop.tax_ids.map(function (el) { + return el.id; + }), + prop.base_amount, + formatOptions.currency_id, + ]; + var add_context = {round: true}; + if ( + prop.tax_ids.length === 1 && + line.createForm && + line.createForm.force_tax_included + ) + add_context.force_price_include = true; + tax_defs.push( + self + ._rpc({ + model: "account.tax", + method: "json_friendly_compute_all", + args: args, + context: $.extend({}, self.context || {}, add_context), + }) + .then(function (result) { + _.each(result.taxes, function (tax) { + var tax_prop = self._formatQuickCreate(line, { + link: prop.id, + tax_ids: tax.tax_ids, + tax_repartition_line_id: + tax.tax_repartition_line_id, + tag_ids: tax.tag_ids, + amount: tax.amount, + label: prop.label + ? prop.label + " " + tax.name + : tax.name, + date: prop.date, + account_id: tax.account_id + ? [tax.account_id, null] + : prop.account_id, + analytic: tax.analytic, + __focus: false, + }); + + prop.tax_exigible = + tax.tax_exigibility === "on_payment" + ? true + : undefined; + prop.amount = tax.base; + prop.amount_str = field_utils.format.monetary( + Math.abs(prop.amount), + {}, + formatOptions + ); + prop.invalid = !self._isValid(prop); + + tax_prop.amount_str = field_utils.format.monetary( + Math.abs(tax_prop.amount), + {}, + formatOptions + ); + tax_prop.invalid = prop.invalid; + + reconciliation_proposition.push(tax_prop); + }); + + prop.tag_ids = result.base_tags; + }) + ); + } else { + prop.amount_str = field_utils.format.monetary( + Math.abs(prop.amount), + {}, + formatOptions + ); + prop.display = self._isDisplayedProposition(prop); + prop.invalid = !self._isValid(prop); + } + }); + + return Promise.all(tax_defs).then(function () { + _.each(reconciliation_proposition, function (prop) { + prop.__tax_to_recompute = false; + }); + line.reconciliation_proposition = reconciliation_proposition; + + var amount_currency = 0; + var total = line.st_line.amount || 0; + var isOtherCurrencyId = _.uniq( + _.pluck( + _.reject(reconciliation_proposition, "invalid"), + "currency_id" + ) + ); + isOtherCurrencyId = + isOtherCurrencyId.length === 1 && + !total && + isOtherCurrencyId[0] !== formatOptions.currency_id + ? isOtherCurrencyId[0] + : false; + + _.each(reconciliation_proposition, function (prop) { + if (!prop.invalid) { + total -= prop.partial_amount || prop.amount; + if (isOtherCurrencyId) { + amount_currency -= + (prop.amount < 0 ? -1 : 1) * + Math.abs(prop.amount_currency); + } + } + }); + var company_currency = session.get_currency(line.st_line.currency_id); + var company_precision = + (company_currency && company_currency.digits[1]) || 2; + total = utils.round_decimals(total, company_precision) || 0; + if (isOtherCurrencyId) { + var other_currency = session.get_currency(isOtherCurrencyId); + var other_precision = + (other_currency && other_currency.digits[1]) || 2; + amount_currency = utils.round_decimals( + amount_currency, + other_precision + ); + } + line.balance = { + amount: total, + amount_str: field_utils.format.monetary( + Math.abs(total), + {}, + formatOptions + ), + currency_id: isOtherCurrencyId, + amount_currency: isOtherCurrencyId ? amount_currency : total, + amount_currency_str: isOtherCurrencyId + ? field_utils.format.monetary( + Math.abs(amount_currency), + {}, + { + currency_id: isOtherCurrencyId, + } + ) + : false, + account_code: self.accounts[line.st_line.open_balance_account_id], + }; + line.balance.show_balance = line.balance.amount_currency != 0; + line.balance.type = line.balance.amount_currency + ? line.st_line.partner_id + ? 0 + : -1 + : 1; + }); + }, + /** + * + * + * @private + * @param {String} handle + * @param {integer} reconcileModelId + */ + _computeReconcileModels: function (handle, reconcileModelId) { + var line = this.getLine(handle); + // If quick create with 2 lines who use 100%, change the both values in same time + var props = _.filter(line.reconciliation_proposition, { + reconcileModelId: reconcileModelId, + __focus: true, + }); + if (props.length === 2 && props[0].percent && props[1].percent) { + if (props[0].percent + props[1].percent === 100) { + props[0].base_amount = props[0].amount = + line.st_line.amount - props[1].base_amount; + props[0].__tax_to_recompute = true; + } + } + }, + /** + * Format a name_get into an object {id, display_name}, idempotent + * + * @private + * @param {Object|Array} [value] data or name_get + */ + _formatNameGet: function (value) { + return value + ? value.id + ? value + : {id: value[0], display_name: value[1]} + : false; + }, + _formatMany2ManyTags: function (value) { + var res = []; + for (var i = 0, len = value.length; i < len; i++) { + res[i] = {id: value[i][0], display_name: value[i][1]}; + } + return res; + }, + _formatMany2ManyTagsTax: function (value) { + var res = []; + for (var i = 0; i < value.length; i++) { + res.push({ + id: value[i], + display_name: this.taxes[value[i]] + ? this.taxes[value[i]].display_name + : "", + }); + } + return res; + }, + /** + * Format each propositions (amount, label, account_id) + * extended in ManualModel + * + * @private + * @param {Object} line + * @param {Object[]} props + */ + _formatLineProposition: function (line, props) { + var self = this; + if (props.length) { + _.each(props, function (prop) { + prop.amount = prop.debit || -prop.credit; + prop.label = prop.name; + prop.account_id = self._formatNameGet( + prop.account_id || line.account_id + ); + prop.is_partially_reconciled = + prop.amount_str !== prop.total_amount_str; + prop.to_check = Boolean(prop.to_check); + }); + } + }, + /** + * Format each server lines and propositions and compute all lines + * overridden in ManualModel + * + * @see '_computeLine' + * + * @private + * @param {Object[]} lines + * @returns {Promise} + */ + _formatLine: function (lines) { + var self = this; + var defs = []; + _.each(lines, function (data) { + var line = _.find(self.lines, function (l) { + return l.id === data.st_line.id; + }); + line.visible = true; + line.limitMoveLines = self.limitMoveLines; + _.extend(line, data); + self._formatLineProposition(line, line.reconciliation_proposition); + if (!line.reconciliation_proposition.length) { + delete line.reconciliation_proposition; + } + + // No partner set on st_line and all matching amls have the same one: set it on the st_line. + defs.push( + self + ._computeLine(line) + .then(function () { + if ( + !line.st_line.partner_id && + line.reconciliation_proposition.length > 0 + ) { + var hasDifferentPartners = function (prop) { + return ( + !prop.partner_id || + prop.partner_id != + line.reconciliation_proposition[0] + .partner_id + ); + }; + + if ( + !_.any( + line.reconciliation_proposition, + hasDifferentPartners + ) + ) { + return self.changePartner( + line.handle, + { + id: + line.reconciliation_proposition[0] + .partner_id, + display_name: + line.reconciliation_proposition[0] + .partner_name, + }, + true + ); + } + } else if ( + !line.st_line.partner_id && + line.partner_id && + line.partner_name + ) { + return self.changePartner( + line.handle, + { + id: line.partner_id, + display_name: line.partner_name, + }, + true + ); + } + return true; + }) + .then(function () { + return data.write_off + ? self.quickCreateProposition( + line.handle, + data.model_id + ) + : true; + }) + .then(function () { + // If still no partner set, take the one from context, if it exists + if ( + !line.st_line.partner_id && + self.context.partner_id && + self.context.partner_name + ) { + return self.changePartner( + line.handle, + { + id: self.context.partner_id, + display_name: self.context.partner_name, + }, + true + ); + } + return true; + }) + ); + }); + return Promise.all(defs); + }, + /** + * Format the server value then compute the line + * overridden in ManualModel + * + * @see '_computeLine' + * + * @private + * @param {String} handle + * @param {Object[]} mv_lines + * @returns {Promise} + */ + _formatMoveLine: function (handle, mode, mv_lines) { + var self = this; + var line = this.getLine(handle); + line["mv_lines_" + mode] = _.uniq( + line["mv_lines_" + mode].concat(mv_lines), + (l) => l.id + ); + if (mv_lines[0]) { + line["remaining_" + mode] = mv_lines[0].recs_count - mv_lines.length; + } else if (line["mv_lines_" + mode].lenght == 0) { + line["remaining_" + mode] = 0; + } + this._formatLineProposition(line, mv_lines); + + if ( + (line.mode == "match_other" || line.mode == "match_rp") && + !line["mv_lines_" + mode].length && + !line["filter_" + mode].length + ) { + line.mode = self._getDefaultMode(handle); + if ( + line.mode !== "match_rp" && + line.mode !== "match_other" && + line.mode !== "inactive" + ) { + return this._computeLine(line).then(function () { + return self.createProposition(handle); + }); + } + } else { + return this._computeLine(line); + } + }, + /** + * Overridden in ManualModel + */ + _getDefaultMode: function (handle) { + var line = this.getLine(handle); + if ( + line.balance.amount === 0 && + (!line.st_line.mv_lines_match_rp || + line.st_line.mv_lines_match_rp.length === 0) && + (!line.st_line.mv_lines_match_other || + line.st_line.mv_lines_match_other.length === 0) + ) { + return "inactive"; + } + if (line.mv_lines_match_rp && line.mv_lines_match_rp.length) { + return "match_rp"; + } + if (line.mv_lines_match_other && line.mv_lines_match_other.length) { + return "match_other"; + } + return "create"; + }, + _getAvailableModes: function (handle) { + var line = this.getLine(handle); + var modes = []; + if (line.mv_lines_match_rp && line.mv_lines_match_rp.length) { + modes.push("match_rp"); + } + if (line.mv_lines_match_other && line.mv_lines_match_other.length) { + modes.push("match_other"); + } + modes.push("create"); + return modes; + }, + /** + * Apply default values for the proposition, format datas and format the + * base_amount with the decimal number from the currency + * extended in ManualModel + * + * @private + * @param {Object} line + * @param {Object} values + * @returns {Object} + */ + _formatQuickCreate: function (line, values) { + values = values || {}; + var today = new moment().utc().format(); + var account = this._formatNameGet(values.account_id); + var formatOptions = { + currency_id: line.st_line.currency_id, + }; + var amount; + switch (values.amount_type) { + case "percentage": + amount = (line.balance.amount * values.amount) / 100; + break; + case "regex": + var matching = line.st_line.name.match( + new RegExp(values.amount_from_label_regex) + ); + amount = 0; + if (matching && matching.length == 2) { + matching = matching[1].replace( + new RegExp("\\D" + values.decimal_separator, "g"), + "" + ); + matching = matching.replace(values.decimal_separator, "."); + amount = parseFloat(matching) || 0; + amount = line.balance.amount > 0 ? amount : -amount; + } + break; + case "fixed": + amount = values.amount; + break; + default: + amount = + values.amount !== undefined + ? values.amount + : line.balance.amount; + } + + var prop = { + id: _.uniqueId("createLine"), + label: values.label || line.st_line.name, + account_id: account, + account_code: account ? this.accounts[account.id] : "", + analytic_account_id: this._formatNameGet(values.analytic_account_id), + analytic_tag_ids: this._formatMany2ManyTags( + values.analytic_tag_ids || [] + ), + journal_id: this._formatNameGet(values.journal_id), + tax_ids: this._formatMany2ManyTagsTax(values.tax_ids || []), + tag_ids: values.tag_ids, + tax_repartition_line_id: values.tax_repartition_line_id, + debit: 0, + credit: 0, + date: values.date + ? values.date + : field_utils.parse.date(today, {}, {isUTC: true}), + force_tax_included: values.force_tax_included || false, + base_amount: amount, + percent: values.amount_type === "percentage" ? values.amount : null, + link: values.link, + display: true, + invalid: true, + to_check: Boolean(values.to_check), + __tax_to_recompute: true, + __focus: "__focus" in values ? values.__focus : true, + }; + if (prop.base_amount) { + // Call to format and parse needed to round the value to the currency precision + var sign = prop.base_amount < 0 ? -1 : 1; + var amount = field_utils.format.monetary( + Math.abs(prop.base_amount), + {}, + formatOptions + ); + prop.base_amount = + sign * field_utils.parse.monetary(amount, {}, formatOptions); + } + + prop.amount = prop.base_amount; + return prop; + }, + /** + * Return list of account_move_line that has been selected and needs to be removed + * from other calls. + * + * @private + * @returns {Array} list of excluded ids + */ + _getExcludedIds: function () { + var excludedIds = []; + _.each(this.lines, function (line) { + if (line.reconciliation_proposition) { + _.each(line.reconciliation_proposition, function (prop) { + if (parseInt(prop.id)) { + excludedIds.push(prop.id); + } + }); + } + }); + return excludedIds; + }, + /** + * Defined whether the line is to be displayed or not. Here, we only display + * the line if it comes from the server or if an account is defined when it + * is created + * extended in ManualModel + * + * @private + * @param {Object} prop + * @returns {Boolean} + */ + _isDisplayedProposition: function (prop) { + return !isNaN(prop.id) || Boolean(prop.account_id); + }, + /** + * Extended in ManualModel + * @private + * @param {Object} prop + * @returns {Boolean} + */ + _isValid: function (prop) { + return ( + !isNaN(prop.id) || + (prop.account_id && + prop.amount && + prop.label && + Boolean(prop.label.length)) + ); + }, + /** + * Fetch 'account.reconciliation.widget' propositions. + * overridden in ManualModel + * + * @see '_formatMoveLine' + * + * @private + * @param {String} handle + * @returns {Promise} + */ + _performMoveLine: function (handle, mode, limit) { + limit = limit || this.limitMoveLines; + var line = this.getLine(handle); + var excluded_ids = _.map( + _.union( + line.reconciliation_proposition, + line.mv_lines_match_rp, + line.mv_lines_match_other + ), + function (prop) { + return _.isNumber(prop.id) ? prop.id : null; + } + ).filter((id) => id != null); + var filter = line["filter_" + mode] || ""; + return this._rpc({ + model: "account.reconciliation.widget", + method: "get_move_lines_for_bank_statement_line", + args: [ + line.id, + line.st_line.partner_id, + excluded_ids, + filter, + 0, + limit, + mode === "match_rp" ? "rp" : "other", + ], + context: this.context, + }).then(this._formatMoveLine.bind(this, handle, mode)); + }, + /** + * Format the proposition to send information server side + * extended in ManualModel + * + * @private + * @param {Object} line + * @param {Object} prop + * @returns {Object} + */ + _formatToProcessReconciliation: function (line, prop) { + var amount = -prop.amount; + if (prop.partial_amount) { + amount = -prop.partial_amount; + } + + var result = { + name: prop.label, + debit: amount > 0 ? amount : 0, + credit: amount < 0 ? -amount : 0, + tax_exigible: prop.tax_exigible, + analytic_tag_ids: [[6, null, _.pluck(prop.analytic_tag_ids, "id")]], + }; + if (!isNaN(prop.id)) { + result.counterpart_aml_id = prop.id; + } else { + result.account_id = prop.account_id.id; + if (prop.journal_id) { + result.journal_id = prop.journal_id.id; + } + } + if (!isNaN(prop.id)) result.counterpart_aml_id = prop.id; + if (prop.analytic_account_id) + result.analytic_account_id = prop.analytic_account_id.id; + if (prop.tax_ids && prop.tax_ids.length) + result.tax_ids = [[6, null, _.pluck(prop.tax_ids, "id")]]; + + if (prop.tag_ids && prop.tag_ids.length) + result.tag_ids = [[6, null, prop.tag_ids]]; + if (prop.tax_repartition_line_id) + result.tax_repartition_line_id = prop.tax_repartition_line_id; + if (prop.reconcileModelId) + result.reconcile_model_id = prop.reconcileModelId; + return result; + }, + /** + * Hook to handle return values of the validate's line process. + * + * @private + * @param {Object} data + * @param {Object[]} data.moves list of processed account.move + * @returns {Deferred} + */ + _validatePostProcess: function (data) { + var self = this; + return Promise.resolve(); + }, + }); + + /** + * Model use to fetch, format and update 'account.move.line' and 'res.partner' + * datas allowing manual reconciliation + */ + var ManualModel = StatementModel.extend({ + quickCreateFields: [ + "account_id", + "journal_id", + "amount", + "analytic_account_id", + "label", + "tax_ids", + "force_tax_included", + "analytic_tag_ids", + "date", + "to_check", + ], + + modes: ["create", "match"], + + // -------------------------------------------------------------------------- + // Public + // -------------------------------------------------------------------------- + + /** + * Return a boolean telling if load button needs to be displayed or not + * + * @returns {Boolean} true if load more button needs to be displayed + */ + hasMoreLines: function () { + if (this.manualLines.length > this.pagerIndex) { + return true; + } + return false; + }, + /** + * Load data from + * - 'account.reconciliation.widget' fetch the lines to reconciliate + * - 'account.account' fetch all account code + * + * @param {Object} context + * @param {String} [context.mode] 'customers', 'suppliers' or 'accounts' + * @param {integer[]} [context.company_ids] + * @param {integer[]} [context.partner_ids] used for 'customers' and + * 'suppliers' mode + * @returns {Promise} + */ + load: function (context) { + var self = this; + this.context = context; + + var domain_account_id = []; + if (context && context.company_ids) { + domain_account_id.push(["company_id", "in", context.company_ids]); + } + + var def_account = this._rpc({ + model: "account.account", + method: "search_read", + domain: domain_account_id, + fields: ["code"], + }).then(function (accounts) { + self.account_ids = _.pluck(accounts, "id"); + self.accounts = _.object(self.account_ids, _.pluck(accounts, "code")); + }); + + var domainReconcile = []; + var session_allowed_company_ids = + session.user_context.allowed_company_ids || []; + var company_ids = + (context && context.company_ids) || + session_allowed_company_ids.slice(0, 1); + + if (company_ids) { + domainReconcile.push(["company_id", "in", company_ids]); + } + var def_reconcileModel = this._loadReconciliationModel({ + domainReconcile: domainReconcile, + }); + var def_taxes = this._loadTaxes(); + + return Promise.all([def_reconcileModel, def_account, def_taxes]).then( + function () { + switch (context.mode) { + case "customers": + case "suppliers": + var mode = + context.mode === "customers" ? "receivable" : "payable"; + var args = ["partner", context.partner_ids || null, mode]; + return self + ._rpc({ + model: "account.reconciliation.widget", + method: "get_data_for_manual_reconciliation", + args: args, + context: context, + }) + .then(function (result) { + self.manualLines = result; + self.valuenow = 0; + self.valuemax = Object.keys( + self.manualLines + ).length; + var lines = self.manualLines.slice( + 0, + self.defaultDisplayQty + ); + self.pagerIndex = lines.length; + return self.loadData(lines); + }); + case "accounts": + return self + ._rpc({ + model: "account.reconciliation.widget", + method: "get_data_for_manual_reconciliation", + args: [ + "account", + context.account_ids || self.account_ids, + ], + context: context, + }) + .then(function (result) { + self.manualLines = result; + self.valuenow = 0; + self.valuemax = Object.keys( + self.manualLines + ).length; + var lines = self.manualLines.slice( + 0, + self.defaultDisplayQty + ); + self.pagerIndex = lines.length; + return self.loadData(lines); + }); + default: + var partner_ids = context.partner_ids || null; + var account_ids = + context.account_ids || self.account_ids || null; + return self + ._rpc({ + model: "account.reconciliation.widget", + method: "get_all_data_for_manual_reconciliation", + args: [partner_ids, account_ids], + context: context, + }) + .then(function (result) { + // Flatten the result + self.manualLines = [].concat( + result.accounts, + result.customers, + result.suppliers + ); + self.valuenow = 0; + self.valuemax = Object.keys( + self.manualLines + ).length; + var lines = self.manualLines.slice( + 0, + self.defaultDisplayQty + ); + self.pagerIndex = lines.length; + return self.loadData(lines); + }); + } + } + ); + }, + + /** + * Reload data by calling load + * It overrides super.reload() because + * it is not adapted for this model. + * + * Use case: coming back to manual reconcilation + * in breadcrumb + */ + reload: function () { + this.lines = {}; + return this.load(this.context); + }, + + /** + * Load more partners/accounts + * overridden in ManualModel + * + * @param {integer} qty quantity to load + * @returns {Promise} + */ + loadMore: function (qty) { + if (qty === undefined) { + qty = this.defaultDisplayQty; + } + var lines = this.manualLines.slice(this.pagerIndex, this.pagerIndex + qty); + this.pagerIndex += qty; + return this.loadData(lines); + }, + /** + * Method to load informations on lines + * + * @param {Array} lines manualLines to load + * @returns {Promise} + */ + loadData: function (lines) { + var self = this; + var defs = []; + _.each(lines, function (l) { + defs.push(self._formatLine(l.mode, l)); + }); + return Promise.all(defs); + }, + /** + * Mark the account or the partner as reconciled + * + * @param {(String|String[])} handle + * @returns {Promise} resolved with the handle array + */ + validate: function (handle) { + var self = this; + var handles = []; + if (handle) { + handles = [handle]; + } else { + _.each(this.lines, function (line, handle) { + if ( + !line.reconciled && + !line.balance.amount && + line.reconciliation_proposition.length + ) { + handles.push(handle); + } + }); + } + + var def = Promise.resolve(); + var process_reconciliations = []; + var reconciled = []; + _.each(handles, function (handle) { + var line = self.getLine(handle); + if (line.reconciled) { + return; + } + var props = line.reconciliation_proposition; + if (!props.length) { + self.valuenow++; + reconciled.push(handle); + line.reconciled = true; + process_reconciliations.push({ + id: + line.type === "accounts" + ? line.account_id + : line.partner_id, + type: line.type, + mv_line_ids: [], + new_mv_line_dicts: [], + }); + } else { + var mv_line_ids = _.pluck( + _.filter(props, function (prop) { + return !isNaN(prop.id); + }), + "id" + ); + var new_mv_line_dicts = _.map( + _.filter(props, function (prop) { + return isNaN(prop.id) && prop.display; + }), + self._formatToProcessReconciliation.bind(self, line) + ); + process_reconciliations.push({ + id: null, + type: null, + mv_line_ids: mv_line_ids, + new_mv_line_dicts: new_mv_line_dicts, + }); + } + line.reconciliation_proposition = []; + }); + if (process_reconciliations.length) { + def = self._rpc({ + model: "account.reconciliation.widget", + method: "process_move_lines", + args: [process_reconciliations], + }); + } + + return def.then(function () { + var defs = []; + var account_ids = []; + var partner_ids = []; + _.each(handles, function (handle) { + var line = self.getLine(handle); + if (line.reconciled) { + return; + } + line.filter_match = ""; + defs.push( + self._performMoveLine(handle, "match").then(function () { + if (!line.mv_lines_match.length) { + self.valuenow++; + reconciled.push(handle); + line.reconciled = true; + if (line.type === "accounts") { + account_ids.push(line.account_id.id); + } else { + partner_ids.push(line.partner_id); + } + } + }) + ); + }); + return Promise.all(defs).then(function () { + if (partner_ids.length) { + self._rpc({ + model: "res.partner", + method: "mark_as_reconciled", + args: [partner_ids], + }); + } + return { + reconciled: reconciled, + updated: _.difference(handles, reconciled), + }; + }); + }); + }, + removeProposition: function (handle, id) { + var self = this; + var line = this.getLine(handle); + var defs = []; + var prop = _.find(line.reconciliation_proposition, {id: id}); + if (prop) { + line.reconciliation_proposition = _.filter( + line.reconciliation_proposition, + function (p) { + return ( + p.id !== prop.id && + p.id !== prop.link && + p.link !== prop.id && + (!p.link || p.link !== prop.link) + ); + } + ); + line.mv_lines_match = line.mv_lines_match || []; + line.mv_lines_match.unshift(prop); + + // No proposition left and then, reset the st_line partner. + if ( + line.reconciliation_proposition.length == 0 && + line.st_line.has_no_partner + ) + defs.push(self.changePartner(line.handle)); + } + line.mode = + (id || line.mode !== "create") && isNaN(id) ? "create" : "match"; + defs.push(this._computeLine(line)); + return Promise.all(defs).then(function () { + return self.changeMode(handle, line.mode, true); + }); + }, + + // -------------------------------------------------------------------------- + // Private + // -------------------------------------------------------------------------- + + /** + * override change the balance type to display or not the reconcile button + * + * @override + * @private + * @param {Object} line + * @returns {Promise} + */ + _computeLine: function (line) { + return this._super(line).then(function () { + var props = _.reject(line.reconciliation_proposition, "invalid"); + _.each(line.reconciliation_proposition, function (p) { + delete p.is_move_line; + }); + line.balance.type = -1; + if (!line.balance.amount_currency && props.length) { + line.balance.type = 1; + } else if ( + _.any(props, function (prop) { + return prop.amount > 0; + }) && + _.any(props, function (prop) { + return prop.amount < 0; + }) + ) { + line.balance.type = 0; + } + }); + }, + /** + * Format each server lines and propositions and compute all lines + * + * @see '_computeLine' + * + * @private + * @param {'customers' | 'suppliers' | 'accounts'} type + * @param {Object} data + * @returns {Promise} + */ + _formatLine: function (type, data) { + var line = (this.lines[_.uniqueId("rline")] = _.extend(data, { + type: type, + reconciled: false, + mode: "inactive", + limitMoveLines: this.limitMoveLines, + filter_match: "", + reconcileModels: this.reconcileModels, + account_id: this._formatNameGet([data.account_id, data.account_name]), + st_line: data, + visible: true, + })); + this._formatLineProposition(line, line.reconciliation_proposition); + if (!line.reconciliation_proposition.length) { + delete line.reconciliation_proposition; + } + return this._computeLine(line); + }, + /** + * Override to add journal_id + * + * @override + * @private + * @param {Object} line + * @param {Object} props + */ + _formatLineProposition: function (line, props) { + var self = this; + this._super(line, props); + if (props.length) { + _.each(props, function (prop) { + var tmp_value = prop.debit || prop.credit; + prop.credit = prop.credit !== 0 ? 0 : tmp_value; + prop.debit = prop.debit !== 0 ? 0 : tmp_value; + prop.amount = -prop.amount; + prop.journal_id = self._formatNameGet( + prop.journal_id || line.journal_id + ); + prop.to_check = Boolean(prop.to_check); + }); + } + }, + /** + * Override to add journal_id on tax_created_line + * + * @private + * @param {Object} line + * @param {Object} values + * @returns {Object} + */ + _formatQuickCreate: function (line, values) { + // Add journal to created line + if ( + values && + values.journal_id === undefined && + line && + line.createForm && + line.createForm.journal_id + ) { + values.journal_id = line.createForm.journal_id; + } + return this._super(line, values); + }, + /** + * @override + * @param {Object} prop + * @returns {Boolean} + */ + _isDisplayedProposition: function (prop) { + return Boolean(prop.journal_id) && this._super(prop); + }, + /** + * @override + * @param {Object} prop + * @returns {Boolean} + */ + _isValid: function (prop) { + return prop.journal_id && this._super(prop); + }, + /** + * Fetch 'account.move.line' propositions. + * + * @see '_formatMoveLine' + * + * @override + * @private + * @param {String} handle + * @returns {Promise} + */ + _performMoveLine: function (handle, mode, limit) { + limit = limit || this.limitMoveLines; + var line = this.getLine(handle); + var excluded_ids = _.map( + _.union(line.reconciliation_proposition, line.mv_lines_match), + function (prop) { + return _.isNumber(prop.id) ? prop.id : null; + } + ).filter((id) => id != null); + var filter = line.filter_match || ""; + var args = [ + line.account_id.id, + line.partner_id, + excluded_ids, + filter, + 0, + limit, + ]; + return this._rpc({ + model: "account.reconciliation.widget", + method: "get_move_lines_for_manual_reconciliation", + args: args, + context: this.context, + }).then(this._formatMoveLine.bind(this, handle, "")); + }, + + _formatToProcessReconciliation: function (line, prop) { + var result = this._super(line, prop); + result.date = prop.date; + return result; + }, + _getDefaultMode: function (handle) { + var line = this.getLine(handle); + if ( + line.balance.amount === 0 && + (!line.st_line.mv_lines_match || + line.st_line.mv_lines_match.length === 0) + ) { + return "inactive"; + } + return line.mv_lines_match.length > 0 ? "match" : "create"; + }, + _formatMoveLine: function (handle, mode, mv_lines) { + var self = this; + var line = this.getLine(handle); + line.mv_lines_match = _.uniq( + (line.mv_lines_match || []).concat(mv_lines), + (l) => l.id + ); + this._formatLineProposition(line, mv_lines); + + if ( + line.mode !== "create" && + !line.mv_lines_match.length && + !line.filter_match.length + ) { + line.mode = + this.avoidCreate || !line.balance.amount ? "inactive" : "create"; + if (line.mode === "create") { + return this._computeLine(line).then(function () { + return self.createProposition(handle); + }); + } + } else { + return this._computeLine(line); + } + }, + }); + + return { + StatementModel: StatementModel, + ManualModel: ManualModel, + }; +}); diff --git a/account_reconciliation_widget/static/src/js/reconciliation/reconciliation_renderer.js b/account_reconciliation_widget/static/src/js/reconciliation/reconciliation_renderer.js new file mode 100644 index 0000000000..6584ea3218 --- /dev/null +++ b/account_reconciliation_widget/static/src/js/reconciliation/reconciliation_renderer.js @@ -0,0 +1,1194 @@ +odoo.define("account.ReconciliationRenderer", function (require) { + "use strict"; + + var Widget = require("web.Widget"); + var FieldManagerMixin = require("web.FieldManagerMixin"); + var relational_fields = require("web.relational_fields"); + var basic_fields = require("web.basic_fields"); + var core = require("web.core"); + var time = require("web.time"); + var session = require("web.session"); + var qweb = core.qweb; + var _t = core._t; + + /** + * Rendering of the bank statement action contains progress bar, title and + * auto reconciliation button + */ + var StatementRenderer = Widget.extend(FieldManagerMixin, { + template: "reconciliation.statement", + events: { + 'click *[rel="do_action"]': "_onDoAction", + "click button.js_load_more": "_onLoadMore", + }, + /** + * @override + */ + init: function (parent, model, state) { + this._super(parent); + this.model = model; + this._initialState = state; + }, + /** + * Display iniial state and create the name statement field + * + * @override + */ + start: function () { + var self = this; + var defs = [this._super.apply(this, arguments)]; + this.time = Date.now(); + this.$progress = $(""); + + return Promise.all(defs); + }, + + // -------------------------------------------------------------------------- + // Public + // -------------------------------------------------------------------------- + /* + * hide the button to load more statement line + */ + hideLoadMoreButton: function (show) { + if (!show) { + this.$(".js_load_more").show(); + } else { + this.$(".js_load_more").hide(); + } + }, + showRainbowMan: function (state) { + if (this.model.display_context !== "validate") { + return; + } + var dt = Date.now() - this.time; + var $done = $( + qweb.render("reconciliation.done", { + duration: moment(dt).utc().format(time.getLangTimeFormat()), + number: state.valuenow, + timePerTransaction: Math.round(dt / 1000 / state.valuemax), + context: state.context, + }) + ); + $done.find("*").addClass("o_reward_subcontent"); + $done + .find(".button_close_statement") + .click(this._onCloseBankStatement.bind(this)); + $done + .find(".button_back_to_statement") + .click(this._onGoToBankStatement.bind(this)); + // Display rainbowman after full reconciliation + if (session.show_effect) { + this.trigger_up("show_effect", { + type: "rainbow_man", + fadeout: "no", + message: $done, + }); + this.$el.css("min-height", "450px"); + } else { + $done.appendTo(this.$el); + } + }, + /** + * Update the statement rendering + * + * @param {Object} state - statement data + * @param {integer} state.valuenow - for the progress bar + * @param {integer} state.valuemax - for the progress bar + * @param {String} state.title - for the progress bar + * @param {[object]} [state.notifications] + */ + update: function (state) { + var self = this; + this._updateProgressBar(state); + + if (state.valuenow === state.valuemax && !this.$(".done_message").length) { + this.showRainbowMan(state); + } + + if (state.notifications) { + this._renderNotifications(state.notifications); + } + }, + _updateProgressBar: function (state) { + this.$progress.find(".valuenow").text(state.valuenow); + this.$progress.find(".valuemax").text(state.valuemax); + this.$progress + .find(".progress-bar") + .attr("aria-valuenow", state.valuenow) + .attr("aria-valuemax", state.valuemax) + .css("width", (state.valuenow / state.valuemax) * 100 + "%"); + }, + + // -------------------------------------------------------------------------- + // Private + // -------------------------------------------------------------------------- + /** + * render the notifications + * + * @param {[Object]} notifications + */ + _renderNotifications: function (notifications) { + this.$(".notification_area").empty(); + for (var i = 0; i < notifications.length; i++) { + var $notification = $( + qweb.render("reconciliation.notification", notifications[i]) + ).hide(); + $notification.appendTo(this.$(".notification_area")).slideDown(300); + } + }, + + // -------------------------------------------------------------------------- + // Handlers + // -------------------------------------------------------------------------- + + /** + * @private + * Click on close bank statement button, this will + * close and then open form view of bank statement + * @param {MouseEvent} event + */ + _onCloseBankStatement: function (e) { + this.trigger_up("close_statement"); + }, + /** + * @private + * @param {MouseEvent} event + */ + _onDoAction: function (e) { + e.preventDefault(); + var name = e.currentTarget.dataset.action_name; + var model = e.currentTarget.dataset.model; + if (e.currentTarget.dataset.ids) { + var ids = e.currentTarget.dataset.ids.split(",").map(Number); + var domain = [["id", "in", ids]]; + } else { + var domain = e.currentTarget.dataset.domain; + } + var context = e.currentTarget.dataset.context; + var tag = e.currentTarget.dataset.tag; + if (tag) { + this.do_action({ + type: "ir.actions.client", + tag: tag, + context: context, + }); + } else { + this.do_action({ + name: name, + res_model: model, + domain: domain, + context: context, + views: [ + [false, "list"], + [false, "form"], + ], + type: "ir.actions.act_window", + view_mode: "list", + }); + } + }, + /** + * Open the list view for account.bank.statement model + * @private + * @param {MouseEvent} event + */ + _onGoToBankStatement: function (e) { + var journalId = $(e.target).attr("data_journal_id"); + if (journalId) { + journalId = parseInt(journalId); + } + $(".o_reward").remove(); + this.do_action({ + name: "Bank Statements", + res_model: "account.bank.statement", + views: [ + [false, "list"], + [false, "form"], + ], + type: "ir.actions.act_window", + context: {search_default_journal_id: journalId, journal_type: "bank"}, + view_mode: "form", + }); + }, + /** + * Load more statement lines for reconciliation + * @private + * @param {MouseEvent} event + */ + _onLoadMore: function (e) { + this.trigger_up("load_more"); + }, + }); + + /** + * Rendering of the bank statement line, contains line data, proposition and + * view for 'match' and 'create' mode + */ + var LineRenderer = Widget.extend(FieldManagerMixin, { + template: "reconciliation.line", + events: { + "click .accounting_view caption .o_buttons button": "_onValidate", + "click .accounting_view tfoot": "_onChangeTab", + click: "_onTogglePanel", + "click .o_field_widget": "_onStopPropagation", + "keydown .o_input, .edit_amount_input": "_onStopPropagation", + "click .o_notebook li a": "_onChangeTab", + "click .cell": "_onEditAmount", + "change input.filter": "_onFilterChange", + "click .match .load-more a": "_onLoadMore", + "click .match .mv_line td": "_onSelectMoveLine", + "click .accounting_view tbody .mv_line td": "_onSelectProposition", + "click .o_reconcile_models button": "_onQuickCreateProposition", + "click .create .add_line": "_onCreateProposition", + "click .reconcile_model_create": "_onCreateReconcileModel", + "click .reconcile_model_edit": "_onEditReconcileModel", + "keyup input": "_onInputKeyup", + "blur input": "_onInputKeyup", + keydown: "_onKeydown", + }, + custom_events: _.extend({}, FieldManagerMixin.custom_events, { + field_changed: "_onFieldChanged", + }), + _avoidFieldUpdate: {}, + MV_LINE_DEBOUNCE: 200, + + _onKeydown: function (ev) { + switch (ev.which) { + case $.ui.keyCode.ENTER: + this.trigger_up("navigation_move", { + direction: "validate", + handle: this.handle, + }); + break; + case $.ui.keyCode.UP: + ev.stopPropagation(); + ev.preventDefault(); + this.trigger_up("navigation_move", { + direction: "up", + handle: this.handle, + }); + break; + case $.ui.keyCode.DOWN: + ev.stopPropagation(); + ev.preventDefault(); + this.trigger_up("navigation_move", { + direction: "down", + handle: this.handle, + }); + break; + } + }, + + /** + * Create partner_id field in editable mode + * + * @override + */ + init: function (parent, model, state) { + this._super(parent); + FieldManagerMixin.init.call(this); + + this.model = model; + this._initialState = state; + if (this.MV_LINE_DEBOUNCE) { + this._onSelectMoveLine = _.debounce( + this._onSelectMoveLine, + this.MV_LINE_DEBOUNCE, + true + ); + } else { + this._onSelectMoveLine = this._onSelectMoveLine; + } + }, + /** + * @override + */ + start: function () { + var self = this; + var def1 = this._makePartnerRecord( + this._initialState.st_line.partner_id, + this._initialState.st_line.partner_name + ).then(function (recordID) { + self.fields = { + partner_id: new relational_fields.FieldMany2One( + self, + "partner_id", + self.model.get(recordID), + { + mode: "edit", + attrs: { + placeholder: + self._initialState.st_line + .communication_partner_name || + _t("Select Partner"), + }, + } + ), + }; + self.fields.partner_id.insertAfter( + self.$(".accounting_view caption .o_buttons") + ); + }); + var def3 = session + .user_has_group("analytic.group_analytic_tags") + .then(function (has_group) { + self.group_tags = has_group; + }); + var def4 = session + .user_has_group("analytic.group_analytic_accounting") + .then(function (has_group) { + self.group_acc = has_group; + }); + $('') + .appendTo(this.$("thead .cell_info_popover")) + .attr( + "data-content", + qweb.render("reconciliation.line.statement_line.details", { + state: this._initialState, + }) + ); + this.$el.popover({ + selector: ".line_info_button", + placement: "left", + container: this.$el, + html: true, + // Disable bootstrap sanitizer because we use a table that has been + // rendered using qweb.render so it is safe and also because sanitizer escape table by default. + sanitize: false, + trigger: "hover", + animation: false, + toggle: "popover", + }); + var def2 = this._super.apply(this, arguments); + return Promise.all([def1, def2, def3, def4]); + }, + + // -------------------------------------------------------------------------- + // Public + // -------------------------------------------------------------------------- + + /** + * update the statement line rendering + * + * @param {Object} state - statement line + */ + update: function (state) { + var self = this; + // IsValid + var to_check_checked = Boolean(state.to_check); + this.$("caption .o_buttons button.o_validate").toggleClass( + "d-none", + Boolean(state.balance.type) && !to_check_checked + ); + this.$("caption .o_buttons button.o_reconcile").toggleClass( + "d-none", + state.balance.type <= 0 || to_check_checked + ); + this.$("caption .o_buttons .o_no_valid").toggleClass( + "d-none", + state.balance.type >= 0 || to_check_checked + ); + self.$("caption .o_buttons button.o_validate").toggleClass( + "text-warning", + to_check_checked + ); + + // Partner_id + this._makePartnerRecord( + state.st_line.partner_id, + state.st_line.partner_name + ).then(function (recordID) { + self.fields.partner_id.reset(self.model.get(recordID)); + self.$el.attr("data-partner", state.st_line.partner_id); + }); + + // Mode + this.$el.data("mode", state.mode).attr("data-mode", state.mode); + this.$(".o_notebook li a").attr("aria-selected", false); + this.$(".o_notebook li a").removeClass("active"); + this.$(".o_notebook .tab-content .tab-pane").removeClass("active"); + this.$('.o_notebook li a[href*="notebook_page_' + state.mode + '"]').attr( + "aria-selected", + true + ); + this.$( + '.o_notebook li a[href*="notebook_page_' + state.mode + '"]' + ).addClass("active"); + this.$( + '.o_notebook .tab-content .tab-pane[id*="notebook_page_' + + state.mode + + '"]' + ).addClass("active"); + this.$(".create, .match").each(function () { + $(this).removeAttr("style"); + }); + + // Reconciliation_proposition + var $props = this.$(".accounting_view tbody").empty(); + + // Search propositions that could be a partial credit/debit. + var props = []; + var balance = state.balance.amount_currency; + _.each(state.reconciliation_proposition, function (prop) { + if (prop.display) { + props.push(prop); + } + }); + + _.each(props, function (line) { + var $line = $( + qweb.render("reconciliation.line.mv_line", { + line: line, + state: state, + proposition: true, + }) + ); + if (!isNaN(line.id)) { + $('') + .appendTo($line.find(".cell_info_popover")) + .attr( + "data-content", + qweb.render("reconciliation.line.mv_line.details", { + line: line, + }) + ); + } + $props.append($line); + }); + + // Mv_lines + var matching_modes = self.model.modes.filter((x) => x.startsWith("match")); + for (let i = 0; i < matching_modes.length; i++) { + var stateMvLines = state["mv_lines_" + matching_modes[i]] || []; + var recs_count = + stateMvLines.length > 0 ? stateMvLines[0].recs_count : 0; + var remaining = state["remaining_" + matching_modes[i]]; + var $mv_lines = this.$( + 'div[id*="notebook_page_' + + matching_modes[i] + + '"] .match table tbody' + ).empty(); + this.$( + '.o_notebook li a[href*="notebook_page_' + matching_modes[i] + '"]' + ) + .parent() + .toggleClass( + "d-none", + stateMvLines.length === 0 && + !state["filter_" + matching_modes[i]] + ); + + _.each(stateMvLines, function (line) { + var $line = $( + qweb.render("reconciliation.line.mv_line", { + line: line, + state: state, + }) + ); + if (!isNaN(line.id)) { + $('') + .appendTo($line.find(".cell_info_popover")) + .attr( + "data-content", + qweb.render("reconciliation.line.mv_line.details", { + line: line, + }) + ); + } + $mv_lines.append($line); + }); + this.$( + 'div[id*="notebook_page_' + + matching_modes[i] + + '"] .match div.load-more' + ).toggle(remaining > 0); + this.$( + 'div[id*="notebook_page_' + + matching_modes[i] + + '"] .match div.load-more span' + ).text(remaining); + } + + // Balance + this.$(".popover").remove(); + this.$("table tfoot").html( + qweb.render("reconciliation.line.balance", {state: state}) + ); + + // Create form + if (state.createForm) { + var createPromise; + if (!this.fields.account_id) { + createPromise = this._renderCreate(state); + } + Promise.resolve(createPromise).then(function () { + var data = self.model.get(self.handleCreateRecord).data; + return self.model + .notifyChanges(self.handleCreateRecord, state.createForm) + .then(function () { + // FIXME can't it directly written REPLACE_WITH ids=state.createForm.analytic_tag_ids + return self.model.notifyChanges(self.handleCreateRecord, { + analytic_tag_ids: {operation: "REPLACE_WITH", ids: []}, + }); + }) + .then(function () { + var defs = []; + _.each(state.createForm.analytic_tag_ids, function (tag) { + defs.push( + self.model.notifyChanges(self.handleCreateRecord, { + analytic_tag_ids: { + operation: "ADD_M2M", + ids: tag, + }, + }) + ); + }); + return Promise.all(defs); + }) + .then(function () { + return self.model.notifyChanges(self.handleCreateRecord, { + tax_ids: {operation: "REPLACE_WITH", ids: []}, + }); + }) + .then(function () { + var defs = []; + _.each(state.createForm.tax_ids, function (tag) { + defs.push( + self.model.notifyChanges(self.handleCreateRecord, { + tax_ids: {operation: "ADD_M2M", ids: tag}, + }) + ); + }); + return Promise.all(defs); + }) + .then(function () { + var record = self.model.get(self.handleCreateRecord); + _.each(self.fields, function (field, fieldName) { + if (self._avoidFieldUpdate[fieldName]) return; + if (fieldName === "partner_id") return; + if ( + (data[fieldName] || state.createForm[fieldName]) && + !_.isEqual( + state.createForm[fieldName], + data[fieldName] + ) + ) { + field.reset(record); + } + if (fieldName === "tax_ids") { + if ( + !state.createForm[fieldName].length || + state.createForm[fieldName].length > 1 + ) { + $(".create_force_tax_included").addClass( + "d-none" + ); + } else { + $(".create_force_tax_included").removeClass( + "d-none" + ); + var price_include = + state.createForm[fieldName][0] + .price_include; + var force_tax_included = + state.createForm[fieldName][0] + .force_tax_included; + self.$(".create_force_tax_included input").prop( + "checked", + force_tax_included + ); + self.$(".create_force_tax_included input").prop( + "disabled", + price_include + ); + } + } + }); + if (state.to_check) { + // Set the to_check field to true if global to_check is set + self.$(".create_to_check input") + .prop("checked", state.to_check) + .change(); + } + return true; + }); + }); + } + this.$(".create .add_line").toggle(Boolean(state.balance.amount_currency)); + }, + + updatePartialAmount: function (line_id, amount) { + var $line = this.$(".mv_line[data-line-id=" + line_id + "]"); + $line.find(".edit_amount").addClass("d-none"); + $line.find(".edit_amount_input").removeClass("d-none"); + $line.find(".edit_amount_input").focus(); + $line.find(".edit_amount_input").val(amount); + $line.find(".line_amount").addClass("d-none"); + }, + + // -------------------------------------------------------------------------- + // Private + // -------------------------------------------------------------------------- + + /** + * @private + * @param {jQueryElement} $el + */ + _destroyPopover: function ($el) { + var popover = $el.data("bs.popover"); + if (popover) { + popover.dispose(); + } + }, + /** + * @private + * @param {integer} partnerID + * @param {String} partnerName + * @returns {String} local id of the dataPoint + */ + _makePartnerRecord: function (partnerID, partnerName) { + var field = { + relation: "res.partner", + type: "many2one", + name: "partner_id", + }; + if (partnerID) { + field.value = [partnerID, partnerName]; + } + return this.model.makeRecord("account.bank.statement.line", [field], { + partner_id: { + domain: ["|", ["is_company", "=", true], ["parent_id", "=", false]], + options: { + no_open: true, + }, + }, + }); + }, + + /** + * Create account_id, tax_ids, analytic_account_id, analytic_tag_ids, label and amount fields + * + * @private + * @param {Object} state - statement line + * @returns {Promise} + */ + _renderCreate: function (state) { + var self = this; + return this.model + .makeRecord( + "account.bank.statement.line", + [ + { + relation: "account.account", + type: "many2one", + name: "account_id", + domain: [ + ["company_id", "=", state.st_line.company_id], + ["deprecated", "=", false], + ], + }, + { + relation: "account.journal", + type: "many2one", + name: "journal_id", + domain: [["company_id", "=", state.st_line.company_id]], + }, + { + relation: "account.tax", + type: "many2many", + name: "tax_ids", + domain: [["company_id", "=", state.st_line.company_id]], + }, + { + relation: "account.analytic.account", + type: "many2one", + name: "analytic_account_id", + }, + { + relation: "account.analytic.tag", + type: "many2many", + name: "analytic_tag_ids", + }, + { + type: "boolean", + name: "force_tax_included", + }, + { + type: "char", + name: "label", + }, + { + type: "float", + name: "amount", + }, + { + type: "char", // TODO is it a bug or a feature when type date exists ? + name: "date", + }, + { + type: "boolean", + name: "to_check", + }, + ], + { + account_id: { + string: _t("Account"), + }, + label: {string: _t("Label")}, + amount: {string: _t("Account")}, + } + ) + .then(function (recordID) { + self.handleCreateRecord = recordID; + var record = self.model.get(self.handleCreateRecord); + + self.fields.account_id = new relational_fields.FieldMany2One( + self, + "account_id", + record, + {mode: "edit", attrs: {can_create: false}} + ); + + self.fields.journal_id = new relational_fields.FieldMany2One( + self, + "journal_id", + record, + {mode: "edit"} + ); + + self.fields.tax_ids = new relational_fields.FieldMany2ManyTags( + self, + "tax_ids", + record, + { + mode: "edit", + additionalContext: {append_type_to_tax_name: true}, + } + ); + + self.fields.analytic_account_id = new relational_fields.FieldMany2One( + self, + "analytic_account_id", + record, + {mode: "edit"} + ); + + self.fields.analytic_tag_ids = new relational_fields.FieldMany2ManyTags( + self, + "analytic_tag_ids", + record, + {mode: "edit"} + ); + + self.fields.force_tax_included = new basic_fields.FieldBoolean( + self, + "force_tax_included", + record, + {mode: "edit"} + ); + + self.fields.label = new basic_fields.FieldChar( + self, + "label", + record, + {mode: "edit"} + ); + + self.fields.amount = new basic_fields.FieldFloat( + self, + "amount", + record, + {mode: "edit"} + ); + + self.fields.date = new basic_fields.FieldDate( + self, + "date", + record, + {mode: "edit"} + ); + + self.fields.to_check = new basic_fields.FieldBoolean( + self, + "to_check", + record, + {mode: "edit"} + ); + + var $create = $( + qweb.render("reconciliation.line.create", { + state: state, + group_tags: self.group_tags, + group_acc: self.group_acc, + }) + ); + self.fields.account_id + .appendTo($create.find(".create_account_id .o_td_field")) + .then(addRequiredStyle.bind(self, self.fields.account_id)); + self.fields.journal_id.appendTo( + $create.find(".create_journal_id .o_td_field") + ); + self.fields.tax_ids.appendTo( + $create.find(".create_tax_id .o_td_field") + ); + self.fields.analytic_account_id.appendTo( + $create.find(".create_analytic_account_id .o_td_field") + ); + self.fields.analytic_tag_ids.appendTo( + $create.find(".create_analytic_tag_ids .o_td_field") + ); + self.fields.force_tax_included.appendTo( + $create.find(".create_force_tax_included .o_td_field") + ); + self.fields.label + .appendTo($create.find(".create_label .o_td_field")) + .then(addRequiredStyle.bind(self, self.fields.label)); + self.fields.amount + .appendTo($create.find(".create_amount .o_td_field")) + .then(addRequiredStyle.bind(self, self.fields.amount)); + self.fields.date.appendTo($create.find(".create_date .o_td_field")); + self.fields.to_check.appendTo( + $create.find(".create_to_check .o_td_field") + ); + self.$(".create").append($create); + + function addRequiredStyle(widget) { + widget.$el.addClass("o_required_modifier"); + } + }); + }, + + // -------------------------------------------------------------------------- + // Handlers + // -------------------------------------------------------------------------- + /** + * The event on the partner m2o widget was propagated to the bank statement + * line widget, causing it to expand and the others to collapse. This caused + * the dropdown to be poorly placed and an unwanted update of this widget. + * + * @private + */ + _onStopPropagation: function (ev) { + ev.stopPropagation(); + }, + + /** + * @private + * @param {MouseEvent} event + */ + _onCreateReconcileModel: function (event) { + event.preventDefault(); + var self = this; + this.do_action( + { + type: "ir.actions.act_window", + res_model: "account.reconcile.model", + views: [[false, "form"]], + target: "current", + }, + { + on_reverse_breadcrumb: function () { + self.trigger_up("reload"); + }, + } + ); + }, + _editAmount: function (event) { + event.stopPropagation(); + var $line = $(event.target); + var moveLineId = $line.closest(".mv_line").data("line-id"); + this.trigger_up("partial_reconcile", { + data: {mvLineId: moveLineId, amount: $line.val()}, + }); + }, + _onEditAmount: function (event) { + event.preventDefault(); + event.stopPropagation(); + // Don't call when clicking inside the input field + if (!$(event.target).hasClass("edit_amount_input")) { + var $line = $(event.target); + this.trigger_up("getPartialAmount", { + data: $line.closest(".mv_line").data("line-id"), + }); + } + }, + /** + * @private + * @param {MouseEvent} event + */ + _onEditReconcileModel: function (event) { + event.preventDefault(); + var self = this; + this.do_action( + { + type: "ir.actions.act_window", + res_model: "account.reconcile.model", + views: [ + [false, "list"], + [false, "form"], + ], + view_mode: "list", + target: "current", + }, + { + on_reverse_breadcrumb: function () { + self.trigger_up("reload"); + }, + } + ); + }, + /** + * @private + * @param {OdooEvent} event + */ + _onFieldChanged: function (event) { + event.stopPropagation(); + var fieldName = event.target.name; + if (fieldName === "partner_id") { + var partner_id = event.data.changes.partner_id; + this.trigger_up("change_partner", {data: partner_id}); + } else { + if (event.data.changes.amount && isNaN(event.data.changes.amount)) { + return; + } + this.trigger_up("update_proposition", {data: event.data.changes}); + } + }, + /** + * @private + */ + _onTogglePanel: function () { + if (this.$el[0].getAttribute("data-mode") == "inactive") + this.trigger_up("change_mode", {data: "default"}); + }, + /** + * @private + */ + _onChangeTab: function (event) { + if (event.currentTarget.nodeName === "TFOOT") { + this.trigger_up("change_mode", {data: "next"}); + } else { + var modes = this.model.modes; + var selected_mode = modes.find(function (e) { + return event.target.getAttribute("href").includes(e); + }); + if (selected_mode) { + this.trigger_up("change_mode", {data: selected_mode}); + } + } + }, + /** + * @private + * @param {input event} event + */ + _onFilterChange: function (event) { + this.trigger_up("change_filter", { + data: _.str.strip($(event.target).val()), + }); + }, + /** + * @private + * @param {keyup event} event + */ + _onInputKeyup: function (event) { + var target_partner_id = $(event.target).parents('[name="partner_id"]'); + if (target_partner_id.length === 1) { + return; + } + if (event.keyCode === 13) { + if ($(event.target).hasClass("edit_amount_input")) { + $(event.target).blur(); + return; + } + var created_lines = _.findWhere(this.model.lines, {mode: "create"}); + if (created_lines && created_lines.balance.amount) { + this._onCreateProposition(); + } + return; + } + if ($(event.target).hasClass("edit_amount_input")) { + if (event.type === "keyup") { + return; + } + return this._editAmount(event); + } + + var self = this; + for (var fieldName in this.fields) { + var field = this.fields[fieldName]; + if (!field.$el.is(event.target)) { + continue; + } + this._avoidFieldUpdate[field.name] = event.type !== "focusout"; + field.value = false; + field._setValue($(event.target).val()).then(function () { + self._avoidFieldUpdate[field.name] = false; + }); + break; + } + }, + /** + * @private + */ + _onLoadMore: function (ev) { + ev.preventDefault(); + this.trigger_up("change_offset"); + }, + /** + * @private + * @param {MouseEvent} event + */ + _onSelectMoveLine: function (event) { + var $el = $(event.target); + $el.prop("disabled", true); + this._destroyPopover($el); + var moveLineId = $el.closest(".mv_line").data("line-id"); + this.trigger_up("add_proposition", {data: moveLineId}); + }, + /** + * @private + * @param {MouseEvent} event + */ + _onSelectProposition: function (event) { + var $el = $(event.target); + this._destroyPopover($el); + var moveLineId = $el.closest(".mv_line").data("line-id"); + this.trigger_up("remove_proposition", {data: moveLineId}); + }, + /** + * @private + * @param {MouseEvent} event + */ + _onQuickCreateProposition: function (event) { + document.activeElement && document.activeElement.blur(); + this.trigger_up("quick_create_proposition", { + data: $(event.target).data("reconcile-model-id"), + }); + }, + /** + * @private + */ + _onCreateProposition: function () { + document.activeElement && document.activeElement.blur(); + var invalid = []; + _.each(this.fields, function (field) { + if (!field.isValid()) { + invalid.push(field.string); + } + }); + if (invalid.length) { + this.do_warn(_t("Some fields are undefined"), invalid.join(", ")); + return; + } + this.trigger_up("create_proposition"); + }, + /** + * @private + */ + _onValidate: function () { + this.trigger_up("validate"); + }, + }); + + /** + * Rendering of the manual reconciliation action contains progress bar, title + * and auto reconciliation button + */ + var ManualRenderer = StatementRenderer.extend({ + template: "reconciliation.manual.statement", + }); + + /** + * Rendering of the manual reconciliation, contains line data, proposition and + * view for 'match' mode + */ + var ManualLineRenderer = LineRenderer.extend({ + template: "reconciliation.manual.line", + /** + * @override + * @param {String} handle + * @param {Number} proposition id (move line id) + * @returns {Promise} + */ + removeProposition: function (handle, id) { + if (!id) { + return Promise.resolve(); + } + return this._super(handle, id); + }, + /** + * Move the partner field + * + * @override + */ + start: function () { + var self = this; + return this._super.apply(this, arguments).then(function () { + return self.model + .makeRecord("account.move.line", [ + { + relation: "account.account", + type: "many2one", + name: "account_id", + value: [ + self._initialState.account_id.id, + self._initialState.account_id.display_name, + ], + }, + ]) + .then(function (recordID) { + self.fields.title_account_id = new relational_fields.FieldMany2One( + self, + "account_id", + self.model.get(recordID), + {mode: "readonly"} + ); + }) + .then(function () { + return self.fields.title_account_id.appendTo( + self.$(".accounting_view thead td:eq(0) span:first") + ); + }); + }); + }, + /** + * @override + */ + update: function (state) { + this._super(state); + var props = _.filter(state.reconciliation_proposition, {display: true}); + if (!props.length) { + var $line = $( + qweb.render("reconciliation.line.mv_line", {line: {}, state: state}) + ); + this.$(".accounting_view tbody").append($line); + } + }, + // -------------------------------------------------------------------------- + // Handlers + // -------------------------------------------------------------------------- + /** + * display journal_id field + * + * @override + */ + _renderCreate: function (state) { + var self = this; + var parentPromise = this._super(state).then(function () { + self.$(".create .create_journal_id").show(); + self.$(".create .create_date").removeClass("d-none"); + self.$(".create .create_journal_id .o_input").addClass( + "o_required_modifier" + ); + }); + return parentPromise; + }, + }); + + return { + StatementRenderer: StatementRenderer, + ManualRenderer: ManualRenderer, + LineRenderer: LineRenderer, + ManualLineRenderer: ManualLineRenderer, + }; +}); diff --git a/account_reconciliation_widget/static/src/scss/account_reconciliation.scss b/account_reconciliation_widget/static/src/scss/account_reconciliation.scss new file mode 100644 index 0000000000..f776eebe5a --- /dev/null +++ b/account_reconciliation_widget/static/src/scss/account_reconciliation.scss @@ -0,0 +1,382 @@ +.progress-reconciliation { + .progress-bar { + font-size: 1.08333333rem; + height: 14px; + background-color: $o-enterprise-color; + span { + display: contents; + } + } +} + +.o_reconciliation { + .o_filter_input_wrapper { + position: relative; + width: 150px; + margin: 0.5rem !important; + .searchIcon { + position: absolute; + right: 10px; + } + .o_filter_input { + border: none; + border-bottom: 1px black solid; + } + } + + .import_to_suspense { + margin: 0.5rem !important; + } + + .notification_area { + clear: both; + } + + .o_view_noreconciliation { + max-width: none; + padding: 0 10%; + color: $o-main-color-muted; + font-size: 125%; + } + + .accounting_view { + width: 100%; + + .cell_left { + border-right: 1px solid #333; + padding-right: 5px; + } + .edit_amount { + margin-left: 20px; + color: #bbb; + } + .cell:hover .edit_amount { + color: #00a09d; + } + .strike_amount { + text-decoration: line-through; + } + tbody tr:hover .cell_account_code::before { + content: "\f068"; + font-family: FontAwesome; + position: relative; + margin-left: -17px; + left: -4px; + line-height: 0; + padding: 3px 2px 5px 5px; + } + } + + .o_multi_currency { + margin-right: 5px; + &.o_multi_currency_color_0 { + color: #dd6666; + } + &.o_multi_currency_color_1 { + color: #aaaaaa; + } + &.o_multi_currency_color_2 { + color: #66dd66; + } + &.o_multi_currency_color_3 { + color: #6666dd; + } + &.o_multi_currency_color_4 { + color: #dddd66; + } + &.o_multi_currency_color_5 { + color: #dd66dd; + } + &.o_multi_currency_color_6 { + color: #66dddd; + } + &.o_multi_currency_color_7 { + color: #aaa333; + } + } + + .o_reconciliation_line { + margin-bottom: 30px; + table { + width: 100%; + vertical-align: top; + } + tbody tr { + cursor: pointer; + } + tr.already_reconciled { + color: $o-account-info-color; + } + tr.invalid { + text-decoration: line-through; + } + td { + padding: 1px 2px; + } + thead td { + border-top: $o-account-light-border; + padding-top: 4px; + padding-bottom: 5px; + background-color: $o-account-initial-line-background; + } + tfoot td { + color: #bbb; + } + + /* columns */ + + .cell_action { + width: 15px; + color: gray("700"); + background: #fff; + border: 0; + text-align: center; + .fa-add-remove:before { + content: ""; + } + } + tr:hover .cell_action .fa-add-remove:before { + content: "\f068"; + } + .is_tax .cell_action .fa-add-remove:before { + position: relative; + top: -18px; + } + .cell_account_code { + width: 80px; + padding-left: 5px; + } + .cell_due_date { + width: 100px; + } + .cell_label { + width: auto; + } + .cell_left { + padding-right: 5px; + } + .cell_right, + .cell_left { + text-align: right; + width: 120px; + } + .cell_info_popover { + text-align: right; + width: 15px; + color: #ccc; + + &:empty { + padding: 0; + width: 0; + } + } + + table.accounting_view { + .cell_right, + .cell_left, + .cell_label, + .cell_due_date, + .cell_account_code, + .cell_info_popover { + box-shadow: 0 1px 0 #eaeaea; + } + } + /* info popover */ + .popover { + max-width: none; + } + + table.details { + vertical-align: top; + td:first-child { + vertical-align: top; + padding-right: 10px; + font-weight: bold; + } + } + + tr.one_line_info { + td { + padding-top: 10px; + text-align: center; + color: $o-account-info-color; + } + } + + /* Icons */ + + .toggle_match, + .toggle_create { + transform: rotate(0deg); + transition: transform 300ms ease 0s; + } + .visible_toggle, + &[data-mode="match"] .toggle_match, + &[data-mode="create"] .toggle_create { + visibility: visible !important; + transform: rotate(90deg); + } + .toggle_create { + font-size: 10px; + } + + /* Match view & Create view */ + > .o_notebook { + display: none; + + > .o_notebook_headers { + margin-right: 0; + margin-left: 0; + } + } + + > .o_notebook > .tab-content > div { + border: 1px solid #ddd; + border-top: 0; + } + + > .o_notebook .match table tr:hover { + background-color: #eee; + } + + &:not([data-mode="inactive"]) > .o_notebook { + display: block; + } + + &:not(:focus-within) .o_web_accesskey_overlay { + display: none; + } + &:focus caption .o_buttons button { + outline: none; + box-shadow: 4px 4px 4px 0px $o-enterprise-color; + } + &:focus { + outline: none; + box-shadow: 0 0 0 0; + } + } + + .o_reconcile_models .btn-primary { + margin: 0 2px 3px 0; + } + + /* Match view */ + + .match { + .cell_action .fa-add-remove:before { + content: ""; + } + tr:hover .cell_action .fa-add-remove:before { + content: "\f067"; + } + .match_controls { + padding: 5px 0 5px + ($o-account-action-col-width + $o-account-main-table-borders-padding); + + .filter { + width: 240px; + display: inline-block; + } + + .fa-chevron-left, + .fa-chevron-right { + display: inline-block; + cursor: pointer; + } + + .fa-chevron-left { + margin-right: 10px; + } + + .fa-chevron-left.disabled, + .fa-chevron-right.disabled { + color: #ddd; + cursor: default; + } + } + .show_more { + display: inline-block; + margin-left: ( + $o-account-action-col-width + $o-account-main-table-borders-padding + ); + margin-top: 5px; + } + } + + /* Create view */ + .create { + > div > div.quick_add > .o_reconcile_models { + max-width: 100%; + max-height: 70px; + flex-wrap: wrap; + overflow: auto; + + & > * { + flex-grow: 0; + } + } + .quick_add { + margin-bottom: 7px; + padding: 0 8px; + } + .o_group table.o_group_col_6 { + width: 49%; + margin: 0; + vertical-align: top; + } + .o_group table.o_group_col_6:first-child { + margin-left: 8px; + } + .btn { + padding-top: 0; + padding-bottom: 0; + } + .add_line_container { + text-align: center; + clear: both; + color: $o-enterprise-primary-color; + cursor: pointer; + } + } + + .o_notebook .tab-content > .tab-pane { + padding: 5px 0; + } +} + +/*Manual Reconciliation*/ +.o_manual_statement { + .accounting_view { + td[colspan="3"] span:first-child { + width: 100%; + display: inline-block; + } + td[colspan="2"] { + border-bottom: 1px solid #333; + text-align: center; + width: 240px; + } + .do_partial_reconcile_true { + display: none; + } + } +} + +// This is rtl language specific fix +// It will flip the fa-fa play icon in left direction +.o_rtl { + .o_reconciliation { + .o_reconciliation_line { + .toggle_match, + .toggle_create { + transform: rotate(180deg); + transition: transform 300ms; + } + .visible_toggle, + &[data-mode="match"] .toggle_match, + &[data-mode="create"] .toggle_create { + transform: rotate(270deg); + } + } + } +} diff --git a/account_reconciliation_widget/static/src/xml/account_reconciliation.xml b/account_reconciliation_widget/static/src/xml/account_reconciliation.xml new file mode 100644 index 0000000000..e3a946d997 --- /dev/null +++ b/account_reconciliation_widget/static/src/xml/account_reconciliation.xml @@ -0,0 +1,636 @@ + + + +
+
+
+
+
+
+
+ + +
+
/
+
+
+ + +
+
+
+
+ +
+
+
+

Nothing to do!

+

This page displays all the bank transactions that are to be reconciled and provides with a neat interface to do so.

+
+ + + + + + + + + +

Good Job! There is nothing to reconcile.

+

All invoices and payments have been matched, your accounts' balances are clean.

+

+ From now on, you may want to: +

+

+
+
+ +
+

Congrats, you're all done!

+

You reconciled transactions in . + +
That's on average seconds per transaction. + +

+ +

+ + + + + + +

+
+
+ + + +
+ + + + + + + + + + + + + + + + + +
+
+ + + +
+
() +
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+ + + + +
+ + + +
+
+ + + + + + + + + Last Reconciliation: + + + + + + + + + + + + + Open balanceChoose counterpart or Create Write-off + + + + + + + +
+
+ +
+ + + +
+ +
+ + +
+
+
+ + + +

To speed up reconciliation, define reconciliation models.

+
+ +
+
+
+ + + + + + + + + + + + + + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+
+ +
+ + + + + + + + +
+
+
+ + + + + + + + +
+
+ + + + + + + + New + + + + + + + : + + + + : + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Account
Date
Due Date
Journal
Partner
Label
Ref
Amount ()
Residual + () +
This payment is registered but not reconciled.
+
+ + + + + + + + + + + +
Date
Partner
Transaction
Description
Amount ()
Account
Note
+
+ + + + + + + statement lines + + have been reconciled automatically. + + + + + + + + + + + + + + +
+ + + + + + + +
+
+ + diff --git a/account_reconciliation_widget/static/tests/account_reconciliation_tests.js b/account_reconciliation_widget/static/tests/account_reconciliation_tests.js new file mode 100644 index 0000000000..e97eb10d6b --- /dev/null +++ b/account_reconciliation_widget/static/tests/account_reconciliation_tests.js @@ -0,0 +1,4922 @@ +odoo.define("account.reconciliation_tests.data", function () { + "use strict"; + + /* + * Debug tip: + * To be able to "see" the test in the browser: + * var $body = $('body'); + * $body.addClass('debug'); + * clientAction.appendTo($body); + */ + + var Datas = {}; + + var db = { + "res.company": { + fields: { + id: {string: "ID", type: "integer"}, + display_name: {string: "Displayed name", type: "char"}, + }, + records: [{id: 1, display_name: "company 1"}], + }, + "res.partner": { + fields: { + id: {string: "ID", type: "integer"}, + display_name: {string: "Displayed name", type: "char"}, + image: {string: "image", type: "integer"}, + parent_id: {string: "Parent", type: "boolean"}, + is_company: {string: "Is company", type: "boolean"}, + property_account_receivable_id: { + string: "Account receivable", + type: "many2one", + relation: "account.account", + }, + property_account_payable_id: { + string: "Account payable", + type: "many2one", + relation: "account.account", + }, + }, + records: [ + {id: 1, display_name: "partner 1", image: "AAA"}, + {id: 2, display_name: "partner 2", image: "BBB"}, + {id: 3, display_name: "partner 3", image: "CCC"}, + {id: 4, display_name: "partner 4", image: "DDD"}, + {id: 8, display_name: "Agrolait", image: "EEE"}, + { + id: 12, + display_name: "Camptocamp", + image: "FFF", + property_account_receivable_id: 287, + property_account_payable_id: 287, + }, + // Add more to have 'Search More' option + {id: 98, display_name: "partner 98", image: "YYY"}, + {id: 99, display_name: "partner 99", image: "ZZZ"}, + ], + mark_as_reconciled: function () { + return Promise.resolve(); + }, + }, + "account.account": { + fields: { + id: {string: "ID", type: "integer"}, + code: {string: "code", type: "integer"}, + name: {string: "Displayed name", type: "char"}, + company_id: { + string: "Company", + type: "many2one", + relation: "res.company", + }, + deprecated: {string: "Deprecated", type: "boolean"}, + }, + records: [ + { + id: 282, + code: 100000, + name: "100000 Fixed Asset Account", + company_id: 1, + }, + {id: 283, code: 101000, name: "101000 Current Assets", company_id: 1}, + { + id: 284, + code: 101110, + name: "101110 Stock Valuation Account", + company_id: 1, + }, + { + id: 285, + code: 101120, + name: "101120 Stock Interim Account (Received)", + company_id: 1, + }, + { + id: 286, + code: 101130, + name: "101130 Stock Interim Account (Delivered)", + company_id: 1, + }, + { + id: 287, + code: 101200, + name: "101200 Account Receivable", + company_id: 1, + }, + {id: 288, code: 101300, name: "101300 Tax Paid", company_id: 1}, + {id: 308, code: 101401, name: "101401 Bank", company_id: 1}, + {id: 499, code: 499001, name: "499001 Suspense Account", company_id: 1}, + {id: 500, code: 500, name: "500 Account", company_id: 1}, + {id: 501, code: 501, name: "501 Account", company_id: 1}, + {id: 502, code: 502, name: "502 Account", company_id: 1}, + {id: 503, code: 503, name: "503 Account", company_id: 1}, + {id: 504, code: 504, name: "504 Account", company_id: 1}, + ], + mark_as_reconciled: function () { + return Promise.resolve(); + }, + }, + "account.account.tag": { + fields: { + id: {string: "ID", type: "integer"}, + }, + records: [{id: 1}, {id: 2}, {id: 3}, {id: 4}], + }, + "account.tax.repartition.line": { + fields: { + id: {string: "ID", type: "integer"}, + repartition_type: {string: "Repartition Type", type: "selection"}, + account_id: { + string: "Account", + type: "many2one", + relation: "account.account", + }, + factor_percent: {string: "%", type: "integer"}, + tag_ids: { + string: "Tax Grids", + type: "many2many", + relation: "account.account.tag", + }, + }, + records: [ + {id: 1, factor_percent: 100, repartition_type: "base", tag_ids: [1]}, + {id: 2, factor_percent: 100, repartition_type: "tax", tag_ids: [2]}, + {id: 3, factor_percent: 100, repartition_type: "base", tag_ids: [3]}, + { + id: 4, + factor_percent: 100, + repartition_type: "tax", + tag_ids: [4], + account_id: 288, + }, + ], + }, + "account.tax": { + fields: { + id: {string: "ID", type: "integer"}, + display_name: {string: "Displayed name", type: "char"}, + amount: {string: "amout", type: "float"}, + price_include: {string: "Included in Price", type: "boolean"}, + company_id: { + string: "Company", + type: "many2one", + relation: "res.company", + }, + amount_type: {string: "type", type: "selection"}, + invoice_repartition_line_ids: { + string: "Invoice Repartition", + type: "one2many", + relation: "account.tax.repartition.line", + }, + // No need for refund repartition lines in our test; they're not used by reconciliation widget anyway + }, + records: [ + { + id: 6, + display_name: "Tax 20.00%", + amount: 20, + amount_type: "percent", + price_include: false, + company_id: 1, + invoice_repartition_line_ids: [1, 2], + }, + { + id: 7, + display_name: "Tax 10.00% include", + amount: 10, + amount_type: "percent", + price_include: true, + company_id: 1, + invoice_repartition_line_ids: [3, 4], + }, + ], + json_friendly_compute_all: function (args) { + var tax = _.find(db["account.tax"].records, {id: args[0][0]}); + var amount = args[1]; + + var tax_base = null; + var base_tags = null; + var taxes = []; + + for (let i = 0; i < tax.invoice_repartition_line_ids.length; i++) { + var rep_ln = _.find(db["account.tax.repartition.line"].records, { + id: tax.invoice_repartition_line_ids[i], + }); + + if (rep_ln.repartition_type == "base") { + tax_base = + (tax.price_include + ? (amount * 100) / (100 + tax.amount) + : amount) * + (rep_ln.factor_percent / 100); + base_tags = rep_ln.tag_ids; + } else if (rep_ln.repartition_type == "tax") { + /* + IMPORTANT : + For simplicity of testing, we assume there is ALWAYS a + base repartition line before the tax one, so tax_base is non-null + */ + taxes.push({ + id: tax.id, + amount: (tax_base * tax.amount) / 100, + base: tax_base, + name: tax.display_name, + analytic: false, + account_id: rep_ln.account_id, + price_include: tax.price_include, + tax_repartition_line_id: rep_ln.id, + tag_ids: rep_ln.tag_ids, + tax_ids: [tax.id], + }); + } + } + + return Promise.resolve({ + base: amount, + taxes: taxes, + base_tags: base_tags, + total_excluded: (amount / 100) * (100 - tax.amount), + total_included: amount, + }); + }, + }, + "account.journal": { + fields: { + id: {string: "ID", type: "integer"}, + display_name: {string: "Displayed name", type: "char"}, + company_id: { + string: "Company", + type: "many2one", + relation: "res.company", + }, + }, + records: [{id: 8, display_name: "company 1 journal", company_id: 1}], + }, + "account.analytic.account": { + fields: { + id: {string: "ID", type: "integer"}, + display_name: {string: "Displayed name", type: "char"}, + }, + records: [ + {id: 16, display_name: "Administrative"}, + {id: 7, display_name: "Agrolait - Agrolait"}, + {id: 8, display_name: "Asustek - ASUSTeK"}, + {id: 15, display_name: "Camp to Camp - Camptocamp"}, + {id: 6, display_name: "CampToCamp - Camptocamp"}, + {id: 17, display_name: "Commercial & Marketing"}, + {id: 23, display_name: "Data Import/Export Plugin - Delta PC"}, + {id: 9, display_name: "Delta PC - Delta PC"}, + ], + }, + "account.analytic.tag": { + fields: { + id: {string: "id", type: "integer"}, + display_name: {string: "display_name", type: "char"}, + }, + records: [ + {id: 1, display_name: "Come together"}, + {id: 2, display_name: "Right now"}, + ], + }, + "account.bank.statement": { + fields: {}, + }, + "account.bank.statement.line": { + fields: { + id: {string: "ID", type: "integer"}, + display_name: {string: "Displayed name", type: "char"}, + partner_id: { + string: "partner", + type: "many2one", + relation: "res.partner", + }, + company_id: { + string: "Company", + type: "many2one", + relation: "res.company", + }, + }, + records: [ + {id: 5, display_name: "SAJ/2014/002 and SAJ/2014/003", company_id: 1}, + {id: 6, display_name: "Bank fees", company_id: 1}, + {id: 7, display_name: "Prepayment", company_id: 1}, + { + id: 8, + display_name: "First 2000 \u20ac of SAJ/2014/001", + company_id: 1, + }, + ], + }, + "account.move.line": { + fields: {}, + }, + "account.reconcile.model": { + fields: { + id: {string: "ID", type: "integer"}, + name: {string: "Button Label", type: "char"}, + rule_type: { + string: "Type", + type: "selection", + selection: [ + ["writeoff_button", "Create a Button"], + ["writeoff_suggestion", "Write off Suggestion"], + ["invoice_matching", "Invoice matching"], + ], + default: "writeoff_button", + }, + has_second_line: {string: "Add a second line", type: "boolean"}, + account_id: { + string: "Account", + type: "many2one", + relation: "account.account", + }, + journal_id: { + string: "Journal", + type: "many2one", + relation: "account.journal", + }, + label: {string: "Journal Item Label", type: "char"}, + amount_type: { + string: "amount_type", + type: "selection", + selection: [ + ["fixed", "Fixed"], + ["percentage", "Percentage of balance"], + ], + default: "percentage", + }, + amount: { + string: "Amount", + type: "float", + digits: 0, + help: + "Fixed amount will count as a debit if it is negative, as a credit if it is positive.", + default: 100.0, + }, + tax_ids: {string: "Tax", type: "many2many", relation: "account.tax"}, + analytic_account_id: { + string: "Analytic Account", + type: "many2one", + relation: "account.analytic.account", + }, + second_account_id: { + string: "Second Account", + type: "many2one", + relation: "account.account", + domain: [("deprecated", "=", false)], + }, + second_journal_id: { + string: "Second Journal", + type: "many2one", + relation: "account.journal", + help: "This field is ignored in a bank statement reconciliation.", + }, + second_label: {string: "Second Journal Item Label", type: "char"}, + second_amount_type: { + string: "Second amount_type", + type: "selection", + selection: [ + ["fixed", "Fixed"], + ["percentage", "Percentage of balance"], + ], + default: "percentage", + }, + second_amount: { + string: "Second Amount", + type: "float", + digits: 0, + help: + "Fixed amount will count as a debit if it is negative, as a credit if it is positive.", + default: 100.0, + }, + second_tax_ids: { + string: "Second Tax", + type: "many2many", + relation: "account.tax", + }, + second_analytic_account_id: { + string: "Second Analytic Account", + type: "many2one", + relation: "account.analytic.account", + }, + match_journal_ids: { + string: "Journal Ids", + type: "many2many", + relation: "account.journal", + }, + analytic_tag_ids: { + string: "Analytic tags", + type: "many2many", + relation: "account.analytic.tag", + }, + }, + records: [ + { + second_analytic_account_id: false, + second_amount_type: "percentage", + second_journal_id: false, + id: 4, + analytic_account_id: false, + display_name: "Int\u00e9rrets", + rule_type: "writeoff_button", + second_tax_ids: [], + has_second_line: false, + journal_id: false, + label: false, + second_label: false, + second_account_id: false, + account_id: 282, + company_id: [1, "Demo SPRL"], + tax_ids: [], + amount_type: "fixed", + name: "Int\u00e9rrets", + amount: 0.0, + second_amount: 100.0, + match_journal_ids: [], + }, + { + second_analytic_account_id: false, + second_amount_type: "percentage", + second_journal_id: false, + id: 2, + analytic_account_id: false, + display_name: "Perte et Profit", + rule_type: "writeoff_button", + second_tax_ids: [], + has_second_line: false, + journal_id: false, + label: false, + second_label: false, + second_account_id: false, + account_id: 283, + company_id: [1, "Demo SPRL"], + tax_ids: [], + amount_type: "percentage", + name: "Perte et Profit", + amount: 100.0, + second_amount: 100.0, + match_journal_ids: [], + }, + { + second_analytic_account_id: false, + second_amount_type: "percentage", + second_journal_id: false, + id: 5, + analytic_account_id: false, + display_name: "Fs bank", + rule_type: "writeoff_button", + second_tax_ids: [], + has_second_line: false, + journal_id: false, + label: false, + second_label: false, + second_account_id: false, + account_id: 284, + company_id: [1, "Demo SPRL"], + tax_ids: [], + amount_type: "percentage", + name: "Fs bank", + amount: 100.0, + second_amount: 100.0, + }, + { + second_analytic_account_id: false, + second_amount_type: "percentage", + second_journal_id: false, + id: 8, + analytic_account_id: false, + display_name: "Caisse Sand.", + rule_type: "writeoff_button", + second_tax_ids: [], + has_second_line: false, + journal_id: false, + label: "Caisse Sand.", + second_label: false, + second_account_id: false, + account_id: 308, + company_id: [1, "Demo SPRL"], + tax_ids: [], + amount_type: "percentage", + name: "Caisse Sand.", + amount: 100.0, + second_amount: 100.0, + match_journal_ids: [], + }, + { + second_analytic_account_id: false, + second_amount_type: "percentage", + second_journal_id: false, + id: 3, + analytic_account_id: false, + display_name: "ATOS", + rule_type: "writeoff_button", + second_tax_ids: [7], + has_second_line: true, + journal_id: false, + label: "ATOS Banque", + second_label: "ATOS Frais", + second_account_id: 286, + account_id: 285, + company_id: [1, "Demo SPRL"], + tax_ids: [6], + amount_type: "percentage", + name: "ATOS", + amount: 97.5, + second_amount: -14.75, + }, + { + second_analytic_account_id: false, + second_amount_type: "percentage", + second_journal_id: false, + id: 10, + analytic_account_id: false, + display_name: "Double", + rule_type: "writeoff_button", + second_tax_ids: [], + has_second_line: true, + journal_id: false, + label: "Double Banque", + second_label: "Double Frais", + second_account_id: 286, + account_id: 285, + company_id: [1, "Demo SPRL"], + tax_ids: [], + amount_type: "percentage", + name: "Double", + amount: 97.5, + second_amount: 100, + match_journal_ids: [], + analytic_tag_ids: [1, 2], + }, + ], + }, + "account.reconciliation.widget": { + fields: {}, + auto_reconcile: function () { + return Promise.resolve(Datas.used.auto_reconciliation); + }, + process_bank_statement_line: function (args) { + var datas = args[1]; + var ids = _.flatten( + _.pluck( + _.pluck(datas, "counterpart_aml_dicts"), + "counterpart_aml_id" + ) + ); + ids = ids.concat(_.flatten(_.pluck(datas, "payment_aml_ids"))); + ids = _.compact(ids); + + for (var key in Datas.used.move_lines_for_manual_reconciliation) { + Datas.used.move_lines_for_manual_reconciliation[key] = _.filter( + Datas.used.move_lines_for_manual_reconciliation[key], + function (mv_line) { + return ids.indexOf(mv_line.id) === -1; + } + ); + } + return Promise.resolve(); + }, + get_move_lines_for_bank_statement_line: function (args) { + var partner_id = args.splice(1, 1)[0]; + var excluded_ids = args.splice(1, 1)[0]; + var mode = args.splice(-1, 1)[0]; + if (mode === "other") return Promise.resolve([]); + args.splice(-1, 1); // Ignore limit + var key = JSON.stringify(args); + if (!Datas.used.mv_lines[key]) { + throw new Error( + "Unknown parameters for get_move_lines_for_bank_statement_line: '" + + key + + "'" + ); + } + var lines = Datas.used.mv_lines[key] + .filter(function (line) { + return ( + excluded_ids.indexOf(line.id) === -1 && + (!partner_id || partner_id === line.partner_id) + ); + }) + .map(function (line, i, src) { + line.recs_count = src.length; + return line; + }) + .slice(0, options.params.limitMoveLines); + return Promise.resolve(lines); + }, + get_bank_statement_line_data: function (args) { + var ids = args[0]; + var results = { + value_min: 0, + value_max: ids.length, + lines: _.filter(Datas.used.data_widget, function (w) { + return _.contains(ids, w.st_line.id); + }), + }; + return Promise.resolve(results); + }, + get_bank_statement_data: function () { + var results = Datas.used.data_preprocess; + results.lines = _.filter(Datas.used.data_widget, function (w) { + return _.contains(results.st_lines_ids, w.st_line.id); + }); + return Promise.resolve(results); + }, + get_move_lines_for_manual_reconciliation: function (args) { + var excluded_ids = args.splice(2, 1)[0]; + args.splice(-1, 1); // Ignore limit + var key = JSON.stringify(args); + if (!Datas.used.move_lines_for_manual_reconciliation[key]) { + throw new Error( + "Unknown parameters for get_move_lines_for_manual_reconciliation: '" + + key + + "'" + ); + } + var lines = Datas.used.move_lines_for_manual_reconciliation[key] + .filter(function (line) { + return excluded_ids.indexOf(line.id) === -1; + }) + .map(function (line, i, src) { + line.recs_count = src.length; + return line; + }) + .slice(0, options.params.limitMoveLines); + return Promise.resolve(lines); + }, + get_all_data_for_manual_reconciliation: function (args) { + var key = JSON.stringify(args); + if (!Datas.used.data_for_manual_reconciliation_widget[key]) { + throw new Error( + "Unknown parameters for get_all_data_for_manual_reconciliation: '" + + key + + "'" + ); + } + return Promise.resolve( + Datas.used.data_for_manual_reconciliation_widget[key] + ); + }, + process_move_lines: function (args) { + var datas = args[0]; + for (var i in datas) { + var data = datas[i]; + for (var key in Datas.used.move_lines_for_manual_reconciliation) { + Datas.used.move_lines_for_manual_reconciliation[key] = _.filter( + Datas.used.move_lines_for_manual_reconciliation[key], + function (mv_line) { + return data.mv_line_ids.indexOf(mv_line.id) === -1; + } + ); + } + } + return Promise.resolve(); + }, + }, + }; + + var data_preprocess = { + value_min: 0, + value_max: 4, + notifications: [], + num_already_reconciled_lines: 0, + st_lines_ids: [5, 6, 7, 8], + statement_name: "BNK/2014/001", + }; + + var data_widget = [ + { + st_line: { + currency_id: 3, + communication_partner_name: false, + open_balance_account_id: 287, + name: "SAJ/2014/002 and SAJ/2014/003", + partner_name: "Agrolait", + partner_id: 8, + has_no_partner: false, + journal_id: 84, + account_name: "Bank", + note: "", + amount: 1175.0, + amount_str: "$ 1,175.00", + amount_currency_str: "", + date: "2017-01-01", + account_code: "101401", + ref: "", + id: 5, + statement_id: 2, + company_id: 1, + }, + reconciliation_proposition: [], + }, + { + st_line: { + currency_id: 3, + communication_partner_name: false, + name: "Bank fees", + partner_name: false, + partner_id: false, + has_no_partner: true, + journal_id: 84, + account_name: "Bank", + note: "", + amount: -32.58, + amount_str: "$ 32.58", + amount_currency_str: "", + date: "2017-01-01", + account_code: "101401", + ref: "", + id: 6, + statement_id: 2, + company_id: 1, + }, + reconciliation_proposition: [], + }, + { + st_line: { + currency_id: 3, + communication_partner_name: false, + open_balance_account_id: 287, + name: "Prepayment", + partner_name: "Camptocamp", + partner_id: 12, + has_no_partner: false, + journal_id: 84, + account_name: "Bank", + note: "", + amount: 650.0, + amount_str: "$ 650.00", + amount_currency_str: "", + date: "2017-01-01", + account_code: "101401", + ref: "", + id: 7, + statement_id: 2, + company_id: 1, + }, + reconciliation_proposition: [ + { + account_type: "receivable", + amount_currency_str: "", + currency_id: false, + date_maturity: "2017-02-07", + date: "2017-01-08", + total_amount_str: "$ 650.00", + partner_id: 12, + account_name: "101200 Account Receivable", + name: "INV/2017/0012", + partner_name: "Camptocamp", + total_amount_currency_str: "", + id: 133, + credit: 0.0, + journal_id: [1, "Customer Invoices"], + amount_str: "$ 650.00", + debit: 650.0, + account_id: [287, "101200 Account Receivable"], + account_code: "101200", + ref: "", + already_paid: false, + }, + ], + }, + { + st_line: { + currency_id: 3, + communication_partner_name: false, + open_balance_account_id: 285, + name: "First 2000 \u20ac of SAJ/2014/001", + partner_name: "Camptocamp", + partner_id: 12, + has_no_partner: false, + journal_id: 84, + account_name: "Bank", + note: "", + amount: 2000.0, + amount_str: "$ 2,000.00", + amount_currency_str: "", + date: "2017-01-01", + account_code: "101401", + ref: "", + id: 8, + statement_id: 2, + company_id: 1, + }, + reconciliation_proposition: [], + }, + ]; + + var mv_lines = { + "[]": [], + '[5,"",0]': [ + { + account_type: "receivable", + amount_currency_str: "", + currency_id: false, + date_maturity: "2017-02-07", + date: "2017-01-08", + total_amount_str: "$ 650.00", + partner_id: 8, + account_name: "101200 Account Receivable", + name: "INV/2017/0002", + partner_name: "Agrolait", + total_amount_currency_str: "", + id: 109, + credit: 0.0, + journal_id: [1, "Customer Invoices"], + amount_str: "$ 650.00", + debit: 650.0, + account_code: "101200", + ref: "", + already_paid: false, + }, + { + account_type: "receivable", + amount_currency_str: "", + currency_id: false, + date_maturity: "2017-02-07", + date: "2017-01-08", + total_amount_str: "$ 525.00", + partner_id: 8, + account_name: "101200 Account Receivable", + name: "INV/2017/0003", + partner_name: "Agrolait", + total_amount_currency_str: "", + id: 112, + credit: 0.0, + journal_id: [1, "Customer Invoices"], + amount_str: "$ 525.00", + debit: 525.0, + account_code: "101200", + ref: "", + already_paid: false, + }, + { + account_type: "receivable", + amount_currency_str: "", + currency_id: false, + date_maturity: "2017-02-07", + date: "2017-01-08", + total_amount_str: "$ 650.00", + partner_id: 12, + account_name: "101200 Account Receivable", + name: "INV/2017/0012", + partner_name: "Camptocamp", + total_amount_currency_str: "", + id: 134, + credit: 0.0, + journal_id: [1, "Customer Invoices"], + amount_str: "$ 650.00", + debit: 650.0, + account_id: [287, "101200 Account Receivable"], + account_code: "101200", + ref: "", + already_paid: false, + }, + { + account_type: "receivable", + amount_currency_str: "", + currency_id: false, + date_maturity: "2017-02-28", + date: "2017-01-01", + total_amount_str: "$ 4,610.00", + partner_id: 12, + account_name: "101200 Account Receivable", + name: "INV/2017/0001", + partner_name: "Camptocamp", + total_amount_currency_str: "", + id: 106, + credit: 0.0, + journal_id: [1, "Customer Invoices"], + amount_str: "$ 4,610.00", + debit: 4610.0, + account_id: [287, "101200 Account Receivable"], + account_code: "101200", + ref: "", + already_paid: false, + }, + { + account_type: "payable", + amount_currency_str: "", + currency_id: false, + date_maturity: "2017-02-28", + date: "2017-01-01", + total_amount_str: "$ 10,000.00", + partner_id: 12, + account_name: "Account Payable", + name: "BILL/2017/0001", + partner_name: "Camptocamp", + total_amount_currency_str: "", + id: 114, + credit: 10000.0, + journal_id: [2, "Vendor Bills"], + amount_str: "$ 10,000.00", + debit: 0.0, + account_id: [284, "101110 Stock Valuation Account"], + account_code: "111100", + ref: "", + already_paid: false, + }, + ], + '[5,"b",0]': [ + { + account_type: "liquidity", + amount_currency_str: "", + currency_id: false, + date_maturity: "2017-01-23", + date: "2017-01-23", + total_amount_str: "$ 100.00", + partner_id: 8, + account_name: "Bank", + name: "BNK1/2017/0003: CUST.IN/2017/0001", + partner_name: "Agrolait", + total_amount_currency_str: "", + id: 394, + credit: 0.0, + journal_id: "Bank", + amount_str: "$ 100.00", + debit: 100.0, + account_code: "101401", + ref: "", + already_paid: true, + }, + { + account_type: "liquidity", + amount_currency_str: "", + currency_id: false, + date_maturity: "2017-01-23", + date: "2017-01-23", + total_amount_str: "$ 525.50", + partner_id: 8, + account_name: "Bank", + name: "BNK1/2017/0004: CUST.IN/2017/0002", + partner_name: "Agrolait", + total_amount_currency_str: "", + id: 396, + credit: 0.0, + journal_id: "Bank", + amount_str: "$ 525.50", + debit: 525.5, + account_code: "101401", + ref: "INV/2017/0003", + already_paid: true, + }, + ], + '[6,"",0]': [ + { + account_type: "liquidity", + amount_currency_str: "", + currency_id: false, + date_maturity: "2017-01-23", + date: "2017-01-23", + total_amount_str: "$ 376.00", + partner_id: 7, + account_name: "Bank", + name: "BNK1/2017/0002: SUPP.OUT/2017/0002", + partner_name: "ASUSTeK", + total_amount_currency_str: "", + id: 392, + credit: 376.0, + journal_id: "Bank", + amount_str: "$ 376.00", + debit: 0.0, + account_code: "101401", + ref: "BILL/2017/0003", + already_paid: true, + }, + { + account_type: "liquidity", + amount_currency_str: "", + currency_id: false, + date_maturity: "2017-01-23", + date: "2017-01-23", + total_amount_str: "$ 100.00", + partner_id: 8, + account_name: "Bank", + name: "BNK1/2017/0003: CUST.IN/2017/0001", + partner_name: "Agrolait", + total_amount_currency_str: "", + id: 394, + credit: 0.0, + journal_id: "Bank", + amount_str: "$ 100.00", + debit: 100.0, + account_code: "101401", + ref: "", + already_paid: true, + }, + { + account_type: "payable", + amount_currency_str: "", + currency_id: false, + date_maturity: "2017-02-28", + date: "2017-01-01", + total_amount_str: "$ 10,000.00", + partner_id: 12, + account_name: "Account Payable", + name: "BILL/2017/0001", + partner_name: "Camptocamp", + total_amount_currency_str: "", + id: 114, + credit: 10000.0, + journal_id: [2, "Vendor Bills"], + amount_str: "$ 10,000.00", + debit: 0.0, + account_code: "111100", + ref: "", + already_paid: false, + }, + { + account_type: "liquidity", + amount_currency_str: "", + currency_id: false, + date_maturity: "2017-01-23", + date: "2017-01-23", + total_amount_str: "$ 525.50", + partner_id: 8, + account_name: "Bank", + name: "BNK1/2017/0004: CUST.IN/2017/0002", + partner_name: "Agrolait", + total_amount_currency_str: "", + id: 396, + credit: 0.0, + journal_id: "Bank", + amount_str: "$ 525.50", + debit: 525.5, + account_code: "101401", + ref: "INV/2017/0003", + already_paid: true, + }, + { + account_type: "receivable", + amount_currency_str: "", + currency_id: false, + date_maturity: "2017-02-07", + date: "2017-01-08", + total_amount_str: "$ 650.00", + partner_id: 8, + account_name: "101200 Account Receivable", + name: "INV/2017/0002", + partner_name: "Agrolait", + total_amount_currency_str: "", + id: 109, + credit: 0.0, + journal_id: [1, "Customer Invoices"], + amount_str: "$ 650.00", + debit: 650.0, + account_code: "101200", + ref: "", + already_paid: false, + }, + { + account_type: "receivable", + amount_currency_str: "", + currency_id: false, + date_maturity: "2017-02-22", + date: "2017-01-23", + total_amount_str: "$ 525.00", + partner_id: 8, + account_name: "101200 Account Receivable", + name: "INV/2017/0004", + partner_name: "Agrolait", + total_amount_currency_str: "", + id: 399, + credit: 0.0, + journal_id: [1, "Customer Invoices"], + amount_str: "$ 525.00", + debit: 525.0, + account_code: "101200", + ref: "", + already_paid: false, + }, + { + account_type: "receivable", + amount_currency_str: "", + currency_id: false, + date_maturity: "2017-02-28", + date: "2017-01-01", + total_amount_str: "$ 4,610.00", + partner_id: 12, + account_name: "101200 Account Receivable", + name: "INV/2017/0001", + partner_name: "Camptocamp", + total_amount_currency_str: "", + id: 106, + credit: 0.0, + journal_id: [1, "Customer Invoices"], + amount_str: "$ 4,610.00", + debit: 4610.0, + account_code: "101200", + ref: "", + already_paid: false, + }, + { + account_type: "payable", + amount_currency_str: "", + currency_id: false, + date_maturity: "2017-02-28", + date: "2017-01-15", + total_amount_str: "$ 5,749.99", + partner_id: 7, + account_name: "Account Payable", + name: "BILL/2017/0002", + partner_name: "ASUSTeK", + total_amount_currency_str: "", + id: 117, + credit: 5749.99, + journal_id: [2, "Vendor Bills"], + amount_str: "$ 5,749.99", + debit: 0.0, + account_code: "111100", + ref: "", + already_paid: false, + }, + ], + '[6,"",5]': [ + { + account_type: "receivable", + amount_currency_str: "", + currency_id: false, + date_maturity: "2017-02-28", + date: "2017-01-01", + total_amount_str: "$ 4,610.00", + partner_id: 12, + account_name: "101200 Account Receivable", + name: "INV/2017/0001", + partner_name: "Camptocamp", + total_amount_currency_str: "", + id: 106, + credit: 0.0, + journal_id: [1, "Customer Invoices"], + amount_str: "$ 4,610.00", + debit: 4610.0, + account_code: "101200", + ref: "", + already_paid: false, + }, + { + account_type: "payable", + amount_currency_str: "", + currency_id: false, + date_maturity: "2017-02-28", + date: "2017-01-01", + total_amount_str: "$ 10,000.00", + partner_id: 12, + account_name: "Account Payable", + name: "BILL/2017/0001", + partner_name: "Camptocamp", + total_amount_currency_str: "", + id: 114, + credit: 10000.0, + journal_id: [2, "Vendor Bills"], + amount_str: "$ 10,000.00", + debit: 0.0, + account_code: "111100", + ref: "", + already_paid: false, + }, + { + account_type: "payable", + amount_currency_str: "", + currency_id: false, + date_maturity: "2017-02-28", + date: "2017-01-15", + total_amount_str: "$ 5,749.99", + partner_id: 7, + account_name: "Account Payable", + name: "BILL/2017/0002", + partner_name: "ASUSTeK", + total_amount_currency_str: "", + id: 117, + credit: 5749.99, + journal_id: [2, "Vendor Bills"], + amount_str: "$ 5,749.99", + debit: 0.0, + account_code: "111100", + ref: "", + already_paid: false, + }, + ], + '[7,"",0]': [ + { + account_type: "receivable", + amount_currency_str: "", + currency_id: false, + date_maturity: "2017-02-07", + date: "2017-01-08", + total_amount_str: "$ 650.00", + partner_id: 12, + account_name: "101200 Account Receivable", + name: "INV/2017/0012", + partner_name: "Camptocamp", + total_amount_currency_str: "", + id: 133, + credit: 0.0, + journal_id: [1, "Customer Invoices"], + amount_str: "$ 650.00", + debit: 650.0, + account_id: [287, "101200 Account Receivable"], + account_code: "101200", + ref: "", + already_paid: false, + }, + { + account_type: "receivable", + amount_currency_str: "", + currency_id: false, + date_maturity: "2017-02-28", + date: "2017-01-01", + total_amount_str: "$ 4,610.00", + partner_id: 12, + account_name: "101200 Account Receivable", + name: "INV/2017/0001", + partner_name: "Camptocamp", + total_amount_currency_str: "", + id: 106, + credit: 0.0, + journal_id: [1, "Customer Invoices"], + amount_str: "$ 4,610.00", + debit: 4610.0, + account_id: [287, "101200 Account Receivable"], + account_code: "101200", + ref: "", + already_paid: false, + }, + { + account_type: "payable", + amount_currency_str: "", + currency_id: false, + date_maturity: "2017-02-28", + date: "2017-01-01", + total_amount_str: "$ 10,000.00", + partner_id: 12, + account_name: "Account Payable", + name: "BILL/2017/0001", + partner_name: "Camptocamp", + total_amount_currency_str: "", + id: 114, + credit: 10000.0, + journal_id: [2, "Vendor Bills"], + amount_str: "$ 10,000.00", + debit: 0.0, + account_id: [284, "101110 Stock Valuation Account"], + account_code: "111100", + ref: "", + already_paid: false, + }, + { + account_type: "liquidity", + amount_currency_str: "", + currency_id: false, + date_maturity: "2017-01-23", + date: "2017-01-23", + total_amount_str: "$ 376.00", + partner_id: 7, + account_name: "Bank", + name: "BNK1/2017/0002: SUPP.OUT/2017/0002", + partner_name: "ASUSTeK", + total_amount_currency_str: "", + id: 392, + credit: 376.0, + journal_id: "Bank", + amount_str: "$ 376.00", + debit: 0.0, + account_code: "101401", + ref: "BILL/2017/0003", + already_paid: true, + }, + { + account_type: "liquidity", + amount_currency_str: "", + currency_id: false, + date_maturity: "2017-01-23", + date: "2017-01-23", + total_amount_str: "$ 100.00", + partner_id: 8, + account_name: "Bank", + name: "BNK1/2017/0003: CUST.IN/2017/0001", + partner_name: "Agrolait", + total_amount_currency_str: "", + id: 394, + credit: 0.0, + journal_id: "Bank", + amount_str: "$ 100.00", + debit: 100.0, + account_code: "101401", + ref: "", + already_paid: true, + }, + { + account_type: "liquidity", + amount_currency_str: "", + currency_id: false, + date_maturity: "2017-01-23", + date: "2017-01-23", + total_amount_str: "$ 525.50", + partner_id: 8, + account_name: "Bank", + name: "BNK1/2017/0004: CUST.IN/2017/0002", + partner_name: "Agrolait", + total_amount_currency_str: "", + id: 396, + credit: 0.0, + journal_id: "Bank", + amount_str: "$ 525.50", + debit: 525.5, + account_code: "101401", + ref: "INV/2017/0003", + already_paid: true, + }, + { + account_type: "receivable", + amount_currency_str: "", + currency_id: false, + date_maturity: "2017-02-07", + date: "2017-01-08", + total_amount_str: "$ 650.00", + partner_id: 8, + account_name: "101200 Account Receivable", + name: "INV/2017/0002", + partner_name: "Agrolait", + total_amount_currency_str: "", + id: 109, + credit: 0.0, + journal_id: [1, "Customer Invoices"], + amount_str: "$ 650.00", + debit: 650.0, + account_code: "101200", + ref: "", + already_paid: false, + }, + { + account_type: "receivable", + amount_currency_str: "", + currency_id: false, + date_maturity: "2017-02-22", + date: "2017-01-23", + total_amount_str: "$ 525.00", + partner_id: 8, + account_name: "101200 Account Receivable", + name: "INV/2017/0004", + partner_name: "Agrolait", + total_amount_currency_str: "", + id: 399, + credit: 0.0, + journal_id: [1, "Customer Invoices"], + amount_str: "$ 525.00", + debit: 525.0, + account_code: "101200", + ref: "", + already_paid: false, + }, + ], + '[8,"",0]': [], + }; + + var auto_reconciliation = { + num_already_reconciled_lines: 1, + notifications: [ + { + message: "1 transaction was automatically reconciled.", + type: "info", + details: { + model: "account.move", + name: "Automatically reconciled items", + ids: [143], + }, + }, + ], + st_lines_ids: [5, 6, 8], + statement_name: false, + }; + + var data_for_manual_reconciliation_widget = { + "[null,[282,283,284,285,286,287,288,308,499,500,501,502,503,504]]": { + customers: [ + { + account_id: 287, + partner_name: "Agrolait", + reconciliation_proposition: [], + currency_id: 3, + max_date: "2017-02-14 12:30:31", + last_time_entries_checked: null, + account_code: "101200", + partner_id: 8, + account_name: "101200 Account Receivable", + mode: "customers", + }, + { + account_id: 7, + partner_name: "Camptocamp", + reconciliation_proposition: [], + currency_id: 3, + max_date: "2017-02-13 14:24:55", + last_time_entries_checked: null, + account_code: "101200", + partner_id: 12, + account_name: "101200 Account Receivable", + mode: "customers", + }, + ], + accounts: [ + { + account_id: 283, + account_name: "101000 Current Assets", + currency_id: 3, + max_date: "2017-02-16 14:32:04", + last_time_entries_checked: "2017-02-16", + account_code: "101000", + mode: "accounts", + reconciliation_proposition: [ + { + account_id: 283, + account_type: "other", + amount_currency_str: "", + currency_id: false, + date_maturity: "2017-02-16", + date: "2017-02-16", + total_amount_str: "$ 1,000.00", + partner_id: 8, + account_name: "101000 Current Assets", + name: "BNK1/2017/0006: Customer Payment", + partner_name: "Agrolait", + total_amount_currency_str: "", + id: 399, + credit: 1000.0, + journal_id: [3, "Bank"], + amount_str: "$ 1,000.00", + debit: 0.0, + account_code: "101000", + ref: "", + already_paid: false, + }, + { + account_id: 283, + account_type: "other", + amount_currency_str: "", + currency_id: false, + date_maturity: "2017-03-18", + date: "2017-02-16", + total_amount_str: "$ 1,000.00", + partner_id: 8, + account_name: "101000 Current Assets", + name: "INV/2017/0006", + partner_name: "Agrolait", + total_amount_currency_str: "", + id: 402, + credit: 0.0, + journal_id: [1, "Customer Invoices"], + amount_str: "$ 1,000.00", + debit: 1000.0, + account_code: "101000", + ref: "", + already_paid: false, + }, + ], + }, + ], + suppliers: [ + { + account_id: 284, + partner_name: "Agrolait", + reconciliation_proposition: [ + { + account_id: 284, + account_type: "other", + amount_currency_str: "", + currency_id: false, + date_maturity: "2017-02-16", + date: "2017-02-16", + total_amount_str: "$ 1,000.00", + partner_id: 8, + account_name: "101000 Current Assets", + name: "BNK1/999: Customer Payment", + partner_name: "Agrolait", + total_amount_currency_str: "", + id: 999, + credit: 1000.0, + journal_id: [3, "Bank"], + amount_str: "$ 1,000.00", + debit: 0.0, + account_code: "111100", + ref: "", + already_paid: false, + }, + { + account_id: 284, + account_type: "other", + amount_currency_str: "", + currency_id: false, + date_maturity: "2017-03-18", + date: "2017-02-16", + total_amount_str: "$ 1,000.00", + partner_id: 8, + account_name: "101000 Current Assets", + name: "INV/998", + partner_name: "Agrolait", + total_amount_currency_str: "", + id: 998, + credit: 0.0, + journal_id: [1, "Customer Invoices"], + amount_str: "$ 1,000.00", + debit: 1000.0, + account_code: "111100", + ref: "", + already_paid: false, + }, + ], + currency_id: 3, + max_date: "2017-02-14 12:36:05", + last_time_entries_checked: null, + account_code: "111100", + partner_id: 8, + account_name: "Account Payable", + mode: "suppliers", + }, + { + account_id: 284, + partner_name: "Camptocamp", + reconciliation_proposition: [ + { + account_id: 284, + account_type: "other", + amount_currency_str: "", + currency_id: false, + date_maturity: "2017-02-16", + date: "2017-02-16", + total_amount_str: "$ 1,000.00", + partner_id: 12, + account_name: "101000 Current Assets", + name: "BNK1/1999: Customer Payment", + partner_name: "Camptocamp", + total_amount_currency_str: "", + id: 1999, + credit: 1000.0, + journal_id: [3, "Bank"], + amount_str: "$ 1,000.00", + debit: 0.0, + account_code: "111100", + ref: "", + already_paid: false, + }, + { + account_id: 284, + account_type: "other", + amount_currency_str: "", + currency_id: false, + date_maturity: "2017-03-18", + date: "2017-02-16", + total_amount_str: "$ 1,000.00", + partner_id: 12, + account_name: "101000 Current Assets", + name: "INV/1998", + partner_name: "Camptocamp", + total_amount_currency_str: "", + id: 1998, + credit: 0.0, + journal_id: [1, "Customer Invoices"], + amount_str: "$ 1,000.00", + debit: 1000.0, + account_code: "111100", + ref: "", + already_paid: false, + }, + ], + currency_id: 3, + max_date: "2017-02-14 12:36:05", + last_time_entries_checked: null, + account_code: "111100", + partner_id: 12, + account_name: "Account Payable", + mode: "suppliers", + }, + ], + }, + '["partner",null,"receivable"]': [ + { + account_id: 287, + partner_name: "Agrolait", + reconciliation_proposition: [], + currency_id: 3, + max_date: "2017-02-14 12:30:31", + last_time_entries_checked: null, + account_code: "101200", + partner_id: 8, + account_name: "101200 Account Receivable", + mode: "customers", + }, + { + account_id: 287, + partner_name: "Camptocamp", + reconciliation_proposition: [], + currency_id: 3, + max_date: "2017-02-13 14:24:55", + last_time_entries_checked: null, + account_code: "101200", + partner_id: 12, + account_name: "101200 Account Receivable", + mode: "customers", + }, + ], + }; + + var move_lines_for_manual_reconciliation = { + '[287,8,"",0]': [ + { + account_type: "receivable", + account_id: [287, "101200 Account Receivable"], + amount_currency_str: "10,222.00 €", + currency_id: 1, + date_maturity: "2017-02-08", + date: "2017-02-08", + total_amount_str: "$ 11,000.00", + partner_id: 8, + account_name: "101200 Account Receivable", + name: "INV/2017/0004: Customer Payment", + partner_name: "Agrolait", + total_amount_currency_str: "", + id: 17, + credit: 11000.0, + journal_id: [1, "Customer Invoices"], + amount_str: "$ 11,000.00", + debit: 0.0, + account_code: "101200", + ref: "", + already_paid: false, + }, + { + account_type: "receivable", + account_id: [7, "101200 Account Receivable"], + amount_currency_str: "", + currency_id: false, + date_maturity: "2017-02-09", + date: "2017-02-09", + total_amount_str: "$ 1,000.00", + partner_id: 8, + account_name: "101200 Account Receivable", + name: "INV/2017/0005: Customer Payment", + partner_name: "Agrolait", + total_amount_currency_str: "", + id: 19, + credit: 1000.0, + journal_id: [1, "Customer Invoices"], + amount_str: "$ 1,000.00", + debit: 0.0, + account_code: "101200", + ref: "", + already_paid: false, + }, + { + account_type: "receivable", + account_id: [287, "101200 Account Receivable"], + amount_currency_str: "", + currency_id: false, + date_maturity: "2017-02-09", + date: "2017-02-09", + total_amount_str: "$ 180.00", + partner_id: 8, + account_name: "101200 Account Receivable", + name: "BILL/2017/0003: Customer Payment", + partner_name: "Agrolait", + total_amount_currency_str: "", + id: 21, + credit: 180.0, + journal_id: [2, "Vendor Bills"], + amount_str: "$ 180.00", + debit: 0.0, + account_code: "101200", + ref: "fddfgfdgfdgsdfg", + already_paid: false, + }, + { + account_type: "receivable", + account_id: [287, "101200 Account Receivable"], + amount_currency_str: "", + currency_id: false, + date_maturity: "2017-02-09", + date: "2017-02-09", + total_amount_str: "$ 90.00", + partner_id: 8, + account_name: "101200 Account Receivable", + name: "INV/2017/0006: Customer Payment", + partner_name: "Agrolait", + total_amount_currency_str: "", + id: 23, + credit: 90.0, + journal_id: [1, "Customer Invoices"], + amount_str: "$ 90.00", + debit: 0.0, + account_code: "101200", + ref: "", + already_paid: false, + }, + { + account_type: "receivable", + account_id: [287, "101200 Account Receivable"], + amount_currency_str: "", + currency_id: false, + date_maturity: "2017-03-10", + date: "2017-02-08", + total_amount_str: "$ 650.00", + partner_id: 8, + account_name: "101200 Account Receivable", + name: "INV/2017/0012", + partner_name: "Agrolait", + total_amount_currency_str: "", + id: 6, + credit: 0.0, + journal_id: [1, "Customer Invoices"], + amount_str: "$ 1000.00", + debit: 1000.0, + account_code: "101200", + ref: "", + already_paid: false, + }, + { + account_type: "receivable", + account_id: [287, "101200 Account Receivable"], + amount_currency_str: "", + currency_id: false, + date_maturity: "2017-03-10", + date: "2017-02-08", + total_amount_str: "$ 525.00", + partner_id: 8, + account_name: "101200 Account Receivable", + name: "INV/2017/0003", + partner_name: "Agrolait", + total_amount_currency_str: "", + id: 9, + credit: 0.0, + journal_id: [1, "Customer Invoices"], + amount_str: "$ 525.00", + debit: 525.0, + account_code: "101200", + ref: "", + already_paid: false, + }, + ], + '[7,12,"",0]': [ + { + account_type: "receivable", + account_id: [287, "101200 Account Receivable"], + amount_currency_str: "", + currency_id: false, + date_maturity: "2017-02-08", + date: "2017-02-08", + total_amount_str: "$ 11,000.00", + partner_id: 12, + account_name: "101200 Account Receivable", + name: "INV/2017/0004: Customer Payment", + partner_name: "Camptocamp", + total_amount_currency_str: "", + id: 17, + credit: 11000.0, + journal_id: [1, "Customer Invoices"], + amount_str: "$ 11,000.00", + debit: 0.0, + account_code: "101200", + ref: "", + already_paid: false, + }, + { + account_type: "receivable", + account_id: [7, "101200 Account Receivable"], + amount_currency_str: "", + currency_id: false, + date_maturity: "2017-02-09", + date: "2017-02-09", + total_amount_str: "$ 1,000.00", + partner_id: 12, + account_name: "101200 Account Receivable", + name: "INV/2017/0005: Customer Payment", + partner_name: "Camptocamp", + total_amount_currency_str: "", + id: 19, + credit: 1000.0, + journal_id: [1, "Customer Invoices"], + amount_str: "$ 1,000.00", + debit: 0.0, + account_code: "101200", + ref: "", + already_paid: false, + }, + { + account_type: "receivable", + account_id: [287, "101200 Account Receivable"], + amount_currency: 100, + amount_currency_str: "100.00 €", + currency_id: 1, + date_maturity: "2017-02-09", + date: "2017-02-09", + total_amount_str: "$ 170.00", + partner_id: 12, + account_name: "101200 Account Receivable", + name: "INV/2017/0003: Customer Payment", + partner_name: "Camptocamp", + total_amount_currency_str: "", + id: 21, + credit: 0.0, + journal_id: [1, "Customer Invoices"], + amount_str: "$ 170.00", + debit: 170.0, + account_code: "101200", + ref: "INV fddfgfdgfdgsdfg", + already_paid: false, + }, + { + account_type: "receivable", + account_id: [287, "101200 Account Receivable"], + amount_currency: 100, + amount_currency_str: "100.00 €", + currency_id: 1, + date_maturity: "2017-02-10", + date: "2017-02-10", + total_amount_str: "$ 180.00", + partner_id: 12, + account_name: "101200 Account Receivable", + name: "BILL/2017/0003: Customer Payment", + partner_name: "Camptocamp", + total_amount_currency_str: "", + id: 22, + credit: 180.0, + journal_id: [2, "Vendor Bills"], + amount_str: "$ 180.00", + debit: 0.0, + account_code: "101200", + ref: "fddfgfdgfdgsdfg", + already_paid: false, + }, + { + account_type: "receivable", + account_id: [287, "101200 Account Receivable"], + amount_currency: 170, + amount_currency_str: "170.00 €", + currency_id: 1, + date_maturity: "2017-02-09", + date: "2017-02-09", + total_amount_str: "$ 100.00", + partner_id: 12, + account_name: "101200 Account Receivable", + name: "INV/2017/0003: Customer Payment", + partner_name: "Camptocamp", + total_amount_currency_str: "", + id: 23, + credit: 0.0, + journal_id: [1, "Customer Invoices"], + amount_str: "$ 100.00", + debit: 100.0, + account_code: "101200", + ref: "INV fddfgfdgfdgsdfg", + already_paid: false, + }, + { + account_type: "receivable", + account_id: [287, "101200 Account Receivable"], + amount_currency: 180, + amount_currency_str: "180.00 €", + currency_id: 1, + date_maturity: "2017-02-10", + date: "2017-02-10", + total_amount_str: "$ 100.00", + partner_id: 12, + account_name: "101200 Account Receivable", + name: "BILL/2017/0003: Customer Payment", + partner_name: "Camptocamp", + total_amount_currency_str: "", + id: 24, + credit: 100.0, + journal_id: [2, "Vendor Bills"], + amount_str: "$ 100.00", + debit: 0.0, + account_code: "101200", + ref: "fddfgfdgfdgsdfg", + already_paid: false, + }, + ], + '[284,8,"",0]': [ + { + account_type: "receivable", + account_id: [284, "111100 Account Payable"], + amount_currency_str: "", + currency_id: false, + date_maturity: "2017-02-08", + date: "2017-02-08", + total_amount_str: "$ 11,000.00", + partner_id: 8, + account_name: "101200 Account Receivable", + name: "INV/2017/0004: Customer Payment", + partner_name: "Agrolait", + total_amount_currency_str: "", + id: 17, + credit: 11000.0, + journal_id: [1, "Customer Invoices"], + amount_str: "$ 11,000.00", + debit: 0.0, + account_code: "111100", + ref: "", + already_paid: false, + }, + { + account_type: "receivable", + account_id: [284, "111100 Account Payable"], + amount_currency_str: "", + currency_id: false, + date_maturity: "2017-02-09", + date: "2017-02-09", + total_amount_str: "$ 1,000.00", + partner_id: 8, + account_name: "101200 Account Receivable", + name: "INV/2017/0005: Customer Payment", + partner_name: "Agrolait", + total_amount_currency_str: "", + id: 19, + credit: 1000.0, + journal_id: [1, "Customer Invoices"], + amount_str: "$ 1,000.00", + debit: 0.0, + account_code: "111100", + ref: "", + already_paid: false, + }, + { + account_type: "receivable", + account_id: [284, "111100 Account Payable"], + amount_currency_str: "", + currency_id: false, + date_maturity: "2017-02-09", + date: "2017-02-09", + total_amount_str: "$ 180.00", + partner_id: 8, + account_name: "101200 Account Receivable", + name: "BILL/2017/0003: Customer Payment", + partner_name: "Agrolait", + total_amount_currency_str: "", + id: 21, + credit: 180.0, + journal_id: [2, "Vendor Bills"], + amount_str: "$ 180.00", + debit: 0.0, + account_code: "111100", + ref: "fddfgfdgfdgsdfg", + already_paid: false, + }, + ], + '[283,null,"",0]': [ + { + account_type: "other", + amount_currency_str: "", + currency_id: false, + date_maturity: "2017-02-16", + date: "2017-02-16", + total_amount_str: "$ 1,000.00", + partner_id: 8, + account_name: "101000 Current Assets", + name: "BNK1/2017/0006: Customer Payment", + partner_name: "Agrolait", + total_amount_currency_str: "", + id: 399, + credit: 1000.0, + journal_id: [3, "Bank"], + amount_str: "$ 1,000.00", + debit: 0.0, + account_code: "101000", + ref: "", + already_paid: false, + }, + { + account_type: "other", + amount_currency_str: "", + currency_id: false, + date_maturity: "2017-03-18", + date: "2017-02-16", + total_amount_str: "$ 1,000.00", + partner_id: 8, + account_name: "101000 Current Assets", + name: "INV/2017/0006", + partner_name: "Agrolait", + total_amount_currency_str: "", + id: 402, + credit: 0.0, + journal_id: [1, "Customer Invoices"], + amount_str: "$ 1,000.00", + debit: 1000.0, + account_code: "101000", + ref: "", + already_paid: false, + }, + ], + '[284,12,"",0]': [], + }; + + var session = { + currencies: { + 3: { + digits: [69, 2], + position: "before", + symbol: "$", + }, + }, + }; + + var options = { + context: { + statement_line_ids: [4], + }, + params: { + limitMoveLines: 5, + }, + }; + + Datas.params = { + data: db, + data_preprocess: data_preprocess, + data_widget: data_widget, + mv_lines: mv_lines, + auto_reconciliation: auto_reconciliation, + data_for_manual_reconciliation_widget: data_for_manual_reconciliation_widget, + move_lines_for_manual_reconciliation: move_lines_for_manual_reconciliation, + session: session, + options: options, + }; + // This is the main function for this module. Its job is to export (and clone) all data for a test. + Datas.getParams = function () { + return (this.used = $.extend(true, {}, this.params)); + }; + return Datas; +}); + +odoo.define("account.reconciliation_tests", function (require) { + "use strict"; + + var ReconciliationClientAction = require("account.ReconciliationClientAction"); + var ReconciliationRenderer = require("account.ReconciliationRenderer"); + var demoData = require("account.reconciliation_tests.data"); + + var testUtils = require("web.test_utils"); + var testUtilsDom = require("web.test_utils_dom"); + var testUtilsMock = require("web.test_utils_mock"); + + QUnit.module( + "account", + { + beforeEach: function () { + this.params = demoData.getParams(); + testUtils.patch(ReconciliationRenderer.LineRenderer, { + MV_LINE_DEBOUNCE: 0, + }); + }, + afterEach: function () { + testUtils.unpatch(ReconciliationRenderer.LineRenderer); + }, + }, + function () { + QUnit.module("Reconciliation"); + + QUnit.test("Reconciliation basic rendering", async function (assert) { + assert.expect(10); + + var clientAction = new ReconciliationClientAction.StatementAction( + null, + this.params.options + ); + testUtils.mock.addMockEnvironment(clientAction, { + data: this.params.data, + archs: { + "account.bank.statement.line,false,search": + '', + }, + }); + await clientAction.appendTo($("#qunit-fixture")); + await testUtils.nextTick(); + var widget = clientAction.widgets[0]; + + assert.hasClass( + widget.$el, + "o_reconciliation_line", + "should instance of widget reconciliation" + ); + assert.containsOnce(widget, ".accounting_view", "should have one view"); + assert.containsN( + widget, + '[id*="notebook_page_match"]', + 2, + "should have 'match_rp' and 'match_other' panel" + ); + assert.containsOnce(widget, ".create", "should have 'create' panel"); + + assert.strictEqual( + widget + .$("thead") + .text() + .replace(/[\n\r\s]+/g, " "), + " 101401 2017-01-01 SAJ/2014/002 and SAJ/2014/003 $ 1,175.00 ", + "should display the line information" + ); + assert.ok( + widget.$("caption .o_field_many2one").length, + "should display the many2one with to select a partner" + ); + + assert.containsN( + clientAction, + '[data-mode="inactive"]', + 3, + "should be as 'inactive' mode by default" + ); + assert.strictEqual( + widget.$el.data("mode"), + "match_rp", + "the first one should automatically switch to match_rp mode" + ); + + await testUtils.dom.click( + widget.$('.o_notebook .nav-link[href*="notebook_page_create"]') + ); + assert.strictEqual( + widget.$el.data("mode"), + "create", + "should switch to 'create' mode" + ); + + await testUtils.dom.click( + widget.$('.o_notebook .nav-link[href*="notebook_page_match_rp"]') + ); + assert.strictEqual( + widget.$el.data("mode"), + "match_rp", + "should switch to 'match_rp' mode" + ); + + clientAction.destroy(); + }); + + QUnit.test("Reconciliation fields", async function (assert) { + assert.expect(6); + + var clientAction = new ReconciliationClientAction.StatementAction( + null, + this.params.options + ); + testUtils.mock.addMockEnvironment(clientAction, { + data: this.params.data, + session: { + currencies: { + 3: { + digits: [69, 2], + position: "before", + symbol: "$", + }, + }, + user_has_group: function (group) { + if ( + group === "analytic.group_analytic_tags" || + group === "analytic.group_analytic_accounting" + ) { + return $.when(true); + } + return this._super.apply(this, arguments); + }, + }, + archs: { + "account.bank.statement.line,false,search": + '', + }, + }); + await clientAction.appendTo($("#qunit-fixture")); + await testUtils.nextTick(); + + var widget = clientAction.widgets[0]; + + assert.strictEqual( + widget.$(".o_input_dropdown input").val(), + "Agrolait", + "the partner many2one should display agrolait" + ); + assert.strictEqual( + clientAction.widgets[2].$(".o_input_dropdown input").val(), + "Camptocamp", + "the partner many2one should display Camptocamp" + ); + await testUtils.dom.click(widget.$(".accounting_view tfoot td:first")); + await testUtils.dom.click( + widget.$('.o_notebook .nav-link[href*="notebook_page_create"]') + ); + assert.containsN( + widget, + ".create input.o_input", + 8, + "create panel should contain 8 fields (account_id, tax_id, journal_id, analytic_account_id, analytic_tag_ids, label, amount, date)" + ); + assert.containsN( + widget, + ".create .create_account_id .o_required_modifier, .create .create_label .o_required_modifier, .create .create_amount .o_required_modifier", + 3, + "account_id, label and amount should be required fields" + ); + assert.strictEqual( + widget.$(".create .create_label input").val(), + "SAJ/2014/002 and SAJ/2014/003", + "should use the name of the reconciliation line for the default label value" + ); + assert.strictEqual( + widget.$(".create .create_amount input").val(), + "1175.00", + "should have the balance amout as default value for the amout field" + ); + + clientAction.destroy(); + }); + + QUnit.test("Reconciliation basic data", async function (assert) { + assert.expect(17); + + var clientAction = new ReconciliationClientAction.StatementAction( + null, + this.params.options + ); + testUtils.mock.addMockEnvironment(clientAction, { + data: this.params.data, + session: { + currencies: { + 3: { + digits: [69, 2], + position: "before", + symbol: "$", + }, + }, + }, + archs: { + "account.bank.statement.line,false,search": + '', + }, + }); + + await clientAction.appendTo($("#qunit-fixture")); + await testUtils.nextTick(); + var widget = clientAction.widgets[0]; + + assert.containsN( + widget, + ".match:first .mv_line", + 2, + "should display 2 account move lines" + ); + assert.strictEqual( + widget + .$(".match:first .mv_line") + .text() + .replace(/[\n\r\s]+/g, " ") + .replace(/[\u200B]/g, ""), + " 101200 2017-02-07 INV/2017/0002 $ 650.00 101200 2017-02-07 INV/2017/0003 $ 525.00 ", + "should display 4 account move lines who contains the account_code, due_date, label and the credit" + ); + assert.strictEqual( + widget.$('.match:first .mv_line .cell_right:contains(".")').length, + 2, + "should display only the credit account move lines (hide the debit)" + ); + + await testUtils.dom.click( + clientAction.widgets[1].$(".accounting_view thead td:first") + ); + assert.containsN( + clientAction.widgets[1], + ".mv_line", + 5, + "should display 5 account move lines" + ); + assert.strictEqual( + clientAction.widgets[1].$('.mv_line .cell_right:contains(".")') + .length, + 3, + "should display only the credit account move lines (hide the debit)" + ); + assert.containsN( + clientAction.widgets[1], + ".mv_line.already_reconciled", + 3, + "should display 3 already reconciled account move lines" + ); + assert.strictEqual( + clientAction.widgets[1] + .$(".mv_line") + .text() + .replace(/[\n\r\s]+/g, " ") + .replace(/[\u200B]/g, ""), + " 101401 2017-01-23 ASUSTeK: BNK1/2017/0002: SUPP.OUT/2017/0002 : BILL/2017/0003 $ 376.00 101401 2017-01-23 Agrolait: BNK1/2017/0003: CUST.IN/2017/0001 $ 100.00 111100 2017-02-28 Camptocamp: BILL/2017/0001 $ 10,000.00 101401 2017-01-23 Agrolait: BNK1/2017/0004: CUST.IN/2017/0002 : INV/2017/0003 $ 525.50 101200 2017-02-07 Agrolait: INV/2017/0002 $ 650.00 ", + "should display 4 account move lines who contains the account_code, due_date, label and the credit" + ); + assert.strictEqual( + clientAction.widgets[1].$('.mv_line .cell_left:contains(".")') + .length, + 2, + "should display only 2 debit account move lines" + ); + + // Load more + assert.ok( + clientAction.widgets[1].$(".match:first div.load-more a:visible") + .length, + "should display the 'load more' button" + ); + assert.equal( + clientAction.widgets[1].$(".match:first div.load-more span").text(), + 3, + "should display 3 items remaining" + ); + await testUtils.dom.click( + clientAction.widgets[1].$(".match:first div.load-more a") + ); + assert.containsN( + clientAction.widgets[1], + ".mv_line", + 8, + "should load 3 more records" + ); + assert.notOk( + clientAction.widgets[1].$(".match:first div.load-more a:visible") + .length, + "should not display the 'load more' button anymore" + ); + + assert.ok( + clientAction.widgets[0].$("caption button.btn-secondary:visible") + .length, + "should display the secondary 'Validate' button" + ); + assert.equal( + clientAction.widgets[1].$("caption button:disabled:visible").length, + 1, + "button should be disabled" + ); + assert.ok( + clientAction.widgets[2].$("caption button.btn-primary:visible") + .length, + "should display the primary 'Validate' button" + ); + + await testUtils.dom.click( + clientAction.widgets[3].$(".accounting_view thead td:first") + ); + assert.strictEqual( + clientAction.widgets[3].$el.data("mode"), + "create", + "should switch to 'create' mode instead of 'match_rp' mode when 'match_rp' mode is empty" + ); + + // Open the first line + await testUtils.dom.click(widget.$(".accounting_view thead td:first")); + await testUtils.dom.click( + widget.$('.o_notebook .nav-link[href*="notebook_page_match_rp"]') + ); + // Select propositions + await testUtils.dom.click( + widget.$(".match:first .cell_account_code:first") + ); + await testUtils.dom.click( + widget.$(".match:first .cell_account_code:first") + ); + + // Await testUtils.dom.click(widget.$('caption')); //why is it inactive? + + testUtils.mock.intercept(clientAction, "call_service", function ( + event + ) { + if (event.data.args[1].method == "process_bank_statement_line") { + assert.deepEqual( + event.data.args[1].args, + [ + [5], + [ + { + partner_id: 8, + counterpart_aml_dicts: [ + { + counterpart_aml_id: 109, + credit: 650, + debit: 0, + name: "INV/2017/0002", + analytic_tag_ids: [[6, null, []]], + }, + { + counterpart_aml_id: 112, + credit: 525, + debit: 0, + name: "INV/2017/0003", + analytic_tag_ids: [[6, null, []]], + }, + ], + payment_aml_ids: [], + new_aml_dicts: [], + to_check: false, + }, + ], + ], + "Should call process_bank_statement_line with args" + ); + var def = testUtils.makeTestPromise(); + def.abort = function () {}; + event.data.callback(def); + } + }); + + // Click on reconcile button + await testUtils.dom.click(widget.$(".o_reconcile:visible")); + + clientAction.destroy(); + }); + + QUnit.test("Reconciliation validate without proposition", async function ( + assert + ) { + assert.expect(1); + // Test added to prevent this issue happening again: https://github.com/odoo/odoo/commit/3549688b21eb65e16b9c3f2b6462eb8d8b52cd47 + var clientAction = new ReconciliationClientAction.StatementAction( + null, + this.params.options + ); + testUtils.mock.addMockEnvironment(clientAction, { + data: this.params.data, + session: { + currencies: { + 3: { + digits: [69, 2], + position: "before", + symbol: "$", + }, + }, + }, + archs: { + "account.bank.statement.line,false,search": + '', + }, + }); + await clientAction.appendTo($("#qunit-fixture")); + await testUtils.nextTick(); + + var widget = clientAction.widgets[0]; + // Ensure that when we validate a line without any selection, it is the same + // as when we manually create a line with the line.balance and that only one + // line is send back to server. + testUtils.mock.intercept(clientAction, "call_service", function ( + event + ) { + assert.deepEqual( + event.data.args[1].args, + [ + [5], + [ + { + partner_id: 8, + to_check: false, + counterpart_aml_dicts: [], + payment_aml_ids: [], + to_check: false, + new_aml_dicts: [ + { + account_id: 287, + credit: 1175, + debit: 0, + name: "SAJ/2014/002 and SAJ/2014/003", + analytic_tag_ids: [[6, null, []]], + }, + ], + }, + ], + ], + "Should call process_bank_statement_line with ids" + ); + var def = testUtils.makeTestPromise(); + def.abort = function () {}; + event.data.callback(def); + }); + + // Click on validate button + await testUtils.dom.click(widget.$("button.o_validate:not(:hidden)")); + + clientAction.destroy(); + }); + + QUnit.test("Reconciliation validate with proposition", async function ( + assert + ) { + assert.expect(1); + // Test added to check this functionality: https://github.com/odoo/odoo/commit/2f3b469dee6f18cbccce1cdf2a81cfe57960c533 + var clientAction = new ReconciliationClientAction.StatementAction( + null, + this.params.options + ); + testUtils.mock.addMockEnvironment(clientAction, { + data: this.params.data, + session: { + currencies: { + 3: { + digits: [69, 2], + position: "before", + symbol: "$", + }, + }, + }, + archs: { + "account.bank.statement.line,false,search": + '', + }, + }); + + await clientAction.appendTo($("#qunit-fixture")); + await testUtils.nextTick(); + var widget = clientAction.widgets[0]; + // Add a line as proposition + // open the first line + await testUtils.dom.click(widget.$(".accounting_view thead td:first"), { + allowInvisible: true, + }); + await testUtils.nextTick(); + // Select propositions + await testUtils.dom.click( + widget.$(".match:first .cell_account_code:first"), + {allowInvisible: true} + ); + await testUtils.nextTick(); + + // Ensure that when we validate a line with propositions and that there is a remaining balance + // We also create a line which is the open balance. + testUtils.mock.intercept(clientAction, "call_service", function ( + event + ) { + assert.deepEqual( + event.data.args[1].args, + [ + [5], + [ + { + partner_id: 8, + to_check: false, + counterpart_aml_dicts: [ + { + counterpart_aml_id: 109, + credit: 650, + debit: 0, + name: "INV/2017/0002", + analytic_tag_ids: [[6, null, []]], + }, + ], + payment_aml_ids: [], + new_aml_dicts: [ + { + account_id: 287, + credit: 525, + debit: 0, + name: + "SAJ/2014/002 and SAJ/2014/003 : Open balance", + }, + ], + }, + ], + ], + "Should call process_bank_statement_line with ids" + ); + var def = testUtils.makeTestPromise(); + def.abort = function () {}; + event.data.callback(def); + }); + + // Click on validate button + await testUtils.dom.click(widget.$("button.o_validate:not(:hidden)")); + clientAction.destroy(); + }); + + QUnit.test("Reconciliation partial [REQUIRE FOCUS]", async function ( + assert + ) { + assert.expect(8); + + var clientAction = new ReconciliationClientAction.StatementAction( + null, + this.params.options + ); + testUtils.mock.addMockEnvironment(clientAction, { + data: this.params.data, + mockRPC: function (route, args) { + console.log(args.method); + if (args.method === "process_bank_statement_line") { + var lines = args.args["1"]; + console.log(args.arsg); + assert.deepEqual( + args.args, + [ + [6], + [ + { + partner_id: + lines.length == 1 + ? lines[0].partner_id + : false, + counterpart_aml_dicts: [ + { + analytic_tag_ids: [[6, null, []]], + counterpart_aml_id: 114, + credit: 0, + debit: 32.58, + name: "BILL/2017/0001", + }, + ], + payment_aml_ids: [], + new_aml_dicts: [], + to_check: false, + }, + ], + ], + "should call process_bank_statement_line with partial reconcile values" + ); + } + return this._super(route, args); + }, + session: { + currencies: { + 3: { + digits: [69, 2], + position: "before", + symbol: "$", + }, + }, + }, + archs: { + "account.bank.statement.line,false,search": + '', + }, + }); + + await clientAction.appendTo($("#qunit-fixture")); + await testUtils.nextTick(); + var widget = clientAction.widgets[0]; + + await testUtils.dom.click( + widget.$(".match:first .cell_account_code:first") + ); + assert.equal( + widget.$(".cell_right .edit_amount").length, + 1, + "should display the edition pencil" + ); + + widget = clientAction.widgets[1]; + + await testUtils.dom.click(widget.$(".accounting_view thead td:first")); + assert.strictEqual( + widget.$( + ".match:first .mv_line[data-line-id=114] .cell_account_code:first()" + ).length, + 1, + "Should have line" + ); + await testUtils.dom.click( + widget.$( + ".match:first .mv_line[data-line-id=114] .cell_account_code" + ) + ); + + assert.equal( + widget.$(".accounting_view tbody .cell_left .edit_amount").length, + 1, + "should display the edition pencil" + ); + + // The partner has been set automatically, remove it. + await testUtils.dom.click(widget.$(".o_input_dropdown input")); + await testUtils.fields.editAndTrigger( + widget.$(".o_input_dropdown input"), + "", + ["keyup", "blur"] + ); + + assert.equal( + clientAction.widgets[1].$("caption button:disabled:visible").length, + 1, + "button should be disabled" + ); + await testUtils.dom.click( + widget.$(".accounting_view .cell_left .edit_amount") + ); + assert.strictEqual( + widget.$( + ".accounting_view .cell_left .edit_amount_input:not(.d-none)" + ).length, + 1, + "should display the input field to edit amount" + ); + // Edit amount + await testUtils.fields.editAndTrigger( + widget.$( + ".accounting_view .cell_left .edit_amount_input:not(.d-none)" + ), + "32.58", + ["change", "blur"] + ); + assert.strictEqual( + widget + .$(".accounting_view .cell_left .line_amount") + .text() + .replace(/[\n\r\s]+/g, " "), + " $ 10000.00 $ 32.58 ", + "should display previous amount and new amount" + ); + + assert.strictEqual( + widget.$("button.btn-primary:visible").length, + 1, + "should display the reconcile button" + ); + await testUtils.dom.click(widget.$("button.btn-primary:visible")); + + clientAction.destroy(); + }); + + QUnit.test("Reconciliation currencies", async function (assert) { + assert.expect(2); + + var clientAction = new ReconciliationClientAction.StatementAction( + null, + this.params.options + ); + + testUtils.mock.addMockEnvironment(clientAction, { + data: this.params.data, + session: this.params.session, + translateParameters: { + date_format: "%m/%d/%Y", + direction: "ltr", + name: "English", + thousands_sep: ",", + time_format: "%H:%M:%S", + decimal_point: ".", + id: 1, + grouping: [3, 0], + }, + archs: { + "account.bank.statement.line,false,search": + '', + }, + }); + await clientAction.appendTo($("#qunit-fixture")); + await testUtils.nextTick(); + + var widget = clientAction.widgets[0]; + + assert.strictEqual( + clientAction + .$( + ".accounting_view tfoot .cell_right, .accounting_view tfoot .cell_left" + ) + .text() + .replace(/[\n\r\s]+/g, " "), + "$ 1,175.00$ 32.58$ 2,000.00", + "should display the different amounts with the currency" + ); + // Await testUtils.dom.click(widget.$('.accounting_view thead .mv_line td:first')); + + assert.strictEqual( + clientAction + .$(".accounting_view tbody") + .text() + .replace(/[\n\r\s]+/g, " ") + .replace(/[\u200B]/g, ""), + " 101200 2017-02-07 INV/2017/0012 $ 650.00 ", + "should display the created reconciliation line with the currency" + ); + + clientAction.destroy(); + }); + + QUnit.test("Reconciliation change partner", async function (assert) { + assert.expect(17); + + var clientAction = new ReconciliationClientAction.StatementAction( + null, + this.params.options + ); + + testUtils.mock.addMockEnvironment(clientAction, { + data: this.params.data, + archs: { + "res.partner,false,list": + '', + "res.partner,false,search": + '' + + '' + + "", + "account.bank.statement.line,false,search": + '', + }, + }); + + await clientAction.appendTo($("#qunit-fixture")); + await testUtils.nextTick(); + var widget = clientAction.widgets[0]; + assert.strictEqual( + widget.$(".o_input_dropdown input").val(), + "Agrolait", + "the partner many2one should display agrolait" + ); + assert.containsN( + widget, + ".match:first table tr", + 2, + "agrolait should have 2 propositions for reconciliation" + ); + + // Adding the two propositions + // This is in order to try that after changing partner the propositions are emptied + await testUtils.dom.click( + widget.$(".match:first .cell_account_code:first") + ); + await testUtils.dom.click( + widget.$(".match:first .cell_account_code:first") + ); + assert.containsN( + widget, + ".accounting_view tbody tr", + 2, + "Both proposition should be selected" + ); + + // Similate changing partner to one that does not have propositions to see if create mode is open after + await testUtils.dom.click(widget.$(".o_input_dropdown input")); + $(".ui-autocomplete .ui-menu-item a:contains(partner 1)") + .trigger("mouseenter") + .trigger("click"); + await testUtils.nextTick(); + clientAction._onAction({ + target: widget, + name: "change_partner", + data: {data: {display_name: "partner 1", id: 1}}, + stopped: false, + }); + await testUtils.nextTick(); + assert.strictEqual( + widget.$(".o_input_dropdown input").val(), + "partner 1", + "the partner many2one should display partner 1" + ); + assert.containsNone( + widget, + ".match:first table tr.mv_line", + "partner 1 should have 0 propositions for reconciliation" + ); + assert.strictEqual( + widget.$el.data("mode"), + "create", + "widget should be in create mode" + ); + + // Simulate changing partner + await testUtils.dom.clickFirst(widget.$(".o_input_dropdown input")); + $(".ui-autocomplete .ui-menu-item a:contains(Camptocamp)") + .trigger("mouseenter") + .trigger("click"); + clientAction._onAction({ + target: widget, + name: "change_partner", + data: {data: {display_name: "Camptocamp", id: 12}}, + stopped: false, + }); + await testUtils.nextTick(); + assert.strictEqual( + widget.$(".o_input_dropdown input").val(), + "Camptocamp", + "the partner many2one should display Camptocamp" + ); + assert.containsN( + widget, + ".match:first table tr.mv_line", + 3, + "camptocamp should have 3 propositions for reconciliation" + ); + + // Simulate changing partner with SelectCreateDialog + widget = clientAction.widgets[1]; + assert.strictEqual( + $(".modal").length, + 0, + "shouldn't have any opened modal" + ); + await testUtils.dom.click(widget.$(".o_input_dropdown input")); + $(".ui-autocomplete .ui-menu-item a:contains(Search More):eq(1)") + .trigger("mouseenter") + .trigger("click"); + await testUtils.nextTick(); + assert.strictEqual( + $(".modal").length, + 1, + "should open a SelectCreateDialog" + ); + await testUtils.dom.click( + $(".modal table.o_list_table td:contains(Camptocamp)") + ); + assert.strictEqual( + widget.$(".o_input_dropdown input").val(), + "Camptocamp", + "the partner many2one should display Camptocamp" + ); + + widget = clientAction.widgets[2]; + await testUtils.dom.click(widget.$(".accounting_view thead td:first")); + await testUtils.dom.click( + widget.$(".accounting_view .mv_line .cell_label") + ); + assert.strictEqual( + widget.$(".o_input_dropdown input").val(), + "Camptocamp", + "the partner many2one should display agrolait" + ); + assert.containsN( + widget, + ".match:first table tr", + 3, + "Camptocamp should have 3 propositions for reconciliation" + ); + assert.notOk( + widget.$(".match:first div.load-more a:visible").length, + "should not display the load more button" + ); + + // Simulate remove partner + await testUtils.dom.click(widget.$(".o_input_dropdown input")); + await testUtils.fields.editAndTrigger( + widget.$(".o_input_dropdown input"), + "", + ["keyup", "blur"] + ); + + assert.strictEqual( + widget.$(".o_input_dropdown input").val(), + "", + "the partner many2one should be empty" + ); + assert.containsN( + widget, + ".match:first table tr.mv_line", + 5, + "should have 5 propositions for reconciliation if partner is false" + ); + assert.ok( + widget.$(".match:first div.load-more a:visible").length, + "should display the load more button" + ); + + clientAction.destroy(); + }); + + QUnit.test("Reconciliation create line", async function (assert) { + assert.expect(23); + + var clientAction = new ReconciliationClientAction.StatementAction( + null, + this.params.options + ); + + testUtils.mock.addMockEnvironment(clientAction, { + data: this.params.data, + session: { + currencies: { + 3: { + digits: [69, 2], + position: "before", + symbol: "$", + }, + }, + }, + archs: { + "account.bank.statement.line,false,search": + '', + }, + }); + await clientAction.appendTo($("#qunit-fixture")); + await testUtils.nextTick(); + + assert.strictEqual( + clientAction + .$( + ".accounting_view tfoot .cell_right, .accounting_view tfoot .cell_left" + ) + .text() + .replace(/[$, ]+/g, ""), + " 1175.00 32.58 2000.00", + "should display the open balance values" + ); + + var widget = clientAction.widgets[0]; + + assert.strictEqual( + widget.$(".accounting_view tfoot .cell_label").text(), + "Open balance", + "should display 'Open Balance' line with the rest to reconcile" + ); + + await testUtils.dom.click( + widget.$('.o_notebook .nav-link[href*="notebook_page_create"]') + ); + await testUtils.dom.click(widget.$(".create .create_account_id input")); + $( + ".ui-autocomplete .ui-menu-item a:contains(101200 Account Receivable)" + ) + .trigger("mouseenter") + .trigger("click"); + await testUtils.nextTick(); + + assert.notOk( + widget.$(".accounting_view tfoot .cell_label").text(), + "should not display 'Open Balance' line because the rest to reconcile is null" + ); + assert.containsOnce( + widget, + ".accounting_view tbody tr", + "should have only the created reconcile line" + ); + assert.strictEqual( + widget + .$(".accounting_view tbody tr") + .text() + .replace(/[\n\r\s$,]+/g, " ") + .replace(/[\u200B]/g, ""), + " 101200 New SAJ/2014/002 and SAJ/2014/003 1175.00 ", + "the new line should have the selected account, name and amout" + ); + assert.ok( + widget.$("caption button.btn-primary:visible").length, + "should display the 'Reconcile' button" + ); + + testUtils.mock.intercept(clientAction, "do_action", function (event) { + assert.strictEqual( + JSON.stringify(event.data.action), + '{"type":"ir.actions.act_window","res_model":"account.reconcile.model","views":[[false,"form"]],"target":"current"}', + "should open the reconcile model form view" + ); + }); + await testUtils.dom.click(widget.$(".create .reconcile_model_create"), { + allowInvisible: true, + }); + + testUtils.mock.intercept(clientAction, "do_action", function (event) { + assert.strictEqual( + JSON.stringify(event.data.action), + '{"type":"ir.actions.act_window","res_model":"account.reconcile.model","views":[[false,"list"],[false,"form"]],"view_mode":"list","target":"current"}', + "should open the reconcile model list view" + ); + }); + await testUtils.dom.click(widget.$(".create .reconcile_model_edit"), { + allowInvisible: true, + }); + + await testUtils.fields.editInput( + widget.$(".create .create_amount input"), + "1100.00" + ); + + assert.strictEqual( + widget + .$(".accounting_view tbody .cell_right") + .text() + .trim() + .replace(/[\n\r\s\u00a0]+/g, " "), + "$ 1100.00", + "should display the value 1100.00 in right column" + ); + assert.strictEqual( + widget.$(".accounting_view tfoot .cell_right").text(), + "$ 75.00", + "should display 'Open Balance' line because the rest to reconcile is 75.00" + ); + assert.containsOnce( + widget, + ".accounting_view tbody tr", + "should have ever only the created reconcile line" + ); + assert.strictEqual( + widget + .$(".accounting_view tbody tr") + .text() + .replace(/[\n\r\s$,]+/g, " ") + .replace(/[\u200B]/g, ""), + " 101200 New SAJ/2014/002 and SAJ/2014/003 1100.00 ", + "the new line should be update the amout" + ); + assert.ok( + widget.$("caption button.btn-secondary:visible").length, + "should display the 'validate' button" + ); + + await testUtils.dom.click(widget.$(".create .add_line"), { + allowInvisible: true, + }); + await testUtils.fields.editInput( + widget.$(".create .create_amount input"), + "-100" + ); + await testUtils.dom.click(widget.$(".create .create_account_id input")); + $(".ui-autocomplete .ui-menu-item a:contains(101000 Current Assets)") + .trigger("mouseenter") + .trigger("click"); + await testUtils.nextTick(); + await testUtils.fields.editInput( + widget.$(".create .create_label input"), + "test0" + ); + + assert.strictEqual( + widget + .$(".accounting_view tbody .cell_left:last") + .text() + .trim() + .replace(/[\n\r\s\u00a0]+/g, " "), + "$ 100.00", + "should display the value 100.00 in left column" + ); + assert.strictEqual( + widget.$(".accounting_view tfoot .cell_label").text(), + "Open balance", + "should display 'Open Balance'" + ); + assert.strictEqual( + widget.$(".accounting_view tfoot .cell_right").text(), + "$ 175.00", + "should display 'Open Balance' line because the rest to reconcile is 175.00" + ); + assert.containsN( + widget, + ".accounting_view tbody tr", + 2, + "should have 2 created reconcile lines" + ); + assert.strictEqual( + widget + .$(".accounting_view tbody tr:eq(1)") + .text() + .replace(/[\n\r\s$,]+/g, " ") + .replace(/[\u200B]/g, ""), + " 101000 New test0 100.00 ", + "the new line should have the selected account, name and amout" + ); + + await testUtils.dom.click( + widget.$('.o_notebook .nav-link[href*="notebook_page_create"]') + ); + + assert.strictEqual( + widget.$(".create .create_amount input").val(), + "175.00", + "should have '175.00' as default amount value" + ); + + await testUtils.fields.editInput( + widget.$(".create .create_amount input"), + "200" + ); + widget.$(".create .create_account_id input").trigger("click"); + $(".ui-autocomplete .ui-menu-item a:contains(101000 Current Assets)") + .trigger("mouseenter") + .trigger("click"); + await testUtils.nextTick(); + await testUtils.fields.editInput( + widget.$(".create .create_label input"), + "test1" + ); + + assert.strictEqual( + widget + .$(".accounting_view tbody .cell_right:last") + .text() + .trim() + .replace(/[\n\r\s\u00a0]+/g, " "), + "$ 200.00", + "should display the value 200.00 in left column" + ); + assert.strictEqual( + widget.$(".accounting_view tfoot .cell_label").text(), + "Open balance", + "should display 'Open balance'" + ); + assert.strictEqual( + widget.$(".accounting_view tfoot .cell_left").text(), + "$ 25.00", + "should display 'Open balance' with 25.00 in left column" + ); + assert.containsN( + widget, + ".accounting_view tbody tr", + 3, + "should have 3 created reconcile lines" + ); + + clientAction.destroy(); + }); + + QUnit.test("Reconciliation create line (many2one test)", async function ( + assert + ) { + assert.expect(5); + + var clientAction = new ReconciliationClientAction.StatementAction( + null, + this.params.options + ); + var def = testUtils.makeTestPromise(); + + testUtils.mock.addMockEnvironment(clientAction, { + data: this.params.data, + session: { + currencies: { + 3: { + digits: [69, 2], + position: "before", + symbol: "$", + }, + }, + }, + archs: { + "account.account,false,list": + '', + "account.account,false,search": + '', + "account.bank.statement.line,false,search": + '', + }, + mockRPC: function (route, args) { + if (args.method === "name_get") { + return def.then(this._super.bind(this, route, args)); + } + return this._super(route, args); + }, + }); + + await clientAction.prependTo($("#qunit-fixture")); + await testUtils.nextTick(); + + var widget = clientAction.widgets[0]; + + // Open the first line in write-off mode + await testUtils.dom.click( + widget.$('.o_notebook .nav-link[href*="notebook_page_create"]') + ); + + // Select an account with the many2one (drop down) + await testUtils.dom.click(widget.$(".create .create_account_id input")); + $(".ui-autocomplete .ui-menu-item a:contains(101200)") + .trigger("mouseenter") + .trigger("click"); + await testUtils.nextTick(); + assert.strictEqual( + widget.$(".create .create_account_id input").val(), + "101200 Account Receivable", + "Display the selected account" + ); + assert.strictEqual( + widget + .$("tbody:first .cell_account_code") + .text() + .replace(/[\u200B]/g, ""), + "101200", + "Display the code of the selected account" + ); + + // Use the many2one select dialog to change the account + await testUtils.dom.click(widget.$(".create .create_account_id input")); + $(".ui-autocomplete .ui-menu-item a:contains(Search)") + .trigger("mouseenter") + .trigger("click"); + await testUtils.nextTick(); + // Select the account who does not appear in the drop drown + await testUtils.dom.click($(".modal tr.o_data_row:contains(502)")); + assert.strictEqual( + widget.$(".create .create_account_id input").val(), + "101200 Account Receivable", + "Selected account does not change" + ); + // Wait the name_get to render the changes + def.resolve(); + await testUtils.nextTick(); + assert.strictEqual( + widget.$(".create .create_account_id input").val(), + "502 Account", + "Display the selected account" + ); + assert.strictEqual( + widget + .$("tbody:first .cell_account_code") + .text() + .replace(/[\u200B]/g, ""), + "502", + "Display the code of the selected account" + ); + clientAction.destroy(); + }); + + QUnit.test("Reconciliation create line with taxes", async function ( + assert + ) { + assert.expect(13); + + var clientAction = new ReconciliationClientAction.StatementAction( + null, + this.params.options + ); + + testUtils.mock.addMockEnvironment(clientAction, { + data: this.params.data, + session: { + currencies: { + 3: { + digits: [69, 2], + position: "before", + symbol: "$", + }, + }, + }, + archs: { + "account.bank.statement.line,false,search": + '', + }, + }); + await clientAction.appendTo($("#qunit-fixture")); + await testUtils.nextTick(); + + var widget = clientAction.widgets[0]; + + await testUtils.dom.click( + widget.$('.o_notebook .nav-link[href*="notebook_page_create"]') + ); + await testUtils.dom.click(widget.$(".create .create_account_id input")); + $(".ui-autocomplete .ui-menu-item a:contains(101000 Current Assets)") + .trigger("mouseenter") + .trigger("click"); + await testUtils.nextTick(); + await testUtils.fields.editInput( + widget.$(".create .create_label input"), + "test1" + ); + await testUtils.fields.editInput( + widget.$(".create .create_amount input"), + "1100" + ); + + assert.strictEqual( + widget + .$(".accounting_view tbody .cell_right:last") + .text() + .trim() + .replace(/[\n\r\s\u00a0]+/g, " "), + "$ 1100.00", + "should display the value 1100.00 in left column" + ); + assert.strictEqual( + widget.$(".accounting_view tfoot .cell_label").text(), + "Open balance", + "should display 'Open Balance'" + ); + assert.strictEqual( + widget.$(".accounting_view tfoot .cell_right").text(), + "$\u00a075.00", + "should display 'Open Balance' with 75.00 in right column" + ); + assert.containsOnce( + widget, + ".accounting_view tbody tr", + "should have 1 created reconcile lines" + ); + + await testUtils.dom.click(widget.$(".create .create_tax_id input")); + $(".ui-autocomplete .ui-menu-item a:contains(10.00%)") + .trigger("mouseenter") + .trigger("click"); + await testUtils.nextTick(); + + assert.strictEqual( + widget + .$(".accounting_view tbody .cell_right") + .text() + .trim() + .replace(/[\n\r\s\u00a0]+/g, " "), + "$ 1000.00 $ 100.00", + "should have 2 created reconcile lines with right column values" + ); + assert.strictEqual( + widget.$(".accounting_view tfoot .cell_label").text(), + "Open balance", + "should display 'Open Balance'" + ); + assert.strictEqual( + widget.$(".accounting_view tfoot .cell_right").text(), + "$\u00a075.00", + "should display 'Open Balance' with 75.00 in right column" + ); + assert.strictEqual( + widget.$(".accounting_view tfoot .cell_left").text(), + "", + "should display 'Open Balance' without any value in left column" + ); + assert.containsN( + widget, + ".accounting_view tbody tr", + 2, + "should have 2 created reconcile lines" + ); + await testUtils.dom.click(widget.$('[name="tax_ids"] a.o_delete')); + widget + .$(".create .create_tax_id input") + .val("") + .trigger("keyup") + .trigger("blur"); + await testUtils.dom.click(widget.$(".create .create_tax_id input")); + $(".ui-autocomplete .ui-menu-item a:contains(20.00%)") + .trigger("mouseenter") + .trigger("click"); + await testUtils.nextTick(); + + assert.strictEqual( + widget + .$(".accounting_view tbody .cell_right") + .text() + .trim() + .replace(/[\n\r\s\u00a0]+/g, " "), + "$ 1100.00 $ 220.00", + "should have 2 created reconcile lines with right column values" + ); + assert.strictEqual( + widget.$(".accounting_view tfoot .cell_label").text(), + "Open balance", + "should display 'Open balance'" + ); + assert.strictEqual( + widget.$(".accounting_view tfoot .cell_left").text(), + "$\u00a0145.00", + "should display 'Open balance' with 145.00 in right column" + ); + assert.containsN( + widget, + ".accounting_view tbody tr", + 2, + "should have 2 created reconcile lines" + ); + + clientAction.destroy(); + }); + + QUnit.test( + "Reconciliation create line from reconciliation model", + async function (assert) { + assert.expect(6); + + var clientAction = new ReconciliationClientAction.StatementAction( + null, + this.params.options + ); + + testUtils.mock.addMockEnvironment(clientAction, { + data: this.params.data, + archs: { + "account.bank.statement.line,false,search": + '', + }, + }); + await clientAction.appendTo($("#qunit-fixture")); + await testUtils.nextTick(); + + var widget = clientAction.widgets[0]; + + await testUtils.dom.click( + widget.$('.o_notebook .nav-link[href*="notebook_page_create"]') + ); + await testUtils.dom.click( + widget.$(".create .quick_add button:contains(ATOS)") + ); + + assert.strictEqual( + widget + .$( + ".accounting_view tbody .cell_label, .accounting_view tbody .cell_right" + ) + .text() + .replace(/[\n\r\s$,]+/g, " "), + " ATOS Banque 1145.63 ATOS Banque Tax 20.00% 229.13 ATOS Frais 26.78 ATOS Frais Tax 10.00% include 2.68 ", + "should display 4 lines" + ); + assert.strictEqual( + widget + .$( + ".accounting_view tfoot .cell_label, .accounting_view tfoot .cell_left" + ) + .text() + .replace(/[\n\r\s$,]+/g, ""), + "Openbalance229.22", + "should display the 'Open balance' line with value in left column" + ); + + await testUtils.fields.editAndTrigger( + widget.$(".create .create_amount input"), + "100", + ["input"] + ); + + assert.strictEqual( + widget + .$(".accounting_view tbody") + .text() + .replace(/[\n\r\s$,]+/g, " ") + .replace(/[\u200B]/g, ""), + " 101120 New ATOS Banque 1145.63 101120 New ATOS Banque Tax 20.00% 229.13 101130 New ATOS Frais 90.91 101300 New ATOS Frais Tax 10.00% include 9.09 ", + "should update the value of the 2 lines (because the line + its tax must have 100% of the value)" + ); + assert.strictEqual( + widget + .$( + ".accounting_view tfoot .cell_label, .accounting_view tfoot .cell_left" + ) + .text() + .replace(/[\n\r\s$,]+/g, ""), + "Openbalance299.76", + "should change the 'Open balance' line because the 20.00% tax is not an include tax" + ); + + await testUtils.dom.click( + widget.$(".accounting_view tbody .cell_account_code:first") + ); + await testUtils.dom.click( + widget.$(".accounting_view tbody .cell_label:first") + ); + + assert.strictEqual( + widget + .$(".accounting_view tbody") + .text() + .replace(/[\n\r\s$,]+/g, " "), + "", + "should removed every line" + ); + + await testUtils.dom.click( + widget.$(".create .quick_add button:contains(Double)") + ); + + assert.strictEqual( + widget + .$(".accounting_view tbody") + .text() + .replace(/[\n\r\s$,]+/g, " ") + .replace(/[\u200B]/g, ""), + " 101120 New Double Banque 1145.63 101130 New Double Frais 29.37 ", + "should have a sum of reconciliation proposition amounts equal to the line amount" + ); + + clientAction.destroy(); + } + ); + + QUnit.test( + "Reconciliation fetch correct reconciliation models", + async function (assert) { + assert.expect(1); + + testUtilsMock.patch(this.params.options.context, { + active_model: "account.journal", // On account dashboard, click "Reconcile" on a journal + active_ids: [1, 2], // Active journals + company_ids: [3, 4], // Active companies + }); + + var clientAction = new ReconciliationClientAction.StatementAction( + null, + this.params.options + ); + + testUtils.addMockEnvironment(clientAction, { + data: this.params.data, + mockRPC: async function (route, args) { + if ( + args.model === "account.reconcile.model" && + args.method === "search_read" + ) { + assert.deepEqual( + args.kwargs.domain, + [ + ["company_id", "in", [3, 4]], + "|", + ["match_journal_ids", "=", false], + ["match_journal_ids", "in", [1, 2]], + ], + "The domain to get reconcile models should contain the right fields and values" + ); + } + return this._super.apply(this, arguments); + }, + }); + clientAction.appendTo($("#qunit-fixture")); + testUtilsMock.unpatch(this.params.options.context); + + clientAction.destroy(); + } + ); + + QUnit.test("Reconciliation manual", async function (assert) { + assert.expect(13); + + var clientAction = new ReconciliationClientAction.ManualAction( + null, + this.params.options + ); + + testUtils.mock.addMockEnvironment(clientAction, { + data: this.params.data, + session: this.params.session, + archs: { + "account.bank.statement.line,false,search": + '', + }, + }); + await clientAction.appendTo($("#qunit-fixture")); + await testUtils.nextTick(); + + assert.strictEqual( + clientAction + .$(".accounting_view:first thead") + .text() + .replace(/[\n\r\s]+/g, " "), + " 101000 Current AssetsLast Reconciliation: 2017-02-16 101000 ", + "should display the account as title" + ); + assert.strictEqual( + clientAction.$(".o_reconciliation_line:first").data("mode"), + "inactive", + "should be in 'inactive' mode because no line to displayed and the balance amount is null" + ); + assert.containsN( + clientAction, + ".accounting_view:first tbody tr", + 2, + "should have 2 propositions" + ); + assert.containsOnce( + clientAction, + ".accounting_view:first .o_reconcile:visible", + "should display the reconcile button" + ); + + await testUtils.dom.click( + clientAction.$(".accounting_view:first .o_reconcile:visible") + ); + + assert.strictEqual( + clientAction + .$(".accounting_view:first thead") + .text() + .replace(/[\n\r\s]+/g, " "), + " 101200 Account Receivable 101200 ", + "should display the account and the account code as title" + ); + + assert.strictEqual( + clientAction + .$( + ".o_reconciliation_line:first .match:first tr:first .cell_right" + ) + .text() + .trim() + .replace(/[\n\r\s\u00a0]+/g, " "), + "$ 11,000.00", + "sould display the line in $" + ); + assert.strictEqual( + clientAction + .$( + ".o_reconciliation_line:first .match:first tr:first .cell_right .o_multi_currency" + ) + .data("content"), + "10,222.00 €", + "sould display the monetary information in €" + ); + + assert.containsOnce( + clientAction, + ".accounting_view:first .o_no_valid:visible", + "should display the skip button" + ); + + await testUtils.dom.click( + clientAction.$(".o_reconciliation_line:eq(1) .accounting_view") + ); + await testUtils.dom.click( + clientAction.$(".accounting_view:eq(1) thead td:first") + ); + // Debugger + await testUtils.dom.click( + clientAction.$( + '.o_reconciliation_line:eq(1) [data-line-id="21"] .cell_label' + ) + ); + await testUtils.dom.click( + clientAction.$( + '.o_reconciliation_line:eq(1) [data-line-id="22"] .cell_label' + ) + ); + + assert.strictEqual( + clientAction.$(".o_reconciliation_line:eq(1) tfoot tr").length, + 0, + "should not display the 'Write-off' line because the balance is null in Euro" + ); + assert.strictEqual( + clientAction.$(".o_reconciliation_line:eq(1) .o_reconcile:visible") + .length, + 1, + "should display 'Reconcile' button in green" + ); + + await testUtils.dom.click( + clientAction.$(".o_reconciliation_line:eq(1) .o_reconcile:visible") + ); + + assert.containsOnce( + clientAction, + '.o_reconciliation_line[data-mode!="inactive"]', + "should have only one line open" + ); + + await testUtils.dom.click( + clientAction.$( + '.o_reconciliation_line:eq(1) [data-line-id="23"] .cell_label' + ) + ); + await testUtils.dom.click( + clientAction.$( + '.o_reconciliation_line:eq(1) [data-line-id="24"] .cell_label' + ) + ); + + assert.strictEqual( + clientAction.$(".o_reconciliation_line:eq(1) tfoot tr").length, + 1, + "should display the 'Write-off' line because the balance is not null in Euro" + ); + assert.strictEqual( + clientAction.$(".o_reconciliation_line:eq(1) .o_validate:visible") + .length, + 1, + "should display 'Reconcile' button" + ); + + clientAction.destroy(); + }); + + QUnit.test( + "Reconciliation: Payment < inv1 + inv2(partial)", + async function (assert) { + assert.expect(3); + + /* + * One payment: $1175 + * Two Invoices + * The first invoice will be fully reconciled $650 + * The second invoice will be partially paid with the rest of the payment $999 + */ + + // modify the second line that is already in db to put it at $999 + var indexModif = _.findIndex( + this.params.mv_lines['[5,"",0]'], + function (line) { + return line.id === 112; + } + ); + this.params.mv_lines['[5,"",0]'][indexModif] = { + account_type: "receivable", + amount_currency_str: "", + currency_id: false, + date_maturity: "2017-02-07", + date: "2017-01-08", + total_amount_str: "$ 999.00", + partner_id: 8, + account_name: "101200 Account Receivable", + name: "INV/2017/0003", + partner_name: "Agrolait", + total_amount_currency_str: "", + id: 112, + credit: 0.0, + journal_id: [1, "Customer Invoices"], + amount_str: "$ 999.00", + debit: 999.0, + account_code: "101200", + ref: "", + already_paid: false, + }; + + var clientAction = new ReconciliationClientAction.StatementAction( + null, + this.params.options + ); + testUtils.mock.addMockEnvironment(clientAction, { + data: this.params.data, + mockRPC: function (route, args) { + if (args.method === "process_bank_statement_line") { + assert.deepEqual( + args.args, + [ + [5], // Id of the bank statement line + + [ + { + counterpart_aml_dicts: [ + { + name: "INV/2017/0002", + debit: 0, + credit: 650, + analytic_tag_ids: [ + [6, null, []], + ], + counterpart_aml_id: 109, + }, + + { + name: "INV/2017/0003", + debit: 0, + credit: 525, + analytic_tag_ids: [ + [6, null, []], + ], + counterpart_aml_id: 112, + }, + ], + + payment_aml_ids: [], + partner_id: 8, + to_check: false, + new_aml_dicts: [], + }, + ], + ], + "should call process_bank_statement_line with partial reconcile values" + ); + } + return this._super(route, args); + }, + session: { + currencies: { + 3: { + digits: [69, 2], + position: "before", + symbol: "$", + }, + }, + }, + archs: { + "account.bank.statement.line,false,search": + '', + }, + }); + await clientAction.appendTo($("#qunit-fixture")); + await testUtils.nextTick(); + + // The first reconciliation "line" is where it happens + var widget = clientAction.widgets[0]; + + // Add first invoice to reconcile fully + await testUtils.dom.click( + widget.$(".match:first .cell_account_code:first") + ); + assert.ok( + widget.$(".cell_right .edit_amount").length, + "should display the pencil to edit amount" + ); + + // Add second invoice to reconcile partially + await testUtils.dom.click( + widget.$(".match:first .cell_account_code:first") + ); + + // Edit amount on last invoice + await testUtils.dom.click(widget.$(".edit_amount:last()")); + await testUtils.fields.editAndTrigger( + widget.$(".edit_amount_input:last()"), + "525", + ["blur"] + ); + + var $buttonReconcile = widget.$("button.o_reconcile:not(hidden)"); + + assert.equal( + $buttonReconcile.length, + 1, + "The reconcile button must be visible" + ); + + await testUtils.dom.click($buttonReconcile); + + clientAction.destroy(); + } + ); + + QUnit.test("Reconciliation: payment and 2 partials", async function ( + assert + ) { + assert.expect(6); + + /* + * One payment: $1175 + * Two Invoices as Inv1 = 1200; Inv2 = 1200: + * Payment < Inv1 AND Payment < Inv2 + * No partial reconcile is possible, as a write-off of 1225 is necessary + */ + + // modify the invoice line to have their amount > payment + var indexInv1 = _.findIndex(this.params.mv_lines['[5,"",0]'], function ( + line + ) { + return line.id === 109; + }); + this.params.mv_lines['[5,"",0]'][indexInv1] = { + account_type: "receivable", + amount_currency_str: "", + currency_id: false, + date_maturity: "2017-02-07", + date: "2017-01-08", + total_amount_str: "$ 1200.00", + partner_id: 8, + account_name: "101200 Account Receivable", + name: "INV/2017/0002", + partner_name: "Agrolait", + total_amount_currency_str: "", + id: 109, + credit: 0.0, + journal_id: [1, "Customer Invoices"], + amount_str: "$ 1200.00", + debit: 1200.0, + account_code: "101200", + ref: "", + already_paid: false, + }; + + var indexInv2 = _.findIndex(this.params.mv_lines['[5,"",0]'], function ( + line + ) { + return line.id === 112; + }); + this.params.mv_lines['[5,"",0]'][indexInv2] = { + account_type: "receivable", + amount_currency_str: "", + currency_id: false, + date_maturity: "2017-02-07", + date: "2017-01-08", + total_amount_str: "$ 1200.00", + partner_id: 8, + account_name: "101200 Account Receivable", + name: "INV/2017/0003", + partner_name: "Agrolait", + total_amount_currency_str: "", + id: 112, + credit: 0.0, + journal_id: [1, "Customer Invoices"], + amount_str: "$ 1200.00", + debit: 1200.0, + account_code: "101200", + ref: "", + already_paid: false, + }; + + var clientAction = new ReconciliationClientAction.StatementAction( + null, + this.params.options + ); + testUtils.mock.addMockEnvironment(clientAction, { + data: this.params.data, + mockRPC: function (route, args) { + if (args.method === "process_bank_statement_line") { + assert.deepEqual( + args.args, + [ + [5], // Id of the bank statement line + + [ + { + counterpart_aml_dicts: [ + { + name: "INV/2017/0002", + debit: 0, + credit: 1200, + analytic_tag_ids: [[6, null, []]], + counterpart_aml_id: 109, + }, + + { + name: "INV/2017/0003", + debit: 0, + credit: 1200, + analytic_tag_ids: [[6, null, []]], + counterpart_aml_id: 112, + }, + ], + + payment_aml_ids: [], + partner_id: 8, + to_check: false, + new_aml_dicts: [ + { + account_id: 282, + credit: 0, + debit: 1225, + analytic_tag_ids: [[6, null, []]], + name: + "SAJ/2014/002 and SAJ/2014/003", + }, + ], + }, + ], + ], + "should call process_bank_statement_line with new aml dict reconcile values" + ); + } + return this._super(route, args); + }, + session: { + currencies: { + 3: { + digits: [69, 2], + position: "before", + symbol: "$", + }, + }, + }, + archs: { + "account.bank.statement.line,false,search": + '', + }, + }); + await clientAction.appendTo($("#qunit-fixture")); + await testUtils.nextTick(); + + // The first reconciliation "line" is where it happens + var widget = clientAction.widgets[0]; + + // Add first invoice + // There should be the opportunity to reconcile partially + await testUtils.dom.click( + widget.$(".match:first .cell_account_code:first") + ); + assert.ok( + widget.$(".cell_right .edit_amount").length, + "should display the pencil to edit amount" + ); + + // Add second invoice + await testUtils.dom.click( + widget.$(".match:first .cell_account_code:first") + ); + assert.ok( + widget.$(".cell_right .edit_amount").length, + "should display the pencil to edit amount" + ); + + var writeOffCreate = widget.$("div.create"); + + assert.equal( + writeOffCreate.length, + 1, + "A write-off creation should be present" + ); + + assert.equal( + writeOffCreate.find("input[name=amount]").val(), + -1225, + "The right amount should be proposed for the write-off" + ); + + await testUtils.dom.click( + writeOffCreate.find( + ".create_account_id input.ui-autocomplete-input" + ) + ); + await testUtils.dom.click($("ul.ui-autocomplete li a:first")); + + var $buttonReconcile = widget.$("button.o_reconcile:not(hidden)"); + + assert.equal( + $buttonReconcile.length, + 1, + "The reconcile button must be visible" + ); + + await testUtils.dom.click($buttonReconcile); + + clientAction.destroy(); + }); + + QUnit.test( + "Reconciliation: partial payment of 2 invoices with one payment [REQUIRE FOCUS]", + async function (assert) { + assert.expect(4); + + /* + * One payment: $1175 + * Two Invoices as Inv1 = 1200; Inv2 = 1200: + * Payment < Inv1 AND Payment < Inv2 + * Assign 500 to inv1 and 675 to inv2 + */ + + // modify the invoice line to have their amount > payment + var indexInv1 = _.findIndex( + this.params.mv_lines['[5,"",0]'], + function (line) { + return line.id === 109; + } + ); + this.params.mv_lines['[5,"",0]'][indexInv1] = { + account_type: "receivable", + amount_currency_str: "", + currency_id: false, + date_maturity: "2017-02-07", + date: "2017-01-08", + total_amount_str: "$ 1200.00", + partner_id: 8, + account_name: "101200 Account Receivable", + name: "INV/2017/0002", + partner_name: "Agrolait", + total_amount_currency_str: "", + id: 109, + credit: 0.0, + journal_id: [1, "Customer Invoices"], + amount_str: "$ 1200.00", + debit: 1200.0, + account_code: "101200", + ref: "", + already_paid: false, + }; + + var indexInv2 = _.findIndex( + this.params.mv_lines['[5,"",0]'], + function (line) { + return line.id === 112; + } + ); + this.params.mv_lines['[5,"",0]'][indexInv2] = { + account_type: "receivable", + amount_currency_str: "", + currency_id: false, + date_maturity: "2017-02-07", + date: "2017-01-08", + total_amount_str: "$ 1200.00", + partner_id: 8, + account_name: "101200 Account Receivable", + name: "INV/2017/0003", + partner_name: "Agrolait", + total_amount_currency_str: "", + id: 112, + credit: 0.0, + journal_id: [1, "Customer Invoices"], + amount_str: "$ 1200.00", + debit: 1200.0, + account_code: "101200", + ref: "", + already_paid: false, + }; + + var clientAction = new ReconciliationClientAction.StatementAction( + null, + this.params.options + ); + testUtils.mock.addMockEnvironment(clientAction, { + data: this.params.data, + mockRPC: function (route, args) { + if (args.method === "process_bank_statement_line") { + assert.deepEqual( + args.args, + [ + [5], // Id of the bank statement line + + [ + { + counterpart_aml_dicts: [ + { + name: "INV/2017/0002", + debit: 0, + credit: 500, + analytic_tag_ids: [ + [6, null, []], + ], + counterpart_aml_id: 109, + }, + + { + name: "INV/2017/0003", + debit: 0, + credit: 675, + analytic_tag_ids: [ + [6, null, []], + ], + counterpart_aml_id: 112, + }, + ], + + payment_aml_ids: [], + partner_id: 8, + to_check: false, + new_aml_dicts: [], + }, + ], + ], + "should call process_bank_statement_line with correct counterpart_aml_dicts" + ); + } + return this._super(route, args); + }, + session: { + currencies: { + 3: { + digits: [69, 2], + position: "before", + symbol: "$", + }, + }, + }, + archs: { + "account.bank.statement.line,false,search": + '', + }, + }); + await clientAction.appendTo($("#qunit-fixture")); + await testUtils.nextTick(); + + // The first reconciliation "line" is where it happens + var widget = clientAction.widgets[0]; + + // Add first invoice + // There should be the opportunity to reconcile partially + await testUtils.dom.click( + widget.$(".match:first .cell_account_code:first") + ); + assert.ok( + widget.$(".cell_right .edit_amount").length, + "should display the pencil to edit amount" + ); + + // Add second invoice + await testUtils.dom.click( + widget.$(".match:first .cell_account_code:first") + ); + assert.ok( + widget.$(".cell_right .edit_amount").length, + "should display the pencil to edit amount" + ); + + // Edit invoice first amount + await testUtils.dom.click(widget.$(".edit_amount:first()")); + await testUtils.fields.editAndTrigger( + widget.$(".edit_amount_input:first()"), + "500", + ["blur"] + ); + // Edit invoice second amount + var $buttonReconcile = widget.$("button.o_reconcile:not(hidden)"); + await testUtils.dom.click(widget.$(".edit_amount:last()")); + await testUtils.fields.editAndTrigger( + widget.$(".edit_amount_input:last()"), + "675", + ["blur"] + ); + + assert.equal( + $buttonReconcile.length, + 1, + "The reconcile button must be visible" + ); + + await testUtils.dom.click($buttonReconcile, {allowInvisible: true}); + + clientAction.destroy(); + } + ); + + QUnit.test( + "Manual Reconciliation: remove a prop to attain balance and reconcile", + async function (assert) { + assert.expect(5); + + // Tweak the data to fit our needs + this.params.data_for_manual_reconciliation_widget[ + '[283, null, "", 0, 6]' + ] = _.extend( + {}, + this.params.data_for_manual_reconciliation_widget[ + "[null,[282,283,284,285,286,287,288,308,499,500,501,502,503,504]]" + ] + ); + this.params.data_for_manual_reconciliation_widget[ + '[283, null, "", 0, 6]' + ].accounts[0].reconciliation_proposition = [ + { + account_id: 283, + account_type: "other", + amount_currency_str: "", + currency_id: false, + date_maturity: "2017-03-18", + date: "2017-02-16", + total_amount_str: "$ 500.00", + partner_id: 8, + account_name: "101000 Current Assets", + name: "INV/2017/0987", + partner_name: "Agrolait", + total_amount_currency_str: "", + id: 999, + credit: 0.0, + journal_id: [1, "Customer Invoices"], + amount_str: "$ 500.00", + debit: 500.0, + account_code: "101000", + ref: "", + already_paid: false, + }, + ]; + + var clientAction = new ReconciliationClientAction.ManualAction( + null, + this.params.options + ); + testUtils.mock.addMockEnvironment(clientAction, { + data: this.params.data, + mockRPC: function (route, args) { + if (args.method === "process_move_lines") { + assert.deepEqual( + args.args, + [ + [ + { + id: null, + type: null, + mv_line_ids: [399, 402], + new_mv_line_dicts: [], + }, + ], + ], + "should call process_move_lines without the new mv line dict" + ); + } + + return this._super(route, args); + }, + session: { + currencies: { + 3: { + digits: [69, 2], + position: "before", + symbol: "$", + }, + }, + }, + archs: { + "account.bank.statement.line,false,search": + '', + }, + }); + + await clientAction.appendTo($("#qunit-fixture")); + await testUtils.nextTick(); + + // The first reconciliation "line" is where it happens + var widget = clientAction.widgets[0]; + + // Add first prop + await testUtils.dom.click( + widget.$(".match:first .cell_account_code:first") + ); + assert.equal( + widget.$(".cell_right .edit_amount").length, + 0, + "should not display the pencil to edit amount" + ); + + // Add second prop + await testUtils.dom.click( + widget.$(".match:first .cell_account_code:first") + ); + // Check that a create form is here + + await testUtils.dom.click( + widget.$('.o_notebook .nav-link[href*="notebook_page_create"]') + ); + var writeOffCreate = widget.$("div.create"); + + assert.equal( + writeOffCreate.length, + 1, + "A write-off creation should be present" + ); + + assert.equal( + writeOffCreate.find("input[name=amount]").val(), + 500, + "The right amount should be proposed for the write-off" + ); + + // Remove the first line, the other two will balance one another + await testUtils.dom.click( + widget.$('tr[data-line-id="999"] td:first') + ); + + var $buttonReconcile = widget.$("button.o_reconcile:visible"); + assert.equal( + $buttonReconcile.length, + 1, + "The reconcile button must be visible" + ); + + await testUtils.dom.click($buttonReconcile); + + clientAction.destroy(); + } + ); + + QUnit.test("Manual Reconciliation: No lines for account", async function ( + assert + ) { + assert.expect(2); + + var clientAction = new ReconciliationClientAction.ManualAction( + null, + this.params.options + ); + testUtils.mock.addMockEnvironment(clientAction, { + data: this.params.data, + session: { + currencies: { + 3: { + digits: [69, 2], + position: "before", + symbol: "$", + }, + }, + }, + archs: { + "account.bank.statement.line,false,search": + '', + }, + }); + + await clientAction.appendTo($("#qunit-fixture")); + await testUtils.nextTick(); + + // The second reconciliation "line" is where it happens + var widget = clientAction.widgets[1]; + + var emptyLine = widget.$("tr.mv_line"); + + assert.notOk( + "data-line-id" in emptyLine.getAttributes(), + "Empty line should be empty" + ); + + await testUtils.dom.click(emptyLine.find("td:first")); + + // Check that a create form is here + var writeOffCreate = widget.$("div.create .create_account_id"); + + assert.equal( + writeOffCreate.length, + 1, + "A write-off creation should be present" + ); + + clientAction.destroy(); + }); + + QUnit.test("Tax on account receivable", async function (assert) { + assert.expect(21); + + this.params.data_for_manual_reconciliation_widget[ + "[null,[282,283,284,285,286,287,288,308,499,500,501,502,503,504]]" + ].accounts = []; + var clientAction = new ReconciliationClientAction.ManualAction( + null, + this.params.options + ); + testUtils.mock.addMockEnvironment(clientAction, { + data: this.params.data, + session: {}, + mockRPC: function (route, args) { + if (args.method === "name_search") { + switch (args.model) { + // Mock the default mock to do the minimal processing required + // to get the available values for the droplists. + case "account.account": + assert.step("Account"); + return Promise.resolve( + _.map(this.data[args.model].records, function ( + record + ) { + return [record.id, record.name]; + }) + ); + case "account.tax": + assert.step("Tax"); + return Promise.resolve( + _.map(this.data[args.model].records, function ( + record + ) { + return [record.id, record.display_name]; + }) + ); + case "account.journal": + assert.step("Journal"); + return Promise.resolve( + _.map(this.data[args.model].records, function ( + record + ) { + return [record.id, record.display_name]; + }) + ); + } + } + if (args.method === "process_move_lines") { + var mv_line_ids = args.args[0][0].mv_line_ids.slice(0); + mv_line_ids.sort(function (a, b) { + return a - b; + }); + assert.deepEqual( + mv_line_ids, + [6, 19, 21], + "Reconciliation rpc payload, mv_line_ids are correct" + ); + + // Index aiming at the correct object in the list + var idx = _.has( + args.args[0][0].new_mv_line_dicts[0], + "journal_id" + ) + ? 0 + : 1; + assert.deepEqual( + _.pick( + args.args[0][0].new_mv_line_dicts[idx], + "account_id", + "name", + "credit", + "debit", + "journal_id" + ), + { + account_id: 287, + name: "dummy text", + credit: 0, + debit: 180, + journal_id: 8, + }, + "Reconciliation rpc payload, new_mv_line_dicts.gift is correct" + ); + assert.deepEqual( + _.pick( + args.args[0][0].new_mv_line_dicts[1 - idx], + "account_id", + "name", + "credit", + "debit", + "tax_repartition_line_id" + ), + { + account_id: 287, + name: "Tax 20.00%", + credit: 0, + debit: 36, + tax_repartition_line_id: 2, + }, + "Reconciliation rpc payload, new_mv_line_dicts.tax is correct" + ); + } + return this._super.apply(this, arguments); + }, + archs: { + "account.bank.statement.line,false,search": + '', + }, + }); + + await clientAction.appendTo($("#qunit-fixture")); + await testUtils.nextTick(); + + var widget = clientAction.widgets[0]; + + // Select invoice of 1k$, payment of 1k$ and payment of 180$ + var $tableToReconcile = widget.$(".match"); + var defs = _.map([6, 19, 21], function (id) { + return testUtils.dom.click( + $tableToReconcile.find( + "tr.mv_line[data-line-id=" + id + "]:first td:first-child" + ) + ); + }); + await Promise.all(defs); + assert.verifySteps([], "No rpc done"); + + // Store the money in excess to the "account receivable" account with 20% taxes + await testUtils.dom.click( + widget.$('.o_notebook .nav-link[href*="notebook_page_create"]') + ); + var $reconcileForm = widget.$(".create"); + await testUtils.dom.click( + $reconcileForm.find(".create_account_id input") + ); + $( + ".ui-autocomplete .ui-menu-item a:contains(101200 Account Receivable)" + ) + .trigger("mouseover") + .trigger("click"); + await testUtils.nextTick(); + assert.verifySteps(["Account"], "Account rpc done"); + + await testUtils.dom.click($reconcileForm.find(".create_tax_id input")); + $(".ui-autocomplete .ui-menu-item a:contains(Tax 20.00%)") + .trigger("mouseover") + .trigger("click"); + await testUtils.nextTick(); + assert.verifySteps(["Tax"], "Tax rpc done"); + + await testUtils.dom.click( + $reconcileForm.find(".create_journal_id input"), + {allowInvisible: true} + ); + $(".ui-autocomplete .ui-menu-item a:contains(company 1 journal)") + .trigger("mouseover") + .trigger("click"); + await testUtils.nextTick(); + await testUtils.fields.editAndTrigger( + $reconcileForm.find(".create_label input"), + "dummy text", + "input" + ); + await testUtils.dom.click($reconcileForm.find(".create_label input")); + assert.verifySteps(["Journal"], "Journal rpc done"); + + // Verify the two (gift + tax) lines were added to the list + var $newLines = widget.$("tr.mv_line[data-line-id^=createLine]"); + var idx = + $($($newLines[0]).find("td")[3]).text().trim() === "dummy text" + ? 0 + : 1; + + var $newLineGiftTds = $($newLines[1 - idx]).find("td"); + assert.equal( + $($newLineGiftTds[0]) + .text() + .trim() + .replace(/[\u200B]/g, ""), + "101200", + "Gift line account number is valid" + ); + assert.equal( + $($newLineGiftTds[1]).text().trim(), + "New", + "Gift line is flagged as new" + ); + assert.equal( + $($newLineGiftTds[2]).text().trim(), + "dummy text", + "Gift line has the correct label" + ); + assert.equal( + $($newLineGiftTds[3]).text().trim(), + "180.00", + "Gift line has the correct left amount" + ); + assert.equal( + $($newLineGiftTds[4]).text().trim(), + "", + "Gift line has the correct right amount" + ); + + var $newLineTaxeTds = $($newLines[idx]).find("td"); + assert.equal( + $($newLineTaxeTds[0]) + .text() + .trim() + .replace(/[\u200B]/g, ""), + "101200", + "Tax line account number is valid" + ); + assert.equal( + $($newLineTaxeTds[1]).text().trim(), + "New", + "Tax line is flagged as new" + ); + assert.equal( + $($newLineTaxeTds[2]).text().trim(), + "Tax 20.00%", + "Tax line has the correct label" + ); + assert.equal( + $($newLineTaxeTds[3]).text().trim(), + "36.00", + "Tax line has the correct left amount" + ); + assert.equal( + $($newLineTaxeTds[4]).text().trim(), + "", + "Tax line has the correct right amount" + ); + + // Reconcile + await testUtils.dom.click( + widget.$("button.o_reconcile.btn.btn-primary:first") + ); + assert.ok(true, "No error in reconciliation"); + + clientAction.destroy(); + }); + + QUnit.test("Reconcile temporarily and ask to check", async function ( + assert + ) { + assert.expect(4); + this.params.options.context.to_check = true; + var clientAction = new ReconciliationClientAction.StatementAction( + null, + this.params.options + ); + + testUtils.mock.addMockEnvironment(clientAction, { + data: this.params.data, + session: { + currencies: { + 3: { + digits: [69, 2], + position: "before", + symbol: "$", + }, + }, + }, + archs: { + "account.bank.statement.line,false,search": + '', + }, + }); + await clientAction.appendTo($("#qunit-fixture")); + var widget = clientAction.widgets[0]; + + // Add a line as proposition + // open the first line + await testUtils.nextTick(); + await testUtils.dom.click( + widget.$(".accounting_view tfoot td.cell_label") + ); + await testUtils.dom.click( + widget.$('.o_notebook .nav-link[href*="notebook_page_create"]') + ); + + var $reconcileForm = widget.$(".create"); + $reconcileForm + .find(".create_account_id input") + .val("499001 Suspense Account") + .keydown() + .keyup(); + await testUtils.dom.click( + $reconcileForm.find(".create_account_id input") + ); + $(".ui-autocomplete .ui-menu-item a:contains(499001 Suspense Account)") + .trigger("mouseover") + .trigger("click"); + await testUtils.nextTick(); + + assert.equal( + $("button.o_validate.btn.btn-secondary.text-warning:first").length, + 0, + "should not display reconcile button in orange" + ); + await testUtils.dom.click( + $reconcileForm.find(".create_to_check input") + ); + assert.equal( + $("button.o_validate.btn.btn-secondary.text-warning:first").length, + 1, + "should display reconcile button in orange" + ); + + testUtils.mock.intercept(clientAction, "call_service", function ( + event + ) { + assert.deepEqual( + event.data.args[1].args, + [ + [5], + [ + { + partner_id: 8, + counterpart_aml_dicts: [], + payment_aml_ids: [], + new_aml_dicts: [ + { + account_id: 499, + credit: 1175, + debit: 0, + analytic_tag_ids: [[6, null, []]], + name: "SAJ/2014/002 and SAJ/2014/003", + }, + ], + to_check: true, + }, + ], + ], + "Should call process_bank_statement_line with to_check set to true" + ); + var def = testUtils.makeTestPromise(); + def.abort = function () {}; + event.data.callback(def); + }); + + await testUtils.dom.click( + widget.$("button.o_validate.btn.btn-secondary:first") + ); + assert.ok(true, "No error in reconciliation"); + + clientAction.destroy(); + }); + + QUnit.test("Reconciliation Models handle analytic tags", async function ( + assert + ) { + assert.expect(6); + + var clientAction = new ReconciliationClientAction.StatementAction( + null, + this.params.options + ); + testUtils.addMockEnvironment(clientAction, { + data: this.params.data, + mockRPC: function (route, args) { + if (args.method === "process_bank_statement_line") { + var new_aml_dicts = args.args[1][0].new_aml_dicts; + assert.strictEqual(new_aml_dicts.length, 2); + // I personnally judge the following use case rotten, since + // the first and the second line wouldn't have the same tags + assert.deepEqual(new_aml_dicts[0].analytic_tag_ids, [ + [6, null, [1, 2]], + ]); + assert.deepEqual(new_aml_dicts[1].analytic_tag_ids, [ + [6, null, [2]], + ]); + } + return this._super(route, args); + }, + session: { + currencies: { + 3: { + digits: [69, 2], + position: "before", + symbol: "$", + }, + }, + user_has_group: function (group) { + if ( + group === "analytic.group_analytic_tags" || + group === "analytic.group_analytic_accounting" + ) { + return $.when(true); + } + return this._super.apply(this, arguments); + }, + }, + archs: { + "account.bank.statement.line,false,search": + '', + }, + }); + + await clientAction.appendTo($("#qunit-fixture")); + await testUtils.nextTick(); + + // The first reconciliation "line" is where it happens + var widget = clientAction.widgets[0]; + + await testUtilsDom.click(widget.$(".nav-create:visible")); + await testUtilsDom.click( + widget.$('.quick_add button:contains("Double")') + ); + assert.containsN( + widget, + ".create_analytic_tag_ids .o_field_many2manytags .badge", + 2, + "Two tags are loaded" + ); + assert.containsOnce( + widget, + '.create_analytic_tag_ids .o_field_many2manytags .badge:contains("Come together")', + "Tags should have a name" + ); + assert.containsOnce( + widget, + '.create_analytic_tag_ids .o_field_many2manytags .badge:contains("Right now")', + "Tags should have a name" + ); + + await testUtilsDom.click( + widget.$( + ".create_analytic_tag_ids .o_field_many2manytags .badge a.o_delete:first()" + ) + ); + + await testUtilsDom.click(widget.$(".o_reconcile:visible")); + + clientAction.destroy(); + }); + } + ); +}); From aa43f5881b75615239bcb28e222df0e38319e3b4 Mon Sep 17 00:00:00 2001 From: Francisco Ivan Anton Prieto Date: Mon, 14 Dec 2020 03:09:33 +0100 Subject: [PATCH 2/3] [ADD] account_reconciliation_widget: first working alpha --- account_reconciliation_widget/README.rst | 86 ++++ account_reconciliation_widget/__init__.py | 1 + account_reconciliation_widget/__manifest__.py | 24 + .../models/__init__.py | 5 + .../models/account_bank_statement.py | 332 ++++---------- .../models/account_journal.py | 15 + .../models/account_move.py | 2 +- .../models/reconciliation_widget.py | 143 +++--- .../readme/CONTRIBUTORS.rst | 1 + .../readme/DESCRIPTION.rst | 2 + .../readme/USAGE.rst | 9 + .../security/ir.model.access.csv | 2 + .../static/description/index.html | 429 ++++++++++++++++++ .../reconciliation/reconciliation_action.js | 41 +- .../js/reconciliation/reconciliation_model.js | 41 +- .../reconciliation/reconciliation_renderer.js | 26 +- .../static/src/xml/account_reconciliation.xml | 4 +- .../tests/account_reconciliation_tests.js | 51 ++- .../tests/__init__.py | 1 + .../tests/test_reconciliation_widget.py | 239 ++++++++++ .../views/account_bank_statement_view.xml | 25 + .../views/account_journal_dashboard_view.xml | 47 ++ .../views/account_view.xml | 30 ++ .../views/assets.xml | 42 ++ .../odoo/addons/account_reconciliation_widget | 1 + setup/account_reconciliation_widget/setup.py | 6 + 26 files changed, 1192 insertions(+), 413 deletions(-) create mode 100644 account_reconciliation_widget/README.rst create mode 100644 account_reconciliation_widget/__init__.py create mode 100644 account_reconciliation_widget/__manifest__.py create mode 100644 account_reconciliation_widget/models/__init__.py create mode 100644 account_reconciliation_widget/readme/CONTRIBUTORS.rst create mode 100644 account_reconciliation_widget/readme/DESCRIPTION.rst create mode 100644 account_reconciliation_widget/readme/USAGE.rst create mode 100644 account_reconciliation_widget/security/ir.model.access.csv create mode 100644 account_reconciliation_widget/static/description/index.html create mode 100644 account_reconciliation_widget/tests/__init__.py create mode 100644 account_reconciliation_widget/tests/test_reconciliation_widget.py create mode 100644 account_reconciliation_widget/views/account_bank_statement_view.xml create mode 100644 account_reconciliation_widget/views/account_journal_dashboard_view.xml create mode 100644 account_reconciliation_widget/views/account_view.xml create mode 100644 account_reconciliation_widget/views/assets.xml create mode 120000 setup/account_reconciliation_widget/odoo/addons/account_reconciliation_widget create mode 100644 setup/account_reconciliation_widget/setup.py diff --git a/account_reconciliation_widget/README.rst b/account_reconciliation_widget/README.rst new file mode 100644 index 0000000000..0aaf1cc5e2 --- /dev/null +++ b/account_reconciliation_widget/README.rst @@ -0,0 +1,86 @@ +============================= +account_reconciliation_widget +============================= + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Faccount_reconciliation_widget-lightgray.png?logo=github + :target: https://github.com/OCA/account_reconciliation_widget/tree/14.0/account_reconciliation_widget + :alt: OCA/account_reconciliation_widget +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/account_reconciliation_widget-14-0/account_reconciliation_widget-14-0-account_reconciliation_widget + :alt: Translate me on Weblate + +|badge1| |badge2| |badge3| |badge4| + +This module restores account reconciliation widget moved from Odoo community to enterpise in V. 14.0 +Provides two widgets designed to reconcile move lines in a easy way: one focused on bank statements and another for generic use. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +With an user with full accounting features enabled: + +Invoicing --> Accounting --> Actions --> Reconciliation. + +From journal items list view you can select check of them and click Action --> Reconcile. + +From accounting dashboard you can use reconcile button in Bank / Cash journals. + +Also, you can navigate to statements and use the reconcile button. + + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Ozono Multimedia + +Contributors +~~~~~~~~~~~~ + +* Tecnativa - Pedro M. Baeza + + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/account_reconciliation_widget `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/account_reconciliation_widget/__init__.py b/account_reconciliation_widget/__init__.py new file mode 100644 index 0000000000..0650744f6b --- /dev/null +++ b/account_reconciliation_widget/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/account_reconciliation_widget/__manifest__.py b/account_reconciliation_widget/__manifest__.py new file mode 100644 index 0000000000..26e6e10752 --- /dev/null +++ b/account_reconciliation_widget/__manifest__.py @@ -0,0 +1,24 @@ +# Copyright 2020 Ozono Multimedia - Iván Antón +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "account_reconciliation_widget", + "version": "14.0.1.0.0", + "category": "Accounting", + "license": "AGPL-3", + "summary": "Account reconciliation widget", + "author": "Odoo, Ozono Multimedia, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/account-reconcile", + "depends": ["account"], + "data": [ + "security/ir.model.access.csv", + "views/assets.xml", + "views/account_view.xml", + "views/account_bank_statement_view.xml", + "views/account_journal_dashboard_view.xml", + ], + "qweb": [ + "static/src/xml/account_reconciliation.xml", + ], + "installable": True, +} diff --git a/account_reconciliation_widget/models/__init__.py b/account_reconciliation_widget/models/__init__.py new file mode 100644 index 0000000000..7957603b3e --- /dev/null +++ b/account_reconciliation_widget/models/__init__.py @@ -0,0 +1,5 @@ +from . import account_move +from . import account_bank_statement +from . import account_journal +from . import reconciliation_widget +from . import res_company diff --git a/account_reconciliation_widget/models/account_bank_statement.py b/account_reconciliation_widget/models/account_bank_statement.py index cee53c903e..0916d9198d 100644 --- a/account_reconciliation_widget/models/account_bank_statement.py +++ b/account_reconciliation_widget/models/account_bank_statement.py @@ -33,6 +33,7 @@ class AccountBankStatementLine(models.Model): _inherit = "account.bank.statement.line" + # FIXME: is this necessary now? move_name = fields.Char( string="Journal Entry Name", readonly=True, @@ -129,22 +130,23 @@ def process_reconciliation( and user_type_id not in account_types ): account_types |= user_type_id - if suspense_moves_mode: - if any(not line.journal_entry_ids for line in self): - raise UserError( - _( - "Some selected statement line were not already " - "reconciled with an account move." - ) - ) - else: - if any(line.journal_entry_ids for line in self): - raise UserError( - _( - "A selected statement line was already reconciled with " - "an account move." - ) - ) + # FIXME: review + # if suspense_moves_mode: + # if any(not line.journal_entry_ids for line in self): + # raise UserError( + # _( + # "Some selected statement line were not already " + # "reconciled with an account move." + # ) + # ) + # else: + # if any(line.journal_entry_ids for line in self): + # raise UserError( + # _( + # "A selected statement line was already reconciled with " + # "an account move." + # ) + # ) # Fully reconciled moves are just linked to the bank statement total = self.amount @@ -171,7 +173,7 @@ def process_reconciliation( # it. aml_rec.move_id.date = self.date aml_rec.payment_id.payment_date = self.date - aml_rec.move_id.post() + aml_rec.move_id.action_post() # We check the paid status of the invoices reconciled with this # payment for invoice in aml_rec.payment_id.reconciled_invoice_ids: @@ -181,86 +183,10 @@ def process_reconciliation( # (eg. invoice), in which case we reconcile the existing and the new # move lines together, or being a write-off. if counterpart_aml_dicts or new_aml_dicts: - - # Create the move - self.sequence = self.statement_id.line_ids.ids.index(self.id) + 1 - move_vals = self._prepare_reconciliation_move(self.statement_id.name) - if suspense_moves_mode: - self.button_cancel_reconciliation() - move = ( - self.env["account.move"] - .with_context(default_journal_id=move_vals["journal_id"]) - .create(move_vals) + counterpart_moves = self._create_counterpart_and_new_aml( + counterpart_moves, counterpart_aml_dicts, new_aml_dicts ) - counterpart_moves = counterpart_moves | move - - # Create The payment - payment = self.env["account.payment"] - partner_id = ( - self.partner_id - or (aml_dict.get("move_line") and aml_dict["move_line"].partner_id) - or self.env["res.partner"] - ) - if abs(total) > 0.00001: - payment_vals = self._prepare_payment_vals(total) - if not payment_vals["partner_id"]: - payment_vals["partner_id"] = partner_id.id - if payment_vals["partner_id"] and len(account_types) == 1: - payment_vals["partner_type"] = ( - "customer" - if account_types == receivable_account_type - else "supplier" - ) - payment = payment.create(payment_vals) - - # Complete dicts to create both counterpart move lines and write-offs - to_create = counterpart_aml_dicts + new_aml_dicts - date = self.date or fields.Date.today() - for aml_dict in to_create: - aml_dict["move_id"] = move.id - aml_dict["partner_id"] = self.partner_id.id - aml_dict["statement_line_id"] = self.id - self._prepare_move_line_for_currency(aml_dict, date) - - # Create write-offs - for aml_dict in new_aml_dicts: - aml_dict["payment_id"] = payment and payment.id or False - aml_obj.with_context(check_move_validity=False).create(aml_dict) - - # Create counterpart move lines and reconcile them - for aml_dict in counterpart_aml_dicts: - if ( - aml_dict["move_line"].payment_id - and not aml_dict["move_line"].statement_line_id - ): - aml_dict["move_line"].write({"statement_line_id": self.id}) - if aml_dict["move_line"].partner_id.id: - aml_dict["partner_id"] = aml_dict["move_line"].partner_id.id - aml_dict["account_id"] = aml_dict["move_line"].account_id.id - aml_dict["payment_id"] = payment and payment.id or False - - counterpart_move_line = aml_dict.pop("move_line") - new_aml = aml_obj.with_context(check_move_validity=False).create( - aml_dict - ) - - (new_aml | counterpart_move_line).reconcile() - self._check_invoice_state(counterpart_move_line.move_id) - - # Balance the move - st_line_amount = -sum([x.balance for x in move.line_ids]) - aml_dict = self._prepare_reconciliation_move_line(move, st_line_amount) - aml_dict["payment_id"] = payment and payment.id or False - aml_obj.with_context(check_move_validity=False).create(aml_dict) - - # Needs to be called manually as lines were created 1 by 1 - move.update_lines_tax_exigibility() - move.post() - # record the move name on the statement line to be able to retrieve - # it in case of unreconciliation - self.write({"move_name": move.name}) - payment and payment.write({"payment_reference": move.name}) elif self.move_name: raise UserError( _( @@ -277,177 +203,71 @@ def process_reconciliation( if self.account_number and self.partner_id and not self.bank_account_id: # Search bank account without partner to handle the case the # res.partner.bank already exists but is set on a different partner. - self.bank_account_id = self._find_or_create_bank_account() + self.partner_bank_id = self._find_or_create_bank_account() counterpart_moves._check_balanced() return counterpart_moves - def _prepare_reconciliation_move(self, move_ref): - """Prepare the dict of values to create the move from a statement line. - This method may be overridden to adapt domain logic through model - inheritance (make sure to call super() to establish a clean extension - chain). + def _create_counterpart_and_new_aml( + self, counterpart_moves, counterpart_aml_dicts, new_aml_dicts + ): - :param char move_ref: will be used as the reference of the generated - account move - :return: dict of value to create() the account.move - """ - ref = move_ref or "" - if self.ref: - ref = move_ref + " - " + self.ref if move_ref else self.ref - data = { - "type": "entry", - "journal_id": self.statement_id.journal_id.id, - "currency_id": self.statement_id.currency_id.id, - "date": self.statement_id.accounting_date or self.date, - "partner_id": self.partner_id.id, - "ref": ref, - } - if self.move_name: - data.update(name=self.move_name) - return data - - def _prepare_reconciliation_move_line(self, move, amount): - """Prepare the dict of values to balance the move. - - :param recordset move: the account.move to link the move line - :param dict move: a dict of vals of a account.move which will be created - later - :param float amount: the amount of transaction that wasn't already - reconciled - """ - company_currency = self.journal_id.company_id.currency_id - statement_currency = self.journal_id.currency_id or company_currency - st_line_currency = self.currency_id or statement_currency - amount_currency = False - st_line_currency_rate = ( - self.currency_id and (self.amount_currency / self.amount) or False - ) - if isinstance(move, dict): - amount_sum = sum(x[2].get("amount_currency", 0) for x in move["line_ids"]) - else: - amount_sum = sum(x.amount_currency for x in move.line_ids) - # We have several use case here to compare the currency and amount - # currency of counterpart line to balance the move: - if ( - st_line_currency != company_currency - and st_line_currency == statement_currency - ): - # company in currency A, statement in currency B and transaction in - # currency B - # counterpart line must have currency B and correct amount is - # inverse of already existing lines - amount_currency = -amount_sum - elif ( - st_line_currency != company_currency - and statement_currency == company_currency - ): - # company in currency A, statement in currency A and transaction in - # currency B - # counterpart line must have currency B and correct amount is - # inverse of already existing lines - amount_currency = -amount_sum - elif ( - st_line_currency != company_currency - and st_line_currency != statement_currency - ): - # company in currency A, statement in currency B and transaction in - # currency C - # counterpart line must have currency B and use rate between B and - # C to compute correct amount - amount_currency = -amount_sum / st_line_currency_rate - elif ( - st_line_currency == company_currency - and statement_currency != company_currency - ): - # company in currency A, statement in currency B and transaction in - # currency A - # counterpart line must have currency B and amount is computed using - # the rate between A and B - amount_currency = amount / st_line_currency_rate - - # last case is company in currency A, statement in currency A and - # transaction in currency A - # and in this case counterpart line does not need any second currency - # nor amount_currency - - # Check if default_debit or default_credit account are properly configured - account_id = ( - amount >= 0 - and self.statement_id.journal_id.default_credit_account_id.id - or self.statement_id.journal_id.default_debit_account_id.id - ) + aml_obj = self.env["account.move.line"] - if not account_id: - raise UserError( - _( - "No default debit and credit account defined on journal %s " - "(ids: %s)." - % ( - self.statement_id.journal_id.name, - self.statement_id.journal_id.ids, - ) - ) - ) + # Delete previous move_lines + self.move_id.line_ids.with_context(force_delete=True).unlink() - aml_dict = { - "name": self.name, - "partner_id": self.partner_id and self.partner_id.id or False, - "account_id": account_id, - "credit": amount < 0 and -amount or 0.0, - "debit": amount > 0 and amount or 0.0, - "statement_line_id": self.id, - "currency_id": statement_currency != company_currency - and statement_currency.id - or (st_line_currency != company_currency and st_line_currency.id or False), - "amount_currency": amount_currency, - } - if isinstance(move, self.env["account.move"].__class__): - aml_dict["move_id"] = move.id - return aml_dict + # Create liquidity line + liquidity_aml_dict = self._prepare_liquidity_move_line_vals() + aml_obj.with_context(check_move_validity=False).create(liquidity_aml_dict) - def _get_communication(self, payment_method_id): - return self.name or "" + self.sequence = self.statement_id.line_ids.ids.index(self.id) + 1 + counterpart_moves = counterpart_moves | self.move_id - def _prepare_payment_vals(self, total): - """Prepare the dict of values to create the payment from a statement - line. This method may be overridden for update dict - through model inheritance (make sure to call super() to establish a - clean extension chain). + # Complete dicts to create both counterpart move lines and write-offs + to_create = counterpart_aml_dicts + new_aml_dicts + date = self.date or fields.Date.today() + for aml_dict in to_create: + aml_dict["move_id"] = self.move_id.id + aml_dict["partner_id"] = self.partner_id.id + aml_dict["statement_line_id"] = self.id + self._prepare_move_line_for_currency(aml_dict, date) - :param float total: will be used as the amount of the generated payment - :return: dict of value to create() the account.payment - """ - self.ensure_one() - partner_type = False - if self.partner_id: - if total < 0: - partner_type = "supplier" - else: - partner_type = "customer" - if not partner_type and self.env.context.get("default_partner_type"): - partner_type = self.env.context["default_partner_type"] - currency = self.journal_id.currency_id or self.company_id.currency_id - payment_methods = ( - (total > 0) - and self.journal_id.inbound_payment_method_ids - or self.journal_id.outbound_payment_method_ids - ) - return { - "payment_method_id": payment_methods and payment_methods[0].id or False, - "payment_type": total > 0 and "inbound" or "outbound", - "partner_id": self.partner_id.id, - "partner_type": partner_type, - "journal_id": self.statement_id.journal_id.id, - "payment_date": self.date, - "state": "reconciled", - "currency_id": currency.id, - "amount": abs(total), - "communication": self._get_communication( - payment_methods[0] if payment_methods else False - ), - "name": self.statement_id.name or _("Bank Statement %s") % self.date, - } + # Create write-offs + for aml_dict in new_aml_dicts: + aml_obj.with_context(check_move_validity=False).create(aml_dict) + + # Create counterpart move lines and reconcile them + aml_to_reconcile = [] + for aml_dict in counterpart_aml_dicts: + if not aml_dict["move_line"].statement_line_id: + aml_dict["move_line"].write({"statement_line_id": self.id}) + if aml_dict["move_line"].partner_id.id: + aml_dict["partner_id"] = aml_dict["move_line"].partner_id.id + aml_dict["account_id"] = aml_dict["move_line"].account_id.id + + counterpart_move_line = aml_dict.pop("move_line") + new_aml = aml_obj.with_context(check_move_validity=False).create(aml_dict) + + aml_to_reconcile.append((new_aml, counterpart_move_line)) + + # Post to allow reconcile + self.move_id.with_context(skip_account_move_synchronization=True).action_post() + + # Reconcile new lines with counterpart + for new_aml, counterpart_move_line in aml_to_reconcile: + (new_aml | counterpart_move_line).reconcile() + + self._check_invoice_state(counterpart_move_line.move_id) + + # Needs to be called manually as lines were created 1 by 1 + self.move_id.update_lines_tax_exigibility() + self.move_id.with_context(skip_account_move_synchronization=True).action_post() + # record the move name on the statement line to be able to retrieve + # it in case of unreconciliation + self.write({"move_name": self.move_id.name}) + + return counterpart_moves def _prepare_move_line_for_currency(self, aml_dict, date): self.ensure_one() diff --git a/account_reconciliation_widget/models/account_journal.py b/account_reconciliation_widget/models/account_journal.py index 261c8bbccd..3650bc76e2 100644 --- a/account_reconciliation_widget/models/account_journal.py +++ b/account_reconciliation_widget/models/account_journal.py @@ -20,3 +20,18 @@ def action_open_reconcile(self): "company_ids": self.mapped("company_id").ids, }, } + + def action_open_reconcile_to_check(self): + self.ensure_one() + ids = self.to_check_ids().ids + action_context = { + "show_mode_selector": False, + "company_ids": self.mapped("company_id").ids, + } + action_context.update({"suspense_moves_mode": True}) + action_context.update({"statement_line_ids": ids}) + return { + "type": "ir.actions.client", + "tag": "bank_statement_reconciliation_view", + "context": action_context, + } diff --git a/account_reconciliation_widget/models/account_move.py b/account_reconciliation_widget/models/account_move.py index e1a689285e..28d07239a0 100644 --- a/account_reconciliation_widget/models/account_move.py +++ b/account_reconciliation_widget/models/account_move.py @@ -121,7 +121,7 @@ def compute_writeoff_counterpart_vals(values): # post all the writeoff moves at once if writeoff_moves: - writeoff_moves.post() + writeoff_moves.action_post() # Return the writeoff move.line which is to be reconciled return line_to_reconcile diff --git a/account_reconciliation_widget/models/reconciliation_widget.py b/account_reconciliation_widget/models/reconciliation_widget.py index ebe2c71010..0d0a3d1b96 100644 --- a/account_reconciliation_widget/models/reconciliation_widget.py +++ b/account_reconciliation_widget/models/reconciliation_widget.py @@ -1,5 +1,7 @@ import copy +from psycopg2 import sql + from odoo import _, api, models from odoo.exceptions import UserError from odoo.osv import expression @@ -86,8 +88,7 @@ def get_move_lines_for_bank_statement_line( # Blue lines = payment on bank account not assigned to a statement yet aml_accounts = [ - st_line.journal_id.default_credit_account_id.id, - st_line.journal_id.default_debit_account_id.id, + st_line.journal_id.default_account_id.id, ] if partner_id is None: @@ -106,7 +107,8 @@ def get_move_lines_for_bank_statement_line( from_clause, where_clause, where_clause_params = ( self.env["account.move.line"]._where_calc(domain).get_sql() ) - query_str = """ + query_str = sql.SQL( + """ SELECT "account_move_line".id FROM {from_clause} {where_str} ORDER BY ("account_move_line".debit - @@ -115,10 +117,11 @@ def get_move_lines_for_bank_statement_line( "account_move_line".id ASC {limit_str} """.format( - from_clause=from_clause, - where_str=where_clause and (" WHERE %s" % where_clause) or "", - amount=st_line.amount, - limit_str=limit and " LIMIT %s" or "", + from_clause=from_clause, + where_str=where_clause and (" WHERE %s" % where_clause) or "", + amount=st_line.amount, + limit_str=limit and " LIMIT %s" or "", + ) ) params = where_clause_params + (limit and [limit] or []) self.env["account.move"].flush() @@ -290,7 +293,6 @@ def get_bank_statement_data(self, bank_statement_line_ids, srch_domain=None): """ if not bank_statement_line_ids: return {} - suspense_moves_mode = self._context.get("suspense_moves_mode") bank_statements = ( self.env["account.bank.statement.line"] .browse(bank_statement_line_ids) @@ -301,17 +303,13 @@ def get_bank_statement_data(self, bank_statement_line_ids, srch_domain=None): SELECT line.id FROM account_bank_statement_line line LEFT JOIN res_partner p on p.id = line.partner_id - WHERE line.account_id IS NULL + INNER JOIN account_bank_statement st ON line.statement_id = st.id + AND st.state = 'posted' + WHERE line.is_reconciled = FALSE AND line.amount != 0.0 AND line.id IN %(ids)s - {cond} GROUP BY line.id - """.format( - cond=not suspense_moves_mode - and """AND NOT EXISTS (SELECT 1 from account_move_line aml - WHERE aml.statement_line_id = line.id)""" - or "", - ) + """ self.env.cr.execute(query, {"ids": tuple(bank_statement_line_ids)}) domain = [["id", "in", [line.get("id") for line in self.env.cr.dictfetchall()]]] @@ -327,6 +325,9 @@ def get_bank_statement_data(self, bank_statement_line_ids, srch_domain=None): results.update( { + "statement_id": len(bank_statements_left) == 1 + and bank_statements_left.id + or False, "statement_name": len(bank_statements_left) == 1 and bank_statements_left.name or False, @@ -403,7 +404,6 @@ def get_all_data_for_manual_reconciliation(self, partner_ids, account_ids): ) if aml_ids: aml = MoveLine.browse(aml_ids) - aml._check_reconcile_validity() account = aml[0].account_id currency = account.currency_id or account.company_id.currency_id return { @@ -489,8 +489,7 @@ def get_data_for_manual_reconciliation( WHERE l.account_id = a.id {inner_where} AND l.amount_residual != 0 - AND (move.state = 'posted' OR (move.state = 'draft' - AND journal.post_at = 'bank_rec')) + AND move.state = 'posted' ) """.format( inner_where=is_partner and "AND l.partner_id = p.id" or " " @@ -504,8 +503,7 @@ def get_data_for_manual_reconciliation( WHERE l.account_id = a.id {inner_where} AND l.amount_residual > 0 - AND (move.state = 'posted' - OR (move.state = 'draft' AND journal.post_at = 'bank_rec')) + AND move.state = 'posted' ) AND EXISTS ( SELECT NULL @@ -515,13 +513,13 @@ def get_data_for_manual_reconciliation( WHERE l.account_id = a.id {inner_where} AND l.amount_residual < 0 - AND (move.state = 'posted' - OR (move.state = 'draft' AND journal.post_at = 'bank_rec')) + AND move.state = 'posted' ) """.format( inner_where=is_partner and "AND l.partner_id = p.id" or " " ) - query = """ + query = sql.SQL( + """ SELECT {select} account_id, account_name, account_code, max_date FROM ( SELECT {inner_select} @@ -549,35 +547,36 @@ def get_data_for_manual_reconciliation( ) as s {outer_where} """.format( - select=is_partner - and "partner_id, partner_name, to_char(last_time_entries_checked, " - "'YYYY-MM-DD') AS last_time_entries_checked," - or " ", - inner_select=is_partner - and "p.id AS partner_id, p.name AS partner_name, " - "p.last_time_entries_checked AS last_time_entries_checked," - or " ", - inner_from=is_partner - and "RIGHT JOIN res_partner p ON (l.partner_id = p.id)" - or " ", - where1=is_partner - and " " - or "AND ((at.type <> 'payable' AND at.type <> 'receivable') " - "OR l.partner_id IS NULL)", - where2=account_type and "AND at.type = %(account_type)s" or "", - where3=res_ids and "AND " + res_alias + ".id in %(res_ids)s" or "", - company_id=self.env.company.id, - where4=aml_ids and "AND l.id IN %(aml_ids)s" or " ", - where5=all_entries and all_entries_query or only_dual_entries_query, - group_by1=is_partner and "l.partner_id, p.id," or " ", - group_by2=is_partner and ", p.last_time_entries_checked" or " ", - order_by=is_partner - and "ORDER BY p.last_time_entries_checked" - or "ORDER BY a.code", - outer_where=is_partner - and "WHERE (last_time_entries_checked IS NULL " - "OR max_date > last_time_entries_checked)" - or " ", + select=is_partner + and "partner_id, partner_name, to_char(last_time_entries_checked, " + "'YYYY-MM-DD') AS last_time_entries_checked," + or " ", + inner_select=is_partner + and "p.id AS partner_id, p.name AS partner_name, " + "p.last_time_entries_checked AS last_time_entries_checked," + or " ", + inner_from=is_partner + and "RIGHT JOIN res_partner p ON (l.partner_id = p.id)" + or " ", + where1=is_partner + and " " + or "AND ((at.type <> 'payable' AND at.type <> 'receivable') " + "OR l.partner_id IS NULL)", + where2=account_type and "AND at.type = %(account_type)s" or "", + where3=res_ids and "AND " + res_alias + ".id in %(res_ids)s" or "", + company_id=self.env.company.id, + where4=aml_ids and "AND l.id IN %(aml_ids)s" or " ", + where5=all_entries and all_entries_query or only_dual_entries_query, + group_by1=is_partner and "l.partner_id, p.id," or " ", + group_by2=is_partner and ", p.last_time_entries_checked" or " ", + order_by=is_partner + and "ORDER BY p.last_time_entries_checked" + or "ORDER BY a.code", + outer_where=is_partner + and "WHERE (last_time_entries_checked IS NULL " + "OR max_date > last_time_entries_checked)" + or " ", + ) ) self.env["account.move.line"].flush() self.env["account.account"].flush() @@ -822,17 +821,6 @@ def _domain_move_lines_for_reconciliation( # line domain = expression.AND([domain, [("company_id", "=", st_line.company_id.id)]]) - # take only moves in valid state. Draft is accepted only when "Post At" - # is set to "Bank Reconciliation" in the associated journal - domain_post_at = [ - "|", - "&", - ("move_id.state", "=", "draft"), - ("journal_id.post_at", "=", "bank_rec"), - ("move_id.state", "not in", ["draft", "cancel"]), - ] - domain = expression.AND([domain, domain_post_at]) - if st_line.company_id.account_bank_reconciliation_start: domain = expression.AND( [ @@ -858,11 +846,7 @@ def _domain_move_lines_for_manual_reconciliation( "&", ("reconciled", "=", False), ("account_id", "=", account_id), - "|", ("move_id.state", "=", "posted"), - "&", - ("move_id.state", "=", "draft"), - ("move_id.journal_id.post_at", "=", "bank_rec"), ] domain = expression.AND([domain, [("balance", "!=", 0.0)]]) if partner_id: @@ -1046,7 +1030,8 @@ def _get_statement_line(self, st_line): data = { "id": st_line.id, "ref": st_line.ref, - "note": st_line.note or "", + # FIXME: where to fill? + # 'note': st_line.note or "", "name": st_line.name, "date": format_date(self.env, st_line.date), "amount": amount, @@ -1056,11 +1041,11 @@ def _get_statement_line(self, st_line): "journal_id": st_line.journal_id.id, "statement_id": st_line.statement_id.id, "account_id": [ - st_line.journal_id.default_debit_account_id.id, - st_line.journal_id.default_debit_account_id.display_name, + st_line.journal_id.default_account_id.id, + st_line.journal_id.default_account_id.display_name, ], - "account_code": st_line.journal_id.default_debit_account_id.code, - "account_name": st_line.journal_id.default_debit_account_id.name, + "account_code": st_line.journal_id.default_account_id.code, + "account_name": st_line.journal_id.default_account_id.name, "partner_name": st_line.partner_id.name, "communication_partner_name": st_line.partner_name, # Amount in the statement currency @@ -1091,20 +1076,19 @@ def _get_move_line_reconciliation_proposition(self, account_id, partner_id=None) where_str = where_clause and (" WHERE %s" % where_clause) or "" # Get pairs - query = """ + query = sql.SQL( + """ SELECT a.id, b.id FROM account_move_line a, account_move_line b, account_move move_a, account_move move_b, account_journal journal_a, account_journal journal_b WHERE a.id != b.id AND move_a.id = a.move_id - AND (move_a.state = 'posted' - OR (move_a.state = 'draft' AND journal_a.post_at = 'bank_rec')) + AND move_a.state = 'posted' AND move_a.journal_id = journal_a.id AND move_b.id = b.move_id AND move_b.journal_id = journal_b.id - AND (move_b.state = 'posted' - OR (move_b.state = 'draft' AND journal_b.post_at = 'bank_rec')) + AND move_b.state = 'posted' AND a.amount_residual = -b.amount_residual AND a.balance != 0.0 AND b.balance != 0.0 @@ -1118,7 +1102,8 @@ def _get_move_line_reconciliation_proposition(self, account_id, partner_id=None) ORDER BY a.date desc LIMIT 1 """.format( - from_clause + where_str + from_clause + where_str + ) ) move_line_id = self.env.context.get("move_line_id") or None params = ( diff --git a/account_reconciliation_widget/readme/CONTRIBUTORS.rst b/account_reconciliation_widget/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..207c602b26 --- /dev/null +++ b/account_reconciliation_widget/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Tecnativa - Pedro M. Baeza diff --git a/account_reconciliation_widget/readme/DESCRIPTION.rst b/account_reconciliation_widget/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..573558c822 --- /dev/null +++ b/account_reconciliation_widget/readme/DESCRIPTION.rst @@ -0,0 +1,2 @@ +This module restores account reconciliation widget moved from Odoo community to enterpise in V. 14.0 +Provides two widgets designed to reconcile move lines in a easy way: one focused on bank statements and another for generic use. diff --git a/account_reconciliation_widget/readme/USAGE.rst b/account_reconciliation_widget/readme/USAGE.rst new file mode 100644 index 0000000000..4b4b9566c2 --- /dev/null +++ b/account_reconciliation_widget/readme/USAGE.rst @@ -0,0 +1,9 @@ +With an user with full accounting features enabled: + +Invoicing --> Accounting --> Actions --> Reconciliation. + +From journal items list view you can select check of them and click Action --> Reconcile. + +From accounting dashboard you can use reconcile button in Bank / Cash journals. + +Also, you can navigate to statements and use the reconcile button. diff --git a/account_reconciliation_widget/security/ir.model.access.csv b/account_reconciliation_widget/security/ir.model.access.csv new file mode 100644 index 0000000000..d9184f7402 --- /dev/null +++ b/account_reconciliation_widget/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_account_reconciliation_widget_group_invoice,account_reconciliation_widget.group_invoice,model_account_reconciliation_widget,account.group_account_invoice,1,1,1,1 diff --git a/account_reconciliation_widget/static/description/index.html b/account_reconciliation_widget/static/description/index.html new file mode 100644 index 0000000000..560b763ae4 --- /dev/null +++ b/account_reconciliation_widget/static/description/index.html @@ -0,0 +1,429 @@ + + + + + + +account_reconciliation_widget + + + +
+

account_reconciliation_widget

+ + +

Beta License: AGPL-3 OCA/account_reconciliation_widget Translate me on Weblate

+

This module restores account reconciliation widget moved from Odoo community to enterpise in V. 14.0 +Provides two widgets designed to reconcile move lines in a easy way: one focused on bank statements and another for generic use.

+

Table of contents

+ +
+

Usage

+

With an user with full accounting features enabled:

+

Invoicing –> Accounting –> Actions –> Reconciliation.

+

From journal items list view you can select check of them and click Action –> Reconcile.

+

From accounting dashboard you can use reconcile button in Bank / Cash journals.

+

Also, you can navigate to statements and use the reconcile button.

+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Ozono Multimedia
  • +
+
+
+

Contributors

+
    +
  • Tecnativa - Pedro M. Baeza
  • +
+
+
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/account_reconciliation_widget project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/account_reconciliation_widget/static/src/js/reconciliation/reconciliation_action.js b/account_reconciliation_widget/static/src/js/reconciliation/reconciliation_action.js index 3acf3ba443..6e5a3d3a36 100644 --- a/account_reconciliation_widget/static/src/js/reconciliation/reconciliation_action.js +++ b/account_reconciliation_widget/static/src/js/reconciliation/reconciliation_action.js @@ -32,7 +32,6 @@ odoo.define("account.ReconciliationClientAction", function (require) { close_statement: "_onCloseStatement", load_more: "_onLoadMore", reload: "reload", - search: "_onSearch", navigation_move: "_onNavigationMove", }, config: _.extend({}, AbstractAction.prototype.config, { @@ -52,6 +51,7 @@ odoo.define("account.ReconciliationClientAction", function (require) { _onNavigationMove: function (ev) { var non_reconciled_keys = _.keys( + // eslint-disable-next-line no-unused-vars _.pick(this.model.lines, function (value, key, object) { return !value.reconciled; }) @@ -86,7 +86,8 @@ odoo.define("account.ReconciliationClientAction", function (require) { this._super.apply(this, arguments); this.action_manager = parent; this.params = params; - this.controlPanelParams.modelName = "account.bank.statement.line"; + this.searchModelConfig.modelName = "account.bank.statement.line"; + this.controlPanelProps.cp_content = {}; this.model = new this.config.Model(this, { modelName: "account.reconciliation.widget", defaultDisplayQty: @@ -191,7 +192,10 @@ odoo.define("account.ReconciliationClientAction", function (require) { initialState.valuenow = valuenow; initialState.context = self.model.getContext(); self.renderer.showRainbowMan(initialState); - self.remove_cp(); + self.controlPanelProps.cp_content = { + $buttons: $(), + $pager: $(), + }; } else { // Create a notification if some lines have been reconciled automatically. if (initialState.valuenow > 0) @@ -234,12 +238,11 @@ odoo.define("account.ReconciliationClientAction", function (require) { this.$pager = $( QWeb.render("reconciliation.control.pager", {widget: this.renderer}) ); - this.updateControlPanel({ - clear: true, - cp_content: { - $pager: this.$pager, - }, - }); + + this.controlPanelProps.cp_content = { + $buttons: $(), + $pager: this.$pager, + }; this.renderer.$progress = this.$pager; $(this.renderer.$progress) .parent() @@ -248,12 +251,6 @@ odoo.define("account.ReconciliationClientAction", function (require) { } }, - remove_cp: function () { - this.updateControlPanel({ - clear: true, - }); - }, - // -------------------------------------------------------------------------- // Private // -------------------------------------------------------------------------- @@ -384,12 +381,11 @@ odoo.define("account.ReconciliationClientAction", function (require) { /** * @private - * @param {OdooEvent} ev + * @param {Object} searchQuery */ - _onSearch: function (ev) { + _onSearch: function (searchQuery) { var self = this; - ev.stopPropagation(); - this.model.domain = ev.data.domain; + this.model.domain = searchQuery.domain; this.model.display_context = "search"; self.reload().then(function () { self.renderer._updateProgressBar({ @@ -402,7 +398,6 @@ odoo.define("account.ReconciliationClientAction", function (require) { _onActionPartialAmount: function (event) { var self = this; var handle = event.target.handle; - var line = this.model.getLine(handle); var amount = this.model.getPartialReconcileAmount(handle, event.data); self._getWidget(handle).updatePartialAmount(event.data.data, amount); }, @@ -413,12 +408,13 @@ odoo.define("account.ReconciliationClientAction", function (require) { * @private * @param {OdooEvent} event */ + // eslint-disable-next-line no-unused-vars _onCloseStatement: function (event) { var self = this; return this.model.closeStatement().then(function (result) { self.do_action({ name: "Bank Statements", - res_model: "account.bank.statement.line", + res_model: "account.bank.statement", res_id: result, views: [[false, "form"]], type: "ir.actions.act_window", @@ -432,6 +428,7 @@ odoo.define("account.ReconciliationClientAction", function (require) { * * @param {OdooEvent} event */ + // eslint-disable-next-line no-unused-vars _onLoadMore: function (event) { return this._loadMore(this.model.defaultDisplayQty); }, @@ -466,7 +463,7 @@ odoo.define("account.ReconciliationClientAction", function (require) { self.widgets.splice(index, 1); } }); - // Get number of widget and if less than constant and if there are more to laod, load until constant + // Get number of widget and if less than constant and if there are more to load, load until constant if ( self.widgets.length < self.model.defaultDisplayQty && self.model.valuemax - self.model.valuenow >= diff --git a/account_reconciliation_widget/static/src/js/reconciliation/reconciliation_model.js b/account_reconciliation_widget/static/src/js/reconciliation/reconciliation_model.js index 26ca3f0978..6eaf53d3b0 100644 --- a/account_reconciliation_widget/static/src/js/reconciliation/reconciliation_model.js +++ b/account_reconciliation_widget/static/src/js/reconciliation/reconciliation_model.js @@ -5,7 +5,6 @@ odoo.define("account.ReconciliationModel", function (require) { var field_utils = require("web.field_utils"); var utils = require("web.utils"); var session = require("web.session"); - var WarningDialog = require("web.CrashManager").WarningDialog; var core = require("web.core"); var _t = core._t; @@ -50,7 +49,7 @@ odoo.define("account.ReconciliationModel", function (require) { * label: string * amount: number - real amount * amount_str: string - formated amount - * [already_paid]: boolean + * [is_liquidity_line]: boolean * [partner_id]: integer * [partner_name]: string * [account_code]: string @@ -140,7 +139,6 @@ odoo.define("account.ReconciliationModel", function (require) { * @returns {Promise} */ addProposition: function (handle, mv_line_id) { - var self = this; var line = this.getLine(handle); var prop = _.clone(_.find(line["mv_lines_" + line.mode], {id: mv_line_id})); this._addProposition(line, prop); @@ -307,11 +305,11 @@ odoo.define("account.ReconciliationModel", function (require) { closeStatement: function () { var self = this; return this._rpc({ - model: "account.bank.statement.line", - method: "button_confirm_bank", - args: [self.bank_statement_line_id.id], + model: "account.bank.statement", + method: "button_validate", + args: [self.statement.statement_id], }).then(function () { - return self.bank_statement_line_id.id; + return self.statement.statement_id; }); }, /** @@ -807,6 +805,7 @@ odoo.define("account.ReconciliationModel", function (require) { // Check if we have another line with to_check and if yes don't change value of this proposition prop.to_check = line.reconciliation_proposition.some(function ( rec_prop, + // eslint-disable-next-line no-unused-vars index ) { return rec_prop.id !== prop.id && rec_prop.to_check; @@ -928,7 +927,7 @@ odoo.define("account.ReconciliationModel", function (require) { var props = _.filter(line.reconciliation_proposition, function (prop) { return !prop.invalid; }); - var computeLinePromise; + var computeLinePromise = null; if (props.length === 0) { // Usability: if user has not chosen any lines and click validate, it has the same behavior // as creating a write-off of the same amount. @@ -951,13 +950,13 @@ odoo.define("account.ReconciliationModel", function (require) { partner_id: line.st_line.partner_id, counterpart_aml_dicts: _.map( _.filter(props, function (prop) { - return !isNaN(prop.id) && !prop.already_paid; + return !isNaN(prop.id) && !prop.is_liquidity_line; }), self._formatToProcessReconciliation.bind(self, line) ), payment_aml_ids: _.pluck( _.filter(props, function (prop) { - return !isNaN(prop.id) && prop.already_paid; + return !isNaN(prop.id) && prop.is_liquidity_line; }), "id" ), @@ -1124,7 +1123,7 @@ odoo.define("account.ReconciliationModel", function (require) { } return; } - if (!prop.already_paid && parseInt(prop.id)) { + if (!prop.is_liquidity_line && parseInt(prop.id)) { prop.is_move_line = true; } reconciliation_proposition.push(prop); @@ -1135,6 +1134,7 @@ odoo.define("account.ReconciliationModel", function (require) { prop.__tax_to_recompute && prop.base_amount ) { + // (OZM) REVISAR reconciliation_proposition = _.filter( reconciliation_proposition, function (p) { @@ -1170,10 +1170,10 @@ odoo.define("account.ReconciliationModel", function (require) { tax_ids: tax.tax_ids, tax_repartition_line_id: tax.tax_repartition_line_id, - tag_ids: tax.tag_ids, + tax_tag_ids: tax.tag_ids, amount: tax.amount, - label: prop.label - ? prop.label + " " + tax.name + name: prop.name + ? prop.name + " " + tax.name : tax.name, date: prop.date, account_id: tax.account_id @@ -1205,7 +1205,9 @@ odoo.define("account.ReconciliationModel", function (require) { reconciliation_proposition.push(tax_prop); }); - prop.tag_ids = result.base_tags; + prop.tax_tag_ids = self._formatMany2ManyTagsTax( + result.base_tags || [] + ); }) ); } else { @@ -1571,7 +1573,7 @@ odoo.define("account.ReconciliationModel", function (require) { var formatOptions = { currency_id: line.st_line.currency_id, }; - var amount; + var amount = 0; switch (values.amount_type) { case "percentage": amount = (line.balance.amount * values.amount) / 100; @@ -1580,7 +1582,6 @@ odoo.define("account.ReconciliationModel", function (require) { var matching = line.st_line.name.match( new RegExp(values.amount_from_label_regex) ); - amount = 0; if (matching && matching.length == 2) { matching = matching[1].replace( new RegExp("\\D" + values.decimal_separator, "g"), @@ -1714,7 +1715,7 @@ odoo.define("account.ReconciliationModel", function (require) { function (prop) { return _.isNumber(prop.id) ? prop.id : null; } - ).filter((id) => id != null); + ).filter((id) => id !== null); var filter = line["filter_" + mode] || ""; return this._rpc({ model: "account.reconciliation.widget", @@ -1783,8 +1784,8 @@ odoo.define("account.ReconciliationModel", function (require) { * @param {Object[]} data.moves list of processed account.move * @returns {Deferred} */ + // eslint-disable-next-line no-unused-vars _validatePostProcess: function (data) { - var self = this; return Promise.resolve(); }, }); @@ -2288,7 +2289,7 @@ odoo.define("account.ReconciliationModel", function (require) { function (prop) { return _.isNumber(prop.id) ? prop.id : null; } - ).filter((id) => id != null); + ).filter((id) => id !== null); var filter = line.filter_match || ""; var args = [ line.account_id.id, diff --git a/account_reconciliation_widget/static/src/js/reconciliation/reconciliation_renderer.js b/account_reconciliation_widget/static/src/js/reconciliation/reconciliation_renderer.js index 6584ea3218..520e90de3d 100644 --- a/account_reconciliation_widget/static/src/js/reconciliation/reconciliation_renderer.js +++ b/account_reconciliation_widget/static/src/js/reconciliation/reconciliation_renderer.js @@ -35,7 +35,6 @@ odoo.define("account.ReconciliationRenderer", function (require) { * @override */ start: function () { - var self = this; var defs = [this._super.apply(this, arguments)]; this.time = Date.now(); this.$progress = $(""); @@ -98,7 +97,6 @@ odoo.define("account.ReconciliationRenderer", function (require) { * @param {[object]} [state.notifications] */ update: function (state) { - var self = this; this._updateProgressBar(state); if (state.valuenow === state.valuemax && !this.$(".done_message").length) { @@ -147,6 +145,7 @@ odoo.define("account.ReconciliationRenderer", function (require) { * close and then open form view of bank statement * @param {MouseEvent} event */ + // eslint-disable-next-line no-unused-vars _onCloseBankStatement: function (e) { this.trigger_up("close_statement"); }, @@ -215,6 +214,7 @@ odoo.define("account.ReconciliationRenderer", function (require) { * @private * @param {MouseEvent} event */ + // eslint-disable-next-line no-unused-vars _onLoadMore: function (e) { this.trigger_up("load_more"); }, @@ -428,7 +428,6 @@ odoo.define("account.ReconciliationRenderer", function (require) { // Search propositions that could be a partial credit/debit. var props = []; - var balance = state.balance.amount_currency; _.each(state.reconciliation_proposition, function (prop) { if (prop.display) { props.push(prop); @@ -460,8 +459,6 @@ odoo.define("account.ReconciliationRenderer", function (require) { var matching_modes = self.model.modes.filter((x) => x.startsWith("match")); for (let i = 0; i < matching_modes.length; i++) { var stateMvLines = state["mv_lines_" + matching_modes[i]] || []; - var recs_count = - stateMvLines.length > 0 ? stateMvLines[0].recs_count : 0; var remaining = state["remaining_" + matching_modes[i]]; var $mv_lines = this.$( 'div[id*="notebook_page_' + @@ -517,7 +514,7 @@ odoo.define("account.ReconciliationRenderer", function (require) { // Create form if (state.createForm) { - var createPromise; + var createPromise = null; if (!this.fields.account_id) { createPromise = this._renderCreate(state); } @@ -822,6 +819,11 @@ odoo.define("account.ReconciliationRenderer", function (require) { group_acc: self.group_acc, }) ); + + function addRequiredStyle(widget) { + widget.$el.addClass("o_required_modifier"); + } + self.fields.account_id .appendTo($create.find(".create_account_id .o_td_field")) .then(addRequiredStyle.bind(self, self.fields.account_id)); @@ -851,10 +853,6 @@ odoo.define("account.ReconciliationRenderer", function (require) { $create.find(".create_to_check .o_td_field") ); self.$(".create").append($create); - - function addRequiredStyle(widget) { - widget.$el.addClass("o_required_modifier"); - } }); }, @@ -1060,7 +1058,9 @@ odoo.define("account.ReconciliationRenderer", function (require) { * @param {MouseEvent} event */ _onQuickCreateProposition: function (event) { - document.activeElement && document.activeElement.blur(); + if (document.activeElement) { + document.activeElement.blur(); + } this.trigger_up("quick_create_proposition", { data: $(event.target).data("reconcile-model-id"), }); @@ -1069,7 +1069,9 @@ odoo.define("account.ReconciliationRenderer", function (require) { * @private */ _onCreateProposition: function () { - document.activeElement && document.activeElement.blur(); + if (document.activeElement) { + document.activeElement.blur(); + } var invalid = []; _.each(this.fields, function (field) { if (!field.isValid()) { diff --git a/account_reconciliation_widget/static/src/xml/account_reconciliation.xml b/account_reconciliation_widget/static/src/xml/account_reconciliation.xml index e3a946d997..e687e29bd5 100644 --- a/account_reconciliation_widget/static/src/xml/account_reconciliation.xml +++ b/account_reconciliation_widget/static/src/xml/account_reconciliation.xml @@ -66,7 +66,7 @@ rel="do_action" data-action_name="Unpaid Customer Invoices" data-model="account.move" - data-domain="[('type', 'in', ('out_invoice', 'out_refund'))]" + data-domain="[('move_type', 'in', ('out_invoice', 'out_refund'))]" data-context="{'search_default_unpaid': 1}" >unpaid invoices and follow-up customers
  • Pay your vendor bills
  • Check all + + + + account.bank.statement.inherit.view.form + account.bank.statement + + + + + + + + +
    + +
    + + + +
    +
    + + + + + + diff --git a/account_reconciliation_widget/views/account_view.xml b/account_reconciliation_widget/views/account_view.xml new file mode 100644 index 0000000000..2b1f8dab09 --- /dev/null +++ b/account_reconciliation_widget/views/account_view.xml @@ -0,0 +1,30 @@ + + + + + Reconciliation on Bank Statements + account.bank.statement.line + bank_statement_reconciliation_view + + + + Reconcile + manual_reconciliation_view + + action + list + + + + Reconciliation + manual_reconciliation_view + + + + + diff --git a/account_reconciliation_widget/views/assets.xml b/account_reconciliation_widget/views/assets.xml new file mode 100644 index 0000000000..453a0d3bf5 --- /dev/null +++ b/account_reconciliation_widget/views/assets.xml @@ -0,0 +1,42 @@ + + +