[MERGE] forward port branch saas-17 up to d08b4407be

This commit is contained in:
Christophe Simonis
2017-09-12 12:09:11 +02:00
72 changed files with 1151 additions and 394 deletions
@@ -23,7 +23,6 @@ Allows to automatically select analytic accounts based on criterions:
'security/ir.model.access.csv', 'security/ir.model.access.csv',
'security/account_analytic_default_security.xml', 'security/account_analytic_default_security.xml',
'views/account_analytic_default_view.xml', 'views/account_analytic_default_view.xml',
'views/product_views.xml',
'views/res_config_settings_views.xml', 'views/res_config_settings_views.xml',
], ],
'installable': True, 'installable': True,
@@ -3,4 +3,3 @@
from . import account_analytic_default from . import account_analytic_default
from . import sale_order_line from . import sale_order_line
from . import product
@@ -1,33 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
class ProductProduct(models.Model):
_inherit = 'product.product'
rules_count = fields.Integer(compute='_compute_rules_count', string='# Analytic Rules')
def _compute_rules_count(self):
Analytic = self.env['account.analytic.default']
for product in self:
product.rules_count = Analytic.search_count([('product_id', '=', product.id)])
class ProductTemplate(models.Model):
_inherit = 'product.template'
rules_count = fields.Integer(compute='_compute_rules_count', string='# Analytic Rules')
def _compute_rules_count(self):
for template in self:
template.rules_count = sum([p.rules_count for p in template.product_variant_ids])
@api.multi
def action_view_rules(self):
result = self.env.ref('account_analytic_default.action_product_default_list').read()[0]
result['domain'] = [('product_id', 'in', self.mapped('product_variant_ids').ids)]
# Remove context so it is not going to filter on product_id with active_id of template
result['context'] = {'default_product_id': self.mapped('product_variant_ids').id}
return result
@@ -1,30 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="product_form_view_default_analytic_button" model="ir.ui.view">
<field name="name">product.product.inherit.rules_count</field>
<field name="model">product.product</field>
<field name="inherit_id" ref="product.product_normal_form_view"/>
<field name="arch" type="xml">
<button name="toggle_active" position="before">
<button name= "%(action_product_default_list)d" class="oe_stat_button" type="action"
groups="analytic.group_analytic_accounting" icon="fa-bookmark-o">
<field name="rules_count" string="Analytic Rules" widget="statinfo" />
</button>
</button>
</field>
</record>
<record id="product_template_view_default_analytic_button" model="ir.ui.view">
<field name="name">product.template.inherit.rules_count</field>
<field name="model">product.template</field>
<field name="inherit_id" ref="product.product_template_only_form_view"/>
<field name="arch" type="xml">
<button name="toggle_active" position="before">
<button name= "action_view_rules" class="oe_stat_button" type="object"
groups="analytic.group_analytic_accounting" icon="fa-bookmark-o">
<field name="rules_count" string="Analytic Rules" widget="statinfo" />
</button>
</button>
</field>
</record>
</odoo>
@@ -1,6 +1,8 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details. # Part of Odoo. See LICENSE file for full copyright and licensing details.
from ast import literal_eval
from odoo import api, fields, models from odoo import api, fields, models
@@ -22,7 +24,7 @@ class ResConfigSettings(models.TransientModel):
res.update( res.update(
auth_signup_reset_password=get_param('auth_signup.reset_password', 'False').lower() == 'true', auth_signup_reset_password=get_param('auth_signup.reset_password', 'False').lower() == 'true',
auth_signup_uninvited='b2c' if get_param('auth_signup.allow_uninvited', 'False').lower() == 'true' else 'b2b', auth_signup_uninvited='b2c' if get_param('auth_signup.allow_uninvited', 'False').lower() == 'true' else 'b2b',
auth_signup_template_user_id=get_param('auth_signup.template_user_id', 'False').lower() == 'true', auth_signup_template_user_id=literal_eval(get_param('auth_signup.template_user_id', 'False')),
) )
return res return res
@@ -38,6 +40,6 @@ class ResConfigSettings(models.TransientModel):
@api.multi @api.multi
def open_template_user(self): def open_template_user(self):
action = self.env.ref('base.action_res_users').read()[0] action = self.env.ref('base.action_res_users').read()[0]
action['res_id'] = self.env.ref('auth_signup.default_template_user').id action['res_id'] = literal_eval(self.env['ir.config_parameter'].sudo().get_param('auth_signup.template_user_id', 'False'))
action['views'] = [[self.env.ref('base.view_users_form').id, 'form']] action['views'] = [[self.env.ref('base.view_users_form').id, 'form']]
return action return action
@@ -19,7 +19,11 @@
<field name="auth_signup_uninvited" widget="radio" required="True"/> <field name="auth_signup_uninvited" widget="radio" required="True"/>
</div> </div>
<div class="content-group" attrs="{'invisible': [('auth_signup_uninvited','=','b2b')]}"> <div class="content-group" attrs="{'invisible': [('auth_signup_uninvited','=','b2b')]}">
<div class="mt16"> <div class="mt16" groups="base.group_no_one"
attrs="{'invisible': [('auth_signup_template_user_id', '!=', False)]}">
Copy access rights from <field name="auth_signup_template_user_id"/>
</div>
<div class="mt16" attrs="{'invisible': [('auth_signup_template_user_id', '=', False)]}">
<button type="object" name="open_template_user" string="Default Access Rights" icon="fa-arrow-right" class="btn-link"/> <button type="object" name="open_template_user" string="Default Access Rights" icon="fa-arrow-right" class="btn-link"/>
</div> </div>
</div> </div>
+8 -4
View File
@@ -297,6 +297,8 @@ class Import(models.TransientModel):
if all(val.lower() in ('true', 'false', 't', 'f', '') for val in preview_values): if all(val.lower() in ('true', 'false', 't', 'f', '') for val in preview_values):
return ['boolean'] return ['boolean']
# If all values can be cast to float, type is either float or monetary # If all values can be cast to float, type is either float or monetary
# Or a date/datetime if it matches the pattern
results = []
try: try:
thousand_separator = decimal_separator = False thousand_separator = decimal_separator = False
for val in preview_values: for val in preview_values:
@@ -328,7 +330,7 @@ class Import(models.TransientModel):
if thousand_separator and not options.get('float_decimal_separator'): if thousand_separator and not options.get('float_decimal_separator'):
options['float_thousand_separator'] = thousand_separator options['float_thousand_separator'] = thousand_separator
options['float_decimal_separator'] = decimal_separator options['float_decimal_separator'] = decimal_separator
return ['float', 'monetary'] results = ['float', 'monetary']
except ValueError: except ValueError:
pass pass
# Try to see if all values are a date or datetime # Try to see if all values are a date or datetime
@@ -364,13 +366,15 @@ class Import(models.TransientModel):
current_date_pattern = check_patterns(date_patterns, preview_values) current_date_pattern = check_patterns(date_patterns, preview_values)
if current_date_pattern: if current_date_pattern:
options['date_format'] = current_date_pattern options['date_format'] = current_date_pattern
return ['date'] results += ['date']
current_datetime_pattern = check_patterns(datetime_patterns, preview_values) current_datetime_pattern = check_patterns(datetime_patterns, preview_values)
if current_datetime_pattern: if current_datetime_pattern:
options['datetime_format'] = current_datetime_pattern options['datetime_format'] = current_datetime_pattern
return ['datetime'] results += ['datetime']
if results:
return results
return ['id', 'text', 'char', 'datetime', 'selection', 'many2one', 'one2many', 'many2many', 'html'] return ['id', 'text', 'char', 'datetime', 'selection', 'many2one', 'one2many', 'many2many', 'html']
@api.model @api.model
@@ -492,7 +496,7 @@ class Import(models.TransientModel):
'headers_type': header_types or False, 'headers_type': header_types or False,
'preview': preview, 'preview': preview,
'options': options, 'options': options,
'advanced_mode': any([len(models.fix_import_export_id_paths(col)) > 1 for col in headers]), 'advanced_mode': any([len(models.fix_import_export_id_paths(col)) > 1 for col in headers or []]),
'debug': self.user_has_groups('base.group_no_one'), 'debug': self.user_has_groups('base.group_no_one'),
} }
except Exception as error: except Exception as error:
+1 -1
View File
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details. # Part of Odoo. See LICENSE file for full copyright and licensing details.
from xml.etree import ElementTree from lxml import etree as ElementTree
from odoo.http import Controller, route, request from odoo.http import Controller, route, request
+2 -2
View File
@@ -87,9 +87,9 @@ class DeliveryCarrier(models.Model):
return False return False
if self.state_ids and partner.state_id not in self.state_ids: if self.state_ids and partner.state_id not in self.state_ids:
return False return False
if self.zip_from and (partner.zip or '') < self.zip_from: if self.zip_from and (partner.zip or '').upper() < self.zip_from.upper():
return False return False
if self.zip_to and (partner.zip or '') > self.zip_to: if self.zip_to and (partner.zip or '').upper() > self.zip_to.upper():
return False return False
return True return True
+1 -1
View File
@@ -75,7 +75,7 @@ class LivechatController(http.Controller):
# if the user is identifiy (eg: portal user on the frontend), don't use the anonymous name. The user will be added to session. # if the user is identifiy (eg: portal user on the frontend), don't use the anonymous name. The user will be added to session.
if request.session.uid: if request.session.uid:
anonymous_name = request.env.user.name anonymous_name = request.env.user.name
return request.env["im_livechat.channel"].get_mail_channel(channel_id, anonymous_name) return request.env["im_livechat.channel"].with_context(lang=False).get_mail_channel(channel_id, anonymous_name)
@http.route('/im_livechat/feedback', type='json', auth='public') @http.route('/im_livechat/feedback', type='json', auth='public')
def feedback(self, uuid, rate, reason=None, **kwargs): def feedback(self, uuid, rate, reason=None, **kwargs):
+7 -7
View File
@@ -162,7 +162,7 @@ var ActivityMenu = Widget.extend({
template:'mail.chat.ActivityMenu', template:'mail.chat.ActivityMenu',
events: { events: {
"click": "_onActivityMenuClick", "click": "_onActivityMenuClick",
"click .o_activity_filter_button, .o_mail_channel_preview": "_onActivityFilterClick", "click .o_mail_channel_preview": "_onActivityFilterClick",
}, },
start: function () { start: function () {
this.$activities_preview = this.$('.o_mail_navbar_dropdown_channels'); this.$activities_preview = this.$('.o_mail_navbar_dropdown_channels');
@@ -252,19 +252,19 @@ var ActivityMenu = Widget.extend({
* @param {MouseEvent} event * @param {MouseEvent} event
*/ */
_onActivityFilterClick: function (event) { _onActivityFilterClick: function (event) {
event.stopPropagation(); // fetch the data from the button otherwise fetch the ones from the parent (.o_mail_channel_preview).
var $target = $(event.currentTarget); var data = _.extend({}, $(event.currentTarget).data(), $(event.target).data());
var context = {}; var context = {};
if ($target.data('filter')=='my') { if (data.filter === 'my') {
context['search_default_activities_overdue'] = 1; context['search_default_activities_overdue'] = 1;
context['search_default_activities_today'] = 1; context['search_default_activities_today'] = 1;
} else { } else {
context['search_default_activities_' + $target.data('filter')] = 1; context['search_default_activities_' + data.filter] = 1;
} }
this.do_action({ this.do_action({
type: 'ir.actions.act_window', type: 'ir.actions.act_window',
name: $target.data('model_name'), name: data.model_name,
res_model: $target.data('res_model'), res_model: data.res_model,
views: [[false, 'kanban'], [false, 'form']], views: [[false, 'kanban'], [false, 'form']],
search_view_id: [false], search_view_id: [false],
domain: [['activity_user_id', '=', session.uid]], domain: [['activity_user_id', '=', session.uid]],
+38 -5
View File
@@ -41,8 +41,8 @@ QUnit.module('ActivityMenu', {
res_model: "project.issue", res_model: "project.issue",
planned_count: 1, planned_count: 1,
today_count: 1, today_count: 1,
overdue_count: 0, overdue_count: 1,
total_count: 2, total_count: 3,
}], }],
}, },
}; };
@@ -67,10 +67,9 @@ QUnit.test('activity menu widget: menu with no records', function (assert) {
}); });
QUnit.test('activity menu widget: activity menu with 3 records', function (assert) { QUnit.test('activity menu widget: activity menu with 3 records', function (assert) {
assert.expect(4); assert.expect(10);
var self = this; var self = this;
var activityMenu = new systray.ActivityMenu(); var activityMenu = new systray.ActivityMenu();
testUtils.addMockEnvironment(activityMenu, { testUtils.addMockEnvironment(activityMenu, {
mockRPC: function (route, args) { mockRPC: function (route, args) {
if (args.method === 'activity_user_count') { if (args.method === 'activity_user_count') {
@@ -83,7 +82,41 @@ QUnit.test('activity menu widget: activity menu with 3 records', function (asser
assert.ok(activityMenu.$el.hasClass('o_mail_navbar_item'), 'should be the instance of widget'); assert.ok(activityMenu.$el.hasClass('o_mail_navbar_item'), 'should be the instance of widget');
assert.ok(activityMenu.$('.o_mail_channel_preview').hasClass('o_mail_channel_preview'), "should instance of widget"); assert.ok(activityMenu.$('.o_mail_channel_preview').hasClass('o_mail_channel_preview'), "should instance of widget");
assert.ok(activityMenu.$('.o_notification_counter').hasClass('o_notification_counter'), "widget should have notification counter"); assert.ok(activityMenu.$('.o_notification_counter').hasClass('o_notification_counter'), "widget should have notification counter");
assert.strictEqual(parseInt(activityMenu.el.innerText), 4, "widget should have 4 notification counter"); assert.strictEqual(parseInt(activityMenu.el.innerText), 5, "widget should have 5 notification counter");
var context = {};
testUtils.intercept(activityMenu, 'do_action', function(event) {
assert.deepEqual(event.data.action.context, context, "wrong context value");
}, true);
// case 1: click on "late"
context = {
search_default_activities_overdue: 1,
};
activityMenu.$('.dropdown-toggle').click();
assert.strictEqual(activityMenu.$el.hasClass("open"), true, 'ActivityMenu should be open');
activityMenu.$(".o_activity_filter_button[data-model_name='Issue'][data-filter='overdue']").click();
assert.strictEqual(activityMenu.$el.hasClass("open"), false, 'ActivityMenu should be closed');
// case 2: click on "today"
context = {
search_default_activities_today: 1,
};
activityMenu.$('.dropdown-toggle').click();
activityMenu.$(".o_activity_filter_button[data-model_name='Issue'][data-filter='today']").click();
// case 3: click on "future"
context = {
search_default_activities_upcoming_all: 1,
};
activityMenu.$('.dropdown-toggle').click();
activityMenu.$(".o_activity_filter_button[data-model_name='Issue'][data-filter='upcoming_all']").click();
// case 4: click anywere else
context = {
search_default_activities_overdue: 1,
search_default_activities_today: 1,
};
activityMenu.$('.dropdown-toggle').click();
activityMenu.$(".o_mail_navbar_dropdown_channels > div[data-model_name='Issue']").click();
activityMenu.destroy(); activityMenu.destroy();
}); });
}); });
+1 -1
View File
@@ -5,7 +5,7 @@
wiz_obj = self.env['mrp.product.produce'] wiz_obj = self.env['mrp.product.produce']
ctx = dict(self.env.context, active_id=self.id) ctx = dict(self.env.context, active_id=self.id)
produce_wiz = wiz_obj.with_context(ctx).create({'product_qty': self.product_qty, 'lot_id': ref('lot_product_27_0')}) produce_wiz = wiz_obj.with_context(ctx).create({'product_qty': self.product_qty, 'lot_id': ref('lot_product_27_0')})
produce_wiz.consume_line_ids.write({'qty_done': produce_wiz.product_qty}) produce_wiz.produce_line_ids.write({'qty_done': produce_wiz.product_qty})
#Produce Product,Post Inventory and Set MO Done. #Produce Product,Post Inventory and Set MO Done.
produce_wiz.do_produce() produce_wiz.do_produce()
self.post_inventory() self.post_inventory()
+1 -1
View File
@@ -109,7 +109,7 @@ class MrpWorkorder(models.Model):
'stock.move.line', 'workorder_id', 'stock.move.line', 'workorder_id',
domain=[('done_wo', '=', False)]) domain=[('done_wo', '=', False)])
final_lot_id = fields.Many2one( final_lot_id = fields.Many2one(
'stock.production.lot', 'Current Lot', domain="[('product_id', '=', product_id)]", 'stock.production.lot', 'Lot/Serial Number', domain="[('product_id', '=', product_id)]",
states={'done': [('readonly', True)], 'cancel': [('readonly', True)]}) states={'done': [('readonly', True)], 'cancel': [('readonly', True)]})
time_ids = fields.One2many( time_ids = fields.One2many(
'mrp.workcenter.productivity', 'workorder_id') 'mrp.workcenter.productivity', 'workorder_id')
+4 -4
View File
@@ -113,7 +113,7 @@ class TestUnbuild(TestMrpCommon):
}).action_unbuild() }).action_unbuild()
# Check quantity in stock after last unbuild. # Check quantity in stock after last unbuild.
self.assertEqual(self.env['stock.quant']._get_available_quantity(p_final, self.stock_location), -5, 'You should have negative quantity for final product in stock') self.assertEqual(self.env['stock.quant']._get_available_quantity(p_final, self.stock_location, allow_negative=True), -5, 'You should have negative quantity for final product in stock')
self.assertEqual(self.env['stock.quant']._get_available_quantity(p1, self.stock_location), 120, 'You should have 80 products in stock') self.assertEqual(self.env['stock.quant']._get_available_quantity(p1, self.stock_location), 120, 'You should have 80 products in stock')
self.assertEqual(self.env['stock.quant']._get_available_quantity(p2, self.stock_location), 10, 'You should have consumed all the 5 product in stock') self.assertEqual(self.env['stock.quant']._get_available_quantity(p2, self.stock_location), 10, 'You should have consumed all the 5 product in stock')
@@ -194,7 +194,7 @@ class TestUnbuild(TestMrpCommon):
'product_uom_id': self.uom_unit.id, 'product_uom_id': self.uom_unit.id,
}).action_unbuild() }).action_unbuild()
self.assertEqual(self.env['stock.quant']._get_available_quantity(p_final, self.stock_location, lot_id=lot), -5, 'You should have negative quantity for final product in stock') self.assertEqual(self.env['stock.quant']._get_available_quantity(p_final, self.stock_location, lot_id=lot, allow_negative=True), -5, 'You should have negative quantity for final product in stock')
self.assertEqual(self.env['stock.quant']._get_available_quantity(p1, self.stock_location), 120, 'You should have 80 products in stock') self.assertEqual(self.env['stock.quant']._get_available_quantity(p1, self.stock_location), 120, 'You should have 80 products in stock')
self.assertEqual(self.env['stock.quant']._get_available_quantity(p2, self.stock_location), 10, 'You should have consumed all the 5 product in stock') self.assertEqual(self.env['stock.quant']._get_available_quantity(p2, self.stock_location), 10, 'You should have consumed all the 5 product in stock')
@@ -279,7 +279,7 @@ class TestUnbuild(TestMrpCommon):
'product_uom_id': self.uom_unit.id, 'product_uom_id': self.uom_unit.id,
}).action_unbuild() }).action_unbuild()
self.assertEqual(self.env['stock.quant']._get_available_quantity(p_final, self.stock_location), -5, 'You should have negative quantity for final product in stock') self.assertEqual(self.env['stock.quant']._get_available_quantity(p_final, self.stock_location, allow_negative=True), -5, 'You should have negative quantity for final product in stock')
self.assertEqual(self.env['stock.quant']._get_available_quantity(p1, self.stock_location, lot_id=lot), 120, 'You should have 80 products in stock') self.assertEqual(self.env['stock.quant']._get_available_quantity(p1, self.stock_location, lot_id=lot), 120, 'You should have 80 products in stock')
self.assertEqual(self.env['stock.quant']._get_available_quantity(p2, self.stock_location), 10, 'You should have consumed all the 5 product in stock') self.assertEqual(self.env['stock.quant']._get_available_quantity(p2, self.stock_location), 10, 'You should have consumed all the 5 product in stock')
@@ -378,7 +378,7 @@ class TestUnbuild(TestMrpCommon):
'product_uom_id': self.uom_unit.id, 'product_uom_id': self.uom_unit.id,
}).action_unbuild() }).action_unbuild()
self.assertEqual(self.env['stock.quant']._get_available_quantity(p_final, self.stock_location, lot_id=lot_final), -5, 'You should have negative quantity for final product in stock') self.assertEqual(self.env['stock.quant']._get_available_quantity(p_final, self.stock_location, lot_id=lot_final, allow_negative=True), -5, 'You should have negative quantity for final product in stock')
self.assertEqual(self.env['stock.quant']._get_available_quantity(p1, self.stock_location, lot_id=lot_1), 120, 'You should have 80 products in stock') self.assertEqual(self.env['stock.quant']._get_available_quantity(p1, self.stock_location, lot_id=lot_1), 120, 'You should have 80 products in stock')
self.assertEqual(self.env['stock.quant']._get_available_quantity(p2, self.stock_location, lot_id=lot_2), 10, 'You should have consumed all the 5 product in stock') self.assertEqual(self.env['stock.quant']._get_available_quantity(p2, self.stock_location, lot_id=lot_2), 10, 'You should have consumed all the 5 product in stock')
+6 -7
View File
@@ -229,12 +229,11 @@ class TestWorkOrderProcess(common.TransactionCase):
# ----------------- # -----------------
# Produce 6 Unit of custom laptop will consume ( 12 Unit of keybord and 12 Unit of charger) # Produce 6 Unit of custom laptop will consume ( 12 Unit of keybord and 12 Unit of charger)
context = {"active_ids": [mo_custom_laptop.id], "active_id": mo_custom_laptop.id} context = {"active_ids": [mo_custom_laptop.id], "active_id": mo_custom_laptop.id}
product_consume = self.env['mrp.product.produce'].with_context(context).create({'product_qty': 6.00}) product_consume = self.env['mrp.product.produce'].with_context(context).create({'product_qty': 6.00})
laptop_lot_001 = self.env['stock.production.lot'].create({'product_id': custom_laptop.id}) laptop_lot_001 = self.env['stock.production.lot'].create({'product_id': custom_laptop.id})
product_consume.lot_id = laptop_lot_001.id product_consume.lot_id = laptop_lot_001.id
product_consume.consume_line_ids.write({'qty_done': 12}) product_consume.produce_line_ids.write({'qty_done': 12})
product_consume.do_produce() product_consume.do_produce()
# Check consumed move after produce 6 quantity of customized laptop. # Check consumed move after produce 6 quantity of customized laptop.
@@ -259,8 +258,8 @@ class TestWorkOrderProcess(common.TransactionCase):
product_consume = self.env['mrp.product.produce'].with_context(context).create({'product_qty': 4.00}) product_consume = self.env['mrp.product.produce'].with_context(context).create({'product_qty': 4.00})
laptop_lot_002 = self.env['stock.production.lot'].create({'product_id': custom_laptop.id}) laptop_lot_002 = self.env['stock.production.lot'].create({'product_id': custom_laptop.id})
product_consume.lot_id = laptop_lot_002.id product_consume.lot_id = laptop_lot_002.id
self.assertEquals(len(product_consume.consume_line_ids), 2) self.assertEquals(len(product_consume.produce_line_ids), 2)
product_consume.consume_line_ids.write({'qty_done': 8}) product_consume.produce_line_ids.write({'qty_done': 8})
product_consume.do_produce() product_consume.do_produce()
charger_move = mo_custom_laptop.move_raw_ids.filtered(lambda x: x.product_id.id == product_charger.id and x.state != 'done') charger_move = mo_custom_laptop.move_raw_ids.filtered(lambda x: x.product_id.id == product_charger.id and x.state != 'done')
keybord_move = mo_custom_laptop.move_raw_ids.filtered(lambda x: x.product_id.id == product_keybord.id and x.state !='done') keybord_move = mo_custom_laptop.move_raw_ids.filtered(lambda x: x.product_id.id == product_keybord.id and x.state !='done')
@@ -394,9 +393,9 @@ class TestWorkOrderProcess(common.TransactionCase):
product_consume = self.env['mrp.product.produce'].with_context(context).create({'product_qty': 10}) product_consume = self.env['mrp.product.produce'].with_context(context).create({'product_qty': 10})
# laptop_lot_002 = self.env['stock.production.lot'].create({'product_id': custom_laptop.id}) # laptop_lot_002 = self.env['stock.production.lot'].create({'product_id': custom_laptop.id})
product_consume.lot_id = lot_a.id product_consume.lot_id = lot_a.id
self.assertEquals(len(product_consume.consume_line_ids), 2) self.assertEquals(len(product_consume.produce_line_ids), 2)
product_consume.consume_line_ids.filtered(lambda x : x.product_id == product_C).write({'qty_done': 3000}) product_consume.produce_line_ids.filtered(lambda x : x.product_id == product_C).write({'qty_done': 3000})
product_consume.consume_line_ids.filtered(lambda x : x.product_id == product_B).write({'qty_done': 20}) product_consume.produce_line_ids.filtered(lambda x : x.product_id == product_B).write({'qty_done': 20})
product_consume.do_produce() product_consume.do_produce()
mo_custom_product.post_inventory() mo_custom_product.post_inventory()
+1 -1
View File
@@ -59,7 +59,7 @@
<span class="o_stat_text">Work Orders</span> <span class="o_stat_text">Work Orders</span>
</div> </div>
</button> </button>
<button name="%(action_mrp_production_moves)d" type="action" string="Inventory Moves" class="oe_stat_button" icon="fa-arrows-v" attrs="{'invisible': [('state', 'not in', ('progress', 'cancel', 'done'))]}"/> <button name="%(action_mrp_production_moves)d" type="action" string="Inventory Moves" class="oe_stat_button" icon="fa-arrows-v" attrs="{'invisible': [('state', 'not in', ('progress', 'done'))]}"/>
<button class="oe_stat_button" name="action_see_move_scrap" type="object" icon="fa-arrows-v" attrs="{'invisible': [('scrap_count', '=', 0)]}"> <button class="oe_stat_button" name="action_see_move_scrap" type="object" icon="fa-arrows-v" attrs="{'invisible': [('scrap_count', '=', 0)]}">
<div class="o_field_widget o_stat_info"> <div class="o_field_widget o_stat_info">
<span class="o_stat_value"><field name="scrap_count"/></span> <span class="o_stat_value"><field name="scrap_count"/></span>
+3 -3
View File
@@ -1,12 +1,12 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<odoo> <odoo>
<record model="ir.actions.act_window" id="action_mrp_production_moves"> <record model="ir.actions.act_window" id="action_mrp_production_moves">
<field name="name">Stock Moves</field> <field name="name">Inventory Moves</field>
<field name="type">ir.actions.act_window</field> <field name="type">ir.actions.act_window</field>
<field name="res_model">stock.move</field> <field name="res_model">stock.move.line</field>
<field name="view_type">form</field> <field name="view_type">form</field>
<field name="view_mode">tree,form</field> <field name="view_mode">tree,form</field>
<field name="domain">['|', ('raw_material_production_id', '=', active_id), ('production_id', '=', active_id)]</field> <field name="domain">['|', ('move_id.raw_material_production_id', '=', active_id), ('move_id.production_id', '=', active_id)]</field>
</record> </record>
<record id="view_stock_move_lots" model="ir.ui.view"> <record id="view_stock_move_lots" model="ir.ui.view">
+91 -54
View File
@@ -17,53 +17,61 @@ class MrpProductProduce(models.TransientModel):
res = super(MrpProductProduce, self).default_get(fields) res = super(MrpProductProduce, self).default_get(fields)
if self._context and self._context.get('active_id'): if self._context and self._context.get('active_id'):
production = self.env['mrp.production'].browse(self._context['active_id']) production = self.env['mrp.production'].browse(self._context['active_id'])
#serial_raw = production.move_raw_ids.filtered(lambda x: x.product_id.tracking == 'serial')
main_product_moves = production.move_finished_ids.filtered(lambda x: x.product_id.id == production.product_id.id)
serial_finished = (production.product_id.tracking == 'serial') serial_finished = (production.product_id.tracking == 'serial')
serial = bool(serial_finished)
if serial_finished: if serial_finished:
quantity = 1.0 todo_quantity = 1.0
else: else:
quantity = production.product_qty - sum(main_product_moves.mapped('quantity_done')) main_product_moves = production.move_finished_ids.filtered(lambda x: x.product_id.id == production.product_id.id)
quantity = quantity if (quantity > 0) else 0 todo_quantity = production.product_qty - sum(main_product_moves.mapped('quantity_done'))
lines = [] todo_quantity = todo_quantity if (todo_quantity > 0) else 0
existing_lines = [] if 'production_id' in fields:
for move in production.move_raw_ids.filtered(lambda x: (x.product_id.tracking != 'none') and x.state not in ('done', 'cancel')): res['production_id'] = production.id
if not move.move_line_ids.filtered(lambda x: not x.lot_produced_id): if 'product_id' in fields:
qty = quantity / move.bom_line_id.bom_id.product_qty * move.bom_line_id.product_qty res['product_id'] = production.product_id.id
if move.product_id.tracking == 'serial': if 'product_uom_id' in fields:
while float_compare(qty, 0.0, precision_rounding=move.product_uom.rounding) > 0: res['product_uom_id'] = production.product_uom_id.id
if 'serial' in fields:
res['serial'] = bool(serial_finished)
if 'product_qty' in fields:
res['product_qty'] = todo_quantity
if 'produce_line_ids' in fields:
lines = []
for move in production.move_raw_ids.filtered(lambda x: (x.product_id.tracking != 'none') and x.state not in ('done', 'cancel')):
qty_to_consume = todo_quantity / move.bom_line_id.bom_id.product_qty * move.bom_line_id.product_qty
for move_line in move.move_line_ids:
if float_compare(qty_to_consume, 0.0, precision_rounding=move.product_uom.rounding) < 0:
break
to_consume_in_line = min(qty_to_consume, move_line.product_uom_qty)
lines.append({
'move_id': move.id,
'qty_to_consume': to_consume_in_line,
'qty_done': 0.0,
'lot_id': move_line.lot_id.id,
'product_uom_id': move.product_uom.id,
'product_id': move.product_id.id,
})
qty_to_consume -= to_consume_in_line
if float_compare(qty_to_consume, 0.0, precision_rounding=move.product_uom.rounding) > 0:
if move.product_id.tracking == 'serial':
while float_compare(qty_to_consume, 0.0, precision_rounding=move.product_uom.rounding) > 0:
lines.append({
'move_id': move.id,
'qty_to_consume': 1,
'qty_done': 0.0,
'product_uom_id': move.product_uom.id,
'product_id': move.product_id.id,
})
qty_to_consume -= 1
else:
lines.append({ lines.append({
'move_id': move.id, 'move_id': move.id,
'product_qty': min(1,qty), 'qty_to_consume': qty_to_consume,
'qty_done': 0.0, 'qty_done': 0.0,
'product_uom_id': move.product_uom.id, 'product_uom_id': move.product_uom.id,
'product_id': move.product_id.id, 'product_id': move.product_id.id,
'production_id': production.id,
'location_id': move.location_id.id,
'location_dest_id': move.location_dest_id.id,
}) })
qty -= 1
else:
lines.append({
'move_id': move.id,
'product_qty': qty,
'qty_done': 0.0,
'product_uom_id': move.product_uom.id,
'product_id': move.product_id.id,
'production_id': production.id,
'location_id': move.location_id.id,
'location_dest_id': move.location_dest_id.id,
})
else:
existing_lines += move.move_line_ids.filtered(lambda x: not x.lot_produced_id).ids
res['serial'] = serial res['produce_line_ids'] = [(0, 0, x) for x in lines]
res['production_id'] = production.id
res['product_qty'] = quantity
res['product_id'] = production.product_id.id
res['product_uom_id'] = production.product_uom_id.id
res['consume_line_ids'] = (existing_lines and [(6, 0, [x for x in existing_lines])] or []) + [(0, 0, x) for x in lines]
return res return res
serial = fields.Boolean('Requires Serial') serial = fields.Boolean('Requires Serial')
@@ -72,7 +80,7 @@ class MrpProductProduce(models.TransientModel):
product_qty = fields.Float(string='Quantity', digits=dp.get_precision('Product Unit of Measure'), required=True) product_qty = fields.Float(string='Quantity', digits=dp.get_precision('Product Unit of Measure'), required=True)
product_uom_id = fields.Many2one('product.uom', 'Unit of Measure') product_uom_id = fields.Many2one('product.uom', 'Unit of Measure')
lot_id = fields.Many2one('stock.production.lot', string='Lot') lot_id = fields.Many2one('stock.production.lot', string='Lot')
consume_line_ids = fields.Many2many('stock.move.line', 'mrp_produce_stock_move_line', string='Product to Track') produce_line_ids = fields.One2many('mrp.product.produce.line', 'product_produce_id', string='Product to Track')
product_tracking = fields.Selection(related="product_id.tracking") product_tracking = fields.Selection(related="product_id.tracking")
@api.multi @api.multi
@@ -104,14 +112,13 @@ class MrpProductProduce(models.TransientModel):
@api.multi @api.multi
def check_finished_move_lots(self): def check_finished_move_lots(self):
packs = self.env['stock.move.line']
produce_move = self.production_id.move_finished_ids.filtered(lambda x: x.product_id == self.product_id and x.state not in ('done', 'cancel')) produce_move = self.production_id.move_finished_ids.filtered(lambda x: x.product_id == self.product_id and x.state not in ('done', 'cancel'))
if produce_move and produce_move.product_id.tracking != 'none': if produce_move and produce_move.product_id.tracking != 'none':
if not self.lot_id: if not self.lot_id:
raise UserError(_('You need to provide a lot for the finished product')) raise UserError(_('You need to provide a lot for the finished product'))
existing_move_line = produce_move.move_line_ids.filtered(lambda x: x.lot_id == self.lot_id) existing_move_line = produce_move.move_line_ids.filtered(lambda x: x.lot_id == self.lot_id)
if existing_move_line: if existing_move_line:
existing_move_line.product_qty += self.product_qty existing_move_line.product_uom_qty += self.product_qty
existing_move_line.qty_done += self.product_qty existing_move_line.qty_done += self.product_qty
else: else:
vals = { vals = {
@@ -125,18 +132,48 @@ class MrpProductProduce(models.TransientModel):
'location_id': produce_move.location_id.id, 'location_id': produce_move.location_id.id,
'location_dest_id': produce_move.location_dest_id.id, 'location_dest_id': produce_move.location_dest_id.id,
} }
packs.create(vals) self.env['stock.move.line'].create(vals)
for move in self.production_id.move_raw_ids:
for moveline in move.move_line_ids.filtered(lambda x: not x.lot_produced_id): for pl in self.produce_line_ids:
if moveline.qty_done and self.lot_id: if pl.qty_done and pl.lot_id:
#Possibly the entire move is selected ml = pl.move_id.move_line_ids.filtered(lambda ml: ml.lot_id == pl.lot_id and not ml.lot_produced_id)
remaining_qty = moveline.product_uom_qty - moveline.qty_done if ml:
if remaining_qty > 0: if (ml.qty_done + pl.qty_done) >= ml.product_uom_qty:
default = {'product_uom_qty': moveline.qty_done, ml.write({'qty_done': ml.qty_done + pl.qty_done, 'lot_produced_id': self.lot_id.id})
'qty_done': moveline.qty_done, else:
'lot_produced_id': self.lot_id.id} new_qty_todo = ml.product_uom_qty - (ml.qty_done + pl.qty_done)
new_move_line = moveline.copy(default=default) default = {'product_uom_qty': ml.qty_done + pl.qty_done,
moveline.with_context(bypass_reservation_update=True).write({'product_uom_qty': remaining_qty, 'qty_done': 0}) 'qty_done': ml.qty_done + pl.qty_done,
else: 'lot_produced_id': self.lot_id.id}
moveline.write({'lot_produced_id': self.lot_id.id}) ml.copy(default=default)
ml.with_context(bypass_reservation_update=True).write({'product_uom_qty': new_qty_todo, 'qty_done': 0})
else:
self.env['stock.move.line'].create({
'move_id': pl.move_id.id,
'product_id': pl.product_id.id,
'location_id': pl.move_id.location_id.id,
'location_dest_id': pl.move_id.location_dest_id.id,
'product_uom_qty': 0,
'product_uom_id': pl.product_uom_id.id,
'qty_done': pl.qty_done,
'lot_id': pl.lot_id.id,
'lot_produced_id': self.lot_id.id,
})
return True return True
class MrpProductProduceLine(models.TransientModel):
_name = "mrp.product.produce.line"
_description = "Record Production Line"
product_produce_id = fields.Many2one('mrp.product.produce')
product_id = fields.Many2one('product.product', 'Product')
lot_id = fields.Many2one('stock.production.lot', 'Lot')
qty_to_consume = fields.Float('To Consume')
product_uom_id = fields.Many2one('product.uom', 'Unit of Measure')
qty_done = fields.Float('Done')
move_id = fields.Many2one('stock.move')
@api.onchange('lot_id')
def _onchange_lot_id(self):
if self.product_id.tracking == 'serial':
self.qty_done = 1
@@ -14,20 +14,20 @@
<label for="product_qty"/> <label for="product_qty"/>
<div class="o_row"> <div class="o_row">
<field name="product_qty" attrs="{'readonly': [('serial', '=', True)]}"/> <field name="product_qty" attrs="{'readonly': [('serial', '=', True)]}"/>
<field name="product_uom_id" readonly="1"/> <field name="product_uom_id" readonly="1" groups="product.group_uom"/>
</div> </div>
<field name="product_tracking" invisible="1"/> <field name="product_tracking" invisible="1"/>
<field name="lot_id" attrs="{'invisible': [('product_tracking', '=', 'none')], 'required': [('product_tracking', '!=', 'none')]}" domain="[('product_id','=',product_id)]" context="{'default_product_id': product_id}"/> <field name="lot_id" attrs="{'invisible': [('product_tracking', '=', 'none')], 'required': [('product_tracking', '!=', 'none')]}" domain="[('product_id','=',product_id)]" context="{'default_product_id': product_id}"/>
</group> </group>
</group> </group>
<group> <group>
<field name="consume_line_ids" attrs="{'invisible': [('consume_line_ids', '=', [])]}" nolabel="1" context="{'w_production': True, 'active_id': production_id, 'default_lot_id': lot_id}"> <field name="produce_line_ids" attrs="{'invisible': [('produce_line_ids', '=', [])]}" nolabel="1" context="{'w_production': True, 'active_id': production_id, 'default_lot_id': lot_id}">
<tree editable="top" delete="0" create="0"> <tree editable="top" delete="0" create="0">
<field name="product_id" readonly="1"/> <field name="product_id" readonly="1" force_save="1"/>
<field name="lot_id" context="{'default_product_id': product_id}" domain="[('product_id', '=', product_id)]"/> <field name="lot_id" context="{'default_product_id': product_id}" domain="[('product_id', '=', product_id)]"/>
<field name="product_uom_qty" string="To Consume"/> <field name="qty_to_consume" readonly="1"/>
<field name="product_qty" readonly="1" string="Reserved"/>
<field name="qty_done"/> <field name="qty_done"/>
<field name="product_uom_id" readonly="1" force_save="1" groups="product.group_uom"/>
<field name="move_id" invisible="1"/> <field name="move_id" invisible="1"/>
</tree> </tree>
</field> </field>
+1 -1
View File
@@ -22,7 +22,7 @@ class Repair(models.Model):
@api.model @api.model
def _default_stock_location(self): def _default_stock_location(self):
warehouse = self.env.ref('stock.warehouse0', raise_if_not_found=False) warehouse = self.env['stock.warehouse'].search([], limit=1)
if warehouse: if warehouse:
return warehouse.lot_stock_id.id return warehouse.lot_stock_id.id
return False return False
+1 -1
View File
@@ -497,7 +497,7 @@ class SaleOrder(models.Model):
}) })
if self.env.context.get('send_email'): if self.env.context.get('send_email'):
self.force_quotation_send() self.force_quotation_send()
if self.env['ir.config_parameter'].get_param('sale.auto_done_setting'): if self.env['ir.config_parameter'].sudo().get_param('sale.auto_done_setting'):
self.action_done() self.action_done()
return True return True
@@ -67,7 +67,6 @@
} }
&.o_kanban_grouped { &.o_kanban_grouped {
.o-flex-flow(row, wrap);
.o_kanban_record { .o_kanban_record {
min-width: inherit; min-width: inherit;
} }
+22 -7
View File
@@ -121,15 +121,15 @@ class Product(models.Model):
Quant = self.env['stock.quant'] Quant = self.env['stock.quant']
domain_move_in_todo = [('state', 'not in', ('done', 'cancel', 'draft'))] + domain_move_in domain_move_in_todo = [('state', 'not in', ('done', 'cancel', 'draft'))] + domain_move_in
domain_move_out_todo = [('state', 'not in', ('done', 'cancel', 'draft'))] + domain_move_out domain_move_out_todo = [('state', 'not in', ('done', 'cancel', 'draft'))] + domain_move_out
moves_in_res = dict((item['product_id'][0], item['product_qty']) for item in Move.read_group(domain_move_in_todo, ['product_id', 'product_qty'], ['product_id'])) moves_in_res = dict((item['product_id'][0], item['product_qty']) for item in Move.read_group(domain_move_in_todo, ['product_id', 'product_qty'], ['product_id'], orderby='id'))
moves_out_res = dict((item['product_id'][0], item['product_qty']) for item in Move.read_group(domain_move_out_todo, ['product_id', 'product_qty'], ['product_id'])) moves_out_res = dict((item['product_id'][0], item['product_qty']) for item in Move.read_group(domain_move_out_todo, ['product_id', 'product_qty'], ['product_id'], orderby='id'))
quants_res = dict((item['product_id'][0], item['quantity']) for item in Quant.read_group(domain_quant, ['product_id', 'quantity'], ['product_id'])) quants_res = dict((item['product_id'][0], item['quantity']) for item in Quant.read_group(domain_quant, ['product_id', 'quantity'], ['product_id'], orderby='id'))
if dates_in_the_past: if dates_in_the_past:
# Calculate the moves that were done before now to calculate back in time (as most questions will be recent ones) # Calculate the moves that were done before now to calculate back in time (as most questions will be recent ones)
domain_move_in_done = [('state', '=', 'done'), ('date', '>', to_date)] + domain_move_in_done domain_move_in_done = [('state', '=', 'done'), ('date', '>', to_date)] + domain_move_in_done
domain_move_out_done = [('state', '=', 'done'), ('date', '>', to_date)] + domain_move_out_done domain_move_out_done = [('state', '=', 'done'), ('date', '>', to_date)] + domain_move_out_done
moves_in_res_past = dict((item['product_id'][0], item['product_qty']) for item in Move.read_group(domain_move_in_done, ['product_id', 'product_qty'], ['product_id'])) moves_in_res_past = dict((item['product_id'][0], item['product_qty']) for item in Move.read_group(domain_move_in_done, ['product_id', 'product_qty'], ['product_id'], orderby='id'))
moves_out_res_past = dict((item['product_id'][0], item['product_qty']) for item in Move.read_group(domain_move_out_done, ['product_id', 'product_qty'], ['product_id'])) moves_out_res_past = dict((item['product_id'][0], item['product_qty']) for item in Move.read_group(domain_move_out_done, ['product_id', 'product_qty'], ['product_id'], orderby='id'))
res = dict() res = dict()
for product in self.with_context(prefetch_fields=False): for product in self.with_context(prefetch_fields=False):
@@ -272,7 +272,7 @@ class Product(models.Model):
domain_quant.append(('owner_id', '=', owner_id)) domain_quant.append(('owner_id', '=', owner_id))
if package_id: if package_id:
domain_quant.append(('package_id', '=', package_id)) domain_quant.append(('package_id', '=', package_id))
quants_groupby = self.env['stock.quant'].read_group(domain_quant, ['product_id', 'quantity'], ['product_id']) quants_groupby = self.env['stock.quant'].read_group(domain_quant, ['product_id', 'quantity'], ['product_id'], orderby='id')
for quant in quants_groupby: for quant in quants_groupby:
if OPERATORS[operator](quant['quantity'], value): if OPERATORS[operator](quant['quantity'], value):
product_ids.add(quant['product_id'][0]) product_ids.add(quant['product_id'][0])
@@ -359,6 +359,14 @@ class Product(models.Model):
action['domain'] = [('product_id', '=', self.id)] action['domain'] = [('product_id', '=', self.id)]
return action return action
@api.multi
def action_open_product_lot(self):
self.ensure_one()
action = self.env.ref('stock.action_production_lot_form').read()[0]
action['domain'] = [('product_id', '=', self.id)]
action['context'] = {'default_product_id': self.id}
return action
@api.multi @api.multi
def write(self, values): def write(self, values):
res = super(Product, self).write(values) res = super(Product, self).write(values)
@@ -512,7 +520,7 @@ class ProductTemplate(models.Model):
products = self.mapped('product_variant_ids') products = self.mapped('product_variant_ids')
action = self.env.ref('stock.product_open_quants').read()[0] action = self.env.ref('stock.product_open_quants').read()[0]
action['domain'] = [('product_id', 'in', products.ids)] action['domain'] = [('product_id', 'in', products.ids)]
action['context'] = {'search_default_locationgroup': 1, 'search_default_internal_loc': 1} action['context'] = {'search_default_internal_loc': 1}
return action return action
@api.multi @api.multi
@@ -533,6 +541,13 @@ class ProductTemplate(models.Model):
action['domain'] = [('product_id.product_tmpl_id', 'in', self.ids)] action['domain'] = [('product_id.product_tmpl_id', 'in', self.ids)]
return action return action
@api.multi
def action_open_product_lot(self):
self.ensure_one()
action = self.env.ref('stock.action_production_lot_form').read()[0]
action['domain'] = [('product_id.product_tmpl_id', '=', self.id)]
action['context'] = {}
return action
class ProductCategory(models.Model): class ProductCategory(models.Model):
_inherit = 'product.category' _inherit = 'product.category'
+1 -10
View File
@@ -17,15 +17,11 @@ class PutAwayStrategy(models.Model):
_description = 'Put Away Strategy' _description = 'Put Away Strategy'
name = fields.Char('Name', required=True) name = fields.Char('Name', required=True)
method = fields.Selection('_get_putaway_options', "Method", default='fixed', required=True)
fixed_location_ids = fields.One2many( fixed_location_ids = fields.One2many(
'stock.fixed.putaway.strat', 'putaway_id', 'Fixed Locations Per Product Category', copy=True, 'stock.fixed.putaway.strat', 'putaway_id', 'Fixed Locations Per Product Category', copy=True,
help="When the method is fixed, this location will be used to store the products") help="When the method is fixed, this location will be used to store the products")
def _get_putaway_options(self): def putaway_apply(self, product):
return [('fixed', 'Fixed Location')]
def _putaway_apply_fixed(self, product):
for strat in self.fixed_location_ids: for strat in self.fixed_location_ids:
categ = product.categ_id categ = product.categ_id
while categ: while categ:
@@ -34,11 +30,6 @@ class PutAwayStrategy(models.Model):
categ = categ.parent_id categ = categ.parent_id
return self.env['stock.location'] return self.env['stock.location']
def putaway_apply(self, product):
if hasattr(self, '_putaway_apply_%s' % (self.method)):
return getattr(self, '_putaway_apply_%s' % (self.method))(product)
return self.env['stock.location']
class FixedPutAwayStrategy(models.Model): class FixedPutAwayStrategy(models.Model):
_name = 'stock.fixed.putaway.strat' _name = 'stock.fixed.putaway.strat'
+2 -2
View File
@@ -713,7 +713,7 @@ class StockMove(models.Model):
if move.location_id.usage in ('supplier', 'inventory', 'production', 'customer')\ if move.location_id.usage in ('supplier', 'inventory', 'production', 'customer')\
or move.product_id.type == 'consu': or move.product_id.type == 'consu':
# create the move line(s) but do not impact quants # create the move line(s) but do not impact quants
if move.product_id.tracking == 'serial': if move.product_id.tracking == 'serial' and (move.picking_type_id.use_create_lots or move.picking_type_id.use_existing_lots):
for i in range(0, int(move.product_qty)): for i in range(0, int(move.product_qty)):
self.env['stock.move.line'].create(move._prepare_move_line_vals(quantity=1)) self.env['stock.move.line'].create(move._prepare_move_line_vals(quantity=1))
else: else:
@@ -886,7 +886,7 @@ class StockMove(models.Model):
qty_split = move.product_uom._compute_quantity(move.product_uom_qty - move.quantity_done, move.product_id.uom_id) qty_split = move.product_uom._compute_quantity(move.product_uom_qty - move.quantity_done, move.product_id.uom_id)
new_move = move.split(qty_split) new_move = move.split(qty_split)
for move_line in move.move_line_ids: for move_line in move.move_line_ids:
if move_line.product_qty: if move_line.product_qty and move_line.qty_done:
# FIXME: there will be an issue if the move was partially available # FIXME: there will be an issue if the move was partially available
# By decreasing `product_qty`, we free the reservation. # By decreasing `product_qty`, we free the reservation.
# FIXME: if qty_done > product_qty, this could raise if nothing is in stock # FIXME: if qty_done > product_qty, this could raise if nothing is in stock
+1 -1
View File
@@ -46,7 +46,7 @@ class PickingType(models.Model):
'Show Detailed Operations', default=False, 'Show Detailed Operations', default=False,
help="If this checkbox is ticked, the pickings lines will represent detailed stock operations. If not, the picking lines will represent an aggregate of detailed stock operations.") help="If this checkbox is ticked, the pickings lines will represent detailed stock operations. If not, the picking lines will represent an aggregate of detailed stock operations.")
show_reserved = fields.Boolean( show_reserved = fields.Boolean(
'Show Reserved', default=True) 'Show Reserved', default=True, help="If this checkbox is ticked, Odoo will show which products are reserved (lot/serial number, source location, source package).")
# Statistics for the kanban view # Statistics for the kanban view
last_done_picking = fields.Char('Last 10 Done Pickings', compute='_compute_last_done_picking') last_done_picking = fields.Char('Last 10 Done Pickings', compute='_compute_last_done_picking')
+20 -6
View File
@@ -126,7 +126,7 @@ class StockQuant(models.Model):
return self.search(domain, order=removal_strategy_order) return self.search(domain, order=removal_strategy_order)
@api.model @api.model
def _get_available_quantity(self, product_id, location_id, lot_id=None, package_id=None, owner_id=None, strict=False): def _get_available_quantity(self, product_id, location_id, lot_id=None, package_id=None, owner_id=None, strict=False, allow_negative=False):
""" Return the available quantity, i.e. the sum of `quantity` minus the sum of """ Return the available quantity, i.e. the sum of `quantity` minus the sum of
`reserved_quantity`, for the set of quants sharing the combination of `product_id, `reserved_quantity`, for the set of quants sharing the combination of `product_id,
location_id` if `strict` is set to False or sharing the *exact same characteristics* location_id` if `strict` is set to False or sharing the *exact same characteristics*
@@ -146,7 +146,23 @@ class StockQuant(models.Model):
""" """
self = self.sudo() self = self.sudo()
quants = self._gather(product_id, location_id, lot_id=lot_id, package_id=package_id, owner_id=owner_id, strict=strict) quants = self._gather(product_id, location_id, lot_id=lot_id, package_id=package_id, owner_id=owner_id, strict=strict)
return sum(quants.mapped('quantity')) - sum(quants.mapped('reserved_quantity')) if product_id.tracking == 'none':
available_quantity = sum(quants.mapped('quantity')) - sum(quants.mapped('reserved_quantity'))
if allow_negative:
return available_quantity
else:
return available_quantity if available_quantity >= 0.0 else 0.0
else:
availaible_quantities = {lot_id: 0.0 for lot_id in list(set(quants.mapped('lot_id'))) + ['untracked']}
for quant in quants:
if not quant.lot_id:
availaible_quantities['untracked'] += quant.quantity - quant.reserved_quantity
else:
availaible_quantities[quant.lot_id] += quant.quantity - quant.reserved_quantity
if allow_negative:
return sum(availaible_quantities.values())
else:
return sum([available_quantity for available_quantity in availaible_quantities.values() if available_quantity > 0])
@api.model @api.model
def _update_available_quantity(self, product_id, location_id, quantity, lot_id=None, package_id=None, owner_id=None, in_date=None): def _update_available_quantity(self, product_id, location_id, quantity, lot_id=None, package_id=None, owner_id=None, in_date=None):
@@ -206,7 +222,7 @@ class StockQuant(models.Model):
'owner_id': owner_id and owner_id.id, 'owner_id': owner_id and owner_id.id,
'in_date': in_date, 'in_date': in_date,
}) })
return self._get_available_quantity(product_id, location_id, lot_id=lot_id, package_id=package_id, owner_id=owner_id, strict=False), fields.Datetime.from_string(in_date) return self._get_available_quantity(product_id, location_id, lot_id=lot_id, package_id=package_id, owner_id=owner_id, strict=False, allow_negative=True), fields.Datetime.from_string(in_date)
@api.model @api.model
def _update_reserved_quantity(self, product_id, location_id, quantity, lot_id=None, package_id=None, owner_id=None, strict=False): def _update_reserved_quantity(self, product_id, location_id, quantity, lot_id=None, package_id=None, owner_id=None, strict=False):
@@ -223,9 +239,7 @@ class StockQuant(models.Model):
""" """
self = self.sudo() self = self.sudo()
quants = self._gather(product_id, location_id, lot_id=lot_id, package_id=package_id, owner_id=owner_id, strict=strict) quants = self._gather(product_id, location_id, lot_id=lot_id, package_id=package_id, owner_id=owner_id, strict=strict)
available_quantity = self._get_available_quantity(product_id, location_id, lot_id=lot_id, package_id=package_id, owner_id=owner_id, strict=strict)
quants_quantity = sum(quants.mapped('quantity'))
available_quantity = quants_quantity - sum(quants.mapped('reserved_quantity'))
if quantity > 0 and quantity > available_quantity: if quantity > 0 and quantity > available_quantity:
raise UserError(_('It is not possible to reserve more products than you have in stock.')) raise UserError(_('It is not possible to reserve more products than you have in stock.'))
elif quantity < 0 and abs(quantity) > sum(quants.mapped('reserved_quantity')): elif quantity < 0 and abs(quantity) > sum(quants.mapped('reserved_quantity')):
+11 -13
View File
@@ -156,8 +156,7 @@ class MrpStockReport(models.TransientModel):
'model':'stock.move.line', 'model':'stock.move.line',
'product_id': move_line.product_id.display_name, 'product_id': move_line.product_id.display_name,
'product_qty_uom': str(move_line.qty_done) + ' ' + move_line.product_id.uom_id.name, 'product_qty_uom': str(move_line.qty_done) + ' ' + move_line.product_id.uom_id.name,
'location_source': move_line.location_id.name, 'location': move_line.location_id.name + ' -> ' + move_line.location_dest_id.name,
'location_destination': move_line.location_dest_id.name,
'reference_id': ref, 'reference_id': ref,
'res_id': res_id, 'res_id': res_id,
'stream': stream, 'stream': stream,
@@ -166,9 +165,6 @@ class MrpStockReport(models.TransientModel):
def make_dict_head(self, level, parent_id, model=False, stream=False, move_line=False): def make_dict_head(self, level, parent_id, model=False, stream=False, move_line=False):
data = [] data = []
product_id_name = move_line.product_id.display_name
if move_line.lot_id:
product_id_name += ' ('+move_line.lot_id.name+')'
if model == 'stock.move.line': if model == 'stock.move.line':
data = [{ data = [{
'level': level, 'level': level,
@@ -177,9 +173,10 @@ class MrpStockReport(models.TransientModel):
'model_id': move_line.id, 'model_id': move_line.id,
'parent_id': parent_id, 'parent_id': parent_id,
'model': model or 'stock.move.line', 'model': model or 'stock.move.line',
'product_id': product_id_name, 'product_id': move_line.product_id.display_name,
'lot_id': move_line.lot_id.name,
'product_qty_uom': str(move_line.qty_done) + ' ' + move_line.product_id.uom_id.name, 'product_qty_uom': str(move_line.qty_done) + ' ' + move_line.product_id.uom_id.name,
'location_source': move_line.location_dest_id.name, 'location': move_line.location_dest_id.name,
'stream': stream, 'stream': stream,
'reference_id': False}] 'reference_id': False}]
elif model == 'stock.quant': elif model == 'stock.quant':
@@ -190,9 +187,10 @@ class MrpStockReport(models.TransientModel):
'model_id': move_line.id, 'model_id': move_line.id,
'parent_id': parent_id, 'parent_id': parent_id,
'model': model or 'stock.quant', 'model': model or 'stock.quant',
'product_id': product_id_name, 'product_id': move_line.product_id.display_name,
'lot_id': move_line.lot_id.name,
'product_qty_uom': str(move_line.quantity) + ' ' + move_line.product_id.uom_id.name, 'product_qty_uom': str(move_line.quantity) + ' ' + move_line.product_id.uom_id.name,
'location_source': move_line.location_id.name, 'location': move_line.location_id.name,
'stream': stream, 'stream': stream,
'reference_id': False}] 'reference_id': False}]
return data return data
@@ -254,10 +252,10 @@ class MrpStockReport(models.TransientModel):
'res_model': data.get('res_model', False), 'res_model': data.get('res_model', False),
'name': _(data.get('lot_id', False)), 'name': _(data.get('lot_id', False)),
'columns': [data.get('reference_id', False) or data.get('product_id', False), 'columns': [data.get('reference_id', False) or data.get('product_id', False),
data.get('lot_id', False),
data.get('date', False), data.get('date', False),
data.get('product_qty_uom', 0), data.get('product_qty_uom', 0),
data.get('location_source', False), data.get('location', False)],
data.get('location_destination', False)],
'level': level, 'level': level,
'unfoldable': data['unfoldable'], 'unfoldable': data['unfoldable'],
}) })
@@ -315,10 +313,10 @@ class MrpStockReport(models.TransientModel):
'type': 'line', 'type': 'line',
'name': _(data.get('lot_id')), 'name': _(data.get('lot_id')),
'columns': [data.get('reference_id') or data.get('product_id'), 'columns': [data.get('reference_id') or data.get('product_id'),
data.get('lot_id'),
data.get('date'), data.get('date'),
data.get('product_qty_uom', 0), data.get('product_qty_uom', 0),
data.get('location_source'), data.get('location')],
data.get('location_destination')],
'level': data['level'], 'level': data['level'],
'unfoldable': data['unfoldable'], 'unfoldable': data['unfoldable'],
}) })
+119 -10
View File
@@ -74,7 +74,8 @@ class StockMove(TransactionCase):
move1.action_done() move1.action_done()
self.assertEqual(move1.state, 'done') self.assertEqual(move1.state, 'done')
# no quants are created in the supplier location # no quants are created in the supplier location
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product1, self.supplier_location), -100.0) self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product1, self.supplier_location), 0.0)
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product1, self.supplier_location, allow_negative=True), -100.0)
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product1, self.stock_location), 100.0) self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product1, self.stock_location), 100.0)
self.assertEqual(len(self.env['stock.quant']._gather(self.product1, self.supplier_location)), 1.0) self.assertEqual(len(self.env['stock.quant']._gather(self.product1, self.supplier_location)), 1.0)
self.assertEqual(len(self.env['stock.quant']._gather(self.product1, self.stock_location)), 1.0) self.assertEqual(len(self.env['stock.quant']._gather(self.product1, self.stock_location)), 1.0)
@@ -114,8 +115,10 @@ class StockMove(TransactionCase):
self.assertEqual(move_line.product_qty, 0) # change reservation to 0 for done move self.assertEqual(move_line.product_qty, 0) # change reservation to 0 for done move
self.assertEqual(move1.state, 'done') self.assertEqual(move1.state, 'done')
# no quants are created in the supplier location self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product3, self.supplier_location), 0.0)
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product3, self.supplier_location), -5.0) supplier_quants = self.env['stock.quant']._gather(self.product3, self.supplier_location)
self.assertEqual(sum(supplier_quants.mapped('quantity')), -5.0)
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product3, self.stock_location), 5.0) self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product3, self.stock_location), 5.0)
self.assertEqual(len(self.env['stock.quant']._gather(self.product3, self.supplier_location)), 1.0) self.assertEqual(len(self.env['stock.quant']._gather(self.product3, self.supplier_location)), 1.0)
quants = self.env['stock.quant']._gather(self.product3, self.stock_location) quants = self.env['stock.quant']._gather(self.product3, self.stock_location)
@@ -165,7 +168,9 @@ class StockMove(TransactionCase):
self.assertEqual(move1.state, 'done') self.assertEqual(move1.state, 'done')
# Quant balance should result with 5 quant in supplier and stock # Quant balance should result with 5 quant in supplier and stock
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product2, self.supplier_location), -5.0) self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product2, self.supplier_location), 0.0)
supplier_quants = self.env['stock.quant']._gather(self.product2, self.supplier_location)
self.assertEqual(sum(supplier_quants.mapped('quantity')), -5.0)
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product2, self.stock_location), 5.0) self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product2, self.stock_location), 5.0)
self.assertEqual(len(self.env['stock.quant']._gather(self.product2, self.supplier_location)), 5.0) self.assertEqual(len(self.env['stock.quant']._gather(self.product2, self.supplier_location)), 5.0)
@@ -299,7 +304,7 @@ class StockMove(TransactionCase):
self.assertEqual(len(move1.move_line_ids), 2) self.assertEqual(len(move1.move_line_ids), 2)
def test_mixed_tracking_reservation_2(self): def test_mixed_tracking_reservation_2(self):
""" Send products tracked by lot to a customer. In your stock, there two tracked and """ Send products tracked by lot to a customer. In your stock, there are two tracked and
mulitple untracked quants. There should be as many move lines as there are quants mulitple untracked quants. There should be as many move lines as there are quants
reserved. Edit the reserve move lines to set them to new serial numbers, the reservation reserved. Edit the reserve move lines to set them to new serial numbers, the reservation
should stay. Validate and the final quantity in stock should be 0, not negative. should stay. Validate and the final quantity in stock should be 0, not negative.
@@ -315,7 +320,6 @@ class StockMove(TransactionCase):
self.env['stock.quant']._update_available_quantity(self.product2, self.stock_location, 2) self.env['stock.quant']._update_available_quantity(self.product2, self.stock_location, 2)
self.env['stock.quant']._update_available_quantity(self.product2, self.stock_location, 1, lot_id=lot1) self.env['stock.quant']._update_available_quantity(self.product2, self.stock_location, 1, lot_id=lot1)
self.env['stock.quant']._update_available_quantity(self.product2, self.stock_location, 1, lot_id=lot2) self.env['stock.quant']._update_available_quantity(self.product2, self.stock_location, 1, lot_id=lot2)
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product2, self.stock_location), 4.0) self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product2, self.stock_location), 4.0)
# creation # creation
move1 = self.env['stock.move'].create({ move1 = self.env['stock.move'].create({
@@ -685,6 +689,30 @@ class StockMove(TransactionCase):
self.assertEqual(len(self.env['stock.quant']._gather(self.product1, self.stock_location)), 1.0) self.assertEqual(len(self.env['stock.quant']._gather(self.product1, self.stock_location)), 1.0)
self.assertEqual(move1.availability, 50.0) self.assertEqual(move1.availability, 50.0)
def test_availability_3(self):
lot1 = self.env['stock.production.lot'].create({
'name': 'lot1',
'product_id': self.product2.id,
})
lot2 = self.env['stock.production.lot'].create({
'name': 'lot2',
'product_id': self.product2.id,
})
self.env['stock.quant']._update_available_quantity(self.product2, self.stock_location, -1.0, lot_id=lot1)
self.env['stock.quant']._update_available_quantity(self.product2, self.stock_location, 1.0, lot_id=lot2)
move1 = self.env['stock.move'].create({
'name': 'test_availability_3',
'location_id': self.stock_location.id,
'location_dest_id': self.customer_location.id,
'product_id': self.product2.id,
'product_uom': self.uom_unit.id,
'product_uom_qty': 1.0,
})
move1.action_confirm()
move1.action_assign()
self.assertEqual(move1.state, 'assigned')
self.assertEqual(move1.reserved_availability, 1.0)
def test_unreserve_1(self): def test_unreserve_1(self):
""" Check that unreserving a stock move sets the products reserved as available and """ Check that unreserving a stock move sets the products reserved as available and
set the state back to confirmed. set the state back to confirmed.
@@ -1932,7 +1960,8 @@ class StockMove(TransactionCase):
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product1, self.stock_location), 0.0) self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product1, self.stock_location), 0.0)
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product1, shelf1_location), 1.0) self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product1, shelf1_location), 1.0)
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product1, shelf2_location), -1.0) self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product1, shelf2_location), 0.0)
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product1, shelf2_location, allow_negative=True), -1.0)
def test_edit_done_move_line_7(self): def test_edit_done_move_line_7(self):
""" Test that editing a done stock move line linked to an untracked product correctly and """ Test that editing a done stock move line linked to an untracked product correctly and
@@ -2027,8 +2056,10 @@ class StockMove(TransactionCase):
# edit once done, we actually moved 2 products # edit once done, we actually moved 2 products
move1.move_line_ids.qty_done = 2 move1.move_line_ids.qty_done = 2
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product1, shelf1_location), -1.0) self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product1, shelf1_location), 0.0)
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product1, self.stock_location), -1.0) self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product1, shelf1_location, allow_negative=True), -1.0)
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product1, self.stock_location), 0.0)
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product1, self.stock_location, allow_negative=True), -1.0)
self.assertEqual(move1.product_uom_qty, 2.0) self.assertEqual(move1.product_uom_qty, 2.0)
def test_edit_done_move_line_9(self): def test_edit_done_move_line_9(self):
@@ -2243,7 +2274,8 @@ class StockMove(TransactionCase):
self.assertEqual(picking.move_lines.move_line_ids.qty_done, 10.0) self.assertEqual(picking.move_lines.move_line_ids.qty_done, 10.0)
self.assertEqual(picking.move_lines.move_line_ids.product_qty, 0.0) self.assertEqual(picking.move_lines.move_line_ids.product_qty, 0.0)
# Check quants data # Check quants data
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product1, self.stock_location), -10.0) self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product1, self.stock_location), 0.0)
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product1, self.stock_location, allow_negative=True), -10.0)
self.assertEqual(len(self.env['stock.quant']._gather(self.product1, self.stock_location)), 1.0) self.assertEqual(len(self.env['stock.quant']._gather(self.product1, self.stock_location)), 1.0)
def test_immediate_validate_4(self): def test_immediate_validate_4(self):
@@ -2291,6 +2323,50 @@ class StockMove(TransactionCase):
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product1, self.stock_location), 0.0) self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product1, self.stock_location), 0.0)
self.assertEqual(len(self.env['stock.quant']._gather(self.product1, self.stock_location)), 0.0) self.assertEqual(len(self.env['stock.quant']._gather(self.product1, self.stock_location)), 0.0)
def _create_picking_test_immediate_validate_5(self, picking_type_id, product_id):
picking = self.env['stock.picking'].create({
'location_id': self.supplier_location.id,
'location_dest_id': self.stock_location.id,
'picking_type_id': picking_type_id.id,
})
self.env['stock.move'].create({
'name': 'move1',
'location_id': self.supplier_location.id,
'location_dest_id': self.stock_location.id,
'picking_id': picking.id,
'picking_type_id': picking_type_id.id,
'product_id': product_id.id,
'product_uom': self.uom_unit.id,
'product_uom_qty': 5.0,
})
picking.action_confirm()
for line in picking.move_line_ids:
line.qty_done = line.product_uom_qty
return picking
def test_immediate_validate_575487534895(self):
""" Create a picking and simulates validate button effect.
Test that tracked products can be received without specifying a serial
number when the picking type is configured that way.
"""
picking_type_id = self.env.ref('stock.picking_type_in')
product_id = self.product2
self.assertTrue(picking_type_id.use_create_lots or picking_type_id.use_existing_lots)
self.assertEqual(product_id.tracking, 'serial')
picking = self._create_picking_test_immediate_validate_5(picking_type_id, product_id)
# should raise because no serial numbers were specified
self.assertRaises(UserError, picking.do_new_transfer)
picking_type_id.use_create_lots = False
picking_type_id.use_existing_lots = False
picking = self._create_picking_test_immediate_validate_5(picking_type_id, product_id)
picking.do_new_transfer()
self.assertEqual(picking.state, 'done')
def test_immediate_validate_5(self): def test_immediate_validate_5(self):
""" Create a picking and simulates validate button effect. """ Create a picking and simulates validate button effect.
This tests two cases: This tests two cases:
@@ -2727,3 +2803,36 @@ class StockMove(TransactionCase):
self.assertEqual(self.product1.qty_available, 5.0) self.assertEqual(self.product1.qty_available, 5.0)
self.assertEqual(self.product1.with_context(company_owned=True).qty_available, 10.0) self.assertEqual(self.product1.with_context(company_owned=True).qty_available, 10.0)
def test_split_1(self):
""" When we split a move line and having one without quantity done, we want to keep reservation
on the new one as it has not been unreserved during the copy.
"""
move1 = self.env['stock.move'].create({
'name': 'test_split_1',
'location_id': self.stock_location.id,
'location_dest_id': self.customer_location.id,
'product_id': self.product1.id,
'product_uom': self.uom_unit.id,
'product_uom_qty': 10.0,
'picking_type_id': self.env.ref('stock.picking_type_in').id,
})
self.env['stock.quant']._update_available_quantity(self.product1, self.stock_location, 10)
move1.action_confirm()
move1.action_assign()
move_line = move1.move_line_ids
default = {'product_uom_qty': 3,
'qty_done': 3}
move_line.copy(default=default)
move_line.with_context(bypass_reservation_update=True).write({'product_uom_qty': 7, 'qty_done': 0})
move1.action_done()
new_move = self.env['stock.move'].search([('name', '=', 'test_split_1'), ('state', '=', 'confirmed')])
self.assertEqual(move1.move_line_ids.product_uom_qty, 0.0)
self.assertEqual(move1.move_line_ids.qty_done, 3.0)
self.assertEqual(new_move.move_line_ids.product_uom_qty, 7.0)
self.assertEqual(new_move.move_line_ids.qty_done, 0.0)
+4 -2
View File
@@ -622,7 +622,8 @@ class TestSinglePicking(TestStockCommon):
delivery_order.move_lines[0].move_line_ids[0].qty_done = 2 delivery_order.move_lines[0].move_line_ids[0].qty_done = 2
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.productA, pack_location), 0.0) self.assertEqual(self.env['stock.quant']._get_available_quantity(self.productA, pack_location), 0.0)
delivery_order.with_context(debug=True).do_transfer() delivery_order.with_context(debug=True).do_transfer()
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.productA, pack_location), -1.0) self.assertEqual(self.env['stock.quant']._get_available_quantity(self.productA, pack_location), 0.0)
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.productA, pack_location, allow_negative=True), -1.0)
extra_move = delivery_order.move_lines - move1 extra_move = delivery_order.move_lines - move1
extra_move_line = extra_move.move_line_ids[0] extra_move_line = extra_move.move_line_ids[0]
@@ -677,7 +678,8 @@ class TestSinglePicking(TestStockCommon):
delivery_order.move_lines[0].move_line_ids[0].qty_done = 3 delivery_order.move_lines[0].move_line_ids[0].qty_done = 3
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.productA, pack_location), 0.0) self.assertEqual(self.env['stock.quant']._get_available_quantity(self.productA, pack_location), 0.0)
delivery_order.do_transfer() delivery_order.do_transfer()
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.productA, pack_location), -2.0) self.assertEqual(self.env['stock.quant']._get_available_quantity(self.productA, pack_location), 0.0)
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.productA, pack_location, allow_negative=True), -2.0)
extra_move = delivery_order.move_lines - move1 extra_move = delivery_order.move_lines - move1
extra_move_line = extra_move.move_line_ids[0] extra_move_line = extra_move.move_line_ids[0]
+6 -3
View File
@@ -131,7 +131,8 @@ class StockQuant(TransactionCase):
'quantity': 5.0, 'quantity': 5.0,
'reserved_quantity': 0.0, 'reserved_quantity': 0.0,
}) })
self.assertEqual(self.env['stock.quant']._get_available_quantity(product1, stock_location), -5.0) self.assertEqual(self.env['stock.quant']._get_available_quantity(product1, stock_location), 0.0)
self.assertEqual(self.env['stock.quant']._get_available_quantity(product1, stock_location, allow_negative=True), -5.0)
def test_get_available_quantity_7(self): def test_get_available_quantity_7(self):
""" Quantity availability with only one tracked quant in a location. """ Quantity availability with only one tracked quant in a location.
@@ -153,7 +154,8 @@ class StockQuant(TransactionCase):
'reserved_quantity': 20.0, 'reserved_quantity': 20.0,
'lot_id': lot1.id, 'lot_id': lot1.id,
}) })
self.assertEqual(self.env['stock.quant']._get_available_quantity(product1, stock_location, lot_id=lot1), -10.0) self.assertEqual(self.env['stock.quant']._get_available_quantity(product1, stock_location, lot_id=lot1), 0.0)
self.assertEqual(self.env['stock.quant']._get_available_quantity(product1, stock_location, lot_id=lot1, allow_negative=True), -10.0)
def test_get_available_quantity_8(self): def test_get_available_quantity_8(self):
""" Quantity availability with a consumable product. """ Quantity availability with a consumable product.
@@ -282,7 +284,8 @@ class StockQuant(TransactionCase):
'type': 'product', 'type': 'product',
}) })
self.env['stock.quant']._update_available_quantity(product1, stock_location, -1.0) self.env['stock.quant']._update_available_quantity(product1, stock_location, -1.0)
self.assertEqual(self.env['stock.quant']._get_available_quantity(product1, stock_location), -1.0) self.assertEqual(self.env['stock.quant']._get_available_quantity(product1, stock_location), 0.0)
self.assertEqual(self.env['stock.quant']._get_available_quantity(product1, stock_location, allow_negative=True), -1.0)
def test_decrease_available_quantity_2(self): def test_decrease_available_quantity_2(self):
""" Decrease the available quantity when multiple quants are already in a location. """ Decrease the available quantity when multiple quants are already in a location.
+8 -11
View File
@@ -7,18 +7,15 @@
<form string="Putaway"> <form string="Putaway">
<group colspan="4"> <group colspan="4">
<field name="name"/> <field name="name"/>
<field name="method"/>
</group> </group>
<div attrs="{'invisible': [('method', '!=', 'fixed')]}"> <separator string="Fixed Locations Per Categories"/>
<separator string="Fixed Locations Per Categories"/> <field name="fixed_location_ids" colspan="4" nolabel="1">
<field name="fixed_location_ids" colspan="4" nolabel="1"> <tree editable="top">
<tree editable="top"> <field name="sequence" widget='handle'/>
<field name="sequence" widget='handle'/> <field name="category_id"/>
<field name="category_id"/> <field name="fixed_location_id"/>
<field name="fixed_location_id"/> </tree>
</tree> </field>
</field>
</div>
</form> </form>
</field> </field>
</record> </record>
+10 -2
View File
@@ -205,7 +205,7 @@
<span class="o_stat_text">Forecasted</span> <span class="o_stat_text">Forecasted</span>
</div> </div>
</button> </button>
<button string="Inventory Moves" <button string="Product Moves"
type="object" type="object"
name= "action_view_stock_move_lines" name= "action_view_stock_move_lines"
attrs="{'invisible':[('type', 'not in', ['product', 'consu'])]}" attrs="{'invisible':[('type', 'not in', ['product', 'consu'])]}"
@@ -233,6 +233,10 @@
name="action_view_routes" name="action_view_routes"
attrs="{'invisible':[('type', '!=', 'product')]}" attrs="{'invisible':[('type', '!=', 'product')]}"
class="oe_stat_button" icon="fa-cogs" groups="base.group_no_one"/> class="oe_stat_button" icon="fa-cogs" groups="base.group_no_one"/>
<button string="Lot/Serial Number" type="object"
name="action_open_product_lot"
attrs="{'invisible': [('tracking', '=', 'none')]}"
class="oe_stat_button" icon="fa-bars" groups="stock.group_production_lot"/>
</button> </button>
</data> </data>
</field> </field>
@@ -268,7 +272,7 @@
<span class="o_stat_text">Forecasted</span> <span class="o_stat_text">Forecasted</span>
</div> </div>
</button> </button>
<button string="Inventory Moves" type="object" <button string="Product Moves" type="object"
name= "action_view_stock_move_lines" name= "action_view_stock_move_lines"
attrs="{'invisible':[('type', 'not in', ['product', 'consu'])]}" attrs="{'invisible':[('type', 'not in', ['product', 'consu'])]}"
class="oe_stat_button" icon="fa-arrows-v" class="oe_stat_button" icon="fa-arrows-v"
@@ -298,6 +302,10 @@
attrs="{'invisible':[('type', '!=', 'product')]}" attrs="{'invisible':[('type', '!=', 'product')]}"
class="oe_stat_button" icon="fa-cogs" class="oe_stat_button" icon="fa-cogs"
groups="base.group_no_one"/> groups="base.group_no_one"/>
<button string="Lot/Serial Number" type="object"
name="action_open_product_lot"
attrs="{'invisible': [('tracking', '=', 'none')]}"
class="oe_stat_button" icon="fa-bars" groups="stock.group_production_lot"/>
</button> </button>
<!-- change attrs of fields added in view_template_property_form <!-- change attrs of fields added in view_template_property_form
@@ -48,10 +48,10 @@
<thead> <thead>
<tr class="o_report_header"> <tr class="o_report_header">
<th class="o_report_line_header">PRODUCT</th> <th class="o_report_line_header">PRODUCT</th>
<th class="o_report_line_header"></th> <th class="o_report_line_header">LOT/SERIAL NUMBER</th>
<th class="o_report_line_header">DATE</th>
<th class="o_report_line_header">QUANTITY</th> <th class="o_report_line_header">QUANTITY</th>
<th class="o_report_line_header">LOCATION SOURCE</th> <th class="o_report_line_header">LOCATION</th>
<th class="o_report_line_header">LOCATION DESTINATION</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@@ -86,10 +86,10 @@
<thead> <thead>
<tr class="o_report_header"> <tr class="o_report_header">
<th class="o_report_line_header">PRODUCT</th> <th class="o_report_line_header">PRODUCT</th>
<th class="o_report_line_header"></th> <th class="o_report_line_header">LOT/SERIAL NUMBER</th>
<th class="o_report_line_header">DATE</th>
<th class="o_report_line_header">QUANTITY</th> <th class="o_report_line_header">QUANTITY</th>
<th class="o_report_line_header">LOCATION SOURCE</th> <th class="o_report_line_header">LOCATION</th>
<th class="o_report_line_header">LOCATION DESTINATION</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
+1 -1
View File
@@ -355,7 +355,7 @@
</record> </record>
<record id="stock_move_action" model="ir.actions.act_window"> <record id="stock_move_action" model="ir.actions.act_window">
<field name="name">Picking Moves</field> <field name="name">Stock Moves</field>
<field name="res_model">stock.move</field> <field name="res_model">stock.move</field>
<field name="type">ir.actions.act_window</field> <field name="type">ir.actions.act_window</field>
<field name="view_type">form</field> <field name="view_type">form</field>
+18 -13
View File
@@ -185,19 +185,24 @@
<field name="location_id"/> <field name="location_id"/>
</group> </group>
</group> </group>
<notebook> <separator string="Content" />
<page string="Content"> <field name="current_picking_id" invisible="1"/>
<separator string="Products" /> <field name="current_picking_move_line_ids" readonly="1" attrs="{'invisible': [('current_picking_id', '=', False)]}">
<field name="current_picking_id" invisible="1"/> <tree>
<field name="current_picking_move_line_ids" readonly="1" attrs="{'invisible': [('current_picking_id', '=', False)]}"> <field name="product_id"/>
<tree> <field name="lot_id" groups="stock.group_production_lot"/>
<field name="product_id"/> <field name="qty_done" string="Quantity"/>
<field name="qty_done" string="Quantity"/> <field name="product_uom_id" groups="product.group_uom"/>
</tree> </tree>
</field> </field>
<field name="quant_ids" attrs="{'invisible': [('current_picking_id', '=', True)]}"/> <field name="quant_ids" attrs="{'invisible': [('current_picking_id', '=', True)]}">
</page> <tree>
</notebook> <field name="product_id"/>
<field name="lot_id" groups="stock.group_production_lot"/>
<field name="quantity"/>
<field name="product_uom_id" groups="product.group_uom"/>
</tree>
</field>
</sheet> </sheet>
</form> </form>
</field> </field>
+26
View File
@@ -74,6 +74,19 @@ class ProductTemplate(models.Model):
}) })
return accounts return accounts
@api.multi
def action_open_product_moves(self):
self.ensure_one()
action = self.env.ref('stock_account.stock_move_valuation_action').read()[0]
action['domain'] = [('product_id.product_tmpl_id', '=', self.id)]
action['context'] = {
'search_default_outgoing': True,
'search_default_incoming': True,
'search_default_done': True,
'is_avg': self.cost_method == 'average',
}
return action
@api.multi @api.multi
def get_product_accounts(self, fiscal_pos=None): def get_product_accounts(self, fiscal_pos=None):
""" Add the stock journal related to product to the result of super() """ Add the stock journal related to product to the result of super()
@@ -156,6 +169,19 @@ class ProductProduct(models.Model):
moves = StockMove.search(domain) moves = StockMove.search(domain)
product.stock_value = sum(moves.mapped('value')) product.stock_value = sum(moves.mapped('value'))
@api.multi
def action_open_product_moves(self):
self.ensure_one()
action = self.env.ref('stock_account.stock_move_valuation_action').read()[0]
action['domain'] = [('product_id', '=', self.id)]
action['context'] = {
'search_default_outgoing': True,
'search_default_incoming': True,
'search_default_done': True,
'is_avg': self.cost_method == 'average',
}
return action
class ProductCategory(models.Model): class ProductCategory(models.Model):
_inherit = 'product.category' _inherit = 'product.category'
+27 -3
View File
@@ -92,9 +92,11 @@
<field name="model">product.product</field> <field name="model">product.product</field>
<field name="arch" type="xml"> <field name="arch" type="xml">
<tree> <tree>
<field name="name"/> <field name="name" string="Product"/>
<field name="stock_value" sum="Stock Valuation"/> <field name="qty_available" context="{'company_owned': True}" string="Quantity on Hand"/>
<field name="qty_available" context="{'company_owned': True}"/> <field name="uom_id" groups="product.group_uom"/>
<field name="currency_id" invisible="1"/>
<field name="stock_value" sum="Stock Valuation" widget="monetary" string="Total Value"/>
</tree> </tree>
</field> </field>
</record> </record>
@@ -113,5 +115,27 @@
</p> </p>
</field> </field>
</record> </record>
<record id="product_valuation_form_view" model="ir.ui.view">
<field name="name">product.product</field>
<field name="model">product.product</field>
<field name="inherit_id" ref="stock.product_form_view_procurement_button"/>
<field name="arch" type="xml">
<xpath expr="//button[@name='action_view_stock_move_lines']" position="after">
<button string="Inventory Valuation" type="object" name="action_open_product_moves" class="oe_stat_button" icon="fa-bars" attrs="{'invisible': [('cost_method', '=', 'standard')]}"/>
</xpath>
</field>
</record>
<record id="product_template_valuation_form_view" model="ir.ui.view">
<field name="name">product.template</field>
<field name="model">product.template</field>
<field name="inherit_id" ref="stock.product_template_form_view_procurement_button"/>
<field name="arch" type="xml">
<xpath expr="//button[@name='action_view_stock_move_lines']" position="after">
<button string="Inventory Valuation" type="object" name="action_open_product_moves" class="oe_stat_button" icon="fa-bars" attrs="{'invisible': [('cost_method', '=', 'standard')]}"/>
</xpath>
</field>
</record>
</data> </data>
</odoo> </odoo>
@@ -68,13 +68,12 @@
<field name="product_uom_qty" string="Qty"/> <field name="product_uom_qty" string="Qty"/>
<field name="product_uom" options="{'no_open': True, 'no_create': True}" string="Unit of Measure" groups="product.group_uom"/> <field name="product_uom" options="{'no_open': True, 'no_create': True}" string="Unit of Measure" groups="product.group_uom"/>
<field name="price_unit"/> <field name="price_unit"/>
<field name="remaining_qty"/>
<field name="value" sum="Stock Valuation"/> <field name="value" sum="Stock Valuation"/>
<field name="remaining_value" sum="Stock Valuation"/> <field name="remaining_qty" invisible="context.get('is_avg')"/>
<field name="remaining_value" sum="Stock Valuation" invisible="context.get('is_avg')"/>
</tree> </tree>
</field> </field>
</record> </record>
<record id="stock_move_valuation_action" model="ir.actions.act_window"> <record id="stock_move_valuation_action" model="ir.actions.act_window">
<field name="name">Stock Moves</field> <field name="name">Stock Moves</field>
<field name="res_model">stock.move</field> <field name="res_model">stock.move</field>
@@ -877,7 +877,8 @@ var FieldX2Many = AbstractField.extend({
*/ */
_onEditLine: function (ev) { _onEditLine: function (ev) {
ev.stopPropagation(); ev.stopPropagation();
this.renderer.setRowMode(ev.data.recordID, 'edit') var editedRecord = this.value.data[ev.data.index];
this.renderer.setRowMode(editedRecord.id, 'edit')
.done(ev.data.onSuccess); .done(ev.data.onSuccess);
}, },
/** /**
@@ -336,20 +336,30 @@ return core.Class.extend({
attrs.mode = mode; attrs.mode = mode;
if (mode in attrs.views) { if (mode in attrs.views) {
var view = attrs.views[mode]; var view = attrs.views[mode];
// process the default_order, which is like 'name,id desc'
// but we need it like [{name: 'name', asc: true}, {name: 'id', asc: false}]
var defaultOrder = view.arch.attrs.default_order; var defaultOrder = view.arch.attrs.default_order;
if (defaultOrder) { if (defaultOrder) {
// process the default_order, which is like 'name,id desc'
// but we need it like [{name: 'name', asc: true}, {name: 'id', asc: false}]
attrs.orderedBy = _.map(defaultOrder.split(','), function (order) { attrs.orderedBy = _.map(defaultOrder.split(','), function (order) {
order = order.trim().split(' '); order = order.trim().split(' ');
return {name: order[0], asc: order[1] !== 'desc'}; return {name: order[0], asc: order[1] !== 'desc'};
}); });
} else {
// if there is a field with widget `handle`, the x2many
// needs to be ordered by this field to correctly display
// the records
var handleField = _.find(view.arch.children, function (child) {
return child.attrs && child.attrs.widget === 'handle';
});
if (handleField) {
attrs.orderedBy = [{name: handleField.attrs.name, asc: true}];
}
} }
// detect editables list has they behave differently with respect // detect editables list has they behave differently with respect
// to the sorting (changes are not sorted directly) // to the sorting (changes are not sorted directly)
if (mode === 'list' && view.arch.attrs.editable) { if (mode === 'list' && view.arch.attrs.editable) {
attrs.keepChangesUnsorted = true; attrs.keepChangesUnsorted = true;
} }
} }
} }
if (attrs.Widget.prototype.fieldsToFetch) { if (attrs.Widget.prototype.fieldsToFetch) {
@@ -237,7 +237,7 @@ var AbstractView = Class.extend({
} }
defs.push(parent.loadViews( defs.push(parent.loadViews(
field.relation, field.relation,
new Context(context, self.userContext), new Context(context, self.userContext, self.loadParams.context),
[[null, attrs.mode === 'tree' ? 'list' : attrs.mode]]) [[null, attrs.mode === 'tree' ? 'list' : attrs.mode]])
.then(function (views) { .then(function (views) {
for (var viewName in views) { for (var viewName in views) {
@@ -780,6 +780,55 @@ var BasicModel = AbstractModel.extend({
parent.data = _.without(parent.data, elementID); parent.data = _.without(parent.data, elementID);
} }
}, },
/**
* Resequences records.
*
* @param {string} modelName the resIDs model
* @param {Array[integer]} resIDs the new sequence of ids
* @param {string} parentID the localID of the parent
* @param {object} [options]
* @param {integer} [options.offset]
* @param {string} [options.field] the field name used as sequence
* @returns {Deferred<string>} resolves to the local id of the parent
*/
resequence: function (modelName, resIDs, parentID, options) {
options = options || {};
if ((resIDs.length <= 1)) {
return $.when(parentID); // there is nothing to sort
}
var self = this;
var data = this.localData[parentID];
var params = {
model: modelName,
ids: resIDs,
};
if (options.offset) {
params.offset = options.offset;
}
if (options.field) {
params.field = options.field;
}
return this._rpc({
route: '/web/dataset/resequence',
params: params,
})
.then(function () {
data.data = _.sortBy(data.data, function (d) {
return _.indexOf(resIDs, self.localData[d].res_id);
});
data.res_ids = [];
_.each(data.data, function (d) {
var dataPoint = self.localData[d];
if (dataPoint.type === 'record') {
data.res_ids.push(dataPoint.res_id);
} else {
data.res_ids = data.res_ids.concat(dataPoint.res_ids);
}
});
self._updateParentResIDs(data);
return parentID;
});
},
/** /**
* Save a local resource, if needed. This is a complicated operation, * Save a local resource, if needed. This is a complicated operation,
* - it needs to check all changes, * - it needs to check all changes,
@@ -1186,6 +1235,7 @@ var BasicModel = AbstractModel.extend({
record._rawChanges[name] = val; record._rawChanges[name] = val;
return; return;
} }
var oldValue = record._changes[name] || record.data[name];
var id; var id;
if (field.type === 'many2one') { if (field.type === 'many2one') {
id = false; id = false;
@@ -1198,29 +1248,42 @@ var BasicModel = AbstractModel.extend({
var data = _.isArray(val) ? var data = _.isArray(val) ?
{id: val[0], display_name: val[1]} : {id: val[0], display_name: val[1]} :
{id: val}; {id: val};
rec = self._makeDataPoint({ if (!oldValue || (self.localData[oldValue].res_id !== data.id)) {
context: record.context, // only register a change if the value has changed
data: data, rec = self._makeDataPoint({
modelName: field.relation, context: record.context,
parentID: record.id, data: data,
}); modelName: field.relation,
id = rec.id; parentID: record.id,
});
id = rec.id;
record._changes[name] = id;
}
} else {
record._changes[name] = false;
} }
record._changes[name] = id;
} else if (field.type === 'reference') { } else if (field.type === 'reference') {
id = false; id = false;
if (val) { if (val) {
var ref = val.split(','); var ref = val.split(',');
rec = self._makeDataPoint({ var modelName = ref[0];
context: record.context, var resID = parseInt(ref[1]);
data: {id: parseInt(ref[1])}, if (!oldValue || self.localData[oldValue].res_id !== resID ||
modelName: ref[0], self.localData[oldValue].model !== modelName) {
parentID: record.id, // only register a change if the value has changed
}); rec = self._makeDataPoint({
defs.push(self._fetchNameGet(rec)); context: record.context,
id = rec.id; data: {id: parseInt(ref[1])},
modelName: modelName,
parentID: record.id,
});
defs.push(self._fetchNameGet(rec));
id = rec.id;
record._changes[name] = id;
}
} else {
record._changes[name] = id;
} }
record._changes[name] = id;
} else if (field.type === 'one2many' || field.type === 'many2many') { } else if (field.type === 'one2many' || field.type === 'many2many') {
var listId = record._changes[name] || record.data[name]; var listId = record._changes[name] || record.data[name];
var list; var list;
@@ -1240,26 +1303,40 @@ var BasicModel = AbstractModel.extend({
viewType: fieldInfo.viewType, viewType: fieldInfo.viewType,
}); });
} }
// TODO: before registering the changes, verify that the x2many
// value has changed
record._changes[name] = list.id; record._changes[name] = list.id;
var shouldLoad = false; var shouldLoad = false;
list._changes = list._changes || []; list._changes = list._changes || [];
_.each(val, function (command) { _.each(val, function (command) {
var rec;
if (command[0] === 0 || command[0] === 1) { if (command[0] === 0 || command[0] === 1) {
// CREATE or UPDATE // CREATE or UPDATE
var params = { if (command[0] === 1 && command[1]) {
context: list.context, // updating an existing record
fields: list.fields, var recID = _.find(list.data, function (childID) {
fieldsInfo: list.fieldsInfo, var child = self.localData[childID];
modelName: list.model, return child.res_id === command[1];
parentID: list.id, });
viewType: list.viewType, rec = self.localData[recID];
ref: command[1],
};
if (command[0] === 1) {
params.res_id = command[1];
} }
rec = self._makeDataPoint(params); if (!rec) {
list._cache[rec.res_id] = rec.id; var params = {
context: list.context,
fields: list.fields,
fieldsInfo: list.fieldsInfo,
modelName: list.model,
parentID: list.id,
viewType: list.viewType,
ref: command[1],
};
if (command[0] === 1) {
params.res_id = command[1];
}
rec = self._makeDataPoint(params);
list._cache[rec.res_id] = rec.id;
}
list._changes.push({operation: 'ADD', id: rec.id}); list._changes.push({operation: 'ADD', id: rec.id});
if (command[0] === 1) { if (command[0] === 1) {
list._changes.push({operation: 'UPDATE', id: rec.id}); list._changes.push({operation: 'UPDATE', id: rec.id});
@@ -1280,7 +1357,10 @@ var BasicModel = AbstractModel.extend({
defs.push(self._readUngroupedList(list)); defs.push(self._readUngroupedList(list));
} }
} else { } else {
record._changes[name] = self._parseServerValue(field, val); var newValue = self._parseServerValue(field, val);
if (newValue !== oldValue) {
record._changes[name] = newValue;
}
} }
}); });
return $.when.apply($, defs); return $.when.apply($, defs);
@@ -614,7 +614,8 @@ return AbstractModel.extend({
if (this.mapping.all_day && evt[this.mapping.all_day]) { if (this.mapping.all_day && evt[this.mapping.all_day]) {
date_stop.add(1, 'days'); date_stop.add(1, 'days');
} }
var isAllDay = this.fields[this.mapping.date_start].type === 'date' ||
this.mapping.all_day && evt[this.mapping.all_day] || false;
var r = { var r = {
'record': evt, 'record': evt,
'start': date_start, 'start': date_start,
@@ -622,7 +623,7 @@ return AbstractModel.extend({
'r_start': date_start, 'r_start': date_start,
'r_end': date_stop, 'r_end': date_stop,
'title': the_title, 'title': the_title,
'allDay': this.mapping.all_day && evt[this.mapping.all_day] || false, 'allDay': isAllDay,
'id': evt.id, 'id': evt.id,
'attendees':attendees, 'attendees':attendees,
}; };
@@ -110,7 +110,6 @@ var KanbanColumn = Widget.extend({
var record = ui.item.data('record'); var record = ui.item.data('record');
var index = self.records.indexOf(record); var index = self.records.indexOf(record);
record.$el.removeAttr('style'); // jqueryui sortable add display:block inline record.$el.removeAttr('style'); // jqueryui sortable add display:block inline
ui.item.addClass('o_updating');
if (index >= 0) { if (index >= 0) {
if ($.contains(self.$el[0], record.$el[0])) { if ($.contains(self.$el[0], record.$el[0])) {
// resequencing records // resequencing records
@@ -118,6 +117,7 @@ var KanbanColumn = Widget.extend({
} }
} else { } else {
// adding record to this column // adding record to this column
ui.item.addClass('o_updating');
self.trigger_up('kanban_column_add_record', {record: record, ids: self._getIDs()}); self.trigger_up('kanban_column_add_record', {record: record, ids: self._getIDs()});
} }
} }
@@ -179,41 +179,6 @@ var KanbanModel = BasicModel.extend({
} }
return this._super(id, options); return this._super(id, options);
}, },
/**
* Resequences records.
*
* @param {string} modelName
* @param {Array[integer]} resIDs the new sequence of ids
* @param {string]} parentID the localID of the parent
* @returns {Deferred<string>} resolves to the local id of the parent
*/
resequence: function (modelName, resIDs, parentID) {
if ((resIDs.length <= 1)) {
return $.when(parentID); // there is nothing to sort
}
var self = this;
var data = this.localData[parentID];
return this._rpc({
route: '/web/dataset/resequence',
params: {model: modelName, ids: resIDs},
})
.then(function () {
data.data = _.sortBy(data.data, function (d) {
return _.indexOf(resIDs, self.localData[d].res_id);
});
data.res_ids = [];
_.each(data.data, function (d) {
var dataPoint = self.localData[d];
if (dataPoint.type === 'record') {
data.res_ids.push(dataPoint.res_id);
} else {
data.res_ids = data.res_ids.concat(dataPoint.res_ids);
}
});
self._updateParentResIDs(data);
return parentID;
});
},
//-------------------------------------------------------------------------- //--------------------------------------------------------------------------
// Private // Private
@@ -372,9 +372,16 @@ var ListController = BasicController.extend({
* @param {OdooEvent} ev * @param {OdooEvent} ev
*/ */
_onEditLine: function (ev) { _onEditLine: function (ev) {
var self = this;
ev.stopPropagation(); ev.stopPropagation();
this._setMode('edit', ev.data.recordID) this.trigger_up('mutexify', {
.done(ev.data.onSuccess); action: function () {
var record = self.model.get(self.handle);
var editedRecord = record.data[ev.data.index];
self._setMode('edit', editedRecord.id)
.done(ev.data.onSuccess);
},
});
}, },
/** /**
* Opens the Export Dialog * Opens the Export Dialog
@@ -404,23 +411,24 @@ var ListController = BasicController.extend({
* @param {OdooEvent} event * @param {OdooEvent} event
*/ */
_onResequence: function (event) { _onResequence: function (event) {
var data = this.model.get(this.handle); var self = this;
var resIDs = _.map(event.data.rowIDs, function(rowID) {
return _.findWhere(data.data, {id: rowID}).res_id; this.trigger_up('mutexify', {
}) action: function () {
return this._rpc({ var state = self.model.get(self.handle);
route: '/web/dataset/resequence', var resIDs = _.map(event.data.rowIDs, function(rowID) {
params: { return _.findWhere(state.data, {id: rowID}).res_id;
model: this.modelName, });
ids: resIDs, var options = {
offset: event.data.offset, offset: event.data.offset,
field: event.data.handleField, field: event.data.handleField,
};
return self.model.resequence(self.modelName, resIDs, self.handle, options).then(function () {
self._updateEnv();
state = self.model.get(self.handle);
return self.renderer.updateState(state, {noRender: true});
});
}, },
}).then(function () {
data.data = _.sortBy(data.data, function (d) {
return _.indexOf(resIDs, d.res_id);
});
return this.handle;
}); });
}, },
/** /**
@@ -437,11 +437,11 @@ ListRenderer.include({
var self = this; var self = this;
var movedRecordID = ui.item.data('id'); var movedRecordID = ui.item.data('id');
var rowIDs = _.pluck(this.state.data, 'id'); var rowIDs = _.pluck(this.state.data, 'id');
rowIDs = _.without(rowIDs, movedRecordID) rowIDs = _.without(rowIDs, movedRecordID);
rowIDs.splice(ui.item.index(), 0, movedRecordID); rowIDs.splice(ui.item.index(), 0, movedRecordID);
var sequences = _.map(this.state.data, function(record) { var sequences = _.map(this.state.data, function(record) {
return record.data[self.handleField]; return record.data[self.handleField];
}) });
this.trigger_up('resequence', { this.trigger_up('resequence', {
rowIDs: rowIDs, rowIDs: rowIDs,
offset: _.min(sequences), offset: _.min(sequences),
@@ -516,10 +516,9 @@ ListRenderer.include({
var self = this; var self = this;
return this.unselectRow().then(function () { return this.unselectRow().then(function () {
// Notify the controller we want to make a record editable // Notify the controller we want to make a record editable
var record = self.state.data[rowIndex];
var def = $.Deferred(); var def = $.Deferred();
self.trigger_up('edit_line', { self.trigger_up('edit_line', {
recordID: record.id, index: rowIndex,
onSuccess: def.resolve.bind(def), onSuccess: def.resolve.bind(def),
}); });
return def; return def;
@@ -390,7 +390,7 @@ DebugManager.include({
// or m2m // or m2m
if (!value || invisibleOrReadOnly || fieldInfo.type === 'one2many' || if (!value || invisibleOrReadOnly || fieldInfo.type === 'one2many' ||
fieldInfo.type === 'many2many' || fieldInfo.type === 'binary' || fieldInfo.type === 'many2many' || fieldInfo.type === 'binary' ||
fieldsInfo[fieldName].options.isPassword) { fieldsInfo[fieldName].options.isPassword || !_.isEmpty(fieldInfo.depends)) {
return false; return false;
} }
return { return {
@@ -28,6 +28,9 @@
} }
.o_kanban_view.o_kanban_dashboard { .o_kanban_view.o_kanban_dashboard {
// correctly display the no_content_helper in dashboards
.o-flex-flow(row, wrap);
.o_kanban_record { .o_kanban_record {
position: relative; position: relative;
.o-flex-display(); .o-flex-display();
@@ -107,6 +107,8 @@ QUnit.module('relational_fields', {
turtle_qux: {string: "Qux", type: "float", digits: [16,1], required: true, default: 1.5}, turtle_qux: {string: "Qux", type: "float", digits: [16,1], required: true, default: 1.5},
turtle_description: {string: "Description", type: "text"}, turtle_description: {string: "Description", type: "text"},
turtle_trululu: {string: "Trululu", type: "many2one", relation: 'partner'}, turtle_trululu: {string: "Trululu", type: "many2one", relation: 'partner'},
turtle_ref: {string: "Reference", type: 'reference', selection: [
["product", "Product"], ["partner", "Partner"]]},
product_id: {string: "Product", type: "many2one", relation: 'product', required: true}, product_id: {string: "Product", type: "many2one", relation: 'product', required: true},
partner_ids: {string: "Partner", type: "many2many", relation: 'partner'}, partner_ids: {string: "Partner", type: "many2many", relation: 'partner'},
}, },
@@ -126,11 +128,13 @@ QUnit.module('relational_fields', {
}, { }, {
id: 3, id: 3,
display_name: "raphael", display_name: "raphael",
product_id: 37,
turtle_bar: false, turtle_bar: false,
turtle_foo: "kawa", turtle_foo: "kawa",
turtle_int: 21, turtle_int: 21,
turtle_qux: 9.8, turtle_qux: 9.8,
partner_ids: [], partner_ids: [],
turtle_ref: 'product,37',
}], }],
}, },
user: { user: {
@@ -1950,6 +1954,67 @@ QUnit.module('relational_fields', {
form.destroy(); form.destroy();
}); });
QUnit.test('embedded one2many (editable list) with handle widget', function (assert) {
assert.expect(9);
this.data.partner.records[0].p = [1, 2, 4];
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch:'<form string="Partners">' +
'<sheet>' +
'<notebook>' +
'<page string="P page">' +
'<field name="p">' +
'<tree editable="top">' +
'<field name="int_field" widget="handle"/>' +
'<field name="foo"/>' +
'</tree>' +
'</field>' +
'</page>' +
'</notebook>' +
'</sheet>' +
'</form>',
res_id: 1,
});
testUtils.intercept(form, "field_changed", function (event) {
assert.step(event.data.changes.p.data.int_field.toString());
}, true);
assert.strictEqual(form.$('td.o_data_cell:not(.o_handle_cell)').text(), "My little Foo Valueblipyop",
"should have the 3 rows in the correct order");
form.$buttons.find('.o_form_button_edit').click();
assert.strictEqual(form.$('td.o_data_cell:not(.o_handle_cell)').text(), "My little Foo Valueblipyop",
"should still have the 3 rows in the correct order");
// Drag and drop the second line in first position
testUtils.dragAndDrop(
form.$('.ui-sortable-handle').eq(1),
form.$('tbody tr').first(),
{position: 'top'}
);
assert.verifySteps(["0", "1", "2"],
"sequences values should be incremental starting from the previous minimum one");
assert.strictEqual(form.$('td.o_data_cell:not(.o_handle_cell)').text(), "blipMy little Foo Valueyop",
"should have the 3 rows in the new order");
form.$('tbody tr:first td:first').click();
assert.strictEqual(form.$('tbody tr:first td.o_data_cell:not(.o_handle_cell) input').val(), "blip",
"should edit the correct row");
form.$buttons.find('.o_form_button_save').click();
assert.strictEqual(form.$('td.o_data_cell:not(.o_handle_cell)').text(), "blipMy little Foo Valueyop",
"should still have the 3 rows in the new order");
form.destroy();
});
QUnit.test('one2many field when using the pager', function (assert) { QUnit.test('one2many field when using the pager', function (assert) {
assert.expect(13); assert.expect(13);
@@ -3356,6 +3421,72 @@ QUnit.module('relational_fields', {
form.destroy(); form.destroy();
}); });
QUnit.test('one2many and onchange only write modified field', function (assert) {
assert.expect(2);
this.data.partner.onchanges = {
turtles: function (obj) {
obj.turtles = [
[5], // delete all
[1, 3, { // the server returns all fields
display_name: "coucou",
product_id: [37, "xphone"],
turtle_bar: false,
turtle_foo: "has changed",
turtle_int: 42,
turtle_qux: 9.8,
partner_ids: [],
turtle_ref: 'product,37',
}],
];
},
};
this.data.partner.records[0].turtles = [3];
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch:'<form string="Partners">' +
'<field name="foo"/>' +
'<field name="turtles">' +
'<tree editable="bottom">' +
'<field name="display_name"/>' +
'<field name="product_id"/>' +
'<field name="turtle_bar"/>' +
'<field name="turtle_foo"/>' +
'<field name="turtle_int"/>' +
'<field name="turtle_qux"/>' +
'<field name="turtle_ref"/>' +
'</tree>' +
'</field>' +
'</form>',
mockRPC: function (method, args) {
if (args.method === 'write') {
// debugger;
assert.deepEqual(args.args[1].turtles, [
[1, 3, {display_name: 'coucou', turtle_foo: 'has changed', turtle_int: 42}],
], "correct commands should be sent (only send changed values)");
}
return this._super.apply(this, arguments);
},
res_id: 1,
viewOptions: {
mode: 'edit',
},
});
assert.strictEqual(form.$('.o_data_row').length, 1,
"o2m should contain one row");
form.$('.o_field_one2many .o_list_view tbody tr:first td:first').click();
form.$('.o_field_one2many .o_list_view tbody tr:first input:first').val('blurp').trigger('input');
form.$buttons.find('.o_form_button_save').click();
form.destroy();
});
QUnit.test('one2many list (editable): readonly domain is evaluated', function (assert) { QUnit.test('one2many list (editable): readonly domain is evaluated', function (assert) {
assert.expect(2); assert.expect(2);
+16 -7
View File
@@ -57,16 +57,22 @@ var MockServer = Class.extend({
* 'field_view_get' call to the server. Calls processViews() of data_manager * 'field_view_get' call to the server. Calls processViews() of data_manager
* to mimick the real behavior of a call to loadViews(). * to mimick the real behavior of a call to loadViews().
* *
* @param {string|Object} arch a string OR a parsed xml document * @param {Object} params
* @param {string} model a model name (that should be in this.data) * @param {string|Object} params.arch a string OR a parsed xml document
* @param {string} params.model a model name (that should be in this.data)
* @param {Object} params.toolbar the actions possible in the toolbar
* @param {Object} [params.viewOptions] the view options set in the test (optional)
* @returns {Object} an object with 2 keys: arch and fields * @returns {Object} an object with 2 keys: arch and fields
*/ */
fieldsViewGet: function (arch, model, toolbar) { fieldsViewGet: function (params) {
var model = params.model;
var toolbar = params.toolbar;
var viewOptions = params.viewOptions || {};
if (!(model in this.data)) { if (!(model in this.data)) {
throw new Error('Model ' + model + ' was not defined in mock server data'); throw new Error('Model ' + model + ' was not defined in mock server data');
} }
var fields = $.extend(true, {}, this.data[model].fields); var fields = $.extend(true, {}, this.data[model].fields);
var fvg = this._fieldsViewGet(arch, model, fields); var fvg = this._fieldsViewGet(params.arch, model, fields, viewOptions.context);
var fields_views = {}; var fields_views = {};
fields_views[fvg.type] = fvg; fields_views[fvg.type] = fvg;
data_manager.processViews(fields_views, fields); data_manager.processViews(fields_views, fields);
@@ -155,10 +161,11 @@ var MockServer = Class.extend({
* @param {string|Object} arch a string OR a parsed xml document * @param {string|Object} arch a string OR a parsed xml document
* @param {string} model a model name (that should be in this.data) * @param {string} model a model name (that should be in this.data)
* @param {Object} fields * @param {Object} fields
* @param {Object} context
* @returns {Object} an object with 2 keys: arch and fields (the fields * @returns {Object} an object with 2 keys: arch and fields (the fields
* appearing in the views) * appearing in the views)
*/ */
_fieldsViewGet: function (arch, model, fields) { _fieldsViewGet: function (arch, model, fields, context) {
var self = this; var self = this;
var modifiersNames = ['invisible', 'readonly', 'required']; var modifiersNames = ['invisible', 'readonly', 'required'];
var onchanges = this.data[model].onchanges || {}; var onchanges = this.data[model].onchanges || {};
@@ -224,7 +231,8 @@ var MockServer = Class.extend({
} }
_.each(modifiersNames, function (a) { _.each(modifiersNames, function (a) {
if (node.attrs[a]) { if (node.attrs[a]) {
var v = pyeval.py_eval(node.attrs[a]) ? true: false; var pyevalContext = window.py.dict.fromJSON(context || {});
var v = pyeval.py_eval(node.attrs[a], {context: pyevalContext}) ? true: false;
if (inTreeView && a === 'invisible') { if (inTreeView && a === 'invisible') {
modifiers['tree_invisible'] = v; modifiers['tree_invisible'] = v;
} else if (v || !(a in modifiers) || !_.isArray(modifiers[a])) { } else if (v || !(a in modifiers) || !_.isArray(modifiers[a])) {
@@ -252,7 +260,8 @@ var MockServer = Class.extend({
_.each(node.children, function (children) { _.each(node.children, function (children) {
relModel = field.relation; relModel = field.relation;
relFields = $.extend(true, {}, self.data[relModel].fields); relFields = $.extend(true, {}, self.data[relModel].fields);
field.views[children.tag] = self._fieldsViewGet(children, relModel, relFields); field.views[children.tag] = self._fieldsViewGet(children, relModel,
relFields, context);
}); });
} }
@@ -131,7 +131,7 @@ function createAsyncView(params) {
// add mock environment: mock server, session, fieldviewget, ... // add mock environment: mock server, session, fieldviewget, ...
var mockServer = addMockEnvironment(widget, params); var mockServer = addMockEnvironment(widget, params);
var viewInfo = mockServer.fieldsViewGet(params.arch, params.model, params.toolbar); var viewInfo = mockServer.fieldsViewGet(params);
// create the view // create the view
var viewOptions = { var viewOptions = {
@@ -329,10 +329,17 @@ function addMockEnvironment(widget, params) {
var view_type = view_descr[1]; var view_type = view_descr[1];
var key = [model, view_id, view_type].join(','); var key = [model, view_id, view_type].join(',');
var arch = params.archs[key]; var arch = params.archs[key];
var viewParams = {
arch: arch,
model: model,
viewOptions: {
context: event.data.context.eval(),
},
};
if (!arch) { if (!arch) {
throw new Error('No arch found for key ' + key); throw new Error('No arch found for key ' + key);
} }
views[view_type] = mockServer.fieldsViewGet(arch, model); views[view_type] = mockServer.fieldsViewGet(viewParams);
}); });
event.data.on_success(views); event.data.on_success(views);
@@ -1257,6 +1257,37 @@ QUnit.module('Views', {
calendar.destroy(); calendar.destroy();
$view.remove(); $view.remove();
}); });
QUnit.test('set event as all day when field is date', function (assert) {
assert.expect(1);
this.data.event.records[0].start_date = "2016-12-14";
var calendar = createView({
View: CalendarView,
model: 'event',
data: this.data,
arch:
'<calendar class="o_calendar_test" '+
'event_open_popup="true" '+
'date_start="start_date" '+
'all_day="allday" '+
'mode="week" '+
'attendee="partner_ids" '+
'color="partner_id">'+
'<field name="name"/>'+
'<filter name="user_id" avatar_field="image"/>'+
'<field name="partner_ids" write_model="filter_partner" write_field="partner_id"/>'+
'</calendar>',
archs: archs,
viewOptions: {
initialDate: initialDate,
},
});
assert.strictEqual(calendar.$('.fc-day-grid .fc-event-container').length, 1,
"should be one event in the all day row");
calendar.destroy();
});
}); });
}); });
+56 -3
View File
@@ -3921,7 +3921,7 @@ QUnit.module('Views', {
QUnit.test('*_view_ref in context are passed correctly', function (assert) { QUnit.test('*_view_ref in context are passed correctly', function (assert) {
var done = assert.async(); var done = assert.async();
assert.expect(4); assert.expect(3);
createAsyncView({ createAsyncView({
View: FormView, View: FormView,
@@ -3938,8 +3938,6 @@ QUnit.module('Views', {
var context = event.data.context.eval(); var context = event.data.context.eval();
assert.strictEqual(context.tree_view_ref, 'module.tree_view_ref', assert.strictEqual(context.tree_view_ref, 'module.tree_view_ref',
"context should contain tree_view_ref"); "context should contain tree_view_ref");
assert.notOk('some_context' in context,
"should not send record's context to load p's fields_view");
event.data.on_success(); event.data.on_success();
} }
}, },
@@ -5790,6 +5788,61 @@ QUnit.module('Views', {
form.destroy(); form.destroy();
}); });
QUnit.test('process the context for inline subview', function (assert) {
assert.expect(1);
this.data.partner.records[0].p = [2];
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch: '<form string="Partners">' +
'<field name="p">' +
'<tree>' +
'<field name="foo"/>' +
'<field name="bar" invisible="context.get(\'hide_bar\', False)"/>' +
'</tree>' +
'</field>' +
'</form>',
res_id: 1,
viewOptions: {
context: {hide_bar: true},
},
});
assert.strictEqual(form.$('.o_list_view thead tr th').length, 1,
"there should be only one column");
form.destroy();
});
QUnit.test('process the context for subview not inline', function (assert) {
assert.expect(1);
this.data.partner.records[0].p = [2];
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch: '<form string="Partners">' +
'<field name="p"/>' +
'</form>',
archs: {
"partner,false,list": '<tree>' +
'<field name="foo"/>' +
'<field name="bar" invisible="context.get(\'hide_bar\', False)"/>' +
'</tree>',
},
res_id: 1,
viewOptions: {
context: {hide_bar: true},
},
});
assert.strictEqual(form.$('.o_list_view thead tr th').length, 1,
"there should be only one column");
form.destroy();
});
QUnit.test('rainbowman attributes correctly passed on button click', function (assert) { QUnit.test('rainbowman attributes correctly passed on button click', function (assert) {
assert.expect(1); assert.expect(1);
@@ -1801,6 +1801,75 @@ QUnit.module('Views', {
kanban.destroy(); kanban.destroy();
}); });
QUnit.test('resequence a record twice', function (assert) {
assert.expect(10);
this.data.partner.records = [];
var nbResequence = 0;
var kanban = createView({
View: KanbanView,
model: 'partner',
data: this.data,
arch: '<kanban>' +
'<field name="product_id"/>' +
'<templates><t t-name="kanban-box">' +
'<div><field name="display_name"/></div>' +
'</t></templates></kanban>',
groupBy: ['product_id'],
mockRPC: function (route) {
if (route === '/web/dataset/resequence') {
nbResequence++;
return $.when();
}
return this._super.apply(this, arguments);
},
});
kanban.$('.o_column_quick_create').click();
kanban.$('.o_column_quick_create input').val('column1');
kanban.$('.o_column_quick_create button.o_kanban_add').click();
kanban.$('.o_kanban_group:eq(0) .o_kanban_quick_add i').click();
var $quickCreate = kanban.$('.o_kanban_group:eq(0) .o_kanban_quick_create');
$quickCreate.find('input').val('record1');
$quickCreate.find('button.o_kanban_add').click();
kanban.$('.o_kanban_group:eq(0) .o_kanban_quick_add i').click();
$quickCreate = kanban.$('.o_kanban_group:eq(0) .o_kanban_quick_create');
$quickCreate.find('input').val('record2');
$quickCreate.find('button.o_kanban_add').click();
assert.strictEqual(kanban.$('.o_kanban_group:eq(0) .o_kanban_record').length, 2,
"column should contain 2 records");
assert.strictEqual(kanban.$('.o_kanban_group:eq(0) .o_kanban_record:eq(0)').text(), "record2",
"records should be correctly ordered");
assert.strictEqual(kanban.$('.o_kanban_group:eq(0) .o_kanban_record:eq(1)').text(), "record1",
"records should be correctly ordered");
var $record1 = kanban.$('.o_kanban_group:eq(0) .o_kanban_record:eq(1)');
var $record2 = kanban.$('.o_kanban_group:eq(0) .o_kanban_record:eq(0)');
testUtils.dragAndDrop($record1, $record2, {position: 'top'});
assert.strictEqual(kanban.$('.o_kanban_group:eq(0) .o_kanban_record').length, 2,
"column should contain 2 records");
assert.strictEqual(kanban.$('.o_kanban_group:eq(0) .o_kanban_record:eq(0)').text(), "record1",
"records should be correctly ordered");
assert.strictEqual(kanban.$('.o_kanban_group:eq(0) .o_kanban_record:eq(1)').text(), "record2",
"records should be correctly ordered");
testUtils.dragAndDrop($record2, $record1, {position: 'top'});
assert.strictEqual(kanban.$('.o_kanban_group:eq(0) .o_kanban_record').length, 2,
"column should contain 2 records");
assert.strictEqual(kanban.$('.o_kanban_group:eq(0) .o_kanban_record:eq(0)').text(), "record2",
"records should be correctly ordered");
assert.strictEqual(kanban.$('.o_kanban_group:eq(0) .o_kanban_record:eq(1)').text(), "record1",
"records should be correctly ordered");
assert.strictEqual(nbResequence, 2, "should have resequenced twice");
kanban.destroy();
});
QUnit.test('don\'t fold column quick create after creation', function (assert) { QUnit.test('don\'t fold column quick create after creation', function (assert) {
assert.expect(2); assert.expect(2);
+138 -2
View File
@@ -2586,7 +2586,7 @@ QUnit.module('Views', {
assert.strictEqual(list.$('tbody tr:eq(2) td:last').text(), '300', assert.strictEqual(list.$('tbody tr:eq(2) td:last').text(), '300',
"default third record should have amount 300"); "default third record should have amount 300");
assert.strictEqual(list.$('tbody tr:eq(3) td:last').text(), '0', assert.strictEqual(list.$('tbody tr:eq(3) td:last').text(), '0',
"default third record should have amount 0"); "default fourth record should have amount 0");
// Drag and drop the fourth line in second position // Drag and drop the fourth line in second position
testUtils.dragAndDrop( testUtils.dragAndDrop(
@@ -2602,7 +2602,143 @@ QUnit.module('Views', {
assert.strictEqual(list.$('tbody tr:eq(2) td:last').text(), '500', assert.strictEqual(list.$('tbody tr:eq(2) td:last').text(), '500',
"new third record should have amount 500"); "new third record should have amount 500");
assert.strictEqual(list.$('tbody tr:eq(3) td:last').text(), '300', assert.strictEqual(list.$('tbody tr:eq(3) td:last').text(), '300',
"new third record should have amount 300"); "new fourth record should have amount 300");
list.destroy();
});
QUnit.test('editable list with handle widget', function (assert) {
assert.expect(12);
var list = createView({
View: ListView,
model: 'foo',
data: this.data,
arch: '<tree editable="top">' +
'<field name="int_field" widget="handle"/>' +
'<field name="amount" widget="float" digits="[5,0]"/>' +
'</tree>',
mockRPC: function (route, args) {
if (route === '/web/dataset/resequence') {
assert.strictEqual(args.offset, -4,
"should write the sequence starting from the lowest current one");
assert.strictEqual(args.field, 'int_field',
"should write the right field as sequence");
assert.deepEqual(args.ids, [1, 4, 2, 3],
"should write the sequence in correct order");
return $.when();
}
return this._super.apply(this, arguments);
},
});
assert.strictEqual(list.$('tbody tr:eq(0) td:last').text(), '1200',
"default first record should have amount 1200");
assert.strictEqual(list.$('tbody tr:eq(1) td:last').text(), '500',
"default second record should have amount 500");
assert.strictEqual(list.$('tbody tr:eq(2) td:last').text(), '300',
"default third record should have amount 300");
assert.strictEqual(list.$('tbody tr:eq(3) td:last').text(), '0',
"default fourth record should have amount 0");
// Drag and drop the fourth line in second position
testUtils.dragAndDrop(
list.$('.ui-sortable-handle').eq(3),
list.$('tbody tr').first(),
{position: 'bottom'}
);
assert.strictEqual(list.$('tbody tr:eq(0) td:last').text(), '1200',
"new first record should have amount 1200");
assert.strictEqual(list.$('tbody tr:eq(1) td:last').text(), '0',
"new second record should have amount 0");
assert.strictEqual(list.$('tbody tr:eq(2) td:last').text(), '500',
"new third record should have amount 500");
assert.strictEqual(list.$('tbody tr:eq(3) td:last').text(), '300',
"new fourth record should have amount 300");
list.$('tbody tr:eq(1) td:last').click();
assert.strictEqual(list.$('tbody tr:eq(1) td:last input').val(), '0',
"the edited record should be the good one");
list.destroy();
});
QUnit.test('editable list with handle widget with slow network', function (assert) {
assert.expect(15);
var def = $.Deferred();
var list = createView({
View: ListView,
model: 'foo',
data: this.data,
arch: '<tree editable="top">' +
'<field name="int_field" widget="handle"/>' +
'<field name="amount" widget="float" digits="[5,0]"/>' +
'</tree>',
mockRPC: function (route, args) {
if (route === '/web/dataset/resequence') {
assert.strictEqual(args.offset, -4,
"should write the sequence starting from the lowest current one");
assert.strictEqual(args.field, 'int_field',
"should write the right field as sequence");
assert.deepEqual(args.ids, [1, 4, 2, 3],
"should write the sequence in correct order");
return $.when(def);
}
return this._super.apply(this, arguments);
},
});
assert.strictEqual(list.$('tbody tr:eq(0) td:last').text(), '1200',
"default first record should have amount 1200");
assert.strictEqual(list.$('tbody tr:eq(1) td:last').text(), '500',
"default second record should have amount 500");
assert.strictEqual(list.$('tbody tr:eq(2) td:last').text(), '300',
"default third record should have amount 300");
assert.strictEqual(list.$('tbody tr:eq(3) td:last').text(), '0',
"default fourth record should have amount 0");
// drag and drop the fourth line in second position
testUtils.dragAndDrop(
list.$('.ui-sortable-handle').eq(3),
list.$('tbody tr').first(),
{position: 'bottom'}
);
// edit moved row before the end of resequence
list.$('tbody tr:eq(3) td:last').click();
assert.strictEqual(list.$('tbody tr:eq(3) td:last input').length, 0,
"shouldn't edit the line before resequence");
def.resolve();
assert.strictEqual(list.$('tbody tr:eq(3) td:last input').length, 1,
"should edit the line after resequence");
assert.strictEqual(list.$('tbody tr:eq(3) td:last input').val(), '300',
"fourth record should have amount 300");
list.$('tbody tr:eq(3) td:last input').val(301).trigger('input');
list.$('tbody tr:eq(0) td:last').click();
list.$buttons.find('.o_list_button_save').click();
assert.strictEqual(list.$('tbody tr:eq(0) td:last').text(), '1200',
"first record should have amount 1200");
assert.strictEqual(list.$('tbody tr:eq(1) td:last').text(), '0',
"second record should have amount 1");
assert.strictEqual(list.$('tbody tr:eq(2) td:last').text(), '500',
"third record should have amount 500");
assert.strictEqual(list.$('tbody tr:eq(3) td:last').text(), '301',
"fourth record should have amount 301");
list.$('tbody tr:eq(3) td:last').click();
assert.strictEqual(list.$('tbody tr:eq(3) td:last input').val(), '301',
"fourth record should have amount 301");
list.destroy(); list.destroy();
}); });
+17
View File
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details. # Part of Odoo. See LICENSE file for full copyright and licensing details.
import re
import odoo.tests import odoo.tests
@@ -11,3 +12,19 @@ class WebSuite(odoo.tests.HttpCase):
def test_01_js(self): def test_01_js(self):
self.phantom_js('/web/tests?mod=web', "", "", login='admin', timeout=300) self.phantom_js('/web/tests?mod=web', "", "", login='admin', timeout=300)
def test_check_suite(self):
# verify no js test is using `QUnit.only` as it forbid any other test to be executed
re_only = re.compile('QUnit\.only\(')
# As we currently aren't in a request context, we can't render `web.layout`.
# redefinied it as a minimal proxy template.
self.env.ref('web.layout').write({'arch_db': '<t t-name="web.layout"><t t-raw="head"/></t>'})
for asset in self.env['ir.qweb']._get_asset_content('web.qunit_suite', options={})[0]:
filename = asset['filename']
if not filename or asset['atype'] != 'text/javascript':
continue
with open(filename, 'r') as fp:
if re_only.search(fp.read()):
self.fail("`QUnit.only()` used in file %r" % asset['url'])
@@ -496,7 +496,7 @@ dom.removeBetween = function (sc, so, ec, eo, towrite) {
}; };
for (var i=0; i<nodes.length; i++) { for (var i=0; i<nodes.length; i++) {
if (!dom.ancestor(nodes[i], ancestor_first_last) && !$.contains(nodes[i], before) && !$.contains(nodes[i], after)) { if (!dom.ancestor(nodes[i], ancestor_first_last) && !$.contains(nodes[i], before) && !$.contains(nodes[i], after) && !dom.isEditable(nodes[i])) {
nodes[i].parentNode.removeChild(nodes[i]); nodes[i].parentNode.removeChild(nodes[i]);
} }
} }
@@ -1082,6 +1082,9 @@ $.summernote.pluginEvents.enter = function (event, editor, layoutInfo) {
// double enter on the end of a blockquote & pre = new line out of the list // double enter on the end of a blockquote & pre = new line out of the list
$('<p></p>').append(br).insertAfter($(r.sc).closest('blockquote, pre')); $('<p></p>').append(br).insertAfter($(r.sc).closest('blockquote, pre'));
node = br; node = br;
} else if (dom.isEditable(dom.node(r.sc))) {
// if we are directly in an editable, only SHIFT + ENTER should add a newline
node = null;
} else if (last === r.sc) { } else if (last === r.sc) {
if (dom.isBR(last)) { if (dom.isBR(last)) {
last = last.parentNode; last = last.parentNode;
@@ -681,7 +681,7 @@ var VideoDialog = Widget.extend({
if (this.$('.o_video_dialog_iframe').is('iframe')) { if (this.$('.o_video_dialog_iframe').is('iframe')) {
var $content = $( var $content = $(
'<div class="media_iframe_video" data-src="' + this.$content.attr('src') + '">'+ '<div class="media_iframe_video" data-oe-expression="' + this.$content.attr('src') + '">'+
'<div class="css_editable_mode_display">&nbsp;</div>'+ '<div class="css_editable_mode_display">&nbsp;</div>'+
'<div class="media_iframe_video_size" contenteditable="false">&nbsp;</div>'+ '<div class="media_iframe_video_size" contenteditable="false">&nbsp;</div>'+
'<iframe src="' + this.$content.attr('src') + '" frameborder="0" contenteditable="false"></iframe>'+ '<iframe src="' + this.$content.attr('src') + '" frameborder="0" contenteditable="false"></iframe>'+
@@ -1,6 +1,8 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details. # Part of Odoo. See LICENSE file for full copyright and licensing details.
from ast import literal_eval
from odoo import api, fields, models from odoo import api, fields, models
from odoo.exceptions import AccessDenied from odoo.exceptions import AccessDenied
@@ -79,3 +81,10 @@ class ResConfigSettings(models.TransientModel):
set_param('website.has_google_analytics_dashboard', self.has_google_analytics_dashboard) set_param('website.has_google_analytics_dashboard', self.has_google_analytics_dashboard)
set_param('website.has_google_maps', self.has_google_maps) set_param('website.has_google_maps', self.has_google_maps)
set_param('google_maps_api_key', (self.google_maps_api_key or '').strip()) set_param('google_maps_api_key', (self.google_maps_api_key or '').strip())
@api.multi
def open_template_user(self):
action = self.env.ref('base.action_res_users').read()[0]
action['res_id'] = literal_eval(self.env['ir.config_parameter'].sudo().get_param('auth_signup.template_user_id', 'False'))
action['views'] = [[self.env.ref('base.view_users_form').id, 'form']]
return action
@@ -588,7 +588,8 @@ registry.mediaVideo = Animation.extend({
var size = '<div class="media_iframe_video_size">&nbsp;</div>'; var size = '<div class="media_iframe_video_size">&nbsp;</div>';
this.$target.html(editor+size); this.$target.html(editor+size);
} }
this.$target.html(this.$target.html()+'<iframe src="'+_.escape(this.$target.data("src"))+'" frameborder="0" allowfullscreen="allowfullscreen"></iframe>'); // rebuilding the iframe, from https://www.html5rocks.com/en/tutorials/security/sandboxed-iframes/
this.$target.html(this.$target.html()+'<iframe sandbox="allow-scripts allow-same-origin" src="'+_.escape(this.$target.data("oe-expression"))+'" frameborder="0" allowfullscreen="allowfullscreen"></iframe>');
return this._super.apply(this, arguments); return this._super.apply(this, arguments);
}, },
}); });
@@ -148,6 +148,11 @@
<div class="mt8"> <div class="mt8">
<field name="auth_signup_uninvited" widget="radio" required="True"/> <field name="auth_signup_uninvited" widget="radio" required="True"/>
</div> </div>
<div class="content-group">
<div class="mt16">
<button type="object" name="open_template_user" string="Default Access Rights" icon="fa-arrow-right" class="btn-link"/>
</div>
</div>
</div> </div>
</div> </div>
<div class="col-xs-12 col-md-6 o_setting_box" id="cdn_setting" title="A CDN helps you serve your website’s content with high availability and high performance to any visitor wherever they are located." groups="base.group_no_one"> <div class="col-xs-12 col-md-6 o_setting_box" id="cdn_setting" title="A CDN helps you serve your website’s content with high availability and high performance to any visitor wherever they are located." groups="base.group_no_one">
@@ -13,6 +13,9 @@ odoo.define('website_forum.website_forum', function (require) {
return $.Deferred().reject("DOM doesn't contain '.website_forum'"); return $.Deferred().reject("DOM doesn't contain '.website_forum'");
} }
// pull-left class messes up the post layout OPW 769721
$('span[data-oe-model="forum.post"][data-oe-field="content"]').find('img.pull-left').removeClass('pull-left');
$("[data-toggle='popover']").popover(); $("[data-toggle='popover']").popover();
$('.karma_required').on('click', function (ev) { $('.karma_required').on('click', function (ev) {
var karma = $(ev.currentTarget).data('karma'); var karma = $(ev.currentTarget).data('karma');
@@ -410,6 +413,9 @@ odoo.define('website_forum.website_forum', function (require) {
toolbar: toolbar, toolbar: toolbar,
styleWithSpan: false styleWithSpan: false
}); });
// pull-left class messes up the post layout OPW 769721
$form.find('.note-editable').find('img.pull-left').removeClass('pull-left');
$form.on('click', 'button, .a-submit', function () { $form.on('click', 'button, .a-submit', function () {
$textarea.html($form.find('.note-editable').code()); $textarea.html($form.find('.note-editable').code());
}); });
+1 -1
View File
@@ -6,7 +6,7 @@
'website': 'https://www.odoo.com/page/e-commerce', 'website': 'https://www.odoo.com/page/e-commerce',
'version': '1.0', 'version': '1.0',
'description': "", 'description': "",
'depends': ['website', 'sale', 'sale_payment', 'payment', 'website_payment', 'website_mail', 'website_form', 'website_rating'], 'depends': ['website', 'sale_payment', 'website_payment', 'website_mail', 'website_form', 'website_rating'],
'data': [ 'data': [
'security/ir.model.access.csv', 'security/ir.model.access.csv',
'security/website_sale.xml', 'security/website_sale.xml',
+3 -3
View File
@@ -686,11 +686,11 @@ class WebsiteSale(http.Controller):
values = { values = {
'website_sale_order': order, 'website_sale_order': order,
'post': post, 'post': post,
'escape': lambda x: x.replace("'", r"\'") 'escape': lambda x: x.replace("'", r"\'"),
'partner': order.partner_id.id,
'order': order,
} }
values.update(request.env['sale.order']._get_website_data(order))
return request.render("website_sale.extra_info", values) return request.render("website_sale.extra_info", values)
# ------------------------------------------------------ # ------------------------------------------------------
+3 -2
View File
@@ -48,6 +48,7 @@ psycopg2.extensions.register_type(psycopg2.extensions.new_type((700, 701, 1700,)
from . import tools from . import tools
from .tools.func import frame_codeinfo from .tools.func import frame_codeinfo
from .tools import pycompat
from .tools import parse_version as pv from .tools import parse_version as pv
if pv(psycopg2.__version__) < pv('2.7'): if pv(psycopg2.__version__) < pv('2.7'):
@@ -58,8 +59,8 @@ if pv(psycopg2.__version__) < pv('2.7'):
raise ValueError("A string literal cannot contain NUL (0x00) characters.") raise ValueError("A string literal cannot contain NUL (0x00) characters.")
return QuotedString(adapted) return QuotedString(adapted)
psycopg2.extensions.register_adapter(str, adapt_string) for type_ in pycompat.string_types:
psycopg2.extensions.register_adapter(unicode, adapt_string) psycopg2.extensions.register_adapter(type_, adapt_string)
from datetime import timedelta from datetime import timedelta
import threading import threading
+10 -6
View File
@@ -23,9 +23,13 @@ def _convert_nn_fr(val):
if val < 20: if val < 20:
return to_19_fr[val] return to_19_fr[val]
for (dcap, dval) in ((k, 20 + (10 * v)) for (v, k) in enumerate(tens_fr)): for (dcap, dval) in ((k, 20 + (10 * v)) for (v, k) in enumerate(tens_fr)):
if dval + 10 > val: base = 10
if val % 10: if dval in [60, 80]:
return dcap + '-' + to_19_fr[val % 10] base = 20
if dval + base > val:
if val % base:
bond = (val % base in [1, 11] and dval != 80) and '-et-' or '-'
return dcap + bond + to_19_fr[val % base]
return dcap return dcap
def _convert_nnn_fr(val): def _convert_nnn_fr(val):
@@ -38,7 +42,7 @@ def _convert_nnn_fr(val):
word = '' word = ''
(mod, rem) = (val % 100, val // 100) (mod, rem) = (val % 100, val // 100)
if rem > 0: if rem > 0:
word = to_19_fr[rem] + ' Cent' word = (rem > 1 and to_19_fr[rem] + ' cents') or 'Cent'
if mod > 0: if mod > 0:
word += ' ' word += ' '
if mod > 0: if mod > 0:
@@ -93,7 +97,7 @@ def _convert_nn_nl(val):
for (dcap, dval) in ((k, 20 + (10 * v)) for (v, k) in enumerate(tens_nl)): for (dcap, dval) in ((k, 20 + (10 * v)) for (v, k) in enumerate(tens_nl)):
if dval + 10 > val: if dval + 10 > val:
if val % 10: if val % 10:
return dcap + '-' + to_19_nl[val % 10] return to_19_nl[val % 10] + '-en-' + dcap
return dcap return dcap
def _convert_nnn_nl(val): def _convert_nnn_nl(val):
@@ -106,7 +110,7 @@ def _convert_nnn_nl(val):
word = '' word = ''
(mod, rem) = (val % 100, val // 100) (mod, rem) = (val % 100, val // 100)
if rem > 0: if rem > 0:
word = to_19_nl[rem] + ' Honderd' word = (rem > 1 and to_19_nl[rem] + 'honderd') or 'Honderd'
if mod > 0: if mod > 0:
word += ' ' word += ' '
if mod > 0: if mod > 0:
+4
View File
@@ -9,6 +9,8 @@ from PIL import ImageEnhance
from random import randrange from random import randrange
# Preload PIL with the minimal subset of image formats we need # Preload PIL with the minimal subset of image formats we need
from odoo.tools import pycompat
Image.preinit() Image.preinit()
Image._initialized = 2 Image._initialized = 2
@@ -253,6 +255,8 @@ def image_get_resized_images(base64_source, return_big=False, return_medium=True
previous parameters. previous parameters.
""" """
return_dict = dict() return_dict = dict()
if isinstance(base64_source, pycompat.text_type):
base64_source = base64_source.encode('ascii')
if return_big: if return_big:
return_dict[big_name] = image_resize_image_big(base64_source, avoid_if_small=avoid_resize_big) return_dict[big_name] = image_resize_image_big(base64_source, avoid_if_small=avoid_resize_big)
if return_medium: if return_medium:
+7 -7
View File
@@ -24,19 +24,19 @@ _CONFDELTYPES = {
def existing_tables(cr, tablenames): def existing_tables(cr, tablenames):
""" Return the names of existing tables among ``tablenames``. """ """ Return the names of existing tables among ``tablenames``. """
query = """ query = """
SELECT relname SELECT c.relname
FROM pg_class FROM pg_class c
WHERE relname IN %s JOIN pg_namespace n ON (n.oid = c.relnamespace)
AND relkind IN ('r', 'v', 'm') WHERE c.relname IN %s
AND c.relkind IN ('r', 'v', 'm')
AND n.nspname = 'public'
""" """
cr.execute(query, [tuple(tablenames)]) cr.execute(query, [tuple(tablenames)])
return [row[0] for row in cr.fetchall()] return [row[0] for row in cr.fetchall()]
def table_exists(cr, tablename): def table_exists(cr, tablename):
""" Return whether the given table exists. """ """ Return whether the given table exists. """
query = "SELECT 1 FROM pg_class WHERE relkind IN ('r', 'v', 'm') AND relname=%s" return len(existing_tables(cr, {tablename})) == 1
cr.execute(query, (tablename,))
return cr.rowcount
def table_kind(cr, tablename): def table_kind(cr, tablename):
""" Return the kind of a table: ``'r'`` (regular table), ``'v'`` (view), """ Return the kind of a table: ``'r'`` (regular table), ``'v'`` (view),
+1 -1
View File
@@ -153,7 +153,7 @@ def main():
elif len(sys.argv) == 2 and sys.argv[1] in cmds: elif len(sys.argv) == 2 and sys.argv[1] in cmds:
cmds[sys.argv[1]]() cmds[sys.argv[1]]()
else: else:
sys.exit('Unknow command. Command available: %r' % (list(cmds,)) sys.exit('Unknow command. Command available: %r' % (list(cmds,)))
if __name__ == "__main__": if __name__ == "__main__":
main() main()