diff --git a/addons_extensions/bench_management_system/__init__.py b/addons_extensions/bench_management_system/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/addons_extensions/bench_management_system/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/addons_extensions/bench_management_system/__manifest__.py b/addons_extensions/bench_management_system/__manifest__.py new file mode 100644 index 000000000..e721c7e83 --- /dev/null +++ b/addons_extensions/bench_management_system/__manifest__.py @@ -0,0 +1,24 @@ +{ + 'name': 'Bench Management', + 'version': '1.0', + 'category': 'Human Resources', + 'summary': 'Bench Management System', + 'author': 'Team Srivyn', + 'depends': [ + 'hr', + 'project', + 'hr_timesheet', + 'project_task_timesheet_extended', + 'hr_employee_extended' + ], + 'data': [ + 'security/ir.model.access.csv', + 'data/sync_team_lines.xml', + 'views/project.xml', + 'views/bench_management_view.xml', + ], + + 'installable': True, + 'application': True, + 'auto_install': False, +} diff --git a/addons_extensions/bench_management_system/data/sync_team_lines.xml b/addons_extensions/bench_management_system/data/sync_team_lines.xml new file mode 100644 index 000000000..d4cd39c5d --- /dev/null +++ b/addons_extensions/bench_management_system/data/sync_team_lines.xml @@ -0,0 +1,4 @@ + + + + diff --git a/addons_extensions/bench_management_system/models/__init__.py b/addons_extensions/bench_management_system/models/__init__.py new file mode 100644 index 000000000..4d56fe211 --- /dev/null +++ b/addons_extensions/bench_management_system/models/__init__.py @@ -0,0 +1,2 @@ +from . import project +from . import bench_management \ No newline at end of file diff --git a/addons_extensions/bench_management_system/models/bench_management.py b/addons_extensions/bench_management_system/models/bench_management.py new file mode 100644 index 000000000..a59e545de --- /dev/null +++ b/addons_extensions/bench_management_system/models/bench_management.py @@ -0,0 +1,173 @@ +from odoo import models, fields, tools, api + + +class BenchManagementLine(models.Model): + _name = "bench.management.line" + _description = "Employee Availability (Bench)" + _auto = False + _rec_name = 'employee_id' + + employee_id = fields.Many2one("hr.employee", readonly=True) + job_id = fields.Many2one("hr.job", readonly=True) + + project_line_ids = fields.Many2many( + 'project.team.line', + compute='_compute_bench_details', + string='Project Assignments', + readonly=True, + ) + limited_project_line_ids = fields.Many2many( + compute='_compute_bench_details', + comodel_name='project.team.line', + string='Kanban Projects', + readonly=True, + ) + project_names_tooltip = fields.Text( + string="Project Names", + compute='_compute_bench_details', + readonly=True, + ) + project_count = fields.Integer( + string="Project Count", + compute='_compute_bench_details', + readonly=True, + ) + active_project_count = fields.Integer( + string="Active Projects", + compute='_compute_bench_details', + readonly=True, + ) + future_project_count = fields.Integer( + string="Upcoming Projects", + compute='_compute_bench_details', + readonly=True, + ) + completed_project_count = fields.Integer( + string="Completed Projects", + compute='_compute_bench_details', + readonly=True, + ) + + status = fields.Selection([ + ("bench", "Bench"), + ("partial", "Partial"), + ("full", "Full"), + ], readonly=True) + + def _get_line_availability_status(self, line, today): + return line.status or 'not_started' + + def _compute_bench_details(self): + project_team_line = self.env['project.team.line'].sudo() + today = fields.Date.context_today(self) + for rec in self: + project_lines = project_team_line.search( + [('employee_id', '=', rec.employee_id.id)], + order='start_date desc, id desc' + ) + active_lines = project_lines.filtered( + lambda line: rec._get_line_availability_status(line, today) == 'in_progress' + ) + future_lines = project_lines.filtered( + lambda line: rec._get_line_availability_status(line, today) == 'not_started' + ) + completed_lines = project_lines.filtered( + lambda line: rec._get_line_availability_status(line, today) == 'done' + ) + project_records = project_lines.mapped('project_id') + + if active_lines: + bench_status = 'full' + elif future_lines: + bench_status = 'partial' + else: + bench_status = 'bench' + + rec.project_line_ids = project_lines + rec.limited_project_line_ids = project_lines[:3] + rec.project_count = len(project_records) + rec.active_project_count = len(active_lines) + rec.future_project_count = len(future_lines) + rec.completed_project_count = len(completed_lines) + rec.project_names_tooltip = '\n'.join( + f"{line.project_id.display_name or 'No Project'} - {dict(line._fields['status'].selection).get(rec._get_line_availability_status(line, today), 'N/A')}" + for line in project_lines + ) or '' + + def init(self): + tools.drop_view_if_exists(self.env.cr, self._table) + + self.env.cr.execute(""" + CREATE OR REPLACE VIEW bench_management_line AS ( + SELECT + he.id AS id, + he.id AS employee_id, + he.job_id AS job_id, + CASE + WHEN EXISTS ( + SELECT 1 + FROM project_team_line tpl + WHERE tpl.employee_id = he.id + AND tpl.status = 'in_progress' + ) THEN 'full' + WHEN EXISTS ( + SELECT 1 + FROM project_team_line tpl + WHERE tpl.employee_id = he.id + AND tpl.status = 'not_started' + ) THEN 'partial' + ELSE 'bench' + END AS status + FROM hr_employee he + ) + """) + +class ProjectTeamLine(models.Model): + _inherit = 'project.team.line' + + line_status_color = fields.Integer( + compute='_compute_line_status_color', + string='Status Color', + readonly=True, + ) + + @api.depends('status') + def _compute_line_status_color(self): + color_map = { + 'not_started': 8, + 'in_progress': 2, + 'done': 10, + } + for line in self: + line.line_status_color = color_map.get(line.status, 0) + + def name_get(self): + result = [] + for rec in self: + name = rec.project_id.display_name or 'No Project' + result.append((rec.id, name)) + return result + + def _sync_project_members(self): + if self.env.context.get('skip_project_team_member_sync'): + return True + self.mapped('project_id')._sync_members_from_team_lines() + return True + + @api.model_create_multi + def create(self, vals_list): + records = super().create(vals_list) + records._sync_project_members() + return records + + def write(self, vals): + projects = self.mapped('project_id') + res = super().write(vals) + (projects | self.mapped('project_id'))._sync_members_from_team_lines() + return res + + def unlink(self): + projects = self.mapped('project_id') + res = super().unlink() + projects._sync_members_from_team_lines() + return res diff --git a/addons_extensions/bench_management_system/models/project.py b/addons_extensions/bench_management_system/models/project.py new file mode 100644 index 000000000..1bce4c1e4 --- /dev/null +++ b/addons_extensions/bench_management_system/models/project.py @@ -0,0 +1,223 @@ +from odoo import Command, api, fields, models, _ +from odoo.exceptions import ValidationError + +class ProjectProject(models.Model): + _inherit = 'project.project' + + team_line_ids = fields.One2many( + 'project.team.line', + 'project_id', + string="Team Details" + ) + can_manage_team_lines = fields.Boolean( + compute='_compute_can_manage_team_lines', + string='Can Manage Team Lines' + ) + + @api.depends('user_id', 'project_lead') + def _compute_can_manage_team_lines(self): + current_user = self.env.user + for project in self: + project.can_manage_team_lines = bool( + self.env.is_superuser() + or project.user_id == current_user + or ('project_lead' in project._fields and project.project_lead == current_user) + ) + + @api.onchange('team_line_ids') + def _onchange_team_line_ids(self): + for project in self: + users = project.team_line_ids.mapped('user_id') + project.members_ids = [(6, 0, users.ids)] + + def _sync_members_from_team_lines(self): + if self.env.context.get('skip_project_team_member_sync'): + return + for project in self: + users = project.team_line_ids.mapped('user_id') + if set(project.members_ids.ids) != set(users.ids): + project.with_context(skip_project_team_member_sync=True).sudo().write({ + 'members_ids': [Command.set(users.ids)], + }) + + def _sync_team_lines_from_members(self): + if self.env.context.get('skip_project_team_member_sync'): + return + + TeamLine = self.env['project.team.line'].sudo().with_context(skip_project_team_member_sync=True) + for project in self.sudo(): + member_ids = set(project.members_ids.ids) + kept_user_ids = set() + lines_to_remove = self.env['project.team.line'].sudo() + + for line in project.team_line_ids.sorted('id'): + user_id = line.user_id.id + if not user_id or user_id not in member_ids or user_id in kept_user_ids: + lines_to_remove |= line + else: + kept_user_ids.add(user_id) + + if lines_to_remove: + lines_to_remove.with_context(skip_project_team_member_sync=True).unlink() + + for user_id in member_ids - kept_user_ids: + TeamLine.create({ + 'project_id': project.id, + 'user_id': user_id, + }) + + @api.model + def _sync_all_team_lines_from_members(self): + self.search([])._sync_team_lines_from_members() + return True + + @api.model_create_multi + def create(self, vals_list): + projects = super().create(vals_list) + for project, vals in zip(projects, vals_list): + if 'team_line_ids' in vals: + project._sync_members_from_team_lines() + elif 'members_ids' in vals: + project._sync_team_lines_from_members() + return projects + + def write(self, vals): + res = super().write(vals) + if 'team_line_ids' in vals: + self._sync_members_from_team_lines() + elif 'members_ids' in vals: + self._sync_team_lines_from_members() + return res + + + + + +class ProjectTeamLine(models.Model): + _name = 'project.team.line' + _description = 'Project Team Line' + _rec_name = 'project_id' + + project_id = fields.Many2one('project.project', ondelete='cascade') + user_id = fields.Many2one('res.users') + + employee_id = fields.Many2one( + 'hr.employee', + compute="_compute_employee", + store=True + ) + + job_id = fields.Many2one( + 'hr.job', + related='employee_id.job_id', + store=True + ) + + start_date = fields.Date() + end_date = fields.Date() + + status = fields.Selection([ + ('not_started', 'Not Started'), + ('in_progress', 'In Progress'), + ('done', 'Completed') + ], compute='_compute_status', inverse='_inverse_status', store=True, readonly=False) + can_edit_assignment = fields.Boolean( + compute='_compute_can_edit_assignment', + string='Can Edit Assignment' + ) + + # ------------------------ + # COMPUTE EMPLOYEE + # ------------------------ + @api.depends('user_id') + def _compute_employee(self): + for rec in self: + rec.employee_id = self.env['hr.employee'].search([ + ('user_id', '=', rec.user_id.id) + ], limit=1) + + @api.depends('start_date', 'end_date') + def _compute_status(self): + today = fields.Date.context_today(self) + for rec in self: + if rec.end_date and rec.end_date < today: + rec.status = 'done' + elif rec.start_date and rec.start_date > today: + rec.status = 'not_started' + else: + rec.status = 'in_progress' + + @api.depends('project_id.user_id', 'project_id.project_lead') + def _compute_can_edit_assignment(self): + current_user = self.env.user + for rec in self: + project = rec.project_id + rec.can_edit_assignment = bool( + self.env.is_superuser() + or (project and project.user_id == current_user) + or (project and 'project_lead' in project._fields and project.project_lead == current_user) + ) + + def _inverse_status(self): + # Allow manual edits to the stored computed field. + # When start/end dates change later, compute will refresh it again. + return True + + def _check_manager_access(self): + if self.env.is_superuser(): + return + unauthorized = self.filtered(lambda rec: not rec.can_edit_assignment) + if unauthorized: + raise ValidationError(_("Only the related project manager can update team assignment dates or status.")) + + # ------------------------ + # SYNC BENCH + # ------------------------ + def _sync_bench(self): + # Bench data is read live from SQL view / computed fields, + # so there is no separate sync model to refresh here. + return True + + # ------------------------ + # CREATE + # ------------------------ + @api.model_create_multi + def create(self, vals_list): + if not self.env.is_superuser(): + for vals in vals_list: + project_id = vals.get('project_id') + if project_id: + project = self.env['project.project'].browse(project_id) + if not ( + project.user_id == self.env.user + or ('project_lead' in project._fields and project.project_lead == self.env.user) + ): + raise ValidationError(_("Only the related project manager can add team assignments.")) + records = super().create(vals_list) + records._sync_bench() + records.mapped('project_id')._sync_members_from_team_lines() + return records + + # ------------------------ + # WRITE + # ------------------------ + def write(self, vals): + if any(key in vals for key in ('status', 'start_date', 'end_date', 'user_id', 'project_id')): + self._check_manager_access() + projects = self.mapped('project_id') + res = super().write(vals) + self._sync_bench() + if any(key in vals for key in ('user_id', 'project_id')): + (projects | self.mapped('project_id'))._sync_members_from_team_lines() + return res + + # ------------------------ + # UNLINK + # ------------------------ + def unlink(self): + projects = self.mapped('project_id') + self._check_manager_access() + res = super().unlink() + self._sync_bench() + projects._sync_members_from_team_lines() + return res diff --git a/addons_extensions/bench_management_system/security/ir.model.access.csv b/addons_extensions/bench_management_system/security/ir.model.access.csv new file mode 100644 index 000000000..9584d0c62 --- /dev/null +++ b/addons_extensions/bench_management_system/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id,perm_read,perm_write,perm_create,perm_unlink +access_project_team_line,project.team.line,model_project_team_line,,1,1,1,1 +access_bench_management_line,bench.management.line,model_bench_management_line,,1,1,1,1 \ No newline at end of file diff --git a/addons_extensions/bench_management_system/views/bench_management_view.xml b/addons_extensions/bench_management_system/views/bench_management_view.xml new file mode 100644 index 000000000..38f8ee441 --- /dev/null +++ b/addons_extensions/bench_management_system/views/bench_management_view.xml @@ -0,0 +1,250 @@ + + + + + bench.management.line.list + bench.management.line + + + + + + + + + + + + + + + bench.management.line.search + bench.management.line + + + + + + + + + + + + + + + + + + + bench.management.line.form + bench.management.line + +
+ + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + bench.management.line.kanban + bench.management.line + + + + + + + + + + + + + + + + + + +
+ + +
+ + + +
+ +
+ +
+ +
+ +
+ +
+ +
+ + +
+ + + + Bench + + + + + + Partially Available + + + + + + Fully Allocated + + + +
+ Active +
+ +
+ +
+
+
Current
+
+ +
+
+
+
Upcoming
+
+ +
+
+
+
Completed
+
+ +
+
+
+ + +
+ +
+ Projects +
+ +
+ +
+ + +
+ + more +
+
+ +
+ +
+ +
+ +
+ +
+ +
+
+ + + Bench Management + bench.management.line + kanban,list,form + + + + + +
diff --git a/addons_extensions/bench_management_system/views/project.xml b/addons_extensions/bench_management_system/views/project.xml new file mode 100644 index 000000000..778704f19 --- /dev/null +++ b/addons_extensions/bench_management_system/views/project.xml @@ -0,0 +1,30 @@ + + + + project.project.inherit.form.view.inherit + project.project + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons_extensions/employee_it_declaration/__manifest__.py b/addons_extensions/employee_it_declaration/__manifest__.py index befdcf53b..5a6e3ee94 100644 --- a/addons_extensions/employee_it_declaration/__manifest__.py +++ b/addons_extensions/employee_it_declaration/__manifest__.py @@ -51,10 +51,11 @@ 'wizards/children_education_costing.xml', 'wizards/employee_life_insurance.xml', 'wizards/nsc_declaration.xml', - 'wizards/self_occupied_property.xml', - 'wizards/letout_house_property.xml', - 'wizards/nsc_income_loss.xml', - # 'views/it_investment_type.xml', - # 'views/it_investment_costing.xml' - ], -} \ No newline at end of file + 'wizards/self_occupied_property.xml', + 'wizards/letout_house_property.xml', + 'wizards/nsc_income_loss.xml', + 'data/default_investment_types.xml', + # 'views/it_investment_type.xml', + # 'views/it_investment_costing.xml' + ], +} diff --git a/addons_extensions/employee_it_declaration/data/default_investment_types.xml b/addons_extensions/employee_it_declaration/data/default_investment_types.xml new file mode 100644 index 000000000..02d05b1d0 --- /dev/null +++ b/addons_extensions/employee_it_declaration/data/default_investment_types.xml @@ -0,0 +1,579 @@ + + + + 5 + past_employment + both + + + 10 + us_80c + both + + + 20 + us_80d + both + + + 30 + us_10 + both + + + 40 + us_80g + both + + + 50 + chapter_via + both + + + 60 + us_17 + both + + + 70 + house_rent + old + + + 80 + other_i_or_l + both + + + 90 + other_declaration + both + + + + + 10 + Previous Employer Total Income (Gross Salary + Any Other Income) + + PETI + both + + + 20 + Previous Employer LESS: US10 LTA exemption + + PELTA + old + + + 30 + Previous Employer LESS: US10 Gratuity exemption + + PEGRAT + old + + + 40 + Previous Employer LESS: US10 Leave encashment exemption + + PELEAV + old + + + 50 + Previous Employer LESS: US10 Others (HRAUniformWashingetc) + + PEOTH + old + + + 60 + Previous Employer Prof.Tax + + PEPROF + old + + + 70 + Previous Employer Standard Deduction Benefit Claimed + + PESTD + old + + + 80 + Previous Employer Invest US 80C + + PEIUSC + old + + + 90 + Previous Employer Chapter IVAUS80CCCothers + + PECHAP + old + + + 100 + Net Taxable Income + + PENET + 1 + PETI - PELTA - PEGRAT - PELEAV - PEOTH - PEPROF - PESTD - PEIUSC - PECHAP + old + + + 110 + Previous Employer Taxable Income After Exemption (Gross - US10 - Chapter VIA US80C) Except Prof.Tax + + PEAFT + 1 + PETI - PELTA - PEGRAT - PELEAV - PEOTH - PEIUSC - PECHAP + both + + + 120 + Previous Employer Tax + + PETAX + old + + + 130 + Previous Employer Surcharge + + PESUR + old + + + 140 + Previous Employer Cess + + PECESS + old + + + 150 + Previous Employer TDS Deduction + + PETDS + old + + + 160 + Previous Employer House Rent Allowance Exemptions + + PEHRA + old + + + 170 + Previous Employer Conveyance Exemptions + + PECON + old + + + 180 + Previous Employer Medical Reimbursement Exemptions + + PEMED + old + + + 190 + Previous Employer LTA Exemption + + PELT2 + old + + + 200 + Previous Employer Children Exemption + + PECHLD + old + + + 210 + Previous Employer other Exemption + + PEOT2 + old + + + 220 + Previous Employer Infra exemption + + PEINF + old + + + 230 + Previous Employer Gratuity Received + + PEGR2 + old + + + + + 10 + US 80CCD(1) - Contribution to NPS Scheme (10% of salary) + + C80NPS + 150000 + old + + + 20 + Mutual Fund - Equity Linked Savings Scheme (ELSS) + + C80ELSS + 150000 + old + + + 30 + US 80CCC - Pension Funds + + C80PEN + 150000 + old + + + 40 + Children Education Fees + + C80EDU + 1 + + 150000 + old + + + 50 + Public Provident Fund (PPF) + + C80PPF + 150000 + old + + + 60 + LIC - Life Insurance Premiums + + C80LIC + 1 + + 150000 + old + + + 70 + National Savings Certificate (NSC) + + C80NSC + 1 + + 150000 + old + + + 80 + Unit linked Insurance Plan (ULIP) + + C80ULIP + 150000 + old + + + 90 + 5-Yr bank fixed deposits (FDs) + + C80FD5 + 150000 + old + + + 100 + 5-Yr post office time deposit (POTD) scheme + + C80POT + 150000 + old + + + 110 + Certificate provided for Home Loan Principal Repayment + + C80HLP + 150000 + old + + + 120 + Infrastructure Fund + + C80INF + 150000 + old + + + 130 + NABARD rural bonds + + C80NRB + 150000 + old + + + 140 + Provident Fund (PF) + + C80PF + old + + + 150 + Senior Citizen Savings Scheme 2004 (SCSS) + + C80SCS + 150000 + old + + + 160 + Stamp Duty and Registration Charges for a home + + C80SDR + 150000 + old + + + 170 + Superannuation + + C80SUP + 150000 + old + + + 180 + Voluntary Provident Fund (VPF) + + C80VPF + old + + + 190 + Sukanya Samriddhi Scheme + + C80SSS + old + + + + + 10 + US 80D - Medical Insurance Premium Self + + 1 + 25000 + old + + + 20 + US 80D - Preventive Health Checkup (Self) + + 1 + 5000 + old + + + 30 + US 80D - Medical Insurance Premium Parents below 60 years + + 1 + 25000 + old + + + 40 + US 80D - Medical Insurance Premium Parents (Senior Citizen) + + 1 + 50000 + old + + + 50 + US 80D - Medical Insurance Expenditure Very Senior Citizen + + 1 + 50000 + old + + + + + 10 + Children Education Allowance Exemptions + + 72000 + old + + + 20 + Retrenchment Exemptions + + 500000 + old + + + 30 + Leave Travel Allowance Exemptions + + old + + + 40 + Medical Reimbursement Exemptions + + old + + + 50 + Uniform Allowance US10 + + old + + + + 10US 80G - Donationsold + 2001-National Defence Fundold + 3002-PM's National Relief Fundold + 4003-PM's Armenia Earthquake Relief Fundold + 5004-Africa (Public Contributions - India) Fundold + 6005-National Foundation for Communal Harmonyold + 7006-University/Educational Institution of National Eminenceold + 8007-Maharashtra CM's Earthquake relief Fundold + 9008-Zila Saksharta Samitiold + 10010-The National Blood Transfusion Councilold + 11011-State Government medical relief to the poorold + 12012-The Army Central Welfare Fundold + 13013-The Indian Naval Benevolent Fundold + 14014-The Air Force Central Welfare Fundold + 15015-Andhra Pradesh CM's Cyclone Relief Fund 1996old + 16016-National Illness Assistance Fundold + 17017-The CM's Relief Fund of any Stateold + 18018-Lt. Governor's Relief Fund - Union Territoryold + 19019-National sports Fund - Central Governmentold + 20020-National Cultural Fund - Central Governmentold + 21021-Fund for Tech Devt - Central Governmentold + 22022-Welfare of persons with Disabilitiesold + 23023-Any Trust, institution or Fund covered under section 80G providing relief to the victims of earthquake in Gujarat, provided such donation is made between 26/01/2001 to 30/09/2001old + 24025-Prime Minister's Drought Relief Fundold + 25029-For promoting family planningold + 26030-To any Olympic Association or to any other association or institution established in India and notified by the Central Government for Development of infrastructure of sports and Games, or sponsorship for sports or Gamesold + 27031-For other than promoting family planningold + 28032-Institutions/Charitable Trust with 80G approvalold + 29033-For improvement of cities, towns or villagesold + 30034-Corporation-Promoting Interests in Minorityold + 31035-For renovation-Notified Places like Templesold + 32036-National Children's Fundold + 33037-Swachh Bharat Koshold + 34038-Clean Ganga Fundold + 35039-National Fund for Control of Drug Abuseold + + + 10US 80CCG - Investments in RGESS25000old + 20US 80DDB - Maintenance Including medical treatment125000old + 30US 80DDB - Medical treatment for non senior citizens40000old + 40US 80DDBS - Medical treatment for senior citizens100000old + 50US 80E - Higher educationold + 60US 80EE - Interest Paid On Home Loan50000old + 70US 80GG - House Rent Exemption60000old + 80US 24 - Certificate provided for Interest Paid On Home Loan200000old + 90US 24 - Interest Paid On Home Loan (First time buyer)50000old + 100US 24 - Interest Paid On Home Loan For Let Out Property200000old + 110US 24 - Interest Paid On Loan Before 1st April 199930000old + 120US 80CCD - National Pension Scheme (Employee Contribution)50000old + 130US 80CCD (2) - National Pension Scheme (Employer Contribution)both + 140US 80CCG Outside National Pension Scheme (Employee Contribution)50000old + 150US 80CCF - Long term Infrastructure bondsold + 160US 80QQB - Royalty on Books300000old + 170US 80RRB - Royalty on patents300000old + 180US 80TTA - Interest on Saving accounts10000old + 190US 80U - Disability125000old + 200US80TTB - For Senior Citizen, exempt Interest from FDs, Post Office50000old + 210US80EEA - Home Loans Taken on Self-Occupied House Property BY 31-Mar-2020150000old + 220US80EEB - ELECTRONIC VEHICLE EXEMPTION150000old + + + 10Academic Allowanceold + 20Helper Allowanceold + 30Petrol Allowanceold + 40Utility Reimbursementold + 50Books Proofsold + 60Car Maintenance Reimbursementold + 70Car Maintenance - Small Upto 1600 CCold + 80Cell Phone Proofsold + 90Conveyance Proofsold + 100Driver Salary Reimbursementold + 110Driver Salary Reimbursement - Small Car Upto 1600 CCold + 120Driver Proofs36000both + 130Driver, Petrol & Car Maintenance Proofsboth + 140Petrol & Car Maintenance Proofs84000both + 150Entertainment Proofsold + 160Fuel Reimbursementold + 170General Other Reimbursementold + 180Gift Reimbursementold + 190Internet Reimbursementold + 200Journal Reimbursementold + 210Maintenance Reimbursementold + 220Meal Proofsold + 230News Paper Proofsold + 240Parking Proofsboth + 250Telephone Allowanceold + 260Toll Proofsboth + 270Uniform Reimbursementold + + + 10Self occupied house property U/S 241old + 20Bank Interestold + 30Debenture Interestold + 40External Income Othersold + 50Income on Let Out House Property1both + 60Loss on Let Out House Property-200000old + 70Interest on NSC (80 I)1old + 80Previous Employer Tax free other incomeold + 90Fully Taxable Incomeold + 100Fully Taxable Other Incomeboth + 110EXTERNAL_INC_VAR_PERCENTAGEold + 120Previous Employer Other Incomeold + + + + 10 + Medical Insurance Premium Manual Input + + old + + + 20 + Number of Hostel going children + + 2 + old + + + 30 + Number of school going children + + 2 + old + + diff --git a/addons_extensions/employee_it_declaration/models/emp_it_declaration.py b/addons_extensions/employee_it_declaration/models/emp_it_declaration.py index b37bf8524..19b3db83a 100644 --- a/addons_extensions/employee_it_declaration/models/emp_it_declaration.py +++ b/addons_extensions/employee_it_declaration/models/emp_it_declaration.py @@ -1,7 +1,4 @@ -from odoo import models, fields, api, _ -from odoo.exceptions import ValidationError -from datetime import datetime, timedelta -import calendar +from odoo import models, fields, api class EmpITDeclaration(models.Model): @@ -42,103 +39,276 @@ class EmpITDeclaration(models.Model): ('old', 'Old Regime') ], string="Tax Regime", required=True, default='new') - total_investment = fields.Float(string='Total Investment') - - costing_details_generated = fields.Boolean(default=False) - - investment_costing_ids = fields.One2many('investment.costings','it_declaration_id') - house_rent_costing_id = fields.Many2one('investment.costings', compute="_compute_investment_costing") + total_investment = fields.Float(string='Total Investment') + + costing_details_generated = fields.Boolean(default=False) + + investment_costing_ids = fields.One2many('investment.costings','it_declaration_id') + visible_investment_costing_ids = fields.Many2many( + 'investment.costings', + compute='_compute_visible_investment_costing_ids', + string='Visible Investment Costings', + ) + house_rent_costing_id = fields.Many2one('investment.costings', compute="_compute_investment_costing") is_section_open = fields.Boolean() - @api.depends('costing_details_generated','investment_costing_ids') - def _compute_investment_costing(self): - for rec in self: - if rec.investment_costing_ids and rec.costing_details_generated: - rec.house_rent_costing_id = rec.investment_costing_ids.filtered( + @api.depends('costing_details_generated','investment_costing_ids') + def _compute_investment_costing(self): + for rec in self: + if rec.investment_costing_ids and rec.costing_details_generated: + rec.house_rent_costing_id = rec.investment_costing_ids.filtered( lambda e: e.investment_type_id.investment_type == 'house_rent' )[:1] - else: - rec.house_rent_costing_id = False - past_employment_costings = fields.One2many('past_employment.costing.type','it_declaration_id',domain=[('investment_type_line_id.tax_regime', 'in', ['old', 'both'])]) - past_employment_costings_new = fields.One2many('past_employment.costing.type','it_declaration_id',domain=[('investment_type_line_id.tax_regime', 'in', ['new', 'both'])]) - us80c_costings = fields.One2many('us80c.costing.type','it_declaration_id') - us80d_selection_type = fields.Selection([('self_family','Self-family'),('self_family_parent','Self-family and parent'),('self_family_senior_parent','Self-family and senior parent')], default='self_family',required=True) - us80d_health_checkup = fields.Boolean(string='Preventive Health Checkup') - us80d_costings = fields.One2many('us80d.costing.type','it_declaration_id',domain=[('investment_type_line_id.for_family','=',True),('investment_type_line_id.for_parents','=',False),('investment_type_line_id.for_senior_parent','=',False)]) - us80d_costings_parents = fields.One2many('us80d.costing.type','it_declaration_id',domain=['|',('investment_type_line_id.for_family','=',True),('investment_type_line_id.for_parents','=',True),('investment_type_line_id.for_senior_parent','=',False)]) - us80d_costings_senior_parents = fields.One2many('us80d.costing.type','it_declaration_id',domain=['|','|',('investment_type_line_id.for_family','=',True),('investment_type_line_id.for_parents','=',True),('investment_type_line_id.for_senior_parent','=',True)]) + else: + rec.house_rent_costing_id = False + + @api.depends( + 'investment_costing_ids', + 'investment_costing_ids.investment_type_id', + 'investment_costing_ids.investment_type_id.active', + 'period_id', + 'tax_regime', + ) + def _compute_visible_investment_costing_ids(self): + for rec in self: + visible_investment_type_ids = rec._get_visible_investment_types().ids + rec.visible_investment_costing_ids = rec.investment_costing_ids.filtered( + lambda costing: costing.investment_type_id + and costing.investment_type_id.id in visible_investment_type_ids + ) + past_employment_costings = fields.One2many('past_employment.costing.type','it_declaration_id',domain=[('investment_type_line_id.tax_regime', 'in', ['old', 'both'])]) + past_employment_costings_new = fields.One2many('past_employment.costing.type','it_declaration_id',domain=[('investment_type_line_id.tax_regime', 'in', ['new', 'both'])]) + us80c_costings = fields.One2many('us80c.costing.type','it_declaration_id',domain=[('investment_type_line_id.tax_regime', 'in', ['old', 'both'])]) + us80c_costings_new = fields.One2many('us80c.costing.type','it_declaration_id',domain=[('investment_type_line_id.tax_regime', 'in', ['new', 'both'])]) + us80d_selection_type = fields.Selection([('self_family','Self-family'),('self_family_parent','Self-family and parent'),('self_family_senior_parent','Self-family and senior parent')], default='self_family',required=True) + us80d_health_checkup = fields.Boolean(string='Preventive Health Checkup') + us80d_costings = fields.One2many('us80d.costing.type','it_declaration_id',domain=[('investment_type_line_id.for_family','=',True),('investment_type_line_id.for_parents','=',False),('investment_type_line_id.for_senior_parent','=',False),('investment_type_line_id.tax_regime', 'in', ['old', 'both'])]) + us80d_costings_new = fields.One2many('us80d.costing.type','it_declaration_id',domain=[('investment_type_line_id.for_family','=',True),('investment_type_line_id.for_parents','=',False),('investment_type_line_id.for_senior_parent','=',False),('investment_type_line_id.tax_regime', 'in', ['new', 'both'])]) + us80d_costings_parents = fields.One2many('us80d.costing.type','it_declaration_id',domain=['|',('investment_type_line_id.for_family','=',True),('investment_type_line_id.for_parents','=',True),('investment_type_line_id.for_senior_parent','=',False),('investment_type_line_id.tax_regime', 'in', ['old', 'both'])]) + us80d_costings_parents_new = fields.One2many('us80d.costing.type','it_declaration_id',domain=['|',('investment_type_line_id.for_family','=',True),('investment_type_line_id.for_parents','=',True),('investment_type_line_id.for_senior_parent','=',False),('investment_type_line_id.tax_regime', 'in', ['new', 'both'])]) + us80d_costings_senior_parents = fields.One2many('us80d.costing.type','it_declaration_id',domain=['|','|',('investment_type_line_id.for_family','=',True),('investment_type_line_id.for_parents','=',True),('investment_type_line_id.for_senior_parent','=',True),('investment_type_line_id.tax_regime', 'in', ['old', 'both'])]) + us80d_costings_senior_parents_new = fields.One2many('us80d.costing.type','it_declaration_id',domain=['|','|',('investment_type_line_id.for_family','=',True),('investment_type_line_id.for_parents','=',True),('investment_type_line_id.for_senior_parent','=',True),('investment_type_line_id.tax_regime', 'in', ['new', 'both'])]) + + us10_costings = fields.One2many('us10.costing.type','it_declaration_id',domain=[('investment_type_line_id.tax_regime', 'in', ['old', 'both'])]) + us10_costings_new = fields.One2many('us10.costing.type','it_declaration_id',domain=[('investment_type_line_id.tax_regime', 'in', ['new', 'both'])]) + us80g_costings = fields.One2many('us80g.costing.type','it_declaration_id',domain=[('investment_type_line_id.tax_regime', 'in', ['old', 'both'])]) + us80g_costings_new = fields.One2many('us80g.costing.type','it_declaration_id',domain=[('investment_type_line_id.tax_regime', 'in', ['new', 'both'])]) + chapter_via_costings = fields.One2many('chapter.via.costing.type','it_declaration_id',domain=[('investment_type_line_id.tax_regime', 'in', ['old', 'both'])]) + chapter_via_costings_new = fields.One2many('chapter.via.costing.type','it_declaration_id',domain=[('investment_type_line_id.tax_regime', 'in', ['new', 'both'])]) + us17_costings = fields.One2many('us17.costing.type','it_declaration_id',domain=[('investment_type_line_id.tax_regime', 'in', ['old', 'both'])]) + us17_costings_new = fields.One2many('us17.costing.type','it_declaration_id',domain=[('investment_type_line_id.tax_regime', 'in', ['new', 'both'])]) + + house_rent_costings = fields.One2many('house.rent.declaration','it_declaration_id') + + other_il_costings = fields.One2many('other.il.costing.type','it_declaration_id',domain=[('investment_type_line_id.tax_regime', 'in', ['old', 'both'])]) + other_il_costings_new = fields.One2many('other.il.costing.type','it_declaration_id',domain=[('investment_type_line_id.tax_regime', 'in', ['new', 'both'])]) + other_declaration_costings = fields.One2many('other.declaration.costing.type','it_declaration_id',domain=[('investment_type_line_id.tax_regime', 'in', ['old', 'both'])]) + other_declaration_costings_new = fields.One2many('other.declaration.costing.type','it_declaration_id',domain=[('investment_type_line_id.tax_regime', 'in', ['new', 'both'])]) + show_past_employment = fields.Boolean(compute="_compute_show_records") + show_us_80c = fields.Boolean(compute="_compute_show_records") + show_us_80d = fields.Boolean(compute="_compute_show_records") + show_us_10 = fields.Boolean(compute="_compute_show_records") + show_us_80g = fields.Boolean(compute="_compute_show_records") + show_chapter_via = fields.Boolean(compute="_compute_show_records") + show_us_17 = fields.Boolean(compute="_compute_show_records") + show_house_rent = fields.Boolean(compute="_compute_show_records") + show_other_i_or_l = fields.Boolean(compute="_compute_show_records") + show_other_declaration = fields.Boolean(compute="_compute_show_records") + + @api.model + def _investment_type_line_fields(self): + return { + 'past_employment': 'past_employment_ids', + 'us_80c': 'us80c_ids', + 'us_80d': 'us80d_ids', + 'us_10': 'us10_ids', + 'us_80g': 'us80g_ids', + 'chapter_via': 'chapter_via_ids', + 'us_17': 'us17_ids', + 'other_i_or_l': 'other_il_ids', + 'other_declaration': 'other_declaration_ids', + } + + def _get_available_investment_types(self): + self.ensure_one() + if not self.period_id: + return self.env['it.investment.type'] + return self.env['it.investment.type'].sudo().search([ + ('active', '=', True), + '|', + ('period_ids', 'in', self.period_id.id), + ('period_ids', '=', False), + ]) + + def _get_visible_investment_types(self): + self.ensure_one() + return self._get_available_investment_types().filtered( + lambda investment_type: self._is_investment_type_visible(investment_type) + ) + + @api.model + def _investment_type_generation_config(self): + return { + 'past_employment': ('past_employment_ids', 'past_employment.costing.type'), + 'us_80c': ('us80c_ids', 'us80c.costing.type'), + 'us_80d': ('us80d_ids', 'us80d.costing.type'), + 'us_10': ('us10_ids', 'us10.costing.type'), + 'us_80g': ('us80g_ids', 'us80g.costing.type'), + 'chapter_via': ('chapter_via_ids', 'chapter.via.costing.type'), + 'us_17': ('us17_ids', 'us17.costing.type'), + 'other_i_or_l': ('other_il_ids', 'other.il.costing.type'), + 'other_declaration': ('other_declaration_ids', 'other.declaration.costing.type'), + } + + def _get_regime_values(self): + self.ensure_one() + return ['old', 'both'] if self.tax_regime == 'old' else ['new', 'both'] + + def _get_regime_filtered_costings(self, field_name): + self.ensure_one() + return self[field_name].filtered( + lambda line: line.investment_type_line_id + and line.investment_type_line_id.tax_regime in self._get_regime_values() + ) + + def _ensure_investment_costing_records(self): + for rec in self: + available_investment_types = rec._get_available_investment_types() + generation_config = rec._investment_type_generation_config() + + for inv_type in available_investment_types: + investment_costing = rec.investment_costing_ids.filtered( + lambda cost: cost.investment_type_id == inv_type + )[:1] + if not investment_costing: + investment_costing = self.env['investment.costings'].sudo().create({ + 'investment_type_id': inv_type.id, + 'it_declaration_id': rec.id, + }) + + config = generation_config.get(inv_type.investment_type) + if not config: + continue + + line_field, costing_model = config + active_lines = getattr(inv_type, line_field).filtered(lambda line: line.active) + existing_costings = self.env[costing_model].sudo().search([ + ('it_declaration_id', '=', rec.id), + ('costing_type', '=', investment_costing.id), + ]) + existing_line_ids = set(existing_costings.mapped('investment_type_line_id').ids) + + for investment_line in active_lines: + if investment_line.id in existing_line_ids: + continue + self.env[costing_model].sudo().create({ + 'costing_type': investment_costing.id, + 'it_declaration_id': rec.id, + 'investment_type_line_id': investment_line.id, + 'limit': investment_line.limit, + }) + + def _update_investment_amounts(self): + for rec in self: + for investment_type in rec.investment_costing_ids: + if investment_type.investment_type_id.investment_type == 'past_employment': + costings = rec.past_employment_costings if rec.tax_regime == 'old' else rec.past_employment_costings_new + investment_type.amount = sum( + cost.declaration_amount + for cost in costings + if not cost.investment_type_line_id.compute_method + ) + elif investment_type.investment_type_id.investment_type == 'us_80c': + costings = rec.us80c_costings if rec.tax_regime == 'old' else rec.us80c_costings_new + investment_type.amount = sum( + cost.declaration_amount + for cost in costings + if not cost.investment_type_line_id.compute_method + ) + elif investment_type.investment_type_id.investment_type == 'us_80d': + if rec.us80d_selection_type == 'self_family': + costings = rec.us80d_costings if rec.tax_regime == 'old' else rec.us80d_costings_new + elif rec.us80d_selection_type == 'self_family_parent': + costings = rec.us80d_costings_parents if rec.tax_regime == 'old' else rec.us80d_costings_parents_new + else: + costings = rec.us80d_costings_senior_parents if rec.tax_regime == 'old' else rec.us80d_costings_senior_parents_new + investment_type.amount = sum(costings.mapped('declaration_amount') or [0]) + elif investment_type.investment_type_id.investment_type == 'us_10': + costings = rec.us10_costings if rec.tax_regime == 'old' else rec.us10_costings_new + investment_type.amount = sum(costings.mapped('declaration_amount') or [0]) + elif investment_type.investment_type_id.investment_type == 'us_80g': + costings = rec.us80g_costings if rec.tax_regime == 'old' else rec.us80g_costings_new + investment_type.amount = sum(costings.mapped('declaration_amount') or [0]) + elif investment_type.investment_type_id.investment_type == 'chapter_via': + costings = rec.chapter_via_costings if rec.tax_regime == 'old' else rec.chapter_via_costings_new + investment_type.amount = sum(costings.mapped('declaration_amount') or [0]) + elif investment_type.investment_type_id.investment_type == 'us_17': + costings = rec.us17_costings if rec.tax_regime == 'old' else rec.us17_costings_new + investment_type.amount = sum(costings.mapped('declaration_amount') or [0]) + elif investment_type.investment_type_id.investment_type == 'house_rent': + investment_type.amount = sum(rec.house_rent_costings.mapped('rent_amount') or [0]) if rec.tax_regime == 'old' else 0 + elif investment_type.investment_type_id.investment_type == 'other_i_or_l': + costings = rec.other_il_costings if rec.tax_regime == 'old' else rec.other_il_costings_new + investment_type.amount = sum(costings.mapped('declaration_amount') or [0]) + elif investment_type.investment_type_id.investment_type == 'other_declaration': + costings = rec.other_declaration_costings if rec.tax_regime == 'old' else rec.other_declaration_costings_new + investment_type.amount = sum(costings.mapped('declaration_amount') or [0]) + + def _is_investment_type_visible(self, investment_type): + self.ensure_one() + if not investment_type or not investment_type.active: + return False + valid_regimes = ['old', 'both'] if self.tax_regime == 'old' else ['new', 'both'] + if investment_type.regime not in valid_regimes: + return False + line_field = self._investment_type_line_fields().get(investment_type.investment_type) + if not line_field: + return True + return bool(getattr(investment_type, line_field).filtered( + lambda line: line.active and line.tax_regime in valid_regimes + )) + + @api.depends('period_id', 'tax_regime') + def _compute_show_records(self): + field_mapping = { + 'past_employment': 'show_past_employment', + 'us_80c': 'show_us_80c', + 'us_80d': 'show_us_80d', + 'us_10': 'show_us_10', + 'us_80g': 'show_us_80g', + 'chapter_via': 'show_chapter_via', + 'us_17': 'show_us_17', + 'house_rent': 'show_house_rent', + 'other_i_or_l': 'show_other_i_or_l', + 'other_declaration': 'show_other_declaration', + } + for rec in self: + visible_investment_types = rec._get_visible_investment_types() + for field_name in field_mapping.values(): + rec[field_name] = False + + for investment_type_key, field_name in field_mapping.items(): + rec[field_name] = bool(visible_investment_types.filtered( + lambda inv: inv.investment_type == investment_type_key + )) - us10_costings = fields.One2many('us10.costing.type','it_declaration_id') - us80g_costings = fields.One2many('us80g.costing.type','it_declaration_id') - chapter_via_costings = fields.One2many('chapter.via.costing.type','it_declaration_id',domain=[('investment_type_line_id.tax_regime', 'in', ['old', 'both'])]) - chapter_via_costings_new = fields.One2many('chapter.via.costing.type','it_declaration_id',domain=[('investment_type_line_id.tax_regime', 'in', ['new', 'both'])]) - us17_costings = fields.One2many('us17.costing.type','it_declaration_id') - - house_rent_costings = fields.One2many('house.rent.declaration','it_declaration_id') - - other_il_costings = fields.One2many('other.il.costing.type','it_declaration_id',domain=[('investment_type_line_id.tax_regime', 'in', ['old', 'both'])]) - other_il_costings_new = fields.One2many('other.il.costing.type','it_declaration_id',domain=[('investment_type_line_id.tax_regime', 'in', ['new', 'both'])]) - other_declaration_costings = fields.One2many('other.declaration.costing.type','it_declaration_id') - - - def toggle_section_visibility(self): - for rec in self: - rec.is_section_open = not rec.is_section_open - if rec.is_section_open: - for investment_type in rec.investment_costing_ids: - if investment_type.investment_type_id.investment_type == 'past_employment': - if rec.tax_regime == 'old': - investment_type.amount = sum( - cost.declaration_amount - for cost in rec.past_employment_costings - if not cost.investment_type_line_id.compute_method - ) - else: - investment_type.amount = sum( - cost.declaration_amount - for cost in rec.past_employment_costings_new - if not cost.investment_type_line_id.compute_method - ) - elif investment_type.investment_type_id.investment_type == 'us_80c': - investment_type.amount = sum( - cost.declaration_amount - for cost in rec.us80c_costings - if not cost.investment_type_line_id.compute_method - ) if rec.tax_regime == 'old' else 0 - elif investment_type.investment_type_id.investment_type == 'us_80d': - if rec.us80d_selection_type == 'self_family': - investment_type.amount = sum(rec.us80d_costings.mapped('declaration_amount') or [0]) if rec.tax_regime == 'old' else 0 - if rec.us80d_selection_type == 'self_family_parent': - investment_type.amount = sum(rec.us80d_costings_parents.mapped('declaration_amount') or [0]) if rec.tax_regime == 'old' else 0 - if rec.us80d_selection_type == 'self_family_senior_parent': - investment_type.amount = sum(rec.us80d_costings_senior_parents.mapped('declaration_amount') or [0]) if rec.tax_regime == 'old' else 0 - elif investment_type.investment_type_id.investment_type == 'us_10': - investment_type.amount = sum(rec.us10_costings.mapped('declaration_amount') or [0]) if rec.tax_regime == 'old' else 0 - elif investment_type.investment_type_id.investment_type == 'us_80g': - investment_type.amount = sum(rec.us80g_costings.mapped('declaration_amount') or [0]) if rec.tax_regime == 'old' else 0 - elif investment_type.investment_type_id.investment_type == 'chapter_via': - if rec.tax_regime == 'old': - investment_type.amount = sum(rec.chapter_via_costings.mapped('declaration_amount') or [0]) - else: - investment_type.amount = sum(rec.chapter_via_costings_new.mapped('declaration_amount') or [0]) - elif investment_type.investment_type_id.investment_type == 'us_17': - investment_type.amount = sum(rec.us17_costings.mapped('declaration_amount') or [0]) if rec.tax_regime == 'old' else 0 - elif investment_type.investment_type_id.investment_type == 'house_rent': - investment_type.amount = sum(rec.house_rent_costings.mapped('rent_amount') or [0]) if rec.tax_regime == 'old' else 0 - elif investment_type.investment_type_id.investment_type == 'other_i_or_l': - if rec.tax_regime == 'old': - investment_type.amount = sum(rec.other_il_costings.mapped('declaration_amount') or [0]) - else: - investment_type.amount = sum(rec.other_il_costings_new.mapped('declaration_amount') or [0]) - elif investment_type.investment_type_id.investment_type == 'other_declaration': - investment_type.amount = sum(rec.other_declaration_costings.mapped('declaration_amount') or [0]) if rec.tax_regime == 'old' else 0 - - @api.onchange('tax_regime') - def _onchange_tax_regime(self): - if self.tax_regime: - # res = super(empITDeclaration, self).fields_get(allfields, attributes) - # self.fields_get() - if self.tax_regime == 'new': - domain = [('investment_type_line_id.tax_regime', 'in', ['new', 'both'])] + def toggle_section_visibility(self): + for rec in self: + rec.is_section_open = not rec.is_section_open + if rec.is_section_open: + if rec.costing_details_generated: + rec._ensure_investment_costing_records() + rec._update_investment_amounts() + + @api.onchange('tax_regime') + def _onchange_tax_regime(self): + if self.costing_details_generated: + self._ensure_investment_costing_records() + self._update_investment_amounts() + if self.tax_regime: + # res = super(empITDeclaration, self).fields_get(allfields, attributes) + # self.fields_get() + if self.tax_regime == 'new': + domain = [('investment_type_line_id.tax_regime', 'in', ['new', 'both'])] elif self.tax_regime == 'old': domain = [('investment_type_line_id.tax_regime', 'in', ['old', 'both'])] else: @@ -170,105 +340,9 @@ class EmpITDeclaration(models.Model): # 'past_employment_costings': [('investment_type_line_id.tax_regime', 'in', ['new','both'])] # } # } - - def generate_declarations(self): - for rec in self: - investment_types = self.env['it.investment.type'].sudo().search([]) - for inv_type in investment_types: - investment_costing = self.env['investment.costings'].sudo().create({ - 'investment_type_id': inv_type.id, - 'it_declaration_id': rec.id, - }) - - if inv_type.investment_type == 'past_employment': - past_emp_costing_ids = [ - self.env['past_employment.costing.type'].sudo().create({ - 'costing_type': investment_costing.id, - 'it_declaration_id': rec.id, - 'investment_type_line_id': investment_line.id, - 'limit': investment_line.limit - }).id - for investment_line in inv_type.past_employment_ids - ] - if inv_type.investment_type == 'us_80c': - - us80c_costing_ids = [ - self.env['us80c.costing.type'].sudo().create({ - 'costing_type': investment_costing.id, - 'it_declaration_id': rec.id, - 'investment_type_line_id': investment_line.id, - 'limit': investment_line.limit - }).id - for investment_line in inv_type.us80c_ids - ] - if inv_type.investment_type == 'us_80d': - us80d_costing_ids = [ - self.env['us80d.costing.type'].sudo().create({ - 'costing_type': investment_costing.id, - 'it_declaration_id': rec.id, - 'investment_type_line_id': investment_line.id, - 'limit': investment_line.limit - }).id - for investment_line in inv_type.us80d_ids - ] - if inv_type.investment_type == 'us_10': - us10_costing_ids = [ - self.env['us10.costing.type'].sudo().create({ - 'costing_type': investment_costing.id, - 'it_declaration_id': rec.id, - 'investment_type_line_id': investment_line.id, - 'limit': investment_line.limit - }).id - for investment_line in inv_type.us10_ids - ] - if inv_type.investment_type == 'us_80g': - us80g_costing_ids = [ - self.env['us80g.costing.type'].sudo().create({ - 'costing_type': investment_costing.id, - 'it_declaration_id': rec.id, - 'investment_type_line_id': investment_line.id, - 'limit': investment_line.limit - }).id - for investment_line in inv_type.us80g_ids - ] - if inv_type.investment_type == 'chapter_via': - chapter_via_ids = [ - self.env['chapter.via.costing.type'].sudo().create({ - 'costing_type': investment_costing.id, - 'it_declaration_id': rec.id, - 'investment_type_line_id': investment_line.id, - 'limit': investment_line.limit - }).id - for investment_line in inv_type.chapter_via_ids - ] - if inv_type.investment_type == 'us_17': - us17_costing_ids = [ - self.env['us17.costing.type'].sudo().create({ - 'costing_type': investment_costing.id, - 'it_declaration_id': rec.id, - 'investment_type_line_id': investment_line.id, - 'limit': investment_line.limit - }).id - for investment_line in inv_type.us17_ids - ] - if inv_type.investment_type == 'other_i_or_l': - other_il_costing_ids = [ - self.env['other.il.costing.type'].sudo().create({ - 'costing_type': investment_costing.id, - 'it_declaration_id': rec.id, - 'investment_type_line_id': investment_line.id, - 'limit': investment_line.limit - }).id - for investment_line in inv_type.other_il_ids - ] - if inv_type.investment_type == 'other_declaration': - other_declaration_costing_ids = [ - self.env['other.declaration.costing.type'].sudo().create({ - 'costing_type': investment_costing.id, - 'it_declaration_id': rec.id, - 'investment_type_line_id': investment_line.id, - 'limit': investment_line.limit - }).id - for investment_line in inv_type.other_declaration_ids - ] - rec.costing_details_generated = True \ No newline at end of file + + def generate_declarations(self): + for rec in self: + rec._ensure_investment_costing_records() + rec._update_investment_amounts() + rec.costing_details_generated = True diff --git a/addons_extensions/employee_it_declaration/models/investment_costings.py b/addons_extensions/employee_it_declaration/models/investment_costings.py index ef089e2db..08bef3d22 100644 --- a/addons_extensions/employee_it_declaration/models/investment_costings.py +++ b/addons_extensions/employee_it_declaration/models/investment_costings.py @@ -302,8 +302,6 @@ class HouseRentDeclaration(models.Model): def create(self, vals): # Auto-link applicant_id if context is passed correctly if self.env.context.get('default_it_declaration_id'): - import pdb - pdb.set_trace() costing_id = self.env['investment.costings'].sudo().search([('id','=',self.env.context.get('it_declaration_id')),('investment_type_id.investment_type','=','house_rent')],limit=1) vals['costing_type'] = costing_id.id return super().create(vals) \ No newline at end of file diff --git a/addons_extensions/employee_it_declaration/models/investment_types.py b/addons_extensions/employee_it_declaration/models/investment_types.py index d1838409f..079bcfa49 100644 --- a/addons_extensions/employee_it_declaration/models/investment_types.py +++ b/addons_extensions/employee_it_declaration/models/investment_types.py @@ -1,9 +1,21 @@ -from odoo import models, fields +from odoo import models, fields, api class ItInvestmentType(models.Model): _name = 'it.investment.type' - _rec_name = 'investment_type' + + @api.depends('investment_type') + def _compute_display_name(self): + for rec in self: + if rec.investment_type: + rec.display_name = dict( + self._fields['investment_type'].selection + ).get(rec.investment_type) + else: + rec.display_name = ( + rec.investment_type.replace('_', ' ').title() + if rec.investment_type else '' + ) sequence = fields.Integer() investment_type = fields.Selection( @@ -11,6 +23,11 @@ class ItInvestmentType(models.Model): ('us_80g', 'US 80G'), ('chapter_via', 'CHAPTER VIA'), ('us_17', 'US 17'), ('house_rent', 'HOUSE RENT'), ('other_i_or_l', 'OTHER INCOME/LOSS'), ('other_declaration', 'OTHER DECLARATION')], string="Investment Type", required=True) + regime = fields.Selection([ + ('new', 'New Regime'), + ('old', 'Old Regime'), + ('both', 'Both') + ], string='Regime', required=True, default='both') active = fields.Boolean(default=True) past_employment_ids = fields.One2many('past_employment.investment.type','investment_type') @@ -22,6 +39,45 @@ class ItInvestmentType(models.Model): us17_ids = fields.One2many('us17.investment.type', 'investment_type') other_il_ids = fields.One2many('other.il.investment.type', 'investment_type') other_declaration_ids = fields.One2many('other.declaration.investment.type', 'investment_type') + period_ids = fields.Many2many( + 'payroll.period', + 'it_investment_type_payroll_period_rel', + 'investment_type_id', + 'period_id', + string='Periods' + ) + + def init(self): + self.env.cr.execute(""" + INSERT INTO it_investment_type_payroll_period_rel (investment_type_id, period_id) + SELECT investment_type.id, period.id + FROM it_investment_type AS investment_type + JOIN payroll_period AS period ON TRUE + LEFT JOIN it_investment_type_payroll_period_rel AS rel + ON rel.investment_type_id = investment_type.id + WHERE investment_type.active = TRUE + AND rel.investment_type_id IS NULL + """) + + @api.model + def _get_all_period_commands(self): + return [(6, 0, self.env['payroll.period'].search([]).ids)] + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if 'period_ids' not in vals and vals.get('active', True): + vals['period_ids'] = self._get_all_period_commands() + return super().create(vals_list) + + def write(self, vals): + res = super().write(vals) + if vals.get('active') is True: + all_period_commands = self._get_all_period_commands() + self.filtered(lambda record: record.active and not record.period_ids).write({ + 'period_ids': all_period_commands, + }) + return res class pastEmpInvestmentType(models.Model): _name = 'past_employment.investment.type' @@ -217,4 +273,4 @@ class OtherDeclarationInvestmentType(models.Model): # ], string="Tax Regime", required=True) # sequence = fields.Integer() # active = fields.Boolean(default=True) -# limit = fields.Integer() \ No newline at end of file +# limit = fields.Integer() diff --git a/addons_extensions/employee_it_declaration/models/it_tax_statement_wiz.py b/addons_extensions/employee_it_declaration/models/it_tax_statement_wiz.py index fae7d255e..d6227eb75 100644 --- a/addons_extensions/employee_it_declaration/models/it_tax_statement_wiz.py +++ b/addons_extensions/employee_it_declaration/models/it_tax_statement_wiz.py @@ -14,10 +14,13 @@ class ITTaxStatementWizard(models.TransientModel): # Inputs employee_id = fields.Many2one('hr.employee', required=True, default=lambda self: self.env.user.employee_id.id) emp_doj = fields.Date(related='employee_id.doj', store=True) + is_general_tax_statement = fields.Boolean(default=True) contract_id = fields.Many2one('hr.contract', related='employee_id.contract_id', required=True) + currency_id = fields.Many2one('res.currency', related='employee_id.company_id.currency_id') period_id = fields.Many2one('payroll.period', required=True) - period_line = fields.Many2one('payroll.period.line') + period_line = fields.Many2one('payroll.period.line', + domain="[('period_id', '=', period_id), ('to_date', '<', fields.Date.today())]") # Taxpayer profile taxpayer_name = fields.Char(related='employee_id.name') @@ -67,25 +70,56 @@ class ITTaxStatementWizard(models.TransientModel): ded_80G = fields.Float(string="Deduction under 80G", default=0.0) ded_other = fields.Float(string="Other Deductions", default=0.0) - def _get_applicable_slab(self, regime, age, residence_type): - """Get the applicable tax slab based on regime, age, and residence type""" - # Determine age category - if age < 60: - age_category = 'below_60' - elif age < 80: - age_category = '60_to_80' - else: - age_category = 'above_80' + comparison_available = fields.Boolean(default=False) + old_regime_taxable_income = fields.Float( + string="Old Regime Taxable Income", + readonly=True + ) + new_regime_taxable_income = fields.Float( + string="New Regime Taxable Income", + readonly=True + ) + old_regime_tax_payable = fields.Float( + string="Old Regime Tax Payable", + readonly=True + ) + new_regime_tax_payable = fields.Float( + string="New Regime Tax Payable", + readonly=True + ) + tax_difference = fields.Float( + string="Tax Difference", + readonly=True + ) + beneficial_regime = fields.Selection([ + ('old', 'Old Regime'), + ('new', 'New Regime') + ], string="Beneficial Regime", readonly=True) - # Search for slab master - slab_master = self.env['it.slab.master'].search([ + def _get_age_category(self, age): + if age < 60: + return 'below_60' + elif age < 80: + return '60_to_80' + return 'above_80' + + def _find_applicable_slab(self, regime, period_id, age, residence_type): + """Find the applicable tax slab without forcing both regimes to exist.""" + age_category = self._get_age_category(age) + residence_type = (residence_type or '').lower().replace('-', '_') + return self.env['it.slab.master'].search([ + ('period_id','=',period_id.id), ('regime', '=', regime), ('age_category', '=', age_category), '|', - ('residence_type', '=', residence_type.lower()), + ('residence_type', '=', residence_type), ('residence_type', '=', 'both') ], limit=1) + def _get_applicable_slab(self, regime, period_id, age, residence_type): + """Get the applicable tax slab based on regime, age, and residence type""" + age_category = self._get_age_category(age) + slab_master = self._find_applicable_slab(regime, period_id, age, residence_type) if not slab_master: raise ValidationError(_( "No tax slab found for %s Regime with Age Category: %s and Residence Type: %s" @@ -93,174 +127,272 @@ class ITTaxStatementWizard(models.TransientModel): return slab_master + def _get_standard_deduction(self, regime, slab_master=False): + if slab_master: + return slab_master.standard_deduction + return 75000.0 if regime == 'new' else 50000.0 + def _compute_tax_using_slab(self, taxable, slab_master): - """Compute tax using slab master rules""" - tax = 0.0 + """Compute tax using slab fixed amount logic""" + # Sort by sequence first, then by max_income for rules with same sequence + rules = slab_master.rules.sorted(lambda r: (r.sequence, r.max_income or float('inf'))) - # Get rules sorted by min_income - rules = slab_master.rules.sorted('min_income') + if not rules: + return 0.0 + # Find which slab the taxable income falls into + applicable_rule = None for rule in rules: - if taxable <= rule.min_income: - continue + min_income = rule.min_income or 0 + max_income = rule.max_income if rule.max_income else float('inf') - # Calculate amount in this bracket - bracket_max = rule.max_income if rule.max_income else float('inf') - amount_in_bracket = min(taxable, bracket_max) - rule.min_income + if min_income < taxable <= max_income: + applicable_rule = rule + break + if not applicable_rule: + return 0.0 - # Apply tax calculation based on rule structure - if rule.fixed_amount and rule.excess_threshold: - # Rule with fixed amount and excess threshold - excess_amount = max(0, taxable - rule.excess_threshold) - tax_for_bracket = rule.fixed_amount + (excess_amount * rule.tax_rate / 100) - else: - # Standard bracket calculation - tax_for_bracket = amount_in_bracket * rule.tax_rate / 100 + # Get all rules with sequence less than applicable rule + # For rules with same sequence, we need to be careful + previous_rules = rules.filtered( + lambda r: r.sequence < applicable_rule.sequence or + (r.sequence == applicable_rule.sequence and + (r.max_income or float('inf')) < (applicable_rule.max_income or float('inf'))) + ) - tax += tax_for_bracket + # Get the previous slab's max income (or 0 if first slab) + previous_max = 0 + if previous_rules: + # Get the last rule from previous_rules (which is already sorted) + previous_max = previous_rules[-1].max_income or 0 - return tax + # Calculate percentage tax for the current slab + taxable_in_current_slab = taxable - previous_max + current_tax = taxable_in_current_slab * (applicable_rule.tax_rate / 100) - @api.depends('employee_id', 'contract_id', 'period_id') + # Sum fixed amounts from all previous slabs + previous_fixed_amounts = sum(previous_rules.mapped('fixed_amount')) + + total_tax = current_tax + previous_fixed_amounts + + return total_tax + + @api.depends('employee_id', 'contract_id', 'period_id', 'period_line') def _compute_salary_components(self): - """Compute salary components from payroll data""" + """Compute salary components from the same payroll source used by the report.""" for rec in self: - if not rec.employee_id or not rec.contract_id: + rec.basic_salary = 0.0 + rec.hra_salary = 0.0 + rec.lta_salary = 0.0 + rec.special_allowance = 0.0 + rec.gross_salary = 0.0 + + if not rec.employee_id or not rec.contract_id or not rec.period_line: continue - # Get payslip for the period - payslip = self.env['hr.payslip'].search([ - ('employee_id', '=', rec.employee_id.id), - ('date_from', '>=', rec.period_line.from_date), - ('date_to', '<=', rec.period_line.to_date), - ('state', 'in', ['verify','done','paid']) - ], limit=1) - if payslip: - # Extract salary components from payslip lines - rec.basic_salary = self._get_salary_rule_amount(payslip, 'BASIC') - rec.hra_salary = self._get_salary_rule_amount(payslip, 'HRA') - rec.lta_salary = self._get_salary_rule_amount(payslip, 'LTA') - rec.special_allowance = self._get_salary_rule_amount(payslip, 'SPA') - rec.gross_salary = self._get_salary_rule_amount(payslip, 'GROSS') - else: - # Fallback to contract values - rec.basic_salary = rec.contract_id.wage * 0.4 # Assuming 40% basic - rec.hra_salary = rec.contract_id.wage * 0.2 # Assuming 20% HRA - rec.lta_salary = rec.contract_id.wage * 0.1 # Assuming 10% LTA - rec.special_allowance = rec.contract_id.wage * 0.3 # Remaining as special allowance - rec.gross_salary = rec.contract_id.wage + components = rec._get_salary_components_for_period_line(rec.period_line) + rec.basic_salary = components['basic_salary'] + rec.hra_salary = components['hra_salary'] + rec.lta_salary = components['lta_salary'] + rec.special_allowance = components['special_allowance'] + rec.gross_salary = components['gross_salary'] + def _get_valid_payslip_for_period_line(self, period_line): + payslip = self.env['hr.payslip'].search([ + ('employee_id', '=', self.employee_id.id), + ('date_from', '>=', period_line.from_date), + ('date_to', '<=', period_line.to_date), + ('state', 'in', ['verify', 'done', 'paid']) + ], limit=1) + if not payslip: + return payslip - def fetch_salary_components(self): - """fetch salary components from payroll data""" + refund_payslip = self.env['hr.payslip'].search([ + ('id', '!=', payslip.id), + ('name', 'ilike', payslip.number), + ('state', 'in', ['verify', 'done', 'paid']) + ], limit=1) + return self.env['hr.payslip'] if refund_payslip else payslip + + def _get_rule_amounts_for_period_line(self, period_line, rule_codes): + payslip = self._get_valid_payslip_for_period_line(period_line) + dummy_payslip = False + if not payslip: + dummy_payslip = self.env['hr.payslip'].sudo().create({ + 'name': 'Test Payslip', + 'employee_id': self.employee_id.id, + 'date_from': period_line.from_date, + 'date_to': period_line.to_date + }) + dummy_payslip.sudo().compute_sheet() + payslip = dummy_payslip + + try: + return { + rule_code: self._get_salary_rule_amount(payslip, rule_code) + for rule_code in rule_codes + } + finally: + if dummy_payslip: + dummy_payslip.sudo().action_payslip_cancel() + dummy_payslip.sudo().unlink() + + def _get_salary_components_for_period_line(self, period_line): + rule_codes = ['BASIC', 'HRA', 'LTA', 'SPA', 'GROSS', 'NET', 'ASSIG_SALARY', 'ATTACH_SALARY'] + rule_amounts = self._get_rule_amounts_for_period_line( + period_line, + rule_codes + ) + return { + 'basic_salary': rule_amounts['BASIC'], + 'hra_salary': rule_amounts['HRA'], + 'lta_salary': rule_amounts['LTA'], + 'special_allowance': rule_amounts['SPA'], + 'gross_salary': rule_amounts['GROSS'], + 'net_salary': rule_amounts['NET'], + 'salary_advance': rule_amounts['ASSIG_SALARY'], + 'advance_recovery': rule_amounts['ATTACH_SALARY'], + } + + def _get_other_payslip_components_for_period_line(self, period_line): + payslip = self._get_valid_payslip_for_period_line(period_line) + if not payslip: + return [] + + excluded_codes = { + 'BASIC', 'HRA', 'LTA', 'SPA', 'GROSS', 'NET', 'PT', 'PFE', 'PF', + 'ATTACH_SALARY', + } + grouped = {} + income_category_codes = {'BASIC', 'ALW', 'LEAVE'} + for line in payslip.line_ids.filtered( + lambda item: item.total + and (item.salary_rule_id.code or item.code) not in excluded_codes + and item.category_id.code in income_category_codes): + name = line.name or line.salary_rule_id.name or line.code + code = line.salary_rule_id.code or line.code + key = (code, name) + if key not in grouped: + grouped[key] = { + 'code': code, + 'name': name, + 'actual': 0.0, + 'projected': 0.0, + } + grouped[key]['actual'] += line.total + + for input_line in payslip.input_line_ids.filtered(lambda item: item.amount and item.code not in excluded_codes): + name = input_line.name or input_line.input_type_id.name or input_line.code + key = (input_line.code, name) + if key in grouped: + continue + grouped[key] = { + 'code': input_line.code, + 'name': name, + 'actual': input_line.amount, + 'projected': 0.0, + } + + return list(grouped.values()) + + def fetch_salary_components(self): + """fetch salary components from payroll data""" for rec in self: - if not rec.employee_id or not rec.contract_id: - continue data = { 'basic_salary' : {'actual':[],'projected':[]}, 'hra_salary': {'actual': [], 'projected': []}, 'lta_salary': {'actual': [], 'projected': []}, 'special_allowance' : {'actual':[],'projected':[]}, - 'gross_salary' : {'actual':[],'projected':[]} - } + 'gross_salary' : {'actual':[],'projected':[]}, + 'net_salary' : {'actual':[],'projected':[]}, + 'salary_advance' : {'actual':[],'projected':[]}, + 'advance_recovery' : {'actual':[],'projected':[]}, + 'other_components': {}, + } + if not rec.employee_id or not rec.contract_id or not rec.period_id or not rec.period_line: + return data period_lines = rec.period_id.period_line_ids for line in period_lines: - basic_salary = float() - hra_salary = float() - lta_salary = float() - special_allowance = float() - gross_salary = float() - payslip = self.env['hr.payslip'].search([ - ('employee_id', '=', rec.employee_id.id), - ('date_from', '>=', line.from_date), - ('date_to', '<=', line.to_date), - ('state', 'in', ['verify', 'done', 'paid']) - ], limit=1) - if payslip: - # Extract salary components from payslip lines - basic_salary = self._get_salary_rule_amount(payslip, 'BASIC') - hra_salary = self._get_salary_rule_amount(payslip, 'HRA') - lta_salary = self._get_salary_rule_amount(payslip, 'LTA') - special_allowance = self._get_salary_rule_amount(payslip, 'SPA') - gross_salary = self._get_salary_rule_amount(payslip, 'GROSS') - else: - payslip = self.env['hr.payslip'].sudo().create({ - 'name': 'Test Payslip', - 'employee_id': rec.employee_id.id, - 'date_from': line.from_date, - 'date_to': line.to_date - }) - payslip.sudo().compute_sheet() - - # Extract salary components from payslip lines - basic_salary = self._get_salary_rule_amount(payslip, 'BASIC') - hra_salary = self._get_salary_rule_amount(payslip, 'HRA') - lta_salary = self._get_salary_rule_amount(payslip, 'LTA') - special_allowance = self._get_salary_rule_amount(payslip, 'SPA') - gross_salary = self._get_salary_rule_amount(payslip, 'GROSS') - - payslip.sudo().action_payslip_cancel() - payslip.sudo().unlink() - - if line.from_date <= rec.period_line.from_date: - data['basic_salary']['actual'].append(basic_salary) - data['hra_salary']['actual'].append(hra_salary) - data['lta_salary']['actual'].append(lta_salary) - data['special_allowance']['actual'].append(special_allowance) - data['gross_salary']['actual'].append(gross_salary) - else: - data['basic_salary']['projected'].append(basic_salary) - data['hra_salary']['projected'].append(hra_salary) - data['lta_salary']['projected'].append(lta_salary) - data['special_allowance']['projected'].append(special_allowance) - data['gross_salary']['projected'].append(gross_salary) - return data + components = rec._get_salary_components_for_period_line(line) + if line.from_date and rec.period_line.from_date and line.from_date <= rec.period_line.from_date: + data['basic_salary']['actual'].append(components['basic_salary']) + data['hra_salary']['actual'].append(components['hra_salary']) + data['lta_salary']['actual'].append(components['lta_salary']) + data['special_allowance']['actual'].append(components['special_allowance']) + data['gross_salary']['actual'].append(components['gross_salary']) + data['net_salary']['actual'].append(components['net_salary']) + data['salary_advance']['actual'].append(components['salary_advance']) + data['advance_recovery']['actual'].append(components['advance_recovery']) + bucket = 'actual' + else: + data['basic_salary']['projected'].append(components['basic_salary']) + data['hra_salary']['projected'].append(components['hra_salary']) + data['lta_salary']['projected'].append(components['lta_salary']) + data['special_allowance']['projected'].append(components['special_allowance']) + data['gross_salary']['projected'].append(components['gross_salary']) + data['net_salary']['projected'].append(components['net_salary']) + data['salary_advance']['projected'].append(components['salary_advance']) + data['advance_recovery']['projected'].append(components['advance_recovery']) + bucket = 'projected' + for other_component in rec._get_other_payslip_components_for_period_line(line): + key = (other_component['code'], other_component['name']) + if key not in data['other_components']: + data['other_components'][key] = { + 'code': other_component['code'], + 'name': other_component['name'], + 'actual': 0.0, + 'projected': 0.0, + } + data['other_components'][key][bucket] += other_component['actual'] + return data def _get_salary_rule_amount(self, payslip, rule_code): """Get amount for a specific salary rule from payslip""" line = payslip.line_ids.filtered(lambda l: l.salary_rule_id.code == rule_code) - return line.total if line else 0.0 + return sum(line.mapped('total')) if line else 0.0 - @api.depends('employee_id', 'contract_id', 'period_id', 'tax_regime') + @api.depends('employee_id', 'contract_id', 'period_id', 'period_line', 'tax_regime') def _compute_deductions(self): """Compute deductions from payroll data""" for rec in self: - if not rec.employee_id or not rec.contract_id: + rec.professional_tax = 0.0 + rec.standard_deduction = 0.0 + rec.nps_employer_contribution = 0.0 + + if not rec.employee_id or not rec.contract_id or not rec.period_id or not rec.period_line: continue - # Get payslip for the period - payslip = self.env['hr.payslip'].search([ - ('employee_id', '=', rec.employee_id.id), - ('date_from', '>=', rec.period_id.from_date), - ('date_to', '<=', rec.period_id.to_date), - ('state', 'in', ['verify', 'done', 'paid']) - ], limit=1) - - fy_start = self.period_id.from_date - fy_end = self.period_id.to_date - total_months = ((fy_end.year - fy_start.year) * 12 + - (fy_end.month - fy_start.month) + 1) - - line_start = self.period_line.from_date - current_month_index = ((line_start.year - fy_start.year) * 12 + - (line_start.month - fy_start.month) + 1) - if payslip: - rec.professional_tax = (self._get_salary_rule_amount(payslip, 'PT'))*current_month_index - rec.nps_employer_contribution = self._get_salary_rule_amount(payslip, 'PFE') - else: - rec.professional_tax = 0.0 - rec.nps_employer_contribution = 0.0 - - # Get standard deduction from slab master - if rec.tax_regime == 'new': - slab_master = self._get_applicable_slab('new', rec.taxpayer_age, rec.residential_status) - else: - slab_master = self._get_applicable_slab('old', rec.taxpayer_age, rec.residential_status) - - rec.standard_deduction = slab_master.standard_deduction if slab_master else ( - 75000 if rec.tax_regime == 'new' else 50000 + deduction_data = rec.fetch_deduction_components() + rec.professional_tax = ( + sum(deduction_data['professional_tax']['actual']) + + sum(deduction_data['professional_tax']['projected']) ) + rec.nps_employer_contribution = ( + sum(deduction_data['nps_employer_contribution']['actual']) + + sum(deduction_data['nps_employer_contribution']['projected']) + ) + + slab_master = rec._get_applicable_slab( + rec.tax_regime, rec.period_id, rec.taxpayer_age, rec.residential_status + ) + rec.standard_deduction = rec._get_standard_deduction(rec.tax_regime, slab_master) + + def fetch_deduction_components(self): + for rec in self: + data = { + 'professional_tax': {'actual': [], 'projected': []}, + 'nps_employer_contribution': {'actual': [], 'projected': []}, + } + if not rec.employee_id or not rec.contract_id or not rec.period_id or not rec.period_line: + return data + + for line in rec.period_id.period_line_ids: + rule_amounts = rec._get_rule_amounts_for_period_line(line, ['PT', 'PFE']) + bucket = 'actual' if line.from_date <= rec.period_line.from_date else 'projected' + data['professional_tax'][bucket].append(rule_amounts['PT']) + data['nps_employer_contribution'][bucket].append(rule_amounts['PFE']) + return data @api.onchange('employee_id') @@ -272,6 +404,23 @@ class ITTaxStatementWizard(models.TransientModel): else: rec.taxpayer_age = rec.taxpayer_age or 0 + @api.onchange( + 'employee_id', 'period_id', 'period_line', 'tax_regime', 'taxpayer_age', + 'residential_status', 'other_income', 'hra_exemption', + 'interest_home_loan_self', 'interest_home_loan_letout', 'rental_income', + 'ded_80C', 'ded_80CCD1B', 'ded_80D_self', 'ded_80D_parents', + 'ded_80G', 'ded_other' + ) + def _onchange_reset_regime_comparison(self): + for rec in self: + rec.comparison_available = False + rec.old_regime_taxable_income = 0.0 + rec.new_regime_taxable_income = 0.0 + rec.old_regime_tax_payable = 0.0 + rec.new_regime_tax_payable = 0.0 + rec.tax_difference = 0.0 + rec.beneficial_regime = False + @api.onchange('basic_salary', 'hra_salary') def onchange_hra_exemption(self): """Calculate HRA exemption based on salary components""" @@ -319,7 +468,7 @@ class ITTaxStatementWizard(models.TransientModel): return min(60000.0, slab_tax) def _apply_surcharge_with_mr(self, slab_master, taxable, tax_after_rebate, regime): - rules = slab_master.rules.sorted('min_income') + rules = slab_master.surcharges.sorted('min_income') table = [(rule.min_income, rule.surcharge_rate) for rule in rules if rule.surcharge_rate > 0] threshold = None @@ -338,9 +487,11 @@ class ITTaxStatementWizard(models.TransientModel): tax_with_surcharge = total_before_mr - mr return surcharge, mr, tax_with_surcharge - def _compute_tax_old_regime(self, taxable): + def _compute_tax_old_regime(self, taxable, slab_master=False): # Get applicable slab - slab_master = self._get_applicable_slab('old', self.taxpayer_age, self.residential_status) + slab_master = slab_master or self._get_applicable_slab( + 'old', self.period_id, self.taxpayer_age, self.residential_status + ) # Compute slab tax slab_tax = self._compute_tax_using_slab(taxable, slab_master) @@ -358,7 +509,9 @@ class ITTaxStatementWizard(models.TransientModel): rules = slab_master.rules.sorted('min_income') cess_rate = [rule.cess_rate for rule in rules if rule.min_income<=taxable and rule.max_income >= taxable] - cess = tax_with_surcharge * cess_rate[0]/100 + cess = 0 + if cess_rate and tax_with_surcharge: + cess = tax_with_surcharge * cess_rate[0]/100 total_tax = tax_with_surcharge + cess @@ -374,9 +527,11 @@ class ITTaxStatementWizard(models.TransientModel): 'total_tax': total_tax } - def _compute_tax_new_regime(self, taxable): + def _compute_tax_new_regime(self, taxable, slab_master=False): # Get applicable slab (new regime doesn't depend on age) - slab_master = self._get_applicable_slab('new', self.taxpayer_age, self.residential_status) + slab_master = slab_master or self._get_applicable_slab( + 'new', self.period_id, self.taxpayer_age, self.residential_status + ) # Compute slab tax slab_tax = self._compute_tax_using_slab(taxable, slab_master) @@ -393,7 +548,9 @@ class ITTaxStatementWizard(models.TransientModel): rules = slab_master.rules.sorted('min_income') cess_rate = [rule.cess_rate for rule in rules if rule.min_income<=taxable and rule.max_income >= taxable] # Apply cess - cess = tax_with_surcharge * cess_rate[0]/100 + cess = 0 + if cess_rate and tax_with_surcharge: + cess = tax_with_surcharge * cess_rate[0] / 100 total_tax = tax_with_surcharge + cess return { @@ -421,7 +578,154 @@ class ITTaxStatementWizard(models.TransientModel): hp_income = -interest_allowed return hp_income - def _prepare_income_tax_data(self): + def _get_tax_base_values(self, include_comparison=False): + self.ensure_one() + selected_slab = self._get_applicable_slab( + self.tax_regime, self.period_id, self.taxpayer_age, self.residential_status + ) + old_slab = selected_slab if self.tax_regime == 'old' else False + new_slab = selected_slab if self.tax_regime == 'new' else False + if include_comparison: + old_slab = old_slab or self._find_applicable_slab( + 'old', self.period_id, self.taxpayer_age, self.residential_status + ) + new_slab = new_slab or self._find_applicable_slab( + 'new', self.period_id, self.taxpayer_age, self.residential_status + ) + old_standard_deduction = self._get_standard_deduction('old', old_slab) + new_standard_deduction = self._get_standard_deduction('new', new_slab) + selected_standard_deduction = ( + old_standard_deduction if self.tax_regime == 'old' else new_standard_deduction + ) + + salary_components_data = self.fetch_salary_components() + other_components_actual = sum( + component['actual'] for component in salary_components_data['other_components'].values() + ) + other_components_projected = sum( + component['projected'] for component in salary_components_data['other_components'].values() + ) + visible_gross_actual = ( + sum(salary_components_data['basic_salary']['actual']) + + sum(salary_components_data['hra_salary']['actual']) + + sum(salary_components_data['lta_salary']['actual']) + + sum(salary_components_data['special_allowance']['actual']) + + other_components_actual + ) + visible_gross_projected = ( + sum(salary_components_data['basic_salary']['projected']) + + sum(salary_components_data['hra_salary']['projected']) + + sum(salary_components_data['lta_salary']['projected']) + + sum(salary_components_data['special_allowance']['projected']) + + other_components_projected + ) + gross_salary_actual = max(sum(salary_components_data['gross_salary']['actual']), visible_gross_actual) + gross_salary_projected = max( + sum(salary_components_data['gross_salary']['projected']), + visible_gross_projected + ) + annual_gross_salary = ( + gross_salary_actual + + gross_salary_projected + ) + annual_net_salary = ( + sum(salary_components_data['net_salary']['actual']) + + sum(salary_components_data['net_salary']['projected']) + ) + if not annual_net_salary or self.is_general_tax_statement: + annual_net_salary = annual_gross_salary + + hp_income = self._compute_house_property_income() + old_deductions = ( + old_standard_deduction + # self.hra_exemption + + # self.professional_tax + + # self.ded_80C + + # self.ded_80CCD1B + + # self.ded_80D_self + + # self.ded_80D_parents + + # self.ded_80G + + # self.ded_other + + # self.nps_employer_contribution + ) + new_deductions = ( + new_standard_deduction + # self.professional_tax + + # self.nps_employer_contribution + ) + taxable_old = max(0.0, annual_gross_salary + self.other_income + hp_income - old_deductions) + taxable_new = max(0.0, annual_gross_salary + self.other_income + hp_income - new_deductions) + tax_result_old = self._compute_tax_old_regime(taxable_old, old_slab) if old_slab else False + tax_result_new = self._compute_tax_new_regime(taxable_new, new_slab) if new_slab else False + comparison_available = bool(tax_result_old and tax_result_new) + tax_savings = 0.0 + beneficial_regime = False + if comparison_available: + tax_savings = abs(tax_result_old['total_tax'] - tax_result_new['total_tax']) + beneficial_regime = 'old' if tax_result_old['total_tax'] < tax_result_new['total_tax'] else 'new' + + return { + 'selected_slab': selected_slab, + 'old_slab': old_slab, + 'new_slab': new_slab, + 'selected_standard_deduction': selected_standard_deduction, + 'salary_components_data': salary_components_data, + 'annual_gross_salary': annual_gross_salary, + 'gross_salary_actual': gross_salary_actual, + 'gross_salary_projected': gross_salary_projected, + 'annual_net_salary': annual_net_salary, + 'hp_income': hp_income, + 'old_deductions': old_deductions, + 'new_deductions': new_deductions, + 'taxable_old': taxable_old, + 'taxable_new': taxable_new, + 'tax_result_old': tax_result_old, + 'tax_result_new': tax_result_new, + 'comparison_available': comparison_available, + 'tax_savings': tax_savings, + 'beneficial_regime': beneficial_regime, + } + + def _reset_regime_comparison(self): + self.write({ + 'comparison_available': False, + 'old_regime_taxable_income': 0.0, + 'new_regime_taxable_income': 0.0, + 'old_regime_tax_payable': 0.0, + 'new_regime_tax_payable': 0.0, + 'tax_difference': 0.0, + 'beneficial_regime': False, + }) + + def action_check_regime_comparison(self): + self.ensure_one() + if not self.employee_id or not self.contract_id or not self.period_id or not self.period_line: + raise ValidationError(_("Select employee, period, and period line before checking comparison.")) + + values = self._get_tax_base_values(include_comparison=True) + if not values['comparison_available']: + self._reset_regime_comparison() + raise ValidationError(_("Tax comparison is available only when both old and new regime slabs are configured.")) + + self.write({ + 'comparison_available': True, + 'old_regime_taxable_income': values['taxable_old'], + 'new_regime_taxable_income': values['taxable_new'], + 'old_regime_tax_payable': values['tax_result_old']['total_tax'], + 'new_regime_tax_payable': values['tax_result_new']['total_tax'], + 'tax_difference': values['tax_savings'], + 'beneficial_regime': values['beneficial_regime'], + }) + + return { + 'type': 'ir.actions.act_window', + 'res_model': self._name, + 'res_id': self.id, + 'view_mode': 'form', + 'target': 'current', + } + + def _prepare_income_tax_data(self, include_comparison=False): """Prepare data for the tax statement report""" today = date.today() fy_start = self.period_id.from_date @@ -439,41 +743,20 @@ class ITTaxStatementWizard(models.TransientModel): fy_start = date(today.year - 1, 4, 1) fy_end = date(today.year, 3, 31) - # Calculate taxable income for both regimes - # Old regime - old_deductions = ( - self.standard_deduction + - self.hra_exemption + - self.professional_tax + - self.ded_80C + - self.ded_80CCD1B + - self.ded_80D_self + - self.ded_80D_parents + - self.ded_80G + - self.ded_other + - self.nps_employer_contribution - ) - - # House property income - hp_income = self._compute_house_property_income() - - # Taxable income for old regime - taxable_old = max(0.0, (self.gross_salary * total_months) + self.other_income + hp_income - self.standard_deduction) - - # New regime - fewer deductions - new_deductions = ( - self.standard_deduction + - self.professional_tax + - self.nps_employer_contribution - ) - - # Taxable income for new regime - - taxable_new = max(0.0, (self.gross_salary * total_months) + self.other_income + hp_income - self.standard_deduction) - - # Compute tax for both regimes - tax_result_old = self._compute_tax_old_regime(taxable_old) - tax_result_new = self._compute_tax_new_regime(taxable_new) + values = self._get_tax_base_values(include_comparison=include_comparison) + salary_components_data = values['salary_components_data'] + annual_gross_salary = values['annual_gross_salary'] + gross_salary_actual = values['gross_salary_actual'] + gross_salary_projected = values['gross_salary_projected'] + annual_net_salary = values['annual_net_salary'] + selected_standard_deduction = values['selected_standard_deduction'] + old_deductions = values['old_deductions'] + new_deductions = values['new_deductions'] + hp_income = values['hp_income'] + taxable_old = values['taxable_old'] + taxable_new = values['taxable_new'] + tax_result_old = values['tax_result_old'] + tax_result_new = values['tax_result_new'] # Determine which regime to use if self.tax_regime == 'old': @@ -485,9 +768,12 @@ class ITTaxStatementWizard(models.TransientModel): taxable_income = taxable_new chosen = 'new' - # Calculate tax savings - tax_savings = abs(tax_result_old['total_tax'] - tax_result_new['total_tax']) - beneficial_regime = 'old' if tax_result_old['total_tax'] < tax_result_new['total_tax'] else 'new' + if not tax_result: + raise ValidationError(_("No tax calculation could be made for the selected regime.")) + + comparison_available = values['comparison_available'] + tax_savings = values['tax_savings'] + beneficial_regime = values['beneficial_regime'] # Prepare data structure matching screenshot format # Financial year (period_id) @@ -500,16 +786,25 @@ class ITTaxStatementWizard(models.TransientModel): line_start = self.period_line.from_date current_month_index = ((line_start.year - fy_start.year) * 12 + (line_start.month - fy_start.month) + 1) - tax_result['roundoff_taxable_income'] = float(round(tax_result["taxable_income"] / 10) * 10) - birthday = self.employee_id.birthday - if birthday: - diff = relativedelta(date.today(), birthday) - years_months = f"{diff.years} years {diff.months} months" - else: - years_months = "N/A" - month_age = str(self.period_line.name)+ " / " + str(years_months) - salary_components_data = self.fetch_salary_components() - data = { + tax_result['roundoff_taxable_income'] = float(round(tax_result["taxable_income"] / 10) * 10) + birthday = self.employee_id.birthday + if birthday: + diff = relativedelta(date.today(), birthday) + years_months = f"{diff.years} years {diff.months} months" + else: + years_months = "N/A" + month_age = str(self.period_line.name)+ " / " + str(years_months) + other_salary_components = [] + for component in salary_components_data['other_components'].values(): + total = component['actual'] + component['projected'] + if total: + other_salary_components.append({ + 'name': component['name'], + 'actual': component['actual'], + 'projected': component['projected'], + 'total': total, + }) + data = { 'financial_year': f"{fy_start.year}-{fy_end.year}", 'assessment_year': fy_end.year + 1, 'report_time': today.strftime('%d-%m-%Y %H:%M'), @@ -539,14 +834,17 @@ class ITTaxStatementWizard(models.TransientModel): 'special_allowance':{'actual': sum(salary_components_data['special_allowance']['actual']), 'projected': sum(salary_components_data['special_allowance']['projected']), 'total':sum(salary_components_data['special_allowance']['actual']) + sum(salary_components_data['special_allowance']['projected'])}, 'perquisites': {'actual': 0 * current_month_index, 'projected': 0 * (total_months - current_month_index), 'total': 0 * total_months}, 'reimbursement': {'actual': 0 * current_month_index, 'projected': 0 * (total_months - current_month_index), 'total': 0 * total_months}, - 'gross_salary': {'actual': sum(salary_components_data['gross_salary']['actual']), 'projected': sum(salary_components_data['gross_salary']['projected']), 'total':sum(salary_components_data['gross_salary']['actual']) + sum(salary_components_data['gross_salary']['projected'])}, - 'net_salary': {'actual': self.gross_salary * current_month_index, 'projected': self.gross_salary * (total_months - current_month_index), - 'total': self.gross_salary * total_months} - }, + 'gross_salary': {'actual': gross_salary_actual, 'projected': gross_salary_projected, 'total': annual_gross_salary}, + 'salary_advance': {'actual': sum(salary_components_data['salary_advance']['actual']), 'projected': sum(salary_components_data['salary_advance']['projected']), 'total': sum(salary_components_data['salary_advance']['actual']) + sum(salary_components_data['salary_advance']['projected'])}, + 'advance_recovery': {'actual': sum(salary_components_data['advance_recovery']['actual']), 'projected': sum(salary_components_data['advance_recovery']['projected']), 'total': sum(salary_components_data['advance_recovery']['actual']) + sum(salary_components_data['advance_recovery']['projected'])}, + 'other_components': other_salary_components, + 'net_salary': {'actual': sum(salary_components_data['net_salary']['actual']), 'projected': sum(salary_components_data['net_salary']['projected']), + 'total': annual_net_salary} + }, 'deductions': { 'professional_tax': self.professional_tax, - 'standard_deduction': self.standard_deduction, + 'standard_deduction': selected_standard_deduction, 'nps_employer': self.nps_employer_contribution, 'hra_exemption': self.hra_exemption, 'interest_home_loan': self.interest_home_loan_self + self.interest_home_loan_letout, @@ -560,10 +858,10 @@ class ITTaxStatementWizard(models.TransientModel): }, 'income_details': { - 'gross_salary': self.gross_salary, + 'gross_salary': annual_gross_salary, 'other_income': self.other_income, 'house_property_income': hp_income, - 'gross_total_income': (self.gross_salary * total_months) + self.other_income + hp_income - self.standard_deduction, + 'gross_total_income': (annual_gross_salary + self.other_income + hp_income) - selected_standard_deduction, }, 'taxable_income': { @@ -576,8 +874,11 @@ class ITTaxStatementWizard(models.TransientModel): 'regime_used': chosen, 'comparison': { - 'old_regime_tax': tax_result_old['total_tax'], - 'new_regime_tax': tax_result_new['total_tax'], + 'available': comparison_available, + 'old_regime_tax': tax_result_old['total_tax'] if tax_result_old else 0.0, + 'new_regime_tax': tax_result_new['total_tax'] if tax_result_new else 0.0, + 'old_taxable_income': taxable_old if tax_result_old else 0.0, + 'new_taxable_income': taxable_new if tax_result_new else 0.0, 'tax_savings': tax_savings, 'beneficial_regime': beneficial_regime, } @@ -586,9 +887,19 @@ class ITTaxStatementWizard(models.TransientModel): return {'data': data} def action_generate_report(self): - report_data = self._prepare_income_tax_data() + report_data = self._prepare_income_tax_data(include_comparison=False) return self.env.ref('employee_it_declaration.income_tax_statement_action_report').report_action( self, data={'report_data': report_data}, - ) \ No newline at end of file + ) + + def action_generate_comparison_report(self): + report_data = self._prepare_income_tax_data(include_comparison=True) + if not report_data['data']['comparison']['available']: + raise ValidationError(_("Tax comparison is available only when both old and new regime slabs are configured.")) + + return self.env.ref('employee_it_declaration.income_tax_comparison_action_report').report_action( + self, + data={'report_data': report_data}, + ) diff --git a/addons_extensions/employee_it_declaration/models/payroll_periods.py b/addons_extensions/employee_it_declaration/models/payroll_periods.py index 676f76940..ed9bc86c8 100644 --- a/addons_extensions/employee_it_declaration/models/payroll_periods.py +++ b/addons_extensions/employee_it_declaration/models/payroll_periods.py @@ -1,10 +1,9 @@ -from odoo import models, fields, api -from odoo.exceptions import ValidationError -from datetime import datetime, timedelta -import calendar +from odoo import models, fields, api +from odoo.exceptions import ValidationError +import calendar -class PayrollPeriod(models.Model): +class PayrollPeriod(models.Model): _name = 'payroll.period' _description = 'Payroll Period' _rec_name = 'name' @@ -15,12 +14,22 @@ class PayrollPeriod(models.Model): from_date = fields.Date(string="From Date", required=True) to_date = fields.Date(string="To Date", required=True) name = fields.Char(string="Name", required=True) - period_line_ids = fields.One2many('payroll.period.line', 'period_id', string="Monthly Periods") - - @api.onchange('from_date', 'to_date') - def onchange_from_to_date(self): - for rec in self: - if rec.from_date and rec.to_date: + period_line_ids = fields.One2many('payroll.period.line', 'period_id', string="Monthly Periods") + + @api.model_create_multi + def create(self, vals_list): + periods = super().create(vals_list) + active_investment_types = self.env['it.investment.type'].search([('active', '=', True)]) + if active_investment_types: + active_investment_types.write({ + 'period_ids': [(4, period.id) for period in periods], + }) + return periods + + @api.onchange('from_date', 'to_date') + def onchange_from_to_date(self): + for rec in self: + if rec.from_date and rec.to_date: rec.name = f"{rec.from_date.year}-{rec.to_date.year}" diff --git a/addons_extensions/employee_it_declaration/models/slab_master.py b/addons_extensions/employee_it_declaration/models/slab_master.py index 8fb6e769c..79c426bb5 100644 --- a/addons_extensions/employee_it_declaration/models/slab_master.py +++ b/addons_extensions/employee_it_declaration/models/slab_master.py @@ -8,12 +8,13 @@ class IncomeTaxSlabMaster(models.Model): _sql_constraints = [ ( 'unique_slab', - 'unique(regime, age_category, residence_type)', - 'Slab must be unique for the same Regime, Age Category, and Residence Type!' + 'unique(period_id, regime, age_category, residence_type)', + 'Slab must be unique for the same name, Regime, Age Category, and Residence Type!' ) ] name = fields.Char(string="Slab Name", required=True) + period_id = fields.Many2one('payroll.period') regime = fields.Selection([ ('old', 'Old Tax Regime'), ('new', 'New Tax Regime') @@ -31,6 +32,7 @@ class IncomeTaxSlabMaster(models.Model): standard_deduction = fields.Float(string="Standard Deduction") active = fields.Boolean(default=True) rules = fields.One2many('it.slab.master.rules','slab_id', string="Slab Rules") + surcharges = fields.One2many('it.sur.charge.rules','slab_id', string="Surcharges Rules") class IncomeTaxSlabMasterRules(models.Model): _name = 'it.slab.master.rules' @@ -44,6 +46,10 @@ class IncomeTaxSlabMasterRules(models.Model): ) ] + + sequence = fields.Integer( + 'Sequence', + help='Used to deduct the taxes based on order') min_income = fields.Float(string="Min Income (₹)", required=True) max_income = fields.Float(string="Max Income (₹)") tax_rate = fields.Float(string="Tax Rate (%)", required=True) @@ -70,3 +76,28 @@ class IncomeTaxSlabMasterRules(models.Model): raise ValidationError( f"Income ranges overlap with another slab rule: {other.min_income} - {other.max_income}" ) + + +class IncomeTaxSurchargeMasterRules(models.Model): + _name = 'it.sur.charge.rules' + + min_income = fields.Float(string="Min Income (₹)", required=True) + max_income = fields.Float(string="Max Income (₹)") + surcharge_rate = fields.Float(string="Surcharge Rate (%)") + slab_id = fields.Many2one('it.slab.master') + + @api.constrains('min_income', 'max_income', 'slab_id') + def _check_overlap(self): + """Ensure no overlapping or duplicate ranges within the same slab""" + for rule in self: + domain = [ + ('slab_id', '=', rule.slab_id.id), + ('id', '!=', rule.id) + ] + others = self.search(domain) + for other in others: + if not (rule.max_income and other.min_income >= rule.max_income) and \ + not (other.max_income and rule.min_income >= other.max_income): + raise ValidationError( + f"Income ranges overlap with another slab rule: {other.min_income} - {other.max_income}" + ) \ No newline at end of file diff --git a/addons_extensions/employee_it_declaration/report/it_tax_template.xml b/addons_extensions/employee_it_declaration/report/it_tax_template.xml index 00c8c4916..055e040f2 100644 --- a/addons_extensions/employee_it_declaration/report/it_tax_template.xml +++ b/addons_extensions/employee_it_declaration/report/it_tax_template.xml @@ -140,23 +140,41 @@ - - Reimbursement - + + Reimbursement + - - - - Gross Salary - + + + + + + + + + Gross Salary + + + + + Advance Recovery + t-esc="'{:,.0f}'.format(salary_components.get('advance_recovery', {}).get('actual', 0))"/> + + Less: Exemption under section 10 @@ -267,7 +285,7 @@ Round off to nearest 10 Rupee: - + @@ -333,9 +351,9 @@ - -

- Tax Deduction Details + +

+ Tax Deduction Details

@@ -411,9 +429,72 @@ - - - + + + + + @@ -723,4 +804,4 @@ - \ No newline at end of file + diff --git a/addons_extensions/employee_it_declaration/security/ir.model.access.csv b/addons_extensions/employee_it_declaration/security/ir.model.access.csv index 957050c3b..f092b4acd 100644 --- a/addons_extensions/employee_it_declaration/security/ir.model.access.csv +++ b/addons_extensions/employee_it_declaration/security/ir.model.access.csv @@ -63,3 +63,4 @@ access_it_tax_statement_wizard_manager,it.tax.statement.wizard,model_it_tax_stat access_it_slab_master,it.slab.master,model_it_slab_master,base.group_user,1,1,1,1 access_it_slab_master_rules,it.slab.master.rules,model_it_slab_master_rules,base.group_user,1,1,1,1 +access_it_sur_charge_rules,it.sur.charge.rules.user,model_it_sur_charge_rules,base.group_user,1,1,1,1 \ No newline at end of file diff --git a/addons_extensions/employee_it_declaration/views/emp_it_declaration.xml b/addons_extensions/employee_it_declaration/views/emp_it_declaration.xml index fcbb68860..2ccf198f9 100644 --- a/addons_extensions/employee_it_declaration/views/emp_it_declaration.xml +++ b/addons_extensions/employee_it_declaration/views/emp_it_declaration.xml @@ -34,6 +34,16 @@ + + + + + + + + + + @@ -61,7 +71,7 @@ - + @@ -90,7 +100,7 @@ - + @@ -114,9 +124,25 @@ - + - + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + +
+ + + + hr.salary.advance.search + hr.salary.advance + + + + + + + + + + + + + + + + Salary Advances + hr.salary.advance + list,form + {'search_default_running': 1} + + diff --git a/addons_extensions/hr_payroll_extended/views/menus.xml b/addons_extensions/hr_payroll_extended/views/menus.xml new file mode 100644 index 000000000..03166eb7e --- /dev/null +++ b/addons_extensions/hr_payroll_extended/views/menus.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/addons_extensions/hr_recruitment_auto_doc/__init__.py b/addons_extensions/hr_recruitment_auto_doc/__init__.py new file mode 100644 index 000000000..9b4296142 --- /dev/null +++ b/addons_extensions/hr_recruitment_auto_doc/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizard diff --git a/addons_extensions/hr_recruitment_auto_doc/__manifest__.py b/addons_extensions/hr_recruitment_auto_doc/__manifest__.py new file mode 100644 index 000000000..6116445d2 --- /dev/null +++ b/addons_extensions/hr_recruitment_auto_doc/__manifest__.py @@ -0,0 +1,37 @@ +{ + "name": "HR Recruitment Auto Document", + "summary": "Parse resumes and auto-create recruitment records", + "version": "1.0.0", + "category": "Human Resources/Recruitment", + "author": "Pranay", + "website": "https://www.ftprotech.com", + "license": "LGPL-3", + "depends": [ + "document_parser", + "hr_recruitment_extended", + "hr_recruitment_skills", + ], + "data": [ + "security/ir.model.access.csv", + "wizard/hr_recruitment_auto_doc_wizard_views.xml", + "views/hr_applicant_views.xml", + "views/hr_candidate_views.xml", + "views/hr_job_recruitment_views.xml", + "views/hr_recruitment_actions.xml", + ], + "assets": { + "web.assets_backend": [ + "hr_recruitment_auto_doc/static/src/js/recruitment_auto_doc_list.js", + "hr_recruitment_auto_doc/static/src/js/recruitment_auto_doc_kanban.js", + "hr_recruitment_auto_doc/static/src/js/job_skill_transfer_field.js", + "hr_recruitment_auto_doc/static/src/js/many2many_binary_dropzone_field.js", + "hr_recruitment_auto_doc/static/src/scss/recruitment_auto_doc_widgets.scss", + "hr_recruitment_auto_doc/static/src/xml/job_skill_transfer_field.xml", + "hr_recruitment_auto_doc/static/src/xml/many2many_binary_dropzone_field.xml", + "hr_recruitment_auto_doc/static/src/xml/recruitment_auto_doc_buttons.xml", + ], + }, + "installable": True, + "application": False, + "auto_install": False, +} diff --git a/addons_extensions/hr_recruitment_auto_doc/models/__init__.py b/addons_extensions/hr_recruitment_auto_doc/models/__init__.py new file mode 100644 index 000000000..aa866b0a0 --- /dev/null +++ b/addons_extensions/hr_recruitment_auto_doc/models/__init__.py @@ -0,0 +1,3 @@ +from . import hr_applicant +from . import hr_candidate +from . import hr_job_recruitment diff --git a/addons_extensions/hr_recruitment_auto_doc/models/hr_applicant.py b/addons_extensions/hr_recruitment_auto_doc/models/hr_applicant.py new file mode 100644 index 000000000..f88e9d81b --- /dev/null +++ b/addons_extensions/hr_recruitment_auto_doc/models/hr_applicant.py @@ -0,0 +1,14 @@ +from odoo import _, models + + +class HrApplicant(models.Model): + _inherit = "hr.applicant" + + def action_open_auto_doc_wizard(self): + action = self.env.ref("hr_recruitment_auto_doc.action_hr_recruitment_auto_doc_wizard_applicant").read()[0] + context = dict(self.env.context) + if len(self) == 1 and self.hr_job_recruitment: + context["default_job_recruitment_id"] = self.hr_job_recruitment.id + action["context"] = context + action["name"] = _("Parse Resumes") + return action diff --git a/addons_extensions/hr_recruitment_auto_doc/models/hr_candidate.py b/addons_extensions/hr_recruitment_auto_doc/models/hr_candidate.py new file mode 100644 index 000000000..d691152e5 --- /dev/null +++ b/addons_extensions/hr_recruitment_auto_doc/models/hr_candidate.py @@ -0,0 +1,11 @@ +from odoo import _, models + + +class HrCandidate(models.Model): + _inherit = "hr.candidate" + + def action_open_auto_doc_wizard(self): + action = self.env.ref("hr_recruitment_auto_doc.action_hr_recruitment_auto_doc_wizard_candidate").read()[0] + action["context"] = dict(self.env.context) + action["name"] = _("Parse Resumes") + return action diff --git a/addons_extensions/hr_recruitment_auto_doc/models/hr_job_recruitment.py b/addons_extensions/hr_recruitment_auto_doc/models/hr_job_recruitment.py new file mode 100644 index 000000000..9eae68c54 --- /dev/null +++ b/addons_extensions/hr_recruitment_auto_doc/models/hr_job_recruitment.py @@ -0,0 +1,14 @@ +from odoo import _, models + + +class HrJobRecruitment(models.Model): + _inherit = "hr.job.recruitment" + + def action_open_auto_doc_wizard(self): + action = self.env.ref("hr_recruitment_auto_doc.action_hr_recruitment_auto_doc_wizard_job_recruitment").read()[0] + context = dict(self.env.context) + if len(self) == 1: + context["default_job_recruitment_id"] = self.id + action["context"] = context + action["name"] = _("Parse Job Description") + return action diff --git a/addons_extensions/hr_recruitment_auto_doc/security/ir.model.access.csv b/addons_extensions/hr_recruitment_auto_doc/security/ir.model.access.csv new file mode 100644 index 000000000..50393c7aa --- /dev/null +++ b/addons_extensions/hr_recruitment_auto_doc/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_hr_recruitment_auto_doc_wizard,hr.recruitment.auto.doc.wizard,model_hr_recruitment_auto_doc_wizard,base.group_user,1,1,1,1 +access_hr_recruitment_auto_doc_wizard_line,hr.recruitment.auto.doc.wizard.line,model_hr_recruitment_auto_doc_wizard_line,base.group_user,1,1,1,1 diff --git a/addons_extensions/hr_recruitment_auto_doc/static/src/js/job_skill_transfer_field.js b/addons_extensions/hr_recruitment_auto_doc/static/src/js/job_skill_transfer_field.js new file mode 100644 index 000000000..64cee1aab --- /dev/null +++ b/addons_extensions/hr_recruitment_auto_doc/static/src/js/job_skill_transfer_field.js @@ -0,0 +1,105 @@ +import { _t } from "@web/core/l10n/translation"; +import { registry } from "@web/core/registry"; +import { + many2ManyTagsField, + Many2ManyTagsField, +} from "@web/views/fields/many2many_tags/many2many_tags_field"; + +export class JobSkillTransferField extends Many2ManyTagsField { + static template = "hr_recruitment_auto_doc.JobSkillTransferField"; + static props = { + ...Many2ManyTagsField.props, + transferTarget: { type: String, optional: true }, + }; + + setup() { + super.setup(); + } + + get tags() { + return this.props.record.data[this.props.name].records.map((record) => { + const tag = this.getTagProps(record); + return { + ...tag, + draggable: !this.props.readonly && this.props.record.isInEdition, + onDragStart: (ev) => this.onTagDragStart(ev, record), + onDragEnd: () => this.onTagDragEnd(), + }; + }); + } + + onTagDragStart(ev, record) { + if (this.props.readonly || !this.props.transferTarget) { + return; + } + ev.dataTransfer.effectAllowed = "move"; + ev.dataTransfer.setData( + "text/plain", + JSON.stringify({ + resId: record.resId, + fromField: this.props.name, + targetField: this.props.transferTarget, + }) + ); + } + + onTagDragEnd() { + // noop, but keeps the template hook simple + } + + onDragOver(ev) { + if (this.props.readonly || !this.props.transferTarget) { + return; + } + ev.preventDefault(); + ev.dataTransfer.dropEffect = "move"; + } + + async onDrop(ev) { + if (this.props.readonly || !this.props.transferTarget) { + return; + } + ev.preventDefault(); + let payload; + try { + payload = JSON.parse(ev.dataTransfer.getData("text/plain") || "{}"); + } catch { + payload = {}; + } + const { resId, fromField } = payload; + if (!resId || !fromField || fromField === this.props.name) { + return; + } + const sourceList = this.props.record.data[fromField]; + const targetList = this.props.record.data[this.props.name]; + const sourceRecord = sourceList.records.find((record) => record.resId === resId); + const alreadyExists = targetList.records.some((record) => record.resId === resId); + if (!sourceRecord || alreadyExists) { + return; + } + await targetList.addAndRemove({ add: [resId] }); + await sourceList.forget(sourceRecord); + } +} + +export const jobSkillTransferField = { + ...many2ManyTagsField, + component: JobSkillTransferField, + displayName: _t("Tags With Transfer"), + supportedOptions: [ + ...(many2ManyTagsField.supportedOptions || []), + { + label: _t("Transfer target"), + name: "transfer_target", + type: "string", + }, + ], + extractProps: ({ attrs, options, string }, dynamicInfo) => ({ + ...(many2ManyTagsField.extractProps + ? many2ManyTagsField.extractProps({ attrs, options, string }, dynamicInfo) + : {}), + transferTarget: options.transfer_target, + }), +}; + +registry.category("fields").add("job_skill_transfer", jobSkillTransferField); diff --git a/addons_extensions/hr_recruitment_auto_doc/static/src/js/many2many_binary_dropzone_field.js b/addons_extensions/hr_recruitment_auto_doc/static/src/js/many2many_binary_dropzone_field.js new file mode 100644 index 000000000..39bfdf719 --- /dev/null +++ b/addons_extensions/hr_recruitment_auto_doc/static/src/js/many2many_binary_dropzone_field.js @@ -0,0 +1,67 @@ +import { _t } from "@web/core/l10n/translation"; +import { registry } from "@web/core/registry"; +import { useFileUploader } from "@web/core/utils/files"; +import { FileInput } from "@web/core/file_input/file_input"; +import { many2ManyBinaryField, Many2ManyBinaryField } from "@web/views/fields/many2many_binary/many2many_binary_field"; + +import { useState } from "@odoo/owl"; + +export class Many2ManyBinaryDropzoneField extends Many2ManyBinaryField { + static template = "hr_recruitment_auto_doc.Many2ManyBinaryDropzoneField"; + static components = { + ...Many2ManyBinaryField.components, + FileInput, + }; + + setup() { + super.setup(); + this.uploadFiles = useFileUploader(); + this.state = useState({ + isDragging: false, + }); + } + + get dropzoneText() { + return _t("Drag and drop files here, or click to upload"); + } + + onDragEnter(ev) { + ev.preventDefault(); + this.state.isDragging = true; + } + + onDragOver(ev) { + ev.preventDefault(); + this.state.isDragging = true; + } + + onDragLeave(ev) { + ev.preventDefault(); + this.state.isDragging = false; + } + + async onFilesDropped(ev) { + ev.preventDefault(); + this.state.isDragging = false; + const droppedFiles = [...(ev.dataTransfer?.files || [])]; + if (!droppedFiles.length) { + return; + } + const parsedFileData = await this.uploadFiles("/web/binary/upload_attachment", { + csrf_token: odoo.csrf_token, + ufile: droppedFiles, + model: this.props.record.resModel, + id: this.props.record.resId || 0, + }); + if (parsedFileData) { + await this.onFileUploaded(parsedFileData); + } + } +} + +export const many2ManyBinaryDropzoneField = { + ...many2ManyBinaryField, + component: Many2ManyBinaryDropzoneField, +}; + +registry.category("fields").add("many2many_binary_dropzone", many2ManyBinaryDropzoneField); diff --git a/addons_extensions/hr_recruitment_auto_doc/static/src/js/recruitment_auto_doc_kanban.js b/addons_extensions/hr_recruitment_auto_doc/static/src/js/recruitment_auto_doc_kanban.js new file mode 100644 index 000000000..a13400944 --- /dev/null +++ b/addons_extensions/hr_recruitment_auto_doc/static/src/js/recruitment_auto_doc_kanban.js @@ -0,0 +1,34 @@ +/** @odoo-module */ + +import { registry } from "@web/core/registry"; +import { kanbanView } from "@web/views/kanban/kanban_view"; +import { KanbanController } from "@web/views/kanban/kanban_controller"; + +export class RecruitmentAutoDocKanbanController extends KanbanController { + static template = "hr_recruitment_auto_doc.KanbanView"; + + async openParseWizard() { + const actionMap = { + "hr.applicant": "hr_recruitment_auto_doc.action_hr_recruitment_auto_doc_wizard_applicant", + "hr.candidate": "hr_recruitment_auto_doc.action_hr_recruitment_auto_doc_wizard_candidate", + "hr.job.recruitment": "hr_recruitment_auto_doc.action_hr_recruitment_auto_doc_wizard_job_recruitment", + }; + const actionXmlId = actionMap[this.model.config.resModel]; + if (!actionXmlId) { + return; + } + const activeIds = this.model.root.selection.map((record) => record.resId); + await this.actionService.doAction(actionXmlId, { + additionalContext: { + active_model: this.model.config.resModel, + active_ids: activeIds, + }, + }); + } +} + +registry.category("views").add("hr_recruitment_auto_doc_kanban", { + ...kanbanView, + buttonTemplate: "hr_recruitment_auto_doc.KanbanButtons", + Controller: RecruitmentAutoDocKanbanController, +}); diff --git a/addons_extensions/hr_recruitment_auto_doc/static/src/js/recruitment_auto_doc_list.js b/addons_extensions/hr_recruitment_auto_doc/static/src/js/recruitment_auto_doc_list.js new file mode 100644 index 000000000..b73990953 --- /dev/null +++ b/addons_extensions/hr_recruitment_auto_doc/static/src/js/recruitment_auto_doc_list.js @@ -0,0 +1,34 @@ +/** @odoo-module */ + +import { registry } from "@web/core/registry"; +import { listView } from "@web/views/list/list_view"; +import { ListController } from "@web/views/list/list_controller"; + +export class RecruitmentAutoDocListController extends ListController { + static template = "hr_recruitment_auto_doc.ListView"; + + async openParseWizard() { + const actionMap = { + "hr.applicant": "hr_recruitment_auto_doc.action_hr_recruitment_auto_doc_wizard_applicant", + "hr.candidate": "hr_recruitment_auto_doc.action_hr_recruitment_auto_doc_wizard_candidate", + "hr.job.recruitment": "hr_recruitment_auto_doc.action_hr_recruitment_auto_doc_wizard_job_recruitment", + }; + const actionXmlId = actionMap[this.model.config.resModel]; + if (!actionXmlId) { + return; + } + const activeIds = this.model.root.selection.map((record) => record.resId); + await this.actionService.doAction(actionXmlId, { + additionalContext: { + active_model: this.model.config.resModel, + active_ids: activeIds, + }, + }); + } +} + +registry.category("views").add("hr_recruitment_auto_doc_list", { + ...listView, + buttonTemplate: "hr_recruitment_auto_doc.ListButtons", + Controller: RecruitmentAutoDocListController, +}); diff --git a/addons_extensions/hr_recruitment_auto_doc/static/src/scss/recruitment_auto_doc_widgets.scss b/addons_extensions/hr_recruitment_auto_doc/static/src/scss/recruitment_auto_doc_widgets.scss new file mode 100644 index 000000000..31b6881c0 --- /dev/null +++ b/addons_extensions/hr_recruitment_auto_doc/static/src/scss/recruitment_auto_doc_widgets.scss @@ -0,0 +1,41 @@ +.o_job_skill_transfer_tags .o_tag { + cursor: grab; +} + +.o_job_skill_transfer_tags .o_tag:active { + cursor: grabbing; +} + +.o_recruitment_dropzone_field { + border: 2px dashed #cbd5e1; + border-radius: 16px; + background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); + padding: 18px; + transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease; +} + +.o_recruitment_dropzone_field_dragging { + border-color: #0d6efd; + background: #eff6ff; + box-shadow: 0 0 0 4px rgba(13, 110, 253, 0.08); +} + +.o_recruitment_dropzone_box { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + padding: 18px 12px; + text-align: center; +} + +.o_recruitment_dropzone_icon { + font-size: 30px; + color: #2563eb; +} + +.o_recruitment_dropzone_text { + color: #334155; + font-weight: 500; +} diff --git a/addons_extensions/hr_recruitment_auto_doc/static/src/xml/job_skill_transfer_field.xml b/addons_extensions/hr_recruitment_auto_doc/static/src/xml/job_skill_transfer_field.xml new file mode 100644 index 000000000..97ebc659f --- /dev/null +++ b/addons_extensions/hr_recruitment_auto_doc/static/src/xml/job_skill_transfer_field.xml @@ -0,0 +1,57 @@ + + + +
+ + +
+ + + + + +
+ +
+
+
+ diff --git a/addons_extensions/hr_recruitment_auto_doc/static/src/xml/many2many_binary_dropzone_field.xml b/addons_extensions/hr_recruitment_auto_doc/static/src/xml/many2many_binary_dropzone_field.xml new file mode 100644 index 000000000..e1f897ed5 --- /dev/null +++ b/addons_extensions/hr_recruitment_auto_doc/static/src/xml/many2many_binary_dropzone_field.xml @@ -0,0 +1,34 @@ + + + +
+
+
+
+
+ + + +
+
+
+ + + +
+
+ + diff --git a/addons_extensions/hr_recruitment_auto_doc/static/src/xml/recruitment_auto_doc_buttons.xml b/addons_extensions/hr_recruitment_auto_doc/static/src/xml/recruitment_auto_doc_buttons.xml new file mode 100644 index 000000000..1fb668331 --- /dev/null +++ b/addons_extensions/hr_recruitment_auto_doc/static/src/xml/recruitment_auto_doc_buttons.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + diff --git a/addons_extensions/hr_recruitment_auto_doc/views/hr_applicant_views.xml b/addons_extensions/hr_recruitment_auto_doc/views/hr_applicant_views.xml new file mode 100644 index 000000000..76ca68968 --- /dev/null +++ b/addons_extensions/hr_recruitment_auto_doc/views/hr_applicant_views.xml @@ -0,0 +1,50 @@ + + + + hr.applicant.view.list.auto.doc.inherit + hr.applicant + + + + hr_recruitment_auto_doc_list + + + + + + hr.applicant.view.kanban.auto.doc.inherit + hr.applicant + + + + hr_recruitment_auto_doc_kanban + + + + + + hr.applicant.view.kanban.auto.doc.extended.inherit + hr.applicant + + + + hr_recruitment_auto_doc_kanban + + + + + + hr.applicant.view.form.auto.doc.inherit + hr.applicant + + + + +
+
+
+
+ ${escapeHtml(_t("Primary"))} + ${escapeHtml(this._formatMatchPercentage(record.primary_skill_match_percentage))}% +
+
+ ${escapeHtml(_t("Secondary"))} + ${escapeHtml(this._formatMatchPercentage(record.secondary_skill_match_percentage))}% +
+
+ ${escapeHtml(_t("Phone"))} + ${escapeHtml(record.partner_phone || "-")} +
+
+ ${escapeHtml(_t("Email"))} + ${escapeHtml(record.email_from || "-")} +
+
+
+
+ +
+ ${this._renderSkillTags(record.matching_skill_names || [], "o_hr_match_chip_good")} +
+
+
+ +
+ ${this._renderSkillTags(record.missing_skill_names || [], "o_hr_match_chip_warn")} +
+
+
+ + `; + }, + + _renderRecruitmentMatchPanel() { + const panel = this._getRecruitmentMatchPanel(); + if (!panel) { + return; + } + if (!this._matchPanelOpened) { + panel.classList.remove("o_hr_match_panel_open"); + return; + } + + const activeElement = document.activeElement; + const shouldRestoreSearchFocus = activeElement?.classList?.contains("o_hr_match_search_input"); + const searchSelectionStart = shouldRestoreSearchFocus ? activeElement.selectionStart : null; + const searchSelectionEnd = shouldRestoreSearchFocus ? activeElement.selectionEnd : null; + + const payload = this._matchPanelData || { + candidates: [], + applicants: [], + primary_skill_names: [], + secondary_skill_names: [], + candidate_count: 0, + applicant_count: 0, + }; + const filteredCandidates = (payload.candidates || []).filter((record) => this._recordMatchesSearch(record)); + const filteredApplicants = (payload.applicants || []).filter((record) => this._recordMatchesSearch(record)); + const activeRecords = this._matchPanelActiveTab === "applicants" ? filteredApplicants : filteredCandidates; + const activeCards = activeRecords.length + ? activeRecords + .map((record, index) => this._renderMatchCard(record, index, this._matchPanelActiveTab)) + .join("") + : this._renderEmptyState( + this._matchPanelActiveTab === "applicants" ? _t("No applicants found") : _t("No candidates found"), + this._matchPanelSearchTerm + ? _t("Try a different search term to widen the results.") + : this._matchPanelActiveTab === "applicants" + ? _t("Applicants added to this recruitment will appear here with the same match insights.") + : _t("Try refreshing after updating the recruitment skills or candidate pool.") + ); + + panel.innerHTML = ` +
+
+
+

${escapeHtml(_t("Candidate Pool"))}

+

${escapeHtml(payload.job_recruitment_name || _t("Recruitment Matches"))}

+ ${escapeHtml(`${payload.candidate_count || 0} ${_t("pool candidates")} • ${payload.applicant_count || 0} ${_t("applicants")}`)} +
+
+ + +
+
+
+
+ + +
+ +
+
+
+ +
+ ${this._renderSkillTags(payload.primary_skill_names || [], "o_hr_match_chip_primary")} +
+
+
+ +
+ ${this._renderSkillTags(payload.secondary_skill_names || [], "o_hr_match_chip_secondary")} +
+
+
+
+ ${this._matchPanelLoading ? `
${escapeHtml(_t("Refreshing matches..."))}
` : activeCards} +
+
+ `; + panel.classList.add("o_hr_match_panel_open"); + + panel.querySelector(".o_hr_match_close")?.addEventListener("click", () => this._closeRecruitmentMatchPanel()); + panel.querySelector(".o_hr_match_refresh")?.addEventListener("click", () => this._loadRecruitmentMatchPanelData()); + panel.querySelectorAll(".o_hr_match_tab").forEach((tabButton) => { + tabButton.addEventListener("click", () => { + this._matchPanelActiveTab = tabButton.dataset.tab || "candidates"; + this._renderRecruitmentMatchPanel(); + }); + }); + panel.querySelector(".o_hr_match_search_input")?.addEventListener("input", (event) => { + this._matchPanelSearchTerm = event.target.value || ""; + this._renderRecruitmentMatchPanel(); + }); + panel.querySelectorAll('[data-action="add-candidate"]').forEach((button) => { + button.addEventListener("click", (event) => { + event.stopPropagation(); + const candidateId = Number(button.dataset.id); + if (candidateId) { + this._addCandidateToRecruitment(candidateId); + } + }); + }); + panel.querySelectorAll('[data-action="open-applicant"]').forEach((button) => { + button.addEventListener("click", (event) => { + event.stopPropagation(); + const applicantId = Number(button.dataset.id); + if (applicantId) { + this.actionService.doAction({ + type: "ir.actions.act_window", + res_model: "hr.applicant", + res_id: applicantId, + views: [[false, "form"]], + target: "current", + }); + } + }); + }); + for (const card of panel.querySelectorAll(".o_hr_match_card")) { + card.addEventListener("click", (event) => { + const recordId = Number(event.currentTarget.dataset.recordId); + const recordType = event.currentTarget.dataset.recordType; + if (recordId && recordType === "applicants") { + this.actionService.doAction({ + type: "ir.actions.act_window", + res_model: "hr.applicant", + res_id: recordId, + views: [[false, "form"]], + target: "current", + }); + } else if (recordId) { + this.actionService.doAction({ + type: "ir.actions.act_window", + res_model: "hr.candidate", + res_id: recordId, + views: [[false, "form"]], + target: "current", + }); + } + }); + } + + const button = this._getRecruitmentMatchPanelButton(); + if (button) { + button.classList.add("o_hr_match_fab_hidden"); + } + + if (shouldRestoreSearchFocus) { + const searchInput = panel.querySelector(".o_hr_match_search_input"); + if (searchInput) { + searchInput.focus(); + if (searchSelectionStart !== null && searchSelectionEnd !== null) { + searchInput.setSelectionRange(searchSelectionStart, searchSelectionEnd); + } + } + } + }, + + _ensureRecruitmentMatchPanel() { + let panel = this._getRecruitmentMatchPanel(); + if (!panel) { + panel = document.createElement("aside"); + panel.className = "o_hr_match_panel"; + panel.dataset.controllerId = this.__owl__.id; + document.body.appendChild(panel); + } + this._renderRecruitmentMatchPanel(); + }, + + _ensureRecruitmentMatchButton() { + if (!this.model.root.resId) { + this._getRecruitmentMatchPanelButton()?.remove(); + return; + } + let button = this._getRecruitmentMatchPanelButton(); + if (!button) { + button = document.createElement("button"); + button.type = "button"; + button.className = "o_hr_match_fab"; + button.dataset.controllerId = this.__owl__.id; + button.innerHTML = ` + + ${escapeHtml(_t("Pool Matches"))} + `; + button.addEventListener("click", () => this._openRecruitmentMatchPanel()); + document.body.appendChild(button); + } + }, + + _syncRecruitmentMatchPanel() { + this._ensureRecruitmentMatchButton(); + if (this._matchPanelOpened) { + this._ensureRecruitmentMatchPanel(); + } + }, + + _removeRecruitmentMatchPanelArtifacts() { + this._getRecruitmentMatchPanelButton()?.remove(); + this._getRecruitmentMatchPanel()?.remove(); + }, +}); diff --git a/addons_extensions/hr_recruitment_extended/static/src/scss/recruitment_match_panel.scss b/addons_extensions/hr_recruitment_extended/static/src/scss/recruitment_match_panel.scss new file mode 100644 index 000000000..208544dec --- /dev/null +++ b/addons_extensions/hr_recruitment_extended/static/src/scss/recruitment_match_panel.scss @@ -0,0 +1,463 @@ +.o_hr_match_fab { + position: fixed; + right: 24px; + bottom: 24px; + z-index: 1090; + display: inline-flex; + align-items: center; + gap: 12px; + border: 0; + border-radius: 999px; + padding: 14px 18px; + background: linear-gradient(135deg, #0f3d3e 0%, #174a7c 100%); + color: #fff; + box-shadow: 0 20px 45px rgba(15, 23, 42, 0.28); + transition: transform 0.2s ease, box-shadow 0.2s ease, opacity 0.2s ease; +} + +.o_hr_match_fab:hover { + transform: translateY(-2px); + box-shadow: 0 24px 52px rgba(15, 23, 42, 0.34); +} + +.o_hr_match_fab_hidden { + opacity: 0; + pointer-events: none; +} + +.o_hr_match_fab_icon { + width: 36px; + height: 36px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.18); + font-size: 16px; +} + +.o_hr_match_fab_text { + font-weight: 600; + letter-spacing: 0.02em; +} + +.o_hr_match_panel { + position: fixed; + top: 88px; + right: 24px; + bottom: 24px; + width: unquote("min(540px, calc(100vw - 48px))"); + z-index: 1085; + pointer-events: none; + transform: translateX(calc(100% + 24px)); + transition: transform 0.26s ease; +} + +.o_hr_match_panel_open { + pointer-events: auto; + transform: translateX(0); +} + +.o_hr_match_panel_backdropless { + height: 100%; + display: flex; + flex-direction: column; + overflow: hidden; + border-radius: 26px; + border: 1px solid rgba(148, 163, 184, 0.22); + background: + radial-gradient(circle at top right, rgba(56, 189, 248, 0.12), transparent 32%), + radial-gradient(circle at bottom left, rgba(45, 212, 191, 0.09), transparent 26%), + linear-gradient(180deg, rgba(255, 255, 255, 0.985), rgba(246, 248, 251, 0.985)); + backdrop-filter: blur(10px); + box-shadow: 0 28px 60px rgba(15, 23, 42, 0.2); +} + +.o_hr_match_panel_header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; + padding: 20px 22px 16px; + background: linear-gradient(135deg, #12263f, #1f5f72); + color: #fff; +} + +.o_hr_match_panel_header p, +.o_hr_match_panel_header h3, +.o_hr_match_panel_header span { + margin: 0; +} + +.o_hr_match_panel_header p { + font-size: 12px; + letter-spacing: 0.14em; + text-transform: uppercase; + opacity: 0.72; +} + +.o_hr_match_panel_header h3 { + margin-top: 6px; + font-size: 22px; + font-weight: 700; +} + +.o_hr_match_panel_header span { + display: block; + margin-top: 8px; + font-size: 13px; + opacity: 0.84; +} + +.o_hr_match_panel_actions { + display: flex; + gap: 8px; +} + +.o_hr_match_panel_toolbar { + display: grid; + gap: 14px; + padding: 16px 22px 0; +} + +.o_hr_match_tab_row { + display: flex; + gap: 10px; +} + +.o_hr_match_tab { + border: 1px solid rgba(148, 163, 184, 0.24); + border-radius: 999px; + padding: 9px 14px; + background: rgba(255, 255, 255, 0.9); + color: #334155; + font-size: 13px; + font-weight: 700; + transition: all 0.18s ease; +} + +.o_hr_match_tab span { + margin-left: 6px; + color: #64748b; +} + +.o_hr_match_tab_active { + border-color: #1f5f72; + background: linear-gradient(135deg, #10394a, #1f5f72); + color: #fff; + box-shadow: 0 12px 24px rgba(16, 57, 74, 0.2); +} + +.o_hr_match_tab_active span { + color: rgba(255, 255, 255, 0.8); +} + +.o_hr_match_search { + display: flex; + align-items: center; + gap: 10px; + border: 1px solid rgba(148, 163, 184, 0.24); + border-radius: 16px; + padding: 11px 14px; + background: rgba(255, 255, 255, 0.9); + color: #64748b; +} + +.o_hr_match_search:focus-within { + border-color: rgba(31, 95, 114, 0.5); + box-shadow: 0 0 0 4px rgba(31, 95, 114, 0.08); +} + +.o_hr_match_search_input { + flex: 1 1 auto; + min-width: 0; + border: 0; + outline: 0; + background: transparent; + color: #0f172a; + font-size: 14px; +} + +.o_hr_match_close { + min-width: 42px; + padding: 0; + font-size: 24px; + line-height: 1; +} + +.o_hr_match_panel_skill_summary { + display: grid; + gap: 14px; + padding: 16px 22px; + border-bottom: 1px solid rgba(148, 163, 184, 0.2); + background: rgba(255, 255, 255, 0.68); +} + +.o_hr_match_panel_skill_summary label, +.o_hr_match_skill_section label { + display: block; + margin-bottom: 8px; + color: #334155; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.o_hr_match_chip_row { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.o_hr_match_chip { + display: inline-flex; + align-items: center; + border-radius: 999px; + padding: 5px 10px; + font-size: 11px; + font-weight: 600; +} + +.o_hr_match_chip_primary { + background: #ddeefe; + color: #2258a5; +} + +.o_hr_match_chip_secondary { + background: #dbf7ef; + color: #0f766e; +} + +.o_hr_match_chip_good { + background: #dff7ee; + color: #106a57; +} + +.o_hr_match_chip_warn { + background: #fff2d9; + color: #b45309; +} + +.o_hr_match_panel_body { + flex: 1 1 auto; + overflow: auto; + padding: 16px 22px 22px; +} + +.o_hr_match_panel_body_loading { + display: flex; + align-items: center; + justify-content: center; +} + +.o_hr_match_loading, +.o_hr_match_empty { + padding: 28px 18px; + text-align: center; + color: #475569; +} + +.o_hr_match_card { + margin-bottom: 14px; + padding: 16px; + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 18px; + background: #fff; + box-shadow: 0 10px 24px rgba(15, 23, 42, 0.06); + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease; +} + +.o_hr_match_card:hover { + transform: translateY(-2px); + border-color: rgba(31, 95, 114, 0.28); + box-shadow: 0 16px 30px rgba(15, 23, 42, 0.1); +} + +.o_hr_match_card_main { + display: flex; + justify-content: space-between; + gap: 16px; + margin-bottom: 14px; +} + +.o_hr_match_card_identity { + display: flex; + align-items: flex-start; + gap: 10px; + min-width: 0; +} + +.o_hr_match_identity_copy { + min-width: 0; +} + +.o_hr_match_rank { + display: inline-flex; + border-radius: 999px; + padding: 5px 9px; + background: #e0f7f4; + color: #0f766e; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; +} + +.o_hr_match_identity_copy h4, +.o_hr_match_identity_copy p { + margin: 0; +} + +.o_hr_match_identity_copy h4 { + color: #0f172a; + font-size: 17px; + font-weight: 700; + line-height: 1.3; +} + +.o_hr_match_identity_copy p { + margin-top: 4px; + color: #64748b; + font-size: 12px; +} + +.o_hr_match_card_actions { + display: flex; + align-items: flex-start; + gap: 10px; +} + +.o_hr_match_score_ring { + min-width: 72px; + min-height: 72px; + border-radius: 50%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: radial-gradient(circle at 30% 30%, #dcf4ef, #c4ece2 55%, #8fd6c5); + color: #0f172a; + text-align: center; +} + +.o_hr_match_score_ring span { + font-size: 18px; + font-weight: 800; + line-height: 1; +} + +.o_hr_match_score_ring small { + margin-top: 3px; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.o_hr_match_inline_action { + width: 38px; + min-width: 38px; + height: 38px; + border: 0; + border-radius: 12px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; +} + +.o_hr_match_action_primary { + background: linear-gradient(135deg, #10394a, #1f5f72); + color: #fff; +} + +.o_hr_match_action_secondary { + background: #eff6ff; + color: #1d4ed8; +} + +.o_hr_match_inline_action:disabled { + opacity: 0.6; + cursor: wait; +} + +.o_hr_match_score_grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + margin-bottom: 14px; +} + +.o_hr_match_score_grid div { + border-radius: 14px; + background: #f8fafc; + padding: 10px 12px; +} + +.o_hr_match_score_grid span { + display: block; + color: #64748b; + font-size: 12px; + margin-bottom: 6px; +} + +.o_hr_match_score_grid strong { + color: #0f172a; + font-size: 14px; +} + +.o_hr_match_meta_value { + display: block; + overflow-wrap: anywhere; + word-break: break-word; +} + +.o_hr_match_skill_split { + display: grid; + gap: 14px; +} + +@media (min-width: 768px) { + .o_hr_match_skill_split { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 767px) { + .o_hr_match_panel { + top: 72px; + right: 12px; + bottom: 12px; + width: calc(100vw - 24px); + } + + .o_hr_match_fab { + right: 12px; + bottom: 12px; + } + + .o_hr_match_panel_toolbar, + .o_hr_match_panel_skill_summary, + .o_hr_match_panel_body { + padding-left: 16px; + padding-right: 16px; + } + + .o_hr_match_card_main, + .o_hr_match_card_actions { + flex-direction: column; + } + + .o_hr_match_card_actions { + align-items: stretch; + } + + .o_hr_match_inline_action { + width: 100%; + height: 40px; + } + + .o_hr_match_score_grid { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/addons_extensions/hr_recruitment_extended/views/hr_applicant_views.xml b/addons_extensions/hr_recruitment_extended/views/hr_applicant_views.xml index 59ccc26df..93ee83819 100644 --- a/addons_extensions/hr_recruitment_extended/views/hr_applicant_views.xml +++ b/addons_extensions/hr_recruitment_extended/views/hr_applicant_views.xml @@ -4,15 +4,18 @@ hr.applicant.view.list hr.applicant - - - 1 - - - - - - + + + 1 + + + + + + + + + hr.applicant.view.form hr.applicant @@ -48,21 +51,29 @@ - - - - - - - - - - + + + + + + + + + + + - - - - + + + + + + + + + + + 1 @@ -141,18 +152,21 @@
- - - - - + + + + + - - - - - - + + + + + hide_chatter_suggestion + + +
+ hr.applicant.view.search hr.applicant @@ -428,4 +442,4 @@ - \ No newline at end of file + diff --git a/addons_extensions/hr_recruitment_extended/views/hr_job_recruitment.xml b/addons_extensions/hr_recruitment_extended/views/hr_job_recruitment.xml index c1407e421..3bf8c2e3f 100644 --- a/addons_extensions/hr_recruitment_extended/views/hr_job_recruitment.xml +++ b/addons_extensions/hr_recruitment_extended/views/hr_job_recruitment.xml @@ -57,14 +57,15 @@ -
-
-
- - - + +
+
+ + + +
- -
- -
-
+ +
+ +
+
@@ -214,10 +215,10 @@ - - - - + + + + diff --git a/addons_extensions/hr_recruitment_extended/views/recruitment_matching_views.xml b/addons_extensions/hr_recruitment_extended/views/recruitment_matching_views.xml new file mode 100644 index 000000000..8b84609fc --- /dev/null +++ b/addons_extensions/hr_recruitment_extended/views/recruitment_matching_views.xml @@ -0,0 +1,45 @@ + + + + + Show/Hide Chatter Suggestions + + + form + code + action = records.action_toggle_chatter_visibility() + + + + Show/Hide Chatter Suggestions + + + form + code + action = records.action_toggle_chatter_visibility() + + + + Show/Hide Chatter Suggestions + + + form + code + action = records.action_toggle_chatter_visibility() + + + + hr.candidate.view.form.chatter.toggle.inherit + hr.candidate + + + + + + + hide_chatter_suggestion + + + + + diff --git a/addons_extensions/hr_recruitment_extended/wizards/__init__.py b/addons_extensions/hr_recruitment_extended/wizards/__init__.py index b876111ab..026aab5a0 100644 --- a/addons_extensions/hr_recruitment_extended/wizards/__init__.py +++ b/addons_extensions/hr_recruitment_extended/wizards/__init__.py @@ -1,4 +1,4 @@ -from . import post_onboarding_attachment_wizard -from . import applicant_refuse_reason -from . import ats_invite_mail_template_wizard -from . import client_submission_mail_template_wizard \ No newline at end of file +from . import post_onboarding_attachment_wizard +from . import applicant_refuse_reason +from . import ats_invite_mail_template_wizard +from . import client_submission_mail_template_wizard diff --git a/addons_extensions/module_selector_sidebar/controllers/master_switcher.py b/addons_extensions/module_selector_sidebar/controllers/master_switcher.py index 242d3baf2..1726434d1 100644 --- a/addons_extensions/module_selector_sidebar/controllers/master_switcher.py +++ b/addons_extensions/module_selector_sidebar/controllers/master_switcher.py @@ -1,14 +1,22 @@ -from odoo import http -from odoo.http import request - - -class MasterSwitcher(http.Controller): - - @http.route('/switch/master/', type='http', auth='user') - def switch_master(self, code): - - request.session['active_master'] = code - - request.env['ir.ui.menu'].sudo().clear_caches() - - return request.redirect('/web?reload=1') \ No newline at end of file +from odoo import http +from odoo.http import request + + +class MasterSwitcher(http.Controller): + + @http.route('/switch/master/', type='http', auth='user') + def switch_master(self, code): + master = request.env['master.control'].sudo().search([ + ('code', '=', code), + ('user_ids', 'in', [request.env.user.id]), + ], limit=1) + + if not master: + return request.redirect('/web') + + request.session['active_master'] = master.code + request.session['active_user'] = request.env.user.id + + request.env['ir.ui.menu'].sudo().clear_caches() + + return request.redirect('/web?reload=1') diff --git a/addons_extensions/module_selector_sidebar/static/src/js/module_switcher.js b/addons_extensions/module_selector_sidebar/static/src/js/module_switcher.js index 040b09e32..f683a95ae 100644 --- a/addons_extensions/module_selector_sidebar/static/src/js/module_switcher.js +++ b/addons_extensions/module_selector_sidebar/static/src/js/module_switcher.js @@ -1,37 +1,41 @@ -/** @odoo-module **/ - -import { Component, onWillStart, useState } from "@odoo/owl"; -import { registry } from "@web/core/registry"; -import { useService } from "@web/core/utils/hooks"; - -export class ModuleSelector extends Component { - - static template = "module_selector_sidebar.ModuleSelector"; - static props = {}; - - setup() { - - this.orm = useService("orm"); - - this.state = useState({ - masters: [], - }); - - onWillStart(async () => { - - const masters = await this.orm.searchRead( - "master.control", - [], - ["name", "code"] - ); - - this.state.masters = masters; - - }); - } - -} - -registry.category("systray").add("module_selector_sidebar", { - Component: ModuleSelector, -}); \ No newline at end of file +/** @odoo-module **/ + +import { Component, onWillStart, useState } from "@odoo/owl"; +import { registry } from "@web/core/registry"; +import { user } from "@web/core/user"; +import { useService } from "@web/core/utils/hooks"; + +export class ModuleSelector extends Component { + + static template = "module_selector_sidebar.ModuleSelector"; + static props = {}; + + setup() { + + this.orm = useService("orm"); + + this.state = useState({ + masters: [], + }); + + onWillStart(async () => { + if (!user.userId) { + this.state.masters = []; + return; + } + + const masters = await this.orm.searchRead( + "master.control", + [["user_ids", "in", [user.userId]]], + ["name", "code"] + ); + + this.state.masters = masters; + }); + } + +} + +registry.category("systray").add("module_selector_sidebar", { + Component: ModuleSelector, +}); diff --git a/addons_extensions/project_dashboards_management/views/project_dashboard_actions.xml b/addons_extensions/project_dashboards_management/views/project_dashboard_actions.xml index 406922803..89d123c59 100644 --- a/addons_extensions/project_dashboards_management/views/project_dashboard_actions.xml +++ b/addons_extensions/project_dashboards_management/views/project_dashboard_actions.xml @@ -15,6 +15,7 @@ parent="project.menu_project_management" action="action_project_dashboard_fullscreen" groups="project.group_project_manager" + active="0" sequence="10"/> \ No newline at end of file diff --git a/addons_extensions/project_gantt/__manifest__.py b/addons_extensions/project_gantt/__manifest__.py index e5005a95c..5e2e0697b 100644 --- a/addons_extensions/project_gantt/__manifest__.py +++ b/addons_extensions/project_gantt/__manifest__.py @@ -18,7 +18,6 @@ Bridge module for project 'views/project_sharing_views.xml', 'views/project_portal_project_task_templates.xml', ], - 'demo': ['data/project_demo.xml'], 'auto_install': True, 'assets': { 'web.assets_backend': [ diff --git a/addons_extensions/project_task_timesheet_extended/data/data.xml b/addons_extensions/project_task_timesheet_extended/data/data.xml index 8c09610d1..272faaa5a 100644 --- a/addons_extensions/project_task_timesheet_extended/data/data.xml +++ b/addons_extensions/project_task_timesheet_extended/data/data.xml @@ -109,48 +109,48 @@ - - 100 - Backlog - - - - - - 101 - Development - - - - - - 102 - Code Review & Git Merging - - - - - - 103 - Testing - - - - - - 104 - Deployment - - - - - - 105 - Completed - - - - + + 100 + Backlog + + + + + + 101 + Development + + + + + + 102 + Code Review & Git Merging + + + + + + 103 + Testing + + + + + + 104 + Deployment + + + + + + 105 + Completed + + + + @@ -264,4 +264,4 @@ - + diff --git a/addons_extensions/project_task_timesheet_extended/hooks.py b/addons_extensions/project_task_timesheet_extended/hooks.py index dcba4fd6f..7ccc66901 100644 --- a/addons_extensions/project_task_timesheet_extended/hooks.py +++ b/addons_extensions/project_task_timesheet_extended/hooks.py @@ -38,7 +38,9 @@ def post_init_hook(env): ('user_ids', 'in', user.id), '&', ('is_generic', '=', False), - ('user_ids', 'in', user.id), + '|', + ('user_ids', 'in', user.id), + ('involved_user_ids', 'in', user.id), ] """ }) @@ -174,4 +176,5 @@ def post_init_hook(env): task.sequence_name = project.task_sequence_id.next_by_id() # Normalize task stages so each project owns its workflow configuration. - env['project.project'].search([])._ensure_project_owned_task_stages() + if env['project.project'].search([]): + env['project.project'].search([])._ensure_project_owned_task_stages() diff --git a/addons_extensions/project_task_timesheet_extended/models/project_task.py b/addons_extensions/project_task_timesheet_extended/models/project_task.py index 2d0566b26..eb5202edd 100644 --- a/addons_extensions/project_task_timesheet_extended/models/project_task.py +++ b/addons_extensions/project_task_timesheet_extended/models/project_task.py @@ -27,6 +27,19 @@ class projectTask(models.Model): _inherit = 'project.task' _rec_name = 'name' + user_ids = fields.Many2many() + + involved_user_ids = fields.Many2many( + 'res.users', + 'project_task_involved_user_rel', + 'task_id', + 'user_id', + string='Involved Assignees', + tracking=True, + domain="[('id', 'in', assignee_domain_ids)]", + help='Supporting users who can collaborate on the task without owning it in My Tasks.' + ) + deadline_status = fields.Selection([ ('overdue', 'Overdue'), ('near', 'Near Deadline'), @@ -34,6 +47,8 @@ class projectTask(models.Model): ], compute='_compute_deadline_status') model_id = fields.Many2one('project.module.source', string="Module") + allocation_start_date = fields.Date(string="Allocation Start Date") + allocation_end_date = fields.Date(string="Allocation End Date") @api.depends('date_deadline') def _compute_deadline_status(self): @@ -64,9 +79,19 @@ class projectTask(models.Model): def write(self, vals): # Allow stage update for multiple records if 'stage_id' in vals: - return super(projectTask, self).write(vals) + result = super(projectTask, self).write(vals) + if any(field in vals for field in ['allocation_start_date', 'allocation_end_date']): + self._sync_allocated_hours_from_allocation_dates() + if any(field in vals for field in ['user_ids', 'is_generic']): + self._sync_involved_assignees_from_timelines() + return result - return super(projectTask, self).write(vals) + result = super(projectTask, self).write(vals) + if any(field in vals for field in ['allocation_start_date', 'allocation_end_date']): + self._sync_allocated_hours_from_allocation_dates() + if any(field in vals for field in ['user_ids', 'is_generic']): + self._sync_involved_assignees_from_timelines() + return result # # @api.constrains('name') @@ -112,7 +137,12 @@ class projectTask(models.Model): ): raise UserError("Only Task Creator or Project Manager can edit Generic field.") - return super(projectTask, self).write(vals) + result = super(projectTask, self).write(vals) + if any(field in vals for field in ['allocation_start_date', 'allocation_end_date']): + self._sync_allocated_hours_from_allocation_dates() + if any(field in vals for field in ['user_ids', 'is_generic']): + self._sync_involved_assignees_from_timelines() + return result @api.constrains('estimated_hours') def _check_estimated_hours(self): @@ -152,6 +182,7 @@ class projectTask(models.Model): store=False, ) + @api.depends( 'project_id', 'project_id.privacy_visibility', @@ -186,6 +217,7 @@ class projectTask(models.Model): @api.depends( 'is_generic', 'user_ids', + 'involved_user_ids', 'project_id', 'project_id.privacy_visibility', 'project_id.message_partner_ids', @@ -218,14 +250,22 @@ class projectTask(models.Model): # NORMAL TASK: assignees and involved collaborators else: + users = task.user_ids | task.involved_user_ids employees = ( - task.user_ids + users .mapped('employee_id') .filtered(lambda e: e) ) task.allowed_employee_ids = employees + @api.onchange('involved_user_ids', 'is_generic') + def _onchange_assignee_domain_from_involved(self): + for task in self: + if task.is_generic: + return {'domain': {'user_ids': [('id', 'in', task.assignee_domain_ids.ids)]}} + return {'domain': {'user_ids': [('id', 'in', task.involved_user_ids.ids)]}} + @api.onchange('user_ids') def _onchange_user_ids(self): if self.project_id and (self.project_id.user_id or self.project_id.project_lead): @@ -460,7 +500,7 @@ class projectTask(models.Model): task.suggested_deadline.strftime('%Y-%m-%d %H:%M') if task.suggested_deadline else _('Not available') )) - @api.depends("project_id") + @api.depends("project_id", "stage_id") def _compute_has_supervisor_access(self): administrative_users = self.env['project.role'].search([ ('role_level', '=', 'administrative') @@ -478,6 +518,12 @@ class projectTask(models.Model): stages = project.type_ids.sorted("sequence") + if not stages: + continue + + first_stage = stages[0] + + if first_stage: create_access_users = first_stage.team_id.team_lead + first_stage.involved_user_ids + administrative_users.user_ids else: @@ -498,6 +544,52 @@ class projectTask(models.Model): for task in self: task.actual_hours = sum(task.timesheet_ids.mapped('unit_amount')) + def _get_allocation_calendar(self): + self.ensure_one() + return self.company_id.resource_calendar_id or self.env.company.resource_calendar_id + + def _get_allocated_hours_from_dates(self): + self.ensure_one() + start_date = fields.Date.to_date(self.allocation_start_date or self.allocation_end_date) + end_date = fields.Date.to_date(self.allocation_end_date or self.allocation_start_date) + if not start_date: + return 0.0 + if end_date < start_date: + raise ValidationError(_("Allocation End Date cannot be before Allocation Start Date.")) + + calendar = self._get_allocation_calendar() + hours_per_day = calendar.hours_per_day if calendar and calendar.hours_per_day else 8.0 + attendance_days = set() + if calendar: + attendance_days = { + int(attendance.dayofweek) + for attendance in calendar.attendance_ids + } + + total_days = 0 + current_date = start_date + while current_date <= end_date: + weekday = current_date.weekday() + if attendance_days: + if weekday in attendance_days: + total_days += 1 + elif weekday < 5: + total_days += 1 + current_date += timedelta(days=1) + + return total_days * hours_per_day + + def _sync_allocated_hours_from_allocation_dates(self): + for task in self: + if task.allocation_start_date or task.allocation_end_date: + task.allocated_hours = task._get_allocated_hours_from_dates() + + @api.onchange('allocation_start_date', 'allocation_end_date') + def _onchange_allocation_dates(self): + for task in self: + if task.allocation_start_date or task.allocation_end_date: + task.allocated_hours = task._get_allocated_hours_from_dates() + def _post_to_project_channel(self, message_body, mention_partners=None): """Post message to project's discuss channel with proper Odoo mention format""" for task in self: @@ -784,7 +876,7 @@ class projectTask(models.Model): task.stage_id = n_stage task.approval_status = "approved" - activity_log = "%s: approved by %s and moved to %s" % ( + activity_log = "%s: ✅ approved by %s and moved to %s" % ( current_stage.name, self.env.user.employee_id.name, n_stage.name) @@ -825,9 +917,9 @@ class projectTask(models.Model): ) else: task.approval_status = "approved" - notes = "%s: Task approved and completed by %s" % (task.sequence_name, self.env.user.employee_id.name) + notes = "%s: ✅ Task approved and completed by %s" % (task.sequence_name, self.env.user.employee_id.name) - activity_log = "%s: approved by %s" % ( + activity_log = "%s: ✅ approved by %s" % ( current_stage.name, self.env.user.employee_id.name) @@ -863,9 +955,9 @@ class projectTask(models.Model): # Optional: find previous stage if you want to send back stage = task.assignees_timelines.filtered(lambda s: s.stage_id == task.stage_id) - notes = "%s: %s rejected by %s" % (task.sequence_name, current_stage.name, self.env.user.employee_id.name) + notes = "%s: ❌ %s rejected by %s" % (task.sequence_name, current_stage.name, self.env.user.employee_id.name) - activity_log = "%s: rejected by %s: %s" % ( + activity_log = "%s: ❌ rejected by %s: %s" % ( current_stage.name, self.env.user.employee_id.name, reason) @@ -953,6 +1045,7 @@ class projectTask(models.Model): if timeline_vals: self.env['project.task.time.lines'].create(timeline_vals) + task._sync_involved_assignees_from_timelines() # Post to project channel about timeline request channel_message = _("Timelines requested for task %s") % (task.sequence_name or task.name) @@ -962,6 +1055,7 @@ class projectTask(models.Model): @api.model_create_multi def create(self, vals_list): tasks = super().create(vals_list) + tasks._sync_allocated_hours_from_allocation_dates() # Group tasks by project to avoid creating multiple sequences for the same project project_dict = {} for task in tasks: @@ -1026,6 +1120,14 @@ class projectTask(models.Model): involved_users = list((existing_user_ids | timeline_user_ids) - set(task.user_ids.ids)) task.involved_user_ids = [(6, 0, involved_users)] + @api.model + def _sync_all_involved_assignees_from_timelines(self): + tasks = self.search([ + ('is_generic', '=', False), + ('assignees_timelines', '!=', False), + ]) + tasks._sync_involved_assignees_from_timelines() + return True def _fetch_planning_overlap(self, additional_domain=None): use_timeline_logic = any( t.timelines_requested and t.show_approval_flow and t.estimated_hours > 0 @@ -1497,6 +1599,23 @@ class projectTaskTimelines(models.Model): estimated_start_datetime = fields.Datetime(string="Estimated Start Date Time") estimated_end_datetime = fields.Datetime(string="Estimated End Date Time") + @api.model_create_multi + def create(self, vals_list): + records = super().create(vals_list) + records.mapped('task_id')._sync_involved_assignees_from_timelines() + return records + + def write(self, vals): + tasks = self.mapped('task_id') + result = super().write(vals) + (tasks | self.mapped('task_id'))._sync_involved_assignees_from_timelines() + return result + + def unlink(self): + tasks = self.mapped('task_id') + result = super().unlink() + tasks._sync_involved_assignees_from_timelines() + return result # @api.constrains('estimated_start_datetime', 'estimated_end_datetime') # def _check_dates(self): # for rec in self: @@ -1545,15 +1664,20 @@ class projectTaskTimelines(models.Model): task.actual_time = sum( task.task_id.timesheet_ids.filtered(lambda t: t.stage_id == stage).mapped('unit_amount')) - @api.depends('team_id', 'project_id') + @api.depends('team_id', 'project_id', 'task_id.user_ids', 'task_id.involved_user_ids', 'responsible_lead', 'assigned_to') def _compute_team_members(self): for rec in self: members = self.env['res.users'] + task_users = rec.task_id.user_ids | rec.task_id.involved_user_ids if rec.team_id: valid_members = rec.team_id.all_members_ids.filtered(lambda u: u.exists()) lead = rec.team_id.team_lead if rec.team_id.team_lead.exists() else False rec.team_all_member_ids = list(set(valid_members.ids + ([lead.id] if lead else []))) + elif task_users: + extra_users = rec.responsible_lead | rec.assigned_to + rec.team_all_member_ids = (task_users | extra_users).filtered(lambda u: u.exists()).ids + elif rec.project_id and rec.project_id.privacy_visibility == 'followers': project_members = rec.project_id.members_ids.filtered(lambda u: u.exists()) partners = rec.project_id.message_partner_ids.mapped('user_ids').filtered(lambda u: u.exists()) @@ -1592,4 +1716,3 @@ class projectTaskTimelines(models.Model): allowed_teams |= team allowed_teams |= team.child_ids rec.allowed_team_ids = allowed_teams - diff --git a/addons_extensions/project_task_timesheet_extended/security/security.xml b/addons_extensions/project_task_timesheet_extended/security/security.xml index 6d8077944..86cfd666f 100644 --- a/addons_extensions/project_task_timesheet_extended/security/security.xml +++ b/addons_extensions/project_task_timesheet_extended/security/security.xml @@ -1,161 +1,308 @@ - - - - Manager - - - - - - Project Lead - - - - - - - - - - - company: Own Company - - [('company_id', 'in', company_ids + [False])] - - - - Manager: Own Projects - - - [('user_id', '=', user.id)] - - - - - - - - Project/Task: project supervisor: see all tasks linked to his assigned project or its own tasks - - [ - ('project_id.user_id','=',user.id), - '|', ('project_id', '!=', False), - ('user_ids', 'in', user.id), - ] - - - - - Project/Task: project users: don't see non generic tasks - - [ - '&', '&', - ('project_id', '!=', False), - ('is_generic', '=', False), - ('user_ids', 'not in', user.id), - ] - - - - - - - Project/Task: project lead: see all tasks - - [ - '&', '&', '&', - ('project_id', '!=', False), - ('project_id.project_lead', '=', user.id), - '|', ('is_generic', '=', True), ('is_generic', '=', False), - '|', ('user_ids', 'in', user.id), ('user_ids', 'not in', user.id) - ] - - - - - - - - - - Task Availability: project lead: see all user tasks - - - [ - '|', '|', - ('project_id.project_lead', '=', user.id), - ('user_id', '=', user.id), - ('project_id.user_id', '=', user.id), - ] - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + Manager + + + + + + Project Lead + + + + + + + + + + + + + + + + + + ['|', + ('privacy_visibility', '!=', 'followers'), + ('message_ids', 'in', [user.id]) + ] + + + + + + + + + + + company: Own Company + + [('company_id', 'in', company_ids + [False])] + + + + Manager: Own Projects + + + [('user_id', '=', user.id)] + + + + + + + + Project/Task: project supervisor: see all tasks linked to his assigned project or its own tasks + + [ + ('project_id.user_id','=',user.id), + '|', ('project_id', '!=', False), + ('user_ids', 'in', user.id), + ] + + + + + Project/Task: project users: don't see non generic tasks + + [ + '&', '&', '&', + ('project_id', '!=', False), + ('is_generic', '=', False), + ('user_ids', 'not in', user.id), + ('involved_user_ids', 'not in', user.id), + ] + + + + + + + Project/Task: project lead: see all tasks + + [ + '&', '&', '&', + ('project_id', '!=', False), + ('project_id.project_lead', '=', user.id), + '|', ('is_generic', '=', True), ('is_generic', '=', False), + '|', ('user_ids', 'in', user.id), ('user_ids', 'not in', user.id) + ] + + + + + + + + + + + [ + '&', + ('project_id', '!=', False), + ('project_id.privacy_visibility', '!=', 'followers'), + '|', + '&', + ('is_generic', '=', True), + '|', '|', + ('project_id', '!=', False), + ('parent_id', '!=', False), + ('user_ids', 'in', user.id), + '&', + ('is_generic', '=', False), + '|', + ('user_ids', 'in', user.id), + ('involved_user_ids', 'in', user.id), + ] + + + + [ + '|', + '&', + ('project_id', '!=', False), + '|', '|', + ('is_generic', '=', True), + ('user_ids', 'in', user.id), + ('involved_user_ids', 'in', user.id), + '&', + ('project_id', '=', False), + '|', + ('message_partner_ids', 'in', [user.partner_id.id]), + ('user_ids', 'in', user.id), + ] + + + [ + '|', + '&', + ('project_id', '!=', False), + '|', '|', + ('is_generic', '=', True), + ('user_ids', 'in', user.id), + ('involved_user_ids', 'in', user.id), + '&', + ('project_id', '=', False), + '|', + ('message_partner_ids', 'in', [user.partner_id.id]), + ('user_ids', 'in', user.id), + ] + + + [ + '&', '&', + ('user_id', '=', user.id), + ('project_id', '!=', False), + '|', '|', + ('project_id.privacy_visibility', '!=', 'followers'), + ('message_partner_ids', 'in', [user.partner_id.id]), + '&', + ('task_id', '!=', False), + '|', + ('task_id.user_ids', 'in', user.id), + ('task_id.involved_user_ids', 'in', user.id) + ] + + + [ + '&', '&', + ('project_id', '!=', False), + ('task_id', '!=', False), + '|', + '&', + ('project_id.privacy_visibility', '=', 'followers'), + '|', + '|', + ('project_id.project_lead', '=', user.id), + ('project_id.user_id', '=', user.id), + '|', + '&', + ('task_id.is_generic', '=', False), + '|', + ('user_id', 'in', 'task_id.user_ids'), + ('user_id', 'in', 'task_id.involved_user_ids'), + '&', + ('task_id.is_generic', '=', True), + ('user_id.partner_id', 'in', 'project_id.message_partner_ids'), + '&', '&', + ('project_id.privacy_visibility', '!=', 'followers'), + ('task_id.is_generic', '=', False), + '|', + ('user_id', 'in', 'task_id.user_ids'), + ('user_id', 'in', 'task_id.involved_user_ids') + ] + + + Project/Task: involved assignees see involved tasks + + [('involved_user_ids', 'in', user.id)] + + + + + + + + + Timesheets: involved assignees can manage own lines + + [ + '&', '&', '&', + ('user_id', '=', user.id), + ('project_id', '!=', False), + ('task_id', '!=', False), + ('task_id.involved_user_ids', 'in', user.id) + ] + + + + + + + + Task Availability: project lead: see all user tasks + + + [ + '|', '|', + ('project_id.project_lead', '=', user.id), + ('user_id', '=', user.id), + ('project_id.user_id', '=', user.id), + ] + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons_extensions/project_task_timesheet_extended/view/project_task.xml b/addons_extensions/project_task_timesheet_extended/view/project_task.xml index e2ae059d8..9b3e42cad 100644 --- a/addons_extensions/project_task_timesheet_extended/view/project_task.xml +++ b/addons_extensions/project_task_timesheet_extended/view/project_task.xml @@ -25,7 +25,9 @@
- [('id', 'in', assignee_domain_ids)] + involved_assignee_avatar_user + [('id', 'in', involved_user_ids)] + {'no_create': True, 'no_quick_create': True, 'no_create_edit': True} @@ -58,6 +60,7 @@ + @@ -69,8 +72,8 @@ -