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