From f698203a7f5830de384ad3bafe28cd0470e03590 Mon Sep 17 00:00:00 2001 From: karuna Date: Tue, 28 Jul 2026 10:49:12 +0530 Subject: [PATCH] Updated grace period, recruitment auto doc, and shift swap --- .../grace_period/models/attendance_data.py | 246 ++++++++++-------- .../grace_period/views/attendance_data.xml | 7 +- .../wizard/hr_recruitment_auto_doc_wizard.py | 209 ++++++++++++++- .../hr_recruitment_auto_doc_wizard_views.xml | 84 +++++- .../roster_management/models/shift_swap.py | 15 ++ 5 files changed, 430 insertions(+), 131 deletions(-) diff --git a/addons_extensions/grace_period/models/attendance_data.py b/addons_extensions/grace_period/models/attendance_data.py index 85af345f2..bccfa6d11 100644 --- a/addons_extensions/grace_period/models/attendance_data.py +++ b/addons_extensions/grace_period/models/attendance_data.py @@ -1,4 +1,5 @@ from odoo import api, fields, models, tools, _ +from odoo.exceptions import UserError class AttendanceAnalytics(models.Model): @@ -64,6 +65,8 @@ class AttendanceAnalytics(models.Model): ('absent', 'Absent'), + ('invalid_attendance', 'Invalid Attendance'), + ('half_day', 'Half Day'), ('late_in', 'Late In'), @@ -192,7 +195,7 @@ class AttendanceAnalytics(models.Model): for rec in self: if rec.status == 'present': rec.color = 10 - elif rec.status == 'absent': + elif rec.status in ('absent', 'invalid_attendance'): rec.color = 1 elif rec.status == 'half_day': rec.color = 2 @@ -280,6 +283,11 @@ class AttendanceAnalytics(models.Model): def action_create_shiftswap_request(self): self.ensure_one() + if self.date and self.date < fields.Date.context_today(self): + raise UserError( + _("Shift Swap cannot be requested for past dates.") + ) + request = self.env['shift.swap.request'].search([ ('employee_id', '=', self.employee_id.id), ('roster_date', '=', self.date), @@ -341,7 +349,11 @@ class AttendanceAnalytics(models.Model): att.employee_id, - DATE(att.check_in) + DATE( + att.check_in + AT TIME ZONE 'UTC' + AT TIME ZONE 'Asia/Kolkata' + ) AS attendance_date, MIN(att.check_in) @@ -350,6 +362,18 @@ class AttendanceAnalytics(models.Model): MAX(att.check_out) AS max_check_out, + MIN( + att.check_in + AT TIME ZONE 'UTC' + AT TIME ZONE 'Asia/Kolkata' + ) AS min_check_in_local, + + MAX( + att.check_out + AT TIME ZONE 'UTC' + AT TIME ZONE 'Asia/Kolkata' + ) AS max_check_out_local, + SUM(att.worked_hours) AS worked_hours @@ -359,7 +383,11 @@ class AttendanceAnalytics(models.Model): att.employee_id, - DATE(att.check_in) + DATE( + att.check_in + AT TIME ZONE 'UTC' + AT TIME ZONE 'Asia/Kolkata' + ) ), @@ -432,6 +460,32 @@ class AttendanceAnalytics(models.Model): WHERE rl.date_from IS NOT NULL AND rl.date_to IS NOT NULL AND rl.resource_id IS NULL + ), + + calendar_day_schedule AS ( + + SELECT + + rca.calendar_id, + + rca.dayofweek, + + MIN(rca.hour_from) * 60 + AS shift_start_minutes, + + MAX(rca.hour_to) * 60 + AS shift_end_minutes, + + SUM(rca.hour_to - rca.hour_from) + AS hours_per_day + + FROM resource_calendar_attendance rca + + GROUP BY + + rca.calendar_id, + + rca.dayofweek ) SELECT @@ -450,20 +504,20 @@ class AttendanceAnalytics(models.Model): ats.worked_hours, 0 ) AS worked_hours, - rc.hours_per_day AS hours_per_day, + es.hours_per_day AS hours_per_day, ( - rc.hours_per_day + es.hours_per_day + COALESCE(rc.over_time_hrs, 0) ) AS allowed_ot_limit, CASE - WHEN ats.worked_hours > rc.hours_per_day + WHEN ats.worked_hours > es.hours_per_day THEN - ats.worked_hours - rc.hours_per_day + ats.worked_hours - es.hours_per_day ELSE 0 @@ -474,7 +528,7 @@ class AttendanceAnalytics(models.Model): WHEN ats.worked_hours > ( - rc.hours_per_day + es.hours_per_day + COALESCE(rc.over_time_hrs, 0) ) @@ -527,19 +581,11 @@ class AttendanceAnalytics(models.Model): AS department_grace_period, ( - ( - rc.shift_start_time * 60 - ) - + - COALESCE( - dg.grace_period, - rc.late_grace_period, - 0 - ) + es.shift_start_minutes ) AS expected_check_in, ( - rc.shift_end_time * 60 + es.shift_end_minutes ) AS expected_check_out, CASE @@ -551,28 +597,20 @@ class AttendanceAnalytics(models.Model): ( ( EXTRACT( - HOUR FROM ats.min_check_in + HOUR FROM ats.min_check_in_local ) * 60 ) + EXTRACT( MINUTE - FROM ats.min_check_in + FROM ats.min_check_in_local ) ) - ( - ( - rc.shift_start_time * 60 - ) - + - COALESCE( - dg.grace_period, - rc.late_grace_period, - 0 - ) + es.shift_start_minutes ), 0 @@ -591,28 +629,20 @@ class AttendanceAnalytics(models.Model): ( ( EXTRACT( - HOUR FROM ats.min_check_in + HOUR FROM ats.min_check_in_local ) * 60 ) + EXTRACT( MINUTE - FROM ats.min_check_in + FROM ats.min_check_in_local ) ) > ( - ( - rc.shift_start_time * 60 - ) - + - COALESCE( - dg.grace_period, - rc.late_grace_period, - 0 - ) + es.shift_start_minutes ) THEN TRUE @@ -624,7 +654,7 @@ class AttendanceAnalytics(models.Model): ( ( - rc.shift_end_time * 60 + es.shift_end_minutes ) + @@ -640,28 +670,20 @@ class AttendanceAnalytics(models.Model): ( EXTRACT( HOUR - FROM ats.min_check_in + FROM ats.min_check_in_local ) * 60 ) + EXTRACT( MINUTE - FROM ats.min_check_in + FROM ats.min_check_in_local ) ) - ( - ( - rc.shift_start_time * 60 - ) - + - COALESCE( - dg.grace_period, - rc.late_grace_period, - 0 - ) + es.shift_start_minutes ), 0 @@ -671,7 +693,7 @@ class AttendanceAnalytics(models.Model): END - ) AS required_checkout_time, + ) / 60.0 AS required_checkout_time, CASE @@ -683,7 +705,7 @@ class AttendanceAnalytics(models.Model): ( ( - rc.shift_end_time * 60 + es.shift_end_minutes ) + @@ -699,29 +721,20 @@ class AttendanceAnalytics(models.Model): ( EXTRACT( HOUR - FROM ats.min_check_in + FROM ats.min_check_in_local ) * 60 ) + EXTRACT( MINUTE - FROM ats.min_check_in + FROM ats.min_check_in_local ) ) - ( - ( - rc.shift_start_time - * 60 - ) - + - COALESCE( - dg.grace_period, - rc.late_grace_period, - 0 - ) + es.shift_start_minutes ), 0 @@ -739,13 +752,13 @@ class AttendanceAnalytics(models.Model): ( EXTRACT( HOUR - FROM ats.max_check_out + FROM ats.max_check_out_local ) * 60 ) + EXTRACT( MINUTE - FROM ats.max_check_out + FROM ats.max_check_out_local ) ), @@ -767,13 +780,13 @@ class AttendanceAnalytics(models.Model): ( EXTRACT( HOUR - FROM ats.max_check_out + FROM ats.max_check_out_local ) * 60 ) + EXTRACT( MINUTE - FROM ats.max_check_out + FROM ats.max_check_out_local ) ) @@ -782,7 +795,7 @@ class AttendanceAnalytics(models.Model): ( ( - rc.shift_end_time * 60 + es.shift_end_minutes ) + @@ -798,29 +811,20 @@ class AttendanceAnalytics(models.Model): ( EXTRACT( HOUR - FROM ats.min_check_in + FROM ats.min_check_in_local ) * 60 ) + EXTRACT( MINUTE - FROM ats.min_check_in + FROM ats.min_check_in_local ) ) - ( - ( - rc.shift_start_time - * 60 - ) - + - COALESCE( - dg.grace_period, - rc.late_grace_period, - 0 - ) + es.shift_start_minutes ), 0 @@ -842,7 +846,7 @@ class AttendanceAnalytics(models.Model): WHEN ( ( - rc.shift_end_time * 60 + es.shift_end_minutes ) + @@ -858,29 +862,20 @@ class AttendanceAnalytics(models.Model): ( EXTRACT( HOUR - FROM ats.min_check_in + FROM ats.min_check_in_local ) * 60 ) + EXTRACT( MINUTE - FROM ats.min_check_in + FROM ats.min_check_in_local ) ) - ( - ( - rc.shift_start_time - * 60 - ) - + - COALESCE( - dg.grace_period, - rc.late_grace_period, - 0 - ) + es.shift_start_minutes ), 0 @@ -897,13 +892,13 @@ class AttendanceAnalytics(models.Model): ( EXTRACT( HOUR - FROM ats.max_check_out + FROM ats.max_check_out_local ) * 60 ) + EXTRACT( MINUTE - FROM ats.max_check_out + FROM ats.max_check_out_local ) ) @@ -932,35 +927,30 @@ class AttendanceAnalytics(models.Model): WHEN ats.min_check_in IS NULL THEN 'absent' - WHEN ats.worked_hours < (rc.hours_per_day / 2.0) + WHEN ats.worked_hours < 1.0 + THEN 'invalid_attendance' + + WHEN ats.worked_hours < (es.hours_per_day / 2.0) THEN 'half_day' WHEN ( ( EXTRACT( - HOUR FROM ats.min_check_in + HOUR FROM ats.min_check_in_local ) * 60 ) + EXTRACT( MINUTE - FROM ats.min_check_in + FROM ats.min_check_in_local ) ) > ( - ( - rc.shift_start_time * 60 - ) - + - COALESCE( - dg.grace_period, - rc.late_grace_period, - 0 - ) + es.shift_start_minutes ) THEN 'late_in' @@ -970,20 +960,20 @@ class AttendanceAnalytics(models.Model): ( EXTRACT( HOUR - FROM ats.max_check_out + FROM ats.max_check_out_local ) * 60 ) + EXTRACT( MINUTE - FROM ats.max_check_out + FROM ats.max_check_out_local ) ) < ( - rc.shift_end_time * 60 + es.shift_end_minutes ) THEN 'early_out' @@ -1026,6 +1016,36 @@ class AttendanceAnalytics(models.Model): LEFT JOIN resource_calendar rc ON rc.id = ed.resource_calendar_id + LEFT JOIN calendar_day_schedule cds + ON cds.calendar_id = ed.resource_calendar_id + AND cds.dayofweek = ( + ( + ( + EXTRACT(DOW FROM ed.date)::integer + + 6 + ) % 7 + )::text + ) + + LEFT JOIN LATERAL ( + SELECT + COALESCE( + NULLIF(rc.shift_start_time * 60, 0), + cds.shift_start_minutes, + 0 + ) AS shift_start_minutes, + COALESCE( + NULLIF(rc.shift_end_time * 60, 0), + cds.shift_end_minutes, + 0 + ) AS shift_end_minutes, + COALESCE( + NULLIF(rc.hours_per_day, 0), + cds.hours_per_day, + 0 + ) AS hours_per_day + ) es ON TRUE + LEFT JOIN department_grace dg ON dg.calendar_id = ed.resource_calendar_id diff --git a/addons_extensions/grace_period/views/attendance_data.xml b/addons_extensions/grace_period/views/attendance_data.xml index badbd4118..69314f192 100644 --- a/addons_extensions/grace_period/views/attendance_data.xml +++ b/addons_extensions/grace_period/views/attendance_data.xml @@ -7,7 +7,8 @@ attendance.analytics.list attendance.analytics - + @@ -28,7 +29,7 @@ @@ -146,4 +147,4 @@ action="action_attendance_analytics" sequence="-51"/> - \ No newline at end of file + 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 index 085fe5274..175f49e87 100644 --- 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 @@ -152,7 +152,10 @@ class HrRecruitmentAutoDocWizard(models.TransientModel): 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) + line.write(dict( + line._prepare_editable_vals(parsed_data), + extracted_payload=json.dumps(parsed_data, indent=2, ensure_ascii=False), + )) try: processed += 1 @@ -255,6 +258,7 @@ class HrRecruitmentAutoDocWizard(models.TransientModel): try: parsed_data = json.loads(line.extracted_payload) + parsed_data = line._get_edited_parsed_data(parsed_data) with self.env.cr.savepoint(): @@ -642,10 +646,13 @@ class HrRecruitmentAutoDocWizard(models.TransientModel): 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() + phone_values = [] + for phone_candidate in [data.get("phone"), data.get("alternate_phone")] + phone_matches: + phone_value = self._clean_resume_phone_value(phone_candidate) + if phone_value and phone_value not in phone_values: + phone_values.append(phone_value) + data["phone"] = phone_values[0] if phone_values else False + data["alternate_phone"] = phone_values[1] if len(phone_values) > 1 else False linkedin_match = re.search(r"(https?://(?:www\.)?linkedin\.com/[^\s]+)", extracted_text, re.I) if linkedin_match: @@ -654,11 +661,12 @@ class HrRecruitmentAutoDocWizard(models.TransientModel): if not data.get("total_experience_years"): data["total_experience_years"] = self._guess_total_experience(extracted_text) + resume_skills = self._normalize_resume_skill_values(data.get("skills") or [], extracted_text) data["skills"] = self.env[ "document.parser.service" ].validate_explicit_skills( extracted_text, - data.get("skills") or [] + resume_skills ) data["education_history"] = self._normalize_resume_list(data.get("education_history")) data["employer_history"] = self._normalize_resume_list(data.get("employer_history")) @@ -1431,8 +1439,57 @@ class HrRecruitmentAutoDocWizard(models.TransientModel): normalized = re.sub(r"[^\d+]", "", value) return normalized or False + def _clean_resume_phone_value(self, value): + if not value: + return False + raw_value = str(value).strip() + if re.fullmatch(r"(?:19|20)\d{2}\s*[-/]\s*(?:19|20)\d{2}", raw_value): + return False + normalized = self._normalize_phone(raw_value) + digits = re.sub(r"\D", "", normalized or "") + if len(digits) < 10 or len(digits) > 15: + return False + return normalized + + def _normalize_resume_skill_values(self, skills, extracted_text): + skill_parts = [] + for skill in skills: + for skill_part in self._split_resume_skill_value(skill): + normalized = self._normalize_skill_name(skill_part) + if normalized and self._is_valid_resume_skill(normalized, extracted_text): + skill_parts.append(normalized) + normalized_skills = [] + index = 0 + while index < len(skill_parts): + current_skill = skill_parts[index] + next_skill = skill_parts[index + 1] if index + 1 < len(skill_parts) else False + combined_skill = "%s %s" % (current_skill, next_skill) if next_skill else False + if combined_skill and self._is_valid_resume_skill(combined_skill, extracted_text): + normalized_skills.append(combined_skill) + index += 2 + continue + normalized_skills.append(current_skill) + index += 1 + return self._deduplicate_skill_names(normalized_skills) + + def _split_resume_skill_value(self, value): + value = re.sub(r"^[\s\-*\u2022•]+", "", str(value or "")).strip() + if not value: + return [] + if ":" in value: + label, value = [item.strip() for item in value.split(":", 1)] + if not re.search(r"(programming|frameworks?|databases?|analytics?|cloud|devops|tools?|technologies|skills?)", label, re.I): + value = "%s %s" % (label, value) + value = re.sub(r"\((?:advanced|intermediate|beginner|expert)\)", "", value, flags=re.I) + return [item for item in re.split(r"[,;|/]", value) if item.strip()] + def _normalize_skill_name(self, value): - value = re.sub(r"\s+", " ", (value or "")).strip(" -,:;") + value = re.sub(r"^[\s\-*\u2022•]+", "", value or "") + value = re.sub(r"\s+", " ", value).strip(" -,:;()") + if ":" in value: + label, skill_value = [item.strip() for item in value.split(":", 1)] + if re.search(r"(programming|frameworks?|databases?|analytics?|cloud|devops|tools?|technologies|skills?)", label, re.I): + value = skill_value value = re.sub(r"^[0-9.)\-(\s]+", "", value).strip() if not value: return False @@ -2034,13 +2091,12 @@ class HrRecruitmentAutoDocWizard(models.TransientModel): "
    ", ] for row in rows: - html_parts.append( - "
  • %s: %s
  • " % ( - escape(row["filename"]), - row["level"], - escape(row["message"]), - ) - ) + html_parts.append("
  • %s: %s" % ( + escape(row["filename"]), + row["level"], + escape(row["message"]), + )) + html_parts.append("
  • ") html_parts.extend(["
", ""]) return "".join(html_parts) @@ -2072,3 +2128,128 @@ class HrRecruitmentAutoDocWizardLine(models.TransientModel): extracted_payload = fields.Text(readonly=True) candidate_id = fields.Many2one("hr.candidate", readonly=True) applicant_id = fields.Many2one("hr.applicant", readonly=True) + target_model = fields.Selection(related="wizard_id.target_model", readonly=True) + + full_name = fields.Char() + first_name = fields.Char() + last_name = fields.Char() + email = fields.Char() + phone = fields.Char() + alternate_phone = fields.Char() + linkedin_profile = fields.Char() + current_location = fields.Char() + current_organization = fields.Char() + total_experience_years = fields.Float() + relevant_experience_years = fields.Float() + notice_period = fields.Char() + degree = fields.Char() + skills_text = fields.Text(string="Skills") + summary = fields.Text() + + request_id = fields.Char(string="Request ID") + start_date = fields.Char() + end_date = fields.Char() + site_location = fields.Char() + job_title = fields.Char() + job_summary = fields.Text() + requirements = fields.Text() + primary_skills_text = fields.Text(string="Primary Skills") + secondary_skills_text = fields.Text(string="Secondary Skills") + budget = fields.Char() + experience_years = fields.Float() + job_category = fields.Char() + + def action_save_line_changes(self): + self.ensure_one() + parsed_data = self._get_edited_parsed_data() + self.extracted_payload = json.dumps(parsed_data, indent=2, ensure_ascii=False) + return {"type": "ir.actions.act_window_close"} + + def _prepare_editable_vals(self, parsed_data): + parsed_data = parsed_data or {} + return { + "full_name": parsed_data.get("full_name"), + "first_name": parsed_data.get("first_name"), + "last_name": parsed_data.get("last_name"), + "email": parsed_data.get("email"), + "phone": parsed_data.get("phone"), + "alternate_phone": parsed_data.get("alternate_phone"), + "linkedin_profile": parsed_data.get("linkedin_profile"), + "current_location": parsed_data.get("current_location"), + "current_organization": parsed_data.get("current_organization"), + "total_experience_years": self._float_or_zero(parsed_data.get("total_experience_years")), + "relevant_experience_years": self._float_or_zero(parsed_data.get("relevant_experience_years")), + "notice_period": parsed_data.get("notice_period"), + "degree": parsed_data.get("degree"), + "skills_text": self._list_to_text(parsed_data.get("skills")), + "summary": parsed_data.get("summary"), + "request_id": parsed_data.get("request_id"), + "start_date": parsed_data.get("start_date"), + "end_date": parsed_data.get("end_date"), + "site_location": parsed_data.get("site_location"), + "job_title": parsed_data.get("job_title"), + "job_summary": parsed_data.get("job_summary"), + "requirements": parsed_data.get("requirements"), + "primary_skills_text": self._list_to_text(parsed_data.get("primary_skills")), + "secondary_skills_text": self._list_to_text(parsed_data.get("secondary_skills")), + "budget": parsed_data.get("budget"), + "experience_years": self._float_or_zero(parsed_data.get("experience_years")), + "job_category": parsed_data.get("job_category"), + } + + def _get_edited_parsed_data(self, base_data=None): + data = dict(base_data or {}) + if self.target_model == "job_recruitment": + data.update({ + "request_id": self.request_id, + "start_date": self.start_date, + "end_date": self.end_date, + "site_location": self.site_location, + "job_title": self.job_title, + "job_summary": self.job_summary, + "requirements": self.requirements, + "primary_skills": self._text_to_list(self.primary_skills_text), + "secondary_skills": self._text_to_list(self.secondary_skills_text), + "budget": self.budget, + "experience_years": self.experience_years, + "job_category": self.job_category, + }) + else: + data.update({ + "full_name": self.full_name, + "first_name": self.first_name, + "last_name": self.last_name, + "email": self.email, + "phone": self.phone, + "alternate_phone": self.alternate_phone, + "linkedin_profile": self.linkedin_profile, + "current_location": self.current_location, + "current_organization": self.current_organization, + "total_experience_years": self.total_experience_years, + "relevant_experience_years": self.relevant_experience_years, + "notice_period": self.notice_period, + "degree": self.degree, + "skills": self._text_to_list(self.skills_text), + "summary": self.summary, + }) + return data + + def _list_to_text(self, value): + if not value: + return False + if isinstance(value, list): + return "\n".join(str(item) for item in value if item not in (False, None, "")) + return str(value) + + def _text_to_list(self, value): + if not value: + return [] + return [item.strip(" -,\t") for item in str(value).splitlines() if item.strip(" -,\t")] + + def _float_or_zero(self, value): + if value in (False, None, ""): + return 0.0 + try: + return float(value) + except Exception: + return 0.0 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 index 83038e1b5..cf31d36c1 100644 --- 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 @@ -48,7 +48,89 @@ - + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
diff --git a/addons_extensions/roster_management/models/shift_swap.py b/addons_extensions/roster_management/models/shift_swap.py index 134632d53..c49747af2 100644 --- a/addons_extensions/roster_management/models/shift_swap.py +++ b/addons_extensions/roster_management/models/shift_swap.py @@ -34,9 +34,24 @@ class ShiftSwapRequest(models.Model): store=True ) + def _validate_roster_date_not_past(self): + today = fields.Date.context_today(self) + + for rec in self: + if rec.roster_date and rec.roster_date < today: + raise UserError( + _("Shift Swap cannot be requested for past dates.") + ) + + @api.constrains('roster_date') + def _check_roster_date_not_past(self): + self._validate_roster_date_not_past() + def action_submit(self): for rec in self: + rec._validate_roster_date_not_past() + if not rec.reason: raise UserError( _("Please enter reason.")