Files
2025-12-26 13:11:43 +00:00

343 lines
12 KiB
Python
Executable File

# 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'
@classmethod
@ModelView.button
@Workflow.transition('draft')
def draft(cls, sales):
super(Sale, cls).draft(sales)
# Reset to draft unit price
for sale in sales:
changed = False
for line in sale.lines:
if line.type != 'line':
continue
if line.draft_unit_price is not None:
line.unit_price = line.draft_unit_price
line.draft_unit_price = None
changed = True
if line.promotion:
line.promotion = None
changed = True
if changed:
sale.lines = sale.lines # Trigger changes
cls.save(sales)
@classmethod
@ModelView.button
@Workflow.transition('quotation')
def quote(cls, sales):
super(Sale, cls).quote(sales)
# Store draft unit price before changing it
for sale in sales:
for line in sale.lines:
if line.type != 'line':
continue
if line.draft_unit_price is None:
line.draft_unit_price = line.unit_price
sale.apply_promotion()
cls.save(sales)
def apply_promotion(self):
'Apply promotion'
pool = Pool()
Promotion = pool.get('sale.promotion')
promotions = Promotion.get_promotions(self)
for promotion in promotions:
promotion.apply(self)
class Line(metaclass=PoolMeta):
__name__ = 'sale.line'
draft_unit_price = fields.Numeric('Draft Unit Price',
digits=price_digits, readonly=True)
promotion = fields.Many2One('sale.promotion', 'Promotion',
ondelete='RESTRICT',
domain=[
('company', '=', Eval('_parent_sale', {}).get('company', -1)),
],
depends={'sale'})
class Promotion(
DeactivableMixin, ModelSQL, ModelView, MatchMixin):
'Sale Promotion'
__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):
'Sale Promotion - Product'
__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):
"Sale Promotion - Product Category"
__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')