[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:
@@ -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
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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'))]}">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 """
|
||||
|
||||
Reference in New Issue
Block a user