commit d10382ae39ab93da13a34a027f62a31176dbef1b Author: Bertrand Chenal Date: Mon Nov 10 21:44:37 2008 +0100 Stock forecast. First version diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000000..cf92f4b21a --- /dev/null +++ b/__init__.py @@ -0,0 +1,4 @@ +#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 forecast import * diff --git a/__tryton__.py b/__tryton__.py new file mode 100644 index 0000000000..b4f84c0bbe --- /dev/null +++ b/__tryton__.py @@ -0,0 +1,25 @@ +#This file is part of Tryton. The COPYRIGHT file at the top level of +#this repository contains the full copyright notices and license terms. +{ + 'name': 'Stock Forecast', + 'version': '0.0.1', + 'author': 'B2CK', + 'email': 'info@b2ck.com', + 'website': 'http://www.tryton.org/', + 'description': '''Stock Forecast. +Allow to generate future moves which will enable other process to anticipate them. +''', + + 'depends': [ + 'ir', + 'workflow', + 'stock', + 'product', + 'company', + ], + 'xml': [ + 'forecast.xml', + ], + 'translation': [ + ], +} diff --git a/forecast.py b/forecast.py new file mode 100644 index 0000000000..6a2c132291 --- /dev/null +++ b/forecast.py @@ -0,0 +1,241 @@ +#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.osv import fields, OSV +from trytond.wizard import Wizard, WizardOSV +import datetime +from trytond.netsvc import LocalService + +STATES = { + 'readonly': "state != 'draft'", +} + +#TODO : check dates, overlap and max > min + +def distribute(delta, qty): + 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 + + +class Forecast(OSV): + "Stock Forecast" + _name = "stock.forecast" + _description = __doc__ + _rec_name = 'location' + + location = fields.Many2One( + 'stock.location', 'Location', required=True, + domain="[('type', '=', 'storage')]", states={ + 'readonly': "state != 'draft' or bool(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': "state != 'draft' or bool(lines)", + }) + state = fields.Selection([ + ('draft', 'Draft'), + ('done', 'Done'), + ('cancel', 'Cancel'), + ], 'State', readonly=True, select=1) + + def __init__(self): + super(Forecast, self).__init__() + self._rpc_allowed += [ + 'button_draft', + ] + self._order.insert(0, ('location', 'ASC')) + self._order.insert(0, ('from_date', 'DESC')) + + def default_state(self, cursor, user, context=None): + return 'draft' + + def default_company(self, cursor, user, context=None): + company_obj = self.pool.get('company.company') + if context is None: + context = {} + if context.get('company'): + return company_obj.name_get(cursor, user, context['company'], + context=context)[0] + return False + + def button_draft(self, cursor, user, ids, context=None): + workflow_service = LocalService('workflow') + for forecast in self.browse(cursor, user, ids, context=context): + workflow_service.trg_create(user, self._name, forecast.id, cursor, + 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): + date_obj = self.pool.get('ir.date') + 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) +Forecast() + +class ForecastLine(OSV): + '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.Function('get_uom', type='many2one', relation='product.uom', + string='UOM') + unit_digits = fields.Function('get_unit_digits', type='integer', + string='Unit Digits') + quantity = fields.Float('Quantity', digits="(16, unit_digits)") + moves = fields.Many2Many( + 'stock.move', 'forecast_line_move_rel', 'foreacast_line', 'move','Move', + readonly=True, ondelete_target='CASCADE') + forecast = fields.Many2One('stock.forecast', 'Forecast') + + def __init__(self): + super(ForecastLine, self).__init__() + self._sql_constraints += [ + ('check_line_qty_pos', + 'CHECK(quantity >= 0.0)', 'Line quantity must be positive!'), + ('forecast_product_uniq', 'UNIQUE(forecast, product)', + 'Product must be unique by forcast!'), + ] + self._error_messages.update({ + 'line_quantity': 'Line quantity must be greater than the uom ' + 'rounding precision.', + }) + + def default_unit_digits(self, cursor, user, context=None): + return 2 + + 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'] = uom_obj.name_get(cursor, user, product.default_uom.id, + context=context)[0] + res['unit_digits'] = product.default_uom.digits + return res + + def get_uom(self, cursor, user, ids, name, arg, context=None): + uom_obj = self.pool.get('product.uom') + res = {} + for line in self.browse(cursor, user, ids, context=context): + res[line.id] = line.product.default_uom.id + uom2name = {} + for uom_id, name in uom_obj.name_get(cursor, user, res.values(), + context=context): + uom2name[uom_id] = (uom_id, name) + for line_id in res: + res[line_id] = uom2name[res[line_id]] + 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 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 + if line.quantity < line.uom.rounding: + self.raise_user_error(cursor, 'line_quantity') + packet_size = max(line.uom.rounding, 1) + nb_packet = int(line.quantity/packet_size) + distribution = distribute(delta, nb_packet) + unit_price = False + if line.forecast.destination.type == 'customer': + unit_price = line.product.list_price + elif line.forecast.destination.type == 'supplier': + unit_price = line.product.cost_price #XXX use price of first supplier ? + if unit_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 * packet_size, + '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 copy(self, cursor, user, line_id, default=None, context=None): + default = default and default.copy() or {} + default['forecast']= False + default['move'] = False + return super(ForecastLine, self).copy(cursor, user, line_id, + default=default, context=context) + +ForecastLine() diff --git a/forecast.xml b/forecast.xml new file mode 100644 index 0000000000..2da315e299 --- /dev/null +++ b/forecast.xml @@ -0,0 +1,190 @@ + + + + + + stock.forecast + form + + +