trytond-account_invoice_mil.../milestone.py

1524 lines
59 KiB
Python

# The COPYRIGHT file at the top level of this repository contains the full
# copyright notices and license terms.
from dateutil.relativedelta import relativedelta
from decimal import Decimal
from trytond.model import Workflow, ModelView, ModelSQL, fields
from trytond.pool import Pool, PoolMeta
from trytond.pyson import Bool, Eval, If
from trytond.transaction import Transaction
__all__ = ['AccountInvoiceMilestoneGroupType', 'AccountInvoiceMilestoneType',
'AccountInvoiceMilestoneGroup', 'AccountInvoiceMilestone',
'AccountInvoiceMilestoneTriggerSaleLine',
'AccountInvoiceMilestoneToInvoiceSaleLine',
'AccountInvoiceMilestoneRemainderSale']
__metaclass__ = PoolMeta
_ZERO = Decimal('0.0')
_KIND = [
('manual', 'Manual'),
('system', 'System'),
]
_TRIGGER = [
('', ''),
('confirmed_sale', 'On Order Confirmed'),
('shipped_amount', 'On % sent'),
('sent_sale', 'On Order Fully Sent'),
]
def d_round(number, digits):
quantize = Decimal(10) ** -Decimal(digits)
return Decimal(number).quantize(quantize)
class AccountInvoiceMilestoneGroupType(ModelSQL, ModelView):
'Account Invoice Milestone Group Type'
__name__ = 'account.invoice.milestone.group.type'
name = fields.Char('Name', required=True, translate=True)
active = fields.Boolean('Active')
description = fields.Char('Description', translate=True)
lines = fields.One2Many('account.invoice.milestone.type',
'milestone_group', 'Lines')
@classmethod
def __setup__(cls):
super(AccountInvoiceMilestoneGroupType, cls).__setup__()
cls._error_messages.update({
'last_remainder': ('Last line of milestone group type "%s" '
'must be of invoice method remainder.'),
})
@staticmethod
def default_active():
return True
@classmethod
def validate(cls, groups):
super(AccountInvoiceMilestoneGroupType, cls).validate(groups)
for group in groups:
group.check_remainder()
def check_remainder(self):
if not self.lines or not self.lines[-1].invoice_method == 'remainder':
self.raise_user_error('last_remainder', self.rec_name)
def compute_milestone_group(self, sale):
"""
Calculate milestones for supplied sale based on this Group Type.
If the sale already has a milestone group, extend it, otherwise create
a new one.
"""
# TODO implement business_days
# http://pypi.python.org/pypi/BusinessHours/
group = sale.milestone_group
if not group:
group = self._get_milestones_group(sale)
group.save()
sale.milestone_group = group
sale.save()
milestones = []
for line in self.lines:
milestone = line.compute_milestone(sale)
if milestone:
milestones.append(milestone)
if milestones:
group.milestones = (list(group.milestones)
if hasattr(group, 'milestones') else []) + milestones
group.save()
return group
def _get_milestones_group(self, sale):
pool = Pool()
MilestoneGroup = pool.get('account.invoice.milestone.group')
group = MilestoneGroup()
group.company = sale.company
group.currency = sale.currency
group.party = sale.party
group.sales = []
group.sales.append(sale)
return group
class AccountInvoiceMilestoneType(ModelSQL, ModelView):
'Account Invoice Milestone Type'
__name__ = 'account.invoice.milestone.type'
milestone_group = fields.Many2One('account.invoice.milestone.group.type',
'Milestone Group Type', required=True, select=True, ondelete='CASCADE')
sequence = fields.Integer('Sequence',
help='Use to order lines in ascending order')
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_shipped_amount = fields.Numeric('On Shipped Amount',
digits=(16, 8),
domain=[
['OR', [
('trigger_shipped_amount', '=', None),
], [
('trigger_shipped_amount', '>=', 0),
('trigger_shipped_amount', '<=', 1),
]],
],
states={
'required': ((Eval('kind') == 'system')
& (Eval('trigger') == 'shipped_amount')),
'invisible': ((Eval('kind') != 'system')
| (Eval('trigger') != 'shipped_amount')),
}, depends=['kind', 'trigger'],
help="The percentage of sent amount over the total amount of "
"Milestone's Trigger Sale Lines.\n"
"When the Milestone is computed for a Sale, all this sale lines are "
"added as Milestone's Trigger Lines. You'll be able to change this.")
invoice_method = fields.Selection([
('fixed', 'Fixed'),
('percent_on_total', 'Percentage on Total'),
('sale_lines', 'Sale Lines'),
('shipped_goods', 'Shipped Goods'),
('remainder', 'Remainder'),
], 'Invoice Method', required=True, sort=False,
domain=[
If(Eval('trigger', '') == 'confirmed_sale',
('invoice_method', '!=', 'shipped_goods'),
('invoice_method', '!=', None)),
], depends=['trigger'])
amount = fields.Numeric('Amount', digits=(16, Eval('currency_digits', 2)),
states={
'required': Eval('invoice_method', '') == 'fixed',
'invisible': Eval('invoice_method', '') != 'fixed',
}, depends=['invoice_method', 'currency_digits'])
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')
percentage = fields.Numeric('Percentage', digits=(16, 8),
domain=[
['OR', [
('percentage', '=', None),
], [
('percentage', '>=', 0),
('percentage', '<=', 1),
]],
],
states={
'required': Eval('invoice_method', '') == 'percent_on_total',
'invisible': Eval('invoice_method', '') != 'percent_on_total',
}, depends=['invoice_method'])
divisor = fields.Function(fields.Numeric('Divisor', digits=(16, 8),
states={
'required': Eval('invoice_method', '') == 'percent_on_total',
'invisible': Eval('invoice_method', '') != 'percent_on_total',
}, depends=['invoice_method']),
'on_change_with_divisor', setter='set_divisor')
day = fields.Integer('Day of Month', domain=[
['OR', [
('day', '=', None),
], [
('day', '>=', 1),
('day', '<=', 31),
]],
])
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)
weekday = fields.Selection([
(None, ''),
('0', 'Monday'),
('1', 'Tuesday'),
('2', 'Wednesday'),
('3', 'Thursday'),
('4', 'Friday'),
('5', 'Saturday'),
('6', 'Sunday'),
], 'Day of Week', sort=False)
months = fields.Integer('Number of Months', required=True)
weeks = fields.Integer('Number of Weeks', required=True)
days = fields.Integer('Number of Days', required=True)
description = fields.Text('Description',
help='It will be used to prepare the description field of invoice '
'lines.\nYou can use the next tags and they will be replaced by these '
'fields from the sale\'s related to milestone: {sale_description}, '
'{sale_reference}.')
@classmethod
def __setup__(cls):
super(AccountInvoiceMilestoneType, cls).__setup__()
cls._order.insert(0, ('sequence', 'ASC'))
@staticmethod
def order_sequence(tables):
table, _ = tables[None]
return [table.sequence == None, table.sequence]
@fields.depends('trigger', 'invoice_method')
def on_change_trigger(self):
if (self.trigger == 'confirmed_sale'
and self.invoice_method == 'shipped_goods'):
return {
'invoice_method': None,
}
return {}
@staticmethod
def default_currency_digits():
return 2
@fields.depends('currency')
def on_change_with_currency_digits(self, name=None):
if self.currency:
return self.currency.digits
return 2
@staticmethod
def default_kind():
return 'manual'
@staticmethod
def default_invoice_method():
return 'remainder'
@fields.depends('invoice_method')
def on_change_invoice_method(self):
res = {}
if self.invoice_method != 'fixed':
res['amount'] = _ZERO
res['currency'] = None
if self.invoice_method != 'percent_on_total':
res['percentage'] = _ZERO
res['divisor'] = _ZERO
return res
@fields.depends('divisor')
def on_change_with_percentage(self):
return d_round(Decimal('1.0') / self.divisor,
self.__class__.percentage.digits[1]) if self.divisor else _ZERO
@fields.depends('percentage')
def on_change_with_divisor(self, name=None):
return d_round(Decimal('1.0') / self.percentage,
self.__class__.divisor.digits[1]) if self.percentage else _ZERO
@classmethod
def set_divisor(cls, milestone_types, name, value):
milestone = milestone_types[0]
milestone.divisor = value
percentage = milestone.on_change_with_percentage()
cls.write(milestone_types, {
'percentage': percentage,
})
@staticmethod
def default_months():
return 0
@staticmethod
def default_weeks():
return 0
@staticmethod
def default_days():
return 0
def compute_milestone(self, sale):
pool = Pool()
Currency = pool.get('currency.currency')
Milestone = pool.get('account.invoice.milestone')
milestone = Milestone()
# group.append(milestone)
# milestone.description = ??
milestone.kind = self.kind
if self.kind == 'system':
milestone.trigger = self.trigger
milestone.trigger_shipped_amount = self.trigger_shipped_amount
milestone.trigger_lines = [l for l in sale.lines
if l.type == 'line']
if self.invoice_method in ('fixed', 'percent_on_total'):
milestone.invoice_method = 'amount'
if self.invoice_method == 'fixed':
milestone.amount = Currency.compute(self.currency,
self.amount, sale.currency)
else:
milestone.amount = sale.currency.round(
sale.untaxed_amount * self.percentage)
else:
milestone.invoice_method = self.invoice_method
if self.invoice_method in ('shipped_goods', 'sale_lines'):
milestone.sale_lines_to_invoice = [l for l in sale.lines
if l.type == 'line']
elif self.invoice_method == 'remainder':
milestone.sales_to_invoice = [sale]
for fname in ('day', 'month', 'weekday', 'months', 'weeks', 'days',
'description'):
setattr(milestone, fname, getattr(self, fname))
return milestone
_TRIGGER_SALE_STATES = ('confirmed', 'processing', 'done')
class AccountInvoiceMilestoneGroup(ModelSQL, ModelView):
'Account Invoice Milestone Group'
__name__ = 'account.invoice.milestone.group'
_rec_name = 'code'
code = fields.Char('Code', required=True, readonly=True)
company = fields.Many2One('company.company', 'Company', required=True,
select=True, ondelete='CASCADE', states={
'readonly': Bool(Eval('milestones', [])),
}, depends=['milestones'])
currency = fields.Many2One('currency.currency', 'Currency', required=True,
states={
'readonly': Bool(Eval('milestones', [])),
}, depends=['milestones'])
currency_digits = fields.Function(fields.Integer('Currency Digits'),
'on_change_with_currency_digits')
party = fields.Many2One('party.party', 'Party', required=True, states={
'readonly': Bool(Eval('milestones', [])),
}, depends=['milestones'])
milestones = fields.One2Many('account.invoice.milestone', 'group',
'Milestones',
context={
'party': Eval('party'),
},
states={
'readonly': ~Bool(Eval('party')),
},
depends=['party'])
state = fields.Function(fields.Selection([
('to_assign', 'To assign'),
('pending', 'Pending'),
('completed', 'Completed'),
('paid', 'Paid'),
], 'State'),
'get_state')
sales = fields.One2Many('sale.sale', 'milestone_group', 'Sales',
readonly=True,
domain=[
('company', '=', Eval('company', -1)),
('currency', '=', Eval('currency', -1)),
('party', '=', Eval('party', -1)),
],
depends=['company', 'currency', 'party'])
total_amount = fields.Function(fields.Numeric('Total Amount',
digits=(16, Eval('currency_digits', 2)),
depends=['currency_digits'],
help="The Untaxed Amount of all Group's Sales"),
'get_amounts')
merited_amount = fields.Function(fields.Numeric('Merited Amount',
digits=(16, Eval('currency_digits', 2)),
depends=['currency_digits'],
help="The Amount of all shipped moves and supplied services (it "
"calculates the service's sale lines as supplied if the sale is "
"processing or done)."),
'get_amounts')
amount_to_assign = fields.Function(fields.Numeric('Amount to Assign',
digits=(16, Eval('currency_digits', 2)),
depends=['currency_digits'],
help="The amount of Sales lines whose movements are not "
"associated with any Milestone nor their Sales are associated "
"with any remainder Milestone."),
'get_amounts')
assigned_amount = fields.Function(fields.Numeric('Assigned Amount',
digits=(16, Eval('currency_digits', 2)),
depends=['currency_digits', 'state']),
'get_amounts')
amount_to_invoice = fields.Function(fields.Numeric('Amount to Invoice',
digits=(16, Eval('currency_digits', 2)),
depends=['currency_digits']),
'get_amounts')
invoiced_amount = fields.Function(fields.Numeric('Invoiced Amount',
digits=(16, Eval('currency_digits', 2)),
depends=['currency_digits']),
'get_amounts')
@classmethod
def __setup__(cls):
super(AccountInvoiceMilestoneGroup, cls).__setup__()
cls._buttons.update({
'check_triggers': {
'readonly': Eval('state').in_(['completed', 'paid']),
'icon': 'tryton-executable',
},
'close': {
'readonly': Eval('state').in_(['completed', 'paid']),
'icon': 'tryton-ok',
},
})
cls._error_messages.update({
'group_with_pending_milestones': (
'The Milestone Group "%s" has some pending milestones.\n'
'Please, process or cancel all milestones before close '
'the group.'),
'missing_milestone_sequence': ('There is no milestone sequence'
'defined. Please define one in account configuration'),
'delete_cancel_draft': ('Can not delete milestone group "%s" '
'because it\'s milestone "%s" is not in draft or cancel '
'state.'),
})
@staticmethod
def default_company():
return Transaction().context.get('company')
@staticmethod
def default_currency():
Company = Pool().get('company.company')
if Transaction().context.get('company'):
company = Company(Transaction().context['company'])
return company.currency.id
@staticmethod
def default_currency_digits():
Company = Pool().get('company.company')
if Transaction().context.get('company'):
company = Company(Transaction().context['company'])
return company.currency.digits
return 2
@fields.depends('currency')
def on_change_with_currency_digits(self, name=None):
if self.currency:
return self.currency.digits
return 2
def get_state(self, name):
if not self.sales:
return 'to_assign'
if self.amount_to_assign > _ZERO:
return 'to_assign'
paid = True
completed = True
for milestone in self.milestones:
if milestone.state in ('failed', 'cancel'):
continue
if (milestone.state in ('draft', 'confirmed')
and ((milestone.invoice_method
in ('shipped_goods', 'sale_lines')
and not milestone.sale_lines_to_invoice)
or (milestone.invoice_method == 'remainder'
and not milestone.sales_to_invoice))):
return 'to_assign'
if not milestone.invoice or milestone.invoice.state != 'paid':
paid = False
if (not milestone.invoice
or milestone.invoice.state not in ('posted', 'paid')):
completed = False
if paid:
return 'paid'
if completed and self.total_amount == self.invoiced_amount:
return 'completed'
return 'pending'
@classmethod
def get_amounts(cls, groups, names):
result = {}
for name in names:
result[name] = {}
for group in groups:
group_amounts = group._get_amounts(names)
for fname in names:
result[fname][group.id] = group_amounts[fname]
return result
def _get_amounts(self, names):
"""
Any of these amounts take care about advancement amounts.
total_amount is the sum of sale's untaxed amount
amount_to_assign is the sale's untaxed amount for sales without
remainder milestone substracting the amount of stock moves assigned
to any milestone
assigned_amount is the difference between total and to assign amount
invoiced_amount is the sum of milestone's invoices (included draft
invoices)
amount_to_invoice is the difference between total and invoiced amount
"""
pool = Pool()
Milestone = pool.get('account.invoice.milestone')
Uom = pool.get('product.uom')
SaleLine = pool.get('sale.line')
def get_ignored_moves_amount(sale_line):
ignored_amount = Decimal('0')
for ignored_move in sale_line.moves_ignored:
sign = (Decimal('1')
if ignored_move.to_location.type == 'customer'
else Decimal('-1'))
move_qty = Uom.compute_qty(ignored_move.uom,
ignored_move.quantity, ignored_move.origin.unit)
ignored_amount += (Decimal(str(move_qty))
* ignored_move.unit_price * sign)
return ignored_amount
names_set = set(names)
sales_in_live_remainders = []
res = {}.fromkeys(['total_amount', 'merited_amount',
'amount_to_assign', 'assigned_amount', 'amount_to_invoice',
'invoiced_amount'],
_ZERO)
for sale in self.sales:
if sale.state not in ('confirmed', 'processing', 'done'):
continue
res['total_amount'] += sale.untaxed_amount
if {'amount_to_assign', 'assigned_amount'} & names_set:
if sale.remainder_milestones and any(m.state == 'confirmed'
for m in sale.remainder_milestones):
res['assigned_amount'] += sale.untaxed_amount
sales_in_live_remainders.append(sale.id)
# skip_inv_line_ids = set(l.id for i in sale.invoices_recreated
# for l in i.lines)
for sale_line in sale.lines:
if sale.invoice_method == 'shipment':
res['total_amount'] -= get_ignored_moves_amount(sale_line)
if (sale_line.product
and sale_line.product.type != 'service'):
res['merited_amount'] += sale_line.shipped_amount
elif sale.state in ('processing', 'done'):
res['merited_amount'] += sale_line.amount
if {'amount_to_assign', 'assigned_amount', 'invoiced_amount',
'amount_to_invoice'} & names_set:
assigned_sale_lines = []
for milestone in self.milestones:
if milestone.state in ('draft', 'failed', 'cancel'):
continue
if milestone.invoice and milestone.invoice.state != 'cancel':
for inv_line in milestone.invoice.lines:
if (not (isinstance(inv_line.origin, Milestone)
and inv_line.origin == milestone)
and not (isinstance(inv_line.origin, SaleLine)
and inv_line.origin.sale in self.sales)):
# exclude invoice lines not related to this
# milestone group
continue
sign = (Decimal('1.0')
if inv_line.invoice_type == 'out_invoice'
else Decimal('-1.0'))
res['invoiced_amount'] += inv_line.amount * sign
if (milestone.invoice_method == 'remainder'
and isinstance(inv_line.origin, SaleLine)
and (inv_line.origin.sale.id
not in sales_in_live_remainders)):
# not advancement invoice/compensation
res['assigned_amount'] += inv_line.amount * sign
if ({'amount_to_assign', 'assigned_amount'} & names_set
and milestone.invoice_method
in ('shipped_goods', 'sale_lines')):
for sale_line in milestone.sale_lines_to_invoice:
if (sale_line.id in assigned_sale_lines or
sale_line.sale.id in sales_in_live_remainders):
continue
assigned_sale_lines.append(sale_line.id)
if sale_line.sale.invoice_method == 'shipment':
res['assigned_amount'] += (sale_line.amount
- get_ignored_moves_amount(sale_line))
else:
res['assigned_amount'] += sale_line.amount
if 'amount_to_invoice' in names:
res['amount_to_invoice'] = max(Decimal('0'),
res['total_amount'] - res['invoiced_amount'])
if 'amount_to_assign' in names:
res['amount_to_assign'] = (res['total_amount']
- res['assigned_amount'])
for fname in res.keys():
if fname not in names:
del res[fname]
return res
@property
def invoiced_advancement_amount(self):
pool = Pool()
InvoiceLine = pool.get('account.invoice.line')
advancement_milestone_ids = [m.id for m in self.milestones
if m.invoice_method == 'amount']
if not advancement_milestone_ids:
return _ZERO
advancement_invoice_lines = InvoiceLine.search([
('invoice.state', '!=', 'cancel'),
('origin.group', '=', self.id, 'account.invoice.milestone'),
])
if not advancement_invoice_lines:
return _ZERO
return sum(il.amount for il in advancement_invoice_lines)
@classmethod
@ModelView.button
def check_triggers(cls, groups=None):
if groups is None:
company_id = Transaction().context.get('company')
groups = cls.search([
('company', '=', company_id),
])
for group in groups:
sales_from = [s for s in group.sales
if s.state in _TRIGGER_SALE_STATES]
group.check_trigger_condition(sales_from)
def check_trigger_condition(self, sales_from):
pool = Pool()
Milestone = pool.get('account.invoice.milestone')
assert all(s.state in _TRIGGER_SALE_STATES for s in sales_from)
todo = []
for milestone in self.milestones:
if (milestone.state != 'confirmed' or milestone.kind == 'manual'
or milestone.invoice):
continue
if milestone.trigger == 'confirmed_sale':
# Milestones on order confirmed
if all(l.sale in sales_from for l in milestone.trigger_lines):
todo.append(milestone)
elif milestone.trigger == 'sent_sale':
# Milestones on order done
if all((l.sale in sales_from
and l.sale.shipment_state == 'sent')
for l in milestone.trigger_lines):
todo.append(milestone)
# trigger == 'shipped_amount'
elif milestone.trigger_shipped_amount == _ZERO:
if all((l.sale in sales_from
and l.sale.state in ('processing', 'done'))
for l in milestone.trigger_lines):
todo.append(milestone)
elif milestone.trigger_shipped_amount == Decimal('1.0'):
# Milestones on order sent
if all(l.move_done for l in milestone.trigger_lines):
todo.append(milestone)
else:
# compute as shipped lines the lines without product or with
# service product only if line's sale is processing or done
shipped_amount = total_amount = _ZERO
for sale_line in milestone.trigger_lines:
if (sale_line.product
and sale_line.product.type != 'service'
and sale_line.amount != _ZERO):
shipped_amount += sale_line.shipped_amount
total_amount += sale_line.amount
else:
if sale_line.sale.state in ('processing', 'done'):
shipped_amount += sale_line.amount
total_amount += sale_line.amount
shipped_percentage = d_round(shipped_amount / total_amount,
Milestone.trigger_shipped_amount.digits[1])
if shipped_percentage >= milestone.trigger_shipped_amount:
todo.append(milestone)
if todo:
Milestone.do_invoice(todo)
@classmethod
@ModelView.button
def close(cls, groups):
pool = Pool()
Milestone = pool.get('account.invoice.milestone')
to_create = []
for group in groups:
if any(m.state not in ('succeeded', 'failed', 'cancel')
for m in group.milestones):
cls.raise_user_error('group_with_pending_milestones',
(group.rec_name,))
milestone = group._get_closing_milestone()
if milestone:
to_create.append(milestone._save_values)
if to_create:
milestones = Milestone.create(to_create)
Milestone.confirm(milestones)
Milestone.do_invoice(milestones)
def _get_closing_milestone(self):
'Returns the milestone needed to fill all the amount of the group'
pool = Pool()
Date = pool.get('ir.date')
Milestone = pool.get('account.invoice.milestone')
sales_to_invoice = [s for s in self.sales]
if not sales_to_invoice:
return
milestone = Milestone()
milestone.group = self
# milestone.description =
milestone.kind = 'manual'
milestone.invoice_method = 'remainder'
milestone.sales_to_invoice = sales_to_invoice
milestone.invoice_date = Date.today()
return milestone
@classmethod
def copy(cls, groups, default=None):
if default is None:
default = {}
default.setdefault('code', None)
default['sales'] = None
with Transaction().set_context(milestone_group_copy=True):
return super(AccountInvoiceMilestoneGroup, cls).copy(groups,
default)
@classmethod
def create(cls, vlist):
pool = Pool()
Sequence = pool.get('ir.sequence')
Config = pool.get('account.configuration')
config = Config(1)
if not config.milestone_group_sequence:
cls.raise_user_error('missing_milestone_sequence')
for value in vlist:
if value.get('code'):
continue
value['code'] = Sequence.get_id(config.milestone_group_sequence.id)
return super(AccountInvoiceMilestoneGroup, cls).create(vlist)
@classmethod
def delete(cls, groups):
pool = Pool()
Milestone = pool.get('account.invoice.milestone')
milestones = Milestone.search([
('group', 'in', [g.id for g in groups]),
('state', 'not in', ['cancel', 'draft']),
], limit=1)
if milestones:
milestone, = milestones
cls.raise_user_error('delete_cancel_draft', (
milestone.group.rec_name, milestone.rec_name))
super(AccountInvoiceMilestoneGroup, cls).delete(groups)
_STATES = {
'readonly': Eval('state') != 'draft',
}
_DEPENDS = ['state']
_STATES_INV_DATE_CALC = {
'readonly': (Bool(Eval('invoice_date'))
| (~Eval('state', '').in_(['draft', 'confirmed']))),
# 'required': ((Eval('kind', '') == 'system')
# & (Eval('state', '') == 'confirmed')
# & (~Bool(Eval('invoice_date')))),
'invisible': Eval('kind', '') == 'manual',
}
_DEPENDS_INV_DATE_CALC = ['invoice_date', 'kind', 'state']
_DESCRIPTION_STATES = {
'readonly': ~Eval('state').in_(['draft', 'confirmed', 'processing']),
}
_DEPENDS = ['state']
class AccountInvoiceMilestone(Workflow, ModelSQL, ModelView):
'Account Invoice Milestone'
__name__ = 'account.invoice.milestone'
_rec_name = 'code'
group = fields.Many2One('account.invoice.milestone.group',
'Milestone Group', required=True, select=True, states=_STATES,
depends=_DEPENDS, ondelete='CASCADE')
company = fields.Function(fields.Many2One('company.company', 'Company'),
'on_change_with_company', searcher='search_company')
currency_digits = fields.Function(fields.Integer('Currency Digits'),
'on_change_with_currency_digits')
party = fields.Function(fields.Many2One('party.party', 'Party'),
'on_change_with_party', searcher='search_party')
code = fields.Char('Code', required=True, readonly=True)
description = fields.Text('Description', states=_DESCRIPTION_STATES,
depends=_DEPENDS,
help='It will be used to prepare the description field of invoice '
'lines.\nYou can use the next tags and they will be replaced by these '
'fields from the sale\'s related to milestone: {sale_description}, '
'{sale_reference}.')
state = fields.Selection([
('draft', 'Draft'),
('confirmed', 'Confirmed'),
('processing', 'Processing'),
('succeeded', 'Succeeded'),
('failed', 'Failed'),
('cancel', 'Cancelled'),
], 'State', readonly=True, select=True)
processed_date = fields.Date('Processed Date', readonly=True)
kind = fields.Selection(_KIND, 'Kind', required=True, select=True,
states=_STATES, depends=_DEPENDS)
trigger = fields.Selection(_TRIGGER, 'Trigger', sort=False,
states={
'readonly': Eval('state') != 'draft',
'required': Eval('kind') == 'system',
'invisible': Eval('kind') != 'system',
}, depends=['state', 'kind'],
help='Defines when the Milestone will be confirmed and its Invoice '
'Date calculated.')
trigger_shipped_amount = fields.Numeric('On Shipped Amount',
digits=(16, 8),
domain=[
['OR', [
('trigger_shipped_amount', '=', None),
], [
('trigger_shipped_amount', '>=', 0),
('trigger_shipped_amount', '<=', 1),
]],
],
states={
'readonly': Eval('state') != 'draft',
'required': ((Eval('kind') == 'system')
& (Eval('trigger') == 'shipped_amount')),
'invisible': ((Eval('kind') != 'system')
| (Eval('trigger') != 'shipped_amount')),
},
depends=['state', 'party', 'invoice_method', 'kind'],
help="The percentage of sent amount over the total amount of "
"Milestone's Trigger Sale Lines.")
trigger_lines = fields.Many2Many(
'account.invoice.milestone-trigger-sale.line',
'milestone', 'sale_line', 'Trigger Lines',
domain=[
('type', '=', 'line'),
('sale.milestone_group', '=', Eval('group', -1)),
],
states={
'readonly': Eval('state') != 'draft',
'required': ((Eval('state') == 'confirmed') &
(Eval('kind') == 'system')),
'invisible': Eval('kind') == 'manual',
}, depends=['group', 'state', 'kind'])
invoice_method = fields.Selection([
('amount', 'Amount'),
('shipped_goods', 'Shipped Goods'),
('sale_lines', 'Sale Lines'),
('remainder', 'Remainder'),
], 'Invoice Method', required=True, select=True, sort=False,
domain=[
If(Eval('trigger', '') == 'confirmed_sale',
('invoice_method', '!=', 'shipped_goods'),
('invoice_method', '!=', None)),
],
states=_STATES, depends=_DEPENDS + ['trigger'])
amount = fields.Numeric('Amount', digits=(16, Eval('currency_digits', 2)),
states={
'readonly': Eval('state') != 'draft',
'required': Eval('invoice_method') == 'amount',
'invisible': Eval('invoice_method') != 'amount',
}, depends=['currency_digits', 'state', 'invoice_method'])
sale_lines_to_invoice = fields.Many2Many(
'account.invoice.milestone-to_invoice-sale.line',
'milestone', 'sale_line', 'Sale Lines to Invoice',
domain=[
('type', '=', 'line'),
('sale.milestone_group', '=', Eval('group', -1)),
# company domain is "inherit" from milestone_group
],
states={
'readonly': ~Eval('state').in_(['draft', 'confirmed']),
'required': ((Eval('state') == 'processing') &
(Eval('invoice_method').in_(['shipped_goods', 'sale_lines']))),
'invisible': (
~Eval('invoice_method').in_(['shipped_goods', 'sale_lines'])),
}, depends=['group', 'state', 'invoice_method'])
sales_to_invoice = fields.Many2Many(
'account.invoice.milestone-remainder-sale.sale', 'milestone', 'sale',
'Sales to Invoice', domain=[
('milestone_group', '=', Eval('group', -1)),
# company domain is "inherit" from milestone_group
],
states={
'readonly': ~Eval('state').in_(['draft', 'confirmed']),
'required': ((Eval('state') == 'processing') &
(Eval('invoice_method') == 'remainder')),
'invisible': Eval('invoice_method') != 'remainder',
},
depends=['group', 'state', 'invoice_method'])
day = fields.Integer('Day of Month', domain=[
['OR', [
('day', '=', None),
], [
('day', '>=', 1),
('day', '<=', 31),
]],
],
states=_STATES_INV_DATE_CALC, depends=_DEPENDS_INV_DATE_CALC)
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, states=_STATES_INV_DATE_CALC,
depends=_DEPENDS_INV_DATE_CALC)
weekday = fields.Selection([
(None, ''),
('0', 'Monday'),
('1', 'Tuesday'),
('2', 'Wednesday'),
('3', 'Thursday'),
('4', 'Friday'),
('5', 'Saturday'),
('6', 'Sunday'),
], 'Day of Week', sort=False, states=_STATES_INV_DATE_CALC,
depends=_DEPENDS_INV_DATE_CALC)
months = fields.Integer('Number of Months', required=True,
states=_STATES_INV_DATE_CALC, depends=_DEPENDS_INV_DATE_CALC)
weeks = fields.Integer('Number of Weeks', required=True,
states=_STATES_INV_DATE_CALC, depends=_DEPENDS_INV_DATE_CALC)
days = fields.Integer('Number of Days', required=True,
states=_STATES_INV_DATE_CALC, depends=_DEPENDS_INV_DATE_CALC)
invoice_date = fields.Date('Invoice Date', states={
'readonly': ~Eval('state', '').in_(['draft', 'confirmed']),
'required': Eval('state', '').in_(['processing', 'succeeded']),
}, depends=['state'])
planned_invoice_date = fields.Date('Planned Invoice Date')
invoice = fields.One2One('account.invoice-account.invoice.milestone',
'milestone', 'invoice', 'Invoice', readonly=True,
domain=[
('company', '=', Eval('company')),
('party', '=', Eval('party')),
],
states={
'required': Eval('state', '').in_(['processing', 'succeeded']),
},
depends=['company', 'party', 'state'])
advancement_product = fields.Many2One('product.product',
'Advancement Product', states={
'readonly': Eval('state') != 'draft',
'required': Eval('invoice_method') == 'amount',
'invisible': Eval('invoice_method') != 'amount',
}, depends=['currency_digits', 'state', 'invoice_method'])
@classmethod
def __setup__(cls):
super(AccountInvoiceMilestone, cls).__setup__()
cls._transitions |= set((
('draft', 'confirmed'),
('confirmed', 'processing'),
('processing', 'succeeded'),
('processing', 'failed'),
('succeeded', 'failed'), # If invoice is cancelled after post
('succeeded', 'processing'), # If invoice is draft after post
('draft', 'cancel'),
('confirmed', 'cancel'),
('cancel', 'draft'),
))
cls._buttons.update({
'draft': {
'invisible': Eval('state') != 'cancel',
'icon': 'tryton-clear',
},
'confirm': {
'invisible': Eval('state') != 'draft',
'icon': 'tryton-ok',
},
'do_invoice': {
'invisible': ((Eval('state') != 'confirmed') |
(Eval('kind') != 'manual')),
'icon': 'tryton-ok',
},
'cancel': {
'invisible': ~Eval('state').in_(['draft', 'confirmed']),
'icon': 'tryton-cancel',
},
})
cls._error_messages.update({
'invalid_invoice_method': (
'Invalid combination of invoice method and trigger on '
'milestone "%(milestone)s" and sales %(sales)s.'),
'reset_milestone_in_closed_group': (
'You cannot reset to draft the Milestone "%s" because it '
'belongs to a closed Milestone Group.'),
'invoice_not_done_move': ('Milestone "%(milestone)s" can not '
'be invoiced because its move "%(move)s is not done.'),
'no_advancement_product': ('An advancement product must be '
'defined in order to generate advancement invoices.\n'
'Please define one in account configuration.'),
'missing_milestone_sequence': ('There is no milestone sequence'
'defined. Please define one in account configuration'),
})
@fields.depends('group')
def on_change_with_company(self, name=None):
return (self.group.company.id if self.group and self.group.company
else None)
@classmethod
def search_company(cls, name, clause):
return [('group.company',) + tuple(clause[1:])]
@fields.depends('group')
def on_change_with_currency_digits(self, name=None):
if self.group:
return self.group.currency_digits
return 2
@staticmethod
def default_party():
return Transaction().context.get('party')
@fields.depends('group')
def on_change_with_party(self, name=None):
return self.group.party.id if self.group else None
@classmethod
def search_party(cls, name, clause):
return [('group.party',) + tuple(clause[1:])]
@staticmethod
def default_state():
return 'draft'
@staticmethod
def default_kind():
return 'manual'
@staticmethod
def default_invoice_method():
return 'amount'
@staticmethod
def default_months():
return 0
@staticmethod
def default_weeks():
return 0
@staticmethod
def default_days():
return 0
@classmethod
def validate(cls, milestones):
super(AccountInvoiceMilestone, cls).validate(milestones)
for milestone in milestones:
milestone.check_sale_invoice_method()
def check_sale_invoice_method(self):
if self.kind != 'system' or self.trigger != 'confirmed_sale':
return
if self.invoice_method == 'remainder':
sales_shipment_invoice = [s for s in self.sales_to_invoice
if s.invoice_method == 'shipment']
if sales_shipment_invoice:
self.raise_user_error('invalid_invoice_method', {
'milestone': self.rec_name,
'sales': ', '.join('"%s"' % s.rec_name
for s in sales_shipment_invoice),
})
if self.invoice_method == 'sale_lines':
sales_shipment_invoice = set([sl.sale
for sl in self.sale_lines_to_invoice
if sl.sale.invoice_method == 'shipment'])
if sales_shipment_invoice:
self.raise_user_error('invalid_invoice_method', {
'milestone': self.rec_name,
'sales': ', '.join('"%s"' % s.rec_name
for s in sales_shipment_invoice),
})
@classmethod
@ModelView.button
@Workflow.transition('draft')
def draft(cls, milestones):
for milestone in milestones:
if milestone.group.state in ('completed', 'paid'):
cls.raise_user_error('reset_milestone_in_closed_group',
(milestone.rec_name,))
@classmethod
@ModelView.button
@Workflow.transition('confirmed')
def confirm(cls, milestones):
pass
@classmethod
@ModelView.button
def do_invoice(cls, milestones):
pool = Pool()
Date = pool.get('ir.date')
today = Date.today()
to_cancel = []
to_confirm = []
to_proceed = []
for milestone in milestones:
if milestone.state != 'confirmed' or milestone.invoice:
continue
save_milestone = False
if not milestone.invoice_date:
milestone.invoice_date = (
milestone._calc_invoice_date())
save_milestone = True
if (milestone.kind == 'system' and
milestone.invoice_date > today):
# Don't create invoices if it's not the time
if save_milestone:
milestone.save()
continue
invoice = milestone.create_invoice()
if invoice:
milestone.invoice = invoice
milestone.save()
to_proceed.append(milestone)
elif save_milestone:
milestone.save()
pending_sale_lines = []
if milestone.invoice_method in ('shipped_goods', 'sale_lines'):
pending_sale_lines = [sl.id
for sl in milestone.sale_lines_to_invoice
if sl.quantity_to_ship > 0]
pending_sales = []
if milestone.invoice_method == 'remainder':
pending_sales = [s.id for s in milestone.sales_to_invoice
if s.shipment_state != 'sent']
if (not invoice and milestone.kind == 'system'
and milestone.invoice_method in ('shipped_goods',
'sale_lines', 'remainder')
and not pending_sale_lines and not pending_sales):
# triggered milestone with nothing pending to ship
to_cancel.append(milestone)
continue
if invoice and milestone.invoice_method == 'shipped_goods':
pending_sale_lines = []
for sale_line in milestone.sale_lines_to_invoice:
if sale_line.quantity_to_ship <= 0:
continue
if (sale_line.invoice_method == 'order'
and sale_line.quantity_to_invoice <= 0):
continue
pending_sale_lines.append(sale_line)
if pending_sale_lines:
new_milestone, = cls.copy([milestone], {
'sale_lines_to_invoice': [
('add', pending_sale_lines),
],
})
to_confirm.append(new_milestone)
if to_cancel:
cls.cancel(to_cancel)
if to_confirm:
cls.confirm(to_confirm)
if to_proceed:
cls.proceed(to_proceed)
@classmethod
@Workflow.transition('processing')
def proceed(cls, milestones):
pool = Pool()
Date = pool.get('ir.date')
cls.write(milestones, {
'processed_date': Date.today(),
})
@classmethod
@Workflow.transition('succeeded')
def succeed(cls, milestones):
for milestone in milestones:
if (milestone.invoice and milestone.invoice.invoice_date
and milestone.invoice.invoice_date
!= milestone.invoice_date):
milestone.invoice_date = milestone.invoice.invoice_date
milestone.save()
@classmethod
@Workflow.transition('failed')
def fail(cls, milestones):
pass
@classmethod
@ModelView.button
@Workflow.transition('cancel')
def cancel(cls, milestiones):
pass
def _calc_invoice_date(self):
pool = Pool()
Date = pool.get('ir.date')
today = Date.today()
return today + relativedelta(**self._calc_delta())
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,
}
def create_invoice(self):
pool = Pool()
Invoice = pool.get('account.invoice')
invoice_type, invoice_lines = self._get_invoice_type_and_lines()
if not invoice_lines:
return
invoice = self._get_invoice(invoice_type)
invoice.lines = ((list(invoice.lines)
if hasattr(invoice, 'lines') else [])
+ invoice_lines)
invoice.save()
Invoice.update_taxes([invoice])
return invoice
def _get_invoice(self, invoice_type):
pool = Pool()
Invoice = pool.get('account.invoice')
Journal = pool.get('account.journal')
PaymentTerm = pool.get('account.invoice.payment_term')
journals = Journal.search([
('type', '=', 'revenue'),
], limit=1)
if journals:
journal, = journals
else:
journal = None
payment_term = self.party.customer_payment_term
if not payment_term:
terms = PaymentTerm.search([], limit=1)
if terms:
payment_term = terms[0]
invoice = Invoice()
invoice.company = self.group.company
invoice.type = invoice_type
invoice.journal = journal
invoice.party = self.party
invoice.invoice_address = self.party.address_get(type='invoice')
invoice.currency = self.group.currency
invoice.account = self.party.account_receivable
invoice.payment_term = payment_term
invoice.invoice_date = self.invoice_date
if hasattr(self.party, 'agent'):
# Compatibility with commission_party
invoice.agent = self.party.agent
return invoice
def _get_invoice_type_and_lines(self):
lines = []
amount = _ZERO
if self.invoice_method == 'amount':
for invoice_type in ('out_invoice', 'out_credit_note'):
line = self._get_advancement_invoice_line(invoice_type)
if line:
amount += (self.amount if invoice_type == 'out_invoice'
else -self.amount)
lines.append(line)
else:
if self.invoice_method in ('shipped_goods', 'sale_lines'):
lines += self._get_sale_lines_invoice_lines()
else: # remainder
for sale in self.sales_to_invoice:
for sale_line in sale.lines:
lines += sale_line.get_invoice_line('out_invoice')
lines += sale_line.get_invoice_line(
'out_credit_note')
if lines:
amount = sum((Decimal(str(l.quantity)) * l.unit_price
* (Decimal('1.0') if l.invoice_type == 'out_invoice'
else Decimal('-1.0'))) for l in lines)
compensation_line = self.get_compensation_line(amount)
if compensation_line:
amount += (Decimal(str(compensation_line.quantity))
* compensation_line.unit_price)
lines.append(compensation_line)
invoice_type = ('out_credit_note' if amount < _ZERO
else 'out_invoice')
for line in lines:
if line.invoice_type != invoice_type:
line.invoice_type = invoice_type
line.quantity *= -1
return invoice_type, lines
def _get_advancement_invoice_line(self, invoice_type):
pool = Pool()
InvoiceLine = pool.get('account.invoice.line')
if self.state != 'confirmed' or self.invoice_method != 'amount':
return
if invoice_type == 'out_credit_note' and self.amount > _ZERO:
return
if invoice_type == 'out_invoice' and self.amount < _ZERO:
return
product = self.advancement_product
sales = list(set(l.sale for l in self.trigger_lines))
with Transaction().set_user(0, set_context=True):
invoice_line = InvoiceLine()
invoice_line.invoice_type = invoice_type
invoice_line.party = self.party
invoice_line.type = 'line'
invoice_line.sequence = 1
invoice_line.product = product
invoice_line.description = self.calc_invoice_line_description(sales)
invoice_line.quantity = 1.0
invoice_line.unit = product.default_uom
for key, value in invoice_line.on_change_product().iteritems():
setattr(invoice_line, key, value)
invoice_line.unit_price = abs(self.amount)
invoice_line.origin = self
return invoice_line
def _get_sale_lines_invoice_lines(self):
invoice_lines = []
for sale_line in self.sale_lines_to_invoice:
if (self.invoice_method == 'shipped_goods'
and sale_line.shipped_amount <= 0):
continue
invoice_type = ('out_credit_note' if sale_line.quantity < 0.0
else 'out_invoice')
inv_line_desc = self.calc_invoice_line_description(
[sale_line.sale])
with Transaction().set_context(
milestone_invoice_line_description=inv_line_desc):
invoice_lines += sale_line.get_invoice_line(invoice_type)
return invoice_lines
def calc_invoice_line_description(self, sales):
if (not self.description or not sales
or ('sale_reference' not in self.description
and 'sale_description' not in self.description)):
return self.description
if not sales:
return self.description
description = self.description
if '{sale_reference}' in description:
description = description.replace('{sale_reference}',
", ".join(s.reference for s in sales))
if '{sale_description}' in description:
description = description.replace('{sale_description}',
", ".join(s.description for s in sales if s.description))
return description
def get_compensation_line(self, invoice_amount):
pool = Pool()
InvoiceLine = pool.get('account.invoice.line')
amount = self.group.invoiced_advancement_amount
if self.invoice_method == 'remainder':
if (self.group.merited_amount == self.group.total_amount
and (self.group.invoiced_amount - amount + invoice_amount)
== self.group.merited_amount):
# It closes the milestone group
invoice_amount = None
if invoice_amount is not None and invoice_amount < amount:
amount = invoice_amount
if amount == _ZERO:
return
with Transaction().set_user(0, set_context=True):
invoice_line = InvoiceLine()
invoice_line.invoice_type = 'out_invoice'
invoice_line.type = 'line'
invoice_line.sequence = 1
invoice_line.description = ''
invoice_line.origin = self
invoice_line.party = self.group.party
product = self.advancement_product
invoice_line.product = product
invoice_line.unit = product.default_uom
for key, value in invoice_line.on_change_product().iteritems():
setattr(invoice_line, key, value)
invoice_line.quantity = -1.0
invoice_line.unit_price = amount
return invoice_line
@classmethod
def default_advancement_product(cls):
pool = Pool()
Config = pool.get('account.configuration')
config = Config.get_singleton()
if not config.milestone_advancement_product:
cls.raise_user_error('no_advancement_product')
return config.milestone_advancement_product.id
@classmethod
def copy(cls, milestones, default=None):
if default is None:
default = {}
default.setdefault('code', None)
default.setdefault('processed_date', None)
default.setdefault('invoice_date', None)
default.setdefault('invoice', None)
if Transaction().context.get('milestone_group_copy'):
default.setdefault('trigger_lines', [])
default.setdefault('sale_lines_to_invoice', [])
default.setdefault('sales_to_invoice', [])
return super(AccountInvoiceMilestone, cls).copy(milestones, default)
@classmethod
def create(cls, vlist):
pool = Pool()
Sequence = pool.get('ir.sequence')
Config = pool.get('account.configuration')
config = Config(1)
if not config.milestone_sequence:
cls.raise_user_error('missing_milestone_sequence')
for value in vlist:
if value.get('code'):
continue
value['code'] = Sequence.get_id(config.milestone_sequence.id)
return super(AccountInvoiceMilestone, cls).create(vlist)
class AccountInvoiceMilestoneTriggerSaleLine(ModelSQL):
'Account Invoice Milestone - Trigger - Sale Line'
__name__ = 'account.invoice.milestone-trigger-sale.line'
milestone = fields.Many2One('account.invoice.milestone',
'Account Invoice Milestone', ondelete='CASCADE', required=True,
select=True)
sale_line = fields.Many2One('sale.line', 'Sale Line', ondelete='CASCADE',
required=True, select=True)
class AccountInvoiceMilestoneToInvoiceSaleLine(ModelSQL):
'Account Invoice Milestone - To Invoice - Sale Line'
__name__ = 'account.invoice.milestone-to_invoice-sale.line'
milestone = fields.Many2One('account.invoice.milestone',
'Account Invoice Milestone', ondelete='CASCADE', required=True,
select=True)
sale_line = fields.Many2One('sale.line', 'Sale Line', ondelete='CASCADE',
required=True, select=True)
class AccountInvoiceMilestoneRemainderSale(ModelSQL):
'Account Invoice Milestone - Remainder - Sale'
__name__ = 'account.invoice.milestone-remainder-sale.sale'
milestone = fields.Many2One('account.invoice.milestone',
'Account Invoice Milestone', ondelete='CASCADE', required=True,
select=True)
sale = fields.Many2One('sale.sale', 'Sale', ondelete='CASCADE',
required=True, select=True)