diff --git a/account_move_line_reconcile_manual/README.rst b/account_move_line_reconcile_manual/README.rst new file mode 100644 index 00000000..262ac821 --- /dev/null +++ b/account_move_line_reconcile_manual/README.rst @@ -0,0 +1 @@ +Will be auto-generated from readme subdir diff --git a/account_move_line_reconcile_manual/__init__.py b/account_move_line_reconcile_manual/__init__.py new file mode 100644 index 00000000..5cb1c491 --- /dev/null +++ b/account_move_line_reconcile_manual/__init__.py @@ -0,0 +1 @@ +from . import wizards diff --git a/account_move_line_reconcile_manual/__manifest__.py b/account_move_line_reconcile_manual/__manifest__.py new file mode 100644 index 00000000..16550a8f --- /dev/null +++ b/account_move_line_reconcile_manual/__manifest__.py @@ -0,0 +1,21 @@ +# Copyright 2023 Akretion France (http://www.akretion.com/) +# @author: Alexis de Lattre +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Account Move Line Reconcile Manual", + "version": "16.0.1.0.0", + "category": "Accounting", + "license": "AGPL-3", + "summary": "Manually reconcile Journal Items", + "author": "Akretion,Odoo Community Association (OCA)", + "maintainers": ["alexis-via"], + "website": "https://github.com/OCA/account-reconcile", + "depends": ["account"], + "data": [ + "security/ir.model.access.csv", + "wizards/account_move_line_reconcile_manual_view.xml", + "views/account_move_line.xml", + ], + "installable": True, +} diff --git a/account_move_line_reconcile_manual/readme/CONTRIBUTORS.rst b/account_move_line_reconcile_manual/readme/CONTRIBUTORS.rst new file mode 100644 index 00000000..ff65d68c --- /dev/null +++ b/account_move_line_reconcile_manual/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Alexis de Lattre diff --git a/account_move_line_reconcile_manual/readme/DESCRIPTION.rst b/account_move_line_reconcile_manual/readme/DESCRIPTION.rst new file mode 100644 index 00000000..b043884f --- /dev/null +++ b/account_move_line_reconcile_manual/readme/DESCRIPTION.rst @@ -0,0 +1,15 @@ +This module adds a wizard to reconcile manually selected journal items. If the selected journal items are balanced, it will propose a full reconcile. Otherwise, the user will have to choose between partial reconciliation and full reconciliation with a write-off. + +For the old-time Odoo users, the feature provided by this module is similar to the wizard that was provided in the **account** module up to Odoo 11.0. It was later replaced by the special reconciliation JS interface, which was working well, but was not as fast and convenient. + +Full reconciliation: +.. figure:: ../static/description/sshot_full_rec.png + :alt: Full reconciliation + +Choose between partial reconciliation and full reconciliation with a write-off: +.. figure:: ../static/description/sshot_partial_rec.png + :alt: Choose between partial reconciliation and full reconciliation with a write-off + +Reconcile with write-off: +.. figure:: ../static/description/sshot_rec_writeoff.png + :alt: Reconcile with write-off diff --git a/account_move_line_reconcile_manual/security/ir.model.access.csv b/account_move_line_reconcile_manual/security/ir.model.access.csv new file mode 100644 index 00000000..8f52a05f --- /dev/null +++ b/account_move_line_reconcile_manual/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_move_line_reconcile_manual,Full access on account.move.line.reconcile.manual wizard,model_account_move_line_reconcile_manual,account.group_account_user,1,1,1,1 diff --git a/account_move_line_reconcile_manual/static/description/sshot_full_rec.png b/account_move_line_reconcile_manual/static/description/sshot_full_rec.png new file mode 100644 index 00000000..d39e00ca Binary files /dev/null and b/account_move_line_reconcile_manual/static/description/sshot_full_rec.png differ diff --git a/account_move_line_reconcile_manual/static/description/sshot_partial_rec.png b/account_move_line_reconcile_manual/static/description/sshot_partial_rec.png new file mode 100644 index 00000000..4f563da6 Binary files /dev/null and b/account_move_line_reconcile_manual/static/description/sshot_partial_rec.png differ diff --git a/account_move_line_reconcile_manual/static/description/sshot_rec_writeoff.png b/account_move_line_reconcile_manual/static/description/sshot_rec_writeoff.png new file mode 100644 index 00000000..6ca9ddfd Binary files /dev/null and b/account_move_line_reconcile_manual/static/description/sshot_rec_writeoff.png differ diff --git a/account_move_line_reconcile_manual/views/account_move_line.xml b/account_move_line_reconcile_manual/views/account_move_line.xml new file mode 100644 index 00000000..5fbaddb0 --- /dev/null +++ b/account_move_line_reconcile_manual/views/account_move_line.xml @@ -0,0 +1,26 @@ + + + + + + account.move.line + + + +
+
+
+
+
+ +
diff --git a/account_move_line_reconcile_manual/wizards/__init__.py b/account_move_line_reconcile_manual/wizards/__init__.py new file mode 100644 index 00000000..1ce47f66 --- /dev/null +++ b/account_move_line_reconcile_manual/wizards/__init__.py @@ -0,0 +1 @@ +from . import account_move_line_reconcile_manual diff --git a/account_move_line_reconcile_manual/wizards/account_move_line_reconcile_manual.py b/account_move_line_reconcile_manual/wizards/account_move_line_reconcile_manual.py new file mode 100644 index 00000000..77e5f54a --- /dev/null +++ b/account_move_line_reconcile_manual/wizards/account_move_line_reconcile_manual.py @@ -0,0 +1,282 @@ +# Copyright 2023 Akretion France (http://www.akretion.com/) +# @author: Alexis de Lattre +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + +logger = logging.getLogger(__name__) + + +class AccountMoveLineReconcileManual(models.TransientModel): + _name = "account.move.line.reconcile.manual" + _description = "Manual Reconciliation Wizard" + _check_company_auto = True + _inherit = "analytic.mixin" + + account_id = fields.Many2one( + "account.account", required=True, readonly=True, check_company=True + ) + company_id = fields.Many2one("res.company", required=True, readonly=True) + company_currency_id = fields.Many2one(related="company_id.currency_id") + count = fields.Integer(string="# of Journal Items", readonly=True) + total_debit = fields.Monetary(currency_field="company_currency_id", readonly=True) + total_credit = fields.Monetary(currency_field="company_currency_id", readonly=True) + move_line_ids = fields.Many2many( + "account.move.line", readonly=True, check_company=True + ) + partner_count = fields.Integer(readonly=True) + partner_id = fields.Many2one("res.partner", readonly=True) + state = fields.Selection( + [ + ("start", "Start"), + ("writeoff", "Write-off"), + ], + readonly=True, + default="start", + ) + # START WRITE-OFF FIELDS + writeoff_journal_id = fields.Many2one( + "account.journal", + string="Journal", + domain="[('type', '=', 'general'), ('company_id', '=', company_id)]", + check_company=True, + ) + writeoff_date = fields.Date(string="Date", default=fields.Date.context_today) + writeoff_ref = fields.Char(string="Reference", default=lambda self: _("Write-off")) + writeoff_type = fields.Selection( + [ + ("income", "Income"), + ("expense", "Expense"), + ("none", "None"), + ], + readonly=True, + string="Type", + ) + writeoff_amount = fields.Monetary( + currency_field="company_currency_id", readonly=True, string="Amount" + ) + writeoff_account_id = fields.Many2one( + "account.account", + string="Write-off Account", + domain="[('company_id', '=', company_id), ('deprecated', '=', False)]", + check_company=True, + ) + + @api.model + def default_get(self, fields_list): + res = super().default_get(fields_list) + if self._context.get("active_model") == self._name: # write-off step + return res + assert self._context.get("active_model") == "account.move.line" + move_lines = self.env["account.move.line"].browse( + self._context.get("active_ids") + ) + company = move_lines[0].account_id.company_id + ccur = company.currency_id + count = 0 + account = False + total_debit = total_credit = 0.0 + partner_set = set() + for line in move_lines: + count += 1 + total_debit += line.debit + total_credit += line.credit + if line.full_reconcile_id: + raise UserError( + _("Line '%s' is already fully reconciled.") % line.display_name + ) + if account: + if account != line.account_id: + raise UserError( + _( + "The Journal Items selected have different accounts: " + "%(account1)s and %(account2)s.", + account1=account.code, + account2=line.account_id.code, + ) + ) + else: + account = line.account_id + if line.partner_id: + partner_set.add(line.partner_id.id) + # if lines have the same account, they are in the same company + if not account.reconcile: + raise UserError( + _("Account '%s' is not reconciliable.") % account.display_name + ) + if count <= 1: + raise UserError(_("You must select at least 2 journal items!")) + if ccur.is_zero(total_debit): + raise UserError(_("You selected only credit journal items.")) + if ccur.is_zero(total_credit): + raise UserError(_("You selected only debit journal items.")) + writeoff_amount = ccur.round(abs(total_debit - total_credit)) + total_debit = ccur.round(total_debit) + total_credit = ccur.round(total_credit) + compare_res = ccur.compare_amounts(total_debit, total_credit) + if compare_res > 0: + writeoff_type = "expense" + elif compare_res < 0: + writeoff_type = "income" + else: + writeoff_type = "none" + general_journals = self.env["account.journal"].search( + [("type", "=", "general"), ("company_id", "=", company.id)] + ) + writeoff_journal_id = False + if len(general_journals) == 1: + writeoff_journal_id = general_journals.id + res.update( + { + "count": count, + "account_id": account.id, + "company_id": account.company_id.id, + "total_debit": total_debit, + "total_credit": total_credit, + "partner_count": len(partner_set), + "partner_id": len(partner_set) == 1 and partner_set.pop() or False, + "move_line_ids": move_lines.ids, + "writeoff_type": writeoff_type, + "writeoff_amount": writeoff_amount, + "writeoff_journal_id": writeoff_journal_id, + } + ) + return res + + def full_reconcile(self): + self.ensure_one() + self.move_line_ids.remove_move_reconcile() + res = self.move_line_ids.reconcile() + if not res.get("full_reconcile"): + raise UserError(_("Full reconciliation failed. It should never happen!")) + action = { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Successful reconciliation"), + "message": _("Reconcile mark: %s") % res["full_reconcile"].display_name, + "next": {"type": "ir.actions.act_window_close"}, + }, + } + return action + + def partial_reconcile(self): + self.ensure_one() + self.move_line_ids.remove_move_reconcile() + self.move_line_ids.reconcile() + return + + def go_to_writeoff(self): + self.ensure_one() + self.write({"state": "writeoff"}) + action = self.env["ir.actions.actions"]._for_xml_id( + "account_move_line_reconcile_manual.account_move_line_reconcile_manual_action" + ) + action["res_id"] = self.id + return action + + def _prepare_writeoff_move(self): + ccur = self.company_currency_id + bal = ccur.round(self.total_debit - self.total_credit) + compare_res = ccur.compare_amounts(bal, 0) + assert compare_res + if compare_res > 0: + credit = bal + debit = 0 + else: + debit = bal * -1 + credit = 0 + vals = { + "company_id": self.company_id.id, + "journal_id": self.writeoff_journal_id.id, + "ref": self.writeoff_ref, + "date": self.writeoff_date, + "line_ids": [ + ( + 0, + 0, + { + "account_id": self.account_id.id, + "partner_id": self.partner_id and self.partner_id.id or False, + "debit": debit, + "credit": credit, + }, + ), + ( + 0, + 0, + { + "account_id": self.writeoff_account_id.id, + "partner_id": self.partner_id and self.partner_id.id or False, + "debit": credit, + "credit": debit, + "analytic_distribution": self.analytic_distribution, + }, + ), + ], + } + return vals + + def reconcile_with_writeoff(self): + self.ensure_one() + assert self.writeoff_journal_id + assert self.writeoff_date + assert self.writeoff_account_id + assert self.state == "writeoff" + self.move_line_ids.remove_move_reconcile() + vals = self._prepare_writeoff_move() + woff_move = self.env["account.move"].create(vals) + woff_move._post(soft=False) + to_rec_woff_line = woff_move.line_ids.filtered( + lambda x: x.account_id.id == self.account_id.id + ) + assert len(to_rec_woff_line) == 1 + to_rec_lines = self.move_line_ids + to_rec_woff_line + res = to_rec_lines.reconcile() + if not res.get("full_reconcile"): + raise UserError(_("Full reconciliation failed. It should never happen!")) + action = { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Successful reconciliation"), + "message": _( + "Write-off journal entry: %(writeoff_move)s\nReconcile mark: %(full_rec)s", + full_rec=res["full_reconcile"].display_name, + writeoff_move=woff_move.name, + ), + "next": {"type": "ir.actions.act_window_close"}, + }, + } + return action + + @api.onchange("writeoff_account_id") + def writeoff_account_id_change(self): + account = self.writeoff_account_id + if ( + self.writeoff_type in ("income", "expense") + and account + and self.writeoff_type not in account.account_type + ): + message = _( + "This is a/an '%(writeoff_type)s' write-off, " + "but you selected account %(account_code)s " + "which is a/an '%(account_type)s' account.", + writeoff_type=self._fields["writeoff_type"].convert_to_export( + self.writeoff_type, self + ), + account_code=account.code, + account_type=account._fields["account_type"].convert_to_export( + account.account_type, account + ), + ) + res = { + "warning": { + "title": _("Bad write-off account type"), + "message": message, + } + } + return res diff --git a/account_move_line_reconcile_manual/wizards/account_move_line_reconcile_manual_view.xml b/account_move_line_reconcile_manual/wizards/account_move_line_reconcile_manual_view.xml new file mode 100644 index 00000000..ebd8b73a --- /dev/null +++ b/account_move_line_reconcile_manual/wizards/account_move_line_reconcile_manual_view.xml @@ -0,0 +1,99 @@ + + + + + + account.move.line.reconcile.manual + +
+ + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+ + + Reconcile + account.move.line.reconcile.manual + form + new + + +
diff --git a/setup/account_move_line_reconcile_manual/odoo/addons/account_move_line_reconcile_manual b/setup/account_move_line_reconcile_manual/odoo/addons/account_move_line_reconcile_manual new file mode 120000 index 00000000..74cc94cf --- /dev/null +++ b/setup/account_move_line_reconcile_manual/odoo/addons/account_move_line_reconcile_manual @@ -0,0 +1 @@ +../../../../account_move_line_reconcile_manual \ No newline at end of file diff --git a/setup/account_move_line_reconcile_manual/setup.py b/setup/account_move_line_reconcile_manual/setup.py new file mode 100644 index 00000000..28c57bb6 --- /dev/null +++ b/setup/account_move_line_reconcile_manual/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)