# This file is part of lims_sale module for Tryton.
# The COPYRIGHT file at the top level of this repository contains
# the full copyright notices and license terms.
import logging
from io import BytesIO
from email import encoders
from email.mime.base import MIMEBase
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from PyPDF2 import PdfFileMerger
from PyPDF2.utils import PdfReadError
from decimal import Decimal

from trytond.model import ModelSQL, ModelView, fields
from trytond.wizard import Wizard, StateView, StateTransition, Button
from trytond.pool import PoolMeta, Pool
from trytond.pyson import Eval, Bool
from trytond.transaction import Transaction
from trytond.config import config
from trytond.tools import get_smtp_server
from trytond.exceptions import UserError, UserWarning
from trytond.i18n import gettext
from trytond.modules.lims_report_html.html_template import LimsReport
from trytond.modules.sale.exceptions import SaleValidationError

logger = logging.getLogger(__name__)


class Sale(metaclass=PoolMeta):
    __name__ = 'sale.sale'

    invoice_party_domain = fields.Function(fields.Many2Many('party.party',
        None, None, 'Invoice party domain'),
        'on_change_with_invoice_party_domain')
    purchase_order = fields.Char('Purchase order')
    expiration_date = fields.Date('Expiration date', required=True,
        states={'readonly': ~Eval('state').in_(['draft', 'quotation'])},
        depends=['state'])
    template = fields.Many2One('lims.report.template',
        'Sale Template', domain=[
            ('report_name', '=', 'sale.sale'),
            ('type', 'in', [None, 'base']),
            ['OR', ('active', '=', True),
                ('id', '=', Eval('template'))],
            ],
        states={'readonly': Eval('state') != 'draft'},
        depends=['state'])
    clause_template = fields.Many2One('sale.clause.template',
        'Clauses Template', depends=['state'],
        states={'readonly': Eval('state') != 'draft'})
    sections = fields.One2Many('sale.sale.section', 'sale', 'Sections')
    previous_sections = fields.Function(fields.One2Many(
        'sale.sale.section', 'sale', 'Previous Sections',
        domain=[('position', '=', 'previous')]),
        'get_previous_sections', setter='set_previous_sections')
    following_sections = fields.Function(fields.One2Many(
        'sale.sale.section', 'sale', 'Following Sections',
        domain=[('position', '=', 'following')]),
        'get_following_sections', setter='set_following_sections')
    clauses = fields.Text('Clauses',
        states={'readonly': Eval('state') != 'draft'},
        depends=['state'])
    send_email = fields.Boolean('Send automatically by Email',
        states={'readonly': ~Eval('state').in_(['draft', 'quotation'])},
        depends=['state'])
    services_completed = fields.Function(fields.Boolean('Services completed'),
        'get_services_completed')
    services_completed_manual = fields.Boolean('Manually completed services',
        states={
            'invisible': Eval('invoice_method') != 'service',
            'readonly': Eval('state') != 'processing',
            },
        depends=['invoice_method', 'state'])
    completion_percentage = fields.Function(fields.Numeric('Complete',
        digits=(1, 4)), 'get_completion_percentage')
    unlimited_quantity = fields.Function(fields.Boolean(
        'Lines with unlimited quantity'), 'get_unlimited_quantity')

    @classmethod
    def __setup__(cls):
        super().__setup__()
        state = ('expired', 'Expired')
        if state not in cls.state.selection:
            cls.state.selection.append(state)
        cls.shipping_date.states['readonly'] = Eval('state').in_(
            ['processing', 'expired', 'done', 'cancelled'])
        cls.invoice_party.required = True
        cls.invoice_party.select = True
        cls.invoice_party.domain = ['OR',
            ('id', '=', Eval('invoice_party')),
            ('id', 'in', Eval('invoice_party_domain'))]
        cls.invoice_party.depends.append('invoice_party_domain')
        cls.invoice_address.domain = [('party', '=', Eval('invoice_party'))]
        cls.invoice_address.depends.append('invoice_party')
        cls.contact.domain = [('party', '=', Eval('party'))]
        invoice_method = ('service', 'On Entry Confirmed')
        if invoice_method not in cls.invoice_method.selection:
            cls.invoice_method.selection.append(invoice_method)
        cls.invoice_state.states['invisible'] = (
            Eval('invoice_method') == 'service')
        cls.invoice_state.depends.append('invoice_method')
        cls.shipment_state.states['invisible'] = (
            Eval('invoice_method') == 'service')
        cls.shipment_state.depends.append('invoice_method')
        cls._buttons.update({
            'load_services': {
                'invisible': (Eval('state') != 'draft'),
                },
            'load_set_group': {
                'invisible': (Eval('state') != 'draft'),
                },
            })

    @staticmethod
    def default_services_completed_manual():
        return False

    @classmethod
    def view_attributes(cls):
        return super().view_attributes() + [
            ('//group[@id="links"]/link[@name="sale.act_shipment_form"]',
                'states', {
                    'invisible': Eval('invoice_method') == 'service',
                    }),
            ('//group[@id="links"]/link[@name="sale.act_return_form"]',
                'states', {
                    'invisible': Eval('invoice_method') == 'service',
                    }),
            ]

    @fields.depends('party', 'invoice_party')
    def on_change_party(self):
        super().on_change_party()
        self.invoice_party = None
        if self.party:
            invoice_party_domain = self.on_change_with_invoice_party_domain()
            if len(invoice_party_domain) == 1:
                self.invoice_party = invoice_party_domain[0]

    @fields.depends('party', '_parent_party.relations')
    def on_change_with_invoice_party_domain(self, name=None):
        pool = Pool()
        Config = pool.get('lims.configuration')

        config_ = Config(1)
        parties = []
        if self.party:
            parties.append(self.party.id)
            if config_.invoice_party_relation_type:
                parties.extend([r.to.id for r in self.party.relations
                    if r.type == config_.invoice_party_relation_type])
        return parties

    @fields.depends('invoice_party')
    def on_change_invoice_party(self):
        self.invoice_address = None
        if self.invoice_party:
            self.invoice_address = self.invoice_party.address_get(
                type='invoice')

    @fields.depends('template', '_parent_template.sections', 'sections',
        '_parent_template.clause_template',
        methods=['on_change_clause_template'])
    def on_change_template(self):
        if self.template and self.template.sections:
            sections = {}
            for s in self.sections + self.template.sections:
                sections[s.name] = {
                    'name': s.name,
                    'data': s.data,
                    'data_id': s.data_id,
                    'position': s.position,
                    'order': s.order,
                    }
            self.sections = sections.values()
        if self.template and self.template.clause_template:
            self.clause_template = self.template.clause_template
            self.on_change_clause_template()

    @fields.depends('clause_template', '_parent_clause_template.content')
    def on_change_clause_template(self):
        if self.clause_template:
            self.clauses = self.clause_template.content

    def get_previous_sections(self, name):
        return [s.id for s in self.sections if s.position == 'previous']

    @classmethod
    def set_previous_sections(cls, sections, name, value):
        if not value:
            return
        cls.write(sections, {'sections': value})

    def get_following_sections(self, name):
        return [s.id for s in self.sections if s.position == 'following']

    @classmethod
    def set_following_sections(cls, sections, name, value):
        if not value:
            return
        cls.write(sections, {'sections': value})

    def get_services_completed(self, name=None):
        pool = Pool()
        SaleLine = pool.get('sale.line')

        if self.services_completed_manual:
            return True

        sale_lines = SaleLine.search([
            ('sale', '=', self.id),
            ('type', '=', 'line'),
            ])
        if not sale_lines:
            return False
        for line in sale_lines:
            if line.unlimited_quantity:
                return False
            if not line.quantity:
                return False
            if line.quantity > len(line.services):
                return False
        return True

    def get_completion_percentage(self, name=None):
        pool = Pool()
        SaleLine = pool.get('sale.line')
        Sale = pool.get('sale.sale')

        if self.services_completed_manual:
            return Decimal(1)

        sale_lines = SaleLine.search([
            ('sale', '=', self.id),
            ('type', '=', 'line'),
            ])
        if not sale_lines:
            return Decimal(0)

        completed = Decimal(0)
        total = Decimal(0)
        for line in sale_lines:
            if line.unlimited_quantity:
                continue
            if not line.quantity:
                continue
            completed += (line.amount / Decimal(line.quantity) *
                len(line.services))
            total += line.amount
        if not total:
            return Decimal(0)

        digits = Sale.completion_percentage.digits[1]
        return Decimal(
            Decimal(completed) / Decimal(total)
            ).quantize(Decimal(str(10 ** -digits)))

    def get_unlimited_quantity(self, name=None):
        pool = Pool()
        SaleLine = pool.get('sale.line')

        sale_lines = SaleLine.search([
            ('sale', '=', self.id),
            ('type', '=', 'line'),
            ('unlimited_quantity', '=', True),
            ])
        if sale_lines:
            return True
        return False

    def check_method(self):
        super().check_method()
        if (self.shipment_method == 'invoice'
                and self.invoice_method == 'service'):
            raise SaleValidationError(
                gettext('sale.msg_sale_invalid_method',
                    invoice_method=self.invoice_method_string,
                    shipment_method=self.shipment_method_string,
                    sale=self.rec_name))

    @classmethod
    @ModelView.button_action('lims_sale.wiz_sale_load_services')
    def load_services(cls, sales):
        pass

    @classmethod
    @ModelView.button_action('lims_sale.wiz_sale_load_set_group')
    def load_set_group(cls, sales):
        pass

    @classmethod
    def quote(cls, sales):
        super().quote(sales)
        cls.send_email_party(s for s in sales if s.send_email)

    @classmethod
    def send_email_party(cls, sales):
        from_addr = config.get('email', 'from')
        if not from_addr:
            logger.error("Missing configuration to send emails")
            return

        for sale in sales:
            to_addr = 'contacto@silix.com.ar'  # sale.party.email
            if not to_addr:
                logger.error("Missing address for '%s' to send email",
                    sale.party.rec_name)
                continue
            reply_to = sale.create_uid.email

            subject, body = sale._get_subject_body()
            attachment_data = sale._get_attachment()
            msg = cls.create_msg(from_addr, to_addr, reply_to, subject,
                body, attachment_data)
            cls.send_msg(from_addr, to_addr, msg, sale.number)

    def _get_subject_body(self):
        pool = Pool()
        Config = pool.get('sale.configuration')

        config = Config(1)
        subject = str(config.email_quotation_subject)
        body = str(config.email_quotation_body)
        return subject, body

    def _get_attachment(self):
        pool = Pool()
        SaleReport = pool.get('sale.sale', type='report')
        result = SaleReport.execute([self.id], {})

        data = {
            'content': result[1],
            'format': result[0],
            'mimetype': (result[0] == 'pdf' and 'pdf' or
                'vnd.oasis.opendocument.text'),
            'filename': '%s.%s' % (str(self.number), str(result[0])),
            'name': str(self.number),
            }
        return data

    @staticmethod
    def create_msg(from_addr, to_addr, reply_to, subject, body,
            attachment_data):
        if not (from_addr or to_addr):
            return None

        msg = MIMEMultipart('mixed')
        msg['From'] = from_addr
        msg['To'] = to_addr
        msg['Reply-to'] = reply_to
        msg['Subject'] = subject

        msg_body = MIMEText('text', 'plain')
        msg_body.set_payload(body.encode('UTF-8'), 'UTF-8')
        msg.attach(msg_body)

        attachment = MIMEBase('application', 'octet-stream')
        attachment.set_payload(attachment_data['content'])
        encoders.encode_base64(attachment)
        attachment.add_header('Content-Disposition', 'attachment',
            filename=attachment_data['filename'])
        msg.attach(attachment)
        return msg

    @staticmethod
    def send_msg(from_addr, to_addr, msg, task_number):
        success = False
        try:
            server = get_smtp_server()
            server.sendmail(from_addr, [to_addr], msg.as_string())
            server.quit()
            success = True
        except Exception:
            logger.error(
                "Unable to deliver email for task '%s'" % (task_number))
        return success

    def create_invoice(self):
        if self.invoice_method == 'service':
            return
        return super().create_invoice()

    def is_done(self):
        if self.invoice_method != 'service':
            return super().is_done()
        return self.services_completed

    @classmethod
    def process(cls, sales):
        pool = Pool()
        Warning = pool.get('res.user.warning')

        for sale in sales:
            if (sale.state == 'processing' and
                    sale.invoice_method == 'service' and
                    sale.services_completed_manual):
                error_key = 'lims_sale_completed_manual@%s' % sale.id
                if Warning.check(error_key):
                    raise UserWarning(error_key, gettext(
                        'lims_sale.msg_sale_completed_manual'))
        return super().process(sales)

    @classmethod
    def update_expired_sales_status(cls):
        '''
        Cron - Update Expired Sales Status
        '''
        Date = Pool().get('ir.date')
        expired_sales = cls.search([
            ('expiration_date', '<', Date.today()),
            ('state', 'in', ['quotation', 'processing', 'confirmed']),
            ])
        logger.info('Cron - Updating Expired Sales Status: %s found' %
            str(len(expired_sales)))
        if expired_sales:
            cls.write(expired_sales, {'state': 'expired'})


class Sale2(metaclass=PoolMeta):
    __name__ = 'sale.sale'

    def get_invoices(self, name):
        invoices = super().get_invoices(name)
        lims_invoices = set()
        for line in self.lines:
            for invoice_line in line.lims_invoice_lines:
                if invoice_line.invoice:
                    lims_invoices.add(invoice_line.invoice.id)
        return invoices + list(lims_invoices)

    @classmethod
    def search_invoices(cls, name, clause):
        return ['OR',
            ('lines.invoice_lines.invoice' + clause[0].lstrip(name),) +
            tuple(clause[1:]),
            ('lines.lims_invoice_lines.invoice' + clause[0].lstrip(name),) +
            tuple(clause[1:]),
            ]


class SaleSection(ModelSQL, ModelView):
    'Sale Section'
    __name__ = 'sale.sale.section'
    _order_name = 'order'

    sale = fields.Many2One('sale.sale', 'Sale',
        ondelete='CASCADE', select=True, required=True)
    name = fields.Char('Name', required=True)
    data = fields.Binary('File', filename='name', required=True,
        file_id='data_id', store_prefix='sale_section')
    data_id = fields.Char('File ID', readonly=True)
    position = fields.Selection([
        ('previous', 'Previous'),
        ('following', 'Following'),
        ], 'Position', required=True)
    order = fields.Integer('Order')

    @classmethod
    def __setup__(cls):
        super().__setup__()
        cls._order.insert(0, ('order', 'ASC'))

    @classmethod
    def validate(cls, sections):
        super().validate(sections)
        merger = PdfFileMerger(strict=False)
        for section in sections:
            filedata = BytesIO(section.data)
            try:
                merger.append(filedata)
            except PdfReadError:
                raise UserError(gettext('lims_report_html.msg_section_pdf'))


class SaleLine(metaclass=PoolMeta):
    __name__ = 'sale.line'

    purchase_order = fields.Char('PO Nº')
    product_type = fields.Many2One('lims.product.type', 'Product type',
        domain=['OR', ('id', '=', Eval('product_type')),
            ('id', 'in', Eval('product_type_domain'))],
        states={
            'readonly': Eval('sale_state') != 'draft',
            'invisible': Eval('type') != 'line',
            },
        depends=['product_type_domain', 'sale_state', 'type'])
    product_type_domain = fields.Function(fields.Many2Many('lims.product.type',
        None, None, 'Product type domain'),
        'on_change_with_product_type_domain')
    matrix = fields.Many2One('lims.matrix', 'Matrix',
        domain=['OR', ('id', '=', Eval('matrix')),
            ('id', 'in', Eval('matrix_domain'))],
        states={
            'readonly': Eval('sale_state') != 'draft',
            'invisible': Eval('type') != 'line',
            },
        depends=['matrix_domain', 'sale_state', 'type'])
    matrix_domain = fields.Function(fields.Many2Many('lims.matrix',
        None, None, 'Matrix domain'), 'on_change_with_matrix_domain')
    analysis = fields.Many2One('lims.analysis', 'Service',
        domain=['OR', ('id', '=', Eval('analysis')),
            ('id', 'in', Eval('analysis_domain'))],
        states={
            'readonly': Eval('sale_state') != 'draft',
            'invisible': Eval('type') != 'line',
            },
        depends=['analysis_domain', 'sale_state', 'type'])
    analysis_domain = fields.Function(fields.Many2Many('lims.analysis',
        None, None, 'Analysis domain'), 'on_change_with_analysis_domain')
    method = fields.Many2One('lims.lab.method', 'Method',
        domain=['OR', ('id', '=', Eval('method')),
            ('id', 'in', Eval('method_domain'))],
        states={
            'invisible': Bool(Eval('method_invisible')),
            'readonly': Eval('sale_state') != 'draft',
            },
        depends=['method_domain', 'method_invisible', 'sale_state'])
    method_invisible = fields.Function(fields.Boolean('Method invisible'),
        'on_change_with_method_invisible')
    method_domain = fields.Function(fields.Many2Many('lims.lab.method',
        None, None, 'Method domain'), 'on_change_with_method_domain')
    expiration_date = fields.Date('Expiration date',
        states={'readonly': Eval('sale_state') != 'draft'},
        depends=['sale_state'])
    print_price = fields.Boolean('Print price on quotation',
        states={'readonly': Eval('sale_state') != 'draft'},
        depends=['sale_state'])
    print_service_detail = fields.Boolean('Print service detail',
        states={
            'invisible': Bool(Eval('print_service_detail_invisible')),
            'readonly': Eval('sale_state') != 'draft',
            },
        depends=['print_service_detail_invisible', 'sale_state'])
    print_service_detail_invisible = fields.Function(fields.Boolean(
        'Print service detail invisible'),
        'on_change_with_print_service_detail_invisible')
    unlimited_quantity = fields.Boolean('Unlimited quantity',
        states={'readonly': Eval('sale_state') != 'draft'},
        depends=['sale_state'])
    services = fields.Many2Many('lims.service-sale.line',
        'sale_line', 'service', 'Services', readonly=True)
    services_available = fields.Function(fields.Float('Services available',
        digits=(16, Eval('unit_digits', 2)), depends=['unit_digits']),
        'on_change_with_services_available')
    services_completed = fields.Function(fields.Boolean('Services completed'),
        'on_change_with_services_completed')
    services_completed_icon = fields.Function(fields.Char(
        'Services completed Icon'), 'get_services_completed_icon')
    additional_origin = fields.Many2One('sale.line', 'Origin of additional')

    @staticmethod
    def default_product_type_domain():
        cursor = Transaction().connection.cursor()
        pool = Pool()
        Typification = pool.get('lims.typification')

        cursor.execute('SELECT DISTINCT(product_type) '
            'FROM "' + Typification._table + '" '
            'WHERE valid')
        res = cursor.fetchall()
        if not res:
            return []
        return [x[0] for x in res]

    def on_change_with_product_type_domain(self, name=None):
        return self.default_product_type_domain()

    @fields.depends('product_type', 'matrix', 'matrix_domain')
    def on_change_product_type(self):
        matrix_domain = []
        matrix = None
        if self.product_type:
            matrix_domain = self.on_change_with_matrix_domain()
            if len(matrix_domain) == 1:
                matrix = matrix_domain[0]
        self.matrix_domain = matrix_domain
        self.matrix = matrix

    @fields.depends('product_type')
    def on_change_with_matrix_domain(self, name=None):
        cursor = Transaction().connection.cursor()
        pool = Pool()
        Typification = pool.get('lims.typification')

        if not self.product_type:
            return []

        cursor.execute('SELECT DISTINCT(matrix) '
            'FROM "' + Typification._table + '" '
            'WHERE product_type = %s '
            'AND valid',
            (self.product_type.id,))
        res = cursor.fetchall()
        if not res:
            return []
        return [x[0] for x in res]

    @fields.depends('product_type', 'matrix')
    def on_change_with_analysis_domain(self, name=None):
        cursor = Transaction().connection.cursor()
        pool = Pool()
        Typification = pool.get('lims.typification')
        CalculatedTypification = pool.get('lims.typification.calculated')
        Analysis = pool.get('lims.analysis')

        if not self.product_type or not self.matrix:
            return []

        cursor.execute('SELECT DISTINCT(analysis) '
            'FROM "' + Typification._table + '" '
            'WHERE product_type = %s '
                'AND matrix = %s '
                'AND valid',
            (self.product_type.id, self.matrix.id))
        typified_analysis = [a[0] for a in cursor.fetchall()]
        if not typified_analysis:
            return []

        cursor.execute('SELECT id '
            'FROM "' + Analysis._table + '" '
            'WHERE type = \'analysis\' '
                'AND behavior IN (\'normal\', \'internal_relation\') '
                'AND disable_as_individual IS TRUE '
                'AND state = \'active\'')
        disabled_analysis = [a[0] for a in cursor.fetchall()]
        if disabled_analysis:
            typified_analysis = list(set(typified_analysis) -
                set(disabled_analysis))

        cursor.execute('SELECT DISTINCT(analysis) '
            'FROM "' + CalculatedTypification._table + '" '
            'WHERE product_type = %s '
                'AND matrix = %s',
            (self.product_type.id, self.matrix.id))
        typified_sets_groups = [a[0] for a in cursor.fetchall()]

        cursor.execute('SELECT id '
            'FROM "' + Analysis._table + '" '
            'WHERE behavior = \'additional\' '
                'AND state = \'active\'')
        additional_analysis = [a[0] for a in cursor.fetchall()]

        return typified_analysis + typified_sets_groups + additional_analysis

    @fields.depends('product', 'analysis', '_parent_analysis.methods')
    def on_change_with_method_domain(self, name=None):
        Analysis = Pool().get('lims.analysis')
        if self.analysis:
            return [m.id for m in self.analysis.methods]
        if self.product:
            res = Analysis.search([
                ('product', '=', self.product.id),
                ('type', '=', 'analysis'),
                ])
            if res:
                return [m.id for m in res[0].methods]
        return []

    @staticmethod
    def default_method_invisible():
        return True

    @fields.depends('product', 'analysis', '_parent_analysis.type')
    def on_change_with_method_invisible(self, name=None):
        Analysis = Pool().get('lims.analysis')
        if self.analysis and self.analysis.type == 'analysis':
            return False
        if (self.product and Analysis.search_count([
                    ('product', '=', self.product.id),
                    ('type', '=', 'analysis'),
                    ]) > 0):
                return False
        return True

    @staticmethod
    def default_print_service_detail_invisible():
        return True

    @fields.depends('product', 'analysis', '_parent_analysis.type')
    def on_change_with_print_service_detail_invisible(self, name=None):
        Analysis = Pool().get('lims.analysis')
        if self.analysis and self.analysis.type in ('set', 'group'):
            return False
        if (self.product and Analysis.search_count([
                    ('product', '=', self.product.id),
                    ('type', 'in', ('set', 'group')),
                    ]) > 0):
                return False
        return True

    @fields.depends('analysis')
    def on_change_analysis(self):
        product = None
        if self.analysis and self.analysis.product:
            product = self.analysis.product.id
        self.product = product
        self.on_change_product()

    @classmethod
    def create(cls, vlist):
        sale_lines = super().create(vlist)
        cls.create_additional_services(sale_lines)
        return sale_lines

    @classmethod
    def create_additional_services(cls, sale_lines):
        cursor = Transaction().connection.cursor()
        pool = Pool()
        Typification = pool.get('lims.typification')
        Analysis = pool.get('lims.analysis')

        additional_services = {}
        for sale_line in sale_lines:
            if (not sale_line.product_type or not sale_line.matrix or
                    not sale_line.analysis):
                continue

            analysis = [(sale_line.analysis.id,
                sale_line.method and sale_line.method.id or None)]
            analysis.extend(Analysis.get_included_analysis_method(
                sale_line.analysis.id))

            for a in analysis:
                clause = [
                    ('product_type', '=', sale_line.product_type.id),
                    ('matrix', '=', sale_line.matrix.id),
                    ('analysis', '=', a[0]),
                    ('valid', '=', True),
                    ]
                if a[1]:
                    clause.append(('method', '=', a[1]))
                else:
                    clause.append(('by_default', '=', True))
                typifications = Typification.search(clause)
                if not typifications:
                    continue
                typification = typifications[0]
                key = sale_line.sale.id

                if typification.additional and typification.additional.product:
                    additional = typification.additional
                    if key not in additional_services:
                        additional_services[key] = {}
                    if additional.id not in additional_services[key]:

                        additional_services[key][additional.id] = {
                            'product': additional.product.id,
                            'quantity': sale_line.quantity,
                            'unit': additional.product.default_uom.id,
                            'product_type': sale_line.product_type.id,
                            'matrix': sale_line.matrix.id,
                            'method': None,
                            'additional_origin': sale_line.id,
                            }

                if typification.additionals:
                    if key not in additional_services:
                        additional_services[key] = {}
                    for additional in typification.additionals:
                        if not additional.product:
                            continue
                        if additional.id not in additional_services[key]:

                            cursor.execute('SELECT method '
                                'FROM "' + Typification._table + '" '
                                'WHERE product_type = %s '
                                    'AND matrix = %s '
                                    'AND analysis = %s '
                                    'AND valid IS TRUE '
                                    'AND by_default IS TRUE',
                                (sale_line.product_type.id,
                                    sale_line.matrix.id, additional.id))
                            res = cursor.fetchone()
                            method_id = res and res[0] or None

                            additional_services[key][additional.id] = {
                                'product': additional.product.id,
                                'quantity': sale_line.quantity,
                                'unit': additional.product.default_uom.id,
                                'product_type': sale_line.product_type.id,
                                'matrix': sale_line.matrix.id,
                                'method': method_id,
                                'additional_origin': sale_line.id,
                                }

        if additional_services:
            sale_lines = []
            for sale_id, analysis in additional_services.items():
                for analysis_id, service_data in analysis.items():
                    if cls.search([
                            ('sale', '=', sale_id),
                            ('analysis', '=', analysis_id),
                            ]):
                        continue
                    sale_line = cls(
                        quantity=service_data['quantity'],
                        unit=service_data['unit'],
                        product_type=service_data['product_type'],
                        matrix=service_data['matrix'],
                        analysis=analysis_id,
                        product=service_data['product'],
                        method=service_data['method'],
                        additional_origin=service_data['additional_origin'],
                        sale=sale_id,
                        )
                    sale_line.on_change_product()
                    sale_lines.append(sale_line)
            cls.save(sale_lines)

    @classmethod
    def delete(cls, sale_lines):
        cls.delete_additional_services(sale_lines)
        super().delete(sale_lines)

    @classmethod
    def delete_additional_services(cls, sale_lines):
        lines_to_delete = [l.id for l in sale_lines]
        additionals = cls.search([
            ('additional_origin', 'in', lines_to_delete),
            ])
        additionals_to_delete = [l for l in additionals
            if l.id not in lines_to_delete]
        if additionals_to_delete:
            cls.delete(additionals_to_delete)

    @classmethod
    def copy(cls, sale_lines, default=None):
        if default is None:
            default = {}
        current_default = default.copy()
        current_default['services'] = None
        current_default['additional_origin'] = None
        return super().copy(sale_lines, default=current_default)

    @fields.depends('unlimited_quantity', 'quantity', 'services')
    def on_change_with_services_available(self, name=None):
        if self.quantity is None:
            return None
        if self.unlimited_quantity:
            return None
        res = self.quantity - len(self.services)
        if res < 0:
            return 0
        return res

    @fields.depends('unlimited_quantity', 'quantity', 'services')
    def on_change_with_services_completed(self, name=None):
        if self.unlimited_quantity:
            return False
        if not self.quantity:
            return False
        if self.quantity > len(self.services):
            return False
        return True

    def get_services_completed_icon(self, name):
        if self.services_completed:
            return 'lims-red'
        return 'lims-green'


class SaleLine2(metaclass=PoolMeta):
    __name__ = 'sale.line'

    lims_invoice_lines = fields.One2Many('account.invoice.line',
        'lims_sale_line_origin', 'Invoice Lines', readonly=True)


class SaleLoadServicesStart(ModelView):
    'Load Services from Entry'
    __name__ = 'sale.load_services.start'

    entry = fields.Many2One('lims.entry', 'Entry', required=True,
        domain=[('invoice_party', '=', Eval('party'))], depends=['party'])
    party = fields.Many2One('party.party', 'Party')


class SaleLoadServices(Wizard):
    'Load Services from Entry'
    __name__ = 'sale.load_services'

    start = StateView('sale.load_services.start',
        'lims_sale.sale_load_services_start_view_form', [
            Button('Cancel', 'end', 'tryton-cancel'),
            Button('Load', 'load', 'tryton-ok', default=True),
            ])
    load = StateTransition()

    def default_start(self, fields):
        pool = Pool()
        Sale = pool.get('sale.sale')
        sale = Sale(Transaction().context['active_id'])
        return {
            'party': sale.party.id,
            }

    def transition_load(self):
        pool = Pool()
        Service = pool.get('lims.service')
        SaleLine = pool.get('sale.line')

        sale_id = Transaction().context['active_id']

        sale_services = {}
        with Transaction().set_context(_check_access=False):
            services = Service.search([
                ('entry', '=', self.start.entry.id),
                ('fraction.cie_fraction_type', '=', False),
                ('annulled', '=', False),
                ])
        for service in services:
            if hasattr(service.fraction.type, 'invoiceable') and (
                    not service.fraction.type.invoiceable):
                continue
            if not service.analysis.product:
                continue
            if service.analysis.id not in sale_services:
                sale_services[service.analysis.id] = {
                    'quantity': 0,
                    'unit': service.analysis.product.default_uom.id,
                    'product': service.analysis.product.id,
                    'description': service.analysis.rec_name,
                    }
            sale_services[service.analysis.id]['quantity'] += 1

        sale_lines = []
        for service in sale_services.values():
            sale_line = SaleLine(
                quantity=service['quantity'],
                unit=service['unit'],
                product=service['product'],
                description=service['description'],
                sale=sale_id,
                )
            sale_line.on_change_product()
            sale_lines.append(sale_line)
        SaleLine.save(sale_lines)
        return 'end'


class SaleLoadAnalysisStart(ModelView):
    'Load Analysis from Set/Group'
    __name__ = 'sale.load_set_group.start'

    analysis = fields.Many2One('lims.analysis', 'Set/Group', required=True,
        domain=[('type', 'in', ['set', 'group'])])
    quantity = fields.Integer('Quantity', required=True)

    @staticmethod
    def default_quantity():
        return 1


class SaleLoadAnalysis(Wizard):
    'Load Analysis from Set/Group'
    __name__ = 'sale.load_set_group'

    start = StateView('sale.load_set_group.start',
        'lims_sale.sale_load_set_group_start_view_form', [
            Button('Cancel', 'end', 'tryton-cancel'),
            Button('Load', 'load', 'tryton-ok', default=True),
            ])
    load = StateTransition()

    def transition_load(self):
        pool = Pool()
        SaleLine = pool.get('sale.line')

        sale_id = Transaction().context['active_id']

        def get_sale_services(analysis, quantity=1, sale_services={}):
            if not analysis.included_analysis:
                return sale_services
            for ia in analysis.included_analysis:
                included = ia.included_analysis
                if not included.product:
                    continue
                if included.id not in sale_services.keys():
                    if included.type != 'set':
                        sale_services[included.id] = {
                            'quantity': quantity,
                            'unit': included.product.default_uom.id,
                            'product': included.product.id,
                            'method': ia.method.id if ia.method else None,
                            'description': included.rec_name,
                            }
                    sale_services = get_sale_services(included, quantity, sale_services)
            return sale_services

        sale_services = get_sale_services(self.start.analysis, self.start.quantity)

        sale_lines = []
        for service in sale_services.values():
            sale_line = SaleLine(
                quantity=service['quantity'],
                unit=service['unit'],
                product=service['product'],
                method=service['method'],
                sale=sale_id,
                )
            sale_line.on_change_product()
            sale_lines.append(sale_line)
        SaleLine.save(sale_lines)
        return 'end'


class SaleReport(LimsReport, metaclass=PoolMeta):
    __name__ = 'sale.sale'

    @classmethod
    def execute(cls, ids, data):
        Sale = Pool().get('sale.sale')

        if data is None:
            data = {}
        current_data = data.copy()

        if len(ids) > 1:
            raise UserError(gettext(
                'lims_report_html.msg_print_multiple_record'))

        sale = Sale(ids[0])
        template = sale.template
        if template and template.type == 'base':  # HTML
            result = cls.execute_html_lims_report(ids, current_data)
        else:
            current_data['action_id'] = None
            if template and template.report:
                current_data['action_id'] = template.report.id
            result = cls.execute_custom_lims_report(ids, current_data)

        return result
