Files
tradon/modules/account_invoice/payment_term.py
2026-02-16 20:01:57 +01:00

369 lines
13 KiB
Python
Executable File

# This file is part of Tryton. The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.
from decimal import Decimal
from dateutil.relativedelta import relativedelta
from trytond import backend
from trytond.i18n import gettext
from trytond.model import (
DeactivableMixin, ModelSQL, ModelView, fields, sequence_ordered)
from trytond.modules.currency.fields import Monetary
from trytond.pool import Pool
from trytond.pyson import Eval
from trytond.transaction import Transaction
from trytond.wizard import Button, StateView, Wizard
from .exceptions import PaymentTermComputeError, PaymentTermValidationError
import logging
logger = logging.getLogger(__name__)
class PaymentTerm(DeactivableMixin, ModelSQL, ModelView):
'Payment Term'
__name__ = 'account.invoice.payment_term'
name = fields.Char('Name', size=None, required=True, translate=True)
description = fields.Text('Description', translate=True)
lines = fields.One2Many('account.invoice.payment_term.line', 'payment',
'Lines')
@classmethod
def __setup__(cls):
super(PaymentTerm, cls).__setup__()
cls._order.insert(0, ('name', 'ASC'))
@classmethod
def validate_fields(cls, terms, field_names):
super().validate_fields(terms, field_names)
cls.check_remainder(terms, field_names)
@classmethod
def check_remainder(cls, terms, field_names=None):
if field_names and 'lines' not in field_names:
return
for term in terms:
if not term.lines or not term.lines[-1].type == 'remainder':
raise PaymentTermValidationError(gettext(
'account_invoice'
'.msg_payment_term_missing_last_remainder',
payment_term=term.rec_name))
def compute(self, amount, currency, date, line_ = None):
"""Calculate payment terms and return a list of tuples
with (date, amount) for each payment term line.
amount must be a Decimal used for the calculation.
"""
# TODO implement business_days
# http://pypi.python.org/pypi/BusinessHours/
sign = 1 if amount >= Decimal(0) else -1
res = []
remainder = amount
for line in self.lines:
value = line.get_value(remainder, amount, currency)
value_date = line.get_date(date, line_)
if value is None or not value_date:
continue
if ((remainder - value) * sign) < Decimal(0):
res.append((value_date, remainder))
break
if value:
res.append((value_date, value))
remainder -= value
else:
# Enforce to have at least one term
if not res:
res.append((date, Decimal(0)))
if not currency.is_zero(remainder):
raise PaymentTermComputeError(
gettext('account_invoice.msg_payment_term_missing_remainder',
payment_term=self.rec_name))
return res
class PaymentTermLine(sequence_ordered(), ModelSQL, ModelView):
'Payment Term Line'
__name__ = 'account.invoice.payment_term.line'
payment = fields.Many2One('account.invoice.payment_term', 'Payment Term',
required=True, ondelete="CASCADE")
type = fields.Selection([
('fixed', 'Fixed'),
('percent', 'Percentage on Remainder'),
('percent_on_total', 'Percentage on Total'),
('remainder', 'Remainder'),
], 'Type', required=True)
ratio = fields.Numeric('Ratio', digits=(14, 10),
states={
'invisible': ~Eval('type').in_(['percent', 'percent_on_total']),
'required': Eval('type').in_(['percent', 'percent_on_total']),
})
divisor = fields.Numeric('Divisor', digits=(10, 14),
states={
'invisible': ~Eval('type').in_(['percent', 'percent_on_total']),
'required': Eval('type').in_(['percent', 'percent_on_total']),
})
amount = Monetary(
"Amount", currency='currency', digits='currency',
states={
'invisible': Eval('type') != 'fixed',
'required': Eval('type') == 'fixed',
})
currency = fields.Many2One('currency.currency', 'Currency',
states={
'invisible': Eval('type') != 'fixed',
'required': Eval('type') == 'fixed',
})
relativedeltas = fields.One2Many(
'account.invoice.payment_term.line.delta', 'line', 'Deltas')
@classmethod
def __setup__(cls):
super().__setup__()
cls.__access__.add('payment')
@staticmethod
def default_type():
return 'remainder'
@classmethod
def default_relativedeltas(cls):
if Transaction().user == 0:
return []
return [{}]
@fields.depends('type')
def on_change_type(self):
if self.type != 'fixed':
self.amount = Decimal(0)
self.currency = None
if self.type not in ('percent', 'percent_on_total'):
self.ratio = Decimal(0)
self.divisor = Decimal(0)
@fields.depends('ratio')
def on_change_ratio(self):
if not self.ratio:
self.divisor = Decimal(0)
else:
self.divisor = self.round(1 / self.ratio,
self.__class__.divisor.digits[1])
@fields.depends('divisor')
def on_change_divisor(self):
if not self.divisor:
self.ratio = Decimal(0)
else:
self.ratio = self.round(1 / self.divisor,
self.__class__.ratio.digits[1])
def get_date(self, date, line = None):
#find date based on trigger:
if line and self.trigger_event:
trigger_date = line.get_date(self.trigger_event)
logger.info("DATE_FROM_LINE:%s",trigger_date)
if trigger_date:
date = trigger_date
for relativedelta_ in self.relativedeltas:
date += relativedelta_.get()
return date
def get_value(self, remainder, amount, currency):
Currency = Pool().get('currency.currency')
if self.type == 'fixed':
fixed = Currency.compute(self.currency, self.amount, currency)
return fixed.copy_sign(amount)
elif self.type == 'percent':
return currency.round(remainder * self.ratio)
elif self.type == 'percent_on_total':
return currency.round(amount * self.ratio)
elif self.type == 'remainder':
return currency.round(remainder)
return None
@staticmethod
def round(number, digits):
quantize = Decimal(10) ** -Decimal(digits)
return Decimal(number).quantize(quantize)
@classmethod
def validate_fields(cls, lines, field_names):
super().validate_fields(lines, field_names)
cls.check_ratio_and_divisor(lines, field_names)
@classmethod
def check_ratio_and_divisor(cls, lines, field_names=None):
"Check consistency between ratio and divisor"
if field_names and not (field_names & {'type', 'ratio', 'divisor'}):
return
for line in lines:
if line.type not in ('percent', 'percent_on_total'):
continue
if line.ratio is None or line.divisor is None:
raise PaymentTermValidationError(
gettext('account_invoice'
'.msg_payment_term_invalid_ratio_divisor',
line=line.rec_name))
if (line.ratio != round(
1 / line.divisor, cls.ratio.digits[1])
and line.divisor != round(
1 / line.ratio, cls.divisor.digits[1])):
raise PaymentTermValidationError(
gettext('account_invoice'
'.msg_payment_term_invalid_ratio_divisor',
line=line.rec_name))
class PaymentTermLineRelativeDelta(sequence_ordered(), ModelSQL, ModelView):
'Payment Term Line Relative Delta'
__name__ = 'account.invoice.payment_term.line.delta'
line = fields.Many2One('account.invoice.payment_term.line',
'Payment Term Line', required=True, ondelete='CASCADE')
day = fields.Integer('Day of Month',
domain=['OR',
('day', '=', None),
[('day', '>=', 1), ('day', '<=', 31)],
])
month = fields.Many2One('ir.calendar.month', "Month")
weekday = fields.Many2One('ir.calendar.day', "Day of Week")
months = fields.Integer('Number of Months', required=True)
weeks = fields.Integer('Number of Weeks', required=True)
days = fields.Integer('Number of Days', required=True)
@classmethod
def __setup__(cls):
super().__setup__()
cls.__access__.add('line')
@classmethod
def __register__(cls, module_name):
transaction = Transaction()
cursor = transaction.connection.cursor()
pool = Pool()
Month = pool.get('ir.calendar.month')
Day = pool.get('ir.calendar.day')
sql_table = cls.__table__()
month = Month.__table__()
day = Day.__table__()
table_h = cls.__table_handler__(module_name)
# Migration from 5.0: use ir.calendar
migrate_calendar = False
if (backend.TableHandler.table_exist(cls._table)
and table_h.column_exist('month')
and table_h.column_exist('weekday')):
migrate_calendar = (
table_h.column_is_type('month', 'VARCHAR')
or table_h.column_is_type('weekday', 'VARCHAR'))
if migrate_calendar:
table_h.column_rename('month', '_temp_month')
table_h.column_rename('weekday', '_temp_weekday')
super(PaymentTermLineRelativeDelta, cls).__register__(module_name)
table_h = cls.__table_handler__(module_name)
# Migration from 5.0: use ir.calendar
if migrate_calendar:
update = transaction.connection.cursor()
cursor.execute(*month.select(month.id, month.index))
for month_id, index in cursor:
update.execute(*sql_table.update(
[sql_table.month], [month_id],
where=sql_table._temp_month == str(index)))
table_h.drop_column('_temp_month')
cursor.execute(*day.select(day.id, day.index))
for day_id, index in cursor:
update.execute(*sql_table.update(
[sql_table.weekday], [day_id],
where=sql_table._temp_weekday == str(index)))
table_h.drop_column('_temp_weekday')
@staticmethod
def default_months():
return 0
@staticmethod
def default_weeks():
return 0
@staticmethod
def default_days():
return 0
def get(self):
"Return the relativedelta"
return relativedelta(
day=self.day,
month=int(self.month.index) if self.month else None,
days=self.days,
weeks=self.weeks,
months=self.months,
weekday=int(self.weekday.index) if self.weekday else None,
)
class TestPaymentTerm(Wizard):
'Test Payment Term'
__name__ = 'account.invoice.payment_term.test'
start_state = 'test'
test = StateView('account.invoice.payment_term.test',
'account_invoice.payment_term_test_view_form',
[Button('Close', 'end', 'tryton-close', default=True)])
def default_test(self, fields):
default = {}
if (self.model
and self.model.__name__ == 'account.invoice.payment_term'):
default['payment_term'] = self.record.id if self.record else None
return default
class TestPaymentTermView(ModelView):
'Test Payment Term'
__name__ = 'account.invoice.payment_term.test'
payment_term = fields.Many2One('account.invoice.payment_term',
'Payment Term', required=True)
date = fields.Date("Date", required=True)
amount = Monetary(
"Amount", currency='currency', digits='currency', required=True)
currency = fields.Many2One('currency.currency', 'Currency', required=True)
result = fields.One2Many('account.invoice.payment_term.test.result',
None, 'Result', readonly=True)
@classmethod
def default_date(cls):
return Pool().get('ir.date').today()
@staticmethod
def default_currency():
pool = Pool()
Company = pool.get('company.company')
company = Transaction().context.get('company')
if company:
return Company(company).currency.id
@fields.depends('payment_term', 'date', 'amount', 'currency', 'result')
def on_change_with_result(self):
pool = Pool()
Result = pool.get('account.invoice.payment_term.test.result')
result = []
if (self.payment_term and self.amount and self.currency and self.date):
for date, amount in self.payment_term.compute(
self.amount, self.currency, self.date):
result.append(Result(
date=date,
amount=amount,
currency=self.currency))
return result
class TestPaymentTermViewResult(ModelView):
'Test Payment Term'
__name__ = 'account.invoice.payment_term.test.result'
date = fields.Date('Date', readonly=True)
amount = Monetary(
"Amount", currency='currency', digits='currency', readonly=True)
currency = fields.Many2One('currency.currency', "Currency")