3275 lines
118 KiB
Python
3275 lines
118 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 decimal import Decimal
|
|
from itertools import chain, groupby, islice
|
|
|
|
from dateutil.relativedelta import relativedelta
|
|
from sql import Literal, Null, Window
|
|
from sql.aggregate import Sum
|
|
from sql.conditionals import Case, Coalesce
|
|
from sql.functions import Abs, CharLength, Round
|
|
from sql.operators import Exists
|
|
|
|
from trytond import backend
|
|
from trytond.i18n import gettext
|
|
from trytond.model import (
|
|
Check, DeactivableMixin, Index, ModelSQL, ModelView, dualmethod, fields)
|
|
from trytond.model.exceptions import AccessError
|
|
from trytond.modules.currency.fields import Monetary
|
|
from trytond.pool import Pool
|
|
from trytond.pyson import Bool, Eval, If, PYSONEncoder
|
|
from trytond.report import Report
|
|
from trytond.rpc import RPC
|
|
from trytond.tools import (
|
|
firstline, grouped_slice, reduce_ids, sqlite_apply_types)
|
|
from trytond.transaction import Transaction, check_access
|
|
from trytond.wizard import (
|
|
Button, StateAction, StateTransition, StateView, Wizard)
|
|
|
|
from .exceptions import (
|
|
AccountMissing, CancelDelegatedWarning, CancelWarning, CopyWarning,
|
|
DelegateLineError, GroupLineError, JournalMissing, PeriodNotFoundError,
|
|
PostError, ReconciliationDeleteWarning, ReconciliationError,
|
|
RescheduleLineError)
|
|
|
|
_MOVE_STATES = {
|
|
'readonly': Eval('state') == 'posted',
|
|
}
|
|
_LINE_STATES = {
|
|
'readonly': Eval('state') == 'valid',
|
|
}
|
|
|
|
|
|
class DescriptionOriginMixin:
|
|
__slots__ = ()
|
|
|
|
description_used = fields.Function(
|
|
fields.Char("Description", states=_MOVE_STATES),
|
|
'on_change_with_description_used',
|
|
setter='set_description_used',
|
|
searcher='search_description_used')
|
|
|
|
@fields.depends('description', 'origin')
|
|
def on_change_with_description_used(self, name=None):
|
|
description = self.description
|
|
if not description and hasattr(self.origin, 'description'):
|
|
description = firstline(self.origin.description or '')
|
|
return description
|
|
|
|
@classmethod
|
|
def set_description_used(cls, moves, name, value):
|
|
moves = [m for m in moves if m.description_used != value]
|
|
if moves:
|
|
cls.write(moves, {'description': value})
|
|
|
|
@classmethod
|
|
def search_description_used(cls, name, clause):
|
|
pool = Pool()
|
|
operator = clause[1]
|
|
if operator.startswith('!') or operator.startswith('not '):
|
|
bool_op, bool_desc_op, desc_op = 'AND', 'OR', '='
|
|
else:
|
|
bool_op, bool_desc_op, desc_op = 'OR', 'AND', '!='
|
|
domain = [bool_op, [bool_desc_op,
|
|
('description', desc_op, None),
|
|
('description', desc_op, ''),
|
|
('description', *clause[1:]),
|
|
],
|
|
]
|
|
for origin, _ in cls.get_origin():
|
|
if not origin:
|
|
continue
|
|
Model = pool.get(origin)
|
|
if 'description' in Model._fields:
|
|
domain.append(
|
|
('origin.description', *clause[1:3], origin, *clause[3:]))
|
|
return domain
|
|
|
|
|
|
class Move(DescriptionOriginMixin, ModelSQL, ModelView):
|
|
__name__ = 'account.move'
|
|
_rec_name = 'number'
|
|
number = fields.Char(
|
|
"Number", readonly=True,
|
|
help='Also known as Folio Number.')
|
|
company = fields.Many2One('company.company', 'Company', required=True,
|
|
states=_MOVE_STATES)
|
|
period = fields.Many2One('account.period', 'Period', required=True,
|
|
domain=[
|
|
('company', '=', Eval('company', -1)),
|
|
If(Eval('state') == 'draft',
|
|
('state', '=', 'open'),
|
|
()),
|
|
If(Eval('date', None), [
|
|
('start_date', '<=', Eval('date', None)),
|
|
('end_date', '>=', Eval('date', None)),
|
|
],
|
|
[]),
|
|
],
|
|
states=_MOVE_STATES)
|
|
journal = fields.Many2One(
|
|
'account.journal', "Journal", required=True, states=_MOVE_STATES,
|
|
context={
|
|
'company': Eval('company', -1),
|
|
},
|
|
depends={'company'})
|
|
date = fields.Date('Effective Date', required=True, states=_MOVE_STATES)
|
|
post_date = fields.Date('Post Date', readonly=True)
|
|
description = fields.Char('Description', states=_MOVE_STATES)
|
|
origin = fields.Reference('Origin', selection='get_origin',
|
|
states=_MOVE_STATES)
|
|
state = fields.Selection([
|
|
('draft', 'Draft'),
|
|
('posted', 'Posted'),
|
|
], 'State', required=True, readonly=True, sort=False)
|
|
lines = fields.One2Many('account.move.line', 'move', 'Lines',
|
|
states=_MOVE_STATES, depends={'company'},
|
|
context={
|
|
'journal': Eval('journal', -1),
|
|
'period': Eval('period', -1),
|
|
'date': Eval('date', None),
|
|
})
|
|
|
|
@classmethod
|
|
def __setup__(cls):
|
|
cls.number.search_unaccented = False
|
|
super().__setup__()
|
|
t = cls.__table__()
|
|
cls.create_date.select = True
|
|
cls._check_modify_exclude = {'lines'}
|
|
cls._order.insert(0, ('date', 'DESC'))
|
|
cls._order.insert(1, ('number', 'DESC'))
|
|
cls._buttons.update({
|
|
'post': {
|
|
'invisible': Eval('state') == 'posted',
|
|
'depends': ['state'],
|
|
},
|
|
})
|
|
cls.__rpc__.update({
|
|
'post': RPC(
|
|
readonly=False, instantiate=0, fresh_session=True),
|
|
})
|
|
cls._sql_indexes.update({
|
|
Index(t, (t.period, Index.Range())),
|
|
Index(t, (t.date, Index.Range()), (t.number, Index.Range())),
|
|
Index(
|
|
t,
|
|
(t.journal, Index.Range()),
|
|
(t.period, Index.Range())),
|
|
})
|
|
|
|
@classmethod
|
|
def __register__(cls, module):
|
|
table_h = cls.__table_handler__(module)
|
|
if (table_h.column_exist('number')
|
|
and table_h.column_exist('post_number')):
|
|
table_h.drop_column('number')
|
|
table_h.column_rename('post_number', 'number')
|
|
super().__register__(module)
|
|
|
|
@classmethod
|
|
def order_number(cls, tables):
|
|
table, _ = tables[None]
|
|
return [CharLength(table.number), table.number]
|
|
|
|
@staticmethod
|
|
def default_company():
|
|
return Transaction().context.get('company')
|
|
|
|
@fields.depends('company', 'period', 'date')
|
|
def on_change_company(self):
|
|
pool = Pool()
|
|
Period = pool.get('account.period')
|
|
if self.company:
|
|
if self.period and self.period.company != self.company:
|
|
self.period = None
|
|
if not self.period:
|
|
try:
|
|
self.period = Period.find(self.company, date=self.date)
|
|
except PeriodNotFoundError:
|
|
pass
|
|
else:
|
|
self.period = None
|
|
|
|
@classmethod
|
|
def default_period(cls):
|
|
Period = Pool().get('account.period')
|
|
company = cls.default_company()
|
|
try:
|
|
period = Period.find(company)
|
|
except PeriodNotFoundError:
|
|
return None
|
|
return period.id
|
|
|
|
@staticmethod
|
|
def default_state():
|
|
return 'draft'
|
|
|
|
@classmethod
|
|
def default_date(cls):
|
|
pool = Pool()
|
|
Period = pool.get('account.period')
|
|
Date = pool.get('ir.date')
|
|
today = Date.today()
|
|
period_id = cls.default_period()
|
|
if period_id:
|
|
period = Period(period_id)
|
|
start_date = period.start_date
|
|
end_date = period.end_date
|
|
if start_date <= today <= end_date:
|
|
return today
|
|
elif start_date >= today:
|
|
return start_date
|
|
else:
|
|
return end_date
|
|
|
|
@fields.depends('date', 'period', 'company', 'lines')
|
|
def on_change_date(self):
|
|
pool = Pool()
|
|
Period = pool.get('account.period')
|
|
context = Transaction().context
|
|
if self.date:
|
|
company = self.company or context.get('company')
|
|
if (not self.period
|
|
or not (
|
|
self.period.start_date <= self.date
|
|
<= self.period.end_date)):
|
|
try:
|
|
self.period = Period.find(company, date=self.date)
|
|
except PeriodNotFoundError:
|
|
self.period = None
|
|
for line in (self.lines or []):
|
|
line.date = self.date
|
|
|
|
@fields.depends(
|
|
'period', 'date', 'company', 'journal', methods=['on_change_date'])
|
|
def on_change_period(self):
|
|
pool = Pool()
|
|
Date = pool.get('ir.date')
|
|
Line = pool.get('account.move.line')
|
|
context = Transaction().context
|
|
company_id = (
|
|
self.company.id if self.company else context.get('company'))
|
|
with Transaction().set_context(company=company_id):
|
|
today = Date.today()
|
|
if self.period:
|
|
start_date = self.period.start_date
|
|
end_date = self.period.end_date
|
|
if (not self.date
|
|
or not (start_date <= self.date <= end_date)):
|
|
if start_date <= today <= end_date:
|
|
self.date = today
|
|
else:
|
|
lines = Line.search([
|
|
('company', '=', company_id),
|
|
('journal', '=', self.journal),
|
|
('period', '=', self.period),
|
|
],
|
|
order=[('date', 'DESC')], limit=1)
|
|
if lines:
|
|
line, = lines
|
|
self.date = line.date
|
|
elif start_date >= today:
|
|
self.date = start_date
|
|
else:
|
|
self.date = end_date
|
|
self.on_change_date()
|
|
|
|
@classmethod
|
|
def _get_origin(cls):
|
|
'Return list of Model names for origin Reference'
|
|
return ['account.fiscalyear', 'account.move']
|
|
|
|
@classmethod
|
|
def get_origin(cls):
|
|
Model = Pool().get('ir.model')
|
|
get_name = Model.get_name
|
|
models = cls._get_origin()
|
|
return [(None, '')] + [(m, get_name(m)) for m in models]
|
|
|
|
@classmethod
|
|
def check_modification(cls, mode, moves, values=None, external=False):
|
|
super().check_modification(
|
|
mode, moves, values=values, external=external)
|
|
if (mode == 'delete'
|
|
or (mode == 'write'
|
|
and values.keys() - cls._check_modify_exclude)):
|
|
for move in moves:
|
|
if move.state == 'posted':
|
|
raise AccessError(gettext(
|
|
'account.msg_modify_posted_moved',
|
|
move=move.rec_name))
|
|
|
|
@classmethod
|
|
def copy(cls, moves, default=None):
|
|
pool = Pool()
|
|
Date = pool.get('ir.date')
|
|
Period = pool.get('account.period')
|
|
Warning = pool.get('res.user.warning')
|
|
|
|
if default is None:
|
|
default = {}
|
|
else:
|
|
default = default.copy()
|
|
|
|
def _check_period(origin):
|
|
period = Period(origin['period'])
|
|
if period.state == 'closed':
|
|
move = cls(origin['id'])
|
|
key = Warning.format('copy', [move])
|
|
if Warning.check(key):
|
|
raise CopyWarning(key,
|
|
gettext('account.msg_move_copy_closed_period',
|
|
move=move))
|
|
return False
|
|
else:
|
|
return True
|
|
|
|
def default_period(origin):
|
|
if not _check_period(origin):
|
|
with Transaction().set_context(company=origin['company']):
|
|
today = Date.today()
|
|
period = Period.find(origin['company'], date=today)
|
|
return period.id
|
|
else:
|
|
return origin['period']
|
|
|
|
def default_date(origin):
|
|
if not _check_period(origin):
|
|
with Transaction().set_context(company=origin['company']):
|
|
return Date.today()
|
|
else:
|
|
return origin['date']
|
|
|
|
default.setdefault('number', None)
|
|
default.setdefault('state', cls.default_state())
|
|
default.setdefault('post_date', None)
|
|
default.setdefault('period', default_period)
|
|
default.setdefault('date', default_date)
|
|
return super().copy(moves, default=default)
|
|
|
|
@classmethod
|
|
def validate_move(cls, moves):
|
|
'''
|
|
Validate balanced move
|
|
'''
|
|
pool = Pool()
|
|
MoveLine = pool.get('account.move.line')
|
|
line = MoveLine.__table__()
|
|
transaction = Transaction()
|
|
cursor = transaction.connection.cursor()
|
|
|
|
for company, moves in groupby(moves, key=lambda m: m.company):
|
|
currency = company.currency
|
|
for sub_moves in grouped_slice(list(moves)):
|
|
red_sql = reduce_ids(line.move, [m.id for m in sub_moves])
|
|
|
|
valid_move_query = line.select(
|
|
line.move,
|
|
where=red_sql,
|
|
group_by=line.move,
|
|
having=Abs(Round(
|
|
Sum(line.debit - line.credit),
|
|
currency.digits)) < abs(currency.rounding))
|
|
cursor.execute(*line.update(
|
|
[line.state],
|
|
['valid'],
|
|
where=line.move.in_(valid_move_query)))
|
|
|
|
draft_move_query = line.select(
|
|
line.move,
|
|
where=red_sql,
|
|
group_by=line.move,
|
|
having=Abs(Round(
|
|
Sum(line.debit - line.credit),
|
|
currency.digits)) >= abs(currency.rounding))
|
|
cursor.execute(*line.update(
|
|
[line.state],
|
|
['draft'],
|
|
where=line.move.in_(draft_move_query)))
|
|
|
|
Transaction().counter += 1
|
|
for cache in Transaction().cache.values():
|
|
if MoveLine.__name__ in cache:
|
|
cache_cls = cache[MoveLine.__name__]
|
|
cache_cls.clear()
|
|
|
|
def _cancel_default(self, reversal=False):
|
|
'Return default dictionary to cancel move'
|
|
pool = Pool()
|
|
Date = pool.get('ir.date')
|
|
Period = pool.get('account.period')
|
|
Warning = pool.get('res.user.warning')
|
|
|
|
default = {
|
|
'origin': str(self),
|
|
}
|
|
if self.period.state == 'closed':
|
|
key = '%s.cancel' % self
|
|
if Warning.check(key):
|
|
raise CancelWarning(key,
|
|
gettext('account.msg_move_cancel_closed_period',
|
|
move=self.rec_name))
|
|
with Transaction().set_context(company=self.company.id):
|
|
date = Date.today()
|
|
period = Period.find(self.company, date=date)
|
|
default.update({
|
|
'date': date,
|
|
'period': period.id,
|
|
})
|
|
if reversal:
|
|
default['lines.debit'] = lambda data: data['credit']
|
|
default['lines.credit'] = lambda data: data['debit']
|
|
else:
|
|
default['lines.debit'] = lambda data: data['debit'] * -1
|
|
default['lines.credit'] = lambda data: data['credit'] * -1
|
|
default['lines.amount_second_currency'] = (
|
|
lambda data: data['amount_second_currency'] * -1
|
|
if data['amount_second_currency']
|
|
else data['amount_second_currency'])
|
|
default['lines.tax_lines.amount'] = lambda data: data['amount'] * -1
|
|
default['lines.origin'] = (
|
|
lambda data: 'account.move.line,%s' % data['id'])
|
|
return default
|
|
|
|
def cancel(self, default=None, reversal=False):
|
|
'Return a cancel move'
|
|
if default is None:
|
|
default = {}
|
|
else:
|
|
default = default.copy()
|
|
default.update(self._cancel_default(reversal=reversal))
|
|
cancel_move, = self.copy([self], default=default)
|
|
return cancel_move
|
|
|
|
@dualmethod
|
|
@ModelView.button
|
|
def post(cls, moves):
|
|
pool = Pool()
|
|
Date = pool.get('ir.date')
|
|
Line = pool.get('account.move.line')
|
|
move = cls.__table__()
|
|
line = Line.__table__()
|
|
cursor = Transaction().connection.cursor()
|
|
|
|
to_reconcile = []
|
|
|
|
for company, c_moves in groupby(moves, lambda m: m.company):
|
|
with Transaction().set_context(company=company.id):
|
|
today = Date.today()
|
|
currency = company.currency
|
|
c_moves = list(c_moves)
|
|
for sub_moves in grouped_slice(c_moves):
|
|
sub_moves_ids = [m.id for m in sub_moves]
|
|
|
|
cursor.execute(*move.select(
|
|
move.id,
|
|
where=reduce_ids(move.id, sub_moves_ids)
|
|
& ~Exists(line.select(
|
|
line.move,
|
|
where=line.move == move.id))))
|
|
try:
|
|
move_id, = cursor.fetchone()
|
|
except TypeError:
|
|
pass
|
|
else:
|
|
raise PostError(
|
|
gettext('account.msg_post_empty_move',
|
|
move=cls(move_id).rec_name))
|
|
|
|
cursor.execute(*line.select(
|
|
line.move,
|
|
where=reduce_ids(line.move, sub_moves_ids),
|
|
group_by=line.move,
|
|
having=Abs(Round(
|
|
Sum(line.debit - line.credit),
|
|
currency.digits)) >= abs(currency.rounding)))
|
|
try:
|
|
move_id, = cursor.fetchone()
|
|
except TypeError:
|
|
pass
|
|
else:
|
|
raise PostError(
|
|
gettext('account.msg_post_unbalanced_move',
|
|
move=cls(move_id).rec_name))
|
|
|
|
cursor.execute(*line.select(
|
|
line.id,
|
|
where=reduce_ids(line.move, sub_moves_ids)
|
|
& (line.debit == Decimal(0))
|
|
& (line.credit == Decimal(0))
|
|
& ((line.amount_second_currency == Null)
|
|
| (line.amount_second_currency == Decimal(0)))
|
|
))
|
|
to_reconcile.extend(l for l, in cursor)
|
|
|
|
missing_number = defaultdict(list)
|
|
for move in c_moves:
|
|
move.state = 'posted'
|
|
if not move.number:
|
|
move.post_date = today
|
|
missing_number[move.period.move_sequence_used].append(
|
|
move)
|
|
|
|
for sequence, m_moves in missing_number.items():
|
|
for move, number in zip(
|
|
m_moves, sequence.get_many(len(m_moves))):
|
|
move.number = number
|
|
|
|
cls.save(moves)
|
|
|
|
def keyfunc(line):
|
|
# Set party last to avoid compare party instance and None
|
|
# party will always be None for the same account
|
|
return line.account, line.party
|
|
to_reconcile = Line.browse(sorted(
|
|
[l for l in Line.browse(to_reconcile) if l.account.reconcile],
|
|
key=keyfunc))
|
|
to_reconcile = [list(l) for _, l in groupby(to_reconcile, keyfunc)]
|
|
if to_reconcile:
|
|
Line.reconcile(*to_reconcile)
|
|
|
|
|
|
class MoveContext(ModelView):
|
|
__name__ = 'account.move.context'
|
|
|
|
company = fields.Many2One('company.company', "Company", required=True)
|
|
|
|
@classmethod
|
|
def default_company(cls):
|
|
return Transaction().context.get('company')
|
|
|
|
|
|
class Reconciliation(ModelSQL, ModelView):
|
|
__name__ = 'account.move.reconciliation'
|
|
_rec_name = 'number'
|
|
number = fields.Char("Number", required=True)
|
|
company = fields.Many2One('company.company', "Company", required=True)
|
|
lines = fields.One2Many(
|
|
'account.move.line', 'reconciliation', 'Lines',
|
|
domain=[
|
|
('move.company', '=', Eval('company', -1)),
|
|
])
|
|
date = fields.Date(
|
|
"Date", required=True,
|
|
help='Highest date of the reconciled lines.')
|
|
delegate_to = fields.Many2One(
|
|
'account.move.line', "Delegate To", ondelete="RESTRICT",
|
|
domain=[
|
|
('move.company', '=', Eval('company', -1)),
|
|
],
|
|
help="The line to which the reconciliation status is delegated.")
|
|
|
|
@classmethod
|
|
def __setup__(cls):
|
|
cls.number.search_unaccented = False
|
|
super().__setup__()
|
|
t = cls.__table__()
|
|
cls._sql_indexes.add(Index(t, (t.date, Index.Range())))
|
|
|
|
@classmethod
|
|
def __register__(cls, module_name):
|
|
table = cls.__table_handler__(module_name)
|
|
|
|
# Migration from 6.6: rename name to number
|
|
if table.column_exist('name') and not table.column_exist('number'):
|
|
table.column_rename('name', 'number')
|
|
|
|
super().__register__(module_name)
|
|
|
|
@classmethod
|
|
def order_number(cls, tables):
|
|
table, _ = tables[None]
|
|
return [CharLength(table.number), table.number]
|
|
|
|
@classmethod
|
|
def default_company(cls):
|
|
return Transaction().context.get('company')
|
|
|
|
@classmethod
|
|
def preprocess_values(cls, mode, values):
|
|
pool = Pool()
|
|
Configuration = pool.get('account.configuration')
|
|
values = super().preprocess_values(mode, values)
|
|
if mode == 'create' and not values.get('number'):
|
|
company_id = values.get('company', cls.default_company())
|
|
if company_id is not None:
|
|
configuration = Configuration(1)
|
|
if sequence := configuration.get_multivalue(
|
|
'reconciliation_sequence', company=company_id):
|
|
values['number'] = sequence.get()
|
|
return values
|
|
|
|
@classmethod
|
|
def check_modification(
|
|
cls, mode, reconciliations, values=None, external=False):
|
|
pool = Pool()
|
|
Warning = pool.get('res.user.warning')
|
|
|
|
super().check_modification(
|
|
mode, reconciliations, values=values, external=external)
|
|
|
|
if mode == 'delete':
|
|
for reconciliation in reconciliations:
|
|
if reconciliation.delegate_to:
|
|
key = Warning.format('delete.delegated', [reconciliation])
|
|
if Warning.check(key):
|
|
raise ReconciliationDeleteWarning(key, gettext(
|
|
'account.msg_reconciliation_delete_delegated',
|
|
reconciliation=reconciliation.rec_name,
|
|
line=reconciliation.delegate_to.rec_name,
|
|
move=reconciliation.delegate_to.move.rec_name))
|
|
for line in reconciliation.lines:
|
|
if line.move.journal.type == 'write-off':
|
|
key = Warning.format(
|
|
'delete.write-off', [reconciliation])
|
|
if Warning.check(key):
|
|
raise ReconciliationDeleteWarning(key, gettext(
|
|
'account.'
|
|
'msg_reconciliation_delete_write_off',
|
|
reconciliation=reconciliation.rec_name,
|
|
line=line.rec_name,
|
|
move=line.move.rec_name))
|
|
|
|
@classmethod
|
|
def write(cls, moves, values, *args):
|
|
raise AccessError(gettext('account.msg_write_reconciliation'))
|
|
|
|
@classmethod
|
|
def validate(cls, reconciliations):
|
|
super().validate(reconciliations)
|
|
cls.check_lines(reconciliations)
|
|
|
|
@classmethod
|
|
def check_lines(cls, reconciliations):
|
|
Lang = Pool().get('ir.lang')
|
|
for reconciliation in reconciliations:
|
|
debit = Decimal(0)
|
|
credit = Decimal(0)
|
|
account = None
|
|
if reconciliation.lines:
|
|
party = reconciliation.lines[0].party
|
|
for line in reconciliation.lines:
|
|
if line.state != 'valid':
|
|
raise ReconciliationError(
|
|
gettext('account.msg_reconciliation_line_not_valid',
|
|
line=line.rec_name))
|
|
debit += line.debit
|
|
credit += line.credit
|
|
if not account:
|
|
account = line.account
|
|
elif account.id != line.account.id:
|
|
raise ReconciliationError(
|
|
gettext('account'
|
|
'.msg_reconciliation_different_accounts',
|
|
line=line.rec_name,
|
|
account1=line.account.rec_name,
|
|
account2=account.rec_name))
|
|
if not account.reconcile:
|
|
raise ReconciliationError(
|
|
gettext('account'
|
|
'.msg_reconciliation_account_not_reconcile',
|
|
line=line.rec_name,
|
|
account=line.account.rec_name))
|
|
if line.party != party:
|
|
raise ReconciliationError(
|
|
gettext('account'
|
|
'.msg_reconciliation_different_parties',
|
|
line=line.rec_name,
|
|
party1=line.party.rec_name if line.party else '',
|
|
party2=party.rec_name if party else ''))
|
|
if not reconciliation.company.currency.is_zero(debit - credit):
|
|
lang = Lang.get()
|
|
debit = lang.currency(debit, reconciliation.company.currency)
|
|
credit = lang.currency(credit, reconciliation.company.currency)
|
|
raise ReconciliationError(
|
|
gettext('account.msg_reconciliation_unbalanced',
|
|
debit=debit,
|
|
credit=credit))
|
|
|
|
|
|
class MoveLineMixin:
|
|
__slots__ = ()
|
|
|
|
payable_receivable_date = fields.Function(
|
|
fields.Date("Payable/Receivable Date"),
|
|
'on_change_with_payable_receivable_date')
|
|
|
|
@classmethod
|
|
def get_move_origin(cls):
|
|
Move = Pool().get('account.move')
|
|
return Move.get_origin()
|
|
|
|
@classmethod
|
|
def get_move_states(cls):
|
|
pool = Pool()
|
|
Move = pool.get('account.move')
|
|
return Move.fields_get(['state'])['state']['selection']
|
|
|
|
def get_move_field(self, name):
|
|
field = getattr(self.__class__, name)
|
|
if name.startswith('move_'):
|
|
name = name[5:]
|
|
value = getattr(self.move, name)
|
|
if isinstance(value, ModelSQL):
|
|
if field._type == 'reference':
|
|
return str(value)
|
|
return value.id
|
|
return value
|
|
|
|
@classmethod
|
|
def set_move_field(cls, lines, name, value):
|
|
pool = Pool()
|
|
Move = pool.get('account.move')
|
|
if name.startswith('move_'):
|
|
name = name[5:]
|
|
if not value:
|
|
return
|
|
moves = {line.move for line in lines}
|
|
moves = Move.browse(moves)
|
|
Move.write(moves, {
|
|
name: value,
|
|
})
|
|
|
|
@classmethod
|
|
def search_move_field(cls, name, clause):
|
|
nested = clause[0][len(name):]
|
|
if name.startswith('move_'):
|
|
name = name[5:]
|
|
return [('move.' + name + nested, *clause[1:])]
|
|
|
|
@staticmethod
|
|
def _order_move_field(name):
|
|
def order_field(cls, tables):
|
|
pool = Pool()
|
|
Move = pool.get('account.move')
|
|
field = Move._fields[name]
|
|
table, _ = tables[None]
|
|
move_tables = tables.get('move')
|
|
if move_tables is None:
|
|
move = Move.__table__()
|
|
move_tables = {
|
|
None: (move, move.id == table.move),
|
|
}
|
|
tables['move'] = move_tables
|
|
return field.convert_order(name, move_tables, Move)
|
|
return classmethod(order_field)
|
|
|
|
def get_amount(self, name):
|
|
sign = -1 if self.account.type.statement == 'income' else 1
|
|
if self.amount_second_currency is not None:
|
|
return self.amount_second_currency * sign
|
|
else:
|
|
return (self.debit - self.credit) * sign
|
|
|
|
def get_amount_currency(self, name):
|
|
if self.second_currency:
|
|
currency = self.second_currency
|
|
else:
|
|
currency = self.account.currency
|
|
return currency.id
|
|
|
|
@fields.depends('maturity_date', 'date')
|
|
def on_change_with_payable_receivable_date(self, name=None):
|
|
return self.maturity_date or self.date
|
|
|
|
@classmethod
|
|
def domain_payable_receivable_date(cls, domain, tables):
|
|
pool = Pool()
|
|
Move = pool.get('account.move')
|
|
table, _ = tables[None]
|
|
move = tables.get('move')
|
|
if move is None:
|
|
move = Move.__table__()
|
|
tables['move'] = {
|
|
None: (move, move.id == table.move),
|
|
}
|
|
else:
|
|
move, _ = move[None]
|
|
_, operator, operand = domain
|
|
Operator = fields.SQL_OPERATORS[operator]
|
|
date = Coalesce(table.maturity_date, move.date)
|
|
return Operator(date, operand)
|
|
|
|
@classmethod
|
|
def order_payable_receivable_date(cls, tables):
|
|
pool = Pool()
|
|
Move = pool.get('account.move')
|
|
table, _ = tables[None]
|
|
move = tables.get('move')
|
|
if move is None:
|
|
move = Move.__table__()
|
|
tables['move'] = {
|
|
None: (move, move.id == table.move),
|
|
}
|
|
else:
|
|
move, _ = move[None]
|
|
return [Coalesce(table.maturity_date, move.date)]
|
|
|
|
@classmethod
|
|
def get_payable_receivable_balance(cls, lines, name):
|
|
pool = Pool()
|
|
Account = pool.get('account.account')
|
|
AccountType = pool.get('account.account.type')
|
|
Move = pool.get('account.move')
|
|
|
|
transaction = Transaction()
|
|
context = transaction.context
|
|
cursor = transaction.connection.cursor()
|
|
|
|
line = cls.__table__()
|
|
account = Account.__table__()
|
|
account_type = AccountType.__table__()
|
|
move = Move.__table__()
|
|
|
|
balances = dict.fromkeys(map(int, lines))
|
|
|
|
date = Coalesce(line.maturity_date, move.date)
|
|
|
|
for company, lines in groupby(lines, lambda l: l.company):
|
|
for sub_lines in grouped_slice(lines):
|
|
sub_lines = list(sub_lines)
|
|
id2currency = {
|
|
l.id: l.second_currency or company.currency
|
|
for l in sub_lines}
|
|
where = account.company == company.id
|
|
where_type = Literal(False)
|
|
if context.get('receivable', True):
|
|
where_type |= account_type.receivable
|
|
if context.get('payable', True):
|
|
where_type |= account_type.payable
|
|
where &= where_type
|
|
if not context.get('reconciled'):
|
|
if hasattr(cls, 'reconciliation'):
|
|
where &= line.reconciliation == Null
|
|
elif hasattr(cls, 'reconciled'):
|
|
where &= ~line.reconciled
|
|
|
|
currency = Coalesce(line.second_currency, company.currency.id)
|
|
sign = Case(
|
|
(account_type.statement == 'income', -1),
|
|
else_=1)
|
|
balance = sign * Case(
|
|
(line.amount_second_currency != Null,
|
|
line.amount_second_currency),
|
|
else_=line.debit - line.credit)
|
|
balance = Sum(balance,
|
|
window=Window(
|
|
[line.party, currency],
|
|
order_by=[date.asc.nulls_first, line.id.desc]))
|
|
|
|
party_where = Literal(False)
|
|
parties = {l.party for l in sub_lines}
|
|
if None in parties:
|
|
party_where |= line.party == parties.discard(None)
|
|
party_where |= reduce_ids(line.party, map(int, parties))
|
|
where &= party_where
|
|
|
|
query = (line
|
|
.join(move, condition=line.move == move.id)
|
|
.join(account, condition=line.account == account.id)
|
|
.join(
|
|
account_type,
|
|
condition=account.type == account_type.id)
|
|
.select(
|
|
line.id.as_('id'), balance.as_('balance'),
|
|
where=where))
|
|
query = query.select(
|
|
query.id, query.balance.as_('balance'),
|
|
where=reduce_ids(query.id, [l.id for l in sub_lines]))
|
|
if backend.name == 'sqlite':
|
|
sqlite_apply_types(query, [None, 'NUMERIC'])
|
|
cursor.execute(*query)
|
|
balances.update(
|
|
(i, id2currency[i].round(a)) for (i, a) in cursor)
|
|
return balances
|
|
|
|
def get_rec_name(self, name):
|
|
pool = Pool()
|
|
Lang = pool.get('ir.lang')
|
|
lang = Lang.get()
|
|
amount = lang.currency(self.amount, self.amount_currency)
|
|
return f'{amount} @ {self.account.code}'
|
|
|
|
@classmethod
|
|
def search_rec_name(cls, name, clause):
|
|
return [('account.code', *clause[1:])]
|
|
|
|
|
|
class Line(DescriptionOriginMixin, MoveLineMixin, ModelSQL, ModelView):
|
|
__name__ = 'account.move.line'
|
|
|
|
_states = {
|
|
'readonly': Eval('move_state') == 'posted',
|
|
}
|
|
|
|
debit = Monetary(
|
|
"Debit", currency='currency', digits='currency', required=True,
|
|
states=_states,
|
|
domain=[
|
|
If(Eval('credit', 0), ('debit', '=', 0), ()),
|
|
],
|
|
depends={'credit', 'tax_lines', 'journal'})
|
|
credit = Monetary(
|
|
"Credit", currency='currency', digits='currency', required=True,
|
|
states=_states,
|
|
domain=[
|
|
If(Eval('debit', 0), ('credit', '=', 0), ()),
|
|
],
|
|
depends={'debit', 'tax_lines', 'journal'})
|
|
account = fields.Many2One('account.account', 'Account', required=True,
|
|
domain=[
|
|
('company', '=', Eval('company', -1)),
|
|
('type', '!=', None),
|
|
('closed', '!=', True),
|
|
['OR',
|
|
('start_date', '=', None),
|
|
('start_date', '<=', Eval('date', None)),
|
|
],
|
|
['OR',
|
|
('end_date', '=', None),
|
|
('end_date', '>=', Eval('date', None)),
|
|
],
|
|
],
|
|
context={
|
|
'company': Eval('company', -1),
|
|
'period': Eval('period', -1),
|
|
},
|
|
states=_states, depends={'company', 'period'})
|
|
move = fields.Many2One(
|
|
'account.move', "Move", required=True,
|
|
ondelete='CASCADE',
|
|
states={
|
|
'required': False,
|
|
'readonly': (((Eval('state') == 'valid') | _states['readonly'])
|
|
& Bool(Eval('move'))),
|
|
})
|
|
journal = fields.Function(fields.Many2One(
|
|
'account.journal', 'Journal',
|
|
states=_states,
|
|
context={
|
|
'company': Eval('company', -1),
|
|
},
|
|
depends={'company'}),
|
|
'get_move_field', setter='set_move_field',
|
|
searcher='search_move_field')
|
|
period = fields.Function(fields.Many2One('account.period', 'Period',
|
|
states=_states),
|
|
'get_move_field', setter='set_move_field',
|
|
searcher='search_move_field')
|
|
company = fields.Function(fields.Many2One(
|
|
'company.company', "Company", states=_states),
|
|
'get_move_field', setter='set_move_field',
|
|
searcher='search_move_field')
|
|
date = fields.Function(fields.Date('Effective Date', required=True,
|
|
states=_states),
|
|
'on_change_with_date', setter='set_move_field',
|
|
searcher='search_move_field')
|
|
origin = fields.Reference(
|
|
"Origin", selection='get_origin', states=_states)
|
|
move_origin = fields.Function(
|
|
fields.Reference("Move Origin", selection='get_move_origin'),
|
|
'get_move_field', searcher='search_move_field')
|
|
description = fields.Char('Description', states=_states)
|
|
move_description_used = fields.Function(
|
|
fields.Char("Move Description", states=_states),
|
|
'get_move_field',
|
|
setter='set_move_field',
|
|
searcher='search_move_field')
|
|
amount_second_currency = Monetary(
|
|
"Amount Second Currency",
|
|
currency='second_currency', digits='second_currency',
|
|
domain=[
|
|
If(Eval('amount_second_currency', 0),
|
|
If((Eval('debit', 0) > 0) | (Eval('credit', 0) < 0),
|
|
('amount_second_currency', '>=', 0),
|
|
If((Eval('debit', 0) < 0) | (Eval('credit', 0) > 0),
|
|
('amount_second_currency', '<=', 0),
|
|
())),
|
|
()),
|
|
],
|
|
states={
|
|
'required': Bool(Eval('second_currency')),
|
|
'readonly': _states['readonly'],
|
|
},
|
|
help='The amount expressed in a second currency.')
|
|
second_currency = fields.Many2One('currency.currency', 'Second Currency',
|
|
help='The second currency.',
|
|
domain=[
|
|
If(~Eval('second_currency_required'),
|
|
('id', '!=', Eval('currency', -1)),
|
|
('id', '=', Eval('second_currency_required', -1))),
|
|
],
|
|
states={
|
|
'required': (Bool(Eval('amount_second_currency'))
|
|
| Bool(Eval('second_currency_required'))),
|
|
'readonly': _states['readonly']
|
|
})
|
|
second_currency_required = fields.Function(
|
|
fields.Many2One('currency.currency', "Second Currency Required"),
|
|
'on_change_with_second_currency_required')
|
|
party = fields.Many2One(
|
|
'party.party', "Party",
|
|
states={
|
|
'required': Eval('party_required', False),
|
|
'invisible': ~Eval('party_required', False),
|
|
'readonly': _states['readonly'],
|
|
},
|
|
context={
|
|
'company': Eval('company', -1),
|
|
},
|
|
depends={'company'}, ondelete='RESTRICT')
|
|
party_required = fields.Function(fields.Boolean('Party Required'),
|
|
'on_change_with_party_required')
|
|
maturity_date = fields.Date(
|
|
"Maturity Date",
|
|
states={
|
|
'invisible': ~Eval('has_maturity_date'),
|
|
},
|
|
depends=['has_maturity_date'],
|
|
help="Set a date to make the line payable or receivable.")
|
|
has_maturity_date = fields.Function(
|
|
fields.Boolean("Has Maturity Date"),
|
|
'on_change_with_has_maturity_date')
|
|
state = fields.Selection([
|
|
('draft', 'Draft'),
|
|
('valid', 'Valid'),
|
|
], 'State', readonly=True, required=True, sort=False)
|
|
reconciliation = fields.Many2One(
|
|
'account.move.reconciliation', 'Reconciliation',
|
|
readonly=True, ondelete='SET NULL')
|
|
reconciliations_delegated = fields.One2Many(
|
|
'account.move.reconciliation', 'delegate_to',
|
|
"Reconciliations Delegated", readonly=True)
|
|
tax_lines = fields.One2Many('account.tax.line', 'move_line', 'Tax Lines')
|
|
move_state = fields.Function(
|
|
fields.Selection('get_move_states', "Move State"),
|
|
'on_change_with_move_state', searcher='search_move_field')
|
|
currency = fields.Function(fields.Many2One(
|
|
'currency.currency', "Currency"),
|
|
'on_change_with_currency', searcher='search_currency')
|
|
amount = fields.Function(Monetary(
|
|
"Amount", currency='amount_currency', digits='amount_currency'),
|
|
'get_amount')
|
|
amount_currency = fields.Function(fields.Many2One('currency.currency',
|
|
'Amount Currency'), 'get_amount_currency')
|
|
delegated_amount = fields.Function(Monetary(
|
|
"Delegated Amount",
|
|
currency='amount_currency', digits='amount_currency',
|
|
states={
|
|
'invisible': (
|
|
~Eval('reconciliation', False)
|
|
| ~Eval('delegated_amount', 0)),
|
|
}),
|
|
'get_delegated_amount')
|
|
|
|
payable_receivable_balance = fields.Function(
|
|
Monetary(
|
|
"Payable/Receivable Balance",
|
|
currency='amount_currency', digits='amount_currency'),
|
|
'get_payable_receivable_balance')
|
|
|
|
del _states
|
|
|
|
@classmethod
|
|
def __setup__(cls):
|
|
super().__setup__()
|
|
cls.__access__.add('move')
|
|
cls._check_modify_exclude = {
|
|
'maturity_date', 'reconciliation', 'tax_lines'}
|
|
cls._reconciliation_modify_disallow = {
|
|
'account', 'debit', 'credit', 'party',
|
|
}
|
|
table = cls.__table__()
|
|
cls._sql_constraints += [
|
|
('credit_debit',
|
|
Check(table, table.credit * table.debit == 0),
|
|
'account.msg_line_debit_credit'),
|
|
('second_currency_sign',
|
|
Check(table, Coalesce(table.amount_second_currency, 0)
|
|
* (table.debit - table.credit) >= 0),
|
|
'account.msg_line_second_currency_sign'),
|
|
]
|
|
cls.__rpc__.update({
|
|
'on_written': RPC(instantiate=0),
|
|
})
|
|
# Do not cache default_date nor default_move
|
|
cls.__rpc__['default_get'].cache = None
|
|
cls._order[0] = ('id', 'DESC')
|
|
cls._sql_indexes.update({
|
|
Index(
|
|
table,
|
|
(table.account, Index.Range()),
|
|
(table.party, Index.Range())),
|
|
Index(table, (table.reconciliation, Index.Range())),
|
|
# Index for General Ledger
|
|
Index(
|
|
table,
|
|
(table.move, Index.Range()),
|
|
(table.account, Index.Range())),
|
|
# Index for account.account.party
|
|
Index(
|
|
table,
|
|
(table.account, Index.Range()),
|
|
(table.party, Index.Range()),
|
|
(table.id, Index.Range(cardinality='high')),
|
|
where=table.party != Null),
|
|
# Index for receivable/payable balance
|
|
Index(
|
|
table,
|
|
(table.account, Index.Range()),
|
|
(table.party, Index.Range()),
|
|
where=table.reconciliation == Null),
|
|
})
|
|
|
|
@classmethod
|
|
def default_date(cls):
|
|
'''
|
|
Return the date of the last line for journal, period
|
|
or the starting date of the period
|
|
or today
|
|
'''
|
|
pool = Pool()
|
|
Period = pool.get('account.period')
|
|
Date = pool.get('ir.date')
|
|
context = Transaction().context
|
|
|
|
date = Date.today()
|
|
lines = cls.search([
|
|
('company', '=', context.get('company')),
|
|
('journal', '=', context.get('journal')),
|
|
('period', '=', context.get('period')),
|
|
],
|
|
order=[('date', 'DESC')], limit=1)
|
|
if lines:
|
|
line, = lines
|
|
date = line.date
|
|
elif context.get('period'):
|
|
period = Period(context['period'])
|
|
if period.start_date >= date:
|
|
date = period.start_date
|
|
else:
|
|
date = period.end_date
|
|
if context.get('date'):
|
|
date = context['date']
|
|
return date
|
|
|
|
@classmethod
|
|
def default_move(cls):
|
|
transaction = Transaction()
|
|
context = transaction.context
|
|
if context.get('journal') and context.get('period'):
|
|
lines = cls.search([
|
|
('company', '=', context.get('company')),
|
|
('move.journal', '=', context['journal']),
|
|
('move.period', '=', context['period']),
|
|
('create_uid', '=', transaction.user),
|
|
('state', '=', 'draft'),
|
|
], order=[('id', 'DESC')], limit=1)
|
|
if lines:
|
|
line, = lines
|
|
return line.move.id
|
|
|
|
@fields.depends(
|
|
'move', 'debit', 'credit',
|
|
'_parent_move.lines', '_parent_move.company')
|
|
def on_change_move(self):
|
|
if self.move:
|
|
if not self.debit and not self.credit:
|
|
total = sum((l.debit or 0) - (l.credit or 0)
|
|
for l in getattr(self.move, 'lines', []))
|
|
self.debit = -total if total < 0 else Decimal(0)
|
|
self.credit = total if total > 0 else Decimal(0)
|
|
self.company = self.move.company
|
|
|
|
@classmethod
|
|
def default_company(cls):
|
|
return Transaction().context.get('company')
|
|
|
|
@staticmethod
|
|
def default_state():
|
|
return 'draft'
|
|
|
|
@staticmethod
|
|
def default_debit():
|
|
return Decimal(0)
|
|
|
|
@staticmethod
|
|
def default_credit():
|
|
return Decimal(0)
|
|
|
|
@fields.depends('account')
|
|
def on_change_with_currency(self, name=None):
|
|
return self.account.currency if self.account else None
|
|
|
|
@classmethod
|
|
def search_currency(cls, name, clause):
|
|
return [('account.company.' + clause[0], * clause[1:])]
|
|
|
|
@classmethod
|
|
def _get_origin(cls):
|
|
'Return list of Model names for origin Reference'
|
|
return ['account.move.line']
|
|
|
|
@classmethod
|
|
def get_origin(cls):
|
|
Model = Pool().get('ir.model')
|
|
get_name = Model.get_name
|
|
models = cls._get_origin()
|
|
return [(None, '')] + [(m, get_name(m)) for m in models]
|
|
|
|
@property
|
|
def origin_rec_name(self):
|
|
if self.origin:
|
|
return self.origin.rec_name
|
|
elif self.move_origin:
|
|
return self.move_origin.rec_name
|
|
else:
|
|
return ''
|
|
|
|
@fields.depends('debit', 'credit', 'amount_second_currency')
|
|
def on_change_debit(self):
|
|
if self.debit:
|
|
self.credit = Decimal(0)
|
|
self._amount_second_currency_sign()
|
|
|
|
@fields.depends('debit', 'credit', 'amount_second_currency')
|
|
def on_change_credit(self):
|
|
if self.credit:
|
|
self.debit = Decimal(0)
|
|
self._amount_second_currency_sign()
|
|
|
|
@fields.depends('amount_second_currency', 'debit', 'credit')
|
|
def on_change_amount_second_currency(self):
|
|
self._amount_second_currency_sign()
|
|
|
|
def _amount_second_currency_sign(self):
|
|
'Set correct sign to amount_second_currency'
|
|
if self.amount_second_currency:
|
|
self.amount_second_currency = \
|
|
self.amount_second_currency.copy_sign(
|
|
(self.debit or 0) - (self.credit or 0))
|
|
|
|
@fields.depends('account')
|
|
def on_change_account(self):
|
|
if self.account:
|
|
if self.account.second_currency:
|
|
self.second_currency = self.account.second_currency
|
|
if not self.account.party_required:
|
|
self.party = None
|
|
|
|
@fields.depends('account')
|
|
def on_change_with_second_currency_required(self, name=None):
|
|
if self.account and self.account.second_currency:
|
|
return self.account.second_currency.id
|
|
|
|
@fields.depends('account')
|
|
def on_change_with_party_required(self, name=None):
|
|
if self.account:
|
|
return self.account.party_required
|
|
return False
|
|
|
|
@fields.depends('move', '_parent_move.date')
|
|
def on_change_with_date(self, name=None):
|
|
if self.move:
|
|
return self.move.date
|
|
|
|
@fields.depends('move', '_parent_move.state')
|
|
def on_change_with_move_state(self, name=None):
|
|
if self.move:
|
|
return self.move.state
|
|
|
|
@classmethod
|
|
def order_maturity_date(self, tables):
|
|
pool = Pool()
|
|
Move = pool.get('account.move')
|
|
table, _ = tables[None]
|
|
if 'move' not in tables:
|
|
move = Move.__table__()
|
|
tables['move'] = {
|
|
None: (move, table.move == move.id),
|
|
}
|
|
else:
|
|
move, _ = tables['move'][None]
|
|
return [Coalesce(table.maturity_date, move.date)]
|
|
|
|
@fields.depends('account')
|
|
def on_change_with_has_maturity_date(self, name=None):
|
|
if self.account:
|
|
type_ = self.account.type
|
|
return type_.receivable or type_.payable
|
|
|
|
order_journal = MoveLineMixin._order_move_field('journal')
|
|
order_period = MoveLineMixin._order_move_field('period')
|
|
order_company = MoveLineMixin._order_move_field('company')
|
|
order_date = MoveLineMixin._order_move_field('date')
|
|
order_move_origin = MoveLineMixin._order_move_field('origin')
|
|
order_move_state = MoveLineMixin._order_move_field('state')
|
|
|
|
def get_delegated_amount(self, name):
|
|
def final_delegated_line(line):
|
|
if not line.reconciliation or not line.reconciliation.delegate_to:
|
|
return line
|
|
return final_delegated_line(line.reconciliation.delegate_to)
|
|
|
|
final_delegation = final_delegated_line(self)
|
|
if final_delegation == self:
|
|
return None
|
|
elif final_delegation.reconciliation:
|
|
return final_delegation.amount_currency.round(0)
|
|
else:
|
|
return final_delegation.amount
|
|
|
|
@classmethod
|
|
def query_get(cls, table):
|
|
'''
|
|
Return SQL clause and fiscal years for account move line
|
|
depending of the context.
|
|
table is the SQL instance of account.move.line table
|
|
'''
|
|
pool = Pool()
|
|
FiscalYear = pool.get('account.fiscalyear')
|
|
Move = pool.get('account.move')
|
|
Period = pool.get('account.period')
|
|
move = Move.__table__()
|
|
period = Period.__table__()
|
|
fiscalyear = FiscalYear.__table__()
|
|
context = Transaction().context
|
|
company = context.get('company')
|
|
|
|
fiscalyear_ids = []
|
|
where = Literal(True)
|
|
|
|
if context.get('posted'):
|
|
where &= move.state == 'posted'
|
|
|
|
if context.get('journal'):
|
|
where &= move.journal == context['journal']
|
|
|
|
date = context.get('date')
|
|
from_date, to_date = context.get('from_date'), context.get('to_date')
|
|
fiscalyear_id = context.get('fiscalyear')
|
|
period_ids = context.get('periods')
|
|
if date:
|
|
fiscalyears = FiscalYear.search([
|
|
('start_date', '<=', date),
|
|
('end_date', '>=', date),
|
|
('company', '=', company),
|
|
],
|
|
order=[('start_date', 'DESC')],
|
|
limit=1)
|
|
if fiscalyears:
|
|
fiscalyear_id = fiscalyears[0].id
|
|
else:
|
|
fiscalyear_id = -1
|
|
fiscalyear_ids = list(map(int, fiscalyears))
|
|
where &= period.fiscalyear == fiscalyear_id
|
|
where &= move.date <= date
|
|
elif fiscalyear_id or period_ids is not None or from_date or to_date:
|
|
if fiscalyear_id:
|
|
fiscalyear_ids = [fiscalyear_id]
|
|
where &= fiscalyear.id == fiscalyear_id
|
|
if period_ids is not None:
|
|
where &= move.period.in_(period_ids or [None])
|
|
if from_date:
|
|
where &= move.date >= from_date
|
|
if to_date:
|
|
where &= move.date <= to_date
|
|
else:
|
|
where &= fiscalyear.state == 'open'
|
|
where &= fiscalyear.company == company
|
|
fiscalyears = FiscalYear.search([
|
|
('state', '=', 'open'),
|
|
('company', '=', company),
|
|
])
|
|
fiscalyear_ids = list(map(int, fiscalyears))
|
|
|
|
# Use LEFT JOIN to allow database optimization
|
|
# if no joined table is used in the where clause.
|
|
return (table.move.in_(move
|
|
.join(period, 'LEFT', condition=move.period == period.id)
|
|
.join(fiscalyear, 'LEFT',
|
|
condition=period.fiscalyear == fiscalyear.id)
|
|
.select(move.id, where=where)),
|
|
fiscalyear_ids)
|
|
|
|
@classmethod
|
|
def on_written(cls, lines):
|
|
return list(set(l.id for line in lines for l in line.move.lines))
|
|
|
|
@classmethod
|
|
def validate_fields(cls, lines, field_names):
|
|
super().validate(lines)
|
|
cls.check_account(lines, field_names)
|
|
|
|
@classmethod
|
|
def check_account(cls, lines, field_names=None):
|
|
if field_names and not (field_names & {'account', 'party'}):
|
|
return
|
|
for line in lines:
|
|
if not line.account.type or line.account.closed:
|
|
raise AccessError(
|
|
gettext('account.msg_line_closed_account',
|
|
account=line.account.rec_name))
|
|
if bool(line.party) != bool(line.account.party_required):
|
|
error = 'party_set' if line.party else 'party_required'
|
|
raise AccessError(
|
|
gettext('account.msg_line_%s' % error,
|
|
account=line.account.rec_name,
|
|
line=line.rec_name))
|
|
|
|
@classmethod
|
|
def check_journal_period_modify(cls, period, journal):
|
|
'''
|
|
Check if the lines can be modified or created for the journal - period
|
|
and if there is no journal - period, create it
|
|
'''
|
|
JournalPeriod = Pool().get('account.journal.period')
|
|
journal_periods = JournalPeriod.search([
|
|
('journal', '=', journal.id),
|
|
('period', '=', period.id),
|
|
], limit=1)
|
|
if journal_periods:
|
|
journal_period, = journal_periods
|
|
if journal_period.state == 'closed':
|
|
raise AccessError(
|
|
gettext('account.msg_modify_line_closed_journal_period',
|
|
journal_period=journal_period.rec_name))
|
|
else:
|
|
JournalPeriod.lock()
|
|
JournalPeriod.create([{
|
|
'journal': journal.id,
|
|
'period': period.id,
|
|
}])
|
|
|
|
@classmethod
|
|
def check_modification(cls, mode, lines, values=None, external=False):
|
|
pool = Pool()
|
|
Move = pool.get('account.move')
|
|
|
|
super().check_modification(
|
|
mode, lines, values=values, external=external)
|
|
|
|
if (mode in {'create', 'delete'}
|
|
or (mode == 'write'
|
|
and values.keys() - cls._check_modify_exclude)):
|
|
journal_period_done = set()
|
|
for line in lines:
|
|
if line.move.state == 'posted':
|
|
raise AccessError(gettext(
|
|
'account.msg_modify_line_posted_move',
|
|
line=line.rec_name,
|
|
move=line.move.rec_name))
|
|
journal_period = (line.journal.id, line.period.id)
|
|
if journal_period not in journal_period_done:
|
|
cls.check_journal_period_modify(
|
|
line.period, line.journal)
|
|
journal_period_done.add(journal_period)
|
|
|
|
if mode == 'delete':
|
|
for line in lines:
|
|
if line.reconciliation:
|
|
raise AccessError(gettext(
|
|
'account.msg_delete_line_reconciled',
|
|
line=line.rec_name))
|
|
if (mode == 'write'
|
|
and values.keys() & cls._reconciliation_modify_disallow):
|
|
for line in lines:
|
|
if line.reconciliation:
|
|
raise AccessError(gettext(
|
|
'account.msg_modify_line_reconciled',
|
|
line=line.rec_name))
|
|
|
|
if mode in {'create', 'write'}:
|
|
moves = Move.browse({l.move for l in lines})
|
|
Move.validate_move(moves)
|
|
|
|
@classmethod
|
|
def on_delete(cls, lines):
|
|
pool = Pool()
|
|
Move = pool.get('account.move')
|
|
callback = super().on_delete(lines)
|
|
moves = Move.browse({l.move for l in lines})
|
|
callback.append(lambda: Move.validate_move(moves))
|
|
return callback
|
|
|
|
@classmethod
|
|
def view_attributes(cls):
|
|
attributes = super().view_attributes()
|
|
view_ids = cls._view_reconciliation_muted()
|
|
if Transaction().context.get('view_id') in view_ids:
|
|
attributes.append(
|
|
('/tree', 'visual',
|
|
If(Bool(Eval('reconciliation')), 'muted', '')))
|
|
return attributes
|
|
|
|
@classmethod
|
|
def _view_reconciliation_muted(cls):
|
|
pool = Pool()
|
|
ModelData = pool.get('ir.model.data')
|
|
return {ModelData.get_id(
|
|
'account', 'move_line_view_list_payable_receivable')}
|
|
|
|
@classmethod
|
|
def create(cls, vlist):
|
|
pool = Pool()
|
|
Move = pool.get('account.move')
|
|
|
|
def move_fields(move_name):
|
|
for fname, field in cls._fields.items():
|
|
if (isinstance(field, fields.Function)
|
|
and field.setter == 'set_move_field'):
|
|
if move_name and fname.startswith('move_'):
|
|
fname = fname[5:]
|
|
yield fname
|
|
|
|
moves = {}
|
|
context = Transaction().context
|
|
vlist = [x.copy() for x in vlist]
|
|
for vals in vlist:
|
|
if not vals.get('move'):
|
|
move_values = {}
|
|
for fname in move_fields(move_name=True):
|
|
move_values[fname] = vals.get(fname) or context.get(fname)
|
|
key = tuple(sorted(move_values.items()))
|
|
move = moves.get(key)
|
|
if move is None:
|
|
move = Move(**move_values)
|
|
move.save()
|
|
moves[key] = move
|
|
vals['move'] = move.id
|
|
else:
|
|
# prevent default value for field with set_move_field
|
|
for fname in move_fields(move_name=False):
|
|
vals.setdefault(fname, None)
|
|
return super().create(vlist)
|
|
|
|
@classmethod
|
|
def copy(cls, lines, default=None):
|
|
if default is None:
|
|
default = {}
|
|
else:
|
|
default = default.copy()
|
|
default.setdefault('move', None)
|
|
default.setdefault('reconciliation', None)
|
|
default.setdefault('reconciliations_delegated', [])
|
|
return super().copy(lines, default=default)
|
|
|
|
@classmethod
|
|
def view_toolbar_get(cls):
|
|
pool = Pool()
|
|
Template = pool.get('account.move.template')
|
|
|
|
toolbar = super().view_toolbar_get()
|
|
|
|
# Add a wizard entry for each templates
|
|
context = Transaction().context
|
|
company = context.get('company')
|
|
journal = context.get('journal')
|
|
period = context.get('period')
|
|
if company and journal and period:
|
|
templates = Template.search([
|
|
('company', '=', company),
|
|
('journal', '=', journal),
|
|
])
|
|
for template in templates:
|
|
action = toolbar['action']
|
|
# Use template id for action id to auto-select the template
|
|
action.append({
|
|
'name': template.name,
|
|
'type': 'ir.action.wizard',
|
|
'wiz_name': 'account.move.template.create',
|
|
'id': template.id,
|
|
})
|
|
return toolbar
|
|
|
|
@classmethod
|
|
def reconcile(
|
|
cls, *lines_list, date=None, writeoff=None, description=None,
|
|
delegate_to=None):
|
|
"""
|
|
Reconcile each list of lines together.
|
|
The writeoff keys are: date, method and description.
|
|
"""
|
|
pool = Pool()
|
|
Reconciliation = pool.get('account.move.reconciliation')
|
|
Move = pool.get('account.move')
|
|
Lang = pool.get('ir.lang')
|
|
lang = Lang.get()
|
|
delegate_to = delegate_to.id if delegate_to else None
|
|
|
|
reconciliations = []
|
|
to_post = []
|
|
for lines in lines_list:
|
|
if not lines:
|
|
continue
|
|
for line in lines:
|
|
if line.reconciliation:
|
|
raise AccessError(
|
|
gettext('account.msg_line_already_reconciled',
|
|
line=line.rec_name))
|
|
|
|
lines = list(lines)
|
|
reconcile_account = None
|
|
reconcile_party = None
|
|
amount = Decimal(0)
|
|
amount_second_currency = Decimal(0)
|
|
second_currencies = set()
|
|
posted = True
|
|
for line in lines:
|
|
posted &= line.move.state == 'posted'
|
|
amount += line.debit - line.credit
|
|
if not reconcile_account:
|
|
reconcile_account = line.account
|
|
if not reconcile_party:
|
|
reconcile_party = line.party
|
|
if line.amount_second_currency is not None:
|
|
amount_second_currency += line.amount_second_currency
|
|
second_currencies.add(line.second_currency)
|
|
company = reconcile_account.company
|
|
|
|
try:
|
|
second_currency, = second_currencies
|
|
except ValueError:
|
|
amount_second_currency = None
|
|
second_currency = None
|
|
if second_currency:
|
|
writeoff_amount = amount_second_currency
|
|
writeoff_currency = second_currency
|
|
else:
|
|
writeoff_amount = amount
|
|
writeoff_currency = company.currency
|
|
if writeoff_amount:
|
|
if not writeoff:
|
|
raise ReconciliationError(gettext(
|
|
'account.msg_reconciliation_write_off_missing',
|
|
amount=lang.currency(
|
|
writeoff_amount, writeoff_currency)))
|
|
move = cls._get_writeoff_move(
|
|
reconcile_account, reconcile_party,
|
|
writeoff_amount, writeoff_currency,
|
|
writeoff, date=date, description=description)
|
|
move.save()
|
|
if posted:
|
|
to_post.append(move)
|
|
for line in move.lines:
|
|
if line.account == reconcile_account:
|
|
lines.append(line)
|
|
amount += line.debit - line.credit
|
|
if second_currency and amount:
|
|
move = cls._get_exchange_move(
|
|
reconcile_account, reconcile_party, amount, date)
|
|
move.save()
|
|
if posted:
|
|
to_post.append(move)
|
|
for line in move.lines:
|
|
if line.account == reconcile_account:
|
|
lines.append(line)
|
|
amount += line.debit - line.credit
|
|
assert not amount, f"{amount} must be zero"
|
|
reconciliations.append({
|
|
'company': reconcile_account.company,
|
|
'lines': [('add', [x.id for x in lines])],
|
|
'date': max(l.date for l in lines),
|
|
'delegate_to': delegate_to,
|
|
})
|
|
if to_post:
|
|
Move.post(to_post)
|
|
return Reconciliation.create(reconciliations)
|
|
|
|
@classmethod
|
|
def _get_writeoff_move(
|
|
cls, reconcile_account, reconcile_party, amount, currency,
|
|
writeoff, date=None, description=None):
|
|
pool = Pool()
|
|
Currency = pool.get('currency.currency')
|
|
Date = pool.get('ir.date')
|
|
Period = pool.get('account.period')
|
|
Move = pool.get('account.move')
|
|
company = reconcile_account.company
|
|
if not date:
|
|
with Transaction().set_context(company=company.id):
|
|
date = Date.today()
|
|
period = Period.find(reconcile_account.company, date=date)
|
|
if amount >= 0:
|
|
account = writeoff.debit_account
|
|
else:
|
|
account = writeoff.credit_account
|
|
if account == reconcile_account:
|
|
raise ReconciliationError(gettext(
|
|
'account.msg_reconciliation_write_off_same_account',
|
|
write_off=writeoff.rec_name,
|
|
account=account.rec_name))
|
|
|
|
journal = writeoff.journal
|
|
|
|
if currency != company.currency:
|
|
amount_second_currency = amount
|
|
second_currency = currency
|
|
with Transaction().set_context(date=date):
|
|
amount = Currency.compute(
|
|
currency, amount, company.currency)
|
|
else:
|
|
amount_second_currency = None
|
|
second_currency = None
|
|
|
|
move = Move()
|
|
move.company = company
|
|
move.journal = journal
|
|
move.period = period
|
|
move.date = date
|
|
move.description = description
|
|
|
|
lines = []
|
|
|
|
line = cls()
|
|
lines.append(line)
|
|
line.account = reconcile_account
|
|
line.party = reconcile_party
|
|
line.debit = -amount if amount < 0 else 0
|
|
line.credit = amount if amount > 0 else 0
|
|
if second_currency:
|
|
line.amount_second_currency = (
|
|
amount_second_currency).copy_sign(
|
|
line.debit - line.credit)
|
|
line.second_currency = second_currency
|
|
|
|
line = cls()
|
|
lines.append(line)
|
|
line.account = account
|
|
line.party = (
|
|
reconcile_party if account.party_required else None)
|
|
line.debit = amount if amount > 0 else 0
|
|
line.credit = -amount if amount < 0 else 0
|
|
if account.second_currency:
|
|
with Transaction().set_context(date=date):
|
|
line.amount_second_currency = Currency.compute(
|
|
company.currency, amount,
|
|
reconcile_account.second_currency).copy_sign(
|
|
line.debit - line.credit)
|
|
|
|
move.lines = lines
|
|
return move
|
|
|
|
@classmethod
|
|
def _get_exchange_move(cls, account, party, amount, date=None):
|
|
pool = Pool()
|
|
Configuration = pool.get('account.configuration')
|
|
Date = pool.get('ir.date')
|
|
Move = pool.get('account.move')
|
|
Period = pool.get('account.period')
|
|
|
|
configuration = Configuration(1)
|
|
company = account.company
|
|
if not date:
|
|
with Transaction().set_context(company=company.id):
|
|
date = Date.today()
|
|
|
|
period_id = Period.find(company.id, date=date)
|
|
|
|
move = Move()
|
|
move.company = company
|
|
move.journal = configuration.get_multivalue(
|
|
'currency_exchange_journal', company=company.id)
|
|
if not move.journal:
|
|
raise JournalMissing(gettext(
|
|
'account.'
|
|
'msg_reconciliation_currency_exchange_journal_missing',
|
|
company=company.rec_name))
|
|
move.period = period_id
|
|
move.date = date
|
|
|
|
lines = []
|
|
|
|
line = cls()
|
|
lines.append(line)
|
|
line.account = account
|
|
line.party = party
|
|
line.debit = -amount if amount < 0 else 0
|
|
line.credit = amount if amount > 0 else 0
|
|
|
|
line = cls()
|
|
lines.append(line)
|
|
line.debit = amount if amount > 0 else 0
|
|
line.credit = -amount if amount < 0 else 0
|
|
if line.credit:
|
|
line.account = configuration.get_multivalue(
|
|
'currency_exchange_credit_account', company=company.id)
|
|
if not line.account:
|
|
raise AccountMissing(gettext(
|
|
'account.'
|
|
'msg_reconciliation_currency_exchange_'
|
|
'credit_account_missing',
|
|
company=company.rec_name))
|
|
else:
|
|
line.account = configuration.get_multivalue(
|
|
'currency_exchange_debit_account', company=company.id)
|
|
if not line.account:
|
|
raise AccountMissing(gettext(
|
|
'account.'
|
|
'msg_reconciliation_currency_exchange_'
|
|
'debit_account_missing',
|
|
company=company.rec_name))
|
|
|
|
move.lines = lines
|
|
return move
|
|
|
|
@classmethod
|
|
def find_best_reconciliation(cls, lines, currency, amount=0):
|
|
"""Return the list of lines to reconcile for the amount
|
|
with the smallest remaining.
|
|
|
|
For performance reason it is an approximation that searches for the
|
|
smallest remaining by removing the last line that decrease it."""
|
|
assert len({l.account for l in lines}) <= 1
|
|
|
|
def get_balance(line):
|
|
if line.second_currency == currency:
|
|
return line.amount_second_currency
|
|
elif line.currency == currency:
|
|
return line.debit - line.credit
|
|
else:
|
|
return 0
|
|
|
|
with Transaction().set_context(_record_cache_size=max(len(lines), 1)):
|
|
lines = cls.browse(sorted(lines, key=cls._reconciliation_sort_key))
|
|
debit, credit = [], []
|
|
remaining = -amount
|
|
for line in list(lines):
|
|
balance = get_balance(line)
|
|
if balance > 0:
|
|
debit.append(line)
|
|
elif balance < 0:
|
|
credit.append(line)
|
|
else:
|
|
lines.remove(line)
|
|
remaining += balance
|
|
|
|
best_lines, best_remaining = list(lines), remaining
|
|
if remaining:
|
|
while lines:
|
|
try:
|
|
line = (debit if remaining > 0 else credit).pop()
|
|
except IndexError:
|
|
break
|
|
lines.remove(line)
|
|
remaining -= get_balance(line)
|
|
if lines and abs(remaining) < abs(best_remaining):
|
|
best_lines, best_remaining = list(lines), remaining
|
|
if not remaining:
|
|
break
|
|
return best_lines, best_remaining
|
|
|
|
def _reconciliation_sort_key(self):
|
|
return self.maturity_date or self.date
|
|
|
|
|
|
class LineReceivablePayableContext(ModelView):
|
|
__name__ = 'account.move.line.receivable_payable.context'
|
|
|
|
company = fields.Many2One('company.company', "Company", required=True)
|
|
reconciled = fields.Boolean("Reconciled")
|
|
receivable = fields.Boolean("Receivable")
|
|
payable = fields.Boolean("Payable")
|
|
|
|
@classmethod
|
|
def default_company(cls):
|
|
return Transaction().context.get('company')
|
|
|
|
@classmethod
|
|
def default_reconciled(cls):
|
|
return Transaction().context.get('reconciled', False)
|
|
|
|
@classmethod
|
|
def default_receivable(cls):
|
|
return Transaction().context.get('receivable', True)
|
|
|
|
@classmethod
|
|
def default_payable(cls):
|
|
return Transaction().context.get('payable', True)
|
|
|
|
|
|
class WriteOff(DeactivableMixin, ModelSQL, ModelView):
|
|
__name__ = 'account.move.reconcile.write_off'
|
|
company = fields.Many2One('company.company', "Company", required=True)
|
|
name = fields.Char("Name", required=True, translate=True)
|
|
journal = fields.Many2One('account.journal', "Journal", required=True,
|
|
domain=[('type', '=', 'write-off')],
|
|
context={
|
|
'company': Eval('company', -1),
|
|
},
|
|
depends={'company'})
|
|
credit_account = fields.Many2One('account.account', "Credit Account",
|
|
required=True,
|
|
domain=[
|
|
('type', '!=', None),
|
|
('closed', '!=', True),
|
|
('company', '=', Eval('company', -1)),
|
|
])
|
|
debit_account = fields.Many2One('account.account', "Debit Account",
|
|
required=True,
|
|
domain=[
|
|
('type', '!=', None),
|
|
('closed', '!=', True),
|
|
('company', '=', Eval('company', -1)),
|
|
])
|
|
|
|
@classmethod
|
|
def __setup__(cls):
|
|
super().__setup__()
|
|
cls._order.insert(0, ('name', 'ASC'))
|
|
|
|
@classmethod
|
|
def default_company(cls):
|
|
return Transaction().context.get('company')
|
|
|
|
|
|
class OpenJournalAsk(ModelView):
|
|
__name__ = 'account.move.open_journal.ask'
|
|
company = fields.Many2One('company.company', "Company", required=True)
|
|
journal = fields.Many2One(
|
|
'account.journal', 'Journal', required=True,
|
|
context={
|
|
'company': Eval('company', None),
|
|
})
|
|
period = fields.Many2One('account.period', 'Period', required=True,
|
|
domain=[
|
|
('company', '=', Eval('company', -1)),
|
|
('state', '!=', 'closed'),
|
|
])
|
|
|
|
@classmethod
|
|
def default_company(cls):
|
|
return Transaction().context.get('company')
|
|
|
|
@classmethod
|
|
def default_period(cls):
|
|
pool = Pool()
|
|
Period = pool.get('account.period')
|
|
if company := cls.default_company():
|
|
try:
|
|
period = Period.find(company)
|
|
except PeriodNotFoundError:
|
|
return None
|
|
return period.id
|
|
|
|
|
|
class OpenJournal(Wizard):
|
|
__name__ = 'account.move.open_journal'
|
|
_readonly = True
|
|
start = StateTransition()
|
|
ask = StateView('account.move.open_journal.ask',
|
|
'account.open_journal_ask_view_form', [
|
|
Button('Cancel', 'end', 'tryton-cancel'),
|
|
Button('Open', 'open_', 'tryton-ok', default=True),
|
|
])
|
|
open_ = StateAction('account.act_move_line_form')
|
|
|
|
def transition_start(self):
|
|
if (self.model
|
|
and self.model.__name__ == 'account.journal.period'
|
|
and self.record):
|
|
return 'open_'
|
|
return 'ask'
|
|
|
|
def default_ask(self, fields):
|
|
if (self.model
|
|
and self.model.__name__ == 'account.journal.period'
|
|
and self.record):
|
|
return {
|
|
'company': self.record.company.id,
|
|
'journal': self.record.journal.id,
|
|
'period': self.record.period.id,
|
|
}
|
|
return {}
|
|
|
|
def do_open_(self, action):
|
|
JournalPeriod = Pool().get('account.journal.period')
|
|
|
|
if (self.model
|
|
and self.model.__name__ == 'account.journal.period'
|
|
and self.record):
|
|
journal = self.record.journal
|
|
period = self.record.period
|
|
else:
|
|
journal = self.ask.journal
|
|
period = self.ask.period
|
|
journal_periods = JournalPeriod.search([
|
|
('journal', '=', journal.id),
|
|
('period', '=', period.id),
|
|
], limit=1)
|
|
if not journal_periods:
|
|
journal_period, = JournalPeriod.create([{
|
|
'journal': journal.id,
|
|
'period': period.id,
|
|
}])
|
|
else:
|
|
journal_period, = journal_periods
|
|
|
|
action['name'] += ' (%s)' % journal_period.rec_name
|
|
action['pyson_domain'] = PYSONEncoder().encode([
|
|
('journal', '=', journal.id),
|
|
('period', '=', period.id),
|
|
('company', '=', period.company.id),
|
|
])
|
|
action['pyson_context'] = PYSONEncoder().encode({
|
|
'journal': journal.id,
|
|
'period': period.id,
|
|
'company': period.company.id,
|
|
})
|
|
return action, {}
|
|
|
|
def transition_open_(self):
|
|
return 'end'
|
|
|
|
|
|
class OpenAccount(Wizard):
|
|
__name__ = 'account.move.open_account'
|
|
start_state = 'open_'
|
|
_readonly = True
|
|
open_ = StateAction('account.act_move_line_form')
|
|
|
|
def do_open_(self, action):
|
|
pool = Pool()
|
|
FiscalYear = pool.get('account.fiscalyear')
|
|
context = Transaction().context
|
|
|
|
company_id = self.record.company.id if self.record else -1
|
|
date = context.get('date')
|
|
fiscalyear = context.get('fiscalyear')
|
|
if date:
|
|
fiscalyears = FiscalYear.search([
|
|
('start_date', '<=', date),
|
|
('end_date', '>=', date),
|
|
('company', '=', company_id),
|
|
],
|
|
order=[('start_date', 'DESC')],
|
|
limit=1)
|
|
elif fiscalyear:
|
|
fiscalyears = [FiscalYear(fiscalyear)]
|
|
else:
|
|
fiscalyears = FiscalYear.search([
|
|
('state', '=', 'open'),
|
|
('company', '=', company_id),
|
|
])
|
|
periods = [p for f in fiscalyears for p in f.periods]
|
|
|
|
action['pyson_domain'] = [
|
|
('period', 'in', [p.id for p in periods]),
|
|
('account', '=', self.record.id if self.record else None),
|
|
('state', '=', 'valid'),
|
|
]
|
|
if Transaction().context.get('posted'):
|
|
action['pyson_domain'].append(('move.state', '=', 'posted'))
|
|
if Transaction().context.get('date'):
|
|
action['pyson_domain'].append(('move.date', '<=',
|
|
Transaction().context['date']))
|
|
if self.record:
|
|
action['name'] += ' (%s)' % self.record.rec_name
|
|
action['pyson_domain'] = PYSONEncoder().encode(action['pyson_domain'])
|
|
action['pyson_context'] = PYSONEncoder().encode({
|
|
'fiscalyear': Transaction().context.get('fiscalyear'),
|
|
})
|
|
return action, {}
|
|
|
|
|
|
class ReconcileLinesWriteOff(ModelView):
|
|
__name__ = 'account.move.reconcile_lines.writeoff'
|
|
company = fields.Many2One('company.company', "Company", readonly=True)
|
|
writeoff = fields.Many2One('account.move.reconcile.write_off', "Write Off",
|
|
required=True,
|
|
domain=[
|
|
('company', '=', Eval('company', -1)),
|
|
])
|
|
date = fields.Date('Date', required=True)
|
|
amount = Monetary(
|
|
"Amount", currency='currency', digits='currency', readonly=True)
|
|
currency = fields.Many2One('currency.currency', "Currency", readonly=True)
|
|
description = fields.Char('Description')
|
|
|
|
@staticmethod
|
|
def default_date():
|
|
Date = Pool().get('ir.date')
|
|
return Date.today()
|
|
|
|
|
|
class ReconcileLines(Wizard):
|
|
__name__ = 'account.move.reconcile_lines'
|
|
start = StateTransition()
|
|
writeoff = StateView('account.move.reconcile_lines.writeoff',
|
|
'account.reconcile_lines_writeoff_view_form', [
|
|
Button('Cancel', 'end', 'tryton-cancel'),
|
|
Button('Reconcile', 'reconcile', 'tryton-ok', default=True),
|
|
])
|
|
reconcile = StateTransition()
|
|
|
|
@classmethod
|
|
def check_access(cls, models=None):
|
|
transaction = Transaction()
|
|
if transaction.user:
|
|
context = transaction.context
|
|
models = set() if models is None else models.copy()
|
|
models.update({'account.move.line', 'account.general_ledger.line'})
|
|
model = context.get('active_model')
|
|
if model and model not in models:
|
|
raise AccessError(gettext(
|
|
'ir.msg_access_wizard_model_error',
|
|
wizard=cls.__name__,
|
|
model=model))
|
|
super().check_access()
|
|
|
|
def get_writeoff(self):
|
|
"Return writeoff amount and company"
|
|
company = currency = None
|
|
amount = Decimal(0)
|
|
amount_second_currency = Decimal(0)
|
|
second_currencies = set()
|
|
for line in self.records:
|
|
amount += line.debit - line.credit
|
|
if line.amount_second_currency is not None:
|
|
amount_second_currency += line.amount_second_currency
|
|
second_currencies.add(line.second_currency)
|
|
if not company:
|
|
company = line.account.company
|
|
currency = company.currency
|
|
try:
|
|
second_currency, = second_currencies
|
|
except ValueError:
|
|
second_currency = None
|
|
if second_currency:
|
|
amount = amount_second_currency
|
|
currency = second_currency
|
|
return amount, currency, company
|
|
|
|
def transition_start(self):
|
|
amount, currency, company = self.get_writeoff()
|
|
if not company:
|
|
return 'end'
|
|
if currency.is_zero(amount):
|
|
return 'reconcile'
|
|
return 'writeoff'
|
|
|
|
def default_writeoff(self, fields):
|
|
amount, currency, company = self.get_writeoff()
|
|
return {
|
|
'amount': amount,
|
|
'currency': currency.id,
|
|
'company': company.id,
|
|
}
|
|
|
|
def transition_reconcile(self):
|
|
self.model.reconcile(
|
|
self.records,
|
|
writeoff=self.writeoff.writeoff,
|
|
date=self.writeoff.date,
|
|
description=self.writeoff.description)
|
|
return 'end'
|
|
|
|
|
|
class UnreconcileLines(Wizard):
|
|
__name__ = 'account.move.unreconcile_lines'
|
|
start_state = 'unreconcile'
|
|
unreconcile = StateTransition()
|
|
|
|
@classmethod
|
|
def check_access(cls, models=None):
|
|
transaction = Transaction()
|
|
if transaction.user:
|
|
context = transaction.context
|
|
models = set() if models is None else models.copy()
|
|
models.update({'account.move.line', 'account.general_ledger.line'})
|
|
model = context.get('active_model')
|
|
if model and model not in models:
|
|
raise AccessError(gettext(
|
|
'ir.msg_access_wizard_model_error',
|
|
wizard=cls.__name__,
|
|
model=model))
|
|
super().check_access()
|
|
|
|
def transition_unreconcile(self):
|
|
self.make_unreconciliation(self.records)
|
|
return 'end'
|
|
|
|
@classmethod
|
|
def make_unreconciliation(cls, lines):
|
|
pool = Pool()
|
|
Reconciliation = pool.get('account.move.reconciliation')
|
|
|
|
reconciliations = [x.reconciliation for x in lines if x.reconciliation]
|
|
if reconciliations:
|
|
Reconciliation.delete(reconciliations)
|
|
|
|
|
|
class Reconcile(Wizard):
|
|
__name__ = 'account.reconcile'
|
|
start = StateView(
|
|
'account.reconcile.start',
|
|
'account.reconcile_start_view_form', [
|
|
Button("Cancel", 'end', 'tryton-cancel'),
|
|
Button("Reconcile", 'setup', 'tryton-ok', default=True),
|
|
])
|
|
setup = StateTransition()
|
|
next_ = StateTransition()
|
|
show = StateView('account.reconcile.show',
|
|
'account.reconcile_show_view_form', [
|
|
Button('Cancel', 'end', 'tryton-cancel'),
|
|
Button('Skip', 'next_', 'tryton-forward', validate=False),
|
|
Button('Reconcile', 'reconcile', 'tryton-ok', default=True),
|
|
])
|
|
reconcile = StateTransition()
|
|
|
|
def get_accounts(self):
|
|
'Return a list of account id to reconcile'
|
|
pool = Pool()
|
|
Rule = pool.get('ir.rule')
|
|
Line = pool.get('account.move.line')
|
|
line = Line.__table__()
|
|
Account = pool.get('account.account')
|
|
AccountType = pool.get('account.account.type')
|
|
account = Account.__table__()
|
|
account_type = AccountType.__table__()
|
|
cursor = Transaction().connection.cursor()
|
|
account_rule = Rule.query_get(Account.__name__)
|
|
|
|
if self.model and self.model.__name__ == 'account.move.line':
|
|
lines = [l for l in self.records if not l.reconciliation]
|
|
return list({l.account for l in lines if l.account.reconcile})
|
|
|
|
balance = line.debit - line.credit
|
|
cursor.execute(*line.join(account,
|
|
condition=line.account == account.id)
|
|
.join(account_type, condition=account.type == account_type.id)
|
|
.select(
|
|
account.id,
|
|
where=((line.reconciliation == Null)
|
|
& (line.state == 'valid')
|
|
& account.reconcile
|
|
& account.id.in_(account_rule)),
|
|
group_by=[account.id,
|
|
account_type.receivable, account_type.payable],
|
|
having=((
|
|
Sum(Case((balance > 0, 1), else_=0)) > 0)
|
|
& (Sum(Case((balance < 0, 1), else_=0)) > 0)
|
|
| Case((account_type.receivable
|
|
& ~Coalesce(account_type.payable, False),
|
|
Sum(balance) < 0),
|
|
else_=False)
|
|
| Case((account_type.payable
|
|
& ~Coalesce(account_type.receivable, False),
|
|
Sum(balance) > 0),
|
|
else_=False)
|
|
)))
|
|
return [a for a, in cursor]
|
|
|
|
def get_parties(self, account, party=None):
|
|
'Return a list party to reconcile for the account'
|
|
pool = Pool()
|
|
Line = pool.get('account.move.line')
|
|
line = Line.__table__()
|
|
cursor = Transaction().connection.cursor()
|
|
|
|
if self.model and self.model.__name__ == 'account.move.line':
|
|
lines = [l for l in self.records if not l.reconciliation]
|
|
return list({
|
|
l.party for l in lines
|
|
if l.account == account and l.party})
|
|
|
|
where = ((line.reconciliation == Null)
|
|
& (line.state == 'valid')
|
|
& (line.account == account.id))
|
|
if party:
|
|
where &= (line.party == party.id)
|
|
else:
|
|
where &= (line.party != Null)
|
|
cursor.execute(*line.select(line.party,
|
|
where=where,
|
|
group_by=line.party))
|
|
return [p for p, in cursor]
|
|
|
|
def get_currencies(self, account, party, currency=None, _balanced=False):
|
|
"Return a list of currencies to reconcile for the account and party"
|
|
pool = Pool()
|
|
Line = pool.get('account.move.line')
|
|
line = Line.__table__()
|
|
cursor = Transaction().connection.cursor()
|
|
|
|
if self.model and self.model.__name__ == 'account.move.line':
|
|
lines = [l for l in self.records if not l.reconciliation]
|
|
return list({
|
|
(l.second_currency or l.currency).id
|
|
for l in lines
|
|
if l.account == account and l.party == party})
|
|
|
|
balance = Case(
|
|
(line.second_currency != Null, line.amount_second_currency),
|
|
else_=line.debit - line.credit)
|
|
if _balanced:
|
|
having = Sum(balance) == 0
|
|
else:
|
|
having = ((
|
|
Sum(Case((balance > 0, 1), else_=0)) > 0)
|
|
& (Sum(Case((balance < 0, 1), else_=0)) > 0)
|
|
| Case((account.type.receivable, Sum(balance) < 0),
|
|
else_=False)
|
|
| Case((account.type.payable, Sum(balance) > 0),
|
|
else_=False)
|
|
)
|
|
where = ((line.reconciliation == Null)
|
|
& (line.state == 'valid')
|
|
& (line.account == account.id)
|
|
& (line.party == (party.id if party else None)))
|
|
currency_expr = Coalesce(line.second_currency, account.currency.id)
|
|
if currency:
|
|
where &= (currency_expr == currency.id)
|
|
cursor.execute(*line.select(
|
|
currency_expr,
|
|
where=where,
|
|
group_by=line.second_currency,
|
|
having=having))
|
|
return [p for p, in cursor]
|
|
|
|
def transition_setup(self):
|
|
return self.transition_next_(first=True)
|
|
|
|
@check_access
|
|
def transition_next_(self, first=False):
|
|
pool = Pool()
|
|
Line = pool.get('account.move.line')
|
|
|
|
def next_account():
|
|
accounts = list(self.show.accounts)
|
|
if not accounts:
|
|
return
|
|
account = accounts.pop()
|
|
self.show.account = account
|
|
self.show.accounts = accounts
|
|
self.show.parties = self.get_parties(account)
|
|
return account
|
|
|
|
def next_party():
|
|
parties = list(self.show.parties)
|
|
if not parties:
|
|
return
|
|
party = parties.pop()
|
|
self.show.party = party
|
|
self.show.parties = parties
|
|
self.show.currencies = self.get_currencies(
|
|
self.show.account, party)
|
|
return party,
|
|
|
|
def next_currency():
|
|
currencies = list(self.show.currencies)
|
|
if not currencies:
|
|
return
|
|
currency = currencies.pop()
|
|
self.show.currency = currency
|
|
self.show.currencies = currencies
|
|
return currency
|
|
|
|
if first:
|
|
self.show.accounts = self.get_accounts()
|
|
account = next_account()
|
|
if not account or (not next_party() and account.party_required):
|
|
return 'end'
|
|
if self.show.account.party_required:
|
|
self.show.parties = self.get_parties(self.show.account)
|
|
if not next_party():
|
|
return 'end'
|
|
else:
|
|
self.show.parties = []
|
|
self.show.party = None
|
|
self.show.currencies = self.get_currencies(
|
|
self.show.account, self.show.party)
|
|
|
|
while True:
|
|
while not next_currency():
|
|
if self.show.account.party_required:
|
|
while not next_party():
|
|
if not next_account():
|
|
return 'end'
|
|
else:
|
|
if not next_account():
|
|
return 'end'
|
|
if self.start.automatic or self.start.only_balanced:
|
|
lines = self._default_lines()
|
|
if lines and self.start.automatic:
|
|
while lines:
|
|
Line.reconcile(lines)
|
|
lines = self._default_lines()
|
|
continue
|
|
elif not lines and self.start.only_balanced:
|
|
continue
|
|
return 'show'
|
|
|
|
def default_show(self, fields):
|
|
pool = Pool()
|
|
Date = pool.get('ir.date')
|
|
|
|
defaults = {}
|
|
defaults['accounts'] = [a.id for a in self.show.accounts]
|
|
defaults['account'] = self.show.account.id
|
|
defaults['company'] = self.show.account.company.id
|
|
defaults['parties'] = [p.id for p in self.show.parties]
|
|
defaults['party'] = self.show.party.id if self.show.party else None
|
|
defaults['currencies'] = [c.id for c in self.show.currencies]
|
|
defaults['currency'] = (
|
|
self.show.currency.id if self.show.currency else None)
|
|
defaults['lines'] = list(map(int, self._default_lines()))
|
|
defaults['write_off_amount'] = None
|
|
with Transaction().set_context(company=self.show.account.company.id):
|
|
defaults['date'] = Date.today()
|
|
return defaults
|
|
|
|
def _all_lines(self):
|
|
'Return all lines to reconcile for the current state'
|
|
pool = Pool()
|
|
Line = pool.get('account.move.line')
|
|
return Line.search([
|
|
('account', '=', self.show.account.id),
|
|
('party', '=',
|
|
self.show.party.id if self.show.party else None),
|
|
('reconciliation', '=', None),
|
|
['OR',
|
|
[
|
|
('currency', '=', self.show.currency.id),
|
|
('second_currency', '=', None),
|
|
],
|
|
('second_currency', '=', self.show.currency.id),
|
|
],
|
|
('state', '=', 'valid'),
|
|
],
|
|
order=[])
|
|
|
|
def _line_sort_key(self, line):
|
|
return [line.maturity_date or line.date]
|
|
|
|
def _default_lines(self):
|
|
'Return the larger list of lines which can be reconciled'
|
|
pool = Pool()
|
|
Line = pool.get('account.move.line')
|
|
|
|
if self.model and self.model.__name__ == 'account.move.line':
|
|
requested = {
|
|
l for l in self.records
|
|
if l.account == self.show.account
|
|
and l.party == self.show.party
|
|
and (l.second_currency or l.currency) == self.show.currency}
|
|
else:
|
|
requested = []
|
|
currency = self.show.currency
|
|
|
|
lines, remaining = Line.find_best_reconciliation(
|
|
self._all_lines(), currency)
|
|
if remaining:
|
|
return requested
|
|
else:
|
|
return lines
|
|
|
|
def transition_reconcile(self):
|
|
pool = Pool()
|
|
Line = pool.get('account.move.line')
|
|
|
|
if self.show.lines:
|
|
Line.reconcile(self.show.lines,
|
|
date=self.show.date,
|
|
writeoff=self.show.write_off,
|
|
description=self.show.description)
|
|
|
|
if self.get_currencies(
|
|
self.show.account, self.show.party,
|
|
currency=self.show.currency):
|
|
return 'show'
|
|
return 'next_'
|
|
|
|
|
|
class ReconcileStart(ModelView):
|
|
__name__ = 'account.reconcile.start'
|
|
automatic = fields.Boolean(
|
|
"Automatic",
|
|
help="Automatically reconcile suggestions.")
|
|
only_balanced = fields.Boolean(
|
|
"Only Balanced",
|
|
help="Skip suggestion with write-off.")
|
|
|
|
@classmethod
|
|
def default_automatic(cls):
|
|
return False
|
|
|
|
@classmethod
|
|
def default_only_balanced(cls):
|
|
return False
|
|
|
|
|
|
class ReconcileShow(ModelView):
|
|
__name__ = 'account.reconcile.show'
|
|
company = fields.Many2One('company.company', "Company", readonly=True)
|
|
accounts = fields.Many2Many('account.account', None, None, 'Account',
|
|
readonly=True)
|
|
account = fields.Many2One('account.account', 'Account', readonly=True)
|
|
parties = fields.Many2Many(
|
|
'party.party', None, None, 'Parties', readonly=True,
|
|
context={
|
|
'company': Eval('company', -1),
|
|
},
|
|
depends={'company'})
|
|
party = fields.Many2One(
|
|
'party.party', 'Party', readonly=True,
|
|
context={
|
|
'company': Eval('company', -1),
|
|
},
|
|
depends={'company'})
|
|
currencies = fields.Many2Many(
|
|
'currency.currency', None, None, "Currencies", readonly=True)
|
|
currency = fields.Many2One('currency.currency', "Currency", readonly=True)
|
|
lines = fields.Many2Many('account.move.line', None, None, 'Lines',
|
|
domain=[
|
|
('account', '=', Eval('account', -1)),
|
|
('party', '=', Eval('party', -1)),
|
|
('reconciliation', '=', None),
|
|
['OR',
|
|
[
|
|
('currency', '=', Eval('currency', -1)),
|
|
('second_currency', '=', None),
|
|
],
|
|
('second_currency', '=', Eval('currency', -1)),
|
|
],
|
|
])
|
|
|
|
_write_off_states = {
|
|
'required': Bool(Eval('write_off_amount', 0)),
|
|
'invisible': ~Eval('write_off_amount', 0),
|
|
}
|
|
|
|
write_off_amount = Monetary(
|
|
"Amount", currency='currency', digits='currency', readonly=True,
|
|
states=_write_off_states)
|
|
write_off = fields.Many2One(
|
|
'account.move.reconcile.write_off', "Write Off",
|
|
domain=[
|
|
('company', '=', Eval('company', -1)),
|
|
],
|
|
states=_write_off_states)
|
|
date = fields.Date('Date', states=_write_off_states)
|
|
description = fields.Char('Description',
|
|
states={
|
|
'invisible': _write_off_states['invisible'],
|
|
})
|
|
|
|
@fields.depends('lines', 'currency', 'company')
|
|
def on_change_lines(self):
|
|
amount = Decimal(0)
|
|
if self.currency:
|
|
for line in self.lines:
|
|
if line.second_currency == self.currency:
|
|
amount += (
|
|
line.amount_second_currency or 0)
|
|
elif line.currency == self.currency:
|
|
amount += (
|
|
(line.debit or 0) - (line.credit or 0))
|
|
amount = self.currency.round(amount)
|
|
self.write_off_amount = amount
|
|
|
|
|
|
class CancelMoves(Wizard):
|
|
__name__ = 'account.move.cancel'
|
|
start_state = 'default'
|
|
default = StateView('account.move.cancel.default',
|
|
'account.move_cancel_default_view_form', [
|
|
Button('Cancel', 'end', 'tryton-cancel'),
|
|
Button('OK', 'cancel', 'tryton-ok', default=True),
|
|
])
|
|
cancel = StateTransition()
|
|
|
|
def default_cancel(self, move):
|
|
default = {}
|
|
if self.default.description:
|
|
default['description'] = self.default.description
|
|
return default
|
|
|
|
def transition_cancel(self):
|
|
pool = Pool()
|
|
Line = pool.get('account.move.line')
|
|
Move = pool.get('account.move')
|
|
Warning = pool.get('res.user.warning')
|
|
Unreconcile = pool.get('account.move.unreconcile_lines', type='wizard')
|
|
transaction = Transaction()
|
|
|
|
moves = self.records
|
|
moves_w_delegation = {
|
|
m: [ml for ml in m.lines
|
|
if ml.reconciliation and ml.reconciliation.delegate_to]
|
|
for m in moves}
|
|
if any(dml for dml in moves_w_delegation.values()):
|
|
names = ', '.join(m.rec_name for m in
|
|
islice(moves_w_delegation.keys(), None, 5))
|
|
if len(moves_w_delegation) > 5:
|
|
names += '...'
|
|
key = Warning.format('cancel_delegated', moves_w_delegation)
|
|
if Warning.check(key):
|
|
raise CancelDelegatedWarning(
|
|
key, gettext(
|
|
'account.msg_cancel_line_delegated', moves=names))
|
|
|
|
to_post = []
|
|
for move in moves:
|
|
if moves_w_delegation.get(move):
|
|
with transaction.set_context(_skip_warnings=True):
|
|
Unreconcile.make_unreconciliation(moves_w_delegation[move])
|
|
default = self.default_cancel(move)
|
|
cancel_move = move.cancel(
|
|
default=default, reversal=self.default.reversal)
|
|
if move.state == 'posted':
|
|
to_post.append(cancel_move)
|
|
to_reconcile = defaultdict(list)
|
|
for line in move.lines + cancel_move.lines:
|
|
if line.account.reconcile:
|
|
to_reconcile[(line.account, line.party)].append(line)
|
|
for lines in to_reconcile.values():
|
|
Line.reconcile(lines)
|
|
if to_post:
|
|
Move.post(to_post)
|
|
return 'end'
|
|
|
|
|
|
class CancelMovesDefault(ModelView):
|
|
__name__ = 'account.move.cancel.default'
|
|
description = fields.Char('Description')
|
|
reversal = fields.Boolean(
|
|
"Reversal",
|
|
help="Reverse debit and credit.")
|
|
|
|
@classmethod
|
|
def default_reversal(cls):
|
|
return True
|
|
|
|
|
|
class GroupLines(Wizard):
|
|
__name__ = 'account.move.line.group'
|
|
start = StateView('account.move.line.group.start',
|
|
'account.move_line_group_start_view_form', [
|
|
Button("Cancel", 'end', 'tryton-cancel'),
|
|
Button("Group", 'group', 'tryton-ok', default=True),
|
|
])
|
|
group = StateAction('account.act_move_form_grouping')
|
|
|
|
def do_group(self, action):
|
|
move, balance_line = self._group_lines(self.records)
|
|
if all(l.move.state == 'posted' for l in self.records):
|
|
move.post()
|
|
return action, {'res_id': move.id}
|
|
|
|
def _group_lines(self, lines, date=None):
|
|
move, balance_line = self.group_lines(lines, self.start.journal, date)
|
|
move.description = self.start.description
|
|
move.save()
|
|
return move, balance_line
|
|
|
|
@classmethod
|
|
def group_lines(cls, lines, journal, date=None):
|
|
pool = Pool()
|
|
Line = pool.get('account.move.line')
|
|
|
|
grouping = cls.grouping(lines)
|
|
|
|
move, balance_line = cls.get_move(lines, grouping, journal, date)
|
|
move.save()
|
|
|
|
to_reconcile = defaultdict(list)
|
|
for line in chain(lines, move.lines):
|
|
if line.account.reconcile:
|
|
to_reconcile[line.account].append(line)
|
|
|
|
if balance_line:
|
|
balance_line.move = move
|
|
balance_line.save()
|
|
|
|
for lines in to_reconcile.values():
|
|
Line.reconcile(lines, delegate_to=balance_line)
|
|
|
|
return move, balance_line
|
|
|
|
@classmethod
|
|
def grouping(cls, lines):
|
|
if len(lines) == 1:
|
|
raise GroupLineError(gettext('account.msg_group_line_single'))
|
|
companies = set()
|
|
parties = set()
|
|
accounts = set()
|
|
second_currencies = set()
|
|
for line in lines:
|
|
if not cls.allow_grouping(line.account):
|
|
raise GroupLineError(gettext('account.msg_group_line'))
|
|
companies.add(line.move.company)
|
|
parties.add(line.party)
|
|
accounts.add(line.account)
|
|
second_currencies.add(line.second_currency)
|
|
try:
|
|
company, = companies
|
|
except ValueError:
|
|
raise GroupLineError(
|
|
gettext('account.msg_group_line_same_company'))
|
|
try:
|
|
party, = parties
|
|
except ValueError:
|
|
raise GroupLineError(
|
|
gettext('account.msg_group_line_many_parties'))
|
|
try:
|
|
second_currency, = second_currencies
|
|
except ValueError:
|
|
raise GroupLineError(
|
|
gettext('account.msg_group_line_same_second_currency'))
|
|
if len(accounts) > 2:
|
|
raise GroupLineError(
|
|
gettext('account.msg_group_line_maximum_account'))
|
|
return {
|
|
'company': company,
|
|
'party': party,
|
|
'second_currency': second_currency,
|
|
'accounts': accounts,
|
|
}
|
|
|
|
@classmethod
|
|
def allow_grouping(cls, account):
|
|
return account.type.payable or account.type.receivable
|
|
|
|
@classmethod
|
|
def get_move(cls, lines, grouping, journal, date=None):
|
|
pool = Pool()
|
|
Date = pool.get('ir.date')
|
|
Move = pool.get('account.move')
|
|
Period = pool.get('account.period')
|
|
Line = pool.get('account.move.line')
|
|
|
|
company = grouping['company']
|
|
if not date:
|
|
with Transaction().set_context(company=company.id):
|
|
date = Date.today()
|
|
period = Period.find(company, date=date)
|
|
|
|
move = Move()
|
|
move.company = company
|
|
move.date = date
|
|
move.period = period
|
|
move.journal = journal
|
|
|
|
accounts = defaultdict(Decimal)
|
|
amount_second_currency = 0
|
|
maturity_dates = {
|
|
'debit': defaultdict(lambda: None),
|
|
'credit': defaultdict(lambda: None),
|
|
}
|
|
|
|
counterpart_lines = []
|
|
for line in lines:
|
|
if line.maturity_date:
|
|
if line.debit:
|
|
m_dates = maturity_dates['debit']
|
|
else:
|
|
m_dates = maturity_dates['credit']
|
|
if m_dates[line.account]:
|
|
m_dates[line.account] = min(
|
|
m_dates[line.account], line.maturity_date)
|
|
elif line.maturity_date:
|
|
m_dates[line.account] = line.maturity_date
|
|
cline = cls._counterpart_line(line)
|
|
accounts[cline.account] += cline.debit - cline.credit
|
|
if cline.amount_second_currency:
|
|
amount_second_currency += cline.amount_second_currency
|
|
counterpart_lines.append(cline)
|
|
move.lines = counterpart_lines
|
|
|
|
balance_line = None
|
|
if len(accounts) == 1:
|
|
account, = grouping['accounts']
|
|
amount = -accounts[account]
|
|
if amount:
|
|
balance_line = Line()
|
|
balance_line.account = account
|
|
else:
|
|
first, second = grouping['accounts']
|
|
if accounts[first] != accounts[second]:
|
|
balance_line = Line()
|
|
amount = -(accounts[first] + accounts[second])
|
|
if abs(accounts[first]) > abs(accounts[second]):
|
|
balance_line.account = first
|
|
else:
|
|
balance_line.account = second
|
|
if balance_line:
|
|
if balance_line.account.party_required:
|
|
balance_line.party = grouping['party']
|
|
if amount > 0:
|
|
balance_line.debit, balance_line.credit = amount, 0
|
|
maturity_date = maturity_dates['debit'][balance_line.account]
|
|
else:
|
|
balance_line.debit, balance_line.credit = 0, -amount
|
|
maturity_date = maturity_dates['credit'][balance_line.account]
|
|
balance_line.maturity_date = maturity_date
|
|
if grouping['second_currency']:
|
|
balance_line.second_currency = grouping['second_currency']
|
|
balance_line.amount_second_currency = (
|
|
-amount_second_currency)
|
|
return move, balance_line
|
|
|
|
@classmethod
|
|
def _counterpart_line(cls, line):
|
|
pool = Pool()
|
|
Line = pool.get('account.move.line')
|
|
|
|
counterpart = Line()
|
|
counterpart.account = line.account
|
|
counterpart.debit = line.credit
|
|
counterpart.credit = line.debit
|
|
counterpart.party = line.party
|
|
counterpart.second_currency = line.second_currency
|
|
if line.second_currency:
|
|
counterpart.amount_second_currency = -line.amount_second_currency
|
|
else:
|
|
counterpart.amount_second_currency = None
|
|
return counterpart
|
|
|
|
|
|
class GroupLinesStart(ModelView):
|
|
__name__ = 'account.move.line.group.start'
|
|
|
|
journal = fields.Many2One('account.journal', "Journal", required=True)
|
|
description = fields.Char("Description")
|
|
|
|
|
|
class RescheduleLines(Wizard):
|
|
__name__ = 'account.move.line.reschedule'
|
|
start = StateView('account.move.line.reschedule.start',
|
|
'account.move_line_reschedule_start_view_form', [
|
|
Button("Cancel", 'end', 'tryton-cancel'),
|
|
Button("Preview", 'preview', 'tryton-ok',
|
|
validate=False, default=True),
|
|
])
|
|
preview = StateView('account.move.line.reschedule.preview',
|
|
'account.move_line_reschedule_preview_view_form', [
|
|
Button("Cancel", 'end', 'tryton-cancel'),
|
|
Button("Reschedule", 'reschedule', 'tryton-ok', default=True),
|
|
])
|
|
reschedule = StateAction('account.act_move_form_rescheduling')
|
|
|
|
def get_origin(self):
|
|
try:
|
|
origin, = {r.move.origin for r in self.records}
|
|
except ValueError:
|
|
raise RescheduleLineError(
|
|
gettext('account.msg_reschedule_line_same_origins'))
|
|
return origin
|
|
|
|
@classmethod
|
|
def get_currency(cls, lines):
|
|
try:
|
|
currency, = {l.amount_currency for l in lines}
|
|
except ValueError:
|
|
raise RescheduleLineError(
|
|
gettext('account.msg_reschedule_line_same_currency'))
|
|
return currency
|
|
|
|
@classmethod
|
|
def get_account(cls, lines):
|
|
try:
|
|
account, = {l.account for l in lines}
|
|
except ValueError:
|
|
raise RescheduleLineError(
|
|
gettext('account.msg_reschedule_line_same_account'))
|
|
return account
|
|
|
|
@classmethod
|
|
def get_party(cls, lines):
|
|
try:
|
|
party, = {l.party for l in lines}
|
|
except ValueError:
|
|
raise RescheduleLineError(
|
|
gettext('account.msg_reschedule_line_same_party'))
|
|
return party
|
|
|
|
@classmethod
|
|
def get_total_amount(cls, lines):
|
|
return sum(l.amount for l in lines)
|
|
|
|
@classmethod
|
|
def get_balance(cls, lines):
|
|
return sum(l.debit - l.credit for l in lines)
|
|
|
|
def default_start(self, fields):
|
|
values = {}
|
|
self.get_origin()
|
|
self.get_account(self.records)
|
|
currency = self.get_currency(self.records)
|
|
values['currency'] = currency.id
|
|
values['total_amount'] = self.get_total_amount(self.records)
|
|
return values
|
|
|
|
def default_preview(self, fields):
|
|
values = {}
|
|
currency = self.get_currency(self.records)
|
|
try:
|
|
journal, = {r.move.journal for r in self.records}
|
|
values['journal'] = journal.id
|
|
except ValueError:
|
|
pass
|
|
values['currency'] = currency.id
|
|
if (self.start.start_date
|
|
and self.start.interval
|
|
and self.start.currency):
|
|
remaining = self.start.total_amount
|
|
date = self.start.start_date
|
|
values['terms'] = terms = []
|
|
if self.start.amount:
|
|
interval = self.start.interval
|
|
amount = self.start.amount.copy_sign(remaining)
|
|
while remaining - amount > 0:
|
|
terms.append({
|
|
'date': date,
|
|
'amount': amount,
|
|
'currency': currency.id,
|
|
})
|
|
date = (
|
|
self.start.start_date + relativedelta(months=interval))
|
|
interval += self.start.interval
|
|
remaining -= amount
|
|
if remaining:
|
|
terms.append({
|
|
'date': date,
|
|
'amount': remaining,
|
|
'currency': currency.id,
|
|
})
|
|
elif self.start.number:
|
|
amount = self.start.currency.round(
|
|
self.start.total_amount / self.start.number)
|
|
for i in range(self.start.number):
|
|
terms.append({
|
|
'date': date + relativedelta(months=i),
|
|
'amount': amount,
|
|
'currency': currency.id,
|
|
})
|
|
remaining -= amount
|
|
if remaining:
|
|
terms[-1]['amount'] += remaining
|
|
return values
|
|
|
|
def do_reschedule(self, action):
|
|
move, balance_line = self.reschedule_lines(
|
|
self.records, self.preview.journal, self.preview.terms)
|
|
move.origin = self.get_origin()
|
|
move.description = self.preview.description
|
|
move.save()
|
|
if all(l.move.state == 'posted' for l in self.records):
|
|
move.post()
|
|
action['res_id'] = [move.id]
|
|
return action, {}
|
|
|
|
@classmethod
|
|
def _line_values(cls, lines):
|
|
account = cls.get_account(lines)
|
|
currency = cls.get_currency(lines)
|
|
if currency == account.currency:
|
|
currency = None
|
|
return {
|
|
'account': account,
|
|
'second_currency': currency,
|
|
'party': cls.get_party(lines),
|
|
}
|
|
|
|
@classmethod
|
|
def reschedule_lines(cls, lines, journal, terms):
|
|
pool = Pool()
|
|
Lang = pool.get('ir.lang')
|
|
Line = pool.get('account.move.line')
|
|
|
|
total_amount = cls.get_total_amount(lines)
|
|
amount = sum(t.amount for t in terms)
|
|
if amount != total_amount:
|
|
lang = Lang.get()
|
|
currency = cls.get_currency(lines)
|
|
raise RescheduleLineError(
|
|
gettext('account.msg_reschedule_line_wrong_amount',
|
|
total_amount=lang.currency(total_amount, currency),
|
|
amount=lang.currency(amount, currency)))
|
|
|
|
balance = cls.get_balance(lines)
|
|
line_values = cls._line_values(lines)
|
|
account = line_values['account']
|
|
move, balance_line = cls.get_reschedule_move(
|
|
amount, balance, journal, terms, **line_values)
|
|
move.save()
|
|
balance_line.move = move
|
|
balance_line.save()
|
|
|
|
if account.reconcile:
|
|
Line.reconcile(lines + [balance_line])
|
|
return move, balance_line
|
|
|
|
@classmethod
|
|
def get_reschedule_move(
|
|
cls, amount, balance, journal, terms, account, date=None,
|
|
**line_values):
|
|
pool = Pool()
|
|
Date = pool.get('ir.date')
|
|
Line = pool.get('account.move.line')
|
|
Move = pool.get('account.move')
|
|
Period = pool.get('account.period')
|
|
|
|
company = account.company
|
|
if not date:
|
|
with Transaction().set_context(company=company.id):
|
|
date = Date.today()
|
|
period = Period.find(company, date=date)
|
|
|
|
move = Move()
|
|
move.company = company
|
|
move.date = date
|
|
move.period = period
|
|
move.journal = journal
|
|
|
|
balance_line = Line(account=account, **line_values)
|
|
if balance >= 0:
|
|
balance_line.debit, balance_line.credit = 0, balance
|
|
else:
|
|
balance_line.debit, balance_line.credit = -balance, 0
|
|
if balance_line.second_currency:
|
|
balance_line.amount_second_currency = -amount
|
|
|
|
remaining_balance = balance
|
|
remaining_amount = amount
|
|
lines = []
|
|
for term in terms:
|
|
line = Line(account=account, **line_values)
|
|
line.maturity_date = term.date
|
|
factor = term.amount / amount
|
|
line_amount = account.currency.round(balance * factor)
|
|
if balance >= 0:
|
|
line.debit, line.credit = line_amount, 0
|
|
else:
|
|
line.debit, line.credit = 0, -line_amount
|
|
remaining_balance -= line_amount
|
|
if line.second_currency:
|
|
line_amount_second_currency = line.second_currency.round(
|
|
amount * factor)
|
|
line.amount_second_currency = line_amount_second_currency
|
|
remaining_amount -= line_amount_second_currency
|
|
lines.append(line)
|
|
if remaining_balance:
|
|
if line.debit:
|
|
line.debit += remaining_balance
|
|
else:
|
|
line.credit += remaining_balance
|
|
if remaining_amount and line.second_currency:
|
|
line.amount_second_currency += remaining_amount
|
|
move.lines = lines
|
|
return move, balance_line
|
|
|
|
|
|
class RescheduleLinesStart(ModelView):
|
|
__name__ = 'account.move.line.reschedule.start'
|
|
start_date = fields.Date("Start Date", required=True)
|
|
frequency = fields.Selection([
|
|
('monthly', "Monthly"),
|
|
('quarterly', "Quarterly"),
|
|
('other', "Other"),
|
|
], "Frequency", sort=False, required=True)
|
|
interval = fields.Integer(
|
|
"Interval", required=True,
|
|
states={
|
|
'invisible': Eval('frequency') != 'other',
|
|
},
|
|
help="The length of each period, in months.")
|
|
amount = Monetary(
|
|
"Amount", currency='currency', digits='currency',
|
|
states={
|
|
'required': ~Eval('number'),
|
|
'invisible': Bool(Eval('number')),
|
|
},
|
|
domain=[If(Eval('amount'),
|
|
If(Eval('total_amount', 0) > 0,
|
|
[
|
|
('amount', '<=', Eval('total_amount', 0)),
|
|
('amount', '>', 0),
|
|
],
|
|
[
|
|
('amount', '>=', Eval('total_amount', 0)),
|
|
('amount', '<', 0),
|
|
]),
|
|
[])])
|
|
number = fields.Integer(
|
|
"Number",
|
|
domain=[
|
|
('number', '>', 0),
|
|
],
|
|
states={
|
|
'required': ~Eval('amount'),
|
|
'invisible': Bool(Eval('amount')),
|
|
})
|
|
|
|
total_amount = fields.Numeric("Total Amount", readonly=True)
|
|
currency = fields.Many2One('currency.currency', "Currency", readonly=True)
|
|
|
|
@classmethod
|
|
def default_frequency(cls):
|
|
return 'monthly'
|
|
|
|
@classmethod
|
|
def frequency_intervals(cls):
|
|
return {
|
|
'monthly': 1,
|
|
'quarterly': 3,
|
|
'other': None,
|
|
}
|
|
|
|
@fields.depends('frequency', 'interval')
|
|
def on_change_frequency(self):
|
|
if self.frequency:
|
|
self.interval = self.frequency_intervals()[self.frequency]
|
|
|
|
|
|
class RescheduleLinesPreview(ModelView):
|
|
__name__ = 'account.move.line.reschedule.preview'
|
|
journal = fields.Many2One('account.journal', "Journal", required=True)
|
|
description = fields.Char("Description")
|
|
terms = fields.One2Many(
|
|
'account.move.line.reschedule.term', None, "Terms",
|
|
domain=[
|
|
('currency', '=', Eval('currency', -1)),
|
|
])
|
|
currency = fields.Many2One('currency.currency', "Currency", readonly=True)
|
|
|
|
|
|
class RescheduleLinesTerm(ModelView):
|
|
__name__ = 'account.move.line.reschedule.term'
|
|
date = fields.Date("Date", required=True)
|
|
amount = Monetary(
|
|
"Amount", currency='currency', digits='currency', required=True)
|
|
currency = fields.Many2One('currency.currency', "Currency", required=True)
|
|
|
|
|
|
class DelegateLines(Wizard):
|
|
__name__ = 'account.move.line.delegate'
|
|
start = StateView(
|
|
'account.move.line.delegate.start',
|
|
'account.move_line_delegate_start_view_form', [
|
|
Button("Cancel", 'end', 'tryton-cancel'),
|
|
Button("Delegate", 'delegate', 'tryton-ok', default=True),
|
|
])
|
|
delegate = StateAction('account.act_move_form_delegate')
|
|
|
|
def default_start(self, fields):
|
|
values = {}
|
|
if 'journal' in fields:
|
|
journals = {l.journal for l in self.records}
|
|
if len(journals) == 1:
|
|
journal, = journals
|
|
values['journal'] = journal.id
|
|
return values
|
|
|
|
def do_delegate(self, action):
|
|
move = self._delegate_lines(self.records, self.start.party)
|
|
if all(l.move.state == 'posted' for l in self.records):
|
|
move.post()
|
|
return action, {'res_id': move.id}
|
|
|
|
def _delegate_lines(self, lines, party, date=None):
|
|
move = self.delegate_lines(lines, party, self.start.journal, date)
|
|
move.description = self.start.description
|
|
move.save()
|
|
return move
|
|
|
|
@classmethod
|
|
def delegate_lines(cls, lines, party, journal, date=None):
|
|
pool = Pool()
|
|
Line = pool.get('account.move.line')
|
|
|
|
move, counterpart, delegated = cls.get_move(
|
|
lines, party, journal, date=date)
|
|
move.save()
|
|
Line.save(counterpart + delegated)
|
|
for line, cline, dline in zip(lines, counterpart, delegated):
|
|
Line.reconcile([line, cline], delegate_to=dline)
|
|
return move
|
|
|
|
@classmethod
|
|
def get_move(cls, lines, party, journal, date=None):
|
|
pool = Pool()
|
|
Date = pool.get('ir.date')
|
|
Move = pool.get('account.move')
|
|
Period = pool.get('account.period')
|
|
|
|
try:
|
|
company, = {l.company for l in lines}
|
|
except ValueError:
|
|
raise DelegateLineError(
|
|
gettext('account.msg_delegate_line_same_company'))
|
|
|
|
try:
|
|
origin, = {l.move.origin for l in lines}
|
|
except ValueError:
|
|
raise DelegateLineError(
|
|
gettext('account.msg_delegate_line_same_origins'))
|
|
|
|
if not date:
|
|
with Transaction().set_context(company=company.id):
|
|
date = Date.today()
|
|
period = Period.find(company, date=date)
|
|
|
|
move = Move()
|
|
move.company = company
|
|
move.date = date
|
|
move.period = period
|
|
move.journal = journal
|
|
move.origin = origin
|
|
|
|
counterpart = []
|
|
delegated = []
|
|
for line in lines:
|
|
cline = cls.get_move_line(line)
|
|
cline.move = move
|
|
cline.debit, cline.credit = line.credit, line.debit
|
|
if cline.amount_second_currency:
|
|
cline.amount_second_currency *= -1
|
|
counterpart.append(cline)
|
|
dline = cls.get_move_line(line)
|
|
dline.move = move
|
|
dline.party = party
|
|
delegated.append(dline)
|
|
return move, counterpart, delegated
|
|
|
|
@classmethod
|
|
def get_move_line(cls, line):
|
|
pool = Pool()
|
|
Line = pool.get('account.move.line')
|
|
|
|
new = Line()
|
|
new.debit = line.debit
|
|
new.credit = line.credit
|
|
new.account = line.account
|
|
new.origin = line
|
|
new.description = line.description
|
|
new.amount_second_currency = line.amount_second_currency
|
|
new.second_currency = line.second_currency
|
|
new.party = line.party
|
|
new.maturity_date = line.maturity_date
|
|
return new
|
|
|
|
|
|
class DelegateLinesStart(ModelView):
|
|
__name__ = 'account.move.line.delegate.start'
|
|
|
|
journal = fields.Many2One('account.journal', "Journal", required=True)
|
|
party = fields.Many2One('party.party', "Party", required=True)
|
|
description = fields.Char("Description")
|
|
|
|
|
|
class GeneralJournal(Report):
|
|
__name__ = 'account.move.general_journal'
|
|
|
|
@classmethod
|
|
def get_context(cls, records, header, data):
|
|
pool = Pool()
|
|
Company = pool.get('company.company')
|
|
context = Transaction().context
|
|
report_context = super().get_context(records, header, data)
|
|
report_context['company'] = Company(context['company'])
|
|
return report_context
|