diff --git a/account_mass_reconcile/models/advanced_reconciliation.py b/account_mass_reconcile/models/advanced_reconciliation.py index af0a4459..2e81337d 100644 --- a/account_mass_reconcile/models/advanced_reconciliation.py +++ b/account_mass_reconcile/models/advanced_reconciliation.py @@ -110,3 +110,107 @@ class MassReconcileAdvancedRef(models.TransientModel): move_line["name"].lower().strip(), ), ) + + +class MassReconcileAdvancedName(models.TransientModel): + + _name = "mass.reconcile.advanced.name" + _inherit = "mass.reconcile.advanced" + _description = "Mass Reconcile Advanced Name" + + @staticmethod + def _skip_line(move_line): + """ + When True is returned on some conditions, the credit move line + will be skipped for reconciliation. Can be inherited to + skip on some conditions. ie: ref or partner_id is empty. + """ + return not (move_line.get("name", "/") != "/" and move_line.get("partner_id")) + + @staticmethod + def _matchers(move_line): + """ + Return the values used as matchers to find the opposite lines + + All the matcher keys in the dict must have their equivalent in + the `_opposite_matchers`. + + The values of each matcher key will be searched in the + one returned by the `_opposite_matchers` + + Must be inherited to implement the matchers for one method + + For instance, it can return: + return ('ref', move_line['rec']) + + or + return (('partner_id', move_line['partner_id']), + ('ref', "prefix_%s" % move_line['rec'])) + + All the matchers have to be found in the opposite lines + to consider them as "opposite" + + The matchers will be evaluated in the same order as declared + vs the the opposite matchers, so you can gain performance by + declaring first the partners with the less computation. + + All matchers should match with their opposite to be considered + as "matching". + So with the previous example, partner_id and ref have to be + equals on the opposite line matchers. + + :return: tuple of tuples (key, value) where the keys are + the matchers keys + (must be the same than `_opposite_matchers` returns, + and their values to match in the opposite lines. + A matching key can have multiples values. + """ + return ( + ("partner_id", move_line["partner_id"]), + ("name", move_line["name"].lower().strip()), + ) + + @staticmethod + def _opposite_matchers(move_line): + """ + Return the values of the opposite line used as matchers + so the line is matched + + Must be inherited to implement the matchers for one method + It can be inherited to apply some formatting of fields + (strip(), lower() and so on) + + This method is the counterpart of the `_matchers()` method. + + Each matcher has to yield its value respecting the order + of the `_matchers()`. + + When a matcher does not correspond, the next matchers won't + be evaluated so the ones which need the less computation + have to be executed first. + + If the `_matchers()` returns: + (('partner_id', move_line['partner_id']), + ('ref', move_line['ref'])) + + Here, you should yield : + yield ('partner_id', move_line['partner_id']) + yield ('ref', move_line['ref']) + + Note that a matcher can contain multiple values, as instance, + if for a move line, you want to search from its `ref` in the + `ref` or `name` fields of the opposite move lines, you have to + yield ('partner_id', move_line['partner_id']) + yield ('ref', (move_line['ref'], move_line['name']) + + An OR is used between the values for the same key. + An AND is used between the differents keys. + + :param dict move_line: values of the move_line + :yield: matchers as tuple ('matcher key', value(s)) + """ + yield ("partner_id", move_line["partner_id"]) + yield ( + "name", + (move_line["name"].lower().strip(),), + ) diff --git a/account_mass_reconcile/models/base_advanced_reconciliation.py b/account_mass_reconcile/models/base_advanced_reconciliation.py index 2d8064e7..57e80321 100644 --- a/account_mass_reconcile/models/base_advanced_reconciliation.py +++ b/account_mass_reconcile/models/base_advanced_reconciliation.py @@ -205,6 +205,7 @@ class MassReconcileAdvanced(models.AbstractModel): ] def _action_rec(self): + self.flush() credit_lines = self._query_credit() debit_lines = self._query_debit() result = self._rec_auto_lines_advanced(credit_lines, debit_lines) @@ -222,57 +223,51 @@ class MassReconcileAdvanced(models.AbstractModel): """ Advanced reconciliation main loop """ # pylint: disable=invalid-commit reconciled_ids = [] - for rec in self: - reconcile_groups = [] - ctx = self.env.context.copy() - ctx["commit_every"] = rec.account_id.company_id.reconciliation_commit_every - _logger.info("%d credit lines to reconcile", len(credit_lines)) - for idx, credit_line in enumerate(credit_lines, start=1): - if idx % 50 == 0: - _logger.info( - "... %d/%d credit lines inspected ...", idx, len(credit_lines) + reconcile_groups = [] + _logger.info("%d credit lines to reconcile", len(credit_lines)) + for idx, credit_line in enumerate(credit_lines, start=1): + if idx % 50 == 0: + _logger.info( + "... %d/%d credit lines inspected ...", idx, len(credit_lines) + ) + if self._skip_line(credit_line): + continue + opposite_lines = self._search_opposites(credit_line, debit_lines) + if not opposite_lines: + continue + opposite_ids = [line["id"] for line in opposite_lines] + line_ids = opposite_ids + [credit_line["id"]] + for group in reconcile_groups: + if any([lid in group for lid in opposite_ids]): + _logger.debug( + "New lines %s matched with an existing " "group %s", + line_ids, + group, ) - if self._skip_line(credit_line): - continue - opposite_lines = self._search_opposites(credit_line, debit_lines) - if not opposite_lines: - continue - opposite_ids = [l["id"] for l in opposite_lines] - line_ids = opposite_ids + [credit_line["id"]] - for group in reconcile_groups: - if any([lid in group for lid in opposite_ids]): - _logger.debug( - "New lines %s matched with an existing " "group %s", - line_ids, - group, - ) - group.update(line_ids) - break - else: - _logger.debug("New group of lines matched %s", line_ids) - reconcile_groups.append(set(line_ids)) - lines_by_id = {l["id"]: l for l in credit_lines + debit_lines} + group.update(line_ids) + break + else: + _logger.debug("New group of lines matched %s", line_ids) + reconcile_groups.append(set(line_ids)) + lines_by_id = {line["id"]: line for line in credit_lines + debit_lines} _logger.info("Found %d groups to reconcile", len(reconcile_groups)) - for group_count, reconcile_group_ids in enumerate( - reconcile_groups, start=1 - ): - _logger.debug( - "Reconciling group %d/%d with ids %s", - group_count, - len(reconcile_groups), - reconcile_group_ids, - ) - group_lines = [lines_by_id[lid] for lid in reconcile_group_ids] - reconciled, full = self._reconcile_lines( - group_lines, allow_partial=True - ) - if reconciled and full: - reconciled_ids += reconcile_group_ids + for group_count, reconcile_group_ids in enumerate(reconcile_groups, start=1): + _logger.debug( + "Reconciling group %d/%d with ids %s", + group_count, + len(reconcile_groups), + reconcile_group_ids, + ) + group_lines = [lines_by_id[lid] for lid in reconcile_group_ids] + reconciled, full = self._reconcile_lines(group_lines, allow_partial=True) + if reconciled and full: + reconciled_ids += reconcile_group_ids - if ctx["commit_every"] and group_count % ctx["commit_every"] == 0: - self.env.cr.commit() - _logger.info( - "Commit the reconciliations after %d groups", group_count - ) + if ( + self.env.context.get("commit_every", 0) + and group_count % self.env.context["commit_every"] == 0 + ): + self.env.cr.commit() + _logger.info("Commit the reconciliations after %d groups", group_count) _logger.info("Reconciliation is over") return reconciled_ids diff --git a/account_mass_reconcile/models/base_reconciliation.py b/account_mass_reconcile/models/base_reconciliation.py index 9c90017f..079d4fa7 100644 --- a/account_mass_reconcile/models/base_reconciliation.py +++ b/account_mass_reconcile/models/base_reconciliation.py @@ -2,7 +2,6 @@ # Copyright 2010 Sébastien Beau # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -from functools import reduce from operator import itemgetter from odoo import _, fields, models @@ -46,6 +45,9 @@ class MassReconcileBase(models.AbstractModel): "id", "debit", "credit", + "currency_id", + "amount_residual", + "amount_residual_currency", "date", "ref", "name", @@ -69,7 +71,8 @@ class MassReconcileBase(models.AbstractModel): self.ensure_one() where = ( "WHERE account_move_line.account_id = %s " - "AND NOT account_move_line.reconciled" + "AND NOT account_move_line.reconciled " + "AND parent_state = 'posted'" ) # it would be great to use dict for params # but as we use _where_calc in _get_filter @@ -78,7 +81,7 @@ class MassReconcileBase(models.AbstractModel): params = [self.account_id.id] if self.partner_ids: where += " AND account_move_line.partner_id IN %s" - params.append(tuple([l.id for l in self.partner_ids])) + params.append(tuple([line.id for line in self.partner_ids])) return where, params def _get_filter(self): @@ -95,16 +98,29 @@ class MassReconcileBase(models.AbstractModel): def _below_writeoff_limit(self, lines, writeoff_limit): self.ensure_one() precision = self.env["decimal.precision"].precision_get("Account") - keys = ("debit", "credit") - sums = reduce( - lambda line, memo: { - key: value + memo[key] for key, value in line.items() if key in keys - }, - lines, + + writeoff_amount = round( + sum([line["amount_residual"] for line in lines]), precision + ) + writeoff_amount_curr = round( + sum([line["amount_residual_currency"] for line in lines]), precision + ) + + first_currency = lines[0]["currency_id"] + if all([line["currency_id"] == first_currency for line in lines]): + ref_amount = writeoff_amount_curr + same_curr = True + # TODO if currency != company currency compute writeoff_limit in currency + else: + ref_amount = writeoff_amount + same_curr = False + + return ( + bool(writeoff_limit >= abs(ref_amount)), + writeoff_amount, + writeoff_amount_curr, + same_curr, ) - debit, credit = sums["debit"], sums["credit"] - writeoff_amount = round(debit - credit, precision) - return bool(writeoff_limit >= abs(writeoff_amount)), debit, credit def _get_rec_date(self, lines, based_on="end_period_last_credit"): self.ensure_one() @@ -113,10 +129,10 @@ class MassReconcileBase(models.AbstractModel): return max(mlines, key=itemgetter("date")) def credit(mlines): - return [l for l in mlines if l["credit"] > 0] + return [line for line in mlines if line["credit"] > 0] def debit(mlines): - return [l for l in mlines if l["debit"] > 0] + return [line for line in mlines if line["debit"] > 0] if based_on == "newest": return last_date(lines)["date"] @@ -128,11 +144,51 @@ class MassReconcileBase(models.AbstractModel): # when date is None return None + def create_write_off(self, lines, amount, amount_curr, same_curr): + self.ensure_one() + if amount < 0: + account = self.account_profit_id + else: + account = self.account_lost_id + currency = same_curr and lines[0].currency_id or lines[0].company_id.currency_id + journal = self.journal_id + partners = lines.mapped("partner_id") + write_off_vals = { + "name": _("Automatic writeoff"), + "amount_currency": same_curr and amount_curr or amount, + "debit": amount > 0.0 and amount or 0.0, + "credit": amount < 0.0 and -amount or 0.0, + "partner_id": len(partners) == 1 and partners.id or False, + "account_id": account.id, + "journal_id": journal.id, + "currency_id": currency.id, + } + counterpart_account = lines.mapped("account_id") + counter_part = write_off_vals.copy() + counter_part["debit"] = write_off_vals["credit"] + counter_part["credit"] = write_off_vals["debit"] + counter_part["amount_currency"] = -write_off_vals["amount_currency"] + counter_part["account_id"] = (counterpart_account.id,) + + move = self.env["account.move"].create( + { + "date": lines.env.context.get("date_p"), + "journal_id": journal.id, + "currency_id": currency.id, + "line_ids": [(0, 0, write_off_vals), (0, 0, counter_part)], + } + ) + move.action_post() + return move.line_ids.filtered( + lambda l: l.account_id.id == counterpart_account.id + ) + def _reconcile_lines(self, lines, allow_partial=False): """Try to reconcile given lines :param list lines: list of dict of move lines, they must at least - contain values for : id, debit, credit + contain values for : id, debit, credit, amount_residual and + amount_residual_currency :param boolean allow_partial: if True, partial reconciliation will be created, otherwise only Full reconciliation will be created @@ -143,36 +199,26 @@ class MassReconcileBase(models.AbstractModel): """ self.ensure_one() ml_obj = self.env["account.move.line"] - below_writeoff, sum_debit, sum_credit = self._below_writeoff_limit( - lines, self.write_off - ) + ( + below_writeoff, + amount_writeoff, + amount_writeoff_curr, + same_curr, + ) = self._below_writeoff_limit(lines, self.write_off) rec_date = self._get_rec_date(lines, self.date_base_on) - line_rs = ml_obj.browse([l["id"] for l in lines]).with_context( + line_rs = ml_obj.browse([line["id"] for line in lines]).with_context( date_p=rec_date, comment=_("Automatic Write Off") ) if below_writeoff: - if sum_credit > sum_debit: - writeoff_account = self.account_profit_id - else: - writeoff_account = self.account_lost_id - line_rs.reconcile( - writeoff_acc_id=writeoff_account, writeoff_journal_id=self.journal_id - ) + balance = amount_writeoff_curr if same_curr else amount_writeoff + if abs(balance) != 0.0: + writeoff_line = self.create_write_off( + line_rs, amount_writeoff, amount_writeoff_curr, same_curr + ) + line_rs |= writeoff_line + line_rs.reconcile() return True, True elif allow_partial: - # We need to give a writeoff_acc_id - # in case we have a multi currency lines - # to reconcile. - # If amount in currency is equal between - # lines to reconcile - # it will do a full reconcile instead of a partial reconcile - # and make a write-off for exchange - if sum_credit > sum_debit: - writeoff_account = self.income_exchange_account_id - else: - writeoff_account = self.expense_exchange_account_id - line_rs.reconcile( - writeoff_acc_id=writeoff_account, writeoff_journal_id=self.journal_id - ) + line_rs.reconcile() return True, False return False, False diff --git a/account_mass_reconcile/models/mass_reconcile.py b/account_mass_reconcile/models/mass_reconcile.py index a5ec4606..519bcb5e 100644 --- a/account_mass_reconcile/models/mass_reconcile.py +++ b/account_mass_reconcile/models/mass_reconcile.py @@ -5,9 +5,10 @@ import logging from datetime import datetime +import psycopg2 from psycopg2.extensions import AsIs -from odoo import _, api, fields, models, sql_db +from odoo import _, api, exceptions, fields, models, sql_db from odoo.exceptions import Warning as UserError _logger = logging.getLogger(__name__) @@ -40,12 +41,6 @@ class MassReconcileOptions(models.AbstractModel): default="newest", ) _filter = fields.Char(string="Filter") - income_exchange_account_id = fields.Many2one( - "account.account", string="Gain Exchange Rate Account" - ) - expense_exchange_account_id = fields.Many2one( - "account.account", string="Loss Exchange Rate Account" - ) class AccountMassReconcileMethod(models.Model): @@ -61,6 +56,7 @@ class AccountMassReconcileMethod(models.Model): ("mass.reconcile.simple.partner", "Simple. Amount and Partner"), ("mass.reconcile.simple.reference", "Simple. Amount and Reference"), ("mass.reconcile.advanced.ref", "Advanced. Partner and Ref."), + ("mass.reconcile.advanced.name", "Advanced. Partner and Name."), ] def _selection_name(self): @@ -136,13 +132,16 @@ class AccountMassReconcile(models.Model): "write_off": rec_method.write_off, "account_lost_id": (rec_method.account_lost_id.id), "account_profit_id": (rec_method.account_profit_id.id), - "income_exchange_account_id": (rec_method.income_exchange_account_id.id), - "expense_exchange_account_id": (rec_method.income_exchange_account_id.id), "journal_id": (rec_method.journal_id.id), "date_base_on": rec_method.date_base_on, "_filter": rec_method._filter, } + def _run_reconcile_method(self, reconcile_method): + rec_model = self.env[reconcile_method.name] + auto_rec_id = rec_model.create(self._prepare_run_transient(reconcile_method)) + return auto_rec_id.automatic_reconcile() + def run_reconcile(self): def find_reconcile_ids(fieldname, move_line_ids): if not move_line_ids: @@ -163,21 +162,36 @@ class AccountMassReconcile(models.Model): # does not. for rec in self: + # SELECT FOR UPDATE the mass reconcile row ; this is done in order + # to avoid 2 processes on the same mass reconcile method. + try: + self.env.cr.execute( + "SELECT id FROM account_mass_reconcile" + " WHERE id = %s" + " FOR UPDATE NOWAIT", + (rec.id,), + ) + except psycopg2.OperationalError: + raise exceptions.UserError( + _( + "A mass reconcile is already ongoing for this account, " + "please try again later." + ) + ) ctx = self.env.context.copy() ctx["commit_every"] = rec.account.company_id.reconciliation_commit_every if ctx["commit_every"]: new_cr = sql_db.db_connect(self.env.cr.dbname).cursor() + new_env = api.Environment(new_cr, self.env.uid, ctx) else: new_cr = self.env.cr + new_env = self.env try: all_ml_rec_ids = [] for method in rec.reconcile_method: - rec_model = self.env[method.name] - auto_rec_id = rec_model.create(self._prepare_run_transient(method)) - - ml_rec_ids = auto_rec_id.automatic_reconcile() + ml_rec_ids = self.with_env(new_env)._run_reconcile_method(method) all_ml_rec_ids += ml_rec_ids diff --git a/account_mass_reconcile/models/simple_reconciliation.py b/account_mass_reconcile/models/simple_reconciliation.py index f55c4801..7ce6ff1d 100644 --- a/account_mass_reconcile/models/simple_reconciliation.py +++ b/account_mass_reconcile/models/simple_reconciliation.py @@ -2,8 +2,12 @@ # Copyright 2010 Sébastien Beau # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import logging + from odoo import models +_logger = logging.getLogger(__name__) + class MassReconcileSimple(models.AbstractModel): _name = "mass.reconcile.simple" @@ -40,6 +44,15 @@ class MassReconcileSimple(models.AbstractModel): if reconciled: res += [credit_line["id"], debit_line["id"]] del lines[i] + if ( + self.env.context.get("commit_every", 0) + and len(res) % self.env.context["commit_every"] == 0 + ): + # new cursor is already open in cron + self.env.cr.commit() # pylint: disable=invalid-commit + _logger.info( + "Commit the reconciliations after %d groups", len(res) + ) break count += 1 return res @@ -58,7 +71,7 @@ class MassReconcileSimple(models.AbstractModel): query = " ".join( (select, self._from_query(), where, where2, self._simple_order()) ) - + self.flush() self.env.cr.execute(query, params + params2) lines = self.env.cr.dictfetchall() return self.rec_auto_lines_simple(lines) diff --git a/account_mass_reconcile/security/ir.model.access.csv b/account_mass_reconcile/security/ir.model.access.csv index dc2e3435..786158c8 100644 --- a/account_mass_reconcile/security/ir.model.access.csv +++ b/account_mass_reconcile/security/ir.model.access.csv @@ -10,3 +10,5 @@ access_mass_reconcile_history_acc_mgr,mass.reconcile.history,model_mass_reconcil access_mass_reconcile_simple_name,mass.reconcile.simple.name,model_mass_reconcile_simple_name,account.group_account_user,1,1,1,1 access_mass_reconcile_simple_partner,mass.reconcile.simple.partner,model_mass_reconcile_simple_partner,account.group_account_user,1,1,1,1 access_mass_reconcile_simple_reference,mass.reconcile.simple.reference,model_mass_reconcile_simple_reference,account.group_account_user,1,1,1,1 +access_mass_reconcile_advanced_ref_acc_user,mass.reconcile.advanced.ref,model_mass_reconcile_advanced_ref,account.group_account_user,1,1,1,1 +access_mass_reconcile_advanced_name_acc_user,mass.reconcile.advanced.name,model_mass_reconcile_advanced_name,account.group_account_user,1,1,1,1 diff --git a/account_mass_reconcile/tests/__init__.py b/account_mass_reconcile/tests/__init__.py index fe1df9fe..89f15015 100644 --- a/account_mass_reconcile/tests/__init__.py +++ b/account_mass_reconcile/tests/__init__.py @@ -2,6 +2,5 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). from . import test_onchange_company -from . import test_reconcile_history from . import test_reconcile from . import test_scenario_reconcile diff --git a/account_mass_reconcile/tests/test_onchange_company.py b/account_mass_reconcile/tests/test_onchange_company.py index 9db6eef4..1730bb86 100644 --- a/account_mass_reconcile/tests/test_onchange_company.py +++ b/account_mass_reconcile/tests/test_onchange_company.py @@ -1,8 +1,6 @@ # © 2014-2016 Camptocamp SA (Damien Crier) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -from odoo import tools -from odoo.modules import get_module_resource from odoo.tests import common @@ -10,15 +8,6 @@ class TestOnChange(common.SavepointCase): @classmethod def setUpClass(cls): super(TestOnChange, cls).setUpClass() - tools.convert_file( - cls.cr, - "account", - get_module_resource("account", "test", "account_minimal_test.xml"), - {}, - "init", - False, - "test", - ) acc_setting = cls.env["res.config.settings"] cls.acc_setting_obj = acc_setting.create({}) cls.company_obj = cls.env["res.company"] diff --git a/account_mass_reconcile/tests/test_reconcile.py b/account_mass_reconcile/tests/test_reconcile.py index 06901e8d..3c176e12 100644 --- a/account_mass_reconcile/tests/test_reconcile.py +++ b/account_mass_reconcile/tests/test_reconcile.py @@ -1,29 +1,24 @@ # © 2014-2016 Camptocamp SA (Damien Crier) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -from odoo import exceptions, fields, tools -from odoo.modules import get_module_resource -from odoo.tests import common +import odoo.tests +from odoo import exceptions, fields + +from odoo.addons.account.tests.common import TestAccountReconciliationCommon -class TestReconcile(common.SavepointCase): +@odoo.tests.tagged("post_install", "-at_install") +class TestReconcile(TestAccountReconciliationCommon): @classmethod def setUpClass(cls): super(TestReconcile, cls).setUpClass() - tools.convert_file( - cls.cr, - "account", - get_module_resource("account", "test", "account_minimal_test.xml"), - {}, - "init", - False, - "test", - ) cls.rec_history_obj = cls.env["mass.reconcile.history"] cls.mass_rec_obj = cls.env["account.mass.reconcile"] cls.mass_rec_method_obj = cls.env["account.mass.reconcile.method"] + + cls.sale_journal = cls.company_data["default_journal_sale"] cls.mass_rec = cls.mass_rec_obj.create( - {"name": "AER2", "account": cls.env.ref("account.a_salary_expense").id} + {"name": "Sale Account", "account": cls.sale_journal.default_account_id.id} ) cls.mass_rec_method = cls.mass_rec_method_obj.create( { @@ -33,7 +28,7 @@ class TestReconcile(common.SavepointCase): } ) cls.mass_rec_no_history = cls.mass_rec_obj.create( - {"name": "AER3", "account": cls.env.ref("account.a_salary_expense").id} + {"name": "AER3", "account": cls.sale_journal.default_account_id.id} ) cls.rec_history = cls.rec_history_obj.create( {"mass_reconcile_id": cls.mass_rec.id, "date": fields.Datetime.now()} @@ -57,4 +52,14 @@ class TestReconcile(common.SavepointCase): def test_prepare_run_transient(self): res = self.mass_rec._prepare_run_transient(self.mass_rec_method) - self.assertEqual(self.ref("account.a_salary_expense"), res.get("account_id", 0)) + self.assertEqual( + self.sale_journal.default_account_id.id, res.get("account_id", 0) + ) + + def test_open_full_empty(self): + res = self.rec_history._open_move_lines() + self.assertEqual([("id", "in", [])], res.get("domain", [])) + + def test_open_full_empty_from_method(self): + res = self.rec_history.open_reconcile() + self.assertEqual([("id", "in", [])], res.get("domain", [])) diff --git a/account_mass_reconcile/tests/test_reconcile_history.py b/account_mass_reconcile/tests/test_reconcile_history.py deleted file mode 100644 index 89ea2647..00000000 --- a/account_mass_reconcile/tests/test_reconcile_history.py +++ /dev/null @@ -1,37 +0,0 @@ -# © 2014-2016 Camptocamp SA (Damien Crier) -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - -from odoo import fields, tools -from odoo.modules import get_module_resource -from odoo.tests import common - - -class TestReconcileHistory(common.SavepointCase): - @classmethod - def setUpClass(cls): - super(TestReconcileHistory, cls).setUpClass() - tools.convert_file( - cls.cr, - "account", - get_module_resource("account", "test", "account_minimal_test.xml"), - {}, - "init", - False, - "test", - ) - cls.rec_history_obj = cls.env["mass.reconcile.history"] - cls.mass_rec_obj = cls.env["account.mass.reconcile"] - cls.mass_rec = cls.mass_rec_obj.create( - {"name": "AER1", "account": cls.env.ref("account.a_expense").id} - ) - cls.rec_history = cls.rec_history_obj.create( - {"mass_reconcile_id": cls.mass_rec.id, "date": fields.Datetime.now()} - ) - - def test_open_full_empty(self): - res = self.rec_history._open_move_lines() - self.assertEqual([("id", "in", [])], res.get("domain", [])) - - def test_open_full_empty_from_method(self): - res = self.rec_history.open_reconcile() - self.assertEqual([("id", "in", [])], res.get("domain", [])) diff --git a/account_mass_reconcile/tests/test_scenario_reconcile.py b/account_mass_reconcile/tests/test_scenario_reconcile.py index 9ef536f5..09fba673 100644 --- a/account_mass_reconcile/tests/test_scenario_reconcile.py +++ b/account_mass_reconcile/tests/test_scenario_reconcile.py @@ -1,24 +1,19 @@ # © 2014-2016 Camptocamp SA (Damien Crier) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -from odoo import fields, tools -from odoo.modules import get_module_resource -from odoo.tests import common +from datetime import timedelta + +import odoo.tests +from odoo import fields + +from odoo.addons.account.tests.common import TestAccountReconciliationCommon -class TestScenarioReconcile(common.SavepointCase): +@odoo.tests.tagged("post_install", "-at_install") +class TestScenarioReconcile(TestAccountReconciliationCommon): @classmethod def setUpClass(cls): super(TestScenarioReconcile, cls).setUpClass() - tools.convert_file( - cls.cr, - "account", - get_module_resource("account", "test", "account_minimal_test.xml"), - {}, - "init", - False, - "test", - ) cls.rec_history_obj = cls.env["mass.reconcile.history"] cls.mass_rec_obj = cls.env["account.mass.reconcile"] cls.invoice_obj = cls.env["account.move"] @@ -26,13 +21,12 @@ class TestScenarioReconcile(common.SavepointCase): cls.bk_stmt_line_obj = cls.env["account.bank.statement.line"] cls.acc_move_line_obj = cls.env["account.move.line"] cls.mass_rec_method_obj = cls.env["account.mass.reconcile.method"] - cls.account_fx_income_id = cls.env.ref("account.income_fx_income").id - cls.account_fx_expense_id = cls.env.ref("account.income_fx_expense").id cls.acs_model = cls.env["res.config.settings"] - acs_ids = cls.acs_model.search( - [("company_id", "=", cls.env.ref("base.main_company").id)] - ) + cls.company = cls.company_data["company"] + cls.bank_journal = cls.company_data["default_journal_bank"] + cls.sale_journal = cls.company_data["default_journal_sale"] + acs_ids = cls.acs_model.search([("company_id", "=", cls.company.id)]) values = {"group_multi_currency": True} @@ -44,185 +38,193 @@ class TestScenarioReconcile(common.SavepointCase): acs_ids = cls.acs_model.create(default_vals) def test_scenario_reconcile(self): - # create invoice - invoice = self.invoice_obj.with_context(default_type="out_invoice").create( - { - "type": "out_invoice", - "company_id": self.ref("base.main_company"), - "journal_id": self.ref("account.sales_journal"), - "partner_id": self.ref("base.res_partner_12"), - "invoice_line_ids": [ - ( - 0, - 0, - { - "name": "[FURN_7800] Desk Combination", - "account_id": self.ref("account.a_sale"), - "price_unit": 1000.0, - "quantity": 1.0, - "product_id": self.ref("product.product_product_3"), - }, - ) - ], - } - ) - # validate invoice - invoice.post() + invoice = self.create_invoice() self.assertEqual("posted", invoice.state) - # create bank_statement - statement = self.bk_stmt_obj.create( + receivalble_account_id = invoice.partner_id.property_account_receivable_id.id + # create payment + payment = self.env["account.payment"].create( { - "balance_end_real": 0.0, - "balance_start": 0.0, - "date": fields.Date.today(), - "journal_id": self.ref("account.bank_journal"), - "line_ids": [ - ( - 0, - 0, - { - "amount": 1000.0, - "partner_id": self.ref("base.res_partner_12"), - "name": invoice.name, - "ref": invoice.name, - }, - ) - ], + "partner_type": "customer", + "payment_type": "inbound", + "partner_id": invoice.partner_id.id, + "destination_account_id": receivalble_account_id, + "amount": 50.0, + "journal_id": self.bank_journal.id, } ) - - # reconcile - line_id = None - for l in invoice.line_ids: - if l.account_id.internal_type == "receivable": - line_id = l - break - - for statement_line in statement.line_ids: - statement_line.process_reconciliation( - [ - { - "move_line": line_id, - "credit": 1000.0, - "debit": 0.0, - "name": invoice.name, - } - ] - ) - - # unreconcile journal item created by previous reconciliation - lines_to_unreconcile = self.acc_move_line_obj.search( - [("reconciled", "=", True), ("statement_id", "=", statement.id)] - ) - lines_to_unreconcile.remove_move_reconcile() + payment.action_post() # create the mass reconcile record mass_rec = self.mass_rec_obj.create( { "name": "mass_reconcile_1", - "account": line_id.account_id.id, + "account": invoice.partner_id.property_account_receivable_id.id, "reconcile_method": [(0, 0, {"name": "mass.reconcile.simple.partner"})], } ) # call the automatic reconcilation method mass_rec.run_reconcile() - invoice.invalidate_cache() - self.assertEqual("paid", invoice.invoice_payment_state) + self.assertEqual("paid", invoice.payment_state) def test_scenario_reconcile_currency(self): # create currency rate self.env["res.currency.rate"].create( { - "name": fields.Date.today().strftime("%Y-%m-%d") + " 00:00:00", + "name": fields.Date.today(), "currency_id": self.ref("base.USD"), - "rate": 1.5, + "rate": 1.25, } ) # create invoice - invoice = self.invoice_obj.with_context(default_type="out_invoice").create( - { - "type": "out_invoice", - "company_id": self.ref("base.main_company"), - "currency_id": self.ref("base.USD"), - "journal_id": self.ref("account.sales_journal"), - "partner_id": self.ref("base.res_partner_12"), - "invoice_line_ids": [ - ( - 0, - 0, - { - "name": "[FURN_7800] Desk Combination", - "account_id": self.ref("account.a_sale"), - "price_unit": 1000.0, - "quantity": 1.0, - "product_id": self.ref("product.product_product_3"), - }, - ) - ], - } + invoice = self._create_invoice( + currency_id=self.ref("base.USD"), + date_invoice=fields.Date.today(), + auto_validate=True, ) - # validate invoice - invoice.post() self.assertEqual("posted", invoice.state) - # create bank_statement - statement = self.bk_stmt_obj.create( + self.env["res.currency.rate"].create( { - "balance_end_real": 0.0, - "balance_start": 0.0, - "date": fields.Date.today(), - "journal_id": self.ref("account.bank_journal_usd"), + "name": fields.Date.today() - timedelta(days=3), "currency_id": self.ref("base.USD"), - "line_ids": [ - ( - 0, - 0, - { - "amount": 1000.0, - "amount_currency": 1500.0, - "partner_id": self.ref("base.res_partner_12"), - "name": invoice.name, - "ref": invoice.name, - }, - ) - ], + "rate": 2, } ) - - # reconcile - line_id = None - for l in invoice.line_ids: - if l.account_id.internal_type == "receivable": - line_id = l - break - - for statement_line in statement.line_ids: - statement_line.process_reconciliation( - [ - { - "move_line": line_id, - "credit": 1000.0, - "debit": 0.0, - "name": invoice.name, - } - ] - ) - # unreconcile journal item created by previous reconciliation - lines_to_unreconcile = self.acc_move_line_obj.search( - [("reconciled", "=", True), ("statement_id", "=", statement.id)] + receivable_account_id = invoice.partner_id.property_account_receivable_id.id + # create payment + payment = self.env["account.payment"].create( + { + "partner_type": "customer", + "payment_type": "inbound", + "partner_id": invoice.partner_id.id, + "destination_account_id": receivable_account_id, + "amount": 50.0, + "currency_id": self.ref("base.USD"), + "journal_id": self.bank_journal.id, + "date": fields.Date.today() - timedelta(days=2), + } ) - lines_to_unreconcile.remove_move_reconcile() + payment.action_post() # create the mass reconcile record mass_rec = self.mass_rec_obj.create( { "name": "mass_reconcile_1", - "account": line_id.account_id.id, + "account": invoice.partner_id.property_account_receivable_id.id, "reconcile_method": [(0, 0, {"name": "mass.reconcile.simple.partner"})], } ) # call the automatic reconcilation method mass_rec.run_reconcile() - invoice.invalidate_cache() - self.assertEqual("paid", invoice.invoice_payment_state) + self.assertEqual("paid", invoice.payment_state) + + def test_scenario_reconcile_partial(self): + invoice1 = self.create_invoice() + invoice1.ref = "test ref" + # create payment + receivable_account_id = invoice1.partner_id.property_account_receivable_id.id + payment = self.env["account.payment"].create( + { + "partner_type": "customer", + "payment_type": "inbound", + "partner_id": invoice1.partner_id.id, + "destination_account_id": receivable_account_id, + "amount": 500.0, + "journal_id": self.bank_journal.id, + "ref": "test ref", + } + ) + payment.action_post() + line_payment = payment.line_ids.filtered( + lambda l: l.account_id.id == receivable_account_id + ) + self.assertEqual(line_payment.reconciled, False) + invoice1_line = invoice1.line_ids.filtered( + lambda l: l.account_id.id == receivable_account_id + ) + self.assertEqual(invoice1_line.reconciled, False) + + # Create the mass reconcile record + reconcile_method_vals = { + "name": "mass.reconcile.advanced.ref", + "write_off": 0.1, + } + mass_rec = self.mass_rec_obj.create( + { + "name": "mass_reconcile_1", + "account": receivable_account_id, + "reconcile_method": [(0, 0, reconcile_method_vals)], + } + ) + mass_rec.run_reconcile() + + self.assertEqual(line_payment.amount_residual, -450.0) + self.assertEqual(invoice1_line.reconciled, True) + invoice2 = self._create_invoice(invoice_amount=500, auto_validate=True) + invoice2.ref = "test ref" + invoice2_line = invoice2.line_ids.filtered( + lambda l: l.account_id.id == receivable_account_id + ) + mass_rec.run_reconcile() + self.assertEqual(line_payment.reconciled, True) + self.assertEqual(invoice2_line.reconciled, False) + + self.assertEqual(invoice2_line.amount_residual, 50.0) + + def test_reconcile_with_writeoff(self): + invoice = self.create_invoice() + + receivable_account_id = invoice.partner_id.property_account_receivable_id.id + # create payment + payment = self.env["account.payment"].create( + { + "partner_type": "customer", + "payment_type": "inbound", + "partner_id": invoice.partner_id.id, + "destination_account_id": receivable_account_id, + "amount": 50.1, + "journal_id": self.bank_journal.id, + } + ) + payment.action_post() + + # create the mass reconcile record + mass_rec = self.mass_rec_obj.create( + { + "name": "mass_reconcile_1", + "account": invoice.partner_id.property_account_receivable_id.id, + "reconcile_method": [ + ( + 0, + 0, + { + "name": "mass.reconcile.simple.partner", + "account_lost_id": self.company_data[ + "default_account_expense" + ].id, + "account_profit_id": self.company_data[ + "default_account_revenue" + ].id, + "journal_id": self.company_data["default_journal_misc"].id, + "write_off": 0.05, + }, + ) + ], + } + ) + # call the automatic reconcilation method + mass_rec.run_reconcile() + self.assertEqual("not_paid", invoice.payment_state) + mass_rec.reconcile_method.write_off = 0.11 + mass_rec.run_reconcile() + self.assertEqual("paid", invoice.payment_state) + full_reconcile = invoice.line_ids.mapped("full_reconcile_id") + writeoff_line = full_reconcile.reconciled_line_ids.filtered( + lambda l: l.debit == 0.1 + ) + self.assertEqual(len(writeoff_line), 1) + self.assertEqual( + writeoff_line.move_id.journal_id.id, + self.company_data["default_journal_misc"].id, + ) diff --git a/account_mass_reconcile/views/mass_reconcile.xml b/account_mass_reconcile/views/mass_reconcile.xml index ce59569a..7a39e161 100644 --- a/account_mass_reconcile/views/mass_reconcile.xml +++ b/account_mass_reconcile/views/mass_reconcile.xml @@ -173,14 +173,6 @@ The lines should have the same partner, and the credit entry ref. is matched wit name="account_profit_id" attrs="{'required':[('write_off','>',0)]}" /> - -