404 lines
13 KiB
Python
404 lines
13 KiB
Python
# 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 collections import defaultdict
|
|
from itertools import groupby
|
|
|
|
from sql import Literal
|
|
|
|
from trytond.model import (
|
|
ChatMixin, Index, Model, ModelSQL, ModelView, Unique, 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, check_access
|
|
from trytond.wizard import (
|
|
Button, StateAction, StateTransition, StateView, Wizard)
|
|
|
|
|
|
class Procedure(ModelSQL, ModelView):
|
|
__name__ = 'account.dunning.procedure'
|
|
name = fields.Char('Name', required=True, translate=True,
|
|
help="The main identifier of the Dunning Procedure.")
|
|
levels = fields.One2Many('account.dunning.level', 'procedure', 'Levels')
|
|
|
|
@classmethod
|
|
def __setup__(cls):
|
|
super().__setup__()
|
|
cls._order.insert(0, ('name', 'ASC'))
|
|
|
|
|
|
class Level(sequence_ordered(), ModelSQL, ModelView):
|
|
__name__ = 'account.dunning.level'
|
|
procedure = fields.Many2One(
|
|
'account.dunning.procedure', "Procedure", required=True)
|
|
overdue = fields.TimeDelta('Overdue',
|
|
help="The delay from the maturity date "
|
|
"after which the level should be applied.")
|
|
|
|
def get_rec_name(self, name):
|
|
return '%s@%s' % (self.procedure.levels.index(self),
|
|
self.procedure.rec_name)
|
|
|
|
def test(self, line, date):
|
|
if self.overdue is not None:
|
|
return (date - line.maturity_date) >= self.overdue
|
|
|
|
|
|
_STATES = {
|
|
'readonly': Eval('state') != 'draft',
|
|
}
|
|
|
|
|
|
class Dunning(ModelSQL, ModelView, ChatMixin):
|
|
__name__ = 'account.dunning'
|
|
company = fields.Many2One(
|
|
'company.company', "Company", required=True, states=_STATES,
|
|
help="Make the dunning belong to the company.")
|
|
line = fields.Many2One('account.move.line', 'Line', required=True,
|
|
help="The receivable line to dun for.",
|
|
domain=[
|
|
('account.type.receivable', '=', True),
|
|
('account.company', '=', Eval('company', -1)),
|
|
['OR',
|
|
('debit', '>', 0),
|
|
('credit', '<', 0),
|
|
],
|
|
],
|
|
states=_STATES)
|
|
procedure = fields.Many2One('account.dunning.procedure', 'Procedure',
|
|
required=True, states=_STATES)
|
|
level = fields.Many2One('account.dunning.level', 'Level', required=True,
|
|
domain=[
|
|
('procedure', '=', Eval('procedure', -1)),
|
|
],
|
|
states=_STATES)
|
|
date = fields.Date(
|
|
"Date", readonly=True,
|
|
states={
|
|
'invisible': Eval('state') == 'draft',
|
|
},
|
|
help="When the dunning reached the level.")
|
|
age = fields.Function(fields.TimeDelta(
|
|
"Age",
|
|
states={
|
|
'invisible': Eval('state') == 'draft',
|
|
},
|
|
help="How long the dunning has been at the level."),
|
|
'get_age')
|
|
blocked = fields.Boolean('Blocked',
|
|
help="Check to block further levels of the procedure.")
|
|
state = fields.Selection([
|
|
('draft', 'Draft'),
|
|
('waiting', "Waiting"),
|
|
('final', "Final"),
|
|
], 'State', readonly=True, sort=False)
|
|
active = fields.Function(fields.Boolean('Active'), 'get_active',
|
|
searcher='search_active')
|
|
party = fields.Function(fields.Many2One(
|
|
'party.party', 'Party',
|
|
context={
|
|
'company': Eval('company', -1),
|
|
},
|
|
depends={'company'}),
|
|
'get_line_field', searcher='search_line_field')
|
|
amount = fields.Function(Monetary(
|
|
"Amount", currency='currency', digits='currency'),
|
|
'get_amount')
|
|
currency = fields.Function(fields.Many2One(
|
|
'currency.currency', "Currency"), 'get_line_field')
|
|
maturity_date = fields.Function(fields.Date('Maturity Date'),
|
|
'get_line_field', searcher='search_line_field')
|
|
amount_second_currency = fields.Function(Monetary(
|
|
'Amount Second Currency',
|
|
currency='second_currency', digits='second_currency',
|
|
states={
|
|
'invisible': Eval('currency') == Eval('second_currency'),
|
|
}),
|
|
'get_amount_second_currency')
|
|
second_currency = fields.Function(fields.Many2One('currency.currency',
|
|
'Second Currency'), 'get_second_currency')
|
|
|
|
@classmethod
|
|
def __setup__(cls):
|
|
super().__setup__()
|
|
table = cls.__table__()
|
|
cls._sql_constraints = [
|
|
('line_unique', Unique(table, table.line),
|
|
'account_dunning.msg_dunning_line_unique'),
|
|
]
|
|
cls._sql_indexes.add(
|
|
Index(
|
|
table,
|
|
(table.state, Index.Equality(cardinality='low')),
|
|
where=table.state.in_(['draft', 'waiting'])))
|
|
cls._active_field = 'active'
|
|
cls._buttons.update({
|
|
'reschedule': {
|
|
'invisible': ~Eval('active', True),
|
|
'depends': ['active'],
|
|
},
|
|
})
|
|
|
|
@staticmethod
|
|
def default_company():
|
|
return Transaction().context.get('company')
|
|
|
|
@staticmethod
|
|
def default_state():
|
|
return 'draft'
|
|
|
|
def get_age(self, name):
|
|
pool = Pool()
|
|
Date = pool.get('ir.date')
|
|
if self.date:
|
|
with Transaction().set_context(company=self.company.id):
|
|
return Date.today() - self.date
|
|
|
|
@classmethod
|
|
def order_age(cls, tables):
|
|
pool = Pool()
|
|
Date = pool.get('ir.date')
|
|
table, _ = tables[None]
|
|
return [Literal(Date.today()) - table.date]
|
|
|
|
def get_active(self, name):
|
|
return not self.line.reconciliation
|
|
|
|
def get_line_field(self, name):
|
|
value = getattr(self.line, name)
|
|
if isinstance(value, Model):
|
|
return value.id
|
|
else:
|
|
return value
|
|
|
|
@classmethod
|
|
def search_line_field(cls, name, clause):
|
|
return [('line.' + clause[0],) + tuple(clause[1:])]
|
|
|
|
def get_amount(self, name):
|
|
return self.line.debit - self.line.credit
|
|
|
|
def get_amount_second_currency(self, name):
|
|
amount = self.line.debit - self.line.credit
|
|
if self.line.amount_second_currency:
|
|
return self.line.amount_second_currency.copy_sign(amount)
|
|
else:
|
|
return amount
|
|
|
|
def get_second_currency(self, name):
|
|
if self.line.second_currency:
|
|
return self.line.second_currency.id
|
|
else:
|
|
return self.line.account.company.currency.id
|
|
|
|
@classmethod
|
|
def search_active(cls, name, clause):
|
|
reverse = {
|
|
'=': '!=',
|
|
'!=': '=',
|
|
}
|
|
if clause[1] in reverse:
|
|
if clause[2]:
|
|
return [('line.reconciliation', clause[1], None)]
|
|
else:
|
|
return [('line.reconciliation', reverse[clause[1]], None)]
|
|
else:
|
|
return []
|
|
|
|
def get_rec_name(self, name):
|
|
return f'{self.level.rec_name} [{self.line.rec_name}]'
|
|
|
|
@classmethod
|
|
def search_rec_name(cls, name, clause):
|
|
_, operator, operand, *extra = clause
|
|
if operator.startswith('!') or operator.startswith('not '):
|
|
bool_op = 'AND'
|
|
else:
|
|
bool_op = 'OR'
|
|
return [bool_op,
|
|
('level.rec_name', operator, operand, *extra),
|
|
('line.rec_name', operator, operand, *extra),
|
|
]
|
|
|
|
@classmethod
|
|
def _overdue_line_domain(cls, date):
|
|
return [
|
|
('account.type.receivable', '=', True),
|
|
('dunnings', '=', None),
|
|
('maturity_date', '<=', date),
|
|
['OR',
|
|
('debit', '>', 0),
|
|
('credit', '<', 0),
|
|
],
|
|
('party', '!=', None),
|
|
('reconciliation', '=', None),
|
|
]
|
|
|
|
@classmethod
|
|
def generate_dunnings(cls, date=None):
|
|
pool = Pool()
|
|
Date = pool.get('ir.date')
|
|
MoveLine = pool.get('account.move.line')
|
|
|
|
if date is None:
|
|
date = Date.today()
|
|
|
|
set_level = defaultdict(list)
|
|
with check_access():
|
|
dunnings = cls.search([
|
|
('state', '=', 'waiting'),
|
|
('blocked', '=', False),
|
|
])
|
|
dunnings = cls.browse(dunnings)
|
|
for dunning in dunnings:
|
|
procedure = dunning.procedure
|
|
levels = procedure.levels
|
|
levels = levels[levels.index(dunning.level) + 1:]
|
|
if levels:
|
|
for level in levels:
|
|
if level.test(dunning.line, date):
|
|
break
|
|
else:
|
|
level = dunning.level
|
|
if level != dunning.level:
|
|
set_level[level].append(dunning)
|
|
else:
|
|
set_level[None].append(dunning)
|
|
to_write = []
|
|
for level, dunnings in set_level.items():
|
|
if level:
|
|
to_write.extend((dunnings, {
|
|
'level': level.id,
|
|
'state': 'draft',
|
|
'date': None,
|
|
}))
|
|
else:
|
|
to_write.extend((dunnings, {
|
|
'state': 'final',
|
|
'date': Date.today(),
|
|
}))
|
|
if to_write:
|
|
cls.write(*to_write)
|
|
|
|
with check_access():
|
|
lines = MoveLine.search(cls._overdue_line_domain(date))
|
|
lines = MoveLine.browse(lines)
|
|
dunnings = (cls._get_dunning(line, date) for line in lines)
|
|
cls.save([d for d in dunnings if d])
|
|
|
|
@classmethod
|
|
def _get_dunning(cls, line, date):
|
|
procedure = line.dunning_procedure
|
|
if not procedure:
|
|
return
|
|
for level in procedure.levels:
|
|
if level.test(line, date):
|
|
break
|
|
else:
|
|
return
|
|
return cls(
|
|
company=line.account.company,
|
|
line=line,
|
|
procedure=procedure,
|
|
level=level,
|
|
)
|
|
|
|
def chat_language(self, audience='internal'):
|
|
language = super().chat_language(audience=audience)
|
|
if audience == 'public':
|
|
language = (
|
|
self.party.lang.code if self.party and self.party.lang
|
|
else None)
|
|
return language
|
|
|
|
@classmethod
|
|
def process(cls, dunnings):
|
|
pool = Pool()
|
|
Date = pool.get('ir.date')
|
|
for company, c_dunnings in groupby(dunnings, key=lambda d: d.company):
|
|
with Transaction().set_context(company=company.id):
|
|
today = Date.today()
|
|
cls.write([d for d in c_dunnings
|
|
if not d.blocked and d.state == 'draft'], {
|
|
'state': 'waiting',
|
|
'date': today,
|
|
})
|
|
|
|
@classmethod
|
|
@ModelView.button_action('account_dunning.act_reschedule_dunning_wizard')
|
|
def reschedule(cls, dunnings):
|
|
pass
|
|
|
|
|
|
class CreateDunningStart(ModelView):
|
|
__name__ = 'account.dunning.create.start'
|
|
date = fields.Date('Date', required=True,
|
|
help="Create dunning up to this date.")
|
|
|
|
@staticmethod
|
|
def default_date():
|
|
Date = Pool().get('ir.date')
|
|
return Date.today()
|
|
|
|
|
|
class CreateDunning(Wizard):
|
|
__name__ = 'account.dunning.create'
|
|
start = StateView('account.dunning.create.start',
|
|
'account_dunning.dunning_create_start_view_form', [
|
|
Button('Cancel', 'end', 'tryton-cancel'),
|
|
Button('Create', 'create_', 'tryton-ok', default=True),
|
|
])
|
|
create_ = StateAction('account_dunning.act_dunning_form')
|
|
|
|
def do_create_(self, action):
|
|
pool = Pool()
|
|
Dunning = pool.get('account.dunning')
|
|
Dunning.generate_dunnings(date=self.start.date)
|
|
return action, {}
|
|
|
|
|
|
class ProcessDunningStart(ModelView):
|
|
__name__ = 'account.dunning.process.start'
|
|
|
|
|
|
class ProcessDunning(Wizard):
|
|
__name__ = 'account.dunning.process'
|
|
start = StateView('account.dunning.process.start',
|
|
'account_dunning.dunning_process_start_view_form', [
|
|
Button('Cancel', 'end', 'tryton-cancel'),
|
|
Button('Process', 'process', 'tryton-ok', default=True),
|
|
])
|
|
process = StateTransition()
|
|
|
|
@classmethod
|
|
def __setup__(cls):
|
|
super().__setup__()
|
|
|
|
# _actions is the list that define the order of each state to process
|
|
# after the 'process' state.
|
|
cls._actions = ['process']
|
|
|
|
def next_state(self, state):
|
|
"Return the next state for the current state"
|
|
try:
|
|
i = self._actions.index(state)
|
|
return self._actions[i + 1]
|
|
except (ValueError, IndexError):
|
|
return 'end'
|
|
|
|
def transition_process(self):
|
|
self.model.process(self.records)
|
|
return self.next_state('process')
|
|
|
|
|
|
class RescheduleDunning(Wizard):
|
|
__name__ = 'account.dunning.reschedule'
|
|
start = StateAction('account.act_reschedule_lines_wizard')
|
|
|
|
def do_start(self, action):
|
|
return action, {
|
|
'ids': [self.record.line.id],
|
|
'model': 'account.move.line',
|
|
}
|