# 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 as dt from decimal import Decimal from itertools import chain from trytond.i18n import gettext from trytond.model import ( MatchMixin, ModelSQL, ModelView, fields, sequence_ordered) from trytond.modules.account_product.exceptions import TaxError from trytond.modules.account_product.product import ( account_used, template_property) from trytond.modules.currency.fields import Monetary from trytond.modules.product import price_digits, round_price from trytond.pool import Pool, PoolMeta from trytond.pyson import Eval, Id, If, TimeDelta from trytond.transaction import Transaction class Category(metaclass=PoolMeta): __name__ = 'product.category' account_rental = fields.MultiValue(fields.Many2One( 'account.account', "Account Rental", domain=[ ('closed', '!=', True), ('type.revenue', '=', True), ('company', '=', Eval('context', {}).get('company', -1)), ], states={ 'invisible': ( ~Eval('context', {}).get('company', -1) | Eval('account_parent', True) | ~Eval('accounting', False)), })) customer_rental_taxes = fields.Many2Many( 'product.category-customer_rental-account.tax', 'category', 'tax', "Customer Rental Taxes", order=[('tax.sequence', 'ASC'), ('tax.id', 'ASC')], domain=[ ('parent', '=', None), ['OR', ('group', '=', None), ('group.kind', 'in', ['sale', 'both']), ], ], states={ 'invisible': ( ~Eval('context', {}).get('company') | Eval('taxes_parent', False) | ~Eval('accounting', False)), }, help="The taxes to apply when renting goods or services " "of this category.") customer_rental_taxes_used = fields.Function( fields.Many2Many( 'account.tax', None, None, "Customer Rental Taxes Used"), 'get_taxes') @classmethod def multivalue_model(cls, field): pool = Pool() if field == 'account_rental': return pool.get('product.category.account') return super().multivalue_model(field) @property @account_used('account_rental') def account_rental_used(self): pass class CategoryAccount(metaclass=PoolMeta): __name__ = 'product.category.account' account_rental = fields.Many2One( 'account.account', "Account Rental", domain=[ ('closed', '!=', True), ('type.revenue', '=', True), ('company', '=', Eval('company', -1)), ]) class CategoryCustomerRentalTax(ModelSQL): "Category - Customer Rental Tax" __name__ = 'product.category-customer_rental-account.tax' category = fields.Many2One( 'product.category', "Category", ondelete='CASCADE', required=True) tax = fields.Many2One( 'account.tax', "Tax", ondelete='RESTRICT', required=True) @classmethod def __setup__(cls): super().__setup__() cls.__access__.add('tax') class Template(metaclass=PoolMeta): __name__ = 'product.template' rentable = fields.Boolean( "Rentable", states={ 'invisible': ~Eval('type').in_(['assets', 'service']), }) rental_unit = fields.Many2One( 'product.uom', "Rental Unit", domain=[ ('category', '=', Id('product', 'uom_cat_time')), ], states={ 'invisible': ~Eval('rentable', False), 'required': Eval('rentable', False), }) rental_per_day = fields.Boolean( "Rental per Day", states={ 'invisible': ~Eval('rentable', False), }) rental_prices = fields.One2Many( 'product.rental.price', 'template', "Rental Prices", states={ 'invisible': ~Eval('rentable', False), }) @classmethod def default_rental_per_day(cls): return False @property @fields.depends('account_category', methods=['get_account']) @account_used('account_rental', 'account_category') def account_rental_used(self): pass @property @fields.depends(methods=['get_taxes', 'account_rental_used']) def customer_rental_taxes_used(self): taxes = self.get_taxes('customer_rental_taxes_used') if taxes is None: account = self.account_rental_used if account: taxes = account.taxes if taxes is None: # Allow empty values on on_change if Transaction().readonly: taxes = [] else: raise TaxError( gettext('account_product.msg_missing_taxes', name=self.rec_name)) return taxes @classmethod def view_attributes(cls): return super().view_attributes() + [ ('//page[@id="rental"]', 'states', { 'invisible': ~Eval('rentable', False), })] @classmethod def copy(cls, templates, default=None): pool = Pool() RentalPrice = pool.get('product.rental.price') default = default.copy() if default is not None else {} copy_rental_prices = 'rental_prices' not in default default.setdefault('rental_prices', None) new_templates = super().copy(templates, default=default) if copy_rental_prices: old2new = {} to_copy = [] for template, new_template in zip(templates, new_templates): to_copy.extend( rp for rp in template.rental_prices if not rp.product) old2new[template.id] = new_template.id if to_copy: RentalPrice.copy(to_copy, { 'template': lambda d: old2new[d['template']], }) return new_templates class Product(metaclass=PoolMeta): __name__ = 'product.product' rental_prices = fields.One2Many( 'product.rental.price', 'product', "Rental Prices", domain=[ ('template', '=', Eval('template', -1)), ], states={ 'invisible': ~Eval('rentable', False), }) rental_price_uom = fields.Function( Monetary("Rental Price", digits=price_digits), 'get_rental_price_uom') account_rental_used = template_property('account_rental_used') customer_rental_taxes_used = template_property( 'customer_rental_taxes_used') @classmethod def get_rental_price_uom(cls, products, name): context = Transaction().context quantity = context.get('quantity') or 0 duration = context.get('duration') or dt.timedelta() return cls.get_rental_price( products, quantity=quantity, duration=duration) @classmethod def get_rental_price(cls, products, quantity=0, duration=None): """ Return the rental price for products, quantity and duration. It uses if set in the context: uom: the unit of measure or the sale uom of the product currency: the currency id for the returned price """ pool = Pool() Currency = pool.get('currency.currency') Date = pool.get('ir.date') UoM = pool.get('product.uom') User = pool.get('res.user') transaction = Transaction() context = transaction.context today = Date.today() prices = {} assert len(products) == len(set(products)) uom = None if context.get('uom'): uom = UoM(context['uom']) currency = None if context.get('currency'): currency = Currency(context['currency']) user = User(transaction.user) date = context.get('rental_date') or today for product in products: unit_price = product._get_rental_unit_price( quantity=quantity, duration=duration, company=user.company.id) if unit_price is not None: if uom and product.default_uom.category == uom.category: unit_price = UoM.compute_price( product.default_uom, unit_price, uom) if currency and user.company: if user.company.currency != currency: with transaction.set_context(date=date): unit_price = Currency.compute( user.company.currency, unit_price, currency, round=False) unit_price = round_price(unit_price) prices[product.id] = unit_price return prices def _get_rental_unit_price(self, quantity=0, duration=None, **pattern): for price in chain(self.rental_prices, self.template.rental_prices): if price.match(quantity, duration, pattern): return price.get_unit_price(self.rental_unit) @classmethod def copy(cls, products, default=None): pool = Pool() RentalPrice = pool.get('product.rental.price') default = default.copy() if default is not None else {} copy_rental_prices = 'rental_prices' not in default if 'template' in default: default.setdefault('rental_prices', None) new_products = super().copy(products, default=default) if 'template' in default and copy_rental_prices: template2new = {} product2new = {} to_copy = [] for product, new_product in zip(products, new_products): if product.rental_prices: to_copy.extend(product.rental_prices) template2new[product.template.id] = new_product.template.id product2new[product.id] = new_product.id if to_copy: RentalPrice.copy(to_copy, { 'product': lambda d: product2new[d['product']], 'template': lambda d: template2new[d['template']], }) return new_products class RentalPrice(sequence_ordered(), ModelSQL, ModelView, MatchMixin): __name__ = 'product.rental.price' template = fields.Many2One( 'product.template', "Product", required=True, ondelete='CASCADE', domain=[ If(Eval('product'), ('products', '=', Eval('product', -1)), ()), ], context={ 'company': Eval('company', -1), }, depends=['company']) product = fields.Many2One( 'product.product', "Variant", domain=[ If(Eval('template'), ('template', '=', Eval('template', -1)), ()), ], context={ 'company': Eval('company', -1), }, depends=['company']) company = fields.Many2One( 'company.company', "Company", required=True, ondelete='CASCADE') duration = fields.TimeDelta( "Duration", required=True, domain=[ ('duration', '>', TimeDelta()), ], help="The minimal duration to apply the price.") price = Monetary( "Price", currency='currency', digits=price_digits, required=True) currency = fields.Function( fields.Many2One('currency.currency', "Currency"), 'on_change_with_currency') @classmethod def __setup__(cls): super().__setup__() cls.__access__.add('template') cls._order.insert(0, ('company', 'ASC')) @classmethod def default_company(cls): return Transaction().context.get('company') @fields.depends('product', '_parent_product.template') def on_change_product(self): if self.product: self.template = self.product.template @fields.depends('company') def on_change_with_currency(self, name=None): if self.company: return self.company.currency.id def match(self, quantity, duration, pattern, match_none=False): if self.duration > duration: return False return super().match(pattern, match_none=match_none) def get_unit_price(self, unit): pool = Pool() UoM = pool.get('product.uom') Data = pool.get('ir.model.data') hour = UoM(Data.get_id('product', 'uom_hour')) unit_price = ( self.price / Decimal(self.duration.total_seconds() / 60 / 60)) return UoM.compute_price(hour, unit_price, unit)