@@ -116,7 +116,7 @@
-
+
diff --git a/addons/payment/controllers/portal.py b/addons/payment/controllers/portal.py
index 4047ef7d30a..444d936429b 100644
--- a/addons/payment/controllers/portal.py
+++ b/addons/payment/controllers/portal.py
@@ -31,49 +31,110 @@ class WebsitePayment(http.Controller):
currency_id = currency_id and int(currency_id) or user.company_id.currency_id.id
currency = env['res.currency'].browse(currency_id)
- # Try default one then fallback on first
- acquirer_id = acquirer_id and int(acquirer_id) or \
- env['ir.default'].get('payment.transaction', 'acquirer_id', company_id=user.company_id.id) or \
- env['payment.acquirer'].search([('website_published', '=', True), ('company_id', '=', user.company_id.id)])[0].id
-
- acquirer = env['payment.acquirer'].with_context(submit_class='btn btn-primary pull-right',
- submit_txt=_('Pay Now')).browse(acquirer_id)
+ acquirers = None
+ if acquirer_id:
+ acquirers = env['payment.acquirer'].browse(int(acquirer_id))
+ if not acquirers:
+ acquirers = env['payment.acquirer'].search([('website_published', '=', True), ('company_id', '=', user.company_id.id)])
# auto-increment reference with a number suffix if the reference already exists
reference = request.env['payment.transaction'].get_next_reference(reference)
partner_id = user.partner_id.id if not user._is_public() else False
- payment_form = acquirer.sudo().render(reference, float(amount), currency.id, values={'return_url': '/website_payment/confirm', 'partner_id': partner_id})
values = {
'reference': reference,
- 'acquirer': acquirer,
'currency': currency,
'amount': float(amount),
- 'payment_form': payment_form,
+ 'return_url': '/website_payment/confirm',
+ 'partner_id': partner_id,
+ 'bootstrap_formatting': True,
+ 'error_msg': kw.get('error_msg')
}
- # TODO: remove return when payment is implemented with refactored payments, i.e. use of
- # payment.payment_tokens_list in the template.
- # return request.render('payment.pay', values)
- return request.render('website.404')
- @http.route(['/website_payment/transaction'], type='json', auth="public", website=True)
- def transaction(self, reference, amount, currency_id, acquirer_id):
+ values['s2s_acquirers'] = [acq for acq in acquirers if acq.payment_flow == 's2s']
+ values['form_acquirers'] = [acq for acq in acquirers if acq.payment_flow == 'form']
+ values['pms'] = request.env['payment.token'].search([('acquirer_id', 'in', [acq.id for acq in values['s2s_acquirers']])])
+
+ return request.render('payment.pay', values)
+
+ def _get_existing_transaction(self, reference, amount, partner_id, currency_id, acquirer_id, tx_id):
+ PaymentTransaction = request.env['payment.transaction']
+ tx = None
+ if tx_id:
+ tx = PaymentTransaction.sudo().browse(tx_id)
+ if not tx.exists() or tx.reference != reference or tx.acquirer_id.id != acquirer_id:
+ tx = None
+
+ if not tx:
+ tx = PaymentTransaction.sudo().search([('reference', '=', reference), ('acquirer_id', '=', acquirer_id)])
+
+ if tx and (tx.state != 'draft' or tx.partner_id.id != partner_id or tx.amount != amount or tx.currency_id.id != currency_id):
+ tx = None
+
+ return tx
+
+ @http.route(['/website_payment/transaction/
//',], type='json', auth='public')
+ def transaction(self, acquirer_id, reference, amount, currency_id, **kwargs):
partner_id = request.env.user.partner_id.id if not request.env.user._is_public() else False
+ acquirer = request.env['payment.acquirer'].browse(acquirer_id)
+
+ tx = self._get_existing_transaction(reference, float(amount), partner_id,
+ int(currency_id), int(acquirer_id), request.session.get('website_payment_tx_id'))
+
+ if not tx:
+ values = {
+ 'acquirer_id': int(acquirer_id),
+ 'reference': reference,
+ 'amount': float(amount),
+ 'currency_id': currency_id,
+ 'partner_id': partner_id,
+ }
+
+ tx = request.env['payment.transaction'].sudo().create(values)
+ request.session['website_payment_tx_id'] = tx.id
+
+ render_values = {
+ 'return_url': '/website_payment/confirm?tx_id=%d' % tx.id,
+ 'partner_id': partner_id,
+ }
+
+ return acquirer.sudo().render(reference, float(amount), int(currency_id), values=render_values)
+
+ @http.route(['/website_payment/token///'], type='http', auth='public', website=True)
+ def payment_token(self, pm_id, reference, amount, currency_id, return_url=None, **kwargs):
+ token = request.env['payment.token'].browse(int(pm_id))
+
+ if not token:
+ return request.redirect('/website_payment/pay?error_msg=%s' % _('Cannot setup the payment.'))
+
+ partner_id = request.env.user.partner_id.id if not request.env.user._is_public() else False
+
values = {
- 'acquirer_id': int(acquirer_id),
+ 'acquirer_id': token.acquirer_id.id,
'reference': reference,
'amount': float(amount),
'currency_id': int(currency_id),
'partner_id': partner_id,
+ 'payment_token_id': pm_id
}
tx = request.env['payment.transaction'].sudo().create(values)
request.session['website_payment_tx_id'] = tx.id
- return tx.id
+
+ try:
+ res = tx.s2s_do_transaction()
+ except Exception as e:
+ return request.redirect('/website_payment/pay?error_msg=%s' % _('Payment transaction failed.'))
+
+ valid_state = 'authorized' if tx.acquirer_id.capture_manually else 'done'
+ if not res or tx.state != valid_state:
+ return request.redirect('/website_payment/pay?error_msg=%s' % _('Payment transaction failed.'))
+
+ return request.redirect(return_url if return_url else '/website_payment/confirm?tx_id=%d' % tx.id)
@http.route(['/website_payment/confirm'], type='http', auth='public', website=True)
def confirm(self, **kw):
- tx_id = request.session.pop('website_payment_tx_id', False)
+ tx_id = int(kw.get('tx_id', 0)) or request.session.pop('website_payment_tx_id', 0)
if tx_id:
tx = request.env['payment.transaction'].browse(tx_id)
if tx.state == 'done':
diff --git a/addons/payment/static/src/js/payment_transaction_portal.js b/addons/payment/static/src/js/payment_transaction_portal.js
index 26c0fb35d1e..094149b6274 100644
--- a/addons/payment/static/src/js/payment_transaction_portal.js
+++ b/addons/payment/static/src/js/payment_transaction_portal.js
@@ -1,24 +1,2 @@
-odoo.define('payment.website_payment', function (require) {
-"use strict";
-
-require('web.dom_ready');
-var ajax = require('web.ajax');
-
- if (!$('.o_website_payment').length) {
- return $.Deferred().reject("DOM doesn't contain '.o_website_payment'");
- }
-
- // When clicking on payment button: create the tx using json then continue to the acquirer
- var $payment = $(".o_website_payment_form");
- $payment.on("click", 'button[type="submit"],button[name="submit"]', function (ev) {
- ev.preventDefault();
- ev.stopPropagation();
- $(ev.currentTarget).attr('disabled', true);
- $(ev.currentTarget).prepend(' ');
- var $form = $(ev.currentTarget).parents('form');
- var data =$("div[class~='o_website_payment_new_payment']").data();
- ajax.jsonRpc('/website_payment/transaction/', 'call', data).then(function (result) {
- $form.submit();
- });
- });
-});
+// This file is kept to avoid breaking assets compilation on instance that didn't run "-u payment".
+// TBE TODO: Remove me in master
\ No newline at end of file
diff --git a/addons/payment/views/payment_portal_templates.xml b/addons/payment/views/payment_portal_templates.xml
index d2fa3c1c4cd..166cd429718 100644
--- a/addons/payment/views/payment_portal_templates.xml
+++ b/addons/payment/views/payment_portal_templates.xml
@@ -46,32 +46,22 @@
Payment
-
-
-
-
-
+
+
+
+
+
+
Reference:
+
Amount:
+
+
+
+
+
+
+
No payment acquirer found.
+
Please configure a payment acquirer.
-
diff --git a/addons/payment_stripe/static/src/js/stripe.js b/addons/payment_stripe/static/src/js/stripe.js
index a9395ea7bd6..7ecf25284f6 100644
--- a/addons/payment_stripe/static/src/js/stripe.js
+++ b/addons/payment_stripe/static/src/js/stripe.js
@@ -108,11 +108,14 @@ odoo.define('payment_stripe.stripe', function(require) {
if ($('.o_website_payment').length !== 0) {
- var create_tx = ajax.jsonRpc('/website_payment/transaction', 'call', {
- reference: $("input[name='invoice_num']").val(),
- amount: amount, // exact amount, not stripe cents
- currency_id: currency_id,
+ var invoice_num = $("input[name='invoice_num']").val();
+ var url = _.str.sprintf("/website_payment/transaction/%s/%f/%s",
+ invoice_num, amount, currency_id);
+
+ var create_tx = ajax.jsonRpc(url, 'call', {
acquirer_id: acquirer_id
+ }).then(function (data) {
+ try { provider_form[0].innerHTML = data; } catch (e) {}
});
}
else if ($('.o_website_quote').length !== 0) {
diff --git a/addons/point_of_sale/models/pos_order.py b/addons/point_of_sale/models/pos_order.py
index 19412db6cf6..af94179c442 100644
--- a/addons/point_of_sale/models/pos_order.py
+++ b/addons/point_of_sale/models/pos_order.py
@@ -227,10 +227,14 @@ class PosOrder(models.Model):
return InvoiceLine.sudo().create(inv_line)
def _create_account_move_line(self, session=None, move=None):
- def _flatten_tax_and_children(taxes):
+ def _flatten_tax_and_children(taxes, group_done=None):
children = self.env['account.tax']
- for tax in taxes:
- children |= _flatten_tax_and_children(tax.children_tax_ids)
+ if group_done is None:
+ group_done = set()
+ for tax in taxes.filtered(lambda t: t.amount_type == 'group'):
+ if tax.id not in group_done:
+ group_done.add(tax.id)
+ children |= _flatten_tax_and_children(tax.children_tax_ids, group_done)
return taxes + children
# Tricky, via the workflow, we only have one id in the ids variable
diff --git a/addons/point_of_sale/static/src/js/models.js b/addons/point_of_sale/static/src/js/models.js
index 99fdc1ec7db..e6b00499e62 100644
--- a/addons/point_of_sale/static/src/js/models.js
+++ b/addons/point_of_sale/static/src/js/models.js
@@ -729,6 +729,7 @@ exports.PosModel = Backbone.Model.extend({
deferred.resolve();
};
+ img.crossOrigin = 'use-credentials';
img.src = url;
return deferred;
diff --git a/addons/point_of_sale/static/src/js/screens.js b/addons/point_of_sale/static/src/js/screens.js
index 4e53d259248..dadd9cffbb8 100644
--- a/addons/point_of_sale/static/src/js/screens.js
+++ b/addons/point_of_sale/static/src/js/screens.js
@@ -1170,8 +1170,9 @@ var ClientListScreenWidget = ScreenWidget.extend({
var order = this.pos.get_order();
if( this.has_client_changed() ){
var default_fiscal_position_id = _.findWhere(this.pos.fiscal_positions, {'id': this.pos.config.default_fiscal_position_id[0]});
- if ( this.new_client ) {
- order.fiscal_position = _.findWhere(this.pos.fiscal_positions, {'id': this.new_client.property_account_position_id[0]});
+ if ( this.new_client && this.new_client.property_account_position_id ) {
+ var client_fiscal_position_id = _.findWhere(this.pos.fiscal_positions, {'id': this.new_client.property_account_position_id[0]});
+ order.fiscal_position = client_fiscal_position_id || default_fiscal_position_id;
order.set_pricelist(_.findWhere(this.pos.pricelists, {'id': this.new_client.property_product_pricelist[0]}) || this.pos.default_pricelist);
} else {
order.fiscal_position = default_fiscal_position_id;
@@ -1277,9 +1278,14 @@ var ClientListScreenWidget = ScreenWidget.extend({
.then(function(partner_id){
self.saved_client_details(partner_id);
},function(type,err){
+ var error_body = _t('Your Internet connection is probably down.');
+ if (err.data) {
+ var except = err.data;
+ error_body = except.arguments && except.arguments[0] || except.message || error_body;
+ }
self.gui.show_popup('error',{
'title': _t('Error: Could not Save Changes'),
- 'body': _t('Your Internet connection is probably down.'),
+ 'body': error_body,
});
});
},
diff --git a/addons/pos_mercury/views/pos_mercury_transaction_templates.xml b/addons/pos_mercury/views/pos_mercury_transaction_templates.xml
index 0ac0f8edb75..3573672acb4 100644
--- a/addons/pos_mercury/views/pos_mercury_transaction_templates.xml
+++ b/addons/pos_mercury/views/pos_mercury_transaction_templates.xml
@@ -6,11 +6,11 @@
-
OneTime
+
OneTime
-
+
@@ -31,7 +31,7 @@
-
+
@@ -52,7 +52,7 @@
-
+
diff --git a/addons/project/controllers/portal.py b/addons/project/controllers/portal.py
index 621113a385b..e847875f2d1 100644
--- a/addons/project/controllers/portal.py
+++ b/addons/project/controllers/portal.py
@@ -14,10 +14,12 @@ class CustomerPortal(CustomerPortal):
def _prepare_portal_layout_values(self):
values = super(CustomerPortal, self)._prepare_portal_layout_values()
- values.update({
- 'project_count': request.env['project.project'].search_count([('privacy_visibility', '=', 'portal')]),
- 'task_count': request.env['project.task'].search_count([('project_id.privacy_visibility', '=', 'portal')])
- })
+ Project = request.env['project.project']
+ Task = request.env['project.task']
+ # portal users can't view projects they don't follow
+ projects = Project.sudo().search([('privacy_visibility', '=', 'portal')])
+ values['project_count'] = Project.search_count([('id', 'in', projects.ids)])
+ values['task_count'] = Task.search_count([('project_id', 'in', projects.ids)])
return values
@http.route(['/my/projects', '/my/projects/page/'], type='http', auth="user", website=True)
@@ -77,8 +79,6 @@ class CustomerPortal(CustomerPortal):
@http.route(['/my/tasks', '/my/tasks/page/'], type='http', auth="user", website=True)
def portal_my_tasks(self, page=1, date_begin=None, date_end=None, sortby=None, filterby=None, search=None, search_in='content', **kw):
values = self._prepare_portal_layout_values()
- domain = [('project_id.privacy_visibility', '=', 'portal')]
-
searchbar_sortings = {
'date': {'label': _('Newest'), 'order': 'create_date desc'},
'name': {'label': _('Title'), 'order': 'name'},
@@ -96,7 +96,9 @@ class CustomerPortal(CustomerPortal):
'all': {'input': 'all', 'label': _('Search in All')},
}
# extends filterby criteria with project (criteria name is the project id)
- projects = request.env['project.project'].search([('privacy_visibility', '=', 'portal')])
+ # Note: portal users can't view projects they don't follow
+ projects = request.env['project.project'].sudo().search([('privacy_visibility', '=', 'portal')])
+ domain = [('project_id', 'in', projects.ids)]
for proj in projects:
searchbar_filters.update({
str(proj.id): {'label': proj.name, 'domain': [('project_id', '=', proj.id)]}
diff --git a/addons/project/models/project.py b/addons/project/models/project.py
index e4b7f259588..b240fdefcc7 100644
--- a/addons/project/models/project.py
+++ b/addons/project/models/project.py
@@ -593,14 +593,12 @@ class Task(models.Model):
@api.onchange('project_id')
def _onchange_project(self):
- default_partner_id = self.env.context.get('default_partner_id')
- default_partner = self.env['res.partner'].browse(default_partner_id) if default_partner_id else self.env['res.partner']
if self.project_id:
- self.partner_id = self.project_id.partner_id or default_partner
+ if self.project_id.partner_id:
+ self.partner_id = self.project_id.partner_id
if self.project_id not in self.stage_id.project_ids:
self.stage_id = self.stage_find(self.project_id.id, [('fold', '=', False)])
else:
- self.partner_id = default_partner
self.stage_id = False
@api.onchange('user_id')
diff --git a/addons/purchase/models/account_invoice.py b/addons/purchase/models/account_invoice.py
index ef23444c32c..4847ddf11ee 100644
--- a/addons/purchase/models/account_invoice.py
+++ b/addons/purchase/models/account_invoice.py
@@ -192,13 +192,13 @@ class AccountInvoice(models.Model):
if child.type_tax_use != 'none':
tax_ids.append((4, child.id, None))
price_before = line.get('price', 0.0)
- line.update({'price': company_currency.round(valuation_price_unit * line['quantity'])})
+ line.update({'price': inv.currency_id.round(valuation_price_unit * line['quantity'])})
diff_res.append({
'type': 'src',
'name': i_line.name[:64],
- 'price_unit': company_currency.round(price_unit - valuation_price_unit),
+ 'price_unit': inv.currency_id.round(price_unit - valuation_price_unit),
'quantity': line['quantity'],
- 'price': company_currency.round(price_before - line.get('price', 0.0)),
+ 'price': inv.currency_id.round(price_before - line.get('price', 0.0)),
'account_id': acc,
'product_id': line['product_id'],
'uom_id': line['uom_id'],
diff --git a/addons/purchase/models/purchase.py b/addons/purchase/models/purchase.py
index 1aee90fc83c..a63a2d679e4 100644
--- a/addons/purchase/models/purchase.py
+++ b/addons/purchase/models/purchase.py
@@ -695,7 +695,7 @@ class PurchaseOrderLine(models.Model):
qty = 0.0
price_unit = self._get_stock_move_price_unit()
for move in self.move_ids.filtered(lambda x: x.state != 'cancel' and not x.location_dest_id.usage == "supplier"):
- qty += move.product_qty
+ qty += move.product_uom._compute_quantity(move.product_uom_qty, self.product_uom, rounding_method='HALF-UP')
template = {
'name': self.name or '',
'product_id': self.product_id.id,
@@ -719,7 +719,14 @@ class PurchaseOrderLine(models.Model):
}
diff_quantity = self.product_qty - qty
if float_compare(diff_quantity, 0.0, precision_rounding=self.product_uom.rounding) > 0:
- template['product_uom_qty'] = diff_quantity
+ quant_uom = self.product_id.uom_id
+ get_param = self.env['ir.config_parameter'].sudo().get_param
+ if self.product_uom.id != quant_uom.id and get_param('stock.propagate_uom') != '1':
+ product_qty = self.product_uom._compute_quantity(diff_quantity, quant_uom, rounding_method='HALF-UP')
+ template['product_uom'] = quant_uom.id
+ template['product_uom_qty'] = product_qty
+ else:
+ template['product_uom_qty'] = diff_quantity
res.append(template)
return res
diff --git a/addons/purchase/tests/test_create_picking.py b/addons/purchase/tests/test_create_picking.py
index b29e85b3825..9cdfe0fb4fd 100644
--- a/addons/purchase/tests/test_create_picking.py
+++ b/addons/purchase/tests/test_create_picking.py
@@ -174,3 +174,53 @@ class TestCreatePicking(common.TestProductCommon):
customer_move._action_assign()
self.assertEqual(customer_move.state, 'assigned', 'Reservation should work with the new quantity provided by the PO.')
+
+ def test_03_uom(self):
+ """ Buy a dozen of products stocked in units. Check that the quantities on the purchase order
+ lines as well as the received quantities are handled in dozen while the moves themselves
+ are handled in units. Edit the ordered quantities, check that the quantites are correctly
+ updated on the moves. Edit the ir.config_parameter to propagate the uom of the purchase order
+ lines to the moves and edit a last time the ordered quantities. Receive, check the quantities.
+ """
+ uom_unit = self.env.ref('product.product_uom_unit')
+ uom_dozen = self.env.ref('product.product_uom_dozen')
+
+ self.assertEqual(self.product_id_1.uom_po_id.id, uom_unit.id)
+
+ # buy a dozen
+ po = self.env['purchase.order'].create(self.po_vals)
+
+ po.order_line.product_qty = 1
+ po.order_line.product_uom = uom_dozen.id
+ po.button_confirm()
+
+ # the move should be 12 units
+ # note: move.product_qty = computed field, always in the uom of the quant
+ # move.product_uom_qty = stored field representing the initial demand in move.product_uom
+ move1 = po.picking_ids.move_lines[0]
+ self.assertEqual(move1.product_uom_qty, 12)
+ self.assertEqual(move1.product_uom.id, uom_unit.id)
+ self.assertEqual(move1.product_qty, 12)
+
+ # edit the so line, sell 2 dozen, the move should now be 24 units
+ po.order_line.product_qty = 2
+ self.assertEqual(move1.product_uom_qty, 24)
+ self.assertEqual(move1.product_uom.id, uom_unit.id)
+ self.assertEqual(move1.product_qty, 24)
+
+ # force the propagation of the uom, sell 3 dozen
+ self.env['ir.config_parameter'].sudo().set_param('stock.propagate_uom', '1')
+ po.order_line.product_qty = 3
+ move2 = po.picking_ids.move_lines.filtered(lambda m: m.product_uom.id == uom_dozen.id)
+ self.assertEqual(move2.product_uom_qty, 1)
+ self.assertEqual(move2.product_uom.id, uom_dozen.id)
+ self.assertEqual(move2.product_qty, 12)
+
+ # deliver everything
+ move1.quantity_done = 24
+ move2.quantity_done = 1
+ po.picking_ids.button_validate()
+
+ # check the delivered quantity
+ self.assertEqual(po.order_line.qty_received, 3.0)
+
diff --git a/addons/purchase/tests/test_stockvaluation.py b/addons/purchase/tests/test_stockvaluation.py
index 0421bde3f71..83f44229d21 100644
--- a/addons/purchase/tests/test_stockvaluation.py
+++ b/addons/purchase/tests/test_stockvaluation.py
@@ -198,3 +198,76 @@ class TestStockValuation(TransactionCase):
self.assertEquals(move1.price_unit, price_unit_usd_new_rate)
self.assertAlmostEqual(self.product1.stock_value, price_unit_usd_new_rate * 10, delta=0.1)
+
+ def test_extra_move_fifo_1(self):
+ """ Check that the extra move when over processing a receipt is correctly merged back in
+ the original move.
+ """
+ self.product1.product_tmpl_id.cost_method = 'fifo'
+ po1 = self.env['purchase.order'].create({
+ 'partner_id': self.partner_id.id,
+ 'order_line': [
+ (0, 0, {
+ 'name': self.product1.name,
+ 'product_id': self.product1.id,
+ 'product_qty': 10.0,
+ 'product_uom': self.product1.uom_po_id.id,
+ 'price_unit': 100.0,
+ 'date_planned': datetime.today().strftime(DEFAULT_SERVER_DATETIME_FORMAT),
+ }),
+ ],
+ })
+ po1.button_confirm()
+
+ picking1 = po1.picking_ids[0]
+ move1 = picking1.move_lines[0]
+ move1.quantity_done = 15
+ res_dict = picking1.button_validate()
+ self.assertEqual(res_dict['res_model'], 'stock.overprocessed.transfer')
+ wizard = self.env[(res_dict.get('res_model'))].browse(res_dict.get('res_id'))
+ wizard.action_confirm()
+
+ # there should be only one move
+ self.assertEqual(len(picking1.move_lines), 1)
+ self.assertEqual(move1.price_unit, 100)
+ self.assertEqual(move1.product_qty, 15)
+ self.assertEqual(self.product1.stock_value, 1500)
+
+ def test_backorder_fifo_1(self):
+ """ Check that the backordered move when under processing a receipt correctly keep the
+ price unit of the original move.
+ """
+ self.product1.product_tmpl_id.cost_method = 'fifo'
+ po1 = self.env['purchase.order'].create({
+ 'partner_id': self.partner_id.id,
+ 'order_line': [
+ (0, 0, {
+ 'name': self.product1.name,
+ 'product_id': self.product1.id,
+ 'product_qty': 10.0,
+ 'product_uom': self.product1.uom_po_id.id,
+ 'price_unit': 100.0,
+ 'date_planned': datetime.today().strftime(DEFAULT_SERVER_DATETIME_FORMAT),
+ }),
+ ],
+ })
+ po1.button_confirm()
+
+ picking1 = po1.picking_ids[0]
+ move1 = picking1.move_lines[0]
+ move1.quantity_done = 5
+ res_dict = picking1.button_validate()
+ self.assertEqual(res_dict['res_model'], 'stock.backorder.confirmation')
+ wizard = self.env[(res_dict.get('res_model'))].browse(res_dict.get('res_id'))
+ wizard.process()
+
+ self.assertEqual(len(picking1.move_lines), 1)
+ self.assertEqual(move1.price_unit, 100)
+ self.assertEqual(move1.product_qty, 5)
+
+ picking2 = po1.picking_ids.filtered(lambda p: p.backorder_id)
+ move2 = picking2.move_lines[0]
+ self.assertEqual(len(picking2.move_lines), 1)
+ self.assertEqual(move2.price_unit, 100)
+ self.assertEqual(move2.product_qty, 5)
+
diff --git a/addons/sale/models/analytic.py b/addons/sale/models/analytic.py
index 73b9f95d93a..6d8d381c6f6 100644
--- a/addons/sale/models/analytic.py
+++ b/addons/sale/models/analytic.py
@@ -41,7 +41,9 @@ class AccountAnalyticLine(models.Model):
@api.multi
def _sale_postprocess(self, values, additional_so_lines=None):
if 'so_line' not in values: # allow to force a False value for so_line
- self.sudo().filtered(lambda aal: not aal.so_line and aal.product_id and aal.product_id.expense_policy != 'no').with_context(sale_analytic_norecompute=True)._sale_determine_order_line()
+ # only take the AAL from expense or vendor bill, meaning having a negative amount
+ self.sudo().filtered(lambda aal: not aal.so_line and aal.product_id and aal.product_id.expense_policy != 'no' and aal.amount <= 0).with_context(sale_analytic_norecompute=True)._sale_determine_order_line()
+ self.filtered(lambda aal: aal.amount <= 0).with_context(sale_analytic_norecompute=True)._sale_determine_order_line()
if any(field_name in values for field_name in self._sale_get_fields_delivered_qty()):
if not self._context.get('sale_analytic_norecompute'):
diff --git a/addons/sale_mrp/tests/test_sale_mrp_flow.py b/addons/sale_mrp/tests/test_sale_mrp_flow.py
index f6268534ef7..1152b7fffa3 100644
--- a/addons/sale_mrp/tests/test_sale_mrp_flow.py
+++ b/addons/sale_mrp/tests/test_sale_mrp_flow.py
@@ -166,8 +166,8 @@ class TestSaleMrpFlow(common.TransactionCase):
mnf_product_a = self.env['mrp.production'].search([('product_id', '=', product_a.id)])
self.assertTrue(mnf_product_a, 'Manufacturing order not created.')
- self.assertEqual(mnf_product_a.product_qty, 10, 'Wrong product quantity in manufacturing order.')
- self.assertEqual(mnf_product_a.product_uom_id, self.uom_dozen, 'Wrong unit of measure in manufacturing order.')
+ self.assertEqual(mnf_product_a.product_qty, 120, 'Wrong product quantity in manufacturing order.')
+ self.assertEqual(mnf_product_a.product_uom_id, self.uom_unit, 'Wrong unit of measure in manufacturing order.')
self.assertEqual(mnf_product_a.state, 'confirmed', 'Manufacturing order should be confirmed.')
# ------------------------------------------------------------------------------------------
diff --git a/addons/sale_stock/models/sale_order.py b/addons/sale_stock/models/sale_order.py
index 99b8d299fa8..67daba354bd 100644
--- a/addons/sale_stock/models/sale_order.py
+++ b/addons/sale_stock/models/sale_order.py
@@ -238,7 +238,7 @@ class SaleOrderLine(models.Model):
continue
qty = 0.0
for move in line.move_ids.filtered(lambda r: r.state != 'cancel'):
- qty += move.product_qty
+ qty += move.product_uom._compute_quantity(move.product_uom_qty, line.product_uom, rounding_method='HALF-UP')
if float_compare(qty, line.product_uom_qty, precision_digits=precision) >= 0:
continue
@@ -263,8 +263,16 @@ class SaleOrderLine(models.Model):
values = line._prepare_procurement_values(group_id=group_id)
product_qty = line.product_uom_qty - qty
+
+ procurement_uom = line.product_uom
+ quant_uom = line.product_id.uom_id
+ get_param = self.env['ir.config_parameter'].sudo().get_param
+ if procurement_uom.id != quant_uom.id and get_param('stock.propagate_uom') != '1':
+ product_qty = line.product_uom._compute_quantity(product_qty, quant_uom, rounding_method='HALF-UP')
+ procurement_uom = quant_uom
+
try:
- self.env['procurement.group'].run(line.product_id, product_qty, line.product_uom, line.order_id.partner_shipping_id.property_stock_customer, line.name, line.order_id.name, values)
+ self.env['procurement.group'].run(line.product_id, product_qty, procurement_uom, line.order_id.partner_shipping_id.property_stock_customer, line.name, line.order_id.name, values)
except UserError as error:
errors.append(error.name)
if errors:
diff --git a/addons/sale_stock/tests/test_sale_stock.py b/addons/sale_stock/tests/test_sale_stock.py
index 5a6e43377fe..c99f211ce50 100644
--- a/addons/sale_stock/tests/test_sale_stock.py
+++ b/addons/sale_stock/tests/test_sale_stock.py
@@ -244,8 +244,8 @@ class TestSaleStock(TestSale):
the new move lines.
"""
# sell two products
- item1 = self.products['prod_order']
- item2 = self.products['prod_del']
+ item1 = self.products['prod_order'] # consumable
+ item2 = self.products['prod_del'] # stockable
self.so = self.env['sale.order'].create({
'partner_id': self.partner.id,
@@ -256,6 +256,66 @@ class TestSaleStock(TestSale):
})
self.so.action_confirm()
+ # deliver them
+ # One of the move is for a consumable product, thus is assigned. The second one is for a
+ # stockable product, thus is unavailable. Hitting `button_validate` will first ask to
+ # process all the reserved quantities and, if the user chose to process, a second wizard
+ # will ask to create a backorder for the unavailable product.
+ self.assertEquals(len(self.so.picking_ids), 1)
+ res_dict = self.so.picking_ids[0].button_validate()
+ wizard = self.env[(res_dict.get('res_model'))].browse(res_dict.get('res_id'))
+ self.assertEqual(wizard._name, 'stock.immediate.transfer')
+ res_dict = wizard.process()
+ wizard = self.env[(res_dict.get('res_model'))].browse(res_dict.get('res_id'))
+ self.assertEqual(wizard._name, 'stock.backorder.confirmation')
+ wizard.process()
+
+ # Now, the original picking is done and there is a new one (the backorder).
+ self.assertEquals(len(self.so.picking_ids), 2)
+ for picking in self.so.picking_ids:
+ move = picking.move_lines
+ if picking.backorder_id:
+ self.assertEqual(move.product_id.id, item2.id)
+ self.assertEqual(move.state, 'confirmed')
+ else:
+ self.assertEqual(picking.move_lines.product_id.id, item1.id)
+ self.assertEqual(move.state, 'done')
+
+ # update the two original sale order lines
+ self.so.write({
+ 'order_line': [
+ (1, self.so.order_line[0].id, {'product_uom_qty': 2}),
+ (1, self.so.order_line[1].id, {'product_uom_qty': 2}),
+ ]
+ })
+ # a single picking should be created for the new delivery
+ self.assertEquals(len(self.so.picking_ids), 2)
+ backorder = self.so.picking_ids.filtered(lambda p: p.backorder_id)
+ self.assertEqual(len(backorder.move_lines), 2)
+ for backorder_move in backorder.move_lines:
+ if backorder_move.product_id.id == item1.id:
+ self.assertEqual(backorder_move.product_qty, 1)
+ elif backorder_move.product_id.id == item2.id:
+ self.assertEqual(backorder_move.product_qty, 2)
+
+ def test_05_create_picking_update_saleorderline(self):
+ """ Same test than test_04 but only with enough products in stock so that the reservation
+ is successful.
+ """
+ # sell two products
+ item1 = self.products['prod_order'] # consumable
+ item2 = self.products['prod_del'] # stockable
+
+ self.env['stock.quant']._update_available_quantity(item2, self.env.ref('stock.stock_location_stock'), 2)
+ self.so = self.env['sale.order'].create({
+ 'partner_id': self.partner.id,
+ 'order_line': [
+ (0, 0, {'name': item1.name, 'product_id': item1.id, 'product_uom_qty': 1, 'product_uom': item1.uom_id.id, 'price_unit': item1.list_price}),
+ (0, 0, {'name': item2.name, 'product_id': item2.id, 'product_uom_qty': 1, 'product_uom': item2.uom_id.id, 'price_unit': item2.list_price}),
+ ],
+ })
+ self.so.action_confirm()
+
# deliver them
self.assertEquals(len(self.so.picking_ids), 1)
self.so.picking_ids[0].force_assign()
@@ -303,3 +363,61 @@ class TestSaleStock(TestSale):
self.assertEqual(len(so1.picking_ids), 2)
picking2 = so1.picking_ids.filtered(lambda p: p.state != 'cancel')
self.assertEqual(picking2.partner_id.id, partner2)
+
+ def test_06_uom(self):
+ """ Sell a dozen of products stocked in units. Check that the quantities on the sale order
+ lines as well as the delivered quantities are handled in dozen while the moves themselves
+ are handled in units. Edit the ordered quantities, check that the quantites are correctly
+ updated on the moves. Edit the ir.config_parameter to propagate the uom of the sale order
+ lines to the moves and edit a last time the ordered quantities. Deliver, check the
+ quantities.
+ """
+ uom_unit = self.env.ref('product.product_uom_unit')
+ uom_dozen = self.env.ref('product.product_uom_dozen')
+ item1 = self.products['prod_order']
+
+ self.assertEqual(item1.uom_id.id, uom_unit.id)
+
+ # sell a dozen
+ so1 = self.env['sale.order'].create({
+ 'partner_id': self.partner.id,
+ 'order_line': [(0, 0, {
+ 'name': item1.name,
+ 'product_id': item1.id,
+ 'product_uom_qty': 1,
+ 'product_uom': uom_dozen.id,
+ 'price_unit': item1.list_price,
+ })],
+ })
+ so1.action_confirm()
+
+ # the move should be 12 units
+ # note: move.product_qty = computed field, always in the uom of the quant
+ # move.product_uom_qty = stored field representing the initial demand in move.product_uom
+ move1 = so1.picking_ids.move_lines[0]
+ self.assertEqual(move1.product_uom_qty, 12)
+ self.assertEqual(move1.product_uom.id, uom_unit.id)
+ self.assertEqual(move1.product_qty, 12)
+
+ # edit the so line, sell 2 dozen, the move should now be 24 units
+ so1.order_line.product_uom_qty = 2
+ self.assertEqual(move1.product_uom_qty, 24)
+ self.assertEqual(move1.product_uom.id, uom_unit.id)
+ self.assertEqual(move1.product_qty, 24)
+
+ # force the propagation of the uom, sell 3 dozen
+ self.env['ir.config_parameter'].sudo().set_param('stock.propagate_uom', '1')
+ so1.order_line.product_uom_qty = 3
+ move2 = so1.picking_ids.move_lines.filtered(lambda m: m.product_uom.id == uom_dozen.id)
+ self.assertEqual(move2.product_uom_qty, 1)
+ self.assertEqual(move2.product_uom.id, uom_dozen.id)
+ self.assertEqual(move2.product_qty, 12)
+
+ # deliver everything
+ move1.quantity_done = 24
+ move2.quantity_done = 1
+ so1.picking_ids.button_validate()
+
+ # check the delivered quantity
+ self.assertEqual(so1.order_line.qty_delivered, 3.0)
+
diff --git a/addons/sale_timesheet/models/product.py b/addons/sale_timesheet/models/product.py
index aeddf1bd2ce..88b98d97065 100644
--- a/addons/sale_timesheet/models/product.py
+++ b/addons/sale_timesheet/models/product.py
@@ -39,7 +39,7 @@ class ProductTemplate(models.Model):
def _inverse_service_policy(self):
for product in self:
policy = product.service_policy
- if not policy:
+ if not policy and not product.invoice_policy =='delivery':
product.invoice_policy = 'order'
product.service_type = 'manual'
elif policy == 'ordered_timesheet':
diff --git a/addons/sales_team/models/crm_team.py b/addons/sales_team/models/crm_team.py
index cf95b66a7e2..4542116c47c 100644
--- a/addons/sales_team/models/crm_team.py
+++ b/addons/sales_team/models/crm_team.py
@@ -232,7 +232,7 @@ class CrmTeam(models.Model):
values[index][y_field] = data_item.get('y_value')
elif self.dashboard_graph_group == 'week':
- weeks_in_start_year = int(date(start_date.year, 12, 31).isocalendar()[1])
+ weeks_in_start_year = int(date(start_date.year, 12, 28).isocalendar()[1]) # This date is always in the last week of ISO years
for week in range(0, (end_date.isocalendar()[1] - start_date.isocalendar()[1]) % weeks_in_start_year + 1):
short_name = get_week_name(start_date + relativedelta(days=7 * week), locale)
values.append({x_field: short_name, y_field: 0})
diff --git a/addons/stock/models/product.py b/addons/stock/models/product.py
index d4db5ff5451..e164edfa331 100644
--- a/addons/stock/models/product.py
+++ b/addons/stock/models/product.py
@@ -365,8 +365,14 @@ class Product(models.Model):
def write(self, values):
res = super(Product, self).write(values)
- if 'active' in values and not values['active'] and self.mapped('orderpoint_ids').filtered(lambda r: r.active):
- raise UserError(_('You still have some active reordering rules on this product. Please archive or delete them first.'))
+ if 'active' in values and not values['active']:
+ products = self.mapped('orderpoint_ids').filtered(lambda r: r.active).mapped('product_id')
+ if products:
+ msg = _('You still have some active reordering rules on this product. Please archive or delete them first.')
+ msg += '\n\n'
+ for product in products:
+ msg += '- %s \n' % product.display_name
+ raise UserError(msg)
return res
class ProductTemplate(models.Model):
diff --git a/addons/stock/models/stock_move.py b/addons/stock/models/stock_move.py
index db19f9d1f95..a30ede5d783 100644
--- a/addons/stock/models/stock_move.py
+++ b/addons/stock/models/stock_move.py
@@ -952,7 +952,8 @@ class StockMove(models.Model):
def _prepare_extra_move_vals(self, qty):
vals = {
'product_uom_qty': qty,
- 'picking_id': self.picking_id.id
+ 'picking_id': self.picking_id.id,
+ 'price_unit': self.price_unit,
}
return vals
@@ -1080,6 +1081,7 @@ class StockMove(models.Model):
'move_dest_ids': [(4, x.id) for x in self.move_dest_ids if x.state not in ('done', 'cancel')],
'move_orig_ids': [(4, x.id) for x in self.move_orig_ids],
'origin_returned_move_id': self.origin_returned_move_id.id,
+ 'price_unit': self.price_unit,
}
return vals
diff --git a/addons/stock/models/stock_picking.py b/addons/stock/models/stock_picking.py
index 446b55a2a0d..267308b3da7 100644
--- a/addons/stock/models/stock_picking.py
+++ b/addons/stock/models/stock_picking.py
@@ -8,7 +8,7 @@ import time
from itertools import groupby
from odoo import api, fields, models, _
from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT
-from odoo.tools.float_utils import float_compare, float_round
+from odoo.tools.float_utils import float_compare, float_is_zero, float_round
from odoo.exceptions import UserError
from odoo.addons.stock.models.stock_move import PROCUREMENT_PRIORITIES
from operator import itemgetter
@@ -443,7 +443,7 @@ class Picking(models.Model):
for picking in self:
if self._context.get('planned_picking') and picking.state == 'draft':
picking.show_validate = False
- elif picking.state not in ('draft', 'confirmed', 'assigned') or not picking.is_locked:
+ elif picking.state not in ('draft', 'waiting', 'confirmed', 'assigned') or not picking.is_locked:
picking.show_validate = False
else:
picking.show_validate = True
@@ -594,8 +594,7 @@ class Picking(models.Model):
@return: True
"""
# TDE FIXME: remove decorator when migration the remaining
- # TDE FIXME: draft -> automatically done, if waiting ?? CLEAR ME
- todo_moves = self.mapped('move_lines').filtered(lambda self: self.state in ['draft', 'partially_available', 'assigned', 'confirmed'])
+ todo_moves = self.mapped('move_lines').filtered(lambda self: self.state in ['draft', 'waiting', 'partially_available', 'assigned', 'confirmed'])
# Check if there are ops not linked to moves yet
for pick in self:
# # Explode manually added packages
@@ -684,10 +683,10 @@ class Picking(models.Model):
# If no lots when needed, raise error
picking_type = self.picking_type_id
- no_quantities_done = all(line.qty_done == 0.0 for line in self.move_line_ids)
- no_initial_demand = all(move.product_uom_qty == 0.0 for move in self.move_lines)
- if no_initial_demand and no_quantities_done:
- raise UserError(_('You cannot validate a transfer if you have not processed any quantity.'))
+ no_quantities_done = all(float_is_zero(move_line.qty_done, precision_rounding=move_line.product_uom_id.rounding) for move_line in self.move_line_ids)
+ no_reserved_quantities = all(float_is_zero(move_line.product_qty, precision_rounding=move_line.product_uom_id.rounding) for move_line in self.move_line_ids)
+ if no_reserved_quantities and no_quantities_done:
+ raise UserError(_('You cannot validate a transfer if you have not processed any quantity. You should rather cancel the transfer.'))
if picking_type.use_create_lots or picking_type.use_existing_lots:
lines_to_check = self.move_line_ids
@@ -832,7 +831,7 @@ class Picking(models.Model):
def _put_in_pack(self):
package = False
- for pick in self:
+ for pick in self.filtered(lambda p: p.state not in ('done', 'cancel')):
operations = pick.move_line_ids.filtered(lambda o: o.qty_done > 0 and not o.result_package_id)
operation_ids = self.env['stock.move.line']
if operations:
diff --git a/addons/stock/models/stock_production_lot.py b/addons/stock/models/stock_production_lot.py
index 06cc2917502..4fc1092839d 100644
--- a/addons/stock/models/stock_production_lot.py
+++ b/addons/stock/models/stock_production_lot.py
@@ -42,17 +42,3 @@ class ProductionLot(models.Model):
# We only care for the quants in internal or transit locations.
quants = self.quant_ids.filtered(lambda q: q.location_id.usage in ['internal', 'transit'])
self.product_qty = sum(quants.mapped('quantity'))
-
- @api.multi
- def action_traceability(self):
- move_ids = self.mapped('quant_ids').mapped('history_ids').ids
- if not move_ids:
- return False
- return {
- 'domain': [('id', 'in', move_ids)],
- 'name': _('Traceability'),
- 'view_mode': 'tree,form',
- 'view_type': 'form',
- 'context': {'tree_view_ref': 'stock.view_move_tree'},
- 'res_model': 'stock.move',
- 'type': 'ir.actions.act_window'}
diff --git a/addons/stock/tests/test_move.py b/addons/stock/tests/test_move.py
index dfa9fc1f8f7..609bf0aa334 100644
--- a/addons/stock/tests/test_move.py
+++ b/addons/stock/tests/test_move.py
@@ -2356,19 +2356,18 @@ class StockMove(TransactionCase):
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product3, self.stock_location, lot_id=lot2, package_id=package1), 1.0)
def test_immediate_validate_1(self):
- """ Create a picking and simulates validate button effect.
- Move line quantity should be set to their reservation quantity automatically
+ """ In a picking with a single available move, clicking on validate without filling any
+ quantities should open a wizard asking to process all the reservation (so, the whole move).
"""
- owner = self.env['res.partner'].create({'name': 'Jean'})
+ partner = self.env['res.partner'].create({'name': 'Jean'})
picking = self.env['stock.picking'].create({
'location_id': self.supplier_location.id,
'location_dest_id': self.stock_location.id,
- 'partner_id': owner.id,
+ 'partner_id': partner.id,
'picking_type_id': self.env.ref('stock.picking_type_in').id,
})
- # move from shelf1
self.env['stock.move'].create({
- 'name': 'test_edit_moveline_1',
+ 'name': 'test_immediate_validate_1',
'location_id': self.supplier_location.id,
'location_dest_id': self.stock_location.id,
'picking_id': picking.id,
@@ -2379,29 +2378,28 @@ class StockMove(TransactionCase):
picking.action_confirm()
picking.action_assign()
res_dict = picking.button_validate()
+ self.assertEqual(res_dict.get('res_model'), 'stock.immediate.transfer')
wizard = self.env[(res_dict.get('res_model'))].browse(res_dict.get('res_id'))
wizard.process()
-
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product1, self.stock_location), 10.0)
def test_immediate_validate_2(self):
- """ Create a picking and simulates validate button effect.
- Move line quantity should be set to their reservation quantity automatically.
- If the Initial demand quantity is greater than the available quantity. It should only
- set the quantity done to the available(reserved) quantity and create a backorder with the
- remaining quantity.
+ """ In a picking with a single partially available move, clicking on validate without
+ filling any quantities should open a wizard asking to process all the reservation (so, only
+ a part of the initial demand). Validating this wizard should open another one asking for
+ the creation of a backorder. If the backorder is created, it should contain the quantities
+ not processed.
"""
- owner = self.env['res.partner'].create({'name': 'Jean'})
+ partner = self.env['res.partner'].create({'name': 'Jean'})
self.env['stock.quant']._update_available_quantity(self.product1, self.stock_location, 5.0)
picking = self.env['stock.picking'].create({
'location_id': self.stock_location.id,
'location_dest_id': self.customer_location.id,
- 'partner_id': owner.id,
+ 'partner_id': partner.id,
'picking_type_id': self.env.ref('stock.picking_type_out').id,
})
- # move from shelf1
self.env['stock.move'].create({
- 'name': 'test_edit_moveline_1',
+ 'name': 'test_immediate_validate_2',
'location_id': self.stock_location.id,
'location_dest_id': self.customer_location.id,
'picking_id': picking.id,
@@ -2411,67 +2409,96 @@ class StockMove(TransactionCase):
})
picking.action_confirm()
picking.action_assign()
+ # Only 5 products are reserved on the move of 10, click on `button_validate`.
res_dict = picking.button_validate()
+ self.assertEqual(res_dict.get('res_model'), 'stock.immediate.transfer')
wizard = self.env[(res_dict.get('res_model'))].browse(res_dict.get('res_id'))
res_dict_for_back_order = wizard.process()
+ self.assertEqual(res_dict_for_back_order.get('res_model'), 'stock.backorder.confirmation')
backorder_wizard = self.env[(res_dict_for_back_order.get('res_model'))].browse(res_dict_for_back_order.get('res_id'))
+ # Chose to create a backorder.
backorder_wizard.process()
- self.assertEqual(len(picking.move_lines.move_line_ids), 1)
+ # Only 5 products should be processed on the initial move.
+ self.assertEqual(picking.move_lines.state, 'done')
self.assertEqual(picking.move_lines.quantity_done, 5.0)
- self.assertEqual(picking.move_lines.move_line_ids.qty_done, 5.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)
+ # The backoder should contain a move for the other 5 produts.
backorder = self.env['stock.picking'].search([('backorder_id', '=', picking.id)])
self.assertEqual(len(backorder), 1.0)
self.assertEqual(backorder.move_lines.product_uom_qty, 5.0)
def test_immediate_validate_3(self):
- """ Create a picking and simulates validate button effect.
- In case of a force assign it should ignore the reserved quantity and set
- the quantity done to the initial demand. It should also create the move line.
+ """ In a picking with two moves, one partially available and one unavailable, clicking
+ on validate without filling any quantities should open a wizard asking to process all the
+ reservation (so, only a part of one of the moves). Validating this wizard should open
+ another one asking for the creation of a backorder. If the backorder is created, it should
+ contain the quantities not processed.
"""
- owner = self.env['res.partner'].create({'name': 'Jean'})
+ product5 = self.env['product.product'].create({
+ 'name': 'Product 5',
+ 'type': 'product',
+ 'categ_id': self.env.ref('product.product_category_all').id,
+ })
+
+ self.env['stock.quant']._update_available_quantity(self.product1, self.stock_location, 1)
+
picking = self.env['stock.picking'].create({
'location_id': self.stock_location.id,
- 'location_dest_id': self.customer_location.id,
- 'partner_id': owner.id,
- 'picking_type_id': self.env.ref('stock.picking_type_out').id,
+ 'location_dest_id': self.pack_location.id,
+ 'picking_type_id': self.env.ref('stock.picking_type_internal').id,
})
- # move from shelf1
- self.env['stock.move'].create({
- 'name': 'test_edit_moveline_1',
+ product1_move = self.env['stock.move'].create({
+ 'name': 'product1_move',
'location_id': self.stock_location.id,
- 'location_dest_id': self.customer_location.id,
+ 'location_dest_id': self.pack_location.id,
'picking_id': picking.id,
'product_id': self.product1.id,
'product_uom': self.uom_unit.id,
- 'product_uom_qty': 10.0,
+ 'product_uom_qty': 100,
+ })
+ product5_move = self.env['stock.move'].create({
+ 'name': 'product3_move',
+ 'location_id': self.stock_location.id,
+ 'location_dest_id': self.pack_location.id,
+ 'picking_id': picking.id,
+ 'product_id': product5.id,
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 100,
})
picking.action_confirm()
- picking.force_assign()
- res_dict = picking.button_validate()
- wizard = self.env[(res_dict.get('res_model'))].browse(res_dict.get('res_id'))
- wizard.process()
+ picking.action_assign()
- self.assertEqual(picking.move_lines.quantity_done, 10.0)
- # Check move_lines data
- self.assertEqual(len(picking.move_lines.move_line_ids), 1)
- self.assertEqual(picking.move_lines.move_line_ids.qty_done, 10.0)
- self.assertEqual(picking.move_lines.move_line_ids.product_qty, 0.0)
- # Check quants data
- 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)
+ # product1_move should be partially available (1/100), product5_move should be totally
+ # unavailable (0/100)
+ self.assertEqual(product1_move.state, 'partially_available')
+ self.assertEqual(product5_move.state, 'confirmed')
+
+ action = picking.button_validate()
+ self.assertEqual(action.get('res_model'), 'stock.immediate.transfer')
+ wizard = self.env[(action.get('res_model'))].browse(action.get('res_id'))
+ action = wizard.process()
+ self.assertTrue(isinstance(action, dict), 'Should open backorder wizard')
+ self.assertEqual(action.get('res_model'), 'stock.backorder.confirmation')
+ wizard = self.env[(action.get('res_model'))].browse(action.get('res_id'))
+ wizard.process()
+ backorder = self.env['stock.picking'].search([('backorder_id', '=', picking.id)])
+ self.assertEqual(len(backorder), 1.0)
+
+ # The backorder should contain 99 product1 and 100 product5.
+ for backorder_move in backorder.move_lines:
+ if backorder_move.product_id.id == self.product1.id:
+ self.assertEqual(backorder_move.product_qty, 99)
+ elif backorder_move.product_id.id == product5.id:
+ self.assertEqual(backorder_move.product_qty, 100)
def test_immediate_validate_4(self):
- """ Create a picking and simulates validate button effect.
- If the product is tracked by lot. The user should set the lot
- and the quantity done himself before validate. Otherwise it should
- return a UserError.
+ """ In a picking with a single available tracked by lot move, clicking on validate without
+ filling any quantities should open an UserError.
"""
- owner = self.env['res.partner'].create({'name': 'Jean'})
+ partner = self.env['res.partner'].create({'name': 'Jean'})
lot1 = self.env['stock.production.lot'].create({
'name': 'lot1',
'product_id': self.product3.id,
@@ -2480,12 +2507,12 @@ class StockMove(TransactionCase):
picking = self.env['stock.picking'].create({
'location_id': self.stock_location.id,
'location_dest_id': self.customer_location.id,
- 'partner_id': owner.id,
+ 'partner_id': partner.id,
'picking_type_id': self.env.ref('stock.picking_type_out').id,
})
# move from shelf1
self.env['stock.move'].create({
- 'name': 'test_edit_moveline_1',
+ 'name': 'test_immediate_validate_4',
'location_id': self.stock_location.id,
'location_dest_id': self.customer_location.id,
'picking_id': picking.id,
@@ -2495,10 +2522,11 @@ class StockMove(TransactionCase):
})
picking.action_confirm()
picking.action_assign()
+ # No quantites/lot filled, it should raise.
with self.assertRaises(UserError):
picking.button_validate()
picking.move_lines.move_line_ids[0].qty_done = 5.0
- # We do not need to catch wizard since everything is already defined and the action_done will be processed immediately
+ # All the information are present (lots and quantities), the wizard won't be opened.
picking.button_validate()
self.assertEqual(picking.move_lines.quantity_done, 5.0)
@@ -2535,9 +2563,9 @@ class StockMove(TransactionCase):
return picking
def test_immediate_validate_5(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.
+ """ In a receipt with a single tracked by serial numbers move, clicking on validate without
+ filling any quantities nor lot should open an UserError except if the picking type is
+ configured to allow otherwise.
"""
picking_type_id = self.env.ref('stock.picking_type_in')
product_id = self.product2
@@ -2555,23 +2583,17 @@ class StockMove(TransactionCase):
self.assertEqual(picking.state, 'done')
def test_immediate_validate_6(self):
- """ Create a picking and simulates validate button effect.
- This tests three cases:
- - if a user has processed no quantities and at least one product is tracked,
- he should specify lots
- - if a user has processed some quantities then it's ok because a backorder
- will be created for the ones not filled in
- - if a user overprocesses a move he will be asked to confirm if this is ok
+ """ In a receipt picking with two moves, one tracked and one untracked, clicking on
+ validate without filling any quantities should displays an UserError as long as no quantity
+ done and lot_name is set on the tracked move. Now if the user validates the picking, the
+ wizard telling the user all reserved quantities will be processed will NOT be opened. This
+ wizard is only opene if no quantities were filled. So validating the picking at this state
+ will open another wizard asking for the creation of a backorder. Now, if the user processed
+ on the second move more than the reservation, a wizard will ask him to confirm.
"""
picking_type = self.env.ref('stock.picking_type_in')
picking_type.use_create_lots = True
picking_type.use_existing_lots = False
- lot1 = self.env['stock.production.lot'].create({
- 'name': 'lot1',
- 'product_id': self.product3.id,
- })
- self.env['stock.quant']._update_available_quantity(self.product1, self.stock_location, 1)
- self.env['stock.quant']._update_available_quantity(self.product3, self.stock_location, 1, lot_id=lot1)
picking = self.env['stock.picking'].create({
'location_id': self.supplier_location.id,
'location_dest_id': self.stock_location.id,
@@ -2597,16 +2619,15 @@ class StockMove(TransactionCase):
})
picking.action_confirm()
picking.action_assign()
+
with self.assertRaises(UserError):
picking.button_validate()
-
product3_move.move_line_ids[0].qty_done = 1
- # should still complain about lots because no lot_{name,id} was set
with self.assertRaises(UserError):
picking.button_validate()
-
product3_move.move_line_ids[0].lot_name = '271828'
action = picking.button_validate() # should open backorder wizard
+
self.assertTrue(isinstance(action, dict), 'Should open backorder wizard')
self.assertEqual(action.get('res_model'), 'stock.backorder.confirmation')
@@ -2615,6 +2636,33 @@ class StockMove(TransactionCase):
self.assertTrue(isinstance(action, dict), 'Should open overprocessing wizard')
self.assertEqual(action.get('res_model'), 'stock.overprocessed.transfer')
+ def test_immediate_validate_7(self):
+ """ In a picking with a single unavailable move, clicking on validate without filling any
+ quantities should display an UserError telling the user he cannot process a picking without
+ any processed quantity.
+ """
+ partner = self.env['res.partner'].create({'name': 'Jean'})
+ picking = self.env['stock.picking'].create({
+ 'location_id': self.stock_location.id,
+ 'location_dest_id': self.customer_location.id,
+ 'partner_id': partner.id,
+ 'picking_type_id': self.env.ref('stock.picking_type_out').id,
+ })
+ self.env['stock.move'].create({
+ 'name': 'test_immediate_validate_2',
+ 'location_id': self.stock_location.id,
+ 'location_dest_id': self.customer_location.id,
+ 'picking_id': picking.id,
+ 'product_id': self.product1.id,
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 10.0,
+ })
+ picking.action_confirm()
+ picking.action_assign()
+ # No products are reserved on the move of 10, click on `button_validate`.
+ with self.assertRaises(UserError):
+ picking.button_validate()
+
def test_set_quantity_done_1(self):
move1 = self.env['stock.move'].create({
'name': 'test_set_quantity_done_1',
diff --git a/addons/stock/wizard/stock_immediate_transfer.py b/addons/stock/wizard/stock_immediate_transfer.py
index e137f5ad85d..66a652ac795 100644
--- a/addons/stock/wizard/stock_immediate_transfer.py
+++ b/addons/stock/wizard/stock_immediate_transfer.py
@@ -23,11 +23,8 @@ class StockImmediateTransfer(models.TransientModel):
if picking.state != 'assigned':
raise UserError(_("Could not reserve all requested products. Please use the \'Mark as Todo\' button to handle the reservation manually."))
for move in picking.move_lines:
- if move.move_line_ids:
- for move_line in move.move_line_ids:
- move_line.qty_done = move_line.product_uom_qty
- else:
- move.quantity_done = move.product_uom_qty
+ for move_line in move.move_line_ids:
+ move_line.qty_done = move_line.product_uom_qty
if picking._check_backorder():
pick_to_backorder |= picking
continue
diff --git a/addons/stock_account/models/account_invoice.py b/addons/stock_account/models/account_invoice.py
index 9547c0625e7..8614fde2f6f 100644
--- a/addons/stock_account/models/account_invoice.py
+++ b/addons/stock_account/models/account_invoice.py
@@ -30,13 +30,13 @@ class AccountInvoice(models.Model):
company_currency = inv.company_id.currency_id
price_unit = i_line._get_anglo_saxon_price_unit()
if inv.currency_id != company_currency:
- currency_id = inv.currency_id.id
+ currency = inv.currency_id
amount_currency = i_line._get_price(company_currency, price_unit)
else:
- currency_id = False
+ currency = False
amount_currency = False
- return self.env['product.product']._anglo_saxon_sale_move_lines(i_line.name, i_line.product_id, i_line.uom_id, i_line.quantity, price_unit, currency=currency_id, amount_currency=amount_currency, fiscal_position=inv.fiscal_position_id, account_analytic=i_line.account_analytic_id, analytic_tags=i_line.analytic_tag_ids)
+ return self.env['product.product']._anglo_saxon_sale_move_lines(i_line.name, i_line.product_id, i_line.uom_id, i_line.quantity, price_unit, currency=currency, amount_currency=amount_currency, fiscal_position=inv.fiscal_position_id, account_analytic=i_line.account_analytic_id, analytic_tags=i_line.analytic_tag_ids)
class AccountInvoiceLine(models.Model):
diff --git a/addons/stock_account/models/product.py b/addons/stock_account/models/product.py
index 93bc4c980ae..098e976fc7e 100644
--- a/addons/stock_account/models/product.py
+++ b/addons/stock_account/models/product.py
@@ -234,7 +234,7 @@ class ProductProduct(models.Model):
'product_id': product.id,
'uom_id': uom.id,
'account_analytic_id': account_analytic and account_analytic.id,
- 'analytic_tag_ids': analytic_tags and analytic_tag.ids and [(6, 0, analytic_tag.ids)] or False,
+ 'analytic_tag_ids': analytic_tags and analytic_tags.ids and [(6, 0, analytic_tags.ids)] or False,
},
{
@@ -259,7 +259,7 @@ class ProductProduct(models.Model):
price = self.standard_price
if not uom or self.uom_id.id == uom.id:
return price
- return self.uom_id._compute_price(self.uom_id.id, price, to_uom_id=uom.id)
+ return self.uom_id._compute_price(price, uom)
class ProductCategory(models.Model):
diff --git a/addons/web/controllers/main.py b/addons/web/controllers/main.py
index b0cf820005b..913edc89932 100644
--- a/addons/web/controllers/main.py
+++ b/addons/web/controllers/main.py
@@ -1382,7 +1382,7 @@ class ExportFormat(object):
model, fields, ids, domain, import_compat = \
operator.itemgetter('model', 'fields', 'ids', 'domain', 'import_compat')(params)
- Model = request.env[model].with_context(**params.get('context', {}))
+ Model = request.env[model].with_context(import_compat=import_compat, **params.get('context', {}))
records = Model.browse(ids) or Model.search(domain, offset=0, limit=False, order=False)
if not Model._is_an_ordinary_table():
diff --git a/addons/web/static/src/js/chrome/web_client.js b/addons/web/static/src/js/chrome/web_client.js
index e725aaee8f6..4b32ffecfb3 100644
--- a/addons/web/static/src/js/chrome/web_client.js
+++ b/addons/web/static/src/js/chrome/web_client.js
@@ -59,7 +59,7 @@ return AbstractWebClient.extend({
update_logo: function(reload) {
var company = session.company_id;
var img = session.url('/web/binary/company_logo' + '?db=' + session.db + (company ? '&company=' + company : ''));
- this.$('.o_sub_menu_logo img').attr('src', '').attr('src', img + (reload ? "#" + Date.now() : ''));
+ this.$('.o_sub_menu_logo img').attr('src', '').attr('src', img + (reload ? "&t=" + Date.now() : ''));
this.$('.oe_logo_edit').toggleClass('oe_logo_edit_admin', session.is_superuser);
},
logo_edit: function(ev) {
@@ -83,13 +83,8 @@ return AbstractWebClient.extend({
action_buttons: true,
headless: true,
};
- self.action_manager.do_action(result).then(function () {
- var form = self.action_manager.dialog_widget.views.form.controller;
- form.on("on_button_cancel", self.action_manager, self.action_manager.dialog_stop);
- form.on('record_saved', self, function() {
- self.action_manager.dialog_stop();
- self.update_logo();
- });
+ self.action_manager.do_action(result, {
+ on_close: self.update_logo.bind(self, true),
});
});
});
diff --git a/addons/web/static/src/js/core/domain.js b/addons/web/static/src/js/core/domain.js
index 75aaf0105ed..8ce570b8605 100644
--- a/addons/web/static/src/js/core/domain.js
+++ b/addons/web/static/src/js/core/domain.js
@@ -240,7 +240,7 @@ var Domain = collections.Tree.extend({
*/
stringToArray: function (domain, evalContext) {
if (!_.isString(domain)) return _.clone(domain);
- return pyeval.eval("domain", domain || "[]", evalContext);
+ return pyeval.eval("domain", domain ? domain.replace(/%%/g, '%') : "[]", evalContext);
},
/**
* Makes implicit "&" operators explicit in the given JS prefix-array
diff --git a/addons/web/static/src/js/core/utils.js b/addons/web/static/src/js/core/utils.js
index 1bdd9e70a69..251cd2b508c 100644
--- a/addons/web/static/src/js/core/utils.js
+++ b/addons/web/static/src/js/core/utils.js
@@ -392,6 +392,23 @@ var utils = {
array[i2] = elem1;
array[i1] = elem2;
},
+
+ /**
+ * @param {string} value
+ * @param {boolean} allow_mailto
+ * @returns boolean
+ */
+ is_email: function (value, allow_mailto) {
+ // http://stackoverflow.com/questions/46155/validate-email-address-in-javascript
+ var re;
+ if (allow_mailto) {
+ re = /^(mailto:)?(([^<>()\[\]\.,;:\s@\"]+(\.[^<>()\[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i;
+ } else {
+ re = /^(([^<>()\[\]\.,;:\s@\"]+(\.[^<>()\[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i;
+ }
+ return re.test(value);
+ },
+
/**
* @param {any} str
* @param {any} elseValues
diff --git a/addons/web/static/src/js/views/basic/basic_controller.js b/addons/web/static/src/js/views/basic/basic_controller.js
index 432d810bbbb..6f07ac1c2f8 100644
--- a/addons/web/static/src/js/views/basic/basic_controller.js
+++ b/addons/web/static/src/js/views/basic/basic_controller.js
@@ -332,7 +332,9 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
}
self.model.discardChanges(recordID);
if (self.model.isNew(recordID)) {
- self._abandonRecord(recordID);
+ if (self.model.canBeAbandoned(recordID)) {
+ self._abandonRecord(recordID);
+ }
return;
}
return self._confirmSave(recordID);
diff --git a/addons/web/static/src/js/views/basic/basic_model.js b/addons/web/static/src/js/views/basic/basic_model.js
index b5b668a43a1..6eef41f149d 100644
--- a/addons/web/static/src/js/views/basic/basic_model.js
+++ b/addons/web/static/src/js/views/basic/basic_model.js
@@ -477,6 +477,30 @@ var BasicModel = AbstractModel.extend({
}
return _t("New");
},
+ /**
+ * Returns true if a record can be abandoned from a list datapoint.
+ *
+ * A record cannot be abandonned if it has been registered as "added"
+ * in the parent's savepoint, otherwise it can be abandonned.
+ *
+ * This is useful when discarding changes on this record, as it means that
+ * we must keep the record even if some fields are invalids (e.g. required
+ * field is empty).
+ *
+ * @param {string} id id for a local resource
+ * @returns {boolean}
+ */
+ canBeAbandoned: function (id) {
+ var data = this.localData[id];
+ var parent = this.localData[data.parentID];
+ var abandonable = true;
+ if (parent) {
+ abandonable = !_.some(parent._savePoint, function (entry) {
+ return entry.operation === 'ADD' && entry.id === id;
+ });
+ }
+ return abandonable;
+ },
/**
* Returns true if a record is dirty. A record is considered dirty if it has
* some unsaved changes, marked by the _isDirty property on the record or
@@ -1428,25 +1452,23 @@ var BasicModel = AbstractModel.extend({
defs.push(self._applyOnChange(command[2], rec));
} else if (command[0] === 4) {
// LINK TO
- rec = self.localData[list._cache[command[1]]];
- if (rec) {
- // modifications done on a record are discarded if
- // the onchange uses a LINK TO
- self.discardChanges(rec.id);
- }
- // the dataPoint id will be set when the record will be
- // fetched (for now, this dataPoint may not exist yet)
- list._changes.push({
- operation: 'ADD',
- id: rec ? rec.id : null,
- resID: command[1],
- });
+ linkRecord(list, command[1]);
} else if (command[0] === 5) {
// DELETE ALL
list._changes = [{operation: 'REMOVE_ALL'}];
+ } else if (command[0] === 6) {
+ list._changes = [{operation: 'REMOVE_ALL'}];
+ _.each(command[2], function (resID) {
+ linkRecord(list, resID);
+ });
}
});
- defs.push(self._readUngroupedList(list));
+ var def = self._readUngroupedList(list).then(function () {
+ var x2ManysDef = self._fetchX2ManysBatched(list);
+ var referencesDef = self._fetchReferencesBatched(list);
+ return $.when(x2ManysDef, referencesDef);
+ });
+ defs.push(def);
} else {
var newValue = self._parseServerValue(field, val);
if (newValue !== oldValue) {
@@ -1455,6 +1477,25 @@ var BasicModel = AbstractModel.extend({
}
});
return $.when.apply($, defs);
+
+ // inner function that adds a record (based on its res_id) to a list
+ // dataPoint (used for onchanges that return commands 4 (LINK TO) or
+ // commands 6 (REPLACE WITH))
+ function linkRecord (list, resID) {
+ rec = self.localData[list._cache[resID]];
+ if (rec) {
+ // modifications done on a record are discarded if the onchange
+ // uses a LINK TO or a REPLACE WITH
+ self.discardChanges(rec.id);
+ }
+ // the dataPoint id will be set when the record will be fetched (for
+ // now, this dataPoint may not exist yet)
+ list._changes.push({
+ operation: 'ADD',
+ id: rec ? rec.id : null,
+ resID: resID,
+ });
+ }
},
/**
* When an operation is applied to a x2many field, the field widgets
@@ -1951,7 +1992,9 @@ var BasicModel = AbstractModel.extend({
_.each(list.data, function (dataPoint) {
var record = self.localData[dataPoint];
var value = record.data[fieldName];
- if (value) {
+ // if the reference field has already been fetched, the value is a
+ // datapoint ID, and in this case there's nothing to do
+ if (value && !self.localData[value]) {
var model = value.split(',')[0];
var resID = value.split(',')[1];
if (!(model in toFetch)) {
diff --git a/addons/web/static/src/js/views/kanban/kanban_model.js b/addons/web/static/src/js/views/kanban/kanban_model.js
index df6172f0d8b..0b0ec09ddf4 100644
--- a/addons/web/static/src/js/views/kanban/kanban_model.js
+++ b/addons/web/static/src/js/views/kanban/kanban_model.js
@@ -73,7 +73,6 @@ var KanbanModel = BasicModel.extend({
domain: parent.domain.concat([[groupBy,"=",result[0]]]),
fields: parent.fields,
fieldsInfo: parent.fieldsInfo,
- groupedBy: parent.groupedBy,
isOpen: true,
limit: parent.limit,
parentID: parent.id,
diff --git a/addons/web/static/src/js/views/list/list_editable_renderer.js b/addons/web/static/src/js/views/list/list_editable_renderer.js
index b57d44e7de2..48078e2a22c 100644
--- a/addons/web/static/src/js/views/list/list_editable_renderer.js
+++ b/addons/web/static/src/js/views/list/list_editable_renderer.js
@@ -54,8 +54,13 @@ ListRenderer.include({
* @returns {Deferred}
*/
start: function () {
- if (this._isEditable()) {
- this.$el.css({height: '100%'});
+ // deliberately use the 'editable' attribute instead of '_isEditable'
+ // function, because the groupBy must not be taken into account to
+ // enable the '_onWindowClicked' handler (otherwise, an editable grouped
+ // list which is reloaded without groupBy wouldn't have this handler
+ // bound, and edited rows couldn't be left by clicking outside the list)
+ if (this.editable) {
+ this.$el.css({height: '100%'}); // seems useless: to remove in master
core.bus.on('click', this, this._onWindowClicked.bind(this));
}
return this._super();
diff --git a/addons/web/static/tests/fields/relational_fields_tests.js b/addons/web/static/tests/fields/relational_fields_tests.js
index 0782d667690..16f4d29e22a 100644
--- a/addons/web/static/tests/fields/relational_fields_tests.js
+++ b/addons/web/static/tests/fields/relational_fields_tests.js
@@ -1154,6 +1154,60 @@ QUnit.module('relational_fields', {
form.destroy();
});
+ QUnit.test('list in form: discard newly added element with empty required field', function (assert) {
+ // This test simulates discarding a record that has been created with
+ // one of its required field that is empty. When we discard the changes
+ // on this empty field, it should not assume that this record should be
+ // abandonned, since it has been added (even though it is a new record).
+ assert.expect(6);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '',
+ mockRPC: function (route, args) {
+ if (args.method === 'default_get') {
+ return $.when({p: [[0, 0, {display_name: 'new record', trululu: false}]]});
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.strictEqual($('tr.o_data_row').length, 1,
+ "should have created the new record in the o2m");
+
+ var requiredElement = $('td.o_data_cell.o_required_modifier');
+ assert.strictEqual(requiredElement.length, 1,
+ "should have a required field on this record");
+ assert.strictEqual(requiredElement.text(), "",
+ "should have empty string in the required field on this record");
+
+ requiredElement.click();
+ // discard by clicking on body
+ $('body').click();
+
+ assert.strictEqual($('tr.o_data_row').length, 1,
+ "should still have the record in the o2m");
+
+ // update selector of required field element
+ requiredElement = $('td.o_data_cell.o_required_modifier');
+ assert.strictEqual(requiredElement.length, 1,
+ "should still have the required field on this record");
+ assert.strictEqual(requiredElement.text(), "",
+ "should still have empty string in the required field on this record");
+ form.destroy();
+ });
+
QUnit.test('list in form: default_get with x2many create', function (assert) {
assert.expect(5);
@@ -2852,6 +2906,163 @@ QUnit.module('relational_fields', {
form.destroy();
});
+ QUnit.test('onchange returning a command 6 for an x2many', function (assert) {
+ assert.expect(2);
+
+ this.data.partner.onchanges = {
+ foo: function (obj) {
+ obj.turtles = [[6, false, [1, 2, 3]]];
+ },
+ };
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch:'',
+ res_id: 1,
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ assert.strictEqual(form.$('.o_data_row').length, 1,
+ "there should be one record in the relation");
+
+ // change the value of foo to trigger the onchange
+ form.$('.o_field_widget[name=foo]').val('some value').trigger('input');
+
+ assert.strictEqual(form.$('.o_data_row').length, 3,
+ "there should be three records in the relation");
+
+ form.destroy();
+ });
+
+ QUnit.test('x2many fields inside x2manys are fetched after an onchange', function (assert) {
+ assert.expect(6);
+
+ this.data.turtle.records[0].partner_ids = [1];
+ this.data.partner.onchanges = {
+ foo: function (obj) {
+ obj.turtles = [[5], [4, 1], [4, 2], [4, 3]];
+ },
+ };
+
+ var checkRPC = false;
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch:'',
+ mockRPC: function (route, args) {
+ if (checkRPC && args.method === 'read' && args.model === 'partner') {
+ assert.deepEqual(args.args[1], ['display_name'],
+ "should only read the display_name for the m2m tags");
+ assert.deepEqual(args.args[0], [1],
+ "should only read the display_name of the unknown record");
+ }
+ return this._super.apply(this, arguments);
+ },
+ res_id: 1,
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ assert.strictEqual(form.$('.o_data_row').length, 1,
+ "there should be one record in the relation");
+ assert.strictEqual(form.$('.o_data_row .o_field_widget[name=partner_ids]').text().replace(/\s/g, ''),
+ 'secondrecordaaa', "many2many_tags should be correctly displayed");
+
+ // change the value of foo to trigger the onchange
+ checkRPC = true; // enable flag to check read RPC for the m2m field
+ form.$('.o_field_widget[name=foo]').val('some value').trigger('input');
+
+ assert.strictEqual(form.$('.o_data_row').length, 3,
+ "there should be three records in the relation");
+ assert.strictEqual(form.$('.o_data_row:first .o_field_widget[name=partner_ids]').text().trim(),
+ 'first record', "many2many_tags should be correctly displayed");
+
+ form.destroy();
+ });
+
+ QUnit.test('reference fields inside x2manys are fetched after an onchange', function (assert) {
+ assert.expect(5);
+
+ this.data.turtle.records[1].turtle_ref = 'product,41';
+ this.data.partner.onchanges = {
+ foo: function (obj) {
+ obj.turtles = [[5], [4, 1], [4, 2], [4, 3]];
+ },
+ };
+
+ var checkRPC = false;
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch:'',
+ mockRPC: function (route, args) {
+ if (checkRPC && args.method === 'name_get') {
+ assert.deepEqual(args.args[0], [37],
+ "should only fetch the name_get of the unknown record");
+ }
+ return this._super.apply(this, arguments);
+ },
+ res_id: 1,
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ assert.strictEqual(form.$('.o_data_row').length, 1,
+ "there should be one record in the relation");
+ assert.strictEqual(form.$('.ref_field').text().trim(), 'xpad',
+ "reference field should be correctly displayed");
+
+ // change the value of foo to trigger the onchange
+ checkRPC = true; // enable flag to check read RPC for reference field
+ form.$('.o_field_widget[name=foo]').val('some value').trigger('input');
+
+ assert.strictEqual(form.$('.o_data_row').length, 3,
+ "there should be three records in the relation");
+ assert.strictEqual(form.$('.ref_field').text().trim(), 'xpadxphone',
+ "reference fields should be correctly displayed");
+
+ form.destroy();
+ });
+
QUnit.test('embedded one2many with handle widget with minimum setValue calls', function (assert) {
var done = assert.async();
assert.expect(20);
diff --git a/addons/web/static/tests/views/kanban_tests.js b/addons/web/static/tests/views/kanban_tests.js
index 00625e1e30c..daa68c6c638 100644
--- a/addons/web/static/tests/views/kanban_tests.js
+++ b/addons/web/static/tests/views/kanban_tests.js
@@ -1947,6 +1947,62 @@ QUnit.module('Views', {
kanban.destroy();
});
+ QUnit.test('archive new kanban column', function (assert) {
+ assert.expect(15);
+
+ this.data.partner.fields.active = {string: 'Active', type: 'char', default: true};
+
+ var kanban = createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '' +
+ '' +
+ '' +
+ '
' +
+ '' +
+ '',
+ groupBy: ['product_id'],
+ mockRPC: function (route) {
+ assert.step(route);
+ return this._super.apply(this, arguments);
+ },
+ });
+ var rpcs = ['/web/dataset/call_kw/partner/read_group', '/web/dataset/search_read', '/web/dataset/search_read'];
+ assert.strictEqual(kanban.$('.o_kanban_group').length, 2,
+ "there should be 2 columns");
+
+ // create a column
+ kanban.$('.o_column_quick_create').click();
+ kanban.$('.o_column_quick_create input').val('new colum');
+ kanban.$('.o_column_quick_create button.o_kanban_add').click();
+ rpcs.push('/web/dataset/call_kw/product/name_create');
+ assert.verifySteps(rpcs);
+
+ // add a record inside
+ kanban.$('.o_kanban_group:eq(2) .o_kanban_quick_add i').click();
+ var $quickCreate = kanban.$('.o_kanban_group:eq(2) .o_kanban_quick_create');
+ $quickCreate.find('input').val('new record');
+ $quickCreate.find('button.o_kanban_add').click();
+ assert.strictEqual(kanban.$('.o_kanban_group').length, 3,
+ "there should be 3 columns");
+ rpcs.push('/web/dataset/call_kw/partner/name_create');
+ rpcs.push('/web/dataset/call_kw/partner/read');
+ assert.verifySteps(rpcs);
+
+ var $newColumn = kanban.$('.o_kanban_group:eq(2)');
+ assert.strictEqual($newColumn.find('.o_kanban_record').length, 1,
+ "there should be 1 record in the new column");
+ $newColumn.find('.o_column_archive').click();
+ assert.strictEqual($newColumn.find('.o_kanban_record').length, 0,
+ "there should not be partners anymore");
+ rpcs.push('/web/dataset/call_kw/partner/write');
+ rpcs.push('/web/dataset/search_read');
+ assert.verifySteps(rpcs, "a search_read should be done after archiving the records");
+
+ kanban.destroy();
+ });
+
QUnit.test('load more records in column', function (assert) {
assert.expect(12);
diff --git a/addons/web/static/tests/views/list_tests.js b/addons/web/static/tests/views/list_tests.js
index 92ae4033562..f1c36aac0a7 100644
--- a/addons/web/static/tests/views/list_tests.js
+++ b/addons/web/static/tests/views/list_tests.js
@@ -2751,10 +2751,13 @@ QUnit.module('Views', {
list.destroy();
});
- QUnit.test('grouped list are not editable', function (assert) {
+ QUnit.test('grouped list are not editable (ungrouped first)', function (assert) {
// Editable grouped list views are not supported, so the purpose of this
// test is to check that when a list view is grouped, its editable
// attribute is ignored
+ // In this test, the view isn't grouped at the beginning, so it is first
+ // editable, and then it is reloaded with a groupBy and is no longer
+ // editable
assert.expect(5);
var list = createView({
@@ -2790,6 +2793,59 @@ QUnit.module('Views', {
list.destroy();
});
+ QUnit.test('grouped list are not editable (grouped first)', function (assert) {
+ // Editable grouped list views are not supported, so the purpose of this
+ // test is to check that when a list view is grouped, its editable
+ // attribute is ignored
+ // In this test, the view is grouped at the beginning, so it isn't
+ // editable, and then it is reloaded with no groupBy and becomes editable
+ assert.expect(6);
+
+ var list = createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '',
+ intercepts: {
+ switch_view: function (event) {
+ var resID = event.data.res_id || false;
+ assert.step('switch view ' + event.data.view_type + ' ' + resID);
+ },
+ },
+ groupBy: ['bar'],
+ });
+
+ // the view being grouped, it is not editable, so clicking on a record
+ // should open the form view
+ list.$('.o_group_header:first').click(); // open first group
+ list.$('.o_data_cell:first').click();
+
+ // for the same reason, clicking on 'Create' should open the form view
+ list.$buttons.find('.o_list_button_add').click();
+
+ assert.verifySteps(['switch view form 1', 'switch view form false'],
+ "two switch view to form should have been requested");
+
+ // reload without groupBy
+ list.reload({groupBy: []});
+
+ // as the view is no longer grouped, it is editable, so clicking on a
+ // row should switch it in edition
+ list.$('.o_data_cell:first').click();
+
+ assert.verifySteps(['switch view form 1', 'switch view form false'],
+ "no more switch view should have been requested");
+ assert.strictEqual(list.$('.o_selected_row').length, 1,
+ "a row should be in edition");
+
+ // clicking on the body should leave the edition
+ $('body').click();
+ assert.strictEqual(list.$('.o_selected_row').length, 0,
+ "the row should no longer be in edition");
+
+ list.destroy();
+ });
+
QUnit.test('field values are escaped', function (assert) {
assert.expect(1);
var value = '';
diff --git a/addons/web_editor/static/src/js/widgets/ace.js b/addons/web_editor/static/src/js/widgets/ace.js
index 0a89d0d8ce7..64502b41c31 100644
--- a/addons/web_editor/static/src/js/widgets/ace.js
+++ b/addons/web_editor/static/src/js/widgets/ace.js
@@ -58,6 +58,10 @@ function checkXML(xml) {
* @returns {string} formatted xml
*/
function formatXML(xml) {
+ // do nothing if an inline script is present to avoid breaking it
+ if (/