From 8082462079aab3a50ec392bf7dff8f44a4db086a Mon Sep 17 00:00:00 2001
From: AJamal13 <135590484+AJamal13@users.noreply.github.com>
Date: Sat, 21 Jun 2025 22:10:17 +0300
Subject: [PATCH 1/8] Adding classification by Cost/Sale Price/Sale Margin
Adding classification by Cost/Sale Price/Sale Margin
---
.../__init__.py | 1 +
.../__manifest__.py | 18 +
.../models/__init__.py | 3 +
.../abc_classification_product_level.py | 9 +
.../models/abc_classification_profile.py | 391 ++++++++++++++++++
.../models/abc_finance_sale_level_history.py | 80 ++++
.../security/ir.model.access.csv | 2 +
...abc_classification_product_level_views.xml | 30 ++
.../views/abc_classification_profile.xml | 14 +
.../abc_finance_sale_level_history_views.xml | 61 +++
10 files changed, 609 insertions(+)
create mode 100644 product_abc_classification_finance/__init__.py
create mode 100644 product_abc_classification_finance/__manifest__.py
create mode 100644 product_abc_classification_finance/models/__init__.py
create mode 100644 product_abc_classification_finance/models/abc_classification_product_level.py
create mode 100644 product_abc_classification_finance/models/abc_classification_profile.py
create mode 100644 product_abc_classification_finance/models/abc_finance_sale_level_history.py
create mode 100644 product_abc_classification_finance/security/ir.model.access.csv
create mode 100644 product_abc_classification_finance/views/abc_classification_product_level_views.xml
create mode 100644 product_abc_classification_finance/views/abc_classification_profile.xml
create mode 100644 product_abc_classification_finance/views/abc_finance_sale_level_history_views.xml
diff --git a/product_abc_classification_finance/__init__.py b/product_abc_classification_finance/__init__.py
new file mode 100644
index 00000000000..9a7e03eded3
--- /dev/null
+++ b/product_abc_classification_finance/__init__.py
@@ -0,0 +1 @@
+from . import models
\ No newline at end of file
diff --git a/product_abc_classification_finance/__manifest__.py b/product_abc_classification_finance/__manifest__.py
new file mode 100644
index 00000000000..37cfa2175d2
--- /dev/null
+++ b/product_abc_classification_finance/__manifest__.py
@@ -0,0 +1,18 @@
+{
+ "name": "Product ABC Classification Finance",
+ "summary": "Financial ABC analysis for products (cost, sale price, margin)",
+ "version": "16.0.1.0.0",
+ "category": "Inventory/Inventory",
+ "author": "AJamal13",
+ "license": "AGPL-3",
+ "website": "https://github.com/AJamal13",
+ "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",
+ ],
+ "installable": True,
+ "application": False,
+}
diff --git a/product_abc_classification_finance/models/__init__.py b/product_abc_classification_finance/models/__init__.py
new file mode 100644
index 00000000000..0419c3edc21
--- /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
\ No newline at end of file
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..a63b2a2c831
--- /dev/null
+++ b/product_abc_classification_finance/models/abc_classification_product_level.py
@@ -0,0 +1,9 @@
+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..ceb9b167a12
--- /dev/null
+++ b/product_abc_classification_finance/models/abc_classification_profile.py
@@ -0,0 +1,391 @@
+import csv
+import logging
+from datetime import datetime, timedelta
+from io import StringIO
+from operator import attrgetter
+
+from odoo import _, api, fields, models
+from odoo.exceptions import UserError, ValidationError
+from odoo.tools import float_round
+
+_logger = logging.getLogger(__name__)
+
+class AbcClassificationProfile(models.Model):
+ _inherit = "abc.classification.profile"
+ _logger = _logger
+
+ 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",
+ ),
+ (
+ "sale_margin",
+ "Based on the Sale Margin of delivered sale order line by product",
+ ),
+ ],
+ ondelete={"cost": "cascade", "sale_price": "cascade", "sale_margin": "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", "sale_margin"] 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
+ - margin: SUM(sol.margin) as total_margin (already line-level total)
+ """
+ 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'
+ elif self.profile_type == 'sale_margin':
+ select_col = 'SUM(sol.margin) AS total_margin'
+ order_col = 'total_margin'
+
+ 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", "sale_margin"):
+ raise UserError(_("Profile type must be cost, sale_price or sale_margin"))
+ 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
+ finance_data.margin = 0.0
+ elif self.profile_type == "sale_price":
+ finance_data.total_cost = 0.0
+ finance_data.total_sales = float(r[1] or 0.0)
+ finance_data.margin = 0.0
+ elif self.profile_type == "sale_margin":
+ margin_val = float(r[1] or 0.0)
+ finance_data.total_cost = 0.0
+ finance_data.total_sales = 0.0
+ finance_data.margin = margin_val
+ if margin_val < 0:
+ exclude_from_abc = True
+ # Always set purchase_price (standard cost) from product.template
+ tmpl = finance_data.product.product_tmpl_id
+ finance_data.purchase_price = float(getattr(tmpl, 'standard_price', 0.0) 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)
+
+ # Optionally, handle negative-margin products separately here (e.g., assign to a special class or report them)
+
+ # 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(getattr(finance_data.product.product_tmpl_id, 'standard_price', 0.0) or 0.0)
+ finance_data.total_cost = 0.0
+ finance_data.total_sales = 0.0
+ finance_data.margin = 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", "sale_margin")
+ 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()._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"
+ elif profile.profile_type == "sale_margin":
+ value_field = "margin"
+ 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
+ )
+ )
+ # Debug logging for cumulative percentage
+ _logger.info(
+ "[ABC] Product %s: value=%.4f, total_value=%.4f, percentage=%.4f, cumulated_percentage=%.4f",
+ finance_data.product.display_name,
+ value,
+ total_value,
+ finance_data.percentage,
+ finance_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:
+ _logger.info(
+ "[ABC] ERROR: Product %s cumulative percentage exceeded: %.4f (value=%.4f, total_value=%.4f)",
+ finance_data.product.display_name,
+ finance_data.cumulated_percentage,
+ value,
+ total_value,
+ )
+ 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:
+ # The line is still significant...
+ 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=';')
+
+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",
+ "margin",
+ "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),
+ float(self.margin 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",
+ "margin",
+ "product_level_id",
+ "from_date",
+ "to_date",
+ "total_products",
+ "percentage_products",
+ "cumulated_percentage_products",
+ "sum_cumulated_percentages",
+ ]
\ No newline at end of file
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..2510376655e
--- /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,
+ )
+ margin = fields.Float(
+ "Margin",
+ 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",
+ )
+ ranking = fields.Integer("Ranking", readonly=True)
+ percentage = fields.Float("Percentage", readonly=True)
+ cumulated_percentage = fields.Float("Cumulated Percentage", readonly=True)
+ standard_cost = fields.Float("Standard Cost", readonly=True)
+ total_cost = fields.Float("Total Cost", readonly=True)
+ total_sales = fields.Float("Total Sales", readonly=True)
+ margin = fields.Float("Margin", 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/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/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..5a7d47d24c9
--- /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..3df8cdc60e3
--- /dev/null
+++ b/product_abc_classification_finance/views/abc_classification_profile.xml
@@ -0,0 +1,14 @@
+
+
+
+
+ abc.classification.profile.form (finance inherit)
+ abc.classification.profile
+
+
+
+ {'required': [('profile_type', 'in', ['sale_stock', 'cost', 'sale_price', 'margin'])], '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..a1301f6d5b4
--- /dev/null
+++ b/product_abc_classification_finance/views/abc_finance_sale_level_history_views.xml
@@ -0,0 +1,61 @@
+
+
+ abc.finance.sale.level.history.tree
+ abc.finance.sale.level.history
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ abc.finance.sale.level.history.form
+ abc.finance.sale.level.history
+
+
+
+
+
From 46950db97c4dc157670f264eb7068d49f302fa17 Mon Sep 17 00:00:00 2001
From: AJamal13 <135590484+AJamal13@users.noreply.github.com>
Date: Sat, 21 Jun 2025 22:20:27 +0300
Subject: [PATCH 2/8] Update __manifest__.py
---
product_abc_classification_finance/__manifest__.py | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/product_abc_classification_finance/__manifest__.py b/product_abc_classification_finance/__manifest__.py
index 37cfa2175d2..a843d6aa9d0 100644
--- a/product_abc_classification_finance/__manifest__.py
+++ b/product_abc_classification_finance/__manifest__.py
@@ -1,11 +1,11 @@
{
"name": "Product ABC Classification Finance",
"summary": "Financial ABC analysis for products (cost, sale price, margin)",
- "version": "16.0.1.0.0",
+ "version": "16.0.1.0.2",
"category": "Inventory/Inventory",
- "author": "AJamal13",
+ "author": "AJamal13,Odoo Community Association (OCA)",
"license": "AGPL-3",
- "website": "https://github.com/AJamal13",
+ "website": "https://github.com/OCA/product-attribute",
"depends": ["product_abc_classification_sale_stock","sale_margin"],
"data": [
"security/ir.model.access.csv",
From fd51b29dca6d23c6802588d20a0e21302b4e86e8 Mon Sep 17 00:00:00 2001
From: AJamal13 <135590484+AJamal13@users.noreply.github.com>
Date: Tue, 24 Jun 2025 00:29:33 +0300
Subject: [PATCH 3/8] Add demo data and tests for finance ABC classification
Introduces demo data for finance-based ABC classification, including new XML records and manifest updates. Adds a comprehensive test suite for finance profile logic, validation, and history record creation. Also includes code cleanup, improved formatting, and minor view XML attribute adjustments for consistency.
---
.../__init__.py | 2 +-
.../__manifest__.py | 5 +-
.../data/abc_classification_finance_demo.xml | 29 ++++
.../models/__init__.py | 2 +-
.../models/abc_classification_profile.py | 116 +++++++-------
.../tests/__init__.py | 1 +
.../tests/test_abc_classification_finance.py | 141 ++++++++++++++++++
...abc_classification_product_level_views.xml | 16 +-
.../views/abc_classification_profile.xml | 9 +-
.../abc_finance_sale_level_history_views.xml | 76 +++++-----
10 files changed, 291 insertions(+), 106 deletions(-)
create mode 100644 product_abc_classification_finance/data/abc_classification_finance_demo.xml
create mode 100644 product_abc_classification_finance/tests/__init__.py
create mode 100644 product_abc_classification_finance/tests/test_abc_classification_finance.py
diff --git a/product_abc_classification_finance/__init__.py b/product_abc_classification_finance/__init__.py
index 9a7e03eded3..0650744f6bc 100644
--- a/product_abc_classification_finance/__init__.py
+++ b/product_abc_classification_finance/__init__.py
@@ -1 +1 @@
-from . import models
\ No newline at end of file
+from . import models
diff --git a/product_abc_classification_finance/__manifest__.py b/product_abc_classification_finance/__manifest__.py
index a843d6aa9d0..ec32e9bef89 100644
--- a/product_abc_classification_finance/__manifest__.py
+++ b/product_abc_classification_finance/__manifest__.py
@@ -6,13 +6,16 @@
"author": "AJamal13,Odoo Community Association (OCA)",
"license": "AGPL-3",
"website": "https://github.com/OCA/product-attribute",
- "depends": ["product_abc_classification_sale_stock","sale_margin"],
+ "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",
+ ],
"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..242132b73c4
--- /dev/null
+++ b/product_abc_classification_finance/data/abc_classification_finance_demo.xml
@@ -0,0 +1,29 @@
+
+
+
+
+
+ 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
index 0419c3edc21..154290b3b06 100644
--- a/product_abc_classification_finance/models/__init__.py
+++ b/product_abc_classification_finance/models/__init__.py
@@ -1,3 +1,3 @@
from . import abc_classification_profile
from . import abc_classification_product_level
-from . import abc_finance_sale_level_history
\ No newline at end of file
+from . import abc_finance_sale_level_history
diff --git a/product_abc_classification_finance/models/abc_classification_profile.py b/product_abc_classification_finance/models/abc_classification_profile.py
index ceb9b167a12..78f500e9987 100644
--- a/product_abc_classification_finance/models/abc_classification_profile.py
+++ b/product_abc_classification_finance/models/abc_classification_profile.py
@@ -1,9 +1,5 @@
-import csv
import logging
from datetime import datetime, timedelta
-from io import StringIO
-from operator import attrgetter
-
from odoo import _, api, fields, models
from odoo.exceptions import UserError, ValidationError
from odoo.tools import float_round
@@ -35,7 +31,10 @@ class AbcClassificationProfile(models.Model):
@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", "sale_margin"] and not rec.warehouse_id:
+ if (
+ rec.profile_type in ["sale_stock", "cost", "sale_price", "sale_margin"]
+ and not rec.warehouse_id
+ ):
raise ValidationError(
_("You must specify a warehouse for {profile_name}").format(
profile_name=rec.name
@@ -59,15 +58,15 @@ def _get_finance_data_query(self, from_date, customer_location_ids):
- sale_price: SUM(sol.price_unit * sol.qty_delivered) as total_sales
- margin: SUM(sol.margin) as total_margin (already line-level total)
"""
- 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'
- elif self.profile_type == 'sale_margin':
- select_col = 'SUM(sol.margin) AS total_margin'
- order_col = 'total_margin'
+ 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"
+ elif self.profile_type == "sale_margin":
+ select_col = "SUM(sol.margin) AS total_margin"
+ order_col = "total_margin"
query = f"""
SELECT
@@ -108,8 +107,12 @@ def _finance_get_data(self, from_date=None):
self.ensure_one()
if self.profile_type not in ("cost", "sale_price", "sale_margin"):
raise UserError(_("Profile type must be cost, sale_price or sale_margin"))
- from_date = from_date if from_date else fields.Datetime.to_string(
- datetime.today() - timedelta(days=self.period)
+ 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 = (
@@ -149,7 +152,9 @@ def _finance_get_data(self, from_date=None):
exclude_from_abc = True
# Always set purchase_price (standard cost) from product.template
tmpl = finance_data.product.product_tmpl_id
- finance_data.purchase_price = float(getattr(tmpl, 'standard_price', 0.0) or 0.0)
+ finance_data.purchase_price = float(
+ getattr(tmpl, 'standard_price', 0.0) or 0.0
+ )
finance_data.ranking = ranking
finance_data.from_date = from_date
finance_data.to_date = to_date
@@ -159,13 +164,14 @@ def _finance_get_data(self, from_date=None):
finance_data_list.append(finance_data)
all_product_ids.remove(product_id)
- # Optionally, handle negative-margin products separately here (e.g., assign to a special class or report them)
-
+ # Optionally, handle negative-margin products separately here (e.g., assign to a special class or report them)
# 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(getattr(finance_data.product.product_tmpl_id, 'standard_price', 0.0) or 0.0)
+ finance_data.purchase_price = float(
+ getattr(finance_data.product.product_tmpl_id, 'standard_price', 0.0) or 0.0
+ )
finance_data.total_cost = 0.0
finance_data.total_sales = 0.0
finance_data.margin = 0.0
@@ -218,7 +224,9 @@ def _compute_abc_classification(self):
elif profile.profile_type == "sale_margin":
value_field = "margin"
else:
- raise UserError(_(f"Unknown finance profile_type: {profile.profile_type}"))
+ 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
@@ -239,9 +247,7 @@ def _compute_abc_classification(self):
finance_data.cumulated_percentage = (
finance_data.percentage
if i == 0
- else (
- finance_data.percentage + previous_data.cumulated_percentage
- )
+ else (finance_data.percentage + previous_data.cumulated_percentage)
)
# Debug logging for cumulative percentage
_logger.info(
@@ -261,7 +267,9 @@ def _compute_abc_classification(self):
value,
total_value,
)
- raise UserError(_("Cumulative percentage greater than 100 (actual: %.4f)." % finance_data.cumulated_percentage))
+ 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
@@ -283,14 +291,10 @@ def _compute_abc_classification(self):
# The line is still significant...
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
- )
+ 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
- )
+ 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
@@ -304,14 +308,16 @@ def _finance_log_history(self, finance_data_list):
import csv
import io
cr = self.env.cr
- table = 'abc_finance_sale_level_history'
+ table = "abc_finance_sale_level_history"
columns = FinanceSaleData._get_col_names()
buf = io.StringIO()
- writer = csv.writer(buf, delimiter=';', lineterminator='\n')
+ 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=';')
+ cr.copy_from(buf, table, columns=columns, sep=";")
+ # Ensure ORM sees the new records for tests
+ self.env['abc.finance.sale.level.history'].flush()
class FinanceSaleData(object):
"""Finance ABC classification data
@@ -328,10 +334,10 @@ class FinanceSaleData(object):
"ranking",
"percentage",
"cumulated_percentage",
- "purchase_price",
- "total_cost",
- "total_sales",
- "margin",
+ "purchase_price",
+ "total_cost",
+ "total_sales",
+ "margin",
"product_level",
"from_date",
"to_date",
@@ -349,20 +355,20 @@ def _to_csv_line(self):
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),
- float(self.margin or 0.0),
+ 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),
+ float(self.margin 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,
+ 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
@@ -377,10 +383,10 @@ def _get_col_names(cls):
"ranking",
"percentage",
"cumulated_percentage",
- "purchase_price",
- "total_cost",
- "total_sales",
- "margin",
+ "purchase_price",
+ "total_cost",
+ "total_sales",
+ "margin",
"product_level_id",
"from_date",
"to_date",
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..621b7c2c4b7
--- /dev/null
+++ b/product_abc_classification_finance/tests/test_abc_classification_finance.py
@@ -0,0 +1,141 @@
+# Copyright 2025 Your Company
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+
+import logging
+from odoo.tests.common import TransactionCase
+from odoo.exceptions import ValidationError
+
+_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': 'Finance Cost Profile',
+ 'profile_type': 'cost',
+ 'warehouse_id': cls.warehouse.id,
+ })
+
+ # 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_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', 'sale_margin'.
+ """
+ for profile_type in ['cost', 'sale_price', 'sale_margin']:
+ 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,
+ 'margin': 2.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_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,
+ })
+ 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})
+ ]
+ }
+ )
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
index 5a7d47d24c9..50300a111b0 100644
--- a/product_abc_classification_finance/views/abc_classification_product_level_views.xml
+++ b/product_abc_classification_finance/views/abc_classification_product_level_views.xml
@@ -4,10 +4,10 @@
abc.classification.product.level
-
-
-
-
+
+
+
+
@@ -18,10 +18,10 @@
diff --git a/product_abc_classification_finance/views/abc_classification_profile.xml b/product_abc_classification_finance/views/abc_classification_profile.xml
index 3df8cdc60e3..a2c0b6872dd 100644
--- a/product_abc_classification_finance/views/abc_classification_profile.xml
+++ b/product_abc_classification_finance/views/abc_classification_profile.xml
@@ -4,10 +4,15 @@
abc.classification.profile.form (finance inherit)
abc.classification.profile
-
+
- {'required': [('profile_type', 'in', ['sale_stock', 'cost', 'sale_price', 'margin'])], 'invisible': []}
+ {'required': [('profile_type', 'in', ['sale_stock', 'cost', 'sale_price', 'margin'])], '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
index a1301f6d5b4..702d3481511 100644
--- 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
@@ -4,25 +4,25 @@
abc.finance.sale.level.history
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -34,25 +34,25 @@
From 0d86ee5acd19d358cfcf8c54d840ab1c1f636253 Mon Sep 17 00:00:00 2001
From: AJamal13 <135590484+AJamal13@users.noreply.github.com>
Date: Tue, 24 Jun 2025 00:51:43 +0300
Subject: [PATCH 4/8] fix checks
---
.../data/abc_classification_finance_demo.xml | 14 +-
.../models/abc_classification_profile.py | 22 +--
.../tests/test_abc_classification_finance.py | 125 +++++++++---------
.../views/abc_classification_profile.xml | 10 +-
4 files changed, 92 insertions(+), 79 deletions(-)
diff --git a/product_abc_classification_finance/data/abc_classification_finance_demo.xml b/product_abc_classification_finance/data/abc_classification_finance_demo.xml
index 242132b73c4..0ccbceb9d1e 100644
--- a/product_abc_classification_finance/data/abc_classification_finance_demo.xml
+++ b/product_abc_classification_finance/data/abc_classification_finance_demo.xml
@@ -1,4 +1,4 @@
-
+
@@ -19,11 +19,17 @@
-
+
Finance Cost Profile
cost
-
+
365
-
+
diff --git a/product_abc_classification_finance/models/abc_classification_profile.py b/product_abc_classification_finance/models/abc_classification_profile.py
index 78f500e9987..508553b0b8e 100644
--- a/product_abc_classification_finance/models/abc_classification_profile.py
+++ b/product_abc_classification_finance/models/abc_classification_profile.py
@@ -119,9 +119,7 @@ def _finance_get_data(self, from_date=None):
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
- )
+ 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
@@ -153,7 +151,7 @@ def _finance_get_data(self, from_date=None):
# Always set purchase_price (standard cost) from product.template
tmpl = finance_data.product.product_tmpl_id
finance_data.purchase_price = float(
- getattr(tmpl, 'standard_price', 0.0) or 0.0
+ getattr(tmpl, "standard_price", 0.0) or 0.0
)
finance_data.ranking = ranking
finance_data.from_date = from_date
@@ -170,7 +168,8 @@ def _finance_get_data(self, from_date=None):
finance_data = self._finance_init_collected_data_instance()
finance_data.product = ProductProduct.browse(product_id)
finance_data.purchase_price = float(
- getattr(finance_data.product.product_tmpl_id, 'standard_price', 0.0) or 0.0
+ getattr(finance_data.product.product_tmpl_id, "standard_price", 0.0)
+ or 0.0
)
finance_data.total_cost = 0.0
finance_data.total_sales = 0.0
@@ -267,9 +266,12 @@ def _compute_abc_classification(self):
value,
total_value,
)
- raise UserError(_("Cumulative percentage greater than 100 (actual: %.4f)."
- % finance_data.cumulated_percentage)
- )
+ 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
@@ -317,7 +319,7 @@ def _finance_log_history(self, finance_data_list):
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()
+ self.env["abc.finance.sale.level.history"].flush()
class FinanceSaleData(object):
"""Finance ABC classification data
@@ -394,4 +396,4 @@ def _get_col_names(cls):
"percentage_products",
"cumulated_percentage_products",
"sum_cumulated_percentages",
- ]
\ No newline at end of file
+ ]
diff --git a/product_abc_classification_finance/tests/test_abc_classification_finance.py b/product_abc_classification_finance/tests/test_abc_classification_finance.py
index 621b7c2c4b7..5fc64955c3a 100644
--- a/product_abc_classification_finance/tests/test_abc_classification_finance.py
+++ b/product_abc_classification_finance/tests/test_abc_classification_finance.py
@@ -2,8 +2,8 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
-from odoo.tests.common import TransactionCase
from odoo.exceptions import ValidationError
+from odoo.tests.common import TransactionCase
_logger = logging.getLogger(__name__)
@@ -14,28 +14,29 @@ def setUpClass(cls):
cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True))
# Create test warehouse
- cls.warehouse = cls.env['stock.warehouse'].search([], limit=1)
+ 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',
+ 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': 'Finance Cost Profile',
- 'profile_type': 'cost',
- 'warehouse_id': cls.warehouse.id,
+ 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_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',
@@ -45,22 +46,22 @@ def setUpClass(cls):
})
# 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',
+ 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_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,
+ profile = self.env["abc.classification.profile"].new({
+ "name": "No Warehouse",
+ "profile_type": "cost",
+ "warehouse_id": False,
})
with self.assertRaises(ValidationError):
profile._check_warehouse_id()
@@ -69,11 +70,11 @@ def test_finance_profile_type_selection(self):
"""
Profile type should accept 'cost', 'sale_price', 'sale_margin'.
"""
- for profile_type in ['cost', 'sale_price', 'sale_margin']:
- profile = self.env['abc.classification.profile'].create({
- 'name': f'Profile {profile_type}',
- 'profile_type': profile_type,
- 'warehouse_id': self.warehouse.id,
+ for profile_type in ["cost", "sale_price", "sale_margin"]:
+ 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)
@@ -81,21 +82,21 @@ 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,
+ 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,
- 'margin': 2.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,
+ 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,
+ "margin": 2.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)
@@ -105,14 +106,14 @@ 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'
+ 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)
+ 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)
@@ -120,22 +121,26 @@ 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,
+ profile = self.env["abc.classification.profile"].create({
+ "name": "Partial Level Profile",
+ "profile_type": "cost",
+ "warehouse_id": self.warehouse.id,
})
- self.env['abc.classification.level'].create({
- 'name': 'A',
- 'profile_id': profile.id,
- 'percentage': 60,
- 'percentage_products': 40,
+ 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})
+ "level_ids": [
+ (
+ 0,
+ 0,
+ {"name": "B", "percentage": 10, "percentage_products": 10},
+ )
]
}
)
diff --git a/product_abc_classification_finance/views/abc_classification_profile.xml b/product_abc_classification_finance/views/abc_classification_profile.xml
index a2c0b6872dd..140392f108e 100644
--- a/product_abc_classification_finance/views/abc_classification_profile.xml
+++ b/product_abc_classification_finance/views/abc_classification_profile.xml
@@ -5,14 +5,14 @@
abc.classification.profile.form (finance inherit)
abc.classification.profile
+ name="inherit_id"
+ ref="product_abc_classification_sale_stock.abc_classification_profile_form_view"
+ />
{'required': [('profile_type', 'in', ['sale_stock', 'cost', 'sale_price', 'margin'])], 'invisible': []}
+ name="attrs"
+ >{'required': [('profile_type', 'in', ['sale_stock', 'cost', 'sale_price', 'margin'])], 'invisible': []}
From 5ca0af07bc4707b8013dea0c3aa5536ab27b60b4 Mon Sep 17 00:00:00 2001
From: AJamal13 <135590484+AJamal13@users.noreply.github.com>
Date: Tue, 24 Jun 2025 01:31:52 +0300
Subject: [PATCH 5/8] Add tests for finance profile validation and margin
exclusion
Added tests to validate that finance profile levels must total 100%, ensure products with negative margin are excluded from ABC ranking for 'sale_margin' profiles, and verify that _get_finance_data_query returns correct SQL for all profile types.
---
.../tests/test_abc_classification_finance.py | 102 ++++++++++++++++++
1 file changed, 102 insertions(+)
diff --git a/product_abc_classification_finance/tests/test_abc_classification_finance.py b/product_abc_classification_finance/tests/test_abc_classification_finance.py
index 5fc64955c3a..d00b23ea4dd 100644
--- a/product_abc_classification_finance/tests/test_abc_classification_finance.py
+++ b/product_abc_classification_finance/tests/test_abc_classification_finance.py
@@ -118,6 +118,108 @@ def test_finance_data_query_methods(self):
self.assertEqual(data_instance.profile, self.finance_profile)
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_negative_margin_exclusion(self):
+ """
+ Products with negative margin should be excluded from ABC ranking for 'sale_margin' profile.
+ """
+ from unittest.mock import patch
+ profile = self.env["abc.classification.profile"].create({
+ "name": "Margin Profile",
+ "profile_type": "sale_margin",
+ "warehouse_id": self.warehouse.id,
+ "period": 365,
+ })
+ self.env["abc.classification.level"].create({
+ "name": "A",
+ "profile_id": profile.id,
+ "percentage": 100,
+ "percentage_products": 100,
+ })
+ product = self.env["product.product"].create({
+ "name": "Negative Margin Product",
+ "uom_id": self.env.ref("uom.product_uom_unit").id,
+ "type": "product",
+ "default_code": "NEG001",
+ "tracking": "none",
+ })
+ # Use a real FinanceSaleData object and set margin negative
+ from ..models.abc_classification_profile import FinanceSaleData
+ finance_data = FinanceSaleData()
+ finance_data.product = product
+ finance_data.profile = profile
+ finance_data.computed_level = None
+ finance_data.product_level = None
+ finance_data.ranking = 1
+ finance_data.percentage = 0
+ finance_data.cumulated_percentage = 0
+ finance_data.purchase_price = 0
+ finance_data.total_cost = 0
+ finance_data.total_sales = 0
+ finance_data.margin = -100.0
+ finance_data.from_date = '2025-01-01'
+ finance_data.to_date = '2025-12-31'
+ finance_data.total_products = 1
+ finance_data.percentage_products = 100
+ finance_data.cumulated_percentage_products = 100
+ finance_data.sum_cumulated_percentages = 100
+
+ with patch.object(type(profile), "_finance_get_data", return_value=([finance_data], 0)), \
+ patch.object(type(profile), "_finance_log_history", return_value=None):
+ try:
+ profile._compute_abc_classification()
+ except Exception as e:
+ self.fail(f"Negative margin exclusion raised unexpected error: {e}")
+
+ 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"),
+ ("sale_margin", "SUM(sol.margin) AS total_margin"),
+ ]
+ 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)
+
"""
Test that levels for a finance profile must total 100%.
"""
From e1d7e1e48e86afb3fef9f491a9eacd5abf8a165d Mon Sep 17 00:00:00 2001
From: AJamal13 <135590484+AJamal13@users.noreply.github.com>
Date: Tue, 24 Jun 2025 00:29:33 +0300
Subject: [PATCH 6/8] Add tests for finance profile validation and margin
exclusion
Added tests to validate that finance profile levels must total 100%, ensure products with negative margin are excluded from ABC ranking for 'sale_margin' profiles, and verify that _get_finance_data_query returns correct SQL for all profile types.
Add demo data and tests for finance ABC classification
Introduces demo data for finance-based ABC classification, including new XML records and manifest updates. Adds a comprehensive test suite for finance profile logic, validation, and history record creation. Also includes code cleanup, improved formatting, and minor view XML attribute adjustments for consistency.
fix checks
---
.../__init__.py | 2 +-
.../__manifest__.py | 5 +-
.../data/abc_classification_finance_demo.xml | 35 +++
.../models/__init__.py | 2 +-
.../models/abc_classification_profile.py | 126 ++++-----
.../tests/__init__.py | 1 +
.../tests/test_abc_classification_finance.py | 248 ++++++++++++++++++
...abc_classification_product_level_views.xml | 16 +-
.../views/abc_classification_profile.xml | 9 +-
.../abc_finance_sale_level_history_views.xml | 76 +++---
10 files changed, 410 insertions(+), 110 deletions(-)
create mode 100644 product_abc_classification_finance/data/abc_classification_finance_demo.xml
create mode 100644 product_abc_classification_finance/tests/__init__.py
create mode 100644 product_abc_classification_finance/tests/test_abc_classification_finance.py
diff --git a/product_abc_classification_finance/__init__.py b/product_abc_classification_finance/__init__.py
index 9a7e03eded3..0650744f6bc 100644
--- a/product_abc_classification_finance/__init__.py
+++ b/product_abc_classification_finance/__init__.py
@@ -1 +1 @@
-from . import models
\ No newline at end of file
+from . import models
diff --git a/product_abc_classification_finance/__manifest__.py b/product_abc_classification_finance/__manifest__.py
index a843d6aa9d0..ec32e9bef89 100644
--- a/product_abc_classification_finance/__manifest__.py
+++ b/product_abc_classification_finance/__manifest__.py
@@ -6,13 +6,16 @@
"author": "AJamal13,Odoo Community Association (OCA)",
"license": "AGPL-3",
"website": "https://github.com/OCA/product-attribute",
- "depends": ["product_abc_classification_sale_stock","sale_margin"],
+ "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",
+ ],
"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..0ccbceb9d1e
--- /dev/null
+++ b/product_abc_classification_finance/data/abc_classification_finance_demo.xml
@@ -0,0 +1,35 @@
+
+
+
+
+
+ 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
index 0419c3edc21..154290b3b06 100644
--- a/product_abc_classification_finance/models/__init__.py
+++ b/product_abc_classification_finance/models/__init__.py
@@ -1,3 +1,3 @@
from . import abc_classification_profile
from . import abc_classification_product_level
-from . import abc_finance_sale_level_history
\ No newline at end of file
+from . import abc_finance_sale_level_history
diff --git a/product_abc_classification_finance/models/abc_classification_profile.py b/product_abc_classification_finance/models/abc_classification_profile.py
index ceb9b167a12..508553b0b8e 100644
--- a/product_abc_classification_finance/models/abc_classification_profile.py
+++ b/product_abc_classification_finance/models/abc_classification_profile.py
@@ -1,9 +1,5 @@
-import csv
import logging
from datetime import datetime, timedelta
-from io import StringIO
-from operator import attrgetter
-
from odoo import _, api, fields, models
from odoo.exceptions import UserError, ValidationError
from odoo.tools import float_round
@@ -35,7 +31,10 @@ class AbcClassificationProfile(models.Model):
@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", "sale_margin"] and not rec.warehouse_id:
+ if (
+ rec.profile_type in ["sale_stock", "cost", "sale_price", "sale_margin"]
+ and not rec.warehouse_id
+ ):
raise ValidationError(
_("You must specify a warehouse for {profile_name}").format(
profile_name=rec.name
@@ -59,15 +58,15 @@ def _get_finance_data_query(self, from_date, customer_location_ids):
- sale_price: SUM(sol.price_unit * sol.qty_delivered) as total_sales
- margin: SUM(sol.margin) as total_margin (already line-level total)
"""
- 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'
- elif self.profile_type == 'sale_margin':
- select_col = 'SUM(sol.margin) AS total_margin'
- order_col = 'total_margin'
+ 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"
+ elif self.profile_type == "sale_margin":
+ select_col = "SUM(sol.margin) AS total_margin"
+ order_col = "total_margin"
query = f"""
SELECT
@@ -108,17 +107,19 @@ def _finance_get_data(self, from_date=None):
self.ensure_one()
if self.profile_type not in ("cost", "sale_price", "sale_margin"):
raise UserError(_("Profile type must be cost, sale_price or sale_margin"))
- from_date = from_date if from_date else fields.Datetime.to_string(
- datetime.today() - timedelta(days=self.period)
+ 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
- )
+ 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
@@ -149,7 +150,9 @@ def _finance_get_data(self, from_date=None):
exclude_from_abc = True
# Always set purchase_price (standard cost) from product.template
tmpl = finance_data.product.product_tmpl_id
- finance_data.purchase_price = float(getattr(tmpl, 'standard_price', 0.0) or 0.0)
+ finance_data.purchase_price = float(
+ getattr(tmpl, "standard_price", 0.0) or 0.0
+ )
finance_data.ranking = ranking
finance_data.from_date = from_date
finance_data.to_date = to_date
@@ -159,13 +162,15 @@ def _finance_get_data(self, from_date=None):
finance_data_list.append(finance_data)
all_product_ids.remove(product_id)
- # Optionally, handle negative-margin products separately here (e.g., assign to a special class or report them)
-
+ # Optionally, handle negative-margin products separately here (e.g., assign to a special class or report them)
# 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(getattr(finance_data.product.product_tmpl_id, 'standard_price', 0.0) or 0.0)
+ finance_data.purchase_price = float(
+ getattr(finance_data.product.product_tmpl_id, "standard_price", 0.0)
+ or 0.0
+ )
finance_data.total_cost = 0.0
finance_data.total_sales = 0.0
finance_data.margin = 0.0
@@ -218,7 +223,9 @@ def _compute_abc_classification(self):
elif profile.profile_type == "sale_margin":
value_field = "margin"
else:
- raise UserError(_(f"Unknown finance profile_type: {profile.profile_type}"))
+ 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
@@ -239,9 +246,7 @@ def _compute_abc_classification(self):
finance_data.cumulated_percentage = (
finance_data.percentage
if i == 0
- else (
- finance_data.percentage + previous_data.cumulated_percentage
- )
+ else (finance_data.percentage + previous_data.cumulated_percentage)
)
# Debug logging for cumulative percentage
_logger.info(
@@ -261,7 +266,12 @@ def _compute_abc_classification(self):
value,
total_value,
)
- raise UserError(_("Cumulative percentage greater than 100 (actual: %.4f)." % finance_data.cumulated_percentage))
+ 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
@@ -283,14 +293,10 @@ def _compute_abc_classification(self):
# The line is still significant...
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
- )
+ 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
- )
+ 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
@@ -304,14 +310,16 @@ def _finance_log_history(self, finance_data_list):
import csv
import io
cr = self.env.cr
- table = 'abc_finance_sale_level_history'
+ table = "abc_finance_sale_level_history"
columns = FinanceSaleData._get_col_names()
buf = io.StringIO()
- writer = csv.writer(buf, delimiter=';', lineterminator='\n')
+ 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=';')
+ cr.copy_from(buf, table, columns=columns, sep=";")
+ # Ensure ORM sees the new records for tests
+ self.env["abc.finance.sale.level.history"].flush()
class FinanceSaleData(object):
"""Finance ABC classification data
@@ -328,10 +336,10 @@ class FinanceSaleData(object):
"ranking",
"percentage",
"cumulated_percentage",
- "purchase_price",
- "total_cost",
- "total_sales",
- "margin",
+ "purchase_price",
+ "total_cost",
+ "total_sales",
+ "margin",
"product_level",
"from_date",
"to_date",
@@ -349,20 +357,20 @@ def _to_csv_line(self):
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),
- float(self.margin or 0.0),
+ 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),
+ float(self.margin 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,
+ 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
@@ -377,10 +385,10 @@ def _get_col_names(cls):
"ranking",
"percentage",
"cumulated_percentage",
- "purchase_price",
- "total_cost",
- "total_sales",
- "margin",
+ "purchase_price",
+ "total_cost",
+ "total_sales",
+ "margin",
"product_level_id",
"from_date",
"to_date",
@@ -388,4 +396,4 @@ def _get_col_names(cls):
"percentage_products",
"cumulated_percentage_products",
"sum_cumulated_percentages",
- ]
\ No newline at end of file
+ ]
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..d00b23ea4dd
--- /dev/null
+++ b/product_abc_classification_finance/tests/test_abc_classification_finance.py
@@ -0,0 +1,248 @@
+# 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_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', 'sale_margin'.
+ """
+ for profile_type in ["cost", "sale_price", "sale_margin"]:
+ 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,
+ "margin": 2.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_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_negative_margin_exclusion(self):
+ """
+ Products with negative margin should be excluded from ABC ranking for 'sale_margin' profile.
+ """
+ from unittest.mock import patch
+ profile = self.env["abc.classification.profile"].create({
+ "name": "Margin Profile",
+ "profile_type": "sale_margin",
+ "warehouse_id": self.warehouse.id,
+ "period": 365,
+ })
+ self.env["abc.classification.level"].create({
+ "name": "A",
+ "profile_id": profile.id,
+ "percentage": 100,
+ "percentage_products": 100,
+ })
+ product = self.env["product.product"].create({
+ "name": "Negative Margin Product",
+ "uom_id": self.env.ref("uom.product_uom_unit").id,
+ "type": "product",
+ "default_code": "NEG001",
+ "tracking": "none",
+ })
+ # Use a real FinanceSaleData object and set margin negative
+ from ..models.abc_classification_profile import FinanceSaleData
+ finance_data = FinanceSaleData()
+ finance_data.product = product
+ finance_data.profile = profile
+ finance_data.computed_level = None
+ finance_data.product_level = None
+ finance_data.ranking = 1
+ finance_data.percentage = 0
+ finance_data.cumulated_percentage = 0
+ finance_data.purchase_price = 0
+ finance_data.total_cost = 0
+ finance_data.total_sales = 0
+ finance_data.margin = -100.0
+ finance_data.from_date = '2025-01-01'
+ finance_data.to_date = '2025-12-31'
+ finance_data.total_products = 1
+ finance_data.percentage_products = 100
+ finance_data.cumulated_percentage_products = 100
+ finance_data.sum_cumulated_percentages = 100
+
+ with patch.object(type(profile), "_finance_get_data", return_value=([finance_data], 0)), \
+ patch.object(type(profile), "_finance_log_history", return_value=None):
+ try:
+ profile._compute_abc_classification()
+ except Exception as e:
+ self.fail(f"Negative margin exclusion raised unexpected error: {e}")
+
+ 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"),
+ ("sale_margin", "SUM(sol.margin) AS total_margin"),
+ ]
+ 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)
+
+ """
+ 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,
+ })
+ 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},
+ )
+ ]
+ }
+ )
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
index 5a7d47d24c9..50300a111b0 100644
--- a/product_abc_classification_finance/views/abc_classification_product_level_views.xml
+++ b/product_abc_classification_finance/views/abc_classification_product_level_views.xml
@@ -4,10 +4,10 @@
abc.classification.product.level
-
-
-
-
+
+
+
+
@@ -18,10 +18,10 @@
diff --git a/product_abc_classification_finance/views/abc_classification_profile.xml b/product_abc_classification_finance/views/abc_classification_profile.xml
index 3df8cdc60e3..140392f108e 100644
--- a/product_abc_classification_finance/views/abc_classification_profile.xml
+++ b/product_abc_classification_finance/views/abc_classification_profile.xml
@@ -4,10 +4,15 @@
abc.classification.profile.form (finance inherit)
abc.classification.profile
-
+
- {'required': [('profile_type', 'in', ['sale_stock', 'cost', 'sale_price', 'margin'])], 'invisible': []}
+ {'required': [('profile_type', 'in', ['sale_stock', 'cost', 'sale_price', 'margin'])], '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
index a1301f6d5b4..702d3481511 100644
--- 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
@@ -4,25 +4,25 @@
abc.finance.sale.level.history
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -34,25 +34,25 @@
From f77be245f4aca14ffe2f4755c9cfa0bdd8d248a7 Mon Sep 17 00:00:00 2001
From: AJamal13 <135590484+AJamal13@users.noreply.github.com>
Date: Tue, 24 Jun 2025 01:40:47 +0300
Subject: [PATCH 7/8] Revert "Add tests for finance profile validation and
margin exclusion"
This reverts commit e1d7e1e48e86afb3fef9f491a9eacd5abf8a165d.
---
.../__init__.py | 2 +-
.../__manifest__.py | 5 +-
.../data/abc_classification_finance_demo.xml | 35 ---
.../models/__init__.py | 2 +-
.../models/abc_classification_profile.py | 126 +++++----
.../tests/__init__.py | 1 -
.../tests/test_abc_classification_finance.py | 248 ------------------
...abc_classification_product_level_views.xml | 16 +-
.../views/abc_classification_profile.xml | 9 +-
.../abc_finance_sale_level_history_views.xml | 76 +++---
10 files changed, 110 insertions(+), 410 deletions(-)
delete mode 100644 product_abc_classification_finance/data/abc_classification_finance_demo.xml
delete mode 100644 product_abc_classification_finance/tests/__init__.py
delete mode 100644 product_abc_classification_finance/tests/test_abc_classification_finance.py
diff --git a/product_abc_classification_finance/__init__.py b/product_abc_classification_finance/__init__.py
index 0650744f6bc..9a7e03eded3 100644
--- a/product_abc_classification_finance/__init__.py
+++ b/product_abc_classification_finance/__init__.py
@@ -1 +1 @@
-from . import models
+from . import models
\ No newline at end of file
diff --git a/product_abc_classification_finance/__manifest__.py b/product_abc_classification_finance/__manifest__.py
index ec32e9bef89..a843d6aa9d0 100644
--- a/product_abc_classification_finance/__manifest__.py
+++ b/product_abc_classification_finance/__manifest__.py
@@ -6,16 +6,13 @@
"author": "AJamal13,Odoo Community Association (OCA)",
"license": "AGPL-3",
"website": "https://github.com/OCA/product-attribute",
- "depends": ["product_abc_classification_sale_stock", "sale_margin"],
+ "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",
- ],
"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
deleted file mode 100644
index 0ccbceb9d1e..00000000000
--- a/product_abc_classification_finance/data/abc_classification_finance_demo.xml
+++ /dev/null
@@ -1,35 +0,0 @@
-
-
-
-
-
- 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
index 154290b3b06..0419c3edc21 100644
--- a/product_abc_classification_finance/models/__init__.py
+++ b/product_abc_classification_finance/models/__init__.py
@@ -1,3 +1,3 @@
from . import abc_classification_profile
from . import abc_classification_product_level
-from . import abc_finance_sale_level_history
+from . import abc_finance_sale_level_history
\ No newline at end of file
diff --git a/product_abc_classification_finance/models/abc_classification_profile.py b/product_abc_classification_finance/models/abc_classification_profile.py
index 508553b0b8e..ceb9b167a12 100644
--- a/product_abc_classification_finance/models/abc_classification_profile.py
+++ b/product_abc_classification_finance/models/abc_classification_profile.py
@@ -1,5 +1,9 @@
+import csv
import logging
from datetime import datetime, timedelta
+from io import StringIO
+from operator import attrgetter
+
from odoo import _, api, fields, models
from odoo.exceptions import UserError, ValidationError
from odoo.tools import float_round
@@ -31,10 +35,7 @@ class AbcClassificationProfile(models.Model):
@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", "sale_margin"]
- and not rec.warehouse_id
- ):
+ if rec.profile_type in ["sale_stock", "cost", "sale_price", "sale_margin"] and not rec.warehouse_id:
raise ValidationError(
_("You must specify a warehouse for {profile_name}").format(
profile_name=rec.name
@@ -58,15 +59,15 @@ def _get_finance_data_query(self, from_date, customer_location_ids):
- sale_price: SUM(sol.price_unit * sol.qty_delivered) as total_sales
- margin: SUM(sol.margin) as total_margin (already line-level total)
"""
- 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"
- elif self.profile_type == "sale_margin":
- select_col = "SUM(sol.margin) AS total_margin"
- order_col = "total_margin"
+ 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'
+ elif self.profile_type == 'sale_margin':
+ select_col = 'SUM(sol.margin) AS total_margin'
+ order_col = 'total_margin'
query = f"""
SELECT
@@ -107,19 +108,17 @@ def _finance_get_data(self, from_date=None):
self.ensure_one()
if self.profile_type not in ("cost", "sale_price", "sale_margin"):
raise UserError(_("Profile type must be cost, sale_price or sale_margin"))
- from_date = (
- from_date
- if from_date
- else fields.Datetime.to_string(
- datetime.today() - timedelta(days=self.period)
- )
+ 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)
+ 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
@@ -150,9 +149,7 @@ def _finance_get_data(self, from_date=None):
exclude_from_abc = True
# Always set purchase_price (standard cost) from product.template
tmpl = finance_data.product.product_tmpl_id
- finance_data.purchase_price = float(
- getattr(tmpl, "standard_price", 0.0) or 0.0
- )
+ finance_data.purchase_price = float(getattr(tmpl, 'standard_price', 0.0) or 0.0)
finance_data.ranking = ranking
finance_data.from_date = from_date
finance_data.to_date = to_date
@@ -162,15 +159,13 @@ def _finance_get_data(self, from_date=None):
finance_data_list.append(finance_data)
all_product_ids.remove(product_id)
- # Optionally, handle negative-margin products separately here (e.g., assign to a special class or report them)
+ # Optionally, handle negative-margin products separately here (e.g., assign to a special class or report them)
+
# 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(
- getattr(finance_data.product.product_tmpl_id, "standard_price", 0.0)
- or 0.0
- )
+ finance_data.purchase_price = float(getattr(finance_data.product.product_tmpl_id, 'standard_price', 0.0) or 0.0)
finance_data.total_cost = 0.0
finance_data.total_sales = 0.0
finance_data.margin = 0.0
@@ -223,9 +218,7 @@ def _compute_abc_classification(self):
elif profile.profile_type == "sale_margin":
value_field = "margin"
else:
- raise UserError(
- _(f"Unknown finance profile_type: {profile.profile_type}")
- )
+ 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
@@ -246,7 +239,9 @@ def _compute_abc_classification(self):
finance_data.cumulated_percentage = (
finance_data.percentage
if i == 0
- else (finance_data.percentage + previous_data.cumulated_percentage)
+ else (
+ finance_data.percentage + previous_data.cumulated_percentage
+ )
)
# Debug logging for cumulative percentage
_logger.info(
@@ -266,12 +261,7 @@ def _compute_abc_classification(self):
value,
total_value,
)
- raise UserError(
- _(
- "Cumulative percentage greater than 100 (actual: %.4f)."
- % finance_data.cumulated_percentage
- )
- )
+ 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
@@ -293,10 +283,14 @@ def _compute_abc_classification(self):
# The line is still significant...
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)
+ 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)
+ 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
@@ -310,16 +304,14 @@ def _finance_log_history(self, finance_data_list):
import csv
import io
cr = self.env.cr
- table = "abc_finance_sale_level_history"
+ table = 'abc_finance_sale_level_history'
columns = FinanceSaleData._get_col_names()
buf = io.StringIO()
- writer = csv.writer(buf, delimiter=";", lineterminator="\n")
+ 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()
+ cr.copy_from(buf, table, columns=columns, sep=';')
class FinanceSaleData(object):
"""Finance ABC classification data
@@ -336,10 +328,10 @@ class FinanceSaleData(object):
"ranking",
"percentage",
"cumulated_percentage",
- "purchase_price",
- "total_cost",
- "total_sales",
- "margin",
+ "purchase_price",
+ "total_cost",
+ "total_sales",
+ "margin",
"product_level",
"from_date",
"to_date",
@@ -357,20 +349,20 @@ def _to_csv_line(self):
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),
- float(self.margin or 0.0),
+ 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),
+ float(self.margin 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,
+ 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
@@ -385,10 +377,10 @@ def _get_col_names(cls):
"ranking",
"percentage",
"cumulated_percentage",
- "purchase_price",
- "total_cost",
- "total_sales",
- "margin",
+ "purchase_price",
+ "total_cost",
+ "total_sales",
+ "margin",
"product_level_id",
"from_date",
"to_date",
@@ -396,4 +388,4 @@ def _get_col_names(cls):
"percentage_products",
"cumulated_percentage_products",
"sum_cumulated_percentages",
- ]
+ ]
\ No newline at end of file
diff --git a/product_abc_classification_finance/tests/__init__.py b/product_abc_classification_finance/tests/__init__.py
deleted file mode 100644
index 030966e31da..00000000000
--- a/product_abc_classification_finance/tests/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-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
deleted file mode 100644
index d00b23ea4dd..00000000000
--- a/product_abc_classification_finance/tests/test_abc_classification_finance.py
+++ /dev/null
@@ -1,248 +0,0 @@
-# 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_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', 'sale_margin'.
- """
- for profile_type in ["cost", "sale_price", "sale_margin"]:
- 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,
- "margin": 2.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_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_negative_margin_exclusion(self):
- """
- Products with negative margin should be excluded from ABC ranking for 'sale_margin' profile.
- """
- from unittest.mock import patch
- profile = self.env["abc.classification.profile"].create({
- "name": "Margin Profile",
- "profile_type": "sale_margin",
- "warehouse_id": self.warehouse.id,
- "period": 365,
- })
- self.env["abc.classification.level"].create({
- "name": "A",
- "profile_id": profile.id,
- "percentage": 100,
- "percentage_products": 100,
- })
- product = self.env["product.product"].create({
- "name": "Negative Margin Product",
- "uom_id": self.env.ref("uom.product_uom_unit").id,
- "type": "product",
- "default_code": "NEG001",
- "tracking": "none",
- })
- # Use a real FinanceSaleData object and set margin negative
- from ..models.abc_classification_profile import FinanceSaleData
- finance_data = FinanceSaleData()
- finance_data.product = product
- finance_data.profile = profile
- finance_data.computed_level = None
- finance_data.product_level = None
- finance_data.ranking = 1
- finance_data.percentage = 0
- finance_data.cumulated_percentage = 0
- finance_data.purchase_price = 0
- finance_data.total_cost = 0
- finance_data.total_sales = 0
- finance_data.margin = -100.0
- finance_data.from_date = '2025-01-01'
- finance_data.to_date = '2025-12-31'
- finance_data.total_products = 1
- finance_data.percentage_products = 100
- finance_data.cumulated_percentage_products = 100
- finance_data.sum_cumulated_percentages = 100
-
- with patch.object(type(profile), "_finance_get_data", return_value=([finance_data], 0)), \
- patch.object(type(profile), "_finance_log_history", return_value=None):
- try:
- profile._compute_abc_classification()
- except Exception as e:
- self.fail(f"Negative margin exclusion raised unexpected error: {e}")
-
- 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"),
- ("sale_margin", "SUM(sol.margin) AS total_margin"),
- ]
- 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)
-
- """
- 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,
- })
- 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},
- )
- ]
- }
- )
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
index 50300a111b0..5a7d47d24c9 100644
--- a/product_abc_classification_finance/views/abc_classification_product_level_views.xml
+++ b/product_abc_classification_finance/views/abc_classification_product_level_views.xml
@@ -4,10 +4,10 @@
abc.classification.product.level
-
-
-
-
+
+
+
+
@@ -18,10 +18,10 @@
diff --git a/product_abc_classification_finance/views/abc_classification_profile.xml b/product_abc_classification_finance/views/abc_classification_profile.xml
index 140392f108e..3df8cdc60e3 100644
--- a/product_abc_classification_finance/views/abc_classification_profile.xml
+++ b/product_abc_classification_finance/views/abc_classification_profile.xml
@@ -4,15 +4,10 @@
abc.classification.profile.form (finance inherit)
abc.classification.profile
-
+
- {'required': [('profile_type', 'in', ['sale_stock', 'cost', 'sale_price', 'margin'])], 'invisible': []}
+ {'required': [('profile_type', 'in', ['sale_stock', 'cost', 'sale_price', 'margin'])], '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
index 702d3481511..a1301f6d5b4 100644
--- 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
@@ -4,25 +4,25 @@
abc.finance.sale.level.history
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -34,25 +34,25 @@
From 95817995211cecc044788adcc74e9b311ac08637 Mon Sep 17 00:00:00 2001
From: AJamal13 <135590484+AJamal13@users.noreply.github.com>
Date: Thu, 26 Jun 2025 09:32:11 +0300
Subject: [PATCH 8/8] product_abc_classification_finance
---
product_abc_classification_finance/README.rst | 99 ++++
.../__manifest__.py | 3 +-
.../data/abc_classification_finance_demo.xml | 1 -
.../abc_classification_product_level.py | 1 +
.../models/abc_classification_profile.py | 80 +---
.../models/abc_finance_sale_level_history.py | 24 +-
.../readme/CONTRIBUTORS.rst | 1 +
.../readme/DESCRIPTION.rst | 2 +
.../readme/USAGE.rst | 10 +
.../static/description/icon.png | Bin 0 -> 9455 bytes
.../static/description/index.html | 437 +++++++++++++++++
.../tests/test_abc_classification_finance.py | 443 +++++++++++-------
.../views/abc_classification_profile.xml | 2 +-
.../abc_finance_sale_level_history_views.xml | 42 +-
.../addons/product_abc_classification_finance | 1 +
.../setup.py | 6 +
16 files changed, 905 insertions(+), 247 deletions(-)
create mode 100644 product_abc_classification_finance/README.rst
create mode 100644 product_abc_classification_finance/readme/CONTRIBUTORS.rst
create mode 100644 product_abc_classification_finance/readme/DESCRIPTION.rst
create mode 100644 product_abc_classification_finance/readme/USAGE.rst
create mode 100644 product_abc_classification_finance/static/description/icon.png
create mode 100644 product_abc_classification_finance/static/description/index.html
create mode 100644 setup/product_abc_classification_finance/odoo/addons/product_abc_classification_finance
create mode 100644 setup/product_abc_classification_finance/setup.py
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/__manifest__.py b/product_abc_classification_finance/__manifest__.py
index ec32e9bef89..01e385626e2 100644
--- a/product_abc_classification_finance/__manifest__.py
+++ b/product_abc_classification_finance/__manifest__.py
@@ -1,6 +1,6 @@
{
"name": "Product ABC Classification Finance",
- "summary": "Financial ABC analysis for products (cost, sale price, margin)",
+ "summary": "Financial ABC analysis for products (cost, sale price)",
"version": "16.0.1.0.2",
"category": "Inventory/Inventory",
"author": "AJamal13,Odoo Community Association (OCA)",
@@ -16,6 +16,7 @@
"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
index 0ccbceb9d1e..6604848fbcf 100644
--- a/product_abc_classification_finance/data/abc_classification_finance_demo.xml
+++ b/product_abc_classification_finance/data/abc_classification_finance_demo.xml
@@ -18,7 +18,6 @@
50
-
100.01:
- _logger.info(
- "[ABC] ERROR: Product %s cumulative percentage exceeded: %.4f (value=%.4f, total_value=%.4f)",
- finance_data.product.display_name,
- finance_data.cumulated_percentage,
- value,
- total_value,
- )
raise UserError(
_(
"Cumulative percentage greater than 100 (actual: %.4f)."
@@ -290,7 +252,6 @@ def _compute_abc_classification(self):
)
finance_data.computed_level = level
if product_abc_classification:
- # The line is still significant...
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)
@@ -309,6 +270,7 @@ 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()
@@ -319,7 +281,8 @@ def _finance_log_history(self, finance_data_list):
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()
+ self.env["abc.finance.sale.level.history"].flush_model()
+
class FinanceSaleData(object):
"""Finance ABC classification data
@@ -339,7 +302,6 @@ class FinanceSaleData(object):
"purchase_price",
"total_cost",
"total_sales",
- "margin",
"product_level",
"from_date",
"to_date",
@@ -363,7 +325,6 @@ def _to_csv_line(self):
float(self.purchase_price or 0.0),
float(self.total_cost or 0.0),
float(self.total_sales or 0.0),
- float(self.margin 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(),
@@ -388,7 +349,6 @@ def _get_col_names(cls):
"purchase_price",
"total_cost",
"total_sales",
- "margin",
"product_level_id",
"from_date",
"to_date",
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
index 2510376655e..a927e4b8c47 100644
--- a/product_abc_classification_finance/models/abc_finance_sale_level_history.py
+++ b/product_abc_classification_finance/models/abc_finance_sale_level_history.py
@@ -1,7 +1,9 @@
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"
@@ -31,11 +33,6 @@ class AbcFinanceSaleLevelHistory(models.Model):
required=True,
readonly=True,
)
- margin = fields.Float(
- "Margin",
- required=True,
- readonly=True,
- )
total_cost = fields.Float(
"Total cost",
required=True,
@@ -59,13 +56,16 @@ class AbcFinanceSaleLevelHistory(models.Model):
readonly=True,
ondelete="cascade",
)
- ranking = fields.Integer("Ranking", readonly=True)
- percentage = fields.Float("Percentage", readonly=True)
- cumulated_percentage = fields.Float("Cumulated Percentage", readonly=True)
- standard_cost = fields.Float("Standard Cost", readonly=True)
- total_cost = fields.Float("Total Cost", readonly=True)
- total_sales = fields.Float("Total Sales", readonly=True)
- margin = fields.Float("Margin", readonly=True)
+ 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",
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/static/description/icon.png b/product_abc_classification_finance/static/description/icon.png
new file mode 100644
index 0000000000000000000000000000000000000000..3a0328b516c4980e8e44cdb63fd945757ddd132d
GIT binary patch
literal 9455
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
z
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
Xviia!U7SGha1wx#SCgwmn*{w2TRX*I
literal 0
HcmV?d00001
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
+
+
+

+
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
+
+
+
+
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.
+
+
+
+
+
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.
+
+
+
+
+
+
+
+
This module is maintained by the OCA.
+

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

+
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/test_abc_classification_finance.py b/product_abc_classification_finance/tests/test_abc_classification_finance.py
index d00b23ea4dd..750eb1cd2d1 100644
--- a/product_abc_classification_finance/tests/test_abc_classification_finance.py
+++ b/product_abc_classification_finance/tests/test_abc_classification_finance.py
@@ -2,113 +2,186 @@
# 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",
- })
+ 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,
- })
+ 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,
- })
-
+ 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",
- })
+ 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,
- })
+ 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', 'sale_margin'.
+ Profile type should accept 'cost', 'sale_price'.
"""
- for profile_type in ["cost", "sale_price", "sale_margin"]:
- profile = self.env["abc.classification.profile"].create({
- "name": f"Profile {profile_type}",
- "profile_type": profile_type,
- "warehouse_id": self.warehouse.id,
- })
+ 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,
- "margin": 2.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,
- })
+ 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.
+ 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)
+ 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)
@@ -117,22 +190,142 @@ def test_finance_data_query_methods(self):
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,
- })
+ 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(
{
@@ -140,64 +333,16 @@ def test_level_assignment_validation(self):
(
0,
0,
- {"name": "B", "percentage": 10, "percentage_products": 10},
+ {
+ "name": "B",
+ "percentage": 10,
+ "percentage_products": 10,
+ },
)
]
}
)
- def test_negative_margin_exclusion(self):
- """
- Products with negative margin should be excluded from ABC ranking for 'sale_margin' profile.
- """
- from unittest.mock import patch
- profile = self.env["abc.classification.profile"].create({
- "name": "Margin Profile",
- "profile_type": "sale_margin",
- "warehouse_id": self.warehouse.id,
- "period": 365,
- })
- self.env["abc.classification.level"].create({
- "name": "A",
- "profile_id": profile.id,
- "percentage": 100,
- "percentage_products": 100,
- })
- product = self.env["product.product"].create({
- "name": "Negative Margin Product",
- "uom_id": self.env.ref("uom.product_uom_unit").id,
- "type": "product",
- "default_code": "NEG001",
- "tracking": "none",
- })
- # Use a real FinanceSaleData object and set margin negative
- from ..models.abc_classification_profile import FinanceSaleData
- finance_data = FinanceSaleData()
- finance_data.product = product
- finance_data.profile = profile
- finance_data.computed_level = None
- finance_data.product_level = None
- finance_data.ranking = 1
- finance_data.percentage = 0
- finance_data.cumulated_percentage = 0
- finance_data.purchase_price = 0
- finance_data.total_cost = 0
- finance_data.total_sales = 0
- finance_data.margin = -100.0
- finance_data.from_date = '2025-01-01'
- finance_data.to_date = '2025-12-31'
- finance_data.total_products = 1
- finance_data.percentage_products = 100
- finance_data.cumulated_percentage_products = 100
- finance_data.sum_cumulated_percentages = 100
-
- with patch.object(type(profile), "_finance_get_data", return_value=([finance_data], 0)), \
- patch.object(type(profile), "_finance_log_history", return_value=None):
- try:
- profile._compute_abc_classification()
- except Exception as e:
- self.fail(f"Negative margin exclusion raised unexpected error: {e}")
-
def test_get_finance_data_query_all_types(self):
"""
Ensure _get_finance_data_query returns correct SQL for all profile types.
@@ -205,44 +350,18 @@ def test_get_finance_data_query_all_types(self):
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"),
- ("sale_margin", "SUM(sol.margin) AS total_margin"),
]
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,
- })
+ 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)
-
- """
- 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,
- })
- 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},
- )
- ]
- }
- )
diff --git a/product_abc_classification_finance/views/abc_classification_profile.xml b/product_abc_classification_finance/views/abc_classification_profile.xml
index 140392f108e..2e9a2bb8347 100644
--- a/product_abc_classification_finance/views/abc_classification_profile.xml
+++ b/product_abc_classification_finance/views/abc_classification_profile.xml
@@ -12,7 +12,7 @@
{'required': [('profile_type', 'in', ['sale_stock', 'cost', 'sale_price', 'margin'])], 'invisible': []}
+ >{'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
index 702d3481511..04fc0a7f903 100644
--- 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
@@ -5,17 +5,30 @@
-
+
+
-
-
-
-
+
+
+
+
+
+
+
@@ -35,17 +48,26 @@
-
+
+
-
-
-
-
+
+
+
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,
+)