first commit
This commit is contained in:
526
modules/account_payment_clearing/payment.py
Normal file
526
modules/account_payment_clearing/payment.py
Normal file
@@ -0,0 +1,526 @@
|
||||
# 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 functools import wraps
|
||||
|
||||
from sql import Null
|
||||
from sql.aggregate import BoolAnd, Min
|
||||
from sql.conditionals import Coalesce
|
||||
|
||||
from trytond import backend
|
||||
from trytond.i18n import gettext
|
||||
from trytond.model import ModelView, Workflow, fields
|
||||
from trytond.modules.account.exceptions import AccountMissing
|
||||
from trytond.pool import Pool, PoolMeta
|
||||
from trytond.pyson import Bool, Eval, If, TimeDelta
|
||||
from trytond.tools import grouped_slice, reduce_ids
|
||||
from trytond.transaction import Transaction
|
||||
from trytond.wizard import Button, StateTransition, StateView, Wizard
|
||||
|
||||
|
||||
class Journal(metaclass=PoolMeta):
|
||||
__name__ = 'account.payment.journal'
|
||||
clearing_account = fields.Many2One('account.account', 'Clearing Account',
|
||||
domain=[
|
||||
('company', '=', Eval('company', -1)),
|
||||
('type', '!=', None),
|
||||
('closed', '!=', True),
|
||||
('party_required', '=', False),
|
||||
],
|
||||
states={
|
||||
'required': Bool(Eval('clearing_journal')),
|
||||
})
|
||||
clearing_journal = fields.Many2One('account.journal', 'Clearing Journal',
|
||||
states={
|
||||
'required': Bool(Eval('clearing_account')),
|
||||
},
|
||||
context={
|
||||
'company': Eval('company', None),
|
||||
})
|
||||
clearing_posting_delay = fields.TimeDelta(
|
||||
"Clearing Posting Delay",
|
||||
domain=['OR',
|
||||
('clearing_posting_delay', '=', None),
|
||||
('clearing_posting_delay', '>=', TimeDelta()),
|
||||
],
|
||||
help="Post automatically the clearing moves after the delay.\n"
|
||||
"Leave empty for no posting.")
|
||||
|
||||
@classmethod
|
||||
def cron_post_clearing_moves(cls, date=None):
|
||||
pool = Pool()
|
||||
Date = pool.get('ir.date')
|
||||
Move = pool.get('account.move')
|
||||
if date is None:
|
||||
date = Date.today()
|
||||
moves = []
|
||||
journals = cls.search([
|
||||
('company', '=', Transaction().context.get('company')),
|
||||
('clearing_posting_delay', '!=', None),
|
||||
])
|
||||
for journal in journals:
|
||||
move_date = date - journal.clearing_posting_delay
|
||||
moves.extend(Move.search([
|
||||
('date', '<=', move_date),
|
||||
('origin.journal.id', '=', journal.id,
|
||||
'account.payment'),
|
||||
('state', '=', 'draft'),
|
||||
('company', '=', journal.company.id),
|
||||
]))
|
||||
Move.post(moves)
|
||||
|
||||
|
||||
def cancel_clearing_move(func):
|
||||
@wraps(func)
|
||||
def wrapper(cls, payments, *args, **kwargs):
|
||||
pool = Pool()
|
||||
Move = pool.get('account.move')
|
||||
Line = pool.get('account.move.line')
|
||||
Reconciliation = pool.get('account.move.reconciliation')
|
||||
|
||||
result = func(cls, payments, *args, **kwargs)
|
||||
|
||||
to_delete = []
|
||||
to_reconcile = defaultdict(lambda: defaultdict(list))
|
||||
to_unreconcile = []
|
||||
for payment in payments:
|
||||
if payment.clearing_move:
|
||||
if payment.clearing_move.state == 'draft':
|
||||
to_delete.append(payment.clearing_move)
|
||||
for line in payment.clearing_move.lines:
|
||||
if line.reconciliation:
|
||||
to_unreconcile.append(line.reconciliation)
|
||||
else:
|
||||
cancel_move = payment.clearing_move.cancel()
|
||||
for line in (payment.clearing_move.lines
|
||||
+ cancel_move.lines):
|
||||
if line.reconciliation:
|
||||
to_unreconcile.append(line.reconciliation)
|
||||
if line.account.reconcile:
|
||||
to_reconcile[payment.party][line.account].append(
|
||||
line)
|
||||
|
||||
# Remove clearing_move before delete
|
||||
# in case reconciliation triggers use it.
|
||||
cls.write(payments, {'clearing_move': None})
|
||||
|
||||
if to_unreconcile:
|
||||
Reconciliation.delete(to_unreconcile)
|
||||
if to_delete:
|
||||
Move.delete(to_delete)
|
||||
for party in to_reconcile:
|
||||
for lines in to_reconcile[party].values():
|
||||
Line.reconcile(lines)
|
||||
cls.update_reconciled(payments)
|
||||
return result
|
||||
return wrapper
|
||||
|
||||
|
||||
class Payment(metaclass=PoolMeta):
|
||||
__name__ = 'account.payment'
|
||||
account = fields.Many2One(
|
||||
'account.account', "Account", ondelete='RESTRICT',
|
||||
domain=[
|
||||
('closed', '!=', True),
|
||||
('company', '=', Eval('company', -1)),
|
||||
['OR',
|
||||
('second_currency', '=', Eval('currency', None)),
|
||||
[
|
||||
('company.currency', '=', Eval('currency', None)),
|
||||
('second_currency', '=', None),
|
||||
],
|
||||
],
|
||||
If(Eval('line'),
|
||||
('id', '=', None),
|
||||
()),
|
||||
],
|
||||
states={
|
||||
'readonly': Eval('state') != 'draft',
|
||||
'invisible': Eval('line') & ~Eval('account'),
|
||||
},
|
||||
help="Define the account to use for clearing move.")
|
||||
clearing_move = fields.Many2One('account.move', 'Clearing Move',
|
||||
readonly=True)
|
||||
clearing_reconciled = fields.Boolean(
|
||||
"Clearing Reconciled", readonly=True,
|
||||
states={
|
||||
'invisible': ~Eval('clearing_move'),
|
||||
},
|
||||
help="Checked if clearing line is reconciled.")
|
||||
|
||||
@property
|
||||
def amount_line_paid(self):
|
||||
amount = super().amount_line_paid
|
||||
|
||||
if self.clearing_move:
|
||||
clearing_lines = [
|
||||
l for l in self.clearing_move.lines
|
||||
if l.account == self.clearing_account]
|
||||
if clearing_lines:
|
||||
clearing_line = clearing_lines[0]
|
||||
if (not self.line.reconciliation
|
||||
and clearing_line.reconciliation):
|
||||
if self.line.second_currency:
|
||||
payment_amount = abs(self.line.amount_second_currency)
|
||||
else:
|
||||
payment_amount = abs(
|
||||
self.line.credit - self.line.debit)
|
||||
amount -= max(min(self.amount, payment_amount), 0)
|
||||
return amount
|
||||
|
||||
@classmethod
|
||||
def __setup__(cls):
|
||||
super().__setup__()
|
||||
line_invisible = Eval('account') & ~Eval('line')
|
||||
if 'invisible' in cls.line.states:
|
||||
cls.line.states['invisible'] &= line_invisible
|
||||
else:
|
||||
cls.line.states['invisible'] = line_invisible
|
||||
cls._buttons.update({
|
||||
'succeed_wizard': cls._buttons['succeed'],
|
||||
})
|
||||
cls.account.domain = [
|
||||
cls.account.domain,
|
||||
cls._account_type_domain(),
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def _account_type_domain(cls):
|
||||
return If(Eval('state') == 'draft',
|
||||
If(Eval('kind') == 'receivable',
|
||||
('type.receivable', '=', True),
|
||||
('type.payable', '=', True),
|
||||
),
|
||||
())
|
||||
|
||||
@fields.depends('party', 'kind', 'date')
|
||||
def on_change_party(self):
|
||||
super().on_change_party()
|
||||
if self.kind == 'receivable':
|
||||
if self.party:
|
||||
with Transaction().set_context(date=self.date):
|
||||
self.account = self.party.account_receivable_used
|
||||
else:
|
||||
self.account = None
|
||||
|
||||
@classmethod
|
||||
@ModelView.button_action('account_payment_clearing.wizard_succeed')
|
||||
def succeed_wizard(cls, payments):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
@ModelView.button
|
||||
@Workflow.transition('succeeded')
|
||||
def succeed(cls, payments):
|
||||
pool = Pool()
|
||||
Line = pool.get('account.move.line')
|
||||
|
||||
super().succeed(payments)
|
||||
|
||||
cls.set_clearing_move(payments)
|
||||
to_reconcile = []
|
||||
for payment in payments:
|
||||
if (payment.line
|
||||
and not payment.line.reconciliation
|
||||
and payment.clearing_move):
|
||||
lines = [l for l in payment.clearing_move.lines
|
||||
if l.account == payment.line.account] + [payment.line]
|
||||
if not sum(l.debit - l.credit for l in lines):
|
||||
to_reconcile.append(lines)
|
||||
Line.reconcile(*to_reconcile)
|
||||
cls.reconcile_clearing(payments)
|
||||
cls.update_reconciled(payments)
|
||||
|
||||
@property
|
||||
def clearing_account(self):
|
||||
transaction = Transaction()
|
||||
context = transaction.context
|
||||
with transaction.set_context(date=context.get('clearing_date')):
|
||||
if self.line:
|
||||
account = self.line.account.current()
|
||||
if not account:
|
||||
raise AccountMissing(gettext(
|
||||
'account_payment_clearing'
|
||||
'.msg_payment_clearing_account_missing',
|
||||
payment=self.rec_name,
|
||||
account=self.line.account.rec_name))
|
||||
elif self.account:
|
||||
account = self.account.current()
|
||||
if not account:
|
||||
raise AccountMissing(gettext(
|
||||
'account_payment_clearing'
|
||||
'.msg_payment_clearing_account_missing',
|
||||
payment=self.rec_name,
|
||||
account=self.account.rec_name))
|
||||
elif self.kind == 'payable':
|
||||
account = self.party.account_payable_used
|
||||
if not account:
|
||||
raise AccountMissing(gettext(
|
||||
'account_payment_clearing'
|
||||
'.msg_payment_clearing_account_payable_missing',
|
||||
payment=self.rec_name,
|
||||
party=self.party.rec_name))
|
||||
elif self.kind == 'receivable':
|
||||
account = self.party.account_receivable_used
|
||||
if not account:
|
||||
raise AccountMissing(gettext(
|
||||
'account_payment_clearing'
|
||||
'.msg_payment_clearing_account_receivable_missing',
|
||||
payment=self.rec_name,
|
||||
party=self.party.rec_name))
|
||||
return account
|
||||
|
||||
@property
|
||||
def clearing_party(self):
|
||||
if self.line:
|
||||
return self.line.party
|
||||
else:
|
||||
return self.party
|
||||
|
||||
@classmethod
|
||||
def set_clearing_move(cls, payments):
|
||||
pool = Pool()
|
||||
Move = pool.get('account.move')
|
||||
moves = []
|
||||
for payment in payments:
|
||||
move = payment._get_clearing_move()
|
||||
if move and not payment.clearing_move:
|
||||
payment.clearing_move = move
|
||||
moves.append(move)
|
||||
if moves:
|
||||
Move.save(moves)
|
||||
cls.save(payments)
|
||||
|
||||
def _get_clearing_move(self, date=None):
|
||||
pool = Pool()
|
||||
Move = pool.get('account.move')
|
||||
Line = pool.get('account.move.line')
|
||||
Currency = pool.get('currency.currency')
|
||||
Period = pool.get('account.period')
|
||||
Date = pool.get('ir.date')
|
||||
|
||||
if (not self.journal.clearing_account
|
||||
or not self.journal.clearing_journal):
|
||||
return
|
||||
if self.clearing_move:
|
||||
return self.clearing_move
|
||||
|
||||
if date is None:
|
||||
date = Transaction().context.get('clearing_date')
|
||||
if date is None:
|
||||
with Transaction().set_context(company=self.company.id):
|
||||
date = Date.today()
|
||||
period = Period.find(self.company, date=date)
|
||||
|
||||
local_currency = self.journal.currency == self.company.currency
|
||||
if not local_currency:
|
||||
with Transaction().set_context(date=self.date):
|
||||
local_amount = Currency.compute(
|
||||
self.journal.currency, self.amount, self.company.currency)
|
||||
else:
|
||||
local_amount = self.amount
|
||||
|
||||
move = Move(journal=self.journal.clearing_journal, origin=self,
|
||||
date=date, period=period, company=self.company)
|
||||
line = Line()
|
||||
if self.kind == 'payable':
|
||||
line.debit, line.credit = local_amount, 0
|
||||
else:
|
||||
line.debit, line.credit = 0, local_amount
|
||||
line.account = self.clearing_account
|
||||
if not local_currency:
|
||||
line.amount_second_currency = self.amount.copy_sign(
|
||||
line.debit - line.credit)
|
||||
line.second_currency = self.journal.currency
|
||||
|
||||
line.party = (self.clearing_party
|
||||
if line.account.party_required else None)
|
||||
counterpart = Line()
|
||||
if self.kind == 'payable':
|
||||
counterpart.debit, counterpart.credit = 0, local_amount
|
||||
else:
|
||||
counterpart.debit, counterpart.credit = local_amount, 0
|
||||
counterpart.account = self.journal.clearing_account.current(date)
|
||||
if not counterpart.account:
|
||||
raise AccountMissing(gettext(
|
||||
'account_payment_clearing'
|
||||
'.msg_payment_clearing_account_missing_journal',
|
||||
payment=self.rec_name,
|
||||
account=self.journal.clearing_account.rec_name,
|
||||
journal=self.journal.rec_name))
|
||||
if not local_currency:
|
||||
counterpart.amount_second_currency = self.amount.copy_sign(
|
||||
counterpart.debit - counterpart.credit)
|
||||
counterpart.second_currency = self.journal.currency
|
||||
move.lines = (line, counterpart)
|
||||
return move
|
||||
|
||||
@classmethod
|
||||
@ModelView.button
|
||||
@Workflow.transition('processing')
|
||||
@cancel_clearing_move
|
||||
def proceed(cls, payments):
|
||||
super().proceed(payments)
|
||||
|
||||
@classmethod
|
||||
@ModelView.button
|
||||
@Workflow.transition('failed')
|
||||
@cancel_clearing_move
|
||||
def fail(cls, payments):
|
||||
super().fail(payments)
|
||||
|
||||
@classmethod
|
||||
def update_reconciled(cls, payments):
|
||||
for payment in payments:
|
||||
if payment.clearing_move:
|
||||
payment.clearing_reconciled = all(
|
||||
l.reconciliation for l in payment.clearing_lines)
|
||||
else:
|
||||
payment.clearing_reconciled = False
|
||||
cls.save(payments)
|
||||
|
||||
@classmethod
|
||||
def reconcile_clearing(cls, payments):
|
||||
pool = Pool()
|
||||
MoveLine = pool.get('account.move.line')
|
||||
Group = pool.get('account.payment.group')
|
||||
to_reconcile = []
|
||||
for payment in payments:
|
||||
if not payment.clearing_move:
|
||||
continue
|
||||
clearing_account = payment.journal.clearing_account
|
||||
if not clearing_account or not clearing_account.reconcile:
|
||||
continue
|
||||
lines = [l for l in payment.clearing_lines if not l.reconciliation]
|
||||
if lines and not sum((l.debit - l.credit) for l in lines):
|
||||
to_reconcile.append(lines)
|
||||
if to_reconcile:
|
||||
MoveLine.reconcile(*to_reconcile)
|
||||
Group.reconcile_clearing(
|
||||
Group.browse(list({p.group for p in payments if p.group})))
|
||||
|
||||
@property
|
||||
def clearing_lines(self):
|
||||
clearing_account = self.journal.clearing_account
|
||||
if self.clearing_move:
|
||||
for line in self.clearing_move.lines:
|
||||
if line.account == clearing_account:
|
||||
yield line
|
||||
|
||||
@classmethod
|
||||
def copy(cls, payments, default=None):
|
||||
if default is None:
|
||||
default = {}
|
||||
else:
|
||||
default = default.copy()
|
||||
default.setdefault('clearing_move')
|
||||
return super().copy(payments, default=default)
|
||||
|
||||
|
||||
class Group(metaclass=PoolMeta):
|
||||
__name__ = 'account.payment.group'
|
||||
|
||||
clearing_reconciled = fields.Function(fields.Boolean(
|
||||
"Clearing Reconciled",
|
||||
help="All payments in the group are reconciled."),
|
||||
'get_reconciled', searcher='search_reconciled')
|
||||
|
||||
@classmethod
|
||||
def __setup__(cls):
|
||||
super().__setup__()
|
||||
cls._buttons.update({
|
||||
'succeed_wizard': cls._buttons['succeed'],
|
||||
})
|
||||
|
||||
@classmethod
|
||||
def get_reconciled(cls, groups, name):
|
||||
pool = Pool()
|
||||
Payment = pool.get('account.payment')
|
||||
payment = Payment.__table__()
|
||||
cursor = Transaction().connection.cursor()
|
||||
result = defaultdict(lambda: None)
|
||||
column = Coalesce(payment.clearing_reconciled, False)
|
||||
if backend.name == 'sqlite':
|
||||
column = Min(column)
|
||||
else:
|
||||
column = BoolAnd(column)
|
||||
for sub_groups in grouped_slice(groups):
|
||||
cursor.execute(*payment.select(
|
||||
payment.group, column,
|
||||
where=reduce_ids(payment.group, sub_groups),
|
||||
group_by=payment.group))
|
||||
result.update(cursor)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def search_reconciled(cls, name, clause):
|
||||
pool = Pool()
|
||||
Payment = pool.get('account.payment')
|
||||
payment = Payment.__table__()
|
||||
|
||||
_, operator, value = clause
|
||||
Operator = fields.SQL_OPERATORS[operator]
|
||||
column = Coalesce(payment.clearing_reconciled, False)
|
||||
if backend.name == 'sqlite':
|
||||
column = Min(column)
|
||||
else:
|
||||
column = BoolAnd(column)
|
||||
|
||||
query = payment.select(
|
||||
payment.group,
|
||||
where=payment.group != Null,
|
||||
having=Operator(column, value),
|
||||
group_by=payment.group)
|
||||
return [('id', 'in', query)]
|
||||
|
||||
@classmethod
|
||||
@ModelView.button_action(
|
||||
'account_payment_clearing.wizard_payment_group_succeed')
|
||||
def succeed_wizard(cls, groups):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def reconcile_clearing(cls, groups):
|
||||
pool = Pool()
|
||||
MoveLine = pool.get('account.move.line')
|
||||
to_reconcile = []
|
||||
for group in groups:
|
||||
clearing_account = group.journal.clearing_account
|
||||
if not clearing_account or not clearing_account.reconcile:
|
||||
continue
|
||||
lines = [l for l in group.clearing_lines if not l.reconciliation]
|
||||
if lines and not sum((l.debit - l.credit) for l in lines):
|
||||
to_reconcile.append(lines)
|
||||
if to_reconcile:
|
||||
MoveLine.reconcile(*to_reconcile)
|
||||
|
||||
@property
|
||||
def clearing_lines(self):
|
||||
for payment in self.payments:
|
||||
yield from payment.clearing_lines
|
||||
|
||||
|
||||
class Succeed(Wizard):
|
||||
__name__ = 'account.payment.succeed'
|
||||
start = StateView('account.payment.succeed.start',
|
||||
'account_payment_clearing.succeed_start_view_form', [
|
||||
Button('Cancel', 'end', 'tryton-cancel'),
|
||||
Button('Succeed', 'succeed', 'tryton-ok', default=True),
|
||||
])
|
||||
succeed = StateTransition()
|
||||
|
||||
def transition_succeed(self):
|
||||
with Transaction().set_context(clearing_date=self.start.date):
|
||||
self.model.succeed(self.records)
|
||||
return 'end'
|
||||
|
||||
|
||||
class SucceedStart(ModelView):
|
||||
__name__ = 'account.payment.succeed.start'
|
||||
date = fields.Date("Date", required=True)
|
||||
|
||||
@classmethod
|
||||
def default_date(cls):
|
||||
pool = Pool()
|
||||
Date = pool.get('ir.date')
|
||||
return Date.today()
|
||||
Reference in New Issue
Block a user