diff --git a/addons/account/models/account_invoice.py b/addons/account/models/account_invoice.py
index 3f78e546e36..db676eb6e7d 100644
--- a/addons/account/models/account_invoice.py
+++ b/addons/account/models/account_invoice.py
@@ -1204,8 +1204,8 @@ class AccountInvoice(models.Model):
for inv in self:
if inv.move_id:
moves += inv.move_id
- if inv.payment_move_line_ids:
- raise UserError(_('You cannot cancel an invoice which is partially paid. You need to unreconcile related payment entries first.'))
+ #unreconcile all journal items of the invoice, since the cancellation will unlink them anyway
+ inv.move_id.line_ids.filtered(lambda x: x.account_id.reconcile).remove_move_reconcile()
# First, set the invoices as cancelled and detach the move ids
self.write({'state': 'cancel', 'move_id': False})
diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py
index 768d7822ee3..6b62949c332 100644
--- a/addons/account/models/account_move.py
+++ b/addons/account/models/account_move.py
@@ -676,7 +676,7 @@ class AccountMoveLine(models.Model):
if len(set(all_accounts)) > 1:
raise UserError(_('Entries are not of the same account!'))
if not (all_accounts[0].reconcile or all_accounts[0].internal_type == 'liquidity'):
- raise UserError(_('The account %s (%s) is not marked as reconcilable !') % (all_accounts[0].name, all_accounts[0].code))
+ raise UserError(_('Account %s (%s) does not allow reconciliation. First change the configuration of this account to allow it.') % (all_accounts[0].name, all_accounts[0].code))
if len(partners) > 1:
raise UserError(_('The partner has to be the same on all lines for receivable and payable accounts!'))
@@ -1500,7 +1500,7 @@ class AccountPartialReconcile(models.Model):
exchange_move = self.env['account.move'].create(
self.env['account.full.reconcile']._prepare_exchange_diff_move(move_date=maxdate, company=aml_to_balance[0].company_id))
#eventually create a journal entry to book the difference due to foreign currency's exchange rate that fluctuates
- rate_diff_amls, rate_diff_partial_rec = self.create_exchange_rate_entry(aml_to_balance, total_debit - total_credit, total_amount_currency, aml_to_balance.currency_id, exchange_move)
+ rate_diff_amls, rate_diff_partial_rec = self.create_exchange_rate_entry(aml_to_balance, total_debit - total_credit, total_amount_currency, aml_to_balance[0].currency_id, exchange_move)
aml_ids += rate_diff_amls.ids
partial_rec_ids += rate_diff_partial_rec.ids
exchange_move.post()
diff --git a/addons/l10n_generic_coa/data/l10n_generic_coa_chart_data.xml b/addons/l10n_generic_coa/data/l10n_generic_coa_chart_data.xml
index 49e203c1dae..cc989f76ac5 100644
--- a/addons/l10n_generic_coa/data/l10n_generic_coa_chart_data.xml
+++ b/addons/l10n_generic_coa/data/l10n_generic_coa_chart_data.xml
@@ -49,12 +49,14 @@
Stock Interim Account (Received)
+
10113
Stock Interim Account (Delivered)
+
diff --git a/addons/purchase/models/account_invoice.py b/addons/purchase/models/account_invoice.py
index 23ec7d87b0c..f2636e003ff 100644
--- a/addons/purchase/models/account_invoice.py
+++ b/addons/purchase/models/account_invoice.py
@@ -4,6 +4,7 @@
from odoo import api, fields, models, _
from odoo.tools.float_utils import float_compare
+from odoo.exceptions import UserError
class AccountInvoice(models.Model):
_inherit = 'account.invoice'
@@ -163,11 +164,14 @@ class AccountInvoice(models.Model):
for line in res:
if line.get('invl_id', 0) == i_line.id and reference_account_id == line['account_id']:
valuation_price_unit = i_line.product_id.uom_id._compute_price(i_line.product_id.standard_price, i_line.uom_id)
+ line_quantity = line['quantity']
+
if i_line.product_id.cost_method != 'standard' and i_line.purchase_line_id:
#for average/fifo/lifo costing method, fetch real cost price from incomming moves
valuation_price_unit = i_line.purchase_line_id.product_uom._compute_price(i_line.purchase_line_id.price_unit, i_line.uom_id)
stock_move_obj = self.env['stock.move']
valuation_stock_move = stock_move_obj.search([('purchase_line_id', '=', i_line.purchase_line_id.id), ('state', '=', 'done')])
+
if valuation_stock_move:
valuation_price_unit_total = 0
valuation_total_qty = 0
@@ -176,9 +180,22 @@ class AccountInvoice(models.Model):
valuation_total_qty += val_stock_move.product_qty
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
+
+ elif i_line.product_id.cost_method == 'real':
+ # In this condition, we have a real price-valuated product which has not yet been received
+ valuation_price_unit = i_line.purchase_line_id.price_unit
+
+ interim_account_price = valuation_price_unit * line_quantity
if inv.currency_id.id != company_currency.id:
+ # We express everyhting in the invoice currency
valuation_price_unit = company_currency.with_context(date=inv.date_invoice).compute(valuation_price_unit, inv.currency_id, round=False)
- if valuation_price_unit != i_line.price_unit and line['price_unit'] == i_line.price_unit and acc:
+ interim_account_price = company_currency.with_context(date=inv.date_invoice).compute(interim_account_price, inv.currency_id, round=False)
+
+ 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 = []
@@ -191,20 +208,28 @@ class AccountInvoice(models.Model):
for child in tax.children_tax_ids:
if child.type_tax_use != 'none':
tax_ids.append((4, child.id, None))
+
price_before = line.get('price', 0.0)
- line.update({'price': inv.currency_id.round(valuation_price_unit * line['quantity'])})
- diff_res.append({
- 'type': 'src',
- 'name': i_line.name[:64],
- 'price_unit': inv.currency_id.round(price_unit - valuation_price_unit),
- 'quantity': line['quantity'],
- 'price': inv.currency_id.round(price_before - line.get('price', 0.0)),
- 'account_id': acc,
- 'product_id': line['product_id'],
- 'uom_id': line['uom_id'],
- 'account_analytic_id': line['account_analytic_id'],
- 'tax_ids': tax_ids,
- })
+ price_unit_val_dif = price_unit - valuation_price_unit
+
+ price_val_dif = price_before - interim_account_price
+ if inv.currency_id.compare_amounts(i_line.price_unit, i_line.purchase_line_id.price_unit) != 0 and acc:
+ # If the unit prices have not changed and we have a
+ # valuation difference, it means this difference is due to exchange rates,
+ # so we don't create anything, the exchange rate entries will
+ # be processed automatically by the rest of the code.
+ diff_res.append({
+ 'type': 'src',
+ 'name': i_line.name[:64],
+ 'price_unit': inv.currency_id.round(price_unit_val_dif),
+ 'quantity': line_quantity,
+ 'price': inv.currency_id.round(price_val_dif),
+ 'account_id': acc,
+ 'product_id': line['product_id'],
+ 'uom_id': line['uom_id'],
+ 'account_analytic_id': line['account_analytic_id'],
+ 'tax_ids': tax_ids,
+ })
return diff_res
return []
@@ -231,6 +256,17 @@ class AccountInvoice(models.Model):
invoice.message_post(body=message)
return result
+ def _get_last_step_stock_moves(self):
+ """ Overridden from stock_account.
+ Returns the stock moves associated to this invoice."""
+ rslt = super(AccountInvoice, self)._get_last_step_stock_moves()
+ for invoice in self.filtered(lambda x: x.type == 'in_invoice'):
+ rslt += invoice.mapped('invoice_line_ids.purchase_line_id.move_ids').filtered(lambda x: x.state == 'done' and x.location_id.usage == 'supplier')
+ for invoice in self.filtered(lambda x: x.type == 'in_refund'):
+ rslt += invoice.mapped('invoice_line_ids.purchase_line_id.move_ids').filtered(lambda x: x.state == 'done' and x.location_dest_id.usage == 'supplier')
+ return rslt
+
+
class AccountInvoiceLine(models.Model):
""" Override AccountInvoice_line to add the link to the purchase order line it is related to"""
_inherit = 'account.invoice.line'
diff --git a/addons/purchase/models/stock.py b/addons/purchase/models/stock.py
index 295632baffb..6d06186b87d 100644
--- a/addons/purchase/models/stock.py
+++ b/addons/purchase/models/stock.py
@@ -50,6 +50,24 @@ class StockMove(models.Model):
return price_unit
return super(StockMove, self)._get_price_unit()
+ def _generate_valuation_lines_data(self, partner_id, qty, debit_value, credit_value, debit_account_id, credit_account_id):
+ """ Overridden from stock_account to support amount_currency on valuation lines generated from po
+ """
+ self.ensure_one()
+
+ rslt = super(StockMove, self)._generate_valuation_lines_data(partner_id, qty, debit_value, credit_value, debit_account_id, credit_account_id)
+
+ if self.purchase_line_id:
+ purchase_currency = self.purchase_line_id.currency_id
+ if purchase_currency != self.company_id.currency_id:
+ purchase_price_unit = self.purchase_line_id.price_unit
+ currency_move_valuation = purchase_currency.round(purchase_price_unit * qty)
+ rslt['credit_line_vals']['amount_currency'] = rslt['credit_line_vals']['credit'] and -currency_move_valuation or currency_move_valuation
+ rslt['credit_line_vals']['currency_id'] = purchase_currency.id
+ rslt['debit_line_vals']['amount_currency'] = rslt['debit_line_vals']['credit'] and -currency_move_valuation or currency_move_valuation
+ rslt['debit_line_vals']['currency_id'] = purchase_currency.id
+ return rslt
+
def _prepare_extra_move_vals(self, qty):
vals = super(StockMove, self)._prepare_extra_move_vals(qty)
vals['purchase_line_id'] = self.purchase_line_id.id
@@ -78,6 +96,13 @@ class StockMove(models.Model):
else:
return super(StockMove, self)._get_upstream_documents_and_responsibles(visited)
+ def _get_related_invoices(self):
+ """ Overridden to return the vendor bills related to this stock move.
+ """
+ rslt = super(StockMove, self)._get_related_invoices()
+ rslt += self.mapped('picking_id.purchase_id.invoice_ids').filtered(lambda x: x.state not in ('draft', 'cancel'))
+ return rslt
+
class StockWarehouse(models.Model):
_inherit = 'stock.warehouse'
diff --git a/addons/purchase/tests/__init__.py b/addons/purchase/tests/__init__.py
index a776a966ed3..e1c4eff850e 100644
--- a/addons/purchase/tests/__init__.py
+++ b/addons/purchase/tests/__init__.py
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from . import test_anglo_saxon_valuation_reconciliation
from . import test_average_price
from . import test_create_picking
from . import test_fifo_price
diff --git a/addons/purchase/tests/test_anglo_saxon_valuation_reconciliation.py b/addons/purchase/tests/test_anglo_saxon_valuation_reconciliation.py
new file mode 100644
index 00000000000..fdb0311662b
--- /dev/null
+++ b/addons/purchase/tests/test_anglo_saxon_valuation_reconciliation.py
@@ -0,0 +1,138 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from datetime import datetime
+
+import time
+
+from odoo.addons.stock_account.tests.test_anglo_saxon_valuation_reconciliation_common import ValuationReconciliationTestCase
+from odoo.tests import tagged
+
+
+@tagged('post_install', '-at_install')
+class TestValuationReconciliation(ValuationReconciliationTestCase):
+
+ def setUp(self):
+ super(TestValuationReconciliation, self).setUp()
+
+ #set a price difference account on the category
+ self.price_dif_account = self.env['account.account'].create({
+ 'name': 'Test price dif',
+ 'code': 'purchase_account_TEST_42',
+ 'user_type_id': self.env['account.account.type'].search([],limit=1).id,
+ 'reconcile': True,
+ 'company_id': self.company.id,
+ })
+ self.test_product_category.property_account_creditor_price_difference_categ = self.price_dif_account.id
+
+ def _create_purchase(self, product, quantity=1.0):
+ rslt = self.env['purchase.order'].create({
+ 'partner_id': self.test_partner.id,
+ 'currency_id': self.currency_two.id,
+ 'order_line': [
+ (0, 0, {
+ 'name': product.name,
+ 'product_id': product.id,
+ 'product_qty': quantity,
+ 'product_uom': product.uom_po_id.id,
+ 'price_unit': self.product_price_unit,
+ 'date_planned': datetime.today(),
+ })],
+ })
+ rslt.button_confirm()
+ return rslt
+
+ def _create_invoice_for_po(self, purchase_order):
+ account_receivable = self.env['account.account'].search([('user_type_id', '=', self.env.ref('account.data_account_type_receivable').id)], limit=1)
+ rslt = self.env['account.invoice'].create({
+ 'purchase_id': purchase_order.id,
+ 'partner_id': self.test_partner.id,
+ 'reference_type': 'none',
+ 'currency_id': self.currency_two.id,
+ 'name': 'vendor bill',
+ 'type': 'in_invoice',
+ 'date_invoice': time.strftime('%Y') + '-12-22',
+ 'account_id': account_receivable.id,
+ })
+ rslt.purchase_order_change()
+ return rslt
+
+ def test_shipment_invoice(self):
+ """ 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)
+ self._process_pickings(purchase_order.picking_ids)
+
+ invoice = self._create_invoice_for_po(purchase_order)
+ self.currency_rate.rate = 7.76435463
+ invoice.action_invoice_open()
+ picking = self.env['stock.picking'].search([('purchase_id','=',purchase_order.id)])
+ self.check_reconciliation(invoice, picking)
+ # cancel the invoice
+ invoice.journal_id.write({'update_posted': 1})
+ invoice.action_cancel()
+
+ def test_invoice_shipment(self):
+ """ Tests the case into which we make the invoice first, and then receive the goods.
+ """
+ test_product = self.test_product_order
+ purchase_order = self._create_purchase(test_product)
+
+ invoice = self._create_invoice_for_po(purchase_order)
+ invoice_line = self.env['account.invoice.line'].search([('invoice_id', '=', invoice.id)])
+ invoice_line.quantity = 1
+
+ self.currency_rate.rate = 13.834739702
+
+ invoice.action_invoice_open()
+ self._process_pickings(purchase_order.picking_ids)
+ picking = self.env['stock.picking'].search([('purchase_id', '=', purchase_order.id)])
+ self.check_reconciliation(invoice, picking)
+
+ #return the goods and refund the invoice
+ self.currency_rate.rate = 10.54739702
+ stock_return_picking = self.env['stock.return.picking']\
+ .with_context(active_ids=[picking.id], active_id=picking.id).create({})
+ stock_return_picking.product_return_moves.quantity = 1.0
+ stock_return_picking_action = stock_return_picking.create_returns()
+ return_pick = self.env['stock.picking'].browse(stock_return_picking_action['res_id'])
+ return_pick.action_assign()
+ return_pick.move_lines.quantity_done = 1
+ return_pick.action_done()
+ self.currency_rate.rate = 9.56564564
+ refund_invoice_wiz = self.env['account.invoice.refund'].with_context(active_ids=[invoice.id]).create({
+ 'description': 'test_invoice_shipment_refund',
+ 'filter_refund': 'cancel',
+ })
+ refund_invoice_wiz.invoice_refund()
+ refund_invoice = self.env['account.invoice'].search([('name', '=', 'test_invoice_shipment_refund')])[0]
+ self.assertTrue(invoice.state == refund_invoice.state == 'paid'), "Invoice and refund should both be in 'Paid' state"
+ self.check_reconciliation(refund_invoice, return_pick)
+
+ def test_multiple_shipments_invoices(self):
+ """ 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, quantity=5.0)
+ self._process_pickings(purchase_order.picking_ids, quantity=2.0)
+ picking = self.env['stock.picking'].search([('purchase_id', '=', purchase_order.id)], order="id asc", limit=1)
+
+ invoice = self._create_invoice_for_po(purchase_order)
+ invoice_line = self.env['account.invoice.line'].search([('invoice_id', '=', invoice.id)])
+ invoice_line.quantity = 3
+ self.currency_rate.rate = 7.76435463
+ invoice.action_invoice_open()
+ self.check_reconciliation(invoice, picking, full_reconcile=False)
+
+ invoice2 = self._create_invoice_for_po(purchase_order)
+ invoice_line = self.env['account.invoice.line'].search([('invoice_id', '=', invoice2.id)])
+ invoice_line.quantity = 2
+ self.currency_rate.rate = 13.834739702
+ invoice2.action_invoice_open()
+ self.check_reconciliation(invoice2, picking, full_reconcile=False)
+
+ self.currency_rate.rate = 12.195747002
+ 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/sale_stock/models/account_invoice.py b/addons/sale_stock/models/account_invoice.py
index 3b4c60cc413..4fa238cdbe2 100644
--- a/addons/sale_stock/models/account_invoice.py
+++ b/addons/sale_stock/models/account_invoice.py
@@ -11,6 +11,16 @@ class AccountInvoice(models.Model):
help="Incoterms are series of sales terms. They are used to divide transaction costs and responsibilities between buyer and seller and reflect state-of-the-art transportation practices.",
readonly=True, states={'draft': [('readonly', False)]})
+ def _get_last_step_stock_moves(self):
+ """ Overridden from stock_account.
+ Returns the stock moves associated to this invoice."""
+ rslt = super(AccountInvoice, self)._get_last_step_stock_moves()
+ for invoice in self.filtered(lambda x: x.type == 'out_invoice'):
+ rslt += invoice.mapped('invoice_line_ids.sale_line_ids.order_id.picking_ids.move_lines').filtered(lambda x: x.state == 'done' and x.location_dest_id.usage == 'customer')
+ for invoice in self.filtered(lambda x: x.type == 'out_refund'):
+ rslt += invoice.mapped('refund_invoice_id.invoice_line_ids.sale_line_ids.order_id.picking_ids.move_lines').filtered(lambda x: x.state == 'done' and x.location_id.usage == 'customer')
+ return rslt
+
class AccountInvoiceLine(models.Model):
_inherit = "account.invoice.line"
diff --git a/addons/sale_stock/models/stock.py b/addons/sale_stock/models/stock.py
index cef63155645..eee0424021c 100644
--- a/addons/sale_stock/models/stock.py
+++ b/addons/sale_stock/models/stock.py
@@ -26,6 +26,15 @@ class StockMove(models.Model):
keys_sorted.append(move.sale_line_id.id)
return keys_sorted
+ def _get_related_invoices(self):
+ """ Overridden from stock_account to return the customer invoices
+ related to this stock move.
+ """
+ rslt = super(StockMove, self)._get_related_invoices()
+ invoices = self.mapped('picking_id.sale_id.invoice_ids').filtered(lambda x: x.state not in ('draft', 'cancel'))
+ rslt += invoices
+ #rslt += invoices.mapped('refund_invoice_ids')
+ return rslt
class ProcurementGroup(models.Model):
_inherit = 'procurement.group'
@@ -91,4 +100,4 @@ class StockPicking(models.Model):
documents = self._log_activity_get_documents(moves, 'sale_line_id', 'DOWN', _keys_in_sorted, _keys_in_groupby)
self._log_activity(_render_note_exception_quantity, documents)
- return super(StockPicking, self)._log_less_quantities_than_expected(moves)
\ No newline at end of file
+ return super(StockPicking, self)._log_less_quantities_than_expected(moves)
diff --git a/addons/sale_stock/tests/__init__.py b/addons/sale_stock/tests/__init__.py
index 9660081dbd5..604424f7a6e 100644
--- a/addons/sale_stock/tests/__init__.py
+++ b/addons/sale_stock/tests/__init__.py
@@ -1,4 +1,5 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from . import test_anglo_saxon_valuation_reconciliation
from . import test_sale_stock
from . import test_sale_stock_lead_time
diff --git a/addons/sale_stock/tests/test_anglo_saxon_valuation_reconciliation.py b/addons/sale_stock/tests/test_anglo_saxon_valuation_reconciliation.py
new file mode 100644
index 00000000000..37c77993954
--- /dev/null
+++ b/addons/sale_stock/tests/test_anglo_saxon_valuation_reconciliation.py
@@ -0,0 +1,173 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import time
+
+from odoo.addons.stock_account.tests.test_anglo_saxon_valuation_reconciliation_common import ValuationReconciliationTestCase
+from odoo.tests import tagged
+
+
+@tagged('post_install', '-at_install')
+class TestValuationReconciliation(ValuationReconciliationTestCase):
+
+ def setUp(self):
+ super(TestValuationReconciliation, self).setUp()
+ self.account_receivable = self.env['account.account'].create({
+ 'code': 'X1111',
+ 'name': 'Sale - Test Receivable Account',
+ 'user_type_id': self.env.ref('account.data_account_type_receivable').id,
+ 'reconcile': True
+ })
+
+ self.account_income = self.env['account.account'].create({
+ 'code': 'X1112',
+ 'name': 'Sale - Test Account',
+ 'user_type_id': self.env.ref('account.data_account_type_direct_costs').id
+ })
+
+ self.env.ref('product.list0').currency_id = self.currency_two.id
+
+ #set the invoice_policy to delivery to have an accurate COGS entry
+ self.test_product_delivery.invoice_policy = "delivery"
+
+ def _create_sale(self, product, quantity=1.0):
+ rslt = self.env['sale.order'].create({
+ 'partner_id': self.test_partner.id,
+ 'currency_id': self.currency_two.id,
+ 'order_line': [
+ (0, 0, {
+ 'name': product.name,
+ 'product_id': product.id,
+ 'product_uom_qty': quantity,
+ 'product_uom': product.uom_po_id.id,
+ 'price_unit': self.product_price_unit,
+ })],
+ })
+ rslt.action_confirm()
+ return rslt
+
+ def _create_invoice_for_so(self, sale_order, product):
+ rslt = self.env['account.invoice'].create({
+ 'partner_id': self.test_partner.id,
+ 'reference_type': 'none',
+ 'currency_id': self.currency_two.id,
+ 'name': 'customer invoice',
+ 'type': 'out_invoice',
+ 'date_invoice': time.strftime('%Y') + '-12-22',
+ 'account_id': self.account_receivable.id,
+ 'invoice_line_ids': [(0, 0, {
+ 'name': 'test line',
+ 'origin': sale_order.name,
+ 'account_id': self.account_income.id,
+ 'price_unit': self.product_price_unit,
+ 'quantity': 1.0,
+ 'discount': 0.0,
+ 'uom_id': product.uom_id.id,
+ 'product_id': product.id,
+ 'sale_line_ids': [(6, 0, [line.id for line in sale_order.order_line])],
+ })],
+ })
+
+ sale_order.invoice_ids += rslt
+ return rslt
+
+ def _set_initial_stock_for_product(self, product):
+ move1 = self.env['stock.move'].create({
+ 'name': 'Initial stock',
+ 'location_id': self.env.ref('stock.stock_location_suppliers').id,
+ 'location_dest_id': self.env.ref('stock.stock_location_stock').id,
+ 'product_id': product.id,
+ 'product_uom': product.uom_id.id,
+ 'product_uom_qty': 11,
+ 'price_unit': 13,
+ })
+ move1._action_confirm()
+ move1._action_assign()
+ move1.move_line_ids.qty_done = 11
+ move1._action_done()
+
+ def test_shipment_invoice(self):
+ """ Tests the case into which we send the goods to the customer before
+ making the invoice
+ """
+ test_product = self.test_product_delivery
+ self._set_initial_stock_for_product(test_product)
+
+ sale_order = self._create_sale(test_product)
+ self._process_pickings(sale_order.picking_ids)
+
+ invoice = self._create_invoice_for_so(sale_order, test_product)
+ self.currency_rate.rate = 9.87366352
+ invoice.action_invoice_open()
+ picking = self.env['stock.picking'].search([('sale_id', '=', sale_order.id)])
+ self.check_reconciliation(invoice, picking, operation='sale')
+
+ def test_invoice_shipment(self):
+ """ Tests the case into which we make the invoice first, and then send
+ the goods to our customer.
+ """
+ test_product = self.test_product_delivery
+ #since the invoice come first, the COGS will use the standard price on product
+ self.test_product_delivery.standard_price = 13
+ self._set_initial_stock_for_product(test_product)
+
+ sale_order = self._create_sale(test_product)
+
+ invoice = self._create_invoice_for_so(sale_order, test_product)
+ self.currency_rate.rate = 0.974784
+ invoice.action_invoice_open()
+
+ self._process_pickings(sale_order.picking_ids)
+
+ picking = self.env['stock.picking'].search([('sale_id', '=', sale_order.id)])
+ self.check_reconciliation(invoice, picking, operation='sale')
+
+ #return the goods and refund the invoice
+ self.currency_rate.rate = 10.54739702
+ stock_return_picking = self.env['stock.return.picking']\
+ .with_context(active_ids=[picking.id], active_id=picking.id).create({})
+ stock_return_picking.product_return_moves.quantity = 1.0
+ stock_return_picking_action = stock_return_picking.create_returns()
+ return_pick = self.env['stock.picking'].browse(stock_return_picking_action['res_id'])
+ return_pick.action_assign()
+ return_pick.move_lines.quantity_done = 1
+ return_pick.action_done()
+ self.currency_rate.rate = 9.56564564
+ refund_invoice_wiz = self.env['account.invoice.refund'].with_context(active_ids=[invoice.id]).create({
+ 'description': 'test_invoice_shipment_refund',
+ 'filter_refund': 'cancel',
+ })
+ refund_invoice_wiz.invoice_refund()
+ refund_invoice = self.env['account.invoice'].search([('name', '=', 'test_invoice_shipment_refund')])[0]
+ self.assertTrue(invoice.state == refund_invoice.state == 'paid'), "Invoice and refund should both be in 'Paid' state"
+ self.check_reconciliation(refund_invoice, return_pick, operation='sale')
+
+ def test_multiple_shipments_invoices(self):
+ """ Tests the case into which we deliver part of the goods first, then 2 invoices at different rates, and finally the remaining quantities
+ """
+ test_product = self.test_product_delivery
+ self._set_initial_stock_for_product(test_product)
+
+ sale_order = self._create_sale(test_product, quantity=5)
+
+ self._process_pickings(sale_order.picking_ids, quantity=2.0)
+ picking = self.env['stock.picking'].search([('sale_id', '=', sale_order.id)], order="id asc", limit=1)
+
+ invoice = self._create_invoice_for_so(sale_order, test_product)
+ invoice_line = self.env['account.invoice.line'].search([('invoice_id', '=', invoice.id)])
+ invoice_line.quantity = 3
+ self.currency_rate.rate = 7.76435463
+ invoice.action_invoice_open()
+ self.check_reconciliation(invoice, picking, full_reconcile=False, operation='sale')
+
+ invoice2 = self._create_invoice_for_so(sale_order, test_product)
+ invoice_line = self.env['account.invoice.line'].search([('invoice_id', '=', invoice2.id)])
+ invoice_line.quantity = 2
+ self.currency_rate.rate = 13.834739702
+ invoice2.action_invoice_open()
+ self.check_reconciliation(invoice2, picking, full_reconcile=False, operation='sale')
+
+ self.currency_rate.rate = 12.195747002
+ self._process_pickings(sale_order.picking_ids.filtered(lambda x: x.state != 'done'), quantity=3.0)
+ picking = self.env['stock.picking'].search([('sale_id', '=', sale_order.id)], order='id desc', limit=1)
+ self.check_reconciliation(invoice2, picking, operation='sale')
diff --git a/addons/stock_account/models/account_invoice.py b/addons/stock_account/models/account_invoice.py
index 5f9263dbbc1..3a7be3a919d 100644
--- a/addons/stock_account/models/account_invoice.py
+++ b/addons/stock_account/models/account_invoice.py
@@ -38,6 +38,56 @@ class AccountInvoice(models.Model):
return self.env['product.product']._anglo_saxon_sale_move_lines(i_line.name, i_line.product_id, i_line.uom_id, i_line.quantity, price_unit, currency=currency, amount_currency=amount_currency, fiscal_position=inv.fiscal_position_id, account_analytic=i_line.account_analytic_id, analytic_tags=i_line.analytic_tag_ids)
+ def _get_last_step_stock_moves(self):
+ """ To be overridden for customer invoices and vendor bills in order to
+ return the stock moves related to the invoices in self.
+ """
+ return self.env['stock.move']
+
+ def _get_products_set(self):
+ """ Returns a recordset of the products contained in this invoice's lines """
+ return self.mapped('invoice_line_ids.product_id')
+
+ def _get_anglosaxon_interim_account(self, product):
+ """ Returns the interim account used in anglosaxon accounting for
+ this invoice"""
+ if self.type in ('out_invoice', 'out_refund'):
+ return product.product_tmpl_id._get_product_accounts()['stock_output']
+ return product.product_tmpl_id.get_product_accounts()['stock_input']
+
+ def invoice_validate(self):
+ res = super(AccountInvoice, self).invoice_validate()
+ self.filtered(lambda i: i.company_id.anglo_saxon_accounting)._anglo_saxon_reconcile_valuation()
+ return res
+
+ def _anglo_saxon_reconcile_valuation(self, product=False):
+ """ Reconciles the entries made in the interim accounts in anglosaxon accounting,
+ reconciling stock valuation move lines with the invoice's.
+ """
+ for invoice in self:
+ if invoice.company_id.anglo_saxon_accounting:
+ stock_moves = invoice._get_last_step_stock_moves()
+ product_set = product or invoice._get_products_set()
+ for prod in product_set:
+ if prod.valuation == 'real_time' and stock_moves:
+ # We first get the invoices move lines (taking the invoice and the previous ones into account)...
+ product_interim_account = invoice._get_anglosaxon_interim_account(prod)
+ to_reconcile = self.env['account.move.line'].search([
+ ('move_id', '=', invoice.move_id.id),
+ ('product_id', '=', prod.id),
+ ('account_id','=', product_interim_account.id),
+ ('reconciled','=', False)
+ ])
+
+ # And then the stock valuation ones.
+ product_stock_moves = stock_moves.filtered(lambda s: s.product_id.id == prod.id)
+ for valuation_line in product_stock_moves.mapped('account_move_ids.line_ids'):
+ if valuation_line.account_id == product_interim_account and not valuation_line.reconciled:
+ to_reconcile += valuation_line
+
+ if to_reconcile:
+ to_reconcile.reconcile()
+
class AccountInvoiceLine(models.Model):
_inherit = "account.invoice.line"
diff --git a/addons/stock_account/models/account_move.py b/addons/stock_account/models/account_move.py
index 7919da15090..97aebcdec29 100644
--- a/addons/stock_account/models/account_move.py
+++ b/addons/stock_account/models/account_move.py
@@ -1,10 +1,12 @@
# -*- coding: utf-8 -*-
-from odoo import fields, models
+from odoo import fields, models, _
+from odoo.tools.float_utils import float_is_zero
+
+from odoo.exceptions import UserError
class AccountMove(models.Model):
_inherit = 'account.move'
stock_move_id = fields.Many2one('stock.move', string='Stock Move')
-
diff --git a/addons/stock_account/models/stock.py b/addons/stock_account/models/stock.py
index a128f5a6942..f9c4afae732 100644
--- a/addons/stock_account/models/stock.py
+++ b/addons/stock_account/models/stock.py
@@ -71,7 +71,7 @@ class StockMoveLine(models.Model):
if move.product_id.valuation == 'real_time' and (move._is_in() or move._is_out()):
move.with_context(force_valuation_amount=correction_value)._account_entry_move()
return res
-
+
@api.multi
def write(self, vals):
if 'qty_done' in vals:
@@ -489,7 +489,7 @@ class StockMove(models.Model):
raise UserError(_('You don\'t have any stock valuation account defined on your product category. You must define one before processing this operation.'))
journal_id = accounts_data['stock_journal'].id
return journal_id, acc_src, acc_dest, acc_valuation
-
+
def _prepare_account_move_line(self, qty, cost, credit_account_id, debit_account_id):
"""
Generate the account.move.line values to post to track the stock valuation difference due to the
@@ -522,7 +522,16 @@ class StockMove(models.Model):
if self.location_id.usage == 'customer' and self.origin_returned_move_id:
debit_value = self.origin_returned_move_id.price_unit * qty
credit_value = debit_value
- partner_id = (self.picking_id.partner_id and self.env['res.partner']._find_accounting_partner(self.picking_id.partner_id).id) or False
+
+ valuation_partner_id = self._get_partner_id_for_valuation_lines()
+ res = [(0, 0, line_vals) for line_vals in self._generate_valuation_lines_data(valuation_partner_id, qty, debit_value, credit_value, debit_account_id, credit_account_id).values()]
+
+ return res
+
+ def _generate_valuation_lines_data(self, partner_id, qty, debit_value, credit_value, debit_account_id, credit_account_id):
+ # This method returns a dictonary to provide an easy extension hook to modify the valuation lines (see purchase for an example)
+ self.ensure_one()
+
debit_line_vals = {
'name': self.name,
'product_id': self.product_id.id,
@@ -534,6 +543,7 @@ class StockMove(models.Model):
'credit': -debit_value if debit_value < 0 else 0,
'account_id': debit_account_id,
}
+
credit_line_vals = {
'name': self.name,
'product_id': self.product_id.id,
@@ -545,16 +555,19 @@ class StockMove(models.Model):
'debit': -credit_value if credit_value < 0 else 0,
'account_id': credit_account_id,
}
- res = [(0, 0, debit_line_vals), (0, 0, credit_line_vals)]
+
+ rslt = {'credit_line_vals': credit_line_vals, 'debit_line_vals': debit_line_vals}
if credit_value != debit_value:
# for supplier returns of product in average costing method, in anglo saxon mode
diff_amount = debit_value - credit_value
price_diff_account = self.product_id.property_account_creditor_price_difference
+
if not price_diff_account:
price_diff_account = self.product_id.categ_id.property_account_creditor_price_difference_categ
if not price_diff_account:
raise UserError(_('Configuration error. Please configure the price difference account on the product or its category to process this operation.'))
- price_diff_line = {
+
+ rslt['price_diff_line_vals'] = {
'name': self.name,
'product_id': self.product_id.id,
'quantity': qty,
@@ -565,8 +578,10 @@ class StockMove(models.Model):
'debit': diff_amount < 0 and -diff_amount or 0,
'account_id': price_diff_account.id,
}
- res.append((0, 0, price_diff_line))
- return res
+ return rslt
+
+ def _get_partner_id_for_valuation_lines(self):
+ return (self.picking_id.partner_id and self.env['res.partner']._find_accounting_partner(self.picking_id.partner_id).id) or False
def _create_account_move_line(self, credit_account_id, debit_account_id, journal_id):
self.ensure_one()
@@ -620,6 +635,16 @@ class StockMove(models.Model):
journal_id, acc_src, acc_dest, acc_valuation = self._get_accounting_data_for_valuation()
self.with_context(force_company=self.company_id.id)._create_account_move_line(acc_src, acc_dest, journal_id)
+ if self.company_id.anglo_saxon_accounting:
+ #eventually reconcile together the invoice and valuation accounting entries on the stock interim accounts
+ self._get_related_invoices()._anglo_saxon_reconcile_valuation(product=self.product_id)
+
+ def _get_related_invoices(self): # To be overridden in purchase and sale_stock
+ """ This method is overrided in both purchase and sale_stock modules to adapt
+ to the way they mix stock moves with invoices.
+ """
+ return self.env['account.invoice']
+
class StockReturnPicking(models.TransientModel):
_inherit = "stock.return.picking"
diff --git a/addons/stock_account/tests/__init__.py b/addons/stock_account/tests/__init__.py
index 065327b149f..5b01a39a676 100644
--- a/addons/stock_account/tests/__init__.py
+++ b/addons/stock_account/tests/__init__.py
@@ -1 +1,2 @@
+from . import test_anglo_saxon_valuation_reconciliation_common
from . import test_stockvaluation
diff --git a/addons/stock_account/tests/test_anglo_saxon_valuation_reconciliation_common.py b/addons/stock_account/tests/test_anglo_saxon_valuation_reconciliation_common.py
new file mode 100644
index 00000000000..3732cddbd7b
--- /dev/null
+++ b/addons/stock_account/tests/test_anglo_saxon_valuation_reconciliation_common.py
@@ -0,0 +1,120 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+
+from odoo.addons.account.tests.account_test_classes import AccountingTestCase
+
+class ValuationReconciliationTestCase(AccountingTestCase):
+ """ Base class for tests checking interim accounts reconciliation works
+ in anglosaxon accounting. It sets up everything we need in the tests, and is
+ extended in both sale_stock and purchase modules to run the 'true' tests.
+ """
+
+ def check_reconciliation(self, invoice, picking, full_reconcile=True, operation='purchase'):
+ interim_account_id = operation == 'purchase' and self.input_account.id or self.output_account.id
+ invoice_line = self.env['account.move.line'].search([('move_id','=', invoice.move_id.id), ('account_id', '=', interim_account_id)])
+ valuation_line = picking.move_lines.mapped('account_move_ids.line_ids').filtered(lambda x: x.account_id.id == interim_account_id)
+
+ self.assertEqual(len(invoice_line), 1, "Only one line should have been written by invoice in stock input account")
+ self.assertEqual(len(valuation_line), 1, "Only one line should have been written for stock valuation in stock input account")
+ self.assertTrue(valuation_line.reconciled or invoice_line.reconciled, "The valuation and invoice line should have been reconciled together.")
+
+ if full_reconcile:
+ self.assertTrue(valuation_line.full_reconcile_id, "The reconciliation should be total at that point.")
+ else:
+ self.assertFalse(valuation_line.full_reconcile_id, "The reconciliation should not be total at that point.")
+
+ def _process_pickings(self, pickings, quantity=False):
+ pickings.action_confirm()
+ pickings.action_assign()
+ for picking in pickings:
+ for ml in picking.move_line_ids:
+ ml.qty_done = quantity or ml.product_qty
+ pickings.action_done()
+
+ def _create_product_category(self):
+ return self.env['product.category'].create({
+ 'name': 'Test category',
+ 'property_valuation': 'real_time',
+ 'property_cost_method': 'fifo',
+ 'property_stock_valuation_account_id': self.valuation_account.id,
+ 'property_stock_account_input_categ_id': self.input_account.id,
+ 'property_stock_account_output_categ_id': self.output_account.id,
+ })
+
+ def setUp(self):
+ super(ValuationReconciliationTestCase, self).setUp()
+
+ self.company = self.env['res.company']._company_default_get()
+ self.company.anglo_saxon_accounting = True
+ self.currency_one = self.company.currency_id
+ currency_two_name = 'USD' if self.currency_one.name != 'USD' else 'EUR'
+ self.currency_two = self.env['res.currency'].search([('name', '=', currency_two_name)])
+ self.currency_rate = self.env['res.currency.rate'].create({
+ 'currency_id': self.currency_one.id,
+ 'company_id': self.company.id,
+ 'rate': 1.234343354, # Totally arbitratry value
+ })
+
+ self.input_account = self.env['account.account'].create({
+ 'name': 'Test stock in',
+ 'code': 'stock_account_TEST_42',
+ 'user_type_id': self.env['account.account.type'].search([],limit=1).id,
+ 'reconcile': True,
+ 'company_id': self.company.id,
+ })
+
+ self.output_account = self.env['account.account'].create({
+ 'name': 'Test stock out',
+ 'code': 'stock_account_TEST_43',
+ 'user_type_id': self.env['account.account.type'].search([],limit=1).id,
+ 'reconcile': True,
+ 'company_id': self.company.id,
+ })
+
+ self.valuation_account = self.env['account.account'].create({
+ 'name': 'Test stock valuation',
+ 'code': 'stock_account_TEST_44',
+ 'user_type_id': self.env['account.account.type'].search([],limit=1).id,
+ 'reconcile': True,
+ 'company_id': self.company.id,
+ })
+
+
+ self.test_product_category = self._create_product_category()
+
+ uom = self.env['uom.uom'].search([], limit=1)
+ test_product_delivery_inv_template = self.env['product.template'].create({
+ 'name': 'Test product template invoiced on delivery',
+ 'type': 'product',
+ 'categ_id': self.test_product_category.id,
+ 'uom_id': uom.id,
+ 'uom_po_id': uom.id,
+ })
+ test_product_order_inv_template = self.env['product.template'].create({
+ 'name': 'Test product template invoiced on order',
+ 'type': 'product',
+ 'categ_id': self.test_product_category.id,
+ 'uom_id': uom.id,
+ 'uom_po_id': uom.id,
+ })
+
+ self.test_product_order = self.env['product.product'].create({
+ 'name': 'The chocolate moose - order',
+ 'product_tmpl_id': test_product_order_inv_template.id,
+ 'standard_price': 42.0,
+ })
+
+ self.test_product_delivery = self.env['product.product'].create({
+ 'name': 'The chocolate moose - delivery',
+ 'product_tmpl_id': test_product_delivery_inv_template.id,
+ 'standard_price': 42.0,
+ })
+
+ self.test_partner = self.env['res.partner'].create({
+ 'name': 'Ruben Rybnik',
+ 'supplier': True,
+ 'customer': True,
+ })
+
+ self.product_price_unit = 66.0