Files
tryton/modules/purchase_requisition/purchase.py
T
Cédric Krier 0504a0e766 Ensure the contextual company is not None and positive before using it
Closes #13868
(grafted from 0ea68af90f3ef7059c7c9c7a33d13fa216e23bc4)

--HG--
branch : 7.0
2025-03-10 23:43:49 +01:00

736 lines
25 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.
from decimal import Decimal
from itertools import chain
from sql.functions import CharLength
from trytond.i18n import gettext
from trytond.model import (
Index, ModelSQL, ModelView, Workflow, fields, sequence_ordered)
from trytond.model.exceptions import AccessError, RequiredValidationError
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 Bool, Eval, Id, If
from trytond.tools import firstline, grouped_slice
from trytond.transaction import Transaction
from trytond.wizard import Wizard
class Configuration(metaclass=PoolMeta):
__name__ = 'purchase.configuration'
purchase_requisition_sequence = fields.MultiValue(fields.Many2One(
'ir.sequence', "Purchase Requisition Sequence", required=True,
domain=[
('company', 'in',
[Eval('context', {}).get('company', -1), None]),
('sequence_type', '=',
Id('purchase_requisition',
'sequence_type_purchase_requisition')),
]))
@classmethod
def multivalue_model(cls, field):
pool = Pool()
if field == 'purchase_requisition_sequence':
return pool.get('purchase.configuration.sequence')
return super(Configuration, cls).multivalue_model(field)
@classmethod
def default_purchase_requisition_sequence(cls, **pattern):
return cls.multivalue_model('purchase_requisition_sequence'
).default_purchase_requisition_sequence()
class ConfigurationSequence(metaclass=PoolMeta):
__name__ = 'purchase.configuration.sequence'
purchase_requisition_sequence = fields.Many2One(
'ir.sequence', "Purchase Requisition Sequence", required=True,
domain=[
('company', 'in', [Eval('company', -1), None]),
('sequence_type', '=',
Id('purchase_requisition',
'sequence_type_purchase_requisition')),
])
@classmethod
def default_purchase_requisition_sequence(cls):
pool = Pool()
ModelData = pool.get('ir.model.data')
try:
return ModelData.get_id(
'purchase_requisition', 'sequence_purchase_requisition')
except KeyError:
return None
class PurchaseRequisition(Workflow, ModelSQL, ModelView):
"Purchase Requisition"
__name__ = 'purchase.requisition'
_rec_name = 'number'
_states = {
'readonly': Eval('state') != 'draft',
}
company = fields.Many2One(
'company.company', "Company", required=True,
states={
'readonly': (Eval('state') != 'draft') | Eval('lines', [0]),
})
number = fields.Char('Number', readonly=True)
description = fields.Char('Description', states=_states)
employee = fields.Many2One(
'company.employee', 'Employee', required=True, states=_states)
supply_date = fields.Date(
'Supply Date',
states={
'required': ~Eval('state').in_(['draft', 'cancelled']),
'readonly': _states['readonly'],
})
warehouse = fields.Many2One(
'stock.location', 'Warehouse',
domain=[
('type', '=', 'warehouse'),
],
states=_states)
currency = fields.Many2One(
'currency.currency', 'Currency',
states={
'readonly': (_states['readonly']
| (Eval('lines', [0]) & Eval('currency'))),
})
total_amount = fields.Function(
Monetary("Total", currency='currency', digits='currency'),
'get_amount')
total_amount_cache = Monetary(
"Total Cache", currency='currency', digits='currency')
lines = fields.One2Many(
'purchase.requisition.line', 'requisition', 'Lines',
states=_states)
approved_by = employee_field(
"Approved By", states=['approved', 'processing', 'done', 'cancelled'])
rejected_by = employee_field(
"Rejected By", states=['rejected', 'processing', 'done', 'cancelled'])
state = fields.Selection([
('draft', "Draft"),
('waiting', "Waiting"),
('rejected', "Rejected"),
('approved', "Approved"),
('processing', "Processing"),
('done', "Done"),
('cancelled', "Cancelled"),
], "State", readonly=True, required=True, sort=False)
del _states
@classmethod
def __setup__(cls):
cls.number.search_unaccented = False
super(PurchaseRequisition, cls).__setup__()
t = cls.__table__()
cls._sql_indexes.add(
Index(
t,
(t.state, Index.Equality()),
where=t.state.in_([
'draft', 'waiting', 'approved', 'processing'])))
cls._transitions |= set((
('cancelled', 'draft'),
('rejected', 'draft'),
('draft', 'cancelled'),
('draft', 'waiting'),
('waiting', 'draft'),
('waiting', 'rejected'),
('waiting', 'approved'),
('approved', 'processing'),
('approved', 'draft'),
('processing', 'done'),
('done', 'processing'),
))
cls._buttons.update({
'cancel': {
'invisible': Eval('state') != 'draft',
'depends': ['state'],
},
'draft': {
'invisible': ~Eval('state').in_(
['cancelled', 'waiting', 'approved', 'rejected']),
'icon': If(Eval('state').in_(['cancelled', 'rejected']),
'tryton-undo',
'tryton-back'),
'depends': ['state'],
},
'wait': {
'pre_validate': [('supply_date', '!=', None)],
'invisible': ((Eval('state') != 'draft')
| ~Eval('lines', [])),
'readonly': ~Eval('lines', []),
'depends': ['state'],
},
'approve': {
'invisible': Eval('state') != 'waiting',
'depends': ['state'],
},
'process': {
'invisible': ~Eval('state').in_(
['approved', 'processing']),
'icon': If(Eval('state') == 'approved',
'tryton-forward', 'tryton-refresh'),
'depends': ['state'],
},
'reject': {
'invisible': Eval('state') != 'waiting',
'depends': ['state'],
},
})
# The states where amounts are cached
cls._states_cached = ['approved', 'done', 'rejected',
'processing', 'cancelled']
@classmethod
def __register__(cls, module_name):
cursor = Transaction().connection.cursor()
table = cls.__table__()
super().__register__(module_name)
# Migration from 5.6: rename state cancel to cancelled
cursor.execute(*table.update(
[table.state], ['cancelled'],
where=table.state == 'cancel'))
@classmethod
def order_number(cls, tables):
table, _ = tables[None]
return [CharLength(table.number), table.number]
@classmethod
def default_state(cls):
return 'draft'
@classmethod
def default_company(cls):
return Transaction().context.get('company')
@classmethod
def default_employee(cls):
return Transaction().context.get('employee')
@classmethod
def default_warehouse(cls):
Location = Pool().get('stock.location')
return Location.get_default_warehouse()
@classmethod
def default_currency(cls):
Company = Pool().get('company.company')
company = Transaction().context.get('company')
if company is not None and company >= 0:
return Company(company).currency.id
@fields.depends('lines', 'currency')
def on_change_with_total_amount(self):
self.total_amount = Decimal(0)
if self.lines:
for line in self.lines:
self.total_amount += getattr(line, 'amount', None) or 0
if self.currency:
self.total_amount = self.currency.round(self.total_amount)
return self.total_amount
@classmethod
def store_cache(cls, requisitions):
for requisition in requisitions:
requisition.total_amount_cache = requisition.total_amount
cls.save(requisitions)
@classmethod
def get_amount(cls, requisitions, name):
total_amount = {}
# Sort cached first and re-instantiate to optimize cache management
requisitions = sorted(requisitions,
key=lambda r: r.state in cls._states_cached, reverse=True)
requisitions = cls.browse(requisitions)
for requisition in requisitions:
if (requisition.state in cls._states_cached
and requisition.total_amount_cache is not None):
total_amount[requisition.id] = requisition.total_amount_cache
else:
total_amount[requisition.id] = (
requisition.on_change_with_total_amount())
return total_amount
@classmethod
def create_requests(cls, requisitions):
pool = Pool()
Request = pool.get('purchase.request')
requests = []
for requisition in requisitions:
for line in requisition.lines:
request = line.compute_request()
if request:
requests.append(request)
if requests:
Request.save(requests)
@classmethod
def view_attributes(cls):
return super().view_attributes() + [
('/tree', 'visual',
If(Eval('state') == 'cancelled', 'muted', '')),
]
@classmethod
def create(cls, vlist):
pool = Pool()
Config = pool.get('purchase.configuration')
config = Config(1)
vlist = [v.copy() for v in vlist]
default_company = cls.default_company()
for values in vlist:
if values.get('number') is None:
values['number'] = config.get_multivalue(
'purchase_requisition_sequence',
company=values.get('company', default_company)).get()
return super(PurchaseRequisition, cls).create(vlist)
@classmethod
def delete(cls, requisitions):
# Cancel before delete
cls.cancel(requisitions)
for requisition in requisitions:
if requisition.state != 'cancelled':
raise AccessError(
gettext('purchase_requisition.msg_delete_cancel',
requisition=requisition.rec_name))
super(PurchaseRequisition, cls).delete(requisitions)
def check_for_waiting(self):
if not self.warehouse:
for line in self.lines:
if line.product and line.product.type in {'goods', 'assets'}:
raise RequiredValidationError(
gettext('purchase_requisition.msg_warehouse_required',
requisition=self.rec_name))
@classmethod
def copy(cls, requisitions, default=None):
if default is None:
default = {}
else:
default = default.copy()
default.setdefault('number', None)
default.setdefault('supply_date', None)
default.setdefault('approved_by')
default.setdefault('rejected_by')
default.setdefault('total_amount_cache')
return super(PurchaseRequisition, cls).copy(
requisitions, default=default)
@classmethod
@ModelView.button
@Workflow.transition('cancelled')
def cancel(cls, requisitions):
cls.store_cache(requisitions)
@classmethod
@ModelView.button
@Workflow.transition('draft')
@reset_employee('approved_by', 'rejected_by')
def draft(cls, requisitions):
cls.write(requisitions, {
'total_amount_cache': None,
})
@classmethod
@ModelView.button
@Workflow.transition('waiting')
def wait(cls, requisitions):
for requisition in requisitions:
requisition.check_for_waiting()
@classmethod
@ModelView.button
@Workflow.transition('rejected')
@set_employee('rejected_by')
def reject(cls, requisitions):
pass
@classmethod
@ModelView.button
@Workflow.transition('approved')
@set_employee('approved_by')
def approve(cls, requisitions):
pool = Pool()
Configuration = pool.get('purchase.configuration')
transaction = Transaction()
context = transaction.context
cls.store_cache(requisitions)
config = Configuration(1)
with transaction.set_context(
queue_scheduled_at=config.purchase_process_after,
queue_batch=context.get('queue_batch', True)):
cls.__queue__.process(requisitions)
@classmethod
@Workflow.transition('processing')
def proceed(cls, requisitions):
pass
@classmethod
@Workflow.transition('done')
def do(cls, requisitions):
pass
@classmethod
@ModelView.button
def process(cls, requisitions):
done = []
process = []
requisitions = [r for r in requisitions
if r.state in {'approved', 'processing', 'done'}]
cls.create_requests(requisitions)
for requisition in requisitions:
if requisition.is_done():
if requisition.state != 'done':
done.append(requisition)
elif requisition.state != 'processing':
process.append(requisition)
if process:
cls.proceed(process)
if done:
cls.do(done)
def is_done(self):
return all(
r.purchase and r.purchase.state in {'cancelled', 'confirmed'}
for l in self.lines for r in l.purchase_requests)
class PurchaseRequisitionLine(sequence_ordered(), ModelSQL, ModelView):
"Purchase Requisition Line"
__name__ = 'purchase.requisition.line'
_states = {
'readonly': Eval('purchase_requisition_state') != 'draft',
}
requisition = fields.Many2One(
'purchase.requisition', 'Requisition',
ondelete='CASCADE', required=True)
supplier = fields.Many2One('party.party', 'Supplier', states=_states)
product = fields.Many2One(
'product.product', 'Product',
ondelete='RESTRICT',
domain=[
If((Eval('purchase_requisition_state') == 'draft')
& ~(Eval('quantity', 0) < 0),
('purchasable', '=', True),
()),
],
states=_states)
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')
description = fields.Text("Description", states=_states)
summary = fields.Function(fields.Char('Summary'), 'on_change_with_summary')
quantity = fields.Float(
"Quantity", digits='unit', required=True, states=_states)
unit = fields.Many2One(
'product.uom', 'Unit', ondelete='RESTRICT',
states={
'required': Bool(Eval('product')),
'readonly': _states['readonly'],
})
unit_price = Monetary(
'Unit Price', currency='currency', digits=price_digits, states=_states)
currency = fields.Function(fields.Many2One('currency.currency',
'Currency'), 'on_change_with_currency')
amount = fields.Function(Monetary(
"Amount", currency='currency', digits='currency'),
'on_change_with_amount')
purchase_requests = fields.One2Many(
'purchase.request', 'origin', 'Purchase Request', readonly=True)
purchase_requisition_state = fields.Function(fields.Selection(
'get_purchase_requisition_states', "Purchase Requisition State"),
'on_change_with_purchase_requisition_state')
del _states
@classmethod
def __setup__(cls):
super().__setup__()
cls.__access__.add('requisition')
unit_categories = cls._unit_categories()
cls.unit.domain = [
If(Bool(Eval('product_uom_category')),
('category', 'in', [Eval(c) for c in unit_categories]),
('category', '!=', -1)),
]
@classmethod
def _unit_categories(cls):
return ['product_uom_category']
@fields.depends('product')
def on_change_with_product_uom_category(self, name=None):
return self.product.default_uom_category if self.product else None
@fields.depends('requisition', '_parent_requisition.currency')
def on_change_with_currency(self, name=None):
return self.requisition.currency if self.requisition else None
@classmethod
def get_purchase_requisition_states(cls):
pool = Pool()
Requisition = pool.get('purchase.requisition')
return Requisition.fields_get(['state'])['state']['selection']
@fields.depends('requisition', '_parent_requisition.state')
def on_change_with_purchase_requisition_state(self, name=None):
if self.requisition:
return self.requisition.state
@fields.depends('product', 'unit', 'quantity', 'supplier')
def on_change_product(self):
if not self.product:
return
category = self.product.purchase_uom.category
if not self.unit or self.unit.category != category:
self.unit = self.product.purchase_uom
@fields.depends('description')
def on_change_with_summary(self, name=None):
return firstline(self.description or '')
@fields.depends(
'quantity', 'unit_price',
'requisition', '_parent_requisition.currency')
def on_change_with_amount(self, name=None):
if (self.unit_price is None) or (self.quantity is None):
return None
amount = Decimal(str(self.quantity)) * self.unit_price
if self.requisition.currency:
amount = self.requisition.currency.round(amount)
return amount
def get_rec_name(self, name):
pool = Pool()
Lang = pool.get('ir.lang')
if self.product:
lang = Lang.get()
return (lang.format_number_symbol(
self.quantity or 0, self.unit, digits=self.unit.digits)
+ ' %s @ %s' % (
self.product.rec_name, self.requisition.rec_name))
else:
return self.requisition.rec_name
def _get_purchase_request_product_supplier_pattern(self):
pattern = {
'company': self.requisition.company.id,
}
if self.supplier:
pattern['party'] = self.supplier.id
return pattern
@property
def request_unit(self):
unit = self.unit
if (self.product
and self.product.purchase_uom.category == self.unit.category):
unit = self.product.purchase_uom
return unit
@property
def request_quantity(self):
pool = Pool()
Uom = pool.get('product.uom')
quantity = self.quantity
request_unit = self.request_unit
if (self.product
and request_unit
and request_unit.category == self.unit.category):
quantity = Uom.compute_qty(
self.unit, self.quantity, request_unit, round=True)
return quantity
@property
def request_unit_price(self):
return self.unit_price
def compute_request(self):
"""
Return the value of the purchase request which will answer to
the needed quantity at the given date.
"""
pool = Pool()
Uom = pool.get('product.uom')
Request = pool.get('purchase.request')
if self.purchase_requests:
return
supply_date = self.requisition.supply_date
supplier = None
purchase_date = None
if self.product:
supplier, purchase_date = Request.find_best_supplier(
self.product, supply_date,
**self._get_purchase_request_product_supplier_pattern())
elif self.supplier:
lead_time = self.supplier.get_multivalue(
'supplier_lead_time', company=self.requisition.company.id)
if lead_time is not None:
purchase_date = supply_date - lead_time
unit = self.request_unit
quantity = self.request_quantity
if (self.product
and self.product.purchase_uom.category == self.unit.category):
unit = self.product.purchase_uom
quantity = Uom.compute_qty(
self.unit, self.quantity, unit, round=True)
return Request(
product=self.product,
description=self.description,
party=supplier or self.supplier,
quantity=quantity,
unit=unit,
computed_quantity=self.quantity,
computed_unit=self.unit,
purchase_date=purchase_date,
supply_date=supply_date,
company=self.requisition.company,
warehouse=self.requisition.warehouse,
origin=self,
)
@classmethod
def copy(cls, lines, default=None):
if default is None:
default = {}
else:
default = default.copy()
default.setdefault('purchase_requests')
return super(PurchaseRequisitionLine, cls).copy(lines, default=default)
class PurchaseRequest(metaclass=PoolMeta):
__name__ = 'purchase.request'
@classmethod
def _get_origin(cls):
return (super(PurchaseRequest, cls)._get_origin()
| {'purchase.requisition.line'})
@property
def currency(self):
pool = Pool()
RequisitionLine = pool.get('purchase.requisition.line')
currency = super(PurchaseRequest, self).currency
if (isinstance(self.origin, RequisitionLine)
and self.origin.requisition.currency):
return self.origin.requisition.currency
return currency
class HandlePurchaseCancellationException(metaclass=PoolMeta):
__name__ = 'purchase.request.handle.purchase.cancellation'
def transition_reset(self):
pool = Pool()
Requisition = pool.get('purchase.requisition')
RequisitionLine = pool.get('purchase.requisition.line')
state = super(
HandlePurchaseCancellationException, self).transition_reset()
requests = self.records
requisition_ids = list({r.origin.requisition.id for r in requests
if isinstance(r.origin, RequisitionLine)})
Requisition.process(Requisition.browse(requisition_ids))
return state
class CreatePurchase(Wizard):
'Create Purchase'
__name__ = 'purchase.request.create_purchase'
def _group_purchase_line_key(self, request):
pool = Pool()
RequisitionLine = pool.get('purchase.requisition.line')
key = super(CreatePurchase, self)._group_purchase_line_key(request)
if isinstance(request.origin, RequisitionLine):
unit_price = request.origin.request_unit_price
if unit_price:
key += (('unit_price', unit_price),)
return key
@classmethod
def compute_purchase_line(cls, key, requests, purchase):
pool = Pool()
RequisitionLine = pool.get('purchase.requisition.line')
Uom = pool.get('product.uom')
line = super().compute_purchase_line(key, requests, purchase)
key_values = dict(key)
if (key_values.get('unit_price') is not None
and any(
isinstance(r.origin, RequisitionLine) for r in requests)):
line.unit_price = round_price(
Uom.compute_price(
key_values.get('unit', line.unit),
key_values['unit_price'],
line.unit))
return line
class Purchase(metaclass=PoolMeta):
__name__ = 'purchase.purchase'
@classmethod
def _process_requisition(cls, purchases):
pool = Pool()
Request = pool.get('purchase.request')
Requisition = pool.get('purchase.requisition')
requests = []
for sub_purchases in grouped_slice(purchases):
requests.append(Request.search([
('purchase_line.purchase.id', 'in',
[x.id for x in sub_purchases]),
('origin', 'like', 'purchase.requisition.line,%'),
]))
requests = list(chain(*requests))
if requests:
requisition_ids = list(set(req.origin.requisition
for req in requests))
Requisition.process(Requisition.browse(requisition_ids))
@classmethod
@ModelView.button
@Workflow.transition('confirmed')
def confirm(cls, purchases):
super(Purchase, cls).confirm(purchases)
cls._process_requisition(purchases)
@classmethod
@ModelView.button
@Workflow.transition('cancelled')
def cancel(cls, purchases):
super(Purchase, cls).cancel(purchases)
cls._process_requisition(purchases)