diff --git a/stock_valuation_fifo_lot/__init__.py b/stock_valuation_fifo_lot/__init__.py index 8ebc8a7c..0dcdc5e3 100644 --- a/stock_valuation_fifo_lot/__init__.py +++ b/stock_valuation_fifo_lot/__init__.py @@ -1,3 +1,4 @@ # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html) from . import models +from .hooks import post_init_hook diff --git a/stock_valuation_fifo_lot/__manifest__.py b/stock_valuation_fifo_lot/__manifest__.py index e149a62c..99c5d8b8 100644 --- a/stock_valuation_fifo_lot/__manifest__.py +++ b/stock_valuation_fifo_lot/__manifest__.py @@ -14,8 +14,10 @@ "data": [ "views/res_config_settings_views.xml", "views/stock_move_line_views.xml", + "views/stock_package_level_views.xml", "views/stock_valuation_layer_views.xml", ], "installable": True, + "post_init_hook": "post_init_hook", "maintainers": ["newtratip"], } diff --git a/stock_valuation_fifo_lot/hooks.py b/stock_valuation_fifo_lot/hooks.py new file mode 100644 index 00000000..2317c98f --- /dev/null +++ b/stock_valuation_fifo_lot/hooks.py @@ -0,0 +1,34 @@ +# Copyright 2024 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html) + +from odoo import SUPERUSER_ID, api +from odoo.tools import float_is_zero + + +def post_init_hook(cr, registry): + env = api.Environment(cr, SUPERUSER_ID, {}) + + svls = env["stock.valuation.layer"].search([("stock_move_id", "!=", False)]) + for svl in svls: + svl.lot_ids = svl.stock_move_id.lot_ids + if not svl.lot_ids: + continue + if svl.quantity <= 0: # Skip outgoing ones + continue + if svl.product_id.with_company(svl.company_id.id).cost_method != "fifo": + continue + svl_consumed_qty = svl_consumed_qty_bal = svl.quantity - svl.remaining_qty + if not svl_consumed_qty: + continue + svl_total_value = svl.value + sum(svl.stock_valuation_layer_ids.mapped("value")) + svl_consumed_value = svl_total_value - svl.remaining_value + product_uom = svl.product_id.uom_id + for ml in svl.stock_move_id.move_line_ids.sorted("id"): + ml_uom = ml.product_uom_id + ml_qty = ml_uom._compute_quantity(ml.qty_done, product_uom) + qty_to_allocate = min(svl_consumed_qty_bal, ml_qty) + ml.qty_consumed += product_uom._compute_quantity(qty_to_allocate, ml_uom) + svl_consumed_qty_bal -= qty_to_allocate + ml.value_consumed += svl_consumed_value * qty_to_allocate / svl_consumed_qty + if float_is_zero(svl_consumed_qty_bal, precision_rounding=ml_uom.rounding): + break diff --git a/stock_valuation_fifo_lot/models/__init__.py b/stock_valuation_fifo_lot/models/__init__.py index 6586f880..49f67d5e 100644 --- a/stock_valuation_fifo_lot/models/__init__.py +++ b/stock_valuation_fifo_lot/models/__init__.py @@ -3,6 +3,7 @@ from . import product from . import res_company from . import res_config_settings +from . import stock_lot from . import stock_move from . import stock_move_line from . import stock_valuation_layer diff --git a/stock_valuation_fifo_lot/models/product.py b/stock_valuation_fifo_lot/models/product.py index bb001f4c..abae8f30 100644 --- a/stock_valuation_fifo_lot/models/product.py +++ b/stock_valuation_fifo_lot/models/product.py @@ -4,7 +4,8 @@ from collections import defaultdict -from odoo import models +from odoo import _, models +from odoo.exceptions import UserError from odoo.osv import expression from odoo.tools import float_is_zero @@ -30,6 +31,15 @@ def _get_fifo_candidates(self, company): for svl in all_candidates: if not svl._get_unconsumed_in_move_line(fifo_lot): all_candidates -= svl + if not all_candidates: + raise UserError( + _( + "There is no remaining balance for FIFO valuation for the " + "lot/serial %s. Please select a Force FIFO Lot/Serial in the " + "detailed operation line." + ) + % fifo_lot.display_name + ) sort_by = self.env.context.get("sort_by") if sort_by == "lot_create_date": @@ -47,12 +57,19 @@ def sorting_key(candidate): def _get_qty_taken_on_candidate(self, qty_to_take_on_candidates, candidate): fifo_lot = self.env.context.get("fifo_lot") if fifo_lot: - candidate_move_line = candidate._get_unconsumed_in_move_line(fifo_lot) - qty_to_take_on_candidates = min( - qty_to_take_on_candidates, candidate_move_line.qty_remaining + candidate_ml = candidate._get_unconsumed_in_move_line(fifo_lot) + ml_uom = candidate_ml.product_uom_id + ml_qty_remaining = ml_uom._compute_quantity( + candidate_ml.qty_remaining, candidate_ml.product_id.uom_id ) - candidate_move_line.qty_consumed += qty_to_take_on_candidates - candidate_move_line.cost_consumed += qty_to_take_on_candidates * ( + qty_to_take_on_candidates = min(qty_to_take_on_candidates, ml_qty_remaining) + ml_qty_to_take_on_candidates = ( + candidate_ml.product_id.uom_id._compute_quantity( + qty_to_take_on_candidates, ml_uom + ) + ) + candidate_ml.qty_consumed += ml_qty_to_take_on_candidates + candidate_ml.value_consumed += ml_qty_to_take_on_candidates * ( candidate.remaining_value / candidate.remaining_qty ) return super()._get_qty_taken_on_candidate(qty_to_take_on_candidates, candidate) @@ -67,9 +84,10 @@ def _run_fifo(self, quantity, company): correction_move_line = self.env.context.get("correction_move_line") move_lines = correction_move_line or fifo_move._get_out_move_lines() for ml in move_lines: + fifo_lot = ml.force_fifo_lot_id or ml.lot_id ml_qty = fifo_move.product_uom._compute_quantity(ml.qty_done, self.uom_id) fifo_qty = min(remaining_qty, ml_qty) - self = self.with_context(fifo_lot=ml.lot_id, fifo_qty=fifo_qty) + self = self.with_context(fifo_lot=fifo_lot, fifo_qty=fifo_qty) ml_fifo_vals = super()._run_fifo(fifo_qty, company) for key, value in ml_fifo_vals.items(): if key in ("remaining_qty", "value"): diff --git a/stock_valuation_fifo_lot/models/res_company.py b/stock_valuation_fifo_lot/models/res_company.py index 6445a538..6152a3c0 100644 --- a/stock_valuation_fifo_lot/models/res_company.py +++ b/stock_valuation_fifo_lot/models/res_company.py @@ -7,6 +7,8 @@ class ResCompany(models.Model): _inherit = "res.company" - use_lot_get_price_unit_fifo = fields.Boolean( - default=True, help="Use the FIFO price unit by lot when there is no PO." + use_lot_cost_for_new_stock = fields.Boolean( + "Use Last Lot/Serial Cost for New Stock", + default=True, + help="Use the lot/serial cost for FIFO products for non-purchase receipts.", ) diff --git a/stock_valuation_fifo_lot/models/res_config_settings.py b/stock_valuation_fifo_lot/models/res_config_settings.py index c5115315..d6a1484e 100644 --- a/stock_valuation_fifo_lot/models/res_config_settings.py +++ b/stock_valuation_fifo_lot/models/res_config_settings.py @@ -7,8 +7,9 @@ class ResConfigSettings(models.TransientModel): _inherit = "res.config.settings" - use_lot_get_price_unit_fifo = fields.Boolean( - related="company_id.use_lot_get_price_unit_fifo", + use_lot_cost_for_new_stock = fields.Boolean( + "Use Last Lot/Serial Cost for New Stock", + related="company_id.use_lot_cost_for_new_stock", readonly=False, - help="Use the FIFO price unit by lot when there is no PO.", + help="Use the lot/serial cost for FIFO products for non-purchase receipts.", ) diff --git a/stock_valuation_fifo_lot/models/stock_lot.py b/stock_valuation_fifo_lot/models/stock_lot.py new file mode 100644 index 00000000..fd58f2ce --- /dev/null +++ b/stock_valuation_fifo_lot/models/stock_lot.py @@ -0,0 +1,32 @@ +# Copyright 2024 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html) + +from odoo import api, fields, models + + +class StockLot(models.Model): + _inherit = "stock.lot" + + is_force_fifo_candidate = fields.Boolean( + compute="_compute_is_force_fifo_candidate", + store=True, + help="Technical field to indicate that the lot has no on-hand quantity but has " + "a remaining value in FIFO valuation terms.", + ) + + @api.depends( + "quant_ids.quantity", "product_id.stock_move_ids.move_line_ids.qty_remaining" + ) + def _compute_is_force_fifo_candidate(self): + for lot in self: + if lot.product_id.cost_method != "fifo": + continue + if not self.env["stock.move.line"].search( + [("lot_id", "=", lot.id), ("qty_remaining", ">", 0)] + ): + continue + lot.is_force_fifo_candidate = not bool( + lot.quant_ids.filtered( + lambda x: x.location_id.usage == "internal" and x.quantity + ) + ) diff --git a/stock_valuation_fifo_lot/models/stock_move.py b/stock_valuation_fifo_lot/models/stock_move.py index 29fd2e51..7c631366 100644 --- a/stock_valuation_fifo_lot/models/stock_move.py +++ b/stock_valuation_fifo_lot/models/stock_move.py @@ -20,17 +20,16 @@ def _create_out_svl(self, forced_quantity=None): layers = self.env["stock.valuation.layer"] for move in self: move = move.with_context(fifo_move=move) - layer = super(StockMove, move)._create_out_svl( + layers |= super(StockMove, move)._create_out_svl( forced_quantity=forced_quantity ) - layers |= layer return layers def _create_in_svl(self, forced_quantity=None): """Change product standard price to the first available lot price.""" layers = self.env["stock.valuation.layer"] for move in self: - layer = super(StockMove, move)._create_in_svl( + layers |= super(StockMove, move)._create_in_svl( forced_quantity=forced_quantity ) product = move.product_id @@ -44,7 +43,6 @@ def _create_in_svl(self, forced_quantity=None): product = product.with_company(move.company_id.id) product = product.with_context(disable_auto_svl=True) product.sudo().standard_price = candidate.unit_cost - layers |= layer return layers def _get_price_unit(self): @@ -52,7 +50,7 @@ def _get_price_unit(self): incoming move line for the lot. """ self.ensure_one() - if not self.company_id.use_lot_get_price_unit_fifo: + if not self.company_id.use_lot_cost_for_new_stock: return super()._get_price_unit() if hasattr(self, "purchase_line_id") and self.purchase_line_id: return super()._get_price_unit() @@ -69,5 +67,5 @@ def _get_price_unit(self): limit=1, ) if move_line: - return move_line.cost_consumed / move_line.qty_consumed + return move_line.value_consumed / move_line.qty_consumed return super()._get_price_unit() diff --git a/stock_valuation_fifo_lot/models/stock_move_line.py b/stock_valuation_fifo_lot/models/stock_move_line.py index 0c571280..f7725ccb 100644 --- a/stock_valuation_fifo_lot/models/stock_move_line.py +++ b/stock_valuation_fifo_lot/models/stock_move_line.py @@ -12,26 +12,59 @@ class StockMoveLine(models.Model): "for FIFO products with a lot/serial.", ) qty_remaining = fields.Float( - compute="_compute_qty_remaining", + compute="_compute_remaining_value", store=True, help="Remaining quantity for valued incoming moves for FIFO products with a " "lot/serial.", ) company_currency_id = fields.Many2one(related="company_id.currency_id") - cost_consumed = fields.Monetary( + value_consumed = fields.Monetary( currency_field="company_currency_id", help="The value of the inventory that has been consumed for FIFO products with " "a lot/serial.", ) + value_remaining = fields.Monetary( + compute="_compute_remaining_value", + store=True, + currency_field="company_currency_id", + ) + force_fifo_lot_id = fields.Many2one( + "stock.lot", + "Force FIFO Lot/Serial", + help="Specify a lot/serial to be consumed (in FIFO costing terms) for the " + "outgoing move line, in case the selected lot has already gone out of stock " + "(in FIFO costing terms).", + ) - @api.depends("qty_done", "qty_consumed") - def _compute_qty_remaining(self): + @api.depends( + "qty_done", "qty_consumed", "move_id.stock_valuation_layer_ids.remaining_value" + ) + def _compute_remaining_value(self): for rec in self: - if rec.location_usage not in ( + if ( + rec.product_id.with_company(rec.company_id.id).cost_method != "fifo" + or not rec.lot_id + ): + continue + if rec.location_usage in ( "internal", "transit", - ) and rec.location_dest_usage in ("internal", "transit"): - rec.qty_remaining = rec.qty_done - rec.qty_consumed + ) or rec.location_dest_usage not in ("internal", "transit"): + continue + rec.qty_remaining = rec.qty_done - rec.qty_consumed + layers = rec.move_id.stock_valuation_layer_ids + remaining_qty = rec.product_uom_id._compute_quantity( + sum(layers.mapped("remaining_qty")), rec.product_id.uom_id + ) + if not remaining_qty: + rec.qty_remaining = 0 + rec.value_remaining = 0 + continue + rec.value_remaining = ( + sum(layers.mapped("remaining_value")) + * rec.qty_remaining + / remaining_qty + ) def _create_correction_svl(self, move, diff): # Pass the move line as a context value in case qty_done is overridden in a done diff --git a/stock_valuation_fifo_lot/models/stock_valuation_layer.py b/stock_valuation_fifo_lot/models/stock_valuation_layer.py index c805bfdb..2d1d53f7 100644 --- a/stock_valuation_fifo_lot/models/stock_valuation_layer.py +++ b/stock_valuation_fifo_lot/models/stock_valuation_layer.py @@ -9,7 +9,7 @@ class StockValuationLayer(models.Model): lot_ids = fields.Many2many( comodel_name="stock.lot", - string="Lots/Serial Numbers", + string="Lots/Serials", ) def _get_unconsumed_in_move_line(self, lot): diff --git a/stock_valuation_fifo_lot/readme/CONFIGURE.rst b/stock_valuation_fifo_lot/readme/CONFIGURE.rst index 61b74d16..6d102b3c 100644 --- a/stock_valuation_fifo_lot/readme/CONFIGURE.rst +++ b/stock_valuation_fifo_lot/readme/CONFIGURE.rst @@ -1,2 +1,5 @@ -If necessary, update the 'Use FIFO cost by lot' setting under Inventory > Configuration > Settings to use the lot cost instead of the standard _get_price() behavior when there is no relation to a purchase order in the stock move. -(enabled by default). +Disable the 'Use Last Lot/Serial Cost for New Stock' setting under *Inventory > +Configuration > Settings*, which is enabled by default, to use the standard +`_get_price()` behavior instead of the lot cost, for receipts of specific lots/serials +with no link to a purchase order (i.e. customer returns and positive inventory +adjustments). diff --git a/stock_valuation_fifo_lot/readme/CONTRIBUTORS.rst b/stock_valuation_fifo_lot/readme/CONTRIBUTORS.rst index e2e916aa..c57efe9a 100644 --- a/stock_valuation_fifo_lot/readme/CONTRIBUTORS.rst +++ b/stock_valuation_fifo_lot/readme/CONTRIBUTORS.rst @@ -7,4 +7,4 @@ * `Quartile `__: * Aung Ko Ko Lin - \ No newline at end of file + * Yoshi Tashiro diff --git a/stock_valuation_fifo_lot/readme/DESCRIPTION.rst b/stock_valuation_fifo_lot/readme/DESCRIPTION.rst index e1787438..0a692220 100644 --- a/stock_valuation_fifo_lot/readme/DESCRIPTION.rst +++ b/stock_valuation_fifo_lot/readme/DESCRIPTION.rst @@ -1 +1,50 @@ -This module is used to calculate FIFO cost by lot. +This module changes the scope of FIFO cost calculation to specific lots/serials (as +opposed to products), effectively achieving Specific Identification costing method. + +Example: Lot-Level Costing +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +- Purchase: + + - Lot A: 100 units at $10 each. + - Lot B: 100 units at $12 each. + +- Sale: + + - 50 units from Lot B. + +- COGS Calculation: + + - 50 units * $12 = $600 assigned to COGS. + +- Ending Inventory: + + - Lot A: 100 units at $10 each. + - Lot B: 50 units at $12 each. + +Main UI Changes +~~~~~~~~~~~~~~~ + +- Stock Valuation Layer + + - Adds the following field: + + - 'Lots/Serials': Taken from related stock moves. + +- Stock Move Line + + - Adds the following fields: + + - 'Qty Consumed' [*]_: Consumed quantity by outgoing moves. + - 'Value Consumed' [*]_: Consumed value by outgoing moves. + - 'Qty Remaining' [*]_: Remaining quantity (the total by product should match that + of the inventory valuation). + - 'Value Remaining' [*]_: Remaining value (the total by product should match that + of the inventory valuation). + - 'Force FIFO Lot/Serial': Used when you are stuck by not being able to find a FIFO + balance for the lot in an outgoing move line. + + .. [*] Updated only for valued incoming moves of the products with FIFO costing method. + The values here represent the theoretical figures in terms of FIFO costing, + meaning that they may differ from the actual stock situation especially for + those updated at the installation of this module. diff --git a/stock_valuation_fifo_lot/readme/USAGE.rst b/stock_valuation_fifo_lot/readme/USAGE.rst new file mode 100644 index 00000000..1cccb72f --- /dev/null +++ b/stock_valuation_fifo_lot/readme/USAGE.rst @@ -0,0 +1,11 @@ +Process an outgoing move with a lot/serial for a product of FIFO costing method, and the +costs are calculated based on the lot/serial. + +You will get a user error in case the lot/serial of your choice (in an outgoing move) +does not have a FIFO balance (i.e. there is no remaining quantity for the incoming move +lines linked to the candidate SVL; this is expected to happen for lots/serials created +before the installation of this module, unless your actual inventory operations have +been strictly FIFO). In such situations, you should select a "rogue" lot/serial (one +that still exists in terms of FIFO costing, but not in reality, due to the inconsistency +carried over from the past) in the 'Force FIFO Lot/Serial' field so that this lot/serial +is used for FIFO costing instead. diff --git a/stock_valuation_fifo_lot/views/res_config_settings_views.xml b/stock_valuation_fifo_lot/views/res_config_settings_views.xml index 975f288e..8d0ab84b 100644 --- a/stock_valuation_fifo_lot/views/res_config_settings_views.xml +++ b/stock_valuation_fifo_lot/views/res_config_settings_views.xml @@ -11,18 +11,18 @@ >
- +
diff --git a/stock_valuation_fifo_lot/views/stock_move_line_views.xml b/stock_valuation_fifo_lot/views/stock_move_line_views.xml index f97a94b3..f59507bb 100644 --- a/stock_valuation_fifo_lot/views/stock_move_line_views.xml +++ b/stock_valuation_fifo_lot/views/stock_move_line_views.xml @@ -4,12 +4,40 @@ stock.move.line - - + + + + - - - + + + + + @@ -27,4 +55,25 @@ + + stock.move.line.operations.tree + stock.move.line + + + + + + + diff --git a/stock_valuation_fifo_lot/views/stock_package_level_views.xml b/stock_valuation_fifo_lot/views/stock_package_level_views.xml new file mode 100644 index 00000000..3324adb1 --- /dev/null +++ b/stock_valuation_fifo_lot/views/stock_package_level_views.xml @@ -0,0 +1,17 @@ + + + Package Level + stock.package_level + + + + + + + +