# This file is part of Tryton. The COPYRIGHT file at the top level of # this repository contains the full copyright notices and license terms. import datetime from functools import wraps from itertools import groupby from sql import Null from sql.conditionals import Case from sql.functions import CharLength from trytond.i18n import gettext from trytond.model import ( ChatMixin, Index, ModelSQL, ModelView, Workflow, fields) from trytond.modules.company import CompanyReport from trytond.modules.currency.fields import Monetary from trytond.modules.product import price_digits from trytond.pool import Pool, PoolMeta from trytond.pyson import Bool, Eval, Id, If from trytond.tools import sortable_values from trytond.transaction import Transaction from trytond.wizard import ( Button, StateAction, StateTransition, StateView, Wizard) from .exceptions import PreviousQuotation def process_request(func): @wraps(func) def wrapper(cls, quotations): pool = Pool() Request = pool.get('purchase.request') result = func(cls, quotations) requests = [l.request for q in quotations for l in q.lines] Request.update_state(requests) return result return wrapper class Configuration(metaclass=PoolMeta): __name__ = 'purchase.configuration' purchase_request_quotation_sequence = fields.MultiValue(fields.Many2One( 'ir.sequence', 'Purchase Request Quotation Sequence', required=True, domain=[ ('company', 'in', [Eval('context', {}).get('company', -1), None]), ('sequence_type', '=', Id('purchase_request_quotation', 'sequence_type_purchase_request_quotation')), ])) @classmethod def multivalue_model(cls, field): pool = Pool() if field == 'purchase_request_quotation_sequence': return pool.get('purchase.configuration.sequence') return super().multivalue_model(field) @classmethod def default_purchase_request_quotation_sequence(cls, **pattern): return cls.multivalue_model('purchase_request_quotation_sequence' ).default_purchase_request_quotation_sequence() class ConfigurationSequence(metaclass=PoolMeta): __name__ = 'purchase.configuration.sequence' purchase_request_quotation_sequence = fields.Many2One( 'ir.sequence', 'Purchase Request Quotation Sequence', required=True, domain=[ ('company', 'in', [Eval('context', {}).get('company', -1), None]), ('sequence_type', '=', Id('purchase_request_quotation', 'sequence_type_purchase_request_quotation')), ]) @classmethod def default_purchase_request_quotation_sequence(cls): pool = Pool() ModelData = pool.get('ir.model.data') try: return ModelData.get_id( 'purchase_request_quotation', 'sequence_purchase_request_quotation') except KeyError: return None class Quotation(Workflow, ModelSQL, ModelView, ChatMixin): __name__ = 'purchase.request.quotation' _rec_name = 'number' number = fields.Char('Number', readonly=True, states={ 'required': ~Eval('state').in_(['draft', 'cancelled']) }, help="The unique identifier of the quotation.") revision = fields.Integer('Revision', readonly=True, help="Number incremented each time the quotation is sent.") reference = fields.Char( "Reference", help="The reference used by the supplier.") company = fields.Many2One( 'company.company', "Company", required=True, states={ 'readonly': Eval('state') != 'draft', }) warehouse = fields.Many2One('stock.location', 'Warehouse', domain=[('type', '=', 'warehouse')]) supplier = fields.Many2One( 'party.party', "Supplier", required=True, states={ 'readonly': Eval('lines', [0]) & Eval('supplier'), }, context={ 'company': Eval('company', -1), }, depends={'company'}) supplier_address = fields.Many2One('party.address', 'Supplier Address', domain=[ ('party', '=', Eval('supplier', -1)), ]) lines = fields.One2Many('purchase.request.quotation.line', 'quotation', 'Lines', states={ 'readonly': Eval('state') != 'draft', }) state = fields.Selection([ ('draft', 'Draft'), ('sent', 'Sent'), ('received', 'Received'), ('rejected', 'Rejected'), ('cancelled', 'Cancelled'), ], "State", readonly=True, required=True, sort=False) @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.state, Index.Equality(cardinality='low')), where=t.state.in_(['draft', 'sent'])), }) cls._transitions |= set(( ('draft', 'cancelled'), ('cancelled', 'draft'), ('draft', 'sent'), ('sent', 'rejected'), ('sent', 'received'), ('sent', 'draft'), ('received', 'rejected'), ('rejected', 'received'), )) cls._buttons.update({ 'cancel': { 'invisible': Eval('state') != 'draft', }, 'draft': { 'invisible': ~Eval('state').in_(['cancelled', 'sent']), 'icon': If(Eval('state') == 'cancelled', 'tryton-undo', 'tryton-back'), }, 'send': { 'invisible': ((Eval('state') != 'draft') | ~Eval('lines', [])), 'readonly': ~Eval('lines', []), }, 'receive': { 'invisible': ~Eval('state').in_(['sent', 'rejected']), }, 'reject': { 'invisible': ~Eval('state').in_(['sent', 'received']), }, }) @classmethod def order_number(cls, tables): table, _ = tables[None] return [ ~((table.state == 'cancelled') & (table.number == Null)), CharLength(table.number), table.number] @classmethod def default_company(cls): return Transaction().context.get('company') @classmethod def default_state(cls): return 'draft' @classmethod def default_revision(cls): return 1 @classmethod def default_warehouse(cls): Location = Pool().get('stock.location') return Location.get_default_warehouse() @classmethod def set_number(cls, quotations): pool = Pool() Config = pool.get('purchase.configuration') config = Config(1) for company, c_quotations in groupby( quotations, key=lambda q: q.company): missing_number = [] for quotation in c_quotations: if quotation.number: quotation.revision += 1 else: missing_number.append(quotation) if missing_number: sequence = config.get_multivalue( 'purchase_request_quotation_sequence', company=company.id) for quotation, number in zip( missing_number, sequence.get_many(len(missing_number))): quotation.number = number cls.save(quotations) @fields.depends('supplier') def on_change_supplier(self): self.supplier_address = None if self.supplier: self.supplier_address = self.supplier.address_get() def chat_language(self, audience='internal'): language = super().chat_language(audience=audience) if audience == 'public': language = self.supplier.lang.code if self.supplier.lang else None return language @classmethod def copy(cls, groups, default=None): if default is None: default = {} else: default = default.copy() default.setdefault('number', None) default.setdefault('reference') default.setdefault('revision', cls.default_revision()) return super().copy(groups, default=default) @property def delivery_full_address(self): if self.warehouse and self.warehouse.address: return self.warehouse.address.full_address return '' @classmethod @ModelView.button @Workflow.transition('cancelled') def cancel(cls, quotations): pass @classmethod @ModelView.button @Workflow.transition('draft') def draft(cls, quotations): pass @classmethod @ModelView.button @Workflow.transition('sent') def send(cls, quotations): cls.set_number(quotations) @classmethod @ModelView.button @process_request @Workflow.transition('received') def receive(cls, quotations): pass @classmethod @ModelView.button @process_request @Workflow.transition('rejected') def reject(cls, quotations): pass class QuotationLine(ModelSQL, ModelView): __name__ = 'purchase.request.quotation.line' supplier = fields.Function(fields.Many2One('party.party', 'Supplier'), 'get_supplier') supply_date = fields.Date('Supply Date', help="When it should be delivered.") product = fields.Function(fields.Many2One('product.product', 'Product'), 'get_product', searcher='search_product') description = fields.Text('Description', states={ 'required': ~Eval('product') }) quantity = fields.Float("Quantity", digits='unit', required=True) unit = fields.Many2One( 'product.uom', 'Unit', ondelete='RESTRICT', states={ 'required': Bool(Eval('product')), }, domain=[ If(Bool(Eval('product_uom_category')), ('category', '=', Eval('product_uom_category')), ('category', '!=', -1)), ]) product_uom_category = fields.Function( fields.Many2One( 'product.uom.category', "Product UoM Category", help="The category of Unit of Measure for the product."), 'on_change_with_product_uom_category') unit_price = Monetary( "Unit Price", currency='currency', digits=price_digits) currency = fields.Many2One('currency.currency', 'Currency', states={ 'required': Bool(Eval('unit_price')), }) request = fields.Many2One( 'purchase.request', "Request", ondelete='CASCADE', required=True, domain=[ If(Eval('quotation_state') == 'draft', ('state', 'in', ['draft', 'quotation', 'received']), (), ), ], states={ 'readonly': Eval('quotation_state') != 'draft' }, help="The request which this line belongs to.") quotation = fields.Many2One('purchase.request.quotation', 'Quotation', ondelete='CASCADE', required=True, domain=[ ('supplier', '=', Eval('supplier', -1)), ]) quotation_state = fields.Function(fields.Selection( 'get_quotation_state', 'Quotation State'), 'on_change_with_quotation_state', searcher='search_quotation_state') @classmethod def __setup__(cls): super().__setup__() cls.__access__.add('quotation') @staticmethod def order_quotation_state(tables): pool = Pool() Quotation = pool.get('purchase.request.quotation') table, _ = tables[None] if 'quotation' not in tables: quotation = Quotation.__table__() tables['quotation'] = { None: (quotation, table.quotation == quotation.id), } else: quotation, _ = tables['quotation'][None] return [Case((quotation.state == 'received', 0), else_=1), quotation.state] def get_supplier(self, name): if self.quotation and self.quotation.supplier: return self.quotation.supplier.id @fields.depends('request', '_parent_request.product', '_parent_request.description', '_parent_request.quantity', '_parent_request.unit', '_parent_request.company', '_parent_request.supply_date') def on_change_request(self): if self.request: self.product = self.request.product self.description = self.request.description self.quantity = self.request.quantity self.unit = self.request.unit if self.request.company: self.currency = self.request.company.currency self.supply_date = self.request.supply_date or datetime.date.max @fields.depends('product') def on_change_with_product_uom_category(self, name=None): return self.product.default_uom_category if self.product else None @classmethod def get_quotation_state(cls): pool = Pool() Quotation = pool.get('purchase.request.quotation') return (Quotation.fields_get( ['state'])['state']['selection']) @fields.depends('quotation', '_parent_quotation.state') def on_change_with_quotation_state(self, name=None): pool = Pool() Quotation = pool.get('purchase.request.quotation') if self.quotation: return self.quotation.state return Quotation.default_state() @classmethod def search_quotation_state(cls, name, clause): return [('quotation.state',) + tuple(clause[1:])] def get_rec_name(self, name): return '%s - %s' % (self.quotation.rec_name, self.supplier.rec_name) @classmethod def search_rec_name(cls, name, clause): domain = [] _, operator, value = clause if value is not None: names = clause[2].split(' - ', 1) domain.append(('quotation', operator, names[0])) if len(names) != 1 and names[1]: domain.append(('supplier', operator, names[1])) if operator.startswith('!') or operator.startswith('not'): domain.insert(0, 'OR') elif not operator.startswith('!') and not operator.startswith('not'): domain.append(('id', '<', 0)) return domain @classmethod def on_delete(cls, lines): pool = Pool() Request = pool.get('purchase.request') callback = super().on_delete(lines) requests = {l.request for l in lines} if requests: requests = Request.browse(requests) callback.append(lambda: Request.update_state(requests)) return callback def get_product(self, name): if self.request and self.request.product: return self.request.product.id @classmethod def search_product(cls, name, clause): return [('request.' + clause[0],) + tuple(clause[1:])] class PurchaseRequestQuotationReport(CompanyReport): __name__ = 'purchase.request.quotation' @classmethod def execute(cls, ids, data): with Transaction().set_context(address_with_party=True): return super( PurchaseRequestQuotationReport, cls).execute(ids, data) @classmethod def get_context(cls, records, header, data): pool = Pool() Date = pool.get('ir.date') context = super().get_context(records, header, data) company = header.get('company') with Transaction().set_context( company=company.id if company else None): context['today'] = Date.today() return context class CreatePurchaseRequestQuotationAskSuppliers(ModelView): __name__ = 'purchase.request.quotation.create.ask_suppliers' suppliers = fields.Many2Many('party.party', None, None, 'Suppliers', required=True) class CreatePurchaseRequestQuotationSucceed(ModelView): __name__ = 'purchase.request.quotation.create.succeed' number_quotations = fields.Integer('Number of Created Quotations', readonly=True) class CreatePurchaseRequestQuotation(Wizard): __name__ = 'purchase.request.quotation.create' start = StateTransition() ask_suppliers = StateView( 'purchase.request.quotation.create.ask_suppliers', 'purchase_request_quotation.' 'purchase_request_quotation_create_ask_suppliers', [ Button('Cancel', 'end', 'tryton-cancel'), Button('Process', 'create_quotations', 'tryton-ok', default=True), ]) create_quotations = StateAction( 'purchase_request_quotation.act_purchase_request_quotation_form') def transition_start(self): pool = Pool() Warning = pool.get('res.user.warning') reqs = [ r for r in self.records if r.state in {'draft', 'quotation', 'received'}] if reqs: reqs = [r for r in reqs if r.state in {'quotation', 'received'}] if reqs: warning_key = Warning.format( 'purchase_request_quotation_create', reqs) if Warning.check(warning_key): names = ', '.join(r.rec_name for r in reqs[:5]) if len(reqs) > 5: names += '...' raise PreviousQuotation( warning_key, gettext('purchase_request_quotation' '.msg_previous_quotation', requests=names)) return 'ask_suppliers' return 'end' def default_ask_suppliers(self, fields): reqs = [ r for r in self.records if r.party and r.state in {'draft', 'quotation', 'received'}] return { 'suppliers': [r.party.id for r in reqs], } def default_succeed(self, fields): return { 'number_quotations': self.succeed.number_quotations, } def filter_request(self, request, supplier): return request def _group_request_key(self, request): return (('company', request.company),) def do_create_quotations(self, action): pool = Pool() Quotation = pool.get('purchase.request.quotation') QuotationLine = pool.get('purchase.request.quotation.line') quotations = [] lines = [] requests = [ r for r in self.records if r.state in {'draft', 'quotation', 'received'}] for supplier in self.ask_suppliers.suppliers: sub_requests = [ r for r in requests if self.filter_request(r, supplier)] sub_requests = sorted( sub_requests, key=sortable_values(self._group_request_key)) for key, grouped_requests in groupby( sub_requests, key=self._group_request_key): quotation = self.get_quotation(supplier, key) for request in grouped_requests: line = self.get_quotation_line(request, quotation) line.quotation = quotation lines.append(line) quotations.append(quotation) Quotation.save(quotations) QuotationLine.save(lines) self.model.update_state(requests) action['domain'] = [] if len(quotations) == 1: action['views'].reverse() return action, { 'res_id': list(map(int, quotations)) } def get_quotation(self, supplier, key): pool = Pool() Quotation = pool.get('purchase.request.quotation') quotation = Quotation() quotation.supplier = supplier quotation.supplier_address = supplier.address_get() for f, v in key: setattr(quotation, f, v) return quotation def get_quotation_line(self, request, quotation): pool = Pool() QuotationLine = pool.get('purchase.request.quotation.line') quotation_line = QuotationLine() quotation_line.request = request quotation_line.description = request.description quotation_line.quantity = request.quantity quotation_line.unit = request.unit quotation_line.currency = request.currency quotation_line.supply_date = request.supply_date or datetime.date.max return quotation_line class PurchaseRequest(metaclass=PoolMeta): __name__ = 'purchase.request' quotation_lines = fields.One2Many( 'purchase.request.quotation.line', 'request', 'Quotation Lines', ) quotation_lines_active = fields.One2Many( 'purchase.request.quotation.line', 'request', 'Active Quotation Lines', filter=[('quotation.state', 'in', ['draft', 'sent', 'received'])], order=[('quotation_state', 'ASC'), ('unit_price', 'ASC')]) best_quotation_line = fields.Function(fields.Many2One( 'purchase.request.quotation.line', 'Best Quotation Line'), 'get_best_quotation') preferred_quotation_line = fields.Many2One( 'purchase.request.quotation.line', 'Preferred Quotation Line', domain=[ ('quotation_state', '=', 'received'), ('request', '=', Eval('id', -1)) ], help="The quotation that will be chosen to create the purchase\n" "otherwise first ordered received quotation line will be selected.") @property def currency(self): currency = super().currency if self.best_quotation_line: return self.best_quotation_line.currency return currency def get_best_quotation(self, name): if self.preferred_quotation_line: return self.preferred_quotation_line else: for line in self.quotation_lines_active: if line.quotation_state == 'received': return line return None @classmethod def __setup__(cls): super().__setup__() selection = [('quotation', 'Quotation'), ('received', 'Received')] for s in selection: if s not in cls.state.selection: cls.state.selection.append(s) cls._buttons.update({ 'create_quotation': { 'invisible': ~Eval('state').in_( ['draft', 'quotation', 'received']), 'depends': ['state'], }, }) def get_state(self): state = super().get_state() if state == 'draft' and self.quotation_lines: state = 'quotation' if any(l.quotation_state == 'received' for l in self.quotation_lines): state = 'received' return state @classmethod @ModelView.button_action( 'purchase_request_quotation.wizard_create_quotation') def create_quotation(cls, requests): pass class CreatePurchase(Wizard): __name__ = 'purchase.request.create_purchase' init = StateTransition() @classmethod def __setup__(cls): super().__setup__() def transition_start(self): to_save = [] reqs = [r for r in self.records if not r.purchase_line and r.quotation_lines] to_save = [] for req in reqs: if req.best_quotation_line: to_save.append(self.apply_quotation(req)) if to_save: self.model.save(to_save) state = super().transition_start() return state def apply_quotation(self, request): request.party = request.best_quotation_line.supplier.id request.description = request.best_quotation_line.description request.quantity = request.best_quotation_line.quantity if not request.preferred_quotation_line: request.preferred_quotation_line = request.best_quotation_line return request @classmethod def compute_purchase_line(cls, key, requests, purchase): line = super().compute_purchase_line(key, requests, purchase) try: line.unit_price = min(req.best_quotation_line.unit_price for req in requests if req.best_quotation_line) except ValueError: pass return line