Files
2026-03-14 09:42:12 +00:00

701 lines
24 KiB
Python

# 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