diff --git a/addons/purchase/models/account_invoice.py b/addons/purchase/models/account_invoice.py index 4f7b15d8627..fd0c47b75cc 100644 --- a/addons/purchase/models/account_invoice.py +++ b/addons/purchase/models/account_invoice.py @@ -194,9 +194,11 @@ 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.with_context(date=valuation_date).compute(abs(val_stock_move.price_unit) * val_stock_move.product_qty, inv.currency_id, 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 @@ -209,7 +211,6 @@ class AccountInvoice(models.Model): invoice_cur_prec = inv.currency_id.decimal_places if float_compare(valuation_price_unit, i_line.price_unit, precision_digits=invoice_cur_prec) != 0 and float_compare(line['price_unit'], i_line.price_unit, precision_digits=invoice_cur_prec) == 0: - # price with discount and without tax included price_unit = i_line.price_unit * (1 - (i_line.discount or 0.0) / 100.0) tax_ids = [] diff --git a/addons/purchase/models/purchase.py b/addons/purchase/models/purchase.py index 7840665bb64..b3bac6ad1de 100644 --- a/addons/purchase/models/purchase.py +++ b/addons/purchase/models/purchase.py @@ -749,7 +749,7 @@ class PurchaseOrderLine(models.Model): if line.product_uom.id != line.product_id.uom_id.id: price_unit *= line.product_uom.factor / line.product_id.uom_id.factor if order.currency_id != order.company_id.currency_id: - price_unit = order.currency_id.compute(price_unit, order.company_id.currency_id, round=False) + price_unit = order.currency_id.with_context(date=order.date_approve).compute(price_unit, order.company_id.currency_id, round=False) return price_unit @api.multi @@ -769,7 +769,7 @@ class PurchaseOrderLine(models.Model): 'name': self.name or '', 'product_id': self.product_id.id, 'product_uom': self.product_uom.id, - 'date': self.order_id.date_order, + 'date': self.order_id.date_approve, 'date_expected': self.date_planned, 'location_id': self.order_id.partner_id.property_stock_supplier.id, 'location_dest_id': self.order_id._get_destination_location(), diff --git a/addons/purchase/models/stock.py b/addons/purchase/models/stock.py index 69b85c7b899..794e7401486 100644 --- a/addons/purchase/models/stock.py +++ b/addons/purchase/models/stock.py @@ -46,7 +46,7 @@ class StockMove(models.Model): if line.product_uom.id != line.product_id.uom_id.id: price_unit *= line.product_uom.factor / line.product_id.uom_id.factor if order.currency_id != order.company_id.currency_id: - price_unit = order.currency_id.compute(price_unit, order.company_id.currency_id, round=False) + price_unit = order.currency_id.with_context(date=self.date or order.date_approve).compute(price_unit, order.company_id.currency_id, round=False) return price_unit return super(StockMove, self)._get_price_unit() diff --git a/addons/purchase/tests/test_anglo_saxon_valuation_reconciliation.py b/addons/purchase/tests/test_anglo_saxon_valuation_reconciliation.py index 3058713d190..0856c44e816 100644 --- a/addons/purchase/tests/test_anglo_saxon_valuation_reconciliation.py +++ b/addons/purchase/tests/test_anglo_saxon_valuation_reconciliation.py @@ -63,8 +63,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({ @@ -150,8 +152,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') @@ -184,6 +187,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/tests/test_stockvaluation.py b/addons/purchase/tests/test_stockvaluation.py index e03a455f115..6655038c2ab 100644 --- a/addons/purchase/tests/test_stockvaluation.py +++ b/addons/purchase/tests/test_stockvaluation.py @@ -3,6 +3,7 @@ import time from datetime import datetime +from unittest.mock import patch from odoo.tests import Form from odoo.tests.common import TransactionCase, tagged @@ -143,6 +144,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 @@ -285,6 +287,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', @@ -302,6 +307,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', @@ -319,12 +329,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({ @@ -411,7 +416,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): @@ -419,12 +424,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']) @@ -453,12 +453,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)