648 lines
22 KiB
Python
648 lines
22 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 trytond import backend
|
|
from trytond.pool import PoolMeta, Pool
|
|
from trytond.pyson import Eval, Bool, If
|
|
from trytond.model import Workflow, ModelView, fields, ModelSQL, \
|
|
sequence_ordered
|
|
from trytond.wizard import Wizard
|
|
from trytond.transaction import Transaction
|
|
from trytond.tools import grouped_slice
|
|
|
|
from trytond.modules.product import price_digits
|
|
|
|
__all__ = ['Configuration', 'ConfigurationSequence',
|
|
'PurchaseRequisition', 'PurchaseRequisitionLine',
|
|
'PurchaseRequest', 'HandlePurchaseCancellationException',
|
|
'CreatePurchase', 'Purchase']
|
|
|
|
|
|
STATES = [
|
|
('draft', 'Draft'),
|
|
('waiting', 'Waiting'),
|
|
('rejected', 'Rejected'),
|
|
('approved', 'Approved'),
|
|
('processing', 'Processing'),
|
|
('done', 'Done'),
|
|
('cancel', 'Canceled'),
|
|
]
|
|
|
|
|
|
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]),
|
|
('code', '=', '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]),
|
|
('code', '=', 'purchase.requisition'),
|
|
],
|
|
depends=['company'])
|
|
|
|
@classmethod
|
|
def __register__(cls, module_name):
|
|
TableHandler = backend.get('TableHandler')
|
|
exist = TableHandler.table_exist(cls._table)
|
|
if exist:
|
|
table = TableHandler(cls, module_name)
|
|
exist &= table.column_exist('purchase_requisition_sequence')
|
|
|
|
super(ConfigurationSequence, cls).__register__(module_name)
|
|
|
|
if not exist:
|
|
cls._migrate_property([], [], [])
|
|
|
|
@classmethod
|
|
def _migrate_property(cls, field_names, value_names, fields):
|
|
field_names.append('purchase_requisition_sequence')
|
|
value_names.append('purchase_requisition_sequence')
|
|
super(ConfigurationSequence, cls)._migrate_property(
|
|
field_names, value_names, fields)
|
|
|
|
@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',
|
|
}
|
|
_depends = ['state']
|
|
|
|
company = fields.Many2One(
|
|
'company.company', 'Company', required=True,
|
|
states={
|
|
'readonly': (Eval('state') != 'draft') | Eval('lines', [0]),
|
|
},
|
|
domain=[
|
|
('id', If(Eval('context', {}).contains('company'), '=', '!='),
|
|
Eval('context', {}).get('company', -1)),
|
|
],
|
|
depends=['lines'] + _depends, select=True)
|
|
number = fields.Char('Number', readonly=True, select=True)
|
|
description = fields.Char(
|
|
'Description', states=_states, depends=_depends)
|
|
employee = fields.Many2One(
|
|
'company.employee', 'Employee', required=True,
|
|
states=_states, depends=_depends)
|
|
supply_date = fields.Date(
|
|
'Supply Date',
|
|
states={
|
|
'required': Eval('state') != 'draft',
|
|
'readonly': _states['readonly'],
|
|
},
|
|
depends=_depends)
|
|
warehouse = fields.Many2One(
|
|
'stock.location', 'Warehouse',
|
|
domain=[
|
|
('type', '=', 'warehouse'),
|
|
],
|
|
states=_states, depends=_depends)
|
|
currency = fields.Many2One(
|
|
'currency.currency', 'Currency',
|
|
states={
|
|
'readonly': (_states['readonly']
|
|
| (Eval('lines', [0]) & Eval('currency'))),
|
|
},
|
|
depends=_depends)
|
|
currency_digits = fields.Function(
|
|
fields.Integer('Currency Digits'), 'on_change_with_currency_digits')
|
|
total_amount = fields.Function(
|
|
fields.Numeric('Total', digits=(16, Eval('currency_digits', 2)),
|
|
depends=['currency_digits']),
|
|
'get_amount')
|
|
total_amount_cache = fields.Numeric(
|
|
'Total Cache', digits=(16, Eval('currency_digits', 2)),
|
|
depends=['currency_digits'])
|
|
lines = fields.One2Many(
|
|
'purchase.requisition.line', 'requisition', 'Lines',
|
|
states=_states, depends=_depends)
|
|
state = fields.Selection(STATES, 'State', readonly=True, required=True)
|
|
|
|
#del _states
|
|
|
|
|
|
@classmethod
|
|
def __setup__(cls):
|
|
super(PurchaseRequisition, cls).__setup__()
|
|
cls._error_messages.update({
|
|
'warehouse_required': ('A warehouse must be defined for '
|
|
'requisition: "%(requisition)s".'),
|
|
'delete_cancel': ('Requisition "%s" must be cancelled '
|
|
'before deletion.'),
|
|
})
|
|
cls._transitions |= set((
|
|
('cancel', 'draft'),
|
|
('rejected', 'draft'),
|
|
('draft', 'cancel'),
|
|
('draft', 'waiting'),
|
|
('waiting', 'draft'),
|
|
('waiting', 'rejected'),
|
|
('waiting', 'approved'),
|
|
('approved', 'processing'),
|
|
('processing', 'done'),
|
|
('done', 'processing'),
|
|
))
|
|
cls._buttons.update({
|
|
'cancel': {
|
|
'invisible': Eval('state') != 'draft',
|
|
},
|
|
'draft': {
|
|
'invisible': ~Eval('state').in_(
|
|
['cancel', 'waiting', 'rejected']),
|
|
'icon': If(Eval('state').in_(['cancel', 'rejected']),
|
|
'tryton-clear',
|
|
'tryton-go-previous'),
|
|
},
|
|
'wait': {
|
|
'pre_validate': [('supply_date', '!=', None)],
|
|
'invisible': ((Eval('state') != 'draft')
|
|
| ~Eval('lines', [])),
|
|
'readonly': ~Eval('lines', []),
|
|
},
|
|
'approve': {
|
|
'invisible': Eval('state') != 'waiting',
|
|
},
|
|
'reject': {
|
|
'invisible': Eval('state') != 'waiting',
|
|
},
|
|
})
|
|
# The states where amounts are cached
|
|
cls._states_cached = ['approved', 'done', 'rejected',
|
|
'processing', 'cancel']
|
|
|
|
@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')
|
|
locations = Location.search(cls.warehouse.domain)
|
|
if len(locations) == 1:
|
|
return locations[0].id
|
|
|
|
@classmethod
|
|
def default_currency(cls):
|
|
Company = Pool().get('company.company')
|
|
company = Transaction().context.get('company')
|
|
if company:
|
|
return Company(company).currency.id
|
|
|
|
@classmethod
|
|
def default_currency_digits(cls):
|
|
Company = Pool().get('company.company')
|
|
company = Transaction().context.get('company')
|
|
if company:
|
|
company = Company(company)
|
|
return company.currency.digits
|
|
return 2
|
|
|
|
@fields.depends('currency')
|
|
def on_change_with_currency_digits(self, name=None):
|
|
if self.currency:
|
|
return self.currency.digits
|
|
return 2
|
|
|
|
@fields.depends('lines', 'currency')
|
|
def on_change_with_total_amount(self):
|
|
self.total_amount = Decimal('0.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):
|
|
to_write = []
|
|
for requisition in requisitions:
|
|
to_write.extend([requisition], {
|
|
'total_amount_cache': requisition.total_amount,
|
|
})
|
|
cls.write(*to_write)
|
|
|
|
@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(cls, vlist):
|
|
pool = Pool()
|
|
Sequence = pool.get('ir.sequence')
|
|
Config = pool.get('purchase.configuration')
|
|
|
|
config = Config(1)
|
|
vlist = [v.copy() for v in vlist]
|
|
for values in vlist:
|
|
if values.get('number') is None:
|
|
values['number'] = Sequence.get_id(
|
|
config.purchase_requisition_sequence.id)
|
|
return super(PurchaseRequisition, cls).create(vlist)
|
|
|
|
@classmethod
|
|
def delete(cls, requisitions):
|
|
# Cancel before delete
|
|
cls.cancel(requisitions)
|
|
for requisition in requisitions:
|
|
if requisition.state != 'cancel':
|
|
cls.raise_user_error('delete_cancel', 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'}:
|
|
self.raise_user_error('warehouse_required', {
|
|
'requisition': self.rec_name,
|
|
})
|
|
|
|
@classmethod
|
|
def copy(cls, requisitions, default=None):
|
|
if default is None:
|
|
default = {}
|
|
default = default.copy()
|
|
default['state'] = 'draft'
|
|
default['number'] = None
|
|
default.setdefault('supply_date', None)
|
|
return super(PurchaseRequisition, cls).copy(
|
|
requisitions, default=default)
|
|
|
|
@classmethod
|
|
@ModelView.button
|
|
@Workflow.transition('cancel')
|
|
def cancel(cls, requisitions):
|
|
pass
|
|
|
|
@classmethod
|
|
@ModelView.button
|
|
@Workflow.transition('draft')
|
|
def draft(cls, requisitions):
|
|
pass
|
|
|
|
@classmethod
|
|
@ModelView.button
|
|
@Workflow.transition('waiting')
|
|
def wait(cls, requisitions):
|
|
for requisition in requisitions:
|
|
requisition.check_for_waiting()
|
|
|
|
@classmethod
|
|
@ModelView.button
|
|
@Workflow.transition('rejected')
|
|
def reject(cls, requisitions):
|
|
pass
|
|
|
|
@classmethod
|
|
@ModelView.button
|
|
@Workflow.transition('approved')
|
|
def approve(cls, requisitions):
|
|
pool = Pool()
|
|
Request = pool.get('purchase.request')
|
|
new_requests = []
|
|
for requisition in requisitions:
|
|
for line in requisition.lines:
|
|
request = line.compute_request()
|
|
if request:
|
|
new_requests.append(request)
|
|
if new_requests:
|
|
Request.save(new_requests)
|
|
# Update the state to allow transition to processing
|
|
cls.write(requisitions, {
|
|
'state': 'approved',
|
|
})
|
|
cls.proceed(requisitions)
|
|
|
|
@classmethod
|
|
@Workflow.transition('processing')
|
|
def proceed(cls, requisitions):
|
|
pass
|
|
|
|
@classmethod
|
|
@Workflow.transition('done')
|
|
def do(cls, requisitions):
|
|
pass
|
|
|
|
@classmethod
|
|
def process(cls, requisitions):
|
|
done = []
|
|
process = []
|
|
for requisition in requisitions:
|
|
if requisition.state not in {'processing', 'done'}:
|
|
continue
|
|
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 {'cancel', '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',
|
|
}
|
|
_depends = ['purchase_requisition_state']
|
|
|
|
requisition = fields.Many2One(
|
|
'purchase.requisition', 'Requisition',
|
|
ondelete='CASCADE', select=True, required=True)
|
|
supplier = fields.Many2One(
|
|
'party.party', 'Supplier', states=_states, depends=_depends)
|
|
product = fields.Many2One(
|
|
'product.product', 'Product',
|
|
ondelete='RESTRICT',
|
|
domain=[
|
|
('purchasable', '=', True),
|
|
],
|
|
states=_states, depends=_depends)
|
|
description = fields.Text(
|
|
'Description',
|
|
states={
|
|
'readonly': _states['readonly'],
|
|
'required': ~Eval('product')
|
|
},
|
|
depends=['product'] + _depends)
|
|
quantity = fields.Float(
|
|
'Quantity', digits=(16, Eval('unit_digits', 2)), required=True,
|
|
states=_states, depends=['unit_digits'] + _depends)
|
|
unit = fields.Many2One(
|
|
'product.uom', 'Unit', ondelete='RESTRICT',
|
|
states={
|
|
'required': Bool(Eval('product')),
|
|
'readonly': _states['readonly'],
|
|
},
|
|
domain=[
|
|
If(Bool(Eval('product_uom_category')),
|
|
('category', '=', Eval('product_uom_category')),
|
|
('category', '!=', -1)),
|
|
],
|
|
depends=['product', 'product_uom_category'] + _depends)
|
|
unit_digits = fields.Function(
|
|
fields.Integer('Unit Digits'), 'on_change_with_unit_digits')
|
|
product_uom_category = fields.Function(
|
|
fields.Many2One('product.uom.category', 'Product Uom Category'),
|
|
'on_change_with_product_uom_category')
|
|
unit_price = fields.Numeric(
|
|
'Unit Price', digits=price_digits, states=_states, depends=_depends)
|
|
amount = fields.Function(
|
|
fields.Numeric('Amount',
|
|
digits=(
|
|
16, Eval('_parent_requisition', {}).get('currency_digits', 2)),
|
|
depends=_depends), 'on_change_with_amount')
|
|
purchase_requests = fields.One2Many(
|
|
'purchase.request', 'origin', 'Purchase Request', readonly=True)
|
|
purchase_requisition_state = fields.Function(
|
|
fields.Selection(STATES, 'Purchase Requisition State'),
|
|
'on_change_with_purchase_requisition_state')
|
|
|
|
del _states
|
|
|
|
@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', 'description', 'supplier')
|
|
def on_change_product(self):
|
|
pool = Pool()
|
|
Product = pool.get('product.product')
|
|
|
|
if not self.product:
|
|
return
|
|
|
|
context = {}
|
|
if self.supplier and self.supplier.lang:
|
|
context['language'] = self.supplier.lang.code
|
|
|
|
category = self.product.purchase_uom.category
|
|
if not self.unit or self.unit.category != category:
|
|
self.unit = self.product.purchase_uom
|
|
self.unit_digits = self.product.purchase_uom.digits
|
|
|
|
if not self.description:
|
|
with Transaction().set_context(context):
|
|
self.description = Product(self.product.id).rec_name
|
|
|
|
@fields.depends('quantity', 'unit_price', 'unit',
|
|
'_parent_requisition.currency', 'requisition',)
|
|
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
|
|
|
|
@fields.depends('product')
|
|
def on_change_with_product_uom_category(self, name=None):
|
|
if self.product:
|
|
return self.product.default_uom_category.id
|
|
|
|
@fields.depends('unit')
|
|
def on_change_with_unit_digits(self, name=None):
|
|
if self.unit:
|
|
return self.unit.digits
|
|
return 2
|
|
|
|
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
|
|
purchase_date = supply_date
|
|
|
|
if not self.supplier and self.product:
|
|
supplier, purchase_date = Request.find_best_supplier(
|
|
self.product, supply_date)
|
|
else:
|
|
supplier = self.supplier
|
|
# TODO compute purchase_date for product_supplier
|
|
|
|
if self.product:
|
|
uom = self.product.purchase_uom or self.product.default_uom
|
|
quantity = Uom.compute_qty(self.unit, self.quantity, uom)
|
|
else:
|
|
uom = self.unit
|
|
quantity = self.quantity
|
|
|
|
return Request(
|
|
product=self.product,
|
|
description=self.description,
|
|
party=supplier,
|
|
quantity=quantity,
|
|
uom=uom,
|
|
computed_quantity=self.quantity,
|
|
computed_uom=self.unit,
|
|
purchase_date=purchase_date,
|
|
supply_date=supply_date,
|
|
company=self.requisition.company,
|
|
warehouse=self.requisition.warehouse,
|
|
origin=self,
|
|
)
|
|
|
|
|
|
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()
|
|
PurchaseRequest = pool.get('purchase.request')
|
|
Requisition = pool.get('purchase.requisition')
|
|
RequisitionLine = pool.get('purchase.requisition.line')
|
|
|
|
state = super(
|
|
HandlePurchaseCancellationException, self).transition_reset()
|
|
|
|
request_ids = Transaction().context['active_ids']
|
|
requests = PurchaseRequest.browse(request_ids)
|
|
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):
|
|
if request.origin.unit_price:
|
|
key += (('unit_price', request.origin.unit_price),)
|
|
return key
|
|
|
|
|
|
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('cancel')
|
|
def cancel(cls, purchases):
|
|
super(Purchase, cls).cancel(purchases)
|
|
cls._process_requisition(purchases)
|