Improve forecast behavior regarding stock_supply

Forecast creates draft moves only on demand
Forecast works at warehouse level
Allow 'production' as destination

review77002
This commit is contained in:
Cédric Krier
2011-08-21 10:48:50 +02:00
parent 04489ddc7c
commit c44eec54d4
4 changed files with 261 additions and 31 deletions
+4
View File
@@ -1,3 +1,7 @@
* Forecast creates draft moves only on demand
* Forecast works at warehouse level
* Allow 'production' as destination
Version 2.0.0 - 2011-04-27
* Bug fixes (see mercurial logs for details)
+136 -25
View File
@@ -3,12 +3,14 @@
import datetime
import time
from dateutil.relativedelta import relativedelta
import itertools
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
from trytond.transaction import Transaction
from trytond.pool import Pool
from trytond.tools import reduce_ids
STATES = {
'readonly': Not(Equal(Eval('state'), 'draft')),
@@ -20,18 +22,19 @@ class Forecast(ModelWorkflow, ModelSQL, ModelView):
"Stock Forecast"
_name = "stock.forecast"
_description = __doc__
_rec_name = 'location'
_rec_name = 'warehouse'
location = fields.Many2One(
warehouse = fields.Many2One(
'stock.location', 'Location', required=True,
domain=[('type', '=', 'storage')], states={
domain=[('type', '=', 'warehouse')], states={
'readonly': Or(Not(Equal(Eval('state'), 'draft')),
Bool(Eval('lines'))),
},
depends=['state', 'lines'])
destination = fields.Many2One(
'stock.location', 'Destination', required=True,
domain=[('type', '=', 'customer')], states=STATES, depends=DEPENDS)
domain=[('type', 'in', ['customer', 'production'])], states=STATES,
depends=DEPENDS)
from_date = fields.Date('From Date', required=True, states=STATES,
depends=DEPENDS)
to_date = fields.Date('To Date', required=True, states=STATES,
@@ -69,16 +72,48 @@ class Forecast(ModelWorkflow, ModelSQL, ModelView):
'locations with overlapping dates'
})
self._order.insert(0, ('from_date', 'DESC'))
self._order.insert(1, ('location', 'ASC'))
self._order.insert(1, ('warehouse', 'ASC'))
def init(self, module_name):
location_obj = Pool().get('stock.location')
cursor = Transaction().cursor
table = TableHandler(cursor, self, module_name)
migrate_warehouse = (not table.column_exist('warehouse')
and table.column_exist('location'))
super(Forecast, self).init(module_name)
# Add index on create_date
table = TableHandler(cursor, self, module_name)
table.index_action('create_date', action='add')
if migrate_warehouse:
location2warehouse = {}
def find_warehouse(location):
if location.type == 'warehouse':
return location.id
elif location.parent:
return find_warehouse(location.parent)
cursor.execute('SELECT id, location FROM "%s"' % self._table)
for forecast_id, location_id in cursor.fetchall():
warehouse_id = location_id # default fallback
if location_id in location2warehouse:
warehouse_id = location2warehouse[location_id]
else:
location = location_obj.browse(location_id)
warehouse_id = find_warehouse(location) or location_id
location2warehouse[location_id] = warehouse_id
cursor.execute('UPDATE "%s" SET warehouse = %%s '
'WHERE id = %%s' % self._table, (warehouse_id, forecast_id))
table.not_null_action('warehouse',
action=self.warehouse.required and 'add' or 'remove')
table.drop_column('location', True)
# Migration from 2.0 delete stock moves
forecast_ids = self.search([])
self.delete_moves(forecast_ids)
def default_state(self):
return 'draft'
@@ -103,7 +138,7 @@ class Forecast(ModelWorkflow, ModelSQL, ModelView):
'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 warehouse = %s ' \
'AND destination = %s ' \
'AND state = \'done\' ' \
'AND company = %s '
@@ -111,7 +146,7 @@ class Forecast(ModelWorkflow, ModelSQL, ModelView):
(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.warehouse.id, forecast.destination.id,
forecast.company.id, forecast.id))
rowcount = cursor.rowcount
if rowcount == -1 or rowcount is None:
@@ -125,9 +160,6 @@ class Forecast(ModelWorkflow, ModelSQL, ModelView):
return True
def wkf_draft(self, forecast):
line_obj = Pool().get('stock.forecast.line')
if forecast.state == "done":
line_obj.cancel_moves(forecast.lines)
self.write(forecast.id, {
'state': 'draft',
})
@@ -138,13 +170,29 @@ class Forecast(ModelWorkflow, ModelSQL, ModelView):
})
def wkf_done(self, forecast):
line_obj = Pool().get('stock.forecast.line')
for line in forecast.lines:
line_obj.create_moves(line)
self.write(forecast.id, {
'state': 'done',
})
def create_moves(self, forecast_ids):
'Create stock moves for the forecast ids'
line_obj = Pool().get('stock.forecast.line')
forecasts = self.browse(forecast_ids)
for forecast in forecasts:
if forecast.state == 'done':
for line in forecast.lines:
line_obj.create_moves(line)
def delete_moves(self, forecast_ids):
'Delete stock moves for the forecast ids'
line_obj = Pool().get('stock.forecast.line')
forecasts = self.browse(forecast_ids)
for forecast in forecasts:
for line in forecast.lines:
line_obj.delete_moves(line)
def copy(self, ids, default=None):
line_obj = Pool().get('stock.forecast.line')
@@ -197,7 +245,10 @@ class ForecastLine(ModelSQL, ModelView):
moves = fields.Many2Many('stock.forecast.line-stock.move',
'line', 'move','Moves', readonly=True)
forecast = fields.Many2One(
'stock.forecast', 'Forecast', required=True, ondelete='CASCADE',)
'stock.forecast', 'Forecast', required=True, ondelete='CASCADE')
quantity_executed = fields.Function(fields.Float('Quantity Executed',
digits=(16, Eval('unit_digits', 2)), depends=['unit_digits']),
'get_quantity_executed')
def __init__(self):
super(ForecastLine, self).__init__()
@@ -243,6 +294,51 @@ class ForecastLine(ModelSQL, ModelView):
res[line.id] = line.product.default_uom.digits
return res
def get_quantity_executed(self, ids, name):
cursor = Transaction().cursor
move_obj = Pool().get('stock.move')
location_obj = Pool().get('stock.location')
uom_obj = Pool().get('product.uom')
forecast_obj = Pool().get('stock.forecast')
line_move_obj = Pool().get('stock.forecast.line-stock.move')
result = dict((x, 0) for x in ids)
lines = self.browse(ids)
key = lambda line: line.forecast.id
lines.sort(key=key)
for forecast_id, lines in itertools.groupby(lines, key):
forecast = forecast_obj.browse(forecast_id)
product2line = dict((line.product.id, line) for line in lines)
product_ids = product2line.keys()
for i in range(0, len(product_ids), cursor.IN_MAX):
sub_ids = product_ids[i:i + cursor.IN_MAX]
red_sql, red_ids = reduce_ids('product', sub_ids)
cursor.execute('SELECT m.product, '
'SUM(m.internal_quantity) AS quantity '
'FROM "' + move_obj._table + '" AS m '
'JOIN "' + location_obj._table + '" AS fl '
'ON m.from_location = fl.id '
'JOIN "' + location_obj._table + '" AS tl '
'ON m.to_location = tl.id '
'LEFT JOIN "' + line_move_obj._table + '" AS lm '
'ON m.id = lm.move '
'WHERE ' + red_sql + ' '
'AND fl.left >= %s AND fl.right <= %s '
'AND tl.left >= %s AND tl.right <= %s '
'AND m.state != %s '
'AND COALESCE(m.effective_date, m.planned_date) >= %s '
'AND COALESCE(m.effective_date, m.planned_date) <= %s '
'AND lm.id IS NULL '
'GROUP BY m.product',
red_ids + [forecast.warehouse.left, forecast.warehouse.right,
forecast.destination.left, forecast.destination.right,
'cancel', forecast.from_date, forecast.to_date])
for product_id, quantity in cursor.fetchall():
line = product2line[product_id]
result[line.id] = uom_obj.compute_qty(
line.product.default_uom, quantity, line.uom)
return result
def copy(self, ids, default=None):
if default is None:
default = {}
@@ -251,11 +347,25 @@ class ForecastLine(ModelSQL, ModelView):
return super(ForecastLine, self).copy(ids, default=default)
def create_moves(self, line):
'Create stock moves for the forecast line'
move_obj = Pool().get('stock.move')
uom_obj = Pool().get('product.uom')
delta = line.forecast.to_date - line.forecast.from_date
date_obj = Pool().get('ir.date')
assert not line.moves
today = date_obj.today()
from_date = line.forecast.from_date
if from_date < today:
from_date = today
to_date = line.forecast.to_date
if to_date < today:
return
delta = to_date - from_date
delta = delta.days + 1
nb_packet = int(line.quantity/line.minimal_quantity)
nb_packet = int((line.quantity - line.quantity_executed)
/ line.minimal_quantity)
distribution = self.distribute(delta, nb_packet)
unit_price = False
if line.forecast.destination.type == 'customer':
@@ -268,26 +378,27 @@ class ForecastLine(ModelSQL, ModelView):
if qty == 0.0:
continue
mid = move_obj.create({
'from_location': line.forecast.location.id,
'from_location': line.forecast.warehouse.storage_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),
'planned_date': (line.forecast.from_date
+ datetime.timedelta(day)),
'company': line.forecast.company.id,
'currency':line.forecast.company.currency.id,
'unit_price': unit_price,
})
moves.append(('add',mid))
self.write(line.id, {'moves': moves})
moves.append(mid)
self.write(line.id, {'moves': [('set', moves)]})
def cancel_moves(self, lines):
def delete_moves(self, line):
'Delete stock moves of the forecast line'
move_obj = Pool().get('stock.move')
move_obj.write([m.id for l in lines for m in l.moves],
{'state': 'cancel'})
move_obj.delete([m.id for l in lines for m in l.moves])
move_obj.delete([m.id for m in line.moves])
def distribute(self, delta, qty):
'Distribute qty over delta'
range_delta = range(delta)
a = {}.fromkeys(range_delta, 0)
while qty > 0:
@@ -416,7 +527,7 @@ class ForecastComplete(Wizard):
stock_destination=[forecast.destination.id],
stock_date_start=data['form']['from_date'],
stock_date_end=data['form']['to_date']):
return product_obj.products_by_location([forecast.location.id],
return product_obj.products_by_location([forecast.warehouse.id],
with_childs=True, skip_zero=False)
def _set_default_products(self, data):
+6 -3
View File
@@ -29,8 +29,8 @@ this repository contains the full copyright notices and license terms. -->
<field name="arch" type="xml">
<![CDATA[
<form string="Forecast" col="4">
<label name="location"/>
<field name="location"/>
<label name="warehouse"/>
<field name="warehouse"/>
<label name="destination"/>
<field name="destination"/>
<label name="from_date"/>
@@ -74,7 +74,7 @@ this repository contains the full copyright notices and license terms. -->
<field name="arch" type="xml">
<![CDATA[
<tree string="Forecasts">
<field name="location" select="1"/>
<field name="warehouse" select="1"/>
<field name="state" select="1"/>
<field name="from_date"/>
<field name="to_date"/>
@@ -141,6 +141,8 @@ this repository contains the full copyright notices and license terms. -->
<field name="minimal_quantity"/>
<label name="uom"/>
<field name="uom"/>
<label name="quantity_executed"/>
<field name="quantity_executed"/>
<field name="moves" colspan="4"/>
<field name="unit_digits" invisible="1" colspan="4"/>
</form>
@@ -157,6 +159,7 @@ this repository contains the full copyright notices and license terms. -->
<field name="quantity" select="1"/>
<field name="minimal_quantity" select="2"/>
<field name="uom" select="1"/>
<field name="quantity_executed"/>
<field name="forecast" select="2"/>
<field name="unit_digits" tree_invisible="1"/>
</tree>
+115 -3
View File
@@ -4,13 +4,18 @@
import sys, os
DIR = os.path.abspath(os.path.normpath(os.path.join(__file__,
'..', '..', '..', '..', '..', 'trytond')))
'..', '..', '..', '..', '..', 'trytond')))
if os.path.isdir(DIR):
sys.path.insert(0, os.path.dirname(DIR))
import unittest
from decimal import Decimal
import datetime
from dateutil.relativedelta import relativedelta
import trytond.tests.test_tryton
from trytond.tests.test_tryton import test_view, test_depends
from trytond.tests.test_tryton import POOL, DB_NAME, USER, CONTEXT, test_view,\
test_depends
from trytond.transaction import Transaction
class StockForecastTestCase(unittest.TestCase):
@@ -20,6 +25,15 @@ class StockForecastTestCase(unittest.TestCase):
def setUp(self):
trytond.tests.test_tryton.install_module('stock_forecast')
self.category = POOL.get('product.category')
self.uom = POOL.get('product.uom')
self.product = POOL.get('product.product')
self.location = POOL.get('stock.location')
self.company = POOL.get('company.company')
self.user = POOL.get('res.user')
self.forecast = POOL.get('stock.forecast')
self.line = POOL.get('stock.forecast.line')
self.move = POOL.get('stock.move')
def test0005views(self):
'''
@@ -33,10 +47,108 @@ class StockForecastTestCase(unittest.TestCase):
'''
test_depends()
def test0020distribute(self):
'''
Test distribute.
'''
for values, result in (
((1, 5), {0: 5}),
((4, 8), {0: 2, 1: 2, 2: 2, 3: 2}),
((2, 5), {0: 2, 1: 3}),
((10, 4), {0: 0, 1: 1, 2: 0, 3: 1, 4: 0,
5: 0, 6: 1, 7: 0, 8: 1, 9: 0}),
):
self.assertEqual(self.line.distribute(*values), result)
def test0030create_moves(self):
'''
Test create_moves.
'''
with Transaction().start(DB_NAME, USER, CONTEXT) as transaction:
category_id = self.category.create({
'name': 'Test create_moves',
})
unit_id, = self.uom.search([('name', '=', 'Unit')])
product_id = self.product.create({
'name': 'Test create_moves',
'type': 'stockable',
'category': category_id,
'cost_price_method': 'fixed',
'default_uom': unit_id,
'list_price': Decimal('1'),
})
customer_id, = self.location.search([('code', '=', 'CUS')])
warehouse_id, = self.location.search([('code', '=', 'WH')])
storage_id, = self.location.search([('code', '=', 'STO')])
company_id, = self.company.search([('name', '=', 'B2CK')])
self.user.write(USER, {
'main_company': company_id,
'company': company_id,
})
today = datetime.date.today()
forecast_id = self.forecast.create({
'warehouse': warehouse_id,
'destination': customer_id,
'from_date': today + relativedelta(months=1, day=1),
'to_date': today + relativedelta(months=1, day=20),
'company': company_id,
'lines': [
('create', {
'product': product_id,
'quantity': 10,
'uom': unit_id,
'minimal_quantity': 2,
},
),
],
})
self.forecast.workflow_trigger_validate(forecast_id, 'done')
self.forecast.create_moves([forecast_id])
forecast = self.forecast.browse(forecast_id)
line, = forecast.lines
self.assertEqual(line.quantity_executed, 0)
self.assertEqual(len(line.moves), 5)
self.assertEqual(sum(move.quantity for move in line.moves), 10)
self.forecast.delete_moves([forecast_id])
forecast = self.forecast.browse(forecast_id)
line, = forecast.lines
self.assertEqual(len(line.moves), 0)
company = self.company.browse(company_id)
self.move.create({
'from_location': storage_id,
'to_location': customer_id,
'product': product_id,
'uom': unit_id,
'quantity': 2,
'planned_date': today + relativedelta(months=1, day=5),
'company': company_id,
'currency': company.currency.id,
'unit_price': Decimal('1'),
})
forecast = self.forecast.browse(forecast_id)
line, = forecast.lines
self.assertEqual(line.quantity_executed, 2)
self.forecast.create_moves([forecast_id])
forecast = self.forecast.browse(forecast_id)
line, = forecast.lines
self.assertEqual(line.quantity_executed, 2)
self.assertEqual(len(line.moves), 4)
self.assertEqual(sum(move.quantity for move in line.moves), 8)
def suite():
suite = trytond.tests.test_tryton.suite()
from trytond.modules.company.tests import test_company
for test in test_company.suite():
if test not in suite:
suite.addTest(test)
suite.addTests(unittest.TestLoader().loadTestsFromTestCase(
StockForecastTestCase))
StockForecastTestCase))
return suite
if __name__ == '__main__':