diff --git a/dev_odex30_accounting/abs_customer_validation/__init__.py b/dev_odex30_accounting/abs_customer_validation/__init__.py new file mode 100644 index 0000000..fd46080 --- /dev/null +++ b/dev_odex30_accounting/abs_customer_validation/__init__.py @@ -0,0 +1,3 @@ + +from . import models + diff --git a/dev_odex30_accounting/abs_customer_validation/__manifest__.py b/dev_odex30_accounting/abs_customer_validation/__manifest__.py new file mode 100644 index 0000000..5c062af --- /dev/null +++ b/dev_odex30_accounting/abs_customer_validation/__manifest__.py @@ -0,0 +1,18 @@ + +{ + 'name': "Customer duplicate validation", + 'author': 'Ascetic Business Solution', + 'category': 'Odex30-Accounting/Odex25-Accounting', + 'summary': """Notify about duplicate while creating partner""", + 'website': 'http://www.asceticbs.com', + 'license': 'AGPL-3', + 'description': """ +""", + 'version': '18.0', + 'depends': ['base', 'sale'], + 'data': ['security/security.xml'], + 'images': ['static/description/banner.png'], + 'installable': True, + 'application': True, + 'auto_install': False, +} diff --git a/dev_odex30_accounting/abs_customer_validation/models/__init__.py b/dev_odex30_accounting/abs_customer_validation/models/__init__.py new file mode 100644 index 0000000..baed701 --- /dev/null +++ b/dev_odex30_accounting/abs_customer_validation/models/__init__.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +################################################################################# +# +# Odoo, Open Source Management Solution +# Copyright (C) 2022-today Ascetic Business Solution +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +################################################################################# +from . import res_partner diff --git a/dev_odex30_accounting/abs_customer_validation/models/res_partner.py b/dev_odex30_accounting/abs_customer_validation/models/res_partner.py new file mode 100644 index 0000000..94fa7f7 --- /dev/null +++ b/dev_odex30_accounting/abs_customer_validation/models/res_partner.py @@ -0,0 +1,49 @@ + +from odoo import api,fields,models,_ +from odoo.exceptions import ValidationError + +class ResPartner(models.Model): + _inherit="res.partner" + + def get_partner_list(self,partner_objs): + partner_list = '' + for partner in partner_objs: + partner_list = partner_list + ' || ' + partner.name + return partner_list + + @api.onchange('name') + def onchange_name(self): + if self.name and self.env.user.has_group("abs_customer_validation.group_activate_customer_validation"): + if self.env['res.partner'].search([('name','=',self.name)]): + raise ValidationError(_('The record ' +self.name+' is already exist ')) + + @api.onchange('phone') + def onchange_phonenumber(self): + if self.phone and self.env.user.has_group("abs_customer_validation.group_activate_customer_validation"): + partner_objs = self.env['res.partner'].search([('phone','=',self.phone)]) + if self.get_partner_list(partner_objs): + raise ValidationError(_('Phone number '+str(self.phone)+' is already exist in the following records:' + '\n' + self.get_partner_list(partner_objs))) + + @api.onchange('mobile') + def onchange_mobilenumber(self): + if self.mobile and self.env.user.has_group("abs_customer_validation.group_activate_customer_validation"): + partner_objs = self.env['res.partner'].search([('mobile','=',self.mobile)]) + if self.get_partner_list(partner_objs): + raise ValidationError(_('Mobile number '+str(self.mobile)+' is already exist in the following records:' + '\n' + self.get_partner_list(partner_objs))) + + @api.onchange('email') + def onchange_email(self): + if self.email and self.env.user.has_group("abs_customer_validation.group_activate_customer_validation"): + partner_objs = self.env['res.partner'].search([('email','=',self.email)]) + if self.get_partner_list(partner_objs): + raise ValidationError(_('Email number '+str(self.email)+' is already exist in the following records:' + '\n' + self.get_partner_list(partner_objs))) + + @api.onchange('website') + def onchange_website(self): + if self.website and self.env.user.has_group("abs_customer_validation.group_activate_customer_validation"): + website_id = "http://"+str(self.website) + partner_objs = self.env['res.partner'].search([('website','=',website_id)]) + if self.get_partner_list(partner_objs): + raise ValidationError(_('Website number '+str(self.website)+' is already exist in the following records:' + '\n' + self.get_partner_list(partner_objs))) + + diff --git a/dev_odex30_accounting/abs_customer_validation/security/security.xml b/dev_odex30_accounting/abs_customer_validation/security/security.xml new file mode 100644 index 0000000..aa7b1fa --- /dev/null +++ b/dev_odex30_accounting/abs_customer_validation/security/security.xml @@ -0,0 +1,10 @@ + + + + + Activate customer validation + + + + + diff --git a/dev_odex30_accounting/abs_customer_validation/static/description/banner.png b/dev_odex30_accounting/abs_customer_validation/static/description/banner.png new file mode 100644 index 0000000..c9360f5 Binary files /dev/null and b/dev_odex30_accounting/abs_customer_validation/static/description/banner.png differ diff --git a/dev_odex30_accounting/abs_customer_validation/static/description/company-logo.png b/dev_odex30_accounting/abs_customer_validation/static/description/company-logo.png new file mode 100644 index 0000000..fee9f34 Binary files /dev/null and b/dev_odex30_accounting/abs_customer_validation/static/description/company-logo.png differ diff --git a/dev_odex30_accounting/abs_customer_validation/static/description/icon.png b/dev_odex30_accounting/abs_customer_validation/static/description/icon.png new file mode 100644 index 0000000..97c2198 Binary files /dev/null and b/dev_odex30_accounting/abs_customer_validation/static/description/icon.png differ diff --git a/dev_odex30_accounting/abs_customer_validation/static/description/index.html b/dev_odex30_accounting/abs_customer_validation/static/description/index.html new file mode 100644 index 0000000..3b5d081 --- /dev/null +++ b/dev_odex30_accounting/abs_customer_validation/static/description/index.html @@ -0,0 +1,60 @@ + + + +
+
+
+

Notify about duplicate while creating partner

+
+
+

+ This module will help you to activate the validation on the partner. Check 'Activate customer validation' access group from the user. This validation helps to take preventive steps to stop creating the duplicate partners. Odoo will notify the user instantly with the list of records which are potentially duplicate while they are adding information (like Name, Phone, Mobile, Fax, Email, Website) on the customer. Please note, this moduel is not to stop creating duplicate partner but notify the user so they should be aware about possible duplication while creating the partner. +

+ +
+
+
+ +
+ +

Need help?

+ +
+

+ Contact this module maintainer for any question, need support or request for the new feature :
+ * Riken Bhorania +91 9427425799, riken.bhorania, riken.bhorania@asceticbs.com
+ * Bhaumin Chorera +91 8530000384, bhaumin.chorera, bhaumin.chorera@asceticbs.com
+

+
+ + + +
+ +
+ +
+ + + + +
+ + +
+ + + diff --git a/dev_odex30_accounting/abs_customer_validation/tests/__init__.py b/dev_odex30_accounting/abs_customer_validation/tests/__init__.py new file mode 100644 index 0000000..ead79c9 --- /dev/null +++ b/dev_odex30_accounting/abs_customer_validation/tests/__init__.py @@ -0,0 +1,3 @@ + +from . import test_customer_validation + diff --git a/dev_odex30_accounting/abs_customer_validation/tests/test_customer_validation.py b/dev_odex30_accounting/abs_customer_validation/tests/test_customer_validation.py new file mode 100644 index 0000000..cc51d81 --- /dev/null +++ b/dev_odex30_accounting/abs_customer_validation/tests/test_customer_validation.py @@ -0,0 +1,72 @@ +from odoo.tests import common, tagged +from odoo.exceptions import ValidationError + + +@tagged('post_install', '-at_install') +class TestCustomerValidation(common.TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.user_no_group = cls.env['res.users'].sudo().create({ + 'name': 'User No Group', 'login': 'no_group', 'email': 'no@group.com' + }) + cls.user_with_group = cls.env['res.users'].sudo().create({ + 'name': 'User With Group', 'login': 'with_group', 'email': 'with@group.com' + }) + + cls.group = cls.env.ref('abs_customer_validation.group_activate_customer_validation') + cls.group.sudo().users |= cls.user_with_group + + cls.existing_partner1 = cls.env['res.partner'].sudo().create({ + 'name': 'Existing Partner 1', 'phone': '+1234567890', 'mobile': '+1987654321', + 'email': 'test@example.com', 'website': 'http://test.com' + }) + cls.existing_partner2 = cls.env['res.partner'].sudo().create({ + 'name': 'Existing Partner 2', 'phone': '+1234567890' + }) + + def test_onchange_name_no_group(self): + partner = self.env['res.partner'].with_user(self.user_no_group.id).new({'name': 'Existing Partner 1'}) + partner.onchange_name() # لا خطأ + + def test_onchange_name_with_group(self): + partner = self.env['res.partner'].with_user(self.user_with_group.id).new({'name': 'Existing Partner 1'}) + with self.assertRaises(ValidationError): + partner.onchange_name() + + def test_onchange_phone_no_group(self): + partner = self.env['res.partner'].with_user(self.user_no_group.id).new({'phone': '+1234567890'}) + partner.onchange_phonenumber() # لا خطأ + + def test_onchange_phone_with_group(self): + partner = self.env['res.partner'].with_user(self.user_with_group.id).new({'phone': '+1234567890'}) + with self.assertRaises(ValidationError) as cm: + partner.onchange_phonenumber() + self.assertIn('Existing Partner 1', str(cm.exception)) + self.assertIn('Existing Partner 2', str(cm.exception)) + + def test_onchange_phone_unique(self): + partner = self.env['res.partner'].with_user(self.user_with_group.id).new({'phone': '+9999999999'}) + partner.onchange_phonenumber() # يمر + + def test_onchange_mobile_with_group(self): + partner = self.env['res.partner'].with_user(self.user_with_group.id).new({'mobile': '+1987654321'}) + with self.assertRaises(ValidationError): + partner.onchange_mobilenumber() + + def test_onchange_email_with_group(self): + partner = self.env['res.partner'].with_user(self.user_with_group.id).new({'email': 'test@example.com'}) + with self.assertRaises(ValidationError): + partner.onchange_email() + + def test_onchange_website_with_group(self): + partner = self.env['res.partner'].with_user(self.user_with_group.id).new({'website': 'test.com'}) + with self.assertRaises(ValidationError): + partner.onchange_website() + + def test_empty_fields(self): + partner = self.env['res.partner'].with_user(self.user_with_group.id).new({}) + partner.onchange_name() + partner.onchange_phonenumber() + partner.onchange_email() # كلها تمر diff --git a/dev_odex30_accounting/account_attachments/__init__.py b/dev_odex30_accounting/account_attachments/__init__.py new file mode 100755 index 0000000..0650744 --- /dev/null +++ b/dev_odex30_accounting/account_attachments/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/dev_odex30_accounting/account_attachments/__manifest__.py b/dev_odex30_accounting/account_attachments/__manifest__.py new file mode 100755 index 0000000..8eac71b --- /dev/null +++ b/dev_odex30_accounting/account_attachments/__manifest__.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- + +{ + 'name': "Account Attachments", + 'version': '18.0.1.0.0', + 'category': 'Account', + 'summary': 'Helps to view all documents attached to account', + 'description': """account Attachments module allows user to view + all the documents attached to account.""", + 'company': 'Expert', + 'website': 'https://www.expert.com', + 'depends': ['base','account'], + 'data': [ + 'views/account_move_view.xml' + ], + 'license': 'AGPL-3', + 'installable': True, + 'auto_install': False, + 'application': True, +} diff --git a/dev_odex30_accounting/account_attachments/i18n/ar_001.po b/dev_odex30_accounting/account_attachments/i18n/ar_001.po new file mode 100755 index 0000000..3389398 --- /dev/null +++ b/dev_odex30_accounting/account_attachments/i18n/ar_001.po @@ -0,0 +1,49 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * account_attachments +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-01-05 15:34+0000\n" +"PO-Revision-Date: 2026-01-05 15:34+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: account_attachments +#: model:ir.model.fields,field_description:account_attachments.field_account_bank_statement_line__attach_no +#: model:ir.model.fields,field_description:account_attachments.field_account_move__attach_no +msgid "Attach No" +msgstr "مرفق" + +#. module: account_attachments +#: model_terms:ir.ui.view,arch_db:account_attachments.account_move_view +msgid "Documents" +msgstr "المرفقات" + +#. module: account_attachments +#: model:ir.model,name:account_attachments.model_account_move +msgid "Journal Entry" +msgstr "قيد اليومية" + +#. module: account_attachments +#: model_terms:ir.ui.view,arch_db:account_attachments.account_move_view +msgid "Model Info" +msgstr "" + +#. module: account_attachments +#: model:ir.model.fields,field_description:account_attachments.field_account_bank_statement_line__res_id +#: model:ir.model.fields,field_description:account_attachments.field_account_move__res_id +msgid "Res" +msgstr "" + +#. module: account_attachments +#: model:ir.model.fields,field_description:account_attachments.field_account_bank_statement_line__res_model +#: model:ir.model.fields,field_description:account_attachments.field_account_move__res_model +msgid "Res Model" +msgstr "" \ No newline at end of file diff --git a/dev_odex30_accounting/account_attachments/models/__init__.py b/dev_odex30_accounting/account_attachments/models/__init__.py new file mode 100755 index 0000000..9c0a421 --- /dev/null +++ b/dev_odex30_accounting/account_attachments/models/__init__.py @@ -0,0 +1 @@ +from . import account_move diff --git a/dev_odex30_accounting/account_attachments/models/account_move.py b/dev_odex30_accounting/account_attachments/models/account_move.py new file mode 100755 index 0000000..7aebf76 --- /dev/null +++ b/dev_odex30_accounting/account_attachments/models/account_move.py @@ -0,0 +1,133 @@ +# -*- coding: utf-8 -*- +from odoo import api, fields, models + +class AccountMove(models.Model): + + _inherit = 'account.move' + + attach_no = fields.Integer(compute='get_attachments') + res_id = fields.Integer() + res_model = fields.Char() + + def get_attachments(self): + print("=== get_attachments START ===") + Attachment = self.env['ir.attachment'] + action = self.env['ir.actions.act_window']._for_xml_id('base.action_attachment') + + if len(self) > 1: + print("Multiple records mode - len:", len(self)) + all_models_ids = set() + + for record in self: + related_pairs = set() + related_pairs.add((record._name, record.id)) + + # PO مباشر أو تلقائي من origin/ref + po_id = record.purchase_id.id if record.purchase_id else False + if not po_id and record.invoice_origin: + po = self.env['purchase.order'].search([('name', '=', record.invoice_origin)], limit=1) + if po: + po_id = po.id + print("Auto-found PO by origin:", po_id) + elif not po_id and record.ref: + po = self.env['purchase.order'].search([('name', '=', record.ref)], limit=1) + if po: + po_id = po.id + print("Auto-found PO by ref:", po_id) + + if po_id: + po = self.env['purchase.order'].browse(po_id) + related_pairs.add((po._name, po.id)) + print("Added PO:", (po._name, po.id)) + + # Request ID - آمن + if hasattr(po, 'request_id') and po.request_id: + related_pairs.add((po.request_id._name, po.request_id.id)) + print("Added request:", (po.request_id._name, po.request_id.id)) + else: + print("NO request_id") + + # Requisition ID - آمن + if hasattr(po, + 'requisition_id') and po.requisition_id and po.requisition_id._name == 'purchase.requisition': + related_pairs.add(('purchase.requisition', po.requisition_id.id)) + print("Added requisition:", ('purchase.requisition', po.requisition_id.id)) + else: + print("NO requisition_id") + + # Confirmation IDs - آمن + if hasattr(po, 'confirmation_ids') and po.confirmation_ids: + for conf in po.confirmation_ids: + related_pairs.add(('budget.confirmation', conf.id)) + print("Added confirmation:", ('budget.confirmation', conf.id)) + else: + print("NO confirmation_ids") + + print("Record related_pairs:", related_pairs) + all_models_ids.update(related_pairs) + + # Build domain... + domain = [] + pairs = list(all_models_ids) + if pairs: + domain = ['|'] * (len(pairs) - 1) + for model, res_id in pairs: + domain.extend(['&', ('res_model', '=', model), ('res_id', '=', res_id)]) + + action['domain'] = domain + action['context'] = {'default_res_model': self[0]._name, 'default_res_id': self[0].id} + + for record in self: + record.attach_no = Attachment.search_count( + [('res_model', '=', record._name), ('res_id', '=', record.id)]) + print("=== MULTIPLE END ===") + return action + + # Single record - تلقائي + self.ensure_one() + print("SINGLE RECORD ID:", self.id) + print("invoice_origin:", self.invoice_origin, "ref:", self.ref) + + related_pairs = [(self._name, self.id)] + + # PO تلقائي + po_id = self.purchase_id.id if self.purchase_id else False + if not po_id and self.invoice_origin: + po = self.env['purchase.order'].search([('name', '=', self.invoice_origin)], limit=1) + if po: po_id = po.id + elif not po_id and self.ref: + po = self.env['purchase.order'].search([('name', '=', self.ref)], limit=1) + if po: po_id = po.id + + if po_id: + po = self.env['purchase.order'].browse(po_id) + related_pairs.append((po._name, po.id)) + print("Auto-linked PO:", po_id) + + if hasattr(po, 'request_id') and po.request_id: + related_pairs.append((po.request_id._name, po.request_id.id)) + if hasattr(po, + 'requisition_id') and po.requisition_id and po.requisition_id._name == 'purchase.requisition': + related_pairs.append(('purchase.requisition', po.requisition_id.id)) + if hasattr(po, 'confirmation_ids') and po.confirmation_ids: + for conf in po.confirmation_ids: + related_pairs.append(('budget.confirmation', conf.id)) + + # Domain + domain = [] + if len(related_pairs) > 1: + domain = ['|'] * (len(related_pairs) - 1) + for model, res_id in related_pairs: + domain.extend(['&', ('res_model', '=', model), ('res_id', '=', res_id)]) + else: + domain = [('res_model', '=', self._name), ('res_id', '=', self.id)] + + action['domain'] = domain + action['context'] = {'default_res_model': self._name, 'default_res_id': self.id} + self.attach_no = Attachment.search_count(domain) + print("attach_no:", self.attach_no, "Domain covers:", related_pairs) + print("=== END ===") + return action + + + diff --git a/dev_odex30_accounting/account_attachments/static/description/icon.png b/dev_odex30_accounting/account_attachments/static/description/icon.png new file mode 100644 index 0000000..4141f52 Binary files /dev/null and b/dev_odex30_accounting/account_attachments/static/description/icon.png differ diff --git a/dev_odex30_accounting/account_attachments/views/account_move_view.xml b/dev_odex30_accounting/account_attachments/views/account_move_view.xml new file mode 100755 index 0000000..2f260ca --- /dev/null +++ b/dev_odex30_accounting/account_attachments/views/account_move_view.xml @@ -0,0 +1,22 @@ + + + account.move.inherit.form.attachment + account.move + + + + + + + + + + + + + + diff --git a/dev_odex30_accounting/odex30_account_accountant_fleet/__init__.py b/dev_odex30_accounting/odex30_account_accountant_fleet/__init__.py new file mode 100644 index 0000000..a9e3372 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant_fleet/__init__.py @@ -0,0 +1,2 @@ + +from . import models diff --git a/dev_odex30_accounting/odex30_account_accountant_fleet/__manifest__.py b/dev_odex30_accounting/odex30_account_accountant_fleet/__manifest__.py new file mode 100644 index 0000000..f76193b --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant_fleet/__manifest__.py @@ -0,0 +1,17 @@ + +{ + 'name': 'Fleet bridge', + 'category': 'Odex30-Accounting/Odex30-Accounting', + 'author': "Expert Co. Ltd.", + 'website': "http://www.exp-sa.com", + 'summary': 'Manage accounting with fleet features', + 'version': '1.0', + 'depends': ['account_fleet', 'odex30_account_accountant'], + 'assets': { + 'web.assets_backend': [ + 'odex30_account_accountant_fleet/static/src/components/**/*', + ], + }, + 'license': 'OEEL-1', + 'auto_install': True, +} diff --git a/dev_odex30_accounting/odex30_account_accountant_fleet/i18n/ar.po b/dev_odex30_accounting/odex30_account_accountant_fleet/i18n/ar.po new file mode 100644 index 0000000..927dfa2 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant_fleet/i18n/ar.po @@ -0,0 +1,49 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * odex30_account_accountant_fleet +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-01-05 17:12+0000\n" +"PO-Revision-Date: 2026-01-05 17:12+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: odex30_account_accountant_fleet +#: model:ir.model,name:odex30_account_accountant_fleet.model_bank_rec_widget +msgid "Bank reconciliation widget for a single statement line" +msgstr "أداة التسوية البنكية لبند كشف حساب واحد " + +#. module: odex30_account_accountant_fleet +#: model:ir.model,name:odex30_account_accountant_fleet.model_account_move_line +msgid "Journal Item" +msgstr "عنصر اليومية" + +#. module: odex30_account_accountant_fleet +#: model:ir.model,name:odex30_account_accountant_fleet.model_bank_rec_widget_line +msgid "Line of the bank reconciliation widget" +msgstr "بند أداة التسوية البنكية " + +#. module: odex30_account_accountant_fleet +#: model:ir.model,name:odex30_account_accountant_fleet.model_account_tax +msgid "Tax" +msgstr "الضريبة" + +#. module: odex30_account_accountant_fleet +#. odoo-javascript +#: code:addons/odex30_account_accountant_fleet/static/src/components/bank_reconciliation/bank_rec_form.xml:0 +#: code:addons/odex30_account_accountant_fleet/static/src/components/bank_reconciliation/kanban.js:0 +#: model:ir.model.fields,field_description:odex30_account_accountant_fleet.field_bank_rec_widget_line__vehicle_id +msgid "Vehicle" +msgstr "" + +#. module: odex30_account_accountant_fleet +#: model:ir.model.fields,field_description:odex30_account_accountant_fleet.field_bank_rec_widget_line__vehicle_required +msgid "Vehicle Required" +msgstr "" \ No newline at end of file diff --git a/dev_odex30_accounting/odex30_account_accountant_fleet/models/__init__.py b/dev_odex30_accounting/odex30_account_accountant_fleet/models/__init__.py new file mode 100644 index 0000000..9aacc58 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant_fleet/models/__init__.py @@ -0,0 +1,6 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import account_move_line +from . import account_tax +from . import bank_rec_widget +from . import bank_rec_widget_line diff --git a/dev_odex30_accounting/odex30_account_accountant_fleet/models/account_move_line.py b/dev_odex30_accounting/odex30_account_accountant_fleet/models/account_move_line.py new file mode 100644 index 0000000..e04624d --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant_fleet/models/account_move_line.py @@ -0,0 +1,13 @@ + +from odoo import api, models +from odoo.tools import SQL + + +class AccountMoveLine(models.Model): + _inherit = 'account.move.line' + + @api.model + def _get_extra_query_base_tax_line_mapping(self) -> SQL: + + query = super()._get_extra_query_base_tax_line_mapping() + return SQL("%s AND COALESCE(base_line.vehicle_id, 0) = COALESCE(account_move_line.vehicle_id, 0)", query) diff --git a/dev_odex30_accounting/odex30_account_accountant_fleet/models/account_tax.py b/dev_odex30_accounting/odex30_account_accountant_fleet/models/account_tax.py new file mode 100644 index 0000000..695fd28 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant_fleet/models/account_tax.py @@ -0,0 +1,30 @@ + +from odoo import models + +class AccountTax(models.Model): + _inherit = 'account.tax' + + def _prepare_base_line_for_taxes_computation(self, record, **kwargs): + results = super()._prepare_base_line_for_taxes_computation(record, **kwargs) + results['vehicle_id'] = self._get_base_line_field_value_from_record(record, 'vehicle_id', kwargs, self.env['fleet.vehicle']) + return results + + def _prepare_tax_line_for_taxes_computation(self, record, **kwargs): + results = super()._prepare_tax_line_for_taxes_computation(record, **kwargs) + results['vehicle_id'] = self._get_base_line_field_value_from_record(record, 'vehicle_id', kwargs, self.env['fleet.vehicle']) + return results + + def _prepare_base_line_grouping_key(self, base_line): + results = super()._prepare_base_line_grouping_key(base_line) + results['vehicle_id'] = base_line['vehicle_id'].id + return results + + def _prepare_base_line_tax_repartition_grouping_key(self, base_line, base_line_grouping_key, tax_data, tax_rep_data): + results = super()._prepare_base_line_tax_repartition_grouping_key(base_line, base_line_grouping_key, tax_data, tax_rep_data) + results['vehicle_id'] = base_line_grouping_key['vehicle_id'] if not tax_rep_data['tax_rep'].use_in_tax_closing else False + return results + + def _prepare_tax_line_repartition_grouping_key(self, tax_line): + results = super()._prepare_tax_line_repartition_grouping_key(tax_line) + results['vehicle_id'] = tax_line['vehicle_id'].id + return results diff --git a/dev_odex30_accounting/odex30_account_accountant_fleet/models/bank_rec_widget.py b/dev_odex30_accounting/odex30_account_accountant_fleet/models/bank_rec_widget.py new file mode 100644 index 0000000..2536f99 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant_fleet/models/bank_rec_widget.py @@ -0,0 +1,18 @@ + +from odoo import models + + +class BankRecWidget(models.Model): + _inherit = 'bank.rec.widget' + + def _lines_prepare_tax_line(self, tax_line_vals): + results = super()._lines_prepare_tax_line(tax_line_vals) + results['vehicle_id'] = tax_line_vals['vehicle_id'] + return results + + def _line_value_changed_vehicle_id(self, line): + self.ensure_one() + self._lines_turn_auto_balance_into_manual_line(line) + + if line.flag != 'tax_line': + self._lines_recompute_taxes() diff --git a/dev_odex30_accounting/odex30_account_accountant_fleet/models/bank_rec_widget_line.py b/dev_odex30_accounting/odex30_account_accountant_fleet/models/bank_rec_widget_line.py new file mode 100644 index 0000000..01d93e7 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant_fleet/models/bank_rec_widget_line.py @@ -0,0 +1,34 @@ + +from odoo import api, fields, models + + +class BankRecWidgetLine(models.Model): + _inherit = 'bank.rec.widget.line' + + vehicle_id = fields.Many2one( + comodel_name='fleet.vehicle', + compute='_compute_vehicle_id', + store=True, + readonly=False, + domain="[('company_id', '=', company_id)]" + ) + vehicle_required = fields.Boolean( + compute='_compute_vehicle_required', + ) + + @api.depends('source_aml_id') + def _compute_vehicle_id(self): + for line in self: + if line.flag == 'aml': + line.vehicle_id = line.source_aml_id.vehicle_id + else: + line.vehicle_id = line.vehicle_id + + def _compute_vehicle_required(self): + self.vehicle_required = False + + def _get_aml_values(self, **kwargs): + return super()._get_aml_values( + **kwargs, + vehicle_id=self.vehicle_id.id, + ) diff --git a/dev_odex30_accounting/odex30_account_accountant_fleet/static/src/components/bank_reconciliation/bank_rec_form.xml b/dev_odex30_accounting/odex30_account_accountant_fleet/static/src/components/bank_reconciliation/bank_rec_form.xml new file mode 100644 index 0000000..02b0a05 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant_fleet/static/src/components/bank_reconciliation/bank_rec_form.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + +
+
+ +
+
+
+ +
+
+
+
+
+
diff --git a/dev_odex30_accounting/odex30_account_accountant_fleet/static/src/components/bank_reconciliation/kanban.js b/dev_odex30_accounting/odex30_account_accountant_fleet/static/src/components/bank_reconciliation/kanban.js new file mode 100644 index 0000000..5cc2be4 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant_fleet/static/src/components/bank_reconciliation/kanban.js @@ -0,0 +1,19 @@ +/** @odoo-module **/ + +import { _t } from "@web/core/l10n/translation"; +import { patch } from "@web/core/utils/patch"; + +import { BankRecKanbanController } from "@odex30_account_accountant/components/bank_reconciliation/kanban"; + +patch(BankRecKanbanController.prototype, { + getOne2ManyColumns() { + const columns = super.getOne2ManyColumns(...arguments); + const lineIdsRecords = this.state.bankRecRecordData.line_ids.records; + + if (lineIdsRecords.some((r) => r.data.vehicle_id || r.data.vehicle_required)) { + const debit_col_index = columns.findIndex((col) => col[0] === "debit"); + columns.splice(debit_col_index, 0, ["vehicle", _t("Vehicle")]); + } + return columns; + } +}); diff --git a/dev_odex30_accounting/odex30_account_accountant_fleet/tests/__init__.py b/dev_odex30_accounting/odex30_account_accountant_fleet/tests/__init__.py new file mode 100644 index 0000000..8bb973b --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant_fleet/tests/__init__.py @@ -0,0 +1,2 @@ + +from . import test_account_fleet_tax_report diff --git a/dev_odex30_accounting/odex30_account_accountant_fleet/tests/test_account_fleet_tax_report.py b/dev_odex30_accounting/odex30_account_accountant_fleet/tests/test_account_fleet_tax_report.py new file mode 100644 index 0000000..10c8560 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant_fleet/tests/test_account_fleet_tax_report.py @@ -0,0 +1,62 @@ + +from odoo import Command +from odoo.addons.account.tests.test_account_move_line_tax_details import TestAccountTaxDetailsReport +from odoo.tests import tagged + + +@tagged('post_install', '-at_install') +class TestAccountFleet(TestAccountTaxDetailsReport): + + def test_tax_report_with_vehicle_split_repartition(self): + + self.env.user.groups_id += self.env.ref('fleet.fleet_group_manager') + brand = self.env["fleet.vehicle.model.brand"].create({"name": "Audi"}) + model = self.env["fleet.vehicle.model"].create({"brand_id": brand.id, "name": "A3"}) + cars = self.env["fleet.vehicle"].create([ + {"model_id": model.id, "plan_to_change_car": False}, + {"model_id": model.id, "plan_to_change_car": False}, + ]) + + expense_account = self.company_data['default_account_expense'] + asset_account = self.company_data['default_account_deferred_expense'] + + tax = self.env['account.tax'].create({ + 'name': 'Split Tax', + 'amount': 10, + 'invoice_repartition_line_ids': [ + Command.create({'repartition_type': 'base', 'factor_percent': 100}), + Command.create({'repartition_type': 'tax', 'factor_percent': 50, 'account_id': expense_account.id}), + Command.create({'repartition_type': 'tax', 'factor_percent': 50, 'account_id': asset_account.id}), + ], + 'refund_repartition_line_ids': [ + Command.create({'repartition_type': 'base', 'factor_percent': 100}), + Command.create({'repartition_type': 'tax', 'factor_percent': 50, 'account_id': expense_account.id}), + Command.create({'repartition_type': 'tax', 'factor_percent': 50, 'account_id': asset_account.id}), + ], + }) + + bill = self.init_invoice('in_invoice', invoice_date='2025-10-16', post=False) + bill.write({ + 'invoice_line_ids': [ + Command.create({ + 'product_id': self.product_a.id, + 'account_id': expense_account.id, + 'price_unit': 100, + 'tax_ids': [Command.set(tax.ids)], + 'vehicle_id': cars[0].id + }), + Command.create({ + 'product_id': self.product_a.id, + 'account_id': expense_account.id, + 'price_unit': 100, + 'tax_ids': [Command.set(tax.ids)], + 'vehicle_id': cars[1].id + }), + ] + }) + bill.action_post() + + tax_details = self._get_tax_details() + self.assertEqual(len(tax_details), 2) + for line in tax_details: + self.assertEqual(line['tax_amount'], 5) diff --git a/dev_odex30_accounting/odex30_account_disallowed_expenses/__init__.py b/dev_odex30_accounting/odex30_account_disallowed_expenses/__init__.py new file mode 100644 index 0000000..ba41f76 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_disallowed_expenses/__init__.py @@ -0,0 +1,3 @@ + +from . import models +from . import report diff --git a/dev_odex30_accounting/odex30_account_disallowed_expenses/__manifest__.py b/dev_odex30_accounting/odex30_account_disallowed_expenses/__manifest__.py new file mode 100644 index 0000000..8e89c4c --- /dev/null +++ b/dev_odex30_accounting/odex30_account_disallowed_expenses/__manifest__.py @@ -0,0 +1,27 @@ + +{ + 'name': 'Disallowed Expenses', + 'category': 'Odex23-Accounting/Odex23-Accounting', + 'author': "Expert Co. Ltd.", + 'website': "http://www.exp-sa.com", + 'summary': 'Manage disallowed expenses', + 'description': 'Manage disallowed expenses', + 'version': '1.0', + 'depends': ['odex30_account_reports'], + 'data': [ + 'data/odex30_account_disallowed_expenses_report.xml', + 'security/ir.model.access.csv', + 'security/odex30_account_disallowed_expenses_security.xml', + 'views/odex30_account_account_views.xml', + 'views/odex30_account_disallowed_expenses_category_views.xml', + 'views/odex30_account_disallowed_expenses_report_views.xml', + ], + 'installable': True, + 'auto_install': True, + 'license': 'OEEL-1', + 'assets': { + 'web.assets_backend': [ + 'odex30_account_disallowed_expenses/static/src/components/**/*', + ], + }, +} diff --git a/dev_odex30_accounting/odex30_account_disallowed_expenses/data/odex30_account_disallowed_expenses_report.xml b/dev_odex30_accounting/odex30_account_disallowed_expenses/data/odex30_account_disallowed_expenses_report.xml new file mode 100644 index 0000000..514c926 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_disallowed_expenses/data/odex30_account_disallowed_expenses_report.xml @@ -0,0 +1,27 @@ + + + + Disallowed Expenses Report + selector + + + previous_year + + + + + Total Amount + total_amount + + + Rate + rate + percentage + + + Disallowed Amount + disallowed_amount + + + + diff --git a/dev_odex30_accounting/odex30_account_disallowed_expenses/i18n/ar.po b/dev_odex30_accounting/odex30_account_disallowed_expenses/i18n/ar.po new file mode 100644 index 0000000..eda4213 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_disallowed_expenses/i18n/ar.po @@ -0,0 +1,227 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * odex30_account_disallowed_expenses +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-01-05 16:46+0000\n" +"PO-Revision-Date: 2026-01-05 16:46+00:00\n" +"Last-Translator: Expert SA\n" +"Language-Team: Arabic Team\n" +"Language: ar\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=6; plural=n<3?n%10==1?0:n%10>=3?1:n%10==2?2:n==0?3:n==1?4:5:3;\n" + +#. module: odex30_account_disallowed_expenses +#: model:ir.model,name:odex30_account_disallowed_expenses.model_account_account +#: model:ir.model.fields,field_description:odex30_account_disallowed_expenses.field_account_disallowed_expenses_category__account_ids +msgid "Account" +msgstr "الحساب" + +#. module: odex30_account_disallowed_expenses +#: model:ir.model.fields,field_description:odex30_account_disallowed_expenses.field_account_disallowed_expenses_category__active +msgid "Active" +msgstr "نشط" + +#. module: odex30_account_disallowed_expenses +#: model_terms:ir.actions.act_window,help:odex30_account_disallowed_expenses.action_odex30_account_disallowed_expenses_category_list +msgid "Add a Disallowed Expenses Category" +msgstr "إضافة فئة مصروفات ممنوعة" + +#. module: odex30_account_disallowed_expenses +#: model:ir.model.fields,field_description:odex30_account_disallowed_expenses.field_account_disallowed_expenses_rate__category_id +msgid "Category" +msgstr "الفئة" + +#. module: odex30_account_disallowed_expenses +#: model_terms:ir.ui.view,arch_db:odex30_account_disallowed_expenses.view_odex30_account_disallowed_expenses_category_form +msgid "Category Name" +msgstr "اسم الفئة" + +#. module: odex30_account_disallowed_expenses +#: model:ir.model.fields,field_description:odex30_account_disallowed_expenses.field_account_disallowed_expenses_category__code +#: model_terms:ir.ui.view,arch_db:odex30_account_disallowed_expenses.view_odex30_account_disallowed_expenses_category_form +msgid "Code" +msgstr "الرمز" + +#. module: odex30_account_disallowed_expenses +#: model:ir.model.fields,field_description:odex30_account_disallowed_expenses.field_account_disallowed_expenses_category__company_id +#: model:ir.model.fields,field_description:odex30_account_disallowed_expenses.field_account_disallowed_expenses_rate__company_id +msgid "Company" +msgstr "الشركة" + +#. module: odex30_account_disallowed_expenses +#: model:ir.model.fields,field_description:odex30_account_disallowed_expenses.field_account_disallowed_expenses_category__create_uid +#: model:ir.model.fields,field_description:odex30_account_disallowed_expenses.field_account_disallowed_expenses_rate__create_uid +msgid "Created by" +msgstr "أُنشئ بواسطة" + +#. module: odex30_account_disallowed_expenses +#: model:ir.model.fields,field_description:odex30_account_disallowed_expenses.field_account_disallowed_expenses_category__create_date +#: model:ir.model.fields,field_description:odex30_account_disallowed_expenses.field_account_disallowed_expenses_rate__create_date +msgid "Created on" +msgstr "تاريخ الإنشاء" + +#. module: odex30_account_disallowed_expenses +#: model:ir.model.fields,field_description:odex30_account_disallowed_expenses.field_account_disallowed_expenses_category__current_rate +msgid "Current Rate" +msgstr "المعدل الحالي" + +#. module: odex30_account_disallowed_expenses +#: model:ir.model.fields,field_description:odex30_account_disallowed_expenses.field_account_disallowed_expenses_rate__rate +msgid "Disallowed %" +msgstr "نسبة الممنوع %" + +#. module: odex30_account_disallowed_expenses +#: model:account.report.column,name:odex30_account_disallowed_expenses.disallowed_expenses_report_disallowed_amount +msgid "Disallowed Amount" +msgstr "المبلغ الممنوع" + +#. module: odex30_account_disallowed_expenses +#: model:ir.ui.menu,name:odex30_account_disallowed_expenses.menu_action_account_report_de +#: model_terms:ir.ui.view,arch_db:odex30_account_disallowed_expenses.view_account_form +msgid "Disallowed Expenses" +msgstr "المصروفات الممنوعة" + +#. module: odex30_account_disallowed_expenses +#: model:ir.actions.act_window,name:odex30_account_disallowed_expenses.action_odex30_account_disallowed_expenses_category_list +#: model:ir.ui.menu,name:odex30_account_disallowed_expenses.menu_action_odex30_account_disallowed_expenses_category_list +msgid "Disallowed Expenses Categories" +msgstr "فئات المصروفات الممنوعة" + +#. module: odex30_account_disallowed_expenses +#: model:ir.model,name:odex30_account_disallowed_expenses.model_account_disallowed_expenses_category +#: model:ir.model.fields,field_description:odex30_account_disallowed_expenses.field_account_account__disallowed_expenses_category_id +msgid "Disallowed Expenses Category" +msgstr "فئة المصروفات الممنوعة" + +#. module: odex30_account_disallowed_expenses +#: model:ir.model,name:odex30_account_disallowed_expenses.model_account_disallowed_expenses_report_handler +msgid "Disallowed Expenses Custom Handler" +msgstr "معالج مخصص للمصروفات الممنوعة" + +#. module: odex30_account_disallowed_expenses +#: model:ir.model,name:odex30_account_disallowed_expenses.model_account_disallowed_expenses_rate +msgid "Disallowed Expenses Rate" +msgstr "معدل المصروفات الممنوعة" + +#. module: odex30_account_disallowed_expenses +#: model:account.report,name:odex30_account_disallowed_expenses.disallowed_expenses_report +#: model:ir.actions.client,name:odex30_account_disallowed_expenses.action_account_report_de +msgid "Disallowed Expenses Report" +msgstr "تقرير المصروفات الممنوعة" + +#. module: odex30_account_disallowed_expenses +#: model:ir.model.constraint,message:odex30_account_disallowed_expenses.constraint_account_disallowed_expenses_category_unique_code +msgid "Disallowed expenses category code should be unique." +msgstr "رمز فئة المصروفات الممنوعة يجب أن يكون فريداً." + +#. module: odex30_account_disallowed_expenses +#: model:ir.model.fields,field_description:odex30_account_disallowed_expenses.field_account_disallowed_expenses_category__display_name +#: model:ir.model.fields,field_description:odex30_account_disallowed_expenses.field_account_disallowed_expenses_rate__display_name +msgid "Display Name" +msgstr "اسم العرض" + +#. module: odex30_account_disallowed_expenses +#. odoo-python +#: code:addons/odex30_account_disallowed_expenses/report/odex30_account_disallowed_expenses_report.py:0 +msgid "General Ledger" +msgstr "الدفتر الرئيسي" + +#. module: odex30_account_disallowed_expenses +#: model:ir.model.fields,field_description:odex30_account_disallowed_expenses.field_account_disallowed_expenses_category__id +#: model:ir.model.fields,field_description:odex30_account_disallowed_expenses.field_account_disallowed_expenses_rate__id +msgid "ID" +msgstr "المعرف" + +#. module: odex30_account_disallowed_expenses +#. odoo-python +#: code:addons/odex30_account_disallowed_expenses/report/odex30_account_disallowed_expenses_report.py:0 +msgid "Journal Items" +msgstr "عناصر اليومية" + +#. module: odex30_account_disallowed_expenses +#: model:ir.model.fields,field_description:odex30_account_disallowed_expenses.field_account_disallowed_expenses_category__write_uid +#: model:ir.model.fields,field_description:odex30_account_disallowed_expenses.field_account_disallowed_expenses_rate__write_uid +msgid "Last Updated by" +msgstr "آخر تحديث بواسطة" + +#. module: odex30_account_disallowed_expenses +#: model:ir.model.fields,field_description:odex30_account_disallowed_expenses.field_account_disallowed_expenses_category__write_date +#: model:ir.model.fields,field_description:odex30_account_disallowed_expenses.field_account_disallowed_expenses_rate__write_date +msgid "Last Updated on" +msgstr "تاريخ آخر تحديث" + +#. module: odex30_account_disallowed_expenses +#: model:ir.model.fields,field_description:odex30_account_disallowed_expenses.field_account_disallowed_expenses_category__name +msgid "Name" +msgstr "الاسم" + +#. module: odex30_account_disallowed_expenses +#. odoo-python +#: code:addons/odex30_account_disallowed_expenses/models/odex30_account_disallowed_expenses.py:0 +msgid "No Rate" +msgstr "لا يوجد معدل" + +#. module: odex30_account_disallowed_expenses +#: model:account.report.column,name:odex30_account_disallowed_expenses.disallowed_expenses_report_rate +#: model:ir.model.fields,field_description:odex30_account_disallowed_expenses.field_account_disallowed_expenses_category__rate_ids +msgid "Rate" +msgstr "المعدل" + +#. module: odex30_account_disallowed_expenses +#: model_terms:ir.ui.view,arch_db:odex30_account_disallowed_expenses.view_odex30_account_disallowed_expenses_category_form +msgid "Rates" +msgstr "المعدلات" + +#. module: odex30_account_disallowed_expenses +#: model_terms:ir.ui.view,arch_db:odex30_account_disallowed_expenses.view_odex30_account_disallowed_expenses_category_form +#: model_terms:ir.ui.view,arch_db:odex30_account_disallowed_expenses.view_odex30_account_disallowed_expenses_category_tree +msgid "Related Account(s)" +msgstr "الحسابات المرتبطة" + +#. module: odex30_account_disallowed_expenses +#: model_terms:ir.ui.view,arch_db:odex30_account_disallowed_expenses.view_odex30_account_disallowed_expenses_category_tree +msgid "Set Rates" +msgstr "تعيين المعدلات" + +#. module: odex30_account_disallowed_expenses +#: model:ir.model.fields,help:odex30_account_disallowed_expenses.field_account_disallowed_expenses_category__active +msgid "Set active to false to hide the category without removing it." +msgstr "اضبط النشط على كاذب لإخفاء الفئة دون حذفها." + +#. module: odex30_account_disallowed_expenses +#: model:ir.model.fields,field_description:odex30_account_disallowed_expenses.field_account_disallowed_expenses_rate__date_from +msgid "Start Date" +msgstr "تاريخ البدء" + +#. module: odex30_account_disallowed_expenses +#. odoo-javascript +#: code:addons/odex30_account_disallowed_expenses/static/src/components/disallowed_expenses_report/warnings.xml:0 +msgid "There are multiple disallowed expenses rates in this period" +msgstr "هناك معدلات متعددة للمصروفات الممنوعة في هذه الفترة" + +#. module: odex30_account_disallowed_expenses +#. odoo-python +#: code:addons/odex30_account_disallowed_expenses/report/odex30_account_disallowed_expenses_report.py:0 +msgid "Total" +msgstr "الإجمالي" + +#. module: odex30_account_disallowed_expenses +#: model:account.report.column,name:odex30_account_disallowed_expenses.disallowed_expenses_report_total_amount +msgid "Total Amount" +msgstr "المبلغ الإجمالي" + +#. module: odex30_account_disallowed_expenses +#: model_terms:ir.ui.view,arch_db:odex30_account_disallowed_expenses.view_odex30_account_disallowed_expenses_category_form +msgid "e.g. 1201" +msgstr "مثال: 1201" + +#. module: odex30_account_disallowed_expenses +#: model_terms:ir.ui.view,arch_db:odex30_account_disallowed_expenses.view_odex30_account_disallowed_expenses_category_form +msgid "e.g. Non-Deductible Tax" +msgstr "مثال: ضريبة غير قابلة للخصم" diff --git a/dev_odex30_accounting/odex30_account_disallowed_expenses/models/__init__.py b/dev_odex30_accounting/odex30_account_disallowed_expenses/models/__init__.py new file mode 100644 index 0000000..e3e66cb --- /dev/null +++ b/dev_odex30_accounting/odex30_account_disallowed_expenses/models/__init__.py @@ -0,0 +1,3 @@ + +from . import odex30_account_account +from . import odex30_account_disallowed_expenses diff --git a/dev_odex30_accounting/odex30_account_disallowed_expenses/models/odex30_account_account.py b/dev_odex30_accounting/odex30_account_disallowed_expenses/models/odex30_account_account.py new file mode 100644 index 0000000..7f30016 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_disallowed_expenses/models/odex30_account_account.py @@ -0,0 +1,13 @@ + +from odoo import models, fields, api + + +class AccountAccount(models.Model): + _inherit = "account.account" + + disallowed_expenses_category_id = fields.Many2one('account.disallowed.expenses.category', string='Disallowed Expenses Category', check_company=True) + + @api.onchange('internal_group') + def _onchange_internal_group(self): + if self.internal_group not in ('income', 'expense'): + self.disallowed_expenses_category_id = None diff --git a/dev_odex30_accounting/odex30_account_disallowed_expenses/models/odex30_account_disallowed_expenses.py b/dev_odex30_accounting/odex30_account_disallowed_expenses/models/odex30_account_disallowed_expenses.py new file mode 100644 index 0000000..3713d06 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_disallowed_expenses/models/odex30_account_disallowed_expenses.py @@ -0,0 +1,78 @@ + +from odoo import fields, models, api, _ +from odoo.osv import expression + + +class AccountDisallowedExpensesCategory(models.Model): + _name = 'account.disallowed.expenses.category' + _description = "Disallowed Expenses Category" + + name = fields.Char(string='Name', required=True, translate=True) + code = fields.Char(string='Code', required=True) + active = fields.Boolean(default=True, help="Set active to false to hide the category without removing it.") + rate_ids = fields.One2many('account.disallowed.expenses.rate', 'category_id', string='Rate') + company_id = fields.Many2one('res.company') + account_ids = fields.One2many('account.account', 'disallowed_expenses_category_id', check_company=True) + current_rate = fields.Char(compute='_compute_current_rate', string='Current Rate') + + _sql_constraints = [ + ('unique_code', 'UNIQUE(code)', 'Disallowed expenses category code should be unique.') + ] + + @api.depends('current_rate', 'code') + def _compute_display_name(self): + for record in self: + rate = record.current_rate or _('No Rate') + name = f'{record.code} - {record.name} ({rate})' + record.display_name = name + + @api.depends('rate_ids') + def _compute_current_rate(self): + rates = self._get_current_rates() + for rec in self: + rec.current_rate = ('%g%%' % rates[rec.id]) if rates.get(rec.id) else None + + def _get_current_rates(self): + sql = """ + SELECT + DISTINCT category_id, + first_value(rate) OVER (PARTITION BY category_id ORDER BY date_from DESC) + FROM account_disallowed_expenses_rate + WHERE date_from < CURRENT_DATE + AND category_id IN %(ids)s + """ + self.env.cr.execute(sql, {'ids': tuple(self.ids)}) + return dict(self.env.cr.fetchall()) + + @api.model + def _search_display_name(self, operator, value): + if value and isinstance(value, str): + code_value = value.split(' ')[0] + is_negative = operator in expression.NEGATIVE_TERM_OPERATORS + positive_operator = expression.TERM_OPERATORS_NEGATION[operator] if is_negative else operator + domain = ['|', ('code', '=ilike', f'{code_value}%'), ('name', positive_operator, value)] + if is_negative: + domain = ['!', *domain] + return domain + return super()._search_display_name(operator, value) + + def action_read_category(self): + self.ensure_one() + return { + 'name': self.display_name, + 'type': 'ir.actions.act_window', + 'view_type': 'form', + 'view_mode': 'form', + 'res_model': 'account.disallowed.expenses.category', + 'res_id': self.id, + } + +class AccountDisallowedExpensesRate(models.Model): + _name = 'account.disallowed.expenses.rate' + _description = "Disallowed Expenses Rate" + _order = 'date_from desc' + + rate = fields.Float(string='Disallowed %', required=True) + date_from = fields.Date(string='Start Date', required=True) + category_id = fields.Many2one('account.disallowed.expenses.category', string='Category', required=True, ondelete='cascade') + company_id = fields.Many2one('res.company', string='Company', required=True, default=lambda self: self.env.company) diff --git a/dev_odex30_accounting/odex30_account_disallowed_expenses/report/__init__.py b/dev_odex30_accounting/odex30_account_disallowed_expenses/report/__init__.py new file mode 100644 index 0000000..98a52ea --- /dev/null +++ b/dev_odex30_accounting/odex30_account_disallowed_expenses/report/__init__.py @@ -0,0 +1,2 @@ + +from . import odex30_account_disallowed_expenses_report diff --git a/dev_odex30_accounting/odex30_account_disallowed_expenses/report/odex30_account_disallowed_expenses_report.py b/dev_odex30_accounting/odex30_account_disallowed_expenses/report/odex30_account_disallowed_expenses_report.py new file mode 100644 index 0000000..36b40a1 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_disallowed_expenses/report/odex30_account_disallowed_expenses_report.py @@ -0,0 +1,356 @@ + +from odoo import models, _ +from odoo.tools import SQL, Query + + +class DisallowedExpensesCustomHandler(models.AbstractModel): + _name = 'account.disallowed.expenses.report.handler' + _inherit = 'account.report.custom.handler' + _description = 'Disallowed Expenses Custom Handler' + + def _dynamic_lines_generator(self, report, options, all_column_groups_expression_totals, warnings=None): + results = self._get_query_results(options, primary_fields=['category_id']) + lines = [] + + totals = { + column_group_key: {key: 0.0 for key in ['total_amount', 'disallowed_amount']} + for column_group_key in options['column_groups'] + } + + for group_key, result in results.items(): + current = self._parse_hierarchy_group_key(group_key) + lines.append((0, self._get_category_line(options, result, current, len(current)))) + self._update_total_values(totals, options, result) + + if (lines): + lines.append((0, self._get_total_line(report, options, totals))) + + return lines + + def _custom_options_initializer(self, report, options, previous_options): + super()._custom_options_initializer(report, options, previous_options=previous_options) + period_domain = [('date_from', '>=', options['date']['date_from']), ('date_from', '<=', options['date']['date_to'])] + rg = self.env['account.disallowed.expenses.rate']._read_group( + period_domain, + ['category_id'], + having=[('__count', '>', 1)], + limit=1, + ) + options['multi_rate_in_period'] = bool(rg) + + def _customize_warnings(self, report, options, all_column_groups_expression_totals, warnings): + if options['multi_rate_in_period']: + warnings['odex30_account_disallowed_expenses.warning_multi_rate'] = {} + + def _caret_options_initializer(self): + return { + 'account.account': [ + {'name': _("General Ledger"), 'action': 'caret_option_open_general_ledger'}, + {'name': _("Journal Items"), 'action': 'open_journal_items'}, + ], + } + + def open_journal_items(self, options, params): + ctx = { + 'search_default_group_by_account': 1, + 'search_default_posted': 0 if options.get('all_entries') else 1, + 'date_from': options.get('date', {}).get('date_from'), + 'date_to': options.get('date', {}).get('date_to'), + 'expand': 1, + } + + if options.get('date', {}).get('date_from'): + ctx['search_default_date_between'] = 1 + else: + ctx['search_default_date_before'] = 1 + + domain = [('display_type', 'not in', ('line_section', 'line_note'))] + + model_to_domain = { + 'account.disallowed.expenses.category': 'account_id.disallowed_expenses_category_id', + 'account.account': 'account_id', + 'fleet.vehicle': 'vehicle_id', + } + + vehicle_audit = False + account_audit = False + for markup, res_model, res_id in self.env['account.report']._parse_line_id(params.get('line_id')): + if model_to_domain.get(res_model): + domain.append((model_to_domain[res_model], '=', res_id)) + if markup: + ctx['search_default_account_id'] = int(markup) + if res_model == 'fleet.vehicle': + vehicle_audit = True + if res_model == 'account.account': + account_audit = True + + if options.get('vehicle_split') and account_audit and not vehicle_audit: + domain.append(('vehicle_id', '=', False)) + + return { + 'name': 'Journal Items', + 'view_mode': 'list', + 'res_model': 'account.move.line', + 'views': [(False, 'list')], + 'type': 'ir.actions.act_window', + 'domain': domain, + 'context': ctx, + } + + def _get_query(self, options, line_dict_id=None) -> tuple[SQL, SQL, SQL, SQL, SQL, SQL]: + + company_ids = tuple(self.env['account.report'].get_report_company_ids(options)) + current = self._parse_line_id(options, line_dict_id) + category_name = self.env['account.disallowed.expenses.category']._field_to_sql('category', 'name') + + query = Query(self.env, alias='aml', table=SQL.identifier('account_move_line')) + query.add_join('LEFT JOIN', alias='account', table='account_account', condition=SQL('aml.account_id = account.id')) + account_code = self.env['account.account']._field_to_sql('account', 'code', query) + + select = SQL( + """ + SELECT + SUM(aml.balance) AS total_amount, + ARRAY_AGG(%(account_name)s) account_name, + ARRAY_AGG(%(account_code)s) account_code, + ARRAY_AGG(category.id) category_id, + ARRAY_AGG(%(category_name)s) category_name, + ARRAY_AGG(category.code) category_code, + ARRAY_AGG(aml.company_id) company_id, + ARRAY_AGG(aml.account_id) account_id, + ARRAY_AGG(rate.rate) account_rate, + SUM(aml.balance * rate.rate) / 100 AS account_disallowed_amount + """, + account_name=self.env['account.account']._field_to_sql('account', 'name'), + account_code=account_code, + category_name=category_name, + ) + + from_ = SQL( + """ + FROM %(from_clause)s + JOIN account_move move ON aml.move_id = move.id + JOIN account_disallowed_expenses_category category ON account.disallowed_expenses_category_id = category.id + LEFT JOIN account_disallowed_expenses_rate rate ON rate.id = ( + SELECT r2.id FROM account_disallowed_expenses_rate r2 + LEFT JOIN account_disallowed_expenses_category c2 ON r2.category_id = c2.id + WHERE r2.date_from <= aml.date + AND c2.id = category.id + ORDER BY r2.date_from DESC LIMIT 1 + ) + """, + from_clause=query.from_clause, + ) + where = SQL( + """ + WHERE aml.company_id in %(company_ids)s + AND aml.date >= %(date_from)s AND aml.date <= %(date_to)s + AND move.state != 'cancel' + %(category_condition)s + %(account_condition)s + %(account_rate_condition)s + %(not_all_entries_condition)s + """, + company_ids=company_ids, + date_from=options['date']['date_from'], + date_to=options['date']['date_to'], + category_condition=SQL("AND category.id = %s", current['category_id']) if current.get('category_id') else SQL(), + account_condition=SQL("AND aml.account_id = %s", current['account_id']) if current.get('account_id') else SQL(), + account_rate_condition=SQL("AND rate.rate = %s", current['account_rate']) if current.get('account_rate') else SQL(), + not_all_entries_condition=SQL("AND move.state = 'posted'") if not options.get('all_entries') else SQL(), + ) + + group_by = SQL( + """GROUP BY category.id %s%s""", + current.get('category_id') and SQL(", account_id") or SQL(), + current.get('account_id') and options['multi_rate_in_period'] and SQL(", rate.rate") or SQL(), + ) + + order_by = SQL("ORDER BY category_id, account_id") + order_by_rate = SQL(", account_rate") + + return select, from_, where, group_by, order_by, order_by_rate + + def _parse_line_id(self, options, line_id): + current = {'category_id': None} + + if not line_id: + return current + + for dummy, model, record_id in self.env['account.report']._parse_line_id(line_id): + if model == 'account.disallowed.expenses.category': + current['category_id'] = record_id + if model == 'account.account': + current['account_id'] = record_id + if model == 'account.disallowed.expenses.rate': + current['account_rate'] = record_id + + return current + + def _build_line_id(self, options, current, level, parent=False, markup=None): + report = self.env['account.report'].browse(options['report_id']) + parent_line_id = None + line_id = report._get_generic_line_id('account.disallowed.expenses.category', current['category_id']) + if current.get('account_id'): + parent_line_id = line_id + line_id = report._get_generic_line_id('account.account', current['account_id'], parent_line_id=line_id) + + if len(current) != level and not current.get('account_rate'): + parent_line_id = line_id + line_id = report._get_generic_line_id('account.account', current['account_id'], parent_line_id=line_id) + if current.get('account_rate'): + parent_line_id = line_id + line_id = report._get_generic_line_id('account.disallowed.expenses.rate', current['account_rate'], markup=markup, parent_line_id=line_id) + + return parent_line_id if parent else line_id + + def _get_query_results(self, options, line_dict_id=None, primary_fields=None, secondary_fields=None, selector=None): + grouped_results = {} + + for column_group_key, column_group_options in self.env['account.report']._split_options_per_column_group(options).items(): + select, from_, where, group_by, order_by, order_by_rate = self._get_query(column_group_options, line_dict_id) + select = SQL("%s, %s AS column_group_key", select, column_group_key) + self.env.cr.execute(SQL(' ').join([select, from_, where, group_by, order_by, order_by_rate])) + + for results in self.env.cr.dictfetchall(): + key = self._get_group_key(results, primary_fields, secondary_fields, selector) + grouped_results.setdefault(key, {})[column_group_key] = results + + return grouped_results + + def _get_group_key(self, results, primary_fields, secondary_fields, selector): + fields = [] + if selector is None or self._get_single_value(results, selector): + fields = primary_fields + elif secondary_fields is not None: + fields = secondary_fields + + group_key_list = [] + for group_key in fields: + group_key_id = self._get_single_value(results, group_key) + if group_key_id: + group_key_list.append(group_key + '~' + (group_key_id and str(group_key_id) or '')) + + return '|'.join(group_key_list) + + def _parse_hierarchy_group_key(self, group_key): + return { + item: int(float(item_id)) + for item, item_id + in [ + full_id.split('~') + for full_id + in (group_key.split('|')) + ] + } + + def _report_expand_unfoldable_line_category_line(self, line_dict_id, groupby, options, progress, offset, unfold_all_batch_data=None): + results = self._get_query_results(options, line_dict_id, ['category_id', 'account_id']) + lines = [] + + for group_key, result in results.items(): + current = self._parse_hierarchy_group_key(group_key) + level = len(self._parse_line_id(options, line_dict_id)) + 1 + lines.append(self._get_account_line(options, result, current, level)) + + return {'lines': lines} + + def _report_expand_unfoldable_line_account_line(self, line_dict_id, groupby, options, progress, offset, unfold_all_batch_data=None): + results = self._get_query_results(options, line_dict_id, ['category_id', 'account_id', 'account_rate']) + lines = [] + + for group_key, result in results.items(): + current = self._parse_hierarchy_group_key(group_key) + level = len(self._parse_line_id(options, line_dict_id)) + 1 + base_line_values = list(result.values())[0] + account_id = self._get_single_value(base_line_values, 'account_id') + lines.append(self._get_rate_line(options, result, current, level, account_id)) + + return {'lines': lines} + + def _get_column_values(self, options, values, is_total_line=False): + column_values = [] + + report = self.env['account.report'].browse(options['report_id']) + for column in options['columns']: + vals = values.get(column['column_group_key'], {}) + if vals and not is_total_line: + vals['rate'] = self._get_current_rate(vals) + vals['disallowed_amount'] = self._get_current_disallowed_amount(vals) + col_val = vals.get(column['expression_label']) + + column_values.append(report._build_column_dict( + col_val, + column, + options=options, + digits=2 if column['figure_type'] == 'percentage' else None, + )) + + return column_values + + def _update_total_values(self, total, options, values): + for column_group_key in options['column_groups']: + for key in total[column_group_key]: + total[column_group_key][key] += values.get(column_group_key, {}).get(key) or 0.0 + + def _get_total_line(self, report, options, totals): + return { + 'id': report._get_generic_line_id(None, None, markup='total'), + 'name': _('Total'), + 'level': 1, + 'columns': self._get_column_values(options, totals, is_total_line=True), + } + + def _get_category_line(self, options, values, current, level): + base_line_values = list(values.values())[0] + return { + **self._get_base_line(options, current, level), + 'name': '%s %s' % (base_line_values['category_code'][0], base_line_values['category_name'][0]), + 'columns': self._get_column_values(options, values), + 'level': level, + 'unfoldable': True, + 'expand_function': '_report_expand_unfoldable_line_category_line', + } + + def _get_account_line(self, options, values, current, level): + base_line_values = list(values.values())[0] + unfoldable = options.get('multi_rate_in_period') + return { + **self._get_base_line(options, current, level), + 'name': '%s %s' % (base_line_values['account_code'][0], base_line_values['account_name'][0]), + 'columns': self._get_column_values(options, values), + 'level': level, + 'unfoldable': unfoldable, + 'caret_options': False if unfoldable else 'account.account', + 'account_id': base_line_values['account_id'][0], + 'expand_function': unfoldable and '_report_expand_unfoldable_line_account_line', + } + + def _get_rate_line(self, options, values, current, level, markup=None): + base_line_values = list(values.values())[0] + return { + **self._get_base_line(options, current, level, markup), + 'name': f"{base_line_values['account_code'][0]} {base_line_values['account_name'][0]}", + 'columns': self._get_column_values(options, values), + 'level': level, + 'unfoldable': False, + 'caret_options': 'account.account', + 'account_id': base_line_values['account_id'][0], + } + + def _get_base_line(self, options, current, level, markup=None): + current_line_id = self._build_line_id(options, current, level, markup=markup) + return { + 'id': current_line_id, + 'parent_id': self._build_line_id(options, current, level, parent=True, markup=markup, ), + 'unfolded': current_line_id in options.get('unfolded_lines') or options.get('unfold_all'), + } + + def _get_single_value(self, values, key): + return all(values[key][0] == x for x in values[key]) and values[key][0] + + def _get_current_rate(self, values): + return self._get_single_value(values, 'account_rate') or None + + def _get_current_disallowed_amount(self, values): + return values['account_disallowed_amount'] diff --git a/dev_odex30_accounting/odex30_account_disallowed_expenses/security/ir.model.access.csv b/dev_odex30_accounting/odex30_account_disallowed_expenses/security/ir.model.access.csv new file mode 100644 index 0000000..f5c8cc2 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_disallowed_expenses/security/ir.model.access.csv @@ -0,0 +1,5 @@ +"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink" +"access_odx30_account_disallowed_expenses_category","access.odex30.account.disallowed.expenses.category","model_account_disallowed_expenses_category","account.group_account_manager",1,1,1,1 +"access_odx30_account_disallowed_expenses_category_readonly","access.odex30.account.disallowed.expenses.category.readonly","model_account_disallowed_expenses_category","account.group_account_readonly",1,0,0,0 +"access_odx30_account_disallowed_expenses_rate","access.odex30.account.disallowed.expenses.rate","model_account_disallowed_expenses_rate","account.group_account_manager",1,1,1,1 +"access_odx30_account_disallowed_expenses_rate_readonly","access.odex30.account.disallowed.expenses.rate.readonly","model_account_disallowed_expenses_rate","account.group_account_readonly",1,0,0,0 diff --git a/dev_odex30_accounting/odex30_account_disallowed_expenses/security/odex30_account_disallowed_expenses_security.xml b/dev_odex30_accounting/odex30_account_disallowed_expenses/security/odex30_account_disallowed_expenses_security.xml new file mode 100644 index 0000000..991049a --- /dev/null +++ b/dev_odex30_accounting/odex30_account_disallowed_expenses/security/odex30_account_disallowed_expenses_security.xml @@ -0,0 +1,11 @@ + + + + + Account disallowed expenses multi-country + + + [('company_id', 'in', company_ids + [False])] + + + diff --git a/dev_odex30_accounting/odex30_account_disallowed_expenses/static/src/components/disallowed_expenses_report/warnings.xml b/dev_odex30_accounting/odex30_account_disallowed_expenses/static/src/components/disallowed_expenses_report/warnings.xml new file mode 100644 index 0000000..9ebd801 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_disallowed_expenses/static/src/components/disallowed_expenses_report/warnings.xml @@ -0,0 +1,6 @@ + + + + There are multiple disallowed expenses rates in this period + + diff --git a/dev_odex30_accounting/odex30_account_disallowed_expenses/views/odex30_account_account_views.xml b/dev_odex30_accounting/odex30_account_disallowed_expenses/views/odex30_account_account_views.xml new file mode 100644 index 0000000..ffe6f91 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_disallowed_expenses/views/odex30_account_account_views.xml @@ -0,0 +1,25 @@ + + + + account.account.form + account.account + + + + + + + + + + account.account.search + account.account + + + + + + + + diff --git a/dev_odex30_accounting/odex30_account_disallowed_expenses/views/odex30_account_disallowed_expenses_category_views.xml b/dev_odex30_accounting/odex30_account_disallowed_expenses/views/odex30_account_disallowed_expenses_category_views.xml new file mode 100644 index 0000000..e4deabc --- /dev/null +++ b/dev_odex30_accounting/odex30_account_disallowed_expenses/views/odex30_account_disallowed_expenses_category_views.xml @@ -0,0 +1,103 @@ + + + + account.disallowed.expenses.rate.list + account.disallowed.expenses.rate + + + + + + + + + + account.disallowed.expenses.category.list + account.disallowed.expenses.category + + + + + + + +