[IMP] account: credit limit by partners

The basic idea of this functionality is for users
to be able to put limits on Partners that would
trigger non-blocking warnings when trying to confirm new:

- Sales Orders
- Invoices

when that partner has too many open invoices.

task-2722165

Part-of: odoo/odoo#83205
This commit is contained in:
John Laterre (jol)
2022-04-01 18:49:46 +02:00
parent f826718365
commit ff0275be6b
13 changed files with 148 additions and 14 deletions
+36 -2
View File
@@ -2,7 +2,7 @@
from odoo import api, fields, models, Command, _
from odoo.exceptions import RedirectWarning, UserError, ValidationError, AccessError
from odoo.tools import float_compare, date_utils, email_split, email_re, html_escape, is_html_empty, sql
from odoo.tools import float_compare, date_utils, email_split, email_re, is_html_empty, sql
from odoo.tools.misc import format_amount, formatLang, format_date, get_lang
from datetime import date, timedelta
@@ -11,7 +11,6 @@ from contextlib import contextmanager
from itertools import zip_longest
from hashlib import sha256
from json import dumps
from markupsafe import Markup
import ast
import json
@@ -376,6 +375,10 @@ class AccountMove(models.Model):
# Technical field to hide Reconciled Entries stat button
has_reconciled_entries = fields.Boolean(compute="_compute_has_reconciled_entries")
show_reset_to_draft_button = fields.Boolean(compute='_compute_show_reset_to_draft_button')
# Credit Limit related field
partner_credit_warning = fields.Text(
compute='_compute_partner_credit_warning',
groups="account.group_account_invoice,account.group_account_readonly")
# ==== Hash Fields ====
restrict_mode_hash_table = fields.Boolean(related='journal_id.restrict_mode_hash_table')
@@ -1691,6 +1694,37 @@ class AccountMove(models.Model):
for record in self:
record.tax_country_code = record.tax_country_id.code
@api.depends('company_id', 'partner_id', 'amount_total_signed')
def _compute_partner_credit_warning(self):
for move in self:
move.with_company(move.company_id)
move.partner_credit_warning = ''
show_warning = move.state == 'draft' and \
move.move_type == 'out_invoice' and \
move.company_id.account_use_credit_limit
if show_warning:
updated_credit = move.partner_id.credit + move.amount_total_signed
move.partner_credit_warning = self._build_credit_warning_message(move, updated_credit)
def _build_credit_warning_message(self, record, updated_credit):
''' Build the warning message that will be displayed in a yellow banner on top of the current record
if the partner exceeds a credit limit (set on the company or the partner itself).
:param record: The record where the warning will appear (Invoice, Sales Order...).
:param updated_credit (float): The partner's updated credit limit including the current record.
:return (str): The warning message to be showed.
'''
credit_limit = record.partner_id.credit_limit
if (not credit_limit) or updated_credit <= credit_limit:
return ''
msg = _('%s has reached its Credit Limit of : %s\nTotal amount due ',
record.partner_id.name,
formatLang(self.env, credit_limit, currency_obj=record.company_id.currency_id))
if updated_credit > record.partner_id.credit:
msg += _('(including this document) ')
msg += ': %s' % formatLang(self.env, updated_credit, currency_obj=record.company_id.currency_id)
return msg
# -------------------------------------------------------------------------
# BUSINESS MODELS SYNCHRONIZATION
# -------------------------------------------------------------------------
+2
View File
@@ -98,6 +98,8 @@ class ResCompany(models.Model):
invoice_is_email = fields.Boolean('Email by default', default=True)
invoice_is_print = fields.Boolean('Print by default', default=True)
account_use_credit_limit = fields.Boolean(
string='Sales Credit Limit', help='Enable the use of credit limit on partners.')
#Fields of the setup step for opening move
account_opening_move_id = fields.Many2one(string='Opening Journal Entry', comodel_name='account.move', help="The journal entry containing the initial balance of all this company's accounts.")
+32 -4
View File
@@ -433,9 +433,21 @@ class ResPartner(models.Model):
partner.currency_id = self.env.company.currency_id
credit = fields.Monetary(compute='_credit_debit_get', search=_credit_search,
string='Total Receivable', help="Total amount this customer owes you.")
debit = fields.Monetary(compute='_credit_debit_get', search=_debit_search, string='Total Payable',
help="Total amount you have to pay to this vendor.")
string='Total Receivable', help="Total amount this customer owes you.",
groups='account.group_account_invoice,account.group_account_readonly')
credit_limit = fields.Float(
string='Credit Limit', help='Credit limit specific to this partner.',
groups='account.group_account_invoice,account.group_account_readonly',
company_dependent=True, copy=False, readonly=False)
use_partner_credit_limit = fields.Boolean(
string='Partner Limit', groups='account.group_account_invoice,account.group_account_readonly',
compute='_compute_use_partner_credit_limit', inverse='_inverse_use_partner_credit_limit')
show_credit_limit = fields.Boolean(
compute='_compute_show_credit_limit', groups='account.group_account_invoice,account.group_account_readonly')
debit = fields.Monetary(
compute='_credit_debit_get', search=_debit_search, string='Total Payable',
help="Total amount you have to pay to this vendor.",
groups='account.group_account_invoice,account.group_account_readonly')
debit_limit = fields.Monetary('Payable Limit')
total_invoiced = fields.Monetary(compute='_invoice_total', string="Total Invoiced",
groups='account.group_account_invoice,account.group_account_readonly')
@@ -527,6 +539,22 @@ class ResPartner(models.Model):
for partner in self:
partner.duplicated_bank_account_partners_count = len(partner._get_duplicated_bank_accounts())
@api.depends_context('company')
def _compute_use_partner_credit_limit(self):
for partner in self:
company_limit = self.env['ir.property']._get('credit_limit', 'res.partner')
partner.use_partner_credit_limit = partner.credit_limit != company_limit
def _inverse_use_partner_credit_limit(self):
for partner in self:
if not partner.use_partner_credit_limit:
partner.credit_limit = self.env['ir.property']._get('credit_limit', 'res.partner')
@api.depends_context('company')
def _compute_show_credit_limit(self):
for partner in self:
partner.show_credit_limit = self.env.company.account_use_credit_limit
def _find_accounting_partner(self, partner):
''' Find the partner for which the accounting entries will be created '''
return partner.commercial_partner_id
@@ -535,7 +563,7 @@ class ResPartner(models.Model):
def _commercial_fields(self):
return super(ResPartner, self)._commercial_fields() + \
['debit_limit', 'property_account_payable_id', 'property_account_receivable_id', 'property_account_position_id',
'property_payment_term_id', 'property_supplier_payment_term_id', 'last_time_entries_checked']
'property_payment_term_id', 'property_supplier_payment_term_id', 'last_time_entries_checked', 'credit_limit']
def action_view_partner_invoices(self):
self.ensure_one()
@@ -141,6 +141,13 @@ class ResConfigSettings(models.TransientModel):
use_invoice_terms = fields.Boolean(
string='Default Terms & Conditions',
config_parameter='account.use_invoice_terms')
account_use_credit_limit = fields.Boolean(
string="Sales Credit Limit", related="company_id.account_use_credit_limit", readonly=False,
help="Enable the use of credit limit on partners.")
account_default_credit_limit = fields.Monetary(
string="Default Credit Limit", readonly=False,
help='This is the default credit limit that will be used on partners that do not have a specific limit on them.',
compute="_compute_account_default_credit_limit", inverse="_inverse_account_default_credit_limit")
# Technical field to hide country specific fields from accounting configuration
country_code = fields.Char(related='company_id.account_fiscal_country_id.code', readonly=True)
@@ -159,6 +166,20 @@ class ResConfigSettings(models.TransientModel):
and self.chart_template_id != self.company_id.chart_template_id:
self.chart_template_id._load(self.env.company)
@api.depends('company_id')
def _compute_account_default_credit_limit(self):
for setting in self:
setting.account_default_credit_limit = self.env['ir.property']._get('credit_limit', 'res.partner')
def _inverse_account_default_credit_limit(self):
for setting in self:
self.env['ir.property']._set_default(
'credit_limit',
'res.partner',
setting.account_default_credit_limit,
self.company_id.id
)
@api.depends('company_id')
def _compute_has_chart_of_accounts(self):
self.has_chart_of_accounts = bool(self.company_id.chart_template_id)
@@ -679,6 +679,10 @@
attrs="{'invisible': ['|', ('state', '!=', 'draft'), ('auto_post', '=', False)]}">
This move is configured to be posted automatically at the accounting date: <field name="date" readonly="1"/>.
</div>
<div class="alert alert-warning mb-0" role="alert"
attrs="{'invisible': [('partner_credit_warning', '=', '')]}">
<field name="partner_credit_warning"/>
</div>
<!-- Currency consistency -->
<div class="alert alert-warning mb-0" role="alert"
attrs="{'invisible': ['|', ('display_inactive_currency_warning', '=', False), ('move_type', 'not in', ('in_invoice', 'in_refund', 'in_receipt'))]}">
+12
View File
@@ -203,6 +203,7 @@
<page name="sales_purchases" position="after">
<page string="Invoicing" name="accounting" attrs="{'invisible': [('is_company','=',False),('parent_id','!=',False)]}" groups="account.group_account_invoice,account.group_account_readonly">
<field name="duplicated_bank_account_partners_count" invisible="1"/>
<field name="show_credit_limit" invisible="1"/>
<group>
<group string="Bank Accounts" name="banks" groups="account.group_account_invoice,account.group_account_readonly">
<field name="bank_ids" nolabel="1" colspan="2">
@@ -225,6 +226,17 @@
<field name="property_account_receivable_id"/>
<field name="property_account_payable_id"/>
</group>
<group string="Credit Limits"
name="credit_limits"
groups="account.group_account_invoice,account.group_account_readonly"
attrs="{'invisible': [('show_credit_limit', '=', False)]}">
<field name="credit"/>
<div name="partner_credit_limit" colspan="2" class="o_checkbox_optional_field">
<label for="use_partner_credit_limit"/>
<field name="use_partner_credit_limit"/>
<field name="credit_limit" class="oe_inline" attrs="{'invisible': [('use_partner_credit_limit', '=', False)]}"/>
</div>
</group>
</group>
</page>
<page string="Invoicing" name="accounting_disabled" attrs="{'invisible': ['|',('is_company','=',True),('parent_id','=',False)]}" groups="account.group_account_invoice,account.group_account_readonly">
@@ -325,6 +325,24 @@
</div>
</div>
</div>
<div class="col-xs-12 col-md-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="account_use_credit_limit"/>
</div>
<div class="o_setting_right_pane">
<label for="account_use_credit_limit"/>
<span class="fa fa-lg fa-building-o" title="Values set here are company-specific." aria-label="Values set here are company-specific." role="img"/>
<div class="text-muted">
Trigger alerts when creating Invoices and Sales Orders for Partners with a Total Receivable amount exceeding a limit.
</div>
<div class="content-group mt-2" attrs="{'invisible': [('account_use_credit_limit', '=', False)]}">
<div class="row">
<label for="account_default_credit_limit" class="col-lg-4 o_light_label"/>
<field name="account_default_credit_limit"/>
</div>
</div>
</div>
</div>
</div>
<h2>Customer Payments</h2>
<div class="row mt16 o_settings_container" id="pay_invoice_online_setting_container">
+2 -2
View File
@@ -488,7 +488,7 @@ class test_convert_import_data(TransactionCase):
"""
import_wizard = self.env['base_import.import'].create({
'res_model': 'res.partner',
'file': u'name,parent_id/id,parent_id/date,parent_id/credit_limit\n'
'file': u'name,parent_id/id,parent_id/date,parent_id/partner_latitude\n'
u'"foo","__export__.res_partner_1","2017年10月12日","5,69"\n'.encode('utf-8'),
'file_type': 'text/csv'
@@ -502,7 +502,7 @@ class test_convert_import_data(TransactionCase):
'has_headers': True
}
data, import_fields = import_wizard._convert_import_data(
['name', 'parent_id/.id', 'parent_id/date', 'parent_id/credit_limit'],
['name', 'parent_id/.id', 'parent_id/date', 'parent_id/partner_latitude'],
options
)
result = import_wizard._parse_import_data(data, import_fields, options)
+15
View File
@@ -146,6 +146,10 @@ class SaleOrder(models.Model):
store=True, readonly=False, required=True, precompute=True,
states=LOCKED_FIELD_STATES,
domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]",)
# Credit Limit related field
partner_credit_warning = fields.Text(
compute='_compute_partner_credit_warning',
groups="account.group_account_invoice,account.group_account_readonly")
fiscal_position_id = fields.Many2one(
comodel_name='account.fiscal.position',
@@ -588,6 +592,17 @@ class SaleOrder(models.Model):
}
}
@api.depends('company_id', 'partner_id', 'amount_total')
def _compute_partner_credit_warning(self):
for order in self:
order.with_company(order.company_id)
order.partner_credit_warning = ''
show_warning = order.state in ('draft', 'sent') and \
order.company_id.account_use_credit_limit
if show_warning:
updated_credit = order.partner_id.credit + (order.amount_total * order.currency_rate)
order.partner_credit_warning = self.env['account.move']._build_credit_warning_message(order, updated_credit)
@api.onchange('partner_id')
def _onchange_partner_id_warning(self):
if not self.partner_id:
+4
View File
@@ -217,6 +217,10 @@
<button name="action_draft" states="cancel" type="object" string="Set to Quotation" data-hotkey="w"/>
<field name="state" widget="statusbar" statusbar_visible="draft,sent,sale"/>
</header>
<field name="partner_credit_warning"
class="alert alert-warning mb-0"
role="alert"
attrs="{'invisible': [('partner_credit_warning', '=', '')]}" />
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_view_invoice"
+1 -2
View File
@@ -177,7 +177,6 @@ class Partner(models.Model):
category_id = fields.Many2many('res.partner.category', column1='partner_id',
column2='category_id', string='Tags', default=_default_category)
credit_limit = fields.Float(string='Credit Limit')
active = fields.Boolean(default=True)
employee = fields.Boolean(help="Check this box if this contact is an Employee.")
function = fields.Char(string='Job Position')
@@ -480,7 +479,7 @@ class Partner(models.Model):
partners that aren't `commercial entities` themselves, and will be
delegated to the parent `commercial entity`. The list is meant to be
extended by inheriting classes. """
return ['vat', 'credit_limit']
return ['vat']
def _commercial_sync_from_company(self):
""" Handle sync of commercial fields when a new parent commercial entity is set,
-3
View File
@@ -108,9 +108,6 @@ class Partner(models.Model):
[50, 10, 2, 20, 5, 10, 1])),
('tz', populate.randomize([tz for tz in self.env['res.partner']._fields['tz'].get_values(self.env)])),
('website', populate.randomize([False, '', 'http://www.example.com'])),
('credit_limit', populate.randomize(
[False, 0, 500, 2500, 5000, 10000],
[50, 30, 5, 5, 5, 5])),
('name', populate.compute(get_name)), # keep after is_company
('ref', populate.randomize([False, '', '{counter}', 'p-{counter}'], [10, 10, 30, 50])),
('industry_id', populate.randomize(
+1 -1
View File
@@ -91,7 +91,7 @@ class TestIrDefault(TransactionCase):
with self.assertRaises(ValidationError):
IrDefault.set('res.partner', 'lang', 'some_LANG')
with self.assertRaises(ValidationError):
IrDefault.set('res.partner', 'credit_limit', 'foo')
IrDefault.set('res.partner', 'partner_latitude', 'foo')
def test_removal(self):
""" check defaults for many2one with their value being removed """