diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py
index 49536efbf4..ef520537c2 100644
--- a/addons/account/models/account_move.py
+++ b/addons/account/models/account_move.py
@@ -2134,7 +2134,7 @@ class AccountMove(models.Model):
values['total_amount_currency'] += sign * line.amount_currency
values['total_residual_currency'] += sign * line.amount_residual_currency
- elif not line.tax_exigible and not line.reconciled:
+ elif not line.tax_exigible:
values['to_process_lines'] += line
currencies.add(line.currency_id or line.company_currency_id)
@@ -3653,6 +3653,16 @@ class AccountMoveLine(models.Model):
vals = {'price_unit': 0.0}
return vals
+ def _get_invoiced_qty_per_product(self):
+ qties = defaultdict(float)
+ for aml in self:
+ qty = aml.product_uom_id._compute_quantity(aml.quantity, aml.product_id.uom_id)
+ if aml.move_id.move_type == 'out_invoice':
+ qties[aml.product_id] += qty
+ elif aml.move_id.move_type == 'out_refund':
+ qties[aml.product_id] -= qty
+ return qties
+
# -------------------------------------------------------------------------
# ONCHANGE METHODS
# -------------------------------------------------------------------------
@@ -4600,7 +4610,7 @@ class AccountMoveLine(models.Model):
# to compute the residual amount for each of them.
# ==========================================================================
- for line in move_values['to_process_lines']:
+ for line in move_values['to_process_lines'].filtered(lambda x: not x.reconciled):
vals = {
'currency_id': line.currency_id.id,
diff --git a/addons/l10n_it_edi/models/res_partner.py b/addons/l10n_it_edi/models/res_partner.py
index 2024221340..e7b80924ec 100644
--- a/addons/l10n_it_edi/models/res_partner.py
+++ b/addons/l10n_it_edi/models/res_partner.py
@@ -38,7 +38,7 @@ class ResPartner(models.Model):
@api.onchange('vat', 'country_id')
def _l10n_it_onchange_vat(self):
- if not self.l10n_it_codice_fiscale and (self.country_id.code == "IT" or (self.vat and self.vat.startswith("IT"))):
+ if not self.l10n_it_codice_fiscale and self.vat and (self.country_id.code == "IT" or self.vat.startswith("IT")):
self.l10n_it_codice_fiscale = self._l10n_it_normalize_codice_fiscale(self.vat)
elif self.country_id.code not in [False, "IT"]:
self.l10n_it_codice_fiscale = ""
diff --git a/addons/l10n_nl/models/account_journal.py b/addons/l10n_nl/models/account_journal.py
index eeebde966e..afdf084842 100644
--- a/addons/l10n_nl/models/account_journal.py
+++ b/addons/l10n_nl/models/account_journal.py
@@ -14,7 +14,7 @@ class AccountJournal(models.Model):
return
company = self.env['res.company'].browse(vals['company_id']) if vals.get('company_id') else self.env.company
- if company.country_id.code == "NL" and not vals.get('type_control_ids')[0][2]:
+ if company.country_id.code == "NL" and not vals.get('type_control_ids', [(6, 0, [])])[0][2]:
type_control_ids = self.env.ref('account.data_account_type_direct_costs').ids
vals['type_control_ids'] = [(6, 0, type_control_ids)]
diff --git a/addons/mail/models/models.py b/addons/mail/models/models.py
index 1d9435551b..1c19a04926 100644
--- a/addons/mail/models/models.py
+++ b/addons/mail/models/models.py
@@ -138,11 +138,12 @@ class BaseModel(models.AbstractModel):
if catchall:
result_email.update(dict((rid, '%s@%s' % (catchall, alias_domain)) for rid in left_ids))
- # compute name of reply-to - TDE tocheck: quotes and stuff like that
- company_name = company.name if company else self.env.company.name
for res_id in result_email:
- name = '%s%s%s' % (company_name, ' ' if doc_names.get(res_id) else '', doc_names.get(res_id, ''))
- result[res_id] = tools.formataddr((name, result_email[res_id]))
+ result[res_id] = self._notify_get_reply_to_formatted_email(
+ result_email[res_id],
+ doc_names.get(res_id) or '',
+ company or self.env.company
+ )
left_ids = set(_res_ids) - set(result_email)
if left_ids:
@@ -150,6 +151,35 @@ class BaseModel(models.AbstractModel):
return result
+ def _notify_get_reply_to_formatted_email(self, record_email, record_name, company):
+ """ Compute formatted email for reply_to and try to avoid refold issue
+ with python that splits the reply-to over multiple lines. It is due to
+ a bad management of quotes (missing quotes after refold). This appears
+ therefore only when having quotes (aka not simple names, and not when
+ being unicode encoded).
+
+ To avoid that issue when formataddr would return more than 78 chars we
+ return a simplified name/email to try to stay under 78 chars. If not
+ possible we return only the email and skip the formataddr which causes
+ the issue in python. We do not use hacks like crop the name part as
+ encoding and quoting would be error prone.
+ """
+ # address itself is too long for 78 chars limit: return only email
+ if len(record_email) >= 78:
+ return record_email
+
+ company_name = company.name if company else self.env.company.name
+
+ # try company_name + record_name, or record_name alone (or company_name alone)
+ name = f"{company_name} {record_name}" if record_name else company_name
+
+ formatted_email = tools.formataddr((name, record_email))
+ if len(formatted_email) > 78:
+ formatted_email = tools.formataddr((record_name or company_name, record_email))
+ if len(formatted_email) > 78:
+ formatted_email = record_email
+ return formatted_email
+
# ------------------------------------------------------------
# ALIAS MANAGEMENT
# ------------------------------------------------------------
diff --git a/addons/mrp/models/mrp_workorder.py b/addons/mrp/models/mrp_workorder.py
index 6722449a06..0393dc71dd 100644
--- a/addons/mrp/models/mrp_workorder.py
+++ b/addons/mrp/models/mrp_workorder.py
@@ -709,7 +709,8 @@ class MrpWorkorder(models.Model):
duration_expected_working = (self.duration_expected - self.workcenter_id.time_start - self.workcenter_id.time_stop) * self.workcenter_id.time_efficiency / (100.0 * cycle_number)
if duration_expected_working < 0:
duration_expected_working = 0
- return alternative_workcenter.time_start + alternative_workcenter.time_stop + cycle_number * duration_expected_working * 100.0 / alternative_workcenter.time_efficiency
+ alternative_wc_cycle_nb = float_round(qty_production / alternative_workcenter.capacity, precision_digits=0, rounding_method='UP')
+ return alternative_workcenter.time_start + alternative_workcenter.time_stop + alternative_wc_cycle_nb * duration_expected_working * 100.0 / alternative_workcenter.time_efficiency
time_cycle = self.operation_id.time_cycle
return self.workcenter_id.time_start + self.workcenter_id.time_stop + cycle_number * time_cycle * 100.0 / self.workcenter_id.time_efficiency
diff --git a/addons/mrp/models/stock_orderpoint.py b/addons/mrp/models/stock_orderpoint.py
index 239c783d67..d5eed3d862 100644
--- a/addons/mrp/models/stock_orderpoint.py
+++ b/addons/mrp/models/stock_orderpoint.py
@@ -44,13 +44,17 @@ class StockWarehouseOrderpoint(models.Model):
orderpoint.show_bom = orderpoint.route_id.id in manufacture_route
def _quantity_in_progress(self):
- bom_kit_orderpoints = {
- orderpoint: bom
- for orderpoint in self
- for bom in self.env['mrp.bom']._bom_find(product=orderpoint.product_id, bom_type='phantom')
- if bom
- }
- res = super(StockWarehouseOrderpoint, self.filtered(lambda p: p not in bom_kit_orderpoints))._quantity_in_progress()
+ bom_manufacture = self.env['mrp.bom']
+ bom_kit_orderpoints = {}
+ for orderpoint in self:
+ bom = self.env['mrp.bom']._bom_find(product=orderpoint.product_id)
+ if bom.type == 'phantom':
+ bom_kit_orderpoints[orderpoint] = bom
+ elif bom.type == 'normal':
+ bom_manufacture |= bom
+
+ orderpoints_without_kit = self - self.env['stock.warehouse.orderpoint'].concat(*bom_kit_orderpoints.keys())
+ res = super(StockWarehouseOrderpoint, orderpoints_without_kit)._quantity_in_progress()
for orderpoint in bom_kit_orderpoints:
boms, bom_sub_lines = bom_kit_orderpoints[orderpoint].explode(orderpoint.product_id, 1)
ratios_qty_available = []
@@ -73,6 +77,16 @@ class StockWarehouseOrderpoint(models.Model):
# (the quantity if we have received all in-progress components) - (the quantity using only available components)
product_qty = min(ratios_total or [0]) - min(ratios_qty_available or [0])
res[orderpoint.id] = orderpoint.product_id.uom_id._compute_quantity(product_qty, orderpoint.product_uom, round=False)
+
+ productions_group = self.env['mrp.production'].read_group(
+ [('bom_id', 'in', bom_manufacture.ids), ('state', '=', 'draft'), ('orderpoint_id', 'in', orderpoints_without_kit.ids)],
+ ['orderpoint_id', 'product_qty', 'product_uom_id'],
+ ['orderpoint_id', 'product_uom_id'], lazy=False)
+ for p in productions_group:
+ uom = self.env['uom.uom'].browse(p['product_uom_id'][0])
+ orderpoint = self.env['stock.warehouse.orderpoint'].browse(p['orderpoint_id'][0])
+ res[orderpoint.id] += uom._compute_quantity(
+ p['product_qty'], orderpoint.product_uom, round=False)
return res
def _set_default_route_id(self):
diff --git a/addons/mrp/tests/test_order.py b/addons/mrp/tests/test_order.py
index 2fed8ede99..01a3563b23 100644
--- a/addons/mrp/tests/test_order.py
+++ b/addons/mrp/tests/test_order.py
@@ -2193,3 +2193,83 @@ class TestMrpOrder(TestMrpCommon):
production_form.product_qty = 5
production = production_form.save()
self.assertEqual(production.workorder_ids[0].duration_expected, 30.0, "Produce 5 with capacity 2, expected is 10mn for each run -> 30mn")
+
+ def test_planning_workorder(self):
+ """
+ Check that the fastest work center is used when planning the workorder.
+
+ - create two work centers with similar production capacity
+ but the work_center_2 with a longer start and stop time.
+
+ 1:/ produce 2 units > work_center_1 faster because
+ it does not need much time to start and to finish the production.
+
+ 2/ - update the production capacity of the work_center_2 to 4
+ - produce 4 units > work_center_2 faster because
+ it must do a single cycle while the work_center_1 have to do two cycles.
+ """
+ workcenter_1 = self.env['mrp.workcenter'].create({
+ 'name': 'wc1',
+ 'capacity': 2,
+ 'time_start': 1,
+ 'time_stop': 1,
+ 'time_efficiency': 100,
+ })
+
+ workcenter_2 = self.env['mrp.workcenter'].create({
+ 'name': 'wc2',
+ 'capacity': 2,
+ 'time_start': 10,
+ 'time_stop': 5,
+ 'time_efficiency': 100,
+ 'alternative_workcenter_ids': [workcenter_1.id]
+ })
+
+ product_to_build = self.env['product.product'].create({
+ 'name': 'final product',
+ 'type': 'product',
+ })
+
+ product_to_use = self.env['product.product'].create({
+ 'name': 'component',
+ 'type': 'product',
+ })
+
+ bom = self.env['mrp.bom'].create({
+ 'product_id': product_to_build.id,
+ 'product_tmpl_id': product_to_build.product_tmpl_id.id,
+ 'product_uom_id': self.uom_unit.id,
+ 'product_qty': 1.0,
+ 'type': 'normal',
+ 'consumption': 'flexible',
+ 'operation_ids': [
+ (0, 0, {'name': 'Test', 'workcenter_id': workcenter_2.id, 'time_cycle': 60, 'sequence': 1}),
+ ],
+ 'bom_line_ids': [
+ (0, 0, {'product_id': product_to_use.id, 'product_qty': 1}),
+ ]})
+
+ #MO_1
+ mo_form = Form(self.env['mrp.production'])
+ mo_form.product_id = product_to_build
+ mo_form.bom_id = bom
+ mo_form.product_qty = 2
+ mo = mo_form.save()
+ mo.action_confirm()
+ mo.button_plan()
+ self.assertEqual(mo.workorder_ids[0].workcenter_id.id, workcenter_1.id, 'workcenter_1 is faster than workcenter_2 to manufacture 2 units')
+ # Unplan the mo to prevent the first workcenter from being busy
+ mo.button_unplan()
+
+ # Update the production capcity
+ workcenter_2.capacity = 4
+
+ #MO_2
+ mo_form = Form(self.env['mrp.production'])
+ mo_form.product_id = product_to_build
+ mo_form.bom_id = bom
+ mo_form.product_qty = 4
+ mo_2 = mo_form.save()
+ mo_2.action_confirm()
+ mo_2.button_plan()
+ self.assertEqual(mo_2.workorder_ids[0].workcenter_id.id, workcenter_2.id, 'workcenter_2 is faster than workcenter_1 to manufacture 4 units')
diff --git a/addons/mrp/tests/test_procurement.py b/addons/mrp/tests/test_procurement.py
index 9b97a2e85c..afe09f492b 100644
--- a/addons/mrp/tests/test_procurement.py
+++ b/addons/mrp/tests/test_procurement.py
@@ -596,3 +596,83 @@ class TestProcurement(TestMrpCommon):
mo.button_mark_done()
self.assertEqual(pick_output.move_ids_without_package.reserved_availability, 10, "Completed products should have been auto-reserved in picking")
+
+ def test_rr_with_dependance_between_bom(self):
+ self.warehouse = self.env.ref('stock.warehouse0')
+ route_mto = self.warehouse.mto_pull_id.route_id
+ route_mto.active = True
+ route_manufacture = self.warehouse.manufacture_pull_id.route_id
+ product_1 = self.env['product.product'].create({
+ 'name': 'Product A',
+ 'type': 'product',
+ 'route_ids': [(6, 0, [route_manufacture.id])]
+ })
+ product_2 = self.env['product.product'].create({
+ 'name': 'Product B',
+ 'type': 'product',
+ 'route_ids': [(6, 0, [route_manufacture.id, route_mto.id])]
+ })
+ product_3 = self.env['product.product'].create({
+ 'name': 'Product B',
+ 'type': 'product',
+ 'route_ids': [(6, 0, [route_manufacture.id])]
+ })
+ product_4 = self.env['product.product'].create({
+ 'name': 'Product C',
+ 'type': 'consu',
+ })
+
+ op1 = self.env['stock.warehouse.orderpoint'].create({
+ 'name': 'Product A',
+ 'location_id': self.warehouse.lot_stock_id.id,
+ 'product_id': product_1.id,
+ 'product_min_qty': 1,
+ 'product_max_qty': 20,
+ })
+
+ op2 = self.env['stock.warehouse.orderpoint'].create({
+ 'name': 'Product B',
+ 'location_id': self.warehouse.lot_stock_id.id,
+ 'product_id': product_3.id,
+ 'product_min_qty': 5,
+ 'product_max_qty': 50,
+ })
+
+ self.env['mrp.bom'].create({
+ 'product_id': product_1.id,
+ 'product_tmpl_id': product_1.product_tmpl_id.id,
+ 'product_uom_id': self.uom_unit.id,
+ 'product_qty': 1,
+ 'consumption': 'flexible',
+ 'type': 'normal',
+ 'bom_line_ids': [(0, 0, {'product_id': product_2.id, 'product_qty': 1})]
+ })
+
+ self.env['mrp.bom'].create({
+ 'product_id': product_2.id,
+ 'product_tmpl_id': product_2.product_tmpl_id.id,
+ 'product_uom_id': self.uom_unit.id,
+ 'product_qty': 1,
+ 'consumption': 'flexible',
+ 'type': 'normal',
+ 'bom_line_ids': [(0, 0, {'product_id': product_3.id, 'product_qty': 1})]
+ })
+
+ self.env['mrp.bom'].create({
+ 'product_id': product_3.id,
+ 'product_tmpl_id': product_3.product_tmpl_id.id,
+ 'product_uom_id': self.uom_unit.id,
+ 'product_qty': 1,
+ 'consumption': 'flexible',
+ 'type': 'normal',
+ 'bom_line_ids': [(0, 0, {'product_id': product_4.id, 'product_qty': 1})]
+ })
+
+ (op1 | op2)._procure_orderpoint_confirm()
+ mo1 = self.env['mrp.production'].search([('product_id', '=', product_1.id)])
+ mo3 = self.env['mrp.production'].search([('product_id', '=', product_3.id)])
+
+ self.assertEqual(len(mo1), 1)
+ self.assertEqual(len(mo3), 1)
+ self.assertEqual(mo1.product_qty, 20)
+ self.assertEqual(mo3.product_qty, 50)
diff --git a/addons/mrp_account/models/__init__.py b/addons/mrp_account/models/__init__.py
index cb124c405d..b179723457 100644
--- a/addons/mrp_account/models/__init__.py
+++ b/addons/mrp_account/models/__init__.py
@@ -4,3 +4,4 @@ from . import mrp_workcenter
from . import mrp_production
from . import product
from . import stock_move
+from . import account_move
diff --git a/addons/mrp_account/models/account_move.py b/addons/mrp_account/models/account_move.py
new file mode 100644
index 0000000000..1afa4068c0
--- /dev/null
+++ b/addons/mrp_account/models/account_move.py
@@ -0,0 +1,26 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details.
+
+from flectra import models
+
+from collections import defaultdict
+
+
+class AccountMoveLine(models.Model):
+ _inherit = "account.move.line"
+
+ def _get_invoiced_qty_per_product(self):
+ # Replace the kit-type products with their components
+ qties = defaultdict(float)
+ res = super()._get_invoiced_qty_per_product()
+ for product, qty in res.items():
+ bom_kit = self.env['mrp.bom']._bom_find(product=product, company_id=self.company_id[:1].id, bom_type='phantom')
+ if bom_kit:
+ invoiced_qty = product.uom_id._compute_quantity(qty, bom_kit.product_uom_id, round=False)
+ factor = invoiced_qty / bom_kit.product_qty
+ dummy, bom_sub_lines = bom_kit.explode(product, factor)
+ for bom_line, bom_line_data in bom_sub_lines:
+ qties[bom_line.product_id] += bom_line.product_uom_id._compute_quantity(bom_line_data['qty'], bom_line.product_id.uom_id)
+ else:
+ qties[product] += qty
+ return qties
diff --git a/addons/payment_authorize/controllers/main.py b/addons/payment_authorize/controllers/main.py
index f2e6d26ff2..cf2d9e0670 100644
--- a/addons/payment_authorize/controllers/main.py
+++ b/addons/payment_authorize/controllers/main.py
@@ -17,8 +17,18 @@ class AuthorizeController(http.Controller):
@http.route([
'/payment/authorize/return/',
'/payment/authorize/cancel/',
- ], type='http', auth='public', csrf=False)
+ ], type='http', auth='public', csrf=False, save_session=False)
def authorize_form_feedback(self, **post):
+ """ Process the data returned by Authorize after redirection.
+
+ The route is flagged with `save_session=False` to prevent Flectra from assigning a new session
+ to the user if they are redirected to this route with a POST request. Indeed, as the session
+ cookie is created without a `SameSite` attribute, some browsers that don't implement the
+ recommended default `SameSite=Lax` behavior will not include the cookie in the redirection
+ request from the payment provider to Flectra. As the redirection to the '/payment/status' page
+ will satisfy any specification of the `SameSite` attribute, the session of the user will be
+ retrieved and with it the transaction which will be immediately post-processed.
+ """
_logger.info('Authorize: entering form_feedback with post data %s', pprint.pformat(post))
if post:
request.env['payment.transaction'].sudo().form_feedback(post, 'authorize')
@@ -77,7 +87,7 @@ class AuthorizeController(http.Controller):
#You cannot issue refunds against unsettled, voided, declined or errored transactions.
return res
- @http.route(['/payment/authorize/s2s/create'], type='http', auth='public')
+ @http.route(['/payment/authorize/s2s/create'], type='http', auth='public', save_session=False)
def authorize_s2s_create(self, **post):
acquirer_id = int(post.get('acquirer_id'))
acquirer = request.env['payment.acquirer'].browse(acquirer_id)
diff --git a/addons/pos_adyen/static/src/js/PaymentScreen.js b/addons/pos_adyen/static/src/js/PaymentScreen.js
new file mode 100644
index 0000000000..7e02301775
--- /dev/null
+++ b/addons/pos_adyen/static/src/js/PaymentScreen.js
@@ -0,0 +1,36 @@
+flectra.define('pos_adyen.PaymentScreen', function(require) {
+ "use strict";
+
+ const PaymentScreen = require('point_of_sale.PaymentScreen');
+ const Registries = require('point_of_sale.Registries');
+ const { onMounted } = owl.hooks;
+
+ const PosAdyenPaymentScreen = PaymentScreen => class extends PaymentScreen {
+ constructor() {
+ super(...arguments);
+ onMounted(() => {
+ const pendingPaymentLine = this.currentOrder.paymentlines.find(
+ paymentLine => paymentLine.payment_method.use_payment_terminal === 'adyen' &&
+ (!paymentLine.is_done() && paymentLine.get_payment_status() !== 'pending')
+ );
+ if (pendingPaymentLine) {
+ const paymentTerminal = pendingPaymentLine.payment_method.payment_terminal;
+ paymentTerminal.set_most_recent_service_id(pendingPaymentLine.terminalServiceId);
+ pendingPaymentLine.set_payment_status('waiting');
+ paymentTerminal.start_get_status_polling().then(isPaymentSuccessful => {
+ if (isPaymentSuccessful) {
+ pendingPaymentLine.set_payment_status('done');
+ pendingPaymentLine.can_be_reversed = paymentTerminal.supports_reversals;
+ } else {
+ pendingPaymentLine.set_payment_status('retry');
+ }
+ });
+ }
+ });
+ }
+ };
+
+ Registries.Component.extend(PaymentScreen, PosAdyenPaymentScreen);
+
+ return PaymentScreen;
+});
diff --git a/addons/pos_adyen/static/src/js/models.js b/addons/pos_adyen/static/src/js/models.js
index 87aa0b5b62..b0bc005f51 100644
--- a/addons/pos_adyen/static/src/js/models.js
+++ b/addons/pos_adyen/static/src/js/models.js
@@ -5,4 +5,25 @@ var PaymentAdyen = require('pos_adyen.payment');
models.register_payment_method('adyen', PaymentAdyen);
models.register_payment_method('flectra_adyen', PaymentAdyen);
models.load_fields('pos.payment.method', 'adyen_terminal_identifier');
+
+const superPaymentline = models.Paymentline.prototype;
+models.Paymentline = models.Paymentline.extend({
+ initialize: function(attr, options) {
+ superPaymentline.initialize.call(this,attr,options);
+ this.terminalServiceId = this.terminalServiceId || null;
+ },
+ export_as_JSON: function(){
+ const json = superPaymentline.export_as_JSON.call(this);
+ json.terminal_service_id = this.terminalServiceId;
+ return json;
+ },
+ init_from_JSON: function(json){
+ superPaymentline.init_from_JSON.apply(this,arguments);
+ this.terminalServiceId = json.terminal_service_id;
+ },
+ setTerminalServiceId: function(id) {
+ this.terminalServiceId = id;
+ }
+});
+
});
diff --git a/addons/pos_adyen/static/src/js/payment_adyen.js b/addons/pos_adyen/static/src/js/payment_adyen.js
index f53bc28f11..e5b87b6341 100644
--- a/addons/pos_adyen/static/src/js/payment_adyen.js
+++ b/addons/pos_adyen/static/src/js/payment_adyen.js
@@ -12,7 +12,7 @@ var PaymentAdyen = PaymentInterface.extend({
send_payment_request: function (cid) {
this._super.apply(this, arguments);
this._reset_state();
- return this._adyen_pay();
+ return this._adyen_pay(cid);
},
send_payment_cancel: function (order, cid) {
this._super.apply(this, arguments);
@@ -24,6 +24,10 @@ var PaymentAdyen = PaymentInterface.extend({
this._super.apply(this, arguments);
},
+ set_most_recent_service_id(id) {
+ this.most_recent_service_id = id;
+ },
+
// private methods
_reset_state: function () {
this.was_cancelled = false;
@@ -110,7 +114,7 @@ var PaymentAdyen = PaymentInterface.extend({
return data;
},
- _adyen_pay: function () {
+ _adyen_pay: function (cid) {
var self = this;
var order = this.pos.get_order();
@@ -125,7 +129,8 @@ var PaymentAdyen = PaymentInterface.extend({
}
var data = this._adyen_pay_data();
-
+ var line = order.paymentlines.find(paymentLine => paymentLine.cid === cid);
+ line.setTerminalServiceId(this.most_recent_service_id);
return this._call_adyen(data).then(function (data) {
return self._adyen_handle_response(data);
});
@@ -265,6 +270,8 @@ var PaymentAdyen = PaymentInterface.extend({
self._show_error(_t('The connection to your payment terminal failed. Please check if it is still connected to the internet.'));
self._adyen_cancel();
resolve(false);
+ } else {
+ line.set_payment_status('waitingCard')
}
});
},
@@ -296,27 +303,30 @@ var PaymentAdyen = PaymentInterface.extend({
return Promise.resolve();
} else {
line.set_payment_status('waitingCard');
-
- var self = this;
- var res = new Promise(function (resolve, reject) {
- // clear previous intervals just in case, otherwise
- // it'll run forever
- clearTimeout(self.polling);
-
- self.polling = setInterval(function () {
- self._poll_for_response(resolve, reject);
- }, 5500);
- });
-
- // make sure to stop polling when we're done
- res.finally(function () {
- self._reset_state();
- });
-
- return res;
+ return this.start_get_status_polling()
}
},
+ start_get_status_polling() {
+ var self = this;
+ var res = new Promise(function (resolve, reject) {
+ // clear previous intervals just in case, otherwise
+ // it'll run forever
+ clearTimeout(self.polling);
+ self._poll_for_response(resolve, reject);
+ self.polling = setInterval(function () {
+ self._poll_for_response(resolve, reject);
+ }, 5500);
+ });
+
+ // make sure to stop polling when we're done
+ res.finally(function () {
+ self._reset_state();
+ });
+
+ return res;
+ },
+
_show_error: function (msg, title) {
if (!title) {
title = _t('Adyen Error');
diff --git a/addons/pos_adyen/views/point_of_sale_assets.xml b/addons/pos_adyen/views/point_of_sale_assets.xml
index 99f3fa74ba..d81ff736ab 100644
--- a/addons/pos_adyen/views/point_of_sale_assets.xml
+++ b/addons/pos_adyen/views/point_of_sale_assets.xml
@@ -3,6 +3,7 @@
+
diff --git a/addons/purchase_stock/models/purchase.py b/addons/purchase_stock/models/purchase.py
index a3de66e9ae..95164a962a 100644
--- a/addons/purchase_stock/models/purchase.py
+++ b/addons/purchase_stock/models/purchase.py
@@ -528,7 +528,7 @@ class PurchaseOrderLine(models.Model):
description_picking = values['product_description_variants']
lines = self.filtered(
lambda l: l.propagate_cancel == values['propagate_cancel']
- and ((values['orderpoint_id'] and not values['move_dest_ids']) and l.orderpoint_id == values['orderpoint_id'] or True)
+ and (l.orderpoint_id == values['orderpoint_id'] if values['orderpoint_id'] and not values['move_dest_ids'] else True)
)
# In case 'product_description_variants' is in the values, we also filter on the PO line
diff --git a/addons/purchase_stock/tests/test_anglo_saxon_valuation_reconciliation.py b/addons/purchase_stock/tests/test_anglo_saxon_valuation_reconciliation.py
index ec65f142d7..552737b4d1 100644
--- a/addons/purchase_stock/tests/test_anglo_saxon_valuation_reconciliation.py
+++ b/addons/purchase_stock/tests/test_anglo_saxon_valuation_reconciliation.py
@@ -210,3 +210,125 @@ class TestValuationReconciliation(ValuationReconciliationTestCommon):
picking = self.env['stock.picking'].search([('purchase_id','=',purchase_order.id)])
self.check_reconciliation(invoice, picking)
+
+ def test_reconcile_cash_basis_bill(self):
+ ''' Test the generation of the CABA move after bill payment
+ '''
+ cash_basis_base_account = self.env['account.account'].create({
+ 'code': 'cash_basis_base_account',
+ 'name': 'cash_basis_base_account',
+ 'user_type_id': self.env.ref('account.data_account_type_revenue').id,
+ 'company_id': self.company_data['company'].id,
+ })
+ self.company_data['company'].account_cash_basis_base_account_id = cash_basis_base_account
+
+ cash_basis_transfer_account = self.env['account.account'].create({
+ 'code': 'cash_basis_transfer_account',
+ 'name': 'cash_basis_transfer_account',
+ 'user_type_id': self.env.ref('account.data_account_type_revenue').id,
+ 'company_id': self.company_data['company'].id,
+ })
+
+ tax_account_1 = self.env['account.account'].create({
+ 'code': 'tax_account_1',
+ 'name': 'tax_account_1',
+ 'user_type_id': self.env.ref('account.data_account_type_revenue').id,
+ 'company_id': self.company_data['company'].id,
+ })
+
+ fake_country = self.env['res.country'].create({
+ 'name': "The Island of the Fly",
+ 'code': 'YY',
+ })
+
+ tax_tags = self.env['account.account.tag'].create({
+ 'name': 'tax_tag_%s' % str(i),
+ 'applicability': 'taxes',
+ 'country_id': fake_country.id,
+ } for i in range(8))
+
+ cash_basis_tax_a_third_amount = self.env['account.tax'].create({
+ 'name': 'tax_1',
+ 'amount': 33.3333,
+ 'company_id': self.company_data['company'].id,
+ 'cash_basis_transition_account_id': cash_basis_transfer_account.id,
+ 'tax_exigibility': 'on_payment',
+ 'invoice_repartition_line_ids': [
+ (0, 0, {
+ 'factor_percent': 100,
+ 'repartition_type': 'base',
+ 'tag_ids': [(6, 0, tax_tags[0].ids)],
+ }),
+
+ (0, 0, {
+ 'factor_percent': 100,
+ 'repartition_type': 'tax',
+ 'account_id': tax_account_1.id,
+ 'tag_ids': [(6, 0, tax_tags[1].ids)],
+ }),
+ ],
+ 'refund_repartition_line_ids': [
+ (0, 0, {
+ 'factor_percent': 100,
+ 'repartition_type': 'base',
+ 'tag_ids': [(6, 0, tax_tags[2].ids)],
+ }),
+
+ (0, 0, {
+ 'factor_percent': 100,
+ 'repartition_type': 'tax',
+ 'account_id': tax_account_1.id,
+ 'tag_ids': [(6, 0, tax_tags[3].ids)],
+ }),
+ ],
+ })
+
+ product_A = self.env["product.product"].create(
+ {
+ "name": "Product A",
+ "type": "product",
+ "default_code": "prda",
+ "categ_id": self.stock_account_product_categ.id,
+ "taxes_id": [(5, 0, 0)],
+ "supplier_taxes_id": [(6, 0, cash_basis_tax_a_third_amount.ids)],
+ "lst_price": 100.0,
+ "standard_price": 10.0,
+ "property_account_income_id": self.company_data["default_account_revenue"].id,
+ "property_account_expense_id": self.company_data["default_account_expense"].id,
+ }
+ )
+ product_A.categ_id.write(
+ {
+ "property_account_creditor_price_difference_categ": False,
+ "property_valuation": "real_time",
+ "property_cost_method": "standard",
+ }
+ )
+
+ date_po_and_delivery = '2018-01-01'
+ purchase_order = self._create_purchase(product_A, date_po_and_delivery, set_tax=True, price_unit=300.0)
+ self._process_pickings(purchase_order.picking_ids, date=date_po_and_delivery)
+
+ bill = self._create_invoice_for_po(purchase_order, '2018-02-02')
+ bill.action_post()
+
+ # Register a payment creating the CABA journal entry on the fly and reconcile it with the tax line.
+ self.env['account.payment.register']\
+ .with_context(active_ids=bill.ids, active_model='account.move')\
+ .create({})\
+ ._create_payments()
+
+ partial_rec = bill.mapped('line_ids.matched_debit_ids')
+ caba_move = self.env['account.move'].search([('tax_cash_basis_rec_id', '=', partial_rec.id)])
+
+ # Tax values based on payment
+ # Invoice amount 300
+ self.assertRecordValues(caba_move.line_ids, [
+ # pylint: disable=C0326
+ # Base amount:
+ {'debit': 0.0, 'credit': 150.0, 'amount_currency': -300.0, 'account_id': cash_basis_base_account.id},
+ {'debit': 150.0, 'credit': 0.0, 'amount_currency': 300.0, 'account_id': cash_basis_base_account.id},
+ # tax:
+ {'debit': 0.0, 'credit': 50.0, 'amount_currency': -100.0, 'account_id': cash_basis_transfer_account.id},
+ {'debit': 50.0, 'credit': 0.0, 'amount_currency': 100.0, 'account_id': tax_account_1.id},
+ ])
diff --git a/addons/purchase_stock/tests/test_reordering_rule.py b/addons/purchase_stock/tests/test_reordering_rule.py
index d315daf6f9..6422f72bb8 100644
--- a/addons/purchase_stock/tests/test_reordering_rule.py
+++ b/addons/purchase_stock/tests/test_reordering_rule.py
@@ -637,3 +637,49 @@ class TestReorderingRule(SavepointCase):
[("product_id", "=", product.id)])
self.assertTrue(po_line)
self.assertEqual("[A] product TEST", po_line.name)
+
+ def test_multi_locations_and_reordering_rule(self):
+ """
+ Suppose two orderpoints for the same product, each one to a different location
+ If the user triggers each orderpoint separately, it should still produce two
+ different purchase order lines (one for each orderpoint)
+ """
+ warehouse = self.env['stock.warehouse'].search([('company_id', '=', self.env.user.id)], limit=1)
+ stock_location = warehouse.lot_stock_id
+ sub_location = self.env['stock.location'].create({'name': 'subloc_1', 'location_id': stock_location.id})
+
+ orderpoint_form = Form(self.env['stock.warehouse.orderpoint'])
+ orderpoint_form.warehouse_id = warehouse
+ orderpoint_form.location_id = stock_location
+ orderpoint_form.product_id = self.product_01
+ orderpoint_form.product_min_qty = 1
+ orderpoint_form.product_max_qty = 1
+ stock_op = orderpoint_form.save()
+
+ orderpoint_form = Form(self.env['stock.warehouse.orderpoint'])
+ orderpoint_form.warehouse_id = warehouse
+ orderpoint_form.location_id = sub_location
+ orderpoint_form.product_id = self.product_01
+ orderpoint_form.product_min_qty = 2
+ orderpoint_form.product_max_qty = 2
+ sub_op = orderpoint_form.save()
+
+ stock_op.action_replenish()
+ sub_op.action_replenish()
+
+ po = self.env['purchase.order'].search([('partner_id', '=', self.partner.id)])
+ self.assertRecordValues(po.order_line, [
+ {'product_id': self.product_01.id, 'product_qty': 1.0, 'orderpoint_id': stock_op.id},
+ {'product_id': self.product_01.id, 'product_qty': 2.0, 'orderpoint_id': sub_op.id},
+ ])
+
+ po.button_confirm()
+ picking = po.picking_ids
+ action = picking.button_validate()
+ wizard = Form(self.env[(action.get('res_model'))].with_context(action['context'])).save()
+ wizard.process()
+
+ self.assertRecordValues(picking.move_line_ids, [
+ {'product_id': self.product_01.id, 'qty_done': 1.0, 'state': 'done', 'location_dest_id': stock_location.id},
+ {'product_id': self.product_01.id, 'qty_done': 2.0, 'state': 'done', 'location_dest_id': sub_location.id},
+ ])
diff --git a/addons/sale_mrp/tests/__init__.py b/addons/sale_mrp/tests/__init__.py
index 6bdbce318e..4f61f044f8 100644
--- a/addons/sale_mrp/tests/__init__.py
+++ b/addons/sale_mrp/tests/__init__.py
@@ -5,4 +5,5 @@ from . import test_sale_mrp_flow
from . import test_sale_mrp_kit_bom
from . import test_sale_mrp_lead_time
from . import test_sale_mrp_procurement
-from . import test_multistep_manufacturing
\ No newline at end of file
+from . import test_multistep_manufacturing
+from . import test_sale_mrp_report
diff --git a/addons/sale_mrp/tests/test_sale_mrp_report.py b/addons/sale_mrp/tests/test_sale_mrp_report.py
new file mode 100644
index 0000000000..b2e3b67748
--- /dev/null
+++ b/addons/sale_mrp/tests/test_sale_mrp_report.py
@@ -0,0 +1,70 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details.
+
+from flectra.tests import common, Form
+
+from flectra.tools import html2plaintext
+
+@common.tagged('post_install', '-at_install')
+class TestSaleMrpInvoices(common.SavepointCase):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.product_by_lot = cls.env['product.product'].create({
+ 'name': 'Product By Lot',
+ 'type': 'product',
+ 'tracking': 'lot',
+ })
+ cls.warehouse = cls.env['stock.warehouse'].search([('company_id', '=', cls.env.company.id)], limit=1)
+ cls.stock_location = cls.warehouse.lot_stock_id
+ cls.lot = cls.env['stock.production.lot'].create({
+ 'name': 'LOT0001',
+ 'product_id': cls.product_by_lot.id,
+ 'company_id': cls.env.company.id,
+ })
+ cls.env['stock.quant']._update_available_quantity(cls.product_by_lot, cls.stock_location, 10, lot_id=cls.lot)
+
+ cls.tracked_kit = cls.env['product.product'].create({
+ 'name': 'Simple Kit',
+ 'type': 'consu',
+ })
+ cls.env['mrp.bom'].create({
+ 'product_tmpl_id': cls.tracked_kit.product_tmpl_id.id,
+ 'type': 'phantom',
+ 'bom_line_ids': [(0, 0, {
+ 'product_id': cls.product_by_lot.id,
+ 'product_qty': 1,
+ })]
+ })
+ cls.partner = cls.env.ref('base.res_partner_1')
+
+ def test_deliver_and_invoice_tracked_components(self):
+ """
+ Suppose the lots are printed on the invoices.
+ The user sells a kit that has one tracked component.
+ The lot of the delivered component should be on the invoice.
+ """
+ display_lots = self.env.ref('sale_stock.group_lot_on_invoice')
+ display_uom = self.env.ref('uom.group_uom')
+ self.env.user.write({'groups_id': [(4, display_lots.id), (4, display_uom.id)]})
+
+ so = self.env['sale.order'].create({
+ 'partner_id': self.partner.id,
+ 'order_line': [
+ (0, 0, {'name': self.tracked_kit.name, 'product_id': self.tracked_kit.id, 'product_uom_qty': 1}),
+ ],
+ })
+ so.action_confirm()
+
+ action = so.picking_ids.button_validate()
+ wizard = Form(self.env[action['res_model']].with_context(action['context'])).save()
+ wizard.process()
+
+ invoice = so._create_invoices()
+ invoice.action_post()
+
+ report = self.env['ir.actions.report']._get_report_from_name('account.report_invoice_with_payments')
+ html = report._render_qweb_html(invoice.ids)[0]
+ text = html2plaintext(html)
+ self.assertRegex(text, r'Product By Lot\n1.00\nUnits\nLOT0001', "There should be a line that specifies 1 x LOT0001")
diff --git a/addons/sale_stock/models/account_move.py b/addons/sale_stock/models/account_move.py
index 98b298979b..2f162303f4 100644
--- a/addons/sale_stock/models/account_move.py
+++ b/addons/sale_stock/models/account_move.py
@@ -4,7 +4,8 @@
from collections import defaultdict
from flectra import fields, models
-from flectra.tools import float_is_zero
+from flectra.tools import float_is_zero, float_compare
+from flectra.tools.misc import formatLang
class AccountMove(models.Model):
@@ -25,86 +26,62 @@ class AccountMove(models.Model):
def _get_invoiced_lot_values(self):
""" Get and prepare data to show a table of invoiced lot on the invoice's report. """
self.ensure_one()
-
- if self.state == 'draft':
+ if self.state == 'draft' or not self.invoice_date or self.move_type not in ('out_invoice', 'out_refund'):
return []
- sale_lines = self.invoice_line_ids.sale_line_ids
- sale_orders = sale_lines.order_id
- stock_move_lines = sale_lines.move_ids.filtered(lambda r: r.state == 'done').move_line_ids
+ current_invoice_amls = self.invoice_line_ids.filtered(lambda aml: not aml.display_type and aml.product_id and aml.quantity)
+ all_invoices_amls = current_invoice_amls.sale_line_ids.invoice_lines.filtered(lambda aml: aml.move_id.state == 'posted').sorted(lambda aml: (aml.date, aml.move_name, aml.id))
+ index = all_invoices_amls.ids.index(current_invoice_amls[:1].id) if current_invoice_amls[:1] in all_invoices_amls else 0
+ previous_amls = all_invoices_amls[:index]
- # Get the other customer invoices and refunds.
- ordered_invoice_ids = sale_orders.mapped('invoice_ids')\
- .filtered(lambda i: i.state not in ['draft', 'cancel'])\
- .sorted(lambda i: (i.invoice_date, i.id))
+ previous_qties_invoiced = previous_amls._get_invoiced_qty_per_product()
+ invoiced_qties = current_invoice_amls._get_invoiced_qty_per_product()
+ invoiced_products = invoiced_qties.keys()
- # Get the position of self in other customer invoices and refunds.
- self_index = None
- i = 0
- for invoice in ordered_invoice_ids:
- if invoice.id == self.id:
- self_index = i
- break
- i += 1
-
- # Get the previous invoices if any.
- previous_invoices = ordered_invoice_ids[:self_index]
-
- # Get the incoming and outgoing sml between self.invoice_date and the previous invoice (if any) of the related product.
- write_dates = [wd for wd in self.invoice_line_ids.mapped('write_date') if wd]
- self_datetime = max(write_dates) if write_dates else None
- last_invoice_datetime = dict()
- for product in self.invoice_line_ids.product_id:
- last_invoice = previous_invoices.filtered(lambda inv: product in inv.invoice_line_ids.product_id)
- last_invoice = last_invoice[-1] if len(last_invoice) else None
- last_write_dates = last_invoice and [wd for wd in last_invoice.invoice_line_ids.mapped('write_date') if wd]
- last_invoice_datetime[product] = max(last_write_dates) if last_write_dates else None
-
- def _filter_incoming_sml(ml):
- if ml.state == 'done' and ml.location_id.usage == 'customer' and ml.lot_id:
- last_date = last_invoice_datetime.get(ml.product_id)
- if last_date:
- return last_date <= ml.date <= self_datetime
- else:
- return ml.date <= self_datetime
- return False
-
- def _filter_outgoing_sml(ml):
- if ml.state == 'done' and ml.location_dest_id.usage == 'customer' and ml.lot_id:
- last_date = last_invoice_datetime.get(ml.product_id)
- if last_date:
- return last_date <= ml.date <= self_datetime
- else:
- return ml.date <= self_datetime
- return False
-
- incoming_sml = stock_move_lines.filtered(_filter_incoming_sml)
- outgoing_sml = stock_move_lines.filtered(_filter_outgoing_sml)
-
- # Prepare and return lot_values
- qties_per_lot = defaultdict(lambda: 0)
- if self.move_type == 'out_refund':
- for ml in outgoing_sml:
- qties_per_lot[ml.lot_id] -= ml.product_uom_id._compute_quantity(ml.qty_done, ml.product_id.uom_id)
- for ml in incoming_sml:
- qties_per_lot[ml.lot_id] += ml.product_uom_id._compute_quantity(ml.qty_done, ml.product_id.uom_id)
- else:
- for ml in outgoing_sml:
- qties_per_lot[ml.lot_id] += ml.product_uom_id._compute_quantity(ml.qty_done, ml.product_id.uom_id)
- for ml in incoming_sml:
- qties_per_lot[ml.lot_id] -= ml.product_uom_id._compute_quantity(ml.qty_done, ml.product_id.uom_id)
- lot_values = []
- for lot_id, qty in qties_per_lot.items():
- if float_is_zero(qty, precision_rounding=lot_id.product_id.uom_id.rounding):
+ qties_per_lot = defaultdict(float)
+ previous_qties_delivered = defaultdict(float)
+ stock_move_lines = current_invoice_amls.sale_line_ids.move_ids.move_line_ids.filtered(lambda sml: sml.state == 'done' and sml.lot_id).sorted(lambda sml: (sml.date, sml.id))
+ for sml in stock_move_lines:
+ if sml.product_id not in invoiced_products:
continue
+ product = sml.product_id
+ product_uom = product.uom_id
+ qty_done = sml.product_uom_id._compute_quantity(sml.qty_done, product_uom)
+
+ if sml.location_id.usage == 'customer':
+ returned_qty = min(qties_per_lot[sml.lot_id], qty_done)
+ qties_per_lot[sml.lot_id] -= returned_qty
+ qty_done = returned_qty - qty_done
+
+ previous_qty_invoiced = previous_qties_invoiced[product]
+ previous_qty_delivered = previous_qties_delivered[product]
+ # If we return more than currently delivered (i.e., qty_done < 0), we remove the surplus
+ # from the previously delivered (and qty_done becomes zero). If it's a delivery, we first
+ # try to reach the previous_qty_invoiced
+ if float_compare(qty_done, 0, precision_rounding=product_uom.rounding) < 0 or \
+ float_compare(previous_qty_delivered, previous_qty_invoiced, precision_rounding=product_uom.rounding) < 0:
+ previously_done = qty_done if sml.location_id.usage == 'customer' else min(previous_qty_invoiced - previous_qty_delivered, qty_done)
+ previous_qties_delivered[product] += previously_done
+ qty_done -= previously_done
+
+ qties_per_lot[sml.lot_id] += qty_done
+
+ lot_values = []
+ for lot, qty in qties_per_lot.items():
+ if float_is_zero(invoiced_qties[lot.product_id], precision_rounding=lot.product_uom_id.rounding) \
+ or float_compare(qty, 0, precision_rounding=lot.product_uom_id.rounding) <= 0:
+ continue
+ invoiced_lot_qty = min(qty, invoiced_qties[lot.product_id])
+ invoiced_qties[lot.product_id] -= invoiced_lot_qty
lot_values.append({
- 'product_name': lot_id.product_id.display_name,
- 'quantity': self.env['ir.qweb.field.float'].value_to_html(qty, {'precision': self.env['decimal.precision'].precision_get('Product Unit of Measure')}),
- 'uom_name': lot_id.product_uom_id.name,
- 'lot_name': lot_id.name,
+ 'product_name': lot.product_id.display_name,
+ 'quantity': formatLang(self.env, invoiced_lot_qty, dp='Product Unit of Measure'),
+ 'uom_name': lot.product_uom_id.name,
+ 'lot_name': lot.name,
# The lot id is needed by localizations to inherit the method and add custom fields on the invoice's report.
- 'lot_id': lot_id.id
+ 'lot_id': lot.id,
})
+
return lot_values
diff --git a/addons/sale_stock/tests/__init__.py b/addons/sale_stock/tests/__init__.py
index ca970a9c03..b9becc5fe3 100644
--- a/addons/sale_stock/tests/__init__.py
+++ b/addons/sale_stock/tests/__init__.py
@@ -9,3 +9,4 @@ from . import test_sale_stock_lead_time
from . import test_sale_stock_report
from . import test_sale_order_dates
from . import test_sale_stock_multicompany
+from . import test_report
diff --git a/addons/sale_stock/tests/test_report.py b/addons/sale_stock/tests/test_report.py
new file mode 100644
index 0000000000..eaf5dc96a8
--- /dev/null
+++ b/addons/sale_stock/tests/test_report.py
@@ -0,0 +1,283 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details.
+
+from flectra.tests import tagged, Form
+from flectra.addons.sale.tests.common import TestSaleCommon
+
+from flectra.tools import html2plaintext
+
+
+@tagged('post_install', '-at_install')
+class TestSaleStockInvoices(TestSaleCommon):
+
+ def setUp(self):
+ super(TestSaleStockInvoices, self).setUp()
+
+ self.product_by_lot = self.env['product.product'].create({
+ 'name': 'Product By Lot',
+ 'type': 'product',
+ 'tracking': 'lot',
+ })
+ self.product_by_usn = self.env['product.product'].create({
+ 'name': 'Product By USN',
+ 'type': 'product',
+ 'tracking': 'serial',
+ })
+ self.warehouse = self.env['stock.warehouse'].search([('company_id', '=', self.env.company.id)], limit=1)
+ self.stock_location = self.warehouse.lot_stock_id
+ lot = self.env['stock.production.lot'].create({
+ 'name': 'LOT0001',
+ 'product_id': self.product_by_lot.id,
+ 'company_id': self.env.company.id,
+ })
+ usn01 = self.env['stock.production.lot'].create({
+ 'name': 'USN0001',
+ 'product_id': self.product_by_usn.id,
+ 'company_id': self.env.company.id,
+ })
+ usn02 = self.env['stock.production.lot'].create({
+ 'name': 'USN0002',
+ 'product_id': self.product_by_usn.id,
+ 'company_id': self.env.company.id,
+ })
+ self.env['stock.quant']._update_available_quantity(self.product_by_lot, self.stock_location, 10, lot_id=lot)
+ self.env['stock.quant']._update_available_quantity(self.product_by_usn, self.stock_location, 1, lot_id=usn01)
+ self.env['stock.quant']._update_available_quantity(self.product_by_usn, self.stock_location, 1, lot_id=usn02)
+
+ def test_invoice_less_than_delivered(self):
+ """
+ Suppose the lots are printed on the invoices.
+ A user invoice a tracked product with a smaller quantity than delivered.
+ On the invoice, the quantity of the used lot should be the invoiced one.
+ """
+ display_lots = self.env.ref('sale_stock.group_lot_on_invoice')
+ display_uom = self.env.ref('uom.group_uom')
+ self.env.user.write({'groups_id': [(4, display_lots.id), (4, display_uom.id)]})
+
+ so = self.env['sale.order'].create({
+ 'partner_id': self.partner_a.id,
+ 'order_line': [
+ (0, 0, {'name': self.product_by_lot.name, 'product_id': self.product_by_lot.id, 'product_uom_qty': 5}),
+ ],
+ })
+ so.action_confirm()
+
+ picking = so.picking_ids
+ picking.move_lines.quantity_done = 5
+ picking.button_validate()
+
+ invoice = so._create_invoices()
+ with Form(invoice) as form:
+ with form.invoice_line_ids.edit(0) as line:
+ line.quantity = 2
+ invoice.action_post()
+
+ report = self.env['ir.actions.report']._get_report_from_name('account.report_invoice_with_payments')
+ html = report._render_qweb_html(invoice.ids)[0]
+ text = html2plaintext(html)
+ self.assertRegex(text, r'Product By Lot\n2.00\nUnits\nLOT0001', "There should be a line that specifies 2 x LOT0001")
+
+ def test_invoice_before_delivery(self):
+ """
+ Suppose the lots are printed on the invoices.
+ The user sells a tracked product, its invoicing policy is "Ordered quantities"
+ A user invoice a tracked product with a smaller quantity than delivered.
+ On the invoice, the quantity of the used lot should be the invoiced one.
+ """
+ display_lots = self.env.ref('sale_stock.group_lot_on_invoice')
+ display_uom = self.env.ref('uom.group_uom')
+ self.env.user.write({'groups_id': [(4, display_lots.id), (4, display_uom.id)]})
+
+ self.product_by_lot.invoice_policy = "order"
+
+ so = self.env['sale.order'].create({
+ 'partner_id': self.partner_a.id,
+ 'order_line': [
+ (0, 0, {'name': self.product_by_lot.name, 'product_id': self.product_by_lot.id, 'product_uom_qty': 4}),
+ ],
+ })
+ so.action_confirm()
+
+ invoice = so._create_invoices()
+ invoice.action_post()
+
+ picking = so.picking_ids
+ picking.move_lines.quantity_done = 4
+ picking.button_validate()
+
+ report = self.env['ir.actions.report']._get_report_from_name('account.report_invoice_with_payments')
+ html = report._render_qweb_html(invoice.ids)[0]
+ text = html2plaintext(html)
+ self.assertRegex(text, r'Product By Lot\n4.00\nUnits\nLOT0001', "There should be a line that specifies 4 x LOT0001")
+
+ def test_backorder_and_several_invoices(self):
+ """
+ Suppose the lots are printed on the invoices.
+ The user sells 2 tracked-by-usn products, he delivers 1 product and invoices it
+ Then, he delivers the other one and invoices it too. Each invoice should have the
+ correct USN
+ """
+ report = self.env['ir.actions.report']._get_report_from_name('account.report_invoice_with_payments')
+ display_lots = self.env.ref('sale_stock.group_lot_on_invoice')
+ display_uom = self.env.ref('uom.group_uom')
+ self.env.user.write({'groups_id': [(4, display_lots.id), (4, display_uom.id)]})
+
+ so = self.env['sale.order'].create({
+ 'partner_id': self.partner_a.id,
+ 'order_line': [
+ (0, 0, {'name': self.product_by_usn.name, 'product_id': self.product_by_usn.id, 'product_uom_qty': 2}),
+ ],
+ })
+ so.action_confirm()
+
+ picking = so.picking_ids
+ picking.move_lines.move_line_ids[0].qty_done = 1
+ picking.button_validate()
+ action = picking.button_validate()
+ wizard = Form(self.env[action['res_model']].with_context(action['context'])).save()
+ wizard.process()
+
+ invoice01 = so._create_invoices()
+ with Form(invoice01) as form:
+ with form.invoice_line_ids.edit(0) as line:
+ line.quantity = 1
+ invoice01.action_post()
+
+ backorder = picking.backorder_ids
+ backorder.move_lines.move_line_ids.qty_done = 1
+ backorder.button_validate()
+
+ html = report._render_qweb_html(invoice01.ids)[0]
+ text = html2plaintext(html)
+ self.assertRegex(text, r'Product By USN\n1.00\nUnits\nUSN0001', "There should be a line that specifies 1 x USN0001")
+ self.assertNotIn('USN0002', text)
+
+ invoice02 = so._create_invoices()
+ invoice02.action_post()
+ html = report._render_qweb_html(invoice02.ids)[0]
+ text = html2plaintext(html)
+ self.assertRegex(text, r'Product By USN\n1.00\nUnits\nUSN0002', "There should be a line that specifies 1 x USN0002")
+ self.assertNotIn('USN0001', text)
+
+ # Posting the second invoice shouldn't change the result of the first one
+ html = report._render_qweb_html(invoice01.ids)[0]
+ text = html2plaintext(html)
+ self.assertRegex(text, r'Product By USN\n1.00\nUnits\nUSN0001', "There should still be a line that specifies 1 x USN0001")
+ self.assertNotIn('USN0002', text)
+
+ # Resetting and posting again the first invoice shouldn't change the results
+ invoice01.button_draft()
+ invoice01.action_post()
+ html = report._render_qweb_html(invoice01.ids)[0]
+ text = html2plaintext(html)
+ self.assertRegex(text, r'Product By USN\n1.00\nUnits\nUSN0001', "There should still be a line that specifies 1 x USN0001")
+ self.assertNotIn('USN0002', text)
+ html = report._render_qweb_html(invoice02.ids)[0]
+ text = html2plaintext(html)
+ self.assertRegex(text, r'Product By USN\n1.00\nUnits\nUSN0002', "There should be a line that specifies 1 x USN0002")
+ self.assertNotIn('USN0001', text)
+
+ def test_invoice_with_several_returns(self):
+ """
+ Mix of returns and partial invoice
+ - Product P tracked by lot
+ - SO with 10 x P
+ - Deliver 10 x Lot01
+ - Return 10 x Lot01
+ - Deliver 03 x Lot02
+ - Invoice 02 x P
+ - Deliver 05 x Lot02 + 02 x Lot03
+ - Invoice 08 x P
+ """
+ report = self.env['ir.actions.report']._get_report_from_name('account.report_invoice_with_payments')
+ display_lots = self.env.ref('sale_stock.group_lot_on_invoice')
+ display_uom = self.env.ref('uom.group_uom')
+ self.env.user.write({'groups_id': [(4, display_lots.id), (4, display_uom.id)]})
+
+ lot01 = self.env['stock.production.lot'].search([('name', '=', 'LOT0001')])
+ lot02, lot03 = self.env['stock.production.lot'].create([{
+ 'name': name,
+ 'product_id': self.product_by_lot.id,
+ 'company_id': self.env.company.id,
+ } for name in ['LOT0002', 'LOT0003']])
+ self.env['stock.quant']._update_available_quantity(self.product_by_lot, self.stock_location, 8, lot_id=lot02)
+ self.env['stock.quant']._update_available_quantity(self.product_by_lot, self.stock_location, 2, lot_id=lot03)
+
+ so = self.env['sale.order'].create({
+ 'partner_id': self.partner_a.id,
+ 'order_line': [
+ (0, 0, {'name': self.product_by_lot.name, 'product_id': self.product_by_lot.id, 'product_uom_qty': 10}),
+ ],
+ })
+ so.action_confirm()
+
+ # Deliver 10 x LOT0001
+ delivery01 = so.picking_ids
+ delivery01.move_lines.quantity_done = 10
+ delivery01.button_validate()
+ self.assertEqual(delivery01.move_line_ids.lot_id.name, 'LOT0001')
+
+ # Return delivery01 (-> 10 x LOT0001)
+ return_form = Form(self.env['stock.return.picking'].with_context(active_ids=[delivery01.id], active_id=delivery01.id, active_model='stock.picking'))
+ return_wizard = return_form.save()
+ action = return_wizard.create_returns()
+ pick_return = self.env['stock.picking'].browse(action['res_id'])
+
+ move_form = Form(pick_return.move_lines, view='stock.view_stock_move_nosuggest_operations')
+ with move_form.move_line_nosuggest_ids.new() as line:
+ line.lot_id = lot01
+ line.qty_done = 10
+ move_form.save()
+ pick_return.button_validate()
+
+ # Return pick_return
+ return_form = Form(self.env['stock.return.picking'].with_context(active_ids=[pick_return.id], active_id=pick_return.id, active_model='stock.picking'))
+ return_wizard = return_form.save()
+ action = return_wizard.create_returns()
+ delivery02 = self.env['stock.picking'].browse(action['res_id'])
+
+ # Deliver 3 x LOT0002
+ delivery02.do_unreserve()
+ move_form = Form(delivery02.move_lines, view='stock.view_stock_move_nosuggest_operations')
+ with move_form.move_line_nosuggest_ids.new() as line:
+ line.lot_id = lot02
+ line.qty_done = 3
+ move_form.save()
+ action = delivery02.button_validate()
+ wizard = Form(self.env[action['res_model']].with_context(action['context'])).save()
+ wizard.process()
+
+ # Invoice 2 x P
+ invoice01 = so._create_invoices()
+ with Form(invoice01) as form:
+ with form.invoice_line_ids.edit(0) as line:
+ line.quantity = 2
+ invoice01.action_post()
+
+ html = report._render_qweb_html(invoice01.ids)[0]
+ text = html2plaintext(html)
+ self.assertRegex(text, r'Product By Lot\n2.00\nUnits\nLOT0002', "There should be a line that specifies 2 x LOT0002")
+ self.assertNotIn('LOT0001', text)
+
+ # Deliver 5 x LOT0002 + 2 x LOT0003
+ delivery03 = delivery02.backorder_ids
+ delivery03.do_unreserve()
+ move_form = Form(delivery03.move_lines, view='stock.view_stock_move_nosuggest_operations')
+ with move_form.move_line_nosuggest_ids.new() as line:
+ line.lot_id = lot02
+ line.qty_done = 5
+ with move_form.move_line_nosuggest_ids.new() as line:
+ line.lot_id = lot03
+ line.qty_done = 2
+ move_form.save()
+ delivery03.button_validate()
+
+ # Invoice 8 x P
+ invoice02 = so._create_invoices()
+ invoice02.action_post()
+
+ html = report._render_qweb_html(invoice02.ids)[0]
+ text = html2plaintext(html)
+ self.assertRegex(text, r'Product By Lot\n6.00\nUnits\nLOT0002', "There should be a line that specifies 6 x LOT0002")
+ self.assertRegex(text, r'Product By Lot\n2.00\nUnits\nLOT0003', "There should be a line that specifies 2 x LOT0003")
+ self.assertNotIn('LOT0001', text)
diff --git a/addons/survey/views/survey_templates.xml b/addons/survey/views/survey_templates.xml
index 3b84a3718f..f3aec7083a 100644
--- a/addons/survey/views/survey_templates.xml
+++ b/addons/survey/views/survey_templates.xml
@@ -65,11 +65,11 @@
-
+
-
+
diff --git a/addons/test_mail/tests/test_mail_message.py b/addons/test_mail/tests/test_mail_message.py
index 1a8cc64ea4..3b00fb34c2 100644
--- a/addons/test_mail/tests/test_mail_message.py
+++ b/addons/test_mail/tests/test_mail_message.py
@@ -9,9 +9,10 @@ from flectra.addons.test_mail.tests.common import TestMailCommon
from flectra.addons.test_mail.models.test_mail_models import MailTestSimple
from flectra.exceptions import AccessError
from flectra.tools import mute_logger, formataddr
-from flectra.tests import tagged
+from flectra.tests import tagged, users
+@tagged('mail_message')
class TestMessageValues(TestMailCommon):
@classmethod
@@ -60,8 +61,79 @@ class TestMessageValues(TestMailCommon):
res = message.with_user(self.user_employee).message_format()
self.assertEqual(res[0].get('record_name'), 'Test1')
+ def test_mail_message_values_body_base64_image(self):
+ msg = self.env['mail.message'].with_user(self.user_employee).create({
+ 'body': 'taratata

',
+ })
+ self.assertEqual(len(msg.attachment_ids), 1)
+ self.assertEqual(
+ msg.body,
+ '
taratata
'
+ '
'.format(attachment=msg.attachment_ids[0])
+ )
+
@mute_logger('flectra.models.unlink')
- def test_mail_message_values_no_document_values(self):
+ @users('employee')
+ def test_mail_message_values_fromto_long_name(self):
+ """ Long headers may break in python if above 78 chars as folding is not
+ done correctly (see ``_notify_get_reply_to_formatted_email`` docstring
+ + commit linked to this test). """
+ # name would make it blow up: keep only email
+ test_record = self.env['mail.test.container'].browse(self.alias_record.ids)
+ test_record.write({
+ 'name': 'Super Long Name That People May Enter "Even with an internal quoting of stuff"'
+ })
+ msg = self.env['mail.message'].create({
+ 'model': test_record._name,
+ 'res_id': test_record.id
+ })
+ reply_to_email = f"{test_record.alias_name}@{self.alias_domain}"
+ self.assertEqual(msg.reply_to, reply_to_email,
+ 'Reply-To: use only email when formataddr > 78 chars')
+
+ # name + company_name would make it blow up: keep record_name in formatting
+ test_record.write({'name': 'Name that would be more than 78 with company name'})
+ msg = self.env['mail.message'].create({
+ 'model': test_record._name,
+ 'res_id': test_record.id
+ })
+ self.assertEqual(msg.reply_to, formataddr((test_record.name, reply_to_email)),
+ 'Reply-To: use recordname as name in format if recordname + company > 78 chars')
+
+ # no record_name: keep company_name in formatting if ok
+ test_record.write({'name': ''})
+ msg = self.env['mail.message'].create({
+ 'model': test_record._name,
+ 'res_id': test_record.id
+ })
+ self.assertEqual(msg.reply_to, formataddr((self.env.user.company_id.name, reply_to_email)),
+ 'Reply-To: use company as name in format when no record name and still < 78 chars')
+
+ # no record_name and company_name make it blow up: keep only email
+ self.env.user.company_id.write({'name': 'Super Long Name That People May Enter "Even with an internal quoting of stuff"'})
+ msg = self.env['mail.message'].create({
+ 'model': test_record._name,
+ 'res_id': test_record.id
+ })
+ self.assertEqual(msg.reply_to, reply_to_email,
+ 'Reply-To: use only email when formataddr > 78 chars')
+
+ # whatever the record and company names, email is too long: keep only email
+ test_record.write({
+ 'alias_name': 'Waaaay too long alias name that should make any reply-to blow the 78 characters limit',
+ 'name': 'Short',
+ })
+ self.env.user.company_id.write({'name': 'Comp'})
+ sanitized_alias_name = 'waaaay-too-long-alias-name-that-should-make-any-reply-to-blow-the-78-characters-limit'
+ msg = self.env['mail.message'].create({
+ 'model': test_record._name,
+ 'res_id': test_record.id
+ })
+ self.assertEqual(msg.reply_to, f"{sanitized_alias_name}@{self.alias_domain}",
+ 'Reply-To: even a long email is ok as only formataddr is problematic')
+
+ @mute_logger('flectra.models.unlink')
+ def test_mail_message_values_fromto_no_document_values(self):
msg = self.Message.create({
'reply_to': 'test.reply@example.com',
'email_from': 'test.from@example.com',
@@ -71,7 +143,7 @@ class TestMessageValues(TestMailCommon):
self.assertEqual(msg.email_from, 'test.from@example.com')
@mute_logger('flectra.models.unlink')
- def test_mail_message_values_no_document(self):
+ def test_mail_message_values_fromto_no_document(self):
msg = self.Message.create({})
self.assertIn('-private', msg.message_id.split('@')[0], 'mail_message: message_id for a void message should be a "private" one')
reply_to_name = self.env.user.company_id.name
@@ -97,7 +169,7 @@ class TestMessageValues(TestMailCommon):
self.assertEqual(msg.email_from, formataddr((self.user_employee.name, self.user_employee.email)))
@mute_logger('flectra.models.unlink')
- def test_mail_message_values_document_alias(self):
+ def test_mail_message_values_fromto_document_alias(self):
msg = self.Message.create({
'model': 'mail.test.container',
'res_id': self.alias_record.id
@@ -134,7 +206,7 @@ class TestMessageValues(TestMailCommon):
self.assertEqual(msg.email_from, formataddr((self.user_employee.name, self.user_employee.email)))
@mute_logger('flectra.models.unlink')
- def test_mail_message_values_document_no_alias(self):
+ def test_mail_message_values_fromto_document_no_alias(self):
test_record = self.env['mail.test.simple'].create({'name': 'Test', 'email_from': 'ignasse@example.com'})
msg = self.Message.create({
@@ -148,7 +220,7 @@ class TestMessageValues(TestMailCommon):
self.assertEqual(msg.email_from, formataddr((self.user_employee.name, self.user_employee.email)))
@mute_logger('flectra.models.unlink')
- def test_mail_message_values_document_manual_alias(self):
+ def test_mail_message_values_fromto_document_manual_alias(self):
test_record = self.env['mail.test.simple'].create({'name': 'Test', 'email_from': 'ignasse@example.com'})
alias = self.env['mail.alias'].create({
'alias_name': 'MegaLias',
@@ -169,7 +241,7 @@ class TestMessageValues(TestMailCommon):
self.assertEqual(msg.reply_to, formataddr((reply_to_name, reply_to_email)))
self.assertEqual(msg.email_from, formataddr((self.user_employee.name, self.user_employee.email)))
- def test_mail_message_values_no_auto_thread(self):
+ def test_mail_message_values_fromto_no_auto_thread(self):
msg = self.Message.create({
'model': 'mail.test.container',
'res_id': self.alias_record.id,
@@ -179,15 +251,6 @@ class TestMessageValues(TestMailCommon):
self.assertNotIn('mail.test.container', msg.message_id.split('@')[0])
self.assertNotIn('-%d-' % self.alias_record.id, msg.message_id.split('@')[0])
- def test_mail_message_base64_image(self):
- msg = self.env['mail.message'].with_user(self.user_employee).create({
- 'body': 'taratata

',
- })
- self.assertEqual(len(msg.attachment_ids), 1)
- body = '
taratata

'
- body = body % (msg.attachment_ids[0].id, msg.attachment_ids[0].access_token, msg.attachment_ids[0].id, msg.attachment_ids[0].access_token)
- self.assertEqual(msg.body, body)
-
class TestMessageAccess(TestMailCommon):