Merge PR #532 into 16.0

Signed-off-by alexis-via
pull/500/head
OCA-git-bot 2023-03-21 21:18:27 +00:00
commit 4bfd45a904
17 changed files with 652 additions and 0 deletions

View File

@ -0,0 +1 @@
Will be auto-generated from readme subdir

View File

@ -0,0 +1 @@
from . import wizards

View File

@ -0,0 +1,21 @@
# Copyright 2023 Akretion France (http://www.akretion.com/)
# @author: Alexis de Lattre <alexis.delattre@akretion.com>
# 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,
}

View File

@ -0,0 +1 @@
* Alexis de Lattre <alexis.delattre@akretion.com>

View File

@ -0,0 +1,18 @@
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

View File

@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

View File

@ -0,0 +1 @@
from . import test_reconcile_manual

View File

@ -0,0 +1,172 @@
# Copyright 2023 Akretion France (http://www.akretion.com/)
# @author: Alexis de Lattre <alexis.delattre@akretion.com>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo.exceptions import UserError
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
@tagged("post_install", "-at_install")
class TestReconcileManual(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True))
cls.company = cls.env.ref("base.main_company")
cls.ccur = cls.company.currency_id
cls.rec_account = cls.env["account.account"].search(
[("company_id", "=", cls.company.id), ("reconcile", "=", True)], limit=1
)
cls.other_account = cls.env["account.account"].search(
[("company_id", "=", cls.company.id), ("reconcile", "=", False)], limit=1
)
cls.journal = cls.env["account.journal"].search(
[("company_id", "=", cls.company.id), ("type", "=", "general")], limit=1
)
cls.partner = cls.env["res.partner"].create(
{"name": "Odoo Community Association", "company_id": cls.company.id}
)
cls.move1 = cls.env["account.move"].create(
{
"journal_id": cls.journal.id,
"company_id": cls.company.id,
"line_ids": [
(
0,
0,
{
"account_id": cls.rec_account.id,
"partner_id": cls.partner.id,
"debit": 100,
},
),
(
0,
0,
{
"account_id": cls.other_account.id,
"partner_id": cls.partner.id,
"credit": 100,
},
),
],
}
)
cls.move1._post(soft=False)
cls.line1 = cls.move1.line_ids.filtered(
lambda x: x.account_id == cls.rec_account
)
cls.move2 = cls.env["account.move"].create(
{
"journal_id": cls.journal.id,
"company_id": cls.company.id,
"line_ids": [
(
0,
0,
{
"account_id": cls.rec_account.id,
"partner_id": cls.partner.id,
"credit": 95,
},
),
(
0,
0,
{
"account_id": cls.other_account.id,
"partner_id": cls.partner.id,
"debit": 95,
},
),
],
}
)
cls.move2._post(soft=False)
cls.line2 = cls.move2.line_ids.filtered(
lambda x: x.account_id == cls.rec_account
)
cls.writeoff_account = cls.env["account.account"].search(
[
("company_id", "=", cls.company.id),
("reconcile", "=", False),
("account_type", "=", "expense"),
],
limit=1,
)
cls.writeoff_ref = "OCApower"
def test_reconcile_manual(self):
# start with partial reconcile
lines_to_rec = self.line1 + self.line2
wiz1 = (
self.env["account.move.line.reconcile.manual"]
.with_context(active_model="account.move.line", active_ids=lines_to_rec.ids)
.create({})
)
self.assertEqual(wiz1.account_id, self.rec_account)
self.assertEqual(wiz1.company_id, self.company)
self.assertEqual(wiz1.count, 2)
self.assertEqual(wiz1.partner_count, 1)
self.assertEqual(wiz1.partner_id, self.partner)
self.assertFalse(self.ccur.compare_amounts(wiz1.total_debit, 100))
self.assertFalse(self.ccur.compare_amounts(wiz1.total_credit, 95))
self.assertEqual(wiz1.writeoff_type, "expense")
wiz1.partial_reconcile()
# reconcile with write-off
wiz2 = (
self.env["account.move.line.reconcile.manual"]
.with_context(active_model="account.move.line", active_ids=lines_to_rec.ids)
.create({})
)
self.assertEqual(wiz2.writeoff_type, "expense")
wiz2.go_to_writeoff()
self.assertEqual(wiz2.state, "writeoff")
self.assertFalse(self.ccur.compare_amounts(wiz2.writeoff_amount, 5))
wiz2.write(
{
"writeoff_journal_id": self.journal.id,
"writeoff_ref": self.writeoff_ref,
"writeoff_account_id": self.writeoff_account.id,
}
)
action2 = wiz2.reconcile_with_writeoff()
self.assertEqual(action2.get("type"), "ir.actions.client")
wo_move = self.env["account.move"].search(
[("company_id", "=", self.company.id)], order="id desc", limit=1
)
self.assertEqual(wo_move.ref, self.writeoff_ref)
self.assertEqual(wo_move.journal_id, self.journal)
self.assertEqual(wo_move.state, "posted")
self.assertEqual(wo_move.company_id, self.company)
wo_line = wo_move.line_ids.filtered(lambda x: x.account_id == self.rec_account)
full_rec2 = wo_line.full_reconcile_id
self.assertTrue(full_rec2)
self.assertEqual(self.line1.full_reconcile_id, full_rec2)
self.assertEqual(self.line2.full_reconcile_id, full_rec2)
# Cannot start wizard on lines fully reconciled!
lines_to_rec += wo_line
with self.assertRaises(UserError):
self.env["account.move.line.reconcile.manual"].with_context(
active_model="account.move.line", active_ids=lines_to_rec.ids
).create({})
# Full reconcile
lines_to_rec.remove_move_reconcile()
wiz4 = (
self.env["account.move.line.reconcile.manual"]
.with_context(active_model="account.move.line", active_ids=lines_to_rec.ids)
.create({})
)
self.assertEqual(wiz4.writeoff_type, "none")
self.assertFalse(self.ccur.compare_amounts(wiz4.total_debit, 100))
self.assertFalse(self.ccur.compare_amounts(wiz4.total_credit, 100))
action4 = wiz4.full_reconcile()
self.assertEqual(action4.get("type"), "ir.actions.client")
full_rec4 = wo_line.full_reconcile_id
self.assertTrue(full_rec4)
self.assertEqual(self.line1.full_reconcile_id, full_rec4)
self.assertEqual(self.line2.full_reconcile_id, full_rec4)

View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8" ?>
<!--
Copyright 2023 Akretion France (http://www.akretion.com/)
@author: Alexis de Lattre <alexis.delattre@akretion.com>
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
-->
<odoo>
<record id="view_move_line_tree" model="ir.ui.view">
<field name="model">account.move.line</field>
<field name="inherit_id" ref="account.view_move_line_tree" />
<field name="arch" type="xml">
<field name="move_id" position="before">
<header>
<button
name="%(account_move_line_reconcile_manual_action)d"
type="action"
string="Reconcile"
groups="account.group_account_user"
/>
</header>
</field>
</field>
</record>
</odoo>

View File

@ -0,0 +1 @@
from . import account_move_line_reconcile_manual

View File

@ -0,0 +1,302 @@
# Copyright 2023 Akretion France (http://www.akretion.com/)
# @author: Alexis de Lattre <alexis.delattre@akretion.com>
# 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.depends("writeoff_account_id", "partner_id")
def _compute_analytic_distribution(self):
aadmo = self.env["account.analytic.distribution.model"]
for wiz in self:
if wiz.writeoff_account_id:
partner = wiz.partner_id
distribution = aadmo._get_distribution(
{
"partner_id": partner and partner.id or False,
"partner_category_id": partner
and partner.category_id.ids
or False,
"account_prefix": wiz.writeoff_account_id.code,
"company_id": wiz.company_id.id,
}
)
wiz.analytic_distribution = distribution or wiz.analytic_distribution
@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,
{
"display_type": "payment_term",
"account_id": self.account_id.id,
"partner_id": self.partner_id and self.partner_id.id or False,
"debit": debit,
"credit": credit,
},
),
(
0,
0,
{
"display_type": "product",
"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.with_context(validate_analytic=True)._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

View File

@ -0,0 +1,99 @@
<?xml version="1.0" encoding="utf-8" ?>
<!--
Copyright 2023 Akretion France (http://www.akretion.com/)
@author: Alexis de Lattre <alexis.delattre@akretion.com>
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
-->
<odoo>
<record id="account_move_line_reconcile_manual_form" model="ir.ui.view">
<field name="model">account.move.line.reconcile.manual</field>
<field name="arch" type="xml">
<form>
<div
class="alert alert-warning"
role="alert"
attrs="{'invisible': [('partner_count', '&lt;', 2)]}"
>
You are trying to reconcile journal items from <field
name="partner_count"
/> different partners: make sure it is intented.
</div>
<group name="main">
<field name="account_id" />
<field name="count" />
<field name="total_debit" />
<field name="total_credit" />
<field name="state" invisible="1" />
<field name="company_id" invisible="1" />
<field name="company_currency_id" invisible="1" />
<field name="partner_count" invisible="1" />
<field name="partner_id" invisible="1" />
</group>
<group name="writeoff" string="Write-off" states="writeoff">
<field
name="writeoff_journal_id"
widget="selection"
attrs="{'required': [('state', '=', 'writeoff')]}"
/>
<field
name="writeoff_date"
attrs="{'required': [('state', '=', 'writeoff')]}"
options="{'datepicker': {'warn_future': true}}"
/>
<field name="writeoff_ref" />
<field name="writeoff_amount" />
<field name="writeoff_type" />
<field
name="writeoff_account_id"
attrs="{'required': [('state', '=', 'writeoff')]}"
/>
<field
name="analytic_distribution"
widget="analytic_distribution"
groups="analytic.group_analytic_accounting"
/>
</group>
<footer>
<button
name="full_reconcile"
type="object"
attrs="{'invisible': ['|', ('state', '!=', 'start'), ('writeoff_type', '!=', 'none')]}"
class="btn-primary"
string="Full Reconcile"
/>
<button
name="partial_reconcile"
type="object"
attrs="{'invisible': ['|', ('state', '!=', 'start'), ('writeoff_type', '=', 'none')]}"
class="btn-primary"
string="Partial Reconcile"
/>
<button
name="go_to_writeoff"
type="object"
attrs="{'invisible': ['|', ('state', '!=', 'start'), ('writeoff_type', '=', 'none')]}"
class="btn-primary"
string="Reconcile with Write-off"
/>
<button
name="reconcile_with_writeoff"
type="object"
states="writeoff"
class="btn-primary"
string="Reconcile with Write-off"
/>
<button special="cancel" string="Cancel" />
</footer>
</form>
</field>
</record>
<record id="account_move_line_reconcile_manual_action" model="ir.actions.act_window">
<field name="name">Reconcile</field>
<field name="res_model">account.move.line.reconcile.manual</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>

View File

@ -0,0 +1 @@
../../../../account_move_line_reconcile_manual

View File

@ -0,0 +1,6 @@
import setuptools
setuptools.setup(
setup_requires=['setuptools-odoo'],
odoo_addon=True,
)