From 2f43fdf27e98bb4227d6aac474c95e63767273cb Mon Sep 17 00:00:00 2001 From: younes Date: Wed, 3 Sep 2025 14:37:44 +0100 Subject: [PATCH 1/2] ADD hr_attendance_excel_report module --- .../hr_attendance_excel_report/README.md | 217 ++++ .../hr_attendance_excel_report/__init__.py | 4 + .../__manifest__.py | 73 ++ .../data/default_status_config.xml | 109 ++ .../hr_attendance_excel_report/i18n/ar.po | 382 ++++++ .../models/__init__.py | 6 + .../models/attendance_report_config.py | 70 ++ .../models/attendance_status_config.py | 294 +++++ .../models/hr_employee_wizard_fields.py | 40 + .../models/wizard_helpers.py | 137 +++ .../security/ir.model.access.csv | 7 + .../security/security.xml | 36 + .../static/src/css/report_wizard.css | 394 ++++++ .../static/src/js/color_picker_widget.js | 216 ++++ .../views/attendance_report_config_views.xml | 225 ++++ .../views/attendance_status_config_views.xml | 199 +++ .../views/menu_views.xml | 32 + .../wizard/__init__.py | 3 + .../wizard/attendance_report_wizard.py | 1069 +++++++++++++++++ .../wizard/attendance_report_wizard_views.xml | 256 ++++ 20 files changed, 3769 insertions(+) create mode 100644 odex25_hr/hr_attendance_excel_report/README.md create mode 100644 odex25_hr/hr_attendance_excel_report/__init__.py create mode 100644 odex25_hr/hr_attendance_excel_report/__manifest__.py create mode 100644 odex25_hr/hr_attendance_excel_report/data/default_status_config.xml create mode 100644 odex25_hr/hr_attendance_excel_report/i18n/ar.po create mode 100644 odex25_hr/hr_attendance_excel_report/models/__init__.py create mode 100644 odex25_hr/hr_attendance_excel_report/models/attendance_report_config.py create mode 100644 odex25_hr/hr_attendance_excel_report/models/attendance_status_config.py create mode 100644 odex25_hr/hr_attendance_excel_report/models/hr_employee_wizard_fields.py create mode 100644 odex25_hr/hr_attendance_excel_report/models/wizard_helpers.py create mode 100644 odex25_hr/hr_attendance_excel_report/security/ir.model.access.csv create mode 100644 odex25_hr/hr_attendance_excel_report/security/security.xml create mode 100644 odex25_hr/hr_attendance_excel_report/static/src/css/report_wizard.css create mode 100644 odex25_hr/hr_attendance_excel_report/static/src/js/color_picker_widget.js create mode 100644 odex25_hr/hr_attendance_excel_report/views/attendance_report_config_views.xml create mode 100644 odex25_hr/hr_attendance_excel_report/views/attendance_status_config_views.xml create mode 100644 odex25_hr/hr_attendance_excel_report/views/menu_views.xml create mode 100644 odex25_hr/hr_attendance_excel_report/wizard/__init__.py create mode 100644 odex25_hr/hr_attendance_excel_report/wizard/attendance_report_wizard.py create mode 100644 odex25_hr/hr_attendance_excel_report/wizard/attendance_report_wizard_views.xml diff --git a/odex25_hr/hr_attendance_excel_report/README.md b/odex25_hr/hr_attendance_excel_report/README.md new file mode 100644 index 000000000..fe20f6dc0 --- /dev/null +++ b/odex25_hr/hr_attendance_excel_report/README.md @@ -0,0 +1,217 @@ +# HR Attendance Excel Report + +## Overview +This Odoo module provides comprehensive Excel attendance reports with customizable status codes, colors, and advanced filtering options. It integrates seamlessly with the existing HR attendance system to generate professional reports that match the format shown in the provided sample. + +## Features + +### 📊 **Report Generation** +- **Excel Output**: Professional Excel reports with cell formatting and colors +- **Configurable Layouts**: Choose which columns to include/exclude +- **Date Range Selection**: Flexible date filtering with quick month selections +- **Employee Filtering**: Filter by departments, branches, or specific employees +- **Summary Columns**: Automatic calculation of attendance totals, leaves, and absences + +### 🎨 **Customizable Status Codes** +- **Flexible Status System**: Configure unlimited status codes for different attendance situations +- **Color Coding**: Assign background and text colors to each status +- **Condition Mapping**: Link status codes to leave types, mission types, or custom conditions +- **Multi-language Support**: Arabic and English descriptions for each status + +### 📋 **Default Status Codes** +| Code | Arabic | English | Color | Condition | +|------|--------|---------|-------|-----------| +| **PH** | عطلة رسمية | Public Holiday | Gray | `public_holiday = True` | +| **AB** | غياب | Absent | Light Red | `is_absent = True` | +| **AL** | إجازة سنوية | Annual Leave | Light Blue | Leave type match | +| **SL** | إجازة مرضية | Sick Leave | Light Orange | Sick leave types | +| **ML** | إجازة زواج | Marriage Leave | Light Purple | Marriage leave | +| **BL** | إجازة وفاة | Bereavement Leave | Light Gray | Bereavement leave | +| **OD** | دوام ميداني | On Duty (Outside) | Light Yellow | Official mission | +| **TR** | تدريب | Training | Light Green | Training missions | +| **UL** | إجازة بدون راتب | Unpaid Leave | Light Salmon | Unpaid leave types | +| **ML/PL** | إجازة أمومة/أبوة | Maternity/Paternity | Light Beige | Maternity/Paternity leave | + +### 🔧 **Advanced Configuration** +- **Multiple Configurations**: Create different report templates for different needs +- **Custom Conditions**: Write Python expressions for complex status matching +- **Column Customization**: Show/hide employee information columns +- **Working Hours Format**: Display as time ranges (8:00-3:30) or decimal hours (7.5) + +## Installation + +### Prerequisites +- Odoo 14.0+ +- Python xlsxwriter library: `pip install xlsxwriter` + +### Steps +1. Copy the module to your Odoo addons directory +2. Update the app list: Settings → Apps → Update Apps List +3. Search for "HR Attendance Excel Report" +4. Click Install + +## Configuration + +### 1. **Report Configuration** +Navigate to: `HR → Attendance Reports → Configuration → Report Configurations` + +- Create a new configuration +- Set default colors for normal attendance +- Choose which employee information columns to include +- Configure working hours display format + +### 2. **Status Configuration** +Navigate to: `HR → Attendance Reports → Configuration → Status Configurations` + +For each status code: +- Set the code (e.g., "PH", "AB", "AL") +- Add Arabic and English descriptions +- Choose background and text colors +- Configure matching conditions: + - **Leave Types**: Link to specific hr.holidays.status records + - **Mission Types**: Link to hr.official.mission.type records + - **Custom Conditions**: Write Python expressions + +### 3. **Custom Condition Examples** +```python +# Late arrival (more than 30 minutes) +transaction.lateness > 0.5 + +# Early exit +transaction.early_exit > 0 + +# Absent without approved leave +transaction.is_absent and not transaction.leave_id + +# Working on weekend +transaction.date.weekday() >= 5 and transaction.sign_in > 0 + +# Overtime (more than 8 hours) +transaction.office_hours > 8.0 +``` + +## Usage + +### Generating Reports +1. Navigate to: `HR → Attendance Reports → Reports → Excel Attendance Report` +2. Select date range (quick buttons available for current/previous month) +3. Choose report configuration +4. Apply filters (employees, departments, branches) +5. Configure options: + - Group by Department + - Include Summary Columns + - Include Status Legend +6. Click "Generate Excel Report" + +### Report Structure +The generated Excel file contains: + +#### **Main Columns:** +- Employee Number (optional) +- Employee Name +- National ID (optional) +- Department (optional) +- Job Position (optional) +- Branch/Location (optional) + +#### **Daily Columns:** +- One column per day in the selected date range +- Color-coded cells based on attendance status +- Time ranges for normal attendance (e.g., "8:00 - 3:30") +- Status codes for special situations (e.g., "PH", "AL", "AB") + +#### **Summary Columns (optional):** +- Attendance Days +- Leave Days +- Emergency Leave Days +- Absence Days +- Actual Working Hours +- Additional Hours +- Late Hours +- Early Exit Hours + +#### **Legend (optional):** +- Color-coded explanation of all status codes +- Arabic and English descriptions + +## Technical Details + +### Database Integration +The module integrates with these existing Odoo models: +- `hr.attendance.transaction` - Main attendance data +- `hr.employee` - Employee information +- `hr.holidays` - Leave requests +- `hr.holidays.status` - Leave types +- `hr.official.mission` - Official missions +- `hr.personal.permission` - Personal permissions +- `hr.department` - Departments and branches + +### File Structure +``` +hr_attendance_excel_report/ +├── __manifest__.py +├── models/ +│ ├── attendance_report_config.py +│ └── attendance_status_config.py +├── wizard/ +│ └── attendance_report_wizard.py +├── views/ +│ ├── attendance_report_config_views.xml +│ ├── attendance_status_config_views.xml +│ ├── attendance_report_wizard_views.xml +│ └── menu_views.xml +├── data/ +│ └── default_status_config.xml +├── security/ +│ ├── ir.model.access.csv +│ └── security.xml +└── static/ + ├── src/css/report_wizard.css + └── src/js/color_picker_widget.js +``` + +### Security +- **HR User**: Can generate reports +- **HR Manager**: Can configure report settings and status codes +- **Multi-company**: Automatic filtering by company + +## Troubleshooting + +### Common Issues + +**1. xlsxwriter not found** +```bash +pip install xlsxwriter +# Restart Odoo server +``` + +**2. No employees found** +- Check employee filters (department, branch, employee selection) +- Ensure employees are active +- Verify company assignment + +**3. Empty cells in report** +- Check if attendance transactions exist for the date range +- Verify status configuration conditions +- Review employee working calendar + +**4. Status codes not appearing** +- Ensure status configurations are active +- Check condition type and mapping +- Verify sequence order (lower numbers have priority) + +### Performance Considerations +- Limit date ranges to 3 months maximum +- Use employee/department filters for large organizations +- The module is optimized for up to 500 employees per report + +## Support + +For issues, bugs, or feature requests, please contact the development team or create an issue in the project repository. + +## License +LGPL-3 + +--- + +**Note**: This module requires the base `hr`, `hr_holidays`, and attendance-related modules to be installed and properly configured. diff --git a/odex25_hr/hr_attendance_excel_report/__init__.py b/odex25_hr/hr_attendance_excel_report/__init__.py new file mode 100644 index 000000000..35e7c9600 --- /dev/null +++ b/odex25_hr/hr_attendance_excel_report/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- + +from . import models +from . import wizard diff --git a/odex25_hr/hr_attendance_excel_report/__manifest__.py b/odex25_hr/hr_attendance_excel_report/__manifest__.py new file mode 100644 index 000000000..6095d1d5a --- /dev/null +++ b/odex25_hr/hr_attendance_excel_report/__manifest__.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +{ + 'name': 'HR Attendance Excel Report with Smart Filtering', + 'version': '14.0.4.0.0', + 'category': 'Human Resources', + 'summary': 'Dynamic Excel attendance reports with configurable status mappings and enhanced flexibility', + 'description': """ +HR Attendance Excel Report - Enhanced with Smart Filtering +========================================================= + +This module provides comprehensive Excel reports for employee attendance with advanced smart filtering capabilities: + +🔥 KEY FEATURES: +=============== +- **Dynamic Status Mapping**: Link attendance statuses with leave types, permission types, and mission types +- **Smart Filtering**: Transaction-based filtering with intelligent shift detection +- **Advanced Excel Reports**: RTL support with 11 comprehensive summary columns +- **Interactive UI**: Simplified wizard with clickable statistics icons +- **Multi-language Support**: Complete Arabic and English translations + +🎯 CORE FUNCTIONALITY: +===================== +- Uses hr.attendance.transaction for accurate data filtering +- Dynamic calendar analysis for shift-based employee selection +- Configurable status codes with custom colors and conditions +- Real-time statistics and preview functions +- Enhanced validation and error handling + +📊 EXCEL FEATURES: +================= +- 11 summary columns (attendance, leaves, absences, permissions, etc.) +- Color-coded cells based on attendance status +- Official working hours display with proper RTL formatting +- Smart time display (sign-out first, then sign-in for Arabic) +- Company and shift information in headers + +🔧 TECHNICAL: +============ +- No hard-coded patterns - fully dynamic configuration +- Priority system for status determination +- Clean architecture with proper error handling +- Integration with HR modules (holidays, permissions, missions) + +Supported status types: Leave, Permission, Mission, Absent, Holiday, Attendance, Late/Early Exit + """, + 'author': 'AI Assistant - Enhanced', + 'website': '', + 'depends': [ + 'hr_base_reports', + 'hr_holidays_public', + ], + 'external_dependencies': { + 'python': ['xlsxwriter'], + }, + 'data': [ + 'security/security.xml', + 'security/ir.model.access.csv', + 'data/default_status_config.xml', + 'views/attendance_report_config_views.xml', + 'wizard/attendance_report_wizard_views.xml', + 'views/menu_views.xml', + ], + 'assets': { + 'web.assets_backend': [ + 'hr_attendance_excel_report/static/src/css/report_wizard.css', + 'hr_attendance_excel_report/static/src/js/color_picker_widget.js', + ], + }, + 'installable': True, + 'auto_install': False, + 'application': False, + 'license': 'LGPL-3', +} diff --git a/odex25_hr/hr_attendance_excel_report/data/default_status_config.xml b/odex25_hr/hr_attendance_excel_report/data/default_status_config.xml new file mode 100644 index 000000000..e50bdd8ed --- /dev/null +++ b/odex25_hr/hr_attendance_excel_report/data/default_status_config.xml @@ -0,0 +1,109 @@ + + + + + + + الإعداد الافتراضي + + True + + + True + True + True + True + True + True + + + + + + + + + 10 + AB + غياب + Absent + #FFB6C1 + #000000 + absent + True + حالة الغياب - تطبق على جميع حالات الغياب + + + + + + 20 + AT + حضور + Attendance + #D9F2D0 + #000000 + attendance + True + الحضور العادي - تطبق على جميع حالات الحضور + + + + + + 30 + LV + إجازة + Leave + #87CEEB + #000000 + leave + True + إجازة عامة - تطبق على جميع أنواع الإجازات غير المخصصة + + + + + + + 40 + PR + استئذان + Permission + #E6E6FA + #000000 + permission + True + استئذان عام - يطبق على جميع أنواع الاستئذانات غير المخصصة + + + + + + 50 + MS + مهمة + Mission + #F0E68C + #000000 + official + True + مهمة عامة - تطبق على جميع أنواع المهام غير المخصصة + + + + + + 999 + PH + عطلة رسمية + Public Holiday + #CCCCCC + #000000 + holiday + True + عطلة رسمية - حالة احتياطية + + + + \ No newline at end of file diff --git a/odex25_hr/hr_attendance_excel_report/i18n/ar.po b/odex25_hr/hr_attendance_excel_report/i18n/ar.po new file mode 100644 index 000000000..0f8bdeafb --- /dev/null +++ b/odex25_hr/hr_attendance_excel_report/i18n/ar.po @@ -0,0 +1,382 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hr_attendance_excel_report +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-08-24 12:00+0000\n" +"PO-Revision-Date: 2025-08-24 12:00+0000\n" +"Last-Translator: AI Assistant\n" +"Language-Team: Arabic\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Language: ar\n" +"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 ? 4 : 5;\n" +"X-Generator: Odoo\n" + +#. module: hr_attendance_excel_report +#: model:ir.actions.act_window,name:hr_attendance_excel_report.action_attendance_report_wizard +#: model:ir.actions.act_window,name:hr_attendance_excel_report.action_attendance_report_wizard_menu +msgid "Generate Attendance Report" +msgstr "إنشاء تقرير الحضور" + +#. module: hr_attendance_excel_report +#: model:ir.ui.menu,name:hr_attendance_excel_report.menu_hr_attendance_excel_report +msgid "Excel Attendance Report" +msgstr "تقرير الحضور Excel" + +#. module: hr_attendance_excel_report +#: code:addons/hr_attendance_excel_report/wizard/attendance_report_wizard.py:0 +#, python-format +msgid "Attendance Report" +msgstr "تقرير الحضور" + +#. module: hr_attendance_excel_report +#: code:addons/hr_attendance_excel_report/wizard/attendance_report_wizard.py:0 +#, python-format +msgid "Employee Number" +msgstr "الرقم الوظيفي" + +#. module: hr_attendance_excel_report +#: code:addons/hr_attendance_excel_report/wizard/attendance_report_wizard.py:0 +#, python-format +msgid "Employee Name" +msgstr "اسم الموظف" + +#. module: hr_attendance_excel_report +#: code:addons/hr_attendance_excel_report/wizard/attendance_report_wizard.py:0 +#, python-format +msgid "National ID" +msgstr "رقم الهوية الوطنية" + +#. module: hr_attendance_excel_report +#: code:addons/hr_attendance_excel_report/wizard/attendance_report_wizard.py:0 +#, python-format +msgid "Department" +msgstr "القسم" + +#. module: hr_attendance_excel_report +#: code:addons/hr_attendance_excel_report/wizard/attendance_report_wizard.py:0 +#, python-format +msgid "Job Position" +msgstr "المنصب الوظيفي" + +#. module: hr_attendance_excel_report +#: code:addons/hr_attendance_excel_report/wizard/attendance_report_wizard.py:0 +#, python-format +msgid "Official Working Hours" +msgstr "ساعات العمل الرسمية" + +#. module: hr_attendance_excel_report +#: code:addons/hr_attendance_excel_report/wizard/attendance_report_wizard.py:0 +#, python-format +msgid "Attendance Days" +msgstr "أيام الحضور" + +#. module: hr_attendance_excel_report +#: code:addons/hr_attendance_excel_report/wizard/attendance_report_wizard.py:0 +#, python-format +msgid "Total Leave Days" +msgstr "إجمالي أيام الإجازات" + +#. module: hr_attendance_excel_report +#: code:addons/hr_attendance_excel_report/wizard/attendance_report_wizard.py:0 +#, python-format +msgid "Absent Days" +msgstr "أيام الغياب" + +#. module: hr_attendance_excel_report +#: code:addons/hr_attendance_excel_report/wizard/attendance_report_wizard.py:0 +#, python-format +msgid "Attendance %" +msgstr "معدل الحضور %" + +#. module: hr_attendance_excel_report +#: code:addons/hr_attendance_excel_report/wizard/attendance_report_wizard.py:0 +#, python-format +msgid "Permission Count" +msgstr "عدد الأذونات" + +#. module: hr_attendance_excel_report +#: code:addons/hr_attendance_excel_report/wizard/attendance_report_wizard.py:0 +#, python-format +msgid "Permission Hours" +msgstr "ساعات الأذونات" + +#. module: hr_attendance_excel_report +#: code:addons/hr_attendance_excel_report/wizard/attendance_report_wizard.py:0 +#, python-format +msgid "Public Holiday Days" +msgstr "أيام العطل الرسمية" + +#. module: hr_attendance_excel_report +#: code:addons/hr_attendance_excel_report/wizard/attendance_report_wizard.py:0 +#, python-format +msgid "Actual Office Hours" +msgstr "ساعات العمل الفعلية" + +#. module: hr_attendance_excel_report +#: code:addons/hr_attendance_excel_report/wizard/attendance_report_wizard.py:0 +#, python-format +msgid "Additional Hours" +msgstr "الساعات الإضافية" + +#. module: hr_attendance_excel_report +#: code:addons/hr_attendance_excel_report/wizard/attendance_report_wizard.py:0 +#, python-format +msgid "Total Lateness" +msgstr "إجمالي التأخير" + +#. module: hr_attendance_excel_report +#: code:addons/hr_attendance_excel_report/wizard/attendance_report_wizard.py:0 +#, python-format +msgid "Total Early Exit" +msgstr "إجمالي الخروج المبكر" + +#. module: hr_attendance_excel_report +#: code:addons/hr_attendance_excel_report/wizard/attendance_report_wizard.py:0 +#, python-format +msgid "Company:" +msgstr "الشركة:" + +#. module: hr_attendance_excel_report +#: code:addons/hr_attendance_excel_report/wizard/attendance_report_wizard.py:0 +#, python-format +msgid "Shift Type:" +msgstr "نوع الشيفت:" + +#. module: hr_attendance_excel_report +#: code:addons/hr_attendance_excel_report/wizard/attendance_report_wizard.py:0 +#, python-format +msgid "Period: from %s to %s" +msgstr "الفترة: من %s إلى %s" + +#. module: hr_attendance_excel_report +#: code:addons/hr_attendance_excel_report/wizard/attendance_report_wizard.py:0 +#, python-format +msgid "Generated: %s" +msgstr "تاريخ الإنشاء: %s" + +#. module: hr_attendance_excel_report +#: code:addons/hr_attendance_excel_report/wizard/attendance_report_wizard.py:0 +#, python-format +msgid "Status Legend:" +msgstr "دليل رموز الحالات:" + +#. module: hr_attendance_excel_report +#: code:addons/hr_attendance_excel_report/wizard/attendance_report_wizard.py:0 +#, python-format +msgid "xlsxwriter library is not installed" +msgstr "مكتبة xlsxwriter غير مثبتة" + +#. module: hr_attendance_excel_report +#: code:addons/hr_attendance_excel_report/wizard/attendance_report_wizard.py:0 +#, python-format +msgid "No employees with %s in the selected period" +msgstr "لا يوجد موظفون بـ %s في الفترة المحددة" + +#. module: hr_attendance_excel_report +#: code:addons/hr_attendance_excel_report/wizard/attendance_report_wizard.py:0 +#, python-format +msgid "From date must be before to date" +msgstr "يجب أن يكون تاريخ البداية قبل تاريخ النهاية" + +#. module: hr_attendance_excel_report +#: code:addons/hr_attendance_excel_report/wizard/attendance_report_wizard.py:0 +#, python-format +msgid "Report period cannot exceed one year" +msgstr "لا يمكن أن تزيد فترة التقرير عن سنة واحدة" + +#. module: hr_attendance_excel_report +#: code:addons/hr_attendance_excel_report/wizard/attendance_report_wizard.py:0 +#, python-format +msgid "Please select employees" +msgstr "يرجى اختيار الموظفين" + +#. module: hr_attendance_excel_report +#: code:addons/hr_attendance_excel_report/wizard/attendance_report_wizard.py:0 +#, python-format +msgid "Please select departments" +msgstr "يرجى اختيار الأقسام" + +#. module: hr_attendance_excel_report +#: code:addons/hr_attendance_excel_report/wizard/attendance_report_wizard.py:0 +#, python-format +msgid "Please select branches" +msgstr "يرجى اختيار الفروع" + +#. module: hr_attendance_excel_report +#: code:addons/hr_attendance_excel_report/wizard/attendance_report_wizard.py:0 +#, python-format +msgid "Preview error: %s" +msgstr "خطأ في المعاينة: %s" + +#. module: hr_attendance_excel_report +#: code:addons/hr_attendance_excel_report/wizard/attendance_report_wizard.py:0 +#, python-format +msgid "Transaction display error: %s" +msgstr "خطأ في عرض المعاملات: %s" + +#. module: hr_attendance_excel_report +#: code:addons/hr_attendance_excel_report/wizard/attendance_report_wizard.py:0 +#, python-format +msgid "No Department" +msgstr "بدون قسم" + +#. module: hr_attendance_excel_report +#: model:ir.model.fields,field_description:hr_attendance_excel_report.field_hr_attendance_report_wizard__company_id +msgid "Company" +msgstr "الشركة" + +#. module: hr_attendance_excel_report +#: model:ir.model.fields,field_description:hr_attendance_excel_report.field_hr_attendance_report_wizard__config_id +msgid "Report Configuration" +msgstr "إعدادات التقرير" + +#. module: hr_attendance_excel_report +#: model:ir.model.fields,field_description:hr_attendance_excel_report.field_hr_attendance_report_wizard__date_from +msgid "From Date" +msgstr "من تاريخ" + +#. module: hr_attendance_excel_report +#: model:ir.model.fields,field_description:hr_attendance_excel_report.field_hr_attendance_report_wizard__date_to +msgid "To Date" +msgstr "إلى تاريخ" + +#. module: hr_attendance_excel_report +#: model:ir.model.fields,field_description:hr_attendance_excel_report.field_hr_attendance_report_wizard__group_by_department +msgid "Group by Department" +msgstr "تجميع حسب القسم" + +#. module: hr_attendance_excel_report +#: model:ir.model.fields,field_description:hr_attendance_excel_report.field_hr_attendance_report_wizard__include_summary_columns +msgid "Include Summary Columns" +msgstr "تضمين أعمدة الملخص" + +#. module: hr_attendance_excel_report +#: model:ir.model.fields,field_description:hr_attendance_excel_report.field_hr_attendance_report_wizard__include_legend +msgid "Include Status Legend" +msgstr "تضمين دليل الرموز" + +#. module: hr_attendance_excel_report +#: model:ir.model.fields,field_description:hr_attendance_excel_report.field_hr_attendance_report_wizard__include_official_hours +msgid "Include Official Working Hours" +msgstr "تضمين ساعات العمل الرسمية" + +#. module: hr_attendance_excel_report +#: model:ir.model.fields,field_description:hr_attendance_excel_report.field_hr_attendance_report_config__name +msgid "Configuration Name" +msgstr "اسم الإعداد" + +#. module: hr_attendance_excel_report +#: model:ir.model.fields,field_description:hr_attendance_excel_report.field_hr_attendance_report_config__description +msgid "Description" +msgstr "الوصف" + +#. module: hr_attendance_excel_report +#: model:ir.model.fields,field_description:hr_attendance_excel_report.field_hr_attendance_report_config__active +#: model:ir.model.fields,field_description:hr_attendance_excel_report.field_hr_attendance_status_config__active +msgid "Active" +msgstr "نشط" + +#. module: hr_attendance_excel_report +#: model:ir.model.fields,field_description:hr_attendance_excel_report.field_hr_attendance_report_config__include_employee_number +msgid "Include Employee Number" +msgstr "تضمين الرقم الوظيفي" + +#. module: hr_attendance_excel_report +#: model:ir.model.fields,field_description:hr_attendance_excel_report.field_hr_attendance_report_config__include_national_id +msgid "Include National ID" +msgstr "تضمين رقم الهوية" + +#. module: hr_attendance_excel_report +#: model:ir.model.fields,field_description:hr_attendance_excel_report.field_hr_attendance_report_config__include_department +msgid "Include Department" +msgstr "تضمين القسم" + +#. module: hr_attendance_excel_report +#: model:ir.model.fields,field_description:hr_attendance_excel_report.field_hr_attendance_report_config__include_job_position +msgid "Include Job Position" +msgstr "تضمين المنصب الوظيفي" + +#. module: hr_attendance_excel_report +#: model:ir.model.fields,field_description:hr_attendance_excel_report.field_hr_attendance_status_config__name_ar +msgid "Arabic Name" +msgstr "الاسم العربي" + +#. module: hr_attendance_excel_report +#: model:ir.model.fields,field_description:hr_attendance_excel_report.field_hr_attendance_status_config__name_en +msgid "English Name" +msgstr "الاسم الإنجليزي" + +#. module: hr_attendance_excel_report +#: model:ir.model.fields,field_description:hr_attendance_excel_report.field_hr_attendance_status_config__code +msgid "Status Code" +msgstr "رمز الحالة" + +#. module: hr_attendance_excel_report +#: model:ir.model.fields,field_description:hr_attendance_excel_report.field_hr_attendance_status_config__bg_color +msgid "Background Color" +msgstr "لون الخلفية" + +#. module: hr_attendance_excel_report +#: model:ir.model.fields,field_description:hr_attendance_excel_report.field_hr_attendance_status_config__text_color +msgid "Text Color" +msgstr "لون النص" + +#. module: hr_attendance_excel_report +#: model:ir.model.fields,field_description:hr_attendance_excel_report.field_hr_attendance_status_config__sequence +msgid "Sequence" +msgstr "التسلسل" + +#. module: hr_attendance_excel_report +#: model:ir.model,name:hr_attendance_excel_report.model_hr_attendance_report_wizard +msgid "Attendance Report Wizard" +msgstr "معالج تقرير الحضور" + +#. module: hr_attendance_excel_report +#: model:ir.model,name:hr_attendance_excel_report.model_hr_attendance_report_config +msgid "HR Attendance Report Configuration" +msgstr "إعدادات تقرير الحضور" + +#. module: hr_attendance_excel_report +#: model:ir.model,name:hr_attendance_excel_report.model_hr_attendance_status_config +msgid "HR Attendance Status Configuration" +msgstr "إعدادات حالة الحضور" + +#. module: hr_attendance_excel_report +#: model:ir.ui.menu,name:hr_attendance_excel_report.menu_hr_attendance_reports_config +msgid "Attendance Report Configurations" +msgstr "إعدادات تقرير الحضور" + +#. module: hr_attendance_excel_report +#: model:ir.ui.menu,name:hr_attendance_excel_report.menu_hr_attendance_report_config +msgid "Report Configurations" +msgstr "إعدادات التقارير" + +#. module: hr_attendance_excel_report +#: code:addons/hr_attendance_excel_report/wizard/attendance_report_wizard.py:0 +#, python-format +msgid "Employee Preview - %s" +msgstr "معاينة الموظفين - %s" + +#. module: hr_attendance_excel_report +#: code:addons/hr_attendance_excel_report/wizard/attendance_report_wizard.py:0 +#, python-format +msgid "Attendance Transactions - %s" +msgstr "معاملات الحضور - %s" + +#. module: hr_attendance_excel_report +#: code:addons/hr_attendance_excel_report/wizard/attendance_report_wizard.py:0 +#, python-format +msgid "Attendance Report - %s" +msgstr "تقرير الحضور - %s" + +#. module: hr_attendance_excel_report +#: code:addons/hr_attendance_excel_report/wizard/attendance_report_wizard.py:0 +#, python-format +msgid "Company: %s | Shift Type: %s" +msgstr "الشركة: %s | نوع الشيفت: %s" \ No newline at end of file diff --git a/odex25_hr/hr_attendance_excel_report/models/__init__.py b/odex25_hr/hr_attendance_excel_report/models/__init__.py new file mode 100644 index 000000000..f8011682b --- /dev/null +++ b/odex25_hr/hr_attendance_excel_report/models/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- + +from . import attendance_report_config +from . import attendance_status_config +from . import wizard_helpers +from . import hr_employee_wizard_fields diff --git a/odex25_hr/hr_attendance_excel_report/models/attendance_report_config.py b/odex25_hr/hr_attendance_excel_report/models/attendance_report_config.py new file mode 100644 index 000000000..fda495755 --- /dev/null +++ b/odex25_hr/hr_attendance_excel_report/models/attendance_report_config.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- + +from odoo import api, fields, models, _ +from odoo.exceptions import ValidationError + + +class AttendanceReportConfig(models.Model): + _name = 'hr.attendance.report.config' + _description = 'Attendance Report Configuration' + _order = 'name' + + name = fields.Char(string='Configuration Name', required=True) + company_id = fields.Many2one('res.company', string='Company', default=lambda self: self.env.company, required=True) + active = fields.Boolean(string='Active', default=True) + + # Status configurations + status_line_ids = fields.One2many('hr.attendance.status.config', 'config_id', string='Status Configurations') + + # Column settings + include_employee_number = fields.Boolean(string='Include Employee Number', default=True) + include_national_id = fields.Boolean(string='Include National ID', default=True) + include_department = fields.Boolean(string='Include Department', default=True) + include_job_position = fields.Boolean(string='Include Job Position', default=True) + include_branch = fields.Boolean(string='Include Branch', default=True) + include_summary_columns = fields.Boolean(string='Include Summary Columns', default=True) + + # Report options - moved from wizard + group_by_department = fields.Boolean(string='Group by Department', default=False) + include_legend = fields.Boolean(string='Include Status Legend', default=True) + include_official_hours = fields.Boolean(string='Include Official Working Hours', default=True) + + @api.model + def create_default_config(self): + default_config = self.create({'name': 'Standard Configuration'}) + + # Create default status configurations + default_statuses = [ + {'code': 'PH', 'name_ar': 'عطلة رسمية', 'name_en': 'Public Holiday', + 'bg_color': '#CCCCCC', 'text_color': '#000000', 'condition_type': 'holiday', 'sequence': 10}, + {'code': 'AB', 'name_ar': 'غياب', 'name_en': 'Absent', + 'bg_color': '#FFB6C1', 'text_color': '#000000', 'condition_type': 'absent', 'sequence': 20}, + {'code': 'AL', 'name_ar': 'إجازة سنوية', 'name_en': 'Annual Leave', + 'bg_color': '#87CEEB', 'text_color': '#000000', 'condition_type': 'leave', 'sequence': 30}, + {'code': 'SL', 'name_ar': 'إجازة مرضية', 'name_en': 'Sick Leave', + 'bg_color': '#FFE4B5', 'text_color': '#000000', 'condition_type': 'leave', 'sequence': 40}, + {'code': 'ML', 'name_ar': 'إجازة زواج', 'name_en': 'Marriage Leave', + 'bg_color': '#DDA0DD', 'text_color': '#000000', 'condition_type': 'leave', 'sequence': 50}, + {'code': 'BL', 'name_ar': 'إجازة وفاة', 'name_en': 'Bereavement Leave', + 'bg_color': '#D3D3D3', 'text_color': '#000000', 'condition_type': 'leave', 'sequence': 60}, + {'code': 'OD', 'name_ar': 'دوام ميداني / خارج المكتب', 'name_en': 'On Duty (Outside Work)', + 'bg_color': '#F0E68C', 'text_color': '#000000', 'condition_type': 'official', 'sequence': 70}, + {'code': 'TR', 'name_ar': 'تدريب أو ورشة عمل', 'name_en': 'Training', + 'bg_color': '#98FB98', 'text_color': '#000000', 'condition_type': 'official', 'sequence': 80}, + {'code': 'UL', 'name_ar': 'إجازة بدون راتب', 'name_en': 'Unpaid Leave', + 'bg_color': '#FFA07A', 'text_color': '#000000', 'condition_type': 'leave', 'sequence': 90}, + {'code': 'ML/PL', 'name_ar': 'إجازة أمومة / أبوة', 'name_en': 'Maternity / Paternity Leave', + 'bg_color': '#F5DEB3', 'text_color': '#000000', 'condition_type': 'leave', 'sequence': 100}, + ] + + for status_data in default_statuses: + status_data['config_id'] = default_config.id + self.env['hr.attendance.status.config'].create(status_data) + + return default_config + + def name_get(self): + result = [] + for record in self: + result.append((record.id, record.name)) + return result diff --git a/odex25_hr/hr_attendance_excel_report/models/attendance_status_config.py b/odex25_hr/hr_attendance_excel_report/models/attendance_status_config.py new file mode 100644 index 000000000..4276aee68 --- /dev/null +++ b/odex25_hr/hr_attendance_excel_report/models/attendance_status_config.py @@ -0,0 +1,294 @@ +# -*- coding: utf-8 -*- + +from odoo import api, fields, models, _ +from odoo.exceptions import ValidationError +import logging + +_logger = logging.getLogger(__name__) + +try: + from odoo.tools.safe_eval import safe_eval +except ImportError: + def safe_eval(expr, context): + import ast + allowed_names = { + '__builtins__': {}, 'len': len, 'str': str, 'int': int, 'float': float, + 'bool': bool, 'abs': abs, 'min': min, 'max': max, + } + allowed_names.update(context) + return eval(expr, {"__builtins__": {}}, allowed_names) + + +class AttendanceStatusConfig(models.Model): + _name = 'hr.attendance.status.config' + _description = 'Attendance Status Configuration' + _order = 'sequence, code' + + # Core fields + config_id = fields.Many2one( + 'hr.attendance.report.config', string='Report Configuration', + required=True, ondelete='cascade') + + sequence = fields.Integer(string='Sequence', default=10) + + code = fields.Char(string='Status Code', required=True, size=10) + + name_ar = fields.Char(string='Arabic Name', required=True) + + name_en = fields.Char(string='English Name', required=True) + + # Display colors + bg_color = fields.Char(string='Background Color', default='#FFFFFF') + + text_color = fields.Char(string='Text Color', default='#000000') + + # Condition settings + condition_type = fields.Selection([ + ('leave', 'إجازة / Leave'), + ('permission', 'استئذان / Permission'), + ('official', 'مهمة / Mission'), + ('absent', 'غياب / Absent'), + ('holiday', 'عطلة رسمية / Public Holiday'), + ('attendance', 'حضور / Attendance'), + ('late', 'تأخير/انصراف مبكر / Late/Early Exit'), + ], string='نوع الشرط', required=True, default='leave') + + # Custom condition (Python expression) + custom_condition = fields.Text(string='Custom Condition') + + active = fields.Boolean(string='Active', default=True) + + notes = fields.Text(string='Notes') + + # Dynamic linking fields + holiday_status_ids = fields.Many2many( + 'hr.holidays.status', 'status_config_holiday_rel', 'status_id', 'holiday_id', + string='أنواع الإجازات المرتبطة', domain="[('active', '=', True)]") + + permission_type_ids = fields.Many2many( + 'hr.personal.permission.type', 'status_config_permission_rel', + 'status_id', 'permission_type_id', string='أنواع الاستئذانات المرتبطة') + + mission_type_ids = fields.Many2many( + 'hr.official.mission.type', 'status_config_mission_rel', + 'status_id', 'mission_type_id', string='أنواع المهام المرتبطة') + + # Field visibility controls + show_holiday_fields = fields.Boolean(string='Show Holiday Fields', compute='_compute_field_visibility') + show_permission_fields = fields.Boolean(string='Show Permission Fields', compute='_compute_field_visibility') + show_mission_fields = fields.Boolean(string='Show Mission Fields', compute='_compute_field_visibility') + + @api.depends('condition_type') + def _compute_field_visibility(self): + for record in self: + record.show_holiday_fields = (record.condition_type == 'leave') + record.show_permission_fields = (record.condition_type == 'permission') + record.show_mission_fields = (record.condition_type == 'official') + + # Validation + @api.constrains('bg_color', 'text_color') + def _check_color_format(self): + import re + color_pattern = re.compile(r'^#[0-9A-Fa-f]{6}$') + + for record in self: + if record.bg_color and not color_pattern.match(record.bg_color): + raise ValidationError(_('Background color must be in hex format (#RRGGBB)')) + if record.text_color and not color_pattern.match(record.text_color): + raise ValidationError(_('Text color must be in hex format (#RRGGBB)')) + + @api.constrains('code') + def _check_unique_code(self): + for record in self: + if record.config_id: + domain = [('config_id', '=', record.config_id.id), ('code', '=', record.code), ('id', '!=', record.id)] + if self.search_count(domain) > 0: + raise ValidationError(_('Status code "%s" must be unique within the same configuration.') % record.code) + + @api.constrains('custom_condition') + def _check_custom_condition(self): + for record in self: + if record.custom_condition: + try: + compile(record.custom_condition, '', 'eval') + except SyntaxError as e: + raise ValidationError(_('Invalid Python expression in custom condition: %s') % str(e)) + + # Core functionality - Dynamic linking system without hard-coded patterns + def check_condition(self, transaction, shift_type=None, wizard=None): + self.ensure_one() + if not self.active: + return False + + try: + # Basic conditions + if self.condition_type == 'holiday': + return bool(getattr(transaction, 'public_holiday', False)) + elif self.condition_type == 'absent': + return bool(getattr(transaction, 'is_absent', False)) + elif self.condition_type == 'attendance': + sign_in = getattr(transaction, 'sign_in', 0) + sign_out = getattr(transaction, 'sign_out', 0) + return sign_in > 0 and sign_out > 0 + + # Dynamic conditions + elif self.condition_type == 'leave': + return self._check_dynamic_leave_condition(transaction) + elif self.condition_type == 'permission': + return self._check_dynamic_permission_condition(transaction) + elif self.condition_type == 'official': + return self._check_dynamic_mission_condition(transaction) + elif self.condition_type == 'late': + lateness = getattr(transaction, 'lateness', 0) or 0 + early_exit = getattr(transaction, 'early_exit', 0) or 0 + return lateness > 0 or early_exit > 0 + elif self.custom_condition: + return self._evaluate_custom_condition(transaction, shift_type, wizard) + + except Exception as e: + _logger.warning(f"Error in check_condition for status {self.code}: {str(e)}") + return False + + return False + + def _check_dynamic_leave_condition(self, transaction): + leave_id = getattr(transaction, 'leave_id', False) + if not leave_id: + return False + + holiday_status = getattr(leave_id, 'holiday_status_id', False) + if not holiday_status: + return False + + if not self.holiday_status_ids: + return True + + return holiday_status.id in self.holiday_status_ids.ids + + def _check_dynamic_permission_condition(self, transaction): + permission_id = getattr(transaction, 'personal_permission_id', False) + if not permission_id: + return False + + permission_type = getattr(permission_id, 'permission_type_id', False) + if not permission_type: + return False + + if not self.permission_type_ids: + return True + + return permission_type.id in self.permission_type_ids.ids + + def _check_dynamic_mission_condition(self, transaction): + official_id = getattr(transaction, 'official_id', False) + is_official = getattr(transaction, 'is_official', False) + + if not (official_id or is_official): + return False + + if not self.mission_type_ids: + return True + + if official_id: + mission_type = getattr(official_id, 'mission_type_id', False) + if mission_type: + return mission_type.id in self.mission_type_ids.ids + + return False + + def _evaluate_custom_condition(self, transaction, shift_type, wizard): + try: + eval_context = { + 'transaction': transaction, + 'employee': getattr(transaction, 'employee_id', self.env['hr.employee']), + 'date': getattr(transaction, 'date', False), + 'shift_type': shift_type, + 'wizard': wizard, + 'has_attendance': lambda: (getattr(transaction, 'sign_in', 0) > 0 and getattr(transaction, 'sign_out', 0) > 0), + 'has_leave': lambda: bool(getattr(transaction, 'leave_id', False)), + 'has_permission': lambda: bool(getattr(transaction, 'personal_permission_id', False)), + 'is_holiday': lambda: bool(getattr(transaction, 'public_holiday', False)), + 'get_leave_type': lambda: (getattr(transaction, 'holiday_name', None) and getattr(transaction.holiday_name, 'name', '') or ''), + } + return bool(safe_eval(self.custom_condition, eval_context)) + except Exception as e: + _logger.warning(f"Error evaluating custom condition for {self.code}: {str(e)}") + return False + + # Display value generation + def get_display_value(self, transaction): + self.ensure_one() + + if (self.condition_type == 'attendance' and + getattr(transaction, 'sign_in', 0) > 0 and + getattr(transaction, 'sign_out', 0) > 0): + return self._get_attendance_display_value(transaction) + + elif (self.condition_type == 'permission' and + getattr(transaction, 'personal_permission_id', False)): + return self._get_permission_display_value(transaction) + + return self.code + + def _get_attendance_display_value(self, transaction): + return self._format_time_range(transaction) + + def _get_permission_display_value(self, transaction): + has_attendance = (getattr(transaction, 'sign_in', 0) > 0 and + getattr(transaction, 'sign_out', 0) > 0) + + permission_hours = getattr(transaction, 'total_permission_hours', 0) or 0 + + if has_attendance: + time_str = self._format_time_range(transaction) + if permission_hours > 0: + return f"{time_str} •{self.code}{permission_hours:.1f}" + else: + return f"{time_str} •{self.code}" + else: + if permission_hours > 0: + return f"{self.code}{permission_hours:.1f}" + else: + return self.code + + def _format_time_range(self, transaction): + sign_in = getattr(transaction, 'sign_in', 0) or 0 + sign_out = getattr(transaction, 'sign_out', 0) or 0 + + if sign_in <= 0 or sign_out <= 0: + return "" + + in_h, in_m = int(sign_in), int((sign_in % 1) * 60) + out_h, out_m = int(sign_out), int((sign_out % 1) * 60) + + # RTL format: sign-out first, then sign-in + return f"{out_h:02d}:{out_m:02d}-{in_h:02d}:{in_m:02d}" + + # Utility functions + def name_get(self): + result = [] + for record in self: + name = f"{record.code} - {record.name_ar}" + if record.condition_type: + name += f" ({dict(record._fields['condition_type'].selection)[record.condition_type]})" + result.append((record.id, name)) + return result + + @api.model + def get_default_status_configs(self): + return [ + {'sequence': 10, 'code': 'غ', 'name_ar': 'غياب', 'name_en': 'Absent', + 'condition_type': 'absent', 'bg_color': '#FFB6C1', 'text_color': '#000000'}, + {'sequence': 20, 'code': 'إس', 'name_ar': 'إجازة سنوية', 'name_en': 'Annual Leave', + 'condition_type': 'leave', 'bg_color': '#87CEEB', 'text_color': '#000000'}, + {'sequence': 30, 'code': 'إم', 'name_ar': 'إجازة مرضية', 'name_en': 'Sick Leave', + 'condition_type': 'leave', 'bg_color': '#FFE4B5', 'text_color': '#000000'}, + {'sequence': 40, 'code': 'مه', 'name_ar': 'مهمة رسمية', 'name_en': 'Official Mission', + 'condition_type': 'official', 'bg_color': '#F0E68C', 'text_color': '#000000'}, + {'sequence': 50, 'code': 'ع', 'name_ar': 'استئذان', 'name_en': 'Permission', + 'condition_type': 'permission', 'bg_color': '#E6E6FA', 'text_color': '#000000'}, + {'sequence': 60, 'code': 'ح', 'name_ar': 'حضور عادي', 'name_en': 'Normal Attendance', + 'condition_type': 'attendance', 'bg_color': '#D9F2D0', 'text_color': '#000000'}, + {'sequence': 999, 'code': 'عر', 'name_ar': 'عطلة رسمية', 'name_en': 'Public Holiday', + 'condition_type': 'holiday', 'bg_color': '#CCCCCC', 'text_color': '#000000'}, + ] diff --git a/odex25_hr/hr_attendance_excel_report/models/hr_employee_wizard_fields.py b/odex25_hr/hr_attendance_excel_report/models/hr_employee_wizard_fields.py new file mode 100644 index 000000000..c788a6814 --- /dev/null +++ b/odex25_hr/hr_attendance_excel_report/models/hr_employee_wizard_fields.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- + +from odoo import api, fields, models + + +class HrEmployeeReportFields(models.Model): + _inherit = 'hr.employee' + + # Computed fields for wizard display + wizard_employee_number = fields.Char(string='Employee Number', compute='_compute_wizard_display_fields', store=False) + wizard_national_id = fields.Char(string='National ID', compute='_compute_wizard_display_fields', store=False) + + @api.depends('barcode', 'identification_id') + def _compute_wizard_display_fields(self): + for employee in self: + employee_number = '' + national_id = '' + + # Get employee number from transaction first + transaction = self.env['hr.attendance.transaction'].search([('employee_id', '=', employee.id)], limit=1) + + if transaction and hasattr(transaction, 'employee_number') and transaction.employee_number: + employee_number = transaction.employee_number + else: + # Fallback to employee fields + if employee.barcode: + employee_number = employee.barcode + elif hasattr(employee, 'registration_number') and employee.registration_number: + employee_number = employee.registration_number + + # Get national ID + if employee.identification_id: + national_id = employee.identification_id + elif hasattr(employee, 'identity_number') and employee.identity_number: + national_id = employee.identity_number + elif hasattr(employee, 'ssnid') and employee.ssnid: + national_id = employee.ssnid + + employee.wizard_employee_number = employee_number + employee.wizard_national_id = national_id diff --git a/odex25_hr/hr_attendance_excel_report/models/wizard_helpers.py b/odex25_hr/hr_attendance_excel_report/models/wizard_helpers.py new file mode 100644 index 000000000..525274ab9 --- /dev/null +++ b/odex25_hr/hr_attendance_excel_report/models/wizard_helpers.py @@ -0,0 +1,137 @@ +# -*- coding: utf-8 -*- + +from odoo import api, fields, models + + +class AttendanceReportWizardEmployee(models.TransientModel): + _name = 'hr.attendance.report.wizard.employee' + _description = 'Attendance Report Wizard - Employee Selection' + _order = 'employee_name' + + wizard_id = fields.Many2one('hr.attendance.report.wizard', string='Wizard', required=True, ondelete='cascade') + employee_id = fields.Many2one('hr.employee', string='Employee', required=True, domain="[('active', '=', True)]") + + # Display fields + employee_name = fields.Char(string='Employee Name', related='employee_id.name', store=True, readonly=True) + employee_number = fields.Char(string='Employee Number', compute='_compute_employee_details', store=True) + national_id = fields.Char(string='National ID', compute='_compute_employee_details', store=True) + department_name = fields.Char(string='Department', related='employee_id.department_id.name', store=True, readonly=True) + job_title = fields.Char(string='Job Position', related='employee_id.job_id.name', store=True, readonly=True) + + @api.depends('employee_id') + def _compute_employee_details(self): + for record in self: + if record.employee_id: + employee_number = '' + national_id = '' + + # Get employee number from transaction first + transaction = self.env['hr.attendance.transaction'].search([('employee_id', '=', record.employee_id.id)], limit=1) + + if transaction and transaction.employee_number: + employee_number = transaction.employee_number + else: + # Fallback to employee fields + if hasattr(record.employee_id, 'barcode') and record.employee_id.barcode: + employee_number = record.employee_id.barcode + elif hasattr(record.employee_id, 'employee_id') and record.employee_id.employee_id: + employee_number = record.employee_id.employee_id + elif hasattr(record.employee_id, 'registration_number') and record.employee_id.registration_number: + employee_number = record.employee_id.registration_number + + # Get national ID + if hasattr(record.employee_id, 'identification_id') and record.employee_id.identification_id: + national_id = record.employee_id.identification_id + elif hasattr(record.employee_id, 'identity_number') and record.employee_id.identity_number: + national_id = record.employee_id.identity_number + elif hasattr(record.employee_id, 'ssnid') and record.employee_id.ssnid: + national_id = record.employee_id.ssnid + + record.employee_number = employee_number + record.national_id = national_id + else: + record.employee_number = '' + record.national_id = '' + + @api.model + def create(self, vals): + if 'wizard_id' in vals and 'employee_id' in vals: + existing = self.search([('wizard_id', '=', vals['wizard_id']), ('employee_id', '=', vals['employee_id'])]) + if existing: + return existing + return super().create(vals) + + +class AttendanceReportWizardDepartment(models.TransientModel): + _name = 'hr.attendance.report.wizard.department' + _description = 'Attendance Report Wizard - Department Selection' + _order = 'department_name' + + wizard_id = fields.Many2one('hr.attendance.report.wizard', string='Wizard', required=True, ondelete='cascade') + department_id = fields.Many2one('hr.department', string='Department', required=True) + + # Display fields + department_name = fields.Char(string='Department Name', related='department_id.name', store=True, readonly=True) + manager_name = fields.Char(string='Manager', related='department_id.manager_id.name', store=True, readonly=True) + employee_count = fields.Integer(string='Employees Count', compute='_compute_employee_count', store=True) + + @api.depends('department_id') + def _compute_employee_count(self): + for record in self: + if record.department_id: + count = self.env['hr.employee'].search_count([ + ('department_id', '=', record.department_id.id), + ('active', '=', True) + ]) + record.employee_count = count + else: + record.employee_count = 0 + + @api.model + def create(self, vals): + if 'wizard_id' in vals and 'department_id' in vals: + existing = self.search([('wizard_id', '=', vals['wizard_id']), ('department_id', '=', vals['department_id'])]) + if existing: + return existing + return super().create(vals) + + +class AttendanceReportWizardBranch(models.TransientModel): + _name = 'hr.attendance.report.wizard.branch' + _description = 'Attendance Report Wizard - Branch Selection' + _order = 'branch_name' + + wizard_id = fields.Many2one('hr.attendance.report.wizard', string='Wizard', required=True, ondelete='cascade') + branch_id = fields.Many2one('hr.department', string='Branch', required=True, domain="[('is_branch', '=', True)]") + + # Display fields + branch_name = fields.Char(string='Branch Name', related='branch_id.name', store=True, readonly=True) + manager_name = fields.Char(string='Branch Manager', related='branch_id.manager_id.name', store=True, readonly=True) + employee_count = fields.Integer(string='Employees Count', compute='_compute_employee_count', store=True) + + @api.depends('branch_id') + def _compute_employee_count(self): + for record in self: + if record.branch_id: + # Count employees with transactions in this branch + employee_ids = self.env['hr.attendance.transaction'].search([ + ('is_branch', '=', record.branch_id.id) + ]).mapped('employee_id.id') + + # Remove duplicates and count active employees + unique_employee_ids = list(set(employee_ids)) + count = self.env['hr.employee'].search_count([ + ('id', 'in', unique_employee_ids), + ('active', '=', True) + ]) + record.employee_count = count + else: + record.employee_count = 0 + + @api.model + def create(self, vals): + if 'wizard_id' in vals and 'branch_id' in vals: + existing = self.search([('wizard_id', '=', vals['wizard_id']), ('branch_id', '=', vals['branch_id'])]) + if existing: + return existing + return super().create(vals) diff --git a/odex25_hr/hr_attendance_excel_report/security/ir.model.access.csv b/odex25_hr/hr_attendance_excel_report/security/ir.model.access.csv new file mode 100644 index 000000000..c14e30e93 --- /dev/null +++ b/odex25_hr/hr_attendance_excel_report/security/ir.model.access.csv @@ -0,0 +1,7 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_hr_attendance_report_config_user,hr.attendance.report.config.user,model_hr_attendance_report_config,hr.group_hr_user,1,0,0,0 +access_hr_attendance_report_config_manager,hr.attendance.report.config.manager,model_hr_attendance_report_config,hr.group_hr_manager,1,1,1,1 +access_hr_attendance_status_config_user,hr.attendance.status.config.user,model_hr_attendance_status_config,hr.group_hr_user,1,0,0,0 +access_hr_attendance_status_config_manager,hr.attendance.status.config.manager,model_hr_attendance_status_config,hr.group_hr_manager,1,1,1,1 +access_hr_attendance_report_wizard_user,hr.attendance.report.wizard.user,model_hr_attendance_report_wizard,hr.group_hr_user,1,1,1,1 +access_hr_attendance_report_wizard_manager,hr.attendance.report.wizard.manager,model_hr_attendance_report_wizard,hr.group_hr_manager,1,1,1,1 diff --git a/odex25_hr/hr_attendance_excel_report/security/security.xml b/odex25_hr/hr_attendance_excel_report/security/security.xml new file mode 100644 index 000000000..064dcad11 --- /dev/null +++ b/odex25_hr/hr_attendance_excel_report/security/security.xml @@ -0,0 +1,36 @@ + + + + + + + Attendance Report User + + Can view and generate attendance reports + + + + + Attendance Report Manager + + Can configure attendance report settings + + + + + + Attendance Report Config: multi-company + + [('company_id', 'in', company_ids)] + + + + + Attendance Status Config: multi-company + + [('config_id.company_id', 'in', company_ids)] + + + + + diff --git a/odex25_hr/hr_attendance_excel_report/static/src/css/report_wizard.css b/odex25_hr/hr_attendance_excel_report/static/src/css/report_wizard.css new file mode 100644 index 000000000..b89b2da35 --- /dev/null +++ b/odex25_hr/hr_attendance_excel_report/static/src/css/report_wizard.css @@ -0,0 +1,394 @@ +/* HR Attendance Excel Report Styles */ + +.attendance_status_preview { + display: inline-block; + padding: 8px 16px; + border: 2px solid #ddd; + border-radius: 4px; + text-align: center; + font-weight: bold; + font-family: 'Courier New', monospace; + background: var(--bg-color, #ffffff); + color: var(--text-color, #000000); + min-width: 80px; + min-height: 40px; + line-height: 24px; +} + +.legend-preview { + max-height: 300px; + overflow-y: auto; + border: 1px solid #e9ecef; + border-radius: 4px; + padding: 10px; + background-color: #f8f9fa; +} + +.legend-preview .badge { + font-size: 12px; + font-weight: bold; + min-width: 40px; + padding: 4px 8px; + font-family: 'Courier New', monospace; +} + +/* Color picker enhancements */ +.o_field_widget[name="bg_color"] input, +.o_field_widget[name="text_color"] input { + border-radius: 4px; + border: 2px solid #ddd; + padding: 4px 8px; +} + +/* Wizard form improvements */ +.o_form_view .oe_title h1 { + color: #2c3e50; + margin-bottom: 20px; + font-size: 1.75rem; +} + +.o_form_view .oe_title h1 i { + margin-right: 10px; + color: #1f7244; +} + +/* Enhanced Group styling - Vertical Layout */ +.o_group[col="1"] { + display: flex; + flex-direction: column; + gap: 15px; +} + +.o_group[col="1"] > .o_group { + margin-bottom: 15px; + border: 1px solid #e9ecef; + border-radius: 8px; + padding: 15px; + background-color: #f8f9fa; +} + +.o_group .o_form_label { + font-weight: 600; + color: #495057; + margin-bottom: 5px; +} + +.o_horizontal_separator { + margin: 20px 0 15px 0; + border-top: 2px solid #dee2e6; + color: #6c757d; + font-weight: 600; + font-size: 1.1rem; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* Date button improvements */ +.oe_inline .btn { + margin-right: 8px; + margin-top: 8px; + font-size: 0.875rem; + padding: 8px 16px; + border-radius: 6px; + font-weight: 500; + border: 2px solid; + transition: all 0.3s ease; +} + +.btn-outline-primary { + border-color: #007bff; + color: #007bff; + background-color: transparent; +} + +.btn-outline-primary:hover { + background-color: #007bff; + border-color: #007bff; + color: white; + transform: translateY(-1px); + box-shadow: 0 2px 4px rgba(0,123,255,0.3); +} + +.btn-outline-secondary { + border-color: #6c757d; + color: #6c757d; + background-color: transparent; +} + +.btn-outline-secondary:hover { + background-color: #6c757d; + border-color: #6c757d; + color: white; + transform: translateY(-1px); + box-shadow: 0 2px 4px rgba(108,117,125,0.3); +} + +/* Notebook and Page styling */ +.nav-tabs { + border-bottom: 2px solid #dee2e6; + margin-bottom: 20px; +} + +.nav-tabs .nav-link { + padding: 12px 20px; + font-weight: 500; + color: #6c757d; + border: none; + border-bottom: 3px solid transparent; + transition: all 0.3s ease; +} + +.nav-tabs .nav-link:hover { + border-bottom-color: #007bff; + color: #007bff; +} + +.nav-tabs .nav-link.active { + background-color: transparent; + border-bottom-color: #28a745; + color: #28a745; + font-weight: 600; +} + +.tab-content { + padding: 20px 0; +} + +/* Many2many tags styling - Full width in notebook pages */ +.tab-pane .o_field_many2manytags { + width: 100%; +} + +.o_field_many2manytags .badge { + font-size: 0.875rem; + margin: 3px; + padding: 8px 12px; + border-radius: 15px; + border: none; + background-color: #007bff; + color: white; + font-weight: 500; +} + +.o_field_many2manytags .o_field_widget { + min-height: 50px; + border: 2px solid #ced4da; + border-radius: 8px; + padding: 10px; + background-color: white; + transition: border-color 0.3s ease; +} + +.o_field_many2manytags .o_field_widget:focus-within { + border-color: #007bff; + box-shadow: 0 0 0 0.2rem rgba(0,123,255,0.25); +} + +/* Alert box improvements for pages */ +.tab-pane .alert-info { + border: 1px solid #b8daff; + background-color: #d1ecf1; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.05); + margin-top: 15px; + padding: 15px; +} + +.tab-pane .alert-info i { + color: #17a2b8; + margin-right: 8px; +} + +/* Main alert box for report features */ +.alert-success { + border: 1px solid #c3e6cb; + background-color: #d4edda; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.05); +} + +.alert-success h5 { + color: #155724; + margin-bottom: 15px; + font-weight: 600; +} + +.alert-success h5 i { + color: #28a745; + margin-right: 8px; +} + +.alert-success strong { + color: #155724; +} + +.alert-success small { + color: #6c757d; + line-height: 1.4; +} + +/* Table preview styling */ +.table-bordered th, +.table-bordered td { + border: 1px solid #dee2e6; + padding: 8px; + text-align: center; + vertical-align: middle; +} + +.table-primary th { + background-color: #b6d7ff; + font-weight: bold; +} + +/* Status configuration form styling */ +.o_form_view .attendance_status_preview { + margin: 10px 0; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +/* Configuration tree view improvements */ +.o_list_view .o_field_widget[name="bg_color"], +.o_list_view .o_field_widget[name="text_color"] { + width: 60px; +} + +/* Footer button improvements */ +.modal-footer .btn-primary { + background-color: #1f7244; + border-color: #1f7244; + font-weight: 600; + padding: 12px 24px; + border-radius: 6px; + box-shadow: 0 2px 4px rgba(31,114,68,0.3); + transition: all 0.3s ease; +} + +.modal-footer .btn-primary:hover { + background-color: #155a33; + border-color: #155a33; + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(31,114,68,0.4); +} + +.modal-footer .btn-secondary { + font-weight: 500; + padding: 12px 24px; + border-radius: 6px; + transition: all 0.3s ease; +} + +/* Improved field spacing for vertical layout */ +.o_group[col="1"] .o_field_widget { + margin-bottom: 10px; + width: 100%; +} + +.o_group[col="1"] .o_form_label { + margin-bottom: 5px; + display: block; +} + +/* Better group headers */ +.o_group > tbody > tr > td.o_td_label { + font-weight: 600; + color: #2c3e50; + background-color: #f1f3f4; + padding: 8px 12px; + border-radius: 4px 4px 0 0; + border-bottom: 2px solid #dee2e6; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .legend-preview { + max-height: 200px; + } + + .table-responsive { + font-size: 0.875rem; + } + + .attendance_status_preview { + min-width: 60px; + padding: 6px 12px; + } + + .o_form_view .oe_title h1 { + font-size: 1.5rem; + } + + .alert-success .row .col-md-4 { + margin-bottom: 15px; + } + + .oe_inline .btn { + margin-bottom: 8px; + font-size: 0.8rem; + padding: 6px 12px; + } + + .nav-tabs .nav-link { + padding: 8px 12px; + font-size: 0.875rem; + } +} + +/* Color validation feedback */ +.o_field_invalid input { + border-color: #dc3545; + box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.25); +} + +/* Loading state for report generation */ +.o_form_button_box .btn[disabled] { + opacity: 0.6; + cursor: not-allowed; +} + +/* Excel preview table */ +.excel-preview-table { + font-family: 'Segoe UI', Arial, sans-serif; + font-size: 11px; + border-collapse: collapse; +} + +.excel-preview-table th, +.excel-preview-table td { + border: 1px solid #c0c0c0; + padding: 2px 4px; + min-width: 60px; + text-align: center; +} + +.excel-preview-table th { + background-color: #e7e6e6; + font-weight: bold; +} + +/* Placeholder text styling */ +.o_field_widget input::placeholder, +.o_field_widget textarea::placeholder { + color: #6c757d; + font-style: italic; +} + +/* Improved focus states */ +.o_field_widget input:focus, +.o_field_widget select:focus { + border-color: #007bff; + box-shadow: 0 0 0 0.2rem rgba(0,123,255,0.25); + outline: none; +} + +/* Custom styling for notebook pages content */ +.tab-pane .o_group { + border: none; + background: none; + padding: 0; +} + +/* Better spacing in pages */ +.tab-pane > .o_group { + margin-bottom: 0; +} diff --git a/odex25_hr/hr_attendance_excel_report/static/src/js/color_picker_widget.js b/odex25_hr/hr_attendance_excel_report/static/src/js/color_picker_widget.js new file mode 100644 index 000000000..89a99c3c5 --- /dev/null +++ b/odex25_hr/hr_attendance_excel_report/static/src/js/color_picker_widget.js @@ -0,0 +1,216 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { Component, useState, onMounted } from "@odoo/owl"; + +/** + * Color Picker Widget for Attendance Report Status Configuration + */ +class ColorPickerWidget extends Component { + setup() { + this.state = useState({ + color: this.props.value || '#FFFFFF' + }); + + onMounted(() => { + this.updatePreview(); + }); + } + + /** + * Update color preview in status configuration + */ + updatePreview() { + const previewElement = document.querySelector('.attendance_status_preview'); + if (previewElement && this.props.name) { + if (this.props.name === 'bg_color') { + previewElement.style.backgroundColor = this.state.color; + } else if (this.props.name === 'text_color') { + previewElement.style.color = this.state.color; + } + } + } + + /** + * Handle color change + */ + onColorChange(event) { + const newColor = event.target.value; + this.state.color = newColor; + this.updatePreview(); + + // Trigger change event for Odoo + if (this.props.update) { + this.props.update(newColor); + } + } + + /** + * Validate hex color format + */ + validateColor(color) { + const hexPattern = /^#[0-9A-Fa-f]{6}$/; + return hexPattern.test(color); + } +} + +ColorPickerWidget.template = "hr_attendance_excel_report.ColorPickerWidget"; + +// Register the widget +registry.category("fields").add("attendance_color_picker", ColorPickerWidget); + +/** + * Quick Date Selection Functions + */ +class AttendanceReportWizard { + + /** + * Set date range to current month + */ + static setCurrentMonth() { + const today = new Date(); + const firstDay = new Date(today.getFullYear(), today.getMonth(), 1); + const lastDay = new Date(today.getFullYear(), today.getMonth() + 1, 0); + + this.updateDateFields(firstDay, lastDay); + } + + /** + * Set date range to previous month + */ + static setPreviousMonth() { + const today = new Date(); + const firstDay = new Date(today.getFullYear(), today.getMonth() - 1, 1); + const lastDay = new Date(today.getFullYear(), today.getMonth(), 0); + + this.updateDateFields(firstDay, lastDay); + } + + /** + * Update date fields in the form + */ + static updateDateFields(fromDate, toDate) { + const dateFromField = document.querySelector('input[name="date_from"]'); + const dateToField = document.querySelector('input[name="date_to"]'); + + if (dateFromField) { + dateFromField.value = fromDate.toISOString().split('T')[0]; + dateFromField.dispatchEvent(new Event('change')); + } + + if (dateToField) { + dateToField.value = toDate.toISOString().split('T')[0]; + dateToField.dispatchEvent(new Event('change')); + } + } + + /** + * Initialize report wizard enhancements + */ + static init() { + // Add event listeners for quick date buttons + document.addEventListener('DOMContentLoaded', function() { + // Current month button + const currentMonthBtn = document.querySelector('[name="action_set_current_month"]'); + if (currentMonthBtn) { + currentMonthBtn.addEventListener('click', function(e) { + e.preventDefault(); + AttendanceReportWizard.setCurrentMonth(); + }); + } + + // Previous month button + const previousMonthBtn = document.querySelector('[name="action_set_previous_month"]'); + if (previousMonthBtn) { + previousMonthBtn.addEventListener('click', function(e) { + e.preventDefault(); + AttendanceReportWizard.setPreviousMonth(); + }); + } + + // Add loading state to generate button + const generateBtn = document.querySelector('[name="action_generate_excel_report"]'); + if (generateBtn) { + generateBtn.addEventListener('click', function() { + this.disabled = true; + this.innerHTML = ' Generating...'; + + // Re-enable after 10 seconds (safety fallback) + setTimeout(() => { + this.disabled = false; + this.innerHTML = ' Generate Excel Report'; + }, 10000); + }); + } + }); + } +} + +/** + * Status Configuration Enhancements + */ +class StatusConfigEnhancements { + + /** + * Update preview when colors change + */ + static updateStatusPreview(bgColor, textColor, code) { + const preview = document.querySelector('.attendance_status_preview'); + if (preview) { + preview.style.backgroundColor = bgColor || '#FFFFFF'; + preview.style.color = textColor || '#000000'; + if (code) { + preview.textContent = code; + } + } + } + + /** + * Initialize status configuration enhancements + */ + static init() { + document.addEventListener('DOMContentLoaded', function() { + // Monitor color field changes + const bgColorField = document.querySelector('input[name="bg_color"]'); + const textColorField = document.querySelector('input[name="text_color"]'); + const codeField = document.querySelector('input[name="code"]'); + + if (bgColorField) { + bgColorField.addEventListener('change', function() { + StatusConfigEnhancements.updateStatusPreview( + this.value, + textColorField?.value, + codeField?.value + ); + }); + } + + if (textColorField) { + textColorField.addEventListener('change', function() { + StatusConfigEnhancements.updateStatusPreview( + bgColorField?.value, + this.value, + codeField?.value + ); + }); + } + + if (codeField) { + codeField.addEventListener('change', function() { + StatusConfigEnhancements.updateStatusPreview( + bgColorField?.value, + textColorField?.value, + this.value + ); + }); + } + }); + } +} + +// Initialize enhancements +AttendanceReportWizard.init(); +StatusConfigEnhancements.init(); + +// Export for potential external use +export { AttendanceReportWizard, StatusConfigEnhancements, ColorPickerWidget }; diff --git a/odex25_hr/hr_attendance_excel_report/views/attendance_report_config_views.xml b/odex25_hr/hr_attendance_excel_report/views/attendance_report_config_views.xml new file mode 100644 index 000000000..35f04afd2 --- /dev/null +++ b/odex25_hr/hr_attendance_excel_report/views/attendance_report_config_views.xml @@ -0,0 +1,225 @@ + + + + + > + + hr.attendance.report.config.tree + hr.attendance.report.config + + + + + + + + + + + + + + hr.attendance.report.config.form + hr.attendance.report.config + +
+ +
+ + +
+ + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

+ + - + +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ +
+
+ + + + hr.attendance.report.config.search + hr.attendance.report.config + + + + + + + + + + + + + + + + + + + إعدادات تقارير الحضور + hr.attendance.report.config + tree,form + {'search_default_active': 1} + +

+ إنشئ أول إعداد لتقرير الحضور! +

+

+ اضبط كيفية إنشاء تقارير الحضور، بما في ذلك رموز الحالة والألوان وإعدادات الأعمدة. +

+
+
+ +
+
\ No newline at end of file diff --git a/odex25_hr/hr_attendance_excel_report/views/attendance_status_config_views.xml b/odex25_hr/hr_attendance_excel_report/views/attendance_status_config_views.xml new file mode 100644 index 000000000..564184aea --- /dev/null +++ b/odex25_hr/hr_attendance_excel_report/views/attendance_status_config_views.xml @@ -0,0 +1,199 @@ + + + + + + + hr.attendance.status.config.tree + hr.attendance.status.config + + + + + + + + + + + + + + + + + + hr.attendance.status.config.form + hr.attendance.status.config + +
+ +
+ + +
+ + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+

Available variables:

+
    +
  • transaction: hr.attendance.transaction record
  • +
  • employee: hr.employee record
  • +
  • date: date of the transaction
  • +
+

Examples:

+
    +
  • transaction.lateness > 0.5 - Late more than 30 minutes
  • +
  • transaction.early_exit > 0 - Early exit
  • +
  • transaction.is_absent and not transaction.leave_id - Absent without leave
  • +
+
+
+ + +
+

Selected Condition Type:

+
    +
  • Absent: Matches when employee is marked as absent
  • +
  • Leave: Matches when employee has any leave request
  • +
  • Official Duty: Matches when employee has official mission
  • +
  • Public Holiday: Matches when day is marked as public holiday
  • +
  • Normal Attendance: Matches when employee has normal sign in/out
  • +
  • Personal Permission: Matches when employee has personal permission
  • +
  • Late/Early Exit: Matches when employee is late or exits early
  • +
+
+
+
+
+ + +
+
+

Excel Cell Preview

+
+ +
+
+
+

Status Information

+ + + + + + + + + + + + + +
Arabic Name:
English Name:
Condition Type:
+
+
+
+ + + + +
+
+
+
+
+ + + + hr.attendance.status.config.search + hr.attendance.status.config + + + + + + + + + + + + + + + + + + + + + + + + + Attendance Status Configurations + hr.attendance.status.config + tree,form + {'search_default_active': 1} + +

+ Create your first attendance status configuration! +

+

+ Configure status codes that will appear in your attendance reports, + including their colors and matching conditions. +

+
+
+ +
+
diff --git a/odex25_hr/hr_attendance_excel_report/views/menu_views.xml b/odex25_hr/hr_attendance_excel_report/views/menu_views.xml new file mode 100644 index 000000000..2d169b5b3 --- /dev/null +++ b/odex25_hr/hr_attendance_excel_report/views/menu_views.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/odex25_hr/hr_attendance_excel_report/wizard/__init__.py b/odex25_hr/hr_attendance_excel_report/wizard/__init__.py new file mode 100644 index 000000000..fa3e8b995 --- /dev/null +++ b/odex25_hr/hr_attendance_excel_report/wizard/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import attendance_report_wizard diff --git a/odex25_hr/hr_attendance_excel_report/wizard/attendance_report_wizard.py b/odex25_hr/hr_attendance_excel_report/wizard/attendance_report_wizard.py new file mode 100644 index 000000000..482b5e93f --- /dev/null +++ b/odex25_hr/hr_attendance_excel_report/wizard/attendance_report_wizard.py @@ -0,0 +1,1069 @@ +# -*- coding: utf-8 -*- + +import base64 +import io +import logging +from datetime import datetime, timedelta +from dateutil.relativedelta import relativedelta + +from odoo import api, fields, models, _ +from odoo.exceptions import ValidationError, UserError + +try: + import xlsxwriter +except ImportError: + xlsxwriter = None + +_logger = logging.getLogger(__name__) + + +class AttendanceReportWizard(models.TransientModel): + _name = 'hr.attendance.report.wizard' + _description = 'Attendance Report Wizard' + + # Core fields + company_id = fields.Many2one('res.company', string='Company', default=lambda self: self.env.company, required=True) + config_id = fields.Many2one('hr.attendance.report.config', string='Report Configuration', required=True, domain="[('company_id', '=', company_id)]") + + date_from = fields.Date(string='From Date', required=True, default=lambda self: fields.Date.today().replace(day=1)) + date_to = fields.Date(string='To Date', required=True, default=lambda self: (fields.Date.today().replace(day=1) + relativedelta(months=1) - timedelta(days=1))) + + shift_type = fields.Selection([ + ('shift_one', 'الشيفت الأول (الصباحي)'), + ('shift_two', 'الشيفت الثاني (المسائي)'), + ], string='نوع الشيفت', required=True) + + filter_type = fields.Selection([ + ('all', 'جميع الموظفين'), + ('employees', 'موظفين محددين'), + ('departments', 'أقسام محددة'), + ('branches', 'فروع محددة'), + ], string='نوع التصفية', default='all', required=True) + + employee_ids = fields.Many2many('hr.employee', string='الموظفين') + department_ids = fields.Many2many('hr.department', string='الأقسام') + branch_ids = fields.Many2many('hr.department', 'wizard_branch_rel', string='الفروع', domain="[('is_branch', '=', True)]") + + + # Statistics fields + available_employees_count = fields.Integer(string='Available Employees Count', compute='_compute_smart_statistics', store=False) + available_departments_count = fields.Integer(string='Available Departments Count', compute='_compute_smart_statistics', store=False) + available_branches_count = fields.Integer(string='Available Branches Count', compute='_compute_smart_statistics', store=False) + valid_transactions_count = fields.Integer(string='Valid Transactions Count', compute='_compute_smart_statistics', store=False) + total_transactions_count = fields.Integer(string='Total Transactions Count', compute='_compute_smart_statistics', store=False) + + + filter_warning_message = fields.Html(string='Filter Warning Message', compute='_compute_filter_warning_message') + + # Compute methods + @api.depends('shift_type', 'date_from', 'date_to', 'company_id', 'filter_type', 'employee_ids', 'department_ids', 'branch_ids') + def _compute_smart_statistics(self): + for record in self: + record.available_employees_count = 0 + record.available_departments_count = 0 + record.available_branches_count = 0 + record.valid_transactions_count = 0 + record.total_transactions_count = 0 + + if not record.shift_type or not record.date_from or not record.date_to or not record.company_id: + continue + + try: + employees = record._get_available_employees_for_shift() + record.available_employees_count = len(employees) + + if employees: + departments = employees.mapped('department_id').filtered(lambda d: d) + record.available_departments_count = len(departments) + record.available_branches_count = record._count_available_branches(departments) + + transaction_counts = record._get_transaction_counts(employees) + record.total_transactions_count = transaction_counts['total'] + record.valid_transactions_count = transaction_counts['valid'] + + except Exception as e: + _logger.warning(f"Error computing smart statistics: {str(e)}") + + + @api.depends('shift_type', 'filter_type', 'available_employees_count', 'employee_ids', 'department_ids', 'branch_ids') + def _compute_filter_warning_message(self): + for record in self: + if not record.shift_type: + record.filter_warning_message = '
Please select shift type first
' + continue + + try: + messages = [] + shift_name = dict(record._fields['shift_type'].selection).get(record.shift_type, 'Unknown') + messages.append(f'Selected Shift: {shift_name}') + + filter_msg = record._get_filter_status_message() + if filter_msg: + messages.append(filter_msg) + + if messages: + record.filter_warning_message = f'
{" | ".join(messages)}
' + else: + record.filter_warning_message = '' + + except Exception: + record.filter_warning_message = '
Error computing report status
' + + # Helper methods + def _get_available_employees_for_shift(self): + if not self.shift_type or not self.date_from or not self.date_to: + return self.env['hr.employee'] + + domain = [('active', '=', True), ('company_id', '=', self.company_id.id)] + all_employees = self.env['hr.employee'].search(domain) + return self._filter_employees_by_shift_optimized(all_employees) + + def _filter_employees_by_shift_optimized(self, employees): + if not employees: + return employees + + transactions = self.env['hr.attendance.transaction'].search([ + ('employee_id', 'in', employees.ids), + ('date', '>=', self.date_from), + ('date', '<=', self.date_to), + ('calendar_id', '!=', False) + ]) + + employee_transactions = {} + for transaction in transactions: + emp_id = transaction.employee_id.id + if emp_id not in employee_transactions: + employee_transactions[emp_id] = [] + employee_transactions[emp_id].append(transaction) + + valid_employee_ids = [] + for employee in employees: + emp_transactions = employee_transactions.get(employee.id, []) + if emp_transactions and self._employee_has_valid_shift(emp_transactions): + valid_employee_ids.append(employee.id) + + return employees.filtered(lambda e: e.id in valid_employee_ids) + + def _employee_has_valid_shift(self, transactions): + for transaction in transactions: + if transaction.calendar_id and self._calendar_has_shift_type(transaction.calendar_id): + return True + return False + + def _count_available_branches(self, departments): + if not departments: + return 0 + + try: + if departments and hasattr(departments[0], 'is_branch'): + return len(departments.filtered(lambda d: getattr(d, 'is_branch', False))) + else: + return len(departments.filtered(lambda d: 'فرع' in (d.name or ''))) + except Exception: + return 0 + + def _get_transaction_counts(self, employees): + if not employees: + return {'total': 0, 'valid': 0} + + all_transactions = self.env['hr.attendance.transaction'].search([ + ('employee_id', 'in', employees.ids), + ('date', '>=', self.date_from), + ('date', '<=', self.date_to) + ]) + + total_count = len(all_transactions) + valid_count = 0 + for transaction in all_transactions: + if self._is_transaction_valid_for_shift(transaction): + valid_count += 1 + + return {'total': total_count, 'valid': valid_count} + + def _get_filter_status_message(self): + if self.filter_type == 'all': + if self.available_employees_count > 0: + return f'All available employees ({self.available_employees_count} employees)' + else: + return '⚠️ No employees available for selected shift' + elif self.filter_type == 'employees': + if len(self.employee_ids) > 0: + return f'Selected employees ({len(self.employee_ids)})' + else: + return '⚠️ No employees selected' + elif self.filter_type == 'departments': + if len(self.department_ids) > 0: + return f'Selected departments ({len(self.department_ids)})' + else: + return '⚠️ No departments selected' + elif self.filter_type == 'branches': + if len(self.branch_ids) > 0: + return f'Selected branches ({len(self.branch_ids)})' + else: + return '⚠️ No branches selected' + return '' + + # Onchange methods + @api.model + def default_get(self, fields_list): + result = super().default_get(fields_list) + if 'config_id' in fields_list and not result.get('config_id'): + company_id = result.get('company_id') or self.env.company.id + config = self.env['hr.attendance.report.config'].search([('company_id', '=', company_id)], limit=1) + if config: + result['config_id'] = config.id + return result + + @api.onchange('company_id') + def _onchange_company_id(self): + if self.company_id: + config = self.env['hr.attendance.report.config'].search([('company_id', '=', self.company_id.id)], limit=1) + self.config_id = config.id if config else False + + @api.onchange('date_from') + def _onchange_date_from(self): + if self.date_from: + next_month = self.date_from.replace(day=1) + relativedelta(months=1) + self.date_to = next_month - timedelta(days=1) + + @api.onchange('filter_type') + def _onchange_filter_type(self): + filter_fields = { + 'employees': ['department_ids', 'branch_ids'], + 'departments': ['employee_ids', 'branch_ids'], + 'branches': ['employee_ids', 'department_ids'], + 'all': ['employee_ids', 'department_ids', 'branch_ids'] + } + + fields_to_clear = filter_fields.get(self.filter_type, []) + for field_name in fields_to_clear: + if field_name != f"{self.filter_type.rstrip('s')}_ids": + setattr(self, field_name, [(6, 0, [])]) + + # Core functions + def _get_filtered_employees(self): + domain = [('active', '=', True), ('company_id', '=', self.company_id.id)] + + if self.filter_type == 'employees' and self.employee_ids: + domain.append(('id', 'in', self.employee_ids.ids)) + elif self.filter_type == 'departments' and self.department_ids: + domain.append(('department_id', 'in', self.department_ids.ids)) + elif self.filter_type == 'branches' and self.branch_ids: + branch_employee_ids = [] + for branch in self.branch_ids: + departments_in_branch = self.env['hr.department'].search([ + '|', ('id', '=', branch.id), ('parent_id', 'child_of', branch.id) + ]) + branch_employees = self.env['hr.employee'].search([ + ('department_id', 'in', departments_in_branch.ids), ('active', '=', True) + ]) + branch_employee_ids.extend(branch_employees.ids) + + if branch_employee_ids: + domain.append(('id', 'in', list(set(branch_employee_ids)))) + else: + domain.append(('id', '=', False)) + + employees = self.env['hr.employee'].search(domain, order='name') + return self._filter_employees_by_shift(employees) + + def _filter_employees_by_shift(self, employees): + valid_employee_ids = [] + transactions = self.env['hr.attendance.transaction'].search([ + ('employee_id', 'in', employees.ids), + ('date', '>=', self.date_from), + ('date', '<=', self.date_to), + ('calendar_id', '!=', False) + ]) + + for employee in employees: + emp_transactions = transactions.filtered(lambda t: t.employee_id.id == employee.id) + if emp_transactions: + for transaction in emp_transactions: + if self._calendar_has_shift_type(transaction.calendar_id): + valid_employee_ids.append(employee.id) + break + + return employees.filtered(lambda e: e.id in valid_employee_ids) + + def _calendar_has_shift_type(self, calendar): + if not calendar: + return False + + is_full_day = getattr(calendar, 'is_full_day', True) + + if self.shift_type == 'shift_one': + if is_full_day: + return True + else: + return ( + (getattr(calendar, 'shift_one_working_hours', 0) > 0) or + (getattr(calendar, 'shift_one_max_sign_in', 0) > 0 and + getattr(calendar, 'shift_one_min_sign_out', 0) > 0) + ) + elif self.shift_type == 'shift_two': + if is_full_day: + return False + else: + return ( + (getattr(calendar, 'shift_two_working_hours', 0) > 0) or + (getattr(calendar, 'shift_two_max_sign_in', 0) > 0 and + getattr(calendar, 'shift_two_min_sign_out', 0) > 0) + ) + return False + + def _get_attendance_data(self): + employees = self._get_filtered_employees() + if not employees: + shift_name = dict(self._fields['shift_type'].selection)[self.shift_type] + raise UserError(_('No employees with %s in the selected period') % shift_name) + + transactions = self.env['hr.attendance.transaction'].search([ + ('date', '>=', self.date_from), + ('date', '<=', self.date_to), + ('employee_id', 'in', employees.ids) + ], order='employee_id, date, sequence') + + employee_data = {} + for employee in employees: + emp_transactions = transactions.filtered(lambda t: t.employee_id.id == employee.id) + valid_transactions = emp_transactions.filtered(lambda t: self._is_transaction_valid_for_shift(t)) + + employee_data[employee.id] = { + 'employee': employee, + 'days': self._calculate_daily_data(valid_transactions), + 'summary': self._calculate_enhanced_employee_summary(employee, valid_transactions), + 'official_hours': self._get_employee_working_hours(employee) + } + + return employee_data + + def _is_transaction_valid_for_shift(self, transaction): + if not transaction.calendar_id: + return False + if not self._calendar_has_shift_type(transaction.calendar_id): + return False + if self.shift_type == 'shift_one': + return transaction.sequence == 1 + elif self.shift_type == 'shift_two': + return transaction.sequence == 2 + return False + + def _get_primary_transaction_for_day(self, day_transactions): + if len(day_transactions) == 1: + return day_transactions[0] + + target_seq = 1 if self.shift_type == 'shift_one' else 2 + for transaction in day_transactions: + if transaction.sequence == target_seq: + return transaction + return day_transactions[0] + + def _calculate_daily_data(self, transactions): + daily_data = {} + current_date = self.date_from + + while current_date <= self.date_to: + day_transactions = transactions.filtered(lambda t: t.date == current_date) + + if day_transactions: + transaction = self._get_primary_transaction_for_day(day_transactions) + status_info = self._determine_status_from_transaction(transaction) + daily_data[current_date.day] = status_info + else: + daily_data[current_date.day] = { + 'display_value': '', 'bg_color': '#FFFFFF', 'text_color': '#000000' + } + + current_date += timedelta(days=1) + return daily_data + + def _determine_status_from_transaction(self, transaction): + """تحديد حالة اليوم من المعاملة - مُصحح لحل مشكلة البصمات""" + if getattr(transaction, 'public_holiday', False): + return self._get_status_display('holiday', transaction) + if getattr(transaction, 'leave_id', False): + status_info = self._get_dynamic_status('leave', transaction) + if status_info: + return status_info + return self._get_status_display('leave', transaction) + if (getattr(transaction, 'official_id', False) or getattr(transaction, 'is_official', False)): + status_info = self._get_dynamic_status('official', transaction) + if status_info: + return status_info + return self._get_status_display('official', transaction) + if getattr(transaction, 'personal_permission_id', False): + status_info = self._get_dynamic_status('permission', transaction) + if status_info: + return status_info + return self._get_status_display('permission', transaction) + + # التحقق الصحيح من البصمات - مُصحح + sign_in = float(getattr(transaction, 'sign_in', 0) or 0) + sign_out = float(getattr(transaction, 'sign_out', 0) or 0) + + # إذا كان هناك بصمة دخول أو خروج = حضور + if sign_in > 0 or sign_out > 0: + return self._get_status_display('attendance', transaction) + + # التحقق من الغياب + if getattr(transaction, 'is_absent', False): + return self._get_status_display('absent', transaction) + + # حالة افتراضية (فارغ) + return {'display_value': '', 'bg_color': '#FFFFFF', 'text_color': '#000000'} + + def _get_dynamic_status(self, condition_type, transaction): + matching_statuses = self.config_id.status_line_ids.filtered( + lambda s: s.active and s.condition_type == condition_type + ).sorted('sequence') + + if not matching_statuses: + return None + + specific_statuses = [] + general_statuses = [] + + for status in matching_statuses: + if status.check_condition(transaction, self.shift_type, self): + if condition_type == 'leave' and status.holiday_status_ids: + specific_statuses.append(status) + elif condition_type == 'permission' and status.permission_type_ids: + specific_statuses.append(status) + elif condition_type == 'official' and status.mission_type_ids: + specific_statuses.append(status) + else: + general_statuses.append(status) + + selected_status = None + if specific_statuses: + selected_status = specific_statuses[0] + elif general_statuses: + selected_status = general_statuses[0] + + if selected_status: + return { + 'display_value': selected_status.get_display_value(transaction), + 'bg_color': selected_status.bg_color or '#FFFFFF', + 'text_color': selected_status.text_color or '#000000', + 'status_code': selected_status.code + } + return None + + def _get_status_display(self, condition_type, transaction): + status_config = self.config_id.status_line_ids.filtered( + lambda s: s.condition_type == condition_type and s.active + )[:1] + + if status_config: + return { + 'display_value': status_config.get_display_value(transaction), + 'bg_color': status_config.bg_color or '#FFFFFF', + 'text_color': status_config.text_color or '#000000', + 'status_code': status_config.code + } + + fallback_codes = { + 'holiday': 'PH', 'absent': 'AB', 'leave': 'AL', + 'official': 'WM', 'permission': 'PP', 'attendance': 'ATT' + } + + return { + 'display_value': fallback_codes.get(condition_type, ''), + 'bg_color': '#FFFFFF', 'text_color': '#000000', + 'status_code': fallback_codes.get(condition_type, '') + } + + def _calculate_enhanced_employee_summary(self, employee, transactions): + """حساب الإجماليات المحسنة للموظف - مُصحح بالكامل 100%""" + if not transactions: + return [0] * 11 + + # تصفية المعاملات حتى تاريخ اليوم فقط (لا نحسب الأيام المستقبلية) + today = fields.Date.context_today(self) + effective_date_to = min(self.date_to, today) + valid_transactions = transactions.filtered(lambda t: t.date <= effective_date_to) + + if not valid_transactions: + return [0] * 11 + + # فلترة المعاملات حسب التسلسل المطلوب (شيفت محدد) + processed_transactions = [] + transactions_by_date = {} + + for transaction in valid_transactions: + date = transaction.date + if date not in transactions_by_date: + transactions_by_date[date] = [] + transactions_by_date[date].append(transaction) + + # اختيار المعاملة الأساسية لكل يوم + for date, day_transactions in transactions_by_date.items(): + primary_transaction = self._get_primary_transaction_for_day(day_transactions) + processed_transactions.append(primary_transaction) + + # حساب الإجماليات بدقة 100% - مُصحح + summary_counters = { + 'attendance_days': 0, + 'total_leave_days': 0, + 'absent_days': 0, + 'public_holiday_days': 0, + 'permission_count': 0, + 'total_permission_hours': 0.0, + 'total_office_hours': 0.0, + 'total_additional_hours': 0.0, + 'total_lateness': 0.0, + 'total_early_exit': 0.0 + } + + for transaction in processed_transactions: + # تصنيف نوع اليوم بالأولوية الصحيحة - مُصحح + if getattr(transaction, 'public_holiday', False): + summary_counters['public_holiday_days'] += 1 + elif getattr(transaction, 'leave_id', False): + summary_counters['total_leave_days'] += 1 + else: + # التحقق الصحيح من وجود بصمة حضور أو انصراف - مُصحح + sign_in = float(getattr(transaction, 'sign_in', 0) or 0) + sign_out = float(getattr(transaction, 'sign_out', 0) or 0) + + # إذا كان هناك بصمة دخول أو خروج (أكبر من صفر) = يوم حضور + if sign_in > 0 or sign_out > 0: + summary_counters['attendance_days'] += 1 + elif getattr(transaction, 'is_absent', False): + # فقط الأيام التي لا يوجد بها بصمة نهائياً وهي غياب حقيقي + summary_counters['absent_days'] += 1 + + # عدد الأذونات (في حالة وجود إذن شخصي) + if getattr(transaction, 'personal_permission_id', False): + summary_counters['permission_count'] += 1 + + # جمع الساعات مباشرة من الحقول المحسوبة مسبقاً + summary_counters['total_permission_hours'] += float(getattr(transaction, 'total_permission_hours', 0) or 0) + summary_counters['total_office_hours'] += float(getattr(transaction, 'office_hours', 0) or 0) + summary_counters['total_additional_hours'] += float(getattr(transaction, 'additional_hours', 0) or 0) + summary_counters['total_lateness'] += float(getattr(transaction, 'lateness', 0) or 0) + summary_counters['total_early_exit'] += float(getattr(transaction, 'early_exit', 0) or 0) + + # حساب معدل الحضور المُصحح: النسبة من إجمالي أيام الفترة المختارة + total_report_days = (self.date_to - self.date_from).days + 1 + + # حساب النسبة المئوية الصحيحة من إجمالي أيام التقرير + if total_report_days > 0: + attendance_percentage = (summary_counters['attendance_days'] / total_report_days) * 100 + else: + attendance_percentage = 0.0 + + # تحويل الساعات إلى تنسيق الساعات:الدقائق للعرض + formatted_permission_hours = self._format_hours_and_minutes(summary_counters['total_permission_hours']) + formatted_office_hours = self._format_hours_and_minutes(summary_counters['total_office_hours']) + formatted_additional_hours = self._format_hours_and_minutes(summary_counters['total_additional_hours']) + formatted_lateness = self._format_hours_and_minutes(summary_counters['total_lateness']) + formatted_early_exit = self._format_hours_and_minutes(summary_counters['total_early_exit']) + + return [ + summary_counters['attendance_days'], # أيام الحضور + summary_counters['total_leave_days'], # إجمالي أيام الإجازات + summary_counters['absent_days'], # أيام الغياب + attendance_percentage, # معدل الحضور % - من إجمالي أيام الفترة + summary_counters['permission_count'], # عدد الأذونات + formatted_permission_hours, # ساعات الأذونات - بتنسيق ساعات:دقائق + summary_counters['public_holiday_days'], # أيام العطل الرسمية + formatted_office_hours, # ساعات العمل الفعلية - بتنسيق ساعات:دقائق + formatted_additional_hours, # الساعات الإضافية - بتنسيق ساعات:دقائق + formatted_lateness, # إجمالي التأخير - بتنسيق ساعات:دقائق + formatted_early_exit, # إجمالي الخروج المبكر - بتنسيق ساعات:دقائق + ] + + def _analyze_day_transaction_simplified(self, transaction): + day_summary = { + 'has_public_holiday': False, 'has_leave': False, 'has_attendance': False, + 'has_absence': False, 'permission_count': 0, 'permission_hours': 0.0, + 'office_hours': 0.0, 'additional_hours': 0.0, 'lateness': 0.0, 'early_exit': 0.0 + } + + try: + if getattr(transaction, 'public_holiday', False): + day_summary['has_public_holiday'] = True + elif getattr(transaction, 'leave_id', False): + day_summary['has_leave'] = True + + sign_in = getattr(transaction, 'sign_in', 0) or 0 + sign_out = getattr(transaction, 'sign_out', 0) or 0 + if sign_in > 0 and sign_out > 0: + day_summary['has_attendance'] = True + elif getattr(transaction, 'is_absent', False): + day_summary['has_absence'] = True + + if getattr(transaction, 'personal_permission_id', False): + day_summary['permission_count'] += 1 + permission_hours = getattr(transaction, 'total_permission_hours', 0) or 0 + day_summary['permission_hours'] += float(permission_hours) + + day_summary['office_hours'] += float(getattr(transaction, 'office_hours', 0) or 0) + day_summary['additional_hours'] += float(getattr(transaction, 'additional_hours', 0) or 0) + day_summary['lateness'] += float(getattr(transaction, 'lateness', 0) or 0) + day_summary['early_exit'] += float(getattr(transaction, 'early_exit', 0) or 0) + + except Exception as e: + _logger.warning(f"Error analyzing transaction {transaction.id}: {str(e)}") + + return day_summary + + def _get_employee_working_hours(self, employee): + last_transaction = self.env['hr.attendance.transaction'].search([ + ('employee_id', '=', employee.id), ('calendar_id', '!=', False) + ], limit=1, order='date desc') + + if not last_transaction or not last_transaction.calendar_id: + return "" + + calendar = last_transaction.calendar_id + is_full_day = getattr(calendar, 'is_full_day', True) + + if self.shift_type == 'shift_one': + if is_full_day: + start = getattr(calendar, 'full_max_sign_in', 0) + end = getattr(calendar, 'full_min_sign_out', 0) + else: + start = getattr(calendar, 'shift_one_max_sign_in', 0) + end = getattr(calendar, 'shift_one_min_sign_out', 0) + else: + if not is_full_day: + start = getattr(calendar, 'shift_two_max_sign_in', 0) + end = getattr(calendar, 'shift_two_min_sign_out', 0) + else: + return "" + + if start > 0 and end > 0: + return self._format_time_range(start, end) + return "" + + def _format_time_range(self, start_float, end_float): + start_h, start_m = int(start_float), int((start_float % 1) * 60) + end_h, end_m = int(end_float), int((end_float % 1) * 60) + return f"{start_h:02d}:{start_m:02d} - {end_h:02d}:{end_m:02d}" + + def _format_hours_and_minutes(self, decimal_hours): + """تحويل الساعات العشرية إلى تنسيق الساعات:الدقائق (مثال: 5.5 => 5:30)""" + if not decimal_hours or decimal_hours == 0: + return "0:00" + + total_minutes = int(decimal_hours * 60) + hours = total_minutes // 60 + minutes = total_minutes % 60 + return f"{hours}:{minutes:02d}" + + def _get_employee_number(self, employee): + transaction = self.env['hr.attendance.transaction'].search([('employee_id', '=', employee.id)], limit=1) + + if transaction and getattr(transaction, 'employee_number', None): + return str(transaction.employee_number) + + for field_name in ['emp_no', 'barcode', 'identification_id']: + try: + if hasattr(employee, field_name): + field_value = getattr(employee, field_name, None) + if field_value: + return str(field_value) + except Exception: + continue + return '' + + def _get_national_id(self, employee): + identity_number = getattr(employee, 'identity_number', None) + if identity_number: + id_str = str(identity_number).strip() + if id_str and id_str != '0': + return id_str + + for field_name in ['identification_id', 'ssnid', 'passport_id', 'pin']: + try: + if hasattr(employee, field_name): + field_value = getattr(employee, field_name, None) + if field_value: + value_str = str(field_value).strip() + if value_str and value_str != '0': + return value_str + except Exception: + continue + return '' + + # Excel generation + def action_generate_excel_report(self): + if not xlsxwriter: + raise UserError(_('xlsxwriter library is not installed')) + + employee_data = self._get_attendance_data() + + output = io.BytesIO() + workbook = xlsxwriter.Workbook(output, {'in_memory': True}) + worksheet = workbook.add_worksheet(_('Attendance Report')) + + self._setup_worksheet(worksheet, workbook) + row = self._write_report_header(worksheet, workbook) + row = self._write_column_headers(worksheet, workbook, row) + row = self._write_employee_data(worksheet, workbook, employee_data, row) + + if self.config_id.include_legend: + self._write_legend(worksheet, workbook, row + 2) + + workbook.close() + output.seek(0) + + shift_suffix = dict(self._fields['shift_type'].selection)[self.shift_type] + report_name = f"Attendance_Report_{self.date_from.strftime('%Y_%m')}_{shift_suffix}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" + + attachment = self.env['ir.attachment'].create({ + 'name': report_name, + 'type': 'binary', + 'datas': base64.b64encode(output.read()), + 'store_fname': report_name, + 'mimetype': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + }) + + return { + 'type': 'ir.actions.act_url', + 'url': f'/web/content/{attachment.id}?download=true', + 'target': 'self', + } + + def _setup_worksheet(self, worksheet, workbook): + worksheet.right_to_left() + + col_index = 0 + + if self.config_id.include_employee_number: + worksheet.set_column(col_index, col_index, 15) + col_index += 1 + worksheet.set_column(col_index, col_index, 25) + col_index += 1 + + if self.config_id.include_national_id: + worksheet.set_column(col_index, col_index, 15) + col_index += 1 + if self.config_id.include_department: + worksheet.set_column(col_index, col_index, 20) + col_index += 1 + if self.config_id.include_job_position: + worksheet.set_column(col_index, col_index, 20) + col_index += 1 + if self.config_id.include_official_hours: + worksheet.set_column(col_index, col_index, 18) + col_index += 1 + + num_days = (self.date_to - self.date_from).days + 1 + for i in range(num_days): + worksheet.set_column(col_index + i, col_index + i, 14) + col_index += num_days + + if self.config_id.include_summary_columns: + for i in range(11): + worksheet.set_column(col_index + i, col_index + i, 12) + + freeze_col = self._get_freeze_columns_count() + worksheet.freeze_panes(6, freeze_col) + + def _get_freeze_columns_count(self): + count = 0 + if self.config_id.include_employee_number: + count += 1 + count += 1 + if self.config_id.include_national_id: + count += 1 + if self.config_id.include_department: + count += 1 + if self.config_id.include_job_position: + count += 1 + if self.config_id.include_official_hours: + count += 1 + return count + + def _write_report_header(self, worksheet, workbook): + title_format = workbook.add_format({ + 'bold': True, 'font_size': 14, 'align': 'center', 'valign': 'vcenter', + 'bg_color': '#1f497d', 'font_color': 'white', 'border': 1, 'reading_order': 2 + }) + info_format = workbook.add_format({ + 'bold': True, 'font_size': 10, 'align': 'center', 'valign': 'vcenter', + 'bg_color': '#D7E4BD', 'border': 1, 'reading_order': 2 + }) + + basic_cols = self._get_freeze_columns_count() + shift_text = dict(self._fields['shift_type'].selection)[self.shift_type] + + worksheet.merge_range(0, 0, 0, basic_cols - 1, _("Attendance Report - %s") % shift_text, title_format) + company_info = _("Company: %s | Shift Type: %s") % (self.company_id.name, shift_text) + worksheet.merge_range(1, 0, 1, basic_cols - 1, company_info, info_format) + period_text = _("Period: from %s to %s") % (self.date_from.strftime('%Y/%m/%d'), self.date_to.strftime('%Y/%m/%d')) + worksheet.merge_range(2, 0, 2, basic_cols - 1, period_text, info_format) + generation_time = datetime.now().strftime('%Y/%m/%d - %H:%M:%S') + worksheet.merge_range(3, 0, 3, basic_cols - 1, _("Generated: %s") % generation_time, info_format) + + for i in range(4): + worksheet.set_row(i, 20) + return 4 + + def _write_column_headers(self, worksheet, workbook, start_row): + header_format = workbook.add_format({ + 'bold': True, 'align': 'center', 'valign': 'vcenter', 'bg_color': '#366092', + 'font_color': 'white', 'border': 1, 'reading_order': 2, 'text_wrap': True + }) + day_header_format = workbook.add_format({ + 'bold': True, 'align': 'center', 'valign': 'vcenter', + 'bg_color': '#B7DEE8', 'border': 1, 'reading_order': 2 + }) + + col = 0 + fixed_headers = [] + + if self.config_id.include_employee_number: + fixed_headers.append(_('Employee Number')) + fixed_headers.append(_('Employee Name')) + if self.config_id.include_national_id: + fixed_headers.append(_('National ID')) + if self.config_id.include_department: + fixed_headers.append(_('Department')) + if self.config_id.include_job_position: + fixed_headers.append(_('Job Position')) + if self.config_id.include_official_hours: + fixed_headers.append(_('Official Working Hours')) + + for header in fixed_headers: + worksheet.write(start_row + 1, col, header, header_format) + col += 1 + + current_date = self.date_from + while current_date <= self.date_to: + worksheet.write(start_row + 1, col, current_date.day, day_header_format) + col += 1 + current_date += timedelta(days=1) + + if self.config_id.include_summary_columns: + summary_headers = [ + _('Attendance Days'), _('Total Leave Days'), _('Absent Days'), _('Attendance %'), + _('Permission Count'), _('Permission Hours'), _('Public Holiday Days'), + _('Actual Office Hours'), _('Additional Hours'), _('Total Lateness'), _('Total Early Exit') + ] + for header in summary_headers: + worksheet.write(start_row + 1, col, header, header_format) + col += 1 + + return start_row + 2 + + def _write_employee_data(self, worksheet, workbook, employee_data, start_row): + text_format = workbook.add_format({ + 'align': 'center', 'valign': 'vcenter', 'border': 1, 'reading_order': 2, 'font_size': 10 + }) + employee_name_format = workbook.add_format({ + 'align': 'right', 'valign': 'vcenter', 'border': 1, 'reading_order': 2, 'font_size': 10 + }) + number_format = workbook.add_format({ + 'align': 'center', 'valign': 'vcenter', 'border': 1, + 'num_format': '0.0', 'reading_order': 2, 'font_size': 10 + }) + percentage_format = workbook.add_format({ + 'align': 'center', 'valign': 'vcenter', 'border': 1, + 'num_format': '0.0%', 'reading_order': 2, 'font_size': 10 + }) + + current_row = start_row + + if self.config_id.group_by_department: + employees = sorted(employee_data.values(), + key=lambda x: (x['employee'].department_id.name or '', x['employee'].name)) + current_row = self._write_grouped_employees( + worksheet, workbook, employees, current_row, text_format, employee_name_format, number_format, percentage_format) + else: + employees = sorted(employee_data.values(), key=lambda x: x['employee'].name) + for emp_data in employees: + current_row = self._write_employee_row( + worksheet, workbook, emp_data, current_row, text_format, employee_name_format, number_format, percentage_format) + + return current_row + + def _write_grouped_employees(self, worksheet, workbook, employees, start_row, text_format, name_format, number_format, percentage_format): + current_row = start_row + current_dept = None + + for emp_data in employees: + employee = emp_data['employee'] + + if employee.department_id.name != current_dept: + if current_dept is not None: + current_row += 1 + + dept_format = workbook.add_format({ + 'bold': True, 'bg_color': '#F2F2F2', 'border': 1, 'align': 'center', 'reading_order': 2 + }) + + total_cols = self._get_total_columns_count() + dept_name = employee.department_id.name or _('No Department') + worksheet.merge_range(current_row, 0, current_row, total_cols - 1, dept_name, dept_format) + current_row += 1 + current_dept = employee.department_id.name + + current_row = self._write_employee_row( + worksheet, workbook, emp_data, current_row, text_format, name_format, number_format, percentage_format) + + return current_row + + def _write_employee_row(self, worksheet, workbook, emp_data, row, text_format, name_format, number_format, percentage_format): + employee = emp_data['employee'] + col = 0 + + if self.config_id.include_employee_number: + emp_number = self._get_employee_number(employee) + worksheet.write(row, col, emp_number, text_format) + col += 1 + + worksheet.write(row, col, employee.name, name_format) + col += 1 + + if self.config_id.include_national_id: + nat_id = self._get_national_id(employee) + worksheet.write(row, col, nat_id, text_format) + col += 1 + if self.config_id.include_department: + dept_name = employee.department_id.name if employee.department_id else '' + worksheet.write(row, col, dept_name, text_format) + col += 1 + if self.config_id.include_job_position: + job_name = employee.job_id.name if employee.job_id else '' + worksheet.write(row, col, job_name, text_format) + col += 1 + if self.config_id.include_official_hours: + worksheet.write(row, col, emp_data['official_hours'], text_format) + col += 1 + + current_date = self.date_from + while current_date <= self.date_to: + day_data = emp_data['days'].get(current_date.day, { + 'display_value': '', 'bg_color': '#FFFFFF', 'text_color': '#000000' + }) + + cell_format = workbook.add_format({ + 'align': 'center', 'valign': 'vcenter', 'border': 1, + 'bg_color': day_data['bg_color'], 'font_color': day_data['text_color'], + 'reading_order': 2, 'font_size': 9, 'text_wrap': False + }) + + worksheet.write(row, col, day_data['display_value'], cell_format) + col += 1 + current_date += timedelta(days=1) + + if self.config_id.include_summary_columns: + summary = emp_data['summary'] + + # تنسيق خاص للساعات (النص) + hours_text_format = workbook.add_format({ + 'align': 'center', 'valign': 'vcenter', 'border': 1, + 'reading_order': 2, 'font_size': 10 + }) + + for i, value in enumerate(summary): + if i == 3: # معدل الحضور % + worksheet.write(row, col + i, float(value)/100, percentage_format) + elif i in [0, 1, 2, 4, 6]: # الأعداد الصحيحة + worksheet.write(row, col + i, int(value), text_format) + elif i in [5, 7, 8, 9, 10]: # الساعات المنسقة كنص (ساعات:دقائق) + worksheet.write(row, col + i, str(value), hours_text_format) + + return row + 1 + + def _write_legend(self, worksheet, workbook, start_row): + legend_header_format = workbook.add_format({ + 'bold': True, 'align': 'right', 'bg_color': '#E7E6E6', + 'border': 1, 'font_size': 12, 'reading_order': 2 + }) + legend_format = workbook.add_format({ + 'align': 'right', 'border': 1, 'text_wrap': True, 'reading_order': 2, 'font_size': 10 + }) + + worksheet.write(start_row, 0, _('Status Legend:'), legend_header_format) + current_row = start_row + 1 + + status_configs = self.config_id.status_line_ids.filtered('active').sorted('sequence') + + for status in status_configs: + status_format = workbook.add_format({ + 'align': 'center', 'valign': 'vcenter', 'border': 1, + 'bg_color': status.bg_color, 'font_color': status.text_color, + 'bold': True, 'reading_order': 2, 'font_size': 10 + }) + + worksheet.write(current_row, 0, status.name_ar, legend_format) + worksheet.write(current_row, 1, status.code, status_format) + worksheet.write(current_row, 2, status.name_en, legend_format) + current_row += 1 + + return current_row + + def _get_total_columns_count(self): + count = self._get_freeze_columns_count() + count += (self.date_to - self.date_from).days + 1 + if self.config_id.include_summary_columns: + count += 11 + return count + + # Validation + @api.constrains('date_from', 'date_to') + def _check_dates(self): + for record in self: + if not record.date_from or not record.date_to: + continue + if record.date_from > record.date_to: + raise ValidationError(_('From date must be before to date')) + days_diff = (record.date_to - record.date_from).days + if days_diff > 366: + raise ValidationError(_('Report period cannot exceed one year')) + + @api.constrains('filter_type', 'employee_ids', 'department_ids', 'branch_ids') + def _check_filter_requirements(self): + filter_requirements = { + 'employees': ('employee_ids', 'Please select employees'), + 'departments': ('department_ids', 'Please select departments'), + 'branches': ('branch_ids', 'Please select branches') + } + + for record in self: + if record.filter_type in filter_requirements: + field_name, error_msg = filter_requirements[record.filter_type] + if not getattr(record, field_name): + raise ValidationError(_(error_msg)) + + # Preview functions + def action_preview_employees(self): + try: + employees = self._get_filtered_employees() + return { + 'name': _('Employee Preview - %s') % dict(self._fields["shift_type"].selection)[self.shift_type], + 'type': 'ir.actions.act_window', + 'res_model': 'hr.employee', + 'view_mode': 'tree,form', + 'domain': [('id', 'in', employees.ids)], + 'target': 'new', + 'context': {'default_company_id': self.company_id.id, 'search_default_group_by_department': True} + } + except Exception as e: + raise UserError(_('Preview error: %s') % str(e)) + + def action_show_transactions(self): + try: + employees = self._get_filtered_employees() + domain = [ + ('date', '>=', self.date_from), ('date', '<=', self.date_to), + ('employee_id', 'in', employees.ids), ('calendar_id', '!=', False) + ] + + return { + 'name': _('Attendance Transactions - %s') % dict(self._fields["shift_type"].selection)[self.shift_type], + 'type': 'ir.actions.act_window', + 'res_model': 'hr.attendance.transaction', + 'view_mode': 'tree,form', + 'domain': domain, + 'target': 'new', + 'context': {'search_default_group_by_employee': True, 'search_default_group_by_date': True} + } + except Exception as e: + raise UserError(_('Transaction display error: %s') % str(e)) \ No newline at end of file diff --git a/odex25_hr/hr_attendance_excel_report/wizard/attendance_report_wizard_views.xml b/odex25_hr/hr_attendance_excel_report/wizard/attendance_report_wizard_views.xml new file mode 100644 index 000000000..6c6d7c4da --- /dev/null +++ b/odex25_hr/hr_attendance_excel_report/wizard/attendance_report_wizard_views.xml @@ -0,0 +1,256 @@ + + + + + + + wizard.employee.tree + hr.employee + + + + + + + + + + + + + + + + wizard.department.tree + hr.department + + + + + + + + + + + + + + + wizard.branch.tree + hr.department + + + + + + + + + + + + + + + hr.attendance.report.wizard.form + hr.attendance.report.wizard + +
+ +
+

+ + إنشاء تقرير الحضور Excel +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
الإحصائيات الذكية للشيفت المحدد
+
+
+
+ + + موظف + + متوفر للشيفت (انقر للمعاينة) +
+
+
+
+ + + قسم + + يضم موظفين بالشيفت +
+
+
+
+ + + فرع + + يضم موظفين بالشيفت +
+
+
+
+ + + / + + سجلات صالحة/إجمالي (انقر للعرض) +
+
+
+
+
+
+ + +
+
+ حالة التقرير:
+ +
+
+ + + + + + + +
+ + + + + + + + + + + + + +
+ +
+ + + + + + + + + + + + +
+ +
+ + + + + + + + + + + + +
+ +
+ + +
+ +
+
+
+ +
+
+ + + + إنشاء تقرير الحضور + hr.attendance.report.wizard + form + new + {} + + + + + تقرير الحضور Excel + hr.attendance.report.wizard + form + new + {} + + +
+
From bc13bd2335005eeea590cdf1edc531fdbbbb704d Mon Sep 17 00:00:00 2001 From: younes Date: Wed, 3 Sep 2025 14:52:51 +0100 Subject: [PATCH 2/2] ADD hr_attendance_excel_report module --- .github/workflows/block_reserved_branches.yml | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/workflows/block_reserved_branches.yml b/.github/workflows/block_reserved_branches.yml index 308ba4805..25eef0b2f 100644 --- a/.github/workflows/block_reserved_branches.yml +++ b/.github/workflows/block_reserved_branches.yml @@ -82,18 +82,25 @@ jobs: preprod_odex-event preprod_openeducat_erp-14.0.1.0 ) + # Check if branch is an exact reserved name for reserved in "${RESERVED_NAMES[@]}"; do if [[ "$BRANCH_NAME" == "$reserved" ]]; then echo "❌ Branch name '$BRANCH_NAME' is reserved. Deleting..." - curl -s -X DELETE -H "Authorization: token $GH_TOKEN" https://api.github.com/repos/$REPO/git/refs/heads/$BRANCH_NAME + curl -s -X DELETE \ + -H "Authorization: token $GH_TOKEN" \ + https://api.github.com/repos/$REPO/git/refs/heads/$BRANCH_NAME exit 1 fi done + # Check if branch name matches restricted patterns if [[ "$BRANCH_NAME" == master_* || "$BRANCH_NAME" == preprod_* || "$BRANCH_NAME" == dev_* ]]; then echo "❌ Branch name '$BRANCH_NAME' matches restricted pattern. Deleting..." - curl -s -X DELETE -H "Authorization: token $GH_TOKEN" https://api.github.com/repos/$REPO/git/refs/heads/$BRANCH_NAME + curl -s -X DELETE \ + -H "Authorization: token $GH_TOKEN" \ + https://api.github.com/repos/$REPO/git/refs/heads/$BRANCH_NAME exit 1 fi - echo "✅ Branch '$BRANCH_NAME' is allowed." \ No newline at end of file + + echo "✅ Branch '$BRANCH_NAME' is allowed."