diff --git a/addons/auth_signup/controllers/main.py b/addons/auth_signup/controllers/main.py index cffc8f00fd5..dbf6c2fec53 100644 --- a/addons/auth_signup/controllers/main.py +++ b/addons/auth_signup/controllers/main.py @@ -44,7 +44,7 @@ class AuthSignupHome(Home): lang=user_sudo.lang, auth_login=werkzeug.url_encode({'auth_login': user_sudo.email}), ).send_mail(user_sudo.id, force_send=True) - return super(AuthSignupHome, self).web_login(*args, **kw) + return self.web_login(*args, **kw) except UserError as e: qcontext['error'] = e.name or e.value except (SignupError, AssertionError) as e: @@ -69,7 +69,7 @@ class AuthSignupHome(Home): try: if qcontext.get('token'): self.do_signup(qcontext) - return super(AuthSignupHome, self).web_login(*args, **kw) + return self.web_login(*args, **kw) else: login = qcontext.get('login') assert login, _("No login provided.") diff --git a/addons/base_import_module/models/ir_module.py b/addons/base_import_module/models/ir_module.py index 425ad1a01f4..7fdba9688aa 100644 --- a/addons/base_import_module/models/ir_module.py +++ b/addons/base_import_module/models/ir_module.py @@ -38,6 +38,8 @@ class IrModule(models.Model): installed_mods = [m.name for m in known_mods if m.state == 'installed'] terp = load_information_from_description_file(module, mod_path=path) + if not terp: + return False values = self.get_values_from_terp(terp) if 'version' in terp: values['latest_version'] = terp['version'] @@ -133,8 +135,8 @@ class IrModule(models.Model): try: # assert mod_name.startswith('theme_') path = opj(module_dir, mod_name) - self._import_module(mod_name, path, force=force) - success.append(mod_name) + if self._import_module(mod_name, path, force=force): + success.append(mod_name) except Exception as e: _logger.exception('Error while importing module') errors[mod_name] = exception_to_unicode(e) diff --git a/addons/event_sale/models/sale_order.py b/addons/event_sale/models/sale_order.py index fac1730bead..52017208adc 100644 --- a/addons/event_sale/models/sale_order.py +++ b/addons/event_sale/models/sale_order.py @@ -42,11 +42,11 @@ class SaleOrderLine(models.Model): registrations linked to this line. This method update existing registrations and create new one for missing one. """ Registration = self.env['event.registration'].sudo() - registrations = Registration.search([('sale_order_line_id', 'in', self.ids), ('state', '!=', 'cancel')]) + registrations = Registration.search([('sale_order_line_id', 'in', self.ids)]) for so_line in self.filtered('event_id'): existing_registrations = registrations.filtered(lambda self: self.sale_order_line_id.id == so_line.id) if confirm: - existing_registrations.filtered(lambda self: self.state != 'open').confirm_registration() + existing_registrations.filtered(lambda self: self.state not in ['open', 'cancel']).confirm_registration() if cancel_to_draft: existing_registrations.filtered(lambda self: self.state == 'cancel').do_draft() diff --git a/addons/l10n_fr_pos_cert/models/__init__.py b/addons/l10n_fr_pos_cert/models/__init__.py index 76de40a9e62..33657992e1c 100644 --- a/addons/l10n_fr_pos_cert/models/__init__.py +++ b/addons/l10n_fr_pos_cert/models/__init__.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. from . import account_bank_statement +from . import account_fiscal_position from . import res_company from . import pos diff --git a/addons/l10n_fr_pos_cert/models/account_fiscal_position.py b/addons/l10n_fr_pos_cert/models/account_fiscal_position.py new file mode 100644 index 00000000000..71b4b12155a --- /dev/null +++ b/addons/l10n_fr_pos_cert/models/account_fiscal_position.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- + +from odoo import _, models +from odoo.exceptions import UserError + + +class AccountFiscalPosition(models.Model): + _inherit = "account.fiscal.position" + + def write(self, vals): + if "tax_ids" in vals: + if self.env["pos.order"].sudo().search_count([("fiscal_position_id", "in", self.ids)]): + raise UserError( + _( + "You cannot modify a fiscal position used in a POS order. " + + "You should archive it and create a new one." + ) + ) + return super(AccountFiscalPosition, self).write(vals) diff --git a/addons/point_of_sale/data/point_of_sale_data.xml b/addons/point_of_sale/data/point_of_sale_data.xml index e35a32fc706..34b75548760 100644 --- a/addons/point_of_sale/data/point_of_sale_data.xml +++ b/addons/point_of_sale/data/point_of_sale_data.xml @@ -1,13 +1,14 @@ - + Picking POS POS 5 - + + PoS Orders diff --git a/addons/point_of_sale/views/account_journal_view.xml b/addons/point_of_sale/views/account_journal_view.xml index 94c80cbacef..99e0403306e 100644 --- a/addons/point_of_sale/views/account_journal_view.xml +++ b/addons/point_of_sale/views/account_journal_view.xml @@ -5,8 +5,7 @@ account.journal form tree,kanban,form - - [('journal_user','=', 1)] +

Add a new payment method diff --git a/addons/purchase_stock/models/account_invoice.py b/addons/purchase_stock/models/account_invoice.py index efb494777cd..048853d4c64 100644 --- a/addons/purchase_stock/models/account_invoice.py +++ b/addons/purchase_stock/models/account_invoice.py @@ -75,13 +75,15 @@ class AccountInvoice(models.Model): for val_stock_move in valuation_stock_move: # In case val_stock_move is a return move, its valuation entries have been made with the # currency rate corresponding to the original stock move - valuation_date = val_stock_move.origin_returned_move_id.date or val_stock_move.date_expected + valuation_date = val_stock_move.origin_returned_move_id.date or val_stock_move.date valuation_price_unit_total += company_currency._convert( abs(val_stock_move.price_unit) * val_stock_move.product_qty, inv.currency_id, company=inv.company_id, date=valuation_date, round=False, ) valuation_total_qty += val_stock_move.product_qty + + # in Stock Move, price unit is in company_currency valuation_price_unit = valuation_price_unit_total / valuation_total_qty valuation_price_unit = i_line.product_id.uom_id._compute_price(valuation_price_unit, i_line.uom_id) line_quantity = valuation_total_qty 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 5b466f75078..a15190cddfb 100644 --- a/addons/purchase_stock/tests/test_anglo_saxon_valuation_reconciliation.py +++ b/addons/purchase_stock/tests/test_anglo_saxon_valuation_reconciliation.py @@ -62,8 +62,10 @@ class TestValuationReconciliation(ValuationReconciliationTestCase): """ Tests the case into which we receive the goods first, and then make the invoice. """ test_product = self.test_product_delivery - purchase_order = self._create_purchase(test_product, '2018-01-01') - self._process_pickings(purchase_order.picking_ids) + date_po_and_delivery = '2018-01-01' + + purchase_order = self._create_purchase(test_product, date_po_and_delivery) + self._process_pickings(purchase_order.picking_ids, date=date_po_and_delivery) invoice = self._create_invoice_for_po(purchase_order, '2018-02-02') self.env['res.currency.rate'].create({ @@ -149,8 +151,9 @@ class TestValuationReconciliation(ValuationReconciliationTestCase): """ Tests the case into which we receive part of the goods first, then 2 invoices at different rates, and finally the remaining quantities """ test_product = self.test_product_delivery - purchase_order = self._create_purchase(test_product, '2017-01-01', quantity=5.0) - self._process_pickings(purchase_order.picking_ids, quantity=2.0) + date_po_and_delivery0 = '2017-01-01' + purchase_order = self._create_purchase(test_product, date_po_and_delivery0, quantity=5.0) + self._process_pickings(purchase_order.picking_ids, quantity=2.0, date=date_po_and_delivery0) picking = self.env['stock.picking'].search([('purchase_id', '=', purchase_order.id)], order="id asc", limit=1) invoice = self._create_invoice_for_po(purchase_order, '2017-01-15') @@ -183,6 +186,8 @@ class TestValuationReconciliation(ValuationReconciliationTestCase): 'rate': 12.195747002, 'name': '2017-04-01', }) + # We don't need to make the date of processing explicit since the very last rate + # will be taken self._process_pickings(purchase_order.picking_ids.filtered(lambda x: x.state != 'done'), quantity=3.0) picking = self.env['stock.picking'].search([('purchase_id', '=', purchase_order.id)], order='id desc', limit=1) self.check_reconciliation(invoice2, picking) diff --git a/addons/purchase_stock/tests/test_stockvaluation.py b/addons/purchase_stock/tests/test_stockvaluation.py index 336cb7ebd7a..73df7e8c22f 100644 --- a/addons/purchase_stock/tests/test_stockvaluation.py +++ b/addons/purchase_stock/tests/test_stockvaluation.py @@ -3,6 +3,7 @@ import time from datetime import datetime +from unittest.mock import patch from odoo import fields from odoo.tests import Form @@ -144,6 +145,7 @@ class TestStockValuation(TransactionCase): the currency rate, validate the receipt and then check that the value of the received goods is set according to the last currency rate. """ + self.env['res.currency.rate'].search([]).unlink() usd_currency = self.env.ref('base.USD') self.env.user.company_id.currency_id = usd_currency.id @@ -290,6 +292,9 @@ class TestStockValuationWithCOA(AccountingTestCase): self.partner_id = self.env.ref('base.res_partner_1') self.product1 = self.env.ref('product.product_product_8') Account = self.env['account.account'] + self.usd_currency = self.env.ref('base.USD') + self.eur_currency = self.env.ref('base.EUR') + self.stock_input_account = Account.create({ 'name': 'Stock Input', 'code': 'StockIn', @@ -307,6 +312,11 @@ class TestStockValuationWithCOA(AccountingTestCase): 'code': 'Stock Valuation', 'user_type_id': self.env.ref('account.data_account_type_current_assets').id, }) + self.price_diff_account = Account.create({ + 'name': 'price diff account', + 'code': 'price diff account', + 'user_type_id': self.env.ref('account.data_account_type_current_assets').id, + }) self.stock_journal = self.env['account.journal'].create({ 'name': 'Stock Journal', 'code': 'STJTEST', @@ -324,12 +334,7 @@ class TestStockValuationWithCOA(AccountingTestCase): self.product1.product_tmpl_id.cost_method = 'fifo' self.product1.product_tmpl_id.valuation = 'real_time' self.product1.product_tmpl_id.invoice_policy = 'delivery' - price_diff_account = self.env['account.account'].create({ - 'name': 'price diff account', - 'code': 'price diff account', - 'user_type_id': self.env.ref('account.data_account_type_current_assets').id, - }) - self.product1.property_account_creditor_price_difference = price_diff_account + self.product1.property_account_creditor_price_difference = self.price_diff_account # Receive 10@10 ; create the vendor bill po1 = self.env['purchase.order'].create({ @@ -416,7 +421,7 @@ class TestStockValuationWithCOA(AccountingTestCase): creditnote_po2.action_invoice_open() # check the anglo saxon entries - price_diff_entry = self.env['account.move.line'].search([('account_id', '=', price_diff_account.id)]) + price_diff_entry = self.env['account.move.line'].search([('account_id', '=', self.price_diff_account.id)]) self.assertEqual(price_diff_entry.credit, 100) def test_anglosaxon_valuation(self): @@ -424,12 +429,7 @@ class TestStockValuationWithCOA(AccountingTestCase): self.product1.product_tmpl_id.cost_method = 'fifo' self.product1.product_tmpl_id.valuation = 'real_time' self.product1.product_tmpl_id.invoice_policy = 'delivery' - price_diff_account = self.env['account.account'].create({ - 'name': 'price diff account', - 'code': 'price diff account', - 'user_type_id': self.env.ref('account.data_account_type_current_assets').id, - }) - self.product1.property_account_creditor_price_difference = price_diff_account + self.product1.property_account_creditor_price_difference = self.price_diff_account # Create PO po_form = Form(self.env['purchase.order']) @@ -458,12 +458,422 @@ class TestStockValuationWithCOA(AccountingTestCase): invoice.action_invoice_open() # Check what was posted in the price difference account - price_diff_aml = self.env['account.move.line'].search([('account_id','=',price_diff_account.id)]) + price_diff_aml = self.env['account.move.line'].search([('account_id','=', self.price_diff_account.id)]) self.assertEquals(len(price_diff_aml), 1, "Only one line should have been generated in the price difference account.") self.assertAlmostEquals(price_diff_aml.debit, 5, "Price difference should be equal to 5 (15-10)") # Check what was posted in stock input account - input_aml = self.env['account.move.line'].search([('account_id','=',self.stock_input_account.id)]) + input_aml = self.env['account.move.line'].search([('account_id','=', self.stock_input_account.id)]) self.assertEquals(len(input_aml), 2, "Only two lines should have been generated in stock input account: one when receiving the product, one when making the invoice.") self.assertAlmostEquals(sum(input_aml.mapped('debit')), 10, "Total debit value on stock input account should be equal to the original PO price of the product.") self.assertAlmostEquals(sum(input_aml.mapped('credit')), 10, "Total credit value on stock input account should be equal to the original PO price of the product.") + + def test_average_realtime_anglo_saxon_valuation_multicurrency_same_date(self): + """ + The PO and invoice are in the same foreign currency. + The PO is invoiced on the same date as its creation. + This shouldn't create a price difference entry. + """ + company = self.env.user.company_id + company.anglo_saxon_accounting = True + + date_po = '2019-01-01' + + # SetUp product + self.product1.product_tmpl_id.cost_method = 'average' + self.product1.product_tmpl_id.valuation = 'real_time' + self.product1.product_tmpl_id.invoice_policy = 'order' + self.product1.product_tmpl_id.purchase_method = 'purchase' + + self.product1.property_account_creditor_price_difference = self.price_diff_account + + # SetUp currency and rates + self.cr.execute("UPDATE res_company SET currency_id = %s WHERE id = %s", (self.usd_currency.id, company.id)) + self.env['res.currency.rate'].search([]).unlink() + self.env['res.currency.rate'].create({ + 'name': date_po, + 'rate': 1.0, + 'currency_id': self.usd_currency.id, + 'company_id': company.id, + }) + + self.env['res.currency.rate'].create({ + 'name': date_po, + 'rate': 1.5, + 'currency_id': self.eur_currency.id, + 'company_id': company.id, + }) + + # Proceed + po = self.env['purchase.order'].create({ + 'currency_id': self.eur_currency.id, + 'partner_id': self.partner_id.id, + 'order_line': [ + (0, 0, { + 'name': self.product1.name, + 'product_id': self.product1.id, + 'product_qty': 1.0, + 'product_uom': self.product1.uom_po_id.id, + 'price_unit': 100.0, + 'date_planned': date_po, + }), + ], + }) + po.button_confirm() + + inv = self.env['account.invoice'].create({ + 'type': 'in_invoice', + 'date_invoice': date_po, + 'currency_id': self.eur_currency.id, + 'purchase_id': po.id, + 'partner_id': self.partner_id.id, + 'invoice_line_ids': [(0, 0, { + 'name': 'Test', + 'price_subtotal': 100.0, + 'price_unit': 100.0, + 'product_id': self.product1.id, + 'purchase_id': po.id, + 'purchase_line_id': po.order_line.id, + 'quantity': 1.0, + 'account_id': self.stock_input_account.id, + })] + }) + + inv.action_invoice_open() + + move_lines = inv.move_id.line_ids + self.assertEqual(len(move_lines), 2) + + payable_line = move_lines.filtered(lambda l: l.account_id.internal_type == 'payable') + self.assertEqual(payable_line.amount_currency, -100.0) + self.assertAlmostEqual(payable_line.balance, -66.67) + + stock_line = move_lines.filtered(lambda l: l.account_id == self.stock_input_account) + self.assertEqual(stock_line.amount_currency, 100.0) + self.assertAlmostEqual(stock_line.balance, 66.67) + + def test_realtime_anglo_saxon_valuation_multicurrency_different_dates(self): + """ + The PO and invoice are in the same foreign currency. + The PO is invoiced at a later date than its creation. + This should create a price difference entry for standard cost method + Not for average cost method though, since the PO and invoice have the same currency + """ + company = self.env.user.company_id + company.anglo_saxon_accounting = True + + date_po = '2019-01-01' + date_invoice = '2019-01-16' + + # SetUp product Average + self.product1.product_tmpl_id.write({ + 'cost_method': 'average', + 'valuation': 'real_time', + 'purchase_method': 'purchase', + 'property_account_creditor_price_difference': self.price_diff_account.id, + }) + self.product1.invoice_policy = 'order' + + # SetUp product Standard + # should have bought at 60 USD + # actually invoiced at 70 EUR > 35 USD + product_standard = self.product1.product_tmpl_id.copy({ + 'cost_method': 'standard', + 'name': 'Standard Val', + 'standard_price': 60, + 'property_account_creditor_price_difference': self.price_diff_account.id + }).product_variant_id + + # SetUp currency and rates + self.cr.execute("UPDATE res_company SET currency_id = %s WHERE id = %s", (self.usd_currency.id, company.id)) + self.env['res.currency.rate'].search([]).unlink() + self.env['res.currency.rate'].create({ + 'name': date_po, + 'rate': 1.0, + 'currency_id': self.usd_currency.id, + 'company_id': company.id, + }) + + self.env['res.currency.rate'].create({ + 'name': date_po, + 'rate': 1.5, + 'currency_id': self.eur_currency.id, + 'company_id': company.id, + }) + + self.env['res.currency.rate'].create({ + 'name': date_invoice, + 'rate': 2, + 'currency_id': self.eur_currency.id, + 'company_id': company.id, + }) + + # To allow testing validation of PO + def _today(*args, **kwargs): + return date_po + patchers = [ + patch('odoo.fields.Date.context_today', _today), + ] + + for p in patchers: + p.start() + + # Proceed + po = self.env['purchase.order'].create({ + 'currency_id': self.eur_currency.id, + 'partner_id': self.partner_id.id, + 'order_line': [ + (0, 0, { + 'name': self.product1.name, + 'product_id': self.product1.id, + 'product_qty': 1.0, + 'product_uom': self.product1.uom_po_id.id, + 'price_unit': 100.0, + 'date_planned': date_po, + }), + (0, 0, { + 'name': product_standard.name, + 'product_id': product_standard.id, + 'product_qty': 1.0, + 'product_uom': product_standard.uom_po_id.id, + 'price_unit': 40.0, + 'date_planned': date_po, + }), + ], + }) + po.button_confirm() + + line_product_average = po.order_line.filtered(lambda l: l.product_id == self.product1) + line_product_standard = po.order_line.filtered(lambda l: l.product_id == product_standard) + + inv = self.env['account.invoice'].create({ + 'type': 'in_invoice', + 'date_invoice': date_invoice, + 'currency_id': self.eur_currency.id, + 'purchase_id': po.id, + 'partner_id': self.partner_id.id, + 'invoice_line_ids': [ + (0, 0, { + 'name': self.product1.name, + 'price_subtotal': 100.0, + 'price_unit': 100.0, + 'product_id': self.product1.id, + 'purchase_id': po.id, + 'purchase_line_id': line_product_average.id, + 'quantity': 1.0, + 'account_id': self.stock_input_account.id, + }), + (0, 0, { + 'name': product_standard.name, + 'price_subtotal': 70.0, + 'price_unit': 70.0, + 'product_id': product_standard.id, + 'purchase_id': po.id, + 'purchase_line_id': line_product_standard.id, + 'quantity': 1.0, + 'account_id': self.stock_input_account.id, + }) + ] + }) + + inv.action_invoice_open() + + for p in patchers: + p.stop() + + move_lines = inv.move_id.line_ids + self.assertEqual(len(move_lines), 4) + + # Ensure no exchange difference move has been created + self.assertTrue(all([not l.reconciled for l in move_lines])) + + # PAYABLE CHECK + payable_line = move_lines.filtered(lambda l: l.account_id.internal_type == 'payable') + self.assertEqual(payable_line.amount_currency, -170.0) + self.assertAlmostEqual(payable_line.balance, -85.00) + + # PRODUCTS CHECKS + + # NO EXCHANGE DIFFERENCE (average) + # We ordered for a value of 100 EUR + # But by the time we are invoiced for it + # the foreign currency appreciated from 1.5 to 2.0 + # We still have to pay 100 EUR, which now values at 50 USD + product_lines = move_lines.filtered(lambda l: l.product_id == self.product1) + + # Stock-wise, we have been invoiced 100 EUR, and we ordered 100 EUR + # there is no price difference + # However, 100 EUR should be converted at the time of the invoice + stock_line = product_lines.filtered(lambda l: l.account_id == self.stock_input_account) + self.assertEqual(stock_line.amount_currency, 100.00) + self.assertAlmostEqual(stock_line.balance, 50.00) + + # PRICE DIFFERENCE (STANDARD) + # We ordered a product that should have cost 60 USD (120 EUR) + # However, we effectively got invoiced 70 EUR (35 USD) + product_lines = move_lines.filtered(lambda l: l.product_id == product_standard) + + stock_line = product_lines.filtered(lambda l: l.account_id == self.stock_input_account) + self.assertEqual(stock_line.amount_currency, 120.00) + self.assertAlmostEqual(stock_line.balance, 60.00) + + price_diff_line = product_lines.filtered(lambda l: l.account_id == self.price_diff_account) + self.assertEqual(price_diff_line.amount_currency, -50.00) + self.assertAlmostEqual(price_diff_line.balance, -25.00) + + def test_average_realtime_with_delivery_anglo_saxon_valuation_multicurrency_different_dates(self): + """ + The PO and invoice are in the same foreign currency. + The delivery occurs in between PO validation and invoicing + The invoice is created at an even different date + This should create a price difference entry. + """ + company = self.env.user.company_id + company.anglo_saxon_accounting = True + + date_po = '2019-01-01' + date_delivery = '2019-01-08' + date_invoice = '2019-01-16' + + product_avg = self.product1.product_tmpl_id.copy({ + 'valuation': 'real_time', + 'purchase_method': 'purchase', + 'cost_method': 'average', + 'name': 'AVG', + 'standard_price': 60, + 'property_account_creditor_price_difference': self.price_diff_account.id + }).product_variant_id + product_avg.invoice_policy = 'order' + + # SetUp currency and rates + self.cr.execute("UPDATE res_company SET currency_id = %s WHERE id = %s", (self.usd_currency.id, company.id)) + self.env['res.currency.rate'].search([]).unlink() + self.env['res.currency.rate'].create({ + 'name': date_po, + 'rate': 1.0, + 'currency_id': self.usd_currency.id, + 'company_id': company.id, + }) + + self.env['res.currency.rate'].create({ + 'name': date_po, + 'rate': 1.5, + 'currency_id': self.eur_currency.id, + 'company_id': company.id, + }) + + self.env['res.currency.rate'].create({ + 'name': date_delivery, + 'rate': 0.7, + 'currency_id': self.eur_currency.id, + 'company_id': company.id, + }) + + self.env['res.currency.rate'].create({ + 'name': date_invoice, + 'rate': 2, + 'currency_id': self.eur_currency.id, + 'company_id': company.id, + }) + + # To allow testing validation of PO + def _today(*args, **kwargs): + return date_po + # To allow testing validation of Delivery + def _now(*args, **kwargs): + return date_delivery + ' 01:00:00' + + patchers = [ + patch('odoo.fields.Date.context_today', _today), + patch('odoo.fields.Datetime.now', _now), + ] + + for p in patchers: + p.start() + + # Proceed + po = self.env['purchase.order'].create({ + 'currency_id': self.eur_currency.id, + 'partner_id': self.partner_id.id, + 'order_line': [ + (0, 0, { + 'name': product_avg.name, + 'product_id': product_avg.id, + 'product_qty': 1.0, + 'product_uom': product_avg.uom_po_id.id, + 'price_unit': 30.0, + 'date_planned': date_po, + }) + ], + }) + po.button_confirm() + + line_product_avg = po.order_line.filtered(lambda l: l.product_id == product_avg) + + picking = po.picking_ids + (picking.move_lines + .filtered(lambda l: l.purchase_line_id == line_product_avg) + .write({'quantity_done': 1.0})) + + picking.button_validate() + + inv = self.env['account.invoice'].create({ + 'type': 'in_invoice', + 'date_invoice': date_invoice, + 'currency_id': self.eur_currency.id, + 'purchase_id': po.id, + 'partner_id': self.partner_id.id, + 'invoice_line_ids': [ + (0, 0, { + 'name': product_avg.name, + 'price_subtotal': 30.0, + 'price_unit': 30.0, + 'product_id': product_avg.id, + 'purchase_id': po.id, + 'purchase_line_id': line_product_avg.id, + 'quantity': 1.0, + 'account_id': self.stock_input_account.id, + }) + ] + }) + + inv.action_invoice_open() + + for p in patchers: + p.stop() + + move_lines = inv.move_id.line_ids + self.assertEqual(len(move_lines), 2) + + # PAYABLE CHECK + payable_line = move_lines.filtered(lambda l: l.account_id.internal_type == 'payable') + self.assertEqual(payable_line.amount_currency, -30.0) + self.assertAlmostEqual(payable_line.balance, -15.00) + + # PRODUCTS CHECKS + + # DELIVERY DIFFERENCE (AVERAGE) + # We ordered a product at 30 EUR valued at 20 USD + # We received it when the exchange rate has appreciated + # So, the actualized 20 USD are now 20*1.5/0.7 = 42.86 USD + product_lines = move_lines.filtered(lambda l: l.product_id == product_avg) + + # Although those 42.86 USD are just due to the exchange difference + stock_line = product_lines.filtered(lambda l: l.account_id == self.stock_input_account) + self.assertEqual(stock_line.journal_id, inv.journal_id) + self.assertEqual(stock_line.amount_currency, 30.00) + self.assertAlmostEqual(stock_line.balance, 15.00) + full_reconcile = stock_line.full_reconcile_id + self.assertTrue(full_reconcile.exists()) + + reconciled_lines = full_reconcile.reconciled_line_ids - stock_line + self.assertEqual(len(reconciled_lines), 2) + + stock_journal_line = reconciled_lines.filtered(lambda l: l.journal_id == self.stock_journal) + self.assertEqual(stock_journal_line.amount_currency, -30.00) + self.assertAlmostEqual(stock_journal_line.balance, -42.86) + + exhange_diff_journal = company.currency_exchange_journal_id.exists() + exchange_stock_line = reconciled_lines.filtered(lambda l: l.journal_id == exhange_diff_journal) + self.assertEqual(exchange_stock_line.amount_currency, 0.00) + self.assertAlmostEqual(exchange_stock_line.balance, 27.86) diff --git a/addons/sale/views/sale_portal_templates.xml b/addons/sale/views/sale_portal_templates.xml index ca59a2d0c95..640d917689c 100644 --- a/addons/sale/views/sale_portal_templates.xml +++ b/addons/sale/views/sale_portal_templates.xml @@ -484,8 +484,8 @@ - - + + @@ -524,7 +524,7 @@ Subtotal diff --git a/addons/sale_management/controllers/portal.py b/addons/sale_management/controllers/portal.py index 9547d8e182b..1796beb7223 100644 --- a/addons/sale_management/controllers/portal.py +++ b/addons/sale_management/controllers/portal.py @@ -1,7 +1,10 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from functools import partial + from odoo import http +from odoo.tools import formatLang from odoo.exceptions import AccessError, MissingError from odoo.http import request from odoo.addons.sale.controllers.portal import CustomerPortal @@ -11,6 +14,13 @@ class CustomerPortal(CustomerPortal): @http.route(['/my/orders//update_line'], type='json', auth="public", website=True) def update(self, line_id, remove=False, unlink=False, order_id=None, access_token=None, **post): + values = self.update_line_dict(line_id, remove, unlink, order_id, access_token, **post) + if values: + return [values['order_line_product_uom_qty'], values['order_amount_total']] + return values + + @http.route(['/my/orders//update_line_dict'], type='json', auth="public", website=True) + def update_line_dict(self, line_id, remove=False, unlink=False, order_id=None, access_token=None, input_quantity=False, **kwargs): try: order_sudo = self._document_check_access('sale.order', order_id, access_token=access_token) except (AccessError, MissingError): @@ -24,10 +34,27 @@ class CustomerPortal(CustomerPortal): if unlink: order_line.unlink() return False # return False to reload the page, the line must move back to options and the JS doesn't handle it - number = -1 if remove else 1 - quantity = order_line.product_uom_qty + number + + if input_quantity is not False: + quantity = input_quantity + else: + number = -1 if remove else 1 + quantity = order_line.product_uom_qty + number + + if quantity < 0: + quantity = 0.0 order_line.write({'product_uom_qty': quantity}) - return [str(quantity), str(order_sudo.amount_total)] + currency = order_sudo.currency_id + format_price = partial(formatLang, request.env, digits=currency.decimal_places) + + return { + 'order_line_product_uom_qty': str(quantity), + 'order_line_price_total': format_price(order_line.price_total), + 'order_line_price_subtotal': format_price(order_line.price_subtotal), + 'order_amount_total': format_price(order_sudo.amount_total), + 'order_amount_untaxed': format_price(order_sudo.amount_untaxed), + 'order_amount_tax': format_price(order_sudo.amount_tax), + } @http.route(["/my/orders//add_option/"], type='http', auth="public", website=True) def add(self, order_id, option_id, access_token=None, **post): diff --git a/addons/sale_management/static/src/js/sale_management.js b/addons/sale_management/static/src/js/sale_management.js index 77806630166..88b956e0e6e 100644 --- a/addons/sale_management/static/src/js/sale_management.js +++ b/addons/sale_management/static/src/js/sale_management.js @@ -14,31 +14,155 @@ if (!$('.o_portal_sale_sidebar').length) { events: { 'click' : 'onClick', }, - onClick: function (ev) { - ev.preventDefault(); + /** + * @override + */ + start: function () { var self = this; + return this._super.apply(this, arguments).then(function () { + self.elems = self._getUpdatableElements(); + self.elems.$lineQuantity.change(function (ev) { + var quantity = parseInt(this.value); + self._onChangeQuantity(quantity); + }); + }); + }, + /** + * Process the change in line quantity + * + * @private + * @param {Int} quantity, the new quantity of the line + * If not present it will increment/decrement the existing quantity + */ + _onChangeQuantity: function (quantity) { var href = this.$el.attr("href"); var order_id = href.match(/my\/orders\/([0-9]+)/); var line_id = href.match(/update_line\/([0-9]+)/); var params = { 'line_id': line_id[1], - 'remove': self.$el.is('[href*="remove"]'), - 'unlink': self.$el.is('[href*="unlink"]'), + 'remove': this.$el.is('[href*="remove"]'), + 'unlink': this.$el.is('[href*="unlink"]'), + 'input_quantity': quantity >= 0 ? quantity : false, }; - var token = href.match(/token=(.*)/); + var token = href.match(/token=([\w\d-]*)/)[1]; if (token) { params.access_token = token; } - var url = "/my/orders/" + parseInt(order_id[1]) + "/update_line"; - ajax.jsonRpc(url, 'call', params).then(function (data) { - if (!data) { - window.location.reload(); - } - self.$el.parents('.input-group:first').find('.js_quantity').val(data[0]); - $('[data-id="total_amount"]>span').html(data[1]); - }); + + this._callUpdateLineRoute(order_id, params).then(this._updateOrderValues.bind(this)); return false; }, + /** + * Reacts to the click on the -/+ buttons + * + * @param {Event} ev + */ + onClick: function (ev) { + ev.preventDefault(); + return this._onChangeQuantity(); + }, + /** + * Calls the route to get updated values of the line and order + * when the quantity of a product has changed + * + * @private + * @param {Object} params + * @return {Deferred} + */ + _callUpdateLineRoute: function (order_id, params) { + var def = new $.Deferred(); + var url = "/my/orders/" + parseInt(order_id[1]) + "/update_line_dict"; + ajax.jsonRpc(url, 'call', params) + .then(def.resolve.bind(def)) + .fail(function () { + // Compatibility: the server may not have been restarted + // So the real route may not exist + delete params.input_quantity; + ajax.jsonRpc("/quote/update_line", 'call', params) + .fail(def.reject.bind(def)) + .then(function (data) { + // Data is an array, convert it to a dict + var actualData = data; + if (data) { + actualData = { + order_amount_total: data[1], + order_line_product_uom_qty: data[0], + }; + } + def.resolve(actualData); + }); + }); + return def; + }, + /** + * Processes data from the server to update the UI + * + * @private + * @param {Object} data: contains order and line updated values + */ + _updateOrderValues: function (data) { + if (!data) { + window.location.reload(); + } + + var orderAmountTotal = data.order_amount_total; + var orderAmountUntaxed = data.order_amount_untaxed; + var orderAmountTax = data.order_amount_tax; + + var lineProductUomQty = data.order_line_product_uom_qty; + var linePriceTotal = data.order_line_price_total; + var linePriceSubTotal = data.order_line_price_subtotal; + + this.elems.$lineQuantity.val(lineProductUomQty) + + if (this.elems.$linePriceTotal.length && linePriceTotal !== undefined) { + this.elems.$linePriceTotal.text(linePriceTotal); + } + if (this.elems.$linePriceSubTotal.length && linePriceSubTotal !== undefined) { + this.elems.$linePriceSubTotal.text(linePriceSubTotal); + } + + if (orderAmountUntaxed !== undefined) { + this.elems.$orderAmountUntaxed.text(orderAmountUntaxed); + } + + if (orderAmountTotal !== undefined) { + this.elems.$orderAmountTotal.text(orderAmountTotal); + } + }, + /** + * Locate in the DOM the elements to update + * Mostly for compatibility, when the module has not been upgraded + * In that case, we need to fall back to some other elements + * + * @private + * @return {Object}: Jquery elements to update + */ + _getUpdatableElements: function () { + var $parentTr = this.$el.parents('tr:first'); + var $linePriceTotal = $parentTr.find('.oe_order_line_price_total .oe_currency_value'); + var $linePriceSubTotal = $parentTr.find('.oe_order_line_price_subtotal .oe_currency_value'); + + if (!$linePriceTotal.length && !$linePriceSubTotal.length) { + $linePriceTotal = $linePriceSubTotal = $parentTr.find('.oe_currency_value').last(); + } + + var $orderAmountUntaxed = $('[data-id="total_untaxed"]>span'); + var $orderAmountTotal = $('[data-id="total_amount"]>span'); + + if (!$orderAmountUntaxed.length) { + $orderAmountUntaxed = $orderAmountTotal.eq(1); + $orderAmountTotal = $orderAmountTotal.eq(0).add($orderAmountTotal.eq(3)); + } + + return { + $lineQuantity: this.$el.parents('.input-group:first').find('.js_quantity'), + $linePriceSubTotal: $linePriceSubTotal, + $linePriceTotal: $linePriceTotal, + $orderAmountUntaxed: $orderAmountUntaxed, + $orderAmountTotal: $orderAmountTotal, + } + } }); var update_button_list = []; diff --git a/addons/sale_mrp/models/sale_mrp.py b/addons/sale_mrp/models/sale_mrp.py index d7738367ef7..a17ef14d11f 100644 --- a/addons/sale_mrp/models/sale_mrp.py +++ b/addons/sale_mrp/models/sale_mrp.py @@ -60,6 +60,19 @@ class SaleOrderLine(models.Model): return self.env.context['previous_product_uom_qty'].get(self.id, 0.0) return super(SaleOrderLine, self)._get_qty_procurement() + @api.multi + @api.depends('product_id', 'move_ids.state') + def _compute_qty_delivered_method(self): + lines = self.env['sale.order.line'] + for line in self: + bom = self.env['mrp.bom']._bom_find(product=line.product_id, company_id=line.company_id.id) + if bom and bom.type == 'phantom' and line.order_id.state == 'sale': + bom_delivered = all([move.state == 'done' for move in line.move_ids]) + if not bom_delivered: + line.qty_delivered_method = 'manual' + lines |= line + super(SaleOrderLine, self - lines)._compute_qty_delivered_method() + class AccountInvoiceLine(models.Model): # TDE FIXME: what is this code ?? diff --git a/addons/sale_quotation_builder/views/sale_order_views.xml b/addons/sale_quotation_builder/views/sale_order_views.xml index e46a0cdd68b..ff2eb495f49 100644 --- a/addons/sale_quotation_builder/views/sale_order_views.xml +++ b/addons/sale_quotation_builder/views/sale_order_views.xml @@ -7,6 +7,13 @@ + + + + + + + diff --git a/addons/stock_dropshipping/models/purchase.py b/addons/stock_dropshipping/models/purchase.py index af98e29d390..eef78466366 100644 --- a/addons/stock_dropshipping/models/purchase.py +++ b/addons/stock_dropshipping/models/purchase.py @@ -15,7 +15,9 @@ class PurchaseOrderLine(models.Model): return res def _merge_in_existing_line(self, product_id, product_qty, product_uom, location_id, name, origin, values): - if values.get('route_ids') and values['route_ids'] == self.env.ref('stock_dropshipping.route_drop_shipping'): + if values.get('sale_line_id') and values['sale_line_id'] != self.sale_line_id.id: + # if this is defined, this is a dropshipping line, so no + # this is to correctly map delivered quantities to the so lines return False return super(PurchaseOrderLine, self)._merge_in_existing_line( product_id=product_id, product_qty=product_qty, product_uom=product_uom, diff --git a/addons/stock_dropshipping/tests/test_dropship.py b/addons/stock_dropshipping/tests/test_dropship.py index af492930a7c..f70897f9eb8 100644 --- a/addons/stock_dropshipping/tests/test_dropship.py +++ b/addons/stock_dropshipping/tests/test_dropship.py @@ -48,6 +48,22 @@ class TestDropship(common.TransactionCase): so.order_line.product_uom_qty = 2.00 self.assertAlmostEqual(po_line.product_qty, 2.00) + # Create a new so line + sol2 = self.env['sale.order.line'].create({ + 'order_id': so.id, + 'name': prod.name, + 'product_id': prod.id, + 'product_uom_qty': 3.00, + 'product_uom': prod.uom_id.id, + 'price_unit': 12, + }) + # there is a new line + pol2 = po.order_line - po_line + # the first line is unchanged + self.assertAlmostEqual(po_line.product_qty, 2.00) + # the new line matches the new line on the so + self.assertAlmostEqual(pol2.product_qty, sol2.product_uom_qty) + def test_00_dropship(self): # Create a vendor diff --git a/addons/website_event_sale/models/sale_order.py b/addons/website_event_sale/models/sale_order.py index 097ce0adbaf..cd25e6e1943 100644 --- a/addons/website_event_sale/models/sale_order.py +++ b/addons/website_event_sale/models/sale_order.py @@ -41,7 +41,7 @@ class SaleOrder(models.Model): values['product_id'] = ticket.product_id.id values['event_id'] = ticket.event_id.id values['event_ticket_id'] = ticket.id - values['price_unit'] = ticket.price_reduce or ticket.price + values['price_unit'] = ticket.price_reduce values['name'] = ticket.get_ticket_multiline_description_sale() # avoid writing related values that end up locking the product record diff --git a/addons/website_sale/models/sale_order.py b/addons/website_sale/models/sale_order.py index bec2445e64f..05db43f9f4b 100644 --- a/addons/website_sale/models/sale_order.py +++ b/addons/website_sale/models/sale_order.py @@ -127,7 +127,10 @@ class SaleOrder(models.Model): pu = currency._convert(pu, order.pricelist_id.currency_id, order.company_id, date) discount = (pu - price) / pu * 100 if discount < 0: + # In case the discount is negative, we don't want to show it to the customer, + # but we still want to use the price defined on the pricelist discount = 0 + pu = price else: pu = product.price if order.pricelist_id and order.partner_id: