first commit

This commit is contained in:
Suherdy Yacob 2026-06-06 15:44:48 +07:00
commit e2b70ae161
11 changed files with 512 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
__pycache__/
*.pyc
*.pyo
*.pyd
.DS_Store

1
__init__.py Normal file
View File

@ -0,0 +1 @@
from . import models

24
__manifest__.py Normal file
View File

@ -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',
}

2
models/__init__.py Normal file
View File

@ -0,0 +1,2 @@
from . import res_users
from . import pos_order

210
models/pos_order.py Normal file
View File

@ -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)

13
models/res_users.py Normal file
View File

@ -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

12
readme.md Normal file
View File

@ -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.

View File

@ -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();
}
});

1
tests/__init__.py Normal file
View File

@ -0,0 +1 @@
from . import test_pos_hide_margin

View File

@ -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)

24
views/res_users_views.xml Normal file
View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_users_form_inherit" model="ir.ui.view">
<field name="name">res.users.form.inherit</field>
<field name="model">res.users</field>
<field name="inherit_id" ref="base.view_users_form"/>
<field name="arch" type="xml">
<xpath expr="//group[@name='other_preferences']" position="inside">
<field name="x_hide_margin"/>
</xpath>
</field>
</record>
<record id="view_users_form_simple_modif_inherit" model="ir.ui.view">
<field name="name">res.users.form.simple.modif.inherit</field>
<field name="model">res.users</field>
<field name="inherit_id" ref="base.view_users_form_simple_modif"/>
<field name="arch" type="xml">
<xpath expr="//group[@name='other_preferences']" position="inside">
<field name="x_hide_margin"/>
</xpath>
</field>
</record>
</odoo>