diff --git a/setup/stock_account_avco_return_origin/odoo/addons/stock_account_avco_return_origin b/setup/stock_account_avco_return_origin/odoo/addons/stock_account_avco_return_origin new file mode 120000 index 00000000..9584c844 --- /dev/null +++ b/setup/stock_account_avco_return_origin/odoo/addons/stock_account_avco_return_origin @@ -0,0 +1 @@ +../../../../stock_account_avco_return_origin \ No newline at end of file diff --git a/setup/stock_account_avco_return_origin/setup.py b/setup/stock_account_avco_return_origin/setup.py new file mode 100644 index 00000000..28c57bb6 --- /dev/null +++ b/setup/stock_account_avco_return_origin/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/stock_account_avco_return_origin/README.rst b/stock_account_avco_return_origin/README.rst new file mode 100644 index 00000000..95406beb --- /dev/null +++ b/stock_account_avco_return_origin/README.rst @@ -0,0 +1,134 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +================================ +Stock Account AVCO Return Origin +================================ + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:f17913e624a9cac06a46f3e17cbe552389316d9a5f6dec7b4534bb7c37c54a5f + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fstock--logistics--workflow-lightgray.png?logo=github + :target: https://github.com/OCA/stock-logistics-workflow/tree/16.0/stock_account_avco_return_origin + :alt: OCA/stock-logistics-workflow +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/stock-logistics-workflow-16-0/stock-logistics-workflow-16-0-stock_account_avco_return_origin + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/stock-logistics-workflow&target_branch=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module values supplier returns of average-cost (AVCO) products at +the **original receipt price** instead of the current average cost. The +outgoing valuation layer is corrected to the price of the move being +returned, and the product's average cost is realigned to the resulting +valuation, so subsequent issues use the correct cost. + +The behavior is controlled by a company-level setting and applies to all +supplier returns of AVCO products once enabled. Customer returns and +non-AVCO products are unaffected. + +**Table of contents** + +.. contents:: + :local: + +Use Cases / Context +=================== + +When an average-cost (AVCO) product is returned to a supplier, standard +Odoo values the outgoing return move at the *current* average cost. If +the average cost has drifted since the original receipt (for example +because of a later receipt at a different price), the return removes the +wrong amount of value from inventory and the remaining average cost +becomes distorted. + +Configuration +============= + +To enable the feature: + +1. Go to *Inventory > Configuration > Settings*. +2. Under *Valuation*, enable *Avco Return Origin Cost*. + +Usage +===== + +Once the setting is enabled, validating a supplier return of an AVCO +product values the return at the original receipt price and realigns the +product's average cost accordingly. The valuation layer's description +notes that the original receipt price was used. + +If a return would empty the product's stock on hand (so that pricing it +at the original receipt price would leave residual value with no stock +to absorb it), that return is valued at the standard average cost +instead. This keeps the valuation consistent without blocking the +operation or requiring a manual revaluation. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Quartile + +Contributors +------------ + +- `Quartile `__: + + - Aung Ko Ko Lin + - Toshikimi Shigenobu + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-nobuQuartile| image:: https://github.com/nobuQuartile.png?size=40px + :target: https://github.com/nobuQuartile + :alt: nobuQuartile +.. |maintainer-aungkokolin1997| image:: https://github.com/aungkokolin1997.png?size=40px + :target: https://github.com/aungkokolin1997 + :alt: aungkokolin1997 + +Current `maintainers `__: + +|maintainer-nobuQuartile| |maintainer-aungkokolin1997| + +This module is part of the `OCA/stock-logistics-workflow `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/stock_account_avco_return_origin/__init__.py b/stock_account_avco_return_origin/__init__.py new file mode 100644 index 00000000..0650744f --- /dev/null +++ b/stock_account_avco_return_origin/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/stock_account_avco_return_origin/__manifest__.py b/stock_account_avco_return_origin/__manifest__.py new file mode 100644 index 00000000..d1132570 --- /dev/null +++ b/stock_account_avco_return_origin/__manifest__.py @@ -0,0 +1,16 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Stock Account AVCO Return Origin", + "summary": "Value AVCO supplier returns at the original receipt price.", + "version": "16.0.1.0.0", + "category": "Warehouse Management", + "author": "Quartile, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/stock-logistics-workflow", + "license": "AGPL-3", + "depends": ["stock_account"], + "data": ["views/res_config_settings_views.xml"], + "installable": True, + "maintainers": ["nobuQuartile", "aungkokolin1997"], +} diff --git a/stock_account_avco_return_origin/i18n/ja.po b/stock_account_avco_return_origin/i18n/ja.po new file mode 100644 index 00000000..22a5f95b --- /dev/null +++ b/stock_account_avco_return_origin/i18n/ja.po @@ -0,0 +1,71 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * stock_account_avco_return_origin +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-07-23 01:40+0000\n" +"PO-Revision-Date: 2026-07-23 01:40+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: stock_account_avco_return_origin +#: model:ir.model,name:stock_account_avco_return_origin.model_res_company +msgid "Companies" +msgstr "会社" + +#. module: stock_account_avco_return_origin +#: model:ir.model,name:stock_account_avco_return_origin.model_res_config_settings +msgid "Config Settings" +msgstr "コンフィグ設定" + +#. module: stock_account_avco_return_origin +#. odoo-python +#: code:addons/stock_account_avco_return_origin/models/product.py:0 +#, python-format +msgid "" +"Original receipt price would result in negative inventory valuation; unit " +"cost capped at the remaining inventory value to keep the valuation non-" +"negative." +msgstr "" +"元の入荷価格では在庫評価額がマイナスになるため、単位原価を残存在庫評価額に制限" +"し、評価額がマイナスにならないようにしています。" + +#. module: stock_account_avco_return_origin +#: model:ir.model,name:stock_account_avco_return_origin.model_product_product +msgid "Product Variant" +msgstr "プロダクトバリアント" + +#. module: stock_account_avco_return_origin +#: model:ir.model,name:stock_account_avco_return_origin.model_stock_move +msgid "Stock Move" +msgstr "在庫移動" + +#. module: stock_account_avco_return_origin +#: model:ir.model.fields,field_description:stock_account_avco_return_origin.field_res_company__avco_return_origin_cost +#: model:ir.model.fields,field_description:stock_account_avco_return_origin.field_res_config_settings__avco_return_origin_cost +msgid "Value AVCO Returns at Origin Cost" +msgstr "AVCO返品を元の原価で評価" + +#. module: stock_account_avco_return_origin +#: model_terms:ir.ui.view,arch_db:stock_account_avco_return_origin.res_config_settings_view_form +msgid "" +"Value supplier returns of AVCO products at the\n" +" original receipt price instead of the current\n" +" average cost." +msgstr "" +"AVCO製品の仕入先返品を現在の平均原価ではなく\n" +" 元の入荷価格で評価します。" + +#. module: stock_account_avco_return_origin +#. odoo-python +#: code:addons/stock_account_avco_return_origin/models/product.py:0 +#, python-format +msgid "Valued at the original receipt price." +msgstr "元の入荷価格で評価されています。" diff --git a/stock_account_avco_return_origin/models/__init__.py b/stock_account_avco_return_origin/models/__init__.py new file mode 100644 index 00000000..b2fdcc04 --- /dev/null +++ b/stock_account_avco_return_origin/models/__init__.py @@ -0,0 +1,4 @@ +from . import res_company +from . import res_config_settings +from . import product +from . import stock_move diff --git a/stock_account_avco_return_origin/models/product.py b/stock_account_avco_return_origin/models/product.py new file mode 100644 index 00000000..f1cb5bb4 --- /dev/null +++ b/stock_account_avco_return_origin/models/product.py @@ -0,0 +1,49 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import _, models +from odoo.tools import float_compare + + +class ProductProduct(models.Model): + _inherit = "product.product" + + def _prepare_out_svl_vals(self, quantity, company): + vals = super()._prepare_out_svl_vals(quantity, company) + move_id = self.env.context.get("avco_origin_return_move_id") + if not move_id: + return vals + remaining_qty = self.quantity_svl + vals["quantity"] + if ( + float_compare(remaining_qty, 0, precision_rounding=self.uom_id.rounding) + <= 0 + ): + return vals + move = self.env["stock.move"].browse(move_id) + unit_cost = move._get_price_unit() + value = company.currency_id.round(vals["quantity"] * unit_cost) + rounding_msg = _("Valued at the original receipt price.") + # A large price gap between receipts can make the origin-price return + # remove more value than remains on hand, pushing the valuation negative + # even though stock is still on hand. Cap the removal at the current + # inventory value so the valuation floors at zero instead of going + # negative, and realign unit_cost to the capped value. + if ( + float_compare( + self.value_svl + value, + 0, + precision_rounding=company.currency_id.rounding, + ) + < 0 + ): + value = -self.value_svl + unit_cost = company.currency_id.round(value / vals["quantity"]) + rounding_msg = _( + "Original receipt price would result in negative inventory" + " valuation; unit cost capped at the remaining inventory" + " value to keep the valuation non-negative." + ) + vals["unit_cost"] = unit_cost + vals["value"] = value + vals["rounding_adjustment"] = "\n" + rounding_msg + return vals diff --git a/stock_account_avco_return_origin/models/res_company.py b/stock_account_avco_return_origin/models/res_company.py new file mode 100644 index 00000000..54abe146 --- /dev/null +++ b/stock_account_avco_return_origin/models/res_company.py @@ -0,0 +1,10 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = "res.company" + + avco_return_origin_cost = fields.Boolean("Value AVCO Returns at Origin Cost") diff --git a/stock_account_avco_return_origin/models/res_config_settings.py b/stock_account_avco_return_origin/models/res_config_settings.py new file mode 100644 index 00000000..a377febc --- /dev/null +++ b/stock_account_avco_return_origin/models/res_config_settings.py @@ -0,0 +1,13 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + avco_return_origin_cost = fields.Boolean( + related="company_id.avco_return_origin_cost", + readonly=False, + ) diff --git a/stock_account_avco_return_origin/models/stock_move.py b/stock_account_avco_return_origin/models/stock_move.py new file mode 100644 index 00000000..a65af21c --- /dev/null +++ b/stock_account_avco_return_origin/models/stock_move.py @@ -0,0 +1,62 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import models +from odoo.tools import float_compare + + +class StockMove(models.Model): + _inherit = "stock.move" + + def _is_avco_origin_return(self): + self.ensure_one() + origin_move = self.origin_returned_move_id + return bool( + self.company_id.avco_return_origin_cost + and origin_move + and origin_move._is_in() + and origin_move.location_id.usage == "supplier" + and self.with_company(self.company_id).product_id.cost_method == "average" + ) + + def _sync_avco_origin_standard_price(self, product, company): + product = product.with_company(company) + product.invalidate_recordset(["value_svl", "quantity_svl"]) + if ( + float_compare( + product.quantity_svl, 0, precision_rounding=product.uom_id.rounding + ) + <= 0 + ): + # Stock depleted (or negative): the emptying/over return was valued at + # standard AVCO (see product._prepare_out_svl_vals), so there is no + # residual to realign. + return + product.sudo().with_context(disable_auto_svl=True).standard_price = ( + product.value_svl / product.quantity_svl + ) + + def _create_out_svl(self, forced_quantity=None): + origin_returns = self.filtered(lambda m: m._is_avco_origin_return()) + normal = self - origin_returns + # When a single _action_done() batch mixes normal out moves with origin + # returns of the same product, the normal moves are intentionally valued + # first, at the average in effect before any return realignment, and the + # standard_price is realigned only once at the end (below). A batch has no + # defined move order, and core AVCO already values all batched out moves + # at the same pre-batch average; the return correction applies going + # forward, not retroactively to same-batch deliveries. Do not reorder. + layers = super(StockMove, normal)._create_out_svl( + forced_quantity=forced_quantity + ) + for move in origin_returns: + layers |= super( + StockMove, move.with_context(avco_origin_return_move_id=move.id) + )._create_out_svl(forced_quantity=forced_quantity) + # Realign after each move so that a later return in the same batch + # sees an average reflecting the value already removed by earlier + # origin returns. Deferring this would let a return whose quantity + # empties stock fall back to a stale average and strand valuation + # (qty 0 with value != 0). + move._sync_avco_origin_standard_price(move.product_id, move.company_id) + return layers diff --git a/stock_account_avco_return_origin/readme/CONFIGURE.md b/stock_account_avco_return_origin/readme/CONFIGURE.md new file mode 100644 index 00000000..15516a8f --- /dev/null +++ b/stock_account_avco_return_origin/readme/CONFIGURE.md @@ -0,0 +1,4 @@ +To enable the feature: + +1. Go to *Inventory > Configuration > Settings*. +2. Under *Valuation*, enable *Avco Return Origin Cost*. diff --git a/stock_account_avco_return_origin/readme/CONTEXT.md b/stock_account_avco_return_origin/readme/CONTEXT.md new file mode 100644 index 00000000..422e230a --- /dev/null +++ b/stock_account_avco_return_origin/readme/CONTEXT.md @@ -0,0 +1,5 @@ +When an average-cost (AVCO) product is returned to a supplier, standard Odoo +values the outgoing return move at the *current* average cost. If the average +cost has drifted since the original receipt (for example because of a later +receipt at a different price), the return removes the wrong amount of value +from inventory and the remaining average cost becomes distorted. diff --git a/stock_account_avco_return_origin/readme/CONTRIBUTORS.md b/stock_account_avco_return_origin/readme/CONTRIBUTORS.md new file mode 100644 index 00000000..d330dda3 --- /dev/null +++ b/stock_account_avco_return_origin/readme/CONTRIBUTORS.md @@ -0,0 +1,3 @@ +- [Quartile](https://www.quartile.co): + - Aung Ko Ko Lin + - Toshikimi Shigenobu diff --git a/stock_account_avco_return_origin/readme/DESCRIPTION.md b/stock_account_avco_return_origin/readme/DESCRIPTION.md new file mode 100644 index 00000000..5c4f40a3 --- /dev/null +++ b/stock_account_avco_return_origin/readme/DESCRIPTION.md @@ -0,0 +1,9 @@ +This module values supplier returns of average-cost (AVCO) products at the +**original receipt price** instead of the current average cost. The outgoing +valuation layer is corrected to the price of the move being returned, and the +product's average cost is realigned to the resulting valuation, so subsequent +issues use the correct cost. + +The behavior is controlled by a company-level setting and applies to all +supplier returns of AVCO products once enabled. Customer returns and non-AVCO +products are unaffected. diff --git a/stock_account_avco_return_origin/readme/USAGE.md b/stock_account_avco_return_origin/readme/USAGE.md new file mode 100644 index 00000000..d469dbf3 --- /dev/null +++ b/stock_account_avco_return_origin/readme/USAGE.md @@ -0,0 +1,10 @@ +Once the setting is enabled, validating a supplier return of an AVCO product +values the return at the original receipt price and realigns the product's +average cost accordingly. The valuation layer's description notes that the +original receipt price was used. + +If a return would empty the product's stock on hand (so that pricing it at the +original receipt price would leave residual value with no stock to absorb it), +that return is valued at the standard average cost instead. This keeps the +valuation consistent without blocking the operation or requiring a manual +revaluation. diff --git a/stock_account_avco_return_origin/static/description/index.html b/stock_account_avco_return_origin/static/description/index.html new file mode 100644 index 00000000..abe36a5f --- /dev/null +++ b/stock_account_avco_return_origin/static/description/index.html @@ -0,0 +1,474 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Stock Account AVCO Return Origin

+ +

Beta License: AGPL-3 OCA/stock-logistics-workflow Translate me on Weblate Try me on Runboat

+

This module values supplier returns of average-cost (AVCO) products at +the original receipt price instead of the current average cost. The +outgoing valuation layer is corrected to the price of the move being +returned, and the product’s average cost is realigned to the resulting +valuation, so subsequent issues use the correct cost.

+

The behavior is controlled by a company-level setting and applies to all +supplier returns of AVCO products once enabled. Customer returns and +non-AVCO products are unaffected.

+

Table of contents

+ +
+

Use Cases / Context

+

When an average-cost (AVCO) product is returned to a supplier, standard +Odoo values the outgoing return move at the current average cost. If +the average cost has drifted since the original receipt (for example +because of a later receipt at a different price), the return removes the +wrong amount of value from inventory and the remaining average cost +becomes distorted.

+
+
+

Configuration

+

To enable the feature:

+
    +
  1. Go to Inventory > Configuration > Settings.
  2. +
  3. Under Valuation, enable Avco Return Origin Cost.
  4. +
+
+
+

Usage

+

Once the setting is enabled, validating a supplier return of an AVCO +product values the return at the original receipt price and realigns the +product’s average cost accordingly. The valuation layer’s description +notes that the original receipt price was used.

+

If a return would empty the product’s stock on hand (so that pricing it +at the original receipt price would leave residual value with no stock +to absorb it), that return is valued at the standard average cost +instead. This keeps the valuation consistent without blocking the +operation or requiring a manual revaluation.

+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Quartile
  • +
+
+
+

Contributors

+
    +
  • Quartile:
      +
    • Aung Ko Ko Lin
    • +
    • Toshikimi Shigenobu
    • +
    +
  • +
+
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

Current maintainers:

+

nobuQuartile aungkokolin1997

+

This module is part of the OCA/stock-logistics-workflow project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+
+ + diff --git a/stock_account_avco_return_origin/tests/__init__.py b/stock_account_avco_return_origin/tests/__init__.py new file mode 100644 index 00000000..1ca9f47d --- /dev/null +++ b/stock_account_avco_return_origin/tests/__init__.py @@ -0,0 +1 @@ +from . import test_avco_return_origin diff --git a/stock_account_avco_return_origin/tests/test_avco_return_origin.py b/stock_account_avco_return_origin/tests/test_avco_return_origin.py new file mode 100644 index 00000000..decabc73 --- /dev/null +++ b/stock_account_avco_return_origin/tests/test_avco_return_origin.py @@ -0,0 +1,323 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.tests import Form, tagged +from odoo.tests.common import TransactionCase + + +@tagged("post_install", "-at_install") +class TestAvcoReturnOrigin(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env.company.avco_return_origin_cost = True + cls.product_category = cls.env["product.category"].create( + {"name": "AVCO Category", "property_cost_method": "average"} + ) + cls.product = cls.env["product.product"].create( + { + "name": "AVCO Product", + "type": "product", + "categ_id": cls.product_category.id, + } + ) + cls.supplier_location = cls.env.ref("stock.stock_location_suppliers") + cls.stock_location = cls.env.ref("stock.stock_location_stock") + cls.customer_location = cls.env.ref("stock.stock_location_customers") + cls.picking_type_in = cls.env.ref("stock.picking_type_in") + cls.picking_type_out = cls.env.ref("stock.picking_type_out") + + def _create_receipt(self, price_unit, qty=1): + picking = self.env["stock.picking"].create( + { + "location_id": self.supplier_location.id, + "location_dest_id": self.stock_location.id, + "picking_type_id": self.picking_type_in.id, + } + ) + self.env["stock.move"].create( + { + "name": "Receipt", + "product_id": self.product.id, + "product_uom_qty": qty, + "product_uom": self.product.uom_id.id, + "location_id": self.supplier_location.id, + "location_dest_id": self.stock_location.id, + "picking_id": picking.id, + "price_unit": price_unit, + } + ) + picking.action_confirm() + picking.action_assign() + picking.move_ids.move_line_ids.qty_done = qty + picking.button_validate() + return picking + + def _create_delivery(self, qty=1): + picking = self.env["stock.picking"].create( + { + "location_id": self.stock_location.id, + "location_dest_id": self.customer_location.id, + "picking_type_id": self.picking_type_out.id, + } + ) + self.env["stock.move"].create( + { + "name": "Delivery", + "product_id": self.product.id, + "product_uom_qty": qty, + "product_uom": self.product.uom_id.id, + "location_id": self.stock_location.id, + "location_dest_id": self.customer_location.id, + "picking_id": picking.id, + } + ) + picking.action_confirm() + picking.action_assign() + picking.move_ids.move_line_ids.qty_done = qty + picking.button_validate() + return picking + + def _prepare_return(self, picking, qty=1): + """Create a supplier return and mark it done, but leave it unvalidated.""" + return_wizard = Form( + self.env["stock.return.picking"].with_context( + active_ids=picking.ids, + active_id=picking.id, + active_model="stock.picking", + ) + ).save() + return_wizard.product_return_moves.write({"quantity": qty}) + action = return_wizard.create_returns() + return_picking = self.env["stock.picking"].browse(action["res_id"]) + return_picking.move_ids.move_line_ids.qty_done = qty + return return_picking + + def _create_return(self, picking, qty=1): + return_picking = self._prepare_return(picking, qty=qty) + return_picking.button_validate() + return return_picking + + def _prepare_delivery(self, qty=1): + """Create a customer delivery and mark it done, but leave it unvalidated.""" + picking = self.env["stock.picking"].create( + { + "location_id": self.stock_location.id, + "location_dest_id": self.customer_location.id, + "picking_type_id": self.picking_type_out.id, + } + ) + self.env["stock.move"].create( + { + "name": "Delivery", + "product_id": self.product.id, + "product_uom_qty": qty, + "product_uom": self.product.uom_id.id, + "location_id": self.stock_location.id, + "location_dest_id": self.customer_location.id, + "picking_id": picking.id, + } + ) + picking.action_confirm() + picking.action_assign() + picking.move_ids.move_line_ids.qty_done = qty + return picking + + def test_avco_return_at_original_price(self): + """Return reduces valuation at the original receipt price.""" + picking1 = self._create_receipt(100) + self._create_receipt(150) + self.assertEqual(self.product.standard_price, 125) # AVCO + self.assertEqual(self.product.value_svl, 250) + + return_picking = self._create_return(picking1) + return_svl = return_picking.move_ids.stock_valuation_layer_ids + self.assertEqual(return_svl.value, -100) + self.assertEqual(return_svl.unit_cost, 100) + + # Remaining: 1 unit valued at 150, AVCO realigned to 150. + self.assertEqual(self.product.quantity_svl, 1) + self.assertEqual(self.product.value_svl, 150) + self.assertEqual(self.product.standard_price, 150) + + def test_avco_return_disabled_uses_avco(self): + """With the company setting off, the return behaves as standard AVCO.""" + self.env.company.avco_return_origin_cost = False + picking1 = self._create_receipt(100) + self._create_receipt(150) + + return_picking = self._create_return(picking1) + return_svl = return_picking.move_ids.stock_valuation_layer_ids + self.assertEqual(return_svl.value, -125) # current AVCO + self.assertEqual(self.product.value_svl, 125) + self.assertEqual(self.product.standard_price, 125) + + def test_avco_return_partial(self): + """Partial return uses the original receipt price pro-rata.""" + picking = self._create_receipt(100, qty=4) + self._create_receipt(200, qty=4) + self.assertEqual(self.product.standard_price, 150) # (400 + 800) / 8 + + self._create_return(picking, qty=2) + # SVL value -200 (2 * 100), not -300 (2 * 150). + self.assertEqual(self.product.quantity_svl, 6) + self.assertEqual(self.product.value_svl, 1000) # 1200 - 200 + self.assertAlmostEqual(self.product.standard_price, 166.67, places=2) + + def test_avco_return_depleting_stock_falls_back_to_avco(self): + """A return that empties stock is valued at standard AVCO, not blocked.""" + picking1 = self._create_receipt(100) + self._create_receipt(150) + # Issue one unit at AVCO so the later return brings stock to zero. + self._create_delivery(1) + self.assertEqual(self.product.quantity_svl, 1) + + # Returning the original ¥100 unit would empty stock; rather than strand + # value (or block), the return is valued at the standard average (125). + return_picking = self._create_return(picking1) + return_svl = return_picking.move_ids.stock_valuation_layer_ids + self.assertEqual(return_svl.value, -125) + self.assertEqual(self.product.quantity_svl, 0) + self.assertEqual(self.product.value_svl, 0) + + def test_avco_return_capped_at_inventory_value(self): + """A return is capped so the valuation never goes negative with stock on hand. + + With a large price gap between receipts, valuing the return at the + original receipt price would remove more value than remains on hand. + The removal is capped at the current inventory value, flooring the + valuation at zero instead of driving it negative while stock is still + on hand. + """ + self._create_receipt(100, qty=2) + receipt2 = self._create_receipt(1000, qty=1) + self.assertEqual(self.product.quantity_svl, 3) + self.assertEqual(self.product.value_svl, 1200) + self.assertEqual(self.product.standard_price, 400) # (200 + 1000) / 3 + + # Deliver one unit at AVCO (400); 2 units / 800 remain. + self._create_delivery(1) + self.assertEqual(self.product.quantity_svl, 2) + self.assertEqual(self.product.value_svl, 800) + + # Returning the ¥1000 unit at its origin price would remove 1000 from + # an 800 valuation. Cap the removal at 800 so the valuation floors at 0. + return_picking = self._create_return(receipt2) + return_svl = return_picking.move_ids.stock_valuation_layer_ids + self.assertEqual(return_svl.value, -800) + self.assertEqual(return_svl.unit_cost, 800) + self.assertEqual(self.product.quantity_svl, 1) + self.assertEqual(self.product.value_svl, 0) + self.assertEqual(self.product.standard_price, 0) + + def test_mixed_batch_delivery_valued_before_return_realign(self): + """A delivery batched with a same-product return keeps the pre-return AVCO. + + Characterization test: when a single ``_action_done()`` values a normal + delivery together with an origin-cost supplier return of the same product + (e.g. multi-picking validation from the list view or a batch transfer), + the delivery is valued at the average in effect before the return, and the + standard_price realignment is applied only afterwards. See the comment in + ``stock_move._create_out_svl``. This pins the intended behavior; do not + "fix" it by reordering the split. + """ + receipt1 = self._create_receipt(100, qty=2) + self._create_receipt(150, qty=2) + self.assertEqual(self.product.standard_price, 125) # AVCO + self.assertEqual(self.product.quantity_svl, 4) + self.assertEqual(self.product.value_svl, 500) + + delivery = self._prepare_delivery(1) + return_picking = self._prepare_return(receipt1, 1) + + # Validate both pickings in a single _action_done batch. + (delivery | return_picking).button_validate() + + delivery_svl = delivery.move_ids.stock_valuation_layer_ids + return_svl = return_picking.move_ids.stock_valuation_layer_ids + # Delivery valued at the pre-return average (125), not the corrected one. + self.assertEqual(delivery_svl.value, -125) + self.assertEqual(delivery_svl.unit_cost, 125) + # Return valued at the original receipt price (100). + self.assertEqual(return_svl.value, -100) + self.assertEqual(return_svl.unit_cost, 100) + # Final: 2 units, 500 - 125 - 100 = 275, AVCO realigned to 137.5. + self.assertEqual(self.product.quantity_svl, 2) + self.assertEqual(self.product.value_svl, 275) + self.assertEqual(self.product.standard_price, 137.5) + + def test_multiple_returns_emptying_stock_strands_no_value(self): + """Several origin returns emptying stock in one batch leave value at 0. + + The standard_price is realigned after each origin return, so a later + return whose quantity empties stock falls back to the correctly updated + average instead of a stale one. Without per-move realignment the emptying + return would strand valuation (qty 0 with value != 0). + """ + receipt1 = self._create_receipt(100) + receipt2 = self._create_receipt(150) + self.assertEqual(self.product.quantity_svl, 2) + self.assertEqual(self.product.value_svl, 250) + + return1 = self._prepare_return(receipt1, 1) + return2 = self._prepare_return(receipt2, 1) + # Validate both returns in a single _action_done batch; combined they + # empty stock. + (return1 | return2).button_validate() + + svl1 = return1.move_ids.stock_valuation_layer_ids + svl2 = return2.move_ids.stock_valuation_layer_ids + # First return at its origin price; the emptying return absorbs the + # realigned residual so nothing is stranded. + self.assertEqual(svl1.value, -100) + self.assertEqual(svl2.value, -150) + self.assertEqual(self.product.quantity_svl, 0) + self.assertEqual(self.product.value_svl, 0) + + def test_fifo_return_unaffected(self): + """FIFO returns must not be affected by the origin-price override. + + The company setting is on, but the product is FIFO, so + ``_is_avco_origin_return`` must be False and this module must stay out of + the way. + """ + fifo_category = self.env["product.category"].create( + {"name": "FIFO Category", "property_cost_method": "fifo"} + ) + self.product = self.env["product.product"].create( + { + "name": "FIFO Product", + "type": "product", + "categ_id": fifo_category.id, + } + ) + self._create_receipt(100) + picking2 = self._create_receipt(150) + self.assertFalse(picking2.move_ids._is_avco_origin_return()) + + def test_standard_return_unaffected(self): + """Standard-cost returns are valued at standard_price, not origin price.""" + standard_category = self.env["product.category"].create( + {"name": "Standard Category", "property_cost_method": "standard"} + ) + self.product = self.env["product.product"].create( + { + "name": "Standard Product", + "type": "product", + "categ_id": standard_category.id, + "standard_price": 120, + } + ) + # Received at 100, but standard costing values it at standard_price (120). + picking = self._create_receipt(100) + self.assertFalse(picking.move_ids._is_avco_origin_return()) + self.assertEqual(self.product.value_svl, 120) + + return_picking = self._create_return(picking) + return_svl = return_picking.move_ids.stock_valuation_layer_ids + # Standard price (120), not the receipt's origin price (100). + self.assertEqual(return_svl.value, -120) + self.assertEqual(return_svl.unit_cost, 120) + self.assertNotIn("original receipt price", return_svl.description or "") + self.assertEqual(self.product.quantity_svl, 0) + self.assertEqual(self.product.value_svl, 0) diff --git a/stock_account_avco_return_origin/views/res_config_settings_views.xml b/stock_account_avco_return_origin/views/res_config_settings_views.xml new file mode 100644 index 00000000..45c9162b --- /dev/null +++ b/stock_account_avco_return_origin/views/res_config_settings_views.xml @@ -0,0 +1,30 @@ + + + + res.config.settings.form (in stock_account_avco_return_origin) + res.config.settings + + + +
+
+ +
+
+
+
+
+
+
+