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.
+
+
+
+
+
+ Go to bank statement(s)
+
+
+ Close statement
+
+
+
+
+
+
+
+
+
+
+
+ Validate
+ Validate
+ Validate
+
+
+
+
+
+
+ ( )
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Reconcile
+ Reconcile
+ Skip
+
+
+
+
+
+
+
+
+
+
+ Last Reconciliation:
+
+
+
+
+
+
+
+
+
+
+
+
+ Open balance Choose counterpart or Create Write-off
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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
+
+
+
+
+
+
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_journal_dashboard_view.xml b/account_reconciliation_widget/views/account_journal_dashboard_view.xml
new file mode 100644
index 0000000000..662dd8d6b8
--- /dev/null
+++ b/account_reconciliation_widget/views/account_journal_dashboard_view.xml
@@ -0,0 +1,47 @@
+
+
+
+
+ account.journal.inherit.dashboard.kanban
+ account.journal
+
+
+
+
+ Reconcile Items
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/setup/account_reconciliation_widget/odoo/addons/account_reconciliation_widget b/setup/account_reconciliation_widget/odoo/addons/account_reconciliation_widget
new file mode 120000
index 0000000000..9fb3ea8219
--- /dev/null
+++ b/setup/account_reconciliation_widget/odoo/addons/account_reconciliation_widget
@@ -0,0 +1 @@
+../../../../account_reconciliation_widget
\ No newline at end of file
diff --git a/setup/account_reconciliation_widget/setup.py b/setup/account_reconciliation_widget/setup.py
new file mode 100644
index 0000000000..28c57bb640
--- /dev/null
+++ b/setup/account_reconciliation_widget/setup.py
@@ -0,0 +1,6 @@
+import setuptools
+
+setuptools.setup(
+ setup_requires=['setuptools-odoo'],
+ odoo_addon=True,
+)
From 038f4150d9400be65e5dfcf11138cc8dae0f3f0f Mon Sep 17 00:00:00 2001
From: Francisco Ivan Anton Prieto
Date: Tue, 19 Jan 2021 11:16:00 +0100
Subject: [PATCH 3/3] [FIX] account_reconciliation_widget: dict refactoring /
typo fix
---
account_reconciliation_widget/models/account_journal.py | 4 ++--
account_reconciliation_widget/models/account_move.py | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/account_reconciliation_widget/models/account_journal.py b/account_reconciliation_widget/models/account_journal.py
index 3650bc76e2..cf519d70ab 100644
--- a/account_reconciliation_widget/models/account_journal.py
+++ b/account_reconciliation_widget/models/account_journal.py
@@ -27,9 +27,9 @@ def action_open_reconcile_to_check(self):
action_context = {
"show_mode_selector": False,
"company_ids": self.mapped("company_id").ids,
+ "suspense_moves_mode": True,
+ "statement_line_ids": 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",
diff --git a/account_reconciliation_widget/models/account_move.py b/account_reconciliation_widget/models/account_move.py
index 28d07239a0..a710ba60bd 100644
--- a/account_reconciliation_widget/models/account_move.py
+++ b/account_reconciliation_widget/models/account_move.py
@@ -13,7 +13,7 @@ def _create_writeoff(self, writeoff_vals):
: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
+ create both writeoff account.move.line and their enclosing
account.move.
"""