#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 trytond.model import ModelView, ModelWorkflow, ModelSQL, fields from trytond.wizard import Wizard from trytond.pyson import Not, Equal, Eval, Or, Bool from trytond.backend import TableHandler import datetime import time from dateutil.relativedelta import relativedelta STATES = { 'readonly': Not(Equal(Eval('state'), 'draft')), } class Forecast(ModelWorkflow, ModelSQL, ModelView): "Stock Forecast" _name = "stock.forecast" _description = __doc__ _rec_name = 'location' location = fields.Many2One( 'stock.location', 'Location', required=True, domain=[('type', '=', 'storage')], states={ 'readonly': Or(Not(Equal(Eval('state'), 'draft')), Bool(Eval('lines'))), }) destination = fields.Many2One( 'stock.location', 'Destination', required=True, domain=[('type', '=', 'customer')], states=STATES) from_date = fields.Date('From Date', required=True, states=STATES) to_date = fields.Date('To Date', required=True, states=STATES) lines = fields.One2Many( 'stock.forecast.line', 'forecast', 'Lines', states=STATES) company = fields.Many2One( 'company.company', 'Company', required=True, states={ 'readonly': Or(Not(Equal(Eval('state'), 'draft')), Bool(Eval('lines'))), }) state = fields.Selection([ ('draft', 'Draft'), ('done', 'Done'), ('cancel', 'Cancel'), ], 'State', readonly=True, select=1) def __init__(self): super(Forecast, self).__init__() self._rpc.update({ 'button_draft': True, }) self._sql_constraints += [ ('check_from_to_date', 'CHECK(to_date >= from_date)', '"To Date" must be greater than "From Date"!'), ] self._constraints += [ ('check_date_overlap', 'date_overlap'), ] self._error_messages.update({ 'date_overlap': 'You can not create forecasts for the same ' 'locations with overlapping dates' }) self._order.insert(0, ('from_date', 'DESC')) self._order.insert(1, ('location', 'ASC')) def init(self, cursor, module_name): super(Forecast, self).init(cursor, module_name) # Add index on create_date table = TableHandler(cursor, self, module_name) table.index_action('create_date', action='add') def default_state(self, cursor, user, context=None): return 'draft' def default_destination(self, cursor, user, context=None): location_obj = self.pool.get('stock.location') location_ids = location_obj.search(cursor, user, self.destination.domain, context=context) if len(location_ids) == 1: return location_ids[0] return False def default_company(self, cursor, user, context=None): if context is None: context = {} if context.get('company'): return context['company'] return False def check_date_overlap(self, cursor, user, ids): for forecast in self.browse(cursor, user, ids): if forecast.state != 'done': continue cursor.execute('SELECT id ' \ 'FROM stock_forecast ' \ 'WHERE ((from_date <= %s AND to_date >= %s) ' \ 'OR (from_date <= %s AND to_date >= %s) ' \ 'OR (from_date >= %s AND to_date <= %s)) ' \ 'AND location = %s ' \ 'AND destination = %s ' \ 'AND state = \'done\' ' \ 'AND id != %s', (forecast.from_date, forecast.from_date, forecast.to_date, forecast.to_date, forecast.from_date, forecast.to_date, forecast.location.id, forecast.destination.id, forecast.id)) if cursor.rowcount: return False return True def button_draft(self, cursor, user, ids, context=None): self.workflow_trigger_create(cursor, user, ids, context=context) return True def set_state_draft(self, cursor, user, forecast_id, context=None): line_obj = self.pool.get("stock.forecast.line") forecast = self.browse(cursor, user, forecast_id, context=context) if forecast.state == "done": line_obj.cancel_moves(cursor, user, forecast.lines, context=context) self.write(cursor, user, forecast_id, { 'state': 'draft', }, context=context) def set_state_cancel(self, cursor, user, forecast_id, context=None): self.write(cursor, user, forecast_id, { 'state': 'cancel', }, context=context) def set_state_done(self, cursor, user, forecast_id, context=None): line_obj = self.pool.get('stock.forecast.line') forecast = self.browse(cursor, user, forecast_id, context=context) for line in forecast.lines: line_obj.create_moves(cursor, user, line, context=context) self.write( cursor, user, forecast_id, {'state': 'done',}, context=context) def copy(self, cursor, user, ids, default=None, context=None): line_obj = self.pool.get('stock.forecast.line') int_id = False if isinstance(ids, (int, long)): int_id = True ids = [ids] if default is None: default = {} default = default.copy() default['lines'] = False new_ids = [] for forecast in self.browse(cursor, user, ids, context=context): new_id = super(Forecast, self).copy(cursor, user, forecast.id, default=default, context=context) line_obj.copy(cursor, user, [x.id for x in forecast.lines], default={ 'forecast': new_id, }, context=context) new_ids.append(new_id) if int_id: return new_ids[0] return new_ids Forecast() class ForecastLine(ModelSQL, ModelView): 'Stock Forecast Line' _name = 'stock.forecast.line' _description = __doc__ _rec_name = 'product' product = fields.Many2One('product.product', 'Product', required=True, domain=[('type', '=', 'stockable')], on_change=['product']) uom = fields.Many2One( 'product.uom', 'UOM', required=True, domain=[ ('category', '=', (Eval('product'), 'product.default_uom.category')), ]) unit_digits = fields.Function('get_unit_digits', type='integer', string='Unit Digits') quantity = fields.Float('Quantity', digits=(16, Eval('unit_digits', 2)), required=True) minimal_quantity = fields.Float('Minimal Qty', digits=(16, Eval('unit_digits', 2)), required=True) moves = fields.Many2Many('stock.forecast.line-stock.move', 'line', 'move','Moves', readonly=True) forecast = fields.Many2One( 'stock.forecast', 'Forecast', required=True, ondelete='CASCADE',) def __init__(self): super(ForecastLine, self).__init__() self._sql_constraints += [ ('check_line_qty_pos', 'CHECK(quantity >= 0.0)', 'Line quantity must be positive!'), ('check_line_minimal_qty', 'CHECK(quantity >= minimal_quantity)', 'Line quantity must be greater than the minimal quantity!'), ('forecast_product_uniq', 'UNIQUE(forecast, product)', 'Product must be unique by forcast!'), ] def default_unit_digits(self, cursor, user, context=None): return 2 def default_minimal_quantity(self, cursor, user, context=None): return 1.0 def on_change_product(self, cursor, user, ids, vals, context=None): product_obj = self.pool.get('product.product') uom_obj = self.pool.get('product.uom') res = {} res['unit_digits'] = 2 if vals.get('product'): product = product_obj.browse(cursor, user, vals['product'], context=context) res['uom'] = product.default_uom.id res['uom.rec_name'] = product.default_uom.rec_name res['unit_digits'] = product.default_uom.digits return res def get_unit_digits(self, cursor, user, ids, name, arg, context=None): res = {} for line in self.browse(cursor, user, ids, context=context): res[line.id] = line.product.default_uom.digits return res def copy(self, cursor, user, ids, default=None, context=None): if default is None: default = {} default = default.copy() default['moves'] = False return super(ForecastLine, self).copy(cursor, user, ids, default=default, context=context) def create_moves(self, cursor, user, line, context=None): move_obj = self.pool.get('stock.move') uom_obj = self.pool.get('product.uom') delta = line.forecast.to_date - line.forecast.from_date delta = delta.days + 1 nb_packet = int(line.quantity/line.minimal_quantity) distribution = self.distribute( cursor, user, delta, nb_packet, context=context) unit_price = False if line.forecast.destination.type == 'customer': unit_price = line.product.list_price unit_price = uom_obj.compute_price( cursor, user, line.product.default_uom, unit_price, line.uom, context=context) moves = [] for day, qty in distribution.iteritems(): if qty == 0.0: continue mid = move_obj.create( cursor, user, {'from_location': line.forecast.location.id, 'to_location': line.forecast.destination.id, 'product': line.product.id, 'uom': line.uom.id, 'quantity': qty * line.minimal_quantity, 'planned_date': line.forecast.from_date + datetime.timedelta(day), 'company': line.forecast.company.id, 'currency':line.forecast.company.currency, 'unit_price': unit_price, }, context=context) moves.append(('add',mid)) self.write(cursor, user, line.id, {'moves': moves}, context=context) def cancel_moves(self, cursor, user, lines, context=None): move_obj = self.pool.get('stock.move') move_obj.write( cursor, user, [m.id for l in lines for m in l.moves], {'state': 'cancel'}, context=context) move_obj.delete( cursor, user, [m.id for l in lines for m in l.moves], context=context) def distribute(self, cursor, user, delta, qty, context=None): range_delta = range(delta) a = {}.fromkeys(range_delta, 0) while qty > 0: if qty > delta: for i in range_delta: a[i] += qty//delta qty = qty%delta elif delta//qty > 1: i = 0 while i < qty: a[i*delta//qty + (delta//qty/2)] += 1 i += 1 qty = 0 else: for i in range_delta: a[i] += 1 qty = delta-qty i = 0 while i < qty: a[delta - ((i*delta//qty) + (delta//qty/2)) - 1] -= 1 i += 1 qty = 0 return a ForecastLine() class ForecastLineMove(ModelSQL): 'ForecastLine - Move' _name = 'stock.forecast.line-stock.move' _table = 'forecast_line_stock_move_rel' _description = __doc__ line = fields.Many2One('stock.forecast.line', 'Forecast Line', ondelete='CASCADE', select=1, required=True) move = fields.Many2One('stock.move', 'Move', ondelete='CASCADE', select=1, required=True) ForecastLineMove() class ForecastCompleteAsk(ModelView): 'Forecast Complete Ask' _name = 'stock.forecast.complete.ask' _description = __doc__ from_date = fields.Date('From Date', required=True) to_date = fields.Date('To Date', required=True) ForecastCompleteAsk() class ForecastCompleteChoose(ModelView): 'Forecast Complete Choose' _name = 'stock.forecast.complete.choose' _description = __doc__ products = fields.Many2Many('product.product', None, None, 'Products') ForecastCompleteChoose() class ForecastComplete(Wizard): 'Complete Forecast' _name = 'stock.forecast.complete' states = { 'init': { 'actions': ['_set_default_dates'], 'result': { 'type': 'form', 'object': 'stock.forecast.complete.ask', 'state': [ ('end', 'cancel', 'tryton-cancel'), ('choose', 'Choose products', 'tryton-go-next'), ('complete', 'Complete', 'tryton-ok', True), ], }, }, 'choose': { 'actions': ['_set_default_products'], 'result': { 'type': 'form', 'object': 'stock.forecast.complete.choose', 'state': [ ('end', 'cancel', 'tryton-cancel'), ('init', 'Choose Dates', 'tryton-go-previous'), ('complete', 'Complete', 'tryton-ok', True), ], }, }, 'complete': { 'result': { 'type': 'action', 'action': '_complete', 'state': 'end', }, }, } def __init__(self): super(ForecastComplete, self).__init__() self._error_messages.update({ 'from_to_date': '"From Date" should be smaller than "To Date"!', }) def _set_default_dates(self, cursor, user, data, context=None): """ Forecast dates shifted by one year. """ forecast_obj = self.pool.get('stock.forecast') forecast = forecast_obj.browse(cursor, user, data['id'], context=context) res = {} for field in ("to_date", "from_date"): res[field] = forecast[field] - relativedelta(years=1) return res def _get_product_quantity(self, cursor, user, data, context=None): forecast_obj = self.pool.get('stock.forecast') product_obj = self.pool.get('product.product') forecast = forecast_obj.browse(cursor, user, data['id'], context=context) if data['form']['from_date'] > data['form']['to_date']: self.raise_user_error(cursor, 'from_to_date', context=context) local_context = context and context.copy() or {} local_context['stock_destinations'] = [forecast.destination.id] local_context['stock_date_start'] = data['form']['from_date'] local_context['stock_date_end'] = data['form']['to_date'] return product_obj.products_by_location( cursor, user, [forecast.location.id], with_childs=True, skip_zero=False, context=local_context) def _set_default_products(self, cursor, user, data, context=None): """ Collect products for which there is an outgoing stream between the given location and the destination. """ pbl = self._get_product_quantity(cursor, user, data, context=context) products = [] for (_, product), qty in pbl.iteritems(): if qty < 0: products.append(product) data['form'].update({'products': products}) return data['form'] def _complete(self, cursor, user, data, context=None): forecast_obj = self.pool.get('stock.forecast') forecast_line_obj = self.pool.get('stock.forecast.line') product_obj = self.pool.get('product.product') prod2line = {} forecast_line_ids = forecast_line_obj.search( cursor, user, [('forecast', '=', data['id'])], context=context) for forecast_line in forecast_line_obj.browse( cursor, user, forecast_line_ids, context=context): prod2line[forecast_line.product.id] = forecast_line.id pbl = self._get_product_quantity(cursor, user, data, context=context) product_ids = [x[1] for x in pbl] prod2uom = {} for product in product_obj.browse(cursor, user, product_ids, context=context): prod2uom[product.id] = product.default_uom.id if data['form'].get('products'): products = data['form']['products'][0][1] else: products = None for (_, product), qty in pbl.iteritems(): if products and product not in products: continue if -qty <= 0: continue if product in prod2line: forecast_line_obj.write( cursor, user, prod2line[product], {'product': product, 'quantity': -qty, 'uom': prod2uom[product], 'forecast': data['id'], 'minimal_quantity': min(1, -qty), }, context=context) else: forecast_line_obj.create( cursor, user, {'product': product, 'quantity': -qty, 'uom': prod2uom[product], 'forecast': data['id'], 'minimal_quantity': min(1, -qty), }, context=context) return {} ForecastComplete()