Skip to content

Commit

Permalink
[IMP] stock_valuation_fifo_lot
Browse files Browse the repository at this point in the history
- Avoid completely overriding _run_fifo and use the standard behavior.
- Update the product's standard price to the first available lot price.
- Improve _get_price_unit to retrieve the value from the most recent incoming move line for the lot for the No PO (e.g. customer returns) stock moves.
- Add quantity and value-related fields in stock_move_line to cover all cases including multiple lots exist in an SVL record, and it is unclear which lot's remaining quantity is being delivered(Eg. Receive serials 001, 002 and 003 for an incoming move. Deliver 002, return 002 and deliver 002 again.)
- Add force_fifo_lot_id in stock_move_line to handle cases where a delivery needs to be made for an existing lot created before the installation of this module (e.g., lots 001 and 002 were received, lot 002 was delivered before the module's installation, and lot 001 is delivered after the module is installed).
- Add a post_init_hook to update the lot_ids in SVL for existing records and to update the field values in existing stock_move_line records.
- Remove stock_no_negative from dependency and check negative inventory is created when SVL is created for delivery.
  • Loading branch information
AungKoKoLin1997 committed Sep 25, 2024
1 parent a7a187c commit bdc4591
Show file tree
Hide file tree
Showing 21 changed files with 1,096 additions and 257 deletions.
82 changes: 78 additions & 4 deletions stock_valuation_fifo_lot/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,62 @@ Stock Valuation Fifo Lot

|badge1| |badge2| |badge3| |badge4| |badge5|

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 Base' [*]: Base quantity for FIFO allocation; represents the total quantity
of the moves with incoming valuation for the move line. In product UoM.
- 'Qty Consumed' [*]: Consumed quantity by outgoing valuation. In product UoM.
- 'Value Consumed' [*]: Consumed value by outgoing valuation.
- 'Qty Remaining' [*]: Remaining quantity (the total by product should match that
of the inventory valuation). In product UoM.
- '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 products with FIFO costing method only, for valued incoming
moves, and outgoing moves where the qty_done has been reduced in the completed
state.
For these outgoing moves, the system generates positive stock valuation layers
with a remaining balance, which need to be reflected in the related move line.
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.
.. IMPORTANT::
This is an alpha version, the data model and design can change at any time without warning.
Expand All @@ -43,8 +98,26 @@ This module is used to calculate FIFO cost by lot.
Configuration
=============

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).

Usage
=====

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.

Bug Tracker
===========
Expand All @@ -63,6 +136,7 @@ Authors
~~~~~~~

* Ecosoft
* Quartile

Contributors
~~~~~~~~~~~~
Expand All @@ -76,7 +150,7 @@ Contributors
* `Quartile <https://www.quartile.co>`__:

* Aung Ko Ko Lin

* Yoshi Tashiro

Maintainers
~~~~~~~~~~~
Expand Down
1 change: 1 addition & 0 deletions stock_valuation_fifo_lot/__init__.py
Original file line number Diff line number Diff line change
@@ -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
8 changes: 6 additions & 2 deletions stock_valuation_fifo_lot/__manifest__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# Copyright 2023 Ecosoft Co., Ltd (https://ecosoft.co.th)
# Copyright 2024 Quartile (https://www.quartile.co)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html)

{
Expand All @@ -7,13 +8,16 @@
"category": "Warehouse Management",
"development_status": "Alpha",
"license": "AGPL-3",
"author": "Ecosoft, Odoo Community Association (OCA)",
"author": "Ecosoft, Quartile, Odoo Community Association (OCA)",
"website": "https://github.com/OCA/stock-logistics-workflow",
"depends": ["stock_account", "stock_no_negative"],
"depends": ["stock_account"],
"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"],
}
42 changes: 42 additions & 0 deletions stock_valuation_fifo_lot/hooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# 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, {})

moves = env["stock.move"].search([("stock_valuation_layer_ids", "!=", False)])
for move in moves:
if (
move.product_id.with_company(move.company_id).cost_method != "fifo"
or not move.lot_ids
):
continue
svls = move.stock_valuation_layer_ids
svls.lot_ids = move.lot_ids
if move._is_out():
remaining_qty = sum(svls.mapped("remaining_qty"))
if remaining_qty:
# The case where outgoing done qty is reduced
# Let the first move line take such adjustments.
move.move_line_ids[0].qty_base = remaining_qty
continue
consumed_qty = consumed_qty_bal = sum(svls.mapped("quantity")) - sum(
svls.mapped("remaining_qty")
)
total_value = sum(svls.mapped("value")) + sum(
svls.stock_valuation_layer_ids.mapped("value")
)
consumed_value = total_value - sum(svls.mapped("remaining_value"))
product_uom = move.product_id.uom_id
for ml in move.move_line_ids.sorted("id"):
ml.qty_base = ml.product_uom_id._compute_quantity(ml.qty_done, product_uom)
if float_is_zero(consumed_qty_bal, precision_rounding=product_uom.rounding):
continue
qty_to_allocate = min(consumed_qty_bal, ml.qty_base)
ml.qty_consumed += qty_to_allocate
consumed_qty_bal -= qty_to_allocate
ml.value_consumed += consumed_value * qty_to_allocate / consumed_qty
2 changes: 2 additions & 0 deletions stock_valuation_fifo_lot/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +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
146 changes: 67 additions & 79 deletions stock_valuation_fifo_lot/models/product.py
Original file line number Diff line number Diff line change
@@ -1,111 +1,99 @@
# Copyright 2023 Ecosoft Co., Ltd (https://ecosoft.co.th)
# Copyright 2024 Quartile (https://www.quartile.co)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html)

from odoo import models
from collections import defaultdict

from odoo import _, models
from odoo.exceptions import UserError
from odoo.osv import expression
from odoo.tools import float_is_zero


class ProductProduct(models.Model):
_inherit = "product.product"

def _get_fifo_candidates_domain(self, company):
res = super()._get_fifo_candidates_domain(company)
fifo_lot = self.env.context.get("fifo_lot")
if not fifo_lot:
return res
return expression.AND([res, [("lot_ids", "in", fifo_lot.ids)]])

def _sort_by_all_candidates(self, all_candidates, sort_by):
"""Hook function for other sort by"""
return all_candidates

def _get_fifo_candidates(self, company):
all_candidates = super()._get_fifo_candidates(company)
fifo_lot = self.env.context.get("fifo_lot")
if fifo_lot:
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":

def sorting_key(candidate):
if candidate.lot_ids:
return min(candidate.lot_ids.mapped("create_date"))
else:
return candidate.create_date
return candidate.create_date

all_candidates = all_candidates.sorted(key=sorting_key)
elif sort_by is not None:
all_candidates = self._sort_by_all_candidates(all_candidates, sort_by)
return all_candidates

def _run_fifo(self, quantity, company):
self.ensure_one()
move_id = self._context.get("used_in_move_id")
if self.tracking == "none" or not move_id:
return super()._run_fifo(quantity, company)

move = self.env["stock.move"].browse(move_id)
move_lines = move._get_out_move_lines()
tmp_value = 0
tmp_remaining_qty = 0
for move_line in move_lines:
# Find back incoming stock valuation layers
# (called candidates here) to value `quantity`.
qty_to_take_on_candidates = move_line.product_uom_id._compute_quantity(
move_line.qty_done, move.product_id.uom_id
# Depends on https://github.com/odoo/odoo/pull/180245
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_ml = candidate._get_unconsumed_in_move_line(fifo_lot)
qty_to_take_on_candidates = min(
qty_to_take_on_candidates, candidate_ml.qty_remaining
)
# Find incoming stock valuation layers that have lot_ids on their moves
# Check with stock_move_id.lot_ids to cover the situation where the stock
# was received either before or after the installation of this module
candidates = self._get_fifo_candidates(company).filtered(
lambda l: move_line.lot_id in l.stock_move_id.lot_ids
candidate_ml.qty_consumed += qty_to_take_on_candidates
candidate_ml.value_consumed += qty_to_take_on_candidates * (
candidate.remaining_value / candidate.remaining_qty
)
for candidate in candidates:
qty_taken_on_candidate = min(
qty_to_take_on_candidates, candidate.remaining_qty
)
return super()._get_qty_taken_on_candidate(qty_to_take_on_candidates, candidate)

candidate_unit_cost = (
candidate.remaining_value / candidate.remaining_qty
)
value_taken_on_candidate = qty_taken_on_candidate * candidate_unit_cost
value_taken_on_candidate = candidate.currency_id.round(
value_taken_on_candidate
)
new_remaining_value = (
candidate.remaining_value - value_taken_on_candidate
def _run_fifo(self, quantity, company):
self.ensure_one()
fifo_move = self._context.get("fifo_move")
if self.tracking == "none" or not fifo_move:
return super()._run_fifo(quantity, company)
remaining_qty = quantity
vals = defaultdict(float)
correction_ml = self.env.context.get("correction_move_line")
move_lines = correction_ml or fifo_move._get_out_move_lines()
moved_qty = 0
for ml in move_lines:
fifo_lot = ml.force_fifo_lot_id or ml.lot_id
if correction_ml:
moved_qty = quantity
else:
moved_qty = ml.product_uom_id._compute_quantity(
ml.qty_done, self.uom_id
)

candidate_vals = {
"remaining_qty": candidate.remaining_qty - qty_taken_on_candidate,
"remaining_value": new_remaining_value,
}

candidate.write(candidate_vals)

qty_to_take_on_candidates -= qty_taken_on_candidate
tmp_value += value_taken_on_candidate

if float_is_zero(
qty_to_take_on_candidates,
precision_rounding=self.uom_id.rounding,
):
break

if candidates and qty_to_take_on_candidates > 0:
tmp_value += abs(candidate.unit_cost * -qty_to_take_on_candidates)
tmp_remaining_qty += qty_to_take_on_candidates

# Calculate standard price (Sorted by lot created date)
all_candidates = self.with_context(
sort_by="lot_create_date"
)._get_fifo_candidates(company)
new_standard_price = 0.0
if all_candidates:
new_standard_price = all_candidates[0].unit_cost
elif candidates:
new_standard_price = candidate.unit_cost

# Update standard price
if new_standard_price and self.cost_method == "fifo":
self.sudo().with_company(company.id).with_context(
disable_auto_svl=True
).standard_price = new_standard_price

# Value
vals = {
"remaining_qty": -tmp_remaining_qty,
"value": -tmp_value,
"unit_cost": tmp_value / (quantity + tmp_remaining_qty),
}
fifo_qty = min(remaining_qty, moved_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"):
vals[key] += value
continue
vals[key] = value # unit_cost
remaining_qty -= fifo_qty
if float_is_zero(remaining_qty, precision_rounding=self.uom_id.rounding):
break
return vals
6 changes: 4 additions & 2 deletions stock_valuation_fifo_lot/models/res_company.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
)
7 changes: 4 additions & 3 deletions stock_valuation_fifo_lot/models/res_config_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
)
Loading

0 comments on commit bdc4591

Please sign in to comment.