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
+
+
+
+
+
+
+
+
+
+
+ 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 @@
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+ TAX REGIME COMPARISON
+
+
+
+
+ | Employee: |
+ Emp Code: |
+
+
+ | Financial Year: |
+ Selected Regime: |
+
+
+
+
+
+
+ | Particulars |
+ Old Regime |
+ New Regime |
+
+
+
+
+ | Taxable Income |
+ |
+ |
+
+
+ | Tax Payable |
+ |
+ |
+
+
+ | Tax Difference |
+ |
+
+
+ | Beneficial Regime |
+
+
+ |
+
+
+
+
+
+ Report Time:
+
+
+
+
+
@@ -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 @@
-
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -134,12 +160,12 @@
-
+
-
+
@@ -149,7 +175,17 @@
-
+
+
+
+
+
+
+
+
+
+
+
@@ -160,7 +196,29 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -172,8 +230,18 @@
-
-
+
+
+
+
+
+
+
+
+
+
+
+
@@ -184,8 +252,18 @@
-
-
+
+
+
+
+
+
+
+
+
+
+
+
@@ -197,7 +275,7 @@
-
+
@@ -220,8 +298,18 @@
-
-
+
+
+
+
+
+
+
+
+
+
+
+
@@ -232,7 +320,7 @@
-
+
+
@@ -319,12 +407,21 @@
-
-
-
+
+
-
+
+
+
+
+
+
+
+
+
+
+
@@ -352,4 +449,4 @@
action="action_emp_it_declaration" sequence="99"/>
-
\ No newline at end of file
+
diff --git a/addons_extensions/employee_it_declaration/views/investment_types.xml b/addons_extensions/employee_it_declaration/views/investment_types.xml
index badf1c7b5..c37bd94d0 100644
--- a/addons_extensions/employee_it_declaration/views/investment_types.xml
+++ b/addons_extensions/employee_it_declaration/views/investment_types.xml
@@ -19,12 +19,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -95,7 +122,19 @@
employee_it_declaration.generate_income_tax_statement_rpt
employee_it_declaration.generate_income_tax_statement_rpt
- 'INCOMETAX - %s' % (object.display_name)
+ '%s - %s' % (object.employee_id.name or '', object.period_line.name or '')
+
+ report
+
+
+
+ Download Tax Regime Comparison
+ it.tax.statement.wizard
+ qweb-pdf
+ employee_it_declaration.generate_income_tax_comparison_rpt
+ employee_it_declaration.generate_income_tax_comparison_rpt
+
+ '%s - Tax Regime Comparison' % (object.employee_id.name or '')
report
diff --git a/addons_extensions/employee_it_declaration/views/slab_master.xml b/addons_extensions/employee_it_declaration/views/slab_master.xml
index 72f270f89..997b538b9 100644
--- a/addons_extensions/employee_it_declaration/views/slab_master.xml
+++ b/addons_extensions/employee_it_declaration/views/slab_master.xml
@@ -24,6 +24,7 @@
+
@@ -42,6 +43,7 @@
+
@@ -52,16 +54,27 @@
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/addons_extensions/flutter_odoo/controllers/__init__.py b/addons_extensions/flutter_odoo/controllers/__init__.py
index e69de29bb..deec4a8b8 100644
--- a/addons_extensions/flutter_odoo/controllers/__init__.py
+++ b/addons_extensions/flutter_odoo/controllers/__init__.py
@@ -0,0 +1 @@
+from . import main
\ No newline at end of file
diff --git a/addons_extensions/flutter_odoo/controllers/controllers.py b/addons_extensions/flutter_odoo/controllers/controllers.py
new file mode 100644
index 000000000..71a611bf6
--- /dev/null
+++ b/addons_extensions/flutter_odoo/controllers/controllers.py
@@ -0,0 +1,9 @@
+# from odoo import http
+# from odoo.http import request
+#
+# class MyAPI(http.Controller):
+#
+# @http.route('/api/products', type='json', auth='user', methods=['GET'])
+# def get_products(self):
+# products = request.env['product.product'].search([])
+# return [{"id": p.id, "name": p.name} for p in products]
\ No newline at end of file
diff --git a/addons_extensions/flutter_odoo/controllers/main.py b/addons_extensions/flutter_odoo/controllers/main.py
new file mode 100644
index 000000000..063061b6b
--- /dev/null
+++ b/addons_extensions/flutter_odoo/controllers/main.py
@@ -0,0 +1,56 @@
+from odoo import http
+from odoo.http import request
+
+
+class MobileArchiveController(http.Controller):
+
+ @http.route( '/mobile/archive_record',type='json',auth='user',methods=['POST'],csrf=False)
+ def archive_record(self, model=None, res_ids=None, archive=True):
+
+ if not model or not res_ids:
+ return {
+ 'status': False,
+ 'message': 'model and res_ids are required'
+ }
+
+ try:
+
+ # Ensure list
+ if not isinstance(res_ids, list):
+ return {
+ 'status': False,
+ 'message': 'res_ids must be a list'
+ }
+
+ records = request.env[model].sudo().browse(res_ids)
+
+ if not records.exists():
+ return {
+ 'status': False,
+ 'message': 'Records not found'
+ }
+
+ # Check active field exists
+ if 'active' not in records._fields:
+ return {
+ 'status': False,
+ 'message': 'Archive not supported for this model'
+ }
+
+ # Archive / Unarchive
+ records.write({
+ 'active': not archive
+ })
+
+ return {
+ 'status': True,
+ 'message': 'Records archived successfully'
+ if archive else
+ 'Records unarchived successfully'
+ }
+
+ except Exception as e:
+ return {
+ 'status': False,
+ 'message': str(e)
+ }
\ No newline at end of file
diff --git a/addons_extensions/hr_payroll/data/hr_payroll_data.xml b/addons_extensions/hr_payroll/data/hr_payroll_data.xml
index a13162b44..0c8eec0c4 100644
--- a/addons_extensions/hr_payroll/data/hr_payroll_data.xml
+++ b/addons_extensions/hr_payroll/data/hr_payroll_data.xml
@@ -100,7 +100,7 @@ result_name = inputs['ATTACH_SALARY'].name
-
+
Assignment of Salary
174
ASSIG_SALARY
@@ -108,7 +108,7 @@ result_name = inputs['ATTACH_SALARY'].name
result = 'ASSIG_SALARY' in inputs
code
-result = -inputs['ASSIG_SALARY'].amount
+result = inputs['ASSIG_SALARY'].amount
result_name = inputs['ASSIG_SALARY'].name
diff --git a/addons_extensions/hr_payroll_extended/__init__.py b/addons_extensions/hr_payroll_extended/__init__.py
new file mode 100644
index 000000000..9a7e03ede
--- /dev/null
+++ b/addons_extensions/hr_payroll_extended/__init__.py
@@ -0,0 +1 @@
+from . import models
\ No newline at end of file
diff --git a/addons_extensions/hr_payroll_extended/__manifest__.py b/addons_extensions/hr_payroll_extended/__manifest__.py
new file mode 100644
index 000000000..4babe4137
--- /dev/null
+++ b/addons_extensions/hr_payroll_extended/__manifest__.py
@@ -0,0 +1,18 @@
+{
+ 'name': 'Payroll Extended',
+ 'category': 'Human Resources/Payroll',
+ 'summary': 'Manage your employee payroll records and Extending the Payroll featuers',
+ 'installable': True,
+ 'application': True,
+ 'depends': [
+ 'hr_payroll'
+ ],
+ 'data': [
+ 'data/hr_salary_advance_sequence.xml',
+ 'security/ir.model.access.csv',
+ 'views/hr_salary_advance_views.xml',
+ 'views/menus.xml'
+ ],
+ 'assets': {
+ },
+}
diff --git a/addons_extensions/hr_payroll_extended/data/hr_salary_advance_sequence.xml b/addons_extensions/hr_payroll_extended/data/hr_salary_advance_sequence.xml
new file mode 100644
index 000000000..280763354
--- /dev/null
+++ b/addons_extensions/hr_payroll_extended/data/hr_salary_advance_sequence.xml
@@ -0,0 +1,10 @@
+
+
+
+ Salary Advance
+ hr.salary.advance
+ SA/%(year)s/
+ 4
+
+
+
diff --git a/addons_extensions/hr_payroll_extended/models/__init__.py b/addons_extensions/hr_payroll_extended/models/__init__.py
new file mode 100644
index 000000000..1f3710b17
--- /dev/null
+++ b/addons_extensions/hr_payroll_extended/models/__init__.py
@@ -0,0 +1 @@
+from . import hr_salary_advance
diff --git a/addons_extensions/hr_payroll_extended/models/hr_salary_advance.py b/addons_extensions/hr_payroll_extended/models/hr_salary_advance.py
new file mode 100644
index 000000000..e85dcc947
--- /dev/null
+++ b/addons_extensions/hr_payroll_extended/models/hr_salary_advance.py
@@ -0,0 +1,261 @@
+# -*- coding: utf-8 -*-
+
+from dateutil.relativedelta import relativedelta
+
+from odoo import api, fields, models, _
+from odoo.exceptions import UserError, ValidationError
+from odoo.tools.date_utils import start_of
+
+
+class HrSalaryAdvance(models.Model):
+ _name = 'hr.salary.advance'
+ _description = 'Salary Advance'
+ _inherit = ['mail.thread']
+ _order = 'date_advance desc, id desc'
+
+ name = fields.Char(default='New', copy=False, readonly=True)
+ employee_id = fields.Many2one(
+ 'hr.employee',
+ string='Employee',
+ required=True,
+ tracking=True,
+ domain=lambda self: [('company_id', 'in', self.env.companies.ids)],
+ )
+ company_id = fields.Many2one(
+ 'res.company',
+ string='Company',
+ required=True,
+ default=lambda self: self.env.company,
+ )
+ currency_id = fields.Many2one('res.currency', related='company_id.currency_id')
+ description = fields.Char(required=True, default='Salary Advance', tracking=True)
+ date_advance = fields.Date(
+ string='Advance Payslip Date',
+ required=True,
+ default=lambda self: start_of(fields.Date.today(), 'month'),
+ tracking=True,
+ )
+ deduction_start_date = fields.Date(
+ string='Deduction Start Date',
+ required=True,
+ default=lambda self: start_of(fields.Date.today() + relativedelta(months=1), 'month'),
+ tracking=True,
+ )
+ advance_amount = fields.Monetary(required=True, tracking=True)
+ deduction_period_months = fields.Integer(string='Deduction Period', required=True, default=1, tracking=True)
+ monthly_deduction_amount = fields.Monetary(string='Monthly Deduction', required=True, tracking=True)
+ date_estimated_end = fields.Date(string='Estimated Deduction End', compute='_compute_date_estimated_end')
+ assignment_attachment_id = fields.Many2one(
+ 'hr.salary.attachment',
+ string='Advance Salary Attachment',
+ copy=False,
+ readonly=True,
+ ondelete='restrict',
+ )
+ deduction_attachment_id = fields.Many2one(
+ 'hr.salary.attachment',
+ string='Deduction Salary Attachment',
+ copy=False,
+ readonly=True,
+ ondelete='restrict',
+ )
+ attachment_count = fields.Integer(compute='_compute_attachment_count')
+ state = fields.Selection(
+ [
+ ('open', 'Running'),
+ ('close', 'Completed'),
+ ('cancel', 'Cancelled'),
+ ],
+ string='Status',
+ compute='_compute_state',
+ store=True,
+ readonly=False,
+ default='open',
+ required=True,
+ copy=False,
+ tracking=True,
+ )
+
+ @api.depends('assignment_attachment_id.state', 'deduction_attachment_id.state')
+ def _compute_state(self):
+ for advance in self:
+ attachments = (advance.assignment_attachment_id | advance.deduction_attachment_id).exists()
+ states = set(attachments.mapped('state'))
+ if attachments and states == {'cancel'}:
+ advance.state = 'cancel'
+ elif attachments and states == {'close'}:
+ advance.state = 'close'
+ elif 'cancel' in states and 'open' not in states:
+ advance.state = 'cancel'
+ else:
+ advance.state = 'open'
+
+ @api.depends('deduction_start_date', 'deduction_period_months')
+ def _compute_date_estimated_end(self):
+ for advance in self:
+ if advance.deduction_start_date and advance.deduction_period_months:
+ advance.date_estimated_end = start_of(
+ advance.deduction_start_date + relativedelta(months=advance.deduction_period_months - 1),
+ 'month',
+ )
+ else:
+ advance.date_estimated_end = False
+
+ @api.depends('assignment_attachment_id', 'deduction_attachment_id')
+ def _compute_attachment_count(self):
+ for advance in self:
+ advance.attachment_count = len(
+ (advance.assignment_attachment_id | advance.deduction_attachment_id).exists()
+ )
+
+ @api.onchange('advance_amount', 'deduction_period_months')
+ def _onchange_monthly_deduction_amount(self):
+ for advance in self:
+ if advance.advance_amount and advance.deduction_period_months:
+ advance.monthly_deduction_amount = advance.advance_amount / advance.deduction_period_months
+
+ @api.constrains('advance_amount', 'monthly_deduction_amount', 'deduction_period_months')
+ def _check_amounts(self):
+ for advance in self:
+ if advance.advance_amount <= 0:
+ raise ValidationError(_('Advance amount must be strictly positive.'))
+ if advance.monthly_deduction_amount <= 0:
+ raise ValidationError(_('Monthly deduction must be strictly positive.'))
+ if advance.deduction_period_months <= 0:
+ raise ValidationError(_('Deduction period must be at least one month.'))
+ if advance.monthly_deduction_amount * advance.deduction_period_months < advance.advance_amount:
+ raise ValidationError(_('Monthly deduction does not cover the advance amount in the selected period.'))
+
+ @api.constrains('date_advance', 'deduction_start_date')
+ def _check_dates(self):
+ for advance in self:
+ if advance.deduction_start_date < advance.date_advance:
+ raise ValidationError(_('Deduction start date cannot be before the advance payslip date.'))
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ advances = super().create(vals_list)
+ for advance in advances:
+ if advance.name == 'New':
+ advance.name = self.env['ir.sequence'].next_by_code('hr.salary.advance') or _('Salary Advance')
+ advance._create_salary_attachments()
+ return advances
+
+ def write(self, vals):
+ sync_fields = {
+ 'employee_id', 'company_id', 'description', 'date_advance', 'deduction_start_date',
+ 'advance_amount', 'monthly_deduction_amount',
+ }
+ if sync_fields.intersection(vals):
+ for advance in self:
+ advance._check_editable_attachments()
+ result = super().write(vals)
+ if sync_fields.intersection(vals):
+ for advance in self.filtered(lambda rec: rec.state == 'open'):
+ advance._sync_salary_attachments()
+ return result
+
+ def _get_assignment_input_type(self):
+ return self.env.ref('hr_payroll.input_assignment_salary')
+
+ def _get_deduction_input_type(self):
+ return self.env.ref('hr_payroll.input_attachment_salary')
+
+ def _check_editable_attachments(self):
+ self.ensure_one()
+ attachments = self.assignment_attachment_id | self.deduction_attachment_id
+ if any(attachments.mapped('has_done_payslip')):
+ raise UserError(_('You cannot change this salary advance because one of its salary attachments already has a done payslip.'))
+
+ def _create_salary_attachments(self):
+ for advance in self:
+ if advance.assignment_attachment_id or advance.deduction_attachment_id:
+ continue
+ assignment = self.env['hr.salary.attachment'].create(advance._prepare_assignment_attachment_vals())
+ deduction = self.env['hr.salary.attachment'].create(advance._prepare_deduction_attachment_vals())
+ advance.write({
+ 'assignment_attachment_id': assignment.id,
+ 'deduction_attachment_id': deduction.id,
+ })
+
+ def _sync_salary_attachments(self):
+ for advance in self:
+ if not advance.assignment_attachment_id or not advance.deduction_attachment_id:
+ advance._create_salary_attachments()
+ continue
+ advance.assignment_attachment_id.write(advance._prepare_assignment_attachment_vals())
+ advance.deduction_attachment_id.write(advance._prepare_deduction_attachment_vals())
+
+ def _prepare_common_attachment_vals(self, input_type):
+ self.ensure_one()
+ return {
+ 'employee_ids': [(6, 0, self.employee_id.ids)],
+ 'company_id': self.company_id.id,
+ 'description': self.description,
+ 'other_input_type_id': input_type.id,
+ }
+
+ def _prepare_assignment_attachment_vals(self):
+ vals = self._prepare_common_attachment_vals(self._get_assignment_input_type())
+ vals.update({
+ 'monthly_amount': self.advance_amount,
+ 'total_amount': self.advance_amount,
+ 'paid_amount': 0.0,
+ 'date_start': self.date_advance,
+ })
+ return vals
+
+ def _prepare_deduction_attachment_vals(self):
+ vals = self._prepare_common_attachment_vals(self._get_deduction_input_type())
+ vals.update({
+ 'monthly_amount': self.monthly_deduction_amount,
+ 'total_amount': self.advance_amount,
+ 'paid_amount': 0.0,
+ 'date_start': self.deduction_start_date,
+ })
+ return vals
+
+ def action_open_attachments(self):
+ self.ensure_one()
+ attachments = (self.assignment_attachment_id | self.deduction_attachment_id).exists()
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': _('Salary Attachments'),
+ 'res_model': 'hr.salary.attachment',
+ 'view_mode': 'list,form',
+ 'domain': [('id', 'in', attachments.ids)],
+ }
+
+ def action_cancel(self):
+ for advance in self:
+ attachments = (advance.assignment_attachment_id | advance.deduction_attachment_id).filtered(
+ lambda attachment: attachment.state == 'open'
+ )
+ for attachment in attachments:
+ attachment.action_cancel()
+ advance.state = 'cancel'
+
+ def action_open(self):
+ for advance in self:
+ attachments = (advance.assignment_attachment_id | advance.deduction_attachment_id).filtered(
+ lambda attachment: attachment.state == 'cancel'
+ )
+ for attachment in attachments:
+ attachment.action_open()
+ advance.state = 'open'
+
+ def action_done(self):
+ for advance in self:
+ attachments = (advance.assignment_attachment_id | advance.deduction_attachment_id).filtered(
+ lambda attachment: attachment.state == 'open'
+ )
+ for attachment in attachments:
+ attachment.action_done()
+ advance.state = 'close'
+
+ def unlink(self):
+ for advance in self:
+ attachments = advance.assignment_attachment_id | advance.deduction_attachment_id
+ if any(attachment.state == 'open' for attachment in attachments):
+ raise UserError(_('Cancel the salary advance before deleting it.'))
+ return super().unlink()
diff --git a/addons_extensions/hr_payroll_extended/security/ir.model.access.csv b/addons_extensions/hr_payroll_extended/security/ir.model.access.csv
new file mode 100644
index 000000000..25d262472
--- /dev/null
+++ b/addons_extensions/hr_payroll_extended/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_salary_advance_user,hr.salary.advance.user,model_hr_salary_advance,hr_payroll.group_hr_payroll_user,1,1,1,0
+access_hr_salary_advance_manager,hr.salary.advance.manager,model_hr_salary_advance,hr_payroll.group_hr_payroll_manager,1,1,1,1
diff --git a/addons_extensions/hr_payroll_extended/views/hr_salary_advance_views.xml b/addons_extensions/hr_payroll_extended/views/hr_salary_advance_views.xml
new file mode 100644
index 000000000..499002f69
--- /dev/null
+++ b/addons_extensions/hr_payroll_extended/views/hr_salary_advance_views.xml
@@ -0,0 +1,90 @@
+
+
+
+ hr.salary.advance.list
+ hr.salary.advance
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ hr.salary.advance.form
+ hr.salary.advance
+
+
+
+
+
+
+ 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
+
+
+
+
+
+
+
+
diff --git a/addons_extensions/hr_recruitment_auto_doc/views/hr_candidate_views.xml b/addons_extensions/hr_recruitment_auto_doc/views/hr_candidate_views.xml
new file mode 100644
index 000000000..d39010ca4
--- /dev/null
+++ b/addons_extensions/hr_recruitment_auto_doc/views/hr_candidate_views.xml
@@ -0,0 +1,39 @@
+
+
+
+ hr.candidate.view.tree.auto.doc.inherit
+ hr.candidate
+
+
+
+ hr_recruitment_auto_doc_list
+
+
+
+
+
+ hr.candidate.view.kanban.auto.doc.inherit
+ hr.candidate
+
+
+
+ hr_recruitment_auto_doc_kanban
+
+
+
+
+
+ hr.candidate.view.form.auto.doc.inherit
+ hr.candidate
+
+
+
+
+
+
+
+
diff --git a/addons_extensions/hr_recruitment_auto_doc/views/hr_job_recruitment_views.xml b/addons_extensions/hr_recruitment_auto_doc/views/hr_job_recruitment_views.xml
new file mode 100644
index 000000000..653908316
--- /dev/null
+++ b/addons_extensions/hr_recruitment_auto_doc/views/hr_job_recruitment_views.xml
@@ -0,0 +1,53 @@
+
+
+
+ hr.job.recruitment.tree.auto.doc.inherit
+ hr.job.recruitment
+
+
+
+ hr_recruitment_auto_doc_list
+
+
+
+
+
+ hr.job.recruitment.kanban.auto.doc.inherit
+ hr.job.recruitment
+
+
+
+ hr_recruitment_auto_doc_kanban
+
+
+
+
+
+ hr.job.recruitment.form.auto.doc.inherit
+ hr.job.recruitment
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/addons_extensions/hr_recruitment_auto_doc/views/hr_recruitment_actions.xml b/addons_extensions/hr_recruitment_auto_doc/views/hr_recruitment_actions.xml
new file mode 100644
index 000000000..3324fc6a5
--- /dev/null
+++ b/addons_extensions/hr_recruitment_auto_doc/views/hr_recruitment_actions.xml
@@ -0,0 +1,35 @@
+
+
+
+ Parse Resumes
+ hr.recruitment.auto.doc.wizard
+ form
+ new
+
+
+ action
+ list,form,kanban
+
+
+
+ Parse Resumes
+ hr.recruitment.auto.doc.wizard
+ form
+ new
+
+
+ action
+ list,form,kanban
+
+
+
+ Parse Job Description
+ hr.recruitment.auto.doc.wizard
+ form
+ new
+
+
+ action
+ list,form,kanban
+
+
diff --git a/addons_extensions/hr_recruitment_auto_doc/wizard/__init__.py b/addons_extensions/hr_recruitment_auto_doc/wizard/__init__.py
new file mode 100644
index 000000000..3b4abdb20
--- /dev/null
+++ b/addons_extensions/hr_recruitment_auto_doc/wizard/__init__.py
@@ -0,0 +1 @@
+from . import hr_recruitment_auto_doc_wizard
diff --git a/addons_extensions/hr_recruitment_auto_doc/wizard/hr_recruitment_auto_doc_wizard.py b/addons_extensions/hr_recruitment_auto_doc/wizard/hr_recruitment_auto_doc_wizard.py
new file mode 100644
index 000000000..90612ed21
--- /dev/null
+++ b/addons_extensions/hr_recruitment_auto_doc/wizard/hr_recruitment_auto_doc_wizard.py
@@ -0,0 +1,1412 @@
+import json
+import mimetypes
+import re
+from datetime import datetime
+from difflib import SequenceMatcher
+
+from markupsafe import escape
+
+from odoo import _, api, fields, models
+from odoo.exceptions import UserError, ValidationError
+from odoo.tools import email_normalize
+
+
+class HrRecruitmentAutoDocWizard(models.TransientModel):
+ _name = "hr.recruitment.auto.doc.wizard"
+ _description = "HR Recruitment Auto Document Wizard"
+
+ target_model = fields.Selection(
+ selection=[
+ ("applicant", "Applicants"),
+ ("candidate", "Candidates"),
+ ("job_recruitment", "Job Requests"),
+ ],
+ required=True,
+ default="candidate",
+ readonly=True,
+ )
+ job_recruitment_id = fields.Many2one("hr.job.recruitment", string="Job Request")
+ create_missing_skills = fields.Boolean(default=True)
+ update_existing_candidates = fields.Boolean(default=True)
+ attachment_ids = fields.Many2many(
+ "ir.attachment",
+ "hr_recruitment_auto_doc_wizard_ir_attachment_rel",
+ "wizard_id",
+ "attachment_id",
+ string="Uploaded Documents",
+ )
+ line_ids = fields.One2many(
+ "hr.recruitment.auto.doc.wizard.line",
+ "wizard_id",
+ string="Documents",
+ )
+ result_html = fields.Html(readonly=True)
+ processed_count = fields.Integer(readonly=True)
+ created_count = fields.Integer(readonly=True)
+ updated_count = fields.Integer(readonly=True)
+ skipped_count = fields.Integer(readonly=True)
+
+ @api.model
+ def default_get(self, fields_list):
+ res = super().default_get(fields_list)
+ active_model = self.env.context.get("active_model")
+ active_ids = self.env.context.get("active_ids") or []
+ default_job_recruitment_id = self.env.context.get("default_job_recruitment_id")
+
+ if active_model == "hr.applicant":
+ res["target_model"] = "applicant"
+ applicants = self.env["hr.applicant"].browse(active_ids).exists()
+ job_requests = applicants.mapped("hr_job_recruitment")
+ if default_job_recruitment_id:
+ res["job_recruitment_id"] = default_job_recruitment_id
+ elif len(job_requests) == 1:
+ res["job_recruitment_id"] = job_requests.id
+ elif active_model == "hr.candidate":
+ res["target_model"] = "candidate"
+ elif active_model == "hr.job.recruitment":
+ res["target_model"] = "job_recruitment"
+ if default_job_recruitment_id:
+ res["job_recruitment_id"] = default_job_recruitment_id
+ elif len(active_ids) == 1:
+ res["job_recruitment_id"] = active_ids[0]
+ return res
+
+ def action_parse_documents(self):
+ self.ensure_one()
+ self._sync_upload_lines()
+ if not self.line_ids:
+ raise UserError(_("Please add at least one document to parse."))
+ if self.target_model == "applicant" and not self.job_recruitment_id:
+ raise ValidationError(_("Please select the Job Request before continuing."))
+
+ parser_service = self.env["document.parser.service"]
+ summary_rows = []
+ processed = created = updated = skipped = 0
+
+ for line in self.line_ids:
+ if not line.file or not line.file_name:
+ line.write({
+ "state": "error",
+ "message": _("Missing file or filename."),
+ })
+ skipped += 1
+ continue
+
+ required_fields = self._get_jd_required_fields() if self.target_model == "job_recruitment" else self._get_resume_required_fields()
+ prompt = self._get_jd_prompt() if self.target_model == "job_recruitment" else self._get_resume_prompt()
+ parsed_payload = parser_service.parse_document(
+ file_content=line.file,
+ filename=line.file_name,
+ required_fields=required_fields,
+ extra_instructions=prompt,
+ )
+ if parsed_payload.get("error") and not parsed_payload.get("result"):
+ line.write({
+ "state": "error",
+ "message": parsed_payload["error"],
+ "candidate_id": False,
+ "applicant_id": False,
+ })
+ skipped += 1
+ summary_rows.append(self._build_summary_row(line, parsed_payload["error"], "danger"))
+ continue
+ parsed_data = parsed_payload["result"]
+ if self.target_model == "job_recruitment":
+ parsed_data = self._post_process_jd_data(parsed_data, parsed_payload["text"])
+ else:
+ parsed_data = self._post_process_resume_data(parsed_data, parsed_payload["text"], line.file_name)
+ line.extracted_payload = json.dumps(parsed_data, indent=2, ensure_ascii=False)
+
+ try:
+ with self.env.cr.savepoint():
+ if self.target_model == "job_recruitment":
+ job_request, job_state = self._apply_jd_parse(parsed_data, parsed_payload)
+ self._attach_jd_document_to_job_request(job_request, line)
+ action_label = _("Created") if job_state == "created" else _("Updated")
+ job_message = _("%(action)s job request %(name)s from parsed JD.") % {
+ "action": action_label,
+ "name": job_request.display_name,
+ }
+ processed += 1
+ if job_state == "created":
+ created += 1
+ else:
+ updated += 1
+ line.write({
+ "state": "done",
+ "message": job_message,
+ })
+ summary_rows.append(self._build_summary_row(line, job_message, "success"))
+ continue
+
+ candidate, candidate_state, candidate_message = self._find_or_create_candidate(line, parsed_data, parsed_payload)
+
+ linked_record_message = candidate_message
+
+ if self.target_model == "candidate":
+ line.candidate_id = candidate.id
+ line.applicant_id = False
+ updated += 1 if candidate_state == "updated" else 0
+ created += 1 if candidate_state == "created" else 0
+ else:
+ applicant, applicant_state, applicant_message = self._find_or_create_applicant(candidate, line, parsed_data)
+ line.candidate_id = candidate.id
+ line.applicant_id = applicant.id
+ linked_record_message = f"{candidate_message} {applicant_message}".strip()
+ if applicant_state == "created":
+ created += 1
+ elif candidate_state == "updated":
+ updated += 1
+
+ processed += 1
+ line.write({
+ "state": "done",
+ "message": linked_record_message,
+ })
+ summary_rows.append(self._build_summary_row(line, linked_record_message, "success"))
+ except Exception as exc:
+ line.write({
+ "state": "error",
+ "message": str(exc),
+ "candidate_id": False,
+ "applicant_id": False,
+ })
+ skipped += 1
+ summary_rows.append(self._build_summary_row(line, str(exc), "danger"))
+
+ self.write({
+ "processed_count": processed,
+ "created_count": created,
+ "updated_count": updated,
+ "skipped_count": skipped,
+ "result_html": self._build_summary_html(summary_rows),
+ })
+
+ return {
+ "type": "ir.actions.act_window",
+ "res_model": self._name,
+ "res_id": self.id,
+ "view_mode": "form",
+ "target": "new",
+ }
+
+ def _sync_upload_lines(self):
+ self.ensure_one()
+ existing_by_attachment = {
+ line.attachment_id.id: line
+ for line in self.line_ids
+ if line.attachment_id
+ }
+ current_attachment_ids = set(self.attachment_ids.ids)
+
+ stale_lines = self.line_ids.filtered(
+ lambda line: line.attachment_id and line.attachment_id.id not in current_attachment_ids and line.state == "draft"
+ )
+ if stale_lines:
+ stale_lines.unlink()
+
+ for attachment in self.attachment_ids:
+ if attachment.id in existing_by_attachment:
+ continue
+ self.env["hr.recruitment.auto.doc.wizard.line"].create({
+ "wizard_id": self.id,
+ "attachment_id": attachment.id,
+ "file_name": attachment.name or _("Uploaded document"),
+ "file": attachment.datas,
+ })
+
+ def _get_resume_required_fields(self):
+ return {
+ "full_name": {"type": "string", "description": "Candidate full name"},
+ "first_name": {"type": "string", "description": "Candidate first name"},
+ "last_name": {"type": "string", "description": "Candidate last name"},
+ "email": {"type": "string", "description": "Primary email address"},
+ "phone": {"type": "string", "description": "Primary phone number"},
+ "alternate_phone": {"type": "string", "description": "Secondary phone number if available"},
+ "linkedin_profile": {"type": "string", "description": "LinkedIn profile URL"},
+ "current_location": {"type": "string", "description": "Current city or location"},
+ "current_organization": {"type": "string", "description": "Current employer or organization"},
+ "total_experience_years": {"type": "float", "description": "Total years of experience as a number"},
+ "relevant_experience_years": {"type": "float", "description": "Relevant years of experience as a number"},
+ "notice_period": {"type": "string", "description": "Notice period text"},
+ "degree": {"type": "string", "description": "Highest degree or main qualification"},
+ "skills": {"type": "list", "description": "All explicit technical and functional skills"},
+ "summary": {"type": "string", "description": "Short professional summary from the resume"},
+ }
+
+ def _get_jd_required_fields(self):
+ return {
+ "request_id": {"type": "string", "description": "Request or requisition identifier"},
+ "start_date": {"type": "string", "description": "Requested start date"},
+ "end_date": {"type": "string", "description": "Requested end date"},
+ "site_location": {"type": "string", "description": "Worksite or HR site location"},
+ "job_title": {"type": "string", "description": "Job title or role name"},
+ "job_summary": {"type": "string", "description": "Short summary of the job description"},
+ "requirements": {"type": "string", "description": "Candidate requirements and qualifications"},
+ "primary_skills": {"type": "list", "description": "Primary skills required for this job"},
+ "secondary_skills": {"type": "list", "description": "Secondary or nice to have skills"},
+ "budget": {"type": "string", "description": "Budget or salary range"},
+ "experience_years": {"type": "float", "description": "Expected total years of experience"},
+ }
+
+ def _get_resume_prompt(self):
+ return (
+ "Focus on resume extraction for recruitment. "
+ "Return precise contact details, experience, location, and skills. "
+ "Do not guess missing values. "
+ "Normalize skills into clean individual names. "
+ "For experience values, return numeric years when clearly inferable."
+ "Do not consider certifications, responsibilities, Non Technical Stuff as skills"
+ )
+
+ def _get_jd_prompt(self):
+ return (
+ "Focus on job description extraction for recruitment. "
+ "Extract the role title, clear summary, requirements, primary skills, secondary skills, budget, "
+ "and expected years of experience. "
+ "The job title must come from the Job Title field, not from job function text or summary text. "
+ "The summary must be a concise professional summary of the role, not the job title and not a single header label. "
+ "Skills must be individual clean items and must represent real technical tools, languages, platforms, frameworks, "
+ "architectures, protocols, debuggers, operating systems, or domain technologies explicitly mentioned in the JD. "
+ "Do not invent values. Do not extract generic communication or behavioral traits, abstract concepts, or plain responsibilities. "
+ "Primary skills are the core mandatory skills explicitly required to do the job. "
+ "Secondary skills are nice-to-have, supporting, optional, or less emphasized skills. "
+ "Do not return the same skill in both primary and secondary. "
+ "Do not consider certifications, responsibilities, non-technical traits, or header labels as skills."
+ )
+
+ def _find_or_create_candidate(self, line, parsed_data, parsed_payload):
+ candidate = self._find_existing_candidate(parsed_data)
+ candidate_vals = self._prepare_candidate_vals(line, parsed_data, parsed_payload)
+
+ if candidate:
+ if self.update_existing_candidates:
+ candidate.write(self._prepare_sparse_update_vals(candidate, candidate_vals))
+ self._sync_candidate_skills(candidate, parsed_data.get("skills") or [])
+ message = _("Matched existing candidate: %s") % candidate.display_name
+ return candidate, "updated", message
+
+ self._ensure_resume_creation_allowed(parsed_data, line.file_name)
+ candidate = self.env["hr.candidate"].create(candidate_vals)
+ self._sync_candidate_skills(candidate, parsed_data.get("skills") or [])
+ message = _("Created candidate: %s") % candidate.display_name
+ return candidate, "created", message
+
+ def _find_or_create_applicant(self, candidate, line, parsed_data):
+ existing_applicant = self.env["hr.applicant"].with_context(active_test=False).search([
+ ("candidate_id", "=", candidate.id),
+ ("hr_job_recruitment", "=", self.job_recruitment_id.id),
+ ], limit=1)
+ if existing_applicant:
+ return existing_applicant, "existing", _("Existing application reused for this job request.")
+
+ applicant_vals = {
+ "candidate_id": candidate.id,
+ "partner_name": candidate.partner_name,
+ "email_from": candidate.email_from,
+ "partner_phone": candidate.partner_phone,
+ "linkedin_profile": parsed_data.get("linkedin_profile"),
+ "current_location": parsed_data.get("current_location"),
+ "current_organization": parsed_data.get("current_organization"),
+ "total_exp": parsed_data.get("total_experience_years"),
+ "relevant_exp": parsed_data.get("relevant_experience_years"),
+ "notice_period": parsed_data.get("notice_period"),
+ "applicant_comments": parsed_data.get("summary"),
+ "hr_job_recruitment": self.job_recruitment_id.id,
+ }
+ if parsed_data.get("total_experience_years"):
+ applicant_vals["exp_type"] = "experienced"
+ applicant_vals["total_exp_type"] = "year"
+ applicant_vals = {key: value for key, value in applicant_vals.items() if value not in (False, None, "")}
+ applicant = self.env["hr.applicant"].create(applicant_vals)
+ return applicant, "created", _("Created application for job request %s.") % self.job_recruitment_id.display_name
+
+ def _find_existing_candidate(self, parsed_data):
+ search_model = self.env["hr.candidate"].with_context(active_test=False)
+ email_value = email_normalize(parsed_data.get("email") or "")
+ phone_values = [self._normalize_phone(parsed_data.get("phone")), self._normalize_phone(parsed_data.get("alternate_phone"))]
+ phone_values = [phone for phone in phone_values if phone]
+
+ candidate = False
+ if email_value:
+ candidate = search_model.search([("email_normalized", "=", email_value)], limit=1)
+ if not candidate and phone_values:
+ candidate = search_model.search([
+ "|",
+ ("partner_phone_sanitized", "in", phone_values),
+ ("alternate_phone", "in", phone_values),
+ ], limit=1)
+ return candidate
+
+ def _post_process_resume_data(self, parsed_data, extracted_text, filename):
+ data = dict(parsed_data or {})
+ extracted_text = extracted_text or ""
+
+ name_from_text = self._guess_name_from_text(extracted_text)
+ if not data.get("full_name") or self._is_filename_like_name(data.get("full_name"), filename):
+ data["full_name"] = name_from_text
+ if not data.get("first_name") and data.get("full_name"):
+ data["first_name"] = data["full_name"].split(" ")[0]
+ if not data.get("last_name") and data.get("full_name") and len(data["full_name"].split(" ")) > 1:
+ data["last_name"] = " ".join(data["full_name"].split(" ")[1:])
+
+ email_match = re.search(r"([A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,})", extracted_text, re.I)
+ if email_match:
+ data["email"] = email_match.group(1)
+
+ phone_matches = re.findall(r"(\+?\d[\d\-\s()]{7,}\d)", extracted_text)
+ if phone_matches and not data.get("phone"):
+ data["phone"] = phone_matches[0].strip()
+ if len(phone_matches) > 1 and not data.get("alternate_phone"):
+ data["alternate_phone"] = phone_matches[1].strip()
+
+ linkedin_match = re.search(r"(https?://(?:www\.)?linkedin\.com/[^\s]+)", extracted_text, re.I)
+ if linkedin_match:
+ data["linkedin_profile"] = linkedin_match.group(1).rstrip(".,)")
+
+ if not data.get("total_experience_years"):
+ data["total_experience_years"] = self._guess_total_experience(extracted_text)
+
+ data["skills"] = self._merge_resume_skills(data.get("skills") or [], extracted_text)
+ return data
+
+ def _post_process_jd_data(self, parsed_data, extracted_text):
+ data = dict(parsed_data or {})
+ text = extracted_text or ""
+ if not data.get("request_id"):
+ request_match = re.search(r"request\s*id\s*[:\-]\s*([A-Za-z0-9._/-]+)", text, re.I)
+ if request_match:
+ data["request_id"] = request_match.group(1).strip()
+ if not data.get("job_title"):
+ title_match = re.search(r"job\s*title\s*[:\-]\s*(.+)", text, re.I)
+ if title_match:
+ data["job_title"] = title_match.group(1).strip()
+ if not data.get("site_location"):
+ location_match = re.search(r"(?:hr\s+site\s+location|site\s+location)\s*[:\-]\s*(.+)", text, re.I)
+ if location_match:
+ data["site_location"] = location_match.group(1).strip()
+ if not data.get("start_date") or not data.get("end_date"):
+ date_match = re.search(r"start/end\s*dates\s*[:\-]\s*([0-9/.-]+)\s*-\s*([0-9/.-]+)", text, re.I)
+ if date_match:
+ data["start_date"] = data.get("start_date") or date_match.group(1).strip()
+ data["end_date"] = data.get("end_date") or date_match.group(2).strip()
+ data["job_title"] = self._extract_jd_job_title(data.get("job_title"), text)
+ data["job_summary"] = self._extract_jd_summary(data.get("job_summary"), text, data.get("job_title"))
+ data["requirements"] = self._extract_jd_requirements(data.get("requirements"), text)
+ primary_skills, secondary_skills = self._separate_jd_skills(
+ ai_primary_skills=data.get("primary_skills") or [],
+ ai_secondary_skills=data.get("secondary_skills") or [],
+ extracted_text=text,
+ )
+ data["primary_skills"] = primary_skills
+ data["secondary_skills"] = secondary_skills
+ return data
+
+ def _extract_jd_job_title(self, current_title, extracted_text):
+ title_match = re.search(r"job\s*title\s*[:\-]\s*(.+)", extracted_text or "", re.I)
+ title = (title_match.group(1).strip() if title_match else current_title or "").strip()
+ return title
+
+ def _extract_jd_summary(self, current_summary, extracted_text, job_title):
+ text = extracted_text or ""
+ summary = (current_summary or "").strip()
+ if summary:
+ normalized_summary = self._normalize_title_for_matching(summary)
+ normalized_title = self._normalize_title_for_matching(job_title or "")
+ if normalized_summary != normalized_title and not re.match(r"^(job function|job title|skills?/experience)\b", summary, re.I):
+ return summary
+
+ description_match = re.search(r"job\s*description\s*[:\-]\s*(.*?)(?:skills?/experience\s*:|education requirements\s*:|$)", text, re.I | re.S)
+ if description_match:
+ candidate = re.sub(r"\s+", " ", description_match.group(1)).strip(" .:-")
+ if candidate:
+ return candidate
+
+ for line in text.splitlines():
+ clean_line = re.sub(r"\s+", " ", line).strip(" .:-")
+ if not clean_line:
+ continue
+ if re.match(r"^(request id|start/end dates|hr site location|job title|job description|skills?/experience|education requirements)\b", clean_line, re.I):
+ continue
+ if self._normalize_title_for_matching(clean_line) == self._normalize_title_for_matching(job_title or ""):
+ continue
+ return clean_line
+ return False
+
+ def _extract_jd_requirements(self, current_requirements, extracted_text):
+ requirements = (current_requirements or "").strip()
+ if requirements:
+ return requirements
+ text = extracted_text or ""
+ requirements_match = re.search(
+ r"(skills?/experience\s*:.*?)(?:education requirements\s*:|$)",
+ text,
+ re.I | re.S,
+ )
+ if requirements_match:
+ return re.sub(r"\s+", " ", requirements_match.group(1)).strip()
+ return False
+
+ def _separate_jd_skills(self, ai_primary_skills, ai_secondary_skills, extracted_text):
+ all_skills = []
+ all_skills.extend(self._merge_resume_skills(ai_primary_skills or [], extracted_text))
+ all_skills.extend(self._merge_resume_skills(ai_secondary_skills or [], extracted_text, limit_to_registry=False))
+ all_skills.extend(self._extract_explicit_resume_skills(extracted_text))
+ normalized_skills = []
+ for skill in all_skills:
+ normalized_skill = self._normalize_jd_skill_name(skill, extracted_text)
+ if normalized_skill and self._is_valid_jd_skill(normalized_skill, extracted_text):
+ normalized_skills.append(normalized_skill)
+ all_skills = self._deduplicate_skill_names(normalized_skills)
+
+ scored_primary = []
+ scored_secondary = []
+ for skill in all_skills:
+ primary_score, secondary_score = self._score_jd_skill(skill, extracted_text)
+ if primary_score <= 0 and secondary_score <= 0:
+ continue
+ if primary_score >= secondary_score:
+ scored_primary.append((skill, primary_score, secondary_score))
+ else:
+ scored_secondary.append((skill, secondary_score, primary_score))
+
+ scored_primary.sort(key=lambda item: (-item[1], item[0].lower()))
+ primary = [item[0] for item in scored_primary]
+ primary_keys = {self._skill_canonical_key(skill) for skill in primary}
+
+ secondary = []
+ for skill, _secondary_score, _primary_score in sorted(scored_secondary, key=lambda item: (-item[1], item[0].lower())):
+ if self._skill_canonical_key(skill) in primary_keys:
+ continue
+ secondary.append(skill)
+
+ if not primary and all_skills:
+ primary = all_skills[: min(len(all_skills), 5)]
+ primary_keys = {self._skill_canonical_key(skill) for skill in primary}
+ secondary = [skill for skill in all_skills if self._skill_canonical_key(skill) not in primary_keys]
+
+ secondary = [skill for skill in secondary if self._skill_canonical_key(skill) not in primary_keys]
+ return self._deduplicate_skill_names(primary), self._deduplicate_skill_names(secondary)
+
+ def _normalize_jd_skill_name(self, skill, extracted_text):
+ normalized = self._normalize_skill_name(skill)
+ if not normalized:
+ return False
+ normalized = re.sub(
+ r"\b(?:strong|solid|excellent|good|very good|well versed|well-versed|understanding of|knowledge of|experience with|experience on|experience in)\b",
+ "",
+ normalized,
+ flags=re.I,
+ )
+ normalized = re.sub(r"\b(?:skills?|skillset|experience|environments?|issues?)\b", "", normalized, flags=re.I)
+ normalized = re.sub(r"\s+", " ", normalized).strip(" -,:;/")
+
+ match = re.search(r"\(([^)]+)\)", skill or "")
+ if match:
+ bracket_value = self._normalize_skill_name(match.group(1))
+ if bracket_value and self._looks_like_specific_technical_term(bracket_value, extracted_text):
+ normalized = bracket_value
+
+ if "like " in (skill or "").lower():
+ like_match = re.search(r"\blike\s+([A-Za-z0-9+#./-][A-Za-z0-9+#./\-\s]*)", skill or "", re.I)
+ if like_match:
+ candidate = self._normalize_skill_name(like_match.group(1))
+ if candidate:
+ normalized = candidate
+
+ return normalized
+
+ def _is_valid_jd_skill(self, skill, extracted_text):
+ if not self._is_valid_resume_skill(skill, extracted_text):
+ return False
+ if self._is_abstract_jd_skill(skill):
+ return False
+ registry_skill = self._match_registry_skill(skill)
+ if registry_skill and self._skill_mentioned_in_text(registry_skill.name, extracted_text):
+ return True
+ return self._looks_like_specific_technical_term(skill, extracted_text)
+
+ def _is_abstract_jd_skill(self, skill):
+ tokens = (skill or "").lower().split()
+ if not tokens:
+ return True
+ if len(tokens) == 1 and re.fullmatch(r"[a-z]+", tokens[0]) and len(tokens[0]) > 8:
+ return True
+ generic_words = {
+ "communication",
+ "interpersonal",
+ "understanding",
+ "exception",
+ "pressure",
+ "solver",
+ "player",
+ "lifecycle",
+ "process",
+ }
+ return all(token in generic_words for token in tokens)
+
+ def _looks_like_specific_technical_term(self, skill, extracted_text):
+ value = (skill or "").strip()
+ if not value:
+ return False
+ if re.search(r"[0-9+#/.-]", value):
+ return True
+ if len(value.split()) > 4:
+ return False
+
+ occurrence = self._extract_original_term_occurrence(value, extracted_text)
+ if occurrence and re.search(r"[A-Z0-9+#/.-]", occurrence):
+ return True
+
+ technical_context = self._extract_term_context(value, extracted_text)
+ if not technical_context:
+ return False
+ return bool(
+ re.search(
+ r"\b(architecture|programming|language|debugger|debugging|tool|platform|protocol|kernel|linux|firmware|software|hardware|system|development|environment|compiler|trace|processor|virtualization|security)\b",
+ technical_context,
+ re.I,
+ )
+ )
+
+ def _extract_original_term_occurrence(self, value, extracted_text):
+ if not value or not extracted_text:
+ return ""
+ match = re.search(r"(? 1 else False)
+ resume_mimetype = parsed_payload.get("mimetype") or mimetypes.guess_type(line.file_name or "")[0]
+ degree_id = False
+ if parsed_data.get("degree"):
+ degree_id = self.env["hr.recruitment.degree"].search([("name", "=ilike", parsed_data["degree"])], limit=1).id
+
+ vals = {
+ "partner_name": full_name,
+ "first_name": first_name,
+ "last_name": last_name,
+ "email_from": parsed_data.get("email"),
+ "partner_phone": parsed_data.get("phone"),
+ "alternate_phone": parsed_data.get("alternate_phone"),
+ "linkedin_profile": parsed_data.get("linkedin_profile"),
+ "resume": line.file,
+ "resume_name": line.file_name,
+ "resume_type": resume_mimetype,
+ "type_id": degree_id,
+ }
+ return {key: value for key, value in vals.items() if value not in (False, None, "")}
+
+ def _ensure_resume_creation_allowed(self, parsed_data, filename):
+ full_name = (parsed_data.get("full_name") or "").strip()
+ email_value = (parsed_data.get("email") or "").strip()
+ phone_value = (parsed_data.get("phone") or "").strip()
+ skills = parsed_data.get("skills") or []
+
+ if not full_name or self._is_filename_like_name(full_name, filename):
+ raise ValidationError(_("Unable to validate candidate name from the resume. Record creation was skipped."))
+ if not email_value and not phone_value:
+ raise ValidationError(_("At least one contact detail between email and phone is required. Record creation was skipped."))
+ if not skills:
+ raise ValidationError(_("No reliable skills were found in the resume. Record creation was skipped."))
+
+ def _prepare_sparse_update_vals(self, candidate, values):
+ update_vals = {}
+ field_map = {
+ "partner_name": not candidate.partner_name,
+ "first_name": not getattr(candidate, "first_name", False),
+ "last_name": not getattr(candidate, "last_name", False),
+ "email_from": not candidate.email_from,
+ "partner_phone": not candidate.partner_phone,
+ "alternate_phone": not getattr(candidate, "alternate_phone", False),
+ "linkedin_profile": not candidate.linkedin_profile,
+ "resume": not getattr(candidate, "resume", False),
+ "resume_name": not getattr(candidate, "resume_name", False),
+ "resume_type": not getattr(candidate, "resume_type", False),
+ }
+ for field_name, can_update in field_map.items():
+ if can_update and values.get(field_name):
+ update_vals[field_name] = values[field_name]
+ return update_vals
+
+ def _sync_candidate_skills(self, candidate, skills):
+ if not skills:
+ return
+ skill_records = self._find_or_create_skill_records(skills)
+ existing_skill_ids = candidate.candidate_skill_ids.mapped("skill_id").ids
+ candidate_skill_commands = []
+ for skill in skill_records:
+ if skill.id in existing_skill_ids:
+ continue
+ default_level = skill.skill_type_id.skill_level_ids.filtered("default_level")[:1] or skill.skill_type_id.skill_level_ids[:1]
+ if not default_level:
+ continue
+ candidate_skill_commands.append((0, 0, {
+ "skill_id": skill.id,
+ "skill_type_id": skill.skill_type_id.id,
+ "skill_level_id": default_level.id,
+ }))
+ if candidate_skill_commands:
+ candidate.write({"candidate_skill_ids": candidate_skill_commands})
+
+ def _find_or_create_skill_records(self, skill_names):
+ skill_model = self.env["hr.skill"]
+ skill_records = self.env["hr.skill"]
+ default_type = self._get_or_create_default_skill_type()
+ for skill_name in skill_names:
+ clean_name = self._normalize_skill_name(skill_name)
+ if not clean_name:
+ continue
+ skill = self._match_registry_skill(clean_name)
+ if not skill and self.create_missing_skills:
+ skill = skill_model.create({
+ "name": clean_name,
+ "skill_type_id": default_type.id,
+ })
+ if skill:
+ skill_records |= skill
+ return skill_records
+
+ def _get_or_create_default_skill_type(self):
+ skill_type_model = self.env["hr.skill.type"]
+ skill_level_model = self.env["hr.skill.level"]
+ skill_type = skill_type_model.search([("name", "=", "Resume Parsed")], limit=1)
+ if not skill_type:
+ skill_type = skill_type_model.create({"name": "Resume Parsed"})
+ if not skill_type.skill_level_ids:
+ skill_level_model.create({
+ "name": "Intermediate",
+ "skill_type_id": skill_type.id,
+ "level_progress": 50,
+ "default_level": True,
+ })
+ return skill_type
+
+ def _normalize_phone(self, value):
+ if not value:
+ return False
+ normalized = re.sub(r"[^\d+]", "", value)
+ return normalized or False
+
+ def _normalize_skill_name(self, value):
+ value = re.sub(r"\s+", " ", (value or "")).strip(" -,:;")
+ value = re.sub(r"^[0-9.)\-(\s]+", "", value).strip()
+ if not value:
+ return False
+ lower_value = value.lower()
+ blacklist = {"skills", "technical skills", "core skills", "summary", "experience", "education"}
+ if lower_value in blacklist:
+ return False
+ return value
+
+ def _merge_resume_skills(self, ai_skills, extracted_text, limit_to_registry=True):
+ merged = []
+ registry_skills = self._extract_registry_skills(extracted_text)
+ merged.extend(registry_skills)
+ merged.extend(self._extract_explicit_resume_skills(extracted_text))
+ for skill in ai_skills:
+ resolved_skill = self._resolve_resume_skill(skill, extracted_text)
+ if resolved_skill:
+ merged.append(resolved_skill)
+ if not merged and not limit_to_registry:
+ merged.extend(self._guess_skill_candidates_from_text(extracted_text))
+ return self._deduplicate_skill_names(merged)
+
+ def _extract_registry_skills(self, extracted_text):
+ matches = []
+ for skill in self.env["hr.skill"].search([]):
+ skill_name = (skill.name or "").strip()
+ if not skill_name:
+ continue
+ if self._skill_mentioned_in_text(skill_name, extracted_text):
+ matches.append(skill_name)
+ return matches
+
+ def _guess_skill_candidates_from_text(self, text):
+ result = []
+ for block in self._extract_candidate_skill_blocks(text):
+ for part in re.split(r"[•,\n;|]", block):
+ value = self._normalize_skill_name(part)
+ if self._is_valid_resume_skill(value, text):
+ result.append(value)
+ return self._deduplicate_skill_names(result)[:25]
+
+
+ def _detect_resume_domain(self, text):
+ text = text.lower()
+
+ if any(x in text for x in ["quality", "inspection", "production", "material", "audit"]):
+ return "manufacturing"
+
+ if any(x in text for x in ["python", "java", "software", "developer", "api"]):
+ return "it"
+
+ return "general"
+
+ def _resolve_resume_skill(self, skill_name,
+ extracted_text):
+ normalized = self._normalize_skill_name(skill_name)
+ if not normalized:
+ return False
+ if not self._is_valid_resume_skill(normalized, extracted_text):
+ return False
+ registry_skill = self._match_registry_skill(normalized)
+ if registry_skill and (
+ self._skill_mentioned_in_text(registry_skill.name, extracted_text)
+ or self._skill_mentioned_in_text(normalized, extracted_text)
+ ):
+ return registry_skill.name
+ if self._skill_mentioned_in_text(normalized, extracted_text):
+ return normalized
+ return False
+
+ def _match_registry_skill(self, skill_name):
+ normalized = self._normalize_skill_name(skill_name)
+ if not normalized:
+ return self.env["hr.skill"]
+ normalized_variants = self._skill_variants(normalized)
+ exact_skill = self.env["hr.skill"].search([("name", "=ilike", normalized)], limit=1)
+ if exact_skill:
+ return exact_skill
+
+ matched_skill = self.env["hr.skill"]
+ for skill in self.env["hr.skill"].search([]):
+ skill_variants = self._skill_variants(skill.name)
+ if normalized_variants & skill_variants:
+ matched_skill = skill
+ break
+ return matched_skill
+
+ def _skill_mentioned_in_text(self, skill_name, extracted_text):
+ if not skill_name or not extracted_text:
+ return False
+
+ text = extracted_text.lower()
+
+ pattern = r'(? 120:
+ continue
+ if ":" not in clean_line:
+ continue
+ label, value = [item.strip() for item in clean_line.split(":", 1)]
+ if re.search(r"(skills?|tools?|software|technologies|competencies|proficiencies|expertise)", label, re.I):
+ for part in re.split(r"[,;|/]", value):
+ normalized = self._normalize_skill_name(part)
+ if self._is_valid_resume_skill(normalized, extracted_text):
+ skills.append(normalized)
+
+ return self._deduplicate_skill_names(skills)
+
+ def _extract_candidate_skill_blocks(self, text):
+ matches = []
+ patterns = [
+ r"(key skills|skills|technical skills|core skills|core competencies|competencies|technical competencies|software skills|tools|technologies|areas of expertise|expertise)(.*?)(experience|professional experience|work experience|employment|education|projects|certifications|achievements|summary|profile|declaration|$)",
+ r"(?:^|\n)(?:technical summary|professional summary|profile summary)\s*[:\-]\s*(.+)",
+ ]
+ for pattern in patterns:
+ for match in re.finditer(pattern, text or "", re.I | re.S):
+ if match.lastindex:
+ matches.append(match.group(match.lastindex))
+ return matches
+
+ def _is_valid_resume_skill(self, value, extracted_text=None):
+ normalized = self._normalize_skill_name(value)
+ if not normalized:
+ return False
+
+ normalized_lower = normalized.lower()
+ if normalized_lower in self._skill_phrase_blacklist():
+ return False
+ if any(token in normalized_lower for token in self._skill_noise_tokens()):
+ return False
+ if len(normalized.split()) > 4:
+ return False
+ if re.search(r"\b(responsible|responsibility|handling|managed|managing|worked on|supporting|coordination)\b", normalized_lower):
+ return False
+ if self._is_certification_like_skill(normalized, extracted_text):
+ return False
+ if extracted_text and not self._skill_mentioned_in_text(normalized, extracted_text):
+ registry_skill = self._match_registry_skill(normalized)
+ if not registry_skill or not self._skill_mentioned_in_text(registry_skill.name, extracted_text):
+ return False
+ return True
+
+ def _is_certification_like_skill(self, value, extracted_text=None):
+ normalized_lower = (value or "").lower()
+ if normalized_lower in {"n d t", "ndt", "nasscom"}:
+ return True
+ if not extracted_text:
+ return False
+
+ compact_value = re.sub(r"\s+", r"\\s+", re.escape(normalized_lower))
+ contexts = re.finditer(compact_value, extracted_text.lower())
+ certification_hits = 0
+ total_hits = 0
+ for match in contexts:
+ total_hits += 1
+ start = max(match.start() - 80, 0)
+ end = min(match.end() + 80, len(extracted_text))
+ context = extracted_text[start:end].lower()
+ if re.search(r"(certificate|certification|certified|training|course|assessment|nasscom|license)", context):
+ certification_hits += 1
+ return total_hits > 0 and certification_hits == total_hits
+
+ def _deduplicate_skill_names(self, values):
+ deduplicated = []
+ seen = set()
+ for value in values:
+ normalized = self._normalize_skill_name(value)
+ if not normalized:
+ continue
+ canonical = self._skill_canonical_key(normalized)
+ if canonical in seen:
+ continue
+ seen.add(canonical)
+ deduplicated.append(normalized)
+ return deduplicated
+
+ def _skill_canonical_key(self, value):
+ normalized = self._normalize_skill_name(value)
+ if not normalized:
+ return ""
+ variants = self._skill_variants(normalized)
+ compact_variants = sorted(re.sub(r"[^a-z0-9+#]+", "", item.lower()) for item in variants if item)
+ return compact_variants[0] if compact_variants else re.sub(r"[^a-z0-9+#]+", "", normalized.lower())
+
+ def _skill_phrase_blacklist(self):
+ return {
+ "cross-functional collaboration",
+ "cross functional collaboration",
+ "cost saving",
+ "cost savings",
+ "resource utilization",
+ "material management",
+ "data analysis",
+ "statistical tools",
+ "task allocation",
+ "safety protocols",
+ "compliance requirements",
+ "production targets",
+ "workforce training",
+ "operational downtime reduction",
+ "quality standards",
+ "problem solving",
+ "team player",
+ "leadership",
+ "communication",
+ "time management",
+ "decision making",
+ "analytical skills",
+ "documentation",
+ "reporting",
+ }
+
+ def _skill_noise_tokens(self):
+ return {
+ "collaboration",
+ "utilization",
+ "saving",
+ "savings",
+ "coordination",
+ "reporting",
+ "allocation",
+ "targets",
+ "downtime",
+ "workforce",
+ }
+
+ def _skill_variants(self, skill_name):
+ normalized = self._normalize_skill_name(skill_name)
+ if not normalized:
+ return set()
+
+ aliases = self._skill_alias_map()
+ lowered = normalized.lower()
+ compact = re.sub(r"[^a-z0-9+#]+", "", lowered)
+ variants = {
+ lowered,
+ re.sub(r"\s+", " ", lowered).strip(),
+ compact,
+ }
+
+ for canonical, alias_values in aliases.items():
+ alias_set = {canonical, *alias_values}
+ alias_compact = {re.sub(r"[^a-z0-9+#]+", "", value.lower()) for value in alias_set}
+ if lowered in alias_set or compact in alias_compact:
+ for value in alias_set:
+ variants.add(value)
+ variants.add(re.sub(r"[^a-z0-9+#]+", "", value.lower()))
+ return {value for value in variants if value}
+
+ def _skill_alias_map(self):
+ return {
+ "python": {"py"},
+ "javascript": {"js", "java script"},
+ "typescript": {"ts", "type script"},
+ "c#": {"c sharp", "c-sharp"},
+ "c++": {"cpp", "c plus plus"},
+ "node.js": {"nodejs", "node js"},
+ "react.js": {"react", "reactjs", "react js"},
+ "vue.js": {"vue", "vuejs", "vue js"},
+ "angular.js": {"angular", "angularjs", "angular js"},
+ "postgresql": {"postgres", "postgre sql", "postgres sql"},
+ "mysql": {"my sql"},
+ "ms sql": {"mssql", "ms-sql"},
+ }
+
+ def _guess_name_from_text(self, extracted_text):
+ for line in (extracted_text or "").splitlines()[:15]:
+ clean_line = re.sub(r"[^A-Za-z .'-]", "", line).strip()
+ if not clean_line:
+ continue
+ if re.search(r"(resume|curriculum|vitae|email|phone|linkedin|skills|experience)", clean_line, re.I):
+ continue
+ parts = clean_line.split()
+ if 1 < len(parts) <= 4:
+ return clean_line
+ return False
+
+ def _is_filename_like_name(self, value, filename):
+ if not value:
+ return True
+ value_lower = value.lower().strip()
+ filename_base = re.sub(r"\.[^.]+$", "", (filename or "")).lower().replace("_", " ").replace("-", " ").strip()
+ if value_lower == filename_base:
+ return True
+ return bool(re.search(r"\.(pdf|doc|docx|txt)$", value_lower))
+
+ def _guess_total_experience(self, extracted_text):
+ text = extracted_text or ""
+ patterns = [
+ r"(\d+(?:\.\d+)?)\s*\+?\s*years? of experience",
+ r"total experience[^0-9]{0,10}(\d+(?:\.\d+)?)",
+ r"experience[^0-9]{0,10}(\d+(?:\.\d+)?)\s*\+?\s*years?",
+ ]
+ for pattern in patterns:
+ match = re.search(pattern, text, re.I)
+ if match:
+ try:
+ return float(match.group(1))
+ except Exception:
+ continue
+ years_months = re.search(r"(\d+)\s+years?.{0,8}(\d+)\s+months?", text, re.I)
+ if years_months:
+ return round(float(years_months.group(1)) + (float(years_months.group(2)) / 12.0), 2)
+ return False
+
+ def _apply_jd_parse(self, parsed_data, parsed_payload):
+ self.ensure_one()
+ job_request, state = self._get_or_create_job_request(parsed_data, parsed_payload)
+ experience_id = False
+ experience_years = parsed_data.get("experience_years")
+ if experience_years not in (False, None, ""):
+ experience_floor = int(float(experience_years))
+ experience_id = self.env["candidate.experience"].search([
+ ("experience_from", "<=", experience_floor),
+ ("experience_to", ">=", experience_floor),
+ ], limit=1).id
+
+ primary_skills = self._find_or_create_skill_records(parsed_data.get("primary_skills") or [])
+ secondary_skills = self._find_or_create_skill_records(parsed_data.get("secondary_skills") or [])
+ write_vals = {
+ "description": parsed_data.get("job_summary") or parsed_payload.get("text"),
+ "requirements": parsed_data.get("requirements"),
+ "budget": parsed_data.get("budget"),
+ "experience": experience_id,
+ "target_from": self._parse_date_value(parsed_data.get("start_date")),
+ "target_to": self._parse_date_value(parsed_data.get("end_date")),
+ "skill_ids": [(6, 0, primary_skills.ids)] if primary_skills else False,
+ "secondary_skill_ids": [(6, 0, secondary_skills.ids)] if secondary_skills else False,
+ }
+ job_category = self._match_job_category(parsed_data, parsed_payload)
+ if job_category:
+ write_vals["job_category"] = job_category.id
+ if job_request.job_id and job_request.job_id.job_category != job_category:
+ job_request.job_id.job_category = job_category.id
+ write_vals = {key: value for key, value in write_vals.items() if value not in (False, None, "")}
+ if write_vals:
+ job_request.write(write_vals)
+ return job_request, state
+
+ def _get_or_create_job_request(self, parsed_data, parsed_payload):
+ if self.job_recruitment_id:
+ return self.job_recruitment_id, "updated"
+
+ request_id = (parsed_data.get("request_id") or "").strip()
+ job_title = (parsed_data.get("job_title") or "").strip()
+ job_request_model = self.env["hr.job.recruitment"]
+
+ job_request = False
+ if request_id:
+ job_request = job_request_model.search([("recruitment_sequence", "=", request_id)], limit=1)
+ if not job_request and job_title:
+ job_request = job_request_model.search([("name", "=ilike", job_title)], limit=1)
+ if job_request:
+ return job_request, "updated"
+
+ if not job_title:
+ raise ValidationError(_("Unable to determine the Job Title from the JD."))
+
+ job = self._match_existing_job_position(job_title)
+ if not job:
+ normalized_job_title = self._normalize_job_title(job_title)
+ job = self.env["hr.job"].create({
+ "name": normalized_job_title or job_title,
+ "company_id": self.env.company.id,
+ })
+
+ job_category = self._match_job_category(parsed_data, parsed_payload)
+ if job_category and job.job_category != job_category:
+ job.job_category = job_category.id
+
+ create_vals = {
+ "job_id": job.id,
+ "company_id": self.env.company.id,
+ "description": parsed_data.get("job_summary") or parsed_payload.get("text"),
+ "requirements": parsed_data.get("requirements"),
+ "target_from": self._parse_date_value(parsed_data.get("start_date")),
+ "target_to": self._parse_date_value(parsed_data.get("end_date")),
+ "job_category": job_category.id if job_category else False,
+ }
+ if request_id:
+ create_vals["recruitment_sequence"] = request_id
+ create_vals = {key: value for key, value in create_vals.items() if value not in (False, None, "")}
+ return job_request_model.create(create_vals), "created"
+
+ def _match_existing_job_position(self, job_title):
+ hr_job_model = self.env["hr.job"]
+ title_candidates = self._job_title_candidates(job_title)
+ for candidate in title_candidates:
+ job = hr_job_model.search([("name", "=ilike", candidate)], limit=1)
+ if job:
+ return job
+ for candidate in title_candidates:
+ job = hr_job_model.search([("name", "ilike", candidate)], limit=1)
+ if job:
+ return job
+ best_job = hr_job_model
+ best_score = 0.0
+ normalized_candidates = [self._normalize_title_for_matching(candidate) for candidate in title_candidates]
+ normalized_candidates = [candidate for candidate in normalized_candidates if candidate]
+ if not normalized_candidates:
+ return hr_job_model
+
+ search_terms = []
+ for candidate in normalized_candidates:
+ search_terms.extend(candidate.split())
+ search_terms = list(dict.fromkeys(search_terms))
+
+ candidate_jobs = hr_job_model
+ for term in search_terms[:4]:
+ candidate_jobs |= hr_job_model.search([("name", "ilike", term)], limit=30)
+ if not candidate_jobs:
+ candidate_jobs = hr_job_model.search([], limit=200)
+
+ for job in candidate_jobs:
+ normalized_job_name = self._normalize_title_for_matching(job.name)
+ if not normalized_job_name:
+ continue
+ for normalized_candidate in normalized_candidates:
+ score = self._job_title_match_score(normalized_candidate, normalized_job_name)
+ if score > best_score:
+ best_score = score
+ best_job = job
+ return best_job if best_score >= 0.72 else hr_job_model
+
+ def _job_title_candidates(self, job_title):
+ raw_title = re.sub(r"\s+", " ", (job_title or "")).strip(" -|")
+ if not raw_title:
+ return []
+
+ candidates = []
+ pipe_parts = [part.strip(" -") for part in raw_title.split("|") if part.strip(" -")]
+ dash_parts = [part.strip(" -") for part in raw_title.split(" - ") if part.strip(" -")]
+
+ normalized = self._normalize_job_title(raw_title)
+ if normalized:
+ candidates.append(normalized)
+ candidates.append(raw_title)
+ candidates.extend([part for part in pipe_parts if len(part.split()) >= 2])
+ candidates.extend([part for part in dash_parts if len(part.split()) >= 2])
+
+ deduped = []
+ seen = set()
+ for candidate in candidates:
+ cleaned = re.sub(r"\s+", " ", candidate).strip(" -|")
+ if not cleaned:
+ continue
+ key = cleaned.lower()
+ if key in seen:
+ continue
+ seen.add(key)
+ deduped.append(cleaned)
+ return deduped
+
+ def _normalize_job_title(self, job_title):
+ raw_title = re.sub(r"\s+", " ", (job_title or "")).strip()
+ if not raw_title:
+ return False
+
+ parts = [part.strip() for part in raw_title.split(" - ") if part.strip()]
+ for part in reversed(parts):
+ if re.fullmatch(r"[A-Za-z][A-Za-z0-9/ &.-]*\b(?:I|II|III|IV|V|VI|VII|VIII|IX|X)\b", part):
+ return part
+ if "|" in raw_title:
+ left_part = raw_title.split("|", 1)[0].strip()
+ if left_part and left_part != raw_title:
+ return self._normalize_job_title(left_part) or left_part
+ shortest_part = min(parts, key=len) if parts else raw_title
+ return shortest_part or raw_title
+
+ def _normalize_title_for_matching(self, title):
+ normalized = re.sub(r"\|.*$", " ", str(title or ""))
+ normalized = re.sub(r"[^A-Za-z0-9]+", " ", normalized).lower()
+ tokens = [token for token in normalized.split() if token]
+ return " ".join(tokens)
+
+ def _job_title_match_score(self, left_title, right_title):
+ left_tokens = set(left_title.split())
+ right_tokens = set(right_title.split())
+ if not left_tokens or not right_tokens:
+ return 0.0
+ overlap_score = len(left_tokens & right_tokens) / float(max(len(left_tokens), len(right_tokens)))
+ sequence_score = SequenceMatcher(None, left_title, right_title).ratio()
+ return max(overlap_score, sequence_score)
+
+ def _match_job_category(self, parsed_data, parsed_payload):
+ categories = self.env["job.category"].search([])
+ if not categories:
+ return self.env["job.category"]
+
+ match_text = " ".join(filter(None, [
+ parsed_data.get("job_title"),
+ parsed_data.get("job_summary"),
+ parsed_data.get("requirements"),
+ parsed_payload.get("text"),
+ ]))
+ normalized_text = self._normalize_title_for_matching(match_text)
+ if not normalized_text:
+ return self.env["job.category"]
+
+ best_category = self.env["job.category"]
+ best_score = 0.0
+ for category in categories:
+ category_name = (category.category_name or "").strip()
+ normalized_category = self._normalize_title_for_matching(category_name)
+ if not normalized_category:
+ continue
+ score = self._job_title_match_score(normalized_text, normalized_category)
+ token_overlap = self._category_token_coverage(normalized_text, normalized_category)
+ final_score = max(score, token_overlap)
+ if final_score > best_score:
+ best_score = final_score
+ best_category = category
+ return best_category if best_score >= 0.45 else self.env["job.category"]
+
+ def _category_token_coverage(self, normalized_text, normalized_category):
+ text_tokens = set(normalized_text.split())
+ category_tokens = set(normalized_category.split())
+ if not text_tokens or not category_tokens:
+ return 0.0
+ return len(text_tokens & category_tokens) / float(len(category_tokens))
+
+ def _parse_date_value(self, value):
+ if not value:
+ return False
+ for date_format in ("%m/%d/%Y", "%m-%d-%Y", "%Y-%m-%d", "%d/%m/%Y", "%d-%m-%Y"):
+ try:
+ return datetime.strptime(str(value).strip(), date_format).date()
+ except Exception:
+ continue
+ return fields.Date.to_date(value)
+
+ def _attach_jd_document_to_job_request(self, job_request, line):
+ if not job_request or not line.file:
+ return False
+
+ attachment_name = line.file_name or _("JD Document")
+ existing_attachment = self.env["ir.attachment"].search([
+ ("res_model", "=", "hr.job.recruitment"),
+ ("res_id", "=", job_request.id),
+ ("name", "=", attachment_name),
+ ], limit=1)
+ if existing_attachment:
+ return existing_attachment
+
+ attachment_vals = {
+ "name": attachment_name,
+ "datas": line.file,
+ "res_model": "hr.job.recruitment",
+ "res_id": job_request.id,
+ "mimetype": line.attachment_id.mimetype if line.attachment_id else mimetypes.guess_type(attachment_name)[0],
+ "type": "binary",
+ }
+ attachment = self.env["ir.attachment"].create(attachment_vals)
+ job_request.message_post(
+ body=_("JD document uploaded and attached from the parser wizard."),
+ attachment_ids=[attachment.id],
+ )
+ return attachment
+
+ def _build_summary_row(self, line, message, level):
+ return {
+ "filename": line.file_name or _("Unnamed file"),
+ "message": message,
+ "level": level,
+ }
+
+ def _build_summary_html(self, rows):
+ if not rows:
+ return "No documents were processed.
"
+ html_parts = [
+ "",
+ "
",
+ ]
+ for row in rows:
+ html_parts.append(
+ "- %s: %s
" % (
+ escape(row["filename"]),
+ row["level"],
+ escape(row["message"]),
+ )
+ )
+ html_parts.extend(["
", "
"])
+ return "".join(html_parts)
+
+
+class HrRecruitmentAutoDocWizardLine(models.TransientModel):
+ _name = "hr.recruitment.auto.doc.wizard.line"
+ _description = "HR Recruitment Auto Document Wizard Line"
+ _order = "id"
+
+ wizard_id = fields.Many2one(
+ "hr.recruitment.auto.doc.wizard",
+ required=True,
+ ondelete="cascade",
+ )
+ attachment_id = fields.Many2one("ir.attachment", readonly=True, ondelete="set null")
+ file = fields.Binary(required=True)
+ file_name = fields.Char(required=True)
+ state = fields.Selection(
+ selection=[
+ ("draft", "Draft"),
+ ("done", "Done"),
+ ("error", "Error"),
+ ],
+ default="draft",
+ readonly=True,
+ )
+ message = fields.Char(readonly=True)
+ extracted_payload = fields.Text(readonly=True)
+ candidate_id = fields.Many2one("hr.candidate", readonly=True)
+ applicant_id = fields.Many2one("hr.applicant", readonly=True)
diff --git a/addons_extensions/hr_recruitment_auto_doc/wizard/hr_recruitment_auto_doc_wizard_views.xml b/addons_extensions/hr_recruitment_auto_doc/wizard/hr_recruitment_auto_doc_wizard_views.xml
new file mode 100644
index 000000000..5af4548fa
--- /dev/null
+++ b/addons_extensions/hr_recruitment_auto_doc/wizard/hr_recruitment_auto_doc_wizard_views.xml
@@ -0,0 +1,78 @@
+
+
+
+ hr.recruitment.auto.doc.wizard.form
+ hr.recruitment.auto.doc.wizard
+
+
+
+
+
diff --git a/addons_extensions/hr_recruitment_extended/__manifest__.py b/addons_extensions/hr_recruitment_extended/__manifest__.py
index d314f2bdb..aa203bb5b 100644
--- a/addons_extensions/hr_recruitment_extended/__manifest__.py
+++ b/addons_extensions/hr_recruitment_extended/__manifest__.py
@@ -41,23 +41,26 @@
'views/recruitment_attachments.xml',
'views/hr_employee_education_employer_family.xml',
'views/hr_recruitment_source.xml',
- 'views/requisitions.xml',
- 'views/skills.xml',
- 'wizards/post_onboarding_attachment_wizard.xml',
- 'wizards/applicant_refuse_reason.xml',
- 'wizards/ats_invite_mail_template_wizard.xml',
- 'wizards/client_submission_mail_template_wizard.xml',
- # 'views/resume_pearser.xml',
- ],
- 'assets': {
-'web.assets_backend': [
- 'hr_recruitment_extended/static/src/img/pdf_icon.png',
- ],
- 'web.assets_frontend': [
- 'hr_recruitment_extended/static/src/js/website_hr_applicant_form.js',
- 'hr_recruitment_extended/static/src/js/pre_onboarding_attachment_requests.js',
- 'hr_recruitment_extended/static/src/js/post_onboarding_form.js',
- ],
- }
-}
+ 'views/requisitions.xml',
+ 'views/skills.xml',
+ 'views/recruitment_matching_views.xml',
+ 'wizards/post_onboarding_attachment_wizard.xml',
+ 'wizards/applicant_refuse_reason.xml',
+ 'wizards/ats_invite_mail_template_wizard.xml',
+ 'wizards/client_submission_mail_template_wizard.xml',
+ # 'views/resume_pearser.xml',
+ ],
+ 'assets': {
+'web.assets_backend': [
+ 'hr_recruitment_extended/static/src/img/pdf_icon.png',
+ 'hr_recruitment_extended/static/src/js/recruitment_match_panel.js',
+ 'hr_recruitment_extended/static/src/scss/recruitment_match_panel.scss',
+ ],
+ 'web.assets_frontend': [
+ 'hr_recruitment_extended/static/src/js/website_hr_applicant_form.js',
+ 'hr_recruitment_extended/static/src/js/pre_onboarding_attachment_requests.js',
+ 'hr_recruitment_extended/static/src/js/post_onboarding_form.js',
+ ],
+ }
+}
diff --git a/addons_extensions/hr_recruitment_extended/models/hr_applicant.py b/addons_extensions/hr_recruitment_extended/models/hr_applicant.py
index 47f24e84a..cc93cb4e4 100644
--- a/addons_extensions/hr_recruitment_extended/models/hr_applicant.py
+++ b/addons_extensions/hr_recruitment_extended/models/hr_applicant.py
@@ -9,12 +9,31 @@ import warnings
from odoo.tools.mimetypes import guess_mimetype, fix_filename_extension
-class HRApplicant(models.Model):
- _inherit = 'hr.applicant'
- _track_duration_field = 'recruitment_stage_id'
-
-
- candidate_image = fields.Image(related='candidate_id.candidate_image', readonly=False, compute_sudo=True)
+class HRApplicant(models.Model):
+ _inherit = 'hr.applicant'
+ _track_duration_field = 'recruitment_stage_id'
+
+ hide_chatter_suggestion = fields.Boolean(string="Hide Chatter Suggestions", default=False, tracking=True)
+ primary_skill_match_percentage = fields.Float(
+ string="Primary Skill Match (%)",
+ compute='_compute_skill_match_percentages',
+ store=True,
+ digits=(16, 2),
+ )
+ secondary_skill_match_percentage = fields.Float(
+ string="Secondary Skill Match (%)",
+ compute='_compute_skill_match_percentages',
+ store=True,
+ digits=(16, 2),
+ )
+ overall_skill_match_percentage = fields.Float(
+ string="Overall Skill Match (%)",
+ compute='_compute_skill_match_percentages',
+ store=True,
+ digits=(16, 2),
+ )
+
+ candidate_image = fields.Image(related='candidate_id.candidate_image', readonly=False, compute_sudo=True)
submitted_to_client = fields.Boolean(string="Submitted_to_client", default=False, readonly=True, tracking=True)
client_submission_date = fields.Datetime(string="Submission Date")
submitted_stage = fields.Many2one('hr.recruitment.stage')
@@ -22,12 +41,33 @@ class HRApplicant(models.Model):
refused_comments = fields.Text(string='Reject Comments')
is_on_hold = fields.Boolean(string="Is On Hold", default=False)
- def hold_unhold_button(self):
- for rec in self:
- if rec.is_on_hold:
- rec.is_on_hold = False
- else:
- rec.is_on_hold = True
+ def hold_unhold_button(self):
+ for rec in self:
+ if rec.is_on_hold:
+ rec.is_on_hold = False
+ else:
+ rec.is_on_hold = True
+
+ def action_toggle_chatter_visibility(self):
+ for record in self:
+ record.hide_chatter_suggestion = not record.hide_chatter_suggestion
+ return {'type': 'ir.actions.client', 'tag': 'reload'}
+
+ @api.depends('hr_job_recruitment.skill_ids', 'hr_job_recruitment.secondary_skill_ids', 'candidate_id.skill_ids')
+ def _compute_skill_match_percentages(self):
+ for applicant in self:
+ percentages = {
+ 'primary_skill_match_percentage': 0.0,
+ 'secondary_skill_match_percentage': 0.0,
+ 'overall_skill_match_percentage': 0.0,
+ }
+ if applicant.hr_job_recruitment and applicant.candidate_id:
+ percentages = applicant.hr_job_recruitment._get_skill_match_percentages(applicant.candidate_id.skill_ids)
+ percentages = {
+ key: value for key, value in percentages.items()
+ if key in {'primary_skill_match_percentage', 'secondary_skill_match_percentage', 'overall_skill_match_percentage'}
+ }
+ applicant.update(percentages)
@api.constrains('candidate_id','hr_job_recruitment')
def hr_applicant_constrains(self):
for rec in self:
diff --git a/addons_extensions/hr_recruitment_extended/models/hr_job_recruitment.py b/addons_extensions/hr_recruitment_extended/models/hr_job_recruitment.py
index cf3f351f4..6bec277ce 100644
--- a/addons_extensions/hr_recruitment_extended/models/hr_job_recruitment.py
+++ b/addons_extensions/hr_recruitment_extended/models/hr_job_recruitment.py
@@ -1,22 +1,34 @@
-from odoo import models, fields, api, _
-from datetime import date
-from odoo.exceptions import ValidationError
-from datetime import timedelta
-import datetime
+from odoo import models, fields, api, _
+from datetime import date
+from odoo.exceptions import ValidationError
+from datetime import timedelta
+import datetime
+import re
+import unicodedata
-class HRJobRecruitment(models.Model):
+class HRJobRecruitment(models.Model):
_name = 'hr.job.recruitment'
_description = 'Recruitment'
_inherit = ['mail.thread', 'mail.activity.mixin']
_inherits = {'hr.job': 'job_id'}
_rec_name = 'recruitment_sequence'
- active = fields.Boolean(default=True)
+ active = fields.Boolean(default=True)
+ hide_chatter_suggestion = fields.Boolean(string="Hide Chatter Suggestions", default=False, tracking=True)
- _sql_constraints = [
- ('unique_recruitment_sequence', 'UNIQUE(recruitment_sequence)', 'Recruitment sequence must be unique!')
- ]
+ _sql_constraints = [
+ ('unique_recruitment_sequence', 'UNIQUE(recruitment_sequence)', 'Recruitment sequence must be unique!')
+ ]
+ _SKILL_ALIAS_GROUPS = {
+ 'python': {'python', 'python3', 'py'},
+ 'postgresql': {'postgresql', 'postgres', 'postgre', 'pgsql', 'psql', 'pgadmin'},
+ 'javascript': {'javascript', 'js', 'nodejs', 'node'},
+ 'typescript': {'typescript', 'ts'},
+ 'react': {'react', 'reactjs'},
+ 'vue': {'vue', 'vuejs'},
+ 'angular': {'angular', 'angularjs'},
+ }
def _get_first_stage(self):
"""This function is used to fetch the starting stage"""
@@ -154,14 +166,208 @@ class HRJobRecruitment(models.Model):
tracking=True, help="The Recruiter will be the default value for all Applicants in this job \
position. The Recruiter is automatically added to all meetings with the Applicant.")
interviewer_ids = fields.Many2many('res.users', string='Interviewers', domain="[('share', '=', False), ('company_ids', 'in', company_id)]", tracking=True, help="The Interviewers set on the job position can see all Applicants in it. They have access to the information, the attachments, the meeting management and they can refuse him. You don't need to have Recruitment rights to be set as an interviewer.")
- skill_ids = fields.Many2many('hr.skill','hr_job_recruitment_hr_primary_skill_rel','job_id', 'user_id', string="Primary Skills", tracking=True)
+ skill_ids = fields.Many2many('hr.skill','hr_job_recruitment_hr_primary_skill_rel','job_id', 'user_id', string="Primary Skills", tracking=True)
address_id = fields.Many2one(
'res.partner', "Job Location", default=_default_address_id,
domain="[('is_company','=',True),('contact_type','=',recruitment_type)]",
help="Select the location where the applicant will work. Addresses listed here are defined on the company's contact information.", exportable=False, tracking=True)
- recruitment_type = fields.Selection([('internal','In-House'),('external','Client-Side')], required=True, default='internal', tracking=True)
- requested_by = fields.Many2one('res.partner', string="Requested By",
- default=lambda self: self.env.user.partner_id, domain="[('contact_type','=',recruitment_type)]", tracking=True)
+ recruitment_type = fields.Selection([('internal','In-House'),('external','Client-Side')], required=True, default='internal', tracking=True)
+ requested_by = fields.Many2one('res.partner', string="Requested By",
+ default=lambda self: self.env.user.partner_id, domain="[('contact_type','=',recruitment_type)]", tracking=True)
+
+ def action_toggle_chatter_visibility(self):
+ for record in self:
+ record.hide_chatter_suggestion = not record.hide_chatter_suggestion
+ return {'type': 'ir.actions.client', 'tag': 'reload'}
+
+ def _normalize_skill_name(self, skill_name):
+ normalized_name = unicodedata.normalize('NFKD', skill_name or '')
+ normalized_name = normalized_name.encode('ascii', 'ignore').decode('ascii').lower()
+ normalized_name = re.sub(r'[^a-z0-9]+', '', normalized_name)
+ if not normalized_name:
+ return ''
+ for canonical_name, aliases in self._SKILL_ALIAS_GROUPS.items():
+ if normalized_name in aliases or any(alias in normalized_name for alias in aliases if len(alias) > 3):
+ return canonical_name
+ return normalized_name
+
+ def _get_normalized_skill_name_map(self, skill_names):
+ normalized_map = {}
+ for skill_name in skill_names:
+ normalized_name = self._normalize_skill_name(skill_name)
+ if normalized_name and normalized_name not in normalized_map:
+ normalized_map[normalized_name] = skill_name
+ return normalized_map
+
+ def _get_skill_match_percentages_from_names(self, primary_skill_names, secondary_skill_names, candidate_skill_names):
+ self.ensure_one()
+ candidate_skill_map = self._get_normalized_skill_name_map(candidate_skill_names)
+ primary_skill_map = self._get_normalized_skill_name_map(primary_skill_names)
+ secondary_skill_map = self._get_normalized_skill_name_map(secondary_skill_names)
+ all_skill_map = {**primary_skill_map, **secondary_skill_map}
+
+ def _percentage(required_skill_map):
+ if not required_skill_map:
+ return 0.0
+ return round(
+ (len(set(required_skill_map) & set(candidate_skill_map)) / len(required_skill_map)) * 100,
+ 2,
+ )
+
+ matching_skill_keys = set(all_skill_map) & set(candidate_skill_map)
+ missing_skill_keys = set(all_skill_map) - set(candidate_skill_map)
+
+ return {
+ 'primary_skill_match_percentage': _percentage(primary_skill_map),
+ 'secondary_skill_match_percentage': _percentage(secondary_skill_map),
+ 'overall_skill_match_percentage': _percentage(all_skill_map),
+ 'matching_skill_names': [all_skill_map[key] for key in matching_skill_keys],
+ 'missing_skill_names': [all_skill_map[key] for key in missing_skill_keys],
+ }
+
+ def _get_skill_match_percentages(self, candidate_skills, primary_skill_names=None, secondary_skill_names=None):
+ self.ensure_one()
+ primary_skill_names = primary_skill_names if primary_skill_names is not None else self.skill_ids.mapped('name')
+ secondary_skill_names = secondary_skill_names if secondary_skill_names is not None else self.secondary_skill_ids.mapped('name')
+ candidate_skill_names = candidate_skills.mapped('name')
+ return self._get_skill_match_percentages_from_names(
+ primary_skill_names,
+ secondary_skill_names,
+ candidate_skill_names,
+ )
+
+ def _prepare_candidate_pool_match_payload(self, candidate, primary_skill_names, secondary_skill_names):
+ self.ensure_one()
+ percentages = self._get_skill_match_percentages(
+ candidate.skill_ids,
+ primary_skill_names=primary_skill_names,
+ secondary_skill_names=secondary_skill_names,
+ )
+ return {
+ 'candidate_id': candidate.id,
+ 'candidate_name': candidate.partner_name or candidate.display_name,
+ 'candidate_sequence': getattr(candidate, 'candidate_sequence', False),
+ 'email_from': candidate.email_from,
+ 'partner_phone': candidate.partner_phone,
+ 'matching_skill_names': sorted(percentages['matching_skill_names']),
+ 'missing_skill_names': sorted(percentages['missing_skill_names']),
+ 'primary_skill_match_percentage': percentages['primary_skill_match_percentage'],
+ 'secondary_skill_match_percentage': percentages['secondary_skill_match_percentage'],
+ 'overall_skill_match_percentage': percentages['overall_skill_match_percentage'],
+ }
+
+ def _prepare_applicant_match_payload(self, applicant, primary_skill_names, secondary_skill_names):
+ self.ensure_one()
+ candidate = applicant.candidate_id
+ percentages = self._get_skill_match_percentages(
+ candidate.skill_ids,
+ primary_skill_names=primary_skill_names,
+ secondary_skill_names=secondary_skill_names,
+ ) if candidate else {
+ 'primary_skill_match_percentage': 0.0,
+ 'secondary_skill_match_percentage': 0.0,
+ 'overall_skill_match_percentage': 0.0,
+ 'matching_skill_names': [],
+ 'missing_skill_names': [],
+ }
+ return {
+ 'applicant_id': applicant.id,
+ 'applicant_name': applicant.partner_name or applicant.display_name,
+ 'candidate_id': candidate.id if candidate else False,
+ 'candidate_sequence': getattr(candidate, 'candidate_sequence', False) if candidate else False,
+ 'email_from': applicant.email_from or (candidate.email_from if candidate else False),
+ 'partner_phone': applicant.partner_phone or (candidate.partner_phone if candidate else False),
+ 'recruitment_stage_name': applicant.recruitment_stage_id.display_name,
+ 'matching_skill_names': sorted(percentages['matching_skill_names']),
+ 'missing_skill_names': sorted(percentages['missing_skill_names']),
+ 'primary_skill_match_percentage': percentages['primary_skill_match_percentage'],
+ 'secondary_skill_match_percentage': percentages['secondary_skill_match_percentage'],
+ 'overall_skill_match_percentage': percentages['overall_skill_match_percentage'],
+ }
+
+ def get_candidate_pool_matches_data(self, primary_skill_names=None, secondary_skill_names=None):
+ self.ensure_one()
+ primary_skill_names = primary_skill_names if primary_skill_names is not None else self.skill_ids.mapped('name')
+ secondary_skill_names = secondary_skill_names if secondary_skill_names is not None else self.secondary_skill_ids.mapped('name')
+ existing_candidate_ids = self.application_ids.candidate_id.ids
+ candidates = self.env['hr.candidate'].search([
+ ('id', 'not in', existing_candidate_ids),
+ ])
+
+ ranked_candidates = []
+ for candidate in candidates:
+ ranked_candidates.append(
+ self._prepare_candidate_pool_match_payload(candidate, primary_skill_names, secondary_skill_names)
+ )
+
+ ranked_applicants = []
+ for applicant in self.application_ids.sorted(lambda rec: (
+ -(rec.overall_skill_match_percentage or 0.0),
+ -(rec.primary_skill_match_percentage or 0.0),
+ -(rec.secondary_skill_match_percentage or 0.0),
+ rec.partner_name or rec.display_name or '',
+ )):
+ ranked_applicants.append(
+ self._prepare_applicant_match_payload(applicant, primary_skill_names, secondary_skill_names)
+ )
+
+ ranked_candidates.sort(
+ key=lambda item: (
+ item['overall_skill_match_percentage'],
+ item['primary_skill_match_percentage'],
+ item['secondary_skill_match_percentage'],
+ item['candidate_name'] or '',
+ ),
+ reverse=True,
+ )
+ ranked_applicants.sort(
+ key=lambda item: (
+ item['overall_skill_match_percentage'],
+ item['primary_skill_match_percentage'],
+ item['secondary_skill_match_percentage'],
+ item['applicant_name'] or '',
+ ),
+ reverse=True,
+ )
+
+ return {
+ 'job_recruitment_id': self.id,
+ 'job_recruitment_name': self.display_name,
+ 'primary_skill_names': list(self._get_normalized_skill_name_map(primary_skill_names).values()),
+ 'secondary_skill_names': list(self._get_normalized_skill_name_map(secondary_skill_names).values()),
+ 'candidate_count': len(ranked_candidates),
+ 'applicant_count': len(ranked_applicants),
+ 'candidates': ranked_candidates,
+ 'applicants': ranked_applicants,
+ }
+
+ def action_add_candidate_to_recruitment(self, candidate_id):
+ self.ensure_one()
+ candidate = self.env['hr.candidate'].browse(candidate_id).exists()
+ if not candidate:
+ raise ValidationError(_("The selected candidate no longer exists."))
+
+ existing_applicant = self.application_ids.filtered(lambda applicant: applicant.candidate_id == candidate)[:1]
+ if existing_applicant:
+ return {
+ 'applicant_id': existing_applicant.id,
+ 'already_exists': True,
+ }
+
+ applicant_vals = {
+ 'candidate_id': candidate.id,
+ 'partner_name': candidate.partner_name or candidate.display_name,
+ 'email_from': candidate.email_from,
+ 'partner_phone': candidate.partner_phone,
+ 'hr_job_recruitment': self.id,
+ 'user_id': self.user_id.id,
+ 'company_id': candidate.company_id.id or self.company_id.id,
+ }
+ applicant = self.env['hr.applicant'].create(applicant_vals)
+ return {
+ 'applicant_id': applicant.id,
+ 'already_exists': False,
+ }
@api.onchange('recruitment_type')
def _onchange_recruitment_type(self):
diff --git a/addons_extensions/hr_recruitment_extended/models/hr_recruitment.py b/addons_extensions/hr_recruitment_extended/models/hr_recruitment.py
index a968e7a8b..60a9c0b80 100644
--- a/addons_extensions/hr_recruitment_extended/models/hr_recruitment.py
+++ b/addons_extensions/hr_recruitment_extended/models/hr_recruitment.py
@@ -11,14 +11,15 @@ import datetime
# hiring_history = fields.One2many('recruitment.status.history', 'job_id', string='History')
-class HrCandidate(models.Model):
- _inherit = "hr.candidate"
+class HrCandidate(models.Model):
+ _inherit = "hr.candidate"
_sql_constraints = [
('unique_candidate_sequence', 'UNIQUE(candidate_sequence)', 'Candidate sequence must be unique!'),
]
#personal Details
- candidate_sequence = fields.Char(string='Candidate Sequence', readonly=False, default='/', copy=False)
+ candidate_sequence = fields.Char(string='Candidate Sequence', readonly=False, default='/', copy=False)
+ hide_chatter_suggestion = fields.Boolean(string="Hide Chatter Suggestions", default=False, tracking=True)
first_name = fields.Char(string='First Name',required=False, help="This is the person's first name, given at birth or during a naming ceremony. It’s the name people use to address you.")
middle_name = fields.Char(string='Middle Name', help="This is an extra name that comes between the first name and last name. Not everyone has a middle name")
@@ -30,8 +31,13 @@ class HrCandidate(models.Model):
resume_type = fields.Char()
resume_name = fields.Char()
- applications_stages_stat = fields.Many2many('application.stage.status',string="Applications History", compute="_compute_applications_stages_stat")
- # availability_status = fields.Selection([('available','Available'),('not_available','Not Available'),('hired','Hired'),('abscond','Abscond')])
+ applications_stages_stat = fields.Many2many('application.stage.status',string="Applications History", compute="_compute_applications_stages_stat")
+ # availability_status = fields.Selection([('available','Available'),('not_available','Not Available'),('hired','Hired'),('abscond','Abscond')])
+
+ def action_toggle_chatter_visibility(self):
+ for record in self:
+ record.hide_chatter_suggestion = not record.hide_chatter_suggestion
+ return {'type': 'ir.actions.client', 'tag': 'reload'}
@api.onchange('resume')
@@ -586,4 +592,4 @@ class ApplicationsStageStatus(models.Model):
WHERE
a.active = 't' or a.active = 'f'
);
- """ % (self._table))
\ No newline at end of file
+ """ % (self._table))
diff --git a/addons_extensions/hr_recruitment_extended/security/ir.model.access.csv b/addons_extensions/hr_recruitment_extended/security/ir.model.access.csv
index 0489a3da2..f41605a76 100644
--- a/addons_extensions/hr_recruitment_extended/security/ir.model.access.csv
+++ b/addons_extensions/hr_recruitment_extended/security/ir.model.access.csv
@@ -28,7 +28,7 @@ access_hr_recruitment_stage_hr,hr.recruitment.stage.hr,hr_recruitment.model_hr_r
access_application_stage_status,application.stage.status,model_application_stage_status,base.group_user,1,1,1,1
-access_ats_invite_mail_template_wizard,ats.invite.mail.template.wizard.user,hr_recruitment_extended.model_ats_invite_mail_template_wizard,,1,1,1,1
-access_client_submission_mails_template_wizard,client.submission.mails.template.wizard.user,hr_recruitment_extended.model_client_submission_mails_template_wizard,,1,1,1,1
-access_hr_application_public,hr.applicant.public.access,hr_recruitment.model_hr_applicant,base.group_public,1,0,0,0
-access_hr_application_group_hr,hr.applicant.hr.access,hr_recruitment.model_hr_applicant,hr.group_hr_manager,1,1,0,0
\ No newline at end of file
+access_ats_invite_mail_template_wizard,ats.invite.mail.template.wizard.user,hr_recruitment_extended.model_ats_invite_mail_template_wizard,,1,1,1,1
+access_client_submission_mails_template_wizard,client.submission.mails.template.wizard.user,hr_recruitment_extended.model_client_submission_mails_template_wizard,,1,1,1,1
+access_hr_application_public,hr.applicant.public.access,hr_recruitment.model_hr_applicant,base.group_public,1,0,0,0
+access_hr_application_group_hr,hr.applicant.hr.access,hr_recruitment.model_hr_applicant,hr.group_hr_manager,1,1,0,0
diff --git a/addons_extensions/hr_recruitment_extended/static/src/js/recruitment_match_panel.js b/addons_extensions/hr_recruitment_extended/static/src/js/recruitment_match_panel.js
new file mode 100644
index 000000000..d5bc89689
--- /dev/null
+++ b/addons_extensions/hr_recruitment_extended/static/src/js/recruitment_match_panel.js
@@ -0,0 +1,463 @@
+/** @odoo-module **/
+
+import { _t } from "@web/core/l10n/translation";
+import { patch } from "@web/core/utils/patch";
+import { useService } from "@web/core/utils/hooks";
+import { onMounted, onPatched, onWillUnmount } from "@odoo/owl";
+import { RecruitmentFormController } from "@hr_recruitment/views/recruitment_form_controller";
+
+function escapeHtml(value) {
+ return String(value || "")
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """)
+ .replaceAll("'", "'");
+}
+
+patch(RecruitmentFormController.prototype, {
+ setup() {
+ super.setup();
+ this.actionService = useService("action");
+ this.notification = useService("notification");
+ this.orm = useService("orm");
+ this._matchPanelOpened = false;
+ this._matchPanelLoading = false;
+ this._matchPanelData = null;
+ this._matchPanelSearchTerm = "";
+ this._matchPanelActiveTab = "candidates";
+ this._matchPanelAddingCandidateId = null;
+
+ onMounted(() => this._syncRecruitmentMatchPanel());
+ onPatched(() => this._syncRecruitmentMatchPanel());
+ onWillUnmount(() => this._removeRecruitmentMatchPanelArtifacts());
+ },
+
+ _getRecruitmentMatchPanelButton() {
+ return document.querySelector(`.o_hr_match_fab[data-controller-id="${this.__owl__.id}"]`);
+ },
+
+ _getRecruitmentMatchPanel() {
+ return document.querySelector(`.o_hr_match_panel[data-controller-id="${this.__owl__.id}"]`);
+ },
+
+ _getCurrentSkillNames(fieldName) {
+ const fieldValue = this.model.root.data[fieldName];
+ if (!fieldValue || !Array.isArray(fieldValue.records)) {
+ return [];
+ }
+ return fieldValue.records
+ .map((record) => {
+ const recordData = record.data || {};
+ return recordData.name || recordData.display_name || record.display_name || "";
+ })
+ .filter(Boolean);
+ },
+
+ async _fetchRecruitmentMatchPanelData() {
+ const resId = this.model.root.resId;
+ if (!resId) {
+ this.notification.add(_t("Save the recruitment first to view candidate pool matches."), {
+ type: "warning",
+ });
+ return null;
+ }
+ return this.orm.call("hr.job.recruitment", "get_candidate_pool_matches_data", [[resId], this._getCurrentSkillNames("skill_ids"), this._getCurrentSkillNames("secondary_skill_ids")]);
+ },
+
+ async _openRecruitmentMatchPanel() {
+ this._matchPanelOpened = true;
+ this._ensureRecruitmentMatchPanel();
+ await this._loadRecruitmentMatchPanelData();
+ },
+
+ _closeRecruitmentMatchPanel() {
+ this._matchPanelOpened = false;
+ const panel = this._getRecruitmentMatchPanel();
+ if (panel) {
+ panel.classList.remove("o_hr_match_panel_open");
+ }
+ const button = this._getRecruitmentMatchPanelButton();
+ if (button) {
+ button.classList.remove("o_hr_match_fab_hidden");
+ }
+ },
+
+ _formatMatchPercentage(value) {
+ const numericValue = Number(value || 0);
+ return Number.isInteger(numericValue) ? String(numericValue) : numericValue.toFixed(2).replace(/\.00$/, "");
+ },
+
+ _normalizeSearchValue(value) {
+ return String(value || "").trim().toLowerCase();
+ },
+
+ _recordMatchesSearch(record) {
+ const searchTerm = this._normalizeSearchValue(this._matchPanelSearchTerm);
+ if (!searchTerm) {
+ return true;
+ }
+ const haystack = [
+ record.candidate_name,
+ record.applicant_name,
+ record.candidate_sequence,
+ record.email_from,
+ record.partner_phone,
+ record.recruitment_stage_name,
+ ...(record.matching_skill_names || []),
+ ...(record.missing_skill_names || []),
+ ]
+ .filter(Boolean)
+ .join(" ")
+ .toLowerCase();
+ return haystack.includes(searchTerm);
+ },
+
+ async _loadRecruitmentMatchPanelData() {
+ if (!this._matchPanelOpened) {
+ return;
+ }
+ this._matchPanelLoading = true;
+ this._renderRecruitmentMatchPanel();
+ try {
+ this._matchPanelData = await this._fetchRecruitmentMatchPanelData();
+ } catch (error) {
+ this.notification.add(_t("Unable to load candidate pool matches."), { type: "danger" });
+ this._matchPanelData = {
+ candidates: [],
+ primary_skill_names: [],
+ secondary_skill_names: [],
+ candidate_count: 0,
+ };
+ } finally {
+ this._matchPanelLoading = false;
+ this._renderRecruitmentMatchPanel();
+ }
+ },
+
+ async _addCandidateToRecruitment(candidateId) {
+ if (!candidateId || this._matchPanelAddingCandidateId) {
+ return;
+ }
+ this._matchPanelAddingCandidateId = candidateId;
+ this._renderRecruitmentMatchPanel();
+ try {
+ const result = await this.orm.call("hr.job.recruitment", "action_add_candidate_to_recruitment", [[this.model.root.resId], candidateId]);
+ this.notification.add(
+ result.already_exists
+ ? _t("Candidate is already linked to this recruitment.")
+ : _t("Candidate added to applicants."),
+ { type: result.already_exists ? "warning" : "success" }
+ );
+ this._matchPanelActiveTab = "applicants";
+ await this._loadRecruitmentMatchPanelData();
+ } catch (error) {
+ this.notification.add(_t("Unable to add candidate to this recruitment."), { type: "danger" });
+ } finally {
+ this._matchPanelAddingCandidateId = null;
+ this._renderRecruitmentMatchPanel();
+ }
+ },
+
+ _renderSkillTags(skillNames, className) {
+ if (!skillNames.length) {
+ return `${escapeHtml(_t("None"))}`;
+ }
+ return skillNames
+ .map((skillName) => `${escapeHtml(skillName)}`)
+ .join("");
+ },
+
+ _renderEmptyState(title, description) {
+ return `
+
+
${escapeHtml(title)}
+
${escapeHtml(description)}
+
+ `;
+ },
+
+ _renderMatchCard(record, index, type) {
+ const isApplicant = type === "applicants";
+ const title = isApplicant ? record.applicant_name : record.candidate_name;
+ const identifier = isApplicant ? record.applicant_id : record.candidate_id;
+ const actionLabel = isApplicant ? _t("Open Applicant") : _t("Add Applicant");
+ const actionIcon = isApplicant ? "fa-external-link" : "fa-plus";
+ const actionClass = isApplicant ? "o_hr_match_action_secondary" : "o_hr_match_action_primary";
+ const metaLine = [record.candidate_sequence, isApplicant ? record.recruitment_stage_name : null]
+ .filter(Boolean)
+ .join(" • ");
+ return `
+
+
+
+
#${index + 1}
+
+
${escapeHtml(title || _t("Unnamed Candidate"))}
+
${escapeHtml(metaLine || _t("Profile available"))}
+
+
+
+
+ ${escapeHtml(this._formatMatchPercentage(record.overall_skill_match_percentage))}%
+ ${escapeHtml(_t("match"))}
+
+
+
+
+
+
+ ${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 = `
+
+
+
+
+
+
+
+ ${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 @@
-