434 lines
15 KiB
Python
434 lines
15 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.
|
|
import datetime
|
|
from decimal import Decimal
|
|
|
|
from simpleeval import simple_eval
|
|
|
|
from trytond.i18n import gettext
|
|
from trytond.model import (
|
|
DeactivableMixin, MatchMixin, ModelSQL, ModelView, Workflow, fields)
|
|
from trytond.modules.currency.fields import Monetary
|
|
from trytond.modules.product import price_digits, round_price
|
|
from trytond.modules.product_price_list import Null
|
|
from trytond.pool import Pool, PoolMeta
|
|
from trytond.pyson import Bool, Eval, If
|
|
from trytond.tools import decistmt
|
|
from trytond.transaction import Transaction
|
|
|
|
from .exceptions import FormulaError
|
|
|
|
|
|
class Sale(metaclass=PoolMeta):
|
|
__name__ = 'sale.sale'
|
|
|
|
original_untaxed_amount = fields.Function(Monetary(
|
|
"Original Untaxed", digits='currency', currency='currency',
|
|
states={
|
|
'invisible': (
|
|
~Eval('state').in_(['draft', 'quotation'])
|
|
| (Eval('original_untaxed_amount')
|
|
== Eval('untaxed_amount'))),
|
|
}),
|
|
'get_original_amount')
|
|
original_tax_amount = fields.Function(Monetary(
|
|
"Original Tax", digits='currency', currency='currency',
|
|
states={
|
|
'invisible': (
|
|
~Eval('state').in_(['draft', 'quotation'])
|
|
| (Eval('original_tax_amount') == Eval('tax_amount'))),
|
|
}),
|
|
'get_original_amount')
|
|
original_total_amount = fields.Function(Monetary(
|
|
"Original Total", digits='currency', currency='currency',
|
|
states={
|
|
'invisible': (
|
|
~Eval('state').in_(['draft', 'quotation'])
|
|
| (Eval('original_total_amount') == Eval('total_amount'))),
|
|
}),
|
|
'get_original_amount')
|
|
|
|
def get_original_amount(self, names):
|
|
amounts = dict.fromkeys(names)
|
|
if self.state in {'draft', 'quotation'}:
|
|
amounts['original_untaxed_amount'] = sum(
|
|
(line.original_amount for line in self.line_lines), Decimal(0))
|
|
if {'original_tax_amount', 'original_total_amount'} & set(names):
|
|
with Transaction().set_context(_original_amount=True):
|
|
amounts['original_tax_amount'] = self.get_tax_amount()
|
|
amounts['original_total_amount'] = sum(
|
|
filter(None, amounts.values()))
|
|
return amounts
|
|
|
|
@classmethod
|
|
@ModelView.button
|
|
@Workflow.transition('draft')
|
|
def draft(cls, sales):
|
|
super().draft(sales)
|
|
# Reset to draft unit price
|
|
for sale in sales:
|
|
sale.unapply_promotion()
|
|
cls.save(sales)
|
|
|
|
@classmethod
|
|
@ModelView.button
|
|
@Workflow.transition('quotation')
|
|
def quote(cls, sales):
|
|
super().quote(sales)
|
|
# Store draft unit price before changing it
|
|
for sale in sales:
|
|
sale.apply_promotion()
|
|
cls.save(sales)
|
|
|
|
def unapply_promotion(self):
|
|
"Unapply promotion"
|
|
changed = False
|
|
for line in self.lines:
|
|
if line.type != 'line':
|
|
continue
|
|
if line.original_unit_price is not None:
|
|
line.unit_price = line.original_unit_price
|
|
line.original_unit_price = None
|
|
changed = True
|
|
if line.promotion:
|
|
line.promotion = None
|
|
changed = True
|
|
if changed:
|
|
self.lines = self.lines # Trigger changes
|
|
|
|
def apply_promotion(self):
|
|
"Apply promotion"
|
|
pool = Pool()
|
|
Promotion = pool.get('sale.promotion')
|
|
|
|
for line in self.lines:
|
|
if line.type == 'line' and line.original_unit_price is None:
|
|
line.original_unit_price = line.unit_price
|
|
|
|
promotions = Promotion.get_promotions(self)
|
|
for promotion in promotions:
|
|
promotion.apply(self)
|
|
|
|
|
|
class Line(metaclass=PoolMeta):
|
|
__name__ = 'sale.line'
|
|
|
|
original_unit_price = Monetary(
|
|
"Original Unit Price", digits=price_digits, currency='currency',
|
|
readonly=True,
|
|
states={
|
|
'required': Bool(Eval('promotion', None)),
|
|
'invisible': ~Eval('promotion'),
|
|
})
|
|
original_amount = fields.Function(Monetary(
|
|
"Original Amount", digits='currency', currency='currency',
|
|
states={
|
|
'invisible': ~Eval('promotion'),
|
|
}),
|
|
'get_original_amount')
|
|
promotion = fields.Many2One('sale.promotion', "Promotion",
|
|
ondelete='RESTRICT',
|
|
domain=[
|
|
('company', '=', Eval('company', -1)),
|
|
])
|
|
|
|
@classmethod
|
|
def __register__(cls, module):
|
|
table_h = cls.__table_handler__(module)
|
|
# Migration from 7.4: rename draft_unit_price
|
|
table_h.column_rename('draft_unit_price', 'original_unit_price')
|
|
super().__register__(module)
|
|
|
|
def get_original_amount(self, name):
|
|
currency = self.sale.currency
|
|
|
|
def _amount(line):
|
|
return currency.round(
|
|
Decimal(str(line.quantity))
|
|
* (line.original_unit_price or line.unit_price))
|
|
|
|
if self.type == 'line':
|
|
return _amount(self)
|
|
elif self.type == 'subtotal':
|
|
amount = Decimal(0)
|
|
for line2 in self.sale.lines:
|
|
if line2.type == 'line':
|
|
amount += _amount(line2)
|
|
elif line2.type == 'subtotal':
|
|
if self == line2:
|
|
break
|
|
amount = Decimal(0)
|
|
return amount
|
|
|
|
@property
|
|
def taxable_lines(self):
|
|
lines = super().taxable_lines
|
|
if (getattr(self, 'type', None) == 'line'
|
|
and Transaction().context.get('_original_amount')):
|
|
lines = [(
|
|
getattr(self, 'taxes', None) or [],
|
|
getattr(self, 'original_unit_price', None)
|
|
or getattr(self, 'unit_price', None)
|
|
or Decimal(0),
|
|
getattr(self, 'quantity', None) or 0,
|
|
None,
|
|
)]
|
|
return lines
|
|
|
|
|
|
class Promotion(
|
|
DeactivableMixin, ModelSQL, ModelView, MatchMixin):
|
|
__name__ = 'sale.promotion'
|
|
|
|
name = fields.Char('Name', translate=True, required=True)
|
|
company = fields.Many2One(
|
|
'company.company', "Company", required=True,
|
|
states={
|
|
'readonly': Eval('id', 0) > 0,
|
|
})
|
|
start_date = fields.Date('Start Date',
|
|
domain=['OR',
|
|
('start_date', '<=', If(~Eval('end_date', None),
|
|
datetime.date.max,
|
|
Eval('end_date', datetime.date.max))),
|
|
('start_date', '=', None),
|
|
])
|
|
end_date = fields.Date('End Date',
|
|
domain=['OR',
|
|
('end_date', '>=', If(~Eval('start_date', None),
|
|
datetime.date.min,
|
|
Eval('start_date', datetime.date.min))),
|
|
('end_date', '=', None),
|
|
])
|
|
price_list = fields.Many2One('product.price_list', 'Price List',
|
|
ondelete='CASCADE',
|
|
domain=[
|
|
('company', '=', Eval('company', -1)),
|
|
])
|
|
|
|
amount = Monetary("Amount", currency='currency', digits='currency')
|
|
currency = fields.Many2One(
|
|
'currency.currency', "Currency",
|
|
states={
|
|
'required': Bool(Eval('amount', 0)),
|
|
})
|
|
untaxed_amount = fields.Boolean(
|
|
"Untaxed Amount",
|
|
states={
|
|
'invisible': ~Eval('amount'),
|
|
})
|
|
|
|
quantity = fields.Float('Quantity', digits='unit')
|
|
unit = fields.Many2One('product.uom', 'Unit',
|
|
states={
|
|
'required': Bool(Eval('quantity', 0)),
|
|
})
|
|
products = fields.Many2Many(
|
|
'sale.promotion-product.product', 'promotion', 'product', "Products",
|
|
context={
|
|
'company': Eval('company', -1),
|
|
},
|
|
depends={'company'})
|
|
categories = fields.Many2Many(
|
|
'sale.promotion-product.category', 'promotion', 'category',
|
|
"Categories",
|
|
context={
|
|
'company': Eval('company', -1),
|
|
},
|
|
depends={'company'})
|
|
formula = fields.Char('Formula', required=True,
|
|
help=('Python expression that will be evaluated with:\n'
|
|
'- unit_price: the original unit_price'))
|
|
|
|
@staticmethod
|
|
def default_company():
|
|
return Transaction().context.get('company')
|
|
|
|
@classmethod
|
|
def default_untaxed_amount(cls):
|
|
return False
|
|
|
|
@classmethod
|
|
def validate_fields(cls, promotions, field_names):
|
|
super().validate_fields(promotions, field_names)
|
|
cls.check_formula(promotions, field_names)
|
|
|
|
@classmethod
|
|
def check_formula(cls, promotions, field_names=None):
|
|
if field_names and 'formula' not in field_names:
|
|
return
|
|
for promotion in promotions:
|
|
context = promotion.get_context_formula(None)
|
|
try:
|
|
unit_price = promotion.get_unit_price(**context)
|
|
if not isinstance(unit_price, Decimal):
|
|
raise ValueError('Not a Decimal')
|
|
except Exception as exception:
|
|
raise FormulaError(
|
|
gettext('sale_promotion.msg_invalid_formula',
|
|
formula=promotion.formula,
|
|
promotion=promotion.rec_name,
|
|
exception=exception)) from exception
|
|
|
|
@classmethod
|
|
def _promotions_domain(cls, sale):
|
|
pool = Pool()
|
|
Date = pool.get('ir.date')
|
|
with Transaction().set_context(company=sale.company.id):
|
|
sale_date = sale.sale_date or Date.today()
|
|
return [
|
|
['OR',
|
|
('start_date', '<=', sale_date),
|
|
('start_date', '=', None),
|
|
],
|
|
['OR',
|
|
('end_date', '=', None),
|
|
('end_date', '>=', sale_date),
|
|
],
|
|
['OR',
|
|
('price_list', '=', None),
|
|
('price_list', '=',
|
|
sale.price_list.id if sale.price_list else None),
|
|
],
|
|
('company', '=', sale.company.id),
|
|
]
|
|
|
|
@classmethod
|
|
def get_promotions(cls, sale, pattern=None):
|
|
'Yield promotions that apply to sale'
|
|
promotions = cls.search(cls._promotions_domain(sale))
|
|
if pattern is None:
|
|
pattern = {}
|
|
for promotion in promotions:
|
|
ppattern = pattern.copy()
|
|
ppattern.update(promotion.get_pattern(sale))
|
|
if promotion.match(ppattern):
|
|
yield promotion
|
|
|
|
def get_pattern(self, sale):
|
|
pool = Pool()
|
|
Currency = pool.get('currency.currency')
|
|
Uom = pool.get('product.uom')
|
|
Sale = pool.get('sale.sale')
|
|
pattern = {}
|
|
if self.currency:
|
|
amount = self.get_sale_amount(Sale(sale.id))
|
|
pattern['amount'] = Currency.compute(
|
|
sale.currency, amount, self.currency)
|
|
if self.unit:
|
|
quantity = 0
|
|
for line in sale.lines:
|
|
if line.type != 'line':
|
|
continue
|
|
if self.is_valid_sale_line(line):
|
|
quantity += Uom.compute_qty(line.unit, line.quantity,
|
|
self.unit)
|
|
pattern['quantity'] = quantity
|
|
return pattern
|
|
|
|
def match(self, pattern):
|
|
def sign(amount):
|
|
return Decimal(1).copy_sign(amount)
|
|
if 'quantity' in pattern:
|
|
pattern = pattern.copy()
|
|
if (self.quantity or 0) > pattern.pop('quantity'):
|
|
return False
|
|
if 'amount' in pattern:
|
|
pattern = pattern.copy()
|
|
amount = pattern.pop('amount')
|
|
if (sign(self.amount or 0) * sign(amount) >= 0
|
|
and abs(self.amount or 0) > abs(amount)):
|
|
return False
|
|
return super().match(pattern)
|
|
|
|
def get_sale_amount(self, sale):
|
|
if self.untaxed_amount:
|
|
return sale.untaxed_amount
|
|
else:
|
|
return sale.total_amount
|
|
|
|
def is_valid_sale_line(self, line):
|
|
|
|
def parents(categories):
|
|
for category in categories:
|
|
while category:
|
|
yield category
|
|
category = category.parent
|
|
|
|
if line.quantity <= 0 or line.unit_price <= 0:
|
|
return False
|
|
elif self.unit and line.unit.category != self.unit.category:
|
|
return False
|
|
elif self.products and line.product not in self.products:
|
|
return False
|
|
elif self.categories:
|
|
if not line.product:
|
|
return False
|
|
categories = set(parents(line.product.categories_all))
|
|
if not categories.intersection(self.categories):
|
|
return False
|
|
return True
|
|
|
|
def apply(self, sale):
|
|
applied = False
|
|
for line in sale.lines:
|
|
if line.type != 'line':
|
|
continue
|
|
if not self.is_valid_sale_line(line):
|
|
continue
|
|
context = self.get_context_formula(line)
|
|
new_price = self.get_unit_price(**context)
|
|
if new_price is not None:
|
|
if new_price < 0:
|
|
new_price = Decimal(0)
|
|
if line.unit_price >= new_price:
|
|
line.unit_price = round_price(new_price)
|
|
line.promotion = self
|
|
applied = True
|
|
if applied:
|
|
sale.lines = sale.lines # Trigger the change
|
|
|
|
def get_context_formula(self, sale_line):
|
|
pool = Pool()
|
|
Product = pool.get('product.product')
|
|
if sale_line:
|
|
with Transaction().set_context(
|
|
sale_line._get_context_sale_price()):
|
|
prices = Product.get_sale_price([sale_line.product])
|
|
unit_price = prices[sale_line.product.id]
|
|
else:
|
|
unit_price = Decimal(0)
|
|
return {
|
|
'names': {
|
|
'unit_price': unit_price if unit_price is not None else Null(),
|
|
},
|
|
}
|
|
|
|
def get_unit_price(self, **context):
|
|
'Return unit price (as Decimal)'
|
|
context.setdefault('functions', {})['Decimal'] = Decimal
|
|
unit_price = simple_eval(decistmt(self.formula), **context)
|
|
unit_price = max(unit_price, Decimal(0))
|
|
if isinstance(unit_price, Null):
|
|
unit_price = None
|
|
return unit_price
|
|
|
|
|
|
class Promotion_Product(ModelSQL):
|
|
__name__ = 'sale.promotion-product.product'
|
|
|
|
promotion = fields.Many2One(
|
|
'sale.promotion', "Promotion", required=True, ondelete='CASCADE')
|
|
product = fields.Many2One('product.product', 'Product',
|
|
required=True, ondelete='CASCADE')
|
|
|
|
|
|
class Promotion_ProductCategory(ModelSQL):
|
|
__name__ = 'sale.promotion-product.category'
|
|
|
|
promotion = fields.Many2One(
|
|
'sale.promotion', "Promotion", required=True, ondelete='CASCADE')
|
|
category = fields.Many2One(
|
|
'product.category', "Category",
|
|
required=True, ondelete='CASCADE')
|