Files
2026-03-14 09:42:12 +00:00

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