[IMP] stock_account, anglo-saxon accounting: help to clear out interim accounts
Accounting entries made for invoices and stock valuation on the interim accounts (stock input/output accounts) are now reconciled together for both sales and purchases. This will definitively help to have those accounts zero-outed when all operations are processed. The reconciliation is made as long as the stock valuation is set in real-time, whatever the costing method. Note that change change also allow a particular use case to be solved: when a purchase is made in a foreign currency whose rate change between the incoming shipment reception and the bill validation (there will be an automated exchange rate entry created). Was task 32331. Was PR #22483
This commit is contained in:
@@ -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})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -49,12 +49,14 @@
|
||||
<field name="name">Stock Interim Account (Received)</field>
|
||||
<field name="user_type_id" ref="account.data_account_type_current_assets"/>
|
||||
<field name="chart_template_id" ref="configurable_chart_template"/>
|
||||
<field name="reconcile" eval="True"/>
|
||||
</record>
|
||||
<record id="conf_cas_interim2" model="account.account.template">
|
||||
<field name="code">10113</field>
|
||||
<field name="name">Stock Interim Account (Delivered)</field>
|
||||
<field name="user_type_id" ref="account.data_account_type_current_assets"/>
|
||||
<field name="chart_template_id" ref="configurable_chart_template"/>
|
||||
<field name="reconcile" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="conf_a_recv" model="account.account.template">
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
return super(StockPicking, self)._log_less_quantities_than_expected(moves)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
@@ -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"
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
from . import test_anglo_saxon_valuation_reconciliation_common
|
||||
from . import test_stockvaluation
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user