# 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 functools import partial from trytond.i18n import gettext from trytond.ir.attachment import AttachmentCopyMixin from trytond.ir.note import NoteCopyMixin from trytond.model import ( ChatMixin, Index, ModelSQL, ModelView, Unique, Workflow, dualmethod, fields, sequence_ordered) from trytond.model.exceptions import AccessError, ValidationError from trytond.model.fields.date import FormatMixin from trytond.modules.account.tax import TaxableMixin from trytond.modules.company.model import ( employee_field, reset_employee, set_employee) 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 Date, DateTime, Eval, Id, If from trytond.report import Report from trytond.tools import timezone as tz from trytond.transaction import Transaction from trytond.wizard import Button, StateTransition, StateView, Wizard _datetime_format = '%H:%M' _time_min = dt.datetime.strptime( dt.time.min.strftime(_datetime_format), _datetime_format).time() _time_max = dt.datetime.strptime( dt.time.max.strftime(_datetime_format), _datetime_format).time() class DateOrDateTime(FormatMixin, fields.Date): def __init__(self, *args, **kwargs): self.format = kwargs.pop('format') super().__init__(*args, **kwargs) def definition(self, model, language): definition = super().definition(model, language) definition['type'] = 'datetime' return definition def to_datetime(value, company=None, time=None): if isinstance(value, dt.date) and not isinstance(value, dt.datetime): if time is None: time = _time_min if company and company.timezone: timezone = tz.ZoneInfo(company.timezone) else: timezone = None value = (dt.datetime.combine(value, time, timezone) .astimezone(tz.UTC) .replace(tzinfo=None)) return value def to_date(value, company=None): if isinstance(value, dt.datetime): if company and company.timezone: timezone = tz.ZoneInfo(company.timezone) else: timezone = None value = (value .replace(tzinfo=tz.UTC) .astimezone(timezone) .replace(tzinfo=None) .date()) return value class Configuration(metaclass=PoolMeta): __name__ = 'sale.configuration' rental_sequence = fields.MultiValue(fields.Many2One( 'ir.sequence', "Rental Sequence", required=True, domain=[ ('company', 'in', [Eval('context', {}).get('company', -1), None]), ('sequence_type', '=', Id('sale_rental', 'sequence_type_rental')), ])) @classmethod def multivalue_model(cls, field): pool = Pool() if field == 'rental_sequence': return pool.get('sale.configuration.sequence') return super().multivalue_model(field) @classmethod def default_rental_sequence(cls, **pattern): return cls.multivalue_model( 'rental_sequence').default_rental_sequence() class ConfigurationSequence(metaclass=PoolMeta): __name__ = 'sale.configuration.sequence' rental_sequence = fields.Many2One( 'ir.sequence', "Rental Sequence", required=True, domain=[ ('company', 'in', [Eval('company', -1), None]), ('sequence_type', '=', Id('sale_rental', 'sequence_type_rental')), ]) @classmethod def default_rental_sequence(cls): pool = Pool() ModelData = pool.get('ir.model.data') try: return ModelData.get_id( 'sale_rental', 'sequence_rental') except KeyError: return None class Rental( Workflow, ModelSQL, ModelView, TaxableMixin, AttachmentCopyMixin, NoteCopyMixin, ChatMixin): __name__ = 'sale.rental' _rec_name = 'number' company = fields.Many2One( 'company.company', "Company", required=True, states={ 'readonly': (Eval('state') != 'draft') | Eval('party', True), }, help="Make the sale rental belong to the company.") number = fields.Char( "Number", readonly=True, help="The main identification of the sale rental.") reference = fields.Char( "Reference", help="The identification of an external origin.") description = fields.Char( "Description", states={ 'readonly': Eval('state') != 'draft', }) party = fields.Many2One( 'party.party', "Party", required=True, states={ 'readonly': ( (Eval('state') != 'draft') | (Eval('lines', [0]) & Eval('party'))), }, context={ 'company': Eval('company', -1), }, depends=['company'], help="The party who is renting.") contact = fields.Many2One( 'party.contact_mechanism', "Contact", context={ 'company': Eval('company', -1), }, search_context={ 'related_party': Eval('party'), }, depends={'company', 'party'}) invoice_party = fields.Many2One('party.party', "Invoice Party", states={ 'readonly': ((Eval('state') != 'draft') | Eval('lines', [0])), }, context={ 'company': Eval('company', -1), }, search_context={ 'related_party': Eval('party'), }, depends={'company', 'party'}) invoice_address = fields.Many2One( 'party.address', "Invoice Address", domain=[ ('party', '=', If(Eval('invoice_party'), Eval('invoice_party', -1), Eval('party', -1))), ], states={ 'readonly': Eval('state') != 'draft', 'required': ~Eval('state').in_(['draft', 'cancelled']), }) payment_term = fields.Many2One( 'account.invoice.payment_term', "Payment Term", states={ 'readonly': Eval('state') != 'draft', }) warehouse = fields.Many2One( 'stock.location', "Warehouse", domain=[ ('type', '=', 'warehouse'), ], states={ 'readonly': Eval('state') != 'draft', 'required': ~Eval('state').in_(['draft', 'cancelled']), }) currency = fields.Many2One( 'currency.currency', "Currency", required=True, states={ 'readonly': ((Eval('state') != 'draft') | (Eval('lines', [0]) & Eval('currency', 0))), }) start = fields.Function( DateOrDateTime("Start", format=_datetime_format), 'on_change_with_start') end = fields.Function( DateOrDateTime("End", format=_datetime_format), 'on_change_with_end') lines = fields.One2Many( 'sale.rental.line', 'rental', "Lines", states={ 'readonly': Eval('state') != 'draft', }, depends=['start', 'end']) outgoing_moves = fields.One2Many( 'stock.move', 'origin', "Outgoing Moves", readonly=True, filter=[ ('to_location.type', '=', 'rental'), ]) incoming_moves = fields.One2Many( 'stock.move', 'origin', "Incoming Moves", readonly=True, filter=[ ('from_location.type', '=', 'rental'), ]) has_returnable_lines = fields.Function( fields.Boolean("Has Returnable Lines"), 'get_has_returnable_lines') has_lines_to_invoice = fields.Function( fields.Boolean("Has Lines to Invoice"), 'get_has_lines_to_invoice') untaxed_amount = fields.Function( Monetary("Untaxed", digits='currency', currency='currency'), 'get_amount') untaxed_amount_cache = fields.Numeric( "Untaxed Cache", digits='currency', readonly=True) tax_amount = fields.Function( Monetary("Tax", digits='currency', currency='currency'), 'get_amount') tax_amount_cache = fields.Numeric( "Tax Cache", digits='currency', readonly=True) total_amount = fields.Function( Monetary("Total", digits='currency', currency='currency'), 'get_amount') total_amount_cache = fields.Numeric( "Total Cache", digits='currency', readonly=True) quoted_by = employee_field( "Quoted By", states=['quotation', 'confirmed', 'done', 'cancelled']) confirmed_by = employee_field( "Confirmed By", states=['confirmed', 'done', 'cancelled']) state = fields.Selection([ ('draft', "Draft"), ('quotation', "Quotation"), ('confirmed', "Confirmed"), ('picked up', "Picked up"), ('done', "Done"), ('cancelled', "Cancelled"), ], "State", readonly=True, required=True, sort=False, help="The current state of the sale rental.") @classmethod def __setup__(cls): cls.number.search_unaccented = False cls.reference.search_unaccented = False super().__setup__() t = cls.__table__() cls._sql_indexes.update({ Index(t, (t.reference, Index.Similarity())), Index(t, (t.party, Index.Equality())), Index( t, (t.state, Index.Equality()), where=t.state.in_([ 'draft', 'quotation', 'confirmed', 'picked up'])), }) cls._transitions |= { ('draft', 'quotation'), ('draft', 'cancelled'), ('quotation', 'confirmed'), ('quotation', 'draft'), ('quotation', 'cancelled'), ('confirmed', 'picked up'), ('confirmed', 'draft'), ('picked up', 'done'), ('picked up', 'draft'), ('cancelled', 'draft'), } cls._buttons.update({ 'cancel': { 'invisible': ~Eval('state').in_(['draft', 'quotation']), 'icon': 'tryton-cancel', 'depends': ['state'], }, 'draft': { 'invisible': ~Eval('state').in_( ['quotation', 'confirmed', 'picked up', 'cancelled']), 'icon': If(Eval('state') == 'cancelled', 'tryton-undo', 'tryton-back'), 'depends': ['state'], }, 'quote': { 'invisible': Eval('state') != 'draft', 'readonly': ~Eval('lines', []), 'icon': 'tryton-forward', 'depends': ['state'], }, 'confirm': { 'invisible': Eval('state') != 'quotation', 'icon': 'tryton-forward', 'depends': ['state'], }, 'pickup': { 'invisible': Eval('state') != 'confirmed', 'icon': 'tryton-shipment-out', 'depends': ['state'], }, 'return_': { 'invisible': ( Eval('state').in_(['draft', 'quote']) | ~Eval('has_returnable_lines')), 'icon': 'tryton-shipment-in', 'depends': ['state', 'has_returnable_lines'], }, 'invoice': { 'invisible': ( Eval('state').in_(['draft', 'quote']) | ~Eval('has_lines_to_invoice')), 'icon': 'tryton-invoice', 'depends': ['has_lines_to_invoice'], }, }) cls._states_cached = {'confirmed', 'picked up', 'done', 'cancelled'} @classmethod def default_company(cls): return Transaction().context.get('company') @classmethod def default_warehouse(cls): Location = Pool().get('stock.location') return Location.get_default_warehouse() @classmethod def default_currency(cls, **pattern): pool = Pool() Company = pool.get('company.company') company = pattern.get('company') if not company: company = cls.default_company() if company: return Company(company).currency.id @classmethod def default_state(cls): return 'draft' @fields.depends('company', 'party', 'invoice_party', 'currency', 'lines') def on_change_party(self): if not self.invoice_party: self.invoice_address = None if not self.lines: self.currency = self.default_currency( company=self.company.id if self.company else None) if self.party: if not self.invoice_party: self.invoice_address = self.party.address_get(type='invoice') self.payment_term = self.party.customer_payment_term if not self.lines and self.party.customer_currency: self.currency = self.party.customer_currency @fields.depends('party', 'invoice_party') def on_change_invoice_party(self): if self.invoice_party: self.invoice_address = self.invoice_party.address_get( type='invoice') elif self.party: self.invoice_address = self.party.address_get(type='invoice') @classmethod def default_start(cls): now = dt.datetime.now().replace(second=0, microsecond=0) now += dt.timedelta(minutes=30 - now.minute % 30) return now @fields.depends('company', 'lines') def on_change_with_start(self, name=None): return min(map(partial(to_datetime, company=self.company), filter( None, (getattr(l, 'start', None) for l in (self.lines or [])))), default=self.default_start()) @fields.depends('company', 'lines') def on_change_with_end(self, name=None): return max(map(partial( to_datetime, company=self.company, time=_time_max), filter( None, (getattr(l, 'end', None) for l in (self.lines or [])))), default=None) @fields.depends('lines', 'currency', methods=['get_tax_amount']) def on_change_lines(self): self.untaxed_amount = Decimal(0) self.tax_amount = Decimal(0) self.total_amount = Decimal(0) for line in self.lines or []: self.untaxed_amount += getattr(line, 'amount', None) or 0 self.tax_amount = self.get_tax_amount() if self.currency: self.untaxed_amount = self.currency.round(self.untaxed_amount) self.tax_amount = self.currency.round(self.tax_amount) self.total_amount = self.untaxed_amount + self.tax_amount if self.currency: self.total_amount = self.currency.round(self.total_amount) def get_has_returnable_lines(self, name): return any(l.rental_state == 'picked up' for l in self.lines) def get_has_lines_to_invoice(self, name): return any(l.to_invoice for l in self.lines) @fields.depends(methods=['_get_taxes']) def get_tax_amount(self): return sum( (t.amount for t in self._get_taxes().values()), Decimal(0)) @property def taxable_lines(self): taxable_lines = [] for line in self.lines: taxable_lines.extend(line.taxable_lines) return taxable_lines @fields.depends('party', 'company') def _get_tax_context(self): context = {} if self.party and self.party.lang: context['language'] = self.party.lang.code if self.company: context['company'] = self.company.id return context @classmethod def get_amount(cls, rentals, names): untaxed_amount = {} tax_amount = {} total_amount = {} if {'tax_amount', 'total_amount'} & set(names): compute_taxes = True else: compute_taxes = False # Sort cached first and re-instanciate to optimize cache management rentals = sorted( rentals, key=lambda s: s.state in cls._states_cached, reverse=True) for rental in cls.browse(rentals): if (rental.state in cls._states_cached and rental.untaxed_amount_cache is not None and rental.tax_amount_cache is not None and rental.total_amount_cache is not None): untaxed_amount[rental.id] = rental.untaxed_amount_cache if compute_taxes: tax_amount[rental.id] = rental.tax_amount_cache total_amount[rental.id] = rental.total_amount_cache else: untaxed_amount[rental.id] = sum( (line.amount for line in rental.lines), Decimal(0)) if compute_taxes: tax_amount[rental.id] = rental.get_tax_amount() total_amount[rental.id] = ( untaxed_amount[rental.id] + tax_amount[rental.id]) amounts = {} if 'untaxed_amount' in names: amounts['untaxed_amount'] = untaxed_amount if 'tax_amount' in names: amounts['tax_amount'] = tax_amount if 'total_amount' in names: amounts['total_amount'] = total_amount return amounts @classmethod def set_number(cls, rentals): pool = Pool() Config = pool.get('sale.configuration') config = Config(1) for rental in rentals: if rental.number: continue rental.number = config.get_multivalue( 'rental_sequence', company=rental.company.id).get() cls.save(rentals) @classmethod def set_moves(cls, rentals): pool = Pool() Line = pool.get('sale.rental.line') Move = pool.get('stock.move') lines, moves = [], set() for rental in rentals: for line in rental.lines: line.outgoing_moves = line.get_moves('out') moves.update(line.outgoing_moves) line.incoming_moves = line.get_moves('in') moves.update(line.incoming_moves) lines.append(line) Move.save(moves) Line.save(lines) @classmethod def delete_moves(cls, rentals, all_=False): pool = Pool() Line = pool.get('sale.rental.line') Move = pool.get('stock.move') lines, moves = [], set() for rental in rentals: for line in rental.lines: moves.update(line.outgoing_moves) line.outgoing_moves = [] moves.update(line.incoming_moves) line.incoming_moves = [] lines.append(line) Line.save(lines) moves = Move.browse(moves) Move.draft(moves) Move.delete([ m for m in moves if all_ or m.state in {'draft', 'cancelled'}]) @classmethod def store_cache(cls, rentals): for rental in rentals: rental.untaxed_amount_cache = rental.untaxed_amount rental.tax_amount_cache = rental.tax_amount rental.total_amount_cache = rental.total_amount cls.save(rentals) def create_invoice(self): invoice_lines = [] for line in self.lines: if line.to_invoice: invoice_lines.extend(line.get_invoice_lines()) if not invoice_lines: return invoice = self._get_invoice() if getattr(invoice, 'lines', None): invoice_lines = list(invoice.lines) + invoice_lines invoice.lines = invoice_lines return invoice def _get_invoice(self): pool = Pool() Invoice = pool.get('account.invoice') party = self.invoice_party or self.party invoice = Invoice( company=self.company, type='out', party=party, invoice_address=self.invoice_address, currency=self.currency, account=party.account_receivable_used, ) invoice.set_journal() invoice.payment_term = self.payment_term return invoice def get_rec_name(self, name): items = [] if self.number: items.append(self.number) if self.reference: items.append('[%s]' % self.reference) if not items: items.append('(%s)' % self.id) return ' '.join(items) @classmethod def search_rec_name(cls, name, clause): _, operator, value = clause if operator.startswith('!') or operator.startswith('not '): bool_op = 'AND' else: bool_op = 'OR' domain = [bool_op, ('number', operator, value), ('reference', operator, value), ] return domain def chat_language(self, audience='internal'): language = super().chat_language(audience=audience) if audience == 'public': language = self.party.lang.code if self.party.lang else None return language @classmethod def copy(cls, rentals, default=None): default = default.copy() if default is not None else {} default.setdefault('number', None) default.setdefault('quoted_by') default.setdefault('confirmed_by') default.setdefault('lines.outgoing_moves', None) default.setdefault('lines.incoming_moves', None) return super().copy(rentals, default=default) @classmethod def view_attributes(cls): return super().view_attributes() + [ ('/tree', 'visual', If(Eval('state') == 'cancelled', 'muted', '')), ] @classmethod @ModelView.button @Workflow.transition('cancelled') def cancel(cls, rentals): cls.delete_moves(rentals, all_=True) @classmethod @ModelView.button @Workflow.transition('draft') @reset_employee('quoted_by', 'confirmed_by') def draft(cls, rentals): pool = Pool() Line = pool.get('sale.rental.line') # Set actual period as planned period lines = [] for rental in rentals: for line in rental.lines: if line.per_day: if line.actual_start: line.planned_start_day = to_date( line.actual_start, line.company) if line.actual_end: line.planned_end_day = to_date( line.actual_end, line.company) if line.planned_end_day < line.planned_start_day: line.planned_end_day = line.planned_start_day else: if line.actual_start: line.planned_start = line.actual_start if line.actual_end: line.planned_end = line.actual_end if line.planned_end < line.planned_start: line.planned_end = line.planned_start lines.append(line) Line.save(lines) cls.delete_moves(rentals) @classmethod @ModelView.button @Workflow.transition('quotation') @set_employee('quoted_by') def quote(cls, rentals): cls.set_number(rentals) cls.set_moves(rentals) @classmethod @ModelView.button @Workflow.transition('confirmed') @set_employee('confirmed_by') def confirm(cls, rentals): cls.store_cache(rentals) cls.write(rentals, {'state': 'confirmed'}) cls.try_picked_up(rentals) @classmethod @ModelView.button_action('sale_rental.wizard_sale_rental_pickup') def pickup(cls, rentals): pass @dualmethod def try_picked_up(cls, rentals): to_picked_up = [] for rental in rentals: if all( l.rental_state in {'picked up', 'done'} for l in rental.lines): to_picked_up.append(rental) if to_picked_up: cls.picked_up(to_picked_up) @classmethod @Workflow.transition('picked up') def picked_up(cls, rentals): cls.write(rentals, {'state': 'picked up'}) cls.try_done(rentals) @classmethod @ModelView.button_action('sale_rental.wizard_sale_rental_return') def return_(cls, rentals): pass @classmethod @ModelView.button def invoice(cls, rentals): pool = Pool() Invoice = pool.get('account.invoice') invoices = {} for rental in rentals: invoice = rental.create_invoice() if invoice: invoices[rental] = invoice Invoice.save(invoices.values()) Invoice.update_taxes(invoices.values()) for sale, invoice in invoices.items(): sale.copy_resources_to(invoice) @dualmethod def try_done(cls, rentals): to_do = [] for rental in rentals: if all(l.rental_state == 'done' for l in rental.lines): to_do.append(rental) if to_do: cls.do(to_do) @classmethod @Workflow.transition('done') def do(cls, rentals): cls.invoice(rentals) class RentalLine(sequence_ordered(), ModelSQL, ModelView, TaxableMixin): __name__ = 'sale.rental.line' rental = fields.Many2One( 'sale.rental', "Rental", required=True, ondelete='CASCADE', states={ 'readonly': ( (Eval('rental_state') != 'draft') & Eval('rental')), }, help="Add the line below the rental.") product = fields.Many2One( 'product.product', "Product", required=True, ondelete='RESTRICT', domain=[ ('type', 'in', ['assets', 'service']), ('rentable', '=', True), ], states={ 'readonly': Eval('rental_state') != 'draft', }, context={ 'company': Eval('company', -1), }, search_context={ 'locations': If(Eval('_parent_rental', {}).get('warehouse'), [Eval('_parent_rental', {}).get('warehouse', -1)], []), 'stock_date_end': Date(start=Eval('start', DateTime())), 'stock_skip_warehouse': True, 'rental_date': Date(start=Eval('start', DateTime())), 'currency': Eval('_parent_rental', {}).get('currency', -1), 'customer': Eval('_parent_rental', {}).get('party', -1), 'uom': Eval('unit', -1), 'taxes': Eval('taxes', []), 'quantity': Eval('quantity', 0), 'duration': Eval('duration', None), }, depends=['company']) per_day = fields.Boolean( "Per Day", states={ 'readonly': Eval('rental_state') != 'draft', }) quantity = fields.Float( "Quantity", digits='unit', required=True, domain=[ If(Eval('quantity'), ('quantity', '>=', 0), ()), ], states={ 'readonly': Eval('rental_state') != 'draft', }) unit = fields.Many2One( 'product.uom', "Unit", required=True, states={ 'readonly': Eval('rental_state') != 'draft', }, domain=[ If(Eval('product_uom_category'), ('category', '=', Eval('product_uom_category', -1)), ('category', '=', -1)), ]) planned_start_day = fields.Date( "Planned Start", domain=[ If(Eval('planned_start_day') & Eval('planned_end_day'), ('planned_start_day', '<=', Eval('planned_end_day', None)), ()), If(~Eval('per_day'), ('planned_start_day', '=', None), ()), ], states={ 'required': Eval('per_day', False), 'invisible': ~Eval('per_day'), 'readonly': Eval('rental_state') != 'draft', }) planned_end_day = fields.Date( "Planned End", domain=[ If(Eval('planned_start_day') & Eval('planned_end_day'), ('planned_end_day', '>=', Eval('planned_start_day', None)), ()), If(~Eval('per_day'), ('planned_end_day', '=', None), ()), ], states={ 'required': Eval('per_day', False), 'invisible': ~Eval('per_day'), 'readonly': Eval('rental_state') != 'draft', }) planned_start = fields.DateTime( "Planned Start", format=_datetime_format, domain=[ If(Eval('planned_start') & Eval('planned_end'), ('planned_start', '<=', Eval('planned_end')), ()), If(Eval('per_day', False), ('planned_start', '=', None), ()), ], states={ 'required': ~Eval('per_day'), 'invisible': Eval('per_day', False), 'readonly': Eval('rental_state') != 'draft', }) planned_end = fields.DateTime( "Planned End", format=_datetime_format, domain=[ If(Eval('planned_start') & Eval('planned_end'), ('planned_end', '>=', Eval('planned_start')), ()), If(Eval('per_day', False), ('planned_end', '=', None), ()), ], states={ 'required': ~Eval('per_day'), 'invisible': Eval('per_day', False), 'readonly': Eval('rental_state') != 'draft', }) planned_duration = fields.Function( fields.TimeDelta( "Planned Duration", states={ 'invisible': ~Eval('planned_duration'), }), 'on_change_with_planned_duration') actual_start = fields.DateTime( "Actual Start", format=_datetime_format, readonly=True, domain=[ If(Eval('actual_start') & Eval('actual_end'), ('actual_start', '<=', Eval('actual_end')), ()), ], states={ 'invisible': ~Eval('actual_start'), 'required': Eval('rental_state').in_(['picked up', 'done']), }) actual_end = fields.DateTime( "Actual End", format=_datetime_format, readonly=True, domain=[ If(Eval('actual_start') & Eval('actual_end'), ('actual_end', '>=', Eval('actual_start')), ()), ], states={ 'invisible': ~Eval('actual_end'), 'required': Eval('rental_state') == 'done', }) start = fields.Function( DateOrDateTime("Start", format=_datetime_format), 'on_change_with_start') end = fields.Function( DateOrDateTime("End", format=_datetime_format), 'on_change_with_end') duration = fields.Function( fields.TimeDelta( "Duration", states={ 'invisible': ~Eval('duration'), }), 'on_change_with_duration') unit_price = Monetary( "Unit Price", currency='currency', digits=price_digits, required=True, states={ 'readonly': Eval('rental_state') != 'draft', }) unit_price_unit = fields.Many2One( 'product.uom', "Unit Price Unit", required=True, domain=[ ('category', '=', Id('product', 'uom_cat_time')), ], states={ 'readonly': Eval('rental_state') != 'draft', }) taxes = fields.Many2Many( 'sale.rental.line-account.tax', 'line', 'tax', "Taxes", order=[('tax.sequence', 'ASC'), ('tax.id', 'ASC')], domain=[ ('parent', '=', None), ['OR', ('group', '=', None), ('group.kind', 'in', ['sale', 'both'])], ('company', '=', Eval('company', -1)), ], states={ 'readonly': Eval('rental_state') != 'draft', }) planned_amount = fields.Function( Monetary( "Planned Amount", digits='currency', currency='currency', states={ 'invisible': ~Eval('planned_duration'), }), 'on_change_with_planned_amount') amount = fields.Function( Monetary( "Amount", digits='currency', currency='currency', states={ 'invisible': ~Eval('duration'), }), 'on_change_with_amount') outgoing_moves = fields.Many2Many( 'sale.rental.line-outgoing-stock.move', 'line', 'move', "Outgoing Moves", states={ 'readonly': True, }) incoming_moves = fields.Many2Many( 'sale.rental.line-incoming-stock.move', 'line', 'move', "Incoming Moves", states={ 'readonly': True, }) invoice_lines = fields.One2Many( 'account.invoice.line', 'origin', "Invoice Lines", readonly=True) rental_state = fields.Function( fields.Selection('get_rental_states', "Rental State"), 'on_change_with_rental_state') company = fields.Function( fields.Many2One('company.company', "Company"), 'on_change_with_company') currency = fields.Function( fields.Many2One('currency.currency', "Currency"), 'on_change_with_currency') product_uom_category = fields.Function( fields.Many2One('product.uom.category', "Product UoM Category"), 'on_change_with_product_uom_category') @classmethod def __setup__(cls): super().__setup__() cls.__access__.add('rental') @fields.depends( 'rental', 'per_day', '_parent_rental.company', '_parent_rental.start', '_parent_rental.end', 'planned_start_day', 'planned_end_day', 'planned_start', 'planned_end', methods=['on_change_with_duration']) def _set_planned(self): if self.rental: if self.per_day: if not self.planned_start_day: self.planned_start_day = to_date( self.rental.start, self.rental.company) if not self.planned_end_day: self.planned_end_day = to_date( self.rental.end, self.rental.company) self.planned_start = self.planned_end = None else: self.planned_start_day = self.planned_end_day = None if not self.planned_start: self.planned_start = to_datetime( self.rental.start, self.rental.company) if not self.planned_end: self.planned_end = to_datetime( self.rental.end, self.rental.company, _time_max) self.duration = self.on_change_with_duration() @fields.depends( 'product', 'unit', methods=[ 'compute_taxes', 'compute_unit_price', 'on_change_with_amount', '_set_planned']) def on_change_product(self): if not self.product: return self.per_day = self.product.rental_per_day self._set_planned() self.taxes = self.compute_taxes() if (not self.unit or self.unit.category != self.product.default_uom.category): self.unit = self.product.default_uom self.unit_price_unit = self.product.rental_unit self.unit_price = self.compute_unit_price() self.amount = self.on_change_with_amount() @fields.depends( 'product', 'rental', '_parent_rental.invoice_party', '_parent_rental.party', methods=['_get_tax_rule_pattern']) def compute_taxes(self): party = None if self.rental: party = self.rental.invoice_party or self.rental.party taxes = set() pattern = self._get_tax_rule_pattern() for tax in self.product.customer_rental_taxes_used: if party and party.customer_tax_rule: tax_ids = party.customer_tax_rule.apply(tax, pattern) if tax_ids: taxes.update(tax_ids) continue taxes.add(tax.id) if party and party.customer_tax_rule: tax_ids = party.customer_tax_rule.apply(None, pattern) if tax_ids: taxes.update(tax_ids) return list(taxes) def _get_tax_rule_pattern(self): return {} @property def taxable_lines(self): # In case we're called from an on_change # we have to use some sensible defaults return [( getattr(self, 'taxes', None) or [], getattr(self, 'unit_price', None) or Decimal(0), (getattr(self, 'quantity', None) or 0) * (getattr(self, 'duration_unit', None) or 0), None, )] def _get_tax_context(self): return self.rental._get_tax_context() @fields.depends( 'product', 'quantity', 'unit_price_unit', methods=['on_change_with_duration', '_get_context_rental_price']) def compute_unit_price(self): pool = Pool() Product = pool.get('product.product') UoM = pool.get('product.uom') if not self.product: return duration = self.on_change_with_duration() with Transaction().set_context(self._get_context_rental_price()): unit_price = Product.get_rental_price( [self.product], quantity=self.quantity or 0, duration=duration or dt.timedelta())[self.product.id] if unit_price is not None: if self.unit_price_unit: unit_price = UoM.compute_price( self.product.rental_unit, unit_price, self.unit_price_unit) unit_price = round_price(unit_price) return unit_price @fields.depends( 'rental', '_parent_rental.currency', '_parent_rental.party', 'start', 'company', 'unit', 'product', 'taxes') def _get_context_rental_price(self): context = {} if self.rental: if self.rental.currency: context['currency'] = self.rental.currency.id if self.rental.party: context['customer'] = self.rental.party.id if self.start: context['rental_date'] = self.start if self.company: context['company'] = self.company.id if self.unit: context['uom'] = self.unit.id elif self.product: context['uom'] = self.product.default_uom.id context['taxes'] = [t.id for t in self.taxes or []] return context @fields.depends(methods=['compute_unit_price', 'on_change_with_amount']) def on_change_quantity(self): self.unit_price = self.compute_unit_price() self.amount = self.on_change_with_amount() @fields.depends(methods=['on_change_quantity']) def on_change_unit(self): self.on_change_quantity() @fields.depends(methods=['compute_unit_price', 'on_change_with_amount']) def _on_change_planned(self): self.unit_price = self.compute_unit_price() self.amount = self.on_change_with_amount() @fields.depends(methods=['_on_change_planned']) def on_change_planned_start_day(self): self._on_change_planned() @fields.depends(methods=['_on_change_planned']) def on_change_planned_end_day(self): self._on_change_planned() @fields.depends(methods=['_on_change_planned']) def on_change_planned_start(self): self._on_change_planned() @fields.depends(methods=['_on_change_planned']) def on_change_planned_end(self): self._on_change_planned() @fields.depends( 'per_day', 'planned_start_day', 'planned_end_day', 'planned_start', 'planned_end') def on_change_with_planned_duration(self, name=None): if self.per_day: if self.planned_start_day and self.planned_end_day: return self.planned_end_day - self.planned_start_day else: if self.planned_start and self.planned_end: return self.planned_end - self.planned_start @property @fields.depends('planned_duration', 'unit_price_unit') def planned_duration_unit(self): pool = Pool() UoM = pool.get('product.uom') Data = pool.get('ir.model.data') hour = UoM(Data.get_id('product', 'uom_hour')) if self.planned_duration: duration = self.planned_duration.total_seconds() / 60 / 60 else: duration = 0 if self.unit_price_unit: duration = UoM.compute_qty( hour, duration, self.unit_price_unit, round=False) return duration @fields.depends( 'per_day', 'company', 'actual_start', 'planned_start_day', 'planned_start') def on_change_with_start(self, name=None): if self.per_day: return ( to_date(self.actual_start, self.company) or self.planned_start_day) else: return self.actual_start or self.planned_start @fields.depends( 'per_day', 'company', 'actual_end', 'planned_end_day', 'planned_end') def on_change_with_end(self, name=None): if self.per_day: return ( to_date(self.actual_end, self.company) or self.planned_end_day) else: return self.actual_end or self.planned_end @fields.depends(methods=['on_change_with_start', 'on_change_with_end']) def on_change_with_duration(self, name=None): start = self.on_change_with_start() end = self.on_change_with_end() if start and end: return end - start @property @fields.depends('duration', 'unit_price_unit') def duration_unit(self): pool = Pool() UoM = pool.get('product.uom') Data = pool.get('ir.model.data') hour = UoM(Data.get_id('product', 'uom_hour')) if self.duration: duration = self.duration.total_seconds() / 60 / 60 else: duration = 0 if self.unit_price_unit: duration = UoM.compute_qty( hour, duration, self.unit_price_unit, round=False) return duration @fields.depends( 'quantity', 'unit_price', 'currency', methods=['on_change_with_planned_duration', 'planned_duration_unit']) def on_change_with_planned_amount(self, name=None): self.planned_duration = self.on_change_with_planned_duration() if self.quantity is not None and self.unit_price is not None: amount = ( Decimal(self.quantity) * Decimal(self.planned_duration_unit) * self.unit_price) if self.currency: amount = self.currency.round(amount) return amount @fields.depends( 'quantity', 'unit_price', 'currency', methods=['on_change_with_duration', 'duration_unit']) def on_change_with_amount(self, name=None): self.duration = self.on_change_with_duration() if self.quantity is not None and self.unit_price is not None: amount = ( Decimal(self.quantity) * Decimal(self.duration_unit) * self.unit_price) if self.currency: amount = self.currency.round(amount) return amount @classmethod def get_rental_states(cls): pool = Pool() Rental = pool.get('sale.rental') return Rental.fields_get(['state'])['state']['selection'] @fields.depends( 'rental', '_parent_rental.state', 'actual_start', 'actual_end') def on_change_with_rental_state(self, name=None): if self.actual_end: return 'done' elif self.actual_start: return 'picked up' elif self.rental: return self.rental.state @fields.depends('rental', '_parent_rental.company') def on_change_with_company(self, name=None): if self.rental and self.rental.company: return self.rental.company.id @fields.depends('rental', '_parent_rental.currency') def on_change_with_currency(self, name=None): if self.rental and self.rental.currency: return self.rental.currency.id @fields.depends('product') def on_change_with_product_uom_category(self, name=None): if self.product: return self.product.default_uom_category.id @property def picking_location(self): return ( self.rental.warehouse.rental_picking_location or self.rental.warehouse.storage_location) @property def return_location(self): return ( self.rental.warehouse.rental_return_location or self.rental.warehouse.storage_location) @property def rental_location(self): return self.rental.warehouse.rental_location def get_moves(self, type): if self.product.type == 'service': return [] if type == 'out' and self.outgoing_moves: return self.outgoing_moves elif type == 'in' and self.incoming_moves: return self.incoming_moves move = self.get_move(type) move.quantity = self.quantity move.unit = self.unit return [move] def get_move(self, type): pool = Pool() Move = pool.get('stock.move') move = Move() move.product = self.product if type == 'out': move.from_location = self.picking_location move.to_location = self.rental_location move.planned_date = to_date(self.start, self.company) elif type == 'in': move.from_location = self.rental_location move.to_location = self.return_location move.planned_date = to_date(self.end, self.company) move.state = 'draft' move.company = self.rental.company if move.on_change_with_unit_price_required(): move.unit_price = self.unit_price * self.duration_unit move.currency = self.rental.currency move.origin = self.rental return move @property def to_invoice(self): return self.rental_state == 'done' and not self.invoice_lines @property def start_invoice(self): return self.start @property def end_invoice(self): return self.end @property def duration_invoice(self): return self.duration @property def duration_unit_invoice(self): pool = Pool() UoM = pool.get('product.uom') Data = pool.get('ir.model.data') hour = UoM(Data.get_id('product', 'uom_hour')) if duration := self.duration_invoice: duration = duration.total_seconds() / 60 / 60 else: duration = 0 if self.unit_price_unit: duration = UoM.compute_qty( hour, duration, self.unit_price_unit, round=False) return duration def get_invoice_lines(self): pool = Pool() InvoiceLine = pool.get('account.invoice.line') if self.rental_state != 'done' or self.invoice_lines: return [] invoice_line = InvoiceLine(invoice_type='out', type='line') invoice_line.currency = self.rental.currency invoice_line.company = self.rental.company invoice_line.origin = self invoice_line.quantity = self.quantity invoice_line.unit = self.unit invoice_line.product = self.product invoice_line.unit_price = round_price( self.unit_price * Decimal(self.duration_unit_invoice)) invoice_line.taxes = self.taxes invoice_line.account = self.product.account_rental_used return [invoice_line] def get_rec_name(self, name): pool = Pool() Lang = pool.get('ir.lang') lang = Lang.get() converter = self.__class__.duration.converter quantity = lang.format_number_symbol( self.quantity, self.unit, digits=self.unit.digits) duration = Report.format_timedelta( self.duration, converter=converter, lang=lang) product = self.product.rec_name rental = self.rental.rec_name return f'{duration} × {quantity} {product} @ {rental}' @classmethod def search_rec_name(cls, name, clause): _, operator, value = clause if operator.startswith('!') or operator.startswith('not '): bool_op = 'AND' else: bool_op = 'OR' return [bool_op, ('rental.rec_name', *clause[1:]), ('product.rec_name', *clause[1:]), ] @classmethod def copy(cls, lines, default=None): default = default.copy() if default is not None else {} default.setdefault('actual_start') default.setdefault('actual_end') default.setdefault('invoice_lines') return super().copy(lines, default=default) @classmethod def check_modification(cls, mode, lines, values=None, external=False): super().check_modification( mode, lines, values=values, external=external) if mode == 'delete': for line in lines: if line.rental_state not in {'cancelled', 'draft'}: raise AccessError(gettext( 'sale_rental.msg_rental_line_delete_cancel_draft', line=line.rec_name, rental=line.rental.rec_name)) class RentalLine_Tax(ModelSQL): __name__ = 'sale.rental.line-account.tax' line = fields.Many2One( 'sale.rental.line', "Rental Line", ondelete='CASCADE', required=True) tax = fields.Many2One( 'account.tax', "Tax", ondelete='RESTRICT', required=True) @classmethod def __setup__(cls): super().__setup__() t = cls.__table__() cls._sql_constraints += [ ('line_tax_unique', Unique(t, t.line, t.tax), 'sale_rental.msg_rental_line_tax_unique'), ] class RentalLine_Outgoing_Move(ModelSQL): __name__ = 'sale.rental.line-outgoing-stock.move' line = fields.Many2One( 'sale.rental.line', "Rental Line", ondelete='CASCADE', required=True) move = fields.Many2One( 'stock.move', "Move", ondelete='RESTRICT', required=True, domain=[ ('origin', 'like', 'sale.rental,%'), ('to_location.type', '=', 'rental'), ]) class RentalLine_Incoming_Move(ModelSQL): __name__ = 'sale.rental.line-incoming-stock.move' line = fields.Many2One( 'sale.rental.line', "Rental Line", ondelete='CASCADE', required=True) move = fields.Many2One( 'stock.move', "Move", ondelete='RESTRICT', required=True, domain=[ ('origin', 'like', 'sale.rental,%'), ('from_location.type', '=', 'rental'), ]) class _RentalShowLine(ModelView): rental_line = fields.Many2One( 'sale.rental.line', "Rental Line", readonly=True) product = fields.Function( fields.Many2One('product.product', "Product"), 'on_change_with_product') quantity = fields.Function( fields.Float("Quantity", digits='unit'), 'on_change_with_quantity') unit = fields.Function( fields.Many2One('product.uom', "Unit"), 'on_change_with_unit') @classmethod def get(cls, rental_line): line = cls() line.rental_line = rental_line line.product = line.on_change_with_product() line.quantity = line.on_change_with_quantity() line.unit = line.on_change_with_unit() return line @fields.depends('rental_line') def on_change_with_product(self, name=None): if self.rental_line and self.rental_line.product: return self.rental_line.product.id @fields.depends('rental_line') def on_change_with_quantity(self, name=None): if self.rental_line: return self.rental_line.quantity @fields.depends('rental_line') def on_change_with_unit(self, name=None): if self.rental_line and self.rental_line.unit: return self.rental_line.unit.id def get_moves(self, type, date=None): if self.rental_line.product.type == 'service': return [] move = self.rental_line.get_move(type) move.unit = self.unit move.effective_date = date return [move] class RentalPickup(Wizard): __name__ = 'sale.rental.pickup' start = StateTransition() show = StateView('sale.rental.pickup.show', 'sale_rental.sale_rental_pickup_show_view_form', [ Button("Cancel", 'end', 'tryton-cancel'), Button("Pickup", 'pickup', 'tryton-ok', default=True), ]) pickup = StateTransition() @property def rental_lines(self): for line in self.record.lines: if line.rental_state == 'confirmed': yield line def transition_start(self): if any(self.rental_lines): return 'show' else: self.record.try_picked_up() return 'end' def default_show(self, fields): pool = Pool() Line = pool.get('sale.rental.pickup.show.line') defaults = {} if 'start' in fields: defaults['start'] = min( map(partial(to_datetime, company=self.record.company), (l.planned_start or l.planned_start_day for l in self.rental_lines)), default=dt.datetime.now()) if 'lines' in fields: defaults['lines'] = lines = [] for rental_line in self.rental_lines: lines.append(Line.get(rental_line)._changed_values()) return defaults def transition_pickup(self): pool = Pool() Lang = pool.get('ir.lang') Line = pool.get('sale.rental.line') Move = pool.get('stock.move') lang = Lang.get() rental_lines = [] moves_to_delete = [] outgoing_moves, incoming_moves = [], [] for line in self.show.lines: rental_line = line.rental_line if rental_line in rental_lines: raise ValidationError(gettext( 'sale_rental.msg_rental_pickup_once', line=rental_line.rec_name)) rental_lines.append(rental_line) quantity = line.quantity_picked unit = rental_line.unit if not quantity: continue if not (0 <= quantity <= rental_line.quantity): raise ValidationError(gettext( 'sale_rental.msg_rental_pickup_quantity', line=rental_line.rec_name, quantity=lang.format_number_symbol( rental_line.quantity, unit), picked=lang.format_number_symbol(quantity, unit))) remaining = unit.round(rental_line.quantity - quantity) if remaining: with Transaction().set_context(_sale_rental_line_split=True): Line.copy([rental_line], default={ 'quantity': remaining, 'outgoing_moves': None, 'incoming_moves': None, }) rental_line.actual_start = self.show.start rental_line.quantity = quantity moves_to_delete.extend(rental_line.outgoing_moves) rental_line.outgoing_moves = line.get_moves( 'out', date=self.show.start.date()) outgoing_moves.extend(rental_line.outgoing_moves) moves_to_delete.extend(rental_line.incoming_moves) rental_line.incoming_moves = line.get_moves('in') incoming_moves.extend(rental_line.incoming_moves) Move.save(outgoing_moves + incoming_moves) Line.save(rental_lines) Move.delete(moves_to_delete) self.model.set_moves([self.record]) Move.do(outgoing_moves) return 'end' class RentalPickupShow(ModelView): __name__ = 'sale.rental.pickup.show' start = fields.DateTime("Start", format=_datetime_format, required=True) lines = fields.One2Many('sale.rental.pickup.show.line', 'parent', "Lines") class RentalPickupShowLine(_RentalShowLine): __name__ = 'sale.rental.pickup.show.line' parent = fields.Many2One( 'sale.rental.pickup.show', "Parent", readonly=True) quantity_picked = fields.Float( "Quantity Picked", digits='unit', required=True, domain=[ ('quantity_picked', '<=', Eval('quantity')), ('quantity_picked', '>=', 0), ]) @classmethod def get(cls, rental_line): line = super().get(rental_line) line.quantity_picked = 0 return line def get_moves(self, type, date=None): moves = super().get_moves(type, date=date) for move in moves: move.quantity = self.quantity_picked return moves class RentalReturn(Wizard): __name__ = 'sale.rental.return' start = StateTransition() show = StateView('sale.rental.return.show', 'sale_rental.sale_rental_return_show_view_form', [ Button("Cancel", 'end', 'tryton-cancel'), Button("Return", 'return_', 'tryton-ok', default=True), ]) return_ = StateTransition() @property def rental_lines(self): for line in self.record.lines: if line.rental_state == 'picked up': yield line def transition_start(self): if any(self.rental_lines): return 'show' else: self.record.try_done() return 'end' def default_show(self, fields): pool = Pool() Line = pool.get('sale.rental.return.show.line') defaults = {} if 'end' in fields: defaults['end'] = max( map(partial( to_datetime, company=self.record.company, time=_time_max), (l.planned_end or l.planned_end_day for l in self.rental_lines)), default=dt.datetime.now()) if 'lines' in fields: defaults['lines'] = lines = [] for rental_line in self.rental_lines: lines.append(Line.get(rental_line)._changed_values()) return defaults def transition_return_(self): pool = Pool() Lang = pool.get('ir.lang') Line = pool.get('sale.rental.line') Move = pool.get('stock.move') lang = Lang.get() rental_lines = [] moves_to_delete = [] incoming_moves = [] for line in self.show.lines: rental_line = line.rental_line if rental_line in rental_lines: raise ValidationError(gettext( 'sale_rental.msg_rental_return_once', line=rental_line.rec_name)) rental_lines.append(rental_line) quantity = line.quantity_returned unit = rental_line.unit if not quantity: continue if not (0 <= quantity <= rental_line.quantity): raise ValidationError(gettext( 'sale_rental.msg_rental_return_quantity', line=rental_line.rec_name, quantity=lang.format_number_symbol( rental_line.quantity, unit), returned=lang.format_number_symbol(quantity, unit))) remaining = unit.round(rental_line.quantity - quantity) if remaining: with Transaction().set_context(_sale_rental_line_split=True): Line.copy([rental_line], default={ 'quantity': remaining, 'actual_start': rental_line.actual_start, 'incoming_moves': None, }) rental_line.actual_end = self.show.end rental_line.quantity = quantity moves_to_delete.extend(rental_line.incoming_moves) rental_line.incoming_moves = line.get_moves( 'in', date=self.show.end.date()) incoming_moves.extend(rental_line.incoming_moves) Move.save(incoming_moves) Line.save(rental_lines) Move.delete(moves_to_delete) self.model.set_moves([self.record]) Move.do(incoming_moves) return 'end' class RentalReturnShow(ModelView): __name__ = 'sale.rental.return.show' end = fields.DateTime("End", format=_datetime_format, required=True) lines = fields.One2Many('sale.rental.return.show.line', 'parent', "Lines") class RentalReturnShowLine(_RentalShowLine): __name__ = 'sale.rental.return.show.line' parent = fields.Many2One( 'sale.rental.return.show', "Parent", readonly=True) quantity_returned = fields.Float( "Quantity Returned", digits='unit', required=True, domain=[ ('quantity_returned', '<=', Eval('quantity')), ('quantity_returned', '>=', 0), ]) @classmethod def get(cls, rental_line): line = super().get(rental_line) line.quantity_returned = 0 return line def get_moves(self, type, date=None): moves = super().get_moves(type, date=date) for move in moves: move.quantity = self.quantity_returned return moves