diff --git a/product_abc_classification_finance/README.rst b/product_abc_classification_finance/README.rst new file mode 100644 index 00000000000..39675b289f1 --- /dev/null +++ b/product_abc_classification_finance/README.rst @@ -0,0 +1,99 @@ +================================== +Product ABC Classification Finance +================================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:0ec73bee1065a082a937419d6b736faeedfa1e9e4e0cde1147cc746d17b65c90 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/licence-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%2Fproduct--attribute-lightgray.png?logo=github + :target: https://github.com/OCA/product-attribute/tree/16.0/product_abc_classification_finance + :alt: OCA/product-attribute +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/product-attribute-16-0/product-attribute-16-0-product_abc_classification_finance + :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/product-attribute&target_branch=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This modules includes an ABC analysis computation profile based +on the cost and sale price of sale order lines delivered from a given date by product. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +To use this module, you need to: + +#. Go to Sales or Inventory menu, then to + Configuration > Products > ABC Classification Profile and create a profile + with levels. The sum of all levels in the profile should equal 100 and all + levels must be unique. + +#. Assign the profile to product variants. The cron job will automatically + classify these products into one of the profile's levels based on the total + cost and total sales of delivered products. + +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 +~~~~~~~ + +* AJamal13 + +Contributors +~~~~~~~~~~~~ + +* Ahmed Jamal + +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-AJamal13| image:: https://github.com/AJamal13.png?size=40px + :target: https://github.com/AJamal13 + :alt: AJamal13 + +Current `maintainer `__: + +|maintainer-AJamal13| + +This module is part of the `OCA/product-attribute `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/product_abc_classification_finance/__init__.py b/product_abc_classification_finance/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/product_abc_classification_finance/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/product_abc_classification_finance/__manifest__.py b/product_abc_classification_finance/__manifest__.py new file mode 100644 index 00000000000..01e385626e2 --- /dev/null +++ b/product_abc_classification_finance/__manifest__.py @@ -0,0 +1,22 @@ +{ + "name": "Product ABC Classification Finance", + "summary": "Financial ABC analysis for products (cost, sale price)", + "version": "16.0.1.0.2", + "category": "Inventory/Inventory", + "author": "AJamal13,Odoo Community Association (OCA)", + "license": "AGPL-3", + "website": "https://github.com/OCA/product-attribute", + "depends": ["product_abc_classification_sale_stock", "sale_margin"], + "data": [ + "security/ir.model.access.csv", + "views/abc_finance_sale_level_history_views.xml", + "views/abc_classification_product_level_views.xml", + "views/abc_classification_profile.xml", + ], + "demo": [ + "data/abc_classification_finance_demo.xml", + ], + "maintainers": ["AJamal13"], + "installable": True, + "application": False, +} diff --git a/product_abc_classification_finance/data/abc_classification_finance_demo.xml b/product_abc_classification_finance/data/abc_classification_finance_demo.xml new file mode 100644 index 00000000000..6604848fbcf --- /dev/null +++ b/product_abc_classification_finance/data/abc_classification_finance_demo.xml @@ -0,0 +1,34 @@ + + + + + + A + 80 + 20 + + + B + 15 + 30 + + + C + 5 + 50 + + + + Finance Cost Profile + cost + + 365 + + + diff --git a/product_abc_classification_finance/models/__init__.py b/product_abc_classification_finance/models/__init__.py new file mode 100644 index 00000000000..154290b3b06 --- /dev/null +++ b/product_abc_classification_finance/models/__init__.py @@ -0,0 +1,3 @@ +from . import abc_classification_profile +from . import abc_classification_product_level +from . import abc_finance_sale_level_history diff --git a/product_abc_classification_finance/models/abc_classification_product_level.py b/product_abc_classification_finance/models/abc_classification_product_level.py new file mode 100644 index 00000000000..d19cc511ddb --- /dev/null +++ b/product_abc_classification_finance/models/abc_classification_product_level.py @@ -0,0 +1,10 @@ +from odoo import fields, models + + +class AbcClassificationProductLevel(models.Model): + _inherit = "abc.classification.product.level" + + finance_sale_level_history_ids = fields.One2many( + comodel_name="abc.finance.sale.level.history", + inverse_name="product_level_id", + ) diff --git a/product_abc_classification_finance/models/abc_classification_profile.py b/product_abc_classification_finance/models/abc_classification_profile.py new file mode 100644 index 00000000000..313f7fce572 --- /dev/null +++ b/product_abc_classification_finance/models/abc_classification_profile.py @@ -0,0 +1,359 @@ +from datetime import datetime, timedelta + +from odoo import _, api, fields, models +from odoo.exceptions import UserError, ValidationError +from odoo.tools import float_round + + +class AbcClassificationProfile(models.Model): + _inherit = "abc.classification.profile" + + profile_type = fields.Selection( + selection_add=[ + ( + "cost", + "Based on the Cost of delivered sale order line by product", + ), + ( + "sale_price", + "Based on the Sale Price of delivered sale order line by product", + ), + ], + ondelete={"cost": "cascade", "sale_price": "cascade"}, + ) + + @api.constrains("profile_type", "warehouse_id") + def _check_warehouse_id(self): + for rec in self: + if ( + rec.profile_type in ["sale_stock", "cost", "sale_price"] + and not rec.warehouse_id + ): + raise ValidationError( + _("You must specify a warehouse for {profile_name}").format( + profile_name=rec.name + ) + ) + + @api.model + def _finance_get_collected_data_class(self): + return FinanceSaleData + + def _finance_init_collected_data_instance(self): + self.ensure_one() + finance_sale_data = self._finance_get_collected_data_class()() + finance_sale_data.profile = self + return finance_sale_data + + def _get_finance_data_query(self, from_date, customer_location_ids): + """ + Build a query to aggregate financial data by product for ABC classification, + depending on profile_type. + - cost: SUM(sol.purchase_price * sol.qty_delivered) as total_cost + - sale_price: SUM(sol.price_unit * sol.qty_delivered) as total_sales + """ + if self.profile_type == "cost": + select_col = "SUM(sol.purchase_price * sol.qty_delivered) AS total_cost" + order_col = "total_cost" + elif self.profile_type == "sale_price": + select_col = "SUM(sol.price_unit * sol.qty_delivered) AS total_sales" + order_col = "total_sales" + + query = f""" + SELECT + sol.product_id AS product_id, + {select_col} + FROM + sale_order so + JOIN + sale_order_line sol ON sol.order_id = so.id + JOIN + abc_classification_profile_product_rel rel ON rel.product_id = sol.product_id + JOIN + product_product pp ON pp.id = sol.product_id + WHERE + sol.qty_delivered > 0 + AND pp.active + AND rel.profile_id = %(profile_id)s + AND so.warehouse_id = %(current_warehouse_id)s + AND EXISTS ( + SELECT 1 FROM stock_move sm + WHERE sm.date > %(start_date)s + AND sm.location_dest_id in %(customer_loc_ids)s + AND sm.sale_line_id = sol.id + ) + GROUP BY sol.product_id + ORDER BY {order_col} DESC + """ + params = { + "start_date": from_date, + "current_warehouse_id": self.warehouse_id.id, + "profile_id": self.id, + "customer_loc_ids": tuple(customer_location_ids), + } + return query, params + + def _finance_get_data(self, from_date=None): + """Get a list of statics info from the DB ordered by number of lines desc""" + self.ensure_one() + if self.profile_type not in ("cost", "sale_price"): + raise UserError(_("Profile type must be based on Cost or Sale Price")) + from_date = ( + from_date + if from_date + else fields.Datetime.to_string( + datetime.today() - timedelta(days=self.period) + ) + ) + to_date = datetime.today() + customer_location_ids = ( + self.env["stock.location"].search([("usage", "=", "customer")]).ids + ) + all_product_ids = self._get_all_product_ids() + query, params = self._get_finance_data_query(from_date, customer_location_ids) + self.env.cr.execute(query, params) + result = self.env.cr.fetchall() + total = 0 + finance_data_list = [] + ranking = 1 + ProductProduct = self.env["product.product"] + # Map SQL result into FinanceSaleData fields + for r in result: + finance_data = self._finance_init_collected_data_instance() + product_id = r[0] + finance_data.product = ProductProduct.browse(product_id) + # Map aggregate value to the right field + exclude_from_abc = False + if self.profile_type == "cost": + finance_data.total_cost = float(r[1] or 0.0) + finance_data.total_sales = 0.0 + elif self.profile_type == "sale_price": + finance_data.total_cost = 0.0 + finance_data.total_sales = float(r[1] or 0.0) + # Always set purchase_price (standard cost) from product.template + tmpl = finance_data.product.product_tmpl_id + finance_data.purchase_price = float(tmpl.standard_price or 0.0) + finance_data.ranking = ranking + finance_data.from_date = from_date + finance_data.to_date = to_date + if not exclude_from_abc: + ranking += 1 + total += float(r[1] or 0.0) + finance_data_list.append(finance_data) + all_product_ids.remove(product_id) + + # Add all products not sold or not delivered into this timelapse + for product_id in all_product_ids: + finance_data = self._finance_init_collected_data_instance() + finance_data.product = ProductProduct.browse(product_id) + finance_data.purchase_price = float( + finance_data.product.product_tmpl_id.standard_price or 0.0 + ) + finance_data.total_cost = 0.0 + finance_data.total_sales = 0.0 + finance_data.ranking = ranking + finance_data.from_date = from_date + finance_data.to_date = to_date + finance_data_list.append(finance_data) + + return finance_data_list, total + + def _finance_data_to_vals(self, finance_data, create=False): + self.ensure_one() + res = {"computed_level_id": finance_data.computed_level.id} + if create: + res.update( + { + "product_id": finance_data.product.id, + "profile_id": finance_data.profile.id, + } + ) + return res + + def _compute_abc_classification(self): + # Only process finance profile types in this module + finance_types = ("cost", "sale_price") + to_compute = self.filtered(lambda p: p.profile_type in finance_types) + remaining = self - to_compute + res = None + if remaining: + # Delegate to super for non-finance profiles + res = super( + AbcClassificationProfile, remaining + )._compute_abc_classification() + ProductClassification = self.env["abc.classification.product.level"] + + for profile in to_compute: + # Get finance data per product (list of FinanceSaleData), + # plus total for percentage computation + finance_data_list, total_value = profile._finance_get_data() + existing_level_ids_to_remove = profile._get_existing_level_ids() + level_percentage = profile._build_ordered_level_cumulative_percentage() + if not level_percentage: + continue + level, percentage = level_percentage.pop(0) + previous_data = None + total_products = len(finance_data_list) + percentage_products = (100.0 / total_products) if total_products else 0.0 + # Pick the correct value field for this profile type + if profile.profile_type == "cost": + value_field = "total_cost" + elif profile.profile_type == "sale_price": + value_field = "total_sales" + else: + raise UserError( + _(f"Unknown finance profile_type: {profile.profile_type}") + ) + + for i, finance_data in enumerate(finance_data_list): + finance_data.total_products = total_products + finance_data.percentage_products = percentage_products + finance_data.cumulated_percentage_products = ( + finance_data.percentage_products + if i == 0 + else ( + finance_data.percentage_products + + previous_data.cumulated_percentage_products + ) + ) + # Compute percentages and cumulative percentages for the products + value = getattr(finance_data, value_field, 0.0) or 0.0 + finance_data.percentage = ( + (100.0 * value / total_value) if total_value else 0.0 + ) + finance_data.cumulated_percentage = ( + finance_data.percentage + if i == 0 + else (finance_data.percentage + previous_data.cumulated_percentage) + ) + # Allow for floating point imprecision: round to 2 decimals and allow up to 101 + if float_round(finance_data.cumulated_percentage, 2) > 100.01: + raise UserError( + _( + "Cumulative percentage greater than 100 (actual: %.4f)." + % finance_data.cumulated_percentage + ) + ) + finance_data.sum_cumulated_percentages = ( + finance_data.cumulated_percentage + + finance_data.cumulated_percentage_products + ) + # Compute ABC classification for the products based on the + # sum of cumulated percentages + if ( + finance_data.sum_cumulated_percentages > percentage + and len(level_percentage) > 0 + ): + level, percentage = level_percentage.pop(0) + product = finance_data.product + levels = product.abc_classification_product_level_ids + product_abc_classification = levels.filtered( + lambda p, prof=profile: p.profile_id == prof + ) + finance_data.computed_level = level + if product_abc_classification: + existing_level_ids_to_remove.remove(product_abc_classification.id) + if product_abc_classification.level_id != level: + vals = profile._finance_data_to_vals(finance_data, create=False) + product_abc_classification.write(vals) + else: + vals = profile._finance_data_to_vals(finance_data, create=True) + product_abc_classification = ProductClassification.create(vals) + finance_data.product_level = product_abc_classification + previous_data = finance_data + if finance_data_list: + profile._finance_log_history(finance_data_list) + profile._purge_obsolete_level_values(existing_level_ids_to_remove) + return res + + def _finance_log_history(self, finance_data_list): + """Log the financial ABC classification history for this profile.""" + import csv + import io + + cr = self.env.cr + table = "abc_finance_sale_level_history" + columns = FinanceSaleData._get_col_names() + buf = io.StringIO() + writer = csv.writer(buf, delimiter=";", lineterminator="\n") + for finance_data in finance_data_list: + writer.writerow(finance_data._to_csv_line()) + buf.seek(0) + cr.copy_from(buf, table, columns=columns, sep=";") + # Ensure ORM sees the new records for tests + self.env["abc.finance.sale.level.history"].flush_model() + + +class FinanceSaleData(object): + """Finance ABC classification data + + This class is used to store all the collected and computed financial data for + ABC classification at the product level. It provides methods for bulk + inserting data into the abc.finance.sale.level.history table. + """ + + __slots__ = [ + "product", + "profile", + "computed_level", + "ranking", + "percentage", + "cumulated_percentage", + "purchase_price", + "total_cost", + "total_sales", + "product_level", + "from_date", + "to_date", + "total_products", + "percentage_products", + "cumulated_percentage_products", + "sum_cumulated_percentages", + ] + + def _to_csv_line(self): + """Return values to write into a csv file""" + return [ + self.product.id, + self.product.product_tmpl_id.id, + self.profile.id, + self.computed_level.id if self.computed_level else None, + self.profile.warehouse_id.id if self.profile.warehouse_id else None, + self.ranking or 0, + self.percentage or 0.0, + self.cumulated_percentage or 0.0, + float(self.purchase_price or 0.0), + float(self.total_cost or 0.0), + float(self.total_sales or 0.0), + self.product_level.id if self.product_level else None, + self.from_date or fields.Date.today(), + self.to_date or fields.Date.today(), + self.total_products or 0, + self.percentage_products or 0.0, + self.cumulated_percentage_products or 0.0, + self.sum_cumulated_percentages or 0.0, + ] + + @classmethod + def _get_col_names(cls): + """Return the ordered list of column names for the financial ABC history table""" + return [ + "product_id", + "product_tmpl_id", + "profile_id", + "computed_level_id", + "warehouse_id", + "ranking", + "percentage", + "cumulated_percentage", + "purchase_price", + "total_cost", + "total_sales", + "product_level_id", + "from_date", + "to_date", + "total_products", + "percentage_products", + "cumulated_percentage_products", + "sum_cumulated_percentages", + ] diff --git a/product_abc_classification_finance/models/abc_finance_sale_level_history.py b/product_abc_classification_finance/models/abc_finance_sale_level_history.py new file mode 100644 index 00000000000..a927e4b8c47 --- /dev/null +++ b/product_abc_classification_finance/models/abc_finance_sale_level_history.py @@ -0,0 +1,80 @@ +from odoo import fields, models + + +class AbcFinanceSaleLevelHistory(models.Model): + """Finance ABC Classification Product Level History""" + + _name = "abc.finance.sale.level.history" + _description = "Abc Finance Sale Level History" + + computed_level_id = fields.Many2one( + "abc.classification.level", + string="Computed classification level", + readonly=True, + ondelete="cascade", + ) + product_id = fields.Many2one( + "product.product", + string="Product", + index=True, + required=True, + readonly=True, + ondelete="cascade", + ) + product_tmpl_id = fields.Many2one( + "product.template", + string="Product template", + related="product_id.product_tmpl_id", + readonly=True, + store=True, + ) + purchase_price = fields.Float( + "Purchase price", + required=True, + readonly=True, + ) + total_cost = fields.Float( + "Total cost", + required=True, + readonly=True, + ) + total_sales = fields.Float( + "Total sales", + required=True, + readonly=True, + ) + profile_id = fields.Many2one( + "abc.classification.profile", + string="Profile", + required=True, + readonly=True, + ondelete="cascade", + ) + warehouse_id = fields.Many2one( + "stock.warehouse", + string="Warehouse", + readonly=True, + ondelete="cascade", + ) + profile_type = fields.Selection( + related="profile_id.profile_type", + readonly=True, + ) + ranking = fields.Integer(readonly=True) + percentage = fields.Float(readonly=True) + cumulated_percentage = fields.Float(readonly=True) + standard_cost = fields.Float(readonly=True, related="product_id.standard_price") + total_cost = fields.Float(readonly=True) + total_sales = fields.Float(readonly=True) + product_level_id = fields.Many2one( + "abc.classification.product.level", + string="Product Level", + readonly=True, + ondelete="cascade", + ) + from_date = fields.Date(readonly=True) + to_date = fields.Date(readonly=True) + total_products = fields.Integer(readonly=True) + percentage_products = fields.Float(readonly=True) + cumulated_percentage_products = fields.Float(readonly=True) + sum_cumulated_percentages = fields.Float(readonly=True) diff --git a/product_abc_classification_finance/readme/CONTRIBUTORS.rst b/product_abc_classification_finance/readme/CONTRIBUTORS.rst new file mode 100644 index 00000000000..d11973f5849 --- /dev/null +++ b/product_abc_classification_finance/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Ahmed Jamal diff --git a/product_abc_classification_finance/readme/DESCRIPTION.rst b/product_abc_classification_finance/readme/DESCRIPTION.rst new file mode 100644 index 00000000000..044a9c6d886 --- /dev/null +++ b/product_abc_classification_finance/readme/DESCRIPTION.rst @@ -0,0 +1,2 @@ +This modules includes an ABC analysis computation profile based +on the cost and sale price of sale order lines delivered from a given date by product. diff --git a/product_abc_classification_finance/readme/USAGE.rst b/product_abc_classification_finance/readme/USAGE.rst new file mode 100644 index 00000000000..558ef42cd93 --- /dev/null +++ b/product_abc_classification_finance/readme/USAGE.rst @@ -0,0 +1,10 @@ +To use this module, you need to: + +#. Go to Sales or Inventory menu, then to + Configuration > Products > ABC Classification Profile and create a profile + with levels. The sum of all levels in the profile should equal 100 and all + levels must be unique. + +#. Assign the profile to product variants. The cron job will automatically + classify these products into one of the profile's levels based on the total + cost and total sales of delivered products. diff --git a/product_abc_classification_finance/security/ir.model.access.csv b/product_abc_classification_finance/security/ir.model.access.csv new file mode 100644 index 00000000000..cf73c6ad8fa --- /dev/null +++ b/product_abc_classification_finance/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_abc_finance_sale_level_history,abc.finance.sale.level.history,model_abc_finance_sale_level_history,,1,0,0,0 diff --git a/product_abc_classification_finance/static/description/icon.png b/product_abc_classification_finance/static/description/icon.png new file mode 100644 index 00000000000..3a0328b516c Binary files /dev/null and b/product_abc_classification_finance/static/description/icon.png differ diff --git a/product_abc_classification_finance/static/description/index.html b/product_abc_classification_finance/static/description/index.html new file mode 100644 index 00000000000..501edd77fa2 --- /dev/null +++ b/product_abc_classification_finance/static/description/index.html @@ -0,0 +1,437 @@ + + + + + +Product ABC Classification Finance + + + +
+

Product ABC Classification Finance

+ + +

Beta License: AGPL-3 OCA/product-attribute Translate me on Weblate Try me on Runboat

+

This modules includes an ABC analysis computation profile based +on the cost and sale price of sale order lines delivered from a given date by product.

+

Table of contents

+ +
+

Usage

+

To use this module, you need to:

+
    +
  1. Go to Sales or Inventory menu, then to +Configuration > Products > ABC Classification Profile and create a profile +with levels. The sum of all levels in the profile should equal 100 and all +levels must be unique.
  2. +
  3. Assign the profile to product variants. The cron job will automatically +classify these products into one of the profile’s levels based on the total +cost and total sales of delivered products.
  4. +
+
+
+

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

+
    +
  • AJamal13
  • +
+
+ +
+

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 maintainer:

+

AJamal13

+

This module is part of the OCA/product-attribute project on GitHub.

+

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

+
+
+
+ + diff --git a/product_abc_classification_finance/tests/__init__.py b/product_abc_classification_finance/tests/__init__.py new file mode 100644 index 00000000000..030966e31da --- /dev/null +++ b/product_abc_classification_finance/tests/__init__.py @@ -0,0 +1 @@ +from . import test_abc_classification_finance diff --git a/product_abc_classification_finance/tests/test_abc_classification_finance.py b/product_abc_classification_finance/tests/test_abc_classification_finance.py new file mode 100644 index 00000000000..750eb1cd2d1 --- /dev/null +++ b/product_abc_classification_finance/tests/test_abc_classification_finance.py @@ -0,0 +1,367 @@ +# Copyright 2025 Your Company +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging + +from odoo.exceptions import ValidationError +from odoo.tests.common import TransactionCase + +_logger = logging.getLogger(__name__) + + +class TestABCClassificationFinance(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + + # Create test warehouse + cls.warehouse = cls.env["stock.warehouse"].search([], limit=1) + if not cls.warehouse: + cls.warehouse = cls.env["stock.warehouse"].create( + { + "name": "Test Warehouse", + "code": "TST", + "reception_steps": "one_step", + "delivery_steps": "ship_only", + } + ) + + # Create ABC profile for finance (cost based) + cls.finance_profile = cls.env["abc.classification.profile"].create( + { + "name": "Test Finance Cost Profile", + "profile_type": "cost", + "warehouse_id": cls.warehouse.id, + "period": 365, + } + ) + + # Create levels for the profile + cls.level_A = cls.env["abc.classification.level"].create( + { + "name": "A", + "profile_id": cls.finance_profile.id, + "percentage": 70, + "percentage_products": 30, + } + ) + cls.level_B = cls.env["abc.classification.level"].create( + { + "name": "B", + "profile_id": cls.finance_profile.id, + "percentage": 30, + "percentage_products": 70, + } + ) + + # Create a product + cls.product = cls.env["product.product"].create( + { + "name": "Finance Product", + "uom_id": cls.env.ref("uom.product_uom_unit").id, + "type": "product", + "default_code": "FIN001", + "tracking": "none", + } + ) + + def test_compute_abc_classification_value_field(self): + """ + _compute_abc_classification should assign value_field correctly for valid types + """ + for profile_type, _expected_field in [ + ("cost", "total_cost"), + ("sale_price", "total_sales"), + ]: + profile = self.env["abc.classification.profile"].create( + { + "name": f"Test {profile_type}", + "profile_type": profile_type, + "warehouse_id": self.warehouse.id, + "period": 365, + } + ) + # Create a level for this profile + level = self.env["abc.classification.level"].create( + { + "name": f"Level for {profile_type}", + "profile_id": profile.id, + "percentage": 100, + "percentage_products": 100, + } + ) + # Patch _finance_get_data to return dummy data + from unittest.mock import patch + + from ..models.abc_classification_profile import FinanceSaleData + + dummy_data = FinanceSaleData() + dummy_data.product = self.product + dummy_data.profile = profile + with patch.object( + type(profile), "_finance_get_data", return_value=([dummy_data], 0) + ), patch.object( + type(profile), "_finance_log_history", return_value=None + ), patch.object( + type(profile), "_get_existing_level_ids", return_value=[] + ), patch.object( + type(profile), + "_build_ordered_level_cumulative_percentage", + return_value=[(level, 100.0)], + ), patch.object( + type(profile), "_purge_obsolete_level_values", return_value=None + ): + try: + profile._compute_abc_classification() + except Exception as e: + self.fail(f"Unexpected error for profile_type {profile_type}: {e}") + + def test_profile_requires_warehouse(self): + """ + Finance profile must require a warehouse. + """ + profile = self.env["abc.classification.profile"].new( + { + "name": "No Warehouse", + "profile_type": "cost", + "warehouse_id": False, + } + ) + with self.assertRaises(ValidationError): + profile._check_warehouse_id() + + def test_finance_profile_type_selection(self): + """ + Profile type should accept 'cost', 'sale_price'. + """ + for profile_type in ["cost", "sale_price"]: + profile = self.env["abc.classification.profile"].create( + { + "name": f"Profile {profile_type}", + "profile_type": profile_type, + "warehouse_id": self.warehouse.id, + } + ) + self.assertEqual(profile.profile_type, profile_type) + + def test_history_record_creation(self): + """ + Test that finance history records can be created and linked to product level. + """ + product_level = self.env["abc.classification.product.level"].create( + { + "product_id": self.product.id, + "computed_level_id": self.level_A.id, + "profile_id": self.finance_profile.id, + } + ) + history = self.env["abc.finance.sale.level.history"].create( + { + "computed_level_id": self.level_A.id, + "product_id": self.product.id, + "purchase_price": 10.0, + "total_cost": 100.0, + "total_sales": 120.0, + "profile_id": self.finance_profile.id, + "warehouse_id": self.warehouse.id, + "product_level_id": product_level.id, + } + ) + self.assertEqual(history.product_id, self.product) + self.assertEqual(history.product_level_id, product_level) + self.assertEqual(product_level.finance_sale_level_history_ids, history) + + def test_finance_data_query_methods(self): + """ + Smoke test for _get_finance_data_query + and _finance_init_collected_data_instance. + """ + from_date = "2025-01-01" + customer_location_ids = [] + query, params = self.finance_profile._get_finance_data_query( + from_date, customer_location_ids + ) + self.assertIsInstance(query, str) + self.assertIsInstance(params, dict) + self.assertIn("SUM(", query) + self.assertIn("GROUP BY", query) + self.assertIn("ORDER BY", query) + data_instance = self.finance_profile._finance_init_collected_data_instance() + self.assertEqual(data_instance.profile, self.finance_profile) + + def test_finance_get_data_cost(self): + """ + Test _finance_get_data for 'cost' profile returns correct structure and values. + """ + self.finance_profile.write( + {"level_ids": [(6, 0, [self.level_A.id, self.level_B.id])]} + ) + self.product.write( + {"abc_classification_profile_ids": [(6, 0, [self.finance_profile.id])]} + ) + finance_data_list, total = self.finance_profile._finance_get_data() + self.assertIsInstance(finance_data_list, list) + self.assertTrue(any(fd.product == self.product for fd in finance_data_list)) + for fd in finance_data_list: + self.assertTrue(hasattr(fd, "total_cost")) + self.assertTrue(hasattr(fd, "ranking")) + self.assertTrue(hasattr(fd, "product")) + self.assertTrue(hasattr(fd, "from_date")) + self.assertTrue(hasattr(fd, "to_date")) + self.assertIsInstance(total, (int, float)) + import csv + import io + + from ..models.abc_classification_profile import FinanceSaleData + + cr = self.env.cr + table = "abc_finance_sale_level_history" + columns = FinanceSaleData._get_col_names() + buf = io.StringIO() + writer = csv.writer(buf, delimiter=";", lineterminator="\n") + for finance_data in finance_data_list: + finance_data.computed_level = self.level_A + finance_data.product_level = None + finance_data.percentage = 100.0 + finance_data.cumulated_percentage = 100.0 + finance_data.purchase_price = getattr(finance_data, "purchase_price", 10.0) + finance_data.total_cost = getattr(finance_data, "total_cost", 10.0) + finance_data.total_sales = getattr(finance_data, "total_sales", 10.0) + finance_data.from_date = getattr(finance_data, "from_date", "2025-01-01") + finance_data.to_date = getattr(finance_data, "to_date", "2025-12-31") + finance_data.total_products = 1 + finance_data.percentage_products = 100.0 + finance_data.cumulated_percentage_products = 100.0 + finance_data.sum_cumulated_percentages = 100.0 + row = finance_data._to_csv_line() + for idx in [0, 1, 2, 3, 4, 5, 11]: + if row[idx] in (None, ""): + row[idx] = "\\N" + for idx in [12, 13]: + if not row[idx]: + row[idx] = "\\N" + writer.writerow(row) + buf.seek(0) + cr.copy_from(buf, table, columns=columns, sep=";") + self.env["abc.finance.sale.level.history"].flush_model() + records = self.env["abc.finance.sale.level.history"].search([]) + self.assertGreaterEqual(len(records), 1) + + def test_finance_get_data_sale_price(self): + """ + Test _finance_get_data for 'sale_price' profile returns + correct structure and values. + """ + profile = self.env["abc.classification.profile"].create( + { + "name": "Test Sale Price Profile", + "profile_type": "sale_price", + "warehouse_id": self.warehouse.id, + "period": 365, + } + ) + profile.write({"level_ids": [(6, 0, [self.level_A.id, self.level_B.id])]}) + self.product.write({"abc_classification_profile_ids": [(6, 0, [profile.id])]}) + finance_data_list, total = profile._finance_get_data() + self.assertIsInstance(finance_data_list, list) + for fd in finance_data_list: + self.assertTrue(hasattr(fd, "total_sales")) + self.assertEqual(fd.total_cost, 0.0) + import csv + import io + + from ..models.abc_classification_profile import FinanceSaleData + + cr = self.env.cr + table = "abc_finance_sale_level_history" + columns = FinanceSaleData._get_col_names() + buf = io.StringIO() + writer = csv.writer(buf, delimiter=";", lineterminator="\n") + for finance_data in finance_data_list: + finance_data.computed_level = self.level_A + finance_data.product_level = None + finance_data.percentage = 100.0 + finance_data.cumulated_percentage = 100.0 + finance_data.purchase_price = getattr(finance_data, "purchase_price", 10.0) + finance_data.total_cost = getattr(finance_data, "total_cost", 0.0) + finance_data.total_sales = getattr(finance_data, "total_sales", 10.0) + finance_data.from_date = getattr(finance_data, "from_date", "2025-01-01") + finance_data.to_date = getattr(finance_data, "to_date", "2025-12-31") + finance_data.total_products = 1 + finance_data.percentage_products = 100.0 + finance_data.cumulated_percentage_products = 100.0 + finance_data.sum_cumulated_percentages = 100.0 + row = finance_data._to_csv_line() + for idx in [0, 1, 2, 3, 4, 5, 11]: + if row[idx] in (None, ""): + row[idx] = "\\N" + for idx in [12, 13]: + if not row[idx]: + row[idx] = "\\N" + writer.writerow(row) + buf.seek(0) + cr.copy_from(buf, table, columns=columns, sep=";") + self.env["abc.finance.sale.level.history"].flush_model() + records = self.env["abc.finance.sale.level.history"].search([]) + self.assertGreaterEqual(len(records), 1) + + def test_level_assignment_validation(self): + """ + Test that levels for a finance profile must total 100%. + """ + profile = self.env["abc.classification.profile"].create( + { + "name": "Partial Level Profile", + "profile_type": "cost", + "warehouse_id": self.warehouse.id, + "period": 365, + } + ) + self.env["abc.classification.level"].create( + { + "name": "A", + "profile_id": profile.id, + "percentage": 60, + "percentage_products": 40, + } + ) + with self.assertRaises(ValidationError): + profile.write( + { + "level_ids": [ + ( + 0, + 0, + { + "name": "B", + "percentage": 10, + "percentage_products": 10, + }, + ) + ] + } + ) + + def test_get_finance_data_query_all_types(self): + """ + Ensure _get_finance_data_query returns correct SQL for all profile types. + """ + types_and_keywords = [ + ("cost", "SUM(sol.purchase_price * sol.qty_delivered) AS total_cost"), + ("sale_price", "SUM(sol.price_unit * sol.qty_delivered) AS total_sales"), + ] + for profile_type, expected in types_and_keywords: + profile = self.env["abc.classification.profile"].create( + { + "name": f"Query Profile {profile_type}", + "profile_type": profile_type, + "warehouse_id": self.warehouse.id, + "period": 365, + } + ) + query, params = profile._get_finance_data_query("2025-01-01", [1, 2]) + self.assertIn(expected, query) + self.assertIn("GROUP BY", query) + self.assertIn("ORDER BY", query) + self.assertIsInstance(params, dict) diff --git a/product_abc_classification_finance/views/abc_classification_product_level_views.xml b/product_abc_classification_finance/views/abc_classification_product_level_views.xml new file mode 100644 index 00000000000..50300a111b0 --- /dev/null +++ b/product_abc_classification_finance/views/abc_classification_product_level_views.xml @@ -0,0 +1,30 @@ + + + abc.classification.product.level.tree + abc.classification.product.level + + + + + + + + + + + abc.classification.product.level.form + abc.classification.product.level + +
+ + + + + + + + +
+
+
+
diff --git a/product_abc_classification_finance/views/abc_classification_profile.xml b/product_abc_classification_finance/views/abc_classification_profile.xml new file mode 100644 index 00000000000..2e9a2bb8347 --- /dev/null +++ b/product_abc_classification_finance/views/abc_classification_profile.xml @@ -0,0 +1,19 @@ + + + + + abc.classification.profile.form (finance inherit) + abc.classification.profile + + + + {'required': [('profile_type', 'in', ['sale_stock', 'cost', 'sale_price'])], 'invisible': []} + + + + diff --git a/product_abc_classification_finance/views/abc_finance_sale_level_history_views.xml b/product_abc_classification_finance/views/abc_finance_sale_level_history_views.xml new file mode 100644 index 00000000000..04fc0a7f903 --- /dev/null +++ b/product_abc_classification_finance/views/abc_finance_sale_level_history_views.xml @@ -0,0 +1,83 @@ + + + abc.finance.sale.level.history.tree + abc.finance.sale.level.history + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + abc.finance.sale.level.history.form + abc.finance.sale.level.history + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
diff --git a/setup/product_abc_classification_finance/odoo/addons/product_abc_classification_finance b/setup/product_abc_classification_finance/odoo/addons/product_abc_classification_finance new file mode 100644 index 00000000000..5c70089f613 --- /dev/null +++ b/setup/product_abc_classification_finance/odoo/addons/product_abc_classification_finance @@ -0,0 +1 @@ +../../../../product_abc_classification_finance \ No newline at end of file diff --git a/setup/product_abc_classification_finance/setup.py b/setup/product_abc_classification_finance/setup.py new file mode 100644 index 00000000000..28c57bb6403 --- /dev/null +++ b/setup/product_abc_classification_finance/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)