commit e2b70ae1612783c8ad674fd4c14a7f318c2f7ded Author: Suherdy Yacob Date: Sat Jun 6 15:44:48 2026 +0700 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..23a5e4f --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.pyc +*.pyo +*.pyd +.DS_Store diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/__manifest__.py b/__manifest__.py new file mode 100644 index 0000000..ffecb33 --- /dev/null +++ b/__manifest__.py @@ -0,0 +1,24 @@ +{ + 'name': 'POS Hide Margin', + 'version': '19.0.1.0.0', + 'category': 'Point of Sale', + 'summary': 'Hide margin and cost fields in POS frontend and backend views for selected users', + 'description': """ + Adds a configuration on the User form (Hide Margin). + When active: + - All POS margin/cost fields are hidden in form, tree, search and pivot views. + - POS frontend popup hides cost and margin details. + """, + 'author': 'Suherdy Yacob', + 'depends': ['point_of_sale'], + 'data': [ + 'views/res_users_views.xml', + ], + 'assets': { + 'point_of_sale._assets_pos': [ + 'pos_hide_margin/static/src/app/**/*', + ], + }, + 'installable': True, + 'license': 'LGPL-3', +} diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..d6da8fa --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,2 @@ +from . import res_users +from . import pos_order diff --git a/models/pos_order.py b/models/pos_order.py new file mode 100644 index 0000000..7451db4 --- /dev/null +++ b/models/pos_order.py @@ -0,0 +1,210 @@ +from lxml import etree +from odoo import models, api, fields + + +def _hide_margin_fields_in_view(env, res): + if env.user.x_hide_margin: + for view_type, view_data in res.get('views', {}).items(): + arch = view_data.get('arch') + if arch: + doc = etree.fromstring(arch) + # Hide field elements matching margin or margin_percent + for field_node in doc.xpath("//field[@name='margin' or @name='margin_percent']"): + field_node.set('invisible', '1') + field_node.set('column_invisible', 'True') + # Hide label elements matching margin or margin_percent + for label_node in doc.xpath("//label[@for='margin' or @for='margin_percent']"): + label_node.set('invisible', '1') + # Hide the container div matching field margin + for div_node in doc.xpath("//div[field[@name='margin']]"): + div_node.set('invisible', '1') + # Remove measure fields in pivot/graph views + for measure_node in doc.xpath("//field[@name='margin' and @type='measure']"): + measure_node.getparent().remove(measure_node) + # Hide filters/groupby on margin or margin_percent in search views + for filter_node in doc.xpath("//filter[@name='margin' or @name='margin_percent' or contains(@context, 'margin') or contains(@domain, 'margin')]"): + filter_node.getparent().remove(filter_node) + view_data['arch'] = etree.tostring(doc, encoding='utf-8', xml_declaration=False).decode('utf-8') + return res + + +def _hide_margin_fields_in_fields_get(env, res): + if env.user.x_hide_margin: + for field in ['margin', 'margin_percent']: + if field in res: + res[field]['invisible'] = True + res[field]['searchable'] = False + res[field]['sortable'] = False + return res + + +def _hide_margin_fields_in_read_group_dict(env, res): + if env.user.x_hide_margin: + for group in res: + for key in list(group.keys()): + if 'margin' in key: + group[key] = 0.0 + return res + + +def _hide_margin_fields_in_read_group(env, groupby, aggregates, res): + if env.user.x_hide_margin: + margin_indices = [ + i for i, agg in enumerate(aggregates) + if 'margin' in agg + ] + groupby_margin_indices = [ + i for i, field in enumerate(groupby) + if 'margin' in field + ] + if margin_indices or groupby_margin_indices: + new_res = [] + offset = len(groupby) + for row in res: + row_list = list(row) + for idx in margin_indices: + if offset + idx < len(row_list): + row_list[offset + idx] = 0.0 + for idx in groupby_margin_indices: + if idx < len(row_list): + row_list[idx] = 0.0 + new_res.append(tuple(row_list)) + return new_res + return res + + +def _hide_margin_fields_in_read_grouping_sets(env, grouping_sets, aggregates, res): + if env.user.x_hide_margin: + margin_indices = [ + idx for idx, agg in enumerate(aggregates) + if 'margin' in agg + ] + + new_res = [] + for groupby, group_results in zip(grouping_sets, res): + groupby_margin_indices = [ + idx for idx, field in enumerate(groupby) + if 'margin' in field + ] + + if margin_indices or groupby_margin_indices: + new_group_results = [] + offset = len(groupby) + for row in group_results: + row_list = list(row) + for idx in margin_indices: + if offset + idx < len(row_list): + row_list[offset + idx] = 0.0 + for idx in groupby_margin_indices: + if idx < len(row_list): + row_list[idx] = 0.0 + new_group_results.append(tuple(row_list)) + new_res.append(new_group_results) + else: + new_res.append(group_results) + return new_res + return res + + +def _hide_margin_fields_in_read(env, res): + if env.user.x_hide_margin: + for record in res: + for key in list(record.keys()): + if 'margin' in key: + record[key] = 0.0 + return res + + +class PosOrder(models.Model): + _inherit = 'pos.order' + + def _compute_margin(self): + super()._compute_margin() + if self.env.user.x_hide_margin: + for order in self: + order.margin = 0.0 + order.margin_percent = 0.0 + + @api.model + def get_views(self, views, options=None): + res = super().get_views(views, options) + return _hide_margin_fields_in_view(self.env, res) + + def fields_get(self, allfields=None, attributes=None): + res = super().fields_get(allfields, attributes) + return _hide_margin_fields_in_fields_get(self.env, res) + + @api.model + def read_group(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True): + res = super().read_group(domain, fields, groupby, offset=offset, limit=limit, orderby=orderby, lazy=lazy) + return _hide_margin_fields_in_read_group_dict(self.env, res) + + @api.model + def _read_group(self, domain, groupby=(), aggregates=(), having=(), offset=0, limit=None, order=None): + res = super()._read_group(domain, groupby, aggregates, having, offset, limit, order) + return _hide_margin_fields_in_read_group(self.env, groupby, aggregates, res) + + @api.model + def _read_grouping_sets(self, domain, grouping_sets, aggregates=(), order=None): + res = super()._read_grouping_sets(domain, grouping_sets, aggregates, order) + return _hide_margin_fields_in_read_grouping_sets(self.env, grouping_sets, aggregates, res) + + def read(self, fields=None, load='_classic_read'): + res = super().read(fields, load=load) + return _hide_margin_fields_in_read(self.env, res) + + +class PosOrderLine(models.Model): + _inherit = 'pos.order.line' + + def _compute_margin(self): + super()._compute_margin() + if self.env.user.x_hide_margin: + for line in self: + line.margin = 0.0 + line.margin_percent = 0.0 + + @api.model + def get_views(self, views, options=None): + res = super().get_views(views, options) + return _hide_margin_fields_in_view(self.env, res) + + def fields_get(self, allfields=None, attributes=None): + res = super().fields_get(allfields, attributes) + return _hide_margin_fields_in_fields_get(self.env, res) + + def read(self, fields=None, load='_classic_read'): + res = super().read(fields, load=load) + return _hide_margin_fields_in_read(self.env, res) + + +class ReportPosOrder(models.Model): + _inherit = 'report.pos.order' + + @api.model + def get_views(self, views, options=None): + res = super().get_views(views, options) + return _hide_margin_fields_in_view(self.env, res) + + def fields_get(self, allfields=None, attributes=None): + res = super().fields_get(allfields, attributes) + return _hide_margin_fields_in_fields_get(self.env, res) + + @api.model + def read_group(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True): + res = super().read_group(domain, fields, groupby, offset=offset, limit=limit, orderby=orderby, lazy=lazy) + return _hide_margin_fields_in_read_group_dict(self.env, res) + + @api.model + def _read_group(self, domain, groupby=(), aggregates=(), having=(), offset=0, limit=None, order=None): + res = super()._read_group(domain, groupby, aggregates, having, offset, limit, order) + return _hide_margin_fields_in_read_group(self.env, groupby, aggregates, res) + + @api.model + def _read_grouping_sets(self, domain, grouping_sets, aggregates=(), order=None): + res = super()._read_grouping_sets(domain, grouping_sets, aggregates, order) + return _hide_margin_fields_in_read_grouping_sets(self.env, grouping_sets, aggregates, res) + + def read(self, fields=None, load='_classic_read'): + res = super().read(fields, load=load) + return _hide_margin_fields_in_read(self.env, res) diff --git a/models/res_users.py b/models/res_users.py new file mode 100644 index 0000000..91014fb --- /dev/null +++ b/models/res_users.py @@ -0,0 +1,13 @@ +from odoo import models, fields, api + + +class ResUsers(models.Model): + _inherit = 'res.users' + + x_hide_margin = fields.Boolean(string="Hide Margin", default=False) + + @api.model + def _load_pos_data_fields(self, config): + fields_list = super()._load_pos_data_fields(config) + fields_list.append('x_hide_margin') + return fields_list diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..d2e6d71 --- /dev/null +++ b/readme.md @@ -0,0 +1,12 @@ +# POS Hide Margin + +This module adds a configuration option "Hide Margin" to the user preferences. +When enabled, all margin and cost fields are hidden from both the POS backend views (including Pivot reports) and the POS Frontend app. + +## Features + +* Adds "Hide Margin" preference under the user form view. +* Automatically zero-out margin calculations in the backend for users with this option enabled. +* Hides the margin and cost details in the POS Frontend product info popup. +* Hides margin and margin percent columns/fields from backend tree, form, and search views dynamically. +* Zeros out margin field metrics in Pivot and Graph reports for affected users. diff --git a/static/src/app/components/popups/product_info_popup/product_info_popup_patch.js b/static/src/app/components/popups/product_info_popup/product_info_popup_patch.js new file mode 100644 index 0000000..55f9526 --- /dev/null +++ b/static/src/app/components/popups/product_info_popup/product_info_popup_patch.js @@ -0,0 +1,14 @@ +/** @odoo-module **/ + +import { ProductInfoPopup } from "@point_of_sale/app/components/popups/product_info_popup/product_info_popup"; +import { patch } from "@web/core/utils/patch"; + +patch(ProductInfoPopup.prototype, { + _hasMarginsCostsAccessRights() { + const user = this.pos.user || this.pos.cashier || this.pos.getCashier?.(); + if (user && user.x_hide_margin) { + return false; + } + return super._hasMarginsCostsAccessRights(); + } +}); diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..f172a35 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +from . import test_pos_hide_margin diff --git a/tests/test_pos_hide_margin.py b/tests/test_pos_hide_margin.py new file mode 100644 index 0000000..cd9f267 --- /dev/null +++ b/tests/test_pos_hide_margin.py @@ -0,0 +1,206 @@ +from odoo.tests import TransactionCase, tagged + + +@tagged('post_install', '-at_install') +class TestPosHideMargin(TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Find the first active company + active_company = cls.env['res.company'].search([], limit=1) + if not active_company: + # Fallback to any company if no active one exists + active_company = cls.env['res.company'].with_context(active_test=False).search([], limit=1) + + # Use the active company environment for all record creation in setup + cls.env = cls.env['res.users'].with_company(active_company).env + + # Create a user with x_hide_margin=True + cls.restricted_user = cls.env['res.users'].create({ + 'name': 'Restricted POS User', + 'login': 'restricted_pos_user', + 'email': 'restricted@example.com', + 'x_hide_margin': True, + 'group_ids': [(6, 0, [ + cls.env.ref('point_of_sale.group_pos_user').id, + cls.env.ref('base.group_user').id, + ])], + 'company_id': active_company.id, + 'company_ids': [(6, 0, [active_company.id])], + }) + + # Create a normal user with x_hide_margin=False + cls.normal_user = cls.env['res.users'].create({ + 'name': 'Normal POS User', + 'login': 'normal_pos_user', + 'email': 'normal@example.com', + 'x_hide_margin': False, + 'group_ids': [(6, 0, [ + cls.env.ref('point_of_sale.group_pos_user').id, + cls.env.ref('base.group_user').id, + ])], + 'company_id': active_company.id, + 'company_ids': [(6, 0, [active_company.id])], + }) + + # Create a product and template with cost and list price + cls.product = cls.env['product.product'].create({ + 'name': 'Margin Test Product', + 'list_price': 100.0, + 'standard_price': 60.0, # cost = 60 + }) + + # Create a POS Config & Session + cls.pos_config = cls.env['pos.config'].create({ + 'name': 'Test POS shop', + 'company_id': active_company.id, + }) + cls.pos_session = cls.env['pos.session'].create({ + 'config_id': cls.pos_config.id, + 'user_id': cls.env.uid, + }) + + # Create a POS Order + cls.pos_order = cls.env['pos.order'].create({ + 'session_id': cls.pos_session.id, + 'partner_id': cls.env.ref('base.partner_admin').id, + 'company_id': active_company.id, + 'amount_total': 100.0, + 'amount_tax': 0.0, + 'amount_paid': 100.0, + 'amount_return': 0.0, + 'lines': [(0, 0, { + 'product_id': cls.product.id, + 'qty': 1, + 'price_unit': 100.0, + 'price_subtotal': 100.0, + 'price_subtotal_incl': 100.0, + 'total_cost': 60.0, + 'is_total_cost_computed': True, + })] + }) + + # Compute margins + cls.pos_order._compute_margin() + cls.pos_order.lines._compute_margin() + + def test_margin_computation_normal_user(self): + """Test that margin is correctly calculated for a normal user.""" + order = self.pos_order.with_user(self.normal_user) + order._compute_margin() + # Cost is 60, price is 100 -> margin = 40, margin_percent = 40% (0.4) + self.assertAlmostEqual(order.margin, 40.0) + self.assertAlmostEqual(order.margin_percent, 0.4) + + line = self.pos_order.lines[0].with_user(self.normal_user) + line._compute_margin() + self.assertAlmostEqual(line.margin, 40.0) + + def test_margin_computation_restricted_user(self): + """Test that margin calculations are zeroed out for restricted users.""" + order = self.pos_order.with_user(self.restricted_user) + order._compute_margin() + self.assertEqual(order.margin, 0.0) + self.assertEqual(order.margin_percent, 0.0) + + line = self.pos_order.lines[0].with_user(self.restricted_user) + line._compute_margin() + self.assertEqual(line.margin, 0.0) + self.assertEqual(line.margin_percent, 0.0) + + def test_fields_get_restrictions(self): + """Test fields_get hides margin fields metadata for restricted users.""" + # Check for normal user + fields_normal = self.env['pos.order'].with_user(self.normal_user).fields_get(['margin', 'margin_percent']) + self.assertFalse(fields_normal.get('margin', {}).get('invisible', False)) + + # Check for restricted user + fields_restricted = self.env['pos.order'].with_user(self.restricted_user).fields_get(['margin', 'margin_percent']) + self.assertTrue(fields_restricted.get('margin', {}).get('invisible', False)) + self.assertFalse(fields_restricted.get('margin', {}).get('searchable', True)) + + def test_report_pos_order_read_restrictions(self): + """Test reading report.pos.order zeros out margins for restricted users.""" + # Create a report record via SQL reload + self.env['report.pos.order'].init() + report_records = self.env['report.pos.order'].search([('order_id', '=', self.pos_order.id)]) + self.assertTrue(report_records) + + # Normal user reads report + report_normal = report_records.with_user(self.normal_user).read(['margin']) + self.assertAlmostEqual(report_normal[0]['margin'], 40.0) + + # Restricted user reads report + report_restricted = report_records.with_user(self.restricted_user).read(['margin']) + self.assertEqual(report_restricted[0]['margin'], 0.0) + + # Normal user read_group + group_normal = self.env['report.pos.order'].with_user(self.normal_user).read_group( + [('order_id', '=', self.pos_order.id)], + ['margin'], + ['product_categ_id'] + ) + self.assertAlmostEqual(group_normal[0]['margin'], 40.0) + + # Restricted user read_group + group_restricted = self.env['report.pos.order'].with_user(self.restricted_user).read_group( + [('order_id', '=', self.pos_order.id)], + ['margin'], + ['product_categ_id'] + ) + self.assertEqual(group_restricted[0]['margin'], 0.0) + + # Normal user _read_group + _group_normal = self.env['report.pos.order'].with_user(self.normal_user)._read_group( + [('order_id', '=', self.pos_order.id)], + ['product_categ_id'], + ['margin:sum'] + ) + self.assertAlmostEqual(_group_normal[0][1], 40.0) + + # Restricted user _read_group + _group_restricted = self.env['report.pos.order'].with_user(self.restricted_user)._read_group( + [('order_id', '=', self.pos_order.id)], + ['product_categ_id'], + ['margin:sum'] + ) + self.assertEqual(_group_restricted[0][1], 0.0) + + # Normal user _read_grouping_sets + sets_normal = self.env['report.pos.order'].with_user(self.normal_user)._read_grouping_sets( + [('order_id', '=', self.pos_order.id)], + [['product_categ_id']], + ['margin:sum'] + ) + self.assertAlmostEqual(sets_normal[0][0][1], 40.0) + + # Restricted user _read_grouping_sets + sets_restricted = self.env['report.pos.order'].with_user(self.restricted_user)._read_grouping_sets( + [('order_id', '=', self.pos_order.id)], + [['product_categ_id']], + ['margin:sum'] + ) + self.assertEqual(sets_restricted[0][0][1], 0.0) + + def test_pos_order_read_restrictions(self): + """Test reading pos.order and pos.order.line zeros out margins for restricted users.""" + # Normal user reads order + order_normal = self.pos_order.with_user(self.normal_user).read(['margin', 'margin_percent']) + self.assertAlmostEqual(order_normal[0]['margin'], 40.0) + self.assertAlmostEqual(order_normal[0]['margin_percent'], 0.4) + + # Restricted user reads order + order_restricted = self.pos_order.with_user(self.restricted_user).read(['margin', 'margin_percent']) + self.assertEqual(order_restricted[0]['margin'], 0.0) + self.assertEqual(order_restricted[0]['margin_percent'], 0.0) + + # Normal user reads line + line_normal = self.pos_order.lines[0].with_user(self.normal_user).read(['margin', 'margin_percent']) + self.assertAlmostEqual(line_normal[0]['margin'], 40.0) + + # Restricted user reads line + line_restricted = self.pos_order.lines[0].with_user(self.restricted_user).read(['margin', 'margin_percent']) + self.assertEqual(line_restricted[0]['margin'], 0.0) + self.assertEqual(line_restricted[0]['margin_percent'], 0.0) diff --git a/views/res_users_views.xml b/views/res_users_views.xml new file mode 100644 index 0000000..6ef8d4e --- /dev/null +++ b/views/res_users_views.xml @@ -0,0 +1,24 @@ + + + + res.users.form.inherit + res.users + + + + + + + + + + res.users.form.simple.modif.inherit + res.users + + + + + + + +