# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. import time from datetime import datetime, timedelta from freezegun import freeze_time from unittest.mock import patch import odoo from odoo import fields, exceptions, Command from odoo.tests import Form from odoo.tests.common import TransactionCase, tagged from odoo.addons.account.tests.common import AccountTestInvoicingCommon from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT from odoo.addons.stock.tests.common import TestStockCommon class TestStockValuation(TransactionCase): @classmethod def setUpClass(cls): super().setUpClass() cls.url_extract_rec_id_and_model = TestStockCommon.url_extract_rec_id_and_model cls.supplier_location = cls.env.ref('stock.stock_location_suppliers') cls.stock_location = cls.env.ref('stock.stock_location_stock') cls.partner_id = cls.env['res.partner'].create({ 'name': 'Wood Corner Partner', 'company_id': cls.env.user.company_id.id, }) cls.product1 = cls.env['product.product'].create({ 'name': 'Large Desk', 'standard_price': 1299.0, 'list_price': 1799.0, # Ignore tax calculations for these tests. 'supplier_taxes_id': False, 'is_storable': True, }) Account = cls.env['account.account'] cls.stock_input_account = Account.create({ 'name': 'Stock Input', 'code': 'StockIn', 'account_type': 'asset_current', 'reconcile': True, }) cls.stock_output_account = Account.create({ 'name': 'Stock Output', 'code': 'StockOut', 'account_type': 'asset_current', 'reconcile': True, }) cls.stock_valuation_account = Account.create({ 'name': 'Stock Valuation', 'code': 'StockValuation', 'account_type': 'asset_current', }) cls.stock_journal = cls.env['account.journal'].create({ 'name': 'Stock Journal', 'code': 'STJTEST', 'type': 'general', }) cls.product1.categ_id.write({ 'property_valuation': 'real_time', 'property_stock_account_input_categ_id': cls.stock_input_account.id, 'property_stock_account_output_categ_id': cls.stock_output_account.id, 'property_stock_valuation_account_id': cls.stock_valuation_account.id, 'property_stock_journal': cls.stock_journal.id, }) cls.env.ref('base.EUR').active = True def test_different_uom(self): """ Set a quantity to replenish via the "Buy" route where product_uom is different from purchase uom """ self.env['ir.config_parameter'].sudo().set_param('stock.propagate_uom', False) # Create and set a new weight unit. kgm = self.env.ref('uom.product_uom_kgm') ap = self.env['uom.uom'].create({ 'category_id': kgm.category_id.id, 'name': 'Algerian Pounds', 'uom_type': 'bigger', 'ratio': 2.47541, 'rounding': 0.001, }) kgm_price = 100 ap_price = kgm_price / ap.factor self.product1.uom_id = ap self.product1.uom_po_id = kgm # Set vendor vendor = self.env['res.partner'].create(dict(name='The Replenisher')) supplierinfo = self.env['product.supplierinfo'].create({ 'partner_id': vendor.id, 'price': kgm_price, }) self.product1.seller_ids = [(4, supplierinfo.id, 0)] # Automated stock valuation self.product1.product_tmpl_id.categ_id.property_valuation = 'real_time' self.product1.product_tmpl_id.categ_id.property_cost_method = 'fifo' # Create a manual replenishment replenishment_uom_qty = 200 replenish_wizard = self.env['product.replenish'].with_context(default_product_tmpl_id=self.product1.product_tmpl_id.id).create({ 'product_id': self.product1.id, 'product_tmpl_id': self.product1.product_tmpl_id.id, 'product_uom_id': ap.id, 'quantity': replenishment_uom_qty, 'warehouse_id': self.env['stock.warehouse'].search([('company_id', '=', self.env.user.id)], limit=1).id, }) genrated_picking = replenish_wizard.launch_replenishment() links = genrated_picking.get("params", {}).get("links") url = links and links[0].get("url", "") or "" purchase_order_id, model_name = self.url_extract_rec_id_and_model(url) last_po_id = False if purchase_order_id and model_name: last_po_id = self.env[model_name].browse(int(purchase_order_id)) order_line = last_po_id.order_line.search([('product_id', '=', self.product1.id)]) self.assertEqual(order_line.product_qty, ap._compute_quantity(replenishment_uom_qty, kgm, rounding_method='HALF-UP'), 'Quantities does not match') # Recieve products last_po_id.button_confirm() picking = last_po_id.picking_ids[0] move = picking.move_ids[0] move.quantity = move.product_uom_qty move.picked = True picking.button_validate() self.assertEqual(move.stock_valuation_layer_ids.unit_cost, last_po_id.currency_id.round(ap_price), "Wrong Unit price") def test_change_unit_cost_average_1(self): """ Confirm a purchase order and create the associated receipt, change the unit cost of the purchase order before validating the receipt, the value of the received goods should be set according to the last unit cost. """ self.product1.product_tmpl_id.categ_id.property_cost_method = 'average' po1 = self.env['purchase.order'].create({ 'partner_id': self.partner_id.id, 'order_line': [ (0, 0, { 'name': self.product1.name, 'product_id': self.product1.id, 'product_qty': 10.0, 'product_uom': self.product1.uom_po_id.id, 'price_unit': 100.0, 'date_planned': datetime.today().strftime(DEFAULT_SERVER_DATETIME_FORMAT), }), ], }) po1.button_confirm() picking1 = po1.picking_ids[0] move1 = picking1.move_ids[0] # the unit price of the purchase order line is copied to the in move self.assertEqual(move1.price_unit, 100) # update the unit price on the purchase order line po1.order_line.price_unit = 200 # validate the receipt picking1.button_validate() # the unit price of the valuationlayer used the latest value self.assertEqual(move1.stock_valuation_layer_ids.unit_cost, 200) self.assertEqual(self.product1.value_svl, 2000) def test_standard_price_change_1(self): """ Confirm a purchase order and create the associated receipt, change the unit cost of the purchase order and the standard price of the product before validating the receipt, the value of the received goods should be set according to the last standard price. """ self.product1.product_tmpl_id.categ_id.property_cost_method = 'standard' # set a standard price self.product1.product_tmpl_id.standard_price = 10 po1 = self.env['purchase.order'].create({ 'partner_id': self.partner_id.id, 'order_line': [ (0, 0, { 'name': self.product1.name, 'product_id': self.product1.id, 'product_qty': 10.0, 'product_uom': self.product1.uom_po_id.id, 'price_unit': 11.0, 'date_planned': datetime.today().strftime(DEFAULT_SERVER_DATETIME_FORMAT), }), ], }) po1.button_confirm() picking1 = po1.picking_ids[0] move1 = picking1.move_ids[0] # the move's unit price reflects the purchase order line's cost even if it's useless when # the product's cost method is standard self.assertEqual(move1.price_unit, 11) # set a new standard price self.product1.product_tmpl_id.standard_price = 12 # the unit price on the stock move is not directly updated self.assertEqual(move1.price_unit, 11) # validate the receipt picking1.button_validate() # the unit price of the valuation layer used the latest value self.assertEqual(move1.stock_valuation_layer_ids.unit_cost, 12) self.assertEqual(self.product1.value_svl, 120) def test_extra_move_fifo_1(self): """ Check that the extra move when over processing a receipt is correctly merged back in the original move. """ self.product1.product_tmpl_id.categ_id.property_cost_method = 'fifo' po1 = self.env['purchase.order'].create({ 'partner_id': self.partner_id.id, 'order_line': [ (0, 0, { 'name': self.product1.name, 'product_id': self.product1.id, 'product_qty': 10.0, 'product_uom': self.product1.uom_po_id.id, 'price_unit': 100.0, 'date_planned': datetime.today().strftime(DEFAULT_SERVER_DATETIME_FORMAT), }), ], }) po1.button_confirm() picking1 = po1.picking_ids[0] move1 = picking1.move_ids[0] move1.quantity = 15 move1.picked = True picking1.button_validate() # there should be only one move self.assertEqual(len(picking1.move_ids), 1) self.assertEqual(move1.price_unit, 100) self.assertEqual(move1.stock_valuation_layer_ids.unit_cost, 100) self.assertEqual(move1.product_qty, 10) self.assertEqual(move1.quantity, 15) self.assertEqual(self.product1.value_svl, 1500) def test_backorder_fifo_1(self): """ Check that the backordered move when under processing a receipt correctly keep the price unit of the original move. """ self.product1.product_tmpl_id.categ_id.property_cost_method = 'fifo' po1 = self.env['purchase.order'].create({ 'partner_id': self.partner_id.id, 'order_line': [ (0, 0, { 'name': self.product1.name, 'product_id': self.product1.id, 'product_qty': 10.0, 'product_uom': self.product1.uom_po_id.id, 'price_unit': 100.0, 'date_planned': datetime.today().strftime(DEFAULT_SERVER_DATETIME_FORMAT), }), ], }) po1.button_confirm() picking1 = po1.picking_ids[0] move1 = picking1.move_ids[0] move1.quantity = 5 move1.picked = True res_dict = picking1.button_validate() self.assertEqual(res_dict['res_model'], 'stock.backorder.confirmation') wizard = self.env[(res_dict.get('res_model'))].browse(res_dict.get('res_id')).with_context(res_dict['context']) wizard.process() self.assertEqual(len(picking1.move_ids), 1) self.assertEqual(move1.price_unit, 100) self.assertEqual(move1.product_qty, 5) picking2 = po1.picking_ids.filtered(lambda p: p.backorder_id) move2 = picking2.move_ids[0] self.assertEqual(len(picking2.move_ids), 1) self.assertEqual(move2.price_unit, 100) self.assertEqual(move2.product_qty, 5) @tagged('post_install', '-at_install') class TestStockValuationWithCOA(AccountTestInvoicingCommon): @classmethod def setUpClass(cls): super().setUpClass() cls.supplier_location = cls.env.ref('stock.stock_location_suppliers') cls.stock_location = cls.env.ref('stock.stock_location_stock') cls.partner_id = cls.env['res.partner'].create({'name': 'Wood Corner Partner'}) cls.product1 = cls.env['product.product'].create({'name': 'Large Desk'}) cls.cat = cls.env['product.category'].create({ 'name': 'cat', }) cls.product1 = cls.env['product.product'].create({ 'name': 'product1', 'is_storable': True, 'categ_id': cls.cat.id, }) cls.product1_copy = cls.env['product.product'].create({ 'name': 'product1', 'is_storable': True, 'categ_id': cls.cat.id, }) Account = cls.env['account.account'] cls.usd_currency = cls.env.ref('base.USD') cls.eur_currency = cls.env.ref('base.EUR') cls.usd_currency.active = True cls.eur_currency.active = True cls.stock_input_account = Account.create({ 'name': 'Stock Input', 'code': 'StockIn', 'account_type': 'asset_current', 'reconcile': True, }) cls.stock_output_account = Account.create({ 'name': 'Stock Output', 'code': 'StockOut', 'account_type': 'asset_current', 'reconcile': True, }) cls.stock_valuation_account = Account.create({ 'name': 'Stock Valuation', 'code': 'StockValuation', 'account_type': 'asset_current', }) cls.price_diff_account = Account.create({ 'name': 'price diff account', 'code': 'priceDiffAccount', 'account_type': 'asset_current', }) cls.stock_journal = cls.env['account.journal'].create({ 'name': 'Stock Journal', 'code': 'STJTEST', 'type': 'general', }) cls.product1.categ_id.write({ 'property_stock_account_input_categ_id': cls.stock_input_account.id, 'property_stock_account_output_categ_id': cls.stock_output_account.id, 'property_stock_valuation_account_id': cls.stock_valuation_account.id, 'property_stock_journal': cls.stock_journal.id, 'property_account_creditor_price_difference_categ': cls.product1.product_tmpl_id.get_product_accounts()['expense'], 'property_valuation': 'real_time', }) old_action_post = odoo.addons.account.models.account_move.AccountMove.action_post old_create = odoo.models.BaseModel.create def new_action_post(self): """ Force the creation of tracking values. """ res = old_action_post(self) if self: cls.env.flush_all() cls.cr.flush() return res def new_create(self, vals_list): cls.cr._now = datetime.now() return old_create(self, vals_list) post_patch = patch('odoo.addons.account.models.account_move.AccountMove.action_post', new_action_post) create_patch = patch('odoo.models.BaseModel.create', new_create) cls.startClassPatcher(post_patch) cls.startClassPatcher(create_patch) @classmethod def default_env_context(cls): # OVERRIDE return {} def _bill(self, po, qty=None, price=None): action = po.action_create_invoice() bill = self.env["account.move"].browse(action["res_id"]) bill.invoice_date = fields.Date.today() if qty is not None: bill.invoice_line_ids.quantity = qty if price is not None: bill.invoice_line_ids.price_unit = price bill.action_post() return bill def _refund(self, inv, qty=None): ctx = {'active_ids': inv.ids, 'active_model': 'account.move'} credit_note_wizard = self.env['account.move.reversal'].with_context(ctx).create({ 'journal_id': inv.journal_id.id, }) rinv = self.env['account.move'].browse(credit_note_wizard.refund_moves()['res_id']) if qty is not None: rinv.invoice_line_ids.quantity = qty rinv.action_post() return rinv def _return(self, picking, qty=None): wizard_form = Form(self.env['stock.return.picking'].with_context(active_ids=picking.ids, active_id=picking.id, active_model='stock.picking')) wizard = wizard_form.save() qty = qty or picking.move_ids.quantity for line in wizard.product_return_moves: line.quantity = qty action = wizard.action_create_returns() return_picking = self.env["stock.picking"].browse(action["res_id"]) return_picking.move_ids.move_line_ids.quantity = qty return_picking.move_ids.picked = True return_picking.button_validate() return return_picking def test_change_currency_rate_average_1(self): """ Confirm a purchase order in another currency and create the associated receipt, change the currency rate, validate the receipt and then check that the value of the received goods is set according to the last currency rate. """ self.env['res.currency.rate'].search([]).unlink() usd_currency = self.env.ref('base.USD') self.env.company.currency_id = usd_currency.id eur_currency = self.env.ref('base.EUR') self.product1.product_tmpl_id.categ_id.property_cost_method = 'average' # default currency is USD, create a purchase order in EUR po1 = self.env['purchase.order'].create({ 'partner_id': self.partner_id.id, 'currency_id': eur_currency.id, 'order_line': [ (0, 0, { 'name': self.product1.name, 'product_id': self.product1.id, 'product_qty': 10.0, 'product_uom': self.product1.uom_po_id.id, 'price_unit': 100.0, 'date_planned': datetime.today().strftime(DEFAULT_SERVER_DATETIME_FORMAT), }), ], }) po1.button_confirm() picking1 = po1.picking_ids[0] move1 = picking1.move_ids[0] # convert the price unit in the company currency price_unit_usd = po1.currency_id._convert( po1.order_line.price_unit, po1.company_id.currency_id, self.env.company, fields.Date.today(), round=False) # the unit price of the move is the unit price of the purchase order line converted in # the company's currency self.assertAlmostEqual(move1.price_unit, price_unit_usd, places=2) # change the rate of the currency self.env['res.currency.rate'].create({ 'name': time.strftime('%Y-%m-%d'), 'rate': 2.0, 'currency_id': eur_currency.id, 'company_id': po1.company_id.id, }) eur_currency._compute_current_rate() price_unit_usd_new_rate = po1.currency_id._convert( po1.order_line.price_unit, po1.company_id.currency_id, self.env.company, fields.Date.today(), round=False) # the new price_unit is lower than th initial because of the rate's change self.assertLess(price_unit_usd_new_rate, price_unit_usd) # the unit price on the stock move is not directly updated self.assertAlmostEqual(move1.price_unit, price_unit_usd, places=2) # validate the receipt picking1.button_validate() # the unit price of the valuation layer used the latest value self.assertAlmostEqual(move1.stock_valuation_layer_ids.unit_cost, price_unit_usd_new_rate) self.assertAlmostEqual(self.product1.value_svl, price_unit_usd_new_rate * 10, delta=0.1) def test_fifo_anglosaxon_return(self): self.env.company.anglo_saxon_accounting = True self.product1.product_tmpl_id.categ_id.property_cost_method = 'fifo' self.product1.product_tmpl_id.categ_id.property_valuation = 'real_time' # Receive 10@10 ; create the vendor bill po1 = self.env['purchase.order'].create({ 'partner_id': self.partner_id.id, 'order_line': [ (0, 0, { 'name': self.product1.name, 'product_id': self.product1.id, 'product_qty': 10.0, 'product_uom': self.product1.uom_po_id.id, 'price_unit': 10.0, 'date_planned': datetime.today().strftime(DEFAULT_SERVER_DATETIME_FORMAT), }), ], }) po1.button_confirm() receipt_po1 = po1.picking_ids[0] receipt_po1.move_ids.quantity = 10 receipt_po1.move_ids.picked = True receipt_po1.button_validate() move_form = Form(self.env['account.move'].with_context(default_move_type='in_invoice')) move_form.invoice_date = move_form.date move_form.partner_id = self.partner_id move_form.purchase_vendor_bill_id = self.env['purchase.bill.union'].browse(-po1.id) invoice_po1 = move_form.save() invoice_po1.action_post() # Receive 10@20 ; create the vendor bill po2 = self.env['purchase.order'].create({ 'partner_id': self.partner_id.id, 'order_line': [ (0, 0, { 'name': self.product1.name, 'product_id': self.product1.id, 'product_qty': 10.0, 'product_uom': self.product1.uom_po_id.id, 'price_unit': 20.0, 'date_planned': datetime.today().strftime(DEFAULT_SERVER_DATETIME_FORMAT), }), ], }) po2.button_confirm() receipt_po2 = po2.picking_ids[0] receipt_po2.move_ids.quantity = 10 receipt_po2.move_ids.picked = True receipt_po2.button_validate() move_form = Form(self.env['account.move'].with_context(default_move_type='in_invoice')) move_form.invoice_date = move_form.date move_form.partner_id = self.partner_id move_form.purchase_vendor_bill_id = self.env['purchase.bill.union'].browse(-po2.id) invoice_po2 = move_form.save() invoice_po2.action_post() # valuation of product1 should be 300 self.assertEqual(self.product1.value_svl, 300) # return the second po stock_return_picking_form = Form(self.env['stock.return.picking'].with_context( active_ids=receipt_po2.ids, active_id=receipt_po2.ids[0], active_model='stock.picking')) stock_return_picking = stock_return_picking_form.save() stock_return_picking.product_return_moves.quantity = 10 stock_return_picking_action = stock_return_picking.action_create_returns() return_pick = self.env['stock.picking'].browse(stock_return_picking_action['res_id']) return_pick.move_ids[0].move_line_ids[0].quantity = 10 return_pick.move_ids[0].picked = True return_pick.button_validate() # valuation of product1 should be 200 as the first items will be sent out self.assertEqual(self.product1.value_svl, 200) # create a credit note for po2 move_form = Form(self.env['account.move'].with_context(default_move_type='in_refund')) move_form.invoice_date = move_form.date move_form.partner_id = self.partner_id # Not supposed to see/change the purchase order of a refund invoice by default # #