496 lines
17 KiB
Python
496 lines
17 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 decimal import Decimal
|
|
from itertools import chain
|
|
|
|
from simpleeval import simple_eval
|
|
|
|
from trytond import backend, config
|
|
from trytond.i18n import gettext
|
|
from trytond.model import (
|
|
DeactivableMixin, ModelSQL, ModelView, Workflow, fields)
|
|
from trytond.modules.company.model import (
|
|
CompanyMultiValueMixin, CompanyValueMixin)
|
|
from trytond.modules.currency.fields import Monetary
|
|
from trytond.pool import Pool, PoolMeta
|
|
from trytond.pyson import Bool, Eval
|
|
from trytond.tools import decistmt
|
|
from trytond.transaction import Transaction
|
|
|
|
from .exceptions import FormulaError
|
|
|
|
|
|
class AdvancePaymentTerm(
|
|
DeactivableMixin, ModelSQL, ModelView):
|
|
__name__ = 'sale.advance_payment_term'
|
|
|
|
name = fields.Char("Name", required=True, translate=True)
|
|
lines = fields.One2Many(
|
|
'sale.advance_payment_term.line', 'advance_payment_term', "Lines")
|
|
|
|
def get_advance_payment_context(self, sale):
|
|
return {
|
|
'total_amount': sale.total_amount,
|
|
'untaxed_amount': sale.untaxed_amount,
|
|
}
|
|
|
|
def get_lines(self, sale):
|
|
lines = []
|
|
term_context = self.get_advance_payment_context(sale)
|
|
for sale_line in self.lines:
|
|
line = sale_line.get_line(sale.currency, **term_context)
|
|
if line.amount > 0:
|
|
lines.append(line)
|
|
return lines
|
|
|
|
|
|
class AdvancePaymentTermLine(ModelView, ModelSQL, CompanyMultiValueMixin):
|
|
__name__ = 'sale.advance_payment_term.line'
|
|
_rec_name = 'description'
|
|
|
|
advance_payment_term = fields.Many2One(
|
|
'sale.advance_payment_term', "Advance Payment Term",
|
|
required=True, ondelete='CASCADE')
|
|
description = fields.Char(
|
|
"Description", required=True, translate=True,
|
|
help="Used as description for the invoice line.")
|
|
account = fields.MultiValue(
|
|
fields.Many2One('account.account', "Account", required=True,
|
|
domain=[
|
|
('type.unearned_revenue', '=', True),
|
|
],
|
|
help="Used for the line of advance payment invoice."))
|
|
accounts = fields.One2Many(
|
|
'sale.advance_payment_term.line.account', 'line', "Accounts")
|
|
block_supply = fields.Boolean(
|
|
"Block Supply",
|
|
help="Check to prevent any supply request before advance payment.")
|
|
block_shipping = fields.Boolean(
|
|
"Block Shipping",
|
|
help="Check to prevent the packing of the shipment "
|
|
"before advance payment.")
|
|
invoice_delay = fields.TimeDelta(
|
|
"Invoice Delay",
|
|
help="Delta to apply on the sale date for the date of "
|
|
"the advance payment invoice.")
|
|
formula = fields.Char('Formula', required=True,
|
|
help="A python expression used to compute the advance payment amount "
|
|
"that will be evaluated with:\n"
|
|
"- total_amount: The total amount of the sale.\n"
|
|
"- untaxed_amount: The total untaxed amount of the sale.")
|
|
|
|
@classmethod
|
|
def __setup__(cls):
|
|
super().__setup__()
|
|
cls.__access__.add('advance_payment_term')
|
|
|
|
@fields.depends('formula', 'description')
|
|
def pre_validate(self, **names):
|
|
super().pre_validate()
|
|
names['total_amount'] = names['untaxed_amount'] = 0
|
|
try:
|
|
if not isinstance(self.compute_amount(**names), Decimal):
|
|
raise Exception('The formula does not return a Decimal')
|
|
except Exception as exception:
|
|
raise FormulaError(
|
|
gettext('sale_advance_payment.msg_term_line_invalid_formula',
|
|
formula=self.formula,
|
|
term_line=self.description or '',
|
|
exception=exception)) from exception
|
|
|
|
def get_compute_amount_context(self, **names):
|
|
return {
|
|
'names': names,
|
|
'functions': {
|
|
'Decimal': Decimal,
|
|
},
|
|
}
|
|
|
|
def compute_amount(self, **names):
|
|
context = self.get_compute_amount_context(**names)
|
|
return simple_eval(decistmt(self.formula), **context)
|
|
|
|
def get_line(self, currency, **context):
|
|
pool = Pool()
|
|
Line = pool.get('sale.advance_payment.line')
|
|
|
|
return Line(
|
|
block_supply=self.block_supply,
|
|
block_shipping=self.block_shipping,
|
|
amount=currency.round(self.compute_amount(**context)),
|
|
account=self.account,
|
|
invoice_delay=self.invoice_delay,
|
|
description=self.description)
|
|
|
|
|
|
class AdvancePaymentTermLineAccount(ModelSQL, CompanyValueMixin):
|
|
__name__ = 'sale.advance_payment_term.line.account'
|
|
|
|
line = fields.Many2One(
|
|
'sale.advance_payment_term.line', "Line",
|
|
required=True, ondelete='CASCADE',
|
|
context={
|
|
'company': Eval('company', -1),
|
|
})
|
|
account = fields.Many2One(
|
|
'account.account', "Account", required=True,
|
|
domain=[
|
|
('type.unearned_revenue', '=', True),
|
|
('company', '=', Eval('company', -1)),
|
|
])
|
|
|
|
|
|
class AdvancePaymentLine(ModelSQL, ModelView):
|
|
__name__ = 'sale.advance_payment.line'
|
|
_rec_name = 'description'
|
|
|
|
_states = {
|
|
'readonly': Eval('sale_state') != 'draft',
|
|
}
|
|
|
|
sale = fields.Many2One(
|
|
'sale.sale', "Sale", required=True, ondelete='CASCADE',
|
|
states={
|
|
'readonly': ((Eval('sale_state') != 'draft')
|
|
& Bool(Eval('sale'))),
|
|
})
|
|
description = fields.Char("Description", required=True, states=_states)
|
|
amount = Monetary(
|
|
"Amount", currency='currency', digits='currency', states=_states)
|
|
account = fields.Many2One(
|
|
'account.account', "Account", required=True,
|
|
domain=[
|
|
('type.unearned_revenue', '=', True),
|
|
('company', '=', Eval('sale_company', -1)),
|
|
],
|
|
states=_states)
|
|
block_supply = fields.Boolean("Block Supply", states=_states)
|
|
block_shipping = fields.Boolean("Block Shipping", states=_states)
|
|
invoice_delay = fields.TimeDelta("Invoice Delay", states=_states)
|
|
|
|
invoice_lines = fields.One2Many(
|
|
'account.invoice.line', 'origin', "Invoice Lines", readonly=True)
|
|
completed = fields.Function(fields.Boolean("Completed"), 'get_completed')
|
|
|
|
sale_state = fields.Function(fields.Selection(
|
|
'get_sale_states', "Sale State"), 'on_change_with_sale_state')
|
|
sale_company = fields.Function(fields.Many2One(
|
|
'company.company', "Company"), 'on_change_with_sale_company')
|
|
currency = fields.Function(fields.Many2One(
|
|
'currency.currency', "Currency"),
|
|
'on_change_with_currency')
|
|
|
|
del _states
|
|
|
|
@classmethod
|
|
def __setup__(cls):
|
|
super().__setup__()
|
|
cls._order.insert(0, ('amount', 'ASC'))
|
|
cls.__access__.add('sale')
|
|
|
|
@classmethod
|
|
def __register__(cls, module):
|
|
# Migration from 7.0: rename condition into line
|
|
backend.TableHandler.table_rename(
|
|
config.get(
|
|
'table', 'sale.advance_payment.condition',
|
|
default='sale_advance_payment_condition'),
|
|
cls._table)
|
|
super().__register__(module)
|
|
|
|
@classmethod
|
|
def get_sale_states(cls):
|
|
pool = Pool()
|
|
Sale = pool.get('sale.sale')
|
|
return Sale.fields_get(['state'])['state']['selection']
|
|
|
|
@fields.depends('sale', '_parent_sale.state')
|
|
def on_change_with_sale_state(self, name=None):
|
|
if self.sale:
|
|
return self.sale.state
|
|
|
|
@fields.depends('sale', '_parent_sale.company')
|
|
def on_change_with_sale_company(self, name=None):
|
|
return self.sale.company if self.sale else None
|
|
|
|
@fields.depends('sale', '_parent_sale.currency')
|
|
def on_change_with_currency(self, name=None):
|
|
return self.sale.currency if self.sale else None
|
|
|
|
@classmethod
|
|
def copy(cls, lines, default=None):
|
|
if default is None:
|
|
default = {}
|
|
else:
|
|
default = default.copy()
|
|
default.setdefault('invoice_lines', [])
|
|
return super().copy(lines, default)
|
|
|
|
def create_invoice(self):
|
|
invoice = self.sale._get_invoice()
|
|
if self.invoice_delay is not None:
|
|
invoice.invoice_date = self.sale.sale_date + self.invoice_delay
|
|
invoice.payment_term = None
|
|
|
|
invoice_lines = self.get_invoice_advance_payment_lines(invoice)
|
|
if not invoice_lines:
|
|
return None
|
|
invoice.lines = invoice_lines
|
|
return invoice
|
|
|
|
def get_invoice_advance_payment_lines(self, invoice):
|
|
pool = Pool()
|
|
InvoiceLine = pool.get('account.invoice.line')
|
|
|
|
advance_amount = self._get_advance_amount()
|
|
advance_amount += self._get_ignored_amount()
|
|
if advance_amount >= self.amount:
|
|
return []
|
|
|
|
invoice_line = InvoiceLine()
|
|
invoice_line.invoice = invoice
|
|
invoice_line.type = 'line'
|
|
invoice_line.quantity = 1
|
|
invoice_line.account = self.account
|
|
invoice_line.unit_price = self.amount - advance_amount
|
|
invoice_line.description = self.description
|
|
invoice_line.origin = self
|
|
invoice_line.company = self.sale.company
|
|
invoice_line.currency = self.sale.currency
|
|
# Set taxes
|
|
invoice_line.on_change_account()
|
|
return [invoice_line]
|
|
|
|
def _get_advance_amount(self):
|
|
return sum(l.amount for l in self.invoice_lines
|
|
if l.invoice.state != 'cancelled')
|
|
|
|
def _get_ignored_amount(self):
|
|
skips = {l for i in self.sale.invoices_recreated for l in i.lines}
|
|
return sum(l.amount for l in self.invoice_lines
|
|
if l.invoice.state == 'cancelled' and l not in skips)
|
|
|
|
def get_completed(self, name):
|
|
advance_amount = 0
|
|
lines_ignored = set(l for i in self.sale.invoices_ignored
|
|
for l in i.lines)
|
|
for l in self.invoice_lines:
|
|
if l.invoice.state == 'paid' or l in lines_ignored:
|
|
advance_amount += l.amount
|
|
return advance_amount >= self.amount
|
|
|
|
|
|
class Sale(metaclass=PoolMeta):
|
|
__name__ = 'sale.sale'
|
|
|
|
advance_payment_term = fields.Many2One('sale.advance_payment_term',
|
|
'Advance Payment Term',
|
|
ondelete='RESTRICT', states={
|
|
'readonly': Eval('state') != 'draft',
|
|
})
|
|
advance_payment_lines = fields.One2Many(
|
|
'sale.advance_payment.line', 'sale', "Advance Payment Lines",
|
|
states={
|
|
'readonly': Eval('state') != 'draft',
|
|
})
|
|
advance_payment_invoices = fields.Function(fields.Many2Many(
|
|
'account.invoice', None, None, "Advance Payment Invoices"),
|
|
'get_advance_payment_invoices',
|
|
searcher='search_advance_payment_invoices')
|
|
|
|
@classmethod
|
|
def __setup__(cls):
|
|
super().__setup__()
|
|
cls.invoices_ignored.domain = [
|
|
'OR', cls.invoices_ignored.domain, [
|
|
('id', 'in', Eval('advance_payment_invoices', [])),
|
|
('state', '=', 'cancelled'),
|
|
],
|
|
]
|
|
|
|
@classmethod
|
|
@ModelView.button
|
|
@Workflow.transition('quotation')
|
|
def quote(cls, sales):
|
|
pool = Pool()
|
|
AdvancePaymentLine = pool.get('sale.advance_payment.line')
|
|
|
|
super().quote(sales)
|
|
|
|
AdvancePaymentLine.delete(
|
|
list(chain(*(s.advance_payment_lines for s in sales))))
|
|
|
|
for sale in sales:
|
|
sale.set_advance_payment_term()
|
|
cls.save(sales)
|
|
|
|
@classmethod
|
|
def copy(cls, sales, default=None):
|
|
if default is None:
|
|
default = {}
|
|
else:
|
|
default = default.copy()
|
|
default.setdefault('advance_payment_lines', None)
|
|
return super().copy(sales, default=default)
|
|
|
|
def set_advance_payment_term(self):
|
|
pool = Pool()
|
|
AdvancePaymentTerm = pool.get('sale.advance_payment_term')
|
|
if self.advance_payment_term:
|
|
if self.party and self.party.lang:
|
|
with Transaction().set_context(language=self.party.lang.code):
|
|
advance_payment_term = AdvancePaymentTerm(
|
|
self.advance_payment_term.id)
|
|
else:
|
|
advance_payment_term = self.advance_payment_term
|
|
self.advance_payment_lines = advance_payment_term.get_lines(self)
|
|
|
|
def get_advance_payment_invoices(self, name):
|
|
invoices = set()
|
|
for line in self.advance_payment_lines:
|
|
for invoice_line in line.invoice_lines:
|
|
if invoice_line.invoice:
|
|
invoices.add(invoice_line.invoice.id)
|
|
return list(invoices)
|
|
|
|
@classmethod
|
|
def search_advance_payment_invoices(cls, name, clause):
|
|
return [('advance_payment_lines.invoice_lines.invoice'
|
|
+ clause[0][len(name):], *clause[1:])]
|
|
|
|
@property
|
|
def _invoices_for_state(self):
|
|
return super()._invoices_for_state + self.advance_payment_invoices
|
|
|
|
def get_recall_lines(self, invoice):
|
|
pool = Pool()
|
|
InvoiceLine = pool.get('account.invoice.line')
|
|
|
|
recall_lines = []
|
|
advance_lines = [
|
|
l
|
|
for c in self.advance_payment_lines
|
|
for l in c.invoice_lines
|
|
if l.type == 'line' and l.invoice.state == 'paid']
|
|
for advance_line in advance_lines:
|
|
amount = advance_line.amount
|
|
for recalled_line in advance_line.advance_payment_recalled_lines:
|
|
amount += recalled_line.amount
|
|
if amount:
|
|
line = InvoiceLine(
|
|
invoice=invoice,
|
|
company=invoice.company,
|
|
type='line',
|
|
quantity=-1,
|
|
account=advance_line.account,
|
|
unit_price=amount,
|
|
description=advance_line.description,
|
|
origin=advance_line,
|
|
taxes=advance_line.taxes,
|
|
taxes_date=advance_line.tax_date,
|
|
)
|
|
recall_lines.append(line)
|
|
return recall_lines
|
|
|
|
@classmethod
|
|
def _process_invoice(cls, sales):
|
|
pool = Pool()
|
|
Invoice = pool.get('account.invoice')
|
|
invoices = []
|
|
for sale in sales:
|
|
if (sale.advance_payment_eligible()
|
|
and not sale.advance_payment_completed):
|
|
for line in sale.advance_payment_lines:
|
|
invoice = line.create_invoice()
|
|
if invoice:
|
|
invoices.append(invoice)
|
|
Invoice.save(invoices)
|
|
|
|
super()._process_invoice(sales)
|
|
|
|
def create_invoice(self):
|
|
invoice = super().create_invoice()
|
|
|
|
if (invoice is not None
|
|
and self.advance_payment_eligible()
|
|
and self.advance_payment_completed):
|
|
invoice.lines = (
|
|
list(getattr(invoice, 'lines', ()))
|
|
+ self.get_recall_lines(invoice))
|
|
return invoice
|
|
|
|
def advance_payment_eligible(self, shipment_type=None):
|
|
"""
|
|
Returns True when the shipment_type is eligible to further processing
|
|
of the sale's advance payment.
|
|
"""
|
|
return bool((shipment_type == 'out' or shipment_type is None)
|
|
and self.advance_payment_lines)
|
|
|
|
@property
|
|
def advance_payment_completed(self):
|
|
"""
|
|
Returns True when the advance payment process is completed
|
|
"""
|
|
return (bool(self.advance_payment_lines)
|
|
and all(c.completed for c in self.advance_payment_lines))
|
|
|
|
@property
|
|
def supply_blocked(self):
|
|
for line in self.advance_payment_lines:
|
|
if not line.block_supply:
|
|
continue
|
|
if not line.completed:
|
|
return True
|
|
return False
|
|
|
|
@property
|
|
def shipping_blocked(self):
|
|
for line in self.advance_payment_lines:
|
|
if not line.block_shipping:
|
|
continue
|
|
if not line.completed:
|
|
return True
|
|
return False
|
|
|
|
|
|
class SaleLine(metaclass=PoolMeta):
|
|
__name__ = 'sale.line'
|
|
|
|
def get_move(self, shipment_type):
|
|
move = super().get_move(shipment_type)
|
|
if (self.sale.advance_payment_eligible(shipment_type)
|
|
and self.sale.supply_blocked):
|
|
return None
|
|
return move
|
|
|
|
def get_purchase_request(self, product_quantities):
|
|
request = super().get_purchase_request(product_quantities)
|
|
if (self.sale.advance_payment_eligible()
|
|
and self.sale.supply_blocked):
|
|
return None
|
|
return request
|
|
|
|
def get_invoice_line(self):
|
|
lines = super().get_invoice_line()
|
|
if (self.sale.advance_payment_eligible()
|
|
and not self.sale.advance_payment_completed):
|
|
return []
|
|
return lines
|
|
|
|
|
|
class HandleInvoiceException(metaclass=PoolMeta):
|
|
__name__ = 'sale.handle.invoice.exception'
|
|
|
|
def default_ask(self, fields):
|
|
default = super().default_ask(fields)
|
|
invoices = default['domain_invoices']
|
|
|
|
sale = self.record
|
|
skips = set(sale.invoices_ignored)
|
|
skips.update(sale.invoices_recreated)
|
|
for invoice in sale.advance_payment_invoices:
|
|
if invoice.state == 'cancelled' and invoice not in skips:
|
|
invoices.append(invoice.id)
|
|
return default
|