trytond-project_invoice_mil.../milestone.py

770 lines
28 KiB
Python

# The COPYRIGHT file at the top level of this repository contains the full
# copyright notices and license terms.
from trytond.model import Workflow, ModelSQL, ModelView, fields
from trytond.pool import Pool
from trytond.pyson import Bool, Eval, If
from trytond.transaction import Transaction
from dateutil.relativedelta import relativedelta
from decimal import Decimal
from itertools import groupby
from jinja2 import Template as Jinja2Template
__all__ = ['MilestoneTypeGroup', 'MilestoneType', 'Milestone']
_KIND = [
('manual', 'Manual'),
('system', 'System'),
]
_TRIGGER = [
('', ''),
('start_project', 'On Project Start'),
('progress', 'On % Progress'),
('finish_project', 'On Project Finish'),
]
_ZERO = Decimal('0.0')
class MilestoneMixin:
kind = fields.Selection(_KIND, 'Kind', required=True, select=True)
trigger = fields.Selection(_TRIGGER, 'Trigger', sort=False,
states={
'required': Eval('kind') == 'system',
'invisible': Eval('kind') != 'system',
}, depends=['kind'],
help='Defines when the Milestone will be confirmed and its Invoice '
'Date calculated.')
trigger_progress = fields.Numeric('On Progress',
digits=(16, 8),
domain=[
['OR',
('trigger_progress', '=', None),
[
('trigger_progress', '>=', 0),
('trigger_progress', '<=', 1),
],
],
],
states={
'required': ((Eval('kind') == 'system') &
(Eval('trigger') == 'progress')),
'invisible': ((Eval('kind') != 'system') |
(Eval('trigger') != 'progress')),
}, depends=['kind', 'trigger'],
help="The percentage of progress over the total amount of Project.")
invoice_method = fields.Selection([
('fixed', 'Fixed'),
('percent', 'Percent'),
('progress', 'Progress'),
('remainder', 'Remainder'),
], 'Invoice Method', required=True, sort=False)
invoice_percent = fields.Numeric('Invoice Percent',
digits=(16, 8),
states={
'required': Eval('invoice_method') == 'percent',
'invisible': Eval('invoice_method') != 'percent',
},
depends=['invoice_method', 'currency_digits'])
advancement_product = fields.Many2One('product.product',
'Advancement Product', states={
'required': Eval('invoice_method') == 'fixed',
'invisible': Eval('invoice_method').in_(
['progress', 'remainder']),
}, depends=['invoice_method'])
advancement_amount = fields.Numeric('Advancement Amount',
digits=(16, Eval('currency_digits', 2)),
states={
'required': Eval('invoice_method') == 'fixed',
'invisible': Eval('invoice_method') != 'fixed',
},
depends=['invoice_method', 'currency_digits'])
compensation_product = fields.Many2One('product.product',
'Compensation Product', states={
'required': Eval('invoice_method').in_(['progress', 'remainder']),
'invisible': Eval('invoice_method') == 'percent'
}, depends=['invoice_method'])
currency = fields.Many2One('currency.currency', 'Currency',
states={
'required': Eval('invoice_method') == 'fixed',
'invisible': Eval('invoice_method') != 'fixed',
},
depends=['invoice_method'])
currency_digits = fields.Function(fields.Integer('Currency Digits'),
'on_change_with_currency_digits')
months = fields.Integer('Number of Months', required=True)
month = fields.Selection([
(None, ''),
('1', 'January'),
('2', 'February'),
('3', 'March'),
('4', 'April'),
('5', 'May'),
('6', 'June'),
('7', 'July'),
('8', 'August'),
('9', 'September'),
('10', 'October'),
('11', 'November'),
('12', 'December'),
], 'Month', sort=False)
weeks = fields.Integer('Number of Weeks', required=True)
weekday = fields.Selection([
(None, ''),
('0', 'Monday'),
('1', 'Tuesday'),
('2', 'Wednesday'),
('3', 'Thursday'),
('4', 'Friday'),
('5', 'Saturday'),
('6', 'Sunday'),
], 'Day of Week', sort=False)
days = fields.Integer('Number of Days', required=True)
day = fields.Integer('Day of Month', domain=[
['OR', [
('day', '=', None),
], [
('day', '>=', 1),
('day', '<=', 31),
]],
])
description = fields.Text('Description',
help='It will be used to prepare the description field of invoice '
'lines.\nYou can use tags and they will be replaced by these '
'fields from the record related to milestone: {{ record.rec_name }}.')
@staticmethod
def default_kind():
return 'manual'
@staticmethod
def default_invoice_method():
return 'fixed'
@staticmethod
def default_advancement_product():
pool = Pool()
Config = pool.get('project.invoice_milestone.configuration')
config = Config.get_singleton()
if getattr(config, 'advancement_product', None):
return config.advancement_product.id
@staticmethod
def default_compensation_product():
pool = Pool()
Config = pool.get('project.invoice_milestone.configuration')
config = Config.get_singleton()
if config and config.compensation_product:
return config.compensation_product.id
@staticmethod
def default_currency():
pool = Pool()
Company = pool.get('company.company')
company_id = Transaction().context.get('company')
if company_id:
return Company(company_id).currency.id
@staticmethod
def default_currency_digits():
pool = Pool()
Company = pool.get('company.company')
company_id = Transaction().context.get('company')
if company_id:
return Company(company_id).currency.digits
return 2
@staticmethod
def default_months():
return 0
@staticmethod
def default_weeks():
return 0
@staticmethod
def default_days():
return 0
@fields.depends('currency')
def on_change_with_currency_digits(self, name=None):
if self.currency:
return self.currency.digits
return 2
def _calc_delta(self):
return {
'day': self.day,
'month': int(self.month) if self.month else None,
'days': self.days,
'weeks': self.weeks,
'months': self.months,
'weekday': int(self.weekday) if self.weekday else None,
}
class MilestoneTypeGroup(ModelSQL, ModelView):
'Project Invoice Milestone Type Group'
__name__ = 'project.invoice_milestone.type.group'
name = fields.Char('Name', required=True, translate=True)
active = fields.Boolean('Active')
description = fields.Char('Description', translate=True)
lines = fields.One2Many('project.invoice_milestone.type', 'group', 'Lines')
@staticmethod
def default_active():
return True
def compute(self, project):
milestones = []
for line in self.lines:
milestone = line.compute_milestone(project)
milestones.append(milestone)
return milestones
class MilestoneType(ModelSQL, ModelView, MilestoneMixin):
'Milestone Type'
__name__ = 'project.invoice_milestone.type'
group = fields.Many2One('project.invoice_milestone.type.group',
'Group', required=True, select=True, ondelete='CASCADE')
sequence = fields.Integer('Sequence',
help='Use to order lines in ascending order')
@classmethod
def __setup__(cls):
super(MilestoneType, cls).__setup__()
cls._order.insert(0, ('sequence', 'ASC'))
@staticmethod
def order_sequence(tables):
table, _ = tables[None]
return [table.sequence == None, table.sequence]
def compute_milestone(self, project):
pool = Pool()
Milestone = pool.get('project.invoice_milestone')
milestone = Milestone()
milestone.project = project
milestone.kind = self.kind
milestone.invoice_percent = self.invoice_percent
if self.kind == 'system':
milestone.trigger = self.trigger
milestone.trigger_progress = self.trigger_progress
milestone.invoice_method = self.invoice_method
if self.invoice_method not in ['progress', 'remainder']:
milestone.advancement_product = self.advancement_product
milestone.advancement_amount = self.advancement_amount
milestone.currency = self.currency
if self.invoice_method not in ['percent']:
milestone.compensation_product = self.compensation_product
for fname in ('months', 'month', 'weeks', 'weekday', 'days', 'day',
'description'):
setattr(milestone, fname, getattr(self, fname))
# invoice_date, planned_invoice_date
return milestone
class Milestone(Workflow, ModelSQL, ModelView, MilestoneMixin):
'Milestone'
__name__ = 'project.invoice_milestone'
_rec_name = 'number'
number = fields.Char('Number', readonly=True, select=True)
project = fields.Many2One('project.work', 'Project', required=True,
ondelete='CASCADE', select=True, domain=[
('type', '=', 'project'),
('project_invoice_method', '=', 'milestone'),
('company', If(Eval('context', {}).contains('company'), '=', '!='),
Eval('context', {}).get('company', -1)),
],
states={
'readonly': Eval('state') != 'draft',
},
depends=['state'])
project_company = fields.Function(fields.Many2One('company.company',
'Project Company'),
'on_change_with_project_company', searcher='search_project_field')
project_party = fields.Function(fields.Many2One('party.party',
'Project Party'),
'on_change_with_project_party', searcher='search_project_field')
is_credit = fields.Boolean('Is Credit?', readonly=True)
invoice_date = fields.Date('Invoice Date', states={
'readonly': ~Eval('state', '').in_(['draft', 'confirmed']),
'required': Eval('state', '') == 'invoiced',
}, depends=['state'])
planned_invoice_date = fields.Date('Planned Invoice Date',
states={
'readonly': Eval('state') != 'draft',
},
depends=['state'])
invoice = fields.One2One('account.invoice-project.invoice_milestone',
'milestone', 'invoice', 'Invoice', domain=[
('company', '=', Eval('project_company', -1)),
('party', '=', Eval('project_party', -1)),
], readonly=True, depends=['project_company', 'project_party'])
# Selection items set in __setup__
invoice_state = fields.Function(fields.Selection([], 'Invoice State'),
'get_invoice_state', searcher='search_invoice_state')
invoiced_amount = fields.Function(fields.Numeric('Invoiced Amount'),
'get_invoiced_amount')
state = fields.Selection([
('draft', 'Draft'),
('confirmed', 'Confirmed'),
('invoiced', 'Invoiced'),
('cancel', 'Cancelled'),
], 'State', readonly=True, select=True)
@classmethod
def __setup__(cls):
Invoice = Pool().get('account.invoice')
super(Milestone, cls).__setup__()
for field_name in ['kind', 'trigger', 'trigger_progress',
'invoice_method', 'advancement_product', 'advancement_amount',
'compensation_product', 'currency', 'months', 'month', 'weeks',
'weekday', 'days', 'day', 'description']:
field = getattr(cls, field_name)
if not field.states.get('readonly'):
field.states['readonly'] = Eval('state') != 'draft'
else:
field.states['readonly'] = (field.states['readonly']
| (Eval('state') != 'draft'))
field.depends.append('state')
cls.invoice_state.selection = Invoice.state.selection[:]
cls._transitions |= set((
('draft', 'confirmed'),
('confirmed', 'invoiced'),
('draft', 'cancel'),
('confirmed', 'cancel'),
('invoiced', 'cancel'),
('cancel', 'draft'),
))
cls.invoice_state.selection += [(None, '')]
cls._buttons.update({
'draft': {
'invisible': ((Eval('state') != 'cancel')
| Bool(Eval('invoice'))),
'icon': 'tryton-clear',
},
'confirm': {
'invisible': Eval('state') != 'draft',
'icon': 'tryton-ok',
},
'check_trigger': {
'invisible': ((Eval('state') != 'confirmed')
| (Eval('kind') == 'manual')),
'icon': 'tryton-executable',
},
'do_invoice': {
'invisible': (
(Eval('state') != 'confirmed') |
(Eval('kind') != 'manual') |
Eval('invoice')),
'icon': 'tryton-ok',
},
'cancel': {
'invisible': Eval('state').in_(['invoiced', 'cancel']),
'icon': 'tryton-cancel',
},
})
cls._error_messages.update({
'missing_project_invoice_milestone_sequence': (
'The "Milestone Sequence" configuration param is empty in '
'Project\'s "Invoice Milestones Configuration".'),
'reset_milestone_with_invoice': (
'You cannot reset to draft the Milestone "%s" because it '
'has an invoice. Duplicate it to reinvoice.'),
'reset_milestone_done_project': (
'You cannot reset to draft the Milestone "%s" because its '
'project is already done.'),
'reset_credit_milestone': (
'You cannot reset to draft the Milestone "%s" because it '
'is a credit milestone.'),
})
@classmethod
def view_attributes(cls):
return [
('/form//group[@id="invoice_date_calculator"]', 'states',
{'invisible': Eval('kind') != 'system'}),
]
@fields.depends('project')
def on_change_with_project_company(self, name=None):
return self.project.company.id if self.project else None
@fields.depends('project')
def on_change_with_project_party(self, name=None):
return (self.project.party.id
if self.project and self.project.party else None)
@classmethod
def search_project_field(cls, name, clause):
project_fname = name.replace('project_', '')
return [
('project.%s' % project_fname,) + tuple(clause[1:]),
]
@fields.depends('project', 'invoice_method')
def on_change_with_currency(self):
if self.invoice_method == 'fixed' and self.project:
return self.project.company.currency.id
@classmethod
def search_invoice_state(cls, name, clause):
return [('invoice.state',) + tuple(clause[1:])]
def get_invoice_state(self, name):
return self.invoice.state if self.invoice else None
def get_invoiced_amount(self, name):
return self.invoice.untaxed_amount if self.invoice else Decimal(0)
@staticmethod
def default_state():
return 'draft'
@classmethod
def set_number(cls, milestones):
'''
Fill the number field with the milestone sequence
'''
pool = Pool()
Sequence = pool.get('ir.sequence')
Config = pool.get('project.invoice_milestone.configuration')
config = Config(1)
if not config.milestone_sequence:
cls.raise_user_error('missing_project_invoice_milestone_sequence')
for milestone in milestones:
if milestone.number:
continue
milestone.number = Sequence.get_id(config.milestone_sequence.id)
def _credit(self, credit_invoice):
pool = Pool()
Config = pool.get('project.invoice_milestone.configuration')
Date = pool.get('ir.date')
config = Config(1)
milestone = self.__class__()
for fname in ('project', 'advancement_product', 'compensation_product',
'currency', 'description'):
setattr(milestone, fname, getattr(self, fname))
milestone.kind = 'manual'
milestone.invoice_method = 'fixed'
milestone.is_credit = True
if self.invoice_method == 'fixed':
milestone.advancement_amount = -self.advancement_amount
else:
milestone.currency = self.invoice.currency
compensation_amount = Decimal(0)
for inv_line in self.invoice.lines:
if inv_line.origin == self:
compensation_amount = -inv_line.amount
break
milestone.advancement_amount = compensation_amount
milestone.advancement_product = config.advancement_product
milestone.project = self.project
milestone.invoice_date = credit_invoice.invoice_date or Date.today()
milestone.invoice = credit_invoice
return milestone
@classmethod
def cron_check_triggers(cls):
'Cron Check Triggers'
milestones = cls.search([
('state', '=', 'confirmed'),
('kind', '=', 'system'),
('invoice', '=', None),
('invoice_date', '!=', None),
])
if milestones:
cls.check_trigger(milestones)
@classmethod
@ModelView.button
@Workflow.transition('draft')
def draft(cls, milestones):
for milestone in milestones:
if milestone.invoice:
cls.raise_user_error('reset_milestone_with_invoice',
(milestone.rec_name,))
if milestone.project.state == 'done':
cls.raise_user_error('reset_milestone_done_project',
(milestone.rec_name,))
if milestone.is_credit:
cls.raise_user_error('reset_credit_milestone',
(milestone.rec_name,))
@classmethod
@ModelView.button
@Workflow.transition('confirmed')
def confirm(cls, milestones):
cls.set_number(milestones)
cls.save(milestones)
@classmethod
@Workflow.transition('invoiced')
def invoiced(cls, milestones):
pass
@classmethod
@ModelView.button
@Workflow.transition('cancel')
def cancel(cls, milestones):
assert all(m.invoice == None for m in milestones)
# TODO
pass
@classmethod
@ModelView.button
def check_trigger(cls, milestones):
triggered_milestones = cls.check_trigger_condition(milestones)
with Transaction().set_user(0):
cls.do_invoice(triggered_milestones)
@classmethod
def check_trigger_condition(cls, milestones):
triggered_milestones = []
for milestone in milestones:
if (milestone.state != 'confirmed' or milestone.kind == 'manual' or
milestone.invoice):
continue
method = getattr(milestone, 'check_trigger_%s'
% milestone.trigger)
triggered = method()
if not triggered:
continue
triggered_milestones.append(milestone)
return triggered_milestones
def check_trigger_start_project(self):
if self.project.state == 'opened':
return True
return False
def check_trigger_finish_project(self):
if self.project.state == 'done':
return True
return False
def check_trigger_progress(self):
if self.project.percent_progress_amount >= self.trigger_progress:
return True
return False
@classmethod
@ModelView.button
def do_invoice(cls, milestones):
"""
It's a replica of project.work.invoice()
"""
pool = Pool()
Date = pool.get('ir.date')
Invoice = pool.get('account.invoice')
today = Date.today()
invoices = []
to_invoice = []
for milestone in milestones:
if not milestone.invoice_date:
milestone.invoice_date = milestone._calc_invoice_date()
milestone.save()
if(milestone.kind == 'system' and milestone.invoice_date > today):
continue
if milestone.invoice:
to_invoice.append(milestone)
continue
inv_line_vals = milestone._get_line_vals_to_invoice()
if not inv_line_vals and milestone.invoice_method != 'remainder':
continue
invoice = milestone._get_invoice()
invoice.project_milestone = milestone
invoice.lines = []
if inv_line_vals:
invoice.save()
elif milestone.invoice_method not in ('percent', 'progress',
'remainder'):
continue
invoice_amount = Decimal(0)
for key, grouped_inv_line_vals in groupby(inv_line_vals,
key=milestone.project._group_lines_to_invoice_key):
grouped_inv_line_vals = list(grouped_inv_line_vals)
key = dict(key)
invoice_line = milestone.project._get_invoice_line(
key, invoice, grouped_inv_line_vals)
invoice_line.invoice = invoice
invoice_line.origin = milestone
invoice_line.save()
invoice_amount += invoice_line.amount
origins = {}
for line_vals in grouped_inv_line_vals:
origin = line_vals.get('origin', None)
if origin:
origin.invoice_line = invoice_line
origins.setdefault(origin.__class__, []).append(origin)
for klass, records in origins.iteritems():
klass.save(records) # Store first new origins
if milestone.invoice_method in ('percent', 'progress', 'remainder'):
invoice_line = milestone._get_compensation_invoice_line(
invoice_amount)
if invoice_line:
if not inv_line_vals:
invoice.save()
invoice_line.invoice = invoice
invoice_line.save()
elif not inv_line_vals:
# not progress/remainder lines nor compensation
continue
invoices.append(invoice)
to_invoice.append(milestone)
if invoices:
Invoice.update_taxes(Invoice.browse([i.id for i in invoices]))
if to_invoice:
cls.invoiced(to_invoice)
def _calc_invoice_date(self):
pool = Pool()
Date = pool.get('ir.date')
today = Date.today()
return today + relativedelta(**self._calc_delta())
def _get_invoice(self):
invoice = self.project._get_invoice()
if hasattr(self.project.party, 'agent'):
# Compatibility with commission_party
invoice.agent = self.project.party.agent
invoice.invoice_date = self.invoice_date
return invoice
def _get_line_vals_to_invoice(self, work=None, test=False):
"""Return line vals for work and children.
If work is not supplied, it use milestone's project"""
if self.invoice_method == 'fixed':
return self._get_line_vals_to_invoice_fixed()
if self.invoice_method == 'percent':
return self._get_line_vals_to_invoice_percent()
lines = []
if work is None:
work = self.project
if test is None:
test = work._test_group_invoice()
lines += getattr(
work, '_get_lines_to_invoice_%s' % self.invoice_method)()
for children in work.children:
if children.type == 'project':
if test != children._test_group_invoice():
continue
lines += self._get_line_vals_to_invoice(work=children, test=test)
return lines
def _get_line_vals_to_invoice_fixed(self):
if self.state != 'confirmed' or self.invoice:
return []
amount = self.advancement_amount
return [{
'product': self.advancement_product,
'quantity': 1.0 if amount > _ZERO else -1.0,
'unit': self.advancement_product.default_uom,
'unit_price': abs(amount),
'description': self._calc_invoice_line_description(),
}]
def _get_line_vals_to_invoice_percent(self):
InvoicedProgress = Pool().get('project.work.invoiced_progress')
if self.state != 'confirmed' or self.invoice:
return []
advancement_product = self.advancement_product
uom = advancement_product.default_uom
quantity = float(Decimal(
self.project.quantity * float(self.invoice_percent)).quantize(
Decimal(1) / 10 ** uom.digits))
invoiced_progress = InvoicedProgress(work=self.project,
quantity=quantity)
return [{
'product': advancement_product,
'quantity': quantity,
'unit': uom,
'unit_price': abs(self.project.list_price),
'origin': invoiced_progress,
'description': self._calc_invoice_line_description(),
}]
def _get_compensation_invoice_line(self, current_invoice_amount):
InvoiceLine = Pool().get('account.invoice.line')
amount = self.project.pending_to_compensate_advanced_amount
# TODO: review
# if self.invoice_method == 'remainder':
# if (self.group.merited_amount == self.group.total_amount
# and (self.group.invoiced_amount - amount + current_invoice_amount)
# == self.group.merited_amount):
# # It closes the milestone group
# current_invoice_amount = None
if (current_invoice_amount is not None
and self.invoice_method != 'remainder'
and current_invoice_amount < amount):
# If it is remainder => if compensates all, generating a negative invoice if it corresponds
# Otherwise, it never generates a negative invoice
amount = current_invoice_amount
if amount == _ZERO:
return
with Transaction().set_user(0, set_context=True):
invoice_line = InvoiceLine()
invoice_line.invoice_type = 'out'
invoice_line.type = 'line'
invoice_line.sequence = 1
invoice_line.origin = self
invoice_line.party = self.project.party
invoice_line.product = self.compensation_product
invoice_line.unit = self.compensation_product.default_uom
invoice_line.on_change_product()
invoice_line.quantity = -1.0
invoice_line.unit_price = amount
return invoice_line
@staticmethod
def template_context(record):
"""Generate the tempalte context"""
return {
'record': record,
}
def _calc_invoice_line_description(self):
if self.description:
template = Jinja2Template(self.description)
template_context = self.template_context(self)
return template.render(template_context)
return self.number
@classmethod
def copy(cls, milestones, default=None):
if default is None:
default = {}
default.setdefault('number', None)
default.setdefault('invoice_date', None)
default.setdefault('invoice', None)
return super(Milestone, cls).copy(milestones, default)