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

+ + +

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

+

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

+

Table of contents

+ +
+

Usage

+

To use this module, you need to:

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

Bug Tracker

+

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

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • AJamal13
  • +
+
+ +
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

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

+

Current maintainer:

+

AJamal13

+

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

+

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

+
+
+
+ + diff --git a/product_abc_classification_finance/tests/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, +)