Files
tryton/product.py
T

549 lines
20 KiB
Python
Raw Normal View History

2014-12-02 12:56:04 +01:00
# This file is part of Tryton. The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.
2010-08-19 22:22:44 +02:00
import datetime
2018-03-27 10:51:36 +02:00
import functools
from decimal import Decimal
from collections import defaultdict
from sql import Literal, Null, Select
from sql.aggregate import Max
2015-07-01 19:42:33 +02:00
from sql.functions import CurrentTimestamp
2013-09-06 22:34:48 +02:00
from sql.conditionals import Coalesce
2013-06-21 23:24:17 +02:00
2018-12-29 14:20:29 +01:00
from trytond.i18n import gettext
2012-09-24 22:18:17 +02:00
from trytond.model import ModelSQL, ModelView, fields
2018-12-29 14:20:29 +01:00
from trytond.model.exceptions import AccessError
from trytond.wizard import Wizard, StateTransition, StateAction
from trytond.pyson import Eval, Or, PYSONEncoder
2010-08-19 22:22:44 +02:00
from trytond.transaction import Transaction
2012-09-24 22:18:17 +02:00
from trytond.pool import Pool, PoolMeta
2014-07-17 19:00:19 +02:00
from trytond.tools import grouped_slice
2012-09-24 22:18:17 +02:00
from .move import StockMixin
2012-09-24 22:18:17 +02:00
__all__ = ['Template', 'Product',
'ProductByLocationContext',
'ProductQuantitiesByWarehouse', 'ProductQuantitiesByWarehouseContext',
2015-06-15 00:58:00 +02:00
'RecomputeCostPrice']
2008-04-24 16:01:26 +02:00
2018-03-27 10:51:36 +02:00
def check_no_move(func):
def find_moves(cls, records):
pool = Pool()
Move = pool.get('stock.move')
if cls.__name__ == 'product.template':
field = 'product.template'
else:
field = 'product'
for sub_records in grouped_slice(records):
moves = Move.search([
2018-06-26 19:11:55 +02:00
(field, 'in', list(map(int, sub_records))),
2018-03-27 10:51:36 +02:00
],
limit=1, order=[])
if moves:
2018-12-29 14:20:29 +01:00
return moves
2018-03-27 10:51:36 +02:00
return False
@functools.wraps(func)
def decorator(cls, *args):
pool = Pool()
Template = pool.get('product.template')
transaction = Transaction()
if (transaction.user != 0
and transaction.context.get('_check_access')):
actions = iter(args)
for records, values in zip(actions, actions):
2018-12-29 14:20:29 +01:00
for field, msg in Template._modify_no_move:
2018-03-27 10:51:36 +02:00
if field in values:
if find_moves(cls, records):
2018-12-29 14:20:29 +01:00
raise AccessError(gettext(msg))
2018-03-27 10:51:36 +02:00
# No moves for those records
break
func(cls, *args)
return decorator
2018-06-26 19:11:55 +02:00
class Template(metaclass=PoolMeta):
2012-09-24 22:18:17 +02:00
__name__ = "product.template"
2018-12-04 12:25:45 +01:00
quantity = fields.Function(fields.Float('Quantity',
help="The amount of stock in the location."),
'sum_product')
forecast_quantity = fields.Function(fields.Float('Forecast Quantity',
help="The amount of stock expected to be in the location."),
'sum_product')
cost_value = fields.Function(fields.Numeric('Cost Value',
help="The value of the stock in the location."),
'sum_product')
2012-09-24 22:18:17 +02:00
def sum_product(self, name):
if name not in ('quantity', 'forecast_quantity', 'cost_value'):
raise Exception('Bad argument')
2012-09-24 22:18:17 +02:00
sum_ = 0. if name != 'cost_value' else Decimal(0)
for product in self.products:
sum_ += getattr(product, name)
return sum_
2012-09-24 22:18:17 +02:00
@classmethod
def __setup__(cls):
super(Template, cls).__setup__()
cls.cost_price.states['required'] = Or(
cls.cost_price.states.get('required', True),
Eval('type').in_(['goods', 'assets']))
2012-09-24 22:18:17 +02:00
cls.cost_price.depends.append('type')
cls._modify_no_move = [
2018-12-29 14:20:29 +01:00
('default_uom', 'stock.msg_product_change_default_uom'),
('type', 'stock.msg_product_change_type'),
]
2012-09-24 22:18:17 +02:00
@classmethod
2018-03-27 10:51:36 +02:00
@check_no_move
2013-12-19 14:45:31 +01:00
def write(cls, *args):
super(Template, cls).write(*args)
2015-06-15 00:58:00 +02:00
@classmethod
def recompute_cost_price(cls, templates):
pool = Pool()
Product = pool.get('product.product')
products = [p for t in templates for p in t.products]
Product.recompute_cost_price(products)
2018-06-26 19:11:55 +02:00
class Product(StockMixin, object, metaclass=PoolMeta):
2012-09-24 22:18:17 +02:00
__name__ = "product.product"
2018-12-04 12:25:45 +01:00
quantity = fields.Function(fields.Float('Quantity',
help="The amount of stock in the location."),
'get_quantity', searcher='search_quantity')
forecast_quantity = fields.Function(fields.Float('Forecast Quantity',
help="The amount of stock expected to be in the location."),
'get_quantity', searcher='search_quantity')
cost_value = fields.Function(fields.Numeric('Cost Value',
help="The value of the stock in the location."),
'get_cost_value')
2008-04-24 16:01:26 +02:00
2012-09-24 22:18:17 +02:00
@classmethod
def get_quantity(cls, products, name):
location_ids = Transaction().context.get('locations')
2018-06-26 19:11:55 +02:00
product_ids = list(map(int, products))
return cls._get_quantity(
products, name, location_ids, grouping_filter=(product_ids,))
2008-04-24 16:01:26 +02:00
2012-09-24 22:18:17 +02:00
@classmethod
def search_quantity(cls, name, domain=None):
location_ids = Transaction().context.get('locations')
return cls._search_quantity(name, location_ids, domain)
2008-04-24 16:12:07 +02:00
2012-09-24 22:18:17 +02:00
@classmethod
def get_cost_value(cls, products, name):
cost_values = {p.id: None for p in products}
context = {}
trans_context = Transaction().context
2014-05-27 22:21:36 +02:00
if trans_context.get('stock_date_end'):
# Use the last cost_price of the day
context['_datetime'] = datetime.datetime.combine(
trans_context['stock_date_end'], datetime.time.max)
# The date could be before the product creation
products = [p for p in products
if p.create_date <= context['_datetime']]
with Transaction().set_context(context):
for product in cls.browse(products):
# The product may not have a cost price
if product.cost_price is not None:
cost_values[product.id] = (
Decimal(str(product.quantity)) * product.cost_price)
return cost_values
2018-03-27 10:51:36 +02:00
@classmethod
@check_no_move
def write(cls, *args):
super(Product, cls).write(*args)
2012-09-24 22:18:17 +02:00
@classmethod
def products_by_location(cls, location_ids,
with_childs=False, grouping=('product',), grouping_filter=None):
"""
Compute for each location and product the stock quantity in the default
uom of the product.
2010-08-19 22:22:44 +02:00
The context with keys:
stock_skip_warehouse: if set, quantities on a warehouse are no more
quantities of all child locations but quantities of the storage
zone.
2010-08-19 22:22:44 +02:00
2013-06-21 23:24:17 +02:00
Return a dictionary with location id and grouping as key
2012-09-24 22:18:17 +02:00
and quantity as value.
"""
2011-07-03 13:19:14 +02:00
pool = Pool()
2012-09-24 22:18:17 +02:00
Location = pool.get('stock.location')
2013-06-21 23:24:17 +02:00
Move = pool.get('stock.move')
2008-09-18 16:23:17 +02:00
# Skip warehouse location in favor of their storage location
# to compute quantities. Keep track of which ids to remove
# and to add after the query.
storage_to_remove = set()
wh_to_add = {}
if Transaction().context.get('stock_skip_warehouse'):
location_ids = set(location_ids)
for location in Location.browse(list(location_ids)):
if location.type == 'warehouse':
location_ids.remove(location.id)
if location.storage_location.id not in location_ids:
storage_to_remove.add(location.storage_location.id)
location_ids.add(location.storage_location.id)
wh_to_add[location.id] = location.storage_location.id
location_ids = list(location_ids)
query = Move.compute_quantities_query(location_ids, with_childs,
grouping=grouping, grouping_filter=grouping_filter)
if query is None:
return {}
quantities = Move.compute_quantities(query, location_ids, with_childs,
grouping=grouping, grouping_filter=grouping_filter)
if wh_to_add:
2018-06-26 19:11:55 +02:00
for wh, storage in wh_to_add.items():
for key in quantities:
if key[0] == storage:
quantities[(wh,) + key[1:]] = quantities[key]
if storage in storage_to_remove:
del quantities[key]
return quantities
2008-04-24 16:01:26 +02:00
2015-06-15 00:58:00 +02:00
@classmethod
def recompute_cost_price(cls, products):
digits = cls.cost_price.digits
costs = defaultdict(list)
for product in products:
if product.type == 'service':
continue
cost = getattr(product,
'recompute_cost_price_%s' % product.cost_price_method)()
cost = cost.quantize(Decimal(str(10.0 ** -digits[1])))
costs[cost].append(product)
if not costs:
return
2015-06-15 00:58:00 +02:00
to_write = []
2018-06-26 19:11:55 +02:00
for cost, records in costs.items():
to_write.append(records)
2015-06-15 00:58:00 +02:00
to_write.append({'cost_price': cost})
# Enforce check access for account_stock*
with Transaction().set_context(_check_access=True):
cls.write(*to_write)
2015-06-15 00:58:00 +02:00
def recompute_cost_price_fixed(self):
return self.cost_price
2019-10-14 00:04:14 +02:00
@property
def _domain_moves_cost(self):
"Returns the domain for moves to use in cost computation"
context = Transaction().context
return [
('company', '=', context.get('company')),
]
2015-06-15 00:58:00 +02:00
def recompute_cost_price_average(self):
pool = Pool()
Move = pool.get('stock.move')
Currency = pool.get('currency.currency')
Uom = pool.get('product.uom')
moves = Move.search([
2019-10-14 00:04:14 +02:00
('product', '=', self.id),
2015-06-15 00:58:00 +02:00
('state', '=', 'done'),
2019-10-14 00:04:14 +02:00
self._domain_moves_cost,
2015-06-15 00:58:00 +02:00
['OR',
[
('to_location.type', '=', 'storage'),
('from_location.type', '!=', 'storage'),
],
[
('from_location.type', '=', 'storage'),
('to_location.type', '!=', 'storage'),
],
],
], order=[('effective_date', 'ASC'), ('id', 'ASC')])
cost_price = Decimal(0)
quantity = 0
for move in moves:
qty = Uom.compute_qty(move.uom, move.quantity, self.default_uom)
qty = Decimal(str(qty))
if move.from_location.type == 'storage':
qty *= -1
if (move.from_location.type in ['supplier', 'production']
or move.to_location.type == 'supplier'):
with Transaction().set_context(date=move.effective_date):
unit_price = Currency.compute(
move.currency, move.unit_price,
move.company.currency, round=False)
unit_price = Uom.compute_price(move.uom, unit_price,
self.default_uom)
if quantity + qty > 0 and quantity >= 0:
2015-06-15 00:58:00 +02:00
cost_price = (
(cost_price * quantity) + (unit_price * qty)
) / (quantity + qty)
elif qty > 0:
cost_price = unit_price
2015-06-15 00:58:00 +02:00
quantity += qty
return cost_price
2008-06-05 01:00:26 +02:00
class ProductByLocationContext(ModelView):
2012-01-02 18:07:56 +01:00
'Product by Location'
__name__ = 'product.by_location.context'
2008-06-10 18:14:51 +02:00
forecast_date = fields.Date(
2018-12-04 12:25:45 +01:00
'At Date',
help="The date for which the stock quantity is calculated.\n"
"* An empty value calculates as far ahead as possible.\n"
"* A date in the past will provide historical values.")
stock_date_end = fields.Function(fields.Date('At Date'),
'on_change_with_stock_date_end')
2008-10-06 12:11:57 +02:00
2012-09-24 22:18:17 +02:00
@staticmethod
def default_forecast_date():
Date = Pool().get('ir.date')
return Date.today()
2008-06-10 18:14:51 +02:00
@fields.depends('forecast_date')
def on_change_with_stock_date_end(self, name=None):
if self.forecast_date is None:
return datetime.date.max
return self.forecast_date
2012-01-02 18:07:56 +01:00
2012-07-25 20:36:58 +02:00
class ProductQuantitiesByWarehouse(ModelSQL, ModelView):
'Product Quantities By Warehouse'
2012-09-24 22:18:17 +02:00
__name__ = 'stock.product_quantities_warehouse'
class _Date(fields.Date):
def get(self, ids, model, name, values=None):
if values is None:
values = {}
result = {}
for v in values:
date = v[name]
# SQLite does not convert to date
if isinstance(date, str):
date = datetime.date(*map(int, date.split('-', 2)))
result[v['id']] = date
return result
date = _Date('Date')
2012-07-25 20:36:58 +02:00
quantity = fields.Function(fields.Float('Quantity'), 'get_quantity')
del _Date
2012-09-24 22:18:17 +02:00
@classmethod
def __setup__(cls):
super(ProductQuantitiesByWarehouse, cls).__setup__()
cls._order.insert(0, ('date', 'ASC'))
2012-07-25 20:36:58 +02:00
2012-09-24 22:18:17 +02:00
@staticmethod
def table_query():
pool = Pool()
Move = pool.get('stock.move')
Location = pool.get('stock.location')
Product = pool.get('product.product')
Date = pool.get('ir.date')
move = from_ = Move.__table__()
context = Transaction().context
today = Date.today()
2012-07-25 20:36:58 +02:00
if context.get('product_template') is not None:
product = Product.__table__()
from_ = move.join(product, condition=move.product == product.id)
product_clause = (
product.template == context['product_template'])
else:
product_clause = move.product == context.get('product', -1)
warehouse_id = context.get('warehouse', -1)
2013-09-06 22:34:48 +02:00
warehouse_query = Location.search([
2012-07-25 20:36:58 +02:00
('parent', 'child_of', [warehouse_id]),
2013-09-06 22:34:48 +02:00
], query=True, order=[])
date_column = Coalesce(move.effective_date, move.planned_date)
return (from_.select(
Max(move.id).as_('id'),
Literal(0).as_('create_uid'),
CurrentTimestamp().as_('create_date'),
Literal(None).as_('write_uid'),
Literal(None).as_('write_date'),
date_column.as_('date'),
where=product_clause
& (move.from_location.in_(warehouse_query)
| move.to_location.in_(warehouse_query))
& (Coalesce(move.effective_date, move.planned_date) != Null)
& (date_column != today),
group_by=(date_column, move.product))
| Select([
Literal(0).as_('id'),
Literal(0).as_('create_uid'),
CurrentTimestamp().as_('create_date'),
Literal(None).as_('write_uid'),
Literal(None).as_('write_date'),
Literal(today).as_('date'),
]))
2012-07-25 20:36:58 +02:00
2012-09-24 22:18:17 +02:00
@classmethod
def get_quantity(cls, lines, name):
Product = Pool().get('product.product')
trans_context = Transaction().context
2012-07-25 20:36:58 +02:00
def valid_context(name):
return (trans_context.get(name) is not None
2018-06-26 19:11:55 +02:00
and isinstance(trans_context[name], int))
if not any(map(valid_context, ['product', 'product_template'])):
return {l.id: None for l in lines}
if trans_context.get('product') is not None:
grouping = ('product',)
grouping_filter = ([trans_context['product']],)
key = trans_context['product']
else:
grouping = ('product.template',)
grouping_filter = ([trans_context.get('product_template')],)
key = trans_context['product_template']
warehouse_id = trans_context.get('warehouse')
2012-07-25 20:36:58 +02:00
dates = sorted(l.date for l in lines)
quantities = {}
date_start = None
for date in dates:
context = {
'stock_date_start': date_start,
'stock_date_end': date,
'forecast': True,
2012-07-25 20:36:58 +02:00
}
with Transaction().set_context(**context):
2012-09-24 22:18:17 +02:00
quantities[date] = Product.products_by_location(
[warehouse_id],
grouping=grouping,
grouping_filter=grouping_filter,
with_childs=True).get((warehouse_id, key), 0)
try:
date_start = date + datetime.timedelta(1)
except OverflowError:
pass
2012-07-25 20:36:58 +02:00
cumulate = 0
for date in dates:
cumulate += quantities[date]
quantities[date] = cumulate
return dict((l.id, quantities[l.date]) for l in lines)
@classmethod
def get_rec_name(cls, records, name):
pool = Pool()
Lang = pool.get('ir.lang')
Product = pool.get('product.product')
Template = pool.get('product.template')
Location = pool.get('stock.location')
context = Transaction().context
if context.get('product_template'):
name = Template(context['product_template']).rec_name
elif context.get('product'):
name = Product(context['product']).rec_name
else:
name = ''
if context.get('warehouse'):
warehouse_name = Location(context['warehouse']).rec_name
else:
warehouse_name = '-'
lang = Lang.get()
names = {}
for record in records:
names[record.id] = '%s (%s) @ %s' % (
name, warehouse_name, lang.strftime(record.date))
return names
2012-07-25 20:36:58 +02:00
class ProductQuantitiesByWarehouseContext(ModelView):
2012-07-25 20:36:58 +02:00
'Product Quantities By Warehouse'
__name__ = 'stock.product_quantities_warehouse.context'
warehouse = fields.Many2One('stock.location', 'Warehouse', required=True,
domain=[
2012-07-25 20:36:58 +02:00
('type', '=', 'warehouse'),
2018-12-04 12:25:45 +01:00
],
help="The warehouse for which the quantities will be calculated.")
2012-07-25 20:36:58 +02:00
@classmethod
def default_warehouse(cls):
2012-09-24 22:18:17 +02:00
Location = Pool().get('stock.location')
return Location.get_default_warehouse()
2012-07-25 20:36:58 +02:00
class OpenProductQuantitiesByWarehouse(Wizard):
"Open Product Quantities By Warehouse"
__name__ = 'stock.product_quantities_warehouse.open'
start_state = 'open_'
open_ = StateAction('stock.act_move_form')
def do_open_(self, action):
pool = Pool()
Date = pool.get('ir.date')
ProductQuantitiesByWarehouse = pool.get(
'stock.product_quantities_warehouse')
context = Transaction().context
today = Date.today()
record = ProductQuantitiesByWarehouse(context['active_id'])
warehouse_id = context.get('warehouse', -1)
domain = [
['OR',
[
('from_location', 'child_of', [warehouse_id], 'parent'),
('to_location', 'not child_of', [warehouse_id], 'parent'),
],
[
('to_location', 'child_of', [warehouse_id], 'parent'),
('from_location', 'not child_of',
[warehouse_id], 'parent'),
],
],
['OR',
('effective_date', '=', record.date),
[
('effective_date', '=', None),
('planned_date', '=', record.date),
],
],
]
if record.date < today:
domain.append(('state', '=', 'done'))
if context.get('product_template'):
domain.append(
('product.template', '=', context['product_template']))
else:
domain.append(('product', '=', context.get('product', -1)))
action['pyson_domain'] = PYSONEncoder().encode(domain)
action['pyson_search_value'] = None
action['name'] += ' (' + record.rec_name + ')'
return action, {}
2015-06-15 00:58:00 +02:00
class RecomputeCostPrice(Wizard):
'Recompute Cost Price'
__name__ = 'product.recompute_cost_price'
start_state = 'recompute'
recompute = StateTransition()
def transition_recompute(self):
pool = Pool()
Product = pool.get('product.product')
Template = pool.get('product.template')
context = Transaction().context
if context['active_model'] == 'product.product':
products = Product.browse(context['active_ids'])
Product.recompute_cost_price(products)
elif context['active_model'] == 'product.template':
templates = Template.browse(context['active_ids'])
Template.recompute_cost_price(templates)
return 'end'