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 image0 ' + 'image0

'.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 image0 image0

' - 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):